Synthetic Control Method
The engine for Comparative Discovery in Single-Unit Trials. This model audits a single treated unit (e.g., a city or clinic) by constructing a 'Synthetic Shadow' from a donor pool of controls, reveal the definitive impact of local policy or intervention.
What is it?
Synthetic Control Method is a causal inference method designed to estimate treatment effects by adjusting for confounding in observational studies.
The engine for Comparative Discovery in Single-Unit Trials. This model audits a single treated unit (e.g., a city or clinic) by constructing a 'Synthetic Shadow' from a donor pool of controls, reveal the definitive impact of local policy or intervention.
Goals & Indications
- Shadow-Unit Construction: Mathematically assemble a combination of control units that perfectly mimics the treated unit's pre-intervention pulse.
- Dynamic Causal Audit: Reveal the 'Trajectory Gap' between the treated unit and its synthetic counterfactual after the intervention date.
- Policy Sensitivity Discovery: Isolate the impact of a large-scale change when randomized control trials are logistically impossible.
Core Idea Diagram
Claims tested
How it works
- Identify donor pool units resembling treated unit in pre-treatment variables.
- Compute optimal weights W mapping donor outcomes to treated pre-treatment path.
- Construct the synthetic counterfactual path using weighted donor pool outcomes.
- Compare post-treatment outcomes between actual treated unit and synthetic control.
Assumptions
Important Note
SCM estimates the counterfactual outcome for a treated unit by constructing a weighted combination of control units that closely approximates the treated unit's pre-intervention characteristics. The treatment effect is the post-intervention gap between the treated unit and its synthetic control. Inference typically relies on permutation-based placebo tests rather than classical hypothesis testing.
Worked Example
| Donor Unit | Weight | Pre-MSPE Match | Post-Treatment Gap |
|---|---|---|---|
| State A | 0.42 | 0.021 | -8.45% |
| State B | 0.35 | ||
| State C | 0.23 |
Synthetic Control Counterfactual Path
Observe how the actual treated unit path diverges from the weighted synthetic counterfactual control after the intervention.
Hypotheses
Pragmatic null and alternative hypotheses defined in mathematical notation.
H₀: No treatment effect (treated unit's post-intervention outcome equals synthetic control's counterfactual)
Hₐ: Treatment effect exists (treated unit diverges from synthetic control post-intervention)
SCM estimates the counterfactual outcome for a treated unit by constructing a weighted combination of control units that closely approximates the treated unit's pre-intervention characteristics. The treatment effect is the post-intervention gap between the treated unit and its synthetic control. Inference typically relies on permutation-based placebo tests rather than classical hypothesis testing.
Assumptions
The core mathematical criteria needed to ensure that statistical testing remains unbiased and valid.
Diagnostics
Checking residual plots and indices to examine model deviations and ensure standard error integrity.
- Pre-treatment fit plot (treated vs synthetic control over time)
- Pre-treatment RMSPE (root mean squared prediction error)
- In-space placebo tests (apply SCM to control units, compare effect sizes)
- Predictor balance table (covariates: treated vs synthetic control)
- In-time placebo tests (move treatment date backward, test for false effects)
- Placebo distribution plot (treated effect vs all placebo effects)
- Leave-one-out robustness (exclude each donor sequentially)
- Weights distribution (which donors contribute most to synthetic control)
- Post-treatment gap plot with trend extrapolation
- Permutation-based inference p-value
Applied Minds
Review concrete study examples, data layout guidelines, and copy executable syntax scripts.
Effect of Tobacco Control Program on Cigarette Sales (California Proposition 99)
Research question: Did California's 1988 tobacco control program (Proposition 99) causally reduce cigarette consumption? Design: Comparative case study with California as treated unit and 38 other US states as donor pool. Outcome: Per-capita cigarette sales (packs per capita) 1970-2000. Pre-treatment: 1970-1988 (19 years). Post-treatment: 1989-2000 (12 years). Predictors: Lagged cigarette sales, beer consumption, retail price, income. This replicates the seminal Abadie, Diamond & Hainmueller (2010) study.
# Synthetic Control Method: California Prop 99 → cigarette sales
# Based on Abadie, Diamond & Hainmueller (2010)
library(Synth) # Core SCM package
library(tidyverse)
library(reshape2)
set.seed(2025)
# === STEP 1: Simulate realistic panel data ===
# 39 states (California + 38 donors), 1970-2000 (31 years)
states <- c("California", paste0("State", 1:38))
years <- 1970:2000
n_states <- 39
n_years <- 31
treatment_year <- 1989 # Prop 99 implemented
# Generate baseline cigarette sales (downward trend + state effects)
data_list <- list()
for (i in 1:n_states) {
state_effect <- rnorm(1, 120, 15) # State-specific baseline (packs/capita)
time_trend <- -1.2 # General decline over time
volatility <- runif(1, 3, 6)
# Pre-treatment
sales_pre <- state_effect + time_trend * (1:(treatment_year - 1970)) +
rnorm(treatment_year - 1970, 0, volatility)
# Post-treatment
if (states[i] == "California") {
# California: additional -20 pack decline due to Prop 99
treatment_effect <- -20
sales_post <- state_effect + time_trend * ((treatment_year - 1970 + 1):n_years) +
treatment_effect +
rnorm(n_years - (treatment_year - 1970), 0, volatility)
} else {
# Controls: continue baseline trend
sales_post <- state_effect + time_trend * ((treatment_year - 1970 + 1):n_years) +
rnorm(n_years - (treatment_year - 1970), 0, volatility)
}
sales <- c(sales_pre, sales_post)
data_list[[i]] <- data.frame(
state = states[i],
state_num = i,
year = years,
sales = pmax(sales, 20), # Floor at 20 packs
beer = rnorm(n_years, 25, 5), # Predictor: beer consumption
price = seq(1.5, 3.5, length.out = n_years) + rnorm(n_years, 0, 0.2),
income = seq(20, 40, length.out = n_years) + rnorm(n_years, 0, 2)
)
}
data <- bind_rows(data_list)
# Add treatment indicator
data$treated <- ifelse(data$state == "California" & data$year >= treatment_year, 1, 0)
cat("=== DATA STRUCTURE ===", "\n")
cat("States:", n_states, "(1 treated, 38 donors)\n")
cat("Years:", min(years), "-", max(years), "\n")
cat("Pre-treatment periods:", treatment_year - min(years), "\n")
cat("Post-treatment periods:", max(years) - treatment_year + 1, "\n\n")
# === STEP 2: Prepare data for Synth package ===
dataprep_out <- dataprep(
foo = as.data.frame(data),
predictors = c("beer", "price", "income"),
predictors.op = "mean",
time.predictors.prior = 1970:(treatment_year - 1),
dependent = "sales",
unit.variable = "state_num",
unit.names.variable = "state",
time.variable = "year",
treatment.identifier = 1, # California
controls.identifier = 2:39,
time.optimize.ssr = 1970:(treatment_year - 1),
time.plot = 1970:2000
)
cat("=== SYNTHETIC CONTROL OPTIMIZATION ===", "\n")
# === STEP 3: Estimate synthetic control weights ===
synth_out <- synth(dataprep_out)
cat("\nConvergence:", ifelse(synth_out$solution.w.star[1] > 0, "SUCCESS", "FAILED"), "\n")
# Extract weights
weights <- data.frame(
state = dataprep_out$Y0names,
weight = synth_out$solution.w
)
cat("\n=== DONOR WEIGHTS(top 5) ===", "\n")
print(head(weights %>% arrange(desc(weight)), 10))
cat("\nNumber of donors with weight > 0.01:", sum(weights$weight > 0.01), "\n")
# === STEP 4: Assess pre-treatment fit ===
cat("\n=== PRE-TREATMENT FIT ===", "\n")
# Calculate RMSPE
pre_treatment_years <- 1970:(treatment_year - 1)
treated_pre <- dataprep_out$Y1plot[as.character(pre_treatment_years)]
synthetic_pre <- dataprep_out$Y0plot %*% synth_out$solution.w
rmspe_pre <- sqrt(mean((treated_pre - synthetic_pre)^2))
cat("Pre-treatment RMSPE:", round(rmspe_pre, 3), "packs\n")
# Predictor balance
cat("\n=== PREDICTOR BALANCE ===", "\n")
synth_tables <- synth.tab(dataprep.res = dataprep_out, synth.res = synth_out)
print(synth_tables$tab.pred)
# === STEP 5: Visualize results ===
cat("\n=== GENERATING PLOTS ===", "\n")
# Main path plot
path.plot(synth.res = synth_out, dataprep.res = dataprep_out,
Ylab = "Per-Capita Cigarette Sales(packs)",
Xlab = "Year",
Legend = c("California", "Synthetic California"),
Legend.position = "topright",
Main = "California vs Synthetic Control: Cigarette Sales")
abline(v = treatment_year, lty = 2, col = "red")
# Gap plot (treatment effect over time)
gaps <- dataprep_out$Y1plot - (dataprep_out$Y0plot %*% synth_out$solution.w)
gaps.plot(synth.res = synth_out, dataprep.res = dataprep_out,
Ylab = "Gap in Cigarette Sales(packs)",
Xlab = "Year",
Main = "Gap: California - Synthetic California")
abline(v = treatment_year, lty = 2, col = "red")
abline(h = 0, lty = 2, col = "gray")
# === STEP 6: Calculate treatment effects ===
post_treatment_years <- treatment_year:2000
post_gaps <- gaps[as.character(post_treatment_years)]
cat("\n=== TREATMENT EFFECTS ===", "\n")
cat("Average post-treatment effect:", round(mean(post_gaps), 2), "packs\n")
cat("Effect in final year(2000):", round(post_gaps[length(post_gaps)], 2), "packs\n")
# === STEP 7: In-space placebo tests (permutation inference) ===
cat("\n=== IN-SPACE PLACEBO TESTS ===", "\n")
cat("Running placebo SCM for all 38 donor states(may take 30-60 seconds)...\n")
placebo_effects <- numeric(38)
placebo_rmspe_pre <- numeric(38)
placebo_rmspe_post <- numeric(38)
for (i in 1:38) {
# Run SCM with donor i as "treated"
tryCatch({
dataprep_placebo <- dataprep(
foo = as.data.frame(data),
predictors = c("beer", "price", "income"),
predictors.op = "mean",
time.predictors.prior = 1970:(treatment_year - 1),
dependent = "sales",
unit.variable = "state_num",
unit.names.variable = "state",
time.variable = "year",
treatment.identifier = i + 1, # Placebo treated
controls.identifier = setdiff(2:39, i + 1),
time.optimize.ssr = 1970:(treatment_year - 1),
time.plot = 1970:2000
)
synth_placebo <- synth(dataprep_placebo, verbose = FALSE)
# Calculate placebo effect (mean post-treatment gap)
gaps_placebo <- dataprep_placebo$Y1plot -
(dataprep_placebo$Y0plot %*% synth_placebo$solution.w)
placebo_effects[i] <- mean(gaps_placebo[as.character(post_treatment_years)])
# Pre and post RMSPE
placebo_rmspe_pre[i] <- sqrt(mean(gaps_placebo[as.character(pre_treatment_years)]^2))
placebo_rmspe_post[i] <- sqrt(mean(gaps_placebo[as.character(post_treatment_years)]^2))
}, error = function(e) {
placebo_effects[i] <- NA
})
}
# True California effect
true_effect <- mean(post_gaps)
# p-value: proportion of placebo effects as extreme as true effect
p_value_twosided <- mean(abs(placebo_effects) >= abs(true_effect), na.rm = TRUE)
p_value_onesided <- mean(placebo_effects <= true_effect, na.rm = TRUE)
cat("\nTrue California effect:", round(true_effect, 2), "packs\n")
cat("Placebo effects range:", round(range(placebo_effects, na.rm = TRUE), 2), "\n")
cat("P-value(two-sided):", round(p_value_twosided, 3), "\n")
cat("P-value(one-sided):", round(p_value_onesided, 3), "\n")
cat("Rank:", sum(placebo_effects <= true_effect, na.rm = TRUE), "out of", sum(!is.na(placebo_effects)) + 1, "\n")
# Plot placebo distribution
hist(placebo_effects, breaks = 15, col = "lightblue",
main = "Placebo Distribution(In-Space Tests)",
xlab = "Average Post-Treatment Effect(packs)",
xlim = c(min(c(placebo_effects, true_effect), na.rm = TRUE) - 5,
max(c(placebo_effects, true_effect), na.rm = TRUE) + 5))
abline(v = true_effect, col = "red", lwd = 3, lty = 2)
text(true_effect, par("usr")[4] * 0.9, "California", col = "red", pos = 4)
# === STEP 8: Pre/Post RMSPE ratio test ===
# Exclude placebos with poor pre-fit (RMSPE ratio > 2)
rmspe_ratio_california <- sqrt(mean(post_gaps^2)) / rmspe_pre
rmspe_ratios_placebo <- placebo_rmspe_post / placebo_rmspe_pre
cat("\n=== RMSPE RATIO TEST ===", "\n")
cat("California RMSPE ratio(post/pre):", round(rmspe_ratio_california, 2), "\n")
cat("Mean placebo RMSPE ratio:", round(mean(rmspe_ratios_placebo, na.rm = TRUE), 2), "\n")
# Filter placebos with good pre-fit
good_prefit <- which(placebo_rmspe_pre < 2 * rmspe_pre)
cat("Placebos with good pre-fit:", length(good_prefit), "/", length(placebo_effects), "\n")
if (length(good_prefit) > 0) {
p_value_filtered <- mean(rmspe_ratios_placebo[good_prefit] >=
rmspe_ratio_california, na.rm = TRUE)
cat("P-value(RMSPE ratio, filtered):", round(p_value_filtered, 3), "\n")
}
cat("\n=== APA-STYLE REPORTING ===", "\n")
cat(paste0(
"We used the synthetic control method to estimate the causal effect of ",
"California's Proposition 99 tobacco control program on per-capita cigarette ",
"sales. Using 38 US states as donors, we constructed a synthetic California ",
"that closely matched pre-intervention sales(1970-1988, RMSPE = ",
round(rmspe_pre, 2), " packs). ",
"The synthetic control was a weighted combination of ",
sum(weights$weight > 0.01), " states. ",
"Post-intervention(1989-2000), California's sales diverged substantially from ",
"the synthetic control, with an average reduction of ",
abs(round(true_effect, 1)), " packs per capita. ",
"Permutation-based inference using in-space placebo tests yielded p = ",
round(p_value_onesided, 3), ", indicating the effect was unlikely due to chance. ",
"These findings provide strong evidence that Proposition 99 causally reduced ",
"cigarette consumption in California."
))
The synthetic control analysis estimated that California's Proposition 99 tobacco control program causally reduced per-capita cigarette sales by approximately 20 packs per year post-intervention (1989-2000). The synthetic California (constructed from 38 donor states) provided excellent pre-treatment fit (RMSPE = 2-4 packs), indicating the donor pool could credibly approximate California's counterfactual trajectory. Post-intervention, California's sales diverged substantially from the synthetic control, with the gap widening over time. Permutation-based inference using in-space placebo tests showed that California's effect was more extreme than 95% of placebo effects (p < 0.05), providing strong evidence against the null hypothesis of no effect. The RMSPE ratio (post/pre) for California was substantially larger than most placebos, further supporting causal interpretation. These findings replicate the seminal Abadie et al. (2010) study and demonstrate SCM's power for comparative case studies.
Alternatives
Structured fallback pathways for choosing alternative tests when normality or slopes requirements fail.
- Augmented Synthetic Control — Incorporate negative weights or ridge penalties to force better pre-fit.
- Generalized Synthetic Control — Utilize latent factors to model the underlying pulse of the donor pool.
- Interrupted Time Series (ITS) — Pivot if you have no donors and must rely purely on the treated unit's own history.
- Synthetic DiD — A hybrid model that increases power when donor counts are low.
- Differencing Audit — Mathematically 'level' the treated and synthetic lines before the gap strike.
Post-hoc
Group mean comparisons and correction controls (e.g. Tukey HSD, Bonferroni) to protect against Family-Wise Error Rates.
- Placebo tests: apply method to untreated units (permutation inference)
- Leave-one-out: exclude each donor unit and check robustness
- In-time placebo: apply treatment at fake pre-treatment dates
- Compare pre-treatment fit (RMSPE ratio)
- Sensitivity to donor pool selection
Synthetic control compares treated unit to weighted combination of controls. Traditional post-hoc tests are not applicable.
Effect Size
Understanding effect sizes (e.g., Cohen's d, Partial Eta-Squared) and clinical impact benchmarks.
Raw difference between treated unit and synthetic control at each time point. Most transparent measure.
Mean gap across post-treatment period. Summarizes overall magnitude but loses temporal dynamics.
Sum of all post-treatment gaps. Useful for assessing total impact (e.g., total lives saved, revenue lost).
Relative effect size. Facilitates comparison across studies with different scales.
0.2
0.5
0.8
Sample Size
Guidelines for minimum sample requirements and power analysis parameters.
The 'Shadow Stability' Mandate: A donor pool of at least 10-15 control units is essential to construct a high-fidelity synthetic shadow. Single-unit causal discovery fails if the donor diversity is too shallow.
| Effect Size | Parameters | Required n |
|---|---|---|
| Small Effect | Low Signal (10% shift) | T0 ≈ 20, J ≈ 30 |
| Medium Effect | Moderate Signal (25% shift) | T0 ≈ 10, J ≈ 15 |
| Large Effect | Strong Signal (50% shift) | T0 ≈ 5, J ≈ 8 |
The 'Placebo Strike': SCM significance is proven through placebo iterations on all donor units. If your donor pool is small (J < 10), your p-value resolution is capped at 1/11 (p=0.09), making it impossible to reach 'Elite' significance thresholds.
Reporting
How to compile statistical results into publication prose matching APA and journal style guides.
We used the synthetic control method to estimate the causal effect of treatment/intervention on outcome in treated unit. The donor pool consisted of N control units. We constructed a synthetic control by optimizing weights to minimize pre-treatment mean squared prediction error over T_pre pre-treatment periods (year_start - year_end). Report key predictors used for matching. The synthetic control achieved good/poor pre-treatment fit (RMSPE = value). Report which donors received largest weights, or note if weights were dispersed. Post-intervention (year_start - year_end, T_post = N periods), treated unit diverged from the synthetic control by an average of X units (interpret magnitude and direction). Cumulative effect: sum of gaps = Y. To assess statistical significance, we conducted in-space/in-time placebo tests describe. Report p-value from permutation distribution. Robustness checks: leave-one-out, alternative specifications. These findings support/do not support a causal interpretation of the treatment effect.
- Number of donor units and which received largest weights
- Pre-treatment fit: RMSPE, visual plot, years covered
- Average post-treatment gap (treatment effect) with direction
- Placebo test results (p-value from permutation inference)
- Predictor balance table (treated vs synthetic control)
- Post-treatment trajectory plot (gap over time)
- Robustness checks (LOO, in-time placebos, sensitivity)
Manuscript Lab
Copy standard summary tables and forensic reporting grids to outline analysis details.
| Predictor | Treated Unit | Synthetic Control | Sample Average | Balance Status |
|---|---|---|---|---|
| Prior Growth Rate | 4.2% | 4.1% | 3.5% | BALANCED |
| Population Density | 154.2 | 155.0 | 112.4 | BALANCED |
| Baseline Spend | $12,450 | $12,400 | $10,800 | BALANCED |
The 'Digital Twin'. A weighted combination of other units that mimics the treated unit's behavior perfectly before the intervention.
The Naive Baseline. Proves why Synthetic Control is better: the average state is NOT a good match for the treated state.
Command Center
Syntax libraries and function parameters for executing calculations in stats packages.
# 1. Construct Synthetic Twin
dat <- Synth::dataprep(df, predictors = c('growth', 'pop'),
dependent = 'y', unit.variable = 'id')
synth_out <- Synth::synth(dat)
# 2. Visualize Gap (Path Plot)
Synth::path.plot(synth_out, dat)Traditional p-values don't exist here. You MUST use 'Placebo Tests' (Permutation) to see if the effect in your treated state is larger than what you'd find by picking a random donor state.
# Execute Placebo Audit (In-space Permutation)
SCtools::generate.placebos(dat, synth_out)Common Mistakes
Analytical caveats and corrections to maintain modeling integrity.
References
Scholarly lineage and citation keys grounding the statistical framework.