Skip to contents

Model and source

  • Citation: Tohi M, Irie K, Mizuno T, Okuyoshi H, Hirabatake M, Ikesue H, Muroi N, Eto M, Fukushima S, Tomii K, Hashida T. Population Pharmacokinetics of Nivolumab in Japanese Patients with Nonsmall Cell Lung Cancer. Ther Drug Monit. 2023;45(1):110-116. doi:10.1097/FTD.0000000000000996
  • Description: Two-compartment population PK model for intravenous nivolumab (anti-PD-1 IgG4) in Japanese adults with non-small cell lung cancer, with power effects of serum albumin and eGFR on clearance and a sigmoid Emax decrease of clearance with time since the first dose (Tohi 2023)
  • Article: https://doi.org/10.1097/FTD.0000000000000996 (open access, PMC9819210)
  • Supplemental Digital Content 1: Table S1 (forward covariate inclusion), Table S2 (backward elimination), Table S3 (longitudinal albumin and eGFR) and Figure S1 (Monte Carlo trough concentrations by regimen, albumin and eGFR).

Tohi et al. fitted a population PK model to 223 opportunistically sampled remnant-serum nivolumab concentrations from 34 Japanese patients with non-small cell lung cancer (NSCLC) treated with 240 mg nivolumab monotherapy in routine care. The structural model is two-compartment with first-order elimination. Clearance depends on serum albumin (negative power effect), eGFR (positive power effect), and a sigmoid Emax decrease with time since the first dose whose three parameters were held at the values of Osawa et al. (2019, gastric cancer); the data could not estimate them.

