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
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 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. 2023, 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,
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.