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")Expected output:
| state | year | cigsale | income | beer | retprice |
|---|---|---|---|---|---|
| 1 | 1970 | 75.2 | 5,200 | 21.4 | 58.3 |
| 1 | 1971 | 76.1 | 5,350 | 20.8 | 62.1 |
| 1 | 1972 | 76.8 | 5,500 | 21.5 | 55.7 |
| 1 | 1973 | 77.5 | 5,620 | 20.2 | 63.4 |
| 1 | 1974 | 78.0 | 5,780 | 21.1 | 59.8 |
Panel: 40 states, 30 years
Treated unit: state 1, treatment year: 1989
Summary statistics:
| Variable | Mean | Std Dev | Min | Max |
|---|---|---|---|---|
| cigsale | 75.0 | 12.5 | 35 | 110 |
| income | 5,100 | 350 | 4,200 | 6,200 |
| beer | 20.5 | 3.2 | 12 | 30 |
| retprice | 60.0 | 10.0 | 30 | 90 |
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))Expected output: Donor weights
| State | Weight |
|---|---|
| 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.
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)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.
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)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)Expected output:
Exercises
-
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?
-
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?
-
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?
-
Try augmented synthetic control. Use the
augsynthpackage in R (orSparseSCin 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