MethodAtlas
tutorial120 minutes

Lab: Synthetic Control Method

Construct a synthetic control unit from donor states to estimate the causal effect of a policy intervention on a single treated unit. Learn to run placebo tests and assess statistical significance in small-N comparative case studies.

Overview

In this lab you will estimate the effect of a hypothetical anti-smoking policy (modeled on California's Proposition 99) using the synthetic control method of Abadie et al. (2010). You will construct a weighted combination of untreated states that closely matches the treated state's pre-treatment trajectory, then measure how the treated state diverges after the intervention.

What you will learn:

  • How to set up panel data for synthetic control estimation
  • How to construct a synthetic control unit by optimizing donor weights
  • How to interpret the gap between the treated unit and its synthetic counterpart
  • How to run placebo tests (in-space and in-time) for inference
  • How to assess statistical significance with permutation-based p-values

Prerequisites: Familiarity with panel data structure and basic regression. Understanding of the potential outcomes framework is helpful.


Step 1: Simulate State-Level Panel Data

We create a balanced panel of 40 states observed over 30 years (1970–1999). State 1 receives a policy treatment in 1989.

library(Synth)

set.seed(42)
J <- 40; T_len <- 30
treat_unit <- 1; treat_year <- 1989

state_fe <- rnorm(J, 50, 10)
time_fe <- cumsum(rnorm(T_len, 0.5, 0.3))

df <- expand.grid(state = 1:J, year = 1970:1999)
df <- df[order(df$state, df$year), ]
df$income <- 5000 + 100 * state_fe[df$state] / 50 + 50 * time_fe[df$year - 1969] + rnorm(nrow(df), 0, 200)
df$beer <- 20 + 0.5 * state_fe[df$state] / 50 + rnorm(nrow(df), 0, 3)
df$retprice <- 60 + rnorm(nrow(df), 0, 10)
df$cigsale <- state_fe[df$state] + time_fe[df$year - 1969] +
            0.002 * df$income - 0.1 * df$retprice + 0.5 * df$beer +
            rnorm(nrow(df), 0, 3)

# Apply treatment effect
df$cigsale[df$state == treat_unit & df$year >= treat_year] <-
df$cigsale[df$state == treat_unit & df$year >= treat_year] - 20

head(df, 10)
cat("Panel:", length(unique(df$state)), "states,", length(unique(df$year)), "years\n")
RequiresSynth

Expected output:

stateyearcigsaleincomebeerretprice
1197075.25,20021.458.3
1197176.15,35020.862.1
1197276.85,50021.555.7
1197377.55,62020.263.4
1197478.05,78021.159.8
Panel: 40 states, 30 years
Treated unit: state 1, treatment year: 1989

Summary statistics:

VariableMeanStd DevMinMax
cigsale75.012.535110
income5,1003504,2006,200
beer20.53.21230
retprice60.010.03090

Step 2: Construct the Synthetic Control Unit

We find weights for the donor states (2–40) so that the weighted average matches state 1's pre-treatment outcomes and covariates as closely as possible.

# Prepare data for Synth package
dataprep_out <- dataprep(
foo = df,
predictors = c("income", "beer", "retprice"),
predictors.op = "mean",
dependent = "cigsale",
unit.variable = "state",
time.variable = "year",
treatment.identifier = 1,
controls.identifier = 2:40,
time.predictors.prior = 1970:1988,
time.optimize.ssr = 1970:1988,
time.plot = 1970:1999
)

synth_out <- synth(dataprep_out)

# Print top donor weights
w <- synth_out$solution.w
rownames(w) <- 2:40
top5 <- head(w[order(-w[,1]), , drop = FALSE], 5)
print(round(top5, 4))
RequiresSynth

Expected output: Donor weights

StateWeight
State 8~0.35
State 15~0.25
State 22~0.18
State 31~0.12
State 5~0.06
Remaining 34 states~0.04 (total)

The weights are sparse: 3–5 donor states receive meaningful positive weights (>0.05), while most states receive near-zero weight. The selected donors are the states whose pre-treatment cigarette sales trajectories most closely resemble state 1's trajectory.

Concept Check

Why do we constrain the donor weights to be non-negative and sum to one?


Step 3: Estimate the Treatment Effect

Compare the treated unit's actual outcomes to the synthetic control's outcomes after the intervention.

# Plot: treated vs synthetic
path.plot(synth_out, dataprep_out,
        Main = "Treated vs Synthetic Control",
        Ylab = "Per-capita cigarette sales",
        Xlab = "Year",
        Legend = c("State 1", "Synthetic"),
        Legend.position = "bottomleft")
abline(v = 1989, lty = 2, col = "gray")

# Gap plot
gaps.plot(synth_out, dataprep_out,
        Main = "Gap: Treated - Synthetic",
        Ylab = "Gap in cigarette sales")
abline(h = 0, lty = 2, col = "gray")

# Average post-treatment gap
Y1 <- dataprep_out$Y1plot
Y0 <- dataprep_out$Y0plot %*% synth_out$solution.w
gap <- Y1 - Y0
post_idx <- which(rownames(Y1) >= "1989")
cat("Average treatment effect:", mean(gap[post_idx]), "\n")

Expected output:

Average treatment effect (post-1989): ~-20.0
True effect: -20.00

Step 4: Placebo Tests (In-Space)

To assess whether the estimated gap is statistically unusual, we run the same procedure pretending each donor state was the treated unit.

# In-space placebo: iterate over all states, treating each as if it were treated
library(Synth)

# Allocate matrix: rows = years (1970-1999), cols = one per state
placebo_gaps <- matrix(NA, nrow = 30, ncol = J)
for (s in 1:J) {
# All states except s serve as the donor pool for this iteration
donors <- setdiff(1:J, s)
# Prepare data treating state s as the treated unit
dp <- dataprep(foo = df, predictors = c("income", "beer", "retprice"),
               predictors.op = "mean", dependent = "cigsale",
               unit.variable = "state", time.variable = "year",
               treatment.identifier = s, controls.identifier = donors,
               time.predictors.prior = 1970:1988,
               time.optimize.ssr = 1970:1988, time.plot = 1970:1999)
# Use tryCatch so a failed optimization for one state doesn't abort the loop
so <- tryCatch(suppressMessages(synth(dp)), error = function(e) NULL)
if (!is.null(so)) {
  # Store the gap (actual - synthetic) for this placebo state
  placebo_gaps[, s] <- dp$Y1plot - dp$Y0plot %*% so$solution.w
}
}

# Plot all placebo gaps in light gray; treated state's gap in blue
matplot(1970:1999, placebo_gaps, type = "l", col = "lightgray", lty = 1,
      ylab = "Gap", xlab = "Year", main = "In-Space Placebo Test")
lines(1970:1999, placebo_gaps[, 1], col = "blue", lwd = 2)
# Vertical line marks treatment year; horizontal marks zero effect
abline(v = 1989, lty = 2); abline(h = 0, lty = 2)
RequiresSynth

Expected output:

Permutation p-value: ~0.025 (1/40)

Only state 1 (the treated unit) has a post/pre RMSPE ratio that ranks at the top of the distribution, yielding a p-value of 1/40 = 0.025, which is significant at the 5% level. This result confirms that the estimated effect is statistically unusual.

Concept Check

You run placebo tests on all 39 donor states and find that only 1 out of 40 (including the treated state) has a post/pre RMSPE ratio as large as the treated state. What is the implied p-value?


Step 5: In-Time Placebo Test

Assign a fake treatment date before the actual intervention to check whether a 'gap' appears when no treatment occurred.

# In-time placebo: pretend treatment in 1982 (before the real 1989 cutoff)
# Restrict to years before 1989 so there is no post-treatment contamination
dp_fake <- dataprep(
foo = df[df$year < 1989, ],
predictors = c("income", "beer", "retprice"),
predictors.op = "mean", dependent = "cigsale",
unit.variable = "state", time.variable = "year",
treatment.identifier = 1, controls.identifier = 2:40,
# Pre-period for matching is 1970-1981 (before the fake treatment date)
time.predictors.prior = 1970:1981,
time.optimize.ssr = 1970:1981,
# Plot window covers 1970-1988 (the full pre-Prop-99 period)
time.plot = 1970:1988
)
# Fit the synthetic control under the fake treatment assumption
so_fake <- synth(dp_fake)
# A large gap at 1982 would undermine pre-treatment fit credibility
gaps.plot(so_fake, dp_fake, Main = "In-Time Placebo (fake treatment 1982)")
abline(v = 1982, col = "red", lty = 2)
RequiresSynth

Expected output:

If no gap appears at the fake date, the pre-treatment fit is credible.

Step 6: Sensitivity and Extensions

# Leave-one-out: drop each top donor and re-estimate
top5_states <- as.integer(rownames(top5))

# Treated unit actual outcomes
Y1_vec <- dataprep_out$Y1plot
Y_synth_full <- dataprep_out$Y0plot %*% synth_out$solution.w

par(mfrow = c(1, 1))
plot(1970:1999, Y1_vec, type = "l", col = "blue",
   lwd = 2, ylim = range(Y1_vec), ylab = "Cigarette sales", xlab = "Year",
   main = "Leave-One-Out Robustness")
lines(1970:1999, Y_synth_full, col = "red", lwd = 2, lty = 2)

# For each top donor, re-estimate without it
for (d in top5_states) {
donors_loo <- setdiff(2:40, d)
dp_loo <- dataprep(foo = df, predictors = c("income", "beer", "retprice"),
                   predictors.op = "mean", dependent = "cigsale",
                   unit.variable = "state", time.variable = "year",
                   treatment.identifier = 1, controls.identifier = donors_loo,
                   time.predictors.prior = 1970:1988,
                   time.optimize.ssr = 1970:1988, time.plot = 1970:1999)
so_loo <- tryCatch(suppressMessages(synth(dp_loo)), error = function(e) NULL)
if (!is.null(so_loo)) {
  synth_loo <- dp_loo$Y0plot %*% so_loo$solution.w
  lines(1970:1999, synth_loo, col = "gray", lty = 2)
}
}
abline(v = 1989, lty = 2)
RequiresSynth

Expected output:


Exercises

  1. Change the treatment magnitude. Set the true effect to -5 instead of -20 and re-run the analysis. Can the synthetic control method still detect this smaller effect? What happens to the placebo p-value?

  2. Add anticipation effects. Modify the DGP so that the treated state begins responding 2 years before the formal treatment date. How does this affect your estimates if you use 1989 as the treatment date? What should you do?

  3. Increase the number of donors. Expand to 100 states and re-estimate. Does the pre-treatment fit improve? Does the estimate get closer to the truth?

  4. Try augmented synthetic control. Use the augsynth package in R (or SparseSC in Python) and compare the results with the standard synthetic control. When does the augmented version help?


Summary

In this lab you learned:

  • The synthetic control method constructs a data-driven counterfactual from donor units, ideal for case studies with a single treated unit
  • Pre-treatment fit is the key diagnostic: if the synthetic control does not track the treated unit before the intervention, the post-treatment gap is not credible
  • Inference comes from permutation (placebo) tests, not standard asymptotic theory
  • The post/pre RMSPE ratio provides a principled test statistic for significance
  • Leave-one-out checks assess whether results depend on any single donor
  • The method works best with a long pre-treatment period, a sharp intervention, and a rich donor pool