learn-timeseries-forecasting-with-phoebe / Builder session 6 of 10
Learn Timeseries Forecasting with Phoebe · Builder track · Session 6 of 10

ML forecasting: forecasting as supervised regression

So far every method has been a dedicated time-series model. This session reframes the whole problem: turn the series into an ordinary table where the target is the future value and the features are lags, rolling stats, and calendar flags - then let a gradient-boosting regressor learn the mapping. It is the family that rose in the M5 competition, it scales to many related series with external drivers, and it lives or dies on careful feature engineering with zero leakage.

🟠 Builder track Practitioners: analysts · data scientists · engineers Python 3.10+ · a notebook ~45 min
0-3 · Recap 3-18 · Series to a table 18-42 · Build-along: features + boosting 42-45 · Q&A
Part 0

A different way to see the problem

ETS (b3), ARIMA (b4), and Prophet (b5) are all purpose-built time-series models. The ML approach throws that framing out and asks: what if forecasting is just supervised regression? Build a table whose target column is tomorrow's value and whose feature columns describe what we knew up to today - yesterday's value, last week's same day, a 7-day average, the day of week. Fit any regressor to that table. Gradient-boosted trees (XGBoost, LightGBM, sklearn's HistGradientBoostingRegressor) are the workhorse here, and they are what carried the top of the M5 competition. The power is that this scales to hundreds of related series and lets you feed in external drivers; the price is that you engineer the features, and one leak sinks the whole thing.

Live - presented in session Self-study - read after class ★ Run it now (Python) Sources covered (cited)
★ What you walk out with today The Lumen series turned into a supervised feature frame - lag features, rolling statistics, and calendar flags - built so every feature is known at forecast time (no leakage), a fitted gradient-boosting regressor, a recursive multi-step forecast for the next H days scored against the b1 baseline, and a read on which features actually carry the prediction.
Part 1 · covers sklearn / GBM forecasting

Turn the series into a supervised table 6 min live

A regressor needs rows of features -> target. We build them by sliding a window along the series: for each day t, the target is the value at t, and the features summarise the days before it. Slide the window forward one day and you get the next row. Do this for the whole history and the time series becomes an ordinary table any regressor can learn.

A sliding window of the past becomes one feature row series: window of past days (features) predict -> lag_1 lag_7 roll_7 dow is_weekend y (next) 1046 1002 1031 5 1 ? Slide the window one day forward for the next row. The whole series becomes a table of features -> target.
🔍 Click to zoom - series to supervised table: a window of the past predicts the next day
LiveThe three feature families3 min▶

Almost every ML forecast is built from three kinds of feature:

  • Lags - the value at t-1, t-7, t-14. s.shift(1) is yesterday; s.shift(7) is the same weekday last week. Lags carry the autocorrelation that ARIMA modelled explicitly.
  • Rolling statistics - a moving mean or std over a recent window, e.g. s.shift(1).rolling(7).mean(). These summarise the recent level and volatility.
  • Calendar features - day-of-week, month, is-weekend, is-holiday, read straight off the datetime index. These carry seasonality the tree can split on.
The shift(1) is not optional Notice s.shift(1).rolling(7).mean(), not s.rolling(7).mean(). The shift(1) pushes the window to end at yesterday, so the feature only uses data you would actually have when forecasting today. Drop the shift and the rolling mean includes today's target - a leak that flatters your score and fails live.
Self-studyWhy trees, and when this family wins2 min read▶

Any regressor can learn the table, but gradient-boosted trees dominate this space for good reasons:

  • They capture non-linear interactions (a Saturday effect that only shows during a sale) without you specifying them.
  • They shrug off feature scale and tolerate irrelevant features, so you can throw in many candidates.
  • They scale to hundreds or thousands of related series stacked into one big table - the M5 setup, where a single model forecasts every product in every store.
Real world

M5 is why this session exists. The M5 competition (Walmart hierarchical sales, 2020) saw gradient-boosting and ML/hybrid methods rise to the top of a field long dominated by statistical models - especially where many related series and external drivers were in play. The lesson is not "ML always wins" - the M-competitions still show simple baselines are hard to beat on a single series - but that when you have many series plus drivers, the regression framing scales in a way one-series models do not.

Part 2 · covers multi-step strategy

Forecasting H days: direct vs recursive 5 min live

A regressor trained on "features -> next day" predicts one step. To reach H days out you need a strategy. There are two: recursive feeds each prediction back in as a lag to predict the next day with one model, and direct trains a separate model for each horizon (h=1, h=2, ... h=H). Recursive is simple and lets errors compound; direct avoids compounding but costs H models.

Recursive: one model, predictions fed back 1 model day 1 day 2 day 3 ... to day H each prediction becomes the next day's lag - errors can compound Direct: one model per horizon step model h=1 model h=2 model h=3 ... model h=H no compounding, but H models to train and maintain start recursive; go direct only if error compounds too far out
🔍 Click to zoom - recursive (one model, fed back) vs direct (one model per step)
LiveLeakage: the one mistake that ruins ML forecasts3 min▶

The regression framing is powerful precisely because it is flexible - and that flexibility is where leakage sneaks in. Every feature must be computable using only information available at forecast time.

  • Split by time, never at random. Same rule as b1. A random split scatters future rows into training and inflates the score.
  • Lag your rolling windows. shift(1) before rolling, so the window ends yesterday, not today.
  • Only known-in-advance drivers. A "did it rain" feature is a leak unless you have a rain forecast; the calendar (day-of-week, holidays) is safe because it is known in advance.
  • Drop the warm-up rows. The first few rows have no full lag/rolling history - dropna() them rather than filling with something you would not have.
The horizon is a modelling decision again Recursive is fine for short horizons; the further out you go, the more each fed-back prediction carries its own error. If accuracy falls apart past a certain horizon, that is the signal to switch to direct models for the longer steps.
Demo 1 of 2

Build the supervised feature frame ★ 12 min · everyone builds

Turn the Lumen series into a table of features and a target, with every feature engineered to be known at forecast time.

Rebuild the Lumen daily series from b1 and put it in a dataframe with the target column y.

Add lag features with s.shift(1) and s.shift(7), and rolling stats with s.shift(1).rolling(7).

Add calendar features from the datetime index: day-of-week, month, is-weekend.

dropna() the warm-up rows and print the supervised table.

★ Run it now (Python) - series to a supervised feature frameimport pandas as pd, numpy as np # rebuild the Lumen daily sessions series (from b1) idx = pd.date_range("2026-01-01", periods=112, freq="D") season = np.array([40,55,60,50,30,-120,-140]) # weekday shape i = np.arange(112) y = 1000 + 2.5*i + season[i % 7] + 14*np.sin(i*1.7) + 9*np.cos(i*0.6) s = pd.Series(np.round(y), index=idx, name="sessions") # build the supervised table: features must be known at forecast time df = pd.DataFrame({"y": s}) df["lag_1"] = s.shift(1) # yesterday df["lag_7"] = s.shift(7) # same weekday last week df["roll_7"] = s.shift(1).rolling(7).mean() # mean of the last 7 days df["roll_7_std"] = s.shift(1).rolling(7).std() # recent volatility df["dow"] = df.index.dayofweek # 0=Mon .. 6=Sun df["month"] = df.index.month df["is_weekend"] = (df.index.dayofweek >= 5).astype(int) df = df.dropna() # drop warm-up rows w/o full history print(df.head()) print("shape:", df.shape, "| features known at forecast time -> no leakage")
Read the table you just made Each row is now "what we knew -> what happened next". lag_1 and lag_7 carry the recent value and the weekly echo; roll_7 the level; the calendar columns the seasonality. This table, not the raw series, is what the model actually sees.
Demo 2 of 2

Fit gradient boosting and forecast H days ★ 10 min · build your own

Split by time, fit a HistGradientBoostingRegressor on the feature frame, then roll a recursive multi-step forecast forward H days and score it against the b1 seasonal-naive baseline. Finish by asking which features carried the prediction.

Split the table by time - the last H rows are the test set - and fit the regressor on the training rows.

Forecast recursively: predict the next day, append it to the history, rebuild that day's features, repeat H times.

Score MAPE on the holdout and set it beside the seasonal-naive baseline from b1.

