当前位置:首页>python>平行趋势通不过,别再反复调控制变量:用 Python 重新定义 DID 对照组

平行趋势通不过,别再反复调控制变量:用 Python 重新定义 DID 对照组

  • 2026-10-11 05:45:24
平行趋势通不过,别再反复调控制变量:用 Python 重新定义 DID 对照组

在双重差分法(DID)实证分析中,平行趋势检验是判断政策效应是否可信的重要环节。

当事件研究图显示政策实施前的系数显著时,很多研究会尝试增加控制变量、更换变量定义、缩短事件窗口,甚至删除部分年份,希望让平行趋势检验“通过”。

但这些操作未必能够真正解决问题。

平行趋势不成立,往往意味着处理组与对照组在政策实施前就处于不同的发展轨迹。此时,更规范的处理方式应当是重新检查研究设计,尤其是:

当前选择的对照组,能否合理代表处理组未接受政策时的反事实变化?

本文使用 Python 演示如何重新定义 DID 对照组,将“从未接受处理的单位”替换为“未来会接受处理、但当前尚未接受处理的单位”,并比较两种研究设计下的平行趋势结果。

案例背景

DID的基本逻辑,是比较处理组和对照组在政策前后的变化差异。

假设结果变量为 (Y_{it}),基础模型可以写为:

[
Y_{it}

\alpha_i
+
\lambda_t
+
\beta DID_{it}
+
\gamma X_{it}
+
\varepsilon_{it}
]

其中:

  • • (\alpha_i)表示个体固定效应;
  • • (\lambda_t)表示时间固定效应;
  • • (DID_{it})表示政策处理状态;
  • • (X_{it})表示控制变量;
  • • (\beta)表示政策处理效应。

DID真正需要回答的问题是:

如果处理组没有接受政策,它的结果变量原本会怎样变化?

由于处理组未接受政策时的结果无法直接观察,因此需要通过对照组构造反事实。

这意味着,对照组不能只是“没有接受政策的单位”,还需要与处理组具有相近的潜在发展趋势。

以贫困县扶持政策为例。

部分县之所以较早被纳入政策,往往是因为这些地区具有以下特征:

  • • 经济发展水平较低;
  • • 财政能力相对较弱;
  • • 就业机会不足;
  • • 产业结构较为单一;
  • • 基础设施建设相对滞后。

这些特征不仅影响一个县是否会进入政策处理组,也可能直接影响当地收入、就业和产业结构的变化趋势。

如果将较早进入政策的贫困县作为处理组,将所有普通非贫困县作为对照组,那么两组在政策实施前就可能处于不同的发展轨迹。

此时,事件研究模型中的事前系数显著,未必是因为模型中少放了某个控制变量,更可能是因为:

原始对照组无法代表处理组的反事实变化。

分析思路

本文比较两种对照组定义。

第一种定义

使用从未接受政策的地区作为对照组:

  • • 处理组:2016年进入政策的县;
  • • 对照组:样本期内从未进入政策的县。

这种设定操作简单,但从未处理县可能与政策县存在明显的经济基础和发展趋势差异。

第二种定义

使用晚处理地区作为对照组:

  • • 处理组:2016年进入政策的县;
  • • 对照组:2020年才进入同一政策的县。

在2016—2019年期间,晚处理县尚未接受政策,因此可以暂时作为早处理县的对照组。

与从未处理县相比,晚处理县和早处理县通常更可能具有相近的:

  • • 政策资格条件;
  • • 初始经济水平;
  • • 地区发展阶段;
  • • 产业结构;
  • • 财政和人口特征;
  • • 政策进入概率。

因此,晚处理组更有可能代表早处理组未接受政策时的反事实结果。

这里需要注意:

晚处理组只能在正式接受政策之前作为对照组。

一旦晚处理县在2020年进入政策,就不能继续把它当作未处理单位。

事件研究模型

为检验平行趋势,可以估计以下动态DID模型:

[
Y_{it}

\alpha_i
+
\lambda_t
+
\sum_{k\neq-1}
\beta_k
\left(
Treat_i
\times
1{t-T_i=k}
\right)
+
\gamma X_{it}
+
\varepsilon_{it}
]

其中,(k=-1)通常作为基准期。

政策实施前的系数主要用于判断处理组与对照组是否存在提前趋势:

[
\beta_{-5},\beta_{-4},\beta_{-3},\beta_{-2}
]

如果这些系数整体接近零且不显著,说明平行趋势假设相对合理。

政策实施后的系数:

[
\beta_0,\beta_1,\beta_2,\beta_3
]

则反映政策效应的动态变化。

Python中可以使用PyFixest估计包含个体固定效应、时间固定效应和聚类标准误的模型。该工具支持类似fixest的公式写法,也支持通过i()构造事件时间与处理组的交互项。

核心代码

1. 安装环境

pip install pandas numpy pyfixest matplotlib openpyxl

导入需要使用的库:

import re
import
 numpy as np
import
 pandas as pd
import
 matplotlib.pyplot as plt
import
 pyfixest as pf

数据至少需要包含以下字段:

字段
含义
county_id
县级地区编号
year
年份
outcome
结果变量
policy
当年是否已经接受政策
control1
控制变量1
control2
控制变量2
control3
控制变量3

读取数据:

df = pd.read_excel("did_data.xlsx")

required_columns = [
    "county_id"
,
    "year"
,
    "outcome"
,
    "policy"
,
    "control1"
,
    "control2"
,
    "control3"
,
]

missing_columns = [
    column for column in required_columns
    if
 column not in df.columns
]

if
 missing_columns:
    raise
 ValueError(
        f"数据缺少以下字段:{missing_columns}"

    )

df = df.sort_values(
    ["county_id", "year"]
).reset_index(drop=True)

2. 确定首次处理年份

首先计算每个县第一次接受政策的年份。

first_treat = (
    df.loc[df["policy"].eq(1)]
      .groupby("county_id")["year"]
      .min()
      .rename("first_treat")
)

df = df.merge(
    first_treat,
    on="county_id",
    how="left",
)

检查首次处理年份:

treatment_timing = (
    df[["county_id", "first_treat"]]
    .drop_duplicates()
    .sort_values("first_treat")
)

print
(treatment_timing.head(20))

对于样本期内从未接受政策的地区,first_treat为空值。

构造是否曾经接受政策的变量:

df["ever_treated"] = (
    df["first_treat"].notna().astype(int)
)

df["never_treated"] = (
    df["first_treat"].isna().astype(int)
)

3. 设置处理批次

假设:

  • • 2016年进入政策的县为早处理组;
  • • 2020年进入政策的县为晚处理组。
EARLY_YEAR = 2016
LATE_YEAR = 2020

PRE_WINDOW = 5
POST_WINDOW = 3

构造早处理组变量:

df["early_group"] = (
    df["first_treat"].eq(EARLY_YEAR)
).astype(int)

事件时间以早处理组的政策年份为基准:

df["event_time"] = df["year"] - EARLY_YEAR

保留政策实施前5年至实施后3年:

window_condition = df["event_time"].between(
    -PRE_WINDOW,
    POST_WINDOW,
)

同时,为了保证晚处理组尚未受到政策影响,分析期必须早于晚处理年份:

pre_late_treatment = df["year"] < LATE_YEAR

4. 原始对照组

第一种设定使用从未接受政策的地区作为对照组。

original_sample = df.loc[
    window_condition
    & pre_late_treatment
    & (
        df["first_treat"].eq(EARLY_YEAR)
        | df["first_treat"].isna()
    )
].copy()

检查处理组和对照组数量:

original_group_count = (
    original_sample[
        ["county_id", "early_group"]
    ]
    .drop_duplicates()
    ["early_group"]
    .value_counts()
)

print
("原始对照组设定:")
print
(original_group_count)

其中:

  • • early_group=1表示早处理县;
  • • early_group=0表示从未处理县。

估计事件研究模型:

formula = """
outcome
~ i(event_time, early_group, ref=-1)
+ control1
+ control2
+ control3
| county_id
+ year
"""


fit_never = pf.feols(
    formula,
    data=original_sample,
    vcov={"CRV1": "county_id"},
)

print
(fit_never.summary())

模型控制了:

  • • 县固定效应;
  • • 年份固定效应;
  • • 三个控制变量;
  • • 县级聚类标准误。

PyFixest的feols()可以通过公式中| county_id + year吸收县固定效应和年份固定效应,并通过vcov={"CRV1": "county_id"}计算县级聚类标准误。

绘制平行趋势图:

fit_never.iplot(
    coord_flip=False,
    title="从未处理组作为对照组",
    yintercept=0,
)

5. 晚处理对照组

第二种设定仅保留早处理县和晚处理县。

late_control_sample = df.loc[
    window_condition
    & pre_late_treatment
    & df["first_treat"].isin(
        [EARLY_YEAR, LATE_YEAR]
    )
].copy()

检查样本数量:

late_group_count = (
    late_control_sample[
        ["county_id", "first_treat"]
    ]
    .drop_duplicates()
    ["first_treat"]
    .value_counts()
    .sort_index()
)

print
("晚处理对照组设定:")
print
(late_group_count)

在这个样本中:

  • • 2016年处理县为处理组;
  • • 2020年处理县为对照组;
  • • 所有观测年份均早于2020年;
  • • 晚处理县在对照期间尚未接受政策。

重新估计事件研究模型:

fit_late = pf.feols(
    formula,
    data=late_control_sample,
    vcov={"CRV1": "county_id"},
)

print
(fit_late.summary())

绘制重新定义对照组后的事件研究图:

fit_late.iplot(
    coord_flip=False,
    title="晚处理组作为对照组",
    yintercept=0,
)

6. 提取事件研究结果

为了统一输出结果,可以编写一个系数提取函数。

def extract_event_results(
    model,
    model_name: str,
) -> pd.DataFrame:
    """
    提取PyFixest事件研究系数。
    """


    result = model.tidy().reset_index()

    term_column = result.columns[0]

    result["event_time"] = (
        result[term_column]
        .astype(str)
        .str.extract(
            r"event_time::(-?\d+(?:\.\d+)?)"
,
            expand=False,
        )
    )

    result = result.loc[
        result["event_time"].notna()
    ].copy()

    result["event_time"] = (
        result["event_time"].astype(float)
    )

    result = result.rename(
        columns={
            "Estimate"
: "estimate",
            "Std. Error"
: "std_error",
            "Pr(>|t|)"
: "p_value",
            "2.5%"
: "ci_low",
            "97.5%"
: "ci_high",
        }
    )

    keep_columns = [
        "event_time"
,
        "estimate"
,
        "std_error"
,
        "p_value"
,
        "ci_low"
,
        "ci_high"
,
    ]

    result = result[keep_columns]
    result["model"] = model_name

    # 补回基准期

    base_period = pd.DataFrame(
        {
            "event_time"
: [-1.0],
            "estimate"
: [0.0],
            "std_error"
: [0.0],
            "p_value"
: [np.nan],
            "ci_low"
: [0.0],
            "ci_high"
: [0.0],
            "model"
: [model_name],
        }
    )

    result = pd.concat(
        [result, base_period],
        ignore_index=True,
    )

    return
 result.sort_values(
        "event_time"

    ).reset_index(drop=True)

提取两组模型结果:

result_never = extract_event_results(
    fit_never,
    "从未处理组"
,
)

result_late = extract_event_results(
    fit_late,
    "晚处理组"
,
)

event_results = pd.concat(
    [result_never, result_late],
    ignore_index=True,
)

event_results.to_excel(
    "event_study_results.xlsx"
,
    index=False,
)

print
(event_results)

7. 绘制左右对比图

下面将两种对照组的平行趋势图放在同一张图片中。

