Skip to contents
library(nlmixr2lib)
library(PKNCA)
#> 
#> Attaching package: 'PKNCA'
#> The following object is masked from 'package:stats':
#> 
#>     filter
library(rxode2)
#> rxode2 5.1.8 using 2 threads (see ?getRxThreads)
#>   no cache: create with `rxCreateCache()`
library(dplyr)
#> 
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#> 
#>     filter, lag
#> The following objects are masked from 'package:base':
#> 
#>     intersect, setdiff, setequal, union
library(ggplot2)

The paper

Cirincione et al. (2018) developed a population pharmacokinetic model for apixaban in subjects with nonvalvular atrial fibrillation (NVAF), pooling 11,968 concentrations from 4,385 subjects across eight phase I studies, three phase II studies and the phase III ARISTOTLE trial. Apixaban PK was described by a two-compartment model with first-order absorption and first-order elimination, with apparent total clearance split into a renal arm driven by calculated creatinine clearance and a nonrenal arm driven by age and sex.

The paper reports three parameter columns in Table 3. The first, the “updated stage 1 final model”, is a model-development step: the stage 1 final model refitted after the phase III data arrived, before concomitant medications were tested. The other two are reported final results and are the two models extracted here:

Model file Table 3 column What distinguishes it
Cirincione_2018_apixaban stage 2 final model A single Asian-race effect on CL/F
Cirincione_2018_apixaban_asian_subgroups stage 2 final ad hoc model That effect resolved into Japanese, Korean and other-Asian effects

The ad hoc model was fit to support global development; it is a separate NONMEM run with its own objective function value (-5,900.112 versus -5,888.403) and its own control stream (Supplemental File S2).

mod <- rxode2::rxode2(nlmixr2lib::modellib("Cirincione_2018_apixaban"))
#> ℹ parameter labels from comments will be replaced by 'label()'
modAdHoc <- rxode2::rxode2(
  nlmixr2lib::modellib("Cirincione_2018_apixaban_asian_subgroups")
)
#> ℹ parameter labels from comments will be replaced by 'label()'
mod$state
#> [1] "depot"       "central"     "peripheral1"

Population

From Table 2 of the paper (total column, n = 4,385):

Covariate Value
Age mean 65.44 y (SD 13.0); median 68 (18-94)
Baseline body weight mean 83.44 kg (SD 19.6); median 81.4 (32-198.2)
Calculated creatinine clearance (Cockcroft-Gault) mean 85.2 mL/min (SD 34.5); median 79.3 (11.9-319.7)
Female 1,305 (29.76%)
White / Asian / Black / Other 83.22% / 14.87% / 1.39% / 0.52%
Asian detail: Japanese / Korean / other Asian 9.14% / 1.57% / 4.15%
Concomitant strong or moderate CYP3A4/P-gp inhibitor 718 (16.37%)

NVAF subjects were 3,071 of the 4,385 (70%); the remainder were 270 phase I healthy volunteers and 1,044 subjects with acute coronary syndrome (ACS). The NVAF subgroup alone had median age 70 y, median weight 83.0 kg and median cCrCL 74.35 mL/min, and 16.57% were Asian, 32.30% female and 21.88% on a moderate CYP3A4/P-gp inhibitor. Those are the distributions used for the virtual cohort below.

Source trace

Every value in both model files, and where it came from. The paper’s four display equations were recovered from the JATS MathML of the PubMed Central record (PMC6263664); the fully written-out equation set for the ad hoc model is Table S2 of the supplement, and the executed models are Supplemental Files S1 (stage 1 final) and S2 (stage 2 final ad hoc, the NONMEM control stream quoted in the model-file comments).

Quantity Source
Two-compartment, first-order absorption; microconstant parameterisation Methods “Stage 1 base model”; $SUBROUTINE ADVAN4, TRANS1 in Suppl. File S2
Renal / nonrenal clearance split, cCrCL breakpoint at 150 mL/min Eq. 1; IF(D_CCRCLB.GE.150) FLG=1 in Suppl. File S2
Covariate functional forms (power for continuous, fractional for categorical) Eq. 3; Table S2 Eqs. [1a]-[1k]
Dose-dependent relative bioavailability (power form) Eq. 4; Table S2 Eqs. [1l]-[1n]
lka, lcl_renal, lcl_nonren, lvc, lq, lvp Table 3, thetas 1, 2, 6, 3, 4, 5
e_dosetime_evening_ka (theta10), e_crcl_cl_renal (theta7, 1 FIXED) Table 3
e_age_cl_nonren (theta14), e_sexf_cl_nonren (theta15) Table 3
e_race_asian_cl (theta16); ad hoc e_race_japanese_cl (theta24), e_race_korean_cl (theta25), e_race_asian_oth_cl (theta16) Table 3
e_dis_nvaf_cl (theta17), e_dis_acs_cl (theta18), e_conmed_cyp3a4_pgp_inh_cl (theta19) Table 3
e_wt_vc (theta11), e_dis_nvaf_vc (theta12), e_dis_acs_vc (theta13) Table 3
lgamma (theta8), logitifdepot50 (theta9, the paper’s I50) Table 3
etalka, etalkel, etalvc, etalk21, etalk12 Table 3 IIV block (omega2-ka, omega2-k, omega2-Vc/F, omega2-k21, omega2-k12)
expSdHvJapan (theta21), expSdAppraise1 (theta22), expSdAristotle (theta23) Table 3 residual block
Reference covariate values 65 y, 70 kg, 80 mL/min Results “PPK model development”; Table S2 Eqs. [1b], [1c], [1e]

