Uncategorized

因果推論学習:Phase1






Phase 1: 統計的因果推論の理論とPython実装 - 18週間マーケティングサイエンティスト育成プログラム


🎯 Phase 1: 統計的因果推論の理論とPython実装

Week 2-5(4週間) | 因果推論の基礎を理解し、交絡を調整した効果推定をPythonで実装する

📊 Phase 1で達成すること

🔬 因果推論の基本概念
潜在的アウトカム、ATEを定義し、因果の言語で議論できる
🗺️ DAG(因果グラフ)
交絡構造を特定し、調整すべき変数を判断できる
📈 回帰分析の活用
交絡調整のツールとして回帰分析を使いこなせる
🎯 傾向スコア
Propensity Scoreを算出し、マッチングまたは層別化を実装できる

📚 使用教材

主教材

  • 理論書: 金本『因果推論』(第1-4章)
    • 因果推論の基本的な枠組み、DAG、回帰分析による推定
    • 統計的因果推論の理論的基礎を体系的に学習
  • 実装教材: Udemy『データサイエンスの為の因果推論入門』(Python利用)
    • Python(pandas, statsmodels, scikit-learn)での実装例
    • 傾向スコア、回帰による推定を中心に学習
    • 実データを使った実践的なハンズオン

副読書・資料

  • 概念書: 『統計的因果推論の基礎』(Pearl著) イントロダクションおよびDAG部分
  • 実装資料: statsmodels公式ドキュメント(OLS, Logit, WLS)
  • 補助教材: 『効果検証入門』(技術評論社)対応箇所
⚠️ Phase 1開始前の確認事項

  • ✅ Python環境(Jupyter Notebook, pandas, numpy, statsmodels)が動作すること
  • ✅ 基本的な統計(平均、分散、回帰分析の係数の解釈)を理解していること
  • ✅ Phase 0で「相関≠因果」の雰囲気を掴んでいること
  • ✅ 統計検定準1級レベルの確率・統計の基礎知識
💡 WEBマーケティングでの応用例

  • 広告効果測定: 「特定の広告(施策)がCVRに与える純粋な影響」を年齢や過去購買履歴の**交絡**を除去して推定する
  • コンテンツ効果: 「ブログ記事Aを読んだこと(施策)が、その後のサイト滞在時間に与える影響」を推定する
  • 価格弾力性: 価格変動が売上に与える影響を、競合の動向などの交絡を調整して計測する
  • サイト適用: オフィス物件の問い合わせ数に対する各種マーケティング施策の真の効果を推定

📅 Week 2: 潜在的アウトカムとDAG(因果グラフ)の基礎

Week 2 の学習目標: 因果推論の「言語」を習得する

  • **潜在的アウトカム**(反実仮想)を理解し、因果効果を数学的に定義できる
  • **セレクション・バイアス**の正体を言語化し、なぜ単純比較が誤るかを説明できる
  • **DAG(有向非巡回グラフ)**の基本ルールを理解し、因果構造を可視化できる
  • **交絡因子**、**媒介変数**、**合流点(コライダー)**をDAG上で識別できる
  • **バックドア基準**を使って、調整すべき変数セットを決定できる
Day 1-3: 潜在的アウトカムとバイアス

📖 月曜・火曜(各3時間)

  • 理論(金本 第1章): 潜在的アウトカム、Rubinの因果モデル(RCM)
  • 因果効果の定義: 個別因果効果、**平均因果効果 (ATE)**、処置群の平均因果効果 (ATT)
  • 問題の核心: **「反実仮想(もし〜だったら)」**の観測不能性(根本問題)
  • RCTと無視可能性: RCTがなぜバイアスを打ち消すのかを数学的に理解する
  • セレクション・バイアス: 処置を受ける人と受けない人が系統的に異なる場合の問題
📚 潜在的アウトカムの数学的定義

各個体 i に対して:

  • Yi(1): 処置を受けた場合のアウトカム(潜在的)
  • Yi(0): 処置を受けなかった場合のアウトカム(潜在的)
  • 個別因果効果: τi = Yi(1) - Yi(0)
  • ATE(平均因果効果): τ = E[Yi(1) - Yi(0)] = E[Yi(1)] - E[Yi(0)]

根本問題: Yi(1) と Yi(0) を同時に観測することは不可能

💻 Pythonによるシミュレーションデータ作成

import pandas as pd
import numpy as np
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
import seaborn as sns

np.random.seed(42)

# 1. 交絡因子 Z: ユーザーの習熟度 (正規分布)
n = 1000
Z = np.random.normal(5, 2, n)

# 2. 施策 X: 広告接触 (Zが高い人ほど広告に接触しやすい)
P_X = 1 / (1 + np.exp(-(-1 + 0.3 * Z))) # ロジスティック関数
X = np.random.binomial(1, P_X) # 0: 広告なし, 1: 広告あり

# 3. アウトカム Y: CVR (真の因果効果: 広告でCVRが+3%増加)
Y0 = 0.1 + 0.05 * Z + np.random.normal(0, 0.05, n) # 施策なしのCVR (Zに依存)
Y1 = Y0 + 0.03 # 施策ありのCVR (+0.03が真の因果効果)
Y = Y1 * X + Y0 * (1 - X) # 観測されるCVR (反実仮想)

df = pd.DataFrame({'Z': Z, 'X': X, 'Y': Y, 'Y0': Y0, 'Y1': Y1})

# 真のATEを計算(本来は観測不可能)
true_ate = (Y1 - Y0).mean()

# 単純な比較 (セレクション・バイアスあり)
mean_Y1 = df[df['X']==1]['Y'].mean()
mean_Y0 = df[df['X']==0]['Y'].mean()
naive_ate = mean_Y1 - mean_Y0

print("=" * 60)
print("因果推論の基本問題: 反実仮想の観測不能性")
print("=" * 60)
print(f"真のATE (観測不可能): {true_ate:.4f}")
print(f"単純比較 (ナイーブATE): {naive_ate:.4f}")
print(f"バイアス: {naive_ate - true_ate:.4f}")
print(f"バイアスの割合: {(naive_ate - true_ate) / true_ate * 100:.1f}%")

# 可視化: 交絡因子の分布
plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
plt.hist(df[df['X']==0]['Z'], bins=30, alpha=0.6, label='広告なし (X=0)', color='#2196f3')
plt.hist(df[df['X']==1]['Z'], bins=30, alpha=0.6, label='広告あり (X=1)', color='#9c27b0')
plt.xlabel('習熟度 (Z)')
plt.ylabel('頻度')
plt.title('交絡因子の分布の違い\n→ 広告を見る人は元々習熟度が高い', fontweight='bold')
plt.legend()
plt.grid(alpha=0.3)

plt.subplot(1, 2, 2)
plt.scatter(df[df['X']==0]['Z'], df[df['X']==0]['Y'], alpha=0.4, label='広告なし', s=20, color='#2196f3')
plt.scatter(df[df['X']==1]['Z'], df[df['X']==1]['Y'], alpha=0.4, label='広告あり', s=20, color='#9c27b0')
plt.xlabel('習熟度 (Z)')
plt.ylabel('CVR (Y)')
plt.title('習熟度とCVRの関係\n→ 習熟度が両方に影響(交絡)', fontweight='bold')
plt.legend()
plt.grid(alpha=0.3)

