Buying is done four weeks ahead on last year plus a feeling, and stock runs out. Build a better forecast, and prove it is better. Work it yourself before reading the walkthrough.
The situation
Ashcroft Garden sells garden and outdoor goods. You have 156 weeks of weekly units for 12 SKUs, 2023-08-07 to 2026-07-27. Purchase orders go in four weeks before delivery. The buying team wants reorder points at a 90% service level.
The data
| Column | Meaning |
|---|---|
| week_start, week_index | One row per SKU per week |
| sku, product_name, category | What it is |
| unit_price, on_promotion | Commercial context. The promotional calendar is known in advance |
| stockout | Whether the line was unavailable for part of that week |
| units_sold | What left the shelf |
The range includes both fast moving lines and lines that sell nothing most weeks. That mix is deliberate.
What the room believes
- A machine learning model will comfortably beat last year’s numbers.
- MAPE is a reasonable way to compare forecast accuracy across SKUs.
- One held-out period is enough to know how good the forecast is.
- A good point forecast is what the buying team needs.
Definition of done
- A verdict on each of the four beliefs, with the evidence.
- A forecast at the horizon the business actually orders at, validated in a way that reflects how it would be used.
- A comparison against at least one baseline that requires no model at all.
- A defence of the accuracy metric you chose, including why you rejected the obvious one.
- Something a buyer can put in a purchase order, and a statement of the service level it delivers.
Four questions worth asking before you fit anything
How old must a lag be before a four week ahead forecast is allowed to use it? What happens to a percentage error when the actual is zero, and how many of your SKU weeks are zero? If you test on one window, how would you know whether that window was lucky? And is the number a buyer needs the middle of your forecast, or something else?
If you want to go further
- Compute the same accuracy figure on several different test windows and look at the spread before you compare any two models.
- For each SKU, count how many test weeks your chosen metric is able to score at all.
- Work out what proportion of weeks your forecast is above actual demand, and decide whether that is the number the buyer needs.
- Look at what the stockout column does to the history the model is learning from.
When you are done, read the walkthrough. All four beliefs turn out to be wrong or badly incomplete, and one of them would have had the team ranking their worst forecast as one of their best. Compare your validation design to its design before you compare accuracy.
The buying team orders on last year plus a feeling. A model beats that, by less than anyone hoped. The bigger finding is that the accuracy metric everyone reports ranks the worst forecast in the range as one of the best.
The situation. Ashcroft Garden sells garden and outdoor goods. 156 weeks of demand across 12 SKUs, from 2023-08-07 to 2026-07-27. Buying is done four weeks ahead and stock runs out on the lines that matter.
What the business is left with. A four week forecast per SKU, an honest measure of how wrong it will be, and a reorder point that accounts for the difference between a forecast and a plan.
Attempt it first. The brief has the data and the horizon with none of the answers.
Contents
Section 1Problem Definition
No code yet. Forecasting has more ways to fool yourself than any other modelling task on this site, and three of them are decided before a model is fitted.
| Decision | The easy answer | Why it is wrong |
|---|---|---|
| How to validate | A random train and test split | It lets the model learn from next month to predict last month. Nothing in production works that way |
| Which accuracy metric | MAPE, because everyone reports it | It divides by the actual. On a SKU that sells nothing some weeks, it either fails or quietly ignores those weeks. Section 7b measures how many |
| What to deliver | A number of units per week | A buyer needs a reorder point, which is a quantile, not a mean. Section 7d |
Business objective
Produce a 4 week ahead forecast per SKU, good enough to set reorder points at a 90% service level, and state honestly how much better it is than what the team does now.
Hypotheses
- H1. A machine learning model will comfortably beat last year’s numbers.
- H2. MAPE is a reasonable way to compare forecast accuracy across SKUs.
- H3. One held-out period is enough to know how good the forecast is.
- H4. A good point forecast is what the buying team needs.
Section 2Data Collection
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.linear_model import Ridge
SEED, HORIZON, SERVICE = 20260822, 4, 0.90
d = pd.read_csv('data/ashcroft-weekly-demand.csv').sort_values(['sku', 'week_index'])
print('rows :', len(d))
print('skus :', d['sku'].nunique(), ' weeks:', d['week_index'].nunique())
print('stockout weeks :', int(d['stockout'].sum()))
rows : 1,872
skus : 12 weeks: 156
stockout weeks : 76
| Column | Meaning |
|---|---|
| week_start, week_index | One row per SKU per week |
| sku, product_name, category | What it is |
| unit_price, on_promotion | Commercial context |
| stockout | Whether the line was unavailable for part of the week |
| units_sold | What actually left the shelf |
units_sold is not demand
In a stockout week the shelf was empty, so the number records what could be sold rather than what customers wanted. 76 weeks are affected, 4.1% of the file, averaging 27.2 units against 62.1 in a normal week.
Every model below is therefore trained to forecast sales, and quietly learns to forecast the stockouts too. Section 8 makes that an owned problem rather than a footnote.
Section 3Data Preprocessing
3aDuplicates and schema checks
print('duplicate rows :', d.duplicated().sum())
print('duplicate sku and week :', d.duplicated(subset=['sku', 'week_index']).sum())
print('missing cells :', d.isna().sum().sum())
print('weeks per sku :', d.groupby('sku').size().unique())
duplicate rows : 0
duplicate sku and week : 0
missing cells : 0
weeks per sku : [156]
A complete rectangular panel, which is what a lag feature needs. A SKU with missing weeks would silently shift its own history when lagged.
3bHandling categorical mess
Category and SKU are clean. Neither is one-hot encoded into the model: with 12 SKUs, dummies would let the model memorise each line’s mean and call it a forecast. The lag features carry the SKU’s own level instead, which generalises to a new line.
3cDealing with outliers
for sku, g in d.groupby('sku'):
print(f'{sku} {g["product_name"].iloc[0]:24s} mean {g["units_sold"].mean():7.1f} '
f'sd {g["units_sold"].std():6.1f} zero weeks {(g["units_sold"] == 0).mean():6.1%}')
AG-1010 Rotary Mower 40cm mean 48.4 sd 33.2 zero weeks 0.0%
AG-1020 Cordless Hedge Trimmer mean 29.3 sd 17.3 zero weeks 0.0%
AG-2010 Compost 50L mean 205.2 sd 102.9 zero weeks 0.0%
AG-2020 Tomato Feed 1L mean 103.8 sd 77.9 zero weeks 0.0%
AG-2030 Seed Potatoes 2kg mean 77.9 sd 75.0 zero weeks 1.3%
AG-3010 Patio Cleaner 5L mean 54.0 sd 33.0 zero weeks 0.0%
AG-3020 Lawn Sand 20kg mean 41.6 sd 34.5 zero weeks 3.2%
AG-4010 Teak Bench mean 4.7 sd 5.9 zero weeks 44.2%
AG-4020 Parasol 3m mean 7.3 sd 9.0 zero weeks 41.7%
AG-4030 Fire Pit Large mean 3.2 sd 3.8 zero weeks 40.4%
AG-5010 Bird Feeder Deluxe mean 24.9 sd 10.7 zero weeks 0.0%
AG-5020 Suet Balls 50pk mean 128.3 sd 72.3 zero weeks 0.0%
| SKU | Product | Mean | SD | Zero weeks | Coefficient of variation |
|---|---|---|---|---|---|
| AG-4010 | Teak Bench | 4.7 | 5.9 | 44.2% | 1.25 |
| AG-4020 | Parasol 3m | 7.3 | 9.0 | 41.7% | 1.24 |
| AG-4030 | Fire Pit Large | 3.2 | 3.8 | 40.4% | 1.19 |
| AG-3020 | Lawn Sand 20kg | 41.6 | 34.5 | 3.2% | 0.83 |
| AG-2030 | Seed Potatoes 2kg | 77.9 | 75.0 | 1.3% | 0.96 |
| AG-1010 | Rotary Mower 40cm | 48.4 | 33.2 | 0.0% | 0.69 |
| AG-1020 | Cordless Hedge Trimmer | 29.3 | 17.3 | 0.0% | 0.59 |
| AG-2010 | Compost 50L | 205.2 | 102.9 | 0.0% | 0.5 |
| AG-2020 | Tomato Feed 1L | 103.8 | 77.9 | 0.0% | 0.75 |
| AG-3010 | Patio Cleaner 5L | 54.0 | 33.0 | 0.0% | 0.61 |
| AG-5010 | Bird Feeder Deluxe | 24.9 | 10.7 | 0.0% | 0.43 |
| AG-5020 | Suet Balls 50pk | 128.3 | 72.3 | 0.0% | 0.56 |
Nothing is trimmed. What matters here is not outliers but the split between continuous and intermittent demand: three SKUs sell nothing in around 44.2% of weeks. That is not noise, it is the demand pattern, and section 7b shows what it does to the usual accuracy metric.
3dHandling missing values
None in the file. Plenty are created by the lag features, which is expected: the first 52 weeks of every SKU have no same-week-last-year value. Those rows are dropped from training rather than imputed, because imputing a lag invents history.
3eHandling skewed data
Demand is right-skewed and strongly seasonal. No log transform: the objective is units in the warehouse, and a model optimised on log units systematically under-forecasts the peaks, which are the weeks the buying team actually cares about.
3fData types and normalisation
The features, and the rule that makes them legitimate.
def build(f):
f = f.copy()
f['woy'] = pd.to_datetime(f['week_start']).dt.isocalendar().week.astype(int)
for k, name in [(1, '1'), (2, '2')]:
f['sin' + name] = np.sin(2 * np.pi * k * f['woy'] / 52.0)
f['cos' + name] = np.cos(2 * np.pi * k * f['woy'] / 52.0)
# every lag is at least HORIZON weeks old: a forecast made today for four
# weeks ahead cannot use sales from next week
for lag in (HORIZON, HORIZON + 1, HORIZON + 4, 52):
f['lag_' + str(lag)] = f.groupby('sku')['units_sold'].shift(lag)
f['roll8'] = (f.groupby('sku')['units_sold'].shift(HORIZON)
.rolling(8).mean().reset_index(level=0, drop=True))
return f
F = build(d)
FEATS = ['sin1', 'cos1', 'sin2', 'cos2', 'on_promotion', 'week_index',
'lag_4', 'lag_5', 'lag_8', 'lag_52', 'roll8']
print('features :', len(FEATS))
print('rows usable after lags :', F.dropna(subset=FEATS).shape[0], 'of', len(F))
features : 11
rows usable after lags : 1,248 of 1,872
The lag rule is the whole of leakage prevention in forecasting
Every lag is at least 4 weeks old. A forecast produced today for four weeks’ time cannot see last week’s sales, because last week has not happened when the purchase order goes in.
Use shift(1) in a four week ahead model and the accuracy will be excellent and the forecast unusable. This is the forecasting equivalent of putting the outcome in the feature set, and it is much easier to do by accident.
Section 4Exploratory Data Analysis
4aTarget variable analysis
Weekly units, strongly seasonal, with three SKUs whose demand is intermittent rather than continuous. Those three are 25.0% of the range and they decide which accuracy metric is usable.
4bNumerical variables
Seasonality dominates everything. Growing lines peak in spring, furniture in early summer, and wildlife feed runs counter-seasonally into winter. The Fourier terms in 3f exist to give a model that shape without one dummy per week.
F['woy'] = pd.to_datetime(F['week_start']).dt.isocalendar().week.astype(int)
seasonal = (F.groupby(['category', 'woy'])['units_sold'].mean()
.groupby('category').agg(['idxmax', 'max', 'min']))
for cat, row in seasonal.iterrows():
print(f'{cat:10s} peaks in week {row["idxmax"][1]:2d} '
f'peak {row["max"]:7.1f} trough {row["min"]:6.1f} '
f'ratio {row["max"] / max(row["min"], 0.1):5.1f}x')
4cCategorical variables
Promotions run in 7.4% of SKU weeks and lift demand materially, which is why on_promotion is a feature. It is also the one feature the buyer knows in advance, because the promotional calendar is set months out. That makes it usable at forecast time in a way that no other future variable is.
4dRelationships between variables
Last year’s same week is the single most useful column in the file, which is why the seasonal naive baseline in 7a is hard to beat and why any model that fails to beat it should not be deployed.
usable = F.dropna(subset=['lag_52'])
print('correlation with the same week last year :',
round(usable['units_sold'].corr(usable['lag_52']), 4))
print('correlation with four weeks ago :',
round(usable['units_sold'].corr(usable['lag_4']), 4))
4eTesting our hypotheses
| Hypothesis | Verdict | Evidence |
|---|---|---|
| H1. A model will comfortably beat last year | Partly, and not comfortably | WAPE 0.3073 against 0.3456 for seasonal naive, an improvement of 0.0383 |
| H2. MAPE is a reasonable cross-SKU metric | Wrong | It ranks Fire Pit Large, the SKU with the second worst WAPE, as one of the most accurate, using 11 of 24 weeks |
| H3. One held-out period is enough | Wrong | A single split reports 0.2542 against 0.3073 across six origins, and the folds range over 0.115 |
| H4. A point forecast is what the buyer needs | Wrong | The point forecast is above actual demand in only 56.2% of weeks, so half the orders are short |
4fSubgroups
The furniture lines are the subgroup that breaks things. They are low volume, high value and intermittent, and they are also the lines where a stockout is most expensive. Every metric decision below is really a decision about how much those three SKUs count.
Section 5Feature Engineering
5aThe leakage trap
Covered in 3f, and worth restating because it is the one that gets published. Any lag shorter than the horizon leaks the future. So does a rolling mean computed without a shift, so does a target encoding of SKU built on the whole file, and so does scaling fitted before the split.
The tell
A forecast that is suspiciously accurate at four weeks and no better at one week is usually reading something it should not. If accuracy does not degrade with horizon, the horizon is not real.
5bNew features
Four Fourier terms for annual seasonality, four lags, and an eight week rolling mean shifted by the horizon. Nothing per-SKU, so a new product can be forecast the day it has enough history rather than after a model rebuild.
5cEncoding
None needed. Promotion is already binary and week of year enters through the Fourier terms rather than as 52 dummies, which would cost more parameters than there are years of data to fit them.
5dFeature selection
Eleven features, fixed before the backtest. Selecting features by backtest performance would overfit the backtest, which is the one thing standing between this project and a number nobody can trust.
Section 6Model Selection
| Question | Choice | Why not the obvious alternative |
|---|---|---|
| How to validate | Rolling origin backtest over six origins | One holdout gives one number with no sense of its spread. Section 7c shows the folds differ by 0.115 WAPE, which is larger than the gap between the best model and the baseline |
| Which metric | WAPE headline, MASE per SKU | MAPE divides by the actual, and the actual is zero in 44.2% of weeks on some lines. Section 7b shows what it does |
| Which model | Gradient boosting, against ridge and three naive baselines | A seasonal naive baseline is the real competitor. A model that does not beat last year’s same week has no business being maintained |
| What to deliver | A point forecast and a 90% quantile | The buyer sets a reorder point, not an expectation. Section 7d |
Section 7Model Training
7aBaselines
Three, and the middle one is the one to beat.
| Baseline | What it does |
|---|---|
| Naive | Next four weeks equal last week |
| Seasonal naive | Next four weeks equal the same weeks last year. This is what the buying team does |
| Moving average | The mean of the last eight weeks |
7bComparing candidates
def wape(a, p):
return float(np.sum(np.abs(a - p)) / np.sum(np.abs(a)))
def mape(a, p):
m = a != 0
return float(np.mean(np.abs((a[m] - p[m]) / a[m]))) if m.sum() else float('nan')
ORIGINS = [104, 112, 120, 128, 136, 144]
def run_origin(origin):
"""Train on everything before the origin, forecast the next four weeks."""
tr = F[F['week_index'] < origin].dropna(subset=FEATS)
te = F[(F['week_index'] >= origin)
& (F['week_index'] < origin + HORIZON)].dropna(subset=FEATS)
out = {'actual': te['units_sold'].values, 'sku': te['sku'].values}
last = F[F['week_index'] == origin - 1].set_index('sku')['units_sold']
out['naive'] = np.array([last.get(s, 0) for s in te['sku']])
ly = F[(F['week_index'] >= origin - 52) & (F['week_index'] < origin - 52 + HORIZON)]
ly_map = {(r.sku, r.week_index + 52): r.units_sold for r in ly.itertuples()}
out['seasonal_naive'] = np.array([ly_map.get((s, w), 0)
for s, w in zip(te['sku'], te['week_index'])])
ma = (F[(F['week_index'] >= origin - 8) & (F['week_index'] < origin)]
.groupby('sku')['units_sold'].mean())
out['moving_average'] = np.array([ma.get(s, 0) for s in te['sku']])
out['ridge'] = np.clip(Ridge(alpha=1.0).fit(tr[FEATS], tr['units_sold'])
.predict(te[FEATS]), 0, None)
gbm = HistGradientBoostingRegressor(max_depth=4, learning_rate=0.06, max_iter=350,
random_state=SEED).fit(tr[FEATS], tr['units_sold'])
out['boosting'] = np.clip(gbm.predict(te[FEATS]), 0, None)
qm = HistGradientBoostingRegressor(loss='quantile', quantile=SERVICE, max_depth=4,
learning_rate=0.06, max_iter=350,
random_state=SEED).fit(tr[FEATS], tr['units_sold'])
out['p90'] = np.clip(qm.predict(te[FEATS]), 0, None)
return out
METHODS = ['naive', 'seasonal_naive', 'moving_average', 'ridge', 'boosting']
folds = [run_origin(o) for o in ORIGINS]
for m in METHODS:
a = np.concatenate([f['actual'] for f in folds])
p = np.concatenate([f[m] for f in folds])
print(f'{m:16s} WAPE {wape(a, p):.4f} MAPE {mape(a, p):.4f} '
f'MAE {np.mean(np.abs(a - p)):6.2f} bias {np.mean(p - a):+6.2f}')
naive WAPE 0.3682 MAPE 0.5779 MAE 21.51 bias -0.93
seasonal_naive WAPE 0.3456 MAPE 0.3945 MAE 20.19 bias +1.33
moving_average WAPE 0.4418 MAPE 1.1966 MAE 25.82 bias +2.19
ridge WAPE 0.3374 MAPE 0.8805 MAE 19.72 bias +0.61
boosting WAPE 0.3073 MAPE 0.5678 MAE 17.96 bias -0.10
Boosting is the most accurate at 0.3073 WAPE. Seasonal naive, which is what the team already does, reaches 0.3456.
The model wins by 0.0383 WAPE, an improvement of 11.1% over the baseline. That is real and it is smaller than most forecasting projects promise. H1 is half right.
What MAPE does to the same results
for sku in sorted(d['sku'].unique()):
a = np.concatenate([f['actual'][f['sku'] == sku] for f in folds])
p = np.concatenate([f['boosting'][f['sku'] == sku] for f in folds])
used = int((a != 0).sum())
print(f'{sku} WAPE {wape(a, p):.3f} MAPE {mape(a, p):.3f} '
f'weeks MAPE can use {used:2d} of {len(a)}')
AG-1010 WAPE 0.239 MAPE 0.251 weeks MAPE can use 24 of 24
AG-1020 WAPE 0.312 MAPE 0.285 weeks MAPE can use 24 of 24
AG-2010 WAPE 0.293 MAPE 0.477 weeks MAPE can use 24 of 24
AG-2020 WAPE 0.377 MAPE 0.493 weeks MAPE can use 24 of 24
AG-2030 WAPE 0.307 MAPE 0.517 weeks MAPE can use 23 of 24
AG-3010 WAPE 0.196 MAPE 0.291 weeks MAPE can use 24 of 24
AG-3020 WAPE 0.410 MAPE 1.970 weeks MAPE can use 23 of 24
AG-4010 WAPE 1.069 MAPE 0.972 weeks MAPE can use 11 of 24
AG-4020 WAPE 0.811 MAPE 0.661 weeks MAPE can use 15 of 24
AG-4030 WAPE 1.129 MAPE 0.519 weeks MAPE can use 11 of 24
AG-5010 WAPE 0.330 MAPE 0.380 weeks MAPE can use 24 of 24
AG-5020 WAPE 0.218 MAPE 0.281 weeks MAPE can use 24 of 24
The two metrics rank the range differently. Fire Pit Large has a WAPE of 1.1294, among the worst in the range, and a MAPE of 0.5192, among the best.
MAPE marks the worst forecast in the range as one of the best
Fire Pit Large has the highest WAPE at 1.1294. Fire Pit Large is close behind at 1.1294 and MAPE scores it 0.5192, better than most of the range.
MAPE cannot divide by zero, so on that SKU it uses 11 of 24 weeks and silently ignores the rest. The weeks it ignores are exactly the weeks the forecast got wrong.
It fails in the other direction too. Lawn Sand has a WAPE of 0.4096 and a MAPE of 1.97, because one week with a tiny actual produces a percentage error in the hundreds.
MASE puts every SKU on the same scale by dividing by what a naive forecast would have managed. 5 of 12 lines beat the benchmark, and the ones that do not are the intermittent lines.
7cHyperparameter tuning
Barely any, and for a reason the backtest makes visible.
fold_wape = [wape(f['actual'], f['boosting']) for f in folds]
for origin, w in zip(ORIGINS, fold_wape):
print(f'origin week {origin} WAPE {w:.4f}')
print('spread across folds :', round(max(fold_wape) - min(fold_wape), 4))
print('last origin alone :', round(fold_wape[-1], 4))
print('average of all six :', round(wape(np.concatenate([f['actual'] for f in folds]),
np.concatenate([f['boosting'] for f in folds])), 4))
origin week 104 WAPE 0.3645
origin week 112 WAPE 0.2495
origin week 120 WAPE 0.3183
origin week 128 WAPE 0.3111
origin week 136 WAPE 0.3560
origin week 144 WAPE 0.2542
spread across folds : 0.115
last origin alone : 0.2542
average of all six : 0.3073
The same model scores between 0.2495 and 0.3645 depending on which four weeks you test it on.
One split would have flattered this by 0.0531
Testing on the last origin alone gives a WAPE of 0.2542. The average across six origins is 0.3073.
The folds spread over 0.115 WAPE, which is 3.0 times the size of the improvement the model makes over the baseline. Any tuning decision made on one window is noise.
H3 is wrong, and this is why the hyperparameters are left near their defaults: there is not enough signal in the backtest to tune against without fitting the folds.
7dFinal evaluation
The buyer does not order the forecast. They order the forecast plus enough cover to hit a service level, and that is a different quantity.
actual = np.concatenate([f['actual'] for f in folds])
point = np.concatenate([f['boosting'] for f in folds])
p90 = np.concatenate([f['p90'] for f in folds])
print('point forecast covers demand in', round((point >= actual).mean(), 4), 'of weeks')
print('p90 forecast covers demand in ', round((p90 >= actual).mean(), 4), 'of weeks')
print('extra units carried ', round((p90 - point).mean(), 2))
point forecast covers demand in 0.5625 of weeks
p90 forecast covers demand in 0.8264 of weeks
extra units carried 18.89
A point forecast is a coin flip against a stockout
The best point forecast is above actual demand in 56.2% of weeks. Order to it and you are short roughly 43.8% of the time, which is what the buying team is experiencing now.
A 90% quantile forecast covers demand in 82.6% of weeks, short of its own target, and it needs 18.9 extra units per SKU week, about 32.3% more stock.
That gap between 82.6% and the 90% target is not a failure to report away. It is the honest statement that this data supports a service level a little below what was asked for.
Section 8Documentation and Handoff
Ship it, and ship the error bar with it
The model forecasts four weeks ahead at a WAPE of 0.3073 against 0.3456 for last year’s same week. That is an improvement of 11.1% and it is worth having.
It is also smaller than the spread between backtest windows, which is 0.115. Any single week where the forecast looks bad is inside normal variation, and the handoff has to say so before the first bad week rather than after it.
Order to the 90% quantile, not the point forecast. The point forecast is short 43.8% of the time.
What to do, and who owns it
| Action | Detail | Owner |
|---|---|---|
| Forecast the quantile, not the mean | Reorder points come from the 90% forecast. Expect about 32.3% more stock on hand than a point forecast implies | Buying |
| Report WAPE and MASE, never MAPE | MAPE ranked Fire Pit Large as one of the best forecasts in the range while its WAPE was 1.1294 | Analytics |
| Backtest on rolling origins before any change ships | Six origins minimum. A single window here would have reported 0.2542 instead of 0.3073 | Analytics |
| Fix the stockout censoring at source | 76 weeks record what could be sold rather than what was wanted. Until lost sales are estimated, the model is trained to reproduce the stockouts | Buying, Systems |
| Treat the three intermittent lines separately | Furniture is low volume, high value and zero in around 44.2% of weeks. A weekly forecast is the wrong shape for them and a reorder point on lead time is the right one | Buying |
What not to do
- Do not report MAPE. It ignores every week with zero demand, which on the intermittent lines is most of them.
- Do not tune on one holdout window. The folds here differ by more than the model beats the baseline by.
- Do not order to the point forecast. It covers demand in 56.2% of weeks.
- Do not use a lag shorter than the horizon. It produces an excellent backtest and a forecast that cannot be produced on the day it is needed.
- Do not claim the 90% service level is met. The quantile forecast reaches 82.6% on this data, and saying so is the difference between a model people trust and one they stop believing in March.
Reproducibility
| Item | Value |
|---|---|
| File | ashcroft-weekly-demand.csv, 1,872 SKU weeks |
| Window | 2023-08-07 to 2026-07-27, 156 weeks |
| Horizon | 4 weeks ahead |
| Validation | Rolling origin at weeks 104, 112, 120, 128, 136, 144 |
| Metrics | WAPE headline, MASE per SKU, MAPE reported only to show why it is not used |
| Model | Gradient boosting, plus a quantile model at 90% |
| Feature rule | every lag is at least 4 weeks old, because a forecast made today for four weeks ahead cannot use next week’s sales |
| Libraries | pandas, numpy, scikit-learn |
What to take from this
- Beat the seasonal naive baseline or do not deploy. Last year’s same week reaches 0.3456 here, and plenty of models never get past it.
- MAPE is not a general purpose metric. It cannot divide by zero, so it quietly drops the weeks it cannot score, and those are the weeks you got wrong.
- One backtest window is one sample. Report the spread across origins, not the best number you found.
- Every lag must be at least as old as the horizon. This is the forecasting version of leaking the target and it is far easier to do by accident.
- Buyers order quantiles. A point forecast is right half the time by construction, which is not a service level.
- Sales are not demand when the shelf was empty. Until that is fixed, the model learns to reproduce your stockouts.
The buying team wanted a forecast that beats last year. They got one, by 11.1%, and two things worth more than that: a metric that does not lie about the intermittent lines, and a reorder point that reflects how uncertain the forecast actually is.
Before you trust a single backtest window
Model Evaluation Code Generator
Rolling origin validation, error metrics that survive zeros, and quantile evaluation rather than a point estimate. The defaults in most tutorials are the ones this project spends three sections arguing against.
Free, no signup. Pairs with the Model Selection Generator.
Watch it once it is live
Monitoring and Drift Code Generator
A forecast degrades quietly as demand patterns shift, and the first sign is usually a buyer losing confidence rather than a metric moving. This generates the drift checks and thresholds calibrated on your own error distribution.
Free, no signup.
Companion projects. The Profit Leak Audit works on the stock these forecasts order, and shows what carrying the wrong things costs. Rare Event Detection is the other project here where the standard metric is the problem.