この連載について
因果推論を「本を読んだ」で終わらせず、自分の言葉で説明でき、コードで再現できる状態まで落とす30日連載です。直前のDay 10では、因果の仮定をDAG(有向非巡回グラフ)という1枚の図に描き、チェーン・フォーク・コライダーの3パターンで、条件付けによって関連が消えたり生まれたりする様子を確かめました。今日はその3パターンを任意のDAGに使える判定規則へ拡張し、「どの変数を調整すべきか」を機械的に決めます。図はすべて合成データで、真の効果とDAGを私たちが握った状態で見せます。
TL;DR(3行)
-
d分離=DAG上のすべてのパスの開閉を規則で判定し、独立性を読み取る道具。 チェーンとフォークは条件付けで塞がり、コライダーは条件付けで開きます。networkx の
is_d_separatorの判定は、合成データの偏相関(0.578 → 0.001 → −0.731)と正確に対応します。 - バックドア基準=「処置へ矢印が刺さる非因果パスをすべて塞ぎ、処置の子孫は使わない」調整セットの合格条件。 満たす集合 {Z} で調整すると素朴な 3.00 が真値 2.00 に戻り、DoWhy は同じ集合を自動で選びます。
- 調整は足すほど安全ではありません。 中間変数を足すと効果が 0.00 に消え、コライダーを足すと −0.75 に符号反転し、M字バイアスでは真の効果ゼロが −0.40 の「効果」に化けます。
今日の問い
交絡っぽい変数が5個ある。どれを調整に入れ、どれを入れてはいけないのか。
「関係ありそうな変数は全部入れる」ではなく、勘に頼らない規則で決められるか?
Day 6 のつまづきで「処置の後で動く変数を調整するとバイアスを生む」と予告し、Day 10 で3つのパターンを見ました。今日はそれを2つの道具に組み上げます。1つ目は任意のDAGで独立性を判定するd分離、2つ目は調整セットの合否を決めるバックドア基準です。最後に、基準を破る「悪い調整」を3連発で実演し、足し算の調整がどう壊れるかを数値で見ます。
概念① d分離:パスの開閉を機械的に判定する
Day 10 の3パターンは、そのまま「パスが情報を通すか」の規則になります。2つの変数を結ぶパスの上のノードを1つずつ見て、次を確かめます。
- チェーン $A \to B \to C$ とフォーク $A \leftarrow B \to C$ は、素通しです。$B$ を条件付けると塞がります。
- コライダー $A \to B \leftarrow C$ は逆で、最初から塞がっています。$B$(またはその子孫)を条件付けると開きます。
- パスは、どこか1カ所でも塞がっていればブロックです。1カ所も塞がっていなければ開通です。
この規則を全パスに適用したものがd分離です。集合 $S$ を条件付けたとき、$X$ と $Y$ を結ぶパスがすべてブロックされるなら、$X$ と $Y$ は $S$ の下でd分離されていると言い、$X \perp!!!\perp Y \mid S$ が成り立ちます(Pearl の定義1)。大事なのは方向で、これは「このDAGが正しければ、データにこの条件付き独立が現れるはず」という含意です。つまりDAGという仮定から、検証可能な予言が機械的に出てきます。判定は目視でやると必ず漏れるので、networkx の is_d_separator に任せます。
概念② バックドア基準:調整セットの合格条件
処置 $T$ と結果 $Y$ を結ぶパスは2種類に分かれます。$T$ から矢印の向きに $Y$ へ流れるパスが因果パス、$T$ に矢印が刺さる側から始まるパス($T \leftarrow \cdots Y$)がバックドアパスです。バックドアパスは相関を運びますが因果ではありません。Day 6 で見た交絡の正体は、開いたバックドアパスそのものです。
調整セット $S$ の合格条件がバックドア基準です(Pearl の定義3)。
- $S$ は $T$ から $Y$ へのバックドアパスをすべてブロックする。
- $S$ は $T$ の子孫を含まない。
条件1が交絡を消し、条件2が「因果パスを塞ぐ・コライダーを開く」事故を防ぎます。手順に落とすと次のとおりです。
1つ白状しておくと、今日はこの規則を「使う」ことに徹します。バックドア基準を満たす $S$ で調整するとなぜ因果効果が出るのか、その証明は Day 12 の調整化公式で行います。今日の時点では「非因果の流れを全部塞ぎ、因果の流れは塞がない集合」という直感で前に進みます。
手を動かす①:is_d_separator の判定と偏相関を突き合わせる
今日の舞台になる5ノードのDAGを作ります。交絡 $Z$、処置 $T$、中間変数 $M$、結果 $Y$、コライダー $C$ で、因果は $T \to M \to Y$ 経由だけです。まずd分離の判定を機械にやらせて、同じ構造の合成データの偏相関(条件付けたあとの相関)が本当にゼロになるかを突き合わせます。
import numpy as np
import networkx as nx
# DAG: Z→T, Z→Y(交絡)/ T→M→Y(因果はM経由)/ T→C←Y(コライダー)
G = nx.DiGraph([("Z", "T"), ("Z", "Y"), ("T", "M"), ("M", "Y"), ("T", "C"), ("Y", "C")])
print("Z ⊥ M ? ", nx.is_d_separator(G, {"Z"}, {"M"}, set()))
print("Z ⊥ M | T ? ", nx.is_d_separator(G, {"Z"}, {"M"}, {"T"}))
print("Z ⊥ M | T,Y ?", nx.is_d_separator(G, {"Z"}, {"M"}, {"T", "Y"}))
# 同じ構造の合成データを作り、偏相関(条件付けたあとの相関)と突き合わせる
rng = np.random.default_rng(11)
N = 50_000
z = rng.normal(0, 1, N)
t = 1.0 * z + rng.normal(0, 1, N) # Z→T
m = 1.0 * t + rng.normal(0, 1, N) # T→M
y = 2.0 * m + 2.0 * z + rng.normal(0, 1, N) # M→Y と Z→Y(真の総合効果 τ=2.0)
c = 1.5 * t + 1.5 * y + rng.normal(0, 1, N) # T→C←Y
def pcorr(a, b, conds):
"""条件付け変数で回帰した残差同士の相関=偏相関"""
if conds:
X = np.column_stack(conds + [np.ones(N)])
a = a - X @ np.linalg.lstsq(X, a, rcond=None)[0]
b = b - X @ np.linalg.lstsq(X, b, rcond=None)[0]
return np.corrcoef(a, b)[0, 1]
print("corr(Z, M) = %6.3f" % pcorr(z, m, []))
print("pcorr(Z, M | T) = %6.3f" % pcorr(z, m, [t]))
print("pcorr(Z, M | T, Y) = %6.3f" % pcorr(z, m, [t, y]))
Z ⊥ M ? False
Z ⊥ M | T ? True
Z ⊥ M | T,Y ? False
corr(Z, M) = 0.578
pcorr(Z, M | T) = 0.001
pcorr(Z, M | T, Y) = -0.731
3行の判定と3つの数値がきれいに対応します。まず $Z$ と $M$ は、チェーン $Z \to T \to M$ が開いているので従属で、相関は 0.578 です。$T$ を条件付けるとこのチェーンが塞がり、偏相関は 0.001 とほぼゼロに落ちます。面白いのは3行目で、条件付けを増やして ${T, Y}$ にすると独立が壊れます。$Y$ はパス $Z \to Y \leftarrow M$ の上のコライダーなので、条件付けた瞬間にこのパスが開通し、偏相関 −0.731 という強い相関が無から生まれるからです。条件付けは増やすほど独立に近づく、という直感はDAGの上では成り立ちません。
手を動かす②:バックドア基準を満たす集合だけが真値に戻る
次に本題の「$T$ の $Y$ への効果」です。このDAGのバックドアパスは $T \leftarrow Z \to Y$ の1本で、塞げる変数は $Z$ だけです。まず DoWhy にDAGを渡して、バックドア基準を満たす調整セットを自動で選ばせ、そのとおりに回帰します。真の総合効果は $\tau = 1.0 \times 2.0 = 2.0$($T \to M$ が1.0、$M \to Y$ が2.0)に仕込んであります。
import numpy as np
import pandas as pd
import networkx as nx
import statsmodels.formula.api as smf
from dowhy import CausalModel
rng = np.random.default_rng(11)
N = 50_000
z = rng.normal(0, 1, N)
t = 1.0 * z + rng.normal(0, 1, N)
m = 1.0 * t + rng.normal(0, 1, N)
y = 2.0 * m + 2.0 * z + rng.normal(0, 1, N) # 真の総合効果 τ = 2.0
c = 1.5 * t + 1.5 * y + rng.normal(0, 1, N)
df = pd.DataFrame({"z": z, "t": t, "m": m, "y": y, "c": c})
# DoWhy にDAGを渡すと、バックドア基準を満たす調整セットを自動で選ぶ
G = nx.DiGraph([("z", "t"), ("z", "y"), ("t", "m"), ("m", "y"), ("t", "c"), ("y", "c")])
model = CausalModel(data=df, treatment="t", outcome="y", graph=G)
estimand = model.identify_effect()
print("バックドア調整セット =", estimand.get_backdoor_variables())
# 調整セットどおりに回帰して、素朴な推定と比べる
print("無調整 = %.2f" % smf.ols("y ~ t", df).fit().params["t"])
print("{Z} で調整 = %.2f (真値 2.0)" % smf.ols("y ~ t + z", df).fit().params["t"])
バックドア調整セット = ['z']
無調整 = 3.00
{Z} で調整 = 2.00 (真値 2.0)
DoWhy が返した調整セットは ['z'] です。手元にある5変数のうち $M$ と $C$ は「$T$ の子孫」なので候補から自動で外れています。無調整の回帰は 3.00 で、開いたバックドアパスのぶん真値2.0を上振れします。$Z$ で調整すると 2.00 となり真値に一致します。Day 6 で層別が交絡を消した現象を、今日は「バックドアパスを塞いだ」とグラフの言葉で言い直せるようになりました。
手を動かす③:悪い調整3連発、足すほど壊れる
ここからが今日の主戦場です。バックドア基準の条件2「$T$ の子孫を含まない」を破るとどうなるか。そして、条件1と2に合格しているように見えて壊れる有名な罠、M字バイアスまで実演します。
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
rng = np.random.default_rng(11)
N = 50_000
z = rng.normal(0, 1, N)
t = 1.0 * z + rng.normal(0, 1, N)
m = 1.0 * t + rng.normal(0, 1, N)
y = 2.0 * m + 2.0 * z + rng.normal(0, 1, N) # 真の総合効果 τ = 2.0
c = 1.5 * t + 1.5 * y + rng.normal(0, 1, N)
df = pd.DataFrame({"z": z, "t": t, "m": m, "y": y, "c": c})
# 悪い調整その1: 中間変数Mを足す。因果の通り道を塞ぎ、効果が消える
print("{Z, M} で調整 = %5.2f (真値 2.0)" % smf.ols("y ~ t + z + m", df).fit().params["t"])
# 悪い調整その2: コライダーCを足す。非因果パスを開き、符号まで反転する
print("{Z, C} で調整 = %5.2f (真値 2.0)" % smf.ols("y ~ t + z + c", df).fit().params["t"])
# 悪い調整その3: M字バイアス。シードを切り直して別のDAGを合成する
rng = np.random.default_rng(11)
u1 = rng.normal(0, 1, N) # 未観測
u2 = rng.normal(0, 1, N) # 未観測
b = 1.5 * u1 + 1.5 * u2 + rng.normal(0, 1, N) # 観測できる処置前変数(コライダー)
t2 = 1.5 * u1 + rng.normal(0, 1, N)
y2 = 1.5 * u2 + rng.normal(0, 1, N) # T→Yの辺なし=真の効果 0
dfm = pd.DataFrame({"t": t2, "y": y2, "b": b})
print("corr(T, B) = %.2f, corr(Y, B) = %.2f (交絡っぽく見える)"
% (np.corrcoef(t2, b)[0, 1], np.corrcoef(y2, b)[0, 1]))
print("無調整 = %5.2f (真値 0.0)" % smf.ols("y ~ t", dfm).fit().params["t"])
reg = smf.ols("y ~ t + b", dfm).fit()
lo, hi = reg.params["t"] - 1.96 * reg.bse["t"], reg.params["t"] + 1.96 * reg.bse["t"]
print("{B} で調整 = %5.2f (95%%CI [%.2f, %.2f])" % (reg.params["t"], lo, hi))
{Z, M} で調整 = 0.00 (真値 2.0)
{Z, C} で調整 = -0.75 (真値 2.0)
corr(T, B) = 0.53, corr(Y, B) = 0.53 (交絡っぽく見える)
無調整 = -0.00 (真値 0.0)
{B} で調整 = -0.40 (95%CI [-0.41, -0.39])
その1、中間変数 $M$ を足すと推定は 0.00 です。このDAGでは因果がすべて $T \to M \to Y$ を通るので、$M$ を条件付けた瞬間に因果パスそのものが塞がり、効果が丸ごと消えます。その2、コライダー $C$ を足すと −0.75 で、符号まで反転します。$C$ の条件付けが非因果パス $T \to C \leftarrow Y$ を開き、説明消去の負の相関が因果の正の効果を上書きするからです。効果を小さく見誤るどころか、「逆効果だ」という結論すら出かねません。
その3のM字バイアスは、もっとたちが悪い罠です。未観測の $U_1, U_2$ が $U_1 \to B \leftarrow U_2$ と合流し、$U_1 \to T$、$U_2 \to Y$ という「M字」のDAGで、$T \to Y$ の辺はありません。真の効果はゼロです。観測できる $B$ は処置より前の変数で、しかも $T$ とも $Y$ とも相関 0.53。Day 6 までの感覚なら「典型的な交絡因子、当然調整」に見えます。ところがバックドアパス $T \leftarrow U_1 \to B \leftarrow U_2 \to Y$ はコライダー $B$ で最初から塞がっているので、正解は「何もしない」です。実際、無調整は −0.00 で正しくゼロ。$B$ を調整すると塞がっていたパスが開き、−0.40、しかも95%信頼区間はゼロから遠く離れた「統計的に明確な効果」が無から現れます。
3つに共通する教訓は1つです。ある変数を調整すべきかどうかは、その変数と $T$ や $Y$ との相関の強さからは決められません。決めるのはDAG、つまりその変数がどのパスのどの位置にいるかという構造の仮定です。
つまづき・誤解しやすい点
- 「処置前で、TともYとも相関する変数=交絡因子」ではありません。 M字バイアスの $B$ はこの条件をすべて満たしますが、正体はバックドアパス上のコライダーで、調整すると悪化します。相関のチェックリストでは交絡は定義できず、DAGの構造だけが答えを持ちます。だからこそ Day 10 で強調した「DAGは仮定の宣言」が効いてきます。仮定したDAG自体を反証テストにかける話は Day 15 で扱います。
- 中間変数の調整は、常に誤りとは限りません。 手を動かす③の 0.00 は、正確には「$M$ を固定したときの直接効果」の推定です。問いが総合効果なら誤りですが、「$M$ 経由を除いた直接の寄与」を知りたい場面ではむしろ狙って条件付けます。壊れたのではなく別の推定対象(estimand)に化けたのであって、Day 2 の「どの平均を答えるのか」を先に決める話がここでも土台になります。
- バックドア基準に合格しても、まだ「因果効果が出る」ことを証明していません。 今日やったのは調整セットの機械的な選別までで、「その集合で調整した値が介入の効果に等しい」ことの証明は別物です。その証明が Day 12 の調整化公式で、「介入する」を数式で定義するdo演算子が主役になります。
実務での使いどころ(空間データ/天地人の文脈)
- 「とりあえず手持ちの変数を全部調整」が一番危ない、と説明できるようになります。 管路更新の効果を漏水率で評価するとき、更新後の修繕件数や監視で見つけた異常件数を「関係ありそうだから」と共変量に足すと、中間変数調整(効果が消える)やコライダー調整(処置と結果の両方の結果に条件付け)を素で踏みます。処置より後に動く変数を1つ外すだけで、今日の 0.00 や −0.75 の事故は避けられます。
-
予測モデルの特徴量選択と調整セットは別物です。 予測なら、リークさえなければ精度に効く変数を足すほど得です。しかし因果では、LightGBM の重要度で上位に来る変数がバックドア基準では「入れてはいけない側」ということが普通に起きます。効果検証の変数リストをレビューするときは、重要度の表ではなくDAGを1枚描き、
is_d_separatorや DoWhy の識別に判定させるのが速くて確実です。 - DAGを描くと、変数の議論が「矢印の有無」の議論に変わります。 「意欲の高い自治体ほど導入が早く成果指標も良い」のような交絡の主張は、矢印2本のバックドアパスとして図に置けます。合意できないのは大抵1〜2本の矢印だけで、そこだけ感度分析(Day 9)に回せます。なお隣接自治体へ効果が波及する空間の設定では、そもそもパスの前提が崩れます。ここは Day 28 でまとめて掘ります。
参考(本棚)
- Pearl『Causal Inference in Statistics: An Overview』(Statistics Surveys, 2009) 第3節。d分離(定義1)とバックドア基準(定義3)の原典。今日の下敷き
- Facure『Causal Inference in Python』第3章。チェーン/フォーク/コライダーの直感と networkx によるd分離判定。今日の下敷き
- Pearl, Glymour & Jewell『入門 統計的因果推論』。d分離とバックドアの教科書的な導入
- Cinelli, Forney & Pearl『A Crash Course in Good and Bad Controls』(2022)。良い調整・悪い調整をDAGで網羅した決定版
- 図はすべて合成データです。真の効果とDAGを私たちが握ることで、悪い調整のズレをオラクル比較で再現しました。生成スクリプトは元ノート側に添付しています。
次回予告(Day 12)
今日は「バックドア基準を満たす集合で調整せよ」という規則を機械的に使えるようになりました。ただ、肝心のことをまだ定義していません。「調整すれば因果効果が出る」と言うときの因果効果とは、数式の上では何なのか。「治療を受けた人の生存率」と「治療を受けさせたときの生存率」はどう違うのか。次回 Day 12 は、観察の $P(Y \mid X)$ と介入の $P(Y \mid do(X))$ を区別するdo演算子を導入し、調整化公式を導いて、今日のバックドア基準がなぜ正しいのかを証明します。シンプソンのパラドックスの古典データで、層別と全体で結論が逆転する現象も do の言葉で解消します。


