Skip to content

Commit 7bc8d10

Browse files
igerberclaude
andcommitted
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>
1 parent 56ccd2f commit 7bc8d10

5 files changed

Lines changed: 142 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
481481
h=1 clamp-parity test). The symbol is imported independently (mixed-version safe — a
482482
stale extension degrades HC2 to NumPy without disabling older Rust accelerations).
483483
`return_dof` / weighted / CR2-BM requests stay on NumPy (CR2-BM tracked in TODO).
484+
- **CR2 Bell-McCaffrey Satterthwaite DOF: scores-based evaluation (PT2018 Appendix B).**
485+
The unweighted per-contrast DOF previously materialized the dense `n×n` residual-maker
486+
`M = I − X(X'X)⁻¹X'` and contracted it over all cluster pairs (`O(n²)` time per
487+
contrast, `O(n²)` memory — 3.2 GB at n=20k). The pairwise matrix now collapses to
488+
`B = diag(‖ω_g‖²) − P'M_U P` with `P = X'Ω` (disjoint cluster supports), costing
489+
`O(nk + G²k)` per contrast with no `n×n` allocation: ~32x at n=5k/G=50 (0.57s→0.018s);
490+
n=20k/G=100 completes in 0.12s where the old path would allocate 3.2 GB. Algebraically
491+
identical — agreement with a frozen pair-loop oracle at rtol 1e-10 (balanced,
492+
unbalanced, and compound-contrast designs); both NaN-reliability guards (noise floor,
493+
cluster-count bound) unchanged. All 432 consumer tests pass unmodified.
484494
- **`CallawaySantAnna` per-(g,t) IF scatters converted from `np.add.at` to fancy `+=`**
485495
(`staggered.py::_cluster_robust_se_from_per_gt_if` — runs once per (g,t) cell when
486496
`cluster=` is set — and the general combined-IF assembly path in

TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
4646
| `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 |
4747
| 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 |
4848
| `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 |
5049
| 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 |
5150
| 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 |
5251
| `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 |

diff_diff/linalg.py

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,10 +2068,7 @@ def _compute_cr2_bm_vcov_and_dof(
20682068
# `(tr B)² / tr(B²)` form (bit-equal to prior); weighted uses the full
20692069
# clubSandwich P_array construction.
20702070
if weights is None:
2071-
# Build the symmetric residual-maker M = I - H for the simple formula.
2072-
H = X @ M_U @ X.T
2073-
M = np.eye(n) - H
2074-
dof_vec = _cr2_bm_dof_inner(X, M, A_g_matrices, cluster_idx, M_U, contrasts)
2071+
dof_vec = _cr2_bm_dof_inner(X, A_g_matrices, cluster_idx, M_U, contrasts)
20752072
else:
20762073
dof_vec = _cr2_bm_dof_inner_weighted(
20772074
X,
@@ -2154,7 +2151,6 @@ def _compute_cr2_bm(
21542151

21552152
def _cr2_bm_dof_inner(
21562153
X: np.ndarray,
2157-
M: np.ndarray,
21582154
A_g_matrices: Dict[Any, np.ndarray],
21592155
cluster_idx: Dict[Any, np.ndarray],
21602156
bread_inv: np.ndarray,
@@ -2163,10 +2159,10 @@ def _cr2_bm_dof_inner(
21632159
"""Inner DOF loop, parameterized by an arbitrary contrast matrix.
21642160
21652161
Computes the CR2 Bell-McCaffrey Satterthwaite DOF for each column of
2166-
``contrasts`` (shape ``(k, m)``), using the precomputed residual-maker
2167-
``M``, per-cluster adjustment matrices ``A_g_matrices``, cluster index
2168-
map ``cluster_idx``, and ``bread_inv``. The per-coefficient case is
2169-
recovered with ``contrasts=np.eye(k)``; compound contrasts (e.g., a
2162+
``contrasts`` (shape ``(k, m)``), using the per-cluster adjustment
2163+
matrices ``A_g_matrices``, cluster index map ``cluster_idx``, and
2164+
``bread_inv``. The per-coefficient case is recovered with
2165+
``contrasts=np.eye(k)``; compound contrasts (e.g., a
21702166
post-period-average ATT) are handled by the same algebra without
21712167
duplication.
21722168
@@ -2178,6 +2174,23 @@ def _cr2_bm_dof_inner(
21782174
trace_B2 = sum_{g, h} (omega_g' M_{g, h} omega_h)**2
21792175
DOF(c) = trace_B**2 / trace_B2
21802176
2177+
SCORES-BASED EVALUATION (Pustejovsky-Tipton 2018 Appendix B): the
2178+
cluster-pair contraction is never evaluated against an explicit
2179+
residual-maker. With ``Omega`` the ``(n, G)`` matrix stacking the
2180+
``omega_g`` on their (disjoint) cluster supports and
2181+
``M = I - X bread_inv X'``, the pairwise matrix
2182+
``B[g, h] = omega_g' M_{g, h} omega_h`` collapses to
2183+
2184+
B = Omega' M Omega = diag(||omega_g||^2) - P' bread_inv P,
2185+
P = X' Omega (k, G; column g is X_g' omega_g)
2186+
2187+
so ``trace_B2 = ||B||_F^2`` costs ``O(n k + G^2 k)`` per contrast with
2188+
``O(G k)`` memory — the previous form materialized the dense ``n x n``
2189+
``M`` and looped cluster pairs at ``O(n^2)`` per contrast, the exact
2190+
large-``n`` blowup the TODO row tracked. The two evaluations are
2191+
algebraically identical; floating-point agreement is ~1e-12 relative
2192+
(different accumulation order), locked by the frozen-oracle parity test.
2193+
21812194
Returns
21822195
-------
21832196
dof_vec : ndarray of shape (m,)
@@ -2186,6 +2199,7 @@ def _cr2_bm_dof_inner(
21862199
"""
21872200
m = contrasts.shape[1]
21882201
unique_clusters = list(cluster_idx.keys())
2202+
n_g_clusters = len(unique_clusters)
21892203
# Precompute once: q-matrix (n, m) and A_g_Xbi (n_g, k) per cluster.
21902204
# For unit-contrast inputs (contrasts=I_k), this matches the prior
21912205
# inline implementation exactly: q[:, j] == X_bi[:, j] == X @ bread_inv @ e_j.
@@ -2197,28 +2211,27 @@ def _cr2_bm_dof_inner(
21972211
# Omega per cluster per contrast: (n_g, m) = A_g_Xbi[g] @ contrasts
21982212
omega_all = {g: A_g_Xbi[g] @ contrasts for g in unique_clusters}
21992213

2214+
# Scores-based precomputes (see docstring): per cluster g,
2215+
# normsq[g, j] = ||omega_g^{(j)}||^2 and P_all[g, :, j] = X_g' omega_g^{(j)}.
2216+
normsq = np.zeros((n_g_clusters, m))
2217+
P_all = np.zeros((n_g_clusters, X.shape[1], m))
2218+
for gi, g in enumerate(unique_clusters):
2219+
om = omega_all[g] # (n_g, m)
2220+
normsq[gi] = np.einsum("ij,ij->j", om, om)
2221+
P_all[gi] = X[cluster_idx[g]].T @ om
2222+
22002223
dof_vec = np.empty(m)
22012224
# Retain max|B_{g,h}| per contrast so we can NaN-guard noise-floor
22022225
# degeneracies in a second pass (mirrors `_cr2_bm_dof_inner_weighted`).
22032226
max_abs_B_arr = np.zeros(m)
22042227
for j in range(m):
22052228
q = Q[:, j]
22062229
trace_B = float(np.sum(q * q))
2207-
trace_B2 = 0.0
2208-
max_abs_B = 0.0
2209-
omega_cache = {g: omega_all[g][:, j] for g in unique_clusters}
2210-
for g in unique_clusters:
2211-
idx_g = cluster_idx[g]
2212-
omega_g = omega_cache[g]
2213-
for h in unique_clusters:
2214-
idx_h = cluster_idx[h]
2215-
omega_h = omega_cache[h]
2216-
M_gh = M[np.ix_(idx_g, idx_h)]
2217-
val = float(omega_g @ M_gh @ omega_h)
2218-
trace_B2 += val * val
2219-
if abs(val) > max_abs_B:
2220-
max_abs_B = abs(val)
2221-
max_abs_B_arr[j] = max_abs_B
2230+
P_j = P_all[:, :, j] # (G, k)
2231+
B_j = -(P_j @ bread_inv @ P_j.T)
2232+
B_j[np.diag_indices_from(B_j)] += normsq[:, j]
2233+
trace_B2 = float(np.sum(B_j * B_j))
2234+
max_abs_B_arr[j] = float(np.max(np.abs(B_j))) if B_j.size else 0.0
22222235
dof_vec[j] = (trace_B * trace_B) / trace_B2 if trace_B2 > 0 else np.nan
22232236

22242237
# Noise-floor NaN-guard (unweighted analogue of the guard in

docs/methodology/REGISTRY.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,12 @@ where V is the VCV sub-matrix for post-treatment δ_e coefficients.
238238
per-cluster adjustment matrices, the sandwich vcov, AND the per-coefficient +
239239
post-period-average contrast DOF together from a single shared precompute build
240240
(`_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
243243
`_compute_cr2_bm_contrast_dof` (DOF-only for arbitrary contrasts) are thin wrappers
244244
over that shared core, so every CR2 caller routes through one implementation. The
245245
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).
247247
- **Note:** `LinearRegression.get_se()` / `get_inference()` clamp the vcov diagonal at 0
248248
before `sqrt`. A high-leverage / degenerate coefficient (an absorbed-FE dummy
249249
near-collinear with the treatment, whose Satterthwaite DOF already hits the noise-floor

tests/test_linalg.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2714,3 +2714,95 @@ def test_return_dropped_mask(self):
27142714
# Rank 0 -> every coordinate dropped.
27152715
_, nd0, _, dropped0 = _rank_guarded_inv(np.zeros((3, 3)), return_dropped=True)
27162716
assert nd0 == 3 and dropped0.all()
2717+
2718+
2719+
class TestCR2BMScoresBasedDOF:
2720+
"""Frozen-oracle parity for the scores-based Satterthwaite DOF evaluation
2721+
(PT2018 Appendix B): B = diag(||omega_g||^2) - P' M_U P must agree with
2722+
the previous explicit-residual-maker cluster-pair contraction to float64
2723+
accumulation tolerance, without materializing the n x n M."""
2724+
2725+
@staticmethod
2726+
def _oracle_pairloop_dof(X, cluster_ids, bread_matrix, contrasts):
2727+
"""The PREVIOUS implementation, frozen: dense M = I - H + explicit
2728+
cluster-pair loop (no NaN guards — raw trace ratio)."""
2729+
from diff_diff.linalg import _cr2_adjustment_matrix
2730+
2731+
n, k = X.shape
2732+
bread_inv = np.linalg.solve(bread_matrix, np.eye(k))
2733+
unique = list(np.unique(cluster_ids))
2734+
idx = {g: np.where(cluster_ids == g)[0] for g in unique}
2735+
S_W = bread_matrix
2736+
MUWTWUM = bread_inv @ S_W @ bread_inv
2737+
A = {}
2738+
for g in unique:
2739+
X_g = X[idx[g]]
2740+
H_gg = X_g @ bread_inv @ X_g.T
2741+
G_g = np.eye(len(idx[g])) - H_gg - H_gg.T + X_g @ MUWTWUM @ X_g.T
2742+
A[g] = _cr2_adjustment_matrix(G_g)
2743+
M = np.eye(n) - X @ bread_inv @ X.T
2744+
m = contrasts.shape[1]
2745+
out = np.empty(m)
2746+
for j in range(m):
2747+
c = contrasts[:, j]
2748+
q = X @ bread_inv @ c
2749+
tr_B = float(np.sum(q * q))
2750+
omega = {g: A[g] @ X[idx[g]] @ bread_inv @ c for g in unique}
2751+
tr_B2 = 0.0
2752+
for g in unique:
2753+
for h in unique:
2754+
val = float(omega[g] @ M[np.ix_(idx[g], idx[h])] @ omega[h])
2755+
tr_B2 += val * val
2756+
out[j] = (tr_B * tr_B) / tr_B2 if tr_B2 > 0 else np.nan
2757+
return out
2758+
2759+
def test_scores_based_matches_pairloop_oracle(self):
2760+
from diff_diff.linalg import _compute_cr2_bm
2761+
2762+
rng = np.random.default_rng(11)
2763+
n, k, G = 240, 4, 12
2764+
X = np.column_stack([np.ones(n), rng.normal(size=(n, k - 1))])
2765+
cl = np.repeat(np.arange(G), n // G)
2766+
beta = rng.normal(size=k)
2767+
y = X @ beta + rng.normal(size=n) * (1 + 0.3 * np.abs(X[:, 1]))
2768+
resid = y - X @ np.linalg.lstsq(X, y, rcond=None)[0]
2769+
bread = X.T @ X
2770+
2771+
vcov, dof = _compute_cr2_bm(X, resid, cl, bread)
2772+
oracle = self._oracle_pairloop_dof(X, cl, bread, np.eye(k))
2773+
np.testing.assert_allclose(dof, oracle, rtol=1e-10)
2774+
assert np.all(np.isfinite(dof)) and np.all(dof > 0) and np.all(dof <= G)
2775+
2776+
def test_compound_contrast_matches_oracle(self):
2777+
from diff_diff.linalg import _compute_cr2_bm_contrast_dof
2778+
2779+
rng = np.random.default_rng(13)
2780+
n, k, G = 180, 5, 9
2781+
X = np.column_stack([np.ones(n), rng.normal(size=(n, k - 1))])
2782+
cl = np.repeat(np.arange(G), n // G)
2783+
bread = X.T @ X
2784+
# Compound averaging contrast (post-period-average style) + a unit vector.
2785+
c1 = np.zeros(k)
2786+
c1[2:] = 1.0 / (k - 2)
2787+
c2 = np.zeros(k)
2788+
c2[1] = 1.0
2789+
contrasts = np.column_stack([c1, c2])
2790+
dof = _compute_cr2_bm_contrast_dof(X, cl, bread, contrasts)
2791+
oracle = self._oracle_pairloop_dof(X, cl, bread, contrasts)
2792+
np.testing.assert_allclose(dof, oracle, rtol=1e-10)
2793+
2794+
def test_unbalanced_clusters_match_oracle(self):
2795+
from diff_diff.linalg import _compute_cr2_bm
2796+
2797+
rng = np.random.default_rng(17)
2798+
sizes = [5, 40, 12, 3, 25, 60, 8, 18]
2799+
cl = np.concatenate([np.full(sz, g) for g, sz in enumerate(sizes)])
2800+
n = len(cl)
2801+
k = 3
2802+
X = np.column_stack([np.ones(n), rng.normal(size=(n, k - 1))])
2803+
y = X @ rng.normal(size=k) + rng.normal(size=n)
2804+
resid = y - X @ np.linalg.lstsq(X, y, rcond=None)[0]
2805+
bread = X.T @ X
2806+
_, dof = _compute_cr2_bm(X, resid, cl, bread)
2807+
oracle = self._oracle_pairloop_dof(X, cl, bread, np.eye(k))
2808+
np.testing.assert_allclose(dof, oracle, rtol=1e-10)

0 commit comments

Comments
 (0)