Feature engineering for time series
Turn a time series into an ordinary table of lags, rolling summaries and calendar columns, so any regression model can forecast it — as long as no column peeks at the future.
- 18 min read
- 3 reading levels
- Updated
Read these first
On this page 10
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
The short answer
Feature engineering turns a time series into an ordinary table. Each row describes one day, using only facts known before that day started.
The analogy you have already lived
Watch a shopkeeper decide the bread order for tomorrow.
He does not stare at a chart. He writes a small slip: yesterday I sold thirty. Same day last week I sold thirty-four. Tomorrow is a Saturday. There is a wedding in the lane.
Four facts on one slip, and then he decides a number.
That slip is a feature row. Feature engineering means writing one such slip for every day in your history. A model then learns the decision rule from thousands of slips.
Why this matters so much
Once your data is a plain table of rows and columns, the entire toolbox from machine learning opens up. Random forests. XGBoost. Anything you already know.
This is not a beginner shortcut. Retail forecasting competitions with real supermarket data have been won by exactly this approach. A table of hand-built columns, fed to a tree model. It beat the specialist forecasting methods.
A table can hold things a pure time series method cannot. The price today. Whether there was a promotion. Whether it rained. Which shop, and which product. Real forecasting problems are full of such facts.
The four kinds of columns
Lags — what the series did before. Yesterday's value. The value seven days ago. The value one year ago. These carry the memory.
Rolling summaries — what the recent stretch looked like. The average of the last seven days. The highest value in the last month. These smooth away single odd days.
Calendar columns — what kind of day it is. Day of the week. Month. Whether it is a weekend. Whether it is a public holiday, or the day before one.
Outside facts — everything else you know. Price, promotion, temperature, whether the shop was open.
How it works
original series the table you build
date units date lag1 lag7 avg7 weekday is_holiday units
1 Jan -> 31 8 Jan 34 31 33.4 2 0 36
2 Jan -> 28 9 Jan 36 28 34.1 3 0 32
3 Jan -> 30 10 Jan 32 30 33.7 4 0 33
... ...
\____________________________/ \___/
things known BEFORE the day answerEvery column on the left of that line must have been knowable before the day began. The column on the right is what you are trying to predict.
The one rule that decides everything
A feature may only use information that existed before the moment you are predicting.
Break that rule and your model reads the answer. Your test score will be beautiful. Your live system will be terrible, and the gap will confuse you for weeks.
The mistake is rarely dramatic. It looks like this: you compute a seven-day average across the whole series and add it as a column. That average, on any given row, includes that row's own value. The model learns to work backwards from it.
The fix is one small step: shift the series back by one before summarising it. Take yesterday's value and everything older, then average.
The developer section builds the same model twice, once with a peeking column and once without. The reported error was 37. The real error in production was 83. That gap is entirely manufactured by one careless column.
The one weakness of this approach
Tree models cannot go outside the range they were trained on.
Show a tree model daily sales that grew from 900 to 1500, and it can never predict 1600. It has no notion of continuing a line — it only knows how to sort rows into groups it has already seen.
For a growing business, that is a serious problem, and it gets worse the further ahead you forecast.
The usual fix is to remove the growth first. Fit a plain straight line through the history, subtract it, and let the tree model handle whatever is left. Then add the line back at the end. The developer section measures how much that helps.
Where you have already seen it
- Supermarket ordering systems predict tomorrow per product per store from tables exactly like this.
- Ride-hailing surge uses hour, weekday, weather, recent demand.
- Electricity load forecasts combine yesterday's load, temperature and holiday flags.
- Restaurant staffing tools use last week's covers, the day of week, and local events.
Remember this
- Rewrite the series as a table: lags, rolling summaries, calendar columns, outside facts.
- Every column must be knowable before the day it describes.
- Tree models cannot extrapolate a trend — remove the trend first.
What to learn next
- LSTMs for forecasting — the neural alternative, and when it is worth the trouble.
- Evaluating a forecast — measuring these models without fooling yourself.
- XGBoost — the model family that wins most tabular forecasting competitions.
Developer — Code and libraries.
Setup
pip install numpy pandas scikit-learnEverything runs on CPU in a few seconds. No dataset download.
The feature table, small enough to check by hand
Fourteen rows is enough to verify every cell yourself. Do that once and you will never mis-align a lag again.
import pandas as pd
days = pd.date_range("2025-01-01", periods=14, freq="D") # 1 Jan 2025 was a Wednesday
units = pd.Series([31, 28, 30, 52, 49, 29, 34, 36, 32, 33, 55, 51, 30, 35],
index=days, name="units")
f = pd.DataFrame({"units": units})
f["lag_1"] = units.shift(1) # yesterday
f["lag_7"] = units.shift(7) # the same weekday last week
f["roll_3"] = units.shift(1).rolling(3).mean() # shift FIRST, then roll
f["dow"] = units.index.dayofweek # 0 = Monday, 5 = Saturday, 6 = Sunday
f["is_weekend"] = (f.dow >= 5).astype(int)
print(f.round(2).to_string())units lag_1 lag_7 roll_3 dow is_weekend 2025-01-01 31 NaN NaN NaN 2 0 2025-01-02 28 31.0 NaN NaN 3 0 2025-01-03 30 28.0 NaN NaN 4 0 2025-01-04 52 30.0 NaN 29.67 5 1 2025-01-05 49 52.0 NaN 36.67 6 1 2025-01-06 29 49.0 NaN 43.67 0 0 2025-01-07 34 29.0 NaN 43.33 1 0 2025-01-08 36 34.0 31.0 37.33 2 0 2025-01-09 32 36.0 28.0 33.00 3 0 2025-01-10 33 32.0 30.0 34.00 4 0 2025-01-11 55 33.0 52.0 33.67 5 1 2025-01-12 51 55.0 49.0 40.00 6 1 2025-01-13 30 51.0 29.0 46.33 0 0 2025-01-14 35 30.0 34.0 45.33 1 0
Check one row before reading on. On January 11, roll_3 is 33.67. That is the average of January 8, 9 and 10 — (36 + 32 + 33) / 3. It does not include January 11 itself. That is what .shift(1) bought.
Also note lag_7 on January 11 is 52.0, the previous Saturday. On a series with a weekly rhythm, lag_7 is usually the single strongest feature you have.
The NaN block at the top is unavoidable. A lag of seven needs seven rows of history. Drop those rows before training; never fill them with zero, which teaches the model that history began at zero.
The leak, measured
Now build the same model twice. One version gets a centred rolling average, which reads three days into the future. The other does not.
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error
rng = np.random.default_rng(31)
n, cut = 1000, 850
days = pd.date_range("2023-01-01", periods=n, freq="D")
s = pd.Series((900 + np.linspace(0, 600, n)
+ np.where(days.dayofweek >= 5, 220, 0)
+ 90 * np.sin(2 * np.pi * days.dayofyear / 365.25)
+ rng.normal(0, 45, n)).round(), index=days, name="units")
def frame(smooth_column):
return pd.DataFrame({"lag_1": s.shift(1), "lag_7": s.shift(7),
"dow": s.index.dayofweek, "doy": s.index.dayofyear,
"smooth": smooth_column}, index=s.index)
leaky = frame(s.rolling(3, center=True).mean()) # yesterday, TODAY and TOMORROW
honest = frame(s.shift(1).rolling(3).mean()) # only days that had already happened
ok = leaky.notna().all(axis=1) & honest.notna().all(axis=1)
leaky, honest, ys = leaky[ok], honest[ok], s[ok]
bad = HistGradientBoostingRegressor(max_iter=300, random_state=0).fit(leaky[:cut], ys[:cut])
good = HistGradientBoostingRegressor(max_iter=300, random_state=0).fit(honest[:cut], ys[:cut])
print("leaky model, scored the leaky way MAE:",
round(mean_absolute_error(ys[cut:], bad.predict(leaky[cut:])), 1), " <- the number you would report")
print("leaky model, given knowable inputs MAE:",
round(mean_absolute_error(ys[cut:], bad.predict(honest[cut:])), 1), " <- what production actually gives you")
print("honest model, honest inputs MAE:",
round(mean_absolute_error(ys[cut:], good.predict(honest[cut:])), 1))leaky model, scored the leaky way MAE: 37.4 <- the number you would report leaky model, given knowable inputs MAE: 82.8 <- what production actually gives you honest model, honest inputs MAE: 55.1
Read those three lines carefully, because this is the entire lesson.
The leaky model reported 37.4. That is the number that goes in the slide deck, and it looks like a strong result. The test set was never touched by the training rows, and the split was chronological. Everything looks correct.
In production it delivers 82.8. More than twice the reported error, and considerably worse than the honest model. The model leaned on a column that told it what tomorrow looked like, and that column cannot exist at prediction time.
The honest model's 55.1 is the truth. Less impressive, and the only number worth acting on.
Leakage is not caught by a chronological split. A chronological split protects you from training on future rows. It does nothing about a column that looked forward inside its own row. You have to audit columns one at a time.
The trend problem, and the fix
Tree models split on thresholds. A threshold learned from data between 900 and 1500 cannot produce 1600. Here is the size of the damage, and the standard remedy.
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error
rng = np.random.default_rng(31)
n = 1000
days = pd.date_range("2023-01-01", periods=n, freq="D")
y = (900 + np.linspace(0, 600, n)
+ np.where(days.dayofweek >= 5, 220, 0)
+ 90 * np.sin(2 * np.pi * days.dayofyear / 365.25)
+ rng.normal(0, 45, n))
s = pd.Series(y.round(), index=days, name="units")
cut = 850
X = pd.DataFrame({
"lag_7": s.shift(7), "lag_14": s.shift(14),
"roll_7": s.shift(1).rolling(7).mean(),
"dow": s.index.dayofweek, "doy": s.index.dayofyear,
}, index=s.index)
ok = X.notna().all(axis=1); X, ys = X[ok], s[ok]
t = np.arange(len(ys)).reshape(-1, 1)
m = HistGradientBoostingRegressor(max_iter=300, random_state=0).fit(X[:cut], ys[:cut])
print("trees on the raw level MAE:", round(mean_absolute_error(ys[cut:], m.predict(X[cut:])), 1))
line = LinearRegression().fit(t[:cut], ys[:cut])
detr = ys - line.predict(t)
m2 = HistGradientBoostingRegressor(max_iter=300, random_state=0).fit(X[:cut], detr[:cut])
pred = m2.predict(X[cut:]) + line.predict(t[cut:])
print("straight line + trees on rest MAE:", round(mean_absolute_error(ys[cut:], pred), 1))
print("baseline seasonal naive MAE:", round(mean_absolute_error(ys[cut:], X.lag_7[cut:]), 1))
print("best achievable, roughly MAE:", round(0.8 * 45, 1))trees on the raw level MAE: 49.1 straight line + trees on rest MAE: 40.6 baseline seasonal naive MAE: 52.9 best achievable, roughly MAE: 36.0
Trees on the raw level scored 49.1, barely better than doing nothing (52.9). All that feature work bought almost no improvement, because the model spent its capacity failing to keep up with a rising level.
Removing the trend first took it to 40.6, close to the 36.0 floor set by the noise in this series. The line handles the growth; the trees handle the weekly and yearly shape.
The 0.8 * 45 floor comes from the fact that the mean absolute value of a normal variable is about 0.8 of its standard deviation. It is a rough but very useful sanity check: if your MAE approaches it, stop optimising.
Other ways to handle the trend, all of which work: model the difference s - s.shift(7) instead of the level (works when growth is steady), use a linear model with tree residuals as above, or use a model that extrapolates natively such as a linear model or ARIMA with the trees for the leftovers.
A checklist of features worth having
s.shift(1); s.shift(7); s.shift(28) # short, weekly, four-weekly memory
s.shift(1).rolling(7).mean() # recent level
s.shift(1).rolling(7).std() # recent volatility
s.shift(1).rolling(28).max() # recent ceiling
s.shift(1).rolling(7).mean() / s.shift(1).rolling(28).mean() # short vs long ratio
idx.dayofweek; idx.day; idx.month; idx.quarter # calendar position
idx.is_month_end; idx.is_quarter_end # settlement effects
(idx.dayofyear - 1) // 7 # week of year
np.sin(2*np.pi*idx.dayofweek/7); np.cos(2*np.pi*idx.dayofweek/7) # cyclic encodingThe last line matters for linear models and neural networks. Encoding the weekday as 0 to 6 tells a linear model that Sunday (6) is six units away from Monday (0), when they are adjacent. A sine and cosine pair fixes that. Tree models do not need it — they split on thresholds and can isolate any single value.
Common mistakes
Rolling before shifting. s.rolling(7).mean().shift(1) and s.shift(1).rolling(7).mean() give the same result. s.rolling(7).mean() alone does not. Pick one order and use it everywhere.
Fitting a scaler on the whole series. StandardScaler().fit(X) computes a mean over your test rows. Fit on training rows only.
Filling NaN lags with zero. It teaches the model that history started at zero and produces bizarre early predictions. Drop those rows.
Using KFold cross-validation. Use TimeSeriesSplit, as shown in what is time series data.
Confusing one-step and multi-step forecasting. With lag_1 in your features you can predict one day ahead. Predicting thirty days ahead needs either recursive prediction (feeding predictions back in, which compounds error) or a separate model per horizon using only lags of 30 or more. Decide which one you need before you build features.
Adding a feature you cannot obtain in production. Tomorrow's temperature might be available from a forecast. Tomorrow's actual promotion flag might be in the plan. Tomorrow's actual footfall is not. Ask, for every column: would this value be sitting in a database at prediction time?
Try it yourself
Add a lag_2 and a lag_28 column to the honest model in the leakage script, then rerun.
Predict whether the MAE improves before you look. Then remove dow and see what happens. On a series with a strong weekend effect, dropping the weekday column should hurt badly — unless lag_7 was quietly carrying the same information, in which case it will barely move. Finding out which is true is the point of the exercise.
What to learn next
- LSTMs for forecasting — the neural alternative, and when it is worth the trouble.
- Evaluating a forecast — measuring these models without fooling yourself.
- XGBoost — the model family that wins most tabular forecasting competitions.
Researcher — Mathematics and papers.
The reduction
Feature-based forecasting reduces a sequence problem to supervised regression through a sliding-window transform. Given ${y_t}_{t=1}^{T}$, a lookback $L$ and a horizon $h$, construct
$$ \mathcal{D} = \left{ \left( \mathbf{x}t, \; y{t+h} \right) \right}_{t=L}^{T-h}, \qquad \mathbf{x}_t = \left[ \psi_1(y_{1:t}), \dots, \psi_d(y_{1:t}), \; \mathbf{z}_t \right] $$
- $\psi_j$ — any measurable functional of the history up to and including $t$, and no further.
- $\mathbf{z}_t$ — exogenous covariates whose values at time $t+h$ are known at time $t$.
- $h$ — the horizon; a separate model per $h$ is the direct strategy.
The measurability restriction $\psi_j: \mathcal{F}t \to \mathbb{R}$ is the formal statement of "no leakage". A centred rolling mean is $\mathcal{F}{t+k}$-measurable for $k>0$ and is therefore not an admissible feature, regardless of how the rows are split.
Direct, recursive and hybrid multi-step strategies
| Strategy | Construction | Error behaviour |
|---|---|---|
| Recursive | One model for $h=1$, fed its own predictions | Errors compound; bias accumulates because the model was trained on true lags, not predicted lags — a train/test input distribution mismatch |
| Direct | One model per horizon $h$ | No compounding; $H$ models to train; no consistency across horizons |
| DirRec | Model per horizon, each using previous predictions as inputs | Compromise; more complex |
| MIMO | One model with a vector output over all $H$ | Preserves dependence between horizons; higher variance |
Taieb, Bontempi, Atiya and Sorjamaa (2012), A review and comparison of strategies for multi-step ahead time series forecasting based on the NN5 competition, evaluates all four empirically. No strategy dominates; direct is generally safer at long horizons, recursive at short ones with a well-specified model.
Cross-sectional pooling and hierarchies
The M5 competition (Makridakis, Spiliotis and Assimakopoulos, 2022) is the reference result for this approach. Walmart data, 42,840 series in a 12-level hierarchy, daily. The top solutions were LightGBM models trained on pooled data across series with series-identity features, calendar features, price features and SNAP-benefit flags.
Pooling is the mechanism that makes this competitive rather than a novelty. A single series of 1,900 daily observations cannot support a large model. Forty thousand series sharing a parameter set can. The implicit assumption is partial exchangeability across series after conditioning on the identity features — an assumption that is testable and often violated across very heterogeneous groups.
Hierarchical coherence — forecasts summing correctly up the hierarchy — is a separate problem. Optimal reconciliation via the MinT estimator (Wickramasuriya, Athanasopoulos and Hyndman, 2019) projects base forecasts onto the coherent subspace using the covariance of base forecast errors, and provably does not increase error under its assumptions.
Loss functions and count data
Retail demand is non-negative, over-dispersed and intermittent. Squared error is a poor match.
- Tweedie loss with power $p \in (1,2)$ models a compound Poisson-gamma, giving a point mass at zero and a continuous positive part. This was widely used by M5 top entries; LightGBM supports
objective="tweedie". - Poisson loss for pure counts.
- Pinball loss for quantile forecasts, which is what an inventory decision actually needs. $L_\tau(y,\hat{y}) = \max(\tau(y-\hat{y}), (\tau-1)(y-\hat{y}))$ estimates the conditional $\tau$-quantile. The M5 Uncertainty track scored exactly this.
- Croston's method and its Syntetos-Boylan correction remain the classical baselines for intermittent demand and are surprisingly hard to beat on very sparse series.
Feature selection under dependence
Standard permutation importance is invalid on autocorrelated features: permuting lag_1 breaks its correlation with lag_2, evaluating the model off its training manifold. Options that behave better:
- Blocked permutation — permute contiguous blocks longer than the autocorrelation length rather than individual rows.
- Leave-one-covariate-out refitting — expensive but honest.
- SHAP values with a background sample drawn from the same regime, though the correlation caveat from Lundberg and Lee (2017) still applies. See SHAP and LIME.
Boruta and similar wrappers assume i.i.d. resampling and should not be used unmodified.
Purging and embargo
When labels span time — forecasting a 7-day-ahead cumulative total, for instance — a training row at $t$ and a validation row at $t+3$ share overlapping label windows. López de Prado (2018), chapter 7, formalises purging (remove training rows whose label window overlaps the validation window) and embargo (additionally drop a gap after the validation window to break residual serial correlation). Omitting these inflates validation scores in a way that survives a naive chronological split.
Global versus local models
A local model is fitted per series; a global model shares parameters across series. Montero-Manso and Hyndman (2021), Principles and algorithms for forecasting groups of time series, prove a striking result: a global model can achieve equal or lower generalisation error than local models even when the series are generated by unrelated processes, because the effective sample size grows while model complexity is controlled. The practical implication is that pooling is worth trying even on heterogeneous panels, provided the model has enough capacity and identity features.
Reading
- Makridakis, Spiliotis and Assimakopoulos (2022), M5 accuracy competition: Results, findings and conclusions, IJF 38(4).
- Montero-Manso and Hyndman (2021), Principles and algorithms for forecasting groups of time series: Locality and globality, IJF 37(4) — arxiv.org/abs/2008.00444.
- Taieb et al. (2012), A review and comparison of strategies for multi-step ahead time series forecasting, Expert Systems with Applications 39(8).
- Wickramasuriya, Athanasopoulos and Hyndman (2019), Optimal forecast reconciliation for hierarchical and grouped time series through trace minimization, JASA 114(526).
- López de Prado (2018), Advances in Financial Machine Learning, Wiley, chapter 7.
- Christ, Braun, Neuffer and Kempa-Liehr (2018), Time Series FeatuRe Extraction on basis of Scalable Hypothesis tests (tsfresh), Neurocomputing 307.
What to learn next
- LSTMs for forecasting — the neural alternative, and when it is worth the trouble.
- Evaluating a forecast — measuring these models without fooling yourself.
- XGBoost — the model family that wins most tabular forecasting competitions.