Methotrexate (Zhao 2025)
Source:vignettes/articles/Zhao_2025_methotrexate.Rmd
Zhao_2025_methotrexate.RmdModel and source
- Citation: Zhao J, Wu R, Zhang S, Lu Q, Wang R, He Y, Zhao Z, Mei S (2025). Population pharmacokinetic model of high-dose methotrexate in Chinese patients with intracranial germ cell tumors. Front Pharmacol 16:1548203. doi:10.3389/fphar.2025.1548203.
- Description: Two-compartment population PK model for high-dose intravenous methotrexate (HD-MTX, >= 0.5 g/m^2; standard 1.3 g/m^2 given as a 1 h infusion of one third of the dose followed by an 11 h infusion of the remainder) in Chinese patients with intracranial germ cell tumors (Zhao 2025; n = 505 patients, 73.3% children, 5,470 serum concentrations). Linear first-order elimination from the central compartment. Clearance is a product of four power-form covariate terms normalized to the cohort medians – BSA-normalized eGFR (exponent 0.23), body weight (0.39), total bilirubin (-0.05) and serum albumin (-0.18) – and an exponential bleomycin co-medication term that raises CL 1.08-fold; the central volume carries a body-weight power term (exponent 0.31). Peripheral volume and intercompartmental clearance were fixed at the base-model estimates. Exponential between-subject variability on CL and Vc and a proportional residual error.
- Article: https://doi.org/10.3389/fphar.2025.1548203
No supplementary material accompanies this article; the model is fully specified in the main text (Equations 5-8 and Table 3).
Population
Zhao 2025 is the first published population pharmacokinetic model of methotrexate in intracranial germ cell tumors (iGCTs). It is built on retrospective therapeutic-drug-monitoring data from 505 patients treated at Beijing Puren Hospital between February 2015 and July 2018, contributing 5,470 serum methotrexate concentrations. Children make up 73.3% of the cohort; the median age is 14 years (range 3-48) and the median body weight 47 kg (range 14-121). Baseline characteristics come from Table 1: height 155 cm (103-194), BSA 1.42 m^2 (0.63-2.47, Mosteller), eGFR 102.20 mL/min/1.73 m^2 (41.57-446.22), serum creatinine 62 umol/L (9-147), albumin 40.90 g/L (30.60-52.30) and total bilirubin 15.30 umol/L (3.40-78.50). eGFR was computed with the 2008 bedside Schwartz equation in children and the 2021 CKD-EPI equation in adults.
Patients received methotrexate and vincristine on day 1, bleomycin on day 2 and cisplatin on day 3 or 4. The standard methotrexate dose is 1.3 g/m^2, given as a 1 h infusion of one third of the dose followed by an 11 h infusion of the remaining two thirds; calcium folinate rescue (13 mg/m^2, five doses every 6 h) starts 12 h after methotrexate discontinuation. Concomitant bleomycin was recorded on 58.67% of the concomitant-medication records and is the only co-medication retained in the final model.
The same information is available programmatically via the model’s
population metadata
(readModelDb("Zhao_2025_methotrexate")()$population).
Source trace
The per-parameter origin is recorded as an in-file comment next to
each ini() entry in
inst/modeldb/specificDrugs/Zhao_2025_methotrexate.R. The
table below collects them in one place for review.
| Equation / parameter | Value | Source location |
|---|---|---|
| Two-compartment, first-order elimination | n/a | Results 3.2, first sentence |
lcl (CL) |
12.88 L/h | Table 3, Final model; Eq. 5 |
lvc (Vc) |
72.04 L | Table 3, Final model; Eq. 6 |
lq (Q), fixed |
1.08 L/h | Table 3 “Q (L/h) 1.08 Fixed”; Eq. 7 |
lvp (Vp), fixed |
94.94 L | Table 3 “Vp (L) 94.94 Fixed”; Eq. 8 |
e_crcl_cl (eGFR on CL) |
0.23 | Table 3 “eGFR on CL”; Eq. 5 |
e_wt_cl (BW on CL) |
0.39 | Table 3 “BW on CL”; Eq. 5 |
e_bleomycin_cl (BLM on CL) |
0.08 | Table 3 “BLM on CL”; Eq. 5 |
e_tbili_cl (TBIL on CL) |
-0.05 | Table 3 “TBIL on CL”; Eq. 5 |
e_alb_cl (ALB on CL) |
-0.18 | Table 3 “ALB on CL”; Eq. 5 |
e_wt_vc (BW on Vc) |
0.31 | Table 3 “BW on V (L)”; Eq. 6 |
| Reference eGFR / BW / TBIL / ALB | 102.2 / 47 / 15.3 / 40.9 | Eqs. 5-6; Table 1 medians; Results 3.2 |
etalcl |
2.98 -> omega^2 = 0.0298 (CV 17.4%) | Table 3 “IIV CL(CV%)”, Final model |
etalvc |
0.32 -> omega^2 = 0.0032 (CV 5.7%) | Table 3 “IIV Vc(CV%)”, Final model |
| Exponential IIV form | n/a | Methods 2.5.1 Eq. 1 |
propSd |
0.37 | Table 3 “sigma (proportional)”; Methods 2.5.1 Eq. 3 |
| Dosing regimen | 1.3 g/m^2, 1/3 over 1 h then 2/3 over 11 h | Methods 2.1 |
| Sampling grid | every 12 h to 108 h | Methods 2.3; Figure 1c x-axis |
| Concentration units | umol/L | Methods 2.3; Table 1 |
Virtual cohort
The original patient-level data are not publicly available. The
cohort below is a 200-subject virtual population whose covariate
distributions approximate the Table 1 marginals. Body weight is drawn
log-normally with the Table 1 median; height is derived from weight with
a power relation anchored on the Table 1 median pair (47 kg, 155 cm);
BSA follows from the Mosteller equation, which the paper states was
used. That construction is self-checking: the Table 1 medians are
exactly Mosteller-consistent,
sqrt(155 * 47 / 3600) = 1.423 m^2 against the reported
median BSA of 1.42 m^2, as are the range minima
(sqrt(103 * 14 / 3600) = 0.633 against the reported
0.63).
set.seed(20250502)
n_sub <- 200L # cap is 200 participants per arm
MW_MTX <- 454.44 # g/mol; external chemical constant (PubChem CID 126941),
# used only to convert the g/m^2 protocol dose into the
# umol amount units the model works in.
clamp <- function(x, lo, hi) pmin(pmax(x, lo), hi)
cohort <- tibble(
id = seq_len(n_sub),
# Body weight: Table 1 median 47 kg, range 14-121.
WT = clamp(47 * exp(rnorm(n_sub, 0, 0.38)), 14, 121)
) |>
mutate(
# Height anchored on the Table 1 median pair (47 kg, 155 cm), range 103-194.
HT = clamp(155 * (WT / 47)^0.29 * exp(rnorm(n_sub, 0, 0.05)), 103, 194),
# Mosteller (Methods 2.2). Table 1 median 1.42 m^2, range 0.63-2.47.
BSA = sqrt(HT * WT / 3600),
# Table 1 median 102.20 mL/min/1.73 m^2, range 41.57-446.22.
CRCL = clamp(102.2 * exp(rnorm(n_sub, 0, 0.36)), 41.57, 446.22),
# Table 1 median 15.30 umol/L, range 3.40-78.50.
TBILI = clamp(15.3 * exp(rnorm(n_sub, 0, 0.55)), 3.4, 78.5),
# Table 1 median 40.90 g/L, range 30.60-52.30.
ALB = clamp(rnorm(n_sub, 40.9, 3.8), 30.6, 52.3),
# Table 1: bleomycin on 58.67% of the concomitant-medication records.
CONMED_BLEOMYCIN = rbinom(n_sub, 1, 0.5867),
arm = ifelse(CONMED_BLEOMYCIN == 1, "with bleomycin", "no bleomycin"),
# Methods 2.1: standard dose 1.3 g/m^2, one third over 1 h then two thirds
# over 11 h.
dose_umol = 1.3 * BSA / MW_MTX * 1e6
)
# The virtual cohort must reproduce the Table 1 marginals it was built from.
stopifnot(
abs(median(cohort$WT) - 47) < 4,
abs(median(cohort$HT) - 155) < 8,
abs(median(cohort$BSA) - 1.42) < 0.10,
abs(median(cohort$CRCL) - 102.2) < 10,
abs(median(cohort$TBILI)- 15.3) < 2,
abs(median(cohort$ALB) - 40.9) < 1.5,
all(cohort$WT >= 14, cohort$WT <= 121),
all(cohort$BSA >= 0.63, cohort$BSA <= 2.56)
)
cohort |>
summarise(
across(c(WT, HT, BSA, CRCL, TBILI, ALB),
~ sprintf("%.2f (%.2f-%.2f)", median(.x), min(.x), max(.x)))
) |>
pivot_longer(everything(), names_to = "Covariate", values_to = "Simulated median (range)") |>
mutate(`Zhao 2025 Table 1 median (range)` = c(
"47.00 (14.00-121.00)", "155.00 (103.00-194.00)", "1.42 (0.63-2.47)",
"102.20 (41.57-446.22)", "15.30 (3.40-78.50)", "40.90 (30.60-52.30)"
)) |>
knitr::kable(caption = "Virtual cohort covariates against the Zhao 2025 Table 1 marginals.")| Covariate | Simulated median (range) | Zhao 2025 Table 1 median (range) |
|---|---|---|
| WT | 46.79 (14.03-121.00) | 47.00 (14.00-121.00) |
| HT | 155.14 (113.39-194.00) | 155.00 (103.00-194.00) |
| BSA | 1.44 (0.68-2.55) | 1.42 (0.63-2.47) |
| CRCL | 99.08 (41.57-368.56) | 102.20 (41.57-446.22) |
| TBILI | 15.53 (3.40-74.70) | 15.30 (3.40-78.50) |
| ALB | 40.40 (30.60-51.90) | 40.90 (30.60-52.30) |
The event table gives each subject the two-stage infusion.
Observation records go on the central ODE state;
rxode2 returns the algebraic observable Cc at
those records. A dense grid out to 336 h supports the non-compartmental
analysis (the model’s terminal half-life is roughly 66 h, so 108 h of
clinical sampling alone would not anchor an extrapolated AUC).
obs_times <- sort(unique(c(seq(0, 48, by = 0.25), seq(48, 336, by = 2))))
tdm_times <- seq(12, 108, by = 12) # Methods 2.3 / Figure 1c: 12 h spacing
dose_rows <- bind_rows(
cohort |> transmute(id, time = 0, evid = 1L, amt = dose_umol / 3, dur = 1),
cohort |> transmute(id, time = 1, evid = 1L, amt = 2 * dose_umol / 3, dur = 11)
) |>
mutate(cmt = "central")
obs_rows <- tidyr::expand_grid(id = cohort$id, time = obs_times) |>
mutate(evid = 0L, amt = NA_real_, dur = NA_real_, cmt = "central")
events <- bind_rows(dose_rows, obs_rows) |>
left_join(cohort |> select(id, WT, BSA, CRCL, TBILI, ALB, CONMED_BLEOMYCIN, arm, dose_umol),
by = "id") |>
arrange(id, time, desc(evid))
stopifnot(!anyDuplicated(unique(events[, c("id", "time", "evid")])))Simulation
mod <- readModelDb("Zhao_2025_methotrexate")
sim <- rxode2::rxSolve(mod, events = events, keep = c("arm", "dose_umol")) |>
as.data.frame()
#> ℹ parameter labels from comments will be replaced by 'label()'
# rxSolve silently drops subjects on a bad event table -- assert the count.
stopifnot(dplyr::n_distinct(sim$id) == n_sub)Cc is the individual prediction; the sim
column additionally carries the 37% proportional residual error and is
therefore the column to compare against observed concentrations
from the paper’s figures. Deterministic replication of the published
typical-value equations uses rxode2::zeroRe().
Replicate published figures
Concentration-time profiles on the clinical sampling grid
Zhao 2025 does not publish a concentration-time figure with readable axes, but Figure 1a (observed versus population-predicted concentration, log-log) and Figure 1c (CWRES versus time after dose) between them pin the sampling design and the concentration range: Figure 1c shows dense observation stripes at 12 h intervals from 12 h to about 108 h after dose, and Figure 1a shows three dominant observation clusters spanning roughly 10-60, 0.3-10 and 0.03-2 umol/L. The panel below is the simulated equivalent.
# rxSolve output carries no evid column -- the returned rows ARE the
# observation records, so select the TDM grid by time alone.
sim_tdm <- sim |>
filter(time %in% tdm_times)
# Pooled concentration statistics have to match the paper's own aggregation, and
# the paper's 5,470 records are NOT evenly spread over the sampling grid. The
# Discussion quantifies the drop-off: "records available for 53.07% at 48 h,
# 26.53% at 60 h, and only 1.39% at 72 h". The three earliest times are taken as
# complete -- Figure 1c shows them as the three densest stripes. Times past 72 h
# are excluded from the pooled statistics because the paper quantifies no
# availability for them.
tdm_availability <- tibble(
time = c(12, 24, 36, 48, 60, 72),
availability = c(1, 1, 1, 0.5307, 0.2653, 0.0139)
)
set.seed(1548203)
sim_tdm_realistic <- sim_tdm |>
inner_join(tdm_availability, by = "time") |>
filter(runif(n()) < availability)
# A 37% proportional error on the linear scale can draw a negative value, which
# no assay can report. Those draws are dropped the way an analytical run would
# drop them; the fraction must stay small or the error model is being misused.
n_before <- nrow(sim_tdm_realistic)
sim_tdm_realistic <- sim_tdm_realistic |> filter(sim > 0)
stopifnot((n_before - nrow(sim_tdm_realistic)) / n_before < 0.02)
sim_tdm |>
ggplot(aes(factor(time), sim)) +
geom_boxplot(outlier.size = 0.4, fill = "grey85") +
scale_y_log10(limits = c(0.01, 100)) +
labs(
x = "Time after start of infusion (h)",
y = "Simulated serum methotrexate (umol/L)",
title = "Simulated concentrations on the Zhao 2025 sampling grid",
caption = paste(
"Sampling times read off Figure 1c of Zhao 2025; concentration range",
"comparable with Figure 1a. Includes the 37% proportional residual error."
)
)
#> Warning in transformation$transform(x): NaNs produced
#> Warning in scale_y_log10(limits = c(0.01, 100)): log-10 transformation
#> introduced infinite values.
#> Warning: Removed 16 rows containing non-finite outside the scale range
#> (`stat_boxplot()`).
typ |>
ggplot(aes(time, Cc, group = id, colour = arm)) +
geom_line(alpha = 0.25) +
scale_y_log10(limits = c(0.01, 100)) +
scale_colour_manual(values = c("no bleomycin" = "#3b6ea5", "with bleomycin" = "#b3542f")) +
labs(
x = "Time after start of infusion (h)", y = "Typical-value Cc (umol/L)",
colour = "Co-medication",
title = "Typical-value profiles (between-subject variability zeroed)",
caption = "Spread comes entirely from the covariate distribution and the BSA-scaled dose."
)
#> Warning in scale_y_log10(limits = c(0.01, 100)): log-10 transformation
#> introduced infinite values.
Covariate model, Equations 5 and 6
Regressing the simulated individual clearances on the covariates recovers the published exponents exactly, because the covariate model is a pure product of power terms.
# rxSolve returns the model covariates alongside the derived individual
# parameters, so one row per subject is all that is needed -- no join back to
# `cohort`, which would collide on the shared covariate column names.
per_subject <- sim |>
group_by(id) |>
slice(1) |>
ungroup()
# The regression is run on the zeroRe solution, where cl and vc are exact
# deterministic functions of the covariates. Running it on the IIV-bearing
# solution would only recover the exponents to within the eta noise.
per_subject_typ <- typ |>
group_by(id) |>
slice(1) |>
ungroup()
stopifnot(nrow(per_subject) == n_sub, nrow(per_subject_typ) == n_sub,
all(c("cl", "vc", "CRCL", "WT", "TBILI", "ALB", "CONMED_BLEOMYCIN") %in%
names(per_subject_typ)))
fit_cl <- lm(log(cl) ~ log(CRCL) + log(WT) + log(TBILI) + log(ALB) + CONMED_BLEOMYCIN,
data = per_subject_typ)
fit_vc <- lm(log(vc) ~ log(WT), data = per_subject_typ)
recovered <- tibble(
Term = c("eGFR on CL", "BW on CL", "TBIL on CL", "ALB on CL",
"BLM on CL", "BW on Vc"),
Published = c(0.23, 0.39, -0.05, -0.18, 0.08, 0.31),
Recovered = c(
unname(coef(fit_cl)[["log(CRCL)"]]), unname(coef(fit_cl)[["log(WT)"]]),
unname(coef(fit_cl)[["log(TBILI)"]]), unname(coef(fit_cl)[["log(ALB)"]]),
unname(coef(fit_cl)[["CONMED_BLEOMYCIN"]]), unname(coef(fit_vc)[["log(WT)"]])
)
)
# Exact recovery: the covariate model is a pure product of power terms, so a
# log-log regression on the typical-value solution returns Table 3 to machine
# precision.
stopifnot(max(abs(recovered$Recovered - recovered$Published)) < 1e-8)
recovered |>
mutate(across(c(Published, Recovered), ~ round(.x, 4))) |>
rename("Zhao 2025 Table 3" = Published, "Recovered from simulation" = Recovered) |>
knitr::kable(caption = "Covariate exponents recovered from the packaged model (Eqs. 5-6).")| Term | Zhao 2025 Table 3 | Recovered from simulation |
|---|---|---|
| eGFR on CL | 0.23 | 0.23 |
| BW on CL | 0.39 | 0.39 |
| TBIL on CL | -0.05 | -0.05 |
| ALB on CL | -0.18 | -0.18 |
| BLM on CL | 0.08 | 0.08 |
| BW on Vc | 0.31 | 0.31 |
The bleomycin term is the paper’s headline covariate finding. Zhao
2025 states that co-administration raises clearance 1.08-fold; the model
reproduces exp(0.08) exactly.
# cl is a deterministic product of the covariate terms times exp(etalcl), so the
# regression coefficient on the indicator isolates the bleomycin term exactly.
blm_ratio <- exp(coef(fit_cl)[["CONMED_BLEOMYCIN"]])
stopifnot(abs(blm_ratio - exp(0.08)) < 1e-8)
sprintf("CL ratio with vs without bleomycin: %.4f (published 1.08-fold)", blm_ratio)
#> [1] "CL ratio with vs without bleomycin: 1.0833 (published 1.08-fold)"PKNCA validation
Non-compartmental analysis is run on Cc (the individual
prediction), stratified by bleomycin co-medication. Zhao 2025 reports no
NCA parameters of its own, so the NCA here serves as an
internal-consistency check of the packaged model against the analytic
identities implied by Equations 5-8.
sim_nca <- sim |>
filter(!is.na(Cc)) |>
select(id, time, Cc, arm)
# Guarantee a time = 0 row per (id, arm); an intravenous infusion starting at
# time 0 has Cc = 0 there.
sim_nca <- bind_rows(
sim_nca,
sim_nca |> distinct(id, arm) |> mutate(time = 0, Cc = 0)
) |>
distinct(id, arm, time, .keep_all = TRUE) |>
arrange(id, arm, time)
conc_obj <- PKNCA::PKNCAconc(sim_nca, Cc ~ time | arm + id)
# One total-dose row per subject; the two infusion records are aggregated
# because PKNCA uses the dose only for dose-normalised parameters.
dose_df <- events |>
filter(evid == 1) |>
group_by(id, arm) |>
summarise(time = 0, amt = sum(amt), .groups = "drop")
dose_obj <- PKNCA::PKNCAdose(dose_df, amt ~ time | arm + id)
intervals <- data.frame(
start = 0, end = Inf,
cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE, auclast = TRUE, half.life = TRUE
)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))Structural identities
For a linear model with first-order elimination,
AUC(0-inf) = Dose / CL holds for every individual, and the
terminal half-life must equal the analytic two-compartment beta
half-life computed from that subject’s own micro-constants. Both are
tested per subject rather than on a cohort median, which would hide
subject-level errors behind aggregation noise.
nca_wide <- as.data.frame(nca_res) |>
select(id, arm, PPTESTCD, PPORRES) |>
pivot_wider(names_from = PPTESTCD, values_from = PPORRES)
ident <- per_subject |>
select(id, cl, vc, q, vp) |>
left_join(cohort |> select(id, dose_umol), by = "id") |>
left_join(nca_wide, by = "id") |>
mutate(
auc_expected = dose_umol / cl,
auc_relerr = abs(aucinf.obs - auc_expected) / auc_expected,
kel = cl / vc, k12 = q / vc, k21 = q / vp,
beta = ((kel + k12 + k21) - sqrt((kel + k12 + k21)^2 - 4 * kel * k21)) / 2,
thalf_expected = log(2) / beta,
thalf_relerr = abs(half.life - thalf_expected) / thalf_expected
)
stopifnot(
# Both identities must be evaluable for every subject -- an NA from a failed
# lambda-z fit would silently pass a max() with na.rm = TRUE.
!anyNA(ident$auc_relerr),
!anyNA(ident$thalf_relerr),
nrow(ident) == n_sub,
max(ident$auc_relerr) < 0.01, # AUC(0-inf) == Dose / CL, per subject
max(ident$thalf_relerr) < 0.05 # terminal half-life == analytic beta half-life
)
tibble(
Identity = c("AUC(0-inf) = Dose / CL", "t1/2,z = ln(2) / beta"),
`Worst per-subject relative error` = sprintf(
"%.3f%%", 100 * c(max(ident$auc_relerr), max(ident$thalf_relerr))
)
) |>
knitr::kable(caption = "Per-subject structural identities across all 200 simulated subjects.")| Identity | Worst per-subject relative error |
|---|---|
| AUC(0-inf) = Dose / CL | 0.016% |
| t1/2,z = ln(2) / beta | 0.627% |
NCA summary by co-medication arm
nca_summary <- nca_wide |>
group_by(arm) |>
summarise(
n = n(),
cmax = median(cmax), tmax = median(tmax),
aucinf.obs = median(aucinf.obs), half.life = median(half.life),
.groups = "drop"
)
nca_summary |>
mutate(across(c(cmax, tmax, aucinf.obs, half.life), ~ signif(.x, 4))) |>
rename(
"Co-medication" = arm,
"N" = n,
"Cmax (umol/L)" = cmax,
"Tmax (h)" = tmax,
"AUC0-inf (umol*h/L)" = aucinf.obs,
"t1/2,z (h)" = half.life
) |>
knitr::kable(caption = "Simulated NCA medians by bleomycin co-medication arm.")| Co-medication | N | Cmax (umol/L) | Tmax (h) | AUC0-inf (umol*h/L) | t1/2,z (h) |
|---|---|---|---|---|---|
| no bleomycin | 76 | 18.43 | 12 | 325.9 | 66.39 |
| with bleomycin | 124 | 17.71 | 1 | 289.3 | 65.75 |
The median Tmax differs between the arms, and that is a real consequence of the protocol’s split infusion rather than an artefact. One third of the dose goes in over 1 h and the remaining two thirds over 11 h, so the first hour runs at about 5.5 times the rate of the following eleven. The profile therefore has two nearly equal peaks – one at the end of the fast infusion (1 h) and one at the end of the slow infusion (12 h) – and which is higher depends on the subject’s own CL/Vc ratio. Raising clearance depresses the long infusion’s plateau more than the short infusion’s spike, so the with-bleomycin arm tips towards the 1 h peak.
Bleomycin raises clearance, so the with-bleomycin arm should show a lower median AUC than the no-bleomycin arm once the arms are matched on dose. Because the two virtual arms differ in BSA-scaled dose by chance, the comparison below is made on dose-normalised AUC.
auc_norm <- ident |> # `arm` already rode in from the PKNCA grouping
group_by(arm) |>
summarise(`Median dose-normalised AUC (h/L)` = median(aucinf.obs / dose_umol),
.groups = "drop")
stopifnot(
auc_norm$`Median dose-normalised AUC (h/L)`[auc_norm$arm == "with bleomycin"] <
auc_norm$`Median dose-normalised AUC (h/L)`[auc_norm$arm == "no bleomycin"]
)
auc_norm |>
mutate(`Median dose-normalised AUC (h/L)` = signif(`Median dose-normalised AUC (h/L)`, 4)) |>
rename("Co-medication" = arm) |>
knitr::kable(caption = "Dose-normalised exposure is lower with bleomycin, as the positive CL coefficient requires.")| Co-medication | Median dose-normalised AUC (h/L) |
|---|---|
| no bleomycin | 0.08097 |
| with bleomycin | 0.07312 |
Comparison against published values
Zhao 2025 reports no non-compartmental parameters, so
nlmixr2lib::ncaComparisonTable() has nothing to compare
against. The table below instead pairs every published quantitative
anchor in the paper with its simulated counterpart.
typ_ref <- rxode2::rxSolve(
mod_typical,
events = tibble(
id = 1L, time = c(0, 0, 1, 12), evid = c(1L, 0L, 1L, 0L),
amt = c(1000, NA_real_, 2000, NA_real_),
dur = c(1, NA_real_, 11, NA_real_), cmt = "central",
CRCL = 102.2, WT = 47, TBILI = 15.3, ALB = 40.9, CONMED_BLEOMYCIN = 0
),
omega = NA, sigma = NA
) |>
as.data.frame()
pooled <- sim_tdm_realistic$sim
anchors <- tibble(
Quantity = c(
"Typical CL at the reference covariate set (L/h)",
"Typical Vc at 47 kg (L)",
"Q, fixed (L/h)",
"Vp, fixed (L)",
"CL fold-change with concomitant bleomycin",
"Pooled MTX concentration, median (umol/L)",
"Pooled MTX concentration, minimum (umol/L)",
"Pooled MTX concentration, maximum (umol/L)"
),
`Zhao 2025` = c("12.88", "72.04", "1.08", "94.94", "1.08",
"1.2", "0.01", "55.80"),
Source = c("Table 3 / Eq. 5", "Table 3 / Eq. 6", "Table 3 / Eq. 7",
"Table 3 / Eq. 8", "Abstract; Discussion",
"Table 1", "Table 1", "Table 1"),
Simulated = c(
sprintf("%.2f", typ_ref$cl[1]),
sprintf("%.2f", typ_ref$vc[1]),
sprintf("%.2f", typ_ref$q[1]),
sprintf("%.2f", typ_ref$vp[1]),
sprintf("%.2f", blm_ratio),
sprintf("%.2f", median(pooled)),
sprintf("%.3f", min(pooled)),
sprintf("%.2f", max(pooled))
)
)
# The four structural parameters and the bleomycin ratio are exact identities.
stopifnot(
abs(typ_ref$cl[1] - 12.88) < 1e-6,
abs(typ_ref$vc[1] - 72.04) < 1e-6,
abs(typ_ref$q[1] - 1.08) < 1e-6,
abs(typ_ref$vp[1] - 94.94) < 1e-6
)
# The pooled concentration distribution must sit inside the Table 1 envelope and
# put its median within a factor of about two of the reported 1.2 umol/L. Exact
# agreement is not reachable: every simulated subject receives the protocol
# standard 1.3 g/m^2 whereas the real cohort only had to exceed 0.5 g/m^2, and
# the realised dose distribution is never reported. That biases the simulated
# distribution upwards.
stopifnot(
median(pooled) > 0.6, median(pooled) < 2.5,
max(pooled) < 55.80,
min(pooled) > 0.001
)
knitr::kable(
anchors,
caption = "Every quantitative anchor published in Zhao 2025 against its simulated counterpart.",
align = c("l", "r", "l", "r")
)| Quantity | Zhao 2025 | Source | Simulated |
|---|---|---|---|
| Typical CL at the reference covariate set (L/h) | 12.88 | Table 3 / Eq. 5 | 12.88 |
| Typical Vc at 47 kg (L) | 72.04 | Table 3 / Eq. 6 | 72.04 |
| Q, fixed (L/h) | 1.08 | Table 3 / Eq. 7 | 1.08 |
| Vp, fixed (L) | 94.94 | Table 3 / Eq. 8 | 94.94 |
| CL fold-change with concomitant bleomycin | 1.08 | Abstract; Discussion | 1.08 |
| Pooled MTX concentration, median (umol/L) | 1.2 | Table 1 | 0.81 |
| Pooled MTX concentration, minimum (umol/L) | 0.01 | Table 1 | 0.018 |
| Pooled MTX concentration, maximum (umol/L) | 55.80 | Table 1 | 44.59 |
For orientation, the Discussion compares this model with the two-compartment methotrexate model of Nader 2017 (CL 15.7 L/h, Vc 79.2 L, Q 0.97 L/h, Vp 51.4 L) and notes that “except for the typical value of Vp, which slightly falls outside the range of previous studies (51.4 L vs. 94.94 L), all other parameters were within the ranges of earlier models”.
Assumptions and deviations
-
The scale of the two IIV rows in Table 3 is ambiguous as printed, and is resolved here by a variance-decomposition identity internal to the paper. Table 3 labels its variability rows
IIV Vc(CV%)andIIV CL(CV%)and prints 0.32 and 2.98 for the final model (1.20 and 5.07 for the base model), with no RSE and no confidence interval on either row. The%in the header cannot be taken at face value, because the same table’s(%RSE)column verifiably holds fractions rather than percents: on all nine rows that carry a confidence interval, the CI-implied relative standard error reproduces the printed value only when read as a fraction (Vc 72.04 with CI 70.00-74.09 gives RSE 0.0145, printed0.01; TBIL -0.05 with CI -0.07 to -0.02 gives RSE -0.255, printed-0.27). Four readings are therefore on the table: the value is a CV in percent, a CV as a fraction,omega^2 x 100, oromega^2.The paper settles it. A covariate model can only explain variability that the base model’s IIV actually contained, so
omega^2(base) - omega^2(final)must be about equal to the variance the retained covariates inject into the parameter, and that quantity is computable from the Table 1 covariate ranges and the Eq. 5-6 exponents. Treating the Table 1 ranges as +-3.1 SD of a log-normal (n = 505), the covariates injectvar(log CL) = 0.0286andvar(log Vc) = 0.0116:Reading of the printed value Drop in CL Ratio Drop in Vc Ratio CV in percent 0.00168 0.06 0.00013 0.01 CV as a fraction 0.99429 34.77 0.79451 68.32 omega^2 x 1000.02090 0.73 0.00880 0.76 omega^22.09000 73.10 0.88000 75.67 Only
omega^2 x 100is the right order of magnitude, and it lands on the same ratio for CL and for Vc (0.73 and 0.76) even though the two parameters carry different covariate structures – a coincidence a wrong scale factor would not produce. The shared shortfall below 1.0 is what collinearity among BW, BSA and eGFR plus the range-to-SD conversion would predict. The other three readings miss by factors of 17 to 75. The visual predictive check (Figure 2) agrees: simulated 5th-to-95th percentile spans under this reading are about 1.2 decades against roughly 1.3 decades read off the published figure, whereas the “CV as a fraction” and “omega^2” readings both give more than 4 decades, and the “CV in percent” reading is indistinguishable from a model with no IIV at all.The model file therefore encodes
omega^2 = printed/100:etalcl ~ 0.0298(CV 17.4%) andetalvc ~ 0.0032(CV 5.7%). The base-model values then correspond to CVs of 22.8% and 11.0%, with the covariates absorbing the difference. These magnitudes are also in line with published methotrexate population PK models. This is an inference about the reporting scale, not a value printed in the paper, so anyone re-fitting the model should re-derive it. Sampling times are taken from Figure 1c, not from the Methods text. Methods 2.3 says blood was collected “24 h after the completion of each MTX infusion, followed by sampling at 12-h intervals up to a maximum of 108 h”, which would place the first sample at 36 h given the 12 h infusion. The x-axis of the Figure 1c goodness-of-fit panel instead shows dense observation stripes beginning at approximately 12 h after dose and repeating every 12 h to about 108 h, and the Discussion refers to record availability “at 48 h, 60 h and 72 h” in a day-relative sense consistent with timing from the start of dosing. The simulated grid follows the figure (12, 24, …, 108 h from the start of the infusion). The choice affects only which concentrations the figures display; it does not touch any model parameter.
Cohort covariate distributions are reconstructed from marginals. Table 1 reports medians and ranges only, with no standard deviations and no correlation structure. Body weight, eGFR and total bilirubin are drawn log-normally, and albumin normally, with dispersion chosen so the simulated ranges cover the reported ranges; all are clamped to those ranges. Height is derived from weight by a power relation anchored on the Table 1 median pair, and BSA follows from the Mosteller equation the paper used. eGFR, bilirubin and albumin are drawn independently of weight; in the real cohort they are certainly correlated with body size, particularly eGFR in a cohort that is 73.3% children.
Dose held at the protocol standard. Methods 2.1 gives a standard dose of 1.3 g/m^2 while the inclusion criterion is only “>= 0.5 g/m^2”, and the paper never reports the realised dose distribution. Every simulated subject receives 1.3 g/m^2. This inflates the simulated concentration distribution relative to the pooled Table 1 statistics, which is the main reason the pooled-median comparison is given a loose tolerance.
Record availability over the sampling grid is reconstructed. The pooled concentration statistics can only be compared with Table 1 if the simulated records are distributed over the sampling grid the way the real ones are, and they are not distributed evenly: the Discussion states that records were “available for 53.07% at 48 h, 26.53% at 60 h, and only 1.39% at 72 h”. Those three percentages are used directly; the three earlier times (12, 24, 36 h) are assumed complete, which is what the Figure 1c stripe densities show but is not stated numerically. Times past 72 h are excluded from the pooled statistics because the paper gives no availability for them; they still appear in the concentration-time figure.
A proportional error model on the linear scale can draw negative concentrations. Methods 2.5.1 Eq. 3 gives the residual model as
C_obs = C_pred * (1 + epsilon)with a 37% standard deviation, which produces a negative simulated concentration wheneverepsilon < -1. No assay can report one, so those draws are discarded before the pooled statistics are computed (the vignette asserts the discarded fraction stays under 2%). This is a property of the published error model, not of the extraction.Molar conversion uses an external constant. The paper reports doses in g/m^2 and concentrations in umol/L but never states the molecular weight used. The vignette converts with 454.44 g/mol (methotrexate, PubChem CID 126941). This constant is used only inside the vignette to build the event table; it is not a model parameter and does not appear in the model file.
Non-compartmental comparison is internal. The paper publishes no Cmax, Tmax, AUC or half-life, so there is nothing to compare the PKNCA output against externally. The NCA section instead asserts the analytic identities the model must satisfy (
AUC(0-inf) = Dose / CLand the two-compartment beta half-life) per subject, and the published-anchor table covers every numeric value the paper does report.Sex is inconsistently reported in the source. Table 1 gives “Sex (Male/Female) 357/148” while Results 3.1 gives “505 patients (357 females and 148 males)”. Sex is not a covariate in the final model, so the discrepancy has no effect on any equation; the
populationmetadata follows the Table 1 column header and records the conflict.Concomitant-medication percentages have an ambiguous denominator. Table 1 lists bleomycin as “941 (58.67%)” under “Concomitant medications”, and 941 is 58.67% of about 1,604 records rather than of the 505 patients, yet Results 3.1 states that “concurrent use of BLM was observed in 58.67% of patients”. The virtual cohort assigns bleomycin to 58.67% of subjects, following the Results sentence.
No supplement, erratum or correction was found. A search of the Frontiers in Pharmacology article landing page and of PubMed for corrections to doi:10.3389/fphar.2025.1548203 returned nothing as of the extraction date; all values come from the main text.