Liu Y, Tan Y, Hu L, Li J, Yang J, Diao L, Yang J. Population pharmacokinetics and exposure-response analyses of SAF-189s in Chinese patients with ALK+/ROS1+ non-small cell lung cancer. Front Pharmacol. 2024;15:1418549. doi:10.3389/fphar.2024.1418549
SAF-189s is an investigational second-generation ALK/ROS1 tyrosine kinase inhibitor developed from the ceritinib scaffold. This paper pools a food-effect study in healthy Chinese volunteers with a first-in-human phase I/II study in Chinese patients to build one population PK model, then uses the individual exposures from that model to fit six independent exposure-response regressions. All seven models are packaged here.
| Model | Endpoint | Exposure driver |
|---|---|---|
Liu_2024_saf189s |
plasma concentration | (the PK model itself) |
Liu_2024_saf189s_orr |
overall response (IRC) |
CTROUGH (Cmin,ss), log scale |
Liu_2024_saf189s_pfs |
progression-free survival |
CTROUGH, linear |
Liu_2024_saf189s_dor |
duration of response |
CTROUGH, linear |
Liu_2024_saf189s_hyperglycemia |
hyperglycemia, any grade |
AUC_SAF189S, log scale |
Liu_2024_saf189s_hyperglycemia_grade2 |
hyperglycemia, grade >= 2 |
AUC_SAF189S, log scale |
Liu_2024_saf189s_proteinuria |
proteinuria, any grade |
AUC_SAF189S, log scale |
pk <- rxode2::rxode(readModelDb("Liu_2024_saf189s"))
orr <- rxode2::rxode(readModelDb("Liu_2024_saf189s_orr"))
pfs <- rxode2::rxode(readModelDb("Liu_2024_saf189s_pfs"))
dor <- rxode2::rxode(readModelDb("Liu_2024_saf189s_dor"))
hyper <- rxode2::rxode(readModelDb("Liu_2024_saf189s_hyperglycemia"))
hyp2 <- rxode2::rxode(readModelDb("Liu_2024_saf189s_hyperglycemia_grade2"))
prot <- rxode2::rxode(readModelDb("Liu_2024_saf189s_proteinuria"))Population
pop <- pk$population
flatten_field <- function(x) {
nms <- names(x)
if (is.null(nms)) {
paste(as.character(x), collapse = "; ")
} else {
paste(paste0(nms, ": ", as.character(x)), collapse = "; ")
}
}
tibble::tibble(
Field = names(pop),
Value = vapply(pop, flatten_field, character(1))
) |>
knitr::kable(caption = "Population metadata of the SAF-189s population PK model.")| Field | Value |
|---|---|
| species | human |
| n_subjects | 317 |
| n_studies | 2 |
| n_observations | 3,173 measurable plasma concentrations retained of 3,538 acquired (89.68%); 329 pre-dose BQL, 6 pre-dose non-BQL, 30 post-dose BQL samples and 6 subjects with no samples were excluded |
| age_range | 18.0-84.0 years (pooled; mean 51.6, median 53). Healthy volunteers median 27 (18.0-45.0); SAF001 phase I median 51 (28.0-68.0); SAF001 phase II median 54.1 (20.0-84.0) |
| age_median | 53 years |
| weight_range | 37.3-92.5 kg |
| weight_median | 63.2 kg |
| sex_female_pct | 48.2 |
| race_ethnicity | Asian: 100 |
| disease_state | ALK-positive or ROS1-positive advanced non-small cell lung cancer (190 ALK+ only, 103 ROS1+ only), plus 24 healthy volunteers; 136 of 317 (43%) had brain metastases at enrolment and 221 (69.8%) were disease stage IV |
| dose_range | SAF-189s 20, 40, 80, 120, 160 or 210 mg orally once daily in 21-day cycles (SAF001, with a single-dose 3-day PK run-in); a single 160 mg oral dose in each of two crossover periods (STL31147, fed and fasted) |
| regions | China |
| renal_function | 207 normal (65.2%), 94 mild (29.6%), 1 moderate (0.4%), 15 with eGFR >= 130 (4.8%) |
| hepatic_function | 276 normal (87%), 41 mild dysfunction (13%); no moderate or severe |
| notes | Baseline characteristics from Liu 2024 Supplementary document 1, Supplementary Table 1 (a separate publisher file, Table1.docx). Sex is reported inconsistently: the count-backed Supplementary Table 1 gives 153 of 317 female (48.2%) while the Results narrative says 56.5%; the count is used here. Food had no meaningful effect (geometric mean ratio Cmax 109.1%, AUC0-t 105.1%), so the fed and fasted STL31147 periods are pooled without a food covariate. |
The PK analysis set pools 24 healthy Chinese volunteers (study STL31147, a two-period fed/fasted crossover of a single 160 mg dose) with 293 patients from the phase I/II study SAF001 (NCT04237805, 20-210 mg once daily in 21-day cycles): 317 subjects and 3,173 measurable concentrations. Baseline characteristics are in Liu 2024 Supplementary document 1, Supplementary Table 1. Median age is 53 years (range 18.0-84.0) and median weight 63.2 kg (37.3-92.5); the cohort is entirely Chinese.
The three exposure-response analysis sets are all different from the PK set and from each other: 244 phase II patients with IRC assessment (ORR, PFS), 192 responders among those (DOR), and 296 patients with safety follow-up (the three adverse-event models).
Source trace
tibble::tribble(
~Quantity, ~Value, ~`Source location`,
"V/F", "5,210 L", "Table 2, theta1",
"KA", "0.501 1/h", "Table 2, theta2",
"CL1/F (time-independent)", "64 L/h", "Table 2, theta3",
"CL2/F (time-dependent)", "40.3 L/h", "Table 2, theta4",
"Kout", "1.35 1/day", "Table 2, theta5",
"ALAG1", "0.483 h", "Table 2, theta6",
"Age exponent on CL/F", "-0.314", "Table 2, theta7; reference 53 y from Figure 4 caption",
"ALKPOT 2,3,4 on V/F", "0.734", "Table 2, theta8",
"ALKPOT 5 on V/F", "0.917", "Table 2, theta9",
"ALKPOT 6 on V/F", "0.784", "Table 2, theta10",
"IIV V/F, KA, CL/F, ALAG1", "0.125, 0.279, 0.138, 0.120 (variances)", "Table 2, eta1-eta4; CV column confirms variance scale",
"Proportional residual", "0.0468 (variance) -> SD 0.2163", "Table 2, epsilon1",
"Additive residual", "0.0938 (variance) -> SD 0.3063 ng/mL", "Table 2, epsilon2",
"CL(t) equation", "CL = CL1 + CL2*exp(-Kout*(DAY-1))", "Equation 2; arm roles confirmed by Figure 1",
"One-compartment, lagged first-order absorption", "structure", "Figure 1; Results, PopPK analysis",
"ORR slope", "OR 0.556 per e-fold Cmin,ss", "Results, Exposure-response analysis",
"ORR intercept", "3.871 (digitised)", "Figure 6 fitted curve -- NOT printed",
"PFS hazard coefficient", "0.0071 per ng/mL", "Table 4, PFS row",
"DOR hazard coefficient", "0.0059 per ng/mL", "Table 4, DOR row",
"Hyperglycemia slope", "OR 3.521 per e-fold AUCss", "Results, Exposure-safety analysis",
"Hyperglycemia intercept", "-9.430 (digitised)", "Figure 10A fitted curve -- NOT printed",
"Hyperglycemia gr>=2 slope", "OR 7.662 per e-fold AUCss", "Results, Exposure-safety analysis",
"Hyperglycemia gr>=2 intercept", "-16.812 (digitised)", "Figure 10B fitted curve -- NOT printed",
"Proteinuria slope", "OR 2.031 per e-fold AUCss", "Results, Exposure-safety analysis",
"Proteinuria intercept", "-6.297 (digitised)", "Figure 10C fitted curve -- NOT printed"
) |>
knitr::kable(caption = "Source location for every model equation and ini() parameter.")| Quantity | Value | Source location |
|---|---|---|
| V/F | 5,210 L | Table 2, theta1 |
| KA | 0.501 1/h | Table 2, theta2 |
| CL1/F (time-independent) | 64 L/h | Table 2, theta3 |
| CL2/F (time-dependent) | 40.3 L/h | Table 2, theta4 |
| Kout | 1.35 1/day | Table 2, theta5 |
| ALAG1 | 0.483 h | Table 2, theta6 |
| Age exponent on CL/F | -0.314 | Table 2, theta7; reference 53 y from Figure 4 caption |
| ALKPOT 2,3,4 on V/F | 0.734 | Table 2, theta8 |
| ALKPOT 5 on V/F | 0.917 | Table 2, theta9 |
| ALKPOT 6 on V/F | 0.784 | Table 2, theta10 |
| IIV V/F, KA, CL/F, ALAG1 | 0.125, 0.279, 0.138, 0.120 (variances) | Table 2, eta1-eta4; CV column confirms variance scale |
| Proportional residual | 0.0468 (variance) -> SD 0.2163 | Table 2, epsilon1 |
| Additive residual | 0.0938 (variance) -> SD 0.3063 ng/mL | Table 2, epsilon2 |
| CL(t) equation | CL = CL1 + CL2exp(-Kout(DAY-1)) | Equation 2; arm roles confirmed by Figure 1 |
| One-compartment, lagged first-order absorption | structure | Figure 1; Results, PopPK analysis |
| ORR slope | OR 0.556 per e-fold Cmin,ss | Results, Exposure-response analysis |
| ORR intercept | 3.871 (digitised) | Figure 6 fitted curve – NOT printed |
| PFS hazard coefficient | 0.0071 per ng/mL | Table 4, PFS row |
| DOR hazard coefficient | 0.0059 per ng/mL | Table 4, DOR row |
| Hyperglycemia slope | OR 3.521 per e-fold AUCss | Results, Exposure-safety analysis |
| Hyperglycemia intercept | -9.430 (digitised) | Figure 10A fitted curve – NOT printed |
| Hyperglycemia gr>=2 slope | OR 7.662 per e-fold AUCss | Results, Exposure-safety analysis |
| Hyperglycemia gr>=2 intercept | -16.812 (digitised) | Figure 10B fitted curve – NOT printed |
| Proteinuria slope | OR 2.031 per e-fold AUCss | Results, Exposure-safety analysis |
| Proteinuria intercept | -6.297 (digitised) | Figure 10C fitted curve – NOT printed |
Structural checks against closed forms
The time-dependent clearance is the one piece of structure worth checking before anything stochastic. Liu 2024 Equation 2 is
with DAY the integer post-administration day, so CL/F
starts at 64 + 40.3 = 104.3 L/h on day 1 and decays stepwise to 64 L/h.
These are deterministic identities, so they are asserted tightly.
ref_cov <- data.frame(AGE = 53, PRIOR_ALKI = 0, TUM_ALK_MUT = 1, DIS_HEALTHY = 0)
pk_typ <- rxode2::zeroRe(pk)
ev_sd <- rxode2::et(amt = 160, cmt = "depot") |>
rxode2::et(seq(0, 336, by = 0.25))
sd <- rxode2::rxSolve(pk_typ, ev_sd, ref_cov, returnType = "data.frame")
#> ℹ omega/sigma items treated as zero: 'etalvc', 'etalka', 'etalcl', 'etaltlag'
# Half-life from a log-linear slope well after Tmax (pattern 11: a
# time-to-50% read includes the absorption transient and reads long).
half_life <- function(d) {
fit <- stats::lm(log(Cc) ~ time, data = d)
log(2) / (-stats::coef(fit)[["time"]])
}
t_half_ss <- half_life(subset(sd, time >= 200 & time <= 336))
cl_at <- function(tt) sd$cl[which.min(abs(sd$time - tt))]
cl_expected <- function(day_index) 64 + 40.3 * exp(-1.35 * day_index)
structural <- tibble::tibble(
Quantity = c(
"CL/F on day 1 (L/h)", "CL/F on day 2 (L/h)", "CL/F on day 3 (L/h)",
"CL/F on day 5 (L/h)", "Asymptotic half-life (h)"
),
Model = c(cl_at(0), cl_at(24), cl_at(48), cl_at(96), t_half_ss),
`Closed form` = c(
cl_expected(0), cl_expected(1), cl_expected(2), cl_expected(4),
log(2) * 5210 / 64
),
Paper = c(104.3, NA, NA, NA, 56.4)
)
knitr::kable(structural, digits = 3,
caption = "Time-dependent clearance and the steady-state half-life against their closed forms.")| Quantity | Model | Closed form | Paper |
|---|---|---|---|
| CL/F on day 1 (L/h) | 104.300 | 104.300 | 104.3 |
| CL/F on day 2 (L/h) | 74.447 | 74.447 | NA |
| CL/F on day 3 (L/h) | 66.708 | 66.708 | NA |
| CL/F on day 5 (L/h) | 64.182 | 64.182 | NA |
| Asymptotic half-life (h) | 56.426 | 56.427 | 56.4 |
# Deterministic: the solved CL must equal Equation 2 to solver precision.
stopifnot(
max(abs(structural$Model[1:4] - structural$`Closed form`[1:4])) < 1e-6,
abs(t_half_ss - log(2) * 5210 / 64) < 0.05,
# The paper prints 56.4 h; ln(2)*5210/64 = 56.43 h.
abs(t_half_ss - 56.4) < 0.5
)The day-1 half-life the paper quotes (34.6 h) is
log(2) * 5210 / 104.3 = 34.62 h. A regression over 12-23 h
reads slightly longer because absorption has not fully died away inside
the first dosing interval, so the clean check is the closed form above
rather than a slope fit on day 1.
Steady-state exposure has an equally direct identity: at steady state CL/F has reached its asymptote, so AUC over one dosing interval is exactly dose / CL1.
ev_md <- rxode2::et(amt = 160, cmt = "depot", ii = 24, until = 24 * 20) |>
rxode2::et(seq(0, 24 * 21, by = 0.1))
md <- rxode2::rxSolve(pk_typ, ev_md, ref_cov, returnType = "data.frame",
maxsteps = 200000L)
#> ℹ omega/sigma items treated as zero: 'etalvc', 'etalka', 'etalcl', 'etaltlag'
last_int <- subset(md, time >= 24 * 20 & time <= 24 * 21)
auc_trap <- sum(diff(last_int$time) *
(head(last_int$Cc, -1) + tail(last_int$Cc, -1)) / 2)
auc_closed <- 160 / 64 * 1000 # mg / (L/h) -> mg*h/L -> ng*h/mL
c(`AUCss simulated (ng*h/mL)` = auc_trap,
`Dose / CL1 (ng*h/mL)` = auc_closed,
`percent difference` = 100 * (auc_trap - auc_closed) / auc_closed) |>
round(2)
#> AUCss simulated (ng*h/mL) Dose / CL1 (ng*h/mL) percent difference
#> 2494.13 2500.00 -0.23
# Same drawn parameters on both sides -> pure numerical error, so a tight
# bound is correct here (see CLAUDE.md on cohort-extreme assertions).
stopifnot(abs(100 * (auc_trap - auc_closed) / auc_closed) < 0.5)Covariate effects: replicating Figure 4
Liu 2024 Figure 4 is a forest plot of the covariate effects on CL/F,
V/F and AUCss relative to the reference subject (median age 53 years,
ALK-positive patient with no prior ALK-inhibitor treatment). The caption
prints the point estimates, and every one of them is a deterministic
function of the ini() values.
cl_ratio <- function(age) (age / 53)^(-0.314)
vc_ratio <- function(alki = 0, nonalk = 0, healthy = 0) {
0.734^alki * 0.917^nonalk * 0.784^healthy
}
fig4 <- tibble::tribble(
~Comparison, ~Parameter, ~Model, ~Paper,
"Age 25 y vs 53 y", "CL/F", cl_ratio(25), 1.27,
"Age 71 y vs 53 y", "CL/F", cl_ratio(71), 0.912,
"Age 25 y vs 53 y", "AUCss", 1 / cl_ratio(25), 0.79,
"Age 71 y vs 53 y", "AUCss", 1 / cl_ratio(71), 1.10,
"ALKPOT 2,3,4 vs 1", "V/F", vc_ratio(alki = 1), 0.734,
"ALKPOT 5 vs 1", "V/F", vc_ratio(nonalk = 1), 0.917,
"ALKPOT 6 vs 1", "V/F", vc_ratio(healthy = 1), 0.784
) |>
mutate(`% difference` = 100 * (Model - Paper) / Paper)
knitr::kable(fig4, digits = 3,
caption = "Figure 4 covariate point estimates (ratio to the reference subject).")| Comparison | Parameter | Model | Paper | % difference |
|---|---|---|---|---|
| Age 25 y vs 53 y | CL/F | 1.266 | 1.270 | -0.307 |
| Age 71 y vs 53 y | CL/F | 0.912 | 0.912 | 0.031 |
| Age 25 y vs 53 y | AUCss | 0.790 | 0.790 | -0.022 |
| Age 71 y vs 53 y | AUCss | 1.096 | 1.100 | -0.349 |
| ALKPOT 2,3,4 vs 1 | V/F | 0.734 | 0.734 | 0.000 |
| ALKPOT 5 vs 1 | V/F | 0.917 | 0.917 | 0.000 |
| ALKPOT 6 vs 1 | V/F | 0.784 | 0.784 | 0.000 |
# All deterministic; the paper's own rounding is the only slack.
stopifnot(max(abs(fig4$`% difference`)) < 2)The paper states these as percentage changes: CL/F “increased by 27% in the 5th percentile (25 years) and decreased by 8.8% in the 95th percentile (71 years)”, AUC “decreased by 21% and increased by 10%”, and V/F “26.6% lower when ALKPOT = 2, 3, 4; 8.3% lower when ALKPOT = 5; and 21.6% lower when ALKPOT = 6”. Every one reproduces.
Note that the age effect on AUCss is the reciprocal of its effect on CL/F, because AUCss = dose / CL/F and the age term multiplies total clearance. That is what pins the covariate to total CL rather than to the CL1 arm alone: if the exponent applied only to CL1, the day-1 CL/F ratio at age 25 would be 1.163 rather than 1.266, and the paper’s +27% could not be recovered.
Virtual cohort and simulation
The four phase II dose levels are simulated with the patient covariate mix from Supplementary Table 1. Healthy volunteers are simulated separately as the single-dose 160 mg design of STL31147.
n_per_arm <- 150L
doses <- c(80, 120, 160, 210)
# Age: median 53, range 18-84 (Supplementary Table 1). A truncated normal
# centred at the reported mean reproduces the median and the range.
draw_age <- function(n) {
a <- stats::rnorm(n, mean = 51.6, sd = 11.5)
pmin(pmax(a, 18), 84)
}
# ALKPOT mix among the 293 patients (Supplementary Table 1):
# level 1 115 (ALK+, ALK-inhibitor naive) -> reference
# levels 2-4 64 (ALK+, prior ALK inhibitor)
# level 5 114 (ROS1+ or unknown)
draw_alkpot <- function(n) {
sample(c("naive", "pretreated", "ros1_or_unknown"), n, replace = TRUE,
prob = c(115, 64, 114) / 293)
}
# One covariate template, replicated across the four arms. Subject j of every
# arm therefore has the same age and the same ALKPOT level, and (with the
# common random numbers set in the solve below) the same etas -- so the arms
# differ ONLY by dose. That is what turns the dose-proportionality check into
# a structural identity rather than a race between four noisy medians.
set.seed(20240716)
base_grp <- draw_alkpot(n_per_arm)
base_age <- draw_age(n_per_arm)
cohort <- lapply(seq_along(doses), function(k) {
data.frame(
id = (k - 1L) * n_per_arm + seq_len(n_per_arm),
subject = seq_len(n_per_arm),
dose = doses[k],
AGE = base_age,
PRIOR_ALKI = as.integer(base_grp == "pretreated"),
TUM_ALK_MUT = as.integer(base_grp %in% c("naive", "pretreated")),
DIS_HEALTHY = 0L
)
}) |>
bind_rows() |>
mutate(treatment = factor(paste0(dose, " mg QD"),
levels = paste0(doses, " mg QD")))
summary_cohort <- cohort |>
group_by(treatment) |>
summarise(
n = n(),
`median age` = round(stats::median(AGE), 1),
`prior ALKi (%)` = round(100 * mean(PRIOR_ALKI), 1),
`ALK+ (%)` = round(100 * mean(TUM_ALK_MUT), 1),
.groups = "drop"
)
knitr::kable(summary_cohort, caption = "Simulated cohort by dose arm. The arms are covariate-matched by construction, so the summaries are identical; only the dose differs.")| treatment | n | median age | prior ALKi (%) | ALK+ (%) |
|---|---|---|---|---|
| 80 mg QD | 150 | 55.6 | 25.3 | 56.7 |
| 120 mg QD | 150 | 55.6 | 25.3 | 56.7 |
| 160 mg QD | 150 | 55.6 | 25.3 | 56.7 |
| 210 mg QD | 150 | 55.6 | 25.3 | 56.7 |
rxSolve() on an rxUi scales super-linearly
in the number of subjects, so each arm is solved in its own call.
rxSetSeed() is called inside the loop so every arm draws
the same eta sequence: with common random numbers the dose arms
differ only by dose, which is what makes the dose-proportionality check
below meaningful.
tau <- 24
n_days <- 21L
obs_times <- sort(unique(c(
seq(0, tau, by = 0.25), # first interval
seq(tau, (n_days - 1) * tau, by = tau), # daily troughs
seq((n_days - 1) * tau, n_days * tau, by = 0.25) # steady-state interval
)))
sim <- lapply(doses, function(d) {
cov_d <- cohort |>
filter(dose == d) |>
select(id, AGE, PRIOR_ALKI, TUM_ALK_MUT, DIS_HEALTHY)
ev <- rxode2::et(amt = d, cmt = "depot", ii = tau,
until = (n_days - 1) * tau,
id = cov_d$id) |>
rxode2::et(obs_times, cmt = "central", id = cov_d$id)
rxode2::rxSetSeed(4242)
rxode2::rxSolve(pk, ev, cov_d, returnType = "data.frame",
maxsteps = 200000L, addDosing = FALSE) |>
mutate(dose = d)
}) |>
bind_rows() |>
left_join(select(cohort, id, treatment, AGE), by = "id")
nrow(sim)
#> [1] 127200
band <- sim |>
filter(time <= tau | time >= (n_days - 1) * tau) |>
mutate(window = ifelse(time <= tau, "Day 1", "Day 21 (steady state)"),
t_in_int = ifelse(time <= tau, time, time - (n_days - 1) * tau)) |>
group_by(treatment, window, t_in_int) |>
summarise(lo = stats::quantile(Cc, 0.05),
md = stats::median(Cc),
hi = stats::quantile(Cc, 0.95), .groups = "drop")
ggplot(band, aes(t_in_int, md, colour = treatment, fill = treatment)) +
geom_ribbon(aes(ymin = lo, ymax = hi), alpha = 0.15, colour = NA) +
geom_line() +
facet_wrap(~window) +
labs(x = "Time within dosing interval (h)", y = "SAF-189s (ng/mL)",
colour = NULL, fill = NULL) +
theme_bw()
Simulated SAF-189s plasma concentrations over the first and the twenty-first dosing interval. Ribbons are the 5th-95th percentiles, lines the median.
Accumulation between day 1 and day 21 is the visible signature of the time-dependent clearance working in the opposite direction to accumulation: clearance rises from 64 L/h effective on day 1 (as 104.3 L/h) toward 64 L/h, which damps the accumulation that the 56.4 h terminal half-life would otherwise produce.
PKNCA validation
start_ss <- (n_days - 1) * tau
end_ss <- n_days * tau
conc_df <- sim |>
filter(!is.na(Cc)) |>
select(id, time, Cc, treatment)
dose_df <- cohort |>
transmute(id, time = start_ss, amt = dose, treatment)
conc_obj <- PKNCA::PKNCAconc(conc_df, Cc ~ time | treatment + id,
concu = "ng/mL", timeu = "h")
dose_obj <- PKNCA::PKNCAdose(dose_df, amt ~ time | treatment + id,
doseu = "mg")
intervals <- data.frame(
start = start_ss,
end = end_ss,
cmax = TRUE,
tmax = TRUE,
cmin = TRUE,
auclast = TRUE,
cav = TRUE
)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj,
intervals = intervals))
nca_wide <- as.data.frame(nca_res) |>
select(treatment, id, PPTESTCD, PPORRES)
nca_wide |>
group_by(treatment, PPTESTCD) |>
summarise(median = stats::median(PPORRES), .groups = "drop") |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = median) |>
knitr::kable(digits = 1,
caption = "Median steady-state NCA parameters by dose arm (day 21 interval).")| treatment | auclast | cav | cmax | cmin | tmax |
|---|---|---|---|---|---|
| 80 mg QD | 1251.8 | 52.2 | 58.5 | 44.4 | 5.2 |
| 120 mg QD | 1877.6 | 78.2 | 87.7 | 66.5 | 5.2 |
| 160 mg QD | 2503.5 | 104.3 | 117.0 | 88.7 | 5.2 |
| 210 mg QD | 3285.9 | 136.9 | 153.6 | 116.5 | 5.2 |
Comparison against the published values
Liu 2024 does not tabulate a full NCA table, but it prints four steady-state quantities that a simulation must reproduce: the geometric mean AUCss at the recommended 160 mg dose (2,374 ngh/mL), the median AUCss across the safety analysis set (2,233 ngh/mL), the median Cmin,ss of the phase II efficacy set (79 ng/mL, the Table 3 quartile boundary), and the median Tmax (6 h, from the food-effect study).
sim_nca <- nca_wide |>
filter(PPTESTCD %in% c("auclast", "cmin", "tmax")) |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES)
published <- tibble::tribble(
~treatment, ~auclast, ~cmin, ~tmax,
"160 mg QD", 2374, NA, 6
) |>
mutate(treatment = factor(treatment, levels = levels(cohort$treatment)))
cmp <- nlmixr2lib::ncaComparisonTable(
simulated = filter(sim_nca, treatment == "160 mg QD"),
reference = filter(published, !is.na(auclast)),
by = "treatment",
params = c("auclast", "tmax"),
units = c(auclast = "ng*h/mL", tmax = "h"),
tolerance_pct = 20
)
knitr::kable(cmp,
caption = "Simulated vs published steady-state exposure at the recommended 160 mg once-daily dose. * marks rows differing by >20%.")| NCA parameter | treatment | Reference | Simulated | % diff |
|---|---|---|---|---|
| Tmax (h) | 160 mg QD | 6 | 5.25 | -12.5% |
| AUClast (ng*h/mL) | 160 mg QD | 2370 | 2500 | +5.5% |
auc160 <- filter(sim_nca, treatment == "160 mg QD")$auclast
cmin_all <- sim_nca$cmin
# Dose proportionality, matched subject by subject. The arms are
# covariate-matched and share common random numbers, so subject j at 160 mg
# and subject j at 80 mg differ only by dose; a linear-elimination model must
# give a ratio of exactly 2 for every one of them.
prop_ratio <- sim_nca |>
left_join(select(cohort, id, subject), by = "id") |>
filter(treatment %in% c("80 mg QD", "160 mg QD")) |>
select(subject, treatment, auclast) |>
tidyr::pivot_wider(names_from = treatment, values_from = auclast) |>
mutate(ratio = `160 mg QD` / `80 mg QD`)
gates <- tibble::tibble(
Claim = c(
"Geometric mean AUCss at 160 mg near the published 2,374 ng*h/mL",
"Median Cmin,ss across 80-210 mg near the published 79 ng/mL",
"AUCss is dose proportional (worst per-subject 160 mg / 80 mg ratio)"
),
Achieved = c(
exp(mean(log(auc160))),
stats::median(cmin_all),
prop_ratio$ratio[which.max(abs(prop_ratio$ratio - 2))]
),
Target = c(2374, 79, 2)
)
gates$`% difference` <- 100 * (gates$Achieved - gates$Target) / gates$Target
knitr::kable(gates, digits = 2, caption = "Cohort-level exposure gates.")| Claim | Achieved | Target | % difference |
|---|---|---|---|
| Geometric mean AUCss at 160 mg near the published 2,374 ng*h/mL | 2451.33 | 2374 | 3.26 |
| Median Cmin,ss across 80-210 mg near the published 79 ng/mL | 73.55 | 79 | -6.90 |
| AUCss is dose proportional (worst per-subject 160 mg / 80 mg ratio) | 2.00 | 2 | 0.00 |
stopifnot(
# Centre, not extremes. Realised +2.2% and -9.3%. With CV ~38% on clearance
# over 150 subjects the cohort statistic itself carries a couple of percent
# of draw-to-draw noise (and rxode2's stream is partitioned per solver
# thread, so CI draws a different cohort). These bounds keep several times
# that headroom while still going red on a mis-transcribed dose, volume or
# clearance, each of which moves the whole distribution by tens of percent.
abs(gates$`% difference`[1]) < 20,
abs(gates$`% difference`[2]) < 30,
# Dose proportionality is a structural identity of a linear-elimination
# model with covariate-matched, common-random-number arms: it holds for
# EVERY subject, up to trapezoidal error on a shared time grid. Asserted on
# the worst subject, not the median.
abs(gates$`% difference`[3]) < 0.1
)The median Cmin,ss falls a little below the paper’s 79 ng/mL because the simulated cohort splits evenly across the four dose arms, whereas actual phase II enrolment was concentrated at 160 mg and above (56.8% of phase II subjects received 160 mg and a further 10% received 210 mg), which pulls the real cohort’s median up. The dose-proportionality check is the tight one: elimination is linear in this model and the arms are covariate-matched with common random numbers, so doubling the dose must double AUCss for every subject.
Exposure-response
Efficacy: Figure 6 and Table 4
The exposure-efficacy models are static regressions on
CTROUGH, so they are driven directly by the simulated
steady-state troughs.
# These models carry no ODE and no dose. The exposure column is supplied as a
# time-varying covariate on the event table, one observation row per grid
# point, which is the idiom the other static exposure-response vignettes use.
er_eval <- function(model, output, cov_name, grid) {
ev <- data.frame(id = 1L, time = seq_along(grid), amt = 0, evid = 0L)
ev[[cov_name]] <- grid
s <- as.data.frame(rxode2::rxSolve(model, events = ev,
returnType = "data.frame"))
stopifnot(nrow(s) == length(grid))
s[[output]]
}
ctrough_grid <- exp(seq(log(18), log(182), length.out = 200))
orr_curve <- data.frame(
CTROUGH = ctrough_grid,
prob_orr_central = er_eval(orr, "prob_orr_central", "CTROUGH", ctrough_grid)
)
quartiles <- c(18, 60.7, 79, 108, 182)
observed_q <- tibble::tibble(
# Liu 2024 Table 3: CR + PR counts per Cmin,ss quartile, n = 61 each.
mid = sqrt(head(quartiles, -1) * tail(quartiles, -1)),
p = c(54, 44, 47, 47) / 61
)
ggplot(orr_curve, aes(CTROUGH, prob_orr_central)) +
geom_line(colour = "blue") +
geom_point(data = observed_q, aes(mid, p), inherit.aes = FALSE,
shape = 2, size = 3) +
geom_vline(xintercept = quartiles, linetype = "dashed", alpha = 0.4) +
scale_x_log10() +
coord_cartesian(ylim = c(0, 1)) +
labs(x = "Cmin,ss (ng/mL, log scale)",
y = "Probability of IRC-assessed response",
caption = "Replicates Figure 6 of Liu 2024; triangles are the observed per-quartile response rates from Table 3.") +
theme_bw()
ui_orr <- orr$iniDf
slope_orr <- ui_orr$est[ui_orr$name == "e_ctrough_logit"]
p_at_median <- orr_curve$prob_orr_central[which.min(abs(orr_curve$CTROUGH - 79))]
hazard_at <- function(mod, ctrough) {
er_eval(mod, "hr", "CTROUGH", ctrough)
}
er_eff <- tibble::tibble(
Claim = c(
"ORR odds ratio per e-fold Cmin,ss",
"P(response) at the median Cmin,ss (79 ng/mL) vs the observed ORR",
"PFS hazard ratio per 1 ng/mL Cmin,ss",
"DOR hazard ratio per 1 ng/mL Cmin,ss"
),
Achieved = c(
exp(slope_orr),
p_at_median,
hazard_at(pfs, 1) / hazard_at(pfs, 0),
hazard_at(dor, 1) / hazard_at(dor, 0)
),
Published = c(0.556, 0.7869, 1.007, 1.006)
)
er_eff$`% difference` <- 100 * (er_eff$Achieved - er_eff$Published) / er_eff$Published
knitr::kable(er_eff, digits = 4,
caption = "Exposure-efficacy models against the published estimates.")| Claim | Achieved | Published | % difference |
|---|---|---|---|
| ORR odds ratio per e-fold Cmin,ss | 0.5560 | 0.5560 | 0.0000 |
| P(response) at the median Cmin,ss (79 ng/mL) vs the observed ORR | 0.7871 | 0.7869 | 0.0285 |
| PFS hazard ratio per 1 ng/mL Cmin,ss | 1.0071 | 1.0070 | 0.0124 |
| DOR hazard ratio per 1 ng/mL Cmin,ss | 1.0059 | 1.0060 | -0.0082 |
stopifnot(
# Odds and hazard ratios are exp() of a stored coefficient: deterministic.
abs(er_eff$`% difference`[c(1, 3, 4)]) < 0.1,
# The intercept was digitised; agreement with the independently reported
# observed ORR is the check that it was read correctly.
abs(er_eff$Achieved[2] - 0.7869) < 0.01
)The response curve is essentially flat across the observed exposure range, which is the paper’s finding: the odds ratio confidence interval (0.285-1.083) spans 1 and p = 0.084. The two Cox models are equally null (p = 0.059 and p = 0.251).
Safety: Figure 10
auc_grid <- exp(seq(log(174.0126), log(8338.102), length.out = 300))
safety_models <- list(
"Hyperglycemia (any grade)" = list(m = hyper, out = "prob_hyperglycemia"),
"Hyperglycemia (grade >= 2)" = list(m = hyp2, out = "prob_hyperglycemia_grade2"),
"Proteinuria (any grade)" = list(m = prot, out = "prob_proteinuria")
)
safety_curve <- lapply(names(safety_models), function(nm) {
s <- safety_models[[nm]]
data.frame(AUC = auc_grid, Endpoint = nm,
p = er_eval(s$m, s$out, "AUC_SAF189S", auc_grid))
}) |>
bind_rows() |>
mutate(Endpoint = factor(Endpoint, levels = names(safety_models)))
ggplot(safety_curve, aes(AUC, p)) +
geom_line(colour = "blue") +
facet_wrap(~Endpoint) +
geom_vline(xintercept = 2233, linetype = "dashed", alpha = 0.5) +
scale_x_log10() +
coord_cartesian(ylim = c(0, 1)) +
labs(x = "AUCss (ng*h/mL, log scale)", y = "Probability of AE",
caption = "Replicates Figure 10 of Liu 2024. Dashed line is the published median AUCss of 2,233 ng*h/mL.") +
theme_bw()
p_at <- function(df, endpoint, auc) {
d <- filter(df, Endpoint == endpoint)
d$p[which.min(abs(d$AUC - auc))]
}
slope_of <- function(mod) {
d <- mod$iniDf
d$est[d$name == "e_auc_saf189s_logit"]
}
er_saf <- tibble::tibble(
Endpoint = c("Hyperglycemia (any grade)", "Hyperglycemia (grade >= 2)",
"Proteinuria (any grade)"),
`OR (model)` = c(exp(slope_of(hyper)), exp(slope_of(hyp2)), exp(slope_of(prot))),
`OR (paper)` = c(3.521, 7.662, 2.031),
`P at median AUCss` = c(
p_at(safety_curve, "Hyperglycemia (any grade)", 2233),
p_at(safety_curve, "Hyperglycemia (grade >= 2)", 2233),
p_at(safety_curve, "Proteinuria (any grade)", 2233)
),
`Observed incidence (Table 5)` = c(165 / 296, 82 / 296, 90 / 296)
)
knitr::kable(er_saf, digits = 4,
caption = "Exposure-safety models against the published odds ratios and the Table 5 incidences.")| Endpoint | OR (model) | OR (paper) | P at median AUCss | Observed incidence (Table 5) |
|---|---|---|---|---|
| Hyperglycemia (any grade) | 3.521 | 3.521 | 0.5679 | 0.5574 |
| Hyperglycemia (grade >= 2) | 7.662 | 7.662 | 0.2469 | 0.2770 |
| Proteinuria (any grade) | 2.031 | 2.031 | 0.3025 | 0.3041 |
stopifnot(
# Odds ratios are exp() of a stored coefficient: deterministic.
max(abs(100 * (er_saf$`OR (model)` - er_saf$`OR (paper)`) /
er_saf$`OR (paper)`)) < 0.1,
# The three intercepts were digitised from Figure 10. The check that they
# were read correctly is that each model's fitted probability at the median
# exposure lands on the independently counted Table 5 incidence. All three
# agree within 3 percentage points; the bound admits that without becoming
# un-failable (a mis-read intercept moves these by tens of points).
max(abs(er_saf$`P at median AUCss` -
er_saf$`Observed incidence (Table 5)`)) < 0.05
)Finally, the three safety models can be driven by the simulated exposures rather than by a grid, which is how a dose-selection analysis would use them.
auc_by_subject <- nca_wide |>
filter(PPTESTCD == "auclast") |>
transmute(id, treatment, AUC_SAF189S = PPORRES)
pred_by_dose <- lapply(levels(auc_by_subject$treatment), function(tr) {
d <- filter(auc_by_subject, treatment == tr)
data.frame(
treatment = tr,
hyper = mean(er_eval(hyper, "prob_hyperglycemia", "AUC_SAF189S", d$AUC_SAF189S)),
hyper2 = mean(er_eval(hyp2, "prob_hyperglycemia_grade2", "AUC_SAF189S", d$AUC_SAF189S)),
prot = mean(er_eval(prot, "prob_proteinuria", "AUC_SAF189S", d$AUC_SAF189S))
)
}) |>
bind_rows()
pred_by_dose |>
rename("Dose arm" = treatment,
"Hyperglycemia, any grade" = hyper,
"Hyperglycemia, grade >= 2" = hyper2,
"Proteinuria, any grade" = prot) |>
knitr::kable(digits = 3,
caption = "Model-predicted mean adverse-event probability by dose arm, using the simulated steady-state AUCss of each subject.")| Dose arm | Hyperglycemia, any grade | Hyperglycemia, grade >= 2 | Proteinuria, any grade |
|---|---|---|---|
| 80 mg QD | 0.388 | 0.106 | 0.224 |
| 120 mg QD | 0.508 | 0.204 | 0.277 |
| 160 mg QD | 0.593 | 0.305 | 0.320 |
| 210 mg QD | 0.669 | 0.419 | 0.362 |
stopifnot(
# Trend, not step-by-step monotonicity of a noisy cohort statistic.
pred_by_dose$hyper[nrow(pred_by_dose)] > pred_by_dose$hyper[1],
pred_by_dose$hyper2[nrow(pred_by_dose)] > pred_by_dose$hyper2[1]
)This reproduces the paper’s dose-selection argument quantitatively: predicted grade >= 2 hyperglycemia rises steeply from the 80 mg arm to the 210 mg arm, while the 160 mg arm sits close to the observed cohort-wide rate. Liu 2024 selected 160 mg once daily as the recommended phase III dose on exactly this basis, noting that the geometric mean AUCss at 160 mg (2,374 ngh/mL) is close to the median exposure (2,233 ngh/mL) below which adverse-event incidence was significantly lower.
Assumptions and deviations
Figure-derived intercepts (four models). Liu 2024
reports the slope of every exposure-response regression but no
intercept, and prints no coefficient table for any of them. The
intercepts of Liu_2024_saf189s_orr,
Liu_2024_saf189s_hyperglycemia,
Liu_2024_saf189s_hyperglycemia_grade2 and
Liu_2024_saf189s_proteinuria were therefore recovered by
digitising the fitted curves of Figures 6 and 10. In each case the
slope was digitised too and reproduced the printed odds ratio
to within 0.15% (0.5553 vs 0.556; 3.524 vs 3.521; 7.672 vs 7.662; 2.032
vs 2.031), with the logit linear in log exposure to R-squared >=
0.99995 – which is what licenses reading the intercept off the same
curve. Each intercept was then cross-checked against a source that is
not the figure: the fitted probability at the median exposure
against the independently counted event proportion (0.787 vs the
reported ORR 78.69%; 0.569 vs 55.74%; 0.248 vs 27.70%; 0.303 vs 30.41%).
The axis calibration itself was validated by the median-quartile dashed
line landing at 2,231 ngh/mL against the 2,233 ngh/mL printed
in the Discussion. The slopes in the model files are the printed values;
only the intercepts are figure-derived.
No baseline hazard for PFS and DOR. A Cox regression
is semiparametric, so h0(t) is not an unreported parameter
but a quantity the method never produces.
Liu_2024_saf189s_pfs and Liu_2024_saf189s_dor
therefore return the relative hazard only and offer no survivor
function. Liu 2024 Figure 8 gives Kaplan-Meier curves stratified at the
median exposure as an empirical description of the absolute time course;
both log-rank tests were non-significant.
Tumour-growth model not extracted. Liu 2024 Equation 1 gives a Stein-Fojo biexponential model for the sum of longest lesion diameters, but reports no estimate for any of its parameters (SOD0, KG, KS) anywhere in the paper or either supplementary document, and prints no figure of them. The Results say only that “there was no trend observed between the exposure and tumor growth model parameters”. With no point estimates there is nothing to transcribe, so no model file was created for it.
Endpoints without an exposure relationship. Liu 2024 screened hypercholesterolemia, nausea, vomiting and diarrhea against exposure and found no significant trend, reporting incidences (Table 5) but no coefficients. No model files exist for those endpoints for the same reason.
Residual error entered as variances. Table 2 prints
the residual terms as 0.0468 and 0.0938 without stating a scale. They
are entered here as variances, so
propSd = sqrt(0.0468) = 0.2163 and
addSd = sqrt(0.0938) = 0.3063 ng/mL. Read as standard
deviations instead, the proportional error would be 4.68% – tighter than
the assay on pooled sparse phase I/II oncology data – and the additive
error would be 0.094 ng/mL, a fifth of the 0.5 ng/mL lower limit of
quantitation. Both readings of the variance interpretation are ordinary;
both readings of the SD interpretation are not.
Interindividual variability entered as variances.
Unambiguous: Table 2 prints a percent CV beside each omega-squared, and
sqrt(exp(omega^2) - 1) reproduces all four printed CVs
(36.5%, 56.7%, 38.5%, 35.7%) to the printed decimal.
A single eta on clearance. The Results text says an
exponential IIV model was implemented “including CL1/F, CL2/F, V/F, KA,
and tlag”, but Table 2 lists only four eta rows with a single clearance
term. Equation 2 settles it: exp(eta_i) multiplies the
whole bracket, so one eta is shared by both clearance arms. The model
file applies etalcl to both, which is algebraically
identical.
High shrinkage on KA and the lag time. Table 2 reports 53.3% and 66.2% shrinkage on those two etas, so their individual estimates are weakly informed. Simulated absorption-phase variability should be read with that in mind; the clearance eta (2.2% shrinkage) and the volume eta (41.1%) are better determined.
Covariates screened but not retained are recorded in
the model file’s covariatesDataExcluded list rather than
covariateData, because the paper reports no point estimate
for any of them: body weight, sex, creatinine clearance, albumin, ALT,
ALP, total bilirubin, serum creatinine and concomitant medications.
Deterministic probability outputs. The four logistic
models emit a probability with a placeholder additive residual of 0.001
fixed, because rxode2 requires an observation declaration; the source
likelihood is Bernoulli and estimates no residual. Downstream callers
should sample outcomes with rbinom(n, 1, prob).
Cohort composition. The simulated cohort splits evenly across the four phase II dose arms, whereas actual enrolment concentrated on 160 mg (54.8% of PK-evaluable subjects). Cohort-wide exposure summaries here are therefore not directly comparable with the paper’s pooled medians; the per-arm comparison at 160 mg is.
Errata
The CL1/CL2 prose is reversed. Liu 2024 Results
describes CL1/F as “the initial apparent clearance after single dosing”
and CL2/F as “the subsequent time-varying apparent clearance at steady
state”. Equation 2, Figure 1 and the printed half-lives all say the
opposite: exp(-Kout*(DAY-1)) multiplies TVCL2, Figure 1
labels the two elimination arms “Time independent CL1” and “Time
dependent CL2”, and only the assignment used here reproduces both
printed half-lives (ln(2)*5210/(64+40.3) = 34.6 h after the
first dose; ln(2)*5210/64 = 56.4 h at steady state). The
reversed assignment reproduces neither. The parameter values are fine;
the sentence is the error.
Table 2 unit labels. Table 2 prints “KA (L h-1)” and “Kout (L d-1)”. Both are first-order rate constants; the litre is spurious. The Results narrative gives them correctly as 1/h and 1/day.
The odds-ratio unit statement. The Results say the exposure-safety odds ratios correspond to “an increase in SAF-189s exposure of 1 nghmL-1d-1”. An odds ratio of 3.5 per 1 ngh/mL across a range spanning thousands of ng*h/mL is arithmetically impossible. The same paragraph states that “a linear effect of log AUCss described the data well”, and digitising Figure 10 confirms the unit is one natural-log unit (an e-fold). The models use the e-fold reading.
Sex is reported two ways. Supplementary Table 1 counts 153 of 317 subjects female (48.2%); the Results narrative says “approximately half of them were women (56.5%)”. The count-backed 48.2% is used in the population metadata. Sex was screened and not retained, so nothing in the model depends on it.
Supplementary Table 1 has misaligned laboratory
rows. That table reports a median serum creatinine of 163
umol/L (range 139-190) and a median total bilirubin of 68 umol/L (range
35.0-110). Neither is credible for a cohort the same table describes as
65% renally normal and 87% hepatically normal, and the creatinine value
contradicts the creatinine clearance of 96.2 mL/min in the row above it.
Median height of 134 cm and median hemoglobin of 146 g/L with a range
starting at 20 g/L are similarly implausible. These look like rows
shifted against their labels. None of the affected covariates was
retained in the final model, so the extraction does not depend on them;
they are recorded in covariatesDataExcluded with the
discrepancy flagged.
Kout RSE. Kout is the least precisely estimated
structural parameter (RSE 31.2%, bootstrap 95% CI 0.927-2.24 per day).
Its value controls only how fast CL/F reaches its asymptote (half-life
ln(2)/1.35 = 0.51 days), not the asymptote itself, so
steady-state exposure is insensitive to it; day 1 to day 3 predictions
are not.