MultiState

Aalen-Johansen estimator of multi-state transition and occupancy probabilities.

Usage

Source

MultiState()

Given counting-process intervals (start, stop] each labelled with the state occupied (state) and the state transitioned to at stop (event, or a censoring marker), this forms the Aalen-Johansen product \(P(0, t) = \prod (I + dA(s))\) and reports the state occupancy probabilities over time. Occupancy probabilities are validated to tolerance against R’s survfit multi-state pstate. (Competing risks and Kaplan-Meier are special cases handled by AalenJohansen and KaplanMeier.)

Returns

Fitted estimator
Call fit() to produce a fitted estimator with cached results (states_, time_, occupancy_, and internal transition matrices), accessible as tidy DataFrames.

Examples

The mgus2 patients occupy three states in turn: "mgus" at entry, then possibly "pcm" (plasma-cell malignancy), then "death". Reshape the wide dataset into counting-process intervals (start, stop], one interval per state occupied, labelled with the state entered next. A patient who progresses before dying contributes two intervals; everyone else contributes one. Fitting reports the occupancy probability of each state over time.

import greenwood as gw

mg = gw.load_dataset("mgus2", backend="pandas")
start, stop, state, event = [], [], [], []
for i in range(len(mg)):
    pt, ft = mg["ptime"][i], mg["futime"][i]
    progressed, died = mg["pstat"][i] == 1, mg["death"][i] == 1
    if progressed and pt < ft:
        start += [0, pt]; stop += [pt, ft]; state += ["mgus", "pcm"]
        event += ["pcm", "death" if died else None]
    else:
        start += [0]; stop += [ft]; state += ["mgus"]
        event += ["death" if died else ("pcm" if progressed else None)]
rows = [(a, b, s, e) for a, b, s, e in zip(start, stop, state, event) if b > a]
start, stop, state, event = map(list, zip(*rows))
ms = gw.MultiState().fit(start, stop, state, event, states=("mgus", "pcm", "death"))
ms.to_frame(format="polars")
shape: (276, 4)
timemguspcmdeath
f64f64f64f64
1.00.9696530.00.030347
2.00.9479610.0014460.050593
3.00.9371140.0014460.061439
4.00.9248220.0021690.073009
5.00.9168680.0028920.080239
356.00.081750.00.91825
373.00.05450.027250.91825
376.00.05450.027250.91825
394.00.05450.027250.91825
424.00.00.027250.97275

Methods

Name Description
fit() Fit a multi-state model using counting-process intervals.
predict() State occupancy probabilities at specified times.
to_frame() Return occupancy probabilities over time as a DataFrame.

fit()

Fit a multi-state model using counting-process intervals.

Usage

Source

fit(start, stop, state, event, *, states=None)

Estimates state-occupancy probabilities and transition dynamics over time from counting-process data (multiple overlapping intervals per subject). The model tracks how subjects move between states and computes the probability of being in each state at any given time, accounting for censoring and competing transitions.

This estimator is ideal for:

  • Panel data: Subjects observed at discrete times, with state changes recorded between observations.
  • Chronic-disease progression: Modeling progression through stages (e.g., MGUS → PCM → death).
  • Multi-event data: Non-absorbing or semi-absorbing intermediate states.
  • Irregular follow-up: Each subject’s observation times may differ.

The model estimates without distributional assumptions via non-parametric maximum likelihood. Occupancy probabilities are computed as a product of transition matrices evaluated at each event time.

Parameters
start: Any

Start time of each interval. Can be a 1-D array-like (or Polars/Pandas Series). Intervals are half-open: (start, stop].

stop: Any

Stop (end) time of each interval. Must have the same length as start. Intervals define subject-time windows.

state: Any

The state occupied during each interval (the “from” state). Can be string, int, or other hashable label. Must have the same length as start and stop.

event: Any

The state transitioned to at the stop time. If None, NaN, or 0, the subject was censored (no transition). Otherwise, must be a valid state label. Must have the same length as start and stop.

states: Any = None
Optional ordered sequence of all state labels (default: auto-detected from data). If provided, must include all unique states in state and event. Useful for enforcing a specific state ordering (e.g., disease progression order) or including states with no observed transitions.
Returns
MultiState
The fitted estimator object itself (for method chaining) with cached results (states_, time_, occupancy_, transition_) accessible via to_frame() (optionally format=). Occupancy probabilities and transition probabilities can be queried at any time via predict().
Details

Data format: Intervals are half-open (start, stop]. Each row represents a subject-interval: the period during which the subject was in state and either remained (censored) or transitioned to event at stop.

State labels: States can be strings, integers, or other hashable types (e.g., tuples). Mixed types are not allowed. Transitions between the same state (self-loops) treated as censoring.

Handling censoring: Censored intervals (event = None/NaN/0) contribute right-censored data. Subjects re-enter their original state after censoring (common in discrete-time or periodic follow-up studies).

Computational method: Non-parametric maximum likelihood. At each distinct event time, transition intensities are estimated from risk sets (subjects at risk to transition from each state), and occupancy is updated by matrix multiplication of transition probabilities.

Examples

Build a multi-state model from counting-process intervals. First, prepare interval data from a chronic-disease cohort:

import greenwood as gw

mg = gw.load_dataset("mgus2", backend="pandas")
start, stop, state, event = [], [], [], []
for i in range(len(mg)):
    pt, ft = mg["ptime"][i], mg["futime"][i]
    progressed, died = mg["pstat"][i] == 1, mg["death"][i] == 1
    if progressed and pt < ft:
        start += [0, pt]; stop += [pt, ft]; state += ["mgus", "pcm"]
        event += ["pcm", "death" if died else None]
    else:
        start += [0]; stop += [ft]; state += ["mgus"]
        event += ["death" if died else ("pcm" if progressed else None)]
