Isoniazid (Vinnard 2017)
Source:vignettes/articles/Vinnard_2017_isoniazid.Rmd
Vinnard_2017_isoniazid.RmdModel and source
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
Citation: Vinnard C, Ravimohan S, Tamuhla N, Ivaturi V, Pasipanodya J, Srivastava S, Modongo C, Zetola NM, Weissman D, Gumbo T, Bisson GP. (2017). Isoniazid clearance is impaired among human immunodeficiency virus/tuberculosis patients with high levels of immune activation. Br J Clin Pharmacol 83(4):801-811. doi:10.1111/bcp.13172.
Description: Two-compartment population PK model with first-order absorption and an absorption lag time for oral isoniazid in HIV/tuberculosis co-infected adults in Botswana (Vinnard 2017). NAT2 acetylator genotype (slow acetylator as the reference, with proportional shifts for the intermediate and rapid phenotypes) and systemic immune activation (percent CD38 and HLA-DR co-expression on CD8+ T cells, entered as a median-normalised power term) act on apparent oral clearance; between-subject variability on CL/F, V/F and the absorption lag time, and inter-occasion variability on CL/F across the pre-ART and post-ART pharmacokinetic visits.
PubMed Central: PMC5346858
Vinnard and colleagues asked whether systemic immune activation, the
hallmark of both untreated HIV infection and active tuberculosis,
impairs isoniazid clearance over and above the well-established effect
of NAT2 acetylator genotype. Forty ART-naive HIV/TB
patients in Gaborone, Botswana were sampled intensively before starting
antiretroviral therapy, and 24 of them again about a month after. The
answer was yes: after accounting for NAT2 genotype, a
higher percentage of circulating CD8+ T cells co-expressing CD38 and
HLA-DR predicted lower isoniazid clearance, and therefore
higher isoniazid exposure.
Population
Sixty-one HIV/TB patients were screened and 40 enrolled (Table 1, journal page 805): 45% women, median age 32 years (IQR 27-43), median weight 55.0 kg (IQR 49.3-59.3), median creatinine clearance 102.1 mL/min (IQR 92.5-114.1). All were citizens of Botswana, ART-naive at enrolment, newly diagnosed with pulmonary TB, and established on a standard WHO first-line regimen dosed by weight band. Creatinine clearance below 50 mL/min and transaminases above three times the upper limit of normal were exclusion criteria.
The first pharmacokinetic visit occurred 5 to 28 days after starting anti-TB therapy (median 20 days), before any ART. Twenty-four patients returned for a second visit after a median of 33 days of tenofovir/emtricitabine/efavirenz. Between visits the median CD4+ count rose from 238 to 308 cells/uL and HIV viral load fell in all but one participant, while the immune-activation marker %CD38+DR+CD8+ fell from a median of 36.9% (IQR 27.7-45.7) to 24.8% (IQR 21.9-35.9) – though individual trajectories went both ways.
NAT2 genotype was resolved in 38 of 40 participants: 7
slow (18%), 18 intermediate (45%) and 13 rapid (33%) acetylators, with 2
ambiguous.
Serum isoniazid was measured at 0, 0.3, 0.9, 2.2, 4.5 and 8 h post-dose by stable-isotope dilution LC-ESI-MS/MS (LLOQ 0.16 mg/L). Estimation used FOCE in Phoenix NLME 1.3.
The same information is available programmatically:
str(rxode2::rxode(readModelDb("Vinnard_2017_isoniazid"))$population)
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> List of 16
#> $ species : chr "human"
#> $ n_subjects : int 40
#> $ n_studies : int 1
#> $ age_range : chr "21 years and older (enrolment criterion)"
#> $ age_median : chr "32 years (IQR 27-43) at visit 1; 32 years (IQR 28-43) at visit 2"
#> $ weight_range : chr "Not tabulated as a range; median 55.0 kg (IQR 49.3-59.3) at visit 1 and 56.6 kg (IQR 52.5-61.8) at visit 2"
#> $ weight_median : chr "55.0 kg"
#> $ sex_female_pct : num 45
#> $ race_ethnicity : chr "Citizens of Botswana (sub-Saharan African); detailed ancestry not reported."
#> $ disease_state : chr "ART-naive HIV-infected adults newly diagnosed with pulmonary TB and established on a standard WHO first-line an"| __truncated__
#> $ dose_range : chr "Oral isoniazid once daily as part of a first-line fixed-dose combination, dosed by WHO weight band (Methods 'St"| __truncated__
#> $ regions : chr "Botswana (Gaborone; 22 public clinics and Princess Marina Hospital)."
#> $ renal_function : chr "Creatinine clearance below 50 mL/min was an exclusion criterion; median CrCl 102.1 mL/min (IQR 92.5-114.1) at visit 1."
#> $ hepatic_function: chr "Alanine or aspartate transaminase above 3 times the upper limit of normal was an exclusion criterion."
#> $ co_medication : chr "First-line antitubercular fixed-dose combination therapy at both visits; tenofovir/emtricitabine/efavirenz ART "| __truncated__
#> $ notes : chr "Prospective observational two-visit design. 61 patients were screened and 40 enrolled and sampled at visit 1 (m"| __truncated__Source trace
Every ini() entry carries an in-file comment naming its
source location in
inst/modeldb/specificDrugs/Vinnard_2017_isoniazid.R. They
are collected here for review.
| Equation / parameter | Value | Source location |
|---|---|---|
lka |
log(0.88) |
Table 2, journal page 808: Ka = 0.88 1/h (RSE 12.45%) |
lcl |
log(10.99) |
Table 2: CL = 10.99 L/h (RSE 9.86%) |
lvc |
log(5.55) |
Table 2: V = 5.55 L (RSE 48.11%) |
lvp |
log(17.54) |
Table 2: V2 = 17.54 L (RSE 16.29%) |
lq |
log(11.61) |
Table 2: Q = 11.61 L/h (RSE 34.06%) |
ltlag |
log(0.25) |
Table 2: Tlag = 0.25 h (RSE 11.67%) |
e_nat2_int_cl |
0.63 |
Table 2: Theta(Intermediate NAT2) = 0.63 (RSE 30.62%), 95% CI 0.30 to 1.06 |
e_nat2_rapid_cl |
1.65 |
Table 2: Theta(Rapid NAT2) = 1.65 (RSE 18.22%), 95% CI 1.14 to 2.25 |
e_cd8_cd38dr_pct_cl |
-0.31 |
Table 2: Theta(%CD38 + HLA-DR + |CD8) = -0.31 (RSE 40.06%), 95% CI -0.57 to -0.07 |
etalcl |
0.08237 |
Table 2: BSV-CL 29.3% CV; omega^2 = log(1 + 0.293^2) |
etalvc |
1.07858 |
Table 2: BSV-V 139.3% CV; omega^2 = log(1 + 1.393^2) |
etaltlag |
0.00937 |
Table 2: BSV-Tlag 9.7% CV; omega^2 = log(1 + 0.097^2) |
etaiov_cl_1, etaiov_cl_2
|
0.00202 |
Table 2: BSV-IOV 4.5% CV; omega^2 = log(1 + 0.045^2); one variance shared across both occasions |
addSd |
0.06 |
Table 2: additive error SD = 0.06 mg/L (RSE 16.68%) |
propSd |
0.25 |
Table 2: proportional error = 0.25 (RSE 12.27%) |
| Two-compartment ODEs, first-order absorption | n/a | Results paragraph 3: “best described by a two-compartment model with first-order elimination” |
alag(depot) <- tlag |
n/a | Results paragraph 3: “Fit of the concentrations during the absorptive phase was improved with the use of a time-lag absorption model” |
| BSV on CL, V, Tlag only | n/a | Results paragraph 4: “BSV on CL, V, and Tlag (Table 2)”; eta shrinkage 10.0% / 12.8% / 43.0% |
| IOV on CL across two visits | n/a | Results paragraph 4: “we introduced interoccasional variability (IOV) on CL into the population PK model” |
| Combined additive + proportional error | n/a | Results paragraph 3: “a combined additive/proportional residual error model” |
| Normalisation of %CD38+DR+CD8+ at 36.9% | n/a | Table 1, journal page 805: visit-1 cohort median 36.9% (IQR 27.7-45.7, n = 38) |
NAT2_SLOW as covariate-model reference |
n/a | Table 2 prints thetas for the intermediate and rapid levels only, so slow is the omitted reference |
| Covariate functional forms | n/a | Not printed by the paper. Recovered from published anchors; see the next section |
| Dose 300 mg for the simulations below | n/a | Not tabulated by the paper. Implied by the Figure 3 CL/AUC pairs; see Assumptions |
Recovering the covariate functional forms
The paper gives point estimates for three covariate coefficients but
never writes the covariate equation. Phoenix NLME admits several
standard forms, and they are not interchangeable: for the
rapid-acetylator coefficient of 1.65 a proportional
(1 + theta) form means a 2.65-fold clearance increase while
an exponential exp(theta) form means a 5.21-fold increase.
Four independent anchors published in the paper settle it.
theta_int <- 0.63
theta_rapid <- 1.65
theta_cd38 <- -0.31
cl_ref <- 10.99 # Table 2 typical value = the slow-acetylator reference
cd38_ref <- 36.9 # Table 1 visit-1 median, the normalising value
# The two candidate categorical forms.
cl_prop <- cl_ref * c(slow = 1,
intermediate = 1 + theta_int,
rapid = 1 + theta_rapid)
cl_expo <- cl_ref * c(slow = 1,
intermediate = exp(theta_int),
rapid = exp(theta_rapid))
# --- Anchor 1: Figure 3A (journal page 809) plots every individual's predicted
# CL/F, faceted by NAT2 genotype, pre-ART vs post-ART. These are the pre-ART
# per-genotype medians read off that panel by on-screen digitisation
# (approximately +/- 10%; the panel's y-axis tops out just above 36 L/h).
fig3a_median_cl <- c(slow = 10.2, intermediate = 19.0, rapid = 26.5)
anchor1 <- tibble::tibble(
genotype = names(fig3a_median_cl),
`Figure 3A median CL/F (L/h)` = as.numeric(fig3a_median_cl),
`Proportional form (L/h)` = as.numeric(cl_prop),
`Exponential form (L/h)` = as.numeric(cl_expo),
`Proportional % diff` = 100 * (cl_prop - fig3a_median_cl) / fig3a_median_cl,
`Exponential % diff` = 100 * (cl_expo - fig3a_median_cl) / fig3a_median_cl
)
knitr::kable(
anchor1, digits = 1,
caption = paste(
"Anchor 1. Per-genotype typical CL/F under each candidate form, against the",
"digitised pre-ART medians of Figure 3A. The exponential form puts the rapid",
"typical value at 57 L/h, off the published panel's axis entirely."
)
)| genotype | Figure 3A median CL/F (L/h) | Proportional form (L/h) | Exponential form (L/h) | Proportional % diff | Exponential % diff |
|---|---|---|---|---|---|
| slow | 10.2 | 11.0 | 11.0 | 7.7 | 7.7 |
| intermediate | 19.0 | 17.9 | 20.6 | -5.7 | 8.6 |
| rapid | 26.5 | 29.1 | 57.2 | 9.9 | 115.9 |
# --- Anchor 2: Figure 2A (journal page 808) box-plots eta_CL from the BASE
# model (no covariates) by genotype. Digitised medians:
fig2a_eta_median <- c(slow = -0.53, intermediate = 0.07, rapid = 0.40)
# A base model's etas centre on the population geometric mean, so each form
# predicts eta_g = log(factor_g) - E[log(factor)] over the published genotype
# frequencies (7 slow / 18 intermediate / 13 rapid of 38 non-ambiguous).
geno_freq <- c(slow = 7, intermediate = 18, rapid = 13)
geno_freq <- geno_freq / sum(geno_freq)
centred_eta <- function(cl_vec) {
lf <- log(cl_vec / cl_ref)
lf - sum(geno_freq * lf)
}
anchor2 <- tibble::tibble(
genotype = names(fig2a_eta_median),
`Figure 2A median eta_CL` = as.numeric(fig2a_eta_median),
`Proportional form` = as.numeric(centred_eta(cl_prop)),
`Exponential form` = as.numeric(centred_eta(cl_expo))
)
rmse <- function(a, b) sqrt(mean((a - b)^2))
rmse_prop <- rmse(anchor2$`Figure 2A median eta_CL`, anchor2$`Proportional form`)
rmse_expo <- rmse(anchor2$`Figure 2A median eta_CL`, anchor2$`Exponential form`)
knitr::kable(
anchor2, digits = 3,
caption = sprintf(
paste("Anchor 2. Base-model eta_CL by genotype (Figure 2A) against each form's",
"frequency-centred prediction. RMSE: proportional %.3f, exponential %.3f."),
rmse_prop, rmse_expo
)
)| genotype | Figure 2A median eta_CL | Proportional form | Exponential form |
|---|---|---|---|
| slow | -0.53 | -0.565 | -0.863 |
| intermediate | 0.07 | -0.076 | -0.233 |
| rapid | 0.40 | 0.410 | 0.787 |
# --- Anchor 3: the paper reports the BSV-CL sequence as covariates were added
# (Results paragraphs 3-4): 45.9% in the base model, 32.0% after NAT2, 29.3%
# after adding immune activation. The variance a covariate removes from
# log-CL is therefore recoverable.
cv_to_omega <- function(cv) sqrt(log(1 + cv^2))
omega_base <- cv_to_omega(0.459)
omega_nat2 <- cv_to_omega(0.320)
omega_fin <- cv_to_omega(0.293)
sd_explained_nat2 <- sqrt(omega_base^2 - omega_nat2^2)
sd_explained_cd38 <- sqrt(omega_nat2^2 - omega_fin^2)
# What each form actually contributes, over the published genotype frequencies.
sd_of <- function(cl_vec) {
lf <- log(cl_vec / cl_ref)
sqrt(sum(geno_freq * lf^2) - sum(geno_freq * lf)^2)
}
# The immune-activation term contributes theta * SD(log(CD38 / ref)). Table 1's
# visit-1 IQR 27.7 to 45.7 gives SD(log CD38) = log(45.7 / 27.7) / 1.349.
sd_log_cd38 <- log(45.7 / 27.7) / (2 * qnorm(0.75))
anchor3 <- tibble::tibble(
Covariate = c("NAT2 genotype", "NAT2 genotype", "%CD38+DR+CD8+"),
Form = c("proportional (1 + theta)", "exponential exp(theta)",
"median-normalised power"),
`SD of log-CL contributed` = c(sd_of(cl_prop), sd_of(cl_expo),
abs(theta_cd38) * sd_log_cd38),
`SD required by published BSV drop` = c(sd_explained_nat2, sd_explained_nat2,
sd_explained_cd38)
) |>
dplyr::mutate(Ratio = `SD of log-CL contributed` / `SD required by published BSV drop`)
knitr::kable(
anchor3, digits = 3,
caption = paste(
"Anchor 3. Variance accounting. A covariate form must remove as much",
"log-CL spread as the published BSV reduction says it did. The exponential",
"form removes roughly twice too much."
)
)| Covariate | Form | SD of log-CL contributed | SD required by published BSV drop | Ratio |
|---|---|---|---|---|
| NAT2 genotype | proportional (1 + theta) | 0.345 | 0.306 | 1.127 |
| NAT2 genotype | exponential exp(theta) | 0.612 | 0.306 | 2.000 |
| %CD38+DR+CD8+ | median-normalised power | 0.115 | 0.123 | 0.935 |
# --- Anchor 4: Figure 2B (journal page 808) regresses eta_CL on
# %CD38+DR+CD8+. The plotted regression line runs from about +0.225 at x = 9.5
# to -0.29 at x = 66.5 (digitised), i.e. a slope of about -0.0090 per
# percentage point, crossing zero near the cohort median.
fig2b_slope <- (-0.29 - 0.225) / (66.5 - 9.5)
# Any covariate form normalised by a reference value has d log(CL) / dx =
# theta / ref at that reference. This is what pins the NORMALISATION: an
# un-normalised exp(theta * (x - median)) form would give a slope of -0.31 per
# percentage point, 34-fold too steep, and a fraction-scaled version would be
# about 3-fold too shallow.
slope_normalised <- theta_cd38 / cd38_ref
slope_unnormalised <- theta_cd38
slope_fraction <- theta_cd38 / 100
anchor4 <- tibble::tibble(
Form = c("power / linear in x, normalised by the 36.9% median",
"exponential, centred by subtraction, x in percent",
"exponential, centred by subtraction, x as a fraction"),
`d log(CL) / dx at the median` = c(slope_normalised, slope_unnormalised,
slope_fraction),
`Figure 2B regression slope` = fig2b_slope
) |>
dplyr::mutate(Ratio = `d log(CL) / dx at the median` / `Figure 2B regression slope`)
knitr::kable(
anchor4, digits = 4,
caption = paste(
"Anchor 4. Slope of eta_CL on %CD38+DR+CD8+ (Figure 2B) against each",
"candidate scaling. Only the median-normalised form matches."
)
)| Form | d log(CL) / dx at the median | Figure 2B regression slope | Ratio |
|---|---|---|---|
| power / linear in x, normalised by the 36.9% median | -0.0084 | -0.009 | 0.9298 |
| exponential, centred by subtraction, x in percent | -0.3100 | -0.009 | 34.3107 |
| exponential, centred by subtraction, x as a fraction | -0.0031 | -0.009 | 0.3431 |
# Gate. Each of these can go red: the exponential categorical form fails
# anchors 1 and 3 by more than 100% and 90% respectively, and the
# subtraction-centred continuous forms fail anchor 4 by 34-fold / 3-fold.
stopifnot(
# Anchor 1: proportional form within the digitisation tolerance for all three
# genotypes; the exponential form is not (it misses rapid by >100%).
max(abs(anchor1$`Proportional % diff`)) < 20,
max(abs(anchor1$`Exponential % diff`)) > 60,
# Anchor 2: the proportional form tracks the base-model etas far better.
rmse_prop < 0.15,
rmse_expo > 2 * rmse_prop,
# Anchor 3: both retained forms account for the published BSV drop within
# 35%, and the exponential categorical alternative overshoots by >70%.
abs(anchor3$Ratio[anchor3$Form == "proportional (1 + theta)"] - 1) < 0.35,
abs(anchor3$Ratio[anchor3$Form == "median-normalised power"] - 1) < 0.35,
anchor3$Ratio[anchor3$Form == "exponential exp(theta)"] > 1.7,
# Anchor 4: median normalisation reproduces the Figure 2B slope within 20%.
abs(slope_normalised / fig2b_slope - 1) < 0.2,
abs(slope_unnormalised / fig2b_slope) > 20
)All four anchors point the same way, so the packaged model implements
CL/F = 10.99 * (1 + 0.63 * I(intermediate) + 1.65 * I(rapid))
* (%CD38+DR+CD8+ / 36.9)^(-0.31) * exp(eta_CL + kappa_IOV)
with the slow acetylator as the reference. As a fifth, external
corroboration: Horita_2018_isoniazid reports CL/F of 4.44
L/h (slow) and 8.08 L/h (nonslow) in 14.3 kg Ghanaian children. Scaled
allometrically to this cohort’s median 55 kg those become 12.2 and 22.2
L/h, against 10.99 L/h (slow) and a frequency-weighted 22.6 L/h
(nonslow) here. The exponential form would put the nonslow value at 36
L/h.
horita_slow_55kg <- 4.44 * (55 / 14.3)^0.75
horita_nonslow_55kg <- 8.08 * (55 / 14.3)^0.75
nonslow_w <- geno_freq[c("intermediate", "rapid")] /
sum(geno_freq[c("intermediate", "rapid")])
this_nonslow <- sum(nonslow_w * cl_prop[c("intermediate", "rapid")])
this_nonslow_expo <- sum(nonslow_w * cl_expo[c("intermediate", "rapid")])
tibble::tibble(
Stratum = c("slow", "nonslow"),
`Horita 2018 scaled to 55 kg (L/h)` = c(horita_slow_55kg, horita_nonslow_55kg),
`This model, proportional form (L/h)` = c(cl_ref, this_nonslow),
`This model, exponential form (L/h)` = c(cl_ref, this_nonslow_expo)
) |>
knitr::kable(digits = 1,
caption = "External cross-check against the Horita 2018 isoniazid model.")| Stratum | Horita 2018 scaled to 55 kg (L/h) | This model, proportional form (L/h) | This model, exponential form (L/h) |
|---|---|---|---|
| slow | 12.2 | 11.0 | 11 |
| nonslow | 22.2 | 22.6 | 36 |
Typical-value predictions by genotype
The paper’s own sampling design ran to 8 h, but Figure 3B reports AUC0-inf, so the checks below extend to 48 h. Isoniazid dose is 300 mg (see Assumptions).
inh_dose <- 300
mod <- readModelDb("Vinnard_2017_isoniazid")
mod_typical <- rxode2::zeroRe(mod)
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
genotypes <- tibble::tibble(
genotype = c("slow", "intermediate", "rapid"),
NAT2_SLOW = c(1, 0, 0),
NAT2_RAPID = c(0, 0, 1)
)
# Dense early grid so Cmax and Tmax are resolved (Tmax is under 1 h here), then
# coarser through the terminal phase.
obs_times <- sort(unique(c(seq(0, 4, by = 0.02), seq(4, 48, by = 0.25))))
make_events <- function(row, cd38, occ, id_offset = 0L, n = 1L) {
ev <- rxode2::et(amt = inh_dose, cmt = "depot") |>
rxode2::et(obs_times, cmt = "central")
ev <- as.data.frame(ev)
out <- lapply(seq_len(n), function(i) {
d <- ev
d$id <- id_offset + i
d$NAT2_SLOW <- row$NAT2_SLOW
d$NAT2_RAPID <- row$NAT2_RAPID
d$CD8_CD38DR_PCT <- cd38[i]
d$OCC <- occ
d$genotype <- row$genotype
d
})
dplyr::bind_rows(out)
}
ev_typ <- dplyr::bind_rows(lapply(seq_len(nrow(genotypes)), function(i) {
make_events(genotypes[i, ], cd38 = cd38_ref, occ = 1L, id_offset = i - 1L)
}))
sim_typ <- rxode2::rxSolve(mod_typical, ev_typ, keep = "genotype",
returnType = "data.frame")
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etaltlag', 'etaiov_cl_1', 'etaiov_cl_2'
#> Warning: multi-subject simulation without without 'omega'
sim_typ <- sim_typ[!is.na(sim_typ$Cc), ]
stopifnot(nrow(sim_typ) > 0, all(sim_typ$Cc >= 0))
typ_summary <- sim_typ |>
dplyr::group_by(genotype) |>
dplyr::summarise(
`CL/F (L/h)` = dplyr::first(cl),
`V/F (L)` = dplyr::first(vc),
`Cmax (mg/L)` = max(Cc),
`Tmax (h)` = time[which.max(Cc)],
.groups = "drop"
) |>
dplyr::mutate(
`AUC0-inf = Dose/CL (mg*h/L)` = inh_dose / `CL/F (L/h)`,
genotype = factor(genotype, levels = genotypes$genotype)
) |>
dplyr::arrange(genotype)
knitr::kable(typ_summary, digits = 2,
caption = "Typical-value predictions at the cohort-median immune-activation level.")| genotype | CL/F (L/h) | V/F (L) | Cmax (mg/L) | Tmax (h) | AUC0-inf = Dose/CL (mg*h/L) |
|---|---|---|---|---|---|
| slow | 10.99 | 5.55 | 8.13 | 0.82 | 27.30 |
| intermediate | 17.91 | 5.55 | 6.52 | 0.70 | 16.75 |
| rapid | 29.12 | 5.55 | 4.98 | 0.60 | 10.30 |
# The typical CL/F must equal the Table 2 value times the covariate factor
# exactly -- this is deterministic, so the tolerance is numerical only.
stopifnot(all(abs(typ_summary$`CL/F (L/h)` /
as.numeric(cl_prop[as.character(typ_summary$genotype)]) - 1) < 1e-6))Replicating Figure 3
Figure 3 plots each individual’s model-predicted CL/F (panel A) and AUC0-inf (panel B) by genotype. Panel A’s pre-ART medians were used as Anchor 1 above; panel B’s are compared here. Both digitised sets carry roughly +/- 10% digitisation error.
fig3_published <- tibble::tibble(
genotype = factor(c("slow", "intermediate", "rapid"), levels = genotypes$genotype),
cl_fig3a = c(10.2, 19.0, 26.5),
auc_fig3b = c(28.5, 15.2, 11.0)
)
fig3_cmp <- typ_summary |>
dplyr::select(genotype, cl_model = `CL/F (L/h)`,
auc_model = `AUC0-inf = Dose/CL (mg*h/L)`) |>
dplyr::left_join(fig3_published, by = "genotype") |>
dplyr::mutate(
cl_pct = 100 * (cl_model - cl_fig3a) / cl_fig3a,
auc_pct = 100 * (auc_model - auc_fig3b) / auc_fig3b
)
fig3_cmp |>
dplyr::rename(
"NAT2 genotype" = genotype,
"Model CL/F (L/h)" = cl_model,
"Figure 3A median CL/F (L/h)" = cl_fig3a,
"CL % diff" = cl_pct,
"Model AUC0-inf (mg*h/L)" = auc_model,
"Figure 3B median AUC0-inf (mg*h/L)" = auc_fig3b,
"AUC % diff" = auc_pct
) |>
knitr::kable(digits = 1,
caption = "Replicates Figure 3 of Vinnard 2017 (pre-ART medians).")| NAT2 genotype | Model CL/F (L/h) | Model AUC0-inf (mg*h/L) | Figure 3A median CL/F (L/h) | Figure 3B median AUC0-inf (mg*h/L) | CL % diff | AUC % diff |
|---|---|---|---|---|---|---|
| slow | 11.0 | 27.3 | 10.2 | 28.5 | 7.7 | -4.2 |
| intermediate | 17.9 | 16.7 | 19.0 | 15.2 | -5.7 | 10.2 |
| rapid | 29.1 | 10.3 | 26.5 | 11.0 | 9.9 | -6.4 |
fig3_cmp |>
dplyr::select(genotype, Model = cl_model, `Figure 3A` = cl_fig3a) |>
tidyr::pivot_longer(-genotype, names_to = "source", values_to = "cl") |>
ggplot(aes(genotype, cl, fill = source)) +
geom_col(position = "dodge") +
labs(x = "NAT2 genotype", y = "Isoniazid CL/F (L/h)",
fill = NULL, title = "Figure 3A -- typical CL/F by NAT2 genotype",
caption = "Replicates Figure 3A of Vinnard 2017 (pre-ART medians).")
Virtual cohort and simulation
Three arms of 200 subjects, one per NAT2 genotype, each
drawing %CD38+DR+CD8+ from a log-normal matched to the Table 1 visit-1
median and IQR and truncated to the span actually plotted in Figure 2B
(about 11% to 64%).
# rxSetSeed fixes rxode2's stream per solver thread, not across thread counts,
# and set.seed only touches R's RNG. Every assertion below is therefore written
# to hold for any cohort this model can produce.
set.seed(20170411)
rxode2::rxSetSeed(20170411)
n_per_arm <- 200L
# Log-normal for %CD38+DR+CD8+: median 36.9, IQR 27.7-45.7 (Table 1, visit 1).
cd38_sdlog_v1 <- log(45.7 / 27.7) / (2 * qnorm(0.75))
draw_cd38 <- function(n, med, sdlog) {
x <- rlnorm(n, meanlog = log(med), sdlog = sdlog)
pmin(pmax(x, 11), 64)
}
events <- dplyr::bind_rows(lapply(seq_len(nrow(genotypes)), function(i) {
make_events(
genotypes[i, ],
cd38 = draw_cd38(n_per_arm, cd38_ref, cd38_sdlog_v1),
occ = 1L,
id_offset = (i - 1L) * n_per_arm,
n = n_per_arm
)
}))
stopifnot(!anyDuplicated(unique(events[, c("id", "time", "evid")])))
sim <- rxode2::rxSolve(mod, events, keep = c("genotype", "CD8_CD38DR_PCT"),
returnType = "data.frame")
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
sim <- sim[!is.na(sim$Cc), ]
sim$genotype <- factor(sim$genotype, levels = genotypes$genotype)
stopifnot(nrow(sim) > 0, all(sim$Cc >= 0))
sim |>
dplyr::group_by(genotype, time) |>
dplyr::summarise(
Q05 = quantile(Cc, 0.05), Q50 = median(Cc), Q95 = quantile(Cc, 0.95),
.groups = "drop"
) |>
dplyr::filter(time <= 12) |>
ggplot(aes(time, Q50)) +
geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.25) +
geom_line() +
facet_wrap(~genotype) +
scale_y_log10() +
labs(x = "Time (h)", y = "Serum isoniazid (mg/L)",
title = "Simulated concentration-time profiles by NAT2 genotype",
caption = paste("Median with 5th-95th percentile band, 200 subjects per",
"arm, 300 mg oral isoniazid. Comparable to Figure S2",
"(visual predictive check), which is not on disk."))
#> Warning in scale_y_log10(): log-10 transformation introduced infinite values.
#> log-10 transformation introduced infinite values.
#> log-10 transformation introduced infinite values.
#> log-10 transformation introduced infinite values.
PKNCA validation
sim_nca <- sim |>
dplyr::filter(!is.na(Cc)) |>
dplyr::select(id, time, Cc, genotype)
# Guarantee a time = 0 row per subject; pre-dose Cc = 0 is correct for an
# extravascular dose.
sim_nca <- dplyr::bind_rows(
sim_nca,
sim_nca |> dplyr::distinct(id, genotype) |> dplyr::mutate(time = 0, Cc = 0)
) |>
dplyr::distinct(id, genotype, time, .keep_all = TRUE) |>
dplyr::arrange(id, genotype, time)
conc_obj <- PKNCA::PKNCAconc(sim_nca, Cc ~ time | genotype + id,
concu = "mg/L", timeu = "h")
dose_df <- events |>
dplyr::filter(evid == 1) |>
dplyr::select(id, time, amt, genotype)
dose_obj <- PKNCA::PKNCAdose(dose_df, amt ~ time | genotype + id, doseu = "mg")
intervals <- data.frame(
start = 0,
end = Inf,
cmax = TRUE,
tmax = TRUE,
auclast = TRUE,
aucinf.obs = TRUE,
half.life = TRUE
)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))
nca_tbl <- as.data.frame(nca_res$result)
# Confirm the gate has rows to test before summarising (a zero-row lookup would
# make every check below vacuously true).
stopifnot(
all(c("cmax", "tmax", "aucinf.obs", "auclast", "half.life") %in% nca_tbl$PPTESTCD),
sum(nca_tbl$PPTESTCD == "aucinf.obs" & !is.na(nca_tbl$PPORRES)) >=
0.95 * 3 * n_per_arm
)
nca_med <- nca_tbl |>
dplyr::filter(!is.na(PPORRES)) |>
dplyr::group_by(genotype, PPTESTCD) |>
dplyr::summarise(value = median(PPORRES), .groups = "drop") |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = value)
# Reference side: Dose/CL is an identity for a linear model, so the
# typical-value AUC0-inf is a zero-parameter target; Cmax and Tmax come from
# the deterministic typical-value solve above.
published <- typ_summary |>
dplyr::transmute(
genotype = as.character(genotype),
cmax = `Cmax (mg/L)`,
tmax = `Tmax (h)`,
aucinf.obs = `AUC0-inf = Dose/CL (mg*h/L)`
)
cmp <- nlmixr2lib::ncaComparisonTable(
simulated = nca_res,
reference = published,
by = "genotype",
units = c(cmax = "mg/L", tmax = "h", aucinf.obs = "mg*h/L"),
tolerance_pct = 20
)
knitr::kable(cmp, digits = 2, align = c("l", "l", "r", "r", "r"),
caption = paste("Simulated cohort medians against the typical-value",
"targets. * differs from reference by >20%."))| NCA parameter | genotype | Reference | Simulated | % diff |
|---|---|---|---|---|
| Cmax (mg/L) | slow | 8.13 | 8.28 | +1.9% |
| Cmax (mg/L) | intermediate | 6.52 | 6.37 | -2.3% |
| Cmax (mg/L) | rapid | 4.98 | 4.81 | -3.4% |
| Tmax (h) | slow | 0.82 | 0.75 | -8.5% |
| Tmax (h) | intermediate | 0.7 | 0.68 | -2.9% |
| Tmax (h) | rapid | 0.6 | 0.6 | +0.0% |
| AUC0-∞ (obs) (mg*h/L) | slow | 27.3 | 28.4 | +3.9% |
| AUC0-∞ (obs) (mg*h/L) | intermediate | 16.7 | 17 | +1.4% |
| AUC0-∞ (obs) (mg*h/L) | rapid | 10.3 | 10 | -2.7% |
The AUC0-inf comparison is the load-bearing one: for a linear model
AUC0-inf = Dose / (CL/F) is an identity, so a
mis-transcribed clearance, dose or unit moves the simulated median by
tens of percent. Cmax and Tmax are included for completeness but the
cohort median of Cmax sits below the typical-value Cmax because the 139%
CV on V/F makes the peak strongly right-skewed, and Tmax lands on a grid
point.
auc_med <- nca_med |>
dplyr::mutate(genotype = as.character(genotype)) |>
dplyr::left_join(published, by = "genotype", suffix = c("", "_ref")) |>
dplyr::mutate(auc_pct = 100 * (aucinf.obs - aucinf.obs_ref) / aucinf.obs_ref)
# Cohort-derived, so assert on the CENTRE rather than on any subject's extreme
# (see the repo CLAUDE.md note on thread-fragile assertions). Realised
# |auc_pct| was at most 3.9% across all three arms at 2 threads and 3.7% at 16;
# the residual is trapezoidal error on a peak that Tmax reaches in under an
# hour, not cohort noise. A bound of 12 leaves room for a different cohort draw
# while still breaking on a mis-transcribed clearance, dose or unit, which
# would move this by tens of percent.
stopifnot(
max(abs(auc_med$auc_pct)) < 12,
# Ordering of the three genotype AUCs is a structural claim with a 2.65-fold
# spread, not a near-zero effect, so it is safe to assert.
auc_med$aucinf.obs[auc_med$genotype == "slow"] >
auc_med$aucinf.obs[auc_med$genotype == "intermediate"],
auc_med$aucinf.obs[auc_med$genotype == "intermediate"] >
auc_med$aucinf.obs[auc_med$genotype == "rapid"]
)Immune activation drives the pre-ART to post-ART clearance change
The paper’s Figure 3 narrative is that isoniazid clearance rose after ART in most participants: “individual predicted isoniazid CL increased among three of five slow acetylators, five of nine intermediate acetylators and six of seven rapid acetylators” – 14 of 21, on a median %CD38+DR+CD8+ that fell from 36.9% to 24.8%. The IOV term is tiny (4.5% CV), so in this model the change is almost entirely the immune-activation covariate.
Simulating both occasions for the same subjects (common random numbers) turns this into a paired check.
set.seed(20170412)
rxode2::rxSetSeed(20170412)
n_paired <- 200L
cd38_sdlog_v2 <- log(35.9 / 21.9) / (2 * qnorm(0.75))
cd38_v1 <- draw_cd38(n_paired, cd38_ref, cd38_sdlog_v1)
cd38_v2 <- draw_cd38(n_paired, 24.8, cd38_sdlog_v2)
# One arm per occasion, intermediate genotype (the modal group), same subject
# IDs so etalcl is drawn once per subject per solve. Reseeding inside the loop
# gives common random numbers across the two occasions.
solve_occasion <- function(cd38, occ, seed) {
rxode2::rxSetSeed(seed)
ev <- make_events(genotypes[genotypes$genotype == "intermediate", ],
cd38 = cd38, occ = occ, n = n_paired)
s <- rxode2::rxSolve(mod, ev, keep = "CD8_CD38DR_PCT", returnType = "data.frame")
s <- s[!is.na(s$Cc), ]
s |>
dplyr::group_by(id) |>
dplyr::summarise(cl = dplyr::first(cl),
cd38 = dplyr::first(CD8_CD38DR_PCT), .groups = "drop")
}
occ1 <- solve_occasion(cd38_v1, 1L, 991L)
occ2 <- solve_occasion(cd38_v2, 2L, 991L)
paired <- dplyr::inner_join(occ1, occ2, by = "id", suffix = c("_v1", "_v2")) |>
dplyr::mutate(
cl_ratio = cl_v2 / cl_v1,
cd38_ratio = cd38_v2 / cd38_v1,
# The model's analytic prediction for the ratio, ignoring the small IOV
# term: (cd38_v2 / cd38_v1)^theta.
predicted = cd38_ratio^theta_cd38
)
tibble::tibble(
Quantity = c("Median CL/F, occasion 1 (L/h)",
"Median CL/F, occasion 2 (L/h)",
"Median CL/F ratio (occ 2 / occ 1)",
"Ratio implied by the published medians, (24.8/36.9)^-0.31",
"Fraction of subjects with higher CL/F after ART",
"Vinnard 2017 Figure 3A: 14 of 21 participants"),
Value = c(median(paired$cl_v1), median(paired$cl_v2),
median(paired$cl_ratio), (24.8 / cd38_ref)^theta_cd38,
mean(paired$cl_ratio > 1), 14 / 21)
) |>
knitr::kable(digits = 3,
caption = "Paired pre-ART / post-ART clearance change.")| Quantity | Value |
|---|---|
| Median CL/F, occasion 1 (L/h) | 18.350 |
| Median CL/F, occasion 2 (L/h) | 20.530 |
| Median CL/F ratio (occ 2 / occ 1) | 1.125 |
| Ratio implied by the published medians, (24.8/36.9)^-0.31 | 1.131 |
| Fraction of subjects with higher CL/F after ART | 0.750 |
| Vinnard 2017 Figure 3A: 14 of 21 participants | 0.667 |
rel_err <- abs(paired$cl_ratio / paired$predicted - 1)
# Every bound below is set from the ANALYTIC expectation, not from one run.
# The only quantity separating cl_ratio from its covariate-only prediction is
# the IOV term, whose difference across two occasions has SD
# 0.045 * sqrt(2) = 0.064, giving an expected median |rel_err| of 0.043 and an
# expected 90th percentile of 0.105 (realised 0.042 and 0.104 at 2 threads).
# The bounds sit at roughly twice those, so they break if the covariate form is
# wrong (rel_err would then be systematically tens of percent) or if the IOV
# variance were mis-transcribed by a factor of two -- but not on a cohort draw.
stopifnot(
median(rel_err) < 0.08,
quantile(rel_err, 0.9) < 0.20,
# A majority of subjects clear faster after ART, as in Figure 3A. The
# analytic fraction is 0.76 (realised 0.75); the published fraction is
# 14/21 = 0.67. Asserting > 0.55 needs the whole mean shift to vanish.
mean(paired$cl_ratio > 1) > 0.55,
# The median ratio tracks the ratio implied by the published visit medians;
# analytic agreement is exact, realised relative error 0.006 at 2 threads.
abs(median(paired$cl_ratio) / ((24.8 / cd38_ref)^theta_cd38) - 1) < 0.12
)Assumptions and deviations
-
Covariate functional forms are not printed by the
paper. Table 2 gives three coefficients and no equation. The
proportional
(1 + theta)form forNAT2and the median-normalised power form for %CD38+DR+CD8+ were identified from four published anchors (Figure 3A per-genotype clearances, Figure 2A base-model etas, the Results BSV-reduction sequence, and the Figure 2B regression slope) plus an external cross-check againstHorita_2018_isoniazid. All are shown above with the rejected alternatives side by side. This is the single largest interpretive step in the extraction: a reader who has access to the authors’ Phoenix NLME control stream should check it against the forms above. -
The normalising value for %CD38+DR+CD8+ (36.9%) is the
visit-1 cohort median from Table 1, not a value the paper
identifies as the normalisation constant. The model was fit to both
occasions pooled, so the constant the authors actually used was most
likely the pooled median (somewhere between the 36.9% visit-1 and 24.8%
visit-2 medians). Using 36.9% makes
lclexactly the printed 10.99 L/h at visit-1-median activation; a different constant would rescalelclby(constant / 36.9)^-0.31and leave every prediction in this vignette unchanged in shape. -
Non-paper-derived values used for validation only.
The Figure 2A eta medians (-0.53 / 0.07 / 0.40), the Figure 2B
regression endpoints (+0.225 at x = 9.5, -0.29 at x = 66.5), the Figure
3A clearance medians (10.2 / 19.0 / 26.5 L/h) and the Figure 3B AUC
medians (28.5 / 15.2 / 11.0 mg*h/L) were read off the published figures
by on-screen digitisation, with roughly +/- 10% uncertainty. No
ini()parameter value comes from a figure – every one is a printed Table 2 estimate. -
Isoniazid dose is not tabulated. The paper states
only that participants were dosed by WHO weight band. For a linear model
AUC0-inf = Dose / (CL/F), so pairing the Figure 3A clearances with the
Figure 3B AUCs for the slow acetylators back-solves the dose: the six
recoverable pairs give 278 to 343 mg with a mean of 302 mg. The
simulations here therefore use 300 mg, which is also the standard adult
isoniazid component of the WHO 55-70 kg band that contains this cohort’s
median weight of 55.0 kg. The companion paper on the same cohort
(Vinnard 2017, J Antimicrob Chemother,
Vinnard_2017_rifampicin) reports rifampicin doses of 300/450/600/750 mg for 1/19/17/3 participants, i.e. 2/3/4/5 tablets of the same fixed-dose combination, implying isoniazid doses of 150/225/300/375 mg across the cohort. -
BSV reported as a percentage is read as CV% and
converted with the exact log-normal identity
omega^2 = log(1 + CV^2). The paper says only that BSV used “an exponential variability model with mean of zero and variance omega^2” and tabulates percentages. This matters most forBSV-Vat 139.3%, whereomega = 1.039under this identity versus1.393if the percentage were read asomega * 100. -
Inter-occasion variability is encoded as two etas with equal
variance. Table 2 reports one IOV variance (4.5% CV) shared
across the two occasions, which in NONMEM would be
$OMEGA BLOCK(1)plusSAME. nlmixr2 has noSAMEshortcut, so the second occasion’s variance is fixed equal to the first – the pattern used byWilkins_2008_rifampicinand the companionVinnard_2017_rifampicin. rxode2 warns that these indicator-multiplexed etas are not mu-referenced; that is expected for the IOV idiom and does not affect simulation. -
The two ambiguous-
NAT2participants are not representable. The paper resolved 38 of 40 genotypes and does not say how the other two entered the covariate model. SetNAT2_SLOW = 0, NAT2_RAPID = 0to place a subject in the intermediate group, or exclude them. -
Screened-and-rejected covariates carry no
coefficients. Creatinine clearance, sex, weight, CD4+ count,
IL-6, neopterin and CRP were all tested and none was retained; the paper
reports no point estimates for them, so they are documented in the model
file’s
covariatesDataExcludedlist rather than incovariateData. In particular there is no allometric weight scaling in this model, which is why theHorita_2018_isoniazidcross-check above has to supply the 0.75 exponent externally. -
The supplement is not obtainable and is not needed.
Europe PMC reports
Article with id PMC5346858 is not open access onefor the supplementary-file endpoint (a control PMCID returned a valid archive from the same endpoint, so this is a missing deposit rather than a service outage). The supplement holds Table S1 (per-participantNAT2alleles), Table S2 (NAT2gene positions), Figure S1 (goodness-of-fit plots) and Figure S2 (visual predictive checks) – no parameter estimates. Everyini()value comes from Table 2 of the main article, so no parameter is missing. The absent Figure S2 is the one validation target that could not be reproduced directly; the simulated profiles above stand in for it. - V/F is weakly identified in the source. Table 2 reports V = 5.55 L with 48% RSE, a bootstrap 95% CI of 2.01 to 12.31 L, and 139.3% BSV. Reproduced faithfully, this makes the simulated peak concentration strongly right-skewed and the typical-value Cmax (about 8 mg/L at 300 mg in a slow acetylator) higher than the cohort median. The paper’s sampling schedule has no observation between 0.3 and 0.9 h, which brackets this model’s Tmax, so the peak is the least-constrained region of the fit. Exposure metrics (AUC0-inf, and therefore the paper’s own conclusions) depend only on CL/F and are unaffected.