When units adopt treatment at different times, coresynth groups them
into adoption cohorts and aggregates the cohort ATTs
with weights proportional to N_treated × T_post (Clarke et
al. 2024):
All six estimators support this; staggered timing is detected automatically from the treatment column — no extra flag is needed.
The cohort ATTs themselves come about in two different ways.
scm, sdid, gsc, and
si fit each cohort separately, against a donor pool you
choose with control_group. mc and
tasc instead impute the counterfactual from a single fit:
their observation mask already blanks each treated unit from its own
adoption date, so one factorisation covers every cohort at once. That is
why those two take no control_group argument — all cohorts
share the same donor set, namely every observed cell in the panel.
A staggered panel
Here u1 is treated from period 11 and u2
from period 16.
set.seed(42)
N <- 12; TT <- 20
f <- cumsum(rnorm(TT, 0, 0.5))
lam <- rnorm(N, 1, 0.3)
dat <- expand.grid(time = seq_len(TT), id = paste0("u", seq_len(N)))
dat$y <- as.vector(outer(f, lam)) + rnorm(nrow(dat), 0, 0.3)
dat$d <- 0L
dat$d[dat$id == "u1" & dat$time > 10] <- 1L
dat$d[dat$id == "u2" & dat$time > 15] <- 1L
dat$y[dat$d == 1] <- dat$y[dat$d == 1] + 2.0 # true ATT = 2.0Fitting
fit <- scm_fit(y ~ d | id + time, data = dat, method = "sdid")
fit$estimate # aggregate ATT
#> [1] 1.962067
fit$staggered # TRUE
#> [1] TRUEPer-cohort detail is in cohort_estimates:
fit$cohort_estimates
#> cohort n_treated T_pre T_post estimate weight
#> 1 11 1 10 10 1.973250 0.6666667
#> 2 16 1 15 5 1.939702 0.3333333Choosing the control group
control_group controls which units serve as donors for
each cohort in the estimators that refit cohort by cohort
("scm", "sdid", "gsc",
"si"):
-
"never_treated"(default) — never-treated units only. -
"clean"(deprecated) — never-treated units plus units adopting later than the current cohort.
Each cohort’s effect is averaged over every period from its adoption
date to the end of the panel. A unit that adopts later is untreated
before its own date but treated after it, so under "clean"
its post-adoption outcomes enter the earlier cohort’s
synthetic control and pull the effect towards zero. Neither source
design does this: Clarke et al. (2024, Algorithm 1) use pure controls
only, and Ben-Michael, Feller & Rothstein (2022) admit a donor only
if it is untreated through the last event time they estimate.
"clean" therefore warns and will be removed in the next
major release.
A staggered panel needs at least two never-treated units for these
estimators. When every unit is eventually treated, "mc" and
"tasc" still apply: they impute each unit’s counterfactual
from the untreated cells of the whole panel rather than from a donor
pool.
SCM: partially pooled synthetic controls
The classic SCM was designed for a single treated
unit, so its staggered extension rests on Ben-Michael, Feller &
Rothstein (2022, JRSS-B). Units adopting at the same time are averaged
into one cohort — fully pooling within a cohort is justified by their
theory — and each cohort gets its own synthetic control. How the cohort
fits interact is controlled by nu:
-
nu = "auto"(default) — the paper’s heuristic, based on how well separate synthetic controls already balance the average. -
nuin[0, 1]— partially pooled SCM: all cohort weight vectors are chosen jointly, trading off per-cohort pre-treatment fit (nu = 0) against the fit of the average placebo gap across cohorts (nu = 1), which anchors the aggregate ATT. The paper advises against both extremes. -
nu = "separate"— each cohort fitted independently with the classic V-optimised SCM. This isnu = 0reached through a path that keeps the V optimisation, donor filtering and penalty options the pooled solver has no room for, and it is what the default falls back to whendonor_mspe_threshold,lambda_penorv_selection = "oos"is set.
fixedeff = TRUE (also the default) demeans every unit by
its own pre-treatment mean within each cohort (an intercept shift),
which the paper reports dominates the alternatives when outcome levels
differ across units.
fit_pp <- scm_fit(y ~ d | id + time, data = dat, method = "scm")
fit_pp$estimate
#> [1] 1.97145
fit_pp$pooling[c("nu", "q_sep", "q_pool", "q_sep_separate", "q_pool_separate")]
#> $nu
#> [1] 0.7966777
#>
#> $q_sep
#> [1] 0.3459748
#>
#> $q_pool
#> [1] 0.2147741
#>
#> $q_sep_separate
#> [1] 0.3275213
#>
#> $q_pool_separate
#> [1] 0.2311956q_pool (the pooled imbalance) improves relative to the
separate solution at the cost of a small increase in q_sep
(the average per-cohort imbalance). scm_balance_frontier()
traces that trade-off across a grid of nu, which the paper
calls “an important tool for using partially pooled SCM in
practice”.
Two further arguments control what the fit is aimed at:
-
pool_cohorts = FALSEgives every treated unit its own weight vector (the paper’s Section 4 formulation) instead of one per cohort (its Appendix A.2 hierarchy, the default).scm_rank_pval()needs the unit-level form. -
weightssets the mass each treated unit carries in the aggregate ATT — a population or GDP figure, say, following Abadie (2021, S.8). It sets the block’s target series, its weight in the pooled objective, and its aggregation weight alike.
Auxiliary covariates
covariates partials the covariates out of the outcome
before fitting, which is the device SDID uses (Clarke et al. 2024,
S.2.2). SCM has another option: Ben-Michael, Feller & Rothstein
(2022, S.5.2) balance the covariates alongside the lagged
outcomes, adding a second imbalance measure to the objective weighted
against the first by xi.
scm_fit(y ~ d | id + time, data = dat, method = "scm",
covariates = c("x1", "x2"), cov_method = "balance")Each covariate enters as its average over the periods before the
earliest adoption, standardised on the donor units. xi
defaults to the paper’s rule, the sample variance of those units’
outcomes before the last adoption date; raising it buys covariate
balance at the cost of outcome balance, and letting it fall to zero
recovers the fit without covariates. The balance term needs the
partially pooled solver, so it cannot be combined with
nu = "separate", donor_mspe_threshold,
lambda_pen or v_selection = "oos".
fit$pooling then also reports q_cov_sep and
q_cov_pool, and scm_balance_frontier() carries
both across the nu grid. Read those diagnostics differently
from the outcomes-only ones above: the paper’s footnote 7 normalises by
the combined measure, so q_sep_separate and
q_pool_separate are not the outcome imbalance of the
nu = 0 solution, the _rel columns of the
frontier are not 1 there, and the outcome imbalances can both fall with
nu because the covariate side is the third quantity being
traded off.
Inference under staggered adoption
sdid_inference(), gsc_inference(), and
si_inference() all extend to staggered fits, following
Clarke et al. (2024). The bootstrap draws the treated units from all
cohorts together along with the controls, rebuilds the cohorts from each
draw, and lets the controls drawn serve every cohort. The jackknife
drops one unit at a time, treated units included, so it needs at least
two treated units in every cohort; jackknife_global is the
staggered-specific variant that drops each unit from
all cohorts at once, capturing the correlation that
shared donors induce between them.
library(broom)
tidy(sdid_inference(fit, method = "bootstrap", n_boot = 100, seed = 1))
#> term estimate std.error statistic p.value conf.low conf.high method
#> 1 ATT 1.962067 0.1209928 16.2164 3.861865e-59 1.700572 2.179586 bootstrap
#> alternative n_controls staggered
#> 1 two.sided 10 TRUE
# Two treated units per cohort for the jackknife
dat2 <- dat
dat2$d[dat2$id == "u3" & dat2$time > 10] <- 1L
dat2$d[dat2$id == "u4" & dat2$time > 15] <- 1L
dat2$y[dat2$id %in% c("u3", "u4") & dat2$d == 1] <-
dat2$y[dat2$id %in% c("u3", "u4") & dat2$d == 1] + 2.0
fit2 <- scm_fit(y ~ d | id + time, data = dat2, method = "sdid")
tidy(sdid_inference(fit2, method = "jackknife_global"))
#> term estimate std.error statistic p.value conf.low conf.high
#> 1 ATT 1.972735 0.08786111 22.45288 1.19946e-111 1.80053 2.14494
#> method alternative n_controls staggered
#> 1 jackknife_global two.sided 8 TRUEStaggered SCM fits use scm_inference().
Its default is the leave-one-unit-out jackknife of Ben-Michael, Feller
& Rothstein (2022, Appendix A.1): each unit of the panel, donors
included, is dropped in turn and the weights refitted with
nu held fixed, so the interval reflects the noise in the
synthetic controls as well as in the treated units.
method = "wild_bootstrap" gives their multiplier bootstrap
instead, after Otsu & Rai (2017): the weights stay fixed and every
unit’s term – a treated unit’s effect, a donor’s residual – is perturbed
by its own multiplier. It needs no refitting, so it suits panels too
large for the jackknife, and is somewhat conservative.
tidy(scm_inference(fit_pp))
#> term estimate std.error statistic p.value conf.low conf.high method
#> 1 ATT 1.97145 0.1022017 19.2898 6.543136e-83 1.771138 2.171762 jackknife
#> alternative n_controls staggered
#> 1 two.sided 10 TRUENotes
-
plot()andaugment()for staggered fits operate per cohort; the aggregate synthetic series is not defined (Y_synth = NULL). - SCM staggered adoption supports
covariates =(partial-out) but not thepredictors = pred(...)interface. - SI additionally supports staggered and multi-arm simultaneously — see Estimators.
