ppsynth implements
the estimator in Grier (2026), “Correlated Weights: Partial Pooling for
Synthetic Control with Multiple Outcomes”. It is the R counterpart of
the Stata command ppsynth, with the same options, the same
returned quantities and the same printed display, and its engines are
certified against the Python engine the Stata command calls.
A study that applies the synthetic control method to several outcomes
faces a choice: fit each outcome its own donor weights, or impose one
common set on all of them. ppsynth() makes the middle
available. Each outcome gets its own weights, their dispersion around a
common centroid is penalized, no single penalty is ever selected (the
estimator aggregates over a grid by leave-one-out exponential
weighting), and the resulting mass profile reports how much pooling the
data support.
The package ships the panel of Section 5A of the paper: five outcomes
for 21 OECD countries, 1970-2004, Germany (id = 8) treated
from 1990.
library(ppsynth)
data(germany_panel)
outs <- c("proprights", "money", "inttrade", "regulation", "csh_g")
fit <- ppsynth(germany_panel, outcomes = outs,
id = "id", time = "year", trunit = 8, trperiod = 1990)
fit
#>
#> Partial-pooling synthetic control (5 outcomes, method = aggregate)
#> ----------------------------------------------------------------
#> pre: 20 post: 15 donors: 20
#> ----------------------------------------------------------------
#> outcome mean post-treatment effect pre-RMSPE
#> proprights -0.07577 0.03276
#> money -0.03366 0.1148
#> inttrade 0.04376 0.2204
#> regulation -0.1108 0.1834
#> csh_g 0.008094 0.002093
#> ----------------------------------------------------------------
#> Realized weight pooling: 16.1%
#> Middle 50% of predictive mass: 4% to 24% pooling
#> ----------------------------------------------------------------
#> (add inference = TRUE for placebo p-values, massprofile = TRUE for the lambda grid)
#>
#> CAUTION: outcome 'inttrade' lies outside the support of the donor outcomes in 15 of 20 pre-treatment periods (75%). Synthetic control weights cannot extrapolate beyond the donor range. Check the data and consider revising the donor pool before proceeding.This mirrors the Stata call
ppsynth proprights money inttrade regulation csh_g, trunit(8) trperiod(1990) id(id) time(year)
and prints the same display: the per-outcome mean post-treatment
effects and pre-RMSPE, then the realized weight pooling of the final
estimator and the middle 50% of predictive mass on the pooling scale.
The lambda-grid mass profile (the Stata massprofile option)
is one argument away:
print(fit, massprofile = TRUE)
#>
#> Partial-pooling synthetic control (5 outcomes, method = aggregate)
#> ----------------------------------------------------------------
#> pre: 20 post: 15 donors: 20
#> ----------------------------------------------------------------
#> outcome mean post-treatment effect pre-RMSPE
#> proprights -0.07577 0.03276
#> money -0.03366 0.1148
#> inttrade 0.04376 0.2204
#> regulation -0.1108 0.1834
#> csh_g 0.008094 0.002093
#> ----------------------------------------------------------------
#> Realized weight pooling: 16.1%
#> Middle 50% of predictive mass: 4% to 24% pooling
#> ----------------------------------------------------------------
#> (add inference = TRUE for placebo p-values)
#>
#> Aggregation mass profile:
#> lambda 0 0.01 0.03 0.1 0.3 1 3 10 30 inf
#> pool % 0 1 4 8 12 24 37 50 69 100
#> mass % 11.9 12.6 12.9 14.0 15.7 16.3 13.3 3.3 0.1 0.0
#> Note: pooling per lambda is panel-specific; see ?ppsynth.
#>
#> CAUTION: outcome 'inttrade' lies outside the support of the donor outcomes in 15 of 20 pre-treatment periods (75%). Synthetic control weights cannot extrapolate beyond the donor range. Check the data and consider revising the donor pool before proceeding.The mass profile is an interior hump: the data reject common weights
but do not endorse fully separate fits either. The per-outcome donor
weights (Stata’s r(gamma)) and the effects:
round(fit$gamma, 2)
#> proprights money inttrade regulation csh_g
#> 1 0.34 0.00 0.0 0.00 0.03
#> 2 0.00 0.23 0.0 0.09 0.03
#> 3 0.00 0.01 0.0 0.02 0.00
#> 4 0.00 0.00 0.0 0.00 0.00
#> 5 0.00 0.00 0.0 0.00 0.16
#> 6 0.00 0.00 0.0 0.00 0.00
#> 7 0.00 0.00 0.0 0.09 0.04
#> 9 0.00 0.00 0.0 0.00 0.00
#> 10 0.26 0.00 0.0 0.00 0.15
#> 11 0.00 0.00 0.0 0.13 0.02
#> 12 0.00 0.03 0.0 0.13 0.01
#> 13 0.00 0.27 0.0 0.01 0.01
#> 14 0.00 0.00 0.0 0.00 0.00
#> 15 0.00 0.05 0.0 0.00 0.03
#> 16 0.00 0.00 0.0 0.21 0.01
#> 17 0.00 0.00 0.0 0.08 0.00
#> 18 0.25 0.00 0.0 0.04 0.05
#> 19 0.00 0.34 0.1 0.00 0.18
#> 20 0.00 0.00 0.0 0.00 0.13
#> 21 0.15 0.07 0.9 0.18 0.16
fit$tau
#> proprights money inttrade regulation csh_g
#> -0.075772935 -0.033663742 0.043760226 -0.110776553 0.008093527inference = TRUE reruns the entire pipeline, aggregation
included, for each donor treated as a placebo, and reports per-outcome
and joint p-values under the Phipson-Smyth convention by default
(convention = "strict" or likesynth = TRUE
give the synth-style strict count). On this panel the 21 re-estimations
take a minute or two on one core; cores spreads them across
cores without changing any number.
fit_ppsynth() and placebo_inference() take
the data as lists of matrices, exactly like the Python functions of the
same name, and return the same fields.
Y_tr <- lapply(outs, function(v) germany_panel[[v]][germany_panel$id == 8])
Y_dn <- lapply(outs, function(v)
sapply(setdiff(sort(unique(germany_panel$id)), 8),
function(u) germany_panel[[v]][germany_panel$id == u]))
eng <- fit_ppsynth(Y_tr, Y_dn, T0 = 20)
round(eng$agg_mass, 3)
#> [1] 0.119 0.126 0.129 0.140 0.157 0.163 0.133 0.033 0.001 0.000Two engines run the same algorithm with the same solver settings:
engine = "cpp" (the default) is a compiled inner solver
mirroring the numba engine behind the Stata command, and
engine = "r" is a pure-R reference solver. Every returned
quantity of both engines is compared against the Python engines’ output
on the package’s fixtures (inst/certification/fixtures,
generated by tools/make_fixtures.py); the measured
deviations are recorded in inst/certification/results and
the package tests enforce them.