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)
问题:个体能够精确地操控驱动变量,使自己刚好越过断点。
例子:
- 考试作弊:学生知道600分是断点,想办法作弊到刚好600分
- 选举舞弊:候选人操纵选票,使得票率刚好超过50%
- 企业游说:企业游说政府,使自己的规模刚好低于监管阈值
后果:
- 断点右侧的个体系统性地不同于左侧(选择性偏差)
- 潜在结果在断点处可能跳跃(违反连续性)
如何检测:
- 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 检验的实现
步骤:
- 将驱动变量分成小区间(bins)
- 计算每个区间的频数(构建直方图)
- 对断点左右两侧分别拟合平滑曲线(核密度估计或局部线性)
- 检验左右两侧在断点处的密度跳跃
检验统计量:
其中:
- :断点右侧密度的估计值
- :断点左侧密度的估计值
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)处,不应该有跳跃
实施:
- 选择一个假断点 (远离真实断点 )
- 在假断点处运行RDD
- 检验是否有显著效应
期望结果:
- : 假断点处无效应()
- 如果拒绝 → 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
)关键要点
连续性假设
- 核心:潜在结果在断点处连续(平滑)
- 不可检验:涉及反事实,无法直接观察
- 间接验证:通过协变量平衡、密度检验等
协变量平衡检验
- 逻辑:如果处理准随机,协变量应平衡
- 实施:对每个协变量运行RDD,检验跳跃
- 解释:p > 0.05 表示平衡(好)
McCrary 密度检验
- 目的:检测是否存在精确操控
- 方法:检验驱动变量密度在断点处是否连续
- 解释:p > 0.05 表示无操控证据(好)
安慰剂检验
- 逻辑:效应应只在真实断点出现
- 实施:在假断点处运行RDD
- 解释:假断点无效应表明设计可信(好)
本节总结
在本节中,我们学习了:
- 连续性假设的含义和威胁因素
- 协变量平衡检验的理论和实现
- McCrary 密度检验(检测操控)
- 安慰剂检验(使用假断点)
- 完整的 Python 实现和报告生成
关键教训:
"RDD的可信度取决于连续性假设。虽然无法直接检验,但我们可以通过多种间接方法验证其合理性。"
下一步:在 第4节 中,我们将深入讨论带宽选择、敏感性分析和其他稳健性检验。
严格检验假设,确保因果推断可信!