この連載について
因果推論を「本を読んだ」で終わらせず、自分の言葉で説明でき、コードで再現できる状態まで落とすための30日連載です。1日1テーマ、各回にコピペで動く最小コードを必ず付けます。到達点はこう組みます。
Claudeと伴奏しながら自分の理解度を深めよう!ということでの1人アドベントカレンダー企画です。
学習計画や下書きなどはClaudeに全面的に協力していただいていますが、あくまでも自分の理解を深めるための企画です。
- Day 1–3:全体像(相関≠因果/潜在的結果/3つの流派)← いまここ
- Day 4–9:Rubin流(潜在的結果・傾向スコア)
- Day 10–15:Pearl流(DAG・バックドア基準・do演算子)
- Day 16–21:計量経済の準実験(回帰・IV・DID・RDD)
- Day 22–27:ML×因果(CATE・Double ML・Causal Forest)
- Day 28–30:空間データ×因果/ベイズ×因果/総括
初回は連載の土台になる問い、「なぜ相関を因果と勘違いしてしまうのか」 をやります。
TL;DR(3行)
- 相関と因果がズレるのは主に3つの罠 — ①ただの偶然(疑似相関)/②交絡(共通の原因)/③選択バイアス(サンプルの選ばれ方)。
- 「アイス売上」と「水難事故」の見かけの相関 0.68 は、共通原因の気温で層別化すると 0.01 まで消える(=交絡)。
- ただし②と③は処方箋が真逆。交絡は「調整すべき」、選択バイアス(合流点)は「調整してはいけない」。ここを取り違えると自分でバイアスを作り込む。
今日の問い
夏になるとアイスクリームの売上が伸び、同じ時期に水難事故も増える。データを取れば両者はきれいに正の相関を示す。
では、アイスの販売を止めれば、溺れる人は減るのか?
直感で「減るわけがない」と分かります。この直感の正体を言語化するのが今日のゴールです。相関が因果からズレる理由を3つに分解し、うち中心的な「交絡」を numpy で作って、条件付けで消えるところまで手を動かします。
相関が因果に化ける3つの罠
「2つの量が一緒に動く(相関)」ことと「片方が片方を動かす(因果)」ことは別物です。ズレを生む典型を3つに整理します。
罠1:疑似相関(ただの偶然)
因果関係も共通原因もない、純粋な偶然。無関係な系列でも、たくさん探せば必ず「相関するペア」が見つかります(多重比較)。有名な例が「ニコラス・ケイジの年間出演本数」と「プールでの溺死者数」。笑い話に見えますが、特徴量を大量に試すMLの現場でも同じ罠が口を開けています(あとで10万ペアで実演します)。
罠2:交絡(共通の原因)★今日の主役
XとYに共通の原因Zがあると、XとYは因果関係がなくても一緒に動きます。アイス(X)と水難事故(Y)はまさにこれで、共通原因は気温/季節(Z)。
XからYへの矢印は無いのに、Z → X と Z → Y の**裏道(バックドア経路)**を通って相関が漏れてくる。これが交絡です。
罠3:選択バイアス(サンプルの選ばれ方)
XとYが共通の結果Cを持ち、そのCで絞り込む(選抜する)と、無関係だったXとYに見かけの相関が生まれます。Cを**合流点(collider)**と呼びます。
才能と美貌が世間全体では無相関でも、「才能か美貌のどちらかが高くないと芸能人になれない」なら、芸能人だけを見ると両者は負の相関に見える(才能が低い芸能人は美貌が高いはず、という選抜の副作用)。生存者バイアスもこの仲間です。
ここが今日いちばん大事:交絡(罠2)は「Zで調整すると消える」。しかし選択バイアス(罠3)は逆で、合流点Cで調整(層別化・コントロール)すると、むしろバイアスが生まれる。「とりあえず共変量を全部入れて調整」は危険で、何で調整すべきかは因果構造(DAG)で決まる——これが Day 10〜11 の伏線です。
手を動かす①:交絡を作って、条件付けで消す
罠2をコードで再現します。方針は「XはYの原因ではないが、共通原因Zで両方を動かす」データを作り、素朴な相関 → 気温で層別化した相関、を比べます。
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 2000
# Z(交絡)= 季節/気温レベル 0:真冬 … 4:真夏。ice も drown もこれが原因。
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 には一切依存しない)
df = pd.DataFrame({"season": season, "ice": ice, "drown": drown})
# (1) 素朴な相関(見かけの相関)
print("全体の相関 r(ice, drown) = %.3f" % df["ice"].corr(df["drown"]))
# (2) 気温(季節)で層別化 = Z を一定にして相関を見る
names = ["真冬", "冬", "春秋", "夏", "真夏"]
rs = []
for s in range(5):
g = df[df["season"] == s]
r = g["ice"].corr(g["drown"])
rs.append(r)
print(" %s の中だけ: r = %+.3f" % (names[s], r))
print("層内相関の平均 = %.3f" % np.mean(rs))
実行結果(乱数シード固定なので手元でも一致します):
全体の相関 r(ice, drown) = 0.681
真冬 の中だけ: r = +0.054
冬 の中だけ: r = -0.026
春秋 の中だけ: r = -0.016
夏 の中だけ: r = -0.027
真夏 の中だけ: r = +0.055
層内相関の平均 = 0.008
全体で 0.68 あった相関が、同じ季節の中だけで見ると 0.008 まで消えました。 コードの中で drown は ice に一切依存させていない(=真の因果効果はゼロ)ので、これは正しい答えです。0.68 は「気温という共通原因の影」だったわけです。
図にすると一目瞭然です(任意・matplotlib)。色が季節。全体では右肩上がりに見えますが、同じ色の点の塊(=同じ季節)の中には傾きが無いのがポイント。
import matplotlib.pyplot as plt
import matplotlib_fontja # 日本語ラベル用(pip install matplotlib-fontja)
def ols_line(ax, x, y, **kw): # 最小二乗の直線を引く
b, a = np.polyfit(x, y, 1)
xs = np.array([x.min(), x.max()])
ax.plot(xs, a + b * xs, **kw)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.7), sharex=True, sharey=True)
colors = plt.cm.coolwarm([0.0, 0.25, 0.5, 0.75, 1.0])
# 左: 全体をまとめて見る(見かけの相関)
ax1.scatter(df["ice"], df["drown"], s=14, color="#8899AA", alpha=0.5)
ols_line(ax1, df["ice"], df["drown"], color="#E8703A", lw=2.6)
ax1.set_title("① 全体をまとめて見る → r = %.2f" % df["ice"].corr(df["drown"]))
ax1.set_xlabel("アイス売上"); ax1.set_ylabel("水難事故")
# 右: 同じ季節の中だけで見る(層別化)
for s in range(5):
g = df[df["season"] == s]
ax2.scatter(g["ice"], g["drown"], s=14, color=colors[s], alpha=0.75, label=names[s])
ols_line(ax2, g["ice"], g["drown"], color=colors[s], lw=2.2)
ax2.set_title("② 同じ季節の中だけで見る → r ≈ 0.01")
ax2.set_xlabel("アイス売上"); ax2.legend(title="季節(気温レベル)")
plt.tight_layout()
plt.savefig("fig1_confounding.png", dpi=130, bbox_inches="tight")
「Zを一定にすると相関が消える」——この条件付け(層別化)が、因果推論のいちばん素朴な調整です。連載を通してやることは、突き詰めれば「正しいZで条件付ける方法」を色々な角度から手に入れることです。
偏相関でも確認できます。気温を線形回帰で除いた残差同士の相関(偏相関)も 0.009 で、層別化と同じ結論になります。層別化=離散で条件付け、偏相関=連続で条件付け、という違いだけです。
手を動かす②:無相関が「選抜」で負に化ける(選択バイアス)
罠3も再現します。才能と美貌を独立に生成し、「合計が高い=芸能人」だけに絞ると何が起きるか。
import numpy as np
rng = np.random.default_rng(7)
m = 4000
talent = rng.normal(0, 1, m) # 才能
looks = rng.normal(0, 1, m) # 美貌(才能とは独立に生成)
star = (talent + looks) > 1.5 # 芸能人 = どちらか高ければ選抜される「合流点 C」
print("母集団全体 r(才能, 美貌) = %+.3f" % np.corrcoef(talent, looks)[0, 1])
print("芸能人だけ r(才能, 美貌) = %+.3f" % np.corrcoef(talent[star], looks[star])[0, 1])
これは、「因果推論の科学」では、うまい説明効果と呼ばれていたトリックとなります。
母集団全体 r(才能, 美貌) = -0.001
芸能人だけ r(才能, 美貌) = -0.710
生成時に相関ゼロ(−0.001)だったものが、選抜という「合流点での条件付け」で −0.71 まで負に化けました。罠2と見比べてください——条件付けが、交絡では相関を消し、合流点では相関を作る。同じ「絞り込む」操作が、因果構造しだいで真逆に働きます。
(おまけ)手を動かす③:偶然でここまで相関する
罠1の実感です。長さ12の無関係な系列を10万ペア用意し、その中の最大相関を見ます。
ここでのポイントは、「10万組」作成するということです。
長さ12の乱数を1組だけ作成した場合、
a = rng.normal(0, 1, (12, 1)) # 平均0, 標準偏差1, 形状[1, 12]の乱数配列
b = rng.normal(0, 1, (12, 1)) # 平均0, 標準偏差1, 形状[1, 12]の乱数配列
>>> a[0]
array([ 0.12573022, -0.13210486, 0.64042265, 0.10490012, -0.53566937,
0.36159505, 1.30400005, 0.94708096, -0.70373524, -1.26542147,
-0.62327446, 0.04132598])
>>> b[0]
array([-0.63556367, 0.77278602, 0.91370425, 0.40274777, 0.39820202,
-2.0565533 , -0.23560398, 0.59515886, 0.83801342, 1.40550379,
0.58454769, -0.54382252])
と、全く相関がなさそうな組み合わせのペアが出現します。そりゃそうなります。
しかし、10万ペアを用意すると、中には激しく相関するペアが出てきます。
import numpy as np
rng = np.random.default_rng(0)
T, K = 12, 100_000
a = rng.normal(0, 1, (K, T)); b = rng.normal(0, 1, (K, T))
am = a - a.mean(1, keepdims=True); bm = b - b.mean(1, keepdims=True)
r = (am * bm).sum(1) / np.sqrt((am**2).sum(1) * (bm**2).sum(1))
print("最大 |相関| = %.3f" % np.abs(r).max())
print("|相関|>0.7 の割合 = %.2f%%" % ((np.abs(r) > 0.7).mean() * 100))
最大 |相関| = 0.934
|相関|>0.7 の割合 = 1.09%
完全に無関係でも、探せば 相関0.93 のペアが出るし、約1%は |r|>0.7。特徴量を大量に試して「効いてそうな変数」を拾うと、この偶然を掴まされます。
つまづき・誤解しやすい点
- 「相関がない=因果がない」も誤り。 逆向きの効果や複数経路が打ち消し合うと、因果があるのに相関が0に見えることがあります(Day 2以降で扱う抑圧・相殺)。相関は因果の必要条件ですらありません。
- 調整の向きを間違えると自分でバイアスを作る。 交絡(共通原因)は調整する、合流点(共通結果)は調整しない。「使える変数を全部コントロール」は罠3を踏みにいく行為です。何で調整するかは相関の強さではなく**因果構造(DAG)**で決める——ここが Pearl 流(Day 10–11)の核心。
- 層別化は「観測できている交絡」にしか効かない。 気温を測っていなければ層別化もできません。測れない交絡(未観測交絡)への対処が、傾向スコア・IV・DID など連載後半の主戦場です。
実務での使いどころ(空間データ)
私の本業は衛星データ×インフラ×AIで、扱うデータの多くが「位置」を持ちます。ここで空間交絡という強敵が出ます。
- 隣り合う地域は気候・人口・地質など似た背景(Z)を共有するので、「Xが多い地域はYも多い」の大半は位置由来の交絡かもしれない。アイスと水難事故の「気温」が、空間では**「立地」**に置き換わるイメージです。
- さらに空間では干渉(SUTVA違反)——ある地域への介入が隣の地域の結果に染み出す——も起きます。これは罠の“空間版”で、**Day 28「空間データ×因果推論」**で本気で掘ります(この連載でいちばん出したい独自回)。
「地図の上で相関が出た」ときに、それが因果なのか立地の影か。今日の3つの罠は、そのまま空間データの落とし穴チェックリストになります。
参考(本棚)
- Matheus Facure『Causal Inference in Python』第1章
- 伊藤公一朗『データ分析の力』第1章
- 『はじめての統計的因果推論』
次回予告(Day 2)
「アイスを止めても溺死は減らない」を個体レベルで厳密に言うための言葉——**潜在的結果(もし処置していたら/していなかったら)**と、因果推論の根本問題をやります。ATE/ATT/CATE を定義し、「観測できるのは片方だけ」をコードで可視化します。



