Forecasting Baselines, Temporal Validation, and Backtesting

Michael BrenndoerferOctober 30, 202651 min read

Build naive, seasonal-naive, and drift baselines, then validate forecasts with rolling-origin backtesting, horizon-specific errors, and honest reporting.

Machine Learning from ScratchAll chapters
Part 13 of 15Time Series Forecasting
Chapter 78Forecasting Baselines, Temporal Validation, and Backtesting
Reading the full chapter · 10 sections · 51 min10 sections · 51 min

All 10 sections · 51 min read

Forecasting Baselines, Temporal Validation, and BacktestingFull chapter · Sections in this chapter1 / 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

Forecasting Baselines, Temporal Validation, and Backtesting

A demand planner faces a concrete problem: every Monday morning, using sales that are final only through Sunday night, they must forecast the next four weeks. The forecast is not a claim about a randomly selected future row. It is a claim about specific future dates, made from a specific information set, and it will be scored at several horizons.

Forecasting baselines are the simplest forecasts that respect time. The naive forecast repeats the last observed value. The seasonal-naive forecast repeats the last observed seasonal cycle. The canonical drift forecast continues the line through the first and last observations; fitting a separate trend is a different stated model. These methods are deliberately simple. Their job is to establish what a forecast must beat before we add exponential smoothing, ARIMA, gradient boosting, or neural networks. They also expose whether our validation is honest: a large improvement merits an information-availability audit, but it is not itself evidence of leakage.

This chapter treats forecasting as a sequence of forecast origins. At origin (o), we know the past up to (o) and must produce forecasts for (o+1,\ldots,o+H). The targets are future values (y_{o+1},\ldots,y_{o+H}), and none of them may enter the features, scaling, imputation, or model selection for that origin. We then move the origin forward and repeat the exercise. This is backtesting. It is the time-series analogue of cross-validation, but with an explicit arrow of time and an explicit forecast horizon.

We should keep this problem distinct from time-to-event analysis. In survival analysis, the outcome is a time to an event. Right-censoring records that the event has not occurred by the last observed follow-up time. In forecasting, the outcome is a value at a future date, such as demand next Tuesday. A missing future demand value is not a censored survival time; it is simply unavailable because the future has not happened. We can borrow careful thinking about information availability, but we should not blur the two targets.

Advantages

These three baseline formulas are transparent and avoid iterative model fitting. The seasonal period and history policy still require choices made from training evidence. Their accuracy and cost depend on the series and implementation. If a seasonal-naive forecast is already accurate enough for inventory planning, a complex model must justify its additional maintenance cost, monitoring burden, and failure modes. Baselines therefore provide a minimum acceptable standard rather than a decorative comparison.

They also provide a reference that can prompt a leakage audit. Suppose a gradient-boosted model reports an implausibly low error on next-day demand. Comparing it with a naive forecast at the same origins can prompt an information-availability audit. A rolling average of realized responses that includes an unavailable target-day outcome, a promotion flag unavailable at the forecast origin, or a scaler fitted using values released after that origin violates that origin's information boundary. These violations can distort the comparison even when they produce no accuracy gain. The gap can prompt an audit; a large legitimate improvement is possible, and the gap alone does not establish leakage. Baselines also help us audit the information available to each forecast.

Finally, baselines make horizon-specific reporting natural. A naive forecast may be reasonable one day ahead and poor four weeks ahead. Seasonal naive can be strong at multiples of the seasonal period and weaker between them. Drift can capture a persistent trend but can also extrapolate a temporary run of high values. Reporting error by horizon, with the number of origins and the forecast window stated explicitly, turns a single aggregate score into an interpretable forecast audit.

Disadvantages

The main disadvantage is structural. A naive forecast assumes that the future conditional mean is close to the last observation. A seasonal-naive forecast assumes that the seasonal pattern repeats exactly. A drift forecast assumes that a linear trend estimated from two endpoints continues unchanged. These assumptions can fail under promotions, price changes, supply constraints, holidays, regime changes, and product life cycles. The three rules as defined here do not use covariates such as price, weather, or marketing spend. Covariate-based simple benchmarks are possible, but they are different forecasting rules.

Baselines also provide no built-in uncertainty model. We can construct empirical intervals from backtest errors, but those intervals inherit the dependence structure of the validation design. If we evaluate daily origins with a 28-day horizon, consecutive forecast errors overlap heavily. The number of error rows can look large while the number of independent forecast experiments is much smaller. Treating correlated errors as independent can misstate uncertainty. The direction and size depend on their covariance structure.

The validation itself has costs and pitfalls. Rolling-origin backtesting repeats forecast production and scoring. Model refitting follows the deployment policy and can add substantial cost. Expanding windows reuse all history, which may be inappropriate after a structural break. Rolling windows discard old data, which reduces sample size and can increase variance. A fixed seasonal period may be wrong after calendar changes. Percentage-error measures can explode near zero, and a single zero-demand day can make MAPE undefined. These are not arguments against baselines; they are reasons to report the validation design and the error scale explicitly.

Formula

Forecasting notation earns its keep by keeping two times apart: the time when the forecast is made and the time it is meant to describe. If we blur those times, the model can accidentally learn from the very future it is supposed to predict. The formulas below build a precise contract around that separation. We first define the forecast origin and horizon, then the information set, then the three baseline rules, then the backtest that turns a single forecast into repeated evidence, and finally the error measures that summarize that evidence.

Let the observed series be (y_1,\ldots,y_T), where (T) is the last time index with a realized target. We use one-based time subscripts in the equations. In code, Python indices are zero-based, so the observation at time (t) is stored at position (t-1). A forecast made at origin (o) for target time (o+h) is written

y^o+h∣o.\hat y_{o+h\mid o}.

A one-step-ahead forecast has h=1h=1, a four-week-ahead forecast has h=28h=28 if the data are daily and the horizon is measured in days, and a forecast for the same weekday next week has h=7h=7. The notation o+h∣oo+h\mid o is deliberate: it keeps the target time and the information time separate. Every later formula inherits that separation.

Forecast origins, horizons, and information availability

The forecast origin is the last time point whose target value is allowed to influence the forecast. It is a wall, not a suggestion. If we stand at the end of day oo, the information set for a univariate baseline is

Io={y1,y2,…,yo}.\mathcal I_o=\{y_1,y_2,\ldots,y_o\}.

This set contains the past and nothing else. It does not contain (y_{o+1}), even if that value happens to be sitting in the same data frame. More generally, if we have covariates (x_s), the information set is

Io={y1,…,yo}∪{xs:xs is known by time o}.\mathcal I_o=\{y_1,\ldots,y_o\}\cup\{x_s:\text{\(x_s\) is known by time \(o\)}\}.

A forecast rule is a function

y^o+h∣o=fo,h(Io),\hat y_{o+h\mid o}=f_{o,h}(\mathcal I_o),

which maps the available time-stamped records into a real-valued forecast. The braces in Io\mathcal I_o are shorthand for those records, retaining their times and repeated values. For a baseline, the function is simple. For a machine-learning model, the function may be a fitted regression or tree ensemble, but the domain is still the information set. The target (y_{o+h}) is not in (\mathcal I_o). This is the formal version of an everyday rule: we cannot use next week's sales to predict next week's sales.

The distinction is the time when the feature value became available. A value observed or released only after origin (o) cannot be used there, even if a retrospective table now contains it. For example, a feature “sales the day before the target” can be available for a one-day-ahead forecast made today once today’s sales have been released. For a seven-day-ahead forecast made at the same origin, it would require an unavailable future realization. A feature “sales on the target day” is never available at the origin. Calendar features such as day of week, month, and planned holidays can be known in advance because they are deterministic or pre-scheduled. Planned promotions can also be known in advance, while their future realized effects are unavailable until observed.

It helps to sort features by when their values become known:

  • Known before the origin: calendar structure, scheduled holidays, contractual prices, and approved promotion plans.
  • Observed by the origin: the latest observed sales, inventory, and traffic.
  • Known only after the origin: future realized sales and footfall, weather revisions released after the origin, and aggregates of realized responses that include an unavailable target-day outcome.

Only the first two groups belong in (\mathcal I_o). Values in the third group stay outside that forecast's inputs; after release, they may be used for scoring or retrospective evaluation.

For a direct (h)-step model, a training row is often written as

(xs,h,ys+h),(x_{s,h}, y_{s+h}),

where (x_{s,h}) must be constructed only from information available at origin (s). At fitting origin (o), the target (y_{s+h}) must also have been released already; with no reporting delay this requires (s+h\le o). Revised or delayed data require the historical vintage actually available then. The subscript (h) reminds us that the feature set can differ by horizon. For a recursive model, we forecast one step, append the forecast to the history, and forecast again. Recursive forecasts can propagate errors because later forecasts use earlier predictions as inputs. The closed-form baseline implementations here produce every horizon from the same observed history without feeding predictions back into the functions.

A forecast is evaluable only if the target has been observed. For a fixed maximum horizon (H), an origin (o) is valid when

o+H≤T.o+H\le T.

The target block is then (y_{o+1},\ldots,y_{o+H}). This inequality is the basic guard against accidentally scoring a forecast whose target has not happened. It also explains why backtests shorten as (H) grows: the last usable origins must leave room for the entire target block.

Naive, seasonal-naive, and drift baselines

The three baselines encode different assumptions about how the future relates to the past. Naive and seasonal naive are conditional means under the corresponding zero-mean innovation random walks. The endpoint drift rule plugs an estimated drift into a random-walk-with-drift forecast. These interpretations explain the mechanisms without promising accuracy for an arbitrary series.

The naive forecast uses the last observed value for every horizon:

y^T+h∣Tnaive=yT,h=1,…,H.\hat y^{\text{naive}}_{T+h\mid T}=y_T,\qquad h=1,\ldots,H.

Here TT is the forecast origin, yTy_T is its latest observed value, and hh is the lead time. Under a random walk

yt=yt−1+εt,E[εt∣It−1]=0,y_t=y_{t-1}+\varepsilon_t,\qquad \mathbb E[\varepsilon_t\mid\mathcal I_{t-1}]=0,

with integrable innovations, iterated conditional expectation gives E[YT+h∣IT]=yT\mathbb E[Y_{T+h}\mid\mathcal I_T]=y_T. A trend or changing seasonal pattern can make this model unsuitable; the baseline remains a useful matched-horizon comparison.

The seasonal-naive forecast repeats the last observed seasonal cycle. Let mm be the seasonal period: m=7m=7 for daily data with a weekly pattern, m=12m=12 for monthly data with an annual pattern, and m=4m=4 for quarterly data.