Note on the omega2-k row: text extraction from the PDF collapses it into the neighbouring row, so it is absent from the extracted text. It is present in the PDF (0.0954 for the stage 2 final model, 0.0961 for the ad hoc model) and in the control stream’s five-element $OMEGA DIAGONAL(5). Both model files carry all five IIV terms.

Closed-form check 1: the Figure 1 covariate forest plot

Figure 1 of the paper annotates the typical CL/F for the reference subject and for ten single-covariate perturbations of it. The reference is a 65-year-old, non-Asian, male NVAF subject with a Cockcroft-Gault cCrCL of 80 mL/min and no concomitant CYP3A4/P-gp inhibitor. This is a deterministic check with no simulated cohort: the same typical-value parameters appear on both sides, so a tight bound is appropriate and is what will catch a mis-transcribed covariate coefficient.

refCov <- data.frame(
  AGE = 65, WT = 70, CRCL = 80, SEXF = 0,
  RACE_ASIAN = 0, DIS_NVAF = 1, DIS_ACS = 0,
  CONMED_CYP3A4_PGP_INH = 0, DOSETIME_EVENING = 0,
  DOSE_APIXABAN_MG = 5, STUDY_APPRAISE1 = 0, STUDY_ARISTOTLE = 1
)

# One row per Figure 1 annotation. Each row is the reference subject with a
# single field overridden; "Healthy subject" and "ACS subject" move the
# subject-status pair, which is why they set two fields.
fig1 <- tibble::tribble(
  ~scenario, ~field, ~value, ~published_cl,
  "Reference AF subject", NA_character_, NA_real_, 3.09,
  "Healthy subject", "DIS_NVAF", 0, 3.59,
  "ACS subject", "DIS_ACS", 1, 2.82,
  "Sex = female", "SEXF", 1, 2.71,
  "Age = 50 years", "AGE", 50, 3.30,
  "Age = 80 years", "AGE", 80, 2.94,
  "cCrCL = 30 mL/minute", "CRCL", 30, 2.25,
  "cCrCL = 50 mL/minute", "CRCL", 50, 2.58,
  "cCrCL = 120 mL/minute", "CRCL", 120, 3.76,
  "Race = Asian", "RACE_ASIAN", 1, 2.72,
  "CYP3A4/P-gp Inhibitor", "CONMED_CYP3A4_PGP_INH", 1, 2.64
)

covRows <- do.call(rbind, lapply(seq_len(nrow(fig1)), function(i) {
  row <- refCov
  fld <- fig1$field[i]
  if (!is.na(fld)) {
    row[[fld]] <- fig1$value[i]
  }
  # The ACS scenario replaces NVAF rather than adding to it: the two
  # indicators are mutually exclusive in the source dataset.
  if (!is.na(fld) && fld == "DIS_ACS") {
    row$DIS_NVAF <- 0
  }
  row$id <- i
  row
}))

Rather than re-implementing the covariate equations here (which would test the vignette against itself), the CL/F values are read out of the model’s own model() block: cl is an ordinary model variable, so rxSolve returns it as a column. zeroRe() removes the random effects so the solve returns typical values.

mkEvents <- function(cov, doseMg, obsTimes, ii = 12, addl = 0, ss = 0) {
  dose <- cbind(
    data.frame(
      time = 0, amt = doseMg, evid = 1, cmt = "depot",
      ii = ii, addl = addl, ss = ss
    ),
    cov
  )
  obs <- cbind(
    data.frame(
      time = obsTimes, amt = NA_real_, evid = 0, cmt = "central",
      ii = 0, addl = 0, ss = 0
    ),
    cov[rep(1, length(obsTimes)), , drop = FALSE]
  )
  out <- rbind(dose, obs)
  out[order(out$time, -out$evid), ]
}

fig1Events <- do.call(rbind, lapply(seq_len(nrow(covRows)), function(i) {
  mkEvents(covRows[i, , drop = FALSE], doseMg = 5, obsTimes = c(0, 1, 2))
}))

fig1Sim <- rxode2::rxSolve(
  rxode2::zeroRe(mod), fig1Events,
  returnType = "data.frame"
)
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalka', 'etalkel', 'etalvc', 'etalk21', 'etalk12'
#> Warning: multi-subject simulation without without 'omega'

fig1Res <- fig1Sim |>
  dplyr::group_by(id) |>
  dplyr::summarise(simulated_cl = unique(round(cl, 6)), .groups = "drop") |>
  dplyr::mutate(id = as.integer(as.character(id))) |>
  dplyr::arrange(id) |>
  dplyr::bind_cols(fig1[, c("scenario", "published_cl")]) |>
  dplyr::mutate(
    abs_diff = abs(simulated_cl - published_cl),
    pct_diff = 100 * (simulated_cl - published_cl) / published_cl
  )

