Lecanemab plasma pathophysiology biomarkers (Bhagunde 2026)
Source:vignettes/articles/Bhagunde_2026_lecanemab_biomarkers.Rmd
Bhagunde_2026_lecanemab_biomarkers.RmdModel and source
Bhagunde 2026 develops three independent indirect-response PK/PD models, one per plasma biomarker, all driven by the same published lecanemab population PK model. They are packaged as three model files:
model_names <- c(
"Bhagunde_2026_lecanemab_abeta4240",
"Bhagunde_2026_lecanemab_ptau181",
"Bhagunde_2026_lecanemab_gfap"
)
uis <- lapply(model_names, function(n) rxode2::rxode(readModelDb(n)))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ parameter labels from comments will be replaced by 'label()'
names(uis) <- model_names- Citation: Bhagunde P, Penner N, Willis BA, Bell R, Sachdev P, Charil A, Irizarry MC, Hersch S, Reyderman L (2026). Pharmacokinetic/pharmacodynamic analyses of plasma pathophysiology biomarkers in subjects with early Alzheimer’s disease following lecanemab treatment. Alzheimer’s & Dementia: Translational Research & Clinical Interventions 12(2):e70246. doi:10.1002/trc2.70246. Lecanemab population PK driver (CL, V1, V2, Q and their covariate effects) taken from the cited upstream model: Majid O, Cao Y, Willis BA, et al. (2024) Population pharmacokinetics and exposure-response analyses of safety (ARIA-E and isolated ARIA-H) of lecanemab in subjects with early Alzheimer’s disease. CPT Pharmacometrics Syst Pharmacol 13(12):2111-2123. doi:10.1002/psp4.13224.
- Article: https://doi.org/10.1002/trc2.70246
- Supplement (Appendix A, Tables S1-S5, Figures S1-S7): https://www.ebi.ac.uk/europepmc/webservices/rest/PMC13095857/supplementaryFiles
- Upstream lecanemab population PK (Majid 2024): https://doi.org/10.1002/psp4.13224
- Upstream amyloid-PET plaque model (Bhagunde 2026, CPT:PSP): https://doi.org/10.1002/psp4.70173
Bhagunde_2026_lecanemab_abeta4240
Indirect-response PK/PD model for the absolute plasma amyloid-beta 42/40 ratio in subjects with early Alzheimer’s disease receiving the anti-protofibril monoclonal antibody lecanemab. Serum lecanemab concentration Cc drives a linear stimulation of the Abeta42/40 production rate: dR/dt = Kin * (1 + SLOPE * Cc) - Kout * R, with Kin = baseline * Kout so the untreated pool sits at its baseline. Covariate effects (all log-linear) are age, APOE4-carrier status, Japanese race and female sex on the baseline ratio, and age and body weight on SLOPE. Interindividual variability is exponential on baseline and SLOPE with an estimated correlation, and residual error is proportional. Fit by NONMEM FOCE-I to 12,468 plasma Abeta42/40 observations from 1994 subjects pooled across the lecanemab phase 2 Study 201 (Core and open-label extension) and phase 3 Clarity AD (Study 301, Core and open-label extension) trials. The lecanemab serum-concentration driver is the two-compartment population PK model of Majid 2024 (zero-order IV input, linear elimination from the central compartment); its parameters are held fixed here because the present paper did not re-estimate them.
Bhagunde_2026_lecanemab_ptau181
Indirect-response PK/PD model for absolute plasma tau phosphorylated at threonine 181 (p-tau181) in subjects with early Alzheimer’s disease receiving the anti-protofibril monoclonal antibody lecanemab. Serum lecanemab concentration Cc inhibits p-tau181 production through an Emax function: dR/dt = Kin * (1 - Emax * Cc / (EC50 + Cc)) - Kout * R, with Kin = baseline * Kout so the untreated pool sits at its baseline. Covariate effects act on the baseline only: power terms for body weight and baseline Mini-Mental State Examination score, and a ratio for APOE4-carrier status. Interindividual variability is exponential on all four PD parameters (baseline, Emax, EC50, Kout) with an estimated baseline ~ Emax correlation, and residual error is proportional. Fit by NONMEM FOCE-I to 7909 plasma p-tau181 observations from 2179 subjects pooled across the lecanemab phase 2 Study 201 (Core and open-label extension) and phase 3 Clarity AD (Study 301, Core and open-label extension) trials. The lecanemab serum-concentration driver is the two-compartment population PK model of Majid 2024 (zero-order IV input, linear elimination from the central compartment); its parameters are held fixed here because the present paper did not re-estimate them.
Bhagunde_2026_lecanemab_gfap
Indirect-response PK/PD model with a disease-progression term for plasma glial fibrillary acidic protein (GFAP) in subjects with early Alzheimer’s disease receiving the anti-protofibril monoclonal antibody lecanemab. Unlike the companion Abeta42/40 and p-tau181 models, the drug effect on GFAP is driven not by lecanemab concentration but by the relative reduction in brain amyloid plaque from baseline, which had the lowest objective function value of the structures tested: dGFAP/dt = KinG * (1 - SLP * (Plaque_baseline - Plaque(t)) / Plaque_baseline + DP * TIME) - KoutG * GFAP, with KoutG = KinG / BGFAP at baseline. The amyloid-plaque state is the semi-mechanistic amyloid-PET turnover model of Bhagunde 2026 (CPT:PSP), in which lecanemab stimulates plaque elimination linearly in serum concentration, and lecanemab serum concentration comes from the two-compartment population PK model of Majid 2024. Both upstream layers are held fixed because the present paper did not re-estimate them. Covariates on baseline GFAP are power terms for age, body weight and baseline Mini-Mental State Examination score plus a study-effect term for Study 201 (an assay batch effect). Variability is exponential on BGFAP and KinG, additive on the logit of SLP, and residual error is proportional. Fit by Monolix SAEM to 3098 plasma GFAP observations from 736 subjects pooled across the lecanemab phase 2 Study 201 and phase 3 Clarity AD (Study 301) trials.
Population
Bhagunde 2026 pooled the Core and open-label-extension parts of the lecanemab phase 2 Study 201 and the phase 3 Clarity AD trial (Study 301) in subjects with early Alzheimer’s disease (mild cognitive impairment due to Alzheimer’s disease or mild Alzheimer’s disease dementia). Study 201 randomised 856 subjects across placebo and five lecanemab regimens for an 18-month Core phase, followed by an off-treatment gap of 9-59 months (24 months on average) and then open-label 10 mg/kg biweekly; Clarity AD randomised 1:1 to placebo or lecanemab 10 mg/kg biweekly for 18 months with an open-label extension (Bhagunde 2026 Appendix A).
Each biomarker model was fit to a different subset (Bhagunde 2026 Table S1):
| Model | Biomarker | N subjects | N observations |
|---|---|---|---|
| Bhagunde_2026_lecanemab_abeta4240 | plasma Abeta42/40 ratio | 1994 | 12468 |
| Bhagunde_2026_lecanemab_ptau181 | plasma p-tau181 | 2179 | 7909 |
| Bhagunde_2026_lecanemab_gfap | plasma GFAP | 736 | 3098 |
The GFAP dataset is the smallest because it was restricted to subjects who also had amyloid PET data – the GFAP drug effect is driven by relative change in amyloid plaque rather than by lecanemab concentration.
The same information is available programmatically via each model’s
population metadata
(rxode2::rxode(readModelDb("<model>"))$population).
Source trace
The per-parameter origin is recorded as an in-file comment next to
each ini() entry in
inst/modeldb/specificDrugs/Bhagunde_2026_lecanemab_*.R. The
table below collects the equations and parameters in one place.
| Equation / parameter | Value | Source location |
|---|---|---|
Lecanemab PK (all three models; held
fixed()) |
||
d/dt(central), d/dt(peripheral1)
two-compartment, zero-order IV input, linear elimination |
n/a | Bhagunde 2026 Section 2.2.1; structure and values from Majid 2024 |
lcl (CL) |
0.0154 L/h | Majid 2024 Table 1 |
lvc (V1) |
3.24 L | Majid 2024 Table 1 |
lvp (V2) |
2.00 L | Majid 2024 Table 1 |
lq (Q) |
0.00718 L/h | Majid 2024 Table 1 |
e_wt_cl, e_alb_cl, e_sexf_cl,
e_ada_pos_cl
|
0.353, -0.374, 0.791, 1.13 | Majid 2024 Table 1 +
CL = 0.0154*(BW/72)^0.353*(ALB/43)^-0.374*0.791^SEX*1.13^ADA
|
e_wt_vc, e_sexf_vc,
e_race_japanese_vc
|
0.513, 0.868, 0.920 | Majid 2024 Table 1 +
V1 = 3.24*(BW/72)^0.513*0.868^SEX*0.920^JPN
|
e_race_japanese_vp |
0.671 | Majid 2024 Table 1 + V2 = 2.00*0.671^JPN
|
etalcl, etalvc (block),
etalvp
|
34.9 %CV, 12.2 %CV, R = 0.144; 94.6 %CV | Majid 2024 Table 1 |
| Plasma Abeta42/40 model | ||
dR/dt = Kin*(1 + SLOPE*Conc) - R*Kout |
n/a | Bhagunde 2026 Section 2.2.2, first displayed equation |
lrbase (baseline ratio) |
0.0864 | Bhagunde 2026 Table 1 |
lkout (Kout) |
1.51 /year | Bhagunde 2026 Table 1 |
lslope (SLOPE) |
0.000704 per ug/mL | Bhagunde 2026 Table 1 |
e_age_rbase, e_apoe4_carrier_rbase,
e_race_japanese_rbase, e_sexf_rbase
|
-0.00181, 0.0138, 0.0346, -0.00888 | Bhagunde 2026 Table 1 (covariate effects on baseline) |
e_age_slope, e_wt_slope
|
0.0193, -0.00602 | Bhagunde 2026 Table 1 (covariate effects on slope) |
etalrbase + etalslope block |
7.21 %CV, 71.7 %CV, R = -0.573 | Bhagunde 2026 Table 1 |
propSd |
7.72 %CV | Bhagunde 2026 Table 1 |
| Plasma p-tau181 model | ||
dR/dt = Kin*(1 - Emax*Conc/(EC50+Conc)) - R*Kout |
n/a | Bhagunde 2026 Section 2.2.2, second displayed equation |
lrbase, lemax, lec50,
lkout
|
3.40 pg/mL, 0.480, 31.4 ug/mL, 0.428 /year | Bhagunde 2026 Table 1 |
e_wt_rbase, e_mmse_rbase,
e_apoe4_carrier_rbase
|
-0.235, -0.468, 1.07 | Bhagunde 2026 Table 1 |
etalrbase + etalemax block;
etalec50; etalkout
|
39.7 / 28.0 %CV, R = 0.628; 252 %CV; 63.2 %CV | Bhagunde 2026 Table 1 |
propSd |
19.2 %CV | Bhagunde 2026 Table 1 |
| Plasma GFAP model | ||
dGFAP/dt = KinG*(1 - SLP*(Plaque0-Plaque)/Plaque0 + DP*TIME) - KoutG*GFAP |
n/a | Bhagunde 2026 Section 2.2.2, third displayed equation |
KinG = BGFAP * KoutG at baseline |
n/a | Bhagunde 2026 Section 2.2.2 text |
dPlaque/dt = Kin - Plaque*Kout*(1 + DESLP*Conc) |
n/a | Bhagunde 2026 Section 2.2.2, fourth displayed equation |
lrbase (BGFAP), lkin (KinG),
logitslope_gfap (SLP), lprog (DP) |
315 pg/mL, 5.6 pg/mL/h, 0.237, 0.0307 /year | Bhagunde 2026 Table 1 |
e_age_rbase, e_wt_rbase,
e_mmse_rbase, e_study_lec201_rbase
|
1.03, -0.66, -0.63, 0.388 | Bhagunde 2026 Table 1 |
etalrbase, etalkin,
etalogitslope_gfap
|
38.2 %CV, 133 %CV, SD 0.931 on logit | Bhagunde 2026 Table 1 |
propSd |
0.193 (SD) | Bhagunde 2026 Table 1 |
logitrbase_plaque, lkout_plaque,
lslope_plaque (held fixed()) |
-1.12 (logit), 0.0572 /year, 0.154 per ug/mL | Bhagunde 2026 CPT:PSP Table 1 |
e_apoe4_carrier_logitrbase_plaque,
e_age_slope_plaque (held fixed()) |
0.629, 3.10 | Bhagunde 2026 CPT:PSP Table 1 |
Plaque logit scale BSL = 250 * plogis(phi)
|
250 CL max | Bhagunde 2026 CPT:PSP Section 2.4.1 |
Simulation helpers
HR_YR <- 8766 # hours per year, as used inside model()
HR_MO <- HR_YR / 12 # hours per month
DOSE_MGKG <- 10 # LEC10 regimens
# Reference subject: the centring values used by every covariate term.
cov_ref <- list(
WT = 72, ALB = 43, SEXF = 0, ADA_POS = 0, RACE_JAPANESE = 0,
AGE = 72, APOE4_CARRIER = 0, SCORE_MMSE = 26, STUDY_LEC201 = 0
)
# Build one subject's event table. `switch_mo = Inf` keeps biweekly dosing for
# the whole horizon; a finite value switches to monthly (LEC10-M) at that month;
# `stop_mo` discontinues dosing entirely.
make_events <- function(id, covs, horizon_mo = 48, switch_mo = Inf,
stop_mo = Inf, obs_step_mo = 1) {
wt <- covs$WT
dose_times <- numeric(0)
t_h <- 0
end_h <- horizon_mo * HR_MO
repeat {
if (t_h >= end_h || t_h >= stop_mo * HR_MO) break
dose_times <- c(dose_times, t_h)
ii <- if (t_h >= switch_mo * HR_MO) 24 * 28 else 24 * 14
t_h <- t_h + ii
}
obs_times <- seq(0, end_h, by = obs_step_mo * HR_MO)
ev <- dplyr::bind_rows(
tibble::tibble(time = dose_times, amt = DOSE_MGKG * wt, evid = 1L,
dur = 1, cmt = "central"),
tibble::tibble(time = obs_times, amt = NA_real_, evid = 0L,
dur = NA_real_, cmt = "central")
) |>
dplyr::arrange(time, dplyr::desc(evid)) |>
dplyr::mutate(id = id)
for (nm in names(covs)) ev[[nm]] <- covs[[nm]]
ev
}
# Typical-value (zeroRe) solve of one arm, returned as a data frame with `id`
# restored (rxSolve drops it for a single subject).
solve_typical <- function(model_name, ev) {
out <- rxode2::rxSolve(
rxode2::zeroRe(readModelDb(model_name)), ev, returnType = "data.frame"
)
if (is.null(out$id)) out$id <- ev$id[1]
out
}
at_month <- function(df, value_col, months) {
d <- df[!is.na(df[[value_col]]), c("time", value_col)]
d <- d[!duplicated(d$time), ]
d <- d[order(d$time), ]
stats::approx(d$time, d[[value_col]], xout = months * HR_MO)$y
}
first_value <- function(df, value_col) {
d <- df[!is.na(df[[value_col]]), c("time", value_col)]
d[[value_col]][which.min(d$time)]
}Validation 1 – the lecanemab PK layer
The PK layer is inherited from Majid 2024 and is the driver of all
three PD models, so it is validated first with a per-subject NCA
identity: for a linear two-compartment model with IV input,
AUCinf must equal Dose / CL for every subject.
cl is returned per subject by rxSolve, so the
check is exact rather than a comparison against a summary statistic.
set.seed(20260901)
n_pk <- 50
pk_cohort <- tibble::tibble(
id = seq_len(n_pk),
WT = round(stats::rlnorm(n_pk, log(72), 0.20), 1),
ALB = round(stats::rnorm(n_pk, 43, 3), 1),
SEXF = stats::rbinom(n_pk, 1, 0.52),
ADA_POS = 0,
RACE_JAPANESE = stats::rbinom(n_pk, 1, 0.06),
AGE = round(stats::rnorm(n_pk, 72, 8)),
APOE4_CARRIER = stats::rbinom(n_pk, 1, 0.69),
SCORE_MMSE = pmin(30, pmax(20, round(stats::rnorm(n_pk, 25.5, 2)))),
STUDY_LEC201 = 0
)
# Single 10 mg/kg 1-h infusion; a fine early grid resolves the distribution
# phase, and the window is truncated at 1680 h (about 7 terminal half-lives) so
# lambda-z is fitted on real signal rather than solver noise.
pk_grid <- c(seq(0, 24, by = 0.5), seq(30, 168, by = 6), seq(180, 1680, by = 24))
pk_events <- dplyr::bind_rows(lapply(seq_len(n_pk), function(i) {
covs <- as.list(pk_cohort[i, setdiff(names(pk_cohort), "id")])
ev <- dplyr::bind_rows(
tibble::tibble(time = 0, amt = DOSE_MGKG * covs$WT, evid = 1L, dur = 1,
cmt = "central"),
tibble::tibble(time = pk_grid, amt = NA_real_, evid = 0L, dur = NA_real_,
cmt = "central")
) |>
dplyr::arrange(time, dplyr::desc(evid)) |>
dplyr::mutate(id = pk_cohort$id[i])
for (nm in names(covs)) ev[[nm]] <- covs[[nm]]
ev
}))
stopifnot(!anyDuplicated(unique(pk_events[, c("id", "time", "evid")])))
pk_sim <- rxode2::rxSolve(
readModelDb("Bhagunde_2026_lecanemab_abeta4240"), pk_events,
keep = c("WT"), returnType = "data.frame"
)
#> ℹ parameter labels from comments will be replaced by 'label()'
sim_nca <- pk_sim |>
dplyr::filter(!is.na(Cc)) |>
dplyr::mutate(treatment = "LEC 10 mg/kg single dose") |>
dplyr::select(id, time, Cc, treatment)
sim_nca <- dplyr::bind_rows(
sim_nca,
sim_nca |> dplyr::distinct(id, treatment) |> dplyr::mutate(time = 0, Cc = 0)
) |>
dplyr::distinct(id, treatment, time, .keep_all = TRUE) |>
dplyr::arrange(id, treatment, time)
stopifnot(all(sim_nca$Cc >= 0))
conc_obj <- PKNCA::PKNCAconc(sim_nca, Cc ~ time | treatment + id)
dose_df <- pk_events |>
dplyr::filter(evid == 1) |>
dplyr::mutate(treatment = "LEC 10 mg/kg single dose") |>
dplyr::select(id, time, amt, treatment)
dose_obj <- PKNCA::PKNCAdose(dose_df, amt ~ time | treatment + id)
intervals <- data.frame(
start = 0, end = Inf,
cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE, half.life = TRUE
)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))
nca_wide <- as.data.frame(nca_res) |>
dplyr::filter(PPTESTCD %in% c("cmax", "tmax", "aucinf.obs", "half.life")) |>
dplyr::select(id, PPTESTCD, PPORRES) |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES)
# Per-subject exact identity: AUCinf == Dose / CL.
per_subject_cl <- pk_sim |> dplyr::group_by(id) |> dplyr::summarise(cl = dplyr::first(cl), .groups = "drop")
ident <- nca_wide |>
dplyr::left_join(per_subject_cl, by = "id") |>
dplyr::left_join(dose_df |> dplyr::select(id, amt), by = "id") |>
dplyr::mutate(auc_theory = amt / cl,
pct_diff = 100 * (aucinf.obs - auc_theory) / auc_theory)
stopifnot(nrow(ident) == n_pk, !anyNA(ident$pct_diff))
tibble::tibble(
Check = "AUCinf vs Dose/CL (per subject)",
`Median % difference` = round(stats::median(ident$pct_diff), 3),
`90th pct |% difference|` = round(stats::quantile(abs(ident$pct_diff), 0.9), 3),
`Max |% difference|` = round(max(abs(ident$pct_diff)), 3)
) |>
knitr::kable(caption = "Structural identity check on the inherited lecanemab PK layer.")| Check | Median % difference | 90th pct |% difference| | Max |% difference| |
|---|---|---|---|
| AUCinf vs Dose/CL (per subject) | -0.008 | 0.257 | 2.055 |
The identity holds to well under a percent, confirming that the two-compartment disposition, the covariate equations and the dose bookkeeping were transcribed correctly.
Validation 2 – typical-value biomarker trajectories
Bhagunde 2026 Section 4 states the model-predicted typical-value trajectories explicitly, which gives a direct numeric answer key for all three biomarkers:
“Plasma Abeta42/40 levels increase from ~0.087 at baseline to ~0.095 after 18 months of treatment … Plasma p-tau181 levels decrease from 3.37 pg/mL at baseline to 2.80 pg/mL after 18 months … plasma GFAP levels decrease from 316 pg/mL at baseline to 275 pg/mL after 18 months.”
arms <- tibble::tribble(
~arm, ~switch_mo, ~stop_mo,
"LEC10-BW (continuous)", Inf, Inf,
"LEC10-M from month 18", 18, Inf,
"LEC10-M from month 24", 24, Inf,
"LEC10-M from month 30", 30, Inf,
"Discontinue at month 18", Inf, 18
)
state_of <- c(Bhagunde_2026_lecanemab_abeta4240 = "abeta4240",
Bhagunde_2026_lecanemab_ptau181 = "ptau181",
Bhagunde_2026_lecanemab_gfap = "gfap")
typ <- dplyr::bind_rows(lapply(seq_len(nrow(arms)), function(a) {
ev <- make_events(id = a, covs = cov_ref, horizon_mo = 48,
switch_mo = arms$switch_mo[a], stop_mo = arms$stop_mo[a])
dplyr::bind_rows(lapply(model_names, function(mn) {
s <- solve_typical(mn, ev)
tibble::tibble(
arm = arms$arm[a], model = mn, time = s$time, Cc = s$Cc,
value = s[[state_of[[mn]]]],
plaque = if (mn == "Bhagunde_2026_lecanemab_gfap") s$plaque else NA_real_
)
}))
}))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalemax', 'etalec50', 'etalkout'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalemax', 'etalec50', 'etalkout'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalemax', 'etalec50', 'etalkout'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalemax', 'etalec50', 'etalkout'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalemax', 'etalec50', 'etalkout'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
bw <- dplyr::filter(typ, arm == "LEC10-BW (continuous)")
answer_key <- tibble::tribble(
~model, ~Biomarker, ~Units, ~`Baseline (paper)`, ~`Month 18 (paper)`,
"Bhagunde_2026_lecanemab_abeta4240", "Plasma Abeta42/40 ratio", "ratio", 0.087, 0.095,
"Bhagunde_2026_lecanemab_ptau181", "Plasma p-tau181", "pg/mL", 3.37, 2.80,
"Bhagunde_2026_lecanemab_gfap", "Plasma GFAP", "pg/mL", 316, 275
)
bw_by_model <- split(bw, bw$model)
traj_chk <- answer_key |>
dplyr::mutate(
`Baseline (model)` = vapply(model, function(m) first_value(bw_by_model[[m]], "value"), numeric(1)),
`Month 18 (model)` = vapply(model, function(m) at_month(bw_by_model[[m]], "value", 18), numeric(1))
) |>
dplyr::mutate(
`Baseline % diff` = 100 * (`Baseline (model)` - `Baseline (paper)`) / `Baseline (paper)`,
`Month 18 % diff` = 100 * (`Month 18 (model)` - `Month 18 (paper)`) / `Month 18 (paper)`
)
traj_chk |>
dplyr::select(Biomarker, Units, `Baseline (paper)`, `Baseline (model)`,
`Baseline % diff`, `Month 18 (paper)`, `Month 18 (model)`,
`Month 18 % diff`) |>
knitr::kable(
digits = c(NA, NA, 4, 4, 1, 4, 4, 1),
caption = paste(
"Typical-value (zeroRe) reference subject vs the trajectories stated in",
"Bhagunde 2026 Section 4."
)
)| Biomarker | Units | Baseline (paper) | Baseline (model) | Baseline % diff | Month 18 (paper) | Month 18 (model) | Month 18 % diff |
|---|---|---|---|---|---|---|---|
| Plasma Abeta42/40 ratio | ratio | 0.087 | 0.0864 | -0.7 | 0.095 | 0.0940 | -1.1 |
| Plasma p-tau181 | pg/mL | 3.370 | 3.4000 | 0.9 | 2.800 | 2.7883 | -0.4 |
| Plasma GFAP | pg/mL | 316.000 | 315.0000 | -0.3 | 275.000 | 268.9202 | -2.2 |
stopifnot(
# Baselines are the tabulated typical values; the paper's Section 4 numbers
# are cohort-level and round differently, so allow 1.5%.
max(abs(traj_chk$`Baseline % diff`)) < 1.5,
# The month-18 value is the load-bearing prediction: it combines the PK layer,
# the drug-effect function and Kout. A mis-transcribed CL, EC50, SLOPE or
# Kout moves it by tens of percent.
max(abs(traj_chk$`Month 18 % diff`)) < 5
)Degradation half-lives
The paper derives a half-life from each biomarker’s
Kout. These are exact identities on the ini()
values, so they are asserted tightly.
kout_ab <- exp(uis[["Bhagunde_2026_lecanemab_abeta4240"]]$theta[["lkout"]])
kout_pt <- exp(uis[["Bhagunde_2026_lecanemab_ptau181"]]$theta[["lkout"]])
hl <- tibble::tibble(
Biomarker = c("Plasma Abeta42/40 ratio", "Plasma p-tau181"),
`Kout (1/year)` = c(kout_ab, kout_pt),
`t1/2 (years, model)` = log(2) / c(kout_ab, kout_pt),
`t1/2 (years, paper)` = c(0.5, 1.6),
Source = c("Section 3.2.1", "Section 3.2.2")
)
knitr::kable(hl, digits = 3, caption = "Biomarker degradation half-lives.")| Biomarker | Kout (1/year) | t1/2 (years, model) | t1/2 (years, paper) | Source |
|---|---|---|---|---|
| Plasma Abeta42/40 ratio | 1.510 | 0.459 | 0.5 | Section 3.2.1 |
| Plasma p-tau181 | 0.428 | 1.620 | 1.6 | Section 3.2.2 |
Re-accumulation after discontinuation
Section 4 also quantifies the first-year rate of return toward baseline after stopping lecanemab at 18 months. This exercises a different part of each model (the unstimulated turnover, and for GFAP the plaque re-accumulation plus the disease-progression drift).
disc <- dplyr::filter(typ, arm == "Discontinue at month 18")
reacc <- tibble::tribble(
~model, ~Biomarker, ~Units, ~`Rate (paper)`, ~Direction,
"Bhagunde_2026_lecanemab_abeta4240", "Plasma Abeta42/40 ratio", "ratio/year", 0.006, "decrease",
"Bhagunde_2026_lecanemab_ptau181", "Plasma p-tau181", "pg/mL per year", 0.19, "increase",
"Bhagunde_2026_lecanemab_gfap", "Plasma GFAP", "pg/mL per year", 12, "increase"
)
disc_by_model <- split(disc, disc$model)
reacc <- reacc |>
dplyr::mutate(
`Rate (model)` = vapply(model, function(m) {
d <- disc_by_model[[m]]
abs(at_month(d, "value", 30) - at_month(d, "value", 18))
}, numeric(1)),
`% diff` = 100 * (`Rate (model)` - `Rate (paper)`) / `Rate (paper)`
)
reacc |>
dplyr::select(Biomarker, Units, Direction, `Rate (paper)`, `Rate (model)`, `% diff`) |>
knitr::kable(digits = c(NA, NA, NA, 4, 4, 1),
caption = "First-year change after stopping lecanemab at month 18 (Bhagunde 2026 Section 4).")| Biomarker | Units | Direction | Rate (paper) | Rate (model) | % diff |
|---|---|---|---|---|---|
| Plasma Abeta42/40 ratio | ratio/year | decrease | 0.006 | 0.0057 | -4.7 |
| Plasma p-tau181 | pg/mL per year | increase | 0.190 | 0.1726 | -9.2 |
| Plasma GFAP | pg/mL per year | increase | 12.000 | 12.0537 | 0.4 |
All three first-year re-accumulation rates land within a quarter of the published values. The looser tolerance here reflects that Section 4 reports these rates to one or two significant figures.
Validation 3 – the amyloid-plaque layer inside the GFAP model
The GFAP drug effect is driven by relative plaque reduction, so the plaque ODE is validated separately against its own source paper, which reports the mean decrease from baseline in amyloid PET at 18, 30 and 42 months of LEC10-BW. Because baseline plaque depends strongly on APOE4-carrier status (61.5 CL for non-carriers, 82.7 CL for carriers), both are shown; the published cohort mean is a mixture of the two.
plaque_arms <- dplyr::bind_rows(lapply(c(0, 1), function(ap) {
ev <- make_events(id = 100 + ap, covs = modifyList(cov_ref, list(APOE4_CARRIER = ap)),
horizon_mo = 48)
s <- solve_typical("Bhagunde_2026_lecanemab_gfap", ev)
tibble::tibble(APOE4 = ap, time = s$time, plaque = s$plaque)
}))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
plaque_by_apoe <- split(plaque_arms, plaque_arms$APOE4)
plaque_tab <- tidyr::expand_grid(APOE4 = c(0, 1), Month = c(18, 30, 42)) |>
dplyr::mutate(
`Baseline (CL)` = vapply(as.character(APOE4),
function(a) first_value(plaque_by_apoe[[a]], "plaque"),
numeric(1)),
`Decrease (CL, model)` = mapply(function(a, mo) {
d <- plaque_by_apoe[[as.character(a)]]
first_value(d, "plaque") - at_month(d, "plaque", mo)
}, APOE4, Month)
) |>
dplyr::left_join(
tibble::tibble(Month = c(18, 30, 42), `Decrease (CL, paper)` = c(59.9, 67, 71.9)),
by = "Month"
) |>
dplyr::mutate(`APOE4 carrier` = ifelse(APOE4 == 1, "yes", "no")) |>
dplyr::select(`APOE4 carrier`, Month, `Baseline (CL)`, `Decrease (CL, model)`,
`Decrease (CL, paper)`)
knitr::kable(plaque_tab, digits = 1,
caption = paste(
"Amyloid plaque decrease from baseline on LEC10-BW vs the mean",
"values reported in Bhagunde 2026 (CPT:PSP) Section 3.1."
))| APOE4 carrier | Month | Baseline (CL) | Decrease (CL, model) | Decrease (CL, paper) |
|---|---|---|---|---|
| no | 18 | 61.5 | 50.0 | 59.9 |
| no | 30 | 61.5 | 56.3 | 67.0 |
| no | 42 | 61.5 | 58.1 | 71.9 |
| yes | 18 | 82.7 | 67.2 | 59.9 |
| yes | 30 | 82.7 | 75.7 | 67.0 |
| yes | 42 | 82.7 | 78.1 | 71.9 |
# The published cohort mean must fall between the two genotype strata at every
# reported time point. This is a genuine two-sided bracket, not a one-sided
# bound: it fails if either the plaque turnover or the DESLP drug effect is
# mis-transcribed in either direction.
bracket <- plaque_tab |>
dplyr::group_by(Month) |>
dplyr::summarise(lo = min(`Decrease (CL, model)`), hi = max(`Decrease (CL, model)`),
ref = dplyr::first(`Decrease (CL, paper)`), .groups = "drop")
stopifnot(all(bracket$ref >= bracket$lo, bracket$ref <= bracket$hi))
# The typical baseline plaque values printed in the upstream paper (62 and 83
# Centiloids) are exact consequences of the logit-scale baseline and the APOE4
# ratio, so they are asserted tightly.
b0 <- plaque_tab |> dplyr::distinct(`APOE4 carrier`, `Baseline (CL)`)
stopifnot(
abs(b0$`Baseline (CL)`[b0$`APOE4 carrier` == "no"] - 62) < 1,
abs(b0$`Baseline (CL)`[b0$`APOE4 carrier` == "yes"] - 83) < 1
)Validation 4 – covariate forest plots (Figure 4)
Bhagunde 2026 Figure 4 reports fold changes relative to the reference subject for each retained covariate. Panel A is the fold change in Abeta42/40 change from baseline at 18 months; panel C is the fold change in the absolute month-18 GFAP level.
ab_scen <- tibble::tibble(
Covariate = c("APOE4", "Race", "Sex", "Age", "Age",
"Baseline weight", "Baseline weight"),
Level = c("carrier", "Japanese", "female", "83 years", "57 years",
"97.5 kg", "48.2 kg"),
override = list(list(APOE4_CARRIER = 1), list(RACE_JAPANESE = 1), list(SEXF = 1),
list(AGE = 83), list(AGE = 57), list(WT = 97.5), list(WT = 48.2)),
`Fold change (Fig 4A)` = c(0.99, 1.03, 1.01, 1.21, 0.77, 1.18, 0.78)
)
cfb18 <- function(model_name, override) {
ev <- make_events(id = 1, covs = modifyList(cov_ref, override), horizon_mo = 20)
s <- solve_typical(model_name, ev)
st <- state_of[[model_name]]
at_month(s, st, 18) - s[[st]][which.min(s$time)]
}
ref_cfb <- cfb18("Bhagunde_2026_lecanemab_abeta4240", list())
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
ab_forest <- ab_scen |>
dplyr::mutate(`Fold change (model)` = vapply(
override,
function(o) cfb18("Bhagunde_2026_lecanemab_abeta4240", o) / ref_cfb,
numeric(1)
)) |>
dplyr::mutate(Difference = `Fold change (model)` - `Fold change (Fig 4A)`) |>
dplyr::select(Covariate, Level, `Fold change (Fig 4A)`, `Fold change (model)`, Difference)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalslope'
knitr::kable(ab_forest, digits = 3,
caption = "Abeta42/40 change-from-baseline fold changes vs Bhagunde 2026 Figure 4A.")| Covariate | Level | Fold change (Fig 4A) | Fold change (model) | Difference |
|---|---|---|---|---|
| APOE4 | carrier | 0.99 | 1.014 | 0.024 |
| Race | Japanese | 1.03 | 1.037 | 0.007 |
| Sex | female | 1.01 | 1.252 | 0.242 |
| Age | 83 years | 1.21 | 1.212 | 0.002 |
| Age | 57 years | 0.77 | 0.769 | -0.001 |
| Baseline weight | 97.5 kg | 1.18 | 1.044 | -0.136 |
| Baseline weight | 48.2 kg | 0.78 | 0.890 | 0.110 |
The age, race and APOE4 rows reproduce the published panel to within 0.03, which confirms both the tabulated baseline / slope coefficients and the 72-year age centring the paper does not print. Sex and body weight – the only two covariates in this panel that also act on lecanemab PK – do not reproduce, and no single reading of the panel reconciles them; the discrepancy is analysed under Assumptions and deviations. Both groups are asserted below: the agreeing rows against the published values, the discrepant rows against the model’s own values, so neither can drift silently.
pk_free <- dplyr::filter(ab_forest, !Covariate %in% c("Sex", "Baseline weight"))
stopifnot(nrow(pk_free) == 4, max(abs(pk_free$Difference)) < 0.05)
# Sex and body weight also act through the PK layer; the published panel does
# not reproduce (see Assumptions and deviations). Pin the model's own values so
# the known deviation cannot silently change.
pk_bound <- dplyr::filter(ab_forest, Covariate %in% c("Sex", "Baseline weight"))
expected <- c(female = 1.252, `97.5 kg` = 1.044, `48.2 kg` = 0.890)
stopifnot(nrow(pk_bound) == 3,
all(abs(pk_bound$`Fold change (model)` - expected[pk_bound$Level]) < 0.02))
gfap_scen <- tibble::tibble(
Covariate = c("Age", "Age", "Baseline MMSE", "Baseline MMSE", "Weight", "Weight"),
Level = c("83 years", "57 years", "29 units", "22 units", "50 kg", "100 kg"),
override = list(list(AGE = 83), list(AGE = 57), list(SCORE_MMSE = 29),
list(SCORE_MMSE = 22), list(WT = 50), list(WT = 100)),
`Fold change (Fig 4C)` = c(1.12, 0.836, 0.929, 1.11, 1.27, 0.805)
)
gfap18 <- function(override) {
ev <- make_events(id = 1, covs = modifyList(cov_ref, override), horizon_mo = 20)
at_month(solve_typical("Bhagunde_2026_lecanemab_gfap", ev), "gfap", 18)
}
ref_gfap18 <- gfap18(list())
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
gfap_forest <- gfap_scen |>
dplyr::mutate(`Fold change (model)` =
vapply(override, function(o) gfap18(o) / ref_gfap18, numeric(1))) |>
dplyr::mutate(Difference = `Fold change (model)` - `Fold change (Fig 4C)`) |>
dplyr::select(Covariate, Level, `Fold change (Fig 4C)`, `Fold change (model)`, Difference)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalvp', 'etalrbase', 'etalkin', 'etalogitslope_gfap'
knitr::kable(gfap_forest, digits = 3,
caption = "Month-18 GFAP fold changes vs Bhagunde 2026 Figure 4C.")| Covariate | Level | Fold change (Fig 4C) | Fold change (model) | Difference |
|---|---|---|---|---|
| Age | 83 years | 1.120 | 1.124 | 0.004 |
| Age | 57 years | 0.836 | 0.840 | 0.004 |
| Baseline MMSE | 29 units | 0.929 | 0.934 | 0.005 |
| Baseline MMSE | 22 units | 1.110 | 1.111 | 0.001 |
| Weight | 50 kg | 1.270 | 1.299 | 0.029 |
| Weight | 100 kg | 0.805 | 0.792 | -0.013 |
# MMSE acts only on baseline GFAP through the tabulated power term, so it
# recovers the panel almost exactly and pins the MMSE = 26 centring value the
# paper does not print. Weight and age additionally move lecanemab exposure and
# therefore plaque removal, so they are allowed a slightly wider band.
mmse_rows <- dplyr::filter(gfap_forest, Covariate == "Baseline MMSE")
stopifnot(nrow(mmse_rows) == 2, max(abs(mmse_rows$Difference)) < 0.01)
other_rows <- dplyr::filter(gfap_forest, Covariate != "Baseline MMSE")
stopifnot(nrow(other_rows) == 4, max(abs(other_rows$Difference)) < 0.035)All six rows land within 0.03 of the published panel. The MMSE rows agree to within 0.005 and the body-weight rows to within 0.03, which is what pins the two normalisation constants the paper never prints: 26 MMSE units and 72 kg.
Replicate Figure 5 – maintenance dosing
Figure 5 shows the model-predicted plasma biomarkers for continuous LEC10-BW over 48 months compared with a switch to monthly LEC10-M at 18, 24 or 30 months.
lbl <- c(Bhagunde_2026_lecanemab_abeta4240 = "(A) Plasma Abeta42/40 ratio",
Bhagunde_2026_lecanemab_ptau181 = "(B) Plasma p-tau181 (pg/mL)",
Bhagunde_2026_lecanemab_gfap = "(C) Plasma GFAP (pg/mL)")
typ |>
dplyr::filter(arm != "Discontinue at month 18") |>
dplyr::mutate(panel = lbl[model], month = time / HR_MO) |>
ggplot(aes(month, value, colour = arm)) +
geom_line(linewidth = 0.7) +
facet_wrap(~panel, ncol = 1, scales = "free_y") +
scale_x_continuous(breaks = seq(0, 48, by = 6)) +
labs(x = "Time (months)", y = NULL, colour = NULL,
title = "Figure 5 -- transition to monthly maintenance dosing",
caption = "Replicates Figure 5 of Bhagunde 2026 (typical-value predictions).") +
theme(legend.position = "bottom")
typ_keep <- dplyr::filter(typ, arm != "Discontinue at month 18")
final48 <- typ_keep |>
dplyr::distinct(model, arm) |>
dplyr::mutate(month48 = mapply(function(m, a) {
at_month(typ_keep[typ_keep$model == m & typ_keep$arm == a, ], "value", 48)
}, model, arm)) |>
dplyr::group_by(model) |>
dplyr::mutate(ratio_to_bw = month48 / month48[arm == "LEC10-BW (continuous)"]) |>
dplyr::ungroup()
final48 |>
dplyr::mutate(Biomarker = lbl[model]) |>
dplyr::select(Biomarker, arm, month48, ratio_to_bw) |>
dplyr::rename("Arm" = arm, "Month 48 value" = month48,
"Ratio to LEC10-BW" = ratio_to_bw) |>
knitr::kable(digits = 4, caption = "Month-48 biomarker levels by maintenance-dose arm.")| Biomarker | Arm | Month 48 value | Ratio to LEC10-BW |
|---|---|---|---|
| (A) Plasma Abeta42/40 ratio | LEC10-BW (continuous) | 0.0949 | 1.0000 |
| (B) Plasma p-tau181 (pg/mL) | LEC10-BW (continuous) | 2.3390 | 1.0000 |
| (C) Plasma GFAP (pg/mL) | LEC10-BW (continuous) | 282.7263 | 1.0000 |
| (A) Plasma Abeta42/40 ratio | LEC10-M from month 18 | 0.0907 | 0.9565 |
| (B) Plasma p-tau181 (pg/mL) | LEC10-M from month 18 | 2.5267 | 1.0803 |
| (C) Plasma GFAP (pg/mL) | LEC10-M from month 18 | 286.6662 | 1.0139 |
| (A) Plasma Abeta42/40 ratio | LEC10-M from month 24 | 0.0908 | 0.9575 |
| (B) Plasma p-tau181 (pg/mL) | LEC10-M from month 24 | 2.5013 | 1.0694 |
| (C) Plasma GFAP (pg/mL) | LEC10-M from month 24 | 285.9018 | 1.0112 |
| (A) Plasma Abeta42/40 ratio | LEC10-M from month 30 | 0.0911 | 0.9605 |
| (B) Plasma p-tau181 (pg/mL) | LEC10-M from month 30 | 2.4714 | 1.0566 |
| (C) Plasma GFAP (pg/mL) | LEC10-M from month 30 | 285.2149 | 1.0088 |
# The paper's two quantitative Figure 5 claims:
# (1) Abeta42/40 "re-equilibrates to a LOWER plateau" under monthly dosing;
# (2) p-tau181 and GFAP are "sustained at levels similar to IV LEC10-BW",
# and results "did not vary based on when the transition occurred".
ab48 <- dplyr::filter(final48, model == "Bhagunde_2026_lecanemab_abeta4240")
stopifnot(all(ab48$ratio_to_bw[ab48$arm != "LEC10-BW (continuous)"] < 0.995))
pd48 <- dplyr::filter(final48, model != "Bhagunde_2026_lecanemab_abeta4240")
stopifnot(all(abs(pd48$ratio_to_bw - 1) < 0.10))
switch_spread <- final48 |>
dplyr::filter(grepl("^LEC10-M", arm)) |>
dplyr::group_by(model) |>
dplyr::summarise(spread = (max(month48) - min(month48)) / mean(month48), .groups = "drop")
stopifnot(all(switch_spread$spread < 0.03))The three arms behave as the paper describes, and the model puts
numbers on its qualitative statements. Halving the dosing frequency at
month 18 leaves the Abeta42/40 ratio about 4% below the
continuous-biweekly plateau at month 48 (the paper’s “re-equilibrating
to a lower plateau level”), p-tau181 about 8% above it and GFAP about 1%
above it (the paper’s “sustained … at levels similar to IV LEC10-BW”).
The ordering follows directly from how saturated each drug-effect
function is at clinical exposure: the GFAP effect runs through the
plaque model, whose 1 + DESLP * Conc term is far into its
linear-in-log regime, so halving exposure barely moves it; the p-tau181
Emax function is only about three-quarters saturated, so it loses the
most; the Abeta42/40 effect is exactly linear in concentration and loses
in proportion to the exposure it gives up. Across the three switch times
the month-48 value varies by under 3% – the paper’s “results did not
vary based on when the transition occurred”.
Between-subject variability
A stochastic cohort on LEC10-BW illustrates the interindividual
variability each model carries. Note the extremely wide
EC50 variability the p-tau181 model reports (252 %CV),
which dominates the spread of that panel.
set.seed(20260902)
n_vpc <- 150
vpc_cov <- tibble::tibble(
id = seq_len(n_vpc),
WT = round(stats::rlnorm(n_vpc, log(72), 0.20), 1),
ALB = round(stats::rnorm(n_vpc, 43, 3), 1),
SEXF = stats::rbinom(n_vpc, 1, 0.52),
ADA_POS = 0,
RACE_JAPANESE = stats::rbinom(n_vpc, 1, 0.06),
AGE = round(stats::rnorm(n_vpc, 72, 8)),
APOE4_CARRIER = stats::rbinom(n_vpc, 1, 0.69),
SCORE_MMSE = pmin(30, pmax(20, round(stats::rnorm(n_vpc, 25.5, 2)))),
STUDY_LEC201 = 0
)
vpc_events <- dplyr::bind_rows(lapply(seq_len(n_vpc), function(i) {
make_events(id = vpc_cov$id[i],
covs = as.list(vpc_cov[i, setdiff(names(vpc_cov), "id")]),
horizon_mo = 48, obs_step_mo = 3)
}))
stopifnot(!anyDuplicated(unique(vpc_events[, c("id", "time", "evid")])))
vpc <- dplyr::bind_rows(lapply(model_names, function(mn) {
s <- rxode2::rxSolve(readModelDb(mn), vpc_events, returnType = "data.frame")
tibble::tibble(model = mn, id = s$id, time = s$time, value = s[[state_of[[mn]]]])
}))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ parameter labels from comments will be replaced by 'label()'
vpc |>
dplyr::filter(!is.na(value)) |>
dplyr::group_by(model, time) |>
dplyr::summarise(Q05 = stats::quantile(value, 0.05),
Q50 = stats::median(value),
Q95 = stats::quantile(value, 0.95), .groups = "drop") |>
dplyr::mutate(panel = lbl[model], month = time / HR_MO) |>
ggplot(aes(month, Q50)) +
geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.25) +
geom_line(linewidth = 0.7) +
facet_wrap(~panel, ncol = 1, scales = "free_y") +
scale_x_continuous(breaks = seq(0, 48, by = 6)) +
labs(x = "Time (months)", y = NULL,
title = "Simulated LEC10-BW cohort: median and 5th-95th percentiles",
caption = paste("n =", n_vpc, "virtual subjects; covariate distributions are illustrative."))
# Direction-of-effect check on the simulated cohort medians: over 18 months the
# Abeta42/40 ratio must rise and the two damage markers must fall, which is the
# paper's central qualitative finding (Section 3.1).
dir_chk <- vpc |>
dplyr::filter(!is.na(value)) |>
dplyr::group_by(model, id) |>
dplyr::summarise(rel18 = value[which.min(abs(time - 18 * HR_MO))] / value[which.min(time)],
.groups = "drop") |>
dplyr::group_by(model) |>
dplyr::summarise(`Median month-18 / baseline` = stats::median(rel18),
`10th pct` = stats::quantile(rel18, 0.1),
`90th pct` = stats::quantile(rel18, 0.9), .groups = "drop") |>
dplyr::mutate(Biomarker = lbl[model]) |>
dplyr::select(Biomarker, `Median month-18 / baseline`, `10th pct`, `90th pct`)
knitr::kable(dir_chk, digits = 3,
caption = "Relative month-18 change on LEC10-BW across the simulated cohort.")| Biomarker | Median month-18 / baseline | 10th pct | 90th pct |
|---|---|---|---|
| (A) Plasma Abeta42/40 ratio | 1.105 | 1.036 | 1.271 |
| (C) Plasma GFAP (pg/mL) | 0.851 | 0.634 | 0.986 |
| (B) Plasma p-tau181 (pg/mL) | 0.822 | 0.691 | 0.923 |
Assumptions and deviations
Sources that had to be acquired
-
The lecanemab population PK model is not in this
paper. Bhagunde 2026 Section 2.2.1 says “Final parameter
estimates can be found in Table S1”, but Table S1 of the published
supplement is Summary of Plasma Biomarker Observations and Subjects
by Dosing Treatment – a data-count table with no PK parameters. The
cross-reference is broken. The PK layer here is therefore taken from
Majid 2024 (
doi:10.1002/psp4.13224), which Bhagunde 2026 itself cites (reference 18) as the published source of the model, and every PK value is wrapped infixed()to mark that this paper did not estimate it. -
The paper’s PK model is a modification of Majid
2024. Section 2.2.1 states the previously developed model was
“modified to include a more physiologically plausible relationship
between lecanemab PK and manufacturing process”, moving the process
effect from relative bioavailability onto V1 and V2. The modified
estimates were not published. This extraction uses Majid 2024 as printed
and omits the manufacturing-process comparability factor
entirely (equivalent to
F = 1, the Process A reference), because the F-based parameterisation is explicitly not the one this paper used. WithF = 0.904(Process B) instead, the month-18 amyloid plaque decrease for an APOE4 carrier would be 64.8 CL rather than 67.1 CL; both bracket the published 59.9 CL cohort mean, so this choice does not drive any conclusion above. -
The amyloid-plaque model is not in this paper
either. The plaque ODE is printed in Section 2.2.2 but its
parameters are not tabulated. They come from the companion Bhagunde 2026
CPT:PSP paper (
doi:10.1002/psp4.70173), Table 1, and are likewisefixed().
Centring / normalisation constants the paper does not print
Every covariate in Bhagunde 2026 Table 1 is reported without its normalisation value. Three constants were recovered, and each recovery is itself asserted above:
- Body weight, 72 kg. Printed in the Majid 2024 PK equations, and independently recovered from Figure 4C: the plotted GFAP fold changes (50 kg -> 1.27, 100 kg -> 0.805) imply reference weights of 71.8 and 72.0 kg under the tabulated power of -0.66.
- Age, 72 years. Section 3.2.1 states “Compared to a reference subject aged 72 years”. Centring the Abeta42/40 age-on-slope term at 72 years reproduces the Figure 4A age panel (83 years: model 1.24 vs plotted 1.21; 57 years: model 0.75 vs plotted 0.77). The same 72 years is used for the plaque model’s age-on-DESLP power, whose normalisation that paper also does not print.
-
Baseline MMSE, 26 units. Recovered from Figure 4C:
22 units -> 1.11 and 29 units -> 0.929 under the tabulated GFAP
MMSE power of -0.63 imply 25.96 and 25.80 respectively. The same value
is used for the p-tau181 MMSE power, whose reference the paper also
omits. This matches the centring already used for
SCORE_MMSEinDelor_2013_alzheimer.R.
Errata and internal inconsistencies in the published tables
-
EC50units. Table 1 labels p-tau181EC50 = 31.4as “mg/mL”. Taken literally the drug effect would be about 0.2% at clinical exposures and p-tau181 would not move at all; read as ug/mL the model reproduces the paper’s own 3.37 -> 2.80 pg/mL trajectory to within 1% (see Validation 2). The model encodes ug/mL. -
GFAP
KinGversus the stated GFAP half-life. Table 1 reportsKinG = 5.6 pg/mL/h, and the text definesKoutG = KinG / BGFAP, givingKoutG = 0.0178 /h(half-life 39 h). Section 3.2.3 separately states “The typical t1/2 of plasma GFAP to return to baseline following lecanemab treatment discontinuation was estimated to be 1.7 years”, which would requireKoutG = 0.408 /yearand henceKinG = 128 pg/mL/year, a value 23-fold different in any consistent time unit. The two statements cannot both hold. The tabulatedKinG = 5.6 pg/mL/his encoded, because it is the parameter table and because it reproduces the paper’s own quantitative GFAP trajectory (316 -> 275 pg/mL at 18 months; the model gives 315 -> 269, within 2.2%), whereasKoutG = 0.408 /yearwould require a relative plaque reduction above 100% to reach 275 pg/mL and is therefore arithmetically excluded. The consequence is that the packaged model returns toward baseline faster than 1.7 years after discontinuation. -
GFAP proportional residual error. Table 1 reports
0.193under a “%CV” heading; taken literally that is 0.193%, which is implausible for a Simoa biomarker assay. The table footnote defines proportional error %CV as “square root of variance x 100”, so0.193is the standard deviation, i.e. 19.3%.propSd = 0.193is encoded. -
Figure 4A is not self-consistent about the PK covariate
model. The two covariates in the Abeta42/40 forest panel that
also act on lecanemab PK – sex and body weight – are the only
two the packaged model fails to reproduce, and they fail in opposite
directions.
- Age, Japanese race and APOE4 do reproduce: 83 years gives 1.212 against a plotted 1.21, 57 years gives 0.769 against 0.77, Japanese 1.037 against 1.03, APOE4 carrier 1.014 against 0.99. This confirms the tabulated baseline and slope coefficients and the 72-year centring.
- Sex: the packaged model gives 1.25 against a plotted 1.01. A female’s clearance is 0.791 times a male’s (Majid 2024), which raises steady-state exposure – and hence the Abeta42/40 change from baseline – by about 26%. The panel shows no such effect, i.e. it behaves as though the PK covariate model were switched off.
-
Body weight: the packaged model gives 1.044 (97.5 kg) and
0.890 (48.2 kg) against plotted 1.18 and 0.78. The plotted values are
instead reproduced to within 0.01 by the PK-exposure effect of weight
alone: a 10 mg/kg dose with
CLproportional toWT^0.353makes steady-state exposure proportional toWT^0.647, giving 1.22 and 0.77. That is, the weight row behaves as though only the PK covariate model were on and the tabulatedWeight on slope (exponential) = -0.00602had not been applied.
exp(theta * (COV - reference)), the form the neighbouringAge on slope (exponential) = 0.0193row unambiguously takes and which that row confirms numerically. Both discrepancies are reported in the Figure 4A comparison above and pinned by assertions. - Figure 4A 48.2 kg annotation. The panel prints “0.7 [0.73, 0.84]”, a point estimate outside its own confidence interval. The interval midpoint (0.78) is used as the reference value above.
Modelling choices
-
Time unit. All three models run in hours so the
inherited PK parameters (L/h) are used exactly as published.
Kout,DPand the plaqueKout, reported per year, are divided by 8766 h/year insidemodel(). - Infusion duration. Neither Bhagunde 2026 nor Majid 2024 states the zero-order input duration. A 1-hour infusion is used in this vignette. It has no material effect on biomarkers whose turnover half-lives are months to years.
-
Cccarries no residual error. Bhagunde 2026 drove the PD models with post hoc individual PK predictions, soCcis encoded as an algebraic individual prediction and no PK residual-error model is attached. The lecanemab PK residual error reported by Majid 2024 (proportional 21.0 %CV, additive SD 1.12 ug/mL) is therefore deliberately not carried. - Plaque layer variability. The upstream amyloid-PET model estimates IIV on baseline plaque, on an additive “amyloid floor” term and on DESLP. Because Bhagunde 2026 used the plaque model only as a predictor of GFAP production, the plaque layer is encoded as a typical-value trajectory with no IIV, and the amyloid floor (typical value fixed to 0 in the upstream paper) is omitted.
-
Paper-specific output states. The three observation
variables (
abeta4240,ptau181,gfap) and theplaquestate are declared throughpaper_specific_compartments, followingvanMaanen_2025_amyloid.R.checkModelConventions()raises one warning per model that the single-output observation variable is not a registered canonical; that is the same accepted warningvanMaanen_2025_amyloid.Rcarries forplaque. - Virtual cohort covariates are illustrative. Bhagunde 2026 does not tabulate baseline demographics (Appendix A refers to the study-design publications). The distributions used for the stochastic cohort figure are chosen to be plausible for an early-Alzheimer’s trial population and are not taken from the paper; none of the numeric assertions in this vignette depends on them, which all use the reference subject.
-
ADA status.
ADA_POSis held at 0 throughout. Lecanemab ADA incidence is not reported in this paper, and the effect on CL is a 13% increase, well inside the covariate bands examined above.