Skip to content

11.3 连续性假设与有效性检验

"The validity of RDD rests on the continuity assumption.""RDD的有效性取决于连续性假设。"— David Lee, Econometrician (计量经济学家)

假设不可直接检验,但可以间接验证


本节概要

RDD 的因果识别依赖于连续性假设(Continuity Assumption),但这个假设无法直接检验(涉及反事实)。本节将介绍:

  • 连续性假设的含义和威胁
  • 协变量平衡检验(Covariate Balance Tests)
  • 密度检验(McCrary Density Test)
  • 安慰剂检验(Placebo Tests)
  • Python 完整实现

连续性假设:RDD 的基石

正式定义

连续性假设:在断点 处,潜在结果函数是连续的。

数学表达

白话

  • 如果移除处理,结果变量在断点处应该是平滑的(没有跳跃)
  • 换句话说:驱动变量 的微小变化不会突然改变潜在结果

为什么连续性假设可信?

局部随机化的视角

在断点附近,个体应该是"相似"的:

  • 599分的学生 vs 600分的学生
  • 能力、家庭背景、学习习惯等应该几乎完全一样
  • 唯一差别:一个刚好越过断点

形式化:如果断点附近的个体在所有协变量上都平衡,那么潜在结果也应该平衡。

威胁连续性假设的情况

威胁 1:精确操控(Precise Manipulation)

问题:个体能够精确地操控驱动变量,使自己刚好越过断点。

例子

  1. 考试作弊:学生知道600分是断点,想办法作弊到刚好600分
  2. 选举舞弊:候选人操纵选票,使得票率刚好超过50%
  3. 企业游说:企业游说政府,使自己的规模刚好低于监管阈值

后果

  • 断点右侧的个体系统性地不同于左侧(选择性偏差)
  • 潜在结果在断点处可能跳跃(违反连续性)

如何检测

  • McCrary 密度检验:检查驱动变量的密度在断点处是否有异常堆积

威胁 2:其他政策在断点处同时变化

问题:除了我们关心的处理,还有其他政策在断点处改变。

例子

  • 高考600分不仅能获得奖学金,还能进入重点班
  • 如果观察到GPA提高,无法区分是奖学金的效应还是重点班的效应

后果

  • RDD 估计的是所有在断点处改变的因素的联合效应
  • 无法分离单个处理的效应

如何避免

  • 仔细研究制度背景,确保断点处只有一个政策变化
  • 或者明确承认估计的是"一揽子政策"的效应

威胁 3:驱动变量本身直接影响结果

问题:驱动变量 除了通过处理 ,还直接影响结果

例子

  • 高考分数本身(除了奖学金)也影响学生的自信心
  • 年龄本身(除了Medicare)也影响健康行为

后果

  • 如果 的直接效应在断点处不连续,会违反连续性假设
  • 但只要 的直接效应是平滑的,连续性假设仍然成立

检验

  • 协变量平衡检验(如果协变量平衡,驱动变量应该"准随机")

有效性检验 1:协变量平衡检验

核心思想

逻辑

  • 如果处理在断点附近是"准随机"的
  • 那么所有预处理协变量(Baseline Covariates)应该在断点处平衡(连续)

检验:对每个协变量 ,运行"伪RDD":

零假设(协变量在断点处无跳跃)

如果拒绝

  • 协变量在断点处不平衡
  • 可能存在选择性偏差或操控
  • 连续性假设受到质疑

Python 实现

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

# 假设我们有以下协变量
# X: 驱动变量, D: 处理, Y: 结果
# Z1: 年龄, Z2: 性别, Z3: 家庭收入

def covariate_balance_test(df, covariate, cutoff, bandwidth=None):
    """
    协变量平衡检验

    参数:
    - df: 数据框
    - covariate: 协变量名称
    - cutoff: 断点
    - bandwidth: 带宽(如果为None,使用全样本)
    """
    df_test = df.copy()
    df_test['X_c'] = df_test['X'] - cutoff

    if bandwidth is not None:
        df_test = df_test[np.abs(df_test['X_c']) <= bandwidth]

    # 运行RDD(协变量作为结果变量)
    model = smf.ols(f'{covariate} ~ D + X_c + D:X_c', data=df_test).fit()

    # 提取结果
    jump = model.params['D']
    se = model.bse['D']
    pvalue = model.pvalues['D']

    return {
        'covariate': covariate,
        'jump': jump,
        'se': se,
        'pvalue': pvalue,
        'significant': pvalue < 0.05
    }

