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.
Files changed (48) hide show
  1. scspill/__init__.py +82 -0
  2. scspill/config_models.py +589 -0
  3. scspill/data/__init__.py +286 -0
  4. scspill/data/files/california_W_matrix.csv +39 -0
  5. scspill/data/files/california_panel.csv +1210 -0
  6. scspill/data/files/california_w_vector.csv +39 -0
  7. scspill/data/files/sudan_W_matrix.csv +34 -0
  8. scspill/data/files/sudan_panel.csv +545 -0
  9. scspill/data/files/sudan_w_vector.csv +34 -0
  10. scspill/estimators/__init__.py +1 -0
  11. scspill/estimators/scspill.py +222 -0
  12. scspill/exceptions.py +25 -0
  13. scspill/py.typed +0 -0
  14. scspill/simulate/__init__.py +33 -0
  15. scspill/simulate/dgp.py +302 -0
  16. scspill/simulate/runner.py +309 -0
  17. scspill/simulate/summary.py +198 -0
  18. scspill/utils/__init__.py +1 -0
  19. scspill/utils/datautils.py +55 -0
  20. scspill/utils/effectutils.py +58 -0
  21. scspill/utils/fitutils.py +44 -0
  22. scspill/utils/plotting.py +410 -0
  23. scspill/utils/results_helpers.py +256 -0
  24. scspill/utils/scspill_helpers/__init__.py +6 -0
  25. scspill/utils/scspill_helpers/_kernels.py +514 -0
  26. scspill/utils/scspill_helpers/config.py +177 -0
  27. scspill/utils/scspill_helpers/diagnostics.py +196 -0
  28. scspill/utils/scspill_helpers/effects.py +385 -0
  29. scspill/utils/scspill_helpers/inference.py +64 -0
  30. scspill/utils/scspill_helpers/numba_kernels.py +81 -0
  31. scspill/utils/scspill_helpers/pipeline.py +140 -0
  32. scspill/utils/scspill_helpers/plotter.py +258 -0
  33. scspill/utils/scspill_helpers/sampler_alpha.py +125 -0
  34. scspill/utils/scspill_helpers/sampler_sar.py +565 -0
  35. scspill/utils/scspill_helpers/scm_baseline.py +131 -0
  36. scspill/utils/scspill_helpers/setup.py +280 -0
  37. scspill/utils/scspill_helpers/structures.py +452 -0
  38. scspill/validation/__init__.py +52 -0
  39. scspill/validation/geweke.py +374 -0
  40. scspill/validation/kernels.py +440 -0
  41. scspill/validation/plotter.py +102 -0
  42. scspill/validation/robustness.py +467 -0
  43. scspill/validation/structures.py +108 -0
  44. scspill-0.1.0.dist-info/METADATA +151 -0
  45. scspill-0.1.0.dist-info/RECORD +48 -0
  46. scspill-0.1.0.dist-info/WHEEL +5 -0
  47. scspill-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. 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))
@@ -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}")