"""Online hyperparameter search 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
import time
import warnings
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable
from typing import Any, Literal
import numpy as np
import pandas as pd
import sklearn as sk
import sklearn.base as skb
import sklearn.utils as sku
import sklearn.utils.validation as skv
from scipy.stats import rankdata
from sklearn.model_selection import ParameterGrid, ParameterSampler
from sklearn.utils.parallel import Parallel, delayed
import skfolio.typing as skt
from skfolio.model_selection._online._validation import (
_evaluate_online,
_route_params,
_validate_online_estimator,
_validate_scoring,
_validate_sizes,
)
from skfolio.model_selection._validation import (
_is_portfolio_optimization_estimator,
_validate_entry_rebalancing_params,
)
from skfolio.typing import ArrayLike, FloatArray, IntArray
__all__ = ["OnlineGridSearch", "OnlineRandomizedSearch"]
class BaseOnlineSearch(skb.MetaEstimatorMixin, skb.BaseEstimator, ABC):
"""Abstract base class for online hyperparameter search.
Parameters
----------
estimator : BaseEstimator
Estimator that supports `partial_fit`.
scoring : callable, dict, BaseMeasure, or None
Scoring specification. Semantics depend on the estimator type:
* **Component estimators** (e.g. covariance, expected returns):
`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`.
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.
warmup_size : int, default=252
Number of initial observations (or periods when `freq` is set) used
for the first `partial_fit` call.
test_size : int, default=1
Number of observations (or periods when `freq` is set) per test
window.
freq : str | pandas.offsets.BaseOffset, optional
Rebalancing frequency. When provided, `warmup_size` and `test_size`
are interpreted as period counts rather than observation counts, and
`X` must be a DataFrame with a `DatetimeIndex`. See
:class:`~skfolio.model_selection.WalkForward` for details and
examples.
freq_offset : pandas.offsets.BaseOffset | datetime.timedelta, optional
Offset applied to the `freq` boundaries. Only used when `freq` is
provided.
previous : bool, default=False
Only used when `freq` is provided. If `True`, period boundaries
that fall between observations snap to the previous observation;
otherwise they snap to the next.
purged_size : int, default=0
Number of observations (or periods) to skip between the last data the
model sees and the start of the test window.
reduce_test : bool, default=False
If `True`, the last test window is included even when it contains
fewer observations than `test_size`.
refit : bool, str, or callable, default=True
Controls how the best candidate is selected and whether the
selected fitted candidate is exposed as `best_estimator_`.
This parameter is named for API alignment with scikit-learn.
Unlike scikit-learn search estimators, enabling `refit` does
not trigger an additional fit after model selection because
each candidate is already evaluated through a full online
walk-forward pass and updated through the full sample.
* Single-metric scoring: `True` or `False` are both supported.
If `False`, `best_estimator_` is not stored, but
`best_index_`, `best_params_`, and `best_score_` remain
available.
* Multi-metric scoring: set to a scorer name to select the best
candidate for that metric, or to `False` to disable
best-candidate selection and storage of `best_estimator_`.
* A callable receives `cv_results_` and must return the best
candidate index.
n_jobs : int, optional
Number of parallel jobs. `None` means 1.
verbose : int, default=0
Verbosity level for `joblib.Parallel`.
error_score : "raise" or float, default=np.nan
Value to assign to the score if an error occurs during fitting.
If set to `"raise"`, the error is raised.
portfolio_params : dict, optional
Parameters forwarded to
:class:`~skfolio.portfolio.MultiPeriodPortfolio` when scoring
portfolio estimators.
entry_rebalancing_params : dict, optional
Estimator parameters applied only while constructing the first portfolio of
each candidate's online path. 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 use
lower `transaction_costs` or require a valid initial solution with
`fallback=None`. Only supported for portfolio optimization estimators.
return_predictions : bool, default=False
If `True`, store
:class:`~skfolio.portfolio.MultiPeriodPortfolio` objects per
candidate in `cv_results_["predictions"]`. Only applies to
portfolio optimization estimators.
Attributes
----------
cv_results_ : dict[str, ndarray]
A dict with keys:
* `params`: list of candidate parameter dicts.
* `mean_score`: array of aggregate scores (or
`mean_score_<name>` for multi-metric).
* `rank`: array of ranks where 1 is best (or `rank_<name>`
for multi-metric).
* `fit_time`: array of wall-clock times.
* `predictions`: object array of `MultiPeriodPortfolio` or `None`
aligned with candidates (only when `return_predictions=True` and
the estimator is portfolio-based).
best_estimator_ : BaseEstimator
Estimator fitted on the full data with the best parameters.
Only available when `refit` is not `False`.
best_index_ : int
Index into `cv_results_` of the best candidate. Available for
single-metric scoring and for multi-metric scoring when `refit` is
not `False`.
best_score_ : float
Aggregate score of the selected best candidate. Available when
`best_index_` is defined and `refit` is not callable.
best_params_ : dict
Parameter setting that gave the selected best score. Available when
`best_index_` is defined.
multimetric_ : bool
Whether or not the scorers compute several metrics.
is_portfolio_estimator_ : bool
Whether or not the estimator is a portfolio estimator (from BaseOptimization).
"""
cv_results_: dict[str, FloatArray]
best_estimator_: skb.BaseEstimator
best_score_: float
best_params_: dict
best_index_: int
multimetric_: bool
is_portfolio_estimator_: bool
def __init__(
self,
estimator: skb.BaseEstimator,
*,
scoring=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,
refit: bool | str | Callable[[dict[str, Any]], int] = True,
error_score=np.nan,
return_predictions: bool = False,
portfolio_params: dict | None = None,
entry_rebalancing_params: dict | None = None,
n_jobs: int | None = None,
verbose: int = 0,
):
self.estimator = estimator
self.scoring = scoring
self.warmup_size = warmup_size
self.test_size = test_size
self.freq = freq
self.freq_offset = freq_offset
self.previous = previous
self.purged_size = purged_size
self.reduce_test = reduce_test
self.refit = refit
self.error_score = error_score
self.return_predictions = return_predictions
self.portfolio_params = portfolio_params
self.entry_rebalancing_params = entry_rebalancing_params
self.n_jobs = n_jobs
self.verbose = verbose
@abstractmethod
def _get_candidate_params(self) -> Iterable[dict]:
"""Return the parameter dicts to evaluate."""
def _get_refit_metric_name(self) -> str | None:
"""Return the metric name used to select the best candidate."""
if not self.multimetric_:
return "score"
_check_refit_for_multimetric(self.refit, self.scoring)
if isinstance(self.refit, str):
return self.refit
return None
def fit(
self,
X: ArrayLike,
y: ArrayLike | None = None,
**fit_params,
):
"""Run the online search over all candidate parameter combinations.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns.
y : array-like, optional
Optional Target.
**fit_params
Additional parameters routed via metadata routing.
Returns
-------
self
"""
_validate_online_estimator(
self.estimator,
caller=f"{type(self).__name__}.fit",
)
_validate_sizes(self.warmup_size, self.test_size)
_validate_error_score(self.error_score)
X, y = sku.indexable(X, y)
self.is_portfolio_estimator_ = _is_portfolio_optimization_estimator(
self.estimator
)
self.multimetric_ = isinstance(self.scoring, dict)
_validate_scoring(self.scoring, self.is_portfolio_estimator_)
if (
self.entry_rebalancing_params is not None
and not self.is_portfolio_estimator_
):
raise ValueError(
"`entry_rebalancing_params` is only supported for portfolio "
"optimization estimators."
)
routed_params = _route_params(
self.estimator,
fit_params,
owner=f"{type(self).__name__}.fit",
callee="partial_fit",
)
candidate_params_list = list(self._get_candidate_params())
results = Parallel(n_jobs=self.n_jobs, verbose=self.verbose)(
delayed(_evaluate_candidate)(
self.estimator,
candidate_params,
X,
y,
scoring=self.scoring,
routed_params=routed_params,
warmup_size=self.warmup_size,
test_size=self.test_size,
freq=self.freq,
freq_offset=self.freq_offset,
previous=self.previous,
purged_size=self.purged_size,
reduce_test=self.reduce_test,
return_predictions=self.return_predictions,
error_score=self.error_score,
portfolio_params=self.portfolio_params,
entry_rebalancing_params=self.entry_rebalancing_params,
)
for candidate_params in candidate_params_list
)
self._store_results(results)
return self
def _store_results(self, results: list[dict]) -> None:
"""Populate `cv_results_` and best-candidate attributes."""
all_params = [res["params"] for res in results]
fit_times = np.asarray([res["fit_time"] for res in results], dtype=np.float64)
cv_results: dict[str, Any] = {
"params": all_params,
"fit_time": fit_times,
}
if self.multimetric_:
for name in self.scoring:
scores = np.asarray(
[res["score"][name] for res in results],
dtype=np.float64,
)
cv_results[f"mean_score_{name}"] = scores
cv_results[f"rank_{name}"] = _rank_scores(scores)
else:
scores = np.asarray([res["score"] for res in results], dtype=np.float64)
cv_results["mean_score"] = scores
cv_results["rank"] = _rank_scores(scores)
_raise_if_all_candidates_failed(results)
if self.return_predictions and self.is_portfolio_estimator_:
predictions = np.empty(len(results), dtype=object)
for i, res in enumerate(results):
predictions[i] = res["prediction"]
cv_results["predictions"] = predictions
self.cv_results_ = cv_results
if self.refit or not self.multimetric_:
refit_metric = self._get_refit_metric_name()
selection_metric = refit_metric or "score"
self.best_index_ = _select_best_index(
self.refit,
selection_metric,
cv_results,
)
self.best_params_ = all_params[self.best_index_]
if not callable(self.refit):
score_key = (
"mean_score"
if selection_metric == "score"
else f"mean_score_{selection_metric}"
)
self.best_score_ = float(cv_results[score_key][self.best_index_])
if self.refit:
best_candidate = results[self.best_index_]["estimator"]
if best_candidate is None:
warnings.warn(
"All parameter candidates failed during evaluation. "
"`best_estimator_` is not available.",
category=UserWarning,
stacklevel=2,
)
else:
self.best_estimator_ = best_candidate
def predict(self, X: ArrayLike):
"""Predict using the best estimator found during search.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns.
Returns
-------
prediction : Portfolio | Population
"""
skv.check_is_fitted(self, "best_estimator_")
return self.best_estimator_.predict(X)
def score(self, X: ArrayLike, y=None):
"""Score using the best estimator found during search.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns.
y : Ignored
Present for scikit-learn API compatibility.
Returns
-------
score : float
"""
skv.check_is_fitted(self, "best_estimator_")
return self.best_estimator_.score(X)
[docs]
class OnlineGridSearch(BaseOnlineSearch):
"""Online exhaustive hyperparameter search over a parameter grid.
Each parameter combination is evaluated by running a full online
walk-forward pass. The best estimator is selected based on the
aggregate out-of-sample score.
Parameters
----------
estimator : BaseEstimator
Estimator that supports `partial_fit`.
param_grid : dict or list[dict]
Dictionary with parameters names (`str`) as keys and lists of parameter
settings to try as values, or a list of such dictionaries, in which case the
grids spanned by each dictionary in the list are explored. This enables
searching over any sequence of parameter settings.
scoring : callable, dict, BaseMeasure, or None
Scoring specification. Semantics depend on the estimator type:
* **Component estimators** (e.g. covariance, expected returns):
`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`.
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.
warmup_size : int, default=252
Number of initial observations (or periods when `freq` is set) used
for the first `partial_fit` call.
test_size : int, default=1
Number of observations (or periods when `freq` is set) per test
window.
freq : str | pandas.offsets.BaseOffset, optional
Rebalancing frequency. When provided, `warmup_size` and `test_size`
are interpreted as period counts rather than observation counts, and
`X` must be a DataFrame with a `DatetimeIndex`. See
:class:`~skfolio.model_selection.WalkForward` for details and
examples.
freq_offset : pandas.offsets.BaseOffset | datetime.timedelta, optional
Offset applied to the `freq` boundaries. Only used when `freq` is
provided.
previous : bool, default=False
Only used when `freq` is provided. If `True`, period boundaries
that fall between observations snap to the previous observation;
otherwise they snap to the next.
purged_size : int, default=0
Number of observations (or periods) to skip between the last data the
model sees and the start of the test window.
reduce_test : bool, default=False
If `True`, the last test window is included even when it contains
fewer observations than `test_size`.
refit : bool, str, or callable, default=True
Controls how the best candidate is selected and whether the
selected fitted candidate is exposed as `best_estimator_`.
This parameter is named for API alignment with scikit-learn.
Unlike scikit-learn search estimators, enabling `refit` does
not trigger an additional fit after model selection because
each candidate is already evaluated through a full online
walk-forward pass and updated through the full sample.
* Single-metric scoring: `True` or `False` are both supported.
If `False`, `best_estimator_` is not stored, but
`best_index_`, `best_params_`, and `best_score_` remain
available.
* Multi-metric scoring: set to a scorer name to select the best
candidate for that metric, or to `False` to disable
best-candidate selection and storage of `best_estimator_`.
* A callable receives `cv_results_` and must return the best
candidate index.
error_score : "raise" or float, default=np.nan
Value to assign to the score if an error occurs during fitting.
If set to `"raise"`, the error is raised.
return_predictions : bool, default=False
If `True`, store
:class:`~skfolio.portfolio.MultiPeriodPortfolio` objects per
candidate in `cv_results_["predictions"]`. Only applies to
portfolio optimization estimators.
portfolio_params : dict, optional
Parameters forwarded to
:class:`~skfolio.portfolio.MultiPeriodPortfolio` when scoring
portfolio estimators.
entry_rebalancing_params : dict, optional
Estimator parameters applied only while constructing the first portfolio of
each candidate's online path. 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 use
lower `transaction_costs` or require a valid initial solution with
`fallback=None`. Only supported for portfolio optimization estimators.
n_jobs : int, optional
Number of parallel jobs. `None` means 1.
verbose : int, default=0
Verbosity level for `joblib.Parallel`.
Attributes
----------
cv_results_ : dict[str, ndarray]
A dict with keys:
* `params`: list of candidate parameter dicts.
* `mean_score`: array of aggregate scores (or
`mean_score_<name>` for multi-metric).
* `rank`: array of ranks where 1 is best (or `rank_<name>`
for multi-metric).
* `fit_time`: array of wall-clock times.
* `predictions`: object array of `MultiPeriodPortfolio` or `None`
aligned with candidates (only when `return_predictions=True` and
the estimator is portfolio-based).
best_estimator_ : BaseEstimator
Estimator fitted on the full data with the best parameters.
Only available when `refit` is not `False`.
best_score_ : float
Aggregate score of the selected best candidate. Available when
`best_index_` is defined and `refit` is not callable.
best_params_ : dict
Parameter setting that gave the selected best score. Available when
`best_index_` is defined.
best_index_ : int
Index into `cv_results_` of the best candidate. Available for
single-metric scoring and for multi-metric scoring when `refit` is
not `False`.
multimetric_ : bool
Whether or not the scorers compute several metrics.
is_portfolio_estimator_ : bool
Whether or not the estimator is a portfolio optimization estimator.
See Also
--------
:ref:`sphx_glr_auto_examples_online_learning_plot_2_online_hyperparameter_tuning.py`
Exhaustive online tuning of covariance estimator hyperparameters.
:ref:`sphx_glr_auto_examples_online_learning_plot_3_online_portfolio_optimization_evaluation.py`
Exhaustive online tuning of a `MeanRisk` estimator.
Examples
--------
>>> from skfolio.datasets import load_sp500_dataset
>>> from skfolio.model_selection import OnlineGridSearch
>>> 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(),
... covariance_estimator=EWCovariance(),
... ),
... )
>>> search = OnlineGridSearch( # doctest: +SKIP
... model,
... param_grid={
... "prior_estimator__mu_estimator__half_life": [20, 40, 60],
... "prior_estimator__covariance_estimator__half_life": [20, 40, 60],
... },
... warmup_size=252,
... test_size=5,
... n_jobs=-1,
... )
>>> search.fit(X) # doctest: +SKIP
>>> search.best_params_ # doctest: +SKIP
>>> search.best_estimator_ # doctest: +SKIP
"""
def __init__(
self,
estimator: skb.BaseEstimator,
param_grid: dict | list[dict],
*,
scoring=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,
refit: bool | str | Callable[[dict[str, Any]], int] = True,
error_score=np.nan,
return_predictions: bool = False,
portfolio_params: dict | None = None,
entry_rebalancing_params: dict | None = None,
n_jobs: int | None = None,
verbose: int = 0,
):
super().__init__(
estimator=estimator,
scoring=scoring,
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=refit,
error_score=error_score,
return_predictions=return_predictions,
portfolio_params=portfolio_params,
entry_rebalancing_params=entry_rebalancing_params,
n_jobs=n_jobs,
verbose=verbose,
)
self.param_grid = param_grid
def _get_candidate_params(self) -> Iterable[dict]:
return ParameterGrid(self.param_grid)
[docs]
class OnlineRandomizedSearch(BaseOnlineSearch):
"""Online randomized search on hyperparameters.
Each sampled parameter combination is evaluated by running a full online
walk-forward pass. Unlike :class:`OnlineGridSearch`, not all parameters are tried
out, but a fixed number of parameter settings are sampled from the specified
distributions. The number of parameter settings that are tried is given by `n_iter`.
If all parameters are presented as a list, sampling without replacement is
performed. If at least one parameter is given as a distribution, sampling with
replacement is used. It is highly recommended to use continuous distributions for
continuous parameters.
Parameters
----------
estimator : BaseEstimator
Estimator that supports `partial_fit`.
param_distributions : dict or list of dicts
Dictionary with parameters names (`str`) as keys and distributions
or lists of parameters to try. Distributions must provide a `rvs`
method for sampling (such as those from scipy.stats.distributions).
If a list is given, it is sampled uniformly.
If a list of dicts is given, first a dict is sampled uniformly, and
then a parameter is sampled using that dict as above.
n_iter : int, default=10
Number of parameter settings that are sampled. n_iter trades
off runtime vs quality of the solution.
scoring : callable, dict, BaseMeasure, or None
Scoring specification. Semantics depend on the estimator type:
* **Component estimators** (e.g. covariance, expected returns):
`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`.
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.
warmup_size : int, default=252
Number of initial observations (or periods when `freq` is set) used
for the first `partial_fit` call.
test_size : int, default=1
Number of observations (or periods when `freq` is set) per test
window.
freq : str | pandas.offsets.BaseOffset, optional
Rebalancing frequency. When provided, `warmup_size` and `test_size`
are interpreted as period counts rather than observation counts, and
`X` must be a DataFrame with a `DatetimeIndex`. See
:class:`~skfolio.model_selection.WalkForward` for details and
examples.
freq_offset : pandas.offsets.BaseOffset | datetime.timedelta, optional
Offset applied to the `freq` boundaries. Only used when `freq` is
provided.
previous : bool, default=False
Only used when `freq` is provided. If `True`, period boundaries
that fall between observations snap to the previous observation;
otherwise they snap to the next.
purged_size : int, default=0
Number of observations (or periods) to skip between the last data the
model sees and the start of the test window.
reduce_test : bool, default=False
If `True`, the last test window is included even when it contains
fewer observations than `test_size`.
refit : bool, str, or callable, default=True
Controls how the best candidate is selected and whether the
selected fitted candidate is exposed as `best_estimator_`.
This parameter is named for API alignment with scikit-learn.
Unlike scikit-learn search estimators, enabling `refit` does
not trigger an additional fit after model selection because
each candidate is already evaluated through a full online
walk-forward pass and updated through the full sample.
* Single-metric scoring: `True` or `False` are both supported.
If `False`, `best_estimator_` is not stored, but
`best_index_`, `best_params_`, and `best_score_` remain
available.
* Multi-metric scoring: set to a scorer name to select the best
candidate for that metric, or to `False` to disable
best-candidate selection and storage of `best_estimator_`.
* A callable receives `cv_results_` and must return the best
candidate index.
random_state : int, RandomState instance, optional
Pseudo random number generator state used for random uniform sampling
from lists of possible values instead of scipy.stats distributions.
Pass an int for reproducible output across multiple function calls.
error_score : "raise" or float, default=np.nan
Value to assign to the score if an error occurs during fitting.
If set to `"raise"`, the error is raised.
return_predictions : bool, default=False
If `True`, store
:class:`~skfolio.portfolio.MultiPeriodPortfolio` objects per
candidate in `cv_results_["predictions"]`. Only applies to
portfolio optimization estimators.
portfolio_params : dict, optional
Parameters forwarded to
:class:`~skfolio.portfolio.MultiPeriodPortfolio` when scoring
portfolio estimators.
entry_rebalancing_params : dict, optional
Estimator parameters applied only while constructing the first portfolio of
each candidate's online path. 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 use
lower `transaction_costs` or require a valid initial solution with
`fallback=None`. Only supported for portfolio optimization estimators.
n_jobs : int, optional
Number of parallel jobs. `None` means 1.
verbose : int, default=0
Verbosity level for `joblib.Parallel`.
Attributes
----------
cv_results_ : dict[str, ndarray]
A dict with keys:
* `params`: list of candidate parameter dicts.
* `mean_score`: array of aggregate scores (or
`mean_score_<name>` for multi-metric).
* `rank`: array of ranks where 1 is best (or `rank_<name>`
for multi-metric).
* `fit_time`: array of wall-clock times.
* `predictions`: object array of `MultiPeriodPortfolio` or `None`
aligned with candidates (only when `return_predictions=True` and
the estimator is portfolio-based).
best_estimator_ : BaseEstimator
Estimator fitted on the full data with the best parameters.
Only available when `refit` is not `False`.
best_score_ : float
Aggregate score of the selected best candidate. Available when
`best_index_` is defined and `refit` is not callable.
best_params_ : dict
Parameter setting that gave the selected best score. Available when
`best_index_` is defined.
best_index_ : int
Index into `cv_results_` of the best candidate. Available for
single-metric scoring and for multi-metric scoring when `refit` is
not `False`.
multimetric_ : bool
Whether or not the scorers compute several metrics.
is_portfolio_estimator_ : bool
Whether or not the estimator is a portfolio optimization estimator.
See Also
--------
:ref:`sphx_glr_auto_examples_online_learning_plot_2_online_hyperparameter_tuning.py`
Randomized online tuning of covariance estimator hyperparameters.
Examples
--------
>>> from scipy.stats import uniform
>>> from skfolio.datasets import load_sp500_dataset
>>> from skfolio.model_selection import OnlineRandomizedSearch
>>> 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(),
... covariance_estimator=EWCovariance(),
... ),
... )
>>> search = OnlineRandomizedSearch( # doctest: +SKIP
... model,
... param_distributions={
... "prior_estimator__mu_estimator__half_life": uniform(10, 90),
... "prior_estimator__covariance_estimator__half_life": uniform(10, 90),
... },
... n_iter=20,
... warmup_size=252,
... test_size=5,
... n_jobs=-1,
... random_state=42,
... )
>>> search.fit(X) # doctest: +SKIP
>>> search.best_params_ # doctest: +SKIP
>>> search.best_estimator_ # doctest: +SKIP
"""
def __init__(
self,
estimator: skb.BaseEstimator,
param_distributions: dict,
*,
n_iter: int = 10,
scoring=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,
refit: bool | str | Callable[[dict[str, Any]], int] = True,
random_state: int | None = None,
error_score=np.nan,
return_predictions: bool = False,
portfolio_params: dict | None = None,
entry_rebalancing_params: dict | None = None,
n_jobs: int | None = None,
verbose: int = 0,
):
super().__init__(
estimator=estimator,
scoring=scoring,
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=refit,
error_score=error_score,
return_predictions=return_predictions,
portfolio_params=portfolio_params,
entry_rebalancing_params=entry_rebalancing_params,
n_jobs=n_jobs,
verbose=verbose,
)
self.param_distributions = param_distributions
self.n_iter = n_iter
self.random_state = random_state
def _get_candidate_params(self) -> Iterable[dict]:
return ParameterSampler(
self.param_distributions,
n_iter=self.n_iter,
random_state=self.random_state,
)
def _evaluate_candidate(
estimator: skb.BaseEstimator,
candidate_params: dict[str, Any],
X: ArrayLike,
y: ArrayLike | None,
*,
scoring: skt.Scoring,
routed_params: sku.Bunch,
warmup_size: int,
test_size: int,
freq: str | pd.offsets.BaseOffset | None,
freq_offset: pd.offsets.BaseOffset | dt.timedelta | None,
previous: bool,
purged_size: int,
reduce_test: bool,
return_predictions: bool,
portfolio_params: dict | None,
entry_rebalancing_params: dict | None,
error_score: float | Literal["raise"],
) -> dict[str, Any]:
"""Evaluate a single parameter combination.
Clones the estimator, sets the candidate parameters, runs the full
online walk-forward, and returns a result dict.
"""
candidate = sk.clone(estimator)
candidate.set_params(**candidate_params)
_validate_entry_rebalancing_params(candidate, entry_rebalancing_params)
result: dict[str, Any] = {"params": candidate_params}
start = time.perf_counter()
try:
agg_score, multi_period_portfolio = _evaluate_online(
candidate,
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,
refit_last=True,
portfolio_params=portfolio_params,
entry_rebalancing_params=entry_rebalancing_params,
)
except Exception as e:
if error_score != "raise":
warnings.warn(
f"Estimator fit failed. The score will be set to {error_score}. "
f"Details: \n{e!r}",
category=UserWarning,
stacklevel=2,
)
agg_score = _make_error_score(scoring, error_score)
multi_period_portfolio = None
candidate = None
else:
raise
elapsed = time.perf_counter() - start
result["score"] = agg_score
result["fit_time"] = elapsed
result["estimator"] = candidate
result["prediction"] = multi_period_portfolio if return_predictions else None
return result
def _rank_scores(scores: FloatArray) -> IntArray:
"""Rank scores in descending order (1 = best / highest).
NaN values are assigned the worst rank (tied last).
"""
scores = np.asarray(scores, dtype=np.float64)
if scores.size == 0:
return np.array([], dtype=np.int32)
if np.isnan(scores).all():
return np.ones_like(scores, dtype=np.int32)
min_score = np.nanmin(scores) - 1
scores = np.nan_to_num(scores, nan=min_score)
return rankdata(-scores, method="min").astype(np.int32, copy=False)
def _check_refit_for_multimetric(
refit: bool | str | Callable[[dict[str, Any]], int],
scores: dict[str, Any],
) -> None:
"""Validate `refit` for multi-metric scoring."""
if refit is False:
return
if isinstance(refit, str) and refit in scores:
return
if callable(refit):
return
raise ValueError(
"For multi-metric scoring, the parameter refit must be set to a "
"scorer key or a callable to refit an estimator with the best "
"parameter setting on the whole data and make the best_* "
f"attributes available for that metric. If this is not needed, refit "
f"should be set to False explicitly. {refit!r} was passed."
)
def _select_best_index(
refit: bool | str | Callable[[dict[str, Any]], int],
refit_metric: str,
cv_results: dict[str, Any],
) -> int:
"""Select the best candidate index from `cv_results_`."""
if callable(refit):
best_index = refit(cv_results)
if not isinstance(best_index, numbers.Integral):
raise TypeError("best_index_ returned is not an integer")
if best_index < 0 or best_index >= len(cv_results["params"]):
raise IndexError("best_index_ index out of range")
return int(best_index)
rank_key = "rank" if refit_metric == "score" else f"rank_{refit_metric}"
return int(cv_results[rank_key].argmin())
def _make_error_score(
scoring: skt.Scoring, error_score: float
) -> float | dict[str, float]:
"""Build an error score matching the scoring output shape."""
error_score = float(error_score)
if isinstance(scoring, dict):
return {name: error_score for name in scoring}
return error_score
def _raise_if_all_candidates_failed(results: list[dict[str, Any]]) -> None:
"""Raise when every candidate failed during evaluation."""
if not results or any(result["estimator"] is not None for result in results):
return
raise ValueError(
"All parameter candidates failed during evaluation. It is very likely "
"that your model is misconfigured. You can try to debug the error by "
"setting error_score='raise'."
)
def _validate_error_score(error_score: float | Literal["raise"]) -> None:
"""Validate the `error_score` parameter."""
if error_score == "raise":
return
if isinstance(error_score, bool) or not isinstance(error_score, numbers.Real):
raise ValueError(
"error_score must be the string 'raise' or a real number, "
f"got {error_score!r}."
)