Many-outcomes Synthetic Control (MOSC)

Contents

Many-outcomes Synthetic Control (MOSC)#

When to Use This Estimator#

MOSC implements the many-outcomes estimator of Wang, Schein, Shou and Blei [MOSC]. It answers a question the rest of the library mostly leaves alone: what if the outcome is not the sort of thing a Gaussian model describes?

Almost every synthetic-control method fits an average, and fitting an average well is a Gaussian assumption whether or not anyone writes it down. Match a treated unit’s pre-treatment path by least squares and you have assumed the errors are symmetric, unbounded in both directions, and equally variable however large the outcome is. For a count that is often false. Case counts, crime incidents by block, insurance claims, conversions by market: these are non-negative, they are integers, they cannot go below zero, and their spread grows with their level. A method that assumes otherwise can hand back a counterfactual with negative cases in it.

MOSC lets you choose the likelihood instead. It fits a probabilistic factor model to the pre-intervention panel, takes each unit’s estimated latent loadings, and adjusts for those loadings the way one would adjust for an observed confounder. For a count outcome the factor model can be gamma-Poisson; for a real-valued one it can be probabilistic PCA. The rest of the procedure does not change.

The justification is what makes this legitimate, and it is the paper’s real contribution. Synthetic control is normally defended by assuming the untreated outcomes follow a linear factor model. MOSC drops linearity and argues from negative control outcomes instead – variables known in advance to be unaffected by the intervention. In a panel almost everything is one: every pre-intervention observation, and every observation on an untreated unit. If some per-unit latent variable renders a unit’s outcomes conditionally independent, and enough negative controls are observed to pin that variable down, the effect is identified with no linearity anywhere.

Reach for MOSC when the panel is wide (many units observed at a common intervention date), the pre-period is long, and the outcome’s distribution is the thing your other options are getting wrong.

Do not use MOSC when#

Notation#

The outcome panel is the \(T \times N\) matrix \(\mathbf{Y} = (y_{ti})\) over periods \(t \in \mathcal{T} \coloneqq \{1, \ldots, T\}\) and units \(i \in \mathcal{N} \coloneqq \{1, \ldots, N\}\). The intervention occurs after period \(T_0\), so \(t \leq T_0\) indexes the pre-intervention period and \(t > T_0\) the post-intervention period. Unit \(1\) is the treated unit and \(A_i \in \{0, 1\}\) records treatment status.

Write \(\overleftarrow{\mathbf{Y}}_i\) for unit \(i\)’s pre-intervention outcomes and \(\overrightarrow{\mathbf{Y}}_i\) for its post-intervention outcomes. Potential outcomes are \(y_{ti}(0)\) and \(y_{ti}(1)\), and the estimand is the average effect on the treated unit over the post-intervention period,

\[\mathrm{ATT} = \frac{1}{T - T_0} \sum_{t > T_0} \bigl[ y_{t1}(1) - y_{t1}(0) \bigr].\]

The latent per-unit variable the method adjusts for is \(\mathbf{U}_i\), and \(\mathbf{Z}_i \in \mathbb{R}^{K}\) is the estimate of it that the factor model returns. The two carry different symbols on purpose: a factor model is identified only up to an invertible transformation, so \(\mathbf{Z}_i\) is not \(\mathbf{U}_i\) measured with error but a different coordinate system for the same information.

The factor model itself has per-period factors \(\boldsymbol{\theta}_t \in \mathbb{R}^{K}\), and \(K\) is its rank.

Emancipators and negative controls#

The paper’s argument turns on one definition. A latent variable \(\mathbf{U}_i\) emancipates unit \(i\)’s outcomes when conditioning on it makes them independent of one another:

\[P\bigl(\overleftarrow{\mathbf{Y}}_i, \overrightarrow{\mathbf{Y}}_i \mid \mathbf{U}_i\bigr) = \prod_{t \leq T_0} P(y_{ti} \mid \mathbf{U}_i) \prod_{t > T_0} P(y_{ti} \mid \mathbf{U}_i).\]

The word does the work of “this variable is everything the unit’s outcomes have in common”. Anything shared across a unit’s periods is inside \(\mathbf{U}_i\); what is left is independent noise.

That has a consequence the paper draws out. A confounder that shows up in both the pre- and post-intervention periods – a multi-period confounder – induces dependence between outcomes on both sides of the intervention. If \(\mathbf{U}_i\) missed one, conditioning on \(\mathbf{U}_i\) would not have removed that dependence, contradicting the definition. So an emancipator captures every multi-period confounder automatically. What it cannot capture is a confounder that touches only one period, which is why those must be observed.

Assumptions and remarks#