mod <- readModelDb("Tohi_2023_nivolumab")
ui <- rxode2::rxode(mod)
#> ℹ parameter labels from comments will be replaced by 'label()'
ui
#>  ── rxode2-based free-form 2-cmt ODE model ────────────────────────────────────── 
#>  ── Initalization: ──  
#> Fixed Effects ($theta): 
#>           lcl           lvc           lvp            lq      e_alb_cl 
#>    -5.0514573     0.8241754     0.5933268    -4.0173835    -1.4800000 
#>     e_crcl_cl   cl_time_max       lcl_t50 lcl_time_hill        propSd 
#>     0.4110000    -0.2850000     7.3198649     0.7030975     0.1600000 
#> 
#> Omega ($omega): 
#>         etalcl  etalvc
#> etalcl 0.03695 0.00000
#> etalvc 0.00000 0.19425
#> attr(,"lotriLabels")
#> [1] "Table 2: IIV for CL = 19.4 CV%; log(1 + 0.194^2) = 0.03695"
#> [2] "Table 2: IIV for V1 = 46.3 CV%; log(1 + 0.463^2) = 0.19425"
#> attr(,"lotriFix")
#>        etalcl etalvc
#> etalcl  FALSE  FALSE
#> etalvc  FALSE  FALSE
#> 
#> States ($state or $stateDf): 
#>   Compartment Number Compartment Name
#> 1                  1          central
#> 2                  2      peripheral1
#>  ── μ-referencing ($muRefTable): ──  
#>   theta    eta level
#> 1   lcl etalcl    id
#> 2   lvc etalvc    id
#> 
#>  ── Model (Normalized Syntax): ── 
#> function() {
#>     compartmentData <- list(central = list(analyte = "nivolumab", 
#>         units = "mg", specimen = "serum", verified = TRUE), peripheral1 = list(analyte = "nivolumab", 
#>         units = "mg", specimen = "serum", verified = TRUE))
#>     covariateData <- list(ALB = list(description = "Serum albumin", 
#>         units = "g/L", type = "continuous", reference_category = NULL, 
#>         notes = "Time-varying (measured approximately every 2 weeks; Methods 'Covariate Analysis on Nivolumab Clearance'). Power effect on CL with reference 3.6 g/dL (the Table 1 median and the value printed in the final CL equation). The source reports albumin in g/dL; the canonical column is g/L, so model() converts with alb_gdL <- ALB * 0.1 before applying the published coefficient.", 
#>         source_name = "ALB"), CRCL = list(description = "Estimated glomerular filtration rate, BSA-normalized", 
#>         units = "mL/min/1.73 m^2", type = "continuous", reference_category = NULL, 
#>         notes = "Time-varying eGFR calculated with the Japanese-population equation (Methods 'Patients', reference 14 of the source). Power effect on CL with reference 70 mL/min/1.73 m^2 (the Table 1 median and the value printed in the final CL equation).", 
#>         source_name = "eGFR"))
#>     covariatesDataExcluded <- list(AGE = list(description = "Age", 
#>         units = "years", type = "continuous", reference_category = NULL, 
#>         notes = "Screened on CL in the forward-inclusion step (Table S1, dOFV -0.41, p = 0.522) and not retained."), 
#>         WT = list(description = "Body weight", units = "kg", 
#>             type = "continuous", reference_category = NULL, notes = "Screened on CL as a time-varying covariate (Table S1, dOFV -0.365, p = 0.546) and not retained. Body weight enters only the 3 mg/kg dose in the published Monte Carlo simulation."), 
#>         BSA = list(description = "Body surface area", units = "m^2", 
#>             type = "continuous", reference_category = NULL, notes = "Screened on CL as a time-varying covariate (Table S1, dOFV -0.944, p = 0.331) and not retained."), 
#>         SEXF = list(description = "Biological sex, 1 = female", 
#>             units = "(binary)", type = "binary", reference_category = "0 (male)", 
#>             notes = "Screened on CL (Table S1, dOFV -0.011, p = 0.916) and not retained."), 
#>         ECOG = list(description = "ECOG performance status", 
#>             units = "(score)", type = "categorical", reference_category = NULL, 
#>             notes = "Screened on CL (Table S1, dOFV -0.118, p = 0.731) and not retained."))
#>     description <- "Two-compartment population PK model for intravenous nivolumab (anti-PD-1 IgG4) in Japanese adults with non-small cell lung cancer, with power effects of serum albumin and eGFR on clearance and a sigmoid Emax decrease of clearance with time since the first dose (Tohi 2023)"
#>     population <- list(species = "human", n_subjects = 34L, n_studies = 1L, 
#>         n_observations = 223L, age_range = "38-83 years", age_median = "69 years", 
#>         weight_range = "36.8-80.5 kg", weight_median = "62.7 kg", 
#>         sex_female_pct = 26.5, race_ethnicity = c(Japanese = 100), 
#>         disease_state = "Non-small cell lung cancer (22 adenocarcinoma, 11 squamous cell carcinoma, 1 not otherwise specified) treated with nivolumab monotherapy", 
#>         dose_range = "240 mg flat dose IV every 2 weeks (33 patients) or every 3 weeks (1 patient); median 16.5 doses (range 1-75), median treatment period 296 days (range 14-1217)", 
#>         regions = "Japan (single centre, Kobe City Medical Center General Hospital)", 
#>         renal_function = "eGFR median 70 (range 29-144) mL/min/1.73 m^2", 
#>         albumin = "Serum albumin median 3.6 (range 2.5-4.8) g/dL", 
#>         performance_status = "ECOG PS 0: 10, PS 1: 22, PS 2: 1, unknown: 1", 
#>         notes = "Real-world opportunistic sampling of remnant serum (1-12 samples per patient). Baseline demographics from Tohi 2023 Table 1; longitudinal albumin and eGFR summaries in Supplemental Table S3.")
#>     reference <- "Tohi M, Irie K, Mizuno T, Okuyoshi H, Hirabatake M, Ikesue H, Muroi N, Eto M, Fukushima S, Tomii K, Hashida T. Population Pharmacokinetics of Nivolumab in Japanese Patients with Nonsmall Cell Lung Cancer. Ther Drug Monit. 2023;45(1):110-116. doi:10.1097/FTD.0000000000000996"
#>     units <- list(time = "h", dosing = "mg", concentration = "ug/mL")
#>     vignette <- "Tohi_2023_nivolumab"
#>     ini({
#>         lcl <- -5.05145728861651
#>         label("Clearance at the first dose for the reference patient (L/h)")
#>         lvc <- 0.824175442966349
#>         label("Central volume of distribution (L)")
#>         lvp <- 0.593326845277734
#>         label("Peripheral volume of distribution (L)")
#>         lq <- -4.01738352108597
#>         label("Intercompartmental clearance (L/h)")
#>         e_alb_cl <- -1.48
#>         label("Power exponent of serum albumin on CL (unitless)")
#>         e_crcl_cl <- 0.411
#>         label("Power exponent of eGFR on CL (unitless)")
#>         cl_time_max <- fix(-0.285)
#>         label("Maximal change in CL on the log scale (unitless)")
#>         lcl_t50 <- fix(7.31986492980897)
#>         label("Log of the time after the first dose at which the change in CL is half of its maximum (log h)")
#>         lcl_time_hill <- fix(0.703097511413113)
#>         label("Log of the Hill coefficient of time on CL (log unitless)")
#>         propSd <- c(0, 0.16)
#>         label("Proportional residual error (fraction)")
#>         etalcl ~ 0.03695
#>         label("Table 2: IIV for CL = 19.4 CV%; log(1 + 0.194^2) = 0.03695")
#>         etalvc ~ 0.19425
#>         label("Table 2: IIV for V1 = 46.3 CV%; log(1 + 0.463^2) = 0.19425")
#>     })
#>     model({
#>         alb_gdL <- ALB * 0.1
#>         cl_t50 <- exp(lcl_t50)
#>         cl_time_hill <- exp(lcl_time_hill)
#>         cl_time <- exp(cl_time_max * t^cl_time_hill/(cl_t50^cl_time_hill + 
#>             t^cl_time_hill))
#>         cl <- exp(lcl + etalcl) * (alb_gdL/3.6)^e_alb_cl * (CRCL/70)^e_crcl_cl * 
#>             cl_time
#>         vc <- exp(lvc + etalvc)
#>         vp <- exp(lvp)
#>         q <- exp(lq)
#>         kel <- cl/vc
#>         k12 <- q/vc
#>         k21 <- q/vp
#>         d/dt(central) <- -kel * central - k12 * central + k21 * 
#>             peripheral1
#>         d/dt(peripheral1) <- k12 * central - k21 * peripheral1
#>         Cc <- central/vc
#>         Cc ~ prop(propSd)
#>     })
#> }

Structure

