21  Number needed to treat and survival difference

21.1 When to use it

A hazard ratio is a relative comparison. It does not tell a patient how much survival changes at five years, and it can hide when that change occurs. The absolute survival difference answers that first question directly: how many more patients out of 100 are alive in group 2 than group 1 at time \(t\). Number needed to treat (NNT) asks a different question by inverting the benefit: how many patients must receive group 2 rather than group 1 to prevent one event by that same time.

Direction is part of both estimands. In this chapter the difference is \(S_2(t)-S_1(t)\). A positive value favors group 2, and when the value is expressed in percentage points, \(NNT(t)=100/[S_2(t)-S_1(t)]\). If group 2 has lower survival, call that harm and reverse the contrast before reporting an NNT.

Reach for the survival difference when you want to show the size and timing of a benefit and whether its confidence band clears zero. Reach for NNT when you want the bedside number, the figure that makes a small absolute benefit feel concrete or an over-hyped one feel modest. Both constructors, from hvtiPlotR (Ehrlinger 2026), take a pre-computed data frame. Build and inspect the S3 object first; then plot() returns the ggplot you decorate.

21.2 The data it needs

Both functions expect one row per time point with an estimate column and explicit lower/upper CI columns that you name in the call. sample_nnt_data() generates two survival curves from a hazard-ratio contrast and computes NNT and ARR (absolute risk reduction) at each time point with CI bounds. The groups argument names the arms and sets their hazard multipliers; here the internal thoracic artery graft (ITA) carries 0.75 times the hazard of the saphenous vein graft (SVG) reference.

nnt_dat <- sample_nnt_data(
  n        = 500,
  time_max = 20,
  groups   = c("SVG" = 1.0, "ITA" = 0.75)
)
head(nnt_dat)
        time         arr   arr_lower  arr_upper      nnt nnt_lower nnt_upper
1 0.01000000 0.001548865 -0.03849194 0.04158967       NA        NA        NA
2 0.05006012 0.017341632 -0.13725732 0.17194059       NA  581.5963        NA
3 0.09012024 0.041863418 -0.22369142 0.30741825       NA  325.2897        NA
4 0.13018036 0.072628007 -0.30691800 0.45217401       NA  221.1538        NA
5 0.17024048 0.108520503 -0.38909707 0.60613807 921.4849  164.9789        NA
6 0.21030060 0.148855060 -0.47112585 0.76883597 671.7944  130.0668        NA
nnt <- hv_nnt(
  nnt_dat,
  lower_col = "nnt_lower",
  upper_col = "nnt_upper"
)
nnt$meta
$x_col
[1] "time"

$estimate_col
[1] "nnt"

$lower_col
[1] "nnt_lower"

$upper_col
[1] "nnt_upper"

$group_col
NULL

$has_ci
[1] TRUE

$n_obs
[1] 496

$na_rm
[1] TRUE
target_times <- c(5, 10, 20)
report_rows <- vapply(
  target_times,
  function(x) which.min(abs(nnt_dat$time - x)),
  integer(1)
)
nnt_report <- cbind(
  target_time = target_times,
  grid_time   = nnt_dat$time[report_rows],
  nnt_dat[report_rows, c(
    "arr", "arr_lower", "arr_upper", "nnt", "nnt_lower", "nnt_upper"
  )]
)
rownames(nnt_report) <- NULL
round(nnt_report, 3)
  target_time grid_time    arr arr_lower arr_upper    nnt nnt_lower nnt_upper
1           5     5.018 11.572     4.412    18.731  8.642     5.339    22.666
2          10     9.985 15.628     5.452    25.804  6.399     3.875    18.341
3          20    20.000  5.753    -3.883    15.389 17.382     6.498        NA

21.3 Build it

The NNT object stores the pre-computed estimate and its CI columns. The default estimate_col is "nnt", so we named only the bounds above. The helper uses a 500-point time grid, which does not land exactly on 5 or 10 years. The report code therefore shows each requested target_time beside the actual nearest grid_time; it does not relabel a nearby observation as an exact horizon. The plotted contrast is ITA minus SVG survival: a positive benefit and finite NNT favor ITA.

