Raw rows ingested
42,131
4 tables
Analysis-ready rows
5,200
50 SKUs × 104 weeks
Backtest SKU-weeks
2,400
6 folds × 8 wk × 50
WAPE model / naive
8.75% / 11.16%
21.5% better
Holdout Dec-25
9.26%
80% band covers 79.5%
Sales at risk
₹47.9 L
8 reorder SKUs
Locked capital
₹1.48 Cr
8 markdown SKUs
1. Business problem & success metrics
NorthBay Living (D2C home & lifestyle, ~50 active SKUs in the extract, one warehouse) plans stock on spreadsheets. Best-sellers stock out; slow movers pile up. The brief asks for a weekly SKU-level forecast that beats a naive baseline, a stockout early-warning, an overstock flag, rupee impact, and a dashboard + scoring service the ops team can use unaided.
| Success metric | Target (brief) | Achieved |
|---|
| Forecast accuracy | WAPE beats seasonal-naive on a rolling-origin backtest | 8.75% vs 11.16% (21.5% better); wins 49/50 SKUs |
| Risk precision | Flags right often enough to act on | probability-based; 80% interval calibrated at 79.5% on holdout; triage 8 / 8 / 34 |
| Quantified impact | Rupee sales-at-risk and locked capital | ₹47.9 L at risk · ₹1.48 Cr locked |
| Usability | Ops can answer “what do I reorder?” unaided | Reorder & markdown lists, filters, what-if, CSV export, API |
| Reproducibility | Pipeline re-runs from raw with one command | python src/run_all.py (~1 min), fixed seed, 5 tests |
2. Data inventory & profiling
1
Ingest — four CSV extracts read with pandas; columns renamed to the brief’s data dictionary (snake_case). A fifth file, Stock_data.csv (equity prices, ticker AABA 2006–2017), has no join key and is excluded.
2
Profile — dtypes, nulls, uniques, ranges and duplicates per column (tables below). Findings feed the cleaning rules in step 3.
sales_daily.csv (fact) — 36,550 rows × 6 columns · duplicates: 0
| column | dtype | non-null | nulls | unique | min | max | mean | example |
|---|
| Date | str | 36,550 | 0 | 731 | 2024-01-01 | 2025-12-31 | | 2024-01-01 |
| SKU | str | 36,550 | 0 | 50 | SKU001 | SKU050 | | SKU001 |
| Units_Sold | int64 | 36,550 | 0 | 55 | 0 | 56 | 14.0 | 5 |
| Revenue | float64 | 36,550 | 0 | 1,327 | 0.0 | 546977.0 | 84707.43 | 18320.25 |
| Price | float64 | 36,550 | 0 | 50 | 663.46 | 11642.45 | 5892.66 | 3664.05 |
| Promotion | int64 | 36,550 | 0 | 2 | 0 | 1 | 0.1 | 0 |
sku_master.csv (dimension) — 50 rows × 8 columns · duplicates: 0
| column | dtype | non-null | nulls | unique | min | max | mean | example |
|---|
| SKU | str | 50 | 0 | 50 | SKU001 | SKU050 | | SKU001 |
| Product_Name | str | 50 | 0 | 50 | Product 001 | Product 050 | | Product 001 |
| Category | str | 50 | 0 | 5 | Furniture | Storage | | Furniture |
| Subcategory | str | 50 | 0 | 10 | Cabinet | Table | | Chair |
| Launch_Date | str | 50 | 0 | 48 | 2022-03-12 | 2024-12-09 | | 2022-04-09 |
| Cost_Price | float64 | 50 | 0 | 50 | 307.06 | 7748.2 | 3770.73 | 1758.45 |
| Selling_Price | float64 | 50 | 0 | 50 | 663.46 | 11642.45 | 5892.66 | 3664.05 |
| Gross_Margin_Per_Unit | float64 | 50 | 0 | 50 | -5484.32 | 10224.93 | 2121.93 | 1905.6 |
calendar.csv (dimension) — 731 rows × 11 columns · duplicates: 0
| column | dtype | non-null | nulls | unique | min | max | mean | example |
|---|
| date | str | 731 | 0 | 731 | 2024-01-01 | 2025-12-31 | | 2024-01-01 |
| year | int64 | 731 | 0 | 2 | 2024 | 2025 | 2024.5 | 2024 |
| month | int64 | 731 | 0 | 12 | 1 | 12 | 6.52 | 1 |
| quarter | str | 731 | 0 | 4 | Q1 | Q4 | | Q1 |
| week | int64 | 731 | 0 | 52 | 1 | 52 | 26.4 | 1 |
| day_of_week | str | 731 | 0 | 7 | Friday | Wednesday | | Monday |
| is_weekend | int64 | 731 | 0 | 2 | 0 | 1 | 0.28 | 0 |
| season | str | 731 | 0 | 5 | Autumn | Winter | | Winter |
| holiday | str | 8 | 723 | 4 | Christmas | Republic Day | | nan |
| is_holiday | int64 | 731 | 0 | 2 | 0 | 1 | 0.01 | 0 |
| promotion_event | str | 75 | 656 | 1 | Seasonal Promotion | Seasonal Promotion | | nan |
inventory_snapshots.csv (periodic fact) — 4,800 rows × 8 columns · duplicates: 0
| column | dtype | non-null | nulls | unique | min | max | mean | example |
|---|
| Snapshot_Date | str | 4,800 | 0 | 24 | 2024-01-01 | 2025-12-01 | | 2024-01-01 |
| SKU | str | 4,800 | 0 | 200 | SKU001 | SKU200 | | SKU001 |
| Current_Stock | int64 | 4,800 | 0 | 954 | 2 | 2585 | 304.14 | 16 |
| On_Order | int64 | 4,800 | 0 | 822 | 0 | 3060 | 213.09 | 23 |
| Lead_Time_Days | int64 | 4,800 | 0 | 12 | 3 | 14 | 8.44 | 11 |
| Safety_Stock | int64 | 4,800 | 0 | 341 | 5 | 746 | 83.15 | 7 |
| Reorder_Point | int64 | 4,800 | 0 | 715 | 10 | 1661 | 211.75 | 18 |
| Inventory_Value | float64 | 4,800 | 0 | 4,630 | 2444.2 | 20279867.85 | 1304166.47 | 58420.96 |
Sample rows
sales_daily — first 6 rows| Date | SKU | Units_Sold | Revenue | Price | Promotion |
|---|
| 2024-01-01 | SKU001 | 5 | 18,320.25 | 3,664.05 | 0 |
| 2024-01-01 | SKU002 | 15 | 57,085.35 | 3,805.69 | 0 |
| 2024-01-01 | SKU003 | 5 | 40,391.15 | 8,078.23 | 0 |
| 2024-01-01 | SKU004 | 5 | 34,307.85 | 6,861.57 | 0 |
| 2024-01-01 | SKU005 | 12 | 113,919 | 9,493.22 | 0 |
| 2024-01-01 | SKU006 | 7 | 56,742.28 | 8,106.04 | 0 |
inventory_snapshots — first 6 rows| Snapshot_Date | SKU | Current_Stock | On_Order | Lead_Time_Days | Safety_Stock | Reorder_Point | Inventory_Value |
|---|
| 2024-01-01 | SKU001 | 16 | 23 | 11 | 7 | 18 | 58,420.96 |
| 2024-01-01 | SKU002 | 29 | 12 | 4 | 5 | 10 | 163,983 |
| 2024-01-01 | SKU003 | 34 | 23 | 14 | 6 | 20 | 209,060 |
| 2024-01-01 | SKU004 | 34 | 4 | 7 | 5 | 12 | 42,257.92 |
| 2024-01-01 | SKU005 | 23 | 5 | 5 | 6 | 11 | 170,946 |
| 2024-01-01 | SKU006 | 13 | 6 | 5 | 5 | 10 | 86,143.20 |
sku_master — first 6 rows| SKU | Product_Name | Category | Subcategory | Launch_Date | Cost_Price | Selling_Price | Gross_Margin_Per_Unit |
|---|
| SKU001 | Product 001 | Furniture | Chair | 2022-04-09 | 1,758.45 | 3,664.05 | 1,905.60 |
| SKU002 | Product 002 | Home Decor | Table | 2024-05-01 | 3,867.09 | 3,805.69 | -61.40 |
| SKU003 | Product 003 | Kitchen | Cushion | 2023-12-22 | 589.48 | 8,078.23 | 7,488.75 |
| SKU004 | Product 004 | Lighting | Cookware | 2023-04-28 | 1,445.74 | 6,861.57 | 5,415.83 |
| SKU005 | Product 005 | Storage | Lamp | 2023-04-22 | 5,543.63 | 9,493.22 | 3,949.59 |
| SKU006 | Product 006 | Furniture | Shelf | 2024-08-02 | 6,021.91 | 8,106.04 | 2,084.13 |
calendar — first holiday / promo rows| date | year | month | quarter | week | day_of_week | is_weekend | season | holiday | is_holiday | promotion_event |
|---|
| 2024-01-26 | 2,024 | 1 | Q1 | 4 | Friday | 0 | Winter | Republic Day | 1 | nan |
| 2024-03-02 | 2,024 | 3 | Q1 | 9 | Saturday | 1 | Spring | nan | 0 | Seasonal Promotion |
| 2024-03-03 | 2,024 | 3 | Q1 | 9 | Sunday | 1 | Spring | nan | 0 | Seasonal Promotion |
| 2024-03-09 | 2,024 | 3 | Q1 | 10 | Saturday | 1 | Spring | nan | 0 | Seasonal Promotion |
| 2024-03-10 | 2,024 | 3 | Q1 | 10 | Sunday | 1 | Spring | nan | 0 | Seasonal Promotion |
| 2024-03-16 | 2,024 | 3 | Q1 | 11 | Saturday | 1 | Spring | nan | 0 | Seasonal Promotion |
Descriptive statistics — sales_daily
| column | count | mean | std | min | 25% | 50% | 75% | max |
|---|
| Units_Sold | 36,550.00 | 14.00 | 8.47 | 0.00 | 7.00 | 13.00 | 20.00 | 56.00 |
| Revenue | 36,550.00 | 84,707.43 | 76,534.63 | 0.00 | 27,643.70 | 56,125.90 | 127,954 | 546,977 |
| Price | 36,550.00 | 5,892.66 | 3,138.78 | 663.46 | 3,484.09 | 5,649.80 | 8,516.60 | 11,642.45 |
| Promotion | 36,550.00 | 0.10 | 0.30 | 0.00 | 0.00 | 0.00 | 0.00 | 1.00 |
Descriptive statistics — inventory_snapshots (all 200 SKUs)
| column | count | mean | std | min | 25% | 50% | 75% | max |
|---|
| Current_Stock | 4,800.00 | 304.14 | 280.89 | 2.00 | 109.00 | 229.00 | 418.00 | 2,585.00 |
| On_Order | 4,800.00 | 213.09 | 244.46 | 0.00 | 49.00 | 135.00 | 292.00 | 3,060.00 |
| Lead_Time_Days | 4,800.00 | 8.44 | 3.49 | 3.00 | 5.00 | 8.00 | 11.25 | 14.00 |
| Safety_Stock | 4,800.00 | 83.15 | 70.26 | 5.00 | 35.00 | 67.00 | 112.00 | 746.00 |
| Reorder_Point | 4,800.00 | 211.75 | 179.91 | 10.00 | 87.00 | 169.00 | 286.00 | 1,661.00 |
| Inventory_Value | 4,800.00 | 1,304,166 | 1,741,551 | 2,444.20 | 252,186 | 683,739 | 1,720,023 | 20,279,868 |
3. Cleaning & data-quality log
Every rule lives in src/pipeline.py and writes to data/processed/data_quality_log.json — nothing is edited by hand, and the log is what the dashboard’s Data-quality tab renders.
| Table | Issue found | n | Handling | Rationale |
|---|
| Stock_data.csv | Unrelated file: equity price history (AABA ticker 2006–2017) | — | Excluded from the pipeline | Not part of the NorthBay star schema; no join key to any table. |
| sales_daily | Duplicate (date, sku) rows | 0 | None found — no action | One row per SKU-day is the declared grain. |
| sales_daily | Negative units_sold (returns?) | 0 | None found — no action | Returns are not demand. |
| sales_daily | Missing numeric values | 0 | None found — no action | A missing sales row means nothing sold that day. |
| sales_daily | revenue != units_sold * unit_price (>₹1 gap) | 0 | None found — internally consistent | Revenue is the booked figure; price is a reference. |
| sales_daily | Rows retained after cleaning | 36,550 | 36550 → 36550 | |
| sku_master | launch_date AFTER first recorded sale | 21 | launch_date overridden with first_sale_date (original kept as launch_date_master) | Sales history is the stronger evidence; a product cannot sell before launch. |
| sku_master | list_price below unit_cost (negative gross margin) | 16 | Kept; flagged margin_flag='negative' and surfaced to Finance | Cannot be corrected from data — likely a master-data error or loss-leader; must be confirmed by client. |
| sku_master | category/subcategory pairs that look inconsistent (e.g. Kitchen→Cushion) | 40 | Kept the master 'category' for reporting; added 'subcategory_group' as a sanity view | Category is the client's planning hierarchy; we do not silently re-classify their catalogue. |
| inventory_snapshots | SKUs present in inventory but with NO sales history (SKU051–SKU200) | 150 | Excluded from forecasting/risk; listed separately as 'unsellable-or-unmapped stock' for the client | No demand signal → cannot forecast; likely discontinued or master-data gap. Raised as a finding. |
| inventory_snapshots | Duplicate (date, sku) snapshots | 0 | None found — no action | |
| inventory_snapshots | Snapshot cadence | 24 | Monthly snapshots (1st of month); latest = 2025-12-01 | Risk scoring uses the latest snapshot as the opening stock position at the forecast origin. |
| calendar | Blank holiday / promotion_event cells | — | Treated as 'None' (not missing) | Blank means no event that day. |
| sales_weekly | Partial weeks at series edges (fewer than 7 days) | 50 | Dropped | A 3-day 'week' would look like a demand crash and poison the model. |
| sales_weekly | SKU-weeks with no sales rows at all | 0 | Filled with 0 units | No row = no sale. |
Biggest finding: 150 inventory SKUs (SKU051–SKU200) never sold a unit in two years but hold ₹17.49 Cr of stock at the latest snapshot. Excluded from forecasting (no demand signal); listed in inventory_unmapped_skus.csv for reconciliation.
4. Weekly analysis-ready dataset
3
Aggregate to weeks — Sunday-ending weeks; partial edge weeks dropped (a 3-day “week” looks like a demand crash). Complete SKU × week grid so silent zero-weeks are explicit. Calendar attributes (week-of-year, month, season, promo days, holiday days) and master attributes (category, launch date, price, cost) joined.
4
Set the forecast origin = 2025-11-30, the Sunday before the latest inventory snapshot (2025-12-01), so the stock position and the forecast start on the same day. The 4 December weeks become a true holdout (flag is_holdout).
| Property | Value |
|---|
| Grain | one row per SKU per week |
| Rows | 5,200 = 50 SKUs × 104 weeks |
| Span | 2024-01-07 → 2025-12-28 |
| Training weeks | 100 (≤ origin) |
| Holdout weeks | 4 (Dec-2025) |
| Total units | 510,082 |
| Total revenue | ₹308.56 Cr |
| Columns | sku_id, week_end, units, revenue, promo_days, holiday_days, unit_price, week_of_year, month, year, season, category, subcategory, launch_date, unit_cost, list_price, weeks_since_launch, is_holdout |
sales_weekly — last 10 weeks of SKU012 (note the is_holdout flag)| sku_id | week_end | units | revenue | promo_days | holiday_days | unit_price | week_of_year | month | year | season | category | subcategory | unit_cost | list_price | weeks_since_launch | is_holdout |
|---|
| SKU012 | 2025-10-26 00:00:00 | 141 | 1,237,891 | 0 | 1 | 8,779.37 | 43 | 10 | 2,025 | Autumn | Home Decor | Table | 1,256.89 | 8,779.37 | 94 | False |
| SKU012 | 2025-11-02 00:00:00 | 151 | 1,325,685 | 2 | 0 | 8,779.37 | 44 | 11 | 2,025 | Autumn | Home Decor | Table | 1,256.89 | 8,779.37 | 95 | False |
| SKU012 | 2025-11-09 00:00:00 | 155 | 1,360,802 | 2 | 0 | 8,779.37 | 45 | 11 | 2,025 | Autumn | Home Decor | Table | 1,256.89 | 8,779.37 | 96 | False |
| SKU012 | 2025-11-16 00:00:00 | 163 | 1,431,037 | 2 | 0 | 8,779.37 | 46 | 11 | 2,025 | Autumn | Home Decor | Table | 1,256.89 | 8,779.37 | 97 | False |
| SKU012 | 2025-11-23 00:00:00 | 155 | 1,360,802 | 2 | 0 | 8,779.37 | 47 | 11 | 2,025 | Autumn | Home Decor | Table | 1,256.89 | 8,779.37 | 98 | False |
| SKU012 | 2025-11-30 00:00:00 | 161 | 1,413,479 | 2 | 0 | 8,779.37 | 48 | 11 | 2,025 | Autumn | Home Decor | Table | 1,256.89 | 8,779.37 | 99 | False |
| SKU012 | 2025-12-07 00:00:00 | 167 | 1,466,155 | 0 | 0 | 8,779.37 | 49 | 12 | 2,025 | Winter | Home Decor | Table | 1,256.89 | 8,779.37 | 100 | True |
| SKU012 | 2025-12-14 00:00:00 | 155 | 1,360,802 | 0 | 0 | 8,779.37 | 50 | 12 | 2,025 | Winter | Home Decor | Table | 1,256.89 | 8,779.37 | 101 | True |
| SKU012 | 2025-12-21 00:00:00 | 171 | 1,501,272 | 0 | 0 | 8,779.37 | 51 | 12 | 2,025 | Winter | Home Decor | Table | 1,256.89 | 8,779.37 | 102 | True |
| SKU012 | 2025-12-28 00:00:00 | 150 | 1,316,906 | 0 | 1 | 8,779.37 | 52 | 12 | 2,025 | Winter | Home Decor | Table | 1,256.89 | 8,779.37 | 103 | True |
5. EDA — seasonality & demand drivers
Monthly seasonality — average total units per week (both years, and by year)| Month | Avg units / week | Avg units / week 2024 | Avg units / week 2025 | Seasonality index | Read |
|---|
| Jan | 4,749.38 | 4,739.50 | 4,759.25 | 0.97 | average |
| Feb | 5,126.25 | 5,142.00 | 5,110.50 | 1.05 | average |
| Mar | 5,922.00 | 5,911.80 | 5,932.20 | 1.21 | peak |
| Apr | 5,637.25 | 5,663.00 | 5,611.50 | 1.15 | peak |
| May | 5,534.12 | 5,520.75 | 5,547.50 | 1.13 | peak |
| Jun | 5,644.90 | 5,670.00 | 5,619.80 | 1.15 | peak |
| Jul | 4,778.38 | 4,786.25 | 4,770.50 | 0.98 | average |
| Aug | 4,407.89 | 4,406.50 | 4,409.00 | 0.90 | average |
| Sep | 4,361.89 | 4,382.60 | 4,336.00 | 0.89 | trough |
| Oct | 3,889.50 | 3,892.75 | 3,886.25 | 0.80 | trough |
| Nov | 4,313.67 | 4,342.50 | 4,290.60 | 0.88 | trough |
| Dec | 4,327.00 | 4,306.60 | 4,352.50 | 0.88 | trough |
Reading: Mar is the peak and Oct the trough in both years — a 52% swing that repeats almost identically. That stability is what makes a 52-week seasonal signal usable.

