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:
You want an interpretable set of donor weights – BFSC is a factor model and reports no donor weights. Use a weighting method (Vanilla Synthetic Control (VanillaSC), Synthetic Regressing Control (SRC)) or the Bayesian weighting Bayesian Synthetic Control Methods (BSCM).
You need a dependency-free estimate. BFSC draws its posterior with NUTS (NumPyro), so it needs the
[bayes]optional dependency (pip install 'mlsynth[bayes]'). For a dependency-free Bayesian SC use Bayesian Synthetic Control Methods (BSCM) (a pure-numpy Gibbs sampler over donor weights).You want a frequentist factor-model SC. Use Factor Model Approach (FMA) (Li & Sonnier) or the generalized-SC family.
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.
Bayesian Synthetic Control Methods (BSCM) (Kim, Lee and Gupta) – shrinkage (horseshoe or spike-and-slab) on unconstrained donor weights; a pure-numpy Gibbs sampler, and it reports donor weights.
Bayesian Synthetic Control with a Soft Simplex Constraint (BVS-SS) (Xu and Zhou) – spike-and-slab donor selection on a soft simplex whose tightness is learned; a pure-numpy Metropolis-within-Gibbs sampler, and it reports donor weights and inclusion probabilities.
Bayesian Factor Synthetic Control (BFSC) (Pinkney) – a Bayesian latent-factor model, not a donor weighting; NUTS through the
[bayes]optional dependency, and it reports a counterfactual credible band and no donor 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,
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#
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 throughdataprep(), translating a violation toMlsynthDataError.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.
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_stats –
nuts_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:
objectBayesian 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 matrixFestimated 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.
- model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid'}#
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
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.