plt.tight_layout()
plt.show()

⚠️ つまずきポイント: ナイーブATEはなぜ間違っているか?

上のコードでは、単純な平均比較**(ナイーブATE)**は真のATE(0.03)から大きくズレます。これは、**交絡因子Z(習熟度)**が「広告接触(X)」と「CVR(Y)」の両方に影響しているためです。このズレこそが**セレクション・バイアス**です。

  • Z → X: 習熟度の高いユーザーは広告をクリックしやすい
  • Z → Y: 習熟度の高いユーザーは元々CVRが高い
  • 結果: 広告の効果を過大評価してしまう

解決策: 交絡因子Zを調整する手法(回帰分析、傾向スコアなど)を使う

📖 水曜(3時間)- 演習と復習

  • 実装演習: 上記のシミュレーションコードを改変し、交絡の強さを変えた場合のバイアスの変化を観察
  • サイトへの適用: 「物件問い合わせ」に影響する交絡因子(企業規模、業種、地域など)をリストアップ
  • 課題: RCTがなぜバイアスを除去できるかを、Pythonシミュレーションで確認

Day 4-7: DAG(因果グラフ)とバックドア基準

📖 木曜・金曜(各3時間)

  • 理論(金本 第2章/Udemy): DAG、**交絡因子**、**媒介変数**、**合流点(コライダー)**の役割
  • バックドア基準: どの変数を調整すれば交絡が除去できるかを判断するルール
  • d-分離: DAG上で因果関係の有無を判定する理論的ツール
  • フロントドア基準: 交絡因子が観測不能な場合の代替手法(advanced)
📚 DAGの基本ルール

構造 パス 因果推論での役割 調整の要否
交絡(Fork) X ← Z → Y 偽の相関を生む(バイアスの原因) 必要(バックドアを閉じる)
媒介(Chain) X → M → Y 真の因果が伝わる経路 不要(調整すると因果効果を消す)
合流点(Collider) X → C ← Y 通常は関係なし 不要(調整すると偽の相関を生む)
直接因果 X → Y 推定したい因果効果 調整対象ではない
💡 バックドア基準(Backdoor Criterion)

処置XからアウトカムYへの因果効果を推定するために、変数セットZを調整すべき条件:

  1. 条件1: ZはXの子孫(descendant)を含まない
  2. 条件2: ZはX→Yを含まないすべてのバックドアパスをブロックする

実務での使い方: DAGを描き、Xからバックドアで辿れるすべてのパスを特定 → それらを閉じる最小の変数セットを見つける

import networkx as nx
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch

# DAGの作成と可視化(サイトの広告効果分析の例)
def draw_dag(title):
G = nx.DiGraph()

# ノードの定義
nodes = {
'企業規模': (0, 2),
'業種': (0, 1),
'広告': (2, 2),
'サイト訪問': (4, 2),
'問い合わせ': (6, 2),
'季節性': (3, 0)
}

# エッジの定義(因果関係)
edges = [
('企業規模', '広告'), # 大企業ほど広告予算が大きい
('企業規模', '問い合わせ'), # 大企業ほど問い合わせしやすい(交絡)
('業種', '広告'), # 業種によって広告の選好が異なる
('業種', '問い合わせ'), # 業種によって問い合わせ率が異なる(交絡)
('広告', 'サイト訪問'), # 広告でサイト訪問が増える
('サイト訪問', '問い合わせ'), # 訪問から問い合わせへ(媒介)
('季節性', 'サイト訪問'), # 季節で訪問数が変動
('季節性', '問い合わせ') # 季節で問い合わせ数が変動(交絡)
]

G.add_nodes_from(nodes.keys())
G.add_edges_from(edges)

# 描画
plt.figure(figsize=(12, 8))
ax = plt.gca()

# ノードの色分け
node_colors = []
for node in G.nodes():
if node == '広告':
node_colors.append('#9c27b0') # 処置変数(紫)
elif node == '問い合わせ':
node_colors.append('#f44336') # アウトカム(赤)
elif node == 'サイト訪問':
node_colors.append('#ffc107') # 媒介変数(黄)
else:
node_colors.append('#4caf50') # 交絡因子(緑)

nx.draw(G, nodes,
node_color=node_colors,
node_size=3000,
with_labels=True,
font_size=11,
font_weight='bold',
font_family='Yu Gothic',
arrows=True,
arrowsize=20,
arrowstyle='->',
edge_color='#666',
width=2,
ax=ax)

plt.title(title, fontsize=14, fontweight='bold', pad=20)

# 凡例
legend_elements = [
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='#9c27b0',
markersize=10, label='処置変数'),
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='#f44336',
markersize=10, label='アウトカム'),
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='#ffc107',
markersize=10, label='媒介変数'),
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='#4caf50',
markersize=10, label='交絡因子')
]
plt.legend(handles=legend_elements, loc='upper left', fontsize=10)

plt.tight_layout()
plt.show()

draw_dag('サイトの広告効果分析DAG\n調整すべき変数: 企業規模、業種、季節性')

💡 実務でのDAG作成のコツ

  • ステップ1: 処置変数(X)とアウトカム(Y)を明確にする
  • ステップ2: ドメイン知識で「何が何に影響するか」をリストアップ
  • ステップ3: リストを矢印で繋いでDAGを描く
  • ステップ4: X→Y以外のパスを特定し、どれがバックドアか確認
  • ステップ5: バックドアパスを閉じる最小の変数セットを決定

注意: 媒介変数(X→M→Y)は調整しない!調整すると因果効果が消える

📖 土曜・日曜(演習と復習)

  • 実務演習: あなたの会社のA/Bテストデータ(または想定データ)の因果構造をDAGで図示してみる
  • 課題1: 「広告費(X)→ 売上(Y)」の間に、「景気(Z)」と「ブランド認知度(M)」を加えたDAGを作成し、調整すべき変数を特定せよ
  • 課題2: サイトの「サイトリニューアル → 問い合わせ数」のDAGを作成し、少なくとも5つの変数を含めよ
  • Week 2振り返り: 潜在的アウトカム、ATE、DAG、バックドア基準を自分の言葉でまとめる

✅ Week 2 完了チェックリスト






📅 Week 3: 回帰分析による因果効果の推定(Adjustment Formula)

Week 3 の学習目標: 回帰分析を「交絡調整ツール」として使う

  • 回帰分析(OLS)が因果推論に適用できる条件を理解する
  • **調整公式(Adjustment Formula)**を回帰式として実装する
  • カテゴリ変数、連続変数での因果効果推定をマスターする
  • 回帰分析の限界(線形性の仮定、外挿の問題)を理解する
  • 交互作用項を使った異質性(Heterogeneity)の分析ができる
Day 1-4: 回帰による交絡調整の実装

