Individual Parameters

Maturity: beta — see Feature Maturity for what this means.

The [individual_parameters] block defines how population parameters (theta), random effects (eta), and covariates combine to produce individual PK parameters.

Syntax

PARAM = expression

Each line assigns a PK parameter using an arithmetic expression that can reference:

  • Theta parameters – names defined in [parameters] (e.g., TVCL, TVV)
  • Eta random effects – names defined as omega parameters (e.g., ETA_CL, ETA_V)
  • Covariates – column names from the data file (e.g., WT, CRCL)
  • Event time – the built-ins TIME and time
  • Constants – numeric literals (e.g., 70, 0.75)

Evaluation Order

Statements are evaluated top to bottom, so a name must be declared before it is used. A line may reference any parameter assigned on an earlier line, but a reference to a name defined later in the block is a parse error (caught by ferx check too), not a silent 0.0:

[individual_parameters]
  CL   = TVCL * exp(IMAX) * exp(ETA_CL)   # ERROR: IMAX declared below
  IMAX = TVIMAX

Reorder so each name is defined before it is referenced (IMAX above CL). This matches NONMEM’s $PK-block convention and prevents a mistyped or out-of-order name from quietly reading as zero and collapsing the formula.

Supported Operators and Functions

Operator/Function Example
+, -, *, / TVCL * WT / 70
^ (power) (WT/70)^0.75
exp() exp(ETA_CL)
log(), ln() log(TVCL)
sqrt() sqrt(WT)
abs() abs(ETA_CL)
floor(), ceil(), round() floor(AGE / 10)
logit() logit(THETA_F)
inv_logit(), expit() inv_logit(logit(THETA_F) + ETA_F)
min(a, b), max(a, b) max(TVCL * WT / 70, 0.1)
clamp(x, lo, hi) clamp(ACR20, 0.01, 0.99)
present(x) (condition only) if (present(WT)) (WT/70)^0.75 else 1.0
Parentheses TVCL * (WT/70)^0.75
Comparisons (in if conditions) WT > 70, SEX == 1, AGE != 0
Logical (in if conditions) && (and), \|\| (or), ! (not)
Inline conditional if (SEX == 1) TVCL * 1.5 else TVCL

