流程
凡是用到结局变量或数据分布的步骤,都属于“模型开发”的一部分,内部验证时要在每个训练折或 bootstrap 样本内重做。漏掉一步,验证结果就会偏乐观。
诊断模型预测“现在是否有病”,预后模型预测“将来某个时间内是否发生结局”。两者的预测时点不同,可用的变量也不同。PROBAST 的预测因子领域要求核对“所有预测因子在模型预期使用的时点是否都能获得”,不满足时判为偏倚风险高。
| 步骤 | 推荐做法 | 是否在每次重采样内重做 | 常见错误 |
|---|---|---|---|
| 定义预测问题 | 写清目标人群、预测时点、结局和预测时间窗;只用预测时点能拿到的变量 | — | 用术后或随访中才有的信息预测术前决策 |
| 样本量 | 用 pmsampsize 按候选参数数计算,写进方法 | — | 只报 EPV,或用最终入选变量数计算 EPV |
| 缺失值 | 多重插补,或在模型内处理;插补模型只在训练数据上拟合 | 是 | 直接删除不完整病例且不报告比例 |
| 连续变量 | 保持连续,非线性用限制性立方样条(rcs) | — | 按中位数或 ROC 最佳截断值二分类 |
| 变量筛选 | LASSO,或依据文献和临床知识预先指定 | 是 | 在全部数据上筛选后再划分训练集和验证集 |
| 标准化 | 在训练折上拟合标准化参数,再应用到测试折 | 是 | 在全部数据上标准化 |
| 类别不平衡 | 多数情况不处理;处理时只作用于训练折并重新校准 | 是 | 划分前做 SMOTE |
| 调参 | 内层交叉验证选超参数 | 是(嵌套) | 用同一次交叉验证既调参又报告性能 |
| 内部验证 | bootstrap 优化度校正或嵌套交叉验证 | — | 只报训练集 AUC;样本不大时随机 7:3 拆分 |
| 评估 | 区分度 + 校准 + DCA,均带 95% CI | — | 只报 AUC;用 Hosmer-Lemeshow 检验代替校准曲线 |
| 外部验证 | 时间或地域独立的数据,系数和截断值固定不变 | — | 在验证集上重新拟合系数或重新找截断值 |
| 报告 | TRIPOD+AI 27 条,公开代码 | — | 不报告调参范围、随机种子和软件版本 |
样本量
EPV≥10(每个变量至少 10 个事件)来自 Peduzzi 等 1996 年对回归系数偏倚的模拟,它不考虑结局发生率和模型预期表现。van Smeden 等 2019 年的模拟显示 EPV 与模型在新数据上的预测误差关系很弱。Riley 等 2020 年在 BMJ 提出按目标精度计算的方法;TRIPOD+AI 第 10 条要求说明样本量如何确定并论证其充分性,这一方法是目前方法学文献推荐的计算方式。
library(pmsampsize) # 1.1.3
# 二分类结局:患病率 0.253,参考已发表模型的 C 统计量 0.80(尽量用优化度校正后的值),30 个候选参数
pmsampsize(type = "b", cstatistic = 0.80, parameters = 30, prevalence = 0.253)
#> Criteria 1 (shrinkage >= 0.9) 1136
#> Criteria 2 (R2 optimism <= 0.05) 774
#> Criteria 3 (intercept +/- 0.05) 291
#> Final 1136, 288 events, EPP = 9.58
pmsampsize(type = "b", cstatistic = 0.70, parameters = 30, prevalence = 0.253)
#> Final 2732, 692 events, EPP = 23.04
# 生存结局:Cox-Snell R2 0.10,15 个参数,事件率 0.08/人年,预测 3 年,平均随访 4 年
pmsampsize(type = "s", csrsquared = 0.10, parameters = 15, rate = 0.08,
timepoint = 3, meanfup = 4)
#> Final 1274, 408 events, EPP = 27.18Riley 方法的三条准则
二分类和生存结局取三条准则中所需样本量的最大值:期望收缩因子 ≥0.9(过拟合可控);表观与校正后 Nagelkerke R² 之差 ≤0.05;总体风险(截距)的估计误差在 ±0.05 以内。连续结局另有残差方差的准则。上表中起决定作用的都是收缩因子准则。
参数数按候选参数数,不按最终入选变量数
parameters 填进入筛选前的候选参数总数:一个 5 分类变量算 4 个参数,一个 4 节点样条算 3 个参数,计划考察的交互项也要计入。LASSO 从 30 个参数中最后选出 12 个,样本量仍按 30 计算。
C 统计量从哪里取
cstatistic 用同一结局已发表模型的 C 值,最好是外部验证或优化度校正后的值,函数用它近似 Cox-Snell R²。上表显示预期 C 从 0.80 降到 0.70,所需样本量从 1136 增加到 2732。找不到参考值时,按保守的较低 C 计算。
机器学习需要的样本更多
van der Ploeg 等 2014 年基于三个临床队列的模拟:Logistic 回归在 EPV 约 20 至 50 时 AUC 趋于稳定,随机森林、SVM 和神经网络需要的 EPV 是其 10 倍以上,EPV 超过 200 时仍有明显乐观偏差。pmsampsize 的结果是回归模型的下限。
外部验证的样本量
旧经验是至少 100 个事件和 100 个非事件,画平滑校准曲线需要各 200 个。Riley 等 2024 年 BMJ 的实例中(患病率 0.43,C=0.77),仅为把校准斜率 95% CI 宽度控制在 0.3 以内就需要 949 例、408 个事件。R 与 Stata 中可用 pmvalsampsize 计算。
样本量不够时怎么写
如实报告计算结果和实际样本量的差距,减少候选参数(合并稀有类别、预先依据文献删减变量、用更少的样条节点),用惩罚回归,并在讨论中说明。Riley 等 2021 年指出惩罚方法在小样本下本身不稳定,不能替代足够的样本量。
| 输入 | 所需样本量 | 事件数 | EPP(每参数事件数) | 起决定作用的准则 |
|---|---|---|---|---|
| 患病率 0.253,C=0.80,30 个参数 | 1136 | 288 | 9.58 | 收缩因子 ≥0.9 |
| 患病率 0.253,C=0.70,30 个参数 | 2732 | 692 | 23.04 | 收缩因子 ≥0.9 |
| 患病率 0.10,C=0.80,10 个参数 | 762 | 77 | 7.62 | 收缩因子 ≥0.9 |
| 生存:Cox-Snell R²=0.10,15 个参数,事件率 0.08/年,3 年,平均随访 4 年 | 1274 | 408 | 27.18 | 收缩因子 ≥0.9 与 3 年风险精度 |
变量筛选
glmnet 的 cv.glmnet 随机划分折,帮助文档原文写明“cv.glmnet 的结果是随机的,用户可以多次运行并平均误差曲线来降低随机性”。下表是在同一份数据上的实测,说明不同种子带来的差异有多大。
高维表达数据(几千到几万个基因、几十到几百个样本)中的不稳定比上表更严重:变量远多于样本,高度相关的基因之间可以互相替代,LASSO 往往只从一组共表达基因中随机保留一个。此时报告单一的“特征基因列表”意义有限,更应报告 bootstrap 入选频率,或改用弹性网(alpha 在 0 与 1 之间)让相关基因一起入选。
library(glmnet) # 本页实测版本 5.0
X <- model.matrix(y ~ ., data = dat)[, -1] # 因子自动展开为哑变量;X 必须是数值矩阵
y <- dat$y # 0/1
set.seed(2026)
foldid <- sample(rep(1:10, length.out = nrow(X))) # 固定折分并保存,复现和补充材料都用它
cv <- cv.glmnet(X, y, family = "binomial", foldid = foldid)
# 默认 alpha = 1(LASSO)、standardize = TRUE(内部标准化,系数仍按原尺度返回)、type.measure = "deviance"
plot(cv) # 左竖线 lambda.min,右竖线 lambda.1se
coef(cv, s = "lambda.min") # 一定写 s;coef(cv) 不写 s 时返回的是 lambda.1se
coef(cv, s = "lambda.1se")
# 变量集稳定性:换 100 个种子各做一次交叉验证,统计每个变量的入选次数
sel <- sapply(1:100, function(s) {
set.seed(s)
b <- coef(cv.glmnet(X, y, family = "binomial", nfolds = 10), s = "lambda.1se")[-1, 1]
b != 0
})
sort(rowSums(sel), decreasing = TRUE) # 入选频率
length(unique(apply(sel, 2, paste, collapse = ""))) # 不同变量集的个数
# 降低折分随机性:同一 lambda 序列下平均 100 次折分的交叉验证误差曲线
lam <- glmnet(X, y, family = "binomial")$lambda
cvm <- sapply(1:100, function(s) {
set.seed(s)
cv.glmnet(X, y, family = "binomial", lambda = lam,
foldid = sample(rep(1:10, length.out = nrow(X))))$cvm
})
lam[which.min(rowMeans(cvm))] # 平均曲线上的 lambda.min标准化:保持默认 standardize = TRUE
glmnet 默认在内部把每列标准化后再加惩罚,系数仍按原尺度返回,因此不需要自己先 scale。关掉标准化后,惩罚对不同单位的变量不公平,变量集会改变(上表 12 个变成 8 个)。哑变量也会被标准化,这会让稀有类别更难入选;如果想让哑变量不受这一影响,可用 penalty.factor 单独调整。
lambda.min 还是 lambda.1se
lambda.min 是交叉验证误差最小处,变量多,预测略好;lambda.1se 是误差在最小值一个标准误之内的最大 lambda,变量少,更简约。做预测模型时两者在新数据上的表现通常差别不大,选择哪个要在方法里写明并固定。只为了让变量“看起来少”而改用 1se,或只为了让某个目标基因入选而改用 min,都属于事后选择。
coef(cv) 默认返回 lambda.1se
predict.cv.glmnet 和 coef 的参数 s 默认值是 "lambda.1se"。不少教程正文写“取 lambda.min”,代码却写 coef(cvfit) 不带 s,实际得到的是 1se 的结果。代码中一律显式写 s。
种子不是可以调的参数
set.seed 只保证可复现,不保证稳定。“结果不理想就换种子”等于在多个变量集里挑一个最好看的,属于数据窥探。正确做法是:固定 foldid 并在补充材料中公开;或平均多次折分的误差曲线;或报告 bootstrap 入选频率,把入选率高的变量与入选率低的变量区分开。
“LASSO 筛选 + 多因素回归”两步法的问题
常见流程是 LASSO 选变量,再把选中的变量放进无惩罚的 Logistic 或 Cox 回归,并按 P<0.05 再删一轮。第二步去掉了 LASSO 的收缩,系数重新变得过大;变量是被数据挑出来的,第二步的 P 值和置信区间不再有名义意义(Heinze 等 2018)。做预测时直接用 LASSO 模型的系数;需要无收缩系数时用 glmnet 的 relax = TRUE(松弛 LASSO),并把两步一起放进 bootstrap 验证。
先做单因素筛选再进 LASSO
“单因素 P<0.05 的变量进入 LASSO”会漏掉被混杂掩盖的预测因子,也让后续验证必须重复单因素筛选这一步(Sun 等 1996)。PROBAST(2019)分析领域的信号问题之一是“是否避免了基于单因素分析选择预测因子”。候选变量应依据文献和临床知识预先确定,直接全部进入 LASSO。
| 做法 | 入选变量数 | 不同变量集个数 | 说明 |
|---|---|---|---|
| lambda.min,100 个随机种子 | 19–21 | 3 | lambda.min 取值 0.00566–0.00989 |
| lambda.1se,100 个随机种子 | 10–18(中位数 12) | 7 | 最常见的变量集只出现 37 次;lambda.1se 取值 0.0157–0.0399 |
| 平均 100 次折分的误差曲线 | lambda.min 21;lambda.1se 12 | 1 | 结果不再依赖单个种子 |
| 100 次 bootstrap 重抽样,lambda.1se | — | 98 | scoma、pafi、alb 入选 100 次,hrt、bili 99 次,crea 98 次;age 56 次,urine 51 次 |
| 同一折分,standardize = FALSE | 8(TRUE 时为 12) | — | 取值范围大的变量(如尿量)更容易保留,变量组成改变 |
算法选择
Christodoulou 等 2019 年系统综述了 71 项研究中 282 组 Logistic 回归与机器学习的比较:低偏倚风险的 145 组中两者 logit(AUC) 之差为 0.00(95% CI −0.18 至 0.18);高偏倚风险的 137 组中机器学习高 0.34,作者认为这一差异来自有缺陷的验证。79% 的研究没有评价校准。
选择顺序:先用带样条的 Logistic 或 Cox 回归建立基线;如果机器学习模型在嵌套交叉验证或外部验证中的区分度和校准都没有明显超过基线,报告基线模型。审稿人问“为什么用机器学习”时,这一对比就是回答。
scikit-learn 的 LogisticRegression 默认带 C=1 的 L2 惩罚。论文里写“Logistic 回归”作为基线时,用 scikit-learn 1.8 及以后版本需设 C=np.inf 才是无惩罚模型;1.8 起 penalty 参数已弃用、计划在 1.10 移除,L1 写作 l1_ratio=1,旧教程中的 penalty="l1" 会产生弃用警告。
随机森林在 R 中调用 predict(rf) 不给 newdata 时返回袋外(OOB)预测,这是近似无偏的;给 newdata = 训练集时返回全部树的预测,训练集 AUC 会接近 1。报告随机森林的“训练集表现”时要写清用的是哪一种。
| 方法 | 适合的情况 | 关键超参数(起点) | 概率输出 | 常见错用 |
|---|---|---|---|---|
| Logistic / Cox 回归(含样条) | 表格型临床数据、变量数不多、需要列线图和可解释系数 | 样条节点数 3–5 | 校准通常较好 | 连续变量二分类;逐步回归筛选 |
| LASSO / 弹性网 | 候选变量多或高维表达数据;需要稀疏模型 | lambda(内层 CV),alpha | 有收缩,校准斜率接近 1 | 两步法去掉收缩;种子挑选 |
| 随机森林 | 存在复杂交互和非线性、样本量大 | mtry(R 默认 √p)、ntree 500 以上、最小节点大小 | 最小节点大小为 1 时概率偏向两端,训练集 AUC 接近 1 | 用 predict(rf, newdata = 训练集) 报告训练集表现 |
| XGBoost | 样本量大、变量多、存在交互 | learning_rate 0.01–0.1,max_depth 2–4,min_child_weight,subsample,早停 | 默认目标为 logloss 时概率可用;用 scale_pos_weight 后概率失真 | 用默认 learning_rate 0.3 与 max_depth 6 直接拟合小样本 |
| SVM | 中等样本、连续特征、只需排序 | C、gamma(RBF 核),必须先标准化 | sklearn 的 probability=True 用内部 5 折 Platt 校准,predict 与 predict_proba 可能不一致 | 把 decision_function 当概率画校准曲线 |
调参与内部验证
用来选超参数的交叉验证误差本身偏乐观。Varma 与 Simon 2006 年在没有任何真实信号的模拟数据上,经调参后的交叉验证误差有 18.5% 的训练集低于 30%;嵌套交叉验证给出的误差几乎无偏。
嵌套交叉验证得到性能估计后,最终模型用全部数据、按内层同样的调参流程重新拟合一次。外层各折选出的超参数不同是正常现象,它反映的正是流程的不确定性。
scikit-learn 中给交叉验证划分器传整数 random_state,各模型就使用相同的折,可以逐折比较;给估计器(如随机森林)传整数时,每一折都用相同的随机状态。官方建议划分器用整数,比较算法时估计器用 RandomState 实例或不设定。
rms 的 validate 与 calibrate 默认 B=40,结果在不同种子间波动较大,论文中常用 B=200 或更多。逐步回归可用 validate(fit, B = 200, bw = TRUE) 让 bootstrap 内重复向后剔除;LASSO 没有对应参数,需要像上面的代码那样自己写循环。
import numpy as np
from sklearn.model_selection import StratifiedKFold, GridSearchCV, cross_validate
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier
inner = StratifiedKFold(n_splits=5, shuffle=True, random_state=1) # 内层:调参
outer = StratifiedKFold(n_splits=5, shuffle=True, random_state=2) # 外层:估计性能
models = {
# scikit-learn 1.8 起 penalty 参数弃用,L1 写作 l1_ratio=1;1.8 之前写 penalty="l1"
"lasso_lr": (Pipeline([("imp", SimpleImputer(strategy="median")),
("sc", StandardScaler()),
("clf", LogisticRegression(l1_ratio=1, solver="saga", max_iter=5000))]),
{"clf__C": np.logspace(-3, 1, 20)}),
"rf": (Pipeline([("imp", SimpleImputer(strategy="median")),
("clf", RandomForestClassifier(n_estimators=500, random_state=0))]),
{"clf__max_features": ["sqrt", 0.33, 0.5], "clf__min_samples_leaf": [1, 5, 10, 20]}),
"xgb": (Pipeline([("clf", XGBClassifier(n_estimators=300, eval_metric="logloss"))]),
{"clf__learning_rate": [0.03, 0.1], "clf__max_depth": [2, 3, 4],
"clf__min_child_weight": [1, 5]}),
}
for name, (pipe, grid) in models.items():
search = GridSearchCV(pipe, grid, cv=inner, scoring="neg_log_loss") # 用对数损失调参,兼顾校准
res = cross_validate(search, X, y, cv=outer, scoring=["roc_auc", "neg_brier_score"])
print(name, res["test_roc_auc"].mean().round(3), res["test_roc_auc"].std().round(3),
(-res["test_neg_brier_score"]).mean().round(4))library(glmnet); library(pROC)
auc_of <- function(p, y) as.numeric(auc(roc(y, p, direction = "<", quiet = TRUE))) # 显式 direction
fit_lasso <- function(X, y) cv.glmnet(X, y, family = "binomial", nfolds = 10)
p_of <- function(cv, X) predict(cv, X, s = "lambda.1se", type = "response")[, 1]
cv0 <- fit_lasso(X, y)
apparent <- auc_of(p_of(cv0, X), y) # 表观 AUC:同一数据建模又评估
set.seed(2026)
opt <- replicate(200, {
i <- sample(nrow(X), replace = TRUE)
cvb <- fit_lasso(X[i, ], y[i]) # 变量筛选与 lambda 选择在每个 bootstrap 样本内重做
auc_of(p_of(cvb, X[i, ]), y[i]) - auc_of(p_of(cvb, X), y)
})
c(apparent = apparent, optimism = mean(opt), corrected = apparent - mean(opt))| 方法 | 做法 | 适用条件 | 问题 |
|---|---|---|---|
| 随机拆分(如 7:3) | 一次划分训练集和验证集 | 样本量很大(Harrell 认为少于约 20,000 例时不稳定) | 换一个种子结果就变;验证集小导致 CI 很宽;浪费建模样本 |
| k 折交叉验证 | 全部数据轮流做验证 | 只评估一个固定流程 | 单次 10 折的结果依赖折分 |
| 重复 k 折交叉验证 | 10 折重复多次取平均 | 需要稳定估计 | Harrell 指出约需 100 次重复的 10 折才与 bootstrap 精度相当 |
| bootstrap 优化度校正 | 在 bootstrap 样本上重做全部建模步骤,用原始数据评估,求平均乐观度 | 回归模型、rms 流程;用全部数据建模 | 每一次都要重做筛选和调参,计算量大 |
| 嵌套交叉验证 | 内层调参,外层估计性能 | 有超参数的机器学习模型;比较多种算法 | 外层估计的是“整个建模流程”的表现,而不是最终某一个模型 |
类别不平衡
van den Goorbergh 等 2022 年(JAMIA)比较了随机欠采样、随机过采样和 SMOTE:三种校正都没有提高 AUC,却都使少数类的预测概率被严重高估,校准变差。临床预测模型输出的是风险,校准变差会直接误导决策。
from imblearn.pipeline import Pipeline # 用 imblearn 的 Pipeline,sklearn 的 Pipeline 不接受采样器
from imblearn.over_sampling import SMOTE
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
pipe = Pipeline([("sc", StandardScaler()),
("smote", SMOTE(random_state=0)), # 只作用于每个训练折,测试折保持原始分布
("clf", LogisticRegression(max_iter=1000))])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
print(cross_val_score(pipe, X, y, cv=cv, scoring="roc_auc").mean())
# 重采样后的预测概率整体偏高,要报告风险时需在原始分布的数据上重新校准(TRIPOD+AI 第 13 条)AUC 不受患病率影响
AUC 只看排序,结局占 5% 还是 50% 都不改变 AUC 的含义。不平衡带来的问题是用 0.5 作为分类阈值时灵敏度很低,这可以通过按临床需要选阈值解决,不需要改变训练数据。
必须重采样时放在训练折内
imbalanced-learn 官方示例:先对全部数据欠采样再交叉验证,交叉验证的平衡准确率为 0.724,留出数据只有 0.698;把采样器放进 imblearn 的 Pipeline 后,两者为 0.732 和 0.727。用 imblearn.pipeline.Pipeline,scikit-learn 的 Pipeline 不接受采样器。
XGBoost 的 scale_pos_weight
XGBoost 官方调参说明:只关心 AUC 时可用 scale_pos_weight 平衡正负样本权重;关心预测概率是否正确时不能重平衡数据,可把 max_delta_step 设为有限值(如 1)帮助收敛。
报告要求
TRIPOD+AI 第 13 条要求说明是否使用了类别不平衡方法、原因和具体做法,以及之后如何重新校准模型或其预测值。用了 SMOTE 而没有再校准,校准曲线通常会明显偏离对角线。
常见致命错误
下表的数字来自已发表的方法学研究和官方文档示例,说明这些错误能把 AUC 或准确率抬高多少。
“多种机器学习算法取交集筛 hub 基因”的常见流程是:在同一个 GEO 数据集上分别跑 LASSO、SVM-RFE 和随机森林,取交集得到 3 到 5 个基因,再在同一数据集上画 ROC,AUC 常在 0.9 以上。交集本身不是验证:三种算法都看到了全部样本的结局,交集只是降低了基因数,选择偏倚仍在。这个 ROC 属于上表第一行的错误。Ambroise 与 McLachlan 的随机标签实验说明,在这种流程下纯噪声也能得到接近 0 的误差。
合格的做法有两种。一是把整个流程(差异分析、三种算法筛选、取交集、建模)放进外层交叉验证或 bootstrap 的每一次循环,报告外层性能和每个基因的入选频率。二是在开发数据上固定基因、系数和截断值,到另一个独立队列(不同中心或不同时间,最好是不同平台)中只做预测和评估。GEO 中同一平台、同一实验室的另一组样本可作为补充,但与开发集来源相近,外推性有限。
跨平台验证时(如 TCGA RNA-seq 开发、GEO 芯片验证),同一组系数用在不同尺度的表达值上,风险评分的绝对值没有可比性。常见做法是在各队列内对基因做 z 分数标准化,这时可以评价区分度(C-index、时间依赖 AUC),校准则需要在新队列中重新估计基线风险,并在论文中写明。
| 错误做法 | 已知后果 | 正确做法 |
|---|---|---|
| 在全部数据上筛选特征(差异分析、LASSO、单因素检验),再划分或交叉验证 | scikit-learn 官方示例:200 例×10,000 个纯随机特征,SelectKBest(k=25) 后准确率 0.76,正确做法为 0.5。Ambroise 与 McLachlan 2002:结肠癌数据随机打乱标签后,选择偏倚下的交叉验证误差接近 0,正确的外部交叉验证为 0.40–0.45 | 筛选放进 Pipeline 或每个训练折内;独立验证集在建模全程不参与 |
| 在全部数据上标准化、插补或批次校正 | 测试集的均值、方差信息进入训练;偏差大小依数据而定 | 在训练折拟合 scaler 和插补模型,再应用到测试折 |
| 划分前做 SMOTE 或欠采样 | 合成样本含测试集信息;imblearn 示例中交叉验证分数高于留出数据 | 采样器只放在训练折内 |
| 同一次交叉验证既调参又报告性能 | 无信号数据上也能得到明显低于 50% 的误差(Varma 与 Simon 2006) | 嵌套交叉验证或 bootstrap 内重做调参 |
| 只报训练集 AUC | 随机森林在训练集上的 AUC 接近 1;Logistic 的表观 AUC 也偏高 | 报告优化度校正或外层交叉验证结果,再做外部验证 |
| 同一患者多条记录分到训练集和测试集 | 模型记住个体特征,测试表现虚高 | 按患者分组划分(GroupKFold) |
| 在验证数据上用 pROC 默认 direction = "auto" | pROC 帮助文档:重采样或随机化时会使 AUC 偏高 | 显式设定 direction = "<" 或 ">" |
| 在验证集上重新找截断值、重新拟合系数,或按验证集中位数分高低风险组 | 这已是在验证集上重新建模,不能称为验证 | 系数、截断值和风险分组界值都在开发集上确定 |
评估
TRIPOD+AI 第 12e 条要求说明评价区分度、校准和临床效用的指标与图形,第 23a 条要求报告带置信区间的性能估计。只报 AUC 是审稿中最常被指出的问题之一。
library(pROC); library(rms); library(dcurves)
# p:外部验证集(或外层交叉验证、bootstrap 校正后)的预测概率;y:0/1 结局
r <- roc(y, p, direction = "<", quiet = TRUE) # 验证集上显式写 direction
ci.auc(r) # 默认 DeLong 法 95% CI
# roc.test(r1, r2) # 两个非嵌套模型在同一人群上的配对 DeLong 检验
val.prob(p, y) # 输出 C、Brier、校准截距、校准斜率、Emax 并画平滑校准曲线
df <- data.frame(y = y, model = p)
dca(y ~ model, data = df, thresholds = seq(0, 0.5, by = 0.01)) |>
plot(smooth = TRUE) # 同时画出 Treat All 与 Treat None校准的四个层级
Van Calster 等 2019 年(BMC Medicine)把校准分为:平均校准(截距)、弱校准(截距与斜率)、中等校准(平滑校准曲线)、强校准(所有变量组合下都一致,实际不可达)。样本量小时报告截距和斜率即可,样本量够时加平滑校准曲线。
不用 Hosmer-Lemeshow 检验判断校准
同一文献指出,H-L 检验人为分组,P 值不反映校准偏差的方向和大小,且检验效能低;大样本时又对微小偏差过于敏感。报告校准截距、斜率和校准曲线代替它。
DCA 的阈值与读法
净获益 = 灵敏度 × 患病率 − (1 − 特异度) × (1 − 患病率) × pt/(1 − pt),pt 为阈值概率。Vickers 等 2019 年指出两种常见误解:用决策曲线去挑阈值(阈值应先按干预的利弊确定),以及没有同时与“全部干预”和“全不干预”比较。模型只在部分阈值范围内占优时,结论要限定在该范围。
AUC 高不代表净获益高
净获益同时受区分度和校准影响。重采样或过拟合导致风险被高估时,即使 AUC 不变,在常用阈值处的净获益也可能低于“全部干预”。DCA 应使用外部验证或交叉验证得到的预测值。
外部验证结果变差时
先分开看区分度和校准:AUC 下降不多而校准截距偏离,说明人群基线风险不同,可做截距更新;校准斜率明显小于 1,说明开发时过拟合,可做截距加斜率的 Logistic 重校准。TRIPOD+AI 第 12f 条要求报告模型更新方式。
| 指标 | 回答的问题 | R | Python | 解读要点 |
|---|---|---|---|---|
| AUC / C 统计量及 95% CI | 能否把发生与未发生结局的人排开 | pROC::roc、ci.auc(DeLong) | sklearn.metrics.roc_auc_score + bootstrap CI | 0.5 为随机;外部验证时比开发集低是常见现象,要报告 |
| 两个模型的 AUC 比较 | 新模型是否更好 | pROC::roc.test(配对 DeLong) | bootstrap 差值 CI | 嵌套模型(新模型 = 旧模型 + 新变量)不宜用 DeLong 检验,Demler 等 2012 年指出其检验水准失真 |
| 校准截距(calibration-in-the-large) | 平均预测风险是否等于实际发生率 | rms::val.prob | statsmodels 以 logit(p) 为 offset 的 Logistic 回归 | 目标为 0;负值表示高估 |
| 校准斜率 | 预测是否过于极端 | rms::val.prob | 以 logit(p) 为唯一自变量的 Logistic 回归 | 目标为 1;小于 1 表示过拟合,预测过于极端 |
| 平滑校准曲线 | 各风险水平上预测与实际是否一致 | rms::val.prob、calibrate | sklearn CalibrationDisplay(分箱) | 需要约 200 个事件和 200 个非事件才稳定 |
| Brier 分数 | 概率预测的总体误差 | rms::val.prob | sklearn brier_score_loss | 受患病率影响,同一数据内比较模型时使用 |
| 决策曲线(DCA) | 在合理阈值范围内用模型决策是否比全部干预或全不干预更好 | dcurves::dca | dcurves 无官方 Python 版,可按公式自算 | 阈值范围事先按临床情境确定 |
列线图与生存模型
列线图是把已拟合模型画成可手算的图,它不增加模型的有效性。列线图本身的报告要求与模型相同:内部验证、校准和外部验证。
library(rms) # 本页核对版本 8.1.1
dd <- datadist(dat); options(datadist = "dd") # 必须在拟合前设置,且用同一个数据框
fit <- lrm(y ~ rcs(age, 4) + sex + scoma + meanbp + pafi + alb + bili + crea,
data = dat, x = TRUE, y = TRUE) # x、y = TRUE 供 validate 与 calibrate 使用
nom <- nomogram(fit, fun = plogis, funlabel = "Risk of in-hospital death",
fun.at = c(0.05, 0.1, 0.2, 0.4, 0.6, 0.8))
plot(nom)
set.seed(2026)
validate(fit, B = 200) # Dxy、斜率等的优化度校正;C = Dxy / 2 + 0.5;B 默认只有 40
cal <- calibrate(fit, B = 200)
plot(cal) # Apparent、Bias-corrected 两条线;报告 Bias-correctedlibrary(survival); library(glmnet); library(rms); library(timeROC)
d <- na.omit(subset(pbc, !is.na(trt),
select = c(time, status, age, albumin, bili, copper, protime, ast, edema, stage)))
d$death <- as.integer(d$status == 2) # 2 = 死亡;1 = 肝移植,按删失处理
d$status <- NULL
# LASSO-Cox:y 直接用 Surv 对象(glmnet 4.1 起支持)
X <- model.matrix(~ . - time - death, d)[, -1]
set.seed(2026)
cvc <- cv.glmnet(X, Surv(d$time, d$death), family = "cox", type.measure = "C")
coef(cvc, s = "lambda.1se")
# Cox 列线图:surv = TRUE 且 time.inc 等于之后 calibrate 的 u
dd <- datadist(d); options(datadist = "dd")
fit <- cph(Surv(time, death) ~ log(bili) + albumin + age + protime + edema,
data = d, x = TRUE, y = TRUE, surv = TRUE, time.inc = 1826)
cox.zph(fit) # 比例风险假定
sv <- Survival(fit)
plot(nomogram(fit, fun = list(function(lp) sv(1826, lp)), funlabel = "5-year survival"))
set.seed(2026)
cal <- calibrate(fit, u = 1826, cmethod = "KM", m = 50, B = 200) # 默认 cmethod = "hare" 需要 polspline 包
plot(cal)
# 时间依赖 ROC 与 C-index
lp <- predict(fit) # 线性预测值,越大风险越高
tr <- timeROC(T = d$time, delta = d$death, marker = lp, cause = 1,
times = c(730, 1826), iid = TRUE)
tr$AUC; confint(tr)
concordance(Surv(time, death) ~ lp, data = d, reverse = TRUE) # Harrell C
concordance(Surv(time, death) ~ lp, data = d, reverse = TRUE, timewt = "n/G2") # Uno Ccalibrate.cph 的 u 与 time.inc
u 是评估校准的时间点,与 cph 的 time.inc 取相同值、使用相同的时间单位(pbc 中为天,1826 天约 5 年)。cmethod = "KM" 时 m 为每组人数,按预测生存概率分组;默认 cmethod = "hare" 需要安装 polspline 包。
C-index 与时间依赖 AUC
Harrell C 依赖删失分布,随访越长、删失越多时越难与其他研究比较;Uno C(survival::concordance 中 timewt = "n/G2")对删失的依赖较小。时间依赖 AUC(timeROC)回答“在第 t 年能否区分已发生和未发生事件的人”,报告时给出 1、3、5 年等临床有意义的时间点及 95% CI(iid = TRUE)。
LASSO-Cox 的常见做法与问题
TCGA 预后模型常见流程是单因素 Cox 预筛、LASSO-Cox、按训练集中位风险分高低组画 KM 曲线。按中位数二分丢失信息,组间 log-rank P 值不衡量预测准确性;应以连续风险评分报告 C-index、时间依赖 AUC 和校准。标题写“生存模型”却用 family = "binomial" 拟合死亡与否,会丢掉随访时间信息。
比例风险假定
cox.zph 的全局检验 P<0.05 时,查看 Schoenfeld 残差图找出违反假定的变量,可用分层或时间交互项处理。样本量大时检验对轻微偏离敏感,以残差图判断为主。
森林图
森林图展示多因素 Cox 或 Logistic 模型各变量的 HR 或 OR 及 95% CI。survminer::ggforest 直接接受 coxph 对象,需同时传入 data;也可用 forestplot 包自定义表格列。森林图展示的是关联,不说明变量对预测性能的贡献。
| 报错原文 | 原因 | 改法 |
|---|---|---|
| variable xxx does not have limits defined in fit or with datadist | 拟合前没有设置 datadist,或 datadist 用的数据框与建模数据不同,或公式中写了 dat$x | 先 dd <- datadist(dat); options(datadist = "dd"),公式中只写列名并用 data = 参数 |
| did not use surv=TRUE for cph( ) | calibrate 或 Survival 需要模型保存生存估计 | cph(..., surv = TRUE, x = TRUE, y = TRUE, time.inc = u) |
| fit did not use x=TRUE,y=TRUE | validate、calibrate 需要设计矩阵和结局 | lrm 或 cph 中加 x = TRUE, y = TRUE |
| when lp.at is not specified you must specify x=TRUE in the fit | nomogram 需要从设计矩阵确定线性预测值的范围 | 拟合时加 x = TRUE |
可解释性
SHAP 把单个预测分解为各特征的贡献,适合展示树模型学到的规律。SHAP 官方文档专门有一篇《解读预测模型以寻找因果结论时要谨慎》,说明 SHAP 展示的是模型利用的相关关系。
import shap # 0.52
explainer = shap.TreeExplainer(xgb_model) # xgb_model:已拟合的 XGBClassifier
sv = explainer(X_test) # 在测试集或外层折上计算,不用训练集
shap.plots.beeswarm(sv) # 二分类时横轴单位是 log-odds,不是概率
shap.plots.scatter(sv[:, "age"], color=sv) # 依赖图:看非线性与交互单位与计算数据
TreeExplainer 对 XGBoost 二分类默认给出 log-odds 尺度的贡献,图中数值不能直接读成概率变化。在测试集或外层折上计算;在训练集上计算时,过拟合学到的噪声也会显示为“重要特征”。
相关特征会分摊贡献
两个高度相关的变量(如肌酐与尿素氮)会分摊 SHAP 值,单看排名会低估两者,换一个随机种子排名也可能互换。解释时把相关变量成组讨论。
不能下的结论
SHAP 值高不说明干预该变量会改变结局,不能写成“危险因素”或“治疗靶点”;SHAP 排名在不同算法之间不一致时,也不能把交集当作“稳健的生物标志物”。SHAP 只解释模型,不能弥补模型在验证中的缺陷。
报告
写明 SHAP 版本、explainer 类型、背景数据和计算所用的数据集。SHAP 图放在模型验证结果之后,作为对已验证模型的说明。
报告规范与审稿
TRIPOD+AI(Collins 等,BMJ 2024;385:e078378)共 27 条,适用于回归和机器学习模型,取代 TRIPOD 2015。PROBAST+AI(Moons 等,BMJ 2025;388:e082505)用于评价偏倚风险,模型开发部分有 16 个信号问题,模型评价部分有 18 个,取代 PROBAST 2019。投稿前用两者自查。
“样本量依据是什么?EPV 只有 5”
给出 Riley 方法的计算结果;样本量不足时说明已减少候选参数、使用惩罚回归,并报告 bootstrap 校正后的校准斜率。不要只引用 EPV≥10。
“没有外部验证”
能找到独立数据就做时间或地域验证;找不到时以 bootstrap 优化度校正或嵌套交叉验证作为内部验证,在题目和摘要中不写“验证了模型的泛化能力”,并在局限中说明。把同一数据集随机拆出的 30% 称作“外部验证”会被直接指出。
“为什么用机器学习,和 Logistic 比过吗”
给出带样条的 Logistic 或 Cox 基线在同一嵌套交叉验证中的区分度、校准和 DCA;差别不大时如实写明,并说明选择理由(如可解释性或部署方式)。
“校准如何”
给出校准截距、斜率和 bootstrap 校正的平滑校准曲线,不只给 H-L 检验 P 值。用了 SMOTE 的模型需说明如何再校准。
“特征基因是否只是过拟合的产物”
报告在重采样中的入选频率,展示整个筛选流程在外层循环中的性能,以及在独立队列中固定模型的表现;功能实验验证回答的是生物学问题,不能替代预测性能的验证。
| TRIPOD+AI 条目 | 要求 | 写作提示 |
|---|---|---|
| 7 数据准备 | 数据预处理和质量检查,是否在不同人群间一致 | 写清标准化、批次校正、异常值处理在哪一步、用哪部分数据拟合 |
| 10 样本量 | 开发和评价分别说明样本量如何确定 | 给出 pmsampsize 的输入和输出 |
| 11 缺失数据 | 处理方法与理由 | 写明各变量缺失比例和插补模型 |
| 12a 数据使用 | 数据如何划分和使用 | 写清内部验证方法、折数、重复次数、bootstrap 次数 |
| 12b 预测因子处理 | 函数形式、变换、标准化 | 连续变量是否用样条,是否二分类 |
| 12c 模型与调参 | 模型类型、理由、全部建模步骤(含超参数调优)和内部验证方法 | 列出调参网格、内层交叉验证设置和最终超参数 |
| 12e 性能指标 | 区分度、校准、临床效用的指标与图形 | AUC 或 C-index、校准截距和斜率、校准曲线、DCA |
| 12f 模型更新 | 如重校准 | 外部验证中做了截距或斜率更新要写明 |
| 13 类别不平衡 | 是否使用、原因、方法及之后如何再校准 | 没用就写“未做重采样” |
| 14 公平性 | 处理模型公平性的方法与理由 | 报告性别、年龄等关键亚组的性能 |
| 18c–18f 开放科学 | 方案、注册、数据与代码可获得性 | 把代码放到公开仓库,写明软件版本和随机种子 |
| 23a 模型性能 | 带置信区间的性能估计,包括关键亚组 | 每个指标都给 95% CI |
国内经验
以下来自知乎专栏、医咖会转载的“小白学统计”和博客园的生信教程,已与英文方法学文献和软件文档核对。大量模板化的 CSDN 教程和含虚构审稿意见的文章未采纳。
EPV 中的 V 是参数数
靳帅(知乎,2022)和小白学统计(医咖会,2024)都强调:一个多分类变量对应多个哑变量参数,非线性项和交互项也要计入;有模拟研究建议 20 到 50 EPP。这与 pmsampsize 文档中 parameters 的定义一致。
外部验证样本量的参考数
靳帅引用 Pavlou 等 2021 年的模拟:C 为 0.64–0.85、事件率 0.05–0.30 时,要把验证时 C 的标准误控制在 0.025 以内需要 60 到 170 个事件;Snell 等 2021 年建议不少于 200 个事件。与 Riley 2024 的结论方向一致:100 个事件的经验法则偏少。
固定的 0.05 误差界并非处处合理
小白学统计在介绍 Riley 方法时提醒,截距误差 0.05、R² 差值 0.05 是通用设定,统一套用并不合理,应结合临床知识和既往文献决定。例如结局发生率只有 5% 时,±0.05 的总体风险误差相当于发生率本身。
需要更正:换种子、默认折数和生存结局
博客园一篇 TCGA LASSO 教程写道“结果不理想可以更换种子数”,并称 cv.glmnet 默认 20 折。cv.glmnet 的 nfolds 默认为 10;挑种子属于数据窥探,见本页 LASSO 一节。该教程标题为生存模型,代码却用 family = "binomial",应改为 Surv 对象加 family = "cox"。
glmnet 默认取 lambda.1se
知乎专栏《lasso回归与删选特征变量的思考》(2020)指出 glmnet 预测时默认使用 1se,这与 predict.cv.glmnet 帮助中 s 的默认值一致。照抄 coef(cvfit) 的读者应核对自己报告的是哪个 lambda。
交给 Agent
样本量计算、重复的交叉验证与 bootstrap、多算法比较和出图是耗时但规则明确的步骤,可以交给科学智能体执行;研究问题、预测时点和结论解释仍由研究者决定。
一句话指令示例:“这是我的队列数据 cohort.csv,结局是 30 天死亡,预测时点是入院 24 小时。请先用 pmsampsize 算样本量,再以带样条的 Logistic 为基线,比较 LASSO、随机森林和 XGBoost,用 5×5 嵌套交叉验证报告 AUC、校准截距和斜率、DCA,画列线图,并按 TRIPOD+AI 列出方法部分需要写的内容。”
智能体在隔离云电脑中执行:检查变量类型、缺失比例和只在预测时点之后才有的变量;运行 pmsampsize;把插补、标准化、筛选和调参放进同一个 Pipeline 或 bootstrap 循环;用 100 个种子检查 LASSO 变量集稳定性;输出各模型的嵌套交叉验证结果、校准曲线、决策曲线、列线图和 SHAP 图;生成 TRIPOD+AI 对照表。预装 scikit-learn、statsmodels、pandas 等,R 包由智能体在工作区内安装。智能体会对结果做对抗审阅,检查是否有泄漏步骤;工作区保留代码、随机种子、软件版本、日志和结果,可复现。
读者仍需核对:结局定义和预测时点是否符合临床场景;候选变量是否都能在预测时点获得;DCA 阈值范围是否合理;外部验证数据是否真正独立;结果的临床解释。
参考资料
- Collins GS, et al. TRIPOD+AI statement. BMJ 2024;385:e078378 — 27 条报告清单,第 7、10–14、18、23a 条
- Moons KGM, et al. PROBAST+AI. BMJ 2025;388:e082505 — 偏倚风险与适用性评价,开发 16 个、评价 18 个信号问题
- Wolff RF, et al. PROBAST. Ann Intern Med 2019;170:51-58 — 原版偏倚风险工具,单因素筛选与连续变量二分类的偏倚
- Riley RD, et al. Calculating the sample size required for developing a clinical prediction model. BMJ 2020;368:m441 — 开发样本量的准则与步骤
- pmsampsize 1.1.3 参考手册(CRAN) — 参数定义与示例;cstatistic 应为优化度校正后的值
- Riley RD, et al. Evaluation of clinical prediction models (part 3): sample size for external validation. BMJ 2024;384:e074821 — 外部验证样本量的四条准则与 949 例实例
- van Smeden M, et al. Sample size for binary logistic prediction models: beyond events per variable criteria. Stat Methods Med Res 2019 — EPV 与预测误差关系弱
- Peduzzi P, et al. A simulation study of the number of events per variable in logistic regression analysis. J Clin Epidemiol 1996 — EPV≥10 的来源
- van der Ploeg T, Austin PC, Steyerberg EW. Modern modelling techniques are data hungry. BMC Med Res Methodol 2014 — 随机森林、SVM、神经网络所需 EPV
- Riley RD, et al. Penalization and shrinkage methods produced unreliable clinical prediction models especially when sample size was small. J Clin Epidemiol 2021 — 惩罚参数在小样本下的不确定性
- Christodoulou E, et al. A systematic review shows no performance benefit of machine learning over logistic regression. J Clin Epidemiol 2019 — 282 组比较,低偏倚风险下差异为 0
- Heinze G, Wallisch C, Dunkler D. Variable selection – a review and recommendations for the practicing statistician. Biom J 2018 — 选择后推断与两步法问题
- Sun GW, Shook TL, Kay GL. Inappropriate use of bivariable analysis to screen risk factors. J Clin Epidemiol 1996 — 单因素预筛的问题
- glmnet 文档:An Introduction to glmnet — standardize 默认值、lambda.min 与 lambda.1se 定义
- van den Goorbergh R, et al. The harm of class imbalance corrections for risk prediction models. JAMIA 2022 — 欠采样、过采样、SMOTE 损害校准且不提升 AUC
- imbalanced-learn:Common pitfalls and recommended practices — 重采样泄漏示例与 Pipeline 用法
- scikit-learn:Common pitfalls and recommended practices — 特征选择泄漏示例(0.76 对 0.5)与 random_state 建议
- Ambroise C, McLachlan GJ. Selection bias in gene extraction on the basis of microarray gene-expression data. PNAS 2002 — 随机标签下选择偏倚的交叉验证误差接近 0
- Varma S, Simon R. Bias in error estimation when using cross-validation for model selection. BMC Bioinformatics 2006 — 调参后交叉验证误差的偏倚与嵌套交叉验证
- Harrell F. Split-Sample Model Validation(博客) — 随机拆分在约 20,000 例以下不稳定;约 100 次重复 10 折与 bootstrap 相当
- Van Calster B, et al. Calibration: the Achilles heel of predictive analytics. BMC Med 2019 — 校准层级、截距与斜率解读、不推荐 H-L 检验、200 事件
- Vickers AJ, van Calster B, Steyerberg EW. A simple, step-by-step guide to interpreting decision curve analysis. Diagn Progn Res 2019 — 净获益公式与常见误解
- Demler OV, Pencina MJ, D'Agostino RB. Misuse of DeLong test to compare AUCs for nested models. Stat Med 2012 — 嵌套模型 AUC 比较
- XGBoost 文档:Notes on Parameter Tuning — 不平衡数据下 AUC 与概率的不同处理
- scikit-learn 文档:Support Vector Machines — probability=True 的 Platt 校准与不一致
- SHAP 文档:Be careful when interpreting predictive models in search of causal insights — SHAP 与因果解释
- R-help:help with nomogram function(Harrell 回复) — datadist 报错与公式中使用 $ 的问题
- 经验帖:靳帅《临床预测模型开发和验证的最小样本量要求?》(知乎) — 参数计数与外部验证样本量参考
- 经验帖:小白学统计《临床预测模型,如何估算样本量》(医咖会) — EPP 与 Riley 准则的中文解读
- 经验帖:《TCGA代码分析流程 3.3 生存模型:Lasso回归》(博客园) — 需要更正的种子与折数说法
- 经验帖:《lasso回归与删选特征变量的思考》(知乎) — glmnet 默认 lambda.1se