Synthetic control and synthetic difference-in-differences
Compare counterfactual paths and understand unit and time weighting.
A treated block in a balanced panel
Arrange outcomes as an N\times T matrix: controls first, treated units last; pre-treatment columns first, post-treatment columns last. N0 and T0 mark the ends of the control and pre-period blocks. These estimators assume a common treatment start and a complete outcome matrix. They are not a staggered-adoption or arbitrary missing-panel interface.
Difference-in-differences subtracts an average control trend. Synthetic control instead forms a weighted combination of donors whose pre-treatment outcomes match the treated unit. Synthetic DID combines unit weights\omega and pre-period weights\lambda:
The SDID donor fit allows an intercept: matching the evolution of outcomes does not require matching their level exactly. Time weights compare the controls’ pre-period outcomes to their average post-period outcomes. Ridge regularization and simplex constraints limit unstable extrapolation.
With an intercept, it centers the columns and target before the weight fit. The solver is a Frank–Wolfe algorithm, with an optional sparsification and refinement pass. Its stopping rule uses improvement in the objective; it is not a reported KKT/duality-gap certificate. Check stability to maxiter and min_decrease when an optimum lies near a simplex boundary. This is distinct from CrabbyMetrics’ synthetic-control implementation.
Worked example: California Proposition 99
The small cigarette-sales panel is included as a test fixture from the synthdid project; its attribution and license are in tests/data/. No network download is needed to render this example. The outcome is annual packs sold per capita; California’s treatment starts in 1989.
Show Python
from pathlib import Pathimport numpy as npimport pandas as pdimport torchimport matplotlib.pyplot as pltfrom trex import SyntheticDID, sc_estimate, did_estimatetorch.set_num_threads(1)frame = pd.read_csv(Path("../tests/data/california_prop99.csv"), sep=";")wide = frame.pivot(index="State", columns="Year", values="PacksPerCapita")states = [s for s in wide.index if s !="California"] + ["California"]wide = wide.loc[states]years = wide.columns.to_numpy()Y = torch.tensor(wide.to_numpy(), dtype=torch.float64)N0, T0 =len(states)-1, int((years <1989).sum())settings =dict(maxiter=10000, min_decrease=1e-5, device="cpu")sc = sc_estimate(Y, N0, T0, **settings)sdid = SyntheticDID(**settings).fit(Y, N0, T0).result_pd.DataFrame({"Effect (packs per capita)": [float(did_estimate(Y, N0, T0)),float(sc.estimate), float(sdid.estimate)]}, index=["DID", "SC", "SDID"]).round(3)
Effect (packs per capita)
DID
-27.349
SC
-19.620
SDID
-15.604
Inspect the path, not only the effect
The SC path is the donor-weighted outcome. For SDID, add the \lambda-weighted pre-period treated-minus-synthetic gap to that path. The mean post-treatment gap then equals the SDID estimate. This adjustment is important: plotting the unadjusted donor path can misrepresent what SDID is comparing.
Figure 2: The largest SDID donor weights and its pre-period weights. Concentration is a useful diagnostic, not evidence of identification by itself.
Assumptions, sensitivity, and inference
Good pre-treatment fit is useful evidence about prediction, but does not prove absence of treatment-specific shocks, spillovers, anticipation or donor contamination. Donor selection, intervention timing and outcome definitions remain research decisions. The historical estimate above should not be read as a new causal reanalysis simply because it can be reproduced in Torch.
Examine donor concentration, pre-period residuals, placebo interventions and changes to the donor pool. Select tuning parameters without using treated post-treatment outcomes. Trex exposes point estimates and weights; it does not automatically provide the bootstrap, jackknife or placebo uncertainty procedures used in the SDID literature. Repeated-cell regression standard errors are not a substitute for a justified panel inference procedure.