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
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
| Reuse the demeaner's factorized codes in `absorbed_fe_rank`/`absorbed_fe_cr1_k_increment` instead of re-factorizing: at 186k rows the rank helper adds ~1.9 ms per absorbed fit (7.7% of the fastest Rust-served TWFE fit) and the K_reference increment ~3.2 ms per clustered-hc1 absorbed fit (~13%; see `docs/performance-plan.md` "Component-aware absorbed-FE rank"), and the helpers and `demean_by_groups` factorize the same group columns. Threading the codes through the call sites halves the factorize work; the `connected_components` call itself is ~1.1 ms. Deliberately not done in the correctness PRs. | `diff_diff/utils.py` | #variance-inventory | Quick | Low |
| `EfficientDiD` conditional path: the largest remaining O(n) stage is the sieve/nuisance construction outside the tiled pass (~9s at 10k). (The `_ridge_solve_weights` Python-prep shave landed 2026-07-07 — the `omega_stack[rest]` fancy-index copy and tail scatter are skipped when no row is zero-masked, byte-identical outputs; the `zero_mask` abs scan itself remains, needed for correctness.) | `efficient_did_covariates.py` | CS-scaling | Mid | Low |
| `_rq_fit` LP assembly is dense (`A_eq = [X, I, -I]` with dense identity blocks, rebuilt per cell fit): a `scipy.sparse` construction would cut memory and likely HiGHS time for large cells / bootstrap-heavy covariate CiC/QDiD fits. CAVEAT before doing it: a different matrix representation can change HiGHS's vertex selection at degenerate/tied QR optima - end-to-end covariate goldens are tie-selection-gated (fine), but the `qr_cases` tight coefficient matches may shift to the equal-loss branch; re-run the parity suite and re-calibrate if needed. | `diff_diff/changes_in_changes.py::_rq_fit` | covariates PR | Quick | Low |
| Evaluate flipping `DIFF_DIFF_SOLVE_OLS_FASTPATH` default-ON after an opt-in soak (the 2026-07 certified normal-equations Cholesky fast path, both backends). A flip needs: golden/parity-suite recapture at the tol-bounded posture (fitted ~1e-8 abs / SE ~1e-6 rel — the default today is byte-pinned in several benchmark conventions), certification-rate telemetry across real workloads (any decline is silent-correct but forfeits the speedup), and the staged default-flip protocol used for `df_convention` (v4-class change). Lifecycle tracked in docs/v4-deprecations.yaml (M-008). | `diff_diff/linalg.py::_resolve_solve_ols_fastpath`, `rust/src/linalg.rs::solve_ols_chol` | CS-scaling | Mid | Low |
| Evaluate flipping `DIFF_DIFF_SOLVE_OLS_FASTPATH` default-ON after an opt-in soak (the 2026-07 certified normal-equations Cholesky fast path, both backends). A flip needs: golden/parity-suite recapture at the tol-bounded posture (fitted ~1e-8 abs / SE ~1e-6 rel — the default today is byte-pinned in several benchmark conventions), certification-rate telemetry across real workloads (any decline is silent-correct but forfeits the speedup), and a staged default-flip protocol of its own - a later flip is a major-version change on its own schedule, re-evaluated in 4.x. Decision at the 4.0 cut recorded in docs/v4-deprecations.yaml (M-008). | `diff_diff/linalg.py::_resolve_solve_ols_fastpath`, `rust/src/linalg.rs::solve_ols_chol` | CS-scaling | Mid | Low |

### Testing / docs