Assumption 1 (SUTVA). No interference between units, and one version of the treatment. Remark. Standard, and the reason spillover-contaminated donors break the estimate. If neighbouring units respond to the intervention, use Spillover-Aware Synthetic Control (SPILLSYNTH) or screen donors with Spillover-Detecting Synthetic Control (SPOTSYNTH) first.

Assumption 2 (a determinable-in-theory emancipator). The outcomes follow some factor model whose latent variable emancipates them and is pinned down by the negative control outcomes. Remark. This replaces the linear factor model that normally justifies synthetic control, and it is strictly weaker in the functional form it allows and stronger in what it asks of the data. “Pinned down” holds exactly only as the number of pre-intervention periods goes to infinity, which the authors say plainly. On a panel of thirty periods it holds approximately, and how well is not something the estimate reveals.

Assumption 3 (weak unconfoundedness). Treatment is independent of the potential outcomes given \(\mathbf{U}_i\) and any observed covariates. Remark. In substance this is “no unobserved single-period confounders”. Anything that moved treatment and touched only the pre-period, or only the post-period, has to be observed, because an emancipator cannot see it.

Assumption 4 (the estimate is consistent). The fitted \(\mathbf{Z}_i\) recovers \(\mathbf{U}_i\) up to an invertible transformation. Remark. This is where a misspecified factor model does its damage. Nothing downstream can detect it, which is why the fit is scored and the score reported.

Assumption 5 (overlap). Every value of the covariates and the emancipator occurs with positive probability under both treatment states. Remark. Unusually mild here. \(\mathbf{Z}_i\) is estimated from pre-treatment outcomes alone and never touches the treatment assignment, so estimating it does not erode the overlap it needs.

What this asks for, against what robust synthetic control asks for#

The estimator MOSC is measured against throughout its own paper is robust synthetic control [RSC], which is Cluster Synthetic Controls (CLUSTERSC)’s PCR family here. Their identification strategies differ in kind, and the difference decides which one belongs on a given panel.

rSC is not the linear method it is sometimes described as. Its model is \(M_{it} = f(\theta_i, \rho_t)\) for an arbitrary latent function \(f\) – a latent variable model in the sense of Aldous, Hoover and Chatterjee – which the authors note “subsumes popular econometric factor models, such as the one presented in Abadie et al. (2010), as a special case with \(f\) a bilinear function”. Nonlinearity in the generating process is already there.

What rSC does require, in one specific place, is that the treated unit’s pre-intervention mean vector lie in the linear span of the donors’ mean vectors: \(M^{-}_1 = (M^{-})^{\top} \beta^{*}\). And it supplies the condition under which that relationship survives the intervention, which is its sharpest contribution: if the span holds before and \(\mathrm{rank}(M^{-}) = \mathrm{rank}(M)\), then the same \(\beta^{*}\) holds after. The authors observe that this step “has been amiss in the literature, potentially implicitly believed or assumed starting in the work by Abadie and Gardeazabal (2003)” – classical synthetic control never said why pre-period weights should continue to hold post-period.

Set against each other:

robust synthetic control

MOSC

generating model

\(f(\theta_i, \rho_t)\), \(f\) arbitrary

any factor model whose latent emancipates

identifying assumption

treated mean in the linear span of donor means

\(Y(a) \perp A \mid U, X\)

what carries pre to post

\(\mathrm{rank}(M^{-}) = \mathrm{rank}(M)\)

the same adjustment formula at every period

treatment assignment

never modelled

must be ignorable given the recovered latent

noise

mean zero, finite, one common variance

whatever likelihood you choose

missing data

imputed by singular value thresholding

refused at ingestion

finite-sample theory

yes, with an explicit error bound

none; identification only, and asymptotic

The row that matters most is treatment assignment. rSC asks nothing at all about how treatment came about. MOSC asks for ignorability given a latent it recovered from the outcomes, which is a much stronger claim about the world – and synthetic control is usually reached for precisely where assignment is visibly not ignorable.

So the likelihood argument for MOSC has to be made carefully, because rSC’s consistency theory is distribution-free in the noise: it asks only for mean zero and finite variance. What it does assume is a single noise variance \(\sigma^2\) shared by every unit and period, and its thresholding rule and its rescaling of the panel to \([-1, 1]\) are both calibrated on that one number. A count panel has no such number, since a Poisson variance equals its mean. That, and not linearity, is where a count outcome breaks rSC.

The practical reading. If the span-and-rank story is credible – and on a panel of similar units it usually is – rSC asks less, carries an error bound, and handles missing cells, so reach for Cluster Synthetic Controls (CLUSTERSC). Reach for MOSC when the outcome’s distribution is the thing going wrong. And run the placebo check under either: fit the method to a unit you know was untreated and see whether it finds an effect.