📖 月曜・火曜(各3時間)

  • 理論(金本 第3章): 条件付き独立性、回帰モデルと層別化の関係
  • 回帰式の解釈: Y = β₀ + β₁X + β₂Z + ε における β₁ の意味
  • 調整公式: E[Y|do(X=x)] = ∑_z E[Y|X=x, Z=z]P(Z=z) の回帰版
  • 標準誤差と信頼区間: 推定値の不確実性を評価する
📚 回帰分析による因果効果の推定

基本式: Y = β₀ + β₁X + β₂Z + ε

  • β₁: 交絡因子Zを調整した後のXのYに対する因果効果(ATE)
  • β₂: 交絡因子Zの効果(これ自体は因果効果とは限らない)
  • 前提条件:
    1. 線形性(X、Zと の関係が線形)
    2. すべての交絡因子がモデルに含まれている
    3. 誤差項が独立同分布(i.i.d.)

💻 回帰分析による因果効果(ATE)推定のコード

import statsmodels.formula.api as smf
import statsmodels.api as sm

# Week 2で作成した交絡を含むデータ(df)を使用

# 1. ナイーブ回帰モデル (交絡Zを調整しない)
model_naive = smf.ols('Y ~ X', data=df).fit()

print("=" * 70)
print("ナイーブ回帰 (交絡Zを無視)")
print("=" * 70)
print(f"Xの係数 (ナイーブATE): {model_naive.params['X']:.4f}")
print(f"95%信頼区間: [{model_naive.conf_int().loc['X', 0]:.4f}, {model_naive.conf_int().loc['X', 1]:.4f}]")
print(f"p値: {model_naive.pvalues['X']:.4f}")
print(f"真のATE: 0.0300")
print(f"バイアス: {model_naive.params['X'] - 0.03:.4f}")

# 2. 調整回帰モデル (交絡Zを共変量として追加)
model_adj = smf.ols('Y ~ X + Z', data=df).fit()

print("\n" + "=" * 70)
print("調整回帰 (交絡Zを調整)")
print("=" * 70)
print(f"Xの係数 (調整ATE): {model_adj.params['X']:.4f}")
print(f"95%信頼区間: [{model_adj.conf_int().loc['X', 0]:.4f}, {model_adj.conf_int().loc['X', 1]:.4f}]")
print(f"p値: {model_adj.pvalues['X']:.4f}")
print(f"真のATE: 0.0300")
print(f"推定誤差: {model_adj.params['X'] - 0.03:.4f}")

# 3. モデルの詳細サマリー
print("\n" + "=" * 70)
print("調整回帰モデルの詳細")
print("=" * 70)
print(model_adj.summary())

