13.2 合成控制法 (Synthetic Control Method)
"The synthetic control method provides a systematic way to choose comparison units in comparative case studies.""合成控制法为比较案例研究中选择对照单位提供了系统化的方法。"— Alberto Abadie, MIT Economist (MIT经济学家)
Alberto Abadie的反事实构造艺术:当只有一个处理单位时的因果推断
本节目标
- 理解合成控制法的核心思想和适用场景
- 掌握权重优化算法和V-矩阵的作用
- 区分SCM与传统DID的异同
- 完整复现California Proposition 99案例
- 使用Python实现SCM(手动+SparseSC)
- 进行统计推断和安慰剂检验
引言:当DID失效时
经典DID的局限
回顾Module 9的DID核心假设:
平行趋势假设:
关键要求: 需要多个处理单位和多个对照单位
现实挑战:只有一个处理单位
场景1: 德国统一 (1990)
- 处理单位: 西德(只有1个)
- 对照组: ???
场景2: 加州烟草税 (1988)
- 处理单位: 加州(只有1个)
- 对照组: 其他49个州(但都不完美)
场景3: Brexit (2016)
- 处理单位: 英国(只有1个)
- 对照组: 其他欧洲国家(经济结构差异大)
传统DID的问题:
- 单一对照可能不可比
- 平均多个对照忽略异质性
- 无法保证平行趋势
核心思想:合成一个反事实
Alberto Abadie的天才洞察
核心问题:
如果我们无法找到一个完美的对照单位,为什么不合成一个?
合成控制(Synthetic Control):
用多个对照单位的加权组合创造一个"人工对照":
直觉理解
类比: 调鸡尾酒
- 目标: 复制一杯特殊的鸡尾酒(处理前的加州)
- 材料: 其他州(不同的"基酒")
- 方法: 找到最佳配比(权重)
数学目标:
让"合成加州"在政策前尽可能像真实加州:
其中是政策实施时点。
为什么有效?
关键假设: 如果两个单位在政策前的轨迹相同,那么在没有政策干预的情况下,它们在政策后也会相同。
数学表达:
因果效应:
数学原理
符号定义
单位:
- : 处理单位(如加州)
- : 对照单位(其他州)
时间:
- : 政策前
- : 政策后
结果变量:
- : 单位在时间的观测结果
- : 不接受处理的潜在结果
- : 接受处理的潜在结果
特征变量:
- : 处理单位的协变量
- : 对照单位的协变量矩阵
因子模型设定
Abadie et al. (2010) 的关键假设:
解释:
- : 时间固定效应(所有单位共同的时间趋势)
- : 不随时间变化的协变量(如地理位置)
- : 协变量的时变系数
- : 不可观测的单位特征(如文化因素)
- : 不可观测因子的时变影响
- : 瞬时冲击
关键: 传统固定效应模型要求(不变),但SCM允许随时间变化!
权重向量
定义:
约束:
合成控制单位:
权重优化:核心算法
目标: 最小化政策前的预测误差
优化问题:
其中:
- : 处理单位的特征向量(包括政策前结果)
- : 对照单位的特征矩阵
- : 对角权重矩阵,决定不同特征的重要性
V-矩阵的作用:
是对角矩阵:
如何选择V?
方法1: 交叉验证
方法2: 回归权重
方法3: 等权(基准)
算法流程
输入:
- : 的结果矩阵
- : 协变量
- : 政策时点
步骤:
1. 构造特征向量
其中是单位在时间段的平均结果。
2. 优化V
嵌套优化:
def optimize_weights(X1, X0, V):
"""内层:给定V,求最优W"""
# 二次规划
# min (X1 - X0 @ W)' @ V @ (X1 - X0 @ W)
# s.t. W >= 0, sum(W) = 1
return W_star
def optimize_V(Y1, Y0, X1, X0, T0):
"""外层:优化V使政策前MSPE最小"""
def mspe(V):
W = optimize_weights(X1, X0, V)
pred = Y0 @ W # 预测
actual = Y1[:T0]
return np.mean((actual - pred[:T0])**2)
# 优化V(通常用Nelder-Mead)
result = minimize(mspe, V_init)
return result.x3. 计算处理效应
经典案例:California Proposition 99
背景
Abadie, Diamond & Hainmueller (2010)
政策: 1988年11月,加州通过Proposition 99:
- 香烟税从10美分/包 → 35美分/包(提高250%)
- 全美最高烟草税
- 目标:降低吸烟率
研究问题: Prop 99对加州香烟消费量的因果效应?
挑战:
- 只有1个处理单位(加州)
- 加州在政策前就与其他州不同(更自由、更健康意识)
- 全国趋势:吸烟率普遍下降
数据
结果变量: 人均香烟消费量(包/人/年)
时间: 1970-2000 (政策时点:1988)
单位: 39个州(排除:有大规模烟草控制政策的州)
协变量():
- 政策前香烟消费量(1975, 1980, 1988)
- 啤酒消费量(1984-1988平均)
- 人均收入(1980, 1988)
- 15-24岁人口占比(1980, 1988)
- 香烟零售价(1990)
合成加州的构建
最优权重:
| 州 | 权重 |
|---|---|
| 犹他 | 0.276 |
| 内华达 | 0.234 |
| 科罗拉多 | 0.200 |
| 蒙大拿 | 0.154 |
| 新墨西哥 | 0.136 |
| 其他34个州 | 0.000 |
解释: 只有5个州获得正权重!
为什么这5个州?
| 特征 | 真实加州 | 合成加州 | 差异 |
|---|---|---|---|
| 人均收入(1988) | $21.5k | $21.2k | 1.4% |
| 啤酒消费量 | 24.3 | 24.1 | 0.8% |
| 15-24岁占比(1988) | 17.6% | 17.3% | 1.7% |
| 香烟消费(1980) | 120.2 | 120.5 | 0.2% |
| 香烟消费(1988) | 90.1 | 91.0 | 1.0% |
惊人的拟合!
结果:因果效应
图示: 真实加州 vs 合成加州
香烟消费量(包/人/年)
140|
120| ●────●────●────●●●●●●● 真实加州
100| ◯────◯────◯────◯╲ 合成加州
80| ╲◯────◯────◯
60| ╲________↓
40| 政策效应
20|
0|_____|_____|_____|_____|_____|_____|_____
1970 1975 1980 1985 1990 1995 2000
↑
Prop 99定量结果:
1988-2000年平均效应:
相对效应:
解释: Prop 99使加州香烟消费量下降21.5%!
Python实现:完整代码
手动实现:从零开始
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.optimize import minimize, LinearConstraint
from cvxopt import matrix, solvers
# 中文设置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False
sns.set_style("whitegrid")
# =================================================================
# 第1步:权重优化(二次规划)
# =================================================================
def optimize_weights(X1, X0, V):
"""
求解最优权重W
min (X1 - X0 @ W)' @ V @ (X1 - X0 @ W)
s.t. W >= 0, sum(W) = 1
参数:
------
X1: (K,) 处理单位特征
X0: (K, J) 对照单位特征矩阵
V: (K, K) 权重矩阵
返回:
------
W: (J,) 最优权重
"""
K, J = X0.shape
# 使用cvxopt求解二次规划
# 构造QP: min 0.5 * w' @ P @ w + q' @ w
X0_weighted = V @ X0
P = 2 * (X0.T @ X0_weighted) # (J, J)
q = -2 * (X1.T @ X0_weighted) # (J,)
# 约束: sum(W) = 1, W >= 0
A = np.ones((1, J))
b = np.ones(1)
G = -np.eye(J)
h = np.zeros(J)
# 转换为cvxopt格式
P = matrix(P)
q = matrix(q)
G = matrix(G)
h = matrix(h)
A = matrix(A)
b = matrix(b)
# 求解
solvers.options['show_progress'] = False
sol = solvers.qp(P, q, G, h, A, b)
W = np.array(sol['x']).flatten()
return W
# =================================================================
# 第2步:优化V矩阵
# =================================================================
def optimize_V(Y1, Y0, X1, X0, T0):
"""
优化V使政策前MSPE最小
参数:
------
Y1: (T,) 处理单位结果
Y0: (T, J) 对照单位结果矩阵
X1, X0: 特征矩阵
T0: 政策时点
返回:
------
V: (K, K) 最优V矩阵
"""
K = X1.shape[0]
def loss(v):
"""MSPE损失函数"""
# 确保V是对角矩阵且非负
v = np.abs(v)
V = np.diag(v / v.sum()) # 归一化
# 求最优W
W = optimize_weights(X1, X0, V)
# 计算政策前预测误差
Y_synthetic = Y0[:T0] @ W
mspe = np.mean((Y1[:T0] - Y_synthetic)**2)
return mspe
# 初始V
v_init = np.ones(K)
# 优化(使用Nelder-Mead)
result = minimize(loss, v_init, method='Nelder-Mead')
v_opt = np.abs(result.x)
V_opt = np.diag(v_opt / v_opt.sum())
return V_opt
# =================================================================
# 第3步:合成控制主函数
# =================================================================
def synthetic_control(Y1, Y0, X1, X0, T0, optimize_v=True):
"""
合成控制法主函数
参数:
------
Y1: (T,) 处理单位结果时间序列
Y0: (T, J) 对照单位结果矩阵
X1: (K,) 处理单位特征
X0: (K, J) 对照单位特征矩阵
T0: 政策时点索引
optimize_v: 是否优化V(否则用单位矩阵)
返回:
------
results: dict, 包含权重、合成结果、效应等
"""
T = len(Y1)
K, J = X0.shape
# 优化V
if optimize_v:
print("优化V矩阵...")
V = optimize_V(Y1, Y0, X1, X0, T0)
else:
V = np.eye(K)
# 计算最优W
print("计算最优权重...")
W = optimize_weights(X1, X0, V)
# 构造合成控制
Y_synthetic = Y0 @ W
# 计算效应
effects = Y1 - Y_synthetic
# MSPE
mspe_pre = np.mean((Y1[:T0] - Y_synthetic[:T0])**2)
mspe_post = np.mean((Y1[T0:] - Y_synthetic[T0:])**2)
# 平均效应
att = np.mean(effects[T0:])
results = {
'weights': W,
'V': V,
'Y_synthetic': Y_synthetic,
'effects': effects,
'mspe_pre': mspe_pre,
'mspe_post': mspe_post,
'ATT': att
}
return results
# =================================================================
# 第4步:安慰剂检验(Placebo Test)
# =================================================================
def placebo_test(Y, X, T0, treated_idx=0):
"""
安慰剂检验:对每个对照单位做"假处理"
参数:
------
Y: (T, J+1) 所有单位结果矩阵(第0列是真实处理单位)
X: (K, J+1) 所有单位特征矩阵
T0: 政策时点
treated_idx: 真实处理单位索引
返回:
------
placebo_effects: (T, J+1) 所有单位的"效应"
"""
T, J_plus_1 = Y.shape
placebo_effects = np.zeros((T, J_plus_1))
mspe_ratios = []
for j in range(J_plus_1):
# 将单位j作为"处理单位"
Y1_fake = Y[:, j]
Y0_fake = np.delete(Y, j, axis=1)
X1_fake = X[:, j]
X0_fake = np.delete(X, j, axis=1)
# 运行SCM
try:
results = synthetic_control(Y1_fake, Y0_fake, X1_fake, X0_fake, T0, optimize_v=False)
placebo_effects[:, j] = results['effects']
# MSPE比率
ratio = results['mspe_post'] / results['mspe_pre']
mspe_ratios.append(ratio)
except:
placebo_effects[:, j] = np.nan
mspe_ratios.append(np.nan)
return placebo_effects, np.array(mspe_ratios)
# =================================================================
# 第5步:可视化
# =================================================================
def plot_synthetic_control(years, Y1, Y_synthetic, T0, title="合成控制法结果"):
"""绘制合成控制结果"""
fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True)
# 子图1:趋势对比
ax1 = axes[0]
ax1.plot(years, Y1, 'o-', color='black', linewidth=2,
label='真实处理单位', markersize=6)
ax1.plot(years, Y_synthetic, 's--', color='blue', linewidth=2,
label='合成对照', markersize=5, alpha=0.7)
ax1.axvline(x=years[T0], color='red', linestyle='--', linewidth=2, alpha=0.7)
ax1.text(years[T0]+0.5, ax1.get_ylim()[1]*0.9, '政策实施',
fontsize=12, color='red', fontweight='bold')
ax1.set_ylabel('结果变量', fontsize=12, fontweight='bold')
ax1.set_title(title, fontsize=14, fontweight='bold')
ax1.legend(loc='best', fontsize=11)
ax1.grid(True, alpha=0.3)
# 子图2:处理效应
ax2 = axes[1]
effects = Y1 - Y_synthetic
ax2.plot(years, effects, 'o-', color='darkgreen', linewidth=2, markersize=6)
ax2.axhline(y=0, color='black', linestyle='-', linewidth=1, alpha=0.5)
ax2.axvline(x=years[T0], color='red', linestyle='--', linewidth=2, alpha=0.7)
ax2.fill_between(years[T0:], 0, effects[T0:], alpha=0.3, color='green')
ax2.set_xlabel('年份', fontsize=12, fontweight='bold')
ax2.set_ylabel('处理效应', fontsize=12, fontweight='bold')
ax2.set_title('估计的因果效应', fontsize=13, fontweight='bold')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
return fig
def plot_placebo_test(years, placebo_effects, T0, treated_idx=0):
"""绘制安慰剂检验"""
fig, ax = plt.subplots(figsize=(14, 8))
T, J = placebo_effects.shape
# 绘制所有安慰剂单位(灰色)
for j in range(J):
if j != treated_idx:
ax.plot(years, placebo_effects[:, j],
color='gray', alpha=0.3, linewidth=1)
# 绘制真实处理单位(粗黑线)
ax.plot(years, placebo_effects[:, treated_idx],
color='black', linewidth=3, label='真实处理单位')
ax.axhline(y=0, color='black', linestyle='-', linewidth=1)
ax.axvline(x=years[T0], color='red', linestyle='--', linewidth=2, alpha=0.7)
ax.text(years[T0]+0.5, ax.get_ylim()[1]*0.9, '政策实施',
fontsize=12, color='red', fontweight='bold')
ax.set_xlabel('年份', fontsize=12, fontweight='bold')
ax.set_ylabel('处理效应', fontsize=12, fontweight='bold')
ax.set_title('安慰剂检验:真实处理单位 vs 假处理单位', fontsize=14, fontweight='bold')
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
plt.tight_layout()
return fig
# =================================================================
# 应用示例:模拟Prop 99
# =================================================================
# 设置随机种子
np.random.seed(42)
# 模拟数据
T = 31 # 1970-2000
T0 = 19 # 1988
J = 38 # 38个对照州
years = np.arange(1970, 2001)
# 生成协变量(K=5个特征)
X1 = np.array([120, 90, 24, 21.5, 0.176]) # 加州特征
X0 = np.random.randn(5, J) * 10 + X1.reshape(-1, 1) + np.random.randn(5, J) * 5
# 生成结果变量
# 基础趋势:线性下降
baseline = 130 - 2 * np.arange(T)
# 加州:政策后额外下降
Y1 = baseline + np.random.randn(T) * 3
Y1[T0:] -= 20 # 政策效应
# 对照州:只有基础趋势
Y0 = np.zeros((T, J))
for j in range(J):
state_effect = np.random.randn() * 5
Y0[:, j] = baseline + state_effect + np.random.randn(T) * 3
# 运行SCM
print("="*60)
print("合成控制法分析")
print("="*60)
results = synthetic_control(Y1, Y0, X1, X0, T0, optimize_v=True)
# 输出结果
print(f"\n政策前MSPE: {results['mspe_pre']:.3f}")
print(f"政策后MSPE: {results['mspe_post']:.3f}")
print(f"平均处理效应(ATT): {results['ATT']:.3f}")
# 显示权重
weights_df = pd.DataFrame({
'州': [f'州{j+1}' for j in range(J)],
'权重': results['weights']
})
weights_df = weights_df[weights_df['权重'] > 0.01].sort_values('权重', ascending=False)
print(f"\n主要权重(>1%):")
print(weights_df.to_string(index=False))
# 可视化
fig1 = plot_synthetic_control(years, Y1, results['Y_synthetic'], T0,
"加州Proposition 99的效应:合成控制法")
plt.show()
# 安慰剂检验
print("\n执行安慰剂检验...")
Y_all = np.column_stack([Y1, Y0])
X_all = np.column_stack([X1.reshape(-1, 1), X0])
placebo_effects, mspe_ratios = placebo_test(Y_all, X_all, T0, treated_idx=0)
fig2 = plot_placebo_test(years, placebo_effects, T0, treated_idx=0)
plt.show()
# 计算p值
true_mspe_ratio = mspe_ratios[0]
p_value = np.mean(mspe_ratios >= true_mspe_ratio)
print(f"\nMSPE比率 p值: {p_value:.3f}")
print(f"结论: {'拒绝原假设(有显著效应)' if p_value < 0.05 else '不能拒绝原假设'}")使用SparseSC库
# 安装: pip install SparseSC
from SparseSC import fit
# 准备数据
outcomes = Y_all # (T, J+1)
treated_units = [0] # 加州是第0列
T0_sc = T0
# 拟合
sc_result = fit(
outcomes=outcomes,
treated_units=treated_units,
T0=T0_sc,
verbose=1
)
# 提取结果
Y_synthetic_sparse = sc_result.synthetic_outcomes[:, 0]
effects_sparse = sc_result.effects[:, 0]
print(f"\n[SparseSC] 平均处理效应: {np.mean(effects_sparse[T0:]):.3f}")SCM vs DID:全面对比
相似之处
1. 都基于平行趋势假设
- DID: 处理组和对照组平行
- SCM: 处理单位和合成对照平行
2. 都利用政策前信息
- DID: 依赖时间趋势
- SCM: 拟合政策前轨迹
3. 都可以做安慰剂检验
核心差异
| 维度 | DID | SCM |
|---|---|---|
| 处理单位数 | 多个 | 1个 |
| 对照组 | 平均对照 | 加权合成 |
| 权重 | 等权 | 数据驱动优化 |
| 时间维度 | 2期即可 | 需要较长政策前期 |
| 协变量 | 回归控制 | 匹配协变量 |
| 平行趋势 | 全局假设 | 局部匹配 |
| 外推 | 更强 | 更保守 |
何时用SCM?
优先SCM:
- 只有1个处理单位(如国家、大城市)
- 长时间序列可用(至少10期政策前)
- 多个候选对照单位
- 协变量差异大
优先DID:
- 多个处理单位
- 时间序列短
- 对照组自然可比
数学联系
DID是SCM的特例!
2×2 DID:
等价于SCM的等权重:
SCM的优势: 数据驱动选择最优权重,而非武断等权。
统计推断与稳健性
挑战:没有大样本理论
问题: 只有个处理单位!
传统渐近理论()不适用。
解决方案:排列推断
核心思想: "如果政策无效,那么处理单位应该和对照单位没有区别"
Fisher精确检验 (Fisher 1935):
原假设:
检验统计量: MSPE比率
步骤:
计算真实处理单位的
对每个对照单位,假装它是"处理单位":
计算p值:
解释: 如果排名非常靠前,说明真实处理单位的"异常"不太可能是随机的。
敏感性分析
1. 排除特定对照单位
"如果去掉加权最大的州,结论改变吗?"
def sensitivity_leave_one_out(Y1, Y0, X1, X0, T0):
"""逐一排除对照单位"""
J = Y0.shape[1]
effects_loo = []
for j in range(J):
Y0_j = np.delete(Y0, j, axis=1)
X0_j = np.delete(X0, j, axis=1)
results = synthetic_control(Y1, Y0_j, X1, X0_j, T0, optimize_v=False)
effects_loo.append(results['ATT'])
return effects_loo2. 改变政策前窗口
def sensitivity_pre_periods(Y1, Y0, X1, X0, T0_range):
"""改变政策前期长度"""
atts = []
for T0 in T0_range:
results = synthetic_control(Y1, Y0, X1, X0, T0, optimize_v=True)
atts.append(results['ATT'])
return atts3. 改变协变量
"结果对特征选择敏感吗?"
️ 常见陷阱
陷阱1:政策前拟合差
问题: 如果政策前合成对照和处理单位差异大,政策后的差异可能不是因果效应。
诊断: 检查政策前MSPE
阈值: Abadie et al.建议
解决:
- 增加政策前特征
- 排除异质性过大的对照单位
- 承认SCM不适用
陷阱2:插值vs外推
SCM的假设: 处理单位在对照单位的凸包(Convex Hull)内
检查:
如果违反: 需要外推,结论不可靠
陷阱3:时变混淆
假设: 没有政策后的时变混淆影响处理单位
威胁: 如果政策后处理单位有其他冲击(如经济危机),SCM会高估/低估效应
缓解: 控制政策后协变量(但破坏识别)
陷阱4:溢出效应
SUTVA违反: 如果处理影响对照单位,合成对照本身被"污染"
例子: 加州烟草税可能导致邻州走私
扩展与前沿
1. Augmented Synthetic Control (Ben-Michael et al. 2021)
核心思想: 结合SCM和回归调整
估计量:
优势: 即使权重不完美,回归调整能提高精度
Python: augsynth (R有包,Python在开发)
2. Matrix Completion Methods (Athey et al. 2021)
设定: 将结果矩阵视为低秩矩阵+噪声
其中
估计: 用已观测元素恢复
优势: 更灵活,适用于多个处理单位
3. Synthetic DID (Arkhangelsky et al. 2021)
结合: SCM的加权 + DID的双重差分
估计量:
其中同时优化单位权重和时间权重
Python: synthdid
实战建议
数据准备检查清单
- [ ] 至少10期政策前观测
- [ ] 至少5个候选对照单位
- [ ] 关键协变量无缺失
- [ ] 结果变量连续(非二值)
- [ ] 无严重异常值
分析流程
步骤1: 探索性分析
- 绘制所有单位趋势图
- 识别明显不可比的对照
步骤2: 运行基准SCM
- 优化V和W
- 检查政策前拟合
步骤3: 可视化
- 趋势对比图
- 效应图
步骤4: 统计推断
- 安慰剂检验
- 计算p值
步骤5: 稳健性检验
- Leave-one-out
- 改变政策前窗口
- 改变协变量
步骤6: 报告
- 权重表
- 协变量平衡表
- 主图+安慰剂图
- 敏感性分析结果
本节小结
核心要点
适用场景: 只有1个处理单位时的因果推断利器
核心思想: 用对照单位的加权组合构造反事实
关键优化:
- 外层:优化V矩阵(特征权重)
- 内层:优化W向量(单位权重)
识别假设: 政策前拟合好 → 政策后平行趋势成立
统计推断: 排列推断(安慰剂检验)
vs DID: SCM是数据驱动的加权DID
关键公式
权重优化:
因果效应:
p值(Fisher检验):
Python工具
- 手动实现:
cvxopt,scipy.optimize - 现成库:
SparseSC,augsynth - 新方法:
synthdid
下一节: 13.3 倾向得分匹配 - Imbens & Rubin的匹配艺术
合成控制:当DID遇到优化理论!