The floor term selects the correct cycle offset. Define

k=⌊h−1m⌋,k=\left\lfloor\frac{h-1}{m}\right\rfloor,

where (\lfloor\cdot\rfloor) is the floor function, which rounds down to the nearest integer. The seasonal-naive forecast is

y^T+h∣Tsnaive=yT+h−m(k+1).\hat y^{\text{snaive}}_{T+h\mid T}=y_{T+h-m(k+1)}.

The index T+h−m(k+1)T+h-m(k+1) is the most recent observation that has the same position in the seasonal cycle as the target. For h=1h=1, k=0k=0, so the forecast is yT+1−my_{T+1-m}, the same season one cycle earlier. For h=mh=m, k=0k=0, so the forecast is yTy_T, the last observed value, which is the same season as the target. For h=m+1h=m+1, k=1k=1, so the forecast is again yT+1−my_{T+1-m}. This is the most recent observed value at the target's seasonal position, now two cycles before the target.

where:

  • y^T+h∣Tsnaive\hat y^{\text{snaive}}_{T+h\mid T}: the seasonal-naive forecast for target time T+hT+h
  • mm: the seasonal period (e.g., 7 for a weekly cycle in daily data)
  • hh: the lead time in periods
  • k=⌊(h−1)/m⌋k=\lfloor (h-1)/m \rfloor: the index of the block of mm future horizons containing hh, starting at block zero

With integrable innovations satisfying E[εt∣It−1]=0\mathbb E[\varepsilon_t\mid\mathcal I_{t-1}]=0, the method is the conditional mean forecast under a seasonal random walk

yt=yt−m+εt.y_t=y_{t-m}+\varepsilon_t.

Its weakness is rigidity. Within a forecast block it repeats the last observed seasonal cycle exactly, without smoothing its noise or extrapolating a new level or trend. If the seasonal pattern is strong and stable, that rigidity is a strength. If a promotion or a new competitor changes the pattern, the same rigidity becomes a liability.

The drift baseline extends a straight line from the first observation to the last observation. It requires at least two observations, T≥2T\ge2. The estimated drift per period is

c^=yT−y1T−1.\hat c=\frac{y_T-y_1}{T-1}.

where:

  • c^\hat c: the estimated drift per period
  • yTy_T: the last observation in the history
  • y1y_1: the first observation in the history
  • TT: the number of observations (so T−1T-1 is the number of intervals between them)

The (h)-step forecast is

y^T+h∣Tdrift=yT+hc^.\hat y^{\text{drift}}_{T+h\mid T}=y_T+h\hat c.

For h=1h=1, we add one drift increment to the last observation. For h=2h=2, we add two increments, and so on.

where:

  • y^T+h∣Tdrift\hat y^{\text{drift}}_{T+h\mid T}: the drift forecast for target time T+hT+h
  • yTy_T: the last observed value at the origin
  • c^\hat c: the estimated drift per period
  • hh: the lead time in periods

This plugs an estimated drift into the conditional-mean formula for a random walk with drift,

yt=yt−1+c+εt,y_t=y_{t-1}+c+\varepsilon_t,

where:

  • yty_t: the observed value at time tt
  • yt−1y_{t-1}: the previous period's observation
  • cc: the constant drift per period
  • εt\varepsilon_t: the innovation at time tt, with conditional mean zero

The estimate c^=(yT−y1)/(T−1)\hat c=(y_T-y_1)/(T-1) is simple but endpoint-sensitive. A single unusual first or last observation can change the slope substantially. An alternative is to estimate a slope by ordinary least squares on the training history, which uses interior observations and can reduce sensitivity to a noisy endpoint under an appropriate trend model, but then the method is closer to a linear trend model than to the canonical drift baseline. Either version should be stated explicitly when reporting results.

It is useful to remember the assumptions as a compact contrast:

  • Naive: the future level is the last level.
  • Seasonal naive: the future calendar position repeats the most recent occurrence of that position.
  • Drift: the future continues a straight line whose slope is fixed by the observed endpoints.

Each baseline is a hypothesis about the data-generating process. Backtesting asks whether that hypothesis is good enough for the decision at hand.

Rolling-origin and expanding-window backtests

A fixed-origin holdout forecast gives one forecast origin and one target block. That is often too little evidence to judge a model, and it is easy for one origin to flatter or punish a method by accident. Rolling-origin backtesting repeats the forecast exercise at a sequence of origins

o1<o2<⋯<oK,o_1<o_2<\cdots<o_K,

with (o_k+H\le T) for every origin. At origin (o_k), the model sees information through (o_k), forecasts (H) future values, and is scored on (y_{o_k+1},\ldots,y_{o_k+H}). Then the origin advances and the process repeats. The backtest is a sequence of small, honest forecasting problems, not one large hindsight problem.

In an expanding-window backtest, the training history grows:

Wokexp={y1,…,yok}.\mathcal W^{\text{exp}}_{o_k}=\{y_1,\ldots,y_{o_k}\}.

This is appropriate when older data remain relevant and when we want to mimic a system that accumulates history over time. The first origin has the smallest training set; later origins have more data. More history can improve estimates under a stable seasonal model, but expanding windows can also mix incompatible regimes.

In a rolling-window backtest, only the most recent (w) observations are used:

