観測データで因果を取りに行く:回帰調整・傾向スコア・DiD・RDD(実装編)
実験が打てない現場で因果に迫る道具箱を、架空のD2C企業SOLNAの一次データで実装します。回帰調整・傾向スコア(マッチング/IPW)・差分の差分法(DiD)・回帰不連続(RDD)の成立条件と限界を、コードと点検手順つきで仕分けます。
by Shin
A/Bテストも、配信を止めて残しておく対照群(holdout)も打てない施策の効果を、観測データだけで測れるのか。条件つきで測れますが、どの手法が使えるかは「手元のデータでどんな比較ができるか」で最初から決まっています。この記事では、回帰調整・傾向スコア・DiD・RDDの4つを、架空のD2C企業SOLNAの実データで動かしながら、成立条件・限界・つまずきポイントまで実装レベルで仕分けます。
前提として、潜在結果・ATE・DAG・バックドア基準は第4回で扱いました。本記事は「調整すべき変数が分かった後、どの推定器でATEを取りに行くか」を担当します。自社で何が成立するか・何をやらないかの判断は、対になる因果編を参照してください。
この回の到達目標
- 回帰調整・傾向スコア・DiD・RDDの4手法について、成立条件と限界を自分の言葉で説明できる。
- SOLNAのデータでDiDとRDDをコードで回し、平行トレンド・共通サポート・操作の有無を自分で点検できる。
- 「比較可能な単位が手に入るか」で手法を仕分けるフローを使える。
- 成立しないとき(単一系列・ランダム化なし)に、CausalImpactやupliftといった次の手へ橋を渡せる。
前提
- 第4回までで、反実仮想・ATE・セレクションバイアス・DAG・バックドア基準を理解している。
- 第2回の接合率(どこまで遡れるか)と、第3回の「予測 ≠ 因果」を踏まえている。
- SOLNAのスキーマ(
customers/orders/ad_spend/store_visits/customers.membership_tier)を使います。
回帰調整とその限界
最も素朴な手法が回帰調整です。処置ダミーと一緒に、観測できる交絡を共変量として回帰に投入し、それらを「揃えた上での」処置の係数を効果とみなします。直感は「年齢・地域・流入経路が同じ顧客どうしを比べる」こと。最小限の式で書けば、処置を 、共変量を 、成果を として
の を効果の推定値として読みます。
SOLNAで動かしてみます。題材は「獲得直後の催事・カウンター来訪(store_visits)が、その後90日のLTVを押し上げたか」。来訪は自己選択(来やすい地域・年代がある)なので、ランダム化はありません。まず分析テーブルをBigQueryで組みます。
-- SOLNA: 催事/カウンター来訪を処置とした観測データの分析テーブル
-- 処置: 獲得後30日以内に store_visits があったか
-- 成果: 獲得から90日後までの累計購入額(ltv_90)
WITH base AS (
SELECT customer_id, region, acquisition_channel, age_band,
membership_tier, first_order_at
FROM customers
-- 観察窓を揃える: 同一期間に獲得した顧客に限定(未成熟コホートの混入を防ぐ)
WHERE first_order_at BETWEEN '2025-01-01' AND '2025-09-30'
),
treat AS (
SELECT b.customer_id,
-- 処置は「成果より前」の事象だけを使う(リーク防止)
MAX(IF(v.visit_at <= DATE_ADD(b.first_order_at, INTERVAL 30 DAY), 1, 0)) AS visited
FROM base b
LEFT JOIN store_visits v USING (customer_id)
GROUP BY 1
),
y AS (
SELECT b.customer_id,
-- 成果窓を90日で固定。窓を揃えないと未成熟コホートを過小評価する
SUM(IF(o.order_at <= DATE_ADD(b.first_order_at, INTERVAL 90 DAY), o.amount, 0)) AS ltv_90
FROM base b
LEFT JOIN orders o USING (customer_id)
GROUP BY 1
)
SELECT base.*, treat.visited, COALESCE(y.ltv_90, 0) AS ltv_90
FROM base
JOIN treat USING (customer_id)
JOIN y USING (customer_id);回帰調整の限界は3つです。第一に、揃えられるのは観測できた交絡だけで、観測されない動機(もともと熱量が高い人ほど来訪する)は残ります。第二に、上の式は線形・加法を仮定しており、関数形を誤ると効果がねじれます。第三に、処置群と対照群で共変量の分布が大きくずれていると、回帰は重なっていない領域を「外挿」で埋めてしまい、根拠の薄い数字を出します。
傾向スコア(マッチング・IPW・層別化)と共通サポート
回帰調整が「成果 を共変量で説明する」のに対し、傾向スコアは「処置 の確率を共変量で説明する」発想です。処置を受ける確率 を推定し、このスコアが近い者どうしを比較(マッチング)、または逆数で重み付け(IPW)、あるいは層に分けて(層別化)効果を出します。
import pandas as pd, numpy as np
import statsmodels.formula.api as smf
from sklearn.linear_model import LogisticRegression
df = pd.read_gbq("SELECT * FROM mart.solna_storevisit_effect") # 上のSQLの結果
# 1) 回帰調整(比較用のベースライン)
m = smf.ols(
"ltv_90 ~ visited + C(region) + C(acquisition_channel) + C(age_band) + C(membership_tier)",
data=df,
).fit(cov_type="HC1")
print("回帰調整:", m.params["visited"], m.conf_int().loc["visited"].tolist())
# 2) 傾向スコアの推定
X = pd.get_dummies(
df[["region", "acquisition_channel", "age_band", "membership_tier"]], drop_first=True
)
df["ps"] = LogisticRegression(max_iter=1000).fit(X, df["visited"]).predict_proba(X)[:, 1]
# 3) 共通サポート(オーバーラップ)の点検
# `0.05 < ps < 0.95` の範囲だけが「比較可能」。外れる層は推定から外す
support = df["ps"].between(0.05, 0.95)
df_s = df[support].copy()
# 4) IPW でATEを推定(Hajek型・極端な重みはトリミングが前提)
w1 = 1 / df_s["ps"]
w0 = 1 / (1 - df_s["ps"])
t, c = df_s["visited"] == 1, df_s["visited"] == 0
ate_ipw = (np.average(df_s.loc[t, "ltv_90"], weights=w1[t])
- np.average(df_s.loc[c, "ltv_90"], weights=w0[c]))
print("IPW ATE:", ate_ipw)つまずきポイントはオーバーラップ(positivity)です。たとえば催事来訪がほぼ起きない地域では ps が 0 に張り付き、IPWの重みが爆発します。重なっていない層を比較に含めると、推定値は少数の極端な観測に支配されます。共通サポートの確認と重みのトリミングはセットで行ってください。もう一つ、傾向スコアを当てたら「揃ったつもり」で終わらせず、重み付け後に共変量バランス(標準化差)が縮んだかを必ず確認します。
回帰調整と傾向スコアを片方ずつ信じるより、両方を組み合わせるDoubly Robust(DR)推定が実務では安全です。DoWhyやEconMLにDR推定器が用意されており、片方のモデルが正しければ一致性が保たれます。実装の引き出しはMolak本が詳しい(後述)。なお「誰に効くか」までは平均効果(ATE)からは出ません。異質性(CATE)と、それを当てに行くuplift modelingは第6回で扱います。
DiD ― 平行トレンドという生命線
ここから、共変量の調整ではなく「設計」で交絡を消しに行きます。差分の差分法(DiD)は、処置群と対照群それぞれの「前後の差」をさらに引き算することで、両群に共通して効く時間トレンド(季節・全体景気)を相殺します。鍵は、処置がなければ両群が平行に動いたはずだ、という平行トレンド仮定です。
SOLNAは複数地域に接点があるので、地域配信(特定地域だけにLINEプッシュを打つ)を処置とした地域パネルが組めます。
import pandas as pd
import statsmodels.formula.api as smf
panel = pd.read_gbq("""
SELECT o.region,
DATE_TRUNC(o.order_at, WEEK) AS week,
SUM(o.amount) AS sales,
MAX(IF(a.campaign = 'regional_line_push' AND a.spend > 0, 1, 0)) AS has_push
FROM orders o
LEFT JOIN ad_spend a
ON a.region = o.region
AND DATE_TRUNC(a.date_day, WEEK) = DATE_TRUNC(o.order_at, WEEK)
GROUP BY 1, 2
""")
panel["treated"] = panel.groupby("region")["has_push"].transform("max") # 一度でも配信した地域
panel["post"] = (panel["week"] >= "2025-06-02").astype(int) # 配信開始週以降
# 2way 固定効果(地域・週)+ 交互作用 treated:post がDiD推定量
did = smf.ols("sales ~ treated:post + C(region) + C(week)", data=panel).fit(
cov_type="cluster", cov_kwds={"groups": panel["region"]} # 地域でクラスタ頑健SE
)
print("DiD効果:", did.params["treated:post"])
# 生命線の点検: 配信「前」の週ダミー × treated が0近傍か(イベントスタディ)
event = smf.ols("sales ~ C(week):treated + C(region) + C(week)", data=panel).fit()
# event の配信前期間の係数が0から離れていたら、平行トレンドが崩れているつまずきポイントを3つ。平行トレンドは「過去が平行だった」ことしか確認できず、未来の平行は仮定でしかありません。だからイベントスタディで配信前のトレンド差を必ず可視化します。次にスピルオーバー:隣接地域への漏れや、対照地域への移動があると、対照が「無処置」でなくなり効果を薄めます。最後に、地域ごとに配信開始時期がずれる段階的処置(staggered DiD)では、単純な2元固定効果が既処置地域を対照に使ってしまいバイアスが出ます。タイミングがずれるなら Callaway & Sant'Anna 等の推定器に切り替えてください(実装は differences などのライブラリ)。
RDD ― 閾値の前後を比べる
回帰不連続(RDD)は、処置が連続変数の「閾値」で機械的に決まる状況を使います。閾値のすぐ下とすぐ上の顧客は、ほぼ同質なのに片方だけ処置を受ける。だから閾値での成果の「飛び」を効果とみなせます。SOLNAでは、累計購入額がしきい値を超えると上位会員ランクの特典(送料無料・限定オファー)が付く仕組みが該当します。
import pandas as pd
import statsmodels.formula.api as smf
df = pd.read_gbq("SELECT customer_id, cum_spend, post_perk_ltv FROM mart.solna_tier_rdd")
cutoff = 50000
df["x"] = df["cum_spend"] - cutoff # 中心化(閾値からの距離)
df["above"] = (df["x"] >= 0).astype(int) # 閾値を超えた=特典あり
band = df["x"].abs() <= 8000 # 帯域幅。狭めて局所性を確保
local = df[band].copy()
# 局所線形回帰: 閾値の左右で別々の傾きを許す。above の係数が「飛び」=効果
rdd = smf.ols("post_perk_ltv ~ above + x + above:x", data=local).fit(cov_type="HC1")
print("RDD効果:", rdd.params["above"], rdd.conf_int().loc["above"].tolist())
# 操作の点検: 閾値の直下/直上で密度が不連続なら自己選択を疑う(McCrary検定)
# 帯域幅の最適化と検定は rdrobust / rddensity を使うのが実務的つまずきポイント。第一に操作(manipulation):顧客が閾値を狙って購入を調整していると、直下と直上が同質でなくなります。閾値付近の密度が不連続でないかを密度検定で確認します。第二に帯域幅:狭めるほど比較は綺麗だがサンプルが減り分散が増える。rdrobust で最適帯域とバイアス補正を任せるのが堅実です。第三に外的妥当性:RDDが測るのは「閾値付近の局所的な効果」だけで、全顧客への効果ではありません。なお特典付与が閾値で完全に切り替わらない(一部例外がある)場合はファジーRDDになり、閾値を操作変数として使います。
操作変数(IV)・合成コントロールの位置づけ(紹介)
調整も設計も難しいときの控えがあります。操作変数(IV)は、処置にだけ効いて成果には直接効かない外生的な「ゆらぎ」を手がかりに使う手法です。SOLNAなら、配信枠の偶発的な割当や技術的な配信失敗のような、本人の意思と無関係な変動が候補になりますが、良い操作変数を見つけるのは実務では難所です。
合成コントロールは、処置を受けた単一の地域・店舗に対し、複数の対照地域を加重平均して「もしものSOLNA」を合成し、その差を効果とする手法です。対照群が1つに定まらない単一処置の状況に効きます。発想はベイズ構造時系列で離散介入の増分を測るCausalImpactと地続きで、こちらは第7回で実装します。
手法選択フローチャート
どれを使うかは、手元のデータでどんな比較ができるかによって、ほぼ一意に決まります。上から順に、より強い設計を優先してください。
比較可能な「単位」は何か?
├─ ランダム化された割付がある(holdout 等)
│ → uplift / 単純比較(第6回) ※最強。あるなら観測手法より優先
├─ 明確な閾値(ランク・スコア・年齢)で処置が決まる
│ → RDD(操作と帯域幅を点検)
├─ 処置の「前後」と、影響を受けない対照群(地域・店舗)がある
│ → DiD(平行トレンドを点検)
├─ 明確な介入時点はあるが対照群が乏しい(単一系列)
│ → CausalImpact / 合成コントロール(第7回)
└─ 上のどれも無いが、交絡が観測できている
→ 回帰調整 / 傾向スコア(観測交絡のみ。残差交絡は残る)成立条件と限界、SOLNA(取引件数の多いD2C)と取引件数の少ないB2Bでの可否を一覧にすると次の通りです。
| 手法 | 成立条件 | 主な限界 | SOLNA | 取引件数の少ないB2B |
|---|---|---|---|---|
| 回帰調整 | 交絡が観測できている | 観測外交絡・関数形・外挿 | ◎ | △ |
| 傾向スコア/IPW | 交絡が観測+共通サポート | positivity違反・重み爆発 | ◎ | △ |
| DiD | 対照群+平行トレンド | スピルオーバー・段階的処置 | ◎ | ✕(対照地域なし) |
| RDD | 処置が閾値で決まる | 局所効果・操作 | ◎ | ○(受注額等の閾値があれば) |
成立しない手法は「やらない」で終えず、再評価のトリガー(発動条件)とセットで記録します。その判断の作法は因果編に譲ります。
対応書籍と読みどころ
実装と識別の両輪で、次の順に手を伸ばすのがおすすめです。
- 効果検証入門(ホクソエム):DiD・RDD・傾向スコアを実務のコードで一通り回せる、本回の中核となる一冊。識別の直感とRの実装が近い距離にあります。
- 金本『因果推論』:観測手法の識別条件を理論として詰めたいときの参照点。
- Molak『Pythonライブラリによる因果推論・因果探索』:DoWhy / EconMLでのDR推定など、本回のPython実装を厚くするのに向きます。
フレームとの接続
本回は診断フレームの「比較可能な単位」と、そこから降りる手法選択フローに対応します。6軸のうち因果の可否を最も左右するのが、比較可能な単位(対照地域・閾値・ランダム割付)が手に入るかどうかです。成熟度のステップでは、第4回で入った「因果」の段階の実装側に当たります。ここから一段上の「誰に効くか(CATE・uplift)」が第6回、集計レベルの介入効果(CausalImpact・MMMの素養)が第7回です。
章末チェックリスト
- 共変量に「処置より後の変数」を混ぜていないか(媒介・合流点の調整をしていないか)
- 傾向スコアで共通サポート(オーバーラップ)を確認し、重みをトリミングしたか
- 重み付け後に共変量バランスが縮んだかを点検したか
- DiDでイベントスタディを描き、配信前の平行トレンドを確認したか
- 処置タイミングが地域でずれていないか(段階的処置の罠)を確認したか
- RDDで操作(密度の不連続)と帯域幅、局所効果の限界を確認したか
- どの手法も使えないとき、CausalImpact・合成コントロール・upliftへの発動条件を書いたか
ここまでは「観測データからどう推定するか(HOW)」でした。自社のデータ構造でそもそもどの手法が成立し、何をやらないと決めるか(WHAT/WHY)の判断は、対になる因果編へ。次回は平均効果の先、「誰に効くか」を当てに行くCATEとuplift(第6回)に進みます。