Coverage for src/pyhrp/dendrogram.py: 100%

221 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-10-14 19:39 +0000

1"""Hierarchical clustering tree construction and the Dendrogram container. 

2 

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- build_tree_from_operator: Build the tree from a covariance operator, matrix-free for single linkage 

7- Dendrogram: Container for the clustering result and its visualization 

8""" 

9 

10from __future__ import annotations 

11 

12from collections.abc import Generator, Sequence 

13from dataclasses import dataclass 

14from typing import TYPE_CHECKING, Literal 

15 

16import numpy as np 

17import polars as pl 

18import scipy.cluster.hierarchy as sch 

19import scipy.spatial.distance as ssd 

20 

21from .algos import one_over_n as _one_over_n 

22from .cluster import Cluster, Portfolio 

23from .operators import CovarianceOperator, as_operator, block 

24 

25if TYPE_CHECKING: 

26 import plotly.graph_objects as go 

27 

28__all__ = ["Dendrogram", "build_tree", "build_tree_from_operator"] 

29 

30 

31@dataclass(frozen=True) 

32class Dendrogram: 

33 """Container for hierarchical clustering dendrogram data and visualization. 

34 

35 This class stores the results of hierarchical clustering and provides methods 

36 for accessing and visualizing the dendrogram structure. 

37 

38 Attributes: 

39 root (Cluster): The root node of the hierarchical clustering tree 

40 assets (list[str]): Names of assets included in the clustering 

41 linkage (np.ndarray | None): Linkage matrix in scipy format for plotting 

42 distance (pl.DataFrame | None): Distance matrix used for clustering 

43 method (str | None): Linkage method used for clustering 

44 """ 

45 

46 root: Cluster 

47 assets: list[str] 

48 distance: pl.DataFrame | None = None 

49 linkage: np.ndarray | None = None 

50 method: str | None = None 

51 

52 def __post_init__(self) -> None: 

53 """Validate dataclass fields after initialization. 

54 

55 Ensures that the optional distance matrix, when provided, is a polars 

56 DataFrame with columns aligned to the asset list, and verifies that the 

57 number of leaves in the cluster tree matches the number of assets. 

58 """ 

59 if self.distance is not None: 

60 if not isinstance(self.distance, pl.DataFrame): 

61 msg = "distance must be a polars DataFrame." 

62 raise TypeError(msg) 

63 

64 if self.distance.columns != list(self.assets): 

65 msg = "Distance matrix index/columns must align with assets." 

66 raise ValueError(msg) 

67 

68 if len(self.root.leaves) != len(self.assets): 

69 msg = "Number of leaves does not match number of assets." 

70 raise ValueError(msg) 

71 

72 def plot(self, **kwargs: object) -> go.Figure: 

73 """Build and return a plotly dendrogram figure. 

74 

75 Delegates to :func:`pyhrp.plot.plot_dendrogram`; the plotly dependency 

76 is imported lazily so importing the allocation core stays plotly-free. 

77 """ 

78 from .plot import plot_dendrogram 

79 

80 return plot_dendrogram(self, **kwargs) 

81 

82 def one_over_n(self) -> Generator[tuple[int, Portfolio]]: 

83 """Yield the hierarchical 1/N portfolios level by level for this tree. 

84 

85 Container-level convenience wrapper around :func:`pyhrp.algos.one_over_n`. 

86 

87 Yields: 

88 tuple[int, Portfolio]: The level number and the equal-weight portfolio 

89 at that level. 

90 """ 

91 yield from _one_over_n(self.root, self.assets) 

92 

93 @property 

94 def ids(self) -> list[int]: 

95 """Node values in the order left -> right as they appear in the dendrogram.""" 

96 return [node.value for node in self.root.leaves] 

97 

98 @property 

99 def names(self) -> list[str]: 

100 """The asset names as induced by the order of ids.""" 

101 return [self.assets[i] for i in self.ids] 

102 

103 

104def _compute_distance_matrix(corr: pl.DataFrame) -> pl.DataFrame: 

105 """Convert correlation matrix to distance matrix.""" 

106 c = corr.to_numpy() 

107 dist = np.sqrt(np.clip((1.0 - c) / 2.0, a_min=0.0, a_max=1.0)) 

108 np.fill_diagonal(dist, 0.0) 

109 cols = corr.columns 

