Skip to content

Commit ececea3

Browse files
igerberclaude
andcommitted
feat(rdd): covariate-adjusted RD - covariates= on fit() with rdrobust 4.0.0 parity (CCFT 2019)
Adds the CCFT 2019 additive common-coefficient covariate adjustment to RegressionDiscontinuity (sharp AND fuzzy), parity-targeting R rdrobust 4.0.0 end-to-end. The estimand is unchanged (precision only, unlike the DiD estimators' conditional-parallel-trends covariates role); bandwidths are covariate-aware (Z threads into every pilot of all three selector chains with a per-pilot partialled gamma); collinear covariates are dropped with a warning naming them under covs_drop=True (R's exact dqrdc2 rank/pivot semantics incl. the name-length column sort, ported directly; covs_drop=False is a deterministic strict error). Degenerate covariate adjustment is GUARDED, not reproduced (documented Deviation from R): R's ginv(tol=1e-20) inverts a float-noise singular value on constant covariates / full dummy sets, silently returning platform-dependent estimates; diff-diff excludes per-column degeneracies (a constant covariate reproduces the fit without it bit-for-bit), applies a scale-invariant stabilized cut for rank-deficient sets (a full dummy set reproduces the drop-one-category fit - span invariance), and warns naming the columns. Well-posed systems match R at machine precision. Surfaces: covariates= (fit-time, library-wide name), covs_drop= constructor knob, name-keyed covariate_coefficients / first_stage_covariate_coefficients + covariates/covariates_dropped/ covs_drop result echoes, covariate-adjusted summary() banner. Goldens 23 -> 32 configs (9 covariate configs incl. msetwo/cercomb2 chain pins, collinear-drop, ties, fuzzy, fuzzy-sharpbw; all 23 pre-existing configs reproduced exactly on regeneration; worst covariate-config disagreement 6.2e-12). R-free anchors: the CCFT 2019 partial-out identity tau_adj = tau_unadj - gamma' tau_Z exact at common manual bandwidths (both conventional and bias-corrected rows), span/order invariance, CI shrinkage. Docs: REGISTRY covariate subsection + deviation notes, CCFT-2019/2017 review checkbox flips, api rst, llms guides, README, CHANGELOG, references, choosing-estimator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGca52n6H8oDDXALjjrsp4
1 parent 51ce969 commit ececea3

20 files changed

Lines changed: 2308 additions & 194 deletions

CHANGELOG.md

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