How the estimate is computed#

Three stages, following the paper’s Algorithm 1.

First, fit a probabilistic factor model to the pre-intervention panel. Two are available through factor_model:

\[\text{gamma-Poisson:} \quad \theta_{tk} \sim \mathrm{Gamma}(a, b), \quad Z_{ki} \sim \mathrm{Gamma}(a, b), \quad y_{ti} \mid \mathbf{Z}_i, \boldsymbol{\theta}_t \sim \mathrm{Poisson}\bigl(\boldsymbol{\theta}_t^{\top} \mathbf{Z}_i\bigr),\]
\[\text{PPCA:} \quad y_{ti} \mid \mathbf{Z}_i, \boldsymbol{\theta}_t \sim \mathcal{N}\bigl(\boldsymbol{\theta}_t^{\top} \mathbf{Z}_i + \mu_t, \sigma^2\bigr).\]

The gamma-Poisson posterior is drawn by a conjugate Gibbs sampler. The augmentation that makes it conjugate splits each observed count across the \(K\) components with a multinomial draw, after which both factor matrices are Gamma again. That is why MOSC needs no sampler dependency: no NumPyro, no [bayes] extra, and a fit measured in seconds. PPCA is fit by the classical Tipping-Bishop EM.

Second, for each posterior draw \(s\), regress every unit’s post-intervention outcome vector on that draw’s loadings and a treatment indicator,

\[\mathbb{E}\bigl[\overrightarrow{\mathbf{Y}}_i \mid A_i = a, \mathbf{Z}_i\bigr] = \beta_0 + \beta_A a + \boldsymbol{\beta}_Z^{\top} \mathbf{Z}_i^{(s)},\]

by cross-validated ridge, and read off the treated unit’s fitted row with the indicator set to zero. That prediction is the counterfactual under draw \(s\). Each draw gets its own regression, not one regression on averaged loadings, because a factor model is identified only up to relabelling and the average of the draws need not be a configuration any draw took.

Third, the effect is the observed path minus the counterfactual,

\[\widehat{\mathrm{ATT}}^{(s)} = \frac{1}{T - T_0} \sum_{t > T_0} \bigl[ y_{t1} - \widehat{f}^{(s)}_{t1}(0) \bigr],\]

and the interval around it is formed as the next section describes.

Inference and diagnostics#

Uncertainty is the paper’s Section 3.4 procedure: a nonparametric bootstrap over units. Each replicate draws a donor pool with replacement, re-runs the algorithm on it, and contributes one counterfactual; the interval is the pointwise percentile range across replicates at ci_alpha. This is what Theorem 4 asks for, since the g-formula’s outer expectation is over the distribution of loadings among the treated and its sampling uncertainty comes from having observed these units and not others. The treated unit is held in every replicate: read literally the paper resamples all units, which can drop the one whose counterfactual is the estimand.

inference="posterior" returns instead the spread of the factor model’s own draws. That is what the paper’s Figures 4 and 5 plot, and it is a band on the counterfactual’s conditional mean – it moves only with uncertainty about \(\mathbf{Z}_i\) and conditions on the units that happened to be observed, so it is systematically narrower.

The difference is not academic. The paper says it will evaluate the coverage of its bootstrap in Section 5; that evaluation does not appear, and the word “bootstrap” occurs three times in the paper, twice in the paragraph that prescribes it and once in the bibliography. Running the check on the authors’ own control teams – the twelve whose stadiums never admitted fans, where the effect is zero by construction – gives the following coverage of a nominal 95 percent interval over ten panels:

interval

covers zero

posterior band (what the paper’s figures show)

4 / 10

unit bootstrap, percentile (the default here)

9 / 10

Two things follow for a reader. The posterior band should not be read as a confidence interval; it is not one, and on these panels it excludes zero six times out of ten where nothing happened. And even the bootstrap does not reach nominal. Its one failure, Minnesota, has a point estimate that misses by 21 percent of the outcome, which is a counterfactual that is wrong and not an interval that is narrow. No inference procedure repairs that, and it is the reason the placebo check belongs in any applied use of this estimator: run the method on a unit you know was untreated, and see whether it finds an effect.

Three diagnostics arrive as typed fields on MOSCDiagnostics, because each is something a reader might act on.

heldout_log_density scores the factor model on cells withheld from the fit. Higher is better, and it is a score with no size guarantee attached: it compares two candidate models on the same panel and says nothing about whether either is adequate in absolute terms. Use it to choose factor_model and n_factors.

