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/__init__.py +14 -0
- resolutiontree/core.py +1331 -0
- resolutiontree/utils.py +377 -0
- resolutiontree-0.1.0.dist-info/METADATA +29 -0
- resolutiontree-0.1.0.dist-info/RECORD +8 -0
- resolutiontree-0.1.0.dist-info/WHEEL +5 -0
- resolutiontree-0.1.0.dist-info/licenses/LICENSE +0 -0
- resolutiontree-0.1.0.dist-info/top_level.txt +1 -0
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
|