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.