Source code for skfolio.seriation._hierarchical
"""Hierarchical asset seriation."""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
import scipy.cluster.hierarchy as sch
import sklearn.utils.metadata_routing as skm
from skfolio.cluster import HierarchicalClustering
from skfolio.seriation._base import BaseSeriation
from skfolio.typing import ArrayLike, FloatArray
from skfolio.utils.tools import _validate_bool, check_estimator
[docs]
class HierarchicalSeriation(BaseSeriation):
"""Order assets by the leaves of a hierarchical clustering tree.
Parameters
----------
hierarchical_clustering_estimator : HierarchicalClustering, optional
Hierarchical clustering estimator, including compatible subclasses.
Each `fit` trains a fresh clone when at least two assets are investable.
The default (`None`) uses :class:`~skfolio.cluster.HierarchicalClustering`
with Ward linkage.
optimal_ordering : bool, default=True
If True, minimize distances between adjacent leaves without changing
the tree [1]_. If False, use the leaf order produced by the clustering tree.
Attributes
----------
ordering_ : ndarray of shape (n_investable_assets,)
Positions in the original input matrix, listed in the computed order.
Each investable asset appears exactly once. Empty when no assets are
investable. A single investable asset produces its original position.
investable_mask_ : ndarray of shape (n_assets,)
Boolean mask selecting investable assets for the current ordering.
hierarchical_clustering_estimator_ : HierarchicalClustering or None
Fitted clustering estimator, or None with fewer than two investable assets.
ordered_linkage_matrix_ : ndarray of shape (max(n_investable_assets - 1, 0), 4)
Linkage matrix with the selected leaf ordering. Leaf indices refer to
the compact input order, given by `np.flatnonzero(investable_mask_)`.
n_features_in_ : int
Number of assets in the full schema.
feature_names_in_ : ndarray of shape (`n_features_in_`,)
Asset names, defined when the input names are all strings.
References
----------
.. [1] "Fast optimal leaf ordering for hierarchical clustering".
Ziv Bar-Joseph, David K. Gifford and Tommi S. Jaakkola,
Bioinformatics (2001).
"""
hierarchical_clustering_estimator_: HierarchicalClustering | None
ordered_linkage_matrix_: FloatArray
def __init__(
self,
*,
hierarchical_clustering_estimator: HierarchicalClustering | None = None,
optimal_ordering: bool = True,
) -> None:
self.hierarchical_clustering_estimator = hierarchical_clustering_estimator
self.optimal_ordering = optimal_ordering
[docs]
def fit(
self, X: ArrayLike, y: None = None, **fit_params: Any
) -> HierarchicalSeriation:
"""Start a new hierarchical ordering from a distance snapshot.
Parameters
----------
X : array-like of shape (n_assets, n_assets)
Distance snapshot. NaN diagonal entries mark non-investable assets.
y : Ignored
Not used, present for API consistency by convention.
**fit_params : dict
Parameters to pass to the clustering estimator.
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 : HierarchicalSeriation
Fitted estimator.
"""
_validate_bool(self.optimal_ordering, "optimal_ordering")
clustering = check_estimator(
self.hierarchical_clustering_estimator,
default=HierarchicalClustering(),
check_type=HierarchicalClustering,
)
routed = skm.process_routing(self, "fit", **fit_params)
distance, investable_mask = self._validate_distance(X, reset=True)
indices = np.flatnonzero(investable_mask)
linkage = np.empty((0, 4))
ordering = indices
if len(indices) > 1:
if hasattr(self, "feature_names_in_"):
names = self.feature_names_in_[investable_mask]
distance = pd.DataFrame(distance, index=names, columns=names)
clustering.fit(distance, **routed.hierarchical_clustering_estimator.fit)
linkage = clustering.linkage_matrix_
if self.optimal_ordering:
linkage = sch.optimal_leaf_ordering(
linkage, clustering.condensed_distance_
)
ordering = indices[sch.leaves_list(linkage)]
else:
clustering = None
self.hierarchical_clustering_estimator_ = clustering
self.ordered_linkage_matrix_ = linkage
self.investable_mask_ = investable_mask
self.ordering_ = ordering
return self