双重差分 DID 在金融事件评估:用控制组把「相关性」逼成「因果」
事件研究里最危险的结论不是「没显著」,而是「显著但假」——股价在降准后涨了,到底是因为降准,还是因为那段时间本来就该涨?双重差分 DID 用「处理组 − 控制组」的双重差分,把共同的时间趋势从处理效应里剥掉。本文用纯 numpy 从零实现双向固定效应 TWFE 与一般化的 2×2 差分,在自洽合成面板(1 只被冲击股票 + 19 只同类,事件前 24 月 + 后 24 月,真实 ATT=0.40%/月)上把 τ̂ 估计到 0.41%/月、t=2.79、置换 placebo 检验 p=0.038;并诚实拆穿平行趋势不可检验、控制组污染、政策内生化、溢出效应、动态效应被压平五类真实陷阱(中阶)。
你写了一份事件研究:某政策/事件冲击后,受冲击股票的平均月收益从 0.3% 跳到 1.1%。你很兴奋,打算据此建一个「事件驱动」因子。
慢着。这段「跳升」里,有多少是事件本身造成的,有多少只是那段时间的牛市beta? 如果同期整个板块都涨了,你的「事件效应」大概率是个幻觉——你把「市场/行业涨了」误读成了「事件有用」。
结论先放这:双重差分 DID(Difference-in-Differences)解决的正是一个问题——它有意识地引入一个「控制组」,用「(处理组后 − 处理组前)−(控制组后 − 控制组前)」的双重差分,把「时间趋势」这个共同干扰项消掉。 处理组自己前后比,消不掉随时间变的东西;但再减掉控制组的前后差,控制组恰好代表了「如果没被冲击、这段时间会怎样」。剩下的,才是事件真正的「处理效应」。
在我们的自洽合成面板(1 只被冲击股票 + 19 只同类未冲击股票,事件前 24 月、后 24 月,真实 ATT=0.40%/月)上:双向固定效应 DID 把 τ̂ 估计到 0.41%/月(标准误 0.147,t=2.79),经典 2×2 差分核对得到完全一致的值 0.41;把真实处理组标签与 1000 次随机分配做置换(安慰剂)检验,真实 τ̂ 落在随机分布极尾,p=0.038。 诚实地讲:DID 成立的前提是「平行趋势」——事件前处理组和控制组必须走同样的路。这个假设不可直接检验(你只有一条历史),而且一旦控制组被事件「间接污染」、或事件本身是被股价「内生触发」的,DID 估计就会系统性偏误。但作为把「相关性逼成因果」最朴素也最常用的工具,它值得每个做事件研究的人掌握。

1. 2×2 框架:把「时间趋势」减掉#
最直观的 DID 长这样。记四格均值:
| 事件前 | 事件后 | 组内差分 | |
|---|---|---|---|
| 处理组 | Ȳ₁,ₚᵣₑ | Ȳ₁,ₚₒₛₜ | Δ₁ |
| 控制组 | Ȳ₀,ₚᵣₑ | Ȳ₀,ₚₒₛₜ | Δ₀ |
- 组内差分 Δ₁ = Ȳ₁,ₚₒₛₜ − Ȳ₁,ₚᵣₑ:处理组事件前后变了多少(含时间趋势)。
- 组内差分 Δ₀ = Ȳ₀,ₚₒₛₜ − Ȳ₀,ₚᵣₑ:控制组前后变了多少(只有时间趋势,没被冲击)。
- DID 估计量 τ = Δ₁ − Δ₀:把时间趋势 Δ₀ 从 Δ₁ 里减掉,剩下的就是「纯事件效应」。
直觉很朴素:控制组是处理组的「平行宇宙对照组」——它经历了同样的市场、同样的季节、同样的一切,只是没被事件砸中。两者前后差的差异,自然剥离了所有「共同冲击」。
2. 平行趋势:DID 唯一的命门#
DID 不是魔法,它只在一种情形下成立:事件前,处理组和控制组的演化轨迹是「平行」的(即除了常数项不同,时间路径一致)。
- 如果平行趋势成立,那么「事件后处理组相对控制组的 gap」可以直接读作处理效应。
- 如果平行趋势不成立(比如处理组在事件前就已经因为某些预期提前异动),那事件后的 gap 里混进了「事前就有的差异」,DID 估计有偏。
实操里你会画一张事件研究图(event-study plot):横轴是相对事件的月份,纵轴是处理组减控制组的差。平行趋势下,事件前那段应该围绕 0 波动、没有趋势;事件后那段才系统性地抬升/下坠。这段图既是「假设可视检查」,也是后面动态效应分析的基础。