def plot_event_study(
    ax,
    result: pd.DataFrame,
    title: str,
) -> None:
    """
    绘制单个事件研究图。
    """


    ax.errorbar(
        result["event_time"],
        result["estimate"],
        yerr=[
            result["estimate"] - result["ci_low"],
            result["ci_high"] - result["estimate"],
        ],
        fmt="o-",
        capsize=4,
    )

    ax.axhline(
        y=0,
        linewidth=1,
    )

    ax.axvline(
        x=-0.5,
        linestyle="--",
        linewidth=1,
    )

    ax.set_title(title)
    ax.set_xlabel("相对政策实施时间")
    ax.set_ylabel("动态政策效应")
    ax.set_xticks(
        sorted
(result["event_time"].unique())
    )


fig, axes = plt.subplots(
    1
,
    2
,
    figsize=(13, 5),
    sharey=True,
)

plot_event_study(
    axes[0],
    result_never,
    "从未处理组作为对照组"
,
)

plot_event_study(
    axes[1],
    result_late,
    "晚处理组作为对照组"
,
)

plt.tight_layout()

plt.savefig(
    "parallel_trend_comparison.png"
,
    dpi=300,
    bbox_inches="tight",
)

plt.show()

左图展示使用从未处理地区作为对照组的结果,右图展示使用晚处理地区作为对照组的结果。

8. 计算事前系数指标

除了观察图形,还可以计算政策实施前系数的平均绝对值和最大绝对值。

def pretrend_metrics(
    result: pd.DataFrame,
) -> pd.Series:
    """
    计算事前趋势的简单诊断指标。
    """


    pre_result = result.loc[
        result["event_time"] <= -2
    ].copy()

    return
 pd.Series(
        {
            "事前平均绝对系数"
: (
                pre_result["estimate"]
                .abs()
                .mean()
            ),
            "事前最大绝对系数"
: (
                pre_result["estimate"]
                .abs()
                .max()
            ),
            "事前显著系数数量"
: (
                pre_result["p_value"] < 0.05
            ).sum(),
            "事前系数数量"
: len(pre_result),
        }
    )

比较两种对照组:

pretrend_comparison = pd.DataFrame(
    {
        "从未处理组"
: pretrend_metrics(
            result_never
        ),
        "晚处理组"
: pretrend_metrics(
            result_late
        ),
    }
).T

print
(pretrend_comparison)

pretrend_comparison.to_excel(
    "pretrend_comparison.xlsx"

)

需要注意,平均绝对系数和显著系数数量只是辅助诊断。

正式研究中还应当:

  • • 报告事前系数的联合显著性检验;
  • • 检查处理组与对照组的基期特征;
  • • 比较政策前结果变量的变化率;
  • • 检查是否存在政策预期效应;
  • • 进行不同事件窗口下的稳健性分析。

9. 分期DID扩展

如果样本中存在多个政策处理批次,并且不同批次的政策效应可能不同,可以进一步使用适合分期处理的事件研究估计。

PyFixest的event_study()支持传统双向固定效应模型和完全交互的饱和事件研究模型;其中saturated设定可以估计批次与事件时间相互作用的动态效应。

首先将从未处理单位的处理年份编码为零:

df["treat_cohort"] = (
    df["first_treat"]
    .fillna(0)
    .astype(int)
)

估计传统TWFE事件研究:

fit_twfe = pf.event_study(
    data=df,
    yname="outcome",
    idname="county_id",
    tname="year",
    gname="treat_cohort",
    estimator="twfe",
)

fit_twfe.iplot()

估计完全交互事件研究:

fit_saturated = pf.event_study(
    data=df,
    yname="outcome",
    idname="county_id",
    tname="year",
    gname="treat_cohort",
    estimator="saturated",
)

fit_saturated.iplot()

按事件时间汇总不同处理批次的估计结果:

dynamic_att = fit_saturated.aggregate(
    weighting="shares"
)

print
(dynamic_att)

绘制聚合后的动态政策效应:

fit_saturated.iplot_aggregate(
    weighting="shares"
)

官方文档还提供了did2s()和lpdid()等分期DID估计接口,可用于处理分期实施与动态效应问题。

结果展示

以下结果用于展示推文中的输出形式,实际系数需要以具体数据运行结果为准。

原始对照组

首先使用从未接受政策的地区作为对照组。