Read permutation importance to see which features matter.

★ Run it now (Python) - fit, recursive forecast, MAPE vs baselinefrom sklearn.ensemble import HistGradientBoostingRegressor from sklearn.inspection import permutation_importance H = 14 feats = ["lag_1", "lag_7", "roll_7", "roll_7_std", "dow", "month", "is_weekend"] train_df = df.iloc[:-H] # split by time, never at random model = HistGradientBoostingRegressor(max_iter=300, learning_rate=0.05, max_depth=4, random_state=0) model.fit(train_df[feats], train_df["y"]) # recursive multi-step: feed each prediction back to build the next row's lags hist = s.iloc[:-H].copy() preds = [] for step in range(H): t = hist.index[-1] + pd.Timedelta(days=1) row = {"lag_1": hist.iloc[-1], "lag_7": hist.iloc[-7], "roll_7": hist.iloc[-7:].mean(), "roll_7_std": hist.iloc[-7:].std(), "dow": t.dayofweek, "month": t.month, "is_weekend": int(t.dayofweek >= 5)} yhat = model.predict(pd.DataFrame([row])[feats])[0] preds.append(yhat) hist.loc[t] = yhat # append prediction, roll forward test = s.iloc[-H:] fc = pd.Series(preds, index=test.index) mape = (np.abs(test.values - fc.values) / test.values).mean() * 100 print(f"gradient-boosting MAPE: {mape:.1f}% (vs the b1 seasonal-naive baseline)") # which features carry the forecast? imp = permutation_importance(model, train_df[feats], train_df["y"], n_repeats=10, random_state=0) print(pd.Series(imp.importances_mean, index=feats).sort_values(ascending=False))
Real world

Feature importance is a sanity check, not a victory lap. On Lumen you should see lag_7 and dow near the top - the weekly rhythm is the real signal, exactly what seasonal-naive exploits. If a leaky feature ever tops the list with suspiciously perfect scores, that is your leak alarm. And the ruling is the same as every session: unless this MAPE beats the seasonal-naive baseline on a backtest (b8), the extra machinery has not earned its place. On one clean series, do not be surprised if the simple baseline is right there with you - that is the M-competition lesson, not a bug.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

Sources covered (cited)

Taught from the gradient-boosting documentation and the M-competition evidence. This session covers:

✓scikit-learn / gradient boosting (XGBoost, LightGBM) documentationParts 1-2 + both demos · lag/rolling/calendar features, HistGradientBoostingRegressor, importance
◐M-competitions (Makridakis, M5, 2020)Part 1 · ML/hybrid methods rose where many related series and drivers were in play
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · How does ML forecasting reframe the problem?

The series becomes an ordinary table - features (lags, rolling stats, calendar) map to a target (the future value) - and any regressor, usually gradient boosting, learns it.

2 · Why write s.shift(1).rolling(7).mean() instead of s.rolling(7).mean()?

Without the shift, the rolling mean includes the value you are trying to predict. That is leakage: the score looks great in testing and collapses live.

3 · What is the difference between recursive and direct multi-step forecasting?

Recursive uses one model and feeds each prediction back as a lag (errors can compound); direct trains one model per horizon (no compounding, but H models to maintain).

Builder session 6 cheat sheet · pin this

Forecasting as regressionturn the series into a table: features (lags, rolling, calendar) -> target (next value).
Lag featuress.shift(1), s.shift(7) - yesterday and the same weekday last week. Carry the autocorrelation.
Rolling statss.shift(1).rolling(7).mean()/std() - recent level and volatility. The shift(1) prevents leakage.
Calendar featuresday-of-week, month, is_weekend from the datetime index. Safe: known in advance.
Gradient boostingHistGradientBoostingRegressor / XGBoost / LightGBM - the workhorse regressor here.
Recursive vs directrecursive = one model fed back (errors compound); direct = one model per horizon step.
No leakagesplit by time, features known at forecast time only, drop warm-up rows. One leak sinks it.
Beat the baselineM5 lifted ML with many series + drivers, but on one series simple is still hard to beat.
Next sessionb7 - deep forecasting: a cited survey of DeepAR, N-BEATS, and TFT.