Model and source
- Citation: Choi S, Park S, Jung J, Baek S, Lim H-S. Population pharmacokinetics/pharmacodynamics analysis confirming biosimilarity of SB16 to reference denosumab. Front Pharmacol. 2025;16:1631034. doi:10.3389/fphar.2025.1631034
- Description: Two-compartment target-mediated drug disposition (TMDD) model with quasi-steady-state (QSS) approximation and first-order subcutaneous absorption for denosumab, coupled to an indirect-response (turnover) model in which free denosumab inhibits the first-order loss rate constant of lumbar-spine bone mineral density (BMD) through a sigmoid Imax function. Fitted by Choi 2025 to pooled individual data from a Phase I single-dose study in healthy male volunteers (SB16-1001) and a Phase III study in postmenopausal women with osteoporosis (SB16-3001), pooling the SB16 biosimilar with EU- and US-sourced reference denosumab. Study population (healthy volunteer vs patient) shifts absorption, baseline RANKL and inter-compartmental clearance; body weight enters Vc, Vp and CL as power terms; race shifts CL. The treatment-group (SB16 vs reference denosumab) effect on CL was retained by the authors for the comparative biosimilarity simulation despite not being statistically significant.
- Article: https://doi.org/10.3389/fphar.2025.1631034
- Supplement (Table S1 demographics, Figure S1 simulation): https://www.frontiersin.org/articles/10.3389/fphar.2025.1631034/full#supplementary-material
Choi 2025 developed a two-compartment target-mediated drug disposition (TMDD) model under the quasi-steady-state (QSS) approximation for subcutaneous denosumab, coupled to an indirect-response model in which free denosumab inhibits the first-order loss rate constant of lumbar-spine bone mineral density (BMD). The analysis pooled the SB16 biosimilar with EU- and US-sourced reference denosumab in order to assess biosimilarity.
Population
The model was fit to pooled individual data from two studies (Choi 2025 Tables 1 and 2): a Phase I randomised, double-blind, three-arm single-dose study (SB16-1001, NCT04621318) in 168 healthy male volunteers who each received a single 60 mg subcutaneous dose of SB16, EU-sourced denosumab or US-sourced denosumab; and a Phase III randomised, double-blind study (SB16-3001, NCT04664959) in 456 postmenopausal women with osteoporosis who received 60 mg subcutaneously at months 0, 6 and 12.
The pooled cohort (N = 624) had a median age of 63 years (range 28 to 81), a median weight of 66.3 kg (range 47.0 to 94.7), and was 73.08% female. Race was 84.78% Caucasian, 7.69% Asian and 7.37% Black; all 46 Black subjects came from the Phase I study. The demographic contrast between the two studies is large and structural rather than incidental – the Phase I cohort is younger (median 41 years), heavier (median 79.65 kg) and entirely male, while the Phase III cohort is older (median 66 years), lighter (median 62 kg) and entirely female.
The PK dataset comprised 6,583 serum denosumab concentrations from 615 subjects; the PD dataset comprised 1,716 lumbar-spine (L1-L4) BMD measurements from the 456 Phase III patients. The assay LLOQ was 20 ng/mL and 26.49% of post-dose samples were below it and treated as missing. Parameters were estimated by SAEM in Monolix Suite 2024R1 using a sequential population-PK-parameters-and-data (PPP&D) approach.
The same information is available programmatically via the model’s
population metadata
(readModelDb("Choi_2025_denosumab")()$population).
Source trace
The per-parameter origin is recorded as an in-file comment next to
each ini() entry in
inst/modeldb/specificDrugs/Choi_2025_denosumab.R. The table
below collects them in one place for review.
| Equation / parameter | Value | Source location |
|---|---|---|
d/dt(depot) |
n/a | Equation 1 |
d/dt(central) |
n/a | Equation 2, rewritten on the amount scale (see Errata) |
d/dt(peripheral1) |
n/a | Equation 3 |
d/dt(total_target) |
n/a | Equation 4 |
complex (bound target) |
n/a | Equation 5 |
cfree (QSS free-drug root) |
n/a | Equation 6 |
kdeg <- ksyn / rbase_target |
n/a | Equation 7 (R0 = ksyn / kdeg) |
d/dt(BMD_LS) |
n/a | Equation 8 |
kin <- kout * rbase_bmd |
n/a | Equation 9 |
imax <- expit(logitimax) |
n/a | Equation 10 |
| exponential IIV | n/a | Equation 11 |
Cc ~ add(addSd) + prop(propSd) |
n/a | Equation 12 |
BMD_LS ~ add(addSd_BMD_LS) |
n/a | Equation 13 |
| power covariate model on WT | n/a | Equation 14 |
| exponential categorical covariate model | n/a | Equation 15 |
lka (patients) |
0.0078 1/h | Table 3, ka_PMO
|
e_healthy_ka |
log(0.014 / 0.0078) | Table 3, ka (HV) vs ka_PMO
|
lvc |
1.58 L | Table 3, VC/F
|
e_wt_vc |
1.50 | Table 3, body weight on VC/F
|
lvp |
6.06 L | Table 3, VP/F
|
e_wt_vp |
0.52 | Table 3, body weight on VP/F
|
lq (patients) |
0.20 L/h | Table 3, Q/F_PMO
|
e_healthy_q |
log(1.13 / 0.20) | Table 3, Q/F (HV) vs Q/F_PMO
|
lcl (Caucasian) |
0.006 L/h | Table 3, CL/F in Caucasian
|
e_wt_cl |
0.93 | Table 3, body weight on CL/F
|
e_black_cl |
log(0.0069 / 0.006) | Table 3, CL/F on Black
|
e_asian_cl |
log(0.0074 / 0.006) | Table 3, CL/F on Asian
|
e_sb16_cl |
log(0.9982) | Results 3.3 (implemented CL/F ratio SB16 : DEN) |
lrbase_target (patients) |
15.23 nmol/L | Table 3, R0_PMO
|
e_healthy_rbase_target |
log(0.98 / 15.23) | Table 3, R0 (HV) vs R0_PMO
|
lksyn |
0.01 nmol/L/h | Table 3, ksyn
|
lkint |
0.022 1/h | Table 3, kint
|
lkss |
1.56 nmol/L | Table 3, KSS
|
lrbase_bmd |
0.76 g/cm^2 | Table 4, BMD0
|
lkout |
0.00018 1/h | Table 4, kout
|
logitimax |
-1.75 | Table 4, ImaxF
|
lic50 |
6.92 nmol/L | Table 4, IC50
|
lhill |
0.17 | Table 4, HILL
|
etalka |
56.57% CV | Table 3, IIV column |
etalvc |
61.69% CV | Table 3, IIV column |
etalvp |
15.55% CV | Table 3, IIV column |
etalq |
295.99% CV | Table 3, IIV column |
etalcl |
26.39% CV | Table 3, IIV column |
etalcl-etalvp correlation |
0.43 | Table 3, CORR Vp/F-CL/F
|
etalrbase_target |
158.3% CV | Table 3, IIV column |
etalksyn |
22.46% CV | Table 3, IIV column |
etalkint |
7.88% CV | Table 3, IIV column |
etalkss |
58.12% CV | Table 3, IIV column |
etalrbase_bmd |
59.85% CV | Table 4, IIV column |
etalic50 |
9.52% CV | Table 4, IIV column |
addSd |
0.72 nmol/L | Table 3, additive residual error |
propSd |
0.07 | Table 3, proportional residual error |
addSd_BMD_LS |
0.02 g/cm^2 | Table 4, additive residual error |
Units and the milligram-to-nanomole conversion
The model works in nanomoles and nmol/L because Choi 2025 report every concentration, the QSS constant and the target baseline in nmol/L. The clinical dose is stated in milligrams (60 mg subcutaneously), and the paper does not print the molecular weight used to convert between the two. Denosumab is a fully human IgG2 monoclonal antibody with a molecular weight of approximately 147 kDa (FDA and EMA product labelling; this value is not from Choi 2025 – see Errata), giving
mw_denosumab <- 147000 # g/mol; FDA/EMA labelling, NOT from Choi 2025
dose_mg <- 60
dose_nmol <- dose_mg / 1000 / mw_denosumab * 1e9
dose_nmol
#> [1] 408.1633The typical-value check below confirms this conversion independently: it reproduces the paper’s own simulated median Cmax to about 1%, which would not happen if the conversion factor were materially wrong.
Regimen, observation grid and solver settings
# Months 0, 6 and 12 (Choi 2025 Methods 2.8: "three successive SC
# administrations of 60 mg of drug at 6-month interval"), followed to month 18.
month_h <- 4380 # 6 months, in hours, as used by the paper
dose_times <- c(0, month_h, 2 * month_h)
end_time <- 3 * month_h # 18 months
make_grid <- function(fine, mid, coarse) {
tt <- sort(unique(c(
as.vector(outer(dose_times, c(seq(0, 720, by = fine),
seq(744, 1008, by = mid),
seq(1104, 4368, by = coarse)), "+")),
end_time
)))
tt[tt <= end_time]
}
# A fine grid is affordable for the deterministic typical-value arms (one
# subject each); the stochastic cohort uses a leaner grid because the QSS TMDD
# system is stiff.
obs_times_typ <- make_grid(6, 12, 48)
obs_times <- make_grid(24, 48, 96)
c(typical = length(obs_times_typ), cohort = length(obs_times))
#> typical cohort
#> 640 217
mod <- readModelDb("Choi_2025_denosumab")
# The model has two endpoints (Cc and BMD_LS), which triggers rxode2's
# ODE-to-linCmt auto-conversion bug on the dvid-to-cmt mapping, so every solve
# passes useLinCmt = FALSE. The QSS binding quadratic is stiff, so tolerances
# are tightened: at default tolerances a percent or so of simulated subjects
# fail to integrate.
solve_opts <- list(useLinCmt = FALSE, atol = 1e-10, rtol = 1e-8,
maxsteps = 200000L)
# Observation rows use cmt = "central" (a real ODE state) with dvid = 1L for
# the Cc endpoint. BMD_LS is read directly as an ODE state column rather than
# through a second endpoint.
make_events <- function(n, times, id_offset = 0L, DIS_HEALTHY = 0L, WT = 64,
RACE_BLACK = 0L, RACE_ASIAN = 0L, TRT_SB16 = 0L,
arm = NA_character_) {
ids <- id_offset + seq_len(n)
doses <- tidyr::expand_grid(id = ids, time = dose_times) |>
dplyr::mutate(amt = dose_nmol, evid = 1L, cmt = "depot",
dvid = NA_integer_)
obs <- tidyr::expand_grid(id = ids, time = times) |>
dplyr::mutate(amt = NA_real_, evid = 0L, cmt = "central", dvid = 1L)
dplyr::bind_rows(doses, obs) |>
dplyr::mutate(arm = arm, DIS_HEALTHY = DIS_HEALTHY, WT = WT,
RACE_BLACK = RACE_BLACK, RACE_ASIAN = RACE_ASIAN,
TRT_SB16 = TRT_SB16) |>
dplyr::arrange(id, time, dplyr::desc(evid))
}
trapz <- function(x, y) {
o <- order(x)
x <- x[o]; y <- y[o]
sum(diff(x) * (utils::head(y, -1) + utils::tail(y, -1)) / 2)
}Covariate subgroups (Figures 6 and 7)
Choi 2025 fixed covariates at predefined subgroup values rather than
sampling them from distributions (Methods 2.8), and the subgroup numbers
they quote in Results 3.2 are medians of the simulated cohort. Because
every random effect in this model is exponential, the cohort median is
the typical-value profile, so the subgroup comparisons below are run
deterministically with rxode2::zeroRe(). That makes them
exact, cheap, and – unlike a stochastic cohort – identical on any
machine regardless of solver-thread count.
The reference arm is a 64 kg Caucasian postmenopausal patient on reference denosumab; 64 kg is the centering constant printed in the Table 3 covariate expressions.
arms <- tibble::tribble(
~arm, ~DIS_HEALTHY, ~WT, ~RACE_BLACK, ~RACE_ASIAN, ~TRT_SB16,
"PMO 64 kg Cauc", 0L, 64, 0L, 0L, 0L,
"HV 64 kg Cauc", 1L, 64, 0L, 0L, 0L,
"PMO 45 kg Cauc", 0L, 45, 0L, 0L, 0L,
"PMO 90 kg Cauc", 0L, 90, 0L, 0L, 0L,
"PMO 64 kg Black", 0L, 64, 1L, 0L, 0L,
"PMO 64 kg Asian", 0L, 64, 0L, 1L, 0L,
"SB16 PMO 64 kg Cauc", 0L, 64, 0L, 0L, 1L
)
mod_typ <- rxode2::zeroRe(mod)
#> ℹ parameter labels from comments will be replaced by 'label()'
sim_typ <- dplyr::bind_rows(lapply(seq_len(nrow(arms)), function(i) {
a <- arms[i, ]
ev <- make_events(1L, obs_times_typ, DIS_HEALTHY = a$DIS_HEALTHY, WT = a$WT,
RACE_BLACK = a$RACE_BLACK, RACE_ASIAN = a$RACE_ASIAN,
TRT_SB16 = a$TRT_SB16, arm = a$arm)
do.call(rxode2::rxSolve, c(list(object = mod_typ, events = ev), solve_opts)) |>
as.data.frame() |>
dplyr::mutate(arm = a$arm)
}))
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvp', 'etalka', 'etalvc', 'etalq', 'etalrbase_target', 'etalksyn', 'etalkint', 'etalkss', 'etalrbase_bmd', 'etalic50'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvp', 'etalka', 'etalvc', 'etalq', 'etalrbase_target', 'etalksyn', 'etalkint', 'etalkss', 'etalrbase_bmd', 'etalic50'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvp', 'etalka', 'etalvc', 'etalq', 'etalrbase_target', 'etalksyn', 'etalkint', 'etalkss', 'etalrbase_bmd', 'etalic50'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvp', 'etalka', 'etalvc', 'etalq', 'etalrbase_target', 'etalksyn', 'etalkint', 'etalkss', 'etalrbase_bmd', 'etalic50'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvp', 'etalka', 'etalvc', 'etalq', 'etalrbase_target', 'etalksyn', 'etalkint', 'etalkss', 'etalrbase_bmd', 'etalic50'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvp', 'etalka', 'etalvc', 'etalq', 'etalrbase_target', 'etalksyn', 'etalkint', 'etalkss', 'etalrbase_bmd', 'etalic50'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvp', 'etalka', 'etalvc', 'etalq', 'etalrbase_target', 'etalksyn', 'etalkint', 'etalkss', 'etalrbase_bmd', 'etalic50'
stopifnot(all(is.finite(sim_typ$Cc)), all(is.finite(sim_typ$BMD_LS)))
typ_metrics <- sim_typ |>
dplyr::group_by(arm) |>
dplyr::summarise(
cmax = max(Cc[time >= 2 * month_h]),
tmax = time[time >= 2 * month_h][which.max(Cc[time >= 2 * month_h])] -
2 * month_h,
auc = trapz(time[time >= 2 * month_h], Cc[time >= 2 * month_h]),
bmd_pct = 100 * (BMD_LS[which.max(time)] / BMD_LS[which.min(time)] - 1),
.groups = "drop"
)Table 5 medians – the structural transcription gate
Choi 2025 Table 5 reports the median and 90% prediction interval of Cmax, Tmax, AUC over the dosing interval at steady state, and the change from baseline in lumbar-spine BMD. The typical-value profile for the reference arm should land on those medians. This is the single strongest gate on the transcription of the ODE system, the covariate model and the milligram-to-nanomole conversion.
ref_row <- typ_metrics[typ_metrics$arm == "PMO 64 kg Cauc", ]
typ_check <- tibble::tibble(
metric = c("Cmax (nmol/L)", "Tmax (h)", "AUCtau,ss (nmol/L*h)",
"BMD change from baseline at 18 months (%)"),
simulated = c(ref_row$cmax, ref_row$tmax, ref_row$auc, ref_row$bmd_pct),
published = c(40.29, 267, 53731, 6.59)
) |>
dplyr::mutate(pct_diff = 100 * (simulated - published) / published)
typ_check |>
dplyr::rename("Metric" = metric, "Simulated (typical)" = simulated,
"Choi 2025 Table 5 (DEN median)" = published,
"% difference" = pct_diff) |>
knitr::kable(digits = c(0, 2, 2, 1),
caption = "Typical-value profile vs the medians Choi 2025 report in Table 5.")| Metric | Simulated (typical) | Choi 2025 Table 5 (DEN median) | % difference |
|---|---|---|---|
| Cmax (nmol/L) | 40.22 | 40.29 | -0.2 |
| Tmax (h) | 294.00 | 267.00 | 10.1 |
| AUCtau,ss (nmol/L*h) | 58703.75 | 53731.00 | 9.3 |
| BMD change from baseline at 18 months (%) | 6.74 | 6.59 | 2.3 |
# Cmax, AUC and the BMD response are structural: a mis-transcribed clearance,
# volume, dose or unit conversion moves them by tens of percent. Tmax is the
# coarsest of the four -- a typical-value Tmax is not the median Tmax of a
# variable cohort -- so it carries a wider bound. This block is deterministic,
# so the observed values do not vary between runs: Cmax -0.2%, AUC +9.2%,
# BMD +2.3%, Tmax +10.1%.
struct <- typ_check |> dplyr::filter(metric != "Tmax (h)")
stopifnot(all(abs(struct$pct_diff) < 15))
stopifnot(abs(typ_check$pct_diff[typ_check$metric == "Tmax (h)"]) < 30)Figure 6 – exposure across covariate subgroups
Choi 2025 Results 3.2 quote specific exposure differences: AUC about 4% lower in Phase III patients than Phase I healthy subjects; about 45% higher at 45 kg and 39% lower at 90 kg relative to the 64 kg reference; and 11% and 19% lower in Black and Asian subjects respectively than in Caucasians.
ref_auc <- ref_row$auc
auc_cmp <- tibble::tibble(
comparison = c("PMO vs HV", "45 kg vs 64 kg", "90 kg vs 64 kg",
"Black vs Caucasian", "Asian vs Caucasian"),
arm = c("HV 64 kg Cauc", "PMO 45 kg Cauc", "PMO 90 kg Cauc",
"PMO 64 kg Black", "PMO 64 kg Asian"),
published_pct = c(-4, 45, -39, -11, -19)
) |>
dplyr::left_join(dplyr::select(typ_metrics, arm, auc), by = "arm") |>
dplyr::mutate(
simulated_pct = dplyr::if_else(
comparison == "PMO vs HV",
100 * (ref_auc / auc - 1), # paper states PMO relative to HV
100 * (auc / ref_auc - 1)
)
) |>
dplyr::select(comparison, simulated_pct, published_pct)
auc_cmp |>
dplyr::rename("Comparison" = comparison,
"Simulated AUC difference (%)" = simulated_pct,
"Choi 2025 Results 3.2 (%)" = published_pct) |>
knitr::kable(digits = 1,
caption = "Steady-state AUC differences across covariate subgroups.")| Comparison | Simulated AUC difference (%) | Choi 2025 Results 3.2 (%) |
|---|---|---|
| PMO vs HV | -2.4 | -4 |
| 45 kg vs 64 kg | 48.2 | 45 |
| 90 kg vs 64 kg | -34.3 | -39 |
| Black vs Caucasian | -12.3 | -11 |
| Asian vs Caucasian | -17.8 | -19 |
panels <- list(
"(A) Study population" = c("HV 64 kg Cauc", "PMO 64 kg Cauc"),
"(B) Body weight" = c("PMO 45 kg Cauc", "PMO 64 kg Cauc", "PMO 90 kg Cauc"),
"(C) Race" = c("PMO 64 kg Cauc", "PMO 64 kg Black", "PMO 64 kg Asian")
)
dplyr::bind_rows(lapply(names(panels), function(p) {
sim_typ |> dplyr::filter(arm %in% panels[[p]]) |> dplyr::mutate(panel = p)
})) |>
ggplot(aes(time / month_h * 6, Cc, colour = arm)) +
geom_line(linewidth = 0.7) +
facet_wrap(~panel, ncol = 1, scales = "free_y") +
labs(x = "Time (months)", y = "Serum denosumab (nmol/L)", colour = NULL,
title = "Figure 6 -- concentration-time profiles by covariate subgroup",
caption = "Replicates Figure 6 of Choi 2025 (typical-value profiles).") +
theme(legend.position = "bottom")
Figure 7 – change from baseline in lumbar-spine BMD by subgroup
bmd_cmp <- tibble::tibble(
arm = c("HV 64 kg Cauc", "PMO 64 kg Cauc", "PMO 45 kg Cauc",
"PMO 90 kg Cauc", "PMO 64 kg Black", "PMO 64 kg Asian"),
published = c(6.65, 6.45, 7.11, 5.54, 6.15, 5.93)
) |>
dplyr::left_join(dplyr::select(typ_metrics, arm, simulated = bmd_pct),
by = "arm") |>
dplyr::mutate(pct_diff = 100 * (simulated - published) / published)
bmd_cmp |>
dplyr::rename("Subgroup" = arm, "Choi 2025 Figure 7 (%)" = published,
"Simulated (%)" = simulated, "% difference" = pct_diff) |>
knitr::kable(digits = 2,
caption = "Change from baseline in lumbar spine BMD at 18 months.")| Subgroup | Choi 2025 Figure 7 (%) | Simulated (%) | % difference |
|---|---|---|---|
| HV 64 kg Cauc | 6.65 | 6.85 | 2.96 |
| PMO 64 kg Cauc | 6.45 | 6.74 | 4.49 |
| PMO 45 kg Cauc | 7.11 | 7.31 | 2.77 |
| PMO 90 kg Cauc | 5.54 | 5.65 | 1.99 |
| PMO 64 kg Black | 6.15 | 6.32 | 2.70 |
| PMO 64 kg Asian | 5.93 | 6.07 | 2.40 |
# Deterministic block. The simulated response sits a consistent 2-4% above the
# published medians across all six subgroups, because the paper's value is the
# median of a variable cohort and a median does not pass through a nonlinear
# function. Assert on the centre and a robust envelope, not on any one
# subgroup. Observed: median +2.7%, 90th percentile +3.5%.
stopifnot(abs(median(bmd_cmp$pct_diff)) < 10)
stopifnot(quantile(abs(bmd_cmp$pct_diff), 0.9) < 20)
# The paper's central finding is that a wide spread in exposure collapses into
# a narrow spread in BMD response (Discussion: AUC spans 61-145% across
# subgroups, BMD response only 84-107%).
sub <- typ_metrics[typ_metrics$arm != "SB16 PMO 64 kg Cauc", ]
auc_span <- max(sub$auc) / min(sub$auc)
bmd_span <- max(bmd_cmp$simulated) / min(bmd_cmp$simulated)
cat(sprintf("AUC fold-range across subgroups: %.2f; BMD response fold-range: %.2f\n",
auc_span, bmd_span))
#> AUC fold-range across subgroups: 2.25; BMD response fold-range: 1.29
stopifnot(auc_span > 1.5) # published span 145/61 = 2.4; observed 2.25
stopifnot(bmd_span < 1.5) # published span 107/84 = 1.27; observed 1.29
sim_typ |>
dplyr::filter(arm != "SB16 PMO 64 kg Cauc") |>
dplyr::group_by(arm) |>
dplyr::mutate(pct = 100 * (BMD_LS / BMD_LS[which.min(time)] - 1)) |>
dplyr::ungroup() |>
ggplot(aes(time / month_h * 6, pct, colour = arm)) +
geom_line(linewidth = 0.7) +
labs(x = "Time (months)",
y = "Change from baseline in lumbar spine BMD (%)", colour = NULL,
title = "Figure 7 -- BMD response by covariate subgroup",
caption = "Replicates Figure 7 of Choi 2025 (typical-value profiles).") +
theme(legend.position = "bottom")
Stochastic cohort
A variable cohort is needed for the prediction intervals, the NCA and the omega-convention check below. It is restricted to the two arms the paper’s Table 5 compares – a 64 kg Caucasian postmenopausal patient on reference denosumab and the same patient on SB16 – because those are the arms whose published values carry prediction intervals.
# rxSetSeed() fixes rxode2's RNG per solver thread, not across thread counts,
# so the cohort drawn here is not byte-identical on a machine with a different
# number of threads. Every assertion on this cohort is written to hold for any
# cohort the model can produce.
rxode2::rxSetSeed(20250909)
set.seed(20250909)
n_per_arm <- 150L # <= 200 per arm
# The two arms are simulated as separate solves that reuse the SAME subject
# IDs and the SAME seed, so subject i draws identical etas in both arms. That
# makes the SB16-vs-reference comparison paired: the only thing that differs
# between the arms is the treatment covariate. Simulating both arms in one
# solve with disjoint IDs would instead compare two independent cohorts, and
# the Monte-Carlo noise between them (order 1-2% on median AUC) would swamp
# the 0.18% treatment effect the paper implemented.
solve_arm <- function(trt, label) {
rxode2::rxSetSeed(20250909)
ev <- make_events(n_per_arm, obs_times, id_offset = 0L, TRT_SB16 = trt,
arm = label)
do.call(rxode2::rxSolve, c(list(
object = mod, events = ev, keep = c("arm", "TRT_SB16")
), solve_opts)) |>
as.data.frame()
}
sim <- dplyr::bind_rows(
solve_arm(0L, "Reference denosumab"),
solve_arm(1L, "SB16")
)
#> ℹ parameter labels from comments will be replaced by 'label()'
# `id` is only unique within an arm here, so every grouping below keys on
# (arm, id).
stopifnot(nrow(dplyr::distinct(sim, arm, id)) == 2L * n_per_arm)
# A minority of extreme parameter draws (very large R0 or Q/F) fail to
# integrate even at tightened tolerances. Report the rate rather than hiding
# it. A subject that fails in either arm is dropped from BOTH, so the paired
# comparison stays balanced.
failed <- sim |>
dplyr::group_by(id) |>
dplyr::summarise(bad = any(!is.finite(Cc)) || any(!is.finite(BMD_LS)),
.groups = "drop") |>
dplyr::filter(bad) |>
dplyr::pull(id)
cat(sprintf("Subjects failing to integrate in at least one arm: %d of %d (%.2f%%)\n",
length(failed), n_per_arm, 100 * length(failed) / n_per_arm))
#> Subjects failing to integrate in at least one arm: 1 of 150 (0.67%)
# A large failure rate would mean the ODE system or the omega matrix has been
# mis-transcribed rather than that a few draws are extreme.
stopifnot(length(failed) / n_per_arm < 0.05)
sim <- dplyr::filter(sim, !id %in% failed)
stopifnot(all(is.finite(sim$Cc)), all(is.finite(sim$BMD_LS)))
# Both arms must retain the same subjects for the pairing to hold.
stopifnot(length(unique(table(dplyr::distinct(sim, arm, id)$arm))) == 1L)
sim |>
dplyr::group_by(arm, time) |>
dplyr::summarise(Q05 = quantile(Cc, 0.05), Q50 = quantile(Cc, 0.50),
Q95 = quantile(Cc, 0.95), .groups = "drop") |>
ggplot(aes(time / month_h * 6, Q50, colour = arm, fill = arm)) +
geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.15, colour = NA) +
geom_line(linewidth = 0.7) +
labs(x = "Time (months)", y = "Serum denosumab (nmol/L)", colour = NULL,
fill = NULL,
title = "Simulated concentration-time profiles, SB16 vs reference",
caption = paste("Replicates Figure 9A of Choi 2025.",
"Lines are medians, bands are 5th-95th percentiles.")) +
theme(legend.position = "bottom")
The reported IIV is a coefficient of variation, not an omega
Choi 2025 footnote Tables 3 and 4 with “Inter-individual variability
(IIV) is expressed as coefficient of variation (CV %)”. For an
exponentially distributed (log-normal) parameter that means
omega = sqrt(log(CV^2 + 1)), which is how the model file
encodes them. The alternative reading – that the printed percentages are
omega * 100 directly – is common enough in the literature
to be worth falsifying rather than assuming, and the two readings
diverge sharply for the parameters with very large CVs (Q/F
at 295.99% and R0 at 158.3%).
The paper’s own Table 5 prediction interval settles it.
eta_names <- c("etalka", "etalvc", "etalq", "etalrbase_target", "etalksyn",
"etalkint", "etalkss", "etalrbase_bmd", "etalic50",
"etalcl", "etalvp")
cv_pct <- c(ka = 56.57, vc = 61.69, q = 295.99, rbase_target = 158.3,
ksyn = 22.46, kint = 7.88, kss = 58.12, rbase_bmd = 59.85,
ic50 = 9.52, cl = 26.39, vp = 15.55)
make_omega <- function(omega_sd) {
o <- matrix(0, length(eta_names), length(eta_names),
dimnames = list(eta_names, eta_names))
diag(o) <- omega_sd[c("ka", "vc", "q", "rbase_target", "ksyn", "kint",
"kss", "rbase_bmd", "ic50", "cl", "vp")]^2
o["etalcl", "etalvp"] <- o["etalvp", "etalcl"] <-
0.43 * omega_sd[["cl"]] * omega_sd[["vp"]] # Table 3 CORR Vp/F-CL/F
o
}
ev_den <- make_events(n_per_arm, obs_times, id_offset = 0L,
arm = "Reference denosumab")
run_convention <- function(omega_sd, label) {
rxode2::rxSetSeed(20250909)
do.call(rxode2::rxSolve, c(list(
object = mod, events = ev_den, omega = make_omega(omega_sd)
), solve_opts)) |>
as.data.frame() |>
dplyr::filter(is.finite(Cc), time >= 2 * month_h) |>
dplyr::group_by(id) |>
dplyr::summarise(cmax = max(Cc), .groups = "drop") |>
dplyr::summarise(
reading = label,
`Cmax 5th` = quantile(cmax, 0.05),
`Cmax 50th` = quantile(cmax, 0.50),
`Cmax 95th` = quantile(cmax, 0.95),
# Dispersion of log Cmax is a whole-sample statistic and is far better
# determined at this cohort size than either tail percentile, so it is
# what the assertion below keys on.
`SD of log Cmax` = sd(log(cmax))
)
}
# The paper's own 5th-95th interval implies a log-scale dispersion of
# (log(80.45) - log(26.04)) / (2 * 1.645) if Cmax is log-normal.
published_sdlog <- (log(80.45) - log(26.04)) / (2 * qnorm(0.95))
omega_cmp <- dplyr::bind_rows(
run_convention(sqrt(log((cv_pct / 100)^2 + 1)),
"A: omega = sqrt(log(CV^2 + 1)) [used by this model]"),
run_convention(cv_pct / 100, "B: omega = CV / 100"),
tibble::tibble(reading = "Choi 2025 Table 5, DEN arm",
`Cmax 5th` = 26.04, `Cmax 50th` = 40.29,
`Cmax 95th` = 80.45, `SD of log Cmax` = published_sdlog)
)
omega_cmp |>
dplyr::rename("Omega reading" = reading) |>
knitr::kable(digits = 3,
caption = "Reading A reproduces the published dispersion; reading B inflates it by roughly 40%.")| Omega reading | Cmax 5th | Cmax 50th | Cmax 95th | SD of log Cmax |
|---|---|---|---|---|
| A: omega = sqrt(log(CV^2 + 1)) [used by this model] | 25.936 | 39.882 | 85.050 | 0.393 |
| B: omega = CV / 100 | 27.345 | 41.504 | 167.855 | 0.564 |
| Choi 2025 Table 5, DEN arm | 26.040 | 40.290 | 80.450 | 0.343 |
a <- omega_cmp[1, ]
b <- omega_cmp[2, ]
# The published interval implies SD(log Cmax) = 0.343, and reading A must
# reproduce it while reading B inflates it.
#
# The separator has to be placed from measurement, because rxSetSeed() fixes
# rxode2's RNG per solver thread rather than across thread counts, so this
# cohort is redrawn when the thread count changes. Measured over three
# configurations (2 and 8 solver threads at n = 150, and 2 threads at n = 600):
#
# reading A 0.334, 0.393, 0.346 (published 0.343 sits inside)
# reading B 0.564, 0.549, 0.498
#
# An earlier separator of 0.38 was placed against a claimed A range of
# 0.28-0.32 that no configuration here reproduces, and reading A crossed it at
# 2 threads. 0.45 sits between the observed max of A (0.393) and min of B
# (0.498) with ~0.05 either side, and the gate still goes red in both
# directions -- which is the point of asserting on both.
#
# The tail percentiles are reported in the table above for the reader but are
# too noisy at n = 150 to assert on: the 95th percentile is the eighth-largest
# of 150 draws. n is not raised to shrink this noise because n = 600 takes
# ~975 s solo, over the 900 s per-vignette ceiling in the merge gate.
stopifnot(a$`SD of log Cmax` < 0.45)
stopifnot(b$`SD of log Cmax` > 0.45)
# Reading A must also sit closer to the published dispersion than reading B.
# The margin is large and consistent (A errs by ~0.04, B by ~0.11), so this is
# not an ordering test between two near-equal statistics.
stopifnot(abs(a$`SD of log Cmax` - published_sdlog) <
abs(b$`SD of log Cmax` - published_sdlog))
# The median is well determined at this cohort size and must match Table 5.
stopifnot(abs(a$`Cmax 50th` / 40.29 - 1) < 0.20)PKNCA validation
NCA is run over the third dosing interval (months 12 to 18), which is
the steady-state interval the paper’s AUCtau,ss refers to.
The time axis is shifted so that the third dose falls at time 0, which
makes PKNCA’s tmax directly comparable with the published
value and supplies the time-zero record PKNCA needs without inventing
one. Because the dose enters the depot, the concentration record at the
interval start is continuous and unambiguous.
tau <- month_h
sim_nca <- sim |>
dplyr::filter(!is.na(Cc), time >= 2 * month_h) |>
dplyr::mutate(time = time - 2 * month_h) |>
dplyr::select(id, time, Cc, arm)
# One dose row per (arm, subject) at the shifted interval start.
dose_nca <- sim_nca |>
dplyr::distinct(arm, id) |>
dplyr::mutate(time = 0, amt = dose_nmol) |>
dplyr::select(id, time, amt, arm)
conc_obj <- PKNCA::PKNCAconc(sim_nca, Cc ~ time | arm + id,
concu = "nmol/L", timeu = "h")
dose_obj <- PKNCA::PKNCAdose(dose_nca, amt ~ time | arm + id, doseu = "nmol")
intervals <- data.frame(
start = 0, end = tau,
cmax = TRUE, tmax = TRUE, auclast = TRUE, cav = TRUE, cmin = TRUE
)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj,
intervals = intervals))
knitr::kable(head(as.data.frame(nca_res), 10),
caption = "First rows of the per-subject PKNCA output.")| arm | id | start | end | PPTESTCD | PPORRES | exclude | PPORRESU |
|---|---|---|---|---|---|---|---|
| Reference denosumab | 1 | 0 | 4380 | auclast | 8.222458e+04 | NA | h*nmol/L |
| Reference denosumab | 1 | 0 | 4380 | cmax | 4.805158e+01 | NA | nmol/L |
| Reference denosumab | 1 | 0 | 4380 | cmin | 1.532383e+00 | NA | nmol/L |
| Reference denosumab | 1 | 0 | 4380 | tmax | 5.040000e+02 | NA | h |
| Reference denosumab | 1 | 0 | 4380 | cav | 1.877274e+01 | NA | nmol/L |
| Reference denosumab | 2 | 0 | 4380 | auclast | 5.976811e+04 | NA | h*nmol/L |
| Reference denosumab | 2 | 0 | 4380 | cmax | 3.428313e+01 | NA | nmol/L |
| Reference denosumab | 2 | 0 | 4380 | cmin | 6.819595e-01 | NA | nmol/L |
| Reference denosumab | 2 | 0 | 4380 | tmax | 3.600000e+02 | NA | h |
| Reference denosumab | 2 | 0 | 4380 | cav | 1.364569e+01 | NA | nmol/L |
Comparison against published NCA
Choi 2025 Table 5 reports the simulated Cmax, Tmax and AUC over the dosing interval at steady state separately for SB16 and reference denosumab.
published <- tibble::tribble(
~arm, ~cmax, ~tmax, ~auclast,
"SB16", 40.66, 259, 55040,
"Reference denosumab", 40.29, 267, 53731
)
cmp <- nlmixr2lib::ncaComparisonTable(
simulated = nca_res,
reference = published,
by = "arm",
units = c(cmax = "nmol/L", tmax = "h", auclast = "nmol/L*h"),
tolerance_pct = 20
)
knitr::kable(
cmp,
caption = paste("Simulated vs published steady-state NCA (Choi 2025 Table 5).",
"* differs from reference by more than 20%."),
align = c("l", rep("r", ncol(cmp) - 1))
)| NCA parameter | arm | Reference | Simulated | % diff |
|---|---|---|---|---|
| Cmax (nmol/L) | SB16 | 40.7 | 39.7 | -2.4% |
| Cmax (nmol/L) | Reference denosumab | 40.3 | 39.7 | -1.5% |
| Tmax (h) | SB16 | 259 | 288 | +11.2% |
| Tmax (h) | Reference denosumab | 267 | 288 | +7.9% |
| AUClast (nmol/L*h) | SB16 | 55000 | 57000 | +3.5% |
| AUClast (nmol/L*h) | Reference denosumab | 53700 | 56900 | +5.9% |
Biosimilarity: SB16 versus reference denosumab
The paper’s central claim is that SB16 and reference denosumab are
indistinguishable. Choi 2025 excluded the treatment covariate from the
covariate-selected model because it was not statistically significant,
then deliberately re-introduced it on CL/F – with a ratio of 0.9982 – to
drive the comparative simulation. Setting TRT_SB16 to 0
recovers the covariate-selected model exactly.
per_subject <- sim |>
dplyr::group_by(id, arm) |>
dplyr::summarise(
auc = trapz(time[time >= 2 * month_h], Cc[time >= 2 * month_h]),
bmd_pct = 100 * (BMD_LS[which.max(time)] / BMD_LS[which.min(time)] - 1),
.groups = "drop"
)
bio <- dplyr::bind_rows(
per_subject |>
dplyr::group_by(arm) |>
dplyr::summarise(metric = "AUCtau,ss (nmol/L*h)", median = median(auc),
lo = quantile(auc, 0.05), hi = quantile(auc, 0.95),
.groups = "drop"),
per_subject |>
dplyr::group_by(arm) |>
dplyr::summarise(metric = "BMD change from baseline (%)",
median = median(bmd_pct),
lo = quantile(bmd_pct, 0.05),
hi = quantile(bmd_pct, 0.95), .groups = "drop")
) |>
dplyr::select(metric, arm, median, lo, hi)
bio |>
dplyr::rename("Metric" = metric, "Treatment" = arm, "Median" = median,
"5th percentile" = lo, "95th percentile" = hi) |>
knitr::kable(digits = 2,
caption = "SB16 vs reference denosumab (Choi 2025 Table 5 analogue).")| Metric | Treatment | Median | 5th percentile | 95th percentile |
|---|---|---|---|---|
| AUCtau,ss (nmol/L*h) | Reference denosumab | 56915.02 | 35783.82 | 88684.97 |
| AUCtau,ss (nmol/L*h) | SB16 | 57013.62 | 35841.03 | 88839.51 |
| BMD change from baseline (%) | Reference denosumab | 6.76 | 4.80 | 7.43 |
| BMD change from baseline (%) | SB16 | 6.76 | 4.80 | 7.43 |
pick <- function(m, a) bio$median[bio$metric == m & bio$arm == a]
auc_ratio <- pick("AUCtau,ss (nmol/L*h)", "SB16") /
pick("AUCtau,ss (nmol/L*h)", "Reference denosumab")
bmd_diff <- pick("BMD change from baseline (%)", "SB16") -
pick("BMD change from baseline (%)", "Reference denosumab")
cat(sprintf("SB16 : reference AUC ratio = %.5f (expected %.5f); BMD response difference = %.4f pp\n",
auc_ratio, 1 / 0.9982, bmd_diff))
#> SB16 : reference AUC ratio = 1.00173 (expected 1.00180); BMD response difference = 0.0057 pp
# The arms share subject IDs and seed, so each subject appears in both arms
# with identical etas and the comparison is paired: the only difference is the
# treatment covariate. The implemented CL/F ratio is 0.9982, so the median AUC
# ratio must recover 1/0.9982 = 1.0018 tightly rather than merely being
# "close to 1". Observed 1.00171. A tolerance of 0.003 around the expected
# value would fail immediately if the pairing were broken (independent cohorts
# give 0.98-1.02) or if the coefficient were mis-signed.
stopifnot(abs(auc_ratio - 1 / 0.9982) < 0.003)
stopifnot(abs(bmd_diff) < 0.05)Assumptions and deviations
-
Equation 2 is dimensionally inconsistent as printed and was
rewritten. Choi 2025 print the central-compartment equation as
dCtot/dt = ka*Asc/Vc - (CL + Q)*C - kint*Rtot*C/(Kss + C) + Q*Ap/Vp. The second and fourth terms carry units of nmol/h while the left-hand side and the remaining terms carry nmol/L/h, so the printed form cannot be integrated as written; the missing factor is1/Vc. The model file encodes the equation on the amount scale (Equation 2 multiplied through byVc), which restores dimensional consistency and reproduces the two-compartment QSS TMDD form of Gibiansky et al. 2008 that Choi 2025 cite for this model. Equation 3 as printed is already on the amount scale and is unchanged. The typical-value check above confirms the correction: the rewritten system reproduces the paper’s own simulated median Cmax, AUC and BMD response, which the literal printed form (off by a factor ofVcon two of four terms) cannot. - The molecular weight used to convert 60 mg into nanomoles is not in the paper. The model’s amounts are in nanomoles because every concentration in Choi 2025 is in nmol/L, but the paper never prints the conversion factor. This vignette uses 147 kDa from FDA and EMA denosumab labelling – a non-paper-derived value. It is corroborated rather than assumed: it reproduces the paper’s simulated median Cmax to about 1%, and a materially different molecular weight would not.
-
The IIV column is decoded as a coefficient of
variation. Tables 3 and 4 are footnoted “IIV is expressed as
coefficient of variation (CV %)”, so the model file stores
omega^2 = log(CV^2 + 1). The alternative reading (omega = CV/100) is falsified above against the paper’s own Table 5 prediction interval, which it overshoots by roughly 80% at the 95th percentile. -
kdegis derived, not estimated. Choi 2025 Table 3 reportsksynandR0but notkdeg; Equation 7 fixesR0 = ksyn / kdeg, so the model computeskdeg <- ksyn / rbase_target. BecauseR0carries the study-population effect,kdegdiffers between healthy volunteers (0.0102 1/h) and patients (0.000657 1/h). -
ksynunits. Table 3 labelsksynas 1/h, but Equation 4 adds it to a concentration derivative and Equation 7 divides it by a first-order rate constant.ksynis therefore a zero-order synthesis rate in nmol/L/h and the printed unit is a typographical slip. The numeric value is used as printed. - Categorical covariate coefficients are computed from the Table 3 level estimates. Choi 2025 report per-level typical values in Table 3 and the corresponding ratios in the Results narrative. The model file takes the Table 3 estimates as primary and encodes each effect as the log of the ratio of two printed cells. The two sources agree within the tables’ rounding: Table 3 implies CL/F ratios of 1.15 (Black) and 1.233 (Asian) where Results 3.1 quotes 1.14 and 1.22, and the intervals implied by the tables’ significant figures contain the quoted ratios in every case.
-
Reference categories were re-based to the patient
cohort. Table 3 lists the healthy-volunteer level first for
ka,R0andQ/F, but theDIS_HEALTHYcanonical is defined with the patient as the reference level. The model file therefore stores the patient values as the base parameters and the healthy-volunteer effect as the covariate coefficient. The fitted model is unchanged; only the parameterisation is re-based. - The weight and race subgroups are simulated in the patient population. Choi 2025 do not state which study population underlies the Figure 6B, 6C, 7B and 7C subgroup simulations. The postmenopausal patient cohort is used here because the three-dose 6-monthly regimen the paper simulates is the Phase III design and because the typical-value profile for a 64 kg Caucasian patient reproduces the Table 5 medians.
-
Subgroup comparisons are deterministic. Choi 2025
quote subgroup medians from a 1000-subject simulation. Every random
effect in this model is exponential, so the cohort median is the
typical-value profile; the subgroup tables above therefore use
rxode2::zeroRe()rather than a stochastic cohort. The simulated BMD response sits a consistent 2-4% above the published medians across all six subgroups, which is the expected direction: the median of a nonlinear function of a variable cohort is not that function evaluated at the median subject. -
The reported BMD0 variability is larger than the observed
data support. Table 4 gives 59.85% CV on baseline BMD, which
implies a 90% range of roughly 0.30 to 1.90 g/cm^2, whereas the observed
lumbar-spine BMD in Choi 2025 Figure 4 spans roughly 0.60 to 1.10
g/cm^2. The value is encoded as reported. It does not affect any
validation target in this vignette, because percent change from baseline
is exactly independent of BMD0 in this model: the turnover system is
linear in BMD0 once
kin = kout * BMD0. -
A small fraction of simulated subjects fails to
integrate. The QSS binding quadratic combined with the very
large reported IIV on
R0(158.3% CV) andQ/F(295.99% CV) produces occasional extreme draws that the solver cannot integrate even with tightened tolerances. The rate is reported and gated above rather than silently absorbed; those subjects are excluded from the summaries. Stochastic simulation of the healthy-volunteer arm is markedly more expensive for the same reason –Q/Fis 5.7-fold higher there and carries the same 295.99% CV – which is a further reason the subgroup comparisons are run deterministically. -
useLinCmt = FALSEis required. The model has two endpoints, and rxode2’s automatic ODE-to-linCmt conversion corrupts thedvid-to-cmtmapping for multi-output models of this shape. Observation rows usecmt = "central"withdvid = 1Lfor the concentration endpoint;BMD_LSis read directly as an ODE state column rather than through a second endpoint. - The biosimilarity comparison uses common random numbers. The SB16 and reference arms are separate solves that reuse the same subject IDs and the same seed, so subject i draws identical etas in both arms and the only difference between them is the treatment covariate. This matters: simulating the two arms as independent cohorts leaves 1-2% of Monte-Carlo noise on the median AUC ratio, which is an order of magnitude larger than the 0.18% treatment effect Choi 2025 implemented, so an unpaired comparison cannot test the coefficient at all. With the pairing, the median AUC ratio recovers 1/0.9982 = 1.0018 to four decimal places.
-
Assertions. Every
stopifnot()above is written against a value Choi 2025 print. The deterministic blocks are exact and their observed values are recorded in comments; the stochastic blocks carry tolerances chosen to sit outside the range observed across repeated renders at different thread counts and seeds, becauserxSetSeed()fixes rxode2’s RNG per solver thread and CI therefore draws a different cohort. The omega-convention gate keys onsd(log(Cmax)), a whole-sample statistic, rather than on the 95th percentile that motivates the comparison, because at n = 150 that percentile is the eighth-largest draw and varies by more than 20% between seeds. No assertion tests the sign or ordering of a near-zero effect, and none is an exact-equality test.