# 示例数据
np.random.seed(42)
n = 2000
X = np.random.normal(0, 10, n)
D = (X >= 0).astype(int)

# 生成协变量(应该在断点处平衡)
age = 25 + 0.1 * X + np.random.normal(0, 3, n)
gender = np.random.binomial(1, 0.5, n)  # 独立于X
income = 50000 + 500 * X + np.random.normal(0, 10000, n)

# 生成结果变量
Y = 50 + 0.5 * X + 10 * D + np.random.normal(0, 5, n)

df = pd.DataFrame({
    'X': X, 'D': D, 'Y': Y,
    'age': age, 'gender': gender, 'income': income
})

# 对所有协变量进行平衡检验
covariates = ['age', 'gender', 'income']
balance_results = []

for cov in covariates:
    result = covariate_balance_test(df, cov, cutoff=0, bandwidth=20)
    balance_results.append(result)

balance_df = pd.DataFrame(balance_results)

print("=" * 70)
print("协变量平衡检验")
print("=" * 70)
print(balance_df.to_string(index=False))
print("\n解释:p-value > 0.05 表示协变量在断点处平衡(好)")

可视化协变量平衡

python
from rdrobust import rdplot

fig, axes = plt.subplots(1, 3, figsize=(18, 5))

for i, cov in enumerate(covariates):
    ax = axes[i]

    # 使用 rdplot 绘制
    rdplot_result = rdplot(y=df[cov], x=df['X'], c=0,
                          title=f'Covariate Balance: {cov}',
                          x_label='Running Variable',
                          y_label=cov)

    # 注:rdplot 会创建新图,这里我们手动绘制
    # Binning
    bins = pd.cut(df['X'], bins=20)
    df_binned = df.groupby([bins, 'D']).agg({cov: 'mean', 'X': 'mean'}).reset_index()

    df_left = df_binned[df_binned['D'] == 0]
    df_right = df_binned[df_binned['D'] == 1]

    ax.scatter(df_left['X'], df_left[cov], color='blue', s=50, alpha=0.6, label='Control')
    ax.scatter(df_right['X'], df_right[cov], color='red', s=50, alpha=0.6, label='Treated')

    # 拟合线
    df_local = df[np.abs(df['X']) <= 20]
    df_local_left = df_local[df_local['D'] == 0]
    df_local_right = df_local[df_local['D'] == 1]

    from sklearn.linear_model import LinearRegression
    if len(df_local_left) > 0:
        lr_left = LinearRegression().fit(df_local_left[['X']], df_local_left[cov])
        X_range_left = np.linspace(df_local_left['X'].min(), 0, 100)
        ax.plot(X_range_left, lr_left.predict(X_range_left.reshape(-1, 1)),
                color='blue', linewidth=2)

    if len(df_local_right) > 0:
        lr_right = LinearRegression().fit(df_local_right[['X']], df_local_right[cov])
        X_range_right = np.linspace(0, df_local_right['X'].max(), 100)
        ax.plot(X_range_right, lr_right.predict(X_range_right.reshape(-1, 1)),
                color='red', linewidth=2)

    ax.axvline(x=0, color='green', linestyle='--', linewidth=2)
    ax.set_title(f'{cov} (p={balance_df.loc[i, "pvalue"]:.3f})', fontsize=12, fontweight='bold')
    ax.set_xlabel('Running Variable', fontsize=11)
    ax.set_ylabel(cov, fontsize=11)
    ax.legend()
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

解释

  • 如果协变量在断点处没有明显跳跃 → 通过平衡检验(好)
  • 如果协变量在断点处有跳跃 → 可能存在操控或选择性偏差(坏)

有效性检验 2:密度检验(McCrary Test)

核心思想

逻辑

  • 如果个体能够精确操控驱动变量,会在断点附近堆积
  • 例如:学生作弊使分数刚好达到600分
  • 这会导致驱动变量的密度函数在断点处不连续

McCrary (2008) 检验

其中 是驱动变量 的密度函数。

如果拒绝

  • 密度在断点处有跳跃
  • 可能存在操控(Manipulation)
  • 连续性假设受到严重威胁

McCrary 检验的实现

步骤

  1. 将驱动变量分成小区间(bins)
  2. 计算每个区间的频数(构建直方图)
  3. 对断点左右两侧分别拟合平滑曲线(核密度估计或局部线性)
  4. 检验左右两侧在断点处的密度跳跃

检验统计量

其中:

  • :断点右侧密度的估计值
  • :断点左侧密度的估计值

