The goal of pensynth is to make it easier to perform penalized synthetic control in the spirit of Abadie & L'Hour (2021).
Synth::synth implementation for synthetic controls, because we use the clarabel quadratic program solver with sparse matrices.lambda penalty parameter estimation.[!NOTE] pensynth estimates unit weights, but variable weights have to be pre-specified. This is by design.
I recommend installing the latest version of pensynth from r-universe like so:
install.packages("pensynth", repos = c("https://vankesteren.r-universe.dev", "https://cloud.r-project.org"))
The package is also on CRAN, so it can be installed directly from there too.
install.packages("pensynth")
The latest development version can also be installed from this repository:
remotes::install_github("vankesteren/pensynth")
Penalized synthetic control yields a smooth transition between the synthetic control method (when $\lambda = 0$) and nearest neighbour matching (when $\lambda \to \infty$).
When the treated unit is in the convex hull of the donor units (which is more likely when there are many donors) there is no unique solution for the unit weights of synthetic control. In these cases especially, the penalty can help because it prefers solutions with donors closer in covariate space.
The pensynth implementation achieves this through optimizing the following objective:
\min_{\boldsymbol{W}} \left[ \| \boldsymbol{X}_1 - \boldsymbol{X}_0 \boldsymbol{W} \|^2 + \lambda \sum_{t\in T} \sum_{d\in D} \boldsymbol{W}_{dt} \cdot \|\boldsymbol{X}_1^t - \boldsymbol{X}_0^d\|^2 \right]
\text{s.t.}
\quad \boldsymbol{W}_{\{d,t\}} \geq 0 \,\,\forall \,\,\{d,t\} \in \{D, T\},
\sum_{d\in D} \boldsymbol{W}_{\{d,t\}} = 1 \,\,\forall \,\,t \in T,
Where
The first term in the objective is the same (up to variable weights) as the original synthetic control, and the second term is the nearest neighbour matching penalty.
Here, we perform pensynth with a simple simulated dataset.
library(pensynth)
set.seed(45)
# Generate some data with a 0.8SD effect
dat <- simulate_data_synth(treatment_effect = 0.8)

# Run penalized synthetic control
# estimate lambda using pre-intervention timeseries MSE
fit <- cv_pensynth(
X1 = dat$X1, # Treated unit covariates
X0 = dat$X0, # Donor unit covariates
Z1 = dat$Z1, # Treated unit hold-out values (pre-intervention outcome)
Z0 = dat$Z0 # Donor unit hold-out values
)
plot(fit)

We can then use the predict() method to compute the synthetic control
# Compute average treatment effect post-intervention
Y1_synth <- predict(
object = fit,
newdata = dat$Y0 # Donor units post-intervention outcome
)
mean(dat$Y1 - Y1_synth)
#> [1] 0.8780154
Abadie, A., & L’Hour, J. (2021). A penalized synthetic control estimator for disaggregated data. Journal of the American Statistical Association, 116(536), 1817-1834.
Some of the code was inspired by (but heavily adapted from) jeremylhour/pensynth. Where this was the case, the code is commented.