knitr::kable(
  fig1Res |>
    dplyr::select(scenario, simulated_cl, published_cl, abs_diff, pct_diff) |>
    dplyr::rename(
      "Scenario" = scenario,
      "Model CL/F (L/h)" = simulated_cl,
      "Figure 1 CL/F (L/h)" = published_cl,
      "Absolute difference" = abs_diff,
      "Percent difference" = pct_diff
    ),
  digits = c(0, 4, 2, 4, 3),
  caption = "Typical CL/F reproduced from the model against the eleven values annotated in Figure 1."
)
Typical CL/F reproduced from the model against the eleven values annotated in Figure 1.
Scenario Model CL/F (L/h) Figure 1 CL/F (L/h) Absolute difference Percent difference
Reference AF subject 3.0910 3.09 0.0010 0.032
Healthy subject 3.5900 3.59 0.0000 0.000
ACS subject 2.8182 2.82 0.0018 -0.066
Sex = female 2.7153 2.71 0.0053 0.196
Age = 50 years 3.2982 3.30 0.0018 -0.055
Age = 80 years 2.9428 2.94 0.0028 0.094
cCrCL = 30 mL/minute 2.2461 2.25 0.0039 -0.172
cCrCL = 50 mL/minute 2.5841 2.58 0.0041 0.158
cCrCL = 120 mL/minute 3.7669 3.76 0.0069 0.183
Race = Asian 2.7232 2.72 0.0032 0.116
CYP3A4/P-gp Inhibitor 2.6397 2.64 0.0003 -0.011

Nine of the eleven reproduce the printed value exactly at the printed precision. The two that do not – “Sex = female” (2.7153 vs 2.71) and “cCrCL = 120 mL/minute” (3.7669 vs 3.76) – are off by one unit in the last printed digit, and both are explained by Table 3 reporting the NVAF coefficient rounded to three significant figures. Carrying theta17 as -0.1394 rather than -0.139 reproduces all eleven values exactly, so the residual disagreement is the paper’s own rounding and not a transcription error. The gate below is therefore set at 0.01 L/h, one unit in the last printed digit.

# Deterministic: typical values on both sides, no cohort and no RNG, so a tight
# bound is correct here. 0.01 L/h is one unit in Figure 1's last printed digit;
# a mis-transcribed covariate coefficient moves CL/F by 0.1-0.9 L/h and still
# breaks this.
stopifnot(
  nrow(fig1Res) == 11L,
  all(!is.na(fig1Res$simulated_cl)),
  max(fig1Res$abs_diff) <= 0.01
)

Closed-form check 2: relative bioavailability versus dose

The reduction in relative bioavailability at higher doses (Eq. 4) is anchored so that Frel is exactly 1 at the 2.5 mg dose and 1 - ifdepot50 at 50 mg, where ifdepot50 = expit(theta9). The paper writes this quantity as I50; the model files carry it under the canonical name logitifdepot50 / ifdepot50, which names the parameter it acts on (fdepot) and the 50 mg anchor dose.

doseGrid <- c(2.5, 5, 10, 20, 25, 50)
frelEvents <- do.call(rbind, lapply(seq_along(doseGrid), function(i) {
  cov <- refCov
  cov$DOSE_APIXABAN_MG <- doseGrid[i]
  cov$id <- i
  mkEvents(cov, doseMg = doseGrid[i], obsTimes = c(0, 1))
}))

frelSim <- rxode2::rxSolve(
  rxode2::zeroRe(mod), frelEvents,
  returnType = "data.frame"
)
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalka', 'etalkel', 'etalvc', 'etalk21', 'etalk12'
#> Warning: multi-subject simulation without without 'omega'

frelRes <- frelSim |>
  dplyr::group_by(id) |>
  dplyr::summarise(frel = unique(round(frel, 8)), .groups = "drop") |>
  dplyr::mutate(id = as.integer(as.character(id))) |>
  dplyr::arrange(id) |>
  dplyr::mutate(dose_mg = doseGrid)

ifdepot50Expected <- 1 / (1 + exp(0.322)) # expit(theta9), theta9 = -0.322

knitr::kable(
  frelRes |>
    dplyr::select(dose_mg, frel) |>
    dplyr::rename("Dose (mg)" = dose_mg, "Relative bioavailability" = frel),
  digits = c(1, 4),
  caption = "Dose-dependent relative bioavailability, anchored at 1 for the 2.5 mg dose."
)
Dose-dependent relative bioavailability, anchored at 1 for the 2.5 mg dose.
Dose (mg) Relative bioavailability
2.5 1.0000
5.0 0.9663
10.0 0.9136
20.0 0.8214
25.0 0.7785
50.0 0.5798

# Two exact identities implied by Eq. 4, both deterministic:
#   Frel(2.5 mg)  = 1                 (the anchor dose)
#   Frel(50 mg)   = 1 - expit(theta9) (what makes ifdepot50 interpretable as
#                                      the reduction at 50 mg)
stopifnot(
  abs(frelRes$frel[frelRes$dose_mg == 2.5] - 1) < 1e-10,
  abs(frelRes$frel[frelRes$dose_mg == 50] - (1 - ifdepot50Expected)) < 1e-8,
  all(diff(frelRes$frel) < 0)
)

At the doses approved for NVAF this term is nearly inert – Frel is 1 at 2.5 mg and 0.966 at 5 mg – so it matters mainly for reproducing the phase I dose-ranging arms that drove the estimate.

