跳到正文
百沐一下百沐一下
本节目录

生存分析:KM 曲线与 log-rank 检验

跟完这篇,您能把一份带随访时间和结局的数据,做成一张 Kaplan-Meier 生存曲线,用 log-rank 检验比较分组,读出中位生存与 1、3、5 年生存率,并写成一句站得住的结论。

全程只用 R 的 survivalsurvminer 两个包,五步走:

  1. 整理出 timeevent、分组三列的数据表;
  2. 画 Kaplan-Meier 曲线,配上风险人数表;
  3. 读出每组中位生存与 X 年生存率;
  4. 用 log-rank 检验判断组间差异是否显著;
  5. 写成结论,并交代方法边界。

先把两个包装好、加载好:

install.packages(c("survival", "survminer"))   # 首次使用先安装
library(survival)
library(survminer)

第一步:准备生存数据表

生存分析回答的是「多久之后发生某件事」——发生的可能是死亡、复发、进展,也可以是任何有明确起点和终点的事件。它和普通比较不一样的地方在于:很多样本到随访结束都还没发生事件,这类样本叫「删失(censored)」,既不能当作「没事」丢掉,也不能当作「已发生」。

所以一张最小可用的生存表,核心就是两列,再加上您要比较的分组列:

列名含义取值
time从起点到事件、或到最后一次随访的时长正数(月、天、年,全表统一一种单位)
event随访结束时事件到底发生了没有1 = 发生(如死亡),0 = 删失(失访或随访截止仍存活)
group您要比较的分组high / lowtreat / 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,有三点必须心里有数:

  1. 它检验的是「两组生存分布整体有没有差异」,原假设是「没差异」。p 小,说明差异不像随机造成的。p<0.05(多组、多基因反复比时用校正后的 padj<0.05)是常用的显著性惯例,按场景定,不是铁律。

  2. 它只给 p 值,不给效应量。 log-rank 告诉您「有没有差」,不告诉您「差多少」。想要风险比(HR)和置信区间,用 Cox 回归起步:

    coxph(Surv(time, event) ~ group, data = df)   # 单变量 Cox,先拿到 HR
    

    多因素校正见文末的 Cox 回归

  3. 它在两组风险大致成比例(曲线不交叉)时最灵敏。 若两条曲线中途交叉,或差异只集中在早期 / 晚期,标准 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」。

写出一句站得住的结论

图和检验都跑完,落到笔头。一段规范的生存结果,通常把这几件事说全:

  1. 样本量与事件数:不只报 n,还要报有多少人真正发生了事件(events)。事件太少(如个位数),曲线尾部和 p 值都很不稳,结论要留余地。
  2. 每组中位生存:从 fit 直接读,未达中位就写「未达到」。
  3. 关键时点生存率(如 3 年、5 年):按报告惯例列出,配上置信区间。
  4. 组间差异与 p 值:报 log-rank p,并说清方向(哪组更差)。

一句话范式,可直接改用:

高表达组的中位总生存为 28.3 个月,低表达组为 46.5 个月,3 年生存率分别为 41% 与 58%;log-rank 检验显示两组差异有统计学意义(p = 0.003)。

写的时候,也把方法的边界一并交代清楚:

  • KM + log-rank 是单变量、未校正的分析,没排除年龄、分期等混杂。要调整协变量、要 HR,请转 Cox 回归
  • 它默认删失是非信息性的(退出观察的原因与预后无关)。若失访本身和病情相关,结论会有偏。
  • 有其他事件会「抢走」您关心的结局时(如研究死于本病,却有人先死于他因),标准 KM 会高估累积发生率,这类问题需要竞争风险方法,本篇不覆盖。

在平台上跑一遍

以上每一步在百沐一下都不用写代码:上传带 timeevent 和分组列的数据表,用一句话说清目的——「对这份生存数据做 Kaplan-Meier,按 gene 表达高低分两组,画生存曲线并做 log-rank 检验」——平台会自动选方法、出图,并给出中位生存、X 年生存率、p 值和可复现的 R 代码。上手见 智能统计使用指南,或直接开始:https://app.baimuyixia.com

相关