Atacicept (Pitsiu 2023)
Source:vignettes/articles/Pitsiu_2023_atacicept.Rmd
Pitsiu_2023_atacicept.RmdModel and source
- Citation: Pitsiu M, Yalkinoglu O, Farrell C, Girard P, Vazquez-Mateo C, Papasouliotis O. Population pharmacokinetics of atacicept in systemic lupus erythematosus: An analysis of three clinical trials. CPT Pharmacometrics Syst Pharmacol. 2023;12(8):1157-1169. doi:10.1002/psp4.12982
- Description: Two-compartment quasi-steady-state (QSS) approximation of the target-mediated drug disposition (TMDD) model with first-order subcutaneous absorption for total atacicept (free plus BLyS/APRIL-bound) in healthy volunteers and patients with systemic lupus erythematosus (Pitsiu 2023, Table 1). Apparent clearance and central volume scale allometrically with body weight (exponents fixed at 0.75 and 1); the baseline total-target concentration Rmax scales as a power of baseline serum BLyS. Residual error is proportional and stratified by SLE status.
- Article: https://doi.org/10.1002/psp4.12982 (open access; PMC10431037)
- Supplement:
PSP4-12-1157-s001.txt(the final NONMEM control stream) andPSP4-12-1157-s002.docx(Table S1 demographics, Figure S1 schematic and differential equations, Figure S2 CIVPCs), retrieved from the EuropePMC supplementary-files endpoint for PMC10431037.
Atacicept is a recombinant soluble fusion protein that blocks both BLyS (B lymphocyte stimulator, also called BAFF) and APRIL. Because both targets circulate and are bound by drug, atacicept shows target-mediated drug disposition; the assay measures total atacicept (unbound plus target-bound) after an acid-dissociation step, which is why the authors used a quasi-steady-state (QSS) approximation rather than a full TMDD model – the full model needs both free and total measurements to be identifiable.
Population
The model pools three trials: a phase I single-dose study in healthy volunteers (EMR700461-022, n = 37, 533 observations), APRIL-SLE (NCT00624338, phase II, n = 298, 1728 observations) and ADDRESS II (NCT01972568, phase IIb, n = 205, 1379 observations) – 540 subjects and 3640 total-atacicept serum records in all. 503 subjects (93.1%) had systemic lupus erythematosus; the remaining 37 (6.9%) were healthy volunteers.
Baseline characteristics (supplementary Table S1): 89.6% female; median age 37 years (range 16-75); median body weight 65.0 kg (range 37.0-135); median creatinine clearance 110 mL/min (range 39.0-270). Race was 69.1% White, 20.0% Asian, 4.1% African/African American and 6.9% Other. Median baseline serum BLyS was 1.79 ng/mL (range 0.00-39.4), with 235 of 540 subjects (44%) below the 1.56 ng/mL assay limit of quantification. Median baseline APRIL was 2011 pg/mL. Doses were 25, 75 or 150 mg subcutaneously.
The same information is available programmatically via
readModelDb("Pitsiu_2023_atacicept")()$population.
pop <- rxode2::rxode(readModelDb("Pitsiu_2023_atacicept"))$population
#> ℹ parameter labels from comments will be replaced by 'label()'
str(pop, max.level = 1)
#> List of 15
#> $ species : chr "human"
#> $ n_subjects : num 540
#> $ n_studies : num 3
#> $ n_observations: num 3640
#> $ age_range : chr "16-75 years"
#> $ age_median : chr "37 years"
#> $ weight_range : chr "37.0-135 kg"
#> $ weight_median : chr "65.0 kg"
#> $ sex_female_pct: num 89.6
#> $ race_ethnicity: Named num [1:4] 69.1 4.1 20 6.9
#> ..- attr(*, "names")= chr [1:4] "White" "African/African American" "Asian" "Other"
#> $ disease_state : chr "503 patients (93.1%) with moderate-to-severe or active autoantibody-positive systemic lupus erythematosus; 37 h"| __truncated__
#> $ dose_range : chr "25, 75, or 150 mg subcutaneous: single dose (phase I), or bi-weekly for 4 weeks then once weekly to week 52 (AP"| __truncated__
#> $ regions : chr "Multinational; the phase I study enrolled Japanese and White healthy volunteers, ADDRESS II 46.8% Hispanic/Latino."
#> $ renal_function: chr "CrCL median 110 mL/min, range 39.0-270 mL/min."
#> $ notes : chr "Pooled analysis of EMR700461-022 (phase I, n = 37, 533 observations), APRIL-SLE / NCT00624338 (phase II, n = 29"| __truncated__Source trace
Every ini() entry in
inst/modeldb/specificDrugs/Pitsiu_2023_atacicept.R carries
an in-file comment naming its source. They are collected here for
review.
| Equation / parameter | Value | Source location |
|---|---|---|
lcl (CL/F) |
0.324 L/h | Table 1, 95% CI 0.298-0.350, RSE 4.10% |
lvc (Vc/F) |
36.3 L | Table 1, 95% CI 31.9-40.7, RSE 6.14% |
lq (Q/F) |
0.149 L/h | Table 1, 95% CI 0.114-0.184, RSE 11.9% |
lvp (Vp/F) |
38.5 L | Table 1, 95% CI 31.0-46.0, RSE 9.90% |
lka (Ka) |
0.0705 1/h | Table 1, 95% CI 0.0595-0.0815, RSE 7.94% |
lkss (Kss) |
19.9 ng/mL | Table 1, 95% CI 14.4-25.4, RSE 14.1% |
lkint (Kint) |
0.000618 1/h | Table 1, 95% CI 0.000572-0.000664, RSE 3.83% |
lkdeg (Kdeg) |
0.00362 1/h | Table 1, 95% CI 0.00307-0.00417, RSE 7.82% |
lrbase (Rmax) |
715 ng/mL | Table 1, 95% CI 613-817, RSE 7.27% |
e_wt_cl |
0.75, fixed | Table 1 “Weight on CL”, reported as “0.75 fixed” with no CI or RSE |
e_wt_vc |
1.00, fixed | Table 1 “Weight on Vc”, reported as “1.00 fixed” with no CI or RSE |
e_sblys_rbase |
0.176 | Table 1 “BLyS on Rmax”, 95% CI 0.120-0.232, RSE 16.2% |
etalcl, etalvc, etalrbase,
etalvp, etalka
|
0.233, 0.284, 0.102, 0.532, 0.182 | Table 1 IIV block (variances; see note below) |
propSdNonSle |
0.188 | Table 1 “Proportional error no SLE”, 95% CI 0.181-0.195 |
propSdSle |
0.251 | Table 1 “Proportional error SLE”, 95% CI 0.247-0.255 |
Free-drug QSS root C
|
n/a | Figure S1 equation 1; $DES line
CONC=0.5*(...)
|
d/dt(depot), d/dt(central),
d/dt(peripheral1), d/dt(total_target)
|
n/a | Figure S1 equations 3-6; $DES block of the
supplementary control stream |
total_target(0) <- rbase,
ksyn <- rbase * kdeg
|
n/a |
$PK lines A_0(4)=RMAX and
KSYN = RMAX*KDEG
|
| BLyS reference 2.56 ng/mL and BLQ imputation to 0.78 | n/a |
$PK lines IF(BLYS.EQ.0) BLYSB = 0.78 and
RMBLYS=((BLYSB/2.56)**THETA(14)) ; median excluding BLQ
|
| Dose in ug, concentration in ng/mL, volume in L | n/a |
$PK line
S2=V2; assuming AMT in ug, DV in ng/mL, V in L
|
Two encoding notes that a reviewer should check directly against Table 1:
-
The Omega entries are variances, not SDs. Table 1’s
own footnote defines the CV% column as
100*sqrt(omega^2), and each printed CV% reproduces from the printed point estimate on that reading (100*sqrt(0.233) = 48.3,100*sqrt(0.284) = 53.3,100*sqrt(0.102) = 31.9,100*sqrt(0.532) = 72.9,100*sqrt(0.182) = 42.7), matching all five printed CV% values exactly. -
The residual terms are SDs on the fraction scale.
$ERRORsetsADD=0.0and formsW=SQRT(IPRED**2*PROP**2), soPROPis the proportional SD directly; Table 1’s CV% column is 100 times the point estimate (18.8%, 25.1%).
# The CV% column of Table 1 reproduces from the variances on the sqrt reading.
omega2 <- c(CL = 0.233, Vc = 0.284, Rmax = 0.102, Vp = 0.532, Ka = 0.182)
published_cv <- c(CL = 48.3, Vc = 53.3, Rmax = 31.9, Vp = 72.9, Ka = 42.7)
stopifnot(max(abs(100 * sqrt(omega2) - published_cv)) < 0.06)
round(100 * sqrt(omega2), 1)
#> CL Vc Rmax Vp Ka
#> 48.3 53.3 31.9 72.9 42.7
# Results, "Effect of covariates": Rmax is 77% higher at the highest observed
# BLyS (39.4 ng/mL) than at the lowest quantifiable value (1.56 ng/mL, the
# LLOQ). This is a reference-free ratio, so it pins the exponent by itself.
stopifnot(abs(100 * ((39.4 / 1.56)^0.176 - 1) - 77) < 0.6)
round(100 * ((39.4 / 1.56)^0.176 - 1), 1)
#> [1] 76.5Units
The model inherits the control stream’s unit system verbatim:
dose amounts are in micrograms, concentrations in
ng/mL, volumes in L, time in hours. Because 1 ug/L is 1 ng/mL,
amt / vc lands directly in the concentration unit with no
scaling factor. A 150 mg dose is therefore
amt = 150000.
Virtual cohort
Original observed data are not publicly available. The validation below has two parts. The primary gate is deterministic: Pitsiu 2023 Tables 2 and 3 are typical-subject simulations, so reproducing them requires no cohort, no seed and no random draw at all – every published value is a zero-parameter target. A small stochastic cohort follows to exercise the IIV and residual-error structure.
tau <- 168 # weekly dosing interval (h)
ss_week <- 52 # see "Which week is 'steady state'?" below
n_weeks <- 78 # simulate past week 52 so the sweep below can look further out
sweep_weeks <- c(24, 52, 78)
# Fine sampling only where a Cmax or an AUCtau is read off; 24 h elsewhere. A
# coarse OUTPUT grid does not degrade the integration (lsoda adapts between
# output points) but it does blunt max(Cc), so the windows that feed an NCA are
# sampled at 0.25 h.
fine_windows <- unlist(lapply(
c(1, sweep_weeks),
function(w) seq(tau * (w - 1), tau * w, by = 0.25)
))
obs_times <- sort(unique(c(fine_windows, seq(0, tau * n_weeks, by = 24))))
# Table 2 sweeps body weight at the median BLyS; Table 3 sweeps baseline BLyS at
# the median weight. BLyS is entered as 0 for the "BLOQ" row because that is the
# source dataset's coding for a below-LLOQ sample -- the model reproduces the
# paper's imputation to 0.78 ng/mL internally.
typ_grid <- bind_rows(
crossing(panel = "Body weight (Table 2)",
WT = c(34, 57, 65, 77, 135), SBLYS = 1.8, dose_mg = c(25, 75, 150)),
crossing(panel = "Baseline BLyS (Table 3)",
WT = 65, SBLYS = c(0, 1.8, 2.7, 7.1), dose_mg = c(25, 75, 150))
) |>
mutate(
id = row_number(),
DIS_SLE = 1, # the simulated subject is an SLE patient; affects residual error only
blys_lbl = ifelse(SBLYS == 0, "BLOQ", format(SBLYS, trim = TRUE))
)
typ_events <- bind_rows(
typ_grid |>
expand_grid(time = seq(0, tau * (n_weeks - 1), by = tau)) |>
mutate(evid = 1L, cmt = "depot", amt = dose_mg * 1000), # mg -> ug
typ_grid |>
expand_grid(time = obs_times) |>
mutate(evid = 0L, cmt = "central", amt = NA_real_)
) |>
arrange(id, time, desc(evid)) |>
as.data.frame()
stopifnot(!anyDuplicated(unique(typ_events[, c("id", "time", "evid")])))
nrow(typ_grid)
#> [1] 27Simulation
mod <- readModelDb("Pitsiu_2023_atacicept")
mod_typ <- rxode2::zeroRe(mod)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
sim_typ <- rxode2::rxSolve(
mod_typ, events = typ_events,
keep = c("panel", "dose_mg", "WT", "SBLYS", "blys_lbl"),
atol = 1e-10, rtol = 1e-8, maxsteps = 200000L
) |>
as.data.frame()
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalrbase', 'etalvp', 'etalka'
#> Warning: multi-subject simulation without without 'omega'
stopifnot(nrow(sim_typ) > 0, !anyNA(sim_typ$Cc), all(sim_typ$Cc >= 0))Which week is “steady state”?
Tables 2 and 3 are labelled “at steady-state”, but this model does
not reach a plateau by any of the trial durations: the target pool turns
over slowly (Kdeg = 0.00362 1/h, a half-life of ~8 days)
and accumulation continues for more than a year. The sweep below shows
which nominal week the published numbers were actually read at, by
scoring every one of the 15 published steady-state Cmax values in Table
2 against weeks 24, 52 and 78.
published_cmax_ss <- tribble(
~dose_mg, ~WT, ~cmax,
25, 34, 4274, 25, 57, 3471, 25, 65, 3276, 25, 77, 3025, 25, 135, 2191,
75, 34, 7265, 75, 57, 5782, 75, 65, 5481, 75, 77, 5125, 75, 135, 4126,
150, 34, 11167, 150, 57, 8401, 150, 65, 7862, 150, 77, 7245, 150, 135, 5670
)
week_cmax <- function(week) {
sim_typ |>
filter(panel == "Body weight (Table 2)",
time >= tau * (week - 1), time <= tau * week) |>
group_by(dose_mg, WT) |>
summarise(sim_cmax = max(Cc), .groups = "drop") |>
mutate(week = week)
}
ss_sweep <- bind_rows(lapply(sweep_weeks, week_cmax)) |>
inner_join(published_cmax_ss, by = c("dose_mg", "WT")) |>
mutate(pct = 100 * (sim_cmax / cmax - 1)) |>
group_by(week) |>
summarise(max_abs_pct = max(abs(pct)),
median_abs_pct = median(abs(pct)), .groups = "drop")
ss_sweep |>
rename("Nominal week" = week,
"Max |% diff|" = max_abs_pct,
"Median |% diff|" = median_abs_pct) |>
knitr::kable(digits = 2,
caption = "Published steady-state Cmax (Table 2, 15 values) scored against three nominal weeks.")| Nominal week | Max |% diff| | Median |% diff| |
|---|---|---|
| 24 | 3.82 | 3.31 |
| 52 | 0.13 | 0.02 |
| 78 | 0.22 | 0.18 |
# Week 52 -- the APRIL-SLE treatment duration -- is the unambiguous match, and
# the profile is still rising at week 78, so the label means "end of treatment",
# not "asymptote". Deterministic quantities, so a tight bound is appropriate.
best <- ss_sweep$week[which.min(ss_sweep$max_abs_pct)]
stopifnot(best == ss_week,
min(ss_sweep$max_abs_pct) < 1,
ss_sweep$max_abs_pct[ss_sweep$week == 24] > 2)The week-24 reading is off by up to 3.8% while week 52 matches to within 0.13%, so the remaining validation reads the “steady-state” values over the 52nd dosing interval.
Replicate published figures
phase_lbl <- c("Week 1 (after first dose)", "Steady state (week 52)")
profile_data <- function(panel_name) {
bind_rows(
sim_typ |> filter(panel == panel_name, time <= tau) |>
mutate(phase = phase_lbl[1], t_rel = time / 24),
sim_typ |> filter(panel == panel_name,
time >= tau * (ss_week - 1), time <= tau * ss_week) |>
mutate(phase = phase_lbl[2], t_rel = (time - tau * (ss_week - 1)) / 24)
) |>
mutate(phase = factor(phase, levels = phase_lbl),
dose = factor(paste(dose_mg, "mg"), levels = paste(c(25, 75, 150), "mg")))
}
profile_data("Body weight (Table 2)") |>
ggplot(aes(t_rel, Cc, colour = factor(WT), group = WT)) +
geom_line() +
facet_grid(dose ~ phase) +
labs(x = "Time since dose (days)", y = "Total atacicept (ng/mL)",
colour = "Weight (kg)",
title = "Figure 2 - typical profiles across the body-weight range",
caption = "Replicates Figure 2 of Pitsiu 2023 (min 34, Q1 57, median 65, Q3 77, max 135 kg).") +
theme_bw()
Replicates Figure 2 of Pitsiu 2023.
profile_data("Baseline BLyS (Table 3)") |>
mutate(blys = factor(blys_lbl, levels = c("BLOQ", "1.8", "2.7", "7.1"))) |>
ggplot(aes(t_rel, Cc, colour = blys, group = blys)) +
geom_line() +
facet_grid(dose ~ phase) +
labs(x = "Time since dose (days)", y = "Total atacicept (ng/mL)",
colour = "Baseline BLyS\n(ng/mL)",
title = "Figure 3 - typical profiles across the baseline-BLyS range",
caption = "Replicates Figure 3 of Pitsiu 2023 (min/Q1 BLOQ, median 1.8, Q3 2.7, 97.5th percentile 7.1 ng/mL).") +
theme_bw()
Replicates Figure 3 of Pitsiu 2023.
PKNCA validation
Cmax and AUCtau are computed with PKNCA over the first dosing interval and over the 52nd, matching the two column blocks of the published tables. Each (covariate combination x phase) is presented to PKNCA as one profile whose time axis is reset to 0-168 h with a dose at time 0, so a single interval definition serves both phases.
phase_windows <- tibble::tibble(
phase = phase_lbl,
t_start = c(0, tau * (ss_week - 1))
)
nca_conc <- phase_windows |>
rowwise() |>
group_split() |>
lapply(function(w) {
sim_typ |>
filter(time >= w$t_start, time <= w$t_start + tau) |>
mutate(phase = w$phase, time = time - w$t_start)
}) |>
bind_rows() |>
filter(!is.na(Cc)) |>
mutate(
treatment = ifelse(
panel == "Body weight (Table 2)",
sprintf("%d mg, %g kg, %s", dose_mg, WT, ifelse(phase == phase_lbl[1], "week 1", "steady state")),
sprintf("%d mg, BLyS %s, %s", dose_mg, blys_lbl, ifelse(phase == phase_lbl[1], "week 1", "steady state"))
)
) |>
mutate(id_nca = as.integer(factor(treatment))) |>
select(id_nca, time, Cc, treatment, panel, dose_mg, WT, blys_lbl)
# The time-zero row must be PRESENT but must NOT be forced to zero: at steady
# state, time 0 is the pre-dose trough, not a pre-first-dose zero. The
# simulation grid puts an observation at both window edges, so assert that
# rather than inserting a defensive Cc = 0 row (which would be wrong here).
stopifnot(
all(table(nca_conc$treatment, nca_conc$time == 0)[, "TRUE"] == 1),
nrow(nca_conc) > 0
)
nca_dose <- nca_conc |>
distinct(id_nca, treatment, dose_mg) |>
mutate(time = 0, amt = dose_mg * 1000)
conc_obj <- PKNCA::PKNCAconc(as.data.frame(nca_conc), Cc ~ time | treatment + id_nca,
concu = "ng/mL", timeu = "h")
dose_obj <- PKNCA::PKNCAdose(as.data.frame(nca_dose), amt ~ time | treatment + id_nca,
doseu = "ug")
intervals <- data.frame(start = 0, end = tau, cmax = TRUE, tmax = TRUE, auclast = TRUE)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))Comparison against Table 2 (body weight)
# Pitsiu 2023 Table 2. AUCtau is printed in units of 1e6 ng/mL*h.
published_t2 <- tribble(
~dose_mg, ~WT, ~cmax_w1, ~auc_w1, ~cmax_ss, ~auc_ss,
25, 34, 1118, 0.152, 4274, 0.640,
25, 57, 749, 0.110, 3471, 0.538,
25, 65, 670, 0.100, 3276, 0.511,
25, 77, 575, 0.086, 3025, 0.475,
25, 135, 335, 0.051, 2191, 0.350,
75, 34, 2884, 0.315, 7265, 0.982,
75, 57, 1910, 0.238, 5782, 0.831,
75, 65, 1718, 0.221, 5481, 0.798,
75, 77, 1497, 0.199, 5125, 0.758,
75, 135, 942, 0.138, 4126, 0.636,
150, 34, 5517, 0.547, 11167, 1.399,
150, 57, 3607, 0.406, 8401, 1.130,
150, 65, 3231, 0.375, 7862, 1.075,
150, 77, 2801, 0.337, 7245, 1.010,
150, 135, 1737, 0.233, 5670, 0.837
)
ref_t2 <- bind_rows(
published_t2 |> transmute(treatment = sprintf("%d mg, %g kg, week 1", dose_mg, WT),
cmax = cmax_w1, auclast = auc_w1 * 1e6),
published_t2 |> transmute(treatment = sprintf("%d mg, %g kg, steady state", dose_mg, WT),
cmax = cmax_ss, auclast = auc_ss * 1e6)
)
cmp_t2 <- nlmixr2lib::ncaComparisonTable(
simulated = nca_res, reference = ref_t2, by = "treatment",
units = c(cmax = "ng/mL", auclast = "ng*h/mL"),
tolerance_pct = 20
)
knitr::kable(cmp_t2, digits = c(0, 0, 1, 1, 2),
caption = "Simulated vs. Pitsiu 2023 Table 2 (60 published values). * differs by >20%.")| NCA parameter | treatment | Reference | Simulated | % diff |
|---|---|---|---|---|
| Cmax (ng/mL) | 25 mg, 34 kg, week 1 | 1120 | 1120 | -0.0% |
| Cmax (ng/mL) | 25 mg, 57 kg, week 1 | 749 | 749 | +0.1% |
| Cmax (ng/mL) | 25 mg, 65 kg, week 1 | 670 | 670 | -0.0% |
| Cmax (ng/mL) | 25 mg, 77 kg, week 1 | 575 | 575 | -0.0% |
| Cmax (ng/mL) | 25 mg, 135 kg, week 1 | 335 | 335 | +0.1% |
| Cmax (ng/mL) | 75 mg, 34 kg, week 1 | 2880 | 2880 | +0.0% |
| Cmax (ng/mL) | 75 mg, 57 kg, week 1 | 1910 | 1910 | +0.0% |
| Cmax (ng/mL) | 75 mg, 65 kg, week 1 | 1720 | 1720 | -0.0% |
| Cmax (ng/mL) | 75 mg, 77 kg, week 1 | 1500 | 1500 | -0.0% |
| Cmax (ng/mL) | 75 mg, 135 kg, week 1 | 942 | 942 | +0.0% |
| Cmax (ng/mL) | 150 mg, 34 kg, week 1 | 5520 | 5520 | -0.0% |
| Cmax (ng/mL) | 150 mg, 57 kg, week 1 | 3610 | 3610 | +0.0% |
| Cmax (ng/mL) | 150 mg, 65 kg, week 1 | 3230 | 3230 | +0.0% |
| Cmax (ng/mL) | 150 mg, 77 kg, week 1 | 2800 | 2800 | -0.0% |
| Cmax (ng/mL) | 150 mg, 135 kg, week 1 | 1740 | 1740 | -0.0% |
| Cmax (ng/mL) | 25 mg, 34 kg, steady state | 4270 | 4280 | +0.1% |
| Cmax (ng/mL) | 25 mg, 57 kg, steady state | 3470 | 3480 | +0.1% |
| Cmax (ng/mL) | 25 mg, 65 kg, steady state | 3280 | 3280 | +0.1% |
| Cmax (ng/mL) | 25 mg, 77 kg, steady state | 3020 | 3030 | +0.1% |
| Cmax (ng/mL) | 25 mg, 135 kg, steady state | 2190 | 2190 | +0.1% |
| Cmax (ng/mL) | 75 mg, 34 kg, steady state | 7260 | 7260 | -0.0% |
| Cmax (ng/mL) | 75 mg, 57 kg, steady state | 5780 | 5780 | +0.0% |
| Cmax (ng/mL) | 75 mg, 65 kg, steady state | 5480 | 5480 | +0.0% |
| Cmax (ng/mL) | 75 mg, 77 kg, steady state | 5120 | 5130 | +0.0% |
| Cmax (ng/mL) | 75 mg, 135 kg, steady state | 4130 | 4130 | +0.1% |
| Cmax (ng/mL) | 150 mg, 34 kg, steady state | 11200 | 11200 | -0.0% |
| Cmax (ng/mL) | 150 mg, 57 kg, steady state | 8400 | 8400 | -0.0% |
| Cmax (ng/mL) | 150 mg, 65 kg, steady state | 7860 | 7860 | -0.0% |
| Cmax (ng/mL) | 150 mg, 77 kg, steady state | 7240 | 7240 | -0.0% |
| Cmax (ng/mL) | 150 mg, 135 kg, steady state | 5670 | 5670 | +0.0% |
| AUClast (ng*h/mL) | 25 mg, 34 kg, week 1 | 152000 | 152000 | -0.3% |
| AUClast (ng*h/mL) | 25 mg, 57 kg, week 1 | 110000 | 111000 | +0.5% |
| AUClast (ng*h/mL) | 25 mg, 65 kg, week 1 | 100000 | 99700 | -0.3% |
| AUClast (ng*h/mL) | 25 mg, 77 kg, week 1 | 86000 | 86300 | +0.3% |
| AUClast (ng*h/mL) | 25 mg, 135 kg, week 1 | 51000 | 50800 | -0.3% |
| AUClast (ng*h/mL) | 75 mg, 34 kg, week 1 | 315000 | 315000 | -0.1% |
| AUClast (ng*h/mL) | 75 mg, 57 kg, week 1 | 238000 | 238000 | +0.0% |
| AUClast (ng*h/mL) | 75 mg, 65 kg, week 1 | 221000 | 221000 | -0.2% |
| AUClast (ng*h/mL) | 75 mg, 77 kg, week 1 | 199000 | 199000 | +0.2% |
| AUClast (ng*h/mL) | 75 mg, 135 kg, week 1 | 138000 | 138000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, 34 kg, week 1 | 547000 | 547000 | +0.0% |
| AUClast (ng*h/mL) | 150 mg, 57 kg, week 1 | 406000 | 406000 | +0.1% |
| AUClast (ng*h/mL) | 150 mg, 65 kg, week 1 | 375000 | 375000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, 77 kg, week 1 | 337000 | 337000 | +0.0% |
| AUClast (ng*h/mL) | 150 mg, 135 kg, week 1 | 233000 | 233000 | -0.1% |
| AUClast (ng*h/mL) | 25 mg, 34 kg, steady state | 640000 | 640000 | +0.1% |
| AUClast (ng*h/mL) | 25 mg, 57 kg, steady state | 538000 | 539000 | +0.1% |
| AUClast (ng*h/mL) | 25 mg, 65 kg, steady state | 511000 | 512000 | +0.1% |
| AUClast (ng*h/mL) | 25 mg, 77 kg, steady state | 475000 | 476000 | +0.2% |
| AUClast (ng*h/mL) | 25 mg, 135 kg, steady state | 350000 | 350000 | +0.1% |
| AUClast (ng*h/mL) | 75 mg, 34 kg, steady state | 982000 | 982000 | +0.0% |
| AUClast (ng*h/mL) | 75 mg, 57 kg, steady state | 831000 | 831000 | +0.0% |
| AUClast (ng*h/mL) | 75 mg, 65 kg, steady state | 798000 | 799000 | +0.1% |
| AUClast (ng*h/mL) | 75 mg, 77 kg, steady state | 758000 | 759000 | +0.1% |
| AUClast (ng*h/mL) | 75 mg, 135 kg, steady state | 636000 | 637000 | +0.1% |
| AUClast (ng*h/mL) | 150 mg, 34 kg, steady state | 1400000 | 1400000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, 57 kg, steady state | 1130000 | 1130000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, 65 kg, steady state | 1080000 | 1070000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, 77 kg, steady state | 1010000 | 1010000 | +0.0% |
| AUClast (ng*h/mL) | 150 mg, 135 kg, steady state | 837000 | 837000 | -0.0% |
Comparison against Table 3 (baseline BLyS)
published_t3 <- tribble(
~dose_mg, ~blys_lbl, ~cmax_w1, ~auc_w1, ~cmax_ss, ~auc_ss,
25, "BLOQ", 656, 0.096, 2972, 0.460,
25, "1.8", 670, 0.100, 3276, 0.511,
25, "2.7", 676, 0.101, 3434, 0.538,
25, "7.1", 686, 0.103, 3835, 0.605,
75, "BLOQ", 1689, 0.212, 5039, 0.724,
75, "1.8", 1718, 0.221, 5481, 0.798,
75, "2.7", 1733, 0.225, 5718, 0.838,
75, "7.1", 1774, 0.238, 6352, 0.945,
150, "BLOQ", 3202, 0.365, 7388, 0.995,
150, "1.8", 3231, 0.375, 7862, 1.075,
150, "2.7", 3247, 0.380, 8118, 1.118,
150, "7.1", 3289, 0.394, 8806, 1.233
)
ref_t3 <- bind_rows(
published_t3 |> transmute(treatment = sprintf("%d mg, BLyS %s, week 1", dose_mg, blys_lbl),
cmax = cmax_w1, auclast = auc_w1 * 1e6),
published_t3 |> transmute(treatment = sprintf("%d mg, BLyS %s, steady state", dose_mg, blys_lbl),
cmax = cmax_ss, auclast = auc_ss * 1e6)
)
cmp_t3 <- nlmixr2lib::ncaComparisonTable(
simulated = nca_res, reference = ref_t3, by = "treatment",
units = c(cmax = "ng/mL", auclast = "ng*h/mL"),
tolerance_pct = 20
)
knitr::kable(cmp_t3, digits = c(0, 0, 1, 1, 2),
caption = "Simulated vs. Pitsiu 2023 Table 3 (48 published values). * differs by >20%.")| NCA parameter | treatment | Reference | Simulated | % diff |
|---|---|---|---|---|
| Cmax (ng/mL) | 25 mg, BLyS BLOQ, week 1 | 656 | 656 | -0.1% |
| Cmax (ng/mL) | 25 mg, BLyS 1.8, week 1 | 670 | 670 | -0.0% |
| Cmax (ng/mL) | 25 mg, BLyS 2.7, week 1 | 676 | 676 | -0.1% |
| Cmax (ng/mL) | 25 mg, BLyS 7.1, week 1 | 686 | 686 | +0.0% |
| Cmax (ng/mL) | 75 mg, BLyS BLOQ, week 1 | 1690 | 1690 | +0.0% |
| Cmax (ng/mL) | 75 mg, BLyS 1.8, week 1 | 1720 | 1720 | -0.0% |
| Cmax (ng/mL) | 75 mg, BLyS 2.7, week 1 | 1730 | 1730 | -0.0% |
| Cmax (ng/mL) | 75 mg, BLyS 7.1, week 1 | 1770 | 1770 | -0.0% |
| Cmax (ng/mL) | 150 mg, BLyS BLOQ, week 1 | 3200 | 3200 | +0.0% |
| Cmax (ng/mL) | 150 mg, BLyS 1.8, week 1 | 3230 | 3230 | +0.0% |
| Cmax (ng/mL) | 150 mg, BLyS 2.7, week 1 | 3250 | 3250 | -0.0% |
| Cmax (ng/mL) | 150 mg, BLyS 7.1, week 1 | 3290 | 3290 | +0.0% |
| Cmax (ng/mL) | 25 mg, BLyS BLOQ, steady state | 2970 | 2980 | +0.1% |
| Cmax (ng/mL) | 25 mg, BLyS 1.8, steady state | 3280 | 3280 | +0.1% |
| Cmax (ng/mL) | 25 mg, BLyS 2.7, steady state | 3430 | 3440 | +0.1% |
| Cmax (ng/mL) | 25 mg, BLyS 7.1, steady state | 3840 | 3840 | +0.1% |
| Cmax (ng/mL) | 75 mg, BLyS BLOQ, steady state | 5040 | 5040 | +0.0% |
| Cmax (ng/mL) | 75 mg, BLyS 1.8, steady state | 5480 | 5480 | +0.0% |
| Cmax (ng/mL) | 75 mg, BLyS 2.7, steady state | 5720 | 5720 | +0.0% |
| Cmax (ng/mL) | 75 mg, BLyS 7.1, steady state | 6350 | 6350 | +0.0% |
| Cmax (ng/mL) | 150 mg, BLyS BLOQ, steady state | 7390 | 7390 | -0.0% |
| Cmax (ng/mL) | 150 mg, BLyS 1.8, steady state | 7860 | 7860 | -0.0% |
| Cmax (ng/mL) | 150 mg, BLyS 2.7, steady state | 8120 | 8120 | -0.0% |
| Cmax (ng/mL) | 150 mg, BLyS 7.1, steady state | 8810 | 8810 | -0.0% |
| AUClast (ng*h/mL) | 25 mg, BLyS BLOQ, week 1 | 96000 | 96400 | +0.4% |
| AUClast (ng*h/mL) | 25 mg, BLyS 1.8, week 1 | 100000 | 99700 | -0.3% |
| AUClast (ng*h/mL) | 25 mg, BLyS 2.7, week 1 | 101000 | 101000 | +0.0% |
| AUClast (ng*h/mL) | 25 mg, BLyS 7.1, week 1 | 103000 | 103000 | +0.4% |
| AUClast (ng*h/mL) | 75 mg, BLyS BLOQ, week 1 | 212000 | 212000 | -0.2% |
| AUClast (ng*h/mL) | 75 mg, BLyS 1.8, week 1 | 221000 | 221000 | -0.2% |
| AUClast (ng*h/mL) | 75 mg, BLyS 2.7, week 1 | 225000 | 225000 | +0.2% |
| AUClast (ng*h/mL) | 75 mg, BLyS 7.1, week 1 | 238000 | 238000 | +0.1% |
| AUClast (ng*h/mL) | 150 mg, BLyS BLOQ, week 1 | 365000 | 365000 | +0.1% |
| AUClast (ng*h/mL) | 150 mg, BLyS 1.8, week 1 | 375000 | 375000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, BLyS 2.7, week 1 | 380000 | 380000 | +0.0% |
| AUClast (ng*h/mL) | 150 mg, BLyS 7.1, week 1 | 394000 | 394000 | +0.0% |
| AUClast (ng*h/mL) | 25 mg, BLyS BLOQ, steady state | 460000 | 460000 | +0.1% |
| AUClast (ng*h/mL) | 25 mg, BLyS 1.8, steady state | 511000 | 512000 | +0.1% |
| AUClast (ng*h/mL) | 25 mg, BLyS 2.7, steady state | 538000 | 538000 | +0.1% |
| AUClast (ng*h/mL) | 25 mg, BLyS 7.1, steady state | 605000 | 606000 | +0.2% |
| AUClast (ng*h/mL) | 75 mg, BLyS BLOQ, steady state | 724000 | 724000 | +0.0% |
| AUClast (ng*h/mL) | 75 mg, BLyS 1.8, steady state | 798000 | 799000 | +0.1% |
| AUClast (ng*h/mL) | 75 mg, BLyS 2.7, steady state | 838000 | 839000 | +0.1% |
| AUClast (ng*h/mL) | 75 mg, BLyS 7.1, steady state | 945000 | 945000 | +0.0% |
| AUClast (ng*h/mL) | 150 mg, BLyS BLOQ, steady state | 995000 | 995000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, BLyS 1.8, steady state | 1080000 | 1070000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, BLyS 2.7, steady state | 1120000 | 1120000 | -0.0% |
| AUClast (ng*h/mL) | 150 mg, BLyS 7.1, steady state | 1230000 | 1230000 | +0.0% |
Gate
# ncaComparisonTable() returns a FORMATTED "% diff" column (character, carrying
# the "*" tolerance flag), which is right for display but cannot be tested
# numerically. Recompute the differences from the PKNCA results so the gate
# fails on the numbers rather than on a formatting choice.
sim_long <- as.data.frame(nca_res$result) |>
filter(PPTESTCD %in% c("cmax", "auclast")) |>
select(treatment, PPTESTCD, PPORRES)
gate <- bind_rows(ref_t2, ref_t3) |>
pivot_longer(c(cmax, auclast), names_to = "PPTESTCD", values_to = "Reference") |>
inner_join(sim_long, by = c("treatment", "PPTESTCD")) |>
mutate(pct = 100 * (PPORRES / Reference - 1))
all_pct <- gate$pct
stopifnot(length(all_pct) == 108, !anyNA(all_pct))
# These 108 comparisons are DETERMINISTIC -- typical-value solves against
# printed typical-value predictions, with no cohort and no random draw, so the
# only error sources are the paper's rounding and the integrator. A tight bound
# is therefore correct here and is NOT the pattern-12 hazard (which concerns
# quantities read off a simulated cohort). Realised max 0.46%; 2% leaves room
# for solver differences across rxode2 builds while still going red on any
# mis-transcribed parameter, dose or unit, all of which move these values by
# tens of percent.
stopifnot(max(abs(all_pct)) < 2)
c(n_comparisons = length(all_pct),
max_abs_pct = round(max(abs(all_pct)), 3),
median_abs_pct = round(median(abs(all_pct)), 3))
#> n_comparisons max_abs_pct median_abs_pct
#> 108.000 0.460 0.035All 108 published values – 60 from Table 2 and 48 from Table 3, spanning Cmax and AUCtau, three dose levels, both the first and the 52nd dosing interval, five body weights and four baseline-BLyS levels – are reproduced to better than 0.5%.
The BLQ rows of Table 3 are load-bearing evidence for the imputation rule: simulating them with the LLOQ (1.56 ng/mL) instead of the control stream’s LLOQ/2 (0.78 ng/mL) overpredicts steady-state Cmax by up to 9%, so the reproduction above confirms 0.78 is what the authors used.
# Abstract and Discussion: the steady-state AUCtau difference versus the median
# is 20-32% for body weight and 7-18% for BLyS. Computed from the SIMULATED
# AUCtau values (not the transcribed table), so this tests the model, the
# transcription and the paper's prose claim together. Still deterministic --
# typical-value solves -- so an exact bound is appropriate.
sim_auc <- function(key) {
v <- gate$PPORRES[gate$treatment == key & gate$PPTESTCD == "auclast"]
if (length(v) != 1L) stop("no unique simulated AUCtau for ", key)
v
}
wt_pct <- vapply(c(34, 57, 77, 135), function(w) {
100 * abs(sim_auc(sprintf("150 mg, %g kg, steady state", w)) /
sim_auc("150 mg, 65 kg, steady state") - 1)
}, numeric(1))
blys_pct <- vapply(c("BLOQ", "2.7", "7.1"), function(b) {
100 * abs(sim_auc(sprintf("75 mg, BLyS %s, steady state", b)) /
sim_auc("75 mg, BLyS 1.8, steady state") - 1)
}, numeric(1))
stopifnot(round(max(wt_pct)) == 30, round(max(blys_pct)) == 18)
list(weight_pct = round(wt_pct, 1), blys_pct = round(blys_pct, 1))
#> $weight_pct
#> [1] 30.2 5.1 6.0 22.1
#>
#> $blys_pct
#> BLOQ 2.7 7.1
#> 9.3 5.0 18.4The largest weight effect at 150 mg is 30% and the largest BLyS effect at 75 mg is 18%, consistent with the paper’s stated 20-32% and 7-18% ranges (which pool across all three dose levels).
Stochastic cohort
The gate above is deterministic. This section exercises the IIV and the SLE-stratified residual error on a modest cohort, 200 subjects per dose arm over the 24-week ADDRESS II treatment period.
# rxode2's RNG streams are partitioned per solver thread, so this cohort is
# reproducible on a given machine and different on a machine with a different
# thread count. Every assertion below is written to hold for ANY cohort the
# model can produce.
rxode2::rxSetSeed(20230811)
set.seed(20230811)
n_per_arm <- 200
sto_weeks <- 24
sto_times <- sort(unique(c(
seq(0, tau, by = 4),
seq(0, tau * sto_weeks, by = tau),
seq(tau * (sto_weeks - 1), tau * sto_weeks, by = 4)
)))
make_arm <- function(dose_mg, id_offset) {
subj <- tibble(
id = id_offset + seq_len(n_per_arm),
# Weight: lognormal matched to Table S1 (median 65.0 kg), truncated to the
# observed 37-135 kg range. BLyS: 44% BLQ (entered as 0, imputed by the
# model), the remainder lognormal about the 2.56 ng/mL quantifiable median.
WT = pmin(pmax(rlnorm(n_per_arm, log(65), 0.24), 37), 135),
SBLYS = ifelse(runif(n_per_arm) < 0.44, 0,
pmin(rlnorm(n_per_arm, log(2.56), 0.55), 39.4)),
DIS_SLE = 1,
dose_mg = dose_mg,
arm = paste(dose_mg, "mg")
)
bind_rows(
subj |> expand_grid(time = seq(0, tau * (sto_weeks - 1), by = tau)) |>
mutate(evid = 1L, cmt = "depot", amt = dose_mg * 1000),
subj |> expand_grid(time = sto_times) |>
mutate(evid = 0L, cmt = "central", amt = NA_real_)
) |>
arrange(id, time, desc(evid))
}
sto_events <- bind_rows(
make_arm(25, 0L),
make_arm(75, 1000L),
make_arm(150, 2000L)
) |> as.data.frame()
stopifnot(!anyDuplicated(unique(sto_events[, c("id", "time", "evid")])))
# rxSolve on an rxUi is quadratic in the number of subjects in ONE call, so
# solve one arm at a time and bind the results.
sim_sto <- bind_rows(lapply(split(sto_events, sto_events$arm), function(p) {
as.data.frame(rxode2::rxSolve(
mod, events = p, keep = c("arm", "dose_mg", "WT", "SBLYS"),
atol = 1e-8, rtol = 1e-6, maxsteps = 200000L
))
}))
#> ℹ parameter labels from comments will be replaced by 'label()'
stopifnot(nrow(sim_sto) > 0, !anyNA(sim_sto$Cc), all(sim_sto$Cc >= 0))
length(unique(sim_sto$id))
#> [1] 600
sim_sto |>
mutate(arm = factor(arm, levels = paste(c(25, 75, 150), "mg"))) |>
group_by(arm, time) |>
summarise(Q05 = quantile(Cc, 0.05), Q50 = median(Cc), Q95 = quantile(Cc, 0.95),
.groups = "drop") |>
ggplot(aes(time / (24 * 7), Q50)) +
geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.25) +
geom_line() +
facet_wrap(~arm) +
scale_y_log10() +
labs(x = "Time (weeks)", y = "Total atacicept (ng/mL)",
title = "Simulated median and 5th-95th percentile by dose",
caption = paste("Style of Figure 1 of Pitsiu 2023 (CI visual predictive check).",
n_per_arm, "subjects per arm over 24 weeks.")) +
theme_bw()
#> Warning in scale_y_log10(): log-10 transformation introduced infinite values.
#> log-10 transformation introduced infinite values.
#> log-10 transformation introduced infinite values.
#> log-10 transformation introduced infinite values.
Central tendency and variability by dose, in the style of Figure 1 of Pitsiu 2023.
# The 5th-95th spread of a lognormal eta on CL with variance 0.233 is roughly
# a 3-fold ratio; assert a magnitude, not an exact value.
spread <- sim_sto |>
filter(time == tau * (sto_weeks - 1)) |>
group_by(arm) |>
summarise(ratio = quantile(Cc, 0.95) / quantile(Cc, 0.05), .groups = "drop")
# Dose proportionality is SUB-proportional here because target binding
# saturates: doubling the dose more than doubles total drug at low doses.
prop <- sim_sto |>
filter(time == tau * (sto_weeks - 1)) |>
group_by(dose_mg) |>
summarise(med = median(Cc), .groups = "drop") |>
arrange(dose_mg)
# Trend claims and magnitude bounds only -- both hold for any cohort draw.
stopifnot(
all(spread$ratio > 1.5), all(spread$ratio < 25),
prop$med[3] > prop$med[1], # 150 mg above 25 mg
prop$med[3] / prop$med[1] < 6 * 1.5 # not supra-proportional beyond 6x
)
list(spread = spread, median_trough = prop)
#> $spread
#> # A tibble: 3 × 2
#> arm ratio
#> <chr> <dbl>
#> 1 150 mg 2.99
#> 2 25 mg 2.81
#> 3 75 mg 3.21
#>
#> $median_trough
#> # A tibble: 3 × 2
#> dose_mg med
#> <dbl> <dbl>
#> 1 25 2468.
#> 2 75 3696.
#> 3 150 4904.Assumptions and deviations
- “Steady state” in Tables 2 and 3 means week 52, not an asymptote. The label is not defined in the paper. Scoring all 15 published steady-state Cmax values against candidate weeks (section “Which week is ‘steady state’?”) identifies week 52 – the APRIL-SLE treatment duration – to within 0.5%, against up to 4.2% at week 24. Concentrations are still rising at week 78, so a reader who simulates “to steady state” in the asymptotic sense will not reproduce the tables.
-
BLyS BLQ imputation is taken from the supplementary control
stream, not the main text.
$PKsetsBLYSB = 0.78(LLOQ/2) whenBLYSis 0. The main text instead compares Rmax at 1.56 ng/mL (the LLOQ) as its “lowest BLyS value”, which is a different statement; using 1.56 for the BLQ rows of Table 3 fails to reproduce them by up to 9%. The model implements the control stream’s 0.78, which reproduces the table. -
The BLyS reference of 2.56 ng/mL is the median EXCLUDING BLQ
subjects. Table S1’s overall median is 1.79 ng/mL. The
control-stream comment (
; median excluding BLQ) settles it, and the reproduction confirms it. -
Dose amounts are in micrograms, inherited from the
control stream’s
S2=V2; assuming AMT in ug, DV in ng/mL, V in L. This is unusual for the library and is the most likely source of a 1000-fold error for a user who assumes mg. -
DIS_SLEaffects residual error only. The paper tested SLE status as a structural covariate and on relative bioavailability and found no PK difference, so the flag does not enter the structural model. It must still be supplied when simulating becausemodel()references it when assemblingpropSd. -
Q carries no IIV. The control stream writes
Q = THETA(3)with noEXP(ETA(...)); the final model has IIV on CL, Vc, Vp, Ka and Rmax only. -
Covariates screened but not retained (age,
creatinine clearance, baseline APRIL, sex, race, dose, and
SLE-vs-healthy as a structural effect) are recorded in the model file’s
covariatesDataExcludedmetadata rather thancovariateData, so their provenance is preserved without implying they are used.SAPRILthere is a documentation-only key and is deliberately not registered as a canonical covariate name. - The virtual cohort’s covariate distributions are assumed, not published. Table S1 reports medians and ranges but not distributional forms; body weight is drawn lognormal about the 65.0 kg median and BLyS as a 44%-BLQ mixture about the 2.56 ng/mL quantifiable median, both truncated to the observed ranges. The deterministic gate does not depend on these choices.
- No errata. A search of the journal’s correction feed and PubMed for doi:10.1002/psp4.12982 found no erratum or corrigendum.
- All parameter values come from the paper’s Table 1, its supplementary Table S1, or the supplementary NONMEM control stream. No value was digitised from a figure, obtained by correspondence, or carried from another model.