Forecasting Baselines, Temporal Validation, and Backtesting › Implementation in NumPy and pandas

Michael BrenndoerferOctober 30, 202611 min read
Machine Learning from ScratchAll chapters
Part 13 of 15Time Series Forecasting
Chapter 78Forecasting Baselines, Temporal Validation, and Backtesting › Implementation in NumPy and pandas
Reading section 7 of 10 · Implementation in NumPy and pandas · 11 minSection 7 of 10 · 11 min

Section 7 of 10 · 11 min read

Forecasting Baselines, Temporal Validation, and Backtesting › Implementation in NumPy and pandasSection view · Sections in this chapter7 / 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

Notes & comments

A backtest is a bookkeeping device before it is a modeling device. The implementation below makes the bookkeeping explicit: it slices history at each origin, calls each forecaster with the same interface, records one error row per origin-model-horizon, and lets pandas do the aggregation. NumPy is the right level for the forecasting primitives because each baseline is a short array operation. pandas is useful for holding the time index, grouping errors by horizon, and producing readable summaries. We deliberately avoid a high-level forecasting package in this section so that the origin handling and leakage boundaries remain visible.

The function below accepts a series, a dictionary of forecasters, an initial training size, a horizon, a seasonal period, a step between origins, and a window type. An expanding window uses all observations up to the origin. A rolling window uses the most recent initial_train observations. The function returns a long error table with one row per origin, model, and horizon. The long format is deliberate: it preserves the origin and horizon of every error, so later summaries can be audited rather than trusted as opaque aggregates.

PythonIn [21]
CollapseExpand
def seasonal_scale(y, season, train_end):
    y = np.asarray(y, dtype=float)
    base = y[:train_end]
    if len(base) <= season:
        raise ValueError(
            "The scale window must be longer than the seasonal period."
        )
    diffs = np.abs(base[season:] - base[:-season])
    scale = float(np.mean(diffs))
    if not np.isfinite(scale) or scale == 0.0:
        raise ValueError("The seasonal scale must be positive and finite.")
    return scale


def rolling_origin_backtest(
    y,
    forecasters,
    initial_train,
    horizon,
    season,
    step=1,
    window="expanding",
):
    y = np.asarray(y, dtype=float)
    if window not in {"expanding", "rolling"}:
        raise ValueError("window must be 'expanding' or 'rolling'.")
    if initial_train <= season:
        raise ValueError("initial_train must exceed the seasonal period.")
    rows = []
    origins = range(initial_train - 1, len(y) - horizon, step)
    for origin in origins:
        if window == "expanding":
            hist = y[: origin + 1]
        else:
            start = max(0, origin + 1 - initial_train)
            hist = y[start : origin + 1]
        target = y[origin + 1 : origin + 1 + horizon]
        for name, forecaster in forecasters.items():
            pred = np.asarray(forecaster(hist, horizon, season), dtype=float)
            if pred.shape != (horizon,):
                raise ValueError(
                    f"{name} returned shape {pred.shape}, expected {(horizon,)}."
                )
            for h, (actual_value, forecast_value) in enumerate(
                zip(target, pred), start=1
            ):
                error = actual_value - forecast_value
                rows.append(
                    {
                        "window": window,
                        "origin": origin,
                        "target_index": origin + h,
                        "h": h,
                        "model": name,
                        "actual": actual_value,
                        "forecast": forecast_value,
                        "error": error,
                        "abs_error": abs(error),
                        "squared_error": error**2,
                        "ape": abs(error) / abs(actual_value)
                        if actual_value != 0
                        else np.nan,
                    }
                )
    return pd.DataFrame(rows)

The forecasters dictionary adapts the three baseline functions to a common interface. The common signature is (history, horizon, season), even though the naive and drift functions do not use the seasonal period. This keeps the backtest loop uniform. Uniformity matters here because every model must receive exactly the same history and exactly the same target block; otherwise the comparison is not controlled.

PythonIn [22]
CollapseExpand
forecasters = {
    "Naive": lambda hist, h, m: naive_forecast(hist, h),
    "Seasonal naive": lambda hist, h, m: seasonal_naive_forecast(hist, h, m),
    "Drift": lambda hist, h, m: drift_forecast(hist, h),
}
initial_train = 70
H = 28
errors_expanding = rolling_origin_backtest(
    y, forecasters, initial_train, H, season, step=1, window="expanding"
)
errors_rolling = rolling_origin_backtest(
    y, forecasters, initial_train, H, season, step=1, window="rolling"
)
errors_all = pd.concat([errors_expanding, errors_rolling], ignore_index=True)
scale = seasonal_scale(y, season, initial_train)
errors_all["mase_component"] = errors_all["abs_error"] / scale

