|
| 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