CLi(t)=CL⋅(ALB3.6)−1.48(eGFR70)0.411exp⁡(−0.285t2.0215102.02+t2.02)eηCL CL_i(t) = CL \cdot \left(\frac{ALB}{3.6}\right)^{-1.48} \left(\frac{eGFR}{70}\right)^{0.411} \exp\!\left(\frac{-0.285\, t^{2.02}}{1510^{2.02} + t^{2.02}}\right) e^{\eta_{CL}}

with tt the time since the first dose in hours, ALB in g/dL and eGFR in mL/min/1.73 m2^2. Volumes and Q have no covariates; IIV is on CL and V1 only. The canonical ALB column is in g/L, so the model divides by 10 internally; supply albumin in g/L.

Population

34 Japanese adults with NSCLC at a single centre (Kobe City Medical Center General Hospital), median age 69 years (38-83), median weight 62.7 kg (36.8-80.5), 9 of 34 female (26.5%). Histology was adenocarcinoma in 22, squamous cell carcinoma in 11 and not otherwise specified in 1. Thirty-three patients received 240 mg every 2 weeks and one every 3 weeks; the median number of doses was 16.5 (1-75) over a median 296 days (14-1217). Median eGFR (Japanese-population equation) was 70 (29-144) mL/min/1.73 m2^2 and median serum albumin 3.6 (2.5-4.8) g/dL (Table 1). Albumin and eGFR were entered as time-varying covariates; their baseline and last-dose summaries are in Table S3.

Source trace

Element Value Source
Structure: two-compartment, first-order elimination – Methods, ‘Pharmacokinetic Analysis’; Results, ‘Base Model’
lcl log(0.0064 L/h) Table 2, CL; Results final CL equation
lvc log(2.28 L) Table 2, V1
lvp log(1.81 L) Table 2, V2
lq log(0.018 L/h) Table 2, Q
e_alb_cl -1.48, reference 3.6 g/dL Table 2, theta for albumin; Results final CL equation
e_crcl_cl 0.411, reference 70 mL/min/1.73 m2^2 Table 2, theta for eGFR; Results final CL equation
Covariate form (COV/median)^theta – Methods, continuous-covariate equation
cl_time_max -0.285 (held constant) Table 2, theta for EMAX; Methods (from Osawa et al.)
lcl_t50 log(1510 h) (held constant) Table 2, theta for TM50; Methods. The final CL equation prints 1501 (see Errata)
lcl_time_hill log(2.02) (held constant) Table 2, theta for HILL; Methods
Time-varying CL form exp(Emax t^g/(T50^g + t^g)) – Methods sigmoid Emax equation; Results final CL equation
etalcl log(1 + 0.1942^2) = 0.03695 Table 2, IIV for CL 19.4 CV%
etalvc log(1 + 0.4632^2) = 0.19425 Table 2, IIV for V1 46.3 CV%
No IIV on Q, V2 – Results, ‘Base Model’
propSd 0.160 Table 2, proportional error 16.0 CV%

Virtual cohort

Albumin and eGFR are drawn from normal distributions with the baseline mean and SD of Table S3 (albumin 3.8 +/- 0.5 g/dL, eGFR 72.3 +/- 20.9 mL/min/1.73 m2^2), truncated to the observed ranges of Table 1. Table S3 shows no systematic change in either during treatment, so both are held constant within a subject. Body weight is drawn from the normal distribution the authors used for their Monte Carlo simulation (mean 59.7 kg, SD 12.3 kg) and only matters for weight-based dosing.

set.seed(2023)
rxode2::rxSetSeed(2023)

rtnorm <- function(n, mean, sd, lo, hi) {
  x <- rnorm(n, mean, sd)
  while (any(bad <- x < lo | x > hi)) x[bad] <- rnorm(sum(bad), mean, sd)
  x
}

n_sub <- 200
cohort <- tibble(
  id = seq_len(n_sub),
  alb_gdL = rtnorm(n_sub, 3.8, 0.5, 2.5, 4.8),
  CRCL = rtnorm(n_sub, 72.3, 20.9, 29, 144),
  WT = rtnorm(n_sub, 59.7, 12.3, 36.8, 80.5)
) |>
  mutate(ALB = alb_gdL * 10)
summary(cohort[, c("alb_gdL", "CRCL", "WT")])
#>     alb_gdL           CRCL              WT       
#>  Min.   :2.767   Min.   : 30.58   Min.   :37.01  
#>  1st Qu.:3.561   1st Qu.: 57.20   1st Qu.:50.55  
#>  Median :3.846   Median : 71.02   Median :58.24  
#>  Mean   :3.830   Mean   : 70.59   Mean   :59.14  
#>  3rd Qu.:4.144   3rd Qu.: 83.16   3rd Qu.:68.54  
#>  Max.   :4.794   Max.   :123.10   Max.   :80.13

Simulation: 240 mg every 2 weeks for one year

The infusion duration is not stated in the paper; 30 minutes (the approved duration for the 240 mg flat dose in Japan) is used.

tau <- 336
n_dose <- 26
dose_times <- (seq_len(n_dose) - 1) * tau
obs_times <- sort(unique(c(0, seq(0, n_dose * tau, by = 24),
                           dose_times + 0.5, dose_times + 2)))