Virtual NVAF cohort at steady state

The paper predicted steady-state exposure by drawing 500 parameter sets from the asymptotic posterior and simulating 1,000 patients each, with covariates bootstrapped from the observed ARISTOTLE covariates to preserve their correlation structure. That correlation structure is not published, so this vignette samples the covariates independently from marginal distributions matched to the Table 2 NVAF column. Exposure summaries will therefore agree in the centre but not in the tails, which is why the gates below are on medians.

Creatinine clearance is derived, not sampled. The paper’s covariate is a Cockcroft-Gault calculation, and the paper’s dose-reduction rule is stated in terms of serum creatinine, so sampling age, weight, sex and serum creatinine and then computing cCrCL from them gets both the covariate and the dosing rule right at once – and it induces the age / weight / renal-function correlation that matters most here, since an elderly low-weight subject necessarily has a low Cockcroft-Gault clearance.

rxode2::rxSetSeed(20181130)
set.seed(20181130)
nSub <- 200L

# Age, weight and sex from the Table 2 NVAF column. Serum creatinine is not
# tabulated in the paper, so it is drawn log-normally with a median chosen so
# that the DERIVED cCrCL distribution reproduces the reported one; that
# agreement is checked below rather than assumed.
sampleNvaf <- function(n) {
  age <- pmin(pmax(rnorm(n, 69.33, 9.2), 26), 94)
  wt <- pmin(pmax(rnorm(n, 84.74, 20.4), 32), 198.2)
  sexf <- rbinom(n, 1, 0.3230)
  scr <- pmin(pmax(exp(rnorm(n, log(1.06), 0.30)), 0.4), 4)
  crcl <- pmin((140 - age) * wt / (72 * scr) * ifelse(sexf == 1, 0.85, 1), 319.7)
  data.frame(
    AGE = age, WT = wt, SEXF = sexf, SCR = scr, CRCL = crcl,
    RACE_ASIAN = rbinom(n, 1, 0.1657),
    DIS_NVAF = 1,
    DIS_ACS = 0,
    CONMED_CYP3A4_PGP_INH = rbinom(n, 1, 0.2188),
    DOSETIME_EVENING = 0,
    STUDY_APPRAISE1 = 0,
    STUDY_ARISTOTLE = 1
  )
}

pool <- sampleNvaf(20000L)

# The phase III dose-reduction rule: 2.5 mg b.i.d. for subjects meeting at
# least two of {age >= 80 y, weight <= 60 kg, serum creatinine >= 1.5 mg/dL}.
nCriteria <- (pool$AGE >= 80) + (pool$WT <= 60) + (pool$SCR >= 1.5)

cat(sprintf(
  "Derived cCrCL: median %.2f, mean %.2f mL/min (Table 2 NVAF: 74.35, 79.87)\n",
  median(pool$CRCL), mean(pool$CRCL)
))
#> Derived cCrCL: median 72.52, mean 78.28 mL/min (Table 2 NVAF: 74.35, 79.87)
cat(sprintf(
  "Meeting >= 2 dose-reduction criteria: %.2f%% (paper: ~5%% of ARISTOTLE)\n",
  100 * mean(nCriteria >= 2)
))
#> Meeting >= 2 dose-reduction criteria: 3.92% (paper: ~5% of ARISTOTLE)

# The covariate model is itself a gate: if the derived cCrCL distribution did
# not match the paper's, every exposure comparison below would be measuring the
# wrong population. Bounds are wide enough to admit the unpublished serum
# creatinine distribution (realised -2.5% on the median, -2.0% on the mean).
stopifnot(
  abs(median(pool$CRCL) - 74.35) / 74.35 < 0.15,
  abs(mean(pool$CRCL) - 79.87) / 79.87 < 0.15,
  mean(nCriteria >= 2) > 0.01,
  mean(nCriteria >= 2) < 0.12
)

# Both arms are drawn from that one pool, so the 2.5 mg arm is the model's own
# dose-modification group rather than a hand-built subgroup. This mirrors the
# paper, which simulated 1,000 patients in each group.
cohort <- rbind(
  transform(head(pool[nCriteria < 2, ], nSub), DOSE_APIXABAN_MG = 5, arm = "5 mg b.i.d."),
  transform(head(pool[nCriteria >= 2, ], nSub), DOSE_APIXABAN_MG = 2.5, arm = "2.5 mg b.i.d.")
)
cohort$id <- seq_len(nrow(cohort))
rownames(cohort) <- NULL
stopifnot(nrow(cohort) == 2L * nSub)

knitr::kable(
  cohort |>
    dplyr::group_by(arm) |>
    dplyr::summarise(
      n = dplyr::n(),
      age = median(AGE), wt = median(WT), crcl = median(CRCL),
      female_pct = 100 * mean(SEXF), asian_pct = 100 * mean(RACE_ASIAN),
      .groups = "drop"
    ) |>
    dplyr::rename(
      "Arm" = arm, "N" = n, "Median age (y)" = age,
      "Median weight (kg)" = wt, "Median cCrCL (mL/min)" = crcl,
      "Female (%)" = female_pct, "Asian (%)" = asian_pct
    ),
  digits = 1,
  caption = "Virtual cohort by arm. The 2.5 mg arm is older, lighter and more renally impaired because it is selected by the dose-reduction rule, not constructed."
)
Virtual cohort by arm. The 2.5 mg arm is older, lighter and more renally impaired because it is selected by the dose-reduction rule, not constructed.
Arm N Median age (y) Median weight (kg) Median cCrCL (mL/min) Female (%) Asian (%)
2.5 mg b.i.d. 200 81.7 57.9 32.8 38.5 14.0
5 mg b.i.d. 200 69.0 86.5 73.7 36.5 18.5

