The goal

A wholesale distributor of consumer electronics across Latin America was making stocking decisions on 4,307 SKUs using a spreadsheet and intuition. Their ERP held 4.5 years of order history. Nobody had ever asked the data a simple question:

Which SKUs can actually be forecast, and which ones can't?

That is the entire brief. Not "build a forecasting model." Build a tool that tells an analyst which SKUs they can trust a forecast on — and which ones need a phone call to the customer instead. That distinction is the whole project.

The data

Real ERP export. Not synthetic, not scraped, not a Kaggle competition. Real order lines with real mess in them.

MetricValue
Raw order lines~290,000
Clean order lines after filtering84,741
Distinct customers872
Distinct SKUs4,307
Date range2023-01-02 to 2026-08-31
Total net units~5.78M
Data quality report from the app showing rows, customers, SKUs, invalid rows removed, and exact duplicates removed on the pilot training set
Data Quality tab — the app reports its own cleaning: rows loaded, distinct customers, distinct SKUs, invalid rows removed, exact duplicates removed, and a flag for rows above the 99th percentile of absolute quantity.

Cleaning — what the first hour actually looked like

Roughly 21% of raw rows were not sales. They were discount adjustments, returns, and cancellations posted as negative-quantity transactions. If you naively sum the quantity column, you get a number that looks like demand but isn't. Every downstream forecast is wrong if this step is wrong.

Before any model sees the data, the pipeline does three things:

After cleaning, the app reports its own data quality. On the pilot training set, it retained 103 outlier rows for investigation rather than silently dropping them. Silent outlier removal is how forecasters lie to themselves.

The 0.27% finding

Of the 872 customers and 4,307 SKUs, roughly 34,031 customer × SKU pairs ever had positive demand. The question is: how many have enough monthly history to forecast?

FilterPairs remaining% of total
Any positive demand ever34,031100%
At least 12 months of monthly history920.27%
At least 24 months of monthly history50.015%

And on the SKU side: 79% of SKUs had fewer than 6 months of activity.

Less than one-third of one percent of customer × SKU pairs have enough monthly history for a classical time-series model to even attempt a forecast. Fifteen thousandths of a percent have enough history for a model to estimate seasonality with confidence. This is not an anomaly. This is what wholesale distribution actually looks like.

A reseller orders a bulk shipment in March, another in July, nothing for four months, then a spike when a new SKU launches. That is not monthly demand. That is a reorder-probability problem wearing a time-series costume.

The insight the project produced is not a forecast. It is this:

This dataset has a small forecastable core and a large unforecastable tail, and treating them the same is the actual risk.

Building the engine

Three models, selected per SKU via expanding-window cross-validation:

The pipeline is Pandas + Statsmodels + a custom reliability layer. The UI is Streamlit. The output is a decision workbook in Excel. 38 pytest tests cover the pipeline end-to-end.

The reliability and sensitivity layer

A forecast number alone is not a decision. So the app produces, for every forecastable SKU, a stack of diagnostics that tell the analyst how much weight to put on the number. These turned out to be the most useful parts of the whole system.

Reliability tiers with written reasons

Every SKU gets High / Medium / Low, plus a plain-English reason. Examples from the pilot:

In the pilot, 84 of 92 pairs received Low reliability. That is not a bug. That is the app correctly reporting that most of the data does not support a confident monthly forecast.

Model separation

For each SKU, the app reports the relative WAPE advantage the selected model has over the next-best model. On WALL-CHARGR-25W, the selected model (ETS) has a 13.0% relative WAPE advantage over the runner-up (Naive). That is meaningful but not overwhelming — and the app says so. A 13% advantage is worth selecting ETS, but it is not worth betting the warehouse on ETS alone. That is why the reliability tier is Medium, not High.

Selection robustness — outlier and net-vs-gross sensitivity

For every SKU, the app re-runs model selection under three conditions:

  1. Base model — as-is.
  2. Outliers capped — flagged outlier months (calendar-month median × 3) are winsorized and selection is rerun.
  3. Gross demand — the model is refit on gross demand instead of net demand (returns not subtracted).

If the selected model changes under any of these, the app reports: "Selected model changes when SKU-specific outliers are capped."

For WALL-CHARGR-25W:

CheckResult
Flagged outlier months2025-05, 2025-07
Units in flagged months9,416
Base modelETS
Without outliersETS
Gross demandETS

