Source code for skfolio.optimization.hierarchical._clustering._nco

"""Nested Clusters Optimization estimator."""

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

from __future__ import annotations

from copy import deepcopy
from typing import Any

import numpy as np
import pandas as pd
import sklearn as sk
import sklearn.base as skb
import sklearn.model_selection as sks
import sklearn.utils.metadata_routing as skm
import sklearn.utils.parallel as skp
import sklearn.utils.validation as skv

import skfolio.typing as skt
from skfolio.cluster import HierarchicalClustering
from skfolio.distance import BaseDistance, PearsonDistance
from skfolio.exceptions import OptimizationError
from skfolio.measures import RatioMeasure
from skfolio.model_selection import BaseCombinatorialCV, cross_val_predict
from skfolio.optimization._base import BaseOptimization, _check_finite_weights
from skfolio.optimization.convex import MeanRisk
from skfolio.typing import ArrayLike
from skfolio.utils.tools import check_estimator, fit_single_estimator


[docs] class NestedClustersOptimization(BaseOptimization): """Nested Clusters Optimization estimator. Nested Clusters Optimization (NCO) is a portfolio optimization method developed by Marcos Lopez de Prado. It uses a distance matrix to compute clusters using a clustering algorithm ( Hierarchical Tree Clustering, KMeans, etc.). For each cluster, the inner-cluster weights are computed by fitting the inner-estimator on each cluster using the whole training data. Then the outer-cluster weights are computed by training the outer-estimator using out-of-sample estimates of the inner-estimators with cross-validation. Finally, the final assets weights are the dot-product of the inner-weights and outer-weights. .. note :: The original paper uses KMeans as the clustering algorithm, minimum Variance for the inner-estimator and equal-weighted for the outer-estimator. Here we generalize it to all `sklearn` and `skfolio` clustering algorithms (HierarchicalClustering, KMeans, etc.), all portfolio optimizations (Mean-Variance, HRP, etc.) and risk measures (Variance, CVaR, etc.). To avoid data leakage at the outer-estimator, we use out-of-sample estimates to fit the outer estimator. Parameters ---------- inner_estimator : BaseOptimization, optional :ref:`Optimization estimator <optimization>` used to estimate the inner-weights (also called intra-weights) which are the assets weights inside each cluster. The default `None` is to use :class:`~skfolio.optimization.MeanRisk`. outer_estimator : BaseOptimization, optional :ref:`Optimization estimator <optimization>` used to estimate the outer-weights (also called inter-weights) which are the weights applied to each cluster. The default `None` is to use :class:`~skfolio.optimization.MeanRisk`. distance_estimator : BaseDistance, optional :ref:`Distance estimator <distance>`. The distance estimator is used to estimate the codependence and the distance matrix needed for the computation of the linkage matrix. The default (`None`) is to use :class:`~skfolio.distance.PearsonDistance`. clustering_estimator : BaseEstimator, optional Clustering estimator. Must expose a `labels_` attribute after fitting. The clustering estimator is used to compute the clusters of the assets based on the distance matrix. The default (`None`) is to use :class:`~skfolio.cluster.HierarchicalClustering`. .. note :: Clustering estimators from `sklearn` are also supported. For example: `sklearn.cluster.KMeans`. cv : BaseCrossValidator | BaseCombinatorialCV | int | "ignore", optional Determines the cross-validation splitting strategy. The default (`None`) is to use the 5-fold cross validation `KFold()`. It is applied to the inner-estimators. Its out-of-sample outputs are used to train the outer-estimator. Possible inputs for `cv` are: * "ignore": no cross-validation is used (note that it will likely lead to data leakage with a high risk of overfitting) * Integer, to specify the number of folds in a :class:`sklearn.model_selection.KFold` * An object to be used as a cross-validation generator * An iterable yielding train, test splits * A :class:`~skfolio.model_selection.CombinatorialPurgedCV` If a `CombinatorialCV` cross-validator is used, each cluster out-of-sample outputs becomes a collection of multiple paths instead of one single path. The selected out-of-sample path among this collection of paths is chosen according to the `quantile` and `quantile_measure` parameters. n_jobs : int, optional The number of jobs to run in parallel for `fit` of all `estimators`. The value `-1` means using all processors. The default (`None`) means 1 unless in a `joblib.parallel_backend` context. quantile : float, default=0.5 Quantile for a given measure (`quantile_measure`) of the out-of-sample inner-estimator paths when the `cv` parameter is a :class:`~skfolio.model_selection.CombinatorialPurgedCV` cross-validator. The default value is `0.5` corresponding to the path with the median measure. (see `cv`) quantile_measure : PerfMeasure or RatioMeasure or RiskMeasure or ExtraRiskMeasure, default=RatioMeasure.SHARPE_RATIO Measure used for the quantile path selection (see `quantile` and `cv`). The default is `RatioMeasure.SHARPE_RATIO`. verbose : int, default=0 The verbosity level. The default value is `0`. portfolio_params : dict, optional Portfolio parameters forwarded to the resulting `Portfolio` in `predict`. If not provided and if available on the estimator, the following attributes are propagated to the portfolio by default: `name` and `previous_weights`. fallback : BaseOptimization | "previous_weights" | list[BaseOptimization | "previous_weights"], optional Fallback estimator or a list of estimators to try, in order, when the primary optimization raises during `fit`. Alternatively, use `"previous_weights"` (alone or in a list) to fall back to the estimator's `previous_weights`. When a fallback succeeds, its fitted `weights_` are copied back to the primary estimator so that `fit` still returns the original instance. For traceability, `fallback_` stores the successful estimator (or the string `"previous_weights"`) and `fallback_chain_` stores each attempt with the associated outcome. See :ref:`optimization_fallbacks`. previous_weights : float | dict[str, float] | array-like of shape (n_assets,), optional When `fallback="previous_weights"`, failures will fall back to these weights if provided. raise_on_failure : bool, default=True Controls error handling when fitting fails and no fallback succeeds. If True, the estimator raises the final error. If False, the estimator emits a warning and sets `weights_` to None, so subsequent calls to `predict` return a :class:`~skfolio.portfolio.FailedPortfolio`. During `fit`, `raise_on_failure` applies to any fitting error, including errors raised by the prior estimator. See :ref:`optimization_failure_handling`. Attributes ---------- weights_ : ndarray of shape (n_assets,) Weights of the assets. distance_estimator_ : BaseDistance Fitted `distance_estimator`. inner_estimators_ : list[BaseOptimization] List of fitted `inner_estimator`. One per cluster for clusters containing more than one asset. outer_estimator_ : BaseOptimization Fitted `outer_estimator`. clustering_estimator_ : BaseEstimator Fitted `clustering_estimator`. n_features_in_ : int Number of assets seen during `fit`. feature_names_in_ : ndarray of shape (`n_features_in_`,) Names of assets seen during `fit`. Defined only when `X` has assets names that are all strings. fallback_ : BaseOptimization | "previous_weights" | None The fallback estimator instance, or the string `"previous_weights"`, that produced the final result. `None` if no fallback was used. fallback_chain_ : list[tuple[str, str]] | None Sequence describing the optimization fallback attempts. Each element is a pair `(estimator_repr, outcome)` where `estimator_repr` is the string representation of the primary estimator or a fallback (e.g. `"EqualWeighted()"`, `"previous_weights"`), and `outcome` is `"success"` if that step produced a valid solution, otherwise the stringified error message. For successful fits without any fallback, this is `None`. error_ : str | list[str | None] | None For a single portfolio, this is the recorded error message, or None after a successful allocation or fallback. For multiple portfolios, it is a list with one entry per row of `weights_`, containing an error message for each failed portfolio and None for each successful portfolio. Notes ----- All estimators should specify all parameters as explicit keyword arguments in `__init__` (no `*args` or `**kwargs`), following scikit-learn conventions. Examples -------- For complete tutorials on nested clusters optimization, see the :ref:`cluster_examples` gallery. >>> from skfolio import RiskMeasure >>> from skfolio.datasets import load_sp500_dataset >>> from skfolio.optimization import ( ... MeanRisk, ... NestedClustersOptimization, ... ObjectiveFunction, ... RiskBudgeting, ... ) >>> from skfolio.preprocessing import prices_to_returns >>> >>> # Load historical prices and convert them to returns >>> prices = load_sp500_dataset() >>> X = prices_to_returns(prices) >>> >>> # Maximize the Sharpe ratio within each cluster and minimize the CVaR >>> # computed from the out-of-sample predicted returns of the clusters >>> inner_estimator = MeanRisk( ... objective_function=ObjectiveFunction.MAXIMIZE_RATIO, ... risk_measure=RiskMeasure.STANDARD_DEVIATION, ... ) >>> outer_estimator = RiskBudgeting(risk_measure=RiskMeasure.CVAR) >>> model = NestedClustersOptimization( ... inner_estimator=inner_estimator, ... outer_estimator=outer_estimator, ... ) >>> model.fit(X) NestedClustersOptimization(...) >>> print(model.weights_) [0.0327 0.0029 0. ... 0.0864 0.0236 0.0736] >>> print(model.clustering_estimator_.labels_) [4 4 3 4 2 3 4 1 3 0 1 1 4 0 1 0 2 1 4 2] References ---------- .. [1] "Building diversified portfolios that outperform out of sample", The Journal of Portfolio Management, Marcos López de Prado (2016) .. [2] "A robust estimator of the efficient frontier", SSRN Electronic Journal, Marcos López de Prado (2019) .. [3] "Machine Learning for Asset Managers", Elements in Quantitative Finance. Cambridge University Press, Marcos López de Prado (2020) """ inner_estimators_: list[BaseOptimization] outer_estimator_: BaseOptimization distance_estimator_: BaseDistance clustering_estimator_: skb.BaseEstimator def __init__( self, inner_estimator: BaseOptimization | None = None, outer_estimator: BaseOptimization | None = None, distance_estimator: BaseDistance | None = None, clustering_estimator: skb.BaseEstimator | None = None, cv: sks.BaseCrossValidator | BaseCombinatorialCV | str | int | None = None, quantile: float = 0.5, quantile_measure: skt.Measure = RatioMeasure.SHARPE_RATIO, n_jobs: int | None = None, verbose: int = 0, portfolio_params: dict | None = None, fallback: skt.Fallback = None, previous_weights: skt.MultiInput | None = None, raise_on_failure: bool = True, ) -> None: super().__init__( portfolio_params=portfolio_params, fallback=fallback, previous_weights=previous_weights, raise_on_failure=raise_on_failure, ) self.distance_estimator = distance_estimator self.clustering_estimator = clustering_estimator self.inner_estimator = inner_estimator self.outer_estimator = outer_estimator self.cv = cv self.quantile = quantile self.quantile_measure = quantile_measure self.n_jobs = n_jobs self.verbose = verbose
[docs] def get_metadata_routing(self) -> skm.MetadataRouter: """Get metadata routing for this estimator. Routes metadata passed to `fit` to the `fit` method of `distance_estimator`, `clustering_estimator` and `inner_estimator`. Returns ------- routing : MetadataRouter Metadata routing configuration. """ router = ( skm.MetadataRouter(owner=self.__class__.__name__) .add( distance_estimator=self.distance_estimator, method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"), ) .add( clustering_estimator=self.clustering_estimator, method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"), ) .add( inner_estimator=self.inner_estimator, method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"), ) ) return router
[docs] def fit( self, X: ArrayLike, y: ArrayLike | None = None, **fit_params: Any ) -> NestedClustersOptimization: """Fit the Nested Clusters Optimization estimator. Parameters ---------- X : array-like of shape (n_observations, n_assets) Price returns of the assets. y : array-like of shape (n_observations, n_targets), optional Price returns of factors or a target benchmark. The default is `None`. **fit_params : dict Parameters to pass to the underlying estimators. Only available if `enable_metadata_routing=True`, which can be set by using `sklearn.set_config(enable_metadata_routing=True)`. See :ref:`Metadata Routing User Guide <metadata_routing>` for more details. Returns ------- self : NestedClustersOptimization Fitted estimator. """ routed_params = skm.process_routing(self, "fit", **fit_params) self.distance_estimator_ = check_estimator( self.distance_estimator, default=PearsonDistance(), check_type=BaseDistance, ) self.clustering_estimator_ = check_estimator( self.clustering_estimator, default=HierarchicalClustering(), check_type=skb.BaseEstimator, ) self.outer_estimator_ = check_estimator( self.outer_estimator, default=MeanRisk(), check_type=BaseOptimization, ) _inner_estimator = check_estimator( self.inner_estimator, default=MeanRisk(), check_type=BaseOptimization, ) self.distance_estimator_.fit(X, y, **routed_params.distance_estimator.fit) distance = self.distance_estimator_.distance_ n_assets = distance.shape[0] # To keep the asset_names --> used for visualisation if isinstance(X, pd.DataFrame): distance = pd.DataFrame(distance, columns=X.columns) self.clustering_estimator_.fit( X=distance, y=None, **routed_params.clustering_estimator.fit ) labels = self.clustering_estimator_.labels_ n_clusters = max(labels) + 1 clusters = [np.argwhere(labels == i).flatten() for i in range(n_clusters)] # Intra cluster weights # Fit the inner estimator on the whole training data. Those # base estimators will be used to retrieve the inner weights. # They are exposed publicly. fitted_inner_estimators = skp.Parallel(n_jobs=self.n_jobs)( skp.delayed(fit_single_estimator)( sk.clone(_inner_estimator), X, y, routed_params.inner_estimator.fit, indices=cluster_ids, axis=1, ) for cluster_ids in clusters if len(cluster_ids) != 1 ) fitted_inner_estimators = iter(fitted_inner_estimators) self.inner_estimators_ = [] inner_weights = [] for cluster_ids in clusters: w = np.zeros(n_assets) # For single assets, we don't run the inner optimization estimator. if len(cluster_ids) == 1: w[cluster_ids] = 1 else: fitted_inner_estimator = next(fitted_inner_estimators) self.inner_estimators_.append(fitted_inner_estimator) if fitted_inner_estimator.weights_ is None: raise OptimizationError( "An inner estimator returned no allocation weights." ) w[cluster_ids] = fitted_inner_estimator.weights_ inner_weights.append(w) inner_weights = np.array(inner_weights) assert not any(fitted_inner_estimators), ( "fitted_inner_estimator iterator must be empty" ) # Outer cluster weights # To train the outer-estimator using the most data as possible, we use # a cross-validation to obtain the output of the cluster estimators. # To ensure that the data provided to each estimator are the same, # we need to set the random state of the cv if there is one and we # need to take a copy. if self.cv == "ignore": cv_predictions = None test_indices = slice(None) else: cv = sks.check_cv(self.cv) if hasattr(cv, "random_state") and cv.random_state is None: cv.random_state = np.random.RandomState() cv_predictions = skp.Parallel(n_jobs=self.n_jobs)( skp.delayed(cross_val_predict)( sk.clone(_inner_estimator), X, y, cv=deepcopy(cv), n_jobs=self.n_jobs, verbose=self.verbose, column_indices=cluster_ids, method="predict", params=routed_params.inner_estimator.fit, ) for cluster_ids in clusters if len(cluster_ids) != 1 ) cv_predictions = iter(cv_predictions) if isinstance(self.cv, BaseCombinatorialCV): test_indices = slice(None) else: test_indices = np.sort( np.concatenate([test for _, test in cv.split(X, y)]) ) # We validate and convert to numpy array only after inner-estimator fitting to # keep the assets names in case they are used in the estimator. if y is not None: X, y = skv.validate_data(self, X, y) y_pred = y[test_indices] else: X = skv.validate_data(self, X) y_pred = None X_pred = [] fitted_inner_estimators = iter(self.inner_estimators_) for cluster_ids in clusters: if len(cluster_ids) == 1: pred = X[test_indices, cluster_ids[0]] else: if cv_predictions is None: fitted_inner_estimator = next(fitted_inner_estimators) pred = fitted_inner_estimator.predict(X[test_indices, cluster_ids]) else: pred = next(cv_predictions) if isinstance(self.cv, BaseCombinatorialCV): pred = pred.quantile( measure=self.quantile_measure, q=self.quantile ) X_pred.append(np.asarray(pred)) X_pred = np.array(X_pred).T if cv_predictions is None: assert not any(fitted_inner_estimators), ( "fitted_inner_estimator iterator must be empty" ) else: assert not any(cv_predictions), "cv_predictions iterator must be empty" fit_single_estimator(self.outer_estimator_, X_pred, y_pred, fit_params={}) outer_weights = self.outer_estimator_.weights_ if outer_weights is None: raise OptimizationError( "The outer estimator returned no allocation weights." ) weights = outer_weights @ inner_weights _check_finite_weights(weights) self.weights_ = weights return self