预后 signature 与风险模型
跟完这篇,您能把一堆候选基因走完「单因素 Cox 初筛 → LASSO 降维 → 多因素建模」这条主流水线,压成一个能算风险评分(risk score)的预后 signature,据此把样本切成高、低风险两组,出 KM 曲线、时间依赖 ROC 和列线图,并在独立数据集上验证它到底能不能用。
先备好两样输入
整条流程从头到尾只吃两张表,样本一一对应:
| 表 | 形状 | 关键列 / 值 |
|---|---|---|
表达矩阵 expr | 行样本、列基因 | 标准化后的表达量,如 log2(TPM+1) |
生存信息表 clin | 行样本 | time(随访时长)、event(0/1 结局,1=终点事件发生) |
三件事先对齐好,后面才不会错位:
- 两张表的样本顺序、行名要完全一致,
expr第 i 行必须是clin第 i 行那个人。 time和event成对出现:还活着 / 未失访但未发生终点,记event = 0(删失);发生终点(如死亡、复发)记event = 1。time的单位(天、月)自己定,但全程统一——后面时间依赖 ROC 的年份换算全靠它。
矩阵怎么从原始数据整理成这个形状,见 表达矩阵与分组表整理规范。
整体思路:三步压出一个 signature
预后建模不是把上百个基因一股脑塞进模型,而是层层过滤、逐步降维,最后落到一个「基因数不多、又能预测预后」的组合。主流做法就三步:
| 步骤 | 目的 | 关键包 / 函数 |
|---|---|---|
| 1. 单因素 Cox 初筛 | 从全部候选里挑出「单看就和预后相关」的基因 | survival::coxph |
| 2. LASSO 降维 | 在相关基因里去冗余、防过拟合,进一步缩小到几个 | glmnet::cv.glmnet |
| 3. 多因素 Cox 建模 | 用留下的基因拟合联合模型,得到系数与 risk score | survival::coxph |
第一步,单因素 Cox 逐个筛。 对每个候选基因单独跑一次 Cox,看它与生存是否相关,留下达到阈值的:
library(survival)
genes <- colnames(expr) # 候选基因
uni <- lapply(genes, function(g) {
fit <- coxph(Surv(time, event) ~ expr[, g], data = clin)
s <- summary(fit)
data.frame(gene = g,
HR = s$coefficients[, "exp(coef)"],
p = s$coefficients[, "Pr(>|z|)"])
})
uni <- do.call(rbind, uni)
sig <- uni$gene[uni$p < 0.05] # p<0.05 是常用惯例,非铁律,按数据与候选量调
p < 0.05 只是常见起点。候选基因很多时,为控制假阳性可以收严(如 0.01),或先做多重检验校正——这一步的目的是把明显无关的先剔掉,不是定终局。
第二步,LASSO 进一步降维。 初筛剩下的基因之间常高度共线(一条通路上的基因一起涨一起落),直接全放进多因素模型会不稳。LASSO 通过惩罚把一部分系数压到 0,自动做变量选择:
library(glmnet)
x <- as.matrix(expr[, sig]) # 只用初筛留下的基因
y <- Surv(clin$time, clin$event)
set.seed(1) # 固定随机种子,交叉验证结果可复现
cvfit <- cv.glmnet(x, y, family = "cox", alpha = 1) # alpha=1 即 LASSO
coef_min <- coef(cvfit, s = "lambda.min") # 或 s = "lambda.1se",更稀疏、更保守
keep <- rownames(coef_min)[as.numeric(coef_min) != 0] # 系数非 0 的基因即入选
两点常被问到:
- 不用先手动标准化基因。
glmnet默认standardize = TRUE,内部会把各基因缩放到可比尺度再惩罚,最后把系数还原到原尺度。您自己再 z-score 一遍反而容易搞乱。 lambda.min还是lambda.1se? 前者给交叉验证误差最小的模型,后者在其一个标准误内挑更简单(基因更少)的模型,通常更抗过拟合。选哪个都要如实报告,别混着说。
第三步,多因素 Cox 得 risk score。 用 LASSO 留下的基因拟合联合模型,每个基因得到一个系数,risk score 就是「系数 × 表达量」的加权和:
multi <- coxph(Surv(time, event) ~ .,
data = data.frame(expr[, keep], clin[, c("time", "event")]))
risk <- predict(multi, type = "lp") # 线性预测值,即 risk score
也可以直接用 LASSO 的系数手算 risk score,两条路都常见。到这里,signature 就成型了:一组基因,加上各自的权重。
分高、低风险两组
有了每个样本的 risk score,下一步是切成两组,好画 KM、讲清楚。最常见、也最好复现的切法是按 risk score 中位数:
clin$risk <- risk
clin$group <- ifelse(clin$risk > median(clin$risk), "high", "low")
几点要留心:
- 中位数是常用惯例、不是铁律。 它简单、事先确定、不看结局,最好复现。也有人用最佳截断(
survminer::surv_cutpoint),但那是从结局里挑切点,天然偏向让 p 变小,容易高估显著性、难以外部重现——用了务必如实说明,并在独立集验证。切点选择的坑,见 生存分析:KM 曲线与 log-rank 检验。 - 切点必须在训练集上定死,原样搬到验证集。 不能到了验证集又按验证集自己的中位数重切,否则就不是「验证同一个模型」了。
- 高、低风险的中位 risk score 阈值本身要写进论文,别人才能复算。
出三张标配图:KM、时间依赖 ROC、列线图
分好组,接下来是预后模型的三张标配图,各回答一个不同的问题:
| 图 | 回答什么问题 | 关键包 / 函数 |
|---|---|---|
| KM 曲线 + log-rank | 高、低两组生存差多少 | survminer::ggsurvplot |
| 时间依赖 ROC | risk score 在 1/3/5 年排序准不准 | timeROC::timeROC |
| 列线图 nomogram | 给单个患者手算某年生存概率 | rms::nomogram |
一、KM 曲线 + log-rank: 直观看两组生存差多少。
library(survminer)
fit <- survfit(Surv(time, event) ~ group, data = clin)
ggsurvplot(fit, data = clin, pval = TRUE, risk.table = TRUE)
pval = TRUE 会标出 log-rank p。怎么读这张图、怎么写中位生存,见 生存分析:KM 曲线与 log-rank 检验。
二、时间依赖 ROC: KM 比的是「分组对不对」,ROC 比的是「risk score 排序准不准」。生存数据里结局随时间发生,所以要按时间点(如 1、3、5 年)分别算 AUC:
library(timeROC)
roc <- timeROC(T = clin$time, delta = clin$event,
marker = clin$risk, cause = 1,
times = c(365, 1095, 1825)) # 1/3/5 年,单位随您的 time 而定(此处按天)
roc$AUC # 三个时间点各自的 AUC
times 的单位要和 time 列一致:若随访以天计,1/3/5 年就是 365/1095/1825;以月计则是 12/36/60。AUC 越接近 1 越好,0.5 等于瞎猜。
三、列线图(nomogram): 把 risk score(常再联合分期、年龄等临床变量)做成一张能手算生存概率的图,临床上直观好用。
library(rms)
dd <- datadist(clin); options(datadist = "dd") # rms 要求先注册数据分布
cph_fit <- cph(Surv(time, event) ~ risk + stage + age, data = clin,
surv = TRUE, x = TRUE, y = TRUE)
surv <- Survival(cph_fit) # 取出生存函数,供下面调用
nom <- nomogram(cph_fit,
fun = function(x) surv(1095, x), # 例:预测 3 年(1095 天)生存概率
funlabel = "3-year survival")
plot(nom)
列线图一般要配一张校准曲线(rms::calibrate),看预测概率和实际观测生存对不对得上——预测得漂亮但校准差的模型,不能用。
过拟合与验证:训练集建、独立集验
这是整篇最容易翻车、审稿人也盯得最紧的一步。用来建模的那批数据,模型天生就「见过」,在它身上表现好几乎是必然的——那不叫验证,那叫自我印证。真正说明模型有用的,是它在没参与建模的数据上还站得住。
要交代清楚几件事:
-
划分要在建模前定好。 常见做法是把一个队列按比例(如 7:3)随机分成训练集、内部验证集,或直接用另一个独立队列(如另一个 GEO 数据集、TCGA 之外的队列)做外部验证。所有筛基因、定系数、定切点的动作,只在训练集上做。
-
验证集只做一件事:套用现成模型。 用训练集定下的基因、系数、切点,在验证集上算 risk score、分组,再看 KM 是否依然显著、时间依赖 AUC 是否依然像样。
-
训练集和验证集的表现要分开、成对地报,读者才能自己判断稳不稳:
指标 训练集 验证集 怎么读 KM log-rank p 报 报 验证集仍显著才算数 1/3/5 年 AUC 报 报 掉一点正常,骤降警惕 C-index 报 报 两边都报,看掉多少 C-index(一致性指数)衡量「模型把预后差的排在前面」的能力,
coxph对象直接给:summary(multi)$concordance # 返回 C-index 及其标准误C-index 0.5 等于随机、1 为完美。多少算「好」没有公认铁定阈值,随癌种、随对照而变——关键是训练集掉到验证集掉多少:掉得少说明稳,从很高骤降到接近 0.5,基本就是过拟合。
-
老实交代边界。 单中心、小样本、基因数相对样本数偏多,都会让模型偏乐观;批次效应、平台差异也会让 signature 换个数据集就失灵。这些如实写进局限,比藏着强。
一句话原则:能在独立数据集上重复出来的模型,才值得写;只在自己数据上好看的,多半是过拟合。
结果怎么写进论文
方法和图都齐了,落到笔头。一段规范的预后模型结果,通常把这几件事说全:
-
signature 由哪些基因、什么权重组成:列出最终入选基因及各自 Cox 系数(或直接给出 risk score 公式),别人才能复算。
-
怎么筛、怎么降维、怎么切:单因素阈值、LASSO 选的是
lambda.min还是lambda.1se、按中位数还是别的切点——逐一交代,含随机种子。 -
训练集与验证集的表现分开报:两边的 KM p 值、1/3/5 年 AUC、C-index 都给。
-
一句话范式(可直接改用):
基于 8 个基因构建的风险评分将患者分为高、低风险两组,高风险组总生存显著更差(log-rank p < 0.001);该 signature 在训练集与独立验证集中预测 3 年生存的 AUC 分别为 0.74 与 0.71,C-index 分别为 0.72 与 0.69。
(句中数字仅为示例格式,请换成您自己的真实结果。)
-
写清局限:样本量、单 / 多中心、是否外部验证、有无竞争风险等未处理的问题,如实说明。
在平台上跑一遍
从单因素 Cox 到 LASSO、再到 risk score、KM、时间依赖 ROC、列线图和验证,这整条链在百沐一下都不用写代码:上传带 time、event 的临床表和表达矩阵,用一句话说清目的——「用这批候选基因构建预后风险模型,单因素 Cox 初筛后做 LASSO,建 risk score 并按中位数分高、低风险组,出 KM 和 1/3/5 年时间依赖 ROC,并在验证集上评估 C-index」——平台会按标准流程建模、分组、出图,并给出可复现的 R 代码;想改阈值、切点或验证方式,直接在对话里说明即可。上手见 智能统计使用指南,或直接开始:https://app.baimuyixia.com 。