Source code for skfolio.model_selection._online._validation

"""Online model validation module."""

# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
# Implementation derived from:
# scikit-learn, Copyright (c) 2007-2010 David Cournapeau, Fabian Pedregosa, Olivier
# Grisel Licensed under BSD 3 clause.

from __future__ import annotations

import datetime as dt
import numbers
from collections.abc import Generator
from typing import TYPE_CHECKING

import numpy as np
import pandas as pd
import sklearn as sk
import sklearn.base as skb
import sklearn.utils as sku
from sklearn.pipeline import Pipeline

import skfolio.typing as skt
from skfolio.measures import BaseMeasure, RatioMeasure
from skfolio.metrics._scorer import _BaseScorer, _EstimatorScorer
from skfolio.model_selection._validation import (
    _apply_entry_rebalancing_params,
    _asset_names_enabled,
    _get_last_step,
    _is_portfolio_optimization_estimator,
    _route_params,
)
from skfolio.model_selection._walk_forward import WalkForward
from skfolio.portfolio import FailedPortfolio, MultiPeriodPortfolio
from skfolio.typing import ArrayLike, FloatArray
from skfolio.utils.tools import fit_single_estimator

if TYPE_CHECKING:
    from skfolio.optimization._base import BaseOptimization

__all__ = ["online_predict", "online_score"]


