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