A demand-forecasting model, evaluated with standard 5-fold cross-validation, reported a mean absolute percentage error of 8% — a genuinely strong result. Deployed and evaluated against real, subsequent weeks it had never seen during training, the actual error came in above 19%, more than double the cross-validation estimate. The gap wasn't bad luck or model drift — it was a specific, well-understood flaw in applying standard cross-validation to time-ordered data at all.
Why standard k-fold cross-validation leaks future information into the past
Standard k-fold cross-validation shuffles data randomly into folds, which is exactly correct for i.i.d. data where row order carries no meaning — but for time series data, row order is the entire point. Shuffling means a fold used for training can easily contain observations from weeks after the observations in the "test" fold it's being evaluated against, which means the model is effectively being trained on the future and tested on the past relative to some of its own training data — a form of information leakage that has no equivalent in ordinary tabular classification.
# What standard k-fold actually does to time-ordered data:
# Fold 1 test: weeks 1-10 | trained on: weeks 11-50 (includes future relative to test)
# Fold 2 test: weeks 11-20 | trained on: weeks 1-10, 21-50 (includes future relative to test)
# ...and so on
# In production, the model will NEVER have access to future weeks
# when forecasting a given week — but cross-validation just let it.
A model trained partly on data from after the period it's being asked to predict can pick up on patterns that happen to correlate with the target in that specific historical window — a seasonal trend that reverses later, an anomaly linked to an event without genuine predictive value going forward — inflating the reported score in a way that has no bearing on how the model will actually perform making genuine future predictions from only past data, which is the only scenario that will ever occur in real deployment.
The fix: walk-forward validation
Walk-forward validation (also called rolling-origin or time series cross-validation) respects chronological order strictly: every training fold contains only data from before the corresponding test fold, moving forward through time, never backward.
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
for fold, (train_idx, test_idx) in enumerate(tscv.split(X)):
print(f"Fold {fold}: train weeks {train_idx.min()}-{train_idx.max()}, "
f"test weeks {test_idx.min()}-{test_idx.max()}")
# Fold 0: train weeks 0-9, test weeks 10-18
# Fold 1: train weeks 0-18, test weeks 19-27
# Fold 2: train weeks 0-27, test weeks 28-36
# Fold 3: train weeks 0-36, test weeks 37-45
# Fold 4: train weeks 0-45, test weeks 46-54
Each fold's training window grows to include everything up to that point, and the test window always immediately follows it chronologically — this exactly mirrors the real deployment scenario: at any given point in time, the model only has access to past data, and it's being asked to predict what comes next, never to interpolate within a window it's already partially seen data from on both sides.
Re-running the actual evaluation with the correct method
from sklearn.metrics import mean_absolute_percentage_error
errors = []
for train_idx, test_idx in tscv.split(X):
model = fit_model(X[train_idx], y[train_idx])
predictions = model.predict(X[test_idx])
errors.append(mean_absolute_percentage_error(y[test_idx], predictions))
print(f"Mean MAPE across folds: {sum(errors) / len(errors):.1%}")
# Mean MAPE across folds: 18.4%
18.4% — genuinely close to the 19% actually observed in production, unlike the standard k-fold approach's falsely optimistic 8%. This is the real value of walk-forward validation: it's not just methodologically more correct in the abstract, it produces a number that actually predicts real-world performance, which is the entire point of running cross-validation in the first place.
A second, subtler leakage source: feature engineering computed across the full dataset
Fixing the split method alone didn't fully close the gap on a second model in the same project — a rolling 4-week average feature had been computed once, across the entire dataset, before any train/test split occurred at all:
# Wrong: rolling average computed globally, before any split —
# early weeks' "rolling average" partially reflects data
# from weeks that would later be in a held-out test fold
df['rolling_avg_4wk'] = df['sales'].rolling(4).mean()
# Right: recompute the rolling feature fresh within each fold,
# using only that fold's available training history
def add_rolling_feature(train_df, test_df):
combined = pd.concat([train_df, test_df])
combined['rolling_avg_4wk'] = combined['sales'].shift(1).rolling(4).mean()
return combined.loc[train_df.index], combined.loc[test_df.index]
The critical detail in the corrected version is .shift(1) — shifting by one period before computing the rolling window ensures the feature for any given week only uses data strictly before that week, never including the current week's own value, which would otherwise leak the very thing being predicted into its own input feature. This is a genuinely easy mistake to make with any time-based feature (rolling averages, lagged differences, cumulative sums) computed before a split rather than fold-aware and shift-aware from the start.
Choosing a gap between train and test windows, when it matters
For forecasting tasks where predictions are made some fixed period ahead of the data used to make them — predicting 2 weeks out, using only data available as of "today" — a gap needs to be introduced between the end of the training window and the start of the test window, matching that real forecasting horizon:
tscv = TimeSeriesSplit(n_splits=5, gap=2) # 2-week gap between train and test
Without this gap, the evaluation implicitly assumes the model has access to data right up until the moment being predicted — which is only realistic if the actual production use case genuinely has same-day data available at prediction time. Matching the gap to the real forecasting horizon is what makes the validation setup actually mirror the production scenario rather than a slightly easier version of it.
What this changes about evaluating any time-ordered model
- Never use standard shuffled k-fold cross-validation on time series data — it structurally leaks future information into training in a way that inflates every reported metric, sometimes dramatically, as shown here.
- Any feature involving a rolling window, lag, or cumulative calculation needs to be computed fold-aware and shift-aware, not once globally before splitting — this is a second, independent leakage source beyond the split method itself.
- Match the gap between train and test windows to the real forecasting horizon the model will actually face in production, not a same-day assumption that doesn't reflect how it'll actually be used.
The underlying leakage concept here is the same one covered more generally, for non-time-series data, in cross-validation done right — time series forecasting just adds a chronological-order constraint on top of the same core principle: a model's evaluation should never have access to information it wouldn't genuinely have at real prediction time.
Expanding window vs. sliding window: a real trade-off, not just a config choice
TimeSeriesSplit's default behavior grows the training window with every fold, always starting from the very first available observation. An alternative — a fixed-size sliding window that drops the oldest data as it adds the newest — is worth considering explicitly rather than assuming the expanding default is always correct:
from sklearn.model_selection import TimeSeriesSplit
# Expanding window (default): training data grows every fold
tscv_expanding = TimeSeriesSplit(n_splits=5)
# Sliding window: fixed-size training window, oldest data dropped
# as newest is added — max_train_size caps the window
tscv_sliding = TimeSeriesSplit(n_splits=5, max_train_size=52) # roughly one year of weekly data
An expanding window uses more total data with every fold, which generally helps if the underlying patterns are stable over the full history available. A sliding window deliberately discards older data, which is the more defensible choice if the underlying process genuinely changes over time — a demand pattern that shifted meaningfully after a product redesign eighteen months ago is arguably not representative of demand today, and training on it anyway can actively hurt a model's ability to predict current behavior. Testing both against a held-out final period, rather than assuming the expanding default is automatically correct, is worth doing explicitly for any forecasting problem where the underlying process has a plausible reason to have shifted over the available history.
A backtest is not the same thing as a single walk-forward validation run
It's worth distinguishing walk-forward cross-validation, used to estimate expected error before choosing a final model, from a full backtest, run once a model is finalized, simulating exactly how it would have performed making real sequential forecasts across the entire available history, retrained at each step exactly as it would be in production. The two serve different purposes: cross-validation informs model selection and hyperparameter choices efficiently across several folds; a full backtest is slower but gives a single, comprehensive picture of exactly how the finalized model's actual predictions would have tracked reality week by week, including how errors compound or self-correct over consecutive forecasts in a way an averaged cross-validation score can't show on its own.
- Use walk-forward cross-validation during model development and comparison — it's faster and gives a reliable average estimate across several folds.
- Run a full backtest once on the finalized model before deployment, as a final sanity check that also surfaces specific periods where the model struggled, not just an averaged number across all periods combined.
- A backtest showing consistent failure around a specific recurring period — a holiday season, an end-of-quarter spike — is a concrete, actionable signal a single averaged cross-validation score would hide entirely inside its overall mean, since an average across all folds smooths over exactly the kind of localized, recurring weakness that matters most for a business making real decisions from the forecast.
No comments yet.
Be the first visitor to add a thoughtful comment on this article.