Source code for skfolio.model_selection._validation

"""Model validation module."""

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

from __future__ import annotations

import warnings
from collections import defaultdict
from typing import TYPE_CHECKING

import numpy as np
import sklearn as sk
import sklearn.base as skb
import sklearn.exceptions as ske
import sklearn.model_selection as sks
import sklearn.utils as sku
import sklearn.utils.metadata_routing as skm
import sklearn.utils.parallel as skp
from sklearn.pipeline import Pipeline

from skfolio.model_selection._combinatorial import BaseCombinatorialCV
from skfolio.model_selection._multiple_randomized_cv import MultipleRandomizedCV
from skfolio.model_selection._walk_forward import WalkForward
from skfolio.population import Population
from skfolio.portfolio import MultiPeriodPortfolio, Portfolio
from skfolio.typing import ArrayLike, IntArray
from skfolio.utils.tools import fit_and_predict, safe_split

if TYPE_CHECKING:
    from skfolio.optimization._base import BaseOptimization


[docs] def cross_val_predict( estimator: BaseOptimization | Pipeline, X: ArrayLike, y: ArrayLike = None, cv: sks.BaseCrossValidator | BaseCombinatorialCV | MultipleRandomizedCV | int | None = None, n_jobs: int | None = None, method: str = "predict", verbose: int = 0, params: dict | None = None, pre_dispatch: str = "2*n_jobs", column_indices: IntArray | None = None, portfolio_params: dict | None = None, entry_rebalancing_params: dict | None = None, ) -> MultiPeriodPortfolio | Population: """Generate cross-validated `Portfolios` estimates. The data is split according to the `cv` parameter. The optimization estimator is fitted on the training set and portfolios are predicted on the corresponding test set. For single-path cross-validation such as `KFold` or :class:`~skfolio.model_selection.WalkForward`, the output is a :class:`~skfolio.portfolio.MultiPeriodPortfolio` where each :class:`~skfolio.portfolio.Portfolio` corresponds to a train/test split (`k` portfolios for `KFold`). For multi-path cross-validation such as :class:`~skfolio.model_selection.CombinatorialPurgedCV` or :class:`~skfolio.model_selection.MultipleRandomizedCV`, the output is a :class:`~skfolio.population.Population` of multiple :class:`~skfolio.portfolio.MultiPeriodPortfolio` objects (each test produces a collection of paths rather than a single path). If the final estimator in the pipeline (or the estimator itself) declares `needs_previous_weights=True`, this function automatically propagates `previous_weights` from one fold to the next for sequential CV strategies (e.g., `WalkForward` or `MultipleRandomizedCV`). Parameters ---------- estimator : BaseEstimator | Pipeline Portfolio optimization estimator or pipeline whose last step is an optimization estimator. X : array-like of shape (n_observations, n_assets) Price returns of the assets. y : array-like of shape (n_observations, n_targets), optional Target data (optional). For example, the price returns of the factors. cv : int | cross-validation generator, optional Determines the cross-validation splitting strategy. Possible inputs for cv are: * None, to use the default 5-fold cross validation, * int, to specify the number of folds in a `(Stratified)KFold`, * `CV splitter`, * An iterable that generates (train, test) splits as arrays of indices. n_jobs : int, optional The number of jobs to run in parallel for `fit` of all `estimators`. `None` means 1 unless in a `joblib.parallel_backend` context. -1 means using all processors. method : str Invokes the passed method name of the passed estimator. verbose : int, default=0 The verbosity level. params : dict, optional Parameters to pass to the underlying estimator's `fit` and the CV splitter. pre_dispatch : int or str, default='2*n_jobs' Controls the number of jobs that get dispatched during parallel execution. Reducing this number can be useful to avoid an explosion of memory consumption when more jobs get dispatched than CPUs can process. This parameter can be: * None, in which case all the jobs are immediately created and spawned. Use this for lightweight and fast-running jobs, to avoid delays due to on-demand spawning of the jobs * An int, giving the exact number of total jobs that are spawned * A str, giving an expression as a function of n_jobs, as in '2*n_jobs' column_indices : ndarray, optional Indices of the `X` columns to cross-validate on. portfolio_params : dict, optional Additional portfolio parameters passed to `MultiPeriodPortfolio`. entry_rebalancing_params : dict, optional Estimator parameters applied only while constructing the first portfolio of each sequential 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 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 used for all subsequent optimizations. When provided, `cross_val_predict` evaluates a sequential strategy path and propagates `previous_weights` between portfolios. This is only supported for sequential CV strategies such as :class:`~skfolio.model_selection.WalkForward`, :class:`~sklearn.model_selection.TimeSeriesSplit` and :class:`~skfolio.model_selection.MultipleRandomizedCV`. Returns ------- predictions : MultiPeriodPortfolio | Population This is the result of calling `predict` """ if not _is_portfolio_optimization_estimator(estimator): raise TypeError( "skfolio's `cross_val_predict` only supports portfolio optimization " "estimators. For non-portfolio optimization estimators, use " "`sklearn.model_selection.cross_val_predict`." ) X, y = safe_split(X, y, indices=column_indices, axis=1) X, y = sku.indexable(X, y) routed_params = _route_params( estimator, params, cv=cv, owner="cross_val_predict", callee="fit", ) cv = sks.check_cv(cv, y) splits = list(cv.split(X, y, **routed_params.splitter.split)) portfolio_params = {} if portfolio_params is None else portfolio_params.copy() # We ensure that the folds are not shuffled if not isinstance(cv, BaseCombinatorialCV | MultipleRandomizedCV): try: if cv.shuffle: raise ValueError( "`cross_val_predict` only works with cross-validation setting" " `shuffle=False`" ) except AttributeError: # If we cannot find the attribute shuffle, we check if the first folds # are shuffled for fold in splits[0]: if not np.all(np.diff(fold) > 0): raise ValueError( "`cross_val_predict` only works with un-shuffled folds" ) from None # estimator can be a Pipeline last_step = _get_last_step(estimator) is_sequential_cv = isinstance( cv, WalkForward | MultipleRandomizedCV | sks.TimeSeriesSplit ) use_sequential_path = ( getattr(last_step, "needs_previous_weights", False) or entry_rebalancing_params is not None ) if entry_rebalancing_params is not None and not is_sequential_cv: raise ValueError( "`entry_rebalancing_params` is only supported with sequential CV " "strategies: `WalkForward`, `TimeSeriesSplit` and `MultipleRandomizedCV`." ) if use_sequential_path and is_sequential_cv: if isinstance(cv, MultipleRandomizedCV): splits = list(cv.split(X, y, **routed_params.splitter.split)) path_ids = cv.get_path_ids() paths = defaultdict(list) for (train, test, col_idx), pid in zip(splits, path_ids, strict=True): paths[pid].append((train, test, col_idx)) parallel = skp.Parallel( n_jobs=n_jobs, verbose=verbose, pre_dispatch=pre_dispatch ) predictions = parallel( skp.delayed(_run_path)( estimator=estimator, X=X, y=y, routed_params=routed_params, method=method, path_splits=paths[pid], entry_rebalancing_params=entry_rebalancing_params, ) for pid in sorted(paths.keys()) ) predictions = [ptf for path in predictions for ptf in path] else: if n_jobs not in (None, 1): warnings.warn( "Parallel processing has been disabled because the optimization " "method requires sequential processing of previous weights or " "`entry_rebalancing_params`. To suppress this warning, set " "`n_jobs=None`, remove `entry_rebalancing_params`, or disable " "sequential processing of previous weights by setting your " "Optimization's `needs_previous_weights` attribute to False.", stacklevel=2, ) predictions = _run_path( estimator=estimator, X=X, y=y, routed_params=routed_params, method=method, path_splits=splits, entry_rebalancing_params=entry_rebalancing_params, ) else: # We clone the estimator to make sure that all the folds are independent # and that it is pickle-able. parallel = skp.Parallel( n_jobs=n_jobs, verbose=verbose, pre_dispatch=pre_dispatch ) # TODO remove when https://github.com/joblib/joblib/issues/1071 is fixed predictions = parallel( skp.delayed(fit_and_predict)( sk.clone(estimator), X, y, train=train, test=test, fit_params=routed_params.estimator_params, method=method, column_indices=column_indices[0] if column_indices else None, ) for train, test, *column_indices in splits ) if isinstance(cv, BaseCombinatorialCV | MultipleRandomizedCV): path_ids = cv.get_path_ids() path_nb = np.max(path_ids) + 1 portfolios = [[] for _ in range(path_nb)] if isinstance(cv, BaseCombinatorialCV): for i, prediction in enumerate(predictions): for j, p in enumerate(prediction): path_id = path_ids[i, j] portfolios[path_id].append(p) else: for i, prediction in enumerate(predictions): portfolios[path_ids[i]].append(prediction) name = portfolio_params.pop("name", "path") pred = Population( [ MultiPeriodPortfolio( name=f"{name}_{i}", portfolios=portfolios[i], **portfolio_params ) for i in range(path_nb) ] ) else: # We need to re-order the test folds in case they were un-ordered by the # CV generator. # Because the tests folds are not shuffled, we use the first index of each # fold to order them. test_indices = [test for _, test in splits] concat = np.concatenate(test_indices) if np.unique(concat, axis=0).shape[0] != concat.shape[0]: raise ValueError( "`cross_val_predict` only works with non-duplicated test indices" ) sorted_fold_id = np.argsort([x[0] for x in test_indices]) pred = MultiPeriodPortfolio( portfolios=[predictions[fold_id] for fold_id in sorted_fold_id], check_observations_order=False, **portfolio_params, ) return pred
def _routing_enabled() -> bool: """Return whether metadata routing is enabled. Returns ------- enabled: bool Whether metadata routing is enabled. If the config is not set, it defaults to False. """ return sk.get_config().get("enable_metadata_routing", False) def _route_params( estimator: skb.BaseEstimator | Pipeline, params: dict | None = None, *, owner: str, callee: str, cv: object | None = None, ) -> sku.Bunch: """Build routed parameter bunches for an estimator method. Parameters ---------- estimator : BaseEstimator | Pipeline The estimator (or pipeline) to route parameters for. params : dict, optional Raw parameters from the caller. owner : str Name of the calling function, used in error messages. callee : str Estimator method that will receive the routed parameters. Use `"fit"` for batch evaluation and `"partial_fit"` for online evaluation. cv : cross-validator or None, default=None Cross-validation splitter. When provided, parameters are also routed to the splitter's `split` method and the result includes `routed_params.splitter.split`. Returns ------- routed_params : Bunch Routed parameters with `.estimator_params` attribute and, when `cv` is provided, `.splitter.split`. Values are plain dictionaries, so the result can be passed to worker processes. Raises ------ UnsetMetadataPassedError If metadata routing is enabled and `params` contains metadata that the estimator has not explicitly requested. """ params = params or {} if not params or not _routing_enabled(): # With routing disabled the parameters are passed through unchanged, and with no # metadata there is nothing to route. `process_routing` is bypassed because it # returns a placeholder object that cannot be pickled when metadata is empty. routed_params = sku.Bunch(estimator_params=params) if cv is not None: routed_params.splitter = sku.Bunch(split={}) return routed_params # For estimators, a MetadataRouter is created in get_metadata_routing # methods. For these router methods, we create the router to use # `process_routing` on it. router = skm.MetadataRouter(owner=owner) if cv is not None: router.add( splitter=cv, method_mapping=skm.MethodMapping().add(caller="fit", callee="split"), ) router.add( estimator=estimator, method_mapping=skm.MethodMapping().add(caller="fit", callee=callee), ) try: router_params = skm.process_routing(router, "fit", **params) except ske.UnsetMetadataPassedError as e: # The default exception would mention `fit` since in the above # `process_routing` code, we pass `fit` as the caller. However, # the user is not calling `fit` directly, so we change the message # to make it more suitable for this case. unrequested_params = sorted(e.unrequested_params) request_method = f"set_{callee}_request" raise ske.UnsetMetadataPassedError( message=( f"{unrequested_params} are passed to `{owner}` but are" " not explicitly set as requested or not requested for" f" {owner}'s estimator: " f"{estimator.__class__.__name__}. Call" f" `.{request_method}({{metadata}}=True)` on the estimator" f" for each metadata in {unrequested_params} that you want" " to use and `metadata=False` if you are not using it. See the" " Metadata Routing User guide" " <https://scikit-learn.org/stable/metadata_routing.html>" " for more information." ), unrequested_params=e.unrequested_params, routed_params=e.routed_params, ) from None # Keep only the payload as plain dictionaries: the result is passed to worker # processes. routed_params = sku.Bunch(estimator_params=dict(router_params.estimator[callee])) if cv is not None: routed_params.splitter = sku.Bunch(split=dict(router_params.splitter.split)) return routed_params def _asset_names_enabled(X: ArrayLike) -> bool: """Return whether X is a DataFrame and its column names are transferred inside a Pipeline. """ return hasattr(X, "columns") and sk.get_config().get("transform_output") in [ "pandas", "polars", ] def _get_last_step(estimator: skb.BaseEstimator | Pipeline) -> skb.BaseEstimator: """Return the final estimator to be fitted/predicted. If `estimator` is a `Pipeline`, returns its last step; otherwise returns `estimator` itself. Parameters ---------- estimator : BaseEstimator | Pipeline Estimator or pipeline passed to cross-validation. Returns ------- BaseEstimator The final estimator (last step when a pipeline). """ if isinstance(estimator, Pipeline): return estimator[-1] return estimator def _is_portfolio_optimization_estimator( estimator: skb.BaseEstimator | Pipeline, ) -> bool: """Return whether the estimator or the last pipeline step is a portfolio optimization estimator. Parameters ---------- estimator : BaseEstimator or Pipeline Estimator to inspect. If a `Pipeline` is provided, its last step is inspected. Returns ------- is_portfolio_optimization_estimator : bool `True` when `estimator` itself is a portfolio optimization estimator, or, for a `Pipeline`, when its last step is one. """ from skfolio.optimization._base import BaseOptimization return isinstance(_get_last_step(estimator), BaseOptimization) def _apply_entry_rebalancing_params( estimator: skb.BaseEstimator | Pipeline, entry_rebalancing_params: dict | None, ) -> dict | None: """Apply temporary parameters to the final optimization estimator. Parameters ---------- estimator : BaseEstimator | Pipeline Portfolio optimization estimator or pipeline whose last step is an optimization estimator. entry_rebalancing_params : dict, optional Parameters to apply while constructing the first portfolio. Returns ------- previous_params : dict | None Original parameter values to restore after the first optimization, or `None` when no temporary parameters were provided. """ if entry_rebalancing_params is None: return None _validate_entry_rebalancing_params(estimator, entry_rebalancing_params) last_step = _get_last_step(estimator) valid_params = last_step.get_params(deep=True) previous_params = {name: valid_params[name] for name in entry_rebalancing_params} last_step.set_params(**entry_rebalancing_params) return previous_params def _validate_entry_rebalancing_params( estimator: skb.BaseEstimator | Pipeline, entry_rebalancing_params: dict | None, ) -> None: """Validate parameters applied only to the first portfolio.""" if entry_rebalancing_params is None: return last_step = _get_last_step(estimator) valid_params = last_step.get_params(deep=True) unknown_params = sorted(set(entry_rebalancing_params) - set(valid_params)) if unknown_params: raise ValueError( "`entry_rebalancing_params` contains invalid parameter names for " f"{last_step.__class__.__name__}: {unknown_params}." ) def _run_path( estimator: skb.BaseEstimator | Pipeline, X: ArrayLike, y: ArrayLike | None, routed_params: sku.Bunch, method: str, path_splits: list[tuple[IntArray, IntArray, IntArray | None]], entry_rebalancing_params: dict | None = None, ) -> list[Portfolio]: """Run sequential fit/predict along a single path of ordered splits. Used when the final estimator requires previous portfolio weights between consecutive folds (e.g. walk-forward validation). The function propagates the `previous_weights` from the prediction of the previous fold to the next one. Parameters ---------- estimator : BaseEstimator | Pipeline Estimator or pipeline to clone and fit on each split. X : array-like of shape (n_observations, n_assets) Asset returns. y : array-like of shape (n_observations, n_targets), optional Optional target data (e.g., factor returns). routed_params : Bunch Fit parameters after metadata routing (`routed_params.estimator_params`). method : str Estimator method to call on the test fold (e.g. `"predict"`). path_splits : list of tuple Sequence of `(train_idx, test_idx[, column_indices])` describing one path of folds. `column_indices` can be `None`. entry_rebalancing_params : dict, optional Parameters applied only while constructing the first portfolio in the path. Returns ------- list[Portfolio] Portfolios predicted for each test fold in the path, in order. """ use_dict = _asset_names_enabled(X) predictions = [] prev_weights = _get_last_step(estimator).previous_weights for i, (train, test, *column_indices) in enumerate(path_splits): est = sk.clone(estimator) last_step = _get_last_step(est) last_step.set_params(previous_weights=prev_weights) if i == 0: _apply_entry_rebalancing_params(est, entry_rebalancing_params) ptf = fit_and_predict( est, X, y, train=train, test=test, fit_params=routed_params.estimator_params, method=method, column_indices=column_indices[0] if column_indices else None, ) predictions.append(ptf) prev_weights = ptf.weights_dict if use_dict else ptf.weights return predictions