make_events <- function(cohort, amt_fun) {
  bind_rows(lapply(seq_len(nrow(cohort)), function(i) {
    cv <- cohort[i, ]
    dose <- data.frame(id = cv$id, time = dose_times, amt = amt_fun(cv),
                       dur = 0.5, evid = 1, cmt = "central")
    obs <- data.frame(id = cv$id, time = obs_times, amt = 0, dur = 0,
                      evid = 0, cmt = "central")
    bind_rows(dose, obs) |>
      mutate(ALB = cv$ALB, CRCL = cv$CRCL, WT = cv$WT)
  })) |>
    arrange(id, time, desc(evid))
}

ev_240 <- make_events(cohort, function(cv) 240)
sim_240 <- as.data.frame(rxode2::rxSolve(ui, ev_240, returnType = "data.frame"))

vpc_240 <- sim_240 |>
  filter(!is.na(Cc)) |>
  group_by(time) |>
  summarise(p05 = quantile(Cc, 0.05), p50 = median(Cc), p95 = quantile(Cc, 0.95),
            .groups = "drop")
ggplot(vpc_240, aes(time / 24, p50)) +
  geom_ribbon(aes(ymin = p05, ymax = p95), alpha = 0.25) +
  geom_line() +
  geom_hline(yintercept = 5, linetype = "dotted") +
  labs(x = "Time since first dose (days)", y = "Nivolumab (ug/mL)",
       caption = "Dotted line: assay LLOQ 5 ug/mL") +
  theme_bw()
Simulated nivolumab serum concentrations after 240 mg every 2 weeks (median and 90% prediction interval, 200 virtual patients). Compare with the concentration range in Figure 3 of Tohi 2023 (a pcVPC of the observed data, which this simulation cannot reproduce point for point).

Simulated nivolumab serum concentrations after 240 mg every 2 weeks (median and 90% prediction interval, 200 virtual patients). Compare with the concentration range in Figure 3 of Tohi 2023 (a pcVPC of the observed data, which this simulation cannot reproduce point for point).

Replicating Figure 1: covariate effects on clearance

Figure 1 plots the individual post hoc CL against albumin, eGFR and time since the first dose, with the final-model typical CL overlaid. The typical curves are reproduced below from the model’s own cl output.

typ <- rxode2::zeroRe(ui)
one_cl <- function(alb_gdL, egfr, time) {
  ev <- data.frame(id = seq_along(alb_gdL), time = time, amt = 0, evid = 0,
                   cmt = "central", ALB = alb_gdL * 10, CRCL = egfr)
  as.data.frame(rxode2::rxSolve(typ, ev, returnType = "data.frame"))$cl
}
alb_grid <- seq(2.5, 4.8, by = 0.05)
egfr_grid <- seq(29, 144, by = 1)
time_grid <- seq(0, 1217 * 24, by = 24)
fig1 <- bind_rows(
  tibble(panel = "A: albumin (g/dL)", x = alb_grid,
         cl = one_cl(alb_grid, rep(70, length(alb_grid)), rep(0, length(alb_grid)))),
  tibble(panel = "B: eGFR (mL/min/1.73 m^2)", x = egfr_grid,
         cl = one_cl(rep(3.6, length(egfr_grid)), egfr_grid, rep(0, length(egfr_grid)))),
  tibble(panel = "C: time since first dose (days)", x = time_grid / 24,
         cl = one_cl(rep(3.6, length(time_grid)), rep(70, length(time_grid)), time_grid))
)
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'
ggplot(fig1, aes(x, cl)) +
  geom_line() +
  facet_wrap(~panel, scales = "free_x") +
  labs(x = NULL, y = "Typical CL (L/h)") +
  theme_bw()
Typical nivolumab clearance versus albumin (at eGFR 70, t = 0), eGFR (at albumin 3.6 g/dL, t = 0) and time since the first dose (at the reference covariates). Replicates the solid lines of Figure 1A-C of Tohi 2023.

Typical nivolumab clearance versus albumin (at eGFR 70, t = 0), eGFR (at albumin 3.6 g/dL, t = 0) and time since the first dose (at the reference covariates). Replicates the solid lines of Figure 1A-C of Tohi 2023.

# The model's cl must equal the printed final CL equation evaluated by hand;
# both sides use the same numbers, so the bound is numerical tolerance only.
by_hand <- function(alb, egfr, t) {
  0.0064 * (alb / 3.6)^-1.48 * (egfr / 70)^0.411 *
    exp(-0.285 * t^2.02 / (1510^2.02 + t^2.02))
}
chk <- data.frame(alb = c(2.5, 3.6, 4.8, 3.6), egfr = c(30, 70, 144, 70),
                  t = c(0, 0, 2000, 8760))
chk$model <- mapply(one_cl, chk$alb, chk$egfr, chk$t)
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
chk$hand <- with(chk, by_hand(alb, egfr, t))
knitr::kable(chk, digits = 6)
alb egfr t model hand
2.5 30 0 0.007750 0.007750
3.6 70 0 0.006400 0.006400
4.8 144 2000 0.004688 0.004688
3.6 70 8760 0.004851 0.004851
stopifnot(all(abs(chk$model / chk$hand - 1) < 1e-8))

Replicating Figure S1: Monte Carlo trough concentrations

