敏感度分析
「請補做敏感度分析」是審稿意見裡出現頻率最高的一句,而它要求的東西比多數人以為的具體。本頁用同一份資料跑十六個各自都站得住的分析設定,讓估計值的散布變成看得到的東西,並用打散暴露的置換模擬量出「看到結果之後才挑一個報」要付多少代價。
「請補做敏感度分析」到底在要求什麼
敏感度分析是把一個本來就可以合理選擇的分析決定換掉,重跑,看結論會不會翻。
它動的是分析決定。這句話裡的每一個字都在排除別的東西,而那三件別的東西經常被混進來:
| 它動的是 | 那叫什麼 | 在哪一頁 |
|---|---|---|
| 分析決定(模型設定、共變項、結果定義、納入條件) | 敏感度分析 | 本頁 |
| 資料點(哪一個觀察值、哪一篇研究在推動結果) | 影響分析 | 影響診斷 |
| 族群(這個效果在誰身上比較大) | 次族群分析 | 多重比較 |
| 顯著性門檻(做了很多次檢定該怎麼判) | 多重比較調整 | 多重比較 |
最後一列特別容易搞混,所以講清楚:敏感度分析不需要多重比較調整。它問的不是「這裡面有沒有一個是真的」, 而是「同一個結論在不同設定下站不站得住」。對它做調整等於把穩定性的問題當成顯著性的問題來回答。
這一頁的資料,以及它不能拿來說的話
本頁用 survival::rotterdam,2,982 筆乳癌登錄資料,
其中接受荷爾蒙治療的有 339 筆。整份資料沒有任何遺漏值
(0 格),所以「換一種遺漏值處理」這個很常見的敏感度分析軸在這裡不存在,
下面四個軸是別的東西。
十六個都站得住的分析
四個軸,每個軸兩個選項,全部組合:
| 軸 | 選項 A | 選項 B | 為什麼兩個都站得住 |
|---|---|---|---|
| 結果定義 | 無復發存活 | 整體存活 | 兩者在乳癌文獻都是標準終點 |
| 共變項集合 | 年齡、腫瘤大小、分化度、淋巴結數 | 再加停經狀態與荷爾蒙受體 | 加不加這兩項都有人做 |
| 淋巴結數編碼 | 原值 | 截頂在十 | 右尾很長,截頂是常見處理 |
| 追蹤長度 | 全追蹤 | 截尾在五年 | 五年是乳癌的慣用時間窗 |
用完全因子而不是隨手挑一串設定,是因為只有因子設計能把散布歸因到軸上。 挑一串設定只會得到一堆點,而「哪一個決定在主導結果」才是要問的問題。
figures/scripts/B8-07-sensitivity-analysis.R| 名次 | 結果 | 共變項 | 淋巴結 | 追蹤 | 風險比 | 95% CI | p |
|---|---|---|---|---|---|---|---|
| 1 | 無復發存活 | 核心加兩項 | 截頂在十 | 截尾五年 | 0.695 | 0.585–0.826 | < 0.001 |
| 2 | 無復發存活 | 核心 | 截頂在十 | 截尾五年 | 0.717 | 0.604–0.851 | < 0.001 |
| 3 | 無復發存活 | 核心加兩項 | 截頂在十 | 全追蹤 | 0.733 | 0.627–0.856 | < 0.001 |
| 4 | 無復發存活 | 核心 | 截頂在十 | 全追蹤 | 0.743 | 0.636–0.867 | < 0.001 |
| 5 | 整體存活 | 核心加兩項 | 截頂在十 | 截尾五年 | 0.757 | 0.613–0.934 | = 0.009 |
| 6 | 整體存活 | 核心加兩項 | 截頂在十 | 全追蹤 | 0.786 | 0.659–0.937 | = 0.007 |
| 7 | 整體存活 | 核心 | 截頂在十 | 全追蹤 | 0.809 | 0.680–0.964 | = 0.018 |
| 8 | 整體存活 | 核心 | 截頂在十 | 截尾五年 | 0.823 | 0.669–1.013 | = 0.066 |
| 9 | 無復發存活 | 核心加兩項 | 原值 | 截尾五年 | 0.847 | 0.715–1.002 | = 0.053 |
| 10 | 無復發存活 | 核心 | 原值 | 截尾五年 | 0.870 | 0.736–1.028 | = 0.103 |
| 11 | 無復發存活 | 核心加兩項 | 原值 | 全追蹤 | 0.874 | 0.751–1.018 | = 0.083 |
| 12(主分析) | 無復發存活 | 核心 | 原值 | 全追蹤 | 0.884 | 0.760–1.028 | = 0.111 |
| 13 | 整體存活 | 核心加兩項 | 原值 | 截尾五年 | 0.925 | 0.753–1.138 | = 0.462 |
| 14 | 整體存活 | 核心加兩項 | 原值 | 全追蹤 | 0.932 | 0.784–1.108 | = 0.423 |
| 15 | 整體存活 | 核心 | 原值 | 全追蹤 | 0.954 | 0.803–1.133 | = 0.590 |
| 16 | 整體存活 | 核心 | 原值 | 截尾五年 | 0.995 | 0.811–1.220 | = 0.960 |
一句話讀完這張表:這份資料對「有沒有關聯」這個問題沒有給出單一答案, 給出答案的是分析設定。
主導這條曲線的,是一個沒有人會寫進方法段的決定
把每個軸的兩個選項各自平均起來相減,得到那個軸把估計值推動了多少(對數尺度):
| 軸 | 擺幅 |
|---|---|
| 淋巴結數編碼 | 0.184 |
| 結果定義 | 0.093 |
| 共變項集合 | 0.037 |
| 追蹤長度 | 0.015 |
主導的是淋巴結數要不要截頂。在上圖的下層可以直接看出來:截頂那一列的深色圓點幾乎全部集中在左半邊。
這件事值得停一下。論文的方法段會寫終點是什麼、校正了哪些共變項——那是讀者看得到的兩個軸, 而它們在這裡分別排第二與第三。排第一的是一個連方法段都不會提的資料整理決定。
主分析落在中段,而那正是重點
事先指定的主分析是四個軸上各自最少加工的那個選項: 無復發存活、核心共變項、淋巴結數原值、全追蹤。
它的風險比是 0.884(95% CI 0.760–1.028,p = 0.111), 未達統計顯著,在十六個設定裡排第 12 名。同一條曲線上有 7 個設定是顯著的。
主分析既不是最保守的那一個,也不是最顯著的那一個。它的正當性不來自它在曲線上的位置, 來自它是在看到這條曲線之前就決定好的。 如果正當性來自位置,那麼:
- 挑最顯著的那一個,理由可以寫成「這是效果最明確的設定」
- 挑最保守的那一個,理由可以寫成「我們採取了最謹慎的做法」
兩句話都寫得出來,而且兩句都是在看到結果之後寫的。事後挑一個保守的來假裝謹慎, 與事後挑一個顯著的,是同一個動作。
「敏感度分析顯示結果穩健」是一句空話
這一句在論文裡出現的頻率極高,而它本身不帶任何資訊:沒說做了哪些擾動、 沒說每一個擾動後的估計是多少、沒說有沒有哪一個讓結論改變。
報告時該給的是三件東西:
- 做了哪些擾動,逐一列出,包括那些沒有改變結論的
- 每一個擾動後的效果估計與區間,不是只給 p 值、不是只給平衡表、更不是只給一句話
- 有沒有哪一個讓結論改變,如果有,說明是哪一個、以及為什麼主分析仍然是主分析
穩健不等於對
上面那條曲線是「不穩健」的樣子。但反過來的情形更需要小心:十六個設定全部顯著, 也不能證明結論正確。
理由在本頁的資料裡就看得見。適應症干擾存在於全部十六個設定中, 換結果定義、換共變項、換編碼、換時間窗,沒有任何一個動作碰得到它。 一組整齊一致的曲線,可以整組偏在同一個方向。
影響診斷那一頁用統合分析講過同一件事: 一份合併分析可以在納入研究全都有同一個系統性偏誤時,對逐篇排除完美穩健—— 因為那個偏誤不會因為少了一篇而消失。
敏感度分析能排除的,只有它實際擾動過的那些東西。 沒被擾動的假設,它一個字都沒說。
事後挑最顯著的代價,量出來
前面說「事後挑與事先指定是不同的東西」。那個差別有多大,可以直接量。
做法:把暴露欄位隨機打散,真實關聯因此正好是零,然後重跑同一組 16 個設定。 重複 4,000 次(種子 20260826), 每一次記兩個數字——最小的那個 p 值,以及事先指定那個設定的 p 值。
figures/scripts/B8-07-sensitivity-analysis.R- 只報最顯著的那一個:17.9% 的模擬會宣稱顯著
- 只報事先指定的那一個:6.5%
差距是 11.4 個百分點。這不需要任何推論就能解讀——暴露是被打散的, 所以上面每一次「顯著」都是假陽性。
同一批設定、同一份資料、同一個真實關聯(零)。唯一改變的是哪一個結果會被寫進論文。 這就是為什麼「事先指定」不是行政手續。
讀論文時看哪裡
- 敏感度分析在哪一段。 在附錄一張表裡逐設定列出效果估計,跟在討論裡寫一句「結果穩健」, 是兩件完全不同的事。
- 有沒有效果估計,還是只有 p 值與平衡表。 只有 p 值的話,看不出結論會不會翻。
- 主分析與敏感度分析的位置有沒有寫死。 找「pre-specified」「according to the protocol」 這類字眼,以及有沒有預先註冊編號。
- 列出來的擾動是不是只有沒事的那些。 一組全部通過的敏感度分析, 跟一組被挑過的敏感度分析,在紙上長得一模一樣。
- 沒有被擾動的是什麼。 這通常比列出來的更重要——見上面「穩健不等於對」。
動手跑一次
library(survival)
d0 <- rotterdam
d0$rfs_time <- pmin(d0$rtime, d0$dtime)
d0$rfs <- as.integer(d0$recur == 1 | d0$death == 1)
d0$nodes_cap <- pmin(d0$nodes, 10)
d0$lpgr <- log(d0$pgr + 1)
grid <- expand.grid(
outcome = c("rfs", "os"),
covars = c("core", "plus"),
nodesf = c("raw", "cap"),
followup = c("full", "trunc5"),
stringsAsFactors = FALSE
)
fit_one <- function(d, g) {
if (g$followup == "trunc5") {
tt <- 5 * 365.25
d$rfs <- ifelse(d$rfs_time > tt, 0L, d$rfs); d$rfs_time <- pmin(d$rfs_time, tt)
d$death <- ifelse(d$dtime > tt, 0L, d$death); d$dtime <- pmin(d$dtime, tt)
}
nod <- if (g$nodesf == "raw") "nodes" else "nodes_cap"
cov <- if (g$covars == "core") c("age", "size", "grade", nod)
else c("age", "size", "grade", nod, "meno", "lpgr")
lhs <- if (g$outcome == "rfs") "Surv(rfs_time, rfs)" else "Surv(dtime, death)"
f <- as.formula(paste(lhs, "~ hormon +", paste(cov, collapse = " + ")))
summary(coxph(f, data = d))$coefficients["hormon", c("coef", "Pr(>|z|)")]
}
res <- t(sapply(seq_len(nrow(grid)), function(i) fit_one(d0, as.list(grid[i, ]))))
grid$hr <- exp(res[, 1]); grid$p <- res[, 2]
print(grid[order(grid$hr), ], row.names = FALSE)驗證環境:R 4.6.0。下面只跑十六個設定的部分;置換模擬要跑 4,000 次,在完整腳本裡。
import itertools
import numpy as np
import pandas as pd
from lifelines import CoxPHFitter
url = "https://vincentarelbundock.github.io/Rdatasets/csv/survival/rotterdam.csv"
d0 = pd.read_csv(url)
d0["rfs_time"] = np.minimum(d0["rtime"], d0["dtime"])
d0["rfs"] = ((d0["recur"] == 1) | (d0["death"] == 1)).astype(int)
d0["nodes_cap"] = d0["nodes"].clip(upper=10)
d0["lpgr"] = np.log(d0["pgr"] + 1)
size = pd.get_dummies(d0["size"], prefix="size", drop_first=True).astype(float)
d0 = pd.concat([d0, size], axis=1)
size_cols = list(size.columns)
def fit_one(outcome, covars, nodesf, followup):
d = d0.copy()
if followup == "trunc5":
tt = 5 * 365.25
d["rfs"] = np.where(d["rfs_time"] > tt, 0, d["rfs"])
d["rfs_time"] = d["rfs_time"].clip(upper=tt)
d["death"] = np.where(d["dtime"] > tt, 0, d["death"])
d["dtime"] = d["dtime"].clip(upper=tt)
nod = "nodes" if nodesf == "raw" else "nodes_cap"
cov = ["age", "grade", nod] + size_cols
if covars == "plus":
cov += ["meno", "lpgr"]
t, e = ("rfs_time", "rfs") if outcome == "rfs" else ("dtime", "death")
cph = CoxPHFitter().fit(d[[t, e, "hormon"] + cov], duration_col=t, event_col=e)
return float(np.exp(cph.params_["hormon"])), float(cph.summary.loc["hormon", "p"])
rows = []
for g in itertools.product(["rfs", "os"], ["core", "plus"], ["raw", "cap"],
["full", "trunc5"]):
hr, p = fit_one(*g)
rows.append((*g, hr, p))
out = pd.DataFrame(rows, columns=["outcome", "covars", "nodes", "followup", "hr", "p"])
print(out.sort_values("hr").to_string(index=False))lifelines 的 CoxPHFitter 需要自己把因子展開成 dummy,其餘與 R 對得起來。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 只寫「敏感度分析顯示結果穩健」 | 沒說擾動了什麼、每個估計是多少、有沒有翻,這句話等於沒寫 |
| 敏感度分析只報 p 值 | 看不出結論會不會翻,而那正是它要回答的 |
| 傾向分數的敏感度分析只報平衡表 | 平衡表說的是配對做得好不好,不是估計值有沒有變 |
| 看到結果之後才決定跑哪些設定 | 那是選擇性報告,與事先指定在紙上長得一樣 |
| 事後挑一個最保守的設定當主分析 | 與事後挑最顯著的是同一個動作,只是方向相反 |
| 「敏感度分析全部通過」就寫成結論正確 | 它只排除實際擾動過的東西;共同的偏誤不會因為換設定而消失 |
| 對敏感度分析做多重比較調整 | 它問的是穩定性不是顯著性 |
| 把逐一排除觀察值當成敏感度分析的全部 | 那是影響分析,動的是資料點不是分析決定 |
| 主分析與敏感度分析的位置在稿件裡沒有寫死 | 讀者無從判斷哪一個是事先決定的 |
| 只擾動不會改變結論的那些軸 | 一組被挑過的敏感度分析,看起來與一組完整的一模一樣 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B8-07-sensitivity-analysis.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
同一份資料、同一個問題,十六個各自都站得住的分析設定。最靠近虛無值的那一端給出的風險比是多少,這個數字該怎麼用?
看答案與解析
正確答案: 0.995,而另一端離虛無值明顯更遠。兩端都是站得住的分析,所以要讀的是這個寬度本身,不是從裡面挑一個
0.995 是十六個設定裡最靠近虛無值的那一個,而另一端是 0.695——同一份資料、同一個問題、每一個設定都講得出理由。這道題要練的是把整條曲線當成一個量來讀:寬度告訴你結論對分析決定有多敏感,而它跨過虛無值這件事告訴你「有沒有效果」這個問題在這份資料上不是由資料決定的,是由設定決定的。0.884 是事先指定的主分析,它落在中段而不是右端。0.695 是另一端,把它說成最靠近虛無值的那一個會讓整條曲線的方向反過來;它的 p 值確實最小,但那是因為它離虛無值最遠,不是因為它最靠近。
四個軸各自把估計值推動多少(對數尺度上的擺幅)。哪一個軸主導了整條曲線?
看答案與解析
正確答案: 0.184,淋巴結數的編碼。原值或截頂在十,是一個幾乎沒有論文會在方法段交代的決定
淋巴結數編碼的擺幅是 0.184,比第二名大了一倍左右。機制講得出來:淋巴結數是這份資料裡最強的預後因子,而它同時與是否接受治療有關(預後差的人比較可能拿到),所以「怎麼把它放進模型」直接決定了有多少干擾被移掉——截頂等於換掉一個強干擾因子的函數形式。結果定義的 0.093 排第二,而「換終點是最大的改變」這個推論在這裡不成立:無復發存活與整體存活在乳癌高度相關,換掉它動的是事件集合,共變項結構沒有變。共變項集合的 0.037 排第三,「最能改變估計值」正好相反——多加兩個與治療關聯較弱的變項,能移掉的干擾有限。真正的教訓是:論文看得到的是終點與共變項清單,主導散布的卻是那個沒有人覺得需要交代的資料整理決定。
事先指定的主分析,在依風險比排序的十六個設定裡落在第幾名?這個位置代表什麼?
看答案與解析
正確答案: 第 12 名,落在中段。它既不是最保守也不是最顯著的一個,它的正當性完全來自事先指定
主分析落在第 12 名,中段偏右。它未達統計顯著,而同一條曲線上有七個設定顯著——如果主分析的正當性來自它的位置,這一頁就沒有立場拒絕那七個。第 16 名是最靠近虛無值的那一端,把主分析等同於最保守的設定,等於容許作者在事後挑一個最保守的來假裝謹慎,那與挑最顯著的是同一個動作。第 1 名是最偏離虛無值的那一端,「主分析應該是效果最明確的」正是選擇性報告的標準說法。事先指定之所以有力量,就是因為它在看到這條曲線之前就決定好了。
把暴露隨機打散,真實關聯因此是零,再跑同一組十六個設定。只報最顯著那一個的話,宣稱顯著的比例是多少?
看答案與解析
正確答案: 0.179。暴露已經被打散,所以這個比例裡的每一次顯著都是假陽性
0.179 是只報最顯著那一個設定時的假陽性比例。這個數字不需要任何推論就能解讀,因為暴露是被打散的,關聯為零是設計出來的而不是假設的。0.065 是同一批模擬裡事先指定單一設定的比例,把它說成挑最顯著的那一個會讓兩者的差距整個消失,而那個差距正是這張圖唯一要說的事。0.114 是兩者相減,它是差距不是比例。
置換模擬跑了四千次。它報出來的假陽性比例,蒙地卡羅標準誤是多少,這個數字限制了什麼?
看答案與解析
正確答案: 0.0034。比這個量級更細的差異,這個模擬讀不出來——所以它能支持的是兩組之間的大差距,不是任何一根長條與名目水準之間的小數點
0.0034 是名目水準附近的蒙地卡羅標準誤,也就是這個模擬的解析度。它決定了哪些差異可以讀:兩組相差 0.1140,是它的三十倍以上,穩固得很;而任何一根長條與名目水準之間幾個千分點的差距,就要小心得多。0.0653 是事先指定那一組的實測比例,把它說成標準誤會讓解析度看起來與訊號一樣大,那樣的話這張圖什麼都證明不了。0.1140 是兩組的差距,把它說成標準誤則相反——會把這張圖唯一穩固的發現判成雜訊。跑得夠不夠多不是靠感覺,是靠這個數字。
十六個設定裡有幾個達到統計顯著,而這個數字能支持與不能支持什麼?
看答案與解析
正確答案: 7 個。結論對「換一個分析決定」並不穩健;但反過來就算十六個全部顯著,也不能證明結論正確
七個設定顯著、九個不顯著,所以這個結論對換一個分析決定並不穩健。真正要記住的是反方向那一半:穩健不等於對。這份資料裡的治療是依適應症給的,這個干擾存在於全部十六個設定中,換設定不會讓它消失——一組全部顯著的曲線,仍然可以整組偏在同一個方向。把「全部顯著」讀成通過檢驗,正是這一頁要擋的誤讀。12 是主分析在曲線上的名次,不是顯著的個數;就算它是,「主分析低估了效果所以改報顯著的那些」也正好是選擇性報告的定義。
用到這個方法的章節
素材來源與授權
本頁為原創內容