Time-to-Event (TTE) Models
Maturity: beta — see Feature Maturity for what this means.
Phase 1 (current): Parametric TTE with exponential, Weibull, and Gompertz hazard families; right-censored, exact, and interval-censored observations; left truncation via
TENTRY; FOCEI and SAEM estimation; fixed-effects (n_eta = 0) and random-effects paths.[structural_model],[error_model], and[individual_parameters]are all optional for TTE-only models.
Overview
TTE models describe the probability distribution of the time until a clinical event (e.g. first adverse event, study dropout, disease progression). ferx uses the same FOCEI Laplace objective as for continuous PK/PD data, extended to the per-subject log-likelihood contribution
ℓᵢ = δᵢ · log h(Tᵢ) − H(Tᵢ)
where δᵢ is the event indicator, h(t) the hazard function, and H(t) = ∫₀ᵗ h(s) ds the cumulative hazard.
Model file syntax
Add one [event_model] block per TTE endpoint.
[event_model]
cmt = 2 # data-file CMT value that carries TTE rows
family = exponential # exponential | weibull | gompertz
scale = TVLAMBDA * exp(ETA_LAMBDA) # theta/eta/covariate expression
Note:
[event_model]parameter expressions are evaluated in the theta / eta / covariate namespace, and may also reference names defined in[individual_parameters](e.g.LAMBDA); those are resolved per subject at evaluation time. Writing the full expression in terms ofthetaandetanames directly also works. A referenced individual parameter may not depend on an inter-occasion (IOV/kappa) random effect — the hazard is evaluated once per subject, with no occasion context — and such a reference is rejected with an error.
For Weibull, a shape parameter is also required:
[event_model]
cmt = 2
family = weibull
scale = TVSCALE * exp(ETA_SCALE)
shape = TVSHAPE
Gompertz uses alpha (baseline hazard) and gamma (growth rate):
[event_model]
cmt = 2
family = gompertz
alpha = TVALPHA
gamma = TVGAMMA
Multiple TTE endpoints are supported by repeating the block with unique names:
[event_model DROPOUT]
cmt = 2
family = exponential
scale = TVLAMBDA_DROPOUT * exp(ETA_LAMBDA)
[event_model DEATH]
cmt = 3
family = weibull
scale = TVSCALE_DEATH
shape = TVSHAPE_DEATH
[structural_model] and [error_model] requirement
The parser currently requires both blocks even for TTE-only models. Include a dummy 1-cpt block with fixed parameters:
[parameters]
theta TVLAMBDA(0.05, 0.001, 10.0)
theta DUMMY_CL(1.0, FIX)
theta DUMMY_V(1.0, FIX)
omega ETA_LAMBDA ~ 0.09
sigma SIGMA_DV ~ 0.01 FIX
[individual_parameters]
LAMBDA = TVLAMBDA * exp(ETA_LAMBDA)
CL = DUMMY_CL
V = DUMMY_V
[structural_model]
pk one_cpt_iv(cl=CL, v=V) # inert: no CMT-1 observations in TTE-only data
[error_model]
DV ~ additive(SIGMA_DV) # inert: sigma carries zero gradient
[event_model]
cmt = 2
family = exponential
scale = TVLAMBDA * exp(ETA_LAMBDA) # theta/eta directly, or an [individual_parameters] name
This [structural_model] / [error_model] requirement will be lifted in a future release. (The separate [individual_parameters]-namespace restriction noted above has already been lifted — hazard expressions may reference individual parameters.)
Data format
TTE rows follow the NONMEM observation convention with EVID=0 and a CMT that matches the cmt = declaration in [event_model]:
| DV | Meaning |
|---|---|
0 |
Right-censored (survived past T) |
1 |
Exact event at T |
2 |
Interval-censored: right bound (pair with preceding DV=0 row for same CMT) |
For interval-censored observations, precede the DV=2 row with a DV=0 row carrying the left bound (entry into the at-risk window):
ID,TIME,DV,EVID,CMT
1, 5.0, 0, 0, 2 # left bound of interval (entry)
1,10.0, 2, 0, 2 # right bound → event occurred in (5, 10]
Left truncation (delayed entry)
If subjects enter the study at a time > 0 (e.g. they survived until enrolment), add a TENTRY column. ferx computes
H_eff(T) = H(T) − H(TENTRY)
conditioning the likelihood on survival past the entry time:
ID,TIME,DV,EVID,CMT,TENTRY
1,18.3, 1, 0, 2, 5.0 # subject entered at t=5, event at t=18.3
2,30.0, 0, 0, 2, 5.0 # entered at t=5, censored at t=30
Hazard families
| Family | Parameters | h(t) | H(t) |
|---|---|---|---|
exponential |
scale = λ |
λ · exp(loghr) | λ · exp(loghr) · t |
weibull |
scale = α, shape = γ |
(γ/α)(t/α)^(γ−1) · exp(loghr) | (t/α)^γ · exp(loghr) |
gompertz |
alpha = α, gamma = γ |
α · exp(γ·t) · exp(loghr) | (α/γ)(exp(γ·t) − 1) · exp(loghr) |
All parameters must be positive. The optional loghr key adds a proportional-hazards covariate term: the entire hazard (and cumulative hazard) is multiplied by exp(loghr). When loghr is omitted it defaults to 0 (no effect).
# Exponential with PH covariate on sex (SEX=1 reference):
[event_model]
cmt = 2
family = exponential
scale = TVLAMBDA * exp(ETA_LAMBDA)
loghr = BETA_SEX * SEX
# Weibull with PH covariate on weight:
[event_model]
cmt = 2
family = weibull
scale = TVSCALE * exp(ETA_SCALE)
shape = TVSHAPE
loghr = BETA_WT * WT
Estimation
The FOCEI Laplace objective includes a TTE-specific FD Hessian contribution:
OFV = −2 · Σᵢ [ ℓᵢ(η̂ᵢ) − ½ log |Ω⁻¹ + ∇²ℓᵢ(η̂ᵢ)| ]
where ∇²ℓᵢ is computed via a 4-point central-difference finite-difference stencil (Shi 2021 step-size selection).
Set method = focei in [fit_options]. The gradient = fd setting is the default and the only supported path for TTE objectives.
Joint PK-TTE
When the hazard depends on drug exposure, write it as an expression over the PK state instead of a closed-form family. On an ODE model, the hazard key in [event_model] is accumulated as an extra ODE compartment and estimated jointly with the PK, with shared random effects:
[parameters]
theta TVCL(1.0, 0.01, 100); theta TVV(10, 0.1, 500); theta TVKA(1, 0.01, 50)
theta TVH0(0.01, 1e-5, 10); theta TVBETA(0.5, -10, 10)
omega ETA_CL ~ 0.09
sigma PROP_ERR ~ 0.05 (sd)
[individual_parameters]
CL = TVCL * exp(ETA_CL)
V = TVV
KA = TVKA
H0 = TVH0
BETA = TVBETA
[structural_model]
ode(obs_cmt=central, states=[depot, central])
[odes]
d/dt(depot) = -KA * depot
d/dt(central) = KA * depot - (CL/V) * central
[event_model]
cmt = 3 # TTE on its own CMT (PK observed on CMT 2)
hazard = H0 * exp(BETA * (central / V)) # log-linear hazard in concentration
[error_model]
DV ~ proportional(PROP_ERR) # Gaussian PK rows (CMT 2); TTE rows (CMT 3) route to the event likelihood
[fit_options]
method = focei
The parser appends d/dt(__chz) = H0 * exp(BETA * (central / V)) (init 0) to the ODE system; the TTE likelihood reads H(T) from that state and h(T) from its derivative, combined with the Gaussian PK likelihood in one FOCEI objective (central / V is the concentration driving the hazard). Put the PK observation and the event on different compartments so the data reader can route Gaussian rows and TTE rows unambiguously.
predict_survival() returns S(t) / H(t) / h(t) for the endpoint by integrating the same augmented system at the typical values (its mean_survival is NaN — an ∫₀^∞ S summary for ODE hazards is a follow-up; median_survival is interpolated from the cumulative-hazard grid).
Simulating drug-driven event times
simulate() draws event times from a drug-driven hazard by integrating the augmented PK + cumulative-hazard system until H(T) = −log U — the ODE analogue of the analytic inverse-CDF — localizing the crossing inside the solver step. Because a drug-driven hazard can vanish (the drug clears, the hazard falls toward baseline, and an event may never fire), an ODE-TTE simulation requires a finite [simulation] horizon; a subject that has not crossed by the horizon is right-censored there. EVID-3/4 resets and left-truncated entry (entry_time > 0) are not yet supported for ODE-TTE simulation and raise a clear error rather than mis-sampling. A negative or non-finite hazard fails loudly during sampling — it is never silently treated as a censor.
The hazard expression lives in the [odes] RHS namespace (ODE states + individual parameters — route thetas/covariates through [individual_parameters]), and inter-occasion variability (IOV) is rejected. Joint PK-TTE is the standard NONMEM $DES + cumulative-hazard construct; recovery against NONMEM / nlmixr2 is tracked in issue #564. See examples/pktte_joint.ferx.
Competing risks (cause-specific hazards)
When a subject is at risk of several mutually-exclusive event types, give each cause its own [event_model NAME] block on a distinct compartment. The blocks share the model’s random effects (a common frailty links the causes), so this is the standard cause-specific hazard approach:
[parameters]
theta TVLAMBDA_A(0.10, 0.001, 10.0)
theta TVLAMBDA_B(0.06, 0.001, 10.0)
omega ETA_F ~ 0.25
[event_model cause_a]
cmt = 2
family = exponential
scale = TVLAMBDA_A * exp(ETA_F)
[event_model cause_b]
cmt = 3
family = exponential
scale = TVLAMBDA_B * exp(ETA_F)
Data format. Each subject has one row per cause CMT. The cause that occurred is DV=1 on its CMT; every other cause is right-censored (DV=0) at the same time, because experiencing one event removes the subject from risk of the others:
ID,TIME,DV,EVID,AMT,CMT,RATE,MDV
1,12.25,1,0,0,2,0,0 # subject 1: cause A (CMT 2) at t=12.25 …
1,12.25,0,0,0,3,0,0 # … censored for cause B (CMT 3) at the same time
2,9.38,0,0,0,2,0,0 # subject 2: reached the horizon with no event —
2,9.38,0,0,0,3,0,0 # right-censored for both causes
See examples/tte_competing_risks.ferx and data/tte_competing_risks.csv.
Prediction — cumulative incidence. predict_survival() reports, per cause, the cause-specific cumulative incidence F_k(t) = ∫₀ᵗ h_k(u)·S_all(u) du alongside the all-cause survival S_all(t) = exp(−Σ_j H_j(t)). In the presence of competing risks the naive 1 − exp(−H_k) over-states a cause’s incidence (it ignores that the other causes remove subjects from risk); the reported CIF is the correct quantity, and across all causes Σ_k F_k(t) + S_all(t) = 1 exactly. For a single endpoint it reduces to F(t) = 1 − S(t).
Estimation note. The cause-specific rates are well-identified and recover tightly. The shared frailty variance ω² is weakly identified for TTE and reads high under FOCEI-Laplace at higher event rates (the same approximation limit as single-endpoint frailty; see below) — prefer SAEM/IMP for the variance component. Subdistribution-hazard (Fine–Gray) modelling is not currently provided; cause-specific hazard covers the standard pharmacometric use case.
Validation of the cumulative incidence. The CIF is anchored at three levels:
Closed form (constant hazards). For two exponential causes the actuarial allocation is exact:
F_k(t) = (λ_k / Σλ)·(1 − e^{−Σλ·t}). Asserted to machine precision incif_two_cause_exponential_closed_form(src/survival/mod.rs).Independent quadrature (time-varying hazards). For a Weibull + Gompertz pair the per-cause
F_k(t)fromcif_curvesmatches a high-resolution composite-Simpson integration of the defining integralF_k(t) = ∫₀ᵗ h_k(u)·S_all(u) du— a different numerical scheme — to within1e-3(cif_time_varying_matches_independent_quadrature).Nonparametric reference (R
cmprsk::cuminc). Simulating the example’s two exponential causes (λ_A = 0.10,λ_B = 0.06, administrative censoring att = 14,N = 200 000) and estimating the cumulative incidence with the license-freecmprsk::cumincreproduces ferx’s analytic CIF (max absolute difference≈ 2·10⁻³, i.e. Monte-Carlo noise):t cause A — cmprskcause A — ferx cause B — cmprskcause B — ferx 2 0.1708 0.1712 0.1032 0.1027 6 0.3848 0.3857 0.2324 0.2314 10 0.4970 0.4988 0.3009 0.2993 14 0.5565 0.5585 0.3370 0.3351 library(cmprsk) set.seed(20260625) lamA <- 0.10; lamB <- 0.06; tau <- 14; N <- 2e5 tA <- rexp(N, lamA); tB <- rexp(N, lamB) tmin <- pmin(tA, tB); cause <- ifelse(tA < tB, 1L, 2L) status <- ifelse(tmin <= tau, cause, 0L); ftime <- pmin(tmin, tau) timepoints(cuminc(ftime, status, cencode = 0L), c(2, 6, 10, 14))$est # ferx analytic: F_k(t) = (lam_k/(lamA+lamB)) * (1 - exp(-(lamA+lamB)*t))
Simulation and VPC. simulate() draws the competing causes with the same earliest-cause-wins rule: a latent time is drawn per cause, the earliest is the observed event, and every other cause is right-censored at that time. For a visual predictive check the simulation needs an administrative censoring horizon that is decoupled from the observed event times — otherwise a re-simulated subject that already carries an event re-draws unbounded, biasing the simulated event-time distribution. Supply it with the [simulation] horizon key (or SimulateOptions { horizon, .. } on the library API); when set, it overrides each record’s per-record window so every cause censors at the planned study end:
[simulation]
n_subjects = 200
horizon = 14 # follow each subject to t = 14, censoring survivors there
seed = 12345
Running ferx examples/tte_competing_risks.ferx --simulate then generates one right-censored TTE row per cause compartment (CMT 2 and CMT 3) per synthetic subject and overwrites each with the drawn outcome. A TTE [simulation] block requires horizon (there are no continuous times to schedule).
Comparison with reference software
The exponential validation dataset is tests/reference/tte_exponential/tte_exp.csv (100 subjects; data-generating λ=0.1 h⁻¹, ω²=0.25 on log-rate; right-censored at t=24 → 82 events, 18% censored). All tools fit the same file.
Fixed-effects (no random effect) — exact, license-free anchor. A censored exponential MLE is the closed form λ = events/Σtime, so a ferx n_eta=0 fit must match base-R survival::survreg exactly:
| Quantity | ferx (fixed) | survreg |
agreement |
|---|---|---|---|
| λ (rate) | 0.074506 | 0.074506 | exact (<1e‑4) |
| OFV / −2logLik | 589.888 | 589.888 | exact |
This confirms ferx’s TTE hazard, censoring, and likelihood constants are correct.
Mixed-effects (frailty) — FOCEI. ferx recovers the data-generating truth under a clean large-N simulation–estimation check (N=2000): λ_pop = 0.099 (−1%), ω² = 0.232 (−7%). The small ω² shortfall is the expected mild FOCEI-Laplace bias for TTE (it grows at low event rates; SAEM/IMP reduce it). On the 100-subject file itself:
| Parameter | True | ferx FOCEI | nlmixr2 FOCEI | NONMEM (LAPLACIAN) |
|---|---|---|---|---|
| TVLAMBDA (rate) | 0.10 | 0.0768 | 0.0770 | 0.0771 |
| ω²(ETA_LAMBDA) | 0.25 | 0.290 | 0.293 | 0.286 |
| OFV (−2LL) | — | 588.93 | 588.94 | 588.93 |
ferx and nlmixr2 FOCEI agree to ~3 digits on all parameters and the −2LL for all three families (Weibull and Gompertz likewise — see tests/reference/tte_*/expected.md), including the nonlinear-frailty Weibull omega^2: ferx 0.176, nlmixr2 0.173, NONMEM LAPLACIAN 0.175. Earlier ferx read 0.204 there — not a likelihood difference but a derivative-free outer-optimizer artifact: on the near-flat ω² ridge (the whole 0.175→0.204 span is <0.01 OFV) BOBYQA’s ftol_rel default stopped it short of ferx’s own profile minimum, which already sat at ~0.175. Tightening the TTE outer ftol_rel to 1e-8 (#469; auto-applied to pure-TTE models, whose hazard objective is exact) lands it on the consensus. This is separate from the FOCEI-Laplace method bias above (the large-N Weibull SSE still over-estimates the shape frailty, ~0.34 vs a true 0.20 — that is #440, unchanged by the optimizer fix).
NONMEM reports
THETA(1)=log λ; compareexp(THETA(1))to ferxTVLAMBDA. The NONMEM frailty column is fromnonmem_frailty.ctl(CONDITIONAL LAPLACIAN INTERACTION). Two NONMEM gotchas forF_FLAG=1likelihood models: a dummy$SIGMA 1 FIXis required and must be referenced (DUMMY = EPS(1)) or NM-TRAN infers single-subject data and rejectsCONDITIONAL; andNUMERICAL SLOWare required withLAPLACIAN INTERACTION. All three tools agree on the linear-frailty exponential (λ within 0.4%, ω² within ~2%, identical −2LL). Gompertz (fixed-effects) also matches NONMEM to OFV 3011.12. nlmixr2’s OFV constants differ (§11.3) — compare parameters.
nlmixr2 equivalent
m <- function() {
ini({
TVLAMBDA <- 0.05
eta.lambda ~ 0.09
})
model({
lambda <- TVLAMBDA * exp(eta.lambda)
ll(time.to.event) ~ event * log(lambda) - lambda * time.to.event
})
}
fit <- nlmixr(m, data, est = "focei")NONMEM equivalent
$ESTIMATION METHOD=LAPLACIAN INTERACTION MAXEVAL=500
$THETA (0.001, 0.05, 10)
$OMEGA 0.09
Weibull and Gompertz
The same validation kit exists for Weibull (tests/reference/tte_weibull/) and Gompertz (tests/reference/tte_gompertz/). Highlights:
- Weibull fixed-effects fit matches
survregexactly (scale 22.18, shape 2.12); the 300-subject Gompertz RCT fixed-effects fit recoverslog_alpha,log_gammaand the treatmentlog_hressentially exactly, exercising the[event_model]covariate +loghrterm.
Choosing an estimator for frailty TTE. When a random effect sits on a nonlinear hazard parameter (e.g. the Weibull shape or Gompertz growth rate, not the exponential rate), FOCEI-Laplace over-estimates the frailty variance
omega^2— a clean large-N simulation–estimation check recovers the structural parameters but inflatesomega^2(Weibull shape: 0.34 vs a true 0.20), while a SAEM fit of the same data reads lower (~0.13). This is an approximation limitation, not a likelihood error (fixed-effects fits matchsurvregexactly), and it is a property of the method, not of any one tool: a three-tool cross-check on the n=100 Weibull file has ferx FOCEI (ω² 0.176), nlmixr2 FOCEI (0.173) and NONMEM LAPLACIAN (0.175) all in agreement (structural params and −2LL match too, ≤0.5% / identical OFV). (ferx read 0.204 here before #469 — a derivative-free outer-optimizer convergence artifact on the flat ω² ridge, since fixed; it is unrelated to the method bias, which shows in the large-N SSE above.) Prefer SAEM/IMP for frailty TTE on nonlinear parameters, as in the standard pharmacometric guidance; FOCEI on a linear rate (exponential) is near-unbiased and all three tools agree there (ω² 0.286–0.293).
Joint PK-TTE — fit and simulation
The drug-driven joint model is anchored against NONMEM and nlmixr2 on a shared dataset (tests/reference/pktte_joint/, N=120; oral 1-cpt PK + h = H0·exp(BETA·Cc)).
Fit. All three tools recover the PK fixed effects and variance components closely:
| Parameter | Truth | ferx FOCEI | nlmixr2 FOCEI | NONMEM LAPLACE |
|---|---|---|---|---|
| CL | 1.0 | 1.039 | 1.026 | 1.031 |
| V | 10.0 | 9.879 | 9.946 | 9.897 |
| KA | 1.0 | 0.978 | 0.994 | 0.980 |
| ω²(CL) | 0.09 | 0.083 | 0.080 | 0.081 |
| H0 | 0.015 | 0.0145 | 0.0210 | 0.0149 |
| BETA | 0.25 | 0.256 | 0.201 | 0.251 |
The baseline hazard H0 and the exposure slope BETA are collinear — H0·exp(BETA·Cc) trades baseline against slope over the narrow single-occasion concentration range. ferx and NONMEM (both truth-started) sit near truth; nlmixr2 lands at the other end of the same flat ridge — all valid optima of an under-determined likelihood, with corr(H0, BETA) = −0.93 (ferx) / −0.91 (NONMEM). So the PK block is the cross-tool agreement check; H0 and BETA are jointly, not individually, identified. (The objective value is not cross-tool comparable here — different F_FLAG likelihood normalization — so compare ΔOFV between nested models, not absolutes.)
Simulation. ferx’s simulate() event-time distribution is validated two ways (tests/reference/pktte_joint_sim/):
- Goodness-of-fit (in-tree, license-free): simulated event times, probability-integral- transformed against the model’s own survival via an independent integrator, pass a Kolmogorov–Smirnov test against Uniform(0,1) — D = 0.040 vs the 5% critical 0.061 at N=500, ≈ the value expected for an exactly-correct sampler. This is immune to the H0/BETA ridge (no estimation), so it is the decisive sampler check.
- Cross-tool (NONMEM
$SIM): ferx’s Kaplan–Meier survival matches NONMEM’s analytic marginal survivalmean_i exp(−CHZ_i(t))to max |ΔS(t)| = 0.044 across the horizon (N=500).
See also
examples/tte_exponential.ferx— minimal worked exampledata/tte_exponential.csv— small dataset (30 subjects) used by the smoke teststests/reference/tte_exponential/— 100-subject validation dataset (tte_exp.csv), reference scripts (simulate.R,survreg.R,nlmixr2.R,nonmem.ctl),README.md(NONMEM/nlmixr2 hand-off) andexpected.md(filled comparison)examples/pktte_joint.ferx— joint PK-TTE worked example (drug-driven ODE hazard)tests/reference/pktte_joint/— joint PK-TTE fit anchor (ferx / NONMEM / nlmixr2)tests/reference/pktte_joint_sim/— joint PK-TTE simulation anchor (ferx vs NONMEM$SIM:make_template.py,sim.ctl,compare.R,expected.md)tests/tte_smoke.rs— Tier-2 parse and short-run smoke teststests/tte_convergence.rs— Tier-3 convergence + SSE tests (--features survival,slow-tests)plans/tte-survival-markov.md— full multi-phase roadmap