Source code for causalpy.pymc_forecast_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.
"""Adapter that lets a ``pymc_forecast`` forecasting model act as a model
provider behind CausalPy's experiment API.

CausalPy keeps identification, counterfactual construction, and placebo
methods; ``pymc_forecast`` provides the fitted forecasting model. The wrapper
maps CausalPy's backend protocol onto the ``pymc_forecast`` drivers:

- ``fit(X, y)`` constructs and fits the forecasting model on the pre-period.
- ``predict(X)`` (in-sample) uses ``predict_in_sample()``.
- ``predict(X, out_of_sample=True)`` draws the counterfactual with
  ``forecast(future_covariates=...)`` when the design matrix has columns, or
  ``forecast(future_index=...)`` for a covariate-free trend/seasonal model.
- Draw-level samples are extracted with ``prediction_samples()`` and the
  documented output schema dims are renamed onto CausalPy coords
  (``time`` / ``time_future`` -> ``obs_ind``, ``series`` -> ``treated_units``).

**When to reach for this backend vs the existing PyMCModel classes:** use
:class:`PyMCForecastModel` when the counterfactual is best expressed as a
proper forecasting model (local level / trend, stochastic seasonality, ARIMA-
style dynamics) built with ``pymc_forecast`` primitives, and you want its
priors, inference backends (ADVI / NUTS / Pathfinder), and forecasting
machinery. Stick with the native :class:`~causalpy.pymc_models.PyMCModel`
classes (e.g. ``LinearRegression``, ``BayesianBasisExpansionTimeSeries``) for
plain regression-style counterfactuals or when you need model coefficients
tied to the patsy design matrix.

Requires the optional dependency ``pymc-forecast`` (``pip install
causalpy[forecast]``).

Notes
-----
Causal impact is computed from upstream ``mu`` / ``mu_future``, which CausalPy
interprets as the conditional expected outcome in observed outcome units:
parameter and latent uncertainty, excluding observation-level noise. Models
using a link function must therefore apply the inverse link before passing the
latent to ``pymc_forecast.predict``. Passing a link-scale linear predictor
would make CausalPy subtract quantities in incompatible units and is not
supported. The draw-level posterior predictive of the observed variable is
reported separately as ``y_hat``. One posterior subsample is drawn at fit time
and shared by every predictive call, so draw *i* of the pre-period fit and draw
*i* of the counterfactual come from the same parameter draw (upstream
``posterior=`` passthrough).

``StatespaceForecaster`` models are rejected for now: their upstream outputs
carry no separate noise-free latent, so the impact convention above cannot be
honoured without silently substituting the noisy predictive. Tracked upstream
as `pymc-forecast#50 <https://github.com/pymc-labs/pymc-forecast/issues/50>`_.

Inference diagnostics: :attr:`PyMCForecastModel.idata` holds the thinned,
draw-coherent posterior subsample used for prediction; the *full* fit result
(e.g. the complete NUTS ``DataTree`` with sample stats) is exposed as
:attr:`PyMCForecastModel.fit_idata`.
"""

from __future__ import annotations

from contextlib import nullcontext
from typing import Any

import arviz as az
import numpy as np
import pandas as pd
import xarray as xr

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

__all__ = ["PyMCForecastModel"]


def _import_pymc_forecast():
    """Import ``pymc_forecast`` lazily, failing with install instructions."""
    try:
        import pymc_forecast
    except ImportError as err:  # pragma: no cover - exercised without extra
        raise ImportError(
            "PyMCForecastModel requires the optional dependency 'pymc-forecast'. "
            "Install it with `pip install causalpy[forecast]` or "
            "`pip install 'pymc-forecast[extras]>=0.2,<0.3'`."
        ) from err
    return pymc_forecast


[docs] class PyMCForecastModel: """Wrap a ``pymc_forecast`` model as a CausalPy time-series backend. Parameters ---------- model_fn : callable or pymc_forecast.ForecastingModel The forecasting model body ``(Horizon, covariates) -> None`` or a :class:`pymc_forecast.ForecastingModel` instance. Priors flow through this object (pymc-extras ``Prior``), preserving the transparent-prior ethos. forecaster : type, optional The ``pymc_forecast`` forecaster class used to fit the model. Defaults to :class:`pymc_forecast.HMCForecaster` (NUTS), matching CausalPy's native PyMC backends. Pass :class:`pymc_forecast.Forecaster` (ADVI) or :class:`pymc_forecast.PathfinderForecaster` for faster approximate inference — but check convergence before trusting the counterfactual. :class:`pymc_forecast.StatespaceForecaster` is not supported yet (its outputs carry no noise-free latent; see pymc-forecast#50). forecaster_kwargs : dict, optional Extra keyword arguments for the forecaster constructor (e.g. ``{"draws": 500}`` for MCMC, ``{"num_steps": 20_000}`` for ADVI, or ``{"progressbar": True}`` — accepted uniformly by every forecaster). The forecaster is constructed immediately (unfitted), so invalid options fail at construction rather than at experiment time. num_samples : int, default 500 Number of posterior draws, drawn once at fit time and shared by every predictive call so that in-sample prediction and counterfactual are conditioned on the same parameter draws. random_seed : int, optional Seed passed to fitting, the posterior subsample, and every predictive call. Examples -------- >>> import causalpy as cp >>> import pandas as pd >>> import pymc as pm >>> import pytensor.tensor as pt >>> import pymc_forecast # doctest: +SKIP >>> def local_level(h, covariates): # doctest: +SKIP ... drift = pymc_forecast.time_series( ... h, "drift", lambda name, dims: pm.Normal(name, 0, 0.1, dims=dims) ... ) ... sigma = pm.HalfNormal("sigma", 1) ... pymc_forecast.predict( ... h, ... lambda name, mu, dims, observed: pm.Normal( ... name, mu, sigma, dims=dims, observed=observed ... ), ... pt.cumsum(drift), ... ) >>> result = cp.InterruptedTimeSeries( # doctest: +SKIP ... df, ... treatment_time, ... formula="y ~ 0", ... model=cp.pymc_forecast_models.PyMCForecastModel(local_level), ... ) """
[docs] def __init__( self, model_fn: Any, forecaster: type | None = None, forecaster_kwargs: dict[str, Any] | None = None, num_samples: int = 500, random_seed: int | None = None, ) -> None: self._pf = _import_pymc_forecast() self.model_fn = model_fn if forecaster is None: forecaster = self._pf.HMCForecaster # Statespace outputs carry no noise-free latent (mu/mu_future), so # CausalPy's impact convention cannot be honoured without silently # substituting the noisy predictive. Reject until pymc-forecast#50 # ships the expected-observation outputs. if isinstance(model_fn, self._pf.StatespaceModel) or ( isinstance(forecaster, type) and issubclass(forecaster, self._pf.StatespaceForecaster) ): raise NotImplementedError( "StatespaceModel / StatespaceForecaster backends are not " "supported yet: their prediction outputs carry no noise-free " "latent (mu), which CausalPy's causal-impact convention " "requires. Tracked upstream as " "https://github.com/pymc-labs/pymc-forecast/issues/50." ) self.forecaster_cls = forecaster self.forecaster_kwargs = dict(forecaster_kwargs or {}) self.num_samples = num_samples self.random_seed = random_seed # deferred-fit construction (pymc-forecast >= 0.2): hold a real # configured forecaster, not a (class, kwargs) recipe self.forecaster: Any = self.forecaster_cls( self.model_fn, random_seed=self.random_seed, **self.forecaster_kwargs, ) self.idata: xr.DataTree | None = None self._posterior: xr.Dataset | None = None self._treated_units: list[str] = ["unit_0"] self._has_covariates = False
def _clone(self) -> PyMCForecastModel: """Return a fresh, unfitted copy with the same configuration. Used by CausalPy's sensitivity checks (e.g. :class:`~causalpy.checks.PlaceboInTime`) via :func:`~causalpy.checks.base.clone_model` to refit the same model specification on placebo data. """ return type(self)( self.model_fn, forecaster=self.forecaster_cls, forecaster_kwargs=dict(self.forecaster_kwargs), num_samples=self.num_samples, random_seed=self.random_seed, ) @property def fit_idata(self) -> xr.DataTree: """Full inference result of the underlying forecaster fit. For the default NUTS backend this is the complete MCMC ``DataTree`` (posterior, sample stats, diagnostics) — as distinct from :attr:`idata`, which holds the thinned posterior subsample shared by every predictive call for draw coherence. Raises ------ RuntimeError If the model has not been fit yet. AttributeError If the forecaster does not retain a ``DataTree`` fit result (e.g. variational fits, which expose ``.approx`` / ``.losses`` on ``.forecaster`` instead). """ if self.idata is None: raise RuntimeError("Model has not been fit yet.") fit_result = getattr(self.forecaster, "idata", None) if fit_result is None: raise AttributeError( f"{type(self.forecaster).__name__} does not retain a full " "DataTree fit result; inspect the forecaster directly " "via `.forecaster` (e.g. `.approx` / `.losses` for " "variational fits)." ) return fit_result # -- fitting -----------------------------------------------------------
[docs] def fit( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None = None ) -> xr.DataTree: """Construct and fit the forecasting model on the pre-period. Parameters ---------- X : xr.DataArray Design matrix with dims ``["obs_ind", "coeffs"]`` whose ``obs_ind`` coordinate carries the real (datetime or numeric) index. Columns are passed to ``pymc_forecast`` as covariates; a zero-column design (formula ``"y ~ 0"``) fits a covariate-free model. y : xr.DataArray Outcome with dims ``["obs_ind", "treated_units"]``. Must contain exactly one treated unit. coords : dict, optional Ignored; the real coordinates are read from ``X`` and ``y``. """ if y.sizes["treated_units"] != 1: raise ValueError( "PyMCForecastModel supports a single treated unit, got " f"{y.sizes['treated_units']}." ) self._treated_units = [str(u) for u in y.treated_units.values] data = y.isel(treated_units=0, drop=True).rename({"obs_ind": "time"}) covariates = self._as_covariates(X) self._has_covariates = covariates is not None self.forecaster.fit(data, covariates, random_seed=self.random_seed) # one posterior subsample, shared by every predictive call: draw i of # the pre-period fit and draw i of the counterfactual come from the # same parameter draw posterior_context = ( self.forecaster.model if isinstance(self.forecaster, self._pf.Forecaster) else nullcontext() ) with posterior_context: self._posterior = self.forecaster.draw_posterior( self.num_samples, random_seed=self.random_seed ) self.idata = xr.DataTree.from_dict({"posterior": self._posterior}) return self.idata
@staticmethod def _as_covariates(X: xr.DataArray) -> xr.DataArray | None: """Map a patsy design matrix onto ``pymc_forecast`` covariates.""" if X.sizes["coeffs"] == 0: return None return X.rename({"obs_ind": "time", "coeffs": "covariate"}) # -- prediction --------------------------------------------------------
[docs] def predict( self, X: xr.DataArray, coords: dict[str, Any] | None = None, out_of_sample: bool | None = False, ) -> xr.DataTree: """Predict in-sample (pre-period) or forecast the counterfactual. Parameters ---------- X : xr.DataArray Design matrix with dims ``["obs_ind", "coeffs"]``. In-sample prediction replays the training window (``X`` supplies only the output coordinates); out-of-sample prediction conditions the forecast on ``X``'s columns as future covariates (or, for a covariate-free model, forecasts over ``X``'s ``obs_ind`` index). coords : dict, optional Not used, kept for API compatibility. out_of_sample : bool, default False ``True`` draws the post-period counterfactual ("as if untreated"). Returns ------- xr.DataTree With a ``posterior_predictive`` group holding draw-level ``mu`` (the noise-free latent predictor) and ``y_hat`` (the posterior predictive of the observed variable) with dims ``(chain, draw, obs_ind, treated_units)``. """ if self._posterior is None: raise RuntimeError("Model has not been fit yet.") if out_of_sample: if self._has_covariates: result = self.forecaster.forecast( future_covariates=self._as_covariates(X), posterior=self._posterior, random_seed=self.random_seed, ) else: result = self.forecaster.forecast( future_index=X.obs_ind.values, posterior=self._posterior, random_seed=self.random_seed, ) samples = self._pf.prediction_samples(result) y_hat = samples[self._pf.FORECAST_VAR] mu = samples[self._pf.MU_FORECAST_VAR] time_dim = self._pf.FUTURE_DIM else: result = self.forecaster.predict_in_sample( posterior=self._posterior, random_seed=self.random_seed ) samples = self._pf.prediction_samples(result) y_hat = samples[self._pf.OBS_VAR] mu = samples[self._pf.MU_VAR] time_dim = self._pf.TIME_DIM mu = mu.rename({time_dim: "obs_ind"}) y_hat = y_hat.rename({time_dim: "obs_ind"}) return self._to_inference_data(mu, y_hat, X.obs_ind.values)
def _to_inference_data( self, mu: xr.DataArray, y_hat: xr.DataArray, obs_ind: np.ndarray ) -> xr.DataTree: """Rename schema dims onto CausalPy coords and wrap as a DataTree.""" def normalize(samples: xr.DataArray) -> xr.DataArray: if "series" in samples.dims: samples = samples.rename({"series": "treated_units"}) else: samples = samples.expand_dims(treated_units=self._treated_units) return samples.assign_coords(obs_ind=obs_ind).transpose( "chain", "draw", "obs_ind", "treated_units" ) ds = xr.Dataset({"mu": normalize(mu), "y_hat": normalize(y_hat)}) return xr.DataTree.from_dict({"posterior_predictive": ds}) # -- scoring and impact ------------------------------------------------
[docs] def score( self, X: xr.DataArray, y: xr.DataArray, coords: dict[str, Any] | None = None ) -> pd.Series: """Bayesian :math:`R^2` of the in-sample posterior predictive vs ``y``. Matches the ``PyMCModel.score`` output shape: one ``unit_{i}_r2`` / ``unit_{i}_r2_std`` pair per treated unit. Parameters ---------- X : xr.DataArray Design matrix with dims ``["obs_ind", "coeffs"]``. y : xr.DataArray Observed outcomes with dims ``["obs_ind", "treated_units"]``. coords : dict, optional Not used, kept for API compatibility. """ pred = self.predict(X) mu = az.extract(pred, group="posterior_predictive", var_names="mu") scores = {} for i, unit in enumerate(mu.coords["treated_units"].values): unit_mu = mu.sel(treated_units=unit).transpose("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 posterior means and HDIs of the model's scalar parameters. Forecasting-model parameters do not map onto the patsy design-matrix ``labels``, so those are ignored; every scalar variable in the fitted posterior is reported instead. Time-varying latents are skipped. Parameters ---------- labels : list of str Design-matrix labels; ignored by forecasting models. 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 yet.") posterior = self.idata.posterior scalar_vars = [ name for name, da in posterior.data_vars.items() if set(da.dims) == {"chain", "draw"} ] print("Model parameters:") if not scalar_vars: print(" (no scalar parameters in posterior)") return max_label_length = max(len(name) for name in scalar_vars) for name in scalar_vars: samples = posterior[name] formatted_val = ( f"{round_num(samples.mean().data, round_to)}, " f"{HDI_PROB * 100:.0f}% HDI " f"[{round_num(samples.quantile((1 - HDI_PROB) / 2).data, round_to)}, " f"{round_num(samples.quantile(1 - (1 - HDI_PROB) / 2).data, round_to)}]" ) print(f" {name: <{max_label_length}} {formatted_val}")