Skip to content

Commit ec2c57e

Browse files
Sun-Abraham cluster-robust sandwich SE + ggdid() aggte visualiser
Continue the csdid / differences parity effort by reimplementing the two remaining high-impact pieces from their respective papers. 1. Rewrite `sun_abraham()` inference layer (src/statspai/did/sun_abraham.py) * Saturated cohort × event-time design estimated with proper two-way within transformation (iterative unit/time demeaning, correct on unbalanced panels). * Liang-Zeger cluster-robust sandwich covariance with small-sample (G/(G-1)) · (N-1)/(N-k) adjustment, defaulting to clustering on the unit id; accepts arbitrary `cluster=` column. * IW aggregation SEs via the delta method w' V_β w on the interaction block — replaces the earlier ad-hoc sqrt(sigma² / (total·T)) approximation. * Optional `control_group='lastcohort'` reference per SA 2021 Sec 6. * `model_info` now reports `se_type`, `n_clusters`, and `n_coeffs` for downstream reporting / outreg2. 2. Add `ggdid()` (src/statspai/did/plots.py) mirroring R did::ggdid * Dispatches on `result.model_info['aggregation']` produced by `aggte()`; renders each of simple / dynamic / group / calendar with its natural layout (point, event-study line, horizontal bars, calendar line). * Overlays the Mammen multiplier-bootstrap uniform confidence band on top of the pointwise CI using the `cband_lower` / `cband_upper` columns supplied by aggte. * Exported via `statspai.did.ggdid`. 3. Test suite * tests/test_aggte.py: extended with `test_ggdid_all_aggregation_types` (smoke-renders all four aggregation types under the Agg backend and verifies the uniform-band legend entry). * Full DiD suite now 61/61 green (47 pre-existing + 13 aggte + 1 ggdid). References Sun, L. and Abraham, S. (2021). "Estimating Dynamic Treatment Effects in Event Studies with Heterogeneous Treatment Effects." JoE 225(2), 175-199. Inference follows eq. (18) / Sec. 5. Liang, K.-Y. and Zeger, S.L. (1986). "Longitudinal Data Analysis Using Generalized Linear Models." Biometrika 73(1), 13-22. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d21faff commit ec2c57e

4 files changed

Lines changed: 405 additions & 139 deletions

File tree

src/statspai/did/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
treatment_rollout_plot,
5555
sensitivity_plot,
5656
cohort_event_study_plot,
57+
ggdid,
5758
)
5859

5960

@@ -332,4 +333,5 @@ def did(
332333
'treatment_rollout_plot',
333334
'sensitivity_plot',
334335
'cohort_event_study_plot',
336+
'ggdid',
335337
]

