Event study regression in Python: leads, lags and pre-trend tests
Fit a two-way fixed-effects event study in Python, choose the leads, lags and reference period, test pre-trends and tabulate the dynamic effects.
A difference-in-differences estimate gives one number for the effect of a policy. Often the timing matters as much: does the effect appear at once, build up or fade? Did the treated units already differ before the policy started? An event study answers both questions. It estimates a separate coefficient for each period relative to adoption, before and after, and plots them against event time.
What an event study estimates
Let T0_i be the first treated period of unit i, so e = t − T0_i is event time: negative before adoption, zero in the first treated period. The event-study regression is a two-way fixed-effects (TWFE) model with one indicator per event time:
y_it = a_i + c_t + Σ_{e ≠ −1} β_e · 1{t − T0_i = e} + x_it′b + u_it
- Lags (e ≥ 0) are the dynamic effects: β_0 in the first treated period, β_1 one period later, and so on.
- Leads (e < 0) are placebo coefficients. If treated and control units were on parallel paths before adoption, and outcomes did not react in advance, the leads are zero.
- The reference period is the omitted event time, by convention e = −1. Every β_e is measured relative to it, so the reference coefficient is zero by construction, not by estimation. If units anticipate the policy and react one period early, a −1 reference absorbs part of the effect; an earlier reference such as −2 avoids that.
- End-point binning. Far leads and lags rest on few cohorts. Grouping all e ≤ −L into one lead and all e ≥ K into one lag keeps the window short, but it assumes one common effect inside each bin.
Never-treated units and the not-yet-treated periods of later adopters serve as controls through the time effects.
Pre-trend coefficients need careful reading. Leads near zero are consistent with parallel trends but do not prove them: the test may lack power, and a confounder can move the outcome and the policy together (Freyaldenhoven, Hansen and Shapiro 2019). Roth (2022) shows that conventional pre-tests often have low power and that conditioning an analysis on passing one can make the bias and the coverage of the reported intervals worse.
With staggered adoption there is a further problem. When effects differ across adoption cohorts, each TWFE event-time coefficient mixes in effects from other event times, so leads can be non-zero even when trends are parallel, and lags are not clean averages (Sun and Abraham 2021). The TWFE event study is reliable when all units adopt at once or the dynamic effect profile is the same for every cohort. Otherwise use a heterogeneity-robust estimator.
In OpenEconometrics
oe.eventstudy fits the regression. Give the unit identifier in group, the period in time and each unit's first treated period in treatment_time, missing for never-treated units. time and treatment_time must be integer periods in the same units.
leads=Landlags=Kset the window. With the default reference the indicators are lead2 to leadL and lag0 to lagK, with all e ≤ −L binned into leadL and all e ≥ K into lagK. Without them every observed event time gets its own indicator.reference=-1(the default) is the omitted event time; pass another negative integer to change it.covariance="robust"(the default) clusters bygroupwith t inference on G − 1 degrees of freedom, as inoe.didregress;"cluster","HC1"and"nonrobust"are also available, as arex=controls andweights=.result.tests["pretrends"]is the joint F test that all leads are zero.result.extra["event_table"]has one row per event time (term, observations, estimate, standard error, confidence interval, and binned and reference flags): the data of an event-study plot.result.extra["average_post_effect"]averages the lags.
The model corresponds to a Stata event study with eventdd, or with reghdfe on event-time indicators that you create first, for example reghdfe y lead4 lead3 lead2 lag0 lag1 lag2 lag3 lag4, absorb(firm year) vce(cluster firm). For staggered designs with cohort-specific effects, oe.csdid reports event-time aggregations of clean comparisons, and oe.heterodid offers the Sun–Abraham (model="sunab") and imputation (model="bjs", Borusyak, Jaravel and Spiess 2024) estimators described in the heterogeneous DiD guide. The treatment-effects family guide documents eventstudy itself.
Supported options
| Capability | oe.eventstudy |
|---|---|
| Stata command | eventdd, reghdfe (event-time indicators) |
| Covariance | robust (default), cluster, HC1, nonrobust |
| Cluster dimensions | Up to 2 |
| Weights | aweight, fweight, pweight |
| Inference | Student t |
| Missing values | raise, drop |
| Saved prediction | Not in the common adapter |
| Out-of-core data | oe.Dataset route |
| Saved-result helpers | oe.causal_evaluate |
Options of oe.eventstudy
| Option | Values | Default | Meaning |
|---|---|---|---|
lags | int | — | Post-treatment periods shown; later ones are binned into the last. |
leads | int | — | Pre-treatment periods shown; earlier ones are binned into the first. |
reference | int | -1 | Omitted reference relative period. |
Example
The script simulates 150 firms observed from 2006 to 2017: 50 adopt a practice in 2010, 50 in 2013 and 50 never do. Outcomes have firm effects, year effects and independent normal noise. The effect profile is the same for both cohorts: 0.5 in the adoption year, 1.0 and 1.5 in the next two years, then 2.0 from the fourth year on. There is no effect before adoption. Because the true effect is constant within the binned end points (0 for e ≤ −4, 2.0 for e ≥ 4), the window leads=4, lags=4 is correctly specified. The true average of lag0 to lag4 is 1.4.
import numpy as np
import pandas as pd
import openecon as oe
def true_effect(e):
"""Effect e periods after adoption: 0.5, 1.0, 1.5, then 2.0 from the fourth year on; 0 before."""
return 0.0 if e < 0 else 0.5 * min(e + 1, 4)
def simulate(n_per_group=50, seed=3):
"""Firms observed 2006-2017; adoption in 2010, in 2013 or never, with the same effect profile."""
rng = np.random.default_rng(seed)
adoption = [2010] * n_per_group + [2013] * n_per_group + [np.nan] * n_per_group
year_effect = {t: rng.normal(scale=0.5) for t in range(2006, 2018)}
rows = []
for i, g in enumerate(adoption):
firm_effect = rng.normal()
for t in range(2006, 2018):
effect = true_effect(t - g) if not np.isnan(g) else 0.0
rows.append((i, t, g, firm_effect + year_effect[t] + effect + rng.normal()))
return pd.DataFrame(rows, columns=["firm", "year", "adopted", "y"])
if __name__ == "__main__":
df = simulate()
es = oe.eventstudy(data=df, y="y", group="firm", time="year", treatment_time="adopted",
leads=4, lags=4, reference=-1)
print(es.summary())
print("\nRelative time Term Obs. Estimate True Note")
for r in es.extra["event_table"]:
note = "reference" if r["reference"] else "binned" if r["binned"] else ""
print(f"{r['relative_time']:>13} {r['term']:<5} {r['observations']:>5} {r['estimate']:8.3f}"
f" {true_effect(r['relative_time']):4.1f} {note}")
avg = es.extra["average_post_effect"]
print(f"Average of lag0-lag4: {avg['estimate']:.3f} (std. error {avg['std_error']:.3f}); true value 1.4")
With openecon 0.3.20a1 from PyPI the script prints:
Event-study regression — y
Observations: 1800 | Covariance: robust | Confidence: 95%
Term Estimate Std. error t P>|stat| CI lower CI upper
------- -------- ---------- -------- ----------- ---------- --------
[Event]
lead4 0.229326 0.144548 1.58651 0.114744 -0.0563025 0.514955
lead3 0.125277 0.190276 0.658396 0.5113 -0.250711 0.501265
lead2 0.498192 0.183333 2.71742 0.00735911 0.135924 0.86046
lag0 0.490781 0.181345 2.70634 0.00759703 0.132441 0.849122
lag1 1.09871 0.175501 6.26042 3.88535e-09 0.75192 1.44551
lag2 1.70303 0.145612 11.6956 7.91905e-23 1.4153 1.99076
lag3 2.30667 0.176943 13.0362 2.11153e-26 1.95703 2.65632
lag4 2.11161 0.156801 13.4668 1.51639e-27 1.80177 2.42145
r_squared: 0.765326 | r_squared_within: 0.195547 | rmse: 0.924632 | df_resid: 1780
n_groups: 150 | n_periods: 12 | n_clusters: 150
Joint pre-trend test: all leads = 0: F(3, 149) = 2.89735, p = 0.03713
Warning: Two-way fixed-effects estimates average heterogeneous effects with weights that can be negative under staggered adoption (Goodman-Bacon 2021; de Chaisemartin and D'Haultfoeuille 2020; Sun and Abraham 2021); interpret them as a weighted average only when effects are homogeneous across cohorts and periods, or use oe.csdid.
Relative time Term Obs. Estimate True Note
-4 lead4 250 0.229 0.0 binned
-3 lead3 100 0.125 0.0
-2 lead2 100 0.498 0.0
-1 lead1 100 0.000 0.0 reference
0 lag0 100 0.491 0.5
1 lag1 100 1.099 1.0
2 lag2 100 1.703 1.5
3 lag3 100 2.307 2.0
4 lag4 250 2.112 2.0 binned
Average of lag0-lag4: 1.542 (std. error 0.135); true value 1.4
Reading the output
- The lags trace the dynamic effect. They are 0.491, 1.099, 1.703, 2.307 and 2.112 against true values of 0.5, 1.0, 1.5, 2.0 and 2.0. The largest gap, lag3, is about 1.7 standard errors from the truth.
- The reference row is zero by construction. lead1 is omitted, so every coefficient is the change relative to the year before adoption. The binned rows, lead4 and lag4, pool 250 firm-years each: e ≤ −4 and e ≥ 4.
- The pre-trend test rejects, falsely. The three leads give F(3, 149) = 2.90 with p = 0.037, driven by lead2 = 0.498 (t = 2.72). There is no pre-trend in the simulation, so this is a type I error, which a 5% test commits in one sample in twenty. Neither a rejection nor a non-rejection settles the parallel-trends question on its own.
- The warning is generic. It appears whenever there is more than one adoption cohort. Here the effect profile is identical for both cohorts, so the TWFE coefficients are not contaminated; with cohort-specific effects they would be.
- The average post-treatment effect is 1.542 with a standard error of 0.135, about one standard error above the true 1.4. The 149 denominator degrees of freedom of the F test reflect the 150 firm clusters.
Limitations
- The common
oe.predictandoe.marginsadapters do not coveroe.eventstudy; use its own result tables and family helpers. - Cluster-robust covariance for
oe.eventstudyallows at most 2 clustering dimensions. - With
leadsorlags, event times outside the window are binned into the end points, which imposes one effect per bin. Without them, if no group is never treated, two indicators are not identified and the collinearity screen omits the last one, with a warning. - Event times without treated observations are omitted with a warning. A text
timecolumn raisesinvalid_time. oe.eventstudyhas no Sun–Abraham weighting, imputation or wild cluster bootstrap option; with staggered adoption and cohort-specific effects, its coefficients are not clean event-time effects.- No numerical parity with Stata is claimed: the inventory's
stata_parity_validatedflag isFalse.
References
- Borusyak, K., Jaravel, X. and Spiess, J. (2024). Revisiting event-study designs: Robust and efficient estimation. Review of Economic Studies, 91(6), 3253–3285. doi:10.1093/restud/rdae007
- Freyaldenhoven, S., Hansen, C. and Shapiro, J. M. (2019). Pre-event trends in the panel event-study design. American Economic Review, 109(9), 3307–3338. doi:10.1257/aer.20180609
- Roth, J. (2022). Pretest with caution: Event-study estimates after testing for parallel trends. American Economic Review: Insights, 4(3), 305–322. doi:10.1257/aeri.20210236
- Sun, L. and Abraham, S. (2021). Estimating dynamic treatment effects in event studies with heterogeneous treatment effects. Journal of Econometrics, 225(2), 175–199. doi:10.1016/j.jeconom.2020.09.006