Quickstart#

One model definition serves fitting and forecasting: time_series() 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 model 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, backtest, evaluate_forecast, predict, time_series

# 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:]

def model(h, covariates):
    # a per-step drift latent; time_series adds the matching `_future` latent
    drift = time_series(h, "drift", lambda name, dims: pm.Normal(name, 0.0, 0.5, dims=dims))
    sigma = pm.HalfNormal("sigma", 1.0)
    predict(
        h,
        lambda name, mu, dims, obs: pm.Normal(name, mu, sigma, dims=dims, observed=obs),
        pt.cumsum(drift),                       # local-linear trend
    )

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)

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.

Covariates and richer latents#

For models with real covariates, pass full-horizon covariates to .forecast() instead of horizon= — see the electricity example. See markov_time_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.