Skip to contents

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):

τ̂=∑gNtr,gTpost,gτ̂g∑gNtr,gTpost,g.\hat\tau = \frac{\sum_g N_{tr,g}\, T_{post,g}\, \hat\tau_g}{\sum_g N_{tr,g}\, T_{post,g}}.

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.0

Fitting

fit <- scm_fit(y ~ d | id + time, data = dat, method = "sdid")
fit$estimate          # aggregate ATT
#> [1] 1.962067
fit$staggered         # TRUE
#> [1] TRUE

Per-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.3333333

Choosing 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.

Across estimators

The same call works for every method:

methods <- c("scm", "sdid", "gsc", "mc", "tasc", "si")
sapply(methods, function(m)
  scm_fit(y ~ d | id + time, data = dat, method = m)$estimate)
#>      scm     sdid      gsc       mc     tasc       si 
#> 1.971450 1.962067 2.173199 1.998875 1.876695 2.174914

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.
  • nu in [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 is nu = 0 reached 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 when donor_mspe_threshold, lambda_pen or v_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.2311956

q_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 = FALSE gives 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.
  • weights sets 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      TRUE

Staggered 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      TRUE

Notes

  • plot() and augment() 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 the predictors = pred(...) interface.
  • SI additionally supports staggered and multi-arm simultaneously — see Estimators.