The authors simulated steady-state trough concentrations (Css,min) for 240 mg and 3 mg/kg every 2 weeks at albumin 2.5, 3.5 and 4.5 g/dL and eGFR 30, 60, 90 and 120 mL/min/1.73 m2^2, and plotted them as boxplots (Figure S1). The medians below were digitised by the maintainers from Figure S1; the two extremes agree with the values stated in the Discussion (26.7 and 231.6 ug/mL), which are used directly for those two cells.

Because clearance keeps decreasing with time, the timing of “steady state” matters. The paper does not state it. Troughs are taken here at the end of the 26th dosing interval (one year, when the time effect on CL has reached 97% of its maximum) and, as a sensitivity, at the end of the 12th (24 weeks).

figS1_ref <- tribble(
  ~regimen,  ~alb, ~egfr, ~cmin_ref,
  "3 mg/kg", 2.5,  30,  61.7,
  "3 mg/kg", 2.5,  60,  41.2,
  "3 mg/kg", 2.5,  90,  31.9,
  "3 mg/kg", 2.5, 120,  26.7,
  "3 mg/kg", 3.5,  30, 113.2,
  "3 mg/kg", 3.5,  60,  81.0,
  "3 mg/kg", 3.5,  90,  65.2,
  "3 mg/kg", 3.5, 120,  55.8,
  "3 mg/kg", 4.5,  30, 174.0,
  "3 mg/kg", 4.5,  60, 126.0,
  "3 mg/kg", 4.5,  90, 102.6,
  "3 mg/kg", 4.5, 120,  93.3,
  "240 mg",  2.5,  30,  82.2,
  "240 mg",  2.5,  60,  55.3,
  "240 mg",  2.5,  90,  43.0,
  "240 mg",  2.5, 120,  37.1,
  "240 mg",  3.5,  30, 151.8,
  "240 mg",  3.5,  60, 106.7,
  "240 mg",  3.5,  90,  86.8,
  "240 mg",  3.5, 120,  75.1,
  "240 mg",  4.5,  30, 231.6,
  "240 mg",  4.5,  60, 169.3,
  "240 mg",  4.5,  90, 138.3,
  "240 mg",  4.5, 120, 123.7
) |>
  mutate(treatment = sprintf("%s, ALB %.1f, eGFR %d", regimen, alb, egfr))
n_per_arm <- 200
scen <- figS1_ref |>
  select(regimen, alb, egfr, treatment) |>
  mutate(scen = row_number())
wt_draw <- rtnorm(n_per_arm, 59.7, 12.3, 30, 120)

# One interval at the start and the 12th and 26th intervals are sampled
# densely enough for PKNCA; no dose is given after the 26th interval, so its
# end-of-interval sample is a clean trough.
nca_grid <- c(0, 0.5, 2, 6, 24, 48, 96, 168, 240, 336)
intervals_nca <- tibble(
  start = c(0, 11 * tau, 25 * tau),
  end = c(tau, 12 * tau, 26 * tau),
  window = c("Dose 1", "Dose 12", "Dose 26")
)
ev_s1 <- bind_rows(lapply(seq_len(nrow(scen)), function(k) {
  ids <- (k - 1) * n_per_arm + seq_len(n_per_arm)
  amt <- if (scen$regimen[k] == "240 mg") rep(240, n_per_arm) else 3 * wt_draw
  dose <- data.frame(id = rep(ids, each = n_dose), time = rep(dose_times, n_per_arm),
                     amt = rep(amt, each = n_dose), dur = 0.5, evid = 1)
  obs_t <- unique(unlist(lapply(intervals_nca$start, function(s) s + nca_grid)))
  obs <- data.frame(id = rep(ids, each = length(obs_t)),
                    time = rep(obs_t, n_per_arm), amt = 0, dur = 0, evid = 0)
  bind_rows(dose, obs) |>
    mutate(cmt = "central", ALB = scen$alb[k] * 10, CRCL = scen$egfr[k],
           treatment = scen$treatment[k])
})) |>
  arrange(id, time, desc(evid))

sim_s1 <- as.data.frame(rxode2::rxSolve(ui, ev_s1, keep = "treatment",
                                        returnType = "data.frame"))
trough <- sim_s1 |>
  filter(time %in% c(12 * tau, 26 * tau)) |>
  left_join(scen, by = "treatment") |>
  mutate(window = ifelse(time == 26 * tau, "Dose 26", "Dose 12"))

ggplot(filter(trough, window == "Dose 26"),
       aes(factor(egfr), Cc, fill = regimen)) +
  geom_boxplot(outlier.shape = NA) +
  geom_point(data = figS1_ref, aes(factor(egfr), cmin_ref, group = regimen),
             position = position_dodge(width = 0.75), shape = 4, size = 3) +
  facet_wrap(~ paste("ALB =", alb)) +
  coord_cartesian(ylim = c(0, 450)) +
  labs(x = "eGFR (mL/min/1.73 m^2)", y = "Css,min (ug/mL)", fill = "Dose") +
  theme_bw()
Simulated trough concentrations at the end of the 26th dosing interval by regimen, albumin and eGFR (boxes: 200 virtual patients per cell); crosses are the medians digitised from Figure S1 of Tohi 2023.

