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 collections.abc import Iterator, Sequence
from itertools import chain, islice
from typing import TYPE_CHECKING, Any, TypeVar

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._constants import _RISK_FREE_RATE
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 FailedPortfolio, MultiPeriodPortfolio, Portfolio
from skfolio.portfolio._base import (
    _PORTFOLIO_MEASURE_PARAMS,
    _normalize_annualization_factor_alias,
)
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

_EstimatorT = TypeVar("_EstimatorT", bound=skb.BaseEstimator)


[docs] def cross_val_predict( estimator: BaseOptimization | Pipeline | Sequence[tuple[str, 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. 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). When a list of `(name, estimator)` pairs is provided, all estimators are evaluated on the same splits and their portfolios are returned together in a :class:`~skfolio.population.Population`. 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 | sequence of (str, estimator) Portfolio optimization estimator or pipeline whose last step is an optimization estimator. To evaluate several estimators, pass a sequence of `(name, estimator)` pairs with unique string names. 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. `None` means 1 unless in a `joblib.parallel_backend` context. -1 means using all processors. Named estimators share one worker pool. Folds that depend on previous holdings run sequentially within each path. 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 Portfolio parameters for the evaluation. Parameters shared by `Portfolio` and `MultiPeriodPortfolio` (`compounded`, `risk_free_rate`, `annualization_factor`, `fitness_measures` and the risk measure parameters) are applied to the returned `MultiPeriodPortfolio` and to each `Portfolio` it contains. A value passed here takes precedence over the optimizer's `portfolio_params`. When omitted here, it is inherited from the optimizer's `portfolio_params`. When omitted from both, `risk_free_rate` falls back to the optimizer's `risk_free_rate` parameter when it has one. These parameters only affect how the portfolios are measured, not the optimization. `weight_drift` applies to each `Portfolio` of the path. With `weight_drift=True`, the weights held within each test window drift with the asset returns, and the path runs sequentially: the `ending_weights` of each portfolio are passed as `previous_weights` to the next fit. A value passed here overrides the optimizer's `portfolio_params`. Optimization parameters such as `transaction_costs`, `management_fees` and `previous_weights` are not accepted here. Set them on the optimization estimator. `name`, `tag`, `sample_weight` and `check_observations_order` apply to the returned `MultiPeriodPortfolio` only. For named estimators, these settings apply to every estimator. `name` and `tag` are set from the estimator names and cannot be overridden here. entry_rebalancing_params : dict, optional Optimization 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`. For named estimators, these settings apply to every estimator. Returns ------- predictions : MultiPeriodPortfolio | Population A `MultiPeriodPortfolio` for single-path CV, or a `Population` for multi-path CV. Named estimators always return a `Population`, with one `MultiPeriodPortfolio` per estimator and path, ordered by estimator then path. Estimator names set `name` and `tag`. Notes ----- With a sequential CV, each portfolio's `ending_weights` are passed as `previous_weights` to the next fit when the estimator needs them. Otherwise, fits remain independent and previous weights are assigned to the predicted portfolios afterward for turnover and cost calculations. Ending weights equal the target `weights` when `weight_drift=False` and the weights after the last observation when `weight_drift=True`. Failed and empty portfolios are skipped when propagating holdings. With a non-sequential CV, drift is applied inside each test fold and nothing is propagated. Examples -------- Evaluate an inverse-volatility portfolio with walk-forward cross-validation: >>> from skfolio.datasets import load_sp500_dataset >>> from skfolio.model_selection import WalkForward, cross_val_predict >>> from skfolio.optimization import EqualWeighted, InverseVolatility >>> from skfolio.preprocessing import prices_to_returns >>> prices = load_sp500_dataset() >>> X = prices_to_returns(prices) >>> cv = WalkForward(train_size=252, test_size=63) >>> mpp = cross_val_predict(InverseVolatility(), X, cv=cv) # MultiPeriodPortfolio To compare estimators on the same splits, pass a list of named estimators: >>> estimators = [ ... ("equal_weighted", EqualWeighted()), ... ("inverse_volatility", InverseVolatility()), ... ] >>> population = cross_val_predict(estimators, X, cv=cv) >>> [mpp.name for mpp in population] ['equal_weighted', 'inverse_volatility'] """ is_collection = isinstance(estimator, Sequence) and not isinstance( estimator, str | bytes | bytearray ) named_estimators = ( _validate_named_estimators(estimator) if is_collection else [(None, estimator)] ) if is_collection: conflicting_params = sorted({"name", "tag"} & set(portfolio_params or {})) if conflicting_params: raise ValueError( f"{conflicting_params} cannot be set in `portfolio_params` when " "`cross_val_predict` receives named estimators: their names determine " "the portfolio names and tags." ) evaluations = [] for name, est in named_estimators: if not _is_portfolio_optimization_estimator(est): description = ( f", but the estimator named {name!r} is of type {type(est).__name__}" if is_collection else "" ) raise TypeError( "skfolio's `cross_val_predict` only supports portfolio optimization " f"estimators{description}. For non-portfolio optimization estimators, " "use `sklearn.model_selection.cross_val_predict`." ) est, evaluation_params, explicit_measure_params = ( _resolve_evaluation_portfolio_params(est, portfolio_params) # ty: ignore[invalid-argument-type] ) if is_collection: evaluation_params.update(name=name, tag=name) evaluations.append((est, evaluation_params, explicit_measure_params)) X, y = safe_split(X, y, indices=column_indices, axis=1) X, y = sku.indexable(X, y) cv = sks.check_cv(cv, y) routed_params = _route_params( [est for est, _, _ in evaluations], params, cv=cv, owner="cross_val_predict", callee="fit", ) fit_params = routed_params.estimator_params splits = list(cv.split(X, y, **routed_params.splitter.split)) if len(splits) == 0: raise ValueError( "The cross-validation strategy produced no splits. Check the number of " "observations and cross-validation parameters." ) # We ensure that the folds are not shuffled path_ids = None if isinstance(cv, BaseCombinatorialCV | MultipleRandomizedCV): path_ids = cv.get_path_ids() path_nb = np.max(path_ids) + 1 else: 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 test_indices = [split[1] for split 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" ) # Unshuffled test folds can still be yielded in a different order. sorted_fold_id = np.argsort([indices[0] for indices in test_indices]) is_sequential_cv = isinstance( cv, WalkForward | MultipleRandomizedCV | sks.TimeSeriesSplit ) 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`." ) use_sequential_paths = [ is_sequential_cv and ( getattr(_get_last_step(est), "needs_previous_weights", False) or entry_rebalancing_params is not None ) for est, _, _ in evaluations ] paths = [splits] if isinstance(cv, MultipleRandomizedCV) and any(use_sequential_paths): grouped_splits = defaultdict(list) for split, path_id in zip(splits, path_ids, strict=True): # ty: ignore[invalid-argument-type] grouped_splits[path_id].append(split) paths = [grouped_splits[path_id] for path_id in sorted(grouped_splits)] def tasks() -> Iterator[tuple]: """Yield jobs for `Parallel`, with sequential paths before independent folds.""" for (est, _, _), est_params, use_sequential_path in sorted( zip(evaluations, fit_params, use_sequential_paths, strict=True), key=lambda item: not item[2], ): if use_sequential_path: for path in paths: yield skp.delayed(_run_path)( estimator=est, X=X, y=y, routed_params=sku.Bunch(estimator_params=est_params), method=method, path_splits=path, entry_rebalancing_params=entry_rebalancing_params, ) else: for train, test, *column_indices in splits: yield skp.delayed(fit_and_predict)( sk.clone(est), X, y, train=train, test=test, fit_params=est_params, method=method, column_indices=column_indices[0] if column_indices else None, ) if len(evaluations) == 1 and use_sequential_paths[0] and len(paths) == 1: 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 the " "options that require previous weights, such as `weight_drift`, " "transaction costs, `max_turnover`, or a previous-weights fallback.", stacklevel=2, ) results = [ _run_path( estimator=evaluations[0][0], X=X, y=y, routed_params=sku.Bunch(estimator_params=fit_params[0]), method=method, path_splits=paths[0], entry_rebalancing_params=entry_rebalancing_params, ) ] else: parallel = skp.Parallel( n_jobs=n_jobs, verbose=verbose, pre_dispatch=pre_dispatch ) results = parallel(tasks()) n_path_tasks = len(paths) * sum(use_sequential_paths) path_results = islice(results, n_path_tasks) fold_results = islice(results, n_path_tasks, None) population = [] predictions: list[Any] for (_, evaluation_params, explicit_measure_params), use_sequential_path in zip( evaluations, use_sequential_paths, strict=True ): predictions = list( islice(path_results, len(paths)) if use_sequential_path else islice(fold_results, len(splits)) ) if use_sequential_path: predictions = list(chain.from_iterable(predictions)) if path_ids is not None: portfolios = [[] for _ in range(path_nb)] if isinstance(cv, BaseCombinatorialCV): # Combinatorial CV never runs the sequential path: each prediction is a # list of portfolios. 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 = evaluation_params.pop("name", "path") pred = Population( [ MultiPeriodPortfolio( name=f"{name}_{i}", portfolios=portfolios[i], **evaluation_params, ) for i in range(path_nb) ] ) else: pred = MultiPeriodPortfolio( portfolios=[predictions[fold_id] for fold_id in sorted_fold_id], **evaluation_params, ) if is_sequential_cv and not use_sequential_path: for path in pred if isinstance(pred, Population) else [pred]: path.portfolios = _propagate_previous_weights( portfolios=path.portfolios ) _sync_measure_params_to_portfolios(pred, explicit_measure_params) if not is_collection: return pred population.extend(pred if isinstance(pred, Population) else [pred]) return Population(population)
def _validate_named_estimators( estimators: Sequence, ) -> list[tuple[str, skb.BaseEstimator | Pipeline]]: """Validate a collection of estimators with unique string names.""" if not estimators: raise ValueError("Received an empty collection of estimators.") named_estimators = [] names = set() for item in estimators: if not isinstance(item, tuple | list) or len(item) != 2: raise TypeError( f"Each element must be a `(name, estimator)` pair, got {item!r}." ) name, estimator = item if not isinstance(name, str): raise TypeError(f"Estimator names must be strings, got {name!r}.") if not name: raise ValueError("Estimator names must be non-empty strings.") if name in names: raise ValueError( f"Estimator names must be unique, got duplicated names: {[name]}." ) names.add(name) named_estimators.append((name, estimator)) return named_estimators 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 | list[skb.BaseEstimator | Pipeline], params: dict | None = None, *, owner: str, callee: str, cv: object | None = None, ) -> sku.Bunch: """Build routed parameter bunches for estimator methods. Parameters ---------- estimator : BaseEstimator | Pipeline | list[BaseEstimator | Pipeline] The estimator (or pipeline) to route parameters for, or a list of them. 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` and, when `cv` is provided, `.splitter.split`. `.estimator_params` is a dictionary for a single estimator and a list with one dictionary per estimator, in input order, for a list input. The parameter payloads are plain dictionaries for use in worker processes. Raises ------ UnsetMetadataPassedError If metadata routing is enabled and `params` contains metadata whose request status is unset for an estimator or the splitter. """ is_collection = isinstance(estimator, list) estimators = ( {f"estimator_{i}": est for i, est in enumerate(estimator)} if is_collection else {"estimator": estimator} ) 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.copy() for _ in estimators] if is_collection else 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"), ) for key, est in estimators.items(): router.add( method_mapping=skm.MethodMapping().add(caller="fit", callee=callee), **{key: est}, ) 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" description = ( "estimator: " if len(estimators) == 1 else "estimators: " ) + ", ".join(type(est).__name__ for est in estimators.values()) 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 {description}. 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. payloads = [dict(router_params[key][callee]) for key in estimators] routed_params = sku.Bunch( estimator_params=payloads if is_collection else payloads[0] ) if cv is not None: routed_params.splitter = sku.Bunch(split=dict(router_params.splitter.split)) return routed_params def _has_asset_names(X: ArrayLike) -> bool: """Return whether the estimator's actual input carries string asset names.""" return hasattr(X, "columns") and all(isinstance(name, str) for name in X.columns) # ty: ignore[not-iterable] def _propagate_previous_weights(portfolios: list[Portfolio]) -> list[Portfolio]: """Set previous weights along a path after independent fits. The first portfolio retains the supplied initial holdings. Later portfolios are reconstructed only when their previous holdings differ from the last successful period's ending weights. Failed and empty periods do not advance the holdings. """ result = [] previous_weights = None for portfolio in portfolios: if not isinstance(portfolio, FailedPortfolio) and portfolio.n_observations: if previous_weights is not None: params = portfolio._get_init_params() current = params["previous_weights"] if isinstance(current, dict) or isinstance(previous_weights, dict): same_weights = ( isinstance(current, dict) and isinstance(previous_weights, dict) and current == previous_weights ) else: same_weights = np.array_equal(current, previous_weights) if not same_weights: params["previous_weights"] = previous_weights portfolio = type(portfolio)(**params) previous_weights = ( portfolio.ending_weights_dict if _has_asset_names(X=portfolio.X) else portfolio.ending_weights ) result.append(portfolio) return result 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 _resolve_evaluation_portfolio_params( estimator: _EstimatorT, portfolio_params: dict | None, *, clone_estimator: bool = True, ) -> tuple[_EstimatorT, dict, set[str]]: """Resolve parameters for individual and multi-period portfolio evaluation. Settings listed in `_PORTFOLIO_MEASURE_PARAMS` configure every resulting `MultiPeriodPortfolio` and each `Portfolio` it contains. When absent from the evaluation call, the `MultiPeriodPortfolio` inherits the corresponding value from the estimator's `portfolio_params`. `risk_free_rate` also falls back to the estimator attribute when available. An evaluation-level `weight_drift` is copied into the final estimator step so `predict` can construct each `Portfolio` return series and its `ending_weights`. Parameters ---------- estimator : BaseEstimator | Pipeline Estimator or pipeline whose last step produces `Portfolio` objects. portfolio_params : dict, optional Parameters supplied to the evaluation call, possibly including measure parameters and `weight_drift`. clone_estimator : bool, default=True If True, the estimator is cloned before being modified. Online helpers pass False because they operate on a clone whose fitted state must be kept. Returns ------- estimator : BaseEstimator | Pipeline A clone carrying the evaluation-level `weight_drift`, or the input estimator when `weight_drift` is not supplied. multi_period_portfolio_params : dict Resolved parameters for the resulting `MultiPeriodPortfolio` objects, without `weight_drift`. explicit_measure_param_names : set[str] Canonical names of measure parameters explicitly supplied by the evaluation call. After constructing each `MultiPeriodPortfolio`, its resolved values for these parameters must be copied to every `Portfolio` it contains. """ multi_period_portfolio_params = _normalize_annualization_factor_alias( {} if portfolio_params is None else portfolio_params, stacklevel=5, ) explicit_measure_param_names = ( set(multi_period_portfolio_params) & _PORTFOLIO_MEASURE_PARAMS ) last_step = _get_last_step(estimator) estimator_portfolio_params = _normalize_annualization_factor_alias( {} if last_step.portfolio_params is None else last_step.portfolio_params, # ty: ignore[missing-attribute] stacklevel=5, ) for param in _PORTFOLIO_MEASURE_PARAMS: if ( param not in multi_period_portfolio_params and param in estimator_portfolio_params ): multi_period_portfolio_params[param] = estimator_portfolio_params[param] if _RISK_FREE_RATE not in multi_period_portfolio_params and hasattr( last_step, _RISK_FREE_RATE ): multi_period_portfolio_params[_RISK_FREE_RATE] = getattr( last_step, _RISK_FREE_RATE ) if "weight_drift" not in multi_period_portfolio_params: return estimator, multi_period_portfolio_params, explicit_measure_param_names weight_drift = multi_period_portfolio_params.pop("weight_drift") if clone_estimator: estimator = sk.clone(estimator) last_step = _get_last_step(estimator) individual_portfolio_params = ( {} if last_step.portfolio_params is None else last_step.portfolio_params.copy() # ty: ignore[missing-attribute] ) individual_portfolio_params["weight_drift"] = weight_drift last_step.set_params(portfolio_params=individual_portfolio_params) return estimator, multi_period_portfolio_params, explicit_measure_param_names def _sync_measure_params_to_portfolios( prediction: MultiPeriodPortfolio | Population, explicit_measure_param_names: set[str], ) -> None: """Copy the given measure parameters from each `MultiPeriodPortfolio` to the `Portfolio` objects it contains. The values are read from the constructed `MultiPeriodPortfolio` rather than from the evaluation call, so that constructor defaults are applied once: an explicit `annualization_factor=None` reaches the children as 252 and an explicit `fitness_measures=None` as the default measures. """ if not explicit_measure_param_names: return multi_period_portfolios = ( prediction if isinstance(prediction, Population) else [prediction] ) for multi_period_portfolio in multi_period_portfolios: measure_params = { param: getattr(multi_period_portfolio, param) for param in explicit_measure_param_names } for portfolio in multi_period_portfolio: for param, value in measure_params.items(): setattr(portfolio, param, value) def _is_portfolio_optimization_estimator( estimator: object, ) -> bool: """Return whether the estimator or the last pipeline step is a portfolio optimization estimator. Parameters ---------- estimator : object 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. """ # Imported here rather than at module scope: `skfolio.optimization` imports # `skfolio.model_selection` (through `optimization.hierarchical._clustering._nco` # and `optimization.ensemble._stacking`), which imports this module, so a # module-level import would close a package-level cycle between # `skfolio.model_selection` and `skfolio.optimization`. Annotations are # postponed, so the `TYPE_CHECKING` import above covers the signature. from skfolio.optimization._base import BaseOptimization return isinstance(estimator, skb.BaseEstimator) and 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 passes each portfolio's `ending_weights` as `previous_weights` to the next fit. A failed prediction leaves the propagated weights unchanged. 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. """ predictions = [] prev_weights = _get_last_step(estimator).previous_weights # ty: ignore[missing-attribute] 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, ) if isinstance(ptf, Population): raise ValueError( "Sequential propagation of `previous_weights` requires one " "Portfolio per fold. The estimator returned a Population." ) predictions.append(ptf) if not isinstance(ptf, FailedPortfolio) and ptf.n_observations: prev_weights = ( ptf.ending_weights_dict if _has_asset_names(X=ptf.X) else ptf.ending_weights ) return predictions