Collision Parts Demand Forecasting

Collision parts are replaced after an accident, so they sell rarely and without much of a pattern. That makes them the hardest thing in a spare-parts catalogue to forecast, and the easiest to get wrong.

Intermittent Demand Croston / SBA / TSB Per-SKU Model Selection Demand Segmentation Python
4 Demand classes
28,890 M5 products tested
1,776 Car parts tested
2 Public datasets tested

Where it started

For the final assignment of the Career Accelerator at the University of Cambridge we were given an employer project. The client, a global automotive distributor, tasked us with improving how they forecast demand for collision parts: the body and exterior components replaced after an accident, which by their nature sell rarely and unpredictably.

Working as part of a five person team, we went about tackling the problem. I owned two workstreams, the external data and the lumpy demand. The engagement is under a non-disclosure agreement, so what follows is the method, not the client's results. Every figure on this page comes from public datasets.

Three decisions that shaped the approach

01
Publish the external data once, then test whether it helps
Built the pipeline that collected, cleaned and published exogenous variables in a documented format the team could join straight onto their models, so everyone worked from one agreed version. Then ran the same models with the panel and without it, because adding external data tends to be treated as self-evidently good rather than tested.
02
Split further than the demand class
Sorting parts into four classes still leaves too much going on inside each one. I sub-segmented by where a part sits in its lifecycle, new, established or dormant, and by how often it sells, which separates a part that genuinely moves slowly from one that is simply new to the catalogue or on its way out.
03
Let each part pick its own model
Every part is scored against a field of candidates and assigned whichever one fits its own history, and can move to a different one as that history changes. The field covers Croston, SBA and TSB, seasonal naive and moving averages, gradient-boosted and tree-based models, and a two-stage hurdle model for demand that is mostly zeros.

External data, published once

The team needed external variables in a state they could actually model with, and needed to agree on one version of them. The dashed step is the one I would argue for hardest.

External data workstream

Collect, publish, then test

Owned end to end
External sources Collect, clean, validate One published panel, documented The team joins it onto their models Then test it: the same models, with the panel and without it

Four kinds of demand

The split rests on two questions: how often does a part sell, and how steady is the quantity when it does. That gives four classes, and they do not respond to the same treatment.

Smooth29% zero months
Erratic27% zero months
Lumpy67% zero months
Intermittent82% zero months

Real demand, not illustrations: three years of monthly sales for one representative part in each class, from the open car-parts dataset, on a common vertical scale. Lumpy and intermittent both sell rarely; the difference is that lumpy jumps around in size when it does, while intermittent is roughly the same quantity each time.

Why the class on its own is not enough

Lumpy was the hardest class in the catalogue and the one I took on. These three parts all sit in the same class, and all three would be handed the same model.

New to the catalogueno sales until halfway
Establishedsells throughout
Going dormantstops, and never restarts

Three real parts from the same demand class, three years each. Same classification, three completely different situations.

01

New

No history to learn from, and about to matter. A model fitted on its past has almost nothing to work with.

02

Established

The steady case the models are actually built for, and the only one of the three they handle well.

03

Dormant

Stopped selling eighteen months ago, and is quietly being forecast, and stocked, as though it might come back.

Does it work anywhere else?

The method was built for one client's lumpy parts. The question I wanted to answer next was whether it worked elsewhere, so I extended it from lumpy to all four demand classes, widened the candidate field so each class had models suited to it, and ran it end to end on public data where the results could be published and checked.

M5 · Walmart retail benchmark
SegmentProductsWMAPEvs naiveBias
Intermittent13,45328.550.998−14.2%
Smooth12,76525.630.981−9.7%
Lumpy1,70138.451.018−23.6%
Erratic97140.440.912−18.0%

The headline accuracy is healthy. A WMAPE in the twenties on retail demand is a respectable number and it would be easy to stop there.

What "vs naive" measures. The naive forecast is not zero. It is the simplest thing a planner could do without any model at all: carry the recent level forward. That column divides my engine's error by the naive forecast's error, so 1.018 means my engine was 1.8% worse than just carrying the level forward. The column is not asking whether the forecast is good. It is asking whether the machinery was worth building.

On that measure M5 is humbling. Three of the four segments land between 0.98 and 1.02, and lumpy is slightly worse than the simple approach. M5 is not a hard forecasting problem: the typical product sells around ten units a month in most months, so carrying the level forward already works well. Good numbers, earned mostly by the data.

Then: car parts, deliberately sparse

