進階已經雙重審閱,尚未人工抽查

內部驗證與樂觀偏誤

在配模型的同一批資料上量出來的表現一定偏好,這個差距叫樂觀偏誤。這一頁從一份大世代裡切出兩百人當開發資料,把四種內部驗證方法都跑一遍,再拿剩下的兩千多人當真值對答案。最後示範這個家族最貴的一個錯誤:先在全部資料上選變數、再交叉驗證後面的配適。

表面表現為什麼一定偏好

配模型的過程就是挑一組係數讓這批資料看起來最好。挑完之後再回頭問「這組係數在這批資料上表現如何」, 等於讓考卷的作者去考同一份考卷。

這個差距叫樂觀偏誤(optimism):

optimism=表面表現在新資料上的表現\text{optimism} = \text{表面表現} - \text{在新資料上的表現}

它不是偶爾發生,而是期望值意義上一定發生。問題只有「有多大」,而它的大小取決於 模型複雜度與樣本量的關係(見收縮那一頁)。

內部驗證要回答的就是這件事:只用手上這批資料,把樂觀偏誤估出來扣掉。

這一頁的設計:造一個知道答案的情境

內部驗證方法的比較有一個天然的困難——你不知道正確答案是多少。 這裡用 survival::rotterdam 繞過它:

  • 從 2982 人裡抽 200 人當開發資料 (115 個事件、8 個預測變項,EPV 14.4)
  • 所有內部驗證方法都只准用這 200 人
  • 剩下的 2782 人當真值:同一個世代、同一批測量、模型從沒看過

真值是 C-index 0.648,表面表現是 0.691—— 差距 0.043。這就是各種內部驗證方法要估出來的量。

四種方法,一起對答案

兩張長條圖。左圖比較五種做法給出的 C-index,一條水平虛線標出保留樣本上的真值;表面表現明顯高於虛線,切一半那一根旁邊疊了 20 個空心圓點顯示重複切分的離散程度,交叉驗證與 bootstrap 校正都靠近虛線。右圖比較變數選擇擺在不同位置時的 C-index:選完的表面表現最高,在全部資料上選一次再交叉驗證次之,把選擇放進每一摺明顯最低,真值落在中間。
左:同一份開發資料上五種做法的答案,紅色虛線是保留樣本量出來的真值。切一半那一根上疊的圓點是 20 次不同隨機切分的結果。右:變數選擇擺在重抽樣迴圈內外的差別(本頁最後一節)。產圖腳本 figures/scripts/B5-04-internal-validation.R
做法給出的 C-index重複執行的標準差與真值的差
表面表現(完全不做驗證)0.6908+0.0432
單次切一半0.64650.0298-0.0011
10 摺交叉驗證0.6551+0.0075
重複 10 摺交叉驗證0.64630.0154-0.0013
Bootstrap 樂觀校正0.6623+0.0146

Bootstrap 樂觀校正:完整的步驟

這是 Harrell 提出的做法,也是 rms 套件 validate() 背後的邏輯。它的巧妙之處在於 不需要保留任何資料——全部 200 人都拿去配模型,樂觀偏誤另外估。

重複 200 次:

  1. 從原始資料可重複抽取同樣人數,得到一個 bootstrap 樣本
  2. 在這個 bootstrap 樣本上從頭配一次模型(包括所有變數選擇、所有調參)
  3. 量這個模型在 bootstrap 樣本自己身上的表現 → 樂觀的那個值
  4. 同一個模型原始資料上的表現 → 比較誠實的那個值
  5. 兩者相減 = 這一次的樂觀量

平均起來就是樂觀偏誤的估計,從表面表現扣掉:

C^corrected=C^apparentoptimism\hat{C}_{\text{corrected}} = \hat{C}_{\text{apparent}} - \overline{\text{optimism}}

在這份資料上:表面 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。

這一頁的重點:選變數必須在迴圈裡面

這是預測模型研究裡最貴、也最難自己發現的一個錯誤。

流程長這樣(很多論文就是這樣做的):

  1. 在全部開發資料上跑變數選擇,得到「最終模型」
  2. 這個最終模型做交叉驗證
  3. 報告交叉驗證後的 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 樂觀校正,但它同樣只驗證你寫在公式裡的那個模型——選擇步驟仍要自己包進去。這裡拆開寫,是為了讓那五個步驟看得見。

讀論文時要問的五件事

  1. 內部驗證用的是哪一種? 只寫「已驗證」而不說方法的,等於沒說。
  2. 交叉驗證重複了幾次? 單次 k 摺的第二位小數不穩。
  3. 變數選擇有沒有進到重抽樣裡? Methods 通常不會直接寫; 看它是不是先報「最終模型納入 X 個變項」再說「接著對該模型做交叉驗證」——那個順序就是答案。
  4. 內部驗證通過了,然後呢? 內部驗證處理的是樂觀偏誤, 完全不處理族群差異。那是外部驗證的事。
  5. 報的是哪一個時點的區辨力? 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 模型驗證
簡中Bessie Hiram· 13 min簡中,把內部驗證的操作流程走一遍;術語的中文說法在這裡有對照。
AI 臨床研究實戰 EP7|預測 vs 分類、Data Leakage、Propensity Score
繁中Colon & Code· 10 min繁中,data leakage 那一段與本頁最後一節是同一個機制:任何用到全部資料的步驟都必須進到重抽樣迴圈裡。

素材來源與授權

本頁為原創內容

回報內容問題

這個站的統計內容由 AI 撰寫、AI 互審,人工只做抽查。你看得出來的錯,我們不一定看得出來。

寫得越具體越修得動,例如哪一句話跟哪本教科書/哪篇論文的說法不一致。

留了才回得了信;不留也會看。

一併送出的資訊

這些是自動帶上的,每一項都可以取消。