Parameter transforms: log, logit, and additive ETAs
ferx supports three parameterisation styles for individual parameters:
| Style | DSL pattern | Use case |
|---|---|---|
| Log-normal | P = TVP * exp(ETA_P) |
Most PK parameters (CL, V, KA) — positive by construction |
| Logit-normal | P = inv_logit(logit(THETA_P) + ETA_P) |
Fractions/probabilities (F, bioavailability) — bounded (0, 1) |
| Additive | P = TVP + ETA_P |
Parameters with natural additive variability (lag time, threshold) |
The optimiser always works on the unbounded or transformed scale; ferx back-transforms to the natural scale when reporting results.
For a deeper treatment with CV% formulas and worked examples, see Chapter 7 (Parameter transforms) in the ferx book.
Log-normal ETAs (default)
The standard warfarin model uses log-normal ETAs for CL, V, and KA. This is the default: each individual parameter equals the typical value multiplied by exp(ETA), which keeps the parameter positive and gives a log-normal distribution across subjects.
ex <- ferx_example("warfarin")
ferx_model_inspect(ex$model)After fitting, print() reports omega as [log-normal] with a CV%:
fit <- ferx_fit(ex$model, ex$data, method = "focei", covariance = FALSE)
fit
# OMEGA (between-subject variability)
# ------------------------------------------------------------
# ETA_CL [log-normal] = 0.027888 CV% = 16.8 SE = N/A
# ETA_V [log-normal] = 0.009489 CV% = 9.8 SE = N/A
# ETA_KA [log-normal] = 0.356897 CV% = 65.5 SE = N/A
# (covariance = FALSE, so SE columns are NA)The CV% is derived from the log-scale variance as CV% = 100 * sqrt(exp(omega_var) - 1). For small variances this is approximately 100 * sqrt(omega_var), but the exact formula is always used.
ferx_estimates() returns the omega variance on the log scale in estimate, with transform = "variance" (omega rows always use this label regardless of the ETA parameterisation style — the log-normal interpretation is conveyed by print.ferx_fit(), not by the tidy table). SE and 95 % CI columns are on the same log-variance scale:
ferx_estimates(fit)
# param transform estimate se rse_pct lower_95 upper_95
# ETA_CL variance 0.027888 NA NA NA NA
# ETA_V variance 0.009489 NA NA NA NA
# ETA_KA variance 0.356897 NA NA NA NA
# SE/CI columns are NA because covariance = FALSE was used above.
# Run with covariance = TRUE to populate them.
# estimate_natural / lower_95_natural / upper_95_natural are NA for omega rows
# (back-transform is not defined for variance parameters)Choosing initial omega values
A common question is: “what omega value corresponds to 30% BSV?”
cv_targets <- c(10, 20, 30, 40, 50, 60, 80, 100)
data.frame(
cv_pct = cv_targets,
omega_approx = round((cv_targets / 100)^2, 4),
omega_exact = round(log(1 + (cv_targets / 100)^2), 4)
)Use the exact formula (log(1 + CV²)) for omega variances above 30% — the approximate formula CV²/100² underestimates the required starting value and can slow convergence.
Quick rule: for CV% ≤ 30%, omega ≈ (CV/100)² is fine. For CV% > 30%, use omega = log(1 + (CV/100)²).
Logit-normal ETA (bioavailability)
When a parameter is a fraction bounded between 0 and 1, use inv_logit / logit to ensure the individual values stay in (0, 1):
F = inv_logit(logit(THETA_F) + ETA_F)
THETA_F is specified on the natural (0, 1) scale in [parameters]; ferx maps it to the logit scale internally so the optimiser works on an unbounded space. The omega for ETA_F is the variance on the logit scale.
ex_logit <- ferx_example("warfarin_logit_f")
ferx_model_inspect(ex_logit$model)After fitting, print() shows:
THETA_Fwith[logit scale]and a 95 % CI back-transformed throughinv_logitso the CI is on the (0, 1) scale.ETA_Fwith[logit],SD_logit, and a ±1 SD interval on the (0, 1) scale.
fit_logit <- ferx_fit(ex_logit$model, ex_logit$data)
fit_logitferx_estimates() populates estimate_natural, lower_95_natural, and upper_95_natural for logit-transformed thetas. The CI is computed symmetrically on the logit scale and then back-transformed through inv_logit, giving an asymmetric interval on the (0, 1) scale:
The transform value the backend reports for a logit-scaled theta is "logit_probability" (not "logit"):
est <- ferx_estimates(fit_logit)
est[est$param == "THETA_F", ]
# param transform estimate se rse_pct lower_95 upper_95 estimate_natural lower_95_natural upper_95_natural
# THETA_F logit_probability 0.80197 0.00652 0.8136 0.78918 0.81476 0.69040 0.68766 0.69312
# estimate - logit scale (what the optimiser works with)
# estimate_natural - inv_logit(estimate) = probability on (0, 1) scale
# lower/upper_95_natural - asymmetric CI back-transformed to (0, 1)
# (values captured from a real warfarin_logit_f fit)The omega for ETA_F has transform = "variance" (variance on the logit scale, symmetric CI - same label as all omega rows; the bare ETA name is the parameter label):
est[est$param == "ETA_F", ]
# param transform estimate se rse_pct lower_95 upper_95 init_as_sd
# ETA_F variance 0.1000 0.0633 63.3 -0.0240 0.2240 FALSE
# lower_95 < 0 can occur with Wald CIs when RSE% is large; variance is
# non-negative by definition but the symmetric Wald interval is not constrained.Additive ETA (lag time)
When the BSV on a parameter has an additive rather than multiplicative structure — for example, a lag time where the subject-level deviation is in hours — use a plain sum:
TLAG = TVTLAG + ETA_TLAG
ETA_TLAG follows a normal distribution with mean 0 and variance given by the corresponding omega element. The individual lag times are therefore normally distributed around TVTLAG.
ex_add <- ferx_example("warfarin_additive_eta")
ferx_model_inspect(ex_add$model)After fitting, the ETA_TLAG row in the omega block is printed with [additive] and an SD in the original units (hours):
fit_add <- ferx_fit(ex_add$model, ex_add$data)
fit_addferx_estimates() returns transform = "variance" for that omega element (all omega rows use this label). The estimate is the variance in the original units (hours²), and the CI is symmetric — no back-transform is applied:
ferx_estimates(fit_add)
# param transform estimate se rse_pct lower_95 upper_95 init_as_sd
# ETA_TLAG variance 0.04003 NA NA NA NA FALSE
# SD in original units = sqrt(0.04003) ~ 0.2001 h
# estimate_natural / lower_95_natural / upper_95_natural are NA (not applicable)
# (values captured from a real warfarin_additive_eta fit with covariance = FALSE,
# so SE columns are NA; the bare ETA name is the parameter label - no OMEGA() wrap.)Mixing transforms in one model
All three styles can appear in the same model. The standard two-compartment oral model with a logit bioavailability and an additive lag time would declare:
[individual_parameters]
CL = TVCL * exp(ETA_CL) # log-normal
V1 = TVV1 * exp(ETA_V1) # log-normal
KA = TVKA * exp(ETA_KA) # log-normal
F = inv_logit(logit(THETA_F) + ETA_F) # logit-normal
TLAG = TVTLAG + ETA_TLAG # additive
[structural_model]
pk two_cpt_oral(cl=CL, v1=V1, q=Q, v2=V2, ka=KA, f=F, lagtime=TLAG)
Each ETA is handled independently; the omega matrix contains the corresponding variances on the respective scales (log, logit, or natural units).
Running the mixed-transform model
Using the bundled two-compartment with covariate example as a proxy for a multi-transform model (it uses log-normal ETAs on all parameters), you can verify the pattern compiles and fits:
ex2 <- ferx_example("two_cpt_oral_cov")
ferx_model_inspect(ex2$model)
fit2 <- ferx_fit(ex2$model, ex2$data, method = "gn", covariance = FALSE)
fit2$thetaA model combining logit and additive ETAs follows exactly the same path — ferx_fit() detects each ETA’s parameterisation from the [individual_parameters] expressions and applies the correct transform automatically.
Summary table
| Transform | Omega interpretation | Print label | ferx_estimates() columns |
|---|---|---|---|
| Log-normal | variance on log scale | [log-normal] + CV% |
transform = "variance", symmetric CI on log-var scale |
| Logit-normal | variance on logit scale | [logit] + SD_logit + ±1 SD interval |
transform = "variance"; logit theta gets estimate_natural etc. |
| Additive | variance in original units | [additive] + SD |
transform = "variance", symmetric CI in original units² |