
Prior distributions in nlmixr2: general, nwpri and tnpri
2026-09-15
Source:vignettes/articles/priors-focei.Rmd
priors-focei.Rmd
What a prior is, in nlmixr2
A prior() term inside ini({}) turns an
ordinary estimate into a penalized one: the fit
maximizes data likelihood and agreement with a stated belief
about the parameter, rather than data likelihood alone.
Anything lotri recognizes as a distribution works:
dnorm()/stdNormal(), dcauchy(),
invWishart(), and the multivariate
multiNormal() shorthand c() already produces
for correlated parameters. You can put a prior on a population parameter
(tcl, add.sd, …) or on a
between-subject-variability element (eta.cl’s own variance,
written om.eta.cl or prior(eta.cl) ~ ...).
rxode2::rxUiPriors() shows you what a model declares:
library(nlmixr2)
thetaPriorModel <- function() {
ini({
tka <- 0.45
tcl <- 1
tv <- 3.45
eta.ka ~ 0.6
eta.cl ~ 0.3
eta.v ~ 0.1
add.sd <- 0.7
prior(tcl) ~ dnorm(1.5, 0.05)
})
model({
ka <- exp(tka + eta.ka)
cl <- exp(tcl + eta.cl)
v <- exp(tv + eta.v)
d/dt(depot) <- -ka * depot
d/dt(center) <- ka * depot - cl / v * center
cp <- center / v
cp ~ add(add.sd)
})
}
rxode2::rxUiPriors(thetaPriorModel)
#> name prior neta1 neta2 lower upper
#> 1 tcl dnorm(1.5, 0.05) NA NA -Inf InfWho honours this. As of this writing, the whole
FOCEi family –
focei/foce/focep/fo/foi,
the mu-referenced mfoce*/IRLS ifoce* variants,
laplace, agq, and their *f
fast-path/quadrature siblings – evaluates a prior on both a population
parameter and an omega element, with a real analytic gradient for both
under foceiControl(fast=TRUE) (nlmixr2/nlmixr2est#929,
#931). A
method that has not been extended yet (SAEM, the imp
family, npag/npb,
emvi/fbvi, vae) refuses a prior
outright rather than silently ignoring it – you get a clear error naming
the parameter and the method, not a fit that quietly did something other
than what the model says.
Three conventions, picked for you
The one part of this that trips people up: there is not one universal “Bayesian prior” density. NONMEM, Monolix, and a textbook Bayesian treatment each put a prior on an omega element a genuinely different way, and the three give different numbers for the identical syntax:
-
general– a textbook Bayesian density for whateverlotrireports:dnorm()/stdNormal()/dcauchy()on a population parameter (bounds-truncated to a half-normal/half-Cauchy when the parameter itself is bounded), the jointmultiNormal()block, and a textbook inverse-Wishart on an omega block. -
nwpri– NONMEM’s own$PRIOR NWPRIconvention (NONMEM7 Technical Guide eq. 1.154-1.171). A population parameter’s prior is the same multivariate normalgeneraluses; an omega block’s prior isinvWishart(nu)degrees of freedom, but evaluated with NONMEM’s own parameterization, not the textbook inverse-Wishart withnusubstituted in – the two give different numbers for the samenu. TheinvWishart(nu)can name a single eta (prior(eta.cl) ~ invWishart(nu)) or a whole correlated block at once (prior(eta.cl, eta.v) ~ invWishart(nu), one degrees-of-freedom for the joint 2x2) – the block form is the more common one in practice, since a real omega is rarely diagonal. -
tnpri– the assumption Monolix’s Bayesian estimation makes, and that NONMEM’s own estimation makes for omega: every estimated parameter, omega included, is jointly normal. A prior on an omega element sits directly on its raw value (om.eta.cl ~ ...), never as degrees of freedom. A singleom.eta.cl ~ ...line is common with tools that only ever put a prior on one thing at a time, but NONMEM’s own TNPRI usage is typically a single joint covariance matrix spanning every theta and every raw omega element together (tka ~ 0.1; tcl ~ c(0.01, 1); ...; om.eta.cl ~ c(...)) – see below.
By default you do not choose one of these – the model already says which convention it means, in how the prior is written, and nlmixr2 reads that off automatically:
| what the model writes | method used | why |
|---|---|---|
| a normal/Cauchy prior on a population parameter only | general |
all three agree here, so it does not matter |
invWishart(nu) on a single eta or a correlated
block |
nwpri |
that is NONMEM’s own mechanism for an omega prior |
a normal directly on an omega element
(om.eta.cl ~ ...) |
tnpri |
that is the raw-omega-value convention |
| a joint normal spanning thetas and raw omega elements together | general |
a plain joint normal on raw values is exactly what
general’s own textbook density already is, whether or not
the model calls it “TNPRI” |
| a Cauchy prior anywhere | general |
neither nwpri nor tnpri has a Cauchy
analogue |
That third row is worth pausing on: a joint theta+omega block reads
as general, not tnpri, even though it is the
shape NONMEM users write for a TNPRI prior. This is not a
misclassification – general’s own textbook density for a
normal prior directly on an omega element is the same formula
tnpri uses (they only disagree on
invWishart(), which tnpri refuses and
general reads as a textbook inverse-Wishart).
Auto-detection landing on general here gives an identical
objective and gradient to priorMethod = "tnpri" on the same
model – the worked example below checks this explicitly.
lotri itself refuses to mix invWishart()
and a raw-omega normal in the same model (“these are alternatives, not
additions”), so the ambiguous case – a model that could mean either
NONMEM’s or Monolix’s convention at once – simply cannot be written.
Guessing wrong would silently fit the wrong penalty (the same
invWishart(4) means a different number under
general and nwpri), which is why
auto-detection reads the syntax rather than assuming.
Worked example
We reuse the theophylline one-compartment model and data from the imp/impmap/qrpem article, fit once with no prior at all as a baseline:
baseModel <- function() {
ini({
tka <- 0.45
tcl <- 1
tv <- 3.45
eta.ka ~ 0.6
eta.cl ~ 0.3
eta.v ~ 0.1
add.sd <- 0.7
})
model({
ka <- exp(tka + eta.ka)
cl <- exp(tcl + eta.cl)
v <- exp(tv + eta.v)
d/dt(depot) <- -ka * depot
d/dt(center) <- ka * depot - cl / v * center
cp <- center / v
cp ~ add(add.sd)
})
}
fitBase := nlmixr2(baseModel, nlmixr2data::theo_sd, est = "focei",
control = foceiControl(print = 0L))
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
c(tcl = unname(fitBase$theta["tcl"]),
`var(eta.cl)` = unname(fitBase$omega["eta.cl", "eta.cl"]),
`add.sd` = unname(fitBase$theta["add.sd"]))
#> tcl var(eta.cl) add.sd
#> 1.00968290 0.07042478 0.69409689A prior on a population parameter
Say we have outside information that clearance is closer to
exp(1.5) ~= 4.5 L/h than theophylline’s own sparse data
alone would suggest, and we are fairly confident in it
(sd = 0.05 on the log scale):
thetaModel <- baseModel |> ini(prior(tcl) ~ dnorm(1.5, 0.05))
fitTheta := nlmixr2(thetaModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(print = 0L))
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
c(`no prior` = unname(fitBase$theta["tcl"]),
`with prior` = unname(fitTheta$theta["tcl"]))
#> no prior with prior
#> 1.009683 1.452560The estimate moves from the unconstrained MLE (1.01) toward the prior
mean (1.5), landing between the two – exactly the balance a penalized
fit is supposed to strike. foceiControl(fast = TRUE)
(foceif) stayed on:
fitTheta$env$nAnalyticGradDirect is 24, confirming the
analytic outer gradient (with its own d/dtheta log p(theta)
term) ran, not a finite-difference fallback.
A prior on omega – tnpri
Writing the prior directly on eta.cl’s own variance is
the Monolix/NONMEM-own- estimation (tnpri) convention. Say
we believe the between-subject variability on clearance is closer to 0.2
than the unconstrained fit’s own 0.07:
tnpriModel <- baseModel |> ini(prior(eta.cl) ~ dnorm(0.2, 0.02))
fitTnpri := nlmixr2(tnpriModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(print = 0L))
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
c(`no prior` = unname(fitBase$omega["eta.cl", "eta.cl"]),
`tnpri prior` = unname(fitTnpri$omega["eta.cl", "eta.cl"]))
#> no prior tnpri prior
#> 0.07042478 0.19310763The common NONMEM shape – a joint theta+omega covariance
A single om.eta.cl ~ ... line is not actually how most
NONMEM TNPRI priors look. NONMEM typically inputs one joint covariance
matrix spanning every population parameter and every
raw omega value together, because the whole point of the convention is
that thetas and omegas are jointly normal. lotri’s matrix
shorthand extends past theta names to om.-prefixed omega
names in the same block, so the same name + name ~ c(...)
syntax used for correlated thetas covers this directly – here a prior
linking tcl and eta.cl’s own variance, both
drawn from the SAME joint normal:
tnpriJointModel <- function() {
ini({
tka <- 0.45
tcl <- 1
tv <- 3.45
eta.ka ~ 0.6
eta.cl ~ 0.3
eta.v ~ 0.1
add.sd <- 0.7
tcl + om.eta.cl ~ c(0.01,
0.002, 0.005)
})
model({
ka <- exp(tka + eta.ka)
cl <- exp(tcl + eta.cl)
v <- exp(tv + eta.v)
d/dt(depot) <- -ka * depot
d/dt(center) <- ka * depot - cl / v * center
cp <- center / v
cp ~ add(add.sd)
})
}
fitTnpriJoint := nlmixr2(tnpriJointModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(print = 0L))
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
rxode2::rxUiPriors(tnpriJointModel)
#> name prior
#> 1 tcl multiNormal(c(1, 0.3), lotri(tcl + om.eta.cl ~ c(0.01, 0.002, 0.005)))
#> neta1 neta2 lower upper
#> 1 NA NA -Inf InfThe single row above (multiNormal(...), anchored under
the name tcl) is what a joint block looks like once
lotri has built it – tcl and
om.eta.cl are no longer two separate priors, they are one
bivariate normal. .nlmixr2PriorMethod() reads this as
general rather than tnpri (the row’s
neta1/neta2 are NA because the
prior is anchored to tcl, a population parameter, not to
the omega element alone) – but since a plain joint normal on raw values
is exactly what general’s own textbook density already
implements, this is a difference in label, not in the number
the fit produces:
fitTnpriJointExplicit := nlmixr2(tnpriJointModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(priorMethod = "tnpri", print = 0L))
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
c(`auto objective` = fitTnpriJoint$objective,
`explicit-tnpri objective` = fitTnpriJointExplicit$objective)
#> auto objective explicit-tnpri objective
#> 116.4363 116.4363Single-parameter tools that only ever write one
om.eta.cl ~ ... prior at a time are unaffected by any of
this – that case still auto-detects as tnpri directly (the
worked example above), since there both general and
tnpri agree just the same and the row genuinely is anchored
to the omega element.
A prior on omega – nwpri
invWishart(nu) degrees of freedom on the same block
instead reads as NONMEM’s own $PRIOR NWPRI. The scale
matrix (NONMEM’s $OMEGAP) is the block’s own
ini() value – here 0.3, the diagonal we wrote
above – so nu alone controls how strongly the prior pulls
toward that value:
nwpriModel <- baseModel |> ini(prior(eta.cl) ~ invWishart(4))
fitNwpri := nlmixr2(nwpriModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(print = 0L))
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
c(`no prior` = unname(fitBase$omega["eta.cl", "eta.cl"]),
`nwpri prior (nu=4)` = unname(fitNwpri$omega["eta.cl", "eta.cl"]))
#> no prior nwpri prior (nu=4)
#> 0.07042478 0.13689455The more common shape – a correlated block
A single-eta invWishart(nu) is the simplest case to
show, but a real omega is rarely diagonal, and NONMEM’s
$PRIOR NWPRI is ordinarily written on a whole correlated
block at once. prior(eta1, eta2) ~ invWishart(nu) gives
just the degrees of freedom – the scale matrix ($OMEGAP) is
still the block’s own ini() covariance, exactly as in the
single-eta case, just now a 2x2:
nwpriBlockModel <- function() {
ini({
tka <- 0.45
tcl <- 1
tv <- 3.45
eta.ka ~ 0.6
eta.cl + eta.v ~ c(0.3,
0.01, 0.1)
add.sd <- 0.7
prior(eta.cl, eta.v) ~ invWishart(4)
})
model({
ka <- exp(tka + eta.ka)
cl <- exp(tcl + eta.cl)
v <- exp(tv + eta.v)
d/dt(depot) <- -ka * depot
d/dt(center) <- ka * depot - cl / v * center
cp <- center / v
cp ~ add(add.sd)
})
}
fitNwpriBlock := nlmixr2(nwpriBlockModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(print = 0L))
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
rxode2::rxUiPriors(nwpriBlockModel)
#> name prior neta1 neta2 lower upper
#> 1 eta.cl invWishart(4) 2 2 -Inf InfOne invWishart(4) row covers the whole
eta.cl/eta.v block – lotri does
not expand it into one row per element, and
rxPriorBuildSpec() builds one joint term for it, so this
costs nothing extra over the single-eta case on either the objective or
the (still analytic) gradient side.
Choosing nu – Karlsson’s degrees-of-freedom rule
nu = 4 above was picked to keep the example short, but
it is not arbitrary in practice. Bauer’s NONMEM 7 Technical
Guide (Appendix G, “Degrees of Freedom Assessment for OMEGA
Priors”) attributes to Mats Karlsson a simple way to turn a
previous analysis’s own precision into a degrees-of-freedom
input: for an omega diagonal element with point estimate
omega and standard error SE(omega) from that
earlier (unconstrained) fit,
nu = 2 * (omega / SE(omega))^2
A well-estimated omega (small SE(omega) relative to
omega itself) earns a large nu – a confident
prior. A poorly-estimated one earns a small nu, so the
prior does not pretend to know more than the data that produced it did.
For example, if a previous unconstrained fit reported
var(eta.cl) = 0.3 with SE = 0.15, Karlsson’s
rule gives nu = 2 * (0.3 / 0.15)^2 = 8:
omegaEst <- 0.3
omegaSe <- 0.15
nu <- 2 * (omegaEst / omegaSe)^2
nu
#> [1] 8This is exactly what
.nlmixr2BuildPriorSpec()/rxPriorBuildSpec()
never computes for you: nu is a judgment call about how
much to trust a previous analysis, made once before the model
is written, not something the current fit can derive from its own
(not-yet-run) data. The rule generalizes to a correlated block the same
way NONMEM practice does – apply it to each diagonal element the block
covers (here, both eta.cl and eta.v’s own
variances) and use the smaller of the two, since
$OMEGAPD/invWishart(nu) is a single
degrees-of-freedom value for the whole block, and the more conservative
(less confident) of the two is the one that should govern how strongly
the joint prior pulls.
tnpri and nwpri pull
var(eta.cl) toward two different targets (0.2
vs. the ini() value 0.3) by two different
mechanisms, and both engaged the analytic gradient the same way the
theta prior did:
c(tnpri = fitTnpri$env$nAnalyticGradDirect,
nwpri = fitNwpri$env$nAnalyticGradDirect)
#> tnpri nwpri
#> 19 14That is the point of foceiPriorOmegaGradAdd() under the
hood: the natural-scale d/d(omega) log p(omega) gradient
the shared kernel returns is chain-ruled into FOCEi’s own Cholesky
parameterization the same way regardless of which of the three
densities produced it, reusing the derivative data FOCEi’s own
(non-prior) omega gradient already computes – so
nwpri/tnpri cost nothing extra over
general on the gradient side.
Choosing a method explicitly –
foceiControl(priorMethod=)
foceiControl() (and every sibling control constructor
built on it – foceControl(), focepControl(),
laplaceControl(), agqControl(), …) takes a
priorMethod argument:
c("auto", "general", "nwpri", "tnpri"), defaulting to
"auto" – the table above. You only need to touch this when
you want to force a specific convention, e.g. reproducing a NONMEM
$PRIOR NWPRI run’s numbers exactly, or asserting in code
review that a model is read the way you intend rather than trusting the
auto-detected reading.
An explicit choice the model’s own ini({}) cannot
express under that convention errors before estimation
starts – tnpri puts a prior directly on an omega
element’s raw value and has no degrees-of-freedom reading, so asking for
tnpri on the nwpriModel above (which writes
invWishart(4)) fails immediately, not partway through a
fit:
nlmixr2(nwpriModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(priorMethod = "tnpri", print = 0L))
#> Error:
#> ! cannot evaluate the prior on 'om.eta.cl' (invWishart(4)): 'invWishart()' is not part of the TNPRI method; use method="nwpri" for an omega degrees-of-freedom priorAsking for the method auto-detection would already have picked is a no-op – useful for pinning the choice in a script without changing behavior if the model is later edited:
fitNwpriExplicit := nlmixr2(nwpriModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(priorMethod = "nwpri", print = 0L))
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
identical(unname(fitNwpri$objective), unname(fitNwpriExplicit$objective))
#> [1] TRUE
general – everything else, including a bounded
(half-Cauchy) prior
A Cauchy prior forces general, since neither
nwpri nor tnpri has a Cauchy analogue. Bounds
matter here: add.sd is declared c(0, 0.7)
(lower bound 0), so dcauchy(0, 1) is truncated to a
half-Cauchy, not evaluated as if add.sd
could be negative:
generalModel <- function() {
ini({
tka <- 0.45
tcl <- 1
tv <- 3.45
eta.ka ~ 0.6
eta.cl ~ 0.3
eta.v ~ 0.1
add.sd <- c(0, 0.7)
prior(add.sd) ~ dcauchy(0, 1)
})
model({
ka <- exp(tka + eta.ka)
cl <- exp(tcl + eta.cl)
v <- exp(tv + eta.v)
d/dt(depot) <- -ka * depot
d/dt(center) <- ka * depot - cl / v * center
cp <- center / v
cp ~ add(add.sd)
})
}
fitGeneral := nlmixr2(generalModel, nlmixr2data::theo_sd, est = "foceif",
control = foceiControl(print = 0L))
#> calculating covariance matrix
#> [====|====|====|====|====|====|====|====|====|====] 0:00:00
#> done
c(`no prior` = unname(fitBase$theta["add.sd"]),
`half-Cauchy(0,1) prior` = unname(fitGeneral$theta["add.sd"]))
#> no prior half-Cauchy(0,1) prior
#> 0.6940969 0.6922294The pull is small here – add.sd is well informed by
theophylline’s own dense concentration data, and a Cauchy(0, 1) is a
fairly diffuse belief next to that – which is itself worth seeing: a
prior does not overwhelm good data, it only matters where the data is
genuinely uninformative or where you have asked it to be strong (as the
tighter sd = 0.05/sd = 0.02 priors above
were).
How it works
Every FOCEi-family fit computes the same objective,
-2*log-likelihood, whether or not a model has a prior. When
it does, the fit adds one more term:
objective = -2*log p(y | theta, Omega) - 2*log p(theta, Omega)
evaluated by the shared kernel
rxode2::rxPriorLogDensity() builds (nlmixr2/rxode2#1270)
– the same math regardless of estimation method, only the wiring
in differs by method.
The gradient is analytic, not finite-differenced,
matching how the rest of FOCEi works. A prior on a population parameter
folds a d/dtheta log p(theta) term straight into the same
accumulator the outer finite-difference substitution already uses. A
prior on an omega element is chain-ruled through FOCEi’s own
chol(Omega^-1) parameterization: with
A = Omega^-1 and Omega = A^-1,
d(Omega)/d(theta_k) = -Omega * dA/d(theta_k) * Omega (the
standard matrix-inverse VJP), so d(log p(omega))/d(theta_k)
is a single trace against the same
d(Omega^-1)/d(theta_k) derivatives FOCEi’s own, non-prior
omega gradient already computes – no extra machinery, and the identical
formula whichever of
general/nwpri/tnpri built the
term. The one case this cannot attribute a gradient to – a population
parameter a mu-referenced family has profiled out of the outer
optimization entirely, so there is no analytic theta_k to
fold into – is detected once at fit setup, and that specific fit falls
back to finite differences rather than silently under-counting; every
other fit keeps the analytic gradient throughout.
References
- Gelman A, Carlin JB, Stern HS, Dunson DB, Vehtari A, Rubin DB.
Bayesian Data Analysis, 3rd ed. – the textbook conventions
generalimplements. - Bauer RJ. NONMEM 7 Technical Guide, eq. 1.154-1.171
(
$PRIOR NWPRI) – thenwpriconvention, including thed_W = rho + n + 1degrees-of-freedom relationship that makes it differ from the textbook inverse-Wishart. - Bauer RJ. NONMEM 7 Technical Guide, Appendix G, “Degrees of
Freedom Assessment for OMEGA Priors” – Mats Karlsson’s
nu = 2*(omega/SE(omega))^2heuristic for choosing aninvWishart(nu)degrees of freedom from a previous analysis’s own omega estimate and standard error. - Monolix documentation, “Bayesian estimation of the population
parameters” – the joint-normal-on-everything-including-omega assumption
tnpriimplements. - Murray I. Differentiation of the Cholesky decomposition,
2016 – background for the
chol(Omega^-1)chain rule the omega-prior gradient uses.