,

Rolling windows and lag features: time series into supervised learning without leaking the future

LEARN · FEATURE ENGINEERING & THE SCIKIT-LEARN TOOLKIT

A time series becomes a supervised-learning table when each row represents a prediction decision:

  • Target: the value to predict at timestamp t.

  • Features: information available before that prediction is made.

  • Training example: one timestamp, one feature vector, one target.

The transformation is simple. The timing discipline is not.

A model can score brilliantly while learning from tomorrow’s data. The most common causes are:

  • Rolling statistics that include the current target or future rows.

  • Centered rolling windows.

  • Random train/test splitting.

  • Shuffled cross-validation.

  • Features calculated from data published after the prediction deadline.

  • Multi-step forecasts that quietly use actual intermediate outcomes.

The core rule is:

A feature for timestamp t must be reproducible using only information available at the real-world decision time for t.

Start with an explicit forecasting contract

Before writing feature code, define three timestamps:

  1. Decision time: when the prediction is generated.

  2. Target time: when the predicted event occurs.

  3. Availability time: when each input becomes usable.

Suppose an energy operator predicts hourly demand at the start of each hour. For the row labeled 14:00:

  • The target is demand during the 14:00 hour.

  • Demand through 13:00 may be available.

  • Demand during 14:00 is not available.

  • Demand from 15:00 is obviously not available.

A valid 24-hour rolling mean is therefore:

rolling_mean_24(t) = mean(y[t-24], ..., y[t-1])

It must not include y[t].

This sounds obvious, but pandas makes the unsafe version extremely easy to write.

Build a neutral example time series

The following code creates synthetic hourly energy usage with daily and weekly seasonality, noise, and a late-year regime change.

The regime change is useful because real systems rarely remain perfectly stationary. A random split can train on examples from both sides of the change, even when evaluating earlier timestamps.

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)

index = pd.date_range(
    start="2024-01-01",
    periods=24 * 366,
    freq="h",
)

hour = index.hour.to_numpy()
day_of_week = index.dayofweek.to_numpy()
elapsed_days = np.arange(len(index)) / 24

regime_change = (
    index >= pd.Timestamp("2024-10-01")
).astype(float)

demand = (
    120
    + 18 * np.sin(2 * np.pi * hour / 24)
    + 8 * (day_of_week < 5)
    + 6 * np.sin(2 * np.pi * elapsed_days / 365.25)
    + 15 * regime_change
    + 4 * regime_change * np.sin(2 * np.pi * hour / 24)
    + rng.normal(0, 4, len(index))
)

df = pd.DataFrame(
    {"demand": demand},
    index=index,
)

df.index.name = "timestamp"

print(df.head())
print(df.shape)

Each row’s target is the demand value at that timestamp. We will assume predictions are generated immediately before the hour begins, using observed demand only through the previous hour.

Create lag features safely

A lag feature moves historical observations forward so they can describe a later target.

For example:

  • lag_1 at 14:00 contains demand from 13:00.

  • lag_24 contains demand from 14:00 yesterday.

  • lag_168 contains demand from the same hour one week earlier.

Use shift() before calculating rolling statistics:

def build_features(frame: pd.DataFrame) -> pd.DataFrame:
    out = frame.copy()

    history = out["demand"].shift(1)

    out["lag_1"] = out["demand"].shift(1)
    out["lag_24"] = out["demand"].shift(24)
    out["lag_168"] = out["demand"].shift(168)

    out["mean_24"] = history.rolling(
        window=24,
        min_periods=24,
    ).mean()

    out["std_24"] = history.rolling(
        window=24,
        min_periods=24,
    ).std()

    out["mean_168"] = history.rolling(
        window=168,
        min_periods=168,
    ).mean()

    out["hour_sin"] = np.sin(
        2 * np.pi * out.index.hour / 24
    )
    out["hour_cos"] = np.cos(
        2 * np.pi * out.index.hour / 24
    )

    out["dow_sin"] = np.sin(
        2 * np.pi * out.index.dayofweek / 7
    )
    out["dow_cos"] = np.cos(
        2 * np.pi * out.index.dayofweek / 7
    )

    return out.dropna()


features = build_features(df)

feature_columns = [
    "lag_1",
    "lag_24",
    "lag_168",
    "mean_24",
    "std_24",
    "mean_168",
    "hour_sin",
    "hour_cos",
    "dow_sin",
    "dow_cos",
]

print(features[feature_columns + ["demand"]].head())

The important line is:

history = out["demand"].shift(1)

It creates a historical series whose value at timestamp t is the observation from t-1. Every rolling operation built from history is therefore anchored strictly before the target.

Current pandas rolling operations calculate statistics over the selected window, while shift() provides the explicit temporal offset. Keeping those operations separate makes the availability rule visible in code.

The classic leak: unshifted rolling windows

This feature looks reasonable:

df["bad_mean_24"] = (
    df["demand"]
    .rolling(window=24, min_periods=24)
    .mean()
)

But when predicting demand[t], the rolling window includes demand[t].

The feature contains part of its own target. That is target leakage.

The safe equivalent is:

df["good_mean_24"] = (
    df["demand"]
    .shift(1)
    .rolling(window=24, min_periods=24)
    .mean()
)

A reliable visual rule is:

historical_values = series.shift(forecast_horizon)
feature = historical_values.rolling(window).aggregation()

For a one-step forecast, forecast_horizon is usually 1.

The more obvious leak: centered rolling windows

Centered windows are useful for smoothing historical charts. They are usually invalid as forecasting features.

df["very_bad_centered_mean"] = (
    df["demand"]
    .rolling(
        window=24,
        center=True,
        min_periods=24,
    )
    .mean()
)

With center=True, the timestamp is positioned near the middle of the window. The value at t therefore uses observations from both before and after t.

That means tomorrow helps explain today.

Centered rolling statistics can be legitimate for:

  • Offline signal denoising.

  • Descriptive visualization.

  • Retrospective anomaly labeling.

  • Analysis where the full sequence is already known.

They are not legitimate inputs for a live forecast unless the future values inside the window are genuinely known in advance.

Split by time, not by row count after shuffling

A forecasting test set should simulate deployment:

  1. Train on the past.

  2. Predict a later period.

  3. Never train on timestamps after the evaluation timestamp.

A fixed chronological holdout is the simplest honest test.

from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error

cutoff = pd.Timestamp("2024-10-01")

train = features.loc[features.index < cutoff]
test = features.loc[features.index >= cutoff]

X_train = train[feature_columns]
y_train = train["demand"]

X_test = test[feature_columns]
y_test = test["demand"]

model = HistGradientBoostingRegressor(
    max_iter=250,
    learning_rate=0.05,
    max_leaf_nodes=31,
    random_state=42,
)

model.fit(X_train, y_train)
predictions = model.predict(X_test)

mae = mean_absolute_error(y_test, predictions)

print(f"Train end: {train.index.max()}")
print(f"Test start: {test.index.min()}")
print(f"Test MAE: {mae:.3f}")

The boundary assertions are worth keeping:

assert train.index.max() < test.index.min()
assert train.index.is_monotonic_increasing
assert test.index.is_monotonic_increasing

They will not catch every leak, but they prevent accidental overlap and sorting mistakes.

Why shuffled cross-validation lies

Standard shuffled cross-validation assumes rows are exchangeable. Time series rows are not.

This is dangerous:

from sklearn.model_selection import KFold

random_cv = KFold(
    n_splits=5,
    shuffle=True,
    random_state=42,
)

A validation row from February may be evaluated using a model trained partly on October and December.

The model is not literally given future target values as columns, but its parameters were learned from future regimes, seasonal patterns, category behavior, and distributions. The evaluation no longer represents a model deployed in February.

Even train_test_split() shuffles by default unless shuffle=False is provided. Current scikit-learn documentation explicitly describes its default as shuffle=True, and the official lagged-feature forecasting example shows that a randomly shuffled split can produce overly optimistic error estimates.

You can demonstrate the difference:

from sklearn.model_selection import (
    KFold,
    TimeSeriesSplit,
    cross_val_score,
)

X = features[feature_columns]
y = features["demand"]

random_cv = KFold(
    n_splits=5,
    shuffle=True,
    random_state=42,
)

time_cv = TimeSeriesSplit(
    n_splits=5,
    test_size=24 * 28,
    gap=24,
)

random_mae = -cross_val_score(
    model,
    X,
    y,
    cv=random_cv,
    scoring="neg_mean_absolute_error",
)

time_mae = -cross_val_score(
    model,
    X,
    y,
    cv=time_cv,
    scoring="neg_mean_absolute_error",
)

print(f"Random CV MAE: {random_mae.mean():.3f}")
print(f"Time CV MAE:   {time_mae.mean():.3f}")

On this seeded synthetic series, the random folds usually appear better because every fold contains observations from multiple regimes.

