From d44093f0e60489d121f636d92bcb80d6431b1a38 Mon Sep 17 00:00:00 2001 From: bartzbeielstein <32470350+bartzbeielstein@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:30:15 +0200 Subject: [PATCH 1/4] feat(forecaster): quantile-LightGBM probabilistic head factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements chapter-16 "adopt next" #3 (point → calibrated bands) as a thin, sf2-safe convenience layer over the EXISTING interval machinery — it does not duplicate predict_interval / predict_quantiles (residual bootstrapping + split-conformal already on ForecasterRecursive). Adds native quantile regression heads instead. - quantile_lgbm_forecaster_factory(config, quantiles=(0.1,0.5,0.9)) → one ForecasterRecursive per quantile with LGBMRegressor(objective="quantile", alpha=q), same lag/rolling config as the default factory. Deterministic, LightGBM-only. Refs hong16b, roma19a. - predict_quantile_band(forecasters, steps, enforce_monotonic=True) → assembles the per-quantile forecasts into q_ columns and applies the Chernozhukov rearrangement (row-wise sort) to remove quantile crossing. - Fail-safe quantile validation (open (0,1), unique, non-empty). For distribution-free coverage, compose with the existing ForecasterRecursive.predict_interval(method="conformal"). Tests: 14 new (factory params, validation, rearrangement, fit/predict integration). Suite 2413 passed; factories.py 100% coverage. Docs regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- _quarto.yml | 2 + docs/reference/index.qmd | 2 + ...titask.factories.predict_quantile_band.qmd | 73 ++++++++ ...ories.quantile_lgbm_forecaster_factory.qmd | 58 ++++++ src/spotforecast2_safe/multitask/factories.py | 170 +++++++++++++++++- tests/test_quantile_factory.py | 118 ++++++++++++ 6 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 docs/reference/multitask.factories.predict_quantile_band.qmd create mode 100644 docs/reference/multitask.factories.quantile_lgbm_forecaster_factory.qmd create mode 100644 tests/test_quantile_factory.py diff --git a/_quarto.yml b/_quarto.yml index e9326b4f..542de72d 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -696,6 +696,8 @@ quartodoc: - multitask.predict.PredictTask - multitask.clean.CleanTask - multitask.factories.default_lgbm_forecaster_factory + - multitask.factories.quantile_lgbm_forecaster_factory + - multitask.factories.predict_quantile_band - multitask.strategies.TrainingStrategy - multitask.strategies.LazyStrategy - multitask.strategies.DefaultsStrategy diff --git a/docs/reference/index.qmd b/docs/reference/index.qmd index 4b994e00..56ebcbee 100644 --- a/docs/reference/index.qmd +++ b/docs/reference/index.qmd @@ -182,6 +182,8 @@ strategies, and a one-call runner. | [multitask.predict.PredictTask](multitask.predict.PredictTask.qmd#spotforecast2_safe.multitask.predict.PredictTask) | Task 5 — Predict-only using previously saved models. | | [multitask.clean.CleanTask](multitask.clean.CleanTask.qmd#spotforecast2_safe.multitask.clean.CleanTask) | Cache-cleaning task — removes all cached data from the pipeline cache. | | [multitask.factories.default_lgbm_forecaster_factory](multitask.factories.default_lgbm_forecaster_factory.qmd#spotforecast2_safe.multitask.factories.default_lgbm_forecaster_factory) | Return a fresh, unfitted LightGBM ``ForecasterRecursive``. | +| [multitask.factories.quantile_lgbm_forecaster_factory](multitask.factories.quantile_lgbm_forecaster_factory.qmd#spotforecast2_safe.multitask.factories.quantile_lgbm_forecaster_factory) | Return one quantile-regression LightGBM ``ForecasterRecursive`` per quantile. | +| [multitask.factories.predict_quantile_band](multitask.factories.predict_quantile_band.qmd#spotforecast2_safe.multitask.factories.predict_quantile_band) | Assemble per-quantile forecasts into one non-crossing band. | | [multitask.strategies.TrainingStrategy](multitask.strategies.TrainingStrategy.qmd#spotforecast2_safe.multitask.strategies.TrainingStrategy) | Strategy interface for preparing a forecaster before the final fit. | | [multitask.strategies.LazyStrategy](multitask.strategies.LazyStrategy.qmd#spotforecast2_safe.multitask.strategies.LazyStrategy) | Approach 1 — Lazy fitting with optional cached tuning. | | [multitask.strategies.DefaultsStrategy](multitask.strategies.DefaultsStrategy.qmd#spotforecast2_safe.multitask.strategies.DefaultsStrategy) | Approach 2 — Train with defaults, no tuning, no cached params. | diff --git a/docs/reference/multitask.factories.predict_quantile_band.qmd b/docs/reference/multitask.factories.predict_quantile_band.qmd new file mode 100644 index 00000000..81fd15ea --- /dev/null +++ b/docs/reference/multitask.factories.predict_quantile_band.qmd @@ -0,0 +1,73 @@ +# multitask.factories.predict_quantile_band { #spotforecast2_safe.multitask.factories.predict_quantile_band } + +```python +multitask.factories.predict_quantile_band( + forecasters, + steps, + *, + last_window=None, + exog=None, + enforce_monotonic=True, +) +``` + +Assemble per-quantile forecasts into one non-crossing band. + +Calls ``predict`` on each fitted forecaster from +:func:`quantile_lgbm_forecaster_factory` and stacks the results into columns +named ``q_`` (e.g. ``q_0.1``), in ascending quantile order. Because the +quantile heads are fitted independently they can *cross* (a higher quantile +predicting below a lower one); with ``enforce_monotonic`` the rows are sorted +ascending (the Chernozhukov rearrangement), a deterministic post-hoc fix that +restores monotonicity without changing the marginal quantile levels. + +## Parameters {.doc-section .doc-section-parameters} + +| Name | Type | Description | Default | +|-------------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|------------| +| forecasters | [Mapping](`typing.Mapping`)\[[float](`float`), [ForecasterRecursive](`spotforecast2_safe.forecaster.recursive.ForecasterRecursive`)\] | Map from quantile level to a **fitted** ``ForecasterRecursive`` (as returned by :func:`quantile_lgbm_forecaster_factory`, after fit). | _required_ | +| steps | [int](`int`) | Forecast horizon passed to each ``predict``. | _required_ | +| last_window | [Optional](`typing.Optional`)\[[Any](`typing.Any`)\] | Optional last-window override forwarded to ``predict``. | `None` | +| exog | [Optional](`typing.Optional`)\[[Any](`typing.Any`)\] | Optional exogenous frame forwarded to ``predict``. | `None` | +| enforce_monotonic | [bool](`bool`) | When ``True`` (default), sort each row ascending so the band never crosses. | `True` | + +## Returns {.doc-section .doc-section-returns} + +| Name | Type | Description | +|--------|------------------------------------------------|-----------------------------------------------------------------------| +| | [pd](`pandas`).[DataFrame](`pandas.DataFrame`) | pd.DataFrame: One column per quantile (``q_``), indexed by the | +| | [pd](`pandas`).[DataFrame](`pandas.DataFrame`) | forecast horizon. | + +## Raises {.doc-section .doc-section-raises} + +| Name | Type | Description | +|--------|----------------------------|------------------------------------------------------| +| | [ValueError](`ValueError`) | If *forecasters* is empty or its levels are invalid. | + +## Examples {.doc-section .doc-section-examples} + +```{python} +import numpy as np +import pandas as pd +import types +from spotforecast2_safe.multitask.factories import ( + quantile_lgbm_forecaster_factory, + predict_quantile_band, +) + +idx = pd.date_range("2023-01-01", periods=300, freq="h") +y = pd.Series( + 50 + 10 * np.sin(np.arange(300) * 2 * np.pi / 24), index=idx, name="y" +) +config = types.SimpleNamespace( + random_state=0, lags_consider=[1, 24], window_size=24 +) +heads = quantile_lgbm_forecaster_factory(config, quantiles=[0.1, 0.5, 0.9]) +for fc in heads.values(): + fc.fit(y=y) +band = predict_quantile_band(heads, steps=6) +print(band.columns.tolist()) +# Non-crossing after rearrangement. +assert (band["q_0.1"] <= band["q_0.5"] + 1e-9).all() +assert (band["q_0.5"] <= band["q_0.9"] + 1e-9).all() +``` \ No newline at end of file diff --git a/docs/reference/multitask.factories.quantile_lgbm_forecaster_factory.qmd b/docs/reference/multitask.factories.quantile_lgbm_forecaster_factory.qmd new file mode 100644 index 00000000..7620083d --- /dev/null +++ b/docs/reference/multitask.factories.quantile_lgbm_forecaster_factory.qmd @@ -0,0 +1,58 @@ +# multitask.factories.quantile_lgbm_forecaster_factory { #spotforecast2_safe.multitask.factories.quantile_lgbm_forecaster_factory } + +```python +multitask.factories.quantile_lgbm_forecaster_factory( + config, + *, + quantiles=DEFAULT_QUANTILES, + weight_func=None, + target=None, +) +``` + +Return one quantile-regression LightGBM ``ForecasterRecursive`` per quantile. + +Each forecaster uses ``LGBMRegressor(objective="quantile", alpha=q)`` and the +same lag/rolling configuration as :func:`default_lgbm_forecaster_factory`, so a +caller can fit the lower/median/upper heads independently and assemble a band +with :func:`predict_quantile_band`. Deterministic given ``config.random_state``; +sf2-safe (LightGBM only, no torch/optuna). Refs ``hong16b``, ``roma19a``. + +## Parameters {.doc-section .doc-section-parameters} + +| Name | Type | Description | Default | +|-------------|------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|---------------------| +| config | [Any](`typing.Any`) | Object satisfying the ``PipelineConfig`` protocol; reads ``random_state``, ``lags_consider``, ``window_size``. | _required_ | +| quantiles | [Sequence](`typing.Sequence`)\[[float](`float`)\] | Quantile levels in the open interval ``(0, 1)``. Defaults to ``(0.1, 0.5, 0.9)``. | `DEFAULT_QUANTILES` | +| weight_func | [Optional](`typing.Optional`)\[[Any](`typing.Any`)\] | Optional per-sample weight function. | `None` | +| target | [Optional](`typing.Optional`)\[[str](`str`)\] | Accepted and ignored (parity with the default factory). | `None` | + +## Returns {.doc-section .doc-section-returns} + +| Name | Type | Description | +|--------|---------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| | [Dict](`typing.Dict`)\[[float](`float`), [ForecasterRecursive](`spotforecast2_safe.forecaster.recursive.ForecasterRecursive`)\] | Dict[float, ForecasterRecursive]: Map from quantile level to a fresh, | +| | [Dict](`typing.Dict`)\[[float](`float`), [ForecasterRecursive](`spotforecast2_safe.forecaster.recursive.ForecasterRecursive`)\] | unfitted forecaster, in ascending quantile order. | + +## Raises {.doc-section .doc-section-raises} + +| Name | Type | Description | +|--------|----------------------------|----------------------------------------------------------------| +| | [ValueError](`ValueError`) | If *quantiles* is empty, out of ``(0, 1)``, or has duplicates. | + +## Examples {.doc-section .doc-section-examples} + +```{python} +import types +from spotforecast2_safe.multitask.factories import ( + quantile_lgbm_forecaster_factory, +) + +config = types.SimpleNamespace( + random_state=42, lags_consider=[1, 2, 3], window_size=3 +) +heads = quantile_lgbm_forecaster_factory(config, quantiles=[0.1, 0.5, 0.9]) +print(sorted(heads)) +print(heads[0.1].regressor.get_params()["objective"]) +print(heads[0.1].regressor.get_params()["alpha"]) +``` \ No newline at end of file diff --git a/src/spotforecast2_safe/multitask/factories.py b/src/spotforecast2_safe/multitask/factories.py index 8f1591d0..8d0aaaed 100644 --- a/src/spotforecast2_safe/multitask/factories.py +++ b/src/spotforecast2_safe/multitask/factories.py @@ -8,15 +8,28 @@ a standalone function lets the upcoming ENTSO-E integration (and any future single-target task) supply its own factory via ``config.forecaster_factory`` without subclassing ``BaseTask``. + +A second factory, :func:`quantile_lgbm_forecaster_factory`, builds a *probabilistic +head*: one LightGBM ``ForecasterRecursive`` per quantile (``objective="quantile"``), +giving native quantile regression for calibrated bands. It complements — and is +distinct from — the residual-based ``predict_interval`` / ``predict_quantiles`` +already on ``ForecasterRecursive`` (which derive intervals by bootstrapping or +split-conformal from a single point model). :func:`predict_quantile_band` assembles +the per-quantile forecasts into one non-crossing band. """ -from typing import Any, Optional +from typing import Any, Dict, Mapping, Optional, Sequence +import numpy as np +import pandas as pd from lightgbm import LGBMRegressor from spotforecast2_safe.forecaster.recursive import ForecasterRecursive from spotforecast2_safe.preprocessing import RollingFeatures as RollingFeaturesUnified +# Default lower/median/upper quantiles for the probabilistic head. +DEFAULT_QUANTILES = (0.1, 0.5, 0.9) + def default_lgbm_forecaster_factory( config: Any, @@ -73,3 +86,158 @@ def default_lgbm_forecaster_factory( ), weight_func=weight_func, ) + + +def _validate_quantiles(quantiles: Sequence[float]) -> list: + """Return *quantiles* as a sorted list of floats in (0, 1), fail-safe.""" + qs = [float(q) for q in quantiles] + if not qs: + raise ValueError("quantiles must be non-empty.") + if any(not (0.0 < q < 1.0) for q in qs): + raise ValueError(f"quantiles must lie in the open interval (0, 1); got {qs}.") + if len(set(qs)) != len(qs): + raise ValueError(f"quantiles must be unique; got {qs}.") + return sorted(qs) + + +def quantile_lgbm_forecaster_factory( + config: Any, + *, + quantiles: Sequence[float] = DEFAULT_QUANTILES, + weight_func: Optional[Any] = None, + target: Optional[str] = None, +) -> Dict[float, ForecasterRecursive]: + """Return one quantile-regression LightGBM ``ForecasterRecursive`` per quantile. + + Each forecaster uses ``LGBMRegressor(objective="quantile", alpha=q)`` and the + same lag/rolling configuration as :func:`default_lgbm_forecaster_factory`, so a + caller can fit the lower/median/upper heads independently and assemble a band + with :func:`predict_quantile_band`. Deterministic given ``config.random_state``; + sf2-safe (LightGBM only, no torch/optuna). Refs ``hong16b``, ``roma19a``. + + Args: + config: Object satisfying the ``PipelineConfig`` protocol; reads + ``random_state``, ``lags_consider``, ``window_size``. + quantiles: Quantile levels in the open interval ``(0, 1)``. Defaults to + ``(0.1, 0.5, 0.9)``. + weight_func: Optional per-sample weight function. + target: Accepted and ignored (parity with the default factory). + + Returns: + Dict[float, ForecasterRecursive]: Map from quantile level to a fresh, + unfitted forecaster, in ascending quantile order. + + Raises: + ValueError: If *quantiles* is empty, out of ``(0, 1)``, or has duplicates. + + Examples: + ```{python} + import types + from spotforecast2_safe.multitask.factories import ( + quantile_lgbm_forecaster_factory, + ) + + config = types.SimpleNamespace( + random_state=42, lags_consider=[1, 2, 3], window_size=3 + ) + heads = quantile_lgbm_forecaster_factory(config, quantiles=[0.1, 0.5, 0.9]) + print(sorted(heads)) + print(heads[0.1].regressor.get_params()["objective"]) + print(heads[0.1].regressor.get_params()["alpha"]) + ``` + """ + del target # parity with default factory; not specialised per target + qs = _validate_quantiles(quantiles) + return { + q: ForecasterRecursive( + estimator=LGBMRegressor( + objective="quantile", + alpha=q, + random_state=config.random_state, + verbose=-1, + ), + lags=config.lags_consider[-1], + window_features=RollingFeaturesUnified( + stats=["mean"], window_sizes=config.window_size + ), + weight_func=weight_func, + ) + for q in qs + } + + +def predict_quantile_band( + forecasters: Mapping[float, ForecasterRecursive], + steps: int, + *, + last_window: Optional[Any] = None, + exog: Optional[Any] = None, + enforce_monotonic: bool = True, +) -> pd.DataFrame: + """Assemble per-quantile forecasts into one non-crossing band. + + Calls ``predict`` on each fitted forecaster from + :func:`quantile_lgbm_forecaster_factory` and stacks the results into columns + named ``q_`` (e.g. ``q_0.1``), in ascending quantile order. Because the + quantile heads are fitted independently they can *cross* (a higher quantile + predicting below a lower one); with ``enforce_monotonic`` the rows are sorted + ascending (the Chernozhukov rearrangement), a deterministic post-hoc fix that + restores monotonicity without changing the marginal quantile levels. + + Args: + forecasters: Map from quantile level to a **fitted** ``ForecasterRecursive`` + (as returned by :func:`quantile_lgbm_forecaster_factory`, after fit). + steps: Forecast horizon passed to each ``predict``. + last_window: Optional last-window override forwarded to ``predict``. + exog: Optional exogenous frame forwarded to ``predict``. + enforce_monotonic: When ``True`` (default), sort each row ascending so the + band never crosses. + + Returns: + pd.DataFrame: One column per quantile (``q_``), indexed by the + forecast horizon. + + Raises: + ValueError: If *forecasters* is empty or its levels are invalid. + + Examples: + ```{python} + import numpy as np + import pandas as pd + import types + from spotforecast2_safe.multitask.factories import ( + quantile_lgbm_forecaster_factory, + predict_quantile_band, + ) + + idx = pd.date_range("2023-01-01", periods=300, freq="h") + y = pd.Series( + 50 + 10 * np.sin(np.arange(300) * 2 * np.pi / 24), index=idx, name="y" + ) + config = types.SimpleNamespace( + random_state=0, lags_consider=[1, 24], window_size=24 + ) + heads = quantile_lgbm_forecaster_factory(config, quantiles=[0.1, 0.5, 0.9]) + for fc in heads.values(): + fc.fit(y=y) + band = predict_quantile_band(heads, steps=6) + print(band.columns.tolist()) + # Non-crossing after rearrangement. + assert (band["q_0.1"] <= band["q_0.5"] + 1e-9).all() + assert (band["q_0.5"] <= band["q_0.9"] + 1e-9).all() + ``` + """ + qs = _validate_quantiles(list(forecasters.keys())) + columns = [f"q_{q}" for q in qs] + preds = [] + index = None + for q in qs: + pred = forecasters[q].predict(steps=steps, last_window=last_window, exog=exog) + if index is None: + index = pred.index + preds.append(np.asarray(pred, dtype="float64")) + + values = np.column_stack(preds) + if enforce_monotonic: + values = np.sort(values, axis=1) + return pd.DataFrame(values, index=index, columns=columns) diff --git a/tests/test_quantile_factory.py b/tests/test_quantile_factory.py new file mode 100644 index 00000000..fcac6029 --- /dev/null +++ b/tests/test_quantile_factory.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: 2026 bartzbeielstein +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Tests for the quantile-LightGBM probabilistic head factory.""" + +import types + +import numpy as np +import pandas as pd +import pytest + +from spotforecast2_safe.forecaster.recursive import ForecasterRecursive +from spotforecast2_safe.multitask.factories import ( + DEFAULT_QUANTILES, + predict_quantile_band, + quantile_lgbm_forecaster_factory, +) + + +def _config(): + return types.SimpleNamespace(random_state=0, lags_consider=[1, 24], window_size=24) + + +class TestQuantileFactory: + def test_builds_one_head_per_quantile(self): + heads = quantile_lgbm_forecaster_factory(_config(), quantiles=[0.1, 0.5, 0.9]) + assert sorted(heads) == [0.1, 0.5, 0.9] + assert all(isinstance(fc, ForecasterRecursive) for fc in heads.values()) + + def test_objective_and_alpha_set(self): + heads = quantile_lgbm_forecaster_factory(_config(), quantiles=[0.1, 0.9]) + for q in (0.1, 0.9): + params = heads[q].regressor.get_params() + assert params["objective"] == "quantile" + assert params["alpha"] == q + + def test_default_quantiles(self): + heads = quantile_lgbm_forecaster_factory(_config()) + assert tuple(sorted(heads)) == DEFAULT_QUANTILES + + def test_ascending_order(self): + heads = quantile_lgbm_forecaster_factory(_config(), quantiles=[0.9, 0.1, 0.5]) + assert list(heads.keys()) == [0.1, 0.5, 0.9] + + @pytest.mark.parametrize( + "bad", + [[], [0.0, 0.5], [0.5, 1.0], [1.5], [0.5, 0.5]], + ) + def test_invalid_quantiles_raise(self, bad): + with pytest.raises(ValueError): + quantile_lgbm_forecaster_factory(_config(), quantiles=bad) + + +class _StubForecaster: + """Minimal fitted-forecaster stand-in returning fixed predictions.""" + + def __init__(self, values): + self.values = values + + def predict(self, steps, last_window=None, exog=None): + return pd.Series(self.values[:steps], index=pd.RangeIndex(steps)) + + +class TestPredictQuantileBand: + def test_columns_and_index(self): + heads = { + 0.1: _StubForecaster([1.0, 1.0]), + 0.5: _StubForecaster([2.0, 2.0]), + 0.9: _StubForecaster([3.0, 3.0]), + } + band = predict_quantile_band(heads, steps=2) + assert band.columns.tolist() == ["q_0.1", "q_0.5", "q_0.9"] + assert band.shape == (2, 3) + + def test_rearrangement_fixes_crossing(self): + # Lower head predicts ABOVE the upper head (crossing). + heads = { + 0.1: _StubForecaster([10.0]), + 0.5: _StubForecaster([5.0]), + 0.9: _StubForecaster([8.0]), + } + band = predict_quantile_band(heads, steps=1, enforce_monotonic=True) + # Row [10, 5, 8] sorted ascending → [5, 8, 10]. + assert band["q_0.1"].iloc[0] == 5.0 + assert band["q_0.5"].iloc[0] == 8.0 + assert band["q_0.9"].iloc[0] == 10.0 + + def test_no_rearrangement_keeps_raw(self): + heads = { + 0.1: _StubForecaster([10.0]), + 0.5: _StubForecaster([5.0]), + 0.9: _StubForecaster([8.0]), + } + band = predict_quantile_band(heads, steps=1, enforce_monotonic=False) + assert band["q_0.1"].iloc[0] == 10.0 # crossing preserved + + def test_empty_raises(self): + with pytest.raises(ValueError): + predict_quantile_band({}, steps=1) + + +class TestIntegration: + def test_fit_predict_band_is_monotonic(self): + idx = pd.date_range("2023-01-01", periods=400, freq="h") + y = pd.Series( + 50 + 10 * np.sin(np.arange(400) * 2 * np.pi / 24), index=idx, name="y" + ) + heads = quantile_lgbm_forecaster_factory(_config(), quantiles=[0.1, 0.5, 0.9]) + for fc in heads.values(): + fc.fit(y=y) + band = predict_quantile_band(heads, steps=6) + assert (band["q_0.1"] <= band["q_0.5"] + 1e-9).all() + assert (band["q_0.5"] <= band["q_0.9"] + 1e-9).all() + assert band.shape == (6, 3) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From f36bead16e1114ba1c056331eb59db5f4c433b1a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 8 Jun 2026 17:36:17 +0000 Subject: [PATCH 2/4] chore(release): 19.3.0-rc.2 [skip ci] ## [19.3.0-rc.2](https://github.com/sequential-parameter-optimization/spotforecast2-safe/compare/v19.3.0-rc.1...v19.3.0-rc.2) (2026-06-08) ### Features * **forecaster:** quantile-LightGBM probabilistic head factory ([d44093f](https://github.com/sequential-parameter-optimization/spotforecast2-safe/commit/d44093f0e60489d121f636d92bcb80d6431b1a38)), closes [#3](https://github.com/sequential-parameter-optimization/spotforecast2-safe/issues/3) --- CHANGELOG.md | 7 +++++++ MODEL_CARD.md | 8 ++++---- pyproject.toml | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index debae676..42a0c898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [19.3.0-rc.2](https://github.com/sequential-parameter-optimization/spotforecast2-safe/compare/v19.3.0-rc.1...v19.3.0-rc.2) (2026-06-08) + + +### Features + +* **forecaster:** quantile-LightGBM probabilistic head factory ([d44093f](https://github.com/sequential-parameter-optimization/spotforecast2-safe/commit/d44093f0e60489d121f636d92bcb80d6431b1a38)), closes [#3](https://github.com/sequential-parameter-optimization/spotforecast2-safe/issues/3) + ## [19.3.0-rc.1](https://github.com/sequential-parameter-optimization/spotforecast2-safe/compare/v19.2.0...v19.3.0-rc.1) (2026-06-08) diff --git a/MODEL_CARD.md b/MODEL_CARD.md index 93bdbe7e..4a680c18 100644 --- a/MODEL_CARD.md +++ b/MODEL_CARD.md @@ -7,7 +7,7 @@ This card describes what spotforecast2-safe is, how to use it safely, the condit | Field | Value | | --- | --- | | Name | spotforecast2-safe | -| Version | 19.3.0-rc.1 | +| Version | 19.3.0-rc.2 | | Type | Deterministic Python library for time series feature engineering and recursive multi-step forecasting. It performs no training of its own. | | Developed by | Thomas Bartz-Beielstein, ORCID [0000-0002-5938-5158](https://orcid.org/0000-0002-5938-5158) | | Distributed by | the `sequential-parameter-optimization` GitHub organization | @@ -18,7 +18,7 @@ This card describes what spotforecast2-safe is, how to use it safely, the condit The library depends only on numpy, pandas, scikit-learn, lightgbm, numba, pyarrow, requests, feature-engine, holidays, astral, and tqdm. It deliberately excludes plotly, matplotlib, spotoptim, optuna, torch, and tensorflow, so no plotting or automated-tuning code ships in this package. -Two Common Platform Enumeration (CPE) identifiers let vulnerability-tracking and software bill of materials (SBOM) tools recognize the package. The wildcard identifier `cpe:2.3:a:sequential_parameter_optimization:spotforecast2_safe:*:*:*:*:*:*:*:*` matches any release; the current release is `cpe:2.3:a:sequential_parameter_optimization:spotforecast2_safe:19.3.0-rc.1:*:*:*:*:*:*:*`. +Two Common Platform Enumeration (CPE) identifiers let vulnerability-tracking and software bill of materials (SBOM) tools recognize the package. The wildcard identifier `cpe:2.3:a:sequential_parameter_optimization:spotforecast2_safe:*:*:*:*:*:*:*:*` matches any release; the current release is `cpe:2.3:a:sequential_parameter_optimization:spotforecast2_safe:19.3.0-rc.2:*:*:*:*:*:*:*`. The library itself is a low-risk component: it is deterministic, its source is fully inspectable, and it fails safe on invalid input. It is built to support high-risk AI systems in the sense of the EU AI Act, but it is not itself such a system. When it is embedded in a high-risk deployment, the duties that attach to that system fall on the integrator, not on the library. @@ -30,7 +30,7 @@ Responsibilities are divided as follows. | Distribution | sequential-parameter-optimization on GitHub | repository issue tracker | | Deployment, operation, and audit | the system integrator | defined per deployment | -The current release is 19.3.0-rc.1, with a stable public interface pinned in `spotforecast2_safe.__init__.__all__`. The full version history, including release dates, is recorded in `CHANGELOG.md` and on the GitHub Releases page; it is maintained automatically by the release pipeline and is not repeated here. +The current release is 19.3.0-rc.2, with a stable public interface pinned in `spotforecast2_safe.__init__.__all__`. The full version history, including release dates, is recorded in `CHANGELOG.md` and on the GitHub Releases page; it is maintained automatically by the release pipeline and is not repeated here. ## 2. Intended Use and Scope @@ -216,7 +216,7 @@ Maintainer: Thomas Bartz-Beielstein, ORCID [0000-0002-5938-5158](https://orcid.o } ``` -Or as a formatted reference: Bartz-Beielstein, T. (2026). *spotforecast2-safe: Safety-critical subset of spotforecast2* (Version 19.3.0-rc.1) [Computer software]. https://github.com/sequential-parameter-optimization/spotforecast2-safe +Or as a formatted reference: Bartz-Beielstein, T. (2026). *spotforecast2-safe: Safety-critical subset of spotforecast2* (Version 19.3.0-rc.2) [Computer software]. https://github.com/sequential-parameter-optimization/spotforecast2-safe The technical report (`bart26h/index.qmd`) is the long-form reference for design rationale, compliance mapping, and evaluation protocol. diff --git a/pyproject.toml b/pyproject.toml index b62579e8..43b912e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spotforecast2-safe" -version = "19.3.0-rc.1" +version = "19.3.0-rc.2" description = "spotforecast2-safe (Core): Safety-critical time series forecasting for production" readme = "README.md" license = { text = "AGPL-3.0-or-later" } From a211271021926b02aa89235d7c421cf7a2797361 Mon Sep 17 00:00:00 2001 From: bartzbeielstein <32470350+bartzbeielstein@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:37:48 +0200 Subject: [PATCH 3/4] feat(configurator): mirror new feature flags onto ConfigEntsoe (parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigEntsoe is an independent dataclass (not a ConfigMulti subclass), so the opt-in feature flags added to ConfigMulti in 19.2.0/19.3.0 were unreachable for the ENTSO-E single-target pipeline (the one the team_4 lecture uses). Mirror them so ConfigEntsoe(...) can enable them: - use_population_weighted_weather, include_degree_hours, include_apparent_temperature, degree_hours_base_{heating,cooling} (19.2.0) - include_ephemeris_features, include_day_type_features (19.3.0) All default off → byte-identical baseline. base.py already reads them via getattr, so no wiring change is needed. Adds a parity test that fails if a future ConfigMulti feature flag is not mirrored here. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...onfigurator.config_entsoe.ConfigEntsoe.qmd | 7 ++ .../configurator/config_entsoe.py | 17 +++++ tests/test_config_entsoe_feature_parity.py | 64 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 tests/test_config_entsoe_feature_parity.py diff --git a/docs/reference/configurator.config_entsoe.ConfigEntsoe.qmd b/docs/reference/configurator.config_entsoe.ConfigEntsoe.qmd index 4d8bbbc4..c161ebce 100644 --- a/docs/reference/configurator.config_entsoe.ConfigEntsoe.qmd +++ b/docs/reference/configurator.config_entsoe.ConfigEntsoe.qmd @@ -28,6 +28,13 @@ configurator.config_entsoe.ConfigEntsoe( include_weather_windows=False, include_holiday_features=False, include_holiday_adjacency_features=False, + use_population_weighted_weather=False, + include_degree_hours=False, + include_apparent_temperature=False, + degree_hours_base_heating=15.0, + degree_hours_base_cooling=22.0, + include_ephemeris_features=False, + include_day_type_features=False, poly_features_degree=1, max_poly_features=10, poly_mi_n_jobs=-1, diff --git a/src/spotforecast2_safe/configurator/config_entsoe.py b/src/spotforecast2_safe/configurator/config_entsoe.py index bf86ab96..fa45d895 100644 --- a/src/spotforecast2_safe/configurator/config_entsoe.py +++ b/src/spotforecast2_safe/configurator/config_entsoe.py @@ -257,6 +257,23 @@ class ConfigEntsoe: include_weather_windows: bool = False include_holiday_features: bool = False include_holiday_adjacency_features: bool = False + # Global / derived weather and calendar refinements (parity with ConfigMulti; + # consumed by spotforecast2.multitask.base.build_exogenous_features). All + # default off → byte-identical to the single-point baseline. + # ``use_population_weighted_weather`` samples the fixed German load-centre + # registry and combines cities by population weight; + # ``include_degree_hours`` adds heating/cooling degree-hours; + # ``include_apparent_temperature`` adds apparent temperature + dew point; + # ``include_ephemeris_features`` adds continuous solar geometry + # (solar_elevation, daylight_duration_h, signed sunrise/sunset-relative time); + # ``include_day_type_features`` adds is_workday + a day_type class. + use_population_weighted_weather: bool = False + include_degree_hours: bool = False + include_apparent_temperature: bool = False + degree_hours_base_heating: float = 15.0 + degree_hours_base_cooling: float = 22.0 + include_ephemeris_features: bool = False + include_day_type_features: bool = False poly_features_degree: int = 1 max_poly_features: int = 10 poly_mi_n_jobs: Optional[int] = -1 diff --git a/tests/test_config_entsoe_feature_parity.py b/tests/test_config_entsoe_feature_parity.py new file mode 100644 index 00000000..2e30b5b1 --- /dev/null +++ b/tests/test_config_entsoe_feature_parity.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2026 bartzbeielstein +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""ConfigEntsoe must carry the same opt-in feature flags as ConfigMulti. + +ConfigEntsoe is an independent dataclass (not a ConfigMulti subclass), so new +feature toggles added to ConfigMulti must be mirrored here or the ENTSO-E +single-target pipeline silently cannot enable them. +""" + +from spotforecast2_safe.configurator.config_entsoe import ConfigEntsoe +from spotforecast2_safe.configurator.config_multi import ConfigMulti + +NEW_FEATURE_FLAGS = ( + "use_population_weighted_weather", + "include_degree_hours", + "include_apparent_temperature", + "include_ephemeris_features", + "include_day_type_features", +) +NEW_FLOAT_FIELDS = ("degree_hours_base_heating", "degree_hours_base_cooling") + + +class TestFeatureFlagParity: + def test_flags_present_and_default_off(self): + cfg = ConfigEntsoe() + for flag in NEW_FEATURE_FLAGS: + assert hasattr(cfg, flag), flag + assert getattr(cfg, flag) is False, flag + + def test_float_defaults_match_configmulti(self): + entsoe, multi = ConfigEntsoe(), ConfigMulti() + for fld in NEW_FLOAT_FIELDS: + assert getattr(entsoe, fld) == getattr(multi, fld), fld + + def test_flags_in_param_names(self): + for flag in NEW_FEATURE_FLAGS + NEW_FLOAT_FIELDS: + assert flag in ConfigEntsoe._PARAM_NAMES, flag + + def test_constructs_with_flags_enabled(self): + cfg = ConfigEntsoe( + use_population_weighted_weather=True, + include_degree_hours=True, + include_apparent_temperature=True, + include_ephemeris_features=True, + include_day_type_features=True, + degree_hours_base_heating=16.0, + degree_hours_base_cooling=21.0, + ) + assert cfg.include_ephemeris_features is True + assert cfg.include_day_type_features is True + assert cfg.degree_hours_base_heating == 16.0 + + def test_configmulti_and_entsoe_agree_on_flag_set(self): + multi = set(ConfigMulti._PARAM_NAMES) + entsoe = set(ConfigEntsoe._PARAM_NAMES) + for flag in NEW_FEATURE_FLAGS + NEW_FLOAT_FIELDS: + assert flag in multi and flag in entsoe, flag + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"]) From a8d968497fd4538985808d21ee92e9d08fcaf8a0 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 8 Jun 2026 18:02:18 +0000 Subject: [PATCH 4/4] chore(release): 19.4.0-rc.1 [skip ci] ## [19.4.0-rc.1](https://github.com/sequential-parameter-optimization/spotforecast2-safe/compare/v19.3.0...v19.4.0-rc.1) (2026-06-08) ### Features * **configurator:** mirror new feature flags onto ConfigEntsoe (parity) ([a211271](https://github.com/sequential-parameter-optimization/spotforecast2-safe/commit/a211271021926b02aa89235d7c421cf7a2797361)) * **forecaster:** quantile-LightGBM probabilistic head factory ([d44093f](https://github.com/sequential-parameter-optimization/spotforecast2-safe/commit/d44093f0e60489d121f636d92bcb80d6431b1a38)), closes [#3](https://github.com/sequential-parameter-optimization/spotforecast2-safe/issues/3) --- CHANGELOG.md | 8 ++++++++ MODEL_CARD.md | 8 ++++---- pyproject.toml | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ee2151..32c7d046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [19.4.0-rc.1](https://github.com/sequential-parameter-optimization/spotforecast2-safe/compare/v19.3.0...v19.4.0-rc.1) (2026-06-08) + + +### Features + +* **configurator:** mirror new feature flags onto ConfigEntsoe (parity) ([a211271](https://github.com/sequential-parameter-optimization/spotforecast2-safe/commit/a211271021926b02aa89235d7c421cf7a2797361)) +* **forecaster:** quantile-LightGBM probabilistic head factory ([d44093f](https://github.com/sequential-parameter-optimization/spotforecast2-safe/commit/d44093f0e60489d121f636d92bcb80d6431b1a38)), closes [#3](https://github.com/sequential-parameter-optimization/spotforecast2-safe/issues/3) + ## [19.3.0](https://github.com/sequential-parameter-optimization/spotforecast2-safe/compare/v19.2.0...v19.3.0) (2026-06-08) diff --git a/MODEL_CARD.md b/MODEL_CARD.md index b1009fd9..a14317d9 100644 --- a/MODEL_CARD.md +++ b/MODEL_CARD.md @@ -7,7 +7,7 @@ This card describes what spotforecast2-safe is, how to use it safely, the condit | Field | Value | | --- | --- | | Name | spotforecast2-safe | -| Version | 19.3.0 | +| Version | 19.4.0-rc.1 | | Type | Deterministic Python library for time series feature engineering and recursive multi-step forecasting. It performs no training of its own. | | Developed by | Thomas Bartz-Beielstein, ORCID [0000-0002-5938-5158](https://orcid.org/0000-0002-5938-5158) | | Distributed by | the `sequential-parameter-optimization` GitHub organization | @@ -18,7 +18,7 @@ This card describes what spotforecast2-safe is, how to use it safely, the condit The library depends only on numpy, pandas, scikit-learn, lightgbm, numba, pyarrow, requests, feature-engine, holidays, astral, and tqdm. It deliberately excludes plotly, matplotlib, spotoptim, optuna, torch, and tensorflow, so no plotting or automated-tuning code ships in this package. -Two Common Platform Enumeration (CPE) identifiers let vulnerability-tracking and software bill of materials (SBOM) tools recognize the package. The wildcard identifier `cpe:2.3:a:sequential_parameter_optimization:spotforecast2_safe:*:*:*:*:*:*:*:*` matches any release; the current release is `cpe:2.3:a:sequential_parameter_optimization:spotforecast2_safe:19.3.0:*:*:*:*:*:*:*`. +Two Common Platform Enumeration (CPE) identifiers let vulnerability-tracking and software bill of materials (SBOM) tools recognize the package. The wildcard identifier `cpe:2.3:a:sequential_parameter_optimization:spotforecast2_safe:*:*:*:*:*:*:*:*` matches any release; the current release is `cpe:2.3:a:sequential_parameter_optimization:spotforecast2_safe:19.4.0-rc.1:*:*:*:*:*:*:*`. The library itself is a low-risk component: it is deterministic, its source is fully inspectable, and it fails safe on invalid input. It is built to support high-risk AI systems in the sense of the EU AI Act, but it is not itself such a system. When it is embedded in a high-risk deployment, the duties that attach to that system fall on the integrator, not on the library. @@ -30,7 +30,7 @@ Responsibilities are divided as follows. | Distribution | sequential-parameter-optimization on GitHub | repository issue tracker | | Deployment, operation, and audit | the system integrator | defined per deployment | -The current release is 19.3.0, with a stable public interface pinned in `spotforecast2_safe.__init__.__all__`. The full version history, including release dates, is recorded in `CHANGELOG.md` and on the GitHub Releases page; it is maintained automatically by the release pipeline and is not repeated here. +The current release is 19.4.0-rc.1, with a stable public interface pinned in `spotforecast2_safe.__init__.__all__`. The full version history, including release dates, is recorded in `CHANGELOG.md` and on the GitHub Releases page; it is maintained automatically by the release pipeline and is not repeated here. ## 2. Intended Use and Scope @@ -216,7 +216,7 @@ Maintainer: Thomas Bartz-Beielstein, ORCID [0000-0002-5938-5158](https://orcid.o } ``` -Or as a formatted reference: Bartz-Beielstein, T. (2026). *spotforecast2-safe: Safety-critical subset of spotforecast2* (Version 19.3.0) [Computer software]. https://github.com/sequential-parameter-optimization/spotforecast2-safe +Or as a formatted reference: Bartz-Beielstein, T. (2026). *spotforecast2-safe: Safety-critical subset of spotforecast2* (Version 19.4.0-rc.1) [Computer software]. https://github.com/sequential-parameter-optimization/spotforecast2-safe The technical report (`bart26h/index.qmd`) is the long-form reference for design rationale, compliance mapping, and evaluation protocol. diff --git a/pyproject.toml b/pyproject.toml index 48c3d8aa..eef339a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spotforecast2-safe" -version = "19.3.0" +version = "19.4.0-rc.1" description = "spotforecast2-safe (Core): Safety-critical time series forecasting for production" readme = "README.md" license = { text = "AGPL-3.0-or-later" }