diff --git a/docs/source/api_reference/tags.rst b/docs/source/api_reference/tags.rst index 2ab326a66..176d82b68 100644 --- a/docs/source/api_reference/tags.rst +++ b/docs/source/api_reference/tags.rst @@ -90,6 +90,7 @@ probabilistic regressors. capability__multioutput capability__missing capability__update + capability__pred_int X_inner_mtype y_inner_mtype C_inner_mtype diff --git a/skpro/benchmarking/evaluate.py b/skpro/benchmarking/evaluate.py index db258e04d..c320738eb 100644 --- a/skpro/benchmarking/evaluate.py +++ b/skpro/benchmarking/evaluate.py @@ -202,6 +202,8 @@ def evaluate( scoring = _check_scores(scoring) + _check_scoring_capability(estimator, scoring) + _evaluate_fold_kwargs = { "estimator": estimator, "scoring": scoring, @@ -433,3 +435,63 @@ def _check_scores(metrics): else: metrics_type[scitype].append(metric) return metrics_type + + +def _check_scoring_capability(estimator, scoring): + """Raise if the metrics need predictions the estimator cannot make. + + Some regressors only predict a single number, for example online + regressors adapted from ``river``. These have the ``capability:pred_int`` + tag set to ``False``, and their ``predict_proba``, ``predict_interval``, + ``predict_quantiles`` and ``predict_var`` methods raise an exception. + + Most metrics score a probabilistic prediction, so they cannot be used + with such a regressor. This is checked here, before any model is fitted, + because ``_evaluate_fold`` catches every exception and turns it into + ``error_score``. Without this check, ``evaluate`` would fit all folds + and then quietly return a table of ``NaN``. + + Parameters + ---------- + estimator : skpro regressor, BaseProbaRegressor descendant + regressor passed to ``evaluate`` + scoring : dict + return of ``_check_scores``, metrics keyed by the ``scitype:y_pred`` + tag, i.e., by the prediction method used to score them + + Raises + ------ + ValueError + if ``estimator`` has the ``capability:pred_int`` tag set to ``False``, + and any metric in ``scoring`` scores a probabilistic prediction + """ + # metric scitypes which are scored against a probabilistic prediction + PROBA_SCITYPES = ["pred_proba", "pred_interval", "pred_quantiles"] + + # non-skpro estimators carry no tags, their capability is not known here + if not hasattr(estimator, "get_tag"): + return + + if estimator.get_tag("capability:pred_int", True, raise_error=False): + return + + offenders = [ + getattr(metric, "name", str(metric)) + for scitype in PROBA_SCITYPES + for metric in scoring.get(scitype, []) + ] + + # a point prediction only regressor can still be scored by point metrics + if not offenders: + return + + raise ValueError( + f"Error in evaluate: the estimator {type(estimator).__name__} is " + "point prediction only, i.e., has the capability:pred_int tag set to " + "False, but the following metrics passed in scoring score " + f"probabilistic predictions: {', '.join(offenders)}. " + "To evaluate a point prediction regressor, wrap it in a regressor " + "which adds a distributional prediction, e.g., BaggingRegressor, " + "BootstrapRegressor, ResidualDouble, or EnbpiRegressor, and evaluate " + "the wrapped regressor." + ) diff --git a/skpro/model_selection/_tuning.py b/skpro/model_selection/_tuning.py index 84f50dc07..c57bdce8b 100644 --- a/skpro/model_selection/_tuning.py +++ b/skpro/model_selection/_tuning.py @@ -80,6 +80,9 @@ def __init__( "capability:multioutput", "capability:missing", "capability:survival", + # prediction is delegated to best_estimator_, a clone of estimator, + # so the tuner is point prediction only iff estimator is + "capability:pred_int", ] self.clone_tags(estimator, tags_to_clone) self._set_update_capability_tag(estimator) @@ -99,6 +102,33 @@ def _set_update_capability_tag(self, estimator): "'no_update', 'inner_only', 'full_refit'." ) + def _check_scoring_capability(self, scoring): + """Raise if ``scoring`` needs predictions the tuned estimator cannot make. + + All ``skpro`` metrics score a probabilistic prediction, i.e., the return + of ``predict_proba``, ``predict_interval``, or ``predict_quantiles``. + Tuning an estimator which is point prediction only, i.e., which has the + ``capability:pred_int`` tag set to ``False``, is therefore not possible. + """ + if self.get_tag("capability:pred_int"): + return + + pred_scitype = None + if hasattr(scoring, "get_tag"): + pred_scitype = scoring.get_tag("scitype:y_pred", None, raise_error=False) + + raise ValueError( + f"Error in {type(self).__name__}: the estimator to tune, " + f"{type(self.estimator).__name__}, is point prediction only, " + "i.e., has the capability:pred_int tag set to False, but the " + f"scoring metric {getattr(scoring, 'name', scoring)} scores " + f"probabilistic predictions of scitype {pred_scitype}. " + "To tune a point prediction regressor, wrap it in a regressor " + "which adds a distributional prediction, e.g., BaggingRegressor, " + "BootstrapRegressor, ResidualDouble, or EnbpiRegressor, and tune " + "the wrapped regressor." + ) + # attribute for _DelegatedProbaRegressor, which then delegates # all non-overridden methods are same as of getattr(self, _delegate_name) # see further details in _DelegatedProbaRegressor docstring @@ -158,6 +188,8 @@ def _fit(self, X, y, C=None): scoring = self.scoring scoring_name = f"test_{scoring.name}" + self._check_scoring_capability(scoring) + backend = self.backend backend_params = self.backend_params if self.backend_params else {} diff --git a/skpro/registry/_tags.py b/skpro/registry/_tags.py index eefebc158..c49e494f1 100644 --- a/skpro/registry/_tags.py +++ b/skpro/registry/_tags.py @@ -38,7 +38,6 @@ check_tag_is_valid(tag_name, tag_value) - checks whether tag_value is valid for tag_name """ - import inspect import sys @@ -275,6 +274,18 @@ class capability__update(_BaseTag): } +class capability__pred_int(_BaseTag): + """Support for probabilistic prediction methods.""" + + _tags = { + "tag_name": "capability:pred_int", + "parent_type": "regressor_proba", + "tag_type": "bool", + "short_descr": "whether predict_proba, predict_interval, " + "and predict_quantiles are available", + } + + class X_inner_mtype(_BaseTag): """Internal X machine type.""" diff --git a/skpro/regression/adapters/__init__.py b/skpro/regression/adapters/__init__.py index 7f5ba0b33..60a717a0e 100644 --- a/skpro/regression/adapters/__init__.py +++ b/skpro/regression/adapters/__init__.py @@ -1,2 +1,6 @@ -"""Adapters for probabilistic regressors.""" +"""Adapters for probabilistic and online regressors.""" # copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +from skpro.regression.adapters._coerce import coerce_to_skpro_regressor + +__all__ = ["coerce_to_skpro_regressor"] diff --git a/skpro/regression/adapters/_coerce.py b/skpro/regression/adapters/_coerce.py new file mode 100644 index 000000000..2a67deb7b --- /dev/null +++ b/skpro/regression/adapters/_coerce.py @@ -0,0 +1,33 @@ +"""Coerce foreign regressors to skpro online regressor adapters.""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +__author__ = ["patelchaitany"] + +from skpro.regression.adapters.river._utils import is_river_estimator +from skpro.regression.base import BaseProbaRegressor + + +def coerce_to_skpro_regressor(estimator): + """Wrap foreign online regressors for use in skpro meta-estimators. + + Parameters + ---------- + estimator : object + Estimator passed by the user to a meta-estimator such as + ``BaggingRegressor``. + + Returns + ------- + estimator : skpro regressor + ``estimator`` unchanged if already an skpro regressor; otherwise a + suitable adapter instance. + """ + if isinstance(estimator, BaseProbaRegressor): + return estimator + + if is_river_estimator(estimator): + from skpro.regression.adapters.river import RiverRegressor + + return RiverRegressor(estimator) + + return estimator diff --git a/skpro/regression/adapters/river/__init__.py b/skpro/regression/adapters/river/__init__.py new file mode 100644 index 000000000..9cd4f0ffc --- /dev/null +++ b/skpro/regression/adapters/river/__init__.py @@ -0,0 +1,6 @@ +"""River adapters for online regressors.""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +from skpro.regression.adapters.river._river import RiverRegressor + +__all__ = ["RiverRegressor"] diff --git a/skpro/regression/adapters/river/_clone.py b/skpro/regression/adapters/river/_clone.py new file mode 100644 index 000000000..bae72b674 --- /dev/null +++ b/skpro/regression/adapters/river/_clone.py @@ -0,0 +1,24 @@ +"""Clone plugin for River estimators.""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +__author__ = ["patelchaitany"] + +from copy import deepcopy + +from skbase.base._clone_plugins import BaseCloner + +from skpro.regression.adapters.river._utils import is_river_estimator + + +class _RiverDeepcopyCloner(BaseCloner): + """Clone River estimators via deepcopy. + + River models do not implement sklearn-style ``get_params``; this plugin + allows ``RiverRegressor.clone()`` to produce independent River model copies. + """ + + def _check(self, obj): + return is_river_estimator(obj) + + def _clone(self, obj): + return deepcopy(obj) diff --git a/skpro/regression/adapters/river/_river.py b/skpro/regression/adapters/river/_river.py new file mode 100644 index 000000000..b71f42b0b --- /dev/null +++ b/skpro/regression/adapters/river/_river.py @@ -0,0 +1,121 @@ +"""Adapter for River online regressors.""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +__author__ = ["patelchaitany"] +__all__ = ["RiverRegressor"] + +from copy import deepcopy + +import pandas as pd + +from skpro.regression.adapters.river._clone import _RiverDeepcopyCloner +from skpro.regression.adapters.river._utils import ( + _learn_batch, + _predict_batch, + is_river_estimator, +) +from skpro.regression.base import BaseProbaRegressor + + +class RiverRegressor(BaseProbaRegressor): + """Adapter for River online regressors to the skpro point-prediction API. + + Wraps a River regressor and exposes ``fit``, ``update``, and ``predict`` + with skpro datatype conversion on the public interface. Probabilistic + prediction is intentionally not provided; use a meta-estimator such as + ``BaggingRegressor`` for distributional output. + + On ``fit`` and ``update``, the River model is trained incrementally via + ``learn_many`` when available, otherwise ``learn_one``. + + Parameters + ---------- + estimator : river.base.Estimator + River regressor instance to wrap. + + Attributes + ---------- + estimator_ : river estimator + Deep copy of the River model after fitting. + + Examples + -------- + >>> from skpro.regression.adapters.river import RiverRegressor + >>> from river import linear_model + >>> from sklearn.datasets import load_diabetes + >>> from sklearn.model_selection import train_test_split + >>> + >>> X, y = load_diabetes(return_X_y=True, as_frame=True) + >>> X_train, X_test, y_train, y_test = train_test_split(X, y) + >>> y_train = y_train.to_frame("target") + >>> + >>> reg = RiverRegressor(linear_model.LinearRegression()) + >>> reg.fit(X_train, y_train) + RiverRegressor(...) + >>> reg.update(X_test[:10], y_test[:10].to_frame("target")) + RiverRegressor(...) + >>> y_pred = reg.predict(X_test[10:]) + """ + + _tags = { + "authors": ["patelchaitany"], + "maintainers": ["patelchaitany", "fkiraly"], + "object_type": "regressor_proba", + "estimator_type": "regressor", + "python_dependencies": "river", + "capability:update": True, + "capability:pred_int": False, + "capability:multioutput": False, + "capability:missing": False, + "tests:vm": True, + } + + def __init__(self, estimator): + if not is_river_estimator(estimator): + raise TypeError( + "estimator must be a River estimator instance, " + f"but found type {type(estimator)}" + ) + self.estimator = estimator + super().__init__() + + @classmethod + def _get_clone_plugins(cls): + parent_plugins = super()._get_clone_plugins() + if parent_plugins is None: + parent_plugins = [] + return [_RiverDeepcopyCloner] + list(parent_plugins) + + def _fit(self, X, y, C=None): + if len(y.columns) != 1: + raise ValueError( + "RiverRegressor supports single-output regression only, " + f"but y has {len(y.columns)} columns." + ) + + self.estimator_ = deepcopy(self.estimator) + _learn_batch(self.estimator_, X, y) + return self + + def _update(self, X, y, C=None): + if len(y.columns) != 1: + raise ValueError( + "RiverRegressor supports single-output regression only, " + f"but y has {len(y.columns)} columns." + ) + + _learn_batch(self.estimator_, X, y) + return self + + def _predict(self, X): + preds = _predict_batch(self.estimator_, X) + columns = self._get_columns(method="predict") + return pd.DataFrame(preds, index=X.index, columns=columns) + + @classmethod + def get_test_params(cls, parameter_set="default"): + """Return testing parameter settings for the estimator.""" + from river import linear_model + + params1 = {"estimator": linear_model.LinearRegression()} + return [params1] diff --git a/skpro/regression/adapters/river/_utils.py b/skpro/regression/adapters/river/_utils.py new file mode 100644 index 000000000..06490872a --- /dev/null +++ b/skpro/regression/adapters/river/_utils.py @@ -0,0 +1,97 @@ +"""Utilities for River adapter.""" +# copyright: skpro developers, BSD-3-Clause License (see LICENSE file) + +__author__ = ["patelchaitany"] + +import pandas as pd +from skbase.utils.dependencies import _check_soft_dependencies + + +def is_river_estimator(obj): + """Return whether ``obj`` is a River estimator instance. + + Parameters + ---------- + obj : object + Object to check. + + Returns + ------- + bool + True if ``obj`` is a River estimator, False otherwise. + """ + # if river is not present, obj cannot be a river estimator + if not _check_soft_dependencies("river", severity="none"): + return False + + from river import base + + return isinstance(obj, base.Estimator) + + +def _ensure_str_columns(X): + """Coerce DataFrame column names to strings for River compatibility.""" + if not isinstance(X, pd.DataFrame): + return X + if all(isinstance(c, str) for c in X.columns): + return X + X = X.copy() + X.columns = X.columns.astype(str) + return X + + +def _learn_batch(estimator, X, y): + """Train a River estimator on a batch of inner skpro data. + + Parameters + ---------- + estimator : river estimator + Fitted or unfitted River model. + X : pd.DataFrame + Feature data in skpro inner mtype. + y : pd.DataFrame + Target data in skpro inner mtype, single column. + """ + X = _ensure_str_columns(X) + y_vec = y.iloc[:, 0] + + if hasattr(estimator, "learn_many"): + estimator.learn_many(X, y_vec) + return + + for i in range(len(X)): + xi = X.iloc[i].to_dict() + yi = float(y_vec.iloc[i]) + estimator.learn_one(xi, yi) + + +def _predict_batch(estimator, X): + """Predict with a River estimator on inner skpro feature data. + + Parameters + ---------- + estimator : river estimator + Fitted River model. + X : pd.DataFrame + Feature data in skpro inner mtype. + + Returns + ------- + pd.Series + Point predictions, indexed like ``X``. + """ + X = _ensure_str_columns(X) + + if hasattr(estimator, "predict_many"): + preds = estimator.predict_many(X) + if isinstance(preds, pd.Series): + return preds + if isinstance(preds, pd.DataFrame): + return preds.iloc[:, 0] + return pd.Series(preds, index=X.index) + + preds = [] + for i in range(len(X)): + xi = X.iloc[i].to_dict() + preds.append(estimator.predict_one(xi)) + return pd.Series(preds, index=X.index) diff --git a/skpro/regression/base/_base.py b/skpro/regression/base/_base.py index f7277b7a7..d50b02d2b 100644 --- a/skpro/regression/base/_base.py +++ b/skpro/regression/base/_base.py @@ -31,6 +31,7 @@ class BaseProbaRegressor(BaseEstimator): "capability:multioutput": False, "capability:missing": True, "capability:update": False, + "capability:pred_int": True, "X_inner_mtype": "pd_DataFrame_Table", "y_inner_mtype": "pd_DataFrame_Table", "C_inner_mtype": "pd_DataFrame_Table", @@ -209,6 +210,66 @@ def _update(self, X, y, C=None): """ raise NotImplementedError + def _check_pred_int_capability(self): + """Raise if probabilistic prediction methods are not supported.""" + if not self.get_tag("capability:pred_int"): + raise NotImplementedError( + f"{self.__class__.__name__} does not implement probabilistic " + f"prediction methods (capability:pred_int=False)." + ) + + def _check_proba_components(self, estimators, param_name="estimator"): + """Raise if any component regressor is point prediction only. + + To be called by meta-estimators which invoke ``predict_proba``, + or another probabilistic prediction method, of their components. + Such meta-estimators cannot be used with components which have the + ``capability:pred_int`` tag set to ``False``. + + Parameters + ---------- + estimators : skpro regressor, or iterable of skpro regressor + component regressor(s) to check. + Entries may also be ``(name, estimator)`` pairs, in which case + the name is used in the error message. + param_name : str, optional, default="estimator" + name of the parameter holding ``estimators``, used in the error message + + Raises + ------ + ValueError + if any of ``estimators`` has ``capability:pred_int`` tag ``False`` + """ + if isinstance(estimators, BaseProbaRegressor): + estimators = [estimators] + + offenders = [] + for est in estimators: + if isinstance(est, tuple): + name, est = est[0], est[-1] + else: + name = type(est).__name__ + # non-skpro components, e.g., sklearn estimators, carry no tags, + # their probabilistic capability is determined by the caller + if not hasattr(est, "get_tag"): + continue + if not est.get_tag("capability:pred_int", True, raise_error=False): + offenders.append(name) + + if offenders: + raise ValueError( + f"Error in {type(self).__name__}: this estimator requires " + "probabilistic predictions from its components, but the " + f"following components passed in {param_name} are point " + f"prediction only, i.e., have the capability:pred_int tag " + f"set to False: {', '.join(offenders)}. " + "To obtain probabilistic predictions from a point prediction " + "regressor, wrap it in a regressor which adds a distributional " + "prediction, e.g., BaggingRegressor, BootstrapRegressor, " + "ResidualDouble, or EnbpiRegressor, and pass the wrapped " + "regressor instead." + ) + def predict(self, X): """Predict labels for data from features. @@ -294,6 +355,10 @@ def predict_proba(self, X): y : skpro BaseDistribution, same length as `X` labels predicted for `X` """ + # check that self is fitted, if not raise exception + self.check_is_fitted() + self._check_pred_int_capability() + X = self._check_X(X) y_pred = self._predict_proba(X) @@ -386,6 +451,7 @@ def predict_interval(self, X=None, coverage=0.90): """ # check that self is fitted, if not raise exception self.check_is_fitted() + self._check_pred_int_capability() # check alpha and coerce to list coverage = self._check_alpha(coverage, name="coverage") @@ -491,6 +557,7 @@ def predict_quantiles(self, X=None, alpha=None): """ # check that self is fitted, if not raise exception self.check_is_fitted() + self._check_pred_int_capability() # default alpha if alpha is None: @@ -604,6 +671,7 @@ def predict_var(self, X=None): """ # check that self is fitted, if not raise exception self.check_is_fitted() + self._check_pred_int_capability() # check and convert X X_inner = self._check_X(X=X) diff --git a/skpro/regression/compose/_pipeline.py b/skpro/regression/compose/_pipeline.py index b9c4abaef..591c0443d 100644 --- a/skpro/regression/compose/_pipeline.py +++ b/skpro/regression/compose/_pipeline.py @@ -338,10 +338,13 @@ def __init__(self, steps): super().__init__() + # the pipeline delegates all prediction methods to the final regressor, + # so its capabilities are exactly those of the final regressor tags_to_clone = [ "capability:multioutput", "capability:survival", "capability:update", + "capability:pred_int", ] self.clone_tags(self.regressor_, tags_to_clone) diff --git a/skpro/regression/compose/_ttr.py b/skpro/regression/compose/_ttr.py index 1ef3f68ec..c0999ddde 100644 --- a/skpro/regression/compose/_ttr.py +++ b/skpro/regression/compose/_ttr.py @@ -85,10 +85,13 @@ def __init__(self, regressor, transformer=None): self.regressor_ = regressor.clone() self.transformer_ = clone(transformer) if transformer else None + # prediction methods are delegated to the inner regressor, + # so the composite is point prediction only iff the inner regressor is tags_to_clone = [ "capability:multioutput", "capability:survival", "capability:update", + "capability:pred_int", ] self.clone_tags(self.regressor_, tags_to_clone) diff --git a/skpro/regression/conformal/__init__.py b/skpro/regression/conformal/__init__.py index 46e4d0da0..8cef24d93 100644 --- a/skpro/regression/conformal/__init__.py +++ b/skpro/regression/conformal/__init__.py @@ -1,4 +1,4 @@ -"""MAPIE Conformal Regressors.""" +"""Conformal Regressors.""" from skpro.regression.conformal._mapie_cqr import MapieConformalizedQuantileRegressor from skpro.regression.conformal._mapie_cross_conformal import ( diff --git a/skpro/regression/enbpi.py b/skpro/regression/enbpi.py index 7130b197e..fc48a922b 100644 --- a/skpro/regression/enbpi.py +++ b/skpro/regression/enbpi.py @@ -32,6 +32,13 @@ class EnbpiRegressor(BaseProbaRegressor): The clones are aggregated to predict quantiles of the target distribution, following the original algorithms in [1]_ and [2]_. + Supports online learning via ``update``. On ``update``, the bootstrap + ensemble is not retrained. Instead, new non-conformity scores (residuals) + are computed from the incoming batch using the frozen ensemble, and a + sliding window of fixed size replaces the oldest scores with the new + ones. This allows the prediction intervals to adapt to distributional + changes over time without any model refitting. + The parameters in the reference [2]_ are mapped to the parameters of the estimator as follows: :math:`\mathcal{A}` is ``estimator``, :math:`B` is ``n_bootstrap_samples``, :math:`x_i, i = 1, \dots, T` are the rows of @@ -99,12 +106,15 @@ class EnbpiRegressor(BaseProbaRegressor): >>> reg_proba = EnbpiRegressor(reg_tabular) >>> reg_proba.fit(X_train, y_train) EnbpiRegressor(...) + >>> reg_proba.update(X_test[:10], y_test[:10].to_frame()) + EnbpiRegressor(...) >>> y_pred = reg_proba.predict_proba(X_test) """ _tags = { "authors": ["fkiraly", "hamrel-cxu"], "capability:missing": True, + "capability:update": True, } def __init__( @@ -186,7 +196,6 @@ def _fit(self, X, y): self.estimators_ = [] self._cols = y.columns - # coerce X to pandas DataFrame with string column names X = prep_skl_df(X, copy_df=True) y_pred_bs = np.ones((n_bootstrap_samples,) + y.shape) * np.nan @@ -229,6 +238,50 @@ def _fit(self, X, y): return self + def _update(self, X, y, C=None): + """Online update of conformity scores. + + The bootstrap ensemble is kept frozen. New non-conformity scores + are computed from the incoming batch using the frozen ensemble + predictions. A sliding window of fixed size (equal to the original + training set size) is maintained: the oldest scores are discarded + and the new scores are appended, so that the prediction intervals + reflect recent data volatility. + + Parameters + ---------- + X : pandas DataFrame + feature instances to update with + y : pandas DataFrame, must be same length as X + labels to update with + + Returns + ------- + self : reference to self + """ + n_new = len(X) + n_cols = y.shape[1] + n_est = self.n_bootstrap_samples + + X_skl = prep_skl_df(X, copy_df=True) + + y_preds = np.zeros((n_est, n_new, n_cols)) + for i, est in enumerate(self.estimators_): + y_preds[i] = _coerce_numpy2d(est.predict(X_skl)) + + y_pred_agg = self._agg_preds(y_preds) + + errs = y.values - y_pred_agg + if self.symmetrize: + errs = np.abs(errs) + + new_bs_vs_ix = np.zeros((n_new, n_est)) + + self._errs = np.concatenate([self._errs[n_new:], errs], axis=0) + self._bs_vs_ix = np.concatenate([self._bs_vs_ix[n_new:], new_bs_vs_ix], axis=0) + + return self + def _pred_phi_sans_i(self, y_preds, i, bs_vs_ix): # y_preds - (n_bootstrap_samples, n_samples, n_vars) # bs_vs_ix - (n_train_samples, n_bootstrap_samples) diff --git a/skpro/regression/ensemble/_bagging.py b/skpro/regression/ensemble/_bagging.py index ac1943212..15912ae9e 100644 --- a/skpro/regression/ensemble/_bagging.py +++ b/skpro/regression/ensemble/_bagging.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd +from skpro.distributions.empirical import Empirical from skpro.distributions.mixture import Mixture from skpro.regression.base import BaseProbaRegressor from skpro.utils.sampling import _random_ss_ix @@ -21,6 +22,12 @@ class BaggingRegressor(BaseProbaRegressor): On ``predict_proba``, the mixture of probabilistic predictions is returned. + If the bagged regressor is point prediction only, i.e., has the + ``capability:pred_int`` tag set to ``False``, the bootstrap sample of point + predictions is returned as an empirical distribution instead, same as in + ``BootstrapRegressor``. This allows lifting point prediction regressors, + e.g., online regressors such as ``RiverRegressor``, to probabilistic ones. + In ``update``, each fitted clone is updated on a row subsample of the new batch. The row subsample fraction is the same as in ``fit`` (for integer ``n_samples``, the fraction ``n_samples / n_fit`` is stored at ``fit`` and @@ -267,6 +274,14 @@ def _predict_proba(self, X) -> np.ndarray: reset_cols = self.bootstrap_features Xis = [_subs_cols(X, col_ix_i, reset_cols) for col_ix_i in self.cols_] + # if the bagged estimator is point prediction only, the bootstrap sample + # of point predictions is returned as an empirical distribution, + # same as in BootstrapRegressor + if not self.estimator.get_tag("capability:pred_int", True, raise_error=False): + y_preds = [est.predict(Xi) for est, Xi in zip(self.estimators_, Xis)] + y_pred_df = pd.concat(y_preds, axis=0, keys=range(len(y_preds))) + return Empirical(y_pred_df) + y_probas = [est.predict_proba(Xi) for est, Xi in zip(self.estimators_, Xis)] y_proba = Mixture(y_probas) diff --git a/skpro/regression/ensemble/_stacking.py b/skpro/regression/ensemble/_stacking.py index 7243a24ab..a2c1866bc 100644 --- a/skpro/regression/ensemble/_stacking.py +++ b/skpro/regression/ensemble/_stacking.py @@ -140,6 +140,15 @@ def _fit(self, X, y, C=None): """ from sklearn.model_selection import KFold + # meta-features are built from predict_proba of the base regressors, + # and the distributional prediction is produced by the final estimator, + # so point prediction only components cannot be used + self._check_proba_components(self._estimators, param_name="estimators") + if self.final_estimator is not None: + self._check_proba_components( + self.final_estimator, param_name="final_estimator" + ) + cv = self.cv if isinstance(cv, int): cv = KFold(n_splits=cv) diff --git a/skpro/regression/ensemble/_voting.py b/skpro/regression/ensemble/_voting.py index 37cf5e7ed..94760a3c7 100644 --- a/skpro/regression/ensemble/_voting.py +++ b/skpro/regression/ensemble/_voting.py @@ -112,6 +112,10 @@ def _fit(self, X, y, C=None): ------- self : reference to self """ + # predict_proba mixes the distributional predictions of the components, + # so point prediction only components cannot be used + self._check_proba_components(self._estimators, param_name="estimators") + self.estimators_ = [] for name, est in self._estimators: diff --git a/skpro/regression/online/_batch_mixture.py b/skpro/regression/online/_batch_mixture.py index 10f8929a2..5585dc5e1 100644 --- a/skpro/regression/online/_batch_mixture.py +++ b/skpro/regression/online/_batch_mixture.py @@ -115,6 +115,10 @@ def _fit(self, X, y, C=None): ------- self : reference to self """ + # predict_proba mixes the distributional predictions of the batch clones, + # so a point prediction only estimator cannot be used + self._check_proba_components(self.estimator) + estimator = self.estimator.clone() estimator.fit(X=X, y=y, C=C) diff --git a/skpro/regression/online/_dont_refit.py b/skpro/regression/online/_dont_refit.py index a5783ccb9..c31f821f3 100644 --- a/skpro/regression/online/_dont_refit.py +++ b/skpro/regression/online/_dont_refit.py @@ -39,9 +39,11 @@ def __dynamic_tags__(self): This method should be used for setting dynamic tags only. """ estimator = self.estimator + # all prediction methods are delegated to the wrapped regressor tags_to_clone = [ "capability:missing", "capability:survival", + "capability:pred_int", ] self.clone_tags(estimator, tags_to_clone) diff --git a/skpro/regression/online/_refit.py b/skpro/regression/online/_refit.py index c48139c15..9e21c870d 100644 --- a/skpro/regression/online/_refit.py +++ b/skpro/regression/online/_refit.py @@ -41,9 +41,11 @@ def __dynamic_tags__(self): This method should be used for setting dynamic tags only. """ estimator = self.estimator + # all prediction methods are delegated to the wrapped regressor tags_to_clone = [ "capability:missing", "capability:survival", + "capability:pred_int", ] self.clone_tags(estimator, tags_to_clone) diff --git a/skpro/regression/online/_refit_every.py b/skpro/regression/online/_refit_every.py index daf768cfb..27fb38988 100644 --- a/skpro/regression/online/_refit_every.py +++ b/skpro/regression/online/_refit_every.py @@ -47,9 +47,11 @@ def __dynamic_tags__(self): This method should be used for setting dynamic tags only. """ estimator = self.estimator + # all prediction methods are delegated to the wrapped regressor tags_to_clone = [ "capability:missing", "capability:survival", + "capability:pred_int", ] self.clone_tags(estimator, tags_to_clone) diff --git a/skpro/regression/tests/test_all_regressors.py b/skpro/regression/tests/test_all_regressors.py index ce81648c2..7f783602c 100644 --- a/skpro/regression/tests/test_all_regressors.py +++ b/skpro/regression/tests/test_all_regressors.py @@ -50,6 +50,10 @@ def test_input_output_contract(self, object_instance): for col in y_pred.columns: assert pd.api.types.is_float_dtype(y_pred[col]) + # point-prediction-only estimators (capability:pred_int=False) + if not regressor.get_tag("capability:pred_int"): + return + # test predict_proba output contract y_pred_proba = regressor.predict_proba(X_test) @@ -155,6 +159,9 @@ def test_pred_quantiles_interval(self, object_instance, alpha): from sklearn.datasets import load_diabetes from sklearn.model_selection import train_test_split + if not object_instance.get_tag("capability:pred_int"): + return + X, y = load_diabetes(return_X_y=True, as_frame=True) X = X.iloc[:50] y = y.iloc[:50] @@ -216,6 +223,9 @@ def test_predict_proba_no_param_mutation(self, object_instance): from sklearn.datasets import load_diabetes from sklearn.model_selection import train_test_split + if not object_instance.get_tag("capability:pred_int"): + return + X, y = load_diabetes(return_X_y=True, as_frame=True) X = X.iloc[:50] y = y.iloc[:50] diff --git a/skpro/survival/compose/_reduce_cond_unc.py b/skpro/survival/compose/_reduce_cond_unc.py index a769f3ad3..21bad3709 100644 --- a/skpro/survival/compose/_reduce_cond_unc.py +++ b/skpro/survival/compose/_reduce_cond_unc.py @@ -39,6 +39,10 @@ def __init__(self, estimator): super().__init__() + # all prediction methods are delegated to the wrapped regressor, + # applied to the padded feature frame + self.clone_tags(estimator, ["capability:pred_int"]) + def _fit(self, X, y, C=None): """Fit regressor to training data. diff --git a/skpro/survival/compose/_reduce_uncensored.py b/skpro/survival/compose/_reduce_uncensored.py index 4e29bec50..9a1f53058 100644 --- a/skpro/survival/compose/_reduce_uncensored.py +++ b/skpro/survival/compose/_reduce_uncensored.py @@ -37,6 +37,9 @@ def __init__(self, estimator): super().__init__() + # all prediction methods are delegated to the wrapped regressor + self.clone_tags(estimator, ["capability:pred_int"]) + def _fit(self, X, y, C=None): """Fit regressor to training data.