residual_autocorrelation is the diagnostic that decides outcome_scale. Assumption 2 requires the latent factors to render a unit’s outcomes conditionally independent, so what has to be near zero is the correlation remaining after conditioning on the fitted factors. A cumulative series fails this badly and its first difference does not. If this reads far from zero, set outcome_scale="difference": the factor model is then fit to first differences, and the counterfactual is re-integrated so it still comes back on the outcome’s own scale.

pearson_dispersion is 1 under a well-specified Poisson model. It moves in both directions – an overdispersed panel pushes it above 1, and a smooth series that a rank-\(K\) model fits almost exactly pushes it below. Read a large departure in either direction as the count assumption doing badly.

Example#

import numpy as np
import pandas as pd
from mlsynth import MOSC

rng = np.random.default_rng(0)
n_units, n_periods, pre = 24, 40, 28
factors = rng.gamma(3.0, 3.0, size=(n_periods, 3))
loadings = rng.gamma(3.0, 3.0, size=(3, n_units))
rate = factors @ loadings
rate[pre:, 0] *= 1.4                       # the treated unit's effect
counts = rng.poisson(rate)

panel = pd.DataFrame([
    {"county": f"c{i:02d}", "day": t, "cases": float(counts[t, i]),
     "reopened": int(i == 0 and t >= pre)}
    for i in range(n_units) for t in range(n_periods)
])

result = MOSC({
    "df": panel, "outcome": "cases", "treat": "reopened",
    "unitid": "county", "time": "day",
    "factor_model": "gap", "n_factors": 3, "seed": 0,
}).fit()

print(result.att, result.att_ci)
print(result.diagnostics.residual_autocorrelation)
print(result.donor_weights)          # {} -- MOSC has none, and says so

The empty donor_weights is a statement, not an omission. Every estimator in this library populates the same weights container, and {} records that this one was asked and has no donor weights to give, which None would leave ambiguous.

To see the posterior band, ask the plotter for its figure and display it yourself:

from mlsynth.utils.mosc_helpers import plot_mosc_posterior

figure = plot_mosc_posterior(result)
figure.savefig("mosc.png", dpi=150)

Verification#

MOSC was assessed by a demonstrate-first spike before it was built, recorded in benchmarks/reference/mosc_spike/ and in agents/future_integrations.md §21. The spike ported the authors’ own code, reproduced their semi-synthetic study across 48 cells, and compared the result against Cluster Synthetic Controls (CLUSTERSC)’s robust synthetic control on identical panels.

The paper’s substantive claim reproduces: the gamma-Poisson arm beats both Gaussian arms on mean relative error, at 25 pre-intervention periods as well as 100. Its margin over robust synthetic control is narrower than the paper’s own figure suggests – the Poisson arm wins 29 of 48 cells – and the advantage concentrates where the factor model is violated, not where it holds.

Three deviations from the paper are deliberate, each established by the spike.

The effect takes the sign of the paper’s own equation, observed minus counterfactual. The authors’ code computes the reverse; on their null result the difference is invisible, and on any real effect it returns the wrong sign.

The paper’s p_pop model check is not offered. Its stated false rejection rate is 0.05; measured on data drawn from the very model being checked, it is 0.40. The statistic sums a discrepancy over held-out cells, so its systematic part grows with the cell count while its spread grows with the square root, and past roughly a hundred cells the verdict stops depending on the data. heldout_log_density is the same comparison reported as a score, which makes no calibration claim and so cannot make a false one.

The lagged pre-intervention outcome that the authors’ code adds to every regression behind their published figure is absent here. It appears nowhere in their equations, and the baseline it is compared against gets no equivalent term.

[MOSC]

Wang, Y., Schein, A., Shou, J., & Blei, D. M. A Many-outcomes Perspective on the Synthetic Control Method.

[RSC]

Amjad, M., Shah, D., & Shen, D. (2018). Robust Synthetic Control. Journal of Machine Learning Research 19(22), 1-51.

Core API#

class mlsynth.MOSC(config: MOSCConfig | dict)#

Bases: object

Many-outcomes synthetic control (Wang, Schein, Shou & Blei).

Parameters:

config (MOSCConfig or dict) – Configuration. See mlsynth.config_models.MOSCConfig.

Returns:

MOSCResults – An EffectResult carrying the posterior-mean counterfactual with an interval around it, the ATT and its interval, the draws of the estimated confounding structure, and the diagnostics that say whether the factor model’s assumptions hold on this panel.

Examples

>>> from mlsynth import MOSC
>>> cfg = {"df": panel, "outcome": "cases", "treat": "reopened",
...        "unitid": "county", "time": "date", "factor_model": "gap"}
>>> res = MOSC(cfg).fit()
>>> res.att, res.diagnostics.pearson_dispersion
fit() MOSCResults#

Fit the factor model, adjust for its loadings, and report the effect.

Configuration#

