Skip to content

Commit 676da60

Browse files
igerberclaude
andcommitted
feat: fuzzy regression discontinuity via treatment_col on RegressionDiscontinuity
Local Wald ratio estimand with rdrobust 4.0.0 parity end-to-end: linearized bias correction and delta-method variances (T stacked as a second response column), fuzzy-ratio bandwidth objective with R's sharpbw / one-sided perfect-compliance auto-switch, full first-stage three-row mirror + summary block, weak-first-stage warning (documented deviation - R is silent), and R's exact identification error. 7 new fuzzy golden configs (23 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGca52n6H8oDDXALjjrsp4
1 parent 90b524b commit 676da60

20 files changed

Lines changed: 1788 additions & 274 deletions

CHANGELOG.md

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11-
- **`RegressionDiscontinuity` - sharp regression discontinuity estimation with robust
12-
bias-corrected inference (alias `RDD`).** Local-polynomial sharp RD per Calonico,
13-
Cattaneo & Titiunik (2014), parity-targeting R `rdrobust` 4.0.0 end-to-end: all 10
14-
data-driven bandwidth selectors (`mserd` default, `msetwo`/`msesum`/comb and the
15-
CER-optimal variants), triangular/epanechnikov/uniform kernels, `masspoints`
16-
adjust/check/off, manual `h`/`b`/`rho` with R-exact resolution semantics (including
17-
`rho`-without-`h` applying to selected bandwidths and the unconditional N<20
18-
full-range fallback), and the three-row Conventional / Bias-Corrected / Robust
19-
output. **Canonical binding:** `att`/`se`/`t_stat`/`p_value`/`conf_int` are ONE
11+
- **`RegressionDiscontinuity` - sharp AND fuzzy regression discontinuity estimation
12+
with robust bias-corrected inference (alias `RDD`).** Local-polynomial RD per
13+
Calonico, Cattaneo & Titiunik (2014), parity-targeting R `rdrobust` 4.0.0
14+
end-to-end: all 10 data-driven bandwidth selectors (`mserd` default,
15+
`msetwo`/`msesum`/comb and the CER-optimal variants),
16+
triangular/epanechnikov/uniform kernels, `masspoints` adjust/check/off, manual
17+
`h`/`b`/`rho` with R-exact resolution semantics (including `rho`-without-`h`
18+
applying to selected bandwidths and the unconditional N<20 full-range fallback),
19+
and the three-row Conventional / Bias-Corrected / Robust output. **Fuzzy designs**
20+
via `fit(..., treatment_col=...)` (R's `fuzzy=`): the estimand is the local Wald
21+
ratio (complier LATE at the cutoff under monotonicity) with the LINEARIZED
22+
bias correction and delta-method variance of rdrobust (T stacked as a second
23+
response column; Y-T covariance via the `res @ s_Y` collapse); bandwidths select
24+
on the fuzzy-ratio objective by default with R's exact `sharpbw`/one-sided
25+
perfect-compliance auto-switch to the sharp reduced form; the first stage
26+
(take-up jump) is a full three-row `first_stage*` mirror plus a first-stage
27+
`summary()` block; a weak-first-stage `UserWarning` fires when the first-stage
28+
robust CI contains zero (documented deviation - R is silent; CCT 2014 Theorem 3
29+
"guard and warn", Feir-Lemieux-Marmer weak-IV inference a documented seam); R's
30+
exact no-variation-no-jump identification error is raised on both entry points.
31+
**Canonical binding:** `att`/`se`/`t_stat`/`p_value`/`conf_int` are ONE
2032
coherent row - the robust bias-corrected row (`att = tau_bc`, CI centered on it,
21-
`t_stat == att/se`), preserving the library-wide field identities; rdrobust's
33+
`t_stat == att/se`), preserving the library-wide field identities; the new
34+
`estimand` results field names what `att` measures per fit; rdrobust's
2235
printed headline coefficient is exposed as `att_conventional` with a full inference
2336
row, and `summary()` prints the familiar three-row table. Estimation-path port
24-
(`rdrobust_fit_sharp`: Q_q bias-correction score matrix, conventional/robust NN
25-
sandwiches) validated against a new estimates golden
26-
(`benchmarks/data/rdrobust_estimates_golden.json`, 16 configurations incl. the
27-
Senate anchors) at rtol=1e-9 in `tests/test_rdd_parity.py`; R-free methodology
37+
(`rdrobust_fit`: Q_q bias-correction score matrix, conventional/robust NN
38+
sandwiches, fuzzy ratio/first-stage variances) validated against a new estimates
39+
golden (`benchmarks/data/rdrobust_estimates_golden.json`, 23 configurations incl.
40+
the Senate anchors and 7 fuzzy configs - default/sharpbw/manual-h/epa/msetwo/
41+
one-sided-compliance/ties) at rtol=1e-9 in `tests/test_rdd_parity.py` (+ port-level
42+
linearized-bias pins in `tests/test_rdrobust_port.py`); R-free methodology
2843
anchors (CCT 2014 Remark 7 bias-corrected == local-quadratic equivalence at rel
29-
1e-10 across all kernels, invariances, joint-NaN degenerate contracts) in
44+
1e-10 across all kernels, perfect-compliance == sharp reproduction, bandwidth
45+
auto-switch locks, invariances, joint-NaN degenerate contracts) in
3046
`tests/test_rdd_methodology.py`; API/validation suite in `tests/test_rdd.py`.
3147
Deviations from R (each labeled in the REGISTRY section): warn-instead-of-silent
32-
NaN drops, warn-and-ignore `b`-without-`h`, fail-closed targeted errors on
33-
degenerate designs, and the canonical-binding note above. Sharp designs only:
34-
fuzzy RD, covariates (CCFT 2019 - review on file), cluster-robust variance,
35-
weights, kink estimands, and rdplot/density diagnostics are documented follow-ups.
48+
NaN drops, warn-and-ignore `b`-without-`h`, the weak-first-stage warning,
49+
warn-and-ignore `sharpbw` on sharp fits, fail-closed targeted errors on
50+
degenerate designs, and the canonical-binding note above. Covariates (CCFT 2019 -
51+
review on file), cluster-robust variance, weights, kink estimands, weak-IV-robust
52+
fuzzy inference, and rdplot/density diagnostics are documented follow-ups.
3653
- **Internal: mypy enforced at zero errors.** Triaged the 184 pre-existing
3754
`mypy diff_diff` errors to an enforceable zero and added a blocking Mypy job to
3855
the Lint CI workflow (pinned `mypy==2.1.0` + pinned numpy/pandas/scipy for stub
@@ -91,8 +108,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
91108

92109
### Changed
93110
- `diff_diff/guides/llms-autonomous.txt` no longer lists regression discontinuity as
94-
out of scope: sharp RD routes to `RegressionDiscontinuity`; only fuzzy RD is
95-
referred to external tooling.
111+
out of scope: sharp AND fuzzy RD route to `RegressionDiscontinuity`; only kink
112+
designs and the covariate-adjusted / cluster-robust RD variants are referred to
113+
external tooling.
96114
- **Internal: repo-wide lint normalization + pinned tooling.** black/ruff/mypy are now
97115
pinned exactly in the `dev` extra (`black==26.3.1`, `ruff==0.15.13`, `mypy==2.1.0`;
98116
the tools require Python >= 3.10 — the library floor stays 3.9); full `black` +

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
112112
- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html) - triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility
113113
- [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html) - Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves
114114
- [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html) - de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲ = 0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲ > 0`, continuous-near-d̲ or mass-point), with a multi-period event-study extension (last-treatment cohort, pointwise CIs). **Panel-only** in this release - repeated cross-sections rejected by the validator. Alias `HAD`.
115-
- [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2014) sharp regression discontinuity with robust bias-corrected inference and rdrobust-parity bandwidth selection (all 10 selectors, mass-point handling). **Sharp designs only** in this release; canonical `att` is the bias-corrected estimate with a coherent robust CI (rdrobust's printed headline is `att_conventional`). Alias `RDD`.
115+
- [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2014) sharp AND fuzzy regression discontinuity with robust bias-corrected inference and rdrobust-parity bandwidth selection (all 10 selectors, mass-point handling; fuzzy via `treatment_col=` with a first-stage block and weak-identification warning). Canonical `att` is the bias-corrected estimate with a coherent robust CI (rdrobust's printed headline is `att_conventional`). Alias `RDD`.
116116
- [StackedDiD](https://diff-diff.readthedocs.io/en/stable/api/stacked_did.html) - Wing, Freedman & Hollingsworth (2024) stacked DiD with Q-weights and sub-experiments; optional covariate balancing (Ustyuzhanin 2026)
117117
- [EfficientDiD](https://diff-diff.readthedocs.io/en/stable/api/efficient_did.html) - Chen, Sant'Anna & Xie (2025) efficient DiD with optimal weighting for tighter SEs
118118
- [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html) - Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment

benchmarks/R/generate_rdrobust_estimates_golden.R

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# Golden-value generator for the diff-diff sharp-RD ESTIMATION port
2-
# (diff_diff/_rdrobust_port.py::rdrobust_fit_sharp and the public
3-
# RegressionDiscontinuity estimator).
1+
# Golden-value generator for the diff-diff RD ESTIMATION port - sharp AND
2+
# fuzzy (diff_diff/_rdrobust_port.py::rdrobust_fit and the public
3+
# RegressionDiscontinuity estimator; 23 configs across four synthetic DGPs
4+
# + the Senate data, incl. 7 fuzzy configs with full first-stage
5+
# tau_T/se_T/z_T/pv_T/ci_T blocks and per-side take-up coefficients).
46
#
57
# Deliberately a SEPARATE file/JSON from generate_rdrobust_golden.R so the
68
# bandwidth fixtures reviewed in the machinery PR are never regenerated.
@@ -24,16 +26,20 @@ TARBALL_SHA256 <- "78f0d6b4bdec4091cc8f42f6f1598704747f95926446d3aaee381ea1d613a
2426

2527
run_estimate <- function(y, x, c = 0, masspoints = "adjust", kernel = "tri",
2628
p = 1, q = 2, h = NULL, b = NULL, rho = NULL,
27-
level = 95, bwselect = "mserd") {
29+
level = 95, bwselect = "mserd",
30+
fuzzy = NULL, sharpbw = FALSE) {
2831
args <- list(y = y, x = x, c = c, masspoints = masspoints, kernel = kernel,
29-
p = p, q = q, level = level, bwselect = bwselect)
32+
p = p, q = q, level = level, bwselect = bwselect,
33+
sharpbw = sharpbw)
3034
if (!is.null(h)) args$h <- h
3135
if (!is.null(b)) args$b <- b
3236
if (!is.null(rho)) args$rho <- rho
37+
if (!is.null(fuzzy)) args$fuzzy <- fuzzy
3338
r <- suppressWarnings(do.call(rdrobust, args))
34-
list(
39+
out <- list(
3540
c = c, masspoints = masspoints, kernel = kernel, p = p, q = q,
3641
bwselect = bwselect,
42+
fuzzy_in = !is.null(fuzzy), sharpbw = sharpbw,
3743
h_in = if (is.null(h)) NA else h,
3844
b_in = if (is.null(b)) NA else b,
3945
rho_in = if (is.null(rho)) NA else rho,
@@ -49,6 +55,18 @@ run_estimate <- function(y, x, c = 0, masspoints = "adjust", kernel = "tri",
4955
beta_p_l = unname(as.vector(r$beta_Y_p_l)),
5056
beta_p_r = unname(as.vector(r$beta_Y_p_r))
5157
)
58+
if (!is.null(fuzzy)) {
59+
# First-stage three-row block (Conventional / Bias-Corrected / Robust)
60+
out$tau_T <- unname(r$tau_T)
61+
out$se_T <- unname(r$se_T)
62+
out$z_T <- unname(r$z_T)
63+
out$pv_T <- unname(r$pv_T)
64+
out$ci_T_lower <- unname(r$ci_T[, 1])
65+
out$ci_T_upper <- unname(r$ci_T[, 2])
66+
out$beta_t_p_l <- unname(as.vector(r$beta_T_p_l))
67+
out$beta_t_p_r <- unname(as.vector(r$beta_T_p_r))
68+
}
69+
out
5270
}
5371

5472
golden <- list()
@@ -57,12 +75,14 @@ golden$metadata <- list(
5775
rdrobust_version = as.character(packageVersion("rdrobust")),
5876
rdrobust_tarball_sha256 = TARBALL_SHA256,
5977
seeds = list(dgp_lee_smooth = 42L, dgp_ties_moderate = 123L,
60-
dgp_asymmetric_scaled = 777L),
78+
dgp_asymmetric_scaled = 777L, dgp_fuzzy = 314L),
6179
generator = "benchmarks/R/generate_rdrobust_estimates_golden.R",
6280
algorithm = paste(
63-
"rdrobust() sharp-RD estimation blocks (three-row coef/se/z/pv/ci,",
64-
"counts, per-side beta_p) for the vce='nn' no-covariate path,",
65-
"complementing the bandwidth fixtures in rdrobust_golden.json."
81+
"rdrobust() sharp AND fuzzy estimation blocks (three-row coef/se/z/pv/ci,",
82+
"counts, per-side beta_p; fuzzy configs add the first-stage",
83+
"tau_T/se_T/z_T/pv_T/ci_T rows and per-side beta_T_p) for the vce='nn'",
84+
"no-covariate path, complementing the bandwidth fixtures in",
85+
"rdrobust_golden.json."
6686
),
6787
r_version = R.version.string
6888
)
@@ -116,6 +136,32 @@ golden$dgp_asymmetric_scaled <- list(
116136
)
117137
)
118138

139+
# Fuzzy DGP: two-sided imperfect compliance (take-up jumps 0.15 -> 0.75).
140+
set.seed(314)
141+
n4 <- 1500
142+
x4 <- 2 * rbeta(n4, 2, 4) - 1
143+
t4 <- rbinom(n4, 1, ifelse(x4 >= 0, 0.75, 0.15))
144+
y4 <- 0.5 * x4 + 1.2 * t4 + rnorm(n4, sd = 0.3)
145+
# One-sided perfect compliance variant (T == 0 left of the cutoff):
146+
# exercises the perf_comp bandwidth auto-switch (rdbwselect.R:334-346).
147+
t4_one <- ifelse(x4 >= 0, t4, 0)
148+
# Tied running variable variant (2dp rounding; keeps all masspoints modes
149+
# runnable in R per the sharp-golden lesson).
150+
x4_ties <- round(x4, 2)
151+
152+
golden$dgp_fuzzy <- list(
153+
x = x4, y = y4, t = t4, t_one = t4_one, x_ties = x4_ties,
154+
configs = list(
155+
default = run_estimate(y4, x4, fuzzy = t4),
156+
sharpbw_true = run_estimate(y4, x4, fuzzy = t4, sharpbw = TRUE),
157+
manual_h = run_estimate(y4, x4, fuzzy = t4, h = 0.2),
158+
epa = run_estimate(y4, x4, fuzzy = t4, kernel = "epa"),
159+
msetwo = run_estimate(y4, x4, fuzzy = t4, bwselect = "msetwo"),
160+
one_sided = run_estimate(y4, x4, fuzzy = t4_one),
161+
ties_adjust = run_estimate(y4, x4_ties, fuzzy = t4)
162+
)
163+
)
164+
119165
senate_path <- "benchmarks/data/rdrobust_senate.csv"
120166
stopifnot(file.exists(senate_path))
121167
senate <- read.csv(senate_path)

benchmarks/data/rdrobust_estimates_golden.json

Lines changed: 324 additions & 2 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)