110 return pl.DataFrame(dict(zip(cols, dist, strict=True))) 

111 

112 

113def _bisect_tree(ids: list[int], next_id: int) -> tuple[Cluster, int]: 

114 """Build tree by recursive bisection.""" 

115 if not ids: 

116 msg = "ids must contain at least one node id." 

117 raise ValueError(msg) 

118 if len(ids) == 1: 

119 return Cluster(value=ids[0]), next_id 

120 

121 mid = len(ids) // 2 

122 left_ids, right_ids = ids[:mid], ids[mid:] 

123 left, next_id = _bisect_tree(left_ids, next_id) 

124 right, next_id = _bisect_tree(right_ids, next_id) 

125 next_id += 1 

126 return Cluster(value=next_id, left=left, right=right), next_id 

127 

128 

129def _cluster_diameter(node: Cluster, dist: np.ndarray) -> float: 

130 """Largest pairwise distance between any two leaves under ``node``. 

131 

132 This is the merge height used for the bisection tree's linkage rows. The 

133 diameter is monotone along the tree — a parent's leaf set is a superset of 

134 each child's, so its maximum can only grow — which is what scipy's linkage 

135 format requires of its distance column. 

136 

137 Args: 

138 node (Cluster): The subtree whose leaves span the cluster. 

139 dist (np.ndarray): Square distance matrix indexed by leaf value. 

140 

141 Returns: 

142 float: The cluster diameter; 0.0 for a single leaf. 

143 """ 

144 idx = [int(leaf.value) for leaf in node.leaves] 

145 if len(idx) < 2: 

146 return 0.0 

147 return float(dist[np.ix_(idx, idx)].max()) 

148 

149 

150def _get_linkage(node: Cluster, dist: np.ndarray) -> list[list[float]]: 

151 """Convert tree structure back to linkage matrix format. 

152 

153 Rows are emitted in post-order, matching the id assignment in 

154 :func:`_bisect_tree`, so row ``i`` defines the cluster with id ``n + i``. 

155 Column 2 carries the cluster diameter (see :func:`_cluster_diameter`), 

156 because scipy reads that column as a cophenetic merge distance. 

157 

158 Args: 

159 node (Cluster): The subtree to linearise. 

160 dist (np.ndarray): Square distance matrix indexed by leaf value. 

161 

162 Returns: 

163 list[list[float]]: Rows of ``[left id, right id, height, leaf count]``. 

164 """ 

165 links_list: list[list[float]] = [] 

166 if node.left is not None and node.right is not None: 

167 left, right = node._child_clusters() 

168 links_list.extend(_get_linkage(left, dist)) 

169 links_list.extend(_get_linkage(right, dist)) 

170 links_list.append( 

171 [ 

172 float(left.value), 

173 float(right.value), 

174 _cluster_diameter(node, dist), 

175 float(len(left.leaves) + len(right.leaves)), 

176 ] 

177 ) 

178 return links_list 

179 

180 

181def _check_finite_correlations(cor: pl.DataFrame, c: np.ndarray) -> None: 

182 """Raise if the correlation matrix contains non-finite values. 

183 

184 Names the offending assets when the non-finite values sit on the diagonal, 

185 since a constant (zero-variance) price series is the usual cause. 

186 """ 

187 bad = [col for col, diag in zip(cor.columns, np.diagonal(c), strict=True) if not np.isfinite(diag)] 

188 if bad: 

189 msg = ( 

190 f"Correlation matrix contains non-finite values for assets {bad}; " 

191 "constant (zero-variance) price series produce NaN correlations." 

192 ) 

193 raise ValueError(msg) 

194 if not np.isfinite(c).all(): 

195 msg = "Correlation matrix contains non-finite values." 

196 raise ValueError(msg) 

197 

198 

199def _validate_correlation_matrix(cor: pl.DataFrame) -> None: 

200 """Validate the correlation matrix accepted by :func:`build_tree`. 

201 

202 Raises: 

203 TypeError: If ``cor`` is not a polars DataFrame. 

204 ValueError: If it has fewer than two assets or contains non-finite values. 

205 """ 

206 if not isinstance(cor, pl.DataFrame): 

207 msg = "Correlation matrix must be a polars DataFrame." 

208 raise TypeError(msg) 

209 if len(cor.columns) < 2: 

210 msg = "Correlation matrix must contain at least two assets." 

