Skip to content

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的问题:

  1. 单一对照可能不可比
  2. 平均多个对照忽略异质性
  3. 无法保证平行趋势

核心思想:合成一个反事实

Alberto Abadie的天才洞察

核心问题:

如果我们无法找到一个完美的对照单位,为什么不合成一个?

合成控制(Synthetic Control):

多个对照单位的加权组合创造一个"人工对照":

直觉理解

类比: 调鸡尾酒

  • 目标: 复制一杯特殊的鸡尾酒(处理前的加州)
  • 材料: 其他州(不同的"基酒")
  • 方法: 找到最佳配比(权重)

数学目标:

让"合成加州"在政策前尽可能像真实加州:

其中是政策实施时点。

为什么有效?

关键假设: 如果两个单位在政策前的轨迹相同,那么在没有政策干预的情况下,它们在政策后也会相同。

数学表达:

因果效应:


数学原理

符号定义

单位:

  • : 处理单位(如加州)
  • : 对照单位(其他州)

时间:

  • : 政策前
  • : 政策后

结果变量:

  • : 单位在时间的观测结果
  • : 不接受处理的潜在结果
  • : 接受处理的潜在结果

特征变量:

  • : 处理单位的协变量
  • : 对照单位的协变量矩阵

因子模型设定

Abadie et al. (2010) 的关键假设:

解释:

  • : 时间固定效应(所有单位共同的时间趋势)
  • : 不随时间变化的协变量(如地理位置)
  • : 协变量的时变系数
  • : 不可观测的单位特征(如文化因素)
  • : 不可观测因子的时变影响
  • : 瞬时冲击

关键: 传统固定效应模型要求(不变),但SCM允许随时间变化!

权重向量

定义:

约束:

合成控制单位:

权重优化:核心算法

目标: 最小化政策前的预测误差

优化问题:

其中:

  • : 处理单位的特征向量(包括政策前结果)
  • : 对照单位的特征矩阵
  • : 对角权重矩阵,决定不同特征的重要性

V-矩阵的作用:

对角矩阵:

如何选择V?

方法1: 交叉验证

方法2: 回归权重

方法3: 等权(基准)

算法流程

输入:

  • : 的结果矩阵
  • : 协变量
  • : 政策时点

步骤:

1. 构造特征向量

其中是单位在时间段的平均结果。

2. 优化V

嵌套优化:

python
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.x

3. 计算处理效应


经典案例:California Proposition 99

背景

Abadie, Diamond & Hainmueller (2010)

政策: 1988年11月,加州通过Proposition 99:

  • 香烟税从10美分/包 → 35美分/包(提高250%)
  • 全美最高烟草税
  • 目标:降低吸烟率

研究问题: Prop 99对加州香烟消费量的因果效应?

挑战:

  • 只有1个处理单位(加州)
  • 加州在政策前就与其他州不同(更自由、更健康意识)
  • 全国趋势:吸烟率普遍下降

数据

结果变量: 人均香烟消费量(包/人/年)

时间: 1970-2000 (政策时点:1988)

单位: 39个州(排除:有大规模烟草控制政策的州)

协变量():

  1. 政策前香烟消费量(1975, 1980, 1988)
  2. 啤酒消费量(1984-1988平均)
  3. 人均收入(1980, 1988)
  4. 15-24岁人口占比(1980, 1988)
  5. 香烟零售价(1990)

合成加州的构建

最优权重:

权重
犹他0.276
内华达0.234
科罗拉多0.200
蒙大拿0.154
新墨西哥0.136
其他34个州0.000

解释: 只有5个州获得正权重!

为什么这5个州?

特征真实加州合成加州差异
人均收入(1988)$21.5k$21.2k1.4%
啤酒消费量24.324.10.8%
15-24岁占比(1988)17.6%17.3%1.7%
香烟消费(1980)120.2120.50.2%
香烟消费(1988)90.191.01.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实现:完整代码

手动实现:从零开始

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库

python
# 安装: 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. 都可以做安慰剂检验

核心差异

维度DIDSCM
处理单位数多个1个
对照组平均对照加权合成
权重等权数据驱动优化
时间维度2期即可需要较长政策前期
协变量回归控制匹配协变量
平行趋势全局假设局部匹配
外推更强更保守

何时用SCM?

优先SCM:

  1. 只有1个处理单位(如国家、大城市)
  2. 长时间序列可用(至少10期政策前)
  3. 多个候选对照单位
  4. 协变量差异大

优先DID:

  1. 多个处理单位
  2. 时间序列短
  3. 对照组自然可比

数学联系

DID是SCM的特例!

2×2 DID:

等价于SCM的等权重:

SCM的优势: 数据驱动选择最优权重,而非武断等权。


统计推断与稳健性

挑战:没有大样本理论

问题: 只有个处理单位!

传统渐近理论()不适用。

解决方案:排列推断

核心思想: "如果政策无效,那么处理单位应该和对照单位没有区别"

Fisher精确检验 (Fisher 1935):

原假设:

检验统计量: MSPE比率

步骤:

  1. 计算真实处理单位的

  2. 对每个对照单位,假装它是"处理单位":

  3. 计算p值:

解释: 如果排名非常靠前,说明真实处理单位的"异常"不太可能是随机的。

敏感性分析

1. 排除特定对照单位

"如果去掉加权最大的州,结论改变吗?"

python
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_loo

2. 改变政策前窗口

python
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 atts

3. 改变协变量

"结果对特征选择敏感吗?"


️ 常见陷阱

陷阱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. 适用场景: 只有1个处理单位时的因果推断利器

  2. 核心思想: 用对照单位的加权组合构造反事实

  3. 关键优化:

    • 外层:优化V矩阵(特征权重)
    • 内层:优化W向量(单位权重)
  4. 识别假设: 政策前拟合好 → 政策后平行趋势成立

  5. 统计推断: 排列推断(安慰剂检验)

  6. vs DID: SCM是数据驱动的加权DID

关键公式

权重优化:

因果效应:

p值(Fisher检验):

Python工具

  • 手动实现: cvxopt, scipy.optimize
  • 现成库: SparseSC, augsynth
  • 新方法: synthdid

下一节: 13.3 倾向得分匹配 - Imbens & Rubin的匹配艺术


合成控制:当DID遇到优化理论!

基于 MIT 许可证发布。内容版权归作者所有。