医学统计 / 预测模型

医学预测模型怎么做:LASSO、随机森林与 XGBoost、列线图与外部验证

本页按 TRIPOD+AI 与 PROBAST+AI 的要求,把临床预测模型和生信“机器学习筛选特征基因”论文的建模步骤逐项给出判断标准:样本量怎么算,LASSO 的变量集为什么每次不同,什么时候用随机森林或 XGBoost,调参和验证怎样避免数据泄漏,校准、DCA、列线图和生存模型的代码要点,以及审稿人最常提出的质疑。

直接答案

做医学预测模型的顺序是:先定义预测时点和结局,用 Riley 方法(R 包 pmsampsize)算样本量;连续变量保持连续,用 LASSO 或预先指定的变量建模,并以带样条的 Logistic 或 Cox 回归作为基线;随机森林、XGBoost 只在样本量充足或存在复杂非线性时有优势,在多数表格型临床数据上与 Logistic 回归区分度相当。变量筛选、标准化、插补、重采样和调参都必须放进交叉验证或 bootstrap 的每一次循环内,用嵌套交叉验证或 bootstrap 优化度校正报告内部验证结果,再在时间或地域独立的数据上做外部验证。评估同时报告区分度(AUC 或 C-index 及 95% CI)、校准(校准截距、斜率与平滑校准曲线)和临床净获益(DCA)。最后用 rms 画列线图,按 TRIPOD+AI 清单报告,并公开代码。

流程

凡是用到结局变量或数据分布的步骤,都属于“模型开发”的一部分,内部验证时要在每个训练折或 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 条要求说明样本量如何确定并论证其充分性,这一方法是目前方法学文献推荐的计算方式。

R:pmsampsize 计算开发样本量(输出为本页实测结果的摘录)r
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.18

Riley 方法的三条准则

二分类和生存结局取三条准则中所需样本量的最大值:期望收缩因子 ≥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 个参数11362889.58收缩因子 ≥0.9
患病率 0.253,C=0.70,30 个参数273269223.04收缩因子 ≥0.9
患病率 0.10,C=0.80,10 个参数762777.62收缩因子 ≥0.9
生存:Cox-Snell R²=0.10,15 个参数,事件率 0.08/年,3 年,平均随访 4 年127440827.18收缩因子 ≥0.9 与 3 年风险精度
本页实测:pmsampsize 1.1.3,R 4.4,macOS arm64。第一行的输入取自 SUPPORT 研究数据(1000 例,院内死亡 253 例,21 个候选变量展开为 30 个参数);按这一计算,该数据量不足以支持 30 个参数的模型。

变量筛选

glmnet 的 cv.glmnet 随机划分折,帮助文档原文写明“cv.glmnet 的结果是随机的,用户可以多次运行并平均误差曲线来降低随机性”。下表是在同一份数据上的实测,说明不同种子带来的差异有多大。

高维表达数据(几千到几万个基因、几十到几百个样本)中的不稳定比上表更严重:变量远多于样本,高度相关的基因之间可以互相替代,LASSO 往往只从一组共表达基因中随机保留一个。此时报告单一的“特征基因列表”意义有限,更应报告 bootstrap 入选频率,或改用弹性网(alpha 在 0 与 1 之间)让相关基因一起入选。

R:固定折分、显式 lambda、稳定性检查与平均误差曲线r
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–213lambda.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 121结果不再依赖单个种子
100 次 bootstrap 重抽样,lambda.1se—98scoma、pafi、alb 入选 100 次,hrt、bili 99 次,crea 98 次;age 56 次,urine 51 次
同一折分,standardize = FALSE8(TRUE 时为 12)—取值范围大的变量(如尿量)更容易保留,变量组成改变
本页实测:SUPPORT 数据 1000 例,院内死亡 253 例,30 个候选参数,family = "binomial",10 折,默认 deviance;glmnet 5.0,R 4.4,macOS arm64,100 次 cv.glmnet 共 28.7 秒。缺失值按 SUPPORT 推荐的正常值单一填补,仅用于演示。

算法选择

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 当概率画校准曲线
超参数名称按 randomForest 4.7、scikit-learn 1.9 与 XGBoost 3.x 文档核对。

调参与内部验证

用来选超参数的交叉验证误差本身偏乐观。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 没有对应参数,需要像上面的代码那样自己写循环。

Python:嵌套交叉验证比较 LASSO-Logistic、随机森林与 XGBoost(代码按 scikit-learn 1.9 文档核对)python
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))
R:LASSO-Logistic 的 bootstrap 优化度校正 AUC(筛选在每次 bootstrap 内重做)r
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,却都使少数类的预测概率被严重高估,校准变差。临床预测模型输出的是风险,校准变差会直接误导决策。

Python:SMOTE 只作用于训练折(imbalanced-learn 0.14)python
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 是审稿中最常被指出的问题之一。

R:AUC 与 CI、校准截距和斜率、决策曲线r
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 条要求报告模型更新方式。