rows = [(a, b, s, e) for a, b, s, e in zip(start, stop, state, event) if b > a]
start, stop, state, event = map(list, zip(*rows))
ms = gw.MultiState().fit(start, stop, state, event, states=("mgus", "pcm", "death"))
ms
MultiState (Aalen-Johansen multi-state model)

states: mgus, pcm, death
times: 276

       final occupancy
mgus                 0
pcm            0.02725
death           0.9727

Query occupancy probabilities at specific follow-up times (60, 120, and 240 months); pass format= to choose the backend (here, Polars):

ms.predict([60, 120, 240], format="polars")
shape: (3, 4)
timemguspcmdeath
f64f64f64f64
60.00.6455290.0160060.338464
120.00.404460.0120270.583513
240.00.1761580.0114890.812353

predict()

State occupancy probabilities at specified times.

Usage

Source

predict(times, *, format=None)

Evaluates the state occupancy probabilities (probability of being in each state) at requested times. The occupancy probability for each state is a right-continuous step function, defined at the fitted time points and interpolated or held constant elsewhere.

Parameters
times: Any

Time points at which to evaluate state occupancy. Can be a scalar or array-like of floats. Values before the first transition time use the initial distribution; values after the last transition time use the final distribution.

format: str | None = None
Output format: None (default), "pandas", "polars", or "pyarrow". When None, a backend is auto-detected (Polars, then Pandas, then PyArrow).
Returns
pandas.DataFrame, polars.DataFrame, or pyarrow.Table
A table with a time column and one column per state, containing occupancy probabilities (values between 0 and 1) at each query time.
Examples

Evaluate occupancy probabilities at specific times. The result shows how the probability of being in each state changes over time:

import greenwood as gw

mg = gw.load_dataset("mgus2", backend="pandas")
start, stop, state, event = [], [], [], []
for i in range(len(mg)):
    pt, ft = mg["ptime"][i], mg["futime"][i]
    progressed, died = mg["pstat"][i] == 1, mg["death"][i] == 1
    if progressed and pt < ft:
        start += [0, pt]; stop += [pt, ft]; state += ["mgus", "pcm"]
        event += ["pcm", "death" if died else None]
    else:
        start += [0]; stop += [ft]; state += ["mgus"]
        event += ["death" if died else ("pcm" if progressed else None)]
rows = [(a, b, s, e) for a, b, s, e in zip(start, stop, state, event) if b > a]
start, stop, state, event = map(list, zip(*rows))
ms = gw.MultiState().fit(start, stop, state, event, states=("mgus", "pcm", "death"))
ms.predict([60, 120, 240], format="polars")
shape: (3, 4)
timemguspcmdeath
f64f64f64f64
60.00.6455290.0160060.338464
120.00.404460.0120270.583513
240.00.1761580.0114890.812353

to_frame()

Return occupancy probabilities over time as a DataFrame.

Usage

Source

to_frame(*, format=None)

Exports one row per distinct time and one column per state, where each state column contains its occupancy probability at that time.

Parameters
format: str | None = None
Output format: None (default), "pandas", "polars", or "pyarrow". When None, a backend is auto-detected (Polars, then Pandas, then PyArrow).
Returns
pandas.DataFrame, polars.DataFrame, or pyarrow.Table
A tidy table with a time column and one probability column per state.
Raises
ImportError
If the requested (or, when auto-detecting, any) DataFrame library is not installed.
Examples

Fit a multi-state model on the mgus2 cohort and export the state-occupancy probabilities as a Polars frame:

import greenwood as gw

mg = gw.load_dataset("mgus2", backend="pandas")
start, stop, state, event = [], [], [], []
for i in range(len(mg)):
    pt, ft = mg["ptime"][i], mg["futime"][i]
    progressed, died = mg["pstat"][i] == 1, mg["death"][i] == 1
    if progressed and pt < ft:
        start += [0, pt]; stop += [pt, ft]; state += ["mgus", "pcm"]
        event += ["pcm", "death" if died else None]
    else:
        start += [0]; stop += [ft]; state += ["mgus"]
        event += ["death" if died else ("pcm" if progressed else None)]
rows = [(a, b, s, e) for a, b, s, e in zip(start, stop, state, event) if b > a]
start, stop, state, event = map(list, zip(*rows))
ms = gw.MultiState().fit(start, stop, state, event, states=("mgus", "pcm", "death"))
ms.to_frame(format="polars")
shape: (276, 4)
timemguspcmdeath
f64f64f64f64
1.00.9696530.00.030347
2.00.9479610.0014460.050593
3.00.9371140.0014460.061439
4.00.9248220.0021690.073009
5.00.9168680.0028920.080239
356.00.081750.00.91825
373.00.05450.027250.91825
376.00.05450.027250.91825
394.00.05450.027250.91825
424.00.00.027250.97275

Request a different backend with format=:

ms.to_frame(format="pandas")
time mgus pcm death
0 1.0 0.969653 0.000000 0.030347
1 2.0 0.947961 0.001446 0.050593
2 3.0 0.937114 0.001446 0.061439
3 4.0 0.924822 0.002169 0.073009
4 5.0 0.916868 0.002892 0.080239
... ... ... ... ...
271 356.0 0.081750 0.000000 0.918250
272 373.0 0.054500 0.027250 0.918250
273 376.0 0.054500 0.027250 0.918250
274 394.0 0.054500 0.027250 0.918250
275 424.0 0.000000 0.027250 0.972750

276 rows × 4 columns