Exponential smoothing in state-space form with pymc_forecast#

This notebook ports the upstream NumPyro exponential-smoothing state-space example to PyMC. The model is a damped Holt-Winters single-source-of-error system: one innovation updates level, trend, and seasonality, and future innovations are fed back into the forecast state.

The custom recursion is written with pytensor.scan, while fitting and forecasting use HMCForecaster and the same posterior predictive replay mechanism as the rest of the package.

Prepare notebook#

import logging
import os
from functools import partial

import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
import pytensor
import pytensor.tensor as pt
import xarray as xr

from pymc_forecast import (
    HMCForecaster,
    Horizon,
    eval_coverage,
    eval_crps,
    eval_mae,
    eval_rmse,
    evaluate_forecast,
    null_covariates,
)
from pymc_forecast.data import FUTURE_DIM, TIME_DIM

az.style.use("arviz-darkgrid")
plt.rcParams["figure.figsize"] = [10, 6]
plt.rcParams["figure.dpi"] = 100
plt.rcParams["figure.facecolor"] = "white"

logging.getLogger("pymc").setLevel(logging.ERROR)
logging.getLogger("pytensor").setLevel(logging.ERROR)

SEED = 42
rng = np.random.default_rng(SEED)

# CI executes every example notebook end-to-end with reduced settings.
SMOKE_TEST = os.environ.get("PYMC_FORECAST_SMOKE_TEST", "0") == "1"
DRAWS = 50 if SMOKE_TEST else 1_000
TUNE = 50 if SMOKE_TEST else 1_000
CHAINS = 2 if SMOKE_TEST else 4

Generate synthetic data#

The synthetic series follows the upstream example: a seasonal cosine wave, a slow logarithmic trend, and Gaussian noise. The seasonal state length is m = 15.

n_seasons = 15
t = np.linspace(0, n_seasons + 1, (n_seasons + 1) * n_seasons)
y = np.cos(2 * np.pi * t) + np.log(t + 1) + 0.2 * rng.normal(size=t.shape)

n = len(y)
n_train = int(0.8 * n)
future = n - n_train

train_data = xr.DataArray(y[:n_train], dims=(TIME_DIM,), coords={TIME_DIM: t[:n_train]})
test_data = xr.DataArray(y[n_train:], dims=(TIME_DIM,), coords={TIME_DIM: t[n_train:]})
covariates_full = null_covariates(t)
covariates_train = covariates_full.isel({TIME_DIM: slice(None, n_train)})

print(f"total: {n}, train: {n_train}, test horizon: {future}")
total: 240, train: 192, test horizon: 48
fig, ax = plt.subplots()
ax.plot(train_data[TIME_DIM].values, train_data.values, color="C0", label="train")
ax.plot(test_data[TIME_DIM].values, test_data.values, color="C1", label="test")
ax.axvline(test_data[TIME_DIM].values[0], color="gray", linestyle="--", label="train/test split")
ax.legend(loc="upper left")
ax.set(title="Synthetic time series", xlabel="time", ylabel="y")
plt.show()
../_images/7da44a8f69e8916df256aa41c71af40444bde0dd576a6999c784032cfa3bf185.png

Damping sums#

Damped trend forecasts repeatedly add powers of the damping parameter. The finite sum below is the scan-like quantity that appears in the state-space recursion.

def damping_sum(phi: float, horizon: int) -> tuple[float, np.ndarray]:
    powers = phi ** np.arange(1, horizon + 1)
    partial_sums = np.cumsum(powers)
    return float(partial_sums[-1]), partial_sums


phi_example = 0.8
total, partial_sums = damping_sum(phi_example, 5)
closed_form = sum(phi_example**i for i in range(1, 6))
print(f"scan-style result: {total:.5f}")
print(f"closed form:       {closed_form:.5f}")
print(f"partial sums:      {partial_sums.round(5)}")
scan-style result: 2.68928
closed form:       2.68928
partial sums:      [0.8     1.44    1.952   2.3616  2.68928]

Model#

The in-sample scan computes one-step-ahead means and innovations from observed data. During forecasting, eps_future is absent from the fitted trace, so posterior predictive sampling draws fresh future innovations and feeds them through the same state update.

