欢迎光临
我们一直在努力

断点回归(RDD):自然界最完美的准实验,手把手带你用

第一部分:为什么断点回归被誉为"准实验的黄金标准"?

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分(进普通大学)

关键观察:

  • 他们的成绩几乎相同(都 ≈ 600分)
  • 他们的背景(家庭条件、学习能力等)应该也几乎相同
  • 唯一重大的区别是进了不同的大学
  • 因此,大学相关结果(如毕业后收入、就业率)的差异,必然来自于上重点大学本身的效应,而非他们的其他特征不同。

    这就是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估计结果"
    )

    结果解读:

    模型treated系数的含义
    模型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完整对比")

    方面锐断点RDD模糊断点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)

    赞(0)
    未经允许不得转载:171主机测评 » 断点回归(RDD):自然界最完美的准实验,手把手带你用
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址