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
6 changes: 5 additions & 1 deletion DEFERRED.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ provenance and AI-review deviation-documentation: a row here (or in
| **`LPDiD` non-absorbing exit-event dynamics** (Dube et al. 2025 online Appendix C `eta_h^{g,n}`): the shipped `non_absorbing` modes estimate the **entry-effect** estimands (Eq. 12/13) only; separate dynamic event-studies for treatment switch-*offs* are not implemented. Needs the exit-event clean-sample derivation + estimand contract. | `lpdid.py`, REGISTRY | PR-C follow-up | Low |
| MMM interop, survey/RC totals for the five `aggregate('total')` adopters (CS/DMLDiD/EfficientDiD/ImputationDiD/TwoStageDiD; the shipped v1 is panel non-survey only and fails closed with `NotImplementedError` on repeated-cross-section-routed fits, fits declaring a `survey_design=`, and the CS/DMLDiD bare-`cluster=` divergent corner where the cohort-mass weighting disagrees with the complete-case count): needs (a) DESIGN-AWARE raw-mass recovery - analytic pweight/aweight resolved weights are normalized to sum = n and CS retains no raw record (fit-time RAW retention required there; CS accepts pweight only), while EDiD's analytic fweight fits and all replicate-design fits already retain raw scale in the kits and Imputation/TwoStage retain raw `SurveyMetadata.sum_weights` - and (b) the mass-uncertainty `att*dC` variance derivation (the shipped relay conditions on the realized mass and omits it). Never ship a survey/RC total from normalized masses. | `diff_diff/staggered_results.py`, `diff_diff/survey.py` | mmm-interop | Low |
| **`LPDiD` non-absorbing interior-gap support**: non-absorbing modes require a gap-free panel within each unit's observed span and raise on interior time gaps (the `[t-L, t+h]` window conditions can't be verified across a gap). The absorbing path already reindexes interior gaps to the calendar grid; extending that fail-closed handling (per-window gap masking) to non-absorbing is deferred. | `lpdid.py::_prepare_panel` | PR-C follow-up | Low |
| Bad-control lane on repeated cross sections: Caetano et al. (2026) Remark 1 calls the approach unavailable "to a large extent" with repeated cross sections (`X_{g-1}` and `X_t` must be observed for the same unit); an RCS variant needs its own derivation. `DMLDiD(panel=False).fit(bad_control=)` fails closed. | `dml_did.py` | bad-controls PR-B | Low |
| Limited-anticipation extension of the bad-control lane: MP-2 assumes no anticipation for `Y` AND `X`; the base-period convention for `X` and `W` under `anticipation > 0` is not developed by the paper. `DMLDiD(anticipation=k).fit(bad_control=)` fails closed. | `dml_did.py` | bad-controls PR-B | Low |
| Universal-base bad-control pre-test: MP-5 conditions on the immediately preceding period, and under `base_period="universal"` a pre cell with `t < g - 1` would read `X_t` earlier than `X_{g-1}` / `W`, inverting the ordering; a universal-base version needs its own derivation. `DMLDiD(base_period="universal").fit(bad_control=)` fails closed. | `dml_did.py` | bad-controls PR-B | Low |
| Bad-control lane under `survey_design=`: the nested second-stage nuisances under design weights (Hajek moments, `sample_weight` into the nested fits) and the replicate-weight variance of the nested scores are unvalidated; fails closed (bare `cluster=` is supported). | `dml_did.py` | bad-controls PR-B | Low |

## Needs external reference (R / Stata / Julia)