The lower score is not evidence of a better forecasting system. It is evidence of an easier test.

Use rolling-origin validation

TimeSeriesSplit keeps training observations before validation observations. Successive training sets grow over time, which resembles repeated historical deployment. Its gap parameter removes a specified number of samples between the training and validation segments.

For hourly data:

time_cv = TimeSeriesSplit(
    n_splits=5,
    test_size=24 * 28,
    gap=24,
)

This configuration creates:

  • Five evaluation periods.

  • Twenty-eight days per test fold.

  • A twenty-four-hour gap before each test fold.

A gap is useful when:

  • Features arrive with reporting delays.

  • Labels overlap across neighboring samples.

  • The target spans a future interval.

  • Operational pipelines need a safety buffer.

Remember that gap is measured in rows, not wall-clock duration. It represents 24 hours only when observations are hourly, complete, ordered, and equally spaced.

Match feature construction to the forecast horizon

One-step forecasting is the easiest case because the actual value from the previous step may be available before the next prediction.

Multi-step forecasting changes the rules.

Suppose you generate a 24-hour forecast at midnight. At that decision time:

  • lag_1 is known for 01:00.

  • The true 01:00 demand is not known when constructing the 02:00 forecast.

  • The true 02:00 demand is not known when constructing the 03:00 forecast.

You cannot update lag features with actual future observations during backtesting unless production will also receive those observations before each prediction.

Use one of three explicit strategies:

  • Direct: train a separate model for each horizon.

  • Recursive: feed earlier predictions into later lag positions.

  • Multi-output: predict the complete horizon in one call.

A backtest that fills later rows with actual intermediate targets is evaluating an imaginary system.

Treat external data as point-in-time data

Weather, prices, inventory, promotions, and telemetry can leak even when lag calculations are correct.

Ask:

Was this exact value available at prediction time?

For example, using the final observed temperature to evaluate a day-ahead demand forecast leaks information. Production would have used the weather forecast available at the decision time, not the later observation.

The same problem appears with:

  • Corrected financial records.

  • Revised economic indicators.

  • Orders updated after cancellation.

  • Product categories assigned retrospectively.

  • Incident severity labels finalized after resolution.

  • Aggregates recomputed from the latest database state.

Store or reconstruct data by availability timestamp, not only by event timestamp.

For point-in-time joins, a backward as-of join is often appropriate:

joined = pd.merge_asof(
    predictions.sort_values("decision_time"),
    external_data.sort_values("available_at"),
    left_on="decision_time",
    right_on="available_at",
    direction="backward",
    allow_exact_matches=True,
)

The join key must represent when the record became usable. A business-event timestamp is not sufficient when records are delayed or revised.

Cherry on the cake: leakage changed measured error by 20.5%

A December 2025 forecasting study examined a subtle sequence-building failure: constructing time-series input/output windows before partitioning the data.

That workflow allowed sequence boundaries to cross the intended split. In experiments comparing leaky and clean evaluation, the reported difference in RMSE reached 20.5% for 10-fold cross-validation at extended lag settings. Two-way and three-way temporal splits were generally less affected.

The surprising lesson is not that one particular neural network failed.

It is that a pipeline can preserve the visible order of values inside each sequence and still leak through how those sequences are created and assigned to folds.

Split logic is part of the model.

A practical leakage checklist

Before accepting a forecasting result, verify:

  • The target and decision time are explicitly defined.

  • Every feature has an availability rule.

  • Target-derived rolling features start with shift().

  • No forecasting feature uses center=True.

  • Train, validation, and test periods are chronologically ordered.

  • Cross-validation never trains on later timestamps than it validates.

  • Preprocessing is fitted inside each training fold.

  • External data uses historical vintages or availability timestamps.

  • Multi-step evaluation reproduces the production forecasting strategy.

  • A simple naive baseline is evaluated on the same temporal folds.

  • Feature code can run using only data available at the simulated decision time.

Build the honest result first

Lag features and rolling windows are powerful because they let ordinary regression models capture autocorrelation, seasonality, local level, and recent volatility.

They are also one line of code away from seeing the future.

Take one forecasting notebook today and make three changes:

  1. Write down its decision time and forecast horizon.

  2. Replace every target-based rolling feature with shift(horizon).rolling(...).

  3. Rerun evaluation with a chronological holdout or rolling-origin split.

Do not optimize the model again until the honest score is known. A modest metric you can reproduce in production is worth far more than a spectacular result generated with tomorrow’s information.