生存分析:KM 曲线与 log-rank 检验
跟完这篇,您能把一份带随访时间和结局的数据,做成一张 Kaplan-Meier 生存曲线,用 log-rank 检验比较分组,读出中位生存与 1、3、5 年生存率,并写成一句站得住的结论。
全程只用 R 的 survival 与 survminer 两个包,五步走:
- 整理出
time、event、分组三列的数据表; - 画 Kaplan-Meier 曲线,配上风险人数表;
- 读出每组中位生存与 X 年生存率;
- 用 log-rank 检验判断组间差异是否显著;
- 写成结论,并交代方法边界。
先把两个包装好、加载好:
install.packages(c("survival", "survminer")) # 首次使用先安装
library(survival)
library(survminer)
第一步:准备生存数据表
生存分析回答的是「多久之后发生某件事」——发生的可能是死亡、复发、进展,也可以是任何有明确起点和终点的事件。它和普通比较不一样的地方在于:很多样本到随访结束都还没发生事件,这类样本叫「删失(censored)」,既不能当作「没事」丢掉,也不能当作「已发生」。
所以一张最小可用的生存表,核心就是两列,再加上您要比较的分组列:
| 列名 | 含义 | 取值 |
|---|---|---|
time | 从起点到事件、或到最后一次随访的时长 | 正数(月、天、年,全表统一一种单位) |
event | 随访结束时事件到底发生了没有 | 1 = 发生(如死亡),0 = 删失(失访或随访截止仍存活) |
group | 您要比较的分组 | 如 high / low、treat / control |
长这样就对了:
head(df)
#> sample time event group
#> 1 S001 12.4 1 high
#> 2 S002 34.0 0 high # 随访 34 个月仍存活,删失
#> 3 S003 5.1 1 low
上传或建模前,先照这张清单自查一遍——下面每一条都对应一类常见的返工:
| 检查项 | 要求 | 常见错误 |
|---|---|---|
event 编码 | 1 = 事件发生,0 = 删失 | 编反,整条曲线颠倒 |
time 单位 | 全表统一(月 / 天 / 年选一种) | 月和天混用 |
| 起点 | 全表用同一起点(确诊日或手术日等) | 有的从确诊算、有的从入组算 |
| 缺失值 | time / event 缺失的行先处理 | 直接进模型报错,或被悄悄丢弃 |
第二步:画出 Kaplan-Meier 曲线
Kaplan-Meier(KM)是对「活到某个时间点的概率」的非参数估计。它不假设生存时间服从某个分布,而是每当有事件发生,就按当时「还在观察中的人数」把生存概率往下折一档;删失的样本在其删失时点之后不再计入分母,但不会让曲线下折。
画法很短:
fit <- survfit(Surv(time, event) ~ group, data = df)
ggsurvplot(
fit, data = df,
pval = TRUE, # 标注 log-rank p 值
conf.int = TRUE, # 阴影为置信区间
risk.table = TRUE, # 下方风险人数表
xlab = "时间(月)", ylab = "生存概率"
)
读这张图,先认几样东西:
- 纵轴:从 1 往下走,表示随时间推移仍未发生事件的比例;
- 台阶:每一次下折对应一个事件,台阶越密说明事件越集中;
- 小竖线(+):删失点,代表这里有人退出观察,不是事件;
- 风险人数表(risk table):曲线尾部往往只剩很少的人,此时一次下折就跳很大,看着「陡」其实很不稳。风险表让您一眼看出尾部还剩几个人,别被尾巴上的假差异骗了。
第三步:读出中位生存与 X 年生存率
图画完,接着从同一个 fit 对象里把数字读出来。直接打印,就有每组的中位生存和置信区间:
fit
#> records events median 0.95LCL 0.95UCL
#> high 120 58 28.3 22.1 36.0
#> low 118 31 46.5 38.2 NA # NA 表示随访期内未达中位
若某组过半的人到随访结束仍未发生事件,中位生存会是 NA(未达到)。如实写「未达到」,不要硬编一个数。
临床报告里常要「1 年 / 3 年 / 5 年生存率」,用 summary 指定时点即可(这里单位是月,60 即 5 年):
summary(fit, times = c(12, 36, 60)) # 1、3、5 年时点的生存率
#> time n.risk survival lower.95 upper.95 strata
#> 12 92 0.75 0.67 0.83 group=high
#> 36 34 0.41 0.32 0.52 group=high
#> 60 10 0.22 0.14 0.35 group=high
#> 12 101 0.84 0.77 0.91 group=low
#> 36 63 0.58 0.49 0.69 group=low
#> 60 22 0.40 0.30 0.53 group=low
survival 列就是该时点的生存率,lower.95 / upper.95 是它的置信区间。留意 n.risk:某时点在观察中的人数越少,那一档的生存率越不稳,报的时候心里要有数。
第四步:用 log-rank 检验比较分组
看两条曲线分开了还不够,得有个检验回答「这个差异是不是随机波动」。log-rank 检验就干这件事:把所有事件时点排开,在每个时点上按当时两组还在观察的人数,算出「如果两组生存没差别、各组本该发生多少事件」,再把「实际发生」和「理论期望」的差累加起来,得到一个卡方统计量和 p 值。
sd <- survdiff(Surv(time, event) ~ group, data = df)
sd
#> Chisq= 8.6 on 1 degrees of freedom, p= 0.003
三组及以上时,别只跑一个整体检验就完事,要两两比较,并做多重比较校正:
pairwise_survdiff(Surv(time, event) ~ group, data = df) # 默认 BH 法校正
关于 log-rank,有三点必须心里有数:
-
它检验的是「两组生存分布整体有没有差异」,原假设是「没差异」。p 小,说明差异不像随机造成的。
p<0.05(多组、多基因反复比时用校正后的padj<0.05)是常用的显著性惯例,按场景定,不是铁律。 -
它只给 p 值,不给效应量。 log-rank 告诉您「有没有差」,不告诉您「差多少」。想要风险比(HR)和置信区间,用 Cox 回归起步:
coxph(Surv(time, event) ~ group, data = df) # 单变量 Cox,先拿到 HR多因素校正见文末的 Cox 回归。
-
它在两组风险大致成比例(曲线不交叉)时最灵敏。 若两条曲线中途交叉,或差异只集中在早期 / 晚期,标准 log-rank 会低估差异。这类情况可考虑加权版本(对早期事件加权的 Gehan-Breslow、Peto 等),但要在看数据前就想清楚用哪种,不能挑一个 p 最小的来报。
第五步(可选):分组要自己切时,中位数还是最佳截断
如果分组不是天然的(如处理与对照),而是您要拿一个连续变量(如某基因表达量)切成两组,那「从哪切」就直接影响结论。常见三种切法:
| 切法 | 怎么定 | 优点 | 注意 |
|---|---|---|---|
| 中位数 | 表达量中位数一分为二 | 事先确定、可复现、不看结局 | 若真实阈值不在中位,可能不够灵敏 |
| 临床阈值 | 领域公认的界值 | 有依据、跨研究可比 | 需确有公认阈值 |
surv_cutpoint | 扫描候选切点,选生存差异最显著的 | 数据驱动、对分界敏感 | 偏向让 p 变小,易过拟合,须验证 |
按中位数切,一行搞定:
df$group <- ifelse(df$gene > median(df$gene), "high", "low")
用 surv_cutpoint 找「最佳截断」,它用最大选择秩统计量(maximally selected rank statistics)扫描候选切点,挑出让两组生存差异最显著的那个:
cut <- surv_cutpoint(df, time = "time", event = "event", variables = "gene")
summary(cut) # 给出选中的最佳截断值
df$group <- surv_categorize(cut)$gene # 按该截断分成 high / low
这里有一条必须讲清的红线:「最佳截断」是从您这份数据的结局里挑出来的,天然偏向让 p 变小。 它一定程度上在「用同一份数据既选切点、又检验切点」,会高估显著性,也难在别的数据集上重现。所以:
- 优先用事先指定的切点(中位数,或领域内公认的临床阈值),结论更稳、更好复现;
- 若用了数据驱动的最佳截断,务必如实报告是这么切的,最好在独立数据集上验证,或说明这是探索性结果;
- 别拿多个切点各跑一遍、只挑 p 最小的报——这就是「切点上的 p-hacking」。
写出一句站得住的结论
图和检验都跑完,落到笔头。一段规范的生存结果,通常把这几件事说全:
- 样本量与事件数:不只报 n,还要报有多少人真正发生了事件(events)。事件太少(如个位数),曲线尾部和 p 值都很不稳,结论要留余地。
- 每组中位生存:从
fit直接读,未达中位就写「未达到」。 - 关键时点生存率(如 3 年、5 年):按报告惯例列出,配上置信区间。
- 组间差异与 p 值:报 log-rank p,并说清方向(哪组更差)。
一句话范式,可直接改用:
高表达组的中位总生存为 28.3 个月,低表达组为 46.5 个月,3 年生存率分别为 41% 与 58%;log-rank 检验显示两组差异有统计学意义(p = 0.003)。
写的时候,也把方法的边界一并交代清楚:
- KM + log-rank 是单变量、未校正的分析,没排除年龄、分期等混杂。要调整协变量、要 HR,请转 Cox 回归。
- 它默认删失是非信息性的(退出观察的原因与预后无关)。若失访本身和病情相关,结论会有偏。
- 有其他事件会「抢走」您关心的结局时(如研究死于本病,却有人先死于他因),标准 KM 会高估累积发生率,这类问题需要竞争风险方法,本篇不覆盖。
在平台上跑一遍
以上每一步在百沐一下都不用写代码:上传带 time、event 和分组列的数据表,用一句话说清目的——「对这份生存数据做 Kaplan-Meier,按 gene 表达高低分两组,画生存曲线并做 log-rank 检验」——平台会自动选方法、出图,并给出中位生存、X 年生存率、p 值和可复现的 R 代码。上手见 智能统计使用指南,或直接开始:https://app.baimuyixia.com 。