
“生存分析”这个名字听起来很有距离感实际上它在医学随访、客户流失、设备故障、保险精算里遍地都是核心就一句话我们要等多久才会看到某个事件发生R语言在这个领域的积累非常深survival 包有三十多年历史配套的 survminer 画图又漂亮又省事所以一直到今天学术界和工业界做生存分析R 仍然是首选工具之一。这篇文章我就从实际使用的角度把生存分析从数据准备、KM 曲线、Cox 回归到常见坑位全部梳理一遍。适合刚接触生存分析的医学研究生、做用户留存分析的运营分析师以及任何手里握着“时间 是否发生”这类数据的人。1. 先搞懂生存分析在解决什么问题1.1 生存时间和删失这两个词绕不开生存分析里最普通但最关键的数据结构只有两列一列是时间一列是事件是否发生。举个例子研究一款药物对晚期肺癌患者的效果我们追踪每个患者从入组开始到死亡或者到研究结束。时间就是“存活了多少天”事件就是“有没有死亡”。但问题来了有人中途搬家联系不上有人还在接受治疗但研究截止日期到了这些人并没有发生死亡事件。这时候如果把他们的数据当作“没死”来处理显然不对因为人家可能出组第二天就去世了只是我们没观察到。这就是生存分析所谓的“删失”专业一点的英文叫 censoring。删失不代表信息无效它代表的是“我知道这个人至少活到了某个时点之后我不确定”。R 的 survival 包里所有建模函数都要求你显式告诉它哪些是事件、哪些是删失这一步做错了后面全白搭。删失还分几种右删失最常见意思是只知道事件发生在某个时间点之后区间删失和左删失在医学、金融数据里也存在但入门阶段右删失最需要理解。你只要记住删失信息不是缺失值千万不能粗暴删掉也不能当成“没发生”它是生存分析的核心信息之一。1.2 风险函数真正的主角生存分析经常被误以为在分析“生存时间本身”其实更严谨地说我们关心的是风险函数 h(t)它表示“活到时间 t 的那一刻之后一瞬间发生事件的概率”。这个概念像开车时的瞬时速度生存曲线 S(t) 是累计的“到目前为止还活着多少人”风险函数是“现在这一刻容不容易出事”。很多结论的直观解释都围绕风险展开比如“吸烟组的死亡风险是不吸烟组的 2 倍”这里的“风险”就是风险函数之比。R 语言做生存分析时你会在输出里看到 HR、exp(coef) 这类词它们全都是在风险函数的基础上推出来的。理解“风险是一个瞬时概念”比死记硬背公式更能帮助你读懂结果。1.3 R 语言生态survival 包为什么是首选R 里的生存分析包不少但我个人建议新手不要东张西望直接围绕两个包展开就够了survival 和 survminer。survival 是核心计算包由 Terry Therneau 主导开发KM 曲线、log-rank 检验、Cox 回归、竞争风险模型都靠它survminer 是基于 ggplot2 的可视化包专门用来画发表级别的生存曲线、森林图不用自己手动调一堆坐标轴参数。安装很简单就两行install.packages(survival) install.packages(survminer)有的环境里 survminer 依赖的包比较多装的时候如果报错多半是缺了 ggplot2、ggpubr 这类依赖包顺手一起装上就行。2. 用一张KM曲线快速上手2.1 先找一份能跑通的示例数据学生存分析最怕的是手边没有合适的数据。好在 survival 包自带一个非常经典的数据集lung收集的是晚期肺癌患者的生存数据。这是公众领域数据拿来做案例非常合适你不用自己去模拟一堆假数据。看下数据结构library(survival) data(lung) str(lung)这个数据有 228 行每一行代表一位患者。常用的列有time从入组到死亡或删失的天数status1 表示删失2 表示死亡sex1 表示男性2 表示女性age年龄ph.ecogECOG 体能评分0 到 3分数越高身体状态越差。最关键的是 status 这一列它不是常见的 0/1而是 1/2。很多新手第一次跑就在这里翻车后面我会专门讲。2.2 三步画出 KM 生存曲线KM 曲线全称 Kaplan-Meier 生存曲线是最基础的生存率可视化方法。它的思路很朴素在每个事件发生的时间点上先计算“活过这个点的概率”然后把所有点上的条件概率连乘起来得到一个阶梯状的生存率曲线。在 R 里画这条曲线分三步。第一步把“时间 事件状态”包装成 survival 包认识的 Surv 对象lung$status2 - ifelse(lung$status 2, 1, 0) Surv(lung$time, lung$status2)Surv 的第一个参数是时间第二个参数是事件指示变量一般 0 表示删失1 表示事件发生。如果你手头数据的 status 正好是 0/1 编码可以不用转换直接写 Surv(time, status)。第二步用 survfit 拟合 KM 估计。这里加了一个分组变量 sex看看男性和女性的生存曲线有没有差异km_fit - survfit(Surv(time, status2) ~ sex, data lung)第三步用 survminer 画图library(survminer) ggsurvplot( km_fit, data lung, conf.int TRUE, risk.table TRUE, palette c(#E74C3C, #3498DB), xlab Time (days), ylab Overall Survival Probability )这一步出来的图就是你在论文里常见的那种带置信区间、带风险人数表的生存曲线。2.3 KM 曲线怎么看曲线本身的信息量很大但新手最喜欢盯着 p 值看反而忽略了几个关键细节。第一曲线的台阶处代表有事件发生。每次有人死亡曲线就往下掉一截如果曲线上只有竖线没有台阶说明那个时间点发生了删失患者失去随访但曲线本身不下降。第二中位生存时间。很多人以为中位生存时间就是“一半患者死亡时对应的时间”这句话基本对但因为删失的存在严格来说它是“生存率降到 50% 时对应的时间点”。R 里可以直接算km_fit summary(km_fit)输出结果里的 median 就是中位生存时间。如果曲线的最后一部分一直没降到 50%R 会显示 NA这说明随访时间不够长没法估计中位生存期。第三置信区间。置信区间越宽说明该时间点上可用的样本越少估计越不稳定。曲线尾部通常都有一个“喇叭口”那是样本量越来越少导致的看到这种图别慌。2.4 log-rank 检验两组差异到底有没有意义看图只能说“男的曲线好像比女的高”到底有没有统计学差异需要做检验。最常用的是 log-rank 检验它的原理是在每个事件时间点上比较“观察到的死亡人数”和“如果两组没有差异时预期的死亡人数”最后汇总成一个卡方统计量。R 里一行代码survdiff(Surv(time, status2) ~ sex, data lung)输出里的 p 值如果小于 0.05就认为两组生存曲线有显著差异。对 lung 这个数据来说通常能观察到性别之间存在显著差异女性的生存情况好于男性这也符合很多肺癌临床研究的结论。需要注意的是log-rank 检验对两组生存曲线“后期交叉”的情况不敏感。如果两条曲线先在一起、后来分开、最后又交叉log-rank 检验可能给不出显著结果但那不代表没有差异。遇到这种情况可以考虑更灵活的检验方法比如 Peto 检验或限制平均生存时间不过这是进阶话题了。3. Cox 回归从单因素到多因素3.1 有了KM曲线为什么还要做CoxKM 曲线和 log-rank 检验解决的是“分组比较”的问题但它们有两个明显短板。第一个短板是连续变量。你想看年龄对生存有没有影响总不能把年龄切成若干个年龄段画一堆曲线那样既损失信息又麻烦。第二个短板是混杂因素。两组患者的年龄、体能状态可能本来就不均衡这时候观察到的差异到底是性别带来的还是其他因素带来的KM 曲线回答不了这种“多因素校正”的问题。Cox 比例风险回归就是来解决这两个短板的。它能把年龄、性别、体能评分、治疗方式等多个变量同时放进模型给出每个变量对风险的独立贡献这也是临床论文中多因素分析最常用的方法。Cox 模型的核心公式长这样h(t) h0(t) * exp(b1x1 b2x2 ... bp*xp)h0(t) 是基线风险函数它随时间是变化的但 Cox 模型一个巧妙的地方在于我们根本不用估计 h0(t) 长什么样只需要估计变量前面的系数 b。这也就是为什么 Cox 回归属“半参数模型”——它不假设基线风险的具体分布只对变量的效应做参数假设。3.2 Cox 模型结果怎么读HR 和置信区间用 lung 数据跑一个包含年龄、性别、体能评分的 Cox 模型cox_model - coxph( Surv(time, status2) ~ age factor(sex) ph.ecog, data lung ) summary(cox_model)输出结果会有一大堆你先把注意力放在这几列上coef回归系数也就是公式里的 bexp(coef)风险比 HRHazard RatioPr(|z|)p 值后面的 95% 置信区间。HR 怎么解释如果某个变量的 HR 是 1.30意思是这个变量每增加一个单位事件发生风险提高 30%。如果 HR 小于 1说明是保护因素事件风险下降。举个例子我用这个模型跑过的结果里sex 的 HR 大概在 0.58 左右这意味着女性患者的死亡风险大约是男性的 58%也就是低了 42%。这里要注意因素的水平设置很重要R 默认会把第一个出现的水平当成参考组。sex 这个变量如果用数值直接丢进模型参考组就是 1男性HR 的符号完全取决于数据的排序所以最好转成因子并显式指定水平。lung$sex - factor(lung$sex, levels c(1, 2), labels c(Male, Female))这样在输出里可以明确看到 Male 是参考组结果解释不会拧巴。ph.ecog 这个变量也要留意它默认按连续变量处理HR 大概是 1.3 左右意思是 ECOG 评分每升高 1 分死亡风险增加约 30%。如果你觉得线性效应太强也可以把它转成因子看不同评分之间的阶梯效应这在临床分析中很常见。3.3 PH 假设检验到底在查什么Cox 模型有个关键前提叫“比例风险假设”英文是 Proportional Hazards Assumption简称 PH 假设。意思是不同组别的风险函数比值应该不随时间变化。换句话说如果性别带来的死亡风险始终是男性比女性高 1.7 倍那么它就满足 PH 假设如果第一年男性风险很高两年后男性和女性风险没差别那就不满足。R 里检验 PH 假设非常方便cox_zph - cox.zph(cox_model) print(cox_zph)输出结果里每个变量有一行最后还有一个 GLOBAL 综合检验。如果 p 值小于 0.05通常认为该变量不满足 PH 假设。遇到不满足 PH 假设的情况处理方式有几种。第一最常用的是把不满足的变量作为分层变量用 strata() 放进模型比如coxph(Surv(time, status2) ~ age strata(sex) ph.ecog, data lung)这样每个性别有自己单独的基线风险函数但其他变量的效应仍然是同一个模型照样能解释。第二如果问题出在某个连续变量上可以考虑引入时间交互项用 tt() 处理。但这个方式解释起来比较绕实际临床文章中并不常用。第三如果两组曲线明确交叉那意味着更复杂的情况可能要考虑分段模型或其他方法这时候建议咨询统计师。3.4 用森林图把结果摆出来模型跑完结果怎么展示survminer 里自带一个 ggforest 函数可以直接把 Cox 模型的变量、HR、置信区间、p 值画成森林图ggforest(cox_model)森林图的中间部分是每个变量的 HR 点和置信区间横线横线横跨 1 就说明效应不显著。右边的 p 值也一目了然临床审稿人非常喜欢这种图。如果后续要出论文级的图建议再微调一下字体、配色和标签名称,不过这些细节不影响分析逻辑先把模型结果读对再说。4. 实战中踩过的坑4.1 status 编码颠倒最隐蔽的坑我在帮人复现分析时见过太多人把事件状态搞反。有的数据集里 0 表示“删失”1 表示“事件”有的反过来 1 表示活着、2 表示死亡还有用字符串 Yes/No 的。如果你直接把 status 喂给 Surv()事件指示符不是 0/1R 大多数情况下会报错或者给出奇怪的警告但有时候它也会默默把非 0 值当作事件处理这个更危险。比如 lung 数据里 status 是 1 和 2如果你直接写 Surv(time, status)你会发现所有患者都被当成了死亡log-rank 检验和 Cox 模型的样本量看起来都对但所有结果就都错了。我的习惯是任何数据集拿到手先做三件事table(lung$status) class(lung$time) head(lung)先看事件状态的频数分布再确认时间变量是不是数值型最后扫一眼数据结构。这三步非常快但能拦下 90% 的低级错误。4.2 时间变量的格式问题时间变量是另一个重灾区。很多原始数据表里的时间是日期格式比如 “2021-03-15”而生存分析需要的是“从起点到终点的时间间隔”不是具体日期。有人直接把日期放进 Surv()结果系统报错有人把日期转成字符串再转成数值那算出来的“时间”就完全不可解释。正确做法是先算两个日期之间的差值再统一单位。start_date - as.Date(2021-03-15) end_date - as.Date(2022-09-10) follow_up_days - as.numeric(difftime(end_date, start_date, units days))换算成月还是天要结合领域习惯。临床生存分析常用“月”或“天”用户流失分析常用“天”或“周”。单位本身不影响 Cox 模型的有效性但会影响 HR 和生存时间的数值大小写文章时一定要统一并说明。还有一个小坑如果患者入组当天就发生了事件生存时间是 0 天。部分方法对 time0 的数据非常敏感处理不当会直接报错。建议看看有多少这样的样本如果很少可以和统计专家讨论是否排除或者在敏感性分析里单独验证。4.3 不满足 PH 假设怎么办前面已经简单提过分层这里再补充一个实际经验很多刚接触的人一看到 cox.zph 的 p 值小于 0.05就急着把整个模型推翻其实不需要。PH 假设是针对某个变量的不是针对整个模型。检验结果里通常有两类情况一类是个别变量有问题另一类是整体有问题。如果只是某个分类变量不满足分层是最直接的解决办法。如果是连续变量有问题可以考虑把它离散化后分层或者换成时间依赖模型。另外大样本很容易让 PH 检验变得敏感微小偏离也会出显著 p 值。这时候不要机械地只看 p 值可以画一个 scale Schoenfeld 残差图看残差是不是随机分布在水平线附近如果整体趋势平稳就算 p 值临界也通常可以接受。4.4 竞争风险多结局数据要注意生存分析里还有一个常见情况你想研究的结局是“因特定疾病死亡”但有些患者在观察期内可能因为车祸、其他疾病等原因死亡。这些“其他原因死亡”会阻碍目标事件的发生就叫竞争风险事件。比如研究肿瘤复发如果患者因为其他原因死亡了那就观察不到复发了。这时候如果直接用普通 Cox 模型会高估累积发生率。稍微专业一点的回答是换用 Fine-Gray 模型R 里可以用 cmprsk 包的 crr() 函数实现。但竞争风险模型不是所有场景都要用。如果你的结局是“全因死亡”也就是不管什么原因死亡都算事件那就不存在竞争风险问题。所以先想清楚结局定义再决定要不要换模型。4.5 给新手的核对清单把思路整理成一套可以直接对照的流程分享给大家确认事件状态编码0/1 还是 1/2明确哪个是事件哪个是删失确认时间变量类型是数值型的时间间隔还是需要换算的日期确认单位统一天、月、周和论文其他部分一致确认分类变量的因子水平谁是参考组解释 HR 时心里要有数先画 KM 曲线看数据基本情况再跑 Cox 回归跑完模型用 cox.zph 检查 PH 假设画森林图看结果如果结局是多原因的先判断是否需要竞争风险模型。这套流程看着简单但每一步都踩过不少人的坑。尤其是事件状态编码几乎每个拿着自定义数据来找我的人都要在这上面卡一会儿。我个人实操的体会是生存分析真正难的不是 R 代码而是搞清楚每一个数据列在你研究问题里到底是什么意思。代码只是把你想清楚的事情严谨地表达出来而已。先把数据定义弄明白再动手跑模型你会少走很多弯路。