The problem
Every study passed to admControl() needs a mean vector
E, a covariance V, a sample size
n, the observation times and a dosing event
table ev. A published figure gives you a mean and an error
bar.
The mean is easy. The variance is where aggregate analyses go wrong:
a standard error used as a standard deviation is off by
sqrt(n), and the fit will not tell you — the structural
parameters come back correct and only the between-subject variability
collapses. This vignette turns a figure into E,
V and n, and shows what that mistake
costs.
What V must be
V is the spread across subjects, not the
precision of the mean. admixr2’s likelihood is
where r is the mismatch between observed and predicted
means and V_pred = J Omega J' + Sigma is ONE subject’s
covariance, built from the between-subject variability and the residual
error. V_obs must be the same object, with n
sitting outside it. Hand the likelihood a standard error squared and you
have told it about n twice.
So V = SD^2, never SEM^2.
What is the error bar?
Read the caption. If it does not say, treat the error bar as unknown rather than assuming:
| Figure reports | Convert to SD | Note |
|---|---|---|
| Standard deviation (SD) | SD |
Use directly |
| Standard error (SEM) | SD = SEM * sqrt(n) |
Off by sqrt(n) if confused |
| 95% CI of the mean | SD = (upper - lower) * sqrt(n) / (2 * qt(0.975, n - 1)) |
the divisor tends to 3.92 = 2 × 1.96 as n grows |
| Interquartile range | SD ~ IQR / 1.35 |
Assumes normality |
| LS-mean SE (from an MMRM) | SD ~ SE * sqrt(n) |
Biased small — see below |
sd_from_sem <- function(sem, n) sem * sqrt(n)
sd_from_ci <- function(lower, upper, n) (upper - lower) * sqrt(n) / (2 * qt(0.975, n - 1))
sd_from_iqr <- function(q1, q3) (q3 - q1) / 1.35The LS-mean SE is the row that can silently reproduce the error this
vignette is about. It comes out of a model, usually an MMRM: baseline
and covariate adjustment strip variance from the residual and the
covariance structure borrows across visits, so SE * sqrt(n)
usually comes out smaller than the true between-subject
SD — the same direction as mistaking a SEM for an SD. It also describes
whatever the MMRM modelled, often a change from baseline rather than an
absolute value. Prefer a descriptive SD from the paper’s own baseline
table, or from a comparable study.
A figure reporting standard errors
A 50 mg arm, 60 subjects, digitised from a concentration–time figure whose caption reads “mean ± SEM” — the same arm used in PD and PK/PD data, before it was fitted:
times <- c(0.5, 1, 2, 4, 8, 12, 24)
E <- c(0.969, 0.920, 0.831, 0.679, 0.460, 0.317, 0.111) # mg/L
SEM <- c(0.0267, 0.0243, 0.0205, 0.0161, 0.0137, 0.0125, 0.0080)
n <- 60L
SD <- sd_from_sem(SEM, n)
round(SD, 3)
#> [1] 0.207 0.188 0.159 0.125 0.106 0.097 0.062The plotted bars are sqrt(n) — nearly eight times —
smaller than the standard deviations the fit needs. Side by side, the
SEM band is obviously too tight to be a between-subject spread.
band <- rbind(
data.frame(t = times, m = E, lo = E - SD, hi = E + SD, what = "mean ± SD (what V needs)"),
data.frame(t = times, m = E, lo = E - SEM, hi = E + SEM, what = "mean ± SEM (what the figure plots)"))
ggplot(band, aes(t, m)) +
geom_ribbon(aes(ymin = lo, ymax = hi, fill = what), alpha = 0.25) +
geom_line(linewidth = 0.9, colour = "grey20") +
geom_point(size = 1.8, colour = "grey20") +
facet_wrap(~ what) +
scale_fill_manual(values = c("mean ± SD (what V needs)" = "#0072B2",
"mean ± SEM (what the figure plots)" = "#D55E00"),
guide = "none") +
labs(x = "Time (h)", y = "Concentration (mg/L)") +
theme_minimal(base_size = 12) +
theme(panel.grid.minor = element_blank(),
strip.text = element_text(face = "bold", size = 10))
Passing V as a plain vector of variances is enough;
admixr2 expands it to a diagonal matrix and sets
method = "var":
pk_model <- function() {
ini({
tcl <- log(4) ; label("Log clearance (L/h)")
tv <- log(40) ; label("Log volume (L)")
prop.cp <- 0.1 ; label("Proportional residual error")
eta.cl ~ 0.09
eta.v ~ 0.04
})
model({
cl <- exp(tcl + eta.cl)
v <- exp(tv + eta.v)
d/dt(central) <- -(cl/v) * central
cp <- central / v
cp ~ prop(prop.cp)
})
}fit <- nlmixr2(pk_model, admData(), est = "adgh",
control = adghControl(studies = list(mg50 = study)))
fit
── nlmixr² adgh ──
OBJF AIC BIC Log-likelihood
adgh -1323.144 -1313.144 -1292.943 661.5719
── Time (sec fit$time): ──
optimize covariance other elapsed other
elapsed 0.382 0.133 0 0.515 2.664
── Population Parameters (fit$parFixed or fit$parFixedDf): ──
Parameter Est. SE %RSE
tcl Log clearance (L/h) 1.556 0.03364 2.162
tv Log volume (L) 3.911 0.02645 0.6762
prop.cp Proportional residual error 0.09732 0.05383 55.31
Back-transformed(95%CI) BSV(CV%) Shrink(SD)%
tcl 4.738 (4.436, 5.061) 25.77 NaN
tv 49.96 (47.44, 52.62) 19.68 NaN
prop.cp 0.09732 (-0.008190, 0.2028)
Covariance Type (fit$covMethod): r,s
No correlations in between subject variability (BSV) matrix
Full BSV covariance (fit$omega) or correlation (fit$omegaR; diagonals=SDs)
Distribution stats (mean/skewness/kurtosis/p-value) available in fit$shrink
Censoring (fit$censInformation): No censoring
Minimization message (fit$message):
NLOPT_FTOL_REACHED: Optimization stopped because ftol_rel or ftol_abs (above) was reached. What reading SEM as SD costs
Now make the mistake: use the plotted SEM as if it were
an SD, so V is n-fold too
small.
fit_wrong <- nlmixr2(
pk_model, admData(), est = "adgh",
control = adghControl(studies = list(
mg50 = list(E = E, V = SEM^2, n = n, times = times, # WRONG: SEM^2 as V
ev = rxode2::et(amt = 50, cmt = "central")))))
fit_wrong
── nlmixr² adgh ──
OBJF AIC BIC Log-likelihood
adgh -2942.688 -2932.688 -2912.487 1471.344
── Time (sec fit_wrong$time): ──
optimize covariance other elapsed
1 0.523 0.096 0 0.619
── Population Parameters (fit_wrong$parFixed or fit_wrong$parFixedDf): ──
Parameter Est. SE %RSE
tcl Log clearance (L/h) 1.558 0.005669 0.3639
tv Log volume (L) 3.908 0.004580 0.1172
prop.cp Proportional residual error 0.008208 0.003649 44.46
Back-transformed(95%CI) BSV(CV%) Shrink(SD)%
tcl 4.749 (4.696, 4.802) 4.181 NaN
tv 49.79 (49.34, 50.24) 2.935 NaN
prop.cp 0.008208 (0.001056, 0.01536)
Covariance Type (fit_wrong$covMethod): r,s
No correlations in between subject variability (BSV) matrix
Full BSV covariance (fit_wrong$omega)
or correlation (fit_wrong$omegaR; diagonals=SDs)
Distribution stats (mean/skewness/kurtosis/p-value) available in $shrink
Information about run found (fit_wrong$runInfo):
• covMethod = "r,s": the Hessian is ill-conditioned (rcond 2.4e-06, cond 4.23e+05), and the sandwich inverts it twice where "r" inverts it once -- so the correction is amplified quadratically in the weakly-identified direction, which loads mainly on `prop.cp`. Check that parameter's relative standard error before reading its "r,s" value as a finding; the well-determined parameters are unaffected.
• admixr2: prop.cp finished on the gradient box constraint (grad_bounds = 5 from the starting value), not at an interior optimum. The reported estimate and SE are those of a constrained fit. Widen grad_bounds, or start closer to the expected value.
Censoring (fit_wrong$censInformation): No censoring
Minimization message (fit_wrong$message):
NLOPT_FTOL_REACHED: Optimization stopped because ftol_rel or ftol_abs (above) was reached. Clearance and volume are unchanged to three figures. The between-subject variability is not:
Omega shrinks by more than an order of magnitude on both
random effects: the model is being asked to reproduce a between-subject
spread n times tighter than the real one.
V_pred = J Omega J' + Sigma is linear in Omega
and in the residual variance, so both shrink together and the structural
parameters are free to stay put. Only the mean-mismatch term
r' V_pred^-1 r, which does not rescale, stops the shrinkage
short of the full factor of n.
Nothing in the point estimates warns you. The precision does:
round(c(RSE_CL_correct = fit$parFixedDf["tcl", "%RSE"],
RSE_CL_wrong = fit_wrong$parFixedDf["tcl", "%RSE"]), 3)
#> RSE_CL_correct RSE_CL_wrong
#> 2.162 0.364Clearance comes back not just right but implausibly certain — its
standard error tightens about 5.9-fold, of order sqrt(n).
An RSE that looks too good for digitised literature data, or an IIV that
comes back near zero, is the tell.
Two things make this worse than the demo suggests:
-
The structural parameters survive only because this model
fits. Misspecify it and
ris not zero, so aV_predthat isntimes too small weights that mismatchntimes too heavily, dragging the structural estimates toward a gap they cannot close. -
In a multi-study fit, one bad
Vcaptures everything. It inflates that study’s weight in the joint likelihood roughlyn-fold, whereupon it dominates every other arm and biases the shared parameters. That is the usual way admixr2 is used, and where the mistake is most expensive.
Why V is usually diagonal
A figure gives one error bar per time point and says nothing about
how the times covary, so published summaries carry no off-diagonal
entries. A diagonal V selects method = "var"
and skips the Cholesky solve — the honest default for literature data.
The full-covariance path needs the subject-level matrix and
cov.wt(dv_mat, method = "ML")$cov; see Getting started.
Note the denominator, and declare it. admixr2’s likelihood is the
exact one for n iid draws only under the ML
(n) covariance, while a published SD is the unbiased
(n - 1) one – so V = SD^2 off a figure is on
the n - 1 scale and strictly wants
V = SD^2 * (n - 1) / n.
Rather than apply that by hand, say which convention the number is on:
list(E = E, V = SD^2, n = n, times = times,
ev = rxode2::et(amt = 50, cmt = "central"),
v_denom = "unbiased") # a published SD; admixr2 converts itv_denom defaults to "ml" — what
cov.wt(method = "ML") and datagen() produce — so nothing
changes for data you computed yourself. It is per
study, because a meta-analysis routinely mixes a digitised
figure with a model-derived source and the two need not agree.
At n = 60 the factor is 1.7%. It grows as n
falls, and it stops being cosmetic for any method that scores the
reported covariance against its own sampling law rather than treating it
as a sufficient statistic.
Sample size
n is the number of subjects contributing to the summary,
per arm — not the total across arms, and not the number of
observations.
-
Dropout.
nfalls over time, so a figure’s late points may rest on fewer subjects than its early ones. Convert each error bar with thenthat applies to it, and pass the number contributing to the observations you are fitting — at risk, not randomised. -
Per-endpoint
n. PK and PD are not always measured in the same people, so eachobservationsentry may carry its ownn.
Absolute values or change from baseline?
Many PD papers report a least-squares-mean change from baseline rather than an absolute value. Either can be fitted, so long as the model predicts the same quantity as the data:
- Absolute values need a baseline parameter in the model; see PD and PK/PD data.
- A change means the model output must itself be a change, and
Vwants the SD of the change, not of the absolute value.
Studies reporting different quantities must be converted to a common one before fitting, not after.
When there is no variability at all
A paper often gives a mean with no SD, SEM or CI — a placebo arm in a
footnote, say. You then have to assume a V, so it is worth
knowing what that assumption does. Refit the arm above with the SD
deliberately wrong in each direction:
assume <- function(mult) {
f <- nlmixr2(pk_model, admData(), est = "adgh",
control = adghControl(studies = list(
mg50 = list(E = E, V = (SD * mult)^2, n = n, times = times,
ev = rxode2::et(amt = 50, cmt = "central")))))
c(exp(f$theta[["tcl"]]), exp(f$theta[["tv"]]),
diag(f$omega)[["eta.cl"]], diag(f$omega)[["eta.v"]])
}
mults <- c(0.5, 1, 2, 4)
res <- t(vapply(mults, assume, numeric(4)))
tbl <- data.frame(
`Assumed SD` = c("0.5x (too small)", "1x (correct)",
"2x (too large)", "4x (too large)"),
CL = round(res[, 1], 2),
V = round(res[, 2], 2),
`var(eta.cl)` = round(res[, 3], 3),
`var(eta.v)` = round(res[, 4], 3),
check.names = FALSE
)
knitr::kable(tbl, row.names = FALSE,
caption = "Fit against a deliberately wrong V. Truth: CL = 5, V = 50, var(eta.cl) = 0.09, var(eta.v) = 0.04.")| Assumed SD | CL | V | var(eta.cl) | var(eta.v) |
|---|---|---|---|---|
| 0.5x (too small) | 4.74 | 49.89 | 0.017 | 0.010 |
| 1x (correct) | 4.74 | 49.96 | 0.064 | 0.038 |
| 2x (too large) | 4.77 | 49.86 | 0.245 | 0.103 |
| 4x (too large) | 2.01 | 1.02 | 0.001 | 5.937 |
Omega follows the assumption whichever way it is wrong:
too small and the between-subject variability collapses, too large and
it inflates several-fold. V is data the model must
reproduce, not a weight — so assuming one is assuming the IIV you are
trying to estimate, and it reaches every study through the shared
Omega. Push far enough and the fit stops being sensible: at
4x the structural parameters leave the building.
There is no safe direction to err in. In rough order of preference:
- Take the variability from a comparable arm, or a comparable study of the same endpoint.
- Use a published typical SD for that endpoint and population.
- Exclude the study.
Whichever you choose, record it and refit across the range you consider plausible. If the estimates move, the assumption is doing the work, and the result belongs in a sensitivity table rather than a headline.
Notes
-
The error bar dominates every other error source.
Digitisation software is accurate to a few percent; mistaking a SEM for
an SD is an error of
sqrt(n). The caption matters more than the pixels. -
Geometric means. A geometric mean with CV%
describes a log-normal distribution, while
EandVare arithmetic moments. Convert first. - Digitising. WebPlotDigitizer is the usual tool; extracting a figure twice and comparing is a cheap check.
-
%RSEis on the estimation scale. These parameters are log-scale, so an RSE ontclis not an RSE on clearance. - PD specifics — baselines, placebo arms, two endpoints — are covered in PD and PK/PD data.
See also
- Multiple studies — combine several studies into a meta-analysis
- Simulating data & using published models — the other input type
- Getting started
