1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

因果推論 Day 1/全30回 「アイスが売れると人が溺れる」は本当か — 相関が因果に化ける3つの罠

1
Last updated at Posted at 2026-07-07

この連載について

因果推論を「本を読んだ」で終わらせず、自分の言葉で説明でき、コードで再現できる状態まで落とすための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 → XZ → 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 まで消えました。 コードの中で drownice に一切依存させていない(=真の因果効果はゼロ)ので、これは正しい答えです。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")![fig3_spurious.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253735/087c8d5d-4370-486a-8d07-94527d382146.png)

fig1_confounding.png

「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])

fig2_collider.png

これは、「因果推論の科学」では、うまい説明効果と呼ばれていたトリックとなります。

母集団全体   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))

fig3_spurious.png

最大 |相関| = 0.934
|相関|>0.7 の割合 = 1.09%

fig3_spurious.png

完全に無関係でも、探せば 相関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 を定義し、「観測できるのは片方だけ」をコードで可視化します。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?