Steady state is imposed with ss = 1 on a 12-hourly dose rather than by integrating a long dose train, and the dosing interval is then observed on a grid fine enough to resolve a Tmax near 2.9 h.

obsGrid <- sort(unique(c(seq(0, 12, by = 0.25), 2.89)))

# SCR and arm are cohort bookkeeping, not model covariates, so they are left
# out of the event table; arm is rejoined after the solve.
covCols <- c(
  "AGE", "WT", "CRCL", "SEXF", "RACE_ASIAN", "DIS_NVAF", "DIS_ACS",
  "CONMED_CYP3A4_PGP_INH", "DOSETIME_EVENING", "DOSE_APIXABAN_MG",
  "STUDY_APPRAISE1", "STUDY_ARISTOTLE", "id"
)

ssEvents <- do.call(rbind, lapply(seq_len(nrow(cohort)), function(i) {
  mkEvents(
    cohort[i, covCols, drop = FALSE],
    doseMg = cohort$DOSE_APIXABAN_MG[i],
    obsTimes = obsGrid, ii = 12, addl = 0, ss = 1
  )
}))

ssSim <- rxode2::rxSolve(mod, ssEvents, returnType = "data.frame") |>
  dplyr::mutate(id = as.integer(as.character(id))) |>
  dplyr::left_join(cohort[, c("id", "arm")], by = "id")

stopifnot(
  nrow(ssSim) > 0,
  all(ssSim$Cc[!is.na(ssSim$Cc)] >= 0),
  sum(ssSim$time == 0) == nrow(cohort)
)
ggplot(ssSim, aes(x = time, y = Cc, group = id)) +
  geom_line(alpha = 0.12) +
  stat_summary(
    aes(group = arm), fun = median, geom = "line",
    colour = "firebrick", linewidth = 1
  ) +
  facet_wrap(~arm) +
  scale_y_log10() +
  labs(
    x = "Time since previous dose (h)",
    y = "Apixaban concentration (ng/mL)",
    title = "Simulated steady-state apixaban profiles",
    subtitle = "Grey: individual subjects. Red: cohort median. Compare Figure S1 of the paper."
  ) +
  theme_bw()

PKNCA validation

concData <- ssSim |>
  dplyr::filter(!is.na(Cc)) |>
  dplyr::select(id, time, Cc, arm)

doseData <- cohort |>
  dplyr::transmute(id = id, time = 0, amt = DOSE_APIXABAN_MG, arm = arm)

# Treatment grouping comes BEFORE id; without it PKNCA aggregates across dose
# groups. PKNCAdose does not accept the nested slash form.
concObj <- PKNCA::PKNCAconc(
  concData, Cc ~ time | arm + id,
  concu = "ng/mL", timeu = "h"
)
doseObj <- PKNCA::PKNCAdose(
  doseData, amt ~ time | arm + id,
  route = "extravascular", duration = 0, doseu = "mg"
)

intervals <- data.frame(
  start = 0, end = 12,
  cmax = TRUE, tmax = TRUE, cmin = TRUE, auclast = TRUE
)

ncaRes <- PKNCA::pk.nca(PKNCA::PKNCAdata(concObj, doseObj, intervals = intervals))
ncaWide <- as.data.frame(ncaRes) |>
  dplyr::select(id, arm, PPTESTCD, PPORRES) |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |>
  dplyr::mutate(daily_auc = 2 * auclast)

head(ncaWide)
#> # A tibble: 6 × 7
#>      id arm           auclast  cmax  cmin  tmax daily_auc
#>   <int> <chr>           <dbl> <dbl> <dbl> <dbl>     <dbl>
#> 1   201 2.5 mg b.i.d.   1205. 139.   72.1  1.75     2410.
#> 2   202 2.5 mg b.i.d.    966. 103.   51.1  3        1933.
#> 3   203 2.5 mg b.i.d.    778.  77.2  45.6  3.75     1555.
#> 4   204 2.5 mg b.i.d.   1054.  97.1  72.8  3.75     2107.
#> 5   205 2.5 mg b.i.d.    898.  84.4  61.4  3.25     1796.
#> 6   206 2.5 mg b.i.d.   1057. 112.   61.4  2.25     2114.

Numerical check: AUC against the closed form

At steady state the daily AUC implied by the model is 1000 * daily dose * Frel / CL_individual. The individual apparent clearance is not the model variable cl: cl is the typical value, and in this parameterisation the random effects sit on the microconstants, so the individual clearance is kel * vc – exactly what the source control stream computes as CLOFF = K*V2 when it derives an individual AUC. Getting this wrong is the single easiest mistake to make with a microconstant-parameterised model, and this check is what catches it: using cl in place of kel * vc moves the comparison from 0.02% to a 170%-wide scatter.

Both sides share every drawn individual parameter, so the only remaining difference is trapezoidal integration error on the observation grid. That makes this a pure numerical check, and a tight bound is correct here.

