ml-models / Specialized learning / Time series core

Time series

A time series is data whose order is part of the meaning: this month depends on last month, and the model will be used on months that have not happened yet. This page splits a series into trend, seasonality and noise, reads its autocorrelation, builds lag and calendar features, and shows why only time-ordered validation gives an honest score. It closes with where ARIMA, gradient boosting on lags and LSTMs fit in.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Classical decomposition
  5. Autocorrelation
  6. Turning a series into a table
  7. Classical models
  8. Time-ordered validation
  9. Worked example
  10. Implementation
  11. Tradeoffs
  12. Connections
  13. Read first
  14. Read next
  15. References

Problem

Sales, server load, energy demand, sensor readings and prices all arrive as one value per time step, and the question is almost always the same: what will the next few values be? Two things make this different from the rows-and-columns problems on the rest of this site. The rows are not independent, since neighbouring values are similar, and the future is not drawn from the same shuffled pool as the past. A model can only use what was known at the moment of the forecast, and it is judged on a stretch of time it never saw.

Ignoring either fact produces models that look excellent in a notebook and fail in production. The standard cross-validation recipe shuffles rows, which lets the model peek at the days on either side of each test day. Features computed over the whole series, such as a mean or a scaler, carry information backwards from the future, which is a form of data leakage.

Intuition

Most business and physical series are a few simple pieces added together: a slow trend, a repeating seasonal pattern with a known period (12 for monthly data with a yearly cycle, 7 for daily data with a weekly one) and a remainder that is noisy but not independent, because a shock this month fades over the next few. Forecasting is mostly about finding those pieces and carrying them forward. A model that captures the trend and the season leaves only the remainder, and the best it can do there is use how quickly shocks fade.

The validation rule follows from how the model will be used: train on everything up to a point in time, test on what comes after, and repeat for several cut-off points. That is all TimeSeriesSplit does.

A monthly series with its trend and a forecast for the last year 96 monthly values built from level 100.0, a trend of 0.5 per month, a 12-month sine of amplitude 10.0 and AR(1) noise with coefficient 0.6. The 2×12 centred moving average follows the trend and is undefined for the first and last six months. The trend plus Fourier model, fitted on months 0 to 83, forecasts months 84 to 95 with mean absolute error 2.72. 0 12 24 36 48 60 72 84 96 observed 2×12 moving average (trend) trend + Fourier forecast month →

The worked example's series. The shaded band is the last test year. The forecast was fitted only on the months to its left.

Mechanics

Classical decomposition

The additive model writes each value as yt = Tt + St + Rt, trend plus season plus remainder. With period m, classical decomposition estimates them in three steps:

  1. Trend. A centred moving average over one full period cancels the season. For even m the window has m + 1 points with the two ends weighted ½, the "2×m-MA": T̂t = (½yt−6 + yt−5 + … + yt+5 + ½yt+6) / 12 It is undefined for the first and last m/2 points.
  2. Season. Average the detrended values yt − T̂t separately for each position in the cycle (every January, every February, …), then shift the m averages so they sum to zero.
  3. Remainder. R̂t = yt − T̂t − Ŝt.

If the seasonal swing grows with the level, the multiplicative model y = T × S × R applies instead, or the additive one on log y. STL (Cleveland et al., 1990) replaces the moving averages with loess smoothers, which handles the ends of the series and lets the season change slowly over time.

Autocorrelation

The sample autocorrelation at lag k measures how much the series resembles itself shifted by k steps:

rk = Σt=1..n−k (yt − ȳ)(yt+k − ȳ) / Σt=1..n (yt − ȳ)²

For white noise, about 95% of the rk fall inside ±1.96/√n. A slowly decaying ACF means a trend; a bump at lag m means a season; a remainder whose ACF is still outside the band at small lags means there is short-term structure a model can still use.

Autocorrelation of the series and of its remainder Two bar charts for lags 1 to 24. The raw series has autocorrelation 0.94 at lag 1, falls to 0.52 at lag 6 and rises again to 0.64 at lag 12, never leaving the positive side because of the trend. The remainder after decomposition has 0.41 at lag 1, outside the band ±0.21, and is small after that. observed series 1 0 −1 1 6 12 24 remainder 1 0 −1 1 6 12 24 lag (months). Shaded: ±1.96/√n. Lags 12 and 24 highlighted.