Expand Down
29 changes: 29 additions & 0 deletions changelog.d/20261018-v4-cut-framing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
### Documentation
- **4.0-cut framing** (the first PR of the 4.0 cut, at v3.13.0): `docs/v4-design.md`
re-synced with the library as shipped since 3.9 - the estimator roster now counts
LWDiD/DMLDiD/DurationDiD (27 -> 24 at 4.0, 23 estimators + Bacon in the diagnostic
family), the diagnostic family names the TWFE weights containers and
`DurationDiDPretestResults`, and section 9 carries the ten-PR cut sequence. `M-080`
(auto-cluster policy) re-scoped to the measured roster: static `TwoWayFixedEffects`
already auto-clusters at unit, `SpilloverDiD` is the one estimator whose default SE
moves at 4.0, `cluster=False` becomes the uniform explicit off-switch, and the
by-construction estimators keep no auto-cluster metadata by decision (new "Default
clustering roster" section in `docs/methodology/variance-conventions.md`; REGISTRY
Notes on SpilloverDiD, StackedDiD, ImputationDiD, TwoStageDiD, DMLDiD, DurationDiD).
`M-008` (`DIFF_DIFF_SOLVE_OLS_FASTPATH` default) decided: evaluated, kept off. New
ledger row `M-150` gives `WooldridgeDiDResults.to_dataframe(level="event")` its own
locator (twin of `M-136`). The migration guide's auto-cluster section, appendix and
orientation table follow the ledger (111 appendix rows).

### Internal
- **Ledger cut window** (`docs/v4-deprecations.yaml` header `cut_window: "4.0"`,
`cut_base: "3.13.0"`; spec section 11): rows scheduled at 4.0 may flip terminal
ahead of the version bump so the removals land over many PRs while `__version__`
stays 3.13.0; `tests/test_v4_matrix.py` relaxes only the early-removal guard for the
window version, refuses any other version bump while early-terminal rows exist, and
keeps due enforcement unchanged. The id snapshot and `ROW_COUNT_FLOOR` now include
`M-148`/`M-149` (shipped by #838 without extending them) and `M-150` (132 rows).
`M-091`'s roster records the four later-born `Diagnostic` containers; `M-050`'s
`code_refs` record that `diff_diff/_deprecation.py` survives 4.0 as 5.0-window
infrastructure; `M-045`/`M-115` name the legacy-CR1 remap branches their removal
deletes. A `maint/3.13` branch is cut at `v3.13.0` for 3.13.x patches.
7 changes: 5 additions & 2 deletions diff_diff/_deprecation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

This module is a deliberate LEAF: it imports only the standard library, never
other ``diff_diff`` modules, so every estimator module can import it without
cycles (the ``_base.py`` precedent). It is private and unexported; the 4.0
removal PR deletes the shims that use it.
cycles (the ``_base.py`` precedent). It is private and unexported. The 4.0
removal PRs delete the 3.9 shims that use it, but the module itself SURVIVES
4.0: the 4.0 -> 5.0 ``FutureWarning`` property shims of the results-storage
flip (ledger rows M-016 and M-050..M-058) ride ``deprecated_field_property``
through the 5.0 window (recorded in M-050's ``code_refs``).

The helpers implement the ledger's shim contract (``docs/v4-deprecations.yaml``,
groups ``renames-*``; rules in ``docs/v4-design.md`` section 8):
Expand Down
2 changes: 1 addition & 1 deletion diff_diff/wooldridge_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ def to_dataframe(
level : "simple" | "group" | "calendar" | "event_study" | "gt"
Use "gt" to export raw group-time effects. ("event" is a
deprecated spelling of "event_study"; warns and is rejected
in 4.0 - row M-086.)
in 4.0 - row M-150, the to_dataframe twin of M-086.)
aggregation : str, optional
Deprecated alias for ``level`` (row M-044); warns with
``FutureWarning`` and will be removed in 4.0.
Expand Down
38 changes: 32 additions & 6 deletions docs/methodology/REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2150,6 +2150,11 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only.
- Bootstrap does not update pre-period SEs (they are from the lead regression)
- When `balance_e` is set, lead indicators are restricted to balanced cohorts; the full Omega_0 sample (including never-treated) is kept for within-transformation
- Only affects event study aggregation; overall ATT and group aggregation unchanged
- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c2):
the default `cluster_var = unit` lives INSIDE the unit-level influence-function
variance, so no unclustered variance exists; the uniform 4.0 `cluster=False`
off-switch is an accepted explicit no-op (not an error, not a new SE). Roster in
`docs/methodology/variance-conventions.md` "Default clustering roster".
- **Note:** `pretrends=True` with analytical `survey_design` (strata/PSU/FPC) is supported. The lead regression uses survey-weighted demeaning, WLS point estimates, and `compute_survey_vcov()` for design-based VCV. The full survey design is preserved (subpopulation approach): Omega_0 scores are zero-padded back to full-panel length so PSU/strata structure is maintained for variance estimation. The F-test in `pretrend_test()` uses the full-design `df_survey` as denominator df. Replicate-weight survey designs raise `NotImplementedError` with `pretrends=True` because per-replicate lead regression refits are not yet implemented.
- **Note (pretrends lead-regression tail df, 3.9 / M-127):** the per-lead
`gamma_h` t/p/CI previously used silent normal-theory z on plain clustered
Expand Down Expand Up @@ -2229,6 +2234,10 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only.