src/statspai/did/plots.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,3 +1210,162 @@ def cohort_event_study_plot(
12101210
ax.legend(fontsize=9, frameon=False, ncol=min(3, len(cohorts) + 1))
12111211
fig.tight_layout()
12121212
return fig, ax
1213+
1214+
1215+
# ======================================================================
1216+
# 9. ggdid — aggte() result visualiser with uniform bands
1217+
# ======================================================================
1218+
1219+
def ggdid(
1220+
result,
1221+
ax=None,
1222+
figsize=(10, 6),
1223+
title: Optional[str] = None,
1224+
point_color: str = '#2E86AB',
1225+
band_color: str = '#F18F01',
1226+
show_pointwise: bool = True,
1227+
show_uniform: bool = True,
1228+
):
1229+
"""Plot an ``aggte()`` result, mirroring R :func:`did::ggdid`.
1230+
1231+
Automatically dispatches on ``result.model_info['aggregation']``:
1232+
1233+
- ``simple`` : a single point with pointwise CI
1234+
- ``dynamic`` : event-study line with pointwise CI **and** uniform band
1235+
- ``group`` : horizontal bars of θ̂(g) per cohort
1236+
- ``calendar`` : time-series of θ̂(t) per calendar period
1237+
1238+
Uniform bands (sup-t simultaneous confidence bands) are drawn from the
1239+
``cband_lower`` / ``cband_upper`` columns created by :func:`aggte`.
1240+
1241+
Parameters
1242+
----------
1243+
result : CausalResult
1244+
Output of :func:`aggte`.
1245+
ax : matplotlib Axes, optional
1246+
figsize : tuple, default (10, 6)
1247+
title : str, optional
1248+
point_color, band_color : str
1249+
Colours for the pointwise estimate and the uniform band.
1250+
show_pointwise : bool, default True
1251+
Draw pointwise CI lines.
1252+
show_uniform : bool, default True
1253+
Draw uniform band (shaded region).
1254+
1255+
Returns
1256+
-------
1257+
(fig, ax)
1258+
"""
1259+
plt, _ = _ensure_mpl()
1260+
1261+
info = result.model_info or {}
1262+
agg = info.get('aggregation', 'dynamic')
1263+
df = result.detail
1264+
if df is None or len(df) == 0:
1265+
raise ValueError("result has empty detail table.")
1266+
1267+
if ax is None:
1268+
fig, ax = plt.subplots(figsize=figsize)
1269+
else:
1270+
fig = ax.figure
1271+
1272+
has_cband = 'cband_lower' in df.columns and 'cband_upper' in df.columns
1273+
1274+
if agg == 'simple':
1275+
est = df['att'].iloc[0]
1276+
lo, hi = df['ci_lower'].iloc[0], df['ci_upper'].iloc[0]
1277+
ax.errorbar(
1278+
[0], [est], yerr=[[est - lo], [hi - est]],
1279+
fmt='o', color=point_color, capsize=6, markersize=8,
1280+
linewidth=2, label=f'Overall ATT = {est:.3f}',
1281+
)
1282+
ax.axhline(0, color='gray', linestyle='--', linewidth=0.8)
1283+
ax.set_xticks([])
1284+
ax.set_ylabel('ATT')
1285+
ax.set_title(title or "Callaway-Sant'Anna — simple aggregation")
1286+
ax.legend(frameon=False)
1287+
1288+
elif agg == 'dynamic':
1289+
x = df['relative_time'].values
1290+
est = df['att'].values
1291+
# Uniform band first (behind lines).
1292+
if show_uniform and has_cband:
1293+
ax.fill_between(
1294+
x, df['cband_lower'], df['cband_upper'],
1295+
color=band_color, alpha=0.25,
1296+
label=f"Uniform {int(100*(1-result.alpha))}% band",
1297+
)
1298+
if show_pointwise:
1299+
ax.fill_between(
1300+
x, df['ci_lower'], df['ci_upper'],
1301+
color=point_color, alpha=0.18,
1302+
label=f"Pointwise {int(100*(1-result.alpha))}% CI",
1303+
)
1304+
post = x >= 0
1305+
pre = x < 0
1306+
ax.plot(x[pre], est[pre], 'o-', color='#7F8C8D',
1307+
markersize=6, linewidth=1.5, label='Pre-treatment')
1308+
ax.plot(x[post], est[post], 'o-', color=point_color,
1309+
markersize=7, linewidth=2, label='Post-treatment')
1310+
ax.axhline(0, color='gray', linestyle='--', linewidth=0.8)
1311+
ax.axvline(-0.5, color='#7F8C8D', linestyle=':', linewidth=1, alpha=0.5)
1312+
ax.set_xlabel('Event time e = t − g')
1313+
ax.set_ylabel('ATT(e)')
1314+
ax.set_title(title or "Callaway-Sant'Anna — dynamic (event study)")
1315+
ax.legend(frameon=False, fontsize=9)
1316+
1317+
elif agg == 'group':
1318+
groups = df['group'].values
1319+
est = df['att'].values
1320+
yvals = np.arange(len(groups))
1321+
ax.errorbar(
1322+
est, yvals,
1323+
xerr=[est - df['ci_lower'], df['ci_upper'] - est],
1324+
fmt='o', color=point_color, capsize=5, markersize=7,
1325+
label=f"Pointwise {int(100*(1-result.alpha))}% CI",
1326+
)
1327+
if show_uniform and has_cband:
1328+
ax.errorbar(
1329+
est, yvals,
1330+
xerr=[est - df['cband_lower'], df['cband_upper'] - est],
1331+
fmt='none', color=band_color, capsize=10, linewidth=2,
1332+
alpha=0.6,
1333+
label=f"Uniform {int(100*(1-result.alpha))}% band",
1334+
)
1335+
ax.axvline(0, color='gray', linestyle='--', linewidth=0.8)
1336+
ax.set_yticks(yvals)
1337+
ax.set_yticklabels([f'g = {g}' for g in groups])
1338+
ax.set_xlabel('ATT(g)')
1339+
ax.set_title(title or "Callaway-Sant'Anna — group aggregation")
1340+
ax.legend(frameon=False, fontsize=9)
1341+
1342+
elif agg == 'calendar':
1343+
x = df['time'].values
1344+
est = df['att'].values
1345+
if show_uniform and has_cband:
1346+
ax.fill_between(
1347+
x, df['cband_lower'], df['cband_upper'],
1348+
color=band_color, alpha=0.25,
1349+
label=f"Uniform {int(100*(1-result.alpha))}% band",
1350+
)
1351+
if show_pointwise:
1352+
ax.fill_between(
1353+
x, df['ci_lower'], df['ci_upper'],
1354+
color=point_color, alpha=0.18,
1355+
label=f"Pointwise {int(100*(1-result.alpha))}% CI",
1356+
)
1357+
ax.plot(x, est, 'o-', color=point_color, linewidth=2, markersize=7)
1358+
ax.axhline(0, color='gray', linestyle='--', linewidth=0.8)
1359+
ax.set_xlabel('Calendar time t')
1360+
ax.set_ylabel('ATT(t)')
1361+
ax.set_title(title or "Callaway-Sant'Anna — calendar aggregation")
1362+
ax.legend(frameon=False, fontsize=9)
1363+
1364+
else:
1365+
raise ValueError(
1366+
f"Unsupported aggregation type in result.model_info: {agg!r}"
1367+
)
1368+
1369+
_style_ax(ax)
1370+
fig.tight_layout()
1371+
return fig, ax

0 commit comments

Comments
 (0)