Weekday effect| Weekday | Avg units / SKU-day | Total units | SKU-days | Index vs weekday avg |
|---|
| Monday | 13.03 | 68,430 | 5,250 | 1.00 |
| Tuesday | 13.02 | 68,342 | 5,250 | 1.00 |
| Wednesday | 13.04 | 68,451 | 5,250 | 1.00 |
| Thursday | 13.11 | 68,193 | 5,200 | 1.00 |
| Friday | 13.19 | 68,567 | 5,200 | 1.01 |
| Saturday | 16.28 | 84,652 | 5,200 | 1.24 |
| Sunday | 16.38 | 85,175 | 5,200 | 1.25 |
Promotion effect| Day type | SKU-days | Avg units | Avg revenue (₹) |
|---|
| No promotion | 32,800 | 13.48 | 81,550.08 |
| Promotion day | 3,750 | 18.61 | 112,324 |
Holiday & season effect| Day type | SKU-days | Avg units |
|---|
| Normal day | 36,150.00 | 14.02 |
| National holiday | 400.00 | 12.16 |
| Spring | | 16.67 |
| Summer | | 16.01 |
| Winter | | 13.56 |
| Monsoon | | 12.83 |
| Autumn | | 11.77 |
Weekend lift +24.9%, promotion lift +38.1%, holiday effect -13.3%. Promotions are planned, so promo_days_target is a legitimate forward-looking feature.
6. EDA — categories, SKUs and dead stock
Category mix| Category | SKUs | Units (2 yrs) | Revenue (₹) | Avg price (₹) | Units share % | Revenue share % |
|---|
| Furniture | 10 | 82,026 | 601,392,700 | 6,594.75 | 16.08 | 19.49 |
| Home Decor | 10 | 132,100 | 885,273,983 | 6,478.72 | 25.90 | 28.69 |
| Kitchen | 10 | 100,784 | 561,503,262 | 5,178.73 | 19.76 | 18.20 |
| Lighting | 10 | 102,131 | 457,384,575 | 4,897.31 | 20.02 | 14.82 |
| Storage | 10 | 93,041 | 580,007,535 | 6,313.81 | 18.24 | 18.80 |

