Skip to content

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 / mean of 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 class

Rank 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 once python -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:

Terminal window
python data/make_data.py

That writes three CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical because the seed is fixed):

  • Part Askus_a.csv (300 SKUs: sku_id, unit_price, annual_units).
  • Part Bskus_b.csv (600 SKUs: sku_id, unit_price) and demand_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:

Terminal window
python part_a/segment.py

Prefer 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)

FunctionWhat it does hereDocs
s.sort_values() / np.argsortRank SKUs by valuesort_values
np.cumsumCumulative value share for the Pareto cutcumsum
np.whereAssign A/B/C from the cumulative thresholdswhere
df.groupby("abc").agg(...)Value share per classgroupby

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 # TODO it 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 (AXCZ). 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 — columns sku_id, segment (the 9-way class, e.g. AX, CZ).
  • report.json — with keys skus, achieved_metric (you can leave achieved_metric null; the grader scores you).

Then:

Terminal window
python part_b/starter.py
pytest part_b/test_solution.py -q

If pytest reports everything as skipped, it just means the output files don’t exist yet — run python part_b/starter.py first (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:

  • pytest reports 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.py reproduces 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.

FieldValue
DatasetSynthetic SKU catalogue + monthly demand (Part A 300 SKUs; Part B 600 SKUs × 12 months)
TypeSynthetic
Generated bydata/make_data.py (this repo), seeds 20260742 (A) and 20260743 (B)
Source URLNot applicable — generated, not fetched
LicenseNot applicable — synthetic, no third-party rights
Access / generation date2026-07-18
RedistributionPermitted — synthetic; output regenerated from the seeded script, never committed
robots.txt checkedNot applicable — no scraping
ToS URLNot 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.