Model and source
- Citation: Ou YC, Liu L, Tariq B, Wang K, Jindal A, Tang Z, Gao Y, Sahasranaman S. Population pharmacokinetic analysis of the BTK inhibitor zanubrutinib in healthy volunteers and patients with B-cell malignancies. Clin Transl Sci. 2021;14(2):764-772. doi:10.1111/cts.12948. PMCID: PMC7993273.
- Description: Two-compartment population PK model with sequential zero-order then first-order absorption for oral zanubrutinib in healthy volunteers and adults with B-cell malignancies
- Article: https://doi.org/10.1111/cts.12948 (open access, PMC7993273)
- Supplement (demographics, model-development run log, diagnostic plots): https://europepmc.org/article/PMC/PMC7993273
Zanubrutinib is an oral, irreversible, second-generation Bruton’s tyrosine kinase (BTK) inhibitor. Ou 2021 pooled nine clinical studies – four in healthy volunteers and five in patients with B-cell malignancies – and described the plasma concentrations with a two-compartment model with sequential zero-order then first-order absorption: the dose enters a depot compartment at a constant rate over a duration D1 and is then absorbed into the central compartment with a first-order rate constant ka (Figure 1). Health status (healthy volunteer vs patient) and baseline alanine aminotransferase (ALT) were retained as covariates on apparent clearance. Inter-occasion variability (IOV) was estimated on CL/F, Vc/F and D1, and the residual error switches from additive-only to combined additive plus proportional at 5 hours after the previous dose.
mod <- readModelDb("Ou_2021_zanubrutinib")
ui <- rxode2::rxode(mod)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2, etaiov_vc_1, etaiov_vc_2, etaiov_d1_1, etaiov_d1_2
#> as a work-around try putting the mu-referenced expression on a simple line
ui
#> ── rxode2-based free-form 3-cmt ODE model ──────────────────────────────────────
#> ── Initalization: ──
#> Fixed Effects ($theta):
#> lcl lvc lq lvp
#> 5.1357984 4.7184989 3.2771447 5.8435444
#> lka ld1 e_dis_healthy_cl e_alt_cl
#> -0.6424541 0.1204462 -0.3651138 -0.1890000
#> addSd_early addSd_late propSd_late
#> 8.6900000 0.6330000 0.4490000
#>
#> Omega ($omega):
#> etalcl etalvc etalq etalvp etald1 etaiov_cl_1 etaiov_cl_2
#> etalcl 0.1347 0.1290 0.0000 0.0000 0.0000 0.0000 0.0000
#> etalvc 0.1290 0.1376 0.0000 0.0000 0.0000 0.0000 0.0000
#> etalq 0.0000 0.0000 1.0404 0.0000 0.0000 0.0000 0.0000
#> etalvp 0.0000 0.0000 0.0000 0.7465 0.0000 0.0000 0.0000
#> etald1 0.0000 0.0000 0.0000 0.0000 0.3881 0.0000 0.0000
#> etaiov_cl_1 0.0000 0.0000 0.0000 0.0000 0.0000 0.0818 0.0000
#> etaiov_cl_2 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0818
#> etaiov_vc_1 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000
#> etaiov_vc_2 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000
#> etaiov_d1_1 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000
#> etaiov_d1_2 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000
#> etaiov_vc_1 etaiov_vc_2 etaiov_d1_1 etaiov_d1_2
#> etalcl 0.0000 0.0000 0.0000 0.0000
#> etalvc 0.0000 0.0000 0.0000 0.0000
#> etalq 0.0000 0.0000 0.0000 0.0000
#> etalvp 0.0000 0.0000 0.0000 0.0000
#> etald1 0.0000 0.0000 0.0000 0.0000
#> etaiov_cl_1 0.0000 0.0000 0.0000 0.0000
#> etaiov_cl_2 0.0000 0.0000 0.0000 0.0000
#> etaiov_vc_1 0.4556 0.0000 0.0000 0.0000
#> etaiov_vc_2 0.0000 0.4556 0.0000 0.0000
#> etaiov_d1_1 0.0000 0.0000 0.3881 0.0000
#> etaiov_d1_2 0.0000 0.0000 0.0000 0.3881
#> attr(,"lotriLabels")
#> [1] NA
#> [2] "Table 2, IIV CL/F 36.7% (0.367^2), IIV Vc/F 37.1% (0.371^2), 'Covariance (CL/F, Vc/F)' = 0.129"
#> [3] "Table 2, IIV Q/F 102% (1.02^2)"
#> [4] "Table 2, IIV Vp/F 86.4% (0.864^2)"
#> [5] "Table 2, IIV D1 62.3% (0.623^2)"
#> [6] "Table 2, IOV CL/F 28.6% (0.286^2)"
#> [7] "same variance as occasion 1 (SAME)"
#> [8] "Table 2, IOV Vc/F 67.5% (0.675^2)"
#> [9] "same variance as occasion 1 (SAME)"
#> [10] "Table 2, IOV D1 62.3% (0.623^2); identical to the IIV D1 row as printed"
#> [11] "same variance as occasion 1 (SAME)"
#> attr(,"lotriFix")
#> etalcl etalvc etalq etalvp etald1 etaiov_cl_1 etaiov_cl_2
#> etalcl FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etalvc FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etalq FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etalvp FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etald1 FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etaiov_cl_1 FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etaiov_cl_2 FALSE FALSE FALSE FALSE FALSE FALSE TRUE
#> etaiov_vc_1 FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etaiov_vc_2 FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etaiov_d1_1 FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etaiov_d1_2 FALSE FALSE FALSE FALSE FALSE FALSE FALSE
#> etaiov_vc_1 etaiov_vc_2 etaiov_d1_1 etaiov_d1_2
#> etalcl FALSE FALSE FALSE FALSE
#> etalvc FALSE FALSE FALSE FALSE
#> etalq FALSE FALSE FALSE FALSE
#> etalvp FALSE FALSE FALSE FALSE
#> etald1 FALSE FALSE FALSE FALSE
#> etaiov_cl_1 FALSE FALSE FALSE FALSE
#> etaiov_cl_2 FALSE FALSE FALSE FALSE
#> etaiov_vc_1 FALSE FALSE FALSE FALSE
#> etaiov_vc_2 FALSE TRUE FALSE FALSE
#> etaiov_d1_1 FALSE FALSE FALSE FALSE
#> etaiov_d1_2 FALSE FALSE FALSE TRUE
#>
#> States ($state or $stateDf):
#> Compartment Number Compartment Name
#> 1 1 depot
#> 2 2 central
#> 3 3 peripheral1
#> ── μ-referencing ($muRefTable): ──
#> theta eta level
#> 1 lcl etalcl id
#> 2 lvc etalvc id
#> 3 lq etalq id
#> 4 lvp etalvp id
#> 5 ld1 etald1 id
#> covariates
#> 1 DIS_HEALTHY*e_dis_healthy_cl + log(0.0555555555555556 * ALT)*e_alt_cl
#> 2
#> 3
#> 4
#> 5
#>
#> ── Model (Normalized Syntax): ──
#> function() {
#> compartmentData <- list(depot = list(analyte = "zanubrutinib",
#> units = "mg", specimen = "administration site", verified = TRUE),
#> central = list(analyte = "zanubrutinib", units = "mg",
#> specimen = "plasma", verified = TRUE), peripheral1 = list(analyte = "zanubrutinib",
#> units = "mg", specimen = "tissue", verified = TRUE))
#> covariateData <- list(ALT = list(description = "Baseline serum alanine aminotransferase",
#> units = "U/L", type = "continuous", reference_category = NULL,
#> notes = "Baseline (time-fixed). Enters CL/F as a power function normalised to 18 U/L, the typical-patient value of Eq. 5 and the cohort median (Table S1a: median 18, range 4-197 IU/L overall; 20 IU/L in healthy volunteers and 18 IU/L in patients). The paper writes the effect as -0.189 x log(ALT/18) inside the exponent of Eq. 5, i.e. (ALT/18)^-0.189. Three subjects with missing ALT were imputed to the population median (Supplementary Material, Handling of Missing Covariates).",
#> source_name = "ALT"), DIS_HEALTHY = list(description = "Healthy-volunteer indicator (1 = healthy volunteer, 0 = patient with a B-cell malignancy)",
#> units = "(binary)", type = "binary", reference_category = "patient with a B-cell malignancy (DIS_HEALTHY = 0)",
#> notes = "Eq. 5 carries two indicator terms, 5.13 x Patient + 4.77 x HV, so each health-status group has its own typical log CL/F (Table 2: exp(theta1) = 170 L/h for patients, exp(theta10) = 118 L/h for healthy volunteers). Encoded here as the patient typical value plus a log-ratio shift for DIS_HEALTHY = 1. The patient cohort pools CLL/SLL, MCL, WM and other B-cell malignancies (Table S1b); tumor type was screened but not retained.",
#> source_name = "HV / Patient"), OCC = list(description = "Occasion index for the between-occasion random effects",
#> units = "(count)", type = "categorical", reference_category = NULL,
#> notes = "Two occasions (Results, Base model development): OCC = 1 for records < 7 days after the first dose (single-dose data) and OCC = 2 for records >= 7 days after the first dose (after repeated dosing). Selects the per-occasion IOV etas on CL/F, Vc/F and D1. A single-occasion simulation may use OCC = 1 throughout.",
#> source_name = "OCC"))
#> covariatesDataExcluded <- list(AGE = list(description = "Baseline age",
#> units = "years", type = "continuous", notes = "Screened (19-90 years) and not a statistically significant covariate on CL/F or Vc/F (Results, Base model development and covariate assessment; Figure S1C)."),
#> WT = list(description = "Baseline body weight", units = "kg",
#> type = "continuous", notes = "Screened (36-144 kg) and not statistically significant on CL/F or Vc/F (Figure S1D)."),
#> SEXF = list(description = "Female sex indicator", units = "(binary)",
#> type = "binary", notes = "Screened and not statistically significant (Results)."),
#> RACE_ASIAN = list(description = "Asian race indicator",
#> units = "(binary)", type = "binary", notes = "Race (Asian, white, other) screened and not statistically significant (Figure S1A)."),
#> CRCL_BASE = list(description = "Baseline Cockcroft-Gault creatinine clearance",
#> units = "mL/min", type = "continuous", notes = "Mild or moderate renal impairment (CrCL >= 30 mL/min) screened and not statistically significant (Figure S1E)."),
#> AST = list(description = "Baseline aspartate aminotransferase",
#> units = "U/L", type = "continuous", notes = "Screened and not statistically significant (Results)."),
#> TBILI = list(description = "Baseline total bilirubin",
#> units = "umol/L", type = "continuous", notes = "Screened and not statistically significant (Results)."),
#> CONMED_PPI = list(description = "Concomitant proton-pump inhibitor use",
#> units = "(binary)", type = "binary", notes = "PPI and H2RA use were pooled as one acid-reducing-agent category and were not statistically significant (P > 0.074, ANOVA; Figure S2)."),
#> CONMED_H2RA = list(description = "Concomitant H2-receptor-antagonist use",
#> units = "(binary)", type = "binary", notes = "Pooled with PPI use as acid-reducing agents; not statistically significant (Figure S2)."),
#> TUMTP_MCL = list(description = "Mantle cell lymphoma tumor-type indicator",
#> units = "(binary)", type = "binary", notes = "Tumor type (MCL, CLL/SLL, WM, other) passed the P < 0.01 screen but was not retained by the NONMEM stepwise search (Table S2)."))
#> description <- "Two-compartment population PK model with sequential zero-order then first-order absorption for oral zanubrutinib in healthy volunteers and adults with B-cell malignancies"
#> population <- list(species = "human", n_subjects = 632, n_studies = 9,
#> n_observations = 4925, age_median = "64 years (range 19-90); healthy volunteers 43, patients 66",
#> weight_median = "75 kg (range 36-144)", sex_female_pct = 29.7,
#> race_ethnicity = c(White = 67.7, Asian = 23.1, Black = 4.1,
#> Other = 3, Missing = 2.1), disease_state = "90 healthy volunteers (14.2%) and 542 patients with B-cell malignancies: CLL/SLL 135 (21.4%), Waldenstrom's macroglobulinemia 196 (31.0%), mantle cell lymphoma 70 (11.1%), other 141 (22.3%)",
#> dose_range = "20-320 mg orally once or twice daily (most subjects 160 mg twice daily); the 480 mg single-dose data were excluded",
#> hepatic_function = "baseline ALT median 18 IU/L (range 4-197)",
#> renal_function = "baseline Cockcroft-Gault CrCL median 86 mL/min (range 13.5-240)",
#> co_medication = "proton-pump inhibitors 21.5%, H2-receptor antagonists 9.2%",
#> regions = "global phase I-III program (studies AU-003, 1002, 205, 206, 103, 104, 105, 106, 302)",
#> notes = "Demographics from Tables S1a/S1b; study list from Table 1 of Ou 2021. LLOQ 1 ng/mL; 6.05% of samples were below it and were omitted. Nine healthy volunteers dosed at 480 mg (study BGB-3111-106) were excluded because of less-than-dose-proportional exposure, and two subjects with extreme PK parameters were excluded.")
#> reference <- "Ou YC, Liu L, Tariq B, Wang K, Jindal A, Tang Z, Gao Y, Sahasranaman S. Population pharmacokinetic analysis of the BTK inhibitor zanubrutinib in healthy volunteers and patients with B-cell malignancies. Clin Transl Sci. 2021;14(2):764-772. doi:10.1111/cts.12948. PMCID: PMC7993273."
#> units <- list(time = "h", dosing = "mg", concentration = "ng/mL")
#> vignette <- "Ou_2021_zanubrutinib"
#> ini({
#> lcl <- 5.13579843705026
#> label("Apparent clearance CL/F, patient (L/h)")
#> lvc <- 4.71849887129509
#> label("Apparent central volume Vc/F (L)")
#> lq <- 3.27714473299218
#> label("Apparent intercompartmental clearance Q/F (L/h)")
#> lvp <- 5.84354441703136
#> label("Apparent peripheral volume Vp/F (L)")
#> lka <- -0.642454066244427
#> label("First-order absorption rate constant ka (1/h)")
#> ld1 <- 0.120446153075867
#> label("Duration of zero-order input into the depot D1 (h)")
#> e_dis_healthy_cl <- -0.365113812584597
#> label("Log-ratio of CL/F in healthy volunteers vs patients (unitless)")
#> e_alt_cl <- -0.189
#> label("Power exponent on (ALT/18) for CL/F (unitless)")
#> addSd_early <- 8.69
#> label("Additive residual error SD, TFDS < 5 h (ng/mL)")
#> addSd_late <- 0.633
#> label("Additive residual error SD, TFDS >= 5 h (ng/mL)")
#> propSd_late <- 0.449
#> label("Proportional residual error SD, TFDS >= 5 h (fraction)")
#> etalcl ~ 0.1347
#> etalvc ~ c(0.129, 0.1376)
#> label("Table 2, IIV CL/F 36.7% (0.367^2), IIV Vc/F 37.1% (0.371^2), 'Covariance (CL/F, Vc/F)' = 0.129")
#> etalq ~ 1.0404
#> label("Table 2, IIV Q/F 102% (1.02^2)")
#> etalvp ~ 0.7465
#> label("Table 2, IIV Vp/F 86.4% (0.864^2)")
#> etald1 ~ 0.3881
#> label("Table 2, IIV D1 62.3% (0.623^2)")
#> etaiov_cl_1 ~ 0.0818
#> label("Table 2, IOV CL/F 28.6% (0.286^2)")
#> etaiov_cl_2 ~ fix(0.0818)
#> label("same variance as occasion 1 (SAME)")
#> etaiov_vc_1 ~ 0.4556
#> label("Table 2, IOV Vc/F 67.5% (0.675^2)")
#> etaiov_vc_2 ~ fix(0.4556)
#> label("same variance as occasion 1 (SAME)")
#> etaiov_d1_1 ~ 0.3881
#> label("Table 2, IOV D1 62.3% (0.623^2); identical to the IIV D1 row as printed")
#> etaiov_d1_2 ~ fix(0.3881)
#> label("same variance as occasion 1 (SAME)")
#> })
#> model({
#> oc1 <- (OCC == 1)
#> oc2 <- (OCC == 2)
#> iov_cl <- oc1 * etaiov_cl_1 + oc2 * etaiov_cl_2
#> iov_vc <- oc1 * etaiov_vc_1 + oc2 * etaiov_vc_2
#> iov_d1 <- oc1 * etaiov_d1_1 + oc2 * etaiov_d1_2
#> cl <- exp(lcl + e_dis_healthy_cl * DIS_HEALTHY + e_alt_cl *
#> log(ALT/18) + etalcl + iov_cl)
#> vc <- exp(lvc + etalvc + iov_vc)
#> q <- exp(lq + etalq)
#> vp <- exp(lvp + etalvp)
#> ka <- exp(lka)
#> d1 <- exp(ld1 + etald1 + iov_d1)
#> kel <- cl/vc
#> k12 <- q/vc
#> k21 <- q/vp
#> d/dt(depot) <- -ka * depot
#> d/dt(central) <- ka * depot - kel * central - k12 * central +
#> k21 * peripheral1
#> d/dt(peripheral1) <- k12 * central - k21 * peripheral1
#> dur(depot) <- d1
#> Cc <- 1000 * central/vc
#> tfds <- tad()
#> late <- tfds >= 5
#> addSdTad <- addSd_early * (1 - late) + addSd_late * late
#> propSdTad <- propSd_late * late
#> Cc ~ add(addSdTad) + prop(propSdTad)
#> })
#> }
# A cl/vc pair can make rxode2 replace the ODEs with its analytic linear
# compartment solution, which would silently drop dur(depot). Confirm the
# explicit ODE system is the one being solved.
stopifnot(is.null(ui$linCmt) || isFALSE(ui$linCmt))Population
The analysis dataset held 4,925 zanubrutinib plasma concentrations from 632 subjects across nine studies (Table 1 of Ou 2021; Results, Clinical data): 90 healthy volunteers (14.2%) and 542 patients with B-cell malignancies – CLL/SLL 135 (21.4%), Waldenstrom’s macroglobulinemia 196 (31.0%), mantle cell lymphoma 70 (11.1%) and other B-cell malignancies 141 (22.3%) (Table S1b). Median age was 64 years (range 19-90; 43 in healthy volunteers, 66 in patients), median body weight 75 kg (36-144), 29.7% of subjects were female, and 67.7% were White, 23.1% Asian and 4.1% Black. Median baseline ALT was 18 IU/L (range 4-197) (Table S1a). Doses ranged from 20 to 320 mg once or twice daily, most subjects receiving 160 mg twice daily; data at 480 mg were excluded because exposure was less than dose-proportional. The assay LLOQ was 1 ng/mL, and the 6.05% of samples below it were omitted.
str(ui$population)
#> List of 15
#> $ species : chr "human"
#> $ n_subjects : num 632
#> $ n_studies : num 9
#> $ n_observations : num 4925
#> $ age_median : chr "64 years (range 19-90); healthy volunteers 43, patients 66"
#> $ weight_median : chr "75 kg (range 36-144)"
#> $ sex_female_pct : num 29.7
#> $ race_ethnicity : Named num [1:5] 67.7 23.1 4.1 3 2.1
#> ..- attr(*, "names")= chr [1:5] "White" "Asian" "Black" "Other" ...
#> $ disease_state : chr "90 healthy volunteers (14.2%) and 542 patients with B-cell malignancies: CLL/SLL 135 (21.4%), Waldenstrom's mac"| __truncated__
#> $ dose_range : chr "20-320 mg orally once or twice daily (most subjects 160 mg twice daily); the 480 mg single-dose data were excluded"
#> $ hepatic_function: chr "baseline ALT median 18 IU/L (range 4-197)"
#> $ renal_function : chr "baseline Cockcroft-Gault CrCL median 86 mL/min (range 13.5-240)"
#> $ co_medication : chr "proton-pump inhibitors 21.5%, H2-receptor antagonists 9.2%"
#> $ regions : chr "global phase I-III program (studies AU-003, 1002, 205, 206, 103, 104, 105, 106, 302)"
#> $ notes : chr "Demographics from Tables S1a/S1b; study list from Table 1 of Ou 2021. LLOQ 1 ng/mL; 6.05% of samples were below"| __truncated__Source trace
The per-parameter origin is recorded as an in-file comment next to
each ini() entry in
inst/modeldb/specificDrugs/Ou_2021_zanubrutinib.R. The
table below collects them in one place.
| Equation / parameter | Value | Source location |
|---|---|---|
lcl |
CL/F = 170 L/h (patient) | Table 2, exp(theta1); Eq. 5 coefficient 5.13 |
e_dis_healthy_cl |
log(118 / 170) = -0.365 | Table 2, exp(theta10) CL/F in healthy volunteers = 118
L/h; Eq. 5 coefficient 4.77 |
e_alt_cl |
-0.189 | Table 2, theta11; Eq. 5 term
-0.189 x log(ALT/18)
|
lvc |
Vc/F = 112 L | Table 2, exp(theta2); Eq. 6 coefficient 4.72 |
lq |
Q/F = 26.5 L/h | Table 2, exp(theta3)
|
lvp |
Vp/F = 345 L | Table 2, exp(theta4)
|
lka |
ka = 0.526 1/h | Table 2, exp(theta5)
|
ld1 |
D1 = 1.128 h | Table 2, exp(theta6)
|
etalcl, etalvc
|
0.367^2, 0.371^2 | Table 2, IIV CL/F 36.7%, Vc/F 37.1% |
etalcl-etalvc covariance |
0.129 | Table 2, Covariance (CL/F, Vc/F)
|
etalq, etalvp, etald1
|
1.02^2, 0.864^2, 0.623^2 | Table 2, IIV Q/F 102%, Vp/F 86.4%, D1 62.3% |
etaiov_cl_*, etaiov_vc_*,
etaiov_d1_*
|
0.286^2, 0.675^2, 0.623^2 | Table 2, IOV CL/F 28.6%, Vc/F 67.5%, D1 62.3% |
addSd_early |
8.69 ng/mL | Table 2, theta9, additive error for TFDS < 5 h |
addSd_late |
0.633 ng/mL | Table 2, theta7, additive error for TFDS >= 5 h |
propSd_late |
0.449 | Table 2, theta8, proportional error 44.9% |
| Two-compartment model, zero-order input into depot then first-order absorption | n/a | Figure 1 (equations and diagram); Results, Base model development |
| Covariate form on CL/F | n/a | Eq. 5 |
| IOV occasions (< 7 days and >= 7 days) | n/a | Results, Base model development |
| Residual-error switch at 5 h after the previous dose | n/a | Results, Base model development; Discussion |
Typical-value checks against Figure 4
Figure 4 of Ou 2021 is a sensitivity analysis of steady-state
exposure after 20 doses of 160 mg twice daily. It prints the typical
patient’s exposure (ALT = 18 U/L: AUCss 943 ng*h/mL and Cmax,ss 254
ng/mL) and the percent change for each retained covariate. Those numbers
are typical-value (deterministic) predictions, so they are reproduced
here with zeroRe(). Because Cmax depends on ka and D1 as
well as on the disposition parameters, matching Cmax,ss tests the
absorption structure, not only the clearance.
TAU <- 12
N_DOSES <- 20
SS_START <- (N_DOSES - 1) * TAU # time of the 20th dose
# Dose rows carry rate = -2 so that dur(depot) <- d1 is honoured; without it
# the dose is a bolus into the depot.
ss_events <- function(scen, rate = -2, fine = 0.01) {
doses <- expand.grid(id = scen$id, time = (seq_len(N_DOSES) - 1) * TAU)
doses$amt <- 160
doses$evid <- 1L
doses$rate <- rate
obs <- expand.grid(id = scen$id, time = seq(SS_START, SS_START + TAU, by = fine))
obs$amt <- NA_real_
obs$evid <- 0L
obs$rate <- NA_real_
ev <- bind_rows(doses, obs)
ev$cmt <- ifelse(ev$evid == 1L, "depot", "central")
ev <- merge(ev, scen, by = "id")
ev[order(ev$id, ev$time, -ev$evid), ]
}
scenarios <- tibble::tribble(
~scenario, ~DIS_HEALTHY, ~ALT,
"Patient, ALT 18 U/L", 0, 18,
"Healthy volunteer", 1, 18,
"ALT 10 U/L", 0, 10,
"ALT 37 U/L", 0, 37
) |>
mutate(id = row_number(), OCC = 2)
sim_typ <- rxode2::rxSolve(rxode2::zeroRe(mod), events = ss_events(scenarios),
keep = "id", returnType = "data.frame") |>
filter(!is.na(Cc), time >= SS_START) |>
left_join(scenarios |> select(id, scenario), by = "id")
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2, etaiov_vc_1, etaiov_vc_2, etaiov_d1_1, etaiov_d1_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> Warning: No sigma parameters in the model
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2, etaiov_vc_1, etaiov_vc_2, etaiov_d1_1, etaiov_d1_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> Warning: 'keep' contains id
#> which are output when needed, ignoring these items
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etald1', 'etaiov_cl_1', 'etaiov_cl_2', 'etaiov_vc_1', 'etaiov_vc_2', 'etaiov_d1_1', 'etaiov_d1_2'
#> Warning: multi-subject simulation without without 'omega'Steady-state mass balance
For a linear model the solved AUC over one steady-state dosing
interval must equal Dose / (CL/F). Both sides use the same
parameters, so the only residual is integration and trapezoid error.
mb <- sim_typ |>
group_by(scenario) |>
summarise(cl = first(cl),
auc = sum(diff(time) * (head(Cc, -1) + tail(Cc, -1)) / 2),
cmax = max(Cc), cmin = min(Cc), .groups = "drop") |>
mutate(auc_closed = 160 * 1000 / cl, rel_err = auc / auc_closed - 1)
knitr::kable(
mb |>
select(scenario, cl, auc_closed, auc, rel_err) |>
dplyr::rename("Scenario" = scenario, "CL/F (L/h)" = cl,
"Dose/(CL/F) (ng*h/mL)" = auc_closed,
"Solved AUC0-tau (ng*h/mL)" = auc,
"Relative error" = rel_err),
digits = c(0, 2, 1, 1, 7),
caption = "Steady-state mass balance for the typical-value scenarios."
)| Scenario | CL/F (L/h) | Dose/(CL/F) (ng*h/mL) | Solved AUC0-tau (ng*h/mL) | Relative error |
|---|---|---|---|---|
| ALT 10 U/L | 189.97 | 842.2 | 842.2 | -1e-07 |
| ALT 37 U/L | 148.36 | 1078.5 | 1078.5 | -1e-07 |
| Healthy volunteer | 118.00 | 1355.9 | 1355.9 | -2e-07 |
| Patient, ALT 18 U/L | 170.00 | 941.2 | 941.2 | -1e-07 |
Figure 4: covariate sensitivity
base <- mb |> filter(scenario == "Patient, ALT 18 U/L")
published_fig4 <- tibble::tribble(
~scenario, ~metric, ~published_pct,
"Healthy volunteer", "AUCss", 43.7,
"ALT 10 U/L", "AUCss", -10.5,
"ALT 37 U/L", "AUCss", 14.6,
"Healthy volunteer", "Cmax,ss", 26.8,
"ALT 10 U/L", "Cmax,ss", -7.37,
"ALT 37 U/L", "Cmax,ss", 9.6
)
fig4 <- mb |>
transmute(scenario,
AUCss = 100 * (auc / base$auc - 1),
`Cmax,ss` = 100 * (cmax / base$cmax - 1)) |>
pivot_longer(c(AUCss, `Cmax,ss`), names_to = "metric", values_to = "model_pct") |>
inner_join(published_fig4, by = c("scenario", "metric")) |>
mutate(abs_diff = abs(model_pct - published_pct))
knitr::kable(
fig4 |>
dplyr::rename("Scenario" = scenario, "Metric" = metric,
"Model (% change)" = model_pct,
"Published (% change)" = published_pct,
"|difference| (percentage points)" = abs_diff),
digits = 2,
caption = "Percent change in steady-state exposure vs the typical patient: model vs Figure 4 of Ou 2021."
)| Scenario | Metric | Model (% change) | Published (% change) | |difference| (percentage points) |
|---|---|---|---|---|
| ALT 10 U/L | AUCss | -10.51 | -10.50 | 0.01 |
| ALT 10 U/L | Cmax,ss | -7.38 | -7.37 | 0.01 |
| ALT 37 U/L | AUCss | 14.59 | 14.60 | 0.01 |
| ALT 37 U/L | Cmax,ss | 9.57 | 9.60 | 0.03 |
| Healthy volunteer | AUCss | 44.07 | 43.70 | 0.37 |
| Healthy volunteer | Cmax,ss | 26.99 | 26.80 | 0.19 |
cmin_alt <- mb |>
filter(scenario %in% c("ALT 10 U/L", "ALT 37 U/L")) |>
mutate(pct = 100 * (cmin / base$cmin - 1))
stopifnot(
# Typical patient: 941.2 vs 943 printed (0.19%) and 253.7 vs 254 (0.13%).
# The residual is the paper printing CL/F as 170 rather than its unrounded
# estimate. 1% keeps headroom and still goes red on a unit or dose error.
abs(base$auc / 943 - 1) < 0.01,
abs(base$cmax / 254 - 1) < 0.01,
# Covariate effects: largest gap is the healthy-volunteer AUC (44.07% from
# the rounded 170/118 vs 43.7% printed). 0.6 points absorbs that rounding;
# a sign error, a log10 in place of the natural log, or a wrong reference
# ALT moves these by several points.
max(fig4$abs_diff) < 0.6,
# Results: the ALT effect on Cmin,ss is "moderate (< 30%)".
all(abs(cmin_alt$pct) < 30)
)
# Replicates Figure 4 of Ou 2021 (covariate bars only; the population 5th-95th
# percentile bar is compared with the simulated cohort further below).
bars <- mb |>
transmute(scenario, AUCss = auc, `Cmax,ss` = cmax) |>
pivot_longer(-scenario, names_to = "metric", values_to = "value") |>
left_join(base |> transmute(AUCss = auc, `Cmax,ss` = cmax) |>
pivot_longer(everything(), names_to = "metric", values_to = "base"),
by = "metric") |>
mutate(covariate = case_when(
scenario == "Healthy volunteer" ~ "PAT",
grepl("ALT", scenario) & scenario != "Patient, ALT 18 U/L" ~ "ALT",
TRUE ~ NA_character_)) |>
filter(!is.na(covariate))
ggplot(bars, aes(y = covariate)) +
geom_segment(aes(x = base, xend = value, yend = covariate), linewidth = 8,
colour = "steelblue") +
geom_vline(aes(xintercept = base)) +
geom_text(aes(x = value, label = scenario), vjust = -1.4, size = 3) +
facet_wrap(~metric, scales = "free_x") +
labs(x = "Steady-state exposure (AUC ng*h/mL; Cmax ng/mL)", y = NULL,
title = "Figure 4 -- covariate sensitivity, 160 mg BID",
caption = "Replicates Figure 4 of Ou 2021. Vertical line: typical patient, ALT 18 U/L.") +
theme_bw()
The zero-order input is load-bearing
As a control, the same typical patient is solved with the doses
entered as a bolus into the depot (no rate = -2, so
dur(depot) is ignored). The bolus leaves AUC unchanged but
raises Cmax,ss by about 4%, so the 1% Cmax gate above would fail on it.
The gate therefore checks the absorption structure and not only
CL/F.
bolus <- rxode2::rxSolve(rxode2::zeroRe(mod),
events = ss_events(scenarios[1, ], rate = 0),
returnType = "data.frame") |>
filter(!is.na(Cc), time >= SS_START)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2, etaiov_vc_1, etaiov_vc_2, etaiov_d1_1, etaiov_d1_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> Warning: No sigma parameters in the model
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2, etaiov_vc_1, etaiov_vc_2, etaiov_d1_1, etaiov_d1_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etald1', 'etaiov_cl_1', 'etaiov_cl_2', 'etaiov_vc_1', 'etaiov_vc_2', 'etaiov_d1_1', 'etaiov_d1_2'
bolus_cmax <- max(bolus$Cc)
bolus_cmax
#> [1] 265.1966
stopifnot(
abs(bolus_cmax / 254 - 1) > 0.02,
abs(base$cmax / 254 - 1) < abs(bolus_cmax / 254 - 1)
)Virtual cohort
Subject-level data are not public. The cohort below is two arms of 200 patients with B-cell malignancies at the two dosing regimens shown in Figure 3 of Ou 2021 (160 mg twice daily and 320 mg once daily). Baseline ALT is drawn log-normally with the patient median of 18 IU/L and mean of 22.1 IU/L (Table S1a). Each subject receives eight days of dosing. Records before day 7 are occasion 1 and later records are occasion 2, matching the paper’s occasion definition, so the first-dose and steady-state intervals use independent IOV draws.
# set.seed() fixes the covariate draw. rxode2's own eta and residual draws are
# partitioned per solver thread and can differ between machines, so every
# assertion on this cohort below is a centre or robust-quantile bound.
set.seed(20210301)
rxode2::rxSetSeed(20210301)
N_PER_ARM <- 200
alt_sdlog <- sqrt(2 * log(22.1 / 18))
arms <- tibble::tribble(
~treatment, ~dose, ~tau,
"160 mg BID", 160, 12,
"320 mg QD", 320, 24
)
cohort <- bind_rows(
tibble(id = seq_len(N_PER_ARM), arms[1, ]),
tibble(id = 1000L + seq_len(N_PER_ARM), arms[2, ])
) |>
mutate(ALT = rlnorm(n(), log(18), alt_sdlog), DIS_HEALTHY = 0)
SS_DAY_START <- 192 # start of the day-9 steady-state interval (occasion 2)
cohort_events <- function(cohort) {
per_id <- lapply(split(cohort, cohort$id), function(s) {
dose_t <- seq(0, SS_DAY_START, by = s$tau)
obs_t <- sort(unique(c(
seq(0, s$tau, by = 0.25),
SS_DAY_START + seq(0, s$tau, by = 0.25)
)))
bind_rows(
tibble(id = s$id, time = dose_t, evid = 1L, amt = s$dose, rate = -2,
cmt = "depot"),
tibble(id = s$id, time = obs_t, evid = 0L, amt = NA_real_,
rate = NA_real_, cmt = "central")
)
})
ev <- bind_rows(per_id) |>
left_join(cohort |> select(id, treatment, ALT, DIS_HEALTHY), by = "id") |>
mutate(OCC = ifelse(time < 7 * 24, 1, 2)) |>
arrange(id, time, desc(evid))
as.data.frame(ev)
}
events <- cohort_events(cohort)
stopifnot(!anyDuplicated(events[, c("id", "time", "evid")]))Simulation
sim <- rxode2::rxSolve(mod, events = events, keep = "treatment",
returnType = "data.frame") |>
filter(!is.na(Cc)) |>
mutate(treatment = as.character(treatment),
interval = ifelse(time >= SS_DAY_START, "Steady state (day 9)", "First dose"))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2, etaiov_vc_1, etaiov_vc_2, etaiov_d1_1, etaiov_d1_2
#> as a work-around try putting the mu-referenced expression on a simple line
stopifnot(!anyNA(sim$Cc), all(sim$Cc >= -1e-6 * max(sim$Cc)))Figure 3: concentration vs time after the previous dose
Figure 3 of Ou 2021 is a prediction-corrected VPC by regimen, plotted
against time after the previous dose on a log axis. The panels below
show the 2.5th, 50th and 97.5th percentiles of the simulated
observations (sim, which carries the time-dependent
residual error) at steady state.
vpc <- sim |>
filter(interval == "Steady state (day 9)") |>
mutate(tad = round(time - SS_DAY_START, 2)) |>
group_by(treatment, tad) |>
summarise(lo = quantile(sim, 0.025), med = median(sim),
hi = quantile(sim, 0.975), .groups = "drop")
ggplot(vpc, aes(tad, med)) +
geom_ribbon(aes(ymin = pmax(lo, 0.1), ymax = hi), alpha = 0.25, fill = "purple") +
geom_line(colour = "red") +
scale_y_log10(limits = c(0.1, 1e4)) +
facet_wrap(~treatment, scales = "free_x") +
labs(x = "Time after previous dose (h)", y = "Zanubrutinib concentration (ng/mL)",
title = "Figure 3 -- steady-state concentration vs time after dose",
caption = "Replicates Figure 3 of Ou 2021 (simulated 2.5th, 50th, 97.5th percentiles).") +
theme_bw()
Residual-error structure
The error model is additive only (SD 8.69 ng/mL) for records less than 5 h after the previous dose, and combined additive (0.633 ng/mL) plus proportional (44.9%) from 5 h onward. Standardising each simulated residual by the SD that applies in its stratum should give a unit standard deviation in both strata. Swapping the two branches, or dropping the switch, moves one of them far from 1.
# Time after the previous dose: in the first interval the only dose is at 0;
# in the steady-state interval the last dose is at SS_DAY_START. Records that
# coincide with a dose (tfds = 0, and the end of the first interval, where the
# next dose is given) are dropped so no record sits on a dose boundary.
res <- sim |>
left_join(arms |> select(treatment, tau), by = "treatment") |>
mutate(tfds = ifelse(interval == "First dose", time, time - SS_DAY_START)) |>
filter(tfds > 0, !(interval == "First dose" & tfds >= tau)) |>
mutate(stratum = ifelse(tfds >= 5, "TFDS >= 5 h", "TFDS < 5 h"),
sd_expected = ifelse(tfds >= 5, sqrt(0.633^2 + (0.449 * Cc)^2), 8.69),
z = (sim - Cc) / sd_expected)
res_tab <- res |>
group_by(stratum) |>
summarise(n = n(), sd_z = sd(z), .groups = "drop")
knitr::kable(
res_tab |> dplyr::rename("Stratum" = stratum, "n" = n,
"SD of standardised residual" = sd_z),
digits = 3,
caption = "Standardised residuals by time-after-dose stratum; each should have SD near 1."
)| Stratum | n | SD of standardised residual |
|---|---|---|
| TFDS < 5 h | 15200 | 1.005 |
| TFDS >= 5 h | 42000 | 0.998 |
PKNCA validation
NCA on the steady-state (day 9) dosing interval of each arm, with the time axis shifted so the interval starts at 0.
conc_ss <- sim |>
filter(interval == "Steady state (day 9)") |>
transmute(id, treatment, time = time - SS_DAY_START, Cc)
dose_ss <- cohort |> transmute(id, treatment, time = 0, amt = dose)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(
PKNCA::PKNCAconc(as.data.frame(conc_ss), Cc ~ time | treatment + id,
concu = "ng/mL", timeu = "h"),
PKNCA::PKNCAdose(as.data.frame(dose_ss), amt ~ time | treatment + id,
doseu = "mg"),
intervals = data.frame(
start = 0, end = c(12, 24),
cmax = TRUE, tmax = TRUE, auclast = TRUE, cmin = TRUE
) |> mutate(treatment = c("160 mg BID", "320 mg QD"))
))Comparison against published exposures
Ou 2021 reports typical-patient steady-state exposure for 160 mg twice daily (AUCss 943 ngh/mL, Cmax,ss 254 ng/mL; Figure 4) and a total daily AUC of 1,886 ngh/mL. For a log-normal clearance the cohort median AUC equals the typical value, so the median over the virtual cohort is compared with it. The paper reports no typical exposures for 320 mg once daily. For a linear model its daily AUC equals the 160 mg twice-daily daily AUC, which is used as the reference.
Only AUC is gated. Cmax is not log-linear in the random effects, and Vc/F carries both IIV (37.1%) and IOV (67.5%), a combined log-scale variance of 0.59. The cohort median Cmax therefore sits below the Cmax of the typical subject (about 15% lower here). The typical-value Cmax of 254 ng/mL is already matched exactly by the deterministic check above.
sim_med <- as.data.frame(nca_res$result) |>
filter(PPTESTCD %in% c("cmax", "auclast"), !is.na(PPORRES)) |>
group_by(treatment, PPTESTCD) |>
summarise(PPORRES = median(PPORRES), .groups = "drop")
published <- tibble::tribble(
~treatment, ~cmax, ~auclast,
"160 mg BID", 254, 943,
"320 mg QD", NA, 1886
)
cmp <- nlmixr2lib::ncaComparisonTable(
simulated = sim_med,
reference = published,
by = "treatment",
units = c(cmax = "ng/mL", auclast = "ng*h/mL"),
tolerance_pct = 20
)
knitr::kable(
cmp,
caption = paste(
"Simulated cohort median vs the typical-patient exposures of Ou 2021.",
"The 320 mg QD AUC0-24 reference is the paper's total daily AUC of",
"1,886 ng*h/mL. * marks a difference above 20%."
)
)| NCA parameter | treatment | Reference | Simulated | % diff |
|---|---|---|---|---|
| Cmax (ng/mL) | 160 mg BID | 254 | 217 | -14.7% |
| Cmax (ng/mL) | 320 mg QD | — | 409 | — |
| AUClast (ng*h/mL) | 160 mg BID | 943 | 963 | +2.2% |
| AUClast (ng*h/mL) | 320 mg QD | 1890 | 1820 | -3.5% |
auc_med <- sim_med |> filter(PPTESTCD == "auclast")
stopifnot(
# Centre, not extremes: the cohort median AUC must match the typical value.
# log(AUC) has an SD of about 0.47 (IIV + IOV on CL/F), so the SE of the
# median over 200 subjects is about 4%; 15% is well over 3 SE and still goes
# red on a mis-transcribed CL/F, dose or unit.
abs(auc_med$PPORRES[auc_med$treatment == "160 mg BID"] / 943 - 1) < 0.15,
abs(auc_med$PPORRES[auc_med$treatment == "320 mg QD"] / 1886 - 1) < 0.15
)Figure 4 also prints the 5th-95th percentile range of steady-state exposure across the whole analysis population (AUCss 526-2,114 ng*h/mL; Cmax,ss 152-499 ng/mL at 160 mg twice daily). The paper’s range comes from the individual (post hoc) parameters of the 632 analysed subjects, healthy volunteers included, and is shrunk toward the typical value. The simulated range comes from the full IIV and IOV in patients only. The comparison is shown for context and is not gated.
as.data.frame(nca_res$result) |>
filter(treatment == "160 mg BID", PPTESTCD %in% c("auclast", "cmax")) |>
group_by(PPTESTCD) |>
summarise(p05 = quantile(PPORRES, 0.05), p95 = quantile(PPORRES, 0.95),
.groups = "drop") |>
mutate(published_p05 = ifelse(PPTESTCD == "auclast", 526.2, 152),
published_p95 = ifelse(PPTESTCD == "auclast", 2114, 499)) |>
dplyr::rename("Parameter" = PPTESTCD, "Simulated 5th" = p05,
"Simulated 95th" = p95, "Published 5th" = published_p05,
"Published 95th" = published_p95) |>
knitr::kable(digits = 1, caption = "5th-95th percentile steady-state exposure, 160 mg BID.")| Parameter | Simulated 5th | Simulated 95th | Published 5th | Published 95th |
|---|---|---|---|---|
| auclast | 455.6 | 2279.4 | 526.2 | 2114 |
| cmax | 92.0 | 514.7 | 152.0 | 499 |
Assumptions and deviations
IIV/IOV variance scale. Table 2 reports variability as percent CV. The variances are encoded as
(CV/100)^2. The printed (CL/F, Vc/F) covariance of 0.129 settles this: with(CV/100)^2variances (0.1347, 0.1376) the implied correlation is 0.947, whereaslog(1 + CV^2)variances (0.1261, 0.1289) would imply a correlation of 1.01, which is not a valid covariance matrix.IIV and IOV on D1 are printed identically. Table 2 gives 62.3% (95% CI 57.1-67.1; bootstrap 62.2, 51.9-72.4) for both the IIV and the IOV of D1. The two agree down to the confidence interval, which suggests one row was duplicated when the table was typeset. No other source prints either value, so both are encoded as printed.
Occasions. IOV uses two occasions, records < 7 days and >= 7 days after the first dose (Results). The
OCCcovariate must be supplied as 1 or 2 accordingly; a simulation confined to one occasion may useOCC = 1throughout.Health-status effect. Eq. 5 gives patients and healthy volunteers separate typical log CL/F values (5.13 and 4.77). The model encodes the Table 2 typical values, 170 and 118 L/h, as the patient CL/F plus a log-ratio shift for
DIS_HEALTHY = 1. The rounded values give a 44.1% higher AUC in healthy volunteers against 43.7% printed in Figure 4. The Results text says the health-status effect acts on “CL/F and Vc/F”, but Eq. 5, Eq. 6 and the run log (Table S2) place it on CL/F only, and that is what is encoded.Residual error parameterisation. The Table 2 residual-error thetas are read as standard deviations (additive in ng/mL, proportional as a fraction), combined as
sqrt(add^2 + (prop * Cc)^2)for records 5 h or more after the previous dose. The paper’s Eq. 2 is the usual combined additive-plus-proportional form.Elimination half-life. The Results report a “geometric mean elimination t1/2 of 3.44 hours (CV 40.0%)” without saying how it was derived. For the typical parameters the model’s terminal half-life is 10.5 h and its distribution half-life is 0.39 h. Log-linear fits to the single-dose typical profile give 1.9 h over 4-12 h and 4.9 h over 6-24 h. The 3.44 h figure is therefore not used as a validation target.
Bioavailability. Only oral data were analysed, so all parameters are apparent (CL/F, Vc/F, Q/F, Vp/F). No
f(depot)term is encoded, and the food, CYP3A-interaction and acid-reducing-agent studies enter at face value (acid-reducing agents were screened and not retained).Virtual cohort. Patients only, with baseline ALT drawn log-normally to match the Table S1a patient median (18 IU/L) and mean (22.1 IU/L). Other demographics are not needed because no other covariate is in the final model.