Lab: Event Studies from Scratch
Implement an event study: create event-time indicators, estimate dynamic treatment effects, visualize pre-trends and dynamics, and read the plot correctly.
- Languages
- Python, R, Stata
- Dataset
- Corporate governance reform and firm performance (simulated)
Overview
Event studies extend the DiD framework by estimating separate treatment effects for each time period relative to treatment. Instead of a single "before vs. after" estimate, you trace out how the treatment effect evolves over time. The pre-treatment coefficients serve as a diagnostic for the parallel trends assumption.
What you will learn:
- How to construct event-time (relative time) indicators
- How to estimate a dynamic treatment effects regression
- How to create and interpret the classic event study plot
- How to test for pre-trends
- Why you typically need to omit one reference period (and which one to choose)
- What can go wrong with event study specifications
Prerequisites: DiD (see the DiD lab), Fixed Effects.
Step 1: The Setting
Imagine a corporate governance reform that is adopted by different firms at different times. We want to estimate (a) the dynamic path of the treatment effect and (b) whether there were pre-existing trends that might invalidate the design.
Step 2: Simulate Panel Data with Staggered Treatment
# First-time setup: install.packages(c("fixest", "ggplot2"))
library(fixest)
library(ggplot2)
set.seed(2024)
n_firms <- 200
n_periods <- 20
# Treatment timing
treatment_year <- rep(Inf, n_firms)
treated_firms <- sample(n_firms, 100)
for (i in treated_firms) {
treatment_year[i] <- sample(2005:2014, 1)
}
# Build panel
df <- data.frame()
for (i in 1:n_firms) {
firm_fe <- rnorm(1, 0, 2)
for (t in 0:(n_periods - 1)) {
year <- 2000 + t
year_fe <- 0.3 * t
if (treatment_year[i] < Inf) {
event_time <- year - treatment_year[i]
treated <- 1
post <- as.integer(year >= treatment_year[i])
te <- ifelse(post, 1.0 + 0.5 * pmin(event_time, 5), 0)
} else {
event_time <- NA
treated <- 0
post <- 0
te <- 0
}
y <- 10 + firm_fe + year_fe + te + rnorm(1, 0, 1.5)
df <- rbind(df, data.frame(
firm_id = i, year = year, treated = treated, post = post,
event_time = event_time, performance = y,
treatment_year = ifelse(treatment_year[i] < Inf,
treatment_year[i], NA)
))
}
}
cat("Panel:", nrow(df), "observations\n")
cat("Treated firms:", sum(treatment_year < Inf), "\n")Expected output:
| firm_id | year | treated | post | event_time | performance |
|---|---|---|---|---|---|
| 0 | 2000 | 1 | 0 | -9.0 | 8.12 |
| 0 | 2001 | 1 | 0 | -8.0 | 9.45 |
| 0 | 2009 | 1 | 1 | 0.0 | 12.87 |
| 0 | 2010 | 1 | 1 | 1.0 | 13.54 |
| 150 | 2005 | 0 | 0 | — | 11.03 |
Summary statistics:
| Statistic | Value |
|---|---|
| Panel dimensions | 200 firms x 20 years = 4,000 obs |
| Treated firms | 100 |
| Never-treated firms | 100 |
| Treatment years | 2005–2014 (staggered) |
| Mean performance (pre-treatment) | ~10 + firm FE + time trend |
Step 3: Create Event-Time Indicators
The event study regression requires dummy variables for each period relative to treatment. We normalize to one period before treatment () as the reference category.
# Create event-time indicators
# fixest makes this easy with the i() function
# Clip event time to [-5, 5]
df$event_time_clipped <- pmin(pmax(df$event_time, -5), 5)
# For the manual approach, create dummies
event_times <- c(-5, -4, -3, -2, 0, 1, 2, 3, 4, 5)
for (k in event_times) {
col_name <- paste0("et_", ifelse(k < 0, "m", ""), abs(k))
df[[col_name]] <- as.integer(df$event_time_clipped == k & df$treated == 1)
}
cat("Event-time dummies created. Reference period: k = -1\n")Expected output:
| Event-time indicator | Observations with indicator = 1 |
|---|---|
| et_-5 (binned, k <= -5) | ~500 (treated obs 5+ years pre) |
| et_-4 | ~100 |
| et_-3 | ~100 |
| et_-2 | ~100 |
| et_-1 (reference) | omitted |
| et_0 | ~100 |
| et_1 | ~100 |
| et_2 | ~100 |
| et_3 | ~100 |
| et_4 | ~100 |
| et_5 (binned, k >= 5) | ~500 (treated obs 5+ years post) |
Step 4: Estimate the Event Study Regression
# Event study using fixest (the easiest approach)
# i(event_time_clipped, ref = -1) creates all dummies with -1 as reference
m_es <- feols(performance ~ i(event_time_clipped, treated, ref = -1) |
firm_id + year,
data = df[!is.na(df$event_time) | df$treated == 0, ],
vcov = ~firm_id)
summary(m_es)
# The coefficients are the event study estimatesExpected output:
| Event Time (k) | Coefficient | SE | 95% CI Lower | 95% CI Upper |
|---|---|---|---|---|
| -5 | -0.02 | 0.18 | -0.37 | 0.33 |
| -4 | 0.05 | 0.20 | -0.34 | 0.44 |
| -3 | -0.08 | 0.19 | -0.45 | 0.29 |
| -2 | 0.03 | 0.18 | -0.32 | 0.38 |
| -1 | 0.000 | — | — | — |
| 0 | 1.02 | 0.19 | 0.65 | 1.39 |
| 1 | 1.48 | 0.20 | 1.09 | 1.87 |
| 2 | 2.05 | 0.21 | 1.64 | 2.46 |
| 3 | 2.52 | 0.22 | 2.09 | 2.95 |
| 4 | 3.01 | 0.23 | 2.56 | 3.46 |
| 5 | 3.48 | 0.19 | 3.11 | 3.85 |
The pre-treatment coefficients (k = -5 to -2) are all close to zero and statistically insignificant, consistent with parallel trends. The post-treatment coefficients grow over time, matching the true DGP of 1.0 + 0.5 * min(k, 5).
Step 5: Create the Event Study Plot
The coefficient plot is the signature visualization of the event study design.
# Event study plot using fixest
iplot(m_es,
main = "Event Study: Governance Reform and Firm Performance",
xlab = "Event Time (Years Relative to Treatment)",
ylab = "Coefficient (Relative to k = -1)")
# Add reference line at treatment onset
abline(v = -0.5, lty = 2, col = "red")In your event study plot, the pre-treatment coefficients (k = -5 to k = -2) are all close to zero and statistically insignificant. What can you conclude?
Step 6: Interpreting the Post-Treatment Dynamics
The post-treatment coefficients tell a story about how the treatment effect evolves over time.
# Extract and interpret post-treatment coefficients
coefs <- coeftable(m_es)
cat("Post-Treatment Dynamics:\n")
cat("The effect grows over time as the governance reform takes hold.\n\n")
# True effects for comparison
for (k in 0:5) {
true_te <- 1.0 + 0.5 * min(k, 5)
cat(sprintf(" k = %+d: True = %+.3f\n", k, true_te))
}Expected output:
| Event Time (k) | Estimated Effect | True Effect (DGP) | Interpretation |
|---|---|---|---|
| k = +0 | ~1.0 | 1.0 | Immediate effect at adoption |
| k = +1 | ~1.5 | 1.5 | Effect after 1 year |
| k = +2 | ~2.0 | 2.0 | Effect after 2 years |
| k = +3 | ~2.5 | 2.5 | Effect after 3 years |
| k = +4 | ~3.0 | 3.0 | Effect after 4 years |
| k = +5 | ~3.5 | 3.5 | Effect after 5 years (plateau) |
The gradually building pattern reflects a reform that takes time to produce its full impact. The DGP generates this pattern with the formula: treatment effect = 1.0 + 0.5 * min(k, 5).
Step 7: What Can Go Wrong
Pre-trend Violation
# Simulate pre-trend violation (similar to Python code)
# When you see an upward slope in pre-treatment coefficients,
# parallel trends is violated.
# The post-treatment coefficients mix the true effect
# with the pre-existing differential trend.
cat("If pre-treatment coefficients slope upward, this indicates\n")
cat("a violation of parallel trends. The post-treatment estimates\n")
cat("are contaminated by the pre-existing trend.\n")Your event study shows a clear upward slope in pre-treatment coefficients (k = -5 to k = -2). Your advisor suggests 'just detrending' by subtracting the pre-treatment trend from the post-treatment coefficients. Is this a good solution?
Step 8: Advanced Considerations
Binning Endpoint Periods
In most settings, bin distant event times to avoid sparse cells. For example, group all into a single "" dummy and all into "".
Joint Test for Pre-Trends
Instead of eyeballing individual pre-trend coefficients, formally test whether they are jointly zero.
# Joint test for pre-trends using fixest
# wald() function tests if specified coefficients are jointly zero
wald(m_es, "event_time_clipped::-[2-5]")
cat("p > 0.05: Cannot reject that pre-trends are jointly zero\n")Expected output:
| Statistic | Value |
|---|---|
| Joint F-statistic | ~0.5–2.0 |
| Degrees of freedom | 4 (testing et_-5, et_-4, et_-3, et_-2) |
| p-value | > 0.05 (typically 0.3–0.8) |
| Conclusion | Cannot reject that pre-trends are jointly zero |
The joint F-test confirms what the event study plot shows visually: the pre-treatment coefficients are not distinguishable from zero, consistent with the parallel trends assumption.
Aggregating Post-Treatment Effects
Sometimes you want a single summary treatment effect rather than period-by-period estimates.
# Simple summary DiD estimate
df$treat_post <- df$treated * df$post
m_did <- feols(performance ~ treat_post | firm_id + year,
data = df, vcov = ~firm_id)
cat("Summary DiD estimate:", coef(m_did)["treat_post"], "\n")Expected output:
| Estimator | Estimate | Interpretation |
|---|---|---|
| Average post-treatment coefficient | ~2.25 | Mean of event-time coefficients at k = 0 through k = 5 |
| Static DiD (TWFE) | ~2.0–2.5 | Single summary treatment effect averaging across all post-treatment periods |
The average of the true DGP treatment effects across k = 0 to 5 is (1.0 + 1.5 + 2.0 + 2.5 + 3.0 + 3.5) / 6 = 2.25.
Step 9: Exercises
-
Change the reference period. Re-estimate with as the reference period. How do the coefficients and plot change? Which reference period makes more sense for your setting?
-
Test for anticipation effects. What if firms change behavior before the formal treatment date? Allow the treatment effect to begin one period before the official date and re-estimate.
-
Staggered DiD concerns. With staggered treatment timing and heterogeneous treatment effects, the standard TWFE event study can be biased (Sun & Abraham, 2021). Read about this issue and think about when it matters.
-
Placebo test. For the never-treated firms, assign a random "fake" treatment year and estimate the event study. All coefficients should be near zero.
Summary
In this lab you learned:
- Event studies estimate separate treatment effects for each period relative to treatment
- You typically need to omit one reference period (typically ) to avoid multicollinearity
- Flat pre-treatment coefficients are consistent with parallel trends but do not prove it
- The post-treatment pattern reveals dynamic treatment effects (growing, stable, or fading)
- In most settings, include firm and year fixed effects and cluster standard errors appropriately
- Bin endpoint event times to avoid sparse cells
- Use a joint F-test for pre-trends rather than eyeballing individual coefficients
- Pre-trend violations are a fundamental threat — "detrending" is not a reliable fix
- With staggered treatment, be aware of potential biases in standard TWFE event studies