Wok,wroll={yok−w+1,…,yok}.\mathcal W^{\text{roll}}_{o_k,w}=\{y_{o_k-w+1},\ldots,y_{o_k}\}.

This is appropriate when the data-generating process changes and old observations would bias the forecast. The window length (w) is a hyperparameter, and it must be chosen without looking at the test targets. A rolling window throws away information, so it can increase variance, but it can reduce bias after a structural break.

The spacing between origins is also a choice. Using every possible origin maximizes the number of evaluations. Consecutive target blocks share exactly H−1H-1 dates, so there is no target overlap when H=1H=1. Spacing origins by HH periods makes the target blocks disjoint and generally reduces the number of evaluations; the count can be unchanged when H=1H=1 or only one origin is evaluable. The number of forecast errors alone does not establish independence among them. If we evaluate 100 daily origins with a 28-day horizon, the target blocks overlap, and the errors can be dependent. We should report the number of origins, the horizon, the window type, and the step, not only the number of error rows.

Forecast error measures, uncertainty, and reporting

Once the forecasts are frozen and the targets are revealed, we need a way to turn many forecast errors into evidence. The raw object is the forecast error. For an origin (o) and horizon (h), the forecast error is

eo,h=yo+h−y^o+h∣o.e_{o,h}=y_{o+h}-\hat y_{o+h\mid o}.

The sign convention matters: a positive error means the actual value was above the forecast. If we reverse the sign, bias has the opposite interpretation.

where:

  • eo,he_{o,h}: the forecast error at origin oo and horizon hh
  • yo+hy_{o+h}: the realized target value at time o+ho+h
  • y^o+h∣o\hat y_{o+h\mid o}: the forecast made at origin oo for target time o+ho+h

The sign convention gives a positive error when the actual value exceeds the forecast. The absolute error is ∣eo,h∣|e_{o,h}|, and the squared error is eo,h2e_{o,h}^2. Every metric below is a different way of summarizing these elementary errors.

Let (\mathcal O_h) be the set of origins for which horizon (h) is evaluable. The mean absolute error at horizon hh is

MAE⁡h=1∣Oh∣∑o∈Oh∣eo,h∣.\operatorname{MAE}_h = \frac{1}{|\mathcal O_h|} \sum_{o\in\mathcal O_h}|e_{o,h}|.

where:

  • Oh\mathcal O_h: the set of origins for which horizon hh is evaluable
  • ∣Oh∣|\mathcal O_h|: the number of such origins
  • eo,he_{o,h}: the forecast error at origin oo and horizon hh

The root mean squared error is

RMSE⁡h=1∣Oh∣∑o∈Oheo,h2.\operatorname{RMSE}_h = \sqrt{ \frac{1}{|\mathcal O_h|} \sum_{o\in\mathcal O_h}e_{o,h}^2 }.

where:

  • Oh\mathcal O_h: the set of origins evaluable at horizon hh
  • eo,he_{o,h}: the forecast error at origin oo and horizon hh

MAE is in the original units and is easy to explain. RMSE penalizes large errors more heavily because the errors are squared before averaging. Neither is universally better; they answer different loss questions. The mean error, or bias, is

Bias⁡h=1∣Oh∣∑o∈Oheo,h.\operatorname{Bias}_h = \frac{1}{|\mathcal O_h|} \sum_{o\in\mathcal O_h}e_{o,h}.

where:

  • Oh\mathcal O_h: the set of origins evaluable at horizon hh
  • eo,he_{o,h}: the signed forecast error at origin oo and horizon hh
  • Bias⁡h\operatorname{Bias}_h: positive values indicate under-forecasting, negative values over-forecasting

A model can have low MAE and still be systematically biased. Reporting bias alongside MAE and RMSE prevents that mistake.

Percentage errors are tempting because they are scale-free, but they have a zero problem. The mean absolute percentage error is often written as

MAPE⁡h=100∣Oh∣∑o∈Oh∣eo,hyo+h∣.\operatorname{MAPE}_h = \frac{100}{|\mathcal O_h|} \sum_{o\in\mathcal O_h} \left|\frac{e_{o,h}}{y_{o+h}}\right|.

where:

  • eo,he_{o,h}: the forecast error at origin oo and horizon hh
  • yo+hy_{o+h}: the realized target value at time o+ho+h
  • Oh\mathcal O_h: the set of origins evaluable at horizon hh

If (y_{o+h}=0), the ratio is undefined. If (y_{o+h}) is near zero, the percentage error can explode even when the absolute error is small. We should not silently drop those rows without reporting the exclusion, because dropping zeros changes the target population. A weighted absolute percentage error,

WAPE⁡h=∑o∈Oh∣eo,h∣∑o∈Oh∣yo+h∣,\operatorname{WAPE}_h = \frac{\sum_{o\in\mathcal O_h}|e_{o,h}|} {\sum_{o\in\mathcal O_h}|y_{o+h}|},

avoids division by each individual target. It is undefined when the sum of absolute targets is zero and can become unstable when that sum is near zero. Here Oh\mathcal O_h is the set of origins evaluable at horizon hh, eo,he_{o,h} is the forecast error, and yo+hy_{o+h} is the realized target. It is not a universal fix.

The mean absolute scaled error, or MASE, uses a seasonal-naive in-sample scale:

MASE⁡h=MAE⁡h1T−m∑t=m+1T∣yt−yt−m∣.\operatorname{MASE}_h = \frac{\operatorname{MAE}_h} { \frac{1}{T-m} \sum_{t=m+1}^{T}|y_t-y_{t-m}| }.

where:

  • MAE⁡h\operatorname{MAE}_h: the out-of-sample MAE at horizon hh
  • TT: the last index of the training history
  • mm: the seasonal period
  • yt−yt−my_t-y_{t-m}: the seasonal difference between observation tt and the same season one cycle earlier

The denominator is the average absolute seasonal difference in the training history. If (m=1), it is the average absolute first difference, which is the naive scale. The numerator is the out-of-sample MAE at horizon (h). A MASE below 1 means the forecast has lower MAE than the seasonal-naive scale on the training data; a MASE above 1 means it is worse on that scale. The scale must be computed without test data. In a rolling backtest, we can compute the scale once from the initial training window, or recompute it at each origin from that origin's history. The first choice keeps the scale fixed and comparable across origins. The second updates it from the history retained at each origin: expanding histories accumulate observations, while rolling histories replace older observations with newer ones. For a per-origin scale aoa_o, average the components ∣eo,h∣/ao|e_{o,h}|/a_o; this generally differs from dividing pooled MAE by an average scale. State the convention. If the seasonal differences are all zero, the denominator is zero and MASE is undefined.

Uncertainty should be reported by horizon. At origin oo, a residual-based interval can use only earlier forecast errors whose targets have already been released. With no reporting delay, define

Ao,h={s:s+h≤o},qp,o,h=Quantile⁡p{es,h:s∈Ao,h}.\mathcal A_{o,h}=\{s:s+h\le o\},\qquad q_{p,o,h}=\operatorname{Quantile}_p\{e_{s,h}:s\in\mathcal A_{o,h}\}.

Each es,he_{s,h} must come from a forecast frozen at its own origin ss. Delayed labels additionally require their release date to be no later than oo. When enough completed errors are available, the interval is

[y^o+h∣o+qα/2,o,h,y^o+h∣o+q1−α/2,o,h].\left[\hat y_{o+h\mid o}+q_{\alpha/2,o,h},\quad\hat y_{o+h\mid o}+q_{1-\alpha/2,o,h}\right].

Here pp is a quantile probability and α\alpha is the desired miscoverage level. Error quantiles have the same units as the target. This heuristic assumes the future error distribution resembles the completed past errors. It gives no finite-sample coverage guarantee under arbitrary serial dependence. Evaluate interval coverage at later untouched origins, updating calibration only with errors completed by each origin. Pooling future errors before constructing earlier intervals would leak outcomes. Overlapping errors may be correlated, so row count alone does not determine uncertainty in the reported coverage fraction.

Reporting should include the forecast origins, the horizon, the seasonal period, the window type, the model names, and the error scale. A single aggregate MAE over a mix of horizons can hide the fact that a model is good one step ahead and poor four weeks ahead. A table by horizon, plus a compact overall summary, is usually more informative. A skill score relative to a baseline is also useful:

Skill⁡=1−MAE⁡modelMAE⁡baseline.\operatorname{Skill} = 1-\frac{\operatorname{MAE}_{\text{model}}} {\operatorname{MAE}_{\text{baseline}}}.

where:

  • MAE⁡model\operatorname{MAE}_{\text{model}}: the mean absolute error of the model being evaluated
  • MAE⁡baseline\operatorname{MAE}_{\text{baseline}}: the mean absolute error of the chosen baseline, at the same origins, horizon, and error scale

A positive skill score means the model has lower MAE than the baseline; zero means it ties; negative means it is worse. The baseline and horizon must be stated. If baseline MAE is zero, this ratio is undefined. The skill score is most persuasive when the baseline is the strongest simple benchmark for the same decision, not the weakest straw man.

Mathematical Properties

With integrable, conditionally mean-zero innovations, naive and seasonal naive are conditional means under their respective random-walk models. With known constant drift cc, the conditional mean is yT+hcy_T+hc; the implemented rule uses c^\hat c instead. This is a plug-in estimate. Mean reversion, changing seasonality and other departures can make these models unsuitable.

The baselines are also linear functions of the observed history. The naive forecast is the identity function of the last observation and is constant across horizons at a fixed origin. The seasonal-naive forecast selects one of the last (m) observations according to the horizon. The drift forecast is an affine function of the first and last observations. This linearity makes their dependence on the observed history explicit, but also limits their flexibility. Finite inputs and valid history lengths are still required.

Expanding fitting windows are nested when history is appended without revision. Rolling fitting windows are generally not nested because older records leave the selected window. The full information available over time is a different object from the observations deliberately retained for fitting. More history can help in a stable model, while forgetting old regimes can help under change; neither window has a universal variance or accuracy advantage.

Forecast errors from overlapping origins can be dependent. If origin (o) and origin (o+1) both forecast target (o+28), their errors share the same future observation. Their retained fitting histories overlap according to the window policy: expanding histories are nested, while adjacent rolling windows of width ww share w−1w-1 observations, including none when w=1w=1. We can still average them, but we should not treat the average as if it came from independent samples. This is why a backtest with many overlapping origins can look more precise than it really is.

