# Import the Greenwood library
import greenwood as gw
# Four subjects: events at 5 and 4, censored at 6 and 9
y = gw.Surv(time=[5, 6, 4, 9], event=[1, 0, 1, 0])
# Display a compact summary of the response
ySurv(type=right, n=4, events=2)
Survival analysis studies the time until an event happens: death, relapse, machine failure, customer churn, or any other well-defined endpoint. What makes this kind of data special, and what separates survival analysis from ordinary regression, is that we usually cannot observe the event time for everyone. Some subjects are still event-free when the study ends, some drop out early, and some enter late. Handling these partially observed times correctly is the entire point of the field, and it starts with how you represent the data.
This page explains the structure of survival data, introduces the Surv object (the response type that every estimator and model in Greenwood consumes), and shows how to describe a response by column names when your data lives in a data frame.
Imagine a study that follows patients for up to two years after treatment and records the time until their disease comes back (relapse). Figure 1 shows four of them.
Patients A and D relapsed while they were being followed, so we know exactly when: at month 8 and month 17. Patients B and C are different. B was still relapse-free when the study ended at month 24, and C moved away at month 13 and was never seen again. For both of them we only know that the relapse, if it ever happens, comes after the last time they were seen. Their times are censored: cut off before the event could be observed.
So every patient contributes two pieces of information: a time, and a status that says what the time means. For A and D the time is when the event happened. For B and C it is the last moment they were known to be event-free, which is still useful information: B stayed relapse-free for at least 24 months, and that counts.
Censoring is why ordinary methods break down here. Averaging the four times treats B’s 24 months as if B relapsed at month 24, which makes the treatment look worse than it is. Dropping B and C throws away the fact that they did well for a long time. Survival methods use exactly what each patient tells us, no more and no less, and that is what the rest of this guide is about.
The example above shows right censoring: the event, if any, happens after the last time we saw the patient. It is by far the most common situation. Figure 2 shows the others you may meet, and how each one is written down.
gw.event_time(), and the counting-process form of gw.Surv() for late entry.
All of these are expressed through the same response object, so the estimators and models you meet later work the same way whichever kind of data you have.
A survival observation carries two pieces of information: a time, and a status that says what the time means (an event, or a censoring point). You must always keep them together. Greenwood does this for you with the Surv object.
There are two starting points, shown in Figure 3. If your data sits in a data frame (the usual case), name the columns in a formula such as "Surv(time, status) ~ age", or describe the response once with gw.Outcome.surv(), and pass the frame with data= when you fit a model. If you already hold the values (lists, arrays, or simulated data), build the response directly with gw.Surv(), or with gw.event_time() if you think in the status codes of Figure 2. Either way, every estimator receives the same thing: a Surv response.
gw.Surv() or gw.event_time(). All of them lead to a Surv response, which every estimator accepts.
The rest of this page walks through both starting points, beginning with gw.Surv() because it shows most clearly what a response contains.
Surv is the response you pass to estimators and models. It bundles the times with their status codes, so that downstream code can rely on a clean, consistent representation. You build one with a single call, gw.Surv(time, time2, event, type=, origin=), which mirrors R’s survival::Surv() argument for argument. As in R, the kind of response is inferred from the arguments you supply.
The most common case is right-censored data: pass the observed times and an event indicator, where a true value (or 1) means the event occurred and a false value (or 0) means the observation was censored.
# Import the Greenwood library
import greenwood as gw
# Four subjects: events at 5 and 4, censored at 6 and 9
y = gw.Surv(time=[5, 6, 4, 9], event=[1, 0, 1, 0])
# Display a compact summary of the response
ySurv(type=right, n=4, events=2)
The repr gives a quick summary: the censoring type, the number of observations, and how many of them were events. You can also reach the derived quantities directly.
# Read off the derived counts directly from the response
print("observations:", y.n)
print("events:", y.n_events)
print("censored:", y.n_censored)observations: 4
events: 2
censored: 2
Each kind of observation in Figure 2 has its own form of gw.Surv(). When subjects enter the risk set after time zero, or when a covariate changes during follow-up, you use the counting-process form. Each observation is an interval (start, stop] with an event indicator at the stop time. This is also how left truncation (late entry) is expressed. Supplying time2= is all it takes: time becomes the start and time2 the stop.
# Each row is an interval (start, stop] with an event indicator at the stop time
gw.Surv(time=[0, 2, 1], time2=[5, 6, 4], event=[1, 0, 1])Surv(type=counting, n=3, events=2)
The remaining types must be requested explicitly with type=, exactly as in R. With type="left", a status of 1 is an exact event and a status of 0 means the event had already happened by the recorded time (left censoring).
# Status 1 is an exact event, status 0 is left-censored at the recorded time
gw.Surv(time=[5, 6, 4], event=[1, 0, 1], type="left")Surv(type=left, n=3, events=2)
Interval censoring, where the event is known only to fall between two times, is most easily written with type="interval2". The two times are the lower and upper bounds. An upper bound of numpy.inf (or a missing value) marks right censoring, a missing lower bound marks left censoring, and equal bounds mark an exact event. R’s other form, type="interval", takes the two times plus a status code (0 right, 1 exact, 2 left, 3 interval) and is supported too.
import numpy as np
# The event is known only to fall in [lower, upper]; numpy.inf marks right censoring
gw.Surv(time=[1, 2, 3], time2=[2, np.inf, 5], type="interval2")Surv(type=interval, n=3, events=2)
Competing risks and multi-state endpoints, where more than one kind of event can occur, follow R’s rule as well: make the event a categorical. The first category means censoring, and the remaining categories are the states, in order.
import pandas as pd
# The first category ("censor") is censoring, and the others are the states
outcome = pd.Categorical(
["relapse", "death", "censor", "relapse"],
categories=["censor", "relapse", "death"],
)
gw.Surv(time=[5, 6, 7, 8], event=outcome)Surv(type=mright, n=4, events=3, states=('relapse', 'death'))
When each endpoint lives in its own time and event columns instead, gw.first_event() builds the same multi-state response by taking the earliest event per subject. The competing risks page shows it in action.
Greenwood offers two layers for building a response. gw.Surv(), which mirrors R’s survival::Surv(), and gw.event_time() build a response from values you already hold: lists, NumPy arrays, or simulated data, as in the examples above. When your data lives in a data frame, you describe the response instead, by column names and expressions. A formula such as "Surv(time, status == 2) ~ age" or an outcome such as gw.Outcome.surv(time="time", event="status == 2") names the columns, and the estimator evaluates it against the frame you pass as data= when it fits. Because the description holds names rather than data frame code, it works the same on every backend (pandas, Polars, PyArrow, DuckDB, and lazy frames). That is why it is the recommended way to start from a data frame.
The bundled lung dataset, from the North Central Cancer Treatment Group, is a good example.
# Load the bundled lung dataset as a Polars DataFrame
lung = gw.load_dataset("lung", backend="polars")
# Preview the raw data
lungPolarsRows228Columns10 | ||||||||||
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 |
| 5 | 12 | 1022 | 1 | 74 | 1 | 1 | 50 | 80 | 513 | 0 |
| 6 | 7 | 310 | 2 | 68 | 2 | 2 | 70 | 60 | 384 | 10 |
| 7 | 11 | 361 | 2 | 71 | 2 | 2 | 60 | 80 | 538 | 1 |
| 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 |
In this dataset, and in several others that come from R, the status column is coded 1 for censored and 2 for dead, rather than the 0/1 convention. Surv() handles this the way R does: when the largest status value is 2, the codes are read as 1/2 and shifted down to 0/1. So the column passes straight through, and the formula is the one you would write in R.
# The response and the covariates in one R-style formula, evaluated against `data=`
gw.CoxPH().fit("Surv(time, status) ~ age + sex", data=lung)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
When you fit several models to the same endpoint, or a model that takes no covariates, describe the response once with gw.Outcome.surv(). It takes the same arguments as Surv(), each written as it would be inside a formula, and holds no data.
# The lung endpoint, described by column names
death = gw.Outcome.surv(time="time", event="status")
deathOutcome.surv(time='time', event='status')
Pass it to any estimator along with data=. Covariates and grouping arguments such as by= or strata= can then be column names too:
# The same description drives a Kaplan-Meier curve and a Cox model
km = gw.KaplanMeier().fit(death, by="sex", data=lung)
cox = gw.CoxPH().fit(death, covariates=["age", "sex", "ph.ecog"], data=lung)
coxCoxPH (Cox proportional hazards model, ties='efron')
coef exp(coef) se(coef) z p
age 0.01107 1.011 0.009267 1.194 0.2324
sex -0.5526 0.5754 0.1677 -3.294 0.0009861
ph.ecog 0.4637 1.59 0.1136 4.083 4.447e-05
n = 227, events = 164 (1 observation deleted due to missingness)
Likelihood ratio test = 30.5 on 3 df, p = 1.083e-06
Binding at fit time reads every column the model uses from the one frame, and it drops any row with a missing value in one of them, as R’s na.omit does. The response and the covariates therefore can never get out of step. The printout reports how many rows were dropped, and the fitted model keeps the count in n_dropped_. Here, one patient has no ph.ecog score.
A formula’s response parses to the same Outcome, and bind() evaluates either one against a frame when you want the Surv itself.
Each argument of a response is a column name or an expression. The expressions mirror what you would write in R, so a formula copied from an R script usually works unchanged. For any status coding other than 0/1 or 1/2, write a comparison that gives a true or false value per row. The pbc dataset codes status as 0 (censored), 1 (transplant), and 2 (dead). This column must not be passed raw, because R’s rules would read it as a 1/2 coding and mark the 0 rows as invalid. An all-cause endpoint and a death-only endpoint are each one comparison:
# Load the pbc dataset, whose status has three levels (0, 1, 2)
pbc = gw.load_dataset("pbc", backend="polars")
# All-cause: anything other than 0 is an event
print("all-cause: ", gw.Outcome.surv(time="time", event="status != 0").bind(pbc))
# Death only: transplant becomes censoring
print("death-only:", gw.Outcome.surv(time="time", event="status == 2").bind(pbc))all-cause: Surv(type=right, n=418, events=186)
death-only: Surv(type=right, n=418, events=161)
The comparisons are ==, !=, in, and not in. R’s status %in% c(1, 2) is accepted as a spelling of status in (1, 2). Strings are compared the same way, which saves recoding an outcome column by hand. Missing values follow R: == and != with a missing value give a missing status, while in gives false.
import polars as pl
# Build a small example with string outcomes
study = pl.DataFrame({
"time": [12.0, 5.5, 20.0, 8.0],
"outcome": ["died", "withdrew", "censored", "died"],
})
# Only "died" is an event, and the other outcomes are censored
gw.KaplanMeier().fit("Surv(time, outcome == 'died') ~ 1", data=study)KaplanMeier (Kaplan-Meier survival estimate)
n events median 0.95LCL 0.95UCL
all 4 2 12 8 NA
For competing risks and multi-state endpoints, R’s factor(x, levels, labels) turns a status column into a categorical. The first label means censored and the remaining labels are the states, in order. R’s ranges such as 0:2 work for the levels, and repeating a label merges levels (for example, two codes that both mean censored).
# pbc as a competing-risks response: transplant and death are separate states
gw.Outcome.surv(
time="time",
event="factor(status, c(0, 1, 2), c('censor', 'transplant', 'death'))",
).bind(pbc)Surv(type=mright, n=418, events=186, states=('transplant', 'death'))
A time can also be computed from two date columns with duration(start, end, unit=), where the unit is "days" (the default), "weeks", "months", or "years". The Data sources page shows it on a study export.
On the right-hand side of a formula, strata(), cluster(), and frailty() terms route columns to the strata=, cluster=, and frailty_cluster= arguments. For the non-parametric estimators and the hypothesis tests, the right-hand side names the grouping column(s).
# A formula for a stratified Cox model, a grouped Kaplan-Meier curve, and the log-rank test
gw.CoxPH().fit("Surv(time, status) ~ age + sex + strata(inst)", data=lung)
km = gw.KaplanMeier().fit("Surv(time, status) ~ sex", data=lung)
gw.logrank_test("Surv(time, status) ~ sex", data=lung)TestResult(method='Log-rank test', statistic=10.3267, df=1, p_value=0.001311)
A formula’s response and the arguments of an Outcome only ever name columns and literal values. Nothing in them is evaluated as Python code.
The event must be logical, 0/1, or 1/2. Any other value is not an error: following R, it becomes a missing status and Greenwood issues a warning (“Invalid status value, converted to NA”). Missing rows are dropped when you fit. Watch for that warning, since it usually means a column with more than two codes was used without a comparison. The section on missing values and validation shows what happens next.
Sometimes the clearest way to describe a censored time is to say what kind it is. gw.event_time() does exactly that. It pairs each time with a one-letter status: "e" for an exact event, "r" for right-censored, "l" for left-censored, and "i" for interval-censored. For interval-censored values, time_max= holds the upper end of the interval.
# One exact, one right-censored, one left-censored, and one interval-censored time
x = gw.event_time(
time=[7, 5, 3, 2],
status=["e", "r", "l", "i"],
time_max=[None, None, None, 4],
)
x<event_time[4]>
7 5+ 3- [2, 4]
The printout uses the familiar notation: 5+ is right-censored, 3- is left-censored, and [2, 4] is an interval. Convert to a Surv with gw.as_surv() when you need one.
Surv(type=interval, n=4, events=3)
You often do not need the conversion at all, because estimators accept an event-time vector directly.
Turnbull (self-consistent NPMLE for interval-censored data)
n atoms ambiguous iters converged
all 4 5 4 30 True
When the times and codes are columns of a frame, describe them by name with gw.Outcome.event_time(time=, status=, time_max=) or the formula response event_time(time, status, time_max), just as with Surv.
Surv() follows R in how it treats questionable input. It does not reject a row outright. Instead, an invalid status code becomes a missing value with a warning, and missing times or statuses are kept in the response. The repr reports how many rows are missing, and every estimator drops them when it fits, as R’s na.omit does, and reports the count.
# The status of 3 is invalid, so the last row becomes missing
y = gw.Surv(time=[5, 6, 7, 8, 9], event=[1, 0, 1, 1, 3])
# The missing row is dropped at fit time, and the printout says so
gw.KaplanMeier().fit(y)/tmp/ipykernel_8133/1498405665.py:2: UserWarning: Invalid status value, converted to NA
y = gw.Surv(time=[5, 6, 7, 8, 9], event=[1, 0, 1, 1, 3])
KaplanMeier (Kaplan-Meier survival estimate)
n events median 0.95LCL 0.95UCL
all 4 3 7 5 NA
(1 observation deleted due to missingness)
The counting-process form has one more rule from R. Each start must be strictly less than its stop. A row where a subject enters and exits at the same time has no time at risk, so Surv() sets its start to missing with the warning “Stop time must be > start time, NA created”.
# Subject 1 enters at time 1 and exits at time 1 (zero follow-up), so the row becomes missing
gw.Surv(time=[1, 0], time2=[1, 2], event=[1, 0])/tmp/ipykernel_8133/268776079.py:2: UserWarning: Stop time must be > start time, NA created
gw.Surv(time=[1, 0], time2=[1, 2], event=[1, 0])
Surv(type=counting, n=2, events=1, missing=1)
If you see this warning, check your data:
start >= stop (they have zero or negative follow-up time)stop = start + 1e-10A response also serializes to and from JSON without loss, which is useful for saving an analysis input or moving it between systems. Calling to_json() returns a plain string, so you can write it to a file or send it over the wire. Here is the serialized form of the lung response, truncated to its first stretch so you can see the shape of it.
# Load data and bind the lung response to get a Surv
lung = gw.load_dataset("lung", backend="polars")
y = death.bind(lung)
# Serialize the response to a self-contained JSON string
text = y.to_json(indent=None)
# Peek at the first 200 characters to see the structure
text[:200]'{"version": 2, "type": "right", "states": null, "columns": {"time": [306.0, 455.0, 1010.0, 210.0, 883.0, 1022.0, 310.0, 361.0, 218.0, 166.0, 170.0, 654.0, 728.0, 71.0, 567.0, 144.0, 613.0, 707.0, 61.0'
That string carries the times and status codes together, which is exactly what a Surv object holds. Reading it back with from_json() reconstructs an equivalent response, and we can confirm nothing was lost by comparing the JSON of the original and the restored object.
# Reconstruct an equivalent response from the JSON string
restored = gw.Surv.from_json(text)
# Confirm the round-trip preserved every value
print("round-trips exactly:", restored.to_json() == y.to_json())round-trips exactly: True
For working in memory rather than as text, to_dict() and from_dict() perform the same round-trip with a plain Python dictionary, and to_frame() (optionally with a format= of "polars", "pandas", or "pyarrow") gives a tidy, one-row-per-subject view that is convenient for inspection or export.
# Export as a tidy one-row-per-subject DataFrame for inspection or export
y.to_frame(format="polars")PolarsRows228Columns2 | ||
time f64 |
status f64 |
|
|---|---|---|
| 0 | 306 | 1 |
| 1 | 455 | 1 |
| 2 | 1010 | 0 |
| 3 | 210 | 1 |
| 4 | 883 | 1 |
| 5 | 1022 | 0 |
| 6 | 310 | 1 |
| 7 | 361 | 1 |
| 225 | 105 | 0 |
| 226 | 174 | 0 |
| 227 | 177 | 0 |
The dictionary form is the same structure to_json() serializes, so gw.Surv.from_dict() rebuilds an equivalent response from it.
Under every non-parametric estimate is one tabulation: at each distinct event time, how many subjects are still at risk, how many have the event, and how many are censored. This risk-set table is what Kaplan-Meier, the log-rank test, and Cox all build on, and you can compute it directly with event_table(). It matches R’s survfit tabulation.
# Tabulate events and censorings at each distinct event time
gw.event_table(death, data=lung).to_frame(format="polars")PolarsRows186Columns5 | |||||
strata str |
time f64 |
n_risk f64 |
n_event f64 |
n_censor f64 |
|
|---|---|---|---|---|---|
| 0 | all | 5 | 228 | 1 | 0 |
| 1 | all | 11 | 227 | 3 | 0 |
| 2 | all | 12 | 224 | 1 | 0 |
| 3 | all | 13 | 223 | 2 | 0 |
| 4 | all | 15 | 221 | 1 | 0 |
| 5 | all | 26 | 220 | 1 | 0 |
| 6 | all | 30 | 219 | 1 | 0 |
| 7 | all | 31 | 218 | 1 | 0 |
| 183 | all | 965 | 3 | 0 | 1 |
| 184 | all | 1010 | 2 | 0 | 1 |
| 185 | all | 1022 | 1 | 0 | 1 |
Each row is a distinct time. The n_risk column counts the subjects under observation just before that time, n_event the events at it, and n_censor the censorings. The strata column is "all" for an ungrouped table. Name a grouping column on the right-hand side of a formula (or pass group=) to tabulate within strata, and strata labels each block ("sex=1", "sex=2").
# Tabulate separately within each level of the `sex` column
gw.event_table("Surv(time, status) ~ sex", data=lung).to_frame(format="polars")PolarsRows206Columns5 | |||||
strata str |
time f64 |
n_risk f64 |
n_event f64 |
n_censor f64 |
|
|---|---|---|---|---|---|
| 0 | sex=1 | 11 | 138 | 3 | 0 |
| 1 | sex=1 | 12 | 135 | 1 | 0 |
| 2 | sex=1 | 13 | 134 | 2 | 0 |
| 3 | sex=1 | 15 | 132 | 1 | 0 |
| 4 | sex=1 | 26 | 131 | 1 | 0 |
| 5 | sex=1 | 30 | 130 | 1 | 0 |
| 6 | sex=1 | 31 | 129 | 1 | 0 |
| 7 | sex=1 | 53 | 128 | 2 | 0 |
| 203 | sex=2 | 765 | 3 | 1 | 0 |
| 204 | sex=2 | 821 | 2 | 0 | 1 |
| 205 | sex=2 | 965 | 1 | 0 | 1 |
You rarely need this table directly, but it is the shared foundation the estimators are built on, and it is handy when you want to check counts by hand or drive a custom calculation.
You now know how to represent survival data with Surv() or event_time(), how to describe a response by column names with a formula or Outcome, and how to handle its event coding with expressions. From here you can bring in your own data or start estimating.