min / max take exactly two arguments and desugar to the inline conditional (max(a, b) → if (a >= b) a else b), so they differentiate and compile identically to the hand-written form. A one-argument max(x) is a parse error rather than the silent identity it used to be (#1030).

clamp(x, lo, hi) takes exactly three arguments and is the two-sided bound: clamp(ACR20, 0.01, 0.99) says what min(max(ACR20, 0.01), 0.99) says, without the argument order flipping between the inner and outer call. It desugars to the same nested conditional — if (x <= lo) lo else if (x >= hi) hi else x — so on each bound the bound is returned and the derivative there is zero. Any other arity is a parse error, and literal bounds the wrong way round (clamp(x, 0.9, 0.1)) are rejected rather than silently returning 0.9 for every x; bounds that are not both literals are not checked (#1092).

The table above is the complete list. Calling anything else is a parse error naming the call and listing what is available:

unknown function `tanh`. Supported: exp, log, ln, sqrt, abs, floor, ceil, round,
logit, inv_logit, expit, min(a, b), max(a, b), clamp(x, lo, hi),
present(x) (conditions only)

Before ferx 0.5 an unrecognised name was silently the identity, so CL = TVCL * tanh(ETA_CL) parsed, passed ferx check, fitted and converged while computing TVCL * ETA_CL — a consistently wrong model with no warning anywhere (#1332). The realistic triggers were ordinary: a function that exists in NONMEM, nlmixr2 or mrgsolve but not here (tanh, sinh, sign, log10), and plain typos (sqr(x), exp1(x)). Names are matched case-insensitively, so the NONMEM $PK spellings EXP(...) / LOG(...) keep working; TANH(...) is rejected exactly like tanh(...).

floor, ceil and round are piecewise constant, so their analytic derivative is 0 away from a step (the integer boundaries are undefined and ignored, as for %). A parameter that rounds is therefore flat with respect to the value it rounds, which is usually what a dose-band or occasion-index lookup wants — but it does mean an η that only enters through a rounded quantity gets a zero gradient.

Continuation Lines

A long expression may be split across several physical lines. Two spellings work, and both are unambiguous because no statement can begin with a binary operator: put the operator at the start of the continued line, or at the end of the line being continued.

[individual_parameters]
  LEMAX = LEMAX0
          + log(OR_ABATA) * ABATA
          + log(OR_ADALI) * ADALI
          + ETA_EMAX

  KEL   = CL /
          V

This is the natural layout for an additive-on-logit covariate model — one covariate per line — which previously had to be collapsed onto a single line (#1030). A trailing = continues too, so LEMAX = on its own line followed by an indented expression is accepted. The same applies in [odes], [scaling], [derived], and [initial_conditions]; newlines inside (...) or [...] have always been ignored and still are.

Conditional Logic (if / else)

Two forms are supported and may be combined freely.

Block form

[individual_parameters]
  if (WT > 70) {
    CL = TVCL * (WT / 70)^0.75 * exp(ETA_CL)
  } else if (SEX == 1) {
    CL = TVCL * 1.2 * exp(ETA_CL)
  } else {
    CL = TVCL * exp(ETA_CL)
  }
  V = TVV * exp(ETA_V)

The block form is appropriate when the body contains multiple statements or when several alternative branches are needed. Conditions support comparison operators (<, <=, >, >=, ==, !=) and logical operators (&&, ||, !); parentheses group sub-conditions.

Inline (ternary) form

[individual_parameters]
  CL = if (SEX == 1) TVCL * 1.5 else TVCL
  V  = TVV  * exp(ETA_V)

The inline form produces a value and can appear anywhere an expression is allowed. Both then and else branches are required.

present(...) — guarding a covariate that may be missing

A missing covariate value (blank, ., NA in the data) is carried as NaN. present(X) is true whenever X is not missing:

[individual_parameters]
  CL = TVCL * (if (present(CRCL)) (CRCL / 100)^THETA_CRCL else 1.0) * exp(ETA_CL)

Guard any covariate that can be missing. Without the guard the factor above evaluates to 0 on a row with no CRCL — not to an error or a NaN you would notice — because division by a missing value underflows to zero here. The parameter is silently zeroed for that subject.

present(...) takes any expression (present(WT * 2)), negates (!present(WT)) and combines (present(WT) && WT > 100) like any other condition. It is equivalent to the older idiom X == X, which works for the same reason (NaN compares false against itself) but reads like a typo.

It is a condition, not a value-returning function, so TVCL * present(WT) is not valid — deliberately: as arithmetic it would produce exactly the silently zeroed parameter the guard exists to prevent. A covariate column actually named present is unaffected; the name is only special immediately followed by ( at the head of a condition.

The [covariate_model] block emits this guard around every factor it generates.

Time-dependent parameters

TIME and time are built-ins, not dataset covariates. They evaluate to the current PK event time when ferx evaluates [individual_parameters]: dose records, observations, and EVID=2 parameter-update records each see their own event time. This supports NONMEM-style $PK switches such as:

[individual_parameters]
  if (TIME > 45) {
    CL = TVCL_LATE * exp(ETA_CL)
  } else {
    CL = TVCL * exp(ETA_CL)
  }
  V = TVV * exp(ETA_V)

For analytical models, using TIME/time routes the subject through the same event-driven parameter-switching path used for time-varying covariates. This matches the NONMEM $PK IF (TIME.GT.45) ... event-time convention: $PK is re-evaluated at each event, and the analytical amount is advanced across each inter-event interval with the parameters in effect at the interval’s end event. For reset-stacked data where the displayed sdtab TIME may use the raw per-occasion clock, this built-in follows the internal monotone PK event clock.

Numerical check against NONMEM

A one-compartment IV bolus (dose 100 at t = 0, V = 10) with a CL switch at TIME = 10 from CL_E = 1 to CL_L = 5, observed at t = 5 and t = 20:

[individual_parameters]
  if (TIME > 10) { CL = CL_L } else { CL = CL_E }
  V = V
Obs TIME Active CL advance ferx PRED NONMEM $PK IF(TIME.GT.10)
5 CL_E over [0, 5] 10·e^(−0.5) = 6.065307 6.065307
20 CL_E [0,5] then CL_L [5,20] 10·e^(−8.0) = 0.003355 0.003355

The t = 20 prediction is not 10·e^(−2) ≈ 1.35 (what a frozen TIME = 0 would give, since the switch would never fire) and not 10·e^(−10) (what a single CL_L applied over the whole history would give): the value 10·e^(−8) is the event-driven result NONMEM produces, where CL_L governs only the [5, 20] advance. This equivalence is locked by tests/time_origin.rs::time_builtin_cl_switch_matches_nonmem_event_time_semantics; the same TIME event clock drives ODE right-hand sides (tests/time_origin.rs::ode_time_builtin_*) and [derived] columns (tests/derived_output.rs::derived_time_builtin_echoes_per_observation_time).

Analytic gradient & SE check against NONMEM (#486)

On closed-form (non-IOV) 1-/2-/3-cpt models, a TIME-switched structural parameter gets the exact analytic FOCE/FOCEI gradient (the per-event time is threaded into the same event-driven Dual2/Dual1 sensitivity walk used for time-varying covariates), not finite differences — so the optimiser, its standard errors, and NONMEM’s METHOD=1 INTER agree. Fitting a 60-subject 1-cpt IV bolus simulated with a CL switch at TIME = 45 (IF (TIME.GT.45) CL = TVCL_LATE …):

Parameter ferx (analytic) NONMEM METHOD=1 INTER
OFV −3737.394 −3737.394
TVCL 2.1029 (SE 0.0733) 2.1029 (SE 0.0736)
TVCL_LATE 1.0457 (SE 0.0381) 1.0456 (SE 0.0383)
TVV 53.107 (SE 1.364) 53.108 (SE 1.364)
ω²(CL) 0.07212 (SE 0.01343) 0.07212 (SE 0.01344)
ω²(V) 0.03458 (SE 0.00692) 0.03458 (SE 0.00692)
PROP_ERR 0.10274 (SE 0.00299) 0.10274 (SE 0.00299)

The same dataset fit with an ODE version of the model (ode(states=[central]) with d/dt(central) = -(CL/V)·central and a Form-C [scaling] y = central/V readout) reproduces the fit under its own analytic gradient — OFV −3737.395, TVCL 2.1029, TVCL_LATE 1.0457, TVV 53.107, identical SEs — confirming the ODE route threads the per-event TIME the same way (the ~0.002 OFV difference is ODE-integrator vs NONMEM’s analytic ADVAN1).

Estimates match to 4–5 significant figures and standard errors to ~0.3%. The analytic gradient itself is pinned against central finite differences of the production predictor across every supported route: time_builtin_provider_matches_fd_of_production (closed-form non-IOV, 1-/2-cpt IV switch and 1-cpt oral), iov_time_builtin_provider_matches_fd_of_predict_iov (closed-form IOV, stacked [η_bsv, κ]), and ode_time_builtin_provider_matches_fd_of_production (non-IOV ODE), and ode_iov_time_builtin_provider_matches_fd_of_predict_iov (ODE IOV) — including in combination with an η-dependent ExpressionScale obs_scale (the event-driven walk now applies the subject-static scale quotient post-walk). A direct pk(...=TIME) structural mapping is served analytically too: the parser desugars the mapped slot into a hidden individual parameter (__ferx_pktime_*), so it is exactly the explicit [individual_parameters] form and rides the same per-event walk (time_builtin_direct_pk_mapping_matches_fd_of_production, time_builtin_direct_pk_mapping_equivalent_to_explicit_indiv_param).

Interaction with mu-referencing

When the assignment to a parameter is wrapped in an if block, the (ETA → THETA) relationship is no longer unconditional, so ferx skips mu-reference detection for that parameter. Unconditional assignments in the same block continue to be detected normally.

Tip: if you want mu-referencing for a covariate-adjusted parameter, keep the assignment unconditional and bury the conditional inside the covariate term (e.g. CL = TVCL * (if (WT > 70) (WT / 70)^0.75 else 1.0) * exp(ETA_CL)).

Common Parameterizations

Exponential (log-normal) random effects

The standard approach for PK parameters that must be positive:

[individual_parameters]
  CL = TVCL * exp(ETA_CL)
  V  = TVV  * exp(ETA_V)
  KA = TVKA * exp(ETA_KA)

Allometric scaling with covariates

[individual_parameters]
  CL = TVCL * (WT/70)^0.75 * exp(ETA_CL)
  V  = TVV  * (WT/70)^1.0  * exp(ETA_V)

Estimated covariate effects

Use additional theta parameters for covariate coefficients:

[parameters]
  theta TVCL(0.134, 0.001, 10.0)
  theta THETA_WT(0.75, 0.01, 2.0)
  theta THETA_CRCL(0.5, 0.01, 2.0)

[individual_parameters]
  CL = TVCL * (WT/70)^THETA_WT * (CRCL/100)^THETA_CRCL * exp(ETA_CL)

Logit-normal bioavailability

Use inv_logit(logit(THETA_F) + ETA_F) to constrain bioavailability to (0, 1). The starting value for THETA_F is set directly on the (0, 1) scale — whatever fraction you specify is what the optimiser uses as the typical F:

[parameters]
  theta THETA_F(0.70, 0.001, 0.999)  # typical bioavailability = 70%
  omega ETA_F ~ 0.10                 # BSV on the logit scale

[individual_parameters]
  F = inv_logit(logit(THETA_F) + ETA_F)
  • When ETA_F = 0, F_i = THETA_F exactly (the logit and inv_logit cancel).
  • ETA_F shifts each individual’s F on the logit scale, symmetrically around the typical value.
  • The estimated THETA_F in the output is directly interpretable as the typical bioavailability.
  • omega ETA_F is variance on the logit scale — not the variance of F itself.

The alternative form inv_logit(THETA_F + ETA_F) is also supported, where THETA_F is on the logit scale (e.g., logit(0.70) ≈ 0.847). This is less readable but may be useful when comparing with NONMEM models — and, as the next section explains, it is the form whose typical value SAEM / IMP can update in closed form.

Automatic MU-referencing

When a line matches one of the patterns

PARAM = THETA * exp(ETA)              # multiplicative / log-normal
PARAM = THETA * <anything> * exp(ETA) # multiplicative with covariate terms
PARAM = exp(log(THETA) + ETA)         # canonical MU form
PARAM = THETA + ETA                   # additive
PARAM = inv_logit(THETA + ETA)        # logit-normal, THETA on the logit scale
PARAM = inv_logit(logit(THETA) + ETA) # logit-normal, THETA on the (0,1) scale
PARAM = 1/(1 + exp(-(THETA + ETA)))   # the same, written out by hand

ferx records the (ETA → THETA) mapping and uses it to re-centre the inner-loop ETA search at each outer iteration — reproducing NONMEM / nlmixr2’s MU-referencing behaviour without requiring you to write an explicit MU_i line. See the FAQ for details on which patterns are and are not detected, and for the mu_referencing = false escape hatch.

Typical values on their own line, and explicit MU_ variables

Detection also sees through a local variable that is defined earlier in the block, as long as that variable is assigned exactly once (never inside an if) and does not itself contain an ETA. So both of these are recognised, and give the same (ETA_CL → TVCL) mapping as the one-line forms above:

[individual_parameters]
  # typical value on its own line
  TVCL = THETA_CL * (WT/70)^0.75
  CL   = TVCL * exp(ETA_CL)

  # NONMEM-style explicit MU_ syntax
  MU_1 = log(THETA_V)
  V    = exp(MU_1 + ETA_V)

Logit-normal parameters and the EM estimators

SAEM and IMP/IMPMAP update a mu-referenced typical value with the closed-form EM step mu += gamma * mean(eta), which is much better conditioned than the numeric M-step. That step is applied on the scale the optimiser packs the theta on, so it is used only when the packed scale is the mu scale:

Form mu scale Closed-form EM step
THETA * exp(ETA) log(THETA) yes — THETA is log-packed (lower bound ≥ 0)
inv_logit(THETA + ETA) THETA yes — a logit-scale THETA has a negative lower bound, so it is packed as-is
inv_logit(logit(THETA) + ETA) logit(THETA) no — falls back to the numeric M-step
THETA + ETA THETA no — falls back to the numeric M-step

Both logit forms are correct and both are mu-referenced; the difference is only which one gets the faster, better-behaved M-step. For a bounded parameter that is estimated with SAEM or IMP — especially in a model that also has IIV on the residual error, where the numeric M-step sees noisy conditional samples — prefer

theta LOGIT_F(-0.477, -10.0, 10.0)   # declared on the logit scale

F = inv_logit(LOGIT_F + ETA_F)       # or 1/(1 + exp(-(LOGIT_F + ETA_F)))

and convert back with inv_logit(LOGIT_F) when reporting.

Covariate models that read several thetas

A typical value built from two or more thetas has no single anchor theta:

CL = (TVCL + (CRCL - 90) * TH_CRCL) * exp(ETA_CL)   # additive covariate
CL = TVCL * (WT / 70) ^ TH_WT * exp(ETA_CL)           # estimated allometric exponent
CL = TVCL * exp(TH_AGE * (AGE - 40)) * exp(ETA_CL)    # exponential covariate
F  = inv_logit(LOGIT_F + TH_SEX * SEX + ETA_F)        # logit-scale covariate

Since #619 the parser records each of these as a covariate mu-reference — the whole eta-free typical value, all the thetas it reads, and the link (log for the * exp(ETA) and exp(log(A) + ETA) spellings, logit for inv_logit(A + ETA)). SAEM and IMP/IMPMAP then re-fit those thetas jointly to the population of individual values every iteration, the way NONMEM does for a MU written as a function of several thetas (MU_1 = LOG(THETA(1) + (CRCL-90)*THETA(2))), instead of leaving the slope on the eta-frozen numerical M-step where it used to drift. Local definitions are seen through (TVCL = THETA_CL + TH_CRCL * (CRCL - 90) then CL = TVCL * exp(ETA_CL)), as long as the intermediate is assigned once and carries no eta.

The single-anchor forms above are unaffected: a value with exactly one theta keeps its plain mu-reference, and a form that also matches one (the allometric power, whose anchor is TVCL) keeps it alongside the covariate mu-reference for the inner-loop centring and reporting. FOCE/FOCEI/Laplace fits do not change. Not recorded: a value that reads TIME or MIXNUM, a second eta, an NN output or θ level block, or a local variable assigned inside an if — those stay on the numerical M-step, and the SAEM warning still names them.

A theta the rest of the model also reads (CL = (TVCL + TH_X*WT) * exp(ETA_CL) next to V = TVV + TH_X) is still estimated by the group, but the M-step keeps the observation term: preserving the individual CL does not pin V, so the data are not independent of TH_X. ferx detects this and names the theta in a fit warning; the only cost is a slower M-step. See the SAEM page for the M-step and its validation against NONMEM.

Covariate Detection

Any uppercase identifier in the expression that does not match a theta name, eta name, or built-in such as TIME is automatically treated as a covariate. The covariate value is read from the corresponding column in the data file.

For example, in CL = TVCL * (WT/70)^0.75 * exp(ETA_CL): - TVCL matches a theta parameter - ETA_CL matches an omega parameter - WT matches neither, so it is treated as a covariate column

A covariate factor like the one above can also be declared as data in [covariate_model] — CL ~ WT power(center = 70) — which desugars into exactly this expression, with the theta declaration, the PsN scm default bounds and a missing-value guard filled in. That form is worth reaching for when a covariate search or an agent will be rewriting the model.

Forgetting an omega declaration

Because unmatched names fall through to covariates, deleting an omega line while leaving its exp(ETA_…) term in place turns that random effect into a covariate lookup. ferx catches this on the name’s shape: an unresolved identifier beginning ETA or KAPPA is reported as an undeclared random effect rather than a missing covariate.

$ ferx check model.ferx --data data.csv
error[E_ETA_NOT_DECLARED] parameters:1: Model references ETA_CL as a random effect
but no `omega` / `kappa` declaration defines it. Declare the random effect in
[parameters] (e.g. `omega ETA_CL ~ 0.09`), or — if this is meant to be a data
column — add that column to the data file. A model with no random effects at all
is valid (#989): to make it fixed-effects-only, drop the `exp(ETA_CL)` term as
well as the omega line.
    help: declare `omega ETA_CL ~ <variance>`
invalid: model — 1 error(s), 0 warning(s)

(The diagnostic is one line; it is wrapped here to fit the page.)

Without --data this is a warning rather than an error, since ferx cannot yet know whether the dataset carries such a column: reading a NONMEM-exported ETA_CL column as a genuine covariate is legitimate, and in that case the name resolves and nothing is reported.

To make a model deliberately fixed-effects-only, remove the exp(ETA_…) term as well as the omega line — see no omega at all.

PK Parameter Names

The parameter names on the left side of each assignment must map to recognized PK parameter names:

Name PK Parameter
CL Clearance
V or V1 Volume of distribution (central compartment)
Q Intercompartmental clearance
V2 Peripheral volume
KA Absorption rate constant
F Bioavailability (default 1.0 if omitted)
LAGTIME (alias: ALAG) Dose/absorption lagtime (default 0.0 if omitted) — see Lagtime

F and LAGTIME/ALAG are the analytical engine’s single bioavailability and lag slots. A compartment-indexed F{n}/ALAG{n}/LAGTIME{n} name is an ODE-model dose attribute; on an analytical pk model you may map such a name into the single slot (pk(..., f=F1)), but an unmapped F{n}/ALAG{n} is a parse error rather than a silent no-op (#725).

ImportantThese names are applied to the dose, so don’t read them back

On an ODE model the engine consumes F, LAGTIME/ALAG and the indexed F{n}/ALAG{n}/LAGTIME{n} at the dose event — they never appear in a correct [odes] RHS, [scaling] readout, or [adaptive_dosing] observe signal. Declaring one and referencing it on the prediction path applies it twice and is rejected with E_DOSE_ATTR_DOUBLE_USE (#993). If you need an ordinary parameter, rename it: the name is what routes it.

An init(...) seed is not one of those surfaces, on either engine: an initial condition is not an absorbed dose, so the engine seeds it with the raw value and init(central) = F * 100 applies F exactly once (#1046).

The analytical engine applies the same rule, with one difference: there the pk(...) mapping — not the name — is what binds a parameter to the dose (#1004). A model that maps pk(..., f=F) or lagtime=TLAG and also reads that parameter in [scaling] or [adaptive_dosing] observe is rejected with the same code. So the remedy differs: drop the mapping, not the name — renaming a mapped parameter changes nothing, because the mapping follows it. A parameter merely named F that no pk(...) argument maps is an ordinary parameter on this engine and stays silent.

[initial_conditions] is not one of those surfaces either — the analytical spelling of the same carve-out: the engine deposits the amount with F = 1 and no lag, so init(depot) = F * 500 applies F exactly once.

This is a deliberate divergence from NONMEM, which computes the same doubled value without a diagnostic: $PK defining F1 and S2 = V/F1 (rather than the CL/F, V/F apparent-parameter convention, where F1 is not separately defined) runs clean under ADVAN2 and returns predictions scaled by exactly F1 — measured in tests/dose_attr_double_use_nonmem_anchor.rs.

D{n}/R{n} (modeled infusion duration/rate) carry the same reservation, but the engine only consults them for a dose that codes RATE=-2/-1. That check therefore runs against the dataset, not the model file — see Reading a dose attribute is an error, not a warning.

Reporting is unaffected: referencing any of these from [derived] or [output] is post-solve tabulation, not a second application, and stays silent.

For ODE models, the parameter names are user-defined and passed as a flat vector to the ODE right-hand side function.