diff --git a/PRPs/PRP-MLZOO-C2-prophet-like-additive-model.md b/PRPs/PRP-MLZOO-C2-prophet-like-additive-model.md new file mode 100644 index 00000000..9114b8c9 --- /dev/null +++ b/PRPs/PRP-MLZOO-C2-prophet-like-additive-model.md @@ -0,0 +1,997 @@ +name: "PRP-MLZOO-C2 — Prophet-like Additive Forecasting Model" +description: | + +## Purpose + +The second half of MLZOO-C (`PRPs/INITIAL/INITIAL-MLZOO-C-xgboost-prophet-extensions.md`). +It adds a **Prophet-like additive forecasting model** — `ProphetLikeForecaster` — a +*deterministic, regularized, additive linear* model that decomposes demand into **trend**, +**seasonality**, and **holiday/regressor** components. + +This is **not** a clone of the LightGBM/XGBoost tree models. It is a distinct model-family +design task. The two tree models are gradient-boosted, non-additive, and opaque; the +Prophet-like model is a transparent additive linear model whose fitted coefficients *are* +the component decomposition. Concretely it is a scikit-learn `Pipeline` of a +`SimpleImputer` + a `Ridge` regressor over the canonical 14-column feature frame, plus a +`decompose()` method that splits any forecast into its additive trend / seasonality / +regressor contributions. + +> **Sibling PRP:** `PRPs/PRP-MLZOO-C1-xgboost-model.md` ships the XGBoost model. C1 and C2 +> are intentionally **separate branches and separate review units** — never combine them. +> They are additive and order-independent; whichever merges second rebases cleanly (see +> "Sibling-PRP integration" below). + +> **Naming honesty.** The model is "Prophet-**like**", never "Prophet". It deliberately +> approximates Prophet's *additive decomposition* shape using a linear model over +> already-engineered features. It does **not** add the real `prophet`/Stan dependency and +> does **not** replicate Prophet's changepoint trend, posterior uncertainty intervals, or +> automatic seasonality discovery. `docs/optional-features/05-advanced-ml-model-zoo.md` +> explicitly endorses "Prophet-like" as the intentional term for exactly this. Every +> docstring and doc section MUST set this expectation plainly (see Risks). + +## What this PRP already inherits (DO NOT re-build) + +PRP-29 (MLZOO-A), PRP-30 (MLZOO-B), PRP-27 (the `regression` model), and PRP-MLZOO-B.2 +(feature-aware backtesting) already shipped the structural foundation. Re-use it: + +- **The feature-aware model contract.** `BaseForecaster.requires_features: ClassVar[bool]` + (`app/features/forecasting/models.py:64`). `RegressionForecaster` (`models.py:438`) is the + **closest structural template** — like the Prophet-like model it wraps a pure-scikit-learn + estimator, needs **no optional dependency**, and needs **no feature flag**. (The LightGBM/ + XGBoost forecasters are *less* relevant here — they carry optional-dependency machinery + this model does not need.) +- **The shared feature-frame contract.** `app/shared/feature_frames/` owns + `canonical_feature_columns()` — the fixed, ordered 14-column set: + `["lag_1","lag_7","lag_14","lag_28","dow_sin","dow_cos","month_sin","month_cos", + "is_weekend","is_month_end","price_factor","promo_active","is_holiday", + "days_since_launch"]`. The Prophet-like model consumes this frame **unchanged** and + writes **zero** new contract code (DECISIONS LOCKED #3). +- **Train / predict / scenarios / backtesting** — all branch on + `model.requires_features`, capability-based, never on a `model_type` string + (`forecasting/service.py:219,383`, `scenarios/service.py:114`, + `backtesting/service.py:384-409`). A new feature-aware model trains, is predict-rejected, + re-forecasts in scenarios (`method="model_exogenous"`), and backtests with **zero + changes to those four service layers**. +- **The leakage spec.** `app/features/forecasting/tests/test_regression_features_leakage.py` + and `app/shared/feature_frames/tests/test_leakage.py` pin the historical and future + builders. Because the Prophet-like model consumes the **same** builders, its training and + future feature matrices are leakage-covered by construction (DECISIONS LOCKED #6). + +The **problem this PRP fixes**: `docs/optional-features/05-advanced-ml-model-zoo.md` calls +for "Prophet-like models with trend, seasonality, holiday, and regressor components" as the +third model family — to make ForecastLabAI a credible model-*comparison* platform with more +than tree models. No additive/decomposable model exists today (`regression` is a tree; +`naive`/`seasonal_naive`/`moving_average` are target-only). + +## DEPENDS ON — read before starting + +- `PRPs/INITIAL/INITIAL-MLZOO-C-xgboost-prophet-extensions.md` — the shared C brief. +- `PRPs/INITIAL/INITIAL-MLZOO-index.md` — the MLZOO roadmap. +- `docs/optional-features/05-advanced-ml-model-zoo.md` — § "Prophet-like Models" is the + vision: trend, weekly/yearly seasonality, holiday/event regressors, optional changepoints, + optional external regressors; and the explicit option "Implement a lightweight additive + model using sklearn regression over generated trend/seasonal features." +- `PRPs/PRP-27-scenario-simulation-full-version.md` & `PRPs/ai_docs/exogenous-regressor-forecasting.md` + — how the `regression` model (the structural template) consumes a future feature frame. +- `examples/models/feature_frame_contract.md` — the historical/future frame shapes. + +--- + +## Goal + +Implement `ProphetLikeForecaster` — a deterministic, feature-aware, **additive** forecasting +model — and wire it end-to-end. It is a scikit-learn `Pipeline([SimpleImputer, Ridge])` over +the canonical 14-column feature frame. It exposes the standard `BaseForecaster` interface +(`fit`/`predict`/`get_params`/`set_params`, `requires_features = True`) **plus** a model- +specific `decompose()` method that returns the additive trend / seasonality / holiday- +regressor contribution breakdown of a forecast. Because it is pure scikit-learn (already a +core dependency), it ships **always-enabled** — no optional dependency group, no feature +flag, no lazy import — exactly like the `regression` model. + +**End state:** a user can train a `prophet_like` model (HTTP or job), re-forecast it in a +what-if scenario (`method="model_exogenous"`), and backtest it, with no extra install and no +flag — exactly as they can a `regression` model today. Every existing model behaves +**identically** before and after. + +## Why + +- **The model zoo needs a non-tree, transparent model.** The comparison platform currently + has three target-only baselines and two opaque gradient-boosted trees (`regression`, + `lightgbm` — and `xgboost` from sibling C1). An *additive linear* model is a genuinely + different model family: interpretable, fast, and the natural seam for explainability + (MLZOO-D). It answers "how much of this forecast is trend vs seasonality vs the promo?". +- **Dependency-free.** Unlike the tree models, this needs no native dependency, no extra, + no flag — it ships on the already-pinned `scikit-learn`. Zero install-friction; perfectly + aligned with the single-host vision. +- **The foundation is fully paid for.** Train, predict, scenarios, and backtesting all + branch on `requires_features`. A new feature-aware model is a contained change. +- **Low blast radius.** No migration, no API-contract change, no existing-model change, no + new dependency, no new vertical slice. + +## What + +A backend-only feature PRP. User-visible behaviour gains exactly one thing: `model_type: +"prophet_like"` becomes a real, trainable, scenario-re-forecastable, backtestable model. +Everything else is identical. + +### The model design (READ THIS — it is the core of the PRP) + +**Decomposition mapping.** The canonical 14 columns are partitioned into three +Prophet-style components. Define this as a module-level constant in `models.py`: + +| Component | Canonical columns | Prophet analogue | +|-----------|-------------------|------------------| +| `trend` | `lag_1`, `lag_7`, `lag_14`, `lag_28`, `days_since_launch` | growth `g(t)` — autoregressive level + lifecycle ramp | +| `seasonality` | `dow_sin`, `dow_cos`, `month_sin`, `month_cos`, `is_weekend`, `is_month_end` | seasonal `s(t)` — weekly/monthly cycle (these are exactly `CALENDAR_COLUMNS`) | +| `holiday_regressor` | `price_factor`, `promo_active`, `is_holiday` | holiday + extra-regressor `h(t)` — known-in-advance exogenous effects | + +**The additive math.** A `Ridge` fit gives `y_hat = intercept + Σ_i coef_i · x_i`. Group the +sum by component: `y_hat = intercept + trend_contrib + seasonality_contrib + +regressor_contrib`, where `_contrib = Σ_{i ∈ component} coef_i · x_i`. This is the +literal additive decomposition — each component contribution is just the partial sum of that +component's columns. `decompose(X)` returns the four-way breakdown; the **additive +invariant** is `intercept + trend + seasonality + holiday_regressor == predict(...)` +(within float tolerance) and is a model-specific validation test. + +**NaN tolerance.** Linear models reject `NaN` (`Ridge.fit` raises `ValueError: Input +contains NaN`). The future feature frame intentionally emits `NaN` for un-resolvable lag +cells. Mitigation: a `SimpleImputer(strategy="median")` as the first `Pipeline` step. The +imputer learns its per-column medians on **training `X` only** (`Pipeline.fit` enforces +this) and re-applies them at predict time — no leakage. `decompose()` therefore computes +`coef_ · x` on the **imputed** `X`, not the raw `X`. + +**Determinism.** `Ridge(solver="cholesky")` has a closed-form, deterministic solution (no +`random_state` needed). `SimpleImputer` (median) is deterministic. The whole `Pipeline` is +deterministic — two fits on the same data produce identical coefficients and forecasts. + +**Why `Ridge`, not `LinearRegression`.** The 14 engineered columns are heavily collinear +(`lag_1` vs `lag_7`, the calendar columns). Plain OLS is unstable under collinearity; +`Ridge`'s L2 penalty makes coefficients robust while staying closed-form and deterministic. +`ElasticNet` is rejected — its L1 term zeros coefficients (feature selection), which would +silently drop a curated calendar column and corrupt the seasonal component; it is also +iterative. (See `https://scikit-learn.org/stable/modules/linear_model.html#ridge-regression-and-classification`.) + +### Technical requirements + +1. **No new dependency.** `scikit-learn` is already a core dependency (`pyproject.toml:21`) + and ships `Ridge`, `SimpleImputer`, `Pipeline`. **No** `pyproject.toml` change, **no** + `uv.lock` change, **no** new optional extra (DECISIONS LOCKED #2). +2. **No feature flag.** The model is always available, exactly like `regression`. **No** + `app/core/config.py` change, **no** `forecast_enable_*` setting, **no** route gate + (DECISIONS LOCKED #2). +3. **`ProphetLikeModelConfig`** in `app/features/forecasting/schemas.py` — a + `ModelConfigBase` subclass: `model_type: Literal["prophet_like"]`, `alpha: float` + (Ridge regularization strength, `ge=0.0`, `le=10000.0`, default `1.0`), + `feature_config_hash: str | None`. Conservative — no `seasonality_mode`, no Fourier + order (DECISIONS LOCKED #4). Added to the `ModelConfig` union. +4. **`ProphetLikeForecaster`** in `app/features/forecasting/models.py` — a `BaseForecaster` + subclass, `requires_features: ClassVar[bool] = True`, structurally closest to + `RegressionForecaster`. It builds a `Pipeline([("impute", SimpleImputer(strategy= + "median")), ("ridge", Ridge(alpha=self.alpha, solver="cholesky"))])` inside `fit()`, + stores it as `self._estimator`, and stores the fitted column-component grouping. It + exposes `decompose()` in addition to the base interface. +5. **`model_factory`** — a new `prophet_like` branch (no flag gate, mirroring the + `regression` branch at `models.py:793-803`). The `ModelType` literal (`models.py:736`) + gains `"prophet_like"`. +6. **Jobs integration.** `JobService._execute_train` (`jobs/service.py:454-478`) and + `_execute_backtest` (`jobs/service.py:641-658`) each gain a `prophet_like` branch + building `ProphetLikeModelConfig` — mirroring the `regression` branches. +7. **Persistence/metadata.** **No `ModelBundle` change.** The fitted `Pipeline` is pickled + by joblib exactly like `HistGradientBoostingRegressor`; `sklearn_version` (already + captured, `persistence.py:55,100`) and `runtime_info["sklearn_version"]` (already + captured, `registry/service.py:96-100`) fully cover it. No new version field — there is + no new library to version. (DECISIONS LOCKED #5.) +8. **Tests** — a new `test_prophet_like_forecaster.py` (no `importorskip` — pure sklearn, + always runs) with the standard contract tests **plus** model-specific tests (additive + invariant, imputer NaN tolerance, decomposition determinism); an + `examples/models/prophet_like_additive.py` example; additive docs. + +### Success Criteria + +- [ ] `model_factory(ProphetLikeModelConfig(), random_state=42)` returns a + `ProphetLikeForecaster` — **no flag, never raises a "not enabled" error**. +- [ ] `ProphetLikeForecaster.requires_features is True`; `fit`/`predict` require a + non-`None` `X` and raise the same error-message substrings as `RegressionForecaster` + (`"requires exogenous features"`, `"rows must match"`, `"horizon"`, `"fitted"`). +- [ ] A `predict` over a future frame containing `NaN` lag cells succeeds (the + `SimpleImputer` fills them) — a plain `Ridge` would raise `ValueError: Input contains + NaN`. +- [ ] Two fits on the same data produce **identical** forecasts + (`np.testing.assert_array_equal`). +- [ ] **Additive invariant:** for any fitted model and any `X`, + `decompose(X)` returns `{intercept, trend, seasonality, holiday_regressor}` summing + (within `1e-9` relative tolerance) to `predict(len(X), X)`. +- [ ] `decompose()` uses the **imputed** `X` and the **trained** imputer statistics — a + future-frame `NaN` is imputed with the *training* median, not a predict-time median. +- [ ] `ForecastingService.train_model` trains a `prophet_like` model with **no edit to + `train_model`**; `POST /scenarios/simulate` returns `method="model_exogenous"`; a + backtest produces per-fold metrics — all with **no edit to the four service layers**. +- [ ] `JobService._execute_train` and `_execute_backtest` accept `model_type="prophet_like"`. +- [ ] Every existing model and every existing test pass **with no behaviour change**. +- [ ] `uv run ruff check . && uv run ruff format --check . && uv run mypy app/ && uv run pyright app/ && uv run pytest -v -m "not integration"` all green. +- [ ] No Alembic migration; no new dependency; no `pyproject.toml`/`uv.lock`/`config.py` + change; no route-path/response-schema/WebSocket change. + +--- + +## All Needed Context + +### Documentation & References + +```yaml +- file: app/features/forecasting/models.py + why: RegressionForecaster (lines 438-577) is the STRUCTURAL TEMPLATE — pure-sklearn + wrapper, no optional dep, no flag, estimator constructed inside fit() and typed + `Any`. Copy its __init__/fit/predict guard shape and error strings. The + model_factory `regression` branch (lines 793-803) is the template for the + prophet_like branch (NO flag gate). The ModelType literal is at line 736. The + module already imports sklearn with `# type: ignore[import-untyped]` (lines 20-22) + — add the Ridge/SimpleImputer/Pipeline imports the same way. + +- file: app/features/forecasting/schemas.py + why: RegressionModelConfig (lines 147-189) is the template for ProphetLikeModelConfig — + same ModelConfigBase base, same Field(ge=, le=, default=) idiom, same + feature_config_hash field. The ModelConfig union is at lines 192-199. + +- file: app/features/forecasting/service.py + why: train_model (lines 180-297) branches `if model.requires_features:` (line 219) — + MODEL-AGNOSTIC, builds the historical frame via _build_regression_features and + calls model.fit(features.y, features.X). predict (lines 383-393) rejects + feature-aware models. DO NOT EDIT service.py — a prophet_like model trains and is + predict-rejected purely because requires_features=True. + +- file: app/features/scenarios/service.py + why: model_exogenous dispatch branches on `bundle.model.requires_features` (line 114) — + no model_type strings remain in app/features/scenarios/. A prophet_like bundle + takes the genuine re-forecast path with ZERO scenarios changes. + +- file: app/features/backtesting/service.py + why: lines 384-409 probe `model_factory(...).requires_features` and build per-fold + leakage-safe X. MODEL-AGNOSTIC. A prophet_like model backtests with ZERO + backtesting-service changes. + +- file: app/features/jobs/service.py + why: _execute_train model_type chain at lines 454-478 (the `regression` branch is the + template; final `else: raise ValueError("Unsupported model_type: ...")`). + _execute_backtest has an IDENTICAL chain at lines 641-658. Forecasting-schemas + import block at lines 426-433. ADD a prophet_like branch to BOTH chains. + +- file: app/features/forecasting/persistence.py + why: CONFIRM no change is needed. ModelBundle (lines 48-57) captures sklearn_version + (line 55, 100). The prophet_like Pipeline pickles like HistGradientBoostingRegressor + — sklearn_version covers it. No new field. + +- file: app/features/forecasting/tests/test_regression_forecaster.py + why: The 10-test contract template — clone the contract tests (fit/predict roundtrip, + rejects-None-X, rejects-mismatched-rows, predict-before-fit, get/set params, + determinism, factory creation). Copy the `_synthetic_data` helper verbatim. The + prophet_like test file ADDS model-specific tests on top (see Tasks). + +- file: app/features/forecasting/tests/test_service.py + why: TestFeatureAwareContract (lines 349-412) — extend test_requires_features_flag with + a prophet_like assertion. + +- file: app/features/jobs/tests/test_service.py + why: test_execute_train_builds_regression_config (lines 204-220) and + test_execute_backtest_builds_regression_config (lines 263-284) are the templates + for the prophet_like job tests. test_execute_train_rejects_unsupported_model_type + (lines 243-249) uses "arima" — DO NOT touch it. + +- url: https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.Ridge.html + why: Ridge(alpha=1.0, solver="cholesky") — closed-form, deterministic. solver `"sag"`/ + `"saga"` are STOCHASTIC and need random_state — never use them. `"cholesky"`/`"svd"`/ + `"lsqr"` are deterministic; pin `"cholesky"` explicitly. + critical: Ridge.fit raises `ValueError: Input contains NaN` on any NaN in X — hence the + SimpleImputer. + +- url: https://scikit-learn.org/stable/modules/generated/sklearn.impute.SimpleImputer.html + why: SimpleImputer(strategy="median", missing_values=np.nan) — deterministic per-column + medians, robust to right-skewed sales lag/rolling columns. Learns statistics on + fit() only. + +- url: https://scikit-learn.org/stable/modules/compose.html#pipeline + why: Pipeline([("impute", SimpleImputer(...)), ("ridge", Ridge(...))]) — fit() learns + imputer medians on the TRAINING X, predict()/transform() reuses them. Folding the + imputer inside the Pipeline is what keeps the no-leakage invariant intact. + +- url: https://scikit-learn.org/stable/modules/linear_model.html#ridge-regression-and-classification + section: Ridge regression and classification + critical: Ridge's L2 penalty makes coefficients robust to the collinear 14-column frame; + OLS (LinearRegression) is "highly sensitive" under collinearity. ElasticNet's L1 + term zeros coefficients (unwanted feature selection) — rejected. + +- docfile: docs/optional-features/05-advanced-ml-model-zoo.md + why: § "Prophet-like Models" — the design vision (trend/seasonality/holiday/regressor, + optional changepoints, optional regressors) and the explicit endorsement of the + "lightweight additive model using sklearn regression" option this PRP implements. +``` + +### Current Codebase tree (relevant — all already exist) + +```bash +app/features/forecasting/ +├── models.py # RegressionForecaster (the template), model_factory, ModelType +├── schemas.py # RegressionModelConfig (the template), ModelConfig union +├── service.py # train_model + predict branch on requires_features (untouched) +├── persistence.py # ModelBundle — sklearn_version already covers prophet_like (untouched) +├── routes.py # /forecasting/train — NO gate needed (no flag) (untouched) +└── tests/ + ├── test_regression_forecaster.py # the contract-test template to clone + ├── test_service.py # TestFeatureAwareContract + └── test_regression_features_leakage.py # load-bearing — already covers prophet_like's frame +app/features/scenarios/service.py # model_exogenous on requires_features (untouched) +app/features/backtesting/service.py # feature-aware fold loop on requires_features (untouched) +app/features/jobs/service.py # _execute_train + _execute_backtest model_type chains +app/shared/feature_frames/ # the shared 14-column contract — reused, untouched +pyproject.toml # scikit-learn already core — NO change +``` + +### Desired Codebase tree — files to ADD + +```bash +app/features/forecasting/tests/ +└── test_prophet_like_forecaster.py # contract tests + model-specific (additive) tests +examples/models/ +└── prophet_like_additive.py # minimal train / predict / decompose example +``` + +### Files to MODIFY (all additive or behaviour-preserving) + +```bash +app/features/forecasting/schemas.py # + ProphetLikeModelConfig; + to ModelConfig union +app/features/forecasting/models.py # + Ridge/SimpleImputer/Pipeline imports; + # + _PROPHET_LIKE_COMPONENTS constant; + # + ProphetLikeForecaster; + "prophet_like" + # in ModelType; + model_factory branch +app/features/jobs/service.py # _execute_train + _execute_backtest: + prophet_like +app/features/forecasting/tests/test_service.py # extend TestFeatureAwareContract +app/features/jobs/tests/test_service.py # + prophet_like train + backtest job tests +app/features/scenarios/tests/test_routes_integration.py # + prophet_like model_exogenous test +app/features/backtesting/tests/test_feature_aware_backtest.py # + prophet_like backtest test +examples/models/model_interface.md # additive: prophet_like row +examples/models/feature_frame_contract.md # additive: prophet_like is a feature-aware model +README.md # additive: prophet_like model type +``` + +> Note the **absence**: no `pyproject.toml`, no `uv.lock`, no `app/core/config.py`, no +> `forecasting/routes.py`, no `persistence.py`, no `registry/service.py`. That absence is +> the design — a pure-sklearn model needs none of the optional-dependency machinery the +> tree models carry. + +### DECISIONS LOCKED (resolved during planning — do NOT re-litigate) + +1. **C1 (XGBoost) and C2 (Prophet-like) are separate PRPs, branches, and review units.** + This PRP touches **only** the Prophet-like model. (User-confirmed.) + +2. **Dependency strategy = no new dependency, no optional extra, no feature flag.** The + model is built from `Ridge` + `SimpleImputer` + `Pipeline`, all in `scikit-learn`, which + is already a core dependency. There is therefore nothing to gate — the model ships + always-enabled exactly like `regression`. No `ml-prophet` extra (the real `prophet`/Stan + package is explicitly **not** used). This directly answers `INITIAL-MLZOO-C`'s + "dependency strategy" requirement. (User-confirmed: "Lightweight sklearn additive + model".) + +3. **The model consumes the canonical 14-column frame UNCHANGED — no new columns.** It does + **not** add Fourier seasonal columns. Rationale: (a) the frame already carries calendar + columns (`dow_sin/cos`, `month_sin/cos`, `is_weekend`, `is_month_end`) that a linear + model regresses on to capture weekly/monthly seasonality; (b) adding new columns would + create a **new leakage surface** outside the pinned `test_leakage.py` specs — a + disproportionate risk for a v1. Continuous yearly-Fourier terms are an explicit Open + Question, not v1 scope. + +4. **`ProphetLikeModelConfig` is conservative — `alpha` + `feature_config_hash` only.** No + `seasonality_mode` (the model is strictly additive — multiplicative seasonality is an + Open Question), no Fourier-order field (per #3), no changepoint field (changepoint trend + is an Open Question). `alpha` is the one genuinely model-shaping knob (Ridge L2 + strength). Mirrors the conservative-config precedent (PRP-30 DECISIONS LOCKED #3). + +5. **No `ModelBundle` / `runtime_info` change.** The fitted `Pipeline` pickles like any + sklearn estimator; the existing `sklearn_version` capture (bundle + registry runtime + info) fully covers it. There is no new library, so there is no new version to record. + This answers `INITIAL-MLZOO-C`'s "persistence/metadata shape" requirement: the metadata + shape is **unchanged** — and that is the correct, intentional answer. + +6. **No new leakage test.** The model consumes `_build_regression_features` / + `_assemble_regression_rows` and the shared `app/shared/feature_frames` builders + byte-for-byte — already pinned by the load-bearing leakage specs. The model-specific + tests this PRP adds (additive invariant, imputer NaN tolerance) test the *model*, not the + frame. The SimpleImputer is leakage-safe **because** the `Pipeline` learns medians on + train `X` only — a property covered by a model-specific test (Task 8), not a frame + leakage test. + +7. **`Ridge(solver="cholesky")` — deterministic, pinned explicitly.** `solver="auto"` would + pick a deterministic solver for a dense matrix anyway, but pinning `"cholesky"` makes the + determinism guarantee explicit and immune to a future sklearn default change. Never use + `"sag"`/`"saga"` (stochastic). `SimpleImputer(strategy="median")` — median over `mean` + for robustness to right-skewed retail lag/rolling columns. + +8. **`model_type = "prophet_like"`, class `ProphetLikeForecaster`.** The `_like` suffix is + the honesty marker — it states "approximates Prophet, is not Prophet". + `docs/optional-features/05-advanced-ml-model-zoo.md` endorses "Prophet-like" as the + intentional term. Docstrings and docs MUST reinforce that changepoint trend, uncertainty + intervals, and automatic seasonality are out of scope (see Risks). + +### Known Gotchas of our codebase & Library Quirks + +```python +# CRITICAL: Ridge rejects NaN. `Ridge.fit(X, y)` and `.predict(X)` raise +# `ValueError: Input contains NaN` on ANY NaN cell. The future feature frame +# intentionally emits NaN for un-resolvable lag cells. The SimpleImputer as the FIRST +# Pipeline step is mandatory, not optional — without it, every scenario re-forecast and +# every backtest fold of a prophet_like model raises. + +# CRITICAL: imputer leakage. The SimpleImputer MUST learn its medians on the TRAINING X +# only. `Pipeline.fit(X_train, y)` does this automatically; `Pipeline.predict(X_future)` +# reuses the trained medians. NEVER call SimpleImputer().fit_transform(X_future) +# separately — that would leak future-window statistics. Keep the imputer INSIDE the +# Pipeline; never impute X by hand. + +# CRITICAL: decompose() operates on IMPUTED X. The Ridge coef_ multiply the imputed +# feature values, not the raw NaN-containing values. decompose() must run +# `self._estimator.named_steps["impute"].transform(X)` first, then compute +# coef_ · imputed_X grouped by component. Computing coef_ · raw_X would (a) propagate +# NaN and (b) break the additive invariant (sum != predict()). + +# GOTCHA: interface argument order. BaseForecaster is fit(y, X) / predict(horizon, X); +# the sklearn Pipeline is fit(X, y) / predict(X). ProphetLikeForecaster.fit adapts: +# internally `self._estimator.fit(X, y)`. Mirror RegressionForecaster.fit (models.py:483) +# exactly — it already does this adaptation. + +# GOTCHA: mypy --strict + sklearn. models.py imports sklearn with +# `# type: ignore[import-untyped]` (models.py:20-22). Add the Ridge/SimpleImputer/ +# Pipeline imports the SAME way. Type the estimator `Any` (mirror `estimator: Any = +# HistGradientBoostingRegressor(...)` at models.py:510). decompose()'s return type is a +# concrete typed dict/dataclass — define it explicitly so mypy --strict is satisfied. + +# GOTCHA: no importorskip. test_prophet_like_forecaster.py needs NO `pytest.importorskip` +# — scikit-learn is a core dependency, always installed. The test file always RUNS +# (unlike the lightgbm/xgboost test files which skip without their optional extra). + +# GOTCHA: Ridge with alpha=0 degenerates to OLS. ProphetLikeModelConfig.alpha has ge=0.0 +# so alpha=0 is permitted; that is fine (OLS is still deterministic with solver= +# "cholesky") but loses the collinearity robustness. The default 1.0 is the sane value; +# document that alpha=0 is OLS. + +# GOTCHA: line endings — repo has mixed CRLF/LF, no .gitattributes. Run `git diff --stat` +# before committing; re-normalise any whole-file noise diff to its original ending. + +# SIBLING-PRP integration: PRP-MLZOO-C1 also edits the ModelType Literal (models.py:736) +# and the ModelConfig union (schemas.py:192-199). Both edits are purely additive (one new +# literal entry, one new union member). If C1 merged first you will see its "xgboost" +# entry already present — just add "prophet_like" alongside. A trivial one-line rebase, +# never a semantic conflict. C1 also edits config.py/pyproject.toml/persistence.py — +# files this PRP does NOT touch, so no overlap there. +``` + +--- + +## Implementation Blueprint + +### Data models and structure + +```python +# app/features/forecasting/schemas.py — mirrors RegressionModelConfig (schemas.py:147-189) + +class ProphetLikeModelConfig(ModelConfigBase): + """Configuration for the Prophet-like additive forecaster (MLZOO-C2). + + A deterministic, regularized ADDITIVE linear model — a ``Ridge`` regressor + over the canonical 14-column feature frame — that decomposes demand into + trend / seasonality / holiday-regressor components. It approximates + Prophet's additive shape WITHOUT the real ``prophet``/Stan dependency: it + does not model changepoint trend, posterior uncertainty, or automatic + seasonality discovery. Pure scikit-learn — no optional dependency, no + feature flag, always available (like ``RegressionModelConfig``). + + Attributes: + alpha: Ridge L2 regularization strength. 0.0 degenerates to ordinary + least squares; the default 1.0 keeps coefficients robust to the + collinear engineered-feature frame. + feature_config_hash: Optional hash of the feature contract used. + """ + + model_type: Literal["prophet_like"] = "prophet_like" + alpha: float = Field( + default=1.0, ge=0.0, le=10000.0, description="Ridge L2 regularization strength" + ) + feature_config_hash: str | None = Field( + default=None, description="Hash of the feature contract used for training" + ) + + +# app/features/forecasting/models.py — additions + +# Module scope, near the existing sklearn import (models.py:20-22): +from sklearn.impute import SimpleImputer # type: ignore[import-untyped] +from sklearn.linear_model import Ridge # type: ignore[import-untyped] +from sklearn.pipeline import Pipeline # type: ignore[import-untyped] + +# Module-scope constant — the decomposition column grouping (canonical 14-column order): +_PROPHET_LIKE_COMPONENTS: dict[str, tuple[str, ...]] = { + "trend": ("lag_1", "lag_7", "lag_14", "lag_28", "days_since_launch"), + "seasonality": ("dow_sin", "dow_cos", "month_sin", "month_cos", "is_weekend", "is_month_end"), + "holiday_regressor": ("price_factor", "promo_active", "is_holiday"), +} + +# A typed return type for decompose(): +@dataclass +class ForecastDecomposition: + """Additive component breakdown of a Prophet-like forecast. + + Invariant: ``intercept + trend + seasonality + holiday_regressor`` equals + ``predict(...)`` for the same X (within float tolerance), element-wise. + Each array has shape ``[n_rows]`` — one value per forecast row. + """ + intercept: float + trend: np.ndarray[Any, np.dtype[np.floating[Any]]] + seasonality: np.ndarray[Any, np.dtype[np.floating[Any]]] + holiday_regressor: np.ndarray[Any, np.dtype[np.floating[Any]]] + + +class ProphetLikeForecaster(BaseForecaster): + """Feature-aware ADDITIVE forecaster — Ridge over the canonical frame. + + Prophet-LIKE, not Prophet: it approximates Prophet's additive trend + + seasonality + holiday/regressor decomposition with a regularized linear + model over the already-engineered 14-column feature frame. It REQUIRES a + non-None exogenous X for fit and predict. A SimpleImputer (median) handles + the NaN lag cells the future frame emits; a Ridge(solver="cholesky") gives + a closed-form, deterministic fit. ``decompose()`` returns the per-component + additive contributions. + + NOT modelled (see PRP Risks): changepoint trend, posterior uncertainty + intervals, automatic seasonality discovery, multiplicative seasonality. + """ + + requires_features: ClassVar[bool] = True + + def __init__(self, *, alpha: float = 1.0, random_state: int = 42) -> None: + super().__init__(random_state) # random_state kept for interface parity; + self.alpha = alpha # Ridge(solver="cholesky") needs no seed + self._estimator: Any = None +``` + +### list of tasks (dependency-ordered) + +```yaml +# ════════ STEP 1 — Schema ════════ + +Task 1 — MODIFY app/features/forecasting/schemas.py — ADD ProphetLikeModelConfig: + - PLACE the new class AFTER RegressionModelConfig (after schemas.py:189), before the + ModelConfig union. + - MIRROR RegressionModelConfig's ModelConfigBase idiom (see Data models above). + - ADD `ProphetLikeModelConfig` to the ModelConfig union (schemas.py:192-199). + - VALIDATE: uv run mypy app/features/forecasting/schemas.py + +# ════════ STEP 2 — The forecaster + factory ════════ + +Task 2 — MODIFY app/features/forecasting/models.py — imports + _PROPHET_LIKE_COMPONENTS: + - ADD the three sklearn imports (Ridge, SimpleImputer, Pipeline) near models.py:20-22, + each with `# type: ignore[import-untyped]` (mirror the existing + HistGradientBoostingRegressor import). + - ADD the module-scope `_PROPHET_LIKE_COMPONENTS` dict and the `ForecastDecomposition` + dataclass (see Data models above). Place the dataclass near FitResult (models.py:28). + - VALIDATE: uv run ruff check app/features/forecasting/models.py + +Task 3 — MODIFY app/features/forecasting/models.py — ADD ProphetLikeForecaster: + - PLACE the new class AFTER LightGBMForecaster (after models.py:732), BEFORE the + ModelType alias. + - MIRROR RegressionForecaster for the guard shape + error strings: fit guards (X None -> + ValueError "ProphetLikeForecaster requires exogenous features X for fit()"; empty y + -> "Cannot fit on empty array"; row mismatch -> f"X has {X.shape[0]} rows but y has + {len(y)} — feature/target rows must match"); predict guards (not fitted -> + RuntimeError "Model must be fitted before predict"; X None -> ValueError + "ProphetLikeForecaster requires exogenous features X for predict()"; shape mismatch + -> f"X has {X.shape[0]} rows but horizon is {horizon} — they must match"). + - INSIDE fit(): build the Pipeline and fit it: + estimator: Any = Pipeline([ + ("impute", SimpleImputer(strategy="median")), + ("ridge", Ridge(alpha=self.alpha, solver="cholesky")), + ]) + estimator.fit(X, y) # Pipeline is fit(X, y); imputer learns medians on X here + self._estimator = estimator + - set requires_features: ClassVar[bool] = True; get_params returns {alpha, random_state}; + set_params mirrors RegressionForecaster.set_params. + - ADD the decompose() method (see Per-task pseudocode) — model-specific, NOT on + BaseForecaster. + - VALIDATE: uv run mypy app/features/forecasting/models.py && uv run pyright app/features/forecasting/ + +Task 4 — MODIFY app/features/forecasting/models.py — ModelType literal + model_factory: + - ADD "prophet_like" to the ModelType Literal (models.py:736). + - ADD an `elif model_type == "prophet_like":` branch to model_factory, mirroring the + `regression` branch (models.py:793-803) — NO flag gate: + elif model_type == "prophet_like": + from app.features.forecasting.schemas import ProphetLikeModelConfig + if isinstance(config, ProphetLikeModelConfig): + return ProphetLikeForecaster(alpha=config.alpha, random_state=random_state) + raise ValueError("Invalid config type for prophet_like") + - VALIDATE: uv run mypy app/ && uv run pyright app/ + +# ════════ STEP 3 — Jobs integration ════════ + +Task 5 — MODIFY app/features/jobs/service.py — _execute_train + _execute_backtest: + - ADD `ProphetLikeModelConfig` to the forecasting-schemas import (jobs/service.py:426-433). + - ADD a prophet_like branch to _execute_train (jobs/service.py:454-478), before the final + `else`, mirroring the `regression` branch: + elif model_type == "prophet_like": + config = ProphetLikeModelConfig(alpha=params.get("alpha", 1.0)) + - ADD a prophet_like branch to _execute_backtest (jobs/service.py:641-658): + elif model_type == "prophet_like": + # Feature-aware — the backtest builds per-fold leakage-safe X. + model_config = ProphetLikeModelConfig() + - VALIDATE: uv run mypy app/features/jobs/ && uv run pyright app/features/jobs/ + +# ════════ STEP 4 — Tests ════════ + +Task 6 — CREATE app/features/forecasting/tests/test_prophet_like_forecaster.py: + - NO importorskip — pure sklearn, always runs. + - COPY the `_synthetic_data` helper from test_regression_forecaster.py verbatim, but use + n_features=14 so the component grouping lines up with the canonical contract (the + decompose tests need exactly the 14 canonical columns). + - CLONE the contract tests: fit_predict_roundtrip, fit_rejects_none_features, + fit_rejects_mismatched_rows, predict_rejects_none_features, + predict_rejects_wrong_shape_features, predict_before_fit_raises, + determinism_same_data (np.testing.assert_array_equal), get_and_set_params, + requires_features_is_true, model_factory_creates_prophet_like_forecaster (NO flag). + - VALIDATE: uv run pytest -v app/features/forecasting/tests/test_prophet_like_forecaster.py + +Task 7 — MODIFY app/features/forecasting/tests/test_prophet_like_forecaster.py — model-specific tests: + - test_handles_nan_features: a future frame with NaN lag cells predicts finite values + (the SimpleImputer fills them) — a plain Ridge would raise. Assert np.all(isfinite). + - test_additive_invariant: for a fitted model, `d = model.decompose(X)`; + np.testing.assert_allclose( + d.intercept + d.trend + d.seasonality + d.holiday_regressor, + model.predict(len(X), X), rtol=1e-9) + - test_decompose_components_have_horizon_length: each of d.trend/seasonality/ + holiday_regressor has shape (len(X),). + - test_decompose_uses_trained_imputer_statistics: fit on X_train (no NaN), then call + decompose on an X_future whose lag cell is NaN; assert the imputed value used is + the TRAINING-column median (not the future-column median) — i.e. decompose's + imputed X equals `model._estimator.named_steps["impute"].transform(X_future)`. + - test_decompose_before_fit_raises: decompose() before fit() raises RuntimeError. + - VALIDATE: uv run pytest -v app/features/forecasting/tests/test_prophet_like_forecaster.py + +Task 8 — MODIFY app/features/forecasting/tests/test_service.py: + - In TestFeatureAwareContract.test_requires_features_flag, ADD: + from app.features.forecasting.models import ProphetLikeForecaster + from app.features.forecasting.schemas import ProphetLikeModelConfig + assert model_factory(ProphetLikeModelConfig()).requires_features is True + - (No flag test — prophet_like has no feature flag.) + - VALIDATE: uv run pytest -v -m "not integration" app/features/forecasting/tests/test_service.py + +Task 9 — MODIFY app/features/jobs/tests/test_service.py: + - ADD test_execute_train_builds_prophet_like_config mirroring + test_execute_train_builds_regression_config (lines 204-220). + - ADD test_execute_backtest_builds_prophet_like_config mirroring + test_execute_backtest_builds_regression_config (lines 263-284). + - VALIDATE: uv run pytest -v app/features/jobs/tests/test_service.py + +Task 10 — MODIFY app/features/scenarios/tests/test_routes_integration.py: + - ADD an integration test that trains a `prophet_like` model then POSTs + /scenarios/simulate with its run_id and asserts `method == "model_exogenous"`. + Mirror the existing regression model_exogenous test. NO importorskip, NO flag. + - VALIDATE: uv run pytest -v -m integration app/features/scenarios/tests/test_routes_integration.py + +Task 11 — MODIFY app/features/backtesting/tests/test_feature_aware_backtest.py: + - ADD a test that runs the feature-aware backtest with a ProphetLikeModelConfig and + asserts per-fold metrics + feature_aware=True — mirroring + test_feature_aware_backtest_produces_per_fold_metrics. Satisfies INITIAL-MLZOO-B's + "backtesting integration test comparing baseline and advanced model path". + - VALIDATE: uv run pytest -v app/features/backtesting/tests/test_feature_aware_backtest.py + +# ════════ STEP 5 — Docs & example ════════ + +Task 12 — CREATE examples/models/prophet_like_additive.py: + - A runnable script: build a synthetic [n, 14] frame matching + canonical_feature_columns(), fit ProphetLikeForecaster(alpha=1.0), predict a + horizon, AND call decompose() and print the trend/seasonality/holiday_regressor + split for the first few rows. Mirror the structure/header of + examples/models/advanced_lightgbm.py. + - VALIDATE: uv run python examples/models/prophet_like_additive.py + +Task 13 — MODIFY examples/models/model_interface.md + feature_frame_contract.md: + - model_interface.md: ADDITIVE — add a ProphetLikeModelConfig entry under "## Model + Configurations" and a "### Prophet-like Forecaster" entry under "## Model + Formulas" (give the additive formula y = intercept + trend + seasonality + + holiday_regressor and the component column grouping). Note requires_features=True, + no optional extra, and the decompose() affordance. + - feature_frame_contract.md: ADDITIVE — record prophet_like as an IMPLEMENTED + feature-aware model. Do NOT rewrite the file. + - VALIDATE: uv run ruff check . && uv run ruff format --check . + +Task 14 — MODIFY README.md: + - ADDITIVE: add `prophet_like` to the Supported Model Types list (README.md:344 area) — + "Prophet-like additive linear model (trend / seasonality / regressor + decomposition); pure scikit-learn, always available, no extra to install". Mirror + the existing tone. + - VALIDATE: uv run ruff format --check . +``` + +### Per-task pseudocode (critical details only) + +```python +# ── Task 3 — ProphetLikeForecaster.fit / predict / decompose ── + +def fit(self, y, X=None): + if X is None: + raise ValueError("ProphetLikeForecaster requires exogenous features X for fit()") + if len(y) == 0: + raise ValueError("Cannot fit on empty array") + if X.shape[0] != len(y): + raise ValueError( + f"X has {X.shape[0]} rows but y has {len(y)} — feature/target rows must match" + ) + estimator: Any = Pipeline([ + ("impute", SimpleImputer(strategy="median")), # learns medians on THIS X only + ("ridge", Ridge(alpha=self.alpha, solver="cholesky")), # deterministic, closed-form + ]) + estimator.fit(X, y) # sklearn order is fit(X, y); imputer NaN-safe + self._estimator = estimator + self._last_values = np.asarray(y[-1:], dtype=np.float64) + self._is_fitted = True + return self + +def predict(self, horizon, X=None): + # guards identical in shape to RegressionForecaster.predict (models.py:522-546) + if not self._is_fitted or self._estimator is None: + raise RuntimeError("Model must be fitted before predict") + if X is None: + raise ValueError("ProphetLikeForecaster requires exogenous features X for predict()") + if X.shape[0] != horizon: + raise ValueError(f"X has {X.shape[0]} rows but horizon is {horizon} — they must match") + return np.asarray(self._estimator.predict(X), dtype=np.float64) # Pipeline imputes then Ridge + +def decompose(self, X): + """Additive trend / seasonality / holiday-regressor breakdown of a forecast. + + Operates on the IMPUTED X (the trained imputer's transform) so the + contributions sum exactly to predict(). Returns a ForecastDecomposition. + """ + if not self._is_fitted or self._estimator is None: + raise RuntimeError("Model must be fitted before decompose") + imputer = self._estimator.named_steps["impute"] + ridge = self._estimator.named_steps["ridge"] + x_imputed = imputer.transform(X) # trained medians fill any NaN + columns = canonical_feature_columns() # the 14-name ordered contract + contributions: dict[str, np.ndarray] = {} + for component, comp_cols in _PROPHET_LIKE_COMPONENTS.items(): + idx = [columns.index(c) for c in comp_cols] # column positions for this component + # additive contribution = Σ coef_i · x_i over this component's columns + contributions[component] = x_imputed[:, idx] @ ridge.coef_[idx] + return ForecastDecomposition( + intercept=float(ridge.intercept_), + trend=contributions["trend"], + seasonality=contributions["seasonality"], + holiday_regressor=contributions["holiday_regressor"], + ) + # Invariant: intercept + trend + seasonality + holiday_regressor == predict(len(X), X) + # because the three component column-sets partition all 14 columns exactly. + +# ── Task 4 — model_factory: no flag gate (unlike lightgbm/xgboost) ── +elif model_type == "prophet_like": + from app.features.forecasting.schemas import ProphetLikeModelConfig + if isinstance(config, ProphetLikeModelConfig): + return ProphetLikeForecaster(alpha=config.alpha, random_state=random_state) + raise ValueError("Invalid config type for prophet_like") +``` + +### Integration Points + +```yaml +DEPENDENCY: none. scikit-learn is already core. NO pyproject.toml / uv.lock change. +CONFIG: none. No feature flag. NO app/core/config.py change. +ROUTES: none. No flag -> no route gate. /forecasting/train accepts the new + model_type with no code change (additive ModelConfig union member). +TRAIN/PREDICT/SCENARIOS/BACKTESTING: all UNCHANGED — every path branches on + requires_features; a prophet_like model routes through automatically. +JOBS: jobs/service.py — + prophet_like branch in _execute_train AND + _execute_backtest (the one place a model_type string compare lives). +PERSISTENCE: ModelBundle UNCHANGED — sklearn_version covers the pickled Pipeline. +REGISTRY: _capture_runtime_info UNCHANGED — sklearn_version already recorded. +NO MIGRATION. NO API CONTRACT CHANGE (a new request-body model_type value is additive + and pre-1.0-permitted). +``` + +### Model-specific validation rules (required by INITIAL-MLZOO-C) + +Beyond the shared contract tests, the Prophet-like model has four invariants that the tree +models do not, each pinned by a test in `test_prophet_like_forecaster.py`: + +1. **Additive invariant** — `decompose()`'s four parts sum (rtol `1e-9`) to `predict()`. + This is what makes the model "Prophet-like": the forecast genuinely *is* the sum of its + components. (Task 7 `test_additive_invariant`.) +2. **NaN tolerance via the imputer** — a future frame with `NaN` lag cells must predict + finite values; a model-specific guarantee the bare `Ridge` does not have. (Task 7 + `test_handles_nan_features`.) +3. **Imputer leakage-safety** — `decompose()`/`predict()` impute future-frame `NaN` with + *training-window* medians, never future-window medians. (Task 7 + `test_decompose_uses_trained_imputer_statistics`.) This is the model-specific + leakage rule; the frame-level leakage is already covered by the pinned shared specs. +4. **Determinism** — `Ridge(solver="cholesky")` + `SimpleImputer(median)` are deterministic; + two fits give identical forecasts. (Task 6 `test_determinism_same_data`.) + +--- + +## Validation Loop + +### Level 1: Syntax & Style + +```bash +uv run ruff check . --fix && uv run ruff format --check . +``` + +### Level 2: Type Checks + +```bash +uv run mypy app/ # --strict +uv run pyright app/ # --strict +# The sklearn imports carry `# type: ignore[import-untyped]` (mirror models.py:20-22). +# ForecastDecomposition is a concretely-typed dataclass — no `Any` leakage in decompose()'s +# public return. +``` + +### Level 3: Unit Tests + +```bash +uv run pytest -v app/features/forecasting/tests/test_prophet_like_forecaster.py +uv run pytest -v -m "not integration" app/features/forecasting/tests/test_service.py +uv run pytest -v app/features/jobs/tests/test_service.py +uv run pytest -v app/features/backtesting/tests/test_feature_aware_backtest.py + +# Regression — must stay green, no behaviour change +uv run pytest -v -m "not integration" +# Expected: all green. test_prophet_like_forecaster.py RUNS unconditionally (no +# importorskip — sklearn is core). Every existing model's tests pass UNEDITED. +``` + +### Level 4: Integration Tests + +```bash +docker compose up -d && uv run alembic upgrade head +uv run pytest -v -m integration app/features/forecasting/ app/features/scenarios/ \ + app/features/jobs/ +# The scenarios prophet_like model_exogenous test (Task 10) must report +# method="model_exogenous". +``` + +### Level 5: Manual Validation (dogfood — REQUIRED) + +```bash +# 1. Determinism + the additive invariant +uv run python -c " +import numpy as np +from app.features.forecasting.models import ProphetLikeForecaster +rng = np.random.default_rng(0) +X = rng.normal(size=(120, 14)); y = (3.0*X[:,0] - 2.0*X[:,4] + rng.normal(size=120)).astype(float) +m1 = ProphetLikeForecaster(alpha=1.0).fit(y, X) +m2 = ProphetLikeForecaster(alpha=1.0).fit(y, X) +np.testing.assert_array_equal(m1.predict(12, X[:12]), m2.predict(12, X[:12])) +d = m1.decompose(X[:12]) +np.testing.assert_allclose( + d.intercept + d.trend + d.seasonality + d.holiday_regressor, m1.predict(12, X[:12]), rtol=1e-9) +print('prophet_like deterministic + additive invariant OK')" + +# 2. NaN tolerance +uv run python -c " +import numpy as np +from app.features.forecasting.models import ProphetLikeForecaster +rng = np.random.default_rng(1) +X = rng.normal(size=(80, 14)); y = X[:,0].astype(float) +m = ProphetLikeForecaster().fit(y, X) +fut = X[:6].copy(); fut[2, 0] = np.nan # un-resolvable lag cell +preds = m.predict(6, fut) +assert np.all(np.isfinite(preds)); print('prophet_like NaN-tolerant OK', preds[:3])" + +# 3. End-to-end: POST /forecasting/train with config {"model_type":"prophet_like"} -> 200 +# (no flag needed); POST /scenarios/simulate -> method == "model_exogenous"; +# submit a prophet_like backtest job -> completes with per-fold metrics. +``` + +--- + +## Final Validation Checklist + +- [ ] `uv run ruff check .` and `uv run ruff format --check .` clean. +- [ ] `uv run mypy app/` and `uv run pyright app/` clean (both --strict). +- [ ] `uv run pytest -v -m "not integration"` fully green; `test_prophet_like_forecaster.py` + RUNS unconditionally (no importorskip) and passes — including the additive-invariant, + NaN-tolerance, and imputer-leakage-safety tests. +- [ ] `uv run pytest -v -m integration app/features/{forecasting,scenarios,jobs}/` green, + including the scenarios `prophet_like` `model_exogenous` test. +- [ ] `model_factory(ProphetLikeModelConfig())` returns a `ProphetLikeForecaster` with + **no flag and no "not enabled" path**. +- [ ] A `prophet_like` backtest produces per-fold metrics with **no edit to + `backtesting/service.py`**. +- [ ] Every baseline / `regression` / `lightgbm` test passes with **no edit**. +- [ ] **No** `pyproject.toml`, `uv.lock`, `app/core/config.py`, `forecasting/routes.py`, + `persistence.py`, or `registry/service.py` change — confirm via `git diff --name-only`. +- [ ] No Alembic migration; no new dependency; no route-path/response-schema/WebSocket + change. +- [ ] `git diff --stat` shows only intended files — no whole-file CRLF/LF noise diffs. +- [ ] An OPEN GitHub issue exists (`gh issue view --json state` → `OPEN`); commit + `feat(forecast): add Prophet-like additive forecasting model (#)`; branch + `feat/forecasting-prophet-like-model` off `dev`. +- [ ] The PR description states C2 is one of two MLZOO-C review units, links the sibling + `PRP-MLZOO-C1`, and explicitly states the model is Prophet-LIKE (additive linear + approximation), not the real `prophet` package. + +--- + +## Anti-Patterns to Avoid + +- ❌ Don't implement the XGBoost model — that is `PRP-MLZOO-C1`, a separate branch. +- ❌ Don't combine C1 and C2 into one branch or one PR (DECISIONS LOCKED #1). +- ❌ Don't add the real `prophet` package, `cmdstanpy`, Stan, or an `ml-prophet` extra — + this model is deliberately pure scikit-learn (DECISIONS LOCKED #2). +- ❌ Don't add a `forecast_enable_prophet_like` flag or a route gate — a pure-sklearn model + ships always-on, like `regression`. +- ❌ Don't add Fourier seasonal columns or any new feature-frame columns — the model + consumes the canonical 14-column frame unchanged (DECISIONS LOCKED #3); new columns are a + new leakage surface. +- ❌ Don't impute `X` by hand or call `SimpleImputer().fit_transform(X_future)` — keep the + imputer INSIDE the `Pipeline` so it learns medians on train `X` only (leakage). +- ❌ Don't compute `decompose()` on the raw NaN-containing `X` — it must use the trained + imputer's `transform(X)`, or the additive invariant breaks and NaN propagates. +- ❌ Don't use `LinearRegression` (unstable on the collinear frame) or `ElasticNet` (L1 + zeros curated columns; iterative) — use `Ridge(solver="cholesky")`. +- ❌ Don't use `Ridge(solver="sag"/"saga")` — they are stochastic and break determinism. +- ❌ Don't add `seasonality_mode`, Fourier-order, or changepoint fields to + `ProphetLikeModelConfig` — DECISIONS LOCKED #4 keeps it to `alpha`. +- ❌ Don't edit `train_model`/`predict`, `scenarios/service.py`, or `backtesting/service.py` + — they branch on `requires_features`. +- ❌ Don't write a new frame leakage test — the model reuses the pinned shared builders. +- ❌ Don't claim this is "Prophet" anywhere — it is "Prophet-like" / "additive linear". + +## Risks & Open Questions + +### Risks (document honestly in docstrings + docs) + +- **Not real Prophet.** A `Ridge`-over-features model genuinely cannot do what Prophet does: + - **No changepoint trend.** Prophet fits a piecewise-linear growth curve with automatic + rate changes; this model's "trend" is only what the lag/`days_since_launch` columns + encode — a long-horizon forecast trends roughly linearly. + - **No uncertainty intervals.** `Ridge` returns a point forecast only; Prophet returns + `yhat_lower`/`yhat_upper` via posterior simulation. Prediction intervals are an Open + Question (residual quantiles / conformal prediction / `BayesianRidge`). + - **No automatic seasonality discovery.** Seasonality is fixed at feature-engineering + time — only the periodicity already in the 14 columns is visible. + - **Strictly additive.** No multiplicative seasonality (`seasonality_mode`). +- **Extrapolation fragility.** Linear models extrapolate unboundedly; at long horizons the + lag columns are increasingly imputed (median fill), degrading accuracy. The tree models + and Prophet degrade more gracefully. +- **Component-grouping is a modelling choice.** Putting the lag columns under `trend` (vs a + separate `autoregressive` component) is a deliberate, documented simplification — the + additive invariant holds regardless, but the *labels* are an interpretation. + +### Open Questions — to resolve at PRP review + +- [ ] **Prediction/uncertainty intervals.** Should v1 expose `yhat_lower`/`yhat_upper` + (e.g. from training-residual quantiles)? Currently out of scope — point forecast only. +- [ ] **Fourier seasonal columns.** A continuous yearly cycle is not in the canonical + 14-column frame. Adding Fourier yearly terms would improve long-period seasonality but + requires new frame columns (new leakage surface). Deferred (DECISIONS LOCKED #3) — + confirm deferral or scope a follow-up. +- [ ] **Changepoint trend.** A piecewise-linear trend basis would close the biggest gap + vs real Prophet but is a substantial modelling addition. Deferred — flag if wanted. +- [ ] **Surfacing `decompose()`.** v1 keeps `decompose()` as a model method used by tests + and the example only. Exposing it via an API endpoint / agent tool / the + explainability slice is a natural MLZOO-D item — confirm it stays out of C2 scope. +- [ ] **`alpha` tuning.** v1 ships a fixed default `alpha=1.0` (caller-overridable). Per- + series `alpha` selection (e.g. `RidgeCV`) is deferred to a tuning-focused future PRP. + +## Confidence Score + +**8 / 10** for one-pass implementation success. + +Rationale: the consuming infrastructure is fully paid for — train, predict, scenarios, and +backtesting all branch on `requires_features`, and `RegressionForecaster` is a proven +pure-sklearn template, so the wiring is contained (one class, one config, one factory +branch, two jobs branches — and *fewer* touch-points than C1 because there is no +dependency/flag/metadata machinery). The −2 risk is concentrated in the genuinely *new* +design surface: (a) the `decompose()` additive math — the column-index mapping and +imputed-X discipline must be exact for the additive invariant to hold, but the invariant is +a precise, fast unit test that catches any error immediately; and (b) the imputer-leakage +discipline — keeping `SimpleImputer` inside the `Pipeline` is the one rule that, if broken, +silently leaks, and it too is pinned by a model-specific test. Both risks are caught at +Level 3. The "every existing test passes unedited" gate makes any regression impossible to +miss. diff --git a/README.md b/README.md index 2b9af7f8..12faaa6a 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,7 @@ curl -X POST http://localhost:8123/forecasting/predict \ - `regression` - Gradient-boosted exogenous-feature regressor (feature-aware) - `lightgbm` - LightGBM feature-aware regressor — opt-in: install the `ml-lightgbm` extra and set `forecast_enable_lightgbm=True` - `xgboost` - XGBoost feature-aware regressor — opt-in: install the `ml-xgboost` extra and set `forecast_enable_xgboost=True` +- `prophet_like` - Prophet-like additive linear model (trend / seasonality / regressor decomposition); pure scikit-learn, always available, no extra to install See [examples/models/](examples/models/) for baseline model examples. diff --git a/app/features/backtesting/tests/test_feature_aware_backtest.py b/app/features/backtesting/tests/test_feature_aware_backtest.py index fda1eee1..0ad216ca 100644 --- a/app/features/backtesting/tests/test_feature_aware_backtest.py +++ b/app/features/backtesting/tests/test_feature_aware_backtest.py @@ -27,6 +27,7 @@ from app.features.backtesting.splitter import TimeSeriesSplitter from app.features.forecasting.schemas import ( NaiveModelConfig, + ProphetLikeModelConfig, RegressionModelConfig, XGBoostModelConfig, ) @@ -177,6 +178,38 @@ def test_feature_aware_backtest_runs_with_xgboost_model( assert "mae" in fold.metrics +def test_prophet_like_feature_aware_backtest_produces_per_fold_metrics( + sample_dates_120: list[date], + sample_values_120: np.ndarray, + sample_split_config_expanding: SplitConfig, +) -> None: + """A prophet_like backtest runs end-to-end and yields per-fold metrics. + + The Prophet-like additive model is feature-aware (pure scikit-learn, no + flag), so it routes through the SAME per-fold feature-aware path as the + regression model — satisfying INITIAL-MLZOO-B's "backtesting integration + test comparing baseline and advanced model path". + """ + service = BacktestingService() + series = _series(sample_dates_120, sample_values_120, with_exogenous=True) + splitter = TimeSeriesSplitter(sample_split_config_expanding) + + result = service._run_model_backtest( + series_data=series, + splitter=splitter, + model_config=ProphetLikeModelConfig(), + store_fold_details=True, + ) + + assert result.model_type == "prophet_like" + assert result.feature_aware is True + assert len(result.fold_results) > 0 + assert "mae" in result.aggregated_metrics + for fold in result.fold_results: + assert "mae" in fold.metrics + assert np.isfinite(fold.metrics["mae"]) + + def test_feature_aware_result_records_observed_policy( sample_dates_120: list[date], sample_values_120: np.ndarray, diff --git a/app/features/forecasting/models.py b/app/features/forecasting/models.py index 9fbf028e..1c43ea32 100644 --- a/app/features/forecasting/models.py +++ b/app/features/forecasting/models.py @@ -20,11 +20,56 @@ from sklearn.ensemble import ( # type: ignore[import-untyped] HistGradientBoostingRegressor, ) +from sklearn.impute import SimpleImputer # type: ignore[import-untyped] +from sklearn.linear_model import Ridge # type: ignore[import-untyped] +from sklearn.pipeline import Pipeline # type: ignore[import-untyped] if TYPE_CHECKING: from app.features.forecasting.schemas import ModelConfig +# Canonical 14-column feature frame partitioned into the three Prophet-style +# additive components. Together the three column tuples cover all 14 canonical +# columns exactly — which is what makes the additive invariant hold (the +# component contributions partition the full coef_ · x sum). See +# ``canonical_feature_columns()`` in ``app/shared/feature_frames``. +_PROPHET_LIKE_COMPONENTS: dict[str, tuple[str, ...]] = { + "trend": ("lag_1", "lag_7", "lag_14", "lag_28", "days_since_launch"), + "seasonality": ( + "dow_sin", + "dow_cos", + "month_sin", + "month_cos", + "is_weekend", + "is_month_end", + ), + "holiday_regressor": ("price_factor", "promo_active", "is_holiday"), +} + + +@dataclass +class ForecastDecomposition: + """Additive component breakdown of a Prophet-like forecast. + + Invariant: ``intercept + trend + seasonality + holiday_regressor`` equals + ``predict(...)`` for the same ``X`` (within float tolerance), element-wise. + Each component array has shape ``[n_rows]`` — one value per forecast row. + + Attributes: + intercept: The fitted Ridge intercept (a scalar, broadcast over rows). + trend: Per-row contribution of the trend columns (autoregressive lags + + ``days_since_launch``). + seasonality: Per-row contribution of the calendar/seasonal columns. + holiday_regressor: Per-row contribution of the holiday + extra-regressor + columns (price, promotion, holiday flag). + """ + + intercept: float + trend: np.ndarray[Any, np.dtype[np.floating[Any]]] + seasonality: np.ndarray[Any, np.dtype[np.floating[Any]]] + holiday_regressor: np.ndarray[Any, np.dtype[np.floating[Any]]] + + @dataclass class FitResult: """Result of model fitting. @@ -888,9 +933,191 @@ def set_params(self, **params: Any) -> XGBoostForecaster: # noqa: ANN401 return self +class ProphetLikeForecaster(BaseForecaster): + """Feature-aware ADDITIVE forecaster — Ridge over the canonical frame. + + Prophet-LIKE, not Prophet: it approximates Prophet's additive trend + + seasonality + holiday/regressor decomposition with a regularized linear + model over the already-engineered 14-column feature frame. It REQUIRES a + non-``None`` exogenous ``X`` for both ``fit`` and ``predict``. + + The fitted estimator is a scikit-learn ``Pipeline`` of two deterministic + steps: a ``SimpleImputer(strategy="median")`` that fills the ``NaN`` lag + cells the future feature frame emits (a bare ``Ridge`` raises + ``ValueError: Input contains NaN``), followed by a + ``Ridge(solver="cholesky")`` whose closed-form L2-regularized fit is + robust to the collinear engineered columns. Folding the imputer INSIDE the + pipeline keeps the no-leakage invariant: it learns its medians on the + training ``X`` only and re-applies them at predict time. + + ``decompose()`` returns the per-component additive contributions of a + forecast — the literal ``y_hat = intercept + trend + seasonality + + holiday_regressor`` split, computed on the IMPUTED ``X``. + + NOT modelled (deliberately — see PRP-MLZOO-C2 Risks): changepoint trend, + posterior uncertainty intervals, automatic seasonality discovery, + multiplicative seasonality. This is an additive linear approximation, not + the real ``prophet`` package. + + Attributes: + alpha: Ridge L2 regularization strength (0.0 degenerates to OLS). + """ + + requires_features: ClassVar[bool] = True + """A feature-aware model — ``fit``/``predict`` REQUIRE a non-None ``X``.""" + + def __init__(self, *, alpha: float = 1.0, random_state: int = 42) -> None: + """Initialize the Prophet-like additive forecaster. + + Args: + alpha: Ridge L2 regularization strength. The default 1.0 keeps + coefficients robust to the collinear engineered-feature frame. + random_state: Kept for interface parity with the other forecasters; + ``Ridge(solver="cholesky")`` is closed-form and needs no seed. + """ + super().__init__(random_state) + self.alpha = alpha + self._estimator: Any = None + + def fit( + self, + y: np.ndarray[Any, np.dtype[np.floating[Any]]], + X: np.ndarray[Any, np.dtype[np.floating[Any]]] | None = None, + ) -> ProphetLikeForecaster: + """Fit the additive Ridge pipeline on historical features. + + Args: + y: Target values (1D array of shape ``[n_samples]``). + X: Exogenous features (2D array of shape ``[n_samples, n_features]``). + REQUIRED — unlike the baseline forecasters. + + Returns: + self (for method chaining). + + Raises: + ValueError: If ``X`` is ``None``, ``y`` is empty, or the row counts + of ``X`` and ``y`` do not match. + """ + if X is None: + raise ValueError("ProphetLikeForecaster requires exogenous features X for fit()") + if len(y) == 0: + raise ValueError("Cannot fit on empty array") + if X.shape[0] != len(y): + raise ValueError( + f"X has {X.shape[0]} rows but y has {len(y)} — feature/target rows must match" + ) + # The imputer learns its per-column medians on THIS training X only; + # the Ridge solver is deterministic and closed-form. + estimator: Any = Pipeline( + [ + ("impute", SimpleImputer(strategy="median")), + ("ridge", Ridge(alpha=self.alpha, solver="cholesky")), + ] + ) + estimator.fit(X, y) + self._estimator = estimator + self._last_values = np.asarray(y[-1:], dtype=np.float64) + self._is_fitted = True + return self + + def predict( + self, + horizon: int, + X: np.ndarray[Any, np.dtype[np.floating[Any]]] | None = None, + ) -> np.ndarray[Any, np.dtype[np.floating[Any]]]: + """Generate forecasts from a future feature frame. + + Args: + horizon: Number of steps to forecast. + X: Exogenous features for the forecast period, shape + ``[horizon, n_features]``. REQUIRED. + + Returns: + Array of forecasts with shape ``[horizon]``. + + Raises: + RuntimeError: If the model has not been fitted. + ValueError: If ``X`` is ``None`` or its row count is not ``horizon``. + """ + if not self._is_fitted or self._estimator is None: + raise RuntimeError("Model must be fitted before predict") + if X is None: + raise ValueError("ProphetLikeForecaster requires exogenous features X for predict()") + if X.shape[0] != horizon: + raise ValueError(f"X has {X.shape[0]} rows but horizon is {horizon} — they must match") + # The Pipeline imputes the NaN lag cells, then the Ridge predicts. + predictions = self._estimator.predict(X) + result: np.ndarray[Any, np.dtype[np.floating[Any]]] = np.asarray( + predictions, dtype=np.float64 + ) + return result + + def decompose(self, X: np.ndarray[Any, np.dtype[np.floating[Any]]]) -> ForecastDecomposition: + """Split a forecast into its additive trend / seasonality / regressor parts. + + Operates on the IMPUTED ``X`` — the trained imputer's ``transform`` — + so the per-component contributions sum EXACTLY to ``predict(...)``: any + ``NaN`` cell is filled with the TRAINING-window median, never a + predict-time median (no leakage). Each component contribution is the + partial sum ``Σ_{i ∈ component} coef_i · x_i``; together the three + component column-sets partition all 14 canonical columns, so + ``intercept + trend + seasonality + holiday_regressor == predict()``. + + Args: + X: Feature matrix of shape ``[n_rows, n_features]`` (the same frame + a ``predict`` call would consume). May contain ``NaN`` cells. + + Returns: + A :class:`ForecastDecomposition` with the four-way breakdown. + + Raises: + RuntimeError: If the model has not been fitted. + """ + from app.shared.feature_frames import canonical_feature_columns + + if not self._is_fitted or self._estimator is None: + raise RuntimeError("Model must be fitted before decompose") + imputer = self._estimator.named_steps["impute"] + ridge = self._estimator.named_steps["ridge"] + x_imputed = imputer.transform(X) + columns = canonical_feature_columns() + coef = np.asarray(ridge.coef_, dtype=np.float64) + contributions: dict[str, np.ndarray[Any, np.dtype[np.floating[Any]]]] = {} + for component, comp_cols in _PROPHET_LIKE_COMPONENTS.items(): + idx = [columns.index(c) for c in comp_cols] + contributions[component] = np.asarray(x_imputed[:, idx] @ coef[idx], dtype=np.float64) + return ForecastDecomposition( + intercept=float(ridge.intercept_), + trend=contributions["trend"], + seasonality=contributions["seasonality"], + holiday_regressor=contributions["holiday_regressor"], + ) + + def get_params(self) -> dict[str, Any]: + """Get model parameters. + + Returns: + Dictionary with alpha and random_state. + """ + return {"alpha": self.alpha, "random_state": self.random_state} + + def set_params(self, **params: Any) -> ProphetLikeForecaster: # noqa: ANN401 + """Set model parameters. + + Args: + **params: Parameter names and values to set. + + Returns: + self (for method chaining). + """ + for key, value in params.items(): + setattr(self, key, value) + return self + + # Type alias for model type literals ModelType = Literal[ - "naive", "seasonal_naive", "moving_average", "xgboost", "lightgbm", "regression" + "naive", "seasonal_naive", "moving_average", "xgboost", "lightgbm", "regression", "prophet_like" ] @@ -974,5 +1201,13 @@ def model_factory(config: ModelConfig, random_state: int = 42) -> BaseForecaster random_state=random_state, ) raise ValueError("Invalid config type for regression") + elif model_type == "prophet_like": + # No flag gate — the Prophet-like model is pure scikit-learn and ships + # always-enabled, exactly like ``regression``. + from app.features.forecasting.schemas import ProphetLikeModelConfig + + if isinstance(config, ProphetLikeModelConfig): + return ProphetLikeForecaster(alpha=config.alpha, random_state=random_state) + raise ValueError("Invalid config type for prophet_like") else: raise ValueError(f"Unknown model type: {model_type}") diff --git a/app/features/forecasting/schemas.py b/app/features/forecasting/schemas.py index 205be6b8..9219ab21 100644 --- a/app/features/forecasting/schemas.py +++ b/app/features/forecasting/schemas.py @@ -232,6 +232,37 @@ class RegressionModelConfig(ModelConfigBase): ) +class ProphetLikeModelConfig(ModelConfigBase): + """Configuration for the Prophet-like additive forecaster (MLZOO-C2). + + A deterministic, regularized ADDITIVE linear model — a ``Ridge`` regressor + over the canonical 14-column feature frame — that decomposes demand into + trend / seasonality / holiday-regressor components. It approximates + Prophet's additive shape WITHOUT the real ``prophet``/Stan dependency: it + does not model changepoint trend, posterior uncertainty, or automatic + seasonality discovery. Pure scikit-learn — no optional dependency, no + feature flag, always available (like ``RegressionModelConfig``). + + Attributes: + alpha: Ridge L2 regularization strength. 0.0 degenerates to ordinary + least squares; the default 1.0 keeps coefficients robust to the + collinear engineered-feature frame. + feature_config_hash: Optional hash of the feature contract used. + """ + + model_type: Literal["prophet_like"] = "prophet_like" + alpha: float = Field( + default=1.0, + ge=0.0, + le=10000.0, + description="Ridge L2 regularization strength", + ) + feature_config_hash: str | None = Field( + default=None, + description="Hash of the feature contract used for training", + ) + + # Union type for all model configs ModelConfig = ( NaiveModelConfig @@ -240,6 +271,7 @@ class RegressionModelConfig(ModelConfigBase): | LightGBMModelConfig | XGBoostModelConfig | RegressionModelConfig + | ProphetLikeModelConfig ) diff --git a/app/features/forecasting/tests/test_prophet_like_forecaster.py b/app/features/forecasting/tests/test_prophet_like_forecaster.py new file mode 100644 index 00000000..16f58805 --- /dev/null +++ b/app/features/forecasting/tests/test_prophet_like_forecaster.py @@ -0,0 +1,218 @@ +"""Unit tests for ``ProphetLikeForecaster`` (PRP-MLZOO-C2). + +The Prophet-like forecaster is a deterministic, regularized ADDITIVE linear +model — a ``Ridge`` over the canonical 14-column feature frame, fronted by a +``SimpleImputer`` so the ``NaN`` lag cells the future frame emits do not raise. + +These tests cover the shared feature-aware contract (``X`` required, shape +validated, deterministic fits) PLUS the model-specific invariants the tree +models do not have: the additive decomposition invariant, NaN tolerance via +the imputer, and the imputer's leakage-safety (medians learned on train ``X`` +only). Pure scikit-learn — no ``importorskip``, this file always runs. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from app.features.forecasting.models import ProphetLikeForecaster, model_factory +from app.features.forecasting.schemas import ProphetLikeModelConfig +from app.shared.feature_frames import canonical_feature_columns + +FloatArray = np.ndarray[Any, np.dtype[np.floating[Any]]] + +# The canonical contract is exactly 14 wide — the decompose() component +# grouping partitions these 14 columns, so the synthetic frame must match. +_N_FEATURES = len(canonical_feature_columns()) # 14 + + +def _synthetic_data( + n: int = 120, n_features: int = _N_FEATURES, seed: int = 0 +) -> tuple[FloatArray, FloatArray]: + """Build a synthetic feature matrix and a target that depends on it. + + Defaults to the canonical 14-column width so the decomposition tests line + up with ``canonical_feature_columns()``. + """ + rng = np.random.default_rng(seed) + features = rng.normal(size=(n, n_features)) + target = 50.0 + 5.0 * features[:, 0] - 3.0 * features[:, 1] + rng.normal(scale=0.5, size=n) + return features.astype(np.float64), target.astype(np.float64) + + +# --------------------------------------------------------------------------- +# Shared feature-aware contract tests +# --------------------------------------------------------------------------- + + +def test_fit_predict_roundtrip() -> None: + """A fitted Prophet-like model produces a finite forecast of horizon length.""" + features, target = _synthetic_data() + model = ProphetLikeForecaster() + model.fit(target, features) + assert model.is_fitted + + horizon = 10 + predictions = model.predict(horizon, features[:horizon]) + assert predictions.shape == (horizon,) + assert bool(np.all(np.isfinite(predictions))) + + +def test_fit_rejects_none_features() -> None: + """``fit`` raises when no exogenous features are supplied.""" + _, target = _synthetic_data() + with pytest.raises(ValueError, match="requires exogenous features"): + ProphetLikeForecaster().fit(target, None) + + +def test_fit_rejects_mismatched_rows() -> None: + """``fit`` raises when feature and target row counts differ.""" + features, target = _synthetic_data() + with pytest.raises(ValueError, match="rows must match"): + ProphetLikeForecaster().fit(target, features[:-5]) + + +def test_predict_rejects_none_features() -> None: + """``predict`` raises when no exogenous features are supplied.""" + features, target = _synthetic_data() + model = ProphetLikeForecaster().fit(target, features) + with pytest.raises(ValueError, match="requires exogenous features"): + model.predict(5, None) + + +def test_predict_rejects_wrong_shape_features() -> None: + """``predict`` raises when the feature row count is not the horizon.""" + features, target = _synthetic_data() + model = ProphetLikeForecaster().fit(target, features) + with pytest.raises(ValueError, match="horizon"): + model.predict(5, features[:8]) + + +def test_predict_before_fit_raises() -> None: + """``predict`` raises a RuntimeError before the model is fitted.""" + model = ProphetLikeForecaster() + with pytest.raises(RuntimeError, match="fitted"): + model.predict(5, np.zeros((5, _N_FEATURES), dtype=np.float64)) + + +def test_determinism_same_data() -> None: + """Two fits on the same data yield identical forecasts. + + ``Ridge(solver="cholesky")`` is closed-form and ``SimpleImputer(median)`` + is deterministic, so the whole pipeline is bit-reproducible. + """ + features, target = _synthetic_data() + future = features[:12] + first = ProphetLikeForecaster(alpha=1.0).fit(target, features) + second = ProphetLikeForecaster(alpha=1.0).fit(target, features) + np.testing.assert_array_equal(first.predict(12, future), second.predict(12, future)) + + +def test_get_and_set_params() -> None: + """``get_params`` reflects construction; ``set_params`` mutates in place.""" + model = ProphetLikeForecaster(alpha=2.5) + params = model.get_params() + assert params["alpha"] == 2.5 + assert params["random_state"] == 42 + model.set_params(alpha=0.1) + assert model.alpha == 0.1 + + +def test_requires_features_is_true() -> None: + """The Prophet-like model is feature-aware — the ClassVar is True.""" + assert ProphetLikeForecaster.requires_features is True + + +def test_model_factory_creates_prophet_like_forecaster() -> None: + """``model_factory`` dispatches a ProphetLikeModelConfig with NO flag gate.""" + model = model_factory(ProphetLikeModelConfig(alpha=3.0), random_state=42) + assert isinstance(model, ProphetLikeForecaster) + assert model.alpha == 3.0 + + +# --------------------------------------------------------------------------- +# Model-specific tests — additive decomposition, NaN tolerance, leakage +# --------------------------------------------------------------------------- + + +def test_handles_nan_features() -> None: + """A future frame with NaN lag cells predicts finite values. + + The ``SimpleImputer`` fills the NaN cells — a bare ``Ridge`` would raise + ``ValueError: Input contains NaN``. + """ + features, target = _synthetic_data() + model = ProphetLikeForecaster().fit(target, features) + future = features[:6].copy() + future[2, 0] = np.nan # the future frame emits NaN for un-resolvable lags + predictions = model.predict(6, future) + assert bool(np.all(np.isfinite(predictions))) + + +def test_additive_invariant() -> None: + """``decompose()``'s four parts sum (rtol 1e-9) to ``predict()``. + + This is what makes the model "Prophet-like": the forecast genuinely IS the + sum of its trend / seasonality / holiday-regressor components. + """ + features, target = _synthetic_data() + model = ProphetLikeForecaster(alpha=1.0).fit(target, features) + horizon = 12 + future = features[:horizon] + d = model.decompose(future) + reconstructed = d.intercept + d.trend + d.seasonality + d.holiday_regressor + np.testing.assert_allclose(reconstructed, model.predict(horizon, future), rtol=1e-9) + + +def test_decompose_components_have_horizon_length() -> None: + """Each decomposition component array has shape (len(X),).""" + features, target = _synthetic_data() + model = ProphetLikeForecaster().fit(target, features) + horizon = 9 + d = model.decompose(features[:horizon]) + assert d.trend.shape == (horizon,) + assert d.seasonality.shape == (horizon,) + assert d.holiday_regressor.shape == (horizon,) + assert isinstance(d.intercept, float) + + +def test_decompose_uses_trained_imputer_statistics() -> None: + """``decompose()`` imputes future NaN with the TRAINING-window median. + + The imputed X must equal the trained imputer's ``transform`` of the future + frame — never a fresh imputer fitted on the future window (which would + leak future statistics). + """ + features, target = _synthetic_data() + model = ProphetLikeForecaster().fit(target, features) + future = features[:6].copy() + future[2, 0] = np.nan + + imputer = model._estimator.named_steps["impute"] + expected_imputed = imputer.transform(future) + ridge = model._estimator.named_steps["ridge"] + coef = np.asarray(ridge.coef_, dtype=np.float64) + columns = canonical_feature_columns() + + # The trend component includes lag_1 (column 0) — the NaN cell. Recompute + # its contribution from the trained-imputer transform and assert decompose + # produced exactly that (i.e. it used the trained medians, not new ones). + trend_idx = [ + columns.index(c) for c in ("lag_1", "lag_7", "lag_14", "lag_28", "days_since_launch") + ] + expected_trend = expected_imputed[:, trend_idx] @ coef[trend_idx] + + d = model.decompose(future) + np.testing.assert_allclose(d.trend, expected_trend, rtol=1e-12) + # And the imputed lag_1 cell is the training median, not NaN. + assert np.isfinite(expected_imputed[2, 0]) + + +def test_decompose_before_fit_raises() -> None: + """``decompose()`` raises a RuntimeError before the model is fitted.""" + model = ProphetLikeForecaster() + with pytest.raises(RuntimeError, match="fitted"): + model.decompose(np.zeros((5, _N_FEATURES), dtype=np.float64)) diff --git a/app/features/forecasting/tests/test_service.py b/app/features/forecasting/tests/test_service.py index 403a991c..1f387bb9 100644 --- a/app/features/forecasting/tests/test_service.py +++ b/app/features/forecasting/tests/test_service.py @@ -352,12 +352,18 @@ class TestFeatureAwareContract: def test_requires_features_flag(self): """Baseline forecasters require no features; feature-aware ones do.""" from app.features.forecasting.models import LightGBMForecaster, XGBoostForecaster - from app.features.forecasting.schemas import RegressionModelConfig + from app.features.forecasting.schemas import ( + ProphetLikeModelConfig, + RegressionModelConfig, + ) assert model_factory(NaiveModelConfig()).requires_features is False assert model_factory(SeasonalNaiveModelConfig()).requires_features is False assert model_factory(MovingAverageModelConfig()).requires_features is False assert model_factory(RegressionModelConfig()).requires_features is True + # The Prophet-like model is feature-aware too — pure scikit-learn, so + # the factory needs no flag and no optional dependency. + assert model_factory(ProphetLikeModelConfig()).requires_features is True # LightGBM is feature-aware too — assert the ClassVar directly so this # needs neither the factory flag nor the optional lightgbm dependency. assert LightGBMForecaster.requires_features is True diff --git a/app/features/jobs/service.py b/app/features/jobs/service.py index 9b370937..f1af696b 100644 --- a/app/features/jobs/service.py +++ b/app/features/jobs/service.py @@ -427,6 +427,7 @@ async def _execute_train( LightGBMModelConfig, MovingAverageModelConfig, NaiveModelConfig, + ProphetLikeModelConfig, RegressionModelConfig, SeasonalNaiveModelConfig, XGBoostModelConfig, @@ -482,6 +483,9 @@ async def _execute_train( learning_rate=params.get("learning_rate", 0.1), max_depth=params.get("max_depth", 6), ) + elif model_type == "prophet_like": + # Pure scikit-learn additive model — no flag, always available. + config = ProphetLikeModelConfig(alpha=params.get("alpha", 1.0)) else: msg = f"Unsupported model_type: {model_type}" raise ValueError(msg) @@ -621,6 +625,7 @@ async def _execute_backtest( LightGBMModelConfig, MovingAverageModelConfig, NaiveModelConfig, + ProphetLikeModelConfig, RegressionModelConfig, SeasonalNaiveModelConfig, XGBoostModelConfig, @@ -667,6 +672,9 @@ async def _execute_backtest( # Feature-aware — still gated by forecast_enable_xgboost inside # model_factory; a disabled flag surfaces as a loud failed job. model_config = XGBoostModelConfig() + elif model_type == "prophet_like": + # Feature-aware — the backtest builds per-fold leakage-safe X. + model_config = ProphetLikeModelConfig() else: msg = f"Unsupported model_type: {model_type}" raise ValueError(msg) diff --git a/app/features/jobs/tests/test_service.py b/app/features/jobs/tests/test_service.py index ea4d842c..f377033a 100644 --- a/app/features/jobs/tests/test_service.py +++ b/app/features/jobs/tests/test_service.py @@ -24,6 +24,7 @@ from app.features.backtesting.service import BacktestingService from app.features.forecasting.schemas import ( LightGBMModelConfig, + ProphetLikeModelConfig, RegressionModelConfig, TrainResponse, XGBoostModelConfig, @@ -262,6 +263,26 @@ async def test_execute_train_builds_xgboost_config() -> None: assert result["model_type"] == "xgboost" +async def test_execute_train_builds_prophet_like_config() -> None: + """A train job with model_type='prophet_like' builds a ProphetLikeModelConfig (#248). + + ``train_model`` is mocked, so the test is pure (no DB). The Prophet-like + model is pure scikit-learn — no feature flag, no optional dependency. + """ + fake = _fake_train_response("prophet_like") + with patch.object( + ForecastingService, "train_model", new=AsyncMock(return_value=fake) + ) as mock_train: + result = await JobService()._execute_train( + db=cast(AsyncSession, AsyncMock()), + params={**_REGRESSION_PARAMS, "model_type": "prophet_like"}, + ) + assert mock_train.call_args is not None + config = mock_train.call_args.kwargs["config"] + assert isinstance(config, ProphetLikeModelConfig) + assert result["model_type"] == "prophet_like" + + async def test_execute_train_rejects_unsupported_model_type() -> None: """_execute_train still rejects a genuinely unsupported model_type.""" with pytest.raises(ValueError, match="Unsupported model_type"): @@ -345,6 +366,27 @@ async def test_execute_backtest_builds_xgboost_config() -> None: assert result["model_type"] == "xgboost" +async def test_execute_backtest_builds_prophet_like_config() -> None: + """A backtest job with model_type='prophet_like' builds a ProphetLikeModelConfig. + + ``run_backtest`` is mocked, so the test is pure (no DB): it pins that + ``_execute_backtest`` widened its allow-list to the pure-sklearn additive + model and shaped the result. + """ + response = _make_response() + with patch.object( + BacktestingService, "run_backtest", new=AsyncMock(return_value=response) + ) as mock_run: + result = await JobService()._execute_backtest( + db=cast(AsyncSession, AsyncMock()), + params={**_BACKTEST_PARAMS, "model_type": "prophet_like"}, + ) + assert mock_run.call_args is not None + config = mock_run.call_args.kwargs["config"] + assert isinstance(config.model_config_main, ProphetLikeModelConfig) + assert result["model_type"] == "prophet_like" + + async def test_execute_backtest_rejects_unsupported_model_type() -> None: """_execute_backtest still rejects a genuinely unsupported model_type.""" with pytest.raises(ValueError, match="Unsupported model_type"): diff --git a/app/features/scenarios/tests/conftest.py b/app/features/scenarios/tests/conftest.py index 72452de3..c5ebc731 100644 --- a/app/features/scenarios/tests/conftest.py +++ b/app/features/scenarios/tests/conftest.py @@ -25,6 +25,7 @@ from app.features.forecasting.models import ( LightGBMForecaster, NaiveForecaster, + ProphetLikeForecaster, RegressionForecaster, XGBoostForecaster, ) @@ -32,6 +33,7 @@ from app.features.forecasting.schemas import ( LightGBMModelConfig, NaiveModelConfig, + ProphetLikeModelConfig, RegressionModelConfig, XGBoostModelConfig, ) @@ -260,3 +262,53 @@ def trained_regression_model() -> Generator[str, None, None]: yield run_id (artifacts_dir / f"model_{run_id}.joblib").unlink(missing_ok=True) + + +@pytest.fixture +def trained_prophet_like_model() -> Generator[str, None, None]: + """Save a real fitted ``ProphetLikeForecaster`` bundle on disk; yield run_id. + + The Prophet-like additive model is feature-aware (pure scikit-learn — no + flag, no optional dependency), so the bundle carries the full PRP-27 + feature metadata and the model-exogenous simulate path can build a future + feature frame and genuinely re-forecast — exactly as it does for a + regression or LightGBM bundle. Demand is wired to respond negatively to + ``price_factor`` so a price cut lifts the forecast. + """ + settings = get_settings() + artifacts_dir = Path(settings.forecast_model_artifacts_dir) + artifacts_dir.mkdir(parents=True, exist_ok=True) + + run_id = uuid.uuid4().hex[:12] + columns = canonical_feature_columns() + rng = np.random.default_rng(7) + n_rows = 200 + features = rng.normal(size=(n_rows, len(columns))) + price_index = columns.index("price_factor") + target = 40.0 - 20.0 * features[:, price_index] + rng.normal(scale=0.5, size=n_rows) + + model = ProphetLikeForecaster(random_state=7) + model.fit(target.astype(np.float64), features.astype(np.float64)) + + history_start = date(2026, 4, 1) + bundle = ModelBundle( + model=model, + config=ProphetLikeModelConfig(), + metadata={ + "store_id": TEST_STORE_ID, + "product_id": TEST_PRODUCT_ID, + "train_end_date": TEST_TRAIN_END_DATE, + "n_observations": n_rows, + "feature_columns": columns, + "history_tail": [12.0] * 90, + "history_tail_dates": [ + (history_start + timedelta(days=offset)).isoformat() for offset in range(90) + ], + "launch_date": "2025-01-01", + }, + ) + save_model_bundle(bundle, artifacts_dir / f"model_{run_id}") + + yield run_id + + (artifacts_dir / f"model_{run_id}.joblib").unlink(missing_ok=True) diff --git a/app/features/scenarios/tests/test_routes_integration.py b/app/features/scenarios/tests/test_routes_integration.py index b1f34d8c..79092115 100644 --- a/app/features/scenarios/tests/test_routes_integration.py +++ b/app/features/scenarios/tests/test_routes_integration.py @@ -217,6 +217,31 @@ async def test_xgboost_baseline_returns_model_exogenous( assert data["disclaimer"], "every comparison must carry a non-empty disclaimer" assert len(data["points"]) == 14 + async def test_prophet_like_baseline_returns_model_exogenous( + self, client: AsyncClient, trained_prophet_like_model: str + ) -> None: + """A prophet_like baseline is feature-aware — it re-forecasts (MLZOO-C2). + + Pins that the capability-based dispatch in ``ScenarioService.simulate`` + (the ``bundle.model.requires_features`` branch) routes the pure-sklearn + additive model through the genuine re-forecast path with zero scenarios + changes — no flag, no optional dependency. + """ + response = await client.post( + "/scenarios/simulate", + json={ + "run_id": trained_prophet_like_model, + "horizon": 14, + "assumptions": _PRICE_ASSUMPTION, + }, + ) + assert response.status_code == 200 + data = response.json() + + assert data["method"] == "model_exogenous" + assert data["disclaimer"], "every comparison must carry a non-empty disclaimer" + assert len(data["points"]) == 14 + async def test_regression_empty_assumptions_equals_baseline( self, client: AsyncClient, trained_regression_model: str ) -> None: diff --git a/examples/models/feature_frame_contract.md b/examples/models/feature_frame_contract.md index d0010f77..5e408fe6 100644 --- a/examples/models/feature_frame_contract.md +++ b/examples/models/feature_frame_contract.md @@ -1,7 +1,7 @@ # Feature-Frame Contract -The contract a **feature-aware** forecasting model (the regression, LightGBM -and XGBoost forecasters today; a Prophet-like model later in the MLZOO sequence) +The contract a **feature-aware** forecasting model (the regression, LightGBM, +XGBoost, and Prophet-like forecasters today) stands on. The single source of truth in code is [`app/shared/feature_frames`](../../app/shared/feature_frames/) — the pinned constants, the canonical column set and order, the `FutureFeatureFrame` @@ -91,9 +91,12 @@ at origin `T` — a long-lag whose source day lies in the horizon, or `days_since_launch` for a product with no launch date. `NaN` means *unknown*; it is never silently replaced with a fabricated default such as `0.0`. -`HistGradientBoostingRegressor` tolerates `NaN` natively. A future model that is -**not** NaN-tolerant must impute explicitly inside its own `fit`/`predict` — the -shared frame builders must not impute on its behalf. +`HistGradientBoostingRegressor` and `lightgbm.LGBMRegressor` tolerate `NaN` +natively. A model that is **not** NaN-tolerant must impute explicitly inside its +own `fit`/`predict` — the shared frame builders must not impute on its behalf. +The `prophet_like` model is the worked example: its `Ridge` step rejects `NaN`, +so it folds a `SimpleImputer(median)` in as the first `Pipeline` step (the +imputer learns its medians on the training `X` only — no leakage). ## How a future advanced model plugs in diff --git a/examples/models/model_interface.md b/examples/models/model_interface.md index 6a35f925..cd6a1424 100644 --- a/examples/models/model_interface.md +++ b/examples/models/model_interface.md @@ -185,6 +185,29 @@ A **feature-aware** model (`requires_features = True`) wrapping `forecast_enable_xgboost=true`. It consumes the same canonical feature frame as `regression` and `lightgbm` — see [`feature_frame_contract.md`](feature_frame_contract.md). +### ProphetLikeModelConfig + +```python +{ + "schema_version": "1.0", + "model_type": "prophet_like", + "alpha": 1.0 # 0.0-10000.0 (Ridge L2 regularization strength) +} +``` + +A **feature-aware** model (`requires_features = True`) — a deterministic, +regularized **additive linear** model (MLZOO-C2). It is a scikit-learn +`Pipeline` of a `SimpleImputer(median)` + a `Ridge(solver="cholesky")` over the +same canonical 14-column feature frame as `regression`. Unlike the tree models +it ships **always-enabled**: pure scikit-learn, no optional extra, no feature +flag. It exposes a model-specific `decompose()` method that splits any forecast +into its additive trend / seasonality / holiday-regressor contributions. + +It is "Prophet-**like**", not Prophet: it approximates Prophet's additive shape +with a linear model over engineered features. It does **not** add the real +`prophet`/Stan dependency and does **not** model changepoint trend, posterior +uncertainty intervals, or automatic seasonality discovery. + --- ## Model Formulas @@ -248,6 +271,30 @@ fixed `random_state`, no stochastic subsampling), and NaN-tolerant (`missing=np.nan`). Optional — behind the `ml-xgboost` extra and the `forecast_enable_xgboost` flag. +### Prophet-like Forecaster + +``` +ŷ[t+h] = intercept + trend[t+h] + seasonality[t+h] + holiday_regressor[t+h] +``` + +An **additive** linear forecast: a `Ridge` fit gives `ŷ = intercept + Σ coefᵢ·xᵢ`, +and that sum is grouped into three Prophet-style components, each the partial +sum over its columns of the canonical 14-column frame: + +| Component | Canonical columns | +|-----------|-------------------| +| `trend` | `lag_1`, `lag_7`, `lag_14`, `lag_28`, `days_since_launch` | +| `seasonality` | `dow_sin`, `dow_cos`, `month_sin`, `month_cos`, `is_weekend`, `is_month_end` | +| `holiday_regressor` | `price_factor`, `promo_active`, `is_holiday` | + +The three column sets partition all 14 columns exactly, so the **additive +invariant** holds: `decompose(X)`'s four parts sum (within float tolerance) to +`predict(...)`. Feature-aware (`requires_features = True`), deterministic +(`Ridge(solver="cholesky")` closed-form, `SimpleImputer(median)`), and +NaN-tolerant via the imputer. Pure scikit-learn — always available, no extra, +no flag. The `decompose()` method (model-specific, not on `BaseForecaster`) +returns the four-way breakdown. + --- ## Persistence (ModelBundle) diff --git a/examples/models/prophet_like_additive.py b/examples/models/prophet_like_additive.py new file mode 100644 index 00000000..1a9378f2 --- /dev/null +++ b/examples/models/prophet_like_additive.py @@ -0,0 +1,78 @@ +"""Example: Training, predicting, and decomposing with the Prophet-like model (MLZOO-C2). + +``ProphetLikeForecaster`` is a deterministic, regularized ADDITIVE linear model +— a scikit-learn ``Pipeline`` of a ``SimpleImputer`` + a ``Ridge`` regressor +over the canonical 14-column feature frame. Like the other feature-aware models +it REQUIRES an exogenous feature matrix ``X`` for both ``fit`` and ``predict``. + +It is "Prophet-LIKE", not Prophet: it approximates Prophet's additive trend + +seasonality + holiday/regressor decomposition with a linear model over already- +engineered features. It does NOT add the real ``prophet``/Stan dependency and +does NOT model changepoint trend, posterior uncertainty intervals, or automatic +seasonality discovery. + +Pure scikit-learn — no optional extra to install, always available: + + uv run python examples/models/prophet_like_additive.py +""" + +import numpy as np + +from app.features.forecasting.models import ProphetLikeForecaster +from app.shared.feature_frames import canonical_feature_columns + + +def main() -> None: + # 1. Build a small synthetic feature matrix matching the canonical 14-column + # feature-frame contract, plus a target that genuinely depends on it. + columns = canonical_feature_columns() + n_features = len(columns) # 14 + rng = np.random.default_rng(42) + n_rows = 120 + x_train = rng.normal(size=(n_rows, n_features)) + y_train = ( + 50.0 + 5.0 * x_train[:, 0] - 3.0 * x_train[:, 1] + rng.normal(scale=0.5, size=n_rows) + ).astype(np.float64) + print(f"Training data: {n_rows} rows x {n_features} features") + print(f"Feature columns: {columns}") + + # 2. Create the model — deterministic (Ridge solver="cholesky" is closed-form). + model = ProphetLikeForecaster(alpha=1.0, random_state=42) + print(f"\nrequires_features: {ProphetLikeForecaster.requires_features}") + + # 3. Fit on the historical feature frame (the SimpleImputer learns its + # per-column medians on this training X only — no leakage). + model.fit(y_train, x_train) + print(f"Model fitted: {model.is_fitted}") + print(f"Model params: {model.get_params()}") + + # 4. Predict over a future feature frame of `horizon` rows. + horizon = 7 + x_future = rng.normal(size=(horizon, n_features)) + forecasts = model.predict(horizon, x_future) + print(f"\n{horizon}-day forecast:") + for i, value in enumerate(forecasts): + print(f" Day {i + 1}: {value:.2f}") + + # 5. Decompose the forecast into its additive components. The invariant is + # intercept + trend + seasonality + holiday_regressor == predict(...). + decomposition = model.decompose(x_future) + print(f"\nAdditive decomposition (intercept = {decomposition.intercept:.2f}):") + print(" Day | trend | seasonality | holiday_regressor | sum | predict") + for i in range(horizon): + component_sum = ( + decomposition.intercept + + decomposition.trend[i] + + decomposition.seasonality[i] + + decomposition.holiday_regressor[i] + ) + print( + f" {i + 1:>3} | {decomposition.trend[i]:>6.2f} | " + f"{decomposition.seasonality[i]:>11.2f} | " + f"{decomposition.holiday_regressor[i]:>17.2f} | " + f"{component_sum:>7.2f} | {forecasts[i]:>7.2f}" + ) + + +if __name__ == "__main__": + main()