R/impmap.R

Defines functions nlmixr2Est.impmap .impmapNameCov .impRestoreCovMethod .impmapRecomputeObjf .impmapFamilyFit .impmapFamilyControl .impmapGammaRunInfo .impmapResolveGammaMethod nmObjGetFoceiControl.impmap .impmapControlToFoceiControl nmObjGetControl.impmap getValidNlmixrCtl.impmap nmObjHandleControlObject.impmapControl impmapControl

Documented in getValidNlmixrCtl.impmap impmapControl nlmixr2Est.impmap nmObjGetControl.impmap nmObjGetFoceiControl.impmap nmObjHandleControlObject.impmapControl

# est="impmap" -- importance-sampling EM.
#
# impmap centers a multivariate-normal proposal at each subject's MAP mode
# (produced by the mu-referenced FOCEI inner problem, muModel="lin"), draws
# importance samples, and updates the population parameters by an EM step.
# The numerical kernel lives in C++ (src/imp.cpp); this file is orchestration
# only: control construction, dispatch, and post-fit assembly.

# Importance-sampling / EM control names -- stripped when down-converting to a
# plain foceiControl for the MAP inner problem / output.
.impmapIsControlNames <- c("isample", "nIter", "mapIter", "gamma",
                           "gammaMethod", "gammaMethodUser", "gammaRule",
                           "df", "auto", "autoNonNormal",
                           "autoNonmemSparse", "autoDfPatience",
                           "iscaleMin", "iscaleMax", "iaccept",
                           "ctol", "nConvWindow", "impSeed", "impCov",
                           "qr", "qrShift", "qrRefresh", "sir", "sirSample",
                           # internal M-step index maps added in .impmapFamilyFit;
                           # not foceiControl() arguments, so they must be dropped
                           # when down-converting (e.g. .setOfvFo's do.call(foceiControl))
                           "impMuThetaIdx", "impMuEtaIdx", "impThetaSensIdx",
                           "impOmegaFixedEta")

