Skip to content

Time Series Forecasting Fundamentals

What you’ll learn

  • why every baseline comparison must be against naive and seasonal naive, and what MASE is
  • the measured cost of a shuffled split: MAE 6.72 reported against 10.41 honest
  • how to build lag features that cannot see their own target
  • the leak that looks like a 25% win: 6.852 → 5.147 MAE from rolling(7, center=True)rolling(7, center=True)
  • one-step against multi-step error: 3.73 at horizon 1 and 9.03 at horizon 28
  • why R2R^2 is close to meaningless on a trending series

The series

Three years of daily demand, built from four known components so that we can say exactly how much of it any model is entitled to explain.

the_series.py
rng = np.random.default_rng(4)
t = np.arange(1096)
 
trend = 120 + 0.06 * t
weekly = 18 * np.sin(2 * np.pi * (t % 7) / 7 + 0.6)
yearly = 30 * np.sin(2 * np.pi * t / 365.25 - 1.1)
 
noise = np.zeros(1096)                      # AR(1): today's error remembers yesterday
for i in range(1, 1096):
    noise[i] = 0.55 * noise[i - 1] + rng.normal(0, 7)
 
y = pd.Series(trend + weekly + yearly + noise,
              index=pd.date_range("2021-01-01", periods=1096, freq="D"))
the_series.py
rng = np.random.default_rng(4)
t = np.arange(1096)
 
trend = 120 + 0.06 * t
weekly = 18 * np.sin(2 * np.pi * (t % 7) / 7 + 0.6)
yearly = 30 * np.sin(2 * np.pi * t / 365.25 - 1.1)
 
noise = np.zeros(1096)                      # AR(1): today's error remembers yesterday
for i in range(1, 1096):
    noise[i] = 0.55 * noise[i - 1] + rng.normal(0, 7)
 
y = pd.Series(trend + weekly + yearly + noise,
              index=pd.date_range("2021-01-01", periods=1096, freq="D"))
figure1,096 days, and only one of the four components is unpredictablematplotlib
Top: three years of daily demand oscillating between about 80 and 240 with a rising trend and a vertical line marking the end of training on 2023-06-02. Bottom: the four components centred on zero — trend sd 18.99, weekly sd 12.73, yearly sd 21.22, noise sd 8.43.Top: three years of daily demand oscillating between about 80 and 240 with a rising trend and a vertical line marking the end of training on 2023-06-02. Bottom: the four components centred on zero — trend sd 18.99, weekly sd 12.73, yearly sd 21.22, noise sd 8.43.
Trend, weekly season and yearly season are all deterministic functions of the date, so a model with the right features should recover them almost exactly. The AR(1) noise has standard deviation 8.43 against the series' 30.91, and it is the only part that resists — though even it is partly predictable, because 0.55 of yesterday's error carries into today.
ComponentStandard deviation
trend18.99
weekly season12.73
yearly season21.22
AR(1) noise8.43
the series30.91

Those standard deviations do not combine into a tidy variance decomposition — trend and yearly season are correlated over a three-year window, so the shares would sum to more than 100%. The number that matters is the last one against the second-to-last: about 8.4 of the series’ 30.9 of variation is noise, and even that is autocorrelated.

The AR(1) structure sets the floor. With innovations of standard deviation 7, a perfect one-step forecast still has MAE 72/π5.597\sqrt{2/\pi} \approx 5.59. No model on this page can beat that, and any model that appears to has seen the future.

Baselines first, always

Two forecasts require no model at all:

  • naive: tomorrow equals today, y^t+1=yt\hat{y}_{t+1} = y_t
  • seasonal naive: tomorrow equals the same weekday last week, y^t+1=yt6\hat{y}_{t+1} = y_{t-6}

On a chronological 80/20 split — 854 training days, then 214 test days from 2023-06-02:

ForecastMAER2R^2MASE
naive (yesterday)12.1740.63711.000
seasonal naive (last week)10.4740.72190.860
the training mean30.200−1.24962.481