211 raise ValueError(msg) 

212 _check_finite_correlations(cor, cor.to_numpy()) 

213 

214 

215def _to_cluster(node: sch.ClusterNode) -> Cluster: 

216 """Convert a scipy ClusterNode tree into our Cluster format. 

217 

218 Args: 

219 node (sch.ClusterNode): A node from scipy's hierarchical clustering. 

220 

221 Returns: 

222 Cluster: Equivalent node in our Cluster format. 

223 """ 

224 # Two passes instead of recursion. scipy's tree is as deep as the linkage is 

225 # unbalanced -- single linkage chains, so depth grows with the asset count -- 

226 # and recursing here raised RecursionError before allocation was ever reached. 

227 # 

228 # Pass one collects nodes parent-before-child; reversing that order visits every 

229 # child before its parent, which is what building bottom-up requires. 

230 order: list[sch.ClusterNode] = [] 

231 stack: list[sch.ClusterNode] = [node] 

232 while stack: 

233 current = stack.pop() 

234 order.append(current) 

235 if current.left is not None and current.right is not None: 

236 stack.append(current.left) 

237 stack.append(current.right) 

238 

239 # Pass two builds each Cluster once its children exist. scipy assigns every 

240 # node a unique id, so the id is a safe key for the partially built tree. 

241 converted: dict[int, Cluster] = {} 

242 for current in reversed(order): 

243 if current.left is not None and current.right is not None: 

244 converted[current.id] = Cluster( 

245 value=current.id, 

246 left=converted[current.left.id], 

247 right=converted[current.right.id], 

248 ) 

249 else: 

250 converted[current.id] = Cluster(value=current.id) 

251 

252 return converted[node.id] 

253 

254 

255def build_tree( 

256 cor: pl.DataFrame, method: Literal["single", "complete", "average", "ward"] = "ward", bisection: bool = False 

257) -> Dendrogram: 

258 """Build hierarchical cluster tree from correlation matrix. 

259 

260 This function converts a correlation matrix to a distance matrix, performs 

261 hierarchical clustering, and returns a Dendrogram object containing the 

262 resulting tree structure. 

263 

264 Note: 

265 There is no limit on the depth of the resulting tree. That matters because 

266 ``method="single"`` chains: on smoothly decaying correlations it produces a 

267 tree of depth *n* rather than *O(log n)*, so an ordinary equity universe can 

268 exceed Python's recursion limit. Tree construction and every traversal that 

269 walks the result are iterative for exactly that reason. 

270 

271 Args: 

272 cor (pl.DataFrame): Correlation matrix of asset returns (columns are assets) 

273 method (Literal["single", "complete", "average", "ward"]): Linkage method for hierarchical clustering 

274 - "single": minimum distance between points (nearest neighbor) 

275 - "complete": maximum distance between points (furthest neighbor) 

276 - "average": average distance between all points 

277 - "ward": Ward variance minimization 

278 bisection (bool): Whether to use bisection method for tree construction 

279 

280 Returns: 

281 Dendrogram: Object containing the hierarchical clustering tree, with: 

282 - root: Root cluster node 

283 - linkage: Linkage matrix for plotting 

284 - assets: List of assets 

285 - method: Clustering method used 

286 - distance: Distance matrix 

287 

288 Examples: 

289 >>> import polars as pl 

290 >>> from pyhrp.dendrogram import build_tree 

291 >>> cor = pl.DataFrame({"A": [1.0, 0.5], "B": [0.5, 1.0]}) 

292 >>> dg = build_tree(cor, method="ward") 

293 >>> dg.root.leaf_count 

294 2 

295 """ 

296 _validate_correlation_matrix(cor) 

297 dist = _compute_distance_matrix(cor) 

298 links = sch.linkage(ssd.squareform(dist.to_numpy(), checks=False), method=method) 

299 

300 root = _to_cluster(sch.to_tree(links, rd=False)) 

301 

302 # Apply bisection if requested 

303 if bisection: 

304 # Rebuild tree using bisection 

305 leaf_ids: list[int] = [int(node.value) for node in root.leaves] 

306 root, _ = _bisect_tree(ids=leaf_ids, next_id=max(leaf_ids)) 

307 links = np.array(_get_linkage(root, dist.to_numpy())) 

308 

