Skip to content

Commit b4106a7

Browse files
committed
Use multipartite layout
1 parent 060148a commit b4106a7

7 files changed

Lines changed: 76 additions & 33 deletions

File tree

CITATION.cff

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ authors:
88
- family-names: "Puelles"
99
given-names: "Victor G."
1010
title: "pyclustree: Visualizing cluster resolution optimization for biomedical data"
11-
version: 0.3.1
12-
date-released: 2024-10-24
11+
version: 0.4.0
12+
date-released: 2025-03-28
1313
url: "https://zenodo.org/records/13987570"

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ Also add information on how to contact you by electronic and paper mail.
653653
If the program does terminal interaction, make it output a short
654654
notice like this when it starts in an interactive mode:
655655

656-
pyclustree Copyright (C) 2024, Malte Kuehl & Malte Hellmig
656+
pyclustree Copyright (C) 2025, Malte Kuehl & Malte Hellmig
657657
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
658658
This is free software, and you are welcome to redistribute it
659659
under certain conditions; type `show c' for details.

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# -- Project information -----------------------------------------------------
1414

1515
project = "pyclustree"
16-
copyright = "2024, Malte Kuehl"
16+
copyright = "2025, Malte Kuehl"
1717
author = "Malte Kuehl"
1818

1919
# -- General configuration ---------------------------------------------------

docs/source/example.ipynb

Lines changed: 9 additions & 6 deletions
Large diffs are not rendered by default.

pyclustree/_clustree.py

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from logging import warning
2-
from typing import Callable, Optional, Literal, Union
2+
from typing import Callable, Literal, Optional, Union
33

44
import networkx as nx
55
import numpy as np
66
from anndata import AnnData
7-
from numpy.typing import ArrayLike, NDArray
87
from matplotlib import pyplot as plt
98
from matplotlib.colors import Colormap
9+
from numpy.typing import ArrayLike, NDArray
1010

1111
from pyclustree._utils import calculate_transition_matrix, order_unique_clusters
1212

@@ -19,7 +19,7 @@ def clustree(
1919
node_colormap: Union[list[Colormap], Colormap, str] = "tab20",
2020
node_color_gene: Optional[str] = None,
2121
node_color_gene_use_raw: bool = True,
22-
node_color_gene_transformer: Optional[callable] = None,
22+
node_color_gene_transformer: Optional[Callable] = None,
2323
node_size_range: tuple[float, float] = (100, 1000),
2424
edge_width_range: tuple[float, float] = (0.5, 5.0),
2525
edge_weight_threshold: float = 0.0,
@@ -30,7 +30,10 @@ def clustree(
3030
show_fraction: bool = False,
3131
show_cluster_keys: bool = True,
3232
score_clustering: Optional[
33-
Union[Literal["silhouette", "davies_bouldin", "calinski_harabasz"], Callable[[ArrayLike, ArrayLike], float]]
33+
Union[
34+
Literal["silhouette", "davies_bouldin", "calinski_harabasz"],
35+
Callable[[ArrayLike, ArrayLike], float],
36+
]
3437
] = None,
3538
score_basis: Literal["X", "raw", "pca"] = "pca",
3639
graph_plot_kwargs: Optional[dict] = None,
@@ -132,7 +135,8 @@ def clustree(
132135

133136
transition_matrices = [
134137
calculate_transition_matrix(
135-
df_cluster_assignments[cluster_keys[i]], df_cluster_assignments[cluster_keys[i + 1]]
138+
df_cluster_assignments[cluster_keys[i]],
139+
df_cluster_assignments[cluster_keys[i + 1]],
136140
)
137141
for i in range(len(cluster_keys) - 1)
138142
]
@@ -144,17 +148,20 @@ def clustree(
144148
unique_clusters = order_unique_clusters(unique_clusters, transition_matrices)
145149

146150
# Create the Graph
147-
G = nx.DiGraph()
151+
G: nx.Graph = nx.DiGraph()
148152

149153
# Add the nodes for each cluster key (unique clusters)
150154
node_names = []
151155

152156
for i, key in enumerate(cluster_keys):
153157
level_nodes = [f"{key}_{cluster_name}" for cluster_name in unique_clusters[i]]
154158
node_names.append(level_nodes)
155-
level_nodes = reversed(level_nodes)
159+
level_nodes.reverse()
156160

157-
G.add_nodes_from([node for nodes in node_names for node in level_nodes], layer=len(cluster_keys) - i)
161+
G.add_nodes_from(
162+
[node for nodes in node_names for node in level_nodes],
163+
layer=len(cluster_keys) - i,
164+
)
158165

159166
# Add edges between each level and the next level
160167
for i, transition_matrix in enumerate(transition_matrices):
@@ -258,7 +265,7 @@ def clustree(
258265
gene_min, gene_max = np.min(gene_cluster_means), np.max(gene_cluster_means)
259266
norm_gene = plt.Normalize(vmin=gene_min, vmax=gene_max)
260267

261-
node_colors = [node_colormap(norm_gene(gene_cluster_mean)) for gene_cluster_mean in gene_cluster_means]
268+
node_colors = [node_colormap(norm_gene(gene_cluster_mean)) for gene_cluster_mean in gene_cluster_means] # type: ignore
262269

263270
for i, key in enumerate(cluster_keys):
264271
index = sum([len(unique_clusters[k]) for k in range(i)])
@@ -271,13 +278,19 @@ def clustree(
271278
for cluster in unique_clusters[i]:
272279
node_sizes.append(np.sum(df_cluster_assignments[cluster_keys[i]] == cluster))
273280

274-
node_sizes = np.clip((np.array(node_sizes) * node_size_range[1]) / np.max(node_sizes), *node_size_range)
281+
node_sizes = np.clip(
282+
(np.array(node_sizes) * node_size_range[1]) / np.max(node_sizes),
283+
*node_size_range,
284+
)
275285

276286
# Scale the edge width based on the edge weight
277287
edge_widths = [G.edges[edge]["weight"] for edge in G.edges]
278288

279289
# Set the maximum edge width to be the maximum and clip the values to be within the range
280-
edge_widths = np.clip((np.array(edge_widths) * edge_width_range[1]) / np.max(edge_widths), *edge_width_range)
290+
edge_widths = np.clip(
291+
(np.array(edge_widths) * edge_width_range[1]) / np.max(edge_widths),
292+
*edge_width_range,
293+
)
281294

282295
figsize = x_spacing * len(cluster_keys), y_spacing * len(unique_clusters[0])
283296
fig, ax = plt.subplots(figsize=figsize, dpi=300)
@@ -301,7 +314,7 @@ def clustree(
301314
"with_labels": True,
302315
"labels": {node: node.split("_")[-1] for node in G.nodes},
303316
"pos": node_positions,
304-
"node_color": [G.nodes[node]["color"] for node in G.nodes] if node_color_gene is not None else node_colors,
317+
"node_color": ([G.nodes[node]["color"] for node in G.nodes] if node_color_gene is not None else node_colors),
305318
"edge_color": "black",
306319
"width": edge_widths,
307320
"font_size": 8,
@@ -351,15 +364,25 @@ def clustree(
351364
sm = plt.cm.ScalarMappable(cmap=node_colormap, norm=norm)
352365
sm.set_array([])
353366
label = f"{node_color_gene} expression" if node_color_gene is not None else "Cluster color"
354-
colorbar = fig.colorbar(sm, ax=ax, orientation="vertical", fraction=0.02, pad=0.02, label=label, aspect=10)
367+
colorbar = fig.colorbar(
368+
sm,
369+
ax=ax,
370+
orientation="vertical",
371+
fraction=0.02,
372+
pad=0.02,
373+
label=label,
374+
aspect=10,
375+
)
355376
colorbar.ax.yaxis.set_label_position("left")
356377
elif show_colorbar and isinstance(node_colormap, list):
357378
warning("Colorbars are not supported when providing a list of colormaps. Ignoring the argument.")
358379

359380
if score_clustering is not None or (show_cluster_keys is True and scatter_reference is not None):
360381
# Position them in equal intervals along the y-axis
361382
y_positions_levels = np.linspace(
362-
ax.get_ylim()[1], ax.get_ylim()[1] - len(cluster_keys) * 1.0, len(cluster_keys)
383+
ax.get_ylim()[1],
384+
ax.get_ylim()[1] - len(cluster_keys) * 1.0,
385+
len(cluster_keys),
363386
)
364387

365388
# Show the name of the cluster key on the left side of the plot
@@ -368,13 +391,14 @@ def clustree(
368391

369392
if scatter_reference is not None:
370393
# Use the level colors for the facecolor
394+
facecolor: Union[list[str], list[tuple[float, float, float, float]]] = []
371395
if isinstance(node_colormap, list):
372396
warning("Cannot show colored cluster keys when providing a list of colormaps. Showing white keys.")
373397
facecolor = ["white"] * len(cluster_keys)
374398
else:
375399
facecolor = [node_colormap(norm(i)) for i in range(len(cluster_keys))]
376400
else:
377-
y_positions_levels = [node_positions[node_names[i][0]][1] for i in range(len(node_names))]
401+
y_positions_levels = [node_positions[node_names[i][0]][1] for i in range(len(node_names))] # type: ignore
378402
facecolor = ["white"] * len(cluster_keys)
379403

380404
for i, key in enumerate(cluster_keys):
@@ -390,7 +414,11 @@ def clustree(
390414
color="black",
391415
ha="center",
392416
va="center",
393-
bbox={"boxstyle": "round", "facecolor": facecolor[i], "edgecolor": "black"},
417+
bbox={
418+
"boxstyle": "round",
419+
"facecolor": facecolor[i],
420+
"edgecolor": "black",
421+
},
394422
)
395423

396424
# Set the title of the plot
@@ -433,7 +461,9 @@ def clustree(
433461
else:
434462
raise ValueError(f"Score '{score_clustering}' not a valid scoring method")
435463
elif callable(score_clustering) is True:
436-
score_array[index] = float(score_clustering(basis, adata.obs[cluster_key]))
464+
score_array[index] = float(
465+
score_clustering(basis, adata.obs[cluster_key]) # type: ignore
466+
)
437467

438468
score_name_map = {
439469
"silhouette": "Silhouette score",
@@ -446,7 +476,7 @@ def clustree(
446476
ax.text(
447477
x_max,
448478
y=ax.get_ylim()[1],
449-
s=score_name_map[score_clustering],
479+
s=score_name_map[score_clustering], # type: ignore
450480
fontsize=12,
451481
color="black",
452482
ha="center",
@@ -458,11 +488,15 @@ def clustree(
458488
ax.text(
459489
x_max,
460490
y=y_positions_levels[i],
461-
s=str(round(score_array[i], 2)),
491+
s=str(round(score_array[i], 2)), # type: ignore
462492
fontsize=12,
463493
ha="center",
464494
va="center",
465-
bbox={"boxstyle": "round", "facecolor": facecolor[i], "edgecolor": "black"},
495+
bbox={
496+
"boxstyle": "round",
497+
"facecolor": facecolor[i],
498+
"edgecolor": "black",
499+
},
466500
)
467501

468502
return fig

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ requires = [ "flit-core>=3.4,<4" ]
55

66
[project]
77
name = "pyclustree"
8-
version = "0.3.2"
8+
version = "0.4.0"
99
description = "Visualize cluster assignments at different resolutions"
1010
readme = "README.md"
1111
license = { file = "LICENSE" }
@@ -69,6 +69,7 @@ optional-dependencies.dev = [
6969
"sphinxcontrib-bibtex>=1",
7070
"sphinxext-opengraph",
7171
"twine>=4.0.2",
72+
"types-networkx",
7273
]
7374
optional-dependencies.sklearn = [ "scikit-learn" ]
7475

@@ -123,7 +124,7 @@ lint.pydocstyle.convention = "google"
123124
log_format = "%(asctime)s %(levelname)s %(message)s"
124125
log_date_format = "%Y-%m-%d %H:%M:%S"
125126
log_level = "WARN"
126-
log_cli = "true"
127+
log_cli = true
127128
testpaths = [ "tests" ]
128129
xfail_strict = true
129130
addopts = [

tests/test_pyclustree.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,9 @@ def test_scatter_reference():
8989
# Testing node_color_gene when node colormap argument is provided
9090
with pytest.raises(AssertionError):
9191
cluster_keys = [f"leiden_{str(resolution).replace('.', '_')}" for resolution in [0.2, 1.0]]
92-
clustree(adata, cluster_keys, node_colormap=["#FF0000"] * len(cluster_keys), node_color_gene="CD8A")
92+
clustree(
93+
adata,
94+
cluster_keys,
95+
node_colormap=["#FF0000"] * len(cluster_keys),
96+
node_color_gene="CD8A",
97+
)

0 commit comments

Comments
 (0)