3. 从 2×2 到面板:双向固定效应 TWFE#
2×2 只适用于「一组 vs 一组、一段前 vs 一段后」。真实数据往往是面板:N 只股票 × T 期,事件可能在某期发生。这时候用**双向固定效应(TWFE)**把 DID 写成一个回归,一次性把成百上千只控制组用上:
Y_it = α_i + λ_t + τ · (treat_i × post_t) + ε_itplaintextα_i:单元固定效应(每只股票的「固有水平」,比如有的股票天生波动大)。λ_t:时间固定效应(每一期的「共同冲击」,比如某月大盘涨了)。treat_i × post_t:DID 交互项,系数 τ 就是我们要的 ATT(平均处理效应)。
下面用纯 numpy 从零实现(不依赖任何计量库):构造设计矩阵,单元哑变量 + 时间哑变量 + DID 项,普通最小二乘求解。
import numpy as np
def did_twfe(Y, treat, post):
"""Y: (N, T) 面板; treat: (N,) 0/1 处理标识; post: (T,) 0/1 事件后标识。
返回 DID 系数 tau 及其标准误。"""
N, Tt = Y.shape
yv = Y.ravel()
unit = np.repeat(np.arange(N), Tt)
time = np.tile(np.arange(Tt), N)
did = (treat[:, None] * post[None, :]).ravel() # treat_i × post_t
# 设计矩阵:单元哑变量 + 时间哑变量 + DID 项
X = []
for i in range(N):
X.append((unit == i).astype(float))
for t in range(Tt):
X.append((time == t).astype(float))
X.append(did)
X = np.array(X).T
beta, *_ = np.linalg.lstsq(X.T @ X, X.T @ yv, rcond=None)
tau = beta[-1]
resid = yv - X @ beta
dof = len(yv) - X.shape[1]
se = np.sqrt(float(resid @ resid / dof) * np.linalg.inv(X.T @ X)[-1, -1])
return tau, se
# 在我们的合成面板上:
# 真实 ATT = 0.40%/月(合成时设定),DID 估计 τ̂ = 0.41,se = 0.147,t = 2.79python注意一个细节:单元哑变量会吃掉 treat_i 的主效应,时间哑变量会吃掉 post_t 的主效应,所以回归里只放交互项就够,不需要再单独放 treat/post(否则会「完全共线」被吸收掉)。这是 TWFE 的标准写法。
4. 推断:置换(安慰剂)检验#
DID 给了一个点估计 τ̂,但「显著吗?」不能只看 t 值——t 值背后的标准误假设了误差独立同分布,而金融面板里截面相关性(同一天所有股票一起动)会让标准误被严重低估。
更稳健的推断是置换/安慰剂检验(permutation / placebo test):把「谁被冲击」这件事随机重排 1000 次。每次随机挑一只股票当「假处理组」,其余当控制组,重新跑 DID 得到假 τ。如果真事件是真的,真实 τ̂ 应该明显大于这 1000 个「随机噪音 τ」。
def did_permutation(Y, treat, post, n_perm=1000, rng=np.random.default_rng(0)):
N = Y.shape[0]
real_tau, _ = did_twfe(Y, treat, post)
perm = np.zeros(n_perm)
for p in range(n_perm):
tr = np.zeros(N)
tr[rng.choice(N, 1)] = 1.0 # 随机指定一只为假处理组
perm[p], _ = did_twfe(Y, tr, post)
pval = np.mean(np.abs(perm) >= abs(real_tau))
return real_tau, perm, pval
# 真实 τ̂ = 0.41 落在 1000 次随机分配分布的极尾,p = 0.038python
如果 p 很小(比如 <0.05),说明「随机乱选也能得到这么大的差距」的概率很低,你的事件效应站得住。如果 p 很大,那 0.41 很可能只是某只股票的运气。
5. 五类真实陷阱(不藏)#
陷阱 1:平行趋势不可直接检验。 你只有一条历史,事件前处理组和控制组是否「真的平行」没法用统计检验证伪(你能做的只是画事件研究图做视觉检查,或加事件前若干期虚拟变量看是否显著——但这本质是「检验一个你假设成立的零假设」)。缓解:拉长事件前窗口、做协变量平衡性检查、用合成控制/匹配补充。
陷阱 2:控制组被事件「间接污染」。 如果事件通过产业链/情绪传染到你的控制组(比如政策冲击了整个板块,你选的「同类」也跟着动),那控制组不再代表「没被冲击的平行宇宙」,DID 估计会向下偏(处理效应被控制组稀释)。缓解:选地理/业务上确实隔离的控制组,或做溢出效应诊断。
陷阱 3:政策内生化(reverse causality)。 如果事件本身是「因为股票跌了才出台政策」,那因果方向反了——不是政策影响股价,是股价触发政策。DID 的「外生冲击」假设被破坏,估计有偏。缓解:用外生性更强的冲击(如监管法规、自然灾害、行政区划调整)。
陷阱 4:溢出/一般均衡效应。 真实世界里处理组和控制组不是孤岛。政策可能改变整个市场的资金分配,连控制组都间接受益/受损。经典 DID 假设「无溢出」,破了就偏。缓解:用空间 DID、或明确界定处理边界。
陷阱 5:动态效应被压平成「平均」。 TWFE 给的是一个平均 τ,但真实效应往往是「事件后第 1 月冲高、第 6 月衰减」的动态路径。把动态路径压成一个数,会漏掉「效应何时最强、持续多久」的关键信息。缓解:放事件后各期虚拟变量 × treat,画出动态 ATT 路径(也就是第 2 节的事件研究图)。
6. 小结:DID 在你工作流里的位置#
- 它解决什么:把「事件后涨了」分解成「时间趋势(控制组替你承担了)+ 真正事件效应」。
- 它不解决什么:平行趋势、控制组纯净度、外生性,这些它假设成立,你得自己保证。
- 落地路径:真实数据用
linearmodels的PanelOLS或DoubleML跑 TWFE 会更快更稳;推断优先用聚类标准误(按时间聚类)或置换检验而不是朴素 t 值;永远先画事件研究图看平行趋势,再谈显著。
下一篇我们用合成控制法(SCM)——当「找不到合适的控制组」时,它用一篮子未受冲击单元的加权组合为你「造」出一个专属对照组,正好补上 DID 最脆弱的那块。