Simulated trough concentrations at the end of the 26th dosing interval by regimen, albumin and eGFR (boxes: 200 virtual patients per cell); crosses are the medians digitised from Figure S1 of Tohi 2023.

PKNCA validation

PKNCA computes Cmax, Cmin, AUC and average concentration over the first, 12th and 26th dosing intervals for every Figure S1 cell. The treatment grouping carries the regimen-albumin-eGFR cell. Cmin of the first interval is the pre-dose zero; the later intervals’ Cmin is the trough at the end of the interval.

conc_s1 <- sim_s1 |>
  filter(!is.na(Cc)) |>
  select(id, time, Cc, treatment)
# Guarantee a time-zero record per subject for the first interval.
conc_s1 <- bind_rows(
  conc_s1,
  conc_s1 |> distinct(id, treatment) |> mutate(time = 0, Cc = 0)
) |>
  distinct(id, time, .keep_all = TRUE) |>
  arrange(id, time)

dose_s1 <- ev_s1 |>
  filter(evid == 1) |>
  select(id, time, amt, treatment)

conc_obj <- PKNCA::PKNCAconc(conc_s1, Cc ~ time | treatment + id,
                             concu = "ug/mL", timeu = "h")
dose_obj <- PKNCA::PKNCAdose(dose_s1, amt ~ time | treatment + id,
                             doseu = "mg")
intervals <- intervals_nca |>
  select(start, end) |>
  mutate(cmax = TRUE, cmin = TRUE, auclast = TRUE, cav = TRUE)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))

nca_tab <- as.data.frame(nca_res) |>
  left_join(intervals_nca, by = c("start", "end")) |>
  filter(PPTESTCD %in% c("cmax", "cmin", "auclast", "cav")) |>
  group_by(treatment, window, PPTESTCD) |>
  summarise(median = median(PPORRES), .groups = "drop")

nca_tab |>
  filter(grepl("ALB 3.5, eGFR 60", treatment)) |>
  pivot_wider(names_from = PPTESTCD, values_from = median) |>
  rename(Treatment = treatment, Window = window,
         "Cmax (ug/mL)" = cmax, "Cmin (ug/mL)" = cmin,
         "AUC (ug*h/mL)" = auclast, "Cavg (ug/mL)" = cav) |>
  knitr::kable(digits = 1, caption = "Median PKNCA results for the albumin 3.5 g/dL, eGFR 60 cells.")
Median PKNCA results for the albumin 3.5 g/dL, eGFR 60 cells.
Treatment Window AUC (ug*h/mL) Cavg (ug/mL) Cmax (ug/mL) Cmin (ug/mL)
240 mg, ALB 3.5, eGFR 60 Dose 1 16253.0 48.4 105.2 0.0
240 mg, ALB 3.5, eGFR 60 Dose 12 47205.1 140.5 218.5 108.2
240 mg, ALB 3.5, eGFR 60 Dose 26 50848.2 151.3 229.1 118.0
3 mg/kg, ALB 3.5, eGFR 60 Dose 1 11521.1 34.3 77.6 0.0
3 mg/kg, ALB 3.5, eGFR 60 Dose 12 33867.8 100.8 157.2 75.7
3 mg/kg, ALB 3.5, eGFR 60 Dose 26 36738.8 109.3 165.2 82.7

Comparison against Figure S1

sim_cmin_26 <- as.data.frame(nca_res) |>
  filter(PPTESTCD == "cmin", start == 25 * tau) |>
  select(treatment, PPTESTCD, PPORRES)
ref_cmin <- figS1_ref |>
  select(treatment, cmin = cmin_ref)