Insights: top-10 SKUs = 47.7% of revenue, bottom-10 = 4.0%; dead-stock candidates SKU028, SKU048, SKU010, SKU050, SKU011. 16 SKUs are priced below cost (16.7% of revenue) — master-data error or loss-leaders; Finance must confirm before discounting.
7. Feature engineering (leakage-safe)
5
Direct multi-horizon design — for every origin week t and horizon h = 1…8, one training row: features from history ≤ t plus known calendar attributes of week t+h; target = units in week t+h. Origins step weekly from week 13 onward, giving 2,400 scored rows in the backtest and a larger training set for the final model.
| Feature | Definition | Source / leakage status |
|---|
| h | Horizon (weeks ahead, 1–8) | design |
| lag1…lag4, lag8, lag13 | Units in the k-th week before the origin | history ≤ origin |
| mean4 / mean8 / mean13 | Rolling mean of the last 4/8/13 weeks | history ≤ origin |
| std4 | Rolling std of last 4 weeks (volatility) | history ≤ origin |
| trend4_13 | mean4 − mean13 (short vs medium level) | history ≤ origin |
| lag52_target | Units in the same week last year (target week − 52) | history ≤ origin |
| lag52_mean3 | Mean of last year's target week ±1 | history ≤ origin |
| level_ratio_52 | mean4 ÷ same 4 weeks last year | history ≤ origin |
| promo_days_target | Planned promotion days in the target week | calendar (known in advance) |
| holiday_days_target | Holiday days in the target week | calendar |
| week_of_year, month, season | Calendar position of the target week | calendar |
| weeks_since_launch | Product age at the target week | master data |
| unit_price | Selling price | master data |
| sku_id, category | Identity (LightGBM categorical) | master data |
Leakage guard: tests/test_foresight.py::test_no_future_leakage_in_training_rows asserts that lag1 equals the last observed week ≤ origin and every target week is strictly after the origin.
8. Metric & baseline
WAPE = Σ|actual − forecast| ÷ Σ actual (primary — robust when some SKUs sell few units)
Bias = Σ(forecast − actual) ÷ Σ actual (secondary — systematic over/under-forecast)
MAPE = mean(|actual − forecast| ÷ actual) over actual > 0 (reported, not optimised)
Seasonal-naive: forecast(t+h) = actual(t+h−52) (falls back to mean of last 4 weeks if last year is missing)
Horizon fixed at 8 weeks. The baseline is the bar: if the model cannot beat it on the backtest, the baseline ships.
9. Model
| LightGBM parameter | Value | Why |
|---|
| objective | tweedie (variance power 1.2) | non-negative, right-skewed count-like demand |
| n_estimators | 600 | |
| learning_rate | 0.03 | |
| num_leaves | 31 | |
| min_child_samples | 20 | |
| subsample / colsample_bytree | 0.8 / 0.8 (freq 1) | regularisation |
| reg_lambda | 1.0 | |
| random_state | 42 | fixed seed → reproducible |
One model for all 50 SKUs (identity as a categorical feature) so shared seasonality and promotion effects are learned jointly; Tweedie objective suits non-negative, right-skewed demand; predictions clipped at 0.
10. Rolling-origin backtest
6
Six origins in 2025, four weeks apart (2025-05-18, 2025-06-15, 2025-07-13, 2025-08-10, 2025-09-07, 2025-10-05). At each, the model is retrained from scratch on data ≤ origin and scored on the next 8 weeks. No random splits.
By fold| Fold | Origin | WAPE model | WAPE naive |
|---|
| 1 | 2025-05-18 | 7.98% | 10.04% |
| 2 | 2025-06-15 | 8.48% | 10.97% |
| 3 | 2025-07-13 | 9.38% | 11.69% |
| 4 | 2025-08-10 | 9.21% | 12.13% |
| 5 | 2025-09-07 | 8.99% | 11.37% |
| 6 | 2025-10-05 | 8.70% | 11.03% |
By horizon| Weeks ahead | WAPE model | WAPE naive |
|---|
| 1 | 8.02% | 10.66% |
| 2 | 8.62% | 10.60% |
| 3 | 8.77% | 10.95% |
| 4 | 9.18% | 11.67% |
| 5 | 8.71% | 11.09% |
| 6 | 8.61% | 11.08% |
| 7 | 8.63% | 11.23% |
| 8 | 9.57% | 12.07% |
By category| Category | WAPE model | WAPE naive |
|---|
| Furniture | 9.67% | 12.12% |
| Home Decor | 8.32% | 10.37% |
| Kitchen | 8.75% | 11.50% |
| Lighting | 8.39% | 10.79% |
| Storage | 8.95% | 11.46% |
Model WAPE by fold × horizon| Fold | Origin | h1 | h2 | h3 | h4 | h5 | h6 | h7 | h8 |
|---|
| 1 | 2025-05-18 | 6.1% | 6.9% | 7.7% | 8.9% | 8.8% | 8.6% | 6.9% | 10.1% |
| 2 | 2025-06-15 | 7.8% | 8.8% | 7.1% | 10.2% | 7.6% | 8.9% | 9.4% | 8.1% |
| 3 | 2025-07-13 | 7.4% | 9.9% | 10.3% | 8.2% | 9.6% | 8.2% | 10.4% | 11.3% |
| 4 | 2025-08-10 | 9.8% | 7.9% | 10.3% | 10.1% | 9.6% | 8.5% | 7.6% | 9.9% |
| 5 | 2025-09-07 | 9.2% | 8.5% | 8.7% | 9.4% | 8.2% | 10.0% | 9.9% | 8.2% |
| 6 | 2025-10-05 | 8.5% | 10.4% | 8.9% | 8.3% | 8.5% | 7.5% | 7.8% | 9.9% |

