Folioevanzhou.org
← 返回 Tessera
R2 张图2026-04-13

survival_curve

A step curve per group that drops only when an event is observed, with censoring quietly shrinking the risk set.

示例数据
lung_survival
配色
heat_light
语言
R
成图预览另有 1 张在配方里
survival_curve

Introduction

生存曲线处理的是**"事件发生了没有"和"什么时候发生"合在一起**的数据,而且必然带删失——有人到研究结束还没发生事件,你只知道他"至少活到了这一天"。

普通的均值比较处理不了这种数据:把删失的人当成"没事件"会低估风险,直接扔掉又引入偏倚。Kaplan-Meier 曲线是专门为此设计的:只有观察到事件时才下降一级,删失只是把这个人从风险集里移走,不让曲线掉。

读的时候按这个顺序:

  1. 起点是 1。 时间 0 时所有人都还没发生事件。
  2. 每级台阶是一次事件。 曲线是阶梯状的,不是平滑的——它只在观察到事件的那一刻下降。
  3. 看两条线分不分得开。 分得越开,两组的生存经验差别越大。
  4. 看置信区间。 后期变宽是正常的:风险集里人少了。
  5. 看风险表。 这一行最容易被跳过,却最要紧——某个时间点之后只剩几个人,那之后的曲线就别当真了。
  6. 最后看 p 值。 log-rank 概括的是整个随访期的差异,不说明差多少。

Example Data

生存曲线要的输入是:随访时间 + 事件状态(0/1)+ 分组变量,一行一个人。时间和状态必须成对,缺一个这个人就进不了模型;分组变量决定画几条曲线。

lung_survival 是 228 例肺癌的随访数据。它由 survival::lung 改写而来,事件列已经固化成明确的 0/1——原始的 status 用的是 1 = 删失、2 = 死亡,而绝大多数函数期待的是 0/1,编码错了曲线照样画得出来,只是上下颠倒。

library(survival)
library(ggsurvfit)
library(dplyr)
library(biopalette)

# 公开地址,和数据集页上「下载 CSV」给的是同一个 —— 不写仓库相对路径:
# 那个目录不进仓库,读者 clone 下来也没有这个文件,这段代码就跑不了
data_km <- read.csv("https://assets.evanzhou.org/tessera/csv/lung_survival.csv") |>
  mutate(
    event = as.integer(event),
    # 显式写 levels:图例和配色都按因子水平的顺序走
    sex = factor(sex, levels = c("male", "female"))
  ) |>
  # 三列缺一不可:时间和状态是一对,分组缺了这个人也没法归入任何一条曲线
  filter(!is.na(time_days), !is.na(event), !is.na(sex))

# 换自己的数据时保留这一行。事件编码错了不会报错、不会有任何症状,
# 只会得到一张上下颠倒的曲线
stopifnot(all(data_km$event %in% c(0L, 1L)))

# survfit2() 来自 ggsurvfit,和基础的 survfit() 算的是同一件事,
# 但它把调用记在对象里,后面的图层才能自己找到数据
fit_km <- survfit2(Surv(time_days, event) ~ sex, data = data_km)

Palettes

两组用 heat_light 的蓝红配色。两个色相直接区分两组,曲线、置信区间和图例始终使用同一映射。

置信区间沿用曲线本色再降透明度,不额外引入一套浅色——两条带子叠在一起时,多一种颜色就多一层要辨认的东西。

曲线图里曲线比填充重。 置信区间是背景信息,深浅必须和曲线拉开,否则重叠处谁也看不清。

# heat_light 正好两色,按因子水平的顺序绑上去 —— 用名字而不是位置,
# 后面 scale_*_manual() 的 breaks 才不会和图例次序错开
pal_sex <- setNames(
  rev(get_palette("heat_light", type = "qualitative")),
  levels(data_km$sex)
)

Recipe

No. Method Input Data Palettes
1 ggsurvfit fit_km pal_sex
2 survminer data_km pal_sex

1 · ggsurvfit

一张完整的 KM 图有五件东西:曲线、置信区间、风险表、中位线、检验 p 值。

ggsurvfit() 返回的是普通 ggplot 对象,所以这五件里除了曲线本身,其余都是标准图层,能和同一篇里的其它图共用主题。

#| fig: km
#| fig-width: 8
#| fig-height: 7
# log-rank:rho = 0 是标准 log-rank,各时间点等权;rho = 1 是 Peto 权重,
# 更看重早期事件。选哪个是分析决定,不是画图参数
sd_logrank <- survdiff(Surv(time_days, event) ~ sex, data = data_km, rho = 0)
p_logrank <- pchisq(sd_logrank$chisq, df = length(sd_logrank$n) - 1,
                    lower.tail = FALSE)

