Causal Inference VII — Synthetic Control (R companion)¶
tidysynth — Abadie's predictor-based synthetic control for Proposition 99¶
R is where synthetic control was born (Abadie's own Synth package) and where the modern tidy interface lives. This companion uses tidysynth, whose pipeline mirrors Abadie, Diamond & Hainmueller (2010) exactly: match California to a donor pool on economic predictors (price, income, youth share, beer) and pre-treatment cigarette-sales lags, solve for the optimally-weighted donor mix, and run the full placebo distribution for inference.
The key contrast with the from-scratch Python notebook is deliberate: Python matched on the pre-treatment outcome path only and California ranked 3rd of 39 in the placebo test ($p\approx0.08$); here, Abadie's predictor-based matching sharpens the placebo distribution and pushes California to rank 1 of 39 with Fisher's exact $p\approx0.026$ — while the effect size (~26 fewer packs by 2000) is the same. Specification matters for inference, not for the headline effect.
1. Building the synthetic control — the tidysynth pipeline¶
The pipeline reads like the method itself: declare the treated unit and time, add predictors (averaged over chosen pre-treatment windows), generate the optimal weights over the donor pool, and generate the synthetic control. The resulting donor weights reproduce Abadie's published result almost exactly — a sparse blend of Utah, Nevada, Montana, Colorado, and Connecticut.
suppressMessages({library(tidysynth); library(dplyr)})
sm<-read.csv("smoking.csv")
out<-sm %>%
synthetic_control(outcome=cigsale, unit=state, time=year, i_unit="California", i_time=1988, generate_placebos=TRUE) %>%
generate_predictor(time_window=1980:1988, ln_income=mean(lnincome,na.rm=TRUE), ret_price=mean(retprice,na.rm=TRUE), youth=mean(age15to24,na.rm=TRUE)) %>%
generate_predictor(time_window=1984:1988, beer_sales=mean(beer,na.rm=TRUE)) %>%
generate_predictor(time_window=1975, cigsale_1975=cigsale) %>%
generate_predictor(time_window=1980, cigsale_1980=cigsale) %>%
generate_predictor(time_window=1988, cigsale_1988=cigsale) %>%
generate_weights(optimization_window=1970:1988) %>%
generate_control()
w<-out %>% grab_unit_weights() %>% arrange(desc(weight)) %>% filter(weight>0.01)
cat("Donor weights forming synthetic California (predictor-based):\n"); print(as.data.frame(w), row.names=FALSE)
Warning message: "package 'tidysynth' was built under R version 4.6.1"
Donor weights forming synthetic California (predictor-based):
unit weight
Utah 0.34322360
Nevada 0.23575162
Montana 0.18197056
Colorado 0.17468464
Connecticut 0.06241991
2. Real vs synthetic California — plot_trends and plot_weights¶
plot_trends overlays real and synthetic California: a tight match before 1988, then a widening gap as Proposition 99 takes effect. plot_weights shows the sparse donor composition. The post-1988 divergence — real California falling below its synthetic counterpart — is the estimated effect, reaching roughly 26 fewer packs per capita by 2000.
options(repr.plot.width=9, repr.plot.height=5)
print(out %>% plot_trends() + ggplot2::ggtitle("tidysynth: California vs synthetic California — Proposition 99"))
options(repr.plot.width=8, repr.plot.height=4)
print(out %>% plot_weights() + ggplot2::ggtitle("Donor and predictor weights"))
Warning message: "Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0. ℹ Please use `linewidth` instead. ℹ The deprecated feature was likely used in the tidysynth package. Please report the issue to the authors."
3. Placebo inference — plot_placebos, plot_mspe_ratio, grab_significance¶
Synthetic control's inference is the placebo permutation test: re-run the analysis treating each donor as if it had adopted the policy, and compare California's gap to the placebo distribution. plot_placebos overlays all the gaps; plot_mspe_ratio ranks states by the post/pre MSPE ratio; and grab_significance returns California's rank and Fisher's exact $p$-value. With predictor-based matching, California ranks 1st of 39 ($p\approx0.026$) — the sharp inference Abadie reports, and a visible improvement over the pure-outcome Python specification.
options(repr.plot.width=9, repr.plot.height=4.6)
print(out %>% plot_placebos() + ggplot2::ggtitle("Placebo gaps: California (black) vs donor placebos"))
sig<-out %>% grab_significance() %>% filter(unit_name=="California") %>% select(unit_name, mspe_ratio, rank, fishers_exact_pvalue)
cat("California placebo inference:\n"); print(as.data.frame(sig), row.names=FALSE)
options(repr.plot.width=8, repr.plot.height=4.6)
print(out %>% plot_mspe_ratio() + ggplot2::ggtitle("Post/pre MSPE ratio — California tops the ranking"))
California placebo inference:
unit_name mspe_ratio rank fishers_exact_pvalue California 120.4927 1 0.02564103
Ignoring unknown labels: • colour : ""
4. Summary¶
tidysynth reproduced Abadie's canonical Proposition 99 analysis: a synthetic California built from Utah, Nevada, Montana, Colorado, and Connecticut tracked the real state through 1988 and diverged after, estimating that the program cut per-capita cigarette sales by a growing margin (~26 packs by 2000), with a placebo test placing California 1st of 39 ($p\approx0.026$). Compared to the from-scratch Python notebook, the predictor-based matching (price, income, youth, beer, plus outcome lags) sharpened the placebo distribution while leaving the effect size unchanged — the value R's mature Synth/tidysynth implementation adds.
The workflow to remember: declare treated unit and time → add predictors → optimize weights → generate control → validate with placebos and the MSPE-ratio permutation test. Synthetic control is difference-in-differences with data-driven weights (the previous notebook) and its placebo inference is Fisher's randomization test (the first notebook), applied to a single-unit comparative case study. Next: DAGs, Mediation & the Structural Causal Model — the graphical language of identification that unifies the whole arc.