Overall WAPE
8.75%
naive 11.16%
SKUs won
49/50
folds won 6/6
11. Prediction intervals & December holdout
Relative residuals (actual − forecast) ÷ forecast from the backtest, per horizon, give the 10th/90th percentiles for an 80% band and a sigma used by the risk layer.
Interval calibration by horizon| Horizon h | q10 (rel.) | q90 (rel.) | rel. sigma |
|---|
| 1 | -0.14 | 0.17 | 0.14 |
| 2 | -0.15 | 0.17 | 0.14 |
| 3 | -0.16 | 0.15 | 0.13 |
| 4 | -0.17 | 0.16 | 0.14 |
| 5 | -0.15 | 0.14 | 0.13 |
| 6 | -0.16 | 0.14 | 0.14 |
| 7 | -0.16 | 0.15 | 0.14 |
| 8 | -0.17 | 0.18 | 0.15 |

Top-revenue SKU: history, baseline, forecast with 80% band, December holdout dots.
| December-2025 holdout (never trained on) | Value |
|---|
| Holdout weeks | 4 |
| WAPE model | 9.26% |
| WAPE seasonal-naive | 12.42% |
| Bias model | -0.15% |
| 80% interval coverage | 79.5% (target 80%) |
13. Risk scoring formulas (transparent, no black box)
D_LT = forecast demand over the lead time (weekly forecast interpolated to days) · σ_LT = summed weekly sigma
P_lt = P[ D_LT > on_hand ] = 1 − Φ((on_hand − D_LT)/σ_LT) ← stock runs out before the open order lands
P_rop = P[ D_LT + safety_stock > on_hand + on_order ] ← inventory position already below the reorder point
Stockout score = max(P_lt, P_rop)
weeks_of_cover = (on_hand + on_order) ÷ average weekly forecast
Overstock score = clip((weeks_of_cover − 8) ÷ 8, 0, 1) → 0 at ≤8 weeks, 0.5 at 12, 1 at ≥16
Sales at risk (₹) = max(0, D_LT − on_hand, D_LT + SS − on_hand − on_order) × list price
Locked capital (₹) = max(0, on_hand + on_order − 8-week demand) × unit cost
Reorder qty = demand over (lead time + 4-week review) + safety stock − (on_hand + on_order)
| Quadrant | Rule (threshold 0.5) | Action |
|---|
| Reorder now | stockout ≥ 0.5 and overstock < 0.5 | Raise a replenishment order (quantity given) |
| Markdown / clear | overstock ≥ 0.5 and stockout < 0.5 | Promote / discount; defer open orders |
| Watch / volatile | both ≥ 0.5 | Investigate erratic demand manually |
| Healthy | both < 0.5 | No action |
Calibration note: a first version compared 8-week demand with stock on hand and flagged 36/50 SKUs for reorder — technically true, practically useless for a team that reorders monthly. Reframing stockout as “will it run out before the next order can land” produced the 8 / 8 / 34 triage below.
14. Risk results — all 50 SKUs

15. Reorder & markdown lists
Reorder now — 8 SKUs| SKU | Product | Category | Order qty | Sales at risk | Days of stock |
|---|
| SKU012 | Product 012 | Home Decor | 919 | ₹20.5 L | 1.90 |
| SKU017 | Product 017 | Home Decor | 506 | ₹10.8 L | 7.50 |
| SKU031 | Product 031 | Furniture | 617 | ₹9.9 L | 5.20 |
| SKU042 | Product 042 | Home Decor | 559 | ₹3.2 L | 4.20 |
| SKU040 | Product 040 | Storage | 512 | ₹1.3 L | 4.80 |
| SKU034 | Product 034 | Lighting | 536 | ₹87,590 | 1.80 |
| SKU010 | Product 010 | Storage | 519 | ₹75,057 | 5.30 |
| SKU023 | Product 023 | Kitchen | 418 | ₹38,906 | 9.30 |
Markdown / clear — 8 SKUs| SKU | Product | Category | Excess units | Locked capital | Weeks cover |
|---|
| SKU020 | Product 020 | Storage | 647 | ₹43.3 L | 15.30 |
| SKU006 | Product 006 | Furniture | 491 | ₹29.5 L | 19.10 |
| SKU011 | Product 011 | Furniture | 882 | ₹15.2 L | 58.30 |
| SKU036 | Product 036 | Furniture | 262 | ₹14.2 L | 16.20 |
| SKU025 | Product 025 | Storage | 1048 | ₹14.0 L | 69.10 |
| SKU015 | Product 015 | Storage | 244 | ₹13.3 L | 18.40 |
| SKU004 | Product 004 | Lighting | 281 | ₹4.1 L | 17.70 |
| SKU050 | Product 050 | Storage | 542 | ₹3.8 L | 21.90 |
16. Rupee impact
By category| Category | SKUs | Sales at risk | Locked capital |
|---|
| Furniture | 10 | ₹9.9 L | ₹62.0 L |
| Home Decor | 10 | ₹34.6 L | ₹1.2 L |
| Kitchen | 10 | ₹38,906 | ₹4.2 L |
| Lighting | 10 | ₹87,590 | ₹5.8 L |
| Storage | 10 | ₹2.1 L | ₹74.4 L |
| Measure | Value |
|---|
| Sales at risk (lead-time shortfall × price) | ₹47.9 L |
| Locked capital (excess beyond 8 weeks × cost) | ₹1.48 Cr |
| Inventory on hand at cost (50 sellable SKUs) | ₹5.12 Cr |
| Unmapped inventory (150 SKUs, no sales) | ₹17.49 Cr |
| Quadrants | Healthy: 34, Markdown / clear: 8, Reorder now: 8 |

