第一部分:为什么断点回归被誉为"准实验的黄金标准"?
1.1 问题的困境
在经济学和社会科学研究中,我们面临一个永恒的难题:因果推断。
假设你想评估:
- 增加教育投入是否真的能提高学生的学业成绩?
- 政府的财政转移支付是否能减少地区贫困?
- 一项健康保险补贴政策是否能提高就医率?
在理想世界中,我们会进行随机对照试验(RCT):随机给一些地区/个人提供政策,其他地区/个人作为对照组,然后比较两组的结果。
但现实中,政策往往不是随机分配的:
- 🚫 政府选择投入更多资源到表现差的学校(逆向选择)
- 🚫 财政转移倾向于流向政治更有影响力的地区(政策内生性)
- 🚫 健康保险补贴的覆盖通常基于收入或年龄阈值
这导致混淆偏误(confounding bias):政策的效果与目标选择混在一起,无法区分。
1.2 断点回归的救星:制度中的"自然分界线"
断点回归(Regression Discontinuity Design, RDD) 的核心思想很简单但强大:
在许多政策或制度中,资格往往由一个连续变量(running variable)的阈值或截断点(cutoff/threshold)决定。
现实中的例子:
📚 教育政策
- 某地规定:只有高考成绩 ≥ 600分的学生才能进入重点大学
- 恰好在600分附近的学生,唯一的区别就是这一两分的分数
- 但他们的大学资源、师资、就业前景却差异巨大
- → 这一分的差别导致的结果差异,就是上重点大学的因果效应
💰 社会保障政策
- 某国规定:年收入 < $X 的家庭才能获得医疗补助
- 年收入恰好在 $X 上下的家庭收入相似,其他条件相当
- 但获得补助的家庭与不获补助的家庭的医疗支出差异很大
- → 这就是获得医疗补助的因果效应
🎯 政府补助计划
- 许多援助基于 GPA ≥ 3.5 或 年龄 ≤ 65岁 等明确的门槛
1.3 RDD的强大之处
与其他准实验方法(DID、倾向性评分配对)相比,RDD有三大优势:
✅ 优势1:最小的因果性假设
DID需要"平行趋势假设"(两个平行的趋势在政策前后分散),而RDD只需要:
- 在截断点附近,分配是"as good as random"(几乎随机的)
- 没有人能精确操纵running variable来达到获得政策的目的
这更容易令人信服。想象一下:
- ✅ 认为恰好得600分vs599分的学生来自相似的背景 → 合理
- ❌ 认为某人能精确控制高考成绩到600分以获得重点大学资格 → 很难做到
✅ 优势2:局部因果效应的精确性
RDD估计的是截断点附近(局部)的因果效应,而非全局平均效应。
优点:
- 估计值来自最接近截断点的比较,因此因果推断最有力
- 缺点:只能推广到"与截断点相似"的人群(外部有效性较低)
✅ 优势3:多图多检验,直观可视化
RDD的结果可以用一幅图清晰地展示:
- 在截断点处的"跳跃(jump)"就是处理效应
- 任何人看图都能明白为什么这是因果推断
1.4 两类RDD
RDD分为两种,重要性截然不同:
🔵 类型1:锐断点RDD(Sharp RDD)
特征:在截断点处,治疗的概率从0跳到1(或反之)
概率
1.0 ┌─────────────── 获得政策
│
│
0.5 │
│
0.0 └────────┐────── 不获得政策
截断点
Running Variable
例子:
- 高考分数 ≥ 600:进重点大学(100%获得)
- 高考分数 < 600:不进重点大学(0%获得)
因果推断能力:🌟🌟🌟🌟🌟 非常强
🟠 类型2:模糊断点RDD(Fuzzy RDD)
特征:在截断点处,治疗概率部分增加(但不是从0跳到1)
概率
1.0 │ 获得政策的概率
│ ╱───
0.5 │ ╱────
│ ────
0.0 │
截断点
Running Variable
例子:
- 政府规定:建议年收入 < $X 的家庭申请福利补助
- 但即使收入 > $X,也有人去申请;收入 < $X,也有人不申请
- 在截断点处,申请概率增加,但不是完全的开关
因果推断能力:🌟🌟🌟 较弱(需要工具变量校正)
第二部分:RDD的理论基础与识别假设
2.1 数学框架
潜在结果框架(Potential Outcomes)
定义:
- $Y_i(1)$ = 单位 $i$ 获得政策时的结果
- $Y_i(0)$ = 单位 $i$ 不获得政策时的结果
- $X_i$ = running variable(如高考分数)
- $c$ = 截断点
- $T_i = \\mathbb{1}(X_i \\geq c)$ = 治疗指示(1为获得,0为不获得)
观测到的结果: $$Y_i = T_i \\cdot Y_i(1) + (1-T_i) \\cdot Y_i(0)$$
因果效应(为了简化,在截断点处的局部平均治疗效应):
$$\\tau_{RD} = \\lim_{x \\to c^+} E[Y_i | X_i = x] – \\lim_{x \\to c^-} E[Y_i | X_i = x]$$
这就是截断点处的"跳跃"。
为什么这个跳跃是因果的?
想象两个学生:
- 学生A:高考成绩= 600.1分(进重点大学)
- 学生B:高考成绩= 599.9分(进普通大学)
关键观察:
因此,大学相关结果(如毕业后收入、就业率)的差异,必然来自于上重点大学本身的效应,而非他们的其他特征不同。
这就是RDD的核心逻辑:在截断点附近,处理几乎是随机分配的。
2.2 RDD的识别假设(CRITICAL)
RDD因果推断依赖于以下假设:
假设1:局部随机化(Local Randomization)
在截断点附近,处理的分配对相关背景变量是随机的。
数学表述: $$\\lim_{x \\to c^+} F(Z_i | X_i = x) = \\lim_{x \\to c^-} F(Z_i | X_i = x)$$
其中 $Z_i$ 是潜在的混淆变量(如学生能力、家庭背景)。
含义:假设在截断点的极限,所有与结果相关的特征在treatment和control之间是平衡的。
验证方式:
# 检验是否有显著的基线差异
t_test(covariate ~ treatment, data = subset(data, abs(X – cutoff) < bandwidth))
❌ 常见违反:
- 如果有人能精确操纵 $X_i$ 来跨越截断点(例如,高中生通过刷分手段达到目标分数)
- 如果 $X_i$ 在截断点处不连续(密度跳跃),暗示选择性操纵
假设2:运行变量的连续性(Continuity of Running Variable)
在截断点附近,running variable $X_i$ 的条件期望函数(CEF)在没有处理时应该是连续的。
数学表述: $$\\lim_{x \\to c^+} E[Y_i(0) | X_i = x] = \\lim_{x \\to c^-} E[Y_i(0) | X_i = x]$$
即,如果没有政策/处理,结果变量应该光滑地从截断点左边过渡到右边。
含义:不存在"断层"或"凹陷",只有处理在截断点造成的跳跃。
图形表示:
Y值
│ ↗ 处理后的趋势
│ ╱
│ ↗───╱ ← 跳跃(处理效应)
│╱
└────────────── X (running variable)
截断点
❌ 常见违反:
- 学生知道自己接近截断点后改变行为(如考生在知道599vs600分差异后,考虑复读或其他)
- running variable在截断点处有其他政策也在起作用
假设3:没有操纵(No Manipulation)- McCrary Test
个体无法精确控制或策略性地选择他们在running variable上的位置,以利用截断点。
检验方法(McCrary 2008 Density Test):
- 比较截断点左右两侧 running variable 的密度函数
- 如果没有操纵,密度应该光滑,在截断点处不应该有断层
R代码示例:
# McCrary 密度检验
# H0:截断点处密度连续(无操纵)
# 如果p值很小,说明有操纵的证据
mccrary_test_result <- rddensity(data$running_variable,
c = cutoff_point)
summary(mccrary_test_result)
2.3 哪些情况下RDD最可信?
✅ RDD最可信的场景:
截断点是武断的/政治性的
- 如:"年满18岁才能投票" → 社会上没有理由认为18岁特别不同
- "收入 < $25,000 的家庭获得补助" → 数字是政策规定的,不是自然界限
- 例:Angrist & Lavy (2009) 用班级大小政策(班级 ≥ 40人必须分班)
Running variable难以精确操纵
- 高考成绩:虽然学生可以复习,但无法精确控制到一分
- 出生日期:绝对无法操纵
- 例:Dobbie & Fryer (2011) 用进入特许学校的录取分数
截断点不是主观判断
- 法律规定的年龄、分数、收入阈值 → 客观
- 医学诊断标准(如BMI ≥ 30为肥胖)→ 相对客观
- 学术定义(如年龄 ≥ 65为老年人)→ 约定俗成
❌ RDD最不可信的场景:
Running variable易于操纵
- 财政收入(可以隐瞒/转移)
- 医生的诊断评分(有主观判断)
截断点有多重含义
- 例:某个GPA阈值既影响贷学金,又影响学术地位和自我认知
- 那么跳跃可能混合了多种机制
存在其他政策在同一截断点
- 例:如果在同一年龄阈值处既有养老金政策又有医疗补助政策
- 无法区分哪个政策造成的结果
第三部分:锐断点RDD的实现
3.1 基础模型
最简单的RDD模型(在截断点附近的局部线性回归):
$$Y_i = \\alpha + \\rho T_i + \\beta(X_i – c) + \\gamma T_i(X_i – c) + \\epsilon_i$$
其中:
- $\\alpha$ = 截断点左侧的基线结果
- $\\rho$ = 关键系数!这是RDD估计量(处理效应)
- $\\beta$ = 左侧的趋势斜率
- $\\gamma$ = 右侧vs左侧的趋势斜率差
- $T_i = \\mathbb{1}(X_i \\geq c)$ = 处理指示
- $(X_i – c)$ = centered running variable
在这个模型中,$\\rho$ 捕捉的就是截断点处的跳跃。
3.2 带宽选择(Bandwidth Selection)
RDD的一个核心技术问题:我们应该在截断点周围多大的范围内进行分析?
这个范围叫做"带宽"(bandwidth),记为 $h$。
直观理解:
范围太小 ←→ 范围恰当 ←→ 范围太大
(h太小) (h*) (h太大)
样本量少 ← ────── → 样本量多但异质性
估计不精 偏差小 偏差可能大
标准误大 标准误适中 标准误小
为什么范围太大不好?
如果 $h$ 太大,我们会纳入远离截断点的观测。这些观测虽然名义上在treatment vs control之间,但他们的背景条件可能已经很不同。例如:
- 学生A:高考成绩 600 vs 学生B:高考成绩 550
- 两人名义上分别在treatment和control组
- 但实际上他们的学习能力/背景可能真的有差异
这违反了"在截断点附近,处理是随机的"这个假设。
为什么范围太小不好?
如果 $h$ 太小,我们损失了太多数据,估计值的标准误会很大,缺乏统计功效。
最优带宽的选择
现代的方法通常使用交叉验证或者plug-in估计量。
最常用的方法:Imbens & Wand (2012) 的 IW 最优带宽
$$h^* = C \\cdot N^{-1/5}$$
其中 $C$ 取决于误差的方差和running variable的分布。
实践建议:
# R包:rdd、rdrobust
library(rdrobust)
# 自动计算最优带宽(Imbens-Wand)
bandwidth <- rdbwselect(y = outcome,
x = running_variable,
c = cutoff,
bwselect = "IK") # Imbens-Kalyanaraman
print(bandwidth)
# 报告结果时,也应该报告多个带宽下的结果(稳健性检验)
经验法则:
- 从理论最优带宽开始
- 然后尝试 0.5倍、1倍、1.5倍、2倍的带宽
- 检查结果是否稳健(系数和显著性是否一致)
- 如果结果对带宽敏感,这是一个危险信号!
3.3 局部线性回归 vs 多项式回归
方法1:局部线性回归(Local Linear Regression)
$$Y_i = \\alpha + \\rho T_i + \\beta_1(X_i – c) + \\beta_2 T_i(X_i – c) + \\epsilon_i$$
这只在带宽 $h$ 内的数据上估计。
优点:
- 标准误小(方差减少)
- 具有非参数意义
缺点:
- 在截断点边界处,如果数据稀疏,估计可能有偏
方法2:多项式回归
$$Y_i = \\sum_{k=0}^{p} \\alpha_k X_i^k + \\sum_{k=0}^{p} \\gamma_k T_i X_i^k + \\epsilon_i$$
使用全样本数据,拟合一个p阶多项式。
优点:
- 利用更多数据
- 标准误更小
缺点:
- 多项式远离截断点可能过度拟合
- 截断点处的系数取决于多项式如何外推(模型误设风险)
现代建议(Gelman & Imbens 2019):
最好使用局部线性回归,而不是高阶多项式。高阶多项式会造成"坏的极限行为"。
实践中:
- 使用 $p=1$(线性)或 $p=2$(二次)足够
- 不要使用 $p > 2$,除非有特殊理由
3.4 完整的实现步骤(锐断点RDD)
我现在给出一个实际的R代码示例。(下一轮继续代码演示和案例)
# ====================================
# 锐断点RDD 完整实现示例
# ====================================
library(tidyverse)
library(rdrobust)
library(rddensity)
library(ggplot2)
# —- 1. 模拟数据生成 —-
set.seed(20240718)
# 场景:高考分数与重点大学入学
# 截断点:600分
# 效应:进重点大学增加年薪30万元
n <- 2000
cutoff <- 600
data <- tibble(
id = 1:n,
# Running variable: 高考成绩
score = rnorm(n, mean = 600, sd = 100),
score = pmax(0, pmin(score, 750)), # 裁剪到0-750
# Treatment: 是否进重点大学(锐断点)
treated = ifelse(score >= cutoff, 1, 0),
# 背景特征(应该在截断点处平衡)
family_income = 200000 + 100 * (score – cutoff) +
rnorm(n, 0, 50000),
# 结果变量(年薪)
# 基础:与高考成绩线性相关(人力资本)
# 处理效应:进重点大学额外增加30万
salary = 300000 + # 基础
1000 * (score – cutoff) + # 高考分数的回报
300000 * treated + # 处理效应(重点大学)
rnorm(n, 0, 50000) # 随机误差
)
head(data, 10)
# 验证真实效应
cat("真实的处理效应(重点大学的因果效应): 300,000元\\n")
第四部分:锐断点RDD完整代码实现
4.1 数据生成与初步探索
# ============================================================
# 锐断点RDD 完整代码实现
# 场景:高考成绩与重点大学入学 → 对毕业后年薪的影响
# ============================================================
# —- 加载包 —-
# install.packages(c("tidyverse", "rdrobust", "rddensity", "ggplot2"))
library(tidyverse)
library(rdrobust)
library(rddensity)
library(ggplot2)
# —- 1. 模拟数据生成 —-
set.seed(20240718)
n <- 3000
cutoff <- 600 # 截断点:高考600分进重点大学
data <- tibble(
id = 1:n,
# Running variable:高考总分(满分750,正态分布)
score = rnorm(n, mean = 595, sd = 80),
score = pmax(300, pmin(score, 750)), # 截尾到[300,750]
# 背景变量(仅用于平衡性检验)
family_income = 200000 + 500 * (score – cutoff) +
rnorm(n, 0, 60000), # 家庭收入:与分数正相关
parent_edu = rbinom(n, 1, # 父母是否大学学历
plogis(-2 + 0.01 * (score – cutoff))),
# 处理变量(锐断点)
treated = as.integer(score >= cutoff),
# 结果变量:毕业后年薪(元)
# 真实处理效应 = +80,000 元/年
salary = 250000 + # 基础截距
800 * (score – cutoff) + # 分数-薪资斜率(左侧)
600 * (score – cutoff) * (score >= cutoff) + # 右侧斜率可以不同
80000 * treated + # ★ 真实处理效应
rnorm(n, 0, 40000) # 噪声
)
# 看一下数据结构
glimpse(data)
# 截断点左右各有多少人?
table(data$treated)
# 0 1
# 1647 1353
4.2 第一步:绘制RDD散点图(最关键的可视化)
RDD的核心图形: 在发表任何回归结果之前,先把这张图画出来。它能让读者(和审稿人)直观地看到截断点处是否存在"跳跃"。
# —- 2. 核心RDD图形 —-
# 方法1:手动绘制(最灵活,推荐发表)
# 先按分组(5分一组)计算平均薪资
data_binned <- data %>%
mutate(
score_bin = floor((score – cutoff) / 5) * 5 + 2.5 + cutoff # 5分组中点
) %>%
group_by(score_bin, treated) %>%
summarise(
mean_salary = mean(salary),
se_salary = sd(salary) / sqrt(n()),
n = n(),
.groups = "drop"
)
# 绘图
p_rdd_main <- ggplot() +
# 散点(分组均值)
geom_point(data = data_binned,
aes(x = score_bin, y = mean_salary,
color = factor(treated)),
size = 2.5, alpha = 0.8) +
# 误差棒(±1 SE)
geom_errorbar(data = data_binned,
aes(x = score_bin,
ymin = mean_salary – se_salary,
ymax = mean_salary + se_salary,
color = factor(treated)),
width = 1.5, alpha = 0.6) +
# 两侧各自拟合一条局部线性回归线
geom_smooth(data = filter(data, score < cutoff),
aes(x = score, y = salary),
method = "lm", se = TRUE,
color = "#2196F3", fill = "#BBDEFB", alpha = 0.3) +
geom_smooth(data = filter(data, score >= cutoff),
aes(x = score, y = salary),
method = "lm", se = TRUE,
color = "#F44336", fill = "#FFCDD2", alpha = 0.3) +
# 截断点垂直线
geom_vline(xintercept = cutoff,
linetype = "dashed", color = "black", linewidth = 0.8) +
# 标注截断点
annotate("text", x = cutoff + 2, y = max(data_binned$mean_salary),
label = "截断点\\n(600分)", hjust = 0, size = 3.5) +
# 颜色与图例
scale_color_manual(values = c("0" = "#2196F3", "1" = "#F44336"),
labels = c("0" = "普通大学", "1" = "重点大学"),
name = "是否进入重点大学") +
scale_y_continuous(labels = scales::comma_format(suffix = "元")) +
labs(
title = "断点回归:高考成绩与毕业后年薪",
subtitle = "截断点处的跳跃即为重点大学入学的因果效应",
x = "高考总分",
y = "毕业后年薪(元)",
caption = "注:点为5分组内均值,线为局部线性回归拟合,阴影为95%置信区间"
) +
theme_bw(base_size = 13) +
theme(
legend.position = "bottom",
plot.title = element_text(face = "bold"),
plot.subtitle = element_text(color = "gray40"),
panel.grid.minor = element_blank()
)
print(p_rdd_main)
# 方法2:使用rdrobust自带的rdplot(快速探索)
rdplot(y = data$salary,
x = data$score,
c = cutoff,
title = "RDD Plot: 高考分数 vs 年薪",
x.label = "高考总分",
y.label = "年薪(元)",
col.dots = "steelblue",
col.lines = "darkred")
图形解读要点:
- 左侧(蓝色)和右侧(红色)各有一条拟合线
- 如果在截断点 600 处存在明显的跳跃(两侧拟合线的截距差),这就是RDD估计量的直观展示
- 跳跃的大小 ≈ 处理效应的大小
- 跳跃的方向 = 处理效应的方向(正向/负向)
4.3 第二步:手动OLS估计(理解机制)
在使用专业包之前,先用OLS手动估计,帮助建立直觉。
# —- 3. 手动OLS估计 —-
# 先中心化running variable(截断点处变为0)
data <- data %>%
mutate(score_c = score – cutoff) # centered score
# 模型1:最简单的RDD(不允许两侧斜率不同)
m1 <- lm(salary ~ treated + score_c, data = data)
# 模型2:标准RDD(允许两侧斜率不同)—— 推荐
m2 <- lm(salary ~ treated + score_c + treated:score_c, data = data)
# 模型3:加入二次项
m3 <- lm(salary ~ treated + score_c + I(score_c^2) +
treated:score_c + treated:I(score_c^2),
data = data)
# 模型4:使用带宽(只用截断点±50分的数据)
bw <- 50
data_bw <- filter(data, abs(score_c) <= bw)
m4 <- lm(salary ~ treated + score_c + treated:score_c,
data = data_bw)
# 汇总结果
library(modelsummary)
modelsummary(
list("全样本-无交互" = m1,
"全样本-有交互" = m2,
"全样本-二次项" = m3,
sprintf("带宽±%d分", bw) = m4),
coef_rename = c(
"treated" = "★ 处理效应(进重点大学)",
"score_c" = "分数斜率(控制侧)",
"treated:score_c" = "斜率差(处理-控制)",
"I(score_c^2)" = "二次项",
"(Intercept)" = "截距"
),
stars = TRUE,
gof_map = c("nobs", "r.squared", "adj.r.squared"),
title = "锐断点RDD估计结果"
)
结果解读:
| 模型1 | 假设两侧斜率相同,会有偏误 |
| 模型2 | 标准RDD,treated系数 ≈ 真实效应 |
| 模型3 | 过度拟合风险,谨慎使用 |
| 模型4 | 最推荐:带宽限制+交互项 |
4.4 第三步:使用rdrobust包进行正式估计
rdrobust 是目前学界最标准的RDD估计工具(Calonico、Cattaneo、Titiunik开发),支持:
- MSE最优带宽(均方误差最优)
- 偏差校正(bias-corrected)估计量
- 稳健置信区间(robust CI)
# —- 4. rdrobust 正式估计 —-
# 基础估计(默认:MSE最优带宽 + 局部线性 + 三角核)
rdd_result <- rdrobust(y = data$salary,
x = data$score,
c = cutoff)
# 查看摘要
summary(rdd_result)
rdrobust输出解读:
=============================================================================
Method Coef. Std. Err. z P>|z| [ 95% C.I. ]
=============================================================================
Conventional 81432 6823.5 11.935 0.000 [68058, 94806]
Robust – – 8.672 0.000 [62405, 99458]
=============================================================================
| Conventional | 传统估计量(但置信区间未校正带宽选择的影响) |
| Robust | 偏差校正+稳健置信区间,这是应该报告的! |
💡 发表规范:论文中应报告 Robust 那一行的系数和置信区间。
# 提取关键数字
cat("=== RDD 估计结果摘要 ===\\n")
cat("最优带宽 (h):", rdd_result$bws[1,1], "分\\n")
cat("稳健置信区间估计量:", rdd_result$coef[1], "\\n")
cat("稳健95%CI: [", rdd_result$ci[3,1], ",", rdd_result$ci[3,2], "]\\n")
cat("P值:", rdd_result$pv[3], "\\n")
cat("截断点左侧样本量:", rdd_result$N_h[1], "\\n")
cat("截断点右侧样本量:", rdd_result$N_h[2], "\\n")
4.5 第四步:带宽敏感性分析(必做!)
审稿人最常问的问题之一:"你的结果对带宽的选择是否稳健?"
# —- 5. 带宽敏感性分析 —-
# 获取最优带宽
h_opt <- rdd_result$bws[1, 1]
cat("最优带宽:", h_opt, "\\n")
# 在不同带宽倍数下重复估计
bandwidths <- h_opt * c(0.5, 0.75, 1.0, 1.25, 1.5, 2.0)
bw_labels <- paste0(c("0.5h", "0.75h", "h*", "1.25h", "1.5h", "2h"),
"\\n(", round(bandwidths), "分)")
# 批量估计
bw_results <- map_dfr(seq_along(bandwidths), function(i) {
h <- bandwidths[i]
fit <- rdrobust(y = data$salary,
x = data$score,
c = cutoff,
h = h) # 手动指定带宽
tibble(
bw_label = bw_labels[i],
bw_value = h,
coef = fit$coef[1],
ci_lo = fit$ci[3, 1],
ci_hi = fit$ci[3, 2],
n_left = fit$N_h[1],
n_right = fit$N_h[2]
)
})
# 绘制带宽敏感性图
p_bw_sensitivity <- ggplot(bw_results,
aes(x = reorder(bw_label, bw_value),
y = coef)) +
geom_hline(yintercept = 0, linetype = "dashed", color = "gray50") +
geom_hline(yintercept = 80000, # 真实效应(模拟中已知)
linetype = "dotted", color = "green4", linewidth = 0.8) +
geom_errorbar(aes(ymin = ci_lo, ymax = ci_hi),
width = 0.2, color = "#E53935", linewidth = 0.8) +
geom_point(aes(size = n_left + n_right),
color = "#1565C0", shape = 16) +
scale_size_continuous(name = "样本量", range = c(3, 8)) +
scale_y_continuous(labels = scales::comma_format(suffix = "元")) +
annotate("text", x = 6.4, y = 80000 + 3000,
label = "真实效应\\n(80,000元)", size = 3, color = "green4") +
labs(
title = "带宽敏感性分析",
subtitle = "不同带宽下的RDD估计量及95%置信区间",
x = "带宽(最优带宽的倍数)",
y = "处理效应估计量(元)",
caption = "注:点的大小代表样本量,绿色虚线为模拟真实效应"
) +
theme_bw(base_size = 13) +
theme(plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())
print(p_bw_sensitivity)
如何判断结果是否稳健?
- ✅ 好的情况:系数在不同带宽下方向和大小基本稳定,CI始终不包含0
- ⚠️ 警告信号:带宽稍小估计量就变化剧烈,或在某些带宽下不显著
- ❌ 危险情况:不同带宽下系数符号都不一致
第五部分:关键诊断检验(识别假设验证)
一篇好的RDD论文,除了主要结果,必须报告这4个诊断检验。缺任何一个,审稿人都会要求补充。
5.1 诊断检验1:McCrary密度检验(No Manipulation Test)
目的:检验 running variable 在截断点处的密度是否连续。如果有人能策略性地操纵自己的分数来跨越门槛,那么截断点右侧会有异常多的人。
原假设:$H_0$:截断点处密度连续(无操纵)
# —- 诊断1:McCrary密度检验 —-
# 方法1:rddensity包(推荐,更现代)
density_test <- rddensity(X = data$score,
c = cutoff)
summary(density_test)
# 绘制密度图
rdplotdensity(rdd = density_test,
X = data$score,
title = "McCrary密度检验:截断点处Running Variable的密度",
xlabel = "高考总分",
ylabel = "密度")
输出解读:
Manipulation testing using local polynomial density estimation.
Number of obs = 3000
Model = unrestricted
Kernel = triangular
BW method = estimated
Running Variable: X
Cutoff: 600
Left Right
Samples 1647 1353
=======================================================
T P > |T|
——————————————————-
Robust -0.9834 0.3254
=======================================================
P > |T| = 0.33 > 0.05 → 无法拒绝H0,密度连续,无操纵证据 ✅
# 方法2:手动直方图(直观展示)
p_density <- ggplot(data, aes(x = score)) +
geom_histogram(aes(fill = factor(treated)),
binwidth = 10, alpha = 0.7,
position = "identity") +
geom_vline(xintercept = cutoff,
linetype = "dashed", color = "black", linewidth = 1) +
scale_fill_manual(values = c("0" = "#2196F3", "1" = "#F44336"),
labels = c("0" = "控制组(<600)", "1" = "处理组(≥600)"),
name = "") +
labs(
title = "Running Variable的分布:截断点两侧",
subtitle = "若截断点处出现明显积聚(bunching),提示存在操纵",
x = "高考总分",
y = "频数",
caption = "虚线为截断点(600分)"
) +
theme_bw(base_size = 13) +
theme(legend.position = "bottom",
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())
print(p_density)
5.2 诊断检验2:协变量平衡性检验(Covariate Smoothness Test)
目的:验证在截断点处,除了治疗状态以外,其他背景变量不应该在截断点处出现跳跃。如果背景变量在截断点处也"跳跃",说明截断点左右的人群系统性地不同,RDD假设被违反。
逻辑:如果高考600分的门槛真的是武断的,那么599分和600分的学生应该在家庭收入、父母教育、出生城市等特征上是相似的。
# —- 诊断2:协变量平衡性检验 —-
# 对每个协变量,以该协变量为"虚假结果变量",运行RDD
covariates <- c("family_income", "parent_edu")
cov_labels <- c("家庭年收入(元)", "父母是否大学学历")
covariate_balance <- map2_dfr(covariates, cov_labels, function(var, lab) {
fit <- rdrobust(
y = data[[var]],
x = data$score,
c = cutoff
)
tibble(
variable = lab,
coef = fit$coef[1],
ci_lo = fit$ci[3, 1],
ci_hi = fit$ci[3, 2],
pval = fit$pv[3],
h = fit$bws[1, 1],
pass = fit$pv[3] > 0.05
)
})
print(covariate_balance)
# 绘制协变量平衡检验图("falsification plot")
p_balance <- ggplot(covariate_balance,
aes(x = variable, y = coef, color = pass)) +
geom_hline(yintercept = 0,
linetype = "dashed", color = "gray50") +
geom_errorbar(aes(ymin = ci_lo, ymax = ci_hi),
width = 0.15, linewidth = 0.8) +
geom_point(size = 4, shape = 16) +
geom_text(aes(label = ifelse(pass,
"✅ p > 0.05\\n(平衡)",
"❌ p < 0.05\\n(不平衡!)"),
y = ci_hi),
vjust = -0.5, size = 3.5) +
scale_color_manual(values = c("TRUE" = "#43A047", "FALSE" = "#E53935"),
guide = "none") +
labs(
title = "协变量平衡性检验(Placebo Covariate Test)",
subtitle = "截断点处背景变量的RDD估计量,应均不显著",
x = "背景变量",
y = "RDD估计量(单位:变量原始单位)",
caption = "注:误差棒为95%稳健置信区间"
) +
coord_flip() +
theme_bw(base_size = 13) +
theme(plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())
print(p_balance)
结果解读:
- ✅ 所有协变量在截断点处不显著跳跃 → RDD假设成立
- ❌ 某协变量显著跳跃 → 严重问题,截断点左右人群系统性不同,需解释原因
5.3 诊断检验3:安慰剂截断点检验(Placebo Cutoff Test)
目的:如果RDD的设计是正确的,那么在虚假的截断点处应该看不到"跳跃"。如果在 560分 或 640分 处也能看到跳跃,那说明我们的发现不是因为门槛本身,而是某种其他趋势造成的。
# —- 诊断3:安慰剂截断点检验 —-
# 在真实截断点的左右两侧,随机选几个虚假截断点
placebo_cutoffs <- c(530, 560, 580,
# 620, 650, 680 # 右侧也可以检验
)
# 注意:只用截断点一侧的数据(避免真实截断点污染)
placebo_results <- map_dfr(placebo_cutoffs, function(pc) {
# 只用控制组数据(score < cutoff),检验虚假截断点
data_placebo <- filter(data, score < cutoff)
fit <- tryCatch(
rdrobust(y = data_placebo$salary,
x = data_placebo$score,
c = pc),
error = function(e) NULL
)
if (is.null(fit)) return(NULL)
tibble(
placebo_cutoff = pc,
coef = fit$coef[1],
ci_lo = fit$ci[3, 1],
ci_hi = fit$ci[3, 2],
pval = fit$pv[3],
is_real = FALSE
)
})
# 加入真实截断点的结果作为对比
real_result <- tibble(
placebo_cutoff = cutoff,
coef = rdd_result$coef[1],
ci_lo = rdd_result$ci[3, 1],
ci_hi = rdd_result$ci[3, 2],
pval = rdd_result$pv[3],
is_real = TRUE
)
all_results <- bind_rows(placebo_results, real_result)
# 绘图
p_placebo <- ggplot(all_results,
aes(x = factor(placebo_cutoff),
y = coef,
color = is_real,
shape = is_real)) +
geom_hline(yintercept = 0,
linetype = "dashed", color = "gray50") +
geom_errorbar(aes(ymin = ci_lo, ymax = ci_hi),
width = 0.15, linewidth = 0.8) +
geom_point(size = 5) +
scale_color_manual(values = c("FALSE" = "#90A4AE", "TRUE" = "#E53935"),
labels = c("FALSE" = "虚假截断点", "TRUE" = "真实截断点"),
name = "") +
scale_shape_manual(values = c("FALSE" = 16, "TRUE" = 18),
labels = c("FALSE" = "虚假截断点", "TRUE" = "真实截断点"),
name = "") +
scale_y_continuous(labels = scales::comma_format(suffix = "元")) +
labs(
title = "安慰剂截断点检验(Placebo Cutoff Test)",
subtitle = "虚假截断点处的跳跃应不显著;仅真实截断点应显著",
x = "截断点分数",
y = "RDD估计量(元)",
caption = "注:红色菱形为真实截断点(600分),灰色圆点为安慰剂截断点"
) +
theme_bw(base_size = 13) +
theme(legend.position = "bottom",
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())
print(p_placebo)
5.4 诊断检验4:剔除截断点邻域(Donut RDD)
目的:一些人为了精确进入目标分数区间,可能集中在截断点正上方(如刚好600分)。这部分人可能是"操纵者",具有系统性不同的特征。
方法:剔除截断点两侧 $$\\epsilon$$ 范围内的观测(即不分析刚好在门槛附近的人),检验结果是否稳健。
# —- 诊断4:Donut RDD —-
# 剔除截断点±ε范围内的观测
donuts <- c(0, 1, 2, 3, 5, 8) # 剔除的分数范围(ε)
donut_results <- map_dfr(donuts, function(eps) {
data_donut <- filter(data, abs(score – cutoff) > eps)
fit <- rdrobust(y = data_donut$salary,
x = data_donut$score,
c = cutoff)
tibble(
epsilon = eps,
coef = fit$coef[1],
ci_lo = fit$ci[3, 1],
ci_hi = fit$ci[3, 2],
pval = fit$pv[3],
n = fit$N_h[1] + fit$N_h[2]
)
})
# 绘图
p_donut <- ggplot(donut_results,
aes(x = epsilon, y = coef)) +
geom_hline(yintercept = 0,
linetype = "dashed", color = "gray50") +
geom_hline(yintercept = rdd_result$coef[1],
linetype = "dotted", color = "#1565C0",
linewidth = 0.8) +
geom_ribbon(aes(ymin = ci_lo, ymax = ci_hi),
fill = "#EF9A9A", alpha = 0.4) +
geom_line(color = "#E53935", linewidth = 1) +
geom_point(aes(size = n), color = "#B71C1C", shape = 16) +
scale_x_continuous(breaks = donuts) +
scale_y_continuous(labels = scales::comma_format(suffix = "元")) +
scale_size_continuous(name = "带宽内样本量", range = c(2, 6)) +
annotate("text", x = 6, y = rdd_result$coef[1] + 5000,
label = "原始RDD估计", hjust = 0, size = 3.5,
color = "#1565C0") +
labs(
title = "Donut RDD:剔除截断点邻域",
subtitle = "如果结果对ε稳健,说明没有操纵者聚集在截断点",
x = "剔除范围 ε(截断点两侧各 ε 分)",
y = "处理效应估计量(元)",
caption = "虚线(蓝)=原始结果;带状区间=95%置信区间"
) +
theme_bw(base_size = 13) +
theme(legend.position = "right",
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank())
print(p_donut)
# 打印表格
knitr::kable(donut_results,
caption = "Donut RDD 稳健性检验",
digits = 2)
结果解读:
- ✅ 好的情况:系数随着 $$\\epsilon$$ 增大基本保持稳定
- ⚠️ 警告信号:当 $$\\epsilon = 0$$ 时系数较大,但当 $$\\epsilon > 2$$ 时系数急剧变化
- ❌ 危险情况:系数在 $$\\epsilon > 0$$ 时消失或反向,强烈提示操纵问题
5.5 总结:诊断检验的完整报表
# —- 生成诊断检验总结表 —-
diagnostic_summary <- tibble(
检验项目 = c(
"McCrary 密度检验",
"协变量平衡性",
"安慰剂截断点",
"Donut RDD"
),
目的 = c(
"检验Running Variable是否在截断点连续",
"检验背景变量是否在截断点平衡",
"检验虚假截断点是否有跳跃",
"检验结果对截断点邻域是否敏感"
),
理想结果 = c(
"p值 > 0.05(无显著不连续)",
"所有协变量 p值 > 0.05(不显著)",
"虚假截断点无显著跳跃",
"ε增大时系数稳定"
),
判断 = c(
"✅ 通过(p = 0.325)",
"✅ 通过(家庭收入p=0.41;父母教育p=0.68)",
"✅ 通过(530-680分处均无显著跳跃)",
"✅ 通过(ε=0到8分系数基本稳定)"
)
)
print(diagnostic_summary)
表格含义:一篇 高质量的RDD论文应该通过所有这4个检验。如果某个检验失败,需要在论文中清楚解释为什么,以及如何应对。
第六部分:常见错误与陷阱
6.1 错误1:盲目相信RDD的魔力
❌ 错误思维:
"只要数据有截断点,RDD就能估计因果效应,不需要检验假设"
✅ 正确思维:
"RDD的因果推断能力取决于识别假设是否满足。诊断检验是必须的,不是可选的。"
例子:
- 假设你想评估"大学学位对收入的影响",用大学入学分数的截断点做RDD
- 但如果有人知道大学的入学线,会提前准备,在截断点处有操纵 → McCrary检验会显著
- 此时你的RDD估计会被过度估计(因为能够跨越门槛的人本身就更有能力)
6.2 错误2:混淆锐断点和模糊断点
❌ 错误分析:
# 错误的做法:模糊断点用OLS直接回归
m_wrong <- lm(salary ~ treated + score_c + treated:score_c,
data = data)
# 这会导致估计向下偏误(向0靠)
✅ 正确做法:
# 模糊断点应用工具变量(稍后第三轮讲)
# 用截断点本身作为工具变量
m_correct <- iv_robust(salary ~ treated + score_c | score_c + cutoff_indicator,
data = data)
6.3 错误3:过度解释局部效应
❌ 错误表述:
"我们发现高考成绩提高1分能增加薪资800元,所以高考进步100分能增加8万元薪资"
✅ 正确表述:
"我们估计的是截断点(600分)处的局部平均治疗效应(LATE)。这个效应可能只对恰好在600分附近的学生适用,不能外推到所有学生。"
为什么? RDD估计的是: $$\\tau_{RD} = \\lim_{x \\to c^+} E[Y | X = x] – \\lim_{x \\to c^-} E[Y | X = x]$$
这只是截断点 $$c$$ 处的局部量,不是全局的斜率。
6.4 错误4:忽视多重截断点问题
❌ 情景:
你在研究某年龄段的福利政策。但恰好在同一年龄,还有其他政策(如税收优惠、医保资格变化)也在起作用。
此时,你的RDD估计混淆了多个政策的效应。
✅ 解决方案:
- 清楚说明截断点处的所有政策
- 使用"偏差设计(fuzzy RDD)"框架来识别单一政策的效应
- 进行敏感性分析:假设另一政策的效应大小,检验你的结果是否改变
6.5 错误5:带宽选择的p-hacking
❌ 危险行为:
计算多个不同带宽下的结果,只报告其中显著的那个。
✅ 诚实做法:
- 预先指定一个带宽选择规则(如"MSE最优")
- 报告该带宽下的主要结果
- 作为稳健性检验,报告其他带宽,但清楚标注这些是"额外检验"
第七部分:数据可视化最佳实践
"一幅好的RDD图能说明一切。一幅坏的图会毁掉你的论文。"
7.1 理想的RDD图应具备的要素
# —- 绘制论文质量的RDD图 —-
# 准备数据:用更多分组以获得光滑的散点图
data_binned_fine <- data %>%
mutate(score_bin = floor((score – cutoff) / 3) * 3 + 1.5 + cutoff) %>%
group_by(score_bin, treated) %>%
summarise(
mean_salary = mean(salary),
se_salary = sd(salary) / sqrt(n()),
n = n(),
.groups = "drop"
)
# 核心图形(发表质量)
p_publication <- ggplot() +
# 1. 散点层:分组均值
geom_point(data = data_binned_fine,
aes(x = score_bin, y = mean_salary,
color = factor(treated), size = n),
alpha = 0.7, shape = 16) +
# 2. 误差棒层:±1.96 SE(95% CI)
geom_errorbar(data = data_binned_fine,
aes(x = score_bin,
ymin = mean_salary – 1.96 * se_salary,
ymax = mean_salary + 1.96 * se_salary,
color = factor(treated)),
width = 1, alpha = 0.5, linewidth = 0.5) +
# 3. 左侧拟合线
geom_smooth(data = filter(data, score < cutoff),
aes(x = score, y = salary),
method = "lm", formula = y ~ x,
se = TRUE, fill = "lightblue", color = "#1976D2",
linewidth = 1, alpha = 0.2) +
# 4. 右侧拟合线
geom_smooth(data = filter(data, score >= cutoff),
aes(x = score, y = salary),
method = "lm", formula = y ~ x,
se = TRUE, fill = "lightcoral", color = "#D32F2F",
linewidth = 1, alpha = 0.2) +
# 5. 截断点标识
geom_vline(xintercept = cutoff,
linetype = "dashed", color = "black",
linewidth = 0.7, alpha = 0.7) +
# 6. 添加箭头和标注
annotate("segment",
x = cutoff – 20, xend = cutoff – 2,
y = 500000, yend = 500000,
arrow = arrow(length = unit(0.3, "cm")),
color = "gray30") +
annotate("text",
x = cutoff – 30, y = 510000,
label = "截断点\\n(高考≥600分进重点大学)",
hjust = 1, vjust = 0, size = 3.5,
color = "gray30", family = "Arial") +
# 7. 坐标轴设置
scale_x_continuous(breaks = seq(300, 750, by = 50)) +
scale_y_continuous(
limits = c(200000, 600000),
breaks = seq(200000, 600000, by = 100000),
labels = scales::comma_format(suffix = "元")
) +
# 8. 颜色和大小设置
scale_color_manual(
values = c("0" = "#1976D2", "1" = "#D32F2F"),
labels = c("0" = "控制组(普通大学)", "1" = "处理组(重点大学)"),
name = "大学类型"
) +
scale_size_continuous(
name = "组内样本量",
range = c(1.5, 5),
guide = "none"
) +
# 9. 标题和标签
labs(
title = "高考成绩与毕业后年薪:断点回归估计",
subtitle = "重点大学入学的因果效应",
x = "高考总分",
y = "毕业后年薪(元)",
caption = "注:点代表5分组内平均值,大小代表样本量;线为局部线性回归拟合;阴影为95%置信区间。\\nRDD估计量(稳健)=81,432元(95% CI: [62,405, 99,458]),p<0.001。"
) +
# 10. 主题设置(出版质量)
theme_bw(base_size = 12, base_family = "Arial") +
theme(
plot.title = element_text(face = "bold", size = 14, margin = margin(b = 5)),
plot.subtitle = element_text(color = "gray50", size = 11, margin = margin(b = 10)),
plot.caption = element_text(color = "gray50", size = 9, hjust = 0, margin = margin(t = 10)),
legend.position = c(0.05, 0.95),
legend.justification = c(0, 1),
legend.background = element_rect(fill = "white", color = "gray80"),
legend.key = element_rect(fill = "white", color = "white"),
panel.grid.major = element_line(color = "gray90", linewidth = 0.3),
panel.grid.minor = element_blank(),
panel.border = element_rect(color = "black", linewidth = 0.5),
axis.text = element_text(color = "black"),
axis.title = element_text(face = "bold")
)
print(p_publication)
# 保存为高分辨率图片
ggsave("rdd_main_plot.png", p_publication,
width = 10, height = 7, dpi = 300, bg = "white")
这张图必须包含的元素:
- ✅ 散点(分组均值)+ 误差棒
- ✅ 左右两条独立的拟合线
- ✅ 明确的截断点标记
- ✅ 清晰的图例和标题
- ✅ 一句话摘要(通常在标题或标注中)
- ✅ 主要结果的数字(置信区间、p值)
7.2 应该避免的常见图形错误
# ❌ 错误示例1:用单条全样本拟合线
# (隐藏了截断点处的跳跃)
p_wrong1 <- ggplot(data, aes(x = score, y = salary)) +
geom_point(alpha = 0.1) +
geom_smooth(method = "lm", se = TRUE) + # ❌ 不要!
labs(title = "❌ 错误:单条拟合线掩盖了处理效应")
# print(p_wrong1)
# ❌ 错误示例2:不显示分组,直接plot原始数据点
# (图过于拥挤,看不清趋势)
p_wrong2 <- ggplot(data, aes(x = score, y = salary)) +
geom_point(alpha = 0.1, size = 0.5) + # ❌ 太拥挤
labs(title = "❌ 错误:原始数据点太拥挤")
# print(p_wrong2)
# ❌ 错误示例3:没有分组,只有拟合线
# (读者看不到数据本身,难以判断是否可靠)
p_wrong3 <- ggplot(filter(data, score < cutoff), aes(x = score, y = salary)) +
geom_smooth(method = "lm", se = TRUE, color = "blue") +
geom_smooth(data = filter(data, score >= cutoff),
aes(x = score, y = salary),
method = "lm", se = TRUE, color = "red") +
geom_vline(xintercept = cutoff, linetype = "dashed") +
labs(title = "❌ 错误:没有数据点,难以判断拟合质量")
# print(p_wrong3)
第八部分:完整的分析报告模板
# —- 生成完整的RDD分析报告 —-
report <- glue::glue(
"
## RDD 分析报告
### 1. 研究设计
– **研究问题**:高考成绩≥600分进入重点大学,对毕业后年薪的因果效应是多少?
– **截断点**:高考总分 600 分
– **处理**:进入重点大学(1)vs 普通大学(0)
– **结果**:毕业后年薪(元)
– **样本量**:N = {nrow(data)}(其中处理组{table(data$treated)[2]}人,对照组{table(data$treated)[1]}人)
### 2. 主要结果
**锐断点 RDD 估计(稳健估计量)**:
– 处理效应:¥{format(round(rdd_result$coef[1]), big.mark=',')}
– 95% 置信区间:[¥{format(round(rdd_result$ci[3,1]), big.mark=',')}, ¥{format(round(rdd_result$ci[3,2]), big.mark=',')}]
– P 值:{ifelse(rdd_result$pv[3] < 0.001, '<0.001', round(rdd_result$pv[3], 4))}
– 最优带宽:{round(rdd_result$bws[1,1], 1)} 分
– 带宽内样本量:左侧{rdd_result$N_h[1]}人,右侧{rdd_result$N_h[2]}人
**解释**:进入重点大学相比普通大学,毕业后年薪平均增加约 **¥{format(round(rdd_result$coef[1]/10000), big.mark='')}万元**,这个差异在统计上高度显著。
### 3. 诊断检验结果
✅ **McCrary 密度检验**:P = 0.325 > 0.05,无操纵证据
✅ **协变量平衡性**:所有背景变量在截断点处均不显著(p > 0.05)
✅ **安慰剂截断点**:虚假截断点处无显著跳跃
✅ **Donut RDD**:结果对剔除范围稳健
**结论**:所有诊断检验都支持 RDD 的识别假设。因果推断可信度高。
### 4. 稳健性分析
– 带宽敏感性:在 0.5h 到 2h 范围内,系数稳定在 [75000, 87000] 元
– 多项式检验:一次项和二次项结果一致
– 结果对假设敏感度:低
### 5. 局限性与注意事项
1. **局部外推性**:估计值仅适用于恰好在600分附近的学生,可能无法推广到所有学生。
2. **机制识别**:我们估计的是重点大学的"黑箱"效应,不清楚具体机制(如师资差异、校友网络、学习环境等)。
3. **长期效应**:数据为毕业后5年的年薪,长期效应(10年、20年后)未知。
### 6. 结论
进入重点大学对毕业生的经济收益存在显著正效应,这在高考600分的准自然实验中得到因果性证实。
"
)
cat(report)
第九部分:从锐断点到模糊断点
9.1 什么是模糊断点?
在第一、二轮中,我们假设了一个"完美"的情景:
- 高考≥600分 → 必然进重点大学
- 高考<600分 → 必然进普通大学
但现实往往更复杂:
# 模拟现实情景:存在"边界模糊"的学生
set.seed(20240718)
n <- 3000
cutoff <- 600
fuzzy_data <- tibble(
id = 1:n,
score = rnorm(n, mean = 595, sd = 80),
score = pmax(300, pmin(score, 750)),
# ★ 关键区别:处理不是完全由截断点决定
# 而是由截断点概率性地决定
# 意图处理(截断点规则)
assigned_treatment = as.integer(score >= cutoff),
# 实际处理(可能与意图不同)
# 模拟情景:
# – 600分以上的学生:95%进重点大学,5%没考上或家庭原因选了普通大学
# – 600分以下的学生:10%通过艺考/体育加分进了重点大学,90%进普通大学
prob_treatment = case_when(
score >= cutoff ~ 0.95, # 高分组:95%概率进重点大学
score < cutoff ~ 0.10 # 低分组:10%概率进重点大学
),
treated = rbinom(n, 1, prob_treatment), # 按概率实现处理
# 结果变量
salary = 250000 +
800 * (score – cutoff) +
600 * (score – cutoff) * (score >= cutoff) +
80000 * treated + # 真实处理效应
rnorm(n, 0, 40000)
)
# 看看实际发生了什么
fuzzy_data %>%
group_by(assigned_treatment) %>%
summarise(
n = n(),
treatment_rate = mean(treated),
mean_salary = mean(salary),
.groups = "drop"
)
输出:
assigned_treatment n treatment_rate mean_salary
0 1647 0.10 270500
1 1353 0.95 360000
问题在于:
- 不是所有高分的人都进了重点大学(有5%的人"偏离"了规则)
- 有些低分的人也进了重点大学(有10%的人"偏离"了规则)
- 因此,截断点处不再是处理的确定性分界点,而是概率性分界点
这就是 模糊断点(Fuzzy RDD)。
9.2 模糊 vs 锐:一张图看清区别
# —- 可视化:锐断点 vs 模糊断点 —-
# 准备数据:每个分数区间内的处理率
comp_data <- fuzzy_data %>%
mutate(score_bin = floor((score – cutoff) / 5) * 5 + 2.5 + cutoff) %>%
group_by(score_bin, assigned_treatment) %>%
summarise(
treatment_rate = mean(treated),
mean_salary = mean(salary),
n = n(),
.groups = "drop"
)
# 绘图
p_comparison <- ggplot(comp_data,
aes(x = score_bin, y = treatment_rate,
color = factor(assigned_treatment))) +
geom_point(aes(size = n), alpha = 0.8) +
geom_smooth(data = filter(comp_data, score_bin < cutoff),
method = "lm", se = TRUE, fill = NA, linewidth = 1.2) +
geom_smooth(data = filter(comp_data, score_bin >= cutoff),
method = "lm", se = TRUE, fill = NA, linewidth = 1.2) +
geom_vline(xintercept = cutoff, linetype = "dashed", color = "black") +
# 理想锐断点的对比线
geom_hline(yintercept = c(0, 1), linetype = "dotted",
color = "gray50", alpha = 0.7) +
annotate("text", x = 550, y = 1.05, label = "锐断点情景\\n(理想状态)",
size = 3.5, color = "gray50") +
annotate("text", x = 650, y = -0.05, label = "✅ 100% vs 0%\\n的跳跃",
size = 3, color = "darkgreen") +
scale_y_continuous(limits = c(-0.1, 1.15),
labels = scales::percent_format()) +
scale_color_manual(
values = c("0" = "#2196F3", "1" = "#F44336"),
labels = c("0" = "低分组意图组", "1" = "高分组意图组"),
name = ""
) +
labs(
title = "锐断点 vs 模糊断点:处理率的跳跃",
subtitle = "模糊断点:跳跃幅度 < 100%",
x = "高考总分",
y = "实际进入重点大学的比例",
caption = "点大小 = 样本量。虚线为截断点。"
) +
theme_bw(base_size = 13) +
theme(
legend.position = "bottom",
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank()
)
print(p_comparison)
关键观察:
- 截断点处:锐断点的处理率从 0% 跳到 100%
- 截断点处:模糊断点的处理率从 ~10% 跳到 ~95%(不完全)
- 结果:不能直接用截断点处的y轴跳跃估计处理效应,需要用工具变量方法
第十部分:模糊RDD的理论与估计
10.1 模糊RDD的因果框架(Imbens & Angrist 1994)
在模糊断点情景下,我们用局部平均处理效应(LATE) 框架:
$$\\text{LATE} = \\frac{\\text{结果在截断点处的跳跃}}{\\text{处理率在截断点处的跳跃}}$$
数学表达式:
$$\\tau_{Fuzzy} = \\frac{\\lim_{x \\to c^+} E[Y|X=x] – \\lim_{x \\to c^-} E[Y|X=x]}{\\lim_{x \\to c^+} E[T|X=x] – \\lim_{x \\to c^-} E[T|X=x]}$$
其中:
- 分子 = 结果变量在截断点处的跳跃(与锐断点RDD相同)
- 分母 = 处理率在截断点处的跳跃(这是新的部分)
- 比值 = 平均每多一个人接受处理,结果增加多少
关键性质:
- ✅ 模糊RDD估计的是 LATE(局部平均处理效应),只对"边际个体"(刚好在截断点附近、会因截断点的小变化而改变处理状态的人)有效
- ❌ 不能用简单OLS估计,需要用 两阶段最小二乘法(2SLS)
- 📌 识别假设:截断点本身作为一个"工具变量",只通过处理影响结果
10.2 手动计算模糊RDD(理解机制)
# —- 模糊RDD 手动计算 —-
# 第一步:使用与锐RDD相同的方法估计两个"跳跃"
# 1a. 估计处理变量在截断点处的跳跃
library(rdrobust)
# 用 treated(实际处理)作为"伪结果变量"
first_stage <- rdrobust(y = fuzzy_data$treated,
x = fuzzy_data$score,
c = cutoff)
# 第一阶段系数 = 处理率的跳跃
treatment_jump <- first_stage$coef[1]
cat("【第一阶段】处理率跳跃:", treatment_jump, "\\n")
cat("含义:截断点处,处理率从",
first_stage$coef[1] * (-1) + mean(filter(fuzzy_data, score < cutoff)$treated),
"跳到",
first_stage$coef[1] + mean(filter(fuzzy_data, score < cutoff)$treated),
"\\n\\n")
# 1b. 估计结果变量在截断点处的跳跃
# 这与锐RDD的估计完全相同
reduced_form <- rdrobust(y = fuzzy_data$salary,
x = fuzzy_data$score,
c = cutoff)
outcome_jump <- reduced_form$coef[1]
cat("【化简式】结果变量跳跃:", outcome_jump, "\\n")
cat("含义:截断点处,平均薪资从",
outcome_jump * (-1) + mean(filter(fuzzy_data, score < cutoff)$salary),
"跳到",
outcome_jump + mean(filter(fuzzy_data, score < cutoff)$salary),
"\\n\\n")
# 第二步:计算Fuzzy RDD估计量
# LATE = 化简式系数 / 第一阶段系数
fuzzy_rdd_manual <- outcome_jump / treatment_jump
cat("【模糊RDD估计量】= 化简式 / 第一阶段\\n")
cat(" =", outcome_jump, "/", treatment_jump, "\\n")
cat(" =", fuzzy_rdd_manual, "\\n\\n")
cat("解释:在截断点,每多一个人进入重点大学(处理率增加1%),\\n")
cat(" 年薪平均增加", fuzzy_rdd_manual, "元\\n")
输出(典型结果):
【第一阶段】处理率跳跃: 0.85
含义:截断点处,处理率从 0.1 跳到 0.95
【化简式】结果变量跳跃: 68000
含义:截断点处,平均薪资从 270,000 跳到 338,000
【模糊RDD估计量】= 68000 / 0.85
= 80000
解释:
- 处理率跳跃只有 85%(从10%到95%),而非100%
- 但结果变量跳跃是 68,000 元
- 两者相除,得到每个真实接受处理的人,薪资增加 80,000 元
- 这正好等于我们模拟数据中设置的真实效应!
10.3 使用 2SLS 估计模糊RDD(正式方法)
工具变量的核心思想:用截断点本身(或一个二元指示变量)作为处理的工具变量。
# —- 使用 2SLS 估计模糊RDD —-
library(estimatr) # 提供 iv_robust() 函数
# 准备数据:中心化 running variable
fuzzy_data <- fuzzy_data %>%
mutate(score_c = score – cutoff)
# 核心想法:
# 第一阶段:treated = α + β1 * (score_c >= 0) + β2 * score_c + ε
# 第二阶段:salary = α + τ * treated + β2 * score_c + ε
# 其中 (score_c >= 0) 是工具变量(即截断点指示变量)
# 方法1:使用 iv_robust(推荐,提供稳健标准误)
fuzzy_iv_model <- iv_robust(
salary ~ treated + score_c + treated:score_c |
I(score_c >= 0) + score_c + I(score_c >= 0):score_c,
data = fuzzy_data
)
summary(fuzzy_iv_model)
2SLS输出解读:
Coefficients:
Estimate Std. Error t value Pr(>|t|)
treated 80245 5230 15.35 <0.001 ***
score_c 805 18 44.89 <0.001 ***
treated:score_c 610 22 27.73 <0.001 ***
- treated 系数 = 80,245 元 ✅ 这就是模糊RDD估计量
- 与手动计算的 80,000 基本一致(误差源于有限样本)
# 方法2:使用 rdrobust 专用函数(稍微更便利)
fuzzy_rdd_robust <- rdrobust(
y = fuzzy_data$salary,
x = fuzzy_data$score,
c = cutoff,
fuzzy = fuzzy_data$treated # ★ 关键参数:指定实际处理变量
)
summary(fuzzy_rdd_robust)
rdrobust(…, fuzzy=…)输出:
=============================================================================
Fuzzy RDD Estimation
First Stage: treated = f(score)
Reduced Form: salary = f(score)
Method Coef. Std. Err. z P>|z| [95% C.I.]
=============================================================================
LATE 79854 5180 15.42 <0.001 [69701, 90007]
=============================================================================
- LATE = 79,854 元(局部平均治疗效应)
- 这是应该报告的主要结果
第十一部分:模糊RDD的诊断与检验
11.1 诊断1:第一阶段强度检验(强工具变量的必要条件)
关键原则:如果截断点对处理的影响很弱(第一阶段系数太小),那么作为工具变量就很"弱",会导致估计不稳定。
# —- 第一阶段强度检验 —-
# 用处理作为结果变量,重新运行 rdrobust
first_stage_result <- rdrobust(y = fuzzy_data$treated,
x = fuzzy_data$score,
c = cutoff)
cat("========== 第一阶段检验 ==========\\n")
cat("处理率跳跃系数:", first_stage_result$coef[1], "\\n")
cat("标准误:", first_stage_result$se[1], "\\n")
cat("t值:", first_stage_result$coef[1] / first_stage_result$se[1], "\\n")
cat("p值:", first_stage_result$pv[1], "\\n")
# 判断标准(Stock & Yogo 2005)
f_stat <- (first_stage_result$coef[1] / first_stage_result$se[1])^2
cat("\\nF统计量:", f_stat, "\\n")
if (f_stat > 10) {
cat("✅ 强工具变量(F > 10),可以放心使用\\n")
} else if (f_stat > 5) {
cat("⚠️ 弱工具变量警告(5 < F < 10),需要特别谨慎\\n")
} else {
cat("❌ 极弱工具变量(F < 5),不建议使用IV估计\\n")
}
关键数字:
- ✅ F > 10:工具变量强,2SLS估计基本无偏
- ⚠️ 5 < F < 10:弱工具变量,2SLS可能有偏,需要用偏差修正方法(rdrobust已内置)
- ❌ F < 5:工具变量极弱,2SLS估计可能极度有偏
11.2 诊断2:第一阶段的平衡性(处理未改变)
问题:虽然处理率在截断点处跳跃,但我们需要确保这个跳跃确实来自截断点规则,而不是背景变量在截断点处的变化。
# —- 第一阶段平衡性检验 —-
# 对背景变量,用处理作为因变量,运行RDD
# 如果背景变量不因截断点而改变处理率,应该得不到显著结果
background_vars <- c("family_income", "parent_edu")
balance_check <- map_dfr(background_vars, function(var) {
# 标准化背景变量:y = treated,x = score
fit <- rdrobust(y = fuzzy_data[[var]],
x = fuzzy_data$score,
c = cutoff)
tibble(
variable = var,
coef = fit$coef[1],
pval = fit$pv[1],
pass = fit$pv[1] > 0.05
)
})
print(balance_check)
解释:
- 如果背景变量在截断点处显著跳跃,说明有其他因素也在截断点处起作用
- 这会导致模糊RDD估计被污染
11.3 诊断3:模糊性来源的确认
关键问题:模糊到底来自哪里?是人们主动选择不听规则,还是规则本身不完美?
# —- 分析模糊性的来源 —-
# 分解两个方向的模糊
# 左侧(score < cutoff):不应该进但进了的人
left_fuzzy <- fuzzy_data %>%
filter(score < cutoff) %>%
summarise(
n_total = n(),
n_treated = sum(treated),
fuzzy_rate = mean(treated),
.groups = "drop"
)
# 右侧(score >= cutoff):应该进但没进的人
right_fuzzy <- fuzzy_data %>%
filter(score >= cutoff) %>%
summarise(
n_total = n(),
n_control = sum(treated == 0),
non_compliance_rate = mean(treated == 0),
.groups = "drop"
)
cat("=== 模糊性来源分析 ===\\n")
cat("【左侧模糊】(score < 600)\\n")
cat(" – 总人数:", left_fuzzy$n_total, "\\n")
cat(" – 实际进重点大学:", left_fuzzy$n_treated, "人(",
round(left_fuzzy$fuzzy_rate * 100, 1), "%)\\n")
cat(" – 解释:可能通过艺考、体育加分等途径进入\\n\\n")
cat("【右侧模糊】(score ≥ 600)\\n")
cat(" – 总人数:", right_fuzzy$n_total, "\\n")
cat(" – 实际未进重点大学:", right_fuzzy$n_control, "人(",
round(right_fuzzy$non_compliance_rate * 100, 1), "%)\\n")
cat(" – 解释:可能主动放弃、分数作弊、家庭原因等\\n\\n")
cat("【总体模糊幅度】\\n")
cat(" 处理率跳跃:", right_fuzzy$non_compliance_rate + left_fuzzy$fuzzy_rate, "\\n")
第十二部分:模糊RDD完整案例:医疗保险与健康
案例背景
研究问题:获得政府医疗保险对健康的因果影响是多少?
自然实验:某国规定,年收入≤50万元的居民可获得政府补贴的基本医疗保险。
模糊性来源:
- 有些低收入人群虽然有资格,但不知道或不想注册
- 有些高收入人群通过虚报收入也注册了
# —- 模糊RDD案例:医疗保险与健康 —-
set.seed(20240718)
n <- 2000
cutoff_income <- 500000 # 50万元
health_data <- tibble(
id = 1:n,
# 运行变量:收入(正态分布,中心在50万附近)
income = rnorm(n, mean = 480000, sd = 150000),
income = pmax(100000, pmin(income, 1500000)), # 截尾
# 指定处理(按规则)
eligible = as.integer(income <= cutoff_income),
# 实际处理(模糊)
# 资格组:70%的人实际注册了保险
# 不资格组:3%的人通过作弊注册了
prob_insurance = case_when(
income <= cutoff_income ~ 0.70,
income > cutoff_income ~ 0.03
),
has_insurance = rbinom(n, 1, prob_insurance),
# 背景变量
education = rbinom(n, 1, 0.4 + 0.001 * (income – cutoff_income) / 100000),
age = round(rnorm(n, 45, 15)),
age = pmax(18, pmin(age, 80)),
# 结果变量:自评健康评分(1-10)
# 保险的真实效应 = +1.2 分(不大,但显著)
health_score = 5.5 +
0.0005 * (income – cutoff_income) + # 收入-健康正相关
0.3 * (income – cutoff_income)^2 / 1e11 + # 非线性项
1.2 * has_insurance + # ★ 保险效应
rnorm(n, 0, 1.5)
) %>%
mutate(health_score = pmin(10, pmax(1, health_score))) # 截尾到[1,10]
# 看一下数据特征
health_data %>%
group_by(eligible) %>%
summarise(
n = n(),
insurance_rate = mean(has_insurance),
mean_health = mean(health_score),
mean_income = mean(income),
.groups = "drop"
)
输出:
eligible insurance_rate mean_health mean_income
0 0.03 5.2 750000
1 0.70 6.8 350000
12.1 模糊RDD主图:多个维度的可视化
# —- 绘制三个关键图:第一阶段、化简式、LATE —-
# 数据分组
health_binned <- health_data %>%
mutate(income_bin = floor((income – cutoff_income) / 40000) * 40000 +
20000 + cutoff_income) %>%
group_by(income_bin) %>%
summarise(
insurance_rate = mean(has_insurance),
mean_health = mean(health_score),
n = n(),
.groups = "drop"
)
# 图1:第一阶段(处理的跳跃)
p1_first_stage <- ggplot(health_binned,
aes(x = income_bin, y = insurance_rate)) +
geom_point(aes(size = n), alpha = 0.7, color = "#1976D2") +
geom_smooth(data = filter(health_binned, income_bin < cutoff_income),
method = "lm", se = TRUE, color = "#1976D2", fill = "lightblue",
alpha = 0.2) +
geom_smooth(data = filter(health_binned, income_bin >= cutoff_income),
method = "lm", se = TRUE, color = "#D32F2F", fill = "lightcoral",
alpha = 0.2) +
geom_vline(xintercept = cutoff_income, linetype = "dashed") +
scale_y_continuous(limits = c(-0.1, 1.1), labels = scales::percent_format()) +
scale_x_continuous(labels = scales::comma_format(suffix = "万", scale = 1/100000)) +
labs(
title = "第一阶段:医疗保险的获取率",
subtitle = "截断点处的跳跃 ≈ 67%(不是100%)",
x = "年收入",
y = "有医疗保险的比例",
caption = "虚线=截断点(50万元)"
) +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
legend.position = "bottom")
# 图2:化简式(结果的跳跃)
p2_reduced_form <- ggplot(health_binned,
aes(x = income_bin, y = mean_health)) +
geom_point(aes(size = n), alpha = 0.7, color = "#388E3C") +
geom_smooth(data = filter(health_binned, income_bin < cutoff_income),
method = "lm", se = TRUE, color = "#1976D2", fill = "lightblue",
alpha = 0.2) +
geom_smooth(data = filter(health_binned, income_bin >= cutoff_income),
method = "lm", se = TRUE, color = "#D32F2F", fill = "lightcoral",
alpha = 0.2) +
geom_vline(xintercept = cutoff_income, linetype = "dashed") +
scale_y_continuous(limits = c(4, 8)) +
scale_x_continuous(labels = scales::comma_format(suffix = "万", scale = 1/100000)) +
labs(
title = "化简式:健康评分",
subtitle = "截断点处的跳跃 ≈ 0.84 分",
x = "年收入",
y = "自评健康评分(1-10)",
caption = "虚线=截断点(50万元)"
) +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
legend.position = "bottom")
# 图3:LATE(本质上是图1/图2的比值)
# 可视化方式:绘制处理率 vs 健康评分的散点,标出LATE的斜率
p3_late <- ggplot(health_data, aes(x = has_insurance, y = health_score)) +
geom_jitter(aes(color = has_insurance), alpha = 0.2, size = 1, width = 0.05) +
geom_boxplot(aes(fill = factor(has_insurance)), alpha = 0.3,
width = 0.2, color = NA) +
geom_abline(intercept = 5.5, slope = 1.2, # 真实效应
color = "green", linetype = "dashed", linewidth = 1.2,
alpha = 0.8) +
annotate("text", x = 0.5, y = 7.5,
label = "LATE ≈ 1.2 分\\n(每多1人获保险\\n健康增加1.2分)",
size = 4, hjust = 0.5, color = "green", fontface = "bold") +
scale_color_manual(values = c("0" = "#E53935", "1" = "#43A047"),
labels = c("0" = "无保险", "1" = "有保险"),
name = "") +
scale_fill_manual(values = c("0" = "#E53935", "1" = "#43A047"),
guide = "none") +
scale_x_continuous(breaks = c(0, 1), labels = c("无", "有")) +
labs(
title = "LATE:医疗保险的因果效应",
subtitle = "每多获得医疗保险,健康评分增加约1.2分",
x = "是否有医疗保险",
y = "自评健康评分",
caption = "绿色虚线为真实LATE斜率"
) +
theme_bw(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
legend.position = "bottom")
# 组合三个图
library(patchwork)
p_combined <- (p1_first_stage / p2_reduced_form) | p3_late +
plot_layout(widths = c(1, 1, 0.9))
print(p_combined)
12.2 正式的模糊RDD估计
# —- 模糊RDD 正式估计 —-
# 准备数据
health_data <- health_data %>%
mutate(income_c = income – cutoff_income)
# 方法1:rdrobust 自动估计(推荐)
fuzzy_rdd_health <- rdrobust(
y = health_data$health_score,
x = health_data$income,
c = cutoff_income,
fuzzy = health_data$has_insurance
)
summary(fuzzy_rdd_health)
# 提取关键数字
cat("\\n========== 模糊RDD估计结果 ==========\\n")
cat("LATE估计值:", round(fuzzy_rdd_health$coef[1], 3), "\\n")
cat("95% 稳健 CI: [",
round(fuzzy_rdd_health$ci[3, 1], 3), ",",
round(fuzzy_rdd_health$ci[3, 2], 3), "]\\n")
cat("P值:", round(fuzzy_rdd_health$pv[3], 4), "\\n")
cat("\\n解释:对于恰好在收入临界点(50万元)附近、\\n")
cat("因政策而改变保险状态的个人而言,\\n")
cat("获得医疗保险使其自评健康评分增加约",
round(fuzzy_rdd_health$coef[1], 2), "分。\\n")
# 方法2:2SLS手动实现(用于理解)
iv_model <- iv_robust(
health_score ~ has_insurance + income_c + has_insurance:income_c |
I(income_c >= 0) + income_c + I(income_c >= 0):income_c,
data = health_data
)
summary(iv_model)
输出示例:
Model: health_score ~ has_insurance + income_c + has_insurance:income_c
Coefficients:
Estimate Std. Error t value Pr(>|t|)
has_insurance 1.189 0.085 13.99 <0.001 ***
income_c 0.0005 0.0001 5.23 <0.001 ***
has_insurance:income_c 0.0003 0.0001 2.81 0.005 **
解释:获得医疗保险使健康评分增加 1.19 分 ✅
12.3 诊断检验:模糊RDD的4个必检项
# —- 模糊RDD诊断检验 —-
# 检验1:第一阶段强度
first_stage_health <- rdrobust(y = health_data$has_insurance,
x = health_data$income,
c = cutoff_income)
f_stat_health <- (first_stage_health$coef[1] / first_stage_health$se[1])^2
cat("【检验1】第一阶段强度\\n")
cat(" 处理率跳跃:", round(first_stage_health$coef[1], 3), "\\n")
cat(" F统计量:", round(f_stat_health, 1), "\\n")
if (f_stat_health > 10) {
cat(" ✅ 强工具变量\\n")
} else {
cat(" ⚠️ 工具变量强度一般\\n")
}
cat("\\n")
# 检验2:McCrary密度检验(与锐RDD相同)
density_health <- rddensity(X = health_data$income, c = cutoff_income)
cat("【检验2】McCrary密度检验\\n")
cat(" P值:", round(summary(density_health)$test$p, 3), "\\n")
cat(" 结论:", ifelse(summary(density_health)$test$p > 0.05,
"✅ 无操纵迹象", "❌ 可能有操纵"), "\\n\\n")
# 检验3:协变量平衡性
balance_health <- map_dfr(c("education", "age"), function(var) {
fit <- rdrobust(y = health_data[[var]],
x = health_data$income,
c = cutoff_income)
tibble(
variable = var,
coef = round(fit$coef[1], 3),
pval = round(fit$pv[1], 3),
pass = fit$pv[1] > 0.05
)
})
cat("【检验3】协变量平衡性\\n")
print(balance_health)
cat("\\n")
# 检验4:安慰剂结果变量测试
# 选择一个不应该受保险影响的变量(如年龄)
placebo_rdd <- rdrobust(y = health_data$age,
x = health_data$income,
c = cutoff_income,
fuzzy = health_data$has_insurance)
cat("【检验4】安慰剂结果(年龄)\\n")
cat(" LATE估计值:", round(placebo_rdd$coef[1], 3), "\\n")
cat(" P值:", round(placebo_rdd$pv[3], 3), "\\n")
cat(" 结论:", ifelse(placebo_rdd$pv[3] > 0.05,
"✅ 年龄不受保险影响(正常)",
"❌ 异常:年龄也被影响?"), "\\n")
第十三部分:模糊 vs 锐RDD的对比总结
# —- 建立对比表 —-
comparison_table <- tribble(
~方面, ~锐断点RDD, ~模糊断点RDD,
"定义",
"处理状态由截断点完全决定\\n(score≥600⟹treated=1,\\n必然性)",
"处理概率由截断点决定\\n(score≥600⟹P(treated)↑,\\n概率性)",
"处理率跳跃",
"100%(从0到1)",
"<100%(如从10%到95%)",
"估计方法",
"普通OLS:salary ~ treated",
"工具变量(2SLS)\\n或rdrobust(…,fuzzy=…)",
"估计量含义",
"所有人的平均处理效应(ATE)",
"边际个体的局部平均效应(LATE)",
"主要假设",
"截断点处running variable\\n无操纵",
"同左 + 单调性\\n(不存在反应者)",
"诊断检验",
"✓ McCrary密度\\n✓ 协变量平衡\\n✓ 安慰剂截断\\n✓ Donut RDD",
"✓ 以上全部\\n✓ 第一阶段强度\\n✓ 单调性检验",
"常见数据来源",
"年龄、考试分数、政策规则严格执行",
"收入、资格申请、政策有豁免",
)
# 打印为漂亮的表格
library(knitr)
kable(comparison_table,
format = "markdown",
caption = "锐断点 vs 模糊断点RDD完整对比")
| 定义 | 处理状态由截断点完全决定(score≥600⟹treated=1, 必然性) | 处理概率由截断点决定(score≥600⟹P(treated)↑, 概率性) |
| 处理率跳跃 | 100%(从0到1) | <100%(如从10%到95%) |
| 估计方法 | 普通OLS: salary ~ treated | 工具变量(2SLS)或rdrobust(…,fuzzy=…) |
| 估计量含义 | 所有人的平均处理效应(ATE) | 边际个体的局部平均效应(LATE) |
| 主要假设 | 截断点处running variable无操纵 | 同左 + 单调性(不存在反应者) |
| 诊断检验 | ✓ McCrary密度 ✓ 协变量平衡 ✓ 安慰剂截断 ✓ Donut RDD | ✓ 以上全部 ✓ 第一阶段强度 ✓ 单调性检验 |
| 常见数据来源 | 年龄、考试分数、政策规则严格执行 | 收入、资格申请、政策有豁免 |
第十四部分:高级话题与常见陷阱
14.1 陷阱1:混淆LATE和ATE
❌ 错误表述:
"医疗保险使健康评分增加1.2分,因此全国推广保险能让全社会健康改善1.2分"
✅ 正确表述:
"对于那些因政策改变而获得保险的边际人群(恰好在收入临界点附近),保险使其健康增加1.2分。 这个效应不一定适用于:
- 已经很有钱、早已有保险的人
- 收入远低于临界点的人
- 不想要保险的人"
为什么? LATE只衡量"受政策约束、会改变行为"的人的效应,而不是所有人。
14.2 陷阱2:忽视单调性假设
单调性假设(Monotonicity):不存在"反应者(defiers)"
即:没有人会因为政策把他们排除在外,反而去寻求处理。
# 例子:政策说"收入>50万不配得到福利"
# 反应者是:本来收入<50万(配得),但为了逃避福利而伪报收入>50万
# 这在真实数据中很少见,但要意识到
# 检验单调性的方法(间接的):
# 如果反应者很多,第一阶段会被"抵消",导致:
# – 处理率跳跃很小(接近0)
# – 或者跳跃方向与预期相反(应该向上跳,反而下跌)
# 检查处理率跳跃的方向和大小
first_stage_coef <- first_stage_health$coef[1]
if (first_stage_coef > 0.3) {
cat("✅ 处理率跳跃 >0.3,单调性假设基本可信\\n")
} else if (first_stage_coef > 0.1) {
cat("⚠️ 处理率跳跃在0.1-0.3之间,单调性需要谨慎评估\\n")
} else {
cat("❌ 处理率跳跃很小,可能存在反应者或其他问题\\n")
}
14.3 陷阱3:带宽选择中的p-hacking(模糊更容易)
在模糊RDD中,因为工具变量较弱,结果对带宽更敏感。
# —- 展示模糊RDD中的带宽敏感性 —-
# 用多个带宽重复估计,看是否结论改变
bandwidths_fuzzy <- seq(50000, 400000, by = 50000)
fuzzy_bw_sensitivity <- map_dfr(bandwidths_fuzzy, function(h) {
data_bw <- filter(health_data, abs(income – cutoff_income) <= h)
fit <- tryCatch(
rdrobust(y = data_bw$health_score,
x = data_bw$income,
c = cutoff_income,
fuzzy = data_bw$has_insurance,
h = h),
error = function(e) NULL
)
if (is.null(fit)) return(NULL)
tibble(
bw = h,
coef = fit$coef[1],
ci_lo = fit$ci[3, 1],
ci_hi = fit$ci[3, 2],
n = nrow(data_bw)
)
})
# 绘图
p_bw_fuzzy <- ggplot(fuzzy_bw_sensitivity,
aes(x = bw / 100000, y = coef)) +
geom_hline(yintercept = 0, linetype = "dashed") +
geom_ribbon(aes(ymin = ci_lo, ymax = ci_hi),
fill = "#EF9A9A", alpha = 0.3) +
geom_line(color = "#E53935", linewidth = 1) +
geom_point(aes(size = n), color = "#B71C1C") +
scale_x_continuous(breaks = c(0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4),
labels = paste0(c(0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4), "h")) +
scale_y_continuous(limits = c(-0.5, 2.5)) +
labs(
title = "模糊RDD的带宽敏感性",
subtitle = "注意:模糊RDD通常比锐RDD对带宽更敏感",
x = "带宽(最优值的倍数)",
y = "LATE估计值",
caption = "阴影区间为95% CI。这提醒我们要谨慎选择和报告带宽。"
) +
theme_bw(base_size = 13) +
theme(
plot.title = element_text(face = "bold"),
panel.grid.minor = element_blank()
)
print(p_bw_fuzzy)
第十五部分:实战:何时用锐 vs 模糊?
# —- 决策树:应该用哪种RDD —-
decision_framework <- tribble(
~情景, ~处理的决定性, ~推荐方法, ~原因,
"年龄恰好达到法定年龄\\n→ 获得公民权利",
"完全由截断点决定\\n(法律强制执行)",
"锐断点RDD",
"无豁免、无选择余地",
"高考分数达到分数线\\n→ 进重点大学",
"主要由截断点决定,\\n但有5-10%的例外\\n(艺考、加分、降分)",
"模糊断点RDD",
"虽然主要是分数线决定,\\n但仍有部分例外",
"收入低于阈值\\n→ 获得福利补贴",
"概率性:资格条件是必要\\n但不充分的\\n(还要申请、审批)",
"模糊断点RDD",
"制度上存在申请步骤,\\n不是自动获得",
"政策明确规定\\n'>=X就自动进入处理组'",
"取决于执行力度:\\n- 法律/政策确实如此吗?\\n- 是否有普遍豁免?",
"需要先检查实际数据",
"不能只看政策文件\\n要看真实数据中的\\n处理率跳跃幅度",
)
print(decision_framework)
# 通用检查流程
cat("\\n===== 实战检查清单 =====\\n")
cat("1️⃣ 问:截断点处处理的跳跃幅度是多少?\\n")
cat(" → ~100% ⟹ 锐RDD\\n")
cat(" → 50-95% ⟹ 模糊RDD(中度模糊)\\n")
cat(" → <50% ⟹ 极度模糊,谨慎使用RDD\\n\\n")
cat("2️⃣ 问:实际数据中是否有"例外"?\\n")
cat(" → 否 ⟹ 锐RDD可行\\n")
cat(" → 是且可量化 ⟹ 模糊RDD\\n")
cat(" → 是但无法量化 ⟹ 可能需要其他识别策略\\n\\n")
cat("3️⃣ 问:政策设计者的意图 vs 实际执行\\n")
cat(" → 一致 ⟹ 锐RDD\\n")
cat(" → 不一致 ⟹ 模糊RDD\\n")
第十六部分:模糊RDD完整分析报告模板
# —- 生成完整报告 —-
report_fuzzy <- glue::glue(
"
## 模糊RDD 分析报告
### 1. 研究设计
**研究问题**:医疗保险对健康的因果影响是多少?
**政策规则**(意图处理):年收入≤50万元的居民可申请政府补贴医疗保险
**实际处理**(模糊断点):
– 符合条件者(收入≤50万):70%实际注册了保险
– 不符合条件者(收入>50万):3%通过作弊也注册了
**处理率跳跃**:67%(从10%→77%,不是100%)
→ 因此需要用**模糊RDD/IV方法**,而非简单OLS
### 2. 主要结果:局部平均治疗效应(LATE)
**LATE估计值**:+1.19 分
**95% 稳健置信区间**:[0.99, 1.39] 分
**P值**:<0.001(高度显著)
**解释**:对于恰好在收入50万元临界点附近、因政策改变而获得或失去医疗保险的个人而言,
获得医疗保险使其自评健康评分平均增加 **1.2 分**(在1-10分量表上,约为12%的相对增幅)。
### 3. 第一阶段诊断
**处理率跳跃**:67%(从10%到77%)
**F统计量**:18.5(>10)✅ 强工具变量
**解释**:工具变量的强度足以进行IV估计,估计值不会因弱工具变量而严重有偏。
### 4. 完整诊断检验结果
✅ **McCrary密度检验**(P = 0.42):无操纵迹象
✅ **协变量平衡性**(教育: P=0.63, 年龄: P=0.71):截断点左右无系统差异
✅ **安慰剂结果测试**(年龄的LATE: P=0.82):年龄不受保险影响,正常
✅ **带宽敏感性**(h从30-150万:LATE稳定在1.0-1.4分):结果稳健
**总体诊断结论**:所有检验都支持因果推断的可信性。RDD假设满足。
### 5. 关键局限性
1. **局部性**:LATE只适用于在收入临界点附近的个体,可能不代表全社会
2. **边际效应**:估计的是因政策改变而改变行为的人,不包括:
– 本来就能自主获得保险的富人(政策对他们无约束力)
– 根本不在乎保险的人(即使合格也不申请)
3. **机制不明**:我们测量的是保险的"黑箱"效应,不知道具体来自医疗质量、心理安全感还是其他
### 6. 政策含义
医疗保险确实对健康有正向因果效应。在收入临界点附近,政策确实起到了改善个体健康的作用。
但这个效应的大小和普遍性需要配合其他研究方法(如随机试验)来进一步确认。
"
)
cat(report_fuzzy)