All three re-runs selected ETS. The model choice is robust to those sensitivities on this SKU. That means the ETS selection is not an artifact of two crazy months. It is the model genuinely preferring trend-following on this series.

Forecast-vs-trend sanity warnings

Independent of backtest metrics, the app compares the forecast against the last 12 months of actuals and fires warnings for:

On WALL-CHARGR-25W, two warnings fired:

Both warnings were correct. The model over-forecast by 155% on the holdout. The app flagged it before the analyst ever looked at the number.

SKU decision page for WALL-CHARGR-25W showing Medium reliability, ETS selected model, 67.6% best WAPE, Validate with Customer action, and forecast-vs-trend warnings
SKU Decision page — WALL-CHARGR-25W. Reliability: Medium. Pattern: Growing. Selected model: ETS. Best WAPE: 67.6%. Action: Validate with Customer. Two sanity warnings fired, both of which proved correct in the holdout.

The validation — the finding that changed the story

I tested the app on 92 forecastable customer × SKU pairs. Training window: 2023-01 to 2025-08. Holdout: 2025-09 to 2026-08. Twelve months held back, never seen by the model.

Pairs attempted:             92
Pairs with a forecast:       57
Pairs with non-zero holdout: 31
Model beats naive on:        18 of 31  → 58.1% win rate
Median |% error| forecast:   208.4%
Median |% error| naive:      1900.0%
Volume-weighted WAPE (forecast): 178.7%
Volume-weighted WAPE (naive):    65.5%   ← naive is 3× better on volume

Two readings, both true:

Reading 1 (favourable): the model beat naive on 58% of pairs. Median error dropped from 1900% to 208%. That is a 9× improvement.

Reading 2 (honest, and this is the true one): volume-weighted WAPE — which is what actually matters for stock decisions — is 178.7% for the model vs 65.5% for naive. On the SKUs that carry the business, the model is 3× worse than "just repeat last year."

The 58% win rate counts pairs equally, including 20-unit tail SKUs. The volume-weighted number counts the big SKUs, which is where stockouts and overstock actually cost money.

validation_results.csv sorted by actual_12m descending, first 15 rows, showing selected_model, train_best_wape, forecast_12m, actual_12m, and naive_12m columns
validation_results.csv — top 15 pairs by holdout volume, sorted by actual_12m descending. Every top-volume pair is OMNI CHANNEL C/O AMAZON US. Forecast > actual > naive everywhere: the model over-forecast the most, naive the least.

What went wrong

Look at the top 10 pairs by holdout volume. Every single one is OMNI CHANNEL C/O AMAZON US. That is the concentration problem.

For the top SKU, WALL-CHARGR-25W:

QuantityValue
Forecast (ETS)58,148
Actual holdout22,791
Naive (last 12m of training)37,021

The ETS model saw growth in the training window, extrapolated it, and forecast 58k. Actual was 22.8k — a 155% over-forecast. Naive said 37k — a 62% over-forecast. Both wrong, both over. Naive was closer.

Same pattern on the next SKU. R400-SOM-BDFEGA: forecast 52k, actual 17k, naive 11k. Both over, naive closer.

Why is naive closer than ETS here? Because ETS with additive trend on lumpy wholesale data extrapolates noise. When the training window contains a big spike — a bulk Amazon order, a launch stocking, a promotion — ETS thinks that spike is the trend and projects it forward. Naive just says "repeat last year" and stays grounded.

This is a real, documented, common failure mode of trend-following models on wholesale data. It is not a bug in the code. It is the physics of the problem.

What the app got right

The app correctly flagged the SKUs where the model would fail. It did not confidently produce a wrong number. It produced a number and a warning.

The sentence I would walk into an interview with

The most important output of this forecasting app was telling the user not to trust the forecast without validation. Not "my model beat naive." Not "look at my MAPE." The honest sentence: I built a forecasting tool, I validated it on real data, the model lost to the naive baseline on the SKUs that matter, and the app's reliability layer correctly identified the failure mode.

Executive decision summary — the business view

The top of the app is a one-screen view for a supply chain lead:

ActionCount (pilot)
Validate85
Increase Stocking0
Evaluate for Reduction0
Maintain Current Stocking0

Eighty-five SKUs need a customer conversation before anyone orders anything. Zero SKUs get an automatic "increase stocking" or "reduce stocking" recommendation. That is the correct output for this dataset — the app is telling the business that almost nothing in this portfolio is forecastable enough to act on without human validation.