We now summarize by window and model. The summary reports MAE, RMSE, bias, MASE, and the number of error rows. The MASE scale is fixed from the initial training window, so it does not use any test target. The number of error rows alone does not establish independence among the origin-horizon records. That distinction is essential when reading the table: a large n_errors can coexist with a small number of effective independent comparisons.

The summarize_errors helper is intentionally simple. It groups the long table by window and model and computes the metrics from the error columns. Because the errors were recorded with their signs, bias and MAE come from the same rows. Because the squared errors were recorded separately, RMSE does not require reconstructing squares from rounded display values.

PythonIn [23]
CollapseExpand
def summarize_errors(errors):
    return (
        errors.groupby(["window", "model"])
        .agg(
            mae=("abs_error", "mean"),
            rmse=("squared_error", lambda s: np.sqrt(np.mean(s))),
            bias=("error", "mean"),
            mase=("mase_component", "mean"),
            n_errors=("error", "size"),
        )
        .reset_index()
        .sort_values(["window", "mae"])
    )


summary_all = summarize_errors(errors_all)
ConsoleOut [24]
CollapseExpand
   window          model   mae  rmse   bias  mase  n_errors
expanding Seasonal naive 3.233 3.882  2.081 1.381      1204
expanding          Naive 7.069 8.607  1.698 3.019      1204
expanding          Drift 7.528 9.163  0.980 3.215      1204
  rolling Seasonal naive 3.233 3.882  2.081 1.381      1204
  rolling          Naive 7.069 8.607  1.698 3.019      1204
  rolling          Drift 7.223 8.838 -0.056 3.085      1204

The two windows represent different history policies. Their score difference on this backtest may reflect sampling variation, endpoint sensitivity or changes in the process. It does not identify a structural break by itself. In this implementation, naive and seasonal naive use the same latest observations under either window; only the drift baseline changes with the older endpoint and the elapsed training span used in its slope denominator. Select a policy from training evidence and evaluate it at untouched origins.

Both policies start with the same initial training history. After the first evaluation origin, their drift slopes use different first observations and training spans: expanding windows retain the full history, while rolling windows retain only the most recent initial_train observations. The figure compares their drift error curves at the same origins and targets.

PythonIn [25]
CollapseExpand
drift_window_h = (
    errors_all[errors_all["model"] == "Drift"]
    .groupby(["window", "h"])["abs_error"]
    .mean()
    .reset_index()
    .rename(columns={"abs_error": "mae"})
)
drift_window_curve = (
    drift_window_h.pivot(index="h", columns="window", values="mae")
    .reset_index()
    .sort_values("h")
)
VisualizationOut [27]
CollapseExpand
Line chart comparing drift MAE by horizon under expanding and rolling windows.
Drift baseline MAE by horizon under expanding and rolling history windows, with origins and target blocks held fixed. The gap between curves shows the combined effect of changing the first observation and elapsed training span in the drift slope.

We can also inspect MAE by horizon for the expanding window. The pivot table makes it easy to see whether a model degrades quickly with lead time. A model with a low overall MAE but a steep horizon curve may still be unsuitable for long-horizon planning. The pivot also makes the horizon-specific comparison visible without hiding it inside a single aggregate.

PythonIn [28]
CollapseExpand
horizon_summary = (
    errors_all[errors_all["window"] == "expanding"]
    .groupby(["model", "h"])
    .agg(mae=("abs_error", "mean"), bias=("error", "mean"))
    .reset_index()
)
horizon_mae = horizon_summary.pivot(index="h", columns="model", values="mae")
ConsoleOut [29]
CollapseExpand
model  Drift  Naive  Seasonal naive
h                                  
1       5.04   5.02            2.90
2       8.48   8.39            2.96
3      10.08   9.91            2.85
4      10.31  10.11            2.84
5       8.31   8.06            2.92
6       5.02   4.89            2.95
7       2.96   2.89            2.89
8       5.22   5.10            2.73
9       8.61   8.23            2.75
10     10.46   9.89            2.67

The horizon table makes the degradation of each baseline visible without a plot. A column that grows with the horizon shows increasing average absolute error on these origins; a flat column shows similar average error across the displayed horizons. Neither pattern alone establishes that a forecasting assumption holds or fails for the underlying process.

