Skip to content

feat(rdd): covariate-adjusted RD - covariates= on fit() with rdrobust 4.0.0 parity (CCFT 2019)#691

Merged
igerber merged 2 commits into
mainfrom
rdd-covariates
Jul 18, 2026
Merged

feat(rdd): covariate-adjusted RD - covariates= on fit() with rdrobust 4.0.0 parity (CCFT 2019)#691
igerber merged 2 commits into
mainfrom
rdd-covariates

Conversation

@igerber

@igerber igerber commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds CCFT (2019) covariate adjustment to RegressionDiscontinuity (sharp AND fuzzy) via fit(..., covariates=[...]) (R's covs=) - additive common-coefficient specification with a pooled-across-sides gamma; the estimand is UNCHANGED (precision only, explicitly contrasted with the DiD estimators' conditional-parallel-trends covariates role), and the covariate-balance placebo recipe (fit each covariate as the outcome) is documented in the module docstring and REGISTRY.
  • Bandwidths are covariate-aware: Z stacks into every pilot fit of all three selector chains with a per-pilot partialled gamma and extended delta vectors (fuzzy+covariates composes the ratio machinery with the length-(2+dZ) variance vector of rdrobust.R:722).
  • covs_drop=True constructor knob (R name/default): redundant covariates are dropped via a direct port of LINPACK dqrdc2's rank/pivot loop (R's exact qr(z, tol=1e-7) semantics, incl. the name-length column sort - order never leaks: every user-facing covariate surface is name-keyed and order-invariance is tested) with a warning NAMING the dropped columns; covs_drop=False is a deterministic strict error.
  • Degenerate covariate adjustment is GUARDED, not reproduced (documented Deviation from R in REGISTRY): R's ginv(tol=1e-20) inverts a float-noise singular value on constant covariates / full one-hot dummy sets, silently returning platform-dependent estimates (28% cross-implementation gamma spread, ~0.5% tau shifts observed). diff-diff excludes per-column degeneracies (a constant covariate reproduces the fit without it to numerical precision), applies a scale-invariant stabilized cut for rank-deficient sets (a full dummy set reproduces the drop-one-category fit - span invariance), and warns once per fit naming the columns; well-posed systems solve with the same pinv(rcond=1e-20) semantics as R and match at machine precision.
  • Results surface: name-keyed covariate_coefficients (+ fuzzy first_stage_covariate_coefficients) nuisance-gamma echoes, covariates/covariates_dropped/covs_drop config echoes, covariate-adjusted summary() banner mirroring R's rdmodel string.