The app is also explicit about what it cannot say: "No SKU currently receives Evaluate for Reduction. This is a demand signal only; physical overstock cannot be asserted without on-hand inventory." That is a boundary, and it is stated, not hidden.

The workbook — Excel as a live decision layer

The output is not a CSV dump. It is a decision workbook with live formulas, a pivot-table guide, and a documented methodology sheet.

Annualization test with data cut at June

Instead of blindly annualizing YTD × (12 / months_elapsed), the app shows what the annualized number would have been at the June cutoff, side by side with the latest.

CustomerAnnualized (latest)Annualized (June)Change %
OMNI CHANNEL C/O AMAZON US122,322105,918+15.5%
Telispire PCS/ZEFCOM691.5912−24.2%
Horizon Shop391.5486−19.4%
C1 BM / 1 Communication ltd309412−25.0%

Those swings are a signal, not noise. They mean the annualized answer depends heavily on which cutoff you pick, which means neither number should be treated as final.

Customer × SKU YTD with live formulas

The workbook ships with live formulas, not just static values. The Customer_SKU_YTD sheet computes:

YTD_Quantity_Live         =SUMIFS(tblMonthlyDemand[quantity], tblMonthlyDemand[customer_id], A2, tblMonthlyDemand[sku], B2, tblMonthlyDemand[year], YEAR(G2), tblMonthlyDemand[month], "<="&G2)
Annualized_Quantity_Live  =IF(M2=0, 0, M2 / MONTH(G2) * 12)
Prior_Full_Year_Live      =SUMIFS(..., tblMonthlyDemand[year], YEAR(G2) - 1)
YoY_%_Live                =IF(O2<=0, NA(), (M2 - O2) / ABS(O2))

Every downstream metric recalculates when the underlying tblMonthlyDemand table is refreshed. This is not "Excel as a viewer." It is "Excel as a live calculation layer with documented methodology." A Formula_Guide sheet lists the canonical formula for every metric so an auditor can trace any number back to source.

Exceptions

Every month × SKU is screened for spikes (≥5× same-calendar-month median) and drops (≤25% of same-calendar-month median), with intermittent demand handled separately (spike/drop rules suppressed when ≥40% of months are zero). The Exceptions sheet is the analyst's work queue. Hundreds of exceptions across the portfolio. That is the correct output — the app's job is not to hide the mess, it is to make the mess legible.

Bugs found along the way

Eight bugs were worth writing down. Each one cost more than an hour.

Bug 01 · Workbook Generation

Excel Live-Formula Concatenation Bug

A formula referenced the wrong cell range and produced "142" instead of "N2" when the workbook was programmatically regenerated. Cause: string concatenation of an integer column label. The column index 142 was being concatenated into the formula string where the cell reference N2 was intended.

Lesson: never concatenate integer column labels into formula strings — convert to letter notation explicitly and unit-test the emitted formulas.

Bug 02 · Empty Sheets

Three Empty Sheets in the Workbook Export

Customer_Forecast, Customer_SKU_Forecast, and June_Annualization_Test were being created but populated only when a specific path executed. On a different code path, the sheets existed with no rows and no headers — and the downstream pivot tables and SUMIFS ranges broke on the missing columns.

Lesson: force-write the header row on every sheet, even when the body is empty, so downstream formulas never touch a blank range.

Bug 03 · Documentation Drift

Stale Formula_Guide References

The guide listed formulas that referenced an older schema. Once the sheet names or column positions changed, the documented formulas no longer matched the actual workbook.

Lesson: generate the guide from the same dict that generates the workbook, so they cannot drift.

Bug 04 · Intermittent Demand

Exception Noise from Intermittent SKUs

SKU_26 produced 21 "Demand Drop" rows in a single month because the calendar-month median was zero and any nonzero value looked like a spike while every zero looked like a drop. The exceptions sheet was drowning in false positives for a SKU with genuinely sporadic demand.

The fix: suppress spike/drop rules entirely when ≥40% of months are zero, and report "intermittent demand" as a single labelled row with the zero-month count.

Lesson: spike/drop detection is meaningless when the median is zero. Detect the zero-heavy pattern first and switch rules accordingly.

Bug 05 · Serialization

pyarrow Mixed-Type Error in the Exceptions DataFrame

