- Recommend 898 units for retail demand — once bulk/wholesale orders are pulled out of this SKU's automated reorder trigger (see the 2×2 below). Until that operational split exists, use 1,708, not the 1,392 a textbook formula would suggest.
- Two independent fixes point at the same answer: neither "use a method that doesn't assume a bell curve" alone (1,708) nor "just segment out bulk orders" alone (869) is as well-supported as doing both (898) — the two calculation methods agree far more closely once bulk orders are removed (3% apart vs. 23% apart when they're mixed in), which is itself evidence for the segmentation, not just a smaller number.
- The forecasting model is not a reliably better predictor: across 40 independent backtests it beats the best simple baseline only 28% of the time. Its real, measured value is avoiding rare bad-week blowups, not everyday accuracy.
Turns a raw retail transaction log into an operational decision: at what inventory level should a warehouse reorder this product?
Two findings actually drive the recommendation in memo.md:
- The reorder point should be computed on retail-only demand, using a method that doesn't assume a bell curve — doing only one of those two things is not enough. Daily demand's standard deviation (154.61) exceeds its mean (102.74) — a symmetric bell curve can't produce that. The cause is a handful of large, recurring wholesale-scale orders. Fixing only the distributional assumption (empirical method, still on all-orders demand) gives 1,708 units — higher than the naive number, because it correctly reflects real tail risk in a demand mix that includes bulk orders. Fixing only the population (segment bulk orders out, but keep the standard bell-curve formula) gives 869 units. Fixing both gives 898 units — and the fact that 869 and 898 are only 3% apart (vs. 23% apart when bulk orders are still mixed in) is itself evidence that the segmentation is the right structural fix, not just a smaller number that happened to come out of a different formula. Full 2×2 and the reasoning behind picking 898 are below.
- The forecasting model's edge over simple baselines is not robust.
A single 42-day test suggested Holt-Winters beat the best naive
baseline by a small margin. Tested properly — 40 independent
rolling-origin backtests, not one split — that edge disappears:
Holt-Winters wins only 28% of head-to-head comparisons. What it does
demonstrably buy is tail-risk protection: a "blowup" (an origin where a
method's error exceeds 2× its own typical error) hits
naiveon 20% of origins vs. 7.5% for Holt-Winters, andnaive's worst-case miss (2,056 units) is over 6× larger than Holt-Winters' worst case. That's insurance against a bad week, not better everyday accuracy, and the recommendation inmemo.mdreflects that distinction rather than the flattering single-split number.
Both findings are independently checkable, not just asserted: the
reorder-point numbers (all four cells of the 2×2) are re-derived from raw
data in
src/verify_claims.py (23/23 pass), and the
forecasting robustness claim is backed by
outputs/rolling_backtest_detail.csv
— one row per origin, so the 28% isn't an assertion anyone has to take on
faith.
If you only read one file, read memo.md — it's the actual
deliverable, written for a warehouse manager rather than a data scientist.
UCI "Online Retail" — ~540,000 transactions from a UK-based online gift retailer, Dec 2010–Dec 2011. Each row is one product line item on one invoice.
The raw file is not committed to this repo (it's ~43MB). Download it with:
curl -sL "https://raw.githubusercontent.com/databricks/Spark-The-Definitive-Guide/master/data/retail-data/all/online-retail-dataset.csv" -o data/online_retail.csvThe small processed output — one SKU's daily demand series
(data/daily_demand_85123A.csv) — is committed, so the rest of the
project (forecasting, reorder point, memo numbers) reproduces without
needing to re-download anything.
python -m venv .venv
.venv/Scripts/activate # Windows
# source .venv/bin/activate # macOS/Linux
pip install -r requirements.txt# 1. Download the raw data (see above), then clean it and build a daily
# demand series for one SKU (default: 85123A)
python src/prepare_data.py --sku 85123A
# 2. Compare a real forecasting model against three naive baselines on a
# held-out 42-day test window (daily and weekly granularity), then
# stress-test the result with a 40-origin rolling-origin backtest
python src/forecast.py --sku 85123A
# 3. Turn recent demand statistics into a reorder point + safety stock -
# both a normal-approximation method and an empirical bootstrap method,
# plus a service-level sensitivity table/chart (90/95/97/99%)
python src/reorder_point.py --sku 85123A --lead-time-days 7 --service-level 0.95 --lookback-days 90
# 4. Optional: run the full test suite and verify every memo.md number
# re-derives correctly from raw data (see "Validation" below)
pytest tests/ -v
python src/verify_claims.pyEach script is independently runnable and writes its outputs to outputs/
(or data/ for the cleaned series).
- Drops cancelled orders (
InvoiceNostarting with"C") — a cancellation is a customer taking an order back, not new demand. - Drops rows with
Quantity <= 0orUnitPrice <= 0— data-entry artifacts and adjustments, not genuine sales. - Aggregates one SKU's transactions to daily totals, then reindexes over the full date range and fills missing days with 0 rather than dropping them. A day with zero sales is real information — it's part of the weekly seasonality pattern (this product barely sells on some days) and dropping those rows would corrupt that signal.
Default SKU is 85123A ("WHITE HANGING HEART T-LIGHT HOLDER"), a
high-volume, frequently-sold item — a reasonable candidate for this kind of
model. Pass --sku <code> to run the whole pipeline on a different product.
The rule this project follows: baselines are established before anything "clever" is built, and every method is reported even if it loses.
Baselines, in increasing order of sophistication:
| Method | Rule |
|---|---|
naive |
Tomorrow = today. Last training-day value, held flat. |
seasonal_naive |
Today = same weekday last week. The last observed 7-day pattern, repeated. This is the theoretically stronger baseline — this product has some real weekly seasonality, so "same day last week" should in principle beat "same as yesterday." |
moving_average_7d |
Average of the last 7 training days, held flat. |
Model: Holt-Winters exponential smoothing — additive trend (damped) +
additive weekly seasonality (statsmodels.tsa.holtwinters.ExponentialSmoothing,
seasonal_periods=7). Unlike the baselines, it can extrapolate a trend and
a seasonal shape simultaneously rather than just repeating recent history.
Metrics: MAE (mean absolute error) and RMSE (root mean squared error, which penalizes large misses more heavily than small ones). The headline number is % improvement (or win rate) vs. the best of the three baselines — whichever one actually has the lowest error, not whichever one is theoretically most sophisticated.
The honest way to evaluate a forecasting method is not to pick one train/test split and report the number it happens to produce — a single 42-day window is one sample, and a margin of a few percent can easily be an artifact of which 42 days got held out (see the methodology footnote below for exactly that: an initial single-split test that looked like a modest win). The real test run for this project is a rolling-origin (walk-forward) backtest: move the train/test cutoff ("origin") forward through the whole series in 7-day steps, refit every method from scratch at each origin, forecast 7 days ahead (matching the supplier lead time), and look at the full distribution of results across origins — not just one number.
This gives 40 independent origins, spanning March–November 2011. At each one, every method is refit using only the data available up to that origin (no peeking forward — see the "Validation" section below for how that's proven, not just claimed) and scored on its 7-day-ahead forecast:
| model | mean MAE | median MAE | std MAE | min MAE | max MAE |
|---|---|---|---|---|---|
| holt_winters | 78.6 | 63.0 | 50.0 | 36.2 | 325.6 |
| moving_average_7d | 84.3 | 56.9 | 70.1 | 23.3 | 345.5 |
| seasonal_naive | 88.8 | 68.2 | 73.1 | 15.3 | 342.9 |
| naive | 142.9 | 63.2 | 321.0 | 18.4 | 2056.3 |
This is the same data as the table above, but the picture makes the
mean-vs-median story obvious in a way the numbers alone don't: the four
boxes (the middle 50% of origins) sit at roughly the same height — a
typical origin looks similar no matter which method you use — but
naive's points stretch far above everyone else's on the log-scaled y-axis.
The risk is entirely in the tail, not the middle.
Full detail (every origin × method): outputs/rolling_backtest_detail.csv.
Summary: outputs/rolling_backtest_summary.csv.
Per-origin win/loss margin against the best baseline at that origin: outputs/rolling_backtest_edge.csv.
Head-to-head — comparing Holt-Winters against whichever baseline actually
performed best at that specific origin — Holt-Winters wins only 28% of the
time (11 of 40 origins), with a mean edge of -41.5% (std 71.6 percentage
points). That is this project's actual finding: the forecasting model's
edge over simple baselines is not reliable. On a typical rolling window, a
naive rule is usually just as good or better; the median MAE tells the same
story (moving_average_7d is actually best on a typical origin at 56.9,
with naive and Holt-Winters roughly tied around 63).
What Holt-Winters does demonstrably buy is protection against rare, severe misses — quantified below, not just illustrated with one anecdote:
| model | p90 MAE | p95 MAE | max MAE | origins > 2× own median ("blowups") |
|---|---|---|---|---|
| holt_winters | 108.4 | 129.9 | 325.6 | 3 / 40 (7.5%) |
| moving_average_7d | 155.6 | 199.1 | 345.5 | 7 / 40 (17.5%) |
| seasonal_naive | 168.7 | 238.5 | 342.9 | 6 / 40 (15.0%) |
| naive | 235.2 | 347.5 | 2056.3 | 8 / 40 (20.0%) |
Holt-Winters has the best (lowest) number in every column here — mean, p90, p95, max, and blowup rate. Its blowup rate (a MAE more than double its own typical error) is under half of every baseline's, and its worst case (326) is a fraction of naive's (2,056). This is what "tail-risk protection" means in measured terms, not just "one bad week happened to look better for the model."
Why does naive specifically blow up? It forecasts all 7 days of its
window as a flat copy of a single training day's value — the last one it
saw — so if that one day happened to include an outlier (a large bulk order,
like the 1,010-unit order documented in the bulk-order finding below), the
outlier doesn't cost naive one bad day, it propagates through the entire
7-day forecast. The clearest example: the origin starting 2011-04-19, where
naive held a spike flat for a week and racked up a 2,056-unit MAE,
against Holt-Winters' 70 on that same stretch — Holt-Winters' smoothing
means no single day's value ever gets copied forward unchanged. So "more
accurate on a typical week" and "safer against a worst-case week" point to
different methods here, and neither the single 42-day split nor either
framing alone should be read as a clean win for Holt-Winters. This
distinction — tail-risk insurance, not better everyday forecasting — is the
one that actually reaches memo.md.
A related finding: seasonal_naive is not the best baseline on this SKU,
despite being the theoretically-motivated one (it accounts for weekly
seasonality, which plain naive ignores). Across the backtest it's beaten
on both mean and median MAE by moving_average_7d, and it isn't clearly
better than plain naive either. This SKU's weekly pattern is real (see
Holt-Winters' seasonal component still adding value over
moving_average_7d — a plain mean with no seasonality at all — in the
single-split results below) but noisy enough, or dominated enough by
non-weekly swings like the bulk-order pattern documented further down, that
"same day last week" isn't automatically the safe default baseline to
assume for other SKUs in this catalog. It should be checked per-product,
not assumed.
The rolling-origin backtest above wasn't the starting point — it was added because an initial, more conventional evaluation produced a margin too small to trust on its own. That original evaluation held out the final 42 days of the series as a single test set, fit every method on everything before that cutoff, and scored a genuine 42-day-ahead forecast (no method saw any test-period actual, not even one day ahead).
At the daily level (42 individual daily forecasts):
| model | MAE | RMSE | % improvement vs. best baseline (MAE) |
|---|---|---|---|
| naive (best baseline) | 103.29 | 211.14 | — |
| seasonal_naive | 107.19 | 217.88 | -3.8% |
| moving_average_7d | 113.95 | 222.85 | -10.3% |
| holt_winters | 100.28 | 204.10 | +2.9% |
At the weekly level (the same 42 daily forecasts summed into 6 non-overlapping 7-day totals — the unit the reorder point actually consumes):
| model | MAE (units/7-day window) | % improvement vs. best baseline (MAE) |
|---|---|---|
| naive (best baseline) | 381.67 | — |
| seasonal_naive | 621.33 | -62.8% |
| moving_average_7d | 621.33 | -62.8% |
| holt_winters | 454.78 | -19.2% |
Full numbers: outputs/forecast_comparison.csv,
outputs/forecast_comparison_weekly.csv.
Plot: outputs/forecast_plot.png.
Why this isn't the project's result: a +2.9% daily-level margin, on a
sample of one 42-day window, is exactly the size of margin that could
plausibly flip on a different window — and the rolling-origin backtest
above shows it does: head-to-head across 40 origins, Holt-Winters only wins
28% of the time. Typical daily demand for this SKU is around 103 units/day
(the 90-day mean used by reorder_point.py below), so a MAE around 100
also means this SKU is close to unforecastable at the daily level in
absolute terms, independent of which method wins. At the weekly level, the
single split showed Holt-Winters losing outright (-19.2%) — consistent with,
not contradicted by, the backtest finding above. seasonal_naive and
moving_average_7d score exactly identically at the weekly level in this
table; that's not a copy-paste error, it's mathematically guaranteed — both
hold a constant daily forecast built from the last 7 training days (one
tiles that week's pattern, the other repeats its average), so any 7-day
bucket sums to the same total either way. They only diverge day-by-day.
The moving_average_7d baseline loses outright at both granularities in
this single-split table too, which is reported here rather than left out,
per this project's own rule about not hiding a loss.
Two independent methods are computed side by side, because one of them rests on an assumption this dataset actually violates, and the honest way to handle that is to show both rather than quietly pick the one that looks more standard.
reorder_point = (mean_daily_demand * lead_time) + safety_stock
safety_stock = z * std_daily_demand * sqrt(lead_time)
mean_daily_demand/std_daily_demandcome from the most recent--lookback-days(default 90) of the daily demand series.zis the inverse normal CDF at the target--service-level(default 0.95 → z ≈ 1.645), i.e. the number of standard deviations of buffer needed to cover that probability of demand under a normal distribution.lead_time(--lead-time-days, default 7) is the assumed number of days between placing a purchase order and receiving stock.
This is the standard textbook formula, and it's fast, simple, and explainable — but it assumes demand is approximately normally distributed, and the data itself says otherwise (see below).
No distributional assumption at all: draw a real, contiguous 7-day window
at random (with replacement) from the same --lookback-days of demand
history, sum it into a synthetic lead-time-demand sample, repeat 10,000
times, and take the target service level's percentile (95th, by default)
directly off that empirical distribution. Sampling real contiguous weeks
— rather than resampling individual days independently — preserves whatever
day-to-day correlation and bulk-order clustering actually exists in the
data, instead of assuming a shape for it. reorder_point = empirical percentile of resampled lead-time demand, directly — no separate
mean-plus-cushion decomposition is needed, though one is reported anyway
(percentile minus the same lead-time-demand baseline as Method 1) so the two
methods' implied safety stock can be compared apples-to-apples.
Both methods use the exact same underlying --lookback-days window, so
the comparison below isolates the effect of the distributional assumption,
not a difference in what data was used.
The standard deviation of daily demand (154.61) exceeds the mean
(102.74) — a symmetric bell curve cannot produce that combination; it's the
signature of a right-skewed distribution with a fat tail. This isn't a
minor technicality: it's Finding #1 from the top of this README. A handful
of large, recurring wholesale-scale orders (documented in memo.md and
further down this section) create exactly this kind of skew, and a formula
that assumes symmetry has no way to represent "usually calm, occasionally a
1,000+ unit order" — it can only widen the safety margin symmetrically
around the mean, which both overshoots the ordinary case and, as the result
below shows, can still undershoot the true tail.
| Method 1: normal approximation | Method 2: empirical bootstrap | |
|---|---|---|
| Lead-time demand baseline (shared — see note) | 719.2 units | 719.2 units |
| Safety stock (implied cushion) | 672.9 units | 988.8 units |
| Reorder point | 1,392 units | 1,708 units |
| Reference only: bootstrap's own mean 7-day demand | — | 708.3 units |
Both columns use the same 719.2-unit baseline (mean_daily_demand * lead_time
from Method 1), so baseline + safety stock reconciles to the stated reorder
point in both columns: 719.2 + 672.9 ≈ 1,392, and 719.2 + 988.8 = 1,708. The
bootstrap's own mean of its 10,000 resampled 7-day windows (708.3) is a
different number — it's what the empirical distribution centers on, not the
baseline its safety-stock figure was computed against — so it's shown
separately, as reference, rather than in the arithmetic row. (An earlier
version of this table put 708.3 directly in the "baseline" row, which made
708.3 + 988.8 = 1,697 look like it should equal the reorder point above it
when it doesn't - a presentation bug, not a computation one; the underlying
empirical_reorder_point and empirical_safety_stock values were always
correct, only this table's labeling was wrong.)
Full output (both methods, one row): outputs/reorder_point.csv.
The empirical method's reorder point is 316 units (23%) higher. That direction makes sense given the skew: a symmetric formula fit to this mean/std understates how bad the real 95th-percentile week can get, because real bad weeks here aren't "somewhat higher than average" (what a normal distribution would produce) — they're "ordinary, except one bulk order landed in it," a qualitatively different and larger jump that the empirical method captures directly from real history and the normal approximation can't represent at all.
Which number is more defensible? The empirical one (1,708 units). The
whole reason to reach for a distributional assumption in the first place is
convenience, not correctness — and here it's demonstrably the wrong
assumption. The empirical method doesn't need demand to be any particular
shape; it just asks "in the real history available, how bad did a 7-day
stretch actually get, 1 time in 20?" That said, this method has a real
limitation worth being upfront about, not hidden: with a 90-day lookback
and a 7-day window, there are only 84 distinct real windows underlying
the bootstrap (bootstrap_n_available_windows in the CSV) — the 10,000
resamples smooth the percentile estimate but cannot manufacture information
beyond what those 84 real windows contain, and the 95th-percentile estimate
is effectively being driven by roughly the worst 4-5 of them. A longer
lookback (see "what I'd want confirmed" in memo.md) would make this
estimate meaningfully more stable in either direction.
Note the standard deviation being larger than the mean, driving all of the above, traces to the same cause as Finding #1 at the top of this README: a handful of large wholesale-scale orders from a recurring customer. That's not a separate issue from the empirical-vs-normal question above — it's the same skew, addressable two different ways — so both methods were also run against demand with those bulk orders excluded, to complete the full 2×2 rather than leave it half-computed.
| All orders | Retail orders only (<200 units/invoice) | |
|---|---|---|
| Normal approximation | 1,392 units | 869 units |
| Empirical bootstrap | 1,708 units | 898 units |
The retail-only column has no committed output file (see "Validation"
below for why — it needs invoice-level detail the committed
data/daily_demand_85123A.csv doesn't carry); both retail-only cells are
computed and checked in src/verify_claims.py.
The recommended cell is empirical × retail-only: 898 units. Two reasons, not just "it's the smallest number":
- It's the only cell that addresses both problems at once — the wrong distributional assumption (fixed by using the empirical method) and the wrong population (fixed by excluding bulk orders) are independent issues, and 898 is the only cell where neither mistake is still present.
- The two methods converge once bulk orders are removed, and that convergence is itself evidence. Empirical vs. normal differ by 316 units (23%) on all-orders demand, but by only 29 units (3%) on retail-only demand. If the bulk orders are really what's driving the distributional mismatch documented above, removing them should make the two methods agree more closely — and they do, sharply. That's a stronger argument for the retail-only segmentation than either individual number is on its own.
If the operational segmentation isn't in place, 1,708 (empirical,
all-orders) remains the right fallback — not 1,392, for the same
distributional reason established above. The gap between 898 and 1,708
(810 units, 47%) is itself the business case for implementing that
segmentation rather than deferring it; see memo.md for how this is framed
for the person who'd actually act on it.
95% was picked as a reasonable default (see memo.md), not derived as
correct for this product — the target service level is a business decision
(how often a stockout is tolerable) that this project can't make on
someone else's behalf. What it can do is make the cost of that decision
visible: reorder_point.py recomputes both methods at 90%, 95%, 97%, and
99% and reports the marginal unit cost of each step up.
| service level | z-score | normal method ROP | empirical method ROP | marginal units (empirical) |
|---|---|---|---|---|
| 90% | 1.282 | 1,243 | 1,295 | — |
| 95% | 1.645 | 1,392 | 1,708 | +413 |
| 97% | 1.881 | 1,489 | 1,746 | +38 |
| 99% | 2.326 | 1,671 | 1,751 | +5 |
Full table (both methods, all four columns): outputs/service_level_sensitivity.csv.
Chart (empirical/recommended method): outputs/service_level_tradeoff.png
A genuinely surprising result, reported as found rather than smoothed over: under the normal-approximation formula, marginal cost behaves the way intuition expects — it climbs as the service level climbs (the z-score itself accelerates: 1.282 → 1.645 → 1.881 → 2.326). But under the empirical method — the one this project recommends — almost the entire cost of pursuing a higher service level is paid going from 90% to 95%; pushing further to 97% or 99% barely moves the number at all.
Why: this is a direct, visible consequence of the bootstrap's small underlying sample, flagged as a limitation earlier in this section. With only 84 distinct real 7-day windows in the 90-day lookback, the empirical distribution has a genuinely small number of extreme values to draw its highest percentiles from. Once the target percentile is high enough to be selecting from that same small cluster of largest observed weeks, pushing the percentile target higher doesn't find a worse historical week to report — because there isn't a much worse one in this 90-day sample. That's a real property of the data available, not a computation error, but it also means the near-flatness above 95% should be read as "roughly cheap, resolution-limited by a thin sample of bad weeks," not "provably almost free." A longer lookback would likely reveal a smoother — and possibly higher — cost curve at the top end.
Three claims underpin the credibility of this project: that the forecast
evaluation is genuinely honest (no test-period data leaking into training),
that every number in memo.md is real rather than hand-typed, and that the
whole pipeline stays correct as it changes. All three are checked by code
that ships in this repo, not just asserted in prose.
forecast.py claims that all 42 held-out test days are invisible to every
forecasting method while it's being fit, including Holt-Winters' own
internal parameter optimizer. Reading the code supports that claim (test
is only ever used after all four forecasts are already computed - once to
score them, once to plot them), but a code walkthrough is a claim about the
code, not proof about what runs. This test file provides the proof:
- Split integrity - asserts training and test dates don't overlap, are chronologically contiguous (train ends 2011-10-28, test starts 2011-10-29, a 1-day gap meaning "the next calendar day"), and are exactly 332 / 42 days long.
- The overwrite test - the strongest check. It overwrites the 42 held-out days with random values ~10,000x the normal scale of this series (millions of units/day, versus a typical day in the tens-to-hundreds), re-runs all four forecasting methods - including refitting Holt-Winters' optimizer - and asserts every resulting forecast is byte-identical to the forecast produced on the real, uncorrupted data.
Why the overwrite test is stronger evidence than code inspection alone: code inspection tells you what the code appears to do; it can't rule out a subtle bug (an off-by-one in the slice, a stray reference to the wrong variable, a library defaulting to using the full series internally) that only shows up at runtime. Overwriting the test window with values so extreme that any leak would completely blow up the forecast, then asserting nothing changed, is a claim a reviewer can independently verify by running the test themselves - they don't have to trust the docstring, they can watch it fail if leakage exists and pass if it doesn't.
Run it with:
pip install -r requirements.txt # includes pytest
pytest tests/test_no_leakage.py -v(part of the full suite - see "3. The full test suite" below for everything else it covers, and how it runs on every push.)
This script independently recomputes every numeric claim in memo.md
directly from data/online_retail.csv, and prints PASS/FAIL for each one
against the value memo.md actually states.
Two different kinds of claim are checked here, and they trace to committed files differently — worth being explicit about, not glossed over:
- The reorder point claims (normal approximation, empirical bootstrap, and
the service-level sensitivity table) do have a committed counterpart:
outputs/reorder_point.csvandoutputs/service_level_sensitivity.csv, written byreorder_point.pyfrom the committeddata/daily_demand_85123A.csv. For these,verify_claims.pyrecomputing from the raw transaction log instead of reading those CSVs back is a genuine independence check — a stale or corrupted output file can't cause a false "verified." - The bulk-order share of volume, customer 17450's order pattern, and the
retail-only reorder point have no committed output file at all. They
need invoice- and customer-level detail that only exists in the raw
43MB
data/online_retail.csv— the committeddata/daily_demand_85123A.csvis already aggregated to one SKU's daily totals, which throws that detail away. So these specific numbers are only reproducible by downloading the raw file and running this script; they cannot currently be checked against anything already sitting in this repo. That's flagged here rather than left implicit.
Claims checked: the bulk-order share of volume (16 of 2,198 invoices,
0.73%, are ≥200 units and account for 27.23% of volume), customer 17450's
order pattern (9 orders, 128-1,010 units each, 10.92% of all volume), the
all-orders reorder point under the normal approximation (mean 102.74 / std
154.61 / safety stock 672.86 / reorder point 1,392.07), the retail-only
reorder point under the normal approximation (mean 79.86 / std 71.32 /
safety stock 310.40 / reorder point 869.38, a 37.5% reduction), the
all-orders reorder point under the empirical bootstrap method (1,708.0
units), the service-level sensitivity table (empirical method at
90/95/97/99%: 1,295.0 / 1,708.0 / 1,746.0 / 1,751.0 units), and the
retail-only reorder point under the empirical bootstrap method (898.0
units) — the fourth cell of the 2×2 in the "Reorder point methodology"
section above. The bootstrap is independently reimplemented in this script
with the same random seed as reorder_point.py, so for the bootstrap
claims, this is a reproducibility check as much as a correctness one.
python src/verify_claims.pyCurrent result: 23/23 claims verified.
test_no_leakage.py (above) is one file in a broader pytest suite covering
the rest of the pipeline with small, fast, hand-checkable unit tests - none
of them need the raw 43MB dataset, so they run the same way locally or in CI:
| File | What it checks |
|---|---|
test_no_leakage.py |
train/test split integrity + the no-leakage overwrite test (above) |
test_prepare_data.py |
cancellations and non-positive qty/price rows get dropped; zero-sales days are zero-filled into a continuous, gapless date index rather than dropped |
test_reorder_point.py |
safety stock (and the empirical method's reorder point) increase monotonically as the target service level rises; compute_reorder_point() matches an independently hand-derived value on a tiny 5-point fixture series; a zero-variance series needs zero safety stock; baseline + safety_stock == reorder_point for both methods, against an independently-computed baseline (regression test for the presentation bug noted above) |
test_forecast_baselines.py |
each of the three naive baselines (naive, seasonal_naive, moving_average_7d) returns the exact expected values on a small, fully worked-out toy series - including the seasonal-naive tiling behavior past one full period |
pytest tests/ -vGitHub Actions (.github/workflows/tests.yml)
runs this exact command on every push and every pull request, on a clean
Ubuntu runner with a fresh pip install -r requirements.txt - the badge at
the top of this README reflects the latest run. This matters for a project
that's made several rounds of substantive changes to the same evaluation
code (see the git history): a regression in, say, the seasonal-naive tiling
logic or the bootstrap's window bounds would silently corrupt every number
downstream in memo.md without a test suite catching it before it's
noticed by a human.
src/prepare_data.py clean raw transactions, build daily demand series for one SKU
src/forecast.py compare Holt-Winters against 3 naive baselines: daily, weekly, rolling-origin
src/reorder_point.py compute reorder point + safety stock from recent demand stats
src/verify_claims.py re-derive every memo.md number from raw data, print PASS/FAIL
tests/conftest.py makes src/ importable for every test module
tests/test_no_leakage.py prove no test-period data leaks into training/fitting
tests/test_prepare_data.py cancellation/invalid-row dropping, zero-fill continuity
tests/test_reorder_point.py service-level monotonicity, hand-computed fixture, baseline+safety_stock=ROP check
tests/test_forecast_baselines.py naive/seasonal_naive/moving_average on a toy series
.github/workflows/tests.yml runs the full pytest suite on every push/PR
data/online_retail.csv raw dataset (gitignored, ~43MB - download it yourself)
data/daily_demand_85123A.csv processed daily demand series (committed, reproducible)
outputs/forecast_comparison.csv MAE/RMSE for all 4 methods, daily granularity (single split)
outputs/forecast_comparison_weekly.csv MAE/RMSE for all 4 methods, 7-day-total granularity (single split)
outputs/rolling_backtest_detail.csv per-origin MAE for all 4 methods, 40 walk-forward origins
outputs/rolling_backtest_summary.csv mean/median/std/p90/p95/max MAE + blowup counts, per method
outputs/rolling_backtest_edge.csv per-origin Holt-Winters vs. best-baseline-at-that-origin margin
outputs/rolling_backtest_distribution.png box plot + per-origin points, MAE distribution per method
outputs/forecast_plot.png actual vs. baseline vs. best model, test window
outputs/reorder_point.csv both reorder point methods (normal + empirical bootstrap), one row
outputs/service_level_sensitivity.csv both methods' ROP at 90/95/97/99% service levels
outputs/service_level_tradeoff.png chart: empirical-method ROP vs. service level
memo.md decision memo written for a warehouse manager
See memo.md's "What this does NOT account for" and "What I'd want
confirmed" sections for the operational limitations. Methodologically:
- One SKU is modeled at a time; the pipeline doesn't (yet) scale across a full catalog.
- The normal-approximation reorder point formula assumes normally-distributed demand, which is a simplification given the fat right tail from bulk orders noted above — this is why the empirical bootstrap method exists alongside it. The bootstrap method has its own limitation, though: with a 90-day lookback and a 7-day window, it draws from only 84 distinct real windows, so its 95th-percentile estimate is effectively driven by roughly the worst 4-5 of them — a longer lookback would make it more stable.
- Holt-Winters is fit fresh each run with
optimized=True(statsmodels' own parameter search) rather than hand-tuned — in keeping with this project's rule of not tuning until something wins and then hiding the attempts, no manual parameter search was done beyond trying the model family described above. - The rolling-origin backtest steps the origin forward 7 days at a time
(
ROLLING_STEP = 7inforecast.py), giving 40 non-overlapping 7-day test windows rather than a daily-stepped version with hundreds of heavily-overlapping windows. This was a deliberate choice - non-overlapping windows are less correlated with each other, which matters more for believable spread/win-rate statistics than raw origin count does - but it does mean the 40 origins aren't fully independent of the single-split daily/weekly evaluations above, since they draw from the same 374-day series.


