合成控制法评估政策冲击:当找不到对照组,就「造」一个出来
DID 的灵魂是「控制组」,但现实里常有「只有一个受冲击单元、又找不到可比对照组」的尴尬——比如某国突然资本管制、某只指数被纳入 MSCI、某城试点房产税。合成控制法 SCM 给出一条出路:用一大篮未受冲击单元的「加权组合」为你合成一个专属对照组,权重由事件前的拟合优度决定。本文用纯 numpy + scipy 从零实现凸权重 SCM(非负、和为 1),在自洽合成面板(1 个受冲击单元 + 39 个 donor,事件后真实水平偏移=1.08%/期)上把事件前贴合 RMSE 压到 0.040、事件后 gap 估到 1.08,且权重极度稀疏(仅 6/39 个 donor 非零);留一安慰剂检验中受冲击单元的 gap 是 40 个单元里的极端离群点(p≈0.025),并诚实拆穿外推/负权重、donor 池太窄、前拟合好后崩、唯一解不唯一、小样本过拟合五类真实陷阱(中阶)。
上篇我们聊了 DID:它靠一个「现成的控制组」把时间趋势剥掉。但你会很快撞上 DID 的死穴——找不到控制组。
比如你想评估「某国突然宣布资本管制」对汇率/股市的冲击:受冲击的只有一个国家,全市场里没有第二个「各方面都像它、只是没管制」的平行宇宙对照组。又比如「某只股票被纳入 MSCI 指数」的事件研究——被纳入的只有它一只,同类里有谁「本该被纳入却没被纳入」?
结论先放这:合成控制法 SCM(Synthetic Control Method, Abadie et al. 2010)的聪明之处在于——它不找一个现成对照,而是用一篮子「未受冲击单元」(donor pool)的加权组合,为你「合成」出一个专属对照。 权重怎么定?让合成单元在事件前尽可能贴合受冲击单元——拟合越好,这个合成对照越可信;事件后受冲击单元相对合成对照的 gap,就是处理效应。
在我们的自洽合成面板(1 个受冲击单元 + 39 个未受冲击 donor,事件前 24 期、后 24 期,真实事件后水平偏移=1.08/期)上:事件前贴合 RMSE 仅 0.040,事件后平均 gap 估到 1.08(几乎无偏);权重极度稀疏——39 个 donor 里只有 6 个拿了非零权重,前两大占了 84.6%。留一安慰剂检验中,受冲击单元的事件后 gap 是 40 个单元里的极端离群点(p≈0.025)。 诚实地讲:SCM 对「前拟合好、后崩溃」很敏感,如果合成单元事件前就贴合得勉强,事件后的 gap 很可能是拟合误差而非真效应;而且只有 1 个受冲击单元,你无法像 DID 那样靠「成百上千个控制组」把噪声平均掉。但作为「单一冲击、无现成对照」场景的标配武器,它把「造对照组」这件事变得可量化、可检验。

1. 核心思想:用 donor 的加权组合当对照组#
设受冲击单元的结果序列是 Y_treat(长度 T,前 T₀ 期是事件前、后 T−T₀ 期是事件后),donor 池是 Y_donors(T × K,K 个未受冲击单元)。
SCM 要找一组权重 W = (W₁, …, W_K),满足:
- 非负:
W_j ≥ 0(不接受「做空某个 donor 来凑数」,否则权重失去经济含义)。 - 和为 1:
Σ W_j = 1(合成单元是 donor 的凸组合,像一个「虚拟的同类单元」)。 - 事件前贴合最优:
W最小化Σ_{t≤T₀} (Y_treat,t − Σ_j W_j Y_donors,t,j)²。
求出 W 后,合成对照就是 Y_synth = Y_donors @ W。事件后的处理效应:
τ_t = Y_treat,t − Y_synth,t (对 t > T₀)plaintext这跟 DID 的「处理组 − 控制组」一脉相承,只是控制组从「一只现成的」变成了「一篮子凑出来的」。
2. 从零实现:凸约束下的权重求解#
约束「W≥0 且 ΣW=1」是个凸二次规划,用 scipy.optimize.minimize 的 SLSQP 即可。
import numpy as np
from scipy.optimize import minimize
def scm_weights(y_treat_pre, X_pre):
"""在事件前最小化 ‖y_treat_pre − X_pre @ w‖²,
约束 w>=0, sum(w)=1。返回权重 w。"""
K = X_pre.shape[1]
def obj(w):
return float(np.sum((y_treat_pre - X_pre @ w) ** 2))
cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
bounds = [(0, None)] * K
w0 = np.full(K, 1.0 / K) # 均匀初值
res = minimize(obj, w0, method="SLSQP", bounds=bounds,
constraints=cons, options={"ftol": 1e-10, "maxiter": 1000})
return res.x
# 合成面板(因子模型驱动,事件后受冲击单元叠加 +1.8 的持续性偏移):
# 事件前贴合 RMSE = 0.041
# 事件后平均 gap(处理效应)= 1.08,与真实偏移几乎一致
# 非零权重数 = 6 / 39;Top-5 权重 = [0.479, 0.367, 0.053, 0.052, 0.027]python为什么权重往往极度稀疏? 因为合成一个「像你」的单元,通常只需要少数几个最像的 donor;其余 donor 的噪声反而拖累拟合,优化器自然把它们权重压到 0。这个稀疏性本身就是一种诊断——它告诉你「到底是哪几个单元在替受冲击单元说话」,比 DID 的「一整组混在一起」更透明。