相对时间
系数
标准误
p值
显著性
-5
-0.136
0.052
0.009
**
-4
-0.118
0.048
0.014
**
-3
-0.094
0.044
0.033
**
-2
-0.071
0.035
0.043
**
-1
0.000
—
—
基准期
0
0.058
0.031
0.061
*
1
0.102
0.039
0.009
***
2
0.147
0.045
0.001
***
3
0.176
0.051
0.001
***

政策实施前第5年至第2年的估计系数均显著为负。

这说明,在政策正式实施之前,早处理县与从未处理县之间已经存在系统性的趋势差异。

此时,政策后的正向系数可能同时包含:

  1. 1. 政策产生的真实影响;
  2. 2. 两类地区原本存在的发展趋势差异。

因此,从未处理地区可能不是一个理想的反事实对照组。

晚处理对照组

将对照组替换为2020年才进入政策的县后,得到以下结果。

相对时间
系数
标准误
p值
显著性
-5
-0.031
0.044
0.481
-4
-0.024
0.039
0.538
-3
-0.018
0.035
0.607
-2
-0.009
0.031
0.772
-1
0.000
—
—
基准期
0
0.046
0.026
0.077
*
1
0.087
0.033
0.008
***
2
0.126
0.038
0.001
***
3
0.151
0.044
0.001
***

重新定义对照组后,政策实施前的系数明显向零靠近,而且均不再显著。

这说明早处理县与晚处理县在政策实施前具有更加接近的发展趋势。

结果对比

检验内容
从未处理组
晚处理组
事前平均绝对系数
0.105
0.021
事前最大绝对系数
0.136
0.031
事前显著系数数量
4
0
事前联合检验p值
0.018
0.624
事后平均政策效应
0.121
0.103
对照组可比性
较弱
较强
平行趋势表现
未通过
明显改善

可以看到,更换对照组后,政策实施后的估计方向没有发生根本变化,但政策实施前的趋势差异明显缩小。

这表明原始平行趋势不成立,很可能不是因为控制变量不足,而是因为从未处理县与早处理县缺乏可比性。

结果解释

重新定义对照组的目的,并不是为了让平行趋势检验机械地“通过”。

真正需要回答的是:

哪些单位能够更加合理地代表处理组在没有接受政策时的发展轨迹?

如果政策进入具有明显的选择性,那么所有未处理地区并不一定都适合作为对照组。

晚处理县与早处理县都会在未来进入同一政策,说明它们通常满足相近的政策资格或筛选条件,只是接受政策的时间存在差异。

因此,在晚处理县正式接受政策之前,将其作为早处理县的对照组,往往能够提高处理组与对照组的可比性。

不过,这种方法仍然需要注意以下问题。

预期效应

如果晚处理地区提前知道自己即将进入政策,可能会在正式实施前调整投资、财政支出或就业安排。

此时,晚处理组在正式处理前就可能受到政策影响。

样本变化

从“所有从未处理县”改为“晚处理县”后,对照组规模通常会下降。

因此,需要同时报告:

  • • 样本量变化;
  • • 处理组数量;
  • • 对照组数量;
  • • 各处理批次分布;
  • • 样本筛选标准。

处理效应异质性

当政策分批实施,并且不同批次、不同年份的政策效果存在差异时,传统双向固定效应模型可能无法给出容易解释的平均处理效应。

此时应进一步使用:

  • • 完全交互事件研究;
  • • Sun-Abraham类型估计;
  • • DID2S;
  • • Local Projections DID;
  • • 其他适合分期处理的估计方法。

研究设计优先

合理的分析顺序应当是:

  1. 1. 分析政策进入机制;
  2. 2. 检查处理组与对照组的可比性;
  3. 3. 比较政策前的结果变量趋势;
  4. 4. 重新定义更合理的对照组;
  5. 5. 重新估计事件研究模型;
  6. 6. 再进行控制变量、窗口和样本稳健性检验。

平行趋势不通过时,不应首先通过反复调整参数寻找一张“更好看”的图。

DID实证分析的核心不是让平行趋势检验通过,而是构造一个可信的反事实。

最新文章

随机文章