ergodicDocs
Products: switch from ergodic to another product
Get started

GuidesBacktest a forecast

Backtest a forecast

Score a forecaster on months it has not seen, next to simple baselines, before you trust its numbers or its bands.

You'll need

  • ergodic with the forecasting extra: pip install "ergodic[forecast]". It adds PyMC only. Optionally, pip install jax numpyro switches sampling to the faster NumPyro backend, which recorded the output on this page. The baselines alone need no extra.
  • A series long enough to hold out a few horizons: by default, each fold trains on at least twice the horizon.

Run the program#

The data is 60 simulated months of sales with a steady trend and a yearly season. The backtest cuts the history at two points, refits each model on everything before the cut, and scores the six months after it.

backtest_sales.pyDownload
import numpy as np
import pandas as pd
from ergodic import backtest, timeseries
from ergodic.forecast import Drift, Naive, SeasonalNaive, forecast_model

# 1. Simulate 60 months of sales with a trend and a yearly season.
rng = np.random.default_rng(0)
T = 60
month = pd.date_range("2021-01-31", periods=T, freq="ME")
season = 10 * np.sin(2 * np.pi * np.arange(T) / 12)
sales = 100 + 1.5 * np.arange(T) + season + rng.normal(0, 3, T)
data = timeseries(pd.DataFrame({"month": month, "sales": sales}), time="month", freq="ME")

# 2. Line up three baselines and the Bayesian forecaster.
models = [
    Naive(),
    SeasonalNaive(period=12),
    Drift(),
    forecast_model(name="Bayesian", draws=500, warmup=500),
]

# 3. Refit each model at two origins and score the next six months.
reports = [backtest(model, data, test_size=6, n_splits=2, m=12) for model in models]
print(reports[-1])

# 4. Compare the mean scores across folds.
columns = ["mae", "rmse", "mase", "crps", "coverage"]
table = pd.DataFrame({r.model: r.summary().loc["sales", columns] for r in reports}).T
print(table.round(2).to_string())

# 5. Check how the sampler behaved in each fold.
diagnostics = reports[-1].diagnostics()
print(diagnostics[["fold", "cutoff", "divergences", "min_ess"]].round(1).to_string(index=False))
Recorded output
BacktestReport(Bayesian, expanding: 2 folds at [48, 54], horizon=6, 1 nodes)
                 mae   rmse  mase   crps  coverage
Naive          10.53  10.87  0.57   6.79      0.92
SeasonalNaive  16.55  17.21  0.89  14.31      0.00
Drift          10.73  10.96  0.58   7.09      0.67
Bayesian        5.30   6.00  0.29   3.46      1.00
 fold  cutoff  divergences  min_ess
    0      48            1     25.8
    1      54            0     26.8

Read the scores#

Lower is better for every error measure here:

The report holds 14 metrics in all, including MAPE, sMAPE and rolling accuracy over 3- and 6-month windows. report.metrics lists them.

Read the report#

backtest returns a BacktestReport:

Two folds is a small test

Twelve held-out months can't tell a 90% band from a 95% one. Use more folds when the history allows, and treat a coverage far from the nominal level as a reason to widen your doubt, not to tune the level.

Choose the folds#

Each fold refits from scratch on a copy of the model, so no fold sees the future, and the model you pass in is never changed.

Which models can I backtest?

Anything with fit(data, *, seed=0), returning a fitted copy, and forecast(horizon, *, seed=0), returning a ForecastResult. The baselines and forecast_model(...) both qualify, and so can your own model.

forecast_model(name=..., **options) takes every keyword of forecast except data and horizon. A keyword forecast doesn't take raises at once, not at the first fold. name labels the model in reports, so two configurations can be told apart.

A series in the forecast that isn't a column of the data can't be scored. The report lists it in nodes_unscored.

Next steps#

Documentation

Search ergodic 0.2.1

Search ergodic by task, function or error.