17. Dashboard — how it works
The dashboard (index.html) is a static page: src/export_web.py packs the forecast, risk table, 26 weeks of history per SKU, backtest metrics and the data-quality log into data/dashboard.json (~92 KB). The page loads it once, then everything — filters, sorting, charts, what-if — runs in the browser. risk_engine.js is a line-for-line port of src/risk.py, so the what-if slider and the API give the same numbers as the batch job.
| Tab | What it shows | Notes |
|---|
| Overview | 5 KPI cards, decisioning bubble grid (click a bubble → explorer), rupee impact by category, top priorities, total demand with forecast band | filters: category, quadrant, search |
| Reorder & markdown list | Reorder list with order qty and days of stock; markdown list with weeks of cover and margin flag; sortable full table; CSV export | sort by any column |
| Forecast explorer & what-if | Per-SKU history + forecast + 80% band + seasonal-naive + Dec holdout dots; lead-time slider, on-hand/on-order/safety inputs → instant re-score with a plain-language “why” | stretch goal from the brief |
| Model accuracy | Backtest by fold, horizon, category; feature importance; honest limitations text | — |
| Data quality | The pipeline’s data-quality log and dataset summary | — |
| Scoring API | Endpoint documentation and a live “try it” box | calls /api/score |
| States | Loading spinner while fetching; explicit error card if data is missing; empty-state cards when filters match nothing | acceptance criterion D5.4 |

