Bayesian Penalized Synthetic Control under Spillovers (BPSCS)#
When to use BPSCS – and when not to#
Bayesian Penalized Synthetic Control under Spillovers (Fernandez-Morales, Oganisian and Lee 2026) is a Bayesian synthetic control for the situation where some donors are contaminated by the treatment. When a policy spills over onto nearby areas – cross-border shopping after a city soda tax, say – those neighbours are no longer clean controls: their post-period outcomes move because of the treatment, which biases an ordinary synthetic control. BPSCS keeps such donors in the pool but shrinks their contribution, using a utility that blends covariate similarity and spatial distance to the treated unit. Reach for it when:
Your donors are geographic units and the intervention plausibly leaks into the ones nearest the treated unit, so excluding them wholesale throws away information but trusting them fully biases the estimate.
You want a full posterior credible band on the counterfactual and the effect, not a single line.
You have per-unit spatial coordinates and baseline covariates, and you want the donor down-weighting to be data-driven from those rather than a hand-picked exclusion list.
Do not use BPSCS when:
There is no spillover and no spatial structure to exploit – a plain weighting or factor SC (Vanilla Synthetic Control (VanillaSC), Bayesian Factor Synthetic Control (BFSC)) is simpler and the utility term buys you nothing.
You need a dependency-free estimate. BPSCS draws its posterior with NUTS (NumPyro), so it needs the
[bayes]optional dependency (pip install 'mlsynth[bayes]').You can enumerate exactly which donors are contaminated and by how much – then a structural spillover model (Spillover-Aware Synthetic Control (SPILLSYNTH), Spatial Synthetic Difference-in-Differences (SpSyDiD)) that estimates the spillover directly may serve better. BPSCS assumes only that spillover risk is a monotone function of distance, and down-weights accordingly.
Relation to the other spillover and Bayesian estimators#
BPSCS sits at the intersection of two families. Like Spillover-Aware Synthetic Control (SPILLSYNTH) and Spatial Synthetic Difference-in-Differences (SpSyDiD) it targets spillover onto the donor pool; unlike them it does not model the spillover structurally – it treats spillover risk as increasing with proximity and mitigates it through the prior. Like Bayesian Synthetic Control Methods (BSCM), Bayesian Factor Synthetic Control (BFSC) and Multitask Gaussian Process Synthetic Control (MTGP) it is a Bayesian SC with a full posterior; unlike Bayesian Synthetic Control Methods (BSCM) (which shrinks donor weights from the data fit alone) its shrinkage is informed by an external covariate-and-distance utility. Reach for BPSCS specifically when the threat is distance-driven contamination of the controls.
Notation#
We use the synthetic-control canon. Let \(j = 1\) denote the treated unit and \(\mathcal{N}_0\) the donor pool over units \(\mathcal{N} \coloneqq \{1, \ldots, N\}\); time is \(t \in \mathcal{T} \coloneqq \{1, \ldots, T\}\) with the intervention after \(T_0\). Each unit carries baseline covariates \(\mathbf{X}_i\) and spatial coordinates \(\mathbf{P}_i\). BPSCS models the treated unit’s no-intervention outcome as an autoregressive linear synthetic control,
with an autoregressive coefficient \(\psi\), a treated-covariate effect \(\boldsymbol{\phi}\), and donor coefficients \(\boldsymbol{\beta}\) that play the role of synthetic-control weights but are unconstrained (they may be negative and need not sum to one).
Utility. Each donor’s coefficient is shrunk according to a utility that blends covariate similarity and spatial distance to the treated unit,
where \(\kappa_d \in [0,1]\) trades off the two terms and \(K\) is the maximum distance (a normalizer). With \(\kappa_d = 0\) only spatial distance matters (the paper’s emphasized spillover regime); with \(\kappa_d = 1\) only covariate similarity does.
Two priors. The utility scales a shrinkage prior on \(\beta_i\) in one of two
ways. The distance-horseshoe (dhs) gives a continuous shrinkage,
\(\lambda_i \sim \mathrm{Half\text{-}Cauchy}(0, u^C_i)\), so a low-utility
(spatially close) donor gets a small scale and is pulled toward zero. The
distance-spike-and-slab (ds2) applies a hard cutoff: donors with
\(u^C_i\) below an inclusion radius \(\rho\) (a quantile of the utility
scores) fall into a spike at zero, the rest into a diffuse slab.
Standardization. Outcomes are standardized on the pre-period and the estimated effect is returned on the treated unit’s original scale; covariates are z-scored across units before the utility is computed.
Counterfactual. The treated post-period outcomes are imputed by a free-running forward simulation of the fitted model, so their posterior is the no-intervention counterfactual \(\widehat{y}_{1t}^N\); the per-period effect is \(\tau_t \coloneqq y_{1t} - \widehat{y}_{1t}^N\). Point summaries use the posterior median, which is robust to the heavy upper tail the recursive simulation can produce (see the remark under Inference).
Assumptions#
Single treated unit, balanced panel with per-unit covariates and coordinates. One unit is treated after \(T_0\); every unit is observed at every period; each carries the covariate and coordinate columns named in the config.
Remark. The setup boundary (
mlsynth.utils.bpscs_helpers.setup) enforces the panel throughdataprep()and validates the covariate / coordinate columns, translating violations toMlsynthDataError/MlsynthConfigError.Spillover risk increases with proximity. A donor’s chance of being contaminated is a monotone function of its distance to the treated unit.
Remark. This is the paper’s core, minimal assumption – weaker than naming the contaminated donors, but it does require that distance is a sensible proxy for exposure. Where spillover is not distance-driven, the utility mis-targets the shrinkage and residual bias can remain.
A shared linear structure with autoregressive dynamics generates the no-intervention outcome. The treated series is a linear combination of donor outcomes plus its own lag and baseline covariates.
Remark. The unconstrained \(\boldsymbol{\beta}\) (unlike simplex SC) lets the treated unit sit outside the donors’ convex hull, at the usual cost that the counterfactual can extrapolate; the shrinkage prior guards against over-fitting when \(T_0\) is short.
Inference and diagnostics#
BPSCS is inferential by construction: res.inference.ci_lower /
res.inference.ci_upper give the ATT credible interval, and the counterfactual
band is on res.inference_detail (counterfactual_lower /
counterfactual_upper). The signed donor coefficients are surfaced as
res.weights.donor_weights (posterior medians), and the prior, the importance
weight \(\kappa_d\), the inclusion radius, and NUTS diagnostics
(nuts_accept_prob, nuts_divergences, max_rhat) are on
res.weights.summary_stats.
Remark on the point estimate. The counterfactual is a recursive forward simulation, so a posterior draw with a large autoregressive coefficient can make the imputed path explode; the posterior mean of the effect is therefore not a usable summary (a handful of draws dominate it). BPSCS reports the posterior median for the counterfactual and the ATT, which is stable to that tail; the credible band’s far edge is genuinely wide in the late post-period and is reported as such rather than papered over.
Example#
import pandas as pd
from mlsynth import BPSCS # requires: pip install 'mlsynth[bayes]'
# df has columns: unit, period, sales, treat (0/1), plus per-unit
# baseline covariates (income, density) and coordinates (lat, lon)
res = BPSCS({
"df": df, "outcome": "sales", "treat": "treat",
"unitid": "unit", "time": "period",
"covariates": ["income", "density"], "coords": ["lat", "lon"],
"prior": "dhs", "kappa_d": 0.0, # spatial-only shrinkage
"seed": 0, "display_graphs": False,
}).fit()
print(f"median post ATT : {res.att:+.3f}")
print(f"95% credible : [{res.inference.ci_lower:+.3f}, {res.inference.ci_upper:+.3f}]")
print(f"donors shrunk : {sum(abs(v) < 1e-3 for v in res.weights.donor_weights.values())}")
Set prior="ds2" for the hard-cutoff spike-and-slab, and raise kappa_d
toward 1 to weight covariate similarity over distance.
Verification#
BPSCS is cross-validated against the authors’ own reference: the Stan programs
(sc_dhs.stan / sc_ds2.stan) from their replication repository, run live
via rstan on the shipped Philadelphia beverage-tax example panel. Ported to
NumPyro, BPSCS matches the reference posterior credible band cell-for-cell – the
counterfactual band tracks with correlation \(0.997\)–\(0.9995\) for the
distance-horseshoe and \(0.969\)–\(0.998\) for the spike-and-slab, the
residual being Monte-Carlo error between two independent NUTS runs (amplified only
in the extreme lower tail of the free-running counterfactual, where the model is
numerically fragile). See the replication page BPSCS – Bayesian Penalized Synthetic Control under Spillovers (Fernandez-Morales et al. 2026). Because
the reference is GPL-licensed it is fetched and run at cross-check time rather
than redistributed; the durable self-contained case
benchmarks/cases/bpscs_synthetic.py checks effect recovery and distance-based
shrinkage on a simulated spatial panel. The estimator, config validation, the
missing-dependency guard, effect recovery, both priors, and the result contract
are unit-tested (mlsynth/tests/test_bpscs.py).
Core API#
Bayesian Penalized Synthetic Control under Spillovers (BPSCS).
Implements the utility-based shrinkage priors of:
Fernandez-Morales, E., Oganisian, A., & Lee, Y. (2026). “Bayesian shrinkage priors for penalized synthetic control estimators in the presence of spillovers.” Biometrics 82(2), ujag054.
BPSCS models the treated unit’s no-intervention outcome with an autoregressive
linear synthetic control whose donor coefficients carry a shrinkage prior. The
prior scale for each donor is set by a utility blending covariate similarity and
spatial distance to the treated unit, so spatially close (likely spillover-
contaminated) donors are shrunk toward zero rather than excluded. Two priors are
offered: dhs (distance-horseshoe) and ds2 (distance-spike-and-slab). The
counterfactual is a free-running forward simulation of the treated series; its
posterior gives a credible band that widens through the post-period. Point
summaries use the posterior median (robust to the free-running tail). The
posterior is drawn with NUTS (NumPyro, double precision), so BPSCS needs the
[bayes] optional dependency.
See mlsynth.utils.bpscs_helpers for the algorithmic pieces.
- class mlsynth.estimators.bpscs.BPSCS(config: BPSCSConfig | dict)#
Bases:
objectBayesian Penalized Synthetic Control under Spillovers (Fernandez-Morales et al. 2026).
- Parameters:
config (BPSCSConfig or dict) – Configuration object. See
mlsynth.config_models.BPSCSConfig.- Returns:
BPSCSResults – Posterior counterfactual with a credible band, the ATT posterior median + credible interval, the (signed) donor coefficients, and NUTS diagnostics. Requires
mlsynth[bayes].
- fit() BPSCSResults#
Run the BPSCS NUTS sampler and assemble results.
Configuration#
- class mlsynth.config_models.BPSCSConfig(*, df: ~pandas.DataFrame, outcome: str, treat: str, unitid: str, time: str, display_graphs: bool = False, save: bool | str = False, counterfactual_color: ~typing.List[str] = <factory>, treated_color: str = 'black', plot: ~mlsynth.config_models.PlotConfig = <factory>, covariates: ~typing.Annotated[~typing.List[str], ~annotated_types.MinLen(min_length=1)], coords: ~typing.Annotated[~typing.List[str], ~annotated_types.MinLen(min_length=1)], prior: ~typing.Literal['dhs', 'ds2'] = 'dhs', kappa_d: ~typing.Annotated[float, ~annotated_types.Ge(ge=0.0), ~annotated_types.Le(le=1.0)] = 0.0, inclusion_quantile: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.25, n_warmup: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 2000, n_samples: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 1000, n_chains: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 4, target_accept: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.95, max_tree_depth: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 12, ci_alpha: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.05, seed: int = 0, verbose: bool = False)#
Configuration for Bayesian Penalized Synthetic Control under Spillovers (BPSCS).
Implements the utility-based shrinkage priors of:
Fernandez-Morales, E., Oganisian, A., & Lee, Y. (2026). “Bayesian shrinkage priors for penalized synthetic control estimators in the presence of spillovers.” Biometrics 82(2), ujag054.
BPSCS models the treated unit’s no-intervention outcome with an autoregressive linear synthetic control whose donor coefficients carry a shrinkage prior. The prior scale for each donor is set by a utility that blends covariate similarity and spatial distance to the treated unit, so donors that are spatially close – and therefore likely contaminated by spillovers – are shrunk toward zero rather than excluded outright. Two priors are available:
dhs(distance-horseshoe, continuous shrinkage) andds2(distance-spike-and-slab, a hard utility cutoff). The posterior is drawn with NUTS (NumPyro, in double precision), so this estimator requires the[bayes]optional dependency (pip install mlsynth[bayes]).- Parameters:
covariates (list of str) – Baseline (time-invariant) covariate columns used for the covariate similarity term of the utility. At least one is required.
coords (list of str) – Coordinate columns (e.g.
["lat", "lon"]) giving each unit’s spatial location; the utility’s distance term uses the Euclidean distance from each donor to the treated unit. At least one is required.prior ({“dhs”, “ds2”}) – Shrinkage prior.
dhsis the distance-horseshoe (continuous);ds2is the distance-spike-and-slab (hard cutoff at the inclusion radius).kappa_d (float) – Importance weight in [0, 1] trading off covariate similarity against spatial distance in the utility.
0uses spatial distance only (the paper’s emphasized spillover regime);1uses covariate similarity only.inclusion_quantile (float) – For
ds2, the quantile of the utility scores that sets the inclusion radius; donors below it fall in the spike (excluded). Paper default 0.25.n_warmup (int) – NUTS warm-up (adaptation) iterations per chain.
n_samples (int) – Retained NUTS samples per chain.
n_chains (int) – Number of NUTS chains.
target_accept (float) – NUTS target acceptance probability. The paper uses 0.995; the default here (0.95) trades a little robustness for speed.
max_tree_depth (int) – NUTS maximum tree depth (paper 15; default 12 here).
ci_alpha (float) – Two-sided level for the credible interval (0.05 -> 95% band).
seed (int) – PRNG seed (makes a fit reproducible).
display_graphs (bool) – Plot the observed-vs-counterfactual path with the credible band.
verbose (bool) – Show the NUTS progress bar.
- model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid'}#
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- prior: Literal['dhs', 'ds2']#
Result Containers#
BPSCS.fit() returns a
BPSCSResults – an EffectResult
whose standardized sub-models carry the median ATT, the counterfactual and gap
paths, the credible interval, and the signed donor coefficients, with the
posterior draws and NUTS diagnostics on res.posterior.