Source code for causalpy.pymc_models

#   Copyright 2022 - 2026 The PyMC Labs Developers
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.
"""Custom PyMC models for causal inference."""

import inspect
import warnings
from copy import deepcopy
from typing import Any, Literal

import arviz as az
import numpy as np
import pandas as pd
import pymc as pm
import pytensor.tensor as pt
import xarray as xr
from patsy import dmatrix
from pymc_extras.prior import Prior

from causalpy.constants import HDI_PROB
from causalpy.utils import _bayesian_r2_score, round_num
from causalpy.variable_selection_priors import VariableSelectionPrior


def _as_xtensor_obs_ind(x: Any) -> Any:
    """Convert a tensor-like input to an obs_ind xtensor."""
    import pytensor.xtensor as ptx

    return ptx.as_xtensor(x, dims=("obs_ind",))


def _uses_xtensor_api(function: Any) -> bool:
    """Return True when an upstream transform expects xtensor inputs."""
    try:
        return "as_xtensor" in inspect.getsource(function)
    except (OSError, TypeError):
        code = getattr(function, "__code__", None)
        return code is not None and "as_xtensor" in code.co_names


def _call_time_component_apply(
    component: Any,
    t: Any,
) -> Any:
    """Call time components across tensor and xtensor variants."""
    parameters = inspect.signature(component.apply).parameters
    if _uses_xtensor_api(component.apply) or (
        "sum" in parameters and "result_callback" not in parameters
    ):
        t = _as_xtensor_obs_ind(t)
    result = component.apply(t)
    return getattr(result, "values", result)


def _call_seasonality_component_apply(
    seasonality_component: Any,
    dayofperiod: Any,
) -> Any:
    """Call seasonality components across tensor and xtensor variants."""
    return _call_time_component_apply(seasonality_component, dayofperiod)


def _extend_datatree_left(idata: xr.DataTree, other: xr.DataTree) -> xr.DataTree:
    """Add DataTree groups without replacing groups already in ``idata``."""
    for group, node in other.children.items():
        if group not in idata:
            idata[group] = node
    return idata


def _assign_group_coords(idata: xr.DataTree, group: str, **coords: Any) -> xr.DataTree:
    """Assign coordinates to a DataTree group through its Dataset."""
    idata[group] = idata[group].to_dataset().assign_coords(**coords)
    return idata


