How the forecast, the explainability, the simulator, and the LLM layer fit together. Everything in this doc is grounded in code that runs — file paths are real and linkable.
Monthly hectolitres (Hl) at material_id × sub_channel × month. The UI labels this "SKU × channel". A forecast point is (SKU, channel, future month) → predicted hL.
A weekly view is available via /api/forecast?granularity=week — it's a deterministic ISO-week split of the monthly forecast (see services/weekly_split.py), not a separately-trained weekly model.
One command:
make trainRuns services/forecast/train.py which orchestrates 11 steps. The interesting ones:
| Step | Module | Purpose | Output |
|---|---|---|---|
| 1 | train_lgb |
LightGBM quantile models (p10, p50, p90) on the monthly panel | model joblib + learning curves |
| 3 | zeroshot |
amazon/chronos-bolt-base foundation-model forecast (no Damm training) |
forecasts_zeroshot.parquet |
| 4 | cmbc |
StatsForecast carve-out for FREE TRADE CMBC (B2B replenishment, different behavior) |
forecasts_cmbc.parquet |
| 5 | ensemble |
Blend LGB + Chronos + Chronos-Promo + AutoARIMA + CMBC with channel-specific weights | forecast.parquet (canonical) |
| 7 | calibrate |
Per-channel interval calibration so the 80% band is actually 80% | calibrated columns on forecast.parquet |
| 8 | cv |
Rolling-origin CV (train on past, predict 3 months ahead, slide cutoff forward) | mape.parquet |
| 9 | explain |
SHAP on the LGB p50 → top features per SKU × channel | drivers.parquet |
| 11 | causal |
Historical promo lift estimation by (promo_type × brand) | promo_roi.parquet |
Lags (1, 3, 6, 12), rolling means (3, 6, 12), month/quarter, sin/cos seasonality, calendar flags (is_christmas_month, is_summer, is_christmas_buildup, is_easter_month), UK holiday count, and the external series (weather, search trends, ONS retail). Categorical SKU/channel/brand are target-encoded with smoothing (no val/test leakage).
What the model does not see directly: planned promos (not joined into the training panel) and one-off mega-events (World Cup, Euros — only fire every 2-4 years so LGB has 0-1 training instances). Both are handled separately:
- Promos: simulator applies a deterministic lift curve at runtime (see services/forecast/simulate.py).
- Event-driven moments the seasonality multiplier can't capture: post-forecast boost in the ensemble step, table-driven from
POST_FORECAST_BOOSTin services/calendar.py. Covers UK beer-demand events that fall in months the seasonality multiplier under-lifts: tournament finals (World Cup / Euros — +20% demo-amplified above the measured +3-7% band so the tournament month visibly stands out on the chart), Premier League season kickoff (+4% in August), Bonfire Night (+8% in November — fills the previously-flat gap between October stocking peak and December), Notting Hill Carnival (+3% in August), Easter Monday (+3% in the month Easter shifts into), Wimbledon (+2% sustained sport-pub demand), Halloween (+2%) and Black Friday (+3% off-trade stocking). Fixed-date bank holidays that land in months seasonality already lifts strongly (Christmas, May BH, Spring BH, Summer BH) are deliberately NOT boosted to avoid double-counting. Importance labels for the simulator's promo-amplifier were also revised: New Year's Day and Good Friday reclassified to LOW (post-NYE recovery; religious holiday with traditional pub closures), as both are weak beer-demand drivers vs the other bank holidays. - Monthly seasonality shape: iterative h-step LGB smooths out variation by horizon 4+ (each step feeds its own p50 forward as the next lag). To recover the shape we apply a multiplicative index post-forecast in services/seasonality.py. Two-tier resolution: per-(SKU × sub_channel × month) when the SKU has full 12-month coverage (108 series qualify; bounds
[0.40, 2.20]since the whole point is to recover SKU-specific peaks the brand-pooled tier washes out), falling back to brand-pooled (brand × sub_channel × month) when not (29 brand series, tighter bounds[0.55, 1.80]). Both tiers renormalise so the 12-month mean is exactly 1.0 — annual level preserved, only intra-year shape changes. Hero Estrella 330ml NRB × GROCERY's July forecast went from3073 → 3796 hL(target 3813) and October from2662 → 3960 hL(target 5149) once per-SKU resolution kicked in — the brand-pooled tier was averaging the spike with flatter Estrella SKUs and washing it out.
| Sub-channel | LGB | Chronos | Chronos-Promo | AutoARIMA | CMBC |
|---|---|---|---|---|---|
| GROCERY | 0.45 | 0.25 | 0.30 | – | – |
| FREE TRADE CMBC | – | – | – | – | 1.00 |
| NATIONAL ON TRADE | 0.55 | 0.30 | – | 0.15 | – |
| FREE TRADE | 0.55 | 0.30 | – | 0.15 | – |
| CONVENIENCE & WHOLESALE | 0.55 | 0.30 | – | 0.15 | – |
| MDD COPACKING | 0.70 | 0.30 | – | – | – |
Source: DEFAULT_WEIGHTS in services/forecast/ensemble.py.
Rolling-origin CV (3 folds, walk-forward):
| Level | MAPE |
|---|---|
| brand × sub_channel | 44.2% |
| SKU × sub_channel | 127.3% |
SKU-level MAPE is high because long-tail SKUs swing wildly month-to-month at this resolution. Brand-level aggregation reduces variance and brings MAPE back to a usable band. The UI mostly reads from SKU-level for action items (the inbox) and brand/channel-level for portfolio readouts (the pulse + rollups).
Forecast = Hl_hat_p50 from forecast.parquet.
Target = derived target_hl from targets.parquet (prior-year same-month actuals or trailing median; see DATA.md for why we derive rather than read).
gap_hl = forecast_hl − target_hl
gap_pct = gap_hl / target_hl
Negative gap = forecast below target. The chart shows the median forecast (solid), 80% confidence band (shaded), and target (dashed).
LLMs turn model outputs into business language. They never invent forecasts — the numeric source of truth stays Parquet.
Router: services/llm.py.
| Profile | Model | Provider | Used for |
|---|---|---|---|
fast |
meta-llama/Llama-3.3-70B-Instruct |
Groq | low-latency explain / chat |
deep |
moonshotai/Kimi-K2-Instruct |
Novita | recommendations + briefs |
fallback |
Qwen/Qwen2.5-72B-Instruct |
HF auto | provider failure recovery |
Token: HF_TOKEN from backend/.env or ~/.cache/huggingface/token.
| Endpoint | Role | Fallback |
|---|---|---|
/api/recommend |
3 commercial scenarios (conservative / balanced / aggressive) for SKU × channel × period | deterministic scenario set |
/api/explain-view |
Headline + bullets summarising visible dashboard state | deterministic generic summary |
/api/brief |
Customer-call brief content | deterministic brief text |
/api/chat |
Conversational route | depends on router behavior |
- Invent customer or supermarket names (data is anonymized — see DATA.md)
- Change forecast numbers
- Hide uncertainty
- Claim "official budget" when our target is derived
- Claim promo lift not present in the ROI context
These rules are enforced in the system prompts and via the deterministic fallback paths.
For /api/recommend it receives the forecast point, the target, the gap, top SHAP drivers, and historical promo ROI for the SKU's brand × channel. It returns structured JSON validated via Pydantic before reaching the UI.
For /api/brief it gets the customer's at-risk SKU basket (top 5), the channel + period, recent news context, and meeting timing — produces a structured brief with headline, push-forward narrative, per-SKU asks, market context links, and agenda.
If the model returns malformed JSON or the provider fails, the deterministic fallback fires and the UI still renders without an empty state.
- No official budget file; we derive a
target_hlproxy. - Weekly forecast is a deterministic split of monthly, not a separately-trained model.
- Promo-to-SKU matching is brand/label based — the promo Excel uses retailer SKU names, not Damm material IDs.
- External context for forecast months without actuals uses prior-year-same-month as a proxy, flagged in the API response.
/api/recommendmay return fallback scenarios when the LLM is unavailable.
These were tried, measured, and rolled back. Documented here so the same ground doesn't get re-tilled.
- Event-importance as model features (
event_importance_score,event_high/med/low). Added toEXTERNAL_COLS, MAPE worsened by ~3pp at brand level and ~15pp at SKU level in 3-fold CV. Recurring events are already captured bymonth+is_christmas_month+uk_holidays_count; truly one-off events have insufficient history. Replaced with deterministic post-forecast boosts inservices/calendar.py'sPOST_FORECAST_BOOSTtable (World Cup / Euros +20% demo-amplified, Wimbledon +2%, Easter Monday +3%). - Planned-promo intensity as model features (
n_planned_promos,avg_planned_discount, sourced from the Damm Trade Plan and aggregated to brand × GROCERY × month byattach_planned_promosinservices/etl.py). Added toEXTERNAL_COLS, MAPE delta was exactly 0.00pp at both brand and SKU level in 3-fold CV — LightGBM never split on the feature because the trade plan only covers months from late 2025 onwards, leaving ~98% of training rows at the (0, 0.0) default. To get signal we'd need historical promo flags reconstructed from past actuals (price-drop detection) or a retrospective extension of the plan; neither is available in the provided dataset. The columns remain inwide_monthlybecause the simulator and the decision-page "Planned promos" card both consume them at runtime.