Survival data and the Surv object

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.

Why survival data is different

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.

Relapse observed Censored: relapse-free when last seen What is recorded time status Patient A 8 event Patient B ? study ended 24 censored Patient C ? moved away 13 censored Patient D 17 event end of study 0 4 8 12 16 20 24 Months since enrolment
Figure 1: Four patients in a two-year study. Each line is the time a patient was followed. A filled dot marks a relapse that was seen. An open circle marks the last time the patient was seen relapse-free, and the dashed arrow says the true relapse time, if there is one, lies somewhere later. On the right is what ends up in the data: one time and one status per patient.

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.

Other ways an observation can be incomplete

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.

What we know about one subject Written as Exact event The event was seen when it happened. status "e" event at 8 Right-censored Event-free when last seen. status "r" event after 12 Left-censored Already happened by the first check. status "l" event before 8 Interval-censored Happened between two visits. status "i" event in (4, 12] Late entry Only observed after joining the study. Surv(t0, t1, event) start, stop, event somewhere here first check somewhere here visit visit not yet in study 0 4 8 12 16 20 Time Event Censored Where the event could be Not observed
Figure 2: Kinds of observation, one subject per row. The shaded band is where the event could be. In left censoring the event had already happened by the first check. In interval censoring it happened between two visits. With late entry (also called left truncation), the subject is only observed after joining the study, so the time before entry tells us nothing about them. The right column shows the matching status code for 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.

NoteThe key idea

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.

Ways to build a response

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.

Where is your data? In a data frame pandas, Polars, PyArrow, DuckDB, lazy frames Already in hand lists, NumPy arrays, simulated values Name columns in a formula "Surv(time, status) ~ age" or Describe it once with an Outcome gw.Outcome.surv(time="time", event="status") Times and an event indicator gw.Surv(time=t, event=d) or Event-time codes e, r, l, i gw.event_time(time=t, status=s) evaluated against data= converted with gw.as_surv() A Surv response right, left, interval, counting, multi-state Any estimator's fit()
Figure 3: The ways to build a survival response. From a data frame, name columns in a formula or an Outcome. From values in hand, call 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.

The Surv object

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
y
Surv(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

The other kinds of observation

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.

Building a response from a data frame

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
lung
PolarsRows228Columns10
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

Describing an outcome once

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")
death
Outcome.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)
cox
CoxPH (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.

# The formula response and the Outcome are the same description
print(gw.Outcome.from_formula(formula="Surv(time, status)") == death)

# Bind the description to a frame to get a Surv
death.bind(lung)
True
Surv(type=right, n=228, events=165)

The expression grammar

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.

WarningCheck your event coding

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.

Writing it as an event-time vector

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.

# Convert the event-time vector to the equivalent Surv
gw.as_surv(x)
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's NPMLE fitted straight from the event-time vector
gw.Turnbull().fit(x)
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.

Missing values and validation

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:

  • Most common fix: exclude subjects with start >= stop (they have zero or negative follow-up time)
  • Data quality check: why do some subjects have identical entry and exit times? Is this a data entry error?
  • If intentional: if the event truly occurred instantaneously, add a small epsilon to the stop time: stop = start + 1e-10

Serialization

A 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.

# Serialize to a dict, then immediately reconstruct from it
gw.Surv.from_dict(gw.Surv(time=[5, 6, 4], event=[1, 0, 1]).to_dict())
Surv(type=right, n=3, events=2)

The risk-set table

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.

Next steps

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.

  • Data sources and formats shows how to load data from pandas, Polars, and other backends, how to compute durations from timestamps and encode event indicators from compound status columns, and lists the datasets bundled with the package.
  • Estimating survival with Kaplan-Meier shows how to estimate and summarize the survival curve.
  • Comparing groups covers the log-rank family of tests.
  • The Quick start is a fast tour of everything if you prefer to skim first.