Key Parameters

This section summarizes the key parameters documented above and the main functions used in the implementation. Below are the parameters that control the validation design and the baselines.

initial_train is the size of the first training window. It must be larger than the seasonal period so that seasonal-naive and MASE calculations are defined. In a rolling window, it also acts as the window length in this implementation. A larger initial window provides more fitting observations but can include stale regimes. On a shared origin schedule, a smaller window drops older observations no later and can increase estimation variance in fitted models; the naive and seasonal-naive rules here use only the latest observation or cycle once enough history is available. The choice is a bias-variance trade-off expressed in time, not in model capacity.

The parameter horizon is the maximum lead time. It determines which targets are evaluable and how the error table is grouped. If the business decision is a four-week order, the horizon should be 28 days, not one day. A model that is excellent one day ahead but poor 28 days ahead is not a solution to the four-week problem. The horizon should be chosen from the decision cadence, not from the desire to make the error curve look smooth.

The parameter step is the spacing between forecast origins. A step of 1 uses every possible origin; adjacent target blocks share H−1H-1 dates. A step equal to the horizon creates disjoint target blocks and generally yields fewer evaluations, with no strict reduction required when H=1H=1 or only one origin is evaluable. The step should be stated in the report because it affects the dependence between errors and the apparent sample size. A dense grid of origins is not automatically better evidence; the dependence among its errors depends on the horizon, history policy and data-generating process.

The parameter window chooses expanding or rolling history. Expanding history is appropriate when the process is stable and old data remain relevant. Rolling history is appropriate when the process changes. The choice should be made from training evidence, not from the test score. In practice, it is often worth reporting both windows as a sensitivity check, especially when a structural break is suspected.

The parameter season is the seasonal period used by seasonal naive and by the MASE scale. It should reflect the data frequency and the business calendar. Daily data with a weekly pattern usually use m=7m=7. Monthly data with an annual pattern usually use m=12m=12. If the seasonality is not stable, a fixed mm can be misleading. The seasonal period is a modeling assumption, and it should be defended in the same way as the choice of model.

The parameter scale is the denominator used for MASE. In this implementation it is computed once from the initial training window. A per-origin scale is also possible, but it should be documented. A zero or near-zero scale makes MASE unstable, just as zero targets make MAPE unstable. The scale should be treated as part of the reporting contract, not as an invisible implementation detail.

Key Methods

naive_forecast returns a constant array equal to the last observation. It is a simple last-value baseline and the standard random-walk benchmark. Comparing this baseline at matched origins and horizons tests the predictive value of the most recent level under the chosen loss. It is also the first place to look when a complex model appears suspiciously accurate, because an unavailable feature can produce an invalid comparison, even though a large legitimate improvement is also possible.

seasonal_naive_forecast tiles the last seasonal cycle until the requested horizon is filled. It is the standard seasonal random-walk benchmark. Comparing this baseline at matched origins and horizons tests the predictive value of repeating the latest seasonal pattern under the chosen loss. The function uses only the last season observations, so within that forecast block it cannot smooth their noise or extrapolate a new level; that is both its simplicity and its limitation.

drift_forecast estimates a slope from the first and last observations and adds that slope at each future step. It is the standard random-walk-with-drift benchmark. Because it uses endpoints, it is sensitive to outliers at the boundaries; a least-squares trend uses the interior observations and may be less sensitive to a noisy endpoint when a linear trend model is appropriate. The endpoint sensitivity is not a bug in the implementation; it is the defining assumption of the canonical drift baseline.

rolling_origin_backtest is the validation engine. It advances the origin, slices the available history, calls each forecaster, and records the realized error at every horizon. The slicing is the most important line in the function: it must never include the target block in the history. The function raises an error if a forecaster returns the wrong number of horizons, which prevents silent shape mismatches. The shape check catches an invalid forecaster return before its values enter the error table.

seasonal_scale computes the in-sample seasonal-naive scale for MASE. It uses only the training window passed to it. Keeping the scale function separate makes the leakage boundary explicit: the scale is estimated from training history, then used to normalize out-of-sample errors. summarize_errors groups by window and model; the separate horizon table groups by model and horizon. The grouping is where we turn forecast errors into a report that a decision-maker can interpret. Because the long table retains origins and horizons, the summary can always be traced back to the individual forecasts that produced it.

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!

Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy