diff --git a/PRPs/INITIAL/INITIAL-MLZOO-C-xgboost-prophet-extensions.md b/PRPs/INITIAL/INITIAL-MLZOO-C-xgboost-prophet-extensions.md index ee41a887..1ad89ad1 100644 --- a/PRPs/INITIAL/INITIAL-MLZOO-C-xgboost-prophet-extensions.md +++ b/PRPs/INITIAL/INITIAL-MLZOO-C-xgboost-prophet-extensions.md @@ -1,5 +1,20 @@ # INITIAL-MLZOO-C-xgboost-prophet-extensions.md - XGBoost and Prophet-like Extensions +> **This brief is split into TWO PRPs — two branches, two review units. Never one.** +> This INITIAL is the shared brief for both, but the two models are delivered separately: +> +> - **`PRPs/PRP-MLZOO-C1-xgboost-model.md`** — the XGBoost half. A low-risk follow-up that +> mirrors the merged `LightGBMForecaster` design (optional `ml-xgboost` extra, feature +> flag, lazy import, deterministic training, registry metadata). +> - **`PRPs/PRP-MLZOO-C2-prophet-like-additive-model.md`** — the Prophet-like half. A +> distinct model-family design task — a pure-scikit-learn additive linear model with +> trend / seasonality / holiday-regressor decomposition; **not** a clone of the tree +> models and **not** the real `prophet` dependency. +> +> Do not combine the two models into a single PRP or a single branch. The "Out of scope" +> lists below still apply to *each* PRP individually (e.g. C1 does not touch Prophet-like +> work; C2 does not touch XGBoost). See `INITIAL-MLZOO-index.md` for the updated roadmap. + ## FEATURE: Extend the Advanced ML Model Zoo after the feature-frame foundation and first advanced model path are stable. diff --git a/PRPs/INITIAL/INITIAL-MLZOO-index.md b/PRPs/INITIAL/INITIAL-MLZOO-index.md index b41faf77..7d2634a2 100644 --- a/PRPs/INITIAL/INITIAL-MLZOO-index.md +++ b/PRPs/INITIAL/INITIAL-MLZOO-index.md @@ -18,16 +18,24 @@ Recommended PRP sequence: | 1 | `INITIAL-MLZOO-A-foundation-feature-frames.md` | PRP-29 | Feature-aware forecasting foundation and leakage-safe frame contracts | | 2 | `INITIAL-MLZOO-B-lightgbm-first-model.md` | PRP-30 | First advanced model path with LightGBM (optional `ml-lightgbm` extra) | | 2.5 | `INITIAL-MLZOO-B.2-feature-aware-backtesting.md` | PRP-MLZOO-B.2 | Wire feature-aware models into the backtesting fold loop (per-fold leakage-safe `X_train` / `X_future`) | -| 3 | `INITIAL-MLZOO-C-xgboost-prophet-extensions.md` | Future PRP | XGBoost and Prophet-like extensions | +| 3a | `INITIAL-MLZOO-C-xgboost-prophet-extensions.md` (XGBoost half) | PRP-MLZOO-C1 | XGBoost feature-aware model — a low-risk follow-up mirroring the merged LightGBM design (optional `ml-xgboost` extra) | +| 3b | `INITIAL-MLZOO-C-xgboost-prophet-extensions.md` (Prophet-like half) | PRP-MLZOO-C2 | Prophet-like additive model — a distinct model-family design (pure scikit-learn; trend / seasonality / regressor decomposition) | | 4 | `INITIAL-MLZOO-D-frontend-registry-explainability.md` | Future PRP | UI, registry surfacing, and explanation polish | +**C is two PRPs, not one.** `INITIAL-MLZOO-C` briefs both XGBoost and a Prophet-like model, +but they are deliberately split into **two separate PRPs, branches, and review units** — +`PRP-MLZOO-C1` (XGBoost) and `PRP-MLZOO-C2` (Prophet-like). They are additive and +order-independent; whichever merges second rebases cleanly. Do **not** combine them into a +single branch or a single review unit (this honours the "one reviewable unit" rule below). + Dependency graph: ```text A. Foundation feature frames -> B. LightGBM first model -> B.2 Feature-aware backtesting - -> C. XGBoost / Prophet-like extensions + -> C1. XGBoost model (separate review unit) + -> C2. Prophet-like model (separate review unit; parallel to C1) -> D. Frontend / registry / explainability ``` @@ -74,5 +82,7 @@ Read these before creating any MLZOO PRP: - Do not implement LightGBM before the feature-frame contracts and leakage tests are stable. - Do not implement XGBoost or Prophet-like models before the first advanced model path proves the architecture. - Do not add frontend/explainability scope before backend metadata and persistence contracts are stable. -- Keep each PRP to one branch and one reviewable unit. +- Keep each PRP to one branch and one reviewable unit. In particular, `INITIAL-MLZOO-C`'s + two models (XGBoost, Prophet-like) are **two PRPs** — `PRP-MLZOO-C1` and `PRP-MLZOO-C2` — + never one combined branch. diff --git a/PRPs/PRP-MLZOO-C1-xgboost-model.md b/PRPs/PRP-MLZOO-C1-xgboost-model.md new file mode 100644 index 00000000..16ac07d5 --- /dev/null +++ b/PRPs/PRP-MLZOO-C1-xgboost-model.md @@ -0,0 +1,979 @@ +name: "PRP-MLZOO-C1 — XGBoost Feature-Aware Forecasting Model" +description: | + +## Purpose + +The first half of MLZOO-C (`PRPs/INITIAL/INITIAL-MLZOO-C-xgboost-prophet-extensions.md`). +It adds **one** advanced, feature-aware forecasting model — `XGBoostForecaster`, wrapping +`xgboost.XGBRegressor` — as a low-risk follow-up that **mirrors the merged +`LightGBMForecaster` design byte-for-byte** (PRP-30 / MLZOO-B, commit `2f1b8a5`). + +This PRP implements **XGBoost only**: its `XGBoostModelConfig` schema, the +`XGBoostForecaster` class, its `model_factory` wiring, the `forecast_enable_xgboost` +runtime flag, the `ml-xgboost` optional dependency group, the jobs train/backtest +branches, the reproducibility metadata, and tests. It adds **no** Prophet-like model +(that is PRP-MLZOO-C2, a separate branch and review unit — see DECISIONS LOCKED #1), +**no** hyperparameter search, **no** portfolio/global models, **no** frontend, and +**no** explainability change. + +> **Sibling PRP:** `PRPs/PRP-MLZOO-C2-prophet-like-additive-model.md` ships the +> Prophet-like additive 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). + +## What this PRP already inherits (DO NOT re-build) + +PRP-29 (MLZOO-A), PRP-30 (MLZOO-B), and PRP-MLZOO-B.2 (feature-aware backtesting, +PR #244) already shipped the entire structural foundation a new feature-aware model +stands on. Re-use it; do not re-derive it: + +- **The feature-aware model contract.** `BaseForecaster.requires_features: ClassVar[bool]` + (`app/features/forecasting/models.py:64`). `RegressionForecaster` (`models.py:438`) and + `LightGBMForecaster` (`models.py:580`) are the *existing* feature-aware models — + `requires_features = True`, `fit(y, X)` / `predict(horizon, X)` both require a + non-`None` `X`. `XGBoostForecaster` is their structural twin. +- **The shared feature-frame contract.** `app/shared/feature_frames/` owns the pinned + constants, `canonical_feature_columns()` (the 14-column set), the leakage-safe pure + builders, and the `FeatureSafety` taxonomy. A new feature-aware model writes **zero** + new contract code. +- **The training-frame branch.** `ForecastingService.train_model` + (`app/features/forecasting/service.py:180-297`) branches on `model.requires_features` + (`service.py:219`) — **model-type-agnostic**, no string compare. If true it builds the + historical frame via `_build_regression_features` and calls `model.fit(features.y, + features.X)`, persisting `feature_columns` / `history_tail` / `launch_date` into the + bundle metadata. **An XGBoost model trains with zero changes to `train_model`.** +- **The predict rejection.** `ForecastingService.predict` (`service.py:383-393`) rejects + any `bundle.model.requires_features` model — capability-based, not `model_type`-string. + An XGBoost model is rejected there automatically; it forecasts through + `POST /scenarios/simulate`. +- **The scenario `model_exogenous` dispatch.** `app/features/scenarios/service.py:114` + already branches on `bundle.model.requires_features` — no `model_type` strings remain + in `app/features/scenarios/`. An XGBoost bundle takes the genuine re-forecast path + with **zero scenarios changes**. +- **Feature-aware backtesting.** `app/features/backtesting/service.py:384-409` probes + `model_factory(...).requires_features` and, when true, builds per-fold leakage-safe + `X_train` (sliced) / `X_future` (rebuilt) via `build_historical_feature_rows` / + `build_future_feature_rows`. **Model-agnostic** — never checks a `model_type` string. + An XGBoost model backtests with **zero backtesting-service changes**. (This is the key + difference from PRP-30, which had to defer backtesting to B.2 — B.2 is now merged.) +- **The historical-frame 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. XGBoost consumes the **same** builders → these specs already cover its + training and future feature matrices. **No new leakage test is required** (DECISIONS + LOCKED #6). + +The **problem this PRP fixes**: XGBoost — named in `INITIAL-MLZOO-C` and +`docs/optional-features/05-advanced-ml-model-zoo.md` as the second tree-based model and +the robust-regularization benchmark against LightGBM — does not exist. There is no +`xgboost` dependency, no `XGBoostModelConfig`, no `xgboost` in the `ModelType` literal, +no `model_factory` branch, and `JobService._execute_train` / `_execute_backtest` reject +`model_type="xgboost"` as unsupported. + +## 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 (A ✅ → B ✅ → B.2 ✅ → + **C1 (this) ∥ C2** → D). +- `PRPs/PRP-30-lightgbm-first-advanced-model.md` — **the byte-for-byte template for this + PRP.** Every DECISIONS LOCKED entry and Anti-Pattern there applies here with `lightgbm` + → `xgboost`. Read it in full first. +- `PRPs/PRP-MLZOO-B.2-feature-aware-backtesting.md` — explains why backtesting now works + for any `requires_features` model with no per-model wiring. +- `examples/models/feature_frame_contract.md` — the historical/future frame shapes a + feature-aware model consumes, and the canonical 14-column set. + +--- + +## Goal + +Implement `XGBoostForecaster` — a deterministic, feature-aware forecasting model wrapping +`xgboost.XGBRegressor` — and wire it end-to-end: `model_factory` instantiates it (behind a +new `forecast_enable_xgboost` flag), `ForecastingService.train_model` trains it through the +existing `requires_features` branch, `POST /scenarios/simulate` re-forecasts it through +`method="model_exogenous"`, the backtesting fold loop backtests it through the existing +`requires_features` probe, `JobService._execute_train` and `_execute_backtest` accept +`model_type="xgboost"`, and the XGBoost library version is captured in the model bundle and +the registry's `runtime_info`. XGBoost ships as an **optional dependency group** +(`ml-xgboost`); the model code lazy-imports it so a single-host install without the extra +still works for every other model. + +**End state:** a user with `forecast_enable_xgboost=True` and the `ml-xgboost` extra +installed can train an `xgboost` model (HTTP or job), re-forecast it in a what-if scenario, +and backtest it, exactly as they can a `lightgbm` model today. Every existing model behaves +**identically** before and after. + +## Why + +- **The model zoo needs a second tree benchmark.** `docs/optional-features/05-advanced-ml-model-zoo.md` + frames XGBoost as the "strong tabular benchmark … robust regularization … useful + comparison against LightGBM". A credible model-*comparison* platform needs more than one + advanced model; XGBoost is the industry-standard second. +- **The foundation is fully paid for.** PRP-29/30/B.2 made train, predict, scenarios, and + backtesting all branch on `requires_features`. Adding a second tree model is now a + *small, contained* change — one class (a near-clone of the proven `LightGBMForecaster`), + one config, one factory branch, two jobs branches, metadata, and tests. +- **De-risks the dependency one step at a time.** `INITIAL-MLZOO-index.md` mandates "Add + XGBoost as a second tree model" only after the first advanced model path is stable. It is. +- **Low blast radius.** No migration, no API-contract change, no existing-model change, no + new vertical slice. + +## What + +A backend-only feature PRP. User-visible behaviour gains exactly one thing: `model_type: +"xgboost"` becomes a real, trainable, scenario-re-forecastable, backtestable model when the +feature flag and the optional dependency are both present. Everything else is identical. + +### Technical requirements + +1. **Optional dependency group.** `pyproject.toml` gains `[project.optional-dependencies] + ml-xgboost = ["xgboost>=2.1.0"]`. CI already runs `uv sync --frozen --all-extras --dev` + (`.github/workflows/ci.yml:48,74,116,163`) so the extra is installed and tested in CI + with **no workflow change**. `uv.lock` is regenerated (`uv lock`) because CI uses + `--frozen`. +2. **Runtime flag.** `app/core/config.py` gains `forecast_enable_xgboost: bool = False` + (after `forecast_enable_lightgbm`, `config.py:101`) — mirrors the LightGBM gate exactly. +3. **`XGBoostModelConfig`** in `app/features/forecasting/schemas.py` — a `ModelConfigBase` + subclass, **conservative field set matching `LightGBMModelConfig`** (DECISIONS LOCKED + #4): `n_estimators` (10-1000, default 100), `max_depth` (1-20, default 6), + `learning_rate` (0.001-1.0, default 0.1), `feature_config_hash: str | None`. Added to + the `ModelConfig` union. +4. **`XGBoostForecaster`** in `app/features/forecasting/models.py` — a `BaseForecaster` + subclass with `requires_features: ClassVar[bool] = True`, structurally mirroring + `LightGBMForecaster` (`models.py:580-732`). It lazy-imports `xgboost` inside `fit()` so + importing `models.py` never requires the optional dependency. It is deterministic + (`n_jobs=1`, `tree_method="hist"`, fixed `random_state`) and NaN-tolerant (XGBoost + handles `NaN` natively via `missing=np.nan`). +5. **`model_factory`** — a new `xgboost` branch mirroring the `lightgbm` branch + (`models.py:778-792`), gated on `forecast_enable_xgboost`. The `ModelType` literal + (`models.py:736`) gains `"xgboost"`. +6. **Jobs integration.** `JobService._execute_train` (`jobs/service.py:454-478`) and + `_execute_backtest` (`jobs/service.py:641-658`) each gain an `xgboost` branch building + `XGBoostModelConfig` — mirroring the existing `lightgbm` branches. +7. **Route gate.** `POST /forecasting/train` (`forecasting/routes.py:67-72`) gains an + `xgboost` flag gate mirroring the `lightgbm` one. +8. **Reproducibility metadata.** `ModelBundle` gains an `xgboost_version: str | None` + field (best-effort captured on save, mismatch-warned on load — mirroring + `lightgbm_version`, `persistence.py:56,104-108,185-199`); + `RegistryService._capture_runtime_info` (`registry/service.py:124-129`) gains an + `xgboost` version block. +9. **Tests** mirroring the `LightGBMForecaster` suite, gated with + `pytest.importorskip("xgboost")`; an `examples/models/advanced_xgboost.py` example; + additive docs. + +### Success Criteria + +- [ ] `model_factory(XGBoostModelConfig(), random_state=42)` returns an `XGBoostForecaster` + when `forecast_enable_xgboost=True`; raises a clear `ValueError` when the flag is off. +- [ ] `XGBoostForecaster.requires_features is True`; `fit`/`predict` require a non-`None` + `X` and raise the same error-message substrings as `LightGBMForecaster` + (`"requires exogenous features"`, `"rows must match"`, `"horizon"`, `"fitted"`). +- [ ] Two fits with the same `random_state` produce **identical** forecasts + (`np.testing.assert_array_equal`) — single-threaded `hist` is reproducible within one + environment (see Gotchas). +- [ ] `ForecastingService.train_model` trains an `xgboost` model with **no edit to + `train_model`** (routes through the existing `requires_features` branch). +- [ ] `POST /scenarios/simulate` against a trained `xgboost` run returns + `method="model_exogenous"` (not `"heuristic"`) — **no edit to scenarios code**. +- [ ] A backtest of an `xgboost` model produces per-fold metrics — **no edit to + backtesting-service code** (the B.2 `requires_features` probe handles it). +- [ ] `JobService._execute_train` and `_execute_backtest` accept `model_type="xgboost"`. +- [ ] `ModelBundle.xgboost_version` and registry `runtime_info["xgboost_version"]` are + captured when `xgboost` is installed. +- [ ] Every baseline model, `regression`, `lightgbm`, 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 route/schema/WebSocket *contract* change; XGBoost stays an + *optional* dependency (the core `dependencies` list is unchanged). + +--- + +## All Needed Context + +### Documentation & References + +```yaml +- file: PRPs/PRP-30-lightgbm-first-advanced-model.md + why: THE template. This PRP is a near-clone with lightgbm -> xgboost. Every DECISIONS + LOCKED entry, every Anti-Pattern, every Validation Level there applies here. Read + it fully before touching code. + +- file: app/features/forecasting/models.py + why: LightGBMForecaster (lines 580-732) is the BYTE-FOR-BYTE structural template for + XGBoostForecaster — same __init__ shape, same fit/predict guards, same error + strings, same lazy-import-inside-fit pattern, same get_params/set_params. The + model_factory lightgbm branch (lines 778-792) is the template for the xgboost + branch. The ModelType literal is at line 736. + critical: The estimator is typed `Any` (`estimator: Any = lgb.LGBMRegressor(...)` at + models.py:661) — mirror that for XGBRegressor so pyright --strict stays quiet. + +- file: app/features/forecasting/schemas.py + why: LightGBMModelConfig (lines 107-144) is the template for XGBoostModelConfig — same + four fields, same Field(ge=, le=, default=) bounds. The ModelConfig union is at + lines 192-199. DECISIONS LOCKED #4: keep XGBoostModelConfig conservative — DO NOT + add subsample/colsample_bytree/reg_alpha/reg_lambda. + +- file: app/features/forecasting/service.py + why: train_model (lines 180-297) branches `if model.requires_features:` (line 219) — + MODEL-AGNOSTIC. predict (lines 299-437) rejects feature-aware models at + lines 383-393 — also capability-based. _build_regression_features and + _assemble_regression_rows are REUSED unchanged by XGBoost. + critical: DO NOT EDIT service.py. An XGBoost model trains and is predict-rejected purely + because requires_features=True. Verify by reading, then leave it alone. + +- file: app/features/forecasting/persistence.py + why: ModelBundle dataclass (lines 48-57) has python_version + sklearn_version + + lightgbm_version. save_model_bundle captures lightgbm_version best-effort at + lines 102-108; load_model_bundle mismatch-warns at lines 185-199. ADD + `xgboost_version` mirroring `lightgbm_version` EXACTLY. compute_hash (lines 59-72) + reads only config_hash/model_params/metadata — adding xgboost_version does NOT + change any bundle hash. + +- file: app/features/forecasting/routes.py + why: POST /forecasting/train has the lightgbm feature-flag gate at lines 67-72 + (`request.config.model_type == "lightgbm" and not settings.forecast_enable_lightgbm` + -> 400). ADD a parallel xgboost gate. ValueError -> 400 (lines 115-118). + +- file: app/features/jobs/service.py + why: _execute_train has the model_type if/elif chain at lines 454-478 (lightgbm branch + at the elif; final `else: raise ValueError("Unsupported model_type: ...")`). + _execute_backtest has an IDENTICAL chain at lines 641-658. The forecasting-schemas + import block is at lines 426-433. ADD an xgboost branch to BOTH chains and + XGBoostModelConfig to the import. + +- file: app/features/registry/service.py + why: _capture_runtime_info (lines 84-131) best-effort-imports sklearn/numpy/pandas/ + joblib/lightgbm into a runtime_info dict. The lightgbm block is at lines 124-129. + ADD an identical `try: import xgboost` block. runtime_info is JSONB — NO migration. + +- file: app/features/backtesting/service.py + why: lines 384-409 probe `model_factory(...).requires_features` and branch to + _run_feature_aware_fold for any feature-aware model. MODEL-AGNOSTIC — DO NOT EDIT. + Read to confirm an xgboost model backtests for free. + +- file: app/features/forecasting/tests/test_lightgbm_forecaster.py + why: THE test template for test_xgboost_forecaster.py. Clone every test 1:1 swapping + LightGBMForecaster -> XGBoostForecaster, LightGBMModelConfig -> XGBoostModelConfig, + forecast_enable_lightgbm -> forecast_enable_xgboost, and the importorskip target. + Copy the `_synthetic_data` helper verbatim. + +- file: app/features/forecasting/tests/test_regression_forecaster.py + why: The fuller 10-test template the LightGBM file itself was cloned from — same test + names. Either file works as the clone source. + +- file: app/features/forecasting/tests/test_service.py + why: TestFeatureAwareContract (lines 349-412) — test_requires_features_flag and + test_lightgbm_factory_respects_flag. Extend the first with XGBoost; add an + xgboost-flag mirror of the second. + +- file: app/features/jobs/tests/test_service.py + why: test_execute_train_rejects_unsupported_model_type (lines 243-249, already uses + "arima" — NO fix needed). test_execute_train_builds_lightgbm_config (lines 222-241) + and test_execute_backtest_builds_lightgbm_config (lines 286-304) are the templates + for the xgboost job tests. + +- url: https://xgboost.readthedocs.io/en/stable/python/python_api.html#xgboost.XGBRegressor + why: XGBRegressor sklearn-API constructor — n_estimators, learning_rate, max_depth, + random_state, n_jobs, tree_method, verbosity. fit(X, y) / predict(X) are + sklearn-compatible. `missing` defaults to np.nan (native NaN handling). + +- url: https://xgboost.readthedocs.io/en/stable/faq.html + section: "Slightly different result between runs" + critical: XGBoost has NO `deterministic=True` switch (unlike LightGBM). Single-machine + bit-reproducibility comes from `n_jobs=1` + a fixed `random_state` + no stochastic + sampling (conservative config has no subsample/colsample, so this holds) + + `tree_method="hist"` (the default; pin it explicitly). Multi-threaded fits differ + by float-summation order. Reproducibility is promised only within the SAME + hardware+build — fine for CI and the determinism unit test. + +- url: https://xgboost.readthedocs.io/en/stable/parameter.html + section: Parameters for Tree Booster / Global Configuration + why: max_depth, eta(=learning_rate), tree_method, verbosity semantics and ranges. +``` + +### Current Codebase tree (relevant — all already exist) + +```bash +app/features/forecasting/ +├── models.py # BaseForecaster, RegressionForecaster, LightGBMForecaster, +│ # model_factory (lightgbm branch is the template), ModelType +├── schemas.py # LightGBMModelConfig (the template), ModelConfig union +├── service.py # train_model + predict branch on requires_features (untouched) +├── persistence.py # ModelBundle (python/sklearn/lightgbm_version) +├── routes.py # /forecasting/train has the lightgbm flag gate (lines 67-72) +└── tests/ + ├── test_lightgbm_forecaster.py # the test template to clone + ├── test_regression_forecaster.py # the fuller 10-test template + ├── test_service.py # TestFeatureAwareContract + ├── test_routes.py + ├── test_persistence.py + └── test_regression_features_leakage.py # load-bearing — already covers XGBoost's frame +app/core/config.py # forecast_enable_lightgbm at line 101 +app/features/scenarios/service.py # model_exogenous dispatch on requires_features (untouched) +app/features/backtesting/service.py # feature-aware fold loop, requires_features probe (untouched) +app/features/jobs/service.py # _execute_train + _execute_backtest model_type chains +app/features/registry/service.py # _capture_runtime_info (lightgbm block at 124-129) +app/shared/feature_frames/ # the shared contract — reused, untouched +examples/models/advanced_lightgbm.py # the example template +pyproject.toml # ml-lightgbm extra + lightgbm.* mypy override +.github/workflows/ci.yml # uv sync --frozen --all-extras --dev (no change) +``` + +### Desired Codebase tree — files to ADD + +```bash +app/features/forecasting/tests/ +└── test_xgboost_forecaster.py # cloned from test_lightgbm_forecaster.py, importorskip +examples/models/ +└── advanced_xgboost.py # minimal XGBoost train/predict example +``` + +### Files to MODIFY (all additive or behaviour-preserving) + +```bash +pyproject.toml # + [project.optional-dependencies] ml-xgboost + # + (only if mypy --strict complains) xgboost.* override +uv.lock # regenerated by `uv lock` +app/core/config.py # + forecast_enable_xgboost: bool = False +app/features/forecasting/schemas.py # + XGBoostModelConfig; + to ModelConfig union +app/features/forecasting/models.py # + XGBoostForecaster; + "xgboost" in ModelType; + # + model_factory xgboost branch +app/features/forecasting/persistence.py # + ModelBundle.xgboost_version (save + load) +app/features/forecasting/routes.py # + xgboost flag gate +app/features/jobs/service.py # _execute_train + _execute_backtest: + xgboost branch +app/features/registry/service.py # _capture_runtime_info: + xgboost block +app/features/forecasting/tests/test_service.py # extend TestFeatureAwareContract +app/features/forecasting/tests/test_routes.py # + xgboost 400-when-disabled route test +app/features/forecasting/tests/test_persistence.py # + xgboost_version captured assertion +app/features/jobs/tests/test_service.py # + xgboost train + backtest job tests +app/features/scenarios/tests/test_routes_integration.py # + xgboost model_exogenous test +app/features/backtesting/tests/test_feature_aware_backtest.py # + light xgboost backtest test +app/features/registry/tests/test_service.py # + runtime_info has xgboost_version +examples/models/model_interface.md # additive: xgboost row +examples/models/feature_frame_contract.md # additive: xgboost is a feature-aware model +README.md # additive: the ml-xgboost optional extra +``` + +### DECISIONS LOCKED (resolved during planning — do NOT re-litigate) + +1. **C1 (XGBoost) and C2 (Prophet-like) are separate PRPs, branches, and review units.** + `INITIAL-MLZOO-C` describes both; the MLZOO index now lists them as two rows. This PRP + touches **only** XGBoost. If you find yourself adding a Prophet-like model, stop — that + is `PRPs/PRP-MLZOO-C2-prophet-like-additive-model.md`. (User-confirmed.) + +2. **XGBoost ships as an optional dependency group, not a core dependency.** A new + `[project.optional-dependencies] ml-xgboost = ["xgboost>=2.1.0"]`. Rationale: mirrors + the merged `ml-lightgbm` decision (PRP-30 DECISIONS LOCKED #1); the single-host vision + keeps the core install dependency-light; `INITIAL-MLZOO-index.md` mandates dependency + groups (`ml-xgboost` is named there). CI's `--all-extras` installs and tests it. (User- + confirmed.) + +3. **The `xgboost` import is LAZY — inside `fit()`, never at module scope.** `models.py` + is imported by every forecasting code path (baseline models included); a module-level + `import xgboost` would make every forecast path require the optional extra. Mirror + `LightGBMForecaster` exactly: `model_factory` and `XGBoostForecaster.__init__` only + store parameters; `import xgboost` happens the first time `fit()` runs. + `requires_features` is a `ClassVar` → readable with no import. + +4. **`XGBoostModelConfig` is CONSERVATIVE — `n_estimators` / `max_depth` / + `learning_rate` / `feature_config_hash` only.** It mirrors `LightGBMModelConfig` + (PRP-30 DECISIONS LOCKED #3). `subsample` / `colsample_bytree` / `reg_alpha` / + `reg_lambda` (named in `docs/optional-features/05-advanced-ml-model-zoo.md`) are a + deliberate future-PRP extension — adding them now widens the schema surface for no MVP + value, AND `subsample`/`colsample_bytree` < 1.0 introduce stochastic row/column + sampling that complicates the determinism guarantee. The forecaster uses XGBoost + defaults for every parameter not in the config (so `subsample`/`colsample_bytree` stay + at 1.0 → no stochastic sampling). (User-confirmed: "Conservative (match LightGBM)".) + +5. **Backtesting needs NO backtesting-service change.** Unlike PRP-30 (which deferred + backtesting to B.2), B.2 is merged: `backtesting/service.py` probes + `requires_features` and is fully model-agnostic. An XGBoost model backtests for free. + This PRP only adds the `xgboost` branch to `JobService._execute_backtest` (the job + layer still maps a `model_type` string → a config object). + +6. **No new leakage test.** XGBoost reuses `_build_regression_features` / + `_assemble_regression_rows` (historical frame) and the shared `app/shared/feature_frames` + builders (future + per-fold frames) byte-for-byte. Those are pinned by the load-bearing + `test_regression_features_leakage.py` and `app/shared/feature_frames/tests/test_leakage.py`. + XGBoost is leakage-covered by construction; a duplicate XGBoost-flavoured leakage test + would test the same code twice. State the reuse explicitly in the PR description. + +7. **Determinism: `n_jobs=1` + `tree_method="hist"` + fixed `random_state` + conservative + config (no subsample/colsample).** XGBoost has no `deterministic`/`force_col_wise` + switch (LightGBM does). Single-threaded `hist` with a fixed seed and no stochastic + sampling is bit-reproducible within one hardware+build environment — which is exactly + the determinism unit test's scope. Keep `np.testing.assert_array_equal` (the repo + idiom). See Gotchas for the residual-risk note. + +8. **`POST /forecasting/predict` is NOT changed.** An XGBoost model is feature-aware + (`requires_features=True`) and is rejected by the existing capability-based predict + branch — identical to `regression`/`lightgbm`. It forecasts through + `POST /scenarios/simulate`. + +### Known Gotchas of our codebase & Library Quirks + +```python +# CRITICAL: lazy import. `import xgboost` goes INSIDE XGBoostForecaster.fit(), not at the +# top of models.py and not in __init__. models.py is imported for naive/seasonal/mavg/ +# regression/lightgbm too; a module-level xgboost import would make every forecast path +# require the optional extra. Mirror LightGBMForecaster.fit (models.py:657-659). + +# CRITICAL: determinism. XGBoost has NO `deterministic=True` flag. Pin n_jobs=1 + +# tree_method="hist" + a fixed random_state, and rely on the conservative config leaving +# subsample/colsample_bytree at their 1.0 defaults (no stochastic sampling). Single- +# threaded `hist` is bit-reproducible within one environment — which is the +# test_determinism_same_random_state scope. Keep np.testing.assert_array_equal. +# IF (and only if) that test proves genuinely flaky in CI across runs on the SAME +# environment, that is a real signal — investigate the XGBoost build, do NOT paper over +# it by switching to assert_allclose. (Cross-environment bit-equality is never promised +# and is not what the test checks.) + +# GOTCHA: mypy --strict + warn_unused_ignores=true. xgboost ships a py.typed marker, so +# `import xgboost` resolves WITHOUT an override in most cases. Start WITHOUT a +# [[tool.mypy.overrides]] xgboost.* block. ONLY if `uv run mypy app/` flags xgboost.* +# internals, add `module = ["xgboost.*"] ignore_missing_imports = true` (mirroring the +# lightgbm.* override at pyproject.toml:150-152). Do NOT add both an override AND an +# inline `# type: ignore` — warn_unused_ignores would flag the redundant one. Type the +# estimator `Any` (mirror `estimator: Any = lgb.LGBMRegressor(...)` at models.py:661). + +# GOTCHA: pyright --strict excludes tests/ but scans app/. With ml-xgboost installed +# (CI: --all-extras; locally: Validation Level 0) pyright resolves `import xgboost`. +# reportUnknownMemberType is already "warning" (pyproject:177) so dynamic XGBRegressor +# attribute access does not fail the gate. + +# GOTCHA: uv.lock + --frozen. CI installs with `uv sync --frozen` — `--frozen` REFUSES to +# update the lockfile. After editing pyproject.toml you MUST run `uv lock` and commit the +# refreshed uv.lock, or every CI job fails at the install step. + +# GOTCHA: tests must not hard-require the optional dep. test_xgboost_forecaster.py starts +# with `pytest.importorskip("xgboost")` so a dev who ran `uv sync --extra dev` (no +# ml-xgboost) sees the suite SKIP, not ERROR. CI installs --all-extras so it RUNS there. + +# GOTCHA: loading an XGBoost bundle requires the ml-xgboost extra. joblib.load unpickles +# the embedded XGBRegressor, which needs `xgboost` importable. Inherent to an optional +# ML dependency — document it; do not engineer around it. + +# GOTCHA: silence training output with `verbosity=0` in the XGBRegressor constructor +# (default is 1 = warnings). `verbose` is a fit() arg for eval-set printing, not a +# constructor param — not needed here (no eval_set). + +# GOTCHA: line endings — repo has mixed CRLF/LF, no .gitattributes. Run `git diff --stat` +# before committing; if a modified file shows a whole-file diff, re-normalise to its +# original ending so the review shows only the real change. + +# SIBLING-PRP integration: PRP-MLZOO-C2 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 C2 merged first you will see its +# "prophet_like" entry already present — just add "xgboost" alongside. A trivial +# one-line rebase, never a semantic conflict. +``` + +--- + +## Implementation Blueprint + +### Data models and structure + +No ORM model, no migration. One new Pydantic schema and one new forecaster class: + +```python +# app/features/forecasting/schemas.py — mirrors LightGBMModelConfig (schemas.py:107-144) + +class XGBoostModelConfig(ModelConfigBase): + """Configuration for the XGBoost regressor (feature-flagged). + + XGBoost is an advanced, feature-aware gradient-boosted-tree model. Like + ``LightGBMModelConfig`` the field set is deliberately conservative — + ``n_estimators`` / ``max_depth`` / ``learning_rate`` only — so the schema + surface stays small and training stays deterministic (no stochastic + subsampling). Only available when ``forecast_enable_xgboost=True``. + """ + + model_type: Literal["xgboost"] = "xgboost" + n_estimators: int = Field(default=100, ge=10, le=1000, description="Number of boosting rounds") + max_depth: int = Field(default=6, ge=1, le=20, description="Maximum depth of trees") + learning_rate: float = Field( + default=0.1, ge=0.001, le=1.0, description="Learning rate for gradient boosting" + ) + feature_config_hash: str | None = Field( + default=None, description="Hash of FeatureSetConfig used for training" + ) + + +# app/features/forecasting/models.py — mirrors LightGBMForecaster (models.py:580-732) + +class XGBoostForecaster(BaseForecaster): + """Feature-aware forecaster wrapping ``xgboost.XGBRegressor``. + + The second ADVANCED feature-aware tree model (MLZOO-C1). Structurally a + twin of ``LightGBMForecaster``: REQUIRES a non-``None`` exogenous ``X`` for + both ``fit`` and ``predict``; ``xgboost`` is imported LAZILY inside ``fit``. + + Determinism: ``XGBRegressor`` has no ``deterministic`` switch — bit- + reproducibility comes from ``n_jobs=1`` + ``tree_method="hist"`` + a fixed + ``random_state`` + the conservative config leaving ``subsample`` / + ``colsample_bytree`` at 1.0 (no stochastic sampling). XGBoost tolerates + ``NaN`` natively (``missing=np.nan``). + """ + + requires_features: ClassVar[bool] = True + + def __init__( + self, *, n_estimators: int = 100, learning_rate: float = 0.1, + max_depth: int = 6, random_state: int = 42, + ) -> None: + super().__init__(random_state) + self.n_estimators = n_estimators + self.learning_rate = learning_rate + self.max_depth = max_depth + self._estimator: Any = None +``` + +### list of tasks (dependency-ordered) + +```yaml +# ════════ STEP 1 — Optional dependency + runtime flag ════════ + +Task 1 — MODIFY pyproject.toml + regenerate uv.lock: + - ADD under [project.optional-dependencies], after the `ml-lightgbm` line (pyproject:47): + # Opt-in advanced forecasting model (MLZOO-C1). Same optional-extra + # pattern as ml-lightgbm; CI installs it via --all-extras. + ml-xgboost = ["xgboost>=2.1.0"] + - DO NOT add a [[tool.mypy.overrides]] xgboost.* block yet — xgboost ships py.typed. + Add it ONLY if Validation Level 2 (mypy --strict) complains about xgboost.*. + - RUN `uv lock` to refresh uv.lock (CI uses `uv sync --frozen`). + - RUN `uv sync --extra dev --extra ml-lightgbm --extra ml-xgboost` locally. + - VALIDATE: uv run python -c "import xgboost; print(xgboost.__version__)" + +Task 2 — MODIFY app/core/config.py — add the runtime flag: + - ADD after `forecast_enable_lightgbm: bool = False` (config.py:101): + forecast_enable_xgboost: bool = False + - Mirror the surrounding comment style of the Forecasting settings block. + - VALIDATE: uv run python -c "from app.core.config import get_settings; \ + print(get_settings().forecast_enable_xgboost)" + +# ════════ STEP 2 — Schema ════════ + +Task 3 — MODIFY app/features/forecasting/schemas.py — ADD XGBoostModelConfig: + - PLACE the new class immediately AFTER LightGBMModelConfig (after schemas.py:144), + BEFORE RegressionModelConfig. + - MIRROR LightGBMModelConfig field-for-field (see Data models above). + - ADD `XGBoostModelConfig` to the ModelConfig union (schemas.py:192-199), e.g. between + LightGBMModelConfig and RegressionModelConfig. + - VALIDATE: uv run mypy app/features/forecasting/schemas.py + +# ════════ STEP 3 — The forecaster + factory ════════ + +Task 4 — MODIFY app/features/forecasting/models.py — ADD XGBoostForecaster: + - PLACE the new class immediately AFTER LightGBMForecaster (after models.py:732), + BEFORE the `ModelType` alias (models.py:736). + - MIRROR LightGBMForecaster byte-for-byte: __init__ shape, fit guards (X is None -> + ValueError "XGBoostForecaster 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 is None -> ValueError + "XGBoostForecaster requires exogenous features X for predict()"; shape mismatch -> + f"X has {X.shape[0]} rows but horizon is {horizon} — they must match"), + get_params, set_params. + - INSIDE fit(): `import xgboost as xgb` (LAZY), then + `estimator: Any = xgb.XGBRegressor(n_estimators=self.n_estimators, + learning_rate=self.learning_rate, max_depth=self.max_depth, + random_state=self.random_state, n_jobs=1, tree_method="hist", verbosity=0)`; + `estimator.fit(X, y)`. + - set requires_features: ClassVar[bool] = True. + - get_params returns {n_estimators, learning_rate, max_depth, random_state}. + - PRESERVE the error-message substrings EXACTLY — the cloned tests `match=` on them. + - VALIDATE: uv run mypy app/features/forecasting/models.py && uv run pyright app/features/forecasting/ + +Task 5 — MODIFY app/features/forecasting/models.py — ModelType literal + model_factory: + - ADD "xgboost" to the ModelType Literal (models.py:736): + ModelType = Literal["naive", "seasonal_naive", "moving_average", "xgboost", + "lightgbm", "regression"] + - ADD an `elif model_type == "xgboost":` branch to model_factory, mirroring the + lightgbm branch (models.py:778-792) — gate FIRST on forecast_enable_xgboost: + elif model_type == "xgboost": + if not settings.forecast_enable_xgboost: + raise ValueError( + "XGBoost is not enabled. Set forecast_enable_xgboost=True in settings." + ) + from app.features.forecasting.schemas import XGBoostModelConfig + if isinstance(config, XGBoostModelConfig): + return XGBoostForecaster( + n_estimators=config.n_estimators, + learning_rate=config.learning_rate, + max_depth=config.max_depth, + random_state=random_state, + ) + raise ValueError("Invalid config type for xgboost") + - VALIDATE: uv run mypy app/ && uv run pyright app/ + +# ════════ STEP 4 — Route gate ════════ + +Task 6 — MODIFY app/features/forecasting/routes.py — add the xgboost flag gate: + - ADD, immediately after the lightgbm gate (routes.py:67-72), a parallel gate: + if request.config.model_type == "xgboost" and not settings.forecast_enable_xgboost: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="XGBoost is disabled. Set forecast_enable_xgboost=True in settings.", + ) + - VALIDATE: uv run mypy app/features/forecasting/ && uv run pyright app/features/forecasting/ + +# ════════ STEP 5 — Jobs integration ════════ + +Task 7 — MODIFY app/features/jobs/service.py — _execute_train + _execute_backtest: + - ADD `XGBoostModelConfig` to the forecasting-schemas import block (jobs/service.py:426-433). + - ADD an xgboost branch to the _execute_train if/elif chain (jobs/service.py:454-478), + BEFORE the final `else`, mirroring the lightgbm branch: + elif model_type == "xgboost": + # forecast_enable_xgboost gate lives in model_factory — a disabled + # flag surfaces as a loud failed job. + config = XGBoostModelConfig( + n_estimators=params.get("n_estimators", 100), + learning_rate=params.get("learning_rate", 0.1), + max_depth=params.get("max_depth", 6), + ) + - ADD an xgboost branch to the _execute_backtest if/elif chain (jobs/service.py:641-658), + mirroring its lightgbm branch: + elif model_type == "xgboost": + # Feature-aware — the backtest builds per-fold leakage-safe X. + model_config = XGBoostModelConfig() + - VALIDATE: uv run mypy app/features/jobs/ && uv run pyright app/features/jobs/ + +# ════════ STEP 6 — Reproducibility metadata ════════ + +Task 8 — MODIFY app/features/forecasting/persistence.py — ModelBundle.xgboost_version: + - ADD field to ModelBundle (after `lightgbm_version: str | None = None`, persistence.py:56): + xgboost_version: str | None = None + - UPDATE the ModelBundle docstring Attributes block to mention xgboost_version (mirror + the lightgbm_version wording at persistence.py:43-44). + - In save_model_bundle, AFTER the lightgbm best-effort capture (persistence.py:102-108), + ADD an identical block: + try: + import xgboost + bundle.xgboost_version = str(xgboost.__version__) + except ImportError: + bundle.xgboost_version = None + - In load_model_bundle, AFTER the lightgbm mismatch-warning block (persistence.py:185-199), + ADD an identical block logging `forecasting.xgboost_version_mismatch` (saved vs + current) only when both are non-None and differ; guard the current-version lookup + in try/except ImportError. + - compute_hash (persistence.py:59-72) is unchanged — confirm no bundle hash shifts. + - VALIDATE: uv run mypy app/features/forecasting/ && uv run pyright app/features/forecasting/ + +Task 9 — MODIFY app/features/registry/service.py — _capture_runtime_info: + - ADD, after the lightgbm block (registry/service.py:124-129): + # XGBoost is an optional dependency — only recorded when installed. + try: + import xgboost + runtime_info["xgboost_version"] = xgboost.__version__ + except ImportError: + pass + - VALIDATE: uv run mypy app/features/registry/ && uv run pyright app/features/registry/ + +# ════════ STEP 7 — Tests ════════ + +Task 10 — CREATE app/features/forecasting/tests/test_xgboost_forecaster.py: + - CLONE test_lightgbm_forecaster.py 1:1. Module-scope `pytest.importorskip("xgboost")`. + - Swap LightGBMForecaster -> XGBoostForecaster, LightGBMModelConfig -> XGBoostModelConfig, + forecast_enable_lightgbm -> forecast_enable_xgboost throughout. + - COPY the `_synthetic_data` helper verbatim. + - Keep test_determinism_same_random_state with np.testing.assert_array_equal. + - VALIDATE: uv run pytest -v app/features/forecasting/tests/test_xgboost_forecaster.py + +Task 11 — MODIFY app/features/forecasting/tests/test_service.py: + - In TestFeatureAwareContract.test_requires_features_flag, ADD: + from app.features.forecasting.models import XGBoostForecaster + assert XGBoostForecaster.requires_features is True + - ADD test_xgboost_factory_respects_flag mirroring test_lightgbm_factory_respects_flag + (flag off -> ValueError "not enabled"; flag on -> isinstance XGBoostForecaster). + - VALIDATE: uv run pytest -v -m "not integration" app/features/forecasting/tests/test_service.py + +Task 12 — MODIFY app/features/forecasting/tests/test_routes.py: + - ADD test_train_xgboost_rejected_when_disabled: POST /forecasting/train with + config={"model_type":"xgboost"} and forecast_enable_xgboost at its default (False) + -> 400, problem+json detail mentioning XGBoost disabled. Mirror the lightgbm route + test if one exists; otherwise follow the file's ASGITransport client fixture idiom. + - VALIDATE: uv run pytest -v app/features/forecasting/tests/test_routes.py + +Task 13 — MODIFY app/features/jobs/tests/test_service.py: + - ADD test_execute_train_builds_xgboost_config mirroring + test_execute_train_builds_lightgbm_config (lines 222-241). + - ADD test_execute_backtest_builds_xgboost_config mirroring + test_execute_backtest_builds_lightgbm_config (lines 286-304). + - The rejects-unsupported test (lines 243-249) already uses "arima" — DO NOT touch it. + - VALIDATE: uv run pytest -v app/features/jobs/tests/test_service.py + +Task 14 — MODIFY app/features/forecasting/tests/test_persistence.py: + - ADD test_xgboost_version_recorded: after `pytest.importorskip("xgboost")`, save a + ModelBundle and assert `bundle.xgboost_version` is a non-empty str. + - VALIDATE: uv run pytest -v -m "not integration" app/features/forecasting/tests/test_persistence.py + +Task 15 — MODIFY app/features/scenarios/tests/test_routes_integration.py: + - ADD an integration test that trains an `xgboost` model then POSTs /scenarios/simulate + with its run_id and asserts the response `method == "model_exogenous"`. Mirror the + existing lightgbm/regression model_exogenous test; gate with + `pytest.importorskip("xgboost")` and enable forecast_enable_xgboost. + - VALIDATE: uv run pytest -v -m integration app/features/scenarios/tests/test_routes_integration.py + +Task 16 — MODIFY app/features/backtesting/tests/test_feature_aware_backtest.py: + - ADD a light test that runs the feature-aware backtest with an XGBoostModelConfig and + asserts per-fold metrics + `feature_aware=True` — mirroring + test_feature_aware_backtest_produces_per_fold_metrics. Gate with + `pytest.importorskip("xgboost")` and enable forecast_enable_xgboost. This satisfies + INITIAL-MLZOO-B's "backtesting integration test comparing baseline and advanced + model path" for the XGBoost model. + - VALIDATE: uv run pytest -v app/features/backtesting/tests/test_feature_aware_backtest.py + +Task 17 — MODIFY app/features/registry/tests/test_service.py: + - ADD/extend a runtime_info test: with `pytest.importorskip("xgboost")` a created run's + runtime_info contains the `xgboost_version` key. Mirror the lightgbm assertion. + - VALIDATE: uv run pytest -v app/features/registry/tests/test_service.py + +# ════════ STEP 8 — Docs & example ════════ + +Task 18 — CREATE examples/models/advanced_xgboost.py: + - CLONE examples/models/advanced_lightgbm.py, swapping LightGBMForecaster -> + XGBoostForecaster and the docstring/install line (`--extra ml-xgboost`). + - VALIDATE: uv run python examples/models/advanced_xgboost.py (requires ml-xgboost) + +Task 19 — MODIFY examples/models/model_interface.md + feature_frame_contract.md: + - model_interface.md: ADDITIVE — add an XGBoostModelConfig entry under "## Model + Configurations" and an "### XGBoost Forecaster" entry under "## Model Formulas"; + note requires_features=True and the ml-xgboost optional extra. + - feature_frame_contract.md: ADDITIVE — record XGBoost as an IMPLEMENTED feature-aware + model in the relevant sentence/list. Do NOT rewrite the file. + - VALIDATE: uv run ruff check . && uv run ruff format --check . + +Task 20 — MODIFY README.md: + - ADDITIVE: extend the install-section opt-in note and the Supported Model Types list + (README.md:344 area) — `xgboost` is an opt-in model installed via + `uv sync --extra dev --extra ml-xgboost` and enabled with + `forecast_enable_xgboost=true`. Mirror the existing ml-lightgbm wording. + - VALIDATE: uv run ruff format --check . (README is markdown — visual check only) +``` + +### Per-task pseudocode (critical details only) + +```python +# ── Task 4 — XGBoostForecaster.fit (lazy import + determinism is the crux) ── +def fit(self, y, X=None): + if X is None: + raise ValueError("XGBoostForecaster 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" + ) + import xgboost as xgb # LAZY — optional dependency; never module-scope + estimator: Any = xgb.XGBRegressor( + n_estimators=self.n_estimators, + learning_rate=self.learning_rate, + max_depth=self.max_depth, + random_state=self.random_state, + n_jobs=1, # single-threaded — removes float-summation + # non-determinism (XGBoost has no `deterministic`) + tree_method="hist", # explicit; the default, and the reproducible path + verbosity=0, # silence XGBoost training chatter + ) + estimator.fit(X, y) # NaN in X is fine — missing=np.nan is the default + self._estimator = estimator + self._last_values = np.asarray(y[-1:], dtype=np.float64) + self._is_fitted = True + return self + +# predict() is byte-identical to LightGBMForecaster.predict (models.py:677-706), +# only the error-string prefix changes: "XGBoostForecaster requires exogenous features ...". +``` + +### Integration Points + +```yaml +DEPENDENCY: + - pyproject.toml: + [project.optional-dependencies] ml-xgboost = ["xgboost>=2.1.0"]. + - uv.lock: regenerated by `uv lock` (CI installs with --frozen). + - CI: NO workflow change — ci.yml already runs `uv sync --frozen --all-extras --dev`. + +CONFIG: + - app/core/config.py: + forecast_enable_xgboost: bool = False (the runtime gate). + - forecast_random_seed (config.py:97) is the determinism source threaded through + model_factory — UNCHANGED. + +TRAIN / PREDICT / SCENARIOS / BACKTESTING: + - ForecastingService.train_model, ForecastingService.predict, + scenarios/service.py, backtesting/service.py — ALL UNCHANGED. Each branches on + `requires_features`; an XGBoost model (requires_features=True) routes through every + path automatically. + +JOBS: + - jobs/service.py: + xgboost branch in _execute_train AND _execute_backtest (the job + layer maps a model_type string -> a config object — the one place a string compare + still lives by design). + +PERSISTENCE / REGISTRY: + - ModelBundle: + xgboost_version field (best-effort on save, mismatch-warn on load). + compute_hash unchanged -> no bundle hash shifts. + - runtime_info JSONB: + "xgboost_version" key when xgboost is importable. NO migration. + +NO MIGRATION: this PRP touches no SQLAlchemy model and no Alembic version. +NO API CONTRACT CHANGE: no route path, response schema, or WebSocket frame changes + (a new request-body `model_type` value is an additive, pre-1.0-permitted change). +``` + +--- + +## Validation Loop + +### Level 0: Environment + +```bash +uv lock # refresh lock after pyproject edit +uv sync --extra dev --extra ml-lightgbm --extra ml-xgboost +uv run python -c "import xgboost; print('xgboost', xgboost.__version__)" +# Expected: prints a 2.x/3.x version. Without this, mypy/pyright on the lazy import and +# the XGBoost tests cannot run locally (CI installs --all-extras automatically). +``` + +### Level 1: Syntax & Style + +```bash +uv run ruff check . --fix && uv run ruff format --check . +# Expected: no errors. Fix everything before Level 2. +``` + +### Level 2: Type Checks + +```bash +uv run mypy app/ # --strict; gates merge +uv run pyright app/ # --strict; gates merge +# If mypy flags xgboost.* internals, add the [[tool.mypy.overrides]] xgboost.* block +# (see Task 1). Do NOT add both an override and an inline `# type: ignore`. +``` + +### Level 3: Unit Tests + +```bash +uv run pytest -v app/features/forecasting/tests/test_xgboost_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 — these must stay green with NO behaviour change +uv run pytest -v -m "not integration" app/features/forecasting/tests/ +uv run pytest -v -m "not integration" app/features/backtesting/tests/ +uv run pytest -v -m "not integration" # whole fast suite +# Expected: all green. Every baseline / regression / lightgbm test passes UNEDITED. +# If xgboost is somehow absent, test_xgboost_forecaster.py SKIPS — never ERRORs. +``` + +### 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/ app/features/registry/ +# CRITICAL: the scenarios xgboost model_exogenous test (Task 15) must report +# method="model_exogenous". No migration in this PRP. +``` + +### Level 5: Manual Validation (dogfood — REQUIRED) + +```bash +# 1. Determinism +uv run python -c " +import numpy as np +from app.features.forecasting.models import XGBoostForecaster +rng = np.random.default_rng(0) +X = rng.normal(size=(80, 14)); y = (3.0 * X[:, 0] + rng.normal(size=80)).astype(np.float64) +a = XGBoostForecaster(random_state=7).fit(y, X).predict(12, X[:12]) +b = XGBoostForecaster(random_state=7).fit(y, X).predict(12, X[:12]) +np.testing.assert_array_equal(a, b); print('xgboost deterministic OK', a[:3])" + +# 2. requires_features +uv run python -c " +from app.features.forecasting.models import XGBoostForecaster +assert XGBoostForecaster.requires_features is True; print('requires_features OK')" + +# 3. End-to-end: set FORECAST_ENABLE_XGBOOST=true in .env, restart uvicorn, then +# POST /forecasting/train with config {"model_type":"xgboost"} -> 200; take the run_id +# and POST /scenarios/simulate -> ScenarioComparison "method" == "model_exogenous"; +# submit an xgboost backtest job -> completes with per-fold metrics. + +# 4. The optional dep stays optional — in a venv WITHOUT ml-xgboost, training a naive +# model still succeeds and `import app.features.forecasting.models` does not raise. +``` + +--- + +## 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_xgboost_forecaster.py` + runs (xgboost installed) and passes — never ERRORs. +- [ ] `uv run pytest -v -m integration app/features/{forecasting,scenarios,jobs,registry}/` + green, including the scenarios `xgboost` `model_exogenous` test. +- [ ] `model_factory(XGBoostModelConfig())` returns an `XGBoostForecaster` with the flag + on, raises a clear `ValueError` with the flag off. +- [ ] An `xgboost` backtest produces per-fold metrics with **no edit to + `backtesting/service.py`**. +- [ ] Every baseline / `regression` / `lightgbm` test passes with **no edit**. +- [ ] `uv.lock` is regenerated and committed; the core `[project] dependencies` list is + UNCHANGED (XGBoost is only in `[project.optional-dependencies]`). +- [ ] No Alembic migration; 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 XGBoost feature-aware forecasting model (#)`; branch + `feat/forecasting-xgboost-model` off `dev`. +- [ ] The PR description states C1 is one of two MLZOO-C review units and links the + sibling `PRP-MLZOO-C2`. + +--- + +## Anti-Patterns to Avoid + +- ❌ Don't implement the Prophet-like model — that is `PRP-MLZOO-C2`, a separate branch. +- ❌ Don't combine C1 and C2 into one branch or one PR (DECISIONS LOCKED #1). +- ❌ Don't add hyperparameter search, portfolio/global models, or an explainability change. +- ❌ Don't add `xgboost` to the core `[project] dependencies` — it is an OPTIONAL extra. + Don't `import xgboost` at module scope — lazy-import inside `fit()`. +- ❌ Don't add `subsample` / `colsample_bytree` / `reg_alpha` / `reg_lambda` to + `XGBoostModelConfig` — DECISIONS LOCKED #4 keeps it conservative (and stochastic + subsampling would complicate determinism). +- ❌ Don't edit `ForecastingService.train_model` / `predict`, `scenarios/service.py`, or + `backtesting/service.py` — they already branch on `requires_features`. +- ❌ Don't write a new leakage test — XGBoost reuses the already-pinned shared builders. +- ❌ Don't "fix" a determinism-test flake with `assert_allclose` — pin `n_jobs=1` + + `tree_method="hist"` + a fixed `random_state` and keep `assert_array_equal`. A genuine + flake on the same environment is a real signal to investigate, not to silence. +- ❌ Don't forget `uv lock` — CI's `uv sync --frozen` fails on a stale lockfile. +- ❌ Don't make `test_xgboost_forecaster.py` hard-require the extra — `pytest.importorskip`. + +## Open Questions — RESOLVED + +`INITIAL-MLZOO-C`'s open points are resolved for the XGBoost half: +- **Scope** → XGBoost only; Prophet-like is the sibling PRP-MLZOO-C2 (DECISIONS LOCKED #1). +- **Dependency strategy** → optional `ml-xgboost` extra (#2), mirroring `ml-lightgbm`. +- **Config fields** → conservative, matching `LightGBMModelConfig` (#4). +- **Determinism** → `n_jobs=1` + `tree_method="hist"` + fixed seed + no stochastic + sampling (#7); residual cross-environment non-determinism is documented, not tested. +- **Holiday/regressor features** → already carried as columns in the canonical 14-column + frame (`is_holiday`, `price_factor`, `promo_active`); no XGBoost-specific handling. + +Nothing is left to litigate at implementation time. + +## Confidence Score + +**9 / 10** for one-pass implementation success. + +Rationale: this is the lowest-risk PRP in the MLZOO sequence. The merged `LightGBMForecaster` +(PRP-30) is a *proven, tested* template — `XGBoostForecaster` is a near-mechanical clone +with two library swaps (`lgb.LGBMRegressor` → `xgb.XGBRegressor`, and +`deterministic/force_col_wise` → `tree_method="hist"`). Every consuming path — +train, predict, scenarios, **backtesting** — already branches on `requires_features`, so the +only genuinely new wiring is two `model_factory`/jobs branches and the metadata field. The +−1 risk is XGBoost determinism: unlike LightGBM there is no `deterministic` flag, so +`assert_array_equal` rests on single-threaded `hist` + fixed seed + no stochastic sampling +being reproducible within one environment — which the research confirms it is, and the +conservative config guarantees no subsampling. The risk is caught immediately by the Level 3 +determinism test, and the "every existing test passes unedited" gate makes any accidental +regression impossible to miss. diff --git a/README.md b/README.md index d406b033..2b9af7f8 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,9 @@ docker-compose up -d ```bash uv sync --extra dev # or: pip install -e ".[dev]" -# LightGBM is an opt-in advanced model — add the extra to enable it: +# LightGBM and XGBoost are opt-in advanced models — add the extra to enable each: # uv sync --extra dev --extra ml-lightgbm (then set forecast_enable_lightgbm=true) +# uv sync --extra dev --extra ml-xgboost (then set forecast_enable_xgboost=true) ``` 4. **Run database migrations** @@ -342,6 +343,7 @@ curl -X POST http://localhost:8123/forecasting/predict \ - `moving_average` - Mean of last N observations - `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` See [examples/models/](examples/models/) for baseline model examples. @@ -394,7 +396,7 @@ curl -X POST http://localhost:8123/backtesting/run \ When `include_baselines=true`, automatically compares against naive and seasonal_naive models. **Feature-Aware Models:** -`regression` and `lightgbm` models can be backtested too — set +`regression`, `lightgbm`, and `xgboost` models can be backtested too — set `model_config_main.model_type` accordingly. Each fold builds a leakage-safe per-fold feature matrix (`min_train_size >= 30` required); the result carries `feature_aware: true` and `exogenous_policy: "observed"`. diff --git a/app/core/config.py b/app/core/config.py index d3ac4a24..b30253d7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -99,6 +99,7 @@ class Settings(BaseSettings): forecast_max_horizon: int = 90 forecast_model_artifacts_dir: str = "./artifacts/models" forecast_enable_lightgbm: bool = False + forecast_enable_xgboost: bool = False # Backtesting backtest_max_splits: int = 20 diff --git a/app/features/backtesting/tests/test_feature_aware_backtest.py b/app/features/backtesting/tests/test_feature_aware_backtest.py index e5281ea6..fda1eee1 100644 --- a/app/features/backtesting/tests/test_feature_aware_backtest.py +++ b/app/features/backtesting/tests/test_feature_aware_backtest.py @@ -25,7 +25,11 @@ SeriesData, ) from app.features.backtesting.splitter import TimeSeriesSplitter -from app.features.forecasting.schemas import NaiveModelConfig, RegressionModelConfig +from app.features.forecasting.schemas import ( + NaiveModelConfig, + RegressionModelConfig, + XGBoostModelConfig, +) from app.shared.feature_frames import canonical_feature_columns _N_FEATURES = len(canonical_feature_columns()) # 14 — 4 lags + 6 calendar + 4 exogenous @@ -135,6 +139,44 @@ def test_feature_aware_backtest_produces_per_fold_metrics( assert "mae" in fold.metrics +def test_feature_aware_backtest_runs_with_xgboost_model( + sample_dates_120: list[date], + sample_values_120: np.ndarray, + sample_split_config_expanding: SplitConfig, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An XGBoost backtest runs end-to-end and yields per-fold metrics. + + Mirrors ``test_feature_aware_backtest_produces_per_fold_metrics`` for the + XGBoost feature-aware model (PRP-MLZOO-C1) — proving the B.2 + ``requires_features`` probe needs no per-model backtesting-service wiring. + SKIPs when the optional ``ml-xgboost`` dependency is absent; the + ``forecast_enable_xgboost`` flag is enabled so ``model_factory`` dispatches. + """ + pytest.importorskip("xgboost") + from app.core.config import get_settings + + monkeypatch.setattr(get_settings(), "forecast_enable_xgboost", True) + + 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=XGBoostModelConfig(), + store_fold_details=True, + ) + + assert result.model_type == "xgboost" + 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 + + 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 aed9828c..9fbf028e 100644 --- a/app/features/forecasting/models.py +++ b/app/features/forecasting/models.py @@ -732,8 +732,166 @@ def set_params(self, **params: Any) -> LightGBMForecaster: # noqa: ANN401 return self +class XGBoostForecaster(BaseForecaster): + """Feature-aware forecaster wrapping ``xgboost.XGBRegressor``. + + The second ADVANCED feature-aware tree model (MLZOO-C1). Structurally a + twin of ``LightGBMForecaster``: it REQUIRES a non-``None`` exogenous ``X`` + for both ``fit`` and ``predict``; the estimator is gradient-boosted trees + from the optional ``xgboost`` package. + + ``xgboost`` is imported LAZILY inside ``fit`` — never at module scope and + never in ``__init__`` — so importing this module (which every forecasting + code path does, baseline models included) never requires the optional + ``ml-xgboost`` dependency. + + Determinism: ``XGBRegressor`` has no ``deterministic`` switch (unlike + LightGBM). Bit-reproducibility comes from ``n_jobs=1`` + ``tree_method="hist"`` + + a fixed ``random_state`` + the conservative config leaving ``subsample`` / + ``colsample_bytree`` at their ``1.0`` defaults (no stochastic sampling) — + all pinned in ``fit``. XGBoost tolerates ``NaN`` natively (``missing=np.nan``), + which matters because the future feature frame leaves lag cells ``NaN`` + when their source target lies in the un-observed horizon. + + Attributes: + n_estimators: Number of boosting rounds. + learning_rate: Gradient-boosting learning rate. + max_depth: Maximum depth of each tree. + """ + + requires_features: ClassVar[bool] = True + """A feature-aware model — ``fit``/``predict`` REQUIRE a non-None ``X``.""" + + def __init__( + self, + *, + n_estimators: int = 100, + learning_rate: float = 0.1, + max_depth: int = 6, + random_state: int = 42, + ) -> None: + """Initialize the XGBoost forecaster. + + Args: + n_estimators: Number of boosting rounds. + learning_rate: Gradient-boosting learning rate. + max_depth: Maximum depth of each tree. + random_state: Random seed for reproducibility (determinism). + """ + super().__init__(random_state) + self.n_estimators = n_estimators + self.learning_rate = learning_rate + self.max_depth = max_depth + 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, + ) -> XGBoostForecaster: + """Fit the gradient-boosted regressor 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("XGBoostForecaster 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" + ) + # LAZY import — the optional ``ml-xgboost`` dependency is only needed + # the first time an XGBoost model is actually fitted. + import xgboost as xgb + + estimator: Any = xgb.XGBRegressor( + n_estimators=self.n_estimators, + learning_rate=self.learning_rate, + max_depth=self.max_depth, + random_state=self.random_state, + n_jobs=1, # single-threaded — removes float-summation non-determinism + tree_method="hist", # explicit; the default, and the reproducible path + verbosity=0, # silence XGBoost's training chatter + ) + 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("XGBoostForecaster 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") + predictions = self._estimator.predict(X) + result: np.ndarray[Any, np.dtype[np.floating[Any]]] = np.asarray( + predictions, dtype=np.float64 + ) + return result + + def get_params(self) -> dict[str, Any]: + """Get model parameters. + + Returns: + Dictionary with n_estimators, learning_rate, max_depth, random_state. + """ + return { + "n_estimators": self.n_estimators, + "learning_rate": self.learning_rate, + "max_depth": self.max_depth, + "random_state": self.random_state, + } + + def set_params(self, **params: Any) -> XGBoostForecaster: # 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", "lightgbm", "regression"] +ModelType = Literal[ + "naive", "seasonal_naive", "moving_average", "xgboost", "lightgbm", "regression" +] def model_factory(config: ModelConfig, random_state: int = 42) -> BaseForecaster: @@ -790,6 +948,21 @@ def model_factory(config: ModelConfig, random_state: int = 42) -> BaseForecaster random_state=random_state, ) raise ValueError("Invalid config type for lightgbm") + elif model_type == "xgboost": + if not settings.forecast_enable_xgboost: + raise ValueError( + "XGBoost is not enabled. Set forecast_enable_xgboost=True in settings." + ) + from app.features.forecasting.schemas import XGBoostModelConfig + + if isinstance(config, XGBoostModelConfig): + return XGBoostForecaster( + n_estimators=config.n_estimators, + learning_rate=config.learning_rate, + max_depth=config.max_depth, + random_state=random_state, + ) + raise ValueError("Invalid config type for xgboost") elif model_type == "regression": from app.features.forecasting.schemas import RegressionModelConfig diff --git a/app/features/forecasting/persistence.py b/app/features/forecasting/persistence.py index 575c23ff..e5b055e1 100644 --- a/app/features/forecasting/persistence.py +++ b/app/features/forecasting/persistence.py @@ -42,6 +42,8 @@ class ModelBundle: sklearn_version: Scikit-learn version used when saving. lightgbm_version: LightGBM version used when saving, ``None`` when the optional ``ml-lightgbm`` dependency was not installed. + xgboost_version: XGBoost version used when saving, ``None`` when the + optional ``ml-xgboost`` dependency was not installed. bundle_hash: Deterministic hash of bundle contents. """ @@ -54,6 +56,7 @@ class ModelBundle: python_version: str | None = None sklearn_version: str | None = None lightgbm_version: str | None = None + xgboost_version: str | None = None bundle_hash: str | None = None def compute_hash(self) -> str: @@ -106,6 +109,14 @@ def save_model_bundle(bundle: ModelBundle, path: str | Path) -> Path: bundle.lightgbm_version = str(lightgbm.__version__) except ImportError: bundle.lightgbm_version = None + # Best-effort: XGBoost is an optional dependency, so a baseline-only + # install legitimately has no version to record. + try: + import xgboost + + bundle.xgboost_version = str(xgboost.__version__) + except ImportError: + bundle.xgboost_version = None bundle.bundle_hash = bundle.compute_hash() # Save with compression @@ -198,6 +209,22 @@ def load_model_bundle(path: str | Path, base_dir: str | Path | None = None) -> M current_lightgbm=current_lightgbm, ) + # XGBoost is optional — only warn when the bundle recorded a version AND + # the optional dependency is importable here AND the two differ. + if bundle.xgboost_version: + try: + import xgboost + + current_xgboost: str | None = str(xgboost.__version__) + except ImportError: + current_xgboost = None + if current_xgboost is not None and bundle.xgboost_version != current_xgboost: + logger.warning( + "forecasting.xgboost_version_mismatch", + saved_xgboost=bundle.xgboost_version, + current_xgboost=current_xgboost, + ) + logger.info( "forecasting.model_bundle_loaded", path=str(path), diff --git a/app/features/forecasting/routes.py b/app/features/forecasting/routes.py index f9fbf007..9d84d003 100644 --- a/app/features/forecasting/routes.py +++ b/app/features/forecasting/routes.py @@ -71,6 +71,13 @@ async def train_model( detail="LightGBM is disabled. Set forecast_enable_lightgbm=True in settings.", ) + # Check if XGBoost is enabled + if request.config.model_type == "xgboost" and not settings.forecast_enable_xgboost: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="XGBoost is disabled. Set forecast_enable_xgboost=True in settings.", + ) + logger.info( "forecasting.train_request_received", store_id=request.store_id, diff --git a/app/features/forecasting/schemas.py b/app/features/forecasting/schemas.py index b019529c..205be6b8 100644 --- a/app/features/forecasting/schemas.py +++ b/app/features/forecasting/schemas.py @@ -144,6 +144,49 @@ class LightGBMModelConfig(ModelConfigBase): ) +class XGBoostModelConfig(ModelConfigBase): + """Configuration for the XGBoost regressor (feature-flagged). + + XGBoost is an advanced, feature-aware gradient-boosted-tree model. Like + ``LightGBMModelConfig`` the field set is deliberately conservative — + ``n_estimators`` / ``max_depth`` / ``learning_rate`` only — so the schema + surface stays small and training stays deterministic (no stochastic + subsampling). + + CRITICAL: Only available when forecast_enable_xgboost=True in settings. + + Attributes: + n_estimators: Number of boosting rounds. + max_depth: Maximum depth of trees. + learning_rate: Learning rate for gradient boosting. + feature_config_hash: Hash of FeatureSetConfig used for training. + """ + + model_type: Literal["xgboost"] = "xgboost" + n_estimators: int = Field( + default=100, + ge=10, + le=1000, + description="Number of boosting rounds", + ) + max_depth: int = Field( + default=6, + ge=1, + le=20, + description="Maximum depth of trees", + ) + learning_rate: float = Field( + default=0.1, + ge=0.001, + le=1.0, + description="Learning rate for gradient boosting", + ) + feature_config_hash: str | None = Field( + default=None, + description="Hash of FeatureSetConfig used for training", + ) + + class RegressionModelConfig(ModelConfigBase): """Configuration for the exogenous-regressor forecaster (PRP-27). @@ -195,6 +238,7 @@ class RegressionModelConfig(ModelConfigBase): | SeasonalNaiveModelConfig | MovingAverageModelConfig | LightGBMModelConfig + | XGBoostModelConfig | RegressionModelConfig ) diff --git a/app/features/forecasting/tests/test_persistence.py b/app/features/forecasting/tests/test_persistence.py index 1f46edf7..9dbae07a 100644 --- a/app/features/forecasting/tests/test_persistence.py +++ b/app/features/forecasting/tests/test_persistence.py @@ -141,6 +141,29 @@ def test_lightgbm_version_recorded( assert isinstance(bundle.lightgbm_version, str) assert bundle.lightgbm_version + def test_xgboost_version_recorded( + self, sample_naive_config, sample_time_series, tmp_model_path + ): + """save_model_bundle records the XGBoost version best-effort (PRP-MLZOO-C1). + + The version is captured regardless of model type whenever the optional + ``ml-xgboost`` dependency is importable — here a baseline naive bundle. + """ + pytest.importorskip("xgboost") + model = NaiveForecaster() + model.fit(sample_time_series) + + bundle = ModelBundle( + model=model, + config=sample_naive_config, + metadata={}, + ) + + save_model_bundle(bundle, tmp_model_path) + + assert isinstance(bundle.xgboost_version, str) + assert bundle.xgboost_version + def test_save_creates_directory(self, sample_naive_config, sample_time_series): """Test that save creates parent directories if needed.""" with TemporaryDirectory() as tmpdir: diff --git a/app/features/forecasting/tests/test_routes.py b/app/features/forecasting/tests/test_routes.py index e04365fd..42939637 100644 --- a/app/features/forecasting/tests/test_routes.py +++ b/app/features/forecasting/tests/test_routes.py @@ -79,6 +79,25 @@ async def test_train_lightgbm_rejected_when_disabled(client: AsyncClient) -> Non assert "lightgbm" in response.text.lower() +@pytest.mark.integration +async def test_train_xgboost_rejected_when_disabled(client: AsyncClient) -> None: + """XGBoost training is refused with 400 while the feature flag is off. + + ``forecast_enable_xgboost`` defaults to ``False``; the route gate returns a + 400 before any DB or model work (PRP-MLZOO-C1). + """ + payload = { + "store_id": 1, + "product_id": 2, + "train_start_date": "2024-01-01", + "train_end_date": "2024-01-31", + "config": {"model_type": "xgboost"}, + } + response = await client.post("/forecasting/train", json=payload) + assert response.status_code == 400 + assert "xgboost" in response.text.lower() + + @pytest.mark.integration async def test_predict_accepts_request(client: AsyncClient) -> None: # PredictRequest has no date fields; this is a smoke test for completeness diff --git a/app/features/forecasting/tests/test_service.py b/app/features/forecasting/tests/test_service.py index 921fb0df..403a991c 100644 --- a/app/features/forecasting/tests/test_service.py +++ b/app/features/forecasting/tests/test_service.py @@ -351,7 +351,7 @@ class TestFeatureAwareContract: def test_requires_features_flag(self): """Baseline forecasters require no features; feature-aware ones do.""" - from app.features.forecasting.models import LightGBMForecaster + from app.features.forecasting.models import LightGBMForecaster, XGBoostForecaster from app.features.forecasting.schemas import RegressionModelConfig assert model_factory(NaiveModelConfig()).requires_features is False @@ -361,6 +361,8 @@ def test_requires_features_flag(self): # 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 + # XGBoost is feature-aware too — same import-free ClassVar assertion. + assert XGBoostForecaster.requires_features is True def test_lightgbm_factory_respects_flag(self): """model_factory gates LightGBM behind forecast_enable_lightgbm. @@ -385,6 +387,29 @@ def test_lightgbm_factory_respects_flag(self): model = model_factory(LightGBMModelConfig()) assert isinstance(model, LightGBMForecaster) + def test_xgboost_factory_respects_flag(self): + """model_factory gates XGBoost behind forecast_enable_xgboost. + + Construction is flag-gated but import-free (``xgboost`` is imported + lazily inside ``fit``), so neither branch needs the optional extra. + """ + from app.features.forecasting.models import XGBoostForecaster + from app.features.forecasting.schemas import XGBoostModelConfig + + disabled = MagicMock() + disabled.forecast_enable_xgboost = False + with ( + patch("app.core.config.get_settings", return_value=disabled), + pytest.raises(ValueError, match="not enabled"), + ): + model_factory(XGBoostModelConfig()) + + enabled = MagicMock() + enabled.forecast_enable_xgboost = True + with patch("app.core.config.get_settings", return_value=enabled): + model = model_factory(XGBoostModelConfig()) + assert isinstance(model, XGBoostForecaster) + def test_canonical_columns_match_regression_contract(self): """The canonical column set is the exact 14-name regression contract. diff --git a/app/features/forecasting/tests/test_xgboost_forecaster.py b/app/features/forecasting/tests/test_xgboost_forecaster.py new file mode 100644 index 00000000..2f4e5fb1 --- /dev/null +++ b/app/features/forecasting/tests/test_xgboost_forecaster.py @@ -0,0 +1,143 @@ +"""Unit tests for ``XGBoostForecaster`` (PRP-MLZOO-C1). + +The XGBoost forecaster is the second ADVANCED feature-aware tree model and a +structural twin of ``LightGBMForecaster``. Like ``RegressionForecaster`` it +*consumes* the exogenous ``X`` argument, so these tests mirror that contract: +``X`` is required, its shape is validated, fits are deterministic, and ``NaN`` +features are tolerated (XGBoost handles missing values natively via +``missing=np.nan``). + +The whole module SKIPs (never ERRORs) when the optional ``ml-xgboost`` +dependency is absent — ``pytest.importorskip``. Importing ``XGBoostForecaster`` +itself is leak-free (``xgboost`` is imported lazily inside ``fit``), so the +class import sits with the other module imports; the ``importorskip`` guard +fires only because every test below actually fits a model. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from app.features.forecasting.models import XGBoostForecaster, model_factory +from app.features.forecasting.schemas import XGBoostModelConfig + +pytest.importorskip("xgboost") + +FloatArray = np.ndarray[Any, np.dtype[np.floating[Any]]] + + +def _synthetic_data( + n: int = 120, n_features: int = 6, seed: int = 0 +) -> tuple[FloatArray, FloatArray]: + """Build a synthetic feature matrix and a target that depends on it.""" + 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) + + +def test_fit_predict_roundtrip() -> None: + """A fitted XGBoost model produces a finite forecast of horizon length.""" + features, target = _synthetic_data() + model = XGBoostForecaster() + 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"): + XGBoostForecaster().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"): + XGBoostForecaster().fit(target, features[:-5]) + + +def test_predict_rejects_none_features() -> None: + """``predict`` raises when no exogenous features are supplied.""" + features, target = _synthetic_data() + model = XGBoostForecaster().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 = XGBoostForecaster().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 = XGBoostForecaster() + with pytest.raises(RuntimeError, match="fitted"): + model.predict(5, np.zeros((5, 3), dtype=np.float64)) + + +def test_determinism_same_random_state() -> None: + """Two fits with the same random_state yield identical forecasts. + + XGBoost has no ``deterministic`` switch (unlike LightGBM). Bit- + reproducibility comes from ``n_jobs=1`` + ``tree_method="hist"`` + a fixed + ``random_state`` + the conservative config leaving ``subsample`` / + ``colsample_bytree`` at their ``1.0`` defaults — all pinned in ``fit`` — so + an EXACT ``assert_array_equal`` within one environment is the correct gate. + """ + features, target = _synthetic_data() + future = features[:12] + first = XGBoostForecaster(random_state=7).fit(target, features) + second = XGBoostForecaster(random_state=7).fit(target, features) + np.testing.assert_array_equal(first.predict(12, future), second.predict(12, future)) + + +def test_handles_nan_features() -> None: + """``XGBRegressor`` tolerates NaN feature cells natively.""" + features, target = _synthetic_data() + model = XGBoostForecaster().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_get_and_set_params() -> None: + """``get_params`` reflects construction; ``set_params`` mutates in place.""" + model = XGBoostForecaster(n_estimators=150, learning_rate=0.03, max_depth=4) + params = model.get_params() + assert params["n_estimators"] == 150 + assert params["learning_rate"] == 0.03 + assert params["max_depth"] == 4 + model.set_params(max_depth=9) + assert model.max_depth == 9 + + +def test_requires_features_is_true() -> None: + """XGBoost is a feature-aware model — the ClassVar is True.""" + assert XGBoostForecaster.requires_features is True + + +def test_model_factory_creates_xgboost_forecaster() -> None: + """``model_factory`` dispatches an XGBoostModelConfig when the flag is on.""" + enabled = MagicMock() + enabled.forecast_enable_xgboost = True + with patch("app.core.config.get_settings", return_value=enabled): + model = model_factory(XGBoostModelConfig(n_estimators=120), random_state=42) + assert isinstance(model, XGBoostForecaster) + assert model.n_estimators == 120 + assert model.random_state == 42 diff --git a/app/features/jobs/service.py b/app/features/jobs/service.py index 9ec982a0..9b370937 100644 --- a/app/features/jobs/service.py +++ b/app/features/jobs/service.py @@ -429,6 +429,7 @@ async def _execute_train( NaiveModelConfig, RegressionModelConfig, SeasonalNaiveModelConfig, + XGBoostModelConfig, ) from app.features.forecasting.service import ForecastingService @@ -473,6 +474,14 @@ async def _execute_train( learning_rate=params.get("learning_rate", 0.1), max_depth=params.get("max_depth", 6), ) + elif model_type == "xgboost": + # The forecast_enable_xgboost gate lives in model_factory — an + # xgboost job with the flag off fails loud at train time. + config = XGBoostModelConfig( + n_estimators=params.get("n_estimators", 100), + learning_rate=params.get("learning_rate", 0.1), + max_depth=params.get("max_depth", 6), + ) else: msg = f"Unsupported model_type: {model_type}" raise ValueError(msg) @@ -614,6 +623,7 @@ async def _execute_backtest( NaiveModelConfig, RegressionModelConfig, SeasonalNaiveModelConfig, + XGBoostModelConfig, ) service = BacktestingService() @@ -653,6 +663,10 @@ async def _execute_backtest( # Feature-aware — still gated by forecast_enable_lightgbm inside # model_factory; a disabled flag surfaces as a loud failed job. model_config = LightGBMModelConfig() + elif model_type == "xgboost": + # Feature-aware — still gated by forecast_enable_xgboost inside + # model_factory; a disabled flag surfaces as a loud failed job. + model_config = XGBoostModelConfig() 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 9773cb96..ea4d842c 100644 --- a/app/features/jobs/tests/test_service.py +++ b/app/features/jobs/tests/test_service.py @@ -26,6 +26,7 @@ LightGBMModelConfig, RegressionModelConfig, TrainResponse, + XGBoostModelConfig, ) from app.features.forecasting.service import ForecastingService from app.features.jobs.service import JobService, _finite, _shape_backtest_result @@ -240,6 +241,27 @@ async def test_execute_train_builds_lightgbm_config() -> None: assert result["model_type"] == "lightgbm" +async def test_execute_train_builds_xgboost_config() -> None: + """A train job with model_type='xgboost' builds an XGBoostModelConfig (#247). + + ``train_model`` is mocked, so ``model_factory`` (and its feature-flag gate) + is never reached and ``XGBoostModelConfig`` is a pure Pydantic schema — + this test needs neither the flag nor the optional xgboost dependency. + """ + fake = _fake_train_response("xgboost") + 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": "xgboost"}, + ) + assert mock_train.call_args is not None + config = mock_train.call_args.kwargs["config"] + assert isinstance(config, XGBoostModelConfig) + assert result["model_type"] == "xgboost" + + 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"): @@ -303,6 +325,26 @@ async def test_execute_backtest_builds_lightgbm_config() -> None: assert result["model_type"] == "lightgbm" +async def test_execute_backtest_builds_xgboost_config() -> None: + """A backtest job with model_type='xgboost' builds an XGBoostModelConfig. + + ``run_backtest`` is mocked, so ``model_factory``'s feature-flag gate is + never reached and the optional xgboost dependency is not required. + """ + 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": "xgboost"}, + ) + assert mock_run.call_args is not None + config = mock_run.call_args.kwargs["config"] + assert isinstance(config.model_config_main, XGBoostModelConfig) + assert result["model_type"] == "xgboost" + + 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/registry/service.py b/app/features/registry/service.py index e091ef83..1170d3af 100644 --- a/app/features/registry/service.py +++ b/app/features/registry/service.py @@ -128,6 +128,14 @@ def _capture_runtime_info(self) -> dict[str, Any]: except ImportError: pass + # XGBoost is an optional dependency — only recorded when installed. + try: + import xgboost + + runtime_info["xgboost_version"] = xgboost.__version__ + except ImportError: + pass + return runtime_info def _compute_config_hash(self, config: dict[str, Any]) -> str: diff --git a/app/features/registry/tests/test_service.py b/app/features/registry/tests/test_service.py index 86fe8bdb..684ec0b4 100644 --- a/app/features/registry/tests/test_service.py +++ b/app/features/registry/tests/test_service.py @@ -99,6 +99,14 @@ def test_capture_runtime_info_has_lightgbm_version(self) -> None: assert "lightgbm_version" in info + def test_capture_runtime_info_has_xgboost_version(self) -> None: + """Captures the XGBoost version when the optional dep is installed (PRP-MLZOO-C1).""" + pytest.importorskip("xgboost") + service = RegistryService() + info = service._capture_runtime_info() + + assert "xgboost_version" in info + class TestRegistryServiceConfigHashDuplicate: """Tests for config hash and duplicate detection.""" diff --git a/app/features/scenarios/tests/conftest.py b/app/features/scenarios/tests/conftest.py index 2cd1fdc5..72452de3 100644 --- a/app/features/scenarios/tests/conftest.py +++ b/app/features/scenarios/tests/conftest.py @@ -26,12 +26,14 @@ LightGBMForecaster, NaiveForecaster, RegressionForecaster, + XGBoostForecaster, ) from app.features.forecasting.persistence import ModelBundle, save_model_bundle from app.features.forecasting.schemas import ( LightGBMModelConfig, NaiveModelConfig, RegressionModelConfig, + XGBoostModelConfig, ) from app.features.scenarios.models import ScenarioPlan from app.main import app @@ -159,6 +161,56 @@ def trained_lightgbm_model() -> Generator[str, None, None]: (artifacts_dir / f"model_{run_id}.joblib").unlink(missing_ok=True) +@pytest.fixture +def trained_xgboost_model() -> Generator[str, None, None]: + """Save a real fitted ``XGBoostForecaster`` bundle on disk; yield run_id. + + SKIPs when the optional ``ml-xgboost`` dependency is absent. The bundle + carries the full PRP-27 feature metadata so the model-exogenous simulate + path can build a future feature frame and genuinely re-forecast — exactly + as it does for a regression bundle (PRP-MLZOO-C1). + """ + pytest.importorskip("xgboost") + + 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 = XGBoostForecaster(random_state=7) + model.fit(target.astype(np.float64), features.astype(np.float64)) + + history_start = date(2026, 4, 1) + bundle = ModelBundle( + model=model, + config=XGBoostModelConfig(), + 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) + + @pytest.fixture def trained_regression_model() -> Generator[str, None, None]: """Save a real fitted ``RegressionForecaster`` bundle on disk; yield run_id. diff --git a/app/features/scenarios/tests/test_routes_integration.py b/app/features/scenarios/tests/test_routes_integration.py index 3a2cfecc..b1f34d8c 100644 --- a/app/features/scenarios/tests/test_routes_integration.py +++ b/app/features/scenarios/tests/test_routes_integration.py @@ -193,6 +193,30 @@ async def test_lightgbm_baseline_returns_model_exogenous( # a fixed multiplier, and the modelled price response lifts demand. assert data["units_delta"] > 0.0 + async def test_xgboost_baseline_returns_model_exogenous( + self, client: AsyncClient, trained_xgboost_model: str + ) -> None: + """An XGBoost baseline is feature-aware — it re-forecasts (PRP-MLZOO-C1). + + Pins the capability-based dispatch in ``ScenarioService.simulate`` — + the branch is ``bundle.model.requires_features``, not a hard-coded + ``model_type == "regression"`` string. + """ + response = await client.post( + "/scenarios/simulate", + json={ + "run_id": trained_xgboost_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/advanced_xgboost.py b/examples/models/advanced_xgboost.py new file mode 100644 index 00000000..0b58337d --- /dev/null +++ b/examples/models/advanced_xgboost.py @@ -0,0 +1,54 @@ +"""Example: Training and predicting with the XGBoost forecaster (MLZOO-C1). + +``XGBoostForecaster`` is the second ADVANCED feature-aware tree model — it wraps +``xgboost.XGBRegressor`` and, like ``LightGBMForecaster``, REQUIRES an exogenous +feature matrix ``X`` for both ``fit`` and ``predict``. + +XGBoost is an OPTIONAL dependency. Install the extra first: + + uv sync --extra dev --extra ml-xgboost + +Usage: + python examples/models/advanced_xgboost.py +""" + +import numpy as np + +from app.features.forecasting.models import XGBoostForecaster +from app.shared.feature_frames import canonical_feature_columns + + +def main(): + # 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 given a fixed random_state. + model = XGBoostForecaster(n_estimators=100, learning_rate=0.1, max_depth=6, random_state=42) + print(f"\nrequires_features: {XGBoostForecaster.requires_features}") + + # 3. Fit on the historical feature frame (``xgboost`` is imported lazily here). + 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, f in enumerate(forecasts): + print(f" Day {i + 1}: {f:.2f}") + + +if __name__ == "__main__": + main() diff --git a/examples/models/feature_frame_contract.md b/examples/models/feature_frame_contract.md index 27356c73..d0010f77 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 and LightGBM -forecasters today; XGBoost / Prophet-like models later in the MLZOO sequence) +The contract a **feature-aware** forecasting model (the regression, LightGBM +and XGBoost forecasters today; a Prophet-like model later in the MLZOO sequence) 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` diff --git a/examples/models/model_interface.md b/examples/models/model_interface.md index 9a08ade8..6a35f925 100644 --- a/examples/models/model_interface.md +++ b/examples/models/model_interface.md @@ -166,6 +166,25 @@ A **feature-aware** model (`requires_features = True`) wrapping `forecast_enable_lightgbm=true`. It consumes the same canonical feature frame as `regression` — see [`feature_frame_contract.md`](feature_frame_contract.md). +### XGBoostModelConfig + +```python +{ + "schema_version": "1.0", + "model_type": "xgboost", + "n_estimators": 100, # 10-1000 (boosting rounds) + "max_depth": 6, # 1-20 + "learning_rate": 0.1 # 0.001-1.0 +} +``` + +A **feature-aware** model (`requires_features = True`) wrapping +`xgboost.XGBRegressor` — the second *advanced* tree model in the MLZOO sequence +(PRP-MLZOO-C1). XGBoost is an **optional dependency**: install the +`ml-xgboost` extra (`uv sync --extra dev --extra ml-xgboost`) and enable +`forecast_enable_xgboost=true`. It consumes the same canonical feature frame as +`regression` and `lightgbm` — see [`feature_frame_contract.md`](feature_frame_contract.md). + --- ## Model Formulas @@ -216,6 +235,19 @@ is `lightgbm.LGBMRegressor` — gradient-boosted leaf-wise trees. Feature-aware `force_col_wise=True`, fixed `random_state`), and NaN-tolerant. Optional — behind the `ml-lightgbm` extra and the `forecast_enable_lightgbm` flag. +### XGBoost Forecaster + +``` +ŷ[t+h] = XGBRegressor.predict(X[t+h]) +``` + +Same exogenous-feature contract as the regression and LightGBM forecasters, but +the estimator is `xgboost.XGBRegressor` — gradient-boosted trees. Feature-aware +(`requires_features = True`), deterministic (`n_jobs=1`, `tree_method="hist"`, +fixed `random_state`, no stochastic subsampling), and NaN-tolerant +(`missing=np.nan`). Optional — behind the `ml-xgboost` extra and the +`forecast_enable_xgboost` flag. + --- ## Persistence (ModelBundle) @@ -232,6 +264,7 @@ class ModelBundle: python_version: str # Python version sklearn_version: str # Scikit-learn version lightgbm_version: str | None # LightGBM version (None if extra not installed) + xgboost_version: str | None # XGBoost version (None if extra not installed) bundle_hash: str # Deterministic hash ``` diff --git a/pyproject.toml b/pyproject.toml index 4abda802..5fee3bdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ dev = [ # dependency list so a single-host install stays dependency-light; CI # installs it via `uv sync --frozen --all-extras --dev`. ml-lightgbm = ["lightgbm>=4.5.0"] +# Opt-in advanced forecasting model (MLZOO-C1). Same optional-extra +# pattern as ml-lightgbm; CI installs it via --all-extras. +ml-xgboost = ["xgboost>=2.1.0"] [build-system] requires = ["hatchling"] diff --git a/uv.lock b/uv.lock index 3415e378..3ac17729 100644 --- a/uv.lock +++ b/uv.lock @@ -859,6 +859,9 @@ dev = [ ml-lightgbm = [ { name = "lightgbm" }, ] +ml-xgboost = [ + { name = "xgboost" }, +] [package.dev-dependencies] dev = [ @@ -895,8 +898,9 @@ requires-dist = [ { name = "structlog", specifier = ">=24.4.0" }, { name = "tiktoken", specifier = ">=0.7.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, + { name = "xgboost", marker = "extra == 'ml-xgboost'", specifier = ">=2.1.0" }, ] -provides-extras = ["dev", "ml-lightgbm"] +provides-extras = ["dev", "ml-lightgbm", "ml-xgboost"] [package.metadata.requires-dev] dev = [{ name = "pandas-stubs", specifier = ">=2.3.3.260113" }] @@ -2020,6 +2024,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, ] +[[package]] +name = "nvidia-nccl-cu12" +version = "2.30.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/2b/1757b6b74ee241de5efee3f35487dcb33e09c07605254809c6ce36aeb783/nvidia_nccl_cu12-2.30.4-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:606fa9aa9215c00367d060188eb1a5bbd28396aff5e11b9200d99d1a6ab79a71", size = 300091935, upload-time = "2026-04-23T03:22:58.024Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c3/0e45ff4dce8401f6ea7c25d80d75738813a47f5ae2691e2478f2fd1e5e93/nvidia_nccl_cu12-2.30.4-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:040974b261edec4b8b793e59e92ab7176fe4ab4bc61b800f9f3bfaeec2d436f3", size = 300164158, upload-time = "2026-04-23T03:23:19.589Z" }, +] + [[package]] name = "openai" version = "2.36.0" @@ -3837,6 +3850,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/98/8b4019b35f2200295c5eec8176da4b779ec3a0fd60eba7196b618f437e1f/xai_sdk-1.6.1-py3-none-any.whl", hash = "sha256:f478dee9bd8839b8d341bd075277d0432aff5cd7120a4284547d25c6c9e7ab3b", size = 240917, upload-time = "2026-01-29T03:13:05.626Z" }, ] +[[package]] +name = "xgboost" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/bb/1eb0242409d22db725d7a88088e6cfd6556829fb0736f9ff69aa9f1e9455/xgboost-3.2.0.tar.gz", hash = "sha256:99b0e9a2a64896cdaf509c5e46372d336c692406646d20f2af505003c0c5d70d", size = 1263936, upload-time = "2026-02-10T11:03:05.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/49/6e4cdd877c24adf56cb3586bc96d93d4dcd780b5ea1efb32e1ee0de08bae/xgboost-3.2.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:2f661966d3e322536d9c448090a870fcba1e32ee5760c10b7c46bac7a342079a", size = 2507014, upload-time = "2026-02-10T10:50:57.44Z" }, + { url = "https://files.pythonhosted.org/packages/93/f1/c09ef1add609453aa3ba5bafcd0d1c1a805c1263c0b60138ec968f8ec296/xgboost-3.2.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:eabbd40d474b8dbf6cb3536325f9150b9e6f0db32d18de9914fb3227d0bef5b7", size = 2328527, upload-time = "2026-02-10T10:51:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/96/9f/d9914a7b8df842832850b1a18e5f47aaa071c217cdd1da2ae9deb291018b/xgboost-3.2.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:852eabc6d3b3702a59bf78dbfdcd1cb9c4d3a3b6e5ed1f8781d8b9512354fdd2", size = 131100954, upload-time = "2026-02-10T11:02:42.704Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/679de17c2caa4fd3b0b4386ecf7377301702cb0afb22930a07c142fcb1d8/xgboost-3.2.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:99b4a6bbcb47212fec5cf5fbe12347215f073c08967431b0122cfbd1ee70312c", size = 131748579, upload-time = "2026-02-10T10:54:40.424Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1661dd114a914a67e3f7ab66fa1382e7599c2a8c340f314ad30a3e2b4d08/xgboost-3.2.0-py3-none-win_amd64.whl", hash = "sha256:0d169736fd836fc13646c7ab787167b3a8110351c2c6bc770c755ee1618f0442", size = 101681668, upload-time = "2026-02-10T10:59:31.202Z" }, +] + [[package]] name = "yarl" version = "1.22.0"