coresynth (development version)
A review of the GSC, TASC, SI and SDID code against their papers and against gsynth/fect. Estimates from method = "gsc" with covariates, from method = "tasc", and the intervals of sdid_inference(), gsc_inference(), si_inference() and gsc_boot() change.
Bug fixes
GSC with covariates no longer stops before its estimates settle. The loop that alternates the factors and the covariate coefficients updated
betaby plain alternating least squares and stopped silently after 50 iterations. When the covariates move with the factors – the design of Xu (2017)’s own simulations – that leftbetaat (0.79, 2.77) against a true (1, 3) and biased the effect by +0.06.betais now updated with the loadings concentrated out (Bai 2009), which reaches the same fixed point in a few dozen iterations; the loop runs to a tolerance of1e-8within at most 1000 iterations and warns if it does not get there.gsc_ife_cpp()reportsn_iterandconverged.gsc_inference()andsi_inference()resample the treated units, not only the controls. Holding the treated units fixed left their own noise out of the standard error: with one treated unit, a 95% interval covered the truth 30% of the time for GSC, and 43% with five treated units for SI. The bootstrap now draws treated and control units separately, and the jackknife drops each of them in turn. A fit with a single treated unit (in any arm) is an error that points togsc_boot()orconformal_inference(), since no resampling of units can reach that unit’s noise. Coverage in simulations is now 0.90-0.96 for GSC and 0.85-0.93 for SI.On staggered fits, the bootstrap draws the controls once for all cohorts. It used to redraw them independently for each cohort, although under the default
control_group = "never_treated"every cohort shares the same donors, which dropped the correlation between the cohort effects (a 95% interval covered 42% of the time). Treated units are drawn from all cohorts together, so the cohorts and their weights are rebuilt from each draw, as in Clarke et al. (2024, Algorithm 2). Coverage is now 0.93-0.95.-
**
sdid_inference()follows Arkhangelsky et al. (2021) and Clarke et al.- for the bootstrap and the jackknife.** The bootstrap resampled the controls only, holding the treated mean and
zeta2fixed, and on staggered fits redrew the controls separately for each cohort; the jackknife dropped only controls and re-estimated the weights. Their 95% intervals covered 0.68-0.90 of the time. Nowmethod = "bootstrap"(Algorithm 2) draws units with replacement from the whole panel, treated and control alike, redraws a sample without a treated unit or with fewer than two controls, and reruns the whole estimator – the covariate partial-out,zeta2(unless fixed inscm_fit()) and both sets of weights – with the cohorts rebuilt from the treated units drawn.method = "jackknife"(Algorithm 3) drops every unit in turn with the fitted unit and time weights held fixed. Coverage is now 0.88-0.97. Both need more than one treated unit and point tomethod = "placebo"otherwise, which is unchanged. This is the same schemesdid_event_study()already used, so the two now agree.
- for the bootstrap and the jackknife.** The bootstrap resampled the controls only, holding the treated mean and
The jackknife of a staggered fit needs two treated units in every cohort for
sdid_inference(),gsc_inference()andsi_inference(): dropping a cohort’s only treated unit leaves its effect undefined (Clarke et al. 2024, Algorithm 3), and such a fit now raises an error that points to the bootstrap.The per-cohort jackknife of a staggered multi-arm SI fit no longer inflates its standard error. It took the variance of each cohort’s weighted sum over arms rather than of its weighted mean, which made the standard error about 20 times too large.
gsc_boot()follows Xu (2017, Algorithm 2). It drew independent normal errors with a single variance, which ignored serial correlation (an interval covered 77% of the time when the errors had an autocorrelation of 0.5) and gave the treated units the in-sample error of the controls. It now resamples whole residual series: the controls’ in-sample residuals, and for the treated units the prediction errors of control units left out as if they were treated. Coverage is 0.92-0.93 in both cases.TASC estimates its state-space model by the exact EM algorithm. The M-step used the smoothed state means only, leaving out their covariances. That understates the state noise, and the understatement compounded until the state noise collapsed to zero within the 20 iterations the fit ran. The M-step now follows Rho et al. (2026, Algorithm 7) – smoothed covariances included, the initial state learned, diagonal noise covariances – with the missing cells of Shumway & Stoffer (1982), and runs until the fitted values settle.
em_iteris now the maximum number of iterations (default 2000),em_tol(default1e-6) sets the tolerance, and a fit that does not converge warns.conformal_inference()keeps the covariates of a GSC fit. It refitted the factor model without them, so an interval for a fit with covariates was computed on a different model.scm_design()uses the order-statistic quantile of Abadie & Zhao (2026, Equation 18) for its split-conformal intervals, instead of interpolating below it, which narrowed the intervals when there were few blank periods. When it samples permutations it no longer resets the random number generator of the calling session.
New features
method = "gsc"takes additive fixed effects throughforce:"unit","time"or"two-way"(default"none"), the extended model of Xu (2017, Remark 3). With additive effectsr = 0is allowed, and the cross-validation ofrstarts from it. Estimates match gsynth to machine precision for each of these choices.TASC gives each unit its own intercept in the observation equation (
intercept = TRUE, the default). Without one, the states have to carry each unit’s level, and on panels far from zero the EM drifted for tens of thousands of iterations, moving the effect by up to 0.4 depending on when it stopped.intercept = FALSEfits the paper’s model as written.
Internal
- Staggered GSC fits the never-treated donors once rather than once per cohort, and
sdid_inference(),gsc_inference()andsi_inference()share one resampling routine. GSC and SI staggered fits recordcontrol_group; SDID staggered fits recordzeta2_userandzeta_t, and the raw outcomes and covariates when they have covariates.
coresynth 1.1.0
New features
-
sdid_event_study()computes period-by-period SDID treatment effects with cluster or unit bootstrap inference. For a sharpmethod = "sdid"fit, it returns the event-study series aligned on thelambda-weighted pre-treatment gap – so the post-treatment mean of the period effects equalsfit$estimate(the ATT) – alongside bootstrap standard errors and both normal-approximation and percentile confidence intervals for every period and for the overall ATT from the same bootstrap draws:-
clusteraccepts a column name indata(or one-sided formula~ col) or a named vector keyed by unit ID (positional unnamed vectors are rejected so unit-to-cluster alignment never depends on row order). Whencluster = NULL(default), individual units are resampled. - Each bootstrap draw resamples both treated and control clusters/units with replacement, treats duplicated units as distinct columns, and re-estimates
sigma_hat,zeta2, unit weightsomega, and time weightslambda. Draws in which either the treated or control group is empty (or has fewer than two controls) are discarded, andn_boot_validandn_boot_droppedreport the valid and discarded counts. -
boot_gaps(n_boot_valid x Tmatrix) andboot_att(lengthn_boot_validvector) store the replication draws for full reproducibility withseed. -
plot(),plot_data(),tidy(), andglance()methods format and plot the event-study series with point estimates, confidence intervals, zero and treatment reference lines, and observed-period axis breaks that handle non-consecutive time indices cleanly.
-
sdid_inference()documents and enforces its control-only bootstrap scope. Itsmethod = "bootstrap"resamples control units only with the treated mean series andzeta2held fixed; passingclustertosdid_inference()now raises an informative error pointing tosdid_event_study()(and notes when a non-bootstrapmethodwas selected).
coresynth 1.0.1
CRAN release: 2026-10-11
Bug fixes
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_upperwere the raw quantiles instead – an acceptance region around zero that never contained a non-zero estimate.ci_lower/ci_upperchange;p_value,seandboot_distdo not.scm_fit()rejects a transformed outcome or treatment in the formula. The columns are read fromdataby name, solog(y) ~ d | id + timewas fitted asyandy ~ I(t > 1990) | id + timeusedtas the treatment, without a word. Both are now errors that ask for the variable to be created indatafirst.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, andR CMD check --as-cranruns on at most two threads (see?coresynth-package). Results do not depend on the thread count.R CMD checkruns the test suite.tests/testthat.Rwas missing, so the checks (locally and on CI) never ran the tests.
Performance
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.
coresynth 1.0.0
Breaking changes
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
covariatesargument of"sdid"(sharp and staggered) and of staggered"scm"withcov_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 subtractX * beta. The fixed effects were missing, sobetaalso 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_hatwas 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 withcovariateschange. 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 withnuheld 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 injack_ests.-
**
method = "wild_bootstrap"now perturbs every unit, as Otsu & Rai- 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.
- 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
Staggered SCM fits store every unit’s adoption date (
T_adopt), andscm_balance_frontier()and the new jackknife rebuild the panel from it. Rebuilding it fromcohort_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 = 2factors for GSC, a penalty of 1% of the spectral norm for MC, andk = 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 = NULLruns the leave-one-period-out cross-validation of Xu (2017, Algorithm 1) — for each candidater, 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 recoveredrexactly for 1 through 4, where the old fixedr = 2was right only by coincidence. The scoring table is kept infit$r_cv. Candidates start at 1:r = 0is not representable by a rank-rtruncated SVD. -
"mc":lambda = NULLruns 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_nlambdaandcv_seedcontrol it, andfit$lambda_cvreports whether the search ran. -
"si":k = NULLapplies the optimal hard threshold of Gavish & Donoho- 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
kwould 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 suppliedkto that cohort’s dimensions, sofit$kis one value per cohort, named by adoption date.
- 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
-
-
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 theN_treated,g * T_post,gweights 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, andcohort_fits, and inherit from"coresynth_staggered". Consequentlyglance()reportsstaggered = TRUErather thanFALSE,tidy()returns the per-cohort table instead of an empty data frame,augment()returns one series per cohort with.periodkeyed to that cohort’s own adoption date, andplot()errors and points ataugment(), 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_treatand keeps the raw per-unit series inY_treat_mat, alongsiden_treated, whichprint(),summary(),glance()andexport_json()all report. Donor weights stay a single simplex vector, sopredictors,v_window,lambda_pen,v_selection = "oos",plot(),placebo_in_time(),loo_donors()andaugment_scm()all work as they do for one treated unit. Eachpred()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 atscm_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.nudefaulted to separate per-cohort SCM andfixedefftoFALSE. Ben-Michael, Feller & Rothstein argue against exactly that combination: separate SCM is “unlikely to yield good results” (abstract), S.4.2 advises against values ofnunear 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"andfixedeff = FALSErestore the old behaviour, andnugained"separate"as a name for the older per-cohort path so that0can 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 numericnuthere is still an error. The fallback reports itself in a message naming the argument that caused it, rather than changing the estimator in silence, andfit$nurecords which of the two ran: the number the pooled solver settled on, or"separate". The heuristic itself was checked againstaugsynthand agrees to four or five decimal places, across cohorts of equal and unequal size alike. -
qp_solvernow 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 mostk + 1donors 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, andfit$qp_solverrecords the value actually used. The default
vline_offsetinplot.coresynth()andplot.scm_placebo()is now-1instead of0, 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-1everything left of the line is the period the fit was asked to match. Passvline_offset = 0for the previous position.scm_fit()now takes...ahead of its estimator-specific arguments, which must therefore be named in full; partial matches such asv_opt =and any use of the fourth or later argument by position no longer work. This is what makeslambdareachmethod = "mc": whilelambda_pen(an SCM-only argument) sat before..., R matchedlambdato it partially, so MC’s nuclear-norm penalty could not be set throughscm_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 —predictorsornuwithmethod = "mc",covariatesorfixedeffwith"tasc",control_groupwith 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"rejectscovariatesinstead 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, becausepredictorsis unavailable there. pool_cohortsnow takes effect on the per-cohort staggered path. It was read only by the partially pooled solver, sopool_cohorts = FALSEwas silently discarded whenever the fit went down thenu = "separate"path – including the automatic fallback thatdonor_mspe_threshold,lambda_penandv_selection = "oos"trigger. The fit then carried cohort-level blocks the caller had asked not to have, andscm_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, andfit$pool_cohortsrecords it. Sharp fits reject the argument, as they already didnuandfixedeff.-
Partially pooled staggered SCM weights each cohort by its size. The two imbalance measures
nutrades 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 whatpool_cohorts = TRUEimplements – writes the cohort problem on the sum over the cohort’s units: its target issum_i 1{T_i = T(g)} Y_iand its weights satisfysum_i gamma_ig = n_g. A cohort’s residual is thereforen_gtimes its mean gap, so a cohort ofn_gunits countsn_gtimes towards the pooled imbalance andn_g^2towards 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 theN_treated,g * T_post,gweights 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. weightsnow 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 itsT_post-multiplied aggregation weight alike, andscm_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 ignoredweightsentirely. Staggered"scm"estimates change whenweightsis 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 = FALSErecovers the S.4 baseline. The fit gainsfe_unit,fe_timeandL_lowrank;L_hatremains 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.
New features
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 wheremspe_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), wheremspe_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 inplot.scm_placebo(), where it changed the picture but not the p-value.mspe_prunenow applies it to the test, andn_placebo_prunedreports how many runs it dropped. This is a different control frommspe_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 atmspe_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()andplot_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 fortype = "ratios"as well astype = "gaps": the subtitle reports the number of runs the p-value rests on, so a figure drawing the discarded ones contradicts its own caption. Passmspe_prune = Infto draw them all regardless.plot(type = "ratios")also draws whichever statistic the test ranked, so astatistic = "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, inratioandpost_mspe. -
pool_cohorts = FALSEfits 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 whatpool_cohorts = FALSEfits;cohort_fitsthen names the unit behind each block, which is whatscm_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. (augsynthdefaults the other way.)Those blocks are labelled by unit rather than by adoption date, because several of them share one:
cohort_estimatescarries aunitcolumn,tidy()returnsunit_<id>terms, andaugment()keys.uniton 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 theunitcolumn names the unit of a one-unit cohort and isNAfor a pooled one. Estimators without the notion carry no such column. weightssets 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’sT_post; a sharp fit makes the pooled target a weighted average of the treated series. LeftNULLthe units count equally, as before.-
scm_balance_frontier()andplot()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 ofnuand 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, movingnufrom 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
nuheuristic 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 about1/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 inaugsynth, so the default is unchanged and the frontier is offered instead. print()andsummary()describe staggered fits as staggered. Both previously used the sharp output, which reportsT_preandT_postfrom 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, andsummary()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 whatcovariateshas 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 becausepredictorsis 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
xiweighting the two sides.xidefaults 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 thenu = 0solution, as their footnote 7 defines.fit$poolinggainsq_cov_sep,q_cov_poolandn_covariates, andscm_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 atnu = 0, and the two outcome imbalances can both fall asnurises 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_penandv_selection = "oos"rather than falling back silently.xiis refused on the same grounds wherever the balance term is not in the objective – a sharp fit, orcov_method = "partial_out"– instead of being accepted and ignored."partial_out"remains the default, so no existing fit changes.As
xifalls to zero the fit collapses onto the outcomes-only one at a rate linear inxi, 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.
Bug fixes
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
covariatesreports 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 ownY_treat– is on that residualised scale, but the staggeredY_treatwas the raw outcome.treated_outcomes()therefore returned one series whileaugment()returned another. Affects"scm"and"sdid"; withoutcovariatesthe two scales coincide and nothing changes.mspe_ratio_pval()namesmspe_post_placeboby donor. Its companionmspe_pre_placebowas 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, butr,kandlambdaeach 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-matchrtor_cv, orlambdatolambda_cv, if the exact name were ever absent.
coresynth 0.5.0
CRAN release: 2026-08-25
New features
-
linetypesinplot.coresynth()andplot.scm_placebo()makes the line type of each series settable, the third member of thecolors/labelsfamily 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 fromplot_data().For
type = "trend"it takes a named vector over"treated","synthetic", and (withshow_donors > 0)"donors", e.g.linetypes = c(synthetic = "solid"); series not mentioned keep their default. Fortype = "gap"it takes a single line type for the gap line, mirroring howcolorstakes a single color there. Forplot.scm_placebo(type = "gaps")the keys are"treated"and"placebo". Values areggplot2line type names ("solid","dashed","dotted", …) or the equivalent integer codes0:6; an unknown key errors with the list of valid keys, as forcolorsandlabels. Bar charts ("weights","pred_weights") and the point chart ("ratios") ignore the argument.All defaults reproduce the previous appearance exactly. The argument sits after
labelsin 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.
Changed
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
datacarries, not on the span of thepred()list, so the samepredictorsargument switches paths whendatareaches 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 atv_windowand at subsettingdataas 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 guardscm_fit()already had – such a window previously surfaced as “no feasible treatment set found”, which named neither the predictor nor the window.
coresynth 0.4.1
CRAN release: 2026-07-28
Changed
Sharp SCM fits with
predictors = NULLnow 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) andv_selection = "oos"(its own split is described under that argument). Wrap the call insuppressMessages()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 (Synthrequires 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, andSynth’s per-periodspecial.predictorsrun now agree (weights within 3e-3 ofSynth, identical between the two coresynth forms). Passv_optim = "multistart"explicitly to force the predictor-path optimiser for such a spec.
New features
-
scm_fit(qp_solver = "wolfe")selects a Caratheodory-sparse inner solver.X0 wtraces the convex hull of the donor columns in the k-dimensional predictor space aswranges over the simplex, so the inner QP is a projection onto a polytope inR^k, and by Caratheodory’s theorem an optimum supported on at mostk + 1donors always exists. The Wolfe- 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.
Bug fixes
-
The inner SCM QP now returns a KKT-exact optimum.
scm_inner_weights_cpp()– which supplies the reported weights forv_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.
Performance
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 factorB = diag(sqrt(V)) X0instead.Qhas 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(), andconformal_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 manypred()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 frombuild_covariate_array()(the covariate paths of GSC, SDID, and staggered SCM) and fromscm_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
predictorsare 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 againstSynthon Proposition 99 (ATT, synthetic series, pre-RMSPE, donor weights all agree) and across a degeneracy/noise sweep.
coresynth 0.4.0
CRAN release: 2026-07-20
Breaking changes
-
Predictor-based SCM fits now default to a multi-start outer V optimisation (
v_optim = "auto", the new default, resolves to"multistart"whenpredictorsare 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 referenceSynth/tidysynthsolution’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); passv_optim = "coord_descent"to reproduce the previous behaviour. Outcomes-only fits are unchanged. Note that the old"auto"(choose"bfgs"whenk <= 15) is gone;"bfgs"itself remains available.
New features
-
plot(fit, type = "pred_weights")draws the predictor/variable weight matrixVof a sharp SCM fit as a horizontal bar chart, the companion to the existing donortype = "weights"chart. Bars are labelled with the predictor names (orV1..V_Tprefor outcomes-only fits) and honour thefillandtop_narguments. Staggered SCM and the other estimators do not estimate aVmatrix and raise an informative error. -
plot_data()returns the tidydata.framebehind anyplot()view, taking the sametypeargument ("trend","gap","weights","pred_weights"for a fit;"gaps","ratios"for anscm_placeboobject). It is the data-side companion toplot(): 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 thattype = "weights"applies to the chart is not used here, so every donor is returned (usetop_nto subset). -
v_windowargument inscm_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-windowlossare untouched – andmspe_ratio_pval()mirrors it in every placebo refit. Cannot be combined withv_selection = "oos", which manages its own train/validation split. -
colors/labelsinplot.coresynth()andplot.scm_placebo()are now keyed by one-word series identifiers:treated,synthetic, and (withshow_donors > 0)donorsfor trend plots;treatedandplacebofor 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 thatlabelsitself relabels. Writecolors = c(treated = "black")instead ofc(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_covariatesdefaults toNULL(auto) instead ofFALSE. When the fit was estimated with apredictorsspecification, each placebo unit is now refit with that same specification – the Abadie et al. (2010) /Synthconvention, 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 aT_pre-dimensional V optimisation. Outcomes-only fits are unaffected (auto resolves to the outcomes-only placebo path, as before). Passuse_covariates = FALSEexplicitly to reproduce the old behaviour on covariate fits. -
Treatment-line placement in plots:
plot.coresynth()(type = "trend"and"gap") andplot.scm_placebo()(type = "gaps") gain avline_offsetargument controlling where the vertical treatment line is drawn, in periods relative to the first post-treatment period (the previous fixed position).vline_offset = -1moves it to the last pre-treatment period, and fractional values such as-0.5interpolate between adjacent observed times, on numeric,Date, andPOSIXctaxes alike. Thevlinestyle list now also accepts anxinterceptelement for one or more absolute positions (previously an error), e.g.vline = list(xintercept = "1989-01-01")on aDateaxis. Hiding the line still works viavline = FALSE. -
Level-aligned trend and gap plots:
plot.coresynth()gains analignargument fortype = "trend"and"gap".align = TRUEshifts 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 = TRUEuses the time-weight (lambda) weighted pre-period gap, which makes the average post-period gap in the plot equal the SDID estimate exactly. -
Donor paths in trend plots:
plot.coresynth(type = "trend")gains ashow_donorsargument that draws the outcome paths of theshow_donorsdonor units with the largest weights as thin background lines (Infshows all). The new"Donors"series participates incolors/labelsoverrides. -
SDID time weights in the weights plot: for SDID fits,
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. -
Placebo SE and CI for SDID:
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. -
Partially pooled staggered SCM (Ben-Michael, Feller & Rothstein 2022, JRSS-B):
scm_fit(method = "scm")on a staggered panel gains anuargument.nu = NULL(default) keeps the previous behaviour (per-cohort V-optimised SCM). A numericnuin[0, 1]minimises the convex combinationnu * (pooled pre-treatment imbalance)^2 + (1 - nu) * (per-cohort imbalance)^2over all cohort weight vectors jointly, so that the aggregate ATT is anchored by the pooled fit (nu = 0reproduces separate per-cohort SCM with uniform lag weights,nu = 1is fully pooled).nu = "auto"selects the paper’s heuristic value. The fit stores balance diagnostics infit$pooling(q_sep,q_pool, and their separate-SCM baselines). -
Intercept-shifted staggered SCM: new
fixedeffargument for staggered SCM fits.fixedeff = TRUEdemeans 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. -
Wild bootstrap inference for staggered SCM: new
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 standardcoresynth_inferenceobject (works withtidy()/glance()). This is the first inference method available for staggered SCM fits. -
solve_simplex_qp()gains an optionalx0warm-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 implementationaugsynth(weights correlate at 1.0, identical heuristicnu, equal pooled imbalance atnu = 1).
Deprecated
-
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: usev_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.
Performance
-
The multi-start outer search runs at interactive speed. Outer-loss evaluations are KKT-gated warm-started active-set solves; the active set gained Bland’s-rule anti-cycling (engaged only beyond 30 pivots, so previously terminating pivot paths are untouched) and now solves each face subproblem in the null space of the sum constraint via a Cholesky/eigendecomposition hybrid that is robust to the rank-deficient metrics (k < |active set|) the multi-start screen hits constantly – and whose branch decision cannot be flipped by last-bit input perturbations, preserving the scale-invariance of
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-donormspe_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. -
The nested V/W coordinate descent is orders of magnitude faster. The solver behind
scm_weights_cpp()and the outcomes-only placebo loop inscm_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 rebuildingX0' V X0per grid point, and computes the Lipschitz constant once per coordinate-descent sweep instead of once per QP. On a monthly panel withT_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-onlyscm_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. -
Covariate-spec placebo refits now run in parallel. When the fit was estimated with a
predictorsspecification,mspe_ratio_pval()previously refit each placebo unit in a sequential R loop; the loop now runs in C++ under OpenMP (new low-level routinescm_placebo_x_cpp(), the covariate counterpart ofscm_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.
Bug fixes
-
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 crashedR CMD check --run-dontteston macOS.
coresynth 0.3.0
CRAN release: 2026-07-12
New features
-
Outcome-series accessors: new exported generics
treated_outcomes(),synthetic_outcomes(), anddonor_outcomes()return the treated series, the estimated counterfactual, and the donor outcome matrix from anycoresynthfit under a uniform interface, regardless of the estimation method. They replace the per-method field sniffing previously duplicated acrossaugment(),conformal_inference(), andplot(); each returnsNULLwhen the series is not stored in the fit (e.g. staggered fits, which keep their data per cohort infit$cohort_fits). -
Structural subclasses: fits with staggered adoption now additionally inherit from
"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. Thestaggered/multi_armlist fields are retained, so existing code that reads them keeps working, and all class checks remaininherits()-compatible. -
In-space placebo visualization (Abadie, Diamond & Hainmueller 2010, Section 3.4):
mspe_ratio_pval()now returns anscm_placeboobject (still fully backward compatible with the previous plain-list fields) that additionally carries the placebo gap path for every donor unit. A newplot.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, withmspe_pruneimplementing the paper’s relative pre-treatment MSPE pruning at 20x/5x/2x), andtype = "ratios"plots the post/pre-treatment MSPE ratio for every unit (Figure 8). -
Plot style customization:
plot.coresynth()andplot.scm_placebo()gaincolors,labels,vline,hline, and (fortype = "weights")fillandtop_narguments.top_nrestricts the weights bar chart to thetop_nlargest-weight donors (defaultInfkeeps every donor with a non-negligible weight), useful for large donor pools.vline/hlineaccept a list ofgeom_vline()/geom_hline()aesthetic overrides merged onto the built-in defaults, orNULL/FALSEto suppress the reference line entirely — since a line already added to a returnedggplotobject cannot be removed, only restyled or overplotted, suppression has to happen inside the plot method.colorsaccepts a named vector overriding individual series colors (unmentioned series keep their default).labelsaccepts a named vector overriding the legend text of individual series (e.g.labels = c(Treated = "California")); keys are always the original series names, socolorsandlabelscompose independently, and intype = "ratios"the treated unit’s axis tick follows the relabeled legend entry. All defaults reproduce the previous appearance exactly, so existing calls are unaffected.
Improvements
-
augment(fit, include_donors = TRUE)now returns donor rows formethod = "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 internaldata.framelength error. - The gap plot (
plot(fit, type = "gap")) now labels its y-axis simply “Gap” and states the definition (“Treated − synthetic control”) in a new subtitle, replacing the previousY_treated - Y_syntheticaxis 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.
Bug fixes
- For
method = "tasc",plot(),augment(), and theY_synthseries ofexport_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 fortascfits change; the ATT estimate itself was always computed from the correct per-unit gaps and is unaffected. -
scm_design()solved theweakly_targeted(eq. 9) andunit_level(eq. 10) designs by a sequential approximation: the treated weightswwere always chosen to match the population average predictor vector alone, which made thexipenalty 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 forscm_design(design = "weakly_targeted")anddesign = "unit_level"whenm >= 2(the defaultm = 1selects a single treated unit, sowis degenerate and both old and new solvers agree).
Internal
-
conformal_inference()’s counterfactual refit now dispatches on the fit’s class (one S3 method per estimator) rather than anif-chain on the method string. Results are unchanged.
coresynth 0.2.4
CRAN release: 2026-07-04
Bug fixes
-
method = "mc"treated missing(id, time)panel cells andNAoutcomes 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 formcfits on unbalanced panels or panels withNAoutcomes. -
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, andvar()) 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.
Input validation
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:
- 0-row input data
-
NAunit or time identifiers -
NAor negative treatment indicator values - no treated units, or (for sharp fits only) no control units — staggered fits may legitimately have no never-treated units when future adopters serve as clean controls
- missing
(id, time)cells or non-finite outcomes, for the estimators that require a fully observed panel (scm,sdid,gsc,si—mcandtaschandle 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.
coresynth 0.2.3
Performance
- The inner simplex QP solvers (
solve_simplex_qp()/solve_simplex_qp_lr()), used by everyscm_fit(method = "scm")fit and by themethod = "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 ofQ = 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 within1e-10), so the returned weights are unchanged. - For the common outcomes-only case the fit is effectively identical (differences
~1e-5). Forv_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 forv_selection = "oos"and penalised fits on poorly conditioned data.
coresynth 0.2.2
CRAN release: 2026-06-26
Bug fixes
-
panel_to_matrices()(and therefore everyscm_fit()method) andscm_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 rendersDate/POSIXcttime axes correctly. The time vector was coerced withas.numeric(), so dates appeared as days-since-epoch (e.g. 16000,- on the x-axis.
Date/POSIXctvalues are now passed through unchanged so ggplot2 selects the appropriate date scale; onlycharacter/factortime values are coerced to numeric.
- on the x-axis.
coresynth 0.2.1
Bug fixes
-
v_selection = "oos"(outcomes-only case) previously fit candidateW(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: candidateW(V)are fitted on training-half outcomes only,V*minimises validation-half MSPE, andW*is refit withV*on the outcomes of the lastfloor(T_pre/2)pre-treatment periods. For OOS fits,v_weightsnow hasfloor(T_pre/2)entries and a newv_rowsfield records which periods they refer to. This changes numerical results forv_selection = "oos".
New features
-
scale_predictors(defaultTRUE): predictor rows supplied viapredictors =are now divided by their standard deviation across all units before optimisation, matching the Synth reference implementation (Abadie, Diamond & Hainmueller 2011, JSS).predictor_tablecontinues to report values on the original scale. This changes numerical results for SCM fits with user-suppliedpredictorsunlessscale_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 apred()time window produces missing or non-finite predictor values.
coresynth 0.2.0
CRAN release: 2026-06-12
New features
-
Conformal inference (
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 acoresynth_inferencesubclass compatible withtidy()andglance().
Minor improvements
-
panel_to_matrices(): fill loop replaced by vectorisedmatch()+ 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 topinvinstead of aborting when the innovation covariance is not numerically PD. -
%||%null-coalescing helper centralised inutils.R; duplicate definitions inbroom.Randplot.Rremoved. -
check_sharp_adoption()(unused internal function) removed.
coresynth 0.1.0
First public release.
Methods
-
SCM (Abadie, Diamond & Hainmueller 2010): Synthetic Control Method with unified formula interface. Supports predictor variables via
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 viamspe_ratio_pval(). -
SDID (Arkhangelsky et al. 2021): Synthetic Difference-in-Differences. Supports time-varying covariates (
covariates =), sharp and staggered adoption. Inference:sdid_inference()with placebo / bootstrap / jackknife / jackknife_global. -
GSC (Xu 2017): Generalised Synthetic Control with interactive fixed effects. Supports time-varying covariates via the full EM algorithm, sharp and staggered adoption. Inference: parametric bootstrap (
gsc_boot()) and non-parametric (gsc_inference()). - MC (Athey et al. 2021): Matrix Completion via nuclear-norm regularisation (Soft-Impute). Supports sharp and staggered adoption.
- TASC (Rho et al. 2026): Time-Aware Synthetic Control via Kalman EM. Supports sharp and staggered adoption.
-
SI (Agarwal et al. 2025): Synthetic Interventions via SI-PCR. Supports sharp, staggered, multi-arm (K > 1), and staggered × multi-arm. Inference:
si_inference()with bootstrap / jackknife / jackknife_global. -
SCM-Design (Abadie & Zhao 2026):
scm_design()with base / weakly_targeted / unit_level variants, blank-period permutation test, and split-conformal confidence intervals.
Unified API
- Single
scm_fit(outcome ~ treatment | unit + time, data, method = ...)entry point for all methods. -
panel_to_tensor()for multi-arm SI data preparation. -
broomintegration:tidy(),glance(),augment()for all methods and inference objects. -
plot.coresynth(): trend, gap, and weights plots via ggplot2. -
export_json(): JSON export for reproducibility.