Left: the trend keeps every lag positive and the season adds a wave with period 12. Right: what decomposition leaves is close to noise, except at lag 1, where the AR(1) shocks still carry over.

Turning a series into a table

Most machine learning models need rows of features and a target. A forecast at time t for horizon h may use only values up to t, so the usual features are:

  • Lags: yt, yt−1, …, and the seasonal lag yt+h−m, whenever h ≤ m.
  • Rolling statistics over past windows (mean, max, standard deviation), computed so that the window ends at t, never after it.
  • Calendar features: a time index for the trend and the month, weekday or holiday flags for the season. Fourier terms sin(2πkt/m), cos(2πkt/m) for k = 1…K encode a smooth season with 2K columns instead of m − 1 dummies.
  • Known future inputs such as planned promotions or the weather forecast, which are available at forecast time.

For several steps ahead there are two main strategies. A recursive model predicts one step, feeds that prediction back in as a lag, and repeats, so its errors compound. A direct model is trained separately for each horizon h, using only features known at t. The "trend + lag 12" model below is direct for every h ≤ 12, because yt−12 is always in the past.

Classical models

An autoregressive model AR(p) predicts yt = c + φ1yt−1 + … + φpyt−p + εt. A moving-average model MA(q) uses past noise terms instead: yt = c + εt + θ1εt−1 + … + θqεt−q. Both assume the series is stationary, meaning its mean and autocorrelations do not drift over time. ARIMA(p, d, q) differences the series d times (yt − yt−1) to remove a trend first, and seasonal ARIMA adds seasonal differences yt − yt−m and seasonal AR and MA terms. Box and Jenkins set out the iterative recipe still used for these models: identify p, d and q from the autocorrelations, estimate the coefficients, then check that the residuals look like noise. Exponential smoothing (simple, Holt's trend, Holt-Winters seasonal) is the other classical family: forecasts are weighted averages of the past with weights that decay geometrically. Neither is in PyTorch or scikit-learn; they live in statistics packages.

Time-ordered validation

TimeSeriesSplit(n_splits, test_size, gap, max_train_size) places n_splits test blocks of test_size consecutive samples at the end of the data. The first starts at n − n_splits × test_size, and each training set is everything before its test block. gap drops that many samples between training and test, for features whose windows would otherwise straddle the boundary. max_train_size turns the growing window into a sliding one. Unlike KFold, there is no shuffle option. The class assumes the samples are equally spaced and already in time order.

TimeSeriesSplit folds compared with shuffled K-fold Four rows for TimeSeriesSplit on 96 months with test_size 12: test blocks start at months 48, 60, 72, 84, and each training block is every month before them. Below, the first fold of the worked example's shuffled 5-fold cross-validation, whose test months are scattered among training months, so every test month has a training month next to it. fold 1 fold 2 fold 3 fold 4 K-fold outlined: train, filled: test. The K-fold row is the first fold of the worked example's shuffled split.

TimeSeriesSplit always tests on the future. Shuffled K-fold interleaves training and test months.

ComponentShape/ParamsMeaningSource
TimeSeriesSplit n_splits=5, max_train_size=None, test_size=None, gap=0 Growing-window, time-ordered folds. test_size defaults to n_samples // (n_splits + 1). _split.py 1116-1327, parameters 1145-1164
TimeSeriesSplit._split X (n_samples, n_features) Test blocks start at range(n − n_splits·test_size, n, test_size), train_end = test_start − gap, and the error checks for too many splits. _split.py 1275-1327
TimeSeriesSplit.__init__ passes shuffle=False to the base class No shuffling, by construction. _split.py 1239-1243
KFold n_splits=5, shuffle=False, random_state=None Contiguous folds by default; with shuffle=True, test samples are scattered through time. _split.py 437-530
nn.LSTM(batch_first) default False With True, input and output are (N, L, H) instead of (L, N, H); hidden and cell states keep (layers, N, H). rnn.py 877-880
nn.LSTM inputs input (N, L, Hin), optional (h0, c0) each (D·num_layers, N, H) A batch of N windows of L past steps with Hin features each. States default to zeros. rnn.py 887-913
nn.LSTM outputs output (N, L, D·Hout), (hn, cn) A forecasting head usually reads output[:, −1] or hn[−1], the state after the last observed step. rnn.py 915-930
LSTM.forward input, hx=None Handles packed, batched and unbatched input, then calls the fused LSTM kernel. rnn.py 1082-1204

The scikit-learn docstring's own example, 12 samples with n_splits=3 and test_size=2, gives training sets ending at 5, 7, 9 and test blocks 6–7, 8–9, 10–11. With gap=2 the training sets shrink to end at 3, 5, 7. This page's time_series_split helper reproduces both and is tested against them.

Why does TimeSeriesSplit have no shuffle parameter while KFold does?

Shuffling would put training samples after test samples, so the model would be scored on the past using the future. The point of the splitter is that every test block comes strictly after its training block (and after the gap).

Worked example

The series. 96 months, yt = 100.0 + 0.5t + 10.0 sin(2πt/12) + ut, where ut = 0.6ut−1 + εt and εt ~ N(0, 2.0²), drawn with seed 7. The first twelve values are (99.5, 106.2, 109.6, 110.9, 108.4, 105.7, 104.2, 100.0, 98.3, 96.8, 98.5, 102.2).

Decomposition. Seasonal indices from the 2×12 moving average, month 0 first. They sum to zero.

Month01234567891011
Ŝ−0.344.007.6410.287.695.010.56−5.51−8.51−9.13−7.32−4.36
true 10.0 sin(2πt/12)0.005.008.6610.008.665.000.00−5.00−8.66−10.00−8.66−5.00

The estimates are close to the true seasonal shape but not exact: each month's index averages only 7 detrended values, and the AR(1) noise does not average out over so few. The remainder's lag-1 autocorrelation is 0.41, outside ±0.21, so it is not white noise. The noise process used φ = 0.6, but the remainder is not that noise itself: the moving average and the seasonal averages absorb part of it, so its autocorrelation need not equal φ.

Four forecasters, four folds. TimeSeriesSplit with n_splits=4 and test_size=12. Each model is fitted on the training months and forecasts the whole next year at once, horizons 1 to 12. MASE divides the MAE by the in-sample MAE of the one-step naive forecast, mean |yt − yt−1| over the training months, so a value below 1 beats that benchmark.

  • naive: every month equals the last training value.
  • seasonal naive: every month equals the same month one year earlier.
  • trend + Fourier: least squares on [1, t/n] plus 2 sine–cosine pairs, 6 columns.
  • trend + lag 12: least squares on [1, t/n, yt−12], a direct model.
ModelMAE, test 48–59MAE, test 60–71MAE, test 72–83MAE, test 84–95Mean MAEMean RMSEMean MASE
naive10.998.525.807.518.219.402.36
seasonal naive7.284.647.166.876.497.411.87
trend + Fourier2.082.532.852.722.542.970.73
trend + lag 122.343.673.135.293.614.321.05

Seasonal naive beats naive on average (6.49 against 8.21) but not on every fold, because it repeats last year's level and misses about 6 units of yearly growth. Trend + Fourier wins with MASE 0.73: its form matches how the data were made. The lag-12 model learns the season from data rather than assuming a sine, which costs it here (3.61) because yt−12 carries last year's noise into every forecast. The per-fold spread is part of the result. Looking only at the last fold would hide that the ranking of the two naive methods flips from fold to fold.

Leakage from shuffling. A deliberately useless model: predict yt as the training value at the nearest month. Scored two ways on the same data:

ValidationMAE of nearest-month model
shuffled 5-fold (seed 3)3.53
TimeSeriesSplit, 4 × 128.21

Shuffled K-fold scores it at 3.53, better than every forecaster above except trend + Fourier, because the month next to each test month is almost always in the training set. Under TimeSeriesSplit the nearest training month is always the last one, so the model becomes the naive forecast and scores exactly its 8.21. Only the second number says anything about next year.

Implementation

import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.model_selection import TimeSeriesSplit, cross_val_score

df = pd.DataFrame({"y": y}, index=pd.period_range("2017-01", periods=len(y), freq="M"))
for lag in (1, 2, 12):
    df[f"lag_{lag}"] = df["y"].shift(lag)            # only past values
df["roll_mean_3"] = df["y"].shift(1).rolling(3).mean()   # window ends at t - 1
df["month"] = df.index.month
df = df.dropna()

X, target = df.drop(columns="y"), df["y"]
cv = TimeSeriesSplit(n_splits=4, test_size=12, gap=0)
scores = cross_val_score(HistGradientBoostingRegressor(), X, target, cv=cv,
                         scoring="neg_mean_absolute_error")
# lag_1 makes this a one-step-ahead model. For a 12-month horizon use only lags >= 12,
# or forecast recursively and feed predictions back into lag_1.


import torch
from torch import nn

class WindowForecaster(nn.Module):
    def __init__(self, hidden=32, horizon=12):
        super().__init__()
        self.lstm = nn.LSTM(input_size=1, hidden_size=hidden, batch_first=True)
        self.head = nn.Linear(hidden, horizon)       # direct: all horizons at once

    def forward(self, window):                       # window: (N, L, 1), scaled
        output, (h_n, c_n) = self.lstm(window)       # output: (N, L, hidden)
        return self.head(output[:, -1])              # (N, horizon)

The pandas shift calls are where leakage is usually introduced or prevented: every feature for row t must be computable at t. The scaler for the LSTM's inputs, like any preprocessing, is fitted on the training fold only. Tree ensembles such as gradient boosting cannot extrapolate a trend beyond the range of targets they were trained on, so a common pattern is to difference or detrend first and model what remains. An LSTM reads a fixed window of L past steps; with batch_first=True the batch of windows is (N, L, features).

Tradeoffs

  • Simple baselines first. Naive and seasonal naive forecasts cost nothing and are hard to beat on short or noisy series. MASE against a naive benchmark makes "better than trivial" a number.
  • Classical versus learned. ARIMA and exponential smoothing fit one series with a handful of parameters and give prediction intervals. Gradient boosting and neural models need many related series or long histories to pay off, and they can share what they learn across series.
  • Growing versus sliding windows. A growing window (the default) uses all history. max_train_size keeps only recent data, which helps when the process changes over time and hurts when it does not.
  • The gap. If a feature uses a window of w past steps, or the target is aggregated over a future window, set gap so no training row overlaps the test period.
  • Few, correlated folds. Later folds share most of their training data and test on adjacent periods, so fold scores are not independent and their spread understates the true uncertainty.
  • Is K-fold ever fine? Bergmeir and Benítez (2012) compared validation schemes on synthetic and real series and recommended blocked cross-validation (contiguous folds, no shuffle) as the standard. Shuffled folds remain wrong for models like the nearest-month one above, which can read neighbours of the test points.
  • Metrics. MAE is in the series' units, RMSE punishes large misses, and percentage errors such as MAPE explode near zero. MASE (Hyndman and Koehler, 2006) is scale-free and comparable across series.
A model uses a 7-day rolling mean of y ending at day t as a feature, and is evaluated on day t + 1 with TimeSeriesSplit and gap=0. Is there leakage?

No: the window ends at t, which is before the target at t + 1, and TimeSeriesSplit keeps every test day after every training day. There would be leakage if the rolling mean were centred on t, or computed with the window ending at t + 1, since then it would contain the target.

Connections

Read first

Read next

No page builds on this one yet.

References

  • Box and Jenkins. Time Series Analysis: Forecasting and Control. Holden-Day, 1970.
  • Cleveland, Cleveland, McRae and Terpenning. STL: A Seasonal-Trend Decomposition Procedure Based on Loess. Journal of Official Statistics 6, 3–73, 1990.
  • Hyndman and Koehler. Another look at measures of forecast accuracy. International Journal of Forecasting 22(4), 679–688, 2006. doi:10.1016/j.ijforecast.2006.03.001
  • Bergmeir and Benítez. On the use of cross-validation for time series predictor evaluation. Information Sciences, 2012. doi:10.1016/j.ins.2011.12.028
  • Hyndman and Athanasopoulos. Forecasting: Principles and Practice, 3rd edition. OTexts, 2021. otexts.com/fpp3
  • scikit-learn 1.9.1: sklearn/model_selection/_split.py. PyTorch v2.15.0: torch/nn/modules/rnn.py.