Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Reorder Point & Demand Forecasting

Tests

  • 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.

Actual vs. best baseline vs. best model over the 42-day held-out test window

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:

  1. 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 unitshigher 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.
  2. 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 naive on 20% of origins vs. 7.5% for Holt-Winters, and naive'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 in memo.md reflects 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.

Dataset

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.csv

The 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.

Setup

python -m venv .venv
.venv/Scripts/activate        # Windows
# source .venv/bin/activate   # macOS/Linux
pip install -r requirements.txt

Reproduction steps

# 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.py

Each script is independently runnable and writes its outputs to outputs/ (or data/ for the cleaned series).

Data cleaning (src/prepare_data.py)

  • Drops cancelled orders (InvoiceNo starting with "C") — a cancellation is a customer taking an order back, not new demand.
  • Drops rows with Quantity <= 0 or UnitPrice <= 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.

Forecasting methodology (src/forecast.py)

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 finding: Holt-Winters' edge is not robust, but it does buy tail-risk protection

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

MAE distribution across 40 rolling-origin backtests, per method — box plots with every individual origin's MAE overlaid as a jittered point, log scale, with naive's worst origin (2,056) directly labeled

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.

Methodology footnote: the single 42-day split that motivated this backtest

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.

Reorder point methodology (src/reorder_point.py)

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.

Method 1: normal approximation

reorder_point = (mean_daily_demand * lead_time) + safety_stock
safety_stock   = z * std_daily_demand * sqrt(lead_time)
  • mean_daily_demand / std_daily_demand come from the most recent --lookback-days (default 90) of the daily demand series.
  • z is 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).

Method 2: empirical (block bootstrap)

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.

Why two methods: the normal approximation's assumption doesn't hold here

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.

Results comparison (SKU 85123A, lead time 7 days, service level 95%, 90-day lookback)

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.

The full 2×2: method × demand segmentation

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":

  1. 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.
  2. 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.

Service-level sensitivity

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

Reorder point (empirical method) vs. target service level: a steep rise from 90% to 95% (+413 units), then a nearly flat line through 97% (+38) and 99% (+5)

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.

Validation

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.

1. No test-data leakage (tests/test_no_leakage.py)

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

2. Every memo.md number, re-derived from raw data (src/verify_claims.py)

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.csv and outputs/service_level_sensitivity.csv, written by reorder_point.py from the committed data/daily_demand_85123A.csv. For these, verify_claims.py recomputing 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 committed data/daily_demand_85123A.csv is 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.py

Current result: 23/23 claims verified.

3. The full test suite, run automatically on every push (tests/, GitHub Actions)

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/ -v

GitHub 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.

Project structure

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

Limitations

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 = 7 in forecast.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.

About

Demand forecasting and reorder point analysis on 540k retail transactions — with a warehouse manager's decision memo, honest baseline comparisons, and every number in the writeup independently verifiable from raw data.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages