校準
校準問的是「模型說 30% 的那群人,是不是真的有三成出事」。它比鑑別力更難修、更常被略過,而且只要臨床決策綁在一個絕對機率門檻上,它就比鑑別力更重要。這一頁用一個真實的外部驗證把校準的四個層次逐一量出來,畫出彈性校準曲線,再把三個層次的重新校準各做一次——並且在另一半病人身上檢查它們是不是真的有效。
為什麼校準比鑑別力更值得擔心
鑑別力(discrimination)問的是排序:模型有沒有把出事的人排在沒出事的人前面。 校準(calibration)問的是數值:模型說 30% 的那群人,是不是真的有三成出事。
只要臨床決策長成「排序然後挑前面幾個」(誰先做電腦斷層、器官怎麼分配),鑑別力就夠。 但只要決策綁在一個絕對機率門檻上——「十年心血管風險超過 20% 就給 statin」、 「復發風險低於某個數就不加輔助治療」——校準不良的模型會系統性地把病人分錯邊, 而 C-index 完全看不出來。
還有一個現實層面的理由:校準比鑑別力更容易壞,也更容易修。 族群的基準風險會因為轉診模式、篩檢政策、治療進展而改變, 而這些改變幾乎不會改變變項之間的排序關係。所以外部驗證最常見的畫面就是本頁的畫面—— 在此驗證世代,C-index 為 0.642;但平均預測五年風險比觀察風險低 7.21 個百分點(O/E 1.165), 校準斜率為 0.691(95% CI 0.539–0.842)。
四個層次,愈後面愈嚴格
| 層次 | 要求 | 怎麼量 | 什麼時候只做到這裡就好 |
|---|---|---|---|
| 一、平均校準 (mean / calibration-in-the-large) | 平均預測風險 = 整體發生率 | 兩個數字相比,或 O/E 比值 | 樣本非常小時,這是唯一還算得準的東西 |
| 二、弱校準 (weak) | 整體不高估也不低估,而且風險差異的幅度也對 | 校準截距(目標 0)與校準斜率(目標 1) | 驗證世代只有幾百人時的合理上限 |
| 三、中度校準 (moderate) | 預測 10% 的那群人,實際就有一成出事——每一段風險都要對 | 彈性校準曲線(loess 或樣條) | 驗證世代夠大時的標準要求 |
| 四、強校準 (strong) | 任意一組預測變項值的組合都要對 | 實務上做不到 | 不要以它為目標;它是理論上的極限,不是驗收標準 |
在真實資料上逐層量一次
模型在 survival::rotterdam(2982 人)開發,
在 survival::gbsg(686 人、299 個事件)驗證,
時間視野 5 年。鑑別力是 C-index 0.642。
第一層:平均校準
平均預測風險 43.6%, 實際觀察(Kaplan-Meier)50.8%。 O/E 比值 1.165,差距 7.21 個百分點。
O/E 大於 1 代表這群人出的事比模型預期的多,也就是模型低估了風險。
第二層:弱校準
校準斜率 0.691(95% CI 0.539–0.842)。
存活模型沒有一個字面上的「截距」,它的對應物是基準累積風險。 把斜率固定在 1、只讓基準風險自由,新的基準風險是原來的 1.211 倍,取對數是 0.192——這個數字扮演的角色就是校準截距, 正值代表低估。
第三層:中度校準
figures/scripts/B5-06-calibration.R| 預測風險五分位 | 人數 | 平均預測 | 實際觀察 | 差距 |
|---|---|---|---|---|
| 第 1 組 | 138 | 28.6% | 36.4% | 7.8 個百分點 |
| 第 2 組 | 137 | 33.2% | 44.4% | 11.2 個百分點 |
| 第 3 組 | 137 | 38.7% | 46.0% | 7.3 個百分點 |
| 第 4 組 | 137 | 47.2% | 55.1% | 7.9 個百分點 |
| 第 5 組 | 137 | 70.4% | 72.1% | 1.7 個百分點 |
校準斜率小於 1 代表什麼
斜率是把驗證世代的線性預測值當成唯一的預測變項重配一次模型,得到的那個係數。
- 斜率 = 1:模型給出的風險差異幅度剛好。
- 斜率 < 1:風險差異太極端——高風險的人被推得太高、低風險的人被壓得太低。
- 斜率 > 1:反過來,模型給出的差異太保守、太集中在平均值附近。
小於 1 是外部驗證裡最常見的形態,它有兩個來源,而且校準斜率本身分不出是哪一個:
- 開發時的過度配適——係數本來就太大(見收縮那一頁)
- 兩個族群的風險結構不同——某個預測變項在新族群裡的效應本來就比較小
不要用 Hosmer-Lemeshow 檢定
它是最常出現在論文裡的「校準檢定」,而方法學文獻建議不要再用它。三個理由:
- 它靠人為分組——組數與切法會改變結果。
- 它給的 p 值不帶方向也不帶幅度——不顯著不代表校準好,只代表沒偵測到偏離; 顯著也不告訴你是高估還是低估、差多少。
- 它的檢定力很低——樣本小的時候幾乎一定不顯著,於是「p 值不顯著」被寫成「校準良好」, 而那正好是樣本不足時最不該下的結論。
那 calibration belt 呢
它在義大利與重症醫學文獻裡很常見(GiViTI calibration belt,R 套件 givitiR),
形式上是把觀察結果對預測風險配一條多項式,在整段預測風險上畫出那條曲線的
同時信賴區域(simultaneous confidence region),再從那個區域導出一個 p 值——
區域在任何一段離開對角線就顯著。
它與這一頁第三層的彈性校準曲線問的是同一個問題,但它多給的不只是一個 p 值, 而是曲線本身的不確定性——而這一頁的圖完全沒有畫這一層。 上面那條 spline 曲線是點估計,旁邊沒有任何帶子, 所以讀者無法分辨圖上那個凹陷是真的校準偏離,還是幾百個事件配出來的 spline 在晃。 belt 的加值就落在這個缺口上。p 值只是那個區域的副產品,而上面三點勸退的是那個副產品。
這一頁沒有畫 belt,理由不是「belt 沒有加值」。 一部分是範圍:
這一頁講的是四個層次與重新校準,而「彈性曲線的不確定性」這一層,本頁根本沒有畫——
上面那個校準斜率是有給信賴區間的,但曲線本身只是一條點估計。
另一部分是對不上——belt 在原始文獻與 givitiR 裡都是為二元結果與其預測機率定義的,
而這一頁驗證的是帶設限追蹤的 Cox 模型,這份驗證世代裡有相當一部分人根本沒被追滿五年。
把它壓成「五年有沒有事件」的二元變項,等於不是把這些人丟掉、就是把他們算成沒事件。
所以看到論文報 belt 不必反對,但要看的是那個區域在哪一段離開對角線、離開多少,不是旁邊那個 p。
重新校準:三個層次
校準壞掉的預設反應不是丟掉模型,是更新它。從輕到重:
| 層次 | 改什麼 | 需要多少驗證資料 | 改得動什麼 |
|---|---|---|---|
| 一、只更新基準風險 | 整條風險刻度平移,係數與斜率全部不動 | 最少 | 只修平均校準(第一層) |
| 二、更新基準風險與斜率 | 再多一個參數,把風險差異的幅度一起縮放 | 中等 | 修到弱校準(第二層) |
| 三、重估全部係數 | 等於在新族群重新開發一次 | 最多——資料不夠就只是把過度配適搬個家 | 可能改變鑑別力 |
在整個驗證世代上各做一次:
| 做法 | C-index | 校準斜率 | 平均預測 | 實際觀察 | O/E |
|---|---|---|---|---|---|
| 不更新 | 0.642 | 0.691 | 43.6% | 50.8% | 1.165 |
| 只更新基準風險 | 0.642 | 0.691 | 49.4% | 50.8% | 1.029 |
| 更新基準風險與斜率 | 0.642 | 1.000 | 51.3% | 50.8% | 0.990 |
| 重估全部係數 | 0.651 | 1.000 | 51.5% | 50.8% | 0.988 |
不論做哪一層,更新之後都需要再驗證一次,而且不能在拿來更新的那批人身上驗。
動手跑一次
library(survival); library(splines)
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)
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")
H <- 5 * 365.25
fit <- coxph(Surv(rfs_time, rfs_event) ~ age + size_mm + nodes + grade3, data = rot)
# Breslow 基準累積風險,自己寫。理由見上面的紅字提醒
breslow_h0 <- function(time, event, lp, h) {
o <- order(time); time <- time[o]; event <- event[o]; r <- exp(lp[o])
sum(1 / rev(cumsum(rev(r)))[event == 1 & time <= h])
}
lp_dev <- as.matrix(rot[, v]) %*% coef(fit)
h0 <- breslow_h0(rot$rfs_time, rot$rfs_event, lp_dev, H)
lp <- as.numeric(as.matrix(ext[, v]) %*% coef(fit))
pred <- 1 - exp(-h0 * exp(lp))
# 第一層:平均校準
km <- survfit(Surv(rfs_time, rfs_event) ~ 1, data = ext)
obs <- 1 - summary(km, times = H, extend = TRUE)$surv
c(mean_predicted = mean(pred), observed = obs, OE = obs / mean(pred))
# 第二層:斜率
cal <- coxph(Surv(rfs_time, rfs_event) ~ lp, data = ext)
coef(cal); confint(cal)
# 第三層:彈性校準曲線
flex <- coxph(Surv(rfs_time, rfs_event) ~ ns(lp, df = 3), data = ext)
h0f <- breslow_h0(ext$rfs_time, ext$rfs_event,
as.numeric(predict(ns(lp, df = 3), newx = lp) %*% coef(flex)), H)
g <- seq(quantile(lp, .02), quantile(lp, .98), length.out = 100)
obs_curve <- 1 - exp(-h0f * exp(as.numeric(predict(ns(lp, df = 3), newx = g) %*% coef(flex))))
pred_curve <- 1 - exp(-h0 * exp(g))
plot(pred_curve, obs_curve, type = "l", xlim = c(0, 1), ylim = c(0, 1)); abline(0, 1, lty = 2)
# 重新校準第一層:只換基準風險
h0_new <- breslow_h0(ext$rfs_time, ext$rfs_event, lp, H)
mean(1 - exp(-h0_new * exp(lp))) # 現在應該貼近 obs驗證環境:R 4.6.0 + survival 3.8.6。⚠️ 產圖腳本沒有用 basehaz() 去算重新校準後的基準風險,而是自己寫了 Breslow 估計式:當 coxph 模型帶 offset 而沒有共變數時,basehaz() 回傳的是「平均 offset 處」的累積風險,centered = FALSE 也不會還原,在這份資料上會把基準風險灌大將近四倍。
import numpy as np, pandas as pd
from lifelines import CoxPHFitter, KaplanMeierFitter
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"]; H = 5 * 365.25
fit = CoxPHFitter().fit(rot[v + ["rfs_time", "rfs_event"]], "rfs_time", "rfs_event")
lp = ext[v].to_numpy() @ fit.params_[v].to_numpy()
def breslow_h0(time, event, lp, h):
o = np.argsort(time); t, e, r = time[o], event[o], np.exp(lp[o])
at_risk = np.cumsum(r[::-1])[::-1]
return np.sum(1.0 / at_risk[(e == 1) & (t <= h)])
lp_dev = rot[v].to_numpy() @ fit.params_[v].to_numpy()
h0 = breslow_h0(rot["rfs_time"].to_numpy(), rot["rfs_event"].to_numpy(), lp_dev, H)
pred = 1 - np.exp(-h0 * np.exp(lp))
km = KaplanMeierFitter().fit(ext["rfs_time"], ext["rfs_event"])
obs = 1 - float(km.predict(H))
print(pred.mean(), obs, obs / pred.mean())
tmp = ext[["rfs_time", "rfs_event"]].copy(); tmp["lp"] = lp
print(CoxPHFitter().fit(tmp, "rfs_time", "rfs_event").params_["lp"]) # 校準斜率lifelines 沒有現成的彈性校準曲線;把線性預測值丟進 patsy 或 numpy 產生的樣條基底,再配一次 CoxPHFitter,做法與 R 相同。
讀論文時要問的四件事
- 有沒有校準圖? 只報 C-index 或 AUC 的預測模型論文,等於只交了一半。
- 報的是哪一層? 只報 O/E 是第一層;只報斜率是第二層;有彈性曲線才到第三層。
- 驗證世代有多少事件? 少於一百時,校準的結論不論正負都要打折看。
- 有沒有用 Hosmer-Lemeshow 的 p 值宣稱「校準良好」? 那是不成立的推論。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 只報鑑別力,不報校準 | 排序對了不代表數值對;臨床決策用的是絕對機率 |
| 用 Hosmer-Lemeshow 不顯著宣稱校準良好 | 未達顯著只代表沒偵測到偏離,而且它的檢定力本來就低 |
| 校準截距接近 0、斜率接近 1 就宣稱校準好 | 一段高估、一段低估可以互相抵銷,要看彈性曲線 |
| 在拿來重新校準的同一批人身上報告改善 | 斜率變成 1、O/E 變成 1 是定義上的結果 |
| 驗證樣本只有兩三百人就重估全部係數 | 只是把過度配適從一個族群搬到另一個 |
| 校準壞掉就宣布模型無效 | 多數情況只需要更新基準風險 |
| 期待重新校準改善 C-index | 前兩層不改變排序,C-index 完全不動 |
| 把校準曲線畫到資料稀疏的兩端還照樣解讀 | 那一段是外推,要看底部的分布圖 |
| 以強校準為驗收標準 | 它是理論上的極限,實務上做不到 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B5-06-calibration.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
外部驗證報出 O/E 比值 1.165。這個模型在這群人身上高估還是低估了風險?
看答案與解析
正確答案: 這群人實際觀察到的五年風險是 0.508,高於模型平均預測的,所以模型低估了風險
O/E 的分子是觀察、分母是預期。0.508 是實際觀察到的、0.436 是模型平均預測的,兩者相除就是題幹那個大於 1 的比值——觀察比預期多,代表模型低估。把兩個數字的位置讀反,就會把低估講成高估,而這兩句話的臨床後果剛好相反:低估導致治療不足,高估導致過度治療。0.691 是校準斜率,它量的是風險差異的幅度對不對,不是整體高低;一個模型可以平均校準幾乎完美而斜率遠離 1,反過來也可以。
同一份驗證報出校準斜率 0.691,95% 信賴區間 0.539 到 0.842。哪一個說法對?
看答案與解析
正確答案: 斜率 0.691 小於 1,代表模型把風險差異拉得太開——高的推太高、低的壓太低
斜率是把驗證世代的線性預測值當成唯一的預測變項重配一次得到的係數,它量的是風險差異的幅度,不是整體高低。0.842 是區間上界,整段區間確實都在 1 以下,但那說的是幅度太極端這件事被偵測到了,不是每一個人都被預測得太低——後者是平均校準的話,要看 O/E。0.539 是下界;區間不算窄,可是它整段不含 1,所以說還不能下判斷正好講反了:不精確與沒有偏離是兩回事。斜率小於 1 有兩個來源——開發時過度配適、兩個族群的風險結構不同——而斜率本身分不出是哪一個。
把基準風險換成驗證世代自己的之後,O/E 從 1.165 貼到 1.029。同一張表上的 C-index 會怎麼變?
看答案與解析
正確答案: 還是 0.642,一個字都沒動——同方向平移不改變任何一對病人的先後
只更新基準風險是把整條風險刻度平移,只更新斜率是把線性預測值乘一個常數,兩者都不改變任何一對病人的排序,所以 C-index 完全不動——同一張表的前三列都是 0.642。0.651 是第四列重估全部係數在同一批人身上量到的,那是唯一可能動到鑑別力的一層,而且它在自己調過的資料上量自己。0.639 則是把驗證世代切一半、到另一半量重估全部係數時得到的,反而比不更新還低。重新校準修的是校準,不是鑑別力;看到論文說重新校準之後模型變準了,要問變的是哪一個。
只更新基準風險之後的校準圖上,最高風險那一組的紅點從對角線上方翻到了下方。哪一個說法對?
看答案與解析
正確答案: 那一組更新後的平均預測風險是 0.764,已經高過它實際觀察到的,紅點因此落到對角線下方
更新基準風險只動預測值那一側。觀察到的風險是這批病人真正發生的事,不會因為誰重算了模型而改變——0.721 是更新前後都一樣的那個觀察值,把它讀成被改小了,等於以為重新校準能改寫追蹤結果。更新後最高風險組的預測 0.764 已經超過觀察,紅點因此落到對角線下方;原因是平移對每個人加的量差不多,而斜率仍然小於 1,風險差異被拉得太開這件事一點都沒修到。0.335 是最低風險組更新後的預測,它確實幾乎貼上對角線,但那只說明那一端修好了,推不出其餘各段都沒問題,更推不出最高風險端的翻轉是抽樣波動——那個翻轉有明確的機制,而且方向是可以事先講出來的。
在整個驗證世代上做「更新基準風險與斜率」,事後量到的校準斜率剛好是 1.000。這證明這個更新有效嗎?
看答案與解析
正確答案: 沒有。到沒有參與更新的那一半量,同一個更新的斜率是 1.085,已經超過 1
在自己調過的資料上量自己,斜率被拉到 1.000 是定義上的結果,不是驗證——你調哪一個,哪一個就會被拉到位。誠實的做法是切一半:在一半上做更新、到另一半量,同一個更新的斜率變成 1.085,在半個世代上估出來的斜率自己帶著雜訊,套到另一半就過頭了。至於 0.691 那一列,它是只更新基準風險的結果,而平移本來就不會動到斜率,所以兩列並不矛盾——把沒被改到讀成互相矛盾,會讓人以為表格算錯了。
把驗證世代切一半、到另一半評估,重估全部係數的 C-index 是 0.639,比完全不更新的 0.642 還低。該怎麼寫這句話?
看答案與解析
正確答案: 200 次切分的平均差異是 -0.003,在這個樣本量下未偵測到穩定的鑑別力改善
那兩個百分位數是分布的兩端,不是結論: -0.019 只說最不利的那一成切分長什麼樣,0.011 只說最有利的那一成長什麼樣,各拿一端去推會傷害或多半改善,都是拿尾巴當中央。要看的是整個分布的位置與寬度——平均差異 -0.003,而兩端從 -0.019 橫跨到 0.011,零就在中間,所以正確的敘述是在這個樣本量下未偵測到穩定的鑑別力改善,不是重估全部係數比較差。這也是完整重配的一般處境:它花掉最多資料,而它買到的校準,兩種更便宜的更新本來就給得出來。
用到這個方法的章節
延伸觀看
VALIDATING PREDICTION MODELS – what is discrimination and calibration?
SEER 數據之臨床預測模型 課時09 利用校準圖評價模型素材來源與授權
- Calibration: the Achilles heel of predictive analyticsCC BY本頁的四個校準層次、重新校準的層次區分、以及不建議使用 Hosmer-Lemeshow 檢定的理由,改寫自 Van Calster 等人(BMC Medicine 2019)。文中的 QRISK2 對照為該文引用的已發表數值;其餘所有數字皆為本站在 rotterdam/gbsg 上自行計算。