ppsynth: partial pooling for synthetic control with multiple outcomes

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 worked example: German reunification

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.008093527

Graphs

plot(fit, which = "paths")   # treated (solid) vs synthetic (dash), one panel per outcome

plot(fit, which = "mass")    # aggregation mass over the lambda grid

Placebo inference

inference = 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.

inf <- ppsynth(germany_panel, outcomes = outs,
               id = "id", time = "year", trunit = 8, trperiod = 1990,
               inference = TRUE, cores = 2)
print(inf, effects = TRUE)
plot(inf, which = "gaps")

Matrix interface

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.000

Engines and certification

Two 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.