因果推断实操指南¶
从相关性到因果性——当 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 的三个条件¶
- 相关性:工具变量与干预变量相关(第一阶段 F > 10)
- 外生性:工具变量不直接影响结果(只能通过干预)
- 排他性:没有其他路径(最不可检验的条件)
📏 方法三:断点回归(RDD)¶
核心思想:在某个阈值上下,个体几乎相同——除了「是否刚好过线」
经典案例:奖学金效应¶
# 模拟数据
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 的有效性检验¶
- McCrary 检验:断点附近的人数不应有跳跃(不能有操纵)
- 前定变量连续性:年龄、性别等应在断点处连续
- 安慰剂检验:在非断点处做假检验,不应该有效应
🎯 方法四:倾向得分匹配(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、面板数据 | 传统计量 |
🔗 相关资源¶
- A/B 测试实操指南 — RCT 的黄金标准
- 因果推断资源合集 — 完整书单+课程+论文
- Causal Inference: What If 中英对照 — 因果推断教科书
- 相关案例:营销组合模型 · 贝叶斯风险评估