はじめに
「施策Aを打ったユーザーの方が、打たなかったユーザーより指標が高かった。だから施策Aは効いた」
この言い方、現場ではとても自然です。
でも、たいていは危ないです。
なぜなら、施策を受けた人と受けなかった人は、最初から同じではないからです。
- 重症な人ほど治療されやすい
- 離脱しそうな人ほどフォロー施策に乗りやすい
- ベースラインが悪い人ほど介入対象になりやすい
この「もともとの違い」を無視したまま平均を比べると、相関を因果と読み違えます。
そこで使いたいのが DoWhy です。
DoWhy の良いところは、ただ推定器を回すのではなく、
- どんな因果構造を仮定するのかを DAG で書く
- その仮定のもとで何を調整すればよいか識別する
- 効果を推定する
- 最後に反証して、結果がどれくらい壊れにくいかを見る
という順番を強制してくれるところにあります。
実際にこちらで DoWhy 0.14 を実行した結果をそのまま使って、
- ナイーブ比較がどれくらいズレるか
- 正しい DAG を置くとどこまで回復するか
- DAG を間違えるとどれくらい壊れるか
- refute でどんな確認ができるか
を一気通貫で見ます。
先に結論
今回の合成データでは、真の平均因果効果を 2.0 に埋め込んであります。
そのうえで実行すると、結果はこうなりました。
| 項目 | 値 |
|---|---|
| 真の ATE | 2.0000 |
| 介入群 − 非介入群の単純平均差 | 0.5460 |
| DoWhy 推定値(正しい DAG) | 1.9802 |
DoWhy 推定値(severity を落とした誤 DAG) |
0.1554 |
これだけでもかなり重要です。
- 単純比較は大きく外す
- 正しい DAG を置けば真値にかなり近づく
- DAG を1つ間違えるだけで簡単に壊れる
因果推論で一番大事なのは、「どの推定器を使ったか」より前に、
何を仮定しているかを明示することだと分かります。
この記事の前提
- Python 3.13
- dowhy 0.14
- numpy / pandas / statsmodels
今回は、再現可能性を優先して 合成データ を使います。
合成データなら「本当の因果効果」を知った状態で、DoWhy の挙動を検証できるからです。
問題設定
次のような観察データを考えます。
-
treatment: 介入の有無(0/1) -
outcome: 改善スコア(大きいほど良い) -
age: 年齢 -
severity: 重症度 -
baseline: 介入前ベースライン指標
直感としてはこうです。
- 重症な人ほど介入されやすい
- ベースラインが悪い人ほど介入されやすい
- ただし重症な人は、そもそも outcome が悪くなりやすい
つまり severity や baseline が treatment と outcome の両方に効くので、
ここに交絡があります。
データ生成
import numpy as np
import pandas as pd
np.random.seed(42)
n = 5000
age = np.random.normal(55, 10, size=n)
severity = np.random.normal(0, 1, size=n)
baseline = np.random.normal(0, 1, size=n)
# 観察研究らしい treatment assignment
lin = -0.03 * (age - 55) + 1.2 * severity + 0.5 * baseline
p = 1 / (1 + np.exp(-lin))
treatment = np.random.binomial(1, p)
# 真のATEは 2.0
outcome = (
2.0 * treatment
- 0.5 * (age - 55) / 10
- 2.0 * severity
+ 0.5 * baseline
+ np.random.normal(0, 1, size=n)
)
df = pd.DataFrame(
{
"age": age,
"severity": severity,
"baseline": baseline,
"treatment": treatment,
"outcome": outcome,
}
)
実行結果
| treatment | age | severity | baseline | outcome |
|---|---|---|---|---|
| 0 | 56.114624 | -0.440934 | -0.181829 | 0.738600 |
| 1 | 53.992321 | 0.423260 | 0.203861 | 1.284649 |
介入率は 0.4988 でした。
この時点で、介入群は
- 少し若い
- かなり重症
- ベースラインも高め
という偏りを持っています。
まずはナイーブ比較
何も考えずに、介入群と非介入群の outcome の平均差だけを見るとこうなります。
naive_diff = df.groupby("treatment")["outcome"].mean().diff().iloc[-1]
print(naive_diff)
実行結果
0.5460488950714538
真の ATE は 2.0 なのに、単純比較では 0.5460 しか出ません。
このズレの原因は、介入群の方が重症だからです。
重症な人は治療を受けやすい一方で、重症であること自体が outcome を悪くします。
だから、
「介入群の平均が少し高い」
だけを見ても、
「介入が効いた」
とは言えません。
ここで DoWhy の出番です。
DAG を書く
今回の仮定を DAG にするとこうなります。
要するに、
-
age,severity,baselineは treatment と outcome の共通原因 -
treatment -> outcomeが知りたい因果効果
という設定です。
ここで大事なのは、DAG は飾りではないということです。
DAG は「何を調整するのが妥当か」を決める設計図です。
DoWhy で causal model を作る
from dowhy import CausalModel
graph = """
digraph {
age -> treatment;
age -> outcome;
severity -> treatment;
severity -> outcome;
baseline -> treatment;
baseline -> outcome;
treatment -> outcome;
}
"""
model = CausalModel(
data=df,
treatment="treatment",
outcome="outcome",
graph=graph,
)
識別:この DAG なら何を調整すればよいか
identified_estimand = model.identify_effect(proceed_when_unidentifiable=True)
print(identified_estimand)
実行結果
Estimand type: EstimandType.NONPARAMETRIC_ATE
### Estimand : 1
Estimand name: backdoor
Estimand expression:
d
────────────(E[outcome|severity,baseline,age])
d[treatment]
Estimand assumption 1, Unconfoundedness: If U→{treatment} and U→outcome then P(outcome|treatment,severity,baseline,age,U) = P(outcome|treatment,severity,baseline,age)
### Estimand : 2
Estimand name: iv
No such variable(s) found!
### Estimand : 3
Estimand name: frontdoor
No such variable(s) found!
### Estimand : 4
Estimand name: general_adjustment
Estimand expression:
d
────────────(E[outcome|severity,baseline,age])
d[treatment]
Estimand assumption 1, Unconfoundedness: If U→{treatment} and U→outcome then P(outcome|treatment,severity,baseline,age,U) = P(outcome|treatment,severity,baseline,age)
DoWhy は、この DAG のもとでは backdoor adjustment によって ATE が識別できると返しています。
つまり、今回の仮定を信じるなら調整すべき変数は
severitybaselineage
です。
ここが大事です。
因果推論は「全部入れて回帰すること」ではありません。
DAG から、どの変数をなぜ調整するのかを決めることです。
推定:backdoor.linear_regression で ATE を出す
estimate = model.estimate_effect(
identified_estimand,
method_name="backdoor.linear_regression",
test_significance=True,
)
print(estimate)
実行結果
*** Causal Estimate ***
## Identified estimand
Estimand type: EstimandType.NONPARAMETRIC_ATE
### Estimand : 1
Estimand name: backdoor
Estimand expression:
d
────────────(E[outcome|severity,baseline,age])
d[treatment]
Estimand assumption 1, Unconfoundedness: If U→{treatment} and U→outcome then P(outcome|treatment,severity,baseline,age,U) = P(outcome|treatment,severity,baseline,age)
## Realized estimand
b: outcome~treatment+severity+baseline+age
Target units: ate
## Estimate
Mean value: 1.980234776764819
p-value: [0.]
推定値は 1.9802 でした。
今回のデータ生成過程では真の ATE を 2.0 にしているので、
DoWhy はかなり正確に回収できています。
ここまでを並べると、かなり分かりやすいです。
| 項目 | 値 |
|---|---|
| 真の ATE | 2.0000 |
| 単純平均差 | 0.5460 |
| DoWhy 推定値 | 1.9802 |
単純比較だけ見ていたら「効果は小さい」と誤読していたはずです。
でも DAG に基づいて交絡を調整すると、真の因果効果にかなり近づきました。
では、DAG を間違えたら?
ここがこの記事のいちばん重要なところです。
因果推論で怖いのは、「推定器の選び方」以上に DAG の間違い です。
今回は、わざと severity を DAG から落としてみます。
wrong_graph = """
digraph {
age -> treatment;
age -> outcome;
baseline -> treatment;
baseline -> outcome;
treatment -> outcome;
}
"""
wrong_model = CausalModel(
data=df,
treatment="treatment",
outcome="outcome",
graph=wrong_graph,
)
wrong_estimand = wrong_model.identify_effect(proceed_when_unidentifiable=True)
wrong_estimate = wrong_model.estimate_effect(
wrong_estimand,
method_name="backdoor.linear_regression",
test_significance=True,
)
print(wrong_estimate)
実行結果
*** Causal Estimate ***
## Identified estimand
Estimand type: EstimandType.NONPARAMETRIC_ATE
### Estimand : 1
Estimand name: backdoor
Estimand expression:
d
────────────(E[outcome|baseline,age])
d[treatment]
Estimand assumption 1, Unconfoundedness: If U→{treatment} and U→outcome then P(outcome|treatment,baseline,age,U) = P(outcome|treatment,baseline,age)
## Realized estimand
b: outcome~treatment+baseline+age
Target units: ate
## Estimate
Mean value: 0.15535417693740872
p-value: [0.00982534]
推定値は 0.1554 まで崩れました。
かなり強烈です。
- 正しい DAG: 1.9802
-
severityを1つ落とした誤 DAG: 0.1554
同じ backdoor.linear_regression を使っていても、
何を調整対象とみなすかが変わると、推定値はここまで壊れます。
つまり、因果推論で先に DAG を書くのは儀式ではなく、
推定の意味そのものを決める作業です。
反証:推定値をそのまま信じない
DoWhy の好きなところはここです。
推定して終わりではなく、refute を標準フローに入れられます。
今回は次の3つを回しました。
random_common_causeplacebo_treatment_refuterdata_subset_refuter
for method_name in [
"random_common_cause",
"placebo_treatment_refuter",
"data_subset_refuter",
]:
ref = model.refute_estimate(
identified_estimand,
estimate,
method_name=method_name,
)
print(method_name)
print(ref)
実行結果 1: random common cause
Refute: Add a random common cause
Estimated effect:1.980234776764819
New effect:1.9803338354089381
p value:0.8799999999999999
ランダムな共通原因を追加しても、推定値はほぼ変わりませんでした。
実行結果 2: placebo treatment
Refute: Use a Placebo Treatment
Estimated effect:1.980234776764819
New effect:0.004524189283413496
p value:0.96
偽 treatment を入れると効果は 0.0045 まで落ちました。
これはかなり良い挙動です。
本当に因果効果がない placebo に対して、大きな effect を出してしまうなら危険です。
実行結果 3: data subset refuter
Refute: Use a subset of data
Estimated effect:1.980234776764819
New effect:1.9832235286054918
p value:0.94
データの一部だけで再推定しても、効果はほぼ同じでした。
反証結果をまとめる
| refuter | 元の推定値 | 新しい推定値 | p-value | 読み方 |
|---|---|---|---|---|
| random_common_cause | 1.9802 | 1.9803 | 0.88 | 余計なノイズ追加で壊れていない |
| placebo_treatment_refuter | 1.9802 | 0.0045 | 0.96 | 偽 treatment ではゼロ近傍になる |
| data_subset_refuter | 1.9802 | 1.9832 | 0.94 | サブサンプルでも安定 |
もちろん、これで「絶対に正しい」と証明できるわけではありません。
でも少なくとも、
- 少し揺らしただけで崩れる推定ではない
- 偽物の treatment に大きな効果を出す推定ではない
- 特定の一部データだけに依存した推定でもない
という確認はできます。
因果推論では、推定値を1つ出すことより、
その推定値がどんな条件で壊れるかを点検することの方が大事な場面が多いです。
この記事で伝えたかったこと
1. 単純比較は簡単にズレる
今回の例では、真の ATE が 2.0 なのに、単純比較は 0.5460 でした。
観察データでは、群の違いがそのまま介入効果になるとは限りません。
2. DAG は飾りではない
何を調整するべきかは DAG から決まります。
DAG は「推定の前提条件」です。
3. 識別と推定を分けて考える
DoWhy では、
- DAG から識別する
- そのあとで推定する
という順番を崩しにくいです。
この分離がかなり重要です。
4. 推定して終わらない
DoWhy の refute は、「それっぽい数字が出た」段階で止まらないための安全装置になります。
おわりに
DoWhy の入門で一番大事なのは、
「すごい estimator を覚えること」ではなく、
- DAG を書く
- 識別する
- 推定する
- 反証する
という流れを体で覚えることだと思います。
今回の結果をもう一度だけ並べます。
| 項目 | 値 |
|---|---|
| 真の ATE | 2.0000 |
| 単純平均差 | 0.5460 |
| 正しい DAG での DoWhy 推定値 | 1.9802 |
| 誤 DAG での DoWhy 推定値 | 0.1554 |
この差を見るだけでも、
「回帰を回す」のと「因果推論をする」の間には、DAG と識別という大きな段差がある
ことが伝わるはずです。