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.
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.001The 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.
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$coefficientsWith 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":
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.