Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **`RegressionDiscontinuity` - sharp regression discontinuity estimation with robust
bias-corrected inference (alias `RDD`).** Local-polynomial sharp RD per Calonico,
Cattaneo & Titiunik (2014), parity-targeting R `rdrobust` 4.0.0 end-to-end: all 10
data-driven bandwidth selectors (`mserd` default, `msetwo`/`msesum`/comb and the
CER-optimal variants), triangular/epanechnikov/uniform kernels, `masspoints`
adjust/check/off, manual `h`/`b`/`rho` with R-exact resolution semantics (including
`rho`-without-`h` applying to selected bandwidths and the unconditional N<20
full-range fallback), and the three-row Conventional / Bias-Corrected / Robust
output. **Canonical binding:** `att`/`se`/`t_stat`/`p_value`/`conf_int` are ONE
coherent row - the robust bias-corrected row (`att = tau_bc`, CI centered on it,
`t_stat == att/se`), preserving the library-wide field identities; rdrobust's
printed headline coefficient is exposed as `att_conventional` with a full inference
row, and `summary()` prints the familiar three-row table. Estimation-path port
(`rdrobust_fit_sharp`: Q_q bias-correction score matrix, conventional/robust NN
sandwiches) validated against a new estimates golden
(`benchmarks/data/rdrobust_estimates_golden.json`, 16 configurations incl. the
Senate anchors) at rtol=1e-9 in `tests/test_rdd_parity.py`; R-free methodology
anchors (CCT 2014 Remark 7 bias-corrected == local-quadratic equivalence at rel
1e-10 across all kernels, invariances, joint-NaN degenerate contracts) in
`tests/test_rdd_methodology.py`; API/validation suite in `tests/test_rdd.py`.
Deviations from R (each labeled in the REGISTRY section): warn-instead-of-silent
NaN drops, warn-and-ignore `b`-without-`h`, fail-closed targeted errors on
degenerate designs, and the canonical-binding note above. Sharp designs only:
fuzzy RD, covariates (CCFT 2019 - review on file), cluster-robust variance,
weights, kink estimands, and rdplot/density diagnostics are documented follow-ups.
- **Internal: mypy enforced at zero errors.** Triaged the 184 pre-existing
`mypy diff_diff` errors to an enforceable zero and added a blocking Mypy job to
the Lint CI workflow (pinned `mypy==2.1.0` + pinned numpy/pandas/scipy for stub
Expand All @@ -24,7 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
already-stale ignores were removed. Tightening tracked in TODO.md. No public
API or numerical behavior change.
- **Internal: rdrobust sharp-RD bandwidth-selection port (`diff_diff/_rdrobust_port.py`).**
Step-1 machinery for the upcoming `RegressionDiscontinuity` estimator: a faithful
Step-1 machinery for the `RegressionDiscontinuity` estimator above: a faithful
pure-Python NumPy/SciPy port of R `rdrobust` 4.0.0's `rdbwselect` sharp-RD path (all 10 data-driven
selectors - mserd default, msetwo/msesum/comb and the CER-optimal variants -
triangular/epanechnikov/uniform kernels, `masspoints` adjust/check/off, IK-style
Expand All @@ -36,10 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Cattaneo-Frandsen-Titiunik 2015) validated at rtol=1e-9 across 17 configurations x 10
selectors in `tests/test_rdrobust_port.py` (kernels, p/q orders, deriv, masspoints
modes, stdvars, bwrestrict, scaleregul, nnmatch), including the 2017 Stata Journal
published Senate anchors under `masspoints="off"`. **No public API change** - the module is private;
the estimator, robust bias-corrected inference, and docs surfaces land in the follow-up PR.
Port-level deviations documented in `docs/methodology/REGISTRY.md` (new
RegressionDiscontinuity section stub).
published Senate anchors under `masspoints="off"`. The module is private; the public
estimator, robust bias-corrected inference, and docs surfaces are the
`RegressionDiscontinuity` entry above. Port-level deviations documented in
`docs/methodology/REGISTRY.md` (RegressionDiscontinuity section).
- **Internal: ungated `Lint` CI workflow.** Check-only `ruff check` + `black --check`
(at the versions pinned in the `dev` extra) run on every PR push and on pushes to
main — no `ready-for-ci` label needed; the aggregate `Lint Gate` job is the single
Expand All @@ -65,6 +90,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`benchmarks/data/qte_golden.json`.

