Skip to content

Commit e5d68ec

Browse files
Audit round 2: 3 deeper semantic / UX bugs found and fixed
Second-pass audit of today's work — focused on algorithmic semantics rather than mechanics. Three genuine issues, all now fixed with regression tests. 1. **aggte(type='dynamic').estimate averaged pre + post event times.** R `did::aggte(type='dynamic')` reports the overall ATT as a simple average of *post-treatment* event-study estimates (e ≥ 0). Pre- treatment cells are placebos — averaging them into the headline number pollutes the causal summary with pre-trend signal. On the canonical demo DGP the bug produced overall = 0.7590 (diluted by 6 pre-treatment rows with mean-zero placebos); the correct value is 1.4052 — off by nearly a factor of 2. Fixed in `_aggregate` computation of `w_overall`: for dynamic, restrict the averaging weights to cells where the label (event time) is ≥ 0. Group and calendar aggregations are unchanged: by construction their weight matrices already pick only post- treatment (g, t) cells. Also switched the bootstrap-off fallback SE formula from `sqrt(mean(se²))` to `sqrt(sum(w² · se²))` so it respects the same weight vector and remains comparable with the bootstrap path. Docstring updated to explicitly state the four overall conventions. 2. **cs_report() crashed with KeyError: 'group' on non-CS results.** Before: passing a Sun-Abraham / BJS / dCDH result (which carry `relative_time` but not `group` / `time`) raised a cryptic pandas KeyError deep inside aggte's weight builder. Now: detects the missing CS schema up-front and raises a ValueError pointing the user to honest_did() for those result types. 3. **cs_report(pre_fitted_cs, estimator='reg', …) silently ignored the estimator override.** A user reading the call site would reasonably believe the estimator was applied; in fact the pre- fit's settings were used verbatim. Now emits a UserWarning that lists every shadowed argument and tells the caller to pass raw data instead if they want to re-estimate. Regression tests (+4 new cases): - `test_dynamic_overall_averages_post_event_only` - `test_cs_report_rejects_non_cs_result` - `test_cs_report_warns_on_shadowed_args` - `test_cs_report_pre_fitted_without_shadowed_args_is_silent` Full DiD suite: 169/169 (was 165). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d03fbf3 commit e5d68ec

4 files changed

Lines changed: 135 additions & 6 deletions

File tree

