內部驗證與樂觀偏誤
在配模型的同一批資料上量出來的表現一定偏好,這個差距叫樂觀偏誤。這一頁從一份大世代裡切出兩百人當開發資料,把四種內部驗證方法都跑一遍,再拿剩下的兩千多人當真值對答案。最後示範這個家族最貴的一個錯誤:先在全部資料上選變數、再交叉驗證後面的配適。
表面表現為什麼一定偏好
配模型的過程就是挑一組係數讓這批資料看起來最好。挑完之後再回頭問「這組係數在這批資料上表現如何」, 等於讓考卷的作者去考同一份考卷。
這個差距叫樂觀偏誤(optimism):
它不是偶爾發生,而是期望值意義上一定發生。問題只有「有多大」,而它的大小取決於 模型複雜度與樣本量的關係(見收縮那一頁)。
內部驗證要回答的就是這件事:只用手上這批資料,把樂觀偏誤估出來扣掉。
這一頁的設計:造一個知道答案的情境
內部驗證方法的比較有一個天然的困難——你不知道正確答案是多少。
這裡用 survival::rotterdam 繞過它:
- 從 2982 人裡抽 200 人當開發資料 (115 個事件、8 個預測變項,EPV 14.4)
- 所有內部驗證方法都只准用這 200 人
- 剩下的 2782 人當真值:同一個世代、同一批測量、模型從沒看過
真值是 C-index 0.648,表面表現是 0.691—— 差距 0.043。這就是各種內部驗證方法要估出來的量。
四種方法,一起對答案
figures/scripts/B5-04-internal-validation.R| 做法 | 給出的 C-index | 重複執行的標準差 | 與真值的差 |
|---|---|---|---|
| 表面表現(完全不做驗證) | 0.6908 | — | +0.0432 |
| 單次切一半 | 0.6465 | 0.0298 | -0.0011 |
| 10 摺交叉驗證 | 0.6551 | — | +0.0075 |
| 重複 10 摺交叉驗證 | 0.6463 | 0.0154 | -0.0013 |
| Bootstrap 樂觀校正 | 0.6623 | — | +0.0146 |
Bootstrap 樂觀校正:完整的步驟
這是 Harrell 提出的做法,也是 rms 套件 validate() 背後的邏輯。它的巧妙之處在於
不需要保留任何資料——全部 200 人都拿去配模型,樂觀偏誤另外估。
重複 200 次:
- 從原始資料可重複抽取同樣人數,得到一個 bootstrap 樣本
- 在這個 bootstrap 樣本上從頭配一次模型(包括所有變數選擇、所有調參)
- 量這個模型在 bootstrap 樣本自己身上的表現 → 樂觀的那個值
- 量同一個模型在原始資料上的表現 → 比較誠實的那個值
- 兩者相減 = 這一次的樂觀量
平均起來就是樂觀偏誤的估計,從表面表現扣掉:
在這份資料上:表面 0.691 − 樂觀 0.0286 = 0.662(真值 0.648)。
切一半、k 摺、重複 k 摺
| 做法 | 怎麼做 | 問題 |
|---|---|---|
| 切一半(split sample) | 隨機分成訓練與測試,各用一次 | 兩邊都變小:模型配得比實際差,測試又不精確。而且答案隨切法大幅晃動 |
| k 摺交叉驗證 | 切成 k 份,輪流當測試集,每一份都當過一次 | 每個人都被用到了,但分組仍是隨機的,單次執行仍會晃 |
| 重複 k 摺 | 把 k 摺整套重跑很多次、平均 | 成本 × 重複次數,但這是最直接的減噪方式 |
| Bootstrap 樂觀校正 | 如上一節 | 模型永遠用全部資料配,但每一次重抽樣都要把整個建模流程重跑 |
單次 10 摺交叉驗證在這份資料上給 0.655; 把整套重跑 20 次,答案落在 0.608 到 0.668 之間,平均 0.646、標準差 0.0154。
這一頁的重點:選變數必須在迴圈裡面
這是預測模型研究裡最貴、也最難自己發現的一個錯誤。
流程長這樣(很多論文就是這樣做的):
- 在全部開發資料上跑變數選擇,得到「最終模型」
- 對這個最終模型做交叉驗證
- 報告交叉驗證後的 C-index,說「已做內部驗證」
第 2 步驗證的只有「配係數」這件事,沒有驗證「挑變數」這件事。 而挑變數才是消耗自由度的大戶。
把它做出來:在同樣的 200 人裡,除了 8 個真實變項之外, 再塞 10 個純亂數變項(共 18 個候選)。 向後 AIC 留下了 6 個,其中 2 個是亂數。
| 做法 | C-index |
|---|---|
| 選完之後的表面表現 | 0.678 |
| 在全部資料上選一次,然後只交叉驗證配適 | 0.655 |
| 每一摺裡都重跑一次選擇 | 0.600 |
| 沒被抽到的那兩千多人(真值) | 0.635 |
這條規則不只適用於變數選擇。 任何看過結果變項才做的決定都必須進到迴圈裡:
不必進迴圈的是沒看過結果的步驟:單位換算、依臨床知識刪掉拿不到的變項、 用預測變項自己的分布做的標準化。
動手跑一次
library(survival); library(MASS)
data(cancer, package = "survival")
rot <- rotterdam
rot$rfs_time <- pmin(rot$rtime, rot$dtime)
rot$rfs_event <- as.integer(rot$recur == 1 | rot$death == 1)
rot$size_mm <- c("<=20" = 15, "20-50" = 35, ">50" = 60)[as.character(rot$size)]
rot$grade3 <- as.integer(rot$grade >= 3)
rot$log_pgr <- log1p(rot$pgr); rot$log_er <- log1p(rot$er)
v <- c("age", "meno", "size_mm", "grade3", "nodes", "log_pgr", "log_er", "hormon")
f <- as.formula(paste("Surv(rfs_time, rfs_event) ~", paste(v, collapse = " + ")))
set.seed(1)
i <- sample(nrow(rot), 200)
dev <- rot[i, ]; rest <- rot[-i, ]
fit <- coxph(f, data = dev)
apparent <- summary(fit)$concordance[1]
cidx <- function(lp, d)
concordance(Surv(d$rfs_time, d$rfs_event) ~ lp, reverse = TRUE)$concordance
# Bootstrap 樂觀校正,五個步驟原封不動
opt <- replicate(200, {
bd <- dev[sample(nrow(dev), replace = TRUE), ]
bf <- coxph(f, data = bd) # 2. 從頭配
c_boot <- summary(bf)$concordance[1] # 3. 在自己身上
c_orig <- cidx(as.matrix(dev[, v]) %*% coef(bf), dev) # 4. 在原始資料上
c_boot - c_orig # 5. 相減
})
apparent - mean(opt)
# 真值:模型從沒看過的那些人
cidx(as.matrix(rest[, v]) %*% coef(fit), rest)
# ⚠️ 選擇要放在迴圈「裡面」。下面這個迴圈每一摺都重跑一次 stepAIC
folds <- sample(rep(1:10, length.out = nrow(dev)))
lp <- rep(NA, nrow(dev))
for (k in 1:10) {
tr <- dev[folds != k, ]
ff <- f; environment(ff) <- environment()
st <- stepAIC(coxph(ff, data = tr), direction = "backward", trace = 0)
vk <- names(coef(st))
lp[folds == k] <- as.matrix(dev[folds == k, vk, drop = FALSE]) %*% coef(st)
}
cidx(lp, dev)驗證環境:R 4.6.0 + survival 3.8.6 + MASS 7.3.65。實務上 rms 套件的 validate(fit, method = "boot", B = 200) 一行就能做 bootstrap 樂觀校正,但它同樣只驗證你寫在公式裡的那個模型——選擇步驟仍要自己包進去。這裡拆開寫,是為了讓那五個步驟看得見。
import numpy as np, pandas as pd
from sklearn.model_selection import RepeatedKFold
from sklearn.pipeline import Pipeline
from sklearn.feature_selection import SelectKBest, f_classif
from lifelines import CoxPHFitter
from lifelines.utils import concordance_index
RD = "https://vincentarelbundock.github.io/Rdatasets/csv/"
d = pd.read_csv(RD + "survival/rotterdam.csv")
# 與上面 R 端相同的衍生欄位。rotterdam 原始欄位沒有這些,少了這一段
# 下面每一行都會 KeyError。
d["rfs_time"] = d[["rtime", "dtime"]].min(axis=1)
d["rfs_event"] = ((d["recur"] == 1) | (d["death"] == 1)).astype(int)
d["size_mm"] = d["size"].map({"<=20": 15, "20-50": 35, ">50": 60})
d["grade3"] = (d["grade"] >= 3).astype(int)
d["log_pgr"] = np.log1p(d["pgr"]); d["log_er"] = np.log1p(d["er"])
v = ["age", "meno", "size_mm", "grade3", "nodes", "log_pgr", "log_er", "hormon"]
rng = np.random.default_rng(1)
i = rng.choice(len(d), 200, replace=False)
dev, rest = d.iloc[i], d.drop(d.index[i])
# ⚠️ 關鍵在於「選擇」寫在哪裡。放進 Pipeline 才會被 cross_val 重跑;
# 先在 dev 全體上跑 SelectKBest、再把選中的欄位餵進 cross_val_score,
# 就是本頁最後一節那個錯誤。
sel = Pipeline([("select", SelectKBest(f_classif, k=4))])
lp = np.full(len(dev), np.nan)
for tr_i, te_i in RepeatedKFold(n_splits=10, n_repeats=1, random_state=0).split(dev):
tr, te = dev.iloc[tr_i], dev.iloc[te_i]
sel.fit(tr[v], tr["rfs_event"]) # 選擇:只看訓練摺
cols = [c for c, keep in zip(v, sel.named_steps["select"].get_support()) if keep]
m = CoxPHFitter().fit(tr[cols + ["rfs_time", "rfs_event"]], "rfs_time", "rfs_event")
lp[te_i] = te[cols].to_numpy() @ m.params_[cols].to_numpy()
print(concordance_index(dev["rfs_time"], -lp, dev["rfs_event"]))scikit-learn 的 cross_val_score 只驗證 pipeline 裡的東西,所以正確做法是把選擇步驟寫成 pipeline 的一個 step;寫在 pipeline 外面就是本頁最後一節的那個錯誤。
讀論文時要問的五件事
- 內部驗證用的是哪一種? 只寫「已驗證」而不說方法的,等於沒說。
- 交叉驗證重複了幾次? 單次 k 摺的第二位小數不穩。
- 變數選擇有沒有進到重抽樣裡? Methods 通常不會直接寫; 看它是不是先報「最終模型納入 X 個變項」再說「接著對該模型做交叉驗證」——那個順序就是答案。
- 內部驗證通過了,然後呢? 內部驗證處理的是樂觀偏誤, 完全不處理族群差異。那是外部驗證的事。
- 報的是哪一個時點的區辨力? C-index 把所有時點壓成一個數字, 而臨床決策發生在特定時點。要看它隨時間怎麼變,見 隨時間變動的 AUC。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 直接報表面 C-index 當作模型表現 | 期望值意義上一定偏高 |
| 在全部資料上選完變數,再交叉驗證那個模型 | 每一摺的測試集都參與過選擇,樂觀偏誤仍在 |
| 用單次切一半當內部驗證 | 答案隨切法大幅晃動,而且同時弄小了訓練與測試 |
| 只跑一次 k 摺就報第二位小數 | 分組是隨機的,重跑會給不同答案 |
| bootstrap 時沿用原始資料選好的變項 | 那不是同一個建模流程,樂觀偏誤被低估 |
| 把切一半叫作「外部驗證」 | 那是內部驗證;外部要求另一群人 |
| 內部驗證通過就宣稱模型可以用 | 內部驗證完全看不到族群差異與校準漂移 |
| 交叉驗證只報鑑別力不報校準 | 校準斜率在內部驗證裡正是過度配適的直接指標 |
| 依結果排除離群值之後才開始重抽樣 | 那個決定也用到了結果,必須進迴圈 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B5-04-internal-validation.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
同一份兩百人的開發資料,表面 C-index 是 0.691,而模型從沒看過的那兩千多人身上量到 0.648。這個差距叫什麼、又來自哪裡?
看答案與解析
正確答案: 0.691 是在挑係數的同一批人身上量出來的,等於讓考卷的作者去考同一份考卷;那個差距叫樂觀偏誤
0.691 減 0.648 就是這一頁要估的目標量。保留樣本是從同一個世代裡切出來的,同一批測量、同一套納入條件,族群差異這個解釋被關掉了,剩下的只有「係數是為了讓這批人好看而挑的」。0.029 是 bootstrap 樂觀校正估出來的樂觀量,它與真實差距不相等很正常:那是一個估計,本來就有誤差,而這一頁的整個設計正是為了看各種方法估得準不準。要記住的是樂觀偏誤不是偶爾發生,它在期望值的意義上一定發生,問題只有有多大。
單次切一半給出的 C-index 點估計幾乎正中真值,看起來是五種做法裡最準的。可以據此說切一半是好方法嗎?
看答案與解析
正確答案: 不能。同一份資料換不同的隨機切法重跑,答案的標準差是 0.0298,這一次剛好命中是運氣;判斷一個方法好不好要看它重跑會晃多少
0.0298 是二十次不同切法之間的標準差,而重複交叉驗證是 0.0154,大約一半。一個方法這一次離真值多近,跟它可不可靠是兩件事——同一份資料換一個隨機種子,切一半的答案會在很大的範圍裡跑,全距 0.1379。第二個選項的補救聽起來合理,但「多切幾次取平均」正好就是交叉驗證在做的事,而且切一半每一次只用一半的人訓練、一半的人測試,交叉驗證則讓每個人都當過一次測試集,成本並沒有比較高。第三個選項把「不偏」與「精確」混在一起:一個估計可以平均而言正確、卻每一次都離譜。切一半真正的成本是它同時讓模型變差與讓評估變糊,而它偏偏是最直覺、最常被採用的那一個。
論文寫「10 摺交叉驗證後的 C-index 是 0.655」,沒有說重複了幾次。這一頁的哪一個數字最該讓你對那第二位小數保持懷疑?
看答案與解析
正確答案: 同一份資料只換分組重跑,單次最高 0.668、最低 0.608,晃動蓋過了想比較的模型差異
0.668 與 0.608 是同一份資料、同一段程式,只換交叉驗證的隨機分組跑出來的兩端。那個範圍比多數論文想宣稱的模型差異還大,所以一個沒有說重複幾次的 0.655 並沒有指向一個穩定的量。0.646 是重複之後的平均,它比這一次的單次結果低沒錯,但「單次永遠偏高」不成立——單次可以落在範圍的任何一處。0.015 確實是重複執行之間的標準差,可是把它讀成「所以夠穩」是誤判尺度:它已經足以造成 0.608 到 0.668 這樣的跨度,而那個跨度比多數論文想宣稱的差異還大。
Bootstrap 樂觀校正的第二步寫著「在 bootstrap 樣本上從頭配一次模型」。如果變數選擇是在原始資料上做完、然後固定下來,會發生什麼?
看答案與解析
正確答案: 在全部資料上選一次、然後只交叉驗證配適,得到 0.655;選擇沒有進到迴圈裡,每一摺的測試集都參與過那次選擇,所以樂觀偏誤仍然留在裡面
0.655 與 0.600 差得不只一點點:前者的每一摺測試集都參與過那次選擇,所以它們不再是「模型沒看過的資料」;後者每一摺都重跑一次選擇,才真的把選擇的代價算了進去。第二個選項提到的「每一摺只用九成的人訓練」確實存在,也確實讓全迴圈版本略為保守,但它解釋不了這麼大的落差——候選清單裡塞了十個純亂數變項,向後 AIC 留下了其中兩個,而它們之所以會被選中,正是因為在這兩百人身上剛好有一點相關。0.678 是選完之後在同一批人身上量的表面表現,它是三個數字裡最樂觀的一個,不是被低估的那一個。這條規則也不只適用於變數選擇:切點的選擇、懲罰參數的交叉驗證、依結果做的離群值排除,任何看過結果變項才做的決定都必須進到迴圈裡。
把變數選擇放進每一摺之後,交叉驗證給的 0.600 反而低於保留樣本上的真值 0.635。這是錯誤嗎?
看答案與解析
正確答案: 不是。0.635 估的是「這一次實際挑出來的那個模型有多好」,全迴圈交叉驗證估的是「讓演算法自己挑,平均會得到多好」——兩者在回答不同的問題
0.635 是這一次挑出來的那個模型在兩千多個沒被抽到的人身上的表現;0.600 是同一個建模流程平均值多少,而且每一摺只用九成的人訓練,所以還略為保守。兩個問題不同,答案不同不構成錯誤。第一個選項說的「重複懲罰」並不存在——每一摺的選擇都只用該摺的訓練資料重跑一次,沒有任何一個變項被算兩次。第三個選項挑的 0.655 恰恰是本頁在示範的那個錯誤做法:它的每一摺測試集都參與過選擇,數字比較高不是因為它平衡,是因為它還沒把選擇的代價算進去;而且它其實高於保留樣本上的真值 0.635,不是任何意義上的中間值。實務上該相信哪一個?如果你打算把這個特定模型發表出去,你需要的是外部驗證——內部驗證能告訴你的是建模流程大概值多少,不是這次的手氣多好。
在兩百人的開發資料裡,八個真實變項之外又塞進十個純亂數變項,向後 AIC 從十八個候選裡留下六個。哪一個說法對?
看答案與解析
正確答案: 留下的六個裡有 2 個是亂數;它們被選中是因為在這兩百人身上剛好有一點相關
被留下的六個變項裡有兩個是亂數——它們在這兩百人身上剛好有一點相關,如此而已。真實變項比亂數多不能拿來說「大致分得出真假」:亂數變項的真實貢獻是零,只要有任何一個進得了最終模型,這個流程就已經在學這批人的雜訊了,而後面的驗證若不把選擇一起重跑,那份雜訊會被算成模型的本事。候選比留下的多也不代表保守:篩選本身就在消耗自由度,砍得愈多、用掉的自由度愈多,而那些被砍掉的候選同樣要算進樣本數計算的參數個數裡。
用到這個方法的章節
延伸觀看
SEER 數據之臨床預測模型 課時10 模型驗證
AI 臨床研究實戰 EP7|預測 vs 分類、Data Leakage、Propensity Score素材來源與授權
本頁為原創內容