Outcome

A reusable description of a survival response: column names and expressions, no data.

Usage

Source

Outcome()

An Outcome is the structured form of a formula’s left-hand side. Outcome.surv() takes the arguments of R’s Surv() and Outcome.event_time() those of event_time(), each written as it would be inside a formula: a column name, or an expression such as "status == 2". Pass the Outcome to any estimator’s fit method together with data=. The estimator reads every column it needs from that one frame, drops rows with missing values once (as R’s na.omit does), evaluates the expressions, and builds the response with Surv() or event_time().

Because an Outcome is made of names and expressions rather than DataFrame code, the same description works on any backend: pandas, Polars, PyArrow, DuckDB, or a lazy frame. That makes it, together with the equivalent formula strings, the recommended way to start from a data frame. Use Surv() or event_time() directly when the values are already in hand.

The expressions understood here and in formulas are:

  • a column name (backticks quote unusual names inside an expression),
  • a comparison: status == 2, status != 0, status in (1, 2) (R’s %in% c(1, 2) works), or status not in (1, 2),
  • factor(x, levels, labels), R’s factor, for a multi-state status whose first level means censored,
  • duration(start, end, unit="days") for a time computed from two date columns.

Examples

We’ll use the bundled lung dataset, from a North Central Cancer Treatment Group trial in advanced lung cancer. time is days of follow-up and status is 1 (censored) or 2 (died). Here is what the dataset looks like:

PolarsRows228Columns10
lung: NCCTG advanced lung cancer, 228 patients
inst
i64
time
i64
status
i64
age
i64
sex
i64
ph.ecog
i64
ph.karno
i64
pat.karno
i64
meal.cal
i64
wt.loss
i64
0 3 306 2 74 1 1 90 100 1175 None
1 3 455 2 68 1 0 90 90 1225 15
2 3 1010 1 56 1 0 90 90 None 15
3 5 210 2 57 1 1 90 60 1150 11
4 1 883 2 60 1 0 100 90 None 0
225 32 105 1 75 2 2 60 70 1025 5
226 6 174 1 66 1 1 90 100 1075 1
227 22 177 1 58 2 1 80 90 1060 0

Describe the endpoint once, with the same arguments Surv() takes:

import greenwood as gw

death = gw.Outcome.surv(time="time", event="status == 2")
death
Outcome.surv(time='time', event='status == 2')

Pass it to estimators along with the frame. Column names work for covariates and by= too:

lung = gw.load_dataset("lung")

cox = gw.CoxPH().fit(death, covariates=["age", "sex"], data=lung)
km = gw.KaplanMeier().fit(death, by="sex", data=lung)
cox
CoxPH (Cox proportional hazards model, ties='efron')

        coef  exp(coef)  se(coef)       z         p
age  0.01705      1.017  0.009223   1.848   0.06459
sex  -0.5132     0.5986    0.1675  -3.065  0.002178

n = 228, events = 165
Likelihood ratio test = 14.12 on 2 df, p = 0.0008574

The same response written as a formula parses to an equal Outcome:

gw.Outcome.from_formula(formula="Surv(time, status == 2)") == death
True

Attributes

Name Description
arguments The arguments of the response, by name, written as they would be in a formula.
column_names The data columns this response reads, in the order they first appear.
endpoints For a first_event outcome, each endpoint’s time and event expressions.
kind Which function builds the response: "surv", "event_time", or "first_event".
origin The origin= passed to Surv(): a time subtracted from every time when bound.
type The type= passed to Surv(), or None to let it be inferred when the data is bound.

arguments

The arguments of the response, by name, written as they would be in a formula.

arguments: dict[str, str]

For a surv outcome the names are those of Surv() that were given (time=, time2=, event=), and for an event_time outcome those of event_time() (time=, status=, time_max=). Each value is a column name or an expression such as "status == 2". As in R, Surv(time, x) without event= reads x as the status, so it is listed as event=. For a first_event outcome the endpoints are in endpoints, and this holds only censor_at= and start= when they were given. The type and origin have their own properties, Outcome.type and Outcome.origin.

Examples

import greenwood as gw

gw.Outcome.from_formula(formula="Surv(tstart, tstop, status == 2)").arguments
{'time': 'tstart', 'time2': 'tstop', 'event': 'status == 2'}

column_names

The data columns this response reads, in the order they first appear.

column_names: tuple[str, …]

