这次我们来看生存分析的三个进阶方法。入门生存分析时,很多人只会KM曲线加Cox回归,遇到非比例风险、竞争事件、复杂随访数据就不知道怎么处理。这篇文章不讲基础概念,直接给三板斧:非比例风险处理、竞争风险模型、受限平均生存时间RMST。每一板斧都会给适用场景、R代码、结果解读方法,并用一份模拟数据串起完整流程。如果你是医学统计、临床科研、用户流失分析或者工程可靠性分析方向,建议直接收藏。
1. 三板斧核心思路速览
| 方法 | 解决什么问题 | 适用场景 | 主流实现 |
|---|---|---|---|
| 第一板斧:非比例风险处理 | Cox回归的PH假设被违反,HR不能代表全程效应 | 两组生存曲线交叉、治疗效果随时间减弱或增强、干预长期效果不明 | R的survival包、Python的lifelines |
| 第二板斧:竞争风险模型 | 存在死亡、复发、其他事件互相竞争,普通KM和Cox会高估事件风险 | 肿瘤研究、慢性病随访、多终点队列 | R的cmprsk包、survival包、Python的scikit-survival |
| 第三板斧:RMST | 非比例风险下,HR不稳定时,用平均生存时间作为替代指标 | 临床试验次要终点、非劣效设计、政策评估 | R的survRM2包、survival包 |
为什么基础KM曲线+Cox回归不够?因为KM曲线只能描述单事件、单终点,且存在删失时只做分层比较。Cox回归输出的是风险比HR,它默认一个关键前提:不同组别的风险比值在整个随访期内保持不变,也就是比例风险假设PH假设。实际数据里这个假设经常不成立,比如早期手术效果好、后期疗效趋同,或者随访后期出现了其他死亡原因。这时候三板斧就有用了。
2. 适用场景与使用边界
这三板斧主要适合以下场景:
- 临床试验和队列研究:比较治疗组与对照组、暴露组与非暴露组的生存差异,但不想被PH假设卡住。
- 多终点随访数据:同一个研究对象可能发生复发、死亡、失访,需要竞争风险模型区分“事件类型”。
- 长期随访数据:KM曲线会在后期交叉或贴近,简单HR解释困难,需要RMST给一个“平均生存时间”的直观指标。
- 用户流失和产品生命周期分析:用户可能因为卸载、沉默、注销等原因流失,不同流失原因之间存在竞争关系。
使用边界也要说清楚:
- 三板斧不能替代因果推断。观察性数据里的组间比较,仍然需要控制混杂因素,比如多变量Cox、倾向性评分匹配或工具变量。
- 竞争风险模型选择有讲究。同样分析事件A,fine-gray模型和cause-specific模型回答的科学问题不同,不能随便换。
- RMST的时间窗需要提前定。随访时间太短,RMST趋近于总生存率;随访时间过长,删失比例高,估计误差变大。
- 真实人群数据涉及隐私和授权。使用患者数据、员工数据、用户行为数据做分析前,必须确认数据获取合法、脱敏合规、结果不外泄。
3. 环境准备与软件安装
推荐使用R,因为生存分析的生态最完整:survival、survminer、cmprsk、survRM2都有成熟实现。也可以用Python的lifelines和scikit-survival做类似分析,但竞争风险和RMST的丰富程度不如R。
3.1 R环境检查
建议使用R 4.2以上版本,配合RStudio Desktop。先检查版本:
R.version.string3.2 安装需要的数据包
packages <- c( "survival", # KM、Cox、cox.zph "survminer", # 生存曲线可视化 "cmprsk", # Fine-Gray竞争风险模型 "survRM2", # RMST计算和比较 "tidyverse", # 数据处理 "purrr" # 批量分析循环 ) install.packages(packages)如果是在公司内网环境,无法访问CRAN,可以通过本地镜像或离线安装包解决。安装完成后加载:
library(survival) library(survminer) library(cmprsk) library(survRM2) library(tidyverse)3.3 Python环境(可选)
如果用Python做生存分析,至少需要以下库:
pip install lifelines scikit-survival pandas numpy matplotlib不过本篇文章的代码示例以R为主,Python用户可以直接用lifelines完成KM、Cox和PH假设检验,竞争风险模型使用scikit-survival。
4. 数据准备与基础生存分析
这里用模拟数据演示完整流程。不是真实患者数据,但结构覆盖了生存分析最常见的情况:时间、事件状态、分组变量、协变量、删失。
4.1 模拟数据结构
set.seed(2024) n <- 400 df <- tibble( id = 1:n, group = factor(sample(c("treatment", "control"), n, replace = TRUE)), age = rnorm(n, 60, 10), sex = factor(sample(c("male", "female"), n, replace = TRUE)) ) # 生成生存时间:对照组基线风险更高,治疗组后期效应衰减 df <- df %>% mutate( hazard = ifelse(group == "control", 0.05, 0.08) * exp(0.02 * (age - 60)), event_time = rexp(n, rate = hazard), censoring_time = runif(n, 2, 8), time = pmin(event_time, censoring_time), status = ifelse(event_time <= censoring_time, 1, 0) )这里status=1表示发生了目标事件,status=0表示删失。实际数据中,status的编码可能是0/1,也可能是字符串事件名,先统一转换为数值型因子。
4.2 KM曲线与log-rank检验
km_fit <- survfit(Surv(time, status) ~ group, data = df) ggsurvplot( km_fit, pval = TRUE, risk.table = TRUE, conf.int = TRUE, xlab = "Time", ylab = "Overall Survival Probability" )KM曲线能直观看到两组生存率,但只能回答“有没有差异”,不能回答“差多少、差异是否随时间变化”。看KM曲线时,重点注意两条曲线是否在某个时间点交叉,或者后期是否完全重合。如果出现交叉,后面Cox回归的PH假设很可能不满足。
4.3 标准Cox回归
cox_unadj <- coxph(Surv(time, status) ~ group, data = df) summary(cox_unadj)cox_adj <- coxph(Surv(time, status) ~ group + age + sex, data = df) summary(cox_adj)标准Cox回归输出的是风险比HR、95%置信区间和p值。HR的解释是:treatment组相对于control组,在任意时刻的风险比例。这个解释成立的前提就是PH假设成立。
4.4 检查PH假设
ph_test <- cox.zph(cox_adj) print(ph_test) plot(ph_test)cox.zph会给出每个变量的Schoenfeld残差检验p值,整体p值小于0.05说明PH假设被违反。如果group变量的p值很小,那么标准Cox回归的结果就需要谨慎解读。接下来第一板斧上场。
5. 第一板斧:非比例风险处理与时依协变量
5.1 什么时候需要处理非比例风险
非比例风险的常见信号有三个:
- 两条KM曲线明显交叉。
cox.zph的p值小于0.05。- Schoenfeld残差图里的平滑曲线呈明显趋势,而不是水平线。
如果治疗组的早期效应强、后期效应减弱,或者某种暴露只在随访前几年有影响,HR就不能用一个固定数值描述。
5.2 用Schoenfeld残差判断时变趋势
先看残差图趋势:
plot(ph_test, var = "group") abline(h = coef(cox_adj)["grouptreatment"], lty = 2)残差图上如果有明显上升或下降趋势,说明group变量的效应随时间变化。这时需要把group效应拆分成“不同时间段的效应”,或者用时间交互项处理。
5.3 时依协变量扩展Cox模型
R的survival包提供了tt函数,可以给变量加上时间变换项。常见做法是让group与log(time)交互,表示效应随时间对数衰减:
cox_tt <- coxph( Surv(time, status) ~ group + age + sex + tt(group), data = df, tt = function(x, t, ...) x * log(t) ) summary(cox_tt)运行后,grouptreatment代表基线时t趋近于1附近的效应,tt(group)代表随时间的变化斜率。如果tt(group)这一项的p值显著,说明group效应确实随时间在变。
这里需要注意:tt函数的写法不同,表达的时间变化形式也不同。可以用x * t、x * log(t),也可以用x * ns(t, df = 2)做样条变换。实际分析中不要盲目套公式,先看残差图的趋势再选变换形式。
5.4 分层Cox模型
如果只是某个变量不满足PH假设,对关心的问题不造成影响,可以对这个变量做分层。分层Cox模型允许不同层有各自的基线风险函数,但协变量效应假设一致:
cox_stratified <- coxph( Surv(time, status) ~ age + sex + strata(group), data = df ) summary(cox_stratified)注意一点:分层后不再输出group的HR。这种方法适合把group作为调整变量而非研究变量时使用。如果研究变量就是group且PH假设不成立,更推荐时依协变量或者RMST。
5.5 第一板斧的验证思路
跑完时依协变量模型后,需要重新看模型整体和变量显著性:
tt(group)是否显著。- 模型AIC是否比基础Cox更低。
- 解释时按照“治疗早期HR为XX,每增加一个log时间单位,HR变化XX”的方式来描述。
从项目经验看,最常见的坑是把时依协变量和时依协变量数据混淆。tt函数处理的是“效应的时变”,不是把每条样本的协变量取值随时间更新。如果你手里是长短格式的纵向数据,每次随访都记录了新的协变量值,应该用Surv(tstart, tstop, status)的计数过程写法:
cox_time_varying <- coxph( Surv(tstart, tstop, status) ~ group + age + sex, data = long_df )两种思路解决的是不同问题,不要混用。
6. 第二板斧:竞争风险模型
6.1 什么情况需要竞争风险
回到肿瘤随访数据:患者可能复发,也可能在复发前死亡。如果复发是研究终点,死亡就是竞争事件。普通KM把“没有复发”当作删失处理,实际上死亡的病人已经不可能复发,这种处理会高估累积复发率。
判断是否需要竞争风险,可以问三个问题:
- 是否存在多个互斥的结局事件?
- 某个结局的发生会不会阻止另一个结局发生?
- 忽略竞争事件是否会影响临床决策?
如果答案都是“是”,就需要用竞争风险模型。
6.2 累积发生函数CIF
模拟数据里追加一个事件类型变量,1表示目标事件,2表示竞争事件:
df_comp <- df %>% mutate( event_cause = case_when( status == 0 ~ 0, runif(n) < 0.3 ~ 2, TRUE ~ 1 ) )计算两种事件的累积发生函数:
cif <- cuminc( ftime = df_comp$time, fstatus = df_comp$event_cause, group = df_comp$group ) plot(cif)cuminc输出的是每种事件在各时间点的累积发生率。注意CIF与1-KM的区别:1-KM把所有删失都当作未发生事件,高估目标事件风险;CIF则把竞争事件看作一个真实去向,累积发生率之和不超过100%。
6.3 Cause-specific Cox模型
因果特化Cox模型关注的是“在给定风险集和竞争事件未发生前,某因素对目标事件发生率的影响”。用survival包实现时,把竞争事件作为删失即可,和标准Cox差别不大:
cox_cs <- coxph( Surv(time, event_cause == 1) ~ group + age + sex, data = df_comp ) summary(cox_cs)但这里“竞争事件记为删失”只是数学处理,解释时不能说“竞争事件被排除”,而是“目标事件之前的竞争事件被视为删失”。
6.4 Fine-Gray模型
Fine-Gray模型关注的是“某因素对目标事件累积发生率的影响”,更像直接预测CIF。R里用cmprsk包:
cov_matrix <- model.matrix(~ group + age + sex, data = df_comp)[, -1] fg_fit <- crr( ftime = df_comp$time, fstatus = df_comp$event_cause, cov1 = cov_matrix, failcode = 1, cencode = 0 ) summary(fg_fit)failcode=1表示把事件1作为目标事件,failcode=2则分析竞争事件。Fine-Gray输出的是subdistribution hazard ratio,解释为“该因素对目标事件累积发生函数的影响”。
6.5 竞争风险模型的选择建议
- 如果科学问题是“某因素是否影响目标事件的发生率”,用cause-specific Cox。
- 如果科学问题是“某因素是否影响目标事件在人群中的累积发生率”,用Fine-Gray。
- 如果报告临床预后和风险预测,Fine-Gray更常用,因为它直接对应CIF。
- 如果怀疑两组在竞争事件上差异很大,两个模型都跑,结果放在一起讨论。
这套选择逻辑也适用于Python用户,scikit-survival中有CompetingRiskSurvivalAnalysis实现,但Fine-Gray细节不如R丰富。
7. 第三板斧:RMST受限平均生存时间
7.1 为什么用RMST
Cox回归在非比例风险下难以用一个HR概括组间差异,KM曲线交叉时差异很难解释。RMST给一个绝对指标:在指定的时间窗口内,平均每个人存活了多少时间。它的好处是不依赖PH假设,临床解释直接,适合向非统计背景的读者汇报。
RMST的定义是在时间点tau之前,生存曲线下的面积:
[ RMST(\tau) = \int_0^\tau S(t) dt ]
tau需要预先指定,一般取随访中位数、临床随访截止时间,或者根据既往研究设定。tau太接近最大随访时间会导致尾部不稳定。
7.2 两组RMST比较
用survRM2包计算:
fit_rmst <- rmst2( time = df$time, status = df$status, arm = as.numeric(df$group == "treatment"), tau = 6 ) print(fit_rmst)这个输出会包含:
- 两组的RMST估计值。
- RMST差值treatment - control。
- RMST比值。
- 各自95%置信区间和p值。
如果差值大于0,说明在0到6这个时间窗内,treatment组平均生存时间更长。这里可以结合KM曲线一起报告,比如“两组KM曲线在随访早期分开,但后期接近;6个月RMST差异为XX个月”。
7.3 调整协变量的RMST
如果要做多变量调整,survival包里的rmst函数可以拟合带协变量的RMST回归模型:
rmst_adj <- survival::rmst( time = df$time, status = df$status, arm = as.numeric(df$group == "treatment"), rho = 0, tau = 6, adjust = model.matrix(~ age + sex, data = df)[, -1] ) summary(rmst_adj)注意rmst函数的参数选择要按版本确认。实际项目里,我更常用伪观测值法(pseudo-observations)做多变量RMST回归,st包或pseudo包都能实现。
7.4 RMST的适用边界
RMST不是万能的。如果随访时间很长、删失比例很高,尾部生存曲线不稳定,RMST估计方差会变大。这时候可以尝试不同的tau做敏感性分析,例如分别取tau=4、6、8,观察结论是否一致。
另一个容易踩的坑是:RMST是“平均生存时间”,不是“中位生存时间”。很多临床报告习惯用中位数,RMST和它是两个不同指标,不要混用。
8. 接口 API 与自动化批量分析
这三板斧属于统计建模,不是模型服务平台,没有现成的HTTP接口。如果你的项目需要把这套分析嵌入生产流程,建议通过R脚本批量跑分析,再用R Markdown或Quarto输出报告。
8.1 批量跑多个终点
实际场景里,一个队列可能有多个事件终点,比如全因死亡、心血管死亡、肿瘤复发。可以写一个函数,依次对每个终点做Cox回归,结果汇总到数据框:
run_cox_by_outcome <- function(dataset, outcome_var) { formula <- as.formula( paste0("Surv(time, ", outcome_var, ") ~ group + age + sex") ) model_fit <- coxph(formula, data = dataset) tibble( outcome = outcome_var, term = rownames(summary(model_fit)$coefficients), hr = exp(coef(model_fit)), p_value = summary(model_fit)$coefficients[, "Pr(>|z|)"] ) } outcome_list <- c("status", "event_cause") results <- map_dfr(outcome_list, ~ run_cox_by_outcome(df_comp, .x)) print(results)用purrr::map_dfr批量循环,结果直接合并成表,方便输出到CSV或Excel。
8.2 批量跑多个亚组
按性别分层批量跑分析:
df %>% group_by(sex) %>% nest() %>% mutate( model = map(data, ~ coxph(Surv(time, status) ~ group + age, data = .x)) ) %>% mutate( result = map(model, ~ broom::tidy(.x)) ) %>% unnest(result)这种模式适合快速生成亚组森林图数据。跑批量分析时建议加上tryCatch,单个亚组报错不影响整体流程:
safe_cox <- safely(coxph)8.3 输出报告
推荐用R Markdown或Quarto,把KM曲线、PH检验、竞争风险和RMST结果放在一份报告里,输入数据和输出结果分目录管理:
project/ ├── data/ │ └── raw_data.csv ├── scripts/ │ ├── 01_descriptive.R │ ├── 02_non_proportional_hazard.R │ ├── 03_competing_risk.R │ └── 04_rmst.R ├── output/ │ ├── figures/ │ └── tables/ └── reports/ └── analysis_report.qmd9. 资源占用与性能观察
生存分析本身对硬件要求不高,普通笔记本就可以跑通模拟数据和中小规模队列数据。但如果你处理的是数十万行、上千万行的电子健康档案或用户行为日志,有几个性能点要注意:
- KM曲线和Cox回归在数据量较大时仍很快,瓶颈主要在数据清洗和合并阶段。
- Fine-Gray模型的迭代比普通Cox慢,数据集越大,求解耗时越长。
- RMST用bootstrap计算置信区间时,需要重复抽样几百到上千次,耗时随样本量上升。
- 时依协变量长格式数据会让行数膨胀,比如一个患者多次随访,每行代表一个时间区间,行数可能从几万膨胀到几百万。
建议先做小样本调试,再上全量数据。可以用system.time()记录耗时,用object.size()查看数据占用:
system.time(cox_adj <- coxph(Surv(time, status) ~ group + age + sex, data = df))如果数据超过几百万行,可以考虑data.table做数据清洗,再把建模部分交给survival包。生存分析模型本身没有GPU需求,不像深度学习任务那样依赖显卡。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| cox.zph检验p值小于0.05 | PH假设不满足 | 画Schoenfeld残差图,看趋势 | 使用时依协变量、分层Cox或RMST |
| KM曲线交叉,HR不好解释 | 组间效应随时间变化 | 检查cox.zph和残差图 | 按时间分段计算HR,或改用RMST |
| 竞争风险数据用KM高估事件率 | 忽略了竞争事件 | 计算CIF并与1-KM比较 | 使用cuminc()或Fine-Gray模型 |
| Fine-Gray模型结果不稳定 | 事件数太少或竞争事件定义不清 | 检查事件数和CIF曲线 | 增加样本量,重新定义竞争事件边界 |
| RMST结果受tau影响大 | tau选择不合理 | 不同tau做敏感性分析 | 根据临床随访期预先设定tau,并报告敏感性分析 |
| 长格式时依协变量模型报错 | tstart/tstop区间重叠或排序错误 | 检查每条记录的时间区间 | 确保区间不重叠并正确排序 |
| R包安装失败 | 网络镜像或编译环境问题 | 查看报错信息,尝试指定镜像 | 换CRAN镜像,或安装预编译版本 |
| 批量循环中某个亚组报错 | 样本量太小或变量类别缺失 | 打印逐次日志 | 用safely()捕获错误,跳过异常亚组 |
一个很常见的错误是:把竞争风险里的cause-specific HR和Fine-Gray的HR混在一起解释。写文章时一定要标明用的是哪种模型,否则审稿人或业务方会质疑结果的解释逻辑。
11. 最佳实践与使用建议
三板斧虽然只是三个方法,但组合起来覆盖了大多数真实生存数据项目。这里给几条工程化建议:
第一,先跑基础KM和Cox,再决定要不要进阶。不要一上来就Fine-Gray,先搞清楚数据里有多少竞争事件、事件定义是否清晰。基础分析能帮你发现数据质量问题和事件定义问题。
第二,PH假设检验应该是标准Cox回归后的固定步骤。每次跑完coxph,接着跑cox.zph。如果p值小于0.05,就把时依协变量、分层或RMST的备选结果准备好,而不是硬着头皮报告一个固定HR。
第三,竞争风险模型的事件编码必须规范。建议统一用0=删失、1=主要事件、2=竞争事件。分析前检查事件总数,每个事件组的样本量太小时,Fine-Gray模型结果不可靠。
第四,RMST的tau要在分析方案里预先写明。tau不是跑完数据后再找的,否则容易被人质疑是在数据挖掘。实际项目里可以在方案阶段就定义主要tau和敏感性分析tau。
第五,批量分析要保留日志。用R脚本批量跑多个终点时,建议把每个模型的样本量、事件数、收敛状态、警告信息全部记录下来,而不是只记录HR和p值。否则模型出问题时,很难定位是哪一步的数据出了问题。
第六,数据合规要前置。涉及人群随访数据、患者信息、用户行为数据的分析,必须确保数据来源合法、脱敏到位、结果不泄露个人身份信息。不要为了演示效果使用未授权的真实数据,用模拟数据或公开数据集练习更稳妥。
12. 总结与下一步
这三板斧能解决的实际问题很明确:
- 非比例风险处理解决的是“HR不可信”的问题。
- 竞争风险模型解决的是“终点事件互相干扰”的问题。
- RMST解决的是“组间差异难以直接量化”的问题。
建议第一次尝试时,先用自己的数据跑通基础KM和Cox,接着用cox.zph检验PH假设,根据检验结果决定是否需要时依协变量。然后检查数据是否存在竞争事件,如果有,补一组CIF和Fine-Gray结果。最后在方案阶段确定好RMST的tau,作为汇报的补充指标。
如果这三个方法已经熟练,下一步可以往多状态模型、参数生存模型、因果生存分析方向扩展。多状态模型可以同时建模“无病-复发-死亡”的完整过程,参数生存模型可以对不同分布形态做更精细拟合,因果生存分析则可以回答“如果所有人都接受治疗,生存率会怎样”的反事实问题。每一步都比KM曲线+Cox回归更接近真实世界的数据复杂度。建议先跑通第一板斧,后续再看自己的数据需要哪一层。