[docs] def online_predict( estimator: BaseOptimization, X: ArrayLike, y: ArrayLike | None = None, warmup_size: int = 252, test_size: int = 1, freq: str | pd.offsets.BaseOffset | None = None, freq_offset: pd.offsets.BaseOffset | dt.timedelta | None = None, previous: bool = False, purged_size: int = 0, reduce_test: bool = False, params: dict | None = None, portfolio_params: dict | None = None, entry_rebalancing_params: dict | None = None, ) -> MultiPeriodPortfolio: r"""Generate out-of-sample portfolios using online learning. Walks forward through the data, updating the estimator incrementally via `partial_fit` and predicting on each subsequent test window. Unlike :func:`~skfolio.model_selection.cross_val_predict`, which clones the estimator for each fold, this function maintains a single stateful estimator that accumulates knowledge over time. The algorithm: 1. Clone the estimator to ensure a clean, unfitted starting state. 2. Initialize the estimator on the first `warmup_size` observations via `partial_fit`. 3. At each step, predict on the test window, then update the model with the newly observed data via `partial_fit`. If the estimator declares `needs_previous_weights=True`, portfolio weights are automatically propagated from one step to the next. Parameters ---------- estimator : BaseOptimization Portfolio optimization estimator. It must implement `partial_fit`. Pipelines are not supported. X : array-like of shape (n_observations, n_assets) Price returns of the assets. Must be a DataFrame with a `DatetimeIndex` when `freq` is provided. y : array-like of shape (n_observations, n_targets), optional Target data to pass to `partial_fit`. warmup_size : int, default=252 Number of initial observations (or periods when `freq` is set) used for the first `partial_fit` call. No predictions are made during warmup. test_size : int, default=1 Length of each test set. If `freq` is `None` (default), it represents the number of observations. Otherwise, it represents the number of periods defined by `freq`. Controls the rebalancing frequency. freq : str | pandas.offsets.BaseOffset, optional If provided, it must be a frequency string or a pandas DateOffset, and `X` must be a DataFrame with an index of type `DatetimeIndex`. In that case, `warmup_size` and `test_size` represent the number of periods defined by `freq` instead of the number of observations. freq_offset : pandas.offsets.BaseOffset | datetime.timedelta, optional Only used if `freq` is provided. Offsets `freq` by a pandas DateOffset or a datetime timedelta offset. previous : bool, default=False Only used if `freq` is provided. If set to `True`, and if the period start or period end is not in the `DatetimeIndex`, the previous observation is used; otherwise, the next observation is used. purged_size : int, default=0 The number of observations to exclude from the end of each training window before the test window. Use `purged_size >= 1` when execution is delayed relative to observation. reduce_test : bool, default=False If set to `True`, the last test window is returned even if it is partial, otherwise it is ignored. params : dict, optional Parameters to pass to the underlying estimator's `partial_fit` through metadata routing. portfolio_params : dict, optional Additional parameters forwarded to the resulting :class:`~skfolio.portfolio.MultiPeriodPortfolio`. entry_rebalancing_params : dict, optional Estimator parameters applied only while constructing the first portfolio. This is useful when the strategy starts with no existing position, while later portfolios represent regular rebalancing from the previously predicted weights. For example, the entry rebalancing can relax `max_turnover` or use lower `transaction_costs` to avoid a slow ramp from cash caused by recurring rebalancing constraints. The first portfolio is included in the result; the regular estimator parameters are restored before the next online update. Returns ------- prediction : MultiPeriodPortfolio A :class:`~skfolio.portfolio.MultiPeriodPortfolio` containing one :class:`~skfolio.portfolio.Portfolio` per test window, ordered chronologically. Raises ------ TypeError If the estimator is not a portfolio optimization estimator, does not implement `partial_fit`, or is a pipeline. ValueError If `warmup_size < 1`, `test_size < 1`, or the data is too short for at least one test window. See Also -------- :ref:`sphx_glr_auto_examples_online_learning_plot_3_online_portfolio_optimization_evaluation.py` Online evaluation of portfolio optimization using `online_predict`. Examples -------- >>> from skfolio.datasets import load_sp500_dataset >>> from skfolio.model_selection import online_predict >>> from skfolio.moments import EWCovariance, EWMu >>> from skfolio.optimization import MeanRisk >>> from skfolio.preprocessing import prices_to_returns >>> from skfolio.prior import EmpiricalPrior >>> >>> prices = load_sp500_dataset() >>> X = prices_to_returns(prices) >>> >>> model = MeanRisk( ... prior_estimator=EmpiricalPrior( ... mu_estimator=EWMu(half_life=40), ... covariance_estimator=EWCovariance(half_life=40), ... ), ... ) >>> pred = online_predict(model, X, warmup_size=252, test_size=5) """ _validate_online_estimator( estimator, caller="online_predict", require_portfolio=True ) _validate_sizes(warmup_size, test_size) estimator = sk.clone(estimator) X, y = sku.indexable(X, y) routed_params = _route_params( estimator, params, owner="online_predict", callee="partial_fit" ) return _online_predict( estimator, X, y, routed_params, warmup_size=warmup_size, test_size=test_size, freq=freq, freq_offset=freq_offset, previous=previous, purged_size=purged_size, reduce_test=reduce_test, portfolio_params=portfolio_params, entry_rebalancing_params=entry_rebalancing_params, )
[docs] def online_score( estimator: skb.BaseEstimator, X: ArrayLike, y: ArrayLike | None = None, warmup_size: int = 252, test_size: int = 1, freq: str | pd.offsets.BaseOffset | None = None, freq_offset: pd.offsets.BaseOffset | dt.timedelta | None = None, previous: bool = False, purged_size: int = 0, reduce_test: bool = False, scoring: skt.Scoring = None, params: dict | None = None, per_step: bool = False, portfolio_params: dict | None = None, entry_rebalancing_params: dict | None = None, ) -> float | dict[str, float] | FloatArray | dict[str, FloatArray]: r"""Score an online estimator using walk-forward evaluation. Walks forward through the data, updating the estimator incrementally via `partial_fit` and scoring on each subsequent test window. This is the scoring counterpart of :func:`online_predict`. The function handles both *non-predictor estimators* (e.g. covariance, expected returns, prior) and *portfolio optimization* estimators: * **non-predictor estimators** are scored on each test window independently. By default the average of per-step scores is returned. * **Portfolio optimization estimators** are evaluated by collecting out-of-sample predictions into a :class:`~skfolio.portfolio.MultiPeriodPortfolio` and computing the requested measure on the full multi-period portfolio. Parameters ---------- estimator : BaseEstimator Estimator instance to use to fit the data. It must implement `partial_fit`. Pipelines are not supported. X : array-like of shape (n_observations, n_assets) Price returns of the assets. Must be a DataFrame with a `DatetimeIndex` when `freq` is provided. y : array-like of shape (n_observations, n_targets), optional Target data to pass to `partial_fit`. warmup_size : int, default=252 Number of initial observations (or periods when `freq` is set) used for the first `partial_fit` call. No scores are produced during warmup. test_size : int, default=1 Length of each test set. If `freq` is `None` (default), it represents the number of observations. Otherwise, it represents the number of periods defined by `freq`. freq : str | pandas.offsets.BaseOffset, optional If provided, it must be a frequency string or a pandas DateOffset, and `X` must be a DataFrame with an index of type `DatetimeIndex`. In that case, `warmup_size` and `test_size` represent the number of periods defined by `freq` instead of the number of observations. freq_offset : pandas.offsets.BaseOffset | datetime.timedelta, optional Only used if `freq` is provided. Offsets `freq` by a pandas DateOffset or a datetime timedelta offset. previous : bool, default=False Only used if `freq` is provided. If set to `True`, and if the period start or period end is not in the `DatetimeIndex`, the previous observation is used; otherwise, the next observation is used. purged_size : int, default=0 The number of observations to exclude from the end of each training window before the test window. reduce_test : bool, default=False If set to `True`, the last test window is returned even if it is partial, otherwise it is ignored. scoring : callable, dict, BaseMeasure, or None Scoring specification. Semantics depend on the estimator type: * **Non-predictor estimators** (e.g. covariance, expected returns, prior): `None` uses `estimator.score`; otherwise pass a callable scorer(estimator, X_test)` or a dict of such callables. * **Portfolio optimization estimators**: a :class:`~skfolio.measures.BaseMeasure` or a dict of measures. `None` defaults to :attr:`~skfolio.measures.RatioMeasure.SHARPE_RATIO`. .. note :: For portfolio optimization estimators, online evaluation scores the aggregated out-of-sample :class:`~skfolio.portfolio.MultiPeriodPortfolio`, rather than scoring each test window independently and averaging as in :class:`~sklearn.model_selection.GridSearchCV`. Pass the measure enum directly; `make_scorer` is not supported. params : dict, optional Parameters to pass to the underlying estimator's `partial_fit` through metadata routing. per_step : bool, default=False If `True`, return per-step score arrays instead of aggregated scalars. Only supported for non-predictor estimators; raises `ValueError` for portfolio optimization estimators. portfolio_params : dict, optional Additional parameters forwarded to the resulting :class:`~skfolio.portfolio.MultiPeriodPortfolio` when scoring a portfolio optimization estimator. entry_rebalancing_params : dict, optional Estimator parameters applied only while constructing the first portfolio of a portfolio estimator. This is useful when the strategy starts with no existing position, while later portfolios represent regular rebalancing from the previously predicted weights. For example, the entry rebalancing can relax `max_turnover` or use lower `transaction_costs` to avoid a slow ramp from cash caused by recurring rebalancing constraints. The regular estimator parameters are restored before the next online update. Returns ------- score : float | dict[str, float] | ndarray | dict[str, ndarray] By default, an aggregate `float` (or `dict` for multi-metric). When `per_step=True`, a `FloatArray` of per-step scores (or `dict` thereof). Raises ------ TypeError If the estimator does not implement `partial_fit` or is a pipeline. ValueError If `per_step=True` is used with a portfolio optimization estimator, or if `warmup_size < 1`, `test_size < 1`, or the data is too short for at least one test window. See Also -------- :ref:`sphx_glr_auto_examples_online_learning_plot_2_online_hyperparameter_tuning.py` Programmatic comparison of covariance estimators with `online_score`. :ref:`sphx_glr_auto_examples_online_learning_plot_3_online_portfolio_optimization_evaluation.py` Portfolio-level evaluation with `online_score`. Examples -------- non-predictor estimator (default `estimator.score`): >>> from skfolio.datasets import load_sp500_dataset >>> from skfolio.model_selection import online_score >>> from skfolio.moments import EWCovariance >>> from skfolio.preprocessing import prices_to_returns >>> >>> prices = load_sp500_dataset() >>> X = prices_to_returns(prices) >>> score = online_score(EWCovariance(), X, warmup_size=252) Portfolio optimization estimator: >>> from skfolio.measures import RatioMeasure >>> from skfolio.moments import EWMu >>> from skfolio.optimization import MeanRisk >>> from skfolio.prior import EmpiricalPrior >>> >>> model = MeanRisk( ... prior_estimator=EmpiricalPrior( ... mu_estimator=EWMu(half_life=40), ... covariance_estimator=EWCovariance(half_life=40), ... ), ... ) >>> score = online_score( # doctest: +SKIP ... model, ... X, ... warmup_size=252, ... test_size=5, ... scoring=RatioMeasure.SHARPE_RATIO, ... ) """ _validate_online_estimator(estimator, caller="online_score") _validate_sizes(warmup_size, test_size) estimator = sk.clone(estimator) X, y = sku.indexable(X, y) routed_params = _route_params( estimator, params, owner="online_score", callee="partial_fit" ) is_portfolio = _is_portfolio_optimization_estimator(estimator) _validate_scoring(scoring, is_portfolio) if entry_rebalancing_params is not None and not is_portfolio: raise ValueError( "`entry_rebalancing_params` is only supported for portfolio optimization " "estimators." ) if per_step and is_portfolio: raise ValueError( "per_step=True is not supported for portfolio optimization " "estimators. Use online_predict to obtain the " "MultiPeriodPortfolio and compute measures on it directly." ) if per_step: return _online_score( estimator, X, y, scoring, routed_params, warmup_size=warmup_size, test_size=test_size, freq=freq, freq_offset=freq_offset, previous=previous, purged_size=purged_size, reduce_test=reduce_test, ) agg, _ = _evaluate_online( estimator, X, y, scoring=scoring, routed_params=routed_params, warmup_size=warmup_size, test_size=test_size, freq=freq, freq_offset=freq_offset, previous=previous, purged_size=purged_size, reduce_test=reduce_test, portfolio_params=portfolio_params, entry_rebalancing_params=entry_rebalancing_params, ) return agg
def _online_walk_forward( estimator: skb.BaseEstimator, X: ArrayLike, y: ArrayLike | None, warmup_size: int, test_size: int, routed_params: sku.Bunch, freq: str | pd.offsets.BaseOffset | None = None, freq_offset: pd.offsets.BaseOffset | dt.timedelta | None = None, previous: bool = False, purged_size: int = 0, reduce_test: bool = False, refit_last: bool = False, ) -> Generator[slice, None, None]: """Walk-forward generator shared by :func:`online_predict` and :func:`online_score`. Yields `test_slice` at each step after warming up and incrementally updating the estimator. The caller owns the estimator reference and may call `predict`, `score`, or `set_params` between yields. Parameters ---------- estimator : BaseEstimator Estimator instance used to fit the data. Must support `partial_fit`. X : array-like of shape (n_observations, n_assets) The data to fit, already validated via `sku.indexable`. y : array-like or None Target data to pass to `partial_fit`. warmup_size : int Size of the first training window. The estimator is initialized via `partial_fit` on this window (not `fit`), so the caller is responsible for any cold-start configuration the estimator may require. test_size : int Size of each test window. routed_params : Bunch Parameters passed to the underlying estimator's `partial_fit`. freq : str or pandas DateOffset, optional Calendar frequency forwarded to :class:`~skfolio.model_selection.WalkForward`. freq_offset : pandas DateOffset or datetime timedelta, optional Optional offset applied to the walk-forward schedule. previous : bool, default=False Alignment rule used for calendar-based schedules. purged_size : int, default=0 Number of observations purged between train and test windows. reduce_test : bool, default=False Whether to keep the final partial test window. refit_last : bool, default=False If `True`, perform a final `partial_fit` on the last test window after yielding it. Used by :class:`OnlineGridSearch` so that the best estimator is fully trained on all available data. Yields ------ test_slice : slice Slice into `X` for the current test window. """ cv = WalkForward( test_size=test_size, train_size=warmup_size, freq=freq, freq_offset=freq_offset, previous=previous, purged_size=purged_size, expand_train=True, reduce_test=reduce_test, ) splits = list(cv.split(X)) if len(splits) == 0: raise ValueError( f"Not enough observations for at least one test window with " f"warmup_size={warmup_size}, test_size={test_size}, " f"purged_size={purged_size}." ) # WalkForward with expand_train=True produces contiguous index arrays, # so converting first/last to a slice is safe and avoids a copy. initial_train = splits[0][0] warmup_slice = slice(int(initial_train[0]), int(initial_train[-1]) + 1) fit_single_estimator( estimator, X, y, fit_params=routed_params.estimator_params, indices=warmup_slice, method="partial_fit", ) last_train_end = warmup_slice.stop last_test_slice = None for train_idx, test_idx in splits: train_end = int(train_idx[-1]) + 1 if train_end > last_train_end: fit_single_estimator( estimator, X, y, fit_params=routed_params.estimator_params, indices=slice(last_train_end, train_end), method="partial_fit", ) last_train_end = train_end last_test_slice = slice(int(test_idx[0]), int(test_idx[-1]) + 1) yield last_test_slice if refit_last and last_test_slice is not None: if last_test_slice.stop > last_train_end: fit_single_estimator( estimator, X, y, fit_params=routed_params.estimator_params, indices=slice(last_train_end, last_test_slice.stop), method="partial_fit", ) def _online_predict( estimator: skb.BaseEstimator, X: ArrayLike, y: ArrayLike | None, routed_params: sku.Bunch, *, warmup_size: int, test_size: int, freq=None, freq_offset=None, previous: bool = False, purged_size: int = 0, reduce_test: bool = False, refit_last: bool = False, portfolio_params: dict | None = None, entry_rebalancing_params: dict | None = None, ) -> MultiPeriodPortfolio: """Online prediction. Operates on an already-cloned, validated estimator. Public callers should use :func:`online_predict` instead. Expects routed `partial_fit` parameters and supports the internal `refit_last` option used by the online search utilities. Returns ------- multi_period_portfolio : MultiPeriodPortfolio Predicted portfolios aggregated across test windows. """ portfolio_params = {} if portfolio_params is None else portfolio_params.copy() last_step = _get_last_step(estimator) needs_prev_weights = getattr(last_step, "needs_previous_weights", False) use_dict = _asset_names_enabled(X) previous_params = _apply_entry_rebalancing_params( estimator, entry_rebalancing_params ) first_optimization = True portfolios = [] try: for test_slice in _online_walk_forward( estimator, X, y, warmup_size, test_size, routed_params, freq=freq, freq_offset=freq_offset, previous=previous, purged_size=purged_size, reduce_test=reduce_test, refit_last=refit_last, ): portfolio = estimator.predict(X[test_slice]) portfolios.append(portfolio) if first_optimization: if previous_params is not None: last_step.set_params(**previous_params) previous_params = None first_optimization = False if needs_prev_weights and not isinstance(portfolio, FailedPortfolio): prev_weights = portfolio.weights_dict if use_dict else portfolio.weights # _online_walk_forward updates the estimator before yielding again. last_step.set_params(previous_weights=prev_weights) finally: if previous_params is not None: last_step.set_params(**previous_params) return MultiPeriodPortfolio(portfolios=portfolios, **portfolio_params) def _online_score( estimator: skb.BaseEstimator, X: ArrayLike, y: ArrayLike | None, scoring, routed_params: sku.Bunch, *, warmup_size: int, test_size: int, freq=None, freq_offset=None, previous: bool = False, purged_size: int = 0, reduce_test: bool = False, refit_last: bool = False, ) -> FloatArray | dict[str, FloatArray]: """Per-step scoring for non-predictor estimators. Returns per-step score arrays. Operates on an already-cloned, validated estimator. Expects routed `partial_fit` parameters and applies the provided scorer to each test window yielded by :func:`_online_walk_forward`. Returns ------- scores : ndarray or dict[str, ndarray] Per-step score arrays. """ multi_scoring = isinstance(scoring, dict) if multi_scoring: scores = {name: [] for name in scoring} else: scores = [] for test_slice in _online_walk_forward( estimator, X, y, warmup_size, test_size, routed_params, freq=freq, freq_offset=freq_offset, previous=previous, purged_size=purged_size, reduce_test=reduce_test, refit_last=refit_last, ): X_test = X[test_slice] if multi_scoring: for name, score_func in scoring.items(): scores[name].append(score_func(estimator, X_test)) elif scoring is not None: scores.append(scoring(estimator, X_test)) else: scores.append(estimator.score(X_test)) if multi_scoring: return {name: np.array(vals) for name, vals in scores.items()} return np.array(scores) def _evaluate_online( estimator: skb.BaseEstimator, X: ArrayLike, y: ArrayLike | None, *, scoring, routed_params: sku.Bunch, warmup_size: int, test_size: int, freq=None, freq_offset=None, previous: bool = False, purged_size: int = 0, reduce_test: bool = False, refit_last: bool = False, portfolio_params: dict | None = None, entry_rebalancing_params: dict | None = None, ) -> tuple[float | dict[str, float], MultiPeriodPortfolio | None]: """Unified online evaluation dispatcher. For portfolio estimators the score is derived from the aggregated :class:`~skfolio.portfolio.MultiPeriodPortfolio`. For non-predictor estimators the score is the average of per-step values. Expects routed `partial_fit` parameters and forwards `refit_last` and `portfolio_params` to the internal evaluation path when needed. Returns ------- aggregate_score : float or dict[str, float] Aggregated score over all test windows. multi_period_portfolio : MultiPeriodPortfolio or None Multi-Period Portfolio for portfolio estimators, otherwise `None`. """ is_portfolio = _is_portfolio_optimization_estimator(estimator) multi_scoring = isinstance(scoring, dict) if is_portfolio: multi_period_portfolio = _online_predict( estimator, X, y, routed_params, warmup_size=warmup_size, test_size=test_size, freq=freq, freq_offset=freq_offset, previous=previous, purged_size=purged_size, reduce_test=reduce_test, refit_last=refit_last, portfolio_params=portfolio_params, entry_rebalancing_params=entry_rebalancing_params, ) if multi_scoring: agg = { name: _score_multi_period_portfolio( multi_period_portfolio, single_scoring ) for name, single_scoring in scoring.items() } else: agg = _score_multi_period_portfolio(multi_period_portfolio, scoring) return agg, multi_period_portfolio per_step = _online_score( estimator, X, y, scoring, routed_params, warmup_size=warmup_size, test_size=test_size, freq=freq, freq_offset=freq_offset, previous=previous, purged_size=purged_size, reduce_test=reduce_test, refit_last=refit_last, ) if multi_scoring: agg = {name: float(np.mean(vals)) for name, vals in per_step.items()} else: agg = float(np.mean(per_step)) return agg, None def _score_multi_period_portfolio( multi_period_portfolio: MultiPeriodPortfolio, scoring: BaseMeasure | None, ) -> float: """Score a :class:`~skfolio.portfolio.MultiPeriodPortfolio` using a measure. Risk measures are negated so that higher is always better. Parameters ---------- multi_period_portfolio : MultiPeriodPortfolio The multi-period portfolio to score. scoring : BaseMeasure or None The measure to evaluate. `None` defaults to :attr:`~skfolio.measures.RatioMeasure.SHARPE_RATIO`. Returns ------- score : float """ if scoring is None: scoring = RatioMeasure.SHARPE_RATIO value = multi_period_portfolio.get_measure(scoring) if scoring.is_risk: value = -value return float(value) def _validate_online_estimator( estimator: skb.BaseEstimator, *, caller: str, require_portfolio: bool = False, ) -> skb.BaseEstimator: """Validate that an estimator is compatible with online evaluation. Pipelines are rejected explicitly because online evaluation updates the estimator in place with repeated `partial_fit` calls, while :class:`~sklearn.pipeline.Pipeline` does not yet expose a stable incremental interface. Parameters ---------- estimator : BaseEstimator Estimator instance to validate. caller : str Public API function performing the validation. Used in error messages. require_portfolio : bool, default=False If `True`, require `estimator` to be a portfolio optimization estimator. If `False`, any non-pipeline estimator implementing `partial_fit` is accepted. Returns ------- estimator : BaseEstimator The validated estimator. """ if isinstance(estimator, Pipeline): raise TypeError(f"Pipeline is not supported for `{caller}`.") if require_portfolio and not _is_portfolio_optimization_estimator(estimator): raise TypeError( f"skfolio's `{caller}` only supports portfolio optimization estimators." ) if not hasattr(estimator, "partial_fit"): raise TypeError( f"The estimator ({type(estimator).__name__}) does not " "implement partial_fit. Use an estimator with incremental learning " "support (e.g., `EmpiricalPrior` using exponentially weighted moments)." ) return estimator def _validate_sizes(warmup_size: int, test_size: int) -> None: """Validate `warmup_size` and `test_size`. Parameters ---------- warmup_size : int Number of observations in the initial training window. test_size : int Number of observations in each test window. """ if isinstance(warmup_size, bool) or not isinstance(warmup_size, numbers.Integral): raise TypeError( f"warmup_size must be an integer, got {type(warmup_size).__name__}." ) if isinstance(test_size, bool) or not isinstance(test_size, numbers.Integral): raise TypeError( f"test_size must be an integer, got {type(test_size).__name__}." ) if warmup_size < 1: raise ValueError(f"warmup_size must be >= 1, got {warmup_size}.") if test_size < 1: raise ValueError(f"test_size must be >= 1, got {test_size}.") def _validate_scoring(scoring: skt.Scoring, is_portfolio: bool) -> None: """Validate that the scoring specification matches the estimator type. Parameters ---------- scoring : callable, dict, BaseMeasure, or None Scoring specification. is_portfolio : bool Whether the estimator is a portfolio optimization estimator. """ values = scoring.values() if isinstance(scoring, dict) else [scoring] for s in values: if s is None: continue if is_portfolio: if isinstance(s, _BaseScorer): raise TypeError( f"Got {s!r} as scoring, but make_scorer is not supported " "for online portfolio evaluation. Pass the measure " "directly (e.g. scoring=RatioMeasure.SHARPE_RATIO). " "make_scorer is designed for sklearn's GridSearchCV / " "cross_val_score which use a per-fold predict/score " "cycle. Online evaluation scores the full aggregated " "MultiPeriodPortfolio instead." ) if not isinstance(s, BaseMeasure): raise TypeError( "For portfolio optimization estimators, `scoring` must be " "`None`, a `BaseMeasure`, or a dict[str, BaseMeasure]." ) else: if isinstance(s, BaseMeasure): raise TypeError( f"Got {s!r} as scoring, but BaseMeasure scoring is only " "supported for portfolio optimization estimators. For " "non-predictor estimators, pass a callable scorer (e.g. " "make_scorer(my_loss, response_method=None)) or use " "estimator.score (scoring=None)." ) if isinstance(s, _BaseScorer) and not isinstance(s, _EstimatorScorer): raise TypeError( f"Got {s!r} as scoring, but portfolio scorers created with " "`make_scorer(..., response_method='predict')` are not " "supported for non-predictor estimators. Use " "`make_scorer(..., response_method=None)` instead." ) if not callable(s): raise TypeError( "For non-predictor estimators, `scoring` must be `None`, a " "callable, or a dict[str, callable]." )