Skip to content

Commit fa149d5

Browse files
igerberclaude
andcommitted
fix(staggered): uniform no-IF NaN cells + cover covariate paths (review #582)
- The covariate-regression non-finite cell now materializes via _nan_gt_entry with NO influence-function entry, matching the other paths and the documented REGISTRY/helper contract (previously it wrote a zero-IF entry and ran batch inference). Aggregates and SEs are unchanged -- the cell was finite-masked / IF-membership-filtered out either way; now the "NaN cells carry no IF entry" invariant holds uniformly across all paths. - Extend the materialization test to cover covariate IPW/DR (panel + RCS) paths. - Remove the now-implemented CallawaySantAnna NaN-cell row from TODO.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c8f82b9 commit fa149d5

3 files changed

Lines changed: 38 additions & 11 deletions

File tree

TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ The `Origin` column (Actionable tables) and the `PR` column (Deferred tables) bo
2929
| Issue | Location | Origin | Effort | Priority |
3030
|-------|----------|--------|--------|----------|
3131
| `SyntheticControl` cv: thread an `"infeasible"` reason-code from `_outer_solve_V_cv()` / `_placebo_fit_unit()` so `in_space_placebo()` / `leave_one_out()` distinguish a structural cv-refit exclusion (donor-indistinguishable re-aggregated window) from a genuine inner-solver non-convergence — mirror the split `in_time_placebo()` already emits. Warnings already distinguish the two causes; only the machine-readable status/count is missing. | `synthetic_control.py`, `synthetic_control_results.py` | follow-up | Mid | Low |
32-
| `CallawaySantAnna`: materialize NaN entries for non-estimable `(g,t)` cells in `group_time_effects` (currently omitted with a consolidated warning); requires updating downstream consumers (event study, `balance_e`, aggregation). | `staggered.py` | #256 | Mid | Low |
3332
| Survey-design resolution / collapse patterns are inconsistent across panel estimators — `ContinuousDiD` rebuilds unit-level design in SE code, `EfficientDiD` builds once in `fit()`, `StackedDiD` re-resolves on stacked data. Extract shared helpers for panel-to-unit collapse, post-filter re-resolution, and metadata recomputation. | `continuous_did.py`, `efficient_did.py`, `stacked_did.py` | #226 | Mid | Low |
3433
| `SyntheticControl` remaining ADH-2015 §4 items: the regression-weight `W^reg = X_0'(X_0 X_0')^{-1} X_1` extrapolation diagnostic (flag implied OLS weights outside `[0,1]`) and sparse-SC subset search (`l < J`, holding `V` fixed). LOO, in-time placebo, CV `V`-selection, and inverse-variance `V` have landed; these two are the deferred tail. | `synthetic_control.py`, `synthetic_control_results.py` | ADH-2015 | Mid | Low |
3534
| `SyntheticControl` conformal (CWZ 2021) extensions: (a) one-sided / signed-`t` variants (§7); (b) covariates in the conformal proxy (`X_jt`, eqs 4/6 — current proxy is outcomes-only); (c) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies. The joint test, pointwise CIs, and average-effect CI have landed. | `conformal.py`, `synthetic_control_results.py` | CWZ-2021 | Heavy | Low |

diff_diff/staggered.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,16 +1522,26 @@ def _compute_all_att_gt_covariate_reg(
15221522

15231523
if nan_cell:
15241524
att = np.nan
1525-
se = np.nan
1526-
inf_treated = np.zeros(n_t)
1527-
inf_control = np.zeros(n_c)
15281525
else:
15291526
var_t = float(np.var(treated_residuals, ddof=1)) if n_t > 1 else 0.0
15301527
var_c = float(np.var(residuals, ddof=1)) if pair_n_c > 1 else 0.0
15311528
se = float(np.sqrt(var_t / n_t + var_c / pair_n_c))
15321529
inf_treated = (treated_residuals - np.mean(treated_residuals)) / n_t
15331530
inf_control = -residuals / pair_n_c
15341531

1532+
# Non-estimable (non-finite regression) cell: materialize NaN with
1533+
# NO influence-function entry — uniform with the missing-period /
1534+
# zero-control / zero-weight paths, so every NaN cell is excluded
1535+
# from aggregation and the bootstrap (the IF-membership filter and
1536+
# the finite-mask both drop it). Skip batch inference (already NaN).
1537+
if not np.isfinite(att):
1538+
group_time_effects[(g, t)] = _nan_gt_entry(
1539+
n_treated=n_t,
1540+
n_control=n_c,
1541+
skip_reason="non_finite_regression",
1542+
)
1543+
continue
1544+
15351545
group_time_effects[(g, t)] = {
15361546
"effect": att,
15371547
"se": se,
@@ -1540,7 +1550,7 @@ def _compute_all_att_gt_covariate_reg(
15401550
"conf_int": (np.nan, np.nan),
15411551
"n_treated": n_t,
15421552
"n_control": n_c,
1543-
"skip_reason": None if np.isfinite(att) else "non_finite_regression",
1553+
"skip_reason": None,
15441554
}
15451555

15461556
all_units = precomputed["all_units"]

tests/test_staggered.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,6 +1298,7 @@ def _cs_nonestimable_data(panel: bool, n: int = 25, seed: int = 0) -> pd.DataFra
12981298
uid = 0
12991299
for g in (2, 3, 4):
13001300
for _ in range(n):
1301+
x = rng.normal(0, 1) # unit-level covariate (for IPW/DR/reg covariate paths)
13011302
if panel:
13021303
fe = rng.normal(0, 1)
13031304
for t in range(1, 5):
@@ -1306,20 +1307,23 @@ def _cs_nonestimable_data(panel: bool, n: int = 25, seed: int = 0) -> pd.DataFra
13061307
{
13071308
"unit": uid,
13081309
"time": t,
1309-
"outcome": fe + 0.3 * t + 1.5 * post + rng.normal(0, 0.5),
1310+
"outcome": fe + 0.3 * t + 1.5 * post + 0.5 * x + rng.normal(0, 0.5),
13101311
"first_treat": g,
1312+
"x": x,
13111313
}
13121314
)
13131315
uid += 1
13141316
else:
13151317
for t in range(1, 5):
13161318
post = 1.0 if t >= g else 0.0
1319+
x_t = rng.normal(0, 1)
13171320
rows.append(
13181321
{
13191322
"unit": uid,
13201323
"time": t,
1321-
"outcome": rng.normal(0, 1) + 0.3 * t + 1.5 * post,
1324+
"outcome": rng.normal(0, 1) + 0.3 * t + 1.5 * post + 0.5 * x_t,
13221325
"first_treat": g,
1326+
"x": x_t,
13231327
}
13241328
)
13251329
uid += 1
@@ -1341,10 +1345,19 @@ class TestCallawaySantAnnaNonEstimableMaterialization:
13411345
}
13421346

13431347
@pytest.mark.parametrize(
1344-
"method,panel",
1345-
[("reg", True), ("ipw", True), ("dr", True), ("reg", False)],
1348+
"method,panel,covariates",
1349+
[
1350+
("reg", True, None), # no-covariate vectorized path
1351+
("ipw", True, None), # general path, no covariates
1352+
("dr", True, None), # general path, no covariates
1353+
("reg", False, None), # repeated cross-section path
1354+
("reg", True, ["x"]), # covariate-regression vectorized path
1355+
("ipw", True, ["x"]), # general path, covariate IPW
1356+
("dr", True, ["x"]), # general path, covariate DR
1357+
("dr", False, ["x"]), # repeated cross-section, covariate DR
1358+
],
13461359
)
1347-
def test_materializes_nan_cell_with_skip_reason(self, method, panel):
1360+
def test_materializes_nan_cell_with_skip_reason(self, method, panel, covariates):
13481361
"""Each previously-omitting path now stores the non-estimable cell as NaN."""
13491362
data = _cs_nonestimable_data(panel=panel, seed=0)
13501363
cs = CallawaySantAnna(
@@ -1356,7 +1369,12 @@ def test_materializes_nan_cell_with_skip_reason(self, method, panel):
13561369
with warnings.catch_warnings():
13571370
warnings.simplefilter("ignore")
13581371
results = cs.fit(
1359-
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
1372+
data,
1373+
outcome="outcome",
1374+
unit="unit",
1375+
time="time",
1376+
first_treat="first_treat",
1377+
covariates=covariates,
13601378
)
13611379

13621380
# The (g=4, t=4) cell has no not-yet-treated controls -> materialized, not omitted.

0 commit comments

Comments
 (0)