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:

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,

\[y_{1t}^N = \psi\, y_{1,t-1}^N + \mathbf{X}_1^{\top}\boldsymbol{\phi} + \sum_{i\in\mathcal{N}_0} \beta_i\, y_{it} + u_{t}, \qquad u_t \sim \mathcal{N}(0, \sigma^2),\]

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,

\[u^C_{i} = \kappa_d\, \frac{1}{1 + \lVert \mathbf{X}_i - \mathbf{X}_1 \rVert} + (1 - \kappa_d)\, \frac{\lVert \mathbf{P}_i - \mathbf{P}_1 \rVert}{K},\]

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#

  1. 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 through dataprep() and validates the covariate / coordinate columns, translating violations to MlsynthDataError / MlsynthConfigError.

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

  3. 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: object

Bayesian 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) and ds2 (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. dhs is the distance-horseshoe (continuous); ds2 is 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. 0 uses spatial distance only (the paper’s emphasized spillover regime); 1 uses 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.

ci_alpha: float#
coords: List[str]#
covariates: List[str]#
display_graphs: bool#
inclusion_quantile: float#
kappa_d: float#
max_tree_depth: int#
model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

n_chains: int#
n_samples: int#
n_warmup: int#
prior: Literal['dhs', 'ds2']#
seed: int#
target_accept: float#
verbose: bool#

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.