Darbepoetin alfa in chemotherapy-induced anemia (Agoram 2006)
Source:vignettes/articles/Agoram_2006_darbepoetin_alfa_cia.Rmd
Agoram_2006_darbepoetin_alfa_cia.RmdModel and source
Agoram 2006 develops two models, fitted sequentially, and nlmixr2lib ships them as two files so that each is usable on its own:
| Model | Role | Source table |
|---|---|---|
Agoram_2006_darbepoetin_alfa_cia |
Population PK of darbepoetin alfa in chemotherapy-induced anemia (CIA), freely estimated with its full random-effects structure | Table 3 |
Agoram_2006_darbepoetin_alfa_cia_hemoglobin |
PK/PD model of the hemoglobin response, with the PK layer held at the Table 3 fixed effects | Table 4 |
The paper is explicit that the second stage reuses the first: “the Pk
model consisted of fixed-effects parameters obtained from the full
covariate model; random IIV and RRV in the Pk model were ignored”. The
PD file therefore wraps every PK entry in fixed() and
carries no PK etas – that is the model the authors actually fitted, not
a simplification introduced here.
- Citation: Agoram B, Heatherington AC, Gastonguay MR. Development and evaluation of a population pharmacokinetic-pharmacodynamic model of darbepoetin alfa in patients with nonmyeloid malignancies undergoing multicycle chemotherapy. The AAPS Journal. 2006;8(3):E552-E563. doi:10.1208/aapsj080364
- Article: https://doi.org/10.1208/aapsj080364
- PubMed Central: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC2761062/
mod_pk <- readModelDb("Agoram_2006_darbepoetin_alfa_cia")
mod_pd <- readModelDb("Agoram_2006_darbepoetin_alfa_cia_hemoglobin")
ui_pk <- rxode2::rxode(mod_pk)
#> ℹ parameter labels from comments will be replaced by 'label()'
ui_pd <- rxode2::rxode(mod_pd)
#> ℹ parameter labels from comments will be replaced by 'label()'PK model – Two-compartment population PK model with first-order subcutaneous absorption for darbepoetin alfa in adults with nonmyeloid malignancies and chemotherapy-induced anemia (Agoram 2006, AAPS J). Both IV and SC routes are supported. Body weight modifies clearance and central volume via a normalized power model (reference 70 kg), and receiving more than two cycles of platinum-containing chemotherapy during the PK assessment multiplies clearance by 0.737. Total measured serum concentration is the sum of the simulated darbepoetin alfa and an individual-specific endogenous-erythropoietin (eEPO) constant that the ELISA assay cross-detects. Exponential (log-normal) residual error. This is the PK layer of the paper’s sequentially fitted PK/PD analysis; the hemoglobin response model is Agoram_2006_darbepoetin_alfa_cia_hemoglobin.
PK/PD model – Sequentially fitted PK/PD model of the hemoglobin response to darbepoetin alfa in adults with nonmyeloid malignancies and chemotherapy-induced anemia (Agoram 2006, AAPS J). The two-compartment PK layer is carried at the fixed-effect estimates of the full covariate PK model (Agoram_2006_darbepoetin_alfa_cia) with its interindividual and residual variability deliberately dropped, exactly as the paper fitted the PD stage. Serum darbepoetin alfa concentration stimulates a zero-order hemoglobin production rate through an Emax-type term (incremental Smax = 43.7%, S50 = 3.68 ng/mL), feeding a Friberg-style catenary chain: one progenitor transit compartment of mean transit time RBCPT = 4.68 days followed by four equal-transit-time mature red-cell lifespan compartments summing to RBCLS = 82.2 days. Total hemoglobin is the sum of the four lifespan compartments. Receiving any concomitant platinum-containing chemotherapy multiplies S50 by 1.86. Additive residual error on hemoglobin.
Note that a different Agoram 2006 paper is also in the
library: Agoram_2006_darbepoetin_alfa is the
healthy-subject popPK model from Br J Clin Pharmacol
63(1):41-52. Same first author, same year, same drug, different study
population – hence the _cia suffix here.
Population
The PK model was built on 140 patients and 1442 serum darbepoetin alfa concentrations pooled from two Amgen studies (990146, n = 56, IV or SC 2.25 ug/kg QW; 20010162, n = 84, SC 6.75 ug/kg Q3W). The PK/PD model was built on a larger, partly disjoint set of 573 patients and 6356 hemoglobin observations from studies 20010162, 980290 and 980291, spanning SC doses from 0.5 ug/kg QW to 15 ug/kg Q3W. Hemoglobin data from study 990146 were excluded from the PD analysis because its hemoglobin entry criterion differed substantially from the other studies (Agoram 2006 Table 1).
Patients were adults (>= 18 years) with nonmyeloid malignancies receiving cyclic chemotherapy, anemic as a result of cancer or chemotherapy, with ECOG performance status 0-2 and adequate renal and hepatic function. Baseline characteristics (Agoram 2006 Table 2) for the combined PK/PD set: 70% female, age mean 60.1 years (SD 12.3, range 20-91), body weight mean 69.9 kg (SD 16.2, range 39-136), baseline hemoglobin mean 9.96 g/dL (SD 1.00, range 5.5-12.3). Tumor types were breast 31%, lung 17%, gastrointestinal 17%, gynecologic 17%, genitourinary 7%, lymphoma 2%, other 9%. Race was screened as a covariate but is not tabulated in the publication.
The same information is available programmatically from either
model’s population metadata:
str(ui_pd$population, max.level = 1, give.attr = FALSE)
#> List of 14
#> $ species : chr "human"
#> $ n_subjects : int 573
#> $ n_studies : int 3
#> $ n_observations: int 6356
#> $ age_range : chr "20-91 years (combined PkPd data set; Agoram 2006 Table 2)"
#> $ age_median : chr "Mean 60.1 years, SD 12.3 (median not tabulated)"
#> $ weight_range : chr "39-136 kg (combined PkPd data set; Agoram 2006 Table 2)"
#> $ weight_median : chr "Mean 69.9 kg, SD 16.2 (median not tabulated)"
#> $ sex_female_pct: num 70
#> $ race_ethnicity: chr "Screened as a PD covariate but not tabulated in Agoram 2006."
#> $ disease_state : chr "Adults (>= 18 years) with nonmyeloid malignancies receiving cyclic chemotherapy, with chemotherapy-induced anem"| __truncated__
#> $ dose_range : chr "0.5 to 4.5 ug/kg SC QW, 3.0 to 9.0 ug/kg SC Q2W, 4.5 to 15 ug/kg SC Q3W and 9.0 to 18.0 ug/kg SC Q4W across stu"| __truncated__
#> $ regions : chr "Three Amgen-sponsored clinical studies (20010162, 980290, 980291); geographic sites not stated in the publication."
#> $ notes : chr "PkPd model development data set: 573 patients and 6356 hemoglobin observations pooled from Amgen studies 200101"| __truncated__Source trace
Every ini() entry in both model files carries an in-file
comment naming the table it came from. They are collected here for
review.
Structure
| Equation / structure | Source location |
|---|---|
Two-compartment disposition, first-order SC absorption,
bioavailability F
|
Figure 1A; Results, “Pharmacokinetic Model” |
| Total measured serum concentration = darbepoetin alfa + endogenous EPO (assay cross-reactivity) | Analytical Methods; Equation 2 |
Log-normal IIV, P_i = TVP * exp(eta)
|
Equation 1 |
| Log-transformed exponential (log-normal) PK residual error | Equation 2 |
| Normalized power / categorical covariate model | Equation 3 |
TVCL = theta_CL * theta_1^X * (BWT/70)^theta_2,
X = 1 if PCNT > 2 |
Equation 12 |
TVV1 = theta_V1 * (BWT/70)^theta_3 |
Equation 13 |
Indirect response, dHb/dt = Rin - Kout * Hb,
Hb(0) = Hb0, 0 = Rin - Kout * Hb0
|
Equations 4, 5, 6 |
dA1/dt = Rin * Stim - kPT * A1 (progenitor
compartment) |
Equation 7 |
Stim = 1 + Smax * C_DA / (S50 + C_DA) |
Equation 8 |
dA2/dt = kPT * A1 - kLS * A2;
dAi/dt = kLS * A(i-1) - kLS * Ai,
i = 3,4,5
|
Equations 9, 10 |
Hb = sum(Ai, i = 2..5) |
Equation 11 |
kPT = 1 / RBCPT, kLS = 4 / RBCLS
|
Text following Equation 6 |
TVS50 = theta_S50 * theta_4^X, X = 1 if
PCNT > 0 |
Equation 14 |
Parameters
| Parameter | Model file entry | Value | Source |
|---|---|---|---|
| CL | lcl |
2010 mL/day | Table 3 |
| V1 | lvc |
3390 mL | Table 3 |
| V2 | lvp |
251 mL | Table 3 |
| Q | lq |
2900 mL/day | Table 3 |
| Ka | lka |
0.318 /day | Table 3 |
| F | lfdepot |
0.443 | Table 3 |
| C0 (eEPO) | leepo |
0.415 ng/mL | Table 3 |
| theta_1 (PCNT on CL) | e_conmed_platin_gt2_cl |
0.737 | Table 3 |
| theta_2 (BWT on CL) | e_wt_cl |
0.623 | Table 3 |
| theta_3 (BWT on V1) | e_wt_vc |
0.639 | Table 3 |
| omega^2 CL, CL-V1, V1 |
etalcl + etalvc block |
0.181, 0.134, 0.225 | Table 3 |
| omega^2 Ka | etalka |
0.0883 | Table 3 |
| omega^2 C0 | etaleepo |
0.501 | Table 3 |
| sigma_1^2 | expSd |
0.483 (SD = 0.695) | Table 3 |
| RBCPT | lmtt |
4.68 day | Table 4 |
| RBCLS | lmtt_rbc |
82.2 day | Table 4 |
| Smax | lemax |
0.437 | Table 4 |
| S50 | lec50 |
3.68 ng/mL | Table 4 |
| Hb0 | lrbase_hb |
9.92 g/dL | Table 4 |
| theta_4 (PCNT on S50) | e_conmed_platin_ec50 |
1.86 | Table 4 |
| omega^2 RBCLS | etalmtt_rbc |
1.63 | Table 4 |
| omega^2 S50, S50-Hb0, Hb0 |
etalec50 + etalrbase_hb block |
8.05, 0.0539, 0.00720 | Table 4 |
| sigma_2^2 | addSd_hb |
0.401 (SD = 0.633 g/dL) | Table 4 |
Part 1 – Pharmacokinetics
Replicating Figure 2C
Figure 2C of Agoram 2006 overlays the mean model prediction on observed mean concentrations after a single 2.25 ug/kg IV dose, a single 2.25 ug/kg SC dose, and a single 6.75 ug/kg SC dose, over 21 days. The panel is a typical-value prediction, so between-subject variability is zeroed.
REF_WT <- 70 # reference / typical weight, Agoram 2006 Eq 12-13
pk_arms <- tibble::tribble(
~arm, ~dose_ug_kg, ~cmt,
"2.25 ug/kg (IV)", 2.25, "central",
"2.25 ug/kg (SC)", 2.25, "depot",
"6.75 ug/kg (SC)", 6.75, "depot"
)
# Dense early grid so Tmax and the alpha phase are resolved; 21 days matches
# the x-range of the published panel and keeps every concentration comfortably
# above solver round-off.
pk_times <- sort(unique(c(
seq(0, 0.5, by = 0.005), seq(0.5, 3, by = 0.02),
seq(3, 21, by = 0.1)
)))
make_pk_arm <- function(arm, dose_ug_kg, cmt, id) {
dplyr::bind_rows(
tibble(id = id, time = 0, amt = dose_ug_kg * REF_WT, evid = 1L, cmt = cmt),
# Observation rows point at the ODE state `central`, never at the
# algebraic observable `Cc`.
tibble(id = id, time = pk_times, amt = NA_real_, evid = 0L, cmt = "central")
) |>
dplyr::mutate(arm = arm, WT = REF_WT, CONMED_PLATIN_GT2 = 0)
}
pk_events <- dplyr::bind_rows(
lapply(seq_len(nrow(pk_arms)), function(i) {
make_pk_arm(pk_arms$arm[i], pk_arms$dose_ug_kg[i], pk_arms$cmt[i], id = i)
})
)
stopifnot(!anyDuplicated(unique(pk_events[, c("id", "time", "evid")])))
pk_typical <- rxode2::zeroRe(mod_pk)
#> ℹ parameter labels from comments will be replaced by 'label()'
sim_pk <- rxode2::rxSolve(
pk_typical, events = pk_events, keep = c("arm", "WT"),
omega = NA, sigma = NA, returnType = "data.frame"
)
# The ELISA cross-detects endogenous erythropoietin, so `Cc` is the TOTAL
# measured concentration (drug + eEPO). The drug-only profile is what the
# structural PK parameters describe, and it is what NCA must be run on -- an
# additive eEPO baseline that never decays would make AUCinf infinite.
sim_pk <- sim_pk |>
dplyr::filter(!is.na(Cc)) |>
dplyr::mutate(Cdrug = pmax(Cc - eepo, 0))
ggplot(sim_pk, aes(time, Cc, colour = arm, linetype = arm)) +
geom_line(linewidth = 0.7) +
scale_y_log10(limits = c(0.1, 100)) +
scale_x_continuous(breaks = seq(0, 22, by = 2)) +
labs(
x = "Time (days)", y = "Serum concentration (ng/mL)",
colour = NULL, linetype = NULL,
title = "Figure 2C -- typical-value profiles after a single dose",
caption = "Replicates Figure 2C of Agoram 2006 (mean predicted profiles)."
) +
theme_bw() + theme(legend.position = "top")
The published panel can be read off at a handful of landmarks. Digitised values below are approximate (gridline resolution of a log-scale figure), so they are checked with a tolerance rather than asserted exactly.
landmark <- function(arm_name, t) {
d <- sim_pk[sim_pk$arm == arm_name, ]
d$Cc[which.min(abs(d$time - t))]
}
fig2c <- tibble::tibble(
arm = c("2.25 ug/kg (IV)", "2.25 ug/kg (SC)", "6.75 ug/kg (SC)",
"6.75 ug/kg (SC)", "6.75 ug/kg (SC)"),
quantity = c("C at 5 min", "Cmax", "Cmax", "C at day 7", "C at day 21"),
digitised = c(46, 6, 16, 7, 0.55),
simulated = c(
landmark("2.25 ug/kg (IV)", 5 / 1440),
max(sim_pk$Cc[sim_pk$arm == "2.25 ug/kg (SC)"]),
max(sim_pk$Cc[sim_pk$arm == "6.75 ug/kg (SC)"]),
landmark("6.75 ug/kg (SC)", 7),
landmark("6.75 ug/kg (SC)", 21)
)
)
fig2c |>
dplyr::mutate(pct_diff = 100 * (simulated - digitised) / digitised) |>
dplyr::rename(
"Arm" = arm, "Landmark" = quantity,
"Digitised from Figure 2C (ng/mL)" = digitised,
"Simulated (ng/mL)" = simulated,
"Difference (%)" = pct_diff
) |>
knitr::kable(digits = 2, caption = "Figure 2C landmarks, digitised vs simulated.")| Arm | Landmark | Digitised from Figure 2C (ng/mL) | Simulated (ng/mL) | Difference (%) |
|---|---|---|---|---|
| 2.25 ug/kg (IV) | C at 5 min | 46.00 | 46.55 | 1.19 |
| 2.25 ug/kg (SC) | Cmax | 6.00 | 5.63 | -6.24 |
| 6.75 ug/kg (SC) | Cmax | 16.00 | 16.05 | 0.29 |
| 6.75 ug/kg (SC) | C at day 7 | 7.00 | 7.21 | 2.97 |
| 6.75 ug/kg (SC) | C at day 21 | 0.55 | 0.51 | -6.76 |
chk <- 100 * abs(fig2c$simulated - fig2c$digitised) / fig2c$digitised
# Deterministic (typical-value) solve, so this bound does not depend on a
# simulated cohort. 25% is the resolution of reading a 3-decade log axis; a
# mis-transcribed volume, clearance or bioavailability moves these landmarks
# by a factor, not by a quarter.
stopifnot(max(chk) < 25)PKNCA validation
NCA is run on the baseline-corrected (drug-only) profile of the
typical-value solve. Running it over a full-IIV cohort would be
misleading here: the half-life estimate is
lambda_z-selected per subject and returns NA
for subjects whose terminal phase is poorly resolved.
sim_nca <- sim_pk |>
dplyr::filter(!is.na(Cdrug)) |>
dplyr::select(id, time, arm, Cc = Cdrug)
# Guarantee a time = 0 record per (id, arm). For the SC arms the pre-dose
# drug concentration is genuinely 0; for the IV arm the t = 0 record already
# exists at the post-dose value and `distinct()` keeps it.
sim_nca <- dplyr::bind_rows(
sim_nca,
sim_nca |> dplyr::distinct(id, arm) |> dplyr::mutate(time = 0, Cc = 0)
) |>
dplyr::distinct(id, arm, time, .keep_all = TRUE) |>
dplyr::arrange(id, arm, time)
conc_obj <- PKNCA::PKNCAconc(as.data.frame(sim_nca), Cc ~ time | arm + id)
dose_df <- pk_events |>
dplyr::filter(evid == 1L) |>
dplyr::select(id, time, amt, arm)
dose_obj <- PKNCA::PKNCAdose(as.data.frame(dose_df), amt ~ time | arm + id)
intervals <- data.frame(
start = 0, end = Inf,
cmax = TRUE, tmax = TRUE, auclast = TRUE, aucinf.obs = TRUE,
half.life = TRUE, aucpext.obs = TRUE
)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))
nca_wide <- as.data.frame(nca_res) |>
dplyr::select(arm, PPTESTCD, PPORRES) |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES)
nca_wide |>
dplyr::select(arm, cmax, tmax, auclast, aucinf.obs, half.life, aucpext.obs) |>
dplyr::rename(
"Arm" = arm,
"Cmax (ng/mL)" = cmax,
"Tmax (day)" = tmax,
"AUC0-21d (ng*day/mL)" = auclast,
"AUC0-inf (ng*day/mL)" = aucinf.obs,
"t1/2 (day)" = half.life,
"AUC extrapolated (%)" = aucpext.obs
) |>
knitr::kable(digits = 3, caption = "PKNCA summary of the typical-value single-dose profiles.")| Arm | Cmax (ng/mL) | Tmax (day) | AUC0-21d (ng*day/mL) | AUC0-inf (ng*day/mL) | t1/2 (day) | AUC extrapolated (%) |
|---|---|---|---|---|---|---|
| 2.25 ug/kg (IV) | 46.460 | 0.00 | 78.358 | 78.358 | 1.259 | 0.001 |
| 2.25 ug/kg (SC) | 5.210 | 2.36 | 34.609 | 34.714 | 2.240 | 0.303 |
| 6.75 ug/kg (SC) | 15.631 | 2.36 | 103.827 | 104.143 | 2.240 | 0.303 |
Agoram 2006 reports no NCA table of its own – it notes only that the model parameters “generally agreed with previously reported noncompartmental values”. The NCA output is therefore checked against two identities that the structural model must satisfy exactly. These are deterministic consequences of a typical-value solve, not cohort statistics, so they are asserted tightly.
get_nca <- function(arm_name, param) {
v <- nca_wide[[param]][nca_wide$arm == arm_name]
if (length(v) != 1L) stop("no unique NCA row for arm '", arm_name, "' / ", param)
v
}
cl_typ <- 2.010 # L/day, Table 3, at WT = 70 kg, PCNT <= 2
dose_iv <- 2.25 * REF_WT # ug
auc_iv <- get_nca("2.25 ug/kg (IV)", "aucinf.obs")
auc_sc225 <- get_nca("2.25 ug/kg (SC)", "aucinf.obs")
identities <- tibble::tibble(
Identity = c(
"AUC0-inf (IV) = Dose / CL",
"AUC0-inf (SC) / AUC0-inf (IV) = F"
),
Expected = c(dose_iv / cl_typ, 0.443),
Observed = c(auc_iv, auc_sc225 / auc_iv)
) |>
dplyr::mutate(`Difference (%)` = 100 * (Observed - Expected) / Expected)
knitr::kable(identities, digits = 4, caption = "Closed-form identities the NCA output must satisfy.")| Identity | Expected | Observed | Difference (%) |
|---|---|---|---|
| AUC0-inf (IV) = Dose / CL | 78.3582 | 78.3583 | 0.0001 |
| AUC0-inf (SC) / AUC0-inf (IV) = F | 0.4430 | 0.4430 | 0.0048 |
# Both sides come from the same deterministic solve, so the only error is
# numerical (trapezoidal AUC plus lambda_z extrapolation). 1.5% has ample
# headroom over that and still fails on a transcribed CL or F.
stopifnot(max(abs(identities$`Difference (%)`)) < 1.5)
# Terminal extrapolation must be small enough that AUC0-inf is meaningful.
stopifnot(max(nca_wide$aucpext.obs) < 1)Both SC arms carry a terminal half-life longer than the IV arm (2.24
vs 1.26 days), which is the flip-flop behaviour Agoram 2006 attributes
to absorption rate-limited kinetics: Ka (0.318 /day) is
smaller than the disposition beta, so the SC terminal slope
is the absorption rate constant.
Part 2 – Hemoglobin response
Baseline is a true steady state
The paper writes the indirect-response baseline as
0 = Rin - Kout * Hb0 (Equation 6) but implements the
response as a transit chain, so Rin has to be recovered
from the chain’s own drug-free steady state. With
kPT = 1/RBCPT and kLS = 4/RBCLS, that state is
A1 = Rin/kPT and A2 = ... = A5 = Rin/kLS,
giving Hb0 = 4*Rin/kLS = Rin*RBCLS, hence
Rin = Hb0/RBCLS. If that derivation were wrong the model
would drift away from its own baseline with no drug on board – which is
exactly what this check tests.
ss_events <- tibble(
id = 1L, time = seq(0, 365, by = 7), amt = NA_real_, evid = 0L, cmt = "central",
WT = REF_WT, CONMED_PLATIN = 0, CONMED_PLATIN_GT2 = 0
)
pd_typical <- rxode2::zeroRe(mod_pd)
#> ℹ parameter labels from comments will be replaced by 'label()'
ss <- rxode2::rxSolve(
pd_typical, events = ss_events, omega = NA, sigma = NA, returnType = "data.frame"
) |>
dplyr::filter(!is.na(hb))
stopifnot(nrow(ss) == length(unique(ss_events$time)))
ss_drift <- max(abs(ss$hb - 9.92))
cat(sprintf("Max drift from Hb0 = 9.92 g/dL over one year, drug-free: %.3g g/dL\n", ss_drift))
#> Max drift from Hb0 = 9.92 g/dL over one year, drug-free: 1.24e-14 g/dL
# Deterministic; the only error is solver tolerance.
stopifnot(ss_drift < 1e-6)Replicating Figure 4 – external predictive check
Figure 4 shows the 2.5th-97.5th percentile band of predicted
hemoglobin over 16 weeks in the 302-patient test set, whose “population
characteristics and administered doses [were] similar to those used in
the modeling data set”. The regimens below are the three represented in
the PD development set (Agoram 2006 Table 1). The band is a
predictive check, so it is taken from the
residual-error-carrying sim column rather than the
individual prediction.
# set.seed() seeds R's RNG, not rxode2's. rxode2 partitions its streams per
# solver thread, so this cohort is NOT reproducible across machines with
# different thread counts. Every assertion below is written to hold for any
# cohort the model can produce (see known-vignette-failure-patterns.md #12).
set.seed(20060901)
rxode2::rxSetSeed(20060901)
N_PER_ARM <- 150L # cap is 200 per arm
WEEKS <- 16
pd_arms <- tibble::tribble(
~regimen, ~dose_ug_kg, ~ii_days,
"2.25 ug/kg QW", 2.25, 7,
"4.5 ug/kg Q3W", 4.5, 21,
"6.75 ug/kg Q3W", 6.75, 21
)
make_pd_arm <- function(regimen, dose_ug_kg, ii_days, id_offset) {
# Body weight from the PD cohort: mean 69.9, SD 16.2, truncated to the
# observed 39-136 kg range (Agoram 2006 Table 2).
wt <- pmin(pmax(rnorm(N_PER_ARM, 69.9, 16.2), 39), 136)
# 61% of the pooled cohort had a platinum-containing regimen; the paper does
# not tabulate PCNT, so this split is an assumption (see Assumptions below).
platin <- rbinom(N_PER_ARM, 1, 0.61)
subj <- tibble(
id = id_offset + seq_len(N_PER_ARM),
WT = wt,
CONMED_PLATIN = platin,
# PCNT > 2 requires PCNT > 0; over a 16-week window a platinum patient on
# a 3-weekly cycle receives more than two cycles, so the two indicators
# coincide here. The mutual-consistency constraint is asserted below.
CONMED_PLATIN_GT2 = platin,
regimen = regimen
)
dose_times <- seq(0, WEEKS * 7 - 1, by = ii_days)
doses <- subj |>
tidyr::crossing(time = dose_times) |>
dplyr::mutate(amt = dose_ug_kg * WT, evid = 1L, cmt = "depot")
obs <- subj |>
tidyr::crossing(time = seq(0, WEEKS * 7, by = 7)) |>
dplyr::mutate(amt = NA_real_, evid = 0L, cmt = "central")
dplyr::bind_rows(doses, obs) |> dplyr::arrange(id, time, dplyr::desc(evid))
}
pd_events <- dplyr::bind_rows(
make_pd_arm(pd_arms$regimen[1], pd_arms$dose_ug_kg[1], pd_arms$ii_days[1], 0L),
make_pd_arm(pd_arms$regimen[2], pd_arms$dose_ug_kg[2], pd_arms$ii_days[2], 200L),
make_pd_arm(pd_arms$regimen[3], pd_arms$dose_ug_kg[3], pd_arms$ii_days[3], 400L)
)
stopifnot(!anyDuplicated(unique(pd_events[, c("id", "time", "evid")])))
# Register constraint: CONMED_PLATIN_GT2 = 1 must imply CONMED_PLATIN = 1.
stopifnot(all(pd_events$CONMED_PLATIN_GT2 <= pd_events$CONMED_PLATIN))
sim_pd <- rxode2::rxSolve(
mod_pd, events = pd_events, keep = c("regimen", "WT", "CONMED_PLATIN"),
returnType = "data.frame"
) |>
dplyr::filter(!is.na(hb))
#> ℹ parameter labels from comments will be replaced by 'label()'
stopifnot(nrow(sim_pd) > 0, dplyr::n_distinct(sim_pd$id) == 3L * N_PER_ARM)
band <- sim_pd |>
dplyr::mutate(week = time / 7) |>
dplyr::group_by(week) |>
dplyr::summarise(
lo = quantile(sim, 0.025, na.rm = TRUE),
med = quantile(sim, 0.500, na.rm = TRUE),
hi = quantile(sim, 0.975, na.rm = TRUE),
.groups = "drop"
)
ggplot(band, aes(week, med)) +
geom_ribbon(aes(ymin = lo, ymax = hi), fill = "orange", alpha = 0.45) +
geom_line(linewidth = 0.8) +
scale_x_continuous(breaks = seq(0, 16, by = 5)) +
coord_cartesian(ylim = c(5, 17)) +
labs(
x = "Time (weeks)", y = "Hemoglobin (g/dL)",
title = "Figure 4 -- predicted hemoglobin, 2.5th-97.5th percentile band",
caption = "Replicates Figure 4 of Agoram 2006 (predictive check band, pooled over regimens)."
) +
theme_bw()
wk <- function(w, col) band[[col]][which.min(abs(band$week - w))]
fig4 <- tibble::tibble(
Quantity = c(
"Median Hb at week 0 (g/dL)",
"Median Hb at week 16 (g/dL)",
"Rise in median Hb, week 0 to 16 (g/dL)",
"Lower 2.5th percentile at week 16 (g/dL)",
"Upper 97.5th percentile at week 16 (g/dL)"
),
`Figure 4 (read off)` = c("~10", "~11.5-12", "~1.5-2", "~8.5", "~13.5-15"),
Simulated = c(wk(0, "med"), wk(16, "med"), wk(16, "med") - wk(0, "med"),
wk(16, "lo"), wk(16, "hi"))
)
knitr::kable(fig4, digits = 2, caption = "Figure 4 band, published vs simulated.")| Quantity | Figure 4 (read off) | Simulated |
|---|---|---|
| Median Hb at week 0 (g/dL) | ~10 | 9.87 |
| Median Hb at week 16 (g/dL) | ~11.5-12 | 11.12 |
| Rise in median Hb, week 0 to 16 (g/dL) | ~1.5-2 | 1.25 |
| Lower 2.5th percentile at week 16 (g/dL) | ~8.5 | 8.47 |
| Upper 97.5th percentile at week 16 (g/dL) | ~13.5-15 | 14.68 |
# Cohort-derived, so these are magnitude bounds with headroom, not tight
# equalities. Week-0 median is anchored by Hb0 = 9.92 with a small IIV
# (omega^2 = 0.0072, ~8.5% CV) plus a 0.633 g/dL residual, so it is the
# stablest of the five.
#
# Realised at 1 / 2 / 4 / 8 solver threads (rxode2 partitions its RNG
# streams per thread, so a 2-core CI runner draws a different cohort than a
# workstation -- see known-vignette-failure-patterns.md #12):
# med week 0 9.868 - 10.005
# rise 1.187 - 1.250
# med week 16 11.074 - 11.193
# 2.5th pct 8.473 - 8.709
# 97.5th pct 14.575 - 15.122
# Every bound below sits outside that range with at least ~0.5 g/dL of
# headroom, and each still goes red on a mis-transcribed Hb0, Smax, S50 or
# RBCLS, which move these numbers by whole g/dL. Do not tighten them back to
# whatever a single local run happens to give.
stopifnot(
abs(wk(0, "med") - 9.92) < 0.5,
wk(16, "med") - wk(0, "med") > 0.5,
wk(16, "med") > 10.5, wk(16, "med") < 12.5,
wk(16, "lo") > 7.0, wk(16, "lo") < 10.0,
wk(16, "hi") > 12.0, wk(16, "hi") < 17.0
)The simulated band reproduces the published one in position and in
shape: a median that starts at the estimated Hb0, climbs
over roughly the first 10 weeks and then plateaus, inside a band about
5-6 g/dL wide. The width is driven almost entirely by
omega^2_S50 = 8.05 – a CV above 5000% – which the paper
attributes both to the genuine heterogeneity of the CIA population and
to S50 absorbing the variability of Smax,
whose IIV could not be estimated.
Time course of the response
sim_pd |>
dplyr::mutate(week = time / 7) |>
dplyr::group_by(regimen, week) |>
dplyr::summarise(
lo = quantile(hb, 0.25), med = quantile(hb, 0.50), hi = quantile(hb, 0.75),
.groups = "drop"
) |>
ggplot(aes(week, med, colour = regimen, fill = regimen)) +
geom_ribbon(aes(ymin = lo, ymax = hi), alpha = 0.2, colour = NA) +
geom_line(linewidth = 0.8) +
labs(
x = "Time (weeks)", y = "Hemoglobin (g/dL)", colour = NULL, fill = NULL,
title = "Individual-prediction median and interquartile range by regimen"
) +
theme_bw() + theme(legend.position = "top")
The lag before hemoglobin moves is set by RBCPT = 4.68
days (progenitor maturation) followed by the mature-cell chain, whose
transit time is RBCLS = 82.2 days over four compartments.
Agoram 2006 notes that 82.2 days is well short of the ~120-day lifespan
of healthy adult red cells, consistent with the myelosuppressive effect
of chemotherapy and anemia of chronic disease.
Assessment of covariate effects
Agoram 2006’s “Assessment of Effect of Covariates” section predicts the difference in 16-week hemoglobin response, at a fixed 150 ug QW dose (equivalent to the prescribed 2.25 ug/kg QW for a 70 kg patient), between the extremes of each covariate’s range. The published mean (95% CI) is 0.939 (0.283, 1.45) g/dL for body weight and 0.317 (-0.504, 0.877) g/dL for platinum-containing chemotherapy.
cov_events <- function(wt, platin) {
tibble(
id = 1L, time = seq(0, 15 * 7, by = 7), amt = 150, evid = 1L, cmt = "depot",
WT = wt, CONMED_PLATIN = platin, CONMED_PLATIN_GT2 = platin
) |>
dplyr::bind_rows(
tibble(
id = 1L, time = seq(0, 16 * 7, by = 7), amt = NA_real_, evid = 0L,
cmt = "central", WT = wt, CONMED_PLATIN = platin,
CONMED_PLATIN_GT2 = platin
)
) |>
dplyr::arrange(time, dplyr::desc(evid))
}
delta_hb <- function(wt, platin) {
s <- rxode2::rxSolve(
pd_typical, events = cov_events(wt, platin),
omega = NA, sigma = NA, returnType = "data.frame"
) |>
dplyr::filter(!is.na(hb))
s$hb[which.min(abs(s$time - 112))] - s$hb[which.min(abs(s$time - 0))]
}
eff_wt <- delta_hb(39, 0) - delta_hb(136, 0)
eff_platin <- delta_hb(REF_WT, 0) - delta_hb(REF_WT, 1)
cov_tab <- tibble::tibble(
Covariate = c("Body weight, 39 kg vs 136 kg", "Platinum chemotherapy, no vs yes"),
`Published mean (g/dL)` = c(0.939, 0.317),
`Published 95% CI` = c("(0.283, 1.45)", "(-0.504, 0.877)"),
`Typical-value contrast (g/dL)` = c(eff_wt, eff_platin),
`Inside published CI` = c(
eff_wt > 0.283 && eff_wt < 1.45,
eff_platin > -0.504 && eff_platin < 0.877
)
)
knitr::kable(cov_tab, digits = 3, caption = "16-week hemoglobin response difference at 150 ug QW, at the extremes of each covariate.")| Covariate | Published mean (g/dL) | Published 95% CI | Typical-value contrast (g/dL) | Inside published CI |
|---|---|---|---|---|
| Body weight, 39 kg vs 136 kg | 0.939 | (0.283, 1.45) | 0.734 | TRUE |
| Platinum chemotherapy, no vs yes | 0.317 | (-0.504, 0.877) | 0.291 | TRUE |
# Deterministic typical-value contrasts, so this gate is stable. The paper's
# figures are Monte Carlo means over the full-IIV cohort rather than
# typical-value contrasts, which is why the point estimates differ while both
# land inside the published intervals.
stopifnot(all(cov_tab$`Inside published CI`))Both effects reproduce inside the paper’s own published confidence intervals. The paper’s conclusion follows directly from the width of those intervals: “it was not possible to classify any of the covariates as clinically significant”, and dose adjustment on either covariate is not recommended.
Assumptions and deviations
-
Residual-error form for hemoglobin. Table 4 reports
sigma_2^2 = 0.401without naming an error model, and Equation 2’s log-transformed exponential form is stated only for the pharmacokinetic observations. It is encoded here as additive, SD =sqrt(0.401)= 0.633 g/dL, which is about 6% of the 9.92 g/dL baseline and matches the roughly +/- 1 g/dL scatter about the line of identity in Figure 3A. A log-scale reading of the same variance would be a 63% CV and would spread individual predictions over roughly 3-34 g/dL, which Figure 3A rules out. -
Equations 7 and 8 as printed are mutually
inconsistent. Equation 8 defines
Stim = (1 + Smax*C_DA/(S50 + C_DA))– the1 +is inside the parentheses – while Equation 7 is typeset asdA1/dt = Rin(1 = Stim) - kPT*A1, where1 = Stimis plainly a typographical error. Under either repair (Rin * Stimwith the1 +insideStim, orRin * (1 + Stim)withStim = Smax*C_DA/(S50+C_DA)) the production term is identical:Rin * (1 + Smax*C_DA/(S50 + C_DA)), saturating at1 + Smax= 1.437. That is the form encoded, and it is the one the abstract describes as an “incremental maximum stimulation of hemoglobin production [of] 43.7%”. Equation 10 is likewise printed with a left-hand side ofdA2/dtwhere the index rangei = 3,4,5on the right makes clear it isdAi/dt. -
Rinis derived, not published. The paper does not print a value forRinorKout; onlyHb0andRBCLSare estimated.Rin = Hb0 / RBCLSis the drug-free steady state of the published chain, verified above to hold the baseline to within solver tolerance over a simulated year. -
Intercompartmental clearance
Qis encoded asfixed(). Its Table 3 row is the only one with both a blank standard-error column and a blank bootstrap column; every parameter the paper estimated has both. The paper does not say in prose thatQwas fixed, so this is an inference from the table’s structure. It has no effect on any simulation –fixed()is provenance metadata only – but a reviewer with access to the control stream should confirm it. -
V2andQcarry no IIV, andFcarries none either. This follows the paper: “It was not possible to estimate the IIV of Q and V2”, and IIV ofFwas not estimated because the IV and SC data came from separate patients. -
No IIV on
RBCPTorSmax. The paper states both explicitly. The consequence, which it also flags, is that the estimatedS50variability probably absorbsSmaxvariability, so covariate conclusions “may equally apply to both these parameters”. -
The abstract’s “steady-state volume of distribution, 3390
mL” is the central volume
V1, notVss. Table 3 labels 3390 mL astheta_V1;Vss = V1 + V2= 3641 mL. The table is taken as authoritative. -
PD covariate distributions in the virtual cohort are
assumptions. Body weight is drawn as N(69.9, 16.2) truncated to
the published 39-136 kg range (Table 2). The paper never tabulates
PCNT, so the proportion of patients on platinum-containing chemotherapy is set to 61%, chosen so that the non-platinum tumor types the paper names as typically platinum-free (breast, and the pancreatic / prostate cases it groups under “other”) are the complement. This affects only the width and position of the Figure 4 band, not any parameter. -
The
CONMED_PLATIN/CONMED_PLATIN_GT2pair. Agoram 2006 dichotomises a single underlying count,PCNT, at two different thresholds in the same publication:> 2for the PK model’s clearance effect (Equation 12) and> 0for the PD model’sS50effect (Equation 14). Per the standing count-covariate policy the count is carried as two binary indicator columns rather than as an integer, and a data set supporting both models must provide both. In the virtual cohort above the two coincide, because a patient on a 3-weekly platinum regimen receives more than two cycles inside a 16-week window. - Figure 2C landmarks are digitised. The published panel reports no numeric table, so the five landmark values in the comparison table were read off the log-scale figure and are accurate only to gridline resolution; they are checked with a 25% tolerance.
-
No published NCA table exists for this paper. The
PK section therefore validates the NCA output against closed-form
identities the structural model must satisfy
(
AUC0-inf = Dose/CLfor the IV arm, and the SC/IV AUC ratio equallingF) rather than against transcribed reference values.