def exponential_smoothing_ssm(h: Horizon, covariates: xr.DataArray) -> None:
    """Damped Holt-Winters exponential smoothing in innovations state-space form."""
    if h.data is None:
        msg = "the exponential smoothing model requires observed data"
        raise ValueError(msg)

    y_obs_np = np.asarray(h.data.values, dtype="float64")
    y_obs = pt.as_tensor_variable(y_obs_np)

    level_smoothing = pm.Beta("level_smoothing", 5, 5)
    level_init = pm.Normal("level_init", float(y_obs_np[0]), 1.0)
    trend_smoothing = pm.Beta("trend_smoothing", 5, 5)
    trend_init = pm.Normal("trend_init", 0.0, 0.1)
    seasonality_smoothing = pm.Beta("seasonality_smoothing", 5, 5)
    phi = pm.Beta("phi", 2, 5)
    seasonality_init = pm.Normal("seasonality_init", 0.0, 1.0, shape=n_seasons)
    noise = pm.HalfNormal("noise", 0.5)

    beta = trend_smoothing * level_smoothing
    gamma = seasonality_smoothing * (1.0 - level_smoothing)

    def train_step(y_t, level, trend, seasonality, alpha, beta, gamma, phi):
        mu_t = level + phi * trend + seasonality[0]
        innovation = y_t - mu_t
        new_level = level + phi * trend + alpha * innovation
        new_trend = phi * trend + beta * innovation
        new_season = seasonality[0] + gamma * innovation
        new_seasonality = pt.concatenate([seasonality[1:], new_season[None]])
        return new_level, new_trend, new_seasonality, mu_t

    level_hist, trend_hist, seasonality_hist, mu = pytensor.scan(
        fn=train_step,
        sequences=[y_obs],
        outputs_info=[level_init, trend_init, seasonality_init, None],
        non_sequences=[level_smoothing, beta, gamma, phi],
        strict=True,
        return_updates=False,
    )
    pm.Deterministic("mu", mu, dims=TIME_DIM)
    pm.Normal("obs", mu=mu, sigma=noise, observed=y_obs_np, dims=TIME_DIM)

    if h.future > 0:
        eps_future = pm.Normal("eps_future", 0.0, noise, dims=FUTURE_DIM)

        def forecast_step(eps_t, level, trend, seasonality, alpha, beta, gamma, phi):
            mu_t = level + phi * trend + seasonality[0]
            y_t = mu_t + eps_t
            new_level = level + phi * trend + alpha * eps_t
            new_trend = phi * trend + beta * eps_t
            new_season = seasonality[0] + gamma * eps_t
            new_seasonality = pt.concatenate([seasonality[1:], new_season[None]])
            return new_level, new_trend, new_seasonality, y_t

        _, _, _, forecast = pytensor.scan(
            fn=forecast_step,
            sequences=[eps_future],
            outputs_info=[level_hist[-1], trend_hist[-1], seasonality_hist[-1], None],
            non_sequences=[level_smoothing, beta, gamma, phi],
            strict=True,
            return_updates=False,
        )
        pm.Deterministic("forecast", forecast, dims=FUTURE_DIM)

Priors#

prior_parameter_idata = az.from_dict(
    {
        "prior": {
            "Beta(5, 5)": pm.draw(pm.Beta.dist(5, 5), draws=5_000, random_seed=SEED)[None, :],
            "Beta(2, 5)": pm.draw(pm.Beta.dist(2, 5), draws=5_000, random_seed=SEED + 1)[
                None,
                :,
            ],
            "HalfNormal(0.5)": pm.draw(
                pm.HalfNormal.dist(0.5),
                draws=5_000,
                random_seed=SEED + 2,
            )[None, :],
        }
    }
)
pc = az.plot_dist(
    prior_parameter_idata,
    var_names=["Beta(5, 5)", "Beta(2, 5)", "HalfNormal(0.5)"],
    group="prior",
    figure_kwargs={"figsize": (10, 6)},
)
pc.viz["figure"].item().suptitle("Priors", fontsize=16, fontweight="bold")
plt.show()
../_images/bb73d44f15ad8e949c1cecf1d83a9cc1fbd23fe7d34d8c00248ea8d7884c02e8.png

Inference#

HMCForecaster wraps pm.sample. The schedule below is moderate for an example; increase draws and tune if you want publication-quality diagnostics.

forecaster = HMCForecaster(
    exponential_smoothing_ssm,
    train_data,
    covariates_train,
    draws=DRAWS,
    tune=TUNE,
    chains=CHAINS,
    random_seed=SEED,
    sample_kwargs={"target_accept": 0.9},
)

Diagnostics#

scalar_vars = [
    "level_smoothing",
    "trend_smoothing",
    "seasonality_smoothing",
    "phi",
    "noise",
    "level_init",
    "trend_init",
]
az.summary(forecaster.idata, var_names=scalar_vars, round_to=2)
mean sd eti89_lb eti89_ub ess_bulk ess_tail r_hat mcse_mean mcse_sd
level_smoothing 0.32 0.06 0.22 0.42 2550.33 2526.28 1.00 0.00 0.00
trend_smoothing 0.47 0.15 0.24 0.71 3170.16 2625.21 1.00 0.00 0.00
seasonality_smoothing 0.22 0.07 0.11 0.35 1441.29 1746.64 1.00 0.00 0.00
phi 0.19 0.12 0.04 0.41 2444.20 1990.43 1.00 0.00 0.00
noise 0.22 0.01 0.20 0.24 2019.24 2422.94 1.00 0.00 0.00
level_init 0.26 0.30 -0.22 0.72 514.49 725.06 1.01 0.01 0.01
trend_init 0.00 0.10 -0.16 0.16 3002.17 2480.78 1.00 0.00 0.00
az.plot_trace(forecaster.idata, var_names=scalar_vars)
plt.show()
../_images/46799503dc668ba576c49fbc66256693a88e23422f52c13e231015ca7ea27616.png

Forecast#

NUM_FORECAST_SAMPLES = 50 if SMOKE_TEST else 1_000

forecast_idata = forecaster.forecast(
    covariates_full,
    num_samples=NUM_FORECAST_SAMPLES,
    random_seed=SEED,
    progressbar=False,
)
in_sample_idata = forecaster.predict_in_sample(
    num_samples=NUM_FORECAST_SAMPLES,
    random_seed=SEED,
    progressbar=False,
)
in_sample_idata["constant_data"] = xr.Dataset(
    {"t_value": (TIME_DIM, train_data[TIME_DIM].values)},
    coords={TIME_DIM: train_data[TIME_DIM].values},
)

in_sample_pp = in_sample_idata["posterior_predictive"]["obs"]
forecast_samples = forecast_idata["predictions"]["forecast"]
test_truth = test_data.rename({TIME_DIM: FUTURE_DIM})
forecast_lm_idata = az.from_dict(
    {
        "posterior_predictive": {"obs": forecast_samples.values},
        "observed_data": {"obs": test_truth.values},
        "constant_data": {"t_value": test_truth[FUTURE_DIM].values},
    },
    coords={TIME_DIM: test_truth[FUTURE_DIM].values},
    dims={"obs": [TIME_DIM], "t_value": [TIME_DIM]},
)

print("in-sample posterior predictive:", in_sample_pp.sizes)
print("forecast samples:", forecast_samples.sizes)
in-sample posterior predictive: Frozen({'chain': 1, 'draw': 1000, 'time': 192})
forecast samples: Frozen({'chain': 1, 'draw': 1000, 'time_future': 48})
train_crps = eval_crps(in_sample_pp, train_data)
test_crps = eval_crps(forecast_samples, test_truth)
title = f"Exponential smoothing forecast (train CRPS: {train_crps:.3f}, test CRPS: {test_crps:.3f})"

pc = az.plot_lm(
    in_sample_idata,
    y="obs",
    x="t_value",
    ci_kind="hdi",
    ci_prob=(0.5, 0.94),
    smooth=False,
    visuals={"ci_band": {"color": "C0"}, "observed_scatter": False, "pe_line": False},
    figure_kwargs={"figsize": (12, 7)},
)
az.plot_lm(
    forecast_lm_idata,
    y="obs",
    x="t_value",
    plot_collection=pc,
    ci_kind="hdi",
    ci_prob=(0.5, 0.94),
    smooth=False,
    visuals={"ci_band": {"color": "C1"}, "observed_scatter": False, "pe_line": False},
)
ax = pc.viz["figure"].item().axes[0]
ax.plot(t, y, color="black", lw=1, label="observed")
ax.axvline(test_data[TIME_DIM].values[0], color="gray", linestyle="--", label="train/test split")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.1), ncol=3)
ax.set(title=title, xlabel="time", ylabel="y")
plt.show()
../_images/17a59a9e96f598b5fd8762f5e002413fd63c66e1e5396d7322a7f13d0ae839f6.png

Evaluation#

metrics = {
    "MAE": eval_mae,
    "RMSE": eval_rmse,
    "CRPS": eval_crps,
    "coverage_90": partial(eval_coverage, alpha=0.9),
}
pd.Series(evaluate_forecast(forecast_samples, test_truth, metrics=metrics)).round(4)
MAE            0.2045
RMSE           0.2536
CRPS           0.1578
coverage_90    1.0000
dtype: float64

References#

  • Upstream NumPyro notebook: https://github.com/juanitorduz/numpyro_forecast/blob/main/docs/examples/exponential_smoothing_state_space.ipynb

  • Hyndman et al., Forecasting with Exponential Smoothing: The State Space Approach.