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.
@@ -0,0 +1,377 @@
1
+ from __future__ import annotations
2
+ import sys
3
+ import pandas as pd
4
+ from collections.abc import Sequence
5
+ from typing import Literal
6
+ from anndata import AnnData
7
+ from scanpy.tools._rank_genes_groups import rank_genes_groups
8
+
9
+ def find_cluster_specific_genes(
10
+ adata: AnnData,
11
+ resolutions: Sequence[float],
12
+ *,
13
+ prefix: str = "leiden_res_",
14
+ method: Literal["wilcoxon"] = "wilcoxon",
15
+ n_top_genes: int = 3,
16
+ min_cells: int = 2,
17
+ deg_mode: Literal["within_parent", "per_resolution"] = "within_parent",
18
+ verbose: bool = False,
19
+ ) -> dict[tuple[str, str], list[str]]:
20
+ """Find differentially expressed genes for clusters in two modes."""
21
+
22
+ if deg_mode not in ["within_parent", "per_resolution"]:
23
+ msg = "deg_mode must be 'within_parent' or 'per_resolution'"
24
+ raise ValueError(msg)
25
+
26
+ # Validate resolutions and clustering columns
27
+ for res in resolutions:
28
+ col = f"{prefix}{res}"
29
+ if col not in adata.obs:
30
+ msg = f"Column {col} not found in adata.obs"
31
+ raise ValueError(msg)
32
+
33
+ top_genes_dict: dict[tuple[str, str], list[str]] = {}
34
+
35
+ if deg_mode == "within_parent":
36
+ top_genes_dict.update(
37
+ find_within_parent_degs(
38
+ adata,
39
+ resolutions,
40
+ prefix=prefix,
41
+ n_top_genes=n_top_genes,
42
+ min_cells=min_cells,
43
+ rank_genes_groups=rank_genes_groups,
44
+ verbose=verbose,
45
+ )
46
+ )
47
+ elif deg_mode == "per_resolution":
48
+ top_genes_dict.update(
49
+ find_per_resolution_degs(
50
+ adata,
51
+ resolutions,
52
+ prefix=prefix,
53
+ n_top_genes=n_top_genes,
54
+ min_cells=min_cells,
55
+ rank_genes_groups=rank_genes_groups,
56
+ verbose=verbose,
57
+ )
58
+ )
59
+
60
+ return top_genes_dict
61
+
62
+
63
+ def find_within_parent_degs(
64
+ adata: AnnData,
65
+ resolutions: Sequence[float],
66
+ *,
67
+ prefix: str,
68
+ n_top_genes: int,
69
+ min_cells: int,
70
+ rank_genes_groups,
71
+ verbose: bool = False,
72
+ ) -> dict[tuple[str, str], list[str]]:
73
+ top_genes_dict = {}
74
+
75
+ for i, res in enumerate(resolutions[:-1]):
76
+ res_key = f"{prefix}{res}"
77
+ next_res_key = f"{prefix}{resolutions[i + 1]}"
78
+ clusters = adata.obs[res_key].cat.categories
79
+
80
+ for cluster in clusters:
81
+ cluster_mask = adata.obs[res_key] == cluster
82
+ cluster_adata = adata[cluster_mask, :]
83
+
84
+ subclusters = cluster_adata.obs[next_res_key].value_counts()
85
+ valid_subclusters = subclusters[subclusters >= min_cells].index
86
+
87
+ if len(valid_subclusters) < 2:
88
+ if verbose:
89
+ print(
90
+ f"Skipping res_{res}_C{cluster}: < 2 subclusters with >= {min_cells} cells."
91
+ )
92
+ continue
93
+
94
+ subcluster_mask = cluster_adata.obs[next_res_key].isin(valid_subclusters)
95
+ deg_adata = cluster_adata[subcluster_mask, :]
96
+
97
+ try:
98
+ rank_genes_groups(deg_adata, groupby=next_res_key, method="wilcoxon")
99
+ for subcluster in valid_subclusters:
100
+ names = deg_adata.uns["rank_genes_groups"]["names"][subcluster]
101
+ scores = deg_adata.uns["rank_genes_groups"]["scores"][subcluster]
102
+ top_genes = [
103
+ name
104
+ for name, score in zip(names, scores, strict=False)
105
+ if score > 0
106
+ ][:n_top_genes]
107
+ parent_node = f"res_{res}_C{cluster}"
108
+ child_node = f"res_{resolutions[i + 1]}_C{subcluster}"
109
+ top_genes_dict[(parent_node, child_node)] = top_genes
110
+ if verbose:
111
+ print(f"{parent_node} -> {child_node}: {top_genes}")
112
+ except KeyError as e:
113
+ print(f"Key error when processing {parent_node} -> {child_node}: {e}")
114
+ continue
115
+ except TypeError as e:
116
+ print(
117
+ f"Type error with the data when processing {parent_node} -> {child_node}: {e}"
118
+ )
119
+ continue
120
+
121
+ return top_genes_dict
122
+
123
+
124
+ def find_per_resolution_degs(
125
+ adata: AnnData,
126
+ resolutions: Sequence[float],
127
+ *,
128
+ prefix: str,
129
+ n_top_genes: int,
130
+ min_cells: int,
131
+ rank_genes_groups,
132
+ verbose: bool = False,
133
+ ) -> dict[tuple[str, str], list[str]]:
134
+ top_genes_dict = {}
135
+
136
+ for i, res in enumerate(resolutions[1:], 1):
137
+ res_key = f"{prefix}{res}"
138
+ prev_res_key = f"{prefix}{resolutions[i - 1]}"
139
+ clusters = adata.obs[res_key].cat.categories
140
+ valid_clusters = [
141
+ c for c in clusters if (adata.obs[res_key] == c).sum() >= min_cells
142
+ ]
143
+
144
+ if not valid_clusters:
145
+ if verbose:
146
+ print(
147
+ f"Skipping resolution {res}: no clusters with >= {min_cells} cells."
148
+ )
149
+ continue
150
+
151
+ deg_adata = adata[adata.obs[res_key].isin(valid_clusters), :]
152
+ try:
153
+ rank_genes_groups(
154
+ deg_adata, groupby=res_key, method="wilcoxon", reference="rest"
155
+ )
156
+ for cluster in valid_clusters:
157
+ names = deg_adata.uns["rank_genes_groups"]["names"][cluster]
158
+ scores = deg_adata.uns["rank_genes_groups"]["scores"][cluster]
159
+ top_genes = [
160
+ name
161
+ for name, score in zip(names, scores, strict=False)
162
+ if score > 0
163
+ ][:n_top_genes]
164
+ parent_cluster = adata.obs[deg_adata.obs[res_key] == cluster][
165
+ prev_res_key
166
+ ].mode()[0]
167
+ parent_node = f"res_{resolutions[i - 1]}_C{parent_cluster}"
168
+ child_node = f"res_{res}_C{cluster}"
169
+ top_genes_dict[(parent_node, child_node)] = top_genes
170
+ if verbose:
171
+ print(f"{parent_node} -> {child_node}: {top_genes}")
172
+ except KeyError as e:
173
+ print(f"Key error when processing {parent_node} -> {child_node}: {e}")
174
+ continue
175
+ except TypeError as e:
176
+ print(
177
+ f"Type error with the data when processing {parent_node} -> {child_node}: {e}"
178
+ )
179
+ continue
180
+
181
+ return top_genes_dict
182
+
183
+
184
+ def cluster_resolution_finder(
185
+ adata: AnnData,
186
+ resolutions: list[float],
187
+ *,
188
+ prefix: str = "leiden_res_",
189
+ method: Literal["wilcoxon"] = "wilcoxon",
190
+ n_top_genes: int = 3,
191
+ min_cells: int = 2,
192
+ deg_mode: Literal["within_parent", "per_resolution"] = "within_parent",
193
+ flavor: Literal["igraph"] = "igraph",
194
+ n_iterations: int = 2,
195
+ inplace: bool = False,
196
+ verbose: bool = False,
197
+ ) -> None:
198
+ """
199
+ Find clusters across multiple resolutions and identify cluster-specific genes.
200
+
201
+ This function performs Leiden clustering at specified resolutions, identifies
202
+ differentially expressed genes (DEGs) for clusters, and stores the results in `adata`.
203
+
204
+ Params
205
+ ------
206
+ adata
207
+ The annotated data matrix.
208
+ resolutions
209
+ List of resolution values for Leiden clustering (e.g., [0.0, 0.2, 0.5]).
210
+ prefix
211
+ Prefix for clustering keys in `adata.obs` (e.g., "leiden_res_").
212
+ method
213
+ Method for differential expression analysis: only "wilcoxon" is supported.
214
+ n_top_genes
215
+ Number of top genes to identify per child cluster.
216
+ min_cells
217
+ Minimum number of cells required in a subcluster to include it.
218
+ deg_mode
219
+ Mode for DEG analysis: "within_parent" (compare child to parent cluster) or
220
+ "per_resolution" (compare within each resolution).
221
+ flavor
222
+ Flavor of Leiden clustering: only "igraph" is supported.
223
+ n_iterations
224
+ Number of iterations for Leiden clustering.
225
+ inplace
226
+ If True, modifies `adata` in place. If False, returns a new AnnData object.
227
+
228
+ Returns
229
+ -------
230
+ adata
231
+ If `inplace` is False, returns a new AnnData object with clustering results.
232
+
233
+ The following annotations are added to `adata`:
234
+
235
+ leiden_res_{resolution}
236
+ Cluster assignments for each resolution in `adata.obs`.
237
+ cluster_resolution_top_genes
238
+ Dictionary mapping (parent_node, child_node) pairs to lists of top marker genes,
239
+ stored in `adata.uns`.
240
+
241
+ Notes
242
+ -----
243
+ This function requires the `igraph` library for Leiden clustering, which is included in the
244
+ `leiden` extra. Install it with: ``pip install scanpy[leiden]``.
245
+
246
+ Requires `sc.pp.neighbors` to be run on `adata` beforehand.
247
+
248
+ Examples
249
+ --------
250
+ >>> import scanpy as sc
251
+ >>> adata = sc.datasets.pbmc68k()
252
+ >>> sc.pp.neighbors(adata)
253
+ >>> sc.tl.find_cluster_resolution(adata, resolutions=[0.0, 0.5])
254
+ >>> sc.pl.cluster_decision_tree(adata, resolutions=[0.0, 0.5])
255
+ """
256
+ import io
257
+
258
+ from scanpy.tools import leiden
259
+
260
+ # Suppress prints if pytest is running
261
+ if "pytest" in sys.modules:
262
+ sys.stdout = io.StringIO()
263
+
264
+ _validate_cluster_resolution_inputs(adata, resolutions, method, flavor)
265
+
266
+ # Run Leiden clustering
267
+ for resolution in resolutions:
268
+ res_key = f"{prefix}{resolution}"
269
+ try:
270
+ leiden(
271
+ adata,
272
+ resolution=resolution,
273
+ flavor="igraph",
274
+ n_iterations=n_iterations,
275
+ key_added=res_key,
276
+ )
277
+ if "pytest" not in sys.modules and not hasattr(
278
+ sys, "_called_from_test"
279
+ ): # Suppress print in tests
280
+ print(f"Completed Leiden clustering for resolution {resolution}")
281
+ except ValueError as e:
282
+ msg = f"Leiden clustering failed at resolution {resolution} due to invalid value: {e}"
283
+ raise RuntimeError(msg) from None
284
+ except TypeError as e:
285
+ msg = f"Leiden clustering failed at resolution {resolution} due to incorrect type: {e}"
286
+ raise RuntimeError(msg) from None
287
+ except RuntimeError as e:
288
+ msg = f"Leiden clustering failed at resolution {resolution}: {e}"
289
+ raise RuntimeError(msg) from None
290
+
291
+ if not inplace:
292
+ adata = adata.copy()
293
+
294
+ # Find cluster-specific genes
295
+ top_genes_dict = find_cluster_specific_genes(
296
+ adata=adata,
297
+ resolutions=resolutions,
298
+ prefix=prefix,
299
+ method=method,
300
+ n_top_genes=n_top_genes,
301
+ min_cells=min_cells,
302
+ deg_mode=deg_mode,
303
+ verbose=verbose,
304
+ )
305
+
306
+ # Create DataFrame for clusterDecisionTree
307
+ try:
308
+ cluster_data = pd.DataFrame(
309
+ {f"{prefix}{r}": adata.obs[f"{prefix}{r}"] for r in resolutions}
310
+ )
311
+ except KeyError as e:
312
+ msg = f"Failed to create cluster_data DataFrame: missing column {e}"
313
+ raise RuntimeError(msg) from None
314
+ except ValueError as e:
315
+ msg = f"Failed to create cluster_data DataFrame due to invalid value: {e}"
316
+ raise RuntimeError(msg) from None
317
+ except TypeError as e:
318
+ msg = f"Failed to create cluster_data DataFrame due to incorrect type: {e}"
319
+ raise RuntimeError(msg) from None
320
+
321
+ # Store the results in adata.uns
322
+ # adata.uns["cluster_resolution_top_genes"] = top_genes_dict
323
+ adata.uns["cluster_resolution_top_genes"] = _convert_tuple_keys(top_genes_dict)
324
+ adata.uns["cluster_resolution_cluster_data"] = cluster_data
325
+
326
+ return adata
327
+
328
+ def _validate_cluster_resolution_inputs(
329
+ adata: AnnData,
330
+ resolutions: Sequence[float],
331
+ method: str,
332
+ flavor: str,
333
+ ) -> None:
334
+ """Validate inputs for the find_cluster_resolution function."""
335
+ if not resolutions:
336
+ msg = "resolutions list cannot be empty"
337
+ raise ValueError(msg)
338
+ if not all(isinstance(r, int | float) and r >= 0 for r in resolutions):
339
+ msg = "All resolutions must be non-negative numbers"
340
+ raise ValueError(msg)
341
+ if method != "wilcoxon":
342
+ msg = "Only method='wilcoxon' is supported"
343
+ raise ValueError(msg)
344
+ if flavor != "igraph":
345
+ msg = "Only flavor='igraph' is supported"
346
+ raise ValueError(msg)
347
+ if "neighbors" not in adata.uns:
348
+ msg = "adata must have precomputed neighbors (run sc.pp.neighbors first)."
349
+ raise ValueError(msg)
350
+
351
+ # Recursively convert tuple keys in .uns to strings
352
+ def _convert_tuple_keys(d, delimiter="_"):
353
+ if isinstance(d, dict):
354
+ new_dict = {}
355
+ for k, v in d.items():
356
+ if isinstance(k, tuple):
357
+ k = delimiter.join(k)
358
+ new_dict[k] = _convert_tuple_keys(v, delimiter=delimiter)
359
+ return new_dict
360
+ elif isinstance(d, list):
361
+ return [_convert_tuple_keys(i, delimiter=delimiter) for i in d]
362
+ else:
363
+ return d
364
+
365
+ # Recursively recover tuple keys in .uns from strings
366
+ def _recover_tuple_keys(d, delimiter="_"):
367
+ if isinstance(d, dict):
368
+ new_dict = {}
369
+ for k, v in d.items():
370
+ if isinstance(k, str) and delimiter in k:
371
+ k = tuple(k.split(delimiter))
372
+ new_dict[k] = _recover_tuple_keys(v, delimiter=delimiter)
373
+ return new_dict
374
+ elif isinstance(d, list):
375
+ return [_recover_tuple_keys(i, delimiter=delimiter) for i in d]
376
+ else:
377
+ return d
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: resolutiontree
3
+ Version: 0.1.0
4
+ Summary: Systematic exploration of clustering resolutions in single-cell analysis
5
+ Home-page: https://github.com/joe-jhou2/resolutiontree
6
+ Author: Joe Hou
7
+ Author-email: Joe Hou <joseph.houjue@gmail.com>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/joe-jhou2/resolutiontree
10
+ Project-URL: Documentation, https://resolutiontree.readthedocs.io/
11
+ Project-URL: Repository, https://github.com/joe-jhou2/resolutiontree
12
+ Project-URL: Bug Tracker, https://github.com/joe-jhou2/resolutiontree/issues
13
+ Keywords: single-cell,clustering,resolution,scanpy,leiden,visualization
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: author
27
+ Dynamic: home-page
28
+ Dynamic: license-file
29
+ Dynamic: requires-python
@@ -0,0 +1,8 @@
1
+ resolutiontree/__init__.py,sha256=Jjk3APrAMIyWHuchBXAH1dCN7gETacl81MacpkmwZ-0,305
2
+ resolutiontree/core.py,sha256=zrMzqvzyJwSS6kEfSIE1uRe3Spdufo1jkrvDYzb35h8,51597
3
+ resolutiontree/utils.py,sha256=pEzAghm0JFsuDyb13Fm8ncjBg0QhRy6wnuxZgDBhDNk,13339
4
+ resolutiontree-0.1.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ resolutiontree-0.1.0.dist-info/METADATA,sha256=v3O-wkDVaVsqHUk4Bl3h2uNGpieCkCLG3B5kyGznGsM,1248
6
+ resolutiontree-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ resolutiontree-0.1.0.dist-info/top_level.txt,sha256=E0usvI1QBYLUw4m1v4wY2UsZ6wRy-YD_RJxwzu-kO2U,15
8
+ resolutiontree-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
File without changes
@@ -0,0 +1 @@
1
+ resolutiontree