Everyday Data Science
Latest
Agentic workflows now power a third of surveyed enterprise automationAfrica's AI startup ecosystem posts record funding yearNew benchmark results reshape the coding-agent leaderboardNigeria launches national AI strategy with major investment planRwanda's sovereign AI cloud enters public betaThe future of AI agents: from tools to teammates
ML & Data ScienceTutorial

Your Train-Test Split Is Leaking the Future

A random split can make a forecasting model look brilliant by letting tomorrow influence yesterday. Here is how to test in the order production will happen.

IDIbrahim Denis FofanahData Scientist & AI Researcher10 min read·Model Evaluation · Time Series

Your model predicts next week's demand with a mean absolute error that looks excellent. The dashboard is green. The cross-validation score is stable. Everyone is ready to deploy.

Then the model meets next week.

The error doubles.

The usual explanation is drift, a weak model, or bad luck. Sometimes the real problem happened much earlier: the evaluation let the model learn from records that occurred after the records it was asked to predict.

The model did not forecast the future. Your split quietly gave it access to the future.

Why the familiar split stops working

For ordinary cross-sectional data, random splitting is often sensible. If each row is an independent customer observed at roughly the same moment, shuffling helps create train and test sets drawn from similar populations.

Time-series data is different because order carries information.

Suppose you have hourly demand from January through June and want to predict July. A random split scatters June rows into training and January rows into testing. The model is now evaluated on the past after learning from the future.

It may not see the target value from the test row directly. It does not need to. Future rows reveal later seasonal patterns, newer pricing rules, product launches, changed customer behavior, and updated measurement systems. Your evaluation asks, "Can a model trained across the whole period explain withheld rows from that same period?" Production asks, "Can a model trained up to today predict what happens next?"

Those are different problems.

scikit-learn's own lagged-feature example shows the consequence: evaluation with a shuffled train/test split is overly optimistic, while a time-based split better represents future performance.

Start with the prediction moment

Before choosing a splitter, write down the moment when a prediction is made.

If you forecast tomorrow's orders every night at 11 p.m., ask what data exists at 11 p.m. If a feature arrives the following morning, it cannot be used even if its database column is eventually attached to yesterday's row.

Three clocks matter:

  1. Observation time: when the event happened.
  2. Availability time: when the feature became known to the system.
  3. Prediction time: when the model must produce its answer.

Most leakage arguments look only at observation time. Production cares about availability time.

A hospital outcome recorded on Monday may not be coded until Friday. A chargeback belongs to a January transaction but may arrive in March. A monthly financial total may be revised weeks later. Joining those final values back onto historical rows creates a dataset that is accurate today and impossible to reproduce at the original prediction time.

Leakage often happens before the split

Even a chronological split can fail if feature engineering touches the full dataset first.

The classic example is a rolling mean:

# Wrong: the current target contributes to its own feature
sales["rolling_7d"] = sales["orders"].rolling(7).mean()

For a next-day forecast, the window must stop before the value being predicted:

# Right: shift first, then calculate from prior observations
sales["rolling_7d"] = (
    sales["orders"]
    .shift(1)
    .rolling(7)
    .mean()
)

That order—shift, then roll—is easy to miss and expensive to miss.

The same rule applies to preprocessing. If you fit an imputer, scaler, encoder, feature selector, or dimensionality-reduction step on the full dataset, information from the test period shapes the training representation. scikit-learn's guidance is explicit: split first, fit transformations only on training data, and use a Pipeline so cross-validation repeats that separation correctly inside every fold.

Common leakage paths include:

  • calculating aggregates with future rows;
  • filling missing values with a global median from all dates;
  • choosing features after looking at test-period performance;
  • encoding categories using values that first appear in the future;
  • joining labels or outcomes that were recorded after prediction time;
  • normalizing with statistics computed across train and test;
  • selecting a cutoff after inspecting the final holdout.

The dangerous part is that the code runs. Leakage rarely throws an error. It produces unusually good metrics.

The correct mental model: rehearse deployment

A useful evaluation is a rehearsal.

If the production system will train on everything through March and forecast April, one validation fold should do exactly that. The next fold can train through April and forecast May. The origin moves forward, but the arrow of time never reverses.

Hyndman and Athanasopoulos call this rolling forecasting origin evaluation. Each test period contains observations later than everything in its corresponding training period. Accuracy is then averaged across multiple historical forecast origins.

There are two common designs.

Expanding window: training begins at a fixed start date and grows with every fold. Use it when old data remains relevant and production retrains on the full history.

Sliding window: training covers only the most recent fixed period. Use it when older behavior becomes stale or production deliberately limits its lookback.

Neither is automatically better. The right one is the one that matches deployment.

A practical scikit-learn pattern

Assume we have hourly demand and want to predict the next hour. We will build lags, create a chronological final holdout, and use TimeSeriesSplit on the remaining development period.

import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
 
# One row per hour, sorted before any time-based feature is created
df = pd.read_parquet("hourly_demand.parquet")
df = df.sort_values("timestamp").reset_index(drop=True)
 
