Lab: Synthetic Difference-in-Differences
Estimate causal effects by combining the strengths of difference-in-differences and synthetic control. Learn to construct the SDID estimator, compare it with standard DiD and synthetic control, and conduct placebo-based inference.
Overview
In this lab you will implement the synthetic difference-in-differences (SDID) estimator of Arkhangelsky et al. (2021). SDID combines two powerful ideas: it reweights untreated units to match the treated unit's pre-treatment trajectory (like synthetic control) and it differences out time-invariant confounders (like DiD). This hybrid approach often outperforms both methods individually.
What you will learn:
- How standard DiD and synthetic control each handle different threats to identification
- How SDID combines unit weights and time weights to improve estimation
- How to implement SDID step by step and with packages
- How to compare SDID, DiD, and SC estimates on the same data
- How to conduct placebo inference for SDID
Prerequisites: Familiarity with difference-in-differences and basic panel data methods. Completion of the DiD tutorial lab is recommended.
Step 1: Simulate State Policy Panel Data
We create a balanced panel with one treated state adopting a policy in period 16 of 25 periods.
library(synthdid)
library(fixest)
set.seed(42)
N <- 30; T_len <- 25; T_pre <- 15
alpha_i <- rnorm(N, 10, 3)
delta_t <- cumsum(rnorm(T_len, 0.2, 0.1))
trends <- rnorm(N, 0, 0.05)
df <- expand.grid(state = 1:N, time = 1:T_len)
df <- df[order(df$state, df$time), ]
df$Y <- alpha_i[df$state] + delta_t[df$time] + trends[df$state] * df$time + rnorm(nrow(df), 0, 0.5)
df$treated_unit <- as.integer(df$state == 1)
df$post <- as.integer(df$time > T_pre)
df$D <- df$treated_unit * df$post
# Treatment effect
df$Y[df$D == 1] <- df$Y[df$D == 1] + 3.0
cat("Panel:", N, "states x", T_len, "periods\n")
cat("True effect: 3.0\n")Expected output:
| state | time | Y | treated_unit | post | D |
|---|---|---|---|---|---|
| 1 | 1 | 10.8 | 1 | 0 | 0 |
| 1 | 2 | 11.3 | 1 | 0 | 0 |
| 1 | 3 | 11.9 | 1 | 0 | 0 |
| 1 | 4 | 12.4 | 1 | 0 | 0 |
| 1 | 5 | 12.6 | 1 | 0 | 0 |
Panel: 30 states x 25 periods = 750 observations
Treated: state 1 from period 16
True treatment effect: 3.0
Step 2: Standard Difference-in-Differences
# TWFE DiD
did <- feols(Y ~ D | state + time, data = df)
cat("DiD estimate:", coef(did)["D"], "\n")
summary(did)Expected output:
=== Standard DiD (TWFE) ===
Estimated effect: ~3.05
SE: ~0.15
True effect: 3.000
Simple 2x2 DiD: ~3.10
Standard DiD may exhibit some bias because the DGP includes state-specific trends (trends ~ N(0, 0.05)), which mildly violate the parallel trends assumption. The estimate will be close to 3.0 but may deviate slightly depending on the realized trends.
Step 3: Synthetic Control
# Reshape to matrix for synthdid
Y_mat <- matrix(df$Y, nrow = N, ncol = T_len, byrow = FALSE)
# Actually, synthdid expects a specific format
# Use the panel.matrices helper
setup <- panel.matrices(df, unit = "state", time = "time", outcome = "Y", treatment = "D")
# SC estimate
sc_est <- sc_estimate(setup$Y, setup$N0, setup$T0)
cat("SC estimate:", sc_est, "\n")Expected output:
=== Synthetic Control ===
Estimated effect: ~3.10
Pre-treatment RMSE: ~0.05
The synthetic control matches the treated unit's pre-treatment trajectory by placing positive weights on a sparse subset of control states. The small pre-treatment RMSE indicates a close fit.
Standard DiD uses equal weights for all control units, while synthetic control uses optimized weights. When would you expect SC to substantially outperform DiD?
Step 4: Synthetic Difference-in-Differences
SDID combines unit weights (from SC) with time weights and applies a DiD-style differencing.
# SDID using the synthdid package
sdid_est <- synthdid_estimate(setup$Y, setup$N0, setup$T0)
cat("SDID estimate:", sdid_est, "\n")
# Compare all three
did_est <- did_estimate(setup$Y, setup$N0, setup$T0)
sc_est2 <- sc_estimate(setup$Y, setup$N0, setup$T0)
cat("\n=== Comparison ===\n")
cat("DiD:", did_est, "\n")
cat("SC:", sc_est2, "\n")
cat("SDID:", sdid_est, "\n")
cat("True: 3.0\n")
# Plot
plot(sdid_est, main = "Synthetic DiD")Expected output:
| Method | Estimate | True Effect |
|---|---|---|
| DiD (TWFE) | ~3.05 | 3.0 |
| Synthetic Control | ~3.10 | 3.0 |
| Synthetic DiD | ~3.02 | 3.0 |
=== Comparison ===
DiD: ~3.05
SC: ~3.10
SDID: ~3.02
True: 3.000
SDID typically produces the estimate closest to the true effect because it both reweights control units (like SC) and differences out fixed effects (like DiD). The SDID unit weights are sparse (a few donor states receive large weights), and the time weights concentrate on the most informative pre-treatment periods.
Step 5: Placebo Inference
# Placebo inference via synthdid
se_sdid <- sqrt(vcov(sdid_est, method = "placebo"))
cat("SDID estimate:", sdid_est, "\n")
cat("Placebo SE:", se_sdid, "\n")
cat("t-stat:", sdid_est / se_sdid, "\n")
# The placebo method reassigns treatment to each control unit
# and computes the distribution of placebo effectsExpected output:
Placebo p-value: ~0.03
SDID uses both unit weights and time weights. What role do the time weights play?
Exercises
-
Multiple treated units. Modify the DGP so that 5 states are treated. Compare DiD, SC, and SDID. Which handles multiple treated units best?
-
Staggered adoption. Have different states adopt the policy in different periods. How does SDID perform compared to standard staggered DiD estimators?
-
Vary pre-treatment periods. Reduce T_pre from 15 to 5. How does this affect the quality of unit weights and the SDID estimate?
-
Covariates. Add a time-varying covariate to the DGP and include it in the SDID estimation. Does this improve precision?
Summary
In this lab you learned:
- Standard DiD relies on parallel trends; synthetic control matches pre-treatment levels; SDID combines both approaches
- SDID uses unit weights (to select comparable controls) and time weights (to select informative pre-periods)
- In simulations, SDID is often at least as good as whichever of DiD or SC performs better
- Inference for SDID uses placebo-based or jackknife standard errors, not conventional OLS standard errors
- SDID is particularly useful when you have a single (or few) treated units and doubt that parallel trends hold exactly
- The
synthdidpackage in R provides a clean implementation; Python and Stata implementations are also available