#' Control options for the impmap (importance-sampling EM) estimation method
#'
#' A NONMEM-style Monte Carlo importance-sampling EM built on the mu-referenced
#' FOCEI MAP.  The proposal density for each subject is centered at the MAP mode
#' (`muModel="lin"`); mu-referenced population parameters are updated by the EM
#' gradient, while non-mu parameters (structural and residual error) are updated
#' by a symbolic-sensitivity Newton step -- the importance-sampling-weighted
#' score and Gauss-Newton information built from the analytic `d(f)/d(theta)` and
#' `d(V)/d(theta)` (exact censored partials for BLQ/M2/M3/M4 points).
#'
#' @inheritParams foceiControl
#' @param ... Parameters used in the default `foceiControl()`
#' @param isample Number of importance samples drawn per subject per iteration
#'   (NONMEM ISAMPLE).  Either a single count used for every subject, or a
#'   vector of length `nsub` giving a count **per subject**.
#'
#'   Per-subject counts are the NM7 Technical Guide's own remedy for poor
#'   coverage (its derivation is Gaussian throughout and never mentions a t
#'   proposal): a subject whose weights are badly behaved can be given more
#'   samples without charging every other subject for them.  Note this treats
#'   the symptom rather than the cause -- more draws from a proposal whose
#'   tails are too light still gives weights with infinite variance, which
#'   `fit$env$impPsisK` will show.  See `df` for the shape-based remedy.
#' @param nIter Maximum number of importance-sampling EM iterations.
#' @param mapIter Number of MAP re-centering iterations per EM step; `> 0`
#'   re-centers the proposal at the MAP mode each iteration.
#' @param gamma Initial proposal-variance inflation factor (NONMEM ISCALE); the
#'   proposal covariance is `gamma` times the inverse of the inner information
#'   matrix at the mode.
#' @param df Degrees of freedom of the importance-sampling proposal (NONMEM
#'   `DF`).  `0` (default) uses a multivariate **normal** proposal; any value
#'   `> 0` uses a multivariate **t** with that many degrees of freedom.
#'
#'   This changes the proposal's SHAPE rather than its width, and that is the
#'   distinction that matters.  `gamma` can only make a Gaussian proposal
#'   wider; it cannot give it heavier tails.  When the target posterior has
#'   heavier tails than the proposal, the importance weights have infinite
#'   variance -- and neither `xi` nor the Kish effective sample size can detect
#'   it, because the offending mass lies where the proposal rarely lands.  The
#'   Pareto k-hat diagnostic (`fit$env$impPsisK`) does detect it: `k > 0.7`
#'   means that subject's weights are unreliable.  A t proposal has polynomial
#'   tails that dominate a Gaussian target's, which bounds the weights.
#'
#'   NONMEM's guidance (Bauer, *NONMEM Tutorial Part II*) is to set a nonzero
#'   `DF` when there are fewer data points than etas, or for categorical data.
#'   Small values (3-8) are heavy; large values approach the Gaussian.
#' @param auto NONMEM `AUTO=1` equivalent: adapt the proposal degrees of
#'   freedom, the sample count and the acceptance target **per subject** rather
#'   than applying one global setting to everybody.
#'
#'   * **`df`** -- any subject gets a heavy-tailed t proposal when the model is
#'     not transformably normal, and any subject whose Pareto k-hat reports tail
#'     failure gets one on that evidence.  The tutorial's other trigger, "fewer
#'     data points than there are ETAs", is **not** applied on its own -- see
#'     `autoNonmemSparse`.  An escalation that fails to improve k-hat over two
#'     iterations is withdrawn, since a heavy tail the data creates is not
#'     repairable by proposal shape.
#'   * **`isample`** -- the total sample budget (`isample * nsub`) is
#'     reallocated toward subjects whose effective-sample fraction is lowest,
#'     the tutorial's "many ETAs or ... large stochastic fluctuations".  It is
#'     load-balancing, not a cost increase.  Note sample count is deliberately
#'     *not* driven by Pareto k-hat: a heavy tail is not repairable by more
#'     draws (see `df`).
#'   * **`iaccept`** -- lowered to 0.2 for the same sparse/categorical
#'     subjects, per the tutorial.
#'
#'   The concrete values (the `df` ladder, the k-hat thresholds, the budget
#'   reallocation rule) are **nlmixr2's choices**.  NONMEM does not publish what
#'   `AUTO=1` picks internally; only the triggers and `IACCEPT ~ 0.2` are
#'   documented.  Unlike NONMEM's AUTO, which its own tutorial warns "may result
#'   in lack of stochastic reproducibility", this remains seeded and
#'   thread-count independent.
#'
#'   Per-subject values are reported in `fit$env$impDfInd`,
#'   `fit$env$impNsampleInd` and `fit$env$impIacceptInd`.
#'
#'   **Measured trade-off.**  `auto = TRUE` (the default) improves the *tail*
#'   behaviour of the importance weights and the accuracy of `Omega`, at some
#'   cost in Monte-Carlo noise on the objective.  On theophylline with three
#'   ETAs -- a one-ETA model has no tail failure to fix, so it shows nothing
#'   either way -- against a reference computed at `isample = 8000`, over 8
#'   seeds:
#'
#'   \itemize{
#'     \item `auto = FALSE`: max Pareto k-hat 0.941, 2.38 subjects above 0.7;
#'       objective RMSE 0.0113, `Omega` RMSE 0.00406
#'     \item `auto = TRUE`: max Pareto k-hat 0.571, 0.25 subjects above 0.7;
#'       objective RMSE 0.0165, `Omega` RMSE 0.00301
#'   }
#'
#'   So `auto` removes the tail failure and estimates `Omega` about 26% more
#'   accurately, for about 46% more Monte-Carlo noise on the objective.  It is
#'   on by default because weights with infinite variance are a correctness
#'   problem -- their error is unbounded in the worst case -- whereas the extra
#'   noise is a bounded, measurable cost.  Setting `df` globally gives a better
#'   objective still on a model that is uniformly heavy-tailed, but costs 75%
#'   more objective RMSE on one that is not, which is why it is not the default.
#'
#'   Set `auto = FALSE` to recover the previous behaviour exactly (that path
#'   remains bit-identical to earlier versions), which is worth doing when you
#'   want the tightest possible objective on a model whose `fit$env$impPsisK`
#'   values are already comfortably below 0.7.
#' @param autoNonmemSparse Apply the NONMEM tutorial's `nobs < neta` trigger
#'   unconditionally, so a subject with fewer observations than random effects
#'   always gets a t proposal.  `FALSE` (default) leaves such subjects to the
#'   Pareto k-hat evidence like any other, and withdraws an escalation that does
#'   not improve k-hat within two iterations.
#'
#'   The default diverges from the tutorial deliberately, on measurement.  On a
#'   fixture built to the tutorial's own definition of sparse (2 observations, 3
#'   etas), 8 seeds against an `isample = 8000` reference, applying the rule made
#'   every number worse -- objective RMSE 0.1517 -> 0.2059, `Omega` RMSE 0.02379
#'   -> 0.03163, and *more* failing subjects (3.88 -> 4.88).  With fewer
#'   observations than random effects the individual posterior is not identified,
#'   so the heavy tail is structural and no proposal shape repairs it; the
#'   reference itself still reads max k-hat 0.794.
#'
#'   Set `TRUE` to get the documented NONMEM rule anyway.  NONMEM's own testing
#'   is not published and was not done on these models, so someone who measures
#'   the opposite on their own problem should be able to have it.
#' @param autoDfPatience Number of consecutive iterations an escalated `df` may
#'   fail to improve Pareto k-hat before that escalation is withdrawn and the
#'   subject returned to the proposal it had without it.  `0` never withdraws.
#'   Withdrawal is final, so escalation and withdrawal cannot oscillate, and it
#'   never goes below the `df` the model itself requires -- a non-normal endpoint
#'   keeps its t proposal.
#'
#'   A rung is judged against what the subject manages WITHOUT any escalation,
#'   measured while it sits there, so deterioration that happens before any
#'   escalation is tracked and an escalation is never credited for it.
#' @param gammaMethod How the proposal scale `gamma` is adapted during the EM.
#'
#'   `"auto"` (default) picks per model: `"individual"` when the model is not
#'   transformably normal -- a general log-likelihood (`ll()`) endpoint, or a
#'   count/categorical/time-to-event distribution -- and `"global"` otherwise.
#'   That is the split the hypothesis actually rests on: `gamma = 1` is already
#'   the efficient proposal when the individual posterior is close to Gaussian,
#'   so a normal model gains nothing from per-subject adaptation and would only
#'   pay for it in effective sample size, while a general-likelihood model is
#'   exactly where the posteriors go non-Gaussian and per-subject coverage
#'   starts to vary.  The test is `all(ui$predDf$distribution == "norm")`, the
#'   same line [rxode2::assertRxUiTransformNormal()] draws.  The resolved value
#'   is reported in the fit's `$runInfo`.
#'
#'   `"global"` keeps one scale shared by every subject, inflated (never
#'   relaxed) only when the *mean* Kish effective-sample fraction falls below
#'   `iaccept`.  It leaves `gamma` at its efficient starting value while
#'   coverage is healthy, which is the right behaviour when the individual
#'   posteriors are close to Gaussian.
#'
#'   `"individual"` gives every subject its own `gamma_i` and adapts it
#'   two-sided toward a target on that subject's `xi_i`, clamped to
#'   `[iscaleMin, iscaleMax]`.  This follows NONMEM's *objective* -- the NM7
#'   Technical Guide states that `gamma` is per-subject (eq. 1.90) and is
#'   "continually adjusted so that xi_i approximates IACCEPT" (note after
#'   eq. 1.76), bounded by `ISCALE_MIN`/`ISCALE_MAX` -- but the guide publishes
#'   no update formula, so the functional form used here is nlmixr2's own.
#'   Prefer this when the
#'   individual posteriors are heavy-tailed or the design is heterogeneous:
#'   a global scale is driven by the mean, so a handful of badly-covered
#'   subjects never trip it and their likelihood and `Omega` contributions end
#'   up carried by a few samples.
#'
#'   The two modes report *different* efficiency statistics -- `"individual"`
#'   targets `xi` (the mean normalized importance weight, NONMEM's `IACCEPT`
#'   quantity) while `"global"` targets the Kish effective-sample fraction.
#'   These are not comparable; the fit's `$runInfo` says which is in force.
#' @param gammaRule How the SHARED (`gammaMethod="global"`) proposal scale is
#'   adapted.  Ignored for `gammaMethod="individual"`, which always follows
#'   NONMEM's two-sided per-subject rule.
#'
#'   `"floor"` treats `iaccept` as a one-sided FLOOR on the mean Kish
#'   effective-sample fraction: `gamma` stays at its efficient starting value
#'   while coverage is healthy and is inflated only when coverage drops below the
#'   floor.  It never comes back down.
#'
#'   `"target"` (default) follows the NM7 Technical Guide, which says `gamma` is
#'   "continually adjusted so that xi_i approximates IACCEPT" -- adapted BOTH
#'   ways, on `xi` rather than the Kish fraction, using the same analytic
#'   inversion as the per-subject controller (`gamma * (xi/iaccept)^(2/neta)`,
#'   capped 1.25x each way and clamped to `[iscaleMin, iscaleMax]`).
#'
#'   The two rules settle at different operating points, so they are not
#'   interchangeable, and constants tuned against one do not carry over to the
#'   other.
#'
#'   **Measured trade-off** (theophylline, 6 seeds, `isample = 300`, RMSE against
#'   an `isample = 6000` reference):
#'
#'   \itemize{
#'     \item 3 ETAs -- `"floor"`: theta RMSE 0.00185, `Omega` RMSE 0.00298, max
#'       Pareto k-hat 0.604, 0.33 subjects above 0.7, converged 100% of seeds.
#'       `"target"`: theta RMSE 0.00257, `Omega` RMSE 0.00378, max k-hat -0.491,
#'       0 subjects above 0.7, converged 0%.
#'     \item 8 ETAs -- `"floor"` never adapts at all (`gamma` pinned at 1.0 with
#'       `xi` 1.35, i.e. a proposal far too narrow), because it watches the mean
#'       Kish fraction, which stays healthy while `xi` says the proposal is
#'       wrong.  `"target"` moves `gamma` to 1.25 and puts `xi` on 0.41.
#'   }
#'
#'   So `"target"` fixes the tail and is the only rule that reacts at high ETA
#'   dimension, but it pays for it: putting `xi` on `iaccept` deliberately widens
#'   the proposal (`gamma` 1.79 on the 3-ETA fixture), which costs effective
#'   sample size (0.96 to 0.70) and adds Monte-Carlo noise to the objective --
#'   enough that the fit often does not meet `ctol` within `nIter`.
#'
#'   `"target"` is the default: tuned against tuned, it wins every column at 3
#'   ETAs and is the only rule that adapts at all at 8 ETAs.  Its cost is on the
#'   1-ETA fixture, where it roughly doubles theta RMSE (0.00113 to 0.00224) and
#'   takes about twice as many iterations.  That trade follows the same weighting
#'   `auto` uses: weights with infinite variance are a correctness problem whose
#'   error is unbounded, while the extra Monte-Carlo noise is bounded and
#'   measurable.
#'
#'   Choose `"floor"` for the previous behaviour -- a proposal left at its
#'   efficient starting value while coverage is healthy.  It is also what the
#'   tail-machinery tests pin, because `"target"` repairs the tail itself and
#'   leaves the `df` ladder nothing to fix.
#'
#'   **The tuned constants travel with the rule.**  Selecting a rule also selects
#'   its tuned defaults (`nConvWindow` 20 for `"target"`, 10 for `"floor"`), so
#'   switching to the NONMEM method does not silently run NONMEM's law on the
#'   other rule's tuning.  An explicitly supplied value always wins.
#' @param iscaleMin,iscaleMax Lower/upper bounds for the adapted `gamma`
#'   (NONMEM ISCALE_MIN / ISCALE_MAX).  Both bounds are reachable under
#'   `gammaRule="target"`; under `"floor"` `gamma` only ever moves up, so only
#'   `iscaleMax` can bind.
#' @param iaccept Minimum importance-sampling effective-sample fraction
#'   (NONMEM IACCEPT).  The proposal scale `gamma` is kept at its efficient
#'   starting value while the achieved fraction stays at or above `iaccept`, and
#'   is inflated (toward `iscaleMax`) only when it drops below this floor.
#' @param ctol Convergence tolerance on the windowed objective-function change;
#'   `NULL` derives it from `sigdig`.
#' @param nConvWindow Length of the trailing iteration window used to average
#'   the objective-function change for convergence (NONMEM-style CTYPE).
#' @param muModel Mu-referencing variant for the MAP inner problem; for
#'   `impmapControl()` this is always `"lin"` and cannot be changed.
#' @param impSeed Base seed for the per-subject thread-safe (threefry) RNG
#'   streams; results are reproducible and independent of the thread count.
#' @param covMethod Covariance method.  `"imp"` (default) computes the
#'   Monte-Carlo importance-sampling observed-information covariance for the
#'   estimated thetas and Omega parameters (a finite-difference Hessian of the
#'   importance-sampling objective over fixed common-random-number samples),
#'   stashed as `$impCov` / `$impSe` and installed as the fit covariance; the
#'   theta standard errors match the Hessian-based FOCEI covariance, though the
#'   variance of a tightly-determined random effect (an Omega diagonal) can be
#'   over-estimated because the fixed samples barely span its prior variation.
#'   `"analytic"`, `"r,s"`, `"r"`, `"s"` instead compute the FOCEI covariance
#'   post-fit at the converged estimates (see [foceiControl()]); `""` skips the
#'   covariance step.
#' @param qr When `TRUE`, draw quasi-random (Sobol low-discrepancy) importance
#'   samples instead of pseudo-random Gaussian samples (QRPEM, Leary &
#'   Dunlavey PAGE 2012); the E-step integrals converge at O(1/N) instead of
#'   O(1/sqrt(N)).
#' @param qrShift Only used with `qr=TRUE`.  When `TRUE` each (iteration,
#'   subject) applies a random Cranley-Patterson shift to the Sobol points
#'   (seeded, thread-count independent); `FALSE` reuses one fixed Sobol point
#'   set everywhere (fully deterministic E-step, no RNG in the draw).
#' @param qrRefresh Only used with `qr=TRUE` and `qrShift=TRUE`.  When `TRUE`
#'   the shift is redrawn each iteration so residual quasi-random error
#'   averages out over the EM; `FALSE` draws one shift per subject at the fit
#'   start, making each EM iteration a deterministic map (smoothest objective
#'   trace).
#' @param sir When `TRUE`, accelerate the non-mu / residual-error M-step by
#'   SIR (sampling-importance-resampling): the theta-sensitivity Newton step
#'   uses `sirSample` equal-weight resampled points per subject instead of all
#'   `isample` weighted samples.
#' @param sirSample Number of SIR resampled points per subject; `NULL` uses
#'   `max(25, ceiling(isample/10))`.  Must be at most `isample`.
#' @return impmapControl object
#' @export
#' @author Matthew L. Fidler
#' @examples
#'
#' impmapControl()
impmapControl <- function(sigdig=3,
                          ...,
                          isample=300L,
                          nIter=100L,
                          mapIter=1L,
                          gamma=1.0,
                          gammaMethod=c("auto", "global", "individual"),
                          gammaRule=c("target", "floor"),
                          df=0,
                          auto=TRUE,
                          autoNonmemSparse=FALSE,
                          autoDfPatience=2L,
                          iscaleMin=0.1,
                          iscaleMax=10.0,
                          iaccept=0.4,
                          ctol=NULL,
                          nConvWindow=10L,
                          impSeed=42L,
                          covMethod=c("imp", "analytic", "r,s", "r", "s", "sa", ""),
                          qr=FALSE,
                          qrShift=TRUE,
                          qrRefresh=TRUE,
                          sir=FALSE,
                          sirSample=NULL,
                          muModel=c("lin", "none")) {
  muModel <- match.arg(muModel)
  gammaMethod <- match.arg(gammaMethod)
  gammaRule <- match.arg(gammaRule)
  # RULE-DEPENDENT DEFAULTS.  The tuned constants belong to the RULE, so switching
  # to the NONMEM rule must bring its constants with it -- otherwise the user gets
  # NONMEM's law running on the floor rule's tuning, which is the unfair pairing
  # this whole comparison was redone to avoid.  Only defaults are substituted: an
  # explicitly supplied value always wins (and a control round-tripped through
  # do.call(impmapControl, ctl) supplies everything, so it is idempotent).
  #
  # nConvWindow: 10 for "floor", 20 for "target".  The target rule tracks a
  # Monte-Carlo statistic, so it needs a longer window to average the noise out.
  # Measured (3 ETAs, 6 seeds, isample=300, RMSE vs an isample=6000 reference):
  #
  #   target w=10   thetaRMSE 0.00196  omegaRMSE 0.00266  maxK -0.356  iter 16
  #   target w=20   thetaRMSE 0.00167  omegaRMSE 0.00196  maxK -0.429  iter 27
  #   target w=30   thetaRMSE 0.00199  omegaRMSE 0.00291  maxK -0.451  iter 34
  #
  # w=20 is the accuracy optimum; w=30 costs iterations for nothing.
  if (identical(gammaRule, "target") && missing(nConvWindow)) {
    nConvWindow <- 20L
  }
  checkmate::assertLogical(qr, any.missing=FALSE, len=1, .var.name="qr")
  checkmate::assertLogical(qrShift, any.missing=FALSE, len=1, .var.name="qrShift")
  checkmate::assertLogical(qrRefresh, any.missing=FALSE, len=1, .var.name="qrRefresh")
  checkmate::assertLogical(sir, any.missing=FALSE, len=1, .var.name="sir")
  # isample may be a single count or one count PER SUBJECT (NONMEM's per-subject
  # ISAMPLE): a badly covered subject can buy more samples without charging
  # every other subject for them.
  checkmate::assertIntegerish(isample, any.missing=FALSE, min.len=1, lower=1,
                              .var.name="isample")
  checkmate::assertIntegerish(impSeed, any.missing=FALSE, len=1, .var.name="impSeed")
  .isampleAll <- as.integer(isample)
  # A per-subject vector must be exactly nsub long.  nsub is not known here, so
  # the length is checked at fit time in the kernel (it used to fall back to a
  # uniform max() and ignore a wrong-length vector without a word).
  # the scalar used for sizing / sirSample defaults / reporting is the largest
  .isample <- max(.isampleAll)
  if (is.null(sirSample)) {
    .sirSample <- max(25L, as.integer(ceiling(.isample / 10)))
  } else {
    checkmate::assertIntegerish(sirSample, any.missing=FALSE, len=1, lower=1,
                                .var.name="sirSample")
    .sirSample <- as.integer(sirSample)
  }
  if (.sirSample > .isample) {
    stop("'sirSample' (", .sirSample, ") cannot exceed 'isample' (", .isample, ")",
         call.=FALSE)
  }
  # covMethod="imp" drives the Monte-Carlo importance-sampling covariance in the
  # C++ kernel (op_focei.impCov); every other token is the post-fit FOCEI
  # covariance, so hand foceiControl a valid token (the estimation pass forces
  # covMethod=0L regardless -- see .impmapFamilyFit).
  .dots <- list(...)
  .impCov <- isTRUE(.dots$impCov)   # may already be set on a round-tripped control
  .dots$impCov <- NULL              # internal field; do not forward to foceiControl
  # gammaMethodUser is stamped on the RUNTIME control by .impmapFamilyFit (it
  # records what the user asked for before "auto" was resolved).  A control that
  # has been round-tripped therefore carries it; keep it, but do not forward it
  # to foceiControl, which has no such argument.
  .gammaMethodUser <- .dots$gammaMethodUser
  .dots$gammaMethodUser <- NULL
  # autoNonNormal is likewise stamped on the RUNTIME control (it records whether
  # the resolved gammaMethod came out "individual"), and is not an argument here
  # either.  Without this, do.call(impmapControl, <a fit's own control>) died
  # with "unused argument: 'autoNonNormal'" -- so re-validating a completed fit's
  # control never worked.
  .autoNonNormal <- .dots$autoNonNormal
  .dots$autoNonNormal <- NULL
  if (is.character(covMethod)) {
    if (length(covMethod) == 1L && !nzchar(covMethod)) {
      covMethod <- ""
    } else {
      covMethod <- match.arg(covMethod)
    }
    .impCov <- identical(covMethod, "imp")
    .foceiCovMethod <- if (.impCov) "analytic" else covMethod
  } else {
    # round-trip: covMethod is already a resolved foceiControl integer slot;
    # keep the impCov flag from the incoming control (read above)
    .foceiCovMethod <- covMethod
  }
  .control <- do.call(foceiControl,
                      c(list(sigdig=sigdig), .dots,
                        list(covMethod=.foceiCovMethod, muModel="lin")))
  .control$impCov <- .impCov
  if (!is.null(.autoNonNormal)) .control$autoNonNormal <- .autoNonNormal
  .control$isample <- .isampleAll
  .control$nIter <- as.integer(nIter)
  .control$mapIter <- as.integer(mapIter)
  .control$gamma <- as.double(gamma)
  .control$gammaMethod <- gammaMethod
  .control$gammaRule <- gammaRule
  .control$df <- as.double(df)
  checkmate::assertLogical(auto, any.missing=FALSE, len=1, .var.name="auto")
  checkmate::assertLogical(autoNonmemSparse, any.missing=FALSE, len=1,
                           .var.name="autoNonmemSparse")
  checkmate::assertIntegerish(autoDfPatience, lower=0, len=1, any.missing=FALSE,
                              .var.name="autoDfPatience")
  .control$auto <- auto
  .control$autoNonmemSparse <- autoNonmemSparse
  .control$autoDfPatience <- as.integer(autoDfPatience)
  if (!is.null(.gammaMethodUser)) .control$gammaMethodUser <- .gammaMethodUser
  .control$iscaleMin <- as.double(iscaleMin)
  .control$iscaleMax <- as.double(iscaleMax)
  .control$iaccept <- as.double(iaccept)
  .control$ctol <- if (is.null(ctol)) NULL else as.double(ctol)
  .control$nConvWindow <- as.integer(nConvWindow)
  .control$impSeed <- as.integer(impSeed)
  .control$qr <- qr
  .control$qrShift <- qrShift
  .control$qrRefresh <- qrRefresh
  .control$sir <- sir
  .control$sirSample <- .sirSample
  class(.control) <- "impmapControl"
  .control
}