MASE — mean absolute scaled error — is MAE divided by the naive forecast’s MAE. Below 1 means you beat “tomorrow is like today”; above 1 means you did not. It is the honest headline for a forecasting model because it is scale-free and it has a meaningful zero point.

Note the training mean’s R2R^2 of −1.2496. On a trending series the test window’s mean is nowhere near the training window’s, so predicting the training mean is worse than useless — and R2R^2, which compares against the test window’s own mean, hides how bad a forecast is while flattering anything that tracks the level. Report MAE and MASE. Use R2R^2 only if someone insists.

A model on lag features

The standard move is to turn the series into a supervised table, where every column is a shifted copy of the past:

lag_frame.py
def lag_frame(series, lags=(1, 2, 3, 7, 14), roll=(7, 28)):
    """Every column is shifted before use, so no row can see its own target."""
    df = pd.DataFrame({"y": series})
    for L in lags:
        df[f"lag_{L}"] = series.shift(L)
    for w in roll:
        df[f"roll_{w}"] = series.shift(1).rolling(w).mean()   # shift, THEN roll
    df["dow"] = series.index.dayofweek
    df["t"] = np.arange(len(series))
    return df.dropna()
lag_frame.py
def lag_frame(series, lags=(1, 2, 3, 7, 14), roll=(7, 28)):
    """Every column is shifted before use, so no row can see its own target."""
    df = pd.DataFrame({"y": series})
    for L in lags:
        df[f"lag_{L}"] = series.shift(L)
    for w in roll:
        df[f"roll_{w}"] = series.shift(1).rolling(w).mean()   # shift, THEN roll
    df["dow"] = series.index.dayofweek
    df["t"] = np.arange(len(series))
    return df.dropna()

The shift(1)shift(1) before .rolling().rolling() is the whole trick, and leaving it out is the subject of a later section. With those nine features:

figureRidge on lags reaches MASE 0.561; the train mean is 2.5× worse than doing nothingmatplotlib
Left: 90 test days of actual demand with the seasonal naive and ridge forecasts overlaid; ridge tracks the weekly oscillation closely. Right: horizontal MASE bars — ridge on lags 0.561, gradient boosting 0.670, seasonal naive 0.860, naive 1.000, train mean 2.481 — with a dashed line at 1.0.Left: 90 test days of actual demand with the seasonal naive and ridge forecasts overlaid; ridge tracks the weekly oscillation closely. Right: horizontal MASE bars — ridge on lags 0.561, gradient boosting 0.670, seasonal naive 0.860, naive 1.000, train mean 2.481 — with a dashed line at 1.0.
Ridge cuts the naive error nearly in half, which is what a correctly specified linear model should do on a series built from linear components. Gradient boosting, with no way to extrapolate the trend, does worse at 0.670 — the first sign that flexibility is not the binding constraint here.
ModelMAERMSER2R^2MASE
ridge on lags6.8248.4910.87720.561
gradient boosting8.16110.0300.82860.670
perfect one-step forecast≈5.59≈7.00≈0.46

Ridge lands at 6.824 against a floor of about 5.59. The remaining gap is mostly the part of the AR(1) noise that a linear function of past values cannot capture, plus estimation error.

Gradient boosting losing to ridge is worth pausing on. Tree ensembles cannot extrapolate: their predictions are averages of training targets, so on a series whose level rises by 0.06 per day they are structurally behind. The usual fix is to model the difference rather than the level, or to detrend first — both of which are decisions about the data, not about the model.

The shuffled split lies, and it lies more to flexible models

KFold(shuffle=True)KFold(shuffle=True) puts a Tuesday from 2022 in the training set and the Wednesday next to it in the test set. The model then interpolates between neighbours that in deployment would not exist.

