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#
Donor weights are the deliverable. MOSC has none at all. The counterfactual is a regression prediction from the treated unit’s own latent loadings, borrowing strength across every unit at once, so there is no “California = 0.4 Utah + 0.3 Montana” to report. If the weights are the result, use Vanilla Synthetic Control (VanillaSC), Two-Step Synthetic Control or Forward Difference-in-Differences (FDID).
The donor pool is small. The outcome model fits one coefficient per latent factor across units, so a handful of donors leaves it interpolating. MOSC refuses a panel with fewer than
n_factors + 3units. With ten donors and a Gaussian outcome, Generalized Synthetic Control (GSYNTH) or Matrix Completion with Nuclear Norm Minimization (MCNNM) are the better tools.The pre-period is short. The identification is asymptotic in the number of negative control outcomes, which here means pre-treatment periods. It still runs on a short panel and the estimate is still defined; the argument behind it is simply weaker than the paper’s asymptotics suggest.
Adoption is staggered. MOSC takes one treated unit and one intervention date, matching the authors’ own design. For staggered adoption use Matrix Completion with Nuclear Norm Minimization (MCNNM) or Partially Pooled SCM (PPSCM).
A Gaussian likelihood is already appropriate and you want a posterior. Then Bayesian Factor Synthetic Control (BFSC), Bayesian Synthetic Control of Martinez & Vives-i-Bastida (MVBBSC) or Multitask Gaussian Process Synthetic Control (MTGP) do the same job with machinery built for that case.
Robust synthetic control will do. It asks strictly less of you, and the next section says exactly what less. Reach for Cluster Synthetic Controls (CLUSTERSC) first and for MOSC when the likelihood is what you cannot get right.
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,
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:
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:
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,
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,
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.
Core API#
- class mlsynth.MOSC(config: MOSCConfig | dict)#
Bases:
objectMany-outcomes synthetic control (Wang, Schein, Shou & Blei).
- Parameters:
config (MOSCConfig or dict) – Configuration. See
mlsynth.config_models.MOSCConfig.- Returns:
MOSCResults – An
EffectResultcarrying 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:
BaseEstimatorConfigConfiguration 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_modelpicks the likelihood,n_factorsits rank, andoutcome_scalewhether 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
Kof 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 reportedpearson_dispersionorresidual_autocorrelationsays 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 computesf(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_popmodel 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_densityis 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.
- factor_model: Literal['gap', 'ppca']#
- 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].
- outcome_scale: Literal['level', 'difference']#
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:
objectWhat 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_autocorrelationis 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_dispersionis 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-Kmodel fits almost exactly drives it below. Read it as a warning that the count assumption is doing badly, in whichever direction it departs.
- 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:
objectPosterior summaries of the counterfactual, the ATT and its band.
- att_samples: ndarray#
- counterfactual_lower: ndarray#
- counterfactual_mean: ndarray#
- counterfactual_upper: ndarray#
- 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:
objectThe panel MOSC fits, oriented
(T, N)with the treated unit first.- panel: ndarray#
- time_labels: ndarray#
- 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:
objectDraws of the estimated confounding structure and of the counterfactual.
- bootstrap_counterfactual: ndarray#
- counterfactual: ndarray#
- loadings: ndarray#
- 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:
BaseEstimatorResultsTop-level container returned by
MOSC.fit(anEffectResult).- 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.loadingsare the per-unit latent vectors the downstream regression adjusts for;factorsandoffsetexist 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 ofZ.- factors: ndarray#
- loadings: ndarray#
- 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.
maskis 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_popcheck 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^Tand 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-
Kfit 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.