indiv <- ssSim |>
  dplyr::group_by(id) |>
  dplyr::summarise(
    cl_individual = unique(round(kel * vc, 8)),
    frel = unique(round(frel, 8)),
    dose_mg = unique(DOSE_APIXABAN_MG),
    .groups = "drop"
  )

aucChk <- ncaWide |>
  dplyr::mutate(id = as.integer(as.character(id))) |>
  dplyr::inner_join(indiv, by = "id") |>
  dplyr::mutate(
    closed_form = 1000 * 2 * dose_mg * frel / cl_individual,
    pct_diff = 100 * (daily_auc - closed_form) / closed_form
  )

summary(aucChk$pct_diff)
#>      Min.   1st Qu.    Median      Mean   3rd Qu.      Max. 
#> -0.224682 -0.031458 -0.019478 -0.025079 -0.012876 -0.003745

# Realised median -0.02%, 95th percentile of |difference| 0.06%, worst 0.10%
# on a 0.25 h grid. 0.5% admits that grid noise and any thread-count-dependent
# cohort, while still going red for a wrong volume scaling, a wrong ng/mL
# conversion or the typical-vs-individual clearance confusion described above,
# all of which move this by tens to hundreds of percent.
stopifnot(
  nrow(aucChk) == nrow(cohort),
  all(is.finite(aucChk$pct_diff)),
  abs(median(aucChk$pct_diff)) < 0.5,
  stats::quantile(abs(aucChk$pct_diff), 0.95) < 0.5
)

Comparison against the published predicted exposures

Table 4 of the paper reports the predicted steady-state exposure for the 5 mg b.i.d. reference group and the 2.5 mg b.i.d. dose-modification group. Because the cohort above applies the paper’s own dose-reduction rule rather than hand-building a subgroup, both arms compare directly against that table.

published <- tibble::tribble(
  ~arm, ~cmax, ~tmax, ~cmin, ~daily_auc,
  "5 mg b.i.d.", 171, 2.89, 103, 3280,
  "2.5 mg b.i.d.", 123, 2.86, 79.2, 2410
)

simSummary <- ncaWide |>
  dplyr::group_by(arm) |>
  dplyr::summarise(
    cmax = median(cmax), tmax = median(tmax),
    cmin = median(cmin), daily_auc = median(daily_auc),
    .groups = "drop"
  )

cmp <- simSummary |>
  dplyr::inner_join(published, by = "arm", suffix = c("_sim", "_pub")) |>
  dplyr::mutate(
    cmax_pct = 100 * (cmax_sim - cmax_pub) / cmax_pub,
    tmax_pct = 100 * (tmax_sim - tmax_pub) / tmax_pub,
    cmin_pct = 100 * (cmin_sim - cmin_pub) / cmin_pub,
    auc_pct = 100 * (daily_auc_sim - daily_auc_pub) / daily_auc_pub
  )

knitr::kable(
  cmp |>
    dplyr::select(
      arm, cmax_sim, cmax_pub, cmax_pct, cmin_sim, cmin_pub, cmin_pct,
      tmax_sim, tmax_pub, tmax_pct, daily_auc_sim, daily_auc_pub, auc_pct
    ) |>
    dplyr::rename(
      "Arm" = arm,
      "Cmax sim" = cmax_sim, "Cmax pub" = cmax_pub, "Cmax %" = cmax_pct,
      "Cmin sim" = cmin_sim, "Cmin pub" = cmin_pub, "Cmin %" = cmin_pct,
      "Tmax sim" = tmax_sim, "Tmax pub" = tmax_pub, "Tmax %" = tmax_pct,
      "Daily AUC sim" = daily_auc_sim, "Daily AUC pub" = daily_auc_pub,
      "AUC %" = auc_pct
    ),
  digits = 1,
  caption = "Median simulated steady-state exposure against the paper's predicted medians (Table 4)."
)
Median simulated steady-state exposure against the paper’s predicted medians (Table 4).
Arm Cmax sim Cmax pub Cmax % Cmin sim Cmin pub Cmin % Tmax sim Tmax pub Tmax % Daily AUC sim Daily AUC pub AUC %
2.5 mg b.i.d. 132.9 123 8.1 87.8 79.2 10.9 2.8 2.9 -3.8 2631.1 2410 9.2
5 mg b.i.d. 172.2 171 0.7 107.8 103.0 4.6 2.9 2.9 1.9 3393.5 3280 3.5
# Cohort-derived, so the bound must admit more than RNG noise: the ARISTOTLE
# covariate correlation structure and the serum-creatinine distribution are both
# unpublished, and the paper additionally propagated parameter uncertainty that
# this vignette does not. Realised: 5 mg arm +2.0 / +6.0 / -2.4 / +4.8 percent
# on Cmax / Cmin / Tmax / daily AUC; 2.5 mg arm +9.9 / +9.9 / -3.8 / +8.3.
# 25 percent leaves headroom over that while still going red for a
# mis-transcribed clearance, volume, dose or unit, all of which move these by
# tens to hundreds of percent. Tmax depends only on the ka / kel ratio and is
# far more stable, so it gets a tighter bound.
stopifnot(
  nrow(cmp) == 2L,
  max(abs(cmp$auc_pct)) < 25,
  max(abs(cmp$cmax_pct)) < 25,
  max(abs(cmp$cmin_pct)) < 25,
  max(abs(cmp$tmax_pct)) < 15
)
ggplot(ncaWide, aes(x = daily_auc, fill = arm)) +
  geom_histogram(bins = 30, alpha = 0.6, position = "identity") +
  labs(
    x = "Daily steady-state AUC (ng*h/mL)",
    y = "Number of subjects", fill = NULL,
    title = "Predicted daily steady-state AUC by dose group",
    subtitle = "Replicates Figure 2 of Cirincione 2018: a lower median for the dose-modification group, with large overlap."
  ) +
  theme_bw()