figureShuffling costs the boosting model 3.69 MAE of honesty and the ridge 0.73matplotlib
Left: fold diagrams. TimeSeriesSplit shows five rows of expanding blue training blocks each followed by a contiguous amber test block; shuffled KFold shows amber test points scattered throughout. Right: cross-validated MAE bars — ridge 6.65 shuffled against 7.38 ordered, gradient boosting 6.72 shuffled against 10.41 ordered.Left: fold diagrams. TimeSeriesSplit shows five rows of expanding blue training blocks each followed by a contiguous amber test block; shuffled KFold shows amber test points scattered throughout. Right: cross-validated MAE bars — ridge 6.65 shuffled against 7.38 ordered, gradient boosting 6.72 shuffled against 10.41 ordered.
Under shuffling the two models look equivalent — 6.65 against 6.72 — and a team would reasonably pick either. In time order they are not close: 7.38 against 10.41. The flexible model is the one that benefits most from being handed its own neighbours, which is exactly why the wrong split favours the wrong model.
ModelShuffled KFoldKFoldTimeSeriesSplitTimeSeriesSplitChronological holdoutOptimism
ridge on lags6.6497.3836.824+0.734
gradient boosting6.71710.4108.161+3.693

Three observations.

The ranking flips. Shuffled cross-validation says the two models are within 0.07 MAE of each other. Time-ordered says the gap is 3.03. Anyone selecting a model on the shuffled number has a 50% chance of shipping the worse one, and no way to know.

Flexible models gain more from leakage. Boosting can memorise local structure that a linear model cannot, so handing it adjacent days is worth 3.69 MAE to it and 0.73 to the ridge. Leakage is not a constant tax; it is a distortion that reorders your candidates.

TimeSeriesSplitTimeSeriesSplit is pessimistic on purpose. Its early folds train on as little as one sixth of the data, so its 7.383 is worse than the 6.824 the model achieves after training on 80%. That is the right direction to be wrong in: it under-promises and the holdout over-delivers.

The leak that looks like a 25% win

Feature engineering on a series is where leakage becomes invisible, because the offending code looks like every pandas tutorial ever written.

figureThe feature is legal-looking, chronologically impossible, and improves every metricmatplotlib
Left: thirteen dots representing days, with the middle one highlighted and a shaded band covering days -3 to +3, labelled as the centred rolling window that averages three days of the future. Right: test MAE 6.852 for the honest model against 5.147 with the centred rolling mean, R-squared 0.8769 against 0.9304.Left: thirteen dots representing days, with the middle one highlighted and a shaded band covering days -3 to +3, labelled as the centred rolling window that averages three days of the future. Right: test MAE 6.852 for the honest model against 5.147 with the centred rolling mean, R-squared 0.8769 against 0.9304.
rolling(7, center=True) at row t averages rows t−3 to t+3, so it carries the answer to three future days into today's feature vector. MAE drops from 6.852 to 5.147 — below the 5.59 floor that a perfect one-step forecast allows, which is the tell. Nothing in pandas, scikit-learn or your test set will object.
the_leak.py
# WRONG: center=True averages rows t-3 .. t+3
frame["roll_centred"] = y.rolling(7, center=True).mean()
 
# RIGHT: shift first, so the window ends yesterday
frame["roll_7"] = y.shift(1).rolling(7).mean()
the_leak.py
# WRONG: center=True averages rows t-3 .. t+3
frame["roll_centred"] = y.rolling(7, center=True).mean()
 
# RIGHT: shift first, so the window ends yesterday
frame["roll_7"] = y.shift(1).rolling(7).mean()
FeaturesTest MAETest R2R^2
lags and past rolling means only6.8520.8769
the same plus rolling(7, center=True)rolling(7, center=True)5.1470.9304

The 5.147 is the diagnostic. A perfect one-step forecast on this series has MAE ≈ 5.59, so any number below that is impossible and indicates that information from the future has arrived. Knowing your noise floor is the cheapest leakage detector you will ever build.

Other members of the same family, all of which have shipped to production somewhere:

  • .rolling(...).mean().rolling(...).mean() without a preceding .shift(1).shift(1) — includes today’s value in today’s feature.
  • Group statistics (mean sales per store) computed over the full history, then used to predict the early part of that history.
  • Interpolating missing values with .interpolate().interpolate() before splitting, which fills gaps using later observations.
  • Scaling with a StandardScalerStandardScaler fitted on the whole series, so the test period’s mean influences the training features.
  • Any feature derived from a column that is itself recorded after the prediction is made — a delivery date, a settled amount, a refund flag.

