Bayesian Factor Synthetic Control (BFSC)#

When to use BFSC – and when not to#

Bayesian Factor Synthetic Control (Pinkney 2021) fits the no-intervention outcome of every unit with a Bayesian latent factor model and reads the treated unit’s counterfactual off the posterior. Reach for it when you have a panel with a clear common structure – seasonality, shared macro or market shocks – and you want honest uncertainty on the effect rather than a single line, and you would rather not commit in advance to a number of factors. It is a good choice when:

  • You want a full posterior credible band on the counterfactual and the ATT, propagating the uncertainty in the factors themselves, not just a point path.

  • The donor pool shares latent factors with the treated unit (a factor / interactive fixed-effects structure), so a weighted average of donors is the wrong model but a shared low-rank term is the right one.

  • You do not want to pick the number of factors by hand: a horseshoe+ prior on the loadings prunes the factors the data do not support, so the factor count is a soft upper bound.

Do not use BFSC when:

The Bayesian SC family#

mlsynth carries three Bayesian synthetic-control estimators; they differ in what they place a prior on, and BFSC is the one that models the outcome rather than the weights.

Reach for a weighting prior (Bayesian Synthetic Control Methods (BSCM), Bayesian Synthetic Control with a Soft Simplex Constraint (BVS-SS)) when you want interpretable donor weights; reach for BFSC when a shared factor structure – not a weighted average of donors – is the right model for the untreated outcome.

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\), pre-period \(\mathcal{T}_1\) and post-period \(\mathcal{T}_2\). BFSC models the no-intervention outcome with the canon’s linear factor model plus additive effects,

\[y_{jt}^N = \mathbf{f}_t^{\top}\boldsymbol{\lambda}_j + \delta_t + \kappa_j + u_{jt}, \qquad u_{jt} \sim \mathcal{N}(0, \sigma^2),\]

with latent factors \(\mathbf{f}_t \in \mathbb{R}^{L}\) (the rows of a \(T\times L\) factor matrix \(\mathbf{F}\)), unit loadings \(\boldsymbol{\lambda}_j\) (the paper’s \(\boldsymbol{\beta}_j\)), a time effect \(\delta_t\), and a unit effect \(\kappa_j\). Unlike a frequentist factor SC, \(\mathbf{F}\) and the loadings are estimated jointly in one Bayesian model; \(\mathbf{F}\) is fixed to lower-trapezoidal (\(f_{tl} = 0\) for \(l > t\)) for identification.

Standardization. Each series is centered on its last pre-period value and scaled by its pre-period standard deviation before fitting; the paper credits this pre-processing for the bulk of its sampling-efficiency gain. No post-period information enters the scaling, so it cannot bias the treated estimate.

Shrinkage. The loadings carry a horseshoe+ prior (Bhadra et al. 2017): \(\lambda_{lj}\) is a product of a per-factor, a global, and a per-series half-Cauchy scale, so factors the data do not need are pruned. This is why the factor count \(L\) is an upper bound rather than a choice.

Counterfactual. The treated unit’s post-period outcomes are masked – treated as missing data and imputed by the model, which contains no treatment-effect parameter – so their posterior is the no-intervention counterfactual \(\widehat{y}_{1t}^N\). The donors contribute all periods, pinning the factors post-treatment; the treated loadings, fit on the pre-period, project that structure forward. The per-period effect is \(\tau_t \coloneqq y_{1t} - \widehat{y}_{1t}^N\) and the ATT is \(\widehat{\tau} \coloneqq T_2^{-1}\sum_{t\in\mathcal{T}_2}\tau_t\), each with a full posterior credible band.

Assumptions#

  1. Single treated unit, balanced panel. One unit is treated after \(T_0\); every unit is observed at every period.

    Remark. The setup boundary (mlsynth.utils.bfsc_helpers.setup) enforces both through dataprep(), translating a violation to MlsynthDataError.

  2. A shared factor structure generates the no-intervention outcomes. Donors and the treated unit load on the same latent factors \(\mathbf{f}_t\).

    Remark. This is the interactive-fixed-effects assumption, weaker than the convex-hull requirement of simplex SC: the treated unit need not lie inside the donors’ hull, only inside their factor span. If the treated loadings fall far outside the donors’, the post-period counterfactual is an extrapolation and the credible band widens to say so.

  3. Homoskedastic idiosyncratic noise. \(u_{jt} \sim \mathcal{N}(0,\sigma^2)\) with a single \(\sigma\) across units and time.

    Remark. The paper notes a series- or time-varying \(\sigma\) is possible but hurts sampling; the scalar \(\sigma\) is the practical default.

Inference and diagnostics#

