
Developer Guide: Adding Hooks to nlmixr2 Fits
Source:vignettes/articles/nlmixr2-hooks.Rmd
nlmixr2-hooks.RmdOverview
nlmixr2est provides a hook system that allows external
packages (such as babelmixr2) or user code to extend the
estimation pipeline without modifying the core package. There are three
stages at which you can insert custom logic:
| Hook type | When it runs | What it can change |
|---|---|---|
preProcessHooks |
Before estimation begins |
ui, est, data,
control
|
preFinalParTableHooks |
After estimation, before the parameter table is built |
theta, thetaNames, cov,
ui (via env) |
postFinalObjectHooks |
After the complete fit object is assembled | The full nlmixr2FitData return object |
All three families share a consistent interface:
-
Add —
*HooksAdd(name, fun)registers a hook. -
Remove —
*HooksRm(name)unregisters a hook. -
List / retrieve —
*Hooks(name = NULL)lists all registered names, or returns the function for a specificname.
Hook names follow R object-name rules and may begin with a dot
(.). By convention, hooks added by internal
nlmixr2est code start with a dot to signal that they are
package-private.
1. Pre-processing hooks (preProcessHooks)
When they run
Pre-processing hooks run at the very start of nlmixr2(),
before any estimation takes place. They are called in registration
order, and each hook sees the (possibly modified) outputs of the
previous hook.
Function signature
myHook <- function(ui, est, data, control) {
# ...
list(ui = newUi) # return only what changed
}| Argument | Type | Description |
|---|---|---|
ui |
rxode2 UI | The model object |
est |
character | Estimation method name, e.g. "focei"
|
data |
data.frame | The observed data |
control |
list | The control object for the estimation method |
Return value
- Return
NULLor an empty list to leave everything unchanged. - Return a named list containing any combination of
"ui","est","data", and/or"control"to replace those components for all subsequent hooks and for the estimation itself.
If the returned list contains any name other than those four, an error is thrown.
Registering and removing
# Add a hook (error if the name is already registered)
preProcessHooksAdd("myPkg.myHook", myHook)
# Remove a hook (warning if the name is not registered)
preProcessHooksRm("myPkg.myHook")
# List all registered hook names
preProcessHooks()
# Retrieve the function for a specific hook
preProcessHooks("myPkg.myHook")Example: modifying the model before estimation
The following hook is a simplified version of the one used in
preProcessZeroOmega.R. It detects fixed-zero variance
parameters and removes them from the model so the estimator is not asked
to estimate them.
.myRemoveZeroEtas <- function(ui, est, data, control) {
zeroEtas <- getZeroEtaNames(ui) # hypothetical helper
if (length(zeroEtas) == 0L) return(NULL)
newUi <- dropEtas(ui, zeroEtas) # hypothetical helper
list(ui = newUi)
}
preProcessHooksAdd(".myRemoveZeroEtas", .myRemoveZeroEtas)Checking estimation-method attributes
Pre-processing hooks often need to behave differently depending on
the estimation method. You can query attributes set on the
nlmixr2Est.<method> S3 generic to make these
decisions without hard-coding method names:
.isMyFeatureSupported <- function(est, control = NULL) {
method <- paste0("nlmixr2Est.", est)
if (method %in% as.character(utils::methods("nlmixr2Est"))) {
val <- attr(utils::getS3method("nlmixr2Est", est), "myFeature")
if (is.null(val)) return(FALSE)
if (is.function(val)) return(isTRUE(val(control)))
return(isTRUE(val))
}
FALSE
}When you implement a new estimation method in an external package,
set the appropriate attributes on your
nlmixr2Est.<method> function so that hooks can detect
your method’s capabilities:
nlmixr2Est.myMethod <- function(env, ...) {
# ... estimation code ...
}
attr(nlmixr2Est.myMethod, "covPresent") <- TRUE # data must have covariates
attr(nlmixr2Est.myMethod, "unbounded") <- FALSE # handles bounds natively
attr(nlmixr2Est.myMethod, "iov") <- TRUE # supports IOV
attr(nlmixr2Est.myMethod, "mu") <- FALSE # not a mu-referencing method2. Pre-final parameter-table hooks
(preFinalParTableHooks)
When they run
These hooks run after the optimizer has converged but before
the final parameter table (parFixedDf, theta data frames,
covariance matrix, etc.) is assembled. Specifically, they are called
inside foceiFinalizeTables via the internal
.preFinalParTableHooksRun() function.
This is the right place to back-transform internal parameter values to their natural scale, apply Jacobian corrections to the covariance matrix, or rename parameters.
Function signature
myParTableHook <- function(env) {
# modify env by side effects
invisible(NULL)
}env is the internal fit environment. Relevant components
you may read or modify include:
env component |
Type | Description |
|---|---|---|
env$theta |
data.frame | Rows = estimated thetas; columns lower,
theta, upper, fixed
|
env$thetaNames |
character | Names corresponding to rows of env$theta
|
env$cov |
matrix | Covariance matrix of the estimated thetas |
env$thetaDf |
data.frame | Extended theta information |
env$etaObf |
data.frame | Eta / OBF table (if present) |
env$ui |
rxode2 UI | Model object used during estimation |
env$skipCov |
logical | Which thetas are excluded from cov
|
The hook must modify env by side
effects and return invisible(NULL). It does not return a
new value.
Registering and removing
preFinalParTableHooksAdd("myPkg.myParTableHook", myParTableHook)
preFinalParTableHooksRm("myPkg.myParTableHook")
preFinalParTableHooks() # list all hooks
preFinalParTableHooks("myPkg.myParTableHook") # get the functionExample: renaming an internal parameter
.myRenameParam <- function(env) {
if (is.null(env$thetaNames)) return(invisible(NULL))
w <- which(env$thetaNames == "internalParamName")
if (length(w) == 1L) {
env$thetaNames[w] <- "userParamName"
rownames(env$theta)[w] <- "userParamName"
}
invisible(NULL)
}
preFinalParTableHooksAdd(".myRenameParam", .myRenameParam)3. Post-final-object hooks (postFinalObjectHooks)
When they run
Post-final-object hooks run after focei (and related
methods) have assembled the complete nlmixr2FitData return
object, just before it is handed back to the user. They are called by
the internal .postFinalObjectHooksRun() at the end of
.foceiFamilyReturn().
Use this stage to:
- Swap out the internal model with the user-facing model (e.g., undo model transformations applied in a pre-process hook).
- Add, remove, or rename columns in the data frame part of the fit.
- Attach additional components to the fit (e.g., IOV tables).
Function signature
myFinalHook <- function(ret) {
# inspect and possibly modify ret
ret # MUST return ret (possibly modified)
}ret is the full nlmixr2FitData /
nlmixr2FitCore object. It is both a data frame (observed
and predicted values) and has a list-like interface. Key components:
| Component | Access | Description |
|---|---|---|
| Model UI | ret$ui |
The rxode2 UI object |
| Fit environment | ret$env |
Internal environment (thetas, etas, omega, etc.) |
| Parameter table | ret$parFixedDf |
Fixed-effect parameter table |
| Random effects | ret$env$ranef |
Individual random-effect estimates |
| Shrinkage | ret$env$shrink |
Eta/epsilon shrinkage |
The hook must return ret (modified or
unmodified).
Registering and removing
postFinalObjectHooksAdd("myPkg.myFinalHook", myFinalHook)
postFinalObjectHooksRm("myPkg.myFinalHook")
postFinalObjectHooks() # list all hooks
postFinalObjectHooks("myPkg.myFinalHook") # get the functionExample: restoring the original model
A common pattern is for a pre-process hook to transform the model, and a post-final-object hook to restore the original so the user sees clean output.
# Pre-process hook stores the original UI and transforms the model
.myPreProcess <- function(ui, est, data, control) {
if (!needsTransform(ui, est)) return(NULL)
myPkgEnv$origUi <- ui # save for later
newUi <- applyTransform(ui)
list(ui = newUi)
}
preProcessHooksAdd(".myPreProcess", .myPreProcess)
# Post-final hook restores the original UI
.myPostFinal <- function(ret) {
if (is.null(myPkgEnv$origUi)) return(ret)
assign("ui", myPkgEnv$origUi, envir = ret$env)
myPkgEnv$origUi <- NULL
ret
}
postFinalObjectHooksAdd(".myPostFinal", .myPostFinal)4. Complete workflow example
Below is a minimal but complete example that shows all three hook types working together. The hypothetical transformation logs a parameter internally for numerical reasons, then restores the natural-scale value in the output.
library(nlmixr2est)
myPkgEnv <- new.env(parent = emptyenv())
# ---- 1. Pre-process: log-transform "tlag" before estimation ----
.logTransformTlag <- function(ui, est, data, control) {
iniDf <- ui$iniDf
w <- which(iniDf$name == "tlag" & is.na(iniDf$neta1))
if (length(w) != 1L) return(NULL)
iniDf$name[w] <- "log_tlag"
iniDf$est[w] <- log(iniDf$est[w])
# Rebuild the UI from the modified iniDf (see .getUiFunFromIniAndModel
# in preProcessZeroOmega.R for the full pattern)
ini <- as.expression(lotri::as.lotri(iniDf))
ini[[1]] <- quote(`ini`)
newUi <- .getUiFunFromIniAndModel(ui, ini, rxode2::as.model(ui))()
list(ui = newUi)
}
preProcessHooksAdd("myPkg.logTransformTlag", .logTransformTlag)
# ---- 2. Pre-final par-table: exponentiate back before table is built ----
.expTransformTlag <- function(env) {
if (is.null(env$thetaNames)) return(invisible(NULL))
w <- which(env$thetaNames == "log_tlag")
if (length(w) != 1L) return(invisible(NULL))
env$theta$theta[w] <- exp(env$theta$theta[w])
env$thetaNames[w] <- "tlag"
rownames(env$theta)[w] <- "tlag"
invisible(NULL)
}
preFinalParTableHooksAdd("myPkg.expTransformTlag", .expTransformTlag)
# ---- 3. Post-final-object: restore original UI ----
.restoreOrigUi <- function(ret) {
if (!is.null(myPkgEnv$origUi)) {
assign("ui", myPkgEnv$origUi, envir = ret$env)
myPkgEnv$origUi <- NULL
}
ret
}
postFinalObjectHooksAdd("myPkg.restoreOrigUi", .restoreOrigUi)5. Hook naming conventions
-
Use a package prefix — hooks live in a global
environment inside
nlmixr2est, so collisions are possible. Prefix your hook names with your package name, e.g."babelmixr2.myHook". -
Dots for internal hooks — hooks that are part of
nlmixr2estitself start with a leading dot, e.g.".preProcessZeroOmega". External packages should not start names with a dot. -
Valid R names — names must match the regex
^[.]*[a-zA-Z]+[a-zA-Z0-9._]*$.
6. Hooks provided by nlmixr2est
The following hooks are registered when the package is loaded:
preProcessHooks
| Name | Source file | Purpose |
|---|---|---|
.nlmixr0preProcessCovariatesPresent |
preProcessCovariatesPresent.R |
Validates and uppercases covariate column names in
data
|
.preProcessDataUi |
preProcessDataUi.R |
Re-compiles the UI if it uses data-dependent user-defined functions |
.nlmixrPreprocessLiteralFix |
preProcessLiteralFix.R |
Replaces fixed parameters with their literal values in the model |
.preProcessZeroOmega |
preProcessZeroOmega.R |
Removes ETAs whose initial variance estimate is zero |
.uiApplyIov |
iov.R |
Expands IOV random effects into per-occasion ETAs |
.uiApplyMu2 |
mu2.R |
Substitutes mu2-referenced covariates into the model |
.preProcessBoundedTransform |
preProcessBoundedTransform.R |
Rewrites bounded parameters as unconstrained internal parameters for unbounded optimizers |
7. Tips and caveats
Order matters. Hooks are executed in the order they were registered.
nlmixr2est’s own hooks are registered at package load time, so they always run before hooks added by user code at the R prompt, but before or after hooks added by other packages depending on load order.Fail loudly. If your hook cannot proceed safely, stop with a clear error message. The estimation will be aborted, which is safer than silently producing wrong results.
Be idempotent where possible. A hook that checks whether it needs to act before acting makes it easier to reason about state (e.g., “was this hook already applied?”).
Side effects across hooks. If a pre-process hook and a post-final hook need to share state (e.g., the original
ui), use a package-level environment (likemyPkgEnvin the examples above) rather than global variables.Respect the
controloption. Where possible, provide acontrolflag (e.g.,control$myFeature = FALSE) that lets users opt out of your hook’s transformation.Testing. Use
preProcessHooks(),preFinalParTableHooks(), andpostFinalObjectHooks()(with no argument) to inspect which hooks are currently registered. In tests, you can add a hook, run a fit, assert on the result, and then remove the hook with*HooksRm().