gsc_boot() reports a confidence interval for
the ATT. Its replicates are generated with no treatment effect,
so they are draws of the estimation error (Xu 2017, Algorithm 2), and
the interval is the estimate minus their quantiles.
ci_lower/ci_upper were the raw quantiles
instead – an acceptance region around zero that never contained a
non-zero estimate. ci_lower/ci_upper
change; p_value, se and
boot_dist do not.
scm_fit() rejects a transformed outcome or
treatment in the formula. The columns are read from
data by name, so log(y) ~ d | id + time was
fitted as y and y ~ I(t > 1990) | id + time
used t as the treatment, without a word. Both are now
errors that ask for the variable to be created in data
first.
The parallel placebo loops cannot take the R session
down. An exception thrown inside an OpenMP region terminates
the process; the outcomes-only SCM and the SDID placebo loops now catch
it and mark that placebo NaN, as the predictor-spec SCM
loop already did.
OpenMP honours a thread limit. The placebo loops
and the multi-start search used every core, which the CRAN Repository
Policy forbids during checks.
options(coresynth.threads = n) now caps the thread count,
and R CMD check --as-cran runs on at most two threads (see
?coresynth-package). Results do not depend on the thread
count.
R CMD check runs the test suite.
tests/testthat.R was missing, so the checks (locally and on
CI) never ran the tests.
SDID weights are solved without forming the donor Gram
matrix. The unit weight QP has one row per pre-treatment period
and a column per donor, so with more donors than periods the gradient
and the step size now come from the factor instead of an N-by-N matrix
and its eigendecomposition. Same iterates up to round-off (estimates,
standard errors and p-values agree to ten digits); on a 300-donor panel
the placebo, jackknife and bootstrap methods of
sdid_inference() run about 6 times faster.
The TASC Kalman filter avoids an N-by-N inverse per period. The observation noise is diagonal, so the gain reduces to an r-by-r solve by the push-through identity. Same moments up to round-off; a 200-unit fit runs about 10 times faster.
Staggered fits of "scm",
"sdid", "gsc" and "si" now take
their donors from the never-treated units only
(control_group = "never_treated"). The old
default, "clean", also admitted units that adopt after the
cohort. Each cohort’s effect is averaged from its adoption date to the
end of the panel, so those units were treated themselves during part of
that window, and their post-adoption outcomes entered the cohort’s
counterfactual. With a constant effect of 3 in a three-cohort simulation
the aggregate ATT came out 0.24 to 0.59 too low depending on the method,
against a bias of at most 0.04 with never-treated donors. Neither source
design does this: Clarke et al. (2024, Algorithm 1) subset to pure
controls, and Ben-Michael, Feller & Rothstein (2022, S.2.2) admit a
donor only if it is untreated through the last event time estimated.
Staggered estimates from these four methods change.
"clean" still runs but warns and will be removed in the
next major release. A staggered panel with fewer than two never-treated
units is now an error that points to "mc" and
"tasc", which impute from untreated cells and need no donor
pool.
Covariate partial-out now includes unit and time fixed
effects. The covariates argument of
"sdid" (sharp and staggered) and of staggered
"scm" with cov_method = "partial_out"
implements the projection of Kranz (2022) that Clarke et al. (2024,
S.2.2) adopt: regress the outcome on the covariates and two-way
fixed effects over the untreated cells, then subtract
X * beta. The fixed effects were missing, so
beta also absorbed how the covariates’ unit levels and
trends line up with the outcome’s, and subtracting it wrote a spurious
differential trend back into the series. In a simulation with a true
coefficient of 1, beta_hat was 3.75 and the staggered SCM
and SDID estimates of an effect of 2 were 1.65 and 1.18; they are now
1.00, 2.01 and 2.01. Sharp SDID also regressed on the control units’
pre-treatment cells only; it now uses every untreated cell, the default
of Kranz’s own implementation, as staggered fits did. Estimates
with covariates change. A covariate that varies
only across units or only over time is absorbed by the fixed effects; it
now gets a zero coefficient and a warning (for staggered SCM,
cov_method = "balance" balances such covariates
instead).
scm_inference() defaults to the
leave-one-unit-out jackknife of Ben-Michael, Feller &
Rothstein (2022, Appendix A.1), which drops each unit of the panel,
donors included, refits with nu held fixed, and uses the
spread of the estimates. It refits once per unit – about 0.7 s at 100
units and 3 s at 200 – skipping donors that carry no weight in any
block, whose removal leaves the solution unchanged, and warm-starting
the rest from the full fit. It warns below 3 treated units, where it
over-rejected mildly (8.5% at a nominal 5%). The result carries the
leave-one-out estimates in jack_ests.
**method = "wild_bootstrap" now perturbs every unit,
as Otsu & Rai
(2017) prescribe.** The bootstrap BFR (2022, S.5.3) use adapts Otsu
& Rai’s wild bootstrap for matching estimators, whose key step is to
resample “not only the regression residuals of treated and untreated
observations but also the ones to estimate the average treatment
effects” (their Eq. 9). The previous implementation perturbed only the
treated units’ deviations from the ATT, so it missed the error of the
synthetic control that every treated unit of a cohort shares, and
under-covered: a nominal 95% interval covered 80% with 8 donors and 8
treated units per cohort, and 45%, 63%, 82% and 91% with 2, 3, 4 and 8
treated units. Each donor now carries its own multiplier on its
residual, weighted by how much the synthetic controls use it, and only
the treated units’ heterogeneity is centred at the ATT – as in Otsu
& Rai, and unlike BFR’s Eq. (13) as printed and
augsynth, which centre every unit and so widen the interval
in proportion to the effect. Residuals are against the two-way model of
the untreated outcome, so a common period shock does not widen it
either. On the same designs it covers 93% to 99.5% (93% with 2 treated
units); it is conservative where outcomes carry unit-specific trends the
two-way model does not absorb. It needs no refitting, which makes it the
option for panels too large for the jackknife, and now warns below 3
treated units rather than 5. Its intervals
change.
Staggered SCM fits store every unit’s adoption date
(T_adopt), and scm_balance_frontier() and the
new jackknife rebuild the panel from it. Rebuilding it from
cohort_fits, as before, turned the treated units of a
cohort the fit had skipped (for too short a pre-treatment window) into
never-treated donors for the refits.
"gsc", "mc" and
"si" now choose their tuning parameter the way their source
papers say to. All three picked it by a fixed rule that appears
nowhere in the literature: r = 2 factors for GSC, a penalty
of 1% of the spectral norm for MC, and
k = floor(sqrt(min(T_pre, N_co))) components for SI. Each
paper specifies a data-driven selection instead, and each is now the
default, so estimates from all three change unless the
parameter is supplied explicitly.
"gsc": r = NULL runs the
leave-one-period-out cross-validation of Xu (2017, Algorithm 1) — for
each candidate r, hold out one pre-treatment period, refit
the treated units’ factor loadings on the rest, and score the
prediction. Xu presents this as central to the method (“it avoids
specification searches”). On panels built with a known number of factors
it recovered r exactly for 1 through 4, where the old fixed
r = 2 was right only by coincidence. The scoring table is
kept in fit$r_cv. Candidates start at 1: r = 0
is not representable by a rank-r truncated SVD."mc": lambda = NULL runs the K-fold
cross-validation of Athey et al. (2021, S.4), drawing folds so the
observed fraction inside a fold matches the panel’s and scoring each
penalty on the held-out cells. It uses its own RNG stream, so the
penalty is reproducible and the caller’s random state is left untouched;
cv_folds, cv_nlambda and cv_seed
control it, and fit$lambda_cv reports whether the search
ran."si": k = NULL applies the optimal hard
threshold of Gavish & Donoho
(2014) to the pre-treatment donor block, one of the three data-driven
rules Agarwal et al. name. It costs a single SVD, where cross-validating
k would cost a PCR fit per candidate. On the same
known-rank panels it recovered the rank exactly for 1 through 4; the old
heuristic returned 4 in every case. A staggered fit applies the
threshold to each cohort’s own donor block, and clips a supplied
k to that cohort’s dimensions, so fit$k is one
value per cohort, named by adoption date.Staggered "mc" and "tasc" fits
now report the same estimand as every other method. Both
estimators recover the counterfactual by imputation: their observation
mask already blanks each treated unit from its own adoption date, so a
staggered panel needs no cohort-by-cohort refitting and the single fit
was, and remains, a valid staggered fit. What was wrong was the
aggregation. They averaged the per-unit post-treatment gaps with one
weight per treated unit, while "scm", "sdid",
"gsc", and "si" use the
N_treated,g * T_post,g weights of Clarke et al. (2024). The
two agree only when every cohort has the same size and the same number
of post-treatment periods; otherwise the same panel yielded a materially
different number from "mc" and "tasc" than
from the other four, with nothing to indicate it. They now use the
cohort weights as well, so staggered "mc" and
"tasc" estimates change. Sharp fits are unaffected
and return bit-identical results.
The fits also declare themselves properly. They gain
staggered = TRUE, cohort_estimates, and
cohort_fits, and inherit from
"coresynth_staggered". Consequently glance()
reports staggered = TRUE rather than FALSE,
tidy() returns the per-cohort table instead of an empty
data frame, augment() returns one series per cohort with
.period keyed to that cohort’s own adoption date, and
plot() errors and points at augment(), as it
already did for the other staggered fits. Most consequentially,
conformal_inference() rejects them: a staggered
"mc" fit previously passed its sharp-only guard unnoticed
and returned a p-value computed against a single global treatment
date.
method = "scm" now fits panels where several
units adopt at the same date, instead of erroring.
scm_fit() refused any sharp panel with more than one
treated unit, while the same units with adoption dates one
period apart went down the staggered path and fitted without complaint.
Simultaneous adoption is one cohort, and S.4.1 of Ben-Michael, Feller
& Rothstein (2022) shows that the across-unit variation putting
unit-level fits into the ATT error bound vanishes when the adoption date
is shared, so the bound is minimised by pooling those units: one
synthetic control for their average. That is the step their Appendix A.2
hierarchy already takes within each cohort, and the one the staggered
path here already took.
The fit reports the treated average in Y_treat and keeps
the raw per-unit series in Y_treat_mat, alongside
n_treated, which print(),
summary(), glance() and
export_json() all report. Donor weights stay a single
simplex vector, so predictors, v_window,
lambda_pen, v_selection = "oos",
plot(), placebo_in_time(),
loo_donors() and augment_scm() all work as
they do for one treated unit. Each pred() row of the
treated predictor vector is the average over the pooled units, under the
same weights the outcome rows are pooled with, so the predictor and
outcome sides describe one target series rather than two.
mspe_ratio_pval() does not, and now says so, pointing at
scm_rank_pval() below. It compares the treated unit against
placebo runs on single donors, and an average of several treated units
is less noisy than any one donor, which inflates its pre/post MSPE
ratio. Simulated under no treatment effect, the test rejected at 28%
with three pooled units and 53% with six, against a nominal
10%.
Staggered "scm" defaults to the pooling
heuristic and the intercept shift. nu defaulted to
separate per-cohort SCM and fixedeff to FALSE.
Ben-Michael, Feller & Rothstein argue against exactly that
combination: separate SCM is “unlikely to yield good results”
(abstract), S.4.2 advises against values of nu near 0 or 1,
and S.6 reports that the intercept-shifted estimator “dominates either
of the alternatives”. Their own implementation, augsynth,
defaults to both. So staggered SCM estimates change;
nu = "separate" and fixedeff = FALSE restore
the old behaviour, and nu gained "separate" as
a name for the older per-cohort path so that 0 can mean
what it means in the paper.
Reproducing their S.6 design – 49 units, 39 periods, selection into treatment through the unit effects, about 20 treated units, a sharp null – bears this out. Under the two-way fixed effects model the heuristic improved on separate SCM in bias, MAD and RMSE (MAD 0.0395 against 0.0413); under the factor model full pooling was much worse than the heuristic (MAD 0.1492 against 0.0607), as the paper reports. The gain depends on selection into treatment: in a panel where treatment is assigned at random the heuristic is the worse choice, which is worth knowing before reaching for it.
The default falls back to "separate" where the pooled
solver cannot go (donor_mspe_threshold,
lambda_pen, v_selection = "oos"); naming a
numeric nu there is still an error. The fallback reports
itself in a message naming the argument that caused it, rather than
changing the estimator in silence, and fit$nu records which
of the two ran: the number the pooled solver settled on, or
"separate". The heuristic itself was checked against
augsynth and agrees to four or five decimal places, across
cohorts of equal and unequal size alike.
qp_solver now defaults to
"auto", which is the Wolfe solver wherever the inner QP can
be degenerate. The inner SCM problem projects the treated unit
onto the convex hull of the donor columns in predictor space, so by
Caratheodory’s theorem an optimum supported on at most
k + 1 donors always exists, and Wolfe (1976) returns such a
solution. "active_set" returns an exact optimum too, but
when the QP is degenerate — fewer predictors than donors in the support,
where the optimal set is a face rather than a point — it breaks the tie
on round-off, so its weights can move with the arithmetic. This was
opt-in since 0.4.0 precisely because it changes those weights; 1.0.0 is
where that change belongs.
On a degenerate predictor spec the difference is not only which optimum is reported. In a 30-donor test panel with one or two aggregated predictors, Wolfe reached a lower pre-treatment loss than the active-set path (2.75 against 3.25, and 2.79 against 3.31) on 3 and 2 non-zero donors rather than 13 and 17 — the sparser, more stable inner solutions give the non-convex outer V search a cleaner objective to descend.
Degeneracy needs few rows next to the donor pool, so
"auto" is Wolfe for every predictor fit and for
outcomes-only fits with fewer than half as many pre-treatment periods as
donors. The other outcomes-only fits have a unique optimum in practice,
which both solvers return, and there "auto" keeps the
active set: Wolfe cold-starts each of the coordinate sweep’s inner
problems and was three times slower for identical weights.
"auto" also falls back to "active_set" on
the paths that run their own QP outside the sharp inner solver
(v_selection = "oos", lambda_pen,
v_optim = "bfgs", and staggered adoption). Naming
"wolfe" explicitly on one of those is still an error rather
than a silent downgrade, and fit$qp_solver records the
value actually used.
The default vline_offset in
plot.coresynth() and plot.scm_placebo() is now
-1 instead of 0, moving the treatment
line from the first post-treatment period to the last pre-treatment one.
At offset 0 the treatment is already in effect, so the treated path and
its counterfactual have usually separated by the time the line is
reached and the figure reads as though the gap opened before treatment.
With -1 everything left of the line is the period the fit
was asked to match. Pass vline_offset = 0 for the previous
position.
scm_fit() now takes ... ahead
of its estimator-specific arguments, which must therefore be
named in full; partial matches such as v_opt = and any use
of the fourth or later argument by position no longer work. This is what
makes lambda reach method = "mc": while
lambda_pen (an SCM-only argument) sat before
..., R matched lambda to it partially, so MC’s
nuclear-norm penalty could not be set through scm_fit() at
all and the value silently went to an argument MC ignores.
scm_fit() rejects arguments the chosen
estimator does not take. One formula interface serves six
estimators, so an argument meant for another one —
predictors or nu with
method = "mc", covariates or
fixedeff with "tasc",
control_group with either — or simply misspelled, was
absorbed by ... and changed nothing about the fit that came
back. These now error and name the methods the argument applies
to.
Sharp method = "scm" rejects
covariates instead of ignoring it. The partial-out
ran inside the staggered branch only, so on a single-cohort panel the
argument reached the fit function, was never read, and returned an
estimate identical to the one without it – the same silent no-op the
argument check above was added to stop, one level deeper. It is not an
oversight that SCM has no partial-out: SCM balances covariates rather
than residualising them. Abadie, Diamond & Hainmueller (2010, S.2.3)
put them in the V-weighted predictor distance, and Ben-Michael, Feller
& Rothstein (2022, S.5.2) add a separate balance term with its own
weight, noting in S.4 that “where Abadie et al. (2010, 2015) balance
auxiliary covariates, we focus exclusively on lagged outcomes”.
Partialling out is the device of Clarke et al. (2024, S.2.2), which SDID
needs because it has nothing to balance with.
A sharp fit does have something:
predictors = list(pred("x", <pre-treatment times>, "mean"))
balances a covariate on its pre-treatment aggregate, which is how BFR
carry a time-varying covariate into the balance problem too. The error
names that route. Staggered SCM keeps the partial-out, because
predictors is unavailable there.
pool_cohorts now takes effect on the
per-cohort staggered path. It was read only by the partially
pooled solver, so pool_cohorts = FALSE was silently
discarded whenever the fit went down the nu = "separate"
path – including the automatic fallback that
donor_mspe_threshold, lambda_pen and
v_selection = "oos" trigger. The fit then carried
cohort-level blocks the caller had asked not to have, and
scm_rank_pval(), which needs one synthetic control per
treated unit, rejected it with a message telling the caller to pass the
argument they had just passed. Both staggered paths now build their
blocks the same way, and fit$pool_cohorts records it. Sharp
fits reject the argument, as they already did nu and
fixedeff.
Partially pooled staggered SCM weights each cohort by its
size. The two imbalance measures nu trades off,
and the heuristic that sets it, treated every cohort as equally
important however many units it held. Appendix A.2 of Ben-Michael,
Feller & Rothstein (2022) – the hierarchy that pools units sharing
an adoption date, which is what pool_cohorts = TRUE
implements – writes the cohort problem on the sum over the
cohort’s units: its target is sum_i 1{T_i = T(g)} Y_i and
its weights satisfy sum_i gamma_ig = n_g. A cohort’s
residual is therefore n_g times its mean gap, so a cohort
of n_g units counts n_g times towards the
pooled imbalance and n_g^2 towards the separate one.
Weighting them all equally is neither, and it is also not what the fit
aggregates with, since the cohort ATTs combine with the
N_treated,g * T_post,g weights of Clarke et al. (2024).
Staggered "scm" estimates change where the
cohorts differ in size. Panels whose cohorts are all the same
size, and panels with one treated unit per adoption date, are unaffected
– the weighting is a common factor there, and the objective is
normalised. augsynth, the authors’ implementation, already
followed the appendix; on a panel with cohorts of 1 and 4 its heuristic
returned 0.94114 where this one returned 0.93563, and it now returns
0.94113. Across three further panels with cohorts of 1, 2 and 8 the two
now agree to four decimal places, where they previously differed by as
much as 0.025.
weights now reaches the objective, not just
the aggregation. A staggered fit multiplied the supplied mass
into each cohort’s aggregation weight while still fitting its weights to
the unweighted average of that cohort’s treated units, so with
unequal weights inside one cohort the reported ATT was neither the
equally weighted quantity nor the weighted one. The mass now sets the
block’s target series, its weight in the partially pooled objective, and
its T_post-multiplied aggregation weight alike, and
scm_inference() resamples each treated unit at its own mass
rather than splitting the block’s evenly.
scm_balance_frontier() refits on the same weighted
objective, where it previously ignored weights entirely.
Staggered "scm" estimates change when
weights is supplied; equal weights reproduce the
unweighted fit exactly.
"mc" estimates unit and time fixed effects
outside the nuclear norm. The S.4 model these fits implemented,
Y = L + e, leaves the panel’s level inside the penalised
matrix, so the nuclear norm shrinks it along with the factor structure
and the imputed counterfactual is pulled towards zero. On a panel
shifted to a level of 20 the ATT came out at +0.230 against a true 2 (z
= 4.5 over 40 draws). S.8.1 of the same paper keeps unit and time
effects out of the penalty – “we regularize the estimates of L*, but do
not wish to regularize the estimates of the fixed effects” – and
MCPanel, the authors’ own implementation, estimates them by default.
Doing so removes the bias (-0.020, z = -0.4) and cuts RMSE by about 30%.
The previous behaviour matched MCPanel with its fixed effects switched
off, and the new behaviour matches its default, to within simulation
error in both cases.
fixedeff = FALSE recovers the S.4 baseline. The fit
gains fe_unit, fe_time and
L_lowrank; L_hat remains the completed matrix
and now includes the fixed effects. conformal_inference()
refits under whichever model the fit used.
This also corrects the note published with the cross-validated penalty above: the small-panel upward bias recorded as a known limitation was this misspecification, not a property of nuclear-norm regularisation. The debiasing of Choi & Yuan (2024, Eq. 3.1) was tested against it and only halves it (+0.123, z = 2.5), adding nothing once the fixed effects are in.
scm_rank_pval() tests the aggregate effect
when several units are treated (Abadie & L’Hour 2021,
S.3.2). Each treated unit contributes its own statistic, the identities
of the treated are reassigned at random, and the sum of the ranks of the
real treated units is compared with its permutation distribution.
Ranking is what makes this work where mspe_ratio_pval()
cannot: every statistic being compared comes from a single unit, so the
averaged treated series never enters. Simulated with no treatment effect
it rejected at 7.0% and 10.0% against a nominal 10% (three and six
treated units), where mspe_ratio_pval() on the same fits
reached 28% and 53%; with an effect of 1.5 its power was 77% and
85%.
mspe_ratio_pval() can discard ill-fitting
placebo runs. Abadie, Diamond & Hainmueller (2010) report
their permutation distribution at cutoffs of 20, 5 and 2 times the
treated unit’s pre-treatment MSPE, on the grounds that a placebo run the
synthetic control could not track before treatment says nothing about
how rare a large post-treatment gap is. That cutoff existed only in
plot.scm_placebo(), where it changed the picture but not
the p-value. mspe_prune now applies it to the test, and
n_placebo_pruned reports how many runs it dropped. This is
a different control from mspe_threshold, which is a floor
guarding the ratio’s denominator against a near-zero pre-treatment
MSPE.
statistic = "post_mspe" adds the alternative of Abadie
(2021, S.3.5): rank the post-treatment MSPE itself, rather than its
ratio to the pre-treatment MSPE, once the ill-fitting runs are gone. On
a panel carrying five donors no synthetic control can track, that
statistic gives p = 0.150 unpruned – the unfittable donors sit at the
top of the distribution on size alone – and p = 0.029 at
mspe_prune = 5. The ratio statistic reaches p = 0.025
without any cutoff, which is the advantage ADH claim for it in their
Figure 8.
plot() and plot_data() on the result now
default to whichever cutoff the test used, so the figure and the p-value
describe the same set of placebo runs. This holds for
type = "ratios" as well as type = "gaps": the
subtitle reports the number of runs the p-value rests on, so a figure
drawing the discarded ones contradicts its own caption. Pass
mspe_prune = Inf to draw them all regardless.
plot(type = "ratios") also draws whichever statistic the
test ranked, so a statistic = "post_mspe" result gives the
post-treatment MSPE rather than its ratio, with the axis and title to
match. plot_data(type = "ratios") returns both statistics,
in ratio and post_mspe.
pool_cohorts = FALSE fits one synthetic
control per treated unit. Staggered SCM pools units sharing an
adoption date into a cohort, which is the Appendix A.2 hierarchy of
Ben-Michael, Feller & Rothstein (2022). Their S.4 formulation
instead gives every treated unit its own weight vector, and that is what
pool_cohorts = FALSE fits; cohort_fits then
names the unit behind each block, which is what
scm_rank_pval() needs. The default stays with the appendix,
where S.4.1 shows there is no across-unit variation to exploit inside a
cohort. (augsynth defaults the other way.)
Those blocks are labelled by unit rather than by adoption date,
because several of them share one: cohort_estimates carries
a unit column, tidy() returns
unit_<id> terms, and augment() keys
.unit on it. Labelling them by cohort would have given two
treated units the same key, which merges their series wherever it is
grouped or drawn on. A fit whose blocks are cohorts keeps the labels it
had, and the unit column names the unit of a one-unit
cohort and is NA for a pooled one. Estimators without the
notion carry no such column.
weights sets how much each treated unit
counts towards the ATT. Abadie (2021, S.8) notes that with
regions or countries a population- or GDP-weighted average effect is
often the quantity of interest. A staggered fit multiplies the supplied
mass by each block’s T_post; a sharp fit makes the pooled
target a weighted average of the treated series. Left NULL
the units count equally, as before.
scm_balance_frontier() and
plot() trace the pooling trade-off. Ben-Michael,
Feller & Rothstein call this plot “an important tool for using
partially pooled SCM in practice” (S.4.2). It refits across a grid of
nu and reports the imbalance of the treated average, which
the aggregate ATT rests on, against the root mean square of the
per-block imbalances, which the block-level effects rest on. The curve
is strongly convex in practice, so it shows where more pooling stops
paying for itself: on a test panel, moving nu from 0 to 0.5
cut the pooled imbalance by 21% for a 6% rise in per-block imbalance,
while the last quarter cost 39% for a further 8%.
This is worth looking at because the nu heuristic does
not always pick well. Where treatment is assigned without selection on
the unit effects, every block already fits to the noise floor and the
block-level gaps are near-independent, so their average shrinks by about
1/sqrt(J) and the heuristic returns roughly that – a number
driven by noise averaging rather than by imbalance pooling could remove.
Separating the two would need an estimate of the noise floor, which is
neither in the paper nor in augsynth, so the default is
unchanged and the frontier is offered instead.
print() and summary() describe
staggered fits as staggered. Both previously used the sharp
output, which reports T_pre and T_post from
the panel-level split — the earliest adoption date, correct for one
cohort and wrong for the rest — with nothing to say the fit was
staggered at all. print() now labels the method
(staggered) and gives the cohort count in place of that
split, and summary() prints the per-cohort table of
estimates, weights, and treatment windows. Multi-arm SI fits show the
cohort-arm table on the same terms.
cov_method = "balance" fits the
auxiliary-covariate form of staggered SCM (Ben-Michael, Feller
& Rothstein 2022, S.5.2, Equation 11). Every SCM paper handles
covariates by balancing them – Abadie, Diamond & Hainmueller put
them in the V-weighted predictor distance, and BFR note in S.4 that
“where Abadie et al. (2010, 2015) balance auxiliary covariates, we focus
exclusively on lagged outcomes” before reintroducing them this way.
Partialling out, which is what covariates has always done
here, is the device of Clarke et al. (2024, S.2.2) that SDID needs
because it has nothing to balance with; staggered SCM borrowed it only
because predictors is unavailable on that path.
Each covariate’s pre-treatment average now enters the partially
pooled objective as a second imbalance measure, pooled and per-block
like the lagged outcomes and carrying the same cohort masses, with
xi weighting the two sides. xi defaults to the
paper’s rule, the sample variance of the donor units’ outcomes before
the last adoption date; both normalisation constants are the combined
measure at the nu = 0 solution, as their footnote 7
defines. fit$pooling gains q_cov_sep,
q_cov_pool and n_covariates, and
scm_balance_frontier() traces the objective including them
and returns them as columns of its own. Read its relative columns
accordingly: they divide by the combined constant, so they are not 1 at
nu = 0, and the two outcome imbalances can both fall as
nu rises because the covariate side is the third quantity
being traded off.
It needs the partially pooled solver, so it is refused alongside
nu = "separate", donor_mspe_threshold,
lambda_pen and v_selection = "oos" rather than
falling back silently. xi is refused on the same grounds
wherever the balance term is not in the objective – a sharp fit, or
cov_method = "partial_out" – instead of being accepted and
ignored. "partial_out" remains the default, so no existing
fit changes.
As xi falls to zero the fit collapses onto the
outcomes-only one at a rate linear in xi, which is the
check that the covariate side reduces exactly to the existing path;
raising it trades outcome balance for covariate balance monotonically,
which is the trade-off the equation describes.
Outcomes-only SCM is fast again. Since 0.4.1 the default fit and its in-space placebo test had run about 3 times slower on panels with many pre-treatment periods, and 1.0.0’s Wolfe default had made it 10 to 14 times slower. Two causes, both fixed without changing any result (weights, estimates and placebo statistics agree with the previous build to 1e-13): the exact inner solve that seeds the coordinate sweep started the active set from all donors at once, paying a face solve per donor it dropped, and now reaches the same optimum from the support a gradient presolve finds; and the sweep over a panel with at least half as many periods as donors now carries the donor Gram matrix with rank-1 updates, where the predictor-oriented factor form rebuilt each face’s Gram from scratch. On a 100-donor, 96-period panel a fit takes 0.013 s and its placebo test 0.4 s, against 0.19 s and 5.6 s before.
The partially pooled staggered SCM solver no longer
decomposes an N-by-N matrix per block update. Each block’s QP
matrix is the Gram of a factor with one row per lag, whose small Gram
gives the same Lipschitz constant; solve_simplex_qp() now
accepts it. Estimates are unchanged (to 1e-14); a 200-unit fit takes
0.08 s instead of 0.13 s, and its jackknife 3 s instead of 27
s.
A staggered fit with covariates reports its
treated series on the scale it was fitted on. The covariates
are partialled out first, and every other series the fit carries – the
per-cohort targets, Y_all/Y_mat, and the sharp
path’s own Y_treat – is on that residualised scale, but the
staggered Y_treat was the raw outcome.
treated_outcomes() therefore returned one series while
augment() returned another. Affects "scm" and
"sdid"; without covariates the two scales
coincide and nothing changes.
mspe_ratio_pval() names
mspe_post_placebo by donor. Its companion
mspe_pre_placebo was named and it was not, so the two could
not be lined up by name although the documentation presents them as a
pair.
loo_donors() re-solves with the inner solver
the fit used. It always called the active-set method, so on a
degenerate predictor spec – where the optimal set is a face rather than
a point and the two solvers pick different points of it – dropping a
donor also changed which optimum was reported, mixing the two effects
the exercise is meant to separate.
conformal_inference() no longer substitutes
a retired rule for a tuning parameter a fit does not carry. The
refit has to impose the model the fit used, but r,
k and lambda each fell back to the fixed
heuristic that was this release’s default before it was replaced –
r = 2, k = floor(sqrt(min(T_pre, N_co))), 1%
of the spectral norm – which would score the null against a differently
specified counterfactual. Every current fit records its own, so the
fallback was unreachable; it is now an error naming the parameter. The
fields are also read exactly rather than with $, which
would partial-match r to r_cv, or
lambda to lambda_cv, if the exact name were
ever absent.
linetypes in plot.coresynth()
and plot.scm_placebo() makes the line type of each
series settable, the third member of the
colors/labels family and keyed the same way.
Line types were previously hard-coded, so the treated series was always
solid and the synthetic control always dashed; drawing both solid – the
usual choice when the two paths are already distinguished by color, or
when the figure is going into a document that separates series by color
alone – meant rebuilding the plot from plot_data().
For type = "trend" it takes a named vector over
"treated", "synthetic", and (with
show_donors > 0) "donors", e.g.
linetypes = c(synthetic = "solid"); series not mentioned
keep their default. For type = "gap" it takes a single line
type for the gap line, mirroring how colors takes a single
color there. For plot.scm_placebo(type = "gaps") the keys
are "treated" and "placebo". Values are
ggplot2 line type names ("solid",
"dashed", "dotted", …) or the equivalent
integer codes 0:6; an unknown key errors with the list of
valid keys, as for colors and labels. Bar
charts ("weights", "pred_weights") and the
point chart ("ratios") ignore the argument.
All defaults reproduce the previous appearance exactly. The argument
sits after labels in both signatures, so the arguments that
follow it (vline, vline_offset,
hline, …) shift by one position for anyone passing them
positionally rather than by name.
A near miss on the 0.4.1 outcomes-only routing is now
reported. Whether a per-period outcome spec is the
outcomes-only fit depends on the pre-treatment periods data
carries, not on the span of the pred() list, so the same
predictors argument switches paths when data
reaches further back – widening the panel for a plot is enough. Sharp
SCM fits whose predictors are all single-period outcome lags but do not
cover the pre-treatment window exactly now say so, report the coverage,
and point at v_window and at subsetting data
as the two ways to move the window the fit is judged on. Both fits were
already correct for the model each one states; what was missing was any
sign that the model had changed.
pred() time windows are checked against the
panel. A window naming times the panel does not carry silently
aggregated the overlap alone while keeping its full label, and a window
reaching past the treatment date silently aggregated values the
treatment had already moved. Both now warn, naming the offending times.
A window with no overlap at all keeps the existing hard error.
scm_design() gained the same non-finite predictor guard
scm_fit() already had – such a window previously surfaced
as “no feasible treatment set found”, which named neither the predictor
nor the window.
Sharp SCM fits with predictors = NULL now
emit a one-line message naming the default model (“using the
outcome in each of the k pre-treatment periods as predictors”), so the
implied specification is visible rather than only documented. The
message is suppressed where it would be noise: staggered fits (always
outcomes-only, no alternative) and v_selection = "oos" (its
own split is described under that argument). Wrap the call in
suppressMessages() to silence it.
A predictor specification consisting solely of the
outcome at each single pre-treatment period is now fitted through the
outcomes-only path, returning a fit identical to
predictors = NULL (with a message). Such a spec builds the
same X0/X1 as the outcomes-only fit – it is the standard way reference
implementations express an outcomes-only SCM (Synth
requires it: with no predictors at all, dataprep() errors)
– but previously it entered the predictor path (per-row SD scaling +
multi-start V search), which could return slightly different weights for
the same model and ran orders of magnitude slower. Verified on
Proposition 99: predictors = NULL, the per-period spec, and
Synth’s per-period special.predictors run now
agree (weights within 3e-3 of Synth, identical between the
two coresynth forms). Pass v_optim = "multistart"
explicitly to force the predictor-path optimiser for such a
spec.
scm_fit(qp_solver = "wolfe") selects a
Caratheodory-sparse inner solver. X0 w traces the
convex hull of the donor columns in the k-dimensional predictor space as
w ranges over the simplex, so the inner QP is a projection
onto a polytope in R^k, and by Caratheodory’s theorem an
optimum supported on at most k + 1 donors always exists.
The Wolfe
(1976) min-norm-point method returns such a solution: it grows a small affinely independent corral one donor at a time instead of shedding coordinates from the full pool, terminates finitely, and is KKT-verified, so it is an exact solver and not an approximation.
Use it when the predictor specification has fewer predictors than donors in the support. There the inner QP’s optimal set is a face rather than a point, the default active-set solver breaks the tie on round-off (so its weights are not stable across arithmetic), and the weights it returns can spread over most of the donor pool – 221 of 240 donors in one measured case – which is neither interpretable nor reproducible. Where the QP is not degenerate the two solvers agree: on Proposition 99 an outcomes-only fit returns the same six donors and the same pre-treatment loss to 1e-15 under either.
Opt-in for now, since it changes reported weights for degenerate
specs; it is intended to become the default in a future major release.
Not available with v_selection = "oos",
lambda_pen, v_optim = "bfgs", or staggered
adoption, which solve their own QP outside the sharp inner solver; those
combinations error rather than silently ignoring the argument.
mspe_ratio_pval() mirrors the fit’s choice so the
permutation test stays symmetric.
The inner SCM QP now returns a KKT-exact
optimum. scm_inner_weights_cpp() – which supplies
the reported weights for v_selection = "oos",
v_optim = "bfgs", loo_donors(),
conformal_inference(), scm_design(), and the
coordinate sweep’s starting candidate – ran accelerated projected
gradient to a 1e-6 step tolerance with no optimality gate. On
rank-deficient problems that left it visibly short of the optimum:
measured KKT residuals ran to 1e-2 relative, meaning the returned
weights did not solve the problem they were documented to solve. It now
runs the same warm-started active-set solver the main path uses, with
the gradient method only as a fallback; residuals across the regression
suite dropped from 1e-5..1e-2 to below 1e-11.
This moves reported weights for specifications whose inner QP is
degenerate. Outcomes-only fits improve slightly (Proposition 99
pre-treatment loss 7.220088 -> 7.220081). Specifications where the
treated unit’s predictor vector lies strictly inside the donor hull – so
the predictor distance is zero for a continuum of weights, e.g. two or
three aggregated predictor windows over a large donor pool – can move
substantially, because which zero-distance solution is returned
is arbitrary and the outcome path attached to it is arbitrary with it.
That is a property of such specifications, not of the solver;
qp_solver = "wolfe" above makes it visible and
stable.
Predictor-based SCM and its in-space placebo test are
100-10,000x faster on large donor pools. The inner simplex QP
no longer forms the N_co x N_co Gram Q = X0' V X0: it
carries the k x N_co factor B = diag(sqrt(V)) X0 instead.
Q has rank at most k, so with few predictors and many
donors it is maximally rank deficient – the regime where forming it
costs the most and says the least. The active-set face solve now runs in
the null space of the sum constraint inside the factor, at
O(k^2 m) instead of O(m^3) (same minimiser, same rank truncation:
pinv(B_A P) = Z pinv(B_A Z) for any orthonormal basis Z of
{z : 1'z = 0}), the dual-feasibility gradient costs O(k
N_co) instead of O(N_co^2), and the Lipschitz bound comes from the k x k
Gram instead of an N_co x N_co eigendecomposition. The NNLS rescue no
longer needs an eigendecomposition at all, since the factor is
the least-squares form it wants. At N_co = 240, T_pre = 20: a single fit
with one predictor drops from 3.78 s to under 0.01 s and its 240-donor
placebo test from 315 s to 0.03 s; with three predictors, from 5.79 s to
0.05 s and 463 s to 2.1 s. Outcomes-only placebo runs improve more
modestly (1.9 s to 1.1 s), as that path was never the rank-deficient
one.
v_optim = "multistart" and the coordinate
sweep short-circuit when there is a single predictor. With k =
1 the normalised V is the single point V = 1: every start in the
multi-start set is the same point and every grid candidate rescales the
inner QP by a positive constant, leaving its argmin unchanged. The
search is therefore vacuous and now reduces to one exact inner solve,
which is also strictly more accurate than the old path’s cold FISTA
solution.
The outcomes-only bordered-KKT face solve now falls back
to the exact null-space solve instead of to FISTA. That saddle
system is singular once the face exceeds the rank of its Gram, and the
previous code then gave up and let the caller fall back to FISTA, which
stops at its 1e-6 tolerance – so the outer V search read an inexact
objective. It now falls through to the exact routes. This makes
v_selection = "oos" measurably better fitting (3.5% lower
validation RMSPE on Proposition 99).
Note on reproducibility: when the inner QP is rank deficient (fewer
predictors than donors in the support) its solution set is a face, not a
point, and the active set’s entering-variable choice breaks ties on
round-off. Weights from predictor-based fits can therefore differ from
0.4.0 while remaining exact optima of the same problem; the
pre-treatment fit itself is unchanged to round-off for outcomes-only
specs and moves by at most a few times 1e-5 for in-sample predictor
specs under the default v_optim. Outcomes-only fits,
staggered SCM, SDID, GSC, MC, TASC, SI, scm_design(), and
conformal_inference() are bit-identical.
Predictor matrix construction is no longer a bottleneck
for predictor-based SCM fits.
build_predictor_matrices() aggregated each predictor row
with one full scan of the input data frame per unit (O(rows x units x
data size)); with many pred() entries – e.g. one outcome
lag per pre-treatment year – this R-level loop dominated the fit time
(1.75 s of a 3.9 s fit at N_co = 150, T_pre = 80, 15k rows). Each
predictor row is now computed with a single grouped aggregation (0.06 s
for the same spec, ~29x). Output is bit-identical to the previous
implementation across ops
(mean/median/sum), id types, NA
handling, and the empty-window error path, so every fit is unchanged.
The same per-unit-scan pattern was removed from
build_covariate_array() (the covariate paths of GSC, SDID,
and staggered SCM) and from scm_design()’s predictor
builder, likewise with bit-identical output.
Outcomes-only SCM fits are ~2-2.5x faster again.
Hardening the inner active-set QP for the predictor multi-start search
(0.4.0) replaced the cheap bordered-KKT face solve with a scale-robust
null-space projection on every pivot. That robustness is only needed
when predictors are supplied (rank-deficient V, scale
invariance under predictor rescaling); the outcomes-only path – dense V,
well-conditioned faces, no rescaling – paid the cost without the
benefit, running roughly twice as slow as 0.3.x despite returning the
identical fit. The outcomes-only inner QP now uses the cheap
bordered-KKT solve again
(scm_weights_cpp(cheap_face = TRUE), wired automatically
for outcomes-only fits, staggered SCM, the outcomes-only in-space
placebo, placebo_in_time(), and OOS V selection).
Predictor-based fits and the multi-start search keep the null-space
solver, so their results and scale invariance are untouched.
Outcomes-only results are bit-identical to the 0.2/0.3 line and differ
from 0.4.0 only at round-off (<= 5e-14 in weights), i.e. not
observably. Verified against Synth on Proposition 99 (ATT,
synthetic series, pre-RMSPE, donor weights all agree) and across a
degeneracy/noise sweep.
v_optim = "auto", the new default,
resolves to "multistart" when predictors are
supplied and to the single-start "coord_descent" for
outcomes-only fits). The outer problem – choose V so that the implied
donor weights minimise pre-treatment outcome MSPE – is non-convex, and
the previous single start from uniform V could settle in a poor basin:
on the Proposition 99 predictor specification it reported a
pre-treatment SSR of 137.8 where 58.5 is attainable (below the reference
Synth/tidysynth solution’s 78.1), halving the
treated unit’s MSPE ratio. The multi-start search screens a fixed, fully
deterministic start set (uniform, one-hot per predictor, 100 fixed-seed
draws) with warm-started exact inner QPs, then runs the leaders through
a Nelder-Mead / coordinate-descent refinement pipeline; its solution is
never worse (in pre-treatment loss) than the single-start path, and no R
RNG state is consumed. mspe_ratio_pval() applies the same
search to every placebo refit so the permutation test stays symmetric.
Numeric results for predictor-based fits therefore change (toward better
pre-treatment fits); pass v_optim = "coord_descent" to
reproduce the previous behaviour. Outcomes-only fits are unchanged. Note
that the old "auto" (choose "bfgs" when
k <= 15) is gone; "bfgs" itself remains
available.plot(fit, type = "pred_weights") draws
the predictor/variable weight matrix V of a sharp SCM fit
as a horizontal bar chart, the companion to the existing donor
type = "weights" chart. Bars are labelled with the
predictor names (or V1..V_Tpre for outcomes-only fits) and
honour the fill and top_n arguments. Staggered
SCM and the other estimators do not estimate a V matrix and
raise an informative error.plot_data() returns the tidy
data.frame behind any plot() view, taking the
same type argument ("trend",
"gap", "weights", "pred_weights"
for a fit; "gaps", "ratios" for an
scm_placebo object). It is the data-side companion to
plot(): extract the plotted series, weights, or placebo
paths to relabel the simplified series names, feed them into a table, or
build a bespoke figure. Columns use plain names (time,
value, series, …), and the arguments that
change which rows or values appear (align,
top_n, show_donors, mspe_prune)
are honoured. The cosmetic near-zero-weight filter that
type = "weights" applies to the chart is not used here, so
every donor is returned (use top_n to subset).v_window argument in
scm_fit() (sharp SCM fits): a vector of
pre-treatment time values over which the outer V optimisation evaluates
the pre-treatment fit, e.g. v_window = 1975:1988. The
default (NULL) keeps the current behaviour of evaluating on
all pre-treatment periods. The window restricts only the outer
evaluation loss – predictor matrices and the reported full-window
loss are untouched – and mspe_ratio_pval()
mirrors it in every placebo refit. Cannot be combined with
v_selection = "oos", which manages its own train/validation
split.colors/labels in
plot.coresynth() and plot.scm_placebo() are
now keyed by one-word series identifiers: treated,
synthetic, and (with show_donors > 0)
donors for trend plots; treated and
placebo for the placebo plots. The previous keys were the
displayed legend strings (Treated,
Synthetic Control, Placebo (donor pool)),
which required quoting for the multi-word names and conflated series
identity with the legend text that labels itself relabels.
Write colors = c(treated = "black") instead of
c(Treated = "black"); an unknown key errors with the list
of valid keys. The displayed legend text is unchanged.mspe_ratio_pval() placebo refits now mirror the
treated fit’s predictor specification by default:
use_covariates defaults to NULL (auto) instead
of FALSE. When the fit was estimated with a
predictors specification, each placebo unit is now refit
with that same specification – the Abadie et al. (2010) /
Synth convention, under which the treated and placebo test
statistics are computed under one common spec. Previously the default
silently switched the placebo refits to the outcomes-only spec, which
(a) made the permutation compare statistics that were not exchangeable
with the treated one, and (b) was extremely slow for long pre-periods
because each placebo re-ran a T_pre-dimensional V
optimisation. Outcomes-only fits are unaffected (auto resolves to the
outcomes-only placebo path, as before). Pass
use_covariates = FALSE explicitly to reproduce the old
behaviour on covariate fits.plot.coresynth() (type = "trend" and
"gap") and plot.scm_placebo()
(type = "gaps") gain a vline_offset argument
controlling where the vertical treatment line is drawn, in periods
relative to the first post-treatment period (the previous fixed
position). vline_offset = -1 moves it to the last
pre-treatment period, and fractional values such as -0.5
interpolate between adjacent observed times, on numeric,
Date, and POSIXct axes alike. The
vline style list now also accepts an
xintercept element for one or more absolute positions
(previously an error), e.g.
vline = list(xintercept = "1989-01-01") on a
Date axis. Hiding the line still works via
vline = FALSE.plot.coresynth() gains an align argument for
type = "trend" and "gap".
align = TRUE shifts the synthetic series by its
pre-treatment level gap to the treated series, so both are drawn on the
same level. SDID’s unit weights are estimated with the intercept
concentrated out of the QP (Arkhangelsky et al. 2021), so its raw trend
plot can show the synthetic control at a different level than the
treated unit; align = TRUE uses the time-weight (lambda)
weighted pre-period gap, which makes the average post-period gap in the
plot equal the SDID estimate exactly.plot.coresynth(type = "trend") gains a
show_donors argument that draws the outcome paths of the
show_donors donor units with the largest weights as thin
background lines (Inf shows all). The new
"Donors" series participates in
colors/labels overrides.plot.coresynth(type = "weights") now shows two panels
— donor unit weights (omega) and pre-period time weights (lambda) — in
the same bar style. Other methods keep the single unit-weight
panel.sdid_inference(method = "placebo") now also reports the
placebo-distribution standard error (Clarke et al. 2024, Algorithm 4)
and the corresponding normal-approximation confidence interval, for both
sharp and staggered fits. The p-value is unchanged (permutation-based).
tidy()/glance() pick the new columns up
automatically.scm_fit(method = "scm") on a
staggered panel gains a nu argument. nu = NULL
(default) keeps the previous behaviour (per-cohort V-optimised SCM). A
numeric nu in [0, 1] minimises the convex
combination
nu * (pooled pre-treatment imbalance)^2 + (1 - nu) * (per-cohort imbalance)^2
over all cohort weight vectors jointly, so that the aggregate ATT is
anchored by the pooled fit (nu = 0 reproduces separate
per-cohort SCM with uniform lag weights, nu = 1 is fully
pooled). nu = "auto" selects the paper’s heuristic value.
The fit stores balance diagnostics in fit$pooling
(q_sep, q_pool, and their separate-SCM
baselines).fixedeff argument for staggered SCM fits.
fixedeff = TRUE demeans every unit by its own pre-treatment
mean within each cohort before fitting (Ben-Michael, Feller &
Rothstein 2022, Section 5.1; Doudchenko & Imbens 2017; Ferman &
Pinto 2021), turning the estimator into a weighted
difference-in-differences. Works with both the default path and the
partially pooled path.scm_inference() function implements the weighted multiplier
(wild) bootstrap of Ben-Michael, Feller & Rothstein (2022, Section
5.3): donor weights are kept fixed and per-treated-unit effect
contributions are perturbed with golden-ratio two-point multipliers.
Returns a standard coresynth_inference object (works with
tidy()/glance()). This is the first inference
method available for staggered SCM fits.solve_simplex_qp() gains an optional x0
warm-start argument, used by the partially pooled block coordinate
descent to restart FISTA from the previous block solution. Together with
an objective-based stopping rule this makes the pooled path roughly an
order of magnitude faster on larger donor pools (N = 100: ~0.8 s to
~0.06 s per fit). Validated against the reference implementation
augsynth (weights correlate at 1.0, identical heuristic
nu, equal pooled imbalance at nu = 1).v_optim = "bfgs" is deprecated and
will be removed in a future release. It is a single-start L-BFGS-B outer
optimiser with no advantage over the alternatives now that the default
is multi-start: use v_optim = "multistart" (or the
"auto" default) for predictor-based fits, and
"coord_descent" for outcomes-only fits. Passing
"bfgs" still works but now emits a deprecation warning.
"coord_descent" is not deprecated: besides
being a selectable optimiser, it is the engine for outcomes-only and
staggered fits and the reference against which the multi-start
never-worse guarantee is defined.scale_predictors. On a degenerate face the warm
active-set solve can churn to its pivot cap, and the old cold-FISTA
fallback (thousands of iterations on ill-conditioned metrics) made a
single slow donor dominate placebo wall time; a finite-termination
Lawson-Hanson NNLS now seeds the exact face solve on those cases in
microseconds, gated by the same KKT check so the returned solution is
unchanged. The rescue is confined to the multi-start internals – every
other path (outcomes-only, coord_descent, oos,
staggered, loo_donors(), conformal) keeps its historical
solver bit-for-bit, and the multi-start uniform-start leg stays on the
plain path so the never-worse guarantee holds. Candidate refinement
pipelines run in parallel, and the placebo battery flattens the (donor x
candidate) task list into one schedule – donor-level parallelism alone
lets the slowest donor pin a thread while the others go idle. On the
Proposition 99 8-predictor spec a full multi-start fit takes ~0.06s and
the complete 38-donor mspe_ratio_pval() battery ~1.7s
(every donor’s fit verified never worse than the single-start path). A
side effect of the exact face solves: inner QPs on rank-deficient faces
are now solved exactly where the previous code fell back to a
loosely-converged FISTA iterate. Outcomes-only sharp, staggered, and
placebo results are bit-identical to the previous release;
v_selection = "oos" outcomes fits (where the training half
has fewer rows than donors) can shift slightly, toward better fits.scm_weights_cpp() and
the outcomes-only placebo loop in scm_placebo_cpp() now
solves each inner simplex QP with a warm-started active-set method
(KKT-verified exact solve, with FISTA fallback), applies V-coordinate
changes to the Gram matrix as implicit rank-1 updates instead of
rebuilding X0' V X0 per grid point, and computes the
Lipschitz constant once per coordinate-descent sweep instead of once per
QP. On a monthly panel with T_pre = 139,
T_post = 19, and 75 donors, the full 75-unit outcomes-only
placebo run drops from roughly an hour to about 2 seconds, and a single
outcomes-only scm_fit() from ~75 s to ~0.1 s. Per-unit
results agree with the previous implementation within the
coordinate-descent convergence tolerance: the estimator and its
grid/accept/normalisation semantics are unchanged, and the inner QP
solutions are now exact rather than first-order-approximate.predictors specification,
mspe_ratio_pval() previously refit each placebo unit in a
sequential R loop; the loop now runs in C++ under OpenMP (new low-level
routine scm_placebo_x_cpp(), the covariate counterpart of
scm_placebo_cpp()). Each leave-one-out problem is solved by
the same coordinate-descent core as before, so per-unit results are
identical to machine precision – only the wall time changes. The speedup
is bounded by the slowest single placebo unit (iterations are
distributed dynamically across cores), so it approaches the core count
when per-unit costs are uniform and is smaller when one hard-to-fit
donor dominates.plot.coresynth() no longer embeds non-ASCII characters
(Greek letters, the Unicode minus sign) in plot titles and subtitles.
Some platforms cannot compute text metrics for these characters outside
a UTF-8 locale, which crashed R CMD check --run-donttest on
macOS.treated_outcomes(), synthetic_outcomes(), and
donor_outcomes() return the treated series, the estimated
counterfactual, and the donor outcome matrix from any
coresynth fit under a uniform interface, regardless of the
estimation method. They replace the per-method field sniffing previously
duplicated across augment(),
conformal_inference(), and plot(); each
returns NULL when the series is not stored in the fit
(e.g. staggered fits, which keep their data per cohort in
fit$cohort_fits)."coresynth_staggered", and
multi-arm SI fits from "coresynth_multiarm". S3 methods
(print(), summary(), tidy(),
augment()) dispatch on these subclasses instead of
branching on internal flags. The
staggered/multi_arm list fields are retained,
so existing code that reads them keeps working, and all class checks
remain inherits()-compatible.mspe_ratio_pval() now
returns an scm_placebo object (still fully backward
compatible with the previous plain-list fields) that additionally
carries the placebo gap path for every donor unit. A new
plot.scm_placebo() method renders the two companion figures
from the paper: type = "gaps" overlays the treated unit’s
gap on the donor-pool placebo gaps (Figures 4-7, with
mspe_prune implementing the paper’s relative pre-treatment
MSPE pruning at 20x/5x/2x), and type = "ratios" plots the
post/pre-treatment MSPE ratio for every unit (Figure 8).plot.coresynth() and plot.scm_placebo() gain
colors, labels, vline,
hline, and (for type = "weights")
fill and top_n arguments. top_n
restricts the weights bar chart to the top_n largest-weight
donors (default Inf keeps every donor with a non-negligible
weight), useful for large donor pools.
vline/hline accept a list of
geom_vline()/geom_hline() aesthetic overrides
merged onto the built-in defaults, or
NULL/FALSE to suppress the reference line
entirely — since a line already added to a returned ggplot
object cannot be removed, only restyled or overplotted, suppression has
to happen inside the plot method. colors accepts a named
vector overriding individual series colors (unmentioned series keep
their default). labels accepts a named vector overriding
the legend text of individual series
(e.g. labels = c(Treated = "California")); keys are always
the original series names, so colors and
labels compose independently, and in
type = "ratios" the treated unit’s axis tick follows the
relabeled legend entry. All defaults reproduce the previous appearance
exactly, so existing calls are unaffected.augment(fit, include_donors = TRUE) now returns donor
rows for method = "si" fits too (previously it warned that
control outcomes were unavailable and returned treated rows only).
Estimates are unaffected.plot() on a staggered fit now fails with a clear
message explaining that staggered fits store their series per cohort,
instead of an internal data.frame length error.plot(fit, type = "gap")) now labels its
y-axis simply “Gap” and states the definition (“Treated − synthetic
control”) in a new subtitle, replacing the previous
Y_treated - Y_synthetic axis label. The placebo gaps plot
(plot(<scm_placebo>, type = "gaps")) uses the same
“Gap” axis label — there each line is that unit’s gap relative to its
own synthetic control, so the old parenthetical was dropped rather than
reworded. Cosmetic only; no computed values change.method = "tasc", plot(),
augment(), and the Y_synth series of
export_json() reported the average fitted value of
all units as the counterfactual: TASC stores its fitted
values as a full T x N matrix (Y_hat), unlike the other
methods, and the shared extraction code averaged over every column. The
treated unit’s columns are now used (synthetic_outcomes()
handles this per method), so the plotted/augmented counterfactual, gap,
and residuals for tasc fits change; the ATT estimate itself
was always computed from the correct per-unit gaps and is
unaffected.scm_design() solved the weakly_targeted
(eq. 9) and unit_level (eq. 10) designs by a sequential
approximation: the treated weights w were always chosen to
match the population average predictor vector alone, which made the
xi penalty of eq. 10 effectively inert and left the eq. 9
objective jointly suboptimal. Both designs are now solved exactly for
every candidate treated set — eq. 9 by two-block alternating
minimisation of the jointly convex QP in (w, v), eq. 10 by
folding the per-unit synthetic-control losses into the treated-weight QP
target — and the solutions have been verified against an independent
exact QP solver. This changes numerical results for
scm_design(design = "weakly_targeted") and
design = "unit_level" when m >= 2
(the default m = 1 selects a single treated unit, so
w is degenerate and both old and new solvers agree).conformal_inference()’s counterfactual refit now
dispatches on the fit’s class (one S3 method per estimator) rather than
an if-chain on the method string. Results are
unchanged.method = "mc" treated missing (id, time)
panel cells and NA outcomes as observed zeros instead of
excluding them from the observation mask used by the Soft-Impute solver.
Missing cells are now masked out the same way as treated post-adoption
cells. This changes numerical results for mc fits
on unbalanced panels or panels with NA
outcomes.method = "tasc"’s EM loop already handles missing
outcome cells (Kalman smoother plus per-unit M-steps), but its
initialisation (svd(), the treated-unit loading OLS, and
var()) failed on panels with missing cells. Initial values
are now computed from a column-mean-imputed matrix; the EM loop itself
still runs on the true, unimputed data.Malformed panels previously produced confusing or misleading errors —
for example, a 0-row data frame (from an upstream filtering bug) was
misclassified as staggered adoption and failed with an unrelated
“predictors not supported” error, and missing (id, time)
cells surfaced as a raw eig_sym(): decomposition failed
from the C++ layer. panel_to_matrices() (shared by all six
estimators) and scm_design() now validate and error clearly
on:
NA unit or time identifiersNA or negative treatment indicator values(id, time) cells or non-finite outcomes, for
the estimators that require a fully observed panel (scm,
sdid, gsc, si — mc
and tasc handle missing data by design)scm_fit() also now rejects non-numeric
(factor/character) outcome or treatment
columns and non-integer treatment values, instead of silently coercing
them (as.integer(factor(...)) returns level codes, not the
original values). “All cohort-level fits failed” errors (SCM/SDID/GSC/SI
staggered paths) now point back to the preceding per-cohort warnings for
diagnosis.
solve_simplex_qp() /
solve_simplex_qp_lr()), used by every
scm_fit(method = "scm") fit and by the
method = "sdid" unit weights, now use FISTA with adaptive
restart (O’Donoghue & Candès 2015, gradient scheme). The first-order
solver’s iteration count grows with the condition number of
Q = X0'VX0, which is large for the collinear pre-treatment
outcome panels typical of synthetic control; resetting the momentum term
when it works against the gradient removes this slowdown. Each inner
solve converges to the same optimum (verified against an exact QP solver
to within 1e-10), so the returned weights are
unchanged.~1e-5). For v_selection = "oos"
and penalised (lambda_pen) fits on ill-conditioned panels,
the non-convex outer V search may now settle on a different local
optimum, shifting results slightly; both the previous and the new
solutions are valid SCM fits with the same objective. This can
change numerical results for v_selection = "oos" and
penalised fits on poorly conditioned data.panel_to_matrices() (and therefore every
scm_fit() method) and scm_design() now error
on duplicate (id, time) entries instead of silently keeping
a single arbitrary row. A balanced panel requires each unit-time cell to
be unique; duplicates were previously overwritten by the last
matrix-index assignment, dropping data without warning. The error
reports the number of offending rows and the first duplicated unit and
time.plot() now renders
Date/POSIXct time axes correctly. The time
vector was coerced with as.numeric(), so dates appeared as
days-since-epoch (e.g. 16000,
20000) on the x-axis. Date/POSIXct values are
now passed through unchanged so ggplot2 selects the appropriate date
scale; only character/factor time values are
coerced to numeric.v_selection = "oos" (outcomes-only case) previously fit
candidate W(V) on the full pre-treatment outcome matrix and
restricted only the MSPE evaluation to the validation window, allowing
the V optimiser to fit the validation period indirectly (a data leak
relative to Abadie (2021) S.3.2). The new
.scm_oos_outcomes() implements the correct train/validation
split: candidate W(V) are fitted on training-half outcomes
only, V* minimises validation-half MSPE, and
W* is refit with V* on the outcomes of the
last floor(T_pre/2) pre-treatment periods. For OOS fits,
v_weights now has floor(T_pre/2) entries and a
new v_rows field records which periods they refer to.
This changes numerical results for
v_selection = "oos".scale_predictors (default TRUE): predictor
rows supplied via predictors = are now divided by their
standard deviation across all units before optimisation, matching the
Synth reference implementation (Abadie, Diamond & Hainmueller 2011,
JSS). predictor_table continues to report values on the
original scale. This changes numerical results for SCM fits with
user-supplied predictors unless
scale_predictors = FALSE.placebo_in_time(): in-time placebo (backdating) test
for sharp SCM fits (Abadie, Diamond & Hainmueller 2015; Abadie &
Vives-i-Bastida 2022).loo_donors(): leave-one-out donor robustness check with
the predictor weights V held fixed (Abadie, Diamond & Hainmueller
2015, footnote 20).build_predictor_matrices() now errors with an
informative message if a pred() time window produces
missing or non-finite predictor values.conformal_inference()): permutation-based p-values and
confidence intervals following Chernozhukov, Wüthrich & Zhu (2021).
Works with sharp fits across all supported estimation methods
(scm, sdid, gsc, mc,
si). The counterfactual proxy is re-estimated under the
null on all T periods (essential for finite-sample validity per
CWZ S.2.2), and p-values are obtained via moving-block (cyclic-shift)
permutation of the estimated residuals. Confidence intervals are
constructed by test inversion over a user-supplied or automatically
chosen grid. Returns a coresynth_inference subclass
compatible with tidy() and glance().panel_to_matrices(): fill loop replaced by vectorised
match() + matrix-index assignment; removes an O(n × (T +
N)) bottleneck in the shared data-prep path.tasc.cpp: safe_inv_sympd() helper added so
the Kalman filter degrades to pinv instead of aborting when
the innovation covariance is not numerically PD.%||% null-coalescing helper centralised in
utils.R; duplicate definitions in broom.R and
plot.R removed.check_sharp_adoption() (unused internal function)
removed.First public release.
pred(), out-of-sample V selection
(v_selection = "oos"), donor filtering
(donor_mspe_threshold), penalised SCM
(lambda_pen), and staggered adoption. Inference: MSPE ratio
permutation test via mspe_ratio_pval().covariates =), sharp and staggered adoption. Inference:
sdid_inference() with placebo / bootstrap / jackknife /
jackknife_global.gsc_boot()) and non-parametric
(gsc_inference()).si_inference() with
bootstrap / jackknife / jackknife_global.scm_design() with base / weakly_targeted / unit_level
variants, blank-period permutation test, and split-conformal confidence
intervals.scm_fit(outcome ~ treatment | unit + time, data, method = ...)
entry point for all methods.panel_to_tensor() for multi-arm SI data
preparation.broom integration: tidy(),
glance(), augment() for all methods and
inference objects.plot.coresynth(): trend, gap, and weights plots via
ggplot2.export_json(): JSON export for reproducibility.All core optimisations implemented in C++ via RcppArmadillo: 50–70x
faster than the Synth package for typical panel sizes
(N_co ≤ 30). src/inference.cpp placebo loops parallelised
with OpenMP.