This is for people changing
nlmixr2nn, not people using it. For the latter, see
vignette("nlmixr2nn").
Most of what follows is one idea repeated: a neural network inside an ODE has to be plumbed through machinery that was not built for it, and almost every way of getting that plumbing wrong produces a wrong number rather than an error. Four separate bugs of exactly that shape have been fixed here. Each trained, converged, and passed the whole test suite while computing something other than what it claimed. The invariants below are the ones that turned out to matter, and each is named with the failure it prevents.
nn() at parse time R/nnUdf.R, R/nnInit.R
| draws weights with R's RNG, emits nn<K>(id, ...)
v
.nnAdopt() at model assembly R/nnAdopt.R
| weights -> rxForcedPars, shapes -> sticky nnMeta
v
solve .nnRehydrate() binds the C registry, per solve
or
fit .nnRun() owns the registry for the duration
|- augmented model built R/nnEst.R
|- inner fit (estimator's own model)
|- cotangent captured src/nlmixr2nnContrib.c
|- weight gradient assembled + torch step
v
trained weights -> rxForcedPars on the fit's ui
nn() is an rxode2::rxUdfUi() user-defined
function. It expands to a compiled call and a declaration of the
weights:
library(nlmixr2nn)
mod <- function() {
model({
d/dt(centr) <- -nn(centr, nHidden = 3) * centr
})
}
rxode2::rxSetSeed(1)
ui <- rxode2::rxode2(mod)
#> ℹ parameter labels from comments are typically ignored in non-interactive mode
#> ℹ Need to run with the source intact to parse comments
cat(paste(ui$lstChr, collapse = "\n"))
#> rx_nnw0_ <- rxnnW1_0_1_1 + rxnnW1_0_2_1 + rxnnW1_0_3_1 + rxnnB1_0_1 + rxnnB1_0_2 + rxnnB1_0_3 + rxnnW2_0_1 + rxnnW2_0_2 + rxnnW2_0_3 + rxnnB2_0
#> d/dt(centr) <- -nn1(0, centr) * centrTwo decisions are visible there.
The weights are covariates, not param()
thetas. Thetas get theta-expanded and mangled during FOCEi
model assembly, and they enter mu-referencing. Covariates are contiguous
in par_ptr, do neither, and can be supplied by the model
rather than the data. The dummy rx_nnw0_ line exists only
so rxode2’s covariate detection sees the names.
The weights are drawn when the model is parsed, with
R’s RNG, in R/nnInit.R, drawn with rxode2’s threefry
generator (rxnorm/rxunif) inside
rxWithSeed(), which restores both the R stream and the
rxode2 seed. So rxSetSeed() reproduces a network – and does
so under threads, which R’s RNG cannot promise – and a parsed model is
solvable immediately without libtorch. torch is loaded from
these values rather than generating its own, which is what makes a whole
fit reproducible under a single seed.
The draw is isolated: building a model does not advance the caller’s stream. Adding a network to a model must not shift the draws in the rest of someone’s script.
rxUdfUi() can return only replace,
before, after, iniDf,
uiUseData and uiUseMv — code, never a value.
And once a ui is compressed, rxUiDecompress() returns a
fresh environment on every call, so an in-place assignment made
after assembly is invisible to whoever holds the model.
That is why rxRegisterUiAssembled() exists (added to
rxode2 for this): .nnAdopt() runs while the ui is still a
mutable environment, and moves the drawn weights onto it as
rxForcedPars plus a sticky nnMeta.
Not
uiUseData. It is tempting to think a UDF could see the dataset at fit time viauiUseData = TRUEand scale its inputs accordingly. It cannot: that re-parse operates on the already-expanded model text, wherenn(centr)has becomenn1(0, centr), so the UDF never runs again. Verified with a probe UDF.
The compiled evaluator reads its weights from a contiguous block of
the solve parameter vector, starting at an offset registered beforehand
with nnSetMeta().
Every model in the pipeline puts that block somewhere different. For one model:
base ui weights at 1..13 (base 0)
saemModel weights at 2..14 (base 1)
foceiModel$inner weights at 3..15 (base 2)
augmented model different again
Registering the wrong one does not fail. The
evaluator reads a different stretch of the parameter vector and computes
a different network, and the training loop then optimizes that
— self-consistently, and wrongly. The symptom, when it was finally
measured, was a fit whose own g differed from the network
at its trained weights by more than the entire range of
g.
So:
Invariant. The offset must be resolved against the model that is actually about to be solved — never reconstructed from a guess at the layout.
.nnEstSolveParams(ui, est) answers this per estimator,
and .nnSetNetBases() applies it. The FOCEi posthoc that
non-FOCEi estimators use to capture their cotangents is itself a FOCEi
fit, so it needs FOCEi’s offset for its duration and the round’s
restored afterwards.
For an ordinary solve this is handled by the ui-prep hook, which
receives the solve model — the reason rxode2’s
rxRegisterUiPrep() gained a second argument.
src/nlmixr2nnContrib.c registers a bundle with
nlmixr2est whose per-observation hook records d(LL)/d(f) —
the exact score for whatever residual model the user wrote. Two
constraints, both learned the hard way.
The hook runs inside nlmixr2est’s OpenMP region. It must not allocate, must not call into R, and must not resize anything. It used to grow its own store with
R_chk_realloc()from a worker thread; that is a data race on the buffer, a use-after-free for every other thread mid-write, and an R API call off the main thread. It segfaulted as soon as the path was genuinely exercised.
The store is now sized once from R, before the fit, from the subject
count and the maximum observations per subject. The hook only writes,
and distinct (id, k) land in distinct slots. The flat index
is id * stride + k, and the same stride must be
used on both sides — it was once a 1024 literal in
C and in three places in R, which silently dropped any subject with
more.
The reported score is on the TRANSFORMED scale. Transform-both-sides is applied in rxode2’s
rx_pred_statement, so by the time the hook seesf, it is already transformed. The augmented solve’s sensitivities are on the natural scale. Chaining them without the transformation’s own derivative leaves the gradient short by that factor at every observation.
R/nnEndpoint.R supplies that derivative for every level
in rxode2’s table. Note that a combined level is a composition:
"logit + yeoJohnson" is yeoJohnson(logit(y)),
so the outer derivative is evaluated at logit(y), not at
y. Multiplying two derivatives both taken at y
is the plausible-looking wrong answer.
| endpoint | score |
|---|---|
untransformed, add()/prop() |
closed-form Gaussian |
| anything else | exact, captured from the hook |
This is a decision, not a stopgap, and it is worth not re-litigating. The exact score is evaluated at the inner fit’s prediction while the sensitivities it multiplies come from the augmented solve, and those two differ by ~4.5e-04 at identical EBEs. The closed form takes both from the same solve, so where it applies it is the more self-consistent of the two. The exact score earns its place on the endpoints the closed form cannot express.
When capture fails, falling back to the closed form is only permitted
where the closed form is genuinely that endpoint’s score —
.nnCanUseClosedForm(). Otherwise it errors, because a
silent fallback on a transformed endpoint trains on the wrong
gradient.
nnAugmentModel() builds the forward-sensitivity model:
for each weight w_j, a variational state per model
state,
d/dt(s_ij) = sum_k F_X[i,k] s_kj + (dR_i/dg)(dg/dw_j)
where F_X is the model Jacobian (rxode2 forms it,
chaining the registered nn<K>_d* input derivatives),
rx_drdg_<state>_ is emitted here, and
dg/dw_j comes from the plain-C nnWeightGrad at
solve time. The prediction’s sensitivity is chained through the states
as rx_predsw_j = sum_s d(pred)/d(s) * rx_sw_s.
This costs one extra ODE state per weight per model state, which is the current ceiling on network size.
Augmentation does not perturb the base trajectory — measured at ~5e-09 relative, which matters because the closed-form score is computed from the augmented solve.
The modules live in C++ so compiled solve code reaches them without
an R callback. The backend is the optimizer and the weight
container, not the autodiff: the in-solve dg/dw uses
the plain-C nnWeightGrad, which is thread-safe and keeps
the backend out of the ODE hot loop. nnTorchSetGrad() +
nnTorchStep() inject an externally computed gradient and
take an Adam/SGD step.
Because that is all a fit asks of it, the backend is interchangeable,
and ./configure picks one at install time:
src/nnTorch.cpp |
src/nnBuiltin.cpp |
|
|---|---|---|
| selected when | the ‘torch’ package has its libtorch binaries | otherwise, and by default on Windows |
| optimizer | libtorch’s Adam/SGD | the same Adam/SGD, written out |
| autograd | real | the analytic VJP, from nnWeightGradWC |
_nlmixr2nn_nnBackend() reports which one is compiled in;
only one ever is. The fallback is why the package installs where
libtorch cannot be linked – Windows, where torch’s libtorch is
MSVC-built and Rtools is MinGW, and CRAN, which will not fetch a 2GB
library at build time.
The analytic weight gradient is checked against torch autograd across
every activation and input dimension, which is what licenses using the C
path. That check needs a libtorch build to mean anything – under the
builtin backend both sides of it are the same routine – so
test-nn-wgrad.R skips unless libtorch is linked, and CI
keeps one row that links it.
A fitted model carries its trained weights in
rxForcedPars() and its shapes in a sticky
nnMeta, so it reloads in a fresh session with no external
state. The transient C registry is rebuilt by the ui-prep hook,
resolving the offset by name against the solve model.
A model that carries its own weights is self-describing, and wins over the loader: in a fresh session the loader buffer is empty, so letting it override would silently zero the network. During training that is inverted — the loop owns the weights and injects them every round.
A short list, each of which cost real time:
nn<K>
family. Compiled model code reaches them through
R_RegisterCCallable(), so it looks internal. But rxode2’s
symengine renderer resolves a model function by name over the search
path when it builds derivatives, so an unexported nn1
breaks every Jacobian. nnWg<K> is genuinely internal
because it only ever appears in generated text handed to C.env = new.env(parent = asNamespace()). Package
internals are visible there and not to a user, which hid the above
completely. test-nn-userenv.R runs in a child of
globalenv() and in a callr subprocess for
exactly this reason.rxForcedPars as “supplied”. It
is applied when gpars are written, after parameter resolution
has decided whether every parameter has a source. rxode2 gained an
explicit change so a forced parameter counts as supplied.nlmixr2est::nlmSolveR does not exist (it is internal); the
error was swallowed by a tryCatch returning
NA, so an entire code path silently never ran.R/nnWeightStep.R hands torch a gradient of
\(-\log L\); R/nnPopFit.R
optimizes \(-2\log L\). The weight
penalty (below) is defined on the \(-2\log
L\) scale, so adding the same vector at both would make one
l2 mean two different things inside a single fit, with
nothing in the output to say so.
.nnAddPen(g, w, pen, scale) takes the scale explicitly and
test-nn-penalty.R asserts the invariant
2 * .nnAddPen(g, w, pen, 1L) == .nnAddPen(2 * g, w, pen, 2L).sum(W1^2) is not L2 here. Input
scaling is folded into the first-layer weights and then discarded
(.nnRescaleW1), so an input of typical magnitude 500
carries a W1 column ~500x smaller at equal functional
effect. A naive weight-decay term would penalize it 250000x less. The
penalty is on the effective weight s_k * W1[j,k],
which is why the spec carries the input scales.nnWeightGrad outside a solve returns zeros, not
an error. It reads the live solve’s par_ptr.
Anything evaluating a network outside a solve — the curvature penalty,
nnEval() — must use the explicit-weight entry points
_nlmixr2nn_nnForwardW /
_nlmixr2nn_nnWeightGradW. Using the registry form would
make the whole penalty vanish silently.R/nnPenalty.R. Two terms, both opt-in
(nnControl(l2 = , smooth = ), both 0 by
default):
\[P = -2\log L + \lambda_{2}\sum W_{\text{eff}}^2 + \lambda_{s}\sum C^2\]
C is the second difference of the network along one
input’s marginal curve, that input swept over its observed 10th-90th
percentile with the others pinned at their center. Sweeping each input’s
own range is what makes smooth scale-free without
extra normalization: \(C = h^2
f''(x)\) and \(h\) is
proportional to the input’s spread, so C is curvature with
respect to the standardized input.
Inputs with no resolvable range — a latent eta, whose EBEs move every
round, or a compound expression such as nn(centr/Vc) — are
held fixed rather than swept.
The gradient reuses the analytic weight Jacobian: \(\partial C/\partial w = J(x_{i+1}) - 2J(x_i) + J(x_{i-1})\), so there is no new autodiff and no new C code.
nnControl(l2 = 0, smooth = 0) is a strict no-op: the
spec is NULL, the gradient vector is returned untouched,
and the fit is bit-identical to an unregularized one.
Each term carries a normalizer, frozen at the first evaluation that knows an objective (the population pre-fit’s own objective, or the first inner fit in the round loop):
\[k_W = \frac{|\text{objf}|}{\sum W_{\text{eff}}^2}, \qquad k_C = \frac{|\text{objf}|}{\sum C^2}\]
so that each term starts at exactly \(\lambda\) times the objective. They are frozen rather than recomputed, so \(P\) stays a fixed objective a gradient can descend rather than a moving target; until they are known the penalty is inactive and contributes exactly zero.
The two terms are normalized separately because their raw scales differ by orders of magnitude — measured at ~180× — so one shared normalizer would leave the mix between them as arbitrary as the magnitude was.
This is not cosmetic. Under the earlier absolute penalty the E5.5
smoke matrix broke at l2 = 0.1 (the
saem/lnorm cell’s RMSE rose instead of
falling) while visible shrinkage on an over-fitting fit needed
l2 = 1 — the safe range and the effective range did not
overlap. After normalizing, the whole matrix passes to
l2 = 0.3 and the objective-optimal value is
0.05.
.nnActCode (R),
nnAct/nnActD/nnActD2 in
src/nnEval.c, and MLPImpl in
src/nnTorch.cpp. The three must agree;
test-nn-wgrad.R and test-nn-general.R check
that they do.R/nnGen.R with tools/genNn.R and raise
NN_KMAX..nnEstSolveParams() which model it solves, and
.nnInterleaveKnob()/.nnNonResuming whether its
partial fit resumes..nnInitDraw() and the weight
layout already take H as a vector; the compiled evaluators
do not. nn() refuses a vector nHidden
explicitly so that boundary is visible.The tests are organised around the invariants rather than the functions:
| file | what it pins |
|---|---|
test-nn-consistency.R |
the network evaluated == the network the weights describe |
test-nn-reproducible.R |
a fit is reproducible under a fixed seed |
test-nn-userenv.R |
it works with only the package attached |
test-nn-endpoint.R |
the transformation derivative, vs rxode2’s own transform |
test-nn-llgrad.R |
the assembled gradient, vs a finite difference of the likelihood |
test-nn-cotangent-agree.R |
the two score sources, compared at the gradient |
test-nn-zeroboiler.R |
no helper call is ever required |