#' @rdname nmObjHandleControlObject
#' @export
nmObjHandleControlObject.impmapControl <- function(control, env) {
  assign("impmapControl", control, envir=env)
}

#' @rdname getValidNlmixrControl
#' @export
getValidNlmixrCtl.impmap <- function(control) {
  .ctl <- control[[1]]
  .cls <- class(control)[1]
  if (is.null(.ctl)) .ctl <- impmapControl()
  if (is.null(attr(.ctl, "class")) && is(.ctl, "list"))
    .ctl <- do.call("impmapControl", .ctl)
  if (inherits(.ctl, "foceiControl")) {
    .minfo(paste0("converting ", class(.ctl)[1], " to impmapControl"))
    class(.ctl) <- NULL
    .ctl <- do.call(impmapControl, .ctl)
  } else if (!inherits(.ctl, "impmapControl")) {
    .minfo(paste0("invalid control for `est=\"", .cls, "\"`, using default"))
    .ctl <- impmapControl()
  } else {
    .ctl <- do.call(impmapControl, .ctl)
  }
  .ctl
}

#' @rdname nmObjGetControl
#' @export
nmObjGetControl.impmap <- function(x, ...) {
  .env <- x[[1]]
  if (exists("impmapControl", .env)) {
    .control <- get("impmapControl", .env)
    if (inherits(.control, "impmapControl")) return(.control)
  }
  if (exists("control", .env)) {
    .control <- get("control", .env)
    if (inherits(.control, "impmapControl")) return(.control)
  }
  stop("cannot find impmap related control object", call.=FALSE)
}

