Illness-death, intervals (CMM)¶
model="cmm" — the illness-death process, returned as counting-process
intervals: one row per transition a subject was at risk of.
Three states: 1 healthy, 2 ill, 3 dead. A subject starts healthy and
either falls ill first (1 → 2, then possibly 2 → 3) or dies directly
(1 → 3).
Built on the multistate engine
Since 3.0.0 this is a configuration of gen_multistate -
the illness-death graph with Weibull edges and a reset clock - rather than a
separate implementation. Its columns, dtypes and parameters are unchanged,
but a given seed no longer reproduces its 2.1.0 output. Reach for the
engine directly when you need a state structure this page does not
describe.
stateDiagram-v2
direction LR
s1: 1 healthy
s2: 2 ill
s3: 3 dead
s1 --> s2: rate[0], rate[1]
s1 --> s3: rate[2], rate[3]
s2 --> s3: rate[4], rate[5]
Want the same process as a panel of observed states instead? Use THMM.
The model¶
Each transition has a latent time drawn from a Weibull-type inversion:
so each transition gets a pair of parameters — an intensity \(\lambda_{ij}\) and a shape \(\rho_{ij}\) — plus one coefficient \(\beta_{ij}\). With \(\rho_{ij} = 1\) the sojourn is exponential and \(\lambda_{ij}\) is the intensity directly.
A subject leaves state 1 at \(\min(T_{12}, T_{13})\), going wherever came first. If censoring beats both, it stays in state 1.
It is semi-Markov, not Markov
The 2 → 3 sojourn is drawn on a reset clock: the row spans \(t_{12}\) to
\(t_{12} + t_{23}\) where \(t_{23}\) is an independent draw, so the intensity of
dying depends on time since falling ill, not on time since entry. This
matches genCMM in the R package.
Parameters¶
| Parameter | Type | Constraint | Meaning |
|---|---|---|---|
n |
int |
> 0 | number of subjects — not the number of rows |
model_cens |
str |
"uniform" or "exponential" |
censoring mechanism |
cens_par |
float |
> 0 | censoring parameter |
beta |
Sequence[float] |
exactly 3 | one coefficient per transition: 1→2, 1→3, 2→3 |
covariate_range |
float |
> 0 | X0 drawn from Uniform(0, covariate_range) |
rate |
Sequence[float] |
exactly 6 | three (intensity, shape) pairs, in transition order |
seed |
int | Generator | None |
— | reproducibility |
rate unpacks as:
| Position | Symbol | Transition |
|---|---|---|
rate[0], rate[1] |
\(\lambda_{12}\), \(\rho_{12}\) | 1 → 2 |
rate[2], rate[3] |
\(\lambda_{13}\), \(\rho_{13}\) | 1 → 3 |
rate[4], rate[5] |
\(\lambda_{23}\), \(\rho_{23}\) | 2 → 3 |
Wrong lengths fail loudly rather than being silently truncated:
LengthError: Argument 'rate' must be a sequence of length 6; got length 4.
Adjust the number of elements. (while validating inputs for model 'cmm')
Example¶
from gen_surv import generate
df = generate(model="cmm", n=6, model_cens="exponential", cens_par=2.0,
beta=[0.1, 0.2, 0.3], covariate_range=1.0,
rate=[0.1, 1.0, 0.2, 1.0, 0.1, 1.0], seed=42)
print(df.head(6))
id start stop from_state to_state status X0
0 0 0.0 2.819921 1 2 0 0.773956
1 0 0.0 2.819921 1 3 0 0.773956
2 1 0.0 1.891403 1 2 0 0.438878
3 1 0.0 1.891403 1 3 1 0.438878
4 2 0.0 0.158588 1 2 0 0.858598
5 2 0.0 0.158588 1 3 0 0.858598
Read subject 1: it was at risk of both 1 → 2 and 1 → 3 over
[0, 1.891), and the 1 → 3 row carries status = 1, so it died without ever
falling ill. Subjects 0 and 2 have status = 0 on both of their rows —
censored while still healthy, contributing time at risk and no event.
Six subjects, twelve rows. Never use len(df) as a sample size:
Check: do the intensities come back?¶
Set every shape to 1 so the sojourns are exponential, and zero the coefficients so the intensity is the same for everyone. Then each transition's MLE is events divided by time at risk — which the frame gives you directly, because that is what the interval columns are for:
from gen_surv import generate
rate = [0.3, 1.0, 0.2, 1.0, 0.5, 1.0]
df = generate(model="cmm", n=40000, model_cens="uniform", cens_par=60.0,
beta=[0.0, 0.0, 0.0], covariate_range=1.0, rate=rate, seed=7)
for (fs, ts), true_l in zip([(1, 2), (1, 3), (2, 3)], [rate[0], rate[2], rate[4]]):
rows = df[(df["from_state"] == fs) & (df["to_state"] == ts)]
exposure = float((rows["stop"] - rows["start"]).sum())
print(f"{fs}->{ts} declared={true_l} mle={int(rows['status'].sum()) / exposure:.3f}")
Fitting a transition-specific model¶
The layout is already what a stratified Cox model wants — one stratum per
transition, with (start, stop] intervals:
from lifelines import CoxPHFitter
trans_12 = df[(df["from_state"] == 1) & (df["to_state"] == 2)]
fit = CoxPHFitter().fit(trans_12[["stop", "status", "X0"]],
duration_col="stop", event_col="status")
For 2 → 3, remember the clock resets: use stop - start as the duration, not
stop.
Related¶
- The multistate engine — what this model is a configuration of
- Illness-death, panel (THMM) — the same process, observed states
- Output schemas — every column
- Competing risks —
1 → 2versus1 → 3is itself a competing-risks problem - API:
gen_cmm