tbl <- nlmixr2lib::ncaComparisonTable(
  sim_cmin_26, ref_cmin,
  by = "treatment",
  units = c(cmin = "ug/mL"),
  tolerance_pct = 20
)
knitr::kable(tbl, caption = "Median trough at the end of the 26th dosing interval: Figure S1 of Tohi 2023 (digitised) vs this model.")
Median trough at the end of the 26th dosing interval: Figure S1 of Tohi 2023 (digitised) vs this model.
NCA parameter treatment Reference Simulated % diff
Cmin (ug/mL) 3 mg/kg, ALB 2.5, eGFR 30 61.7 64.8 +5.0%
Cmin (ug/mL) 3 mg/kg, ALB 2.5, eGFR 60 41.2 43.8 +6.4%
Cmin (ug/mL) 3 mg/kg, ALB 2.5, eGFR 90 31.9 35.2 +10.4%
Cmin (ug/mL) 3 mg/kg, ALB 2.5, eGFR 120 26.7 28.6 +6.9%
Cmin (ug/mL) 3 mg/kg, ALB 3.5, eGFR 30 113 122 +8.0%
Cmin (ug/mL) 3 mg/kg, ALB 3.5, eGFR 60 81 82.7 +2.1%
Cmin (ug/mL) 3 mg/kg, ALB 3.5, eGFR 90 65.2 68.3 +4.7%
Cmin (ug/mL) 3 mg/kg, ALB 3.5, eGFR 120 55.8 58.9 +5.6%
Cmin (ug/mL) 3 mg/kg, ALB 4.5, eGFR 30 174 178 +2.1%
Cmin (ug/mL) 3 mg/kg, ALB 4.5, eGFR 60 126 132 +4.6%
Cmin (ug/mL) 3 mg/kg, ALB 4.5, eGFR 90 103 115 +11.6%
Cmin (ug/mL) 3 mg/kg, ALB 4.5, eGFR 120 93.3 94.2 +1.0%
Cmin (ug/mL) 240 mg, ALB 2.5, eGFR 30 82.2 86.9 +5.7%
Cmin (ug/mL) 240 mg, ALB 2.5, eGFR 60 55.3 62.3 +12.6%
Cmin (ug/mL) 240 mg, ALB 2.5, eGFR 90 43 48.3 +12.3%
Cmin (ug/mL) 240 mg, ALB 2.5, eGFR 120 37.1 39.7 +7.1%
Cmin (ug/mL) 240 mg, ALB 3.5, eGFR 30 152 169 +11.2%
Cmin (ug/mL) 240 mg, ALB 3.5, eGFR 60 107 118 +10.5%
Cmin (ug/mL) 240 mg, ALB 3.5, eGFR 90 86.8 94.2 +8.5%
Cmin (ug/mL) 240 mg, ALB 3.5, eGFR 120 75.1 81 +7.9%
Cmin (ug/mL) 240 mg, ALB 4.5, eGFR 30 232 243 +5.0%
Cmin (ug/mL) 240 mg, ALB 4.5, eGFR 60 169 175 +3.4%
Cmin (ug/mL) 240 mg, ALB 4.5, eGFR 90 138 147 +6.1%
Cmin (ug/mL) 240 mg, ALB 4.5, eGFR 120 124 130 +5.1%
attr(tbl, "footnote")
#> NULL
cmp <- trough |>
  group_by(treatment, regimen, alb, egfr, window) |>
  summarise(sim = median(Cc), .groups = "drop") |>
  left_join(select(figS1_ref, treatment, cmin_ref), by = "treatment") |>
  mutate(pct_diff = 100 * (sim / cmin_ref - 1))

cmp |>
  group_by(Window = window) |>
  summarise("Median % diff" = median(pct_diff),
            "Min % diff" = min(pct_diff),
            "Max % diff" = max(pct_diff), .groups = "drop") |>
  knitr::kable(digits = 1, caption = "Simulated vs digitised Figure S1 medians, by the trough taken as steady state.")
Simulated vs digitised Figure S1 medians, by the trough taken as steady state.
Window Median % diff Min % diff Max % diff
Dose 12 -0.2 -11.7 6.8
Dose 26 6.4 1.2 12.8

cmp26 <- filter(cmp, window == "Dose 26")
stopifnot(
  # Structural: a mis-transcribed CL, exponent or reference value shifts the
  # whole grid by tens of percent.
  abs(median(cmp26$pct_diff)) < 20,
  quantile(abs(cmp26$pct_diff), 0.9) < 25
)

The relative effects stated in the Results can be recomputed from the simulated medians and compared with the published ranges.

med26 <- cmp26 |> select(regimen, alb, egfr, sim)
w <- med26 |> pivot_wider(names_from = regimen, values_from = sim)
reg_pct <- 100 * (w[["240 mg"]] / w[["3 mg/kg"]] - 1)

alb_w <- med26 |> pivot_wider(names_from = alb, values_from = sim, names_prefix = "alb")
alb_vs35 <- 100 * (1 - alb_w$alb2.5 / alb_w$alb3.5)
alb_vs45 <- 100 * (1 - alb_w$alb2.5 / alb_w$alb4.5)

egfr_w <- med26 |> pivot_wider(names_from = egfr, values_from = sim, names_prefix = "e")
e_vs60 <- 100 * (egfr_w$e30 / egfr_w$e60 - 1)
e_vs90 <- 100 * (egfr_w$e30 / egfr_w$e90 - 1)
e_vs120 <- 100 * (egfr_w$e30 / egfr_w$e120 - 1)

ratio_tab <- tibble(
  Comparison = c("240 mg vs 3 mg/kg, % higher",
                 "ALB 2.5 vs 3.5 g/dL, % lower",
                 "ALB 2.5 vs 4.5 g/dL, % lower",
                 "eGFR 30 vs 60, % higher",
                 "eGFR 30 vs 90, % higher",
                 "eGFR 30 vs 120, % higher"),
  Published = c("32.2-38.3", "45.7-51.9", "64.5-71.4", "36.6-49.3", "67.3-93.0", "85.9-129.8"),
  Simulated = sapply(list(reg_pct, alb_vs35, alb_vs45, e_vs60, e_vs90, e_vs120),
                     function(x) sprintf("%.1f-%.1f", min(x), max(x)))
)
knitr::kable(ratio_tab, caption = "Relative differences in median Css,min (Tohi 2023 Results, 'Monte Carlo Simulation Analysis') vs this model.")
Relative differences in median Css,min (Tohi 2023 Results, ‘Monte Carlo Simulation Analysis’) vs this model.
Comparison Published Simulated
240 mg vs 3 mg/kg, % higher 32.2-38.3 28.0-42.6
ALB 2.5 vs 3.5 g/dL, % lower 45.7-51.9 47.0-51.5
ALB 2.5 vs 4.5 g/dL, % lower 64.5-71.4 63.6-69.7
eGFR 30 vs 60, % higher 36.6-49.3 34.9-47.8
eGFR 30 vs 90, % higher 67.3-93.0 55.3-83.9
eGFR 30 vs 120, % higher 85.9-129.8 87.5-126.9

