Breathe. Life can take unexpected turns. The things we call accomplishments or struggles can change overnight.
I’ve seen people become so trapped in their own thoughts and emotions that living with joy becomes nearly impossible. Through it all, I’ve learned to pat myself on the back for one thing: grit. I may not always be the smartest in the room, but I am often the last woman standing. Grit is the fuel of success. It’s what keeps me consistent, determined, and focused on contributing to something greater than myself.
That’s why I’ve poured my energy into biosciences — working at the intersection of technology, health, and human well-being.
Living in a country of tremendous opportunities only deepens my gratitude for being able to do this work.
And today, I want to connect that same grit with a disease that quite literally robs people of sight: Age-related Macular Degeneration (AMD).
What is AMD and Geographic Atrophy?
AMD is one of the leading causes of vision loss globally. In its advanced dry form, called Geographic Atrophy (GA), areas of retinal tissue progressively degenerate.
Recent drugs (like pegcetacoplan and avacincaptad pegol) can slow this progression, but none can restore lost vision. That’s why understanding the natural history of GA growth in untreated patients is so crucial — it helps us measure the true impact of treatments.
Researchers from Karolinska Institutet and St. Erik Eye Hospital (Sweden) followed 111 patients (204 eyes) over up to 10 years using fundus autofluorescence (FAF) imaging.
Key findings:
- Average GA growth: ~1.6 mm² per year (absolute) or 0.26 mm/year (square-root transformed).
Faster progression in:
- Fovea-sparing lesions (not yet involving the central fovea).
- Multifocal lesions (multiple atrophy spots).
- Bilateral cases (both eyes affected).
Growth is not purely linear:
- Quick expansion in the first year,
- Stable phase around year 2,
- Slower decline afterward.
timing matters. Catching GA early — especially before the fovea is involved — can preserve vision for longer.
Why It Matters
For researchers and clinicians:
- Provides a baseline dataset for GA progression.
- Helps design clinical trials and set better endpoints.
- Identifies lesion types that predict faster progression.
For patients:
- If GA is detected early, treatments may buy extra years of functional vision.
Try It Yourself: Simulating GA Growth in Python
small cohort-level growth simulator with:
- per-eye variability,
- subgroup effects (fovea-sparing, multifocal),
- time-varying growth (fast yr-1 → stable yr-2 → slow decline),
- optional fovea-sparing → involving conversion around ~24 months (≈30% of cases),
- summary stats + plots, and a CSV export.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Advanced GA (Geographic Atrophy) Growth Simulator
--------------------------------------------------
- Simulates a cohort of eyes with AMD/GA over time.
- Models square-root growth (sqrtGR) with subgroup effects & time-varying dynamics.
- Can simulate fovea-sparing -> fovea-involving conversion with median ~24 months.
Anchors (Muth et al., 2025, TVST):
- Mean absolute GR ~ 1.6 mm²/year
- Mean sqrtGR ~ 0.264 mm/year overall
- SqrtGR fovea-sparing ~ 0.342, involving ~ 0.234 mm/year
- ~36% fovea-sparing at baseline; ~21% multifocal; ~30% of fovea-sparing convert with median 24 months
"""
from __future__ import annotations
import math
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ----------------------
# Global Config
# ----------------------
SEED = 42
N_PATIENTS = 120
EYES_PER_PATIENT = [1, 2] # patients can contribute 1 or 2 eyes (simple random mix)
SIM_YEARS = 10.0
DT_MONTHS = 1 # integration step (months) for time-varying growth
VISIT_EVERY_MONTHS = 6 # observation cadence (e.g., FAF every 6 months)
SAVE_CSV = True
CSV_PATH = "simulated_ga_cohort.csv"
# Study-anchored parameters (means)
MU_SQRTGR_OVERALL = 0.264 # mm/year
MU_SQRTGR_FOVEA_SP = 0.342 # mm/year
MU_SQRTGR_FOVEA_INV = 0.234 # mm/year
BASELINE_AREA_MEAN = 9.1 # mm²
BASELINE_AREA_SD = 3.5 # mm² (some spread around 9.1)
# Subgroup prevalences (approximate, from cohort summaries)
P_FOVEA_SP = 0.36 # 36% fovea-sparing at baseline
P_MULTIFOCAL = 0.21 # 21% multifocal at baseline
# Effect sizes (small bump for multifocal; fovea status drives most variance)
MULTIFOCAL_DDELTA = 0.03 # +0.03 mm/year on sqrtGR if multifocal
PER_EYE_SQRTGR_SD = 0.07 # per-eye random variation around mean sqrtGR
# Fovea conversion (for baseline fovea-sparing eyes)
P_CONVERT = 0.30 # 30% convert during follow-up
CONVERT_MEDIAN_YRS = 2.0 # median ~24 months; we’ll sample lognormal around this
CONVERT_SIGMA = 0.5 # lognormal sigma; clipped range [0.42, 6] years
# Time-varying growth modulation:
# - Year 1: quick decline (20% drop across first year)
# - Year 2: stable
# - Years 3-10: slow decline (10% total across years 3..10)
YEAR1_DROP = 0.20
YEARS3TO10_DROP = 0.10
rng = np.random.default_rng(SEED)
def sample_conversion_time_years(size: int) -> np.ndarray:
"""
Sample conversion times from a lognormal with median ~2 yrs and mild spread,
clipped to [0.42, 6] years (≈5 to 70 months).
"""
mean_ln = math.log(CONVERT_MEDIAN_YRS) # log(median) = mean of lognormal
t = rng.lognormal(mean=mean_ln, sigma=CONVERT_SIGMA, size=size)
return np.clip(t, 0.42, 6.0)
def time_modulation(t_years: float) -> float:
"""
Piecewise multiplier capturing: fast decline (year 1), stable (year 2), slow decline (later).
Returns a factor in (0, 1].
"""
if t_years <= 1.0:
# Linear drop from 1.0 to (1 - YEAR1_DROP) across first year
return 1.0 - YEAR1_DROP * (t_years / 1.0)
elif t_years <= 2.0:
return 1.0 - YEAR1_DROP # flat second year
else:
# additional slow 10% decline spread across years 3..10
span = max(SIM_YEARS - 2.0, 1e-6)
frac = min(max((t_years - 2.0) / span, 0.0), 1.0)
return (1.0 - YEAR1_DROP) * (1.0 - YEARS3TO10_DROP * frac)
def simulate_eye(
eye_id: str,
baseline_area: float,
fovea_sparing0: bool,
multifocal: bool,
convert_time: float | None,
t_grid_years: np.ndarray,
) -> pd.DataFrame:
"""
Forward-simulate sqrt(area) with time-varying sqrtGR, optional fovea conversion,
then square to area. Integration via monthly steps.
"""
# Base sqrtGR driven primarily by baseline fovea status
mu = MU_SQRTGR_FOVEA_SP if fovea_sparing0 else MU_SQRTGR_FOVEA_INV
if multifocal:
mu += MULTIFOCAL_DDELTA
# Per-eye random effect
mu += rng.normal(0, PER_EYE_SQRTGR_SD)
# Prepare monthly integration
dt_yrs = DT_MONTHS / 12.0
T = int(round(SIM_YEARS * 12 / DT_MONTHS)) + 1
months = np.arange(T) * DT_MONTHS
tyears = months / 12.0
sqrtA = math.sqrt(max(baseline_area, 1e-6))
traj_sqrt = [sqrtA]
# Track evolving fovea status & instantaneous mean (drops if conversion occurs)
converted = False
mu_inv = MU_SQRTGR_FOVEA_INV + (MULTIFOCAL_DDELTA if multifocal else 0.0) # post-conversion mean
for i in range(1, T):
t = tyears[i]
# Check conversion event
if (not converted) and (convert_time is not None) and (t >= convert_time):
converted = True
# Choose current baseline mean given conversion status
mu_now = mu_inv if converted else mu
# Apply time modulation (yr1 decline, yr2 stable, slow later decline)
m = time_modulation(t)
# Euler update on sqrt(area): sqrtA(t+dt) = sqrtA(t) + (mu_now * m) * dt
sqrtA = max(0.0, sqrtA + (mu_now * m) * dt_yrs)
traj_sqrt.append(sqrtA)
traj_sqrt = np.array(traj_sqrt)
traj_area = traj_sqrt ** 2
# Observation grid (e.g., every 6 months) -> pick nearest monthly index
obs_months = np.arange(0, months[-1] + 1e-9, VISIT_EVERY_MONTHS)
obs_idx = np.searchsorted(months, obs_months)
obs_idx = np.clip(obs_idx, 0, len(months) - 1)
df = pd.DataFrame(
{
"eye_id": eye_id,
"month": months[obs_idx],
"time_years": tyears[obs_idx],
"area_mm2": traj_area[obs_idx],
"sqrt_area_mm": traj_sqrt[obs_idx],
"fovea_sparing0": fovea_sparing0,
"multifocal": multifocal,
"converted": converted,
"convert_time_years": convert_time if convert_time is not None else np.nan,
}
)
return df
def build_cohort() -> pd.DataFrame:
rows = []
eye_counter = 0
# Random mix of 1- and 2-eye participants
for pid in range(N_PATIENTS):
n_eyes = rng.choice(EYES_PER_PATIENT)
for e in range(n_eyes):
eye_counter += 1
eye_id = f"P{pid:04d}_E{e+1}"
# Baseline phenotype
fovea_sparing0 = rng.random() < P_FOVEA_SP
multifocal = rng.random() < P_MULTIFOCAL
baseline_area = max(
0.3,
rng.normal(BASELINE_AREA_MEAN, BASELINE_AREA_SD),
)
# Conversion logic (only if fovea-sparing at baseline)
convert_time = None
if fovea_sparing0 and (rng.random() < P_CONVERT):
convert_time = float(sample_conversion_time_years(1)[0])
# Simulate trajectory (we’ll pass t_grid but integration uses monthly steps internally)
t_grid = np.linspace(0, SIM_YEARS, int((SIM_YEARS * 12) / VISIT_EVERY_MONTHS) + 1)
df_eye = simulate_eye(
eye_id=eye_id,
baseline_area=baseline_area,
fovea_sparing0=fovea_sparing0,
multifocal=multifocal,
convert_time=convert_time,
t_grid_years=t_grid,
)
rows.append(df_eye)
cohort = pd.concat(rows, ignore_index=True)
return cohort
def summarize_endpoints(cohort: pd.DataFrame) -> pd.DataFrame:
"""
Compute per-eye GR (absolute and sqrt) using baseline vs last-observed follow-up.
"""
agg = (
cohort.sort_values(["eye_id", "time_years"])
.groupby("eye_id")
.agg(
t0=("time_years", "first"),
t1=("time_years", "last"),
a0=("area_mm2", "first"),
a1=("area_mm2", "last"),
s0=("sqrt_area_mm", "first"),
s1=("sqrt_area_mm", "last"),
fovea_sparing0=("fovea_sparing0", "first"),
multifocal=("multifocal", "first"),
converted=("converted", "first"),
)
.reset_index()
)
dt = np.maximum(agg["t1"] - agg["t0"], 1e-6)
agg["abs_GR_mm2_per_year"] = (agg["a1"] - agg["a0"]) / dt
agg["sqrt_GR_mm_per_year"] = (agg["s1"] - agg["s0"]) / dt
return agg
def main():
cohort = build_cohort()
summary = summarize_endpoints(cohort)
# Global summary
g_abs = summary["abs_GR_mm2_per_year"].mean()
g_sqrt = summary["sqrt_GR_mm_per_year"].mean()
n_eyes = summary.shape[0]
n_conv = summary["converted"].sum()
p_conv = n_conv / max((summary["fovea_sparing0"] == True).sum(), 1)
print("\n=== Cohort Summary ===")
print(f"Eyes simulated: {n_eyes}")
print(f"Mean Absolute GR (mm²/yr): {g_abs:.3f} (target ~1.60)")
print(f"Mean sqrtGR (mm/yr): {g_sqrt:.3f} (target ~0.264)")
print(f"Baseline fovea-sparing: {(summary['fovea_sparing0'].mean()*100):.1f}% (target ~36%)")
print(f"Multifocal at baseline: {(summary['multifocal'].mean()*100):.1f}% (target ~21%)")
print(f"Conversions among fovea-sparing: {p_conv*100:.1f}% (target ~30%)")
# Subgroup breakdown (sqrtGR)
for label, filt in [
("Fovea-sparing (BL)", summary["fovea_sparing0"] == True),
("Fovea-involving (BL)", summary["fovea_sparing0"] == False),
("Multifocal", summary["multifocal"] == True),
("Unifocal", summary["multifocal"] == False),
]:
if filt.any():
print(
f"{label:22s} sqrtGR mean={summary.loc[filt,'sqrt_GR_mm_per_year'].mean():.3f} "
f"± {summary.loc[filt,'sqrt_GR_mm_per_year'].std():.3f} (mm/yr)"
)
# Save CSV
if SAVE_CSV:
cohort.to_csv(CSV_PATH, index=False)
print(f"\nSaved per-visit cohort data to: {CSV_PATH}")
# ----------------------
# Plots (matplotlib only)
# ----------------------
# 1) Spaghetti plot: per-eye area trajectories
plt.figure(figsize=(9, 5.5))
for eye_id, df_eye in cohort.groupby("eye_id"):
plt.plot(df_eye["time_years"], df_eye["area_mm2"], alpha=0.08)
plt.title("Simulated GA Area Trajectories (all eyes)")
plt.xlabel("Years")
plt.ylabel("Area (mm²)")
plt.grid(True)
plt.tight_layout()
# 2) Group means over time (fovea-sparing baseline vs involving baseline)
plt.figure(figsize=(9, 5.5))
for fs, name in [(True, "Fovea-sparing (BL)"), (False, "Fovea-involving (BL)")]:
df = cohort[cohort["fovea_sparing0"] == fs]
m = df.groupby("time_years")["area_mm2"].mean()
plt.plot(m.index.values, m.values, label=name, linewidth=2)
plt.title("Group Mean GA Area Over Time")
plt.xlabel("Years")
plt.ylabel("Area (mm²)")
plt.legend()
plt.grid(True)
plt.tight_layout()
# 3) Distribution of per-eye growth rates
fig, ax = plt.subplots(1, 2, figsize=(11, 4.5))
ax[0].hist(summary["abs_GR_mm2_per_year"], bins=30)
ax[0].set_title("Absolute GR (mm²/yr)")
ax[0].set_xlabel("mm²/yr"); ax[0].set_ylabel("Count"); ax[0].grid(True)
ax[1].hist(summary["sqrt_GR_mm_per_year"], bins=30)
ax[1].set_title("sqrtGR (mm/yr)")
ax[1].set_xlabel("mm/yr"); ax[1].set_ylabel("Count"); ax[1].grid(True)
plt.tight_layout()
plt.show()
if __name__ == "__main__":
main()
What it does:
- Generates a cohort of simulated eyes with baseline GA sizes.
- Tracks lesion growth over 10 years with subgroup effects.
Plots:
- Spaghetti plot of all eye trajectories
- Group mean curves (fovea-sparing vs involving)
- Histograms of growth rates
- Exports data to CSV for further analysis.
👉 Run it, tweak baseline lesion size or conversion probability, and see how the disease trajectory changes. That’s the power of modeling.
Takeaway
Grit is about persistence. Diseases like AMD remind us why persistence matters in science too.
This Swedish study shows that GA growth is influenced by lesion characteristics and isn’t simply linear. By combining long-term data with simulation tools like the Python code above, we can:
- Predict disease course,
- Evaluate treatment efficacy,
- And ultimately, buy patients more time with their vision.
And just like in life, in science too — it’s not always about being the smartest, but about being the last one standing.