Expand Down Expand Up @@ -111,7 +115,7 @@ For survey-specific limitations (`NotImplementedError` paths), see the
| Rust local-method `estimate_model` → unify to `solve_wls_svd` (the global-method's SVD helper) for sub-1e-14 bootstrap-SE parity. The local-method bootstrap parity test passes at `atol=1e-5`; the residual ~1e-7 is roundoff, not a user-visible correctness bug. | `rust/src/trop.rs`, `rust/src/linalg.rs` | follow-up | Low |
| Validate the `.txt` AI guides (`llms-full.txt`, `llms-practitioner.txt`) as executable snippets — **not low-lift** (re-scoped 2026-06-01): only ~20% of ~112 fenced blocks are standalone-runnable; the rest are signature pseudo-code, context fragments, or data-shape-specific. Needs signature-block detection + a context/data skip-allowlist + per-snippet fixtures. | `tests/test_doc_snippets.py` | #239 | Low |
| `TestWorkflowDoesNotExecutePRHeadCode` (CodeQL #14 guard) doesn't model `bash/sh/./source <script>` execution, multi-line `python3 -c` bodies, shell-var indirection, `eval`, `find -exec`, `xargs -I`. Catches common accidental regressions (16 forms); closing the residuals needs multi-line shell parsing + script-exec allowlists — diminishing return given the documented threat model. | `tests/test_openai_review.py`, `.github/workflows/ai_pr_review.yml` | #436 | Low |
| Calendar-time aggregation (R `did` feature gap) — blocks 1 ported test in `test-att_gt.R`. | — | — | Low |
| Calendar-time aggregation (R `did` feature gap) — blocks 1 ported test in `test-att_gt.R`; not supported by CallawaySantAnna or DMLDiD (incl. the bad-control lane). | `staggered_results.py`, `dml_did_results.py` | — | Low |
| **Speculative / low-value performance notes** (relocated from the old `## Performance Optimizations`): numba JIT for bootstrap loops — **blocked by the numpy/pandas/scipy-only dependency policy**; generic sparse-matrix handling for large FE; QR+SVD rank-detection redundancy in `solve_ols` (QR overhead is minimal vs the SVD solve — correctness over micro-opt; `skip_rank_check` already exists for known-full-rank hot paths); incomplete `check_finite=False` bypass (scipy's QR in `_detect_rank_deficiency()` still validates; edge-case only). All `Low`, none correctness-affecting. | `linalg.py::solve_ols` | — | Low |
| `SpilloverDiD` `covariates=` support (Wave B MVP scope cut): the full covariate path mirroring `TwoStageDiD._fit_untreated_model` (stage-1 residualization on the untreated-and-unexposed subsample, then subtraction before stage 2) — appending covariates only at stage 2 would silently bias `tau_total`/`delta_j`; rejected at `fit()` with `NotImplementedError`. | `spillover.py` | Wave B | Low |
| `SpilloverDiD` estimator-level end-to-end vcov reconstruction tests (`bread @ meat @ bread` against `res.vcov`): requires exposing the estimator's internal `X_2_kept` design arrays; the surface is currently pinned from different angles (uniform-weight bit-identity, drift goldens, manual lincom reconstruction at rtol=1e-6). | `spillover.py`, `tests/test_spillover.py` | follow-up | Low |
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,12 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
- [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment
- [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator via `method="qdid"`; bootstrap inference; R qte parity. Alias `CiC`
- [LWDiD](https://diff-diff.readthedocs.io/en/stable/api/lwdid.html) - Lee & Wooldridge (2025, 2026) rolling-transformation DiD: unit-specific demean/detrend converts panel to cross-section, staggered adoption, `estimation_method` in `reg`/`ipw`/`dr`/`psm` (the papers' RA/IPW/IPWRA plus propensity-score matching), exact small-N inference on the classical collapsed regression
- [DMLDiD](https://diff-diff.readthedocs.io/en/stable/api/dml_did.html) - Chang (2020) double/debiased machine learning DiD: staggered ATT(g,t) with cross-fitted ML nuisance learners (DML2) and Neyman-orthogonal scores, for flexible/high-dimensional covariate adjustment under conditional parallel trends; panel or declared repeated cross sections (`panel=False`); survey/cluster support on both lanes
- [DMLDiD](https://diff-diff.readthedocs.io/en/stable/api/dml_did.html) - Chang (2020) double/debiased machine learning DiD: staggered ATT(g,t) with cross-fitted ML nuisance learners (DML2) and Neyman-orthogonal scores, for flexible/high-dimensional covariate adjustment under conditional parallel trends; panel or declared repeated cross sections (`panel=False`); survey/cluster support on both lanes (bad-control lane: panel only, `cluster=` only); Caetano, Callaway, Payne & Sant'Anna (2026) bad-control score via `fit(bad_control=, bad_control_covariates=)`
- [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings

## Diagnostics & Sensitivity

- [Bad-control pre-test](https://diff-diff.readthedocs.io/en/stable/api/dml_did.html) - Caetano, Callaway, Payne & Sant'Anna (2026) Remark 6 `ATT_X(g,t)`: the effect of treatment on a suspected bad control itself, per (g,t) cell (`DMLDiDResults.bad_control_summary()`; analytical SE; pre-period rows pre-test the identifying assumptions MP-5 / MP-8 and should be zero, post-period rows are the Condition-2 check that treatment affects the covariate)
- [RD Plots](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2015) optimal data-driven RD plots (`RDPlot`): all 8 rdrobust `binselect` bin selectors, implied-scale/WIMSE-weight reporting, optional matplotlib rendering
- [Manipulation Testing](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Cattaneo, Jansson & Ma (2020) density-discontinuity test (`RDDensityTest`): rddensity 3.0 parity, robust bias-corrected inference, unrestricted/restricted models, mass-point adjustment
- [Parallel Trends Testing](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html) - simple and Wasserstein-robust parallel trends tests, equivalence testing (TOST)
Expand Down
7 changes: 7 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ Related tracking surfaces:
| `WooldridgeDiD` does not apply the W2025 Sec 5.4 `D_{G_max} x X` covariate normalization, and three sibling covariate rank deficiencies are pre-existing. Measured with the period range pinned and only the never-treated units toggled: (1) time-invariant `exovar` is absorbed by the unit FE, 4 of 26 columns, IDENTICALLY with and without never-treated units; (2) `xgvar`'s cell x covariate block, 19 of 41, identical on both panels; (3) `xtvar` under `demean_covariates=False` does exhibit the `sum_g D_g x = x` dependency that the default demeaning removes; (4) the newly-reachable case -- time-VARYING data passed through `exovar`, which its own docstring reserves for time-invariant covariates -- where the paper's `dT_i` rule would give a deterministic `D_{G_max} x X` drop instead of QR's arbitrary pick (coefficients unaffected, `1.35e-14`; `rank_deficient_action="error"` raises). REGISTRY's narrowed Sec 5.4 note cross-references this row. **Trap for whoever takes it:** `xtvar` under the DEFAULT `demean_covariates=True` is FULL RANK -- the raw block carries demeaned values while `D_g x X` carries raw ones -- and forcing the drop there moves `overall_att` 1.11903 -> 1.46269. Pinned as-is by `TestComparisonSupportFiltering::test_cells_derived_groups_did_not_leak_into_the_design`. | `diff_diff/wooldridge.py` | #729-followup | Heavy | Medium |
| `WooldridgeDiD.n_control_units` counts never-treated UNITS on `control_group="never_treated"` regardless of method, but on the nonlinear paths (`logit`/`poisson`) treated units' pre-treatment rows ARE the identifying comparison -- only the OLS path absorbs them into their own cells. So the reported count under-states the comparison pool exactly where the REGISTRY control-pool asymmetry note applies. Widen to `not_yet_treated or (never_treated and method != "ols")`, or document the count as never-treated-units-by-definition. Behavior is PRE-EXISTING; documented for now in the REGISTRY control-pool Note rather than changed, because widening moves a public results field and wants its own ledger row and test matrix. | `diff_diff/wooldridge.py` | #729-followup | Mid | Low |
| `WooldridgeDiD` has no opt-out for comparison-support period filtering: a user who would rather see the refusal than a reduced sample cannot ask for it. Adding one means a constructor parameter (`get_params`/`set_params` propagation, transactional validation), a ledger row, and a test matrix across both predicate branches and all three `rank_deficient_action` modes -- deliberately out of scope for the change that introduced the filter. The always-on warning is the interim answer. | `diff_diff/wooldridge.py` | #729-followup | Mid | Low |
| Bad-control imputation estimator (Caetano et al. 2026 Section 6.1, Eqs. 5-7, S8 influence function) on a CallawaySantAnna `estimation_method="reg"` host: two untreated-sample OLS fits per cell plus the generated-regressor IF line. | `staggered.py` | bad-controls PR-B | Heavy | Low |
| Bad-control SC "parallel trends for X" variant (Caetano et al. 2026 Section 7 / S17): a linearity-based alternative to covariate unconfoundedness with its own estimand. | `dml_did.py` | bad-controls PR-B | Heavy | Low |
| `ATT_X(g,t)` event-study aggregation + bootstrap replay for the bad-control pre-test (today analytical per-cell only; never aggregated). | `dml_did_results.py` | bad-controls PR-B | Mid | Low |
| Remark-4-weighted overall ATT for the bad-control lane (`sum_g sum_{t>=g} [P(G=g given G in G_bar)/(T-g+1)] ATT(g,t)`: cohort mass among the ever-treated divided by the cohort's post-period count); the headline `att` is CS "simple" (REGISTRY DMLDiD Note). | `staggered_aggregation.py` | bad-controls PR-B | Mid | Low |
| Allow empty `Z` on the bad-control lane (the paper's `Z` may be empty; DMLDiD's covariates-required gate currently applies). | `dml_did.py` | bad-controls PR-B | Quick | Low |
| Recover the Supplementary Appendix DGPs 2 / 3 / 5 (nonlinear `W` / `X` terms) on the bad-control ML lane - needs a flexible-learner fixture via the duck-typed learner route (the parametric built-ins are inconsistent there by design, Table S1). | `tests/test_methodology_dml_did.py` | bad-controls PR-B | Mid | Low |

### Performance

Expand Down Expand Up @@ -95,3 +101,4 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
| Clean-negative precision re-run: Campaign 1's `s3_negative` plans were base_sha-contaminated (real defects at the pinned base), so hallucination rate cleared (dual 3.4%) but trivia-flooding on a *genuinely* clean plan is unmeasured. Small future run (~30 s3-style reviews on 3-5 constructed-clean plans, not the full 120-matrix). | `tools/plan-review-eval/` | campaign-1 verdict | Mid | Low |
| `trop-silent-drop` criteria regression: the rewritten `criteria.md` dropped a silent-failure catch the OLD criteria had (arm A caught it, B/C missed — campaign-1 A-vs-B contrast). Patch the criteria + re-validate (re-opens the criteria identity, so gated behind a re-validation run). | `.claude/skills/plan-review/criteria.md`, `tools/plan-review-eval/` | campaign-1 verdict | Mid | Medium |
| The Stata `jwdid` parity arms cover BALANCED panels only. `jwdid_alltreated` pins the all-eventually-treated cell set, `N` and SE ratio on the `mpdta` subset (191 units, 955 rows, 764 estimated), but every arm is a balanced frame, so REGISTRY's parity claim is scoped to that case. The comparison-support predicate's UNBALANCED behavior -- where `G_max` may be unobserved at later periods so the closed form `t < G_max - anticipation` does not hold and support is set by whichever cohorts are actually observed -- is verified against the predicate directly, not against Stata. Add an unbalanced `jwdid` arm to close that gap. | `benchmarks/stata/generate_etwfe_cs_golden.do`, `tests/test_etwfe_cs_stata_parity.py` | #729-followup | Mid | Low |
| Bad-controls tutorial section for `docs/tutorials/32_dml_did.ipynb` (PR-C): the `fit(bad_control=)` workflow, the `ATT_X(g,t)` pre-test reading, and the Approach-1 comparison via CS base-period covariates. | `docs/tutorials/32_dml_did.ipynb` | bad-controls PR-B | Quick | Medium |
61 changes: 61 additions & 0 deletions benchmarks/R/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,64 @@ every synthetic sample at 17 significant digits (R RNG streams are not
reproducible from numpy), and aborts loudly if the mass-point fixtures fail
to separate the two `nLocalMin`/`nUniqueMin` regularization gates (the
floor-gate configs exist to pin exactly that behavior).

# badcontrols (Caetano, Callaway, Payne & Sant'Anna 2026) black-box goldens

`benchmarks/data/badcontrols_golden.json` (+ the shared input panel
`benchmarks/data/badcontrols_panel.csv`) anchors the `DMLDiD` bad-control
lane (`fit(..., bad_control=, bad_control_covariates=)`) against the
authors' R package `badcontrols`. Consumed by
`tests/test_dml_did_bad_controls_parity.py`.

## Why this package, and the GPL black-box rule

`badcontrols` is the authors' reference implementation of the paper's
doubly-robust / DML estimator (arXiv:2608.03881). It is **GPL-3**; diff-diff
is MIT. The Python lane is derived from the paper alone
(`docs/methodology/papers/caetano-2026-review.md`), and the R package is
used ONLY as an executed oracle: the generator calls `didbc()` and records
its outputs. Contributors must never read `R/*.R` of that package while
working on `diff_diff/` (a direct port was rejected on licensing grounds).

## Regenerating

```sh
Rscript benchmarks/R/requirements.R # installs badcontrols at the pinned commit + ptetools 1.0.0
Rscript benchmarks/R/generate_badcontrols_golden.R
pytest tests/test_dml_did_bad_controls_parity.py # must NOT skip locally
```

The generator hard-fails unless `badcontrols` is 1.0.0 installed from
GitHub commit `651ccc925776125bb9233d76867c862107ea0ba5` (no tags or
releases exist and HEAD also reports 1.0.0, so the `RemoteSha` is the pin)
and `ptetools` is 1.0.0.

## What is recorded

`simulate_bad_controls(n = 1000, T_max = 4)` under `set.seed(20260905)`,
then four `didbc(est_method = "dr_ml", nuisance_method = "parametric",
xformula = ~Z, base_period = "varying", anticipation = 0, bstrap = FALSE,
overlap_threshold = 1, nfolds = 5)` runs: `bad_control_formula = ~X` with
`bad_control_cov_formula = ~W` (not-yet-treated, then never-treated), `~Y`
(Remark 5's lagged outcome, not-yet-treated), and a no-bad-control run
(characterization only - R's `dr_ml` path is not the plain Chang score).
`overlap_threshold = 1` disables the package's silent fallback to its
imputation estimator on high-propensity cells.

Each run is recorded as the per-cell mean over `n_seeds = 10` seeds
(`att_gt_mean`: `group, t, att, V_analytical, att_seed_sd, n, n_treated`)
plus every per-seed table (`att_gt_per_seed`). `V_analytical` is the
diagonal of `att_gt$V_analytical` (the analytical covariance; `att_gt$se`
is bootstrap-based even with `bstrap = FALSE`) and the parity SE is
`sqrt(V_analytical / n)`. `true_att_gt` carries the simulator's truth.

## Tolerance rationale and skip behavior

`didbc()` cross-fits with its own fold draw (`nfolds = 1` errors), so no
bit-exact target exists; single-seed fold noise is ~0.1-0.25 SE on most
cells and up to ~0.5 SE on the smallest ones. Averaging `n_seeds` seeds on
both sides shrinks the noise of the difference of means to ~0.1-0.2 SE, and
the test asserts `|mean_att_py - mean_att_R| < 0.5 SE_R` per cell plus an
SE ratio within 30% (runs 1-2 and run 3's post cells); run 3's pre cells
and run 4 are characterizations at 1.0 SE. A missing fixture skips the
module (isolated-install CI jobs copy `tests/` only).
Loading
Loading