決策曲線分析
決策曲線問的不是模型準不準,而是「照這個模型做決定,比全部治療或都不治療好嗎」。它把偽陽性與偽陰性的相對代價編碼進一個門檻機率,再算出淨效益。這一頁在真實的外部驗證上做出那個常被提起卻很少被示範的對照——鑑別力較高的模型,在臨床上會用到的門檻附近,淨效益反而較低。
前面兩頁都沒回答的那個問題
AUC告訴你模型排序排得好不好。 校準告訴你算出來的機率數值對不對。 兩者都通過之後,還有一個問題沒被回答:
照這個模型做決定,會比現在的做法好嗎?
而「現在的做法」通常只有兩種:全部都治(或全部都做那個檢查),或都不治。 一個模型如果贏不過這兩條線裡比較好的那一條,它再準也沒有用。
決策曲線分析(decision curve analysis, DCA)就是在同一張圖上比較這三件事。
門檻機率:把臨床判斷寫成一個數字
要說「這個模型有沒有用」,必須先說偽陽性與偽陰性哪一個比較糟、糟多少。 DCA 的做法是讓你選一個門檻機率 :
風險達到 我就治療,低於 我就不治療。
這一句話已經把相對代價講完了。你願意在風險剛好 時開始治療,等於是說 「治療的害處」與「不治療而出事的害處」在這個風險水準上剛好打平。整理之後:
| 門檻機率 | 換算的代價比 | 白話 |
|---|---|---|
| 30% | 0.43 | 漏掉一個真的會出事的人,跟白治 2.3 個不會出事的人一樣糟 |
| 40% | 0.67 | 漏掉一個真的會出事的人,跟白治 1.5 個不會出事的人一樣糟 |
| 50% | 1.00 | 漏掉一個真的會出事的人,跟白治 1.0 個不會出事的人一樣糟 |
| 60% | 1.50 | 漏掉一個真的會出事的人,跟白治 0.7 個不會出事的人一樣糟 |
淨效益:把偽陽性換算成真陽性再相減
淨效益(net benefit)的定義是:
第一項是「每個病人平均抓到幾個真陽性」,第二項是偽陽性按代價比折算成真陽性的當量之後扣掉。
單位因此是「每位病人的淨真陽性數」。乘以 1000 就變成 「每千位病人,用這個模型比什麼都不做多抓到幾個真陽性,而且已經扣掉多治療的代價」。
存活資料要多一步:不能直接數事件(有人在時間視野之前就設限了), 所以 TP 與 FP 的比例用被判為高風險那群人的 Kaplan-Meier 風險去估。
兩條參考線:
- 都不治:誰都不治,沒有真陽性也沒有偽陽性,淨效益恆為 0。
- 全部治:全部都判為陽性,。 門檻愈高它掉得愈快,在門檻等於事件率的地方穿過 0。
本頁的例子:在某些門檻上,鑑別力比較高的那個模型淨效益反而較差
在 survival::gbsg(686 人、299 個事件,
5 年觀察風險 50.8%)上比較三個模型,
全部都在 survival::rotterdam 開發:
| 模型 | 預測變項數 | C-index | 平均預測 5 年風險 |
|---|---|---|---|
| A:八個預測變項,照原樣搬過來 | 8 | 0.6617 | 43.1% |
| A′:同一個模型,在此世代重新校準 | 8 | 0.6617 | 51.5% |
| B:四個預測變項,在此世代重新校準 | 4 | 0.6416 | 51.3% |
模型 A 的 C-index 比模型 B 高 0.020。 但 A 沒有重新校準,它平均預測 43.1%, 而這群人實際發生 50.8%——系統性低估。
figures/scripts/B5-07-decision-curve.R| 門檻 | 全部治 | 模型 A | 模型 A′ | 模型 B | A 判為高風險的比例 | B 判為高風險的比例 |
|---|---|---|---|---|---|---|
| 30% | 0.2977 | 0.3038 | 0.2977 | 0.2977 | 81% | 100% |
| 40% | 0.1806 | 0.1925 | 0.2189 | 0.1966 | 44% | 90% |
| 50% | 0.0167 | 0.0982 | 0.1284 | 0.1218 | 25% | 42% |
| 60% | -0.2291 | 0.0647 | 0.0719 | 0.0617 | 15% | 19% |
誠實的部分:重新校準用掉了同一批人
上面 A′ 與 B 的重新校準都是在同一個世代上做的,然後又在同一個世代上評估。 那是樂觀的(理由與內部驗證那一頁相同)。
把驗證世代切一半:在一半上重新校準模型 B,到另一半量淨效益,重複 200 次 (模型 A 不需要重新校準,所以它兩邊都一樣):
| 門檻 | 模型 A | 模型 B(在另一半上) | 全部治 | B 勝過 A 的比例 |
|---|---|---|---|---|
| 30% | 0.3044 | 0.2996 | 0.2995 | 36% |
| 40% | 0.1937 | 0.1874 | 0.1827 | 47% |
| 50% | 0.0974 | 0.1114 | 0.0192 | 78% |
| 60% | 0.0638 | 0.0561 | -0.2259 | 27% |
怎麼讀一張決策曲線
| 看什麼 | 怎麼判斷 |
|---|---|
| 模型曲線在你的門檻附近,有沒有高過兩條參考線 | 沒有的話,那個門檻下不該用這個模型——直接全部治或都不治比較好 |
| 門檻範圍畫得對不對 | 圖的橫軸應該涵蓋臨床上真的會用到的範圍。橫軸畫到 0.9 但沒人會用那麼高的門檻,那一段是裝飾 |
| 兩個模型的曲線有沒有交叉 | 常常會。交叉代表「哪個好」取決於門檻,不能給單一答案 |
| 「全部治」那條線什麼時候穿過零 | 在門檻等於事件率的地方。本頁的資料在約 51% 處 |
| 曲線抖不抖 | 抖動來自小分組的估計誤差;驗證樣本小的時候不要解讀細部起伏 |
動手跑一次
library(survival)
data(cancer, package = "survival")
# 與 B5-05 相同的世代調和與模型配適。攤開寫,這一段才自己跑得動。
H <- 5 * 365.25
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)
# 兩個候選模型,與本頁產圖腳本同一份定義。
BIG <- c("age", "meno", "size_mm", "grade3", "nodes", "log_pgr", "log_er", "hormon")
SMALL <- c("age", "size_mm", "nodes", "grade3")
rot$log_pgr <- log1p(rot$pgr); rot$log_er <- log1p(rot$er)
ext$log_pgr <- log1p(ext$pgr); ext$log_er <- log1p(ext$er)
keep <- c("rfs_time", "rfs_event", BIG)
dev <- rot[complete.cases(rot[, keep]), keep]
ext <- ext[complete.cases(ext[, keep]), keep]
# Breslow 的累積基線風險,直接寫出來——basehaz(centered = FALSE) 與
# predict(type = "lp") 不匹配(後者是中心化的),配錯會讓每個預測風險都少乘一個常數。
breslow_h0 <- function(time, event, lp, h = H) {
o <- order(time); time <- time[o]; event <- event[o]; r <- exp(lp[o])
at_risk <- rev(cumsum(rev(r)))
sum(1 / at_risk[event == 1 & time <= h])
}
form_of <- function(v) as.formula(paste("Surv(rfs_time, rfs_event) ~",
paste(v, collapse = " + ")))
fit_big <- coxph(form_of(BIG), data = dev)
fit_small <- coxph(form_of(SMALL), data = dev)
h0_big <- breslow_h0(dev$rfs_time, dev$rfs_event,
as.numeric(as.matrix(dev[, BIG]) %*% coef(fit_big)))
lp_big <- as.numeric(as.matrix(ext[, BIG]) %*% coef(fit_big))
lp_small <- as.numeric(as.matrix(ext[, SMALL]) %*% coef(fit_small))
# A:八個變項,原封不動搬過來。B:四個變項,再對這個世代重新校準。
pred_A <- 1 - exp(-h0_big * exp(lp_big))
sl_B <- unname(coef(coxph(Surv(rfs_time, rfs_event) ~ lp_small, data = ext))[1])
h0_B <- breslow_h0(ext$rfs_time, ext$rfs_event, sl_B * lp_small)
pred_B <- 1 - exp(-h0_B * exp(sl_B * lp_small))
km_risk <- function(d, h = H) {
if (nrow(d) < 5 || sum(d$rfs_event) == 0) return(NA_real_)
k <- survfit(Surv(rfs_time, rfs_event) ~ 1, data = d)
1 - summary(k, times = h, extend = TRUE)$surv
}
# 淨效益:兩行算式,其餘都是把人挑出來
net_benefit <- function(pred, d, pt) sapply(pt, function(p) {
treat <- pred >= p
if (sum(treat) < 10) return(0) # 太少人就沒有可靠的 KM
ptreat <- mean(treat)
r <- km_risk(d[treat, , drop = FALSE])
r * ptreat - (1 - r) * ptreat * p / (1 - p)
})
nb_all <- function(d, pt) { r <- km_risk(d); r - (1 - r) * pt / (1 - pt) }
pt <- seq(0.05, 0.8, by = 0.01)
plot(pt, nb_all(ext, pt), type = "l", lty = 2, ylim = c(-0.05, 0.5),
xlab = "Threshold probability", ylab = "Net benefit")
abline(h = 0) # 都不治
lines(pt, net_benefit(pred_A, ext, pt), col = "darkorange", lwd = 2)
lines(pt, net_benefit(pred_B, ext, pt), col = "steelblue", lwd = 2)
# 換算成「每千位病人多幾個淨真陽性」
1000 * (net_benefit(pred_B, ext, 0.5) - net_benefit(pred_A, ext, 0.5))驗證環境:R 4.6.0 + survival 3.8.6。這裡刻意不呼叫 dcurves 套件,把公式攤開寫——決策曲線只有兩行算式,自己寫一次比記得參數名稱有用。實務上 dcurves::dca() 可以直接吃 Surv 物件並產生同樣的曲線。
import numpy as np, pandas as pd
from lifelines import KaplanMeierFitter
H = 5 * 365.25
def km_risk(d, h=H):
if len(d) < 5 or d["rfs_event"].sum() == 0:
return np.nan
km = KaplanMeierFitter().fit(d["rfs_time"], d["rfs_event"])
return 1 - float(km.predict(h))
def net_benefit(pred, d, pts):
out = []
for p in pts:
treat = pred >= p
if treat.sum() < 10:
out.append(0.0); continue
ptreat = treat.mean()
r = km_risk(d[treat])
out.append(r * ptreat - (1 - r) * ptreat * p / (1 - p))
return np.array(out)
def nb_all(d, pts):
r = km_risk(d)
return r - (1 - r) * pts / (1 - pts)
pts = np.arange(0.05, 0.81, 0.01)
# 曲線畫出來之後,永遠先看模型有沒有高過 nb_all 與 0 這兩條線Python 沒有廣泛採用的存活版 DCA 套件;下面直接照公式實作,Kaplan-Meier 用 lifelines。二元結果的版本更簡單,把 KM 那一段換成直接數比例即可。
讀論文時要問的四件事
- 門檻範圍合理嗎? 圖的橫軸應該涵蓋臨床上真的會用到的範圍,而且作者要說明為什麼是那個範圍。
- 有沒有畫兩條參考線? 少了「全部治」那條,讀者無從判斷模型有沒有加值。
- 決策曲線是在哪一批資料上畫的? 在開發資料上畫的決策曲線帶著樂觀偏誤, 跟表面 C-index 一樣不能直接採信。
- 模型有沒有先校準好? 決策曲線對校準極度敏感——本頁整個例子就是這件事。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 用 AUC 高低決定要不要採用模型 | AUC 不含臨床代價;校準不良的高 AUC 模型淨效益可能更差 |
| 決策曲線畫出來卻沒有「全部治」那條線 | 沒有參考線就無法判斷模型有沒有加值 |
| 只報單一門檻的淨效益 | 門檻是臨床判斷,不同醫師與病人會不同;要報一段範圍 |
| 跨研究比較淨效益的數值 | 淨效益含事件率,族群不同就不可比 |
| 在開發資料上畫決策曲線當成證據 | 與表面 C-index 同樣樂觀 |
| 在重新校準過的同一批人身上報告淨效益改善 | 樂觀偏誤;要切開或另找世代 |
| 把門檻機率當成模型參數去調到最好看 | 它是臨床代價的編碼,不是可以優化的東西 |
| 曲線交叉時硬要說哪個模型比較好 | 交叉代表答案取決於門檻 |
| 樣本小時解讀曲線的細部起伏 | 那些抖動來自小分組的估計誤差 |
| 把淨效益說成「治療效果」 | 它衡量的是「用這個模型做決定」的價值,不是治療本身的效果 |
那重分類指標呢
決策曲線問的是「用了這個模型,每一千人能多找出幾個而不多治療幾個」。 另一族回答「新模型有沒有比較好」的指標——Brier score、NRI、IDI——問的不是同一件事, 而且在這一頁的同一組模型 A 與 B 上,它們給出的訊號與淨效益相反。 那組對照見 Brier score、NRI 與 IDI。
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B5-07-decision-curve.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
決策曲線的橫軸是門檻機率。把門檻訂在四成,等於宣告了什麼?
看答案與解析
正確答案: 偽陽性與偽陰性的代價比是 0.667——漏掉一個會出事的人,跟白治一個半不會出事的人一樣糟
門檻機率換算成代價比是門檻除以一減門檻,四成的門檻給 0.667:你願意在風險剛好四成時開始治療,等於說漏掉一個會出事的人,跟白治一個半不會出事的人一樣糟。0.429 是三成門檻的代價比;門檻是臨床決定,由治療的副作用、成本、侵入性與漏掉事件的後果決定,模型的表現一個字都沒有進來。1.500 是六成門檻的代價比:門檻愈高,代表你要求愈確定才動手,被判為高風險的人愈少,不是愈多。
在門檻五成這一列,模型 A 的淨效益 0.0982 低於模型 B 的 0.1218,而 A 的 C-index 比 B 高。機制是什麼?
看答案與解析
正確答案: 模型 A 在這個門檻只把 25% 的人判為高風險,被它擋在門檻外的人裡有很多真的會出事
C-index 只看排序,看不到整條刻度被壓低了。模型 A 平均預測 43.1%,而這群人實際發生 50.8%,門檻一畫在五成,被壓低的刻度立刻把人分錯邊:它只判了 25% 的人為高風險,模型 B 判了 42%。多治療本身不會讓淨效益上升——淨效益已經把偽陽性按代價比折算成真陽性扣掉了,治得太多的模型在高門檻上會被扣到低於全部治那條線。至於 15%,那是模型 A 在六成門檻上的比例;門檻愈高、判為高風險的人愈少,是每個模型都會有的事,跟排序能力無關。
第三個模型 A′ 是模型 A 在這個世代重新校準之後的版本,C-index 與 A 完全相同,而在門檻五成上淨效益 0.1284 高於 B 的 0.1218。這說明了什麼?
看答案與解析
正確答案: A′ 平均預測 0.5148,貼近這群人實際的五年風險——贏過 A 的不是變項比較少,是校準過
重新校準不動任何一對病人的先後,所以 A′ 與 A 的 C-index 一模一樣——被改好的不是排序,是刻度:A 原本平均預測 0.4312,遠低於這群人實際的五年風險,A′ 校準後是 0.5148。B 的平均預測 0.5135 與 A′ 幾乎一樣,這正好說明差別不在變項個數。額外的預測變項確實有價值,但那個價值要等校準修好才拿得到。
把驗證世代切一半、在一半上重新校準模型 B、到另一半量淨效益,重複 200 次。結論該怎麼寫?
看答案與解析
正確答案: 五成門檻上 B 在 78.0% 的切分裡贏過 A,所以要寫「在較高的門檻附近,簡單模型的淨效益較高」
決策曲線的結論永遠要綁在門檻範圍上。五成門檻附近 B 在 78.0% 的切分裡贏,那一段站得住;四成門檻只有 46.5%,大約是擲銅板,原本那張表在這個門檻上的優勢沒有撐過去——但擲銅板的意思是未偵測到差異,推不出 B 不如 A,那是把沒有訊號讀成反方向的訊號。六成門檻是 26.5%,比五成低沒錯,可是四成又比六成高,三個數字不是單調的,門檻愈高 B 愈吃虧這句話在同一張表上就被推翻了。
決策曲線上「全部治」那條線在某個門檻穿過零。它為什麼在那裡穿過?
看答案與解析
正確答案: 它在門檻 0.510 附近穿過零,因為那裡剛好是這群人的事件率——過了事件率,全部治就是賠本生意
全部治的淨效益等於事件率減掉一減事件率再乘上代價比,門檻愈高第二項愈重,到門檻等於事件率的地方剛好歸零——這份資料在 0.510 附近。0.508 確實是這群人實際的五年風險,兩個數字幾乎重合正是上面那句話的意思,但它不是模型 A 的平均預測:A 系統性低估,它的平均預測明顯更低。至於 -0.229,那是六成門檻上全部治的淨效益,早就是負的了——穿過零的位置是變號的那一點,不是任何一個負值所在的位置。
門檻五成這一列,模型 B 比模型 A 每千位病人多 24 個淨真陽性。這個數字可以怎麼用?
看答案與解析
正確答案: 三成門檻上同一個差是 -6,方向相反——這種差值只在指定的門檻上有意義,換一個門檻要重算
淨效益差是綁在一個門檻上的量:三成門檻上模型 B 反而少,四成門檻上多一點,五成門檻上多 24 個——同一對模型、同一批病人,只是換了門檻。所以 B 比 A 好這句話沒有門檻就沒有內容。它也不能跨研究比較:淨效益的公式裡有事件率,而事件率是族群的性質,同一個模型搬到事件率不同的世代會給出不同的淨效益,那不代表模型變好或變壞。至於三個門檻的差都是正的,看一眼表就知道不是——三成那一列就是負的。
用到這個方法的章節
素材來源與授權
本頁為原創內容