class mlsynth.utils.mosc_helpers.config.MOSCConfig(*, df: ~pandas.DataFrame, outcome: str, treat: str, unitid: str, time: str, display_graphs: bool = True, save: bool | str = False, counterfactual_color: ~typing.List[str] = <factory>, treated_color: str = 'black', plot: ~mlsynth.config_models.PlotConfig = <factory>, factor_model: ~typing.Literal['gap', 'ppca'] = 'gap', n_factors: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 10, outcome_scale: ~typing.Literal['level', 'difference'] = 'level', n_samples: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 200, n_warmup: ~typing.Annotated[int, ~annotated_types.Ge(ge=0)] = 200, ridge_alphas: ~typing.Tuple[float, ...] = (0.0, 0.0001, 0.001, 0.01), heldout_fraction: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.1, inference: ~typing.Literal['bootstrap', 'posterior'] = 'bootstrap', n_bootstrap: ~typing.Annotated[int, ~annotated_types.Ge(ge=2)] = 200, ci_alpha: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.05, seed: int = 0)#

Bases: BaseEstimatorConfig

Configuration for many-outcomes synthetic control (MOSC).

Implements:

Wang, Y., Schein, A., Shou, J., & Blei, D. M. “A Many-outcomes Perspective on the Synthetic Control Method.”

MOSC justifies synthetic control through negative control outcomes instead of a linear factor model. A probabilistic factor model is fit to the pre-intervention panel; the per-unit latent loadings it returns are the estimated confounding structure, and they enter a downstream outcome regression as though they were observed confounders. Choosing the likelihood is the point: a count panel gets a gamma-Poisson model where the rest of the library’s factor estimators assume a Gaussian one.

The working set is three fields. factor_model picks the likelihood, n_factors its rank, and outcome_scale whether the panel is modelled as given or in first differences. The rest carry defaults that most fits leave alone.

Parameters:
  • factor_model ({“gap”, “ppca”}) – Likelihood for the factor model. "gap" is the gamma-Poisson model of the paper’s case study, drawn by conjugate Gibbs; it needs a non-negative outcome. "ppca" is probabilistic PCA (Tipping & Bishop 1999), the Gaussian arm, and admits any real outcome.

  • n_factors (int) – Rank K of the factor model. Must be smaller than both the number of units and the number of pre-intervention periods.

  • outcome_scale ({“level”, “difference”}) – Whether the factor model sees the outcome as supplied or its first difference. "level" is the default because transforming a caller’s outcome without being asked would change what the estimate means. Reach for "difference" when the reported pearson_dispersion or residual_autocorrelation says the level series does not satisfy the model – a cumulative series is the case that forces it. A differenced fit is re-integrated, so the counterfactual comes back on the outcome’s own scale.

  • n_samples (int) – Posterior draws retained. The counterfactual is a posterior mean, so this does not need to be large.

  • n_warmup (int) – Gibbs sweeps discarded before collection. Ignored by the "ppca" arm, which is fit by EM.

  • ridge_alphas (tuple of float) – Ridge penalties cross-validated over in the outcome regression.

  • heldout_fraction (float) – Share of pre-period cells held out to score the fit. Reported as a predictive log density, which is a score and carries no size guarantee.

  • inference ({“bootstrap”, “posterior”}) – How the interval is formed. "bootstrap" is the paper’s Section 3.4 procedure: resample units with replacement, re-run the algorithm on each resample, and take quantiles of the resulting estimates. It targets the sampling uncertainty in the g-formula of Theorem 4, which comes from having finitely many units. "posterior" returns the spread of the factor model’s own draws, which is what the paper’s figures plot; it is a band on the counterfactual’s conditional mean and is narrower, because it conditions on the units that happened to be observed.

  • n_bootstrap (int) – Resamples drawn when inference="bootstrap".

  • ci_alpha (float) – Two-sided level for the interval (0.05 -> 95%).

  • seed (int) – PRNG seed, which makes a fit reproducible.

Notes

Three deviations from the paper are deliberate, each established by the spike in benchmarks/reference/mosc_spike/.

The effect follows equation 43, Y - f(0). The authors’ code computes f(0) - Y, which inverts it; on their null result the difference is invisible, and on any real effect it flips the sign.

Inference follows the paper’s text and not its figures. Section 3.4 prescribes a nonparametric bootstrap over units and states that its coverage is evaluated in Section 5; that evaluation does not appear, and the case study plots the posterior spread instead. On the authors’ own control panels – teams that never admitted fans, where the effect is zero by construction – the posterior band covered zero four times in ten at a nominal 95%. The bootstrap is therefore the default and the posterior band is available under a name that says what it is.