**Primary source:** [Gardner, J. (2022). Two-stage differences in differences. arXiv:2207.05943.](https://arxiv.org/abs/2207.05943)

- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c2):
as for ImputationDiD, the default `cluster_var = unit` is internal to the unit-level
influence-function machinery, so the uniform 4.0 `cluster=False` off-switch is an
accepted explicit no-op. Roster in `docs/methodology/variance-conventions.md`.
- **Note (rank-guarded TSL-variance bread):** The Stage-2 variance bread `(X_2'WX_2)^{-1}` is inverted by the shared `_rank_guarded_inv` (`diff_diff/linalg.py`) on BOTH the analytical (`two_stage.py`) and multiplier-bootstrap (`two_stage_bootstrap.py`) surfaces. `np.linalg.solve` raised only on an exactly-singular bread (prior fallback: dense `lstsq`); a **near**-singular `X_2'WX_2` would otherwise flow a garbage inverse (~1e13) into the SE. The rank-guard truncates redundant directions → finite SE on the identified subspace (NaN at rank 0) and warns. A dropped (unidentified) Stage-2 coefficient (event-time / group effect) is reported with **NaN** SE on both the analytical and bootstrap surfaces — not the zero-filled `0`. `X_2` is the Stage-2 indicator design (treatment/event-time/group dummies), not user covariates. See the CallawaySantAnna "rank-guarded IF standard errors" Note. Sibling of axis-A finding #17.

**Key implementation requirements:**
Expand Down Expand Up @@ -2430,6 +2439,13 @@ The paper text states a stricter bound (T_min + 1) but the R code by the co-auth
- [x] Anticipation parameter support
- [x] Never-treated encoding (0 and inf)
- [x] Survey design support (Phase 3): Q-weights compose multiplicatively with survey weights; TSL vcov on composed weights; survey design columns propagated through sub-experiments. Replicate weights supported via estimator-level refit with Q-weight composition (see Replicate Weight Variance section).
- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category c3
in `docs/methodology/variance-conventions.md` "Default clustering roster"): the
stacked regression clusters by construction and `cluster` accepts only `'unit'` /
`'unit_subexp'`; the uniform 4.0 `cluster=False` off-switch is therefore REJECTED
here with the existing validator message rather than yielding an unclustered SE
(no such variance is defined for the stacked design). The spec's "becomes an
instance of the general policy" applies to the default resolution only.
- **Note (intercept SEs excluded from R parity):** for the base StackedDiD `clubSandwich` parity fixture (not the CBWSDID `cbwsdid` fixture below), stacked event-study *interaction* SEs match R at ~2e-13, but the regression *intercept* SE differs from R by a measured ~0.3% — a nuisance-parameter reference-cell/parameterization gap, not machine-precision lockable. Surfacing `se_cr1_intercept`/`se_cr2_intercept` would add an unasserted, R-divergent public field, so intercept SEs are deliberately not exposed and are excluded from the parity lock (`tests/test_methodology_stacked_did.py`; SE-audit C1/(c) decision, 2026-07-09).
- **Note:** Survey weights compose multiplicatively with Q-weights for StackedDiD; only `weight_type="pweight"` (default) is supported — `fweight` and `aweight` are rejected because Q-weight composition changes weight semantics (non-integer for fweight, non-inverse-variance for aweight)

