flexMatch: see how matching choices build the final stacks
Aleksandr Kazakov
Turn a panel into inspectable matched event windows. flexMatch screens treatment timing, matches units within the same event period, then expands each match into a stack. The explorer below shows how 18 specifications change the actual retained observations, matched partners and control reuse.
What the package produces
A matched set contains a treated event and its matched controls. A stacked observation is an original unit-period row copied into one of these sets. The same underlying unit can appear in several sets, so copies are not independent observations.
This is a sample-construction tool for matched event studies. It does not establish parallel trends, select a causal estimand, or guarantee that previously treated controls are valid. Use the treatment process to choose the design before tuning matching options.
Reproducible example data
flexmatch_example is entirely synthetic: 10,000 units
and 65,000 observations over 2013–2022, with unbalanced histories and
repeated treatment starts. The explorer uses the first 300
units to keep examples quick. The synthetic outcome contains
persistent and repeated treatment effects; the broad-control examples
below demonstrate construction mechanics, not successful causal
recovery.
data("flexmatch_example")
panel <- as.data.frame(flexmatch_example[flexmatch_example$unit_id <= 300, ])
head(panel[, c("unit_id", "year", "event", "size", "leverage", "industry", "region")])
#> unit_id year event size leverage industry region
#> 1 1 2013 0 88.8 0.237 Manufacturing West
#> 2 1 2014 0 90.9 0.247 Manufacturing West
#> 3 1 2015 0 93.0 0.257 Manufacturing West
#> 4 1 2016 0 95.1 0.267 Manufacturing West
#> 5 1 2017 0 97.2 0.277 Manufacturing West
#> 6 1 2018 0 99.3 0.287 Manufacturing WestThe event column must mark starts with 1 and other observations with 0. For permanent adoption, convert an absorbing treated-state indicator into starts first. Provide one row per original unit and observed period. Time distances count globally ordered labels in the input; gaps absent for every unit are not reconstructed as elapsed calendar periods.
Explore argument choices and final stacks
The live R widget below uses a fixed reproducible random panel with staggered histories, internal holes and three matching covariates. Change arguments independently: each combination runs flexmatch(), not a saved preset. Covariates, nearest-neighbour matching and the control-pool rule stay fixed.
This live widget runs the flexMatch R package on the website server. Open explorer directly.
Understand the timing screen
The retained window and the exclusion buffer serve different roles.
At an event-period candidate c, expansion retains
c - k_pre through c + k_post. Controls must
pass the dirty-period screen at every retained period,
not only at c.
On a regular observed-period index, any control start in the inclusive interval [c − k_pre − k_post − k_freeze, c + 2 × k_post] disqualifies that control candidate. This is more conservative than excluding starts only inside the retained window. It follows from the current screen’s past cooldown and future buffer; these arguments are not estimated anticipation or persistence parameters.
Treated candidates need the complete pre/post window, clean
pre-history and no further start in the post-window.
treated_type = "first" keeps the first
observed start; it does not certify that no treatment
happened before the dataset began. A non-positive match_by
requires an observed covariate period and can lie outside the retained
window. Only the chosen matching-period covariates are required by
matching; outcome completeness needs a separate check.
Choose a treatment design first
| Setting | What it actually selects | Interpretation to justify |
|---|---|---|
control_type = "nevert" |
Units with no observed start anywhere in the supplied panel, also passing window/history checks. | Comparability of those controls; treatment before or after observed coverage remains unknown. |
control_type = "nyt" |
Eventually treated units before their first observed start, passing the same buffers. Never-treated units are excluded. | No anticipation over the relevant horizon and an appropriate adoption comparison. |
control_type = "all" or
"nt"
|
The same clean-window candidate pool; previously treated units may return after the buffer. | Prior effects must have ceased if these units are to serve as untreated controls. |
treated_type = "all" |
Every observed start passing the screen. | Repeated-event interpretation, carryover and dependence across events. |
include_all_years = TRUE |
All observed histories for the selected matches, with outer event-time bins. | Extra histories are outside the eligibility screen and can contain other starts. |
For permanent adoption, start with
treated_type = "first" and substantively suitable
"nevert" or "nyt" controls. Increasing
k_freeze does not make a permanently treated unit untreated
again. For temporary or repeated shocks, a broad pool can be useful if
the assumed washout period has a defensible basis.
Balance, support and the retention tradeoff
The figure below uses MatchIt’s standardized mean differences for the two matching covariates. Each specification’s unmatched comparison starts from its own candidate pool; a tight caliper also changes the matched treated subset. The 0.1 line is a descriptive guide, not a causal-validity threshold.
Exact matching on industry and region, a tighter caliper, or a wider window can discard treated events. Report who was excluded, the remaining covariate overlap, achieved ratios and the resulting target population. Nearest matching is order- and specification-dependent; the same number of rows need not mean the same matched sample. Matching quality does not address unobserved confounding by itself.
Use the pipeline in R
result <- flexmatch(panel, "unit_id", "year", "event",
k_pre = 1, k_post = 1, k_freeze = 0,
match_vars = c("size", "leverage"), match_by = -1,
treated_type = "first", control_type = "nevert",
method = "nearest", ratio = 1, replace = FALSE,
verbose = FALSE)
result$diagnostics$event_matching
#> $treated_candidates
#> [1] 98
#>
#> $treated_matched
#> [1] 98
#>
#> $treated_unmatched
#> [1] 0
#>
#> $unmatched_events
#> [1] unit_id cohort_year
#> <0 rows> (or 0-length row.names)
result$diagnostics$control_usage
#> $matched_slots
#> [1] 98
#>
#> $unique_original_units
#> [1] 62
#>
#> $original_units_in_multiple_sets
#> [1] 29
result$diagnostics$expansion
#> $matched_sets
#> [1] 98
#>
#> $units_within_sets
#> [1] 196
#>
#> $original_units
#> [1] 160
#>
#> $stacked_rows
#> [1] 588
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 139.2429 139.0336735 0.006595657 1.023072 Excellent Balanced
#> 2: leverage 0.2755 0.2821224 -0.163767443 1.394050 Acceptable Distorted
plot_matched_stacks(result$final_expanded_data, max_stacks = 4,
event_col = "event")$plotIn result$diagnostics, matching preserves
original-unit counts, event_matching distinguishes repeated
treated events and lists unmatched unit × event-period pairs,
control_usage measures reuse, and expansion
reports the final sets and rows.
For stage-by-stage access, use label_candidates(),
match_candidates() and
expand_matched_stacks(). plot_event_dynamics()
summarizes timing across original units;
plot_matched_stacks() displays the actual final matched
sets.
fit_event_study() optionally uses
fixest, matching weights, unit-within-set fixed effects
and matched-set-by-calendar-period fixed effects. It clusters by the
original unit by default. That wrapper still requires a justified
comparison group, treatment-effect aggregation and inference design; it
does not implement every modern difference-in-differences estimator.
Repeated rows across sets are not new independent units, and missing
outcomes can change its estimation sample further.
R help and reproducibility
Open help(package = "flexMatch"),
?flexmatch, ?plot_matched_stacks, and
?flexmatch_example in R. This tutorial is also available as
vignette("introduction", package = "flexMatch") after
installation.
Built with flexMatch 0.1.1, MatchIt 4.8.1, ggplot2 4.0.3 and R Under development (unstable) (2026-10-10 r90659). All scenarios use the same synthetic 300-unit input. Matching defaults and available methods follow the installed MatchIt version.