These are the columns named by every argument, including both columns of a duration() and the column inside a comparison or factor(). When a model is fitted from an Outcome and data=, rows with a missing value in any of these columns are dropped (together with any covariate columns) before the response is built.

Examples

import greenwood as gw

died = gw.Outcome.from_formula(formula="Surv(duration(enroll, exit), outcome == 'died')")
died.column_names
('enroll', 'exit', 'outcome')

endpoints

For a first_event outcome, each endpoint’s time and event expressions.

endpoints: dict[str, tuple[str, str]] | None

The endpoints are in priority order, which decides ties: when two endpoints happen at the same time, the first one listed wins. Each value is a (time, event) pair of expression texts, as passed to Outcome.first_event(). None for other kinds of outcome.

Examples

import greenwood as gw

cr = gw.Outcome.first_event(
    endpoints={"pcm": ("ptime", "pstat"), "death": ("futime", "death == 1")}
)
cr.endpoints
{'pcm': ('ptime', 'pstat'), 'death': ('futime', 'death == 1')}

kind

Which function builds the response: "surv", "event_time", or "first_event".

kind: str

An Outcome from Outcome.surv() (or a Surv(...) formula) is bound with Surv(). One from Outcome.event_time() (or an event_time(...) formula) is bound with event_time() and then converted with as_surv(). One from Outcome.first_event() is bound with first_event(). Every kind produces a Surv when bound.

Examples

import greenwood as gw

gw.Outcome.from_formula(formula="event_time(time, code)").kind
'event_time'

origin

The origin= passed to Surv(): a time subtracted from every time when bound.

origin: float

0 (the default) leaves the times unchanged. A non-zero origin shifts every time column, as R’s Surv(origin=) does. Always 0 for event_time and first_event outcomes.

Examples

import greenwood as gw

gw.Outcome.from_formula(formula="Surv(age_at_exit, died, origin=40)").origin
40.0

type

The type= passed to Surv(), or None to let it be inferred when the data is bound.

type: str | None

One of R’s types: "right", "left", "interval", "counting", "interval2", or "mstate". None, the usual case, means Surv() infers right-censored or counting-process data from the arguments, or multi-state data from a factor() status. The type of the bound response is reported by Surv.type. Always None for event_time and first_event outcomes.

Examples

import greenwood as gw

gw.Outcome.surv(time="lower", time2="upper", type="interval2").type
'interval2'

Methods

Name Description
bind() Build the Surv response from the columns of data.
event_time() Describe an event_time() response by column names.
first_event() Describe a first_event() competing-risks response by column names.
from_formula() Parse a formula response such as "Surv(time, status == 2)" into an Outcome.
surv() Describe a Surv() response by column names and expressions.

bind()

Build the Surv response from the columns of data.

Usage

Source

bind(data)

Each argument is evaluated against data on its own backend, and the results go through Surv(), through event_time() and then as_surv(), or through first_event(), depending on kind. bind() is what an estimator does internally when it is given an Outcome and data=, apart from one difference. bind() keeps rows with missing values, as Surv() does, while fitting first drops rows with a missing value in any column the model uses, so the response and the covariates stay aligned.

Use bind() to inspect the response a description produces, or to pass the Surv to a function that takes values rather than an Outcome.

Parameters

data: Any
A data frame (pandas, Polars, PyArrow, DuckDB, a lazy frame, …) or a mapping of column names to values. Only the columns the Outcome names are read.

Returns

Surv
The response for this data.

Raises

KeyError

If a column named by the Outcome is not in data. The message suggests the closest column name.

TypeError

If data is not a data frame or a mapping, or Surv() or event_time() rejects the values (for example, a text status column used without a comparison).

ValueError
If Surv(), event_time(), or first_event() rejects the values.

Warns

UserWarning
When Surv() turns invalid values into missing ones, such as a status code R doesn’t accept. The affected rows are missing in the result.

Examples

We’ll use the bundled lung dataset, where status is 1 (censored) or 2 (died). R’s 1/2 coding passes through:

import greenwood as gw

lung = gw.load_dataset("lung")
death = gw.Outcome.surv(time="time", event="status")

y = death.bind(lung)
y
Surv(type=right, n=228, events=165)

The bound response is an ordinary Surv, with R’s columns:

y.to_frame(format="polars").head(3)
shape: (3, 2)
timestatus
f64f64
306.01.0
455.01.0
1010.00.0