The paper’s p_pop model check is not offered. Its stated false rejection rate is 0.05 and its measured rate on a correctly specified model is 0.40, because equation 36 sums the discrepancy over held-out cells until the comparison stops being random. heldout_log_density is the same comparison reported as a score.

The lagged pre-intervention outcome that the authors’ code adds to the design is absent. It appears nowhere in equations 40-41, and the baseline it is compared against gets no equivalent term.

ci_alpha: float#
factor_model: Literal['gap', 'ppca']#
heldout_fraction: float#
inference: Literal['bootstrap', 'posterior']#
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_bootstrap: int#
n_factors: int#
n_samples: int#
n_warmup: int#
outcome_scale: Literal['level', 'difference']#
ridge_alphas: Tuple[float, ...]#
seed: int#

Results#

MOSCResults: the ATT and its interval, the posterior-mean counterfactual with a band, the draws of the estimated confounding structure, and the diagnostics above.

Typed containers for the MOSC estimator.

Everything crossing a stage boundary is one of these, so a caller reading a result never has to unpack a tuple positionally to find out what it holds.

class mlsynth.utils.mosc_helpers.structures.MOSCDiagnostics(heldout_log_density: float, pearson_dispersion: float, residual_autocorrelation: float, outcome_scale: str, n_heldout_cells: int)#

Bases: object

What the fit says about whether its own assumptions hold.

These are the quantities a caller acts on, so they are typed fields and not messages.

residual_autocorrelation is the one that decides the outcome scale. Equations 12 and 19 require the latent factors to render a unit’s outcomes conditionally independent, so what has to be near zero is the correlation left after conditioning. A cumulative series carries it at 0.2 to 0.45 and its first difference at 0.07 to 0.17, measured on the authors’ own panels; outcome_scale="difference" is the repair.

pearson_dispersion is 1 under a well-specified Poisson model and reports on the likelihood, not on the scale. It moves in both directions: an overdispersed panel drives it above 1, while a smooth series that a rank-K model fits almost exactly drives it below. Read it as a warning that the count assumption is doing badly, in whichever direction it departs.

heldout_log_density: float#
n_heldout_cells: int#
outcome_scale: str#
pearson_dispersion: float#
residual_autocorrelation: float#
class mlsynth.utils.mosc_helpers.structures.MOSCInference(counterfactual_mean: ndarray, counterfactual_lower: ndarray, counterfactual_upper: ndarray, att_mean: float, att_lower: float, att_upper: float, att_samples: ndarray, ci_alpha: float, method: str = 'unit_bootstrap')#

Bases: object

Posterior summaries of the counterfactual, the ATT and its band.

att_lower: float#
att_mean: float#
att_samples: ndarray#
att_upper: float#
ci_alpha: float#
counterfactual_lower: ndarray#
counterfactual_mean: ndarray#
counterfactual_upper: ndarray#
method: str = 'unit_bootstrap'#
class mlsynth.utils.mosc_helpers.structures.MOSCInputs(panel: ndarray, y_target: ndarray, pre_periods: int, total_periods: int, n_units: int, treated_unit_name: str, donor_names: Sequence, time_labels: ndarray)#

Bases: object

The panel MOSC fits, oriented (T, N) with the treated unit first.

donor_names: Sequence#
n_units: int#
panel: ndarray#
pre_periods: int#
time_labels: ndarray#
total_periods: int#
treated_unit_name: str#
y_target: ndarray#
class mlsynth.utils.mosc_helpers.structures.MOSCPosterior(loadings: ~numpy.ndarray, counterfactual: ~numpy.ndarray, n_factors: int, n_draws: int, factor_model: str, bootstrap_counterfactual: ~numpy.ndarray = <factory>)#

Bases: object

Draws of the estimated confounding structure and of the counterfactual.

bootstrap_counterfactual: ndarray#
counterfactual: ndarray#
factor_model: str#
loadings: ndarray#
property n_bootstrap: int#
n_draws: int#
n_factors: int#
class mlsynth.utils.mosc_helpers.structures.MOSCResults(*, effects: EffectsResults | None = None, fit_diagnostics: FitDiagnosticsResults | None = None, time_series: TimeSeriesResults | None = None, weights: WeightsResults | None = None, inference: InferenceResults | None = None, method_details: MethodDetailsResults | None = None, sub_method_results: Dict[str, Any] | None = None, additional_outputs: Dict[str, Any] | None = None, raw_results: Dict[str, Any] | None = None, execution_summary: Dict[str, Any] | None = None, plot_config: PlotConfig | None = None, inputs: MOSCInputs, posterior: MOSCPosterior, diagnostics: MOSCDiagnostics, inference_detail: MOSCInference)#

Bases: BaseEstimatorResults

Top-level container returned by MOSC.fit (an EffectResult).

