Forecast your shop's sales
From a moving average to Holt-Winters over three years of monthly sales. The formula is not the point; what matters is the error on months the model never saw.
SALES holds 36 months of a shop's sales (synthetic, in EUR), with a slow rise, a December peak and an August dip. Write four functions:
movingAverage(xs, k, h): h copies of the mean of the last k values. This is the yardstick: if your model does not beat it, do not use it.
holtWinters(xs, alpha, beta, gamma, m, h): additive triple exponential smoothing. Start level at the mean of the first season (m values), trend at (mean of the second - mean of the first) / m and seasonal[i] = xs[i] - level for i < m. Then, for each t from m:
level = α · (xs[t] - seasonal[t - m]) + (1 - α) · (level + trend)
trend = β · (level - previousLevel) + (1 - β) · trend
seasonal[t] = γ · (xs[t] - level) + (1 - γ) · seasonal[t - m]
Return { level, trend, forecast }, where forecast step i (from 1) is level + i · trend + seasonal[n - m + ((i - 1) % m)].
mae(actual, predicted): the mean of |actual[i] - predicted[i]|.
backtest(xs, holdout, forecaster): train on everything but the last holdout values, call forecaster(train, holdout) and return { forecast, mae } against what really happened.
The preview draws the series and both forecasts over the six held-out months; move α, β and γ and watch the error.
Challenges 0/4
- movingAverage repeats the mean of the last k values
- holtWinters: initialisation, the three update rules and the seasonal forecast
- mae is the mean absolute error
- backtest over the 6 held-out months: Holt-Winters beats the moving average
function movingAverage(xs, k, h) {
// h copies of the mean of the last k values
return [];
}
function holtWinters(xs, alpha, beta, gamma, m, h) {
// level = mean of the first season; trend = (mean of the second - mean of the first) / m; seasonal[i] = xs[i] - level
// then, for t from m on: level, trend and seasonal[t] from the three update rules
// forecast step i (1-based): level + i * trend + seasonal[n - m + ((i - 1) % m)]
return { level: 0, trend: 0, forecast: [] };
}
function mae(actual, predicted) {
// mean of |actual[i] - predicted[i]|
return 0;
}
function backtest(xs, holdout, forecaster) {
// train on all but the last `holdout` values, forecast that many, score against what really happened
return { forecast: [], mae: 0 };
}
console.log(backtest(SALES, HOLDOUT, (train, h) => movingAverage(train, 3, h)).mae);The idea
A forecast is a bet with the error written next to it. The moving average is the simplest bet: "next month will be like the last three". It knows nothing about trend or Christmas, but it is hard to beat with little data, which is why it is the yardstick.
Holt-Winters splits the series into three pieces updated every month: the level (where we are), the trend (where we are heading) and the season (what happens to this month every year). α, β and γ say how much weight the new month carries against what was already believed. With a high α the model reacts fast and panics at any odd month; with a low α it is calm and slow to notice a change.
What matters is not the formula but how you measure it. If you tune α, β and γ by looking at the error on the same months you trained on, it will always look good. The preview holds the last six months out, trains on the 30 before and measures there. That number does tell you how far to trust next month.
With three years of data and a twelve-month season the model is stretched: it sees each month of the year only three times. That is an honest limit, not a bug.
Run it on your machine
With Python and statsmodels, on this exercise's CSV (CC BY 4.0). Same split: 30 months to train, 6 to measure:
curl -O https://vorluxai.com/data/labs/ventas-tienda-mensual.csv
python3 -c "
import pandas as pd
from statsmodels.tsa.holtwinters import ExponentialSmoothing
s = pd.read_csv('ventas-tienda-mensual.csv').ventas_eur
train, test = s[:-6], s[-6:]
fit = ExponentialSmoothing(train, trend='add', seasonal='add', seasonal_periods=12).fit()
print('MAE', (fit.forecast(6).values - test.values).__abs__().mean())
" Go deeper: the Forecasting: Principles and Practice reference →
This in production, with your data? Let's talk for 15 minutes →