plot(nnt) +
  scale_colour_manual(values = c("steelblue"), guide = "none") +
  scale_fill_manual(values = c("steelblue"), guide = "none") +
  scale_x_continuous(breaks = seq(0, 20, 5)) +
  scale_y_continuous(breaks = seq(0, 50, 10)) +
  coord_cartesian(xlim = c(0, 20), ylim = c(0, 50)) +
  labs(x = "Years", y = "Number Needed to Treat") +
  theme_hv_manuscript()
Figure 21.1: Number needed to treat with ITA rather than SVG; NNT falls as the benefit grows to about 10 years, then rises as that benefit narrows

21.4 Read it

This simulated NNT curve falls while the ITA survival benefit widens, reaches about 6 near 10 years, then rises to about 17 at 20 years as that benefit narrows. The report table above shows the requested horizon and the nearby grid time used for each reading. Look for:

  • The direction of change. Early in follow-up the two arms have barely diverged, so you must treat a great many patients to prevent one event and the NNT is large. NNT drops while the survival gap widens and rises if that gap later narrows. Read the value at the time horizon that matters for your decision, not at the visual minimum or endpoint by default.
  • The confidence band. A wide band, especially early when NNT is large and unstable, means the count is poorly determined. When the survival-difference interval includes zero, a finite two-sided NNT interval does not exist; the ribbon may therefore be one-sided or absent at those times.
  • The y-axis scale. NNT is a ratio and blows up when the absolute benefit is near zero. A capped axis (here 0 to 50) keeps the figure readable, but remember the curve is heading to infinity at time zero, not flattening.

21.5 Variations

21.5.1 Absolute risk reduction

The same data can show absolute risk reduction (ARR) in percentage points by setting estimate_col = "arr" and naming the matching CI columns. Here ARR is the ITA minus SVG survival difference, which is also the reduction in event risk. NNT equals 100 divided by ARR, not the raw reciprocal of the plotted percentage-point value.

arr <- hv_nnt(
  nnt_dat,
  estimate_col = "arr",
  lower_col    = "arr_lower",
  upper_col    = "arr_upper"
)

plot(arr) +
  scale_colour_manual(values = c("firebrick"), guide = "none") +
  scale_fill_manual(values = c("firebrick"), guide = "none") +
  geom_hline(yintercept = 0, linetype = "dashed", colour = "grey50") +
  scale_x_continuous(breaks = seq(0, 20, 5)) +
  scale_y_continuous(breaks = seq(-10, 30, 10)) +
  coord_cartesian(xlim = c(0, 20), ylim = c(-10, 30)) +
  labs(x = "Years", y = "Absolute risk reduction (percentage points)") +
  theme_hv_manuscript()
Figure 21.2: Absolute event-risk reduction for ITA versus SVG in percentage points; positive values favor ITA and the zero line marks no difference

21.5.2 Survival difference

hv_survival_difference() plots the difference S_2(t) - S_1(t) between two groups over time with an optional confidence band. sample_survival_difference_data() generates the point-wise difference and an approximate normal interval from a hazard-ratio contrast. It recovers a standard error from each component survival band, combines the two standard errors as \(\sqrt{SE_1^2 + SE_2^2}\), then applies the requested normal quantile. Both arms are simulated at the same n to keep the band width comparable.

diff_dat <- sample_survival_difference_data(
  n      = 500,
  groups = c("Control" = 1.0, "Treatment" = 0.70)
)
head(diff_dat)
        time  difference  diff_lower diff_upper group1_surv group2_surv
1 0.01000000 0.001831068 -0.03739885 0.04106099    99.99558    99.99741
2 0.03002004 0.009522643 -0.08737593 0.10642122    99.97702    99.98654
3 0.05004008 0.020489267 -0.13072627 0.17170481    99.95054    99.97103
4 0.07006012 0.033934691 -0.17121839 0.23908777    99.91808    99.95201
5 0.09008016 0.049459769 -0.21006489 0.30898443    99.88059    99.93005
6 0.11010020 0.066810699 -0.24784775 0.38146915    99.83868    99.90549
surv_diff <- hv_survival_difference(
  diff_dat,
  lower_col = "diff_lower",
  upper_col = "diff_upper"
)
surv_diff$meta
$x_col
[1] "time"