# Features available at the moment of prediction
df["hour"] = df["timestamp"].dt.hour
df["day_of_week"] = df["timestamp"].dt.dayofweek
df["lag_1h"] = df["demand"].shift(1)
df["lag_24h"] = df["demand"].shift(24)
df["mean_24h"] = df["demand"].shift(1).rolling(24).mean()
df["mean_7d"] = df["demand"].shift(1).rolling(24 * 7).mean()
 
features = [
    "hour",
    "day_of_week",
    "lag_1h",
    "lag_24h",
    "mean_24h",
    "mean_7d",
]
 
data = df.dropna(subset=features + ["demand"]).copy()
 
# Keep the last 14 days untouched until every decision is finished
holdout_start = data["timestamp"].max() - pd.Timedelta(days=14)
dev = data[data["timestamp"] < holdout_start]
holdout = data[data["timestamp"] >= holdout_start]
 
X_dev, y_dev = dev[features], dev["demand"]
X_holdout, y_holdout = holdout[features], holdout["demand"]
 
# Each validation block is one week; the 24-hour gap adds separation
cv = TimeSeriesSplit(
    n_splits=5,
    test_size=24 * 7,
    gap=24,
)
 
model = HistGradientBoostingRegressor(random_state=42)
 
cv_mae = -cross_val_score(
    model,
    X_dev,
    y_dev,
    cv=cv,
    scoring="neg_mean_absolute_error",
)
 
print("Fold MAE:", cv_mae)
print("Mean CV MAE:", cv_mae.mean())
 
# Only after model and feature choices are frozen
model.fit(X_dev, y_dev)
holdout_pred = model.predict(X_holdout)
print("Final holdout MAE:", mean_absolute_error(y_holdout, holdout_pred))

This is a template, not a universal recipe. The gap, test size, number of folds, and training-window length should come from the production problem.

What the gap is actually for

TimeSeriesSplit includes a gap parameter that excludes observations between each training block and test block.

A gap is useful when adjacent rows share information or when labels take time to mature. If you build a seven-day rolling feature and place the first test row immediately after the last training row, that is not automatically leakage—the feature may legitimately use the previous seven days. But a gap can make evaluation more conservative when nearby records are nearly duplicates, when sensors overlap, or when operational latency means recent labels would not yet exist.

The gap should represent a real boundary, not a magic number copied from an example.

Ask:

  • How far ahead do we predict?
  • How long until the target becomes known?
  • Do adjacent samples share the same event, user, device, or window?
  • How much recent history is genuinely available at inference time?

For a seven-day-ahead forecast whose label matures after seven days, a seven-day separation may be necessary. For next-hour demand with immediately observed labels, a full-day gap may be conservative rather than required.

Match the metric to the forecast horizon

A one-step-ahead model can look good and still fail the business.

If staffing decisions require a fourteen-day forecast, evaluate fourteen-day forecasts. Errors usually grow with horizon, and recursive models can accumulate their own mistakes. Rolling-origin evaluation should reproduce the same horizon and update frequency used in production.

The test window should also contain the conditions you care about: weekends, month-end, seasonal peaks, promotions, or known reporting cycles. Five folds covering five quiet weeks do not validate a holiday-demand model.

And always compare against a naive baseline. For hourly demand, "same hour yesterday" or "same hour last week" is often stronger than it looks. If the expensive model cannot beat a baseline under honest chronological evaluation, complexity is not a feature.

Panel data needs two boundaries

Many real datasets are both temporal and grouped: transactions by customer, readings by machine, sales by store, or visits by patient.

A time split prevents future-to-past leakage. It does not stop the same entity appearing in both train and test.

That may be correct if production predicts future behavior for known entities. It is wrong if production must generalize to entirely new customers, stores, or devices. In that case you need a design that holds out both future time and unseen groups.

Name the deployment question precisely:

  • Future events for existing entities: split by time.
  • New entities at roughly the same time: split by group.
  • Future events for new entities: enforce both boundaries.

There is no single splitter that answers all three questions because they are not the same prediction problem.

What would make me wrong

Random splitting is not universally wrong just because a timestamp exists.

If rows are genuinely exchangeable, the target is not a future outcome, features are available at the same moment, and deployment samples from the same stable population, a random split may be appropriate. Examples include classifying independently collected images or analyzing a one-time cross-sectional survey.

Time-based evaluation can also mislead. One unlucky holdout period may contain a shock that makes every model look bad. An expanding window can overvalue ancient data that production would ignore. A gap that is too large can discard useful data and create an unrealistically hard test. And TimeSeriesSplit assumes samples are ordered and comparably spaced when fold metrics are meant to cover similar durations.

The principle is not "always use TimeSeriesSplit." The principle is "make evaluation reproduce the information boundaries and sequence of deployment."

Key takeaways

  1. A random split answers the wrong question when production predicts later events from earlier data.
  2. Feature availability matters more than the timestamp attached to the row.
  3. Shift before rolling, and fit every learned transformation inside the training fold.
  4. Use expanding or sliding windows according to how the production model retrains.
  5. Match validation horizon, gap, and test window to the operational decision.
  6. Keep a final chronological holdout untouched until model choices are frozen.
  7. For panel data, decide whether time, entity, or both must be held out.
  8. The best evaluation is a rehearsal of deployment, not the split that produces the highest score.

Sources

Share

Found this useful? Passing it on to someone who builds is the best way to help the publication grow.