## MultiState


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


Usage

``` python
MultiState()
```


Given counting-process intervals `(start, stop]` each labelled with the state occupied (`state`) and the state transitioned to at `stop` ([event](Surv.md#greenwood.Surv.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](AalenJohansen.md#greenwood.AalenJohansen) and [KaplanMeier](KaplanMeier.md#greenwood.KaplanMeier).)


## Returns


`Fitted estimator`  
Call [fit()](AFT.md#greenwood.AFT.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.


``` python
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)

| time  | mgus     | pcm      | death    |
|-------|----------|----------|----------|
| f64   | f64      | f64      | f64      |
| 1.0   | 0.969653 | 0.0      | 0.030347 |
| 2.0   | 0.947961 | 0.001446 | 0.050593 |
| 3.0   | 0.937114 | 0.001446 | 0.061439 |
| 4.0   | 0.924822 | 0.002169 | 0.073009 |
| 5.0   | 0.916868 | 0.002892 | 0.080239 |
| …     | …        | …        | …        |
| 356.0 | 0.08175  | 0.0      | 0.91825  |
| 373.0 | 0.0545   | 0.02725  | 0.91825  |
| 376.0 | 0.0545   | 0.02725  | 0.91825  |
| 394.0 | 0.0545   | 0.02725  | 0.91825  |
| 424.0 | 0.0      | 0.02725  | 0.97275  |


## Methods

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

------------------------------------------------------------------------


#### fit()


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


Usage

``` python
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](Surv.md#greenwood.Surv.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()](AFT.md#greenwood.AFT.to_frame) (optionally `format=`). Occupancy probabilities and transition probabilities can be queried at any time via [predict()](AFT.md#greenwood.AFT.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](Surv.md#greenwood.Surv.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:


``` python
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):


``` python
ms.predict([60, 120, 240], format="polars")
```


shape: (3, 4)

| time  | mgus     | pcm      | death    |
|-------|----------|----------|----------|
| f64   | f64      | f64      | f64      |
| 60.0  | 0.645529 | 0.016006 | 0.338464 |
| 120.0 | 0.40446  | 0.012027 | 0.583513 |
| 240.0 | 0.176158 | 0.011489 | 0.812353 |


------------------------------------------------------------------------


#### predict()


State occupancy probabilities at specified times.


Usage

``` python
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:


``` python
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)

| time  | mgus     | pcm      | death    |
|-------|----------|----------|----------|
| f64   | f64      | f64      | f64      |
| 60.0  | 0.645529 | 0.016006 | 0.338464 |
| 120.0 | 0.40446  | 0.012027 | 0.583513 |
| 240.0 | 0.176158 | 0.011489 | 0.812353 |


------------------------------------------------------------------------


#### to_frame()


Return occupancy probabilities over time as a DataFrame.


Usage

``` python
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:


``` python
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)

| time  | mgus     | pcm      | death    |
|-------|----------|----------|----------|
| f64   | f64      | f64      | f64      |
| 1.0   | 0.969653 | 0.0      | 0.030347 |
| 2.0   | 0.947961 | 0.001446 | 0.050593 |
| 3.0   | 0.937114 | 0.001446 | 0.061439 |
| 4.0   | 0.924822 | 0.002169 | 0.073009 |
| 5.0   | 0.916868 | 0.002892 | 0.080239 |
| …     | …        | …        | …        |
| 356.0 | 0.08175  | 0.0      | 0.91825  |
| 373.0 | 0.0545   | 0.02725  | 0.91825  |
| 376.0 | 0.0545   | 0.02725  | 0.91825  |
| 394.0 | 0.0545   | 0.02725  | 0.91825  |
| 424.0 | 0.0      | 0.02725  | 0.97275  |


Request a different backend with `format=`:


``` python
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
