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 and introduces the Surv object, the response type that every estimator and model in Greenwood consumes.

Why survival data is different

Imagine a study that follows patients for two years and records the time until relapse. At the end of the study, three situations are possible for any given patient.

The first is a fully observed event: the patient relapsed at a known time, say 8 months. The second is right censoring: the patient was relapse-free at their last visit, say at 20 months, so all we know is that their true relapse time is greater than 20 months. The third is a variation on censoring caused by how subjects enter and leave the study.

Right censoring is by far the most common situation, and it is why we cannot simply average the observed times or run a standard regression on them. A censored time of 20 months is not the same as an event at 20 months, and treating it as one would badly bias every estimate. Survival methods are built to use the partial information in a censored observation without pretending it is a complete one.

Subject A Event (t = 8) Subject B Censored (t = 20) 0 4 8 12 16 20 24 Months of follow-up
Figure 1: Two subjects observed over a study window. Subject A reaches the event at month 8. Subject B is still event-free at month 20, so their true event time is only known to exceed 20: this is right censoring.

Greenwood also supports less common but important patterns: left censoring (the event happened before a known time), interval censoring (the event happened between two known times), and left truncation or late entry (a subject only becomes observable after some delay). All of these are expressed through the same response object.

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.

The Surv object

Surv is the response you pass to estimators and models. It bundles the times with their status codes and validates them, so that downstream code can rely on a clean, consistent representation. You build one with a constructor that names the censoring type.

The most common constructor is gw.Surv.right, for right-censored data. It takes the observed times and an event indicator, where a truthy value (or 1) means the event occurred and a falsy value (or 0) means the observation was censored.

import greenwood as gw

# Four subjects: events at 5 and 4, censored at 6 and 9.
y = gw.Surv.right(time=[5, 6, 4, 9], event=[1, 0, 1, 0])
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.

print("observations:", y.n)
print("events:", y.n_events)
print("censored:", y.n_censored)
observations: 4
events: 2
censored: 2

Other censoring types

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 is expressed.

gw.Surv.counting(start=[0, 2, 1], stop=[5, 6, 4], event=[1, 0, 1])
Surv(type=counting, n=3, events=2, truncated)

Left censoring, where all you know is that the event had already happened by the time of observation, uses gw.Surv.left. It takes the same (time, event) arguments as gw.Surv.right, but the time is an upper bound on the unknown event time rather than a lower one.

gw.Surv.left(time=[5, 6, 4], event=[1, 0, 1])
Surv(type=left, n=3, events=2)

Interval censoring, where the event is known only to fall between two times, uses gw.Surv.interval. Use numpy.inf for the upper bound to mark right censoring.

import numpy as np

gw.Surv.interval(lower=[1, 2, 3], upper=[2, np.inf, 5])
Surv(type=interval, n=3, events=2)

Competing risks and multi-state endpoints, where more than one kind of event can occur, use gw.Surv.multistate. The event codes are 0 for censoring and 1, 2, ... for the competing causes, and you name the states.

gw.Surv.multistate(time=[5, 6, 7, 8], event=[1, 2, 0, 1], states=("relapse", "death"))
Surv(type=right, n=4, events=3, states=('relapse', 'death'))

Building a response from a data frame

In practice your data lives in a data frame. Greenwood is dataframe-agnostic through Narwhals, so you can pass pandas or Polars columns directly. The bundled lung dataset, from the North Central Cancer Treatment Group, is a good example.

lung = gw.load_dataset("lung", backend="polars")
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

There is a subtlety here that trips up almost everyone at least once. 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. Greenwood does not guess at this coding, and it will raise an error if you pass 1/2 values directly. You convert the column explicitly instead.

y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))
y
Surv(type=right, n=228, events=165)
WarningCheck your event coding

Greenwood requires the event indicator to be boolean or 0/1, where 1 means the event occurred. If your data uses another convention, such as 1 for censored and 2 for the event, convert it first with a comparison like status == 2. This deliberate strictness prevents a silent and serious error where censoring and events are swapped.

Validation and reproducibility

The Surv constructor validates its inputs immediately. Times must be finite and non-negative, the arrays must have matching lengths, and for the counting-process form each start must be strictly less than its stop. When something is wrong, you get a clear error at construction time rather than a confusing failure deep inside an estimator.

try:
    gw.Surv.right([5, -1, 3], [1, 1, 1])
except ValueError as error:
    print(error)
`stop` times must be non-negative.

Another common data quality issue is when a subject’s entry time equals their exit time (zero follow-up time). This indicates a data problem: if a subject has no time at risk, they cannot experience an event. Greenwood catches this early:

try:
    # Subject enters at time 1, exits at time 1 (follow-up = 0)
    gw.Surv.counting(start=[1, 0], stop=[1, 2], event=[1, 0])
except ValueError as error:
    print(error)

Output:

Each `start` must be strictly less than its `stop`.

If you encounter this error, 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

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.

lung = gw.load_dataset("lung", backend="polars")
y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))

text = y.to_json(indent=None)
text[:200]
'{"type": "right", "stop": [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, 88.0, 301.0, 81.0, 624.0, 371.0, 394.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.

restored = gw.Surv.from_json(text)
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.

y.to_frame(format="polars")
PolarsRows228Columns2
stop
f64
status
i64
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.

gw.Surv.from_dict(gw.Surv.right([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.

lung = gw.load_dataset("lung", backend="polars")
y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))

gw.event_table(y).to_frame(format="polars")
PolarsRows186Columns4
time
f64
n_risk
f64
n_event
f64
n_censor
f64
0 5 228 1 0
1 11 227 3 0
2 12 224 1 0
3 13 223 2 0
4 15 221 1 0
5 26 220 1 0
6 30 219 1 0
7 31 218 1 0
183 965 3 0 1
184 1010 2 0 1
185 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. Pass group= to tabulate within strata, which adds a strata column.

gw.event_table(y, group=lung["sex"]).to_frame(format="polars")
PolarsRows206Columns5
strata
obj
time
f64
n_risk
f64
n_event
f64
n_censor
f64
0 1 11 138 3 0
1 1 12 135 1 0
2 1 13 134 2 0
3 1 15 132 1 0
4 1 26 131 1 0
5 1 30 130 1 0
6 1 31 129 1 0
7 1 53 128 2 0
203 2 765 3 1 0
204 2 821 2 0 1
205 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 and how to avoid the most common coding mistake. From here you can bring in your own data or start estimating.