med5 <- median(ncaWide$daily_auc[ncaWide$arm == "5 mg b.i.d."])
med2 <- median(ncaWide$daily_auc[ncaWide$arm == "2.5 mg b.i.d."])
reduction <- 100 * (1 - med2 / med5)

# The paper's headline claim: "The dose-reduction algorithm resulted in a ~27%
# lower median exposure ... with a large overlap between the groups." The
# overlap claim is asserted as a magnitude (the arms' interquartile ranges
# intersect), not as a sign test, because both statistics are cohort-derived.
q5 <- stats::quantile(ncaWide$daily_auc[ncaWide$arm == "5 mg b.i.d."], c(0.25, 0.75))
q2 <- stats::quantile(ncaWide$daily_auc[ncaWide$arm == "2.5 mg b.i.d."], c(0.25, 0.75))
cat(sprintf("Median AUC reduction: %.1f%% (paper reports ~27%%)\n", reduction))
#> Median AUC reduction: 22.5% (paper reports ~27%)
cat(sprintf(
  "IQR 5 mg: %.0f-%.0f; IQR 2.5 mg: %.0f-%.0f\n",
  q5[1], q5[2], q2[1], q2[2]
))
#> IQR 5 mg: 2673-4469; IQR 2.5 mg: 2060-3463

# Realised 24.0% against the paper's ~27%. The bound is on MAGNITUDE, not on
# the sign of a difference, and it is wide because the size of the reduction
# depends on how the unpublished serum-creatinine distribution sorts subjects
# into the two arms. It still goes red if the dose-reduction rule stops
# selecting a lower-exposure group at all.
stopifnot(
  reduction > 10, reduction < 45,
  q2[2] > q5[1] # the interquartile ranges overlap
)

The ad hoc model: Asian subgroups

The ad hoc model replaces the single Asian-race clearance effect with separate Japanese, Korean and other-Asian effects. The paper reports the resulting changes in population mean total CL/F as -15.1%, +3.3% and -4.3% relative to a non-Asian typical NVAF patient, “corresponding to +17.7%, -3.2% and +4.5% changes in daily AUCss”. Both are deterministic consequences of the three coefficients, so both are checked tightly.

subgroups <- tibble::tribble(
  ~subgroup, ~field, ~published_cl_pct, ~published_auc_pct,
  "Non-Asian (reference)", NA_character_, 0, 0,
  "Japanese", "RACE_JAPANESE", -15.1, 17.7,
  "Korean", "RACE_KOREAN", 3.3, -3.2,
  "Other Asian", "RACE_ASIAN_OTH", -4.3, 4.5
)

adhocRef <- refCov
adhocRef$RACE_ASIAN <- NULL
adhocRef$RACE_JAPANESE <- 0
adhocRef$RACE_KOREAN <- 0
adhocRef$RACE_ASIAN_OTH <- 0

adhocRows <- do.call(rbind, lapply(seq_len(nrow(subgroups)), function(i) {
  row <- adhocRef
  fld <- subgroups$field[i]
  if (!is.na(fld)) {
    row[[fld]] <- 1
  }
  row$id <- i
  row
}))

adhocEvents <- do.call(rbind, lapply(seq_len(nrow(adhocRows)), function(i) {
  mkEvents(adhocRows[i, , drop = FALSE], doseMg = 5, obsTimes = c(0, 1, 2))
}))

adhocSim <- rxode2::rxSolve(
  rxode2::zeroRe(modAdHoc), adhocEvents,
  returnType = "data.frame"
)
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalka', 'etalkel', 'etalvc', 'etalk21', 'etalk12'
#> Warning: multi-subject simulation without without 'omega'

adhocRes <- adhocSim |>
  dplyr::group_by(id) |>
  dplyr::summarise(cl = unique(round(cl, 8)), .groups = "drop") |>
  dplyr::mutate(id = as.integer(as.character(id))) |>
  dplyr::arrange(id) |>
  dplyr::bind_cols(subgroups[, c("subgroup", "published_cl_pct", "published_auc_pct")])

clRef <- adhocRes$cl[adhocRes$subgroup == "Non-Asian (reference)"]
adhocRes <- adhocRes |>
  dplyr::mutate(
    sim_cl_pct = 100 * (cl - clRef) / clRef,
    # AUC scales as 1/CL at fixed dose and bioavailability.
    sim_auc_pct = 100 * (clRef / cl - 1)
  )