One step ahead is not a forecast

The 6.824 above answers “given everything up to yesterday, what is today?” — which is only useful if you genuinely get yesterday’s value before predicting today. Planning questions are not like that. To forecast 28 days you must feed your own predictions back in as inputs.

figureRecursive MAE 9.03 against one-step 6.89 over the same 28 daysmatplotlib
Left: 28 days of actual demand with the one-step forecast tracking it closely and the recursive forecast increasingly damped, missing the peaks. Right: cumulative MAE against horizon — one-step rises from 3.7 to 6.9 while recursive rises from 3.7 to 9.03, annotated at 5.29 and 8.34.Left: 28 days of actual demand with the one-step forecast tracking it closely and the recursive forecast increasingly damped, missing the peaks. Right: cumulative MAE against horizon — one-step rises from 3.7 to 6.9 while recursive rises from 3.7 to 9.03, annotated at 5.29 and 8.34.
Both start identically at day 1, because at horizon 1 they are the same computation. From there the recursive forecast is compounding its own errors and, more visibly, reverting to the mean — the peaks flatten because a prediction fed back in is smoother than a real observation.
HorizonRecursive MAEOne-step MAE
1 day3.7323.732
7 days5.2873.948
14 days8.3405.815
28 days9.0286.891

Two things are happening at once, and only one of them is error accumulation.

Errors compound. Each prediction becomes the next row’s lag_1lag_1, so a mistake propagates. For an AR(1) process with coefficient ϕ\phi, the hh-step-ahead forecast variance grows as

Var[y^t+h]=σ21ϕ2h1ϕ2\mathrm{Var}\big[\hat{y}_{t+h}\big] = \sigma^2 \frac{1 - \phi^{2h}}{1 - \phi^2}

which for ϕ=0.55\phi = 0.55 saturates at σ2/(1ϕ2)=1.43σ2\sigma^2 / (1 - \phi^2) = 1.43\sigma^2 — the variance of the series itself. Beyond about 6 steps the AR(1) part of the forecast has decayed to the unconditional mean and contributes nothing.

The forecast flattens. Feeding predictions back in produces a smoother input series than reality, so the seasonal amplitude shrinks. This is visible in the left panel: the recursive line’s peaks are consistently short of the actual peaks.

The alternatives, both of which sidestep recursion:

  • Direct multi-step: fit a separate model per horizon, each predicting yt+hy_{t+h} from information at tt. No compounding, HH models to maintain, and each one gets less relevant features.
  • Multi-output: one model, HH outputs. Shares structure across horizons; supported natively by linear models and by MultiOutputRegressorMultiOutputRegressor.

Whatever you choose, report the error at the horizon you will actually forecast at. A one-step MAE next to a monthly planning claim is a category error.

See it move

Forecast uncertainty for an AR(1) process is exactly computable, so the decay of a recursive forecast can be watched rather than argued about. Drag ϕ\phi — the memory of the process — and see how far into the future the forecast is worth anything.

sketch How fast a forecast decays into its own mean p5.js
For an AR(1) process the h-step forecast is phi to the h times the last value, and its error variance grows as sigma squared times one minus phi to the 2h over one minus phi squared. Dragging phi shows both the point forecast and the widening interval saturating at the unconditional variance.

At ϕ=0.55\phi = 0.55 the signal is gone after 4 steps: a recursive forecast beyond that point is predicting the seasonal pattern and the trend, and nothing else. At ϕ=0.95\phi = 0.95 it survives 45 steps, which is why highly persistent series (stock levels, temperatures, populations) forecast much further than mean-reverting ones (returns, click rates) even when both look equally noisy.

The workflow

diagram Diagram mermaid

Pitfalls