event_time()

Describe an event_time() response by column names.

Usage

Source

event_time(
    time,
    status,
    time_max=None,
)

event_time() writes each observation as a time and a one-letter status: "e" (exact event), "r" (right-censored), "l" (left-censored), or "i" (interval-censored, with the upper bound in a second time column). This constructor names the columns that hold those values. When the Outcome is bound to data, the columns are passed to event_time(), so its validation applies, and the result is converted to a Surv with as_surv().

This is the same description that the formula response "event_time(time, status)" gives.

Parameters

time: str | Duration

The time column (the lower bound for interval-censored rows), or duration(...) for a time computed from two date columns.

status: str

The column of status codes: "e", "r", "l", or "i".

time_max: str | Duration | None = None
The column of interval upper bounds, missing for rows that are not interval-censored. Needed only when some rows are interval-censored.

Returns

Outcome
A data-free description of the response.

Raises

TypeError
If an argument is not a string or a gw.duration() object.

Examples

A small study where the event time is known for some subjects and only bracketed for others:

import greenwood as gw
import polars as pl

visits = pl.DataFrame({
    "time": [7.0, 5.0, 3.0, 2.0],
    "code": ["e", "r", "l", "i"],
    "upper": [None, None, None, 4.0],
})

observed = gw.Outcome.event_time(time="time", status="code", time_max="upper")
observed.bind(visits)
Surv(type=interval, n=4, events=3)

Interval-censored responses fit with Turnbull:

gw.Turnbull().fit(observed, data=visits)
Turnbull (self-consistent NPMLE for interval-censored data)

     n  atoms  ambiguous  iters  converged
all  1      1          1      1       True

(3 observations deleted due to missingness)

first_event()

Describe a first_event() competing-risks response by column names.

Usage

Source

first_event(
    endpoints,
    *,
    censor_at=None,
    start=None,
)

Some datasets record each endpoint in its own pair of columns: a time and whether the endpoint was observed. A competing-risks analysis needs one time and one cause per subject: the first endpoint observed, or censoring if none was. This constructor names the column pairs. When the Outcome is bound to data, the pairs are evaluated and passed to first_event(), which keeps the earliest observed event for each row.

Parameters

endpoints: Mapping[str, tuple[str | Duration, str]]

A mapping of state name to a (time, event) pair, in priority order: when two endpoints happen at the same time, the one listed first wins. The time is a column name (or duration(...)), and the event is a column or a comparison such as "pstat == 1".

censor_at: str | Duration | None = None

The column holding the time at which rows with no observed event are censored. The default is the latest of the endpoint times.

start: str | Duration | None = None
The column of entry times, for late entry. Gives a multi-state counting-process response.

Returns

Outcome
A data-free description of the response.

Raises

TypeError

If a time or event is not a string or a gw.duration() object.

ValueError
If endpoints is empty, or an endpoint is not a (time, event) pair.

Examples

We’ll use the bundled mgus2 dataset, which follows patients with monoclonal gammopathy. ptime and pstat record progression to plasma-cell malignancy (PCM), and futime and death record death:

import greenwood as gw

cr = gw.Outcome.first_event(
    endpoints={"pcm": ("ptime", "pstat"), "death": ("futime", "death")}
)
cr
Outcome.first_event(endpoints={'pcm': ('ptime', 'pstat'), 'death': ('futime', 'death')})

Fit the cumulative incidence of each cause:

mgus2 = gw.load_dataset("mgus2")

gw.AalenJohansen().fit(cr, data=mgus2)
AalenJohansen (Aalen-Johansen cumulative incidence)

states: pcm, death
n = 1384

       final CIF
pcm       0.1613
death     0.8387

from_formula()

Parse a formula response such as "Surv(time, status == 2)" into an Outcome.

Usage

Source

from_formula(formula)

The response is a call to Surv() or event_time(), with the arguments R’s functions take: Surv(time, time2, event, type=, origin=) and event_time(time, status, time_max). Arguments bind by position or by name, as in R. Each one is a column name or one of the expressions listed under Outcome. Column names that aren’t valid Python identifiers are quoted with backticks, and dotted R names such as ph.ecog work without quoting.

The formula is parsed, not evaluated: only column names, literal values, comparisons, and the functions duration(), factor(), and c() are accepted. Estimators parse full formulas the same way, so fit("Surv(time, status == 2) ~ age", data=df) uses exactly this response.

