Skip to content

Forecasting (sales)

Forecast a multi-series sales panel with Forecaster. It profiles each series, races a curated set of models under rolling-origin CV, gates every winner against an honest baseline, and returns point forecasts with calibrated intervals.

Install extras

pip install "parleml-ml4[forecasting]" covers the stats backend. The tree (RecursiveTabular), foundation (Chronos) and autogluon backends run on AutoGluon-TimeSeries — install "parleml-ml4[forecasting,forecasting-autogluon]" on Python 3.11–3.13. For this repository's shared forecast dependency overrides, use uv sync from the root; see Installation.

1. A long-format panel

The frame is Nixtla-style: unique_id, ds, y, one row per series per date.

import numpy as np
import pandas as pd

dates = pd.date_range("2024-01-01", periods=400, freq="D")
rng = np.random.default_rng(0)
train_df = pd.concat([
    pd.DataFrame({"unique_id": "store_A", "ds": dates,
                  "y": 100 + np.arange(400) * 0.2 + rng.normal(0, 5, 400)}),
    pd.DataFrame({"unique_id": "store_B", "ds": dates,
                  "y": 50 + 10 * np.sin(np.arange(400) / 7) + rng.normal(0, 2, 400)}),
], ignore_index=True)

2. Fit and predict

from parleml_ml4.forecasting import Forecaster
from parleml_ml4.config.forecasting import ForecastConfig

cfg = ForecastConfig(horizon=28, freq="D")     # stats backend + auto mode by default
model = Forecaster(cfg, outputs_dir="runs/sales").fit(train_df)

future = model.predict(h=28, level=(80, 95))   # long frame with interval columns
print(future.head())
# columns: unique_id, ds, <model>, <model>-lo-80, <model>-hi-80, <model>-lo-95, <model>-hi-95

In auto mode each series is profiled and scored independently under rolling-origin CV, so each series gets its own winning model.

3. Read the per-series winners and honesty gate

result = model.result_
print(result.winning_model_per_series)    # {unique_id: model}
print(result.winning_backend_per_series)  # {unique_id: backend}
print(result.cv_scores)                    # per-candidate CV selection scores (mase, smape, ...)
print(result.topline_test())               # the honest held-out test score per metric
print(result.weak_series)                  # winners you should not trust (see below)
print(result.failed_stages)                # stages that genuinely failed (usually {})
print(result.notes)                        # informational breadcrumbs (skipped series, …)

A SeasonalNaive win is a signal

Every series' CV winner is checked against a naive seasonal baseline. If the baseline beats it (CV MASE > 1.0, or SeasonalNaive won the race), the series is listed on result.weak_series as a warning. It is not silently swapped or failed. Inspect result.weak_series and consider a different backend, horizon or aggregation grain.

The strongest reason is mostly_imputed: more than config.drop_gap_pct_threshold of the series' rows were fabricated when it was put on the panel grid (typically a coarse-frequency series inside a finer panel). Its scores, including that MASE, are meaningless; a fabricated flat plateau tends to score a perfect MASE. Fix the input, not the flag. The forecasting guide lists every reason.

4. When the timestamps are events, not observations

Plenty of real tables are one row per thing that happened — a signup, an order, a sensor ping — at whatever moment it happened. There is no cadence to infer, so there is nothing to forecast until you choose a grid:

import numpy as np
import pandas as pd
from parleml_ml4.config.forecasting import ForecastConfig
from parleml_ml4.forecasting import Forecaster

rng = np.random.default_rng(0)
signups = pd.DataFrame({
    "unique_id": "web",
    # 4,000 signups at irregular instants over two years — no two alike.
    "ds": pd.Timestamp("2023-01-01") + pd.to_timedelta(
        np.sort(rng.uniform(0, 730, 4_000)), unit="D",
    ),
    "y": 1.0,                                  # one row = one signup
})

# Roll up to a weekly signup COUNT, which is a series you can forecast.
cfg = ForecastConfig(horizon=8, resample_to="W", resample_agg="sum")
weekly = Forecaster(cfg).fit(signups).predict(h=8)

resample_agg="sum" because signups are a flow — they accumulate over the week. For a level (an inventory balance, a price, a temperature) use "mean": summing it would rescale the target without changing its shape, which no scale-free metric will catch.

Not sure whether a table is forecastable at all? Run the Signal Check first — it always answers, scoring an irregular panel on observation order and telling you so.

5. Helpers and serving

result.forecast                     # long format: unique_id / ds / <alias> [+ bands]
result.winner_counts()              # {"AutoETS": 12, "SeasonalNaive": 3}
result.topline_test()               # the honest held-out score per metric

With outputs_dir set, fit wrote events.ndjson / manifest.json and a deployable FittedForecastPipeline.

See the Forecasting guide for the four backends, hierarchical reconciliation and exogenous regressors, and the Forecaster reference.

Next: Forecasting guide · Deployment & export