| name | synthetic-control |
| description | Econometrics skill for Synthetic Control Method (SCM). Activates when the user asks about "synthetic control", "SCM", "placebo test", "synthetic DID", "合成控制", "安慰剂检验", "合成反事实", "合成DID". |
Synthetic Control Method (SCM) Skill
This skill guides complete synthetic control analyses: donor pool construction, weight optimization, gap estimation, placebo-based inference, and extensions including augmented SCM and synthetic DID. Designed for policy evaluation with few treated units.
When to Use Synthetic Control
| Situation | Method |
|---|
| Single treated unit, many potential controls | Classic SCM |
| Few treated units | Multi-unit SCM or Synthetic DID |
| Treatment at aggregate level (state, country) | Classic SCM |
| Want DID-like inference with SCM weighting | Synthetic DID (Arkhangelsky et al. 2021) |
Key advantage over DID: SCM constructs a data-driven counterfactual rather than assuming parallel trends for all control units equally.
Core Logic
SCM constructs a weighted combination of untreated ("donor") units that best approximates the treated unit's pre-treatment characteristics and outcome trajectory.
Estimand: τ_t = Y₁ₜ − Ŷ₁ₜ^(SC) for post-treatment periods t > T₀
Key Assumptions:
- No interference between units (SUTVA)
- No anticipation of treatment
- Treated unit can be well-approximated by donor pool in pre-treatment period
- The data-generating process is stable across pre/post periods
SCM Workflow
- Define units and treatment: Identify treated unit, donor pool, treatment date
- Select predictors: Choose pre-treatment covariates and outcome lags
- Optimize weights: Minimize pre-treatment MSPE between treated and synthetic control
- Estimate effects: Gap = treated outcome − synthetic control outcome
- Inference: Run placebo tests (in-space and in-time)
- Report: Gap plot, placebo plot, MSPE ratios
Code Templates
R — Classic SCM (Synth package)
library(Synth)
dataprep_out <- dataprep(
foo = df,
predictors = c("gdp_pc", "trade_share", "inflation"),
predictors.op = "mean",
time.predictors.prior = 1980:1999,
special.predictors = list(
list("outcome", 1995:1999, "mean"),
list("outcome", 1990, "mean"),
list("outcome", 1985, "mean")
),
dependent = "outcome",
unit.variable = "unit_id",
unit.names.variable = "unit_name",
time.variable = "year",
treatment.identifier = 1,
controls.identifier = 2:20,
time.optimize.ssr = 1980:1999,
time.plot = 1980:2010
)
synth_out <- synth(dataprep_out)
synth.tab <- synth.tab(synth_out, dataprep_out)
print(synth.tab$tab.w)
print(synth.tab$tab.v)
path.plot(synth_out, dataprep_out,
Ylab = "Outcome", Xlab = "Year",
Main = "Treated vs Synthetic Control")
abline(v = 2000, lty = 2, col = "red")
gaps.plot(synth_out, dataprep_out,
Ylab = "Gap (Treated − Synthetic)", Xlab = "Year",
Main = "Treatment Effect Over Time")
abline(v = 2000, lty = 2, col = "red")
abline(h = 0, lty = 3)
R — Augmented SCM (augsynth)
library(augsynth)
asyn <- augsynth(outcome ~ treatment,
unit = unit_id, time = year,
data = df,
progfunc = "Ridge",
scm = TRUE)
summary(asyn)
plot(asyn)
Ridge Augmentation (progfunc = "Ridge"): When the pre-treatment fit of standard SCM is imperfect, Ridge augmentation adds a bias-correction term estimated by a penalized (ridge) regression of the residuals on donor outcomes. This reduces sensitivity to poor pre-treatment balance by shrinking the bias correction toward zero when donor pool fit is already good. The result is an estimator that converges to the standard SCM when pre-treatment fit is excellent, but provides robustness when it is not. Other options include "gsyn" (matrix completion) and "None" (standard SCM only).
Python — SparseSC
import SparseSC
import numpy as np
Y = df.pivot(index='unit_id', columns='year', values='outcome').values
T0 = 20
sc = SparseSC.fit(
features=Y[:, :T0],
targets=Y[:, T0:],
treated_units=[0]
)
treated_actual = Y[0, T0:]
synthetic_control = sc.predict(Y[0:1, :T0])[0]
effect = treated_actual - synthetic_control
print(f"Average post-treatment effect: {np.mean(effect):.4f}")
Stata — synth and synth_runner
* Stata — Classic SCM
ssc install synth
ssc install synth_runner
tsset unit_id year
synth outcome gdp_pc trade_share inflation ///
outcome(1995) outcome(1990) outcome(1985), ///
trunit(1) trperiod(2000) ///
fig keep(synth_results) replace
* Plot results
twoway (line outcome year if unit_id == 1, lcolor(black) lwidth(medium)) ///
(line _Y_synthetic year if unit_id == 1, lcolor(red) lpattern(dash)), ///
xline(2000, lpattern(dash)) ///
legend(label(1 "Treated") label(2 "Synthetic Control")) ///
title("Synthetic Control Estimate")
Inference: Placebo Tests
SCM does not have standard errors in the traditional sense. Inference is based on placebo (permutation) tests.
In-Space Placebo Test
Iteratively apply SCM to every control unit as if it were treated. If the treated unit's effect is unusually large relative to placebos, the effect is credible.
library(Synth)
placebo_gaps <- list()
all_units <- unique(df$unit_id)
for (u in all_units) {
controls <- setdiff(all_units, u)
dp <- dataprep(foo = df, predictors = c("gdp_pc", "trade_share"),
predictors.op = "mean", time.predictors.prior = 1980:1999,
special.predictors = list(list("outcome", 1995:1999, "mean")),
dependent = "outcome", unit.variable = "unit_id",
time.variable = "year", treatment.identifier = u,
controls.identifier = controls,
time.optimize.ssr = 1980:1999, time.plot = 1980:2010)
so <- synth(dp, Sigf.ipop = 3)
placebo_gaps[[as.character(u)]] <- dp$Y1plot - (dp$Y0plot %*% so$solution.w)
}
plot(1980:2010, placebo_gaps[["1"]], type = "l", lwd = 2, col = "black",
ylim = range(unlist(placebo_gaps)), ylab = "Gap", xlab = "Year")
for (u in names(placebo_gaps)[-1]) {
lines(1980:2010, placebo_gaps[[u]], col = "grey70")
}
abline(v = 2000, lty = 2); abline(h = 0, lty = 3)
legend("topleft", c("Treated", "Placebos"), col = c("black","grey70"), lwd = c(2,1))
* Stata — in-space placebo with synth_runner
synth_runner outcome gdp_pc trade_share inflation ///
outcome(1995) outcome(1990) outcome(1985), ///
trunit(1) trperiod(2000) gen_vars
effect_graphs
single_treatment_graphs
MSPE Ratios
Calculate post/pre MSPE ratio for each unit. The treated unit's ratio should rank highest.
mspe_ratios <- sapply(names(placebo_gaps), function(u) {
gap <- placebo_gaps[[u]]
pre <- gap[1:20]
post <- gap[21:31]
sum(post^2) / sum(pre^2)
})
p_value <- mean(mspe_ratios >= mspe_ratios["1"])
cat("MSPE ratio rank p-value:", p_value, "\n")
In-Time Placebo
Apply SCM with a fake treatment date in the pre-treatment period. Effect should be near zero.
dp_placebo <- dataprep(foo = df, ...,
time.optimize.ssr = 1980:1989,
time.plot = 1980:1999)
so_placebo <- synth(dp_placebo)
Donor Pool Construction
| Guideline | Rationale |
|---|
| Exclude units affected by similar treatment | Avoids contamination |
| Include only structurally similar units | Improves fit quality |
| Use pre-treatment outcome lags as predictors | Most powerful predictors |
| Drop donors with zero weight and large pre-MSPE | Focus on contributing donors |
| Leave-one-out: iteratively drop each donor | Check weight sensitivity |
Covariate Balance Check
After constructing the synthetic control, verify that predictors are balanced between the treated unit and its synthetic counterpart:
synth_tab <- synth.tab(synth_out, dataprep_out)
print(synth_tab$tab.pred)
pred_balance <- synth_tab$tab.pred[, 1:2]
pred_gaps <- pred_balance[, 1] - pred_balance[, 2]
cat("Predictor balance (treated - synthetic):\n")
print(round(pred_gaps, 4))
import numpy as np
import pandas as pd
treated_pred = X_treated.mean(axis=0)
synthetic_pred = (weights @ X_donors).flatten()
balance_df = pd.DataFrame({
'Predictor': predictor_names,
'Treated': treated_pred,
'Synthetic': synthetic_pred,
'Gap': treated_pred - synthetic_pred,
'Pct_Gap': 100 * (treated_pred - synthetic_pred) / np.abs(treated_pred)
})
print(balance_df.to_string(index=False))
* Stata — predictor balance displayed automatically after synth
synth outcome gdp_pc trade_share inflation ///
outcome(1995) outcome(1990) outcome(1985), ///
trunit(1) trperiod(2000)
* The output table "Predictor Balance" compares treated vs synthetic vs sample avg
* Verify that treated and synthetic columns are close for key predictors
Synthetic DID (Arkhangelsky et al. 2021)
Combines SCM weighting with DID estimation; works with multiple treated units.
library(synthdid)
setup <- panel.matrices(df, unit = "unit_id", time = "year",
outcome = "outcome", treatment = "treated")
sdid <- synthdid_estimate(setup$Y, setup$N0, setup$T0)
se <- sqrt(vcov(sdid, method = "placebo"))
cat("SDID estimate:", sdid, "SE:", se, "\n")
plot(sdid)
from synthdid.model import SynthDID
model = SynthDID(df, unit='unit_id', time='year',
outcome='outcome', treatment='treated')
model.fit()
print(f"SDID ATT: {model.att():.4f}")
model.plot()
Pre-Treatment Fit Diagnostics
Good pre-treatment fit is the foundation of SCM credibility. A poorly fitting synthetic control cannot be trusted as a counterfactual.
RMSPE benchmark: Pre-treatment RMSPE should generally be < 5% of the treated unit's pre-treatment outcome mean. Higher values suggest the synthetic control is unreliable; reconsider predictor selection or donor pool composition.
gaps <- dataprep_out$Y1plot - (dataprep_out$Y0plot %*% synth_out$solution.w)
pre_periods <- which(as.numeric(rownames(gaps)) < treatment_year)
post_periods <- which(as.numeric(rownames(gaps)) >= treatment_year)
pre_rmspe <- sqrt(mean(gaps[pre_periods]^2))
post_rmspe <- sqrt(mean(gaps[post_periods]^2))
outcome_mean <- mean(dataprep_out$Y1plot[pre_periods])
cat(sprintf("Pre-treatment RMSPE: %.4f (%.1f%% of outcome mean)\n",
pre_rmspe, 100 * pre_rmspe / outcome_mean))
cat(sprintf("Post-treatment RMSPE: %.4f\n", post_rmspe))
cat(sprintf("Post/Pre MSPE ratio: %.2f\n", (post_rmspe/pre_rmspe)^2))
if (pre_rmspe / outcome_mean > 0.05) {
warning("Pre-treatment RMSPE exceeds 5% of outcome mean — fit may be poor.")
}
import matplotlib.pyplot as plt
import numpy as np
years = np.array(all_years)
treated = Y_actual
synthetic = Y_synthetic
treatment_year = 2000
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(years, treated, 'k-', linewidth=2, label='Treated')
axes[0].plot(years, synthetic, 'r--', linewidth=1.5, label='Synthetic Control')
axes[0].axvline(treatment_year, color='gray', linestyle=':', linewidth=1)
axes[0].set_title('Treated vs Synthetic Control'); axes[0].legend()
gap = treated - synthetic
axes[1].plot(years, gap, 'b-', linewidth=2, label='Gap (τ̂)')
axes[1].axvline(treatment_year, color='gray', linestyle=':', linewidth=1)
axes[1].axhline(0, color='black', linestyle='-', linewidth=0.5)
pre_mask = years < treatment_year
pre_rmspe = np.sqrt(np.mean(gap[pre_mask]**2))
axes[1].set_title(f'Treatment Effect (Pre-RMSPE = {pre_rmspe:.3f})')
axes[1].legend()
plt.tight_layout()
plt.savefig('synthetic_control_fit.pdf', bbox_inches='tight')
* Stata — pre-treatment fit assessment
* After synth estimation, compute RMSPE manually:
* (synth stores results; use stored matrices)
matrix gaps = e(Y_treated) - e(Y_synthetic)
* Pre-treatment RMSPE:
* (Stata code depends on synth version; synth_runner automates this)
synth_runner outcome gdp_pc trade_share outcome(1995) outcome(1990), ///
trunit(1) trperiod(2000) gen_vars
* Outputs pre_rmspe and post_rmspe in stored results
pval2 using synth_results // rank-based p-value from synth_runner
Reporting Standards
- Pre-treatment fit: Show treated vs synthetic control plot with clear pre-treatment match
- Gap plot: Show treatment effect over time with vertical treatment line
- Donor weights table: Report which units contribute to synthetic control and their weights
- Predictor balance table: Compare treated, synthetic, and sample average
- Placebo plot: In-space placebos with treated unit highlighted
- MSPE ratio: Report rank-based p-value (e.g., "treated unit ranks 1st of 20 units")
- Robustness: Leave-one-out donor test, in-time placebo, alternative predictor sets
Key sentence template:
"We construct a synthetic [unit] using a weighted combination of [N] donor [units] from the [donor pool description]. The synthetic [unit] closely tracks the treated [unit] in the pre-treatment period ([year range], pre-treatment MSPE = [value]). The estimated effect is [magnitude] ([% change]), with the treated unit ranking [1st/2nd] out of [N] units in post/pre-MSPE ratio (p = [value])."
Common Pitfalls
- Poor pre-treatment fit: If pre-MSPE is large, the synthetic control is unreliable — reconsider predictor set or donor pool
- Overfitting to noise: Using too many outcome lags can overfit; use 3–5 evenly spaced lags
- Interpolation bias: SCM requires the treated unit to be within the convex hull of donors
- Cherry-picking donors: Always report full donor pool; justify exclusions
See references/synthetic-control-reference.md for multi-unit SCM extensions, staggered adoption SCM, and sensitivity analysis.