Quickstart#

One model definition serves fitting and forecasting: innovations() creates separate {name}_future latents that are absent from the fitted posterior, so posterior predictive sampling replays the fitted parameters while drawing the horizon forward. Write a ForecastingModel with one predict() call, fit it, and forecast:

import numpy as np, pandas as pd, pymc as pm, pytensor.tensor as pt
from pymc_forecast import Forecaster, ForecastingModel, backtest, evaluate_forecast

# a trending weekly series; hold out the last 8 weeks
dates = pd.date_range("2024-01-07", periods=60, freq="W")
y = pd.Series(np.cumsum(np.random.default_rng(0).normal(0.2, 1.0, 60)) + 10, index=dates)
train, test = y.iloc[:52], y.iloc[52:]


class LocalLevel(ForecastingModel):
    def model(self, covariates, data=None):
        # a per-step drift latent; innovations adds the matching `_future` latent
        drift = self.innovations("drift", pm.Normal.dist(0.0, 0.5))
        sigma = pm.HalfNormal("sigma", 1.0)
        self.predict(pm.Normal.dist(0.0, sigma), pt.cumsum(drift))  # local-linear trend


model = LocalLevel()
fc = Forecaster(model, train, num_steps=5_000, random_seed=0)  # ADVI
idata = fc.forecast(horizon=8, num_samples=500, random_seed=0)
forecast = idata["predictions"]["forecast"]  # dims: (chain, draw, time_future)

# score against the held-out weeks (aligned by dim name, not axis position)
truth = test.to_xarray().rename({"index": "time_future"})
print(evaluate_forecast(forecast, truth))  # {'mae': ..., 'rmse': ..., 'crps': ..., 'coverage': ...}

# rolling-origin backtest over the whole series
results = backtest(
    y,
    None,
    model,
    min_train_window=48,
    test_window=4,
    stride=4,
    num_samples=200,
    forecaster_options={"num_steps": 3_000},
    random_seed=0,
)

For a non-identity link, keep the link-scale latent and provide the outcome-scale expectation explicitly. A 1-argument callable receives the windowed latent and returns a .dist(); inside model():

eta = intercept + pt.dot(covariates.values, beta)
self.predict(
    # the callable receives the *windowed* latent, so it applies the inverse
    # link itself — it cannot reuse the full-horizon `pt.exp(eta)` below
    lambda eta_window: pm.Poisson.dist(pt.exp(eta_window)),
    eta,
    expected_observation=pt.exp(eta),
)

Predictions then include mu / mu_future (the supplied eta) and expected_observation / expected_observation_future (expected counts); all retain the same chain, draw, and time coordinates. See the prediction output schema for the full contract.

Functional API#

The same model can be written as a plain function: it builds the current Horizon itself and passes it to the module-level innovations() / predict() primitives that the ForecastingModel helpers wrap. fit_vi() returns a FitResult; draw the posterior from it with draw_posterior() and pass that to forecast():

from pymc_forecast import (
    Horizon,
    draw_posterior,
    fit_vi,
    forecast,
    innovations,
    null_covariates,
    predict,
)


def local_level(covariates, data=None):
    h = Horizon.from_data(covariates, data)
    drift = innovations(h, "drift", pm.Normal.dist(0.0, 0.5))
    sigma = pm.HalfNormal("sigma", 1.0)
    predict(h, pm.Normal.dist(0.0, sigma), pt.cumsum(drift))


result = fit_vi(local_level, train, num_steps=5_000, random_seed=0)  # ADVI
posterior = draw_posterior(result, 500, random_seed=0)
idata = forecast(local_level, posterior, train, null_covariates(dates), random_seed=0)

Both forms build the same PyMC model. The forecaster classes call fit_vi() / fit_mcmc() / fit_pathfinder(), and every forecaster, fitter, and backtest() accepts either model form.

Check VI convergence#

Forecaster uses mean-field ADVI, which can underconverge silently and hand back confidently wrong forecasts. A post-fit heuristic warns when the ELBO loss is still clearly descending, but its absence is not proof of convergence — inspect fc.losses and confirm it has plateaued before trusting results. Increase num_steps, raise the learning rate (optimizer=0.05), or switch to HMCForecaster when accuracy matters more than speed.

Other inference backends#

Swap Forecaster for HMCForecaster (NUTS, with nuts_sampler="nutpie"/"numpyro"/...) or PathfinderForecaster (pymc-extras) — the fit/forecast interface is identical. Every forecaster (including StatespaceForecaster) accepts progressbar= directly, so backends can be swapped without moving that option into fit_kwargs, sample_kwargs, or pathfinder_kwargs:

fc = HMCForecaster(model, train, draws=1_000, progressbar=True)

For configure-now / fit-later lifecycles (sklearn-style adapters), omit the data at construction and call fit() explicitly — passing data to the constructor remains the equivalent one-step path:

fc = Forecaster(model, num_steps=5_000, progressbar=False)
# store or pass `fc` as a configured object, then fit when data is available
fc.fit(train, random_seed=0)
idata = fc.forecast(horizon=8, num_samples=500)

The same object can be refit; its backend configuration is reused. Predictive methods raise NotFittedError until fit() has completed, and fc.is_fitted reports the state.

The functional counterparts are fit_vi(), fit_mcmc(), and fit_pathfinder(): each returns a FitResult (a VI result keeps idata=None; draw_posterior() returns the posterior to pass to forecast).

Covariates and richer latents#

For models with real covariates, pass full-horizon covariates to .forecast() instead of horizon= — see the electricity example. See markov_series() for state-space latents and predict_mvn() for observation noise correlated across time.

Statespace models#

pymc-extras statespace structural models (level/trend, seasonality, SARIMAX, …) are first-class citizens too: define one as a StatespaceModel and fit it with StatespaceForecaster — the same forecast (including exogenous-regression covariates), predict_in_sample, backtest, and metrics calls apply, with the Kalman filter marginalizing the latent states instead of sampling them. See the scan-vs-statespace comparison.