Prof. Marc Los Huertos • Pomona College
Why Survival Analysis? Core Concepts Data Setup Raw Curves Kaplan–Meier Log-Rank Test Cox Model Diagnostics Reporting
What this module covers. Survival analysis models when something happens, not just whether it happens. This tutorial introduces Kaplan–Meier curves, the log-rank test, and Cox proportional hazards regression — applied throughout to a 72-hour algae growth experiment under pollution stress. You will also learn to handle right-censored observations correctly, a problem that ANOVA and t-tests cannot address.
Most introductory statistics courses focus on comparing group means (t-tests, ANOVA) or modeling continuous outcomes (regression). But many environmental and biological questions are fundamentally about time: How long before a population collapses? When does a contaminant first exceed a threshold? How quickly do algae cultures stop growing under different pollution treatments?
These are time-to-event questions, and they require a specialized toolkit for one critical reason: censoring. An observation is censored when the event of interest has not yet occurred by the time the study ends. If an algae culture is still growing at hour 72 when the experiment stops, you do not know when — or whether — it would have eventually declined. Discarding those observations (as a t-test would force you to do) or treating them as “no event” introduces systematic bias.
Survival analysis handles censoring correctly by treating each culture’s data as informative up to its last measured time point, whether or not the event was observed.
In the algae experiment that motivates this tutorial, three treatment groups (Control, Low Pollution, High Pollution) were monitored over 72 hours. The “event” can be defined in two ways:
End-of-experiment censoring — every culture is censored at hour 72, because the experiment ended before all cultures could show a clear biological endpoint. The Kaplan–Meier curves in this design show relative patterns of continued growth.
Threshold crossing — define a biologically meaningful absorbance level (e.g., OD ≥ 0.6). Cultures that reach it are “events”; those that do not are censored at their last measurement. This approach requires a defensible threshold.
Both designs are valid. This tutorial implements the threshold approach, which produces the most interpretable Kaplan–Meier curves and Cox model.
📌 The six reasons to use survival analysis here
The survival function S(t) gives the probability that the event has not yet occurred by time t:
\[S(t) = P(T > t)\]
where T is the random variable representing time-to-event. S(0) = 1 (no events at the start) and S(t) is non-increasing over time.
An observation is right-censored at time c if the event had not occurred by the time the study ended or the subject left observation. The censored observation tells us only that T > c — the event would have happened eventually, but we did not observe it.
In R’s Surv() function, status = 1 indicates an observed
event; status = 0 indicates censoring.
The hazard h(t) is the instantaneous rate at which events occur among those who have not yet had the event. A Cox proportional hazards model estimates how covariates (e.g., treatment group) scale the baseline hazard:
\[h(t \mid x) = h_0(t) \cdot \exp(\beta_1 x_1 + \beta_2 x_2 + \ldots)\]
The hazard ratio HR = exp(β) compares event rates between groups. HR > 1 means the treatment group reaches the threshold faster; HR < 1 means it reaches it more slowly.
library(survival) # Surv(), survfit(), coxph(), cox.zph()
library(survminer) # ggsurvplot(), pairwise_survdiff()
library(dplyr) # data wrangling
library(tidyr)
library(broom) # tidy() for Cox output
library(ggplot2)
The data below simulate a 72-hour algae growth experiment with 10 biological replicates per treatment. Growth follows a logistic curve with treatment-specific rates and noise.
set.seed(42)
# Simulate one replicate
sim_culture <- function(sample_id, treatment, times = seq(0, 72, by = 8)) {
params <- list(
A = list(K = 1.2, r = 0.08, n = 0.06),
B = list(K = 1.0, r = 0.06, n = 0.09),
C = list(K = 0.7, r = 0.04, n = 0.12)
)[[treatment]]
abs_val <- params$K / (1 + exp(-params$r * (times - 36))) +
rnorm(length(times), 0, params$n)
abs_val <- pmax(abs_val, 0)
data.frame(sample = sample_id, treatment = treatment,
time = times, absorbance = abs_val)
}
treatments <- c("A", "B", "C")
df_raw <- do.call(rbind, lapply(treatments, function(trt) {
do.call(rbind, lapply(1:10, function(i) {
sim_culture(paste0(trt, "_", i), trt)
}))
}))
df_raw$treatment <- factor(df_raw$treatment, levels = c("A", "B", "C"))
Choose a biologically meaningful absorbance threshold. Here OD ≥ 0.6 represents the point at which growth is considered well established. For each replicate, find the first time that threshold is reached. Replicates that never reach it are right-censored at their final measurement time.
threshold <- 0.6
time_to_event <- df_raw |>
group_by(sample, treatment) |>
arrange(time) |>
summarise(
event_time = {
hit <- which(absorbance >= threshold)
if (length(hit) == 0) NA_real_ else time[min(hit)]
},
last_time = max(time),
.groups = "drop"
) |>
mutate(
status = if_else(is.na(event_time), 0L, 1L), # 1 = event, 0 = censored
time = if_else(is.na(event_time), last_time, event_time)
)
# Event count by treatment
time_to_event |> count(treatment, status)
## # A tibble: 4 × 3
## treatment status n
## <fct> <int> <int>
## 1 A 1 10
## 2 B 1 10
## 3 C 0 5
## 4 C 1 5
Always plot the raw data before fitting any model. A spaghetti plot (individual traces in the background, treatment mean in bold) shows whether the threshold makes biological sense and reveals between-replicate variation.
df_means <- df_raw |>
group_by(treatment, time) |>
summarise(mean_abs = mean(absorbance), .groups = "drop")
ggplot() +
geom_line(data = df_raw,
aes(time, absorbance, group = sample, color = treatment),
alpha = 0.2, show.legend = FALSE) +
geom_line(data = df_means,
aes(time, mean_abs, color = treatment),
linewidth = 1.2) +
geom_hline(yintercept = threshold, linetype = "dashed", color = "#c8832a") +
annotate("text", x = 68, y = threshold + 0.04,
label = paste0("threshold = ", threshold),
color = "#c8832a", size = 3.2, hjust = 1) +
scale_color_manual(values = c(A = "#4a7c5e", B = "#c8832a", C = "#8b3a2a")) +
labs(x = "Time (hours)", y = "Absorbance (OD)",
title = "Raw growth curves by treatment",
color = "Treatment") +
theme_minimal(base_size = 13) +
theme(legend.position = "top")
Individual growth curves (faint) and treatment means (bold) for 10 replicates per treatment over 72 hours. The dashed line marks the OD = 0.6 threshold that defines the survival event.
💡 What to look for Treatment A cultures should reach the threshold earliest on average; treatment C should reach it last or not at all within 72 hours. The spread of individual curves (grey traces) shows how much between-replicate variability exists. A wide spread means the treatment effect must overcome substantial biological noise to be detectable.
The Kaplan–Meier estimator is a non-parametric method that estimates S(t) directly from the data without assuming any underlying distribution. It updates at each observed event time using:
\[\hat{S}(t) = \prod_{t_i \leq t} \left(1 - \frac{d_i}{n_i}\right)\]
where \(d_i\) is the number of events and \(n_i\) is the number still at risk at time \(t_i\).
km_fit <- survfit(Surv(time, status) ~ treatment, data = time_to_event)
summary(km_fit)
## Call: survfit(formula = Surv(time, status) ~ treatment, data = time_to_event)
##
## treatment=A
## time n.risk n.event survival std.err lower 95% CI upper 95% CI
## 32 10 1 0.9 0.0949 0.7320 1.000
## 40 9 8 0.1 0.0949 0.0156 0.642
## 48 1 1 0.0 NaN NA NA
##
## treatment=B
## time n.risk n.event survival std.err lower 95% CI upper 95% CI
## 40 10 3 0.7 0.1449 0.4665 1.000
## 48 7 6 0.1 0.0949 0.0156 0.642
## 56 1 1 0.0 NaN NA NA
##
## treatment=C
## time n.risk n.event survival std.err lower 95% CI upper 95% CI
## 40 10 1 0.9 0.0949 0.732 1.000
## 48 9 1 0.8 0.1265 0.587 1.000
## 56 8 1 0.7 0.1449 0.467 1.000
## 64 7 1 0.6 0.1549 0.362 0.995
## 72 6 1 0.5 0.1581 0.269 0.929
ggsurvplot(
km_fit,
data = time_to_event,
pval = TRUE,
conf.int = TRUE,
risk.table = TRUE,
palette = c("#4a7c5e", "#c8832a", "#8b3a2a"),
ggtheme = theme_minimal(base_size = 12),
title = "Kaplan–Meier: time to reach absorbance threshold",
xlab = "Time (hours)",
ylab = "Probability of not yet reaching threshold",
legend.title = "Treatment",
risk.table.height = 0.25
)
Kaplan–Meier survival curves showing the probability that each culture has not yet reached OD = 0.6. Shading = 95% CI. The risk table below shows how many replicates remain uncensored at each time point.
Interpreting the curves. S(t) is the probability that a replicate has not yet reached OD 0.6 by time t. A curve that drops steeply early means the treatment reached the threshold quickly; a flat curve means most replicates were still below threshold (censored). Treatment A should show the steepest drop; treatment C the flattest, because pollution suppresses growth.
📌 Survival probability vs. event probability The y-axis shows the survival probability — the fraction still below threshold. To get the event probability (fraction that has crossed the threshold), compute 1 − S(t). Both framings are valid; choose the one that matches your biological question.
The log-rank test is the standard global test for differences between survival curves. It compares the observed and expected number of events in each group across all time points, weighting each time point equally. A significant result means at least one pair of curves differs; it does not identify which pair.
logrank <- survdiff(Surv(time, status) ~ treatment, data = time_to_event)
logrank
## Call:
## survdiff(formula = Surv(time, status) ~ treatment, data = time_to_event)
##
## N Observed Expected (O-E)^2/E (O-E)^2/V
## treatment=A 10 10 4.53 6.613 14.74
## treatment=B 10 10 7.99 0.507 1.29
## treatment=C 10 5 12.48 4.487 17.71
##
## Chisq= 23.1 on 2 degrees of freedom, p= 9e-06
# Extract p-value
p_val <- 1 - pchisq(logrank$chisq, df = length(logrank$n) - 1)
The omnibus log-rank test gives χ² = 23.14 on 2 df, p = 9.45^{-6}.
If the omnibus test is significant, follow up with pairwise comparisons. The Benjamini–Hochberg (BH) method controls the false discovery rate.
pairwise_res <- pairwise_survdiff(
Surv(time, status) ~ treatment,
data = time_to_event,
p.adjust.method = "BH"
)
pairwise_res
##
## Pairwise comparisons using Log-Rank test
##
## data: time_to_event and treatment
##
## A B
## B 0.00657 -
## C 0.00027 0.00148
##
## P value adjustment method: BH
⚠️ Multiple comparisons With three treatments there are three pairwise tests. Always apply a correction (BH, Bonferroni, or similar). Reporting raw p-values from multiple tests inflates the probability of false positives.
The Cox model provides hazard ratios — effect sizes that quantify how much faster (or slower) one treatment group reaches the threshold relative to a reference group — while accounting for the full time course and censored observations.
# Set treatment A as reference
time_to_event <- time_to_event |>
mutate(treatment = relevel(treatment, ref = "A"))
cox1 <- coxph(Surv(time, status) ~ treatment, data = time_to_event)
summary(cox1)
## Call:
## coxph(formula = Surv(time, status) ~ treatment, data = time_to_event)
##
## n= 30, number of events= 25
##
## coef exp(coef) se(coef) z Pr(>|z|)
## treatmentB -1.18744 0.30500 0.49007 -2.423 0.0154 *
## treatmentC -3.32806 0.03586 0.77402 -4.300 1.71e-05 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## exp(coef) exp(-coef) lower .95 upper .95
## treatmentB 0.30500 3.279 0.116721 0.7970
## treatmentC 0.03586 27.884 0.007867 0.1635
##
## Concordance= 0.844 (se = 0.04 )
## Likelihood ratio test= 25.23 on 2 df, p=3e-06
## Wald test = 18.81 on 2 df, p=8e-05
## Score (logrank) test = 25.76 on 2 df, p=3e-06
hr <- tidy(cox1, exponentiate = TRUE, conf.int = TRUE)
print(hr)
## # A tibble: 2 × 7
## term estimate std.error statistic p.value conf.low conf.high
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 treatmentB 0.305 0.490 -2.42 0.0154 0.117 0.797
## 2 treatmentC 0.0359 0.774 -4.30 0.0000171 0.00787 0.163
ggplot(hr, aes(x = term, y = estimate)) +
geom_point(size = 3, color = "#4a7c5e") +
geom_errorbar(aes(ymin = conf.low, ymax = conf.high),
width = 0.15, color = "#4a7c5e") +
geom_hline(yintercept = 1, linetype = "dashed", color = "#c8832a") +
coord_flip() +
labs(y = "Hazard Ratio (HR)", x = "",
title = "Cox model: HR and 95% CI (reference = Treatment A)") +
theme_minimal(base_size = 13)
Cox model hazard ratios (HR) with 95% confidence intervals. HR > 1 means the treatment reaches the threshold faster than treatment A; HR < 1 means slower.
Interpreting hazard ratios. An HR of 2 for treatment C means that at any point in time, a culture in treatment C is twice as likely to reach the threshold as one in treatment A, conditional on not yet having done so. HR < 1 would indicate slower progression.
The Cox model assumes that the hazard ratio between any two groups is constant over time (proportional hazards). Violations — where the treatment effect grows or shrinks over the observation window — invalidate the hazard ratio interpretation.
ph_test <- cox.zph(cox1)
ph_test
## chisq df p
## treatment 1.42 2 0.49
## GLOBAL 1.42 2 0.49
par(mfrow = c(1, ncol(ph_test$y)), mar = c(4, 4, 3, 1))
plot(ph_test)
Schoenfeld residuals against time. A flat line (no trend) supports the proportional hazards assumption. A significant p-value in the table indicates a possible violation.
par(mfrow = c(1, 1))
💡 What to do if PH is violated If
cox.zph() returns a significant p-value for a covariate,
consider: (1) stratifying the Cox model on that covariate with
strata(); (2) adding a time-interaction term
tt(treatment); or (3) fitting a parametric survival model
(Weibull, log-normal) with survreg(), which does not
require proportionality.
weibull_mod <- survreg(Surv(time, status) ~ treatment,
data = time_to_event, dist = "weibull")
summary(weibull_mod)
##
## Call:
## survreg(formula = Surv(time, status) ~ treatment, data = time_to_event,
## dist = "weibull")
## Value Std. Error z p
## (Intercept) 3.7195 0.0366 101.69 <2e-16
## treatmentB 0.1569 0.0511 3.07 0.0021
## treatmentC 0.5872 0.0638 9.20 <2e-16
## Log(scale) -2.1694 0.1596 -13.59 <2e-16
##
## Scale= 0.114
##
## Weibull distribution
## Loglik(model)= -86.1 Loglik(intercept only)= -109.4
## Chisq= 46.64 on 2 degrees of freedom, p= 7.5e-11
## Number of Newton-Raphson Iterations: 7
## n= 30
A complete survival analysis report should include the following elements, in this order:
sample,
treatment, time, and status
(event or censored) for all replicates.cox.zph() results and, if the assumption is satisfied, note
this explicitly (Section 8).knitr::kable(
time_to_event[order(time_to_event$treatment, time_to_event$time), ],
col.names = c("Sample", "Treatment", "Event time", "Last time", "Status", "Time used"),
caption = "Time-to-event data. Status: 1 = threshold reached, 0 = censored.",
digits = 1
)
| Sample | Treatment | Event time | Last time | Status | Time used |
|---|---|---|---|---|---|
| A_3 | A | 32 | 72 | 1 | 32 |
| A_1 | A | 40 | 72 | 1 | 40 |
| A_10 | A | 40 | 72 | 1 | 40 |
| A_2 | A | 40 | 72 | 1 | 40 |
| A_5 | A | 40 | 72 | 1 | 40 |
| A_6 | A | 40 | 72 | 1 | 40 |
| A_7 | A | 40 | 72 | 1 | 40 |
| A_8 | A | 40 | 72 | 1 | 40 |
| A_9 | A | 40 | 72 | 1 | 40 |
| A_4 | A | 48 | 72 | 1 | 48 |
| B_10 | B | 40 | 72 | 1 | 40 |
| B_5 | B | 40 | 72 | 1 | 40 |
| B_7 | B | 40 | 72 | 1 | 40 |
| B_1 | B | 48 | 72 | 1 | 48 |
| B_2 | B | 48 | 72 | 1 | 48 |
| B_3 | B | 48 | 72 | 1 | 48 |
| B_4 | B | 48 | 72 | 1 | 48 |
| B_6 | B | 48 | 72 | 1 | 48 |
| B_9 | B | 48 | 72 | 1 | 48 |
| B_8 | B | 56 | 72 | 1 | 56 |
| C_5 | C | 40 | 72 | 1 | 40 |
| C_9 | C | 48 | 72 | 1 | 48 |
| C_2 | C | 56 | 72 | 1 | 56 |
| C_8 | C | 64 | 72 | 1 | 64 |
| C_1 | C | NA | 72 | 0 | 72 |
| C_10 | C | NA | 72 | 0 | 72 |
| C_3 | C | 72 | 72 | 1 | 72 |
| C_4 | C | NA | 72 | 0 | 72 |
| C_6 | C | NA | 72 | 0 | 72 |
| C_7 | C | NA | 72 | 0 | 72 |
cox.zph() before interpreting HRs.
The survival package includes the lung
dataset. Fit a Kaplan–Meier curve of survival time by sex.
(a) Plot the two curves with confidence bands. (b) Run a log-rank test.
(c) At what time point do the curves diverge most strongly?
library(survival)
data(lung)
km <- survfit(Surv(time, status) ~ sex, data = lung)
plot(km, col = c("#1e3a2f","#c8832a"), lwd = 2,
xlab = "Days", ylab = "Survival probability",
main = "Lung cancer survival by sex")
legend("topright", legend = c("Male","Female"),
col = c("#1e3a2f","#c8832a"), lwd = 2)
survdiff(Surv(time, status) ~ sex, data = lung)