Skip to content

9.2 DID 核心原理

深入理解 DID 的因果推断逻辑


本节目标

  • 理解DID的因果推断逻辑(潜在结果框架)
  • 掌握面板数据DID模型(TWFE固定效应)
  • 学会使用固定效应控制不可观测变量
  • 标准误的聚类调整
  • DID模型的扩展(控制变量)
  • 使用linearmodels实现专业的面板回归

一、DID的因果推断框架

潜在结果框架(Rubin Causal Model)

基本符号

  • :个体 接受处理的潜在结果
  • :个体 不接受处理的潜在结果
  • :是否接受处理(1 = 接受处理)

观测到的结果

个体处理效应(Individual Treatment Effect, ITE)

根本问题:我们永远无法观测到 同时存在——这就是因果推断的根本问题

平均处理效应(Average Treatment Effect, ATE)

定义

处理组平均处理效应(ATT)

为什么不能简单比较?

结论:简单比较混杂了因果效应和选择性偏差,导致有偏估计。


二、DID如何消除偏差

DID的面板数据设定

时间维度 (政策前), (政策后)

潜在结果

  • :个体 在时期 不接受政策的潜在结果
  • :个体 在时期 接受政策的潜在结果

观测到的结果

其中

DID的关键假设(平行趋势假设):

含义:在没有政策干预的情况下,处理组和对照组的变化趋势是平行的。

DID估计量

结论:在平行趋势假设下,DID估计量无偏地识别了处理组平均处理效应(ATT)。


三、面板数据DID模型

双向固定效应模型(TWFE: Two-Way Fixed Effects)

标准的DID回归方程

