1 先读这一页:哪些是 R,哪些不是 R

这份文档不调用原项目中的任何 .R.py 脚本。每个模型都在下面的代码块中重新写公式、重新读取处理后样本、重新估计。

项目并非全部由 R 完成:

  • R:主样本 ITT/IV、平衡检验、RDD、姓名与窗口敏感性、下一次定期选举、制度分层、动态路径、排位形状、机制诊断、设计型随机化推断。
  • Python:NumPyro 贝叶斯分层模型、贝叶斯派生收敛诊断,以及部分州级资料下载/标准化。
  • 人工工作:姓名匹配抽样、州议会晋升与提案案例核验。这一部分按当前安排暂缓,不能因为代码跑通就写成“已人工确认”。

本 Rmd 默认重估所有非模拟型 R 回归。两类耗时模拟默认关闭:

  • run_contest_permutation = FALSE:旧版场次内均匀置换,只是辅助诊断,不是正式制度 RI。
  • run_design_ri = FALSE:正式按选举日期重抽 26 字母表的 RI。先把 ri_reps 设为 20 做冒烟测试;正式稿设为 1000。
coverage <- data.table(
  module = c(
    "主一阶段、ITT、IV", "随机化平衡", "性别/种族异质性",
    "额外因果诊断", "关键子样本稳健性", "姓名和时间窗口",
    "作者当届效应复现", "近胜 RDD", "下一次定期选举",
    "制度分类分层", "1—8 年动态路径", "第一位/第二位排位形状",
    "正式制度异质性", "处理后机制诊断", "设计型 RI",
    "贝叶斯分层模型", "姓名及州级案例人工核验"
  ),
  engine = c(rep("R", 15), "Python", "人工 + R"),
  this_document = c(rep("直接重估", 14), "参数开启后直接重估", "另附 Python 单元格文件", "暂缓")
)
show_result(coverage)
module engine this_document
主一阶段、ITT、IV R 直接重估
随机化平衡 R 直接重估
性别/种族异质性 R 直接重估
额外因果诊断 R 直接重估
关键子样本稳健性 R 直接重估
姓名和时间窗口 R 直接重估
作者当届效应复现 R 直接重估
近胜 RDD R 直接重估
下一次定期选举 R 直接重估
制度分类分层 R 直接重估
1—8 年动态路径 R 直接重估
第一位/第二位排位形状 R 直接重估
正式制度异质性 R 直接重估
处理后机制诊断 R 直接重估
设计型 RI R 参数开启后直接重估
贝叶斯分层模型 Python 另附 Python 单元格文件
姓名及州级案例人工核验 人工 + R 暂缓

2 变量字典

主分析样本是一行一个“首次出现、非现任、随机排位竞选中的候选人”。核心变量如下。

variable_dictionary <- data.table(
  variable = c(
    "first_position", "current_win", "run_again_4yr", "same_local_4yr",
    "same_office_4yr", "future_win_4yr", "new_voteshare", "ballot_order",
    "n_cands", "year", "co", "office2", "random_contest_id", "exact_date",
    "female", "white", "name_length"
  ),
  role = c(
    "处理变量/工具变量 Z", "一阶段因变量、IV 内生变量 D",
    rep("长期结果 Y", 4), "当届得票结果/处理后机制变量", "实际选票位次",
    rep("固定效应", 4), "聚类层级/竞选场次", "共同随机字母表层级",
    rep("预定候选人特征", 3)
  ),
  definition = c(
    "候选人实际排在第一位=1,否则=0",
    "候选人在当届竞选中胜出=1,否则=0",
    "基准选举后 48 个月内在同县再次出现=1",
    "48 个月内在同一地方辖区再次参选=1",
    "48 个月内竞选同一职位=1",
    "48 个月内至少有一次未来胜选=1",
    "候选人的当届合并得票份额",
    "1=第一位、2=第二位,以此类推",
    "该场候选人数", "基准选举年份", "县代码", "职位大类",
    "完整竞选场次编号", "精确选举日期",
    "女性姓名分类=1", "白人姓名分类=1", "标准化姓名字符串长度"
  )
)
show_result(variable_dictionary)
variable role definition
first_position 处理变量/工具变量 Z 候选人实际排在第一位=1,否则=0
current_win 一阶段因变量、IV 内生变量 D 候选人在当届竞选中胜出=1,否则=0
run_again_4yr 长期结果 Y 基准选举后 48 个月内在同县再次出现=1
same_local_4yr 长期结果 Y 48 个月内在同一地方辖区再次参选=1
same_office_4yr 长期结果 Y 48 个月内竞选同一职位=1
future_win_4yr 长期结果 Y 48 个月内至少有一次未来胜选=1
new_voteshare 当届得票结果/处理后机制变量 候选人的当届合并得票份额
ballot_order 实际选票位次 1=第一位、2=第二位,以此类推
n_cands 固定效应 该场候选人数
year 固定效应 基准选举年份
co 固定效应 县代码
office2 固定效应 职位大类
random_contest_id 聚类层级/竞选场次 完整竞选场次编号
exact_date 共同随机字母表层级 精确选举日期
female 预定候选人特征 女性姓名分类=1
white 预定候选人特征 白人姓名分类=1
name_length 预定候选人特征 标准化姓名字符串长度
sample_audit <- data.table(
  item = c("候选人数", "竞选场次数", "选举日期数", "年份起点", "年份终点", "第一位置比例"),
  value = c(
    nrow(main), uniqueN(main$random_contest_id), uniqueN(main$exact_date),
    min(main$year), max(main$year), mean(main$first_position)
  )
)
show_result(sample_audit)
item value
候选人数 37870.00000
竞选场次数 16213.00000
选举日期数 62.00000
年份起点 1999.00000
年份终点 2020.00000
第一位置比例 0.25004

3 主识别方程

令候选人为 \(i\),竞选场次为 \(c\)。所有主模型都吸收候选人数、年份、县和职位大类固定效应,并把标准误聚类到竞选场次:

一阶段:

\[D_{ic}=\pi Z_{ic}+\alpha_{n(c)}+\lambda_{year(c)}+\gamma_{county(c)}+\delta_{office(c)}+u_{ic}.\]

这里 \(Z\)first_position\(D\)current_win\(\pi\) 回答“随机排第一使当届胜选概率提高多少”。

约化式 ITT:

\[Y_{ic}=\beta Z_{ic}+\alpha_{n(c)}+\lambda_{year(c)}+\gamma_{county(c)}+\delta_{office(c)}+\varepsilon_{ic}.\]

\(\beta\) 是被随机分到第一位对长期结果的总效应,单位是概率点。例如 0.022 等于 2.2 个百分点。

IV/LATE:

\[Y_{ic}=\tau \widehat D_{ic}+\text{同一组固定效应}+e_{ic},\qquad D_{ic}\leftarrow Z_{ic}.\]

\(\tau\) 是“当选状态被随机第一位置改变的候选人”中的局部平均处理效应。它比 ITT 依赖更多假设,尤其是排除限制。

3.1 一阶段、4 个 ITT、4 个 IV

outcomes <- c(
  run_again_4yr = "4年内再次参选",
  same_local_4yr = "4年内在同一辖区参选",
  same_office_4yr = "4年内竞选同一职位",
  future_win_4yr = "4年内未来获胜"
)

first_stage <- feols(
  as.formula(paste0("current_win ~ first_position | ", fe_rhs)),
  data = main, cluster = ~random_contest_id, notes = FALSE
)
main_results <- list(extract_fixest(
  first_stage, "first_position", "当前当选", "一阶段:随机第一→当前当选"
))
itt_models <- list(); iv_models <- list()