指标回答的问题RPython解读要点
AUC / C 统计量及 95% CI能否把发生与未发生结局的人排开pROC::roc、ci.auc(DeLong)sklearn.metrics.roc_auc_score + bootstrap CI0.5 为随机;外部验证时比开发集低是常见现象,要报告
两个模型的 AUC 比较新模型是否更好pROC::roc.test(配对 DeLong)bootstrap 差值 CI嵌套模型(新模型 = 旧模型 + 新变量)不宜用 DeLong 检验,Demler 等 2012 年指出其检验水准失真
校准截距(calibration-in-the-large)平均预测风险是否等于实际发生率rms::val.probstatsmodels 以 logit(p) 为 offset 的 Logistic 回归目标为 0;负值表示高估
校准斜率预测是否过于极端rms::val.prob以 logit(p) 为唯一自变量的 Logistic 回归目标为 1;小于 1 表示过拟合,预测过于极端
平滑校准曲线各风险水平上预测与实际是否一致rms::val.prob、calibratesklearn CalibrationDisplay(分箱)需要约 200 个事件和 200 个非事件才稳定
Brier 分数概率预测的总体误差rms::val.probsklearn brier_score_loss受患病率影响,同一数据内比较模型时使用
决策曲线(DCA)在合理阈值范围内用模型决策是否比全部干预或全不干预更好dcurves::dcadcurves 无官方 Python 版,可按公式自算阈值范围事先按临床情境确定

列线图与生存模型

列线图是把已拟合模型画成可手算的图,它不增加模型的有效性。列线图本身的报告要求与模型相同:内部验证、校准和外部验证。

R:Logistic 列线图与 bootstrap 内部验证(rms 8.1.1)r
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-corrected
R:LASSO-Cox、Cox 列线图、校准、时间依赖 ROC 与 C-index(survival::pbc)r
library(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 C

calibrate.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=TRUEvalidate、calibrate 需要设计矩阵和结局lrm 或 cph 中加 x = TRUE, y = TRUE
when lp.at is not specified you must specify x=TRUE in the fitnomogram 需要从设计矩阵确定线性预测值的范围拟合时加 x = TRUE
报错文本取自 rms 8.1.1 源码中的 stop() 语句。

可解释性

SHAP 把单个预测分解为各特征的贡献,适合展示树模型学到的规律。SHAP 官方文档专门有一篇《解读预测模型以寻找因果结论时要谨慎》,说明 SHAP 展示的是模型利用的相关关系。

Python:XGBoost 模型的 SHAP 图(shap 0.52)python
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
条目编号与内容依据 TRIPOD+AI 正文(PMC11019967)。

国内经验

以下来自知乎专栏、医咖会转载的“小白学统计”和博客园的生信教程,已与英文方法学文献和软件文档核对。大量模板化的 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 阈值范围是否合理;外部验证数据是否真正独立;结果的临床解释。

参考资料

常见问题

LASSO 每次运行选出的变量不一样,以哪次为准?

都不以单次为准。固定 foldid 保证可复现,再用平均多次折分误差曲线或 bootstrap 入选频率说明稳定性。本页实测同一数据换 100 个种子,lambda.1se 下有 7 种变量集,平均 100 次误差曲线后结果唯一。挑一个结果最好看的种子属于数据窥探。

训练集 AUC 0.95,验证集只有 0.70,正常吗?

说明模型过拟合,或有泄漏步骤让训练集表现虚高。检查筛选、标准化、重采样是否在划分前做过,随机森林是否用 predict(rf, newdata = 训练集) 计算 AUC。论文中报告优化度校正或嵌套交叉验证的结果,不报告训练集 AUC 作为模型性能。

没有外部验证数据怎么办?

用 bootstrap 优化度校正(200 次以上,每次重做全部建模步骤)或嵌套交叉验证做内部验证,报告校正后的 AUC、校准斜率和校准曲线,在局限中写明缺少外部验证。同一数据随机拆出的验证集属于内部验证。

Hosmer-Lemeshow 检验 P<0.05,模型就不能用吗?

不能据此判断。大样本时 H-L 检验对微小偏差敏感,小样本时检验效能低,P 值也不说明偏差方向。看校准截距、斜率和平滑校准曲线:截距接近 0、斜率接近 1、曲线在临床关心的风险范围内贴近对角线即可。

LASSO 选出变量后还要做多因素回归吗?

做预测模型时直接用 LASSO 的系数。再做无惩罚多因素回归会去掉收缩、让系数偏大,按 P 值再删一轮更会加重过拟合。需要无收缩系数时用 relax = TRUE,并把整个两步流程放进 bootstrap 验证。

用 GEO 里另一个数据集做验证算外部验证吗?

固定基因、系数和截断值后在另一个独立队列上只做预测和评估,可以称为外部验证;来自不同中心、不同时间或不同平台时外推意义更大。在验证集上重新跑 LASSO、重新拟合系数或按验证集中位数分组,都已是重新建模。

让智能体跑完嵌套交叉验证和 bootstrap

把数据和研究问题交给 Scientify 的科学智能体:它在隔离云电脑中完成样本量计算、多算法嵌套交叉验证、校准与 DCA、列线图,关闭本机后继续运行,工作区保留全部代码和日志。新注册用户获得 5 美元等值免费额度。