# scspill
> Synthetic control models with spillover effects, in Python: estimators
> that drop SUTVA on the donor pool and report BOTH the effect on the
> treated unit and the spillover effect received by every donor. pydantic
> config in, standardized results out (mlsynth-style architecture).
Install: `pip install scspill` (or `pip install "scspill[numba]"` for
JIT-compiled samplers).
Models: exactly ONE is implemented -- `method="sar"` (Sakaguchi & Tagawa,
The Econometrics Journal 2026), class SCSPILL, and the default. It routes
spillovers through user-supplied spatial weights scaled by one estimated
intensity rho, via two-step MCMC (horseshoe synthetic weights, then a SAR
spillover block). The catalogue also lists `cd` (Cao & Dowd), `iscm` (Di
Stefano & Mellace) and `grossi` (Grossi et al.) as PLANNED: they are NOT
implemented and SCSPILLConfig rejects them. Do not emit code using them.
Catalogue: https://quarcs-lab.github.io/scspill/models/index.html
Minimal contract (model `sar`): SCSPILL({df, outcome, treat, unitid, time,
spatial_w, spatial_W, covariates?, m_iter, burn, seed, method?}).fit() ->
result with .att, .att_ci, .rho_hat, .rho_ci, .counterfactual,
.spillover_panel, .diagnostics(), .plot(). `method` defaults to 'sar'. The
treated unit and date are inferred from the 0/1 `treat` column; spatial
weights align by donor label.
## Docs
- [Get started (California Prop 99)](https://quarcs-lab.github.io/scspill/get-started.html)
- [Models: catalogue and roadmap](https://quarcs-lab.github.io/scspill/models/index.html)
- [The `sar` model (Sakaguchi & Tagawa)](https://quarcs-lab.github.io/scspill/models/sar.html)
- [Sudan case study](https://quarcs-lab.github.io/scspill/sudan.html)
- [Validating the sampler](https://quarcs-lab.github.io/scspill/articles/validation.html)
- [The Monte Carlo simulation study](https://quarcs-lab.github.io/scspill/articles/simulation-study.html)
- [The bundled datasets](https://quarcs-lab.github.io/scspill/articles/datasets.html)
- [For AI / LLMs](https://quarcs-lab.github.io/scspill/use-with-llms.html)
- [Changelog](https://quarcs-lab.github.io/scspill/changelog.html)
- [API reference](https://quarcs-lab.github.io/scspill/reference/index.html)
## API
### Top level
`SCSPILL`, `SCSPILLConfig`, `SCSPILLResults`
### scspill.validation
`GewekeReport`, `PosteriorSummary`, `PriorPredictiveResult`, `PriorSensitivityResult`, `ProductionKernel`, `SimpleKernel`, `SimpleState`, `batch_means_variance`, `default_g_fn`, `geweke_test`, `plot_geweke`, `plot_prior_predictive`, `ppc_stats`, `prior_predictive`, `prior_sensitivity`, `run_posterior_mcmc`, `simulate_yc_forward`
### scspill.simulate
`SimDGP`, `SimRunResult`, `SimTruth`, `load_r_mc_reference`, `make_w`, `mc_grid`, `rook_W`, `run_many_sim`, `run_one_sim`, `scspill_sim_dgp`, `summarize_many`
### scspill.data
`SpillPanel`, `load_california`, `load_sudan`
## Full corpus
- [llms-full.txt](https://quarcs-lab.github.io/scspill/llms-full.txt) — every docs page's source plus
every public signature and docstring.
---
# Full documentation corpus
---
## PAGE: Get started (California Prop 99)
---
title: "Get started: California Proposition 99"
subtitle: "Estimate a policy effect and its spillovers with the bundled tobacco panel"
aliases:
- /quickstart.html
---
```{=html}
```
In 1988 California passed Proposition 99, a large tobacco tax and control
program. The classic synthetic-control analysis (Abadie, Diamond &
Hainmueller 2010) builds a "synthetic California" from other states and reads
the program's effect off the gap. But cigarette taxes leak: Californians can
buy cigarettes in Nevada. If the donors absorb part of the treatment, the
synthetic control is contaminated and the classical estimate is biased.
scspill models that leakage explicitly. This page walks the full workflow on
the bundled panel: load the data, fit the two-step Bayesian model, read the
treatment effect, and — the part classical SCM cannot do — read the spillover
received by each donor state.
::: {.callout-note}
## MCMC budget in this tutorial
The pages in this documentation run a tutorial-scale chain
(`m_iter=4000, burn=2000`, seconds on a laptop) so they render quickly. The
paper's production run for California used 100,000 iterations; for serious
work use at least tens of thousands and check `diagnostics()`.
:::
## Load the data
`load_california()` returns the panel plus the spatial weights, ready to
splat into the estimator's configuration:
```{python}
from scspill.data import load_california
panel = load_california()
panel.df.head()
```
Three ingredients matter:
```{python}
# 1) A 0/1 treatment column: California from 1988 onward.
panel.df.query("treated == 1").head(3)
```
```{python}
# 2) spatial_w: how exposed is each donor to the *treated* unit?
# Rook contiguity: Nevada is the only state in the ADH donor pool that
# borders California (Oregon and Arizona are excluded from the pool).
panel.spatial_w[panel.spatial_w > 0]
```
```{python}
# 3) spatial_W: donor-to-donor contiguity (row-normalized inside the estimator).
panel.spatial_W.iloc[:5, :5]
```
## Fit the model
```{python}
from scspill import SCSPILL
result = SCSPILL(
{
**panel.config_kwargs(), # df, columns, weights, covariates
"m_iter": 4000,
"burn": 2000,
"seed": 20251022,
"display_graphs": False,
}
).fit()
```
Under the hood this ran the paper's two-step sampler: a horseshoe Gibbs
sampler for the synthetic weights $\alpha$ on the 1970–1987 fit, then a
spatial-autoregressive block that estimates the spillover intensity $\rho$
(with the retail cigarette price as a covariate and one latent factor), and
finally the identification formulas that turn the posterior draws into
effects.
## The treatment effect
```{python}
print(f"ATT: {result.att:.2f} packs per capita "
f"(95% CrI [{result.att_ci[0]:.2f}, {result.att_ci[1]:.2f}])")
print(f"rho: {result.rho_hat:.3f} "
f"(95% CrI [{result.rho_ci[0]:.3f}, {result.rho_ci[1]:.3f}], "
f"ESS {result.rho_ess:.0f})")
```
```{python}
#| fig-cap: "Observed California vs. its no-treatment counterfactual, with the 95% pointwise credible band."
result.plot(kind="full", display=False);
```
```{python}
#| fig-cap: "The per-year treatment effect with its credible band."
result.plot(kind="effect", display=False);
```
## The spillovers
The identification result recovers the effect *received by every donor*.
The spillover panel is a time-by-donor DataFrame; the plot ranks donors by
their post-treatment spillover magnitude:
```{python}
#| fig-cap: "Top spillover paths. Nevada — the only contiguous donor — absorbs the bulk of the leakage."
result.plot(kind="spill_top", top_n=6, display=False);
```
```{python}
spill_post = result.spillover_panel.loc[1988:]
spill_post.abs().mean().sort_values(ascending=False).head(5).round(3)
```
## Weights: horseshoe vs. the classical simplex
scspill's weights are unconstrained and horseshoe-shrunk; the classical
simplex weights are computed alongside for comparison:
```{python}
#| fig-cap: "Posterior-mean synthetic weights (bars) against classical simplex-SCM weights (dots)."
result.plot(kind="weights", display=False);
```
## Convergence diagnostics
```{python}
result.diagnostics(top_n_alpha=4).round(3)
```
$\rho$ is the weakly identified parameter of this model — watch its ESS and
run long chains for publication numbers. The
[validation article](articles/validation.qmd) shows the full toolkit
(Geweke test, prior sensitivity, prior predictive checks).
## Where to go next
- [The Sudan case study](sudan.qmd): trade-network weights instead of
contiguity, and spillovers measured in percent of GDP.
- [The `sar` model page](models/sar.qmd): the identification result, the
samplers, and exactly where this implementation deliberately departs from
the R replication package.
- [The API reference](reference/index.qmd).
---
## PAGE: Models: catalogue and roadmap
---
title: "Models"
subtitle: "Synthetic control estimators that let the treatment reach the donor pool"
---
Classical synthetic control assumes SUTVA on the donor pool: whatever happens
to the treated unit leaves the controls untouched. When that fails — a tobacco
tax that pushes sales across a state line, a secession that travels a trade
network, a tramway that redirects footfall one street over — the synthetic
counterfactual is built partly from contaminated donors and the estimate is
biased. Synthetic control is especially exposed here, because its weights
actively select the donors most correlated with the treated unit, and those
are often exactly the units the treatment leaks into.
Every model in scspill drops that assumption and reports **two** estimands:
- $\xi_{0t}$ — the treatment effect on the treated unit, purged of the
contamination; and
- $\xi_{it}$ — the spillover effect *received by donor $i$*, which classical
synthetic control cannot express at all.
What the models disagree about is what to assume about the leakage: a spatial
process on the donor outcomes, a researcher-specified spillover structure, an
inversion of the donors' own cross-weights, or a partition into exposed and
clean clusters. Those assumptions are not nested, which is exactly why it is
worth having more than one — run two and they bracket the answer.
## Available now
::: {.callout-important}
## One model ships today
scspill implements **one** model. Everything under
[Planned](#planned) below is a roadmap: none of it is implemented, and none of
those `method` names is accepted. Passing one raises a validation error rather
than silently falling back.
:::
| Model | `method` | Reference | Class | Page |
|---|---|---|---|---|
| Bayesian spatial-autoregressive spillover SCM | `"sar"` | Sakaguchi & Tagawa (2026), *The Econometrics Journal*, [doi:10.1093/ectj/utag006](https://doi.org/10.1093/ectj/utag006) | `SCSPILL` | [`sar` →](sar.qmd) |
### `sar` — spillovers as a spatial process
`sar` puts a spatial-autoregressive process on the donor outcomes themselves.
The treated unit's outcome propagates into the donor pool through an exposure
vector $\mathbf w$ you supply, the donors propagate to each other through a
matrix $\mathbf W$ you supply, and a single scalar intensity $\rho$ —
estimated, not assumed — scales the whole channel. Under a perfect
pre-treatment fit with unconstrained weights $\boldsymbol\alpha$, both
estimands are identified in closed form from
$(\boldsymbol\alpha, \rho, \mathbf w, \mathbf W)$ alone; covariate
coefficients, latent factors, and error variances cancel out. Inference is a
two-step Bayesian sampler, so every quantity arrives with a posterior rather
than an asymptotic approximation. At $\rho = 0$ the model collapses *exactly*
to the Bayesian horseshoe synthetic control, so the no-spillover case is
nested rather than assumed away.
**Reach for it when** you can defend a spatial or network weighting a priori
(contiguity, trade volumes, distance decay), you want the per-donor spillover
as an object of interest rather than a nuisance, and you want honest
uncertainty on a weakly identified intensity. **Look elsewhere when** you
cannot motivate $(\mathbf w, \mathbf W)$, when only a handful of donors are
exposed and dropping them is cheap, or when your pre-period is too short to
pin $\rho$ down — $\rho$ is this model's weakly identified parameter, and no
amount of sampling changes that.
Full statement of the model, the identification theorem, the samplers, and the
six documented departures from the authors' R replication package are on
[the `sar` page](sar.qmd).
## Planned {#planned}
::: {.callout-warning}
## Not implemented
The three models below are candidates, not features. They are listed so the
package's intended scope is legible and so the `method` names stay coherent —
**none of them exists in scspill today**, and there is no release date.
:::
| Model | Reserved `method` | Reference | How it *would* identify the spillover |
|---|---|---|---|
| SCM with spillover effects | `"cd"` | Cao & Dowd, [arXiv:1902.07343](https://arxiv.org/abs/1902.07343) (2019, rev. 2026) | *Keep* the exposed donors and impose a researcher-specified spillover-structure matrix, linear in unknown parameters, recovering direct and per-unit spillover effects jointly. Comes with a misspecification test for the structure you assumed. |
| Inclusive synthetic control | `"iscm"` | Di Stefano & Mellace, [arXiv:2403.17624](https://arxiv.org/abs/2403.17624) (2024) | *Keep* the exposed donors with no parametric spillover structure at all: build a synthetic control for every affected unit, then invert the system of their own cross-weights to de-contaminate the gaps. |
| Partial-interference synthetic control group | `"grossi"` | Grossi, Mariani, Mattei, Lattarulo & Öner (2025), *JRSS-A* 188(1):223–240, [arXiv:2004.05027](https://arxiv.org/abs/2004.05027) | *Drop* the treated unit's whole cluster from the pool and rebuild from the far, clean clusters only. Cleanest identification when partial interference is defensible, at the cost of pre-treatment fit. |
In one line each: `sar` models the leakage and estimates its intensity; `cd`
keeps the contaminated donors and assumes a structure it can test; `iscm`
keeps them and inverts the data's own cross-weights; `grossi` drops them and
identifies off a cluster partition.
## Relationship to mlsynth
scspill's estimator architecture follows
[mlsynth](https://github.com/jgreathouse9/mlsynth) — a pydantic config in, a
standardized results object out — and the four models above are exactly the
four its `SPILLSYNTH` dispatcher exposes as
`method="sar" | "cd" | "iscm" | "grossi"`. The names here are deliberately
identical, so moving between the two libraries is a rename rather than a
rewrite. Where scspill goes further is depth: it carries the `sar` model's
full appendix machinery — the Geweke joint distribution test, prior
sensitivity grids, prior predictive checks, a Monte Carlo engine, and a
benchmark suite pinned to the authors' frozen R results.
## References
- Sakaguchi, S., & Tagawa, H. (2026). *Identification and Bayesian Inference
for Synthetic Control Methods with Spillover Effects.* The Econometrics
Journal.
- Cao, J., & Dowd, C. (2019, rev. 2026). *Estimation and Inference for
Synthetic Control Methods with Spillover Effects.*
- Di Stefano, R., & Mellace, G. (2024). *The Inclusive Synthetic Control
Method.*
- Grossi, G., Mariani, M., Mattei, A., Lattarulo, P., & Öner, Ö. (2025).
Direct and spillover effects of a new tramway line on the commercial
vitality of peripheral streets: a synthetic-control approach. *Journal of
the Royal Statistical Society Series A* 188(1):223–240.
---
## PAGE: The `sar` model (Sakaguchi & Tagawa)
---
title: "`sar` — the Bayesian spatial-autoregressive spillover model"
subtitle: "Sakaguchi & Tagawa (2026): identification and two-step Bayesian inference"
aliases:
- /articles/method.html
---
`sar` is scspill's first model, and today its only one — the
[model catalogue](index.qmd) lists what else is planned. It implements
Sakaguchi & Tagawa's *Identification and Bayesian Inference for Synthetic
Control Methods with Spillover Effects*. This page states the model, the
identification result, and the samplers, and documents precisely where this
implementation deliberately departs from the authors' R replication package.
Select it with `method="sar"` in `SCSPILLConfig`. It is the default, so
existing code needs no change.
## Setup
Units $i = 0, 1, \dots, N$ are observed over periods $t = 1, \dots, T$. Unit
$0$ receives a treatment from period $T_0 + 1$ onward; units $1, \dots, N$
are the donors. Potential outcomes are indexed by the *entire* treatment
vector — SUTVA is not assumed — so the donors may be affected by unit $0$'s
treatment. Two estimands follow:
- the **treatment effect on the treated**,
$\xi_{0t} = Y_{0t}(1, 0, \dots, 0) - Y_{0t}(0, \dots, 0)$, and
- the **spillover effect on donor** $i$,
$\xi_{it} = Y_{it}(1, 0, \dots, 0) - Y_{it}(0, \dots, 0)$.
The synthetic-control assumption is *perfect fit with unconstrained
weights*: there exists $\alpha \in \mathbb{R}^N$ with
$Y_{0t}(\mathbf{0}) = \sum_{i \ge 1} \alpha_i\, Y_{it}(\mathbf{0})$ for all
$t$. No simplex constraint — weights may be negative or exceed one.
**Why classical SCM breaks.** The observed gap decomposes as
$$
Y_{0t} - \sum_i \alpha_i Y_{it}
= \xi_{0t} + \underbrace{\textstyle\sum_i \alpha_i
\bigl(Y_{it}(\mathbf 0) - Y_{it}(1, \mathbf 0)\bigr)}_{\text{spillover bias}},
$$
so whenever the donors absorb part of the treatment, the classical estimate
mixes the effect with the contamination.
## The spillover model
Spillovers travel through a spatial-autoregressive (SAR) structure on the
donor outcomes:
$$
\mathbf Y^c_t \;=\; \rho\,\bigl(\mathbf w\, Y_{0t} + \mathbf W\, \mathbf Y^c_t\bigr)
\;+\; \mathbf X_t \boldsymbol\beta \;+\; \mathbf u_t ,
$$
where $\mathbf w \in \mathbb R^N$ measures each donor's exposure to the
*treated* unit (contiguity, trade, any a-priori channel), $\mathbf W \in
\mathbb R^{N \times N}$ links donors to each other, and one scalar $\rho$
scales the whole channel. $\mathbf X_t$ are optional covariates and the
errors follow a latent AR(1) factor model,
$\mathbf u_t = \boldsymbol\eta\, \boldsymbol\gamma_t + \mathbf e_t$. At
$\rho = 0$ the model — and the estimator — collapses exactly to the Bayesian
horseshoe synthetic control.
## Identification
Let $\mathbf A = \mathbf W + \mathbf w\, \boldsymbol\alpha^\top$ and require
$\mathbf I_N - \rho \mathbf A$ to be invertible. Substituting the perfect-fit
assumption into the SAR system yields the donors' no-treatment
counterfactual in closed form:
$$
\mathbf Y^c_t(\mathbf 0)
= (\mathbf I_N - \rho \mathbf A)^{-1}
\bigl[(\mathbf I_N - \rho \mathbf W)\, \mathbf Y^c_t
- \rho\, \mathbf w\, Y_{0t}\bigr],
$$
and with it both estimands:
$$
\xi_{0t} = Y_{0t} - \boldsymbol\alpha^\top \mathbf Y^c_t(\mathbf 0),
\qquad
\boldsymbol\xi^c_t = \mathbf Y^c_t - \mathbf Y^c_t(\mathbf 0).
$$
Only $(\boldsymbol\alpha, \rho, \mathbf w, \mathbf W)$ and the observed
outcomes enter — the covariate coefficients, factors, and error variances
cancel out of the effects.
## Two-step Bayesian inference
The joint pre-treatment likelihood contains the product
$\rho\, \mathbf w \boldsymbol\alpha^\top$, which is weakly identified and
mixes badly, so the paper factorizes it and samples sequentially (a *cut*
posterior):
**Step 1 — synthetic weights.** A Bayesian regression of $Y_{0t}$ on
$\mathbf Y^c_t$ over the pre-period with the horseshoe prior
$$
\alpha_i \mid \lambda_i \sim \mathcal N(0, \lambda_i^2), \quad
\lambda_i \mid \tau \sim \mathcal C^+(0, \tau), \quad
\tau \sim \mathcal C^+(0, \sigma_1), \quad
\sigma_1 \sim \mathcal C^+(0, 10),
$$
sampled with the Makalic–Schmidt (2015) inverse-gamma auxiliary
representation, so every full conditional is closed form. The outcomes are
scaled by their standard deviations only (no centering), and the draws are
back-transformed.
**Step 2 — the SAR block.** With $\boldsymbol\alpha$ fixed at its posterior
mean $\hat{\boldsymbol\alpha}$, a Gibbs sampler draws the latent AR(1)
factors (forward-filter backward-sample), the covariate coefficients
$\boldsymbol\beta$ (horseshoe), the error variance $\sigma^2$
(inverse-gamma), and $\rho$ by random-walk Metropolis. The $\rho$ step's
Jacobian $\log\lvert \mathbf I - \rho \mathbf A\rvert$ is evaluated in
$O(N)$ per proposal from the pre-computed complex eigenvalues of
$\mathbf A$, and $\rho$ lives on the spectral support
$|\rho| < 0.95 / \max(1, \max_i |\lambda_i(\mathbf W)|)$.
**Effects.** Each retained draw pair $(\boldsymbol\alpha^{(m)}, \rho^{(m)})$
is plugged into the identification formulas; posterior means and equal-tailed
quantiles give the point estimates and credible bands. The per-draw inverse
uses a one-time eigendecomposition of $\mathbf W$ plus the Sherman–Morrison
identity for the rank-one term $\rho\, \mathbf w \hat{\boldsymbol\alpha}^\top$,
so the whole posterior sweep costs $O(M N^2 T)$.
## Where `sar` departs from the R replication package
These differences belong to this model's port, not to scspill as a whole.
`sar` defaults to the *paper-correct* behavior and documents every
difference; several came out of validating the port. Each has either an
escape hatch or a benchmark demonstrating the consequence.
| # | R replication package | scspill default | Escape hatch |
|---|---|---|---|
| 1 | The covariate array reaches the C++ sampler through mismatched memory layouts, so $\mathbf X_t$ entered the published fits as scrambled noise | covariates carried as a proper $(T, N, K)$ array end-to-end | drop `covariates` to reproduce the R fits' *effective* specification |
| 2 | $\boldsymbol\beta$ sampled under a flat-plus-ridge conditional despite the paper's horseshoe | the paper's horseshoe on $\boldsymbol\beta$ | `beta_prior="ridge"` |
| 3 | ATT credible intervals vary only $\rho$, holding $\boldsymbol\alpha$ at its posterior mean | paired $(\boldsymbol\alpha^{(m)}, \rho^{(m)})$ draws (the paper's stated procedure) | `propagate_alpha=False` |
| 4 | fixed Metropolis step for $\rho$ (empirical ESS as low as 3–44) | Robbins–Monro adaptation during burn-in toward 44% acceptance, then frozen | `adapt_rho=False` |
| 5 | the factor block's $\omega_k$ conditional treats $\omega_k$ as a variance multiplier while its $\boldsymbol\eta$ / $\sigma_\eta^2$ conditionals treat it as a precision — mutually inconsistent conditionals; its hyperprior also encodes $\omega_k \sim \mathcal C^+(0, 1)$ where the paper states $\mathcal C^+(0, 10)$ | the paper's parametrization $\eta_{ik} \sim \mathcal N(0, \sigma^2_\eta \omega_k)$ with $\omega_k \sim \mathcal C^+(0, 10)$ throughout | — (the R combination is not a valid Gibbs sampler for any posterior) |
| 6 | the FFBS initializes $\gamma_1$ from the stationary AR(1) variance while the $\phi_\gamma$ / $\sigma^2_\gamma$ conditionals assume $\gamma_1 \sim \mathcal N(0, \sigma^2_\gamma)$ | the coherent $\gamma_0 = 0$ initialization | — |
Items 5–6 were caught by the [Geweke joint distribution
test](../articles/validation.qmd) — with the fixes, every sampler block passes it.
Item 1 has a striking consequence: fitted *without* covariates, scspill
reproduces the R California posterior almost exactly
($\hat\rho = 0.187$ vs. R's $0.185$); with the retail-price covariate
entering correctly, $\hat\rho$ moves to roughly $0.35$. The benchmark suite
(`benchmarks/REPORT.md`) gates both claims.
## References
- Sakaguchi, S., & Tagawa, H. (2026). *Identification and Bayesian Inference
for Synthetic Control Methods with Spillover Effects.* The Econometrics
Journal.
- Abadie, A., Diamond, A., & Hainmueller, J. (2010). Synthetic control
methods for comparative case studies. *JASA* 105(490).
- Carvalho, C., Polson, N., & Scott, J. (2010). The horseshoe estimator for
sparse signals. *Biometrika* 97(2).
- Makalic, E., & Schmidt, D. (2015). A simple sampler for the horseshoe
estimator. *IEEE Signal Processing Letters* 23(1).
- Kim, S., Lee, C., & Gupta, S. (2020). Bayesian synthetic control methods.
*Journal of Marketing Research* 57(5).
- Pang, X., Liu, L., & Xu, Y. (2022). A Bayesian alternative to synthetic
control for comparative case studies. *Political Analysis* 30(2).
- Geweke, J. (2004). Getting it right: joint distribution tests of posterior
simulators. *JASA* 99(467).
- LeSage, J., & Pace, R. K. (2009). *Introduction to Spatial Econometrics.*
---
## PAGE: Sudan case study
---
title: "Case study: the 2011 secession of South Sudan"
subtitle: "Spillovers along a trade network, measured in percent of GDP"
---
```{=html}
```
In July 2011 South Sudan seceded from Sudan, taking three quarters of the
oil fields with it. The paper's second application asks what the split cost
"the Sudans" (Sudan and South Sudan combined) in GDP per capita — and where
the shock traveled. Spillovers here are not geographic: they move along
trade links, so the spatial weights are average pre-period bilateral trade
volumes rather than shared borders.
::: {.callout-note}
## MCMC budget in this tutorial
Tutorial scale again (`m_iter=4000, burn=2000`). The paper's production run
for this application used one million iterations — $\rho$ mixes slowly and
this panel is short ($T_0 = 11$ pre-treatment years), so treat the numbers
on this page as illustrative.
:::
## Load the data
```{python}
from scspill.data import load_sudan
panel = load_sudan()
panel.df.head(3)
```
The outcome is GDP per capita (constant 2015 US$) and six World Development
Indicators enter as covariates:
```{python}
panel.outcome, panel.covariates
```
The exposure vector is raw bilateral trade with Sudan — Egypt and Kenya
dominate (the estimator normalizes it internally):
```{python}
panel.spatial_w.sort_values(ascending=False).head(5)
```
## Fit
```{python}
from scspill import SCSPILL
result = SCSPILL(
{
**panel.config_kwargs(),
"m_iter": 4000,
"burn": 2000,
"step_rho": 0.02,
"seed": 20251022,
"display_graphs": False,
}
).fit()
print(f"ATT: {result.att:.1f} US$ per capita "
f"(95% CrI [{result.att_ci[0]:.1f}, {result.att_ci[1]:.1f}])")
print(f"rho: {result.rho_hat:.3f} "
f"(95% CrI [{result.rho_ci[0]:.3f}, {result.rho_ci[1]:.3f}])")
```
```{python}
#| fig-cap: "Observed vs. counterfactual GDP per capita for the Sudans."
result.plot(kind="full", display=False);
```
## Effects in percent
The paper reports the secession loss as a percentage of the counterfactual:
```{python}
import numpy as np
T0 = result.inputs.T0
cf_post = result.effects_detail.cf_mean[T0:]
gap_post = result.inputs.Y0[T0:] - cf_post
years = result.inputs.time_labels[T0:]
for year, g, c in zip(years, gap_post, cf_post):
print(f"{year}: {g:+7.1f} US$ ({100 * g / c:+5.1f}% of counterfactual)")
```
## Spillovers travel the trade network
```{python}
#| fig-cap: "Top spillover paths: the shock lands on Sudan's largest trade partners."
result.plot(kind="spill_top", top_n=6, display=False);
```
```{python}
spill_post = result.spillover_panel.loc[2011:]
spill_post.abs().mean().sort_values(ascending=False).head(5).round(2)
```
## Notes on this application
- The treated unit "Sudan" aggregates Sudan and South Sudan after 2011, so
the estimand is the effect of the split on the combined economy.
- With $T_0 = 11$ pre-periods and 33 donors, the horseshoe prior is doing
real work: it shrinks most donor weights to zero and concentrates the fit
on a few economies.
- $\rho$ is estimated around 0.4–0.5 here versus roughly 0.2–0.35 in the
California application (depending on the specification) — trade
integration transmits more of the shock than a single land border.
---
## PAGE: Validating the sampler
---
title: "Validating the sampler"
subtitle: "The Geweke joint distribution test, prior sensitivity, and prior predictive checks"
---
A Bayesian estimator is only as good as its sampler, so scspill ships the
paper's appendix machinery as first-class functions. This page runs each at
documentation scale; the repository's benchmark suite
(`benchmarks/REPORT.md`) runs them long.
::: {.callout-note}
The chains on this page are kept short so the site renders quickly; the
acceptance thresholds quoted here are the ones the long-running benchmarks
enforce.
:::
## The Geweke joint distribution test
Geweke (2004) tests a posterior simulator by comparing two ways of drawing
from the *joint* distribution $p(\theta, y)$: exact iid draws
($\theta \sim p(\theta)$, then $y \mid \theta$) against the
*successive-conditional* chain that alternates data simulation with one full
sweep of the sampler. If — and only if — every conditional is mutually
consistent with the priors, the two agree in expectation for any statistic
$g(\theta, y)$.
```{python}
from scspill.validation import geweke_test, plot_geweke
report = geweke_test(
kernel="production", # the sampler users actually run
T0=4, N=4, K=0, p=0, # a small design keeps the chain fast-mixing
m_iid=8000, m_mcmc=8000, burn=1500,
a0=3.0, b0=1.0, step_rho=0.5, seed=21,
)
report.table.round(3)
```
```{python}
#| fig-cap: "z-scores per statistic with the Bonferroni-adjusted acceptance band."
fig = plot_geweke(report)
```
Two practical notes, learned the hard way (details in the
`geweke_test` docstring):
- the successive-conditional chain mixes slowly when the test panel is
informative — keep the design small, the Metropolis step generous, and the
batch size large;
- this very test caught two internal inconsistencies in the reference R
implementation's latent-factor block (see the
[`sar` model page](../models/sar.qmd)); with the coherent parametrization every
isolated sampler block passes.
## Prior sensitivity
`prior_sensitivity` re-runs the Step-2 posterior across a grid of prior
settings and support restrictions — the paper's robustness table:
```{python}
import pandas as pd
from scspill.data import load_california
from scspill import SCSPILL
from scspill.validation import prior_sensitivity
panel = load_california()
fit = SCSPILL({**panel.config_kwargs(), "p_factors": 0, "m_iter": 3000,
"burn": 1500, "seed": 20251022, "display_graphs": False}).fit()
inputs = fit.inputs
Y0_pre, Yc_pre = inputs.Y0[:inputs.T0], inputs.Yc[:inputs.T0]
grid = pd.DataFrame({
"a0": [3.0, 5.0, 2.0],
"b0": [1.0, 1.0, 0.5],
"rho_lo": [-0.5, -0.3, -0.7],
"rho_hi": [0.5, 0.3, 0.7],
"step_rho": [0.05, 0.03, 0.07],
})
sens = prior_sensitivity(Yc_pre, inputs.W_raw, inputs.w_raw, fit.alpha_hat,
grid, m_burn=1000, m_keep=3000)
sens.table.query("parameter == 'rho'").round(3)
```
## Prior predictive checks
Does the model class, under its priors, generate panels that look like the
observed one? `prior_predictive` simulates donor panels from the prior and
compares nine summary statistics against the observed California panel — the
p-values are $P(h_{\text{sim}} \le h_{\text{obs}})$:
```{python}
#| fig-cap: "Prior predictive distributions with the observed statistic (red line) and its p-value."
from scspill.validation import prior_predictive, plot_prior_predictive
ppc = prior_predictive(
Y0_pre, inputs.W_raw, inputs.w_raw, fit.alpha_hat,
Yc_obs=Yc_pre, X=inputs.X[:inputs.T0], # retprice enters the simulations
a0=3.0, b0=1.0, rho_support=(-0.99, 0.99),
n_draws=1500, seed=123,
)
fig = plot_prior_predictive(ppc)
```
```{python}
pd.Series(ppc.p_values).round(3)
```
The observed statistics themselves are pinned in the test suite against the
R replication package's frozen table to three decimals — the p-values here
reproduce the published pattern (the first principal component's share of
variance is the one statistic in the tail, exactly as in the paper's
appendix).
## Cross-validation against the R replication package
The repository's `benchmarks/` directory runs the durable comparisons:
- California and Sudan posteriors against the frozen R credible intervals
(matching the R specification, and separately reporting the paper-correct
one — see the [`sar` model page](../models/sar.qmd) for why those differ);
- the reduced Monte Carlo grid against the paper's frozen Tables 1–2 CSVs;
- this Geweke test at a quarter-million draws.
```bash
python benchmarks/run_benchmarks.py --all --report
```
---
## PAGE: The Monte Carlo simulation study
---
title: "The Monte Carlo simulation study"
subtitle: "Plant the truth, watch classical SCM go wrong, watch SCSPILL recover it"
---
The paper's Section 5 quantifies the spillover bias with a Monte Carlo
study: donor outcomes are generated from the SAR model on a rook lattice
with known synthetic weights and a known spillover intensity $\rho$, the
treated unit receives effects $\tau_t \sim \mathcal N(1, 1)$, and — the
crucial part — the post-treatment donor outcomes are regenerated *carrying
the treated unit's treated outcome*, so the donors are contaminated exactly
as the theory says. `scspill.simulate` reproduces that engine.
::: {.callout-note}
This page runs 20 replications per cell at a short chain so it renders in
about a minute; the paper uses 1000 replications at 6000 iterations. The
benchmark case `mc_grid_vs_r` runs 200 replications against the frozen R
results.
:::
## One replication, dissected
```{python}
from scspill.simulate import scspill_sim_dgp, rook_W, make_w
from scspill.simulate.dgp import paper_alpha
dgp = scspill_sim_dgp(
T0=20, T1=10, N=16,
W=rook_W(4, 4), # rook lattice among donors
w=make_w(16), # donors 1-4 are exposed to the treated unit
rho=0.3, sigma2=0.1,
alpha=paper_alpha(16), # the paper's planted weights
K=1, beta=[1.0], seed=1,
)
print(f"realized ATT: {dgp.truth.tau_post.mean():.3f}")
print(f"pre-period fit is exact: max |Y0 - Yc @ alpha| = "
f"{abs(dgp.Y0_pre - dgp.Yc_pre @ dgp.truth.alpha).max():.2e}")
```
`run_one_sim` fits three estimators on this panel — the classical simplex
SCM, the Bayesian horseshoe SCM (which shares scspill's Step 1 but ignores
spillovers), and full SCSPILL — and scores each against the realized
effects:
```{python}
from scspill.simulate import run_one_sim
res = run_one_sim(dgp=dgp, m_iter=1500, burn=700, seed=1)
res.metrics.round(4)
```
## A small grid
Across replications the pattern of the paper's Tables 1–2 emerges: the
classical and horseshoe-only estimators inherit a bias that grows with
$|\rho|$, while SCSPILL stays centered with near-nominal coverage.
```{python}
#| warning: false
from scspill.simulate import run_many_sim, summarize_many
import pandas as pd
rows = []
for rho in (-0.3, 0.0, 0.3):
results = run_many_sim(
20,
{"grid": (4, 4), "T0": 20, "T1": 10, "rho": rho, "sigma2": 0.1,
"K": 1, "beta": [1.0]},
seeds=list(range(20)),
m_iter=1500, burn=700,
)
summary = summarize_many(results)
summary.insert(0, "rho", rho)
rows.append(summary[["rho", "method", "bias_point", "rmse_point", "cover95_point"]])
grid = pd.concat(rows, ignore_index=True)
grid.round(4)
```
```{python}
#| fig-cap: "Per-period RMSE by method and true spillover intensity (20 replications per cell)."
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7, 4))
for method, marker in [("SCM", "o"), ("BSCM", "s"), ("SCSPILL", "^")]:
sub = grid[grid["method"] == method]
ax.plot(sub["rho"], sub["rmse_point"], marker=marker, label=method)
ax.set_xlabel(r"true $\rho$")
ax.set_ylabel("per-period RMSE")
ax.set_yscale("log")
ax.legend()
ax.set_title("Spillovers bias the spillover-blind estimators")
plt.show()
```
## Against the frozen R results
The replication package ships the full study's frozen output; the reduced
grid tracks it closely (and the benchmark suite gates the comparison):
```{python}
from pathlib import Path
from scspill.simulate import load_r_mc_reference
root = Path.cwd()
while not (root / "benchmarks").exists(): # docs pages execute in docs/articles
root = root.parent
ref = load_r_mc_reference(root / "benchmarks" / "reference" / "mc_result")
ref.query("N == 16 and T0 == 20 and rho in (-0.3, 0.0, 0.3)").round(4)
```
At the paper's own scale (`mc_grid()` with its defaults: 1000 replications,
$N \in \{16, 36, 64\}$, $T_0 \in \{20, 50\}$, seven values of $\rho$) the
run takes hours; pass `n_jobs` to parallelize across processes and
`backend="numba"` for the JIT-compiled samplers.
---
## PAGE: The bundled datasets
---
title: "The bundled datasets"
subtitle: "Provenance and dictionaries for the two case studies"
---
Both of the paper's empirical applications ship inside the wheel as plain
CSVs (about 150 kB total), copied verbatim from the replication package's
nonproprietary export. Each loader returns a `SpillPanel` — the long panel
with a 0/1 `treated` column, the raw spatial weights aligned by donor label,
and a `config_kwargs()` helper that feeds `SCSPILLConfig` directly. The
weights are stored *unnormalized*; the estimator row-normalizes `spatial_W`
and scales `spatial_w` to sum to one, exactly as the R package does.
## California Proposition 99
```{python}
from scspill.data import load_california
ca = load_california()
ca.df.head()
```
| Field | Value |
|---|---|
| Units | 39 U.S. states (38 donors + California) |
| Periods | 1970–2000 (annual); treatment from 1988 |
| Outcome | `cigsale` — per-capita cigarette sales (packs) |
| Covariate | `retprice` — retail cigarette price |
| `spatial_w` | California's rook-contiguity row (only Nevada = 1) |
| `spatial_W` | binary rook adjacency among the donors |
Provenance: the Abadie–Diamond–Hainmueller (2010) tobacco panel as
distributed with Cunningham's *Causal Inference: The Mixtape*; contiguity
derived from the 2024 TIGER/Line state shapefile with `spdep::poly2nb(queen
= FALSE)`.
```{python}
ca.spatial_W.sum(axis=1).sort_values(ascending=False).head(5) # most-connected donors
```
## The 2011 Sudan secession
```{python}
from scspill.data import load_sudan
sd = load_sudan()
sd.df.head(3)
```
| Field | Value |
|---|---|
| Units | 34 African countries (33 donors + "Sudan") |
| Periods | 2000–2015 (annual); treatment from 2011 |
| Outcome | `gdp_pc` — GDP per capita, constant 2015 US$ |
| Covariates | `exports_gdp_share`, `merchandise_trade_gdp_share`, `trade_gdp_share`, `clean_fuels_access`, `inflation`, `net_migration` |
| `spatial_w` | average 2000–2010 bilateral trade with Sudan (raw US$) |
| `spatial_W` | average bilateral trade among donors (raw US$) |
Provenance: World Development Indicators (percentage series stored as
proportions), with the treated unit aggregating Sudan and South Sudan after
the split; trade weights from the IMF Direction of Trade Statistics,
symmetrized and averaged over the pre-period. The loader renames the R
export's mangled WDI headers to snake_case; pass `raw_names=True` for the
original headers:
```{python}
sd.column_map
```
## Using your own data
Any long panel works. You need:
1. a DataFrame with unit, time, outcome, and 0/1 treatment-indicator
columns (1 for the treated unit in post-treatment periods);
2. `spatial_w`: a Series/dict keyed by donor label (or an array in sorted
donor order) measuring each donor's exposure to the treated unit;
3. `spatial_W`: a labelled DataFrame (or array) of donor-to-donor weights;
4. optionally, covariate columns.
Alignment is by label wherever labels are given, so the order of your
weights never has to match the panel's — mismatched or missing donor labels
raise immediately.
---
## PAGE: For AI / LLMs
---
title: "scspill for AI agents and LLMs"
subtitle: "The machine-readable entry points and the minimal working contract"
---
This page is the stable entry point for AI assistants working with scspill.
## Machine-readable indexes
- — curated overview:
what the package does, the input contract, and links to every docs page
(llmstxt.org convention).
- — the full docs
corpus: every page's source plus the signature and docstring of every
public function.
Both are also advertised via `` tags on every page and
in `robots.txt`.
## The contract in one block
```python
from scspill import SCSPILL
from scspill.data import load_california
panel = load_california() # or build your own inputs (below)
result = SCSPILL(
{
"df": panel.df, # long panel: one row per (unit, period)
"outcome": "cigsale", # column names, not data
"treat": "treated", # 0/1; 1 = treated unit in post periods
"unitid": "state",
"time": "year",
"spatial_w": panel.spatial_w, # Series/dict by donor label, or array
"spatial_W": panel.spatial_W, # labelled (N, N) DataFrame, or array
"covariates": ["retprice"], # optional columns of df
"m_iter": 20_000,
"burn": 10_000,
"seed": 42,
"display_graphs": False,
}
).fit()
result.att # float: average treatment effect on the treated
result.att_ci # (lo, hi): 95% credible interval
result.rho_hat # float: spillover intensity posterior mean
result.rho_ci # (lo, hi)
result.counterfactual # (T,) array: the treated unit's no-treatment path
result.spillover_panel # (T, N) DataFrame: effect received by each donor
result.diagnostics() # DataFrame: mean/sd/quantiles/ESS/R-hat per chain
result.plot(kind="panel") # counterfactual | effect | top spillovers
```
## Rules of thumb for agents
- The treated unit and the treatment date are inferred from the `treat`
column — there is no `treated_unit` argument.
- Spatial weights align **by label**; pass pandas objects whenever you have
labels. Raw/unnormalized weights are fine (normalization is internal).
- `rho` is the weakly identified parameter: for real analyses use tens of
thousands of iterations and check `result.rho_ess` and
`result.diagnostics()`.
- Errors are typed: `ScspillConfigError` (bad configuration),
`ScspillDataError` (bad panel/weights), `ScspillEstimationError`,
`ScspillPlottingError` — all subclass `ScspillError`.
- Sampler validation lives in `scspill.validation` (`geweke_test`,
`prior_sensitivity`, `prior_predictive`); Monte Carlo tooling in
`scspill.simulate`; the two case studies in `scspill.data`.
- The estimator's architecture mirrors
[mlsynth](https://github.com/jgreathouse9/mlsynth) (pydantic config in,
standardized results object out), so mlsynth patterns transfer.
## Install
```bash
pip install scspill # or: pip install "scspill[numba]"
```
---
## PAGE: Changelog
---
title: "Changelog"
---
{{< include ../CHANGELOG.md >}}
---
## API signatures and docstrings
### Top level
#### `SCSPILL(config: 'SCSPILLConfig | dict') -> 'None'`
Spillover-aware synthetic control estimator (model ``sar``).
Fits the Bayesian spatial-autoregressive spillover model of Sakaguchi &
Tagawa (2026) -- scspill's first and, today, only model. Select it with
``method="sar"``; that is the default, so existing code needs no change.
Parameters
----------
config : SCSPILLConfig or dict
The estimation configuration; a dict is coerced into
:class:`scspill.config_models.SCSPILLConfig`.
Examples
--------
```python
from scspill import SCSPILL
from scspill.data import load_california
panel = load_california()
result = SCSPILL(
{**panel.config_kwargs(), "m_iter": 2000, "burn": 1000, "seed": 42}
).fit()
result.att, result.att_ci
result.rho_hat, result.rho_ci
result.spillover_panel["Nevada"]
```
#### `SCSPILLConfig(*, df: pandas.DataFrame, outcome: str, treat: str, unitid: str, time: str, display_graphs: bool = True, save: bool | str = False, counterfactual_color: list[str] = , treated_color: str = 'black', plot: scspill.config_models.PlotConfig = , method: Literal['sar'] = 'sar', spatial_w: Any, spatial_W: Any, covariates: list[str] | None = None, p_factors: Annotated[int, Ge(ge=0)] = 1, m_iter: Annotated[int, Ge(ge=10)] = 2000, burn: Annotated[int, Ge(ge=0)] = 1000, step_rho: Annotated[float, Gt(gt=0)] = 0.05, adapt_rho: bool = True, target_accept_rho: Annotated[float, Gt(gt=0), Lt(lt=1)] = 0.44, beta_prior: Literal['horseshoe', 'ridge'] = 'horseshoe', propagate_alpha: bool = True, a0: Annotated[float, Gt(gt=0)] = 1.0, b0: Annotated[float, Gt(gt=0)] = 1.0, seed: int | None = None, ci: Annotated[float, Gt(gt=0), Lt(lt=1)] = 0.95, top_n_spill: Annotated[int, Ge(ge=1)] = 8, max_effect_draws: Annotated[int | None, Ge(ge=1)] = None, backend: Literal['auto', 'numpy', 'numba'] = 'auto', verbose: bool = False) -> None`
Configuration for a spillover synthetic-control fit (today, model ``sar``).
Implements:
Sakaguchi, S., & Tagawa, H. (2026). "Identification and Bayesian
Inference for Synthetic Control Methods with Spillover Effects." The
Econometrics Journal. https://doi.org/10.1093/ectj/utag006
The panel is a long DataFrame (one row per unit-period). The treated unit
and the treatment date are inferred from the 0/1 ``treat`` indicator
column (1 for the treated unit in post-treatment periods), following the
mlsynth convention. The spatial structure is supplied through
``spatial_w`` (treated-to-control exposure) and ``spatial_W``
(control-to-control weights), aligned to donor units by label.
Mapping from the R package's ``sc_spillover()`` arguments:
============== ======================= ====================================
R argument SCSPILLConfig field Notes
============== ======================= ====================================
``data`` ``df`` long panel
``y`` ``outcome`` column name
``unit_col`` ``unitid`` column name
``time_col`` ``time`` column name
``treatment_dummy`` ``treat`` column name; also identifies the
treated unit and ``T0``
``treated_unit``/``T0`` -- inferred from ``treat``
``w`` ``spatial_w`` label-aligned Series/dict/array
``W`` ``spatial_W`` label-aligned DataFrame/array
``X`` ``covariates`` column names in ``df``
``p_factors`` ``p_factors``
``M`` ``m_iter``
``burn`` ``burn``
``step_rho`` ``step_rho`` initial value when ``adapt_rho``
``seed`` ``seed``
============== ======================= ====================================
Differences from the R code (paper-correct defaults, each with an
R-compatibility escape hatch):
* ``beta_prior="horseshoe"`` implements the paper's Section 4.2 horseshoe
prior on the covariate coefficients; ``"ridge"`` reproduces the R
code's flat-plus-ridge conditional.
* ``propagate_alpha=True`` pairs ``(alpha^(m), rho^(m))`` posterior draws
in the effect formulas (the paper's stated procedure); ``False``
reproduces the R package's intervals, which hold ``alpha`` fixed at its
posterior mean and vary only ``rho``.
* ``adapt_rho=True`` tunes the ``rho`` random-walk step during burn-in
toward ``target_accept_rho`` (Robbins-Monro), then freezes it; the R
sampler uses a fixed step.
* Covariates are carried as a proper ``(T, N, K)`` array throughout,
fixing the R package's covariate memory-layout mismatch.
#### `SCSPILLResults(*, effects: scspill.config_models.EffectsResults | None = None, fit_diagnostics: scspill.config_models.FitDiagnosticsResults | None = None, time_series: scspill.config_models.TimeSeriesResults | None = None, weights: scspill.config_models.WeightsResults | None = None, inference: scspill.config_models.InferenceResults | None = None, method_details: scspill.config_models.MethodDetailsResults | None = None, sub_method_results: dict[str, typing.Any] | None = None, additional_outputs: dict[str, typing.Any] | None = None, raw_results: dict[str, typing.Any] | None = None, execution_summary: dict[str, typing.Any] | None = None, plot_config: scspill.config_models.PlotConfig | None = None, method: str = 'sar', inputs: scspill.utils.scspill_helpers.structures.SCSPILLInputs, alpha_posterior: scspill.utils.scspill_helpers.structures.AlphaPosterior, sar_posterior: scspill.utils.scspill_helpers.structures.SARPosterior, effects_detail: scspill.utils.scspill_helpers.structures.SCSPILLEffects, scm_weights: dict[str, float] | None = None, mcmc_summary_table: pandas.DataFrame | None = None) -> None`
Standardized results of the SCSPILL estimator.
Subclasses :class:`scspill.config_models.BaseEstimatorResults`, so the
flat effect surface (``att``, ``att_ci``, ``counterfactual``, ``gap``,
``donor_weights``, ``pre_rmse``, ``.plot()``) resolves through the shared
contract, while the Bayesian detail lives in typed fields:
* :attr:`inputs` -- the prepared panel and spatial weights;
* :attr:`alpha_posterior` / :attr:`sar_posterior` -- the two posterior
blocks with their full draw arrays;
* :attr:`effects_detail` -- ATT draws, counterfactual band, and the
per-donor spillover panel;
* :attr:`scm_weights` -- classical simplex-SCM comparator weights.
:attr:`method` records which spillover model produced the fit. It mirrors
``SCSPILLConfig.method`` and the suffix of
``method_details.method_name`` (``"SCSPILL/"``). Only ``"sar"``
exists today; a model added later declares its own posterior blocks
alongside :attr:`alpha_posterior` / :attr:`sar_posterior`, which are
SAR-specific, while :attr:`inputs`, :attr:`effects_detail` and the flat
effect surface are the cross-model contract.
### scspill.validation
#### `GewekeReport(table: 'pd.DataFrame', kernel: 'str', m_iid: 'int', m_mcmc: 'int', burn: 'int', batch_size: 'int', rho_support: 'tuple[float, float]', z_crit: 'float', n_flagged: 'int', passed: 'bool') -> None`
Result of the Geweke (2004) joint distribution test.
Attributes
----------
table : pd.DataFrame
One row per test statistic ``g`` with columns ``mean_iid``,
``mean_mcmc``, ``se_iid``, ``se_mcmc``, ``z``, ``pval``.
kernel : str
``"simple"`` or ``"production"``.
m_iid, m_mcmc, burn : int
Draw counts of the two simulators and the successive-conditional
burn-in.
batch_size : int
Batch size of the batch-means MCMC standard error.
rho_support : tuple of float
The canonical ``rho`` support used by *both* simulators (the prior
draws and the transition kernel; the R implementation used slightly
different supports on the two sides, which this port deliberately
unifies).
z_crit : float
Two-sided critical value used for the pass decision
(Bonferroni-adjusted across the statistics by default).
n_flagged : int
Number of statistics with ``|z| > z_crit``.
passed : bool
``n_flagged == 0``.
#### `PosteriorSummary(table: 'pd.DataFrame', rho_draws: 'np.ndarray', sigma2_draws: 'np.ndarray', meta: 'dict' = ) -> None`
Posterior summary of one Step-2 re-run (prior-sensitivity row).
Attributes
----------
table : pd.DataFrame
One row per parameter (``rho``, ``sigma2``, ``beta[k]``) with columns
``mean``, ``sd``, ``q025``, ``q975``.
rho_draws, sigma2_draws : np.ndarray
The retained chains.
meta : dict
The run settings (kernel, burn/keep/thin, priors, support, step,
seed, acceptance rate).
#### `PriorPredictiveResult(stats: 'pd.DataFrame', observed: 'dict | None', p_values: 'dict | None') -> None`
Result of a prior predictive check.
Attributes
----------
stats : pd.DataFrame
One row per prior draw, one column per summary statistic.
observed : dict or None
The statistics of the observed panel (when supplied).
p_values : dict or None
Per-statistic ``P(h_sim <= h_obs)`` over the finite simulated values.
#### `PriorSensitivityResult(table: 'pd.DataFrame', runs: 'tuple[PosteriorSummary, ...]') -> None`
Result of a prior-sensitivity grid sweep.
Attributes
----------
table : pd.DataFrame
One row per (grid row, parameter) pair: the grid settings joined with
the posterior summary.
runs : tuple of PosteriorSummary
The per-row posterior summaries, in grid order.
#### `ProductionKernel(T0: 'int', N: 'int', K: 'int', p: 'int', Wn: 'np.ndarray', wn: 'np.ndarray', alpha: 'np.ndarray', *, X: 'np.ndarray | None' = None, a0: 'float' = 1.0, b0: 'float' = 1.0, step_rho: 'float' = 0.05, rho_support: 'tuple[float, float] | None' = None, beta_prior: 'str' = 'horseshoe') -> 'None'`
Geweke kernel wrapping the production Step-2 sampler.
Uses :func:`~scspill.utils.scspill_helpers.sar.sampler_sar.draw_prior_state`
for the marginal-conditional side and
:func:`~scspill.utils.scspill_helpers.sar.sampler_sar.one_sweep` (fixed
Metropolis step, no adaptation) as the transition -- so a passing test
certifies the sampler users actually run, including the AR(1) factor
block and the horseshoe-on-``beta`` hierarchy.
Parameters mirror :class:`SimpleKernel`; ``beta_prior`` selects the
horseshoe (default) or ridge conditional.
#### `SimpleKernel(T0: 'int', N: 'int', K: 'int', p: 'int', Wn: 'np.ndarray', wn: 'np.ndarray', alpha: 'np.ndarray', *, X: 'np.ndarray | None' = None, a0: 'float' = 1.0, b0: 'float' = 1.0, step_rho: 'float' = 0.05, rho_support: 'tuple[float, float] | None' = None) -> 'None'`
The appendix's simplified SAR kernel (port of ``scspill_one_step_cpp``).
Priors: ``rho`` flat on ``rho_support`` (intersected with the spectral
bound of ``A``); ``sigma2 ~ IG(a0, b0)``; ``beta ~ N(0, I_K)``;
``Eta_ik ~ N(0, 1)``; ``Gamma_kt ~ N(0, 1)`` (no AR(1) dynamics).
Parameters
----------
T0, N, K, p : int
Panel and model dimensions.
Wn, wn : np.ndarray
Normalized spatial weights (``Wn`` row-stochastic, ``wn`` sums to 1).
alpha : np.ndarray
Fixed synthetic weights, shape ``(N,)``.
X : np.ndarray, optional
Fixed covariate cube ``(T0, N, K)`` (required when ``K > 0``).
a0, b0 : float, default 1.0
Inverse-gamma prior for ``sigma2``.
step_rho : float, default 0.05
Fixed random-walk Metropolis step (no adaptation: the Geweke test
needs a fixed Markov kernel).
rho_support : (float, float), optional
Support of ``rho``; defaults to the spectral bound of ``A`` and is
always intersected with it.
#### `SimpleState(rho: 'float', sigma2: 'float', beta: 'np.ndarray', Eta: 'np.ndarray', Gamma: 'np.ndarray') -> None`
Parameter state of the simplified (appendix) SAR model.
#### `batch_means_variance(x: 'np.ndarray', batch_size: 'int | None' = None) -> 'float'`
Batch-means estimate of the variance of an MCMC sample mean.
Non-finite values are dropped. With batch size ``b`` (default
``max(2, floor(sqrt(M)))``) and ``a = floor(M / b)`` full batches, the
estimate is ``b * var(batch means, ddof=1) / (a * b)``; when fewer than
two batches fit, the naive ``var / M`` is returned. Exact port of the R
``var_mcmc_batchmeans``.
Parameters
----------
x : np.ndarray
The chain.
batch_size : int, optional
Batch length ``b``.
Returns
-------
float
The estimated variance of ``mean(x)``.
#### `default_g_fn(summary: 'dict', Yc: 'np.ndarray', Y0_pre: 'np.ndarray', Wn: 'np.ndarray', wn: 'np.ndarray') -> 'dict'`
Compute the reference set of Geweke test statistics ``g(theta, y)``.
Port of the R ``default_g_fn``: the spillover intensity, log error
variance, panel mean and log variance, the spatial quadratic form
``tr(Yc W Yc') / (N T0)``, the correlation between the pseudo treated
series and the exposure-weighted panel, and the means of ``beta`` /
``Eta`` / ``Gamma`` (NaN when the block is absent).
Parameters
----------
summary : dict
Named parameter blocks from the kernel's ``state_summary``.
Yc : np.ndarray
Simulated panel, shape ``(T0, N)``.
Y0_pre : np.ndarray
Fixed pseudo treated pre-period series, shape ``(T0,)``.
Wn, wn : np.ndarray
Normalized spatial weights.
Returns
-------
dict
Statistic name -> value.
#### `geweke_test(*, kernel: 'str | object' = 'simple', T0: 'int' = 15, N: 'int' = 8, K: 'int' = 2, p: 'int' = 1, spatial_W: 'np.ndarray | None' = None, spatial_w: 'np.ndarray | None' = None, alpha: 'np.ndarray | None' = None, X: 'np.ndarray | None' = None, y0_pre: 'np.ndarray | None' = None, m_iid: 'int' = 20000, m_mcmc: 'int' = 20000, burn: 'int' = 5000, a0: 'float' = 1.0, b0: 'float' = 1.0, step_rho: 'float' = 0.05, rho_support: 'tuple[float, float] | None' = None, beta_prior: 'str' = 'horseshoe', g_fn: 'Callable | None' = None, batch_size: 'int | None' = None, alpha_level: 'float' = 0.05, bonferroni: 'bool' = True, seed: 'int | None' = None, verbose: 'bool' = False) -> 'GewekeReport'`
Run the Geweke joint distribution test of the Step-2 sampler.
Defaults mirror the replication package's Geweke driver: a
``T0 = 15 x N = 8`` panel on a chain graph with ``w = e_1``, ``K = 2``
iid standard-normal covariates, ``p = 1`` latent factor, and standardized
synthetic weights drawn ``N(0, 0.4^2)``.
Parameters
----------
kernel : {"simple", "production"} or kernel object, default "simple"
``"simple"`` tests the appendix's simplified model (comparable to the
R package's frozen table); ``"production"`` tests the sampler users
actually run. A custom object implementing ``draw_prior`` /
``simulate_data`` / ``transition`` / ``state_summary`` /
``extra_stats`` (see :mod:`scspill.validation.kernels`) is accepted.
T0, N, K, p : int
Panel and model dimensions (ignored for a custom kernel object).
spatial_W, spatial_w, alpha, X, y0_pre : arrays, optional
Overrides of the toy design; drawn/derived from ``seed`` when
omitted.
m_iid, m_mcmc, burn : int
Draw counts of the two simulators and the transition burn-in.
a0, b0, step_rho, rho_support, beta_prior : sampler settings
Passed to the kernel (``beta_prior`` is production-only).
g_fn : callable, optional
``g_fn(summary, Yc, y0_pre, Wn, wn) -> dict`` replacing
:func:`default_g_fn`.
batch_size : int, optional
Batch length for the MCMC standard error (default ``floor(sqrt(m))``).
alpha_level : float, default 0.05
Familywise test level.
bonferroni : bool, default True
Bonferroni-adjust the critical value across statistics.
seed : int, optional
Seed for all randomness (design draws included).
verbose : bool, default False
Print stage progress.
Returns
-------
GewekeReport
The per-statistic z table and the pass decision.
Notes
-----
The successive-conditional simulator mixes slowly along two well-known
directions, and an under-resolved run flags them as spurious failures:
* the ``rho`` chain moves only as far per sweep as its data-conditional
posterior allows, so *informative designs* (large ``T0 * N``) make it
diffuse slowly -- keep the test panel small (e.g. ``T0=4, N=4``);
* with ``K > 0`` and ``p > 0`` simultaneously, the ``X beta`` and
``Eta Gamma`` mean components trade off along a ridge whose relaxation
dominates the global data statistics -- test the blocks in isolation
first, and give joint configurations long chains with a large
``batch_size``;
* the production kernel's half-Cauchy scale hierarchies are funnel-shaped
and effectively untestable at feasible chain lengths (the reason the
replication package only ever tested the simplified kernel, at two
million draws).
A genuine incoherence shows up as a *stable, sign-consistent* z across
seeds and scales; mixing artifacts flip sign and shrink as the chain
grows.
#### `plot_geweke(report: 'GewekeReport', *, save: 'str | None' = None, show: 'bool' = False)`
Dot plot of the Geweke z-scores with the critical band.
Parameters
----------
report : GewekeReport
The output of :func:`scspill.validation.geweke_test`.
save : str, optional
File path to save the figure to.
show : bool, default False
Display the figure.
Returns
-------
matplotlib.figure.Figure
#### `plot_prior_predictive(result: 'PriorPredictiveResult', *, save: 'str | None' = None, show: 'bool' = False)`
Histogram grid of the prior predictive statistics with observed markers.
Parameters
----------
result : PriorPredictiveResult
The output of :func:`scspill.validation.prior_predictive`.
save : str, optional
File path to save the figure to.
show : bool, default False
Display the figure.
Returns
-------
matplotlib.figure.Figure
#### `ppc_stats(Yc: 'np.ndarray', Y0_pre: 'np.ndarray', Wn: 'np.ndarray', wn: 'np.ndarray') -> 'dict'`
Nine summary statistics of a pre-treatment donor panel.
Exact port of the R ``ppc_stats``: panel mean and log variance, the
spatial quadratic form ``tr(Yc W Yc') / (N T0)``, the correlation of the
treated series with the exposure-weighted panel, average lag-1 and lag-2
autocorrelations of the per-period cross-sectionally demeaned panel, the
share of variance in the first principal component, and the average
e1071 type-3 skewness and excess kurtosis across donors.
Parameters
----------
Yc : np.ndarray
Donor panel, shape ``(T0, N)``.
Y0_pre : np.ndarray
Treated pre-treatment series, shape ``(T0,)``.
Wn, wn : np.ndarray
Normalized spatial weights.
Returns
-------
dict
Statistic name -> value (NaN where degenerate).
#### `prior_predictive(Y0_pre: 'np.ndarray', spatial_W: 'np.ndarray', spatial_w: 'np.ndarray', alpha: 'np.ndarray', *, Yc_obs: 'np.ndarray | None' = None, X: 'np.ndarray | None' = None, p: 'int' = 0, a0: 'float' = 3.0, b0: 'float' = 1.0, rho_support: 'tuple[float, float] | None' = None, n_draws: 'int' = 2000, seed: 'int' = 123) -> 'PriorPredictiveResult'`
Prior predictive check of the Step-2 model.
Draws parameters from the appendix's simple priors, forward-simulates a
donor panel for each draw, computes the nine :func:`ppc_stats`, and --
when an observed panel is supplied -- reports per-statistic prior
predictive p-values ``P(h_sim <= h_obs)`` over the finite simulated
values.
Parameters
----------
Y0_pre : np.ndarray
Treated pre-treatment series, shape ``(T0,)``.
spatial_W, spatial_w : np.ndarray
Raw spatial weights (normalized internally).
alpha : np.ndarray
Fixed synthetic weights, shape ``(N,)``.
Yc_obs : np.ndarray, optional
Observed donor panel ``(T0, N)`` for the observed column/p-values.
X : np.ndarray, optional
Covariate cube ``(T0, N, K)``.
p : int, default 0
Number of latent factors.
a0, b0 : float, default 3.0 / 1.0
Inverse-gamma prior for ``sigma^2`` (the replication package's
prior-predictive defaults).
rho_support : (float, float), optional
Support of the flat ``rho`` prior (defaults to the spectral bound).
n_draws : int, default 2000
Number of prior draws.
seed : int, default 123
Random seed.
Returns
-------
PriorPredictiveResult
#### `prior_sensitivity(Yc_obs: 'np.ndarray', spatial_W: 'np.ndarray', spatial_w: 'np.ndarray', alpha: 'np.ndarray', grid: 'pd.DataFrame', *, X: 'np.ndarray | None' = None, p: 'int' = 0, m_burn: 'int' = 5000, m_keep: 'int' = 20000, thin: 'int' = 1, base_seed: 'int' = 1000, kernel: 'str' = 'simple') -> 'PriorSensitivityResult'`
Re-run the Step-2 posterior across a grid of prior settings.
Parameters
----------
Yc_obs, spatial_W, spatial_w, alpha, X, p : as in :func:`run_posterior_mcmc`
grid : pd.DataFrame
One row per setting with columns ``a0``, ``b0``, ``rho_lo``,
``rho_hi``, ``step_rho`` (the replication package's grid layout).
m_burn, m_keep, thin : int
MCMC budget shared by every row.
base_seed : int, default 1000
Row ``i`` uses seed ``base_seed + i``.
kernel : {"simple", "production"}, default "simple"
Returns
-------
PriorSensitivityResult
The grid-joined posterior summaries and the per-row runs.
#### `run_posterior_mcmc(Yc_obs: 'np.ndarray', spatial_W: 'np.ndarray', spatial_w: 'np.ndarray', alpha: 'np.ndarray', *, X: 'np.ndarray | None' = None, p: 'int' = 0, a0: 'float' = 1.0, b0: 'float' = 1.0, rho_support: 'tuple[float, float] | None' = None, step_rho: 'float' = 0.05, m_burn: 'int' = 5000, m_keep: 'int' = 20000, thin: 'int' = 1, seed: 'int' = 123, kernel: 'str' = 'simple') -> 'PosteriorSummary'`
Sample the Step-2 posterior on a fixed panel under explicit priors.
``kernel="simple"`` iterates the appendix's simplified sweep
(:class:`~scspill.validation.kernels.SimpleKernel`) on the observed
panel, mirroring the R ``run_mcmc_for_posterior``; ``kernel="production"``
re-runs the production Step-2 sampler with the overridden priors and
support.
Parameters
----------
Yc_obs : np.ndarray
Observed donor pre-period panel, shape ``(T0, N)``.
spatial_W, spatial_w : np.ndarray
Raw spatial weights (normalized internally).
alpha : np.ndarray
Fixed synthetic weights, shape ``(N,)``.
X : np.ndarray, optional
Covariate cube ``(T0, N, K)``.
p : int, default 0
Number of latent factors.
a0, b0 : float, default 1.0
Inverse-gamma prior for ``sigma^2``.
rho_support : (float, float), optional
Support for ``rho`` (defaults to the spectral bound of ``A``).
step_rho : float, default 0.05
Fixed Metropolis step.
m_burn, m_keep, thin : int
Burn-in, retained draws, and thinning interval.
seed : int, default 123
Random seed.
kernel : {"simple", "production"}, default "simple"
Returns
-------
PosteriorSummary
#### `simulate_yc_forward(rng: 'np.random.Generator', T0: 'int', Wn: 'np.ndarray', wn: 'np.ndarray', alpha: 'np.ndarray', rho: 'float', sigma2: 'float', X: 'np.ndarray | None' = None, beta: 'np.ndarray | None' = None, Eta: 'np.ndarray | None' = None, Gamma: 'np.ndarray | None' = None) -> 'np.ndarray'`
Forward-simulate the control panel from the SAR model.
``Yc_t = (I - rho A)^{-1} (X_t beta + Eta Gamma_t + eps_t)`` with
``A = W + w alpha'`` and ``eps_t ~ N(0, sigma2 I)``; a ridge-regularized
normal-equations solve is the fallback when the direct solve fails
(mirroring ``simulate_Yc_forward_cpp``).
Parameters
----------
rng : numpy.random.Generator
T0 : int
Number of periods to simulate.
Wn, wn, alpha : np.ndarray
Normalized spatial weights and synthetic weights.
rho, sigma2 : float
Spillover intensity and error variance.
X : np.ndarray, optional
Covariate cube ``(T0, N, K)``.
beta : np.ndarray, optional
Covariate coefficients ``(K,)``.
Eta : np.ndarray, optional
Factor loadings ``(N, p)``.
Gamma : np.ndarray, optional
Factor paths ``(p, T0)``.
Returns
-------
np.ndarray
Simulated outcomes, shape ``(T0, N)``.
### scspill.simulate
#### `SimDGP(Y0_pre: 'np.ndarray', Y0_post: 'np.ndarray', Yc_pre: 'np.ndarray', Yc_post: 'np.ndarray', X_pre: 'np.ndarray | None', X_post: 'np.ndarray | None', Wn: 'np.ndarray', wn: 'np.ndarray', truth: 'SimTruth', errors: 'np.ndarray | None' = None) -> None`
One simulated spillover panel (matrices plus ground truth).
Attributes
----------
Y0_pre, Y0_post : np.ndarray
Treated outcomes, shapes ``(T0,)`` and ``(T1,)``.
Yc_pre, Yc_post : np.ndarray
Control outcomes, shapes ``(T0, N)`` and ``(T1, N)``.
X_pre, X_post : np.ndarray or None
Covariate cubes ``(T0, N, K)`` / ``(T1, N, K)``, or None when K=0.
Wn, wn : np.ndarray
Normalized spatial weights used by the DGP.
truth : SimTruth
The planted parameters and realized effects.
errors : np.ndarray or None
The error draws ``(T0+T1, N)``, kept when ``keep_internals=True``
(enables exact-identity tests on the DGP).
#### `SimRunResult(metrics: 'pd.DataFrame', truth: 'SimTruth | None' = None, draws: 'dict | None' = None, per_time: 'dict | None' = None) -> None`
Result of one simulation replication.
Attributes
----------
metrics : pd.DataFrame
One row per method (SCM / BSCM / SCSPILL) with columns ``bias_ate``,
``mse_ate``, ``bias_point``, ``mse_point``, ``cover95_ate``,
``cover95_point``, ``method``. Coverage is NaN for SCM.
truth : SimTruth or None
The replication's ground truth (kept when ``keep_full``).
draws : dict or None
Posterior draw arrays (kept when ``keep_full``).
per_time : dict or None
Per-period effect paths and credible bands (kept when ``keep_full``).
#### `SimTruth(rho: 'float', sigma2: 'float', alpha: 'np.ndarray', beta: 'np.ndarray', tau_post: 'np.ndarray', y0_cf_post: 'np.ndarray') -> None`
Ground truth of one simulated panel.
#### `load_r_mc_reference(directory: 'str | Path') -> 'pd.DataFrame'`
Load the frozen R Monte Carlo results for comparison.
Parameters
----------
directory : str or Path
Folder holding the replication package's
``mc_study_N=T0=T1=.csv`` files (the frozen copies live
under ``benchmarks/reference/mc_result/`` in this repository).
Returns
-------
pd.DataFrame
The concatenated tidy frame in the same schema as :func:`mc_grid`,
with the R method label ``Proposed``/``SCSPILL`` normalized to
``SCSPILL``.
#### `make_w(N: 'int', treated=(0, 1, 2, 3)) -> 'np.ndarray'`
Build the indicator exposure vector linking the treated unit to selected controls.
Parameters
----------
N : int
Number of control units.
treated : int or sequence of int, default (0, 1, 2, 3)
Zero-based indices of the exposed controls. The default matches the
paper's simulation design (the first four donors exposed, R's
``1:4``); note the R *function* default is a single exposed donor.
Returns
-------
np.ndarray
A 0/1 vector of length ``N``.
#### `mc_grid(*, Ns: 'tuple[int, ...]' = (16, 36, 64), T0s: 'tuple[int, ...]' = (20, 50), T1: 'int' = 10, rhos: 'tuple[float, ...]' = (-0.8, -0.3, -0.1, 0.0, 0.1, 0.3, 0.8), sims_per: 'int' = 1000, K: 'int' = 1, beta: 'tuple[float, ...]' = (1.0,), sigma2: 'float' = 0.1, treated=(0, 1, 2, 3), m_iter: 'int' = 6000, burn: 'int' = 1000, step_rho: 'float' = 0.05, n_jobs: 'int | None' = 1, seed: 'int | None' = None, backend: 'str' = 'auto', progress: 'bool' = False) -> 'pd.DataFrame'`
Run the paper's Monte Carlo grid (Tables 1-2 design).
Defaults reproduce the replication package's full study: rook lattices of
``N in {16, 36, 64}`` units, pre-periods ``T0 in {20, 50}``, spillover
intensities ``rho in {-0.8, ..., 0.8}``, 1000 replications per cell, one
``N(0, 1)`` covariate with coefficient 1, error variance 0.1, and the
paper's planted ``alpha``. Reduce ``sims_per`` / the grids for quicker
runs.
Parameters
----------
Ns : tuple of int
Numbers of control units; each must be a perfect square.
T0s : tuple of int
Pre-treatment lengths.
T1 : int, default 10
Post-treatment length.
rhos : tuple of float
True spillover intensities.
sims_per : int, default 1000
Replications per scenario.
K, beta, sigma2, treated : DGP settings
See :func:`~scspill.simulate.dgp.scspill_sim_dgp`.
m_iter, burn, step_rho : MCMC budget
Sampler settings per replication (the paper uses 6000/1000/0.05).
n_jobs : int, optional
Parallel workers per scenario (see
:func:`~scspill.simulate.runner.run_many_sim`).
seed : int, optional
Master seed; per-scenario seed lists are spawned deterministically.
backend : {"auto", "numpy", "numba"}, default "auto"
Sampler kernel backend.
progress : bool, default False
Print one line per completed scenario.
Returns
-------
pd.DataFrame
Tidy results in the frozen R schema: ``N``, ``T0``, ``T1``, ``rho``,
``method``, ``bias_point``, ``rmse_point``, ``cover95_point``.
#### `rook_W(nrow: 'int', ncol: 'int', normalize: 'bool' = False) -> 'np.ndarray'`
Binary rook adjacency on an ``nrow x ncol`` lattice (row-major ids).
Parameters
----------
nrow, ncol : int
Lattice dimensions; the matrix has ``nrow * ncol`` units.
normalize : bool, default False
Row-normalize the adjacency before returning.
Returns
-------
np.ndarray
The ``(N, N)`` adjacency: 1 where two cells share an edge.
#### `run_many_sim(n_sims: 'int', dgp_args: 'dict', *, seeds: 'list | None' = None, n_jobs: 'int | None' = 1, keep_full: 'bool' = False, **run_kwargs: 'Any') -> 'list[SimRunResult]'`
Run many replications, optionally in parallel.
Parameters
----------
n_sims : int
Number of replications.
dgp_args : dict
Arguments for the DGP (see :func:`run_one_sim`).
seeds : list of int or of numpy.random.SeedSequence, optional
One seed per replication; independent ``SeedSequence`` children are
spawned when omitted.
n_jobs : int, optional
Worker processes; ``1`` runs serially, ``None`` reads the
``SCSPILL_NWORKERS`` environment variable (R-parity convenience).
Parallel runs return metrics-only results.
keep_full : bool, default False
Keep draw arrays on each result (serial runs only).
**run_kwargs
Forwarded to :func:`run_one_sim` (``m_iter``, ``burn``, ...).
Returns
-------
list of SimRunResult
In replication order.
#### `run_one_sim(dgp: 'SimDGP | None' = None, dgp_args: 'dict | None' = None, *, m_iter: 'int' = 2000, burn: 'int' = 1000, step_rho: 'float' = 0.02, a0: 'float' = 1.0, b0: 'float' = 1.0, seed=None, keep_full: 'bool' = False, backend: 'str' = 'auto') -> 'SimRunResult'`
Run one simulation replication: DGP, three estimators, metrics.
Parameters
----------
dgp : SimDGP, optional
A pre-simulated panel; when omitted, one is generated from
``dgp_args`` (which accepts either ``W`` or ``grid=(nrow, ncol)``,
plus the :func:`~scspill.simulate.dgp.scspill_sim_dgp` arguments;
``alpha`` defaults to the paper's planted weights).
dgp_args : dict, optional
Arguments for the DGP when ``dgp`` is not given.
m_iter, burn : int
MCMC budget for both sampler steps.
step_rho : float, default 0.02
Random-walk Metropolis step for ``rho`` (the R simulation's fixed
step; adaptation is off to mirror the reference study).
a0, b0 : float, default 1.0
Inverse-gamma prior for ``sigma^2``.
seed : int or numpy.random.SeedSequence, optional
Seeds the replication. The DGP (when generated here) and the
samplers draw from two *independent* child streams of this seed, so
the simulated data and the MCMC noise are never correlated.
keep_full : bool, default False
Keep the draw arrays and per-period paths on the result.
backend : {"auto", "numpy", "numba"}, default "auto"
Sampler kernel backend.
Returns
-------
SimRunResult
#### `scspill_sim_dgp(T0: 'int', T1: 'int', N: 'int', W: 'np.ndarray', w: 'np.ndarray', rho: 'float', sigma2: 'float', alpha: 'np.ndarray', K: 'int' = 0, beta: 'np.ndarray | None' = None, seed=None, mu_tau: 'float' = 1.0, sd_tau: 'float' = 1.0, keep_internals: 'bool' = False) -> 'SimDGP'`
Simulate one spillover panel from the paper's SAR DGP.
Parameters
----------
T0, T1 : int
Pre- and post-treatment lengths.
N : int
Number of control units.
W : np.ndarray
Raw control-to-control weights, ``(N, N)`` (row-normalized inside).
w : np.ndarray
Raw treated-to-control exposure, ``(N,)`` (sum-normalized inside).
rho : float
True spillover intensity.
sigma2 : float
Error variance.
alpha : np.ndarray
True synthetic weights, ``(N,)``.
K : int, default 0
Number of iid ``N(0, 1)`` covariates.
beta : np.ndarray, optional
Covariate coefficients (required when ``K > 0``).
seed : int or numpy.random.SeedSequence, optional
Seed for ``numpy.random.default_rng``.
mu_tau, sd_tau : float, default 1.0
Mean and sd of the per-period treatment effects.
keep_internals : bool, default False
Keep the error draws on the returned object (for identity tests).
Returns
-------
SimDGP
Raises
------
ScspillDataError
On shape mismatches or a near-singular SAR system.
#### `summarize_many(results: 'Iterable[SimRunResult]') -> 'pd.DataFrame'`
Aggregate replication metrics into per-method summary rows.
Parameters
----------
results : iterable of SimRunResult
The output of :func:`~scspill.simulate.runner.run_many_sim`.
Returns
-------
pd.DataFrame
One row per method (ordered SCM < BSCM < SCSPILL) with the NaN-aware
mean of each metric, its across-replication standard deviation
(``*_sd`` columns), and the derived ``rmse_ate`` / ``rmse_point``
(square roots of the mean squared errors).
### scspill.data
#### `SpillPanel(df: 'pd.DataFrame', spatial_w: 'pd.Series', spatial_W: 'pd.DataFrame', outcome: 'str', unitid: 'str', time: 'str', treat: 'str', covariates: 'tuple[str, ...]', treated_unit: 'str', treatment_time: 'int', description: 'str', column_map: 'dict[str, str]' = ) -> None`
A bundled case study, ready to feed :class:`scspill.SCSPILLConfig`.
Attributes
----------
df : pd.DataFrame
Long panel with a 0/1 ``treated`` indicator column (1 for the treated
unit in post-treatment periods).
spatial_w : pd.Series
Treated-to-control exposure weights, indexed by donor unit label.
Raw (unnormalized); the estimator scales it to sum to one.
spatial_W : pd.DataFrame
Control-to-control spatial weights, indexed and columned by donor unit
label. Raw (unnormalized); the estimator row-normalizes it.
outcome, unitid, time, treat : str
Column names in ``df``.
covariates : tuple of str
Covariate column names in ``df``.
treated_unit : str
Label of the treated unit.
treatment_time : int
First treated period.
description : str
One-paragraph provenance note.
column_map : dict
Mapping from original CSV headers to the column names in ``df``
(empty when no renaming was applied).
#### `load_california() -> 'SpillPanel'`
Load the California Proposition 99 tobacco panel with rook-contiguity weights.
The panel covers 39 U.S. states over 1970-2000 with per-capita cigarette
sales (``cigsale``) as the outcome and the retail cigarette price
(``retprice``) as a covariate. California passed Proposition 99 in 1988,
so ``treated`` is 1 for California from 1988 onward (18 pre-treatment
years, 13 post-treatment years, 38 donors). ``spatial_w`` is California's
rook-contiguity row over the donors (only Nevada shares a land border);
``spatial_W`` is the binary rook adjacency among the donors.
Returns
-------
SpillPanel
The panel, spatial weights, and column metadata. Use
:meth:`SpillPanel.config_kwargs` to feed it to
:class:`scspill.SCSPILLConfig` directly.
Examples
--------
```python
from scspill.data import load_california
panel = load_california()
panel.df.head()
panel.spatial_w["Nevada"] # 1.0 -- the only contiguous donor
```
#### `load_sudan(raw_names: 'bool' = False) -> 'SpillPanel'`
Load the 2011 Sudan secession panel with bilateral-trade weights.
The panel covers 34 African countries over 2000-2015 with GDP per capita
(constant 2015 US$) as the outcome and six World Development Indicators
series as covariates. South Sudan seceded in July 2011; the treated unit
"Sudan" aggregates Sudan and South Sudan after the split, and ``treated``
is 1 for Sudan from 2011 onward (11 pre-treatment years, 5 post-treatment
years, 33 donors). ``spatial_w`` holds each donor's average pre-period
bilateral trade with Sudan (IMF Direction of Trade Statistics) and
``spatial_W`` the average bilateral trade among donors -- both raw US$
values, normalized inside the estimator.
Parameters
----------
raw_names : bool, default False
When False, the R-mangled WDI column headers are renamed to clean
snake_case (see the returned ``column_map``); when True, the original
CSV headers are kept (useful for byte-level comparison against the R
replication package).
Returns
-------
SpillPanel
The panel, spatial weights, and column metadata.
Examples
--------
```python
from scspill.data import load_sudan
panel = load_sudan()
panel.covariates
panel.spatial_w.nlargest(2) # Egypt and Kenya trade most with Sudan
```