ABC/XYZ Inventory: segment by the money and the variability, not the volume
The data in this project is synthetic — a SKU catalogue and monthly demand generated locally by
data/make_data.py. That is deliberate: to grade the segmentation you need the known-correct class per SKU, and to show why volume and raw std are the wrong axes you need price/volume and level/variability generated independently — neither of which a real, confidential catalogue gives you. No signup, no downloads, no GPU.
Before you start
New to inventory segmentation? Start here.
You can’t manage ten thousand SKUs the same way, so you segment them and apply a policy per segment. Two standard axes:
- ABC — by value. Rank SKUs by annual value (unit price × annual units) and split by cumulative share: the A items are the handful that carry ~80% of the value, B the next ~15%, C the long tail. Tight control goes where the money is.
- XYZ — by variability. Rank by how steady demand is, using the coefficient of variation
(
CV = std / meanof monthly demand): X steady, Y moderate, Z erratic. Steady items are easy to plan; erratic ones need buffer or made-to-order.
Two mistakes are everywhere, and this project is built around them:
- Ranking ABC by volume (units sold) instead of value. A cheap item sold by the pallet moves huge volume but little money — it is not an A item.
- Measuring variability with the raw standard deviation instead of the CV. Std grows with the demand level, so a big, perfectly steady SKU looks “erratic” next to a tiny one. Dividing by the mean removes the scale.
What you’ll learn: build the ABC classification correctly (by value), see how far the volume shortcut drifts, then add the XYZ axis (by CV) for the full 9-way segment. Section 3 lists exactly what you’ll be able to do.
New to Python too? See Start Here — it starts from zero, and shows how to run this in your browser with nothing to install.
1. The Brief
Your warehouse carries thousands of SKUs and every one is being reviewed on the same schedule with the same buffer — wasteful for the cheap tail, risky for the valuable few. You’ll segment the catalogue the way planners actually do it: A/B/C by value and X/Y/Z by demand variability, so each SKU gets a policy that fits. Along the way you’ll see why the two obvious shortcuts (rank by units, measure spread by std) quietly put SKUs in the wrong box.
ABC/XYZ is textbook inventory management and a real first move in any S&OP or planning project. The segmentation drives review frequency, safety stock, and whether an item is even worth stocking — so getting the axes right (value, not volume; CV, not std) is the whole game.
2. Difficulty & time
Difficulty 3 / 10. One tool (pandas), two well-defined classifications, and a fully specified
path — no algorithm, no modelling. Calibrated against the ledger (G10): a peer of silent-row-loss,
idempotent-loads, and duplicate-invoices (all 3) — single-mechanic builds where the lesson is a
specific data-handling judgment (here: pick the right axis — value not volume, CV not std), not a
pipeline or a model. It sits below maverick-spend (4), which layers a conditional policy rule.
It stays above the 1–2 band because both naive axes look reasonable and are both wrong, and Part B
adds a second axis computed from a time series.
Time is separate from difficulty: Part A is about an hour; Part B is 2–3 hours. The scripts run in under a second.
3. What you’ll be able to do after
- Build an ABC (Pareto) classification by cumulative value, and read the value share per class.
- Explain why volume is the wrong axis — and quantify how much a volume ranking reshuffles the catalogue.
- Compute the XYZ axis with the coefficient of variation, and explain why raw std misleads.
- Produce the 9-way ABC × XYZ segment and map each cell to a stocking-policy intent.
The finished result
By the end of Part A you’ll have the Pareto ABC split and the proof that the obvious shortcut (rank by volume) gets ~40% of SKUs wrong:
ABC by value: A 81 SKUs -> 79.9% of value (the vital few) B 91 SKUs -> 15.1% C 128 SKUs -> 5.0% (the long tail)value-ABC vs volume-ABC agree on only 59% of SKUs -- 122 land in a different classRank by value and the money concentrates where you’d expect; rank by volume and cheap pallet-movers get wrongly promoted to A. Choosing the right axis is the whole lesson.
4. Prereqs & time box
You can write a function and use a pandas DataFrame (groupby, sort_values, cumsum, pivot).
No inventory background needed — the primer above covers the vocabulary.
Where to write and run code
Two ways to run this project — pick either:
- In your browser (no install). If the project page shows a ▶ Run button, click it to run Part A right here — nothing to set up. Part A uses only pandas, which runs in the browser.
- On your computer. The one-time setup — installing Python, downloading the code from the
course site (no GitHub, no
git), opening a terminal, creating a virtual environment, installing the requirements, and running Part A step by step — is covered once in Start Here. Come back here oncepython -c "import pandas"runs without error. Part B (writing your own solution) needs this local setup.
Time box
- Part A — Guided Build: 60–90 minutes. Hard cap.
- Part B — Your Turn: 2–4 hours. Hard cap.
5. Setup & data
The data is synthetic and generated locally — see the banner above and SOURCES.md for full
provenance. One command creates both parts’ data:
python data/make_data.pyThat writes three CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical
because the seed is fixed):
- Part A —
skus_a.csv(300 SKUs:sku_id, unit_price, annual_units). - Part B —
skus_b.csv(600 SKUs:sku_id, unit_price) anddemand_b.csv(sku_id, month, units— 12 months per SKU). Part B derives annual units by summing the series, and variability from it.
6. Part A — Guided Build
You’ll classify the 300-SKU catalogue by value. Run the whole thing, or (better) one # %% cell at
a time — see Start Here:
python part_a/segment.pyPrefer the browser? If this page has a ▶ Run button, click it instead of the command above — Part A runs in your browser with nothing to install. The STEP output and every checkpoint below are identical either way.
▶ Try it now — run the real Part A right here in your browser. This one button makes the data and runs Part A for you — you do not need to install anything, and you do not need to type any of the python … commands anywhere on this page (those are only for running on your own computer instead). First run loads Python + this project’s libraries: usually ~10–20 s, a little longer for machine-learning projects.
The full, commented script is in part_a/segment.py. The spine:
Step 1 — Annual value. value = unit_price × annual_units.
Checkpoint: the top-value SKU isn’t the top-volume one.
Step 2 — ABC by value. Sort by value, cumulative split at 80% / 95%.
Checkpoint: A ≈ 81 SKUs holding ~80% of value; B ~91 (~15%); C ~128 (~5%) — the Pareto shape.
Step 3 — The trap: ABC by volume.
Checkpoint: value-ABC and volume-ABC agree on only ~0.593 of SKUs (~122 differ) — cheap high-volume items get wrongly promoted to A, expensive low-volume ones demoted.
Step 4 — The policy read. What A/B/C imply for review and buffers. Writes part_a/abc_a.csv.
Why this and not that
- Why value, not volume. ABC exists to point management attention at the money. Volume answers “what moves a lot,” not “what’s worth a lot” — and for a mixed catalogue those are ~40% different SKUs. Policy follows value.
- Why cumulative share, not a fixed count. “Top 50 SKUs” ignores how concentrated value is. The 80/95 cumulative cut adapts to the actual Pareto curve, which is the thing that justifies differentiated control.
- Why this sets up XYZ. ABC tells you which SKUs are worth controlling; XYZ (Part B) tells you how hard each is to plan. You need both to choose a policy — a valuable but erratic item (A-Z) is a very different problem from a valuable steady one (A-X).
Functions you’ll meet (and where to read more)
| Function | What it does here | Docs |
|---|---|---|
s.sort_values() / np.argsort | Rank SKUs by value | sort_values |
np.cumsum | Cumulative value share for the Pareto cut | cumsum |
np.where | Assign A/B/C from the cumulative thresholds | where |
df.groupby("abc").agg(...) | Value share per class | groupby |
If something looks off
- A has 81 SKUs, not a round number — why? The A/B/C cut is on cumulative value (80% / 95%), not on a fixed SKU count. However many SKUs it takes to reach 80% of the value is your A class, so the counts (81 / 91 / 128 here) fall out of the data, not a quota. That’s the Pareto shape.
- A checkpoint number doesn’t match. The data is seeded, so the counts and value shares are
exact. Re-run
python data/make_data.py(you may have skipped or edited it), then re-run Part A. - Setup problems (Python not found,
pip,FileNotFoundError, re-activating the venv): see the troubleshooting list in Start Here.
7. Part B — Your Turn
Part B is the optional “your turn” half — a guided fill-in-the-blank, not a blank page. You get a working starter (
part_b/starter.py) that already handles the easy version; your job is to finish the# TODOit marks out. More open than Part A’s step-by-step, but you are never staring at an empty file. If you finished Part A and want to prove the skill, this is where it happens. New to Python? It’s completely fine to stop after Part A and come back later.
Same core skill, both axes. Assign each of the 600 SKUs its full ABC × XYZ segment (AX … CZ).
ABC is by value (as Part A); XYZ is by the coefficient of variation of monthly demand
(CV = std / mean; X < 0.5, Y < 1.0, else Z), which you compute from demand_b.csv. The starter
uses volume for ABC and raw std for XYZ and scores ~0.23.
The transfer: Part A ranked on one scalar axis (value). Part B adds a second axis that needs the demand time series — and the variability must be relative (CV), because raw std just tracks the demand level and would call every big SKU “erratic.”
Acceptance criteria (checkable)
Your solution writes into part_b/out/:
segments.csv— columnssku_id, segment(the 9-way class, e.g.AX,CZ).report.json— with keysskus,achieved_metric(you can leaveachieved_metricnull; the grader scores you).
Then:
python part_b/starter.pypytest part_b/test_solution.py -qIf
pytestreports everything asskipped, it just means the output files don’t exist yet — runpython part_b/starter.pyfirst (once your solution fills in the TODOs), then re-run.
You’re done when the tests pass: ≥ 90% of SKUs get the exact correct segment (the reference reaches 1.00). The volume-ABC + std-XYZ baseline scores ~0.23.
Constraints — what must be true of your solution, not how to get there
- ABC on annual value (price × summed units), cumulative 80% / 95% thresholds.
- XYZ on the coefficient of variation (std / mean) of the monthly series, thresholds 0.5 / 1.0.
- One segment per SKU; use all 600.
- Derive annual units and variability from
demand_b.csv(don’t assume a precomputed column).
Hints
Hint 1 — reshape the demand
demand_b.csv is long (one row per sku-month). Pivot it to a SKU × 12 matrix (or group by
sku_id) so you can sum for annual units and take mean/std across the 12 months per SKU.
Hint 2 — the two axes
ABC: reuse Part A’s cumulative-value split on unit_price × annual_units. XYZ: cv = std / mean
of each SKU’s monthly demand, then band by < 0.5 / < 1.0 / else.
Hint 3 — why std alone fails (still not the code)
Compare std and cv for a big steady SKU vs a small erratic one: the big one has larger std but
smaller CV. Banding on std would call it erratic; the CV (dividing by the mean) fixes that.
8. Self-check
You don’t need the answer key. You’re done when all of these hold:
pytestreports segment accuracy ≥ 0.90 (aim for ~1.00 — the thresholds are exact).- Your A class is the high-value SKUs, not the high-volume ones (spot-check a cheap high-volume SKU — it should be B/C, not A).
- Re-running from
python data/make_data.pyreproduces the same numbers — the data is seeded.
And the tell-tale: some big high-volume SKUs should land in X (steady), not Z. If every high-volume SKU is Z, you’re banding on std, not CV.
9. Stretch
- Policy matrix. Attach a stocking policy to each of the 9 cells (e.g. A-X tight/low-buffer, C-Z made-to-order) and report the SKU and value count per cell.
- Intermittent demand. Add SKUs with many zero-demand months and show that plain CV is unstable for them — then try an intermittency-aware measure (e.g. average inter-demand interval) as the Z-trigger.
- (Genuinely hard) Sensitivity. Sweep the ABC (80/95) and XYZ (0.5/1.0) thresholds and report how many SKUs change segment — the segmentation is a modelling choice, and you should know how fragile the boundaries are.
10. Ship it
Put this in a portfolio repo and it counts. In that repo’s README:
- State the finding in one sentence a planner gets: “ranking by units mislabelled ~40% of SKUs; value-based ABC plus CV-based XYZ puts the A-X items (valuable and steady) where tight control pays off.”
- Show the value-vs-volume ABC disagreement and the 9-way segment counts.
- Include the CV calculation and one paragraph on why volume and raw std are the wrong axes. That
paragraph is what signals you understand the segmentation, not just
groupby.
Keep make_data.py and the tests in the repo so anyone can reproduce your numbers from zero.
11. Sources
Rendered from SOURCES.md.
| Field | Value |
|---|---|
| Dataset | Synthetic SKU catalogue + monthly demand (Part A 300 SKUs; Part B 600 SKUs × 12 months) |
| Type | Synthetic |
| Generated by | data/make_data.py (this repo), seeds 20260742 (A) and 20260743 (B) |
| Source URL | Not applicable — generated, not fetched |
| License | Not applicable — synthetic, no third-party rights |
| Access / generation date | 2026-07-18 |
| Redistribution | Permitted — synthetic; output regenerated from the seeded script, never committed |
| robots.txt checked | Not applicable — no scraping |
| ToS URL | Not applicable — no external source |
Synthetic data teaches the mechanic; it does not prove a finding about the real world. Price, volume, and variability were generated independently on purpose — the segmentation technique is the point, not a claim about any real catalogue.