干擾、DAG 與該校正什麼
干擾因子的三個條件、把因果假設畫成 DAG 之後「該放什麼進模型」變成一個可以檢查的問題、以及為什麼校正對撞因子會憑空製造出關聯——包括那個發生在暴露之前、看起來完全無害的變項。
這一頁在解決什麼問題
世代研究那一章談過觀察性研究的核心難題:兩組人本來就不一樣,所以觀察到的關聯不等於治療的效果。那一章的處理方式是「把已測量到的干擾因子放進 Cox 模型校正掉」,並且誠實地說了那句限定詞——統計校正只能處理你有測量到的變項。
這一頁往前補一步,補的是更根本的問題:在你有測量的那些變項裡面,哪些該放進模型?
直覺的答案是「全部都放,反正校正越多越乾淨」。這個直覺在三種情況下是錯的,而且錯的方向不是「校正得不夠」,是校正本身製造出偏誤。分辨這三種情況不能靠 p 值、不能靠逐步迴歸、不能靠 AIC——它們全都只看資料裡的關聯,而該不該校正是一個關於因果結構的問題,資料本身不會告訴你答案。
有向無環圖(directed acyclic graph, DAG)就是把因果結構寫下來的工具。它不會幫你找出真相,它只做一件事:把你腦中的假設攤開,讓「該校正什麼」變成一個可以逐條檢查的問題,而不是一個憑感覺的決定。
干擾因子的三個條件
一個變項 要算是暴露 與結果 之間的干擾因子(confounder),傳統上要同時滿足三個條件:
- 與暴露 有關聯——用藥組與不用藥組在 上不一樣
- 是 的獨立危險因子——就算不看 , 本身也會影響結果
- 不在 的因果路徑上——它不是暴露造成的、也不是暴露發揮作用的中間站
第三條是最常被跳過的,而它正是干擾因子與中介因子的分界線。前兩條在資料裡看得到(可以算相關、可以跑迴歸),第三條看不到——它是關於時序與機轉的知識,只能來自臨床、來自文獻、來自你對這個疾病的理解。
用 DAG 把問題畫出來
DAG 的規則少到可以一句話講完:每個變項畫一個節點,每個「我認為 X 直接造成 Y」畫一個箭頭,方向永遠指向後果,而且不准繞回自己(無環)。 沒有箭頭代表的是一個實質主張——「我認為這兩者之間沒有直接因果」——所以畫不出箭頭的地方跟畫得出來的地方一樣重要。
畫完之後,關聯(association)會沿著三種路徑在圖上流動:
| 路徑形狀 | 名稱 | 預設是通的嗎 | 校正中間那個變項會怎樣 |
|---|---|---|---|
| 分岔(fork)/共同原因 | 通 | 關掉它,這正是我們要的 | |
| 鏈(chain)/中介 | 通 | 關掉它,但關掉的是你想估的效果 | |
| 對撞(collider)/共同結果 | 不通 | 打開它,憑空製造出關聯 |
第三列是整個 DAG 語言最反直覺、也最有用的一條。對撞路徑預設是關著的,校正會把它打開。 這就是「校正越多越好」這個直覺最重要的反例。
四種結構,四種答案
把四種結構各模擬 500 次、每次 2000 個受試者,每一次都跑兩個模型:只放暴露的(Y ~ A),以及多放一個變項的(Y ~ A + Z)。四個結構的箭頭強度全部設成一樣,所以它們之間唯一的差別是形狀。
figures/scripts/B6-01-dag.Rset.seed(20260822)
n <- 2000; g <- 0.8 # g = 每一條非因果箭頭的強度,四種結構共用
# 1. 干擾因子:C 同時造成 A 與 Y,真實效果 = 1
gen_confounder <- function(n) {
C <- rnorm(n); A <- g * C + rnorm(n); Y <- 1 * A + g * C + rnorm(n)
data.frame(A, Y, Z = C)
}
# 2. 對撞因子:A 與 Y 各自獨立,兩者共同造成 S,真實效果 = 0
gen_collider <- function(n) {
A <- rnorm(n); Y <- 0 * A + rnorm(n); S <- g * A + g * Y + rnorm(n)
data.frame(A, Y, Z = S)
}
# 3. 中介因子:A 透過 M 作用,總效果 = 0.2 + 0.8 * 0.8
gen_mediator <- function(n) {
A <- rnorm(n); M <- g * A + rnorm(n); Y <- 0.2 * A + g * M + rnorm(n)
data.frame(A, Y, Z = M)
}
# 4. M-bias:Z 發生在暴露之前,但它是兩個未測量變項的共同結果,真實效果 = 0
gen_mbias <- function(n) {
U1 <- rnorm(n); U2 <- rnorm(n)
Z <- g * U1 + g * U2 + rnorm(n)
A <- g * U1 + rnorm(n)
Y <- 0 * A + g * U2 + rnorm(n)
data.frame(A, Y, Z)
}
d <- gen_collider(n)
coef(lm(Y ~ A, data = d))["A"] # 未校正
coef(lm(Y ~ A + Z, data = d))["A"] # 校正對撞因子之後驗證環境:R 4.6.0,base R 即可,不需要任何套件
import numpy as np, statsmodels.api as sm
rng = np.random.default_rng(20260822)
n, g = 2000, 0.8
def gen_collider(n):
A = rng.normal(size=n)
Y = 0 * A + rng.normal(size=n)
S = g * A + g * Y + rng.normal(size=n)
return A, Y, S
A, Y, S = gen_collider(n)
crude = sm.OLS(Y, sm.add_constant(A)).fit()
adj = sm.OLS(Y, sm.add_constant(np.column_stack([A, S]))).fit()
print(crude.params[1], adj.params[1]) # 0 附近 vs 明顯偏離 0numpy + statsmodels 的版本,四個 generator 一字不差地對應 R 那邊。
figures/scripts/B6-01-dag.R| 結構 | 臨床上的讀法 | 真實效果 | Y ~ A | Y ~ A + Z | 該怎麼做 |
|---|---|---|---|---|---|
| 干擾因子(confounder) | 疾病嚴重度同時決定了用不用藥與會不會死 | 1.00 | +1.39 | +1.00 | 校正 |
| 對撞因子(collider) | 住院與否同時被暴露與結果決定 | 0.00 | 0.00 | −0.39 | 不要校正 |
| 中介因子(mediator) | 藥物透過降血壓起作用 | 0.84 | +0.84 | +0.20 | 不要校正(除非你要的是直接效果) |
| M-bias | 一個發生在暴露之前、卻是兩個未測量因素共同結果的變項 | 0.00 | 0.00 | −0.12 | 不要校正 |
四列裡只有第一列的「多校正一個」是進步的。其餘三列,那個多放進去的變項把一個原本無偏的估計弄壞了。
對撞因子:校正會憑空製造關聯
第二列的數字值得停下來看:暴露與結果在模擬裡是完全獨立的(真實效果設成 0),未校正的估計是 +0.001,校正之後變成 −0.388。單看一次模擬,那個關聯的信賴區間是 -0.455 到 -0.372,完全不跨過 0,p 值小到報表印不出來。
兩個毫無關係的變項,因為多校正了一個共同結果,看起來變得高度相關。
直覺上為什麼會這樣:假設住院()的原因只有兩個,暴露與疾病本身。現在你只看住院的病人——在這群人裡面,沒有暴露的那些人一定是因為疾病比較嚴重才住進來的。於是在住院病人裡,「沒暴露」與「病得重」綁在一起,而這個關聯在整體人群裡根本不存在。它是你選擇看誰的時候創造出來的。
M-bias:發生在暴露之前,還是不能校正
上面那條判準會讓人得出一個很方便的簡化規則:「只校正暴露之前發生的事,就不會踩到對撞因子。」這個規則八成的時候是對的,而 M-bias 就是剩下的那兩成。
第四張 DAG 的結構是這樣:有兩個沒被測量到的東西——(比方說健康行為傾向)與 (比方說尚未診斷的潛在疾病)。 影響病人會不會接受這個暴露, 影響結果,而兩者都影響同一個變項 (比方說某個早年就測過的檢驗值)。
完全符合「發生在暴露之前」,它跟暴露有關聯、跟結果也有關聯——照傳統三條件的檢查表,它看起來就是個標準的干擾因子。但它其實是 與 的對撞因子。校正它,就打開了 這條原本關著的路:真實效果是 0,校正後的估計是 −0.124。
中介因子:校正掉的是你想估的東西
第三列的情況不一樣——它不是偏誤,是估錯了問題。
模擬裡藥物有兩條作用路徑:直接的一條,以及透過中介變項(血壓)的一條。總效果是 0.84,未校正的模型估到 0.841,估得很準。把中介變項放進去之後,估計掉到 0.200——那正好是直接效果。
所以這裡沒有人算錯,只是這兩個數字回答的是不同的問題:
- 總效果(total effect):「給這個藥,結果會差多少?」——這是臨床決策要的數字
- 直接效果(direct effect):「扣掉降血壓那條路之後,這個藥還剩多少作用?」——這是機轉研究要的數字
後門準則的白話版
有了上面三種路徑,「該校正什麼」就有了一個可以機械執行的準則。後門路徑(backdoor path)指的是任何一條從暴露出發、第一個箭頭是指向暴露的(也就是箭頭「倒著」離開暴露)、最後走到結果的路徑。這種路徑帶來的關聯不是因果,是雜訊。
後門準則(backdoor criterion)的白話版是三句話:
- 把所有後門路徑找出來
- 挑一組變項,讓每一條後門路徑都被擋住(路徑上有一個非對撞的節點被校正,或是路徑上有一個對撞節點且它沒有被校正)
- 這組變項裡不能包含暴露的後代(也就是暴露造成的任何東西,包括中介因子)
滿足這三條的那組變項,就是一組合法的校正集合(adjustment set)。注意這裡有兩件事跟直覺不合:
- 合法的校正集合可能不只一組,而且不一定是變項最多的那組
- 有時候把某個變項加進去,會讓原本合法的集合變成不合法——這正是 M-bias 的情況
實務上不必手算:dagitty 套件(也有網頁版)可以把圖畫進去、直接吐出所有最小的合法校正集合,下一節就示範怎麼做。真正需要人做的是把箭頭畫對,那部分沒有工具可以代勞。
把 DAG 交給機器:從圖到校正集合
上一節說「實務上不必手算」。這一節就是那句話的兌現——把同一張圖寫成程式碼,讓它自己吐出答案。
dagitty 的語法幾乎就是把箭頭打出來。下面這段是這一頁第一張圖左上角那個干擾因子結構,一字不差:
library(dagitty)
g <- dagitty('dag {
A [exposure,pos="0,0"]
Y [outcome,pos="1,0"]
C [pos="0.5,-0.85"]
A -> Y
C -> A
C -> Y
}')
adjustmentSets(g) # 所有最小充分校正集合
impliedConditionalIndependencies(g) # 這張圖宣稱「應該看不到」的關聯驗證環境:dagitty 0.3.4。網頁版在 dagitty.net,語法完全相同——下面的字串可以直接貼進去。
# dagitty 沒有等價的 Python 套件。networkx 畫得出圖,
# 但後門準則的求解要自己實作,實務上不划算。
# 建議:DAG 這一步用 R 或 dagitty.net,再把變項清單帶回 Python。Python 這邊沒有對等成熟的實作;DAG 這一步建議直接用 R 或網頁版。
pos= 只影響版面、不影響運算;寫上去是為了讓你貼進 dagitty.net 之後看到的圖跟這裡一樣。
(y 座標取負值,因為網頁版的 y 軸朝下。)
四張圖各自的答案
把這一頁的四個結構都跑一遍,adjustmentSets() 給出的是:
| 結構 | 直覺上會校正的變項 | adjustmentSets() | 直覺對嗎 |
|---|---|---|---|
| 干擾因子 | C | { C } | 對 |
| 對撞因子 | S | { } | 錯 |
| 中介因子 | M | { } | 錯 |
| M-bias | Z | { } | 錯 |
四個結構裡只有干擾因子那一個,直覺的答案與工具的答案一致。另外三個,工具都回
{ }——什麼都不要校正。
這正是這一頁前面用模擬證明過的事,只是換成另一種證據:前面回答「校正了會偏多少」, 這裡回答「這張圖從一開始就沒有要求你校正任何東西」。兩種證據指向同一個結論, 而後者不需要跑任何一筆資料。
DAG 可以被資料反駁
impliedConditionalIndependencies() 列的是這張圖宣稱「在資料裡應該看不到關聯」的地方。
對撞因子與 M-bias 兩個結構,它都吐出同一句
A _||_ Y——
暴露與結果應該無關,因為這兩張圖裡本來就沒有從 A 到 Y 的因果箭頭;
M-bias 那張圖裡唯一把兩者連起來的那條路徑要經過對撞因子 Z,只要你不去校正它,那條路就一直是關著的。
這件事的用處是:你可以拿資料去檢查它。如果資料裡 A 與 Y 明明有關聯,那不是資料有問題, 是你的圖畫錯了——漏了一條箭頭,或是某條箭頭的方向反了。
反過來,干擾因子與中介因子那兩張圖的清單是空的。原因是那兩張圖在觀察得到的三個節點上是完全圖 (每一對之間都有直接的箭頭),而完全圖禁不了任何東西,因此也無從被資料反駁。 這個不對稱值得記住:一張什麼都不禁止的 DAG,是一張無法被證明錯誤的 DAG。
那到底該校正什麼
把上面所有東西壓成一張可以帶進 meeting 的檢查表:
| 變項的角色 | 怎麼認 | 該不該進模型 |
|---|---|---|
| 干擾因子 | 在暴露之前,同時影響暴露與結果,不在因果路徑上 | 要,這是校正存在的理由 |
| 結果的預測因子(與暴露無關) | 影響結果、但與暴露無關 | 可以放,會提高精確度、不會製造偏誤 |
| 暴露的預測因子(與結果無關) | 影響暴露、但不影響結果 | 通常不放,會降低精確度並放大殘餘偏誤 |
| 中介因子 | 在暴露之後,是暴露發揮作用的中間站 | 不放(除非你要的就是直接效果,且做了完整的中介分析) |
| 對撞因子 | 同時被暴露與結果影響 | 絕對不放,也不能用它篩選樣本 |
| 暴露之前的對撞因子(M-bias) | 是兩個未測量因素的共同結果 | 不放,但代價取決於你不知道的箭頭強度 |
| 工具變數 | 影響暴露、但只透過暴露影響結果 | 不放進迴歸,它有自己的用法,見工具變數 |
從 DAG 走到實際的分析
DAG 只告訴你「該擋住哪些路」,不告訴你「怎麼擋」。擋的方法有好幾種,各有各的代價,接下來的幾頁分別是:
- 在迴歸模型裡校正——最直接,見Cox 模型與線性迴歸
- 傾向分數配對——把「誰接受暴露」這件事壓成一個分數,再配出兩組可比較的人
- 逆機率加權——用同一個分數加權,造出一個假想的母體
- 工具變數——當關鍵的干擾因子根本沒被測量時,唯一還有機會的路
- 自我對照設計——讓每個人當自己的對照,一次消掉所有不隨時間變的個人特徵
前三種都有同一個前提:干擾因子有被測量到。 DAG 上那些灰色的、未測量的節點,配對與加權完全處理不了——它們只是換一種方式使用你手上已有的變項。這件事在世代研究那一章已經說過一次,值得再說一次。
常見誤用
| 誤用 | 為什麼錯 |
|---|---|
| 「校正越多越好」 | 對撞因子與中介因子會讓估計變差,不是變好 |
| 用單變項 p 值或逐步迴歸挑校正變項 | 該不該校正是因果結構問題,資料裡的關聯回答不了 |
| 「兩組間有顯著差異所以要校正」 | 只滿足干擾三條件的第一條 |
| 校正治療之後才測到的變項 | 那多半是中介因子或對撞因子,兩種都會壞事 |
| 把中介因子校正掉還說估的是治療效果 | 估到的是直接效果,不是總效果 |
| 只分析住院/只分析有回覆的人,卻不當成一回事 | 用對撞因子篩選樣本與把它放進模型是同一個動作 |
| 「這個變項發生在暴露之前,所以校正一定安全」 | M-bias 是這條規則的反例 |
| 校正後就宣稱因果 | 未測量的干擾因子不會出現在任何統計量裡 |
| 把工具變數當共變項校正進去 | 會放大殘餘偏誤,它的正確用法是另一套估計式 |
| DAG 畫完就當成已經證明了因果結構 | DAG 是把假設寫清楚,不是把假設驗證掉 |
重跑本頁的所有數字
/opt/homebrew/bin/Rscript figures/scripts/B6-01-dag.R讀讀看這張圖
答案取自產生本頁圖表的同一份統計輸出,不是另外打上去的。
模擬把暴露與結果設定成完全獨立,真實效果是 0。500 次模擬裡,把兩者的共同結果放進模型之後,平均估計移到了別的地方。哪一句正確說明這個移動?
看答案與解析
正確答案: 校正後 500 次模擬的平均是 -0.388,這條關聯是校正共同結果打開的,資料生成的時候並沒有它
未校正的平均是 0.001,貼著模擬設定的真實效果;把共同結果放進模型之後,500 次模擬的平均掉到 -0.388。那不是隨機誤差——單次模擬校正後的區間上界 -0.372 已經整段落在零的左邊,既沒有涵蓋零,也確實改變了結論。對撞路徑預設是關著的,校正會把它打開,於是兩個毫無關係的變項看起來高度相關。同樣的事不只發生在「放進模型」:只收住院病人、只分析有回覆問卷的人,在 DAG 上都是同一個動作。
模擬裡藥物有兩條作用路徑,總效果是 0.84。把中介變項放進模型之後估計掉了下來。哪一句正確說明這個下降?
看答案與解析
正確答案: 校正後估到 0.200,那是扣掉中介那條路之後的直接效果,它回答的是機轉問題而不是總效果
未校正估到 0.841,貼著真實的總效果;放進中介變項之後估計是 0.200,那正是模擬裡設定的直接效果那一條路。這一列不是有人算錯,是估到了另一個問題的答案:總效果回答「給這個藥,結果會差多少」,直接效果回答「扣掉那條路之後還剩多少作用」。而 -0.640 是那一列的偏誤欄,也就是估計值與真實值的差,不是任何模型印出來的係數;把偏誤欄讀成估計欄會得到「這個藥有害」這種相反的結論。至於共線性,中介變項與暴露相關正是它一定會改變暴露係數的原因,那個改變就是被扣掉的那條路。
干擾因子那一列的未校正平均估計是 1.390,而模擬設定的真實效果是 1。哪一句正確說明未校正的估計高出來的那一段?
看答案與解析
正確答案: 那一段是 0.390,共同原因讓本來就容易發生結果的人比較容易被暴露,既有的差異被算到暴露頭上
1.390 是未校正的平均估計,1.000 是把共同原因放進模型之後的平均估計,兩者差 0.390,而那一欄在表上就叫偏誤欄。共同原因同時決定誰被暴露與誰會發生結果,於是兩組人本來就存在的差異被算到暴露的帳上;把那條分岔路關掉,估計就回到 1。0.021 是未校正那一列 500 次模擬之間的標準差,比 0.390 小一個數量級,所以那個落差不可能是模擬的隨機波動——這也是為什麼偏誤要看重複模擬的平均,不看單一次的估計。至於「校正把效果一起扣掉了」,校正後的平均並沒有低於真實效果,它就落在真實效果上。
把中介那張圖交給 dagitty 的 adjustmentSets(),它回傳的是什麼?
看答案與解析
正確答案: 它回傳 { },這張圖不要求校正任何東西,直覺想放的中介變項不在任何合法的集合裡
後門準則的第三條說得很明白:合法的校正集合不能包含暴露的後代,而中介變項正是暴露造成的東西。所以這張圖的答案是空集合,工具與模擬指向同一個結論——前者說「這張圖從一開始就沒有要求你校正任何東西」,後者說「校正了會偏多少」。含變項的那一組是干擾因子那張圖的答案,把它套到這張圖上等於把兩張圖的結論交換;而「必須校正的變項」是你打算輸入的東西,不是工具算出來的輸出,工具從來不會把暴露的後代放進來。要注意工具忠實回答的是你畫的那張圖,圖畫錯,答案就錯,而且錯得很有自信。
M-bias 那一列的偏誤比對撞因子那一列小得多。有人據此主張「遇到不確定的變項寧可校正」。哪一句最能說明這個主張的問題?
看答案與解析
正確答案: 校正 Z 之後的平均是 -0.124,而這個量級由四條箭頭決定,其中兩條連到你估不出來的變項
真實效果是零,不校正 Z 時平均估到 -0.001,校正之後掉到 -0.124。它確實比對撞因子那一列的 -0.388 小,這與「實務上寧可校正」那一派的說法一致,但兩件事要一起記得:這個量級由四條箭頭的強度決定,而其中兩條連到未測量的變項,資料估不出它們有多強。所以實務上的結論不是「要不要校正」,而是你必須先寫下你認為的結構,才知道自己在賭什麼。至於「實務上不會發生」,Z 發生在暴露之前、跟暴露有關聯、跟結果也有關聯——照傳統三條件的檢查表它看起來就是干擾因子,這正是「只校正暴露之前的事」這條簡化規則會失手的地方。
對撞因子那一列,單次模擬校正後的估計是 -0.413,95% 信賴區間不跨過零,p 值小到報表印不出來。哪一句正確說明這個 p 值?
看答案與解析
正確答案: 模擬設定的真實效果是 0.000,p 值只說這個關聯不容易由抽樣誤差產生,不說它是怎麼來的
真實效果被設定成 0.000,而校正對撞因子之後那個關聯不但存在、還高度顯著。p 值檢驗的是「這麼大的關聯有多容易由抽樣誤差產生」,不是「這個關聯是不是因果」——偏誤被穩定地複製出來的時候,p 值只會更小、區間只會更窄,所以「離零很遠」與「穩固的因果證據」之間沒有橋,區間下界 -0.455 說的是同一件事。未校正模型的 p 值 0.797 才是該信的那一個,而它之所以該信,不是因為數值比較大,是因為那個模型沒有打開對撞路徑。哪一個模型該信,是圖的形狀決定的,不是 p 值決定的。
用到這個方法的章節
延伸觀看
Clearing Up Confounding
因果圖 DAG 是什麼?一張圖看穿因果的陷阱
Introduction to Causal Graphs
Directed Acyclic Graphs (DAGs)
Principles of Epidemiology 05. Causal Inferences, Bias, Confounding, and Interaction素材來源與授權
本頁為原創內容