3. 推断:留一安慰剂检验#
SCM 只有一个受冲击单元,没法像 DID 那样靠「1000 次随机重排单位」做置换(因为只有 1 个单位能被随机分配为处理组)。替代方案是留一安慰剂检验(leave-one-out placebo):
把 donor 池里的每一个单元,轮流当成「假受冲击单元」,用剩下所有 donor 为它合成对照,算出它的「伪事件后 gap」。如果真实受冲击单元的 gap 明显大于这些伪 gap,说明它不是随机噪音。
def scm_placebo(Y, treat_idx=0):
N = Y.shape[1]
gaps = np.zeros(N)
for i in range(N):
cols = [c for c in range(N) if c != i]
others = Y[:, cols]
y_pre, y_post = Y[:T0, i], Y[T0:, i]
w = scm_weights(y_pre, others[:T0])
synth = others @ w
gaps[i] = (y_post - synth[T0:]).mean()
return gaps
# 真实受冲击单元 gap = 1.08,在 40 个单元(含自己)中位列极端离群
# 即 p = 1/40 ≈ 0.025:随机单元产生这么大 gap 的概率很低python
注意一个 1/N 的尴尬:只有一个受冲击单元时,留一检验的 p 值下界是 1/N。我们这里 N=40,p≈0.025 是它理论上能到的最好值——这意味着 SCM 的「显著性」本质上受单元数限制,别指望像大样本回归那样拿到 p=0.001。
4. 五类真实陷阱(不藏)#
陷阱 1:外推与负权重(bad controls)。 如果事件前 donor 池里没有哪个组合能贴合受冲击单元(比如受冲击单元有个 donor 都没有的特殊结构),优化器会给出负权重或把权重塞给不相关的 donor 去「凑数」。负权重在经济学上不可解释,且意味着合成对照在事件前已经是一种「外推」。缓解:检查权重是否全非负、是否经济可解释;加进去的 donor 必须「潜在可受冲击」——不能把明显不同的单元塞进池子。
陷阱 2:donor 池太窄→合成对照像「四不像」。 池子越小、越不具代表性,事件前 RMSE 越大,事件后 gap 里混的拟合误差越多。缓解:donor 池要大到能「拼出」受冲击单元,但又不能宽到塞进无关单元(权衡见陷阱 1)。
陷阱 3:事件前贴合好,事件后「崩」了。 这是 SCM 最隐蔽的失败模式:优化器在事件前把 RMSE 压到极低,但这靠的是「过拟合 donor 的噪声」。事件后结构一变,合成对照立刻失真,你读到的 gap 其实是「拟合残差」而非真效应。缓解:看事件前轨迹是否真的逐期贴合(不只看 RMSE 一个数);用「安慰剂斜率比」等更严格的检验。
陷阱 4:唯一解「不唯一」。 凸问题通常有唯一解,但当 donor 高度共线(比如 39 个 donor 其实只由 2 个因子驱动),权重会不唯一——不同优化器/初值给出不同 W,合成对照却一样好。此时权重本身不可解读(别去写「单元 A 贡献了 48%」这种话)。缓解:报告合成对照本身,而非过度解读权重分布。
陷阱 5:小样本过拟合。 当事件前期数 T₀ 远小于 donor 数 K(我们的 24 < 39 还好,但真实里常见 T₀=10、K=100),优化器几乎总能找到一个完美贴合事件前的组合,但这只是记忆噪声。缓解:限制 K、或对权重加 L2 正则(岭化 SCM),牺牲一点事件前贴合换取泛化。
5. 小结:SCM 与 DID 怎么选#
| DID | SCM | |
|---|---|---|
| 需要几个受冲击单元 | 最好很多(才能平均噪声、做置换) | 1 个就够 |
| 对照组来源 | 现成挑一只/一组 | 用 donor 加权「造」一个 |
| 透明度 | 控制组混在一起,难拆开 | 权重稀疏,能看出谁在说话 |
| 最大软肋 | 找不到干净控制组就废了 | 只有 1 个单元,推断力受 N 限制 |
| 前拟合失败的信号 | 平行趋势图肉眼可查 | 事件前 RMSE / 轨迹贴合度 |
- 它解决什么:单一、外生、不可复制的政策/事件冲击,且你找不到现成对照。
- 它不解决什么:donor 池的代表性、事件前拟合是否过拟合、只有 1 个单元导致的推断力天花板——这些得靠陷阱 1~5 的诊断和安慰剂检验兜底。
- 落地路径:真实研究直接用
pysyncon或Synth(R);永远先报告事件前 RMSE 和逐期贴合轨迹,再谈事件后 gap;至少配一次留一安慰剂检验。
两篇合起来,DID 与 SCM 覆盖了事件研究中「有对照」与「没对照」两端——它们共同的底线是:没有对照组,就没有因果;对照组的干净程度,决定了你结论的可信度。