この連載について
因果推論を「本を読んだ」で終わらせず、自分の言葉で説明でき、コードで再現できる状態まで落とす30日連載です。前回のDay 9では、感度分析とE-valueで「検証できない仮定に数字で向き合う」ところまでやり、潜在的結果から始まったPhase 1が一区切りつきました。ただここまで、肝心の「どの変数を調整すべきか」は、年齢や事前リスクといったもっともらしい共変量を選ぶ、という直感に頼ってきました。今日からのPhase 2では、Day 3の地図で言うPearl流に入ります。初回は、因果の仮定を1枚の図として書き下す言語、**DAG(有向非巡回グラフ)**の読み方です。
TL;DR(3行)
- DAGはデータから出てきません。分析者が置く因果の仮定を、図の形で宣言したものです。 矢印1本が仮定1つ。そして矢印が「無い」ことも仮定です。
- どんなDAGも3つの部品でできています。 連鎖・分岐・合流。条件付けで相関は、連鎖 +0.58 → +0.00・分岐 +0.50 → +0.00 と消え、合流では逆に +0.00 → -0.50 と生まれます。
- Day 1の罠の正体はこの部品でした。 アイスと水難事故(r = 0.68)は分岐、芸能人の才能と美貌(絞ると r = -0.71)は合流への条件付け。同じ「条件付け」が、構造しだいで真逆に働きます。
今日の問い
Day 6からずっと「正しい$X$で条件付ければ、層の中はRCT」と言ってきた。でも、その「正しい$X$」は誰がどうやって決めるのか。
「何で調整すべきか」を、人によって答えが変わらない形で決めたい。因果の仮定を図として書き下すとは、どういうことか?
今日のゴールは、DAGという言語の語彙(ノード・矢印・パス)と、関連が図の上をどう流れるかを決める3つの基本パターンを、相関係数の数値で体に入れることです。その上でDay 1の2つの罠をDAGとして読み直し、自分の課題をDAGに書き起こす手順まで降ろします。調整セットの機械的な判定(d分離とバックドア基準)は次回Day 11に置き、今日はその部品をそろえます。
概念① DAG:因果の仮定を宣言する言語
DAGは directed acyclic graph、有向非巡回グラフの略です。語彙は4つで足ります。
- ノードは変数です。処置も、アウトカムも、交絡因子の候補も、全部ノードとして並べます。
- **有向辺(矢印)**は「直接の因果」の仮定です。$A \to B$ は「Aを動かせばBが動く」という主張で、矢印1本が仮定1つに対応します。
- パスは、矢印をたどってノードからノードへ渡る道です。途中で矢印の向きに逆らってもかまいません。
- 非巡回は、たどって自分に戻るループが無いことです。原因は結果に先行するので、因果の図は一方通行になります。
ここで、今日いちばん大事なことを先に言います。DAGはデータから出てきません。分析者が置く仮定の宣言です。 同時分布(相関や条件付き確率)の中には、どちらの変数がどちらを動かすかを教える情報が原理的に含まれていません。Pearlはこれを「相関のみから因果的主張を実証することはできない。すべての因果的結論の背後には、観察研究では検証できない因果的仮定が存在しなければならない」と定式化しています。Day 6で「非交絡性は検証できない」と身をもって確かめたのと同じ告白を、DAGは隠さずに図として全員の目の前に置きます。ここがこの言語の価値です。
もうひとつ、見落としやすい点があります。矢印が無いことも仮定です。 辺の欠如は「この2変数の間に直接の因果関係は無い」という積極的な主張で、しばしば矢印を引くことより強い仮定になります。DAGを読むときは、描いてある矢印と同じくらい、描かれていない矢印を確認します。
最後に、処置$T$とアウトカム$Y$を結ぶパスを2種類に分けます。$T$から矢印の向きどおりに$Y$へ進める道が因果パス、どこかで矢印に逆らう道が非因果パスです。非因果パスのうち、$T$の背後($T$へ入ってくる矢印)から回り込む道をバックドアパスと呼びます。Day 1で「裏道」と呼んだものの正式名称です。相関は2種類のパスを区別せずに流れるので、観測される相関はその合算です。因果だけを取り出すには、因果パスに触らず、非因果パスだけを塞ぐ必要があります。どのパスが「開いて」いるのか、条件付けで何が起きるのか。それを決めるのが次の3パターンです。
概念② 3つの基本パターン:連鎖・分岐・合流
どんなに複雑なDAGも、パスを1歩ずつ見れば真ん中のノードの型は3つしかありません。矢印が「通り抜ける」か、「流れ出す」か、「ぶつかる」かです。
連鎖(チェーン) $X \to M \to Y$ の$M$は中間変数(メディエーター)です。関連は$M$を通って流れます($X \not\perp Y$)。ところが$M$で条件付けると流れが止まります($X \perp Y \mid M$)。同じ$M$の値を持つ人たちの中では、$X$を知っても$Y$の追加情報が得られません。
分岐(フォーク) $X \leftarrow Z \to Y$ の$Z$は共通原因、因果推論の言葉で交絡因子です。$X$と$Y$に因果関係が無くても、関連は矢印に逆らって$Z$経由で流れます。$Z$で条件付けると止まります。Day 6の層別調整がやっていたのは、まさにこのパスを閉じる操作でした。
合流(コライダー) $X \to C \leftarrow Y$ の$C$は合流点です。矢印が$C$でぶつかるので、何もしなければ関連は流れません($X \perp Y$)。ところが$C$で条件付けると流れ始めます($X \not\perp Y \mid C$)。直感は「説明消去」です。合格者だけを見ると、筆記が弱いのに受かった人は面接が強かったはずで、無関係だった2つの能力が負に相関して見えます。$C$そのものでなく$C$の子孫で条件付けても、同じパスが開きます。
まとめると、放っておけば連鎖と分岐は開き、合流は閉じています。条件付けると、その全部が反転します。 開いたままの非因果パスに付いた名前が交絡バイアスと選択バイアスです。なおDay 1の罠1(純粋な偶然、多重比較で拾うそっくりさん)は、そもそも構造の無い相関なのでDAGの守備範囲外です。DAGが引き受けるのは罠2と罠3、つまり分岐と合流です。
手を動かす①:3パターンを描いて、条件付けで壊す
まずnetworkxで3パターンを図にします。DAGは辺のリストを渡すだけで作れます。
import matplotlib.pyplot as plt
import matplotlib_fontja # 日本語ラベル用(pip install matplotlib-fontja)
import networkx as nx
patterns = {
"連鎖(チェーン)": [("X", "M"), ("M", "Y")],
"分岐(フォーク)": [("Z", "X"), ("Z", "Y")],
"合流(コライダー)": [("X", "C"), ("Y", "C")],
}
pos = {"X": (0, 0), "Y": (2, 0), "M": (1, 1), "Z": (1, 1), "C": (1, 1)}
fig, axes = plt.subplots(1, 3, figsize=(10, 2.8))
for ax, (name, edges) in zip(axes, patterns.items()):
g = nx.DiGraph(edges)
nx.draw_networkx(g, pos, ax=ax, node_color="#4E79A7", font_color="white",
node_size=900, arrowsize=16)
ax.set_title(name)
ax.set_axis_off()
plt.tight_layout()
plt.show()
描いてみると、3枚は見た目がほとんど同じで、違うのは矢印の向きだけです。その向きだけで条件付けの効き方が真逆になることを、合成データで確かめます。条件付けには、Day 1の最後に出た偏相関(条件付けたい変数を回帰で取り除いた残差どうしの相関)を使います。
import numpy as np
rng = np.random.default_rng(256)
N = 10_000
def r(a, b):
"""相関係数"""
return np.corrcoef(a, b)[0, 1]
def r_given(a, b, c):
"""cで条件付けた偏相関(cを回帰で取り除いた残差どうしの相関)"""
def resid(v):
return v - np.poly1d(np.polyfit(c, v, 1))(c)
return r(resid(a), resid(b))
# 連鎖: X → M → Y(因果はXからYへ流れる)
x = rng.normal(0, 1, N)
m = x + rng.normal(0, 1, N) # MはXが動かす
y_chain = m + rng.normal(0, 1, N) # YはMが動かす
# 分岐: X ← Z → Y(XはYの原因ではない。共通原因Zが両方を動かす)
z = rng.normal(0, 1, N)
x_fork = z + rng.normal(0, 1, N)
y_fork = z + rng.normal(0, 1, N)
# 合流: X → C ← Y(XとYは無関係。共通の結果Cを持つだけ)
x_col = rng.normal(0, 1, N)
y_col = rng.normal(0, 1, N)
c = x_col + y_col + rng.normal(0, 1, N)
print("連鎖 r(X,Y) = %+.2f -> Mで条件付け = %+.2f" % (r(x, y_chain), r_given(x, y_chain, m)))
print("分岐 r(X,Y) = %+.2f -> Zで条件付け = %+.2f" % (r(x_fork, y_fork), r_given(x_fork, y_fork, z)))
print("合流 r(X,Y) = %+.2f -> Cで条件付け = %+.2f" % (r(x_col, y_col), r_given(x_col, y_col, c)))
連鎖 r(X,Y) = +0.58 -> Mで条件付け = +0.00
分岐 r(X,Y) = +0.50 -> Zで条件付け = +0.00
合流 r(X,Y) = +0.00 -> Cで条件付け = -0.50
3行それぞれに読みどころがあります。分岐は、$X$が$Y$の原因ではないのに +0.50 の相関が出て、$Z$で条件付けると +0.00 に消えます。見かけの相関が調整で消える、Day 6でやったことの構造版です。連鎖の +0.58 は本物の因果による相関ですが、$M$で条件付けるとこちらも +0.00 まで消えます。処置の総効果を知りたいときに中間変数を「ついでに」調整へ入れると、因果パスを自分で塞いでしまう。Day 6のつまづきで「処置の後で動く変数を調整に入れるな」と書いた理由が、この1パネルです。そして合流は、もともと無相関(+0.00)だったのに、$C$で条件付けた瞬間に -0.50 が生まれます。データに存在しなかった相関を、分析者の操作が作り出しました。ちなみに3つの数値は係数から手計算できる理論値($1/\sqrt{3} \approx 0.58$、$1/2$、条件付け後の合流は $-1/2$)とぴったり一致しています。
手を動かす②:Day 1の罠をDAGに描き直す
Day 1では「アイスの売上と水難事故の相関0.68は気温の仕業」「無関係な才能と美貌が、芸能人に絞ると負に相関して見える」を数値で確かめました。当時はまだ罠の名前しかありませんでしたが、いまは語彙がそろっています。同じデータ(シードもDay 1と同じ)をDAGとして読み直します。
import numpy as np
# 罠2(分岐): Day 1と同じ生成式・同じシード
rng = np.random.default_rng(42)
n = 2000
season = rng.integers(0, 5, n) # 気温・季節(共通原因)
ice = 10 + 8 * season + rng.normal(0, 4, n) # アイス売上
drown = 2 + 3 * season + rng.normal(0, 4, n) # 水難事故(iceには依存しない)
within = [np.corrcoef(ice[season == s], drown[season == s])[0, 1] for s in range(5)]
print("分岐 全体 r = %.2f -> 同じ季節の中では平均 r = %.2f"
% (np.corrcoef(ice, drown)[0, 1], np.mean(within)))
# 罠3(合流): こちらもDay 1と同じ
rng = np.random.default_rng(7)
talent = rng.normal(0, 1, 4000) # 才能
looks = rng.normal(0, 1, 4000) # 美貌(才能とは独立)
star = (talent + looks) > 1.5 # 芸能人=合流点で選抜
print("合流 全体 r = %+.2f -> 芸能人だけに絞ると r = %+.2f"
% (np.corrcoef(talent, looks)[0, 1], np.corrcoef(talent[star], looks[star])[0, 1]))
分岐 全体 r = 0.68 -> 同じ季節の中では平均 r = 0.01
合流 全体 r = -0.00 -> 芸能人だけに絞ると r = -0.71
左の図(下)を見てください。アイス売上から水難事故への矢印はありません。あるのは気温からの2本だけです。つまり r = 0.68 は全量が「アイス ← 気温 → 水難」というバックドアパスを流れてきた関連で、気温で条件付けるとパスが閉じて 0.01 に消えます。右は「才能 → 芸能人か ← 美貌」の合流です。世間全体ではパスが閉じていて無相関、「芸能人だけ」に絞る行為が合流点での条件付けで、そこで -0.71 が生まれました。
大事なのはここです。この2枚の図が描けた時点で、「気温は調整してよい(すべき)」「芸能人か、で絞ってはいけない」という結論は、データを1行も見る前に決まっています。調整の可否を決めるのは相関の強さではなく図の形です。Day 1の最後に「何で調整すべきかは因果構造で決まる」と書いた伏線を、ここで回収します。
手を動かす③:自分の課題をDAGにする3ステップ
いよいよ自分の課題です。白紙からDAGを描く手順は、3ステップに分けると迷いません。
- 変数を列挙する。 処置とアウトカム、そのどちらか(できれば両方)を動かしそうな変数を書き出します。測れるかどうかは、この段階では気にしません。
- 矢印を1本ずつ、根拠付きで引く。 「AがBを動かす仕組みを一文で言えるか」を通った矢印だけ引きます。言えなければ引かない。この一文がそのまま仮定の宣言になります。
- 観測可否をマークする。 各ノードが手元のデータで測れるかを記します。測れない変数を図から消さないこと。消した瞬間、その交絡は「無いこと」になってしまいます。
例として本業の課題を1つ描きます。衛星画像による漏水調査を導入した水道事業体で、漏水による損失水量は本当に減ったのか、という施策評価です。
このDAGをnetworkxに書き下すと、処置とアウトカムを結ぶパスの列挙まで機械にやらせることができます。
import networkx as nx
# ステップ1と2の結果を辺リストで書き下す(矢印1本=仮定1つ。コメントが根拠)
g = nx.DiGraph([
("衛星調査の導入", "修繕件数"), # 調査で漏水箇所が見つかり、修繕が進む
("修繕件数", "損失水量"), # 修繕が進むほど漏水による損失は減る
("財政力", "衛星調査の導入"), # 財政に余裕がある事業体ほど新技術を導入できる
("財政力", "損失水量"), # 財政力は管路更新への投資を通じて損失も動かす
("老朽化度", "衛星調査の導入"), # 老朽化が深刻なほど調査の必要に迫られる
("老朽化度", "損失水量"), # 老朽化そのものが漏水を増やす
("改善意欲", "衛星調査の導入"), # 意欲的な事業体ほど手を挙げる
("改善意欲", "損失水量"), # 意欲的な事業体は調査以外の対策も打つ
])
unobserved = {"改善意欲"} # ステップ3: 観測できない変数をマーク
t, y = "衛星調査の導入", "損失水量"
def fmt(path):
s = path[0]
for a, b in zip(path, path[1:]):
s += (" -> " if g.has_edge(a, b) else " <- ") + b
return s
causal = list(nx.all_simple_paths(g, t, y))
print("因果パス(矢印の向きどおりに進む道):")
for p in causal:
print(" " + fmt(p))
print("非因果パス(バックドアパス):")
for p in nx.all_simple_paths(g.to_undirected(), t, y):
if p not in causal:
mark = " ※観測できない変数を含む" if set(p) & unobserved else ""
print(" " + fmt(p) + mark)
因果パス(矢印の向きどおりに進む道):
衛星調査の導入 -> 修繕件数 -> 損失水量
非因果パス(バックドアパス):
衛星調査の導入 <- 財政力 -> 損失水量
衛星調査の導入 <- 老朽化度 -> 損失水量
衛星調査の導入 <- 改善意欲 -> 損失水量 ※観測できない変数を含む
因果パスは「導入 → 修繕 → 損失水量」の1本で、ここは塞いではいけません(修繕件数を調整に入れたら連鎖のパネルの再演です)。バックドアは3本の分岐で、財政力と老朽化度は財政指標や管路台帳で測れるので条件付けで塞げます。問題は3本目です。改善意欲は測れないので、このバックドアは調整では塞げません。図を描いたことで、Day 6の「非交絡性は検証できない」が具体的な1本のパスの名前になり、Day 9のE-value(意欲が導入と損失水量にどれほど強く結びついていたら結論が消えるか)を報告すべき場所も、図の上で指させるようになりました。
つまづき・誤解しやすい点
- 「相関行列からDAGを推定すればいい」ではありません。 相関や条件付き独立は分布から定義できる相関的概念、矢印は分布からは定義できない因果的概念で、両者の間には越えられない線があります(Pearlの境界線)。データから構造を探す「因果探索」という分野はありますが、それ自体が忠実性など検証できない仮定の上に立ちます。DAGを描く責任は分析者にあり、だからこそ宣言する価値があります。
- 矢印を引かないことは「わからない」の表明ではありません。 辺の欠如は「直接効果が無い」という積極的な主張です。確信の持てない矢印は引いておくのが保守的な作法です。その分バックドアが増えて調整は大変になりますが、怪しい交絡を勝手に「無いこと」にするよりずっと安全です。
- 合流点の事故は、回帰に変数を入れるときだけ起きるのではありません。 層別も、サンプルの絞り込み(アンケート回答者だけ、稼働中の設備だけ)も、すべて条件付けです。しかも合流点そのものでなく、その下流の変数で絞っても同じパスが開きます。調整で悪化するパターンの網羅と機械判定は次回Day 11でやります(M字バイアスもそこで扱います)。
実務での使いどころ(空間データ/天地人の文脈)
- 予測モデルと因果分析では、入れてよい変数が違います。 漏水の発生を予測するだけなら、合流点だろうと下流変数だろうと、精度が上がる特徴量は入れて構いません。施策評価では、同じ変数が入れた瞬間にバイアスを生みます。同じテーブルから作る2つのモデルで変数リストが違う理由を、DAGは1枚で説明してくれます。
- DAGはチームの共有言語になります。 「交絡が心配」という漠然とした不安は、反論も検証もできません。「財政力から導入と損失水量へ2本の矢印が刺さっている」なら、矢印1本ごとに現場の知見で潰し合える具体的な論点です。数式を介さないので、自治体や現場の担当者が仮定の議論に参加できるのが、実務でいちばん効くところだと感じます。
- 空間の交絡は、DAGでは巨大な分岐として描けます。 隣り合う自治体は気候・地質・人口動態という共通原因を共有します。地図上の相関の多くは「立地」という共通原因からの分岐かもしれません。分岐なら条件付けで塞げるはずですが、空間ではこの共通原因が観測できない上に無数にあります。ここはDay 28(空間×因果)で正面からやります。
参考(本棚)
- Matheus Facure『Causal Inference in Python』第3章 Graphical Causal Models。チェーン・フォーク・コライダーと関連の流れ、networkxでのグラフ照会。今日の下敷き
- J. Pearl (2009) Causal Inference in Statistics: An Overview. Statistics Surveys 3。相関的概念と因果的概念の境界線。「すべての因果的結論の背後には、観察研究では検証できない因果的仮定が存在する」
- Cinelli, Forney & Pearl (2022) A Crash Course in Good and Bad Controls. Sociological Methods & Research。調整してよい変数・いけない変数のカタログ。Day 11の予習に
- 図はすべて合成データです。3パターンの相関は理論値(連鎖 $1/\sqrt{3}$・分岐 $1/2$・条件付け後の合流 $-1/2$)が手計算でき、シミュレーションがそれを再現します。手を動かす②はDay 1と同じシードで数値を再現しました
次回予告(Day 11)
冒頭の一文をもう一度置いて締めます。DAGはデータから出てきません。分析者の仮定の宣言です。 だからこそ、描いたあとの「読み取り」は誰がやっても同じ答えにならなければ困ります。今日の3パターンは部品にすぎず、実際のDAGでは何本ものパスが絡み合います。任意のDAGで「このパスは開いているか」「どの変数集合で条件付ければ、因果パスを残したままバックドアが全部塞がるか」を機械的に判定する規則が、次回のd分離とバックドア基準です。networkxの is_d_separator で判定し、正しい調整セットは真値に戻る、悪い調整(合流点・中間変数)は真値を壊す、を数値で確かめます。