各项含义

  • 个体固定效应(Unit Fixed Effects)
    • 控制个体层面的时不变特征
    • 例如:地理位置、文化传统等
  • 时间固定效应(Time Fixed Effects)
    • 控制所有个体共同面临的时间冲击
    • 例如:全国性的经济周期、技术进步等
  • :处理虚拟变量(
  • DID估计量(ATT)

固定效应如何消除偏差

  1. 个体固定效应 消除截面异质性

    • 通过组内去均值(within transformation)实现
  2. 时间固定效应 消除共同时间趋势

    • 通过时间去均值实现

深入理解 DID的双重差分

使用虚拟变量表示法(更直观):

等价于:


四、Python实现面板DID

方法1:statsmodels (OLS with dummies)

python
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
import seaborn as sns

# 设置
np.random.seed(42)
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False

# 生成模拟面板数据
n_units = 50  # 个体数量
n_periods = 10  # 时期数量
treatment_time = 5  # 政策干预时间
treatment_effect = 20  # 真实政策效应

data = []
for unit in range(n_units):
    treated = 1 if unit >= n_units // 2 else 0  # 一半个体接受处理
    unit_effect = np.random.normal(10 * treated, 5)  # 个体固定效应

    for period in range(n_periods):
        time_effect = 2 * period  # 时间趋势
        post = 1 if period >= treatment_time else 0

        # 结果变量
        y = (50 + unit_effect + time_effect +
             treatment_effect * treated * post +
             np.random.normal(0, 3))

        data.append({
            'unit': unit,
            'period': period,
            'treated': treated,
            'post': post,
            'did': treated * post,
            'y': y
        })

df = pd.DataFrame(data)

print("=" * 70)
print("模拟数据描述统计")
print("=" * 70)
print(df.groupby(['treated', 'period'])['y'].mean().unstack())
print("\n")

# 方法1:OLS with fixed effects dummies
# 注意:包含所有虚拟变量会导致共线性问题,需要设置基准组
model_fe = smf.ols('y ~ C(unit) + C(period) + did', data=df).fit(cov_type='HC1')

print("=" * 70)
print("方法1:OLS with FE dummies")
print("=" * 70)
print(f"DID 估计系数: {model_fe.params['did']:.3f}")
print(f"标准误: {model_fe.bse['did']:.3f}")
print(f"95% CI: [{model_fe.conf_int().loc['did', 0]:.3f}, {model_fe.conf_int().loc['did', 1]:.3f}]")
print(f"真实效应: {treatment_effect}")
print("\n")

方法2:linearmodels (专业的面板数据工具)

python
from linearmodels.panel import PanelOLS

# 设置数据为面板格式(multi-index)
df_panel = df.set_index(['unit', 'period'])

# 使用 PanelOLS with entity and time effects
model_panel = PanelOLS(
    dependent=df_panel['y'],
    exog=df_panel[['did']],
    entity_effects=True,  # 个体固定效应
    time_effects=True,    # 时间固定效应
).fit(cov_type='clustered', cluster_entity=True)  # 聚类标准误

print("=" * 70)
print("方法2:linearmodels PanelOLS (推荐)")
print("=" * 70)
print(model_panel.summary)
print("\n")

# 提取结果
did_coef = model_panel.params['did']
did_se = model_panel.std_errors['did']
did_ci = model_panel.conf_int().loc['did']

print("=" * 70)
print("估计结果总结")
print("=" * 70)
print(f"DID 估计系数: {did_coef:.3f} (SE = {did_se:.3f})")
print(f"95% CI: [{did_ci[0]:.3f}, {did_ci[1]:.3f}]")
print(f"真实效应: {treatment_effect}")
print(f"估计偏差: {did_coef - treatment_effect:.3f}")

重要参数

  • entity_effects=True:添加个体固定效应
  • time_effects=True:添加时间固定效应
  • cov_type='clustered', cluster_entity=True:使用个体层面聚类标准误(Bertrand et al. 2004推荐)

五、可视化平行趋势

事件研究图(Event Study Plot)

python
# 构造相对时间变量
df['rel_period'] = df['period'] - treatment_time
df['rel_period'] = df['rel_period'] * df['treated']  # 只对处理组有效

# 创建leads和lags虚拟变量(以t=-1为基准组)
for t in range(-treatment_time, n_periods - treatment_time):
    if t != -1:  # 基准组
        df[f'lead_lag_{t}'] = ((df['rel_period'] == t) & (df['treated'] == 1)).astype(int)

# 构建回归公式
lead_lag_vars = [f'lead_lag_{t}' for t in range(-treatment_time, n_periods - treatment_time) if t != -1]

# 事件研究回归
formula = 'y ~ C(unit) + C(period) + ' + ' + '.join(lead_lag_vars)
model_es = smf.ols(formula, data=df).fit(cov_type='HC1')

# 提取系数并构造绘图数据
event_study_results = []
for t in range(-treatment_time, n_periods - treatment_time):
    if t == -1:
        # 基准期系数为0
        event_study_results.append({'period': t, 'coef': 0, 'ci_lower': 0, 'ci_upper': 0})
    else:
        var_name = f'lead_lag_{t}'
        coef = model_es.params[var_name]
        ci = model_es.conf_int().loc[var_name]
        event_study_results.append({
            'period': t,
            'coef': coef,
            'ci_lower': ci[0],
            'ci_upper': ci[1]
        })

es_df = pd.DataFrame(event_study_results)

# 绘制事件研究图
fig, ax = plt.subplots(figsize=(14, 8))

ax.plot(es_df['period'], es_df['coef'], 'o-', linewidth=2, markersize=8, color='navy', label='DID 系数')
ax.fill_between(es_df['period'], es_df['ci_lower'], es_df['ci_upper'], alpha=0.2, color='navy', label='95% CI')
ax.axhline(y=0, color='black', linestyle='--', linewidth=1, alpha=0.5)
ax.axvline(x=-0.5, color='red', linestyle='--', linewidth=2, alpha=0.7, label='政策实施时间')

ax.set_xlabel('相对于政策实施的时间', fontsize=14, fontweight='bold')
ax.set_ylabel('估计系数', fontsize=14, fontweight='bold')
ax.set_title('事件研究图:DID 动态效应', fontsize=16, fontweight='bold')
ax.legend(fontsize=12)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print("=" * 70)
print("平行趋势检验")
print("=" * 70)
print("查看政策前系数(t < 0):")
pre_treatment = es_df[es_df['period'] < 0]
print(pre_treatment[['period', 'coef', 'ci_lower', 'ci_upper']])
print("\n如果平行趋势假设成立,政策前系数应当接近0且不显著")

如何解读

  1. 政策前 ():系数应接近0(平行趋势检验)
  2. 政策后 ():系数显著为正,说明政策有效应
  3. 动态效应:观察效应是否随时间增强/减弱/保持稳定

六、标准误的聚类调整

为什么需要聚类标准误

Bertrand et al. (2004) 的重要发现

DID研究中常见的统计问题:

  1. 序列相关(Serial Correlation):个体在不同时期的误差项相关
  2. 标准误低估:OLS标准误严重低估真实标准误
  3. 显著性夸大:导致拒绝原假设的概率过高

解决方法:使用个体层面聚类标准误

Python中的不同选项

python
from linearmodels.panel import PanelOLS
import statsmodels.formula.api as smf

df_panel = df.set_index(['unit', 'period'])

# 1. 朴素OLS(不推荐)
model_ols = PanelOLS(
    df_panel['y'],
    df_panel[['did']],
    entity_effects=True,
    time_effects=True
).fit(cov_type='unadjusted')

# 2. 异方差稳健(不推荐)
model_robust = PanelOLS(
    df_panel['y'],
    df_panel[['did']],
    entity_effects=True,
    time_effects=True
).fit(cov_type='robust')

# 3. 聚类标准误 - 个体层面聚类(推荐)
model_cluster_entity = PanelOLS(
    df_panel['y'],
    df_panel[['did']],
    entity_effects=True,
    time_effects=True
).fit(cov_type='clustered', cluster_entity=True)

# 4. 双向聚类(个体 + 时间)
model_cluster_two_way = PanelOLS(
    df_panel['y'],
    df_panel[['did']],
    entity_effects=True,
    time_effects=True
).fit(cov_type='clustered', clusters=df_panel.reset_index()[['unit', 'period']])

# 比较结果
print("=" * 70)
print("标准误比较")
print("=" * 70)
from scipy import stats

results_comparison = pd.DataFrame({
    '估计系数': [
        model_ols.params['did'],
        model_robust.params['did'],
        model_cluster_entity.params['did'],
        model_cluster_two_way.params['did']
    ],
    '标准误': [
        model_ols.std_errors['did'],
        model_robust.std_errors['did'],
        model_cluster_entity.std_errors['did'],
        model_cluster_two_way.std_errors['did']
    ]
}, index=['OLS', 'Robust', 'Cluster(Entity)', 'Cluster(Two-way)'])

results_comparison['t统计量'] = results_comparison['估计系数'] / results_comparison['标准误']
results_comparison['p值'] = 2 * (1 - stats.t.cdf(np.abs(results_comparison['t统计量']), df=n_units-1))

print(results_comparison)
print("\n")
print("说明:")
print("  • OLS标准误通常会低估真实标准误")
print("  • 推荐使用聚类标准误(entity层面聚类)")

最佳实践建议

  1. 最低要求:使用个体层面聚类(cluster_entity=True
  2. 更稳健:双向聚类(个体 + 时间)
  3. 样本较小:考虑Wild Bootstrap等重抽样方法

七、添加控制变量

带控制变量的DID

回归方程扩展

其中 是时变控制变量(time-varying covariates)

何时添加控制变量

  1. 为了提高估计效率(precision),减小标准误
  2. 为了检验平行趋势的稳健性(控制变量不应改变平行趋势)
  3. 但要注意避免"坏控制"

Python实现

python
# 生成时变控制变量
np.random.seed(42)
df['x1'] = np.random.normal(10, 2, len(df))  # 连续变量
df['x2'] = np.random.binomial(1, 0.5, len(df))  # 二值变量

# 回归(带控制变量)
df_panel = df.set_index(['unit', 'period'])

model_with_controls = PanelOLS(
    df_panel['y'],
    df_panel[['did', 'x1', 'x2']],
    entity_effects=True,
    time_effects=True
).fit(cov_type='clustered', cluster_entity=True)

print("=" * 70)
print("带控制变量的DID")
print("=" * 70)
print(model_with_controls.summary)

注意事项

  1. 只控制外生变量:控制变量必须与处理无关
  2. 不要控制"坏控制"(Bad Controls):不控制政策的中介变量
  3. 使用Lasso等方法选择控制变量:控制变量过多会降低估计效率

八、DID的扩展

Staggered DID(交错DID)

场景:个体在不同时间接受处理

传统 TWFE的问题(Goodman-Bacon 2021)

当处理时间交错时, 不再是简单的 DID,而是多个异质性DID的加权平均(权重可能为负!)

"坏对照组"问题

  • 已处理个体成为后处理个体的对照组
  • 可能导致估计量有偏

解决方法

  1. Callaway & Sant'Anna (2021):估计组别-时间异质性ATT
  2. Sun & Abraham (2021):构造干净的交互项
  3. De Chaisemartin & D'Haultfoeuille (2020):变量稳健的DID

如何使用CS估计器

python
# 示例代码(需要安装: pip install csdid (目前仅R包成熟))
# 可以通过rpy2调用R,或等待Python版本完善

# 伪代码示例:
"""
from csdid import ATT

# 估计group-time specific ATT
att_results = ATT(
    data=df,
    yname='y',
    gname='first_treat',  # 首次接受处理的时间
    tname='period',
    idname='unit',
    control_group='notyettreated'  # 使用尚未处理的个体作为对照组
)

att_results.summary()
att_results.plot()
"""

print("=" * 70)
print("交错DID的处理")
print("=" * 70)
print("若你的数据存在处理时间交错(Staggered DID),建议使用:")
print("1. Callaway & Sant'Anna (2021) 方法")
print("2. Sun & Abraham (2021) 方法")
print("3. 或手动修正 TWFE的潜在问题")

九、本节总结

关键要点

  1. DID的因果推断逻辑

    • 平行趋势假设是识别的核心
    • DID估计量识别了ATT(处理组平均处理效应)
  2. 面板DID模型

    • 使用双向固定效应(TWFE)模型
    • 个体固定效应控制时不变特征
    • 时间固定效应控制共同时间趋势
  3. 标准误

    • 必须使用聚类标准误(使用个体层面聚类)
    • 样本较小时考虑Bootstrap等重抽样方法
  4. 扩展与注意

    • Staggered DID 需要特殊方法
    • 建议使用变量稳健的估计器

Python工具箱

任务推荐工具包
基础DIDstatsmodels.formula.api.ols()
面板DIDlinearmodels.panel.PanelOLS()
聚类标准误cov_type='clustered'
Staggered DIDcsdid, did (R包或Python接口)

下一步

进入 第3节:平行趋势假设 学习更多内容:

  • 如何检验平行趋势假设
  • 事件研究图的绘制
  • 处理违反平行趋势的情况

掌握面板DID是政策评估的基石!

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