admixr2 0.4.1
New features
-
sigdignow controls the fit, not just the output tables – and it is opt-in. ThesigdigandrxControlarguments were documented as solver controls, but the object they built only ever reached nlmixr2’s post-fit table solves: every optimizer solve ran at rxode2’s own default tolerances, so settingsigdigchanged nothing about the fit. It is now passed torxode2::rxSolve()’s ownsigdigargument at every solve the estimators issue.The default is
sigdig = NULL, so default fit results are unchanged.NULLmeans “leave rxode2’s own tolerances alone”, which is exactly the numerics every admixr2 fit had before. It is the default because a looser solve is not free: the estimators finite-difference these solves with steps of the same order –grad_h1e-4,cov_h1e-3,cov_h_outer~2.5e-3 – while rxode2 5.1.5 mapssigdig = 4tortol = 1e-4. Differencing a solution whose own relative noise is 1e-4 with a 1e-4 step returns noise, and it surfaces as a moved objective and an indefinite covariance Hessian (everySEreportedNA) rather than as an error. Turning that on by default would have changed the numerics of every existing script silently, for a knob that looked like table formatting before this release.Set explicitly, it is the lever for trading solver accuracy against speed. Measured on a 1-cmt oral ODE model with two studies, at a fixed iteration count,
sigdig = 4makes adfo 4.8x faster,admc1.3x, andadghunchanged (its batched quadrature solve is not integration-bound); the objective moves by 5e-09 relative and thecovMethod = "r"standard errors are unchanged to four significant figures. It is most worthwhile where the gradient is fully analytic and nothing differences the solve, which after this release isadfoControl(grad = "analytical"). Elsewhere, compare the objective and the standard errors againstNULLbefore relying on it.Passing the digits rather than re-deriving tolerances keeps the mapping rxode2’s business, which matters because rxode2 has changed it between releases:
sigdig = 4isatol = rtol = 5e-07on rxode2 5.1.4 butrtol = 1e-04on 5.1.5.sigdig = NULLis the one setting whose meaning does not move under an upgrade – and no singlesigdigvalue reproduces rxode2’s defaults anyway, since they are asymmetric (atol1e-8 againstrtol1e-6) while thesigdigmap is one-dimensional. Table formatting is unaffected either way:sigdigTablefalls back to 4 whensigdigisNULL.plot()now solves at the tolerance the fit used, so the diagnostic panels describe the same integration the objective was minimised on.datagen()deliberately does not – it generates the reference, and integrates at rxode2’s own tolerances regardless. -
adfo differentiates its structural thetas analytically, and
grad = "analytical"(LBFGS) is now the default. adfo was the last estimator with a finite-difference component: itsV_pred = J Omega J' + residdepends on a structural theta throughJ, so the gradient needsdJ/dtheta, a second derivative that the first-order sensitivity model does not carry. It therefore finite-differenced the whole objective – differencing a log-determinant and a quadratic form, the noisiest construction in the package..admBuildThetaSens()now emits a second-order cross blockd2f/(d eta d dir)on request, and.adfoGrad()contracts it against the samedNLL/dVmatrix the omega path already uses, so the two cannot drift apart. Against a central difference of the objective the structural gradient is accurate to 2e-07..2e-06, where the finite-difference pass it replaces reached 8e-04..1e-02 (worst casetv, 1.4%).Because the gradient is now exact, LBFGS on it beats the derivative-free BOBYQA that
grad = "none"used, so the default changed.grad = "none"remains available, and any model whose second-order model cannot be built falls back to the previous finite-difference gradient automatically.That default flip changes three more things than the gradient, because
grad != "none"is the switch for all four. Spelled out, since only the first is obvious:- The gradient itself, as above.
-
A box constraint. A gradient fit is confined to
p0 +/- grad_bounds(default 5) on the optimizer scale – a factor of ~148 on the log scale. adfo fits were unbounded before. nloptr reports normal convergence at a box corner, so admixr2 now warns when an estimate finishes on that bound and the model itself declared none;admc/adghhave always run this way and gained the same warning. -
The covariance method.
covMethod = "r"builds its Hessian by forward-differencing the gradient rather than the objective when a gradient is available. That is now gated on the struct-theta gradient being genuinely analytic, not merely ongrad != "none": with an order-1 fallback or a transformed endpoint the gradient is itself a finite difference, and differencing it again produced a singular Hessian and “standard errors are unavailable for this fit”. Those cases keep the objective-FD Hessian that 0.4.0 used. -
Whether a sensitivity model is asked for at all.
.admLoadSensModel()returnsNULLby design for a fixed-effects-only model, an ordinal endpoint, and mixed transformed/untransformed endpoints. Each used to run BOBYQA silently and briefly warned on every fit; they now emit a single plain message saying the gradient is finite-differenced.
The startup line distinguishes the two analytic levels:
Grad: Analyticalmeans the struct thetas come from the second-order block,Analytical (struct FD)means the omega/sigma blocks are analytic and the struct thetas are not. -
linCmt()models are supported at second order, by promotion.linCmt()has no second derivative –rxFromSE()cannot emit the nestedlinCmtBderivative, which is why nlmixr2est refuseslinCmt()outright for its own analytic gradient and covariance. admixr2 instead promotes the model to its explicit ODE form with the exportedrxode2::linToOde()and builds the second-order block from that. The promoted solve reproduces the analyticlinCmt()prediction to 1.8e-08 relative.Only the
order = 2request promotes:admc/adghcontinue to use the fast solved form, which is all their first-order moments need. -
Finite-difference steps measured per parameter (Shi 2021), replacing the fixed scale. Every finite difference in admixr2 took its step from the same heuristic –
pmax(abs(p), 0.1) * h, withha fixed constant. That is a single guess about how much noise the objective carries, applied identically to every parameter. A parameter the objective is flat in and one it is sharp in want different steps, and the right step moves with the ODE tolerance.admixr2 now measures. For a central difference the error is
(h^2/6)|f'''| + eps_f/h, minimised ath* = (3 * eps_f/|f'''|)^(1/3), and the procedure estimates|f'''|from a symmetric third difference taken where that difference stands clear of the noise floor. The noise leveleps_fitself comes from More & Wild’s ECnoise. Applied at both places admixr2 finite-differences the objective: the post-fit covariance Hessian, and the optimizer’s gradient (measured once at the starting values and reused, the mechanism FOCEI’snumericGraduses at its first evaluation).nlmixr2est ships this algorithm as
shi21CentralWrap, but it is not exported and admixr2 makes no:::calls into it, so it is reimplemented here. That also buys the input the upstream route cannot take:eps_fis a real argument, and it is the one that matters (h*scales aseps_f^(1/3)).test-optim-steps-shi.Rscores the reimplementation against the upstream routine as an oracle – the intervals agree to within a factor of 1.15 and the derivatives to10 * eps_f^(2/3).
Changes that can move an existing fit
Several changes in this release alter results for scripts that do not name a new argument. None is a bug fix, so all are listed here rather than below.
-
Finite-difference steps are now MEASURED per parameter, by the Shi (2021) procedure, and this is not optional. Every finite difference admixr2 takes of the objective – the optimizer’s gradient under
grad = "fd", and the post-fit covariance Hessian – previously usedpmax(abs(p), 0.1) * hwith a fixedh(grad_h,cov_h_outer). That is one guess about how much noise the objective carries, applied identically to every parameter, and it is the guess behind the “Hessian not positive definite … try increasingcov_h_outer” advice. The step is now chosen per parameter by probing the objective, with the noise level itself estimated by Moré & Wild’s ECnoise.grad_hremains as the FALLBACK a parameter takes when the measurement cannot be made (a direction the objective is flat in, or a failed noise estimate).The covariance Hessian gets the measured NOISE but not the gradient’s step: a Hessian is a second difference, whose error is
(h^2/12)|f4| + 4*eps_f/h^2(withf4the fourth derivative) and whose optimum scales aseps_f^(1/4), about ten times larger than the first-derivativeeps_f^(1/3). Too fine a step there amplifies noise as4*eps_f/h^2– exactly what tips a marginal Hessian out of positive definiteness. Measured 6x to 385x worse across noise levels from 1e-15 to 1e-7 if the gradient’s step is reused.cov_h_outerSCALES the measured Hessian step rather than merely backing it up, andgrad_his the gradient’s fallback. The distinction matters: the measurement almost always succeeds, so a fallback-onlycov_h_outerwould be inert in practice – and it is the escape hatch the documentation points at (“Hessian not positive definite … try increasingcov_h_outer”). Raising it by 100 still gives a step 100 times larger.Measured against the analytic gradient on the integration model, maximum relative error over all five parameters:
step adirmc inner NLL adfo NLL fixed 1e-6 7.3e-06 9.8e-06 Shi21 2.8e-10 3.9e-08 Standard errors and any
grad = "fd"fit will move. They should move toward the truth, but they will move. gillis REMOVED from all four controls. It selected Gill (1983) step selection, added in this same development cycle and never released. It is removed rather than kept alongside Shi21 because measurement showed it was worse than the fixed step it was meant to improve on – 8.1e-04 (adirmc) and 7.9e-04 (adfo) against the fixed step’s 7.3e-06 and 9.8e-06, at four times the evaluations. The cause is not a mistake in the wiring:nlmixr2Gill83()’s exported wrapper acceptsgillRtol/gillK/gillStep/gillFtoland then hardcodes the defaults in the inner call, so it always assumes an objective accurate to about eight significant digits. That is right for FOCEI’s per-subject objective and wrong for admixr2’s aggregate one.gill = TRUEis now an error.-
Forward finite differences are removed;
grad = "fd"is a CENTRAL difference, andgrad = "cfd"is gone. Central was 10^2 to 10^4 times more accurate at every site measured, and the one solve per parameter that forward differencing saved does not pay for a gradient the optimizer cannot descend. Scripts passinggrad = "cfd"must passgrad = "fd"; scripts passinggrad = "fd"keep working and get the central difference.Every remaining finite difference in the package is now central too. The first pass of this change converted the whole-NLL
grad = "fd"paths and left five forward differences behind, three of which were being handed Shi21’s measured step – and that step minimises the error of a CENTRAL difference,h* = (3 eps_f/|f'''|)^(1/3). The forward optimum is a square root,~2 sqrt(eps_f/|f''|), and far coarser, so a forward difference taken at the central step sits where itseps_f/hnoise term dominates: the measured step made those sites worse, not better. Converted:-
.adfoGrad()Pass 2 (structural thetas) and.adghGrad()’s unpaired-theta block. Both are on the defaultgrad = "analytical"path – they run whenever the order-2 block or the theta-sensitivity columns are unavailable. Both already batch their configurations into onerxSolveper study, so the extra evaluations are extra ROWS, not extra calls; the joint-unit branches do pay per configuration. - the IRMC inner gradient (
adirmcControl(grad = "fd")), now2ninner NLL evaluations againstn+1. - the gradient-differenced Hessian (
use_grad = TRUE) in all three of.adfoCalcCov(),.adghCalcCov()and.admCalcCov(). This one runs only when the gradient is analytic, so it was differencing a smooth exact function with a coarse forward step and then symmetrising away the asymmetry that produced. Reported standard errors will move. - the FD-Jacobian fallback in
.adfoGetMuJBatch()and.adfoGetMuJJoint(), used when no sensitivity model could be built. Unlike the others this J entersV_pred = J Omega J', so the adfo objective itself moves on that path, not just the gradient – toward the truth, but it moves. The batched form absorbs the extra eta rows into the same solve; the joint form cannot batch and costs2*n_etasolves againstn_eta.
-
-
adirmcControl(grad = "fd")now differences withgrad_h, not a hard-coded1e-6. The IRMC inner gradient ignoredgrad_hentirely – it was the one finite difference in the package that could not be tuned, which is why the new measured step selection could not reach it either. It now honours the argument, and takes Shi21’s measured step where one can be measured.adirmcControl()’sgrad_hdefault moves to1e-6to match, so a fit that does not namegrad_his unchanged. The IRMC inner NLL is deterministic given fixed proposals, so it wants a finer step than the sampling estimators, whose1e-4default exists to step over Monte Carlo noise; inheriting that common default would have made everygrad = "fd"adirmc fit converge on a step 100x coarser than the inner loop was tuned for.A script that sets
grad_hexplicitly does change: the value was ignored here before and is applied now, so the objective and estimates can move. Every other estimator already usedgrad_hfor this step, so this also removes a discrepancy – the same control meant something different for adirmc than for the other three. -
adfoControl()’s newgrad = "analytical"default brings thegrad_boundsbox with it. The box constraint (p0 +/- grad_bounds, default 5 on the optimizer scale, a factor of ~148 for a log-scale theta) applies only to gradient-based fits, so under the previousgrad = "none"default an adfo fit was unconstrained. A defaultadfoControl(studies = ...)call is now confined to that box.This is rarely reachable – it takes a starting value off by more than ~148x – and a fit that stops on the box now says so. Set
grad_bounds = Inffor the old behaviour with the new gradient, orgrad = "none"for the old behaviour entirely.The box itself is not an adfo peculiarity:
admControl()(grad = "sens") andadghControl()(grad = "analytical") have always defaulted to a gradient andgrad_bounds = 5, so this aligned adfo with them rather than singling it out. That is why the default stays and the REPORTING is what changed: the bounds notice is now emitted as amessage()as well as awarning(). nlmixr2est muffles conditions insidenlmixr2Est.*, so the warning reachesfit$warnings– whereprint(fit)surfaces it – but neverwarnings(). A batch script that writes coefficients to disk without printing the fit would have seen nothing at all; the message goes to the same channel as the live progress table, which such a script does see.
Bug fixes
-
Parallel restarts (
workers > 1) could fail with “a parallel worker could not read the compiled-model cache” whenever a second R session was using admixr2 at the same time. The compiled-model and sensitivity-model caches are content-addressed files in a shared, persistentrxode2::rxTempDir(), so any other process fitting the same model writes the same path – and a parallel fit’s own daemons are readers of that path by design. The entries were written with a bare in-placesaveRDS(), which publishes the file the instant it opens the connection: it exists at zero bytes and is filled in afterwards. A reader landing in that window is handed a truncated payload, and truncation at any fraction makesreadRDS()fail –file.exists()isTRUEfor every one of them, so the existence check could not screen it out.Cache entries are now published atomically: serialised to a temporary file in the same directory, then
file.rename()d over the target. A reader now sees either the previous complete entry or the new complete entry, never a prefix of one. Measured with a single competing writer, reads of a corrupt entry went from 64.7% to 0, and the affected test file went from1 failure, 2 errorsto clean under a sustained competing publisher.A second, independent cause of the same failure is fixed alongside it: a worker’s own startup could delete the cache entry it was about to read.
library(admixr2)in a daemon loads nlmixr2est, and the installed 6.2.0’s.resetCacheIfNeeded()callsrxode2::rxClean()– which wipes the whole sharedrxTempDir()– whenever its version stamp does not match. That branch never rewrites the stamp, so the mismatch is permanent rather than self-healing, and it fires in every daemon on every fit. Having two nlmixr2est builds in play is enough to trigger it, which is an ordinary state when working against an upstream source tree. Restarts now load admixr2 in every worker before any of them reads the cache, and rebuild the model if that startup cleared it.Two consequences worth knowing. A rename can legitimately be refused while another process holds the entry open (Windows reports “Access is denied”); the cache write then reports a warning and the fit continues from the model it already has, which is correct because the existing entry is by construction a valid payload for that key. And the guarantee is only as strong as the other process’s version – a peer running admixr2 < 0.4.1 still writes in place.
adfo could report
NAfor every standard error on a fit that converged normally. The driver decided whether to build the covariance Hessian by forward-differencing the gradient from the sensitivity model’s shape alone, while.adfoGrad()re-derives that decision at run time with stricter requirements – every study’s cacheddJpresent, every theta’s direction resolvable. When they disagreed, the gradient was itself finite-differenced and the Hessian then finite-differenced that, which is exactly the nested FD the gate exists to prevent..adfoGrad()now reports what it actually did and the driver believes that, falling back to the NLL-FD Hessian otherwise.A joint (same-subject) study normalised before the model was known kept
NULLblock outputs. Only the driver’s pass carries the endpoint name, and the short-circuit for an already-normalised study skipped joint units altogether, so each block’scmttag stayed empty: the joint sensitivity solve either dropped the fit to finite differences or read an untagged compartment, giving a finite but wrong joint objective with no warning..admCacheWrite()could delete another session’s valid cache entry. The cleanup that removes a half-written file ran on anysaveRDSfailure, including one that fails at open time and leaves a complete pre-existing entry untouched. A concurrent fit of the same model could therefore have its compiled model removed underneath its parallel workers, which then fail with “parallel restart N failed”. It now only removes a file that call created.A sensitivity model that failed to build was reported as quietly as one refused by design.
.admLoadSensModel()returnsNULLboth for models that cannot have one (no random effects, ordinal, mixed or unlike endpoint transforms) and for genuine failures such as an unwritablerxode2::rxTempDir(). The second case now warns rather than messages, and says what to check – previously the same script silently produced a coarser gradient, and different estimates and standard errors, on a machine with a read-only cache directory.A fit that stops on the gradient box constraint now says so audibly. nlmixr2est muffles conditions raised inside
nlmixr2Est.*, so the warning reachedfit$warnings– whereprint(fit)shows it – but neverwarnings(). A script that writes coefficients to disk without printing the fit saw nothing at all. The notice is now also amessage(), on the same channel as the live progress table.-
The order-2
linCmt()promotion did not run for a linCmt assigned to a variable, so adfo kept finite-differencing its structural thetas there.linCmt()carries no second derivative, so an order-2 request promotes the model to explicit ODE form and builds from that. The gate detecting a solved-form model readui$predDf$linCmt, and on rxode2 5.1.4 that column depends on how the model is written:model line predDf$linCmtpromoted before promoted now linCmt() ~ add(a)TRUEyes yes cp <- linCmt(); cp ~ add(a)FALSEno yes cp <- 2 * linCmt(); cp ~ add(a)FALSEno no – see below The assigned form is the common way to write it, and there the promotion was never reached:
.admLoadSensModel(order = 2L)served an order-1 model and adfo silently kept the forward-FD struct-theta pass (8e-04..1e-02 relative, against ~1e-09 for the analytic block). A correct fit, just the slow noisy one.Detection now uses the exported
rxode2::testRxLinCmt(), which checksui$.linCmtMas well and isTRUEfor all three forms. The third still yields no cross block, becauserxode2::linToOde()hands a derivedlinCmtback unchanged; thelinCmtBtext backstop then correctly refuses and the caller falls back to order 1. So this widens the fix rather than completing it. -
adghControl()accepted an invalid nloptr algorithm, and would hand a derivative-free one a gradient. It had its own two-line algorithm rule instead of the shared.admResolveAlgorithm()the other three controls use, and that rule was one-directional and unvalidated:adghControl(algorithm = "NOT_AN_ALGO")was accepted and surfaced as a cryptic nloptr error mid-fit, andadghControl(grad = "analytical", algorithm = "NLOPT_LN_NELDERMEAD")kept both – paying for a gradient the algorithm discards on every iteration. It now goes through the shared reconciliation, sograd == "none"if and only if the algorithm is derivative-free, as documented.algorithmnow defaults toNULL(“matchgrad”).adgh’scov_h_outerdefault stayseps^(1/4)rather than the other three’seps^(1/5)– that difference is deliberate, since the quadrature surface is noise-free.One combination changes, and it is the one worth knowing about. The old two-line rule existed to special-case exactly
"NLOPT_LN_BOBYQA", upgrading it to LBFGS whenever a gradient was requested – because BOBYQA wasadghControl’s own default, so naming it could not be distinguished from leaving it alone. With the default nowNULL, naming a derivative-free algorithm is unambiguous and is honoured:adghControl(...)0.4.0 0.4.1 grad = "analytical",algorithm = "NLOPT_LN_BOBYQA"analytical+ LBFGSnone+ BOBYQAgrad = "fd",algorithm = "NLOPT_LN_BOBYQA"fd+ LBFGSnone+ BOBYQASo a script that explicitly restated the old default now gets a derivative-free fit where it had a quasi-Newton one. It says so (the reconciliation emits a message), but if you wrote
algorithm = "NLOPT_LN_BOBYQA"meaning “the default”, delete the argument –NULLnow picks LBFGS for you. Every other combination is unchanged, including the four pinned intest-adgh-nodes.R. adirmcControl()validated neithercinorreturnAdmr.ci = 99reached the interval columns as a nonsense level andreturnAdmr = "x"made the driver’sisTRUE()quietlyFALSE, returning a full fit where a plain list was requested. Both are now checked, as in the other three controls.A cache write that fails no longer discards the model it just compiled, or kills the fit. Both disk caches wrote with a bare
saveRDS(). The cache is an optimisation – by the time it is written the model is compiled and loaded – so an unwritable or fullrxTempDir()(a locked-down HPC home, a cache directory owned by another user) should cost speed, not correctness. Instead.admLoadModel()propagated the error and failed the wholenlmixr2()call withcannot open the connection, while.admLoadSensModel()’s callers wrap the build intryCatch(error = function(e) NULL), so a write failure threw away a successfully compiled sensitivity model and dropped adfo from its order-2 analytic structural gradient to forward FD, silently. Both now warn once per file and carry on with the model in hand. (The previous release swallowed the error entirely, which was also wrong – the parallel restart workers find these models by reading exactly these files, so a silent failure resurfaced much later as every restart failing to read the cache.)The session-ownership guard on a cached model rejected nlmixr2est’s own sensitivity model unconditionally.
.admRxLoadAll()requires an artifact under a session-local*Sensbuild directory to belong to the running session, and tested that by comparing against.admModDir()– which is<tempdir>/admixr2Sens, and so can never equal nlmixr2est 7.x’s<tempdir>/nlmixr2estSens. A cached.admSensFromInner()result was therefore reported stale on every call in the session that built it, and the model recompiled (~3 s) for every fit – the endless-recompile failure the guard exists to prevent, caused by the guard. It now tests membership of the current session’stempdir(), which is what identifies the session and covers both build directories.The order-2
linCmt()promotion could write a theta’s value into the wrongTHETA[k]slot. An order-2 request on a solved-formlinCmt()model promotes it to explicit ODE form, and.admBuildThetaSens()numbers its emitted derivative directions from the promotediniDf;.admLoadSensModel()built therename_mapthat fills those columns at solve time from the original one. Any difference across the promotion – a renumberedntheta, an inserted or dropped row, a reordered eta – meant each theta was differentiated in one slot and filled in another. The solve still succeeds anduse_d2skips adfo’s FD cross-check, so the fit would converge to wrong estimates and wrong standard errors with no error and no warning. Both are now derived from the same frame by construction (.admSensNameMaps()). Latent on every model measured here –linToOde()does preserve theiniDfon those – but not guaranteed.The gradient-box warning judged the fit against the wrong point, and stayed silent for the parameters most likely to need it. It differenced the solution against the fit’s
p0, but each restart’s box is centred on its own perturbed starting value, so a restart pinned to its box was not reported (its distance fromp0never reachesgrad_bounds) while an interior one could be reported spuriously. It also suppressed any hit on a parameter whose model declares a bound on that side – a residual-error parameter always does – even when that bound was nowhere near and admixr2’s box was what stopped the fit. It now reconstructs the box actually given to nloptr, centred on the winning restart’s own init, and reports a hit only when that box, rather than a model-declared bound, is the binding edge.-
An explicit
adfoControl(grad = "analytical")that cannot build a sensitivity model warns again. 0.4.1 demoted this to amessage(), which is right whengradwas left at its (new) default – an unavailable sensitivity model is routine and unactionable for a fixed-effects-only model or an ordinal endpoint – but wrong when the user named the argument: a message is swallowed bysuppressMessages(), by a knitr chunk withmessage = FALSE, and by any stderr-capturing wrapper, leaving no record that the fit used the gradient the control asked it not to use.Where it survives is worth stating precisely, because the obvious answer is wrong: nlmixr2est intercepts and muffles conditions raised inside
nlmixr2Est.*, so this warning does not reachwarnings()andoptions(warn = 2)does not turn it into an error. It is recorded onfit$warnings, whichprint(fit)displays. That is a durable record where amessage()left none, which is the point – but do not rely onoptions(warn = 2)to catch it. Normalising a study twice no longer leaves its endpoint unset. The idempotence guard added in this release returned before the point where a unit’s
outputis filled from the caller’s default, so a study first normalised without one (which is what the test fixtures do) keptoutput = NULLpermanently – and for a multi-endpoint model.admBuildEvFull(tag_cmt = TRUE)then has nothing to tagcmtwith, so the unit reads the wrong compartment’s trajectory. A second pass now fills what is still missing before returning.Dev-mode parallel restarts could not see any function this release introduced.
utils::assignInNamespace()can replace a binding in a daemon’s locked installed namespace but cannot add one, and the failure was swallowed, so a newly introduced helper was simply absent in the worker while everything looked healthy –.admGH()/.admGH0(), called from every finite-difference site in.admGrad()/.admGradBatch(), would have broken every dev-modeworkers > 1restart. Dev functions are now injected via a patch environment that patched closures are re-parented onto, so new and existing names resolve alike and a future helper needs no special handling. The same dispatch stopped shipping the parent’s model-cache environments to each daemon: an environment serialises by value, so every dev-mode restart was copying compiled rxode2 models that the worker has to rebuild anyway.-
Generated models are built under role-tagged names, in their own directory, and a cached one is checked before it is trusted. rxode2 names an anonymous model’s
.c/.sofrom the parsed model text alone, but the emitted C also depends on inputs that text cannot see – above all the event-sensitivity code, which is injected afterwards. Two builds of one text that differ there land on a single artifact, and because entry points resolve by NAME (R_GetCCallable) a model bound to the earlier one silently starts executing the replacement (nlmixr2/rxode2#1171). admixr2 is exposed in the worst way of any package:.admSensFromInner()recompiles nlmixr2est’s own inner model text witheventSens = "jump", where nlmixr2est built the same text with a different one. Generated models now carry a name folding in the role andeventSensalongside the parsed md5, and are built in a session-local directory rather than the persistentrxTempDir().The
.rdscaches stay inrxTempDir(), which persists, so a cache entry written by an earlier session necessarily references an artifact this session does not have..admRxLoadAll()therefore checks that a cached model’s DLL exists and belongs to this session’s build directory, and reports the entry as stale otherwise so it is rebuilt. Both halves are load-bearing:rxode2::rxLoad()on a vanished DLL does not reliably error – it returns quietly and the model then solves to garbage (a prediction frozen at itst = 0value, andNAstructural gradients) – and R removes its temp directory only on a clean exit, so a killed session leaves one behind that satisfiesfile.exists()indefinitely. -
Normalising a study twice turned it into a joint (same-subject) study.
.admNormaliseStudy()was not idempotent, and the second pass changed the likelihood. Normalising a legacy single-output study ADDS anobservationslist while KEEPING its top-levelV– which is exactly the signature the joint branch tests for (!is.null(s$observations) && !is.null(s$V)), so a second pass collapsed it into one joint unit:pass 1 is_joint = FALSE pass 2 is_joint = TRUE pass 3 TRUENo error, no warning, and a perfectly plausible fit – down a different likelihood path, and with adfo’s
have_d2forcedFALSE(it requires!any_joint), so the order-2 analytical structural gradient this release adds quietly turned itself off. Each estimator normalises exactly once, so a normal fit never reached it; the test fixtures hand out pre-normalised studies that the driver then normalises again, which is how it was found – meaning a number of end-to-end tests had been exercising the joint path while appearing to test the ordinary one..admNormaliseStudy()now marks what it has normalised and returns such a study untouched. Genuine joint studies are detected exactly as before. Non-finite parameters no longer reach the ODE solver. The screen that rejects an unusable parameter vector before a solve tested that the omega diagonal was positive – and
Inf > 0isTRUE. A covariance probe that perturbs a residual parameter toexp(1e5/2)therefore handedInftorxSolve(), which integrated garbage and emitted on the order of 190,000intdy -- t = <denormal> illegalandlsoda -- h too smallwarnings before the caller discarded the result anyway. The parameter vector is now also checked for finiteness, at the objective and gradient entry points of all three affected estimators (the gradients unpack the optimizer vector themselves, so the objective’s guard did not cover them). Aside from the console noise, this removes a guaranteed-useless ODE solve every time the optimizer or the covariance step overflows a parameter.-
A cache-key collision solved fits at another model’s fixed value. A
fix()ed parameter never reaches the optimizer, so it travels to the solve as data – either baked into$simulationModelas that parameter’s default, or carried on the cached sensitivity object. Both caches were keyed on themodel({})block, which does not distinguishtheta <- fix(0.5)fromfix(0.9). Two such fits therefore shared one compiled model, and the second silently solved at the first’s fixed value: a plausible objective, plausible estimates and plausible standard errors for a model the user never wrote, with no error and no warning, persisting across sessions because the cache directory does. Fixed values now key both caches; ordinary starting values deliberately do not, since a starting value is optimizer state and keying it would force a recompile for nothing.The parallel workers are why this needed more than a longer key: a worker has no
uito re-derive from, and it recomputed the cache path itself. The parent now sends the path onpinfo(which travels by value, so no worker signature changes). One consequence for developers only: adevtools::load_all()parent and an older INSTALLED admixr2 derive that path differently, so a daemon started from a stale install cannot find the file – rundevtools::install()before testing parallel restarts, as the contributor notes already say. The worker’s error message names that as the first thing to check. A stale sensitivity cache entry could survive a change to what it caches. The order-1 fallback is stored under the order-2 key, so editing what the order-2 build emits has to invalidate the entry – otherwise the previously compiled model is served and
.adfoGrad()contracts its second-order columns against the new code’s direction map: a finite, plausible, silently wrong structural gradient with a normal-looking objective. That used to rest on editing a schema-tag string by hand, and was forgotten once during this work. The key now carries the package version and a digest of the emitter’s own source, so an edit between releases invalidates it too, with nothing to remember.linCmt()second-order promotion built its direction set from the pre-promotion model..admBuildThetaSens()swapped in thelinToOde()model but kept the parameter rows derived from the original, despite a comment claiming otherwise. AnyiniDfdifference across promotion – a renumberedntheta, an added or dropped row, a different eta ordering – would have made the emittedrx_f1_THETA_k_/rx_f2_ETA_i_THETA_k_differentiate a different parameter, and withgrad = "analytical"now the default and the FD pass skipped, adfo would descend a gradient computed for the wrong theta. Latent rather than firing (promotion preserves theiniDfon the models measured here), but nothing enforced it.A struct theta missing from the cached direction map crashed the fit. The intended fallback – turn the analytic pass off and finite-difference – was unreachable, because
[[with an unmatched name on an atomic vector throws rather than returningNULL..adfoGrad()is not wrapped in atryCatchthere, so the whole nloptr run died with a baresubscript out of bounds.A transformed endpoint no longer pays for second-order compartments it cannot use. The solve paths deliberately discard the second-order block for an
lnorm/boxCox/yeoJohnson/logit/probitendpoint (chaining a second derivative through the transform needs terms the first-order chain does not carry), but nothing stopped it being built: a 2-state, 2-eta, 1-unpaired-thetalnormmodel integrated 20 states instead of 8 on every solve and then threw the extra away. Those endpoints now build the order-1 model directly.A parallel worker no longer walks on from a model it could not load. The worker’s re-load step discarded its own failure return, so a model whose shared library had been unloaded was handed to the estimator as if live and the first
rxSolve()dereferenced a dead pointer – an opaque error deep in the restart, or a heap-corruption crash on Windows. It now stops with the cache path and the likely cause. A cache file whose contents are not a compiled model at all is also detected again and rebuilt, rather than being reported as loaded..admNLL()gained the non-finite screen the other estimators got. admc’s objective – the function nloptr calls aseval_f– still carried only the omega-diagonal test described below, whichInfpasses, so admc users kept seeing the console flood that adfo and adgh users no longer do.-
Compiled models are held in a session cache.
.admLoadModel()and.admLoadSensModel()reloaded their model from the disk cache on EVERY call – areadRDS()of a compiled model plus adyn.load(), for every fit and every test – and handed back a fresh wrapper object each time. A repeat load now costs a hash lookup instead: measured at roughly 50 ms to 0.5 ms.The mechanism is
nlmixr2est’s, deliberately rather than invented: anemptyenv()-parented environment per purpose, a composite key covering everything that changes the emitted model, and a wholesale wipe at 64 entries to bound retained compiled models – the same shape as its.foceiAnalyticAugCache. The load step matchesrxUiGet.foceiModel()too, which re-loads EVERYrxode2element of a cached object rather than one by name.A cached model is only served while its disk cache file still exists, so
rxode2::rxClean()still forces a genuine recompile, and the metadata the key cannot capture (aboxCox/yeoJohnsonlambda’s VALUE) is re-derived on every hit exactly as the disk path already did.Note what this does NOT change: memory. A session’s footprint is set by how many DISTINCT models it compiles and loads – measured at roughly 4-10 MB and two shared libraries each, and nothing unloads them – so a session fitting many different models grows regardless of caching. Caching changes how often the same model is re-read, not how many are resident.
Internal changes
print.admFit()reaches nlmixr2est’s printer throughgetS3method(). It usedget("print.nlmixr2FitCore", envir = asNamespace("nlmixr2est")), which is semantically a:::call that merely evadesR CMD check’s syntactic scan – and carries exactly the upstream-refactor fragility the package’s no-:::policy exists to avoid. The function is a registered S3 method, so method lookup is the supported public route to it.-
The sensitivity-model builder takes an
orderargument..admBuildThetaSens()/.admLoadSensModel()default toorder = 1L– the existing first-order direction set, unchanged, which is whatadmc/adghread.order = 2Ladditionally emits the eta x direction cross block thatadfoneeds. The block is deliberately asymmetric:rxode2::rxExpandSens2_()accepts two different direction sets, so no theta x theta compartment is generated, and admixr2 needs none of the residual-variance chains that dominate nlmixr2est’s own second-order build (errmodel.Rderives the residual analytically). Second-order initial conditions are emitted too, without which a parameter-dependent IC leaves the cross compartment at zero.The eta x eta half of that block is SYMMETRIC and
rxExpandSens2_()does not know it: asked for the full rectangle it emitsd2/(d eta_1 d eta_2)andd2/(d eta_2 d eta_1)as two variational compartments carrying the same equation. The block is therefore requested one eta row at a time, against only the directions at or after it, and the name matrix mirrors the duplicate cell onto the canonical one – exact, since mixed partials of a smooth prediction commute, and it means the redundant chain expression is not emitted either. Savesn_states * n_eta(n_eta - 1)/2integrated states: 20 -> 18 on a 2-state/2-eta/1-theta model, 63 -> 54 on a 3-state/3-eta/2-theta one. A joint fit now also asks for order 1, sincehave_d2excludes joint units and the cross block it used to compile was integrated on every solve for a result nothing read.The sensitivity cache key includes the order; see the cache-invalidation fix above for how a change to what the order-2 build emits invalidates an existing entry.
A
fix()ed parameter’s VALUE now keys the cache too. A fixed parameter never reaches the optimizer, so it travels to the solve as data carried on the cached object – and a parallel worker, which reads that file and has nouito re-derive from, used the value it found. Two fits of the same model differing only intheta <- fix(0.5)versusfix(0.9)therefore shared one cache entry, and every parallel restart solved at the other fit’s fixed value: silently, and across sessions, since the cache directory persists. Starting values deliberately do not key the cache – they are optimizer state, and invalidating on them would force a recompile for nothing. CI:
R-CMD-checkgained aworkflow_dispatchtrigger and a dependency cache-version bump. The RcppParallel/TBB -> stringfish -> qs2 -> rxode2 stack has broken twice from CRAN-side rebuilds alone, with no commit of this package’s involved, so being able to ask “does the current CRAN state still build?” without pushing a dummy commit is worth two lines. Pair it with a cache-version bump for a genuinely cold resolve – a warm dependency cache is what made macOS look healthy right through the RcppParallel 6.0.0 break.
admixr2 0.4.0
New features
-
Student-t residual error (
cp ~ add(a) + t(nu)) is now supported. nlmixr2 writes Student-t residuals as a scale family – residual = scale * T_nu, with the scale being whateveradd()/prop()/pow()/combined structure the endpoint already has – so on the aggregate scale it is exactly the normal variance timesnu/(nu-2). admixr2 moment-matches it: the mean is unchanged and the variance is multiplied, which is exact for every existing residual form and works with all four estimators. Previously refused outright.nucannot be estimated from aggregate data and should be fixed (nu <- fix(5)). This is structural, not a small-sample issue:nureaches the aggregate moments only through the multipliernu/(nu-2), so it is aliased with the scale and only the producta^2*nu/(nu-2)is identified – an estimatednureflects its starting value, not the data. admixr2 warns whennuis left free.t()is intended for carrying a knownnuthrough an aggregate analysis (for instance a published model supplied viadatagen()), not for estimating tail weight; a fitted t model is observationally equivalent to a normal one with the same total residual variance.Note that moment matching makes the mean term of the objective correct, but admixr2 also scores
tr(V_pred^-1 V_obs), which treats the observed covariance as arising from a normal; that term stays approximate for heavy-tailed residuals, increasingly so asnuapproaches 2. -
The transform-both-sides transforms now call rxode2’s own kernel.
boxCox,yeoJohnson,logitNormandprobitNormused to be evaluated by a line-by-line R port of rxode2’s C_powerD/_powerDi– about ninety lines of branch order, clamps and short-circuits. They now callrxode2::.rxTransform(), which is what rxode2’s ownboxCox()/yeoJohnson()/logit()/probit()call and which bottoms out in the very C routine the solve transforms with, so admixr2 and rxode2 cannot drift apart. (The port had agreed with it exactly – 0 mismatches over every transform x lambda x bounds combination – but only by re-deriving it.)The residual quadrature now evaluates the transform for the whole node grid in one call instead of once per node, which made the switch a speed-up rather than a cost: 3.00 -> 0.60 ms per moment evaluation for
boxCoxat 81 nodes, 1.50 -> 0.30 foryeoJohnson, 0.95 -> 0.30 forlogitNorm(8 observations). The per-node accumulation is still a loop, deliberately, so the summation order and hence the objective are unchanged.Two pieces stay admixr2’s own, for stated reasons: the derivative of the INVERSE transform (rxode2 exposes no equivalent – and its
_powerDDhas a sign error on the Yeo-Johnson negative branch that admixr2 does not reproduce), anddim()restoration, which.rxTransform()drops. .admBackTransform()/.admLogBackTransform()now userxode2::probitInv()instead of an inlinelow + (high - low) * pnorm(p). Numerically identical across the whole range, but it is the kernel rxode2 itself transforms with, and it matches the neighbouringexpitbranch, which had always called rxode2.-
New
resid_nodescontrol argument on all four estimators. A transform-both-sides endpoint (boxCox,yeoJohnson,logitNorm,probitNorm) has no closed-form mean and variance –y = g(h(f) + sigma*eps)– so admixr2 integrates the residual by Gauss-Hermite quadrature.resid_nodessets that node count (default 81); every other error model has closed forms and ignores it.It is an accuracy dial, not a speed one. Worst-case relative error against an independent quadrature, over all four transforms and residual SD in {0.5, 1, 2, 3}: 5.7e-2 at 15 nodes, 4.5e-3 at 31, 5.0e-5 at 81. The error is dominated entirely by the largest SD; at SD <= 1 (a realistic residual on a transformed scale) 31 nodes already gives 1e-7 or better. Cost is linear in the node count in isolation (~50 us at 15, ~300 us at 81 for an eight-row study) but negligible beside the ODE solve – a full NLL evaluation measured 0.750 s per 60 evaluations at both 31 and 81 nodes. Raise it for a saturating endpoint with a large residual SD; there is little to gain by lowering it.
datagenControl()takes it too, with the same default, so a study generated bydatagen()and the fit that consumes it integrate the residual identically unless you deliberately change one of them. New vignette: “Choosing a residual error model” (
vignette("error-models", package = "admixr2")). What thecp ~ prop(...)line actually does once your data are a mean and a covariance; a side-by-side fit of the same study with the right and the wrong residual model (the structural parameters survive, the IIV does not); the full menu of supported models; reading the covariance diagnostic panel, which is the aggregate-data substitute for a residual-vs-predicted plot;resid_nodes; the parameters aggregate data cannot identify (t()’snu, an estimatedbinomsize); and the combinations that are refused, with the reason for each.
Bug fixes
-
Dropped the
qs2dependency. The compiled-model and sensitivity disk caches underrxode2::rxTempDir()are written withsaveRDS()/readRDS()instead ofqs2, and the files are namedadm-sim-*.rds/adm-sens-*.rds. Base R serialization does this job, so the dependency bought nothing; it came up while tracking down rxode2 reverse-dependency failures in a check library that did not containqs2. The caches are keyed by a model digest and live in the session temporary directory, so nothing needs migrating – a leftoveradm-*.qs2is simply a cache miss and the model is recompiled.Note this does not change which packages get loaded:
rxode2itself importsqs2, and R loads a package’sImportswith its namespace, soqs2(andstringfish) still enter the session behindlibrary(admixr2). IRMC importance-sampling shift was wrong for every non-
expmu-referenced theta. For a paired parameterparam <- h(theta + eta),etaandthetaenter the transform through the same argument, so shiftingthetabyDeltashifts the importance-sampling target mean ofetaby exactlyDelta–theta_new - theta_orig– for anyh. The code computed that shift aslog(back(theta)), which equalsthetaonly forexp(log(exp(theta))); for a bounded (expit/probit) paired theta it used a natural-scale-log form and for an additive one (emax <- temax + eta.emax, the standard Emax writing style) it usedlog(theta). Both biased the estimate and its analytical gradient, and the additive case went-Inf/NaNonce the parameter passed through zero. Measured against a directadghevaluation, theexpitshift drove the IRMC objective ~140-2LLunits off within a few tenths of the proposal point; the shift is now the identitytheta_new - theta_origfor all transforms and matches the direct objective to importance-sampling noise (~0.06). Onlyadirmcfits used this path; the other three estimators integrate the random effects directly and were unaffected.A
fix()ed prediction-dependent residual lost its gradient. A single endpoint whose only residual parameter isfix()ed –cp ~ prop(b)withb <- fix(0.2), or a fixedlnorm/boxCoxcoefficient – is still prediction-dependent:Var(y|eta)moves with the prediction..admResidDerivearly-returnedd(var)/df = 0andd(V_pred)/d(V_struct) = 1whenever there were no estimated residual parameters, dropping that dependence from both the structural-theta and omega gradients, so under the default analytic gradient the optimizer descended a direction the objective did not follow. The early return now fires only for a genuinely additive residual (where those defaults are correct); every prediction-dependent form runs the full derivative even with no estimated residual parameter. FD-verified acrossadfo/adgh/admcfor fixedprop,lnormand combined residuals.binom(20L, p)was refused as a non-constant size. An integer literal deparses to"20L", andas.numeric("20L")isNA, so a binomial size written with the integer suffix was misclassified as non-constant and refused with advice tofix()a parameter that does not exist – the only difference frombinom(20, p)being the suffix. A bare numeric literal (double or integer) is now read straight from the model AST.A non-positive
nbinomMusize now gives a clear domain error. The size is estimated on the log scale, so a start<= 0madelog(size)-Inf/NaNand the first NLL evaluationNaNwith no explanation. It now refuses at parse with a domain message, matching the siblingar()correlation andt()degrees-of- freedom guards.betaprecision denominator is guarded against a zero draw..admSimulatecomputed abetaendpoint’s derived meanb1/(b1+b2)without the zero- denominator guard its three sibling solve paths already carry, so a draw withb1 + b2 = 0would have produced aNaNobjective; it now floors the denominator the same way.Standard errors: sigma SEs were uninitialised memory, and omega was excluded. All three
CalcCovfunctions built the Hessian over structural and residual parameters but returned only the structural corner. nlmixr2est’s C++popDfbuilder then read past the end of the matrix, so every sigma row ofparFixedDf$SEprinted a denormal (6.953178e-310,%RSE~1e+307) instead ofNA– while the discarded sigma SEs were in fact good (reported SE / empirical sampling SD 0.90-0.94). The Hessian now also spans omega: excluding it made the structural SEs too small, because a theta carrying an eta is correlated with that eta’s variance. Reported SE / empirical SD for the eta-carrying theta went from 0.67 to 1.17 (prop) and 0.67 to 1.06 (lnorm); a purely additive model barely moved. Under-stated SEs give over-confident intervals, so that was the dangerous direction of error. If the weakly-identified omega Cholesky makes the full Hessian indefinite, the struct+sigma sub-block is reported with a warning rather than nothing at all.-
Omega and sigma standard errors are now reported, on the scale the estimates are printed on.
fit$covpreviously covered the structural thetas alone. It now spans structural thetas, residual error and omega, delta-transformed out of the optimizer’s parameterisation the waynlmixr2estdoes it – soEstimate +- 1.96*SEis meaningful for every row. Residual error is reported as an SD (fromlog(sigma^2)), with the othersigma_roles mapped through their own derivatives (tdegrees of freedom fromlog(nu - 2), anar()correlation from its logit, a negative-binomial size from its log). Omega is reported as the variance/covariance entries, named exactly as nlmixr2est names them (om.<eta>,cov.<eta_i>.<eta_j>).Omega is the one block that is not a per-row rescaling: the optimizer holds the log-Cholesky,
Omega = L L', andd(Omega_ij)/d(L_ab)is dense once omega is correlated. The new.admOmegaJacobian()builds that Jacobian in full and rotates both the omega block and its cross-covariance with struct/sigma; it agrees with a finite difference to 3e-10 on a correlated two-eta model. Calibration against the empirical sampling SD over 40 simulated studies gives reported SE / empirical SD = 1.13 for an IIV variance.Note for anyone touching this: nlmixr2est’s C++
foceiFitCpp_re-dimnames the covariance from its own theta-name vector and blanks the omega rows (upstream ships.impmapNameCov()to repair the same thing for its importance-sampling estimator). It does so in place, which also blanks the driver’s own copy, so the names are snapshot before the matrix is handed over and restored afterwards by.admRestoreCovNames(). -
A printed standard error now belongs to the parameter it is printed beside. nlmixr2est fills
parFixedDf$SEpositionally: it walks the thetas ininiDforder and takes the next entry ofsqrt(diag(fit$cov))for each one it is not skipping. admixr2 builds its covariance in optimizer order – structural thetas first, then residual error – and hands over a matrix that also carries the residual parameters, so two things had to be said explicitly:-
.admCovThetaOrder()puts the theta rows back ininiDforder. A model that declares its residual parameter first,ini({ a <- 0.1; tcl <- log(3); tv <- log(30) }), previously printedawithtcl’s SE,tclwithtv’s andtvwitha’s – a silent rotation, every number finite and plausible. -
.admCovSkip()tells nlmixr2est which thetas the matrix actually carries, derived from the matrix itself rather than from a convention. nlmixr2est’s own default is version-dependent: 6.2.0 skips only fixed thetas, while earlier versions (including 6.0.1, current on CRAN) also skip every residual-error theta, because FOCEI’s covariance genuinely does not include them. Without this, those versions printedNAfor every residual SD and read the structural SEs off the wrong rows.
Verified on nlmixr2est 6.0.1 and 6.2.0, with the residual declared first and last, and with a
fix()ed structural theta (which correctly staysNA). -
Count endpoints could not be fitted with the default gradient.
y ~ pois(cp)andy ~ nbinomMu(k, cp)emitrx_pred_ = llikPois(DV, ...)– the log-likelihood, not the mean – and sensitivity columns that differentiate it, both of which needDV, which an aggregate fit does not have..adghGrad()and.admGradBatch()returned all-NA, soadghdied at iteration 0 with “gradient of objective in x0 returns NA” andadmcsilently produced a zero Hessian and therefore no standard errors. admixr2 now emits sensitivities of the count MEAN (the distribution’s argument), exactly as it already did forbeta; gradients agree with a finite difference to 1.7e-05.The covariance Hessian used the starting lambda for a transformed endpoint.
.admGradBatch()– the evaluator behindcovMethod = "r"when a gradient is available – inherited the transform back-transform but not the estimated-lambda fix: an estimatedboxCox/yeoJohnsonlambda is a sigma name, so the zero-fill of the solve frame handed rxode2 lambda = 0 (a plain log transform) while the inverse used the model’s STARTING lambda, held constant across every configuration. That is the same mismatch documented elsewhere here as making the sensitivity gradient ~60x wrong, driving the Hessian: every reported SE came from the gradient of a different function, and lambda’s own row was insensitive to lambda. Each configuration now writes and inverts with its own lambda; measured against.admGrad()at a lambda well away from its start, the batch gradient went from 67% wrong to exact.-
beta()endpoints were only ever right on the plain NLL path. The prediction ofy ~ beta(b1, b2)is the derived meanb1/(b1+b2)and its variance needs the SOLVED precisionphi = b1 + b2, and every other path read the raw first shape parameter, or droppedphi, or both: thecovMethod = "r"objective evaluator scored a different model from the fit, the finite-difference gradient returned all-NA,datagen()emitted anEthat was a shape parameter and aVofNAs, andplot()gave an all-NApredicted covariance after a perfectly ordinary fit. None of it raised anything. Every path that turns a solve into a prediction now combines the pair and carriesphi.A beta fit is also now driven derivative-free, with a message: a structural theta reaches the objective through
phias well as through the mean, and every gradient path chains through the mean alone.datagen(method = "fo")refuses a beta endpoint for the related reason that FO has no path tophiat all. The
ar()andordinalguards judged every study, not the affected one. Both decided from a model-level scan and then rejected every flattened unit, socp ~ add(a) + ar(rho); ct ~ add(a2)refused actstudy whoseVhappened to be diagonal, and a PK + ordinal model could never be fitted at all – the ordinarycpstudy is neither joint nor supplies one block per category. Each guard now looks only at units that observe the endpoint it is about.Ordinal categories were grouped by exact floating-point time equality. The row times come from the per-category blocks, i.e. from independent user inputs:
seq(0.1, 0.7, by = 0.2)andc(0.1, 0.3, 0.5, 0.7)are the same grid to a reader and differ in the last bit tomatch(), which put the two categories in different groups and silently dropped the-p_j*p_kcross-covariance for those rows – the term a joint ordinal fit exists to capture. Grouped by tolerance now.The moment expansion and its derivative capped the same pole differently.
.admMomF()(what the NLL scores) caps the divergentmu^(k-2)correction against the leading term;.admMomFd()(what the gradient chains through) zeroed it past a magnitude threshold instead. Forpow(b, c)withc < 1near a zero prediction the two differed by orders of magnitude, so the optimizer was handed a direction that does not descend the function it is minimising. The derivatives are now the derivatives of the capped expression, piecewise, and agree with a finite difference of.admMomF()across the capped and uncapped regimes alike.A parallel worker could invert a transform with another model’s lambda. The sensitivity cache key covers the
model({})block, theiniDfnames, thefix()flags and theerrcolumn – but not the estimates, so two models differing only in the VALUE of afix()ed lambda share one file. The parent re-derivespred_tbson a cache hit; the worker could not, and used the file’s. The parallel restarts then minimised a different objective from the sequential ones, invisibly, because the NLL itself is bit-identical. The worker now re-derives it frompinfo, which it already holds.plot()back-transformed three residual roles on the wrong scale. The trace panel special-casedpow_expandt_dfand letar_cor,nb_sizeandtbs_lamfall through to the genericexp(v/2)variance rule: a convergedar()correlation of 0.6 plotted as 1.22, outside its own support and disagreeing with whatprint(fit)reports. The display map now comes from.admSigmaNat()itself, so a newsigma_rolecannot be added in one place and forgotten in the other.A
binomsize written as a model constant was refused as non-constant.nt <- 20; y ~ binom(nt, p)– a genuinely constant number of trials, and how one is usually written – hard-errored with advice tofix()a parameter that does not exist. A bare numeric assignment inmodel({})now resolves; an estimated size is still refused, since it has no gradient path.A count or beta endpoint alongside another endpoint is now refused. Multi-endpoint solves route observations by compartment, and a count endpoint is read through its distribution’s ARGUMENT – a model variable, not a compartment – so the tagged records matched nothing and the objective came back
Infwith no explanation. Relatedly, the dummy frame handed to nlmixr2est now carries the ENDPOINT names rather than the solve columns, which is what itsdvid->cmttranslation expects.datagen()refuses an ordinal endpoint instead of emitting a study without the cross-category covariance: its categories are one joint observation, anddatagen()derives each observed output separately.resid_nodesno longer changes what a positional call means. It was added as the SECOND argument of every estimator control, soadghControl(studies, 7L)– which had always meantn_nodes = 7– setresid_nodes = 7instead, passed its own validation, and leftn_nodesat its default, changing the eta quadrature grid the whole fit is built on with no message.admControl(studies, 20000L)andadirmcControl(studies, 2000L)were the same forn_sim, anddatagenControlshiftedsampling/seed/cores.resid_nodesis now the last argument of all five, where it cannot capture a positional one.The ordinal same-time grouping is now defined once.
.admResidApply(),.admResidVChain()and.admResidMuCoupling()each grouped the rows themselves. When the tolerance-based grouping above was first added, it went into one of the three – which is worse than the exact-match bug it replaced: wrong-but-consistent became objective-and-gradient-disagree, so the optimizer descended a direction the objective does not follow. All three now call.admOrdTimeGroup().Endpoints transformed differently from one another refused the sensitivity model. The guard caught transformed-vs-untransformed mixtures only, while the back-transform spec is a single one taken from the first endpoint. So
cp ~ lnorm(a); ct ~ boxCox(b, lam)appliedexp()toct’s Box-Cox rows, twologitNormendpoints with different bounds shared the first one’s bounds, and twoboxCoxendpoints shared the first one’s lambda – the residual path being per-endpoint already, the gradient then described a different function from the one the objective scored. Any non-identical set now falls back to finite differences.A joint (same-subject) study had no aggregate diagnostics.
.admAggData()solved one output for the whole stacked unit and applied the first endpoint’s residual spec to every row; it then died on the dimnames (the row count is the stacked total, the labels were one block’s times) and, being guarded, leftfit$env$aggDataunset – soplot(fit)‘s mean/cov panels had nothing to show and said nothing about it. It now uses the estimators’ own shared-eta solve and per-row-output residual, and labels rows<endpoint>@<time>.Documented: an
adfostandard error describes scatter, not accuracy. FO linearises at eta = 0, so on a non-additive residual or a large omega the point estimate carries a bias of several standard errors – measured 5-20 SE, giving 0% coverage for a nominal 95% interval even where the SE itself matches the sampling SD. Preferadgh/admcwhen the uncertainty matters.A failed covariance is no longer silent. When the Hessian was singular the covariance came back
NULL,covMethodwas set to""and every SE wasNAwith no warning reaching the user. The drivers now say so.A study
evcontaining observation records now warns.evis dosing-only; observation rows in it were appended a second time by the study’s owntimes, silently duplicating every time point.Residual parameters fixed with
fix()were silently dropped.add(a)witha <- fix(0.7)fitted with no residual variance at all;add(a) + prop(b)with a fixedblost the proportional term; andpow(b, c)with a fixedcreverted toprop()..admParseIniDf()removes fixed rows from the optimizer, and the residual spec indexed only the estimated ones –tdf_fixed,ar_fixedandlam_fixedexisted for exactly this reason butadd/prop/powhad no equivalent. They now carryadd_fixed/prop_fixed/pow_fixed. Fixing a residual parameter is routine (it is what this package’s ownt()advice tells you to do fornu), so this was reachable in ordinary use.A
prop()/pow()term on a transform-both-sides endpoint contributed nothing. ForboxCox/yeoJohnson/logitNorm/probitNormthe quadrature used only the additive parameter, socp ~ add(a) + prop(b) + boxCox(lam)scored identically with and withoutb: the parameter entered the optimizer, had an exactly-zero gradient, and was reported back at its starting value. rxode2 emitsrx_r_ ~ (a)^2 + (rx_pred_f_)^2*(b)^2for that model, and admixr2 now builds the transformed-scale residual SD from the same expression, includingpropT()/powT()(which scale by the transformed prediction) andcombined1().The post-fit covariance was a Hessian of the wrong objective for several error models.
.admNLLBatch()– the evaluatorcovMethod = "r"differentiates – called the fused C++ kernels unconditionally. Those implement additive, proportional, combined and lnorm only, so transform-both-sides, count, beta, ordinal andar()models were scored ascombined2: standard errors and RSEs came from a different model than the one fitted (measured on a boxCox model, 190.28 against 49.46). It now applies the same.admResidCppOK()gate.admNLL()uses.adirmccannot take that route (its kernel forms the importance-weighted mean internally) and now refuses those models with a message.adfodroppedar()from its objective while keeping it in the gradient..adfoVpred()never received the observation times and never added the residual correlation, so the FO objective was exactly invariant inrhowhile.adfoGrad()returned a non-zerorhogradient – the optimizer walked a direction the objective could not move along, andadforeported a different objective fromadgh/admcon identical data. Relatedly,adfoControl(grad = "analytical")warned that it was falling back to finite differences when no sensitivity model was available but did not actually do so.An out-of-support transform aborted the whole fit.
any(ap$ms != 1)was not NaN-guarded in nine places..admTBSi()legitimately returnsNaNoutside a transform’s support, and the defaultgrad_bounds = 5lets a line search reach it, soany(NaN != 1)– which isNA– raised “missing value where TRUE/FALSE needed” instead of the optimizer simply rejecting the point. This killed everyyeoJohnsonfit.The sensitivity-model cache could serve a stale transform spec. The cache key digests
ui$lstExpr, themodel({})block only, but a Box-Cox lambda’s starting value and itsfix()status live inini({})– solam <- fix(0.5)andlam <- 0.5collided.pred_tbsis what tells the solve which lambda to use and how to back-transform, and it was not re-derived on a cache hit (unlikerename_map/fixed_theta, which are, for the same reason). Gradients came back wrong by 102-104x with one component of the wrong sign, while the objective stayed bit-identical, so nothing warned and the fit simply stalled.0^negativein the moment expansion.pow(b, c)withc < 1at a structural prediction of exactly zero – routine for a depot model observed att = 0– produced a negative variance (measured -3.4e+20 atc = 0.25) or a plausible-looking 2.3e+05 atc = 0.75. The second-order term has a genuine pole there and is now dropped rather than evaluated at machine epsilon. The C++ twinadm_mom_f()had no guard at all and returnedNaNwhere the R path returned a finite value, so the same model fitted or did not depending on the estimator.ordinalendpoints are now supported (y ~ c(p1, p2)), as a joint same-subject unit with one observation block per category. The spec is registered under every category probability (only the first was, leaving the others with no residual variance), and the same-time cross-category covariance correctly replaces the structural covariance rather than adding to it – by the law of total covarianceCov(1_j, 1_k) = -E[p_j]E[p_k]exactly, the structural term cancelling. Verified against a multinomial simulation with between-subject variability.dv()is now refused. It scales the residual by the observed DV, an individual-level quantity an aggregate mean and covariance cannot recover. rxode2’s simulation ignoresdv(), so admixr2 had been silently fitting the prediction-scaled model instead.ar()combined withprop()/pow()/combined is now refused, as isar()inside a joint multi-output study. rxode2’s innovation scaling leaves the marginal variance equal torx_r_only whenrx_r_is constant; with a prediction- dependent variance the process is non-stationary and admixr2’s covariance was measured 2.4-12x too high.Known upstream issue – simulating an
ar()fit will not reproduce its covariance. rxode2 has twoar()emitters and they do not agree with each other. Its estimation lines are the prediction-error decomposition (rx_pred_ + phi*prev_resid,rx_r_ * (1 - phi^2)), whose implied marginal variance is the stationary AR(1) admixr2 scores. Its simulation is not stationary when a dose record precedes the first observation: the first observation carries up to 2x the nominal residual variance. A zero-amount dose reproduces it and a plainadd()model does not, so it is record-driven and specific toar(); nlmixr2’s own focei cannot recoverrhofrom rxode2’s own simulation either (0.4617 against a truth of 0.60 on individual-level data, with no admixr2 involved). admixr2 keeps the stationary form – matching the simulator would put it at odds with nlmixr2’s estimator and would break when this is fixed upstream. Every other error model round-trips (simulate from the fitted model, aggregate, and recover the fitted mean and covariance) to within Monte-Carlo noise.-
Prediction-dependent residual error is now composed correctly (
prop(),pow(),lnorm(), combined). admixr2 built the predicted covariance asVar_eta(f) + Sigma(mu_pred)– evaluating the residual variance at the population mean prediction rather than averaging it over individual predictions. That is exact only for additive error. The predicted covariance is now the law of total variance,Var_eta(E[y|eta]) + E_eta[Var(y|eta)], which for a proportional model adds the previously missingb^2 * Var_eta(f)to the diagonal, and forlnorm()also scales the off-diagonals byexp(s)(its conditional mean isf*exp(s/2), so the whole covariance is scaled, not just its diagonal). Validated against individual-level simulation: the old formulas carried fixed biases of ~15-20% that did not shrink with sample size, while the new ones converge to the empirical moments.This changes results for every
prop(),pow()andlnorm()model. Objective values, residual-error and IIV estimates and all standard errors move – for a proportional model with 30-50% IIV, the residual SD and omega were both biased upward by roughly 2-4%; forlnorm()the effect is larger. Purely additive (add()) models are unchanged, bit for bit. Refits are expected to differ from results produced by earlier versions. lnorm()analytic gradients were computed against the wrong quantity. For a log-transformed endpoint the sensitivity model returnsrx_pred_ = log(f)while the NLL path reads the natural-scale prediction, sograd = "sens"/"analytical"differentiatedlog(f)while the objective scoredf. The sensitivity paths now back-transform with the chain rule. This affected everylnorm()fit using an analytic gradient and went unnoticed becauselnorm()appeared in no gradient test; a finite-difference gradient check across all estimators and error models has been added.delay()(DDE) models get an accurate sensitivity solve. A delay model’s sensitivity system – the base ODEs plus one variational compartment per state per direction, all delayed – is stiff enough to trip rxode2’shasDelayAutoSwitch composite (dop853+ros4) into itsros4leg, whose dense delay-history is inaccurate for this system. The failure is silent: the sensitivity model’s predictions match the ordinary solve for the first observations and then drift oncedelay()begins reading the recorded (solved) history, sograd = "sens"gradients on a DDE model could be wrong without any error or warning. Sensitivity solves for a delay model are now forced onto puredop853(dense, noros4secondary), whose 8th-order dense output reproduces the ordinary solve. Non-delay models are untouched, and their solves are unchanged byte for byte. Found by porting the equivalent fix from nlmixr2est’s own augmented-sensitivity solve.
Internal changes
The post-fit covariance’s reported-scale rotation and its non-PD omega fallback are now single shared helpers. The ~46-line block that rotates the optimizer-scale covariance onto the printed scale (residual delta factors plus the omega Jacobian) was byte-identical in all three
CalcCovfunctions, and the “drop to the struct+sigma sub-block when omega makes the Hessian indefinite” fallback was duplicated inadfo/admcwith an already-divergent invert-first variant inadgh. Both are now.admScaleReportedCov()and.admReduceNpdOmega()inutils.R, so a change to how residual/omega SEs reach the printed scale, or to the fallback threshold, is made in one place rather than three. Theadghfallback converges onto the same eigenvalue threshold the other two use; results are unchanged (the full pipeline and covariance suites pass identically).The residual variance’s dependence on
(mu, var_f)is computed once per study/unit instead of three times..admResidVChain(),.admSigmaGrad()and.admResidMuCoupling()each recomputed.admResidDeriv()internally, in every estimator’s hot gradient loop – threeresid_nodes(default 81) quadratures per observation row for a transform-both-sides endpoint. They now accept the precomputed derivative as an optional last argument, which the estimators (which call all three on the same inputs) pass, cutting that to one. Gradients are bit-identical (the same computation, reused); the optional argument defaults to recomputing, so every other caller is unchanged.The residual V-composition tail is one helper,
.admApplyResidTail(). The three-lineV <- V * tcrossprod(ms); diag(V) <- dv; V <- V + rmatthat composes a structural covariance with the residual (lnorm/TBS off-diagonal scale, the composed diagonal, anar()correlation matrix) was hand-copied at eleven sites across every estimator’s moment/objective path,plot.R,datagen.Rand.admJointResidual. Adding an off-diagonal residual channel meant editing all of them, and missing one silently dropped that endpoint’s off-diagonal predicted covariance on that path. It is now written once, including the load-bearingna.rmguard that keeps a NaN from a transform’s out-of-support tail from aborting the fit. Objective and gradients are bit-identical.
admixr2 0.3.0
New features
Analytical gradients for non-mu-referenced (“unpaired”) structural thetas. A structural theta with no mu-referencing eta (
tkawith noeta.ka, or theexp(tcl) * exp(eta.cl)writing style rxode2 does not mu-reference) used to cost an extra finite-differencerxSolveper gradient call. admixr2 now emits its own first-order sensitivity model over an explicit direction set (one direction per random effect plus one per unpaired theta), compiled witheventSens = "jump"so dosing-modifier (f/lag/rate/dur) sensitivities are no longer silently zero. This mirrors the scheme nlmixr2est’s fast-focei uses (.foceiAnalyticDirections) but first-order only, and is cross-validated against nlmixr2est’s inner model to ~1e-13 across ODE, linCmt, dosing modifiers, initial conditions, covariates, if/else and multi-endpoint models. Consumed byadmc,adgh(including joint multi-output studies);adfokeeps finite differences (itsV_pred = J Omega J' + Sigmaneeds a second derivative). Measured 2.5-3.8x faster and ~100x more accurate than the previous finite-difference path on a 2-compartment model. This addssymengine(already a hard dependency ofnlmixr2est, so always installed alongside admixr2) toImports, used to emit the linCmt direction derivatives. The feature degrades gracefully on rxode2 withouteventSens = "jump"support (it falls back to the finite-difference path), so no minimum-version bump is required.-
Residual error models:
pow(),addPow()andcombined1()are now supported, with analytical gradients (#84). admixr2 previously supported onlyadd,propandlnorm. The residual error model is now read fromui$predDf(errType/errTypeF/transform/addProp) rather than frominiDf$erralone, and every estimator evaluates it through one shared specification:form variance combined2(default foradd + prop)a^2 + b^2 * f^(2c)combined1(a + b * f^c)^2lnormmoment-matched lognormal with
c = 1recoveringpropandb = 0recoveringadd. Analyticald(var)/d(sigma),d(mu)/d(sigma)andd(var)/d(f)are supplied for all of them, so residual parameters keep an exact gradient undergrad = "sens"/"analytical".Existing
add/prop/lnormfits are unaffected: the aggregate-2LLis bit-for-bit identical, and their gradients change only by floating-point reassociation (~1 ulp). -
Multi-compartment fitting (multiple observed outputs). A study may now observe several model outputs at once (e.g. plasma and brain/CSF) via an
observationslist – one entry per observed output with its ownoutput,times,EandV. Two modes (#85):-
Independent – each output has its own
n/ev(separate experiments, e.g. literature meta-analysis); the aggregate-2LLis the sum of the per-output likelihood blocks. Fit with full analytical / sensitivity gradients. -
Joint (same subjects) – outputs measured on the same subjects, with a shared
n/evand a joint covariance given either as a study-level fullVor as per-output marginalVplus acrosslist of cross-covariance blocks. Scored by a single MVN over the stacked vector with shared random effects and the full analytical gradient in all three estimators (any number of compartments; the assembled joint covariance is checked for positive-definiteness).
Supported by
est = "admc","adfo"and"adgh";datagen()generates multi-output aggregate data andplot()renders one panel set per compartment. Pass the endpoint names toadmData(), e.g.admData(c("cp", "cCSF")).est = "adirmc"does not support multiple observed outputs. -
Independent – each output has its own
Parallel restarts now run on
miraidaemons.workers > 1starts a pool of background R processes instead of dispatching throughfuture/furrr. This replaces the previous fork (Unix/macOS) vs PSOCK (Windows/RStudio) split with a single code path that behaves identically on every platform, and the pool lives on its own mirai compute profile so it never disturbs daemons the user has set up for their own code.furrrandfutureare no longer used;miraimoves intoSuggests. Workers are still stopped automatically after the restart phase (and now also on error/interrupt, viaon.exit()), so all cores are free for the covariance step;admStopWorkers()remains available.nDisplayProgresscontrol argument for every estimator (admControl(),adfoControl(),adghControl(),adirmcControl()), passed through to therxSolve()calls that drive fitting. It sets how many subjects a single solve must exceed before the solver shows its text progress bar. The default (.Machine$integer.max) keeps the bar off, so it no longer leaks into scripts, logs or rendered vignettes; lower it (e.g.1000L) to watch progress during long interactive fits.The aggregate-data estimators (
adfo,adgh,adirmc,admc) now carrytypeanddescriptionattributes classifying them as “Model Based Meta Analysis” methods, so they appear in the category-grouped estimation-method list nlmixr2est prints for an unsupportedest=(or a barenlmixr2()call) (#107).
Bug fixes
pow()models no longer fit the wrong residual model, silently.pow(b, c)produces twoiniDfrows – the coefficient (err = "pow") and the exponent (err = "pow2"). admixr2 recognised neither, warned once, and then treated both as additive variances: the exponent was stored as2*log(c)and optimized as a variance contributingexp(2*log(c))todiag(V). Apowmodel therefore ran to completion and reported plausible estimates for a model it was not fitting. Residual parameters now carry a role, and apowexponent is estimated on its own (unconstrained, identity) scale.combined1()is honoured.predDf$addPropselects SD-additive (combined1) versus variance-additive (combined2) residual error. admixr2 ignored it and always computedcombined2, dropping the2*a*b*fcross term. (combined2is nlmixr2’s default, so only models that explicitly asked forcombined1()were affected.)An unrepresentable residual model is now refused rather than approximated. Error types admixr2 cannot express as a Gaussian aggregate MVN (
logitNorm,probitNorm, Box-Cox/Yeo-Johnson transforms,t/cauchy,propF/powF) previously emitted a one-time warning and were then treated as additive, so the fit proceeded with the wrong residual model. They nowstop(). This is a behaviour change: a model that “worked” before may now error.propT/propF,norm/dnormanddlnorm/logn/dlognno longer emit spurious “modelled as …” approximation warnings. These are aliases, not approximations:normisadd,lognislnorm, and on an untransformed modelpropT(which scales by the transformed prediction) is exactlyprop, because there the transformed and untransformed predictions are the same quantity. The warnings claimed an inaccuracy that did not exist.Lognormal residual error is now applied to the plotted predicted mean.
plot.admFit()’s aggregate-data helper added the lnorm variance to the predicted covariance but never applied theexp(s/2)mean scaling to the predictedE, so lnorm fits plotted a mean the NLL does not use.The solver progress bar no longer appears during covariance/gradient batches. Most internal
rxSolve()calls already suppressed it, but the covariance and batched-gradient solves inadmchard-coded a lownDisplayProgress(1000), so the bar printed once a chunk exceeded 1000 solves. All solves now honour the newnDisplayProgresscontrol argument (default off).Hard-coded numeric constants in a model’s
model({})block (e.g. a fixed brain volumevb <- 5, common in PBPK/CNS models) are no longer zeroed. admixr2 used to hand-fill every model parameter it did not set with0, clobbering such a constant’s default and producing anNA/non-finite objective (e.g. aqout / vbdivide-by-zero). It now supplies only the parameters it varies and letsrxSolve()fill the rest from the model’s own defaults, so constants and covariate defaults keep their value.adghnow computes gradients for non-mu-referenced (unpaired) structural thetas. The unpaired-parameter set was derived from the eta-indexedstruct_eta_idx, so it was always empty and those thetas silently received a zero gradient; it now uses the struct-indexedstruct_has_eta.Parallel restarts under
devtools::load_all()warn once about the installed package. In dev mode the admixr2 namespace is locked, so worker daemons run the installed package rather than the loaded source; if it is stale the parallel objective silently diverges from the sequential one..admRunRestartsnow emits a one-time warning in this case telling you todevtools::install(). It never fires in production (installed package == source).
Internal changes
-
adghgradient-mode fits are about twice as fast: the objective and the gradient now share one solve (#76).nloptrasks for the objective and the gradient as two separate calls, but LBFGS always asks at the same parameter vector, and.adghGradalready builds exactly the moments the negative log-likelihood needs – so the objective’s solve was duplicate work. It is now memoised onto the gradient’s solve. Measured on a 3-compartment, 5-eta, 40-timepoint fit with a full covarianceV:rxSolvecalls per fit drop from 58 to 23 (n_nodes = 3) and 63 to 25 (n_nodes = 5), roughly halving wall time. Applies tograd = "analytical"only (including multi-restart fits);grad = "fd"/"cfd"/"none"are unchanged, as are all gradient values.Note for anyone comparing objectives across versions: the reported objective now comes from the sensitivity solve rather than the plain one. Both integrate the same underlying model, but the augmented system makes rxode2’s adaptive stepper land a little differently – about 5e-11 relative on the objective, well inside the solver’s own tolerance, and parameter estimates are unchanged (identical to six decimal places in testing). As a side effect the objective and its gradient are now computed from a single trajectory, where previously they came from two slightly different ones.
Model loading and per-fit memory now follow nlmixr2est’s own conventions. admixr2 previously pinned each fit’s
foceiModelcompanion objects in a package-level environment (a Windows GC-finalizer heap-corruption guard) and reclaimed rxode2’s global model registry with a bespoke snapshot/teardown after every fit. Both are gone: the companion objects are no longer pinned (the guard proved unnecessary – verified by running thecovMethod = "r"fit path repeatedly under aggressive GC with no crash), and each estimator now frees memory the way nlmixr2est does, withgc(); rxode2::rxUnloadAll(). The disk model cache continues to useqs2+digest, exactly like rxode2/nlmixr2est; the in-memory pin cache was removed (same-model reloads come from theqs2files). Net: ~290 fewer lines, no admixr2-specific memory machinery, and fit results are unchanged.admClearCache()is removed; userxode2::rxClean(). admixr2’sqs2caches live inrxode2::rxTempDir()alongside rxode2’s and nlmixr2est’s, sorxode2::rxClean()– rxode2’s standard cache wipe (unload all models + clear the temp dir), which nlmixr2est itself calls to reset – already clears admixr2’s cache too. The package-specificadmClearCache()is therefore redundant.print()on a fit no longer writes into rmarkdown’s namespace.print.admFittemporarily overwrotermarkdown:::print.paged_dfviaassignInNamespace()(restoring iton.exit) to steer nlmixr2est away from its paged-table branch. That branch is in fact unreachable: nlmixr2est decides between paged and console output by probing behaviour – it prints apaged_df-classed frame intocapture.output()and infers “a paged renderer consumed my output” from zero captured lines – butrmarkdown:::print.paged_dfreturns itsknit_asisobject visibly and noprint.knit_asismethod exists, so the probe always collects output, always returnsFALSE, and the console branch is always taken. The stub therefore changed nothing except skipping the discarded probe render (~20 ms perprint(fit)), at the cost of mutating a foreign namespace – fragile, unsafe under concurrent rendering, and a CRAN-policy grey area. Printed output is unchanged, byte for byte. (#58)
admixr2 0.2.0
CRAN release: 2026-07-02
New features
- New estimator
est = "adgh": deterministic Gauss-Hermite quadrature over the random-effects prior, configured viaadghControl(). The objective is noise-free (no Monte Carlo draws), the analytical gradient is exact, and it is unbiased at any IIV magnitude. For models with up to ~4 random effects it is the fastest exact estimator (#65). -
datagen()gains FO-approximated population moments (method = "fo", matchingest = "adfo") for design evaluation and optimal-design work (#56). -
adirmcControl(kappa_method = "linearized_gh"): GH-averaged kappa baseline for the IRMC inner loop. -
admClearCache()prunes the session-level compiled-model cache (#10). - Control objects now accept any
nloptralgorithm; the default is chosen from the gradient mode, andgrad/algorithmare reconciled automatically (#70).
Bug fixes
- Fix an infinite recursion (“evaluation nested too deeply” / “node stack overflow”) that aborted the first fit of an R session when a covariance matrix was requested (
covMethod = "r"). Accessingui$simulationModelleft a self-referential compiled-model object inui$meta, which nlmixr2’s ui-cloning during fit assembly could not traverse. admixr2 now clears that transient artifact in.admLoadModel(), keeping the ui in the canonical state nlmixr2 expects. Affected all four estimators (adfo/admc/adgh/adirmc) (#81). - Use the ML denominator (
1/n_sim) consistently in the MC gradient kernels, matching the NLL (#48). - Fix parallel multi-restart dispatch for fork/PSOCK, and fix
adirmcmulti-restart (#45). - Guard non-positive predicted variance in the diagonal-NLL paths (#57).
- Correct the FO diagonal omega gradient scaling, plus assorted plot, output-variable detection, caching, and worker-serialization fixes.
Documentation
- Add Gauss-Hermite sections across the vignettes and fix the pkgdown reference index so the documentation site builds (#79).
admixr2 0.1.0
CRAN release: 2026-06-02
- Initial release.
- Monte Carlo estimator (
est = "admc") viaadmControl(). - Iterative Reweighting Monte Carlo estimator (
est = "adirmc") viaadirmcControl(). - Analytical CRN gradient with sensitivity equations (
grad = "sens"). - Multi-restart parallelism via
furrr/future. - Diagnostic plots: observed vs predicted mean/covariance, NLL trace, parameter trace.
-
traceplot()support: admixr2 fits populate the standardparHistDataslot, so the nlmixr2traceplot()generic works natively (best restart, natural scale, no burn-in marker). - Integrates with the nlmixr2/rxode2 ecosystem.
