resolutiontree 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
resolutiontree/core.py ADDED
@@ -0,0 +1,1331 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import TYPE_CHECKING, TypedDict, cast
5
+
6
+ import igraph as ig
7
+ import matplotlib.colors as mcolors
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ from matplotlib.patches import FancyArrowPatch, PathPatch
11
+ from matplotlib.path import Path
12
+ if TYPE_CHECKING:
13
+ from typing_extensions import NotRequired
14
+ import networkx as nx
15
+ import pandas as pd
16
+ from anndata import AnnData
17
+
18
+
19
+ class OutputSettings(TypedDict):
20
+ output_path: NotRequired[str | None]
21
+ draw: NotRequired[bool]
22
+ figsize: NotRequired[tuple[float, float] | None]
23
+ dpi: NotRequired[int | None]
24
+
25
+
26
+ class NodeStyle(TypedDict):
27
+ node_size: NotRequired[float]
28
+ node_color: NotRequired[str]
29
+ node_colormap: NotRequired[list[str] | None]
30
+ node_label_fontsize: NotRequired[float]
31
+
32
+
33
+ class EdgeStyle(TypedDict):
34
+ edge_color: NotRequired[str]
35
+ edge_curvature: NotRequired[float]
36
+ edge_threshold: NotRequired[float]
37
+ show_weight: NotRequired[bool]
38
+ edge_label_threshold: NotRequired[float]
39
+ edge_label_position: NotRequired[float]
40
+ edge_label_fontsize: NotRequired[float]
41
+
42
+
43
+ class GeneLabelSettings(TypedDict):
44
+ show_gene_labels: NotRequired[bool]
45
+ n_top_genes: NotRequired[int]
46
+ gene_label_threshold: NotRequired[float]
47
+ gene_label_style: NotRequired[dict[str, float]]
48
+ top_genes_dict: NotRequired[dict[tuple[str, str], list[str]] | None]
49
+
50
+
51
+ class LevelLabelStyle(TypedDict):
52
+ level_label_offset: NotRequired[float]
53
+ level_label_fontsize: NotRequired[float]
54
+
55
+
56
+ class TitleStyle(TypedDict):
57
+ title: NotRequired[str]
58
+ title_fontsize: NotRequired[float]
59
+
60
+
61
+ class LayoutSettings(TypedDict):
62
+ node_spacing: NotRequired[float]
63
+ level_spacing: NotRequired[float]
64
+ orientation: NotRequired[str]
65
+ barycenter_sweeps: NotRequired[int]
66
+ use_reingold_tilford: NotRequired[bool]
67
+
68
+
69
+ class ClusteringSettings(TypedDict):
70
+ prefix: NotRequired[str]
71
+ edge_threshold: NotRequired[float]
72
+
73
+
74
+ class ClusterTreePlotter:
75
+ def __init__(
76
+ self,
77
+ adata: AnnData,
78
+ resolutions: list[float],
79
+ *,
80
+ output_settings: OutputSettings | None = None,
81
+ node_style: NodeStyle | None = None,
82
+ edge_style: EdgeStyle | None = None,
83
+ gene_label_settings: GeneLabelSettings | None = None,
84
+ level_label_style: LevelLabelStyle | None = None,
85
+ title_style: TitleStyle | None = None,
86
+ layout_settings: LayoutSettings | None = None,
87
+ clustering_settings: ClusteringSettings | None = None,
88
+ ):
89
+ """
90
+ Initialize the cluster tree plotter.
91
+
92
+ Parameters
93
+ ----------
94
+ adata
95
+ AnnData object with clustering results.
96
+ resolutions
97
+ List of resolution values.
98
+ output_settings
99
+ Output settings (output_path, draw, figsize, dpi).
100
+ node_style
101
+ Node styling (node_size, node_color, node_colormap, node_label_fontsize).
102
+ edge_style
103
+ Edge styling (edge_color, edge_curvature, edge_threshold, ...).
104
+ gene_label_settings
105
+ Gene label settings (show_gene_labels, n_top_genes, ...).
106
+ level_label_style
107
+ Level label settings (level_label_offset, level_label_fontsize).
108
+ title_style
109
+ Title settings (title, title_fontsize).
110
+ layout_settings
111
+ Layout settings (node_spacing, level_spacing).
112
+ clustering_settings
113
+ Clustering settings (prefix).
114
+ """
115
+ self.adata = adata
116
+ self.resolutions = resolutions
117
+ self.output_settings = self._merge_with_default(
118
+ output_settings, self.default_output_settings()
119
+ )
120
+ self.node_style = self._merge_with_default(
121
+ node_style, self.default_node_style()
122
+ )
123
+ self.edge_style = self._merge_with_default(
124
+ edge_style, self.default_edge_style()
125
+ )
126
+ self.gene_label_settings = self._merge_with_default(
127
+ gene_label_settings, self.default_gene_label_settings()
128
+ )
129
+ self.level_label_style = self._merge_with_default(
130
+ level_label_style, self.default_level_label_style()
131
+ )
132
+ self.title_style = self._merge_with_default(
133
+ title_style, self.default_title_style()
134
+ )
135
+ self.layout_settings = self._merge_with_default(
136
+ layout_settings, self.default_layout_settings()
137
+ )
138
+ self.clustering_settings = self._merge_with_default(
139
+ clustering_settings, self.default_clustering_settings()
140
+ )
141
+
142
+ self.settings = {}
143
+ self.settings["output"] = self.output_settings
144
+ self.settings["node"] = self.node_style
145
+ self.settings["edge"] = self.edge_style
146
+ self.settings["gene_label"] = self.gene_label_settings
147
+ self.settings["level_label"] = self.level_label_style
148
+ self.settings["title"] = self.title_style
149
+ self.settings["layout"] = self.layout_settings
150
+ self.settings["clustering"] = self.clustering_settings
151
+
152
+ # Initialize attributes
153
+ self.G = None
154
+ self.pos = None
155
+ self.ax = plt.gca() # Initialize self.ax with the current axis
156
+ self.fig = None
157
+
158
+ def _merge_with_default(self, user_dict, default_dict):
159
+ return {**default_dict, **(user_dict or {})}
160
+
161
+ @staticmethod
162
+ def default_output_settings() -> OutputSettings:
163
+ return {"output_path": None, "draw": False, "figsize": (12, 6), "dpi": 300}
164
+
165
+ @staticmethod
166
+ def default_node_style() -> NodeStyle:
167
+ return {
168
+ "node_size": 500,
169
+ "node_color": "prefix",
170
+ "node_colormap": None,
171
+ "node_label_fontsize": 12,
172
+ }
173
+
174
+ @staticmethod
175
+ def default_edge_style() -> EdgeStyle:
176
+ return {
177
+ "edge_color": "parent",
178
+ "edge_curvature": 0.01,
179
+ "edge_threshold": 0.01,
180
+ "show_weight": True,
181
+ "edge_label_threshold": 0.05,
182
+ "edge_label_position": 0.8,
183
+ "edge_label_fontsize": 8,
184
+ }
185
+
186
+ @staticmethod
187
+ def default_gene_label_settings() -> GeneLabelSettings:
188
+ return {
189
+ "show_gene_labels": False,
190
+ "n_top_genes": 2,
191
+ "gene_label_threshold": 0.001,
192
+ "gene_label_style": {"offset": 0.5, "fontsize": 8},
193
+ "top_genes_dict": None,
194
+ }
195
+
196
+ @staticmethod
197
+ def default_level_label_style() -> LevelLabelStyle:
198
+ return {"level_label_offset": 15, "level_label_fontsize": 12}
199
+
200
+ @staticmethod
201
+ def default_title_style() -> TitleStyle:
202
+ return {"title": "Hierarchical Leiden Clustering", "title_fontsize": 20}
203
+
204
+ @staticmethod
205
+ def default_layout_settings() -> LayoutSettings:
206
+ return {
207
+ "node_spacing": 5.0,
208
+ "level_spacing": 1.5,
209
+ "orientation": "vertical",
210
+ "barycenter_sweeps": 2,
211
+ "use_reingold_tilford": False,
212
+ }
213
+
214
+ @staticmethod
215
+ def default_clustering_settings() -> ClusteringSettings:
216
+ return {"prefix": "leiden_res_", "edge_threshold": 0.05}
217
+
218
+ def build_cluster_graph(self) -> None:
219
+ """
220
+ Build a directed graph representing hierarchical clustering.
221
+
222
+ Uses self.adata.obs, self.settings["clustering"]["prefix"], and self.settings["clustering"]["edge_threshold"].
223
+ Stores the graph in self.G and updates top_genes_dict.
224
+ """
225
+ import networkx as nx
226
+
227
+ prefix = self.settings["clustering"]["prefix"]
228
+ edge_threshold = self.settings["clustering"]["edge_threshold"]
229
+ data = self.adata.obs
230
+
231
+ # Validate input data
232
+ matching_columns = [col for col in data.columns if col.startswith(prefix)]
233
+ if not matching_columns:
234
+ msg = f"No columns found with prefix '{prefix}' in the DataFrame."
235
+ raise ValueError(msg)
236
+
237
+ self.G = nx.DiGraph()
238
+
239
+ # Extract resolutions from column names
240
+ resolutions_col = [col[len(prefix) :] for col in matching_columns]
241
+ resolutions_col = sorted(
242
+ [float(r) for r in resolutions_col if r.replace(".", "", 1).isdigit()]
243
+ )
244
+
245
+ # Add nodes with resolution attribute for layout
246
+ for i, res in enumerate(resolutions_col):
247
+ clusters = data[f"{prefix}{res}"].unique()
248
+ for cluster in sorted(clusters):
249
+ node = f"{res}_C{cluster}"
250
+ self.G.add_node(node, resolution=i, cluster=cluster)
251
+
252
+ # Build edges between consecutive resolutions
253
+ for i in range(len(resolutions_col) - 1):
254
+ res1 = f"{prefix}{resolutions_col[i]}"
255
+ res2 = f"{prefix}{resolutions_col[i + 1]}"
256
+
257
+ grouped = (
258
+ data.loc[:, [res1, res2]]
259
+ .astype(str)
260
+ .groupby(res1, observed=False)[res2]
261
+ .value_counts(normalize=True)
262
+ )
263
+
264
+ for key, frac in grouped.items():
265
+ parent, child = key if isinstance(key, tuple) else (key, None)
266
+ parent = str(parent) if parent is not None else ""
267
+ child = str(child)
268
+ parent_node = f"{resolutions_col[i]}_C{parent}"
269
+ child_node = f"{resolutions_col[i + 1]}_C{child}"
270
+ if frac >= edge_threshold:
271
+ self.G.add_edge(parent_node, child_node, weight=frac)
272
+
273
+ self.settings["gene_label"]["top_genes_dict"] = self.adata.uns.get(
274
+ "top_genes_dict", {}
275
+ )
276
+
277
+ def compute_cluster_layout(self) -> dict[str, tuple[float, float]]:
278
+ """Compute node positions for the cluster decision tree with crossing minimization."""
279
+ import networkx as nx
280
+
281
+ if self.G is None:
282
+ msg = "Graph is not initialized. Call build_graph() first."
283
+ raise ValueError(msg)
284
+
285
+ use_reingold_tilford = self.settings["layout"]["use_reingold_tilford"]
286
+ node_spacing = self.settings["layout"]["node_spacing"]
287
+ level_spacing = self.settings["layout"]["level_spacing"]
288
+ orientation = self.settings["layout"]["orientation"]
289
+ barycenter_sweeps = self.settings["layout"]["barycenter_sweeps"]
290
+ # Step 1: Apply Reingold-Tilford layout or fallback to multipartite layout
291
+ if use_reingold_tilford:
292
+ pos = self._apply_reingold_tilford_layout(self.G, node_spacing)
293
+ else:
294
+ pos = nx.multipartite_layout(
295
+ self.G, subset_key="resolution", scale=int(node_spacing)
296
+ )
297
+
298
+ # Step 2: Adjust orientation
299
+ pos = self._adjust_orientation(
300
+ pos=cast("dict[str, tuple[float, float]]", pos), orientation=orientation
301
+ )
302
+
303
+ # Step 3: Increase vertical spacing
304
+ pos = self._adjust_vertical_spacing(pos, level_spacing)
305
+
306
+ # Step 4: Barycenter-based reordering to minimize edge crossings
307
+ pos = self._barycenter_sweep(
308
+ self.G, pos, self.resolutions, node_spacing, barycenter_sweeps
309
+ )
310
+
311
+ # Step 5: Optimize node ordering
312
+ filtered_edges = [
313
+ (u, v, d["weight"])
314
+ for u, v, d in self.G.edges(data=True)
315
+ if d["weight"] >= 0.02
316
+ ]
317
+ edges = [(u, v) for u, v, w in filtered_edges]
318
+ edges_set = set(edges)
319
+ if len(edges_set) < len(edges):
320
+ print(
321
+ f"Warning: Found {len(edges) - len(edges_set)} duplicate edges in the visualization."
322
+ )
323
+ edges = list(edges_set)
324
+ self._optimize_node_ordering(self.G, pos, edges, self.resolutions)
325
+ self.pos = pos
326
+ return self.pos
327
+
328
+ def _apply_reingold_tilford_layout(
329
+ self, G: nx.DiGraph, node_spacing: float
330
+ ) -> dict[str, tuple[float, float]]:
331
+ """Apply Reingold-Tilford layout to the graph."""
332
+ import networkx as nx
333
+
334
+ try:
335
+ nodes = list(G.nodes)
336
+ edges = [(u, v) for u, v in G.edges()]
337
+ g = ig.Graph()
338
+ g.add_vertices(nodes)
339
+ g.add_edges([(nodes.index(u), nodes.index(v)) for u, v in edges])
340
+ layout = g.layout_reingold_tilford(root=[0])
341
+ return dict(zip(nodes, layout.coords, strict=False))
342
+ except ImportError as e:
343
+ print(
344
+ f"igraph not installed or failed: {e}. Falling back to multipartite_layout."
345
+ )
346
+ return dict(
347
+ nx.multipartite_layout(
348
+ G, subset_key="resolution", scale=int(node_spacing)
349
+ )
350
+ )
351
+
352
+ def _adjust_orientation(
353
+ self, pos: dict[str, tuple[float, float]], orientation: str
354
+ ) -> dict[str, tuple[float, float]]:
355
+ """Adjust the node positions for the specified orientation."""
356
+ if orientation == "vertical":
357
+ return {node: (y, -x) for node, (x, y) in pos.items()}
358
+ return pos
359
+
360
+ def _adjust_vertical_spacing(
361
+ self, pos: dict[str, tuple[float, float]], level_spacing: float
362
+ ) -> dict[str, tuple[float, float]]:
363
+ """Increase vertical spacing between nodes at different levels."""
364
+ new_pos = {}
365
+ for node, (x, y) in pos.items():
366
+ new_y = y * level_spacing
367
+ new_pos[node] = (x, new_y)
368
+ return new_pos
369
+
370
+ def _barycenter_sweep(
371
+ self,
372
+ G: nx.DiGraph,
373
+ pos: dict[str, tuple[float, float]],
374
+ resolutions: list,
375
+ node_spacing: float,
376
+ barycenter_sweeps: int,
377
+ ) -> dict[str, tuple[float, float]]:
378
+ """Perform barycenter-based reordering to minimize edge crossings."""
379
+ for _sweep in range(barycenter_sweeps):
380
+ # Downward sweep: Adjust nodes based on parent positions
381
+ pos = self._downward_sweep(G, pos, resolutions, node_spacing)
382
+ # Upward sweep: Adjust nodes based on child positions
383
+ pos = self._upward_sweep(G, pos, resolutions, node_spacing)
384
+ self.pos = pos
385
+ return pos
386
+
387
+ def _downward_sweep(
388
+ self, G: nx.DiGraph, pos: dict, resolutions: list, node_spacing: float
389
+ ) -> dict[str, tuple[float, float]]:
390
+ """Perform downward sweep in barycenter reordering."""
391
+ for res in resolutions[1:]:
392
+ nodes_at_level = [node for node in G.nodes if node.startswith(f"{res}_C")]
393
+ node_to_barycenter = {}
394
+ for node in nodes_at_level:
395
+ parents = list(G.predecessors(node))
396
+ barycenter = (
397
+ np.mean([pos[parent][0] for parent in parents]) if parents else 0
398
+ )
399
+ node_to_barycenter[node] = barycenter
400
+ sorted_nodes = sorted(
401
+ node_to_barycenter.keys(), key=lambda x: node_to_barycenter[x]
402
+ )
403
+ y_level = pos[sorted_nodes[0]][1]
404
+ n_nodes = len(sorted_nodes)
405
+ x_positions = (
406
+ np.linspace(
407
+ -node_spacing * (n_nodes - 1) / 2,
408
+ node_spacing * (n_nodes - 1) / 2,
409
+ n_nodes,
410
+ )
411
+ if n_nodes > 1
412
+ else [0]
413
+ )
414
+ for node, x in zip(sorted_nodes, x_positions, strict=True):
415
+ pos[node] = (x, y_level)
416
+ return pos
417
+
418
+ def _upward_sweep(
419
+ self,
420
+ G: nx.DiGraph,
421
+ pos: dict[str, tuple[float, float]],
422
+ resolutions: list,
423
+ node_spacing: float,
424
+ ) -> dict[str, tuple[float, float]]:
425
+ """Perform upward sweep in barycenter reordering."""
426
+ for res in reversed(resolutions[:-1]):
427
+ nodes_at_level = [node for node in G.nodes if node.startswith(f"{res}_C")]
428
+ node_to_barycenter = {}
429
+ for node in nodes_at_level:
430
+ children = list(G.successors(node))
431
+ barycenter = (
432
+ np.mean([pos[child][0] for child in children]) if children else 0
433
+ )
434
+ node_to_barycenter[node] = barycenter
435
+ sorted_nodes = sorted(
436
+ node_to_barycenter.keys(), key=lambda x: node_to_barycenter[x]
437
+ )
438
+ y_level = pos[sorted_nodes[0]][1]
439
+ n_nodes = len(sorted_nodes)
440
+ x_positions = (
441
+ np.linspace(
442
+ -node_spacing * (n_nodes - 1) / 2,
443
+ node_spacing * (n_nodes - 1) / 2,
444
+ n_nodes,
445
+ )
446
+ if n_nodes > 1
447
+ else [0]
448
+ )
449
+ for node, x in zip(sorted_nodes, x_positions, strict=True):
450
+ pos[node] = (x, y_level)
451
+ return pos
452
+
453
+ def _optimize_node_ordering(
454
+ self,
455
+ G: nx.DiGraph,
456
+ pos: dict[str, tuple[float, float]],
457
+ edges: list[tuple[str, str]],
458
+ resolutions: list,
459
+ max_iterations=10,
460
+ ) -> None:
461
+ """Optimize node ordering at each level to minimize edge crossings by swapping adjacent nodes."""
462
+ # Group nodes by resolution level
463
+ level_nodes = {
464
+ res_idx: [
465
+ node for node in G.nodes if G.nodes[node]["resolution"] == res_idx
466
+ ]
467
+ for res_idx in range(len(resolutions))
468
+ }
469
+
470
+ for res_idx in range(len(resolutions)):
471
+ nodes = level_nodes[res_idx]
472
+ if len(nodes) < 2:
473
+ continue
474
+
475
+ # Sort nodes by their x-coordinate to establish an initial order
476
+ nodes.sort(key=lambda node: pos[node][0])
477
+
478
+ iteration = 0
479
+ improved = True
480
+ while improved and iteration < max_iterations:
481
+ improved = False
482
+ for i in range(len(nodes) - 1):
483
+ node1, node2 = nodes[i], nodes[i + 1]
484
+ x1, y1 = pos[node1]
485
+ x2, y2 = pos[node2]
486
+
487
+ # Compute current number of crossings
488
+ current_crossings = self._count_crossings(G, pos, edges)
489
+
490
+ # Swap positions and compute new crossings
491
+ pos[node1] = (x2, y1)
492
+ pos[node2] = (x1, y2)
493
+ new_crossings = self._count_crossings(G, pos, edges)
494
+
495
+ # If swapping reduces crossings, keep the swap
496
+ if new_crossings < current_crossings:
497
+ nodes[i], nodes[i + 1] = nodes[i + 1], nodes[i]
498
+ improved = True
499
+ else:
500
+ # Revert the swap if it doesn't improve crossings
501
+ pos[node1] = (x1, y1)
502
+ pos[node2] = (x2, y2)
503
+
504
+ iteration += 1
505
+
506
+ def _count_crossings(
507
+ self,
508
+ G: nx.DiGraph,
509
+ pos: dict[str, tuple[float, float]],
510
+ edges: list[tuple[str, str]],
511
+ ) -> int:
512
+ """Count the number of edge crossings in the graph based on node positions."""
513
+ crossings = 0
514
+ for i, (u1, v1) in enumerate(edges):
515
+ for _j, (u2, v2) in enumerate(edges[i + 1 :], start=i + 1):
516
+ # Skip edges at the same level to avoid counting self-crossings
517
+ level_u1 = G.nodes[u1]["resolution"]
518
+ level_v1 = G.nodes[v1]["resolution"]
519
+ level_u2 = G.nodes[u2]["resolution"]
520
+ level_v2 = G.nodes[v2]["resolution"]
521
+ if level_u1 == level_u2 and level_v1 == level_v2:
522
+ continue
523
+
524
+ # Get coordinates of the edge endpoints
525
+ x1_start, y1_start = pos[u1]
526
+ x1_end, y1_end = pos[v1]
527
+ x2_start, y2_start = pos[u2]
528
+ x2_end, y2_end = pos[v2]
529
+
530
+ # Compute the direction vectors of the edges
531
+ dx1 = x1_end - x1_start
532
+ dy1 = y1_end - y1_start
533
+ dx2 = x2_end - x2_start
534
+ dy2 = y2_end - y2_start
535
+
536
+ # Compute the denominator for the line intersection formula
537
+ denom = dx1 * dy2 - dy1 * dx2
538
+ if abs(denom) < 1e-8: # Adjusted threshold for numerical stability
539
+ continue
540
+
541
+ # Compute intersection parameters s and t
542
+ s = ((x2_start - x1_start) * dy2 - (y2_start - y1_start) * dx2) / denom
543
+ t = ((x2_start - x1_start) * dy1 - (y2_start - y1_start) * dx1) / denom
544
+
545
+ # Check if the intersection occurs within both edge segments
546
+ if 0 < s < 1 and 0 < t < 1:
547
+ crossings += 1
548
+
549
+ return crossings
550
+
551
+ def draw_cluster_tree(self) -> None:
552
+ """Draw a hierarchical cluster tree with nodes, edges, and labels."""
553
+ if self.G is None or self.pos is None:
554
+ msg = "Graph or positions not initialized. Call build_graph() and compute_cluster_layout() first."
555
+ raise ValueError(msg)
556
+ if "cluster_resolution_cluster_data" not in self.adata.uns:
557
+ msg = "adata.uns['cluster_resolution_cluster_data'] not found."
558
+ raise ValueError(msg)
559
+
560
+ import networkx as nx
561
+
562
+ # Retrieve settings
563
+ settings = self._get_draw_settings()
564
+ data = settings["data"]
565
+ prefix = settings["prefix"]
566
+
567
+ # Step 1: Compute Cluster Sizes, Node Sizes, and Node Colors
568
+ cluster_sizes = self._compute_cluster_sizes(data, prefix, self.resolutions)
569
+ node_sizes = self._scale_node_sizes(
570
+ data, prefix, self.resolutions, cluster_sizes, settings["node_size"]
571
+ )
572
+ color_schemes = self._generate_node_color_schemes(
573
+ data,
574
+ prefix,
575
+ self.resolutions,
576
+ settings["node_color"],
577
+ settings["node_colormap"],
578
+ )
579
+ node_colors = self._assign_node_colors(
580
+ data, prefix, self.resolutions, settings["node_color"], color_schemes
581
+ )
582
+ # Step 2: Set up the plot figure and axis
583
+ self.fig = plt.figure(figsize=settings["figsize"], dpi=settings["dpi"])
584
+ self.ax = self.fig.add_subplot(111)
585
+ # Step 3: Compute Edge Weights, Edge Colors
586
+ edges, weights, edge_colors = self._compute_edge_weights_colors(
587
+ self.G, settings["edge_threshold"], settings["edge_color"], node_colors
588
+ )
589
+ # Step 4: Draw Nodes and Node Labels
590
+ node_styles = {"colors": node_colors, "sizes": node_sizes}
591
+ node_labels, gene_labels = self._draw_nodes_and_labels(
592
+ self.G,
593
+ self.pos,
594
+ self.resolutions,
595
+ node_styles=node_styles,
596
+ data=data,
597
+ prefix=prefix,
598
+ top_genes_dict=self.adata.uns.get("cluster_resolution_top_genes", {}),
599
+ show_gene_labels=settings["show_gene_labels"],
600
+ n_top_genes=settings["n_top_genes"],
601
+ gene_label_threshold=settings["gene_label_threshold"],
602
+ )
603
+ nx.draw_networkx_labels(
604
+ self.G,
605
+ self.pos,
606
+ labels=node_labels,
607
+ font_size=int(settings["node_label_fontsize"]),
608
+ font_color="black",
609
+ )
610
+ # Step 5: Draw Gene Labels
611
+ gene_label_bottoms = {}
612
+ if settings["show_gene_labels"] and gene_labels:
613
+ gene_label_bottoms = self._draw_gene_labels(
614
+ self.ax,
615
+ self.pos,
616
+ gene_labels,
617
+ node_sizes=node_sizes,
618
+ node_colors=node_colors,
619
+ offset=settings["gene_label_offset"],
620
+ fontsize=settings["gene_label_fontsize"],
621
+ )
622
+ # Step 6: Build and Draw Edge Labels
623
+ edge_labels = self._build_edge_labels(
624
+ self.G, settings["edge_threshold"], settings["edge_label_threshold"]
625
+ )
626
+ edge_label_style = {
627
+ "position": settings["edge_label_position"],
628
+ "fontsize": settings["edge_label_fontsize"],
629
+ }
630
+ self._draw_edges_with_labels(
631
+ self.ax,
632
+ self.pos,
633
+ edges,
634
+ weights,
635
+ edge_colors=edge_colors,
636
+ node_sizes=node_sizes,
637
+ gene_label_bottoms=gene_label_bottoms,
638
+ show_gene_labels=settings["show_gene_labels"],
639
+ edge_labels=edge_labels,
640
+ edge_label_style=edge_label_style,
641
+ )
642
+ # Step 7: Draw Level Labels
643
+ self._draw_level_labels(
644
+ resolutions=self.resolutions,
645
+ pos=self.pos,
646
+ data=self.adata.uns["cluster_resolution_cluster_data"],
647
+ prefix=prefix,
648
+ level_label_offset=settings["level_label_offset"],
649
+ level_label_fontsize=settings["level_label_fontsize"],
650
+ )
651
+ # Step 8: Final Plot Settings
652
+ self.ax.set_title(settings["title"], fontsize=settings["title_fontsize"])
653
+ self.ax.axis("off")
654
+ # Save or show the plot
655
+ if settings["output_path"]:
656
+ plt.savefig(settings["output_path"], bbox_inches="tight")
657
+ if settings["draw"]:
658
+ plt.show()
659
+
660
+ def _get_draw_settings(self) -> dict:
661
+ """Retrieve settings for drawing the cluster tree."""
662
+ data = self.adata.uns["cluster_resolution_cluster_data"]
663
+ return {
664
+ "data": data,
665
+ "prefix": self.settings["clustering"]["prefix"],
666
+ "node_size": self.settings["node"]["node_size"],
667
+ "node_color": self.settings["node"]["node_color"],
668
+ "node_colormap": self.settings["node"]["node_colormap"],
669
+ "figsize": self.settings["output"]["figsize"],
670
+ "dpi": self.settings["output"]["dpi"],
671
+ "edge_threshold": self.settings["edge"]["edge_threshold"],
672
+ "edge_color": self.settings["edge"]["edge_color"],
673
+ "show_gene_labels": self.settings["gene_label"]["show_gene_labels"],
674
+ "n_top_genes": self.settings["gene_label"]["n_top_genes"],
675
+ "gene_label_threshold": self.settings["gene_label"]["gene_label_threshold"],
676
+ "node_label_fontsize": self.settings["node"]["node_label_fontsize"],
677
+ "gene_label_offset": self.settings["gene_label"]["gene_label_style"][
678
+ "offset"
679
+ ],
680
+ "gene_label_fontsize": self.settings["gene_label"]["gene_label_style"][
681
+ "fontsize"
682
+ ],
683
+ "edge_label_threshold": self.settings["edge"]["edge_label_threshold"],
684
+ "edge_label_position": self.settings["edge"]["edge_label_position"],
685
+ "edge_label_fontsize": self.settings["edge"]["edge_label_fontsize"],
686
+ "level_label_offset": self.settings["level_label"]["level_label_offset"],
687
+ "level_label_fontsize": self.settings["level_label"][
688
+ "level_label_fontsize"
689
+ ],
690
+ "title": self.settings["title"]["title"],
691
+ "title_fontsize": self.settings["title"]["title_fontsize"],
692
+ "output_path": self.settings["output"]["output_path"],
693
+ "draw": self.settings["output"]["draw"],
694
+ }
695
+
696
+ def _compute_cluster_sizes(
697
+ self, data: pd.DataFrame, prefix: str, resolutions: list
698
+ ) -> dict[str, int]:
699
+ """Compute cluster sizes for each node."""
700
+ cluster_sizes = {}
701
+ for res in resolutions:
702
+ res_key = f"{prefix}{res}"
703
+ counts = data[res_key].value_counts()
704
+ for cluster, count in counts.items():
705
+ node = f"{res}_C{cluster}"
706
+ cluster_sizes[node] = count
707
+ return cluster_sizes
708
+
709
+ def _scale_node_sizes(
710
+ self,
711
+ data: pd.DataFrame,
712
+ prefix: str,
713
+ resolutions: list,
714
+ cluster_sizes: dict[str, int],
715
+ node_size: float,
716
+ ) -> dict[str, float]:
717
+ """Scale node sizes based on cluster sizes and node_size setting."""
718
+ node_sizes = {}
719
+ for res in resolutions:
720
+ nodes_at_level = [
721
+ f"{res}_C{cluster}" for cluster in data[f"{prefix}{res}"].unique()
722
+ ]
723
+ sizes = np.array([cluster_sizes[node] for node in nodes_at_level])
724
+ if len(sizes) > 1:
725
+ min_size, max_size = sizes.min(), sizes.max()
726
+ if min_size != max_size:
727
+ normalized_sizes = 0.5 + (sizes - min_size) / (max_size - min_size)
728
+ else:
729
+ normalized_sizes = np.ones_like(sizes) * 0.5
730
+ scaled_sizes = normalized_sizes * node_size
731
+ else:
732
+ scaled_sizes = np.array([node_size])
733
+ if len(nodes_at_level) != len(scaled_sizes):
734
+ msg = (
735
+ f"Length mismatch at resolution {res}: "
736
+ f"{len(nodes_at_level)} nodes vs {len(scaled_sizes)} sizes"
737
+ )
738
+ raise ValueError(msg)
739
+ node_sizes.update(dict(zip(nodes_at_level, scaled_sizes, strict=False)))
740
+ return node_sizes
741
+
742
+ def _generate_node_color_schemes(
743
+ self,
744
+ data: pd.DataFrame,
745
+ prefix: str,
746
+ resolutions: list,
747
+ node_color: str | None,
748
+ node_colormap: list[str] | None,
749
+ ) -> list[str] | dict[str, list] | None:
750
+ """Generate color schemes for nodes."""
751
+ import seaborn as sns
752
+
753
+ if node_color != "prefix":
754
+ return None
755
+
756
+ if node_colormap is None:
757
+ return {
758
+ r: sns.color_palette("Set3", n_colors=data[f"{prefix}{r}"].nunique())
759
+ for r in resolutions
760
+ }
761
+
762
+ if len(node_colormap) < len(resolutions):
763
+ node_colormap = list(node_colormap) + [
764
+ node_colormap[i % len(node_colormap)]
765
+ for i in range(len(resolutions) - len(node_colormap))
766
+ ]
767
+
768
+ color_schemes = {}
769
+ for i, r in enumerate(resolutions):
770
+ color_spec = node_colormap[i]
771
+ if (isinstance(color_spec, str) and mcolors.is_color_like(color_spec)) or (
772
+ isinstance(color_spec, tuple)
773
+ and len(color_spec) in (3, 4)
774
+ and all(isinstance(x, int | float) for x in color_spec)
775
+ ):
776
+ color_schemes[r] = [color_spec]
777
+ else:
778
+ try:
779
+ color_schemes[r] = sns.color_palette(
780
+ color_spec, n_colors=data[f"{prefix}{r}"].nunique()
781
+ )
782
+ except ValueError:
783
+ print(
784
+ f"Warning: '{color_spec}' is not valid for {r}. Using 'Set3'."
785
+ )
786
+ color_schemes[r] = sns.color_palette(
787
+ "Set3", n_colors=data[f"{prefix}{r}"].nunique()
788
+ )
789
+ return color_schemes
790
+
791
+ def _assign_node_colors(
792
+ self,
793
+ data: pd.DataFrame,
794
+ prefix: str,
795
+ resolutions: list,
796
+ node_color: str,
797
+ color_schemes: list[str] | dict[str, list] | None,
798
+ ) -> dict[str, str]:
799
+ node_colors = {}
800
+ for res in resolutions:
801
+ clusters = data[f"{prefix}{res}"].unique()
802
+ for cluster in clusters:
803
+ node = f"{res}_C{cluster}"
804
+ if node_color == "prefix":
805
+ if color_schemes is None:
806
+ msg = "color_schemes is None but node_color='prefix'"
807
+ raise RuntimeError(msg)
808
+ colors = color_schemes[res]
809
+ node_colors[node] = (
810
+ colors[0]
811
+ if len(colors) == 1
812
+ else colors[int(cluster) % len(colors)]
813
+ )
814
+ else:
815
+ node_colors[node] = node_color
816
+ return node_colors
817
+
818
+ def _compute_edge_weights_colors(
819
+ self,
820
+ G: nx.DiGraph,
821
+ edge_threshold: float,
822
+ edge_color: str,
823
+ node_colors: dict,
824
+ ) -> tuple[list, list, list]:
825
+ """Compute edge weights and colors based on the graph and edge_threshold."""
826
+ edges = [
827
+ (u, v) for u, v, d in G.edges(data=True) if d["weight"] >= edge_threshold
828
+ ]
829
+ weights = [
830
+ max(d["weight"] * 5, 1.0)
831
+ for u, v, d in G.edges(data=True)
832
+ if d["weight"] >= edge_threshold
833
+ ]
834
+ edge_colors = []
835
+ for u, v in edges:
836
+ d = G[u][v]
837
+ if edge_color == "parent":
838
+ edge_colors.append(node_colors[u])
839
+ elif edge_color == "samples":
840
+ edge_colors.append(plt.cm.get_cmap("viridis")(d["weight"] / 5))
841
+ else:
842
+ edge_colors.append(edge_color)
843
+ return edges, weights, edge_colors
844
+
845
+ def _draw_nodes_and_labels(
846
+ self,
847
+ G: nx.DiGraph,
848
+ pos: dict[str, tuple[float, float]],
849
+ resolutions: list,
850
+ *,
851
+ node_styles: dict,
852
+ data: pd.DataFrame,
853
+ prefix: str,
854
+ top_genes_dict: dict[tuple[str, str], list[str]],
855
+ show_gene_labels: bool,
856
+ n_top_genes: int,
857
+ gene_label_threshold: float,
858
+ ) -> tuple[dict, dict]:
859
+ """Draw the nodes and their labels."""
860
+ import networkx as nx
861
+
862
+ node_colors = node_styles["colors"]
863
+ node_sizes = node_styles["sizes"]
864
+ node_labels = {}
865
+ gene_labels = {}
866
+ for res in resolutions:
867
+ clusters = data[f"{prefix}{res}"].unique()
868
+ for cluster in clusters:
869
+ node = f"{res}_C{cluster}"
870
+ color = node_colors[node]
871
+ size = node_sizes[node]
872
+ nx.draw_networkx_nodes(
873
+ G,
874
+ pos,
875
+ nodelist=[node],
876
+ node_size=size,
877
+ node_color=color,
878
+ edgecolors="none",
879
+ )
880
+ node_labels[node] = str(cluster)
881
+ if show_gene_labels and top_genes_dict:
882
+ res_idx = resolutions.index(float(res))
883
+ if res_idx == 0:
884
+ continue # No parent level for the top resolution
885
+ parent_res = resolutions[res_idx - 1]
886
+ parent_clusters = data[f"{prefix}{parent_res}"].unique()
887
+ for parent_cluster in parent_clusters:
888
+ parent_node = f"{parent_res}_C{parent_cluster}"
889
+ try:
890
+ edge_weight = G[parent_node][node]["weight"]
891
+ except KeyError:
892
+ continue
893
+ if edge_weight >= gene_label_threshold:
894
+ key = (f"res_{parent_node}", f"res_{node}")
895
+ if key in top_genes_dict:
896
+ genes = top_genes_dict[key][:n_top_genes]
897
+ gene_labels[node] = "\n".join(genes) if genes else ""
898
+ return node_labels, gene_labels
899
+
900
+ def _draw_gene_labels(
901
+ self,
902
+ ax,
903
+ pos: dict[str, tuple[float, float]],
904
+ gene_labels: dict[str, str],
905
+ *,
906
+ node_sizes: dict[str, float],
907
+ node_colors: dict[str, str],
908
+ offset: float = 0.2,
909
+ fontsize: float = 8,
910
+ ) -> dict[str, float]:
911
+ """Draw gene labels in boxes below nodes with matching boundary colors."""
912
+ gene_label_bottoms = {}
913
+ for node, label in gene_labels.items():
914
+ if label:
915
+ x, y = pos[node]
916
+ # Compute the node radius in data coordinates
917
+ radius = math.sqrt(node_sizes[node] / math.pi)
918
+ _fig_width, fig_height = ax.figure.get_size_inches()
919
+ radius_fig = radius / (72 * fig_height)
920
+ # xlim = ax.get_xlim()
921
+ ylim = ax.get_ylim()
922
+ data_height = ylim[0] - ylim[1]
923
+ radius_data = radius_fig * data_height
924
+
925
+ # Position the top of the label box just below the node
926
+ box_top_y = y - radius_data - offset
927
+
928
+ # Compute the height of the label box based on the number of lines
929
+ num_lines = label.count("\n") + 1
930
+ line_height = 0.03 # Reduced line height for better scaling
931
+ label_height = num_lines * line_height + 0.04 # Reduced padding
932
+ box_center_y = box_top_y - label_height / 2
933
+
934
+ # Draw the label
935
+ ax.text(
936
+ x,
937
+ box_center_y,
938
+ label,
939
+ fontsize=fontsize,
940
+ ha="center",
941
+ va="center",
942
+ color="black",
943
+ bbox=dict(
944
+ facecolor="white",
945
+ edgecolor=node_colors[node],
946
+ boxstyle="round,pad=0.2", # Reduced padding for the box
947
+ ),
948
+ )
949
+ gene_label_bottoms[node] = box_top_y - label_height
950
+ return gene_label_bottoms
951
+
952
+ def _build_edge_labels(
953
+ self, G: nx.DiGraph, edge_threshold: float, edge_label_threshold: float
954
+ ) -> dict:
955
+ """Build the edge labels to display on the plot."""
956
+ edge_labels = {
957
+ (u, v): f"{w:.2f}"
958
+ for u, v, w in [
959
+ (u, v, d["weight"])
960
+ for u, v, d in G.edges(data=True)
961
+ if d["weight"] >= edge_threshold
962
+ ]
963
+ if w >= edge_label_threshold
964
+ }
965
+ return edge_labels
966
+
967
+ def _draw_edges_with_labels(
968
+ self,
969
+ ax,
970
+ pos: dict[str, tuple[float, float]],
971
+ edges: list,
972
+ weights: list,
973
+ *,
974
+ edge_colors: list,
975
+ node_sizes: dict,
976
+ gene_label_bottoms: dict,
977
+ show_gene_labels: bool,
978
+ edge_labels: dict,
979
+ edge_label_style: dict,
980
+ ) -> None:
981
+ """Draw edges with labels using Bezier curves."""
982
+ edge_label_position = edge_label_style["position"]
983
+ edge_label_fontsize = edge_label_style["fontsize"]
984
+ for (u, v), w, e_color in zip(edges, weights, edge_colors, strict=False):
985
+ x1, y1 = pos[u]
986
+ x2, y2 = pos[v]
987
+ radius_parent = math.sqrt(node_sizes[u] / math.pi)
988
+ radius_child = math.sqrt(node_sizes[v] / math.pi)
989
+ _fig_width, fig_height = ax.figure.get_size_inches()
990
+ radius_parent_fig = radius_parent / (72 * fig_height)
991
+ radius_child_fig = radius_child / (72 * fig_height)
992
+ ylim = ax.get_ylim()
993
+ data_height = ylim[0] - ylim[1]
994
+ radius_parent_data = radius_parent_fig * data_height
995
+ radius_child_data = radius_child_fig * data_height
996
+ start_y = (
997
+ gene_label_bottoms[u]
998
+ if (show_gene_labels and u in gene_label_bottoms and edge_labels.get(u))
999
+ else y1 - radius_parent_data
1000
+ )
1001
+ start_x = x1
1002
+ end_x, end_y = x2, y2 - radius_child_data
1003
+
1004
+ p0, p1, p2, p3 = self._draw_curved_edge(
1005
+ ax,
1006
+ start_x,
1007
+ start_y,
1008
+ end_x,
1009
+ end_y,
1010
+ linewidth=w,
1011
+ color=e_color,
1012
+ edge_curvature=0.01,
1013
+ )
1014
+
1015
+ if (u, v) in edge_labels and p0 is not None:
1016
+ t = edge_label_position
1017
+ point = self._evaluate_bezier(t, p0, p1, p2, p3)
1018
+ label_x, label_y = point[0], point[1]
1019
+ tangent = self._evaluate_bezier_tangent(t, p0, p1, p2, p3)
1020
+ tangent_angle = np.arctan2(tangent[1], tangent[0])
1021
+ rotation = np.degrees(tangent_angle)
1022
+ if rotation > 90:
1023
+ rotation -= 180
1024
+ elif rotation < -90:
1025
+ rotation += 180
1026
+ ax.text(
1027
+ label_x,
1028
+ label_y,
1029
+ edge_labels[(u, v)],
1030
+ fontsize=edge_label_fontsize,
1031
+ rotation=rotation,
1032
+ ha="center",
1033
+ va="center",
1034
+ bbox=None,
1035
+ )
1036
+
1037
+ def _draw_curved_edge(
1038
+ self,
1039
+ ax,
1040
+ start_x: float,
1041
+ start_y: float,
1042
+ end_x: float,
1043
+ end_y: float,
1044
+ *,
1045
+ linewidth: float,
1046
+ color: str,
1047
+ edge_curvature: float = 0.1,
1048
+ arrow_size: float = 12,
1049
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
1050
+ """Draw a gentle S-shaped curved edge between two points with an arrowhead. Retun a tuple of Bézier control points (p0, p1, p2, p3) for label positioning."""
1051
+ # Define the start and end points
1052
+ p0 = np.array([start_x, start_y])
1053
+ p3 = np.array([end_x, end_y])
1054
+
1055
+ # Calculate the vector from start to end
1056
+ vec = p3 - p0
1057
+ length = np.sqrt(vec[0] ** 2 + vec[1] ** 2)
1058
+
1059
+ if length == 0:
1060
+ empty_array = np.array([[], []])
1061
+ return empty_array, empty_array, empty_array, empty_array
1062
+
1063
+ # Unit vector along the edge
1064
+ unit_vec = vec / length
1065
+
1066
+ # Perpendicular vector for creating the S-shape
1067
+ perp_vec = np.array([-unit_vec[1], unit_vec[0]])
1068
+
1069
+ # Define control points for a single cubic Bézier curve with an S-shape, Place control points at 1/3 and 2/3 along the edge, with small perpendicular offsets
1070
+ offset = length * edge_curvature
1071
+ p1 = (
1072
+ p0 + (p3 - p0) / 3 + perp_vec * offset
1073
+ ) # First control point (bend outward)
1074
+ p2 = (
1075
+ p0 + 2 * (p3 - p0) / 3 - perp_vec * offset
1076
+ ) # Second control point (bend inward)
1077
+
1078
+ # Define the path vertices and codes for a single cubic Bézier curve
1079
+ vertices = [
1080
+ (start_x, start_y), # Start point
1081
+ (p1[0], p1[1]), # First control point
1082
+ (p2[0], p2[1]), # Second control point
1083
+ (end_x, end_y), # End point
1084
+ ]
1085
+ codes = [
1086
+ Path.MOVETO, # Move to start
1087
+ Path.CURVE4, # Cubic Bézier curve (needs 3 points: p0, p1, p2)
1088
+ Path.CURVE4, # Continuation of the Bézier curve
1089
+ Path.CURVE4, # End of the Bézier curve
1090
+ ]
1091
+
1092
+ # Create the path
1093
+ path = Path(vertices, codes)
1094
+
1095
+ # Draw the curve
1096
+ patch = PathPatch(
1097
+ path, facecolor="none", edgecolor=color, linewidth=linewidth, alpha=0.8
1098
+ )
1099
+ ax.add_patch(patch)
1100
+
1101
+ # Add an arrowhead at the end
1102
+ arrow = FancyArrowPatch(
1103
+ (end_x, end_y),
1104
+ (end_x, end_y),
1105
+ arrowstyle="->",
1106
+ mutation_scale=arrow_size,
1107
+ color=color,
1108
+ linewidth=linewidth,
1109
+ alpha=0.8,
1110
+ )
1111
+ ax.add_patch(arrow)
1112
+
1113
+ return p0, p1, p2, p3
1114
+
1115
+ def _evaluate_bezier(
1116
+ self, t: float, p0: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray
1117
+ ) -> np.ndarray:
1118
+ """Evaluate a cubic Bezier curve at parameter t."""
1119
+ if not 0 <= t <= 1:
1120
+ msg = "Parameter t must be in the range [0, 1]"
1121
+ raise ValueError(msg)
1122
+
1123
+ t2 = t * t
1124
+ t3 = t2 * t
1125
+ mt = 1 - t
1126
+ mt2 = mt * mt
1127
+ mt3 = mt2 * mt
1128
+ return mt3 * p0 + 3 * mt2 * t * p1 + 3 * mt * t2 * p2 + t3 * p3
1129
+
1130
+ def _evaluate_bezier_tangent(
1131
+ self, t: float, p0: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray
1132
+ ) -> np.ndarray:
1133
+ """Compute the tangent vector of a cubic Bezier curve at parameter t."""
1134
+ if not 0 <= t <= 1:
1135
+ msg = "Parameter t must be in the range [0, 1]"
1136
+ raise ValueError(msg)
1137
+
1138
+ t2 = t * t
1139
+ mt = 1 - t
1140
+ mt2 = mt * mt
1141
+ return 3 * mt2 * (p1 - p0) + 6 * mt * t * (p2 - p1) + 3 * t2 * (p3 - p2)
1142
+
1143
+ def _draw_level_labels(
1144
+ self,
1145
+ resolutions: list,
1146
+ pos: dict[str, tuple[float, float]],
1147
+ data: pd.DataFrame,
1148
+ *,
1149
+ prefix: str,
1150
+ level_label_offset: float,
1151
+ level_label_fontsize: float,
1152
+ ) -> None:
1153
+ """Draw level labels for each resolution in the plot."""
1154
+ level_positions = {}
1155
+ for node, (_x, y) in pos.items():
1156
+ res = node.split("_")[0]
1157
+ level_positions[res] = y
1158
+
1159
+ cluster_counts = {}
1160
+ for res in resolutions:
1161
+ res_str = f"{res:.1f}"
1162
+ col_name = f"{prefix}{res_str}"
1163
+ if col_name not in data.columns:
1164
+ msg = f"Column {col_name} not found in data. Ensure clustering results are present."
1165
+ raise ValueError(msg)
1166
+ num_clusters = len(data[col_name].dropna().unique())
1167
+ cluster_counts[res_str] = num_clusters
1168
+
1169
+ min_x = min(p[0] for p in pos.values())
1170
+ label_offset = min_x - level_label_offset
1171
+ for res in resolutions:
1172
+ res_str = f"{res:.1f}"
1173
+ label_pos = level_positions[res_str]
1174
+ num_clusters = cluster_counts[res_str]
1175
+ label_text = f"Resolution {res_str}:\n {num_clusters} clusters"
1176
+ plt.text(
1177
+ label_offset,
1178
+ label_pos,
1179
+ label_text,
1180
+ fontsize=level_label_fontsize,
1181
+ verticalalignment="center",
1182
+ bbox=dict(facecolor="white", edgecolor="black", alpha=0.7),
1183
+ )
1184
+
1185
+ @staticmethod
1186
+ def cluster_decision_tree(
1187
+ adata: AnnData,
1188
+ resolutions: list[float],
1189
+ *,
1190
+ output_settings: dict | OutputSettings | None = None,
1191
+ node_style: dict | NodeStyle | None = None,
1192
+ edge_style: dict | EdgeStyle | None = None,
1193
+ gene_label_settings: dict | GeneLabelSettings | None = None,
1194
+ level_label_style: dict | LevelLabelStyle | None = None,
1195
+ title_style: dict | TitleStyle | None = None,
1196
+ layout_settings: dict | LayoutSettings | None = None,
1197
+ clustering_settings: dict | ClusteringSettings | None = None,
1198
+ ) -> nx.DiGraph:
1199
+ """Plot a hierarchical clustering decision tree based on multiple resolutions.
1200
+
1201
+ This static method performs Leiden clustering at different resolutions (if not already computed),
1202
+ constructs a decision tree representing hierarchical relationships between clusters,
1203
+ and visualizes it as a directed graph. Nodes represent clusters at different resolutions,
1204
+ edges represent transitions between clusters, and edge weights indicate the proportion of
1205
+ cells transitioning from a parent to a child cluster.
1206
+
1207
+ Parameters
1208
+ ----------
1209
+ adata
1210
+ Annotated data matrix with clustering results in adata.uns["cluster_resolution_cluster_data"].
1211
+ resolutions
1212
+ List of resolution values for Leiden clustering.
1213
+ output_settings
1214
+ Dictionary with output options (output_path, draw, figsize, dpi).
1215
+ node_style
1216
+ Dictionary with node appearance (node_size, node_color, node_colormap, node_label_fontsize).
1217
+ edge_style
1218
+ Dictionary with edge appearance (edge_color, edge_curvature, edge_threshold, etc.).
1219
+ gene_label_settings
1220
+ Dictionary with gene label options (show_gene_labels, n_top_genes, etc.).
1221
+ level_label_style
1222
+ Dictionary with level label options (level_label_offset, level_label_fontsize).
1223
+ title_style
1224
+ Dictionary with title options (title, title_fontsize).
1225
+ layout_settings
1226
+ Dictionary with layout options (orientation, node_spacing, level_spacing, etc.).
1227
+ clustering_settings
1228
+ Dictionary with clustering options (prefix, edge_threshold).
1229
+
1230
+ Returns
1231
+ -------
1232
+ Directed graph representing the hierarchical clustering.
1233
+
1234
+ """
1235
+ # Run all validations
1236
+ ClusterTreePlotter._validate_parameters(output_settings, node_style, edge_style)
1237
+ ClusterTreePlotter._validate_clustering_data(
1238
+ adata, resolutions, clustering_settings
1239
+ )
1240
+ ClusterTreePlotter._validate_gene_labels(adata, gene_label_settings)
1241
+
1242
+ # Initialize ClusterTreePlotter
1243
+ plotter = ClusterTreePlotter(
1244
+ adata,
1245
+ resolutions,
1246
+ output_settings=cast("OutputSettings", output_settings),
1247
+ node_style=cast("NodeStyle", node_style),
1248
+ edge_style=cast("EdgeStyle", edge_style),
1249
+ gene_label_settings=cast("GeneLabelSettings", gene_label_settings),
1250
+ level_label_style=cast("LevelLabelStyle", level_label_style),
1251
+ title_style=cast("TitleStyle", title_style),
1252
+ layout_settings=cast("LayoutSettings", layout_settings),
1253
+ clustering_settings=cast("ClusteringSettings", clustering_settings),
1254
+ )
1255
+ # Build graph and compute layout
1256
+ plotter.build_cluster_graph()
1257
+ plotter.compute_cluster_layout()
1258
+
1259
+ # Draw if requested
1260
+ if (output_settings or {}).get("draw", True) or (output_settings or {}).get(
1261
+ "output_path"
1262
+ ):
1263
+ plotter.draw_cluster_tree()
1264
+
1265
+ if plotter.G is None:
1266
+ msg = "Graph is not initialized. Ensure build_cluster_graph() has been called."
1267
+ raise ValueError(msg)
1268
+ return plotter.G
1269
+
1270
+ @staticmethod
1271
+ def _validate_parameters(output_settings, node_style, edge_style):
1272
+ if output_settings:
1273
+ figsize = output_settings.get("figsize")
1274
+ if (
1275
+ not isinstance(figsize, tuple | list)
1276
+ or len(figsize) != 2
1277
+ or any(dim <= 0 for dim in figsize)
1278
+ ):
1279
+ msg = "figsize must be a tuple of two positive numbers (width, height)."
1280
+ raise ValueError(msg)
1281
+
1282
+ dpi = output_settings.get("dpi", 0)
1283
+ if not isinstance(dpi, int | float) or dpi <= 0:
1284
+ msg = "dpi must be a positive number."
1285
+ raise ValueError(msg)
1286
+
1287
+ if output_settings.get("draw") not in [True, False, None]:
1288
+ msg = "draw must be True, False, or None."
1289
+ raise ValueError(msg)
1290
+
1291
+ if node_style:
1292
+ node_size_val = node_style.get("node_size")
1293
+ if node_size_val is not None and node_size_val <= 0:
1294
+ msg = "node_size must be a positive number."
1295
+ raise ValueError(msg)
1296
+
1297
+ if edge_style and (
1298
+ (edge_style.get("edge_threshold", 0)) < 0
1299
+ or edge_style.get("edge_label_threshold", 0) < 0
1300
+ ):
1301
+ msg = "edge_threshold and edge_label_threshold must be non-negative."
1302
+ raise ValueError(msg)
1303
+
1304
+ @staticmethod
1305
+ def _validate_clustering_data(adata, resolutions, clustering_settings):
1306
+ if "cluster_resolution_cluster_data" not in adata.uns:
1307
+ msg = "adata.uns['cluster_resolution_cluster_data'] not found. Run `sc.tl.cluster_resolution_finder` first."
1308
+ raise ValueError(msg)
1309
+ if not resolutions:
1310
+ msg = "You must provide a list of resolutions."
1311
+ raise ValueError(msg)
1312
+
1313
+ prefix = (clustering_settings or {}).get("prefix", "leiden_res_")
1314
+ cluster_columns = [f"{prefix}{res}" for res in resolutions]
1315
+ data = adata.uns["cluster_resolution_cluster_data"]
1316
+ missing = [col for col in cluster_columns if col not in data.columns]
1317
+ if missing:
1318
+ msg = f"Missing clustering columns: {missing}"
1319
+ raise ValueError(msg)
1320
+
1321
+ @staticmethod
1322
+ def _validate_gene_labels(adata, gene_label_settings):
1323
+ if (
1324
+ gene_label_settings
1325
+ and gene_label_settings.get("show_gene_labels", False)
1326
+ and "cluster_resolution_top_genes" not in adata.uns
1327
+ ):
1328
+ msg = "Gene labels requested but `adata.uns['cluster_resolution_top_genes']` not found. Run `sc.tl.cluster_resolution_finder` first."
1329
+ raise ValueError(msg)
1330
+
1331
+ cluster_decision_tree = ClusterTreePlotter.cluster_decision_tree