halo 的技术博客

返回

你写了一份事件研究:某政策/事件冲击后,受冲击股票的平均月收益从 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 估计就会系统性偏误。但作为把「相关性逼成因果」最朴素也最常用的工具,它值得每个做事件研究的人掌握。

双重差分 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) + ε_it
plaintext
  • α_i:单元固定效应(每只股票的「固有水平」,比如有的股票天生波动大)。
  • λ_t:时间固定效应(每一期的「共同冲击」,比如某月大盘涨了)。
  • treat_i × post_t:DID 交互项,系数 τ 就是我们要的 ATT(平均处理效应)

下面用纯 numpy 从零实现(不依赖任何计量库):构造设计矩阵,单元哑变量 + 时间哑变量 + DID 项,普通最小二乘求解。

注意一个细节:单元哑变量会吃掉 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.038
python

安慰剂/置换检验:真实效应落在随机分布极尾

如果 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 在你工作流里的位置#

  • 它解决什么:把「事件后涨了」分解成「时间趋势(控制组替你承担了)+ 真正事件效应」。
  • 它不解决什么:平行趋势、控制组纯净度、外生性,这些它假设成立,你得自己保证。
  • 落地路径:真实数据用 linearmodelsPanelOLSDoubleML 跑 TWFE 会更快更稳;推断优先用聚类标准误(按时间聚类)置换检验而不是朴素 t 值;永远先画事件研究图看平行趋势,再谈显著。

下一篇我们用合成控制法(SCM)——当「找不到合适的控制组」时,它用一篮子未受冲击单元的加权组合为你「造」出一个专属对照组,正好补上 DID 最脆弱的那块。

双重差分 DID 在金融事件评估:用控制组把「相关性」逼成「因果」
https://blog.halo26812.eu.org/blog/causal-did-finance
Author halo
Published at 2026年7月22日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