Alongside the er_plot() and er_vpc()
mini-grammars covered in the other articles, erplots supplies a third,
separate mini-grammar built around er_tte() for
Kaplan-Meier/survival-over-time figures. It has a different coordinate
system to the other two: time on the x-axis, survival probability on the
y-axis, rather than exposure-vs-response. er_tte() itself
computes the Kaplan-Meier estimate once at the start of the pipeline,
and every layer added afterwards reads from that shared fit rather than
recomputing it. As with er_plot() and
er_vpc(), nothing is drawn until at least one
er_tte_add_*() layer has been added and the pipeline is
plotted.
This article uses the lung dataset from the survival
package throughout, treating status == 2 as the event
indicator (the dataset codes 1 = censored, 2 =
dead).
A single curve
The minimal pipeline is er_tte() followed by
er_tte_add_curve(), which draws the Kaplan-Meier step curve
with a confidence band:
lung |>
er_tte(time, status == 2) |>
er_tte_add_curve() |>
plot()
er_tte_add_censor() adds a tick mark at every censoring
time, layered on top of the curve:
lung |>
er_tte(time, status == 2) |>
er_tte_add_curve() |>
er_tte_add_censor() |>
plot()
er_tte_add_risktable() adds a number-at-risk panel,
stacked below the curve via patchwork, with a shared x-axis:
lung |>
er_tte(time, status == 2) |>
er_tte_add_curve() |>
er_tte_add_censor() |>
er_tte_add_risktable() |>
plot()
Stratified curves
Passing stratify_by to er_tte() splits the
Kaplan-Meier estimate into one curve per level
(survival::survfit(... ~ strata)), and every layer added
afterwards follows suit. stratify_by must name a
discrete/categorical variable – lung$sex is coded
numerically (1/2), so we convert it to a factor first:
lung_sex <- lung |> transform(sex = factor(sex, labels = c("Male", "Female")))
lung_sex |>
er_tte(time, status == 2, stratify_by = sex) |>
er_tte_add_curve() |>
er_tte_add_censor() |>
er_tte_add_risktable() |>
plot()
A genuinely continuous covariate, like age, needs
binning into groups first – cut_quantile() gives full
control over bin count, tie-breaking, and labels:
lung |>
transform(age_grp = cut_quantile(age, n_bins = 3)) |>
er_tte(time, status == 2, stratify_by = age_grp) |>
er_tte_add_curve() |>
plot()
Summary annotation
er_tte_add_summary() adds a corner-placed text/label
annotation. Its default style,
er_style_tte_summary_logrank(), compares survival across
stratify_by’s levels (survival::survdiff());
on an unstratified object, or one with only 1 stratum level present in
the data, it simply draws nothing (a log-rank test needs at least two
groups to compare) rather than erroring:
lung_sex |>
er_tte(time, status == 2, stratify_by = sex) |>
er_tte_add_curve() |>
er_tte_add_summary() |>
plot()
Other builders don’t depend on stratify_by at all.
er_style_tte_summary_n() draws subject/event counts (one
line per stratum when stratified, a single overall line otherwise):
lung |>
er_tte(time, status == 2) |>
er_tte_add_curve() |>
er_tte_add_summary(style = "n") |>
plot()
er_style_tte_summary_coefficients()/er_style_tte_summary_gof()
draw from a fitted model’s own er_summary() result instead,
passed via er_tte_add_summary()’s model
argument – independent of whatever model, if any, was passed to
er_tte_add_model(). See Implementing the model interface for
what a model needs to implement to support this.
Overlaying a parametric model
er_tte_add_model() overlays a fitted parametric
curve (with an uncertainty band) on top of the Kaplan-Meier estimate,
for any model implementing er_predict_survival() (see Implementing the model interface). This
article uses ertte, the package that implements the full
model interface for time-to-event models, fitting an accelerated failure
time model:
mod <- ertte_aft(Surv(time, status == 2) ~ sex, lung_sex)
lung_sex |>
er_tte(time, status == 2, stratify_by = sex) |>
er_tte_add_curve() |>
er_tte_add_model(mod) |>
plot()
model may reference covariates beyond the strata
variable; erplots fills any other covariate from the plot data with a
reference value (first factor level or numeric mean), the same approach
er_plot_add_model() uses. A Cox model works the same way,
and doesn’t require stratify_by to be set at all:
mod_cox <- ertte_coxph(Surv(time, status == 2) ~ age, lung)
lung |>
er_tte(time, status == 2) |>
er_tte_add_curve() |>
er_tte_add_model(mod_cox) |>
plot()
Since age isn’t a stratification variable here,
er_tte_add_model() predicts S(t) at
age’s mean value from the plot data – a single overlay
curve, rather than one per stratum.
Theming
er_tte_theme() adjusts labels, titles, axis limits, and
the overall ggplot2 theme, without changing which variable drives which
aesthetic. As with
er_plot_theme()/er_vpc_theme(), every argument
defaults to NULL (“leave unchanged”), so repeated calls
only touch the fields they supply:
lung_sex |>
er_tte(time, status == 2, stratify_by = sex) |>
er_tte_add_curve() |>
er_tte_add_summary() |>
er_tte_theme(
xlab = "Days",
ylab = "Overall survival",
strata_lab = "Sex",
title = "Kaplan-Meier estimate of overall survival",
theme_base = ggplot2::theme_minimal()
) |>
plot()
subtitle/caption add further plot-level
text, and ylim overrides the survival-probability axis’s
displayed range – purely cosmetic, since a survival probability is
always modelled on [0, 1] regardless of what’s shown:
lung_sex |>
er_tte(time, status == 2, stratify_by = sex) |>
er_tte_add_curve() |>
er_tte_theme(
subtitle = "Lung cancer cohort",
caption = "Source: survival::lung",
ylim = c(0, 1.05)
) |>
plot()
One theming argument is structural rather than purely cosmetic,
unlike ylim above: xlim overwrites the time
axis’s limits directly, and those limits are what
er_tte_add_model()’s default time_grid and
er_tte_add_risktable()’s default times are
computed from at add-layer time. So
er_tte_theme(xlim = ...) needs to be called before those
layers to affect their defaults – calling it afterwards only changes the
curve panel’s own displayed range:
lung |>
er_tte(time, status == 2) |>
er_tte_theme(xlim = c(0, 600)) |>
er_tte_add_curve() |>
er_tte_add_risktable() |>
plot()
#> Warning: Removed 24 rows containing missing values or values outside the scale range
#> (`geom_step()`).
format_p controls how the log-rank annotation’s p-value
is displayed:
lung_sex |>
er_tte(time, status == 2, stratify_by = sex) |>
er_tte_add_curve() |>
er_tte_add_summary() |>
er_tte_theme(format_p = scales::label_pvalue(accuracy = 0.0001, add_p = TRUE)) |>
plot()
draw_key swaps the legend glyph the curve/model layers
use, e.g. a point instead of the default filled rectangle:
lung_sex |>
er_tte(time, status == 2, stratify_by = sex) |>
er_tte_add_curve() |>
er_tte_theme(draw_key = ggplot2::draw_key_point) |>
plot()
height_curve/height_risktable set the
relative heights patchwork gives to the two stacked panels once
er_tte_add_risktable() is in play – supplying only one
leaves the other unchanged:
lung |>
er_tte(time, status == 2) |>
er_tte_add_curve() |>
er_tte_add_risktable() |>
er_tte_theme(height_curve = 3, height_risktable = 2) |>
plot()
format_percent formats a value as a percentage; pass
show_percent = TRUE to
er_style_tte_risktable_text() to display each break’s
number at risk as a percentage of its stratum’s own baseline (time-zero)
size, alongside the bare count:
lung |>
er_tte(time, status == 2) |>
er_tte_add_curve() |>
er_tte_add_risktable(style = "text", show_percent = TRUE) |>
plot()
For anything er_tte_theme() doesn’t cover,
+ ggplot2::theme(...)/+ ggplot2::labs(...)
remains the general-purpose escape hatch on the ggplot2 object
plot() returns – though note that once
er_tte_add_risktable() is in play, the result is a
patchwork composition of two panels rather than a single ggplot2 object,
so such additions only apply to the top-level object, not automatically
to both panels.