Skip to content

03 · Time Series Forecasting Basics

Time series data breaks the i.i.d. assumption every model so far has relied on: observations are ordered, and the future depends on the past. This module covers the two things that actually matter for tabular forecasting — turning time into features, and splitting/evaluating without leaking the future into the past — plus classic baselines.

Building lag and rolling-window features

import numpy as np
import pandas as pd

rng = pd.date_range("2022-01-01", periods=400, freq="D")
trend = np.linspace(50, 90, 400)
season = 8 * np.sin(2 * np.pi * np.arange(400) / 7)     # weekly seasonality
noise = np.random.default_rng(42).normal(0, 2, 400)
series = pd.Series(trend + season + noise, index=rng, name="sales")

df = series.to_frame()
for lag in [1, 2, 7]:
    df[f"lag_{lag}"] = df["sales"].shift(lag)
df["roll_mean_7"] = df["sales"].shift(1).rolling(7).mean()
df["roll_std_7"] = df["sales"].shift(1).rolling(7).std()
df["dow"] = df.index.dayofweek
df = df.dropna()
print(df.head(3))

Every feature is shifted by at least 1 before any rolling computation — shift(1).rolling(7).mean(), not rolling(7).mean() — so that the feature for day t only uses data available before day t. This is the single most common bug in time series ML: an un-shifted rolling window silently includes today's own target value.

Time-aware train/test splits

Random train_test_split is invalid here — it would let the model train on future days and be tested on earlier ones. Split by time instead:

split_date = df.index[int(len(df) * 0.8)]
train, test = df[df.index < split_date], df[df.index >= split_date]

from sklearn.ensemble import RandomForestRegressor

X_cols = ["lag_1", "lag_2", "lag_7", "roll_mean_7", "roll_std_7", "dow"]
model = RandomForestRegressor(n_estimators=300, random_state=42)
model.fit(train[X_cols], train["sales"])
pred = model.predict(test[X_cols])

from sklearn.metrics import mean_absolute_error
print(f"MAE: {mean_absolute_error(test['sales'], pred):.2f}")   # ~2.4

For robust evaluation, TimeSeriesSplit generalizes this to multiple folds, each still respecting order:

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5)
for fold, (tr_idx, te_idx) in enumerate(tscv.split(df)):
    print(fold, "train size:", len(tr_idx), "test size:", len(te_idx))
# fold 0 train size: 66  test size: 65
# fold 1 train size: 131 test size: 65
# ... (train window grows, test window always comes after it)

Baselines you must beat

naive_pred = test["lag_1"]                                  # "tomorrow = today"
seasonal_naive_pred = test["lag_7"]                          # "same weekday last week"

print(f"naive MAE:          {mean_absolute_error(test['sales'], naive_pred):.2f}")
print(f"seasonal naive MAE: {mean_absolute_error(test['sales'], seasonal_naive_pred):.2f}")
# naive MAE:          6.1
# seasonal naive MAE: 3.0

If a sophisticated model can't beat lag_7 (seasonal naive) on data with weekly seasonality, it isn't earning its complexity — a shockingly common outcome in practice.

Cheat sheet

Task Code
Lag feature df["sales"].shift(k)
Rolling stat (leak-safe) df["sales"].shift(1).rolling(w).mean()
Time-respecting split slice by date, or TimeSeriesSplit
Naive baseline shift(1)
Seasonal naive baseline shift(period)
Error metric mean_absolute_error, mean_absolute_percentage_error

How It Actually Works

Why an un-shifted rolling window leaks the future. rolling(7).mean() computed on df["sales"] directly assigns, to row t, the mean of rows t-6 through t inclusive of t itself — so the feature for day t literally contains sales[t], the very value the model is trying to predict, divided by 7 and mixed with 6 other values. During training the model discovers this near-perfect proxy and assigns it a huge coefficient or split priority; validation metrics computed the same leaky way look excellent, and the model then fails in production where day t's true sales are exactly the unknown quantity being forecast. .shift(1) before .rolling(7) fixes this by first moving the whole series forward one step, so the window used for row t's feature spans rows t-7 through t-1 — mechanically, no information from t or later ever enters the computation.

Why random splits are invalid but TimeSeriesSplit is a direct fix. train_test_split(..., shuffle=True) (the sklearn default) assumes rows are exchangeable — a valid assumption when they're i.i.d. draws, false for a time series where row t+1's target is correlated with row t's. A random split can place day 350 in the training set and day 100 in the test set, letting a lag-1 feature at test time (lag_1 for day 100) come from a value that itself sits inside the training data's temporal neighborhood, and more subtly lets the model implicitly "see" the general trend/season level of periods that are chronologically after some test points. TimeSeriesSplit mechanically enforces train indices < test indices for every fold by construction — each successive fold's test set is a block of rows that come strictly after all of that fold's training rows — which is the only way to estimate how the model will perform on data it genuinely hasn't seen yet: the future.

Why the seasonal-naive baseline is a legitimate scientific control, not just a placeholder. lag_7 predicts "whatever happened exactly one full season ago." For a series that is trend + 8·sin(2πt/7) + noise by construction, lag_7 for day t equals trend[t-7] + 8·sin(2π(t-7)/7) + noise[t-7]; since sin has period 7, sin(2π(t-7)/7) = sin(2πt/7) exactly — the seasonal term matches perfectly, and the only error left is the (small) trend drift over 7 days plus the noise terms of two different days. That's mechanically why its MAE (3.0) is roughly half the plain naive's (6.1): the seasonal-naive baseline is capturing the true generating seasonality almost for free, with zero learned parameters, which is exactly the bar a learned model needs to clear to justify its complexity.

Exercise

Add an is_weekend binary feature (dow >= 5) to X_cols and retrain the random forest. Compare its test MAE against both the model above and the seasonal-naive baseline. Then deliberately introduce the leakage bug (use df["sales"].rolling(7).mean() without .shift(1)) and report how much the reported MAE improves — as a concrete demonstration of why that bug is dangerous precisely because it looks like a win.