19  Survival plots

19.1 When to use it

The Kaplan-Meier curve is the workhorse of every CORR outcomes paper. Reach for it whenever the question is how long until an event and some patients have not had the event yet by the end of follow-up: death, reoperation, readmission. That last part is what makes survival analysis its own method: a patient who is alive at last contact is right-censored, not a non-event, and the product-limit estimator is what lets those partial observations still count.

Before you choose a panel, name the question. These displays share a time axis, but they do not answer the same thing:

  • Survival probability, \(S(t)\): What proportion remains alive and event-free beyond time \(t\)?
  • Cumulative hazard, \(H(t)\): How much event intensity has accumulated through time \(t\)? This is dimensionless and is not a probability.
  • Hazard rate, \(h(t)\): Among patients still at risk at time \(t\), how quickly are events occurring? Its units are events per unit of person-time.
  • Absolute survival difference, \(S_2(t)-S_1(t)\): How many percentage points separate two groups at a named time? We build that contrast in the NNT and survival-difference chapter.
  • Number needed to treat, \(NNT(t)\): How many patients must receive group 2 rather than group 1 to prevent one event by time \(t\)? The direction and time horizon belong beside the number.
  • At-risk denominator, \(n(t)\): How many observed, event-free patients support the estimate immediately before time \(t\)? It tells you how much information remains; it is not another outcome curve.

hv_survival() (Ehrlinger 2026), built on the survival package (Therneau 2026), estimates the product-limit survival function once and stores everything five related panels need (the survival curve, cumulative hazard, hazard rate, log-log check, and integrated survivorship), plus tidy summary tables in $tables. If the old program used %kaplan, hv_survival() is the maintained R workflow. Build the S3 object once, inspect its data and tables, then call plot() with a type argument to pull out the panel you want. Each panel comes back as a bare ggplot you finish with the usual +.

19.2 The data it needs

hv_survival() expects long-format data: one row per patient with a follow-up time and an event indicator (1 = event, 0 = censored). Nothing else is required for an unstratified curve; add a grouping column when you want curves per arm. sample_survival_data() builds a simulated right-censored cohort in exactly this shape, so the recipe runs end to end before you point it at your own data.

dta_km <- sample_survival_data(n = 500, seed = 42)
head(dta_km)
    iv_dead  dead iv_opyrs age_at_op
1  3.966736  TRUE 2003.503  58.08251
2 13.217905  TRUE 2008.716  65.37466
3  5.669821  TRUE 1990.072  58.80676
4  0.763838  TRUE 2005.739  66.45205
5  9.463533  TRUE 2006.139  75.84440
6 20.000000 FALSE 1991.461  60.21944
# Build the S3 object once; reuse it across every panel below.
km <- hv_survival(dta_km, report_times = seq(0, 20, 5))
km$meta
$time_col
[1] "iv_dead"

$event_col
[1] "dead"

$group_col
NULL

$method
[1] "kaplan-meier"

$conf_level
[1] 0.95

$report_times
[1]  0  5 10 15 20

$n_obs
[1] 500

$n_input
[1] 500

$n_excluded
[1] 0

$n_events
[1] 293
km$tables$risk
  strata report_time n.risk
1    All           0    500
2    All           5    411
3    All          10    321
4    All          15    259
5    All          20    207

19.3 Build it

Start from the bare panel so you can see what the constructor produced before any styling. The default type = "survival" is the curve with a logit-transform 95% confidence ribbon.

plot(km)

Then layer on the house style. The pattern below is the one we use most often in CORR: a single steelblue for a one-cohort figure, percent labels on the y-axis, an n = callout in the lower-left, and the manuscript theme. Swap in theme_hv_poster() when you want the larger, poster-sized text.

plot(km, alpha = 0.8) +
  scale_color_manual(values = c(All = "steelblue"), guide = "none") +
  scale_fill_manual(values  = c(All = "steelblue"), guide = "none") +
  scale_y_continuous(breaks = seq(0, 100, 20),
                     labels = function(x) paste0(x, "%")) +
  scale_x_continuous(breaks = seq(0, 20, 5)) +
  coord_cartesian(xlim = c(0, 20), ylim = c(0, 100)) +
  labs(x = "Years after Operation", y = "Freedom from Death (%)",
       title = "Overall Survival") +
  annotate("text", x = 1, y = 5, label = paste0("n = ", nrow(dta_km)),
           hjust = 0, size = 3.5) +
  theme_hv_manuscript()
