knitr::opts_chunk$set( collapse = TRUE, comment = "#>" )
Panel event studies---also known as difference-in-differences (DiD) event studies---are among the most widely used research designs in applied economics, public policy, and health research. When units (firms, states, individuals) receive a treatment at known times and are observed over multiple periods, the panel event study framework lets us estimate treatment effects while controlling for both time-invariant unit characteristics and common time shocks.
This vignette follows the practical guide laid out in Miller (2023), "An Introductory Guide to Event Study Models," Journal of Economic Perspectives, 37(2), 203--230. Miller's paper provides a unified framework covering data structure, the estimating equation, reference-period normalization, endpoint binning, heterogeneous treatment effects with staggered adoption, pre-trend testing, and inference. We map each concept to the EventStudy package's panel module.
library(EventStudy) library(dplyr) library(ggplot2)
Miller classifies panel event study settings along two dimensions:
These distinctions matter because staggered adoption, combined with heterogeneous treatment effects, can bias conventional two-way fixed effects (TWFE) estimators---a point we return to in Section 8.
Thirty units observed over 20 periods. Fifteen units are treated at period 10; fifteen are never treated.
set.seed(42) n_units <- 30 n_periods <- 20 treat_period <- 10 true_effect <- 2.0 dataset_a <- tibble( unit_id = rep(1:n_units, each = n_periods), time_id = rep(1:n_periods, n_units) ) %>% mutate( # First 15 units are treated at period 10 treatment_time = ifelse(unit_id <= 15, treat_period, NA_integer_), treated = ifelse(!is.na(treatment_time) & time_id >= treatment_time, 1, 0), # Unit and time fixed effects + treatment effect + noise unit_fe = rep(rnorm(n_units, 0, 1), each = n_periods), time_fe = rep(rnorm(n_periods, 0, 0.5), n_units), outcome = unit_fe + time_fe + true_effect * treated + rnorm(n() , 0, 1) )
Fifty units over 20 periods. Three treatment cohorts adopt at periods 8, 12, and 16; a fourth group is never treated.
set.seed(123) n_units_b <- 50 n_periods_b <- 20 dataset_b <- tibble( unit_id = rep(1:n_units_b, each = n_periods_b), time_id = rep(1:n_periods_b, n_units_b) ) %>% mutate( # Assign cohorts: units 1-12 at t=8, 13-25 at t=12, 26-37 at t=16, rest never treatment_time = case_when( unit_id <= 12 ~ 8L, unit_id > 12 & unit_id <= 25 ~ 12L, unit_id > 25 & unit_id <= 37 ~ 16L, TRUE ~ NA_integer_ ), treated = ifelse(!is.na(treatment_time) & time_id >= treatment_time, 1, 0), # Heterogeneous effects: early adopters have larger effects true_te = case_when( treatment_time == 8 ~ 3.0, treatment_time == 12 ~ 2.0, treatment_time == 16 ~ 1.0, TRUE ~ 0 ), unit_fe = rep(rnorm(n_units_b, 0, 1), each = n_periods_b), time_fe = rep(rnorm(n_periods_b, 0, 0.5), n_units_b), outcome = unit_fe + time_fe + true_te * treated + rnorm(n(), 0, 1) )
Each dataset is wrapped in a PanelEventStudyTask:
task_a <- PanelEventStudyTask$new( dataset_a, unit_id = "unit_id", time_id = "time_id", outcome = "outcome", treatment = "treated", treatment_time = "treatment_time" ) task_a #> PanelEventStudyTask #> Units: 30 #> Periods: 20 #> Treated: 1 cohort(s) #> Outcome: outcome #> Estimated: FALSE task_b <- PanelEventStudyTask$new( dataset_b, unit_id = "unit_id", time_id = "time_id", outcome = "outcome", treatment = "treated", treatment_time = "treatment_time" )
Miller presents a general dynamic specification for panel event studies:
$$Y_{it} = \alpha_i + \gamma_t + \sum_{j \neq j^*} \delta_j \cdot D_{it}^j + \varepsilon_{it}$$
where:
The EventStudy package implements this equation in
estimate_panel_event_study(). The method argument selects the variant, while
leads, lags, and base_period control the event-time window and
normalization.
The simplest panel event study specification restricts all post-treatment effects to a single coefficient $\beta$:
$$Y_{it} = \alpha_i + \gamma_t + \beta \cdot D_{it} + \varepsilon_{it}$$
Here $D_{it} = 1$ if unit $i$ has been treated by time $t$ (post-treatment), and 0 otherwise. The coefficient $\beta$ is the average treatment effect on the treated (ATT) under the assumption that the treatment effect is constant across all post-treatment periods and cohorts.
task_a <- estimate_panel_event_study(task_a, method = "static_twfe") task_a$results$coefficients #> term estimate std.error statistic p.value #> ATT 2.01 0.15 13.4 <0.001
The static model is a good starting point, but it assumes homogeneous effects. If the treatment effect varies over time (e.g., growing or fading), this assumption can be misleading. The dynamic specification relaxes it.
The dynamic specification estimates a separate coefficient $\delta_j$ for each event-time period, allowing us to trace out the treatment effect path:
task_a <- estimate_panel_event_study( task_a, method = "dynamic_twfe", leads = 5, lags = 5, base_period = -1 ) task_a$results$coefficients #> relative_time estimate std.error statistic p.value #> -5 0.02 0.30 0.07 0.95 #> -4 -0.10 0.28 -0.36 0.72 #> ... #> -1 0.00 0.00 NA NA #> 0 1.95 0.22 8.86 <0.001 #> 1 2.03 0.23 8.83 <0.001 #> ...
The pre-treatment coefficients ($j < 0$) should be close to zero under the parallel trends assumption, while the post-treatment coefficients ($j \geq 0$) capture the treatment effect dynamics.
plot_panel_event_study(task_a, title = "Dynamic TWFE - Single Cohort")
The plot shows point estimates and confidence intervals at each event time, with a vertical dashed line separating the pre- and post-treatment periods.
One event-time indicator must be omitted to avoid perfect collinearity with the unit and time fixed effects. This is the reference period $j^*$, and all coefficients $\delta_j$ are interpreted relative to it.
Miller emphasizes that the choice of reference period is not merely a
mechanical necessity---it has substantive implications. The default
base_period = -1 normalizes to the period immediately before treatment, so
that $\delta_0$ captures the immediate treatment effect. Alternative choices
include:
# Normalize to two periods before treatment task_a <- estimate_panel_event_study( task_a, method = "dynamic_twfe", leads = 5, lags = 5, base_period = -2 )
With base_period = -2, the coefficient at $j = -1$ becomes interpretable as
a test for anticipation effects: if units begin reacting before treatment, this
coefficient will be non-zero.
Miller also discusses extended reference periods where multiple pre-treatment periods are pooled as the reference group. While the package normalizes a single period, you can approximate this by checking that all pre-treatment coefficients are jointly close to zero (see Section 9 on pre-trend testing).
In practice, event time extends beyond the window we estimate. Miller discusses endpoint binning: grouping all event times below $-K$ (or above $K$) into a single bin. This prevents observations far from the treatment date from being dropped and captures the cumulative effect of treatment at long horizons.
The package automatically bins at the leads/lags boundaries. The coefficient
at relative time $-\text{leads}$ captures the average effect for all event times
$\leq -\text{leads}$, and similarly for $+\text{lags}$.
# Narrow window: leads = 3, lags = 3 task_a_narrow <- estimate_panel_event_study( task_a, method = "dynamic_twfe", leads = 3, lags = 3 ) task_a_narrow$results$coefficients # Wider window: leads = 8, lags = 8 task_a_wide <- estimate_panel_event_study( task_a, method = "dynamic_twfe", leads = 8, lags = 8 ) task_a_wide$results$coefficients
With the narrow window, the endpoint bins absorb more observations, and the binned coefficients may differ from the interior ones. With a wider window, each coefficient captures a more specific event time but with potentially fewer observations.
Miller recommends reporting results for multiple window widths as a robustness check, and being transparent about which observations are binned at the endpoints.
When treatment is staggered and treatment effects are heterogeneous across cohorts or over time, the standard TWFE estimator can produce misleading results. The core issue is that TWFE uses all untreated and already-treated units as comparisons. When already-treated units serve as controls for newly-treated units---what Miller calls "forbidden comparisons"--- the resulting weights on cohort-specific treatment effects can be negative.
This means the TWFE estimate can be an inconsistent estimate of the ATT, potentially even with the wrong sign, when:
Sun & Abraham (2021) propose an interaction-weighted (IW) estimator that avoids forbidden comparisons. The idea is to estimate cohort-specific effects by interacting event-time indicators with cohort indicators, then aggregate using cohort-size weights.
The EventStudy package implements this as method = "sun_abraham":
task_b <- estimate_panel_event_study( task_b, method = "sun_abraham", leads = 5, lags = 5 ) task_b$results$coefficients
To see the difference, we can estimate both on the staggered dataset and compare:
# TWFE on staggered data (potentially biased) task_b_twfe <- PanelEventStudyTask$new( dataset_b, unit_id = "unit_id", time_id = "time_id", outcome = "outcome", treatment = "treated", treatment_time = "treatment_time" ) task_b_twfe <- estimate_panel_event_study( task_b_twfe, method = "dynamic_twfe", leads = 5, lags = 5 ) # Sun & Abraham on the same data task_b_sa <- PanelEventStudyTask$new( dataset_b, unit_id = "unit_id", time_id = "time_id", outcome = "outcome", treatment = "treated", treatment_time = "treatment_time" ) task_b_sa <- estimate_panel_event_study( task_b_sa, method = "sun_abraham", leads = 5, lags = 5 ) # Side-by-side comparison coefs_comparison <- bind_rows( task_b_twfe$results$coefficients %>% mutate(method = "Dynamic TWFE"), task_b_sa$results$coefficients %>% mutate(method = "Sun & Abraham") ) ggplot(coefs_comparison, aes(x = relative_time, y = estimate, color = method)) + geom_hline(yintercept = 0, linetype = "dashed", color = "grey40") + geom_vline(xintercept = -0.5, linetype = "dotted", color = "red", alpha = 0.6) + geom_point(position = position_dodge(width = 0.3), size = 2) + geom_line(position = position_dodge(width = 0.3), linewidth = 0.8) + labs( title = "TWFE vs Sun & Abraham: Staggered Adoption", x = "Relative Time to Treatment", y = "Estimate", color = "Method" ) + theme_minimal()
When treatment effects are homogeneous, the two estimators will agree. When effects are heterogeneous---as in Dataset B where early adopters have larger effects---the Sun & Abraham estimates correctly recover the cohort-weighted ATT, while TWFE may be attenuated or distorted.
The validity of the panel event study hinges on the parallel trends assumption: in the absence of treatment, treated and control units would have followed the same outcome trajectory. While this assumption is fundamentally untestable, we can look for indirect evidence.
The pre-treatment coefficients $\delta_j$ for $j < 0$ should be close to zero. The event study plot provides a visual check---if the pre-treatment coefficients trend systematically away from zero, the parallel trends assumption is suspect.
A more formal approach is a joint F-test of the null hypothesis that all pre-treatment coefficients equal zero:
coefs <- task_a$results$coefficients pre_treatment <- coefs %>% filter(relative_time < 0, relative_time != -1) # exclude base period if (nrow(pre_treatment) > 0) { f_stat <- sum(pre_treatment$estimate^2 / pre_treatment$std.error^2) / nrow(pre_treatment) p_value <- pf(f_stat, df1 = nrow(pre_treatment), df2 = task_a$results$model$df.residual, lower.tail = FALSE) cat("Joint pre-trend F-stat:", round(f_stat, 3), "\n") cat("p-value: ", round(p_value, 4), "\n") }
Miller warns that absence of evidence is not evidence of absence. A non-significant pre-trend test does not prove parallel trends---it may simply reflect low statistical power. Conversely, small but statistically significant pre-treatment coefficients may be substantively unimportant. Researchers should:
Miller recommends reporting not just the treatment effect estimates, but also the counterfactual outcomes---what the treated units' outcomes would have been absent treatment. This helps readers assess both the magnitude and plausibility of the estimates.
From the dynamic model, the counterfactual for a treated unit at event time $j$ is the observed outcome minus the estimated effect:
# Construct counterfactual for treated units coef_lookup <- task_a$results$coefficients %>% select(relative_time, estimate) %>% deframe() counterfactual <- dataset_a %>% filter(!is.na(treatment_time)) %>% mutate( rel_time = time_id - treatment_time, delta_hat = coef_lookup[as.character(rel_time)], delta_hat = ifelse(is.na(delta_hat), 0, delta_hat), outcome_counterfactual = outcome - delta_hat ) # Plot observed vs counterfactual counterfactual_summary <- counterfactual %>% group_by(time_id) %>% summarise( observed = mean(outcome), counterfactual = mean(outcome_counterfactual), .groups = "drop" ) ggplot(counterfactual_summary, aes(x = time_id)) + geom_line(aes(y = observed, color = "Observed"), linewidth = 0.8) + geom_line(aes(y = counterfactual, color = "Counterfactual"), linetype = "dashed", linewidth = 0.8) + geom_vline(xintercept = 10, linetype = "dotted", color = "red") + labs( title = "Observed vs Counterfactual Outcomes (Treated Units)", x = "Time Period", y = "Mean Outcome", color = NULL ) + theme_minimal()
The gap between the observed and counterfactual lines after the treatment date visualizes the estimated treatment effect.
Panel event studies typically require clustering standard errors at the unit
level to account for serial correlation within units. When the sandwich
package is installed, estimate_panel_event_study() automatically computes
cluster-robust standard errors via sandwich::vcovCL(). The cluster
parameter controls the clustering variable and defaults to the unit ID:
# Cluster-robust SEs at the unit level (the default) task_a <- estimate_panel_event_study( task_a, method = "dynamic_twfe", leads = 5, lags = 5, cluster = "unit_id" ) # Cluster at a different level (e.g., state) task_a <- estimate_panel_event_study( task_a, method = "dynamic_twfe", leads = 5, lags = 5, cluster = "state_id" )
If sandwich is not installed, the function falls back to OLS standard errors
with an informative message. Install it with install.packages("sandwich").
Miller discusses the well-known problem that clustered standard errors are unreliable when the number of clusters is small. His rule of thumb, drawing on the simulation evidence in the literature: you need approximately 42--50 or more clusters for cluster-robust inference to be reliable.
When the number of clusters is small, alternatives include:
fwildclusterboot packageDrawing on Miller (2023), here is a checklist for panel event study research:
base_period = -1 is standard;
consider whether anticipation effects warrant an earlier reference.Miller, D. L. (2023). An Introductory Guide to Event Study Models. Journal of Economic Perspectives, 37(2), 203--230. doi:10.1257/jep.37.2.203
Sun, L., & 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
MacKinlay, A. C. (1997). Event Studies in Economics and Finance. Journal of Economic Literature, 35(1), 13--39.
Cameron, A. C., Gelbach, J. B., & Miller, D. L. (2008). Bootstrap-Based Improvements for Inference with Clustered Errors. Review of Economics and Statistics, 90(3), 414--427.
Rambachan, A., & Roth, J. (2023). A More Credible Approach to Parallel Trends. Review of Economic Studies, 90(5), 2555--2591.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.