ggsurvfit(fit_km) +
  labs(
    title = "Overall Survival by Sex",
    subtitle = "Tessera Toy: lung_survival - Kaplan-Meier with 95% CI",
    x = "Time (days)", y = "Survival probability"
  ) +
  scale_color_manual(values = pal_sex, breaks = names(pal_sex),
                     labels = c("Male", "Female")) +
  # 置信区间沿用曲线颜色,只降低存在感;不再引入 recipe 外的颜色
  scale_fill_manual(values = pal_sex, guide = "none") +
  add_confidence_interval(type = "ribbon", alpha = 0.14) +
  add_risktable() +      # 风险表 —— 判断尾部可不可信全靠它
  add_quantile(y_value = 0.5, color = "gray50", linewidth = 0.75) +  # 中位生存参考线
  scale_ggsurvfit() +    # 把 y 轴调成 0–1 的百分比刻度
  ggplot2::theme_classic() +
  theme(
    legend.title = element_blank(),
    legend.position = "bottom",
    plot.title = element_text(size = 16, face = "bold")
  ) +
  annotate(
    "text", x = 500, y = 0.8,
    label = sprintf("log-rank p = %.3f", p_logrank),
    hjust = 0, size = 4.2
  )
survival_curve — km
survival_curve-km

2 · survminer

同一批数据的另一个视角:不看"还没发生事件的概率",看"风险累积了多少"。两条累计风险曲线分开,对应的就是 KM 曲线分开——是同一件事的两种画法。

ggsurvplot() 返回的不是 ggplot 对象,而是一个复合对象:主图在 $plot 里,风险表在 $table 里。所以它不能直接 ggsave();存 p$plot 会把风险表丢掉,要连风险表一起存得走 arrange_ggsurvplots()。recipe 1 没有这个问题。

#| fig: cumhaz
#| fig-width: 8
#| fig-height: 6
library(survminer)

# survminer 不认 survfit2 对象,这里用基础的 survfit() 重新拟合一次
fit_km0 <- survfit(Surv(time_days, event) ~ sex, data = data_km)

ggsurvplot(
  fit_km0,
  data = data_km,
  fun  = "cumhaz",          # 关键参数:换成 "event" 是累计发生率,默认是生存概率
  conf.int = TRUE,
  conf.int.style = "ribbon",
  conf.int.alpha = 0.20,
  palette = unname(pal_sex),  # 这里只收色值向量,名字得去掉
  legend = "bottom",
  legend.title = "",
  legend.labs = c("Male", "Female"),
  xlab = "Time (days)", ylab = "Cumulative hazard",
  title = "Cumulative Hazard by Sex",
  ggtheme = ggplot2::theme_classic(base_size = 12),
  break.time.by = 200       # x 轴每 200 天一个刻度
)
survival_curve — cumhaz
survival_curve-cumhaz

Constraints

  • 平着走的一段不等于没人退出。 删失只把人从风险集里移走,不让曲线下降;那一段里可能一直有人退出,只是没发生事件。
  • 尾部不可信。 风险集只剩个位数时,一次事件就能让曲线掉一大截。风险表就是为了让人知道从哪儿开始不该再读。
  • 中位生存可能估不出来。 曲线没降到 0.5 就没有中位数,报告时要写"未达到",不是留空。
  • log-rank 假设风险比恒定,而且它不是效应量。 两条曲线交叉时它的检出力很差;要说"差多少"得给风险比和置信区间。
  • 别在图上量某个时间点的生存率。 曲线是阶梯状的、还压着置信带,肉眼取值误差很大。直接算:summary(fit_km, times = c(365.25, 730.5)),标准误和置信区间一并给出。

两个 recipe 怎么比

  1. 正式图 → recipe 1。返回真正的 ggplot 对象,风险表、中位线、自定义标注都是标准图层,字体和主题能和同一篇里的其它图对齐。
  2. 想看累计风险这个视角,或者要快速出一批标准样式的图 → recipe 2。代价是返回复合对象,再想改就得去动它内部的 $plot。
运行环境6 个包 · 2026-09-02T12:54:26.544+0800

R version 4.5.1 (2025-06-13 ucrt) · x86_64-w64-mingw32

  • biopalette 0.2.2
  • dplyr 1.2.1
  • ggplot2 4.0.3
  • ggsurvfit 1.2.0
  • survival 3.8.3
  • survminer 0.5.1