src/statspai/did/aggte.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,16 @@ def aggte(
9595
Returns
9696
-------
9797
CausalResult
98-
``.estimate`` / ``.se`` hold the overall aggregated ATT (for
99-
``simple``: the single number; for the others: the un-weighted
100-
average over the reported cells, matching R's ``did::aggte``).
98+
``.estimate`` / ``.se`` hold the overall aggregated ATT,
99+
matching R's ``did::aggte`` print convention:
100+
101+
- ``'simple'`` — the single cohort-share-weighted overall ATT
102+
- ``'dynamic'`` — simple average of *post-treatment* event times
103+
(e ≥ 0); pre-treatment cells are placebos and excluded
104+
- ``'group'`` — simple average of the per-cohort θ(g) estimates
105+
- ``'calendar'`` — simple average of the per-calendar-time θ(t)
106+
estimates
107+
101108
``.detail`` is a tidy frame with one row per aggregation cell and
102109
both pointwise and (if requested) uniform bands.
103110
@@ -195,18 +202,39 @@ def aggte(
195202
out['cband_upper'] = est_cells + crit_unif * se_cells
196203
out['crit_val_uniform'] = crit_unif
197204

198-
# "Overall" summary: simple mean of cells (matches R's aggte print).
205+
# "Overall" summary — matches R's did::aggte print() convention:
206+
# simple : the single cohort-share-weighted overall ATT
207+
# dynamic : simple average of POST-treatment event times only
208+
# (pre-treatment cells are placebos, not part of the
209+
# overall causal summary)
210+
# group : simple average across cohorts (all post-treatment by
211+
# construction of the θ(g) weights)
212+
# calendar : simple average across calendar times (all post-treatment
213+
# by construction)
199214
if type == 'simple':
200215
overall_est = float(est_cells[0])
201216
overall_se = float(se_cells[0])
202217
else:
203-
w_overall = np.full(W.shape[0], 1.0 / W.shape[0])
218+
if type == 'dynamic':
219+
post_mask_agg = np.asarray(labels, dtype=float) >= 0
220+
if not post_mask_agg.any():
221+
# No post-treatment cells survived the min_e / max_e filter
222+
# — fall back to the legacy "mean of all reported cells"
223+
# behaviour so the caller still gets a number.
224+
post_mask_agg = np.ones(W.shape[0], dtype=bool)
225+
idx = np.where(post_mask_agg)[0]
226+
w_overall = np.zeros(W.shape[0])
227+
w_overall[idx] = 1.0 / idx.size
228+
else:
229+
w_overall = np.full(W.shape[0], 1.0 / W.shape[0])
204230
overall_est = float(w_overall @ est_cells)
205231
if bstrap and inf_matrix is not None:
206232
agg_inf = (w_overall @ W) @ inf_matrix.T
207233
overall_se = float(np.sqrt(np.mean(agg_inf ** 2) / n_units))
208234
else:
209-
overall_se = float(np.sqrt(np.mean(se_cells ** 2)))
235+
overall_se = float(
236+
np.sqrt(np.sum((w_overall ** 2) * se_cells ** 2))
237+
)
210238

211239
overall_z = overall_est / overall_se if overall_se > 0 else 0.0
212240
overall_pval = float(2 * (1 - stats.norm.cdf(abs(overall_z))))

src/statspai/did/report.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,45 @@ def cs_report(
680680
"""
681681
if isinstance(data_or_result, CausalResult):
682682
cs = data_or_result
683+
# Warn if the caller also supplied estimation-time arguments that
684+
# would normally run a fresh fit — they are silently ignored when
685+
# a pre-fitted result is passed, which is easy to misread as
686+
# "the report re-estimated under my new settings".
687+
import warnings as _warnings
688+
_shadowed = []
689+
if y is not None: _shadowed.append(f'y={y!r}')
690+
if g is not None: _shadowed.append(f'g={g!r}')
691+
if t is not None: _shadowed.append(f't={t!r}')
692+
if i is not None: _shadowed.append(f'i={i!r}')
693+
if x is not None: _shadowed.append(f'x={x!r}')
694+
if estimator != 'dr': _shadowed.append(f'estimator={estimator!r}')
695+
if control_group != 'nevertreated':
696+
_shadowed.append(f'control_group={control_group!r}')
697+
if anticipation != 0: _shadowed.append(f'anticipation={anticipation}')
698+
if _shadowed:
699+
_warnings.warn(
700+
"cs_report() received a pre-fitted CausalResult together "
701+
"with estimation-time arguments ("
702+
+ ", ".join(_shadowed)
703+
+ ") — those arguments are ignored and the pre-fitted "
704+
"result is used as-is. Pass raw data instead if you "
705+
"want to re-estimate under the new settings.",
706+
stacklevel=2,
707+
)
708+
# aggte requires (group, time) in detail — that is the CS schema.
709+
# SA / BJS / dCDH use a different layout (relative_time only), so
710+
# passing one of those here would later fail deep inside aggte with
711+
# a cryptic KeyError. Fail fast with an actionable message.
712+
required = {'group', 'time', 'att', 'se', 'relative_time'}
713+
have = set(cs.detail.columns) if cs.detail is not None else set()
714+
if not required.issubset(have):
715+
raise ValueError(
716+
"cs_report() requires a Callaway–Sant'Anna result (with "
717+
"'group' and 'time' columns in its detail frame). "
718+
f"Got a result of method {cs.method!r} with columns "
719+
f"{sorted(have)}. For Sun–Abraham or BJS results use "
720+
"honest_did() directly on the event study."
721+
)
683722
else:
684723
if not all([y, g, t, i]):
685724
raise ValueError(

tests/test_aggte.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,23 @@ def test_ggdid_all_aggregation_types(cs_result):
186186
if typ != 'simple':
187187
labels = [t.get_text() for t in ax.get_legend().get_texts()]
188188
assert any('Uniform' in lab for lab in labels)
189+
190+
191+
def test_dynamic_overall_averages_post_event_only(cs_result):
192+
"""`aggte(type='dynamic').estimate` must be the mean of e≥0 cells only.
193+
194+
Previously the overall averaged *all* reported cells — including
195+
pre-treatment event times — which polluted the headline number
196+
with placebo signal. Matches R `did::aggte` convention.
197+
"""
198+
import numpy as np
199+
es = aggte(cs_result, type='dynamic', n_boot=100, random_state=0)
200+
post_mean = es.detail.loc[
201+
es.detail['relative_time'] >= 0, 'att'
202+
].mean()
203+
all_mean = es.detail['att'].mean()
204+
# Overall must equal post-event mean, NOT all-cells mean.
205+
assert es.estimate == pytest.approx(post_mean, abs=1e-10)
206+
# Sanity: on this DGP the two differ enough that a silent regression
207+
# would be caught.
208+
assert not np.isclose(post_mean, all_mean, atol=1e-3)

tests/test_cs_report.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,45 @@ def test_save_to_expands_tilde(tmp_path, monkeypatch):
283283
# If ~ was not expanded, we'd see a literal "~" subdirectory under CWD.
284284
assert (tmp_path / "cs_study" / "rpt.md").exists()
285285
assert (tmp_path / "cs_study" / "rpt.txt").exists()
286+
287+
288+
# --------------------------------------------------------------------------- #
289+
# Round-2 audit regressions #
290+
# --------------------------------------------------------------------------- #
291+
292+
def test_cs_report_rejects_non_cs_result():
293+
"""Passing a Sun-Abraham / BJS result must raise a clear ValueError,
294+
not a cryptic KeyError deep inside aggte."""
295+
import statspai as sp
296+
df = _staggered_panel(seed=19)
297+
sa = sp.sun_abraham(df, y='y', g='g', t='t', i='i')
298+
with pytest.raises(ValueError, match="Callaway"):
299+
cs_report(sa, n_boot=50, random_state=0, verbose=False)
300+
301+
302+
def test_cs_report_warns_on_shadowed_args():
303+
"""Passing a pre-fitted result + estimation-time args must warn.
304+
305+
Otherwise users would silently see the pre-fit's settings rather
306+
than the ones they explicitly passed."""
307+
import warnings
308+
df = _staggered_panel(seed=21)
309+
cs = callaway_santanna(df, y='y', g='g', t='t', i='i', estimator='dr')
310+
with warnings.catch_warnings(record=True) as w_list:
311+
warnings.simplefilter("always")
312+
cs_report(cs, estimator='reg',
313+
n_boot=30, random_state=0, verbose=False)
314+
assert any("estimator='reg'" in str(wi.message) for wi in w_list)
315+
316+
317+
def test_cs_report_pre_fitted_without_shadowed_args_is_silent():
318+
"""No warning when user correctly passes only bootstrap/report args."""
319+
import warnings
320+
df = _staggered_panel(seed=23)
321+
cs = callaway_santanna(df, y='y', g='g', t='t', i='i')
322+
with warnings.catch_warnings(record=True) as w_list:
323+
warnings.simplefilter("always")
324+
cs_report(cs, n_boot=30, random_state=0, verbose=False)
325+
# The only warning we tolerate here would be unrelated (none expected).
326+
msgs = [str(wi.message) for wi in w_list]
327+
assert not any("estimation-time" in m for m in msgs), msgs

0 commit comments

Comments
 (0)