diagnostics: MOSCDiagnostics#
inference_detail: MOSCInference#
inputs: MOSCInputs#
model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid', 'frozen': True, 'json_encoders': {<class 'numpy.ndarray'>: <function BaseEstimatorResults.Config.<lambda>>}}#

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

posterior: MOSCPosterior#

Helper Modules#

Data preparation – the DataFrame touchpoint. Pivots to the outcome matrix and refuses a panel the method cannot identify from.

Long-DataFrame -> NumPy boundary for MOSC (wraps dataprep).

Everything a fit needs to refuse is refused here, before any sampling happens. The library declines Postel’s first half on purpose: a panel that MOSC cannot identify from must raise, because the alternative is a counterfactual that looks like an estimate and is not one.

mlsynth.utils.mosc_helpers.setup.check_modelling_scale(panel: ndarray, factor_model: str, outcome_scale: str) None#

Refuse a likelihood whose support excludes the panel it would be fit to.

The constraint belongs to the scale the factor model sees, not to the outcome as supplied: first differences of a count series are routinely negative, so a panel that is admissible in levels need not be admissible differenced.

mlsynth.utils.mosc_helpers.setup.prepare_mosc_inputs(df: DataFrame, outcome: str, unitid: str, time: str, treat: str, n_factors: int, factor_model: str, outcome_scale: str = 'level') MOSCInputs#

Pivot a long panel into MOSC’s (T, N) matrix, treated unit first.

The two probabilistic factor models and the score that compares them.

Probabilistic factor models for MOSC, and the score that compares them.

Two likelihoods, one interface. Each fits a (T, N) panel and returns draws of the per-unit loadings Z – the paper’s estimated confounding structure – together with the per-period factors and any location term, so a caller can reconstruct the fitted mean without knowing which model produced it.

The gamma-Poisson arm is drawn by conjugate Gibbs. The augmentation that makes it conjugate is standard: each observed count is split across the K components by a multinomial draw, after which both factor matrices are Gamma. The authors’ own code sketches this and then draws with NUTS instead, because the sketch does not run. Gibbs removes the sampler from the dependency surface entirely – no NumPyro, no [bayes] extra.

PPCA is the classical Tipping & Bishop (1999) EM: a free loading matrix, an explicit per-period location, and the loadings marginalised in the E step. The authors’ formulation puts standard-normal priors on both factor matrices with no location, which places almost no mass near a panel of counts in the tens of thousands and collapses to the origin under alternating maximisation.

class mlsynth.utils.mosc_helpers.factor.FactorDraws(loadings: ndarray, factors: ndarray, offset: ndarray | None = None, likelihood: str = 'poisson')#

Posterior draws of a factorisation of a (T, N) panel.

loadings are the per-unit latent vectors the downstream regression adjusts for; factors and offset exist so the fitted mean can be rebuilt for scoring, and carry no interpretation of their own – the factor model is identified only up to an invertible transformation of Z.

factors: ndarray#
likelihood: str = 'poisson'#
loadings: ndarray#
mean(draw: int) ndarray#

Fitted mean of the panel under one draw, shape (T, N).

property n_draws: int#
offset: ndarray | None = None#
mlsynth.utils.mosc_helpers.factor.NON_NEGATIVE_ONLY = frozenset({'gap'})#

Likelihoods whose support excludes negative outcomes.

mlsynth.utils.mosc_helpers.factor.gap_gibbs(panel: ndarray, n_factors: int, mask: ndarray | None = None, n_samples: int = 200, n_warmup: int = 200, shape: float = 1.0, rate: float = 1.0, seed: int = 0) FactorDraws#

Gamma-Poisson factorisation by conjugate Gibbs.

mask is True where a cell is observed; held-out cells contribute no likelihood term and are imputed by the fitted rate.

mlsynth.utils.mosc_helpers.factor.heldout_log_density(panel: ndarray, mask: ndarray, draws: FactorDraws) float#

Mean log predictive density per held-out cell. Higher is a better fit.

This is the model comparison the paper reaches for its p_pop check to make, reported as a score. The check itself is not offered: equation 36 sums its discrepancy over held-out cells, so the systematic part grows like the cell count while its spread grows like the square root, and the verdict stops depending on the data. A score makes no calibration claim and so cannot make a false one.

mlsynth.utils.mosc_helpers.factor.ppca_em(panel: ndarray, n_factors: int, mask: ndarray | None = None, n_samples: int = 200, n_iter: int = 300, tol: float = 1e-09, seed: int = 0, **_: object) FactorDraws#

Probabilistic PCA by EM, then draws from the Gaussian posterior over Z.

