scspill 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scspill/__init__.py +82 -0
- scspill/config_models.py +589 -0
- scspill/data/__init__.py +286 -0
- scspill/data/files/california_W_matrix.csv +39 -0
- scspill/data/files/california_panel.csv +1210 -0
- scspill/data/files/california_w_vector.csv +39 -0
- scspill/data/files/sudan_W_matrix.csv +34 -0
- scspill/data/files/sudan_panel.csv +545 -0
- scspill/data/files/sudan_w_vector.csv +34 -0
- scspill/estimators/__init__.py +1 -0
- scspill/estimators/scspill.py +222 -0
- scspill/exceptions.py +25 -0
- scspill/py.typed +0 -0
- scspill/simulate/__init__.py +33 -0
- scspill/simulate/dgp.py +302 -0
- scspill/simulate/runner.py +309 -0
- scspill/simulate/summary.py +198 -0
- scspill/utils/__init__.py +1 -0
- scspill/utils/datautils.py +55 -0
- scspill/utils/effectutils.py +58 -0
- scspill/utils/fitutils.py +44 -0
- scspill/utils/plotting.py +410 -0
- scspill/utils/results_helpers.py +256 -0
- scspill/utils/scspill_helpers/__init__.py +6 -0
- scspill/utils/scspill_helpers/_kernels.py +514 -0
- scspill/utils/scspill_helpers/config.py +177 -0
- scspill/utils/scspill_helpers/diagnostics.py +196 -0
- scspill/utils/scspill_helpers/effects.py +385 -0
- scspill/utils/scspill_helpers/inference.py +64 -0
- scspill/utils/scspill_helpers/numba_kernels.py +81 -0
- scspill/utils/scspill_helpers/pipeline.py +140 -0
- scspill/utils/scspill_helpers/plotter.py +258 -0
- scspill/utils/scspill_helpers/sampler_alpha.py +125 -0
- scspill/utils/scspill_helpers/sampler_sar.py +565 -0
- scspill/utils/scspill_helpers/scm_baseline.py +131 -0
- scspill/utils/scspill_helpers/setup.py +280 -0
- scspill/utils/scspill_helpers/structures.py +452 -0
- scspill/validation/__init__.py +52 -0
- scspill/validation/geweke.py +374 -0
- scspill/validation/kernels.py +440 -0
- scspill/validation/plotter.py +102 -0
- scspill/validation/robustness.py +467 -0
- scspill/validation/structures.py +108 -0
- scspill-0.1.0.dist-info/METADATA +151 -0
- scspill-0.1.0.dist-info/RECORD +48 -0
- scspill-0.1.0.dist-info/WHEEL +5 -0
- scspill-0.1.0.dist-info/licenses/LICENSE +21 -0
- scspill-0.1.0.dist-info/top_level.txt +1 -0
scspill/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""scspill: Bayesian spatial-spillover synthetic control.
|
|
2
|
+
|
|
3
|
+
Implements:
|
|
4
|
+
|
|
5
|
+
Sakaguchi, S., & Tagawa, H. "Identification and Bayesian Inference for
|
|
6
|
+
Synthetic Control Methods with Spillover Effects." The Econometrics
|
|
7
|
+
Journal.
|
|
8
|
+
|
|
9
|
+
A synthetic control method that relaxes SUTVA: spillovers from the treated
|
|
10
|
+
unit to the donor pool are modeled through a spatial-autoregressive (SAR)
|
|
11
|
+
structure with user-supplied spatial weights, and both the treatment effect on
|
|
12
|
+
the treated and the spillover effect received by every control unit are
|
|
13
|
+
identified from the synthetic-control weights ``alpha``, the spillover
|
|
14
|
+
intensity ``rho``, and the weights ``(w, W)``. Estimation is a two-step
|
|
15
|
+
Bayesian sampler: a horseshoe Gibbs sampler for ``alpha`` on the
|
|
16
|
+
pre-treatment fit, then a SAR block (latent AR(1) factors, covariates, and a
|
|
17
|
+
random-walk Metropolis step for ``rho``) conditional on the posterior mean of
|
|
18
|
+
``alpha``.
|
|
19
|
+
|
|
20
|
+
Public API::
|
|
21
|
+
|
|
22
|
+
from scspill import SCSPILL, SCSPILLConfig
|
|
23
|
+
|
|
24
|
+
result = SCSPILL(config).fit() # -> SCSPILLResults
|
|
25
|
+
|
|
26
|
+
plus the companion subpackages :mod:`scspill.validation` (Geweke joint
|
|
27
|
+
distribution test, prior sensitivity, prior predictive checks),
|
|
28
|
+
:mod:`scspill.simulate` (the paper's Monte Carlo engine), and
|
|
29
|
+
:mod:`scspill.data` (the bundled California Proposition 99 and Sudan
|
|
30
|
+
secession case studies).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
34
|
+
|
|
35
|
+
try: # pragma: no cover - fallback exercised only in odd install states
|
|
36
|
+
__version__ = version("scspill")
|
|
37
|
+
except PackageNotFoundError: # pragma: no cover
|
|
38
|
+
__version__ = "0.0.0.dev0"
|
|
39
|
+
|
|
40
|
+
from scspill.exceptions import (
|
|
41
|
+
ScspillConfigError,
|
|
42
|
+
ScspillDataError,
|
|
43
|
+
ScspillError,
|
|
44
|
+
ScspillEstimationError,
|
|
45
|
+
ScspillPlottingError,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"SCSPILL",
|
|
50
|
+
"SCSPILLConfig",
|
|
51
|
+
"SCSPILLResults",
|
|
52
|
+
"ScspillConfigError",
|
|
53
|
+
"ScspillDataError",
|
|
54
|
+
"ScspillError",
|
|
55
|
+
"ScspillEstimationError",
|
|
56
|
+
"ScspillPlottingError",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Lazy exports (PEP 562): the estimator stack imports pandas/pydantic/scipy,
|
|
61
|
+
# so defer those imports until first attribute access for fast cold starts.
|
|
62
|
+
_LAZY_EXPORTS = {
|
|
63
|
+
"SCSPILL": ("scspill.estimators.scspill", "SCSPILL"),
|
|
64
|
+
"SCSPILLConfig": ("scspill.utils.scspill_helpers.config", "SCSPILLConfig"),
|
|
65
|
+
"SCSPILLResults": ("scspill.utils.scspill_helpers.structures", "SCSPILLResults"),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str):
|
|
70
|
+
"""Resolve lazily exported public names (PEP 562)."""
|
|
71
|
+
target = _LAZY_EXPORTS.get(name)
|
|
72
|
+
if target is not None:
|
|
73
|
+
import importlib
|
|
74
|
+
|
|
75
|
+
module_path, attr = target
|
|
76
|
+
return getattr(importlib.import_module(module_path), attr)
|
|
77
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def __dir__():
|
|
81
|
+
"""Include lazy exports in ``dir(scspill)``."""
|
|
82
|
+
return sorted(set(globals()) | set(_LAZY_EXPORTS))
|
scspill/config_models.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Shared configuration bases and standardized result models for scspill.
|
|
2
|
+
|
|
3
|
+
Adapted from ``mlsynth.config_models`` so that scspill estimators expose the
|
|
4
|
+
same configuration and result surface as mlsynth estimators: a pydantic config
|
|
5
|
+
inheriting :class:`BaseEstimatorConfig`, and a result object subclassing
|
|
6
|
+
:class:`BaseEstimatorResults` that populates the standardized sub-models
|
|
7
|
+
(:class:`EffectsResults`, :class:`TimeSeriesResults`, :class:`WeightsResults`,
|
|
8
|
+
:class:`InferenceResults`, :class:`FitDiagnosticsResults`,
|
|
9
|
+
:class:`MethodDetailsResults`).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from pydantic import BaseModel, Field, model_validator
|
|
17
|
+
|
|
18
|
+
from scspill.exceptions import ScspillDataError, ScspillPlottingError
|
|
19
|
+
|
|
20
|
+
_DEFAULT_CF_COLORS = ["red", "blue", "green", "purple", "orange", "brown"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PlotConfig(BaseModel):
|
|
24
|
+
"""Cosmetic configuration for an estimator's plots.
|
|
25
|
+
|
|
26
|
+
The single, nested home for everything a user might tune about a figure --
|
|
27
|
+
colors, line thickness and pattern, axis labels, title, and theme -- so the
|
|
28
|
+
look is fully orchestrated from the top config with sensible defaults the
|
|
29
|
+
user never has to touch. Consumed by :meth:`BaseEstimatorResults.plot` and
|
|
30
|
+
the shared :class:`scspill.utils.plotting.Plotter`.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Observed (treated) series.
|
|
34
|
+
observed_color: str = Field(
|
|
35
|
+
default="black", description="Color of the observed (treated) line."
|
|
36
|
+
)
|
|
37
|
+
observed_linewidth: float = Field(default=1.6, description="Width of the observed line.")
|
|
38
|
+
observed_linestyle: str = Field(
|
|
39
|
+
default="-", description="Matplotlib linestyle for the observed line."
|
|
40
|
+
)
|
|
41
|
+
# Counterfactual series (cycled / broadcast across multiple counterfactuals).
|
|
42
|
+
counterfactual_colors: list[str] = Field(
|
|
43
|
+
default_factory=lambda: list(_DEFAULT_CF_COLORS),
|
|
44
|
+
description="Color cycle for counterfactual line(s).",
|
|
45
|
+
)
|
|
46
|
+
counterfactual_linewidth: float = Field(
|
|
47
|
+
default=1.4, description="Width of counterfactual line(s)."
|
|
48
|
+
)
|
|
49
|
+
counterfactual_linestyle: str = Field(
|
|
50
|
+
default="--", description="Matplotlib linestyle for counterfactual line(s)."
|
|
51
|
+
)
|
|
52
|
+
# Reference lines.
|
|
53
|
+
show_intervention_line: bool = Field(
|
|
54
|
+
default=True, description="Draw a vertical line at the intervention."
|
|
55
|
+
)
|
|
56
|
+
intervention_color: str = Field(
|
|
57
|
+
default="grey", description="Color of the intervention reference line."
|
|
58
|
+
)
|
|
59
|
+
# Labels / title (None => a sensible default derived from the data/method).
|
|
60
|
+
xlabel: str | None = Field(default=None, description="X-axis label override.")
|
|
61
|
+
ylabel: str | None = Field(default=None, description="Y-axis label override.")
|
|
62
|
+
title: str | None = Field(default=None, description="Plot title override.")
|
|
63
|
+
# Theme: a dict of rcParams merged over the house style, or a named style.
|
|
64
|
+
theme: dict | str | None = Field(
|
|
65
|
+
default=None,
|
|
66
|
+
description="Custom theme: rcParams dict (merged over the scspill style) or a named Matplotlib style.",
|
|
67
|
+
)
|
|
68
|
+
# Lifecycle.
|
|
69
|
+
display: bool = Field(default=True, description="Show the figure.")
|
|
70
|
+
save: bool | str | dict = Field(
|
|
71
|
+
default=False, description="Save config: False, a filename, or a dict."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
class Config:
|
|
75
|
+
arbitrary_types_allowed = True
|
|
76
|
+
extra = "forbid"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BaseEstimatorConfig(BaseModel):
|
|
80
|
+
"""Base pydantic model for estimator configurations.
|
|
81
|
+
|
|
82
|
+
Includes the common fields required by every panel-data effect estimator:
|
|
83
|
+
the long panel ``df`` and the names of its outcome / treatment-indicator /
|
|
84
|
+
unit / time columns, plus display and plot cosmetics.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
df: pd.DataFrame = Field(..., description="Input panel data as a pandas DataFrame.")
|
|
88
|
+
outcome: str = Field(..., description="Name of the outcome variable column in the DataFrame.")
|
|
89
|
+
treat: str = Field(..., description="Name of the treatment indicator column in the DataFrame.")
|
|
90
|
+
unitid: str = Field(..., description="Name of the unit identifier column in the DataFrame.")
|
|
91
|
+
time: str = Field(..., description="Name of the time period column in the DataFrame.")
|
|
92
|
+
display_graphs: bool = Field(default=True, description="Whether to display plots of results.")
|
|
93
|
+
save: bool | str = Field(
|
|
94
|
+
default=False,
|
|
95
|
+
description=(
|
|
96
|
+
"Configuration for saving plots. If False (default), plots are not saved. "
|
|
97
|
+
"If True, plots are saved with default names. If a string, it's used as "
|
|
98
|
+
"the base filename for saved plots."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
counterfactual_color: list[str] = Field(
|
|
102
|
+
default_factory=lambda: ["red"],
|
|
103
|
+
description="Color(s) for counterfactual line(s) in plots. (Legacy; prefer `plot`.)",
|
|
104
|
+
)
|
|
105
|
+
treated_color: str = Field(
|
|
106
|
+
default="black",
|
|
107
|
+
description="Color for the treated unit line in plots. (Legacy; prefer `plot`.)",
|
|
108
|
+
)
|
|
109
|
+
plot: PlotConfig = Field(
|
|
110
|
+
default_factory=PlotConfig,
|
|
111
|
+
description="Nested cosmetic plot configuration (colors, line styles, labels, theme).",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
class Config:
|
|
115
|
+
arbitrary_types_allowed = True
|
|
116
|
+
extra = "forbid"
|
|
117
|
+
|
|
118
|
+
def resolved_plot(self) -> "PlotConfig":
|
|
119
|
+
"""Effective :class:`PlotConfig`, folding in the legacy flat fields.
|
|
120
|
+
|
|
121
|
+
If the user supplied a custom ``plot`` it is authoritative; otherwise
|
|
122
|
+
the legacy ``treated_color`` / ``counterfactual_color`` are mapped into
|
|
123
|
+
a fresh ``PlotConfig``. The behavioral ``display_graphs`` / ``save``
|
|
124
|
+
always apply unless overridden on ``plot``.
|
|
125
|
+
"""
|
|
126
|
+
if self.plot != PlotConfig():
|
|
127
|
+
pc = self.plot.model_copy()
|
|
128
|
+
else:
|
|
129
|
+
pc = PlotConfig(
|
|
130
|
+
observed_color=self.treated_color,
|
|
131
|
+
counterfactual_colors=list(self.counterfactual_color),
|
|
132
|
+
)
|
|
133
|
+
if pc.display is True: # default untouched -> honor legacy flag
|
|
134
|
+
pc.display = self.display_graphs
|
|
135
|
+
if pc.save is False: # default untouched -> honor legacy flag
|
|
136
|
+
pc.save = self.save
|
|
137
|
+
return pc
|
|
138
|
+
|
|
139
|
+
@model_validator(mode="after")
|
|
140
|
+
def check_df_and_columns(cls, values: Any) -> Any:
|
|
141
|
+
"""Validate that ``df`` is non-empty and holds the named columns."""
|
|
142
|
+
df = values.df
|
|
143
|
+
outcome = values.outcome
|
|
144
|
+
treat = values.treat
|
|
145
|
+
unitid = values.unitid
|
|
146
|
+
time = values.time
|
|
147
|
+
|
|
148
|
+
if df.empty:
|
|
149
|
+
raise ScspillDataError("Input DataFrame 'df' cannot be empty.")
|
|
150
|
+
|
|
151
|
+
required_columns = {outcome, treat, unitid, time}
|
|
152
|
+
missing_columns = required_columns - set(df.columns)
|
|
153
|
+
if missing_columns:
|
|
154
|
+
raise ScspillDataError(
|
|
155
|
+
f"Missing required columns in DataFrame 'df': {', '.join(sorted(missing_columns))}"
|
|
156
|
+
)
|
|
157
|
+
return values
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- Pydantic models for standardized estimator results ---
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ScspillResult(BaseModel):
|
|
164
|
+
"""Common base for every ``fit()`` return value in scspill.
|
|
165
|
+
|
|
166
|
+
The observational report family (:class:`BaseEstimatorResults`, aliased as
|
|
167
|
+
:class:`EffectResult`) measures a treatment effect on already-observed
|
|
168
|
+
data: ATT, counterfactual, weights, inference. ``isinstance(result,
|
|
169
|
+
ScspillResult)`` always holds for a fitted result, so library behaviour is
|
|
170
|
+
simple and predictable.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
class Config:
|
|
174
|
+
arbitrary_types_allowed = True
|
|
175
|
+
extra = "forbid"
|
|
176
|
+
json_encoders = {
|
|
177
|
+
np.ndarray: lambda arr: (
|
|
178
|
+
[None if pd.isna(x) else x for x in arr.tolist()] if arr is not None else None
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class EffectsResults(BaseModel):
|
|
184
|
+
"""Standardized model for reporting treatment effects."""
|
|
185
|
+
|
|
186
|
+
att: float | None = Field(default=None, description="Average Treatment Effect on the Treated.")
|
|
187
|
+
att_percent: float | None = Field(
|
|
188
|
+
default=None, description="Percentage Average Treatment Effect on the Treated."
|
|
189
|
+
)
|
|
190
|
+
att_std_err: float | None = Field(
|
|
191
|
+
default=None, description="Standard error of the ATT estimate."
|
|
192
|
+
)
|
|
193
|
+
additional_effects: dict[str, Any] | None = Field(
|
|
194
|
+
default=None, description="Dictionary for other estimator-specific effects."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
class Config:
|
|
198
|
+
extra = "allow" # Allow other effect measures to be added dynamically
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class FitDiagnosticsResults(BaseModel):
|
|
202
|
+
"""Standardized model for reporting goodness-of-fit diagnostics."""
|
|
203
|
+
|
|
204
|
+
rmse_pre: float | None = Field(
|
|
205
|
+
default=None, description="Root Mean Squared Error in the pre-treatment period."
|
|
206
|
+
)
|
|
207
|
+
r_squared_pre: float | None = Field(
|
|
208
|
+
default=None, description="R-squared value in the pre-treatment period."
|
|
209
|
+
)
|
|
210
|
+
rmse_post: float | None = Field(
|
|
211
|
+
default=None,
|
|
212
|
+
description="Root Mean Squared Error in the post-treatment period (often std of post-treatment gap).",
|
|
213
|
+
)
|
|
214
|
+
additional_metrics: dict[str, Any] | None = Field(
|
|
215
|
+
default=None, description="Dictionary for other fit metrics."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
class Config:
|
|
219
|
+
extra = "allow"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class TimeSeriesResults(BaseModel):
|
|
223
|
+
"""Standardized model for reporting key time series vectors."""
|
|
224
|
+
|
|
225
|
+
observed_outcome: np.ndarray | None = Field(
|
|
226
|
+
default=None, description="Observed outcome vector for the treated unit."
|
|
227
|
+
)
|
|
228
|
+
counterfactual_outcome: np.ndarray | None = Field(
|
|
229
|
+
default=None, description="Estimated counterfactual outcome vector."
|
|
230
|
+
)
|
|
231
|
+
estimated_gap: np.ndarray | None = Field(
|
|
232
|
+
default=None, description="Estimated treatment effect vector (observed - counterfactual)."
|
|
233
|
+
)
|
|
234
|
+
time_periods: np.ndarray | None = Field(
|
|
235
|
+
default=None, description="Array of time periods corresponding to the series."
|
|
236
|
+
)
|
|
237
|
+
intervention_time: Any | None = Field(
|
|
238
|
+
default=None,
|
|
239
|
+
description="Time label at the pre/post boundary, for the plot's intervention reference line.",
|
|
240
|
+
)
|
|
241
|
+
# Canonical per-period prediction interval on the counterfactual: aligned to
|
|
242
|
+
# ``time_periods``, NaN where the method has no band there. Populate via
|
|
243
|
+
# :func:`scspill.utils.results_helpers.build_effect_submodels`'s
|
|
244
|
+
# ``prediction_interval`` argument, not by hand.
|
|
245
|
+
counterfactual_lower: np.ndarray | None = Field(
|
|
246
|
+
default=None,
|
|
247
|
+
description="Per-period pointwise lower bound on the counterfactual (NaN where absent).",
|
|
248
|
+
)
|
|
249
|
+
counterfactual_upper: np.ndarray | None = Field(
|
|
250
|
+
default=None,
|
|
251
|
+
description="Per-period pointwise upper bound on the counterfactual (NaN where absent).",
|
|
252
|
+
)
|
|
253
|
+
counterfactual_lower_simultaneous: np.ndarray | None = Field(
|
|
254
|
+
default=None,
|
|
255
|
+
description="Per-period simultaneous (joint-coverage) lower bound on the counterfactual.",
|
|
256
|
+
)
|
|
257
|
+
counterfactual_upper_simultaneous: np.ndarray | None = Field(
|
|
258
|
+
default=None,
|
|
259
|
+
description="Per-period simultaneous (joint-coverage) upper bound on the counterfactual.",
|
|
260
|
+
)
|
|
261
|
+
prediction_interval_level: float | None = Field(
|
|
262
|
+
default=None, description="Nominal coverage of the band (e.g. 0.90 for a 90% interval)."
|
|
263
|
+
)
|
|
264
|
+
prediction_interval_kind: str | None = Field(
|
|
265
|
+
default=None, description="Provenance/type tag for the band, e.g. 'bayesian', 'conformal'."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def has_prediction_interval(self) -> bool:
|
|
270
|
+
"""True when a per-period pointwise band is present and not all-NaN."""
|
|
271
|
+
lo = self.counterfactual_lower
|
|
272
|
+
if lo is None:
|
|
273
|
+
return False
|
|
274
|
+
return bool(np.any(np.isfinite(np.asarray(lo, dtype=float))))
|
|
275
|
+
|
|
276
|
+
class Config:
|
|
277
|
+
arbitrary_types_allowed = True
|
|
278
|
+
extra = "allow"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class WeightsResults(BaseModel):
|
|
282
|
+
"""Standardized model for reporting estimator weights.
|
|
283
|
+
|
|
284
|
+
``weights`` are not one thing across synthetic-control methods, so this
|
|
285
|
+
model exposes the real variety as optional faces; an estimator populates
|
|
286
|
+
whichever apply:
|
|
287
|
+
|
|
288
|
+
* ``donor_weights`` -- ``{donor label: weight}`` (most SCMs);
|
|
289
|
+
* ``time_weights`` -- ``{period: weight}``;
|
|
290
|
+
* ``unit_weights`` -- a weight matrix / array.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
donor_weights: dict[str, float] | None = Field(
|
|
294
|
+
default=None, description="Dictionary mapping donor unit names/IDs to their weights."
|
|
295
|
+
)
|
|
296
|
+
time_weights: dict[Any, float] | None = Field(
|
|
297
|
+
default=None, description="Dictionary mapping time periods to weights."
|
|
298
|
+
)
|
|
299
|
+
unit_weights: np.ndarray | None = Field(
|
|
300
|
+
default=None,
|
|
301
|
+
description="Unit weight matrix/array for estimators whose weights are not a donor mapping.",
|
|
302
|
+
)
|
|
303
|
+
summary_stats: dict[str, Any] | None = Field(
|
|
304
|
+
default=None, description="Summary statistics about weights (e.g., cardinality)."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def weight_vector(self) -> np.ndarray | None:
|
|
309
|
+
"""The donor/control weights as a dense :class:`numpy.ndarray`.
|
|
310
|
+
|
|
311
|
+
A computed view, not a stored copy: the values of ``donor_weights`` in
|
|
312
|
+
their existing key order (use ``list(donor_weights)`` for the aligned
|
|
313
|
+
labels). ``None`` when no donor weights are present.
|
|
314
|
+
"""
|
|
315
|
+
if self.donor_weights is None:
|
|
316
|
+
return None
|
|
317
|
+
return np.asarray(list(self.donor_weights.values()), dtype=float)
|
|
318
|
+
|
|
319
|
+
class Config:
|
|
320
|
+
arbitrary_types_allowed = True
|
|
321
|
+
extra = "allow"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class InferenceResults(BaseModel):
|
|
325
|
+
"""Standardized model for reporting statistical inference results."""
|
|
326
|
+
|
|
327
|
+
p_value: float | None = Field(default=None, description="P-value for the estimated ATT.")
|
|
328
|
+
ci_lower: float | None = Field(
|
|
329
|
+
default=None, description="Lower bound of the confidence interval for ATT."
|
|
330
|
+
)
|
|
331
|
+
ci_upper: float | None = Field(
|
|
332
|
+
default=None, description="Upper bound of the confidence interval for ATT."
|
|
333
|
+
)
|
|
334
|
+
standard_error: float | None = Field(
|
|
335
|
+
default=None, description="Standard error of the ATT estimate."
|
|
336
|
+
)
|
|
337
|
+
confidence_level: float | None = Field(
|
|
338
|
+
default=None, description="Confidence level used for the CI (e.g., 0.95 for 95%)."
|
|
339
|
+
)
|
|
340
|
+
method: str | None = Field(
|
|
341
|
+
default=None,
|
|
342
|
+
description="Method used for inference (e.g., 'bayesian_posterior', 'placebo').",
|
|
343
|
+
)
|
|
344
|
+
details: Any | None = Field(
|
|
345
|
+
default=None,
|
|
346
|
+
description="More detailed inference results, e.g., posterior summaries or draw arrays.",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def se(self) -> float | None:
|
|
351
|
+
"""Short read-only alias for :attr:`standard_error`."""
|
|
352
|
+
return self.standard_error
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def ci(self) -> tuple[float | None, float | None]:
|
|
356
|
+
"""Read-only ``(lower, upper)`` view of :attr:`ci_lower` / :attr:`ci_upper`."""
|
|
357
|
+
return (self.ci_lower, self.ci_upper)
|
|
358
|
+
|
|
359
|
+
class Config:
|
|
360
|
+
arbitrary_types_allowed = True
|
|
361
|
+
extra = "allow"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class MethodDetailsResults(BaseModel):
|
|
365
|
+
"""Standardized model for reporting details about the estimation method/variant used."""
|
|
366
|
+
|
|
367
|
+
method_name: str | None = Field(
|
|
368
|
+
default=None, description="Name of the specific method or variant used."
|
|
369
|
+
)
|
|
370
|
+
is_recommended: bool | None = Field(
|
|
371
|
+
default=None, description="Flag indicating if this method variant was recommended."
|
|
372
|
+
)
|
|
373
|
+
parameters_used: dict[str, Any] | None = Field(
|
|
374
|
+
default=None, description="Key parameters used for this specific result set."
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
class Config:
|
|
378
|
+
extra = "allow"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class BaseEstimatorResults(ScspillResult):
|
|
382
|
+
"""The observational report: standardized result of an effect estimator.
|
|
383
|
+
|
|
384
|
+
Aliased as :class:`EffectResult`. Carries the standardized sub-models
|
|
385
|
+
(:class:`EffectsResults`, :class:`TimeSeriesResults`,
|
|
386
|
+
:class:`WeightsResults`, :class:`InferenceResults`,
|
|
387
|
+
:class:`FitDiagnosticsResults`) plus flat convenience accessors (``att``,
|
|
388
|
+
``att_ci``, ``counterfactual``, ``gap``, ``donor_weights``, ``pre_rmse``)
|
|
389
|
+
so every effect estimator exposes one predictable surface.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
effects: EffectsResults | None = None
|
|
393
|
+
fit_diagnostics: FitDiagnosticsResults | None = None
|
|
394
|
+
time_series: TimeSeriesResults | None = None
|
|
395
|
+
weights: WeightsResults | None = None
|
|
396
|
+
inference: InferenceResults | None = None
|
|
397
|
+
method_details: MethodDetailsResults | None = None
|
|
398
|
+
|
|
399
|
+
sub_method_results: dict[str, Any] | None = Field(
|
|
400
|
+
default=None, description="Results for sub-methods or variants."
|
|
401
|
+
)
|
|
402
|
+
additional_outputs: dict[str, Any] | None = Field(
|
|
403
|
+
default=None, description="Dictionary for any other outputs specific to the estimator."
|
|
404
|
+
)
|
|
405
|
+
raw_results: dict[str, Any] | None = Field(
|
|
406
|
+
default=None,
|
|
407
|
+
exclude=True,
|
|
408
|
+
description="Original raw results dictionary from the estimator's core logic.",
|
|
409
|
+
)
|
|
410
|
+
execution_summary: dict[str, Any] | None = Field(
|
|
411
|
+
default=None, description="Summary of execution, including any errors or warnings."
|
|
412
|
+
)
|
|
413
|
+
plot_config: PlotConfig | None = Field(
|
|
414
|
+
default=None,
|
|
415
|
+
exclude=True,
|
|
416
|
+
description="Resolved PlotConfig captured at fit time; drives plot().",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
class Config:
|
|
420
|
+
arbitrary_types_allowed = True
|
|
421
|
+
extra = "forbid"
|
|
422
|
+
json_encoders = {
|
|
423
|
+
np.ndarray: lambda arr: (
|
|
424
|
+
[None if pd.isna(x) else x for x in arr.tolist()] if arr is not None else None
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
# Flat convenience accessors -- the minimum read contract every effect
|
|
430
|
+
# estimator satisfies, delegating to the standardized sub-models.
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
@property
|
|
433
|
+
def att(self) -> float | None:
|
|
434
|
+
"""Average treatment effect on the treated."""
|
|
435
|
+
return self.effects.att if self.effects else None
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def att_ci(self) -> tuple | None:
|
|
439
|
+
"""``(lower, upper)`` confidence interval for the ATT, if available."""
|
|
440
|
+
inf = self.inference
|
|
441
|
+
if inf is not None and inf.ci_lower is not None and inf.ci_upper is not None:
|
|
442
|
+
return (inf.ci_lower, inf.ci_upper)
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def counterfactual(self) -> np.ndarray | None:
|
|
447
|
+
"""Estimated counterfactual outcome path."""
|
|
448
|
+
return self.time_series.counterfactual_outcome if self.time_series else None
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def gap(self) -> np.ndarray | None:
|
|
452
|
+
"""Estimated gap (observed minus counterfactual)."""
|
|
453
|
+
return self.time_series.estimated_gap if self.time_series else None
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def donor_weights(self) -> dict[str, float] | None:
|
|
457
|
+
"""Donor weights ``{label: weight}``."""
|
|
458
|
+
return self.weights.donor_weights if self.weights else None
|
|
459
|
+
|
|
460
|
+
@property
|
|
461
|
+
def weight_vector(self) -> np.ndarray | None:
|
|
462
|
+
"""Donor/control weights as a dense array (see :attr:`WeightsResults.weight_vector`)."""
|
|
463
|
+
return self.weights.weight_vector if self.weights else None
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def pre_rmse(self) -> float | None:
|
|
467
|
+
"""Pre-treatment root-mean-squared fit error."""
|
|
468
|
+
return self.fit_diagnostics.rmse_pre if self.fit_diagnostics else None
|
|
469
|
+
|
|
470
|
+
# ------------------------------------------------------------------
|
|
471
|
+
# Standard plotting -- one entry point for every effect estimator,
|
|
472
|
+
# driven by the standardized ``time_series`` sub-model and the resolved
|
|
473
|
+
# ``PlotConfig`` captured at fit time. Bespoke estimators override.
|
|
474
|
+
# ------------------------------------------------------------------
|
|
475
|
+
def plot(self, kind: str = "auto", *, ax: Any = None, **overrides: Any) -> Any:
|
|
476
|
+
"""Render the standard effect plot from the standardized result.
|
|
477
|
+
|
|
478
|
+
Parameters
|
|
479
|
+
----------
|
|
480
|
+
kind : {"auto", "counterfactual", "gap"}, default "auto"
|
|
481
|
+
``"auto"``/``"counterfactual"`` draw observed vs. counterfactual;
|
|
482
|
+
``"gap"`` draws the per-period effect.
|
|
483
|
+
ax : matplotlib Axes, optional
|
|
484
|
+
Draw into an existing axis (multi-panel composition).
|
|
485
|
+
**overrides
|
|
486
|
+
Per-call cosmetic overrides applied over the stored PlotConfig
|
|
487
|
+
(e.g. ``title=...``, ``observed_color=...``).
|
|
488
|
+
|
|
489
|
+
Returns
|
|
490
|
+
-------
|
|
491
|
+
matplotlib.axes.Axes
|
|
492
|
+
"""
|
|
493
|
+
import matplotlib.pyplot as plt
|
|
494
|
+
|
|
495
|
+
from .utils.plotting import Plotter, scspill_style
|
|
496
|
+
|
|
497
|
+
ts = self.time_series
|
|
498
|
+
if ts is None or ts.observed_outcome is None:
|
|
499
|
+
raise ScspillPlottingError("Result has no time_series.observed_outcome to plot.")
|
|
500
|
+
|
|
501
|
+
pc = self.plot_config or PlotConfig()
|
|
502
|
+
pc = pc.model_copy(update=overrides) if overrides else pc
|
|
503
|
+
|
|
504
|
+
observed = np.asarray(ts.observed_outcome).reshape(-1)
|
|
505
|
+
times = (
|
|
506
|
+
np.asarray(ts.time_periods)
|
|
507
|
+
if ts.time_periods is not None
|
|
508
|
+
else np.arange(observed.shape[0])
|
|
509
|
+
)
|
|
510
|
+
intervention = ts.intervention_time if pc.show_intervention_line else None
|
|
511
|
+
method = self.method_details.method_name if self.method_details else None
|
|
512
|
+
xlabel = pc.xlabel if pc.xlabel is not None else "Time"
|
|
513
|
+
ylabel = pc.ylabel if pc.ylabel is not None else "Outcome"
|
|
514
|
+
|
|
515
|
+
with scspill_style(pc.theme):
|
|
516
|
+
plotter = Plotter.from_config(pc)
|
|
517
|
+
if kind in ("auto", "counterfactual"):
|
|
518
|
+
cf = ts.counterfactual_outcome
|
|
519
|
+
if cf is None:
|
|
520
|
+
raise ScspillPlottingError("Result has no counterfactual_outcome to plot.")
|
|
521
|
+
interval = None
|
|
522
|
+
if ts.has_prediction_interval:
|
|
523
|
+
interval = (ts.counterfactual_lower, ts.counterfactual_upper)
|
|
524
|
+
ax = plotter.observed_vs_counterfactual(
|
|
525
|
+
times,
|
|
526
|
+
observed,
|
|
527
|
+
np.asarray(cf).reshape(-1),
|
|
528
|
+
treated_label=method or "Treated",
|
|
529
|
+
intervention=intervention,
|
|
530
|
+
interval=interval,
|
|
531
|
+
interval_label=(
|
|
532
|
+
f"{round((ts.prediction_interval_level or 0.95) * 100)}% credible interval"
|
|
533
|
+
if ts.prediction_interval_kind == "bayesian"
|
|
534
|
+
else "Prediction interval"
|
|
535
|
+
),
|
|
536
|
+
outcome=ylabel,
|
|
537
|
+
time=xlabel,
|
|
538
|
+
title=pc.title
|
|
539
|
+
or (
|
|
540
|
+
f"{method}: observed vs. counterfactual"
|
|
541
|
+
if method
|
|
542
|
+
else "Observed vs. counterfactual"
|
|
543
|
+
),
|
|
544
|
+
ax=ax,
|
|
545
|
+
)
|
|
546
|
+
elif kind == "gap":
|
|
547
|
+
ax = plotter.gap(
|
|
548
|
+
times,
|
|
549
|
+
np.asarray(ts.estimated_gap).reshape(-1),
|
|
550
|
+
intervention=intervention,
|
|
551
|
+
outcome=ylabel,
|
|
552
|
+
time=xlabel,
|
|
553
|
+
title=pc.title or "Estimated gap",
|
|
554
|
+
ax=ax,
|
|
555
|
+
)
|
|
556
|
+
else:
|
|
557
|
+
raise ScspillPlottingError(f"Unknown plot kind {kind!r}.")
|
|
558
|
+
|
|
559
|
+
fig = ax.figure
|
|
560
|
+
if pc.save:
|
|
561
|
+
fname = pc.save if isinstance(pc.save, str) else f"{method or 'scspill'}_plot.png"
|
|
562
|
+
fig.savefig(fname, bbox_inches="tight")
|
|
563
|
+
if pc.display:
|
|
564
|
+
plt.show()
|
|
565
|
+
return ax
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ``EffectResult`` is the canonical, intention-revealing name for the
|
|
569
|
+
# observational report; ``BaseEstimatorResults`` matches the mlsynth alias.
|
|
570
|
+
EffectResult = BaseEstimatorResults
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ---------------------------------------------------------------------------
|
|
574
|
+
# Lazy re-exports of per-estimator configs (PEP 562), matching mlsynth's
|
|
575
|
+
# convention: configs live next to their helper packages and are re-exported
|
|
576
|
+
# from this module so ``from scspill.config_models import SCSPILLConfig`` works.
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
_RELOCATED_CONFIGS = {
|
|
579
|
+
"SCSPILLConfig": "scspill.utils.scspill_helpers.config",
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def __getattr__(name: str): # PEP 562 module-level attribute hook
|
|
584
|
+
module_path = _RELOCATED_CONFIGS.get(name)
|
|
585
|
+
if module_path is not None:
|
|
586
|
+
import importlib
|
|
587
|
+
|
|
588
|
+
return getattr(importlib.import_module(module_path), name)
|
|
589
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|