BFSC 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). Because the factors are sampled rather than plugged in, the band includes factor uncertainty. NUTS diagnostics are surfaced on res.weights.summary_statsnuts_accept_prob, nuts_divergences, max_rhat. Read convergence on the counterfactual and \(\sigma\) (the identified quantities), not on the raw loadings: a factor model is rotation/sign non-identified, so individual loadings need not mix while the reported counterfactual does. As a placebo check, refit with each donor as the pseudo-treated unit and compare the treated band to the placebo distribution.

Example#

import pandas as pd
from mlsynth import BFSC   # requires: pip install 'mlsynth[bayes]'

df = pd.read_csv(
    "https://raw.githubusercontent.com/jgreathouse9/mlsynth/main/"
    "basedata/german_reunification.csv")
df["treat"] = df["Reunification"].astype(int)

res = BFSC({
    "df": df, "outcome": "gdp", "treat": "treat",
    "unitid": "country", "time": "year",
    "n_factors": 8, "seed": 0, "display_graphs": False,
}).fit()

print(f"mean post ATT   : {res.att:+.0f}")
print(f"95% credible    : [{res.inference.ci_lower:+.0f}, {res.inference.ci_upper:+.0f}]")
print(f"NUTS max r-hat  : {res.weights.summary_stats['max_rhat']:.3f}")

On West Germany this recovers the classical reunification result – a mean post-1990 effect near \(-1600\) PPP-USD per capita, statistically indistinguishable from traditional synthetic control through the 1990s and a somewhat larger effect after 2000 – now with a credible band that widens through the post-period.

Verification#

The BFSC model is cross-validated against the author’s own reference: the appendix Stan program of Pinkney (2021), run on the German reunification panel. The NumPyro posterior counterfactual matches the Stan posterior cell-for-cell – correlation \(0.999999\), maximum discrepancy \(0.48\%\) of the outcome level, and \(\widehat{\sigma}\) to four decimals – the residual being pure Monte-Carlo error between two independent NUTS runs. See the replication page BFSC – Bayesian Factor Synthetic Control (Pinkney 2021) and the durable case benchmarks/cases/bfsc_germany.py. The estimator, config validation, the missing-dependency guard, effect recovery, and the result contract are unit-tested (mlsynth/tests/test_bfsc.py).

Core API#

Bayesian Factor Synthetic Control (BFSC).

Implements:

Pinkney, S. (2021). “An Improved and Extended Bayesian Synthetic Control.” arXiv:2103.16244.

BFSC models the no-intervention outcome of every unit by a Bayesian latent factor model with year and unit effects and a horseshoe+ prior on the loadings (so the factor count is a soft upper bound). The treated unit’s post-period outcomes are masked and imputed; the posterior of that imputation is the counterfactual, giving the treatment effect a full credible band. The posterior is drawn with NUTS (NumPyro), so BFSC needs the [bayes] optional dependency.

See mlsynth.utils.bfsc_helpers for the algorithmic pieces.

class mlsynth.estimators.bfsc.BFSC(config: BFSCConfig | dict)#

Bases: object

Bayesian Factor Synthetic Control (Pinkney 2021).

Parameters:

config (BFSCConfig or dict) – Configuration object. See mlsynth.config_models.BFSCConfig.

Returns:

BFSCResults – Posterior counterfactual with a credible band, the ATT posterior mean + credible interval, and NUTS diagnostics. Requires mlsynth[bayes].

fit() BFSCResults#

Run the BFSC NUTS sampler and assemble results.

Configuration#

class mlsynth.config_models.BFSCConfig(*, 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>, n_factors: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 8, n_warmup: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 500, n_samples: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 500, 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, 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 Factor Synthetic Control (BFSC).

Implements:

Pinkney, S. (2021). “An Improved and Extended Bayesian Synthetic Control.” arXiv:2103.16244.

BFSC models the no-intervention outcome of every unit by a Bayesian latent factor model – y_jt = F_t . beta_j + delta_t + kappa_j + e_jt – with the factor matrix F estimated jointly with its loadings, a horseshoe+ prior on the loadings that shrinks unused factors (so the factor count is a soft upper bound, not a hard choice), and year (delta) and unit (kappa) effects. The treated unit’s post-intervention outcomes are masked and imputed, so the posterior of that imputation is the counterfactual; the treatment effect follows with a full credible band. The posterior is drawn with NUTS (NumPyro), so this estimator requires the [bayes] optional dependency (pip install mlsynth[bayes]).

Parameters:
  • n_factors (int) – Number of latent factors L (an upper bound: the horseshoe+ prior prunes the ones the data do not support). Paper default 8.

  • 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 heavy-tailed horseshoe+ wants a high value; 0.95 by default.

  • 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#
display_graphs: bool#
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_factors: int#
n_samples: int#
n_warmup: int#
seed: int#
target_accept: float#
verbose: bool#

Result Containers#

BFSC.fit() returns a BFSCResults – an EffectResult whose standardized sub-models carry the ATT, the counterfactual and gap paths, and the credible interval, with the posterior draws and NUTS diagnostics on res.posterior.