Time Series Forecasting with ML
"How many orders will we get tomorrow?" is one of the most common ML questions in Indian businesses: a cloud kitchen planning how much dal to cook, a dairy planning milk collection, a courier hub planning staff. Data that arrives in time order is a time series, and predicting its future values is forecasting.
Ordinary regression models such as gradient boosting forecast very well, but only if you build the features and the validation correctly. Time makes leakage easy: one wrong shift and your model reads tomorrow's answer. In this lesson you will build lag features safely, set honest baselines, validate with TimeSeriesSplit, and forecast several days ahead.
The data: daily orders
Every example uses the same generator: two years of daily orders for a cloud kitchen in Pune, with a slow upward trend, busier weekends, a dip during the monsoon and occasional sale days announced in advance by the marketing team. The data is synthetic, so the patterns are known and you can check what the model finds. For date handling in pandas, see Dates & Time Series.
The key idea is to turn the series into an ordinary table, where each row is a day and the features are things known before that day:
- Lag features: the value 1 day ago (
lag_1), 7 days ago (lag_7), and so on. - Rolling features: the average of the previous 7 days. Compute it on the shifted series so it ends yesterday.
- Calendar features: day of the week, month.
- Known future events: a planned sale day, a public holiday.
import numpy as np
import pandas as pd
def make_orders(seed=42):
rng = np.random.default_rng(seed)
days = pd.date_range("2024-01-01", "2025-12-31", freq="D")
t = np.arange(len(days))
weekend = np.where(days.dayofweek >= 5, 1.25, 1.0)
monsoon = 1 - 0.12 * np.exp(-(((days.dayofyear - 210) / 25) ** 2))
sale = rng.random(len(days)) < 0.05
shock = np.zeros(len(days)) # busy and quiet spells that last a few days
for i in range(1, len(days)):
shock[i] = 0.8 * shock[i - 1] + rng.normal(0, 0.06)
mean = (120 + 0.07 * t) * weekend * monsoon * np.where(sale, 1.4, 1.0) * np.exp(shock)
return pd.DataFrame({"orders": rng.poisson(mean), "sale_day": sale.astype(int)}, index=days)
def add_features(df):
out = df.copy()
for lag in [1, 7, 14]:
out[f"lag_{lag}"] = out["orders"].shift(lag)
out["mean_7"] = out["orders"].shift(1).rolling(7).mean() # last 7 days, ending yesterday
out["dayofweek"] = out.index.dayofweek
out["month"] = out.index.month
return out.dropna()
data = add_features(make_orders())
print(data.shape)
print(data.head(3).round(1).to_string())(717, 8)
orders sale_day lag_1 lag_7 lag_14 mean_7 dayofweek month
2024-01-15 115 0 179.0 115.0 109.0 137.0 0 1
2024-01-16 110 0 115.0 126.0 118.0 137.0 1 1
2024-01-17 101 0 110.0 131.0 112.0 134.7 2 1The first 14 days are dropped, because lag_14 needs two weeks of history. Look at the first row, 15 January 2024: lag_1 holds the orders of 14 January, and mean_7 is the average of 8 to 14 January. Nothing in the row uses the orders of 15 January itself, which is the target.
Baselines every forecast must beat
Two forecasts need no model at all:
- Naive forecast: tomorrow equals today (
lag_1). - Seasonal naive forecast: tomorrow equals the same weekday last week (
lag_7).
These are surprisingly hard to beat, and a model that cannot beat them is not worth deploying. Hold out the last 90 days as the test period: training rows always come before test rows, never shuffled.
import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error
def make_orders(seed=42):
rng = np.random.default_rng(seed)
days = pd.date_range("2024-01-01", "2025-12-31", freq="D")
t = np.arange(len(days))
weekend = np.where(days.dayofweek >= 5, 1.25, 1.0)
monsoon = 1 - 0.12 * np.exp(-(((days.dayofyear - 210) / 25) ** 2))
sale = rng.random(len(days)) < 0.05
shock = np.zeros(len(days)) # busy and quiet spells that last a few days
for i in range(1, len(days)):
shock[i] = 0.8 * shock[i - 1] + rng.normal(0, 0.06)
mean = (120 + 0.07 * t) * weekend * monsoon * np.where(sale, 1.4, 1.0) * np.exp(shock)
return pd.DataFrame({"orders": rng.poisson(mean), "sale_day": sale.astype(int)}, index=days)
df = make_orders()
test = df.index >= "2025-10-03" # the last 90 days
print("Test period:", df.index[test][0].date(), "to", df.index[test][-1].date())
y_test = df.loc[test, "orders"]
naive = df["orders"].shift(1)[test]
seasonal = df["orders"].shift(7)[test]
print(f"Naive (yesterday): MAE {mean_absolute_error(y_test, naive):.1f} orders")
print(f"Seasonal naive (last week): MAE {mean_absolute_error(y_test, seasonal):.1f} orders")
print(f"Average daily orders in test: {y_test.mean():.0f}")Test period: 2025-10-03 to 2025-12-31 Naive (yesterday): MAE 35.2 orders Seasonal naive (last week): MAE 26.6 orders Average daily orders in test: 195
Repeating yesterday is off by 35 orders a day on average. Repeating the same weekday from last week cuts that to 26.6, because the weekly pattern is strong. With about 195 orders a day, the seasonal naive forecast is already within about 14%. Any model has to beat 26.6 to be worth deploying.
TimeSeriesSplit and a model
For cross-validation, TimeSeriesSplit creates folds that grow forward in time: each fold trains on everything up to a point and tests on the block that follows. The gap parameter leaves some rows out between training and test, which mimics a delay between your last known data and the forecast period. test_size fixes the length of each test block.
import numpy as np
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
def make_orders(seed=42):
rng = np.random.default_rng(seed)
days = pd.date_range("2024-01-01", "2025-12-31", freq="D")
t = np.arange(len(days))
weekend = np.where(days.dayofweek >= 5, 1.25, 1.0)
monsoon = 1 - 0.12 * np.exp(-(((days.dayofyear - 210) / 25) ** 2))
sale = rng.random(len(days)) < 0.05
shock = np.zeros(len(days)) # busy and quiet spells that last a few days
for i in range(1, len(days)):
shock[i] = 0.8 * shock[i - 1] + rng.normal(0, 0.06)
mean = (120 + 0.07 * t) * weekend * monsoon * np.where(sale, 1.4, 1.0) * np.exp(shock)
return pd.DataFrame({"orders": rng.poisson(mean), "sale_day": sale.astype(int)}, index=days)
def add_features(df):
out = df.copy()
for lag in [1, 7, 14]:
out[f"lag_{lag}"] = out["orders"].shift(lag)
out["mean_7"] = out["orders"].shift(1).rolling(7).mean()
out["dayofweek"] = out.index.dayofweek
out["month"] = out.index.month
return out.dropna()
data = add_features(make_orders())
train, test = data[data.index < "2025-10-03"], data[data.index >= "2025-10-03"]
features = ["lag_1", "lag_7", "lag_14", "mean_7", "dayofweek", "month", "sale_day"]
tscv = TimeSeriesSplit(n_splits=4, test_size=60, gap=1)
for i, (tr, te) in enumerate(tscv.split(train), start=1):
print(f"Fold {i}: train to {train.index[tr[-1]].date()}, test {train.index[te[0]].date()} "
f"to {train.index[te[-1]].date()}")
model = HistGradientBoostingRegressor(max_iter=300, learning_rate=0.05, random_state=42)
cv_mae = -cross_val_score(model, train[features], train["orders"], cv=tscv,
scoring="neg_mean_absolute_error")
print("CV MAE per fold:", cv_mae.round(1))
model.fit(train[features], train["orders"])
print(f"Test MAE, model: {mean_absolute_error(test['orders'], model.predict(test[features])):.1f}")
print(f"Test MAE, seasonal naive: {mean_absolute_error(test['orders'], test['lag_7']):.1f}")Fold 1: train to 2025-02-03, test 2025-02-05 to 2025-04-05 Fold 2: train to 2025-04-04, test 2025-04-06 to 2025-06-04 Fold 3: train to 2025-06-03, test 2025-06-05 to 2025-08-03 Fold 4: train to 2025-08-02, test 2025-08-04 to 2025-10-02 CV MAE per fold: [21.5 21.4 18.2 20.9] Test MAE, model: 22.1 Test MAE, seasonal naive: 26.6
Each fold trains on all earlier days and tests on the next 60, with a one-day gap in between. The fold errors (18 to 22 orders) are similar to each other, which suggests a stable model. On the untouched last 90 days, the model's MAE of 22.1 beats the seasonal naive 26.6 by about 17%, because it combines the weekly pattern with yesterday's level, the trend and the planned sales.
Forecasting several days ahead
The model above forecasts one day ahead: it uses lag_1, yesterday's orders. But the kitchen orders vegetables a week in advance. On Monday you need forecasts for next Monday to Sunday, and for those days "yesterday" is still in the future.
The simplest correct approach is the direct strategy: for a forecast h days ahead, use only features that are at least h days old. For a 7-day horizon, that means lag_7, lag_14 and a rolling mean that ends 7 days before the target day, plus calendar features and planned sales, which are known in advance. Expect the error to grow with the horizon.
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error
def make_orders(seed=42):
rng = np.random.default_rng(seed)
days = pd.date_range("2024-01-01", "2025-12-31", freq="D")
t = np.arange(len(days))
weekend = np.where(days.dayofweek >= 5, 1.25, 1.0)
monsoon = 1 - 0.12 * np.exp(-(((days.dayofyear - 210) / 25) ** 2))
sale = rng.random(len(days)) < 0.05
shock = np.zeros(len(days)) # busy and quiet spells that last a few days
for i in range(1, len(days)):
shock[i] = 0.8 * shock[i - 1] + rng.normal(0, 0.06)
mean = (120 + 0.07 * t) * weekend * monsoon * np.where(sale, 1.4, 1.0) * np.exp(shock)
return pd.DataFrame({"orders": rng.poisson(mean), "sale_day": sale.astype(int)}, index=days)
def features_for_horizon(df, h):
"""Only use values at least h days old, plus calendar and planned sales."""
out = df.copy()
week = 7 * int(np.ceil(h / 7)) # same weekday, at least h days back
for lag in sorted({h, week, week + 7}):
out[f"lag_{lag}"] = out["orders"].shift(lag)
out["mean_7_old"] = out["orders"].shift(h).rolling(7).mean()
out["dayofweek"] = out.index.dayofweek
out["month"] = out.index.month
return out.dropna()
orders = make_orders()
for h in [1, 3, 7]:
data = features_for_horizon(orders, h)
cols = [c for c in data.columns if c != "orders"]
train, test = data[data.index < "2025-10-03"], data[data.index >= "2025-10-03"]
model = HistGradientBoostingRegressor(max_iter=300, learning_rate=0.05, random_state=42)
model.fit(train[cols], train["orders"])
mae = mean_absolute_error(test["orders"], model.predict(test[cols]))
week = 7 * int(np.ceil(h / 7)) # seasonal naive for this horizon
naive = mean_absolute_error(test["orders"], test[f"lag_{week}"])
print(f"{h:>2} day(s) ahead: model MAE {mae:5.1f} seasonal naive MAE {naive:5.1f}")1 day(s) ahead: model MAE 22.1 seasonal naive MAE 26.6 3 day(s) ahead: model MAE 27.8 seasonal naive MAE 26.6 7 day(s) ahead: model MAE 38.2 seasonal naive MAE 26.6
The error grows quickly with the horizon: 22.1 orders one day ahead, 27.8 three days ahead and 38.2 a week ahead. The baseline column tells you what to do with that. One day ahead, the model clearly beats the seasonal naive forecast. Three days ahead it is roughly level, and a week ahead it is much worse than simply repeating last week. By then the recent busy or quiet spell has faded, so the lags add little beyond the weekly pattern, and tree models cannot extrapolate a rising trend to order levels higher than anything in the training years. For the weekly vegetable order, the honest choice is the seasonal naive forecast until a better model beats it. One improvement worth trying is to predict the ratio of orders to a recent average instead of the raw count, which takes the trend out of the target.
The other approach, the recursive strategy, trains one 1-day model and feeds its own predictions back in as lags for the following days. It needs only one model, but errors pile up from day to day.
Common mistakes
- Shuffled splits for time series. Use a date cut-off and
TimeSeriesSplit; neverKFold(shuffle=True). - Rolling features without
shift. The window then includes the value being predicted. - Using features you will not have at forecast time, such as yesterday's orders for a forecast made a week ahead.
- Skipping the seasonal naive baseline. It is often embarrassingly strong.
- Training once and forgetting. Demand patterns change; retrain regularly on recent data and monitor the error.
Key points
- Turn a time series into a table of lag, rolling, calendar and known-event features.
- Compute rolling features on the shifted series so they end before the target day.
- Always compare with naive and seasonal naive forecasts.
- Validate with a date cut-off and
TimeSeriesSplit, which keeps every test block after its training data;gapadds a delay. - For an
h-day horizon, use only information at leasthdays old (the direct strategy). - Error grows with the horizon, so report it for each horizon the business needs.
Exercise
Extend the one-day-ahead model: add lag_28 and a 28-day rolling mean that ends yesterday (shift(1).rolling(28).mean()). Train on data before 2025-10-03 and report the test MAE next to the earlier feature set's MAE. Did the extra history help?
Show solution
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error
def make_orders(seed=42):
rng = np.random.default_rng(seed)
days = pd.date_range("2024-01-01", "2025-12-31", freq="D")
t = np.arange(len(days))
weekend = np.where(days.dayofweek >= 5, 1.25, 1.0)
monsoon = 1 - 0.12 * np.exp(-(((days.dayofyear - 210) / 25) ** 2))
sale = rng.random(len(days)) < 0.05
shock = np.zeros(len(days)) # busy and quiet spells that last a few days
for i in range(1, len(days)):
shock[i] = 0.8 * shock[i - 1] + rng.normal(0, 0.06)
mean = (120 + 0.07 * t) * weekend * monsoon * np.where(sale, 1.4, 1.0) * np.exp(shock)
return pd.DataFrame({"orders": rng.poisson(mean), "sale_day": sale.astype(int)}, index=days)
df = make_orders()
for lag in [1, 7, 14, 28]:
df[f"lag_{lag}"] = df["orders"].shift(lag)
df["mean_7"] = df["orders"].shift(1).rolling(7).mean()
df["mean_28"] = df["orders"].shift(1).rolling(28).mean()
df["dayofweek"], df["month"] = df.index.dayofweek, df.index.month
df = df.dropna()
train, test = df[df.index < "2025-10-03"], df[df.index >= "2025-10-03"]
base = ["lag_1", "lag_7", "lag_14", "mean_7", "dayofweek", "month", "sale_day"]
for name, cols in [("original", base), ("with 28-day history", base + ["lag_28", "mean_28"])]:
model = HistGradientBoostingRegressor(max_iter=300, learning_rate=0.05, random_state=42)
model.fit(train[cols], train["orders"])
print(f"{name:<20} test MAE {mean_absolute_error(test['orders'], model.predict(test[cols])):.1f}")original test MAE 21.7 with 28-day history test MAE 22.3
The longer history does not help: the error moves from 21.7 to 22.3, a difference smaller than the variation between folds. More features are not automatically better, and you only find out by testing. (The original feature set scores 21.7 here rather than 22.1 because the 28-day features need more history, so training starts two weeks later.)
Quick Quiz
1. Which feature leaks the target when predicting today's orders?
Without shift(1) the rolling window includes today's value, which is the answer.
2. What does the seasonal naive forecast predict for next Saturday?
It repeats the value from one season (here one week) earlier.
3. What does TimeSeriesSplit(gap=1) do?
The gap mimics a delay between the last known data and the forecast period.
4. You forecast 7 days ahead. Which feature can you NOT use for the target day?
For a 7-day horizon, the day before the target is still in the future when you forecast.
Learn Machine Learning with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