Figure 19.1: Kaplan-Meier overall survival for the simulated cohort, styled in the CORR house style with a logit-transform 95% confidence ribbon

19.4 Read it

A Kaplan-Meier curve starts at 100% and steps downward at each event time; it never rises. A few things to look at:

  • Censoring changes the denominator, not the curve height. A censor time removes that patient from later risk sets without creating a downward step. This simulated cohort has administrative censoring at 20 years, so the final at-risk count is the group still event-free when observation ends.
  • The ribbon widens to the right. As the at-risk population thins, each remaining event moves the estimate more, so the confidence band fans out. Be cautious reading the far-right tail, which may rest on a handful of patients.
  • Median survival is where the curve crosses 50%. If it never crosses, the median is not reached within follow-up and you report a different landmark (e.g. 5-year survival) instead.
  • The numbers at risk below the figure tell the reader how much data backs each region of the curve. Always show them.

The at-risk strip and the per-time-point report (point estimates with CIs) are stored on the object, so you never recompute them:

km$tables$risk
  strata report_time n.risk
1    All           0    500
2    All           5    411
3    All          10    321
4    All          15    259
5    All          20    207
km$tables$report
  strata report_time  surv     lower     upper n.risk n.event
1    All           0 1.000 1.0000000 1.0000000    500       0
2    All           5 0.822 0.7859710 0.8530976    412       1
3    All          10 0.642 0.5989814 0.6828473    322       1
4    All          15 0.518 0.4741762 0.5615486    260       1
5    All          20 0.414 0.3715880 0.4577269    207       0

19.5 Numbers at risk

The “always show them” advice has a one-call answer. hv_atrisk() turns the object’s risk table into an aligned panel, and hv_atrisk_compose() stacks it under the curve, matching the table’s x-range to the plot’s so the counts sit beneath the time points they describe. Style the curve as usual, build the table from the same object, and compose the two.

curve <- plot(km, alpha = 0.8) +
  scale_color_manual(values = c(All = "steelblue"), guide = "none") +
  scale_fill_manual(values  = c(All = "steelblue"), guide = "none") +
  scale_y_continuous(breaks = seq(0, 100, 20),
                     labels = function(x) paste0(x, "%")) +
  scale_x_continuous(breaks = seq(0, 20, 5)) +
  coord_cartesian(xlim = c(0, 20), ylim = c(0, 100)) +
  labs(x = "Years after Operation", y = "Freedom from Death (%)") +
  theme_hv_manuscript()

risk_panel <- hv_atrisk(km)
hv_atrisk_compose(curve, risk_panel)
Figure 19.2: Survival curve with an aligned numbers-at-risk table composed beneath the time axis

The counts under the axis are the same km$tables$risk numbers from above, now positioned at the curve’s 0-, 5-, 10-, 15-, and 20-year ticks. A stratified figure gives the table one row per group, labelled to match the curves.

19.6 Variations

19.6.1 Stratified by group

To compare arms, pass group_col and the constructor fits a separate curve per stratum. Here we simulate a two-arm valve-type cohort with a 1.4x hazard ratio. Look for clearly separated curves whose CI ribbons stop overlapping in the windows where the treatment difference matters.

dta_km_s <- sample_survival_data(
  n             = 500,
  strata_levels = c("Type A", "Type B"),
  hazard_ratios = c(1, 1.4),
  seed          = 42
)
km_s <- hv_survival(dta_km_s, group_col = "valve_type")

plot(km_s, alpha = 0.8) +
  scale_color_manual(
    values = c("Type A" = "steelblue", "Type B" = "firebrick"),
    name   = "Valve Type"
  ) +
  scale_fill_manual(
    values = c("Type A" = "steelblue", "Type B" = "firebrick"),
    name   = "Valve Type"
  ) +
  scale_y_continuous(breaks = seq(0, 100, 20),
                     labels = function(x) paste0(x, "%")) +
  scale_x_continuous(breaks = seq(0, 20, 5)) +
  coord_cartesian(xlim = c(0, 20), ylim = c(0, 100)) +
  labs(x = "Years after Operation", y = "Freedom from Death (%)",
       title = "Survival by Valve Type") +
  theme_hv_manuscript() +
  theme(legend.position = "top")