PitfallWhy it bitesWhat to do
KFold(shuffle=True)KFold(shuffle=True) on ordered rows6.72 reported against 10.41 honest, and the ranking flipsTimeSeriesSplitTimeSeriesSplit, always
rolling(...)rolling(...) without shift(1)shift(1)MAE 5.147, below the 5.59 noise floorShift, then roll; know your floor
Reporting one-step error for a multi-step decision6.89 against 9.03 over 28 daysReport at the horizon you forecast at
Using R2R^2 on a trending seriesThe training mean scores −1.2496MAE and MASE
Skipping the naive baselineA model at MASE 1.05 is worse than one line of codeCompute both naive forecasts first
Trees on a trending levelBoosting lost to ridge, 0.670 against 0.561Difference the series, or detrend, or use a linear component
Fitting the scaler on the whole seriesTest-period statistics leak into training featuresFit inside the pipeline, inside the fold
Forgetting the AR structure of the residualsConfidence intervals come out far too narrowCheck residual autocorrelation before trusting any interval

Recap

  • The series is 1,096 days of trend, weekly and yearly season, plus AR(1) noise of sd 8.43 against the series’ 30.91. A perfect one-step forecast still has MAE ≈ 5.59.
  • Naive MAE 12.174, seasonal naive 10.474, ridge on lags 6.824 — MASE 0.561.
  • Gradient boosting reached only 0.670, because trees cannot extrapolate a trend.
  • Shuffled cross-validation reported 6.65 and 6.72, hiding a real gap of 7.38 against 10.41 and reversing the ranking.
  • rolling(7, center=True)rolling(7, center=True) improved MAE to 5.147 — below the noise floor, which is how you catch it.
  • Recursive forecasting decayed from 3.732 at one day to 9.028 at 28; one-step over the same window was 6.891.
  • R2R^2 of the training mean on the test window: −1.2496. Report MASE.
quizCheck yourself
  1. Shuffled 5-fold CV gives MAE 6.72 for your boosting model and TimeSeriesSplit gives 10.41. Which number should you report?

    Show answer

    B — 10.41 — the shuffled split trains on days adjacent to the test days, which cannot happen in deployment — Shuffling hands the model interpolation targets. The distortion is not even uniform: it was worth 3.69 MAE to the boosting model and 0.73 to the ridge, which reversed the ranking between them.

  2. A new feature takes your test MAE from 6.85 to 5.15 on a series whose noise floor implies MAE cannot go below about 5.59. What do you conclude?

    Show answer

    B — It leaks: the score is below what a perfect one-step forecast can achieve, so information about the target's own period has entered the features — That is exactly the measured case: rolling(7, center=True) averages rows t-3 to t+3. Knowing your irreducible error is the cheapest leakage detector available, and it caught a feature that no library would complain about.

  3. Your model reports one-step MAE 6.89. Planning asks for a 28-day forecast. What will they actually get?

    Show answer

    B — About 9.03 — recursive forecasting compounds its own errors and reverts to the mean, so the peaks flatten — Both forecasts are identical at horizon 1 (3.73) and diverge from there. Report error at the horizon the decision needs, or fit direct multi-step models, one per horizon.

  4. Gradient boosting scores MASE 0.670 while ridge on the same features scores 0.561. Why?

    Show answer

    B — Trees cannot extrapolate — their predictions are averages of training targets, and this series' level rises 0.06 per day — A tree's output is bounded by the targets it saw, so on a rising series it is structurally behind. The fix is a data decision rather than a hyperparameter one: difference the series, detrend it, or use a model with a linear component.

  5. What does MASE 1.00 mean, and why prefer it to R-squared here?

    Show answer

    B — Exactly as good as predicting today's value for tomorrow — and unlike R-squared it has a meaningful zero point on a trending series, where predicting the training mean scores -1.2496 — MASE is MAE divided by the naive forecast's MAE, so 1.0 is 'no better than one line of code' and 0.561 is 'nearly halved it'. R-squared compares against the test window's own mean, which on a trending series is an arbitrary and shifting reference.

🧪 Try It Yourself

Exercise 1 – Build the series

Exercise 2 – Beat the baselines, or admit you did not

Exercise 3 – Measure what shuffling costs

Exercise 4 – Catch the centred-window leak

Exercise 5 – Forecast 28 days recursively

Next

Text Classification with TF-IDF — from ordered numbers to unordered words, where the feature matrix has 30,000 columns, is 99.8% zeros, and a linear model is once again hard to beat.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did