外部驗證
內部驗證只能處理樂觀偏誤,處理不了「換一群人」。這一頁把同一個乳癌模型放進三種驗證設計——隨機切一半、依手術年份切、以及真正的外部世代——前兩種的校準斜率都落在 1 附近,只有真正的外部世代把它拉到 0.69。也講驗證研究自己的樣本數:事件數少於一百時,校準斜率的信賴區間寬到沒有辦法下結論。
內部驗證做不到的那一半
內部驗證那一頁處理的是樂觀偏誤: 係數是為了讓這批資料好看而挑的,所以在這批資料上量出來的表現偏高。 bootstrap 與交叉驗證把那個差額估出來扣掉。
但它們有一個共同的前提:新的人跟舊的人來自同一個分布。 重抽樣只能重抽你已經有的人;它變不出另一家醫院的轉診模式、另一個年代的治療準則、 另一個國家的檢驗方法。
外部驗證問的是另一個問題:換一群人,這個模型還算得準嗎?
三種外部,強度不同
| 類型 | 怎麼分 | 它在測什麼 | 強度 |
|---|---|---|---|
| 時間性(temporal) | 同一個機構,用日期切開:早期的人開發、晚期的人驗證 | 族群隨時間的漂移、治療準則的改變 | 最弱,但也最容易做 |
| 地理性(geographic) | 不同醫院、不同地區、不同國家 | 轉診模式、疾病嚴重度組成、檢驗方法的差異 | 中等到強 |
| 領域性(domain) | 不同照護層級或不同適應症:醫學中心的模型拿到基層、成人的模型拿到老年 | 模型在一個它原本不是為了誰而設計的族群裡 | 最強,也最常失敗 |
「切一半」不是外部驗證
這是預測模型論文裡最常見的誤標。把資料隨機切成兩半、一半開發一半驗證, 然後在標題或摘要寫 external validation。
它為什麼不是:隨機切開的兩半,照定義來自同一個分布。 你測到的仍然只有樂觀偏誤,而且測得比交叉驗證還糟 (見上一頁:訓練與測試同時被弄小)。
把它做出來。同一個 4 變項的 Cox 模型,三種驗證設計:
| 設計 | 算外部嗎 | 驗證組人數 | 事件數 | C-index | 校準斜率(95% CI;隨機切分列為 P10–P90) | 平均預測 5 年風險 | 實際觀察 |
|---|---|---|---|---|---|---|---|
| 表面表現(開發世代自己) | 否 | 2982 | 1713 | 0.666 | 1(依定義) | 43.8% | 43.6% |
| 隨機 50/50 切分(200 次摘要) | 否 | 依切分而異 | 依切分而異 | 0.665 ± 0.007 | 0.996(P10–P90:0.856–1.128) | 依切分而異 | 依切分而異 |
| 依手術年份切(時間性) | 勉強算 | 1815 | 914 | 0.672 | 1.050(0.942–1.159) | 45.0% | 41.4% |
| 真正的外部世代 | 是 | 686 | 299 | 0.642 | 0.691(0.539–0.842) | 43.7% | 50.8% |
figures/scripts/B5-05-external-validation.R驗證要報三件事,不是一件
| 指標 | 它回答什麼 | 只報它會漏掉什麼 | |
|---|---|---|---|
| 鑑別力 | C-index / AUC(那一頁) | 模型有沒有把高風險的人排在低風險的人前面 | 對整條風險刻度的平移與縮放完全免疫 |
| 校準 | 校準曲線、斜率、O/E(那一頁) | 算出來的機率數值對不對 | 不知道模型在臨床上會不會多治或少治 |
| 臨床效用 | 決策曲線、淨效益(那一頁) | 照這個模型做決定,比「全部治」或「都不治」好嗎 | 不知道這個模型值不值得用 |
驗證研究自己也需要樣本數
驗證研究常被當成「拿現成資料跑一下」的小事,所以幾乎沒有人算樣本數。但它一樣會不夠。
把外部世代按人數比例隨機抽小,抽到接近指定的目標事件數, 重複 200 次,看兩個指標的信賴區間有多寬:
| 目標事件數 | 實際抽取人數 | C-index 的 95% CI 平均寬度 | 校準斜率的 95% CI 平均寬度 |
|---|---|---|---|
| 50 | 115 | 0.165 | 0.834 |
| 100 | 230 | 0.116 | 0.559 |
| 150 | 345 | 0.094 | 0.451 |
| 200 | 459 | 0.082 | 0.377 |
| 299 | 686 | 0.067 | 0.303 |
抽的是人不是事件,所以第一欄是目標值:每一次重複實際拿到幾個事件會在目標附近浮動。 最後一列(299 個事件、686 人)就是完整的外部世代, 200 次重複拿到的都是同一批人,那一列沒有抽樣變異。
校準壞掉之後怎麼辦
不是丟掉模型。多數情況下該做的是重新校準(recalibration), 從只調基準風險到重估全部係數有三個層次——做法、代價與陷阱在 校準那一頁。
要留意的是重新校準之後必須再驗證一次,而且不能在同一批人身上驗。
動手跑一次
library(survival)
data(cancer, package = "survival")
# 兩份世代的欄位定義不同,調和的每一個決定都會影響結果(見 D5 那一章)
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)
ext <- gbsg
ext$rfs_time <- ext$rfstime
ext$rfs_event <- ext$status
ext$size_mm <- ext$size
ext$grade3 <- as.integer(ext$grade >= 3)
v <- c("age", "size_mm", "nodes", "grade3")
fit <- coxph(Surv(rfs_time, rfs_event) ~ age + size_mm + nodes + grade3, data = rot)
# 把模型搬過去:線性預測值自己算,不要用 predict() 的置中版本
lp <- as.numeric(as.matrix(ext[, v]) %*% coef(fit))
# 三件套之一:鑑別力
concordance(Surv(ext$rfs_time, ext$rfs_event) ~ lp, reverse = TRUE)
# 三件套之二:校準斜率(1 才是對的)
cal <- coxph(Surv(rfs_time, rfs_event) ~ lp, data = ext)
coef(cal); confint(cal)
# 平均預測 vs 實際觀察(校準的第一層)
bh <- basehaz(fit, centered = FALSE)
h0 <- approx(bh$time, bh$hazard, xout = 5 * 365.25, rule = 2)$y
mean(1 - exp(-h0 * exp(lp)))
km <- survfit(Surv(rfs_time, rfs_event) ~ 1, data = ext)
1 - summary(km, times = 5 * 365.25, extend = TRUE)$surv
# 對照組:隨機切一半的「外部驗證」看起來會是什麼樣子
i <- sample(nrow(rot), nrow(rot) %/% 2)
f2 <- coxph(Surv(rfs_time, rfs_event) ~ age + size_mm + nodes + grade3, data = rot[i, ])
te <- rot[-i, ]
lp2 <- as.numeric(as.matrix(te[, v]) %*% coef(f2))
coef(coxph(Surv(rfs_time, rfs_event) ~ lp2, data = te)) # 幾乎一定接近 1驗證環境:R 4.6.0 + survival 3.8.6。concordance() 的 reverse = TRUE 是必要的:Cox 的線性預測值愈大代表風險愈高、存活愈短,方向與預設相反。校準斜率是把驗證世代的線性預測值當成唯一的預測變項重配一次 Cox,那個係數就是斜率。
import numpy as np, pandas as pd
from lifelines import CoxPHFitter, KaplanMeierFitter
from lifelines.utils import concordance_index
RD = "https://vincentarelbundock.github.io/Rdatasets/csv/"
rot = pd.read_csv(RD + "survival/rotterdam.csv")
ext = pd.read_csv(RD + "survival/gbsg.csv")
# 與上面 R 端相同的衍生欄位。rotterdam 原始欄位沒有這些,少了這一段
# 下面每一行都會 KeyError。
rot["rfs_time"] = rot[["rtime", "dtime"]].min(axis=1)
rot["rfs_event"] = ((rot["recur"] == 1) | (rot["death"] == 1)).astype(int)
rot["size_mm"] = rot["size"].map({"<=20": 15, "20-50": 35, ">50": 60})
rot["grade3"] = (rot["grade"] >= 3).astype(int)
ext["rfs_time"] = ext["rfstime"]
ext["rfs_event"] = ext["status"]
ext["size_mm"] = ext["size"]
ext["grade3"] = (ext["grade"] >= 3).astype(int)
v = ["age", "size_mm", "nodes", "grade3"]
fit = CoxPHFitter().fit(rot[v + ["rfs_time", "rfs_event"]], "rfs_time", "rfs_event")
lp = ext[v].to_numpy() @ fit.params_[v].to_numpy()
# 鑑別力(注意負號)
print(concordance_index(ext["rfs_time"], -lp, ext["rfs_event"]))
# 校準斜率:在驗證資料上以 lp 為唯一共變數再配一次
tmp = ext[["rfs_time", "rfs_event"]].copy(); tmp["lp"] = lp
cal = CoxPHFitter().fit(tmp, "rfs_time", "rfs_event")
print(cal.params_["lp"], cal.confidence_intervals_.loc["lp"].to_list())
# 整體風險:預測平均 vs Kaplan-Meier
km = KaplanMeierFitter().fit(ext["rfs_time"], ext["rfs_event"])
print(1 - float(km.predict(5 * 365.25)))lifelines 可以做同樣的事;注意 concordance_index 吃的是存活時間的順序,所以線性預測值要取負號。校準斜率則是在驗證資料上以 lp 為唯一共變數再配一次 Cox。
讀論文時要問的五件事
- 哪一種外部? 時間、地理、還是領域。摘要常常只寫 external validation。
- 是不是切一半? 看 Methods 的資料來源;同一份資料切開就是內部驗證。
- 驗證團隊是誰? 獨立團隊做的驗證說服力高得多。
- 三件套報了幾件? 只有 C-index 的等於只報了三分之一。
- 驗證世代有幾個事件? 少於一百的話,校準的結論不論正負都要打折看。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 把隨機切一半叫外部驗證 | 兩半來自同一個分布,只測得到樂觀偏誤 |
| 外部驗證只報 C-index | 校準可以整個崩掉而 C-index 幾乎不動 |
| 因為時間性驗證通過就宣稱模型可移植 | 它只測得到這段期間有沒有漂移,測不到跨機構差異 |
| 驗證世代只有幾十個事件卻下結論 | 校準斜率的信賴區間會寬到 0.7 與 1.0 分不開 |
| 校準壞了就宣布模型失敗 | 多數情況重新校準即可,尤其只是整體風險水準偏移時 |
| 重新校準後在同一批人身上報告改善 | 那是在自己調過的資料上量自己,樂觀偏誤又回來了 |
| 驗證時重新估係數再說「模型表現良好」 | 重估係數等於開發了一個新模型,不是驗證舊的 |
| 兩個世代的變項定義沒對齊就直接套用 | 調和決定會改變結果,而且不會出現在任何檢定裡 |
| 假設外部表現差一定是模型不好 | 也可能是資料調和沒做好,或驗證世代的測量方式不同 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B5-05-external-validation.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
把同一份世代隨機切成兩半、一半開發一半驗證,重複兩百次,校準斜率平均 0.996。這個接近 1 的平均值代表什麼?
看答案與解析
正確答案: 0.996 貼在 1 上是設計本身保證的,不是模型好:隨機切開的兩半照定義來自同一個分布,這個做法測得到的只有樂觀偏誤
0.996 是兩百次不同切法的平均。平均值貼在 1 上是這個設計的必然結果——兩半來自同一個分布,模型在其中一半學到的東西搬到另一半當然還適用。0.107 這個標準差並不小:只有六成五的切分落在 1 附近的一段窄區間裡,所以單獨挑一次切分來報告,斜率可以落在很寬的範圍。至於 0.691,那不是異常值,那正是這一頁要示範的東西:換一個國家的世代,同一個模型的校準斜率就掉到那裡去。把隨機切一半在標題或摘要寫成 external validation,是預測模型論文裡最常見的誤標。
依手術年份切開之後,時間性驗證的校準斜率是 1.050,信賴區間含 1。這說明什麼?
看答案與解析
正確答案: 區間從 0.942 一路到上界,寬兩成多——含 1 只代表沒偵測到漂移,中等幅度的漂移排除不掉
1.050 的信賴區間下界 0.942、上界 1.159,寬度兩成多。含 1 的正確讀法是「沒有偵測到」,不是「沒有發生」——這個寬度容得下中等幅度的漂移。而且時間性驗證測的是同一個機構裡族群與治療準則隨時間的變化,它結構上就看不到轉診模式、疾病嚴重度組成、檢驗方法在不同機構之間的差異,所以「時間上穩定」推不出「可以移植」。0.691 也不是 gbsg 有問題的證據:同一份資料換一個年代沒有偵測到明顯偏離,換一個國家就把斜率打到那裡,這正好凸顯了地理差異的量級。
三種驗證設計的 C-index 都落在 0.642 到 0.672 這個窄範圍裡,而校準斜率從 1.050 一路變到 0.691。這對「只報 C-index 的外部驗證論文」說了什麼?
看答案與解析
正確答案: 外部世代的 C-index 0.642 與另外兩種設計幾乎沒有差別,但它的校準已經明顯偏掉——鑑別力只問排序
0.642、0.665、0.672 三個數字擠在一起,而同一個模型的校準斜率從 1.050 走到 0.691。C-index 只問「模型有沒有把高風險的人排在低風險的人前面」,把每個人的預測風險同乘一個常數、或整條加上一個常數,排序完全不變,C-index 一動也不動。所以「鑑別力最好所以最值得採信」是拿一個看不到問題的指標去挑設計;而 0.665 落在中間也證明不了 C-index 敏感——三個值的差距本來就小到不足以支持任何結論。驗證要報三件事:鑑別力、校準、臨床效用,順序不能顛倒,也不能只挑一件報。這個例子裡只報 C-index 會得出「模型移植得不錯」的結論,而實際上它系統性低估了風險。
把外部世代隨機抽小到只剩五十個事件,校準斜率的 95% 信賴區間平均寬 0.834。這個寬度的實際後果是什麼?
看答案與解析
正確答案: 區間寬 0.834 表示一個明顯偏低的斜率與一個完全正常的斜率在這個樣本量下分不開
0.834 的寬度足以讓區間兩端同時容納「預測值太極端」與「校準正常」兩種相反的結論,所以那張圖畫得出來但不能支持任何結論。0.165 那個 C-index 區間確實窄得多,而這正是另一個「只報 C-index 會過度樂觀」的理由:在同一份小樣本上,鑑別力看起來是穩的,校準其實根本沒測準。0.303 是完整外部世代的寬度,把兩者的差別說成「只是精確度高低」,等於忽略了結論能不能下這件事——方法學文獻建議驗證世代至少一百個事件、最好兩百個以上,理由就在這張表上。
在真正的外部世代上,模型算出來的平均五年風險是 0.437,Kaplan-Meier 量到的是 0.508。這個落差屬於哪一層問題?
看答案與解析
正確答案: 實際觀察到 0.508,比模型給的平均高,那是整體風險水準的偏移,也就是校準的第一層;它與斜率是兩個不同層次的問題,重新校準時先修的通常就是這一層
0.508 對上 0.437,模型系統性地低估了外部世代的風險,那是整條刻度的平移——校準的第一層,不是斜率那一層。開發世代的平均預測 0.438 與外部世代的預測值接近,恰恰說明「模型搬過去之後沒有變」是對的,但那正是問題:模型沒變,人變了,而模型不知道。C-index 0.642 也解釋不了它——鑑別力只管排序,把每個人的預測風險一律往下壓一截,排序完全不變,C-index 一動也不動,平均值卻整個掉下來。追蹤長度也解釋不了這個落差——兩邊比的都是固定五年這個時間點的風險,Kaplan-Meier 已經把設限處理掉了,追蹤久一點只會讓那個估計更穩,不會讓它系統性地高於預測值。校準壞掉不等於模型失敗,多數情況該做的是重新校準,只是重新校準之後必須再驗證一次,而且不能在同一批人身上驗。
校準曲線上,時間性驗證的五個分位裡,低的三組實際觀察值落在預測值下方、高的兩組落在上方;真正外部世代的五個分位則全部落在預測值上方。這個形態上的差別為什麼重要?
看答案與解析
正確答案: 外部世代最低風險那一組被預測 0.287,實際觀察到的比它高,其餘四組同樣是預測低於觀察——五個分位同方向偏離才叫系統性
要看的是方向一不一致,不是有沒有偏離。時間性驗證那條線在對角線兩側都有點:0.238 這一組落在預測值下方,最高的兩組(其中一組是 0.714)反而落在上方,偏離沒有收斂到同一邊,而它的校準斜率信賴區間也含 1。外部世代則是五個分位一路同方向:最低風險組被預測 0.287、實際更高,往上每一組同樣是預測低於觀察,那是整條刻度被往下壓。所以「兩者說的是同一件事」把「有偏離」誤當成「同一種偏離」;而拿最高風險那一組的方向去代表整條線,正是只看單一點會犯的錯——五個分位裡挑一個方向剛好一致的點,幾乎任何兩條曲線都找得到。校準要看整條曲線而不是只看一個摘要數字:曲線的形狀告訴你偏離是全域的平移、是斜率的縮放,還是只發生在某一段風險區間,而重新校準要修哪一層取決於這件事。
用到這個方法的章節
延伸觀看
VALIDATING PREDICTION MODELS – what is discrimination and calibration?
Karel Moons | Validating Medical Predictive Models | Philosophy of Data Science素材來源與授權
- Calibration: the Achilles heel of predictive analyticsCC BY本頁只引用該文對「校準曲線所需樣本數」的建議(有事件與無事件各兩百人);其餘內容為原創,所有數字皆為本站在 rotterdam/gbsg 上自行計算。