资讯动态

多时间点生存ROC曲线:R语言timeROC与ggplot2从建模到SCI出图全流程

发布时间:2026/10/19 0:35:06 来源:尧图企业网站定制
简介这是一份面向具备一定R语言基础的医学科研与生物统计人员的生存分析绘图工具包用于解决多时间点生存ROC曲线的绘制与模型评估问题。压缩包共3个文件包含1个R脚本、1个txt数据文件和1个pdf结果示例整体约13KB体积轻便便于快速上手。其中R脚本完整呈现了从数据读取、AUC计算到多时间点ROC曲线绘制与图例标注的流程txt文件提供可直接运行的示例数据pdf则展示了最终出图效果方便对照调试。资源围绕survival与pROC等常用包展开读者可据此评估风险评分模型在不同随访时间点的区分能力并观察AUC随时间变化的趋势。目前已有351人学习下载适合需要将生存ROC分析纳入论文或课题的中高级研究者参考使用。1. 多时间点生存ROC曲线为什么它是SCI论文里最容易被审稿人揪住的图很多做临床预测模型的同行都有过这种经历模型在单时间点上跑出一条漂亮的ROC曲线AUC 0.85兴冲冲投出去审稿人一句“请补充3年、5年生存时间点的ROC分析”就给打回来了。单时间点ROC在生存分析里其实是个“阉割版”指标——它把随访截断在一个时间点丢弃了删失信息和时间维度而临床预测模型的价值恰恰在于动态预测能力。多时间点生存ROC曲线time-dependent ROC解决的就是这个问题在t1年、3年、5年等多个时间节点上分别评估模型对事件发生的判别能力用AUC(t)刻画随时间变化的区分度。这个方向适合谁做临床预后模型、风险评分、生物标志物组合评估的研究者尤其是手里有生存数据time status 多个预测变量但不知道怎么把模型性能展示得让审稿人满意的人。R语言里能做的包不止一个但真正能直接产出SCI级别图形的方案需要把timeROC、survival、survminer这几个包串起来用。下面我按自己实际跑过的流程从数据准备到出图把每一步的参数和坑讲清楚。2. 生存ROC的底层逻辑与R包选型timeROC为什么比survivalROC更值得用2.1 删失数据下ROC的定义到底变了什么普通ROC的敏感度和特异度是从2×2混淆矩阵直接算的前提是每个样本的“真实结局”已知。生存数据里有一部分人随访结束时还没发生事件右删失你根本不知道他们后面会不会发病。如果直接把删失当“未发生事件”处理AUC会被系统性低估如果直接剔除样本量骤减且引入选择偏倚。timeROC的做法是基于逆概率删失加权IPCW。简单说它先用Kaplan-Meier估计删失分布G(t)然后给每个在t时刻之前仍存活的样本赋予权重1/G(T_i)用加权后的“伪结局”来估计敏感度和特异度。这样做的理论依据是在删失机制独立于事件时间的假设下IPCW估计量是无偏的。实际意义就是——你不需要补全删失数据也不需要丢弃timeROC帮你把删失样本的信息“折算”进去了。这里有个容易翻车的地方IPCW假设删失是随机的非信息性删失。如果你的数据里失访原因和预后相关比如病情重的人更容易失访这个假设就不成立AUC会有偏。审稿人如果懂行会问你这个假设是否合理。常见的应对是在方法部分说明删失机制或者做敏感性分析。2.2 timeROC、survivalROC、riskRegression三个包的取舍包名核心函数支持多时间点支持竞争风险输出AUC的置信区间出图灵活度timeROCtimeROC()是是cause参数是iidTRUE中需自己拼图survivalROCsurvivalROC()否单点否否低riskRegressionScore()是是是高但依赖模型对象我一般首选timeROC原因是第一它原生支持多时间点向量一次调用就能算出1年、3年、5年的AUC第二它返回的TP和FP矩阵可以直接喂给plot函数画多条曲线第三它支持竞争风险场景cause参数指定事件类型这在老年队列里很常见——死于其他病的人不应该算作“未发生事件”。survivalROC的局限在于每次只能算一个时间点而且不提供AUC的标准误你没法在论文里报告置信区间。riskRegression功能更强但它要求你先用它的建模函数如CSC或FGR拟合模型对于已经用coxph建好模型的场景迁移成本高。所以我的工作流通常是coxph建模 → timeROC评估 → ggplot2出图。2.3 从coxph模型到timeROC评估的完整链路假设你已经用survival包的coxph拟合了一个多因素模型接下来要评估它在多个时间点的判别能力。核心思路是用predict函数拿到每个样本的线性预测值lp把这个连续评分作为“标记物”喂给timeROC。# 加载必要的包 library(survival) library(timeROC) library(ggplot2) library(survminer) # 假设数据框为df包含列time随访时间, status0删失,1事件, # age, stage, biomarker三个预测变量 # 第一步拟合多因素Cox模型 cox_fit - coxph(Surv(time, status) ~ age stage biomarker, data df, x TRUE, y TRUE) # 第二步提取线性预测值作为风险评分 # typelp 返回线性预测值值越大风险越高 risk_score - predict(cox_fit, type lp) # 第三步设定需要评估的时间点单位与time一致假设是月 t_points - c(12, 36, 60) # 1年、3年、5年 # 第四步调用timeROC注意iidTRUE才能算置信区间 roc_result - timeROC( T df$time, # 随访时间 delta df$status, # 事件状态 marker risk_score, # 风险评分 cause 1, # 事件编码为1 times t_points, # 多个时间点 iid TRUE, # 启用影响函数计算标准误 ROC TRUE # 保存ROC曲线坐标 ) # 查看各时间点AUC及置信区间 print(roc_result$AUC) # 置信区间通过confint提取 confint(roc_result)$CI_AUC这段代码的关键参数说明cause1指定我们关心的事件类型如果你的status是0/1/21事件2竞争事件这里必须写1否则timeROC会把2也当成事件处理。iidTRUE是计算置信区间的前提代价是计算量增大样本量超过5000时可能慢但临床队列通常几百到几千可以接受。ROCTRUE会保存每个时间点的TP/FP坐标后面画图要用。predict(cox_fit, typelp)返回的是线性预测值不是风险概率。timeROC不关心你的marker是什么尺度它只关心排序所以lp和预测概率的AUC是一样的。但如果你要在图上标注风险方向lp值越大代表风险越高这个方向别搞反了。3. 用ggplot2把timeROC结果画成SCI可投的多时间点ROC图3.1 提取TP/FP坐标并构造绘图数据框timeROC返回的roc_result$TP和roc_result$FP是列表结构每个时间点对应一个矩阵。直接plot能出图但base R的图形风格投SCI不够看。我一般把坐标抽成data.frame用ggplot2重画。# 提取每个时间点的FP和TP坐标 # roc_result$FP和$TP是列表每个元素对应一个时间点 n_times - length(t_points) plot_list - list() for (i in 1:n_times) { # 注意timeROC的FP是从1递减到0需要反转顺序 fp_vec - roc_result$FP[[i]] tp_vec - roc_result$TP[[i]] # 构造数据框标注时间点 temp_df - data.frame( FP fp_vec, TP tp_vec, TimePoint paste0(t_points[i], 个月) ) plot_list[[i]] - temp_df } # 合并所有时间点的数据 roc_df - do.call(rbind, plot_list) # 确保TimePoint是因子控制图例顺序 roc_df$TimePoint - factor(roc_df$TimePoint, levels paste0(t_points, 个月))这里有个细节timeROC返回的FP是从1到0递减的而ggplot2画ROC通常希望横轴从0到1递增。如果你直接画曲线方向会反。解决办法是在构造数据框时用rev()反转或者画图时用scale_x_reverse()。我习惯在数据层面反转这样后续加对角线参考线不会乱。3.2 画图代码AUC标注、配色、对角线参考线# 构造AUC标注文本 auc_labels - paste0( roc_df$TimePoint, AUC , sprintf(%.3f, roc_result$AUC), (, sprintf(%.3f, confint(roc_result)$CI_AUC[,1]), -, sprintf(%.3f, confint(roc_result)$CI_AUC[,2]), ) ) # 去重每个时间点只保留一个标签 auc_df - data.frame( TimePoint factor(paste0(t_points, 个月), levels paste0(t_points, 个月)), AUC_text unique(auc_labels), x 0.6, y c(0.15, 0.08, 0.01) # 手动调整标签位置避免重叠 ) # 绘图 p - ggplot(roc_df, aes(x FP, y TP, color TimePoint)) geom_line(size 1.1) geom_abline(intercept 0, slope 1, linetype dashed, color grey50) scale_x_continuous(limits c(0, 1), expand c(0.01, 0)) scale_y_continuous(limits c(0, 1), expand c(0.01, 0)) scale_color_manual(values c(#E41A1C, #377EB8, #4DAF4A)) labs(x 1 - 特异度 (False Positive Rate), y 敏感度 (True Positive Rate), color 时间点) theme_bw(base_size 14) theme( panel.grid.minor element_blank(), legend.position c(0.75, 0.25), legend.background element_rect(fill white, color grey70) ) geom_text(data auc_df, aes(x x, y y, label AUC_text, color TimePoint), hjust 0, size 4.2, show.legend FALSE) # 输出为高分辨率TIFFSCI期刊常用格式 ggsave(multi_time_ROC.tiff, plot p, width 6, height 5.5, dpi 300, compression lzw)geom_abline画的是对角线参考线代表随机猜测AUC0.5。scale_color_manual里的三个颜色是我常用的配色红蓝绿在黑白打印时也能区分。legend.position用归一化坐标定位放在图内右下角比默认的右侧图例省空间。ggsave输出TIFF时用compressionlzw是无损压缩很多期刊要求TIFF格式且不低于300dpi。AUC标注的位置需要根据你的曲线实际走向手动微调。如果三条曲线在左上角挤在一起标签放右下角如果曲线分离明显标签可以放在曲线旁边。这个没有自动方案我一般先跑一遍看曲线形态再调auc_df$y的值。3.3 多模型对比把两个Cox模型的ROC画在同一张图上实际论文里经常需要对比两个模型——比如基础模型年龄分期和加入生物标志物的新模型。做法是分别算两个模型的risk_score各跑一次timeROC然后把数据合并。# 模型A基础模型 cox_a - coxph(Surv(time, status) ~ age stage, data df) score_a - predict(cox_a, type lp) # 模型B加入biomarker cox_b - coxph(Surv(time, status) ~ age stage biomarker, data df) score_b - predict(cox_b, type lp) # 分别计算timeROC roc_a - timeROC(T df$time, delta df$status, marker score_a, cause 1, times t_points, iid TRUE) roc_b - timeROC(T df$time, delta df$status, marker score_b, cause 1, times t_points, iid TRUE) # 提取并合并增加Model列区分 extract_roc - function(roc_obj, model_name) { do.call(rbind, lapply(1:length(t_points), function(i) { data.frame( FP roc_obj$FP[[i]], TP roc_obj$TP[[i]], TimePoint paste0(t_points[i], 个月), Model model_name ) })) } roc_combined - rbind(extract_roc(roc_a, 基础模型), extract_roc(roc_b, 新模型)) # 画图时用linetype区分模型color区分时间点 p2 - ggplot(roc_combined, aes(x FP, y TP, color TimePoint, linetype Model)) geom_line(size 1) geom_abline(intercept 0, slope 1, linetype dashed, color grey50) scale_x_continuous(limits c(0, 1), expand c(0.01, 0)) scale_y_continuous(limits c(0, 1), expand c(0.01, 0)) scale_color_manual(values c(#E41A1C, #377EB8, #4DAF4A)) scale_linetype_manual(values c(solid, dashed)) labs(x 1 - 特异度, y 敏感度, color 时间点, linetype 模型) theme_bw(base_size 14) ggsave(model_comparison_ROC.tiff, plot p2, width 7, height 5.5, dpi 300, compression lzw)这种图的信息密度较高六个组合3个时间点×2个模型用颜色和线型两个维度区分。如果审稿人觉得太挤可以拆成两个子图facet_wrap每个模型一个panel。我一般先投组合图审稿人要求再拆。4. 避坑与排查多时间点生存ROC最容易翻车的5个地方4.1 时间点选在随访尾部AUC置信区间宽得没法看现象选了5年时间点但中位随访时间只有3.2年5年时事件数不到10个AUC的95%置信区间从0.5跨到0.9。原因timeROC的IPCW估计在时间点接近最大随访时间时处于风险集的样本极少权重1/G(t)变得极大方差爆炸。本质上不是方法的问题是数据在那个时间点信息量不够。解决先看KM曲线选事件数充足的时间点。经验法则是每个时间点至少要有20-30个事件。如果5年事件太少改成1年和3年或者把时间点设为中位随访时间附近。论文里报告时间点选择依据时可以写“基于事件数分布和中位随访时间”。4.2 status编码不是0/1timeROC把删失当成了事件现象AUC算出来0.5左右或者比预期低很多曲线几乎贴着对角线。原因timeROC的delta参数默认事件是1删失是0。如果你的数据里status是1删失、2死亡或者0事件、1删失就会全部搞反。更隐蔽的情况是status是因子类型R按字母顺序内部编码成1和2。解决跑timeROC之前先table(df$status)确认编码。如果是因子用as.numeric(as.character(df$status))转成数值。如果事件编码不是1用cause参数指定或者重新编码。我一般统一在数据清洗阶段就把status转成0/1事件1。4.3 竞争风险没处理AUC被高估现象老年队列里很多患者死于心血管疾病而非目标癌症但分析时把这些死亡当成了“未发生事件”AUC偏高。原因timeROC默认cause1如果你的status有多个事件类型1癌症死亡2其他死亡而你没有正确指定cause它会把所有非零值都当成事件。或者你只关心癌症死亡但其他死亡的人被当作删失实际上他们不可能再发生癌症死亡了这违反了独立删失假设。解决明确你的临床问题。如果关心的是“是否发生癌症死亡”其他死亡是竞争事件用cause1指定timeROC会正确处理。如果关心的是“是否发生任何死亡”那就把癌症死亡和其他死亡合并成一个事件。两种问法的AUC含义不同论文里要写清楚。4.4 两个模型的AUC差异没有做统计检验现象新模型AUC0.82基础模型AUC0.78你觉得有提升但审稿人问“这个差异有统计学意义吗”。原因只报告了各自的置信区间没有做配对比较。两个模型的AUC来自同一批样本是相关的不能直接用独立样本的检验方法。解决timeROC的iidTRUE会保存影响函数可以用compare()函数做配对检验。# 比较两个模型在同一个时间点的AUC差异 # 需要两个timeROC对象都设置了iidTRUE comp_result - compare(roc_a, roc_b, times t_points[2], adjusted TRUE) print(comp_result$p_values)adjustedTRUE会做多重比较校正如果比较多个时间点。如果p0.05说明新模型的AUC提升有统计学意义。这个结果可以放在论文的表格里比只报AUC值更有说服力。4.5 图形输出后曲线锯齿严重或图例遮挡曲线现象ggsave输出的TIFF放大后曲线呈锯齿状或者图例框正好盖住了曲线的重要部分。原因锯齿是因为geom_line默认用直线连接相邻点而timeROC返回的坐标点在某些区域很稀疏。图例遮挡是因为legend.position设在了曲线密集区。解决锯齿问题可以在geom_line里加lineendround和linejoinround或者用geom_path替代。更根本的办法是增加times参数的密度——但timeROC的times是评估时间点不是坐标点密度。实际上timeROC返回的TP/FP坐标点数量取决于样本量样本量小的时候点少曲线自然粗糙。如果样本量确实小可以在画图时用spline插值但要注意插值不改变AUC只是视觉平滑。图例遮挡的解决办法先画一版不带图例的图看曲线走向然后把图例放在空白区域。或者用legend.positionnone去掉图例直接在曲线末端用annotate标注时间点。后者在SCI图里更常见也更美观。5. 让审稿人挑不出毛病的进阶技巧从AUC表格到决策曲线5.1 把AUC(t)做成随时间变化的曲线图多时间点ROC图展示的是几个离散时间点的判别能力但审稿人可能会问“AUC在随访期间是怎么变化的”。你可以画一张AUC随时间变化的曲线图横轴是时间纵轴是AUC附上置信区间带。# 设定更密集的时间网格 t_grid - seq(6, 72, by 6) # 6到72个月每6个月一个点 roc_grid - timeROC(T df$time, delta df$status, marker risk_score, cause 1, times t_grid, iid TRUE) # 提取AUC和置信区间 auc_df - data.frame( time t_grid, auc roc_grid$AUC, lower confint(roc_grid)$CI_AUC[, 1], upper confint(roc_grid)$CI_AUC[, 2] ) # 画AUC随时间变化图 p3 - ggplot(auc_df, aes(x time, y auc)) geom_ribbon(aes(ymin lower, ymax upper), fill #377EB8, alpha 0.2) geom_line(color #377EB8, size 1.1) geom_hline(yintercept 0.5, linetype dashed, color grey50) scale_x_continuous(breaks seq(6, 72, 12)) scale_y_continuous(limits c(0.4, 1), breaks seq(0.4, 1, 0.1)) labs(x 随访时间月, y AUC(t)) theme_bw(base_size 14) ggsave(AUC_over_time.tiff, plot p3, width 6, height 4.5, dpi 300, compression lzw)这张图能直观展示模型在不同随访阶段的判别能力衰减或稳定情况。如果AUC在早期高、后期下降说明模型对短期事件预测好对长期预测弱这在论文讨论部分是个有价值的发现。5.2 决策曲线分析DCA作为补充证据AUC衡量的是判别能力但不告诉临床医生“用这个模型做决策到底划不划算”。决策曲线分析Decision Curve Analysis计算的是净获益net benefit在阈值概率范围内比较模型和“全部治疗”“全部不治疗”策略的收益。审稿人越来越喜欢看到DCA因为它直接关联临床决策。library(rmda) # 构造DCA需要的数据框 dca_data - data.frame( time df$time, status df$status, risk_score risk_score ) # 运行DCA指定时间点 dca_result - decision_curve( status ~ risk_score, data dca_data, family binomial, thresholds seq(0, 0.5, by 0.01), confidence.intervals 0.95 ) # 画DCA曲线 plot_decision_curve(dca_result, curve.names 预测模型, cost.benefit.axis FALSE, confidence.intervals FALSE, standardize FALSE)DCA的细节这里不展开但核心逻辑是如果模型在临床相关的阈值范围内净获益高于默认策略说明模型有实用价值。把DCA和timeROC放在同一篇论文里一个证明“能区分”一个证明“值得用”审稿人很难挑刺。5.3 我踩过的一个血泪坑时间单位不统一最后说一个我自己翻过车的细节。有一次分析里随访时间列的单位是“天”但我设时间点的时候按“月”来设写了timesc(12,36,60)结果算出来的是12天、36天、60天的AUC全部接近0.5。更坑的是图形看起来“正常”因为AUC0.5的曲线也是从(0,0)到(1,1)只是贴着对角线我一开始以为是模型不行换了三个模型都是0.5才反应过来是时间单位的问题。解决很简单跑timeROC之前先summary(df$time)看数值范围。如果中位随访时间是1000多那单位大概率是天如果是30多可能是月如果是3左右可能是年。然后统一换算成你论文里要报告的单位。我现在的习惯是在数据清洗阶段就加一列time_months所有分析都用这一列避免中途搞混。另一个相关坑是时间点的选择要和KM曲线的横轴对齐。如果你论文里KM曲线用的是月ROC的时间点也要用月否则审稿人会问“为什么两个图的时间尺度不一样”。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