Units are the exchangeable data points, so each unit’s column is one observation in R^T and the loading matrix is shared. Missing cells are imputed from the current fit each sweep, which is the EM for data missing at random.

Run loop: factor model, per-draw outcome regression, diagnostics, and the re-integration that returns a differenced fit to the outcome’s own scale.

MOSC’s estimation pipeline: factor model, outcome regression, diagnostics.

The stages are the paper’s Algorithm 1. Fit a probabilistic factor model to the pre-intervention panel; read off each unit’s latent loadings; regress every unit’s post-period outcome on its loadings and a treatment dummy; predict the treated unit’s row with the dummy switched off. The counterfactual is that prediction, and the effect is the observed path minus it.

Two things here are the estimator’s own and not the paper’s, both established by benchmarks/reference/mosc_spike/. The effect takes equation 43’s sign, which the authors’ code inverts. And the design carries no lagged outcome: the authors’ code adds the last pre-intervention outcome to every regression that produced their published figure, gives the baseline no equivalent term, and does not mention it in equations 40-41.

Inference follows the paper’s text over its figures. Section 3.4 prescribes a nonparametric bootstrap over units; the case study plots the posterior spread, which conditions on the units that happened to be observed and so sees none of the sampling uncertainty in the g-formula of Theorem 4.

mlsynth.utils.mosc_helpers.pipeline.bootstrap_counterfactuals(inputs: MOSCInputs, config) ndarray#

The paper’s Section 3.4 uncertainty: resample units, re-run, collect.

Each replicate draws a donor pool with replacement and runs the algorithm on it, so the spread across replicates carries the uncertainty that comes from having observed these units and not others – which is what Theorem 4’s outer expectation is taken over.

The treated unit is held in every replicate. Read literally, resampling all units can drop the one whose counterfactual is the estimand, leaving nothing to predict; resampling the donors is the reading that keeps the procedure defined.

mlsynth.utils.mosc_helpers.pipeline.counterfactual_draws(loadings: ndarray, panel: ndarray, pre_periods: int, ridge_alphas: Tuple[float, ...]) ndarray#

Post-period counterfactual for the treated unit, one draw at a time.

Each draw gets its own outcome model. Averaging the loadings first would be ill-defined – a factor model is identified only up to relabelling, so the mean of the draws need not be a configuration any draw took.

mlsynth.utils.mosc_helpers.pipeline.diagnose(pre_panel: ndarray, mask: ndarray, draws: FactorDraws, outcome_scale: str) MOSCDiagnostics#

Score the fit, and measure whether the model’s own assumptions hold.

Both quantities are evaluated on held-out cells. A rank-K fit to a panel with few units interpolates, so an in-sample dispersion would read near 1 whatever the data did.

mlsynth.utils.mosc_helpers.pipeline.fit_factor_model(pre_panel: ndarray, factor_model: str, n_factors: int, n_samples: int, n_warmup: int, mask: ndarray, seed: int) FactorDraws#

Draw the estimated confounding structure from the pre-intervention panel.

mlsynth.utils.mosc_helpers.pipeline.resample_columns(rng, n_donors: int) ndarray#

Column indices for one bootstrap replicate: the treated unit, then donors.

Drawn with replacement, which is what makes the spread across replicates an estimate of sampling uncertainty. Permuting the pool instead would hand every replicate the same units in a different order, and both the factor model and the outcome regression are exchangeable in units, so the replicates would differ only by their random seed.

Column 0 is the treated unit and is always kept. The paper says to resample the per-unit data points; read literally that can drop the unit whose counterfactual is the estimand, leaving the replicate with nothing to predict.

mlsynth.utils.mosc_helpers.pipeline.run_mosc(inputs: MOSCInputs, config) Tuple[MOSCPosterior, MOSCDiagnostics]#

Fit the factor model and turn its loadings into counterfactual draws.

The posterior-band figure. Builds and returns it; displaying and saving are the caller’s.

MOSC’s diagnostic figure.

The plotter builds and returns a Figure: that is mechanism. Showing it, saving it and choosing a filename are policy, and belong to whoever is driving the estimator. The standard observed-against-counterfactual panel is already on the result as result.plot(); this adds the posterior band, which is the part the contract’s shared plotter cannot know about.

mlsynth.utils.mosc_helpers.plotter.plot_mosc_posterior(results: MOSCResults, title: str | None = None, treated_color: str = 'black', counterfactual_color: str = 'tab:blue') Figure#

Observed path against the counterfactual, with the interval the fit produced.

The legend names the interval by what it is. Under the default unit bootstrap it is a percentile confidence interval; under inference="posterior" it is a credible band on the conditional mean. Labelling one as the other would misdescribe every figure.

Returns:

matplotlib.figure.Figure – The caller displays or saves it.