The Exceptions sheet had a column that was sometimes int, sometimes string, sometimes pd.NA. pyarrow refused to serialize it when the workbook export went through Arrow.

Lesson: force exception columns to string at write time and document the transformation — Arrow's strictness is a feature, not a bug, but you have to meet it.

Bug 06 · Rule Contradiction

Action Layer and Trend Check Disagreeing

A SKU could simultaneously get "Maintain Current Stocking" from the action layer and "Review" from the trend check. Two rules firing on different criteria, and disagreeing on the same SKU in the same report. That is the kind of contradiction that destroys trust in the tool the first time a supply chain lead sees it.

Fix (Option A): if the trend check says Review, force the action to Validate. The stricter rule wins.

Lesson: when two rules disagree, always promote the more conservative one. A forecast you can't trust must never be labelled "Maintain."

Bug 07 · Environment

Stale Virtualenv After Moving the Project from C: to D:

The venv had absolute paths baked into it. After moving the project to a different drive, imports resolved against the old path and failed silently in some cases and loudly in others.

Lesson: recreate the venv after any drive change. Nothing more exotic than that.

Bug 08 · Scope Error

six_month_selected NameError in portfolio.py

The variable was only defined inside one branch of the model-comparison block. When the block took a different branch, the later reference threw NameError at runtime.

Lesson: initialize conditional variables to None at function scope and check before use. Never rely on a variable only existing in one branch.

None of those are interesting in isolation. Together they are the difference between a script that works on your laptop and a tool someone else can run.

What this does not mean

Three things this project is not claiming:

Recommendation

For the 33,939 customer × SKU pairs that cannot be forecast monthly, monthly time-series is the wrong tool. The right recommendations are:

  1. Customer reorder-probability modelling. Instead of predicting how much a customer will order next month, predict whether they will order. The right output is a probability and a reorder window, not a point forecast.
  2. Aggregate customer-level forecasting. Roll the SKU-level chaos up to the customer level. A customer that orders 400 SKUs irregularly still has a fairly stable total monthly demand. Forecast the aggregate, allocate down proportionally.
  3. New-SKU launch curves. For SKUs with under 6 months of activity, you cannot forecast monthly demand. You can forecast against a launch curve — either a category-level curve or a curve from a comparable prior SKU.

For the 92-pair forecastable core: keep the three-model selection with expanding-window backtest, but add two guardrails before the forecast is used downstream:

Both of those guardrails are already partially present in the app (the disagreement warning fires today). They should be promoted to hard rules.

Where it stands now

The forecasting engine ships as a Streamlit application and an audit-ready Excel workbook. It handles cleaning, monthly aggregation, model selection, backtesting, reliability tiering, sanity checks, sensitivity re-runs, and export in a single pass. The pilot validated it on real data, and the validation produced an honest result that the app is designed to surface rather than hide.

84,741
Clean Order Lines
0.27%
Forecastable Pairs
58%
Pair Win Rate (model vs naive)
65.5%
Naive Volume-Weighted WAPE

Engineering summary

The finding is the deliverable. "0.27% of pairs are forecastable" is a more useful sentence than any forecast number this project produced. That sentence changes what the business does next. The forecast numbers change what the business orders next month. The first is strategy. The second is tactics.

Honest metrics beat flattering metrics. I could have reported "58% win rate" and stopped. That number is true and it flatters the model. But the volume-weighted WAPE is the number a supply chain lead needs to make a decision, and that number says the model is 3× worse than a naive baseline on the SKUs that matter.

A forecasting app's most important feature is knowing when not to forecast. This project started as "build a demand forecasting engine." It ended as "build a tool that tells you which demand is forecastable." Those are different products, and the second one is more useful.

The diagnostics are the product. If I had shipped only the forecast numbers, this pilot would have looked like a disaster. If I had shipped only the win rate, it would have looked like a triumph that the volume number contradicts. What I actually shipped was a stack of diagnostics that told the analyst, in plain language, which numbers to trust and which to validate.


Want to see the voice agent log? Read Voice AI · Log 03 →

Full case study: Demand Forecasting Engine — Case Study →


13. What v2 delivered — the replenishment worklist

v1 answered the question "what will demand be?" and was honest that the answer was usually "we don't know." That is a useful tool. It is not a complete tool. A supply chain lead cannot take "we don't know" to a customer. They need a next action.