Parameters

formula: str
The response, optionally followed by ~ 1. To include covariates, pass the full formula to an estimator’s fit method instead.

Returns

Outcome
A data-free description of the response, equal to the one built with surv() or event_time() from the same arguments.

Raises

ValueError
If the formula can’t be parsed, the response is not a call to Surv() or event_time(), it has an unknown or repeated argument (such as the removed event_value=), it has a right-hand side other than 1, or an expression is not supported.

Examples

import greenwood as gw

gw.Outcome.from_formula(formula="Surv(time, status == 2)")
Outcome.surv(time='time', event='status == 2')

It equals the Outcome built from the same arguments:

gw.Outcome.from_formula(formula="Surv(time, status == 2)") == gw.Outcome.surv(
    time="time", event="status == 2"
)
True

R’s other forms work too:

gw.Outcome.from_formula(formula="Surv(tstart, tstop, status %in% c(1, 2))")
Outcome.surv(time='tstart', time2='tstop', event='status in (1, 2)')
gw.Outcome.from_formula(formula="event_time(time, code, upper)")
Outcome.event_time(time='time', status='code', time_max='upper')

surv()

Describe a Surv() response by column names and expressions.

Usage

Source

surv(
    time,
    time2=None,
    event=None,
    *,
    type=None,
    origin=0.0,
)

The arguments are those of Surv() (and of R’s survival::Surv()), each written as it would be inside a formula: a column name, or an expression such as "status == 2". Nothing is read yet. When the Outcome is bound to data, at fit time or with bind(), each argument is evaluated on the data’s own backend and the results are passed to Surv(). R’s rules then apply unchanged:

  • 1/2 status coding passes through, and other codings are written as a comparison.
  • A second argument without event= is read as the status, so Outcome.surv(time="t", time2="d") means the same as Outcome.surv(time="t", event="d").
  • type= selects left-censored or interval data. Without it, the type is inferred: right-censored from a time and a status, counting-process from a start and a stop time, and multi-state from a factor() status.

This is the same description that the formula response "Surv(time, status == 2)" gives, and the two compare equal.

Parameters

time: str | Duration

The time column: the follow-up time for right-censored data, the start time for counting-process data, or the lower bound for interval data. Can also be duration(...) (or a gw.duration() object) for a time computed from two date columns.

time2: str | Duration | None = None

The stop time for counting-process data, or the upper bound for interval data. Without event=, a second argument is the status, as in R.

event: str | None = None

The status: a column name, a comparison such as "status == 2" or "status %in% c(1, 2)", or "factor(cause, c(0, 1, 2), c('censor', 'pcm', 'death'))" for a multi-state response whose first label means censored.

type: str | None = None

As in Surv(): "right", "left", "interval", "counting", "interval2", or "mstate". The default (None) infers the type from the arguments when the data is bound.

origin: float = 0.0
A time subtracted from every time when the data is bound, as in Surv().

Returns

Outcome
A data-free description of the response.

Raises

TypeError

If an argument is not a string or a gw.duration() object. To build a response from values instead of column names, use Surv().

ValueError
If type= is not one of the types above, an expression uses an unsupported operator, or the status is a duration().

Examples

We’ll use the bundled pbc dataset, from a Mayo Clinic trial in primary biliary cholangitis. time is days of follow-up and status is 0 (censored), 1 (transplant), or 2 (died). Death is the event, so the status is a comparison:

import greenwood as gw

death = gw.Outcome.surv(time="time", event="status == 2")
death
Outcome.surv(time='time', event='status == 2')

Pass it to an estimator together with the data. Rows with a missing value in any column used are dropped first:

pbc = gw.load_dataset("pbc")

gw.KaplanMeier().fit(death, by="trt", data=pbc)
KaplanMeier (Kaplan-Meier survival estimate)

         n  events  median  0.95LCL  0.95UCL
trt=1  158      65    3282     2583       NA
trt=2  154      60    3428     3090       NA

(106 observations deleted due to missingness)

A factor() status gives a multi-state response, here with transplant and death as competing events:

competing = gw.Outcome.surv(
    time="time",
    event="factor(status, c(0, 1, 2), c('censor', 'transplant', 'death'))",
)
competing.bind(pbc)
Surv(type=mright, n=418, events=186, states=('transplant', 'death'))