[docs] class PyMCModel(pm.Model): """A wrapper class for PyMC models. This provides a scikit-learn like interface with methods like `fit`, `predict`, and `score`. It also provides other methods which are useful for causal inference. The base :meth:`_data_setter` assumes the model graph contains mutable data nodes named ``"X"`` (predictors) and ``"y"`` (target). Subclasses that use different data nodes should override :meth:`_data_setter`. See :class:`BayesianBasisExpansionTimeSeries` for an example. Parameters ---------- sample_kwargs : dict, optional Dictionary of kwargs that get unpacked and passed to the :func:`pymc.sample` function. Defaults to an empty dictionary if ``None``. priors : dict, optional Dictionary of priors for the model. Defaults to ``None``, in which case default priors are used. Examples -------- >>> import causalpy as cp >>> import numpy as np >>> import pymc as pm >>> from causalpy.pymc_models import PyMCModel >>> class MyToyModel(PyMCModel): ... def build_model(self, X, y, coords): ... with self: ... self.add_coords(coords) ... X_ = pm.Data(name="X", value=X) ... y_ = pm.Data(name="y", value=y) ... beta = pm.Normal( ... "beta", mu=0, sigma=1, shape=(y.shape[1], X.shape[1]) ... ) ... sigma = pm.HalfNormal("sigma", sigma=1, shape=y.shape[1]) ... mu = pm.Deterministic( ... "mu", pm.math.dot(X_, beta.T), dims=["obs_ind", "treated_units"] ... ) ... pm.Normal("y_hat", mu=mu, sigma=sigma, observed=y_) >>> rng = np.random.default_rng(seed=42) >>> X = xr.DataArray( ... rng.normal(loc=0, scale=1, size=(20, 2)), ... dims=["obs_ind", "coeffs"], ... coords={"obs_ind": np.arange(20), "coeffs": ["coeff_0", "coeff_1"]}, ... ) >>> y = xr.DataArray( ... rng.normal(loc=0, scale=1, size=(20, 1)), ... dims=["obs_ind", "treated_units"], ... coords={"obs_ind": np.arange(20), "treated_units": ["unit_0"]}, ... ) >>> model = MyToyModel( ... sample_kwargs={ ... "chains": 2, ... "draws": 2000, ... "progressbar": False, ... "random_seed": 42, ... } ... ) >>> _ = model.fit( ... X, ... y, ... coords={ ... "coeffs": ["coeff_0", "coeff_1"], ... "obs_ind": np.arange(20), ... "treated_units": ["unit_0"], ... }, ... ) >>> model.score(X, y) # doctest: +ELLIPSIS unit_0_r2 ... unit_0_r2_std ... dtype: float64 >>> X_new = rng.normal(loc=0, scale=1, size=(20, 2)) >>> _ = model.predict(X_new) """ default_priors: dict[str, Prior] = {}
[docs] def priors_from_data(self, X, y) -> dict[str, Any]: """ Generate priors dynamically based on the input data. This method allows models to set sensible priors that adapt to the scale and characteristics of the actual data being analyzed. It's called during the `fit()` method before model building, allowing data-driven prior specification that can improve model performance and convergence. The priors returned by this method are merged with any user-specified priors (passed via the `priors` parameter in `__init__`), with user-specified priors taking precedence in case of conflicts. Parameters ---------- X : xarray.DataArray Input features/covariates with dimensions ["obs_ind", "coeffs"]. Used to understand the scale and structure of predictors. y : xarray.DataArray Target variable with dimensions ["obs_ind", "treated_units"]. Used to understand the scale and structure of the outcome. Returns ------- Dict[str, Prior] Dictionary mapping parameter names to Prior objects. The keys should match parameter names used in the model's `build_model()` method. See Also -------- WeightedSumFitter.priors_from_data : Example implementation that sets Dirichlet prior shape based on number of control units. Notes ----- The base implementation returns an empty dictionary, meaning no data-driven priors are set by default. Subclasses should override this method to implement data-adaptive prior specification. **Priority Order for Priors:** 1. User-specified priors (passed to `__init__`) 2. Data-driven priors (from this method) 3. Default priors (from `default_priors` property) Examples -------- A typical implementation might scale priors based on data variance: >>> def priors_from_data(self, X, y): ... y_std = float(y.std()) ... return { ... "sigma": Prior("HalfNormal", sigma=y_std, dims="treated_units"), ... "beta": Prior( ... "Normal", ... mu=0, ... sigma=2 * y_std, ... dims=["treated_units", "coeffs"], ... ), ... } Or set shape parameters based on data dimensions: >>> def priors_from_data(self, X, y): ... n_predictors = X.shape[1] ... return { ... "beta": Prior( ... "Dirichlet", ... a=np.ones(n_predictors), ... dims=["treated_units", "coeffs"], ... ) ... } """ return {}
[docs] def __init__( self, sample_kwargs: dict[str, Any] | None = None, priors: dict[str, Any] | None = None, ) -> None: """ Parameters ---------- sample_kwargs : dict, optional Dictionary of kwargs that get unpacked and passed to the :func:`pymc.sample` function. Defaults to an empty dictionary if None. priors : dict, optional Dictionary of priors for the model. Defaults to None, in which case default priors are used. """ super().__init__() self.idata = None self.sample_kwargs = sample_kwargs if sample_kwargs is not None else {} self._user_priors = priors self.priors = {**self.default_priors, **(priors or {})}
def _clone(self, priors: dict[str, Any] | None = None) -> "PyMCModel": """Create a fresh, unfitted copy with the same configuration. ``copy.deepcopy`` of a ``pm.Model`` subclass loses its class identity, so this method constructs a new instance from the stored init parameters instead. ``priors`` overrides the stored user priors on the copy. It is the sole supported way to re-instantiate a model with a different prior set (used by the ``auto_scale_sigma=False`` opt-out to pin the legacy noise prior), so that no ``type(model)(...)`` reconstruction that could silently drop subclass ``__init__`` configuration exists outside ``_clone``. Omitting it (the ``clone_model`` sensitivity-check path) preserves the stored priors unchanged. """ return type(self)( sample_kwargs=dict(self.sample_kwargs), priors=self._user_priors if priors is None else priors, )
[docs] def build_model( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None ) -> None: """Construct the PyMC model graph. Subclasses must override this method to declare priors, deterministic nodes, and the likelihood for the model. Parameters ---------- X : xarray.DataArray Input features with dimensions ``["obs_ind", "coeffs"]``. y : xarray.DataArray Target variable with dimensions ``["obs_ind", "treated_units"]``. coords : dict or None Mapping of named dimensions to coordinate labels for the underlying ``pm.Model``. Raises ------ NotImplementedError Always, when called on the base class. """ raise NotImplementedError( "This method must be implemented by a subclass" ) # pragma: no cover
def _data_setter(self, X: xr.DataArray) -> None: """ Set data for the model for prediction. This method is called by :meth:`predict` to register new predictor data and reshape the target placeholder so that ``pm.sample_posterior_predictive`` can run with the new observation count. The base implementation updates mutable data nodes named ``"X"`` and ``"y"``. Subclasses that use different data nodes should override this method. See :class:`BayesianBasisExpansionTimeSeries` for an example. """ for name in ("X", "y"): if name not in self.named_vars: raise ValueError( f"Data node '{name}' not found in model. " f"If your model uses different data node names, " f"override _data_setter() (see " f"BayesianBasisExpansionTimeSeries for an example)." ) new_no_of_observations = X.shape[0] # Use integer indices for obs_ind to avoid datetime compatibility issues with PyMC obs_coords = np.arange(new_no_of_observations) with self: treated_units_coord = getattr(self, "coords", {}).get("treated_units") if treated_units_coord is None: n_treated_units = getattr(self, "_n_treated_units", 1) else: n_treated_units = len(treated_units_coord) pm.set_data( {"X": X, "y": np.zeros((new_no_of_observations, n_treated_units))}, coords={"obs_ind": obs_coords}, )
[docs] def fit( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None = None, ) -> xr.DataTree: """Draw samples from posterior, prior predictive, and posterior predictive distributions. Parameters ---------- X : xarray.DataArray Input features as a labeled array. y : xarray.DataArray Target values as a labeled array. coords : dict, optional Dictionary with coordinate names for named dimensions. Defaults to None. Returns ------- xr.DataTree DataTree containing the samples. """ if not isinstance(X, xr.DataArray) or not isinstance(y, xr.DataArray): raise TypeError( "X and y must both be xarray.DataArray objects; specialized " "mapping inputs must be fitted through PyMCModelAdapter" ) coords = {} if coords is None else coords.copy() for data in (X, y): for dimension in data.dims: coords.setdefault(dimension, data.get_index(dimension)) self._n_treated_units = y.sizes.get("treated_units", 1) return self._fit_with_validated_data(X, y, coords)
[docs] def fit_mapping( self, X: dict[str, xr.DataArray], y: dict[str, xr.DataArray], coords: dict[str, Any] | None = None, ) -> xr.DataTree: """Fit a specialized model that accepts mapping-valued inputs. Parameters ---------- X : dict of str to xarray.DataArray Labeled predictor arrays for specialized model components. y : dict of str to xarray.DataArray Labeled target arrays for specialized model components. coords : dict, optional Coordinate metadata for the model. """ raise TypeError(f"{type(self).__name__} does not support mapping-valued inputs")
def _fit_with_validated_data( self, X: Any, y: Any, coords: dict[str, Any], ) -> xr.DataTree: """Build and sample a model after its public fit boundary validates inputs.""" # Ensure random_seed is used in sample_prior_predictive() and # sample_posterior_predictive() if provided in sample_kwargs. random_seed = self.sample_kwargs.get("random_seed", None) # Rebuild the effective priors from scratch on every fit, so that a # previous fit's data-derived priors cannot leak into this one. The # configured state is restored first, which is also what the model is # left in if priors_from_data rejects the data. Precedence is # defaults -> data-derived -> user. self.priors = {**self.default_priors, **(self._user_priors or {})} self.priors = { **self.default_priors, **self.priors_from_data(X, y), **(self._user_priors or {}), } self.build_model(X, y, coords) with self: self.idata = pm.sample(**self.sample_kwargs) if self.idata is None: raise RuntimeError("pm.sample() returned None") self.idata = _extend_datatree_left( self.idata, pm.sample_prior_predictive(random_seed=random_seed) ) pm.sample_posterior_predictive( self.idata, progressbar=False, random_seed=random_seed, extend_inferencedata=True, ) return self.idata
[docs] def predict( self, X: xr.DataArray, coords: dict[str, Any] | None = None, out_of_sample: bool | None = False, ): """ Predict data given input data `X`. .. caution:: Results in KeyError if model hasn't been fit. Parameters ---------- X : xr.DataArray Input features for which predictions are required. coords : dict, optional Coordinate names for named dimensions. Forwarded to subclass ``_data_setter`` overrides; ignored by the base implementation. out_of_sample : bool, optional Marker for out-of-sample prediction. Reserved for subclasses; the base implementation does not act on it. """ # Ensure random_seed is used in sample_prior_predictive() and # sample_posterior_predictive() if provided in sample_kwargs. random_seed = self.sample_kwargs.get("random_seed", None) # Base _data_setter doesn't use coords, but subclasses might override _data_setter to use it. # If a subclass needs coords in _data_setter, it should handle it. self._data_setter(X) with self: pp = pm.sample_posterior_predictive( self.idata, var_names=["y_hat", "mu"], progressbar=False, random_seed=random_seed, ) # Assign coordinates from input X because PyMC uses integer indices internally while experiments need original coordinates (e.g., datetimes) for canonical observed-minus-``mu`` impact. if isinstance(X, xr.DataArray) and "obs_ind" in X.coords: _assign_group_coords(pp, "posterior_predictive", obs_ind=X.obs_ind) return pp
[docs] def score(self, X, y, coords: dict[str, Any] | None = None) -> pd.Series: """Score the Bayesian :math:`R^2` given inputs ``X`` and outputs ``y``. Note that the score is based on a comparison of the observed data ``y`` and the model's expected value of the data, `mu`. .. caution:: The Bayesian :math:`R^2` is not the same as the traditional coefficient of determination, https://en.wikipedia.org/wiki/Coefficient_of_determination. Parameters ---------- X : xr.DataArray Input features. y : xr.DataArray Observed targets to score against the posterior predictive mean. coords : dict, optional Coordinate names for named dimensions. Forwarded to :meth:`predict`; ignored by the base implementation. """ mu = self.predict(X) mu_data = az.extract(mu, group="posterior_predictive", var_names="mu") scores = {} # Always iterate over treated_units dimension - no branching needed! for i, unit in enumerate(mu_data.coords["treated_units"].values): unit_mu = mu_data.sel(treated_units=unit).T # (sample, obs_ind) unit_y = y.sel(treated_units=unit).data unit_score = _bayesian_r2_score(unit_y, unit_mu.data) scores[f"unit_{i}_r2"] = unit_score["r2"] scores[f"unit_{i}_r2_std"] = unit_score["r2_std"] return pd.Series(scores)
[docs] def print_coefficients( self, labels: list[str], round_to: int | None = None ) -> None: """Print the model coefficients with their labels. Parameters ---------- labels : list of str List of strings representing the coefficient names. round_to : int, optional Number of significant figures to round to. Defaults to None, in which case 2 significant figures are used. """ if self.idata is None: raise RuntimeError("Model has not been fit") def _print_row( max_label_length: int, name: str, coeff_samples: xr.DataArray, round_to: int ) -> None: """Print one row of the coefficient table.""" formatted_name = f" {name: <{max_label_length}}" formatted_val = f"{round_num(coeff_samples.mean().data, round_to)}, {HDI_PROB * 100:.0f}% HDI [{round_num(coeff_samples.quantile((1 - HDI_PROB) / 2).data, round_to)}, {round_num(coeff_samples.quantile(1 - (1 - HDI_PROB) / 2).data, round_to)}]" # noqa: E501 print(f" {formatted_name} {formatted_val}") def _print_coefficients_for_unit( unit_coeffs: xr.DataArray, unit_sigma: xr.DataArray, labels: list, round_to: int, ) -> None: """Print coefficients for a single unit.""" # Determine the width of the longest label max_label_length = max(len(name) for name in labels + ["y_hat_sigma"]) for name in labels: coeff_samples = unit_coeffs.sel(coeffs=name) _print_row(max_label_length, name, coeff_samples, round_to) # Add coefficient for measurement std _print_row(max_label_length, "y_hat_sigma", unit_sigma, round_to) print("Model coefficients:") coeffs = az.extract(self.idata.posterior, var_names="beta") # Check if sigma or y_hat_sigma variable exists sigma_var_name = None if "sigma" in self.idata.posterior: sigma_var_name = "sigma" elif "y_hat_sigma" in self.idata.posterior: sigma_var_name = "y_hat_sigma" else: raise ValueError( "Neither 'sigma' nor 'y_hat_sigma' found in posterior" ) # pragma: no cover treated_units = coeffs.coords["treated_units"].values for unit in treated_units: if len(treated_units) > 1: print(f"\nTreated unit: {unit}") unit_coeffs = coeffs.sel(treated_units=unit) unit_sigma = az.extract(self.idata.posterior, var_names=sigma_var_name).sel( treated_units=unit ) _print_coefficients_for_unit(unit_coeffs, unit_sigma, labels, round_to or 2)
[docs] class LinearRegression(PyMCModel): r""" Custom PyMC model for linear regression. Defines the PyMC model .. math:: \beta &\sim \mathrm{Normal}(0, 50) \\ \sigma &\sim \mathrm{HalfNormal}(1) \\ \mu &= X \cdot \beta \\ y &\sim \mathrm{Normal}(\mu, \sigma) \\ Examples -------- >>> import causalpy as cp >>> import numpy as np >>> import xarray as xr >>> from causalpy.pymc_models import LinearRegression >>> rd = cp.load_data("rd") >>> rd["treated"] = rd["treated"].astype(int) >>> coeffs = ["x", "treated"] >>> X = xr.DataArray( ... rd[coeffs].values, ... dims=["obs_ind", "coeffs"], ... coords={"obs_ind": rd.index, "coeffs": coeffs}, ... ) >>> y = xr.DataArray( ... rd["y"].values[:, None], ... dims=["obs_ind", "treated_units"], ... coords={"obs_ind": rd.index, "treated_units": ["unit_0"]}, ... ) >>> lr = LinearRegression(sample_kwargs={"progressbar": False}) >>> coords={"coeffs": coeffs, "obs_ind": np.arange(rd.shape[0]), "treated_units": ["unit_0"]} >>> _ = lr.fit(X, y, coords=coords) """ # noqa: W605 default_priors = { "beta": Prior("Normal", mu=0, sigma=50, dims=["treated_units", "coeffs"]), "y_hat": Prior( "Normal", sigma=Prior("HalfNormal", sigma=1, dims=["treated_units"]), dims=["obs_ind", "treated_units"], ), }
[docs] def build_model( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None ) -> None: """ Define the PyMC model. Parameters ---------- X : xr.DataArray Design matrix with dims ``("obs_ind", "coeffs")``. y : xr.DataArray Outcome with dims ``("obs_ind", "treated_units")``. coords : dict or None Coordinate names for the model's named dimensions. """ with self: # Ensure treated_units coordinate exists for consistency if coords is not None and "treated_units" not in coords: coords = coords.copy() coords["treated_units"] = ["unit_0"] self.add_coords(coords) X = pm.Data("X", X, dims=["obs_ind", "coeffs"]) y = pm.Data("y", y, dims=["obs_ind", "treated_units"]) beta = self.priors["beta"].create_variable("beta") mu = pm.Deterministic( "mu", pt.dot(X, beta.T), dims=["obs_ind", "treated_units"] ) self.priors["y_hat"].create_likelihood_variable("y_hat", mu=mu, observed=y)
#: The observation-noise prior both weighted-sum fitters carried before #887 made #: the scale data-derived. It stays their declared default (so a fit that never #: reaches ``priors_from_data`` is unchanged) and doubles as the opt-out prior. _LEGACY_Y_HAT_PRIOR = Prior( "Normal", sigma=Prior("HalfNormal", sigma=1, dims=["treated_units"]), dims=["obs_ind", "treated_units"], ) def _uses_stock_y_hat_default(model: "PyMCModel") -> bool: """Report whether a model still declares the stock ``y_hat`` default prior. Automatic scaling replaces a default the user never chose. A subclass that declares its own ``y_hat`` default *has* chosen one, so it is left alone; subclasses that only customise other parts of the model are still scaled. Parameters ---------- model : PyMCModel Model whose declared default priors are inspected. Returns ------- bool ``True`` when the ``y_hat`` default is ``_LEGACY_Y_HAT_PRIOR``. """ return type(model).default_priors.get("y_hat") is _LEGACY_Y_HAT_PRIOR #: Fallback outcome scale used when a treated unit's pre-treatment spread cannot #: be estimated (a constant or sub-resolution series, or fewer than two #: observations). Keeping the scale at 1 reproduces the legacy ``HalfNormal(1)`` #: order of magnitude for those degenerate units instead of failing a fit that #: used to work. _DEGENERATE_OUTCOME_SCALE = 1.0 def _data_scaled_y_hat_prior(y: xr.DataArray) -> Prior: """Build a per-treated-unit observation-noise prior from outcome scale. Each treated unit's rate is ``2 / s_i``, giving ``sigma_i`` a prior mean of ``s_i / 2``, where ``s_i`` is that unit's sample standard deviation. Units whose spread is not estimable -- constant, varying only below the outcome's floating-point resolution, or with fewer than two observations -- fall back to ``s_i = 1`` with a warning; non-finite outcomes are a data error and are rejected. """ y_values = np.asarray( y.transpose("obs_ind", "treated_units").values, dtype=float, ) treated_units = np.asarray(y.get_index("treated_units")) non_finite = ~np.isfinite(y_values).all(axis=0) if np.any(non_finite): raise ValueError( "Cannot data-scale the y_hat observation-noise prior: the fitted " "outcome contains non-finite values for treated unit(s) " f"{_format_treated_units(treated_units[non_finite])}. Clean the data, " "or pass a custom y_hat prior; SyntheticControl callers can " "alternatively use auto_scale_sigma=False to retain HalfNormal(1)." ) if y_values.shape[0] < 2: scales = np.zeros(y_values.shape[1]) magnitudes = np.zeros(y_values.shape[1]) else: scales = np.std(y_values, axis=0, ddof=1) magnitudes = np.max(np.abs(y_values), axis=0) with np.errstate(divide="ignore", over="ignore"): rates = 2 / scales # A spread carries no usable scale information in two cases. First, when it # is zero or subnormal, ``2 / s`` overflows to non-finite or is non-positive. # Second -- and this is the case the plain finite/positive test above misses # -- when it is finite but negligible *relative to the outcome's own # magnitude*. ``eps * |y|`` is the width of one representable float64 step at # that magnitude, so a spread at or below it is indistinguishable from # rounding noise; ``2 / s`` would mint that noise into an absurdly tight yet # finite prior (a near-constant series -- e.g. a broken data pull -- is # exactly this). The threshold is deliberately the resolution floor and no # larger: above it the spread is genuine signal, however small in absolute # terms, and the scale-equivariant ``Exponential(2 / s)`` prior is already # calibrated to it. Both degenerate cases fall back to the default scale. resolution = np.finfo(y_values.dtype).eps * magnitudes degenerate = ~np.isfinite(rates) | (rates <= 0) | (scales <= resolution) if np.any(degenerate): warnings.warn( "Cannot estimate the pre-treatment outcome scale for treated unit(s) " f"{_format_treated_units(treated_units[degenerate])}; the series is " "constant, varies only below its floating-point resolution, or has " "fewer than two observations. Falling back to an observation-noise " f"scale of {_DEGENERATE_OUTCOME_SCALE} for those units. Pass a custom " "y_hat prior to control this explicitly.", UserWarning, stacklevel=2, ) rates = np.where(degenerate, 2 / _DEGENERATE_OUTCOME_SCALE, rates) return Prior( "Normal", sigma=Prior("Exponential", lam=rates, dims=["treated_units"]), dims=["obs_ind", "treated_units"], ) def _format_treated_units(units: np.ndarray) -> str: """Render treated-unit labels for use in diagnostics.""" return ", ".join(repr(str(unit)) for unit in units)
[docs] class WeightedSumFitter(PyMCModel): r""" Used for synthetic control experiments. Defines the PyMC model. At fit time, the default observation-noise prior is independently scaled for each treated unit: .. math:: s_i &= \operatorname{sd}(y_i) \\ \sigma_i &\sim \operatorname{Exponential}(2 / s_i) \\ \beta &\sim \operatorname{Dirichlet}(1,\ldots,1) \\ \mu &= X \cdot \beta \\ y &\sim \operatorname{Normal}(\mu, \sigma) The rate gives :math:`\sigma_i` a prior mean of :math:`s_i / 2`, so the prior says the same thing about the noise whatever units the outcome is in. The fixed ``HalfNormal(1)`` used before only suited outcomes on a unit-ish scale: on a larger outcome it pushed the posterior :math:`\sigma` far into its own tail, which narrows the ridge NUTS has to explore and costs both effective sample size and wall time. A treated unit whose pre-treatment series is constant has no estimable :math:`s_i`, so it falls back to :math:`s_i = 1` and warns. A custom ``y_hat`` prior, or one declared as a subclass default, takes precedence. ``SyntheticControl(auto_scale_sigma=False)`` keeps the legacy ``HalfNormal(1)`` prior. Examples -------- >>> import causalpy as cp >>> import numpy as np >>> import xarray as xr >>> from causalpy.pymc_models import WeightedSumFitter >>> sc = cp.load_data("sc") >>> control_units = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] >>> X = xr.DataArray( ... sc[control_units].values, ... dims=["obs_ind", "coeffs"], ... coords={"obs_ind": sc.index, "coeffs": control_units}, ... ) >>> y = xr.DataArray( ... sc['actual'].values.reshape((sc.shape[0], 1)), ... dims=["obs_ind", "treated_units"], ... coords={"obs_ind": sc.index, "treated_units": ["actual"]}, ... ) >>> coords = { ... "coeffs": control_units, ... "treated_units": ["actual"], ... "obs_ind": np.arange(sc.shape[0]), ... } >>> wsf = WeightedSumFitter(sample_kwargs={"progressbar": False}) >>> _ = wsf.fit(X, y, coords=coords) """ # noqa: W605 default_priors = {"y_hat": _LEGACY_Y_HAT_PRIOR}
[docs] def priors_from_data(self, X, y) -> dict[str, Any]: """Set data-dependent priors for weights and observation noise. The Dirichlet weight prior is uniform across available control units. The default ``y_hat`` prior uses an independent ``Exponential(lam=2 / s_i)`` noise scale for each treated outcome, where ``s_i`` is its sample standard deviation. A user-provided ``y_hat`` prior, or a ``y_hat`` default declared by a subclass, takes precedence; so does ``SyntheticControl(auto_scale_sigma=False)``, which leaves the legacy ``HalfNormal(1)`` prior in place. Parameters ---------- X : xarray.DataArray Control unit data with shape (n_obs, n_control_units). y : xarray.DataArray Treated unit outcome data. Returns ------- dict[str, Prior] Data-dependent ``beta`` and, when enabled, ``y_hat`` priors. """ priors = { "beta": Prior( "Dirichlet", a=np.ones(X.shape[1]), dims=["treated_units", "coeffs"] ), } if _uses_stock_y_hat_default(self) and "y_hat" not in (self._user_priors or {}): priors["y_hat"] = _data_scaled_y_hat_prior(y) return priors
[docs] def build_model( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None ) -> None: """ Define the PyMC model. Parameters ---------- X : xr.DataArray Design matrix with dims ``("obs_ind", "coeffs")``. y : xr.DataArray Outcome with dims ``("obs_ind", "treated_units")``. coords : dict or None Coordinate names for the model's named dimensions. """ with self: self.add_coords(coords) X = pm.Data("X", X, dims=["obs_ind", "coeffs"]) y = pm.Data("y", y, dims=["obs_ind", "treated_units"]) beta = self.priors["beta"].create_variable("beta") mu = pm.Deterministic( "mu", pt.dot(X, beta.T), dims=["obs_ind", "treated_units"] ) self.priors["y_hat"].create_likelihood_variable("y_hat", mu=mu, observed=y)
def _softmax_simplex_weights( name: str, prior: "Prior", n_rows: int, dims: list[str], ) -> "pt.TensorVariable": """Create simplex weights via softmax-over-Normal-logits with pinned reference. Pins the first logit to zero (removing softmax shift invariance), samples ``N - 1`` unconstrained Normal logits, and applies softmax to produce weights on the simplex. Parameters ---------- name : str Name for the PyMC Deterministic variable (e.g., "beta", "omega"). prior : Prior Prior for the raw logits. Must be a Normal prior with appropriate dims. n_rows : int Number of rows in the weight matrix (e.g., n_treated for SC, 1 for SDiD). dims : list[str] Dimension names for the output Deterministic. Returns ------- pt.TensorVariable Simplex weights as a PyMC Deterministic. """ if prior.distribution != "Normal": raise ValueError( f"_softmax_simplex_weights expects a Normal prior, got {prior.distribution}" ) raw = prior.create_variable(f"{name}_raw") if n_rows == 1 and raw.ndim == 1: # When the prior has no "treated_units" dim (e.g. SDiD's omega_raw with # dims=["coeffs_raw"]), PyMC creates a 1D tensor. Concatenate along # axis 0 to produce a 1D simplex of shape (N,). zero_logit = pt.zeros((1,)) tilde = pt.concatenate([zero_logit, raw], axis=0) return pm.Deterministic(name, pt.special.softmax(tilde, axis=-1), dims=dims) zero_logit = pt.zeros((n_rows, 1)) tilde = pt.concatenate([zero_logit, raw], axis=-1) return pm.Deterministic(name, pt.special.softmax(tilde, axis=-1), dims=dims)
[docs] class SoftmaxWeightedSumFitter(PyMCModel): r""" Weighted sum model with softmax-over-Normal-logits parameterization. An alternative to :class:`WeightedSumFitter` for synthetic control experiments. Instead of a Dirichlet prior on the simplex weights, this model places Normal priors on unconstrained logits and maps them to the simplex via the softmax transform. The first logit is pinned to zero to remove the softmax's shift invariance. Defines the PyMC model: .. math:: \tilde{\beta}_1 &= 0 \\ \tilde{\beta}_{j} &\sim \mathrm{Normal}(0, \sigma) \quad j = 2, \ldots, N \\ \beta &= \mathrm{softmax}(\tilde{\beta}) \\ \mu &= X \cdot \beta \\ y &\sim \mathrm{Normal}(\mu, \sigma_y) \\ At fit time each treated outcome gets an independent observation-noise prior ``Exponential(lam=2 / s_i)``, where ``s_i`` is that outcome's sample standard deviation, so the prior means the same thing whatever units the outcome is in. See :class:`WeightedSumFitter` for the rationale and the constant-series fallback. A custom ``y_hat`` prior, or one declared as a subclass default, takes precedence, and ``SyntheticControl(auto_scale_sigma=False)`` retains the legacy ``HalfNormal(1)`` prior. Notes ----- The softmax-Normal parameterization and the Dirichlet prior used by :class:`WeightedSumFitter` both produce simplex-valued weights, but they encode different prior beliefs and regularization behavior: - **Dirichlet** (:class:`WeightedSumFitter`): With concentration ``a=1`` the prior is uniform on the simplex. Setting ``a < 1`` encourages sparsity (weights concentrating on fewer donors), while ``a > 1`` encourages uniformity. Regularization strength is controlled by the concentration parameter. - **Softmax-Normal** (this class): The prior scale ``sigma`` on the logits controls regularization. Small ``sigma`` shrinks logits toward zero, producing near-uniform weights (DiD-like behavior). Large ``sigma`` allows the data to concentrate weight on a few well-matching control units (SC-like behavior). The default ``sigma=1.0`` provides moderate regularization. This parameterization is motivated by the Bayesian Synthetic Difference-in-Differences (SDiD) formulation, where the prior scale plays the role of the :math:`\ell_2` regularization parameter :math:`\zeta` in the frequentist SDiD of Arkhangelsky et al. (2021). Examples -------- >>> import causalpy as cp >>> import numpy as np >>> import xarray as xr >>> from causalpy.pymc_models import SoftmaxWeightedSumFitter >>> sc = cp.load_data("sc") >>> control_units = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] >>> X = xr.DataArray( ... sc[control_units].values, ... dims=["obs_ind", "coeffs"], ... coords={"obs_ind": sc.index, "coeffs": control_units}, ... ) >>> y = xr.DataArray( ... sc['actual'].values.reshape((sc.shape[0], 1)), ... dims=["obs_ind", "treated_units"], ... coords={"obs_ind": sc.index, "treated_units": ["actual"]}, ... ) >>> coords = { ... "coeffs": control_units, ... "treated_units": ["actual"], ... "obs_ind": np.arange(sc.shape[0]), ... } >>> wsf = SoftmaxWeightedSumFitter(sample_kwargs={"progressbar": False}) >>> _ = wsf.fit(X, y, coords=coords) """ # noqa: W605 default_priors = {"y_hat": _LEGACY_Y_HAT_PRIOR}
[docs] def priors_from_data(self, X, y) -> dict[str, Any]: """Set data-dependent priors for logits and observation noise. The Normal prior on the ``N - 1`` unconstrained logits uses ``sigma=1.0`` by default. The default ``y_hat`` prior uses an independent ``Exponential(lam=2 / s_i)`` noise scale for each treated outcome, where ``s_i`` is its sample standard deviation. A user-provided ``y_hat`` prior, or a ``y_hat`` default declared by a subclass, takes precedence; so does ``SyntheticControl(auto_scale_sigma=False)``, which leaves the legacy ``HalfNormal(1)`` prior in place. Unlike :meth:`WeightedSumFitter.priors_from_data`, the Normal logit prior broadcasts automatically via its ``dims``, so the predictor shape is not needed. Parameters ---------- X : xarray.DataArray Control unit data with shape (n_obs, n_control_units). y : xarray.DataArray Treated unit outcome data. Returns ------- dict[str, Prior] Data-dependent ``beta_raw`` and, when enabled, ``y_hat`` priors. """ priors = { "beta_raw": Prior( "Normal", mu=0, sigma=1.0, dims=["treated_units", "coeffs_raw"], ), } if _uses_stock_y_hat_default(self) and "y_hat" not in (self._user_priors or {}): priors["y_hat"] = _data_scaled_y_hat_prior(y) return priors
[docs] def build_model( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None ) -> None: """ Build the PyMC model with softmax-parameterized simplex weights. Parameters ---------- X : xr.DataArray Design matrix with dims ``("obs_ind", "coeffs")``. y : xr.DataArray Outcome with dims ``("obs_ind", "treated_units")``. coords : dict or None Coordinate names for the model's named dimensions. """ if not coords or "coeffs" not in coords: raise ValueError( "coords must include 'coeffs' for SoftmaxWeightedSumFitter" ) coeffs_raw = coords["coeffs"][1:] with self: coords_with_raw = dict(coords) if coords else {} coords_with_raw["coeffs_raw"] = coeffs_raw self.add_coords(coords_with_raw) X = pm.Data("X", X, dims=["obs_ind", "coeffs"]) y = pm.Data("y", y, dims=["obs_ind", "treated_units"]) beta = _softmax_simplex_weights( name="beta", prior=self.priors["beta_raw"], n_rows=y.shape[1], dims=["treated_units", "coeffs"], ) mu = pm.Deterministic( "mu", pt.dot(X, beta.T), dims=["obs_ind", "treated_units"] ) self.priors["y_hat"].create_likelihood_variable("y_hat", mu=mu, observed=y)
[docs] class SyntheticDifferenceInDifferencesWeightFitter(PyMCModel): r""" Bayesian weight fitter for Synthetic Difference-in-Differences. Encodes both the unit-weight module and the time-weight module in a single PyMC model. Unit weights balance control units against treated units in the pre-treatment period; time weights balance pre-treatment periods against post-treatment periods for control units. Both use the softmax-over-Normal-logits parameterization with a pinned reference level. The treatment effect is **not** estimated inside this model. It is computed analytically from the weight posteriors via the double-difference formula in the experiment class. Defines the PyMC model: .. math:: \omega &= \mathrm{softmax}(0, \tilde{\omega}_2, \ldots, \tilde{\omega}_{N_\text{co}}) \\ \bar{Y}_{\text{tr},t} &\sim \mathrm{Normal}(\omega_0 + \boldsymbol{\omega}^\top \mathbf{Y}_{\text{co},t},\; \sigma_\omega) \\ \lambda &= \mathrm{softmax}(0, \tilde{\lambda}_2, \ldots, \tilde{\lambda}_{T_\text{pre}}) \\ \bar{Y}_{i,\text{post}} &\sim \mathrm{Normal}(\lambda_0 + \boldsymbol{\lambda}^\top \mathbf{Y}_{i,\text{pre}},\; \sigma_\lambda) Notes ----- This model implements the cut-posterior formulation of Bayesian SDiD. Modules 1 and 2 share no parameters and are conditionally independent given the data. Running them in a single MCMC call is a convenience; the important property is that no treatment-effect likelihood feeds back into the weight posteriors. The prior scales on the logits play the role of the regularization parameter in the frequentist SDiD: - ``omega_raw`` default ``sigma=1.0`` (``zeta_omega=1.0``): moderate regularization, allowing weights between SC-sparse and DiD-uniform. - ``lam_raw`` default ``sigma=100.0`` (``zeta_lambda=0.01``): essentially flat, letting time weights concentrate on the most informative pre-treatment periods. References ---------- .. [1] Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., & Wager, S. (2021). Synthetic Difference-in-Differences. *American Economic Review*, 111(12), 4088-4118. """ # noqa: W605 default_priors: dict[str, Prior] = {}
[docs] def fit( self, X: xr.DataArray | dict[str, xr.DataArray], y: xr.DataArray | dict[str, xr.DataArray], coords: dict[str, Any] | None = None, ) -> xr.DataTree: """Fit SDID mappings while retaining the ordinary model fit contract. Parameters ---------- X : xarray.DataArray or dict of str to xarray.DataArray Predictor data for an ordinary fit or the SDID weight modules. y : xarray.DataArray or dict of str to xarray.DataArray Target data for an ordinary fit or the SDID weight modules. coords : dict, optional Coordinate metadata for the model. """ if isinstance(X, dict) and isinstance(y, dict): return self.fit_mapping(X, y, coords) if isinstance(X, dict) or isinstance(y, dict): raise TypeError("X and y must either both be mappings or both be arrays") return super().fit(X, y, coords)
[docs] def fit_mapping( self, X: dict[str, xr.DataArray], y: dict[str, xr.DataArray], coords: dict[str, Any] | None = None, ) -> xr.DataTree: """Fit the unit- and time-weight modules from labeled mapping inputs. Parameters ---------- X : dict of str to xarray.DataArray Unit- and time-weight design matrices. y : dict of str to xarray.DataArray Unit- and time-weight target arrays. coords : dict, optional Coordinate metadata shared by the two modules. """ if ( not X or not y or not all( isinstance(key, str) and isinstance(value, xr.DataArray) for data in (X, y) for key, value in data.items() ) ): raise TypeError( "X and y must be non-empty dictionaries mapping strings to " "xarray.DataArray objects" ) self._n_treated_units = 1 return self._fit_with_validated_data( X, y, {} if coords is None else coords.copy(), )
[docs] def priors_from_data(self, X, y) -> dict[str, Any]: """ Set default priors for unit and time weight modules. Parameters ---------- X : dict Dict with keys ``"unit"`` and ``"time"``, each an xarray.DataArray. y : dict Dict with keys ``"unit"`` and ``"time"``, each an xarray.DataArray. Returns ------- dict[str, Prior] Priors for omega_raw, lam_raw, omega0, lambda0, sigma_omega, sigma_lambda. """ return { "omega_raw": Prior( "Normal", mu=0, sigma=1.0, dims=["coeffs_raw"], ), "lam_raw": Prior( "Normal", mu=0, sigma=100.0, dims=["obs_ind_raw"], ), "omega0": Prior("Normal", mu=0, sigma=5.0), "lambda0": Prior("Normal", mu=0, sigma=5.0), "sigma_omega": Prior("HalfNormal", sigma=1.0), "sigma_lambda": Prior("HalfNormal", sigma=1.0), }
[docs] def build_model(self, X, y, coords: dict[str, Any] | None) -> None: """ Build the PyMC model with both unit-weight and time-weight modules. Parameters ---------- X : dict Mapping with ``"unit"`` and ``"time"`` design matrices for the unit-weight and time-weight modules respectively. y : dict Mapping with ``"unit"`` and ``"time"`` outcome arrays for the unit-weight and time-weight modules respectively. coords : dict or None Coordinate names for the model's named dimensions. """ with self: self.add_coords(coords) # Data X_unit = pm.Data("X_unit", X["unit"], dims=["obs_ind", "coeffs"]) y_unit = pm.Data("y_unit", y["unit"], dims=["obs_ind"]) X_time = pm.Data("X_time", X["time"], dims=["coeffs", "obs_ind"]) y_time = pm.Data("y_time", y["time"], dims=["coeffs"]) # Module 1: Unit weights omega = _softmax_simplex_weights( name="omega", prior=self.priors["omega_raw"], n_rows=1, dims=["coeffs"], ) # shape (N_co,) — 1D from helper when n_rows=1 omega0 = self.priors["omega0"].create_variable("omega0") sigma_omega = self.priors["sigma_omega"].create_variable("sigma_omega") mu_omega = omega0 + pt.dot(X_unit, omega) pm.Normal("omega_match", mu=mu_omega, sigma=sigma_omega, observed=y_unit) # Module 2: Time weights lam = _softmax_simplex_weights( name="lam", prior=self.priors["lam_raw"], n_rows=1, dims=["obs_ind"], ) # shape (T_pre,) — 1D from helper when n_rows=1 lambda0 = self.priors["lambda0"].create_variable("lambda0") sigma_lambda = self.priors["sigma_lambda"].create_variable("sigma_lambda") mu_lambda = lambda0 + pt.dot(X_time, lam) pm.Normal("lambda_match", mu=mu_lambda, sigma=sigma_lambda, observed=y_time)
[docs] class InstrumentalVariableRegression(PyMCModel): """Custom PyMC model for instrumental linear regression. Parameters ---------- sample_kwargs : dict, optional Keyword arguments forwarded to :func:`pymc.sample`. priors : dict, optional Prior configuration used by the model. Examples -------- >>> import causalpy as cp >>> import numpy as np >>> from causalpy.pymc_models import InstrumentalVariableRegression >>> N = 10 >>> e1 = np.random.normal(0, 3, N) >>> e2 = np.random.normal(0, 1, N) >>> Z = np.random.uniform(0, 1, N) >>> ## Ensure the endogeneity of the the treatment variable >>> X = -1 + 4 * Z + e2 + 2 * e1 >>> y = 2 + 3 * X + 3 * e1 >>> t = X.reshape(10, 1) >>> y = y.reshape(10, 1) >>> Z = np.asarray([[1, Z[i]] for i in range(0, 10)]) >>> X = np.asarray([[1, X[i]] for i in range(0, 10)]) >>> COORDS = {"instruments": ["Intercept", "Z"], "covariates": ["Intercept", "X"]} >>> sample_kwargs = { ... "tune": 5, ... "draws": 10, ... "chains": 2, ... "cores": 1, ... "target_accept": 0.95, ... "progressbar": False, ... } >>> iv_reg = InstrumentalVariableRegression(sample_kwargs=sample_kwargs) >>> _ = iv_reg.fit( ... X, ... Z, ... y, ... t, ... COORDS, ... { ... "mus": [[-2, 4], [0.5, 3]], ... "sigmas": [1, 1], ... "eta": 2, ... "lkj_sd": 1, ... }, ... None, ... ) """
[docs] def __init__( self, sample_kwargs: dict[str, Any] | None = None, priors: dict[str, Any] | None = None, ) -> None: """Configure IV sampling defaults. Parameters ---------- sample_kwargs : dict, optional Keyword arguments forwarded to :func:`pymc.sample`. priors : dict, optional Prior configuration passed to the base model. """ kwargs = {} if sample_kwargs is None else dict(sample_kwargs) # TEMPORARY: avoid macOS arm64/Python 3.14 PyMC 6.0.1–6.2.0 IV fork-worker crashes (CausalPy #1044, https://github.com/pymc-devs/pymc/issues/8377); remove per #1067 only after the upstream fix is verified and the supported floor excludes this range. if kwargs.get("cores") is None: kwargs["cores"] = 1 super().__init__(sample_kwargs=kwargs, priors=priors)
[docs] def build_model( # type: ignore self, X: np.ndarray, Z: np.ndarray, y: np.ndarray, t: np.ndarray, coords: dict[str, Any], priors: dict[str, Any], vs_prior_type: Literal["spike_and_slab", "horseshoe", "normal"] | None = None, vs_hyperparams: dict[str, Any] | None = None, binary_treatment: bool = False, ) -> None: """Specify model with treatment regression and focal regression data and priors. Parameters ---------- X : np.ndarray Array used to predict our outcome y. Z : np.ndarray Array used to predict our treatment variable t. y : np.ndarray Array of values representing our focal outcome y. t : np.ndarray Array representing the treatment t of which we're interested in estimating the causal impact. coords : dict Dictionary with the coordinate names for our instruments and covariates. priors : dict Dictionary of priors for the mus and sigmas of both regressions. Example: ``priors = {"mus": [0, 0], "sigmas": [1, 1], "eta": 2, "lkj_sd": 2}``. vs_prior_type : {"spike_and_slab", "horseshoe", "normal"}, optional Optional variable-selection prior type. ``None`` falls back to standard normal priors. vs_hyperparams : dict, optional Hyperparameters for the variable-selection prior. Only consulted when ``vs_prior_type`` is set. binary_treatment : bool, default False Whether the treatment ``t`` is binary; selects the relevant likelihood term. """ # --- Priors --- with self: self.add_coords(coords) if vs_prior_type and ("mus" in priors or "sigmas" in priors): warnings.warn( "Variable selection priors specified. " "The 'mus' and 'sigmas' in the priors dict will be ignored " "for beta coefficients in the treatment equation." "Only 'eta' and 'lkj_sd' will be used from the priors dict" "where applicable.", stacklevel=2, ) # Create coefficient priors if vs_prior_type: if vs_hyperparams is None: vs_hyperparams = {} # Use variable selection priors self.vs_prior_treatment = VariableSelectionPrior( vs_prior_type, vs_hyperparams ) self.vs_prior_outcome = VariableSelectionPrior( vs_prior_type, vs_hyperparams ) beta_t = self.vs_prior_treatment.create_prior( name="beta_t", n_params=Z.shape[1], dims="instruments", X=Z ) if vs_hyperparams.get("outcome", False): beta_z = self.vs_prior_outcome.create_prior( name="beta_z", n_params=X.shape[1], dims="covariates", X=X ) else: # Fallback to standard normal priors for outcome beta_z = pm.Normal( name="beta_z", mu=priors["mus"][1], sigma=priors["sigmas"][1], dims="covariates", ) else: # Use standard normal priors beta_t = pm.Normal( name="beta_t", mu=priors["mus"][0], sigma=priors["sigmas"][0], dims="instruments", ) beta_z = pm.Normal( name="beta_z", mu=priors["mus"][1], sigma=priors["sigmas"][1], dims="covariates", ) if binary_treatment: # Binary treatment formulation with correlated latent errors sigma_U = pm.Exponential("sigma_U", priors.get("sigma_U", 1.0)) # Correlation/Sensitivity parameter with bounds # 'rho' represents the coupling between the Logistic latent error (V) # and the Normal outcome error (U). # Note: Because V follows a Standard Logistic distribution (heavy tails), # this value is not directly comparable to a Normal-Normal Pearson rho. # It acts as the sensitivity parameter in the Control Function approach. rho_lower = priors.get("rho_bounds", [-0.99, 0.99])[0] rho_upper = priors.get("rho_bounds", [-0.99, 0.99])[1] # Use tanh transform to keep correlation in valid range rho_unconstr = pm.Normal("rho_unconstr", 0, 0.5) rho = pm.Deterministic("rho", pm.math.tanh(rho_unconstr)) # Clip to ensure numerical stability rho_clipped = pt.clip(rho, rho_lower + 0.01, rho_upper - 0.01) u = pm.Uniform("u", 0, 1, shape=X.shape[0]) # 2. Transform to Standard Logistic space # This is the "residual" in the treatment equation V = pm.Deterministic("V", pt.log(u / (1 - u))) # Treatment equation (logit link for binary treatment) # much more stable than probit link in practice mu_treatment = pm.Deterministic("mu_t", pt.dot(Z, beta_t) + V) p_t = pm.math.invlogit(mu_treatment) pm.Bernoulli("likelihood_treatment", p=p_t, observed=t.flatten()) # Conditional Outcome equation formulation mu_outcome = pm.Deterministic("mu_y", pt.dot(X, beta_z)) sigma_v_logistic = pm.math.sqrt(pt.pi**2 / 3) expected_U = rho_clipped * (sigma_U / sigma_v_logistic) * V conditional_mu_y = mu_outcome + expected_U conditional_sigma_y = sigma_U * pm.math.sqrt(1 - rho_clipped**2) pm.Normal( "likelihood_outcome", mu=conditional_mu_y, sigma=conditional_sigma_y, observed=y.flatten(), ) else: sd_dist = pm.Exponential.dist(priors["lkj_sd"], shape=2) chol, _, _ = pm.LKJCholeskyCov( name="chol_cov", eta=priors["eta"], n=2, sd_dist=sd_dist, ) # compute and store the covariance matrix pm.Deterministic(name="cov", var=pt.dot(l=chol, r=chol.T)) # --- Parameterization --- mu_y = pm.Deterministic(name="mu_y", var=pt.dot(X, beta_z)) # focal regression mu_t = pm.Deterministic(name="mu_t", var=pt.dot(Z, beta_t)) # instrumental regression mu = pm.Deterministic( name="mu", var=pt.stack(tensors=(mu_y, mu_t), axis=1) ) # --- Likelihood --- pm.MvNormal( name="likelihood", mu=mu, chol=chol, observed=np.stack(arrays=(y.flatten(), t.flatten()), axis=1), shape=(X.shape[0], 2), )
[docs] def sample_predictive_distribution(self, ppc_sampler: str | None = "jax") -> None: """Function to sample the Multivariate Normal posterior predictive Likelihood term in the IV class. This can be slow without using the JAX sampler compilation method. If using the JAX sampler it will sample only the posterior predictive distribution. If using the PYMC sampler if will sample both the prior and posterior predictive distributions. Parameters ---------- ppc_sampler : {"jax", "pymc"}, optional Backend used for posterior predictive sampling. ``"jax"`` (the default) requires JAX and samples only the posterior predictive distribution; ``"pymc"`` is the fallback and additionally samples the prior predictive. """ random_seed = self.sample_kwargs.get("random_seed", None) if ppc_sampler == "jax": if self.idata is not None: try: import jax # noqa: F401 except ModuleNotFoundError as err: raise ImportError( "ppc_sampler='jax' requires JAX. Install jax or use " "ppc_sampler='pymc'." ) from err with self: pm.sample_posterior_predictive( self.idata, random_seed=random_seed, compile_kwargs={"mode": "JAX"}, extend_inferencedata=True, ) elif ppc_sampler == "pymc" and self.idata is not None: with self: self.idata = _extend_datatree_left( self.idata, pm.sample_prior_predictive(random_seed=random_seed) ) pm.sample_posterior_predictive( self.idata, random_seed=random_seed, extend_inferencedata=True, )
[docs] def fit( # type: ignore[override] self, X: np.ndarray, Z: np.ndarray, y: np.ndarray, t: np.ndarray, coords: dict[str, Any], priors: dict[str, Any], ppc_sampler: Literal["jax", "pymc"] | None = None, vs_prior_type: Literal["spike_and_slab", "horseshoe", "normal"] | None = None, vs_hyperparams: dict[str, Any] | None = None, binary_treatment: bool = False, ) -> xr.DataTree: """Draw samples from posterior distribution and potentially from the prior and posterior predictive distributions. The fit call can take values for the ppc_sampler = ['jax', 'pymc', None] We default to None, so the user can determine if they wish to spend time sampling the posterior predictive distribution independently. Parameters ---------- X : np.ndarray Array used to predict the outcome ``y``. Z : np.ndarray Array used to predict the treatment variable ``t``. y : np.ndarray Focal outcome. t : np.ndarray Treatment whose causal impact is being estimated. coords : dict Coordinate names for the instruments and covariates. priors : dict Prior specification dictionary forwarded to :meth:`build_model`. ppc_sampler : {"jax", "pymc"}, optional Backend for posterior predictive sampling. ``"jax"`` requires JAX, ``"pymc"`` is the fallback, and ``None`` skips it. vs_prior_type : {"spike_and_slab", "horseshoe", "normal"}, optional Variable-selection prior type, forwarded to :meth:`build_model`. vs_hyperparams : dict, optional Hyperparameters for the variable-selection prior. binary_treatment : bool, default False Whether the treatment ``t`` is binary. """ # Ensure random_seed is used in sample_prior_predictive() and # sample_posterior_predictive() if provided in sample_kwargs. # Use JAX for ppc sampling of multivariate likelihood self.build_model( X, Z, y, t, coords, priors, vs_prior_type, vs_hyperparams, binary_treatment ) with self: self.idata = pm.sample(**self.sample_kwargs) self.sample_predictive_distribution(ppc_sampler=ppc_sampler) return self.idata
[docs] class PropensityScore(PyMCModel): r"""Custom PyMC model for inverse propensity score models. .. note: Generally, the `.fit()` method should be used rather than calling `.build_model()` directly. Defines the PyMC model .. math:: \beta &\sim \mathrm{Normal}(0, 1) \\ \sigma &\sim \mathrm{HalfNormal}(1) \\ \mu &= X \cdot \beta \\ p &= \text{logit}^{-1}(\mu) \\ t &\sim \mathrm{Bernoulli}(p) Examples -------- >>> import causalpy as cp >>> import numpy as np >>> from causalpy.pymc_models import PropensityScore >>> df = cp.load_data('nhefs') >>> X = df[["age", "race"]] >>> t = np.asarray(df["trt"]) >>> ps = PropensityScore(sample_kwargs={"progressbar": False}) >>> _ = ps.fit(X, t, coords={ ... 'coeffs': ['age', 'race'], ... 'obs_ind': np.arange(df.shape[0]) ... }, ... prior={'b': [0, 1]}, ... ) """ # noqa: W605 default_priors = { "b": Prior("Normal", mu=0, sigma=1, dims="coeffs"), }
[docs] def build_model( # type: ignore self, X: np.ndarray, t: np.ndarray, coords: dict[str, Any], prior: dict[str, Any] | None = None, noncentred: bool = True, ) -> None: """Define the PyMC propensity model. Parameters ---------- X : np.ndarray Covariate matrix used to predict the treatment. t : np.ndarray Observed treatment indicator (0/1). coords : dict Coordinate names for named dimensions of the model. prior : dict, optional Prior specification overrides; see :attr:`default_priors` for the expected keys. noncentred : bool, default True Reserved for future non-centred parameterisations of the coefficient prior. Currently informational only. """ with self: self.add_coords(coords) X_data = pm.Data("X", X, dims=["obs_ind", "coeffs"]) t_data = pm.Data("t", t.flatten(), dims="obs_ind") b = self.priors["b"].create_variable("b") mu = pt.dot(X_data, b) p = pm.Deterministic("p", pm.math.invlogit(mu)) pm.Bernoulli("t_pred", p=p, observed=t_data, dims="obs_ind")
[docs] def fit( # type: ignore self, X: np.ndarray, t: np.ndarray, coords: dict[str, Any], prior: dict[str, list] | None = None, noncentred: bool = True, ) -> xr.DataTree: """Draw samples from posterior, prior predictive, and posterior predictive distributions. We overwrite the base method because the base method assumes a variable y and we use t to indicate the treatment variable here. Parameters ---------- X : np.ndarray Covariate matrix used to predict the treatment. t : np.ndarray Observed treatment indicator (0/1). coords : dict Coordinate names for named dimensions of the model. prior : dict, optional Prior specification overrides. Defaults to ``{"b": [0, 1]}``. noncentred : bool, default True Forwarded to :meth:`build_model`. """ if prior is None: prior = {"b": [0, 1]} # Ensure random_seed is used in sample_prior_predictive() and # sample_posterior_predictive() if provided in sample_kwargs. random_seed = self.sample_kwargs.get("random_seed", None) self.build_model(X, t, coords, prior, noncentred) with self: self.idata = pm.sample(**self.sample_kwargs) if self.idata is not None: self.idata = _extend_datatree_left( self.idata, pm.sample_prior_predictive(random_seed=random_seed) ) pm.sample_posterior_predictive( self.idata, progressbar=False, random_seed=random_seed, extend_inferencedata=True, ) return self.idata
[docs] def fit_outcome_model( self, X_outcome: pd.DataFrame, y: pd.Series, coords: dict[str, Any], priors: dict[str, Any] | None = None, noncentred: bool = True, normal_outcome: bool = True, spline_component: bool = False, winsorize_boundary: float = 0.0, spline_knots: int = 30, ) -> tuple[xr.DataTree, pm.Model]: """ Fit a Bayesian outcome model using covariates and previously estimated propensity scores. This function implements the second stage of a modular two-step causal inference procedure. It uses propensity scores extracted from a prior treatment model (via `self.fit()`) to adjust for confounding when estimating treatment effects on an outcome variable `y`. Parameters ---------- X_outcome : array-like, shape (n_samples, n_covariates) Covariate matrix for the outcome model. y : array-like, shape (n_samples,) Observed outcome variable. coords : dict Coordinate dictionary for named dimensions in the PyMC model. Should include a key "outcome_coeffs" for `X_outcome`. priors : dict, optional Dictionary specifying priors for outcome model parameters: - "b_outcome": list [mean, std] for regression coefficients. - "sigma": standard deviation of the outcome noise (default 1). noncentred : bool, default True If True, use a non-centred parameterization for the outcome coefficients. normal_outcome : bool, default True If True, assume a Normal likelihood for the outcome. If False, use a Student-t likelihood with unknown degrees of freedom. spline_component : bool, default False If True, include a spline basis expansion on the propensity score to allow flexible (nonlinear) adjustment. Uses B-splines with 30 internal knots. winsorize_boundary : float, default 0.0 If we wish to winsorize the propensity score this can be set to clip the high and low values of the propensity at 0 + winsorize_boundary and 1-winsorize_boundary spline_knots : int, default 30 The number of knots we use in the 0 - 1 interval to create our spline function. Returns ------- idata_outcome : xr.DataTree The posterior and prior predictive samples from the outcome model. model_outcome : pm.Model The PyMC model object. Raises ------ AttributeError If the `self.idata` attribute is not available, which indicates that `fit()` (i.e., the treatment model) has not been called yet. Notes ----- - This model uses a sampled version of the propensity score (`p`) from the posterior of the treatment model, randomly selecting one posterior draw per call. This term is estimated initially in the InversePropensity class initialisation. - The term `beta_ps[0] * p` captures both main effects of the propensity score. - Including spline adjustment enables modeling nonlinear relationships between the propensity score and the outcome. """ if priors is None: priors = { "b_outcome": [0, 1], "sigma": 1, "beta_ps": [0, 1], } if not hasattr(self, "idata"): raise AttributeError( """Object is missing required attribute 'idata' so cannot proceed. Call fit() first""" ) propensity_scores = az.extract(self.idata)["p"] random_seed = self.sample_kwargs.get("random_seed", None) with pm.Model(coords=coords) as model_outcome: X_data_outcome = pm.Data("X_outcome", X_outcome) Y_data_ = pm.Data("Y", y) if noncentred: mu_beta, sigma_beta = priors["b_outcome"] beta_std = pm.Normal("beta_std", 0, 1, dims="outcome_coeffs") beta = pm.Deterministic( "beta_", mu_beta + sigma_beta * beta_std, dims="outcome_coeffs" ) else: beta = pm.Normal( "beta_", priors["b_outcome"][0], priors["b_outcome"][1], dims="outcome_coeffs", ) beta_ps = pm.Normal("beta_ps", priors["beta_ps"][0], priors["beta_ps"][1]) chosen = np.random.choice(range(propensity_scores.shape[1])) p = propensity_scores[:, chosen].values p = np.clip(p, winsorize_boundary, 1 - winsorize_boundary) mu_outcome = pm.math.dot(X_data_outcome, beta) + beta_ps * p if spline_component: beta_ps_spline = pm.Normal( "beta_ps_spline", priors["beta_ps"][0], priors["beta_ps"][1], size=spline_knots + 4, ) B = dmatrix( "bs(ps, knots=knots, degree=3, include_intercept=True, lower_bound=0, upper_bound=1) - 1", {"ps": p, "knots": np.linspace(0, 1, spline_knots)}, ) B_f = np.asarray(B, order="F") splines_summed = pm.Deterministic( "spline_features", pm.math.dot(B_f, beta_ps_spline.T) ) mu_outcome = pm.math.dot(X_data_outcome, beta) + splines_summed sigma = pm.HalfNormal("sigma", priors["sigma"]) if normal_outcome: _ = pm.Normal("like", mu_outcome, sigma, observed=Y_data_) else: nu = pm.Exponential("nu", lam=1 / 10) _ = pm.StudentT( "like", nu=nu, mu=mu_outcome, sigma=sigma, observed=Y_data_ ) idata_outcome = pm.sample_prior_predictive(random_seed=random_seed) idata_outcome.update(pm.sample(**self.sample_kwargs)) return idata_outcome, model_outcome
[docs] class BayesianBasisExpansionTimeSeries(PyMCModel): r""" Bayesian Structural Time Series Model. .. deprecated:: Use :class:`StateSpaceTimeSeries` instead. This class will be removed in a future release. This model allows for the inclusion of trend, seasonality (via Fourier series), and optional exogenous regressors. .. math:: \text{trend} &\sim \text{LinearTrend}(...) \\ \text{seasonality} &\sim \text{YearlyFourier}(...) \\ \beta &\sim \mathrm{Normal}(0, \sigma_{\beta}) \quad \text{(if X is provided)} \\ \sigma &\sim \mathrm{HalfNormal}(\sigma_{err}) \\ \mu &= \text{trend_component} + \text{seasonality_component} + X \cdot \beta \quad \text{(if X is provided)} \\ y &\sim \mathrm{Normal}(\mu, \sigma) Parameters ---------- n_order : int, optional The number of Fourier components for the yearly seasonality. Defaults to 3. Only used if seasonality_component is None. n_changepoints_trend : int, optional The number of changepoints for the linear trend component. Defaults to 10. Only used if trend_component is None. prior_sigma : float, optional Prior standard deviation for the observation noise. Defaults to 5. trend_component : Optional[Any], optional A custom trend component model. If None, the default pymc-marketing LinearTrend component is used. Must have an `apply(time_data)` method that returns a PyMC tensor. seasonality_component : Optional[Any], optional A custom seasonality component model. If None, the default pymc-marketing YearlyFourier component is used. Must have an `apply(time_data)` method that returns a PyMC tensor. sample_kwargs : dict, optional A dictionary of kwargs that get unpacked and passed to the :func:`pymc.sample` function. Defaults to an empty dictionary. priors : dict, optional Dictionary of priors for the model. Defaults to ``None``, in which case default priors are used. """ # noqa: W605
[docs] def __init__( self, n_order: int = 3, n_changepoints_trend: int = 10, prior_sigma: float = 5, trend_component: Any | None = None, seasonality_component: Any | None = None, sample_kwargs: dict[str, Any] | None = None, priors: dict[str, Any] | None = None, ): super().__init__(sample_kwargs=sample_kwargs, priors=priors) warnings.warn( "BayesianBasisExpansionTimeSeries is deprecated and will be removed " "in a future release. Use StateSpaceTimeSeries instead.", DeprecationWarning, stacklevel=2, ) # Store original configuration parameters self.n_order = n_order self.n_changepoints_trend = n_changepoints_trend self.prior_sigma = prior_sigma self._first_fit_timestamp: pd.Timestamp | None = None self._exog_var_names: list[str] | None = None # Store custom components (fix the bug where they were swapped) self._custom_trend_component = trend_component self._custom_seasonality_component = seasonality_component # Initialize and validate components self._trend_component = None self._seasonality_component = None self._validate_and_initialize_components()
def _clone(self, priors: dict[str, Any] | None = None) -> "PyMCModel": """Create a fresh, unfitted copy with the same configuration. ``priors`` overrides the stored user priors on the copy; omitting it preserves them. See :meth:`PyMCModel._clone`. """ return type(self)( n_order=self.n_order, n_changepoints_trend=self.n_changepoints_trend, prior_sigma=self.prior_sigma, trend_component=self._custom_trend_component, seasonality_component=self._custom_seasonality_component, sample_kwargs=dict(self.sample_kwargs), priors=self._user_priors if priors is None else priors, ) def _validate_and_initialize_components(self): """ Validate custom components only. Optional dependencies are imported lazily when default components are actually needed. """ # Validate custom components have required methods if self._custom_trend_component is not None and not hasattr( self._custom_trend_component, "apply" ): raise ValueError( "Custom trend_component must have an 'apply' method that accepts time data " "and returns a PyMC tensor." ) if self._custom_seasonality_component is not None and not hasattr( self._custom_seasonality_component, "apply" ): raise ValueError( "Custom seasonality_component must have an 'apply' method that accepts time data " "and returns a PyMC tensor." ) def _get_trend_component(self): """Get the trend component, creating default if needed.""" if self._custom_trend_component is not None: return self._custom_trend_component # Create default trend component (lazy import of pymc-marketing) if self._trend_component is None: try: from pymc_marketing.mmm import LinearTrend except ImportError as err: raise ImportError( "BayesianBasisExpansionTimeSeries requires pymc-marketing when default trend " "component is used. Install it with `pip install pymc-marketing`." ) from err self._trend_component = LinearTrend( n_changepoints=self.n_changepoints_trend ) return self._trend_component def _get_seasonality_component(self): """Get the seasonality component, creating default if needed.""" if self._custom_seasonality_component is not None: return self._custom_seasonality_component # Create default seasonality component (lazy import of pymc-marketing) if self._seasonality_component is None: try: from pymc_marketing.mmm import YearlyFourier except ImportError as err: raise ImportError( "BayesianBasisExpansionTimeSeries requires pymc-marketing when default seasonality " "component is used. Install it with `pip install pymc-marketing`." ) from err self._seasonality_component = YearlyFourier(n_order=self.n_order) return self._seasonality_component def _prepare_time_and_exog_features( self, X: xr.DataArray | None, ) -> tuple[np.ndarray, np.ndarray, xr.DataArray | None, int]: """ Prepares time features and processes exogenous variables from X. Parameters ---------- X : xr.DataArray or None Input features with dims ["obs_ind", "coeffs"]. The obs_ind coordinate must contain datetime values. Can be None or have 0 columns if no exogenous variables. Returns ------- tuple ``(time_for_trend, time_for_seasonality, X_for_pymc, num_obs)``: - time_for_trend: numpy array of time values for trend component - time_for_seasonality: numpy array of day-of-year values - X_for_pymc: xarray DataArray for exogenous vars, or None if no exog vars - num_obs: number of observations """ if X is None: raise ValueError( "X cannot be None. Pass an empty DataArray if no exog vars." ) if not isinstance(X, xr.DataArray): raise TypeError("X must be an xarray DataArray.") # Extract datetime index from X coordinates if "obs_ind" not in X.coords: raise ValueError("X must have 'obs_ind' coordinate.") obs_ind_vals = X.coords["obs_ind"].values if len(obs_ind_vals) == 0: raise ValueError("X must have at least one observation.") # Check if obs_ind contains datetime values if not isinstance(obs_ind_vals[0], (np.datetime64, pd.Timestamp)): raise ValueError( "X.coords['obs_ind'] must contain datetime values (np.datetime64 or pd.Timestamp)." ) datetime_index = pd.DatetimeIndex(obs_ind_vals) num_obs = len(datetime_index) # Extract coefficient names from X coordinates exog_names: list[str] = [] if "coeffs" in X.coords: coeffs_vals = X.coords["coeffs"].values if len(coeffs_vals) > 0: exog_names = list(coeffs_vals) # Validate dimensions if X.shape[0] != num_obs: raise ValueError( f"Shape mismatch: X has {X.shape[0]} rows but datetime_index has {num_obs} entries." ) if X.shape[1] != len(exog_names): raise ValueError( f"Mismatch: X has {X.shape[1]} columns, but {len(exog_names)} coefficient names provided." ) # Set or validate self._exog_var_names if X.shape[1] > 0: if self._exog_var_names is None: self._exog_var_names = exog_names elif self._exog_var_names != exog_names: raise ValueError( f"Exogenous variable names mismatch. Model fit with {self._exog_var_names}, " f"but current call provides {exog_names}." ) elif self._exog_var_names is None: # No exog vars in this call, and none set before self._exog_var_names = [] # Set first fit timestamp if not set if self._first_fit_timestamp is None: self._first_fit_timestamp = datetime_index[0] # Compute time features (these are numpy arrays) time_for_trend = ( (datetime_index - self._first_fit_timestamp).days / 365.25 ).values time_for_seasonality = datetime_index.dayofyear.values # Determine X to use for PyMC (return as xarray or None) X_for_pymc: xr.DataArray | None = None if self._exog_var_names and X.shape[1] > 0: X_for_pymc = X # Keep as xarray # else: no exog vars, return None return time_for_trend, time_for_seasonality, X_for_pymc, num_obs
[docs] def build_model( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None ) -> None: """ Defines the PyMC model. Parameters ---------- X : xr.DataArray Input features with dims ["obs_ind", "coeffs"]. Can have 0 columns if no exogenous variables. The obs_ind coordinate must contain datetime values. y : xr.DataArray Target variable with dims ["obs_ind", "treated_units"]. coords : dict, optional Coordinates dictionary. Can contain "datetime_index" for backwards compatibility, but datetime is preferentially extracted from X.coords['obs_ind']. """ # Prepare time features and validate X # This extracts datetime from X.coords['obs_ind'] and validates exog vars ( time_for_trend, time_for_seasonality, X_for_pymc, # xarray DataArray or None num_obs, ) = self._prepare_time_and_exog_features(X) # Build model coordinates model_coords = { "obs_ind": np.arange(num_obs), "treated_units": ["unit_0"], } # Add coeffs coordinate if we have exogenous variables if self._exog_var_names: model_coords["coeffs"] = self._exog_var_names # type: ignore[assignment] with self: self.add_coords(model_coords) # Time data for trend and seasonality t_trend_data = pm.Data( "t_trend_data", time_for_trend, dims="obs_ind", ) t_season_data = pm.Data( "t_season_data", time_for_seasonality, dims="obs_ind", ) # Get validated components trend_component_instance = self._get_trend_component() seasonality_component_instance = self._get_seasonality_component() # Seasonal component season_component = pm.Deterministic( "season_component", _call_seasonality_component_apply( seasonality_component_instance, t_season_data ), dims="obs_ind", ) # Trend component trend_component_values = _call_time_component_apply( trend_component_instance, t_trend_data ) trend_component = pm.Deterministic( "trend_component", trend_component_values, dims="obs_ind", ) # Initialize mu with trend and seasonality mu_ = trend_component + season_component # Exogenous regressors (optional) if X_for_pymc is not None: # Use xarray directly with pm.Data X_data = pm.Data("X", X_for_pymc, dims=["obs_ind", "coeffs"]) beta = pm.Normal("beta", mu=0, sigma=10, dims="coeffs") mu_ = mu_ + pm.math.dot(X_data, beta) # Make mu_ an explicit deterministic variable with treated_units dimension # Expand dims to include treated_units for consistency with other models mu = pm.Deterministic("mu", mu_[:, None], dims=["obs_ind", "treated_units"]) # Likelihood - also with treated_units dimension # Use xarray directly with pm.Data sigma = pm.HalfNormal("sigma", sigma=self.prior_sigma, dims="treated_units") y_data = pm.Data("y", y, dims=["obs_ind", "treated_units"]) pm.Normal( "y_hat", mu=mu, sigma=sigma, observed=y_data, dims=["obs_ind", "treated_units"], )
[docs] def fit( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None = None ) -> xr.DataTree: """Draw samples from posterior, prior predictive, and posterior predictive distributions, placing them in the model's idata attribute. Parameters ---------- X : xr.DataArray Input features with dims ["obs_ind", "coeffs"]. Can have 0 columns if no exogenous variables. y : xr.DataArray Target variable with dims ["obs_ind", "treated_units"]. coords : dict Coordinates dictionary. Must contain "datetime_index" (pd.DatetimeIndex). """ random_seed = self.sample_kwargs.get("random_seed", None) self.build_model(X, y, coords=coords) with self: self.idata = pm.sample(**self.sample_kwargs) if self.idata is not None: self.idata = _extend_datatree_left( self.idata, pm.sample_prior_predictive(random_seed=random_seed) ) pm.sample_posterior_predictive( self.idata, var_names=["y_hat", "mu"], progressbar=self.sample_kwargs.get("progressbar", True), random_seed=random_seed, extend_inferencedata=True, ) return self.idata # type: ignore[return-value]
def _data_setter(self, X: xr.DataArray) -> None: """ Set data for the model for prediction. Parameters ---------- X : xr.DataArray Input features with dims ["obs_ind", "coeffs"]. Must have datetime coordinates on obs_ind. """ # Prepare time features and get X for PyMC (as xarray or None) ( time_for_trend_pred_vals, time_for_seasonality_pred_vals, X_for_pymc, # xarray or None num_obs_pred, ) = self._prepare_time_and_exog_features(X) new_obs_inds = np.arange(num_obs_pred) # Create dummy y data with proper shape dummy_y = xr.DataArray( np.zeros((num_obs_pred, 1)), dims=["obs_ind", "treated_units"], coords={"obs_ind": new_obs_inds, "treated_units": ["unit_0"]}, ) data_to_set = { "y": dummy_y, "t_trend_data": time_for_trend_pred_vals, "t_season_data": time_for_seasonality_pred_vals, } coords_to_set = {"obs_ind": new_obs_inds} # Handle exogenous variables if "X" in self.named_vars: if X_for_pymc is None and self._exog_var_names: raise ValueError( "Model was built with exogenous variables. " "New X data must provide these." ) if X_for_pymc is not None: # Use xarray directly data_to_set["X"] = X_for_pymc else: # Model expects X but we have none - create empty xarray empty_X = xr.DataArray( np.empty((num_obs_pred, 0)), dims=["obs_ind", "coeffs"], coords={"obs_ind": new_obs_inds, "coeffs": []}, ) data_to_set["X"] = empty_X elif X_for_pymc is not None: warnings.warn( "X provided exogenous variables, but the model was not " "built with exogenous variables. These will be ignored.", UserWarning, stacklevel=2, ) with self: pm.set_data(data_to_set, coords=coords_to_set)
[docs] def predict( self, X: xr.DataArray, coords: dict[str, Any] | None = None, out_of_sample: bool | None = False, ) -> xr.DataTree: """ Predict data given input X. Parameters ---------- X : xr.DataArray Input features with dims ["obs_ind", "coeffs"]. Must have datetime coordinates on obs_ind. coords : dict, optional Not used, kept for API compatibility. out_of_sample : bool, optional Not used, kept for API compatibility. Returns ------- xr.DataTree Posterior predictive samples. """ random_seed = self.sample_kwargs.get("random_seed", None) self._data_setter(X) with self: post_pred = pm.sample_posterior_predictive( self.idata, var_names=["y_hat", "mu"], progressbar=self.sample_kwargs.get("progressbar", False), random_seed=random_seed, ) # Assign coordinates from input X for proper alignment if isinstance(X, xr.DataArray) and "obs_ind" in X.coords: _assign_group_coords(post_pred, "posterior_predictive", obs_ind=X.obs_ind) return post_pred
[docs] def score( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None = None, ) -> pd.Series: """Score the Bayesian R^2. Parameters ---------- X : xr.DataArray Input features with dims ["obs_ind", "coeffs"]. y : xr.DataArray Target variable with dims ["obs_ind", "treated_units"]. coords : dict, optional Not used, kept for API compatibility. Returns ------- pd.Series R² score and standard deviation for each treated unit. """ # Use base class score method now that we have treated_units dimension return super().score(X, y, coords=coords)
[docs] class StateSpaceTimeSeries(PyMCModel): """ State-space time series model using :class:`pymc-extras.statespace.structural`. The model combines a local level/trend component with frequency-domain seasonality. When `X` is passed to `fit`, its columns (except the patsy `Intercept`, which the level absorbs) enter as exogenous regressors through a static-coefficient `Regression` component, and out-of-sample predictions use the post-period `X` as the forecast scenario. Parameters ---------- level_order : int, optional Order of the local level/trend component. Defaults to 2. seasonal_length : int, optional Seasonal period (e.g., 12 for monthly data with annual seasonality). Defaults to 12. trend_component : optional Custom state-space trend component. Must be a pymc-extras structural component (e.g. `pymc_extras.statespace.structural.LevelTrend`). Components with non-default names introduce their own parameter names; pass matching entries in `priors`. seasonality_component : optional Custom state-space seasonal component. Same requirements as `trend_component`. sample_kwargs : dict, optional Kwargs passed to `pm.sample`. mode : str, optional Pytensor compile mode used when building the state-space model. Defaults to None. priors : dict, optional Dictionary mapping state-space parameter names to :class:`pymc_extras.prior.Prior` objects, overriding the defaults in `default_priors`. The `P0` covariance is parameterized through its diagonal under the key `"P0_diag"`. Dims are resolved from the built state-space model, so priors do not need to declare them. vs_prior_type : {"spike_and_slab", "horseshoe", "normal"}, optional Variable selection prior for the exogenous regression coefficients. Requires covariates. Takes precedence over a `beta_exog` entry in `priors`. vs_hyperparams : dict, optional Hyperparameters for the variable selection prior. See :class:`causalpy.variable_selection_priors.VariableSelectionPrior`. """ default_priors = { "P0_diag": Prior("Gamma", alpha=2, beta=1), "initial_level_trend": Prior("Normal", mu=0, sigma=50), "params_freq": Prior("Normal", mu=0, sigma=80), "sigma_level_trend": Prior("Gamma", alpha=2, beta=5), "sigma_freq": Prior("Gamma", alpha=2, beta=1), "beta_exog": Prior("Normal", mu=0, sigma=50), }
[docs] def __init__( self, level_order: int = 2, seasonal_length: int = 12, trend_component: Any | None = None, seasonality_component: Any | None = None, sample_kwargs: dict[str, Any] | None = None, mode: str | None = None, priors: dict[str, Prior] | None = None, vs_prior_type: Literal["spike_and_slab", "horseshoe", "normal"] | None = None, vs_hyperparams: dict[str, Any] | None = None, ): super().__init__(sample_kwargs=sample_kwargs, priors=priors) if seasonality_component is None and seasonal_length < 2: # FrequencySeasonality needs at least one harmonic; season_length=1 # fails with an obscure ZeroDivisionError inside pymc-extras raise ValueError( "seasonal_length must be at least 2. For a model without " "seasonality, pass a custom seasonality_component." ) self._custom_trend_component = trend_component self._custom_seasonality_component = seasonality_component self.level_order = level_order self.seasonal_length = seasonal_length self.mode = mode self._treated_units = ["unit_0"] self.ss_mod: Any = None self._exog_names: list[str] = [] self.vs_prior_type = vs_prior_type self.vs_hyperparams = vs_hyperparams self.vs_prior: VariableSelectionPrior | None = None if vs_prior_type is not None: # Validates the prior type eagerly self.vs_prior = VariableSelectionPrior(vs_prior_type, vs_hyperparams or {}) if priors and "beta_exog" in priors: warnings.warn( "Both vs_prior_type and a beta_exog entry in priors were " "given. The variable selection prior takes precedence for " "beta_exog.", UserWarning, stacklevel=2, ) self._validate_and_initialize_components()
def _clone(self, priors: dict[str, Any] | None = None) -> "PyMCModel": """Create a fresh, unfitted copy with the same configuration. ``priors`` overrides the stored user priors on the copy; omitting it preserves them. See :meth:`PyMCModel._clone`. """ return type(self)( level_order=self.level_order, seasonal_length=self.seasonal_length, trend_component=self._custom_trend_component, seasonality_component=self._custom_seasonality_component, sample_kwargs=dict(self.sample_kwargs), mode=self.mode, priors=self._user_priors if priors is None else priors, vs_prior_type=self.vs_prior_type, vs_hyperparams=self.vs_hyperparams, ) def _validate_and_initialize_components(self): """ Validate custom components only. Optional dependencies are imported lazily when default components are actually needed. """ # Validate custom components. The base class is only needed when the # user supplies one, so the import stays out of the default path. custom_components = [ ("trend_component", self._custom_trend_component), ("seasonality_component", self._custom_seasonality_component), ] if any(component is not None for _, component in custom_components): try: from pymc_extras.statespace.models.structural.core import Component except ImportError as err: raise ImportError( "Custom components are checked against " "pymc_extras.statespace.models.structural.core.Component, and " "this pymc-extras version does not expose it at that path." ) from err for label, component in custom_components: if component is not None and not isinstance(component, Component): raise ValueError( f"Custom {label} must be a pymc-extras structural state-space " "component (e.g. pymc_extras.statespace.structural.LevelTrend), " f"got {type(component).__name__}." ) # Initialize components self._trend_component = None self._seasonality_component = None def _get_trend_component(self): """Get the trend component, creating default if needed.""" if self._custom_trend_component is not None: return self._custom_trend_component # Create default trend component (lazy import of pymc-extras) if self._trend_component is None: try: from pymc_extras.statespace import structural as st except ImportError as err: raise ImportError( "StateSpaceTimeSeries requires pymc-extras when default trend component is used. " "Install it with `conda/mamba/micromamba install -c conda-forge pymc-extras`." ) from err self._trend_component = st.LevelTrend(order=self.level_order) return self._trend_component def _get_seasonality_component(self): """Get the seasonality component, creating default if needed.""" if self._custom_seasonality_component is not None: return self._custom_seasonality_component # Create default seasonality component (lazy import of pymc-extras) if self._seasonality_component is None: try: from pymc_extras.statespace import structural as st except ImportError as err: raise ImportError( "StateSpaceTimeSeries requires pymc-extras when default seasonality component is used. " "Install it with `conda/mamba/micromamba install -c conda-forge pymc-extras`." ) from err self._seasonality_component = st.FrequencySeasonality( season_length=self.seasonal_length, name="freq" ) return self._seasonality_component def _extract_exog_names(self, X: xr.DataArray | None) -> list[str]: """Exogenous regressor names from X, excluding the patsy intercept. The state-space level absorbs the intercept, so a constant regressor would be unidentified. """ if X is None or "coeffs" not in X.coords: return [] names = [str(name) for name in X.coords["coeffs"].values] if "Intercept" in names: names.remove("Intercept") if names: warnings.warn( "Dropping the 'Intercept' column from the regressors: the " "state-space level already absorbs it. Use a formula like " "'y ~ 0 + x1' to silence this warning.", UserWarning, stacklevel=2, ) return names
[docs] def build_model( self, X: xr.DataArray | None = None, y: xr.DataArray | None = None, coords: dict[str, Any] | None = None, ) -> None: """ Build the PyMC state-space model. Parameters ---------- X : xr.DataArray, optional Input features with dims ["obs_ind", "coeffs"]. Columns other than the patsy "Intercept" become exogenous regressors. If None or empty, the model has trend and seasonality only. y : xr.DataArray Target variable with dims ["obs_ind", "treated_units"]. Must have datetime coordinates on obs_ind. coords : dict, optional Coordinates dictionary. Can contain "datetime_index" for backwards compatibility, but datetime is preferentially extracted from y.coords['obs_ind']. """ if y is None: raise ValueError( "y must be provided for StateSpaceTimeSeries.build_model()" ) if self.free_RVs: raise RuntimeError( "This StateSpaceTimeSeries instance is already built and cannot be " "rebuilt in place, because the variables live on the model itself. " "Create a new instance, or call `_clone()` to copy this " "configuration, and fit that." ) if "treated_units" not in y.dims: raise ValueError( "StateSpaceTimeSeries requires a treated_units dimension with exactly " "one unit." ) n_treated_units = y.sizes["treated_units"] if n_treated_units != 1: raise ValueError( "StateSpaceTimeSeries supports exactly one treated unit, got " f"{n_treated_units}." ) self._treated_units = list(y.get_index("treated_units")) # Extract datetime index from y coordinates if "obs_ind" not in y.coords: raise ValueError("y must have 'obs_ind' coordinate.") obs_ind_vals = y.coords["obs_ind"].values if len(obs_ind_vals) == 0: raise ValueError("y must have at least one observation.") # Check if obs_ind contains datetime values if isinstance(obs_ind_vals[0], (np.datetime64, pd.Timestamp)): datetime_index = pd.DatetimeIndex(obs_ind_vals) elif coords is not None and "datetime_index" in coords: # Fallback to coords dict for backwards compatibility datetime_index = coords["datetime_index"] if not isinstance(datetime_index, pd.DatetimeIndex): raise ValueError( "coords['datetime_index'] must be a pd.DatetimeIndex if provided." ) else: raise ValueError( "y.coords['obs_ind'] must contain datetime values or " "coords must contain 'datetime_index' (pd.DatetimeIndex)." ) self._train_index = datetime_index # Instantiate components and build state-space object trend = self._get_trend_component() season = self._get_seasonality_component() combined = trend + season self._exog_names = self._extract_exog_names(X) if self.vs_prior is not None and not self._exog_names: raise ValueError( "vs_prior_type was set but the model has no exogenous " "covariates. Pass covariates via X, e.g. with a " "'y ~ 0 + x1 + x2' formula." ) if self._exog_names: from pymc_extras.statespace import structural as st combined += st.Regression( name="exog", state_names=self._exog_names, innovations=False ) # `mode` belongs on the state-space model itself; passing it to # `build_statespace_graph` is deprecated in pymc-extras. self.ss_mod = combined.build(mode=self.mode) # Build coordinates for the model coordinates = self.ss_mod.coords.copy() if coords: # Merge with user-provided coords (excluding datetime_index and obs_ind which are handled separately) coords_copy = coords.copy() coords_copy.pop("datetime_index", None) coords_copy.pop( "obs_ind", None ) # obs_ind handled by state-space model's time dimension coordinates.update(coords_copy) # Every state-space parameter needs a prior. P0 is parameterized # through its diagonal, so its prior is looked up as "P0_diag". prior_keys = [ "P0_diag" if name == "P0" else name for name in self.ss_mod.param_names ] missing = [key for key in prior_keys if key not in self.priors] if missing: raise ValueError( f"No prior found for state-space parameters: {missing}. " "Pass them via the `priors` argument. Custom components " "introduce their own parameter names; see `ss_mod.param_info`." ) # Build model self.add_coords(coordinates) with self: # Note for params_freq: keep Normal (not ZeroSumNormal) as default; # frequency-state coefficients are unconstrained here; see PR #679 # for rationale and context. for name in self.ss_mod.param_names: dims = self.ss_mod.param_info[name]["dims"] if name == "P0": # Dims are resolved from the built state-space model, so # copy the Prior and set them on the copy rather than # mutating the shared default_priors entries in place. The # copy keeps options the caller set, such as `centered` # and `transform`. prior = deepcopy(self.priors["P0_diag"]) prior.dims = dims[0] P0_diag = prior.create_variable("P0_diag") pm.Deterministic("P0", pt.diag(P0_diag), dims=dims) elif name == "beta_exog" and self.vs_prior is not None: self.vs_prior.create_prior( "beta_exog", n_params=len(self._exog_names), dims=dims, X=X.sel(coeffs=self._exog_names).values if X is not None else None, ) else: prior = deepcopy(self.priors[name]) prior.dims = dims prior.create_variable(name) # Attach the state-space graph using the observed data # Extract values from xarray for pandas DataFrame y_values = ( y.isel(treated_units=0).values if "treated_units" in y.dims else y.values ) df = pd.DataFrame({"y": y_values.flatten()}, index=datetime_index) if self._exog_names and X is not None: # The state-space graph looks this variable up by name pm.Data("data_exog", X.sel(coeffs=self._exog_names).values) self.ss_mod.build_statespace_graph(df[["y"]])
[docs] def fit( self, X: xr.DataArray | None = None, y: xr.DataArray | None = None, coords: dict[str, Any] | None = None, ) -> xr.DataTree: """ Fit the model, drawing posterior samples. Parameters ---------- X : xr.DataArray, optional Input features with dims ["obs_ind", "coeffs"]. Columns other than the patsy "Intercept" become exogenous regressors. y : xr.DataArray Target variable with dims ["obs_ind", "treated_units"]. Must have datetime coordinates on obs_ind. coords : dict, optional Coordinates dictionary. Can contain "datetime_index" for backwards compatibility. Returns ------- xr.DataTree DataTree with parameter draws. """ if y is None: raise ValueError("y must be provided for StateSpaceTimeSeries.fit()") # Merge in data-driven priors, matching the base class fit() self.priors = {**self.priors_from_data(X, y), **self.priors} self.build_model(X, y, coords) with self: self.idata = pm.sample(**self.sample_kwargs) if self.idata is not None: pm.sample_posterior_predictive( self.idata, extend_inferencedata=True, ) self.conditional_idata = self._smooth() return self._prepare_idata()
def _prepare_idata(self) -> xr.DataTree: """Prepare DataTree with proper dimensions including treated_units.""" if self.idata is None: raise RuntimeError("Model must be fit before smoothing.") new_idata = self.idata.copy() # Get smoothed posterior and sum over state dimension smoothed = self.conditional_idata.isel(observed_state=0).rename( {"smoothed_posterior_observed": "y_hat"} ) y_hat_summed = smoothed.y_hat.copy() # Rename 'time' to 'obs_ind' to match CausalPy conventions if "time" in y_hat_summed.dims: y_hat_final = y_hat_summed.rename({"time": "obs_ind"}) else: y_hat_final = y_hat_summed # Add treated_units dimension for consistency with other models y_hat_with_units = y_hat_final.expand_dims( {"treated_units": self._treated_units} ).transpose("chain", "draw", "obs_ind", "treated_units") new_idata["posterior_predictive"] = xr.Dataset( {"y_hat": y_hat_with_units, "mu": y_hat_with_units} ) self.idata = new_idata return self.idata def _smooth(self) -> xr.Dataset: """ Run the Kalman smoother / conditional posterior sampler. Returns an xarray Dataset with 'smoothed_posterior'. """ if self.idata is None: raise RuntimeError("Model must be fit before smoothing.") conditional_idata = self.ss_mod.sample_conditional_posterior(self.idata) return ( conditional_idata.to_dataset() if isinstance(conditional_idata, xr.DataTree) else conditional_idata )
[docs] def get_inclusion_probabilities( self, param_name: str = "beta_exog" ) -> pd.DataFrame: """ Posterior inclusion probabilities of the exogenous regressors. Only available when the model was configured with `vs_prior_type="spike_and_slab"` and has been fit. Interpret the probabilities as a relative ranking of the candidate regressors. The `beta_exog` point estimates shrink toward zero under this prior (the state-space `P0` lets the regression states drift from the parameter), but counterfactual forecasts use the smoothed states and are not affected by that attenuation. Parameters ---------- param_name : str, optional Name of the coefficient parameter. Defaults to "beta_exog". Returns ------- pd.DataFrame One row per regressor with columns "prob" (inclusion probability), "selected" (probability above 0.5), and "gamma_mean" (mean of the selection indicator). """ if self.vs_prior is None: raise ValueError( "Model was not configured with vs_prior_type; there are no " "inclusion probabilities to report." ) if self.idata is None: raise RuntimeError("Model must be fit first.") return self.vs_prior.get_inclusion_probabilities(self.idata, param_name)
[docs] def get_shrinkage_factors(self, param_name: str = "beta_exog") -> pd.DataFrame: """ Shrinkage factors of the exogenous regressors. Only available when the model was configured with `vs_prior_type="horseshoe"` and has been fit. Parameters ---------- param_name : str, optional Name of the coefficient parameter. Defaults to "beta_exog". Returns ------- pd.DataFrame One row per regressor with the effective shrinkage applied to its coefficient. """ if self.vs_prior is None: raise ValueError( "Model was not configured with vs_prior_type; there are no " "shrinkage factors to report." ) if self.idata is None: raise RuntimeError("Model must be fit first.") return self.vs_prior.get_shrinkage_factors(self.idata, param_name)
def _forecast( self, start: pd.Timestamp, periods: int, scenario: np.ndarray | None = None, ) -> xr.Dataset: """ Forecast future values. `start` is the timestamp of the last observed point, and `periods` is the number of steps ahead. `scenario` carries the exogenous regressor values for the forecast period when the model was fit with covariates. Returns an xarray Dataset with 'forecast_observed'. """ if self.idata is None: raise RuntimeError("Model must be fit before forecasting.") if self.ss_mod is None: raise RuntimeError("State space model not initialized") forecast = self.ss_mod.forecast( self.idata, start=start, periods=periods, scenario=scenario ) return forecast.to_dataset() if isinstance(forecast, xr.DataTree) else forecast
[docs] def predict( self, X: xr.DataArray | None = None, coords: dict[str, Any] | None = None, out_of_sample: bool | None = False, ) -> xr.DataTree: """ Predict data given input X. Parameters ---------- X : xr.DataArray, optional Input features with dims ["obs_ind", "coeffs"]. Must have datetime coordinates on obs_ind for out-of-sample predictions, and must contain the covariate columns used at fit time when the model was fit with exogenous regressors. Not required for in-sample predictions. coords : dict, optional Not used directly, datetime extracted from X coordinates. out_of_sample : bool, optional If True, forecast future values. If False, return in-sample predictions. Returns ------- xr.DataTree Posterior predictive samples with y_hat and mu. """ if not out_of_sample: return self._prepare_idata() else: # Extract datetime from X coordinates if X is None: raise ValueError( "X must be provided for out-of-sample predictions with datetime coordinates" ) if not hasattr(X, "coords") or "obs_ind" not in X.coords: raise ValueError( "X must have 'obs_ind' coordinate with datetime values for prediction" ) obs_ind_vals = X.coords["obs_ind"].values if len(obs_ind_vals) == 0 or not isinstance( obs_ind_vals[0], (np.datetime64, pd.Timestamp) ): raise ValueError("X 'obs_ind' coordinate must contain datetime values") idx = pd.DatetimeIndex(obs_ind_vals) scenario = None if self._exog_names: x_names = ( [str(name) for name in X.coords["coeffs"].values] if "coeffs" in X.coords else [] ) missing = [n for n in self._exog_names if n not in x_names] if missing: raise ValueError( f"X is missing exogenous columns used at fit time: {missing}." ) scenario = X.sel(coeffs=self._exog_names).values last = self._train_index[-1] # start forecasting after the last observed forecast_data = self._forecast( start=last, periods=len(idx), scenario=scenario ) forecast_copy = forecast_data.copy() # Rename 'time' to 'obs_ind' to match CausalPy conventions if "time" in forecast_copy.dims: forecast_copy = forecast_copy.rename({"time": "obs_ind"}) # The forecast generates its own future index from the training # frequency; results are then relabeled with X's dates. Warn when # the two disagree, since values map positionally. forecast_idx = pd.DatetimeIndex(forecast_copy.coords["obs_ind"].values) if not forecast_idx.equals(idx): warnings.warn( "The dates in X do not match the forecast index generated " "from the training data frequency. Forecast values are " "relabeled onto X's dates by position; check that the " "post-period dates continue the training frequency.", UserWarning, stacklevel=2, ) # Extract the forecasted observed data and add treated_units dimension y_hat = forecast_copy["forecast_observed"].isel(observed_state=0) y_hat_with_units = y_hat.expand_dims( {"treated_units": self._treated_units} ).transpose("chain", "draw", "obs_ind", "treated_units") result = xr.DataTree.from_dict( { "posterior_predictive": xr.Dataset( {"y_hat": y_hat_with_units, "mu": y_hat_with_units} ) } ) # Assign coordinates from input X for proper alignment if isinstance(X, xr.DataArray) and "obs_ind" in X.coords: _assign_group_coords(result, "posterior_predictive", obs_ind=X.obs_ind) return result
[docs] def score( self, X: xr.DataArray | None = None, y: xr.DataArray | None = None, coords: dict[str, Any] | None = None, ) -> pd.Series: """ Score the Bayesian R^2 given inputs X and outputs y. In-sample predictions come from the Kalman smoother, which conditions on the observed y, so this R^2 reads higher than for models that predict from covariates alone. Compare scores only within state-space models. Parameters ---------- X : xr.DataArray, optional Input features. In-sample predictions come from the Kalman smoother, so X is not used here. y : xr.DataArray Target variable with dims ["obs_ind", "treated_units"]. coords : dict, optional Not used, kept for API compatibility. Returns ------- pd.Series R² score and standard deviation for each treated unit. """ # Use base class implementation - X is accepted but not used by predict() return super().score(X, y, coords)