Skip to content

Commit 9d57e76

Browse files
igerberclaude
andcommitted
fix(staggered): report real treated/control counts on non-estimable cells (review #582 P2)
The per-cell helper sentinel returns (and the covariate-reg empty-control batch site) hardcoded n_treated=n_control=0 for materialized NaN cells even after the observation masks had been computed, so group_time_effects / to_dataframe could show zero counts for a cell that actually had treated (or control) observations. The zero-control / zero-weight exits in _compute_att_gt_fast and _compute_att_gt_rc, and the covariate-reg empty-control batch site, now return the observed counts; missing-period exits (masks not yet built) keep 0. Display-only metadata -- estimates, SEs, and aggregation are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4291a25 commit 9d57e76

2 files changed

Lines changed: 22 additions & 8 deletions

File tree

diff_diff/staggered.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -911,7 +911,7 @@ def _compute_att_gt_fast(
911911
n_control = np.sum(control_valid)
912912

913913
if n_treated == 0 or n_control == 0:
914-
return None, 0.0, 0, 0, None, None, "zero_treated_control"
914+
return None, 0.0, int(n_treated), int(n_control), None, None, "zero_treated_control"
915915

916916
# Extract outcome changes for treated and control
917917
treated_change = outcome_change[treated_valid]
@@ -924,9 +924,9 @@ def _compute_att_gt_fast(
924924

925925
# Guard against zero effective mass after subpopulation filtering
926926
if sw_treated is not None and np.sum(sw_treated) <= 0:
927-
return None, 0.0, 0, 0, None, None, "zero_weight_mass"
927+
return None, 0.0, int(n_treated), int(n_control), None, None, "zero_weight_mass"
928928
if sw_control is not None and np.sum(sw_control) <= 0:
929-
return None, 0.0, 0, 0, None, None, "zero_weight_mass"
929+
return None, 0.0, int(n_treated), int(n_control), None, None, "zero_weight_mass"
930930

931931
# Get covariates if specified (from the base period)
932932
X_treated = None
@@ -1372,9 +1372,19 @@ def _compute_all_att_gt_covariate_reg(
13721372
n_c_base = int(np.sum(control_valid_base))
13731373
if n_c_base == 0:
13741374
skipped_empty_cell.extend((g, t) for g, t, *_ in tasks)
1375-
for g, t, *_ in tasks:
1375+
# Controls are empty for this whole group; report each cell's actual
1376+
# treated count (computed as in the per-pair loop), not a hardcoded 0.
1377+
for g, t, _bp, base_col, post_col in tasks:
1378+
if is_balanced:
1379+
n_t_task = int(np.sum(cohort_masks[g]))
1380+
else:
1381+
valid_pair = ~(
1382+
np.isnan(outcome_matrix[:, base_col])
1383+
| np.isnan(outcome_matrix[:, post_col])
1384+
)
1385+
n_t_task = int(np.sum(cohort_masks[g] & valid_pair))
13761386
group_time_effects[(g, t)] = _nan_gt_entry(
1377-
n_control=0, skip_reason="zero_treated_control"
1387+
n_treated=n_t_task, n_control=0, skip_reason="zero_treated_control"
13781388
)
13791389
continue
13801390

@@ -3381,7 +3391,7 @@ def _compute_att_gt_rc(
33813391
n_cs = int(np.sum(control_s))
33823392

33833393
if n_gt == 0 or n_ct == 0 or n_gs == 0 or n_cs == 0:
3384-
return None, 0.0, 0, 0, None, None, None, "zero_treated_control"
3394+
return None, 0.0, n_gt, n_ct, None, None, None, "zero_treated_control"
33853395

33863396
# Extract outcomes for each group
33873397
y_gt = obs_outcome[treated_t]
@@ -3399,9 +3409,9 @@ def _compute_att_gt_rc(
33993409
# Guard against zero effective mass
34003410
if sw_gt is not None:
34013411
if np.sum(sw_gt) <= 0 or np.sum(sw_gs) <= 0:
3402-
return None, 0.0, 0, 0, None, None, None, "zero_weight_mass"
3412+
return None, 0.0, n_gt, n_ct, None, None, None, "zero_weight_mass"
34033413
if np.sum(sw_ct) <= 0 or np.sum(sw_cs) <= 0:
3404-
return None, 0.0, 0, 0, None, None, None, "zero_weight_mass"
3414+
return None, 0.0, n_gt, n_ct, None, None, None, "zero_weight_mass"
34053415

34063416
# Get covariates if specified
34073417
obs_covariates = precomputed.get("obs_covariates")

tests/test_staggered.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,10 @@ def test_materializes_nan_cell_with_skip_reason(self, method, panel, covariates)
13871387
assert np.isnan(cell["t_stat"]) and np.isnan(cell["p_value"])
13881388
assert all(np.isnan(b) for b in cell["conf_int"])
13891389
assert cell["skip_reason"] == "zero_treated_control"
1390+
# The cell genuinely has treated observations but no controls -> the
1391+
# materialized counts must reflect that, not a hardcoded (0, 0).
1392+
assert cell["n_treated"] > 0
1393+
assert cell["n_control"] == 0
13901394

13911395
# Every NaN cell carries a known reason + NaN SE; every estimable cell None.
13921396
n_finite = 0

0 commit comments

Comments
 (0)