.impmapControlToFoceiControl <- function(env, assign=TRUE) {
  .impmapControl <- env$impmapControl
  .n <- setdiff(names(.impmapControl), .impmapIsControlNames)
  # np* internals (npBoxLower/npPoints/npResidFreeze ...) and the npag/npb user
  # knobs (points/cycles/gammaOptimize/... -- but NOT seed, a real foceiControl arg)
  # are nonparametric-engine control fields that foceiControl does not accept; drop
  # them so a downstream do.call(foceiControl, .) (e.g. .setOfvFo, general-likelihood
  # tables) does not error with "unused argument".
  .npKnobs <- c("points", "cycles", "gammaOptimize", "residOptimize", "muExpand",
                "gridWidth", "gridBounds", "dfScan",
                "alpha", "burnin", "nsamp", "nchains", "propSd", "est")
  .n <- .n[!grepl("^np[A-Z]", .n) & !(.n %in% .npKnobs)]
  .foceiControl <- setNames(lapply(.n, function(n) .impmapControl[[n]]), .n)
  class(.foceiControl) <- "foceiControl"
  if (assign) env$control <- .foceiControl
  .foceiControl
}

#' @rdname nmObjGetFoceiControl
#' @export
nmObjGetFoceiControl.impmap <- function(x, ...) {
  .env <- x[[1]]
  .impmapControlToFoceiControl(.env, assign=FALSE)
}

