The Bullwhip Effect: steady demand, wild orders, and finding who amplifies
The data in this project is synthetic — generated locally by
data/make_data.py, with no external source. That is deliberate: the bullwhip effect is a property of ordering policies, so a seeded simulator makes the amplification exact and reproducible, and lets the grader recover the true per-stage ratios — which real, confounded supply-chain data never cleanly gives. No signup, no downloads, no GPU.
Before you start
New to the bullwhip effect? Start here.
A supply chain is a line of stages — a retailer buys from a wholesaler, who buys from a distributor, who buys from the factory. Each stage orders from the one above it to restock what it sold.
The bullwhip effect is a famous, well-documented surprise: even when the end customer’s demand is steady, the orders passed up the chain swing more and more wildly at each stage. The factory ends up seeing violent order swings for a product whose actual demand barely moved. It’s not irrationality — it falls out of ordinary forecasting and restocking. Each stage forecasts the (already jumpy) orders it receives, adds safety stock, and refills a lead-time pipeline, and in doing so amplifies the swing it passes on.
The way to measure it is the bullwhip ratio at a stage:
bullwhip ratio = variance of the orders this stage PLACES ------------------------------------------- variance of the demand this stage RECEIVESA ratio above 1 means this stage amplified. The crucial subtlety: you measure it per stage — each echelon against the demand it saw (the stage just below it) — not everyone against the end customer. Measured cumulatively, the number just grows toward the top and hides which stage is the real amplifier.
What you’ll learn: simulate a chain and watch the ratio compound stage by stage, then diagnose a chain from its order log and pinpoint the echelon amplifying most. 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 factory keeps whipsawing between overtime and idle lines, but the sales team swears customer demand is flat. You’ll reproduce the mechanism — simulate a four-stage chain and show steady demand turning into ~100× more variable factory orders — then, handed the order log of a different chain, find the single echelon that’s amplifying the most so it can be fixed.
The bullwhip effect is one of the most cited results in operations (Procter & Gamble’s diapers, the MIT Beer Game) precisely because it’s counterintuitive and expensive: it drives excess inventory, stockouts, and capacity thrash all at once. Being able to measure it per stage — and name the culprit — is the first step any supply-chain analyst takes before proposing a fix.
2. Difficulty & time
Difficulty 5 / 10. A short simulation and a variance ratio — but with real moving parts: an
order-up-to policy (forecast + safety stock + lead-time pipeline), a chain of echelons, and the
per-stage-vs-cumulative distinction that’s easy to get subtly wrong. Calibrated against the ledger
(G10): a peer of reorder-point-trap (5) and analytics-with-duckdb (5) — an operations model
with a genuine trap, more involved than the single-mechanic 3s but not a full algorithm. It sits
alongside mtouch-attribution (5) in needing a couple of correct modelling choices, and below
vendor-dedupe (6), which layers blocking and fuzzy matching. The trap (measuring amplification
cumulatively and fingering the wrong stage) is one you can walk straight into.
Time is separate from difficulty: Part A is about an hour; Part B is 2–3 hours. The scripts run in under a second — the time is understanding the policy and the ratio.
3. What you’ll be able to do after
- Simulate an order-up-to echelon — forecast, safety stock, lead-time pipeline — and chain several together.
- Compute the bullwhip ratio and explain why an order-up-to policy amplifies demand variance.
- Attribute amplification per stage — and explain why a cumulative ratio hides the culprit.
- Diagnose a chain from its order log — find the echelon amplifying most and reason about why (here, order batching).
The finished result
By the end of Part A you’ll have watched steady customer demand turn into wildly swinging factory orders — and measured the amplification stage by stage:
end-customer demand: mean 99, variance 139 (calm)per-stage bullwhip: retailer 2.79 wholesaler 3.94 distributor 3.71 factory 2.62cumulative amplification: 2.8 -> 11.0 -> 40.8 -> 107x (variance 139 -> 14,856 at the factory)longer lead time 1->2->4: retailer bullwhip 2.05 -> 2.79 -> 4.71Steady demand in, ~107× wilder orders out — and it all falls out of an ordinary order-up-to policy, not anyone behaving irrationally.
4. Prereqs & time box
You can write a function and a loop, and use numpy/pandas (var, groupby). No supply-chain
background needed — the primer above covers the vocabulary. A little comfort with the idea of
variance helps.
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 and numpy, which run 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 two CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical
because the seed is fixed):
- Part A —
demand_a.csv(300 periods:t,demand). Steady end-customer demand your simulation is driven by. - Part B —
chain_b.csv(stage,t,order). The order log of a 5-echelon chain: stage 0 is the customer demand, stages 1–5 are each echelon’s orders. The first 30 warm-up periods are already dropped, so it’s at steady state.
6. Part A — Guided Build
You’ll build the chain and measure the amplification. Run the whole thing, or (better) one # %%
cell at a time — see Start Here:
python part_a/simulate_chain.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.
The full, commented script is in
part_a/simulate_chain.py. The spine:
Step 1 — The customer demand. Steady, low variance.
Checkpoint: mean ≈ 100, variance ≈ 139. The customer is calm.
Step 2 — One order-up-to echelon. Simulate the retailer; measure its bullwhip ratio.
Checkpoint: ratio ≈ 2.8 — the retailer’s orders already swing ~3× as much as demand.
Step 3 — Four echelons. Feed each stage’s orders as the next stage’s demand.
Checkpoint: per-stage ratios ~2.8, 3.9, 3.7, 2.6; cumulative ≈ 107× by the factory (variance 139 → ~14,900).
Step 4 — A driver: lead time.
Checkpoint: retailer bullwhip climbs 2.0 → 2.8 → 4.7 as lead time goes 1 → 2 → 4. Writes
part_a/bullwhip_a.csv.
Why this and not that
- Why order-up-to amplifies. Each period the stage re-forecasts (from jumpy incoming orders) and adjusts its target stock; the order it places is demand plus the change in target. That extra term is the amplification — it reacts to the forecast moving, not just to demand.
- Why it compounds up the chain. Each stage’s orders — already more variable than its demand — become the next stage’s demand. Amplification multiplies stage over stage, which is why the factory sees the worst of it.
- Why lead time makes it worse. A longer pipeline means each order must cover more future demand, so any forecast error is scaled up by a bigger factor. Shortening lead times (and steadying forecasts) is a real lever against the bullwhip.
Functions you’ll meet (and where to read more)
| Function | What it does here | Docs |
|---|---|---|
collections.deque | Model the lead-time pipeline of in-transit orders | deque |
np.mean / np.std | Moving-average forecast and safety stock | mean |
np.var | Variance, for the bullwhip ratio | var |
df.groupby("stage") | Split the Part B log into per-stage series | groupby |
If something looks off
- The per-stage ratios rise then fall (2.79, 3.94, 3.71, 2.62) — did the factory stop amplifying? No, that’s the expected shape. Each per-stage ratio compares a stage only to the demand it received, and every stage roughly triples it — so what compounds is the cumulative ratio (2.8 → 107×). By the factory, orders swing ~107× more than the customer’s demand (variance 139 → 14,856) even though the factory’s own multiplier (2.62) is the smallest. Per-stage tells you which echelon amplifies; cumulative tells you how bad it has gotten — Part B turns on keeping them apart.
- A checkpoint number doesn’t match. The data is seeded, so the numbers 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, a chain you didn’t build. chain_b.csv is the order log of a 5-echelon
chain — stage 0 is the customer, stages 1–5 are the echelons’ orders. Measure the per-stage
bullwhip ratio for each echelon and name the one amplifying most. One echelon behaves very
differently from the smooth order-up-to of Part A, and it’s the culprit. The starter
(part_b/starter.py) measures each stage against the end customer — a cumulative ratio — and so
reconstructs only 1 of 5 stages and fingers the wrong echelon.
The transfer: in Part A you knew the policy and drove the sim. Here you only have the log, the stages are heterogeneous, and you must compare each echelon to the demand it saw (the stage below it) — not to the customer.
Acceptance criteria (checkable)
Your solution writes into part_b/out/:
bullwhip.csv— columnsstage, ratiofor stages 1–5 (per-stage bullwhip).report.json— with keysstages,worst_stage,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 both tests pass: you reconstruct ≥ 90% of the five per-stage ratios within
2% (the reference gets all five), and your worst_stage matches the true worst amplifier. The
cumulative shortcut gets 1 of 5 and names the wrong stage.
Constraints — what must be true of your solution, not how to get there
- Measure each stage against the stage directly below it, not against the end customer.
- Report a ratio for every echelon (stages 1–5).
- Identify
worst_stageas the echelon with the highest per-stage ratio. - Don’t reorder or drop stages — the log is already at steady state.
Hints
Hint 1 — pair each stage with the one below
Split the log into a series per stage. The bullwhip for stage s is var(orders[s]) / var(orders[s-1]) — where stage 0 is the customer demand.
Hint 2 — why the starter is wrong
The starter divides by var(orders[0]) (the customer) for every stage. That’s the cumulative
amplification, which only equals the per-stage ratio for stage 1 and always grows toward the top.
Hint 3 — the culprit (still not the code)
Once you have per-stage ratios, worst_stage is simply the stage with the largest one. It won’t
be the top of the chain — look for the echelon whose orders are spiky (it batches its orders
instead of ordering smoothly).
8. Self-check
You don’t need the answer key. You’re done when all of these hold:
pytestreports both green — all five ratios within 2% and the rightworst_stage.- Your worst stage is in the middle of the chain, not the top — if you picked the last stage, you’re measuring cumulatively.
- Re-running from
python data/make_data.pyreproduces the same numbers — the data is seeded.
And the tell-tale: the culprit stage’s per-stage ratio should tower over its neighbours (roughly 18× vs 2–4×). If all your ratios look similar and grow monotonically, you computed the cumulative ratio, not the per-stage one.
9. Stretch
- Explain the culprit. Plot the culprit echelon’s orders against its incoming demand. The spikes (orders every few periods, zero in between) are order batching — quantify how much of its variance comes from the batching interval.
- A second cause. Add price promotions to the customer demand (occasional spikes as buyers forward-buy) and measure how much extra bullwhip they inject — the other classic driver.
- (Genuinely hard) Dampen it. Re-simulate the Part A chain with order smoothing (order only a fraction of the inventory gap each period) and measure the bullwhip reduction — then find where service (fill rate) starts to suffer. The variance-vs-service tradeoff is the real design problem.
10. Ship it
Put this in a portfolio repo and it counts. In that repo’s README:
- State the finding in one sentence an ops director gets: “customer demand varied by X, but factory orders varied by 100× that — and one echelon (order batching) is responsible for most of the amplification.”
- Show the per-stage bullwhip ratios and call out the culprit, next to the cumulative curve so the reader sees why per-stage matters.
- Include the order-up-to simulator and one paragraph on why steady demand still produces wild
orders. That paragraph is the part that signals you understand the mechanism, not just
np.var.
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 multi-echelon supply-chain order flows (Part A 300-period demand; Part B 5-echelon order log) |
| Type | Synthetic |
| Generated by | data/make_data.py (this repo), seeds 20260730 (A) and 20260731 (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. The amplification and the batching echelon were generated on purpose — recovering them demonstrates the technique, not a claim about any real supply chain.
Next up
Finished this one? Continue the Supply Chain track:
The reorder-point trap: why ordering to average demand runs you out of stock → · Browse all courses