Lab: Event Studies from Scratch
Implement an event study design step by step. Create event-time indicators, estimate dynamic treatment effects, visualize pre-trends and post-treatment dynamics, and learn to interpret the event study plot correctly.
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 must 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
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 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 must 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