Days of Cover: the SKU about to stock out isn't the one with the least stock
Before you start
New to this? Two numbers describe any item on a shelf: how much you have right now
(on-hand), and how fast it sells (average daily demand — units sold per day). Divide the
first by the second and you get days-of-cover: how many days the current stock will last
before it hits zero. Example: 240 batteries on hand, selling 60 a day, is 240 ÷ 60 = 4 days of
cover — a big pile that empties in a week. A wrench set with only 6 on hand but selling 0.3 a day
has 6 ÷ 0.3 = 20 days — almost nothing on the shelf, but three weeks of stock. This project
teaches you to rank stockout risk by days-of-cover, and to see why the tempting shortcut — “flag
whatever has the lowest count” — flags the wrong items.
1. The Brief
You’re handed a store’s shelf snapshot — every SKU’s on-hand count and its recent daily sales — and asked one operational question: which SKUs are about to stock out? The reflex answer is to sort by on-hand and reorder the smallest piles. That read is wrong, and it’s wrong in the expensive direction: it sends you chasing slow movers that happen to have low counts while a fast-selling SKU sitting on a big pile quietly runs empty and costs you the sale. The fix is one division — days-of-cover = on-hand ÷ average daily demand — and a threshold. You’ll build the at-risk flag correctly, watch the naive “lowest stock” list miss three of four real risks, and learn the one rule that puts every SKU on the same comparable axis: time, not quantity.
2. Difficulty & time
Difficulty 2/10. One tool (pandas), one file, one concept, and Part A’s path is fully
specified — the low end of the scale. It sits below silent-row-loss (3), cohort-retention
(3), and abc-xyz-inventory (3): each of those has more moving parts (a join to validate, a
cohort matrix to pivot, two segmentation axes at once), whereas this turns on a single idea —
divide stock by demand — applied cleanly. Like abc-xyz-inventory, its real content is choosing
the right axis rather than any hard syntax, which is what keeps it a judgment skill and off the
floor of the scale. Part A is a short read-and-run; Part B is where you do the work.
3. What you’ll be able to do after
- Compute days-of-cover for a set of SKUs and flag the ones below a cover target.
- Explain in one sentence why raw on-hand quantity misranks stockout risk, and give an example of a high-stock SKU that’s at risk and a low-stock SKU that isn’t.
- Derive an average daily demand rate from a short sales/usage history when it isn’t handed to you.
- State what a days-of-cover target actually encodes (the restock interval you’re covering).
4. Prereqs & time box
Part A: ~30 minutes. Part B: ~1.5 hours. Both are hard caps — if Part A runs long, you’re overthinking it. You need to read a CSV with pandas, do arithmetic on columns, and group rows; nothing more.
For where to write and run Python (install, virtual environment, per-OS terminal, running a
script step by step), see Start Here guide. No
account and no GPU — requires_signup is empty.
5. Setup & data
The data is synthetic, generated locally by data/make_data.py from a
fixed seed (byte-identical every run) — see Sources. Generate it once:
python data/make_data.pyThat writes data/store_a.csv (Part A) and data/pantry_b.csv (Part B). Nothing is downloaded;
there is no external source. Synthetic is the right call here: the lesson is whether your at-risk
flag matches the true days-of-cover, and only generated data lets you know the true on-hand and
demand for every SKU in advance.
6. Part A — Guided Build
Run the guided script (or paste it cell-by-cell — each # %% is one step):
python part_a/flag_stockouts.pyIt walks six steps and writes part_a/cover_summary_a.csv. The shape of the argument:
- STEP 1. Load the 10-SKU shelf snapshot. Checkpoint: on-hand ranges from a wrench set at 6 units to deck screws at ~498 — a 80× spread. That column is not the risk column.
- STEP 2. The naive read: flag the four lowest on-hand SKUs. Checkpoint: it flags WRENCH-SET, DRILL-18V, HOSE-50, CAULK-SIL — the least stock on the shelf.
- STEP 3. The real read: days-of-cover = on-hand ÷ avg daily demand, sorted low to high. Checkpoint: lowest cover is CAULK-SIL 3.33, then PAINT-WHT 3.93, AA-BATT 4.03, LED-A19 4.50 days.
- STEP 4. Flag cover < 7 days. Checkpoint: the at-risk set is CAULK-SIL, PAINT-WHT, AA-BATT, LED-A19 — and only one of them (CAULK-SIL) was in the naive list. The naive read missed the paint, the batteries, and the bulbs.
- STEP 5. The two extremes side by side: AA-BATT (242 on hand, 4.0 days → risk) vs WRENCH-SET (6 on hand, 20.0 days → fine). Same trap, opposite directions.
- STEP 6. Write
part_a/cover_summary_a.csvwith cover and the at-risk flag per SKU.
Why this and not that
- Why not just sort by on-hand? Because on-hand answers “how much is there,” not “how long will it last.” Stockout is a race between the pile and the sales rate; you can’t read it off the pile alone. Dividing by demand converts two un-comparable piles into one comparable unit: days.
- Why does a big pile (AA batteries, 242 units) end up at risk? Because it sells 60/day. A high count only helps if demand is low relative to it — cover, not count, is what carries that ratio.
- Why a 7-day target? The target is the restock interval you’re covering: a store that reorders weekly needs ≥ 7 days of cover on every SKU, or it goes empty before the truck arrives. Change the interval and the threshold moves with it.
Functions you’ll meet (and where to read more)
| Function | What it does here |
|---|---|
pandas.read_csv | Load store_a.csv into a DataFrame |
DataFrame.sort_values | Rank SKUs by days-of-cover, lowest first (STEP 3) |
DataFrame.nsmallest | Pull the lowest-on-hand SKUs for the naive read (STEP 2) |
If something looks off
- If your at-risk list equals your “lowest on-hand” list, check that
avg_daily_unitsactually varies across SKUs — the whole effect comes from demand differing between items. - If nothing is flagged, confirm you divided on-hand by demand (not the reverse) and compared against 7 — a cover of 20 means 20 days, well clear of the target.
- A SKU with a huge pile showing up as at-risk is the point of this project, not a bug — read it against the checkpoints above before assuming you broke something.
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 flags SKUs the naive way; 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 skill, new setting, one new twist. A bistro’s pantry_b.csv has one row per ingredient per
day for the last 14 days (units_used), plus each ingredient’s current on_hand. The chef wants
the same at-risk list — but this time nobody computed the daily demand for you. The starter
flags ingredients whose raw on-hand is below a fixed par level of 20 units, which is wrong for the
exact Part A reason: saffron (3 units, a pinch a day) looks scary and is safe, while tomatoes (a
big tub, used fast) slip through. The # TODO is to derive each ingredient’s average daily demand
from its 14-day history, compute days-of-cover, and flag below the 5-day target.
Acceptance criteria (checkable)
Your part_b/starter.py writes part_b/out/report.json with:
at_risk— the list of ingredient names with fewer than 5 days of cover,target_days— the cover target you used (5).
Then:
python part_b/starter.pypytest part_b/test_solution.py -qYou’re done when the tests pass: every truly-at-risk ingredient is caught, and the flagged set’s F1 against the true set is ≥ 0.90 (the reference derives demand and hits 1.0). The naive raw-stock rule catches only 2 of the 5 real risks — F1 0.40 — and fails both tests.
Constraints — what must be true of your solution, not how to get there
- The flag must be days-of-cover against the target, not raw on-hand and not a fixed par level.
- Every ingredient must get a demand rate derived from its own history — don’t hard-code or guess.
- Don’t drop the low-usage ingredients — they belong in the analysis; they just rarely turn out to be at risk.
Hints
Hint 1 — the history is the new part
In Part A the demand rate was a column. Here it isn’t — you have 14 daily units_used rows per
ingredient. Collapse each ingredient’s history to a single average daily demand before you can
divide. groupby("ingredient")["units_used"].mean() is the move.
Hint 2 — on_hand is repeated
on_hand is the same on every one of an ingredient’s 14 rows, so take one value per ingredient
(e.g. groupby("ingredient")["on_hand"].first()), not the sum of all 14.
Hint 3 — the shape of the answer
cover = on_hand / avg_daily_demand, then at_risk = sorted(cover[cover < 5].index). It should
come out to five ingredients, and they should be the fast movers — not the ones with the smallest
raw quantity on the shelf.
8. Self-check
- Your at-risk ingredients should be the fast movers, and at least one should have a comfortable-looking on-hand quantity — that’s the whole point. If your list is just the smallest-quantity ingredients, you flagged by stock, not by cover.
- Re-running
python data/make_data.pynever changes the data (it’s seeded), so your list shouldn’t move between runs. - If you can state, in one sentence, why a big pile can still be at risk, you’ve got the transferable skill — the specific ingredients are secondary.
9. Stretch
- Add a reorder quantity to Part A: for each at-risk SKU, compute how many units bring it back
up to a chosen cover target (e.g. 14 days) —
target_days × avg_daily_units − on_hand— and sort the buy list by urgency. - In Part B, weight the demand estimate toward recent days (e.g. last 3 days count more than the first 3): a ramping ingredient’s 14-day average understates today’s rate. See which ingredients change their flag under a recency-weighted demand.
- (Genuinely hard.) Demand isn’t constant — it’s noisy. A 5-day cover is riskier for a volatile ingredient than a steady one. Using each ingredient’s day-to-day variability from the history, work out a cover target that accounts for demand uncertainty (a safety buffer), and explain why a single flat day threshold under- or over-protects different ingredients.
10. Ship it
Put this in a repo README so it counts as a portfolio piece: one sentence on the trap (“ranking by raw on-hand flagged 3 of 4 SKUs wrong; days-of-cover fixed it”), the AA-batteries-vs-wrench-set example with both numbers, and the one-line rule (divide stock by demand — rank by time, not quantity). Add a short note on Part B showing you transferred the same idea to a different domain (a kitchen, not a shelf) and derived the demand rate from a history yourself. That last part — recognising the same skill under a different surface, and reconstructing the input it needs — is what an interviewer is actually checking for.
11. Sources
See SOURCES.md. The data is synthetic, generated by
data/make_data.py from a fixed seed; there is no external source, and
nothing is downloaded or committed.
▶ Run Part A in your browser
▶ 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.
Next up
Finished this one? Continue the Supply Chain track:
Stock Turns: the biggest pile of inventory is where your cash goes to sit → · Browse all courses