iv1 <- ferx_example("one_cpt_iv")
invisible(ferx_model_get_section(iv1$model, "structural_model"))
#> # [structural_model]
#> pk one_cpt_iv(cl=CL, v=V)
fit_iv1 <- ferx_fit(iv1$model, iv1$data, verbose = FALSE)
fit_iv1$estimates[, c("estimate", "rse_pct")]
#> estimate rse_pct
#> TVCL 3.98804001 5.945805
#> TVV 28.50720507 5.751817
#> ETA_CL 0.05081814 41.090897
#> ETA_V 0.04722010 41.240006
#> PROP_ERR 0.04162044 9.641449Structural models: analytical and ODE
Where you are
The analysis workflow used a built-in two-compartment model throughout. This chapter looks at the structural model itself: which models are built in, when you need to write differential equations instead, how the two relate, and the solver settings for ODE models. It then covers how the model output is mapped to the observations with [scaling], how to read compartment states into analysis outputs with [derived], and fits without random effects.
Analytical 1-, 2- and 3-compartment models are stable in ferx-core. ODE models, the stiff steppers and [scaling] are beta, and the Vern7 stepper is experimental. See feature maturity.
The data
Every model in this chapter is a bundled example with its own dataset.
Minimal runnable call: a built-in model
A built-in model is named in [structural_model] with pk, followed by the individual parameters it uses:
The built-in models are one_cpt_iv, one_cpt_oral, two_cpt_iv, two_cpt_oral, three_cpt_iv and three_cpt_oral, plus the absorption variants in Absorption and bioavailability. Their parameter names show in each bundled model:
builtin <- c("one_cpt_iv", "two_cpt_iv", "three_cpt_iv", "warfarin", "two_cpt_oral_cov", "three_cpt_oral")
data.frame(example = builtin, structural_model = vapply(builtin, function(e) {
trimws(ferx_model_get_section(ferx_example(e)$model, "structural_model", strip = TRUE)[1])
}, character(1)), row.names = NULL)
#> # [structural_model]
#> pk one_cpt_iv(cl=CL, v=V)
#>
#>
#> # [structural_model]
#> pk two_cpt_iv(cl=CL, v1=V1, q=Q, v2=V2)
#>
#>
#> # [structural_model]
#> pk three_cpt_iv(cl=CL, v1=V1, q2=Q2, v2=V2, q3=Q3, v3=V3)
#>
#>
#> # [structural_model]
#> pk one_cpt_oral(cl=CL, v=V, ka=KA)
#>
#>
#> # [structural_model]
#> pk two_cpt_oral(cl=CL, v1=V1, q=Q, v2=V2, ka=KA)
#>
#> # Declare the dataset covariate columns and their type. Optional; when present,
#> # declared columns are validated (must exist + be numerically coded) and echoed
#> # in the fit's covariate table (`fit$covtab`).
#>
#> # [structural_model]
#> pk three_cpt_oral(cl=CL, v1=V1, q2=Q2, v2=V2, q3=Q3, v3=V3, ka=KA)
#> example
#> 1 one_cpt_iv
#> 2 two_cpt_iv
#> 3 three_cpt_iv
#> 4 warfarin
#> 5 two_cpt_oral_cov
#> 6 three_cpt_oral
#> structural_model
#> 1 pk one_cpt_iv(cl=CL, v=V)
#> 2 pk two_cpt_iv(cl=CL, v1=V1, q=Q, v2=V2)
#> 3 pk three_cpt_iv(cl=CL, v1=V1, q2=Q2, v2=V2, q3=Q3, v3=V3)
#> 4 pk one_cpt_oral(cl=CL, v=V, ka=KA)
#> 5 pk two_cpt_oral(cl=CL, v1=V1, q=Q, v2=V2, ka=KA)
#> 6 pk three_cpt_oral(cl=CL, v1=V1, q2=Q2, v2=V2, q3=Q3, v3=V3, ka=KA)Reading the result: analytical and ODE twins
The same disposition can be written as differential equations. [structural_model] then declares the ODE states and the observed state. [odes] gives the right-hand sides, and [scaling] turns the observed amount into a concentration:
iv2_ode <- ferx_example("two_cpt_iv_ode")
for (block in c("structural_model", "odes", "scaling")) {
invisible(ferx_model_get_section(iv2_ode$model, block))
}
#> # [structural_model]
#> ode(obs_cmt=central, states=[central, periph])
#>
#>
#> # [odes]
#> d/dt(central) = -(CL/V1 + Q/V1) * central + (Q/V2) * periph
#> d/dt(periph) = (Q/V1) * central - (Q/V2) * periph
#>
#>
#> # [scaling]
#> obs_scale = V1Each analytical example has an ODE twin in the package. Fitted to the same data, each pair reaches the same OFV; the ODE version only takes longer. The covariance step is skipped here to save time:
pairs <- list(
list("one_cpt_iv", ferx_example("one_cpt_iv"), ferx_example("one_cpt_iv_ode")),
list("two_cpt_iv", ferx_example("two_cpt_iv"), ferx_example("two_cpt_iv_ode")),
list("three_cpt_iv", ferx_example("three_cpt_iv"), ferx_example("three_cpt_iv_ode")),
list("warfarin", ferx_example("warfarin"), ferx_example("warfarin_ode")),
list("three_cpt_oral", ferx_example("three_cpt_oral"), ferx_example("three_cpt_oral_ode")),
list("two_cpt_oral_cov", ferx_example("two_cpt_oral_cov"), ferx_example("two_cpt_oral_cov_ode"))
)
fit_timed <- function(ex) {
start <- Sys.time()
f <- ferx_fit(ex$model, ex$data, covariance = FALSE, verbose = FALSE)
c(ofv = f$ofv, seconds = as.numeric(difftime(Sys.time(), start, units = "secs")))
}
twin_results <- do.call(rbind, lapply(pairs, function(p) {
a <- fit_timed(p[[2]]); o <- fit_timed(p[[3]])
data.frame(model = p[[1]], ofv_analytical = a[["ofv"]], ofv_ode = o[["ofv"]],
seconds_analytical = round(a[["seconds"]], 1), seconds_ode = round(o[["seconds"]], 1))
}))
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
twin_results
#> model ofv_analytical ofv_ode seconds_analytical seconds_ode
#> 1 one_cpt_iv -317.1068 -317.1068 0.0 0.4
#> 2 two_cpt_iv -858.5245 -858.5245 0.1 4.7
#> 3 three_cpt_iv -712.6165 -712.6165 0.2 12.0
#> 4 warfarin -280.3640 -280.3640 0.2 4.0
#> 5 three_cpt_oral -584.1844 -584.1844 0.5 19.7
#> 6 two_cpt_oral_cov -1199.3264 -1199.3264 0.5 41.4Use the analytical form whenever a built-in model fits the problem, and write ODEs only when it doesn’t.
Generating the ODEs from a template
ode_template generates the ODE system of a built-in model, so you don’t write [odes] by hand. It is useful as a starting point for modifying one compartment:
tmpl <- ferx_example("two_cpt_oral_cov_ode_template")
invisible(ferx_model_get_section(tmpl$model, "structural_model"))
#> # [structural_model]
#> # ferx generates the depot/central/periph disposition ODE + obs_scale = V1.
#> ode_template two_cpt_oral(cl=CL, v1=V1, q=Q, v2=V2, ka=KA)
cov <- ferx_example("two_cpt_oral_cov")
all.equal(ferx_predict(tmpl$model, tmpl$data)$PRED, ferx_predict(cov$model, cov$data)$PRED,
tolerance = 1e-3)
#> [1] TRUEThe template’s population predictions match the analytical model. Its full fit is under Variants below.
Observation scaling with [scaling]
ferx-core labels [scaling] beta.
[scaling] maps the raw output of the structural model to the observed DV. What the raw output is depends on the model:
- A built-in
pkmodel returns a concentration. It already divides by its volume parameter. - An ODE model returns the state it observes, usually an amount.
Without [scaling], the raw output is compared with DV directly. The block has three forms, and a per-compartment variant of each:
| Form | Syntax | Evaluated | Use |
|---|---|---|---|
| A: constant divisor | obs_scale = 1000 |
once | Fixed unit conversion |
| B: expression divisor | obs_scale = V |
once per subject | Amount to concentration; a scale that depends on parameters or covariates |
| C: readout expression | y = central / V |
per observation | Any expression of states, parameters, covariates and TIME; replaces the default readout |
| Per compartment |
obs_scale[CMT=2] = ..., y[CMT=3] = ...
|
as above | A different scale for each observed CMT
|
The convention is divisive: forms A and B divide the prediction by obs_scale.
Form A: a constant
In warfarin_scaled the doses are recorded in micrograms while concentrations are in mg/L, so obs_scale = 1000 divides every prediction by 1000:
ws <- ferx_example("warfarin_scaled")
invisible(ferx_model_get_section(ws$model, "scaling"))
#> # [scaling]
#> obs_scale = 1000
head(read.csv(ws$data, na.strings = ".")[, c("ID", "TIME", "AMT", "DV")], 3)
#> ID TIME AMT DV
#> 1 1 0.0 100000 NA
#> 2 1 0.5 NA 5.3653
#> 3 1 1.0 NA 8.2578
fit_ws <- ferx_fit(ws$model, ws$data, verbose = FALSE)
fit_ref <- ferx_fit(ferx_example("warfarin")$model, ferx_example("warfarin")$data, verbose = FALSE)
rbind(scaled = fit_ws$theta, milligram_doses = fit_ref$theta)
#> TVCL TVV TVKA
#> scaled 0.132969 7.7307 0.7252069
#> milligram_doses 0.132969 7.7307 0.7252070Forms B and C: the same model written four ways
warfarin_ode holds amounts and divides by V with form B. Form C writes the readout explicitly. The ode() line then has no obs_cmt, because y says what is observed. A readout can also use named intermediates, lines whose name is not obs_scale or y. On an analytical model, form C sees central as the amount (concentration × V), so y = central / V reproduces the built-in readout. All of these reach the same fit:
[scaling] forms (covariance step skipped).
scaling_dir <- book_tempdir("scaling")
w <- ferx_example("warfarin")
w_ode <- ferx_example("warfarin_ode")
# Replace the one-line [scaling] block of a model file (or add one before [error_model]),
# optionally editing another line first.
set_scaling <- function(lines, scaling, from = NULL, to = NULL) {
if (!is.null(from)) lines <- sub(from, to, lines, fixed = TRUE)
start <- grep("^\\[scaling\\]", lines)
if (length(start)) {
lines <- lines[-c(start, start + 1)]
}
append(lines, c("[scaling]", scaling, ""), after = grep("^\\[error_model\\]", lines) - 1)
}
ode_line <- "ode(obs_cmt=central, states=[depot, central])"
variants <- list(
"analytical, no [scaling]" = readLines(w$model),
"ODE, form B" = readLines(w_ode$model),
"ODE, form C" = set_scaling(readLines(w_ode$model), " y = central / V",
ode_line, "ode(states=[depot, central])"),
"ODE, form C with an intermediate" = set_scaling(readLines(w_ode$model),
c(" CONC = central / V", " y = CONC"),
ode_line, "ode(states=[depot, central])"),
"analytical, form C" = set_scaling(readLines(w$model), " y = central / V")
)
do.call(rbind, lapply(names(variants), function(name) {
path <- file.path(scaling_dir, paste0(make.names(name), ".ferx"))
writeLines(variants[[name]], path)
scaling <- ""
if ("[scaling]" %in% variants[[name]]) {
capture.output(scaling <- ferx_model_get_section(path, "scaling", strip = TRUE)) # quiet
}
# suppress the R warning that reports overriding the model file's covariance setting
f <- suppressWarnings(ferx_fit(path, w$data, covariance = FALSE, verbose = FALSE))
data.frame(model = name, scaling = paste(trimws(scaling[nzchar(trimws(scaling))]), collapse = "; "),
ofv = f$ofv)
}))
#> model scaling ofv
#> 1 analytical, no [scaling] -280.364
#> 2 ODE, form B obs_scale = V -280.364
#> 3 ODE, form C y = central / V -280.364
#> 4 ODE, form C with an intermediate CONC = central / V; y = CONC -280.364
#> 5 analytical, form C y = central / V -280.364Among the bundled examples, the ODE twins above use form B, and most absorption models in Absorption and bioavailability use form C.
A readout that depends on time
Form C is evaluated for each observation, so it may use TIME. Form B is evaluated once per subject, so TIME there is rejected:
time_in_divisor <- file.path(scaling_dir, "time_in_obs_scale.ferx")
writeLines(set_scaling(readLines(w_ode$model), " obs_scale = V * (1 + TIME)"), time_in_divisor)
try(ferx_fit(time_in_divisor, w_ode$data, verbose = FALSE))
#> Error in ferx_rust_fit(model_path = normalizePath(model), data_path = normalizePath(data), :
#> Error parsing model: [scaling] obs_scale: `V * (1 + TIME)` references the `TIME` built-in (or its `T` alias), but `obs_scale` is a subject-static divisor — it is evaluated once per subject at t = 0 and applied to every observation, so `TIME` would always read 0. Write the time-dependent readout as Form C instead (`y = <expr>`, or `y[CMT=N] = <expr>`), where `TIME` resolves to each observation's own time. See issue #1028. [E_PARSE]As form C, the same idea is accepted. Here the readout subtracts a term that grows with time from the concentration, which the predictions confirm:
time_readout <- file.path(scaling_dir, "time_readout.ferx")
writeLines(set_scaling(readLines(w_ode$model), " y = central / V - 0.01 * TIME",
ode_line, "ode(states=[depot, central])"), time_readout)
base_pred <- ferx_predict(w_ode$model, w_ode$data)
readout_pred <- ferx_predict(time_readout, w_ode$data)
all.equal(readout_pred$PRED, base_pred$PRED - 0.01 * base_pred$TIME)
#> [1] TRUEA form C readout may be negative (a change from baseline, for example). The non-negativity guard applies only to the default readout.
Several observed compartments
With more than one observed CMT, give each one its own entry. emax_pkpd reads a concentration from CMT 2 and an Emax response from CMT 3 (PK/PD and multiple endpoints):
invisible(ferx_model_get_section(ferx_example("emax_pkpd")$model, "scaling"))
#> # [scaling]
#> y[CMT=2] = central / V # plasma conc (mg/L)
#> y[CMT=3] = E0 + EMAX * effect^GAMMA / (EC50^GAMMA + effect^GAMMA) # Emax PDEvery observed CMT needs an entry, and the uniform form cannot be mixed with the per-compartment form. [scaling] is not accepted in models with a [diffusion] block (Experimental features).
Scaling mistakes ferx catches
A built-in pk model already divides by its volume. obs_scale = V copied from an ODE version divides a second time. ferx_model_validate() warns, but the model fits, with a different volume:
double_volume <- file.path(scaling_dir, "warfarin_double_volume.ferx")
writeLines(set_scaling(readLines(w$model), " obs_scale = V"), double_volume)
check_double_volume <- ferx_model_validate(double_volume, w$data)
#> Validating: warfarin_double_volume.ferx
#> data: warfarin.csv
#>
#> Sections present:
#> parameters [ok]
#> individual_parameters [ok]
#> structural_model [ok]
#> error_model [ok]
#> fit_options [ok] (optional)
#> scaling [ok] (optional)
#>
#> Result: VALID (with warnings)
#> * warning W_PARSE: [scaling]: obs_scale = `V` references `V`, which is also this model's `pk` structural block volume parameter. A built-in analytical `pk` block already outputs concentration (divides by `V` internally) — this `obs_scale` divides by `V` again on top. If that's intentional (a deliberate additional transform), ignore this warning. If `obs_scale = V` was copied from an `ode(...)` translation of this model (where it converts raw compartment amount to concentration and is required), remove `[scaling]` here — the `pk` block's built-in concentration output is already what that conversion was for.
fit_double_volume <- suppressWarnings(ferx_fit(double_volume, w$data, covariance = FALSE, verbose = FALSE))
rbind(correct = fit_ref$theta, divided_twice = fit_double_volume$theta)
#> TVCL TVV TVKA
#> correct 0.13296903 7.730700 0.725207
#> divided_twice 0.04781466 2.780899 0.725723A dose attribute read again in [scaling] is an error: F mapped with f=F is already applied to the dose.
bio <- ferx_example("bioavailability")
f_twice <- file.path(scaling_dir, "bioavailability_f_twice.ferx")
writeLines(set_scaling(readLines(bio$model), " y = central / V * F"), f_twice)
try(ferx_fit(f_twice, bio$data, verbose = FALSE))
#> Error in ferx_rust_fit(model_path = normalizePath(model), data_path = normalizePath(data), :
#> Error parsing model: [scaling]: `F` is this model's bioavailability — the engine already scales each dose amount by it — but it is also read in [scaling], so the value is applied twice: once at the dose, once where you read it (#1004). If `F` is meant to be bioavailability, remove it from [scaling]; if it is meant to be an ordinary parameter, remove the `f=F` mapping from the `pk(...)` call — the mapping, not the name, is what makes `F` a dose attribute. [E_DOSE_ATTR_DOUBLE_USE]A name in [scaling] that is not a state, parameter or built-in is a covariate, and so a required data column. A typo therefore stops the fit instead of reading 0:
typo <- file.path(scaling_dir, "scaling_typo.ferx")
writeLines(set_scaling(readLines(w_ode$model), " y = central / VV",
ode_line, "ode(states=[depot, central])"), typo)
try(ferx_fit(typo, w_ode$data, verbose = FALSE))
#> Error in ferx_rust_fit(model_path = normalizePath(model), data_path = normalizePath(data), :
#> Fit error: Model references covariate(s) not found in data (case-sensitive): VV. Available covariate columns: (none). [E_MISSING_COVARIATE]The ferx-core scaling page documents the rest: min(), max() and clamp(), conditional readouts, a data column named T, and gradients.
Model states in [derived]
[derived] (Tables and figures) can also read the model’s compartments after the fit, by name or as compartments[i] (0-based, in state order). The values differ between model types, and [scaling] is never applied to them:
Analytical pk model |
ODE model | |
|---|---|---|
| Names | Fixed layout: depot, central, peripheral (peripheral1, peripheral2 for three compartments) |
The names in states=[...]
|
depot |
Amount | As written in [odes]
|
central, peripheral |
Concentration (central equals IPRED) |
As written in [odes]; an amount in the bundled ODE models |
integral() over a state |
compartments[i] |
Name or compartments[i]
|
Here the same [derived] block is added to the analytical and the ODE warfarin model:
states_block <- c("", "[derived]",
" DEPOT_STATE = depot",
" CENTRAL_STATE = central",
" CENTRAL_IDX = compartments[1]")
analytical_states <- file.path(scaling_dir, "warfarin_states.ferx")
ode_states <- file.path(scaling_dir, "warfarin_ode_states.ferx")
writeLines(c(readLines(w$model), states_block), analytical_states)
writeLines(c(readLines(w_ode$model), states_block, " CONC_FROM_STATE = central / V"), ode_states)
fit_states <- ferx_fit(analytical_states, w$data, verbose = FALSE)
fit_ode_states <- ferx_fit(ode_states, w_ode$data, verbose = FALSE)
bind_rows(analytical = filter(fit_states$sdtab, ID == 1, TIME <= 4),
ode = filter(fit_ode_states$sdtab, ID == 1, TIME <= 4), .id = "model") |>
select(model, TIME, IPRED, DEPOT_STATE, CENTRAL_STATE, CENTRAL_IDX)
#> model TIME IPRED DEPOT_STATE CENTRAL_STATE CENTRAL_IDX
#> 1 analytical 0.5 5.351751 55.6039952 5.351751 5.351751
#> 2 analytical 1.0 8.283413 30.9180429 8.283413 8.283413
#> 3 analytical 2.0 10.708454 9.5592537 10.708454 10.708454
#> 4 analytical 4.0 11.383285 0.9137933 11.383285 11.383285
#> 5 ode 0.5 5.351751 55.6039953 44.194932 44.194932
#> 6 ode 1.0 8.283413 30.9180429 68.404697 68.404697
#> 7 ode 2.0 10.708454 9.5592538 88.430761 88.430761
#> 8 ode 4.0 11.383285 0.9137933 94.003540 94.003540The depot amounts agree. central is the concentration in the analytical model but the amount in the ODE model. The ODE copy has one more line, CONC_FROM_STATE = central / V, which reproduces IPRED:
Turning states into analysis outputs
States make outputs available that IPRED alone cannot give: the peripheral concentration, the amount left to absorb, and areas computed from the model instead of from the observation times. An integral over a state evaluates the model again on the grid, while an integral over IPRED with step uses the nearest observation value. For the analytical warfarin model, the exact AUC from 0 to 24 hours of each subject follows from its individual parameters, which shows the difference:
auc_block <- c("", "[derived]",
" AUC_IPRED = integral(IPRED, from=0, to=24, step=0.05)",
" AUC_STATE = integral(compartments[1], from=0, to=24, step=0.05)")
auc_model <- file.path(scaling_dir, "warfarin_auc.ferx")
writeLines(c(readLines(w$model), auc_block), auc_model)
fit_auc <- ferx_fit(auc_model, w$data, verbose = FALSE)
exact_auc <- fit_auc$individual_estimates |>
mutate(ID = as.integer(ID), k = CL / V,
AUC_EXACT = 100 * KA / (V * (KA - k)) * ((1 - exp(-k * 24)) / k - (1 - exp(-KA * 24)) / KA))
distinct(fit_auc$sdtab, ID, AUC_IPRED, AUC_STATE) |>
mutate(ID = as.integer(ID)) |>
left_join(select(exact_auc, ID, AUC_EXACT), by = "ID") |>
head(4)
#> ID AUC_IPRED AUC_STATE AUC_EXACT
#> 1 1 233.4807 232.7901 232.7931
#> 2 2 248.9688 249.7240 249.7255
#> 3 3 228.6082 228.3682 228.3705
#> 4 4 274.4823 276.1205 276.1218(Every warfarin subject received a single dose of 100 at time 0.) The state integral agrees with the exact area; the IPRED integral does not. In the two-compartment covariate model, where every subject received 250 at time 0, the peripheral concentration and the amount remaining in the depot give a per-subject table:
cov_states <- file.path(scaling_dir, "two_cpt_oral_cov_states.ferx")
writeLines(c(readLines(cov$model), "", "[derived]",
" CMAX_PERIPH = max(compartments[2])",
" AUC_PERIPH_0_48 = integral(compartments[2], from=0, to=48, step=0.1)",
" AUC_CENTRAL_0_48 = integral(compartments[1], from=0, to=48, step=0.1)",
" DEPOT_LEFT_1H = min(depot, TIME == 1)",
"", "[output]", " WT CRCL"), cov_states)
fit_cov_states <- ferx_fit(cov_states, cov$data, verbose = FALSE)
distinct(fit_cov_states$sdtab, ID, WT, CRCL, CMAX_PERIPH, AUC_PERIPH_0_48, AUC_CENTRAL_0_48, DEPOT_LEFT_1H) |>
mutate(ID = as.integer(ID), fraction_unabsorbed_1h = DEPOT_LEFT_1H / 250) |>
head(5)
#> ID WT CRCL CMAX_PERIPH AUC_PERIPH_0_48 AUC_CENTRAL_0_48 DEPOT_LEFT_1H
#> 1 1 70.6 73.7 1.325480 41.12734 44.65955 26.808135
#> 2 2 80.9 80.7 1.137738 33.00620 35.15572 8.830838
#> 3 3 90.7 99.1 1.315796 39.14383 42.42518 74.653344
#> 4 4 75.7 51.3 1.577181 51.89148 59.25949 96.119049
#> 5 5 86.5 124.0 1.526808 37.32931 38.80145 74.005127
#> fraction_unabsorbed_1h
#> 1 0.10723254
#> 2 0.03532335
#> 3 0.29861338
#> 4 0.38447620
#> 5 0.29602051max() and min() over a state use the observation rows, like over IPRED. The filter TIME == 1 picks the one-hour sample. Compartment states are NaN for subjects with inter-occasion variability, and for analytical models with time-varying covariates. For ODE models with time-varying covariates they are approximate (ferx-core derived: compartment states).
Variants
Nonlinear elimination
Michaelis-Menten elimination has no built-in closed form, so mm_oral writes it as ODEs. Here the central state is a concentration, so the model needs no [scaling]:
mm <- ferx_example("mm_oral")
invisible(ferx_model_get_section(mm$model, "odes"))
#> # [odes]
#> d/dt(depot) = -KA * depot
#> d/dt(central) = KA * depot / V - VMAX * central / (KM + central)
fit_mm <- ferx_fit(mm$model, mm$data, verbose = FALSE)
fit_mm$estimates[1:4, c("estimate", "rse_pct")]
#> estimate rse_pct
#> TVVMAX 4.138463 7.6590754
#> TVKM 5.934776 1.1143508
#> TVV 11.111915 4.7187069
#> TVKA 1.510612 0.7043578Time inside the ODEs
The right-hand sides of [odes] can use TIME, TAD (time after dose) and TAFD (time after first dose). warfarin_ode_time adds two accumulator states: the concentration integrated over the first 24 hours, and the time spent above 0.5 in the first 24 hours after each dose. It also computes summary columns in [derived] (Tables and figures):
ot <- ferx_example("warfarin_ode_time")
invisible(ferx_model_get_section(ot$model, "odes"))
#> # [odes]
#> d/dt(depot) = -KA * depot
#> d/dt(central) = KA * depot - CL/V * central
#>
#> # TIME and T are synonymous -- both are the ODE solver time axis.
#> # TAFD and TAD are available with the same SS-aware semantics as [derived].
#> d/dt(AUC_D1) = if (TIME < 24) central/V else 0.0
#> d/dt(TAM_INT) = if (central/V > 0.5 && TAD < 24) 1.0 else 0.0
fit_ot <- ferx_fit(ot$model, ot$data, verbose = FALSE)
head(distinct(fit_ot$sdtab, ID, KE, T_HALF, CMAX, AUC_72), 3)
#> ID KE T_HALF CMAX AUC_72
#> 1 1 0.01655817 41.86134 11.38328 511.3443
#> 2 2 0.01860713 37.25171 12.10571 544.4705
#> 3 3 0.01532054 45.24299 11.01641 515.0689ODE solver settings
book_settings_table(c("ode_method", "ode_reltol", "ode_abstol", "ode_max_steps",
"ode_stiff_abort_after", "ode_auto_switch"))| Setting | Values | Default | Description |
|---|---|---|---|
ode_method |
auto, rk45, vern7, rosenbrock23, rodas4, rodas5p
|
auto |
Which stepper integrates the [odes] block. Two axes: vern7 (Verner 7(6)) buys order, for fits that are accuracy-limited at tight tolerances — not a blanket upgrade, since it is slower than rk45 at default tolerances. |
ode_reltol |
float | 1e-4 |
RK45 ODE solver relative tolerance. Applies to ODE models, and to the auto-generated ODE twin that closed-form transit / inverse-Gaussian absorption models (one_cpt_transit, two_cpt_transit, one_cpt_ig, two_cpt_ig) use to serve IOV, time-varying-covariate, and TIME-switch subjects (#719/#814) — so a call-time override reaches those rerouted fits too; otherwise ignored for analytical PK. |
ode_abstol |
float | 1e-6 |
RK45 ODE solver absolute tolerance (companion to ode_reltol). |
ode_max_steps |
integer | 10000 |
Max solver steps per integration segment. ODE models and the absorption ODE twin. |
ode_stiff_abort_after |
integer ≥ 0, or off
|
off |
Abandon an integration segment once this many of its steps have clamped at the minimum step size, instead of grinding on to ode_max_steps. |
ode_auto_switch |
true, false
|
true |
Let ode_method = auto change stepper inside an integration segment, not only at its start. |
The bundled ODE twins set very tight tolerances (ode_reltol = 1e-10, ode_abstol = 1e-12) so that they match the analytical models exactly. Looser tolerances are faster and change the OFV slightly:
w_ode <- ferx_example("warfarin_ode")
tolerances <- list(default = list(), tight = list(ode_reltol = 1e-10, ode_abstol = 1e-12))
do.call(rbind, lapply(names(tolerances), function(nm) {
settings <- utils::modifyList(list(ode_reltol = 1e-4, ode_abstol = 1e-6), tolerances[[nm]])
start <- Sys.time()
# suppress the R warnings that report overriding the model file's settings
f <- suppressWarnings(ferx_fit(w_ode$model, w_ode$data, covariance = FALSE, settings = settings,
verbose = FALSE))
data.frame(tolerance = nm, ode_reltol = settings$ode_reltol, ode_abstol = settings$ode_abstol,
ofv = round(f$ofv, 5), seconds = round(as.numeric(difftime(Sys.time(), start, units = "secs")), 1))
}))
#> tolerance ode_reltol ode_abstol ofv seconds
#> 1 default 0.0001000000 0.000001000000 -280.3589 1.3
#> 2 tight 0.0000000001 0.000000000001 -280.3640 4.0ode_method selects the stepper: explicit (rk45, vern7), stiff (rosenbrock23, rodas4, rodas5p) or auto, which starts explicit and escalates when stiffness is detected. On this non-stiff model, all of them converge, to OFVs within about 0.01 of each other at moderate tolerances, but at different speeds:
ode_methods <- c("auto", "rk45", "vern7", "rosenbrock23", "rodas4", "rodas5p")
do.call(rbind, lapply(ode_methods, function(m) {
start <- Sys.time()
f <- suppressWarnings(ferx_fit(w_ode$model, w_ode$data, covariance = FALSE,
settings = list(ode_method = m, ode_reltol = 1e-6, ode_abstol = 1e-8),
verbose = FALSE))
data.frame(ode_method = m, converged = f$converged, ofv = round(f$ofv, 4),
seconds = round(as.numeric(difftime(Sys.time(), start, units = "secs")), 1))
}))
#> ode_method converged ofv seconds
#> 1 auto TRUE -280.3639 1.8
#> 2 rk45 TRUE -280.3640 19.5
#> 3 vern7 TRUE -280.3640 7.8
#> 4 rosenbrock23 TRUE -280.3587 7.4
#> 5 rodas4 TRUE -280.3640 3.4
#> 6 rodas5p TRUE -280.3640 2.1Fits without random effects
A model may declare no omega at all. All subjects then share one set of parameters (a naive-pooled fit), and sigma carries all the spread:
pooled <- ferx_example("one_cpt_iv_pooled")
invisible(ferx_model_get_section(pooled$model, "parameters"))
#> # [parameters]
#> theta TVCL(4.0, 0.1, 100.0)
#> theta TVV(40.0, 1.0, 500.0)
#>
#> sigma PROP_ERR ~ 0.02 (sd)
fit_pooled <- ferx_fit(pooled$model, pooled$data, verbose = FALSE)
c(converged = fit_pooled$converged, ofv = fit_pooled$ofv, n_eta = nrow(fit_pooled$omega))
#> converged ofv n_eta
#> 1.000 -269.637 0.000
isTRUE(all.equal(fit_pooled$sdtab$PRED, fit_pooled$sdtab$IPRED))
#> [1] TRUEWith no random effects the individual and population predictions are identical.
The ODE template fit
fit_tmpl <- ferx_fit(tmpl$model, tmpl$data, covariance = FALSE, verbose = FALSE)
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
c(template = fit_tmpl$ofv, analytical = ferx_fit(cov$model, cov$data, covariance = FALSE, verbose = FALSE)$ofv)
#> Warning: Model file [fit_options] sets `covariance = true` but ferx_fit()
#> argument overrides it with `false`. The call-time value will be used.
#> template analytical
#> -1199.326 -1199.326Initial conditions
ferx-core also supports an [initial_conditions] block to set non-zero starting amounts for ODE states, for example an endogenous baseline. No bundled ferx-r example uses it; see the ferx-core initial conditions page.
Pitfalls
- ODE models are slower. Use them only when no built-in model fits, and check the cost of tight tolerances.
-
CMTrefers to the declared states. In an ODE model, compartment numbers follow the order ofstates=[...], starting at 1 (ferx-core data format). -
Scale the observation. An ODE state is an amount unless you write it as a concentration. Give
[scaling]anobs_scaleor ayreadout (or write the state in concentration units, asmm_oraldoes). -
Do not scale a built-in model by its volume. A
pkmodel already returns a concentration.obs_scale = Vdivides again and changes the estimates, with only a validation warning. -
States in
[derived]are unscaled. The same name can be a concentration in an analytical model and an amount in an ODE model.
Warnings you may see here
-
ode_solver(warning or info): the ODE solver’s health at the final estimates, for example steps clamped at the minimum step size, unfinished segments, or anautoescalation (as info). The message names the counts. Consider a stiffode_methodor looser tolerances. See solver warnings.
Summary
- Built-in
pkmodels cover 1–3 compartments, IV and oral. Their ODE twins reach the same fit, only more slowly. - Write
[odes]for nonlinear or custom systems.ode_templategenerates a built-in model’s ODEs for you. - Tune ODE fits with
ode_methodand the tolerance settings. -
[scaling]maps the model output toDV: a constant or expression divisor (obs_scale), or a readout per observation (y), perCMTif needed. -
[derived]reads compartment states: concentrations in analytical models, raw states in ODE models. Integrals over states give model-based areas. - A model without
omegagives a naive-pooled fit.
- R help:
?ferx_fit(ODE settings),?ferx_model_get_section,?ferx_model_validate - ferx-core: structural model, ODE models, scaling,
[derived], initial conditions, ODE stepper selection