Expand Down Expand Up @@ -3068,11 +3084,13 @@ the finite-dimensional `p_0` is handled by the variance correction below.
`cluster=None` IS the per-unit IF variance by definition (see "IF-based
variance estimators vs analytical-sandwich estimators"); the inherited
value is family-consistent and truthful. No vocabulary widening.
- **Note:** M-080 position (auto-cluster policy) — DMLDiD's per-unit
influence-function variance IS unit-level clustering, so the 4.0
auto-cluster DEFAULT flip is inert for it — mirroring how CallawaySantAnna
is absent from M-080's `code_refs` and how M-010 pre-empted M-080 for the
TWFE event-study mode. COARSER-than-unit clustering ships via the CS-style
- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 -
category b in `docs/methodology/variance-conventions.md` "Default clustering
roster") — DMLDiD's per-unit influence-function variance IS unit-level
clustering, so the 4.0 auto-cluster DEFAULT flip is inert for it, as for
CallawaySantAnna and every other by-construction estimator; by decision it
carries no auto-cluster metadata (`cluster_name=None`) and the uniform 4.0
`cluster=False` off-switch is an accepted explicit no-op. COARSER-than-unit clustering ships via the CS-style
`cluster=` constructor parameter (3.11): the synthesized/injected
`SurveyDesign(psu=...)` drives PSU-cohesive folds, the per-cell CR1 SE,
the bootstrap draw structure and `df_inference`, while the moment kernels
Expand Down Expand Up @@ -6078,7 +6096,9 @@ equivalently `p <= alpha`.
would be unreachable) and the constant are library choices — the paper
declines to supply a cutoff (review lines 917-918) — and change no behavior.
- **Note:** M-080 unit auto-cluster is satisfied by construction: the bootstrap
resamples individuals. No `cluster=`, `vcov_type=`, survey design, covariates,
resamples individuals (decision recorded 2026-10-18: category d in
`docs/methodology/variance-conventions.md` - no `cluster` param and no cluster
metadata on the container). No `cluster=`, `vcov_type=`, survey design, covariates,
staggered adoption, censoring, or repeated cross-sections in this version
(the review's deferred extensions).
- **Note:** `DiagnosticReport` and `BusinessReport` reject `DurationDiDResults`
Expand Down Expand Up @@ -6841,6 +6861,12 @@ should be a deliberate user choice.

**Note:** This estimator is a **documented synthesis** of ingredients — no single published software package implements the exact recipe. `did2s` (R/Stata, Butts & Gardner 2022) implements the Gardner two-stage residualization but does NOT support ring covariates. The Butts (2021/2023) paper proposes the ring estimator in Equations 5/6/8 (non-staggered) and Section 5 / Table 2 (staggered) but does not ship reference software. The diff-diff implementation combines: (a) Butts (2021) Section 5 / Table 2 identification, (b) Gardner (2022) two-stage residualize-then-fit, (c) Wave A's Conley spatial-HAC vcov.

- **Note:** M-080 position (auto-cluster policy, re-scoped 2026-10-18 - category a):
SpilloverDiD is the ONE required-unit estimator still running observation-level HC
when `cluster=` is omitted, so its default SE flips to unit clustering at 4.0 with
TWFE's carve-outs mirrored (explicit `cluster=` wins; Conley drops the auto-cluster;
never injected as a survey PSU). 3.x numbers are reproduced with `cluster=False`.
Roster in `docs/methodology/variance-conventions.md` "Default clustering roster".
- **Note (rank-guarded Wave D bread):** The Wave D variance bread `A_22 = (X_2'WX_2)^{-1}` is inverted by the shared `_rank_guarded_inv` (`diff_diff/linalg.py`) on the already-column-dropped kept submatrix. `np.linalg.solve` raised only on an exactly-singular bread (prior fallback: dense `lstsq`); a **near**-singular kept Gram would otherwise flow a garbage inverse (~1e13) into the SE. The rank-guard truncates redundant directions → finite SE on the identified subspace (NaN at rank 0), re-inflated to (k, k) with NaN at the dropped positions, and warns. See the CallawaySantAnna "rank-guarded IF standard errors" Note. Sibling of axis-A finding #17.

**Identification spec (committed):**
Expand Down
Loading
Loading