From panel data to matched event-time stacks

Aleksandr Kazakov

The task

flexMatch helps construct matched event windows when units experience treatment at different times. It makes sample eligibility and matching visible before estimation. A clean window and good covariate balance do not establish causal identification: parallel trends, anticipation, repeated treatment and the target estimand still need substantive justification.

Example data

The bundled data are entirely synthetic. The complete panel contains 10,000 units and 65,000 observations, including missing histories and repeated events. We use 100 units so every example is quick.

data("flexmatch_example")
panel <- flexmatch_example[flexmatch_example$unit_id <= 100, ]
head(panel)
#>    unit_id  year      industry region  size leverage event outcome
#>      <int> <int>        <char> <char> <num>    <num> <int>   <num>
#> 1:       1  2013 Manufacturing   West  88.8    0.237     0   33.28
#> 2:       1  2014 Manufacturing   West  90.9    0.247     0   34.38
#> 3:       1  2015 Manufacturing   West  93.0    0.257     0   35.48
#> 4:       1  2016 Manufacturing   West  95.1    0.267     0   36.58
#> 5:       1  2017 Manufacturing   West  97.2    0.277     0   37.68
#> 6:       1  2018 Manufacturing   West  99.3    0.287     0   38.78

Screen, match, and expand

result <- flexmatch(panel, "unit_id", "year", "event",
  k_pre = 1, k_post = 1, k_freeze = 1,
  match_vars = c("size", "leverage"), method = "nearest",
  assess_quality = TRUE, plot_events = TRUE, verbose = FALSE)
result$diagnostics
#> $candidate_counts
#> $candidate_counts$treated
#> [1] 33
#> 
#> $candidate_counts$control
#> [1] 227
#> 
#> 
#> $matching
#> $matching$treated_candidates
#> [1] 33
#> 
#> $matching$treated_matched
#> [1] 33
#> 
#> $matching$treated_unmatched
#> [1] 0
#> 
#> $matching$unmatched_treated_ids
#> integer(0)
#> 
#> $matching$dropped_rows_missing_covariates
#> [1] 0
head(result$final_expanded_data)
#>      stack_id cohort_id unit_id  year rel_time treat  post weights cohort_year
#>        <char>    <char>   <int> <int>    <int> <num> <int>   <num>       <int>
#> 1:   2_2017_1    2017_1       2  2016       -1     1     0       1        2017
#> 2:   2_2017_1    2017_1       2  2017        0     1     1       1        2017
#> 3:   2_2017_1    2017_1       2  2018        1     1     1       1        2017
#> 4: 100_2017_1    2017_1     100  2016       -1     0     0       1        2017
#> 5: 100_2017_1    2017_1     100  2017        0     0     1       1        2017
#> 6: 100_2017_1    2017_1     100  2018        1     0     1       1        2017
#>    industry  region  size leverage event outcome
#>      <char>  <char> <num>    <num> <int>   <num>
#> 1:   Retail   North  86.2    0.235     0   34.34
#> 2:   Retail   North  88.3    0.245     1   37.43
#> 3:   Retail   North  90.4    0.255     0   38.93
#> 4:   Energy Central 111.2    0.254     0   37.56
#> 5:   Energy Central 113.3    0.264     0   38.65
#> 6:   Energy Central 115.4    0.274     0   39.75

The treatment indicator marks event starts. k_pre and k_post define the window; k_freeze extends the cooldown after an event. Matching uses covariates at match_by = -1 by default, with exact matching on the event period. Period distances count the ordered periods present in the input. A completely missing calendar period must be handled in the input before using these distances.

Inspect the sample

print(result$event_plot)
#> Warning: Raster pixels are placed at uneven horizontal intervals and will be shifted
#> ℹ Consider using `geom_tile()` instead.
#> Raster pixels are placed at uneven horizontal intervals and will be shifted
#> ℹ Consider using `geom_tile()` instead.

result$quality_stats$balance_table
#>    Variable     Mean_T      Mean_C        SMD       VR Quality_SMD Quality_VR
#>      <char>      <num>       <num>      <num>    <num>      <char>     <char>
#> 1:     size 138.330303 144.6757576 -0.1925478 1.257435  Acceptable  Distorted
#> 2: leverage   0.277697   0.2846061 -0.1603314 1.270539  Acceptable  Distorted
print(result$quality_stats$numeric_balance_panel)

Balance thresholds are descriptive aids. They do not prove parallel trends or eliminate unobserved confounding.

Choose controls and histories

Use control_type = "nevert" for never-treated controls or "nyt" for eventual adopters before their first event. "all" and "nt" currently select the same clean-window pool. treated_type = "all" allows repeated events that satisfy the window rules. This assumes earlier events have no remaining relevant effect after the chosen cooldown; that assumption should be justified.

include_all_years = TRUE retains observed histories and bins periods outside the window. Those extra observations are not guaranteed to satisfy the clean-window rules and should not be treated as automatically uncontaminated controls for estimation.

Illustrative event regression

if (requireNamespace("fixest", quietly = TRUE)) {
  fit <- fit_event_study(result$final_expanded_data, "outcome", "year")
  coef(fit)
}
#> fixest::rel_time::0:treat fixest::rel_time::1:treat 
#>                  2.000000                  2.400909

The wrapper uses matching weights, a unit-within-set fixed effect, a matched-set-by-calendar-period fixed effect, and clustering by the original unit. Pooled stacked estimates still require an appropriate weighting and treatment-effect interpretation; this wrapper does not implement every modern difference-in-differences estimator.

Function reference

Use help(package = "flexMatch"), ?flexmatch, and ?flexmatch_example for the full R help index. The lower-level functions expose each stage for inspection and customization.