Figure 19.3: Stratified survival curves for a two-arm valve-type cohort simulated with a 1.4x hazard ratio

19.6.2 Cumulative hazard

type = "cumhaz" gives the Nelson-Aalen cumulative hazard, a monotonically non-decreasing estimate of accumulated event intensity. Under the usual survival relationship, \(H(t) = -\log S(t)\); it is not \(1-S(t)\) and it is not a percentage. The local slope is the instantaneous hazard rate. Same object, different type.

plot(km, type = "cumhaz") +
  scale_x_continuous(breaks = seq(0, 20, 5)) +
  labs(x = "Years after Operation", y = "Cumulative Hazard H(t)",
       title = "Nelson-Aalen Cumulative Hazard") +
  scale_color_manual(values = c(All = "steelblue"), guide = "none") +
  theme_hv_manuscript()
Figure 19.4: Nelson-Aalen cumulative hazard, a dimensionless measure of event intensity accumulated through each follow-up time

19.6.3 Hazard rate

type = "hazard" is the instantaneous event rate among patients still at risk. Because follow-up is measured in years, its units are events per patient-year. The raw point estimates are noisy, so for a figure overlay a LOESS smooth. The smoothed curve answers when events occur fastest; it does not report the probability of an event at that time. Very short event intervals can produce a few large point estimates, so the displayed y-range stops at 1.5 events per patient-year to keep the smooth and most points readable.

plot(km, type = "hazard") +
  geom_smooth(aes(x = mid_time, y = hazard, color = strata),
              method = "loess", se = FALSE, span = 0.6) +
  scale_color_manual(values = c(All = "steelblue"), guide = "none") +
  scale_x_continuous(breaks = seq(0, 20, 5)) +
  scale_y_continuous(breaks = seq(0, 1.5, 0.5)) +
  coord_cartesian(xlim = c(0, 20), ylim = c(0, 1.5)) +
  labs(x = "Years after Operation", y = "Hazard rate (events/patient-year)",
       title = "Hazard Rate") +
  theme_hv_manuscript()
Figure 19.5: Instantaneous event rate per patient-year with a LOESS smooth; the y-axis stops at 1.5 so a few unstable interval spikes fall outside the displayed range

19.6.4 Log-log survival (proportional-hazards check)

type = "loglog" plots log(-log S(t)) against log(time). Parallel lines across strata mean the proportional-hazards assumption is reasonable. Run this check before you quote a single hazard ratio. Fit with group_col so there are strata to compare.

plot(km_s, type = "loglog") +
  scale_color_manual(
    values = c("Type A" = "steelblue", "Type B" = "firebrick"),
    name   = "Valve Type"
  ) +
  labs(x = "log(Years after Operation)", y = "log(-log S(t))",
       title = "Log-Log Survival") +
  theme_hv_manuscript()
Figure 19.6: Log-log survival plot for checking the proportional-hazards assumption across strata

19.6.5 Integrated life table (restricted mean survival)

type = "life" is the integrated survivorship: the area under the survival curve up to time t, equal to the restricted mean survival time (RMST) at that horizon. It rises linearly while S(t) is near 1 and bends as events accumulate. RMST is the summary to reach for when the log-log check above shows hazards are not proportional and a single hazard ratio would mislead.

plot(km, type = "life") +
  scale_color_manual(values = c(All = "steelblue"), guide = "none") +
  scale_x_continuous(breaks = seq(0, 20, 5)) +
  labs(x = "Years after Operation",
       y = "Restricted Mean Survival (years)",
       title = "Integral of Survivorship") +
  theme_hv_manuscript()
Figure 19.7: Integrated survivorship, the restricted mean survival time accumulated up to each horizon

19.7 Pitfalls

  • Event coding. event must be 1 for the event and 0 for censored. Flip them and you estimate freedom from being censored, which is meaningless. Check the coding before you trust the curve.
  • Reading the tail. The right end of the curve often rests on very few patients; the widening ribbon is the warning. Quote a landmark estimate (with its at-risk count) rather than the visual endpoint.
  • Non-proportional hazards. If the log-log curves cross, a single hazard ratio is the wrong summary. Report RMST from the integrated panel instead.