#' Resolve gammaMethod="auto" against the model
#'
#' `"individual"` when the model is NOT transformably normal -- a general
#' log-likelihood (`ll()`) endpoint, or a count / categorical / time-to-event
#' distribution -- and `"global"` otherwise.
#'
#' The rationale is the hypothesis the per-subject controller rests on: the
#' proposal is the Laplace approximation, so `gamma = 1` is already efficient
#' when the individual posterior is near-Gaussian.  A normal model therefore
#' gains nothing from per-subject adaptation and pays for it in effective
#' sample size (measured on theophylline: ESS 0.95 -> 0.70 for identical
#' estimates), whereas a general-likelihood model is where the posteriors are
#' genuinely non-Gaussian and per-subject coverage varies.
#'
#' The test is `all(predDf$distribution == "norm")`, the same line
#' `rxode2::assertRxUiTransformNormal()` draws between transformably-normal and
#' general likelihoods.
#'
#' @param gammaMethod Requested setting: "auto", "global" or "individual".
#' @param ui rxode2 ui object
#' @return "global" or "individual"
#' @noRd
.impmapResolveGammaMethod <- function(gammaMethod, ui) {
  if (!identical(gammaMethod, "auto")) return(gammaMethod)
  .dist <- tryCatch(ui$predDf$distribution, error=function(e) NULL)
  # No usable predDf (should not happen for a fittable model): fall back to the
  # conservative choice, which is the historical behaviour.  Same for an NA
  # distribution -- `all(NA == "x")` is NA, which would error an `if`.
  if (is.null(.dist) || length(.dist) == 0L) return("global")
  .dist <- as.character(.dist)
  if (anyNA(.dist)) return("global")
  # Canonicalize before comparing: rxode2 spells the Gaussian family both
  # "norm" and "dnorm" and treats them as identical (rxPreferredDistributionName
  # maps both to "dnorm"), so a literal == "norm" test would send an otherwise
  # ordinary normal model down the "individual" path.  That matters because
  # `linCmt() ~ add(add.sd) + dnorm()` -- the exact-likelihood (Laplace) form of
  # a plain normal endpoint -- reports "dnorm" while having a posterior every
  # bit as Gaussian as the add() form, so it should not pay for per-subject
  # adaptation.
  #
  # Note lognormal / boxCox / yeoJohnson residuals are carried in
  # predDf$transform with distribution still "norm", so they canonicalize to
  # "dnorm" here and correctly count as Gaussian.
  .canon <- tryCatch(rxode2::rxPreferredDistributionName(.dist),
                     error=function(e) .dist)
  if (all(.canon == "dnorm")) "global" else "individual"
}

