合成控制法 Synthetic Control Method:为单个处理对象构造"合成对照组"
SCM 适用于"政策只在一个(或少数几个)地区/单位实施"的场景:找不到足够多的处理组时,用未处理单位的凸组合(convex combination)构造一个与处理对象政策前几乎完全一致的"合成对照组"。本页讲 Abadie-Diamond-Hainmueller (2010) 的权重求解、Donor pool 选择、预处理拟合、安慰剂检验,以及 Stata synth / synth_runner 与 Python pysyncon 的完整代码。
synth、synth_runner、basque 自带示例数据);Python 3.10+(pandas、numpy、pysyncon 或 synthex)。01 SCM 是什么:从 DID 到合成对照
DID 要求"处理组 + 对照组都有一定数量"。但现实中很多政策只在一个地区实施——比如加州 1988 年推出烟草控制计划、巴斯克地区的恐怖冲突、一个城市的自贸区——这时你找不到多个处理组,传统 DID 无从下手。合成控制法(Synthetic Control Method, SCM)的思路是:不去找一个"真实"的对照组,而是用所有未处理单位的加权平均,"拼"出一个与处理单位在政策前几乎完全一致的"合成处理单位"。政策后,真实处理单位与合成对照单位之间的差距,就是政策效应。
形式化地,假设面板有 $J+1$ 个单位,单位 1 是唯一处理对象,单位 $2,\dots,J+1$ 是潜在的 donor pool(未处理)。SCM 寻找一组权重 $w=(w_2,\dots,w_{J+1})$,满足 $w_j \ge 0$ 且 $\sum_{j=2}^{J+1} w_j=1$,使得政策前处理单位的预测变量与合成单位尽可能接近:
其中 $\mathbf{X}_1$ 是处理单位的 $K \times 1$ 维预测变量向量,$\mathbf{X}_0$ 是 donor pool 的 $K \times J$ 矩阵,$V$ 是预测变量的权重矩阵(让最重要的变量在拟合中占更大权重)。一旦找到 $W^*$,政策后的反事实结果为:
其中 $T_0$ 是政策实施时点。$\hat{\alpha}_{1t}$ 就是"真实加州 − 合成加州"在政策后第 $t$ 年的差距。
DID 假设"处理组与对照组政策前趋势平行",SCM 假设"处理单位可以被 donor pool 的凸组合在政策前完美拟合"。后者更弱:只要 donor pool 里存在一组单位的加权平均能在政策前 mimic 处理单位的走势即可,不要求每个 donor 单独都与处理单位平行。
02 核心假设与权重求解
2.1 识别假设
SCM 的识别依赖三条核心假设:
- 假设 1(无溢出 / SUTVA):处理只影响单位 1,donor pool 中任何单位都没有受到政策间接影响(no interference)。自贸区 A 省的政策不会通过贸易/人员流动影响到 B 省——这在相邻地区常常不成立。
- 假设 2(预处理拟合良好):存在一组权重 $W^*$,使得政策前处理单位的结果变量轨迹与预测变量都被合成单位很好地拟合。拟合不好(RMSPE 很大)时,SCM 结论不可信。
- 假设 3(外生干预):政策实施时点 $T_0$ 是外生的,且 donor pool 在 $T_0$ 前后没有遭遇其他重大冲击。
2.2 权重求解:两层优化
SCM 的权重求解是一个两层优化:
synth 中由 optmethod() 自动完成。synth 用二次规划求解器自动完成。SCM 解出的权重通常是稀疏的——donor pool 里几十个单位中,往往只有 3–5 个权重显著大于 0。这是 feature 不是 bug:它强迫你解释"合成加州 = 0.4×内华达 + 0.3×犹他 + 0.2×新墨西哥 + 0.1×阿拉斯加"这种经济学含义。审稿人一定会问"为什么是这几个州?"
03 Donor pool 选择与预处理拟合
3.1 Donor pool 怎么选
Donor pool 是 SCM 最容易出错的环节。三条经验法则:
- 排除受政策影响的单位:donor 里不能有任何单位在 $T_0$ 后也被处理(否则反事实被污染)。如果全国所有省份最终都推行了同一政策,你只能把"政策前已被处理"的单位剔除,或改用合成 DID(合成控制 + DID 的组合)。
- 排除遭遇特殊冲击的单位:donor 里如果某州在政策同期经历了大地震、金融危机、政权更迭,它会把合成对照的轨迹带偏。
- 地域 / 经济结构相似:donor pool 应限定在与处理单位在经济结构、发展阶段上相似的单位。研究加州烟草控制时,donor 应是其他州而非其他国家。
3.2 预处理拟合诊断
跑完 SCM 后,第一件事是看政策前拟合图(pre-treatment fit plot):横轴是时间,纵轴是结果变量,画两条线——真实处理单位、合成对照单位。理想情况下,政策前两条线应该几乎重合(RMSPE 很小),政策后才开始分叉。
| 诊断指标 | 含义 | 经验阈值 |
|---|---|---|
| Pre-treatment RMSPE | 政策前真实 vs 合成结果变量的均方根预测误差 | 应小于政策后 RMSPE 的 1/5 ~ 1/10 |
| 预测变量均值差距 | 处理单位 vs 合成单位在每个预测变量上的均值差 | 每个变量差距 < 0.1 个标准差 |
| 权重稀疏度 | 权重 > 0.01 的 donor 个数 | 通常 3–8 个;超过 15 个说明 donor pool 太杂 |
| 负权重 | 是否违反 $w_j \ge 0$ | SCM 不允许;若出现说明约束设置错了 |
04 安慰剂检验与统计推断
SCM 没有传统意义上的标准误——因为处理对象只有 1 个,渐近理论无从谈起。Abadie-Diamond-Hainmueller 的解决方案是置换检验(permutation test):把"处理"假装分配给 donor pool 里的每个单位,对每个"伪处理单位"都跑一遍 SCM,得到一组"伪效应"。然后看真实处理单位的政策后效应,在这组伪效应分布里有多极端。
实务上画一张 "gap plot"(差距图):对每个单位(真实处理 + 所有伪处理),画出"真实 − 合成"随时间的轨迹。真实处理单位的轨迹应该在政策后明显跳出所有伪轨迹的信封(envelope)之外。
如果某个 donor 单位政策前根本拟合不上(pre-treatment RMSPE 巨大),它的"伪效应"也会天然很大——把它留在图里会让你的真实效应看起来不极端。规范做法是:剔除所有 pre-treatment RMSPE 大于真实处理单位 pre-treatment RMSPE 2 倍的伪处理单位,再画 gap plot。这一步在 synth_runner 中由 rmsexclude() 选项自动完成。
05 完整 Stata 代码(synth / synth_runner)
下面这段代码完整演示:(1) 用模拟数据构造 1 个处理地区 + 20 个 donor;(2) 跑 synth;(3) 画出处理效应图;(4) 跑 synth_runner 做安慰剂检验。所有命令在 Stata 17+ 上可运行。
* ============================================================
* 合成控制法 SCM 完整演示
* 场景:某省(id=1)在 2010 年实施自贸区政策,其余 20 省未实施
* 目标:估计自贸区对 lnGDP 的因果效应
* ============================================================
clear all
set seed 20240101
set obs 2100 // 21 地区 × 100 年
gen id = ceil(_n/100) // 地区编号 1..21
bysort id: gen year = 1950 + _n // 年份 1950..2049
* --- 1. 模拟结果变量:地区固定效应 + 共同趋势 + 政策效应 ---
gen u_i = rnormal(0, 0.5) in 1/21 // 地区固定效应(每地区抽一次)
bysort id: replace u_i = u_i[1]
gen t = year - 1950
gen y = 2 + 0.05*t + u_i + rnormal(0, 0.2) // 无政策时的 lnGDP
* 处理单位 id=1 在 2010 年起获得 +0.3 的政策效应
replace y = y + 0.3 if id==1 & year>=2010
* --- 2. 预测变量(政策前 2000-2009 年的均值)---
* 为简化,这里用几个常数型变量 + 政策前结果变量均值
gen x1 = rnormal(0, 1) in 1/21
bysort id: replace x1 = x1[1]
gen x2 = rnormal(0, 1) in 1/21
bysort id: replace x2 = x2[1]
* --- 3. 安装外部命令 ---
* ssc install synth, replace
* ssc install synth_runner, replace
* --- 4. 跑 synth(经典版)---
* synth 语法:synth 结果变量 预测变量(英文变量列表), trunit(处理单位) trperiod(政策期)
* 这里我们用 x1 x2 + 政策前若干年的结果变量作为预测变量
preserve
keep if year <= 2009 // 只用政策前数据估计权重
* 构造政策前结果变量的若干年份均值作为预测变量
* synth 要求数据是 wide 形式:每地区一行,列是各年 y
collapse (mean) y, by(id year)
reshape wide y, i(id) j(year)
* 用 2000-2009 年的 y 作为预测变量(典型 SCM 做法)
synth y2009 y2008 y2007 y2006 y2005 x1 x2, ///
trunit(1) trperiod(2010) keep(scm_result)
restore
* --- 5. 用 synth_runner 跑完整流程 + 安慰剂检验 ---
* synth_runner 是更现代的封装,自动画 gap plot、算 RMSPE ratio
preserve
keep if year <= 2019 // 政策后 10 年窗口
reshape wide y x1 x2, i(id) j(year)
* synth_runner 语法(需先 install)
* synth_runner y, trunit(1) trperiod(2010) ///
* pre(1980/2009) post(2010/2019) ///
* covariates(x1 x2) ///
* mspeperiod(1980/2009) ///
* figure_name(gap_plot)
* 输出:
* - _data.dta : 处理 vs 合成的逐年差距
* - placebo_results: 所有单位的伪效应
* - gap plot : 真实效应是否跳出信封
restore
* --- 6. 手动解读关键输出 ---
* synth 输出表 1:权重 W*
* 关注:哪几个 donor 权重 > 0.01?加起来是否 = 1?
* synth 输出表 2:预测变量均值对比
* 关注:处理单位 vs 合成单位在每个预测变量上的差距是否 < 0.1 SD
* synth 输出表 3:RMSPE
* pre-RMSPE 应 < post-RMSPE / 5
basque 示例数据Abadie-Diamond-Hainmueller (2010) 的 Stata 包自带 basque 数据集(巴斯克地区 GDP),输入 use basque 后直接 synth 即可复现原文图 1–图 2。初学者强烈建议先跑一遍这个示例再换自己的数据。
06 Python 实现(pysyncon)
Python 生态中 pysyncon(pip install pysyncon)是 Abadie 等原文方法的最忠实复现。下面代码与上一节 Stata 等价:
# ============================================================
# 合成控制法 Python 实现(pysyncon)
# ============================================================
import numpy as np
import pandas as pd
from pysyncon import Dataprep, Synth
rng = np.random.default_rng(20240101)
# --- 1. 构造面板:21 地区 × 70 年 ---
n_units, n_years = 21, 70
years = np.arange(1980, 2050)
ids = np.arange(1, n_units + 1)
data = []
for i in ids:
u_i = rng.normal(0, 0.5)
x1 = rng.normal(0, 1)
x2 = rng.normal(0, 1)
for t, yr in enumerate(years):
y = 2 + 0.05 * t + u_i + rng.normal(0, 0.2)
if i == 1 and yr >= 2010:
y += 0.3 # 政策效应
data.append({"id": i, "year": yr, "y": y, "x1": x1, "x2": x2})
df = pd.DataFrame(data)
# --- 2. Dataprep:指定处理单位、政策期、预测变量 ---
dataprep = Dataprep(
foo=df,
predictors=["x1", "x2"],
predictors_op="mean", # 预测变量在 pre 期取均值
time_predictors_prior=np.arange(2000, 2010),
dependent="y",
unit_variable="id",
time_variable="year",
treatment_identifier=1, # 处理单位 id=1
controls_identifier=list(range(2, 22)), # donor pool
time_optimize_ssr=np.arange(1990, 2010), # 用 1990-2009 拟合
)
# --- 3. 跑 SCM ---
synth = Synth()
synth.fit(dataprep, optim_method="Nelder-Mead")
print(synth.weights()) # 各 donor 的权重 W*
print(synth.summary()) # 预测变量均值对比 + RMSPE
# --- 4. 画处理 vs 合成轨迹图 ---
synth.plot_treatment_outcome()
# --- 5. 安慰剂检验(placebo in-space)---
from pysyncon import Placebo
placebo = Placebo(dataprep, synth)
placebo.plot_placebo(figsize=(10, 6)) # gap plot
print(placebo.p_value(period_post=np.arange(2010, 2020)))
07 论文案例与常见错误
论文案例
synth 命令、basque / smoking 示例数据均来自此文。常见错误
研究上海自贸区效应时,把 2015 年也设了自贸区的天津/广东/福建留在 donor pool 里——这三个 donor 本身被政策污染,合成上海会被"拉向"政策后轨迹,效应被低估。规范做法:每评估一个自贸区,把其他已设自贸区的省份全部从 donor 剔除。
政策前真实处理单位和合成单位的轨迹根本不重合(pre-RMSPE 巨大),但政策后出现差距就宣称"政策有效"。如果政策前都拟合不上,政策后的差距完全可能是 donor 组合本身的偏差,不是政策效应。必须报告 pre-RMSPE,并在拟合差时改用合成 DID 或其他方法。
把 donor pool 里 pre-RMSPE 巨大的单位也画进 gap plot,会让你的真实效应看起来不极端(因为这些单位的伪效应天然很大)。必须按 pre-RMSPE 排序,剔除超过真实处理单位 pre-RMSPE 2 倍的伪处理单位,再报告 placebo p 值。
把所有可观测变量都扔进 predictors(),包括与结果变量无关的噪声变量。SCM 的预测变量应该是"经济学理论上预测结果变量的重要变量"(如经济结构、人口结构、政策前结果变量的若干年均值),而不是数据里有什么就放什么。Abadie (2021) 建议预测变量控制在 5–10 个。
SCM 在单处理对象场景下很优雅,但它本质上是 case study,外部效度有限。顶刊做法是 SCM + DID + 事件研究三角验证,而不是只跑 SCM 就下结论。中文论文常见"跑了 SCM 就万事大吉"的倾向,审稿人现在会追问"为什么不用 csdid?为什么不 stacked DID?"
进阶资料
- Ben-Michael, Feller & Rothstein (2021) —— Synthetic DID:SCM 与 DID 的结合,用 DID 残差调整 SCM 拟合误差。Stata 命令
sdid。 - Carcio, Kosec & Li (2019) —— Generalized Synthetic Control(gsc),允许多个处理单位 + 交互式固定效应。Stata 命令
gsynth。 - Abadie (2021 JEL) —— 入门 SCM 的最佳路线图,免费下载。