Visualizing Forecasting Baselines and Backtests

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]
CollapseExpand
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]
CollapseExpand
context = demand.iloc[max(0, origin_idx - 55) : origin_idx + 1]
VisualizationOut [6]
CollapseExpand
Line chart of daily demand, a vertical forecast-origin line, three baseline forecast paths, and future actual demand across a 35-day horizon.
Daily demand history through a forecast origin, with naive, seasonal-naive, and drift forecasts for the next 35 days. The naive path stays flat at the last observation, seasonal naive repeats the weekly shape without the trend, and drift follows a straight line, while the future actual combines trend, weekly shape, and noise.

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]
CollapseExpand
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]
CollapseExpand
         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      1204

The 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]
CollapseExpand
Line chart of mean absolute error by forecast horizon for naive, seasonal-naive, and drift forecasts, showing how error changes with lead time.
Rolling-origin backtest MAE by forecast horizon for the three baselines, averaged over expanding-window origins with a 28-day horizon. Curves that rise with lead time show where each baseline loses accuracy, and low points near multiples of seven reflect the phase of the weekly synthetic signal relative to each forecast origin.

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]
CollapseExpand
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]
CollapseExpand
Line chart of mean bias by horizon for naive, seasonal-naive, and drift baselines.
Mean signed error by forecast horizon for the three baselines. Positive values indicate under-forecasting and negative values indicate over-forecasting, so each curve shows how the direction of the miss shifts as the lead time grows.

Example

Before trusting a backtest loop, it is worth shrinking the problem until every number can be checked by hand. A small example exposes the bookkeeping: which observations are in the information set, which are targets, and how each baseline turns the same history into a different forecast.

The series is

y1,…,y12=20,22,21,25,24,27,26,30,31,33,32,35.y_1,\ldots,y_{12} = 20,22,21,25,24,27,26,30,31,33,32,35.

We set the forecast origin at (T=8), so the observed history is (y_1,\ldots,y_8), and the target block is (y_9,\ldots,y_{12}). The seasonal period is (m=4), and the maximum horizon is (H=4). The history contains two full cycles and the target block one full cycle. It is also small enough that a reader can verify each forecast against the raw numbers.

For the naive forecast, every horizon repeats (y_8). In symbols,

y^8+h∣8naive=y8,h=1,2,3,4.\hat y^{\text{naive}}_{8+h\mid 8}=y_8, \qquad h=1,2,3,4.

So the naive path is (30,30,30,30), regardless of the upward movement in the history.

For the seasonal-naive forecast, we use (m=4). The floor formula gives (k=0) for (h=1,2,3,4), so the forecasts are (y_5,y_6,y_7,y_8). These are the last four observations, repeated in order. For (h=1), the target is (y_9), and the most recent same-season observation is (y_5). For (h=4), the target is (y_{12}), and the most recent same-season observation is (y_8). In this example the method carries the shape of the last cycle forward, including its local ups and downs, but it does not add the trend.

For the drift forecast, the estimated drift is

c^=y8−y18−1.\hat c=\frac{y_8-y_1}{8-1}.

The forecasts are (y_8+h\hat c) for (h=1,2,3,4). The first observation and the last observation determine the slope. If either endpoint is unusual, the drift forecast changes. This is a useful feature to notice before applying drift to noisy operational data: the method is not estimating a trend from all the data, it is drawing a line between two boundary observations.

The code below computes exactly the same three forecasts from the same history. It does not look at the target block when constructing the predictions. The target block is attached only afterward for comparison.

PythonIn [14]
CollapseExpand
y_small = np.array(
    [20.0, 22.0, 21.0, 25.0, 24.0, 27.0, 26.0, 30.0, 31.0, 33.0, 32.0, 35.0]
)
T_small = 8
m_small = 4
H_small = 4
history_small = y_small[:T_small]
actual_small = y_small[T_small : T_small + H_small]

small_forecasts = pd.DataFrame(
    {
        "h": np.arange(1, H_small + 1),
        "actual": actual_small,
        "naive": naive_forecast(history_small, H_small),
        "seasonal_naive": seasonal_naive_forecast(
            history_small, H_small, m_small
        ),
        "drift": drift_forecast(history_small, H_small),
    }
)

The table below shows the forecasts and the realized targets. Read it row by row and compare it with the formulas. The naive column should be constant, because it repeats y8y_8. The seasonal-naive column should repeat the last four observations, because k=0k=0 at every horizon. The drift column should increase by the same increment each step, because it adds c^\hat c once, twice, three times, and four times.

The table is a useful audit because it separates three questions: Did the model use the right information set? Did the formula implement the intended assumption? Did the realized target happen to favor that assumption? The first two questions are about correctness; the third is about empirical performance.

ConsoleOut [15]
CollapseExpand
 h  actual  naive  seasonal_naive  drift
 1    31.0   30.0            24.0 31.429
 2    33.0   30.0            27.0 32.857
 3    32.0   30.0            26.0 34.286
 4    35.0   30.0            30.0 35.714

The error for each model and horizon is actual minus forecast. We summarize with MAE and bias. The MASE denominator uses the seasonal differences in the training history y1,…,y8y_1,\ldots,y_8, not the future targets. This is the leakage-free scaling convention. If we used the test targets in the denominator, the scale would contain information that the forecast did not have. The scale is therefore part of the training evidence, even though it is computed after the forecasts are made for reporting.

