Model and source
- Citation: Shah NR, Bulitta JB, Kinzig M, Landersdorfer CB, Jiao Y, Sutaria DS, Tao X, Hohl R, Holzgrabe U, Kees F, Stephan U, Sorgel F. Novel Population Pharmacokinetic Approach to Explain the Differences between Cystic Fibrosis Patients and Healthy Volunteers via Protein Binding. Pharmaceutics. 2019 Jun 18;11(6):286. doi:10.3390/pharmaceutics11060286
- Description: Three-compartment population PK model for IV cefotiam with a simultaneous fit of total plasma concentrations and the fraction of dose excreted unchanged in urine. Built from 14 Caucasian adults (8 cystic fibrosis [CF] patients, 6 healthy volunteers [HVs]) each given a single 1027.5 mg cefotiam dose as a 3 min IV infusion. The novel feature of this model is that every disposition parameter is referenced to the UNBOUND cefotiam concentration, and the observed TOTAL plasma concentration is reconstructed algebraically as Cc = Cunbound / fu. Body size and body composition are captured by allometric scaling on lean body mass (LBM) with fixed exponents 0.75 on all clearance terms and 1.0 on all volumes (reference LBM = 53 kg, equivalent to a standard 70 kg total body weight). Unbound total clearance is split into a renal arm (CL_R,u, whose mass is tracked in the canonical urine compartment) and a non-renal arm (CL_NR,u). Because the unbound parameters are shared across all four cohorts, the entire remaining CF-vs-HV and female-vs-male difference is carried by the unbound fraction fu, which is estimated separately for each combination of the DIS_CF and SEXF indicators: fu = 0.500 (fixed, HV males), 0.545 (HV females), 0.563 (CF males) and 0.744 (CF females). This replaces the disease-specific scale factors (FCYF) used by the same group in Bulitta_2011_cefpirome.R and Bulitta_2007_piperacillin.R; the authors removed all FCYF terms when the unbound fractions were estimated.
- Article: https://doi.org/10.3390/pharmaceutics11060286
Shah 2019 is the protein-binding counterpart to the
disease-scale-factor models this library already carries from the same
group (Bulitta_2007_piperacillin,
Bulitta_2011_cefpirome). The question in all three papers
is the same: why do patients with cystic fibrosis (CF) appear to clear
beta-lactams faster than healthy volunteers (HVs)? The earlier papers
answered it with explicit disease-specific scale factors (FCYF)
multiplying clearance and volume. This paper answers it a different way.
Every disposition parameter is referenced to the
unbound cefotiam concentration and is shared across all
four subject groups; the entire residual CF-vs-HV and female-vs-male
difference is absorbed into the plasma unbound fraction fu,
which is estimated per group. Paper Section 2.6.5 is explicit that “all
disease specific scale factors FCYF were removed from the model” once
the unbound fractions were estimated.
The practical consequence, and the thing this vignette sets out to
demonstrate, is that two subjects of identical lean body mass but
different fu have identical unbound
pharmacokinetics and different total pharmacokinetics.
Population
Paper Table 1 describes 14 Caucasian adults, all of whom received a single 1027.5 mg cefotiam dose as a 3 min IV infusion:
- 8 patients with cystic fibrosis (4 female, 4 male): total body weight 33.0-59.0 kg (median 45.5), lean body mass (LBM) 28.8-46.2 kg (median 40.3), age 17-24 years (median 19), BMI 13.4-19.9 kg/m^2 (median 17.0).
- 6 healthy volunteers (3 female, 3 male): total body weight 58.0-80.0 kg (median 68.5), LBM 44.6-65.4 kg (median 50.6), age 21-26 years (median 23.5), BMI 20.3-27.9 kg/m^2 (median 22.5).
LBM was computed with the formula of Cheymol and James (Table 1 footnote a) and is the size descriptor the model scales on. The per-sex LBM medians in Table 1 matter for this model because they separate the two mechanisms: female and male CF patients have essentially the same LBM (38.8 vs 40.4 kg) yet female CF patients had consistently larger clearances in the noncompartmental analysis (Table 2). Size cannot explain that; the unbound fraction can.
Plasma was sampled pre-dose, at the end of the 3 min infusion, and at 5, 10, 15, 20, 30, 45, 60 and 90 min plus 2, 3, 4, 5, 6, 8, 12 and 24 h after the end of infusion. Urine was collected over 0-1, 1-2, 2-3, 3-4, 4-5, 5-6, 6-8, 8-12 and 12-24 h from the start of infusion. Plasma concentrations and urinary fractions were fitted simultaneously, which is what identifies the renal and non-renal clearance arms separately.
Source trace
The final model is paper Table 4. The authors also deposited the Berkeley Madonna Monte Carlo source listing for the final model in the Supplementary Materials; that listing prints the estimates to six significant figures and states the ODE system verbatim, so it is used here as the authoritative implementation with the Table 4 rounding quoted alongside.
| Model element | Source location | Value |
|---|---|---|
| Three-compartment disposition | Figure 1; Results, Structural Model | chosen over 2-compartment on likelihood ratio (p < 0.001) |
| ODE system, urine state | Supplement Madonna listing, d/dt(Cent),
d/dt(Shal), d/dt(Deep),
d/dt(Urin)
|
transcribed verbatim |
FSize,V = LBM / 53 |
Equation 1; Section 2.6.3 | exponent fixed at 1.0 |
FSize,CL = (LBM / 53)^0.75 |
Equation 2; Section 2.6.3 | exponent fixed at 0.75 |
| Individual parameter form | Equation 4 (CLru,i = CLru * FSize,CL,i * exp(eta)) |
no FCYF term |
Cc = Cunbound / fu |
Section 2.6.5; Madonna C1 = Cent/V1cov/FU
|
total from unbound |
CLr,u = 23.8 L/h |
Table 4; Madonna Mean_CLR = 23.7911 |
SE 6.9% |
CLnr,u = 11.0 L/h |
Table 4; Madonna Mean_CLNR = 10.9943 |
SE 7.0% |
V1u = 15.6 L |
Table 4; Madonna Mean_V1 = 15.5927 |
SE 6.5% |
V2u = 6.91 L |
Table 4; Madonna Mean_V2 = 6.90932 |
SE 14.1% |
V3u = 4.56 L |
Table 4; Madonna Mean_V3 = 4.5646 |
SE 16.4% |
CLd_shallow,u = 13.8 L/h |
Table 4; Madonna Mean_CLD = 13.8043 |
SE 15.0% |
CLd_deep,u = 1.84 L/h |
Table 4; Madonna Mean_CLD3 = 1.83622 |
SE 26.1% |
fu HV male = 0.50 |
Table 4 (fixed); Madonna FU_HVM
|
fixed from literature refs 15-18 |
fu HV female = 0.545 |
Table 4; Madonna Mean_FU_HVF = 0.544842 |
SE 13.6% |
fu CF male = 0.563 |
Table 4; Madonna Mean_FU_CFM = 0.562527 |
SE 13.5% |
fu CF female = 0.744 |
Table 4; Madonna Mean_FU_CFF = 0.74359 |
SE 4.5% |
| BSV on CL/V terms | Table 4 BSV column; Madonna CV_*
|
log-scale SD, so omega^2 = BSV^2 |
BSV on the three estimated fu
|
Table 4 footnote d | fixed at 5% CV |
SDin = 0.0186 mg/L, SDsl = 0.166 |
Table 4 | additive + proportional on total plasma |
UDin = 0.384% |
Table 4 | additive on percent of dose in urine |
Two alternative parameterisations are reported in the Supplementary
Materials and are deliberately not encoded: Table S1
(FCYF scale factors with fu fixed to 0.5 everywhere) and
Table S2 (renal clearance split into a fixed 7.2 L/h glomerular
filtration arm plus a binding-independent tubular secretion arm). The
paper presents neither as final; Table S2 had a -2x log-likelihood worse
by 7.1.
Virtual cohort
Four arms, one per combination of DIS_CF and
SEXF, 50 subjects each. LBM is drawn from a log-normal with
the 15% coefficient of variation the paper used for its own Monte Carlo
covariate model (Section 2.6.7), centred on the per-sex, per-cohort
median LBM from Table 1 and truncated to the range Table 1 observed.
rxode2::rxSetSeed(20190618)
set.seed(20190618)
n_per_arm <- 50L
# Table 1: per-cohort, per-sex lean body mass (median [range], kg).
arm_spec <- tibble::tribble(
~cohort, ~DIS_CF, ~SEXF, ~lbm_med, ~lbm_lo, ~lbm_hi,
"CF female", 1L, 1L, 38.8, 28.8, 45.7,
"CF male", 1L, 0L, 40.4, 39.6, 46.2,
"HV female", 0L, 1L, 44.6, 44.6, 45.4,
"HV male", 0L, 0L, 62.8, 55.8, 65.4
)
draw_lbm <- function(n, med, lo, hi, cv = 0.15) {
# Log-normal around the Table 1 median, truncated to the Table 1 range.
out <- pmin(pmax(med * exp(stats::rnorm(n, 0, cv)), lo), hi)
out
}
cohorts <- arm_spec |>
tidyr::uncount(n_per_arm) |>
dplyr::group_by(cohort) |>
dplyr::mutate(LBM = draw_lbm(dplyr::n(), lbm_med[1], lbm_lo[1], lbm_hi[1])) |>
dplyr::ungroup() |>
dplyr::mutate(
id = dplyr::row_number(),
DOSE_CEFOTIAM_MG = 1027.5
) |>
dplyr::select(id, cohort, DIS_CF, SEXF, LBM, DOSE_CEFOTIAM_MG)
cohorts |>
dplyr::group_by(cohort) |>
dplyr::summarise(
n = dplyr::n(),
`LBM median` = round(stats::median(LBM), 1),
`LBM min` = round(min(LBM), 1),
`LBM max` = round(max(LBM), 1),
.groups = "drop"
) |>
knitr::kable(caption = "Simulated cohort lean body mass by arm, against Table 1.")| cohort | n | LBM median | LBM min | LBM max |
|---|---|---|---|---|
| CF female | 50 | 38.8 | 28.8 | 45.7 |
| CF male | 50 | 41.4 | 39.6 | 46.2 |
| HV female | 50 | 44.6 | 44.6 | 45.4 |
| HV male | 50 | 61.9 | 55.8 | 65.4 |
Events
Each subject receives the study dose: 1027.5 mg as a 3 min IV
infusion. The observation grid reproduces the paper’s sampling schedule
exactly, with plasma times expressed relative to the
end of infusion as the paper states them. Plasma
observations are placed on cmt = "Cc" and urine
observations on cmt = "urinePct", because this model
declares two residual endpoints.
dose_amt_mg <- 1027.5
infusion_dur_h <- 3 / 60
# Section 2.3: times are stated post END of infusion.
post_eoi_h <- c(c(0, 5, 10, 15, 20, 30, 45, 60, 90) / 60, 2, 3, 4, 5, 6, 8, 12, 24)
plasma_times <- sort(unique(c(0, infusion_dur_h + post_eoi_h)))
# Section 2.4: urine collection interval ends, from the START of infusion.
urine_times <- c(1, 2, 3, 4, 5, 6, 8, 12, 24)
dose_rows <- cohorts |>
dplyr::mutate(
time = 0,
evid = 1L,
amt = dose_amt_mg,
cmt = "central",
rate = dose_amt_mg / infusion_dur_h
)
obs_rows <- cohorts |>
tidyr::crossing(time = plasma_times) |>
dplyr::mutate(evid = 0L, amt = NA_real_, cmt = "Cc", rate = 0)
urine_rows <- cohorts |>
tidyr::crossing(time = urine_times) |>
dplyr::mutate(evid = 0L, amt = NA_real_, cmt = "urinePct", rate = 0)
events <- dplyr::bind_rows(dose_rows, obs_rows, urine_rows) |>
dplyr::arrange(id, time, dplyr::desc(evid))
stopifnot(!anyDuplicated(events[, c("id", "time", "evid", "cmt")]))Simulation
useLinCmt = FALSE is required: a model with more than
one declared endpoint has its dvid table corrupted by the
linear-compartment solver, which makes every cmt choice
fail.
mod <- readModelDb("Shah_2019_cefotiam")
sim <- rxode2::rxSolve(
mod,
events = events,
keep = c("cohort", "LBM", "DIS_CF", "SEXF"),
useLinCmt = FALSE
) |>
as.data.frame()
#> ℹ parameter labels from comments will be replaced by 'label()'
sim_unique <- sim |>
dplyr::distinct(id, time, .keep_all = TRUE)Exact structural checks
These three checks compare a deterministic solve against a closed
form derived from the published parameters. Both sides use the same
numbers, so the only difference is integration error and a tight bound
is the correct assertion. Random effects are suppressed with
omega = NA, sigma = NA rather than zeroRe(),
which segfaults on multi-endpoint models.
typ_cov <- arm_spec |>
dplyr::mutate(
id = dplyr::row_number(),
LBM = lbm_med,
DOSE_CEFOTIAM_MG = dose_amt_mg
) |>
dplyr::select(id, cohort, DIS_CF, SEXF, LBM, DOSE_CEFOTIAM_MG)
typ_times <- sort(unique(c(
seq(0, 0.2, by = 0.005),
seq(0.25, 6, by = 0.05),
seq(6.25, 48, by = 0.25)
)))
typ_events <- dplyr::bind_rows(
typ_cov |>
dplyr::mutate(
time = 0, evid = 1L, amt = dose_amt_mg,
cmt = "central", rate = dose_amt_mg / infusion_dur_h
),
typ_cov |>
tidyr::crossing(time = typ_times) |>
dplyr::mutate(evid = 0L, amt = NA_real_, cmt = "Cc", rate = 0),
typ_cov |>
tidyr::crossing(time = typ_times) |>
dplyr::mutate(evid = 0L, amt = NA_real_, cmt = "urinePct", rate = 0)
) |>
dplyr::arrange(id, time, dplyr::desc(evid))
typ <- rxode2::rxSolve(
mod, typ_events,
keep = c("cohort", "LBM", "DIS_CF", "SEXF"),
omega = NA, sigma = NA,
useLinCmt = FALSE
) |>
as.data.frame() |>
dplyr::distinct(id, time, .keep_all = TRUE)Check 1: total-scale clearance equals
fu * CL_unbound * (LBM/53)^0.75
Because the volumes and clearances are both unbound-referenced, the
classical total-concentration clearance recovered by NCA must be
fu times the unbound clearance. This is the identity the
whole paper rests on.
cl_ru <- 23.7911
cl_nru <- 10.9943
v1u <- 15.5927
v2u <- 6.90932
v3u <- 4.5646
fu_lookup <- c(
"CF female" = 0.74359, "CF male" = 0.562527,
"HV female" = 0.544842, "HV male" = 0.5
)
closed_form <- typ_cov |>
dplyr::mutate(
fu = unname(fu_lookup[cohort]),
cl_total_pred = fu * (cl_ru + cl_nru) * (LBM / 53)^0.75,
vss_total_pred = fu * (v1u + v2u + v3u) * (LBM / 53)
)
# Dose / AUCinf is exactly CL for any linear system, whatever the input
# function, so PKNCA's cl.obs on the deterministic profile is a direct read of
# the model's total-concentration clearance.
typ_nca_in <- typ |>
dplyr::filter(!is.na(Cc)) |>
dplyr::select(id, time, Cc, cohort) |>
dplyr::arrange(id, time)
typ_conc <- PKNCA::PKNCAconc(
typ_nca_in, Cc ~ time | cohort + id,
concu = "mg/L", timeu = "h"
)
typ_dose <- PKNCA::PKNCAdose(
typ_cov |> dplyr::mutate(time = 0, amt = dose_amt_mg) |>
dplyr::select(id, cohort, time, amt),
amt ~ time | cohort + id,
doseu = "mg"
)
cl_obs_typ <- suppressWarnings(
PKNCA::pk.nca(PKNCA::PKNCAdata(
typ_conc, typ_dose,
intervals = data.frame(start = 0, end = Inf, cl.obs = TRUE)
))
)$result |>
as.data.frame() |>
dplyr::filter(PPTESTCD == "cl.obs") |>
dplyr::transmute(cohort, cl_total_sim = PPORRES)
cl_check <- closed_form |>
dplyr::select(cohort, fu, LBM, cl_total_pred, vss_total_pred) |>
dplyr::left_join(cl_obs_typ, by = "cohort") |>
dplyr::mutate(pct_diff = 100 * (cl_total_sim - cl_total_pred) / cl_total_pred)
cl_check |>
dplyr::transmute(
Cohort = cohort,
`fu` = fu,
`LBM (kg)` = round(LBM, 1),
`CL closed form (L/h)` = round(cl_total_pred, 3),
`CL from solve (L/h)` = round(cl_total_sim, 3),
`% diff` = round(pct_diff, 3)
) |>
knitr::kable(
caption = "Total-concentration clearance: closed form vs deterministic solve."
)| Cohort | fu | LBM (kg) | CL closed form (L/h) | CL from solve (L/h) | % diff |
|---|---|---|---|---|---|
| CF female | 0.743590 | 38.8 | 20.471 | 20.467 | -0.022 |
| CF male | 0.562527 | 40.4 | 15.963 | 15.960 | -0.022 |
| HV female | 0.544842 | 44.6 | 16.652 | 16.648 | -0.021 |
| HV male | 0.500000 | 62.8 | 19.753 | 19.749 | -0.018 |
Check 2: unbound PK is identical across all four groups at equal LBM
This is the paper’s central claim (Discussion: “the unbound clearances and unbound volumes of distribution were the same in all subject groups when subjects had the same body size”), and it is what makes the simulated PK/PD breakpoints in Table 5 identical between female and male CF patients.
eq_cov <- arm_spec |>
dplyr::mutate(
id = dplyr::row_number(),
LBM = 53, # every arm at the reference LBM
DOSE_CEFOTIAM_MG = dose_amt_mg
) |>
dplyr::select(id, cohort, DIS_CF, SEXF, LBM, DOSE_CEFOTIAM_MG)
eq_events <- dplyr::bind_rows(
eq_cov |>
dplyr::mutate(
time = 0, evid = 1L, amt = dose_amt_mg,
cmt = "central", rate = dose_amt_mg / infusion_dur_h
),
eq_cov |>
tidyr::crossing(time = typ_times) |>
dplyr::mutate(evid = 0L, amt = NA_real_, cmt = "Cc", rate = 0)
) |>
dplyr::arrange(id, time, dplyr::desc(evid))
eq <- rxode2::rxSolve(
mod, eq_events,
keep = c("cohort", "SEXF", "DIS_CF"),
omega = NA, sigma = NA, useLinCmt = FALSE
) |>
as.data.frame() |>
dplyr::distinct(id, time, .keep_all = TRUE)
unbound_spread <- eq |>
dplyr::filter(!is.na(Cunbound), Cunbound > 0) |>
dplyr::group_by(time) |>
dplyr::summarise(
rel_range = (max(Cunbound) - min(Cunbound)) / stats::median(Cunbound),
.groups = "drop"
)
total_spread <- eq |>
dplyr::filter(!is.na(Cc), Cc > 0) |>
dplyr::group_by(time) |>
dplyr::summarise(
rel_range = (max(Cc) - min(Cc)) / stats::median(Cc),
.groups = "drop"
)
cat(sprintf(
"Max relative spread across the four arms at equal LBM:\n unbound Cc: %.3g\n total Cc: %.3g\n",
max(unbound_spread$rel_range), max(total_spread$rel_range)
))
#> Max relative spread across the four arms at equal LBM:
#> unbound Cc: 0
#> total Cc: 0.363
# Unbound profiles must superimpose exactly; total profiles must not.
stopifnot(max(unbound_spread$rel_range) < 1e-8)
stopifnot(max(total_spread$rel_range) > 0.3)Check 3: fraction excreted unchanged is
CLr,u / (CLr,u + CLnr,u)
Both clearance arms carry the same fu and the same
allometric exponent, so their ratio is invariant to cohort, sex and body
size. The model therefore predicts a single urinary recovery for
everyone, which is a sharp, fully deterministic prediction to hold
against the observed data.
fe_pred <- 100 * cl_ru / (cl_ru + cl_nru)
fe_sim <- typ |>
dplyr::filter(!is.na(urinePct)) |>
dplyr::group_by(cohort) |>
dplyr::summarise(fe_48h = max(urinePct), .groups = "drop")
cat(sprintf("Closed-form asymptotic recovery: %.2f%% of dose\n", fe_pred))
#> Closed-form asymptotic recovery: 68.39% of dose
print(as.data.frame(fe_sim))
#> cohort fe_48h
#> 1 CF female 68.39392
#> 2 CF male 68.39392
#> 3 HV female 68.39392
#> 4 HV male 68.39392
stopifnot(all(abs(fe_sim$fe_48h - fe_pred) / fe_pred < 0.01))Paper Table 2 reports observed median recoveries of 70.3% (CF, range 47.6-77.8%) and 66.3% (HV, range 59.4-72.7%). The model’s single value of 68.4% sits between the two cohort medians and inside both observed ranges, which is the correct behaviour for a model that deliberately shares both clearance arms across cohorts.
Replicating the published figures
Figure 3: visual predictive check by cohort
vpc <- sim_unique |>
dplyr::filter(!is.na(Cc), Cc > 0) |>
dplyr::mutate(group = ifelse(DIS_CF == 1L, "Patients with CF", "Healthy volunteers")) |>
dplyr::group_by(group, time) |>
dplyr::summarise(
p10 = stats::quantile(Cc, 0.10),
p25 = stats::quantile(Cc, 0.25),
p50 = stats::quantile(Cc, 0.50),
p75 = stats::quantile(Cc, 0.75),
p90 = stats::quantile(Cc, 0.90),
.groups = "drop"
)
ggplot2::ggplot(vpc, ggplot2::aes(x = time)) +
ggplot2::geom_ribbon(ggplot2::aes(ymin = p10, ymax = p90), alpha = 0.18) +
ggplot2::geom_ribbon(ggplot2::aes(ymin = p25, ymax = p75), alpha = 0.30) +
ggplot2::geom_line(ggplot2::aes(y = p50), linewidth = 0.7) +
ggplot2::facet_wrap(~group) +
ggplot2::scale_y_log10() +
ggplot2::labs(
x = "Time (h)", y = "Total cefotiam in plasma (mg/L)",
title = "Replicates Figure 3 of Shah 2019",
subtitle = "Median with interquartile range and 80% prediction interval"
) +
ggplot2::theme_minimal()
The protein-binding mechanism, total vs unbound
Figure 4 of the paper shows that the probability of target attainment is near-identical between female and male CF patients, because target attainment is driven by unbound concentration. The panel below is the direct visualisation of that result: at equal LBM the four arms separate on total concentration and superimpose completely on unbound concentration.
eq |>
dplyr::filter(time > 0, time <= 8, !is.na(Cc)) |>
dplyr::select(cohort, time, Total = Cc, Unbound = Cunbound) |>
tidyr::pivot_longer(c(Total, Unbound), names_to = "scale", values_to = "conc") |>
ggplot2::ggplot(ggplot2::aes(time, conc, colour = cohort)) +
ggplot2::geom_line(linewidth = 0.8) +
ggplot2::facet_wrap(~scale) +
ggplot2::scale_y_log10() +
ggplot2::labs(
x = "Time (h)", y = "Cefotiam in plasma (mg/L)", colour = NULL,
title = "All four groups at LBM = 53 kg",
subtitle = "Total concentrations separate by group; unbound concentrations superimpose"
) +
ggplot2::theme_minimal()
PKNCA validation
The paper’s Table 2 reports noncompartmental parameters from the
observed data, stratified by cohort and by sex. Those are the reference
values. Simulated profiles are censored below 1 mg/L before the NCA: the
paper does not state an LLOQ, but 1 mg/L is the lowest concentration for
which it reports assay recovery (Section 2.5), and an uncensored
simulated profile lets lambda.z run down into
concentrations no assay could have measured, which biases the terminal
half-life upward.
lloq_mgL <- 1.0
sim_nca <- sim_unique |>
dplyr::filter(!is.na(Cc)) |>
dplyr::mutate(Cc = ifelse(time > 0 & Cc < lloq_mgL, NA_real_, Cc)) |>
dplyr::filter(!is.na(Cc)) |>
dplyr::select(id, time, Cc, cohort)
# Defensive time-zero record so PKNCA never reports an AUC range starting
# before the first measurement.
sim_nca <- sim_nca |>
dplyr::bind_rows(
sim_nca |>
dplyr::distinct(id, cohort) |>
dplyr::mutate(time = 0, Cc = 0)
) |>
dplyr::distinct(id, time, .keep_all = TRUE) |>
dplyr::arrange(id, time)
conc_obj <- PKNCA::PKNCAconc(
sim_nca, Cc ~ time | cohort + id,
concu = "mg/L", timeu = "h"
)
dose_df <- cohorts |>
dplyr::mutate(time = 0, amt = dose_amt_mg) |>
dplyr::select(id, cohort, time, amt)
dose_obj <- PKNCA::PKNCAdose(dose_df, amt ~ time | cohort + id, doseu = "mg")
intervals <- data.frame(
start = 0, end = Inf,
cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE,
half.life = TRUE, cl.obs = TRUE, mrt.obs = TRUE, vss.obs = TRUE
)
nca_res <- suppressWarnings(
PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))
)
nca_long <- as.data.frame(nca_res$result) |>
dplyr::filter(PPTESTCD %in% c(
"cmax", "tmax", "aucinf.obs", "half.life", "cl.obs", "mrt.obs", "vss.obs"
))Comparison against the published noncompartmental analysis
Paper Table 2 reports medians by cohort and by sex within cohort. The
reference rows below are the per-sex medians, which is the finest
stratification the paper publishes and the one that exercises the
fu split.
# Shah 2019 Table 2, per-cohort per-sex medians.
reference_nca <- tibble::tribble(
~cohort, ~cl.obs, ~vss.obs, ~cmax, ~half.life, ~mrt.obs,
"CF female", 22.1, 13.3, NA, NA, NA,
"CF male", 15.9, 12.3, NA, NA, NA,
"HV female", 16.2, 10.7, NA, NA, NA,
"HV male", 19.1, 16.7, NA, NA, NA
) |>
# Cmax, half-life and MRT are reported by cohort only (not split by sex),
# so the cohort-level value is carried onto both sexes of that cohort.
dplyr::mutate(
cmax = c(124, 124, 111, 111),
half.life = c(0.931, 0.931, 1.08, 1.08),
mrt.obs = c(0.699, 0.699, 0.707, 0.707)
)
nca_tbl <- nlmixr2lib::ncaComparisonTable(
simulated = nca_long,
reference = reference_nca,
by = "cohort",
params = c("cmax", "cl.obs", "vss.obs", "half.life", "mrt.obs"),
units = c(
cmax = "mg/L", cl.obs = "L/h", vss.obs = "L",
half.life = "h", mrt.obs = "h"
)
)
knitr::kable(
nca_tbl,
caption = "Simulated vs published (Shah 2019 Table 2) noncompartmental parameters."
)| NCA parameter | cohort | Reference | Simulated | % diff |
|---|---|---|---|---|
| Cmax (mg/L) | CF female | 124 | 111 | -10.7% |
| Cmax (mg/L) | CF male | 124 | 135 | +8.7% |
| Cmax (mg/L) | HV female | 111 | 129 | +16.3% |
| Cmax (mg/L) | HV male | 111 | 108 | -2.8% |
| t½ (h) | CF female | 0.931 | 0.687 | -26.3%* |
| t½ (h) | CF male | 0.931 | 0.654 | -29.8%* |
| t½ (h) | HV female | 1.08 | 0.778 | -28.0%* |
| t½ (h) | HV male | 1.08 | 0.716 | -33.7%* |
| CL/F (L/h) | CF female | 22.1 | 20.6 | -6.8% |
| CL/F (L/h) | CF male | 15.9 | 17.9 | +12.5% |
| CL/F (L/h) | HV female | 16.2 | 17.1 | +5.7% |
| CL/F (L/h) | HV male | 19.1 | 20.3 | +6.4% |
| Vss/F (L) | CF female | 13.3 | 13.8 | +3.9% |
| Vss/F (L) | CF male | 12.3 | 11.2 | -8.9% |
| Vss/F (L) | HV female | 10.7 | 11.9 | +10.9% |
| Vss/F (L) | HV male | 16.7 | 13.8 | -17.2% |
| MRT (h) | CF female | 0.699 | 0.651 | -6.9% |
| MRT (h) | CF male | 0.699 | 0.621 | -11.2% |
| MRT (h) | HV female | 0.707 | 0.705 | -0.3% |
| MRT (h) | HV male | 0.707 | 0.687 | -2.8% |
attr(nca_tbl, "footnote")
#> [1] "* differs from reference by more than ±20%."The % diff column of the rendered table is a formatted
string (it carries the * tolerance flag), so the assertions
below recompute the differences numerically from the same medians the
table is built from.
sim_med <- nca_long |>
dplyr::group_by(cohort, PPTESTCD) |>
dplyr::summarise(simulated = stats::median(PPORRES, na.rm = TRUE), .groups = "drop")
ref_long <- reference_nca |>
tidyr::pivot_longer(-cohort, names_to = "PPTESTCD", values_to = "reference")
diffs <- dplyr::inner_join(sim_med, ref_long, by = c("cohort", "PPTESTCD")) |>
dplyr::filter(!is.na(simulated), !is.na(reference)) |>
dplyr::mutate(pct_diff = 100 * (simulated - reference) / reference)
knitr::kable(
diffs |> dplyr::mutate(dplyr::across(dplyr::where(is.numeric), \(x) round(x, 2))),
caption = "Numeric simulated-vs-published differences used by the assertions below."
)| cohort | PPTESTCD | simulated | reference | pct_diff |
|---|---|---|---|---|
| CF female | cl.obs | 20.61 | 22.10 | -6.76 |
| CF female | cmax | 110.68 | 124.00 | -10.74 |
| CF female | half.life | 0.69 | 0.93 | -26.26 |
| CF female | mrt.obs | 0.65 | 0.70 | -6.86 |
| CF female | vss.obs | 13.81 | 13.30 | 3.86 |
| CF male | cl.obs | 17.88 | 15.90 | 12.48 |
| CF male | cmax | 134.77 | 124.00 | 8.68 |
| CF male | half.life | 0.65 | 0.93 | -29.76 |
| CF male | mrt.obs | 0.62 | 0.70 | -11.19 |
| CF male | vss.obs | 11.20 | 12.30 | -8.90 |
| HV female | cl.obs | 17.12 | 16.20 | 5.71 |
| HV female | cmax | 129.05 | 111.00 | 16.26 |
| HV female | half.life | 0.78 | 1.08 | -27.97 |
| HV female | mrt.obs | 0.70 | 0.71 | -0.35 |
| HV female | vss.obs | 11.86 | 10.70 | 10.86 |
| HV male | cl.obs | 20.33 | 19.10 | 6.42 |
| HV male | cmax | 107.95 | 111.00 | -2.75 |
| HV male | half.life | 0.72 | 1.08 | -33.73 |
| HV male | mrt.obs | 0.69 | 0.71 | -2.76 |
| HV male | vss.obs | 13.82 | 16.70 | -17.22 |
# Terminal half-life is deliberately excluded from the pooled gate; it is not
# a property of the model alone but of the assay's quantification limit, and
# the paper publishes no LLOQ. The section below quantifies that and gates it
# separately on a quantity that does not depend on an assumed LLOQ.
pct <- abs(diffs$pct_diff[diffs$PPTESTCD != "half.life"])
# Structural gate: a mis-transcribed clearance, dose or unit would move the
# whole distribution by tens of percent. Assert on the CENTRE and on a robust
# quantile, not on the extreme -- the extreme of a random cohort is not
# reproducible across rxode2 builds.
stopifnot(stats::median(pct) < 12)
stopifnot(stats::quantile(pct, 0.75) < 20)
# Clearance is the parameter the model is really making a claim about, and the
# one Table 2 reports per sex. Hold its centre tighter.
cl_pct <- abs(diffs$pct_diff[diffs$PPTESTCD == "cl.obs"])
stopifnot(stats::median(cl_pct) < 12)Terminal half-life is window-limited, not mis-specified
Every simulated half-life above is 26-34% below the published value, uniformly across all four arms. That uniformity is the clue: a genuine parameter error would not bias all four arms by the same fraction while leaving clearance, volume and MRT within 12%. The cause is that a noncompartmental terminal half-life is not a property of the model at all – it is a property of how far down the concentration profile the assay can still see, and Shah 2019 does not publish a quantification limit.
The model’s own terminal half-life is unambiguous: it is
log(2) over the slowest eigenvalue of the three-compartment
rate matrix, and because fu cancels out of the ODE system
entirely it depends only on LBM.
kel <- (cl_ru + cl_nru) / v1u
k12 <- 13.8043 / v1u
k21 <- 13.8043 / v2u
k13 <- 1.83622 / v1u
k31 <- 1.83622 / v3u
rate_matrix <- matrix(
c(
-(kel + k12 + k13), k21, k31,
k12, -k21, 0,
k13, 0, -k31
),
nrow = 3, byrow = TRUE
)
lambda_z_ref <- -max(Re(eigen(rate_matrix)$values))
thalf_ref <- log(2) / lambda_z_ref
cat(sprintf(
"Model terminal half-life at the reference LBM of 53 kg: %.2f h\n", thalf_ref
))
#> Model terminal half-life at the reference LBM of 53 kg: 1.85 h
# Terminal disposition scales as LBM^(1 - 0.75) = LBM^0.25.
thalf_by_arm <- arm_spec |>
dplyr::transmute(
cohort,
`LBM (kg)` = lbm_med,
`Model terminal t1/2 (h)` = round(thalf_ref * (lbm_med / 53)^0.25, 2)
)
knitr::kable(thalf_by_arm, caption = "Model terminal half-life by arm.")| cohort | LBM (kg) | Model terminal t1/2 (h) |
|---|---|---|
| CF female | 38.8 | 1.71 |
| CF male | 40.4 | 1.72 |
| HV female | 44.6 | 1.77 |
| HV male | 62.8 | 1.93 |
Paper Table 2 publishes terminal half-life as a median with a range: 0.931 [0.881-1.91] h in CF patients and 1.08 [0.753-1.66] h in healthy volunteers. These are not estimates of the model’s asymptotic terminal slope – they are window-limited NCA estimates on real, LLOQ-truncated data, and truncation can only ever shorten an estimated terminal half-life. So the correct expectation is that the model’s asymptotic value exceeds both published medians, which it does. It lands inside the CF observed range and slightly above the narrower HV observed upper bound of 1.66 h, which is the expected direction rather than a defect.
# Window truncation can only shorten an NCA half-life, so the model's
# asymptotic value must exceed both published medians.
stopifnot(thalf_ref > 0.931, thalf_ref > 1.08)
# Order-of-magnitude sanity only -- deliberately loose, not a tuned bound.
stopifnot(thalf_ref < 3)Sweeping the censoring threshold shows exactly how the NCA estimate walks from the model’s true terminal slope up to the steep distribution phase as the assumed quantification limit rises. The published medians sit between the 0.1 and 1 mg/L curves, implying the study’s real quantification limit was somewhere near 0.3-0.5 mg/L.
halflife_at_lloq <- function(profile, dose_frame, lloq) {
d <- profile |>
dplyr::mutate(Cc = ifelse(time > 0 & Cc < lloq, NA_real_, Cc)) |>
dplyr::filter(!is.na(Cc)) |>
dplyr::select(id, time, Cc, cohort)
res <- suppressWarnings(PKNCA::pk.nca(PKNCA::PKNCAdata(
PKNCA::PKNCAconc(d, Cc ~ time | cohort + id),
PKNCA::PKNCAdose(dose_frame, amt ~ time | cohort + id),
intervals = data.frame(start = 0, end = Inf, half.life = TRUE)
)))
as.data.frame(res$result) |>
dplyr::filter(PPTESTCD == "half.life") |>
dplyr::transmute(cohort, thalf = PPORRES)
}
typ_profile <- typ |>
dplyr::filter(!is.na(Cc)) |>
dplyr::select(id, time, Cc, cohort)
typ_dose_frame <- typ_cov |>
dplyr::mutate(time = 0, amt = dose_amt_mg) |>
dplyr::select(id, cohort, time, amt)
sens <- lapply(
c(1, 0.1, 0.01),
\(l) halflife_at_lloq(typ_profile, typ_dose_frame, l) |> dplyr::mutate(lloq = l)
) |>
dplyr::bind_rows() |>
dplyr::mutate(thalf = round(thalf, 2)) |>
tidyr::pivot_wider(names_from = lloq, values_from = thalf, names_prefix = "LLOQ ") |>
dplyr::rename(Cohort = cohort)
knitr::kable(
sens,
caption = paste(
"Terminal half-life (h) from NCA on the deterministic profile as a",
"function of the assumed quantification limit (mg/L)."
)
)| Cohort | LLOQ 1 | LLOQ 0.1 | LLOQ 0.01 |
|---|---|---|---|
| CF female | 0.78 | 1.51 | 1.68 |
| CF male | 0.84 | 1.59 | 1.70 |
| HV female | 0.84 | 1.63 | 1.74 |
| HV male | 0.86 | 1.69 | 1.90 |
# The published medians must be reachable within the swept window -- i.e. the
# 1 mg/L censoring used above is too aggressive and a lower LLOQ recovers the
# published value. No LLOQ was tuned to make this true.
stopifnot(min(sens$`LLOQ 1`) < 0.931, max(sens$`LLOQ 0.01`) > 1.08)The 1 mg/L threshold used in the main NCA is retained rather than tuned downward: it is the only concentration for which the paper reports assay recovery, and clearance, volume and MRT – which are dominated by the early, high-concentration part of the profile – are insensitive to it.
The clearance rows are the substantive result. The model reproduces
the sign and ordering of the Table 2 medians that motivated the
paper: within the CF cohort female clearance exceeds male clearance
despite near-identical LBM, whereas within the HV cohort male clearance
exceeds female clearance because male HVs are 41% leaner-heavier. No
covariate other than fu and LBM is doing any work.
cl_sim <- nca_long |>
dplyr::filter(PPTESTCD == "cl.obs") |>
dplyr::group_by(cohort) |>
dplyr::summarise(cl = stats::median(PPORRES), .groups = "drop")
print(as.data.frame(cl_sim))
#> cohort cl
#> 1 CF female 20.60530
#> 2 CF male 17.88466
#> 3 HV female 17.12495
#> 4 HV male 20.32585
cl_of <- function(x) cl_sim$cl[cl_sim$cohort == x]
stopifnot(cl_of("CF female") > cl_of("CF male")) # driven purely by fu
stopifnot(cl_of("HV male") > cl_of("HV female")) # driven purely by LBMUrinary recovery against Table 2
urine_obs <- sim_unique |>
dplyr::filter(!is.na(urinePct), time == 24) |>
dplyr::mutate(group = ifelse(DIS_CF == 1L, "Patients with CF", "Healthy volunteers")) |>
dplyr::group_by(group) |>
dplyr::summarise(
`Simulated median (%)` = round(stats::median(urinePct), 1),
`Simulated 10th-90th (%)` = sprintf(
"%.1f-%.1f",
stats::quantile(urinePct, 0.10), stats::quantile(urinePct, 0.90)
),
.groups = "drop"
) |>
dplyr::mutate(
`Observed median (%)` = c(66.3, 70.3),
`Observed range (%)` = c("59.4-72.7", "47.6-77.8")
)
knitr::kable(
urine_obs,
caption = "Cumulative 24 h urinary recovery of unchanged cefotiam vs Shah 2019 Table 2."
)| group | Simulated median (%) | Simulated 10th-90th (%) | Observed median (%) | Observed range (%) |
|---|---|---|---|---|
| Healthy volunteers | 69.8 | 58.2-76.4 | 66.3 | 59.4-72.7 |
| Patients with CF | 68.8 | 58.7-76.6 | 70.3 | 47.6-77.8 |
Assumptions and deviations
- Only the final model is encoded. The Supplementary Materials also report a disease-scale-factor model (Table S1) and a glomerular-filtration / tubular-secretion model (Table S2). Neither is presented by the paper as final, and Table S2 fitted worse (-2x log-likelihood worse by 7.1), so both are excluded as model-development iterations.
- Six-figure parameter values come from the authors’ own code. Where the Berkeley Madonna listing in the Supplementary Materials prints more significant figures than Table 4, the listing is used. Every such value is quoted against its Table 4 rounding in the model file comments; none disagrees with Table 4 beyond rounding.
-
BSV is a log-scale standard deviation. Table 4
footnote a calls the BSV an “apparent coefficient of variation of a
normal distribution on natural logarithmic scale”, and Section 2.6.4
says eta had “standard deviation BSV”. The model therefore uses
omega^2 = BSV^2, notlog(1 + CV^2). The supplement’snormal(0, CV_CLR)draws confirm this directly. - No LLOQ is published, and the terminal half-life comparison is therefore not well posed. Simulated profiles are censored at 1 mg/L for the NCA only, that being the lowest concentration at which Section 2.5 reports assay recovery; the model itself has no censoring. This makes every simulated terminal half-life 26-34% lower than Table 2, uniformly across all four arms. The sensitivity sweep above shows the NCA estimate moving from 0.68 h at a 1 mg/L limit to 1.70 h uncensored, so the published 0.931 / 1.08 h medians imply a real quantification limit near 0.3-0.5 mg/L. The censoring threshold was deliberately not lowered to close the gap, because choosing an LLOQ to match a validation target is tuning. Half-life is instead excluded from the pooled numeric gate and checked separately against the published ranges, which needs no LLOQ assumption. Clearance, volume and MRT are dominated by the early profile and are insensitive to the threshold.
-
The urinary endpoint needs a dose column. The paper
fitted the fraction of dose in urine and reports its residual
SD on a percent scale (0.384%). To keep that published number attached
to the paper’s own observation scale, the model expresses
urinePct = 100 * urine / DOSE_CEFOTIAM_MG. A user who prefers a cumulative amount can read theurinestate directly, in mg. - Cohort LBM distributions are reconstructed, not published. Table 1 gives per-sex medians and ranges but no distributional form. The cohort here is drawn log-normally with the 15% CV the paper used in its own Monte Carlo covariate model (Section 2.6.7) and truncated to the Table 1 ranges. The HV female range in Table 1 is extremely narrow (44.6-45.4 kg, n = 3), so that arm is nearly deterministic.
- Table 2 reports Cmax, half-life and MRT by cohort only, not by sex, so the cohort value is carried onto both sexes of that cohort in the comparison table. Only clearance and Vss are genuinely sex-stratified in Table 2.
-
No albumin covariate. The Supplementary Materials
back-calculate an albumin ratio consistent with the estimated
fuvalues, but the study did not measure albumin and the paper states the effect of albumin “was not included in the model”. No albumin column is carried here. -
furandom effects. The three estimated unbound fractions carry a BSV the authors fixed at 5% CV (Table 4 footnote d); the fixed HV-malefucarries no random effect, matching the supplement listing, which drawsETA_FU_CFF,ETA_FU_CFMandETA_FU_HVFbut noETA_FU_HVM.