Python 实现(使用 rddensity 包)

python
# 安装 rddensity
# pip install rddensity

from rddensity import rddensity

# McCrary 密度检验
density_test = rddensity(X=df['X'], c=0)

print("=" * 70)
print("McCrary 密度检验")
print("=" * 70)
print(density_test)
print(f"\np-value: {density_test.pval[0]:.4f}")
print("解释:p-value > 0.05 表示密度连续(无操控证据)")

手动实现密度检验(简化版)

python
def mccrary_density_test(X, c, bandwidth=None, n_bins=30):
    """
    简化版 McCrary 密度检验

    参数:
    - X: 驱动变量
    - c: 断点
    - bandwidth: 带宽
    - n_bins: 直方图区间数
    """
    # 创建直方图
    counts, bin_edges = np.histogram(X, bins=n_bins)
    bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2

    # 分离左右两侧
    left_mask = bin_centers < c
    right_mask = bin_centers >= c

    bin_centers_left = bin_centers[left_mask]
    counts_left = counts[left_mask]

    bin_centers_right = bin_centers[right_mask]
    counts_right = counts[right_mask]

    # 拟合局部线性(简化:使用OLS)
    from sklearn.linear_model import LinearRegression

    if len(bin_centers_left) > 1:
        lr_left = LinearRegression().fit(
            bin_centers_left.reshape(-1, 1), counts_left
        )
        density_left_at_c = lr_left.predict([[c]])[0]
    else:
        density_left_at_c = 0

    if len(bin_centers_right) > 1:
        lr_right = LinearRegression().fit(
            bin_centers_right.reshape(-1, 1), counts_right
        )
        density_right_at_c = lr_right.predict([[c]])[0]
    else:
        density_right_at_c = 0

    # 计算跳跃
    jump = density_right_at_c - density_left_at_c

    # 可视化
    fig, ax = plt.subplots(figsize=(12, 6))

    # 直方图
    ax.bar(bin_centers_left, counts_left, width=np.diff(bin_edges)[0],
           alpha=0.5, color='blue', edgecolor='black', label='Left of cutoff')
    ax.bar(bin_centers_right, counts_right, width=np.diff(bin_edges)[0],
           alpha=0.5, color='red', edgecolor='black', label='Right of cutoff')

    # 拟合线
    if len(bin_centers_left) > 1:
        X_left_range = np.linspace(bin_centers_left.min(), c, 100)
        ax.plot(X_left_range, lr_left.predict(X_left_range.reshape(-1, 1)),
                color='blue', linewidth=3, label='Fitted (left)')

    if len(bin_centers_right) > 1:
        X_right_range = np.linspace(c, bin_centers_right.max(), 100)
        ax.plot(X_right_range, lr_right.predict(X_right_range.reshape(-1, 1)),
                color='red', linewidth=3, label='Fitted (right)')

    # 断点
    ax.axvline(x=c, color='green', linestyle='--', linewidth=2.5)

    ax.set_xlabel('Running Variable (X)', fontsize=13, fontweight='bold')
    ax.set_ylabel('Frequency', fontsize=13, fontweight='bold')
    ax.set_title(f'McCrary Density Test (Jump = {jump:.2f})',
                 fontsize=15, fontweight='bold')
    ax.legend(fontsize=11)
    ax.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.show()

    return jump

# 运行检验
jump = mccrary_density_test(df['X'], c=0)
print(f"\n密度跳跃: {jump:.2f}")

解释

  • 无跳跃):密度在断点处连续,无操控证据
  • 有跳跃):密度在断点处不连续,可能存在操控

有效性检验 3:安慰剂检验(Placebo Tests)

核心思想

逻辑

  • 如果RDD识别是可信的,效应应该只在真实断点处出现
  • 假断点(Placebo Cutoff)处,不应该有跳跃

