Skip to content

Commit 73c40de

Browse files
sp.did(aggregation=...) — one-call CS estimation + aggte in the top-level API
Teach the top-level `sp.did()` dispatcher to pipe a Callaway-Sant'Anna result straight through `aggte()` when the caller passes a new `aggregation=` argument (one of 'simple', 'dynamic', 'group', 'calendar'), so users no longer have to hand-compose: cs = callaway_santanna(...) es = aggte(cs, type='dynamic', n_boot=..., random_state=...) Instead: es = sp.did(df, y=..., treat=..., time=..., id=..., aggregation='dynamic', n_boot=500, random_state=0) Also surface `panel=` and `anticipation=` as first-class arguments on `sp.did()` so the top-level dispatcher can drive the RCS and anticipation paths without `**kwargs` acrobatics. Tests (tests/test_sp_did_aggregation.py, 7 cases): - no-aggregation path still returns the raw (g, t) grid - four aggregation values produce the right number of rows and attach the uniform-band columns on non-simple aggregations - invalid aggregation raises ValueError - panel=False RCS branch is reachable through sp.did() Full DiD suite still green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 53a03a8 commit 73c40de

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

src/statspai/did/__init__.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ def did(
8080
treat_unit=None,
8181
treat_time=None,
8282
se_method: str = 'placebo',
83+
# CS aggte-dispatch (v0.7.1+)
84+
aggregation: Optional[str] = None,
85+
n_boot: int = 1000,
86+
random_state: Optional[int] = None,
87+
panel: bool = True,
88+
anticipation: int = 0,
8389
**kwargs,
8490
) -> CausalResult:
8591
"""
@@ -135,6 +141,22 @@ def did(
135141
For SDID: treatment time.
136142
se_method : str, default 'placebo'
137143
For SDID: 'placebo', 'bootstrap', or 'jackknife'.
144+
aggregation : str, optional
145+
When set and ``method`` is Callaway–Sant'Anna, the raw ATT(g,t)
146+
result is passed through :func:`aggte` with ``type=aggregation``
147+
(``'simple'``, ``'dynamic'``, ``'group'``, or ``'calendar'``),
148+
delivering the aggregated ATT with Mammen multiplier-bootstrap
149+
uniform confidence bands in a single call.
150+
n_boot : int, default 1000
151+
Bootstrap replications for the multiplier bootstrap when
152+
``aggregation`` is set.
153+
random_state : int, optional
154+
Seed for the multiplier bootstrap.
155+
panel : bool, default True
156+
Forwarded to :func:`callaway_santanna`; set ``panel=False`` for
157+
repeated cross-sections.
158+
anticipation : int, default 0
159+
Forwarded to :func:`callaway_santanna`.
138160
139161
Returns
140162
-------
@@ -253,12 +275,25 @@ def did(
253275
raise ValueError(
254276
"'id' (unit identifier) is required for staggered DID."
255277
)
256-
return callaway_santanna(
278+
cs_result = callaway_santanna(
257279
data, y=y, g=treat, t=time, i=id,
258280
x=covariates, estimator=estimator,
259281
control_group=control_group,
260282
base_period=base_period, alpha=alpha,
283+
anticipation=anticipation, panel=panel,
261284
)
285+
if aggregation is not None:
286+
if aggregation not in ('simple', 'dynamic', 'group', 'calendar'):
287+
raise ValueError(
288+
f"aggregation must be one of "
289+
f"'simple'/'dynamic'/'group'/'calendar', "
290+
f"got {aggregation!r}"
291+
)
292+
return aggte(
293+
cs_result, type=aggregation, alpha=alpha,
294+
n_boot=n_boot, random_state=random_state,
295+
)
296+
return cs_result
262297

263298
if method in ('sun_abraham', 'sa', 'sunab'):
264299
if id is None:

tests/test_sp_did_aggregation.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Tests for the `sp.did(..., aggregation='dynamic'|...)` dispatch path."""
2+
import numpy as np
3+
import pandas as pd
4+
import pytest
5+
6+
import statspai as sp
7+
8+
9+
def _panel(seed=0):
10+
rng = np.random.default_rng(seed)
11+
rows = []
12+
for u in range(80):
13+
g = [3, 5, 7, 0][u // 20]
14+
ui = rng.normal(scale=0.3)
15+
for t in range(1, 9):
16+
te = max(0, t - g + 1) * 0.5 if g > 0 else 0
17+
rows.append({'i': u, 't': t, 'g': g,
18+
'y': ui + 0.2 * t + te + rng.normal()})
19+
return pd.DataFrame(rows)
20+
21+
22+
def test_no_aggregation_returns_cs_result():
23+
df = _panel()
24+
r = sp.did(df, y='y', treat='g', time='t', id='i')
25+
# Without aggregation the result should carry the full (g,t) grid.
26+
assert len(r.detail) == 21
27+
assert 'aggregation' not in r.model_info
28+
29+
30+
@pytest.mark.parametrize("agg,expected_rows", [
31+
('simple', 1),
32+
('dynamic', 11), # relative_time -6..-1 (excl -1 base) + 0..5
33+
('group', 3),
34+
('calendar', 6),
35+
])
36+
def test_aggregation_dispatch(agg, expected_rows):
37+
df = _panel()
38+
r = sp.did(df, y='y', treat='g', time='t', id='i',
39+
aggregation=agg, n_boot=100, random_state=0)
40+
assert r.model_info['aggregation'] == agg
41+
assert len(r.detail) == expected_rows
42+
# Aggregated results carry uniform-band columns except 'simple'.
43+
if agg != 'simple':
44+
assert {'cband_lower', 'cband_upper'} <= set(r.detail.columns)
45+
46+
47+
def test_invalid_aggregation_raises():
48+
df = _panel()
49+
with pytest.raises(ValueError, match='aggregation'):
50+
sp.did(df, y='y', treat='g', time='t', id='i', aggregation='bogus')
51+
52+
53+
def test_rcs_path_through_top_level():
54+
"""sp.did(..., panel=False) should route through the RCS branch."""
55+
rng = np.random.default_rng(1)
56+
rows = []
57+
for obs in range(400):
58+
g = int(rng.choice([3, 5, 0], p=[0.3, 0.3, 0.4]))
59+
t = int(rng.integers(1, 7))
60+
te = max(0, t - g + 1) * 0.5 if g > 0 else 0
61+
y = 0.2 * t + te + rng.normal()
62+
rows.append({'obs': obs, 't': t, 'g': g, 'y': y})
63+
df = pd.DataFrame(rows)
64+
r = sp.did(df, y='y', treat='g', time='t', id='obs',
65+
estimator='reg', panel=False)
66+
assert r.model_info['panel'] is False
67+
assert 'RCS' in r.model_info['estimator']

0 commit comments

Comments
 (0)