Skip to content

因果推断实操指南

从相关性到因果性——当 A/B 测试不可行时,如何推断因果关系?


🤔 为什么 A/B 测试不够?

A/B 测试是因果推断的黄金标准,但现实中大量场景无法随机化

场景 为什么不能 A/B 该用什么
新政策对经济的影响 无法把国家随机分成两组 DID 双重差分
教育对收入的影响 不能随机决定谁上学 IV 工具变量
奖学金对成绩的影响 只在分数线上下可比 RDD 断点回归
培训对就业的影响 参加者自愿选择(自选择偏差) PSM 倾向得分匹配

🧭 方法选择决策树

能随机分配吗?
├─ 能 → A/B 测试(RCT)
└─ 不能
   ├─ 有干预前后的面板数据吗?
   │  └─ 有 → 双重差分(DID)
   ├─ 有一个"类似随机"的外部因素吗?
   │  └─ 有 → 工具变量(IV)
   ├─ 干预在某个阈值处断点式变化?
   │  └─ 是 → 断点回归(RDD)
   └─ 只有观测数据?
      └─ 倾向得分匹配(PSM)/ 逆概率加权(IPW)

📐 方法一:双重差分(DID)

核心思想:比较「实验组干预前后的变化」与「对照组同期的自然变化」之差。

直观理解

          干预前    干预后    变化
实验组     10       15       +5
对照组     10       12       +2
─────────────────────────────────
DID 估计 = +5 - (+2) = +3  ← 干预的真实效果

平行趋势假设

DID 成立的前提是:如果没有干预,实验组的变化趋势应和对照组一致

import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

# 模拟数据
np.random.seed(42)
n = 1000

df = pd.DataFrame({
    'id': np.tile(range(n), 2),
    'treated': np.tile(np.random.binomial(1, 0.5, n), 2),  # 50% 进入实验组
    'post': np.repeat([0, 1], n),                           # 0=干预前, 1=干预后
    'time_trend': np.tile(np.random.normal(0, 1, n), 2),
})

# 实验组在干预后额外提升 3 个单位
true_effect = 3
df['outcome'] = (
    10
    + 2 * df['post']                    # 自然时间趋势 +2
    + 1 * df['treated']                 # 实验组基线高 1
    + true_effect * df['treated'] * df['post']  # 干预效应
    + df['time_trend']
    + np.random.normal(0, 0.5, 2*n)
)

# DID 回归: outcome ~ treated + post + treated:post
model = smf.ols('outcome ~ treated + post + treated:post', data=df).fit()
print(model.summary().tables[1])
# treated:post 的系数 ≈ 3.0 ← 因果效应估计

DID 的局限

  • ❌ 必须满足平行趋势假设(做事件研究图来检验)
  • ❌ 不能有其他同时发生的干预(混杂因素)
  • ❌ 适用于面板数据(同一单位前后观测)

🔧 方法二:工具变量(IV)

核心思想:找一个只通过「影响干预」来影响结果,而不直接影响结果的外部因素。

经典案例:教育回报率

想估计:多上一年学,收入增加多少?
问题:  上学年限受"能力"影响,高能力→多上学→高收入(混杂)
工具:  出生季度!冬季出生的学生入学晚,被迫多上一年
       出生季度 → 上学年限 → 收入
       出生季度 ↛ 收入(除了通过上学年限)

两阶段最小二乘法(2SLS)

# 模拟数据:教育对收入的影响
np.random.seed(42)
n = 5000

ability = np.random.normal(0, 1, n)          # 不可观测的"能力"
birth_quarter = np.random.randint(1, 5, n)   # 工具变量:出生季度

# 第一阶段:出生季度 → 教育年限
education = 12 + 0.5 * birth_quarter + 1.5 * ability + np.random.normal(0, 1, n)

# 第二阶段:教育年限 → 收入(但 ability 直接也影响收入!)
true_return = 0.12  # 真实教育回报率:12%
income = np.exp(10 + true_return * education + 0.8 * ability + np.random.normal(0, 0.3, n))

# 错误的 OLS 估计(存在遗漏变量偏误)
from numpy.linalg import lstsq
X_ols = np.column_stack([np.ones(n), education])
beta_ols = lstsq(X_ols, np.log(income), rcond=None)[0]
print(f"OLS 估计教育回报率: {beta_ols[1]:.3f}")  # 偏高!因为 ability 混杂
# 正确的 2SLS 估计
from statsmodels.sandbox.regression.gmm import IV2SLS
import statsmodels.api as sm

df = pd.DataFrame({
    'ln_income': np.log(income),
    'education': education,
    'birth_quarter': birth_quarter
})

# 工具变量回归
iv_model = IV2SLS(
    endog=df['ln_income'],
    exog=sm.add_constant(df[['education']]),
    instrument=sm.add_constant(df[['birth_quarter']])
).fit()

print(f"IV 估计教育回报率: {iv_model.params['education']:.3f}")
# 更接近真实的 0.12