Methodology references (required if estimator / math changes)

  • Method name(s): Covariate-adjusted sharp/fuzzy regression discontinuity (additive common-coefficient adjustment)
  • Paper / source link(s): Calonico, Cattaneo, Farrell & Titiunik (2019), "Regression Discontinuity Designs Using Covariates", REStat 101(3), 442-451, https://doi.org/10.1162/rest_a_00760 (review on file: docs/methodology/papers/calonico-cattaneo-farrell-titiunik-2019-review.md); parity source: CRAN rdrobust 4.0.0 (tarball sha256 pinned in diff_diff/_rdrobust_port.py)
  • Any intentional deviations from the source (and why): all labeled in docs/methodology/REGISTRY.md (RegressionDiscontinuity section) - named-column collinearity warning (R's is generic), rank-0 fail-closed error, deterministic covs_drop=False strict mode (R's chol() failure is roundoff-dependent), and the guarded degenerate-adjustment solve above (R's behavior there is platform-noise, i.e. irreproducible even R-to-R across BLAS builds)

Validation

  • Tests added/updated: tests/test_rdrobust_port.py (TestCovsPortGoldenParity - 9 configs incl. gamma matrices and per-side biases; TestCovsPortValidation; TestCovsDropFun - dqrdc2 rank/pivot unit pins vs live-R qr() incl. the tiny-scaled-column trap; TestCovsDegenerateGuard), tests/test_rdd_parity.py (covariate configs + gamma pins; config count lock 23 -> 32), tests/test_rdd_methodology.py (TestCovariates - the CCFT 2019 partial-out identity tau_adj = tau_unadj - gamma' tau_Z EXACT at common manual bandwidths for both conventional and bias-corrected rows, span/order invariance, CI shrinkage, degenerate-guard contracts), tests/test_rdd.py (TestCovariatesAPI)
  • Backtest / simulation / notebook evidence (if applicable): golden regeneration reproduced all 23 pre-existing configs EXACTLY; the 9 new covariate configs match installed rdrobust 4.0.0 at worst 6.2e-12 relative

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

Generated with Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01QGca52n6H8oDDXALjjrsp4

… 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
@github-actions

Copy link
Copy Markdown

Overall Assessment

✅ Looks good. No unmitigated P0/P1 findings.

Executive Summary

  • Affected methods: covariate-adjusted sharp/fuzzy RegressionDiscontinuity, plus rdbwselect() / rdrobust_fit() covariate paths.
  • The implementation matches the Registry’s CCFT 2019 contract: common additive gamma, unchanged estimand, covariate-aware bandwidths, and fuzzy extended variance vector.
  • Inference uses safe_inference() for main and first-stage rows; I did not find new inline inference or partial-NaN guard anti-patterns.
  • covs_drop is propagated through constructor params, bandwidth selection, estimation, results, and tests.
  • Local tests were not runnable in this environment because pytest is not installed.

Methodology

  • Severity: P3 informational
    Impact: Covariate balance is required for consistency, and the automatic packaged balance helper remains deferred. This is documented in the module docstring and Registry with the prescribed placebo recipe, so it is not a blocker.
    Concrete fix: No required fix for this PR; future work can add the packaged helper described in the Registry.
    References: diff_diff/rdd.py:L22-L44, docs/methodology/REGISTRY.md:L3651-L3692, docs/methodology/REGISTRY.md:L3867-L3874.

  • Severity: P3 informational
    Impact: The guarded degenerate covariate solve intentionally deviates from R’s platform-sensitive ginv(tol=1e-20) behavior. This is documented as a Registry deviation and implemented with warnings/strict errors, so it is not a defect.
    Concrete fix: No required fix.
    References: diff_diff/_rdrobust_port.py:L417-L506, docs/methodology/REGISTRY.md:L3823-L3866.

Code Quality

No findings. The new public parameter is validated and included in get_params() / set_params(), and covariates are validated before model assembly.
References: diff_diff/rdd.py:L645-L715, diff_diff/rdd.py:L784-L807.

Performance

No findings. Covariates add expected matrix work to bandwidth pilots and estimation; no avoidable repeated high-cost path stood out in the changed logic.
References: diff_diff/_rdrobust_port.py:L557-L720, diff_diff/_rdrobust_port.py:L1059-L1105.

Maintainability

No findings. The R source anchors and Registry cross-references are explicit, and the covariate-specific logic is centralized in _covs_gamma() / _covs_entry_drop().
References: diff_diff/_rdrobust_port.py:L37-L64, diff_diff/_rdrobust_port.py:L417-L543.

Tech Debt

No untracked blocker found. The deferred covariate-balance helper is documented in the Registry; broader RD v1 seams remain documented.
References: docs/methodology/REGISTRY.md:L3867-L3874.

Security

No findings. I did not see secrets or unsafe external-input behavior in the changed code paths.

Documentation/Tests

  • Severity: P3 informational
    Impact: I could not run the RD test subset locally because pytest is unavailable in this environment (python -m pytest ... failed with No module named pytest).
    Concrete fix: Run the RD test subset in CI or an environment with test dependencies installed.
    References: tests/test_rdd.py, tests/test_rdd_methodology.py, tests/test_rdd_parity.py, tests/test_rdrobust_port.py.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Jul 17, 2026
CI round 1 (Linux/Windows/pure-python; macOS passed): the two
constant-covariate exclusion tests asserted EXACT equality between the
fit with the excluded constant column and the fit without it. The
equality is exact mathematically (the excluded column's gamma row is
exactly 0) but only roundoff-level numerically: the response matrix
still carries the excluded column, and BLAS matmul kernels differ with
matrix shape across platforms (bit-equal on Accelerate, last-ULP
differences on OpenBLAS/Windows). Asserts relaxed to rel=1e-12 and the
"bit-for-bit" claims in the port docstring / REGISTRY / CHANGELOG
softened to "to floating-point roundoff".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGca52n6H8oDDXALjjrsp4
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: deeea5a20ba5f3bc56caea1e6dbc1cfba07aecdd


Overall Assessment

✅ Looks good. No unmitigated P0/P1 findings.

Executive Summary

  • Affected methods: covariate-adjusted sharp/fuzzy RegressionDiscontinuity, plus covariate-aware rdbwselect() and rdrobust_fit() paths.
  • The implementation matches the Registry’s CCFT 2019 contract: common pooled additive gamma, unchanged RD estimand, covariate-aware bandwidth selection, and fuzzy extended variance vector.
  • Prior P3 items remain documented/mitigated; I did not find new P1+ issues in the re-review scope.
  • Inference uses safe_inference() for main and first-stage rows; no new inline inference or partial-NaN guard anti-pattern found.
  • Edge-case coverage is strong for empty covariates, NaNs, strict/drop collinearity, fuzzy interactions, perfect compliance, and sharpbw with covariates.

Methodology

  • Severity: P3 informational
    Impact: Covariate balance is required for CCFT 2019 consistency, but the packaged balance helper remains deferred. This is documented with the placebo recipe: fit each covariate as the outcome. This is not a blocker.
    Concrete fix: No required PR change; future work can add the packaged helper.
    References: diff_diff/rdd.py:L22-L44, docs/methodology/REGISTRY.md:L3651-L3692, docs/methodology/REGISTRY.md:L3870-L3877.

  • Severity: P3 informational
    Impact: The guarded degenerate-covariate solve intentionally deviates from R’s platform-sensitive ginv(tol=1e-20) behavior. The deviation is explicitly documented and implemented with warnings/strict errors, so it is not a methodology defect.
    Concrete fix: No required PR change.
    References: diff_diff/_rdrobust_port.py:L417-L509, docs/methodology/REGISTRY.md:L3823-L3869.

No undocumented methodology mismatch found for covariate-aware bandwidths or fuzzy covariate variance construction. References: diff_diff/_rdrobust_port.py:L560-L723, diff_diff/_rdrobust_port.py:L1637-L1842, docs/methodology/REGISTRY.md:L3711-L3722.

Code Quality

No findings. covs_drop is validated, included in get_params() / set_params(), and propagated through selection, estimation, results, and summaries. Covariate input validation also handles bare strings, generators, duplicates, structural-column collisions, and missing columns. References: diff_diff/rdd.py:L645-L715, diff_diff/rdd.py:L781-L807, diff_diff/rdd.py:L879-L928, diff_diff/rdd.py:L1202-L1214.

Performance

No findings. The added matrix work is expected for covariate-aware pilots and estimation; I did not see an avoidable repeated high-cost path in the changed logic. References: diff_diff/_rdrobust_port.py:L1031-L1108, diff_diff/_rdrobust_port.py:L1131-L1255.

Maintainability

No findings. The R-source anchors and Registry notes are explicit, and the covariate-specific numerical logic is centralized in covs_drop_fun(), _covs_entry_drop(), and _covs_gamma(). References: diff_diff/_rdrobust_port.py:L354-L546.

Tech Debt

No untracked blocker found. RD v1 seams, including cluster/weights, weak-IV-robust fuzzy inference, and the packaged covariate-balance helper, are documented as follow-ups. Reference: docs/methodology/REGISTRY.md:L3870-L3877.

Security

No findings. I did not see secrets or unsafe external-input behavior in the changed paths.

Documentation/Tests

  • Severity: P3 informational
    Impact: I did not run the test suite locally during this read-only review, so execution is left to CI. The added tests cover the main methodology and edge-case surface.
    Concrete fix: Run the RD test subset in CI: tests/test_rdd.py, tests/test_rdd_methodology.py, tests/test_rdd_parity.py, and tests/test_rdrobust_port.py.
    References: tests/test_rdd.py:L492-L589, tests/test_rdd_methodology.py:L423-L618, tests/test_rdd_parity.py:L91-L205, tests/test_rdrobust_port.py:L638-L899.

@igerber
igerber merged commit dc29766 into main Jul 18, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant