Observation-driven recursions#

.. py:module:: pymc_forecast.ssoe

Observation-driven recursions with a single source of error.

The deterministic training filter and the generative forecast share the same mean and update functions. Only future errors are random variables; they are absent during fitting and drawn fresh during posterior replay. Inspired by numpyro_forecast.models.ssoe (Apache-2.0), using PyTensor and named coords.

.. py:class:: SSOEResult(mu, mu_future, y_future, dims) :module: pymc_forecast.ssoe

Bases: :py:class:object

Outputs of :func:ssoe, before registering an observation likelihood.

mu contains in-sample one-step-ahead means. mu_future contains forecast means conditional on the preceding simulated observations; y_future contains those means plus their current observation errors. Both future tensors have length zero during training. Time is first in these symbolic tensors; dims records the remaining named dimensions. Use mu_future to exclude the current observation error, remembering that earlier future errors still affect the state.

Each parameter is available as an attribute of the same name.

:param mu: In-sample one-step-ahead means, shape (t_obs, *row). :type mu: :py:class:pytensor.tensor.TensorVariable :param mu_future: Future means, shape (future, *row) (length zero during training). :type mu_future: :py:class:pytensor.tensor.TensorVariable :param y_future: Future simulated observations mu_future plus the current errors (length zero during training). :type y_future: :py:class:pytensor.tensor.TensorVariable :param dims: Non-time dims of the rows, in the order used by the tensors. :type dims: :py:class:tuple of :py:class:str

.. py:attribute:: SSOEResult.mu :module: pymc_forecast.ssoe

.. py:attribute:: SSOEResult.mu_future :module: pymc_forecast.ssoe

.. py:attribute:: SSOEResult.y_future :module: pymc_forecast.ssoe

.. py:attribute:: SSOEResult.dims :module: pymc_forecast.ssoe

.. py:function:: ssoe(h, name, y, init, mean, update, noise, xs=None, *, params=(), dims=None) :module: pymc_forecast.ssoe

Filter observed values, then simulate a recursive forecast.

mean(state, x, *params) and update(state, y, error, x, *params) take params because PyTensor scan cannot close over random variables. noise is not scanned: it is a .dist() or a Prior registered only as {name}_future. Pass random coefficients in params. This helper does not auto-detect closed-over RVs.

:param h: The horizon of the current model build. :type h: :py:class:pymc_forecast.model.Horizon :param name: Base name of the future error variable. Only f"{name}_future" is registered, and only when forecasting. :type name: :py:class:str :param y: Labeled driving history. None uses h.data. Must cover exactly the training window (time coords equal to h.time), with finite values and non-time coords matching the model coords. A transformed history can be supplied to compose multiple recursion channels. Prior-only builds need an explicit driving history; this helper does not generate an in-sample history. :type y: :py:class:xarray.DataArray or :py:obj:None :param init: Initial state: one tensor-like or a nonempty tuple of them (only a tuple means multiple states; each is cast to floatX). States may have different shapes (e.g. scalar level and vector seasonality). :type init: :py:class:pytensor.tensor.TensorVariable or :py:class:tuple of :py:class:pytensor.tensor.TensorVariable :param mean: (state, x_t, *params) -> mu_t. Returns the one-step-ahead mean, shaped like one row of y. x_t is None without xs. :type mean: :py:class:callable :param update: (state, y_t, eps_t, x_t, *params) -> state. Returns the next state with the same structure and shapes as init. In-sample, eps_t = y_t - mu_t; in the future, eps_t is freshly drawn and y_t = mu_t + eps_t. Both callbacks must be deterministic. :type update: :py:class:callable :param noise: Unnamed .dist() or pymc-extras Prior for independent, zero-centered per-step future errors, expanded and registered as f"{name}_future" only. An RVFactory or other callable is rejected. Parameters broadcast against ("time_future", *dims), so a per-series scale works. Errors may be correlated across the observation dimensions, using e.g. pm.MvNormal.dist. In-sample errors are residuals, not random variables.

             A learned scale must be a model variable shared with the observation:
             create ``sigma = pm.HalfNormal("sigma", 1)`` in the model body, use it
             in the observation, and pass ``pm.Normal.dist(0, sigma)``. Hence a
             ``Prior`` with a hyper-prior parameter (which would exist only on
             forecasting builds and be drawn from its prior), a model variable
             instead of a ``.dist()``, and a ``.dist()`` depending on unnamed random
             variables are rejected, on training builds too. A Normal/StudentT
             ``Prior`` must leave ``mu`` unset or zero, and a ``pm.Normal.dist`` /
             ``pm.StudentT.dist`` must have a constant zero location
             (``pm.Normal.dist(sigma)`` binds ``sigma`` as ``mu``). The locations of
             other dists are not checked.

