You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
perf(linalg): scores-based CR2 Bell-McCaffrey DOF (PT2018 Appendix B)
The unweighted per-contrast Satterthwaite DOF materialized the dense
n x n residual-maker M = I - X(X'X)^-1 X' and contracted it over all
cluster pairs (O(n^2) time per contrast, O(n^2) memory — 3.2 GB at
n=20k). With Omega the (n, G) matrix stacking omega_g on disjoint
cluster supports, B = Omega' M Omega collapses to
diag(||omega_g||^2) - P' M_U P with P = X' Omega: O(nk + G^2 k) per
contrast, no n x n allocation. ~32x at n=5k/G=50 (0.57s -> 0.018s);
n=20k/G=100 in 0.12s. Algebraically identical — frozen pair-loop
oracle parity at rtol 1e-10 (balanced, unbalanced, compound
contrasts); both NaN-reliability guards unchanged; 432 consumer tests
pass unmodified. REGISTRY notes updated (per-fit setup wording +
Appendix-B evaluation sentence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|`CallawaySantAnna` repeated-cross-section ipw/dr paths (`_ipw_estimation_rc` / `_doubly_robust_rc`) re-solve the propensity logit for EVERY (g,t) cell with no cache — the panel paths dedup via `pscore_cache` keyed `(g, base_period[, t])`. The Phase 3 IRLS fast path already speeds each RC solve; the missing (g,t)-dedup cache is the remaining lever. Unprofiled surface — measure an RC covariate scenario first. |`staggered.py::_ipw_estimation_rc`, `_doubly_robust_rc`| CS-scaling | Mid | Low |
47
47
| Rust `solve_ols` always runs a full thin SVD (equilibrated, gelsd-parity); at 40 covariates it is the top per-cell solver item on a CS dr fit (1.04s of 3.1s at 2M rows, 95 calls). A Cholesky/QR fast path with SVD fallback — mirroring the Phase 3 Python-side pattern (certify well-conditioned, fall back verbatim) — is the natural lever, but `solve_ols` is the universal OLS entry point (every estimator), so the blast radius needs the full backend-parity treatment (`TestSolveOLSSkipRankCheckParity` posture: fitted-values parity, not beta). |`rust/src/linalg.rs::solve_ols`| CS-scaling | Heavy | Low |
48
48
|`ImputationDiD` dense `(A0'A0).toarray()` scales `O((U+T+K)^2)` — OOM risk on large panels (only triggers when the sparse solver fails). Needs an alternative dense fallback or richer sparse strategy. |`imputation.py`|#141| Heavy | Medium |
49
-
| CR2 Bell-McCaffrey DOF uses a naive `O(n²k)` per-coefficient loop over cluster pairs; Pustejovsky-Tipton (2018) Appendix B has a scores-based formulation avoiding the full `n×n``M`. Switch when a user hits a large-`n` cluster-robust design. |`linalg.py::_compute_cr2_bm`| Phase 1a | Heavy | Low |
50
49
| Rust-backend CR2 Bell-McCaffrey: falls through to NumPy (the leverage/Satterthwaite-DOF path needs `return_dof` support, which the Rust vcov dispatch excludes). The one-way HC2 kernel landed 2026-07-07 (`compute_robust_vcov_hc2`, mirrors the NumPy hc2 branch at ~1e-15; near-singular hat-diagonal sentinel + Python-side warn-and-HC1-fallback). |`rust/src/linalg.rs`| Phase 1a | Mid | Low |
51
50
| Wild cluster bootstrap CI inversion calls `_t_star(r)`~O(100) times, each materializing a fresh `(B×n)``y_star` + `(k×B)` refit + `(n×B)` residual arrays. Acceptable for the few-cluster regime; for large-`n`/large-`B`, chunk `_t_star` over draws or precompute the `r`-independent cluster-level pieces (restricted residuals are linear in `r`). |`utils.py::wild_bootstrap_se._t_star`|#543| Mid | Low |
52
51
|`SpilloverDiD` sparse cKDTree path for the staggered nearest-treated-distance helper (mirrors the static helper's sparse branch). `_compute_nearest_treated_distance_staggered` always builds dense `(n_units, n_treated_by_onset)` matrices per cohort; add a sparse branch gated on `n > _CONLEY_SPARSE_N_THRESHOLD`. |`spillover.py`| Wave B | Mid | Low |
Copy file name to clipboardExpand all lines: docs/methodology/REGISTRY.md
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -238,12 +238,12 @@ where V is the VCV sub-matrix for post-treatment δ_e coefficients.
238
238
per-cluster adjustment matrices, the sandwich vcov, AND the per-coefficient +
239
239
post-period-average contrast DOF together from a single shared precompute build
240
240
(`_compute_cr2_bm_vcov_and_dof` in `diff_diff/linalg.py`); the fit bypasses
241
-
`solve_ols`'s separate vcov dispatch on this path so the O(n²) CR2 setup is built
242
-
once per fit rather than twice. `_compute_cr2_bm` (per-coefficient vcov + DOF) and
241
+
`solve_ols`'s separate vcov dispatch on this path so the CR2 setup (per-cluster
242
+
adjustment blocks) is built once per fit rather than twice. `_compute_cr2_bm` (per-coefficient vcov + DOF) and
243
243
`_compute_cr2_bm_contrast_dof` (DOF-only for arbitrary contrasts) are thin wrappers
244
244
over that shared core, so every CR2 caller routes through one implementation. The
245
245
consolidation is bit-identical to the prior two-call path (proven at atol=0).
246
-
- **Note (unweighted per-coef DOF guard):** the unweighted clustered CR2-BM per-coefficient DOF (`_cr2_bm_dof_inner`, the simple `(tr B)²/tr(B²)` form) carries the same two-part reliability guard as the weighted P-array path. (1) **Noise floor:** for a high-leverage FE-dummy / collinear nuisance column `trace_B2 = Σ B_{g,h}²` collapses to float64 accumulation noise while `trace_B` stays O(1), inflating the ratio to a non-physical DOF (observed ~1e61 on the absorbed-FE golden); a contrast whose `max|B_{g,h}|` sits below the batch-relative (`1e-10×max`, computed on the scale-normalized `max|B|/‖c‖²` since `B ∝ ‖c‖²` while the DOF is scale-invariant) or absolute (`(EPS·n·k·bread_scale)²`) floor is NaN'd. (2) **Cluster-count bound:** the Bell-McCaffrey Satterthwaite DOF is `(tr B)²/tr(B²)` with `B` PSD and cluster-structured, so it is bounded by `rank(B) ≤ G` (number of clusters); the simple unweighted form is numerically less faithful than clubSandwich's P-array form on high-leverage columns and can return a finite-but-inflated DOF above `G` (observed ~32.7 and ~16.3 vs R's 6 and 3, `G=8`), which is NaN'd as non-physical. The well-conditioned contrasts estimators consume — the treatment effect, event-study coefficients, and the compound post-period-average — are unaffected and match R clubSandwich; only the non-user-facing high-leverage nuisance DOFs are suppressed (exact P-array reproduction of those is deferred). A `UserWarning` fires per fit. Regression: `tests/test_estimators_vcov_type.py::TestDiDAbsorbedFERParity::test_unweighted_cr2_bm_per_coef_dof_no_nonphysical`.
246
+
- **Note (unweighted per-coef DOF guard):** the unweighted clustered CR2-BM per-coefficient DOF (`_cr2_bm_dof_inner`, the simple `(tr B)²/tr(B²)` form) carries the same two-part reliability guard as the weighted P-array path. (1) **Noise floor:** for a high-leverage FE-dummy / collinear nuisance column `trace_B2 = Σ B_{g,h}²` collapses to float64 accumulation noise while `trace_B` stays O(1), inflating the ratio to a non-physical DOF (observed ~1e61 on the absorbed-FE golden); a contrast whose `max|B_{g,h}|` sits below the batch-relative (`1e-10×max`, computed on the scale-normalized `max|B|/‖c‖²` since `B ∝ ‖c‖²` while the DOF is scale-invariant) or absolute (`(EPS·n·k·bread_scale)²`) floor is NaN'd. (2) **Cluster-count bound:** the Bell-McCaffrey Satterthwaite DOF is `(tr B)²/tr(B²)` with `B` PSD and cluster-structured, so it is bounded by `rank(B) ≤ G` (number of clusters); the simple unweighted form is numerically less faithful than clubSandwich's P-array form on high-leverage columns and can return a finite-but-inflated DOF above `G` (observed ~32.7 and ~16.3 vs R's 6 and 3, `G=8`), which is NaN'd as non-physical. The well-conditioned contrasts estimators consume — the treatment effect, event-study coefficients, and the compound post-period-average — are unaffected and match R clubSandwich; only the non-user-facing high-leverage nuisance DOFs are suppressed (exact P-array reproduction of those is deferred). A `UserWarning` fires per fit. Regression: `tests/test_estimators_vcov_type.py::TestDiDAbsorbedFERParity::test_unweighted_cr2_bm_per_coef_dof_no_nonphysical`. **Evaluation (2026-07):** the pairwise `B` matrix is computed scores-based per Pustejovsky-Tipton (2018) Appendix B — `B = diag(‖ω_g‖²) − P' M_U P` with `P = X'Ω` — instead of contracting against an explicit dense `n×n` residual-maker (`O(n k + G² k)` per contrast, no `n×n` allocation; algebraically identical, ~1e-12 float agreement locked by a frozen-oracle parity test).
247
247
- **Note:** `LinearRegression.get_se()` / `get_inference()` clamp the vcov diagonal at 0
248
248
before `sqrt`. A high-leverage / degenerate coefficient (an absorbed-FE dummy
249
249
near-collinear with the treatment, whose Satterthwaite DOF already hits the noise-floor
0 commit comments