Skip to content

Commit 9cb5f26

Browse files
igerberclaude
andcommitted
fix(staggered): rank-0 centered reg bread collapses to zero correction, not NaN
CI review P1: with a constant as the ONLY reg covariate the centered estimation-effect Gram is rank-0, and _safe_inv's all-NaN sentinel NaN-poisoned per-cell/aggregated SEs for cells whose intercept-only projection is fully identified (pre-fix raw-Gram column-drop and the no-covariate fit both give finite SEs). New _centered_or_bread helper (shared by all three reg sites) maps the rank-0 sentinel to a zero correction - proj collapses to 1/sum(W_c), equal to the no-covariate fit - while partial deficiency and the aggregate rank-guard warning are unchanged. ipw/dr breads are over [1, X] and keep NaN propagation (true pathology); the rank-0 monkeypatch test is re-scoped to ipw/dr with constant-only reg equivalence tests (unweighted + survey) added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4AzrFUMqJxcUumSH31mSr
1 parent ff3761c commit 9cb5f26

3 files changed

Lines changed: 111 additions & 18 deletions

File tree

diff_diff/staggered.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,10 +1437,7 @@ def _compute_all_att_gt_covariate_reg(
14371437
if is_balanced and self.control_group == "never_treated":
14381438
group_xbar_c = X_ctrl_raw.mean(axis=0)
14391439
group_Xc_centered = X_ctrl_raw - group_xbar_c
1440-
group_bread = _safe_inv(
1441-
group_Xc_centered.T @ group_Xc_centered,
1442-
tracker=self._safe_inv_tracker,
1443-
)
1440+
group_bread = self._centered_or_bread(group_Xc_centered.T @ group_Xc_centered)
14441441

14451442
# Process each (g, t) pair in this group
14461443
for g, t, bp_val, base_col, post_col in tasks:
@@ -1604,10 +1601,7 @@ def _compute_all_att_gt_covariate_reg(
16041601
else:
16051602
xbar_c = X_control_pair.mean(axis=0)
16061603
Xc_centered = X_control_pair - xbar_c
1607-
bread = _safe_inv(
1608-
Xc_centered.T @ Xc_centered,
1609-
tracker=self._safe_inv_tracker,
1610-
)
1604+
bread = self._centered_or_bread(Xc_centered.T @ Xc_centered)
16111605
d_tc = X_treated_pair.mean(axis=0) - xbar_c
16121606
with np.errstate(all="ignore"):
16131607
proj_c = 1.0 / pair_n_c + Xc_centered @ (bread @ d_tc)
@@ -2616,6 +2610,28 @@ def fit(
26162610
self.is_fitted_ = True
26172611
return self.results_
26182612

2613+
def _centered_or_bread(self, gram: np.ndarray) -> np.ndarray:
2614+
"""Rank-guarded inverse of the CENTERED control covariate Gram.
2615+
2616+
Unlike the ``[1, X]`` breads elsewhere, the intercept direction is
2617+
handled analytically here (the ``1/sum(W_c)`` leading term of the
2618+
reg estimation-effect projection), so a rank-0 centered Gram — zero
2619+
within-control covariate variation, e.g. a constant covariate as
2620+
the only regressor — is benign: the correction on the identified
2621+
(intercept-only) subset is exactly zero, and the projection
2622+
collapses to ``1/sum(W_c)``. Map ``_safe_inv``'s all-NaN rank-0
2623+
sentinel to a zero matrix so the cell keeps the finite SE on the
2624+
identified subset (rank-guard REGISTRY contract; equals both the
2625+
no-covariate fit and the pre-centered raw-Gram column-drop) instead
2626+
of NaN-poisoning the IF. Partial deficiency is untouched —
2627+
``_safe_inv`` already returns a zero-filled column-dropped inverse,
2628+
and the aggregate rank-guard warning still fires via the tracker.
2629+
"""
2630+
bread = _safe_inv(gram, tracker=self._safe_inv_tracker)
2631+
if bread.size and not np.any(np.isfinite(bread)):
2632+
return np.zeros_like(bread)
2633+
return bread
2634+
26192635
def _outcome_regression(
26202636
self,
26212637
treated_change: np.ndarray,
@@ -2704,10 +2720,7 @@ def _outcome_regression(
27042720
with np.errstate(all="ignore"):
27052721
xbar_c = np.sum(W_c[:, None] * X_control, axis=0) / w_c_sum
27062722
Xc_centered = X_control - xbar_c
2707-
bread = _safe_inv(
2708-
Xc_centered.T @ (W_c[:, None] * Xc_centered),
2709-
tracker=self._safe_inv_tracker,
2710-
)
2723+
bread = self._centered_or_bread(Xc_centered.T @ (W_c[:, None] * Xc_centered))
27112724
proj_c = 1.0 / w_c_sum + Xc_centered @ (bread @ (xbar_t - xbar_c))
27122725
inf_control = -(W_c * resid_c) * proj_c
27132726
inf_func = np.concatenate([inf_treated, inf_control])

docs/methodology/REGISTRY.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,15 @@ Aggregations:
523523
DR's no-covariate per-cell SE keeps its ddof=1 plug-in (O(1/n) from R; TODO row).
524524
Side effect: reg/ipw fits with collinear covariates now route their IF breads
525525
through the rank-guarded inverse and fire the same aggregate warning as dr.
526+
Rank-0 semantics of the reg CENTERED Gram: because the intercept direction is
527+
handled analytically by the `1/sum(W_c)` leading term, a rank-0 centered Gram
528+
(zero within-control covariate variation, e.g. a constant as the only
529+
covariate) is the benign identified-subset case — the estimation-effect
530+
correction is exactly zero and the fit collapses to the no-covariate reg fit
531+
with finite SEs (`_centered_or_bread` maps `_safe_inv`'s all-NaN rank-0
532+
sentinel to a zero correction; the aggregate rank-guard warning still fires).
533+
This differs from the `[1, X]` breads (ipw PS Hessian, dr), where all-NaN is
534+
a true pathology that NaN-propagates.
526535
- All aggregation SEs (simple, event study) include the weight influence function (WIF)
527536
adjustment, matching R's `did::aggte()`. The WIF accounts for uncertainty in estimating
528537
group-size aggregation weights. Group aggregation uses equal time weights (deterministic),

tests/test_staggered.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1655,16 +1655,20 @@ def test_clustered_constant_covariate_finite_se(self):
16551655
assert np.isfinite(res.overall_se)
16561656
assert res.overall_se < 1.0
16571657

1658-
@pytest.mark.parametrize("method", ["reg", "ipw", "dr"])
1658+
@pytest.mark.parametrize("method", ["ipw", "dr"])
16591659
def test_rank0_bread_propagates_nan_not_zero(self, monkeypatch, method):
16601660
# rank-0 is unreachable through covariates alone (the always-present
16611661
# intercept guarantees rank >= 1), so simulate an all-NaN bread to
16621662
# exercise the NaN-masking fix: var_psi becomes NaN and must yield a NaN
1663-
# SE, NOT 0.0 via the old ``var_psi > 0 else 0.0`` guard. reg/ipw now
1664-
# derive their per-cell SE from _safe_inv-based IF terms too (OLS
1665-
# estimation-effect bread / PS Hessian), so all three methods share
1666-
# the contract. The point estimate does NOT depend on the bread, so
1667-
# it stays finite (NaN inference on an estimable cell, not _nan_gt_entry).
1663+
# SE, NOT 0.0 via the old ``var_psi > 0 else 0.0`` guard. ipw's PS
1664+
# Hessian and dr's breads are over [1, X], so all-NaN there is a true
1665+
# pathology that must propagate. reg is deliberately EXCLUDED: its
1666+
# estimation-effect bread is over the CENTERED covariate Gram (the
1667+
# intercept is handled analytically), where rank-0 is the benign
1668+
# constant-covariate case mapped to a zero correction — see
1669+
# test_reg_constant_only_covariate_matches_no_covariate below.
1670+
# The point estimate does NOT depend on the bread, so it stays
1671+
# finite (NaN inference on an estimable cell, not _nan_gt_entry).
16681672
from tests.conftest import assert_nan_inference
16691673
import diff_diff.staggered as staggered_mod
16701674

@@ -2020,6 +2024,73 @@ def test_universal_base_period_anticipation_reg_smoke(self):
20202024
assert np.isfinite(cell["se"]), f"cell ({g},{t})"
20212025
assert np.isfinite(res.overall_se)
20222026

2027+
def test_reg_constant_only_covariate_matches_no_covariate(self):
2028+
"""A constant as the ONLY reg covariate makes the CENTERED
2029+
estimation-effect Gram rank-0 (all-zero). The correction on the
2030+
identified (intercept-only) subset is exactly zero, so effects AND
2031+
SEs must equal the no-covariate fit - finite, never NaN (the rank-0
2032+
centered bread maps to a zero correction, not an all-NaN inverse)."""
2033+
data = generate_staggered_data_with_covariates(seed=789)
2034+
data["xc"] = 5.0
2035+
with warnings.catch_warnings():
2036+
warnings.simplefilter("ignore")
2037+
no_cov = CallawaySantAnna(estimation_method="reg").fit(
2038+
data, "outcome", "unit", "time", "first_treat"
2039+
)
2040+
const_only = CallawaySantAnna(estimation_method="reg").fit(
2041+
data, "outcome", "unit", "time", "first_treat", covariates=["xc"]
2042+
)
2043+
assert np.isfinite(const_only.overall_se)
2044+
np.testing.assert_allclose(
2045+
const_only.overall_att, no_cov.overall_att, rtol=1e-12
2046+
)
2047+
np.testing.assert_allclose(
2048+
const_only.overall_se, no_cov.overall_se, rtol=1e-9
2049+
)
2050+
for key, cell in const_only.group_time_effects.items():
2051+
ref = no_cov.group_time_effects[key]
2052+
np.testing.assert_allclose(cell["effect"], ref["effect"], rtol=1e-12)
2053+
np.testing.assert_allclose(cell["se"], ref["se"], rtol=1e-9)
2054+
2055+
def test_reg_constant_only_covariate_matches_no_covariate_survey(self):
2056+
"""Survey-weighted twin of the constant-only-covariate case: the
2057+
weighted centered Gram is also rank-0, and the general
2058+
(survey-branch) producer must likewise collapse to the
2059+
no-covariate survey fit with finite SEs."""
2060+
from diff_diff.survey import SurveyDesign
2061+
2062+
rng = np.random.default_rng(17)
2063+
data = generate_staggered_data_with_covariates(seed=789)
2064+
data["xc"] = 5.0
2065+
weights = pd.DataFrame(
2066+
{
2067+
"unit": data["unit"].unique(),
2068+
"weight": rng.uniform(0.5, 2.0, size=data["unit"].nunique()),
2069+
}
2070+
)
2071+
data = data.merge(weights, on="unit")
2072+
with warnings.catch_warnings():
2073+
warnings.simplefilter("ignore")
2074+
no_cov = CallawaySantAnna(estimation_method="reg").fit(
2075+
data, "outcome", "unit", "time", "first_treat",
2076+
survey_design=SurveyDesign(weights="weight"),
2077+
)
2078+
const_only = CallawaySantAnna(estimation_method="reg").fit(
2079+
data, "outcome", "unit", "time", "first_treat",
2080+
covariates=["xc"],
2081+
survey_design=SurveyDesign(weights="weight"),
2082+
)
2083+
assert np.isfinite(const_only.overall_se)
2084+
np.testing.assert_allclose(
2085+
const_only.overall_att, no_cov.overall_att, rtol=1e-12
2086+
)
2087+
np.testing.assert_allclose(
2088+
const_only.overall_se, no_cov.overall_se, rtol=1e-9
2089+
)
2090+
for key, cell in const_only.group_time_effects.items():
2091+
ref = no_cov.group_time_effects[key]
2092+
np.testing.assert_allclose(cell["se"], ref["se"], rtol=1e-9)
2093+
20232094
def test_uniform_survey_weights_match_unweighted_per_cell_se(self):
20242095
"""Uniform survey weights route reg+cov through the general
20252096
(survey-branch) producer while the unweighted fit takes the

0 commit comments

Comments
 (0)