# The published ranges come from 1000-patient simulations; allow a few
# percentage points of Monte Carlo noise around each range.
stopifnot(
  median(reg_pct) > 25, median(reg_pct) < 45,
  median(alb_vs35) > 40, median(alb_vs35) < 58,
  median(alb_vs45) > 58, median(alb_vs45) < 78,
  median(e_vs120) > 75, median(e_vs120) < 140
)

Assumptions and deviations

Errata and readings that required arbitration

  • T50 printed as 1501 h in the final CL equation. Methods (“T50 (1510 h)”) and Table 2 (“1510 (FIX)”) both give 1510 h, and the value is stated to be taken unchanged from Osawa et al. The final equation in the Results prints 1501^2.02, read here as a transposition. The model uses 1510 h; the two values change CL by less than 0.5% at any time.
  • Minus signs in the PDF text layer. The PDF text layer renders the minus sign as “2” (e.g. “2 1.48”, “2 0.285”); the signs were confirmed on the typeset page. The negative albumin exponent also agrees with the text (“ALB levels negatively correlated with nivolumab CL”).

Assumptions made because the paper does not say

  • IIV scale. Table 2 reports IIV as CV%. The variances use omega2^2 = log(1 + CV2^2); reading CV% as 100 x omega instead would give 0.0376 and 0.214, within the bootstrap 95% CIs either way.
  • Infusion duration. Not reported; 30 minutes is used.
  • Time origin. The time-varying CL uses time since the first dose; the model reads it from rxode2’s t, so the first dose must be at time 0.
  • Steady state in the Monte Carlo simulation. Not defined in the paper. The trough at the end of the 26th interval (one year) is the primary comparison; its medians run 1-13% above the digitised Figure S1 medians (median +6%). The 24-week trough, shown as a sensitivity, agrees with Figure S1 to within -12% to +7% (median 0%), so the authors’ steady state may be earlier than one year. The relative effects of regimen, albumin and eGFR match the published ranges at either time. No parameter was adjusted to close the gap.
  • Covariate distributions. Baseline albumin and eGFR are drawn from normal distributions with the Table S3 means and SDs, truncated to the Table 1 ranges, and held constant over time (Table S3 shows no systematic change).
  • Albumin units. The published coefficient and the reference 3.6 are in g/dL; the model takes the canonical ALB column in g/L and converts.

Covariates screened but not retained

Age, body weight, BSA, sex, ECOG performance status, histology and PD-L1 expression were tested on CL and not retained (Table S1; PD-L1 dOFV -3.5, p = 0.061). They are documented in covariatesDataExcluded where a canonical column exists.

Session info

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
#>  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
#>  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
#> [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
#> 
#> time zone: UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] ggplot2_4.0.3         tidyr_1.3.2           dplyr_1.2.1          
#> [4] rxode2_5.1.8          PKNCA_0.12.1          nlmixr2lib_0.3.2.9000
#> 
#> loaded via a namespace (and not attached):
#>  [1] gtable_0.3.6        xfun_0.62           bslib_0.12.0       
#>  [4] rxode2lincmt_0.1.0  lattice_0.22-9      vctrs_0.7.3        
#>  [7] tools_4.6.1         generics_0.1.4      parallel_4.6.1     
#> [10] tibble_3.3.1        symengine_0.2.14    pkgconfig_2.0.3    
#> [13] data.table_1.18.6.1 checkmate_2.3.4     RColorBrewer_1.1-3 
#> [16] S7_0.2.2            desc_1.4.3          lifecycle_1.0.5    
#> [19] compiler_4.6.1      farver_2.1.2        textshaping_1.0.5  
#> [22] fontawesome_0.5.3   htmltools_0.5.9     sys_3.4.3          
#> [25] sass_0.4.10         yaml_2.3.12         pillar_1.11.1      
#> [28] pkgdown_2.2.1       crayon_1.5.3        jquerylib_0.1.4    
#> [31] whisker_0.4.1       openssl_2.4.2       cachem_1.1.0       
#> [34] nlme_3.1-169        tidyselect_1.2.1    digest_0.6.39      
#> [37] lotri_1.0.5         purrr_1.2.2         labeling_0.4.3     
#> [40] rxode2ll_2.0.18     fastmap_1.2.0       grid_4.6.1         
#> [43] cli_3.6.6           dparser_1.3.1-14    magrittr_2.0.5     
#> [46] withr_3.0.3         scales_1.4.0        backports_1.5.1    
#> [49] rmarkdown_2.32      otel_0.2.0          askpass_1.2.1      
#> [52] ragg_1.5.2          memoise_2.0.1       evaluate_1.0.5     
#> [55] knitr_1.52          rex_1.2.2           PreciseSums_0.7    
#> [58] rlang_1.3.0         downlit_0.4.5       Rcpp_1.1.2         
#> [61] glue_1.8.1          xml2_1.6.0          jsonlite_2.0.0     
#> [64] R6_2.6.1            systemfonts_1.3.2   fs_2.1.0