#' Build the $runInfo note naming the efficiency statistic in force
#'
#' Both statistics are always computed and stashed on the fit -- NONMEM-style
#' `xi` in `$impXiTrace`/`$impXi` and the Kish effective-sample fraction in
#' `$impNeffFrac`/`$impNeff` -- but only one of them drives the proposal-scale
#' adaptation, and they are NOT the same quantity even though both are governed
#' by `iaccept`.  Measured on theophylline at gamma = 1, mean xi = 1.009 while
#' the mean Kish fraction = 0.997; at gamma = 4 they read 0.503 and 0.667.
#' Reading one as if it were the other, or comparing a "global" fit's number
#' against an "individual" fit's, is a live mistake, so every fit says which is
#' which.
#'
#' @param resolved Resolved gammaMethod: "global" or "individual".
#' @param user What was requested ("auto", "global", "individual").
#' @param iaccept Target/floor value.
#' @return A single-line character message.
#' @noRd
.impmapGammaRunInfo <- function(resolved, user, iaccept) {
  .why <- if (identical(user, "auto")) {
    if (identical(resolved, "individual")) {
      " (auto: not every endpoint is Gaussian)"
    } else {
      " (auto: all endpoints are Gaussian)"
    }
  } else {
    ""
  }
  if (identical(resolved, "individual")) {
    paste0("gammaMethod=\"individual\"", .why,
           ": the proposal scale is adapted per subject toward xi=", iaccept,
           " (NONMEM IACCEPT).  Sampling efficiency for this fit is xi",
           " ($impXi/$impXiTrace); the Kish effective-sample fraction",
           " ($impNeffFrac) is a DIFFERENT statistic and the two are not",
           " comparable -- nor is xi comparable across gammaMethod settings.")
  } else {
    paste0("gammaMethod=\"global\"", .why,
           ": one shared proposal scale, adapted only when the mean Kish",
           " effective-sample fraction falls below ", iaccept,
           ".  Sampling efficiency for this fit is that fraction",
           " ($impNeff/$impNeffFrac); NONMEM-style xi ($impXiTrace) is also",
           " reported but is a DIFFERENT statistic and the two are not",
           " comparable.")
  }
}

#' Install the impmap control into the ui
#'
#' @param env Environment with ui in it
#' @param ... Other arguments
#' @return Nothing, called for side effects
#' @author Matthew L. Fidler
#' @noRd
.impmapFamilyControl <- function(env, ...) {
  .ui <- env$ui
  .control <- env$control
  if (is.null(.control)) {
    .control <- impmapControl()
  }
  if (!inherits(.control, "impmapControl")) {
    .control <- do.call(nlmixr2est::impmapControl, .control)
  }
  assign("control", .control, envir=.ui)
}

