Coverage for src/pyhrp/dendrogram.py: 100%
92 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-17 14:01 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-17 14:01 +0000
1"""Hierarchical clustering tree construction and the Dendrogram container.
3This module builds the hierarchical clustering tree consumed by the HRP
4allocation entry points and stores it in a :class:`Dendrogram`:
5- build_tree: Build a hierarchical cluster tree from a correlation matrix
6- Dendrogram: Container for the clustering result and its visualization
7"""
9from __future__ import annotations
11from collections.abc import Generator
12from dataclasses import dataclass
13from typing import TYPE_CHECKING, Literal
15import numpy as np
16import polars as pl
17import scipy.cluster.hierarchy as sch
18import scipy.spatial.distance as ssd
20from .cluster import Cluster, Portfolio
22if TYPE_CHECKING:
23 import plotly.graph_objects as go
25__all__ = ["Dendrogram", "build_tree"]
28@dataclass(frozen=True)
29class Dendrogram:
30 """Container for hierarchical clustering dendrogram data and visualization.
32 This class stores the results of hierarchical clustering and provides methods
33 for accessing and visualizing the dendrogram structure.
35 Attributes:
36 root (Cluster): The root node of the hierarchical clustering tree
37 assets (list[str]): Names of assets included in the clustering
38 linkage (np.ndarray | None): Linkage matrix in scipy format for plotting
39 distance (pl.DataFrame | None): Distance matrix used for clustering
40 method (str | None): Linkage method used for clustering
41 """
43 root: Cluster
44 assets: list[str]
45 distance: pl.DataFrame | None = None
46 linkage: np.ndarray | None = None
47 method: str | None = None
49 def __post_init__(self) -> None:
50 """Validate dataclass fields after initialization.
52 Ensures that the optional distance matrix, when provided, is a polars
53 DataFrame with columns aligned to the asset list, and verifies that the
54 number of leaves in the cluster tree matches the number of assets.
55 """
56 if self.distance is not None:
57 if not isinstance(self.distance, pl.DataFrame):
58 raise TypeError("distance must be a polars DataFrame.")
60 if self.distance.columns != list(self.assets):
61 raise ValueError("Distance matrix index/columns must align with assets.")
63 if len(self.root.leaves) != len(self.assets):
64 raise ValueError("Number of leaves does not match number of assets.")
66 def plot(self, **kwargs: object) -> go.Figure:
67 """Build and return a plotly dendrogram figure.
69 Delegates to :func:`pyhrp.plot.plot_dendrogram`; the plotly dependency
70 is imported lazily so importing the allocation core stays plotly-free.
71 """
72 from .plot import plot_dendrogram
74 return plot_dendrogram(self, **kwargs)
76 def one_over_n(self) -> Generator[tuple[int, Portfolio]]:
77 """Yield the hierarchical 1/N portfolios level by level for this tree.
79 Container-level convenience wrapper around :func:`pyhrp.algos.one_over_n`;
80 the allocation core is imported lazily so importing the dendrogram module
81 does not pull in the allocation module.
83 Yields:
84 tuple[int, Portfolio]: The level number and the equal-weight portfolio
85 at that level.
86 """
87 from .algos import one_over_n
89 yield from one_over_n(self.root, self.assets)
91 @property
92 def ids(self) -> list[int]:
93 """Node values in the order left -> right as they appear in the dendrogram."""
94 return [node.value for node in self.root.leaves]
96 @property
97 def names(self) -> list[str]:
98 """The asset names as induced by the order of ids."""
99 return [self.assets[i] for i in self.ids]
102def _compute_distance_matrix(corr: pl.DataFrame) -> pl.DataFrame:
103 """Convert correlation matrix to distance matrix."""
104 c = corr.to_numpy()
105 dist = np.sqrt(np.clip((1.0 - c) / 2.0, a_min=0.0, a_max=1.0))
106 np.fill_diagonal(dist, 0.0)
107 cols = corr.columns
108 return pl.DataFrame(dict(zip(cols, dist, strict=True)))
111def _bisect_tree(ids: list[int], next_id: int) -> tuple[Cluster, int]:
112 """Build tree by recursive bisection."""
113 if not ids:
114 raise ValueError("ids must contain at least one node id.")
115 if len(ids) == 1:
116 return Cluster(value=ids[0]), next_id
118 mid = len(ids) // 2
119 left_ids, right_ids = ids[:mid], ids[mid:]
120 left, next_id = _bisect_tree(left_ids, next_id)
121 right, next_id = _bisect_tree(right_ids, next_id)
122 next_id += 1
123 return Cluster(value=next_id, left=left, right=right), next_id
126def _get_linkage(node: Cluster) -> list[list[float]]:
127 """Convert tree structure back to linkage matrix format."""
128 links_list: list[list[float]] = []
129 if node.left is not None and node.right is not None:
130 if not isinstance(node.left, Cluster):
131 raise TypeError("Expected left child to be a Cluster") # pragma: no cover
132 if not isinstance(node.right, Cluster):
133 raise TypeError("Expected right child to be a Cluster") # pragma: no cover
134 links_list.extend(_get_linkage(node.left))
135 links_list.extend(_get_linkage(node.right))
136 links_list.append(
137 [
138 float(node.left.value),
139 float(node.right.value),
140 float(node.size),
141 float(len(node.left.leaves) + len(node.right.leaves)),
142 ]
143 )
144 return links_list
147def _check_finite_correlations(cor: pl.DataFrame, c: np.ndarray) -> None:
148 """Raise if the correlation matrix contains non-finite values.
150 Names the offending assets when the non-finite values sit on the diagonal,
151 since a constant (zero-variance) price series is the usual cause.
152 """
153 bad = [col for col, diag in zip(cor.columns, np.diagonal(c), strict=True) if not np.isfinite(diag)]
154 if bad:
155 msg = (
156 f"Correlation matrix contains non-finite values for assets {bad}; "
157 "constant (zero-variance) price series produce NaN correlations."
158 )
159 raise ValueError(msg)
160 if not np.isfinite(c).all():
161 msg = "Correlation matrix contains non-finite values."
162 raise ValueError(msg)
165def _validate_correlation_matrix(cor: pl.DataFrame) -> None:
166 """Validate the correlation matrix accepted by :func:`build_tree`.
168 Raises:
169 TypeError: If ``cor`` is not a polars DataFrame.
170 ValueError: If it has fewer than two assets or contains non-finite values.
171 """
172 if not isinstance(cor, pl.DataFrame):
173 raise TypeError("Correlation matrix must be a polars DataFrame.")
174 if len(cor.columns) < 2:
175 msg = "Correlation matrix must contain at least two assets."
176 raise ValueError(msg)
177 _check_finite_correlations(cor, cor.to_numpy())
180def _to_cluster(node: sch.ClusterNode) -> Cluster:
181 """Convert a scipy ClusterNode tree into our Cluster format.
183 Args:
184 node (sch.ClusterNode): A node from scipy's hierarchical clustering.
186 Returns:
187 Cluster: Equivalent node in our Cluster format.
188 """
189 if node.left is not None and node.right is not None:
190 return Cluster(value=node.id, left=_to_cluster(node.left), right=_to_cluster(node.right))
191 return Cluster(value=node.id)
194def build_tree(
195 cor: pl.DataFrame, method: Literal["single", "complete", "average", "ward"] = "ward", bisection: bool = False
196) -> Dendrogram:
197 """Build hierarchical cluster tree from correlation matrix.
199 This function converts a correlation matrix to a distance matrix, performs
200 hierarchical clustering, and returns a Dendrogram object containing the
201 resulting tree structure.
203 Args:
204 cor (pl.DataFrame): Correlation matrix of asset returns (columns are assets)
205 method (Literal["single", "complete", "average", "ward"]): Linkage method for hierarchical clustering
206 - "single": minimum distance between points (nearest neighbor)
207 - "complete": maximum distance between points (furthest neighbor)
208 - "average": average distance between all points
209 - "ward": Ward variance minimization
210 bisection (bool): Whether to use bisection method for tree construction
212 Returns:
213 Dendrogram: Object containing the hierarchical clustering tree, with:
214 - root: Root cluster node
215 - linkage: Linkage matrix for plotting
216 - assets: List of assets
217 - method: Clustering method used
218 - distance: Distance matrix
220 Examples:
221 >>> import polars as pl
222 >>> from pyhrp.dendrogram import build_tree
223 >>> cor = pl.DataFrame({"A": [1.0, 0.5], "B": [0.5, 1.0]})
224 >>> dg = build_tree(cor, method="ward")
225 >>> dg.root.leaf_count
226 2
227 """
228 _validate_correlation_matrix(cor)
229 dist = _compute_distance_matrix(cor)
230 links = sch.linkage(ssd.squareform(dist.to_numpy(), checks=False), method=method)
232 root = _to_cluster(sch.to_tree(links, rd=False))
234 # Apply bisection if requested
235 if bisection:
236 # Rebuild tree using bisection
237 leaf_ids: list[int] = [int(node.value) for node in root.leaves]
238 root, _ = _bisect_tree(ids=leaf_ids, next_id=max(leaf_ids))
239 links = np.array(_get_linkage(root))
241 return Dendrogram(root=root, linkage=links, method=method, distance=dist, assets=cor.columns)