$estimate_col
[1] "difference"

$lower_col
[1] "diff_lower"

$upper_col
[1] "diff_upper"

$group_col
NULL

$has_ci
[1] TRUE

$n_obs
[1] 500

The dashed line at zero is the no-difference baseline. Because Control is group 1 and Treatment is group 2, a positive value means higher Treatment survival; a negative value would favor Control. No grouping argument is needed for this two-group case. Read the figure two ways: the height is the percentage-point difference at each time, and the lower edge of the 95% band shows whether the data remain compatible with no difference.

plot(surv_diff) +
  scale_colour_manual(values = c("steelblue"), guide = "none") +
  scale_fill_manual(values = c("steelblue"), guide = "none") +
  geom_hline(yintercept = 0, linetype = "dashed", colour = "grey50") +
  scale_x_continuous(breaks = 0:10) +
  scale_y_continuous(breaks = seq(-5, 35, 5)) +
  coord_cartesian(xlim = c(0, 10), ylim = c(-5, 35)) +
  labs(x = "Years", y = "Survival difference (percentage points)") +
  theme_hv_manuscript()
Figure 21.3: Treatment minus Control survival difference in percentage points with its 95% confidence band; positive values favor Treatment

21.5.3 Multiple comparisons

Combine several two-group differences into one long data frame and use group_col to overlay them. This is how we put one reference arm against several alternatives in a single panel: each line is one contrast, and the ones that sit highest and clear zero earliest are the comparisons with the strongest benefit.

d1 <- sample_survival_difference_data(
  groups = c("Medical Mgmt" = 1.0, "TF-TAVR" = 0.70), seed = 1L
)
d1$comparison <- "TF-TAVR vs Medical Mgmt"

d2 <- sample_survival_difference_data(
  groups = c("TA-TAVR" = 0.90, "TF-TAVR" = 0.70), seed = 2L
)
d2$comparison <- "TF-TAVR vs TA-TAVR"

d3 <- sample_survival_difference_data(
  groups = c("AVR" = 0.80, "TF-TAVR" = 0.70), seed = 3L
)
d3$comparison <- "TF-TAVR vs AVR"

surv_diff_multi <- hv_survival_difference(
  rbind(d1, d2, d3),
  group_col = "comparison"
)

plot(surv_diff_multi) +
  scale_colour_brewer(palette = "Set1", name = NULL) +
  scale_fill_brewer(palette = "Set1", guide = "none") +
  geom_hline(yintercept = 0, linetype = "dashed", colour = "grey50") +
  scale_x_continuous(limits = c(0, 10), breaks = 0:10) +
  labs(x = "Years", y = "Survival difference (percentage points)") +
  theme_hv_manuscript() +
  theme(legend.position = "top")
Figure 21.4: Several two-group survival differences overlaid in one panel, one line per contrast against a shared reference arm

21.6 Pitfalls

  • Quoting NNT at time zero. NNT is a reciprocal, so it explodes near the start of follow-up where the arms have not yet diverged. Always report it at a named horizon, never as a single headline number with no time attached.
  • Mismatched CI columns. NNT and ARR each have their own pair of bounds. Plot ARR with the NNT bounds and the band will be wrong. Name the CI columns that go with the estimate you are showing.
  • Reading the survival difference where the band straddles zero. A positive point estimate whose confidence band still includes zero is compatible with no difference. Keep the estimate and interval together.
  • Leaving the contrast unnamed. The sign means nothing until the reader knows which survival curve was subtracted from which. Put “group 2 minus group 1” in the text or caption, and reverse the labels if you want positive values to mean benefit.
  • Forgetting the zero line. The dashed reference at zero is what makes the survival-difference figure readable. Add the geom_hline() every time; without it the eye has no anchor for “no benefit”.