The next code cell reshapes the forecast table into a long format, computes signed and absolute errors, and builds the summary. The reshaping is not cosmetic: it makes the unit of analysis explicit as one model-horizon-forecast record, which is exactly the unit the later backtest will aggregate.

PythonIn [16]
CollapseExpand
small_long = small_forecasts.melt(
    id_vars=["h", "actual"],
    value_vars=["naive", "seasonal_naive", "drift"],
    var_name="model",
    value_name="forecast",
)
small_long["error"] = small_long["actual"] - small_long["forecast"]
small_long["abs_error"] = small_long["error"].abs()
scale_small = np.mean(
    np.abs(history_small[m_small:] - history_small[:-m_small])
)
small_summary = (
    small_long.groupby("model")
    .agg(mae=("abs_error", "mean"), bias=("error", "mean"))
    .reset_index()
)
small_summary["mase"] = small_summary["mae"] / scale_small
ConsoleOut [17]
CollapseExpand
         model   mae   bias  mase
         drift 0.893 -0.821 0.188
         naive 2.750  2.750 0.579
seasonal_naive 6.000  6.000 1.263

The small-example summaries make the forecasts concrete. The naive forecast is constant at 30, with signed errors 1, 3, 2, and 5; these misses are not monotone in horizon. Seasonal naive has signed errors 7, 6, 6, and 5. Drift extrapolates the endpoint slope and has signed errors −3/7, 1/7, −16/7, and −5/7.

The comparison is intentionally small enough to audit. The naive forecast ignores the upward movement in the series. The seasonal-naive forecast captures the repeating pattern but does not add the trend. The drift forecast adds a trend but ignores the within-season variation. In this example, the best model depends on which of those features dominates the target block. The important lesson is not that one baseline is always best; it is that the formulas, the information set, and the scoring rule are all visible and reproducible.

A signed error curve makes the direction of each miss visible in the small example. Positive values mean the actual value exceeded the forecast, and negative values mean it fell short.

PythonIn [18]
CollapseExpand
small_error_curve = (
    small_long.pivot(index="h", columns="model", values="error")
    .reset_index()
    .sort_values("h")
)
VisualizationOut [20]
CollapseExpand
Line chart of signed forecast errors by horizon for three baselines in a small example.
Signed errors by horizon for the hand-checkable baseline example. Positive values indicate under-forecasting and negative values indicate over-forecasting, and the shape of each curve shows how the direction of the miss changes with lead time.

A baseline win describes the evaluated target blocks under the chosen loss. A naive, seasonal-naive, or drift win can motivate further testing of recent level, recurring seasonal shape, or trend, respectively. A sample win alone does not establish that the corresponding pattern persists in the population. Compare a more complex model with the relevant baselines at the same origins and horizons before attributing an improvement to its added complexity.

Implementation in NumPy and pandas

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.

Practical Implications

Baselines should be part of every forecasting workflow, not a routine first row in a results table. The naive forecast tests the predictive value of the latest level; seasonal naive tests repetition of the observed seasonal pattern; drift tests endpoint-based trend extrapolation. If a more complex model cannot improve accuracy at the horizon that drives the operational decision, the simpler forecast may be the better choice once maintenance, monitoring, and failure modes are included. The comparison is most informative when the benchmark is the strongest simple method for that decision, not a deliberately weak one.

Temporal validation should mirror the deployment cadence. Match the issuance schedule and the latest response actually available. In the Monday-morning example, sales are final through Sunday: use Sunday response-index origins and score the next 28 daily targets, beginning Monday. For daily next-day forecasting with the current day finalized, use that day as the response-index origin and a one-day horizon.

  • The origin schedule, maximum horizon, window type, seasonal period, and step between origins should be reported together, because a single aggregate score over a mix of horizons can hide whether a model is useful at the lead time the business plans for.

  • Expanding windows are appropriate when old data remain relevant, rolling windows when the process changes; when a structural break is suspected, reporting both is a useful sensitivity check. Set initial_train larger than the seasonal period and long enough to cover several cycles; in a rolling window it also fixes the window length.

  • A step of 1 maximizes evaluations, with H−1H-1 shared target dates between adjacent blocks. A step equal to the horizon removes target-block overlap and generally reduces the evaluation count; serial dependence and shared training histories can remain.

Feature availability is one source of leakage. A feature is admissible at origin (o) only if its value is known at (o); rolling averages of realized responses must use only observations available at that origin. Windows over covariates known in advance may include future dates. Scalers, imputers, and any other preprocessing statistics must be fitted on the history available at that origin and then applied forward. Calendar features and approved promotion plans are usually safe because they are known in advance, while unreleased realized sales, traffic, and weather revisions are not. Missing timestamps and irregular gaps need the same treatment in backtesting as in production: filling a gap with a value released after the forecast origin creates leakage. A later observation already available at that origin can be used by the same documented production rule, but not retrospectively at an earlier origin where it was unknown. A training-window statistic is another option and can distort seasonality, so the handling should be documented.

Error metrics should match the decision and be reported by horizon. MAE is natural when the cost of a unit error is roughly linear, RMSE when large errors are disproportionately costly, and bias when under-forecasting and over-forecasting have different consequences. MAPE should be avoided or accompanied by an explicit exclusion rule when the target can be zero, and MASE is useful for scale-free comparison only when its denominator is computed from training history. A single aggregate MAE can conceal a model that is strong one step ahead and poor four weeks ahead, so horizon-specific tables and a compact overall summary are usually more informative than one number. When empirical intervals are reported from backtest residuals, their coverage should be checked by horizon; overlapping origins can induce dependence, so row count alone does not establish precision.