v2 delivers the next action. It takes the forecast v1 already produced, cross-references it against on-hand inventory and supplier lead time, and produces a replenishment worklist — a prioritized queue of SKUs, ranked by urgency, with a specific quantity to order once the customer confirms.

A tool that says "Validate" eighty-five times isn't a tool. It's a warning label. The worklist turns the warning label into an action queue.

13.1 The design tension

The first version of the replenishment layer produced this on the pilot portfolio: 85 SKUs, every one of them receiving action = "Validate with Customer", and every one of them with recommended_order_qty = 0. The trust rule was working correctly — 84 of the 85 SKUs are Low reliability, and the one Medium-reliability SKU had a Review trend-check. Both are legitimate blocks.

But a supply chain lead cannot act on "validate 85 things." That's the same as "do nothing." The trust rule was doing its job. The output was failing the user.

13.2 The resolution: two order numbers

The fix was not to loosen the trust rule. It was to produce two numbers instead of one:

The second number is the product. It's what the supply chain lead walks into the customer call with. The first number is the guardrail. Together they turn "we don't know" into "call this customer today and ask about 9,869 units."

13.3 The priority rank

Every SKU now gets a validation_urgency bucket derived from days of cover versus lead time:

UrgencyRuleMeaning
Call todaydays of cover < lead timeCannot recover if the call waits.
Call this weekdays of cover < 1.5 × lead timeBuffer is thin but recoverable.
Monitordays of cover < lead time + review periodInside the reorder window; watch next cycle.
No actioneverything elseCover is comfortably above the reorder window.

SKUs are then ranked 1..N: first by urgency bucket, then by days of cover ascending within each bucket. Rank 1 is always the SKU with the smallest margin between its current cover and its lead time.

13.4 The pilot output

On the pilot portfolio, the worklist produced this:

RankSKUSupplierUrgencyDays of coverOrder if validated
1R400-SOM-BDFEGAChinaCall today0.19,869
2WALL-CHARGR-25WBossCall today0.29,198
3XT2311-3M128BKUXiomiCall today7.7439
4IPAD9-64SIL-WIFAppleCall today17.150
5R400-SBUDSFEBKUChinaCall today22.050
………………

The top of the list is R400-SOM-BDFEGA, with 2.4 hours of cover against a 25-day lead time. Rank 2 is WALL-CHARGR-25W — the SKU from v1 — with 5.3 hours of cover and a Medium reliability tier whose decision-layer verdict was "Validate with Customer, driven by OMNI CHANNEL C/O AMAZON US, 100% share." The app will not dispatch an order for either. It will tell the analyst to call China about 9,869 units and Boss about 9,198 units, in that order, before lunch.

13.5 The wall charger, revisited

v1 flagged WALL-CHARGR-25W for three independent reasons: Medium reliability, a Review trend-check, and a decision-layer verdict starting with "Validate." Each was sufficient on its own. The app zeroed the order and told the analyst to call the customer.

v2 keeps the same block, but adds the number the analyst takes into the call:

Call today — validate & order. Days of cover 0.2. Stockout risk High. Per policy no order is dispatched until the signal is validated with the customer. If the customer confirms, order 9,198 units from Boss.

That final sentence is the whole difference between v1 and v2. v1 said "don't trust this forecast." v2 says "don't trust this forecast, and here is exactly what to do when you've verified it." The second sentence is what a supply chain lead needs before the customer picks up the phone.

13.6 What v2 does not claim

Two honest boundaries on this release:

13.7 Engineering summary

v1 asked "what will demand be?" and was honest that the answer was usually "we don't know." v2 asks "so what do we do about it?" and produces a prioritized queue of answers that a supply chain analyst can act on this morning. The trust rule is preserved, the guardrail is unchanged, and the output is transformed from a warning into a worklist.

The lesson is not that the first version was wrong. It's that the trust rule without a next action is a monologue, not a conversation. A forecasting tool that tells you not to trust the number, and nothing else, is a tool that gets closed after the first demo. A forecasting tool that tells you not to trust the number, and then hands you the exact call to make, is a tool that gets used every Monday morning.

That distinction is v2.


Tests: 46 passing. New this release: src/inventory_decision.py, the Replenishment worklist tab, the Replenishment_Worklist and Call_Today Excel sheets, and 8 new pytest cases covering the trust-rule branches individually.