# 4. 可視化: 回帰直線の比較
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# ナイーブモデル
axes[0].scatter(df['X'], df['Y'], alpha=0.5, c='#9c27b0', s=30)
x_vals = np.array([0, 1])
y_vals_naive = model_naive.params['Intercept'] + model_naive.params['X'] * x_vals
axes[0].plot(x_vals, y_vals_naive, 'r-', linewidth=3, label=f'傾き={model_naive.params["X"]:.4f}')
axes[0].set_xlabel('広告接触 (X)')
axes[0].set_ylabel('CVR (Y)')
axes[0].set_title('ナイーブ回帰\n→ バイアスあり', fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# 調整モデル(Zの影響を除去した後)
# 残差プロット: YとXからZの影響を除去
residY = smf.ols('Y ~ Z', data=df).fit().resid
residX = smf.ols('X ~ Z', data=df).fit().resid
axes[1].scatter(residX, residY, alpha=0.5, c='#2196f3', s=30)
model_resid = smf.ols('residY ~ residX', data=pd.DataFrame({'residY': residY, 'residX': residX})).fit()
x_resid = np.linspace(residX.min(), residX.max(), 100)
y_resid = model_resid.params['Intercept'] + model_resid.params['residX'] * x_resid
axes[1].plot(x_resid, y_resid, 'r-', linewidth=3, label=f'傾き={model_adj.params["X"]:.4f}')
axes[1].set_xlabel('X(Z調整後の残差)')
axes[1].set_ylabel('Y(Z調整後の残差)')
axes[1].set_title('調整回帰(Frisch-Waugh-Lovell定理)\n→ 真の因果効果に近い', fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

💡 回帰分析を因果推論に使うコツ

  • 線形性: 回帰分析はあくまで**線形モデル**。非線形な関係(例:広告費が一定以上で効果激減)は多項式項や対数変換で対応
  • 変数選択: **調整する変数(Z)**を理論(DAG)に基づいて慎重に選ぶこと。たくさん入れれば良いわけではない(Colliderを調整すると逆にバイアス)
  • 多重共線性: 交絡因子同士が強く相関している場合、係数が不安定になる → VIF(分散拡大係数)で確認
  • 外挿の危険: データの範囲外での予測は信頼できない
⚠️ 回帰分析の限界と注意点

  • 未観測の交絡: モデルに含まれていない交絡因子があると、依然としてバイアスが残る
  • 関数形の誤設定: 真の関係が非線形なのに線形モデルを使うとバイアス
  • 外生性の仮定: 誤差項εがXと無相関という仮定が崩れると因果推論は失敗
  • 解釈の注意: 回帰係数が「因果効果」と解釈できるのは、DAGで正しく変数を選択した場合のみ

📖 水曜・木曜(各3時間)

  • 実装演習(Udemy): ロジスティック回帰による施策効果の推定、アウトカムが二値変数の場合の対応
  • 異質性(Heterogeneity): 調整因子Zの水準によって効果(Xの係数)が異なるケース(交互作用項の導入)
  • カテゴリ変数の扱い: ダミー変数化と解釈
  • 診断: 残差プロット、VIF、モデルの適合度(R²、AIC)
# 交互作用項を使った異質性の分析
# 「習熟度(Z)が高い人ほど、広告の効果が大きい」という仮説を検証

# 交互作用項を含むモデル
model_interaction = smf.ols('Y ~ X + Z + X:Z', data=df).fit()

print("=" * 70)
print("交互作用項を含むモデル(異質性の分析)")
print("=" * 70)
print(model_interaction.summary())

print("\n【解釈】")
print(f"Xの主効果: {model_interaction.params['X']:.4f}")
print(f"X:Zの交互作用: {model_interaction.params['X:Z']:.4f}")
print(f"→ Zが1単位増えると、Xの効果が{model_interaction.params['X:Z']:.4f}だけ変化する")

# 異質性の可視化
z_levels = [3, 5, 7] # 習熟度の低・中・高
colors = ['#e57373', '#9c27b0', '#42a5f5']

plt.figure(figsize=(10, 6))
for z, color in zip(z_levels, colors):
effect_at_z = model_interaction.params['X'] + model_interaction.params['X:Z'] * z
plt.bar(z, effect_at_z, color=color, alpha=0.7, width=0.5,
label=f'Z={z}: 効果={effect_at_z:.4f}')

plt.xlabel('習熟度 (Z)')
plt.ylabel('広告の効果(Xの係数)')
plt.title('異質性の分析: 習熟度別の広告効果\n→ 習熟度が高いほど広告が効く', fontweight='bold')
plt.legend()
plt.grid(alpha=0.3)
plt.show()

✅ Week 3 完了チェックリスト





📅 Week 4: 傾向スコア(Propensity Score)による推定

Week 4 の学習目標: 高次元の交絡を単一変数で調整する

  • **傾向スコア(Propensity Score)**の定義と役割を理解する
  • ロジスティック回帰で傾向スコアを算出できる
  • **傾向スコア・マッチング**と**逆確率重み付け(IPW)**の概念を理解し、実装の準備をする
  • **共通サポート**の概念と可視化を理解する
  • バランスチェックで調整の妥当性を評価できる
Day 1-4: 傾向スコアの算出とマッチングの基礎

📖 月曜・火曜(各3時間)

  • 理論(金本 第4章/Udemy): 傾向スコアの定義、**強い無視可能性(Unconfoundedness)**、**共通サポート(Common Support)**
  • 算出方法: ロジスティック回帰 e(Z) = P(X=1|Z) によるスコア算出
  • Rosenbaumの定理: なぜ傾向スコア1つで多次元の交絡を調整できるのか
  • バランス診断: 標準化差分(Standardized Difference)の計算
📚 傾向スコアとは

定義: e(Z) = P(X=1|Z) = 観測された共変量Zが与えられたとき、処置を受ける確率

なぜ強力か:

  • 交絡因子が10個、20個あっても、傾向スコアという1つの変数に圧縮できる
  • 傾向スコアが同じ個体同士を比較すれば、すべての交絡因子が調整される(Rosenbaumの定理)
  • 高次元の呪いを回避できる

前提条件:

  1. 強い無視可能性: すべての交絡因子が観測されている
  2. 共通サポート: すべての傾向スコアの値で、処置群と対照群の両方が存在する

💻 傾向スコア算出の実装

from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import NearestNeighbors
import matplotlib.pyplot as plt
import seaborn as sns

# ロジスティック回帰モデルで傾向スコアを算出
model_ps = LogisticRegression(solver='liblinear', random_state=42)
model_ps.fit(df[['Z']], df['X'])

# 施策を受ける確率 (傾向スコア) を計算
ps_scores = model_ps.predict_proba(df[['Z']])[:, 1]
df['PS'] = ps_scores

print("=" * 70)
print("傾向スコアの算出")
print("=" * 70)
print(f"傾向スコアの範囲: [{ps_scores.min():.4f}, {ps_scores.max():.4f}]")
print(f"処置群の平均PS: {df[df['X']==1]['PS'].mean():.4f}")
print(f"対照群の平均PS: {df[df['X']==0]['PS'].mean():.4f}")
# 傾向スコアの分布を可視化(共通サポートの確認)
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# プロット1: 傾向スコアの分布
axes[0, 0].hist(df[df['X']==1]['PS'], bins=30, alpha=0.6, label='処置群 (X=1)', color='#9c27b0', density=True)
axes[0, 0].hist(df[df['X']==0]['PS'], bins=30, alpha=0.6, label='対照群 (X=0)', color='#2196f3', density=True)
axes[0, 0].axvline(df[df['X']==1]['PS'].min(), color='#9c27b0', linestyle='--', linewidth=2, label='処置群の最小値')
axes[0, 0].axvline(df[df['X']==0]['PS'].max(), color='#2196f3', linestyle='--', linewidth=2, label='対照群の最大値')
axes[0, 0].set_xlabel('傾向スコア (PS)')
axes[0, 0].set_ylabel('密度')
axes[0, 0].set_title('傾向スコアの分布と共通サポート\n→ 重なり部分が共通サポート', fontweight='bold')
axes[0, 0].legend()
axes[0, 0].grid(alpha=0.3)

# プロット2: 習熟度Zと傾向スコアの関係
axes[0, 1].scatter(df[df['X']==0]['Z'], df[df['X']==0]['PS'], alpha=0.4, label='対照群', s=30, color='#2196f3')
axes[0, 1].scatter(df[df['X']==1]['Z'], df[df['X']==1]['PS'], alpha=0.4, label='処置群', s=30, color='#9c27b0')
z_range = np.linspace(df['Z'].min(), df['Z'].max(), 100)
ps_pred = model_ps.predict_proba(z_range.reshape(-1, 1))[:, 1]
axes[0, 1].plot(z_range, ps_pred, 'r-', linewidth=2, label='ロジスティック曲線')
axes[0, 1].set_xlabel('習熟度 (Z)')
axes[0, 1].set_ylabel('傾向スコア (PS)')
axes[0, 1].set_title('交絡因子と傾向スコアの関係\n→ Zが高いほどPSが高い', fontweight='bold')
axes[0, 1].legend()
axes[0, 1].grid(alpha=0.3)

# プロット3: 傾向スコアの箱ひげ図
ps_data = [df[df['X']==0]['PS'], df[df['X']==1]['PS']]
bp = axes[1, 0].boxplot(ps_data, labels=['対照群', '処置群'], patch_artist=True)
bp['boxes'][0].set_facecolor('#2196f3')
bp['boxes'][1].set_facecolor('#9c27b0')
axes[1, 0].set_ylabel('傾向スコア (PS)')
axes[1, 0].set_title('傾向スコアの分布(箱ひげ図)\n→ 処置群の方がPS高い', fontweight='bold')
axes[1, 0].grid(alpha=0.3, axis='y')

# プロット4: 共通サポートの詳細確認
common_support_min = max(df[df['X']==1]['PS'].min(), df[df['X']==0]['PS'].min())
common_support_max = min(df[df['X']==1]['PS'].max(), df[df['X']==0]['PS'].max())
in_support = (df['PS'] >= common_support_min) & (df['PS'] <= common_support_max) axes[1, 1].hist(df[in_support & (df['X']==1)]['PS'], bins=20, alpha=0.6, label=f'処置群(共通サポート内: {in_support[df["X"]==1].sum()}人)', color='#9c27b0') axes[1, 1].hist(df[in_support & (df['X']==0)]['PS'], bins=20, alpha=0.6, label=f'対照群(共通サポート内: {in_support[df["X"]==0].sum()}人)', color='#2196f3') axes[1, 1].axvspan(common_support_min, common_support_max, alpha=0.2, color='green', label='共通サポート範囲') axes[1, 1].set_xlabel('傾向スコア (PS)') axes[1, 1].set_ylabel('頻度') axes[1, 1].set_title(f'共通サポート: [{common_support_min:.3f}, {common_support_max:.3f}]\n除外: {(~in_support).sum()}人', fontweight='bold') axes[1, 1].legend() axes[1, 1].grid(alpha=0.3) plt.tight_layout() plt.show() print(f"\n共通サポート範囲: [{common_support_min:.4f}, {common_support_max:.4f}]") print(f"共通サポート内のサンプル数: {in_support.sum()}人 ({in_support.mean()*100:.1f}%)") print(f"共通サポート外のサンプル数: {(~in_support).sum()}人 ({(~in_support).mean()*100:.1f}%)")

⚠️ つまずきポイント: 共通サポートの解釈

共通サポート(ヒストグラムが重なる範囲)がない場合、その範囲のユーザーは**比較不可能**です。例えば:

  • 処置群にPS=0.9のユーザーがいるが、対照群にPS=0.9のユーザーがいない場合、そのユーザーは除外(または重み調整)する必要がある
  • 共通サポート外のサンプルを強引にマッチングすると、バイアスが大きくなる
  • 除外されたサンプルの特徴を必ず確認し、結果の一般化可能性を議論すること
💡 バランス診断: 標準化差分(Standardized Difference)

傾向スコアによる調整が成功しているかを確認する指標

標準化差分 = (処置群の平均 - 対照群の平均) / √[(処置群の分散 + 対照群の分散) / 2]

判断基準:

  • |標準化差分| < 0.1: バランス良好
  • 0.1 ≤ |標準化差分| < 0.25: やや不均衡
  • |標準化差分| ≥ 0.25: バランス不良(調整方法の見直しが必要)
# バランス診断: 標準化差分の計算
def standardized_difference(df, var, treatment_col='X'):
"""標準化差分を計算"""
treated = df[df[treatment_col]==1][var]
control = df[df[treatment_col]==0][var]

mean_diff = treated.mean() - control.mean()
pooled_std = np.sqrt((treated.var() + control.var()) / 2)

return mean_diff / pooled_std if pooled_std > 0 else 0

# 調整前のバランス
std_diff_before = standardized_difference(df, 'Z')

print("=" * 70)
print("バランス診断(傾向スコア調整前)")
print("=" * 70)
print(f"習熟度Zの標準化差分: {std_diff_before:.4f}")
if abs(std_diff_before) < 0.1: print("→ バランス良好") elif abs(std_diff_before) < 0.25: print("→ やや不均衡") else: print("→ バランス不良(調整が必要)") # 傾向スコアで層別化した後のバランス確認(簡易版)
# 傾向スコアを5つの層に分割
df['PS_quintile'] = pd.qcut(df['PS'], q=5, labels=False, duplicates='drop')

print("\n" + "=" * 70)
print("傾向スコア層別化後のバランス")
print("=" * 70)

for q in sorted(df['PS_quintile'].unique()):
df_q = df[df['PS_quintile'] == q]
if len(df_q[df_q['X']==1]) > 0 and len(df_q[df_q['X']==0]) > 0:
std_diff_q = standardized_difference(df_q, 'Z')
print(f"第{q+1}層(PS: {df_q['PS'].min():.3f}-{df_q['PS'].max():.3f})")
print(f" 標準化差分: {std_diff_q:.4f}")
print(f" 処置群: {len(df_q[df_q['X']==1])}人, 対照群: {len(df_q[df_q['X']==0])}人")

📖 水曜・木曜(各3時間)

  • 理論/実装(Udemy): **傾向スコア・マッチング**の原理(Nearest Neighbor Matchingなど)
  • マッチング手法: 1対1マッチング、1対多マッチング、カリパーマッチング
  • IPWの概念: **逆確率重み付け(Inverse Probability Weighting)**によるATE推定の理論
  • IPW実装準備: 重み W の計算 W = X/PS + (1-X)/(1-PS) の理解
  • 層別化: 傾向スコアで層を作り、各層内でATE推定
📚 傾向スコアの3つの使い方

手法 原理 長所 短所
マッチング PSが近い対照群を見つけてペアを作る 直感的でわかりやすい
外れ値に頑健
サンプルを捨てる可能性
マッチングの質に依存
層別化 PSで層を作り、各層内で比較 シンプル
すべてのサンプルを使用
層の数の選択が必要
層内の不均衡が残る可能性
IPW PSの逆数で重み付け すべてのサンプルを使用
理論的に最も効率的
極端なPSで重みが不安定
実装がやや複雑
# 傾向スコア・マッチング(1対1 Nearest Neighbor)の実装
from sklearn.neighbors import NearestNeighbors

# 共通サポート内のデータのみ使用
df_support = df[in_support].copy()

# 処置群と対照群に分割
treated = df_support[df_support['X'] == 1].copy()
control = df_support[df_support['X'] == 0].copy()

print("=" * 70)
print("傾向スコア・マッチング(1対1 Nearest Neighbor)")
print("=" * 70)
print(f"処置群: {len(treated)}人")
print(f"対照群: {len(control)}人")

# Nearest Neighbor探索(カリパー付き)
caliper = 0.2 * df_support['PS'].std() # カリパー = PSの標準偏差の0.2倍
print(f"カリパー: {caliper:.4f}")

nn = NearestNeighbors(n_neighbors=1, metric='euclidean')
nn.fit(control[['PS']].values)

distances, indices = nn.kneighbors(treated[['PS']].values)

# カリパー内のマッチのみ採用
valid_matches = distances.flatten() < caliper matched_treated_indices = treated.index[valid_matches] matched_control_indices = control.index[indices.flatten()[valid_matches]] print(f"\nマッチング成功: {valid_matches.sum()}ペア") print(f"マッチング失敗(カリパー外): {(~valid_matches).sum()}人") # マッチング後のデータセット
df_matched = pd.concat([
df_support.loc[matched_treated_indices],
df_support.loc[matched_control_indices]
])

# マッチング後のバランス確認
std_diff_after = standardized_difference(df_matched, 'Z')

print("\n" + "=" * 70)
print("マッチング後のバランス診断")
print("=" * 70)
print(f"調整前の標準化差分: {std_diff_before:.4f}")
print(f"調整後の標準化差分: {std_diff_after:.4f}")
print(f"改善: {abs(std_diff_before) - abs(std_diff_after):.4f}")

# マッチング後のATE推定
ate_matched = (
df_matched[df_matched['X']==1]['Y'].mean() -
df_matched[df_matched['X']==0]['Y'].mean()
)

print("\n" + "=" * 70)
print("因果効果の推定")
print("=" * 70)
print(f"真のATE: 0.0300")
print(f"マッチング後のATE: {ate_matched:.4f}")
print(f"推定誤差: {ate_matched - 0.03:.4f}")

# 可視化: マッチング前後の比較
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# マッチング前
axes[0].scatter(df_support[df_support['X']==0]['PS'],
df_support[df_support['X']==0]['Z'],
alpha=0.5, label='対照群', s=30, color='#2196f3')
axes[0].scatter(df_support[df_support['X']==1]['PS'],
df_support[df_support['X']==1]['Z'],
alpha=0.5, label='処置群', s=30, color='#9c27b0')
axes[0].set_xlabel('傾向スコア (PS)')
axes[0].set_ylabel('習熟度 (Z)')
axes[0].set_title(f'マッチング前\n標準化差分={std_diff_before:.3f}', fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# マッチング後
axes[1].scatter(df_matched[df_matched['X']==0]['PS'],
df_matched[df_matched['X']==0]['Z'],
alpha=0.5, label='対照群(マッチング後)', s=30, color='#2196f3')
axes[1].scatter(df_matched[df_matched['X']==1]['PS'],
df_matched[df_matched['X']==1]['Z'],
alpha=0.5, label='処置群(マッチング後)', s=30, color='#9c27b0')
axes[1].set_xlabel('傾向スコア (PS)')
axes[1].set_ylabel('習熟度 (Z)')
axes[1].set_title(f'マッチング後\n標準化差分={std_diff_after:.3f}', fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

💡 マッチングのコツ

  • カリパーの設定: PSの標準偏差の0.1〜0.25倍が一般的。厳しすぎるとマッチング数が減る
  • 1対多マッチング: 処置群が少ない場合、1人に対して複数の対照群をマッチングする
  • 置換の有無: 同じ対照群を複数回使うか(置換あり)、1回のみか(置換なし)
  • マッチング後のバランスチェック必須: 標準化差分で必ず確認

✅ Week 4 完了チェックリスト






📅 Week 5: 準実験デザインの概要とPhase 1総復習

Week 5 の学習目標: 観測データから因果を炙り出すテクニックを知る

  • **差分の差分法(DiD)**の基本原理と適用例を理解する
  • **回帰不連続デザイン(RDD)**の基本原理を理解する
  • DiDをPythonで実装し、因果効果を推定できる
  • Phase 1の全ての概念を整理し、Phase 2への学習準備を完了する
  • 実務でどの手法を選ぶべきかの判断基準を持つ
Day 1-3: DiD(差分の差分法)とRDDの基礎

📖 月曜・火曜(各3時間)

  • DiD(金本 第5章/Udemy): DiDの数式と「平行トレンドの仮定」の重要性
  • WEBマーケでのDiD例: 「特定地域へのテレビ広告投入」の前後で、広告を打たなかった地域と比較する
  • RDD: 「LTV上位10%に達したユーザーへの特別施策」のように、**閾値**を利用するケース
  • 準実験デザイン: RCTができない場合の代替手法群
📚 差分の差分法(DiD)とは

基本アイデア: 処置群の前後変化 - 対照群の前後変化 = 因果効果

DiD = [E[Y|処置群,後] - E[Y|処置群,前]] - [E[Y|対照群,後] - E[Y|対照群,前]]

なぜ強力か:

  • 時間不変の交絡因子(処置群と対照群の元々の差)が自動的に除去される
  • 共通トレンド(両群に共通の時間変動)も除去される
  • 残るのは処置の純粋な効果のみ

重要な仮定:

  • 平行トレンドの仮定: 処置がなければ、両群のトレンドは平行だったはず
  • この仮定が崩れるとDiDは失敗する

💻 DiDモデルの実装

import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt

# DiDシミュレーションデータ作成
# サイト: 東京エリア(処置群)でサイトリニューアル、大阪エリア(対照群)は変更なし
np.random.seed(42)
N = 400

df_did = pd.DataFrame({
'Group': np.repeat([0, 1], N/2), # 0: 対照(大阪), 1: 処置(東京)
'Time': np.tile(np.repeat([0, 1], N/4), 2), # 0: リニューアル前, 1: リニューアル後
'Y': np.zeros(N)
})

# データ生成(真のDiD効果=10件)
df_did['Y'] = (
50 # ベースライン(問い合わせ数)
+ 5 * df_did['Group'] # グループ差(東京は元々5件多い)
+ 8 * df_did['Time'] # 時間トレンド(全体で8件増加)
+ 10 * df_did['Group'] * df_did['Time'] # 真のDiD効果 (10件)
+ np.random.normal(0, 3, N) # ランダムノイズ
)

print("=" * 70)
print("DiD(差分の差分法)分析: サイトサイトリニューアル効果")
print("=" * 70)

# 各グループ・期間の平均を計算
means = df_did.groupby(['Group', 'Time'])['Y'].mean()
print("\n【各グループ・期間の平均問い合わせ数】")
print(f"大阪(対照群)・リニューアル前: {means[0, 0]:.2f}件")
print(f"大阪(対照群)・リニューアル後: {means[0, 1]:.2f}件")
print(f"東京(処置群)・リニューアル前: {means[1, 0]:.2f}件")
print(f"東京(処置群)・リニューアル後: {means[1, 1]:.2f}件")

# 第1の差分(各グループの前後比較)
diff_control = means[0, 1] - means[0, 0]
diff_treated = means[1, 1] - means[1, 0]

print(f"\n【第1の差分(前後比較)】")
print(f"大阪の変化: {diff_control:.2f}件")
print(f"東京の変化: {diff_treated:.2f}件")

# 第2の差分(DiD効果)
did_effect = diff_treated - diff_control

print(f"\n【DiD効果(差分の差分)】")
print(f"DiD = {diff_treated:.2f} - {diff_control:.2f} = {did_effect:.2f}件")
print(f"真の因果効果: 10.00件")
print(f"推定誤差: {did_effect - 10:.2f}件")

# DiD回帰モデル: Y = β0 + β1*Group + β2*Time + β3*(Group*Time) + ε
# β3がDiD効果に相当する
model_did = smf.ols('Y ~ Group * Time', data=df_did).fit()

print("\n" + "=" * 70)
print("DiD回帰モデルによる推定")
print("=" * 70)
print(model_did.summary())

print(f"\n【回帰モデルでの推定結果】")
print(f"DiD効果(Group:Timeの係数): {model_did.params['Group:Time']:.4f}")
print(f"95%信頼区間: [{model_did.conf_int().loc['Group:Time', 0]:.4f}, {model_did.conf_int().loc['Group:Time', 1]:.4f}]")
print(f"p値: {model_did.pvalues['Group:Time']:.4f}")

# 可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# プロット1: 時系列推移
for group, label, color in [(0, '大阪(対照群)', '#2196f3'), (1, '東京(処置群)', '#9c27b0')]:
df_group = df_did[df_did['Group'] == group]
group_means = df_group.groupby('Time')['Y'].mean()
axes[0].plot([0, 1], group_means.values, 'o-', linewidth=3, markersize=10,
label=label, color=color)

# 反実仮想(東京がリニューアルしなかった場合)
if group == 1:
counterfactual= [means[1, 0], means[1, 0] + diff_control]
axes[0].plot([0, 1], counterfactual, 'o--', linewidth=2, markersize=8,
alpha=0.5, color=color, label='東京(反実仮想)')

axes[0].axvline(x=0.5, color='red', linestyle='--', linewidth=2, alpha=0.7, label='リニューアル実施')
axes[0].set_xticks([0, 1])
axes[0].set_xticklabels(['リニューアル前', 'リニューアル後'])
axes[0].set_ylabel('問い合わせ数(件)')
axes[0].set_title('DiDの可視化\n→ 反実仮想との差がDiD効果', fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# プロット2: DiD効果の分解
categories = ['大阪\n(対照群)', '東京\n(処置群)']
before_means = [means[0, 0], means[1, 0]]
after_means = [means[0, 1], means[1, 1]]

x = np.array([0, 1])
width = 0.35

bars1 = axes[1].bar(x - width/2, before_means, width, label='リニューアル前',
color='lightblue', alpha=0.8)
bars2 = axes[1].bar(x + width/2, after_means, width, label='リニューアル後',
color='orange', alpha=0.8)

# 変化量を矢印で表示
for i, (before, after, diff) in enumerate([(means[0,0], means[0,1], diff_control),
(means[1,0], means[1,1], diff_treated)]):
axes[1].annotate('', xy=(i, after), xytext=(i, before),
arrowprops=dict(arrowstyle='->', lw=2, color='black'))
axes[1].text(i + 0.15, (before + after)/2, f'+{diff:.1f}',
fontsize=11, fontweight='bold', color='red' if i==1 else 'blue')

axes[1].set_ylabel('問い合わせ数(件)')
axes[1].set_xticks(x)
axes[1].set_xticklabels(categories)
axes[1].set_title(f'DiD効果の分解\nDiD = {diff_treated:.1f} - {diff_control:.1f} = {did_effect:.1f}件',
fontweight='bold')
axes[1].legend()
axes[1].grid(axis='y', alpha=0.3)

plt.tight_layout()
plt.show()

💡 DiDの最も重要な仮定: 平行トレンド

DiDが機能するためには、**「もし処置がなければ、処置群と対照群のアウトカムのトレンドは平行だった」**という仮定が必要です。

検証方法:

  • プラセボテスト: 処置前の複数期間でDiDを実施し、効果がゼロかを確認
  • グラフ検証: 処置前の時系列グラフで、両群のトレンドが平行かを目視確認
  • イベントスタディ: 処置前後の各期間での効果を推定し、処置前はゼロかを確認

サイトでの例: 東京と大阪で、リニューアル前3ヶ月間の問い合わせ数のトレンドが平行かを確認

⚠️ DiDが失敗するケース

  • 予期された処置: リニューアル予告で事前に行動変化 → 処置前から差が出る
  • 同時期の他の変化: 処置群だけに別のイベントが発生 → DiD効果に混入
  • 構成変化: 処置群のユーザー構成が処置後に変化 → 平行トレンドが崩れる
  • スピルオーバー: 対照群が処置の影響を受ける → 純粋な対照群ではない

📖 水曜(3時間)- RDDの基礎

  • RDD(金本 第6章/Udemy): 回帰不連続デザインの原理
  • 基本アイデア: 閾値の前後で処置が決まる場合、閾値付近で比較すれば因果効果が推定できる
  • Sharp RDD vs Fuzzy RDD: 閾値で処置が完全に決まるか、確率的に決まるか
  • サイトでの応用例: 「問い合わせ数100件以上の企業に特別対応」の効果測定
📚 回帰不連続デザイン(RDD)とは

基本アイデア: 何らかのスコアに閾値があり、閾値を超えると処置が決まる場合、閾値前後で比較

例(サイト):

  • 企業規模が従業員100人以上 → プレミアム対応
  • 閾値付近(95〜105人)の企業を比較すれば、ほぼランダム化と同じ

重要な仮定:

  • 連続性: 閾値付近で潜在的アウトカムが連続(ジャンプしない)
  • 操作不可能性: 企業が閾値を超えるために従業員数を操作していない
# RDDのシミュレーション(Sharp RDD)
np.random.seed(42)
n_rdd = 500

# Running Variable: 企業規模(従業員数)
company_size = np.random.uniform(50, 150, n_rdd)

# 閾値: 100人
threshold = 100

# 処置: 100人以上でプレミアム対応
treatment_rdd = (company_size >= threshold).astype(int)

# アウトカム: 成約数(閾値で10件ジャンプ = RDD効果)
outcome_rdd = (
50 + # ベースライン
0.3 * (company_size - threshold) + # 企業規模の線形効果
10 * treatment_rdd + # 真のRDD効果(10件)
np.random.normal(0, 3, n_rdd) # ノイズ
)

df_rdd = pd.DataFrame({
'CompanySize': company_size,
'Treatment': treatment_rdd,
'Outcome': outcome_rdd,
'Distance': company_size - threshold # 閾値からの距離
})

print("=" * 70)
print("RDD(回帰不連続デザイン)分析")
print("=" * 70)

# バンド幅内(閾値±20人)でのRDD推定
bandwidth = 20
df_rdd_bw = df_rdd[abs(df_rdd['Distance']) <= bandwidth].copy() print(f"\n使用サンプル: 全体{len(df_rdd)}社 → バンド幅内{len(df_rdd_bw)}社") print(f"閾値: 従業員{threshold}人") print(f"バンド幅: ±{bandwidth}人({threshold-bandwidth}〜{threshold+bandwidth}人)") # 閾値直前と直後の平均比較
mean_below = df_rdd_bw[df_rdd_bw['Treatment']==0]['Outcome'].mean()
mean_above = df_rdd_bw[df_rdd_bw['Treatment']==1]['Outcome'].mean()
rdd_effect_naive = mean_above - mean_below

print(f"\n【単純比較】")
print(f"閾値未満(<100人)の平均成約数: {mean_below:.2f}件") print(f"閾値以上(≥100人)の平均成約数: {mean_above:.2f}件") print(f"差(RDD効果): {rdd_effect_naive:.2f}件") # 局所線形回帰によるRDD推定
model_rdd = smf.ols('Outcome ~ Treatment + Distance + Treatment:Distance',
data=df_rdd_bw).fit()

print(f"\n【局所線形回帰】")
print(f"RDD効果(Treatmentの係数): {model_rdd.params['Treatment']:.4f}")
print(f"95%信頼区間: [{model_rdd.conf_int().loc['Treatment', 0]:.4f}, "
f"{model_rdd.conf_int().loc['Treatment', 1]:.4f}]")
print(f"p値: {model_rdd.pvalues['Treatment']:.4f}")
print(f"真の因果効果: 10.00件")

# 可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# プロット1: RDDの散布図
axes[0].scatter(df_rdd[df_rdd['Treatment']==0]['CompanySize'],
df_rdd[df_rdd['Treatment']==0]['Outcome'],
alpha=0.4, s=30, color='#2196f3', label='処置なし(<100人)') axes[0].scatter(df_rdd[df_rdd['Treatment']==1]['CompanySize'], df_rdd[df_rdd['Treatment']==1]['Outcome'], alpha=0.4, s=30, color='#9c27b0', label='処置あり(≥100人)') # 回帰直線(バンド幅内)
x_below = np.linspace(threshold - bandwidth, threshold, 100)
x_above = np.linspace(threshold, threshold + bandwidth, 100)

y_below = (model_rdd.params['Intercept'] +
model_rdd.params['Distance'] * (x_below - threshold))
y_above = (model_rdd.params['Intercept'] +
model_rdd.params['Treatment'] +
(model_rdd.params['Distance'] + model_rdd.params['Treatment:Distance']) *
(x_above - threshold))

axes[0].plot(x_below, y_below, 'r-', linewidth=3, label='回帰直線(閾値未満)')
axes[0].plot(x_above, y_above, 'r-', linewidth=3, label='回帰直線(閾値以上)')
axes[0].axvline(x=threshold, color='green', linestyle='--', linewidth=2, label='閾値')
axes[0].axvspan(threshold - bandwidth, threshold + bandwidth, alpha=0.1, color='yellow')

axes[0].set_xlabel('企業規模(従業員数)')
axes[0].set_ylabel('成約数(件)')
axes[0].set_title('RDD: 閾値でのジャンプ\n→ ジャンプ幅=処置効果', fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# プロット2: バンド幅内の拡大図
axes[1].scatter(df_rdd_bw[df_rdd_bw['Treatment']==0]['CompanySize'],
df_rdd_bw[df_rdd_bw['Treatment']==0]['Outcome'],
alpha=0.6, s=50, color='#2196f3', label='処置なし')
axes[1].scatter(df_rdd_bw[df_rdd_bw['Treatment']==1]['CompanySize'],
df_rdd_bw[df_rdd_bw['Treatment']==1]['Outcome'],
alpha=0.6, s=50, color='#9c27b0', label='処置あり')

axes[1].plot(x_below, y_below, 'r-', linewidth=3)
axes[1].plot(x_above, y_above, 'r-', linewidth=3)
axes[1].axvline(x=threshold, color='green', linestyle='--', linewidth=2)

# RDD効果を矢印で表示
y_at_threshold_below = model_rdd.params['Intercept']
y_at_threshold_above = model_rdd.params['Intercept'] + model_rdd.params['Treatment']
axes[1].annotate('', xy=(threshold, y_at_threshold_above),
xytext=(threshold, y_at_threshold_below),
arrowprops=dict(arrowstyle='<->', lw=3, color='red'))
axes[1].text(threshold + 2, (y_at_threshold_below + y_at_threshold_above)/2,
f'RDD効果\n{model_rdd.params["Treatment"]:.1f}件',
fontsize=11, fontweight='bold', color='red')

axes[1].set_xlabel('企業規模(従業員数)')
axes[1].set_ylabel('成約数(件)')
axes[1].set_title(f'バンド幅内の拡大図(±{bandwidth}人)\n→ 閾値付近はほぼランダム', fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Day 4-7: Phase 1総復習と実務への統合

📖 木曜・金曜(各3時間)

  • 総復習: 潜在的アウトカム、DAG、回帰分析、傾向スコア、DiD、RDDの全てを整理
  • 手法の選択基準: どの状況でどの手法を使うべきかのフローチャート作成
  • Phase 2の予習: ベイズ統計学で因果推論をどう表現するかを軽く確認
  • 実務課題: サイトの実データ(または仮想データ)で複数手法を試す
📊 因果推論手法の選択フローチャート

状況 推奨手法 理由
ランダム化可能 RCT(A/Bテスト) 最も信頼性が高い(ゴールドスタンダード)
すべての交絡因子が観測済み 回帰分析 or 傾向スコア 交絡を調整できる。高次元なら傾向スコア
処置前後データ + 対照群あり DiD 時間不変の交絡を自動除去。平行トレンド要確認
閾値で処置が決まる RDD 閾値付近はランダム化に近い。操作不可要確認
未観測の交絡あり + 操作変数あり IV(操作変数法) 未観測交絡に対応可能。Phase 1では詳細未学習
対照群がない 合成コントロール法 or 中断時系列 複数の対照候補から合成。Phase 1では詳細未学習
💡 Phase 1で学んだ手法の比較

手法 主な仮定 長所 短所
回帰分析 ・すべての交絡観測済み
・線形性
シンプル
解釈が容易
未観測交絡に弱い
非線形に対応困難
傾向スコア ・すべての交絡観測済み
・共通サポート
高次元交絡に対応
バランスが視覚的
未観測交絡に弱い
モデル依存
DiD ・平行トレンド
・SUTVA
時間不変交絡を除去
直感的
平行トレンド検証必須
処置前後データ必要
RDD ・閾値での連続性
・操作不可能性
未観測交絡に頑健
説得力がある
閾値付近のみ推定
外的妥当性が限定的

📖 土曜・日曜(総合演習とまとめ)

  • 総合演習: サイトの架空データで、4つの手法(回帰、PS、DiD、RDD)を実装し、結果を比較
  • レポート作成: 経営層向けに「Phase 1で習得したスキル」をまとめる
  • Phase 2への準備: ベイズ統計学の基礎(事前分布、事後分布)を予習
  • 振り返り: つまずいたポイント、よく理解できた概念を整理

✅ Week 5 完了チェックリスト






🎓 Phase 1 完了チェックリスト総まとめ

☑ 潜在的アウトカムとATEを明確に定義できる
☑ DAGで交絡因子(Confounder)を特定できる
☑ 回帰分析で交絡因子を調整した因果効果を計算できる
☑ 傾向スコア(PS)をロジスティック回帰で算出し、分布を確認できる
☑ DiDの「平行トレンド」仮定と、回帰モデルでの実装を理解している
☑ RDDの閾値での比較原理を理解している
☑ 「相関≠因果」を脱却し、「因果の言語」で議論できる
☑ 実務でどの手法を選ぶべきかの判断基準を持っている

🎉 Phase 1のまとめと次のステップ

🏆 Phase 1で習得したスキル: 因果推論の基礎力

この4週間で、あなたは「相関」と「因果」の壁を完全に越えました。単なるデータ分析者から、**因果構造を設計できる戦略家**へと進化しました。

  • ✅ 因果効果を定義する**潜在的アウトカム**の思考回路
  • ✅ 交絡を特定する**DAG**という最強のツール
  • ✅ 交絡を調整する**回帰分析・傾向スコア**という実用的な武器
  • ✅ 時間と閾値を活用する**DiD・RDD**という準実験デザイン
  • ✅ 実務でどの手法を選ぶべきかの**判断力**
💡 Phase 2への準備: ベイズ統計学による因果推論

次のPhase 2では、**ベイズ統計学**を導入します。Phase 1で学んだ手法は「点推定」(効果がX%)でしたが、ベイズ統計学では効果を「確率分布」で推定し、**不確実性**を考慮した意思決定が可能になります。

  • 課題1: 回帰分析の係数(ATE)の**信頼区間**を、ベイズ統計でより柔軟に表現する
  • 課題2: 処置効果がゼロでない**確率**を算出することで、意思決定の確度を高める
  • 課題3: 事前知識(ドメイン知識)を**事前分布**として組み込む

Phase 1の統計学的基礎が、Phase 2での柔軟なベイズモデリングを可能にします。

⚠️ Phase 1で学んだ手法の限界を忘れずに

  • 未観測の交絡: すべての手法が「観測された交絡因子が全て」という前提に依存
  • 外的妥当性: 特にRDDやマッチングは、一部のサンプルでの推定 → 全体への一般化に注意
  • 仮定の検証: 平行トレンド、共通サポート、操作不可能性などの仮定を必ず確認
  • 因果推論は万能ではない: 複雑な因果構造、フィードバックループ、動的な関係には限界がある

次のステップ: Phase 6で学ぶDoWhyなどのツールで、仮定の検証と感度分析を強化

🚀 Phase 2へ進む準備はできましたか?

Phase 1を完了したあなたは、因果推論の基礎を完璧にマスターしました。

次のPhase 2では、ベイズ統計学で不確実性を味方につけます。

Phase 2: ベイズ統計学による因果推論へ →



-Uncategorized