Overview

Reorder & markdown lists

Forecast explorer & what-if

Model accuracy
A Streamlit edition (app/streamlit_app.py) with identical logic is included for Streamlit Community Cloud, as recommended in the brief.
18. Scoring API
| Method | Endpoint | Returns |
|---|
| GET | /api/health | status, forecast origin, model, backtest WAPE |
| GET | /api/score?sku=SKU012 | 8-week forecast (mean, lo80, hi80, naive) + risk + action for one SKU |
| GET | /api/score?sku=SKU012&lead_time_days=21&on_hand=30 | what-if override (also on_order, safety_stock) |
| GET | /api/score?sku=SKU012,SKU020 · ?all=true | batch (summary totals + quadrants) |
| POST | /api/score {"skus":[…],"overrides":{…}} | batch with overrides |
| — | errors | unknown SKU → 404 · bad number → 400 · empty → 400 with example · bad JSON → 400 · other method → 405 |
{
"forecast_origin": "2025-11-30",
"count": 1,
"results": [
{
"sku_id": "SKU012",
"product_name": "Product 012",
"category": "Home Decor",
"inventory_position": {
"on_hand": 47,
"on_order": 60,
"lead_time_days": 21,
"safety_stock": 16,
"overridden": true
},
"forecast": {
"origin": "2025-11-30",
"horizon_weeks": 8,
"weeks": [
"2025-12-07",
"2025-12-14",
"2025-12-21",
"2025-12-28",
"2026-01-04",
"2026-01-11",
"2026-01-18",
"2026-01-25"
],
"mean": [
162.6,
165.8,
163.9,
166.7,
171.9,
178.5,
181.7,
180.6
],
"\u2026": "lo80, hi80, seasonal_naive, total_8w"
},
"risk": {
"stockout_score": 1.0,
"overstock_score": 0.0,
"quadrant": "Reorder now",
"action": "Raise a replenishment order for ~N units before stock runs out",
"\u2026": "p_stockout_lead_time, weeks_of_cover, reorder_qty, sales_at_risk_inr, locked_capital_inr"
}
}
]
}
Served by a Netlify serverless function (netlify/functions/score.mjs); a FastAPI twin (service/main.py, docs at /docs) is included for Render.
19. Reproducibility & tests
pip install -r requirements.txt
python src/run_all.py # pipeline → forecast + backtest → risk → figures → dashboard data (~60 s)
python -m pytest tests -q # metrics, risk quadrants, output consistency, leakage guard, API errors
python reports/build_reports.py # PDFs: EDA memo, executive readout, project report, video scripts
python src/build_analysis_page.py # this page
| Path | Purpose |
|---|
| src/pipeline.py | D1 ingest · clean · weekly features · data-quality log |
| src/forecast.py | D3 baseline · LightGBM · rolling-origin backtest · intervals · forecast |
| src/risk.py | D4 stockout/overstock scoring · actions · rupee impact |
| src/eda.py | D2 figures + EDA summary |
| src/export_web.py | dashboard data bundle |
| web/ | D5 static dashboard + risk_engine.js + this page |
| netlify/functions/score.mjs | D6 serverless scoring API |
| app/streamlit_app.py · service/main.py | Streamlit + FastAPI editions |
| notebooks/01–03 | executed notebooks with outputs |
| reports/ | D2 memo · D7 readout · project report · video scripts · figures |
| tests/ | 5 pytest tests |
A fresh end-to-end rerun reproduced the headline numbers bit-for-bit (seed 42).
20. Assumptions, limitations & monitoring
| Topic | Assumption / limit |
|---|
| Stock position | Latest monthly snapshot = opening position at the origin; on-order arrives at end of lead time |
| Future calendar | Promotions/holidays after 31-Dec-2025 unknown → assumed 0 |
| History depth | Two years = one prior seasonal cycle; new SKUs lean on category peers |
| Distribution | Lead-time demand ≈ normal; sigma scaled from backtest relative errors per horizon |
| Scope | No price optimisation, supplier selection, live integrations or automated POs (per brief) |
| Monitoring | Retrain monthly; alert if 4-week WAPE loses to seasonal-naive for 2 consecutive months or |bias| > 5%; track realised stockouts among “Healthy” SKUs |
Bottom line: demand is predictable to within about 9%. NorthBay’s problem is timing, not demand — fast sellers ordered too late, slow sellers too often. FORESIGHT turns that into two short lists every month.