Forecasting Baselines, Temporal Validation, and Backtesting › Visualizing Forecasting Baselines and Backtests
Machine Learning from ScratchAll chapters Forecasting Baselines, Temporal Validation, and Backtesting › Visualizing Forecasting Baselines and BacktestsSection view · Sections in this chapter5 / 10 ↓
Reading tools Definitions, links & notes
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
We generate a daily synthetic demand series with a slow trend, a weekly seasonal pattern, and noise. The baselines expose different parts of that structure: naive retains the latest level, seasonal naive repeats the last cycle, and drift extrapolates an endpoint slope. None is required to win.
We choose a forecast origin and reveal the next 35 days only for display and scoring. The forecasting functions receive history ending at that origin.
PythonIn [3]Expand
n = 140
season = 7
t = np.arange(1, n + 1)
trend = 60.0 + 0.12 * t
weekly = 7.0 * np.sin(2 * np.pi * t / season) + 3.0 * np.cos(
2 * np.pi * t / season
)
noise = rng.normal(0.0, 2.2, n)
y = trend + weekly + noise
dates = pd.date_range("2023-01-02", periods=n, freq="D")
demand = pd.Series(y, index=dates, name="demand")
def naive_forecast(history, horizon):
return np.full(horizon, history[-1], dtype=float)
def seasonal_naive_forecast(history, horizon, season):
if len(history) < season:
raise ValueError("History must contain at least one full season.")
reps = int(np.ceil(horizon / season))
pattern = np.asarray(history[-season:], dtype=float)
return np.tile(pattern, reps)[:horizon]
def drift_forecast(history, horizon):
history = np.asarray(history, dtype=float)
if len(history) < 2:
raise ValueError("Drift requires at least two observations.")
drift = (history[-1] - history[0]) / (len(history) - 1)
steps = np.arange(1, horizon + 1)
return history[-1] + drift * steps
origin_idx = 98
history = demand.iloc[: origin_idx + 1].to_numpy()
horizon = 35
origin_date = demand.index[origin_idx]
future_dates = pd.date_range(
origin_date + pd.Timedelta(days=1),
periods=horizon,
freq="D",
)
actual = demand.iloc[origin_idx + 1 : origin_idx + 1 + horizon].to_numpy()
forecasts = {
"Naive": naive_forecast(history, horizon),
"Seasonal naive": seasonal_naive_forecast(history, horizon, season),
"Drift": drift_forecast(history, horizon),
}The figure below shows the recent history, the forecast origin, the three baseline paths, and the future actual values after the fact. The vertical line is the information boundary. The observed demand values to the left are available at the origin; actual demand values to the right are withheld until scoring. Future dates and the forecast paths issued at the origin are already known.
PythonIn [4]Expand
context = demand.iloc[max(0, origin_idx - 55) : origin_idx + 1]VisualizationOut [6]Expand

The naive path is flat because it repeats the last observation. The seasonal-naive path repeats the last seven observations, so it preserves the weekly shape but not the slow trend. The drift path is a straight line. The future actual series combines trend, weekly shape, and noise, so no baseline matches it exactly. This is the point: the figure shows the assumptions of each method rather than pretending one method is universally correct.
A single origin lets us compare realized losses on one target block, but gives limited evidence across forecasting occasions. We now evaluate at many rolling origins with an expanding window. At each origin, we forecast 28 days ahead and record the absolute error at every horizon. The code uses only the history available at that origin. The future actual values are used only for scoring.
PythonIn [7]Expand
def quick_backtest(series, initial_train, horizon, season, step=1):
series = np.asarray(series, dtype=float)
rows = []
for origin in range(initial_train - 1, len(series) - horizon, step):
hist = series[: origin + 1]
target = series[origin + 1 : origin + 1 + horizon]
predictions = {
"Naive": naive_forecast(hist, horizon),
"Seasonal naive": seasonal_naive_forecast(hist, horizon, season),
"Drift": drift_forecast(hist, horizon),
}
for model, pred in predictions.items():
for h, (actual_value, forecast_value) in enumerate(
zip(target, pred), start=1
):
error = actual_value - forecast_value
rows.append(
{
"origin": origin,
"target_index": origin + h,
"model": model,
"h": h,
"error": error,
"abs_error": abs(error),
}
)
return pd.DataFrame(rows)
bt_quick = quick_backtest(
y, initial_train=70, horizon=28, season=season, step=1
)
bt_quick["squared_error"] = bt_quick["error"] ** 2
mae_by_h = (
bt_quick.groupby(["model", "h"])["abs_error"]
.mean()
.reset_index()
.rename(columns={"abs_error": "mae"})
)
bt_overall = (
bt_quick.groupby("model")
.agg(
mae=("abs_error", "mean"),
rmse=("squared_error", lambda s: np.sqrt(np.mean(s))),
bias=("error", "mean"),
n_errors=("error", "size"),
)
.reset_index()
.sort_values("mae")
)ConsoleOut [8]Expand
model mae rmse bias n_errors
Seasonal naive 3.233 3.882 2.081 1204
Naive 7.069 8.607 1.698 1204
Drift 7.528 9.163 0.980 1204The overall table aggregates the same error rows across all horizons, so it should be read as a compact summary rather than as a horizon-specific statement. MAE and RMSE are in demand units, so their magnitudes depend on the scale of the synthetic series, while bias has the same units and carries a direction.
Across the rolling origins, the baseline with the lowest MAE is a useful reference point rather than a final verdict. RMSE is reported on the same rows, so it reflects the same forecasts; a larger RMSE than MAE indicates variation in absolute error magnitudes, whereas equality requires equal absolute errors. On the same rows, RMSE squared equals MAE squared plus the population variance of the absolute errors; the gap alone does not identify a few outliers. The bias column shows whether the baseline tends to over- or under-forecast across all horizons, and the error count alone does not establish independence among those origin-horizon records. The comparison is most meaningful per horizon, which the next figure makes visible.
The next figure compares MAE by horizon. The horizontal axis is the lead time in days. The vertical axis is in demand units. A curve that rises quickly indicates that the forecast loses accuracy as the horizon grows. Low points near multiples of seven show a weekly phase pattern in these errors. Even a flat naive forecast can have such low points when its latest observed phase recurs; this feature alone does not show that a method has modeled seasonality. We should read this figure together with the number of origins and the scale of the series; a small MAE is not meaningful without that context.
VisualizationOut [10]Expand

The backtest curve is more informative than a single number because it shows where a model fails. A model that is excellent at (h=1) and poor at (h=28) may still be useful for next-day replenishment and useless for monthly planning. A model that is mediocre at every horizon may be easier to maintain. The baseline comparison gives us an accuracy reference. Whether an improvement justifies a more complex method also depends on its operational costs and risks.
Bias is a separate diagnostic from MAE. The next figure uses the same rolling-origin error records and shows whether each baseline tends to over- or under-forecast as the horizon grows.
PythonIn [11]Expand
bias_by_h = (
bt_quick.groupby(["model", "h"])["error"]
.mean()
.reset_index()
.rename(columns={"error": "bias"})
)
bias_curve = (
bias_by_h.pivot(index="h", columns="model", values="bias")
.reset_index()
.sort_values("h")
)VisualizationOut [13]Expand

Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!