309 return Dendrogram(root=root, linkage=links, method=method, distance=dist, assets=cor.columns) 

310 

311 

312def _volatilities(op: CovarianceOperator) -> np.ndarray: 

313 """Return ``sqrt(diag(Sigma))``, refusing an asset without positive variance.""" 

314 var = np.asarray(op.diag, dtype=np.float64) 

315 if not np.all(var > 0.0): 

316 bad = np.flatnonzero(~(var > 0.0)).tolist() 

317 msg = f"Covariance has non-positive or non-finite variance at positions {bad}; correlations are undefined." 

318 raise ValueError(msg) 

319 return np.sqrt(var) 

320 

321 

322def _unit(n: int, i: int) -> np.ndarray: 

323 """The ``i``-th unit vector of length ``n``.""" 

324 e = np.zeros(n) 

325 e[i] = 1.0 

326 return e 

327 

328 

329def _linkage_from_merges(merges: list[tuple[float, int, int]], n: int) -> np.ndarray: 

330 """Turn merges into SciPy's linkage format. 

331 

332 Each merge ``(height, i, j)`` joins the clusters holding assets ``i`` and 

333 ``j``. Merges are sorted by height and replayed by union-find, and each row 

334 lists its two cluster ids smaller first, as SciPy labels them. 

335 

336 Args: 

337 merges (list[tuple[float, int, int]]): The ``n - 1`` merges, in any order. 

338 n (int): The number of assets. 

339 

340 Returns: 

341 np.ndarray: The ``(n - 1) x 4`` linkage matrix. 

342 """ 

343 # Union-find over the assets; label[r] is the cluster id of the set rooted at r. 

344 root = np.arange(n) 

345 label = np.arange(n) 

346 size = np.ones(n, dtype=np.intp) 

347 

348 def find(i: int) -> int: 

349 """Return the representative of ``i``, halving the path on the way.""" 

350 while root[i] != i: 

351 root[i] = root[root[i]] 

352 i = int(root[i]) 

353 return i 

354 

355 links = np.empty((n - 1, 4)) 

356 for k, (height, i, j) in enumerate(sorted(merges, key=lambda merge: merge[0])): 

357 a, b = find(i), find(j) 

358 links[k] = (min(label[a], label[b]), max(label[a], label[b]), height, size[a] + size[b]) 

359 root[b] = a 

360 label[a] = n + k 

361 size[a] += size[b] 

362 return links 

363 

364 

365def _single_linkage(op: CovarianceOperator, vol: np.ndarray) -> np.ndarray: 

366 """Single linkage on ``d_ij = sqrt((1 - rho_ij) / 2)`` without the distance matrix. 

367 

368 Single linkage is the minimum spanning tree of the distance graph. Prim's 

369 algorithm grows it from one column of ``Sigma`` per step, so memory is 

370 ``O(n)`` beyond the operator and the time is ``n`` column products 

371 (``O(n k)`` each for a rank-``k`` factor or Gram operator). 

372 

373 Args: 

374 op (CovarianceOperator): The covariance. 

375 vol (np.ndarray): The volatilities ``sqrt(diag(Sigma))``. 

376 

377 Returns: 

378 np.ndarray: The ``(n - 1) x 4`` linkage matrix in SciPy's format. 

379 """ 

380 n = op.n 

381 in_tree = np.zeros(n, dtype=bool) 

382 best = np.full(n, np.inf) 

383 parent = np.zeros(n, dtype=np.intp) 

384 edges: list[tuple[float, int, int]] = [] 

385 current = 0 

386 for _ in range(n - 1): 

387 in_tree[current] = True 

388 rest = np.flatnonzero(~in_tree) 

389 column = np.asarray(op.matvec(_unit(n, current)))[rest] 

390 rho = column / (vol[rest] * vol[current]) 

391 dist = np.sqrt(np.clip((1.0 - rho) / 2.0, 0.0, 1.0)) 

392 closer = dist < best[rest] 

393 best[rest[closer]] = dist[closer] 

394 parent[rest[closer]] = current 

395 current = int(rest[np.argmin(best[rest])]) 

396 edges.append((float(best[current]), int(parent[current]), current)) 

397 return _linkage_from_merges(edges, n) 

398 

399 

400def _ward_linkage(op: CovarianceOperator, vol: np.ndarray) -> np.ndarray: 