#' Fit the impmap family of models
#'
#' @param env Environment from nlmixr2Est
#' @param ui rxode2 ui object
#' @param ... Other arguments
#' @return fit environment
#' @author Matthew L. Fidler
#' @noRd
.impmapFamilyFit <- function(env, ui, ...) {
  # With est="impmap", foceiFitCpp_ runs impOuter() (src/imp.cpp) in place of
  # foceiOuter().  The FOCEI outer optimizer is turned off (maxOuterIterations=0)
  # because impOuter drives its own EM iteration; the in-fit covariance is off
  # (it would bail on muModel="lin" anyway) -- the covariance is computed
  # post-fit on the full model at the converged estimates (.foceiRecomputeMuCov).
  .control <- ui$control
  .covMethodUser <- .control$covMethod  # restored on the fit env control below
  .control$maxOuterIterations <- 0L
  .control$covMethod <- 0L
  # Resolve gammaMethod="auto" here, where the ui (and therefore predDf) is in
  # scope; the C++ kernel only ever sees a concrete "global"/"individual".
  #
  # Resolve from the USER's original choice, not from whatever this control
  # currently holds: resolution overwrites gammaMethod in place, so a control
  # taken off a finished fit (fit$env$impmapControl) and reused on a different
  # model would otherwise carry the previous model's resolved value and never
  # re-resolve -- an "auto" control from a log-likelihood fit would silently
  # pin a normal model to "individual".  Keying off gammaMethodUser makes
  # resolution idempotent and re-runnable.
  .gmUser <- .control$gammaMethodUser
  if (is.null(.gmUser)) .gmUser <- .control$gammaMethod
  .control$gammaMethodUser <- .gmUser                # kept for $runInfo
  .control$gammaMethod <- .impmapResolveGammaMethod(.gmUser, ui)
  # AUTO's "or data are categorical" trigger: reuse the same transformably-normal
  # test the gammaMethod resolution uses, so there is one notion of model class.
  .control$autoNonNormal <- identical(.impmapResolveGammaMethod("auto", ui), "individual")
  # Say which efficiency statistic this fit's number actually is.  Both are
  # always stashed but they are not the same quantity, and only one drives the
  # adaptation -- warning() here is the established route onto $runInfo
  # (collected in nlmixr2Est.R and printed under "Information about run").
  warning(.impmapGammaRunInfo(.control$gammaMethod, .gmUser,
                              .control$iaccept),
          call.=FALSE)
  # 0-based index maps for the SIMPLE mu-referenced intercepts (theta = population
  # mean of an eta, no covariates): impOuter's M-step shifts each such theta by
  # the mean conditional eta.  Covariate mu-groups are excluded here because they
  # are handled by the regression update (updateMuGroups) instead.
  .env <- ui$foceiOptEnv  # builds foceiMuGroupTheta (the covariate-group thetas)
  .iniDf <- ui$iniDf
  .th <- .iniDf[!is.na(.iniDf$ntheta), ]
  .thNames <- .th[order(.th$ntheta), "name"]
  .etaRows <- .iniDf[!is.na(.iniDf$neta1) & .iniDf$neta1 == .iniDf$neta2, ]
  .etaNames <- .etaRows[order(.etaRows$neta1), "name"]
  .mr <- ui$muRefDataFrame
  .muThetaIdx <- as.integer(match(.mr$theta, .thNames) - 1L)
  .muEtaIdx <- as.integer(match(.mr$eta, .etaNames) - 1L)
  .covGroupTheta <- rxode2::rxGetControl(ui, "foceiMuGroupTheta", integer(0))
  .keep <- !is.na(.muThetaIdx) & !is.na(.muEtaIdx) &
    !(.muThetaIdx %in% .covGroupTheta)
  .control$impMuThetaIdx <- .muThetaIdx[.keep]
  .control$impMuEtaIdx <- .muEtaIdx[.keep]
  # 0-based theta indices of the estimated non-mu thetas (structural + residual
  # error) with sensitivity outputs in the sensitivity model; the M-step Newton
  # update maps its output columns back to these thetas.
  .control$impThetaSensIdx <- as.integer(.impmapEstTheta(ui)$all - 1L)
  # 0-based eta indices whose Omega diagonal is FIXED; the EM Omega update
  # restores their rows/columns to the starting value so fix()ed variances hold.
  .etaOrd <- .etaRows[order(.etaRows$neta1), ]
  .control$impOmegaFixedEta <- as.integer(which(isTRUE(.etaOrd$fix) | .etaOrd$fix) - 1L)
  assign("control", .control, envir=ui)
  # Seed the importance-sampling RNG from the control (impSeed) right before the
  # fit, mirroring saem's set.seed(seed).  The E-step draws through rxode2's
  # threefry engine (getRxSeed1), so a fixed rxseed makes the fit reproducible
  # and independent of the thread count / ambient RNG state; rxWithSeed restores
  # the prior seed state afterward so it does not leak globally.
  .impSeed <- if (is.null(.control$impSeed)) 42L else as.integer(.control$impSeed)
  # est is "impmap" or "imp" (the no-MAP-search variant); pass it through so the
  # C++ kernel (impOuter) selects the proposal accordingly.
  .est <- if (exists("est", envir=env)) get("est", envir=env) else "impmap"
  .fit <- rxode2::rxWithSeed(.impSeed, rxseed=.impSeed,
                             code=.foceiFamilyReturn(env, ui, ..., est=.est))
  # The MC covariance (impCov=TRUE) is published with theta row/column names but
  # the Omega parameters come out unnamed on this path; fill them in (defensively,
  # only when the counts line up) so vcov()/$cov and the correlation are labelled.
  .impmapNameCov(.fit, ui)
  .impRestoreCovMethod(.fit, .covMethodUser)
  # Capture THIS fit's pooled-solve layout before anything else runs.  The odeSwap
  # registry is process-global and describes the most recent registration, so the
  # objective recompute below -- which runs a nested focei fit -- re-registers the
  # slots and the global view stops describing this fit.  Stash it here so the
  # diagnostic travels with the fit and cannot be overwritten by a later one.
  tryCatch({
    .fenv0 <- .fit$env
    if (is.environment(.fenv0)) assign("odeSwapInfo", .odeSwapInfo(), envir=.fenv0)
  }, error=function(e) NULL)
  .impmapRecomputeObjf(.fit)
  # Tail-sensitive companion to xi / Kish ESS: computed post-fit from the
  # stashed final-iteration weights so it costs nothing during the EM.
  tryCatch({
    .fenv <- .fit$env
    if (is.environment(.fenv)) {
      assign("impPsisK", .impPsisKAll(.fenv), envir=.fenv)
    }
  }, error=function(e) NULL)
  .fit
}

#' Publish the objective as a FOCEi evaluation at the converged estimates.
#'
#' The in-C++ finalize (`impMapPass` -> `foceiOuterFinal`) computes the individual
#' objective from an eta-Hessian that never receives its data term on this path:
#' `fInd->a` (d(pred)/d(eta)) is left at allocation residue, so
#' `sum(cHff * a * a)` vanishes and `H` collapses to `Omega^-1` alone.  Measured on
#' theophylline with one random effect: `log|H|` reads 2.17048 for EVERY subject
#' instead of the correct 3.63-3.96, and the published objective comes out 19.96
#' too LOW (173.63 against 193.60).  Models with 2+ random effects are unaffected.
#'
#' Rather than repair that C++ state, take the objective the same way `setOfv()`
#' does for SAEM (`.setOfvFo`, `R/ofv.R`): re-evaluate through the ordinary
#' `nlmixr2()` FOCEi path at the converged estimates.  `maxOuterIterations = 0`
#' pins the thetas and Omega; the INNER problem is deliberately left to optimize,
#' because the FOCEi objective is defined with the etas at their conditional mode
#' -- freezing them at the importance-sampling conditional means is a different
#' (and worse) quantity, measured at 193.615 against 193.601 for the mode.
#'
#' The EM's own estimates are untouched: only the published objective is replaced.
#' `$impObj` (the importance-sampling estimate) is left alone, so the two remain
#' separately readable.
#'
#' @param fit completed impmap-family fit
#' @return invisibly TRUE when the objective was replaced
#' @noRd
.impmapRecomputeObjf <- function(fit) {
  .env <- tryCatch(fit$env, error=function(e) NULL)
  if (!is.environment(.env)) return(invisible(FALSE))
  # deep-copy the UI: the nested re-fit must not mutate THIS fit's UI
  .ui <- tryCatch(rxode2::rxUiDecompress(unserialize(serialize(fit$ui, NULL))),
                  error=function(e) NULL)
  if (is.null(.ui)) return(invisible(FALSE))
  .sigdig <- tryCatch(fit$foceiControl$sigdig, error=function(e) NULL)
  # A nested nlmixr2() calls .nlmixr2globalReset(), which clears nlmixr2global --
  # including the timing environment (dropping the outer fit's "other" timing row)
  # and nlmixr2objectName (which would report ".ui" instead of the user's symbol).
  # Snapshot and restore the whole thing, plus the mu-referencing global.
  .savedMuRef <- .muRefTrans$cur
  on.exit(.muRefTrans$cur <- .savedMuRef, add=TRUE)
  .savedGlobal <- as.list(nlmixr2global, all.names=TRUE)
  on.exit({
    rm(list=ls(nlmixr2global, all.names=TRUE), envir=nlmixr2global)
    for (.gn in names(.savedGlobal)) assign(.gn, .savedGlobal[[.gn]], envir=nlmixr2global)
  }, add=TRUE)
  .ctl <- try(foceiControl(print=0L, covMethod="", maxOuterIterations=0L,
                           calcTables=FALSE, compress=FALSE,
                           sigdig=if (is.null(.sigdig)) 4 else .sigdig),
              silent=TRUE)
  if (inherits(.ctl, "try-error")) return(invisible(FALSE))
  .f2 <- try(suppressMessages(suppressWarnings(
    nlmixr2(.ui, data=nlme::getData(fit), est="focei", control=.ctl))),
    silent=TRUE)
  if (inherits(.f2, "try-error")) return(invisible(FALSE))
  .e2 <- tryCatch(.f2$env, error=function(e) NULL)
  if (!is.environment(.e2)) return(invisible(FALSE))
  # carry the objective AND the per-subject quantities derived from the same
  # (correct) Hessian, so $etaObf/$phiH do not disagree with the published number
  for (.n in c("objective", "OBJF", "objf", "logLik", "AIC", "BIC",
               "etaObf", "etaObfFull", "phiH", "phiC", "phiR", "phiSE", "phiRSE")) {
    if (exists(.n, envir=.e2, inherits=FALSE)) {
      assign(.n, get(.n, envir=.e2), envir=.env)
    }
  }
  # objDf is MERGED, not replaced: the re-fit runs covMethod="" so its objDf has no
  # Condition#(Cov)/Condition#(Cor), and replacing wholesale would drop the columns
  # the imp covariance had already filled in.
  if (exists("objDf", envir=.e2, inherits=FALSE)) {
    .newObjDf <- get("objDf", envir=.e2)
    .oldObjDf <- tryCatch(get("objDf", envir=.env, inherits=FALSE), error=function(e) NULL)
    if (is.data.frame(.oldObjDf) && is.data.frame(.newObjDf) &&
          nrow(.oldObjDf) == nrow(.newObjDf)) {
      for (.c in intersect(names(.newObjDf), names(.oldObjDf))) {
        .oldObjDf[[.c]] <- .newObjDf[[.c]]
      }
      assign("objDf", .oldObjDf, envir=.env)
    } else {
      assign("objDf", .newObjDf, envir=.env)
    }
  }
  invisible(TRUE)
}

