Source code for openstef_models.utils.multi_quantile_regressor

# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project <short.term.energy.forecasts@alliander.com>
#
# SPDX-License-Identifier: MPL-2.0
"""Adaptor for multi-quantile regression using a base quantile regressor.

Designed to work with scikit-learn compatible regressors that support quantile regression.
"""

import inspect
import logging

import numpy as np
import numpy.typing as npt
import pandas as pd
from sklearn.base import BaseEstimator, RegressorMixin

from openstef_core.types import Quantile

logger = logging.getLogger(__name__)

ParamType = float | int | str | bool | None


[docs] class MultiQuantileRegressor(BaseEstimator, RegressorMixin): """Adaptor for multi-quantile regression using a base quantile regressor. This class creates separate instances of a given quantile regressor for each quantile and manages their training and prediction. """
[docs] def __init__( self, base_learner: type[BaseEstimator], quantile_param: str, quantiles: list[Quantile], hyperparams: dict[str, ParamType], ): """Initialize MultiQuantileRegressor. This is an adaptor that allows any quantile-capable regressor to predict multiple quantiles by instantiating separate models for each quantile. Args: base_learner: A scikit-learn compatible regressor class that supports quantile regression. quantile_param: The name of the parameter in base_learner that sets the quantile level. quantiles: List of quantiles to predict (e.g., [0.1, 0.5, 0.9]). hyperparams: Dictionary of hyperparameters to pass to each estimator instance. """ self.quantiles = quantiles self.hyperparams = hyperparams self.quantile_param = quantile_param self.base_learner = base_learner self.is_fitted = False self._models = [self._init_model(q) for q in quantiles]
def _init_model(self, q: float) -> BaseEstimator: params = self.hyperparams.copy() params[self.quantile_param] = q base_learner = self.base_learner(**params) if self.quantile_param not in base_learner.get_params(): msg = f"The base estimator does not support the quantile parameter '{self.quantile_param}'." raise ValueError(msg) return base_learner
[docs] def fit( self, X: npt.NDArray[np.floating] | pd.DataFrame, y: npt.NDArray[np.floating] | pd.Series, sample_weight: npt.NDArray[np.floating] | pd.Series | None = None, feature_name: list[str] | None = None, eval_set: list[tuple[pd.DataFrame, npt.NDArray[np.floating]]] | None = None, eval_sample_weight: list[npt.NDArray[np.floating]] | list[pd.Series] | None = None, ) -> None: """Fit the multi-quantile regressor. Args: X: Input features as a DataFrame. y: Target values as a 2D array where each column corresponds to a quantile. sample_weight: Sample weights for training data. feature_name: List of feature names. eval_set: Evaluation set for early stopping. eval_sample_weight: Sample weights for evaluation data. """ # Pass model-specific eval arguments kwargs = {} x_array = np.asarray(X) for model in self._models: # Check if early stopping is supported if eval_set is None and "early_stopping_rounds" in self.hyperparams: model.set_params(early_stopping_rounds=None) if eval_set is not None and self.learner_eval_sample_weight_param is not None: fit_signature = inspect.signature(getattr(model, "fit")) # noqa: B009 has_eval_x_param = "eval_X" in fit_signature.parameters if has_eval_x_param: # Extract X and y from eval_set tuples for LightGBM eval_x_data = tuple((x_array if eval_x is X else np.asarray(eval_x)) for eval_x, _ in eval_set) eval_y_data = tuple(eval_y for _, eval_y in eval_set) kwargs["eval_X"] = eval_x_data kwargs["eval_y"] = eval_y_data kwargs["eval_sample_weight"] = eval_sample_weight else: # XGBoost uses eval_set kwargs["eval_set"] = [ (x_array if eval_x is X else np.asarray(eval_x), eval_y) for eval_x, eval_y in eval_set ] kwargs[self.learner_eval_sample_weight_param] = eval_sample_weight if "early_stopping_rounds" in self.hyperparams: model.set_params(early_stopping_rounds=self.hyperparams["early_stopping_rounds"]) if feature_name: self.model_feature_names = feature_name else: self.model_feature_names = [] model.fit( # type: ignore X=x_array, y=y, sample_weight=sample_weight, **kwargs, ) self.is_fitted = True
@property def learner_eval_sample_weight_param(self) -> str | None: """Get the name of the sample weight parameter for evaluation sets. Returns: The name of the sample weight parameter if supported, else None. """ learner_name: str = self.base_learner.__name__ params: dict[str, str | None] = { "QuantileRegressor": None, "LGBMRegressor": "eval_sample_weight", "XGBRegressor": "sample_weight_eval_set", } return params.get(learner_name)
[docs] def predict(self, X: npt.NDArray[np.floating] | pd.DataFrame) -> npt.NDArray[np.floating]: """Predict quantiles for the input features. Args: X: Input features as a DataFrame. Returns: A 2D array where each column corresponds to predicted quantiles. """ # noqa: D412 return np.column_stack([model.predict(X=X) for model in self._models]) # type: ignore
@property def models(self) -> list[BaseEstimator]: """Get the list of underlying quantile models. Returns: List of BaseEstimator instances for each quantile. """ return self._models @property def has_feature_names(self) -> bool: """Check if the base estimators have feature names. Returns: True if the base estimators have feature names, False otherwise. """ return len(self.model_feature_names) > 0