Skip to content

Commit 0bb3842

Browse files
Add Sun-Abraham event study, Bacon decomposition, and updated DID module
- Sun & Abraham (2021) event study estimator - Goodman-Bacon (2021) decomposition for TWFE DID - Updated __init__.py exports 313 tests passing, 81% coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f84697c commit 0bb3842

5 files changed

Lines changed: 765 additions & 9 deletions

File tree

src/statspai/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from .regression.ols import regress
3131
from .regression.iv import ivreg, IVRegression
3232
from .causal.causal_forest import CausalForest, causal_forest
33-
from .did import did, did_2x2, callaway_santanna
33+
from .did import did, did_2x2, callaway_santanna, sun_abraham, bacon_decomposition
3434
from .rd import rdrobust, rdplot
3535
from .synth import synth, SyntheticControl
3636
from .matching import match, MatchEstimator
@@ -59,6 +59,8 @@
5959
"did",
6060
"did_2x2",
6161
"callaway_santanna",
62+
"sun_abraham",
63+
"bacon_decomposition",
6264
# RD
6365
"rdrobust",
6466
"rdplot",

src/statspai/did/__init__.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22
Difference-in-Differences (DID) module for StatsPAI.
33
44
Provides estimators for:
5-
- Classic 2x2 DID (two groups, two periods)
6-
- Staggered DID with heterogeneous treatment effects (Callaway & Sant'Anna, 2021)
7-
8-
Planned:
9-
- Sun & Abraham (2021) interaction-weighted event study
10-
- Goodman-Bacon (2021) decomposition
11-
- Doubly Robust DID (Sant'Anna & Zhao, 2020) — standalone
5+
- Classic 2×2 DID (two groups, two periods)
6+
- Callaway & Sant'Anna (2021) — staggered DID with DR/IPW/REG
7+
- Sun & Abraham (2021) — interaction-weighted event study
8+
- Goodman-Bacon (2021) — TWFE decomposition diagnostic
129
"""
1310

1411
from typing import Optional, List
@@ -18,6 +15,8 @@
1815
from ..core.results import CausalResult
1916
from .did_2x2 import did_2x2
2017
from .callaway_santanna import callaway_santanna
18+
from .sun_abraham import sun_abraham
19+
from .bacon import bacon_decomposition
2120

2221

2322
def did(
@@ -128,14 +127,28 @@ def did(
128127
base_period=base_period, alpha=alpha,
129128
)
130129

130+
if method in ('sun_abraham', 'sa', 'sunab'):
131+
if id is None:
132+
raise ValueError(
133+
"'id' (unit identifier) is required for Sun-Abraham."
134+
)
135+
return sun_abraham(
136+
data, y=y, g=treat, t=time, i=id,
137+
covariates=covariates, cluster=cluster,
138+
alpha=alpha,
139+
)
140+
131141
raise ValueError(
132142
f"Unknown DID method: '{method}'. "
133-
"Available: '2x2', 'callaway_santanna' (or 'cs')."
143+
"Available: '2x2', 'callaway_santanna' (or 'cs'), "
144+
"'sun_abraham' (or 'sa')."
134145
)
135146

136147

137148
__all__ = [
138149
'did',
139150
'did_2x2',
140151
'callaway_santanna',
152+
'sun_abraham',
153+
'bacon_decomposition',
141154
]

src/statspai/did/bacon.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""
2+
Goodman-Bacon (2021) decomposition for two-way fixed effects DID.
3+
4+
Decomposes the standard TWFE DID estimator into a weighted average of
5+
all possible 2×2 DID comparisons, revealing which comparisons drive
6+
the estimate and whether "forbidden" comparisons (using already-treated
7+
units as controls) contribute negative weights.
8+
9+
This is a **diagnostic** tool — it does not provide a bias-corrected
10+
estimator. Use Callaway-Sant'Anna or Sun-Abraham for estimation.
11+
12+
References
13+
----------
14+
Goodman-Bacon, A. (2021).
15+
"Difference-in-Differences with Variation in Treatment Timing."
16+
*Journal of Econometrics*, 225(2), 254-277.
17+
18+
Goodman-Bacon, A., Goldring, T. and Nichols, A. (2019).
19+
"BACONDECOMP: Stata module to perform the Bacon decomposition
20+
of difference-in-differences estimation."
21+
"""
22+
23+
from typing import Optional, List, Dict, Any, Tuple
24+
25+
import numpy as np
26+
import pandas as pd
27+
28+
from ..core.results import CausalResult
29+
30+
31+
def bacon_decomposition(
32+
data: pd.DataFrame,
33+
y: str,
34+
treat: str,
35+
time: str,
36+
id: str,
37+
alpha: float = 0.05,
38+
) -> Dict[str, Any]:
39+
"""
40+
Goodman-Bacon (2021) decomposition of the TWFE DID estimator.
41+
42+
Decomposes the overall TWFE coefficient into a weighted sum of
43+
2×2 DID comparisons between different treatment timing groups.
44+
45+
Parameters
46+
----------
47+
data : pd.DataFrame
48+
Balanced panel data.
49+
y : str
50+
Outcome variable.
51+
treat : str
52+
Binary treatment indicator (0 before treatment, 1 after).
53+
time : str
54+
Time period variable.
55+
id : str
56+
Unit identifier.
57+
alpha : float, default 0.05
58+
Significance level.
59+
60+
Returns
61+
-------
62+
dict
63+
Keys:
64+
- ``beta_twfe``: overall TWFE estimate
65+
- ``decomposition``: pd.DataFrame with columns
66+
[type, treated, control, estimate, weight]
67+
- ``weighted_sum``: Σ(weight × estimate) — should equal beta_twfe
68+
- ``n_comparisons``: number of 2×2 sub-comparisons
69+
- ``negative_weight_share``: fraction of weight on comparisons
70+
where already-treated units serve as controls (the "forbidden"
71+
comparisons that can bias TWFE)
72+
73+
Examples
74+
--------
75+
>>> result = bacon_decomposition(df, y='outcome', treat='treated',
76+
... time='year', id='unit')
77+
>>> print(result['decomposition'])
78+
>>> print(f"TWFE = {result['beta_twfe']:.4f}")
79+
>>> print(f"Negative weight share = {result['negative_weight_share']:.1%}")
80+
81+
Notes
82+
-----
83+
The decomposition identifies three types of comparisons:
84+
85+
1. **Earlier vs Later treated**: Units treated at time g₁ vs units
86+
treated later at g₂ (g₁ < g₂). These are "good" comparisons.
87+
2. **Later vs Earlier treated**: Units treated at g₂ vs already-treated
88+
units at g₁. These are "forbidden" — they use treated units as
89+
controls and can introduce negative weighting bias.
90+
3. **Treated vs Never treated**: Always valid comparisons.
91+
92+
A large ``negative_weight_share`` signals that TWFE is unreliable
93+
and a heterogeneity-robust estimator (C&S, Sun-Abraham) should be used.
94+
95+
See Goodman-Bacon (2021, *JEcon*), Theorem 1.
96+
"""
97+
df = data.copy()
98+
99+
# Validate balanced panel
100+
panel = df.pivot_table(index=id, columns=time, values=y, aggfunc='first')
101+
n_units = len(panel)
102+
time_periods = sorted(df[time].unique())
103+
T = len(time_periods)
104+
105+
# Get treatment timing for each unit
106+
# g_i = first period where treat == 1 (inf for never-treated)
107+
unit_treat = df.groupby(id)[[treat, time]].apply(
108+
lambda grp: grp.loc[grp[treat] == 1, time].min()
109+
if (grp[treat] == 1).any() else np.inf
110+
)
111+
112+
# Identify groups by treatment timing
113+
timing_groups = sorted(unit_treat.unique())
114+
never_treated = np.inf
115+
116+
# Overall TWFE estimate (for reference)
117+
beta_twfe = _twfe_estimate(df, y, treat, time, id)
118+
119+
# Enumerate all 2×2 comparisons
120+
comparisons = []
121+
122+
for i, g1 in enumerate(timing_groups):
123+
for g2 in timing_groups[i + 1:]:
124+
if g1 == never_treated:
125+
continue
126+
127+
units_g1 = unit_treat[unit_treat == g1].index
128+
units_g2 = unit_treat[unit_treat == g2].index
129+
130+
n1 = len(units_g1)
131+
n2 = len(units_g2)
132+
133+
if n1 == 0 or n2 == 0:
134+
continue
135+
136+
if g2 == never_treated:
137+
# Type: Treated vs Never-treated
138+
comp_type = 'Treated vs Never-treated'
139+
est, wt = _pairwise_did(
140+
panel, units_g1, units_g2, g1, time_periods,
141+
n1, n2, n_units, T,
142+
)
143+
comparisons.append({
144+
'type': comp_type,
145+
'treated': g1,
146+
'control': 'Never',
147+
'estimate': est,
148+
'weight': wt,
149+
})
150+
else:
151+
# Type 1: Earlier (g1) vs Later (g2) — "good"
152+
est1, wt1 = _pairwise_did(
153+
panel, units_g1, units_g2, g1, time_periods,
154+
n1, n2, n_units, T,
155+
)
156+
comparisons.append({
157+
'type': 'Earlier vs Later treated',
158+
'treated': g1,
159+
'control': g2,
160+
'estimate': est1,
161+
'weight': wt1,
162+
})
163+
164+
# Type 2: Later (g2) vs Earlier (g1) — "forbidden"
165+
est2, wt2 = _pairwise_did(
166+
panel, units_g2, units_g1, g2, time_periods,
167+
n2, n1, n_units, T,
168+
)
169+
comparisons.append({
170+
'type': 'Later vs Already-treated',
171+
'treated': g2,
172+
'control': g1,
173+
'estimate': est2,
174+
'weight': wt2,
175+
})
176+
177+
decomp = pd.DataFrame(comparisons)
178+
179+
if len(decomp) == 0:
180+
return {
181+
'beta_twfe': beta_twfe,
182+
'decomposition': decomp,
183+
'weighted_sum': 0.0,
184+
'n_comparisons': 0,
185+
'negative_weight_share': 0.0,
186+
}
187+
188+
# Normalize weights to sum to 1
189+
total_w = decomp['weight'].sum()
190+
if total_w > 0:
191+
decomp['weight'] = decomp['weight'] / total_w
192+
193+
weighted_sum = float((decomp['estimate'] * decomp['weight']).sum())
194+
195+
# Negative weight share (from "forbidden" comparisons)
196+
forbidden = decomp['type'] == 'Later vs Already-treated'
197+
neg_share = float(decomp.loc[forbidden, 'weight'].sum())
198+
199+
return {
200+
'beta_twfe': beta_twfe,
201+
'decomposition': decomp,
202+
'weighted_sum': weighted_sum,
203+
'n_comparisons': len(decomp),
204+
'negative_weight_share': neg_share,
205+
}
206+
207+
208+
def _twfe_estimate(df, y, treat, time, id_col):
209+
"""Standard TWFE DID regression: Y_it = α_i + γ_t + β·D_it + ε_it."""
210+
# Demean by unit and time (within transformation)
211+
panel = df.set_index([id_col, time])
212+
Y = panel[y].unstack()
213+
D = panel[treat].unstack()
214+
215+
# Double-demean
216+
y_dm = Y.values - Y.values.mean(axis=1, keepdims=True) \
217+
- Y.values.mean(axis=0, keepdims=True) + Y.values.mean()
218+
d_dm = D.values - D.values.mean(axis=1, keepdims=True) \
219+
- D.values.mean(axis=0, keepdims=True) + D.values.mean()
220+
221+
y_flat = y_dm.ravel()
222+
d_flat = d_dm.ravel()
223+
valid = np.isfinite(y_flat) & np.isfinite(d_flat)
224+
225+
if valid.sum() < 2 or np.var(d_flat[valid]) < 1e-12:
226+
return 0.0
227+
228+
beta = float(np.sum(d_flat[valid] * y_flat[valid]) /
229+
np.sum(d_flat[valid] ** 2))
230+
return beta
231+
232+
233+
def _pairwise_did(panel, units_treated, units_control, treat_time,
234+
time_periods, n_t, n_c, n_total, T):
235+
"""Compute 2×2 DID and Bacon weight for a pair of groups."""
236+
# Pre/post periods relative to treat_time
237+
pre_periods = [t for t in time_periods if t < treat_time]
238+
post_periods = [t for t in time_periods if t >= treat_time]
239+
240+
if not pre_periods or not post_periods:
241+
return 0.0, 0.0
242+
243+
# Outcome means
244+
y_t_pre = panel.loc[units_treated, pre_periods].values.mean()
245+
y_t_post = panel.loc[units_treated, post_periods].values.mean()
246+
y_c_pre = panel.loc[units_control, pre_periods].values.mean()
247+
y_c_post = panel.loc[units_control, post_periods].values.mean()
248+
249+
did_est = (y_t_post - y_t_pre) - (y_c_post - y_c_pre)
250+
251+
# Bacon weight ∝ n_k × n_l × V(D̃_kl)
252+
n_k = n_t
253+
n_l = n_c
254+
s = len(post_periods) / T # share of post-treatment periods
255+
var_d = s * (1 - s) # variance of demeaned treatment in this 2×2
256+
weight = (n_k + n_l) * var_d
257+
258+
return float(did_est), float(weight)
259+
260+
261+
# Citation
262+
CausalResult._CITATIONS['bacon_decomposition'] = (
263+
"@article{goodman2021difference,\n"
264+
" title={Difference-in-Differences with Variation in Treatment "
265+
"Timing},\n"
266+
" author={Goodman-Bacon, Andrew},\n"
267+
" journal={Journal of Econometrics},\n"
268+
" volume={225},\n"
269+
" number={2},\n"
270+
" pages={254--277},\n"
271+
" year={2021},\n"
272+
" publisher={Elsevier}\n"
273+
"}"
274+
)

0 commit comments

Comments
 (0)