#' Restore the requested covMethod on the fit env's stored control
#'
#' The estimation pass forces covMethod=0L (the in-fit C++ step would bail on
#' muModel="lin"), and that runtime control is what gets stored on the fit env;
#' the post-fit recompute (.foceiRecomputeMuCov) reads the covMethod from there,
#' so put the requested choice back.
#' @noRd
.impRestoreCovMethod <- function(fit, covMethod) {
  .fenv <- tryCatch(fit$env, error = function(e) NULL)
  if (is.environment(.fenv) &&
        exists("impmapControl", envir = .fenv, inherits = FALSE)) {
    .ic <- get("impmapControl", envir = .fenv)
    .ic$covMethod <- covMethod
    assign("impmapControl", .ic, envir = .fenv)
  }
  invisible(fit)
}

#' Fill the Omega row/column names on the impmap covariance
#' @param fit impmap fit
#' @param ui rxode2 ui
#' @return Nothing, called for side effects
#' @noRd
.impmapNameCov <- function(fit, ui) {
  .fenv <- tryCatch(fit$env, error=function(e) NULL)
  if (is.null(.fenv) || is.null(.fenv$cov) || !is.matrix(.fenv$cov)) return(invisible())
  tryCatch({
    .dn <- dimnames(.fenv$cov)[[1]]
    if (is.null(.dn)) return(invisible())
    .empty <- which(is.na(.dn) | .dn == "")
    if (length(.empty) == 0L) return(invisible())
    .etaN <- .foceiEtaThetaMap(ui)$etaNames
    .op <- .foceiOmegaPairs(.fenv$omega, ui$iniDf)
    .omN <- .foceiOmegaCovNames(.op, .etaN)
    if (length(.omN) == length(.empty)) {
      .dn[.empty] <- .omN
      dimnames(.fenv$cov) <- list(.dn, .dn)
      if (!is.null(.fenv$fullCor) && is.matrix(.fenv$fullCor)) {
        dimnames(.fenv$fullCor) <- list(.dn, .dn)
      }
    }
  }, error=function(e) NULL)
  invisible()
}

#' @rdname nlmixr2Est
#' @export
nlmixr2Est.impmap <- function(env, ...) {
  .ui <- env$ui
  # General (dnorm/ll) likelihoods flow through the shared FOCEI inner problem
  # (impEvalJointLik = likInner0), so only require transformable normality when
  # the rxode2 build has no llik support -- mirrors nlmixr2Est.focei.
  if (!rxode2hasLlik()) {
    rxode2::assertRxUiTransformNormal(.ui, " for the estimation routine 'impmap'", .var.name=.ui$modelName)
  }
  rxode2::assertRxUiIovNoCor(.ui, " for the estimation routine 'impmap'",
                             .var.name=.ui$modelName)
  .control <- env$control
  .foceiFamilyControl(env, ..., type="impmapControl")
  on.exit({
    if (is.environment(.ui) && exists("control", envir=.ui, inherits=FALSE)) {
      rm("control", envir=.ui)
    }
  }, add=TRUE)
  env$impmapControl <- .control
  env$est <- "impmap"
  .ui <- env$ui
  .impmapFamilyFit(env, .ui, ...)
}
attr(nlmixr2Est.impmap, "covPresent") <- TRUE
attr(nlmixr2Est.impmap, "unbounded") <- .foUnbounded
attr(nlmixr2Est.impmap, "iov") <- TRUE
# Activates the mu2/mu3/mu4 covariate-rewriting hook (.uiApplyMu2hook, R/mu2.R),
# gated on muModel/muRefCovAlg, exactly as the mfocei family does.
attr(nlmixr2Est.impmap, "mu") <- function(control) {
  isTRUE(!identical(control$muModel, "none")) && isTRUE(control$muRefCovAlg)
}

Try the nlmixr2est package in your browser

Any scripts or data that you put into this service are public.

nlmixr2est documentation built on Aug. 5, 2026, 1:11 a.m.