1010
### Added
11-
- **`RegressionDiscontinuity` - sharp AND fuzzy regression discontinuity estimation
12-
with robust bias-corrected inference (alias `RDD`).** Local-polynomial RD per
11+
- **`RegressionDiscontinuity` - sharp, fuzzy, AND covariate-adjusted regression
12+
discontinuity estimation with robust bias-corrected inference (alias `RDD`).** Local-polynomial RD per
1313
Calonico, Cattaneo & Titiunik (2014), parity-targeting R `rdrobust` 4.0.0
1414
end-to-end: all 10 data-driven bandwidth selectors (`mserd` default,
1515
`msetwo`/`msesum`/comb and the CER-optimal variants),
@@ -29,6 +29,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
robust CI contains zero (documented deviation - R is silent; CCT 2014 Theorem 3
3030
"guard and warn", Feir-Lemieux-Marmer weak-IV inference a documented seam); R's
3131
exact no-variation-no-jump identification error is raised on both entry points.
32+
**Covariate adjustment** via `fit(..., covariates=[...])` (R's `covs=`; CCFT 2019):
33+
additive common-coefficient adjustment with a pooled-across-sides gamma that
34+
leaves the estimand UNCHANGED (precision only - explicitly unlike the DiD
35+
estimators' conditional-parallel-trends `covariates` role; the operative,
36+
testable requirement is covariate balance at the cutoff, and the placebo recipe
37+
- fit each covariate as the outcome - is documented); bandwidths are
38+
covariate-aware (Z stacked into every pilot with a per-pilot gamma); collinear
39+
columns are dropped with a warning NAMING them under `covs_drop=True` (R's
40+
default and its exact dqrdc2 rank/pivot semantics incl. the name-length column
41+
sort, ported directly; `covs_drop=False` = deterministic strict error); fitted
42+
gammas are exposed name-keyed (`covariate_coefficients`, fuzzy
43+
`first_stage_covariate_coefficients`) and `covariates`/`covariates_dropped`/
44+
`covs_drop` echo on the results. Degenerate adjustments are GUARDED, not
45+
reproduced (documented deviation): R's `ginv(tol=1e-20)` inverts a float-noise
46+
singular value on constant covariates / full dummy sets, silently returning
47+
platform-dependent estimates; diff-diff excludes degenerate columns, applies a
48+
scale-invariant stabilized cut for rank-deficient sets (a full dummy set
49+
reproduces the drop-one-category fit exactly), and warns naming the columns.
3250
**Canonical binding:** `att`/`se`/`t_stat`/`p_value`/`conf_int` are ONE
3351
coherent row - the robust bias-corrected row (`att = tau_bc`, CI centered on it,
3452
`t_stat == att/se`), preserving the library-wide field identities; the new
@@ -37,20 +55,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3755
row, and `summary()` prints the familiar three-row table. Estimation-path port
3856
(`rdrobust_fit`: Q_q bias-correction score matrix, conventional/robust NN
3957
sandwiches, fuzzy ratio/first-stage variances) validated against a new estimates
40-
golden (`benchmarks/data/rdrobust_estimates_golden.json`, 23 configurations incl.
41-
the Senate anchors and 7 fuzzy configs - default/sharpbw/manual-h/epa/msetwo/
42-
one-sided-compliance/ties) at rtol=1e-9 in `tests/test_rdd_parity.py` (+ port-level
43-
linearized-bias pins in `tests/test_rdrobust_port.py`); R-free methodology
44-
anchors (CCT 2014 Remark 7 bias-corrected == local-quadratic equivalence at rel
45-
1e-10 across all kernels, perfect-compliance == sharp reproduction, bandwidth
46-
auto-switch locks, invariances, joint-NaN degenerate contracts) in
58+
golden (`benchmarks/data/rdrobust_estimates_golden.json`, 32 configurations incl.
59+
the Senate anchors, 7 fuzzy configs - default/sharpbw/manual-h/epa/msetwo/
60+
one-sided-compliance/ties - and 9 covariate configs - default/manual-h/msetwo/
61+
cercomb2/epa/collinear-drop/ties/fuzzy/fuzzy-sharpbw with `coef_covs` gamma
62+
pins) at rtol=1e-9 in `tests/test_rdd_parity.py` (+ port-level linearized-bias,
63+
gamma-matrix, and dqrdc2 rank/pivot pins in `tests/test_rdrobust_port.py`);
64+
R-free methodology anchors (CCT 2014 Remark 7 bias-corrected == local-quadratic
65+
equivalence at rel 1e-10 across all kernels, perfect-compliance == sharp
66+
reproduction, bandwidth auto-switch locks, invariances, joint-NaN degenerate
67+
contracts, and the CCFT 2019 partial-out identity - exact at common manual
68+
bandwidths - plus covariate span-/order-invariance and CI-shrinkage anchors) in
4769
`tests/test_rdd_methodology.py`; API/validation suite in `tests/test_rdd.py`.
4870
Deviations from R (each labeled in the REGISTRY section): warn-instead-of-silent
4971
NaN drops, warn-and-ignore `b`-without-`h`, the weak-first-stage warning,
50-
warn-and-ignore `sharpbw` on sharp fits, fail-closed targeted errors on
51-
degenerate designs, and the canonical-binding note above. Covariates (CCFT 2019 -
52-
review on file), cluster-robust variance, weights, kink estimands, weak-IV-robust
53-
fuzzy inference, and rdplot/density diagnostics are documented follow-ups.
72+
warn-and-ignore `sharpbw` on sharp fits (and `covs_drop=False` without
73+
covariates), fail-closed targeted errors on degenerate designs, the guarded
74+
degenerate covariate adjustment, and the canonical-binding note above.
75+
Cluster-robust variance, weights, kink estimands, weak-IV-robust fuzzy
76+
inference, a packaged covariate-balance helper, and rdplot/density diagnostics
77+
are documented follow-ups.
5478
- **Internal: mypy enforced at zero errors.** Triaged the 184 pre-existing
5579
`mypy diff_diff` errors to an enforceable zero and added a blocking Mypy job to
5680
the Lint CI workflow (pinned `mypy==2.1.0` + pinned numpy/pandas/scipy for stub
@@ -134,9 +158,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
134158

135159
### Changed
136160
- `diff_diff/guides/llms-autonomous.txt` no longer lists regression discontinuity as
137-
out of scope: sharp AND fuzzy RD route to `RegressionDiscontinuity`; only kink
138-
designs and the covariate-adjusted / cluster-robust RD variants are referred to
139-
external tooling.
161+
out of scope: sharp, fuzzy, AND covariate-adjusted RD route to
162+
`RegressionDiscontinuity`; only kink designs and the cluster-robust RD variant
163+
are referred to external tooling.
140164
- **Internal: repo-wide lint normalization + pinned tooling.** black/ruff/mypy are now
141165
pinned exactly in the `dev` extra (`black==26.3.1`, `ruff==0.15.13`, `mypy==2.1.0`;
142166
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 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`.
115+
- [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2014) sharp, fuzzy, AND covariate-adjusted 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; covariates via `covariates=` - CCFT 2019, same estimand, covariate-aware bandwidths). 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: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
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).
1+
# Golden-value generator for the diff-diff RD ESTIMATION port - sharp,
2+
# fuzzy, AND covariate-adjusted (diff_diff/_rdrobust_port.py::rdrobust_fit
3+
# and the public RegressionDiscontinuity estimator; 32 configs across five
4+
# synthetic DGPs + the Senate data, incl. 7 fuzzy configs with full
5+
# first-stage tau_T/se_T/z_T/pv_T/ci_T blocks and 9 covariate configs with
6+
# coef_covs (gamma) pins; covariate names deliberately differ in length
7+
# and are passed UNSORTED so every covariate config also pins rdrobust's
8+
# order(nchar(colnames)) column sort, rdrobust.R:131).
69
#
710
# Deliberately a SEPARATE file/JSON from generate_rdrobust_golden.R so the
811
# bandwidth fixtures reviewed in the machinery PR are never regenerated.
@@ -27,19 +30,24 @@ TARBALL_SHA256 <- "78f0d6b4bdec4091cc8f42f6f1598704747f95926446d3aaee381ea1d613a
2730
run_estimate <- function(y, x, c = 0, masspoints = "adjust", kernel = "tri",
2831
p = 1, q = 2, h = NULL, b = NULL, rho = NULL,
2932
level = 95, bwselect = "mserd",
30-
fuzzy = NULL, sharpbw = FALSE) {
33+
fuzzy = NULL, sharpbw = FALSE,
34+
covs = NULL, covs_drop = TRUE) {
3135
args <- list(y = y, x = x, c = c, masspoints = masspoints, kernel = kernel,
3236
p = p, q = q, level = level, bwselect = bwselect,
33-
sharpbw = sharpbw)
37+
sharpbw = sharpbw, covs_drop = covs_drop)
3438
if (!is.null(h)) args$h <- h
3539
if (!is.null(b)) args$b <- b
3640
if (!is.null(rho)) args$rho <- rho
3741
if (!is.null(fuzzy)) args$fuzzy <- fuzzy
42+
if (!is.null(covs)) args$covs <- covs
3843
r <- suppressWarnings(do.call(rdrobust, args))
3944
out <- list(
4045
c = c, masspoints = masspoints, kernel = kernel, p = p, q = q,
4146
bwselect = bwselect,
4247
fuzzy_in = !is.null(fuzzy), sharpbw = sharpbw,
48+
covs_in = !is.null(covs),
49+
covs_names = if (is.null(covs)) NA else colnames(covs),
50+
covs_drop = covs_drop,
4351
h_in = if (is.null(h)) NA else h,
4452
b_in = if (is.null(b)) NA else b,
4553
rho_in = if (is.null(rho)) NA else rho,
@@ -66,6 +74,12 @@ run_estimate <- function(y, x, c = 0, masspoints = "adjust", kernel = "tri",
6674
out$beta_t_p_l <- unname(as.vector(r$beta_T_p_l))
6775
out$beta_t_p_r <- unname(as.vector(r$beta_T_p_r))
6876
}
77+
if (!is.null(covs)) {
78+
# Common projection coefficients gamma (dZ_kept x 1 sharp, x 2 fuzzy)
79+
# over the covariates KEPT after covs_drop, in R's nchar-sorted column
80+
# order; the row count pins WHICH columns survived the drop.
81+
out$coef_covs <- unname(as.matrix(r$coef_covs))
82+
}
6983
out
7084
}
7185

@@ -75,14 +89,15 @@ golden$metadata <- list(
7589
rdrobust_version = as.character(packageVersion("rdrobust")),
7690
rdrobust_tarball_sha256 = TARBALL_SHA256,
7791
seeds = list(dgp_lee_smooth = 42L, dgp_ties_moderate = 123L,
78-
dgp_asymmetric_scaled = 777L, dgp_fuzzy = 314L),
92+
dgp_asymmetric_scaled = 777L, dgp_fuzzy = 314L,
93+
dgp_covs = 2718L),
7994
generator = "benchmarks/R/generate_rdrobust_estimates_golden.R",
8095
algorithm = paste(
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."
96+
"rdrobust() sharp, fuzzy, AND covariate-adjusted estimation blocks",
97+
"(three-row coef/se/z/pv/ci, counts, per-side beta_p; fuzzy configs add",
98+
"the first-stage tau_T/se_T/z_T/pv_T/ci_T rows and per-side beta_T_p;",
99+
"covariate configs add the coef_covs gamma matrix) for the vce='nn'",
100+
"path, complementing the bandwidth fixtures in rdrobust_golden.json."
86101
),
87102
r_version = R.version.string
88103
)
@@ -162,6 +177,42 @@ golden$dgp_fuzzy <- list(
162177
)
163178
)
164179

180+
# Covariate DGP: two informative covariates with NAME LENGTHS that differ
181+
# and are passed UNSORTED (c("zlong", "zb")) so R's order(nchar) column
182+
# sort (rdrobust.R:131) is exercised by every config; zdup is an EXACT
183+
# linear combination for the covs_drop config. covs_ties reuses the
184+
# 2dp-rounded running variable (masspoints machinery x covariates).
185+
set.seed(2718)
186+
n5 <- 1200
187+
x5 <- 2 * rbeta(n5, 2, 4) - 1
188+
zlong <- 0.5 * x5 + rnorm(n5, sd = 0.8)
189+
zb <- rbinom(n5, 1, 0.4)
190+
y5 <- 0.4 * x5 + 0.9 * (x5 >= 0) + 0.7 * zlong + 0.3 * zb + rnorm(n5, sd = 0.3)
191+
t5 <- rbinom(n5, 1, ifelse(x5 >= 0, 0.75, 0.2))
192+
zdup <- 1.5 * zlong - 0.5 * zb
193+
x5_ties <- round(x5, 2)
194+
covs2 <- cbind(zlong = zlong, zb = zb)
195+
covs3 <- cbind(zlong = zlong, zb = zb, zdup = zdup)
196+
197+
golden$dgp_covs <- list(
198+
x = x5, y = y5, t = t5, zlong = zlong, zb = zb, zdup = zdup,
199+
x_ties = x5_ties,
200+
configs = list(
201+
covs_default = run_estimate(y5, x5, covs = covs2),
202+
covs_manual_h = run_estimate(y5, x5, covs = covs2, h = 0.2),
203+
covs_msetwo = run_estimate(y5, x5, covs = covs2,
204+
bwselect = "msetwo"),
205+
covs_cercomb2 = run_estimate(y5, x5, covs = covs2,
206+
bwselect = "cercomb2"),
207+
covs_epa = run_estimate(y5, x5, covs = covs2, kernel = "epa"),
208+
covs_drop_collinear = run_estimate(y5, x5, covs = covs3),
209+
covs_ties = run_estimate(y5, x5_ties, covs = covs2),
210+
fuzzy_covs = run_estimate(y5, x5, covs = covs2, fuzzy = t5),
211+
fuzzy_covs_sharpbw = run_estimate(y5, x5, covs = covs2, fuzzy = t5,
212+
sharpbw = TRUE)
213+
)
214+
)
215+
165216
senate_path <- "benchmarks/data/rdrobust_senate.csv"
166217
stopifnot(file.exists(senate_path))
167218
senate <- read.csv(senate_path)

0 commit comments

Comments
 (0)