So I took it somewhere sparse on purpose. The open car-parts dataset is the same kind of catalogue the client engagement dealt with, and nothing like M5, even though both label a segment "intermittent".

Car parts · intermittent

Average monthly demand
0.33 units
Months with no sale
82%
Typical sale, when it happens
1 unit

M5 · intermittent

Average monthly demand
9.80 units
Months with no sale
42%
Typical sale, when it happens
17 units
Car parts · open intermittent-demand dataset
SegmentPartsWMAPEvs naiveBias
Intermittent1,51590.190.891−27.1%
Lumpy24286.330.812−33.0%
Smooth1583.970.851+7.8%
Erratic470.520.860−29.2%

Greyed rows are too small a population to conclude anything from. Bias is signed: negative means the forecast sits below what actually sold.

Those percentages look like a broken model, and this is the part worth slowing down for. A typical car part sells one unit, roughly twice a year. Percentage error divides by demand, so at a third of a unit a month, being off by a single unit is a 300% error. The floor is set by the data, not by the model.

An oracle, handed each part's true average for the test window it had not seen, scores 119.8% WMAPE on the lumpy segment. My engine, with no such advantage, scores 86.33%.

A forecast with perfect hindsight of the answer does worse, on this metric, than the one built without it. That is not a claim about the model being clever. It is proof that percentage accuracy on demand of one or two units a month is measuring the catalogue, not the forecast, and that ranking this kind of data by WMAPE will point you at exactly the wrong problems.

What the typical part actually looks like

Pooled WMAPE is a single number for a whole segment, weighted by volume, so a handful of high-turnover parts can set it for everyone else. A planner does not manage a segment. They manage lines.

Per-part accuracy

Share of parts by error band

Median shown right
SHARE OF PARTS BY ERROR BAND MEDIAN CAR PARTS Intermittent · 1,515 38.5 Lumpy · 242 42.9 M5 RETAIL Smooth · 12,765 27.7 Intermittent · 13,453 31.2 Lumpy · 1,701 46.8 Erratic · 971 47.4 under 25% 25 to 50% 50 to 100% 100% and over

Per-part error is a three-month rolling WMAPE for each part; the pooled figures in the tables above span the full evaluation horizon and are volume-weighted, so the two are related but not the same calculation. Car-parts smooth and erratic are omitted at 15 and 4 parts.

On car-parts intermittent the pooled figure is 90.19. The median part comes in at 38.5, seven in ten are under 50%, and one part in a thousand is at or above 100%. The segment-level number describes almost none of the parts in it.

The comparison with M5 is the part I did not expect. On the per-part view car parts is the more dependable of the two: essentially no part exceeds 100% error, while on M5 between 9% and 20% of parts do, depending on segment. The dataset with the far worse headline number has the better distribution underneath it, and only the per-part view shows that.

The only column that compares across datasets

Error relative to a naive forecast. Left of the line the engine is earning its place; right of it, the simple approach was better. Segments too small to judge are left out.

Both datasets

Error relative to a naive forecast

1.00 = naive
0.80 0.90 1.00 = naive CAR PARTS Lumpy · 242 0.812 Intermittent · 1,515 0.891 M5 RETAIL Erratic · 971 0.912 Smooth · 12,765 0.981 Intermittent · 13,453 0.998 Lumpy · 1,701 1.018 Beats a naive forecast Loses to a naive forecast

Car parts, the dataset with the ugly percentages, holds the strongest result on this measure. M5, the dataset with the pretty ones, barely registers.

What that means for planning

Car-parts lumpy at 0.812 is the best result across either dataset, and it comes from the segment with the worst headline number. M5 lumpy, at 1.018, has a WMAPE less than half as large and is beaten by carrying the level forward. Judged on accuracy alone the ranking inverts, and you would put your effort into the catalogue that never needed it.

The conclusion is a planning one rather than a modelling one. A forecasting engine should be pointed at the part of a catalogue where it demonstrably beats the simple alternative. Everything else belongs on a min/max reorder policy with safety stock, where the question is how much cover to hold rather than what next month looks like. Deciding which lines fall on which side is worth more than another point of accuracy on lines that were never the problem.

Built with

py Python
pd pandas
sk scikit-learn
lg LightGBM
xg XGBoost
sm statsmodels
fa FastAPI
gh Git

On the client data

The method was developed on a client engagement covered by a non-disclosure agreement. Every figure on this page is reproduced on public datasets. No client data, results or identity appear here.