The following pitfalls can invalidate the comparison. Fitting a scaler with values unavailable at the origin, computing a rolling average of realized responses that includes the unavailable target-day outcome, using a promotion flag that was unavailable at the forecast origin, or computing the MASE scale with test targets makes the evaluation invalid for the stated information boundary and can bias it optimistically. Selecting the window, horizon, or model after inspecting the test score also invalidates an untouched-test interpretation, and treating correlated forecast errors as independent can misstate precision. Drift is sensitive to unusual endpoints, seasonal naive is rigid when the seasonal pattern changes, and naive is flat when a trend or promotion is present; these weaknesses should be diagnosed rather than hidden by an aggregate score.

Computational cost and deployment needs should be considered before the validation design is fixed. If deployment refits at every origin, backtest cost grows with the number of origins and the cost of each fit; with other settings fixed, a larger step cannot increase the evaluation count and may reduce computational cost. Adjacent target-block overlap cannot increase as the spacing grows and stays zero once the blocks are disjoint; weaker error dependence is not guaranteed. For long series or many models, origins can be parallelized or errors aggregated in a streaming table, but the origin, horizon, and history boundary must remain explicit in every run. In production, the origin cadence, horizon, window type, seasonal period, and MASE scale should be part of the monitoring contract, and performance should be tracked by horizon, bias, and interval coverage rather than only by a single aggregate. Baselines are useful as a fallback and as a live diagnostic: if a complex model stops beating the relevant simple benchmark, that is a signal to check for leakage, drift, or a changed process before retraining blindly.

Summary

Forecasting baselines are not a formality. Naive and seasonal naive have simple random-walk interpretations, while drift estimates the slope of a random walk with drift. They provide transparent benchmarks at matched origins, horizons and losses. The naive forecast repeats the last observation, the seasonal-naive forecast repeats the last seasonal cycle, and the drift forecast continues a linear trend. Each makes a specific assumption, and each fails in a specific way.

Temporal validation must respect the forecast origin, the horizon, and the information set. At origin (o), only information through (o) may be used. Rolling-origin backtesting advances the origin, produces forecasts for explicit future targets, and scores them after the targets are revealed. Expanding windows use all history; rolling windows forget old history. Overlapping target blocks can create dependent errors, so error-row count alone does not establish independence.

Forecast error should be reported by horizon, with MAE, RMSE, bias, and a scale-aware measure such as MASE. Percentage errors need explicit handling of zeros and near-zero targets. MASE needs a leakage-free seasonal scale. Empirical uncertainty intervals should be interpreted with the dependence of overlapping errors in mind. When these pieces are in place, a sophisticated forecasting model has a fair benchmark and a validation design that resembles the decision it is meant to support.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about forecasting baselines, temporal validation, and backtesting.

Join the discussionAsk a question or share what you learned.

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!

A different angle
All writing
Machine Learning · Vol. 4

N-HiTS for Multi-Scale Time Series Forecasting

Covers NHITS (Neural Hierarchical Interpolation for Time Series), a deep learning architecture for multi-scale time series forecasting.

71 min read
Machine Learning · Vol. 4

N-BEATS for Interpretable Time Series Forecasting

Covers N-BEATS, an interpretable deep learning architecture for time series forecasting.

51 min read
Machine Learning · Vol. 4

SARIMA: Seasonal Time Series Forecasting with Implementation

Covers SARIMA (Seasonal AutoRegressive Integrated Moving Average) for forecasting time series with seasonal patterns.

35 min read

About the author

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2026forecastingbaselines, author = {Michael Brenndoerfer}, title = {Forecasting Baselines, Temporal Validation, and Backtesting}, year = {2026}, url = {https://mbrenndoerfer.com/writing/forecasting-baselines-backtesting-from-scratch}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-10-30} }
APAAcademic
Michael Brenndoerfer (2026). Forecasting Baselines, Temporal Validation, and Backtesting. Retrieved from https://mbrenndoerfer.com/writing/forecasting-baselines-backtesting-from-scratch
MLAAcademic
Michael Brenndoerfer. "Forecasting Baselines, Temporal Validation, and Backtesting." 2026. Web. October 30, 2026. <https://mbrenndoerfer.com/writing/forecasting-baselines-backtesting-from-scratch>.
CHICAGOAcademic
Michael Brenndoerfer. "Forecasting Baselines, Temporal Validation, and Backtesting." Accessed October 30, 2026. https://mbrenndoerfer.com/writing/forecasting-baselines-backtesting-from-scratch.
HARVARDAcademic
Michael Brenndoerfer (2026) 'Forecasting Baselines, Temporal Validation, and Backtesting'. Available at: https://mbrenndoerfer.com/writing/forecasting-baselines-backtesting-from-scratch (Accessed: October 30, 2026).
SimpleBasic
Michael Brenndoerfer (2026). Forecasting Baselines, Temporal Validation, and Backtesting. https://mbrenndoerfer.com/writing/forecasting-baselines-backtesting-from-scratch
Newsletter

Stay up to date

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

Unsubscribe at any time. Privacy policy