for (y in names(outcomes)) {
  itt_models[[y]] <- feols(
    as.formula(paste0(y, " ~ first_position | ", fe_rhs)),
    data = main, cluster = ~random_contest_id, notes = FALSE
  )
  main_results[[length(main_results) + 1L]] <- extract_fixest(
    itt_models[[y]], "first_position", outcomes[[y]], "ITT:随机第一→长期结果"
  )

  iv_models[[y]] <- feols(
    as.formula(paste0(y, " ~ 1 | ", fe_rhs, " | current_win ~ first_position")),
    data = main, cluster = ~random_contest_id, notes = FALSE
  )
  iv_term <- names(coef(iv_models[[y]]))[1]
  main_results[[length(main_results) + 1L]] <- extract_fixest(
    iv_models[[y]], iv_term, outcomes[[y]], "IV/LATE:被随机排位改变的当选→长期结果"
  )
}
main_table <- rbindlist(main_results, fill = TRUE)
first_stage_F <- (coef(first_stage)[["first_position"]] / se(first_stage)[["first_position"]])^2
main_table[, first_stage_F := first_stage_F]
show_result(main_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n first_stage_F
一阶段:随机第一→当前当选 当前当选 first_position 0.04900 0.00616 0.03693 0.06107 0.00000 37870 63.30993
ITT:随机第一→长期结果 4年内再次参选 first_position 0.02216 0.00514 0.01209 0.03223 0.00002 37870 63.30993
IV/LATE:被随机排位改变的当选→长期结果 4年内再次参选 fit_current_win 0.45226 0.10506 0.24634 0.65819 0.00002 37870 63.30993
ITT:随机第一→长期结果 4年内在同一辖区参选 first_position 0.02059 0.00487 0.01104 0.03013 0.00002 37870 63.30993
IV/LATE:被随机排位改变的当选→长期结果 4年内在同一辖区参选 fit_current_win 0.42014 0.09883 0.22643 0.61386 0.00002 37870 63.30993
ITT:随机第一→长期结果 4年内竞选同一职位 first_position 0.01206 0.00427 0.00368 0.02043 0.00480 37870 63.30993
IV/LATE:被随机排位改变的当选→长期结果 4年内竞选同一职位 fit_current_win 0.24604 0.08589 0.07769 0.41439 0.00418 37870 63.30993
ITT:随机第一→长期结果 4年内未来获胜 first_position 0.01464 0.00421 0.00639 0.02288 0.00051 37870 63.30993
IV/LATE:被随机排位改变的当选→长期结果 4年内未来获胜 fit_current_win 0.29867 0.08181 0.13833 0.45902 0.00026 37870 63.30993
headline <- coef(itt_models[["run_again_4yr"]])[["first_position"]]
cat(sprintf(
  "\n**读法:** `run_again_4yr` 的 ITT 为 %.6f,即随机排在第一位使四年内再次参选概率提高 **%.3f 个百分点**。一阶段 F=%.2f。\n",
  headline, 100 * headline, first_stage_F
))

读法: run_again_4yr 的 ITT 为 0.022162,即随机排在第一位使四年内再次参选概率提高 2.216 个百分点。一阶段 F=63.31。

3.2 与项目保存表逐项核对

这一步只把刚才“现场重估”的数值与旧输出对照;模型估计本身不依赖旧表。

saved_main <- fread(file.path(saved_table_dir, "07_随机排位_一阶段_ITT_IV结果.csv"))
compare_main <- merge(
  main_table[, .(estimand, outcome, new_estimate = estimate)],
  saved_main[, .(estimand, outcome, saved_estimate = estimate)],
  by = c("estimand", "outcome"), all = TRUE
)
compare_main[, difference := new_estimate - saved_estimate]
compare_main[, status := fifelse(abs(difference) < 1e-10, "PASS", "CHECK")]
show_result(compare_main)
estimand outcome new_estimate saved_estimate difference status
ITT:随机第一→长期结果 4年内再次参选 0.02216 0.02216 0 PASS
ITT:随机第一→长期结果 4年内在同一辖区参选 0.02059 0.02059 0 PASS
ITT:随机第一→长期结果 4年内未来获胜 0.01464 0.01464 0 PASS
ITT:随机第一→长期结果 4年内竞选同一职位 0.01206 0.01206 0 PASS
IV/LATE:被随机排位改变的当选→长期结果 4年内再次参选 0.45226 0.45226 0 PASS
IV/LATE:被随机排位改变的当选→长期结果 4年内在同一辖区参选 0.42014 0.42014 0 PASS
IV/LATE:被随机排位改变的当选→长期结果 4年内未来获胜 0.29867 0.29867 0 PASS
IV/LATE:被随机排位改变的当选→长期结果 4年内竞选同一职位 0.24604 0.24604 0 PASS
一阶段:随机第一→当前当选 当前当选 0.04900 0.04900 0 PASS

4 随机化平衡和性别/种族异质性

平衡检验用与 ITT 相同的固定效应和聚类方法,但把预定特征当作因变量。如果排位近似随机,first_position 不应系统预测这些变量。

balance_vars <- c(female = "女性", white = "白人", name_length = "标准化姓名长度")
balance_table <- rbindlist(lapply(names(balance_vars), function(v) {
  d <- main[!is.na(get(v))]
  fit <- feols(
    as.formula(paste0(v, " ~ first_position | ", fe_rhs)),
    data = d, cluster = ~random_contest_id, notes = FALSE
  )
  extract_fixest(fit, "first_position", balance_vars[[v]], "随机化平衡")
}))
show_result(balance_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n
随机化平衡 女性 first_position -0.00402 0.00584 -0.01547 0.00742 0.49115 37459
随机化平衡 白人 first_position -0.00328 0.00496 -0.01300 0.00645 0.50915 36773
随机化平衡 标准化姓名长度 first_position -0.00134 0.03754 -0.07492 0.07225 0.97157 37870

分组点估计只能描述不同组结果;正式差异检验使用交互项:

\[Y=\beta_0 Z+\beta_1 G+\beta_2(Z\times G)+FE+\varepsilon.\]

其中 \(\beta_2\) 才是两组处理效应差的检验。

subgroups <- list(
  "全部首次候选人" = rep(TRUE, nrow(main)),
  "白人候选人" = main$white == 1,
  "非白人候选人" = main$white == 0,
  "女性候选人" = main$female == 1,
  "男性候选人" = main$female == 0
)
heterogeneity <- list()
for (g in names(subgroups)) {
  d <- main[which(subgroups[[g]] %in% TRUE)]
  if (nrow(d) < 500) next
  for (y in c("current_win", "run_again_4yr")) {
    fit <- feols(
      as.formula(paste0(y, " ~ first_position | ", fe_rhs)),
      data = d, cluster = ~random_contest_id, notes = FALSE
    )
    row <- extract_fixest(
      fit, "first_position",
      ifelse(y == "current_win", "当前当选", "4年内再次参选"),
      ifelse(y == "current_win", "一阶段", "ITT")
    )
    row[, subgroup := g]
    heterogeneity[[length(heterogeneity) + 1L]] <- row
  }
}
for (moderator in c("white", "female")) {
  d <- main[!is.na(get(moderator))]
  for (y in c("current_win", "run_again_4yr")) {
    fit <- feols(
      as.formula(paste0(y, " ~ first_position * ", moderator, " | ", fe_rhs)),
      data = d, cluster = ~random_contest_id, notes = FALSE
    )
    term <- paste0("first_position:", moderator)
    row <- extract_fixest(
      fit, term, ifelse(y == "current_win", "当前当选", "4年内再次参选"),
      "组间效应差"
    )
    row[, subgroup := ifelse(
      moderator == "white", "白人效应减非白人效应", "女性效应减男性效应"
    )]
    heterogeneity[[length(heterogeneity) + 1L]] <- row
  }
}
heterogeneity_table <- rbindlist(heterogeneity, fill = TRUE)
show_result(heterogeneity_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n subgroup
一阶段 当前当选 first_position 0.04900 0.00616 0.03693 0.06107 0.00000 37870 全部首次候选人
ITT 4年内再次参选 first_position 0.02216 0.00514 0.01209 0.03223 0.00002 37870 全部首次候选人
一阶段 当前当选 first_position 0.04155 0.00702 0.02779 0.05532 0.00000 28370 白人候选人
ITT 4年内再次参选 first_position 0.01999 0.00589 0.00845 0.03153 0.00069 28370 白人候选人
一阶段 当前当选 first_position 0.07521 0.01273 0.05026 0.10016 0.00000 8402 非白人候选人
ITT 4年内再次参选 first_position 0.02401 0.01140 0.00166 0.04635 0.03529 8402 非白人候选人
一阶段 当前当选 first_position 0.05740 0.01037 0.03708 0.07772 0.00000 13150 女性候选人
ITT 4年内再次参选 first_position 0.01836 0.00862 0.00146 0.03526 0.03328 13150 女性候选人
一阶段 当前当选 first_position 0.04287 0.00732 0.02853 0.05722 0.00000 24309 男性候选人
ITT 4年内再次参选 first_position 0.02418 0.00650 0.01144 0.03692 0.00020 24309 男性候选人
组间效应差 当前当选 first_position:white -0.02571 0.01355 -0.05227 0.00085 0.05782 36773 白人效应减非白人效应
组间效应差 4年内再次参选 first_position:white -0.00468 0.01213 -0.02845 0.01910 0.69992 36773 白人效应减非白人效应
组间效应差 当前当选 first_position:female 0.01997 0.01176 -0.00308 0.04302 0.08956 37459 女性效应减男性效应
组间效应差 4年内再次参选 first_position:female -0.00575 0.01026 -0.02586 0.01435 0.57477 37459 女性效应减男性效应

5 额外因果诊断与稳健性

同场固定效应模型只比较同一竞选内部的候选人。落选者诊断会按处理后的 current_win 选样,因此只能作为排除限制的描述性检查,不能当作新的因果估计。

diagnostics <- list()
for (y in c("current_win", "run_again_4yr")) {
  fit <- feols(
    as.formula(paste0(y, " ~ first_position | random_contest_id")),
    data = main, cluster = ~random_contest_id, notes = FALSE
  )
  diagnostics[[length(diagnostics) + 1L]] <- extract_fixest(
    fit, "first_position", ifelse(y == "current_win", "当前当选", "4年内再次参选"),
    "同场竞选固定效应"
  )
}
loser_fit <- feols(
  as.formula(paste0("run_again_4yr ~ first_position | ", fe_rhs)),
  data = main[current_win == 0], cluster = ~random_contest_id, notes = FALSE
)
diagnostics[[length(diagnostics) + 1L]] <- extract_fixest(
  loser_fit, "first_position", "4年内再次参选", "仅当前落选者(处理后选样;非正式)"
)
date_fit <- feols(
  as.formula(paste0("run_again_4yr ~ first_position | ", fe_rhs)),
  data = main, cluster = ~exact_date, notes = FALSE
)
diagnostics[[length(diagnostics) + 1L]] <- extract_fixest(
  date_fit, "first_position", "4年内再次参选", "仅按精确日期聚类"
)
twoway_fit <- feols(
  as.formula(paste0("run_again_4yr ~ first_position | ", fe_rhs)),
  data = main, cluster = ~random_contest_id + exact_date, notes = FALSE
)
diagnostics[[length(diagnostics) + 1L]] <- extract_fixest(
  twoway_fit, "first_position", "4年内再次参选", "竞选场次+精确日期双向聚类"
)
diagnostic_table <- rbindlist(diagnostics, fill = TRUE)
show_result(diagnostic_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n
同场竞选固定效应 当前当选 first_position 0.04775 0.00777 0.03251 0.06298 0.00000 31687
同场竞选固定效应 4年内再次参选 first_position 0.02376 0.00620 0.01160 0.03592 0.00013 31687
仅当前落选者(处理后选样;非正式) 4年内再次参选 first_position 0.00792 0.00569 -0.00323 0.01907 0.16388 26136
仅按精确日期聚类 4年内再次参选 first_position 0.02216 0.00425 0.01383 0.03050 0.00000 37870
竞选场次+精确日期双向聚类 4年内再次参选 first_position 0.02216 0.00425 0.01383 0.03050 0.00000 37870
robustness_samples <- list(
  "标准主样本" = rep(TRUE, nrow(main)),
  "仅两候选人竞选" = main$n_cands == 2,
  "三名以上候选人" = main$n_cands >= 3,
  "总统大选同期" = as.character(main$electiming3) == "Presidential",
  "中期选举同期" = as.character(main$electiming3) == "Midterm",
  "错峰地方选举" = as.character(main$electiming3) == "Off-cycle",
  "1999—2010" = main$year <= 2010,
  "2011—2020" = main$year >= 2011,
  "至少8年观察前史(2003—2020)" = main$year >= 2003,
  "剔除同票匹配风险" = main$tied_share_in_contest == 0,
  "剔除可观察同名碰撞" = main$possible_name_collision == 0
)
robustness <- rbindlist(lapply(names(robustness_samples), function(label) {
  d <- main[which(robustness_samples[[label]] %in% TRUE)]
  fit <- feols(
    as.formula(paste0("run_again_4yr ~ first_position | ", fe_rhs)),
    data = d, cluster = ~random_contest_id, notes = FALSE
  )
  row <- extract_fixest(fit, "first_position", "4年内再次参选", "ITT 稳健性")
  row[, `:=`(sample = label, contests = uniqueN(d$random_contest_id))]
  row
}), fill = TRUE)
show_result(robustness)
estimand outcome term estimate std_error lower_95 upper_95 p_value n sample contests
ITT 稳健性 4年内再次参选 first_position 0.02216 0.00514 0.01209 0.03223 0.00002 37870 标准主样本 16213
ITT 稳健性 4年内再次参选 first_position 0.01013 0.01050 -0.01045 0.03072 0.33471 5560 仅两候选人竞选 4524
ITT 稳健性 4年内再次参选 first_position 0.02438 0.00588 0.01285 0.03591 0.00003 32310 三名以上候选人 11689
ITT 稳健性 4年内再次参选 first_position 0.02434 0.00871 0.00726 0.04142 0.00524 13251 总统大选同期 5752
ITT 稳健性 4年内再次参选 first_position 0.01784 0.00946 -0.00071 0.03638 0.05951 11167 中期选举同期 4814
ITT 稳健性 4年内再次参选 first_position 0.02178 0.00863 0.00486 0.03870 0.01168 13450 错峰地方选举 5647
ITT 稳健性 4年内再次参选 first_position 0.02105 0.00693 0.00747 0.03463 0.00239 21299 1999—2010 8755
ITT 稳健性 4年内再次参选 first_position 0.02326 0.00769 0.00820 0.03833 0.00249 16571 2011—2020 7458
ITT 稳健性 4年内再次参选 first_position 0.02253 0.00572 0.01132 0.03375 0.00008 30235 至少8年观察前史(2003—2020) 13243
ITT 稳健性 4年内再次参选 first_position 0.02238 0.00515 0.01229 0.03247 0.00001 37752 剔除同票匹配风险 16191
ITT 稳健性 4年内再次参选 first_position 0.02217 0.00513 0.01211 0.03222 0.00002 37776 剔除可观察同名碰撞 16180

6 姓名规则与时间窗口

这组模型的回归式与主 ITT 完全相同,改变的是结果变量的姓名匹配规则或随访窗口。

name_specs <- list(
  list("主结果:完整姓名+县,4年", extended, "run_again_4yr"),
  list("宽松:姓+首个名字+县,4年;剔除歧义键", extended[core_key_ambiguous == 0], "run_again_4yr_relaxed_county"),
  list("完整姓名全州,4年;剔除跨县同名", extended[state_name_ambiguous == 0], "run_again_4yr_exact_state"),
  list("完整姓名+县,2年", extended, "run_again_2yr_exact_county"),
  list("完整姓名+县,8年;基线不晚于2016", extended[year <= 2016], "run_again_8yr_exact_county")
)
name_sensitivity <- rbindlist(lapply(name_specs, function(spec) {
  label <- spec[[1]]; d <- spec[[2]]; y <- spec[[3]]
  fit <- feols(
    as.formula(paste0(y, " ~ first_position | ", fe_rhs)),
    data = d, cluster = ~random_contest_id, notes = FALSE
  )
  extract_fixest(fit, "first_position", y, label)
}), fill = TRUE)
show_result(name_sensitivity)
estimand outcome term estimate std_error lower_95 upper_95 p_value n
主结果:完整姓名+县,4年 run_again_4yr first_position 0.02216 0.00514 0.01209 0.03223 0.00002 37870
宽松:姓+首个名字+县,4年;剔除歧义键 run_again_4yr_relaxed_county first_position 0.01980 0.00547 0.00908 0.03052 0.00030 37665
完整姓名全州,4年;剔除跨县同名 run_again_4yr_exact_state first_position 0.02356 0.00559 0.01259 0.03452 0.00003 33816
完整姓名+县,2年 run_again_2yr_exact_county first_position -0.00226 0.00354 -0.00919 0.00468 0.52394 37870
完整姓名+县,8年;基线不晚于2016 run_again_8yr_exact_county first_position 0.01793 0.00615 0.00588 0.02997 0.00354 30945

7 作者当届选票顺序效应独立复现

这是用作者复制数据先复现“第一位置对当届得票份额/胜选概率”的效果。公式仍吸收候选人数、年份、县和职位类别固定效应,标准误聚类到作者的竞选编号 new_raceid

replication <- as.data.table(readRDS(file.path(raw_dir, "ceda_replication_ballot_order.rds")))
published <- replication[n_cands > 1 & incumb == "N" & !is.na(first)]
published_models <- list(
  new_voteshare = feols(
    new_voteshare ~ first | n_cands + year + co + office2,
    data = published, cluster = ~new_raceid, notes = FALSE
  ),
  win = feols(
    win ~ first | n_cands + year + co + office2,
    data = published, cluster = ~new_raceid, notes = FALSE
  )
)
published_table <- rbindlist(list(
  extract_fixest(published_models$new_voteshare, "first", "当届得票份额", "作者模型复现"),
  extract_fixest(published_models$win, "first", "当届胜选概率", "作者模型复现")
))
show_result(published_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n
作者模型复现 当届得票份额 first 0.01341 0.00119 0.01107 0.01574 0 60330
作者模型复现 当届胜选概率 first 0.04667 0.00509 0.03670 0.05664 0 58917

8 近胜 RDD:首次当选的可行性基线

RDD 样本限定为首次参选、两候选人、单席位、非现任。margin 是两候选人得票份额减 0.5;won=1 表示跨过胜选断点。每个带宽分别估计:

\[Y=\alpha+\tau won+\beta_1 margin+\beta_2(won\times margin)+\varepsilon.\]

\(\tau\) 是断点处由落选跨到当选的跳跃。标准误按 contest_id 聚类并使用 HC1 修正。它是“当选效果”的辅助设计,不是随机选票顺序主设计。

cluster_lm <- function(model, cluster, term = "won") {
  ct <- lmtest::coeftest(model, vcov. = sandwich::vcovCL(model, cluster = cluster, type = "HC1"))
  data.table(
    estimate = unname(ct[term, 1]), std_error = unname(ct[term, 2]),
    p_value = unname(ct[term, 4]), n = nobs(model)
  )
}
rdd_outcomes <- c(
  run_again_4yr = "4年内再次参选", same_local_4yr = "4年内同一辖区参选",
  future_win_4yr = "4年内未来获胜", run_again_8yr = "8年内再次参选"
)
bandwidths <- c(0.02, 0.05, 0.10)
rdd_results <- list()
for (y in names(rdd_outcomes)) for (bw in bandwidths) {
  max_year <- if (y == "run_again_8yr") 2016 else 2020
  d <- rd[year <= max_year & abs(margin) <= bw & !is.na(get(y))]
  fit <- lm(as.formula(paste0(y, " ~ won + margin + won:margin")), data = d)
  row <- cluster_lm(fit, d$contest_id)
  row[, `:=`(
    outcome = rdd_outcomes[[y]], bandwidth = bw,
    lower_95 = estimate - 1.96 * std_error,
    upper_95 = estimate + 1.96 * std_error,
    control_mean = mean(d[won == 0, get(y)]), contests = uniqueN(d$contest_id)
  )]
  rdd_results[[length(rdd_results) + 1L]] <- row
}
rdd_table <- rbindlist(rdd_results, fill = TRUE)
show_result(rdd_table)
estimate std_error p_value n outcome bandwidth lower_95 upper_95 control_mean contests
0.13481 0.07412 0.06954 524 4年内再次参选 0.02 -0.01048 0.28009 0.16842 421
0.14816 0.04724 0.00175 1248 4年内再次参选 0.05 0.05556 0.24075 0.17647 998
0.17472 0.03410 0.00000 2501 4年内再次参选 0.10 0.10789 0.24156 0.14585 2003
0.12406 0.07209 0.08588 524 4年内同一辖区参选 0.02 -0.01724 0.26536 0.12982 421
0.14032 0.04494 0.00184 1248 4年内同一辖区参选 0.05 0.05223 0.22840 0.13575 998
0.16742 0.03241 0.00000 2501 4年内同一辖区参选 0.10 0.10390 0.23094 0.10975 2003
0.19764 0.06509 0.00251 524 4年内未来获胜 0.02 0.07006 0.32522 0.09474 421
0.15818 0.04214 0.00018 1248 4年内未来获胜 0.05 0.07558 0.24078 0.08446 998
0.16544 0.02968 0.00000 2501 4年内未来获胜 0.10 0.10727 0.22362 0.06065 2003
0.24907 0.09548 0.00947 367 8年内再次参选 0.02 0.06192 0.43621 0.19289 296
0.20066 0.05968 0.00081 913 8年内再次参选 0.05 0.08368 0.31764 0.20534 722
0.20223 0.04257 0.00000 1845 8年内再次参选 0.10 0.11880 0.28565 0.18375 1467

RDD 平衡变量不在最小 RDD 文件中,因此下面用 row_id 回接原始 CEDA,只重构原脚本定义的 name_lengthdesignation_missing

raw <- as.data.table(readRDS(file.path(raw_dir, "ceda_allcandidates_1995-2024_fixed.rds")))
raw[, row_id := .I]
raw[, name_norm_rdd := normalize_letters(paste(first, last))]
raw[, `:=`(
  name_length = nchar(name_norm_rdd),
  designation_missing = as.integer(is.na(baldesig) | trimws(baldesig) == "")
)]
rd_balance <- copy(rd)
rd_balance[raw[, .(row_id, name_length, designation_missing)], on = "row_id",
           `:=`(name_length = i.name_length, designation_missing = i.designation_missing)]
rdd_balance_results <- list()
for (v in c("name_length", "designation_missing")) for (bw in bandwidths) {
  d <- rd_balance[abs(margin) <= bw & !is.na(get(v))]
  fit <- lm(as.formula(paste0(v, " ~ won + margin + won:margin")), data = d)
  row <- cluster_lm(fit, d$contest_id)
  row[, `:=`(variable = v, bandwidth = bw, contests = uniqueN(d$contest_id))]
  rdd_balance_results[[length(rdd_balance_results) + 1L]] <- row
}
show_result(rbindlist(rdd_balance_results, fill = TRUE))
estimate std_error p_value n variable bandwidth contests
-0.36433 0.58885 0.53637 524 name_length 0.02 421
0.05134 0.36899 0.88936 1248 name_length 0.05 998
0.10789 0.24827 0.66393 2501 name_length 0.10 2003
0.00000 0.00000 NaN 524 designation_missing 0.02 421
0.00000 0.00000 NaN 1248 designation_missing 0.05 998
0.00000 0.00000 NaN 2501 designation_missing 0.10 2003

9 下一次定期选举结果

这些模型避免把“四年”误解成事后挑选窗口。严格职位键要求县、辖区、职位和席位/选区一致;宽职位键允许席位编号变化。因变量定义已经保存在处理后样本中,回归仍为同一主 ITT 公式。

strict <- next_sample[next_strict_observed == 1L]
broad <- next_sample[next_broad_observed == 1L]
strict_2 <- strict[cycle_type_strict == "约2年"]
strict_4 <- strict[cycle_type_strict == "约4年"]
next_specs <- list(
  list(strict, "run_same_office_next_strict", "下一次定期选举竞选同一职位(严格键)"),
  list(broad, "run_same_office_next_broad", "下一次定期选举竞选同类职位(宽键)"),
  list(strict, "run_any_local_at_next_cycle", "下一次选举同月参加本县任一地方选举"),
  list(strict, "run_by_next_scheduled_any_local", "截至下一次选举前已参加本县任一地方选举"),
  list(strict_2, "run_by_next_scheduled_any_local", "约2年周期:截至下一次选举前再次参选"),
  list(strict_4, "run_by_next_scheduled_any_local", "约4年周期:截至下一次选举前再次参选"),
  list(strict_2, "run_again_4yr", "约2年周期:原四年结果"),
  list(strict_4, "run_again_4yr", "约4年周期:原四年结果")
)
next_results <- rbindlist(lapply(next_specs, function(spec) {
  d <- spec[[1]]; y <- spec[[2]]; label <- spec[[3]]
  fit <- feols(
    as.formula(paste0(y, " ~ first_position | ", fe_rhs)),
    data = d, cluster = ~random_contest_id, notes = FALSE, warn = FALSE
  )
  row <- extract_fixest(fit, "first_position", y, label)
  row[, outcome_mean := mean(d[[y]], na.rm = TRUE)]
  row
}), fill = TRUE)
show_result(next_results)
estimand outcome term estimate std_error lower_95 upper_95 p_value n outcome_mean
下一次定期选举竞选同一职位(严格键) run_same_office_next_strict first_position 0.00057 0.00532 -0.00985 0.01100 0.91400 24359 0.11753
下一次定期选举竞选同类职位(宽键) run_same_office_next_broad first_position -0.00234 0.00393 -0.01004 0.00537 0.55234 28281 0.08345
下一次选举同月参加本县任一地方选举 run_any_local_at_next_cycle first_position 0.00007 0.00554 -0.01079 0.01093 0.99003 24359 0.13100
截至下一次选举前已参加本县任一地方选举 run_by_next_scheduled_any_local first_position 0.00347 0.00595 -0.00819 0.01513 0.55968 24359 0.16154
约2年周期:截至下一次选举前再次参选 run_by_next_scheduled_any_local first_position -0.00920 0.00627 -0.02149 0.00309 0.14235 16875 0.11644
约4年周期:截至下一次选举前再次参选 run_by_next_scheduled_any_local first_position 0.02768 0.01211 0.00393 0.05142 0.02238 7484 0.26323
约2年周期:原四年结果 run_again_4yr first_position 0.02712 0.00873 0.01001 0.04423 0.00190 16875 0.24948
约4年周期:原四年结果 run_again_4yr first_position 0.02682 0.01187 0.00356 0.05009 0.02389 7484 0.25481

10 制度分类分层

这些是分层点估计,不等于正式的组间差异检验。只有候选人不少于 300 且竞选不少于 100 的类别才估计。

families <- c(
  "seat_structure", "effective_competition", "term_class", "special_proxy",
  "election_month_class", "runoff_class", "writein_class", "office_group"
)
institution_results <- list()
for (family in families) for (category in unique(na.omit(institution[[family]]))) {
  d <- institution[get(family) == category]
  for (y in c("current_win", "run_again_4yr")) {
    if (nrow(d) < 300 || uniqueN(d$random_contest_id) < 100) next
    fit <- feols(
      as.formula(paste0(y, " ~ first_position | ", fe_rhs)),
      data = d, cluster = ~random_contest_id, notes = FALSE, warn = FALSE
    )
    row <- extract_fixest(fit, "first_position", y, "制度分层 ITT")
    row[, `:=`(classification_family = family, category = as.character(category),
                contests = uniqueN(d$random_contest_id))]
    institution_results[[length(institution_results) + 1L]] <- row
  }
}
institution_table <- rbindlist(institution_results, fill = TRUE)
show_result(institution_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n classification_family category contests
制度分层 ITT current_win first_position 0.07054 0.00854 0.05380 0.08728 0.00000 23490 seat_structure 多席位 8310
制度分层 ITT run_again_4yr first_position 0.03072 0.00705 0.01689 0.04455 0.00001 23490 seat_structure 多席位 8310
制度分层 ITT current_win first_position 0.02135 0.00875 0.00421 0.03850 0.01465 14380 seat_structure 单席位 7903
制度分层 ITT run_again_4yr first_position 0.01196 0.00751 -0.00277 0.02669 0.11155 14380 seat_structure 单席位 7903
制度分层 ITT current_win first_position 0.04982 0.00618 0.03771 0.06193 0.00000 37702 effective_competition 候选人数多于席位 16094
制度分层 ITT run_again_4yr first_position 0.02259 0.00515 0.01249 0.03269 0.00001 37702 effective_competition 候选人数多于席位 16094
制度分层 ITT current_win first_position 0.04796 0.00626 0.03568 0.06023 0.00000 36448 term_class 完整任期 15526
制度分层 ITT run_again_4yr first_position 0.02107 0.00525 0.01078 0.03136 0.00006 36448 term_class 完整任期 15526
制度分层 ITT current_win first_position 0.06553 0.03391 -0.00093 0.13200 0.05371 1413 term_class 短任期 686
制度分层 ITT run_again_4yr first_position 0.04633 0.02570 -0.00403 0.09669 0.07182 1413 term_class 短任期 686
制度分层 ITT current_win first_position 0.04790 0.00626 0.03563 0.06017 0.00000 36454 special_proxy 普通完整任期代理 15527
制度分层 ITT run_again_4yr first_position 0.02102 0.00525 0.01073 0.03131 0.00006 36454 special_proxy 普通完整任期代理 15527
制度分层 ITT current_win first_position 0.06553 0.03391 -0.00093 0.13200 0.05371 1413 special_proxy 短任期/特别选举代理 686
制度分层 ITT run_again_4yr first_position 0.04633 0.02570 -0.00403 0.09669 0.07182 1413 special_proxy 短任期/特别选举代理 686
制度分层 ITT current_win first_position 0.05669 0.00705 0.04287 0.07050 0.00000 31476 election_month_class 11月选举 13360
制度分层 ITT run_again_4yr first_position 0.02373 0.00557 0.01280 0.03465 0.00002 31476 election_month_class 11月选举 13360
制度分层 ITT current_win first_position 0.01768 0.01191 -0.00566 0.04102 0.13766 6394 election_month_class 非11月选举 2853
制度分层 ITT run_again_4yr first_position 0.01382 0.01301 -0.01167 0.03932 0.28792 6394 election_month_class 非11月选举 2853
制度分层 ITT current_win first_position 0.05845 0.00694 0.04485 0.07204 0.00000 32307 runoff_class 当轮直接决定 14241
制度分层 ITT run_again_4yr first_position 0.02396 0.00543 0.01331 0.03460 0.00001 32307 runoff_class 当轮直接决定 14241
制度分层 ITT current_win first_position -0.00001 0.01174 -0.02302 0.02300 0.99926 5563 runoff_class 存在进入下一轮机制 1972
制度分层 ITT run_again_4yr first_position 0.00686 0.01491 -0.02238 0.03609 0.64584 5563 runoff_class 存在进入下一轮机制 1972
制度分层 ITT current_win first_position 0.05687 0.00774 0.04171 0.07203 0.00000 24093 writein_class 无写入票记录 10026
制度分层 ITT run_again_4yr first_position 0.02567 0.00663 0.01268 0.03866 0.00011 24093 writein_class 无写入票记录 10026
制度分层 ITT current_win first_position 0.03582 0.01021 0.01582 0.05582 0.00045 13777 writein_class 有写入票记录 6187
制度分层 ITT run_again_4yr first_position 0.01486 0.00815 -0.00111 0.03082 0.06822 13777 writein_class 有写入票记录 6187
制度分层 ITT current_win first_position 0.03832 0.01039 0.01795 0.05869 0.00023 14476 office_group City Council 5066
制度分层 ITT run_again_4yr first_position 0.03167 0.00981 0.01244 0.05090 0.00126 14476 office_group City Council 5066
制度分层 ITT current_win first_position 0.06374 0.00917 0.04577 0.08171 0.00000 18347 office_group School Board 8506
制度分层 ITT run_again_4yr first_position 0.01539 0.00647 0.00270 0.02807 0.01746 18347 office_group School Board 8506
制度分层 ITT current_win first_position 0.00560 0.01661 -0.02695 0.03816 0.73593 1990 office_group County Legislator 1012
制度分层 ITT run_again_4yr first_position 0.02557 0.02397 -0.02140 0.07254 0.28621 1990 office_group County Legislator 1012
制度分层 ITT current_win first_position 0.05557 0.03634 -0.01565 0.12679 0.12698 899 office_group Judge 419
制度分层 ITT run_again_4yr first_position -0.01481 0.02888 -0.07141 0.04179 0.60831 899 office_group Judge 419
制度分层 ITT current_win first_position -0.00948 0.02634 -0.06111 0.04214 0.71890 1106 office_group Mayor 597
制度分层 ITT run_again_4yr first_position 0.04126 0.03355 -0.02450 0.10702 0.21926 1106 office_group Mayor 597
制度分层 ITT current_win first_position 0.10678 0.07259 -0.03550 0.24905 0.14296 308 office_group Clerk 195
制度分层 ITT run_again_4yr first_position 0.08358 0.06020 -0.03440 0.20157 0.16661 308 office_group Clerk 195

11 1—8 年动态路径

first_return_gap_months_8yr 是基准选举到首次再次参选之间的月数。第 \(h\) 年累计结果定义为首次回归间隔不超过 \(12h\) 个月。固定八年完整随访样本避免不同年份使用不同基线队列;各期最大完整样本提高每一时点的样本量。

raw_end_index <- max(raw$year * 12L + fifelse(is.na(raw$month), 6L, as.integer(raw$month)), na.rm = TRUE)
balanced_8yr <- dynamic[base_index_dynamic + 96L <= raw_end_index]
dynamic_results <- list()
for (h in 1:8) for (design in c("固定八年完整随访样本", "各期最大完整随访样本")) {
  d <- if (design == "固定八年完整随访样本") balanced_8yr else
    dynamic[base_index_dynamic + 12L * h <= raw_end_index]
  d <- copy(d)
  d[, dynamic_outcome := as.integer(
    !is.na(first_return_gap_months_8yr) & first_return_gap_months_8yr <= 12L * h
  )]
  fit <- feols(
    as.formula(paste0("dynamic_outcome ~ first_position | ", fe_rhs)),
    data = d, cluster = ~random_contest_id, notes = FALSE
  )
  row <- extract_fixest(fit, "first_position", paste0(h, "年内再次参选"), "累计 ITT")
  row[, `:=`(horizon_year = h, sample_design = design,
              control_mean = mean(d[first_position == 0, dynamic_outcome]),
              contests = uniqueN(d$random_contest_id))]
  dynamic_results[[length(dynamic_results) + 1L]] <- row
}
dynamic_table <- rbindlist(dynamic_results, fill = TRUE)
dynamic_table[, p_value_bh := p.adjust(p_value, method = "BH"), by = sample_design]
show_result(dynamic_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n horizon_year sample_design control_mean contests p_value_bh
累计 ITT 1年内再次参选 first_position 0.00130 0.00233 -0.00328 0.00587 0.57829 30945 1 固定八年完整随访样本 0.03224 13002 0.74334
累计 ITT 1年内再次参选 first_position 0.00108 0.00206 -0.00295 0.00512 0.59862 37870 1 各期最大完整随访样本 0.03169 16213 0.59862
累计 ITT 2年内再次参选 first_position -0.00131 0.00401 -0.00917 0.00654 0.74334 30945 2 固定八年完整随访样本 0.10164 13002 0.74334
累计 ITT 2年内再次参选 first_position -0.00226 0.00354 -0.00919 0.00468 0.52394 37870 2 各期最大完整随访样本 0.09979 16213 0.59862
累计 ITT 3年内再次参选 first_position -0.00145 0.00411 -0.00950 0.00660 0.72420 30945 3 固定八年完整随访样本 0.10799 13002 0.74334
累计 ITT 3年内再次参选 first_position -0.00203 0.00364 -0.00916 0.00510 0.57694 37870 3 各期最大完整随访样本 0.10626 16213 0.59862
累计 ITT 4年内再次参选 first_position 0.01910 0.00570 0.00793 0.03026 0.00080 30945 4 固定八年完整随访样本 0.21240 13002 0.00320
累计 ITT 4年内再次参选 first_position 0.02216 0.00514 0.01209 0.03223 0.00002 37870 4 各期最大完整随访样本 0.21355 16213 0.00013
累计 ITT 5年内再次参选 first_position 0.02080 0.00584 0.00934 0.03225 0.00038 30945 5 固定八年完整随访样本 0.22443 13002 0.00300
累计 ITT 5年内再次参选 first_position 0.02144 0.00553 0.01060 0.03228 0.00011 34364 5 各期最大完整随访样本 0.22504 14584 0.00042
累计 ITT 6年内再次参选 first_position 0.01794 0.00595 0.00629 0.02960 0.00256 30945 6 固定八年完整随访样本 0.23821 13002 0.00511
累计 ITT 6年内再次参选 first_position 0.01838 0.00563 0.00734 0.02941 0.00110 34221 6 各期最大完整随访样本 0.23895 14532 0.00294
累计 ITT 7年内再次参选 first_position 0.01854 0.00597 0.00683 0.03024 0.00191 30945 7 固定八年完整随访样本 0.24111 13002 0.00510
累计 ITT 7年内再次参选 first_position 0.01823 0.00595 0.00656 0.02989 0.00220 31184 7 各期最大完整随访样本 0.24099 13083 0.00440
累计 ITT 8年内再次参选 first_position 0.01793 0.00615 0.00588 0.02997 0.00354 30945 8 固定八年完整随访样本 0.26073 13002 0.00567
累计 ITT 8年内再次参选 first_position 0.01793 0.00615 0.00588 0.02997 0.00354 30945 8 各期最大完整随访样本 0.26073 13002 0.00567
ggplot(dynamic_table, aes(horizon_year, 100 * estimate, color = sample_design)) +
  geom_hline(yintercept = 0, linetype = 2, color = "grey60") +
  geom_line() + geom_point() +
  geom_errorbar(aes(ymin = 100 * lower_95, ymax = 100 * upper_95), width = 0.12) +
  scale_x_continuous(breaks = 1:8) +
  labs(x = "随访年数", y = "随机第一位置的累计 ITT(百分点)", color = "样本") +
  theme_minimal()

互斥的“首次回归年份”把累计曲线拆成第 1 年、……、第 8 年首次回归。

interval_results <- list()
for (h in 1:8) {
  d <- copy(balanced_8yr)
  d[, first_return_interval := as.integer(
    !is.na(first_return_gap_months_8yr) &
      first_return_gap_months_8yr > 12L * (h - 1L) &
      first_return_gap_months_8yr <= 12L * h
  )]
  fit <- feols(
    as.formula(paste0("first_return_interval ~ first_position | ", fe_rhs)),
    data = d, cluster = ~random_contest_id, notes = FALSE
  )
  row <- extract_fixest(fit, "first_position", paste0("第", h, "年首次再次参选"), "区间 ITT")
  row[, interval_year := h]
  interval_results[[h]] <- row
}
interval_table <- rbindlist(interval_results)
interval_table[, p_value_bh := p.adjust(p_value, method = "BH")]
show_result(interval_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n interval_year p_value_bh
区间 ITT 第1年首次再次参选 first_position 0.00130 0.00233 -0.00328 0.00587 0.57829 30945 1 0.77105
区间 ITT 第2年首次再次参选 first_position -0.00261 0.00333 -0.00913 0.00391 0.43270 30945 2 0.70411
区间 ITT 第3年首次再次参选 first_position -0.00014 0.00102 -0.00214 0.00187 0.89420 30945 3 0.89420
区间 ITT 第4年首次再次参选 first_position 0.02055 0.00445 0.01181 0.02928 0.00000 30945 4 0.00003
区间 ITT 第5年首次再次参选 first_position 0.00170 0.00158 -0.00140 0.00480 0.28330 30945 5 0.70411
区间 ITT 第6年首次再次参选 first_position -0.00285 0.00148 -0.00575 0.00005 0.05376 30945 6 0.21503
区间 ITT 第7年首次再次参选 first_position 0.00060 0.00077 -0.00092 0.00211 0.44007 30945 7 0.70411
区间 ITT 第8年首次再次参选 first_position -0.00061 0.00191 -0.00435 0.00312 0.74806 30945 8 0.85493

12 回归次数、首次回归去向与排位形状

expanded_outcomes <- c(
  future_election_dates_4yr = "四年内后续参选日期次数",
  future_winning_dates_4yr = "四年内后续胜选日期次数",
  first_return_same_office_4yr = "首次回归为同一职位",
  first_return_other_office_same_place_4yr = "首次回归为同一辖区其他职位",
  first_return_other_place_same_county_4yr = "首次回归为同县其他辖区"
)
expanded_table <- rbindlist(lapply(names(expanded_outcomes), function(y) {
  fit <- feols(
    as.formula(paste0(y, " ~ first_position | ", fe_rhs)),
    data = dynamic, cluster = ~random_contest_id, notes = FALSE
  )
  extract_fixest(fit, "first_position", expanded_outcomes[[y]], "随机第一位置的 ITT")
}))
expanded_table[, p_value_bh := p.adjust(p_value, method = "BH")]
show_result(expanded_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n p_value_bh
随机第一位置的 ITT 四年内后续参选日期次数 first_position 0.01944 0.00598 0.00772 0.03116 0.00115 37870 0.00385
随机第一位置的 ITT 四年内后续胜选日期次数 first_position 0.01415 0.00453 0.00527 0.02303 0.00179 37870 0.00385
随机第一位置的 ITT 首次回归为同一职位 first_position 0.01186 0.00423 0.00357 0.02016 0.00506 37870 0.00633
随机第一位置的 ITT 首次回归为同一辖区其他职位 first_position 0.00792 0.00260 0.00283 0.01301 0.00231 37870 0.00385
随机第一位置的 ITT 首次回归为同县其他辖区 first_position 0.00238 0.00216 -0.00186 0.00662 0.27111 37870 0.27111

在至少三名候选人的竞选中,基准组是第三位及以后:

\[Y=\beta_1 1[position=1]+\beta_2 1[position=2]+FE+\varepsilon.\]

第一位减第二位 是线性组合 \(\beta_1-\beta_2\),不是另跑一条二元回归。

position_sample <- copy(dynamic[n_cands >= 3])
position_sample[, `:=`(
  position_first = as.integer(ballot_order == 1L),
  position_second = as.integer(ballot_order == 2L)
)]
position_outcomes <- c(
  current_win = "当前当选", new_voteshare = "当前得票份额",
  run_again_4yr = "四年内再次参选", same_local_4yr = "四年内同一辖区参选",
  same_office_4yr = "四年内同一职位参选", future_win_4yr = "四年内未来获胜"
)
position_results <- list()
for (y in names(position_outcomes)) {
  fit <- feols(
    as.formula(paste0(y, " ~ position_first + position_second | ", fe_rhs)),
    data = position_sample, cluster = ~random_contest_id, notes = FALSE
  )
  position_results[[length(position_results) + 1L]] <- rbindlist(list(
    extract_fixest(fit, "position_first", position_outcomes[[y]], "第一位相对第三位及以后"),
    extract_fixest(fit, "position_second", position_outcomes[[y]], "第二位相对第三位及以后"),
    linear_combination(fit, c(position_first = 1, position_second = -1),
                       position_outcomes[[y]], "第一位减第二位")
  ))
}
position_table <- rbindlist(position_results)
position_table[estimand == "第一位减第二位", p_value_bh := p.adjust(p_value, method = "BH")]
show_result(position_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n p_value_bh
第一位相对第三位及以后 当前当选 position_first 0.05891 0.00698 0.04524 0.07258 0.00000 32310 NA
第二位相对第三位及以后 当前当选 position_second 0.01654 0.00682 0.00317 0.02990 0.01534 32310 NA
第一位减第二位 当前当选 linear combination 0.04237 0.00819 0.02633 0.05842 0.00000 32310 0.00000
第一位相对第三位及以后 当前得票份额 position_first 0.01332 0.00144 0.01049 0.01615 0.00000 32310 NA
第二位相对第三位及以后 当前得票份额 position_second 0.00294 0.00140 0.00019 0.00569 0.03646 32310 NA
第一位减第二位 当前得票份额 linear combination 0.01038 0.00177 0.00691 0.01385 0.00000 32310 0.00000
第一位相对第三位及以后 四年内再次参选 position_first 0.02603 0.00617 0.01394 0.03813 0.00002 32310 NA
第二位相对第三位及以后 四年内再次参选 position_second 0.00532 0.00601 -0.00646 0.01711 0.37586 32310 NA
第一位减第二位 四年内再次参选 linear combination 0.02071 0.00719 0.00661 0.03481 0.00400 32310 0.00600
第一位相对第三位及以后 四年内同一辖区参选 position_first 0.02255 0.00588 0.01102 0.03408 0.00013 32310 NA
第二位相对第三位及以后 四年内同一辖区参选 position_second -0.00097 0.00567 -0.01209 0.01015 0.86440 32310 NA
第一位减第二位 四年内同一辖区参选 linear combination 0.02352 0.00681 0.01018 0.03686 0.00055 32310 0.00110
第一位相对第三位及以后 四年内同一职位参选 position_first 0.01360 0.00524 0.00333 0.02387 0.00949 32310 NA
第二位相对第三位及以后 四年内同一职位参选 position_second -0.00162 0.00511 -0.01163 0.00839 0.75093 32310 NA
第一位减第二位 四年内同一职位参选 linear combination 0.01522 0.00606 0.00334 0.02710 0.01207 32310 0.01448
第一位相对第三位及以后 四年内未来获胜 position_first 0.01630 0.00504 0.00641 0.02618 0.00123 32310 NA
第二位相对第三位及以后 四年内未来获胜 position_second 0.00682 0.00491 -0.00280 0.01644 0.16453 32310 NA
第一位减第二位 四年内未来获胜 linear combination 0.00947 0.00593 -0.00215 0.02109 0.11014 32310 0.11014

13 正式制度异质性检验

这里让 n_candsyearcooffice2 的固定效应在两组中完全自由,再估计 first_position × moderator。交互项才是正式组间差异。

dynamic[, multi_seat_dynamic := fifelse(
  seat_structure == "多席位", 1L, fifelse(seat_structure == "单席位", 0L, NA_integer_)
)]
dynamic[, crowded_dynamic := as.integer(n_cands >= 4L)]
dynamic[, on_cycle_dynamic := fifelse(
  as.character(electiming3) %in% c("Presidential", "Midterm"), 1L,
  fifelse(as.character(electiming3) == "Off-cycle", 0L, NA_integer_)
)]
moderator_specs <- list(
  multi_seat = list("multi_seat_dynamic", !is.na(dynamic$multi_seat_dynamic), "单席位", "多席位"),
  crowded = list("crowded_dynamic", rep(TRUE, nrow(dynamic)), "2—3名候选人", "4名及以上候选人"),
  on_cycle = list("on_cycle_dynamic", !is.na(dynamic$on_cycle_dynamic), "错峰地方选举", "总统或中期选举同期"),
  council_vs_school = list("council_dynamic", dynamic$office_group %in% c("City Council", "School Board"), "School Board", "City Council")
)
formal_het <- list()
for (mod_name in names(moderator_specs)) {
  spec <- moderator_specs[[mod_name]]
  moderator <- spec[[1]]; d <- copy(dynamic[which(spec[[2]] %in% TRUE)])
  if (mod_name == "council_vs_school") d[, council_dynamic := as.integer(office_group == "City Council")]
  for (v in c("n_cands", "year", "co", "office2")) {
    d[, paste0(v, "_by_group") := interaction(get(v), get(moderator), drop = TRUE)]
  }
  saturated_fe <- "n_cands_by_group + year_by_group + co_by_group + office2_by_group"
  for (y in c("current_win", "run_again_4yr")) {
    fit <- feols(
      as.formula(paste0(y, " ~ first_position * ", moderator, " | ", saturated_fe)),
      data = d, cluster = ~random_contest_id, notes = FALSE
    )
    interaction_term <- paste0("first_position:", moderator)
    rows <- rbindlist(list(
      extract_fixest(fit, "first_position", y, paste0(spec[[3]], "效应")),
      linear_combination(fit, setNames(c(1, 1), c("first_position", interaction_term)),
                         y, paste0(spec[[4]], "效应")),
      extract_fixest(fit, interaction_term, y, paste0(spec[[4]], "减去", spec[[3]]))
    ))
    rows[, moderator := mod_name]
    formal_het[[length(formal_het) + 1L]] <- rows
  }
}
formal_het_table <- rbindlist(formal_het)
formal_het_table[grepl("减去", estimand), p_value_bh := p.adjust(p_value, method = "BH")]
show_result(formal_het_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n moderator p_value_bh
单席位效应 current_win first_position 0.02135 0.00874 0.00423 0.03848 0.01455 37870 multi_seat NA
多席位效应 current_win linear combination 0.07054 0.00855 0.05379 0.08729 0.00000 37870 multi_seat NA
多席位减去单席位 current_win first_position:multi_seat_dynamic 0.04919 0.01222 0.02523 0.07314 0.00006 37870 multi_seat 0.00046
单席位效应 run_again_4yr first_position 0.01196 0.00751 -0.00276 0.02667 0.11121 37870 multi_seat NA
多席位效应 run_again_4yr linear combination 0.03072 0.00706 0.01689 0.04455 0.00001 37870 multi_seat NA
多席位减去单席位 run_again_4yr first_position:multi_seat_dynamic 0.01876 0.01030 -0.00144 0.03896 0.06867 37870 multi_seat 0.12379
2—3名候选人效应 current_win first_position 0.03296 0.01002 0.01331 0.05260 0.00101 37870 crowded NA
4名及以上候选人效应 current_win linear combination 0.06042 0.00773 0.04527 0.07557 0.00000 37870 crowded NA
4名及以上候选人减去2—3名候选人 current_win first_position:crowded_dynamic 0.02746 0.01266 0.00266 0.05227 0.03003 37870 crowded 0.12012
2—3名候选人效应 run_again_4yr first_position 0.01162 0.00754 -0.00316 0.02639 0.12338 37870 crowded NA
4名及以上候选人效应 run_again_4yr linear combination 0.02978 0.00699 0.01607 0.04349 0.00002 37870 crowded NA
4名及以上候选人减去2—3名候选人 run_again_4yr first_position:crowded_dynamic 0.01816 0.01028 -0.00199 0.03832 0.07737 37870 crowded 0.12379
错峰地方选举效应 current_win first_position 0.03767 0.00974 0.01859 0.05675 0.00011 37869 on_cycle NA
总统或中期选举同期效应 current_win linear combination 0.05594 0.00792 0.04042 0.07146 0.00000 37869 on_cycle NA
总统或中期选举同期减去错峰地方选举 current_win first_position:on_cycle_dynamic 0.01827 0.01255 -0.00632 0.04287 0.14540 37869 on_cycle 0.18968
错峰地方选举效应 run_again_4yr first_position 0.02178 0.00862 0.00488 0.03868 0.01155 37869 on_cycle NA
总统或中期选举同期效应 run_again_4yr linear combination 0.02118 0.00641 0.00863 0.03374 0.00094 37869 on_cycle NA
总统或中期选举同期减去错峰地方选举 run_again_4yr first_position:on_cycle_dynamic -0.00060 0.01074 -0.02165 0.02046 0.95578 37869 on_cycle 0.95578
School Board效应 current_win first_position 0.06374 0.00917 0.04577 0.08172 0.00000 32823 council_vs_school NA
City Council效应 current_win linear combination 0.03832 0.01039 0.01796 0.05868 0.00023 32823 council_vs_school NA
City Council减去School Board current_win first_position:council_dynamic -0.02542 0.01386 -0.05258 0.00174 0.06658 32823 council_vs_school 0.12379
School Board效应 run_again_4yr first_position 0.01539 0.00648 0.00270 0.02808 0.01749 32823 council_vs_school NA
City Council效应 run_again_4yr linear combination 0.03167 0.00981 0.01245 0.05089 0.00124 32823 council_vs_school NA
City Council减去School Board run_again_4yr first_position:council_dynamic 0.01628 0.01175 -0.00675 0.03931 0.16597 32823 council_vs_school 0.18968

14 机制诊断:为什么不能叫“直接效应”

current_winnew_voteshare 都会受到随机第一位置影响,属于处理后变量。把它们加入回归会破坏原随机化解释,所以这里只看系数如何变化,不能把剩余 first_position 系数解释为正式直接效应。

mechanism_specs <- list(
  "总ITT:不控制处理后变量" = paste0("run_again_4yr ~ first_position | ", fe_rhs),
  "加入当届胜选" = paste0("run_again_4yr ~ first_position + current_win | ", fe_rhs),
  "加入当届得票份额" = paste0("run_again_4yr ~ first_position + new_voteshare | ", fe_rhs),
  "同时加入胜选和得票份额" = paste0("run_again_4yr ~ first_position + current_win + new_voteshare | ", fe_rhs)
)
mechanism_table <- rbindlist(lapply(names(mechanism_specs), function(label) {
  fit <- feols(
    as.formula(mechanism_specs[[label]]), data = dynamic,
    cluster = ~random_contest_id, notes = FALSE
  )
  row <- extract_fixest(fit, "first_position", "4年内再次参选", label)
  row[, causal_status := ifelse(
    label == "总ITT:不控制处理后变量", "主因果估计", "处理后调整:仅描述"
  )]
  row
}))
show_result(mechanism_table)
estimand outcome term estimate std_error lower_95 upper_95 p_value n causal_status
总ITT:不控制处理后变量 4年内再次参选 first_position 0.02216 0.00514 0.01209 0.03223 0.00002 37870 主因果估计
加入当届胜选 4年内再次参选 first_position 0.01198 0.00496 0.00226 0.02169 0.01566 37870 处理后调整:仅描述
加入当届得票份额 4年内再次参选 first_position 0.00899 0.00494 -0.00070 0.01867 0.06899 37870 处理后调整:仅描述
同时加入胜选和得票份额 4年内再次参选 first_position 0.00743 0.00491 -0.00219 0.01705 0.13015 37870 处理后调整:仅描述

15 辅助场次内置换(可选,不是正式 RI)

旧项目把“第一位”在每场竞选内随机放给一人,忽略了同一日期共用一套字母表,因此只可作为辅助安慰剂。要运行,把 YAML 中 run_contest_permutation 改为 true

if (isTRUE(params$run_contest_permutation)) {
  set.seed(1965)
  B <- as.integer(params$contest_permutation_reps)
  pool_simple <- main[, .(row_id, random_contest_id)]
  perm_est <- numeric(B)
  for (b in seq_len(B)) {
    pool_simple[, perm_first := as.integer(seq_len(.N) == sample.int(.N, 1L)), by = random_contest_id]
    d <- copy(main)
    d[pool_simple, on = "row_id", perm_first := i.perm_first]
    fit <- feols(
      as.formula(paste0("run_again_4yr ~ perm_first | ", fe_rhs)),
      data = d, notes = FALSE, warn = FALSE
    )
    perm_est[b] <- coef(fit)[["perm_first"]]
  }
  p_perm <- (1 + sum(abs(perm_est) >= abs(headline))) / (B + 1)
  show_result(data.table(B = B, observed = headline, null_mean = mean(perm_est),
                         null_sd = sd(perm_est), p_two_sided_plus1 = p_perm))
} else {
  cat("本次 Knit 未运行辅助场次内置换。\n")
}
## 本次 Knit 未运行辅助场次内置换。

16 正式设计型随机化推断(可选)

正式 RI 每次在每个精确选举日期重抽一套完整 26 字母顺序,先给完整竞选候选人重新排位,再把伪第一位回接主样本。这一过程保留了真实制度中的日期层级相关性。

if (isTRUE(params$run_design_ri)) {
  ri_pool <- as.data.table(readRDS(file.path(
    processed_dir, "随机排位_完整竞选候选人_设计型RI分配池.rds"
  )))
  ri_pool[, `:=`(
    surname_clean = normalize_letters(last),
    given_clean = normalize_letters(first)
  )]
  ri_pool[, sort_name_clean := paste0(surname_clean, given_clean)]
  setorder(ri_pool, exact_date, random_contest_id, row_id)
  date_index <- split(seq_len(nrow(ri_pool)), as.character(ri_pool$exact_date))
  alphabet <- paste(LETTERS, collapse = "")
  B <- as.integer(params$ri_reps)
  ri_est <- numeric(B)
  for (b in seq_len(B)) {
    set.seed(20260726L + b)
    translated <- character(nrow(ri_pool))
    for (date_now in names(date_index)) {
      idx <- date_index[[date_now]]
      random_alphabet <- sample(LETTERS, 26L, replace = FALSE)
      rank_letters <- paste(LETTERS[match(LETTERS, random_alphabet)], collapse = "")
      translated[idx] <- chartr(alphabet, rank_letters, ri_pool$sort_name_clean[idx])
    }
    sim <- data.table(
      row_id = ri_pool$row_id, random_contest_id = ri_pool$random_contest_id,
      translated = translated
    )
    setorder(sim, random_contest_id, translated, row_id)
    sim[, pseudo_first := as.integer(seq_len(.N) == 1L), by = random_contest_id]
    d <- copy(main)
    d[sim, on = "row_id", pseudo_first := i.pseudo_first]
    fit <- feols(
      as.formula(paste0("run_again_4yr ~ pseudo_first | ", fe_rhs)),
      data = d, cluster = ~random_contest_id, notes = FALSE, warn = FALSE
    )
    ri_est[b] <- coef(fit)[["pseudo_first"]]
  }
  ri_p <- (1 + sum(abs(ri_est) >= abs(headline))) / (B + 1)
  show_result(data.table(
    reps = B, real_itt = headline, null_mean = mean(ri_est), null_sd = sd(ri_est),
    p_two_sided_plus1 = ri_p,
    note = ifelse(B < 1000, "冒烟测试;不是正式 p 值", "正式 1000 次 RI")
  ))
} else {
  cat("本次 Knit 未运行正式设计型 RI。把 run_design_ri 改为 true;先用 20 次检查,再用 1000 次正式运行。\n")
}
## 本次 Knit 未运行正式设计型 RI。把 run_design_ri 改为 true;先用 20 次检查,再用 1000 次正式运行。

17 Python 贝叶斯部分如何单独复现

R 不能把 NumPyro 的 MCMC 自动变成“R 的结果”。本项目贝叶斯模型是 Python/JAX/NumPyro 模型。配套文件 加州项目_Python贝叶斯交互复现.py 使用 # %% 单元格,可在 VS Code 或支持 Python Cell 的编辑器中逐格运行;它直接写出数据编码、先验、分层逻辑回归、NUTS 采样和诊断,不调用原 11B 脚本。

贝叶斯因变量仍是 run_again_4yr,处理变量仍是 first_position,但链接函数为 logit,并允许日期、县、职位的截距和处理斜率随机变化:

\[logit\{P(Y_i=1)\}=\eta_i+Z_i\Delta_i,\]

\[\eta_i=\alpha+X_i\beta+a_{date[i]}+a_{county[i]}+a_{office[i]},\]

\[\Delta_i=\beta_Z+X_i\theta+b_{date[i]}+b_{county[i]}+b_{office[i]}.\]

主报告不直接解释 log-odds,而把每个后验抽样转换为概率尺度平均边际效应(AME)。

18 最终自动验收

checks <- data.table(
  check = c(
    "主样本行数为 37,870", "主样本竞选为 16,213",
    "四年再参选 ITT 复现 0.02216217", "一阶段复现 0.04900263",
    "一阶段 F 大于 10", "现场重估主表与保存主表一致"
  ),
  pass = c(
    nrow(main) == 37870,
    uniqueN(main$random_contest_id) == 16213,
    abs(headline - 0.02216217) < 0.0000001,
    abs(coef(first_stage)[["first_position"]] - 0.04900263) < 0.0000001,
    first_stage_F > 10,
    all(compare_main$status == "PASS")
  )
)
checks[, status := ifelse(pass, "PASS", "FAIL")]
show_result(checks[, .(check, status)])
check status
主样本行数为 37,870 PASS
主样本竞选为 16,213 PASS
四年再参选 ITT 复现 0.02216217 PASS
一阶段复现 0.04900263 PASS
一阶段 F 大于 10 PASS
现场重估主表与保存主表一致 PASS
stopifnot(all(checks$pass))

如果这一章全部显示 PASS,说明当前电脑上的数据、R 包和上述直接回归能复现核心数值。它不自动替代人工姓名/案例核验,也不代表 Python 贝叶斯链已经重新采样。

sessionInfo()
## R version 4.5.1 (2025-06-13)
## Platform: aarch64-apple-darwin20
## Running under: macOS Tahoe 26.2
## 
## Matrix products: default
## BLAS:   /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: Asia/Shanghai
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] ggplot2_4.0.3     sandwich_3.1-1    lmtest_0.9-40     zoo_1.8-15        fixest_0.14.0     data.table_1.18.0
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6        jsonlite_2.0.0      dplyr_1.2.1         compiler_4.5.1      tidyselect_1.2.1   
##  [6] Rcpp_1.1.1          jquerylib_0.1.4     scales_1.4.0        yaml_2.3.12         fastmap_1.2.0      
## [11] lattice_0.22-7      R6_2.6.1            labeling_0.4.3      generics_0.1.4      Formula_1.2-5      
## [16] knitr_1.51          tibble_3.3.1        pillar_1.11.1       bslib_0.9.0         RColorBrewer_1.1-3 
## [21] rlang_1.2.0         cachem_1.1.0        xfun_0.56           sass_0.4.10         S7_0.2.0           
## [26] stringmagic_1.2.0   otel_0.2.0          cli_3.6.5           withr_3.0.2         magrittr_2.0.4     
## [31] digest_0.6.39       grid_4.5.1          rstudioapi_0.18.0   lifecycle_1.0.5     nlme_3.1-168       
## [36] vctrs_0.7.3         evaluate_1.0.5      glue_1.8.0          farver_2.1.2        numDeriv_2016.8-1.1
## [41] dreamerr_1.5.0      rmarkdown_2.30      pkgconfig_2.0.3     tools_4.5.1         htmltools_0.5.9