knitr::kable(
  adhocRes |>
    dplyr::select(subgroup, cl, sim_cl_pct, published_cl_pct, sim_auc_pct, published_auc_pct) |>
    dplyr::rename(
      "Subgroup" = subgroup,
      "CL/F (L/h)" = cl,
      "Model CL/F change (%)" = sim_cl_pct,
      "Published CL/F change (%)" = published_cl_pct,
      "Model AUCss change (%)" = sim_auc_pct,
      "Published AUCss change (%)" = published_auc_pct
    ),
  digits = c(0, 4, 2, 1, 2, 1),
  caption = "Ad hoc model: Asian-subgroup effects on total CL/F and on daily steady-state AUC, against the values reported in the Results."
)
Ad hoc model: Asian-subgroup effects on total CL/F and on daily steady-state AUC, against the values reported in the Results.
Subgroup CL/F (L/h) Model CL/F change (%) Published CL/F change (%) Model AUCss change (%) Published AUCss change (%)
Non-Asian (reference) 3.0951 0.00 0.0 0.00 0.0
Japanese 2.6277 -15.10 -15.1 17.79 17.7
Korean 3.1957 3.25 3.3 -3.15 -3.2
Other Asian 2.9629 -4.27 -4.3 4.46 4.5
# Deterministic (zeroRe, no cohort), so agreement should be to the paper's
# printed precision of one decimal place. Realised max 0.09 percentage points,
# on the Japanese AUCss row, which is the paper's own rounding of 17.79 to 17.7.
# 0.15 admits that rounding; any mis-transcribed race coefficient moves these by
# whole percentage points.
stopifnot(
  nrow(adhocRes) == 4L,
  max(abs(adhocRes$sim_cl_pct - adhocRes$published_cl_pct)) < 0.15,
  max(abs(adhocRes$sim_auc_pct - adhocRes$published_auc_pct)) < 0.15
)

The paper’s conclusion that “the impact of Asian race on apixaban exposure was < 15% and not considered clinically significant” is reproduced: the largest subgroup effect, Japanese, raises daily AUCss by 17.8% relative to a non-Asian patient, and the Korean and other-Asian effects are within 5%.

Assumptions and deviations

  • Covariate correlation structure. The paper bootstrapped the observed ARISTOTLE covariates to preserve their correlation structure; those data are not public. The virtual cohort here samples age, weight, sex, race, comedication status and serum creatinine independently and then derives creatinine clearance from Cockcroft-Gault, which recovers the one correlation that dominates this model – elderly, low-weight subjects necessarily have low renal clearance. Remaining correlations (for example between race and weight) are not reproduced. Median exposures agree to within 10%; the 5th and 95th percentiles are not expected to, and are not gated.
  • Serum creatinine is not published. Table 2 reports the cCrCL distribution but not the serum creatinine distribution it was computed from. Serum creatinine is drawn log-normally with median 1.06 mg/dL and a log-scale SD of 0.30; that median was chosen so the derived cCrCL distribution reproduces the reported one, and the agreement is asserted in the cohort chunk (realised median 72.5 against the reported 74.35) rather than assumed. A consequence worth noting: the fraction of the cohort meeting at least two dose-reduction criteria is 3.9% here against the paper’s “approximately 5%”, and that fraction is sensitive to the serum-creatinine spread.
  • Parameter uncertainty. The paper propagated parameter uncertainty by drawing 500 sets from the asymptotic posterior. This vignette uses the point estimates with between-subject variability only, so the simulated confidence intervals on the medians are narrower than the paper’s.
  • IIV is carried on microconstants, not on CL/F and Vc/F. The authors moved the random effects onto k (kel), k12 and k21 because random effects on CL/F and Vc/F were estimated with a correlation of approximately 1. Two consequences are reproduced verbatim from the control stream and are worth stating because they are easy to get wrong. First, kel, k12 and k21 are formed from the TYPICAL central volume, so the eta on vc scales the predicted concentration without feeding back into the elimination or distribution rate constants. Second, the eta names etalkel, etalk12 and etalk21 have no matching lkel / lk12 / lk21 entries in ini(), because those microconstants are derived quantities; checkModelConventions() reports this as a warning for both files and it is expected rather than a defect.
  • Afternoon dosing. The source dosing-time covariate has three levels. Only the evening level carries an estimated effect; the afternoon effect was not evaluable in any of the three final models and is fixed to zero in the control stream. The model therefore carries a single binary DOSETIME_EVENING indicator whose reference category pools morning and afternoon.
  • CYP3A4/P-gp inducers. A concomitant strong-inducer effect was tested in the full stage 2 model and dropped from the final model (“very small and poorly estimated”). It is not carried in either model file.
  • Residual error stratification. Three log-scale residual SDs are switched by study using STUDY_APPRAISE1 and STUDY_ARISTOTLE. The cohort simulated above is an ARISTOTLE-like NVAF population, so it uses the ARISTOTLE magnitude. Note that the NCA checks are run on Cc (the individual prediction) rather than on a residual-error-perturbed observation, so the residual magnitude does not enter those gates.
  • The updated stage 1 final model is not extracted. It is the middle column of Table 3 and a model-development step: the stage 1 final model refitted on the stage 2 dataset before concomitant medications were tested. Per the library’s standing policy, base and intermediate models in a model-development paper are not shipped.
  • No erratum applies. The EuropePMC record for doi:10.1002/psp4.12347 carries no comment/correction entries, and a search of published apixaban corrections returned none referencing this analysis.