Skip to content

Commit 7531eac

Browse files
authored
Merge pull request #250 from w7-mgfcode/feat/forecasting-prophet-like-model
feat(forecast): add Prophet-like additive forecasting model (#248)
2 parents 2091f2f + 1ab877c commit 7531eac

14 files changed

Lines changed: 1784 additions & 7 deletions

PRPs/PRP-MLZOO-C2-prophet-like-additive-model.md

Lines changed: 997 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ curl -X POST http://localhost:8123/forecasting/predict \
344344
- `regression` - Gradient-boosted exogenous-feature regressor (feature-aware)
345345
- `lightgbm` - LightGBM feature-aware regressor — opt-in: install the `ml-lightgbm` extra and set `forecast_enable_lightgbm=True`
346346
- `xgboost` - XGBoost feature-aware regressor — opt-in: install the `ml-xgboost` extra and set `forecast_enable_xgboost=True`
347+
- `prophet_like` - Prophet-like additive linear model (trend / seasonality / regressor decomposition); pure scikit-learn, always available, no extra to install
347348

348349
See [examples/models/](examples/models/) for baseline model examples.
349350

app/features/backtesting/tests/test_feature_aware_backtest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from app.features.backtesting.splitter import TimeSeriesSplitter
2828
from app.features.forecasting.schemas import (
2929
NaiveModelConfig,
30+
ProphetLikeModelConfig,
3031
RegressionModelConfig,
3132
XGBoostModelConfig,
3233
)
@@ -177,6 +178,38 @@ def test_feature_aware_backtest_runs_with_xgboost_model(
177178
assert "mae" in fold.metrics
178179

179180

181+
def test_prophet_like_feature_aware_backtest_produces_per_fold_metrics(
182+
sample_dates_120: list[date],
183+
sample_values_120: np.ndarray,
184+
sample_split_config_expanding: SplitConfig,
185+
) -> None:
186+
"""A prophet_like backtest runs end-to-end and yields per-fold metrics.
187+
188+
The Prophet-like additive model is feature-aware (pure scikit-learn, no
189+
flag), so it routes through the SAME per-fold feature-aware path as the
190+
regression model — satisfying INITIAL-MLZOO-B's "backtesting integration
191+
test comparing baseline and advanced model path".
192+
"""
193+
service = BacktestingService()
194+
series = _series(sample_dates_120, sample_values_120, with_exogenous=True)
195+
splitter = TimeSeriesSplitter(sample_split_config_expanding)
196+
197+
result = service._run_model_backtest(
198+
series_data=series,
199+
splitter=splitter,
200+
model_config=ProphetLikeModelConfig(),
201+
store_fold_details=True,
202+
)
203+
204+
assert result.model_type == "prophet_like"
205+
assert result.feature_aware is True
206+
assert len(result.fold_results) > 0
207+
assert "mae" in result.aggregated_metrics
208+
for fold in result.fold_results:
209+
assert "mae" in fold.metrics
210+
assert np.isfinite(fold.metrics["mae"])
211+
212+
180213
def test_feature_aware_result_records_observed_policy(
181214
sample_dates_120: list[date],
182215
sample_values_120: np.ndarray,

app/features/forecasting/models.py

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,56 @@
2020
from sklearn.ensemble import ( # type: ignore[import-untyped]
2121
HistGradientBoostingRegressor,
2222
)
23+
from sklearn.impute import SimpleImputer # type: ignore[import-untyped]
24+
from sklearn.linear_model import Ridge # type: ignore[import-untyped]
25+
from sklearn.pipeline import Pipeline # type: ignore[import-untyped]
2326

2427
if TYPE_CHECKING:
2528
from app.features.forecasting.schemas import ModelConfig
2629

2730

31+
# Canonical 14-column feature frame partitioned into the three Prophet-style
32+
# additive components. Together the three column tuples cover all 14 canonical
33+
# columns exactly — which is what makes the additive invariant hold (the
34+
# component contributions partition the full coef_ · x sum). See
35+
# ``canonical_feature_columns()`` in ``app/shared/feature_frames``.
36+
_PROPHET_LIKE_COMPONENTS: dict[str, tuple[str, ...]] = {
37+
"trend": ("lag_1", "lag_7", "lag_14", "lag_28", "days_since_launch"),
38+
"seasonality": (
39+
"dow_sin",
40+
"dow_cos",
41+
"month_sin",
42+
"month_cos",
43+
"is_weekend",
44+
"is_month_end",
45+
),
46+
"holiday_regressor": ("price_factor", "promo_active", "is_holiday"),
47+
}
48+
49+
50+
@dataclass
51+
class ForecastDecomposition:
52+
"""Additive component breakdown of a Prophet-like forecast.
53+
54+
Invariant: ``intercept + trend + seasonality + holiday_regressor`` equals
55+
``predict(...)`` for the same ``X`` (within float tolerance), element-wise.
56+
Each component array has shape ``[n_rows]`` — one value per forecast row.
57+
58+
Attributes:
59+
intercept: The fitted Ridge intercept (a scalar, broadcast over rows).
60+
trend: Per-row contribution of the trend columns (autoregressive lags
61+
+ ``days_since_launch``).
62+
seasonality: Per-row contribution of the calendar/seasonal columns.
63+
holiday_regressor: Per-row contribution of the holiday + extra-regressor
64+
columns (price, promotion, holiday flag).
65+
"""
66+
67+
intercept: float
68+
trend: np.ndarray[Any, np.dtype[np.floating[Any]]]
69+
seasonality: np.ndarray[Any, np.dtype[np.floating[Any]]]
70+
holiday_regressor: np.ndarray[Any, np.dtype[np.floating[Any]]]
71+
72+
2873
@dataclass
2974
class FitResult:
3075
"""Result of model fitting.
@@ -888,9 +933,191 @@ def set_params(self, **params: Any) -> XGBoostForecaster: # noqa: ANN401
888933
return self
889934

890935

936+
class ProphetLikeForecaster(BaseForecaster):
937+
"""Feature-aware ADDITIVE forecaster — Ridge over the canonical frame.
938+
939+
Prophet-LIKE, not Prophet: it approximates Prophet's additive trend +
940+
seasonality + holiday/regressor decomposition with a regularized linear
941+
model over the already-engineered 14-column feature frame. It REQUIRES a
942+
non-``None`` exogenous ``X`` for both ``fit`` and ``predict``.
943+
944+
The fitted estimator is a scikit-learn ``Pipeline`` of two deterministic
945+
steps: a ``SimpleImputer(strategy="median")`` that fills the ``NaN`` lag
946+
cells the future feature frame emits (a bare ``Ridge`` raises
947+
``ValueError: Input contains NaN``), followed by a
948+
``Ridge(solver="cholesky")`` whose closed-form L2-regularized fit is
949+
robust to the collinear engineered columns. Folding the imputer INSIDE the
950+
pipeline keeps the no-leakage invariant: it learns its medians on the
951+
training ``X`` only and re-applies them at predict time.
952+
953+
``decompose()`` returns the per-component additive contributions of a
954+
forecast — the literal ``y_hat = intercept + trend + seasonality +
955+
holiday_regressor`` split, computed on the IMPUTED ``X``.
956+
957+
NOT modelled (deliberately — see PRP-MLZOO-C2 Risks): changepoint trend,
958+
posterior uncertainty intervals, automatic seasonality discovery,
959+
multiplicative seasonality. This is an additive linear approximation, not
960+
the real ``prophet`` package.
961+
962+
Attributes:
963+
alpha: Ridge L2 regularization strength (0.0 degenerates to OLS).
964+
"""
965+
966+
requires_features: ClassVar[bool] = True
967+
"""A feature-aware model — ``fit``/``predict`` REQUIRE a non-None ``X``."""
968+
969+
def __init__(self, *, alpha: float = 1.0, random_state: int = 42) -> None:
970+
"""Initialize the Prophet-like additive forecaster.
971+
972+
Args:
973+
alpha: Ridge L2 regularization strength. The default 1.0 keeps
974+
coefficients robust to the collinear engineered-feature frame.
975+
random_state: Kept for interface parity with the other forecasters;
976+
``Ridge(solver="cholesky")`` is closed-form and needs no seed.
977+
"""
978+
super().__init__(random_state)
979+
self.alpha = alpha
980+
self._estimator: Any = None
981+
982+
def fit(
983+
self,
984+
y: np.ndarray[Any, np.dtype[np.floating[Any]]],
985+
X: np.ndarray[Any, np.dtype[np.floating[Any]]] | None = None,
986+
) -> ProphetLikeForecaster:
987+
"""Fit the additive Ridge pipeline on historical features.
988+
989+
Args:
990+
y: Target values (1D array of shape ``[n_samples]``).
991+
X: Exogenous features (2D array of shape ``[n_samples, n_features]``).
992+
REQUIRED — unlike the baseline forecasters.
993+
994+
Returns:
995+
self (for method chaining).
996+
997+
Raises:
998+
ValueError: If ``X`` is ``None``, ``y`` is empty, or the row counts
999+
of ``X`` and ``y`` do not match.
1000+
"""
1001+
if X is None:
1002+
raise ValueError("ProphetLikeForecaster requires exogenous features X for fit()")
1003+
if len(y) == 0:
1004+
raise ValueError("Cannot fit on empty array")
1005+
if X.shape[0] != len(y):
1006+
raise ValueError(
1007+
f"X has {X.shape[0]} rows but y has {len(y)} — feature/target rows must match"
1008+
)
1009+
# The imputer learns its per-column medians on THIS training X only;
1010+
# the Ridge solver is deterministic and closed-form.
1011+
estimator: Any = Pipeline(
1012+
[
1013+
("impute", SimpleImputer(strategy="median")),
1014+
("ridge", Ridge(alpha=self.alpha, solver="cholesky")),
1015+
]
1016+
)
1017+
estimator.fit(X, y)
1018+
self._estimator = estimator
1019+
self._last_values = np.asarray(y[-1:], dtype=np.float64)
1020+
self._is_fitted = True
1021+
return self
1022+
1023+
def predict(
1024+
self,
1025+
horizon: int,
1026+
X: np.ndarray[Any, np.dtype[np.floating[Any]]] | None = None,
1027+
) -> np.ndarray[Any, np.dtype[np.floating[Any]]]:
1028+
"""Generate forecasts from a future feature frame.
1029+
1030+
Args:
1031+
horizon: Number of steps to forecast.
1032+
X: Exogenous features for the forecast period, shape
1033+
``[horizon, n_features]``. REQUIRED.
1034+
1035+
Returns:
1036+
Array of forecasts with shape ``[horizon]``.
1037+
1038+
Raises:
1039+
RuntimeError: If the model has not been fitted.
1040+
ValueError: If ``X`` is ``None`` or its row count is not ``horizon``.
1041+
"""
1042+
if not self._is_fitted or self._estimator is None:
1043+
raise RuntimeError("Model must be fitted before predict")
1044+
if X is None:
1045+
raise ValueError("ProphetLikeForecaster requires exogenous features X for predict()")
1046+
if X.shape[0] != horizon:
1047+
raise ValueError(f"X has {X.shape[0]} rows but horizon is {horizon} — they must match")
1048+
# The Pipeline imputes the NaN lag cells, then the Ridge predicts.
1049+
predictions = self._estimator.predict(X)
1050+
result: np.ndarray[Any, np.dtype[np.floating[Any]]] = np.asarray(
1051+
predictions, dtype=np.float64
1052+
)
1053+
return result
1054+
1055+
def decompose(self, X: np.ndarray[Any, np.dtype[np.floating[Any]]]) -> ForecastDecomposition:
1056+
"""Split a forecast into its additive trend / seasonality / regressor parts.
1057+
1058+
Operates on the IMPUTED ``X`` — the trained imputer's ``transform`` —
1059+
so the per-component contributions sum EXACTLY to ``predict(...)``: any
1060+
``NaN`` cell is filled with the TRAINING-window median, never a
1061+
predict-time median (no leakage). Each component contribution is the
1062+
partial sum ``Σ_{i ∈ component} coef_i · x_i``; together the three
1063+
component column-sets partition all 14 canonical columns, so
1064+
``intercept + trend + seasonality + holiday_regressor == predict()``.
1065+
1066+
Args:
1067+
X: Feature matrix of shape ``[n_rows, n_features]`` (the same frame
1068+
a ``predict`` call would consume). May contain ``NaN`` cells.
1069+
1070+
Returns:
1071+
A :class:`ForecastDecomposition` with the four-way breakdown.
1072+
1073+
Raises:
1074+
RuntimeError: If the model has not been fitted.
1075+
"""
1076+
from app.shared.feature_frames import canonical_feature_columns
1077+
1078+
if not self._is_fitted or self._estimator is None:
1079+
raise RuntimeError("Model must be fitted before decompose")
1080+
imputer = self._estimator.named_steps["impute"]
1081+
ridge = self._estimator.named_steps["ridge"]
1082+
x_imputed = imputer.transform(X)
1083+
columns = canonical_feature_columns()
1084+
coef = np.asarray(ridge.coef_, dtype=np.float64)
1085+
contributions: dict[str, np.ndarray[Any, np.dtype[np.floating[Any]]]] = {}
1086+
for component, comp_cols in _PROPHET_LIKE_COMPONENTS.items():
1087+
idx = [columns.index(c) for c in comp_cols]
1088+
contributions[component] = np.asarray(x_imputed[:, idx] @ coef[idx], dtype=np.float64)
1089+
return ForecastDecomposition(
1090+
intercept=float(ridge.intercept_),
1091+
trend=contributions["trend"],
1092+
seasonality=contributions["seasonality"],
1093+
holiday_regressor=contributions["holiday_regressor"],
1094+
)
1095+
1096+
def get_params(self) -> dict[str, Any]:
1097+
"""Get model parameters.
1098+
1099+
Returns:
1100+
Dictionary with alpha and random_state.
1101+
"""
1102+
return {"alpha": self.alpha, "random_state": self.random_state}
1103+
1104+
def set_params(self, **params: Any) -> ProphetLikeForecaster: # noqa: ANN401
1105+
"""Set model parameters.
1106+
1107+
Args:
1108+
**params: Parameter names and values to set.
1109+
1110+
Returns:
1111+
self (for method chaining).
1112+
"""
1113+
for key, value in params.items():
1114+
setattr(self, key, value)
1115+
return self
1116+
1117+
8911118
# Type alias for model type literals
8921119
ModelType = Literal[
893-
"naive", "seasonal_naive", "moving_average", "xgboost", "lightgbm", "regression"
1120+
"naive", "seasonal_naive", "moving_average", "xgboost", "lightgbm", "regression", "prophet_like"
8941121
]
8951122

8961123

@@ -974,5 +1201,13 @@ def model_factory(config: ModelConfig, random_state: int = 42) -> BaseForecaster
9741201
random_state=random_state,
9751202
)
9761203
raise ValueError("Invalid config type for regression")
1204+
elif model_type == "prophet_like":
1205+
# No flag gate — the Prophet-like model is pure scikit-learn and ships
1206+
# always-enabled, exactly like ``regression``.
1207+
from app.features.forecasting.schemas import ProphetLikeModelConfig
1208+
1209+
if isinstance(config, ProphetLikeModelConfig):
1210+
return ProphetLikeForecaster(alpha=config.alpha, random_state=random_state)
1211+
raise ValueError("Invalid config type for prophet_like")
9771212
else:
9781213
raise ValueError(f"Unknown model type: {model_type}")

app/features/forecasting/schemas.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,37 @@ class RegressionModelConfig(ModelConfigBase):
232232
)
233233

234234

235+
class ProphetLikeModelConfig(ModelConfigBase):
236+
"""Configuration for the Prophet-like additive forecaster (MLZOO-C2).
237+
238+
A deterministic, regularized ADDITIVE linear model — a ``Ridge`` regressor
239+
over the canonical 14-column feature frame — that decomposes demand into
240+
trend / seasonality / holiday-regressor components. It approximates
241+
Prophet's additive shape WITHOUT the real ``prophet``/Stan dependency: it
242+
does not model changepoint trend, posterior uncertainty, or automatic
243+
seasonality discovery. Pure scikit-learn — no optional dependency, no
244+
feature flag, always available (like ``RegressionModelConfig``).
245+
246+
Attributes:
247+
alpha: Ridge L2 regularization strength. 0.0 degenerates to ordinary
248+
least squares; the default 1.0 keeps coefficients robust to the
249+
collinear engineered-feature frame.
250+
feature_config_hash: Optional hash of the feature contract used.
251+
"""
252+
253+
model_type: Literal["prophet_like"] = "prophet_like"
254+
alpha: float = Field(
255+
default=1.0,
256+
ge=0.0,
257+
le=10000.0,
258+
description="Ridge L2 regularization strength",
259+
)
260+
feature_config_hash: str | None = Field(
261+
default=None,
262+
description="Hash of the feature contract used for training",
263+
)
264+
265+
235266
# Union type for all model configs
236267
ModelConfig = (
237268
NaiveModelConfig
@@ -240,6 +271,7 @@ class RegressionModelConfig(ModelConfigBase):
240271
| LightGBMModelConfig
241272
| XGBoostModelConfig
242273
| RegressionModelConfig
274+
| ProphetLikeModelConfig
243275
)
244276

245277

0 commit comments

Comments
 (0)