:type noise: :py:class:pymc_extras.prior.Prior or :py:class:pytensor.tensor.TensorVariable :param xs: Labeled inputs spanning the full horizon (None means no inputs). The time dimension is selected by name and its coordinates are checked; non-time dims are checked only when they are model coords. Values must be finite. Future inputs must be known covariates or explicit scenarios: never derive future update gates from held-out observations. Extra rows are ignored during a shorter training build. :type xs: :py:class:xarray.DataArray, optional :param params: Tensor parameters passed explicitly to both callbacks. Required for random coefficients: scan cannot close over RVs, and closed-over RVs are not detected. :type params: :py:class:sequence, default () :param dims: Non-time observation dimensions; None infers them from y. Labeled data are transposed into this order before entering the scan. :type dims: :py:class:tuple of :py:class:str, optional

:returns: :py:class:SSOEResult – In-sample means, future means and future samples. The caller registers obs against result.mu and forecast as a Deterministic of result.y_future. Register mu and mu_future Deterministics to include the means in the standard prediction outputs. Do not add another observation draw to y_future: it already includes noise.

:raises pymc_forecast.exceptions.HorizonError: If there is no observed history (y and h.data are both None, or h.t_obs == 0); if noise is invalid (see above; checked on every build); or, when forecasting, if a dim in ("time_future", *dims) is not a model coord or a .dist() noise has more axes than dims. :raises pymc_forecast.exceptions.AlignmentError: If y or xs is not a DataArray with a "time" dim; if the time coords of y differ from h.time, or xs is shorter than the horizon or its time coords differ; if dims does not name each non-time dim of y exactly once or contains "time_future"; or if a dim’s coords do not match the model coords. :raises ValueError: If y or xs has non-finite values; if init is an empty tuple; or if mean/update violate their contract (mean with the wrong number of dims, update changing the state structure, count or number of dims, or the callbacks creating random updates or model variables).

.. rubric:: Notes

This is an observation-driven filter, not a latent Markov process. For sampled hidden states use :func:~pymc_forecast.markov.markov_series; for linear-Gaussian hidden states consider the statespace backend (:class:~pymc_forecast.statespace.StatespaceForecaster).

A minimal ARMA model#

The state contains the previous observation and its one-step prediction error. The same callbacks filter training observations and simulate future observations.

import pymc as pm
from pymc_forecast import Horizon, ssoe


def arma(covariates, data=None):
    h = Horizon.from_data(covariates, data)
    phi = pm.Uniform("phi", -0.95, 0.95)
    theta = pm.Uniform("theta", -0.95, 0.95)
    sigma = pm.HalfNormal("sigma", 1)

    result = ssoe(
        h,
        "eps",
        None,
        (0.0, 0.0),
        lambda state, x, phi, theta: phi * state[0] + theta * state[1],
        lambda state, y, error, x, phi, theta: (y, error),
        pm.Normal.dist(0, sigma),
        params=(phi, theta),
    )
    pm.Normal("obs", result.mu, sigma, observed=h.data.values, dims="time")
    pm.Deterministic("mu", result.mu, dims="time")
    if h.future:
        pm.Deterministic("mu_future", result.mu_future, dims="time_future")
        pm.Deterministic("forecast", result.y_future, dims="time_future")

The helper registers only eps_future, and only during forecasting. The caller owns the observation distribution and can compose several recursion channels. Do not pass y_future to predict with another noisy observation distribution: y_future already contains the observation error used to update the future state. The explicit Deterministics above use the usual prediction schema and are returned by forecast and predict_in_sample.

mu is a one-step fitted mean conditioned on the actual observed history. In-sample posterior prediction samples observations around these fitted means; it does not simulate a new training history. mu_future conditions on previously simulated future observations and excludes only the current step’s error. It is not an unconditional multi-step mean with all future errors integrated out.

Labeled inputs and update gates#

y defaults to h.data; a transformed DataArray can be passed explicitly. Its time coordinates must match the observed window exactly. Non-time dimensions are inferred by name, checked against model coordinates, and preserved in the result. xs is a DataArray covering the full horizon; its time dimension can appear anywhere. Each callback receives one time slice in its other dimension order. Pass model parameters via params and keep both callbacks deterministic.

For unavailable or missing observations, supply a finite placeholder and use an explicit gate in xs to prevent state updates. The likelihood must also exclude those observations; an update gate alone does not mask a likelihood. Future gates must come from known inputs or an explicit scenario, never held-out observations. The helper does not infer a missing-data or censoring mechanism.

For sampled hidden states, use markov_series. For linear-Gaussian hidden states, the pymc-extras statespace backend can marginalize the state path. See the ARMA, Holt-Winters, and censored demand examples for observation-driven recursions. The censored example is the update-gate pattern above: availability keeps a stockout zero out of the lag, and the likelihood contributes survival mass at a known cap instead of a density.