实施

  1. 选择一个假断点 (远离真实断点
  2. 在假断点处运行RDD
  3. 检验是否有显著效应

期望结果

  • : 假断点处无效应(
  • 如果拒绝 → RDD设计可能有问题

Python 实现

python
def placebo_test(df, true_cutoff, placebo_cutoff, bandwidth=20):
    """
    安慰剂检验

    参数:
    - df: 数据框
    - true_cutoff: 真实断点
    - placebo_cutoff: 假断点
    - bandwidth: 带宽
    """
    # 只使用断点左侧或右侧的数据(避免包含真实断点)
    if placebo_cutoff < true_cutoff:
        # 使用左侧数据
        df_placebo = df[df['X'] < true_cutoff].copy()
    else:
        # 使用右侧数据
        df_placebo = df[df['X'] >= true_cutoff].copy()

    # 创建假处理变量
    df_placebo['D_placebo'] = (df_placebo['X'] >= placebo_cutoff).astype(int)
    df_placebo['X_c_placebo'] = df_placebo['X'] - placebo_cutoff

    # 限制在带宽内
    df_placebo_local = df_placebo[np.abs(df_placebo['X_c_placebo']) <= bandwidth]

    # 运行RDD
    model = smf.ols('Y ~ D_placebo + X_c_placebo + D_placebo:X_c_placebo',
                    data=df_placebo_local).fit()

    return {
        'placebo_cutoff': placebo_cutoff,
        'effect': model.params['D_placebo'],
        'se': model.bse['D_placebo'],
        'pvalue': model.pvalues['D_placebo'],
        'significant': model.pvalues['D_placebo'] < 0.05
    }

# 尝试多个假断点
placebo_cutoffs = [-15, -10, -5, 5, 10, 15]
placebo_results = []

for pc in placebo_cutoffs:
    if pc != 0:  # 排除真实断点
        result = placebo_test(df, true_cutoff=0, placebo_cutoff=pc, bandwidth=10)
        placebo_results.append(result)

placebo_df = pd.DataFrame(placebo_results)

print("=" * 70)
print("安慰剂检验(假断点)")
print("=" * 70)
print(placebo_df.to_string(index=False))
print("\n期望:所有假断点的 p-value > 0.05(无显著效应)")

可视化安慰剂检验

python
fig, ax = plt.subplots(figsize=(12, 6))

# 绘制估计效应和置信区间
ax.errorbar(placebo_df['placebo_cutoff'], placebo_df['effect'],
            yerr=1.96 * placebo_df['se'],
            fmt='o', capsize=5, capthick=2, linewidth=2, markersize=8,
            color='gray', label='Placebo cutoffs')

# 真实断点处的效应
true_model = smf.ols('Y ~ D + X_c + D:X_c',
                     data=df[np.abs(df['X']) <= 20]).fit()
true_effect = true_model.params['D']
true_se = true_model.bse['D']

ax.errorbar(0, true_effect, yerr=1.96 * true_se,
            fmt='*', capsize=5, capthick=3, linewidth=3, markersize=15,
            color='red', label='True cutoff')

# 参考线
ax.axhline(y=0, color='black', linestyle='-', linewidth=1)

ax.set_xlabel('Cutoff', fontsize=13, fontweight='bold')
ax.set_ylabel('Estimated RDD Effect', fontsize=13, fontweight='bold')
ax.set_title('Placebo Test: Effect at Different Cutoffs',
             fontsize=15, fontweight='bold')
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

解释

  • 假断点无效应 → 通过安慰剂检验
  • 假断点有效应 → RDD设计可能有问题

有效性检验 4:协变量作为结果的RDD

核心思想

逻辑

  • 如果处理在断点附近是"准随机"的
  • 那么对预处理协变量运行RDD,不应该有效应

实施

  • 用协变量 替代结果变量
  • 运行标准RDD
  • 检验是否有跳跃

这与协变量平衡检验本质相同,但更直观地展示。

Python 实现

python
from rdrobust import rdrobust

# 对每个协变量运行RDD
print("=" * 70)
print("协变量 RDD 检验")
print("=" * 70)

for cov in covariates:
    result = rdrobust(y=df[cov], x=df['X'], c=0)

    print(f"\n{cov}:")
    print(f"  RDD 效应: {result.coef[0]:.4f}")
    print(f"  p-value: {result.pval[0]:.4f}")
    print(f"  结论: {' 不平衡' if result.pval[0] < 0.05 else ' 平衡'}")

完整有效性检验报告

将所有检验整合到一个报告中:

python
def rdd_validity_report(df, X_col, D_col, Y_col, covariates, cutoff=0, bandwidth=20):
    """
    生成完整的 RDD 有效性检验报告

    参数:
    - df: 数据框
    - X_col: 驱动变量列名
    - D_col: 处理变量列名
    - Y_col: 结果变量列名
    - covariates: 协变量列表
    - cutoff: 断点
    - bandwidth: 带宽
    """
    df = df.copy()
    df['X_c'] = df[X_col] - cutoff

    print("=" * 80)
    print(" " * 20 + "RDD 有效性检验报告")
    print("=" * 80)

    # 1. 主效应估计
    print("\n【1】主效应估计")
    print("-" * 80)
    main_result = rdrobust(y=df[Y_col], x=df[X_col], c=cutoff)
    print(f"RDD 效应: {main_result.coef[0]:.4f}")
    print(f"稳健 p-value: {main_result.pval[0]:.4f}")
    print(f"95% CI: [{main_result.ci[0][0]:.4f}, {main_result.ci[0][1]:.4f}]")

    # 2. 协变量平衡检验
    print("\n【2】协变量平衡检验")
    print("-" * 80)
    balance_results = []
    for cov in covariates:
        result = rdrobust(y=df[cov], x=df[X_col], c=cutoff)
        balance_results.append({
            'Covariate': cov,
            'Jump': result.coef[0],
            'p-value': result.pval[0],
            'Balanced': '' if result.pval[0] > 0.05 else ''
        })
    balance_df = pd.DataFrame(balance_results)
    print(balance_df.to_string(index=False))

    # 3. 密度检验
    print("\n【3】McCrary 密度检验")
    print("-" * 80)
    try:
        from rddensity import rddensity
        density_result = rddensity(X=df[X_col], c=cutoff)
        print(f"T-统计量: {density_result.test['T'][0]:.4f}")
        print(f"p-value: {density_result.pval[0]:.4f}")
        print(f"结论: {' 密度连续(无操控证据)' if density_result.pval[0] > 0.05 else ' 密度不连续(可能存在操控)'}")
    except ImportError:
        print("未安装 rddensity 包,跳过密度检验")

    # 4. 安慰剂检验
    print("\n【4】安慰剂检验(假断点)")
    print("-" * 80)
    placebo_cutoffs = [cutoff - 15, cutoff - 10, cutoff + 10, cutoff + 15]
    placebo_results = []

    for pc in placebo_cutoffs:
        # 使用不包含真实断点的子样本
        if pc < cutoff:
            df_sub = df[df[X_col] < cutoff]
        else:
            df_sub = df[df[X_col] >= cutoff]

        df_sub['D_placebo'] = (df_sub[X_col] >= pc).astype(int)
        df_sub['X_c_placebo'] = df_sub[X_col] - pc
        df_sub_local = df_sub[np.abs(df_sub['X_c_placebo']) <= bandwidth]

        if len(df_sub_local) > 50:
            model = smf.ols(f'{Y_col} ~ D_placebo + X_c_placebo + D_placebo:X_c_placebo',
                           data=df_sub_local).fit()
            placebo_results.append({
                'Placebo Cutoff': pc,
                'Effect': model.params['D_placebo'],
                'p-value': model.pvalues['D_placebo'],
                'Significant': '' if model.pvalues['D_placebo'] < 0.05 else ''
            })

    placebo_df = pd.DataFrame(placebo_results)
    print(placebo_df.to_string(index=False))
    print("\n期望:所有假断点不显著()")

    print("\n" + "=" * 80)
    print(" " * 25 + "报告结束")
    print("=" * 80)

# 生成报告
rdd_validity_report(
    df=df,
    X_col='X',
    D_col='D',
    Y_col='Y',
    covariates=['age', 'gender', 'income'],
    cutoff=0,
    bandwidth=20
)

关键要点

连续性假设

  1. 核心:潜在结果在断点处连续(平滑)
  2. 不可检验:涉及反事实,无法直接观察
  3. 间接验证:通过协变量平衡、密度检验等

协变量平衡检验

  1. 逻辑:如果处理准随机,协变量应平衡
  2. 实施:对每个协变量运行RDD,检验跳跃
  3. 解释:p > 0.05 表示平衡(好)

McCrary 密度检验

  1. 目的:检测是否存在精确操控
  2. 方法:检验驱动变量密度在断点处是否连续
  3. 解释:p > 0.05 表示无操控证据(好)

安慰剂检验

  1. 逻辑:效应应只在真实断点出现
  2. 实施:在假断点处运行RDD
  3. 解释:假断点无效应表明设计可信(好)

本节总结

在本节中,我们学习了:

  • 连续性假设的含义和威胁因素
  • 协变量平衡检验的理论和实现
  • McCrary 密度检验(检测操控)
  • 安慰剂检验(使用假断点)
  • 完整的 Python 实现和报告生成

关键教训

"RDD的可信度取决于连续性假设。虽然无法直接检验,但我们可以通过多种间接方法验证其合理性。"

下一步:在 第4节 中,我们将深入讨论带宽选择敏感性分析和其他稳健性检验


严格检验假设,确保因果推断可信!

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