IV 的三个条件

  1. 相关性:工具变量与干预变量相关(第一阶段 F > 10)
  2. 外生性:工具变量不直接影响结果(只能通过干预)
  3. 排他性:没有其他路径(最不可检验的条件)

📏 方法三:断点回归(RDD)

核心思想:在某个阈值上下,个体几乎相同——除了「是否刚好过线」

经典案例:奖学金效应

分数线 = 600 分
≥ 600 分 → 获得奖学金(干预)
< 600 分 → 未获得

599 分和 601 分的学生几乎一样——除了奖学金
→ 比较这两组的结果差异 = 奖学金的因果效应
# 模拟数据
np.random.seed(42)
n = 1000
score = np.random.normal(600, 30, n)  # 考试成绩
treated = (score >= 600).astype(int)    # 过线 = 获得奖学金

# 真实效应:奖学金让毕业率 +15%
true_effect = 0.15
graduated = (
    0.6
    + 0.002 * (score - 600)         # 成绩的自然影响
    + true_effect * treated          # 奖学金的因果效应
    + np.random.normal(0, 0.1, n)
)
graduated = np.clip(graduated, 0, 1)

# Sharp RDD:局部线性回归
# 只用在断点附近 ±15 分的样本
bandwidth = 15
near = np.abs(score - 600) < bandwidth

df_rdd = pd.DataFrame({
    'graduated': graduated[near],
    'score_centered': score[near] - 600,
    'treated': treated[near]
})

model_rdd = smf.ols(
    'graduated ~ score_centered + treated + score_centered:treated',
    data=df_rdd
).fit()

print(model_rdd.summary().tables[1])
# treated 的系数 ≈ 0.15 ← 断点处的因果效应

RDD 的有效性检验

  1. McCrary 检验:断点附近的人数不应有跳跃(不能有操纵)
  2. 前定变量连续性:年龄、性别等应在断点处连续
  3. 安慰剂检验:在非断点处做假检验,不应该有效应

🎯 方法四:倾向得分匹配(PSM)

核心思想:为每个「处理组」个体,在「对照组」找一个最相似的个体配对。

from sklearn.linear_model import LogisticRegression

# 模拟:培训对工资的影响(自选择问题)
np.random.seed(42)
n = 2000

age = np.random.normal(35, 10, n)
experience = np.random.normal(8, 5, n)
education = np.random.randint(9, 19, n)  # 9-18 年教育

# 高学历 + 年轻 → 更倾向参加培训(自选择!)
propensity = 1 / (1 + np.exp(-(-2 + 0.05*education - 0.02*age + 0.03*experience)))
trained = np.random.binomial(1, propensity)

# 真实培训效应 = +15% 工资
true_effect = 0.15
wage = np.exp(
    np.log(5000)
    + 0.03 * education
    + 0.02 * experience
    + true_effect * trained
    + np.random.normal(0, 0.2, n)
)

# Step 1: 估计倾向得分
X = np.column_stack([age, experience, education])
ps_model = LogisticRegression().fit(X, trained)
ps_score = ps_model.predict_proba(X)[:, 1]

# Step 2: 匹配(最近邻)
from sklearn.neighbors import NearestNeighbors

treated_idx = np.where(trained == 1)[0]
control_idx = np.where(trained == 0)[0]

# 对每个处理组个体,找倾向得分最接近的对照组个体
nn = NearestNeighbors(n_neighbors=1)
nn.fit(ps_score[control_idx].reshape(-1, 1))
distances, indices = nn.kneighbors(ps_score[treated_idx].reshape(-1, 1))
matched_control = control_idx[indices.flatten()]

# Step 3: 计算 ATT(处理组的平均处理效应)
att = wage[treated_idx].mean() - wage[matched_control].mean()
print(f"PSM ATT 估计: 工资变化 = {(np.exp(att/np.log(np.e))-1)*100:.1f}%")

PSM 的前提

  • 条件独立假设(CIA):控制了可观测变量后,干预分配与潜在结果独立
  • 共同支撑(Common Support):处理组和对照组的倾向得分有重叠区域
  • ⚠️ PSM 无法处理不可观测的混杂因素——如果自选择基于"能力",而你没有测量能力,PSM 也无能为力

⚖️ 方法对比总结

方法 需要的假设 适用场景 局限
RCT 随机化成功 能随机分配 成本高、伦理限制
DID 平行趋势 有面板数据 趋势可能不平行
IV 有效工具变量 找到好工具 好工具难找
RDD 断点附近无操纵 有明确阈值 只估计局部效应
PSM 无不可观测混杂 丰富观测变量 不可观测偏误
合成控制 能构造合理反事实 单个单元干预 需要长面板

选择原则:用最可信的假设换取最可靠的结论。


🔗 Python 因果推断工具链

功能 特点
DoWhy 建模→识别→估计→反驳 四步框架,微软出品
EconML DML、DRLearner、OrthoForest 异质性处理效应
causalml Uplift Modeling 营销场景
linearmodels IV、面板数据 传统计量

🔗 相关资源