### Changed
- `diff_diff/guides/llms-autonomous.txt` no longer lists regression discontinuity as
out of scope: sharp RD routes to `RegressionDiscontinuity`; only fuzzy RD is
referred to external tooling.
- **Internal: repo-wide lint normalization + pinned tooling.** black/ruff/mypy are now
pinned exactly in the `dev` extra (`black==26.3.1`, `ruff==0.15.13`, `mypy==2.1.0`;
the tools require Python >= 3.10 — the library floor stays 3.9); full `black` +
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html) - triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility
- [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
- [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`.
- [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`.
- [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)
- [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
- [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html) - Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment
Expand Down
136 changes: 136 additions & 0 deletions benchmarks/R/generate_rdrobust_estimates_golden.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Golden-value generator for the diff-diff sharp-RD ESTIMATION port
# (diff_diff/_rdrobust_port.py::rdrobust_fit_sharp and the public
# RegressionDiscontinuity estimator).
#
# Deliberately a SEPARATE file/JSON from generate_rdrobust_golden.R so the
# bandwidth fixtures reviewed in the machinery PR are never regenerated.
# Same parity target: CRAN rdrobust 4.0.0 (tarball sha256
# 78f0d6b4bdec4091cc8f42f6f1598704747f95926446d3aaee381ea1d613a36f);
# the GitHub 4.1.0-dev tree must NOT be installed when regenerating.
#
# Outputs benchmarks/data/rdrobust_estimates_golden.json: per config the
# embedded inputs (17 significant digits), selected/echoed bandwidths, the
# full three-row (Conventional / Bias-Corrected / Robust) coef/se/z/pv/ci
# block, effective counts, and per-side order-p coefficient vectors.
#
# Run from the repo root: Rscript benchmarks/R/generate_rdrobust_estimates_golden.R

library(rdrobust)
library(jsonlite)

stopifnot(packageVersion("rdrobust") == "4.0.0")

TARBALL_SHA256 <- "78f0d6b4bdec4091cc8f42f6f1598704747f95926446d3aaee381ea1d613a36f"

run_estimate <- function(y, x, c = 0, masspoints = "adjust", kernel = "tri",
p = 1, q = 2, h = NULL, b = NULL, rho = NULL,
level = 95, bwselect = "mserd") {
args <- list(y = y, x = x, c = c, masspoints = masspoints, kernel = kernel,
p = p, q = q, level = level, bwselect = bwselect)
if (!is.null(h)) args$h <- h
if (!is.null(b)) args$b <- b
if (!is.null(rho)) args$rho <- rho
r <- suppressWarnings(do.call(rdrobust, args))
list(
c = c, masspoints = masspoints, kernel = kernel, p = p, q = q,
bwselect = bwselect,
h_in = if (is.null(h)) NA else h,
b_in = if (is.null(b)) NA else b,
rho_in = if (is.null(rho)) NA else rho,
level = level,
h_l = r$bws["h", "left"], h_r = r$bws["h", "right"],
b_l = r$bws["b", "left"], b_r = r$bws["b", "right"],
tau_cl = unname(r$coef[1, 1]), tau_bc = unname(r$coef[2, 1]),
se_cl = unname(r$se[1, 1]), se_rb = unname(r$se[3, 1]),
z = unname(r$z[, 1]), pv = unname(r$pv[, 1]),
ci_lower = unname(r$ci[, 1]), ci_upper = unname(r$ci[, 2]),
N = unname(r$N), N_h = unname(r$N_h), N_b = unname(r$N_b),
bias = unname(r$bias),
beta_p_l = unname(as.vector(r$beta_Y_p_l)),
beta_p_r = unname(as.vector(r$beta_Y_p_r))
)
}

golden <- list()

golden$metadata <- list(
rdrobust_version = as.character(packageVersion("rdrobust")),
rdrobust_tarball_sha256 = TARBALL_SHA256,
seeds = list(dgp_lee_smooth = 42L, dgp_ties_moderate = 123L,
dgp_asymmetric_scaled = 777L),
generator = "benchmarks/R/generate_rdrobust_estimates_golden.R",
algorithm = paste(
"rdrobust() sharp-RD estimation blocks (three-row coef/se/z/pv/ci,",
"counts, per-side beta_p) for the vce='nn' no-covariate path,",
"complementing the bandwidth fixtures in rdrobust_golden.json."
),
r_version = R.version.string
)

# Same seeded DGPs as generate_rdrobust_golden.R (kept in sync by seed).
set.seed(42)
n <- 1000
x1 <- 2 * rbeta(n, 2, 4) - 1
y1 <- 0.48 + 1.27 * x1 + 7.18 * x1^2 + 20.21 * x1^3 + 21.54 * x1^4 +
7.33 * x1^5 + 0.04 * (x1 >= 0) + rnorm(n, sd = 0.1295)

golden$dgp_lee_smooth <- list(
x = x1, y = y1,
configs = list(
default = run_estimate(y1, x1),
manual_h = run_estimate(y1, x1, h = 0.15),
h_rho2 = run_estimate(y1, x1, h = 0.15, rho = 2),
rho_only = run_estimate(y1, x1, rho = 2),
p2q3 = run_estimate(y1, x1, p = 2, q = 3),
p0q1 = run_estimate(y1, x1, p = 0, q = 1),
epa = run_estimate(y1, x1, kernel = "epa"),
uni = run_estimate(y1, x1, kernel = "uni"),
level90 = run_estimate(y1, x1, level = 90),
msetwo = run_estimate(y1, x1, bwselect = "msetwo"),
cercomb2 = run_estimate(y1, x1, bwselect = "cercomb2")
)
)

set.seed(123)
n2 <- 800
x2 <- round(2 * rbeta(n2, 2, 4) - 1, 2)
y2 <- 0.5 + 0.8 * x2 + (x2 >= 0) * 1.0 + rnorm(n2, sd = 0.3)

golden$dgp_ties_moderate <- list(
x = x2, y = y2,
configs = list(
adjust = run_estimate(y2, x2, masspoints = "adjust"),
off = run_estimate(y2, x2, masspoints = "off")
)
)

set.seed(777)
n3 <- 300
x3 <- 40 * rbeta(n3, 5, 2)
y3 <- 2 + 0.05 * x3 - 0.001 * x3^2 + 0.8 * (x3 >= 28) + rnorm(n3, sd = 0.5)

golden$dgp_asymmetric_scaled <- list(
x = x3, y = y3,
configs = list(
default = run_estimate(y3, x3, c = 28)
)
)

senate_path <- "benchmarks/data/rdrobust_senate.csv"
stopifnot(file.exists(senate_path))
senate <- read.csv(senate_path)
ok <- complete.cases(senate$vote, senate$margin)
sv <- senate$vote[ok]
sm <- senate$margin[ok]

golden$senate <- list(
csv = "benchmarks/data/rdrobust_senate.csv",
configs = list(
adjust = run_estimate(sv, sm, masspoints = "adjust"),
off = run_estimate(sv, sm, masspoints = "off")
)
)

out_path <- "benchmarks/data/rdrobust_estimates_golden.json"
write_json(golden, out_path, auto_unbox = TRUE, pretty = TRUE, digits = I(17))
cat("Wrote", out_path, "\n")
517 changes: 517 additions & 0 deletions benchmarks/data/rdrobust_estimates_golden.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions diff_diff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@
TreatmentDoseShape,
profile_panel,
)
from diff_diff.rdd import (
RegressionDiscontinuity,
RegressionDiscontinuityResults,
)
from diff_diff.results import (
DiDResults,
MultiPeriodDiDResults,
Expand Down Expand Up @@ -308,6 +312,7 @@
DCDH = ChaisemartinDHaultfoeuille
HAD = HeterogeneousAdoptionDiD
CiC = ChangesInChanges
RDD = RegressionDiscontinuity

__version__ = "3.7.0"
__all__ = [
Expand Down Expand Up @@ -508,6 +513,10 @@
"HeterogeneousAdoptionDiDResults",
"HeterogeneousAdoptionDiDEventStudyResults",
"HAD",
# RegressionDiscontinuity (sharp RD, rdrobust parity)
"RegressionDiscontinuity",
"RegressionDiscontinuityResults",
"RDD",
# HeterogeneousAdoptionDiD pre-test diagnostics (Phase 3)
"qug_test",
"stute_test",
Expand Down
Loading
Loading