Visualizing survival

A survival curve is far easier to understand as a picture than as a table of numbers. By default Greenwood draws curves with Altair, which produces interactive charts (tooltips and zoom) and is Narwhals-native: the numbers flow straight from your Polars fit into the chart with no Pandas or Matplotlib in the way, so a pure Polars/Narwhals workflow stays pure. This page covers the survival curve plot, confidence bands, censoring marks, and the numbers-at-risk table. A plotnine backend, for a grammar-of-graphics workflow, is described near the end.

Altair is an optional dependency. If you installed Greenwood with the altair extra, or with all, you already have it. We use the lung dataset throughout. First we build the response.

import greenwood as gw

lung = gw.load_dataset("lung", backend="polars")

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

We then fit a Kaplan-Meier estimator stratified by sex, one curve per group, using the log-log confidence interval transform. Printing the fitted estimator summarizes each stratum.

km = gw.KaplanMeier(conf_type="log-log").fit(y, by=lung["sex"])
km
KaplanMeier (Kaplan-Meier survival estimate)

     n  events  median  0.95LCL  0.95UCL
1  138     112     270      210      306
2   90      53     426      345      524

Everything below draws from this fitted km object. Before plotting, it also helps to see the numbers the curve is built from. Calling to_frame() on the fitted estimator returns the step-function estimates in tidy form, one row per stratum and time, with the survival probability and its confidence limits. The preview shows the first and last rows along with the full dimensions of the table.

km.to_frame(format="polars")
PolarsRows206Columns9
strata
obj
time
f64
n_risk
f64
n_event
f64
n_censor
f64
estimate
f64
std_error
f64
conf_low
f64
conf_high
f64
0 1 11 138 3 0 0.978260869565 0.0124139182765 0.934121582525 0.992936557511
1 1 12 135 1 0 0.971014492754 0.014281169221 0.924619139392 0.989021541626
2 1 13 134 2 0 0.95652173913 0.0173597697718 0.905787300104 0.980228948021
3 1 15 132 1 0 0.949275362319 0.0186795340253 0.896548509216 0.975490402087
4 1 26 131 1 0 0.942028985507 0.019892897278 0.887428391293 0.970579101988
5 1 30 130 1 0 0.934782608696 0.0210182948563 0.878418312188 0.965522040145
6 1 31 129 1 0 0.927536231884 0.0220691880472 0.869508906988 0.960339509261
7 1 53 128 2 0 0.913043478261 0.0239859688855 0.851957778533 0.949657700321
203 2 765 3 1 0 0.0832144435134 0.0499212744378 0.0185052512122 0.212363854832
204 2 821 2 0 1 0.0832144435134 0.0499212744378 0.0185052512122 0.212363854832
205 2 965 1 0 1 0.0832144435134 0.0499212744378 0.0185052512122 0.212363854832

Each row is a step in one of the curves. The time and estimate columns give the point at which the survival probability drops and its value after the drop, while conf_low and conf_high give the band that the plot will shade. The strata column identifies the group, so there is one set of steps per sex, and n_risk, n_event, and n_censor record how many subjects were at risk, failed, or were censored at each time. The plot turns exactly these numbers into a picture.

The survival curve plot

The main entry point is plot_survival. Given a fitted KaplanMeier, it draws the step curve for each stratum, a shaded confidence band, and a small notch at each censoring time.

lung = gw.load_dataset("lung", backend="polars")

y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))
km = gw.KaplanMeier(conf_type="log-log").fit(y, by=lung["sex"])

gw.plot_survival(km)

Each element carries meaning. The steps show the estimated survival probability, the band shows its uncertainty, and the censoring notches — angled so they sit on the curve without obscuring it — show where subjects left the risk set without an event. Because the chart is interactive, you can hover a curve to read the survival probability at a time, and drag or scroll to zoom. Curves that separate and stay apart suggest a real group difference, which you would confirm with a log-rank test from Comparing groups.

You can turn individual elements off. Removing the band and the censoring marks produces a cleaner curve for a slide or a small figure.

gw.plot_survival(km, conf_int=False, censor_marks=False)

Adding a numbers-at-risk table

A survival plot is much easier to trust when it is accompanied by the number of subjects still at risk at regular intervals. As the risk set shrinks, the curve becomes less certain, and the table makes that visible. Pass risk_table=True to stack an aligned table beneath the curve.

gw.plot_survival(km, risk_table=True)

The table shares the horizontal axis with the curve above it, so the counts line up with the times they describe — and because they share one x scale, panning or zooming the curve moves the table with it. You control the times shown with the times argument.

gw.plot_survival(km, risk_table=True, times=[0, 250, 500, 750, 1000])

If you only need the numbers, risk_table_data returns them as a tidy frame, which you can render however you like, including through Great Tables. It is backend-neutral, so it works whether or not you have a plotting library installed.

gw.viz.risk_table_data(km, times=[0, 250, 500, 750, 1000], format="polars")
PolarsRows10Columns3
strata
str
time
f64
n_risk
f64
0 1 0 138
1 1 250 62
2 1 500 20
3 1 750 7
4 1 1000 2
5 2 0 90
6 2 250 53
7 2 500 21
8 2 750 3
9 2 1000 0

To draw just the aligned table as its own chart, without a curve above it, use risk_table.

gw.risk_table(km, times=[0, 250, 500, 750, 1000])

Customizing the plot

plot_survival returns an ordinary Altair chart, so you refine it with Altair’s API. Chaining .properties sets the title and dimensions, and you can keep layering or restyling from there.

gw.plot_survival(km).properties(
    title="Survival by sex", width=640, height=360
)
NoteThe plot is a chart object, not an image

plot_survival returns an Altair chart (an alt.LayerChart, or an alt.VConcatChart when risk_table=True). It renders interactively in notebooks and browsers, and you keep composing it with Altair’s API. Nothing about the figure is locked down.

TipSaving figures

Call save on the returned chart. Use chart.save("survival.html") for the interactive version, or chart.save("survival.png") (also .svg, .pdf) for a static image — static export uses vl-convert, which ships with the altair extra.

The plotnine backend

If you prefer a grammar-of-graphics workflow, or you already build figures with ggplot2-style layers, Greenwood also draws the same curves with plotnine under greenwood.viz.plotnine. It is an optional dependency, installed with the plotnine extra (or with all). The plotnine backend is built on Matplotlib and Pandas, so it is not part of a Pandas-free workflow, but it produces static, publication-ready figures you compose with the + operator.

The API mirrors the default one: plot_survival takes the same fitted KaplanMeier and the same conf_int, censor_marks, risk_table, and times arguments, but returns a plotnine ggplot (or a plotnine composition when risk_table=True).

from plotnine import labs, theme_bw

(
    gw.viz.plotnine.plot_survival(km, risk_table=True)
    + theme_bw()
    + labs(title="Survival by sex", x="Days since enrollment", color="Sex")
)

Because the result is a plotnine object, you extend it with any plotnine layer, scale, or theme, and you save it with its save method, for example plot.save("survival.png", width=8, height=6, dpi=300).

Next steps

You can now produce and customize publication-quality survival figures.

  • Cox regression introduces models whose coefficients you will later visualize as forest plots.
  • Competing risks produces cumulative incidence curves, which are plotted the same way.
  • Revisit Comparing groups to pair a grouped figure with a formal test.