401 """Ward linkage on ``d_ij = sqrt((1 - rho_ij) / 2)`` without the distance matrix. 

402 

403 These distances are Euclidean: they are ``|p_i - p_j|`` for points with inner 

404 products ``p_i . p_j = rho_ij / 4``. The centroid of a cluster ``A`` is then 

405 reached through the vector ``v_A`` holding ``1 / (|A| sigma_i)`` on its assets, 

406 with ``c_A . c_B = v_A' Sigma v_B / 4``. One product ``Sigma v_A`` therefore gives 

407 the centroid inner products of ``A`` with every cluster at once (a sum over each 

408 cluster's assets), and with them the Ward distance 

409 

410 ``D(A, B)**2 = 2 |A| |B| / (|A| + |B|) * |c_A - c_B|**2``, 

411 

412 which is the distance SciPy's Lance-Williams update reports. The nearest- 

413 neighbour chain algorithm needs ``O(n)`` such queries, so the time is ``O(n)`` 

414 operator products (``O(n k)`` each for a rank-``k`` factor or Gram operator) 

415 and the memory is ``O(n)``. 

416 

417 Args: 

418 op (CovarianceOperator): The covariance. 

419 vol (np.ndarray): The volatilities ``sqrt(diag(Sigma))``. 

420 

421 Returns: 

422 np.ndarray: The ``(n - 1) x 4`` linkage matrix in SciPy's format. 

423 """ 

424 n = op.n 

425 # Every cluster is named by one of its assets, its representative; owner maps an 

426 # asset to the representative of its cluster. 

427 owner = np.arange(n) 

428 size = np.ones(n) 

429 norm2 = np.full(n, 0.25) # |c_A|**2; a single asset has rho_ii / 4 

430 active = np.ones(n, dtype=bool) 

431 merges: list[tuple[float, int, int]] = [] 

432 chain: list[int] = [] 

433 while len(merges) < n - 1: 

434 if not chain: 

435 chain.append(int(np.flatnonzero(active)[0])) 

436 a = chain[-1] 

437 members = np.flatnonzero(owner == a) 

438 v = np.zeros(n) 

439 v[members] = 1.0 / (size[a] * vol[members]) 

440 g = np.asarray(op.matvec(v)) 

441 dot = np.bincount(owner, weights=g / vol, minlength=n) / (4.0 * size) 

442 d2 = 2.0 * size[a] * size / (size[a] + size) * np.maximum(norm2[a] + norm2 - 2.0 * dot, 0.0) 

443 d2[~active] = np.inf 

444 d2[a] = np.inf 

445 b = int(np.argmin(d2)) 

446 # Reciprocal nearest neighbours: a's nearest is the cluster below it on the chain. 

447 if len(chain) > 1 and d2[chain[-2]] <= d2[b]: 

448 b = chain[-2] 

449 del chain[-2:] 

450 sa, sb = size[a], size[b] 

451 norm2[a] = (sa * sa * norm2[a] + 2.0 * sa * sb * dot[b] + sb * sb * norm2[b]) / (sa + sb) ** 2 

452 size[a] = sa + sb 

453 owner[owner == b] = a 

454 active[b] = False 

455 merges.append((float(np.sqrt(d2[b])), a, b)) 

456 else: 

457 chain.append(b) 

458 return _linkage_from_merges(merges, n) 

459 

460 

461def _bisect_linkage(root: Cluster, links: np.ndarray) -> tuple[Cluster, np.ndarray]: 

462 """Rebuild a tree by bisection of its leaf order, without distances. 

463 

464 The height of a bisection node is the largest merge height between 

465 neighbouring leaves in its range, which for a monotone tree is the range's 

466 cophenetic diameter. Neighbouring leaves ``i`` and ``i + 1`` of a dendrogram 

467 meet at the node that splits between them, so these gaps come from the 

468 original tree in one pass. A parent's range contains its children's, so no 

469 node sits below its children. 

470 

471 Args: 

472 root (Cluster): The clustering tree (single or Ward linkage). 

473 links (np.ndarray): Its ``(n - 1) x 4`` linkage matrix. 

474 

475 Returns: 

476 tuple[Cluster, np.ndarray]: The bisection tree and its linkage matrix. 

477 """ 

478 n = links.shape[0] + 1 

479 leaves, spans = root._layout() 

480 gaps = np.empty(n - 1) 

481 for node, _, mid, _ in spans: 

482 gaps[mid - 1] = links[int(node.value) - n, 2] 

483 

484 ids = [int(leaf.value) for leaf in leaves] 

485 bisected, _ = _bisect_tree(ids=ids, next_id=n - 1) 

486 rows = np.empty((n - 1, 4)) 

487 for node, lo, _, hi in bisected._layout()[1]: 

488 left, right = node._child_clusters() 

489 rows[int(node.value) - n] = (left.value, right.value, gaps[lo : hi - 1].max(), hi - lo) 

490 return bisected, rows 

491 

492 

493def build_tree_from_operator( 

494 cov: CovarianceOperator, 

495 assets: Sequence[str] | None = None, 

496 method: Literal["single", "complete", "average", "ward"] = "ward", 

497 bisection: bool = False, 

498) -> Dendrogram: 

499 """Build the hierarchical cluster tree from a covariance operator. 

500 

501 The correlation distance is ``d_ij = sqrt((1 - rho_ij) / 2)``, as in 

502 :func:`build_tree`. Ward (the default) and single linkage are built 

503 **matrix-free**, from the operator's diagonal and ``O(n)`` products with it 

504 (see :func:`_ward_linkage` and :func:`_single_linkage`), never forming the 

505 ``n x n`` correlation or distance matrix, so a factor or Gram operator 

506 clusters a universe whose covariance would not fit in memory. They give 

507 SciPy's trees. The returned :class:`Dendrogram` carries no ``distance`` 

508 frame. 

509 

510 With ``bisection=True`` the leaf order is split at its midpoint, as in 

511 Lopez de Prado's original algorithm, giving ``ceil(log2(n))`` levels; this 

512 matters for single linkage, whose tree chains to nearly ``n`` levels. 

513 Matrix-free, the merge height of a bisection node is the cophenetic 

514 diameter of its range in the clustering tree, not the largest pairwise 

515 distance :func:`build_tree` reports; the tree is the same, only the plotted 

516 heights differ. 

517 

518 Complete and average linkage need all pairwise distances; the correlation 

519 is then materialised from the operator and the call is delegated to 

520 :func:`build_tree`, at ``O(n**2)`` memory. 

521 

522 Args: 

523 cov (CovarianceOperator): The covariance as an operator. 

524 assets (Sequence[str], optional): Asset names; defaults to ``"0", "1", ...``. 

525 method (Literal["single", "complete", "average", "ward"]): Linkage method. 

526 Defaults to ``"ward"``; ``"ward"`` and ``"single"`` are matrix-free. 

527 bisection (bool): Whether to rebuild the tree by bisection of the leaf order. 

528 

529 Returns: 

530 Dendrogram: The clustering tree over the operator's assets. 

531 

532 Raises: 

533 ValueError: If there are fewer than two assets, an asset has no positive 

534 variance, or ``assets`` has the wrong length. 

535 

536 Examples: 

537 >>> import numpy as np 

538 >>> from pyhrp.dendrogram import build_tree_from_operator 

539 >>> from pyhrp.operators import DenseCovariance 

540 >>> sigma = np.array([[1.0, 0.9, 0.0], [0.9, 1.0, 0.0], [0.0, 0.0, 1.0]]) 

541 >>> dg = build_tree_from_operator(DenseCovariance(sigma), assets=["A", "B", "C"], method="single") 

542 >>> dg.names 

543 ['C', 'A', 'B'] 

544 """ 

545 op, names = as_operator(cov, assets) 

546 if op.n < 2: 

547 msg = "Covariance must contain at least two assets." 

548 raise ValueError(msg) 

549 vol = _volatilities(op) 

550 

551 if method in ("single", "ward"): 

552 links = _single_linkage(op, vol) if method == "single" else _ward_linkage(op, vol) 

553 root = _to_cluster(sch.to_tree(links, rd=False)) 

554 if bisection: 

555 root, links = _bisect_linkage(root, links) 

556 return Dendrogram(root=root, linkage=links, method=method, assets=names) 

557 

558 idx = np.arange(op.n) 

559 corr = block(op, idx, idx) / np.outer(vol, vol) 

560 np.fill_diagonal(corr, 1.0) 

561 return build_tree(pl.DataFrame(dict(zip(names, corr, strict=True))), method=method, bisection=bisection)