spatial-aim 1.0.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.
Files changed (76) hide show
  1. aim/__init__.py +47 -0
  2. aim/__main__.py +6 -0
  3. aim/adata_schema.py +80 -0
  4. aim/aggregation.py +118 -0
  5. aim/aim_config.py +76 -0
  6. aim/analysis/__init__.py +13 -0
  7. aim/analysis/analysis.py +72 -0
  8. aim/analysis/biology.py +205 -0
  9. aim/analysis/confidence.py +49 -0
  10. aim/analysis/ksweep.py +184 -0
  11. aim/analysis/loading.py +70 -0
  12. aim/analysis/onehot.py +41 -0
  13. aim/analysis/reconstruction.py +131 -0
  14. aim/analysis/topology.py +142 -0
  15. aim/analysis/utils.py +16 -0
  16. aim/cli.py +371 -0
  17. aim/clustering.py +242 -0
  18. aim/data/__init__.py +0 -0
  19. aim/data/prepare/__init__.py +0 -0
  20. aim/data/prepare/build_imaging_pairs.py +397 -0
  21. aim/data/prepare/build_xenium_breast_pair.py +310 -0
  22. aim/data/prepare/convertTsvToCsv.py +39 -0
  23. aim/data/prepare/convert_csv_to_h5ad.py +217 -0
  24. aim/data/prepare/createOnlyHVGDataset.py +69 -0
  25. aim/data/prepare/createSmallerDataset.py +102 -0
  26. aim/data/prepare/normalize_gene_names.py +56 -0
  27. aim/data/prepare/perturb_sc_noise.py +107 -0
  28. aim/data/prepare/register_st_patches.py +180 -0
  29. aim/data/prepare/split_st_into_islands.py +551 -0
  30. aim/data/prepare/split_st_into_patches.py +240 -0
  31. aim/data/prepare/subset_st_hvg.py +43 -0
  32. aim/data/validate/__init__.py +35 -0
  33. aim/data/validate/common.py +141 -0
  34. aim/data/validate/runner.py +335 -0
  35. aim/data/validate/validate_pair.py +53 -0
  36. aim/data/validate/validate_sc.py +103 -0
  37. aim/data/validate/validate_st.py +74 -0
  38. aim/gui/__init__.py +8 -0
  39. aim/gui/__main__.py +77 -0
  40. aim/gui/app.py +1784 -0
  41. aim/gui/compute.py +106 -0
  42. aim/gui/data_access.py +292 -0
  43. aim/gui/render.py +1679 -0
  44. aim/gui/scaffold.py +129 -0
  45. aim/gui/widgets.py +675 -0
  46. aim/io.py +196 -0
  47. aim/mapping/__init__.py +14 -0
  48. aim/mapping/base.py +67 -0
  49. aim/mapping/confidence.py +51 -0
  50. aim/mapping/nearest_centroid.py +33 -0
  51. aim/mapping/reference.py +156 -0
  52. aim/mapping/wann.py +208 -0
  53. aim/metrics/__init__.py +26 -0
  54. aim/metrics/biology.py +69 -0
  55. aim/metrics/cossim.py +127 -0
  56. aim/metrics/kselection.py +272 -0
  57. aim/metrics/onehot.py +54 -0
  58. aim/metrics/reconstruction.py +73 -0
  59. aim/metrics/topology.py +62 -0
  60. aim/reference_aligners/__init__.py +0 -0
  61. aim/reference_aligners/environment_dot.yml +23 -0
  62. aim/reference_aligners/environment_tacco.yml +28 -0
  63. aim/reference_aligners/environment_tangram.yml +16 -0
  64. aim/reference_aligners/registry.py +350 -0
  65. aim/reference_aligners/run_dot.R +172 -0
  66. aim/reference_aligners/run_dot.py +116 -0
  67. aim/reference_aligners/run_tacco.py +143 -0
  68. aim/reference_aligners/run_tangram.py +210 -0
  69. aim/sweep.py +261 -0
  70. aim/tree.py +86 -0
  71. spatial_aim-1.0.0.dist-info/METADATA +1005 -0
  72. spatial_aim-1.0.0.dist-info/RECORD +76 -0
  73. spatial_aim-1.0.0.dist-info/WHEEL +5 -0
  74. spatial_aim-1.0.0.dist-info/entry_points.txt +2 -0
  75. spatial_aim-1.0.0.dist-info/licenses/LICENSE +674 -0
  76. spatial_aim-1.0.0.dist-info/top_level.txt +1 -0
aim/cli.py ADDED
@@ -0,0 +1,371 @@
1
+ """Unified ``aim`` command-line interface.
2
+
3
+ Subcommands:
4
+
5
+ * ``aim run`` - run the AIM sweep for a single sc/st pair or a whole
6
+ ``pairs.csv`` (batch). ``--start_from_annotation`` starts
7
+ it from a pre-existing annotation instead of a Leiden
8
+ over-clustering.
9
+ * ``aim gui`` - launch the interactive Streamlit results GUI.
10
+ * ``aim validate`` - validate a single sc/st pair, or every pair of a
11
+ ``pairs.csv``, against the h5ad contract.
12
+
13
+ The method itself lives in the ``aim`` package (see ``aim/__init__.py`` for the
14
+ module map). Heavy imports (scanpy / squidpy) are deferred into the individual
15
+ command handlers so that light commands - ``aim gui``, ``aim validate``,
16
+ ``aim --help`` - start without loading the full sweep stack.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import csv
23
+ import logging
24
+ import sys
25
+ import warnings
26
+ from importlib.metadata import PackageNotFoundError, version
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING
29
+
30
+ # conda ships both Intel (libiomp) and LLVM (libomp) OpenMP runtimes; threadpoolctl
31
+ # warns about the duplicate load on every sklearn/scanpy call. It is harmless for
32
+ # our numeric usage, so silence that one RuntimeWarning before the heavy imports.
33
+ warnings.filterwarnings(
34
+ "ignore",
35
+ message=r"(?s).*Found Intel OpenMP.*LLVM OpenMP",
36
+ category=RuntimeWarning,
37
+ )
38
+ # docrep (pulled in by squidpy) emits a SyntaxWarning for every unrecognised
39
+ # docstring key (e.g. 'n_jobs', 'show_progress_bar'); harmless doc-parsing noise.
40
+ warnings.filterwarnings(
41
+ "ignore",
42
+ message=r".*is not a valid key!",
43
+ category=SyntaxWarning,
44
+ )
45
+
46
+ # Light imports only - both are scanpy-free and just feed the parser.
47
+ from aim.aim_config import LINKAGE_METHODS, MAPPING_CHOICES
48
+
49
+ if TYPE_CHECKING:
50
+ import pandas as pd
51
+
52
+ from aim.aim_config import AIMConfig
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+
57
+ def _pkg_version() -> str:
58
+ try:
59
+ return version("spatial-aim")
60
+ except PackageNotFoundError: # running from a source tree without install
61
+ return "0+unknown"
62
+
63
+
64
+ def _setup_logging(verbose: bool) -> None:
65
+ logging.basicConfig(
66
+ stream=sys.stdout,
67
+ level=logging.DEBUG if verbose else logging.INFO,
68
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
69
+ )
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # aim run - AIM sweep, single pair or batch
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def run_one_pair(
78
+ sc_path: Path,
79
+ st_path: Path,
80
+ root_output_folder: Path,
81
+ config: "AIMConfig",
82
+ ) -> "pd.DataFrame":
83
+ """Write config.yaml + run the K-sweep for a single sc/st pair.
84
+
85
+ The mapper-independent reference scaffold (start clusters + aggregates +
86
+ UMAPs) is cached under ``root_output_folder`` and reused across mappers, so
87
+ running several mappers for one pair computes it once (the GUI relies on this).
88
+ """
89
+ from dataclasses import asdict
90
+
91
+ import yaml
92
+
93
+ from aim import run
94
+
95
+ root_output_folder = Path(root_output_folder)
96
+ root_output_folder.mkdir(parents=True, exist_ok=True)
97
+ mapping_output_folder = Path(root_output_folder) / config.mapping
98
+ mapping_output_folder.mkdir(parents=True, exist_ok=True)
99
+
100
+ with open(mapping_output_folder / "config.yaml", "w") as f:
101
+ yaml.safe_dump({**asdict(config)}, f, sort_keys=False)
102
+
103
+ return run(
104
+ sc_path=sc_path,
105
+ st_path=st_path,
106
+ root_output_folder=root_output_folder,
107
+ mapper=config.build_mapper(),
108
+ linkage_method=config.linkage_method,
109
+ leiden_resolution=config.leiden_resolution,
110
+ k_min=config.k_min,
111
+ k_max=config.k_max,
112
+ k_step=config.k_step,
113
+ start_from_annotation=config.start_from_annotation,
114
+ )
115
+
116
+
117
+ def run_batch(
118
+ pairs_csv: Path,
119
+ sc_dir: Path,
120
+ st_dir: Path,
121
+ output_dir: Path,
122
+ config: "AIMConfig",
123
+ ) -> list[str]:
124
+ """Run the sweep for every row of pairs.csv. Returns the list of error messages
125
+ (empty if all pairs succeeded)."""
126
+ with open(pairs_csv, newline="") as fh:
127
+ all_pairs = list(csv.DictReader(fh))
128
+ logger.info("Loaded %d pairs from %s", len(all_pairs), pairs_csv)
129
+
130
+ errors: list[str] = []
131
+ for pair in all_pairs:
132
+ pair_id = int(pair["PairID"])
133
+ sc_name = pair["scName"]
134
+ st_name = pair["stName"]
135
+ sc_path = sc_dir / f"{sc_name}.h5ad"
136
+ st_path = st_dir / f"{st_name}.h5ad"
137
+
138
+ missing = [p for p in (sc_path, st_path) if not p.exists()]
139
+ if missing:
140
+ msg = f"[Pair {pair_id:>3}] Missing files: {[str(p) for p in missing]}"
141
+ logger.error(msg)
142
+ errors.append(msg)
143
+ continue
144
+
145
+ pair_output = output_dir / f"{pair_id:03d}_{sc_name}__{st_name}"
146
+ logger.info("[Pair %3d] %s x %s -> %s", pair_id, sc_name, st_name, pair_output)
147
+ try:
148
+ run_one_pair(sc_path, st_path, pair_output, config)
149
+ except Exception as exc:
150
+ msg = f"[Pair {pair_id:>3}] FAILED: {exc}"
151
+ logger.error(msg)
152
+ errors.append(msg)
153
+
154
+ if errors:
155
+ logger.warning("%d pair(s) failed:", len(errors))
156
+ for e in errors:
157
+ logger.warning(" %s", e)
158
+ else:
159
+ logger.info("All pairs completed successfully.")
160
+ return errors
161
+
162
+
163
+ def _cmd_run(args: argparse.Namespace) -> int:
164
+ from aim.aim_config import AIMConfig
165
+
166
+ _setup_logging(args.logging == "verbose")
167
+
168
+ config = AIMConfig(
169
+ mapping=args.mapping,
170
+ leiden_resolution=args.leiden_resolution,
171
+ linkage_method=args.linkage_method,
172
+ k_min=args.k_min,
173
+ k_max=args.k_max,
174
+ k_step=args.k_step,
175
+ start_from_annotation=args.start_from_annotation,
176
+ )
177
+
178
+ batch_mode = args.pairs_csv is not None
179
+ if batch_mode:
180
+ if args.sc_dir is None or args.st_dir is None or args.output_dir is None:
181
+ _fail("--pairs_csv requires --sc_dir, --st_dir, and --output_dir")
182
+ run_batch(args.pairs_csv, args.sc_dir, args.st_dir, args.output_dir, config)
183
+ else:
184
+ if args.scdata is None or args.stdata is None or args.output_dir is None:
185
+ _fail(
186
+ "Provide either --scdata/--stdata (single pair) "
187
+ "or --pairs_csv/--sc_dir/--st_dir (batch)"
188
+ )
189
+ run_one_pair(args.scdata, args.stdata, args.output_dir, config)
190
+ return 0
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # aim gui / aim validate
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def _cmd_gui(args: argparse.Namespace) -> int:
199
+ from aim.gui.__main__ import launch
200
+
201
+ return launch(server_port=args.server_port)
202
+
203
+
204
+ def _cmd_validate(args: argparse.Namespace) -> int:
205
+ from aim.data.validate import validate_pairs_csv, validate_single_pair
206
+
207
+ if args.pairs_csv is not None:
208
+ if args.scdata is not None or args.stdata is not None:
209
+ _fail("--pairs_csv cannot be combined with --scdata/--stdata")
210
+ return validate_pairs_csv(args.pairs_csv, args.sc_dir, args.st_dir)
211
+
212
+ if args.scdata is None or args.stdata is None:
213
+ _fail(
214
+ "Provide either --scdata and --stdata (single pair) "
215
+ "or --pairs_csv (every pair of a pairs.csv)"
216
+ )
217
+ if args.sc_dir is not None or args.st_dir is not None:
218
+ _fail("--sc_dir/--st_dir only apply to --pairs_csv")
219
+ return validate_single_pair(args.scdata, args.stdata)
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Parser
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ def _fail(message: str) -> None:
228
+ """Print an error to stderr and exit (mirrors argparse's parser.error)."""
229
+ print(f"aim: error: {message}", file=sys.stderr)
230
+ raise SystemExit(2)
231
+
232
+
233
+ def _add_run_parser(sub: "argparse._SubParsersAction") -> None:
234
+ p = sub.add_parser(
235
+ "run",
236
+ help="Run the AIM sweep (single pair or batch).",
237
+ description="Run the AIM agglomerative-K sweep with a modular spot-to-state "
238
+ "mapper, for one sc/st pair or every row of a pairs.csv.",
239
+ )
240
+ # Single-pair mode
241
+ p.add_argument("--scdata", type=Path, default=None, help="Single-pair: sc .h5ad")
242
+ p.add_argument("--stdata", type=Path, default=None, help="Single-pair: ST .h5ad")
243
+ # Batch mode
244
+ p.add_argument("--pairs_csv", type=Path, default=None, help="Batch: pairs.csv")
245
+ p.add_argument(
246
+ "--sc_dir", type=Path, default=None, help="Batch: folder of scRNA .h5ad"
247
+ )
248
+ p.add_argument(
249
+ "--st_dir", type=Path, default=None, help="Batch: folder of ST .h5ad"
250
+ )
251
+ p.add_argument(
252
+ "--output_dir",
253
+ type=Path,
254
+ default=None,
255
+ help="Output folder (single) or root for all pair outputs (batch)",
256
+ )
257
+ # Method knobs
258
+ p.add_argument(
259
+ "--mapping",
260
+ choices=list(MAPPING_CHOICES),
261
+ default="nearest_centroid",
262
+ help="Spot-to-state mapping: 'nearest_centroid' (zero-parameter "
263
+ "nearest-centroid by cosine, default), 'wann' (reliability-weighted "
264
+ "adaptive-kNN label transfer, parameter-free), or an external reference "
265
+ "aligner 'tangram' / 'tacco' / 'dot' (each runs out-of-process in its own "
266
+ "conda env, once per K).",
267
+ )
268
+ p.add_argument(
269
+ "--start_from_annotation",
270
+ type=str,
271
+ default=None,
272
+ metavar="OBS_COLUMN",
273
+ help="Start the agglomeration from a pre-existing annotation instead of a "
274
+ "Leiden over-clustering: the named scRNA obs column's cell types become the "
275
+ "start clusters, no over-clustering is computed, and K sweeps from the "
276
+ "number of annotated types down (cells with no label are dropped). "
277
+ "--leiden_resolution then only governs the shared-gene reference Leiden. "
278
+ "Give such runs their own --output_dir: a run root is named after the "
279
+ "mapper alone, so the two modes would otherwise overwrite each other.",
280
+ )
281
+ p.add_argument("--leiden_resolution", type=float, default=3.0)
282
+ p.add_argument(
283
+ "--linkage_method",
284
+ choices=list(LINKAGE_METHODS),
285
+ default=LINKAGE_METHODS[0],
286
+ help="Linkage for the agglomeration tree over the start clusters: "
287
+ "'average' (default, UPGMA) peels small tight groups off a dominant "
288
+ "state; 'ward' carries a size term and tends to produce balanced states.",
289
+ )
290
+ p.add_argument("--k_min", type=int, default=None)
291
+ p.add_argument("--k_max", type=int, default=None)
292
+ p.add_argument("--k_step", type=int, default=1)
293
+ p.add_argument("--logging", choices=["normal", "verbose"], default="normal")
294
+ p.set_defaults(func=_cmd_run)
295
+
296
+
297
+ def _add_gui_parser(sub: "argparse._SubParsersAction") -> None:
298
+ p = sub.add_parser(
299
+ "gui",
300
+ help="Launch the interactive Streamlit results GUI.",
301
+ description="Launch the AIM results GUI (configure everything in the sidebar).",
302
+ )
303
+ p.add_argument(
304
+ "--server_port",
305
+ type=int,
306
+ default=8501,
307
+ help="Port for the Streamlit server (default 8501).",
308
+ )
309
+ p.set_defaults(func=_cmd_gui)
310
+
311
+
312
+ def _add_validate_parser(sub: "argparse._SubParsersAction") -> None:
313
+ p = sub.add_parser(
314
+ "validate",
315
+ help="Validate a single sc/st pair or every pair of a pairs.csv.",
316
+ description="Check the scRNA and ST h5ads against the AIM contract (raw "
317
+ "counts, unique uppercase gene names, ST obsm['spatial']) and the pair "
318
+ "against its shared-gene intersection. With --pairs_csv, an index.csv next "
319
+ "to the h5ads additionally cross-checks the recorded counts. Exits "
320
+ "non-zero if any errors are found.",
321
+ )
322
+ # Single-pair mode
323
+ p.add_argument("--scdata", type=Path, default=None, help="Single-pair: sc .h5ad")
324
+ p.add_argument("--stdata", type=Path, default=None, help="Single-pair: ST .h5ad")
325
+ # Batch mode
326
+ p.add_argument(
327
+ "--pairs_csv",
328
+ type=Path,
329
+ default=None,
330
+ help="Batch: pairs.csv - validates every pair it lists and each dataset "
331
+ "it references.",
332
+ )
333
+ p.add_argument(
334
+ "--sc_dir",
335
+ type=Path,
336
+ default=None,
337
+ help="Batch: folder of scRNA .h5ad (default: scRNA/ next to the pairs.csv)",
338
+ )
339
+ p.add_argument(
340
+ "--st_dir",
341
+ type=Path,
342
+ default=None,
343
+ help="Batch: folder of ST .h5ad (default: ST/ next to the pairs.csv)",
344
+ )
345
+ p.set_defaults(func=_cmd_validate)
346
+
347
+
348
+ def _build_parser() -> argparse.ArgumentParser:
349
+ parser = argparse.ArgumentParser(
350
+ prog="aim",
351
+ description="AIM - annotation-independent mapping of scRNA references onto "
352
+ "high-resolution spatial transcriptomics.",
353
+ )
354
+ parser.add_argument(
355
+ "--version", action="version", version=f"%(prog)s {_pkg_version()}"
356
+ )
357
+ sub = parser.add_subparsers(dest="command", required=True)
358
+ _add_run_parser(sub)
359
+ _add_gui_parser(sub)
360
+ _add_validate_parser(sub)
361
+ return parser
362
+
363
+
364
+ def main(argv: list[str] | None = None) -> int:
365
+ parser = _build_parser()
366
+ args = parser.parse_args(argv)
367
+ return args.func(args) or 0
368
+
369
+
370
+ if __name__ == "__main__":
371
+ raise SystemExit(main())
aim/clustering.py ADDED
@@ -0,0 +1,242 @@
1
+ """Start clusters for the AIM sweep: the partition the agglomeration tree is built
2
+ over, plus the reference neighbor graphs the analysis measures against.
3
+
4
+ Two ways to get start clusters:
5
+
6
+ * **Leiden over-clustering** (the default) -- ``run_leiden_clustering_all_genes``
7
+ over-clusters the reference on all genes.
8
+ * **A pre-existing annotation** -- ``set_start_clusters_from_annotation`` takes an
9
+ ``obs`` column of cell-type labels as the starting partition, so no Leiden
10
+ over-clustering is computed at all.
11
+
12
+ Both write the same keys (``OBS_START_CLUSTER`` / ``UNS_N_START_CLUSTERS`` /
13
+ ``UNS_START_CLUSTER_NAMES``), so everything downstream is identical.
14
+
15
+ Building the neighbor graphs is split out from running Leiden on them
16
+ (``build_neighbor_graph_*``): the graphs are needed in both modes -- the modularity
17
+ metrics measure a partition against them and the UMAPs are laid out from them --
18
+ while the all-gene Leiden itself is only run in the first mode. The shared-gene
19
+ Leiden is a property of the reference (it feeds ``modularity_shared_leiden``), not a
20
+ starting partition, so it runs in both modes.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+ import scanpy as sc
30
+ from anndata import AnnData
31
+
32
+ from aim.adata_schema import (
33
+ LAYER_LOGNORM,
34
+ OBS_LEIDEN_SHARED_GENES,
35
+ OBS_START_CLUSTER,
36
+ OBSM_LOGNORM_SHARED_GENES,
37
+ OBSM_PCA,
38
+ OBSM_PCA_SHARED_GENES,
39
+ OBSP_CONNECTIVITIES_SHARED_GENES,
40
+ OBSP_DISTANCES_SHARED_GENES,
41
+ UNS_LEIDEN_NUMBER_STATES_SHARED_GENES,
42
+ UNS_LEIDEN_RESOLUTION_ALL_GENES,
43
+ UNS_LEIDEN_RESOLUTION_SHARED_GENES,
44
+ UNS_N_START_CLUSTERS,
45
+ UNS_NEIGHBORS_SHARED_GENES,
46
+ UNS_SHARED_GENES,
47
+ UNS_START_CLUSTER_NAMES,
48
+ )
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ def build_neighbor_graph_all_genes(
54
+ adata_sc: AnnData,
55
+ n_pca_comps: int = 30,
56
+ ) -> None:
57
+ """
58
+ PCA + KNN graph of the reference over all genes, in place; ``adata_sc.X`` is left raw.
59
+
60
+ Needed in both start-cluster modes: the all-gene Leiden runs on this graph, the
61
+ ``modularity_all`` metric measures the computed states against it, and the
62
+ all-gene UMAP is laid out from it.
63
+
64
+ Requires: adata_sc.layers[LAYER_LOGNORM].
65
+ Adds: adata_sc.obsm[OBSM_PCA] (plus the scanpy neighbor graph under its default keys).
66
+ """
67
+ n = min(n_pca_comps, adata_sc.n_obs - 1, adata_sc.n_vars - 1)
68
+ sc.pp.pca(adata_sc, n_comps=n, layer=LAYER_LOGNORM, key_added=OBSM_PCA)
69
+ sc.pp.neighbors(adata_sc, use_rep=OBSM_PCA)
70
+
71
+
72
+ def run_leiden_clustering_all_genes(
73
+ adata_sc: AnnData,
74
+ resolution: float,
75
+ n_pca_comps: int = 30,
76
+ ):
77
+ """
78
+ Leiden over-cluster the reference on all genes into start clusters, in place.
79
+
80
+ Requires: adata_sc.layers[LAYER_LOGNORM].
81
+ Adds: adata_sc.obsm[OBSM_PCA], adata_sc.obs[OBS_START_CLUSTER],
82
+ adata_sc.uns[UNS_START_CLUSTER_NAMES], adata_sc.uns[UNS_N_START_CLUSTERS],
83
+ adata_sc.uns[UNS_LEIDEN_RESOLUTION_ALL_GENES] (plus the scanpy neighbor graph).
84
+ """
85
+
86
+ build_neighbor_graph_all_genes(adata_sc, n_pca_comps=n_pca_comps)
87
+
88
+ sc.tl.leiden(
89
+ adata_sc,
90
+ resolution=resolution,
91
+ flavor="igraph",
92
+ n_iterations=2,
93
+ key_added=OBS_START_CLUSTER,
94
+ )
95
+ number_of_clusters = len(
96
+ np.unique(adata_sc.obs[OBS_START_CLUSTER].astype(int).values)
97
+ )
98
+ logger.info(
99
+ "Leiden clustering (resolution=%.2f): %d start clusters",
100
+ resolution,
101
+ number_of_clusters,
102
+ )
103
+
104
+ adata_sc.uns[UNS_LEIDEN_RESOLUTION_ALL_GENES] = resolution
105
+ adata_sc.uns[UNS_N_START_CLUSTERS] = number_of_clusters
106
+ adata_sc.uns[UNS_START_CLUSTER_NAMES] = [
107
+ f"cluster_{i}" for i in range(number_of_clusters)
108
+ ]
109
+
110
+
111
+ def drop_unannotated(adata_sc: AnnData, cell_type_key: str) -> AnnData:
112
+ """Return ``adata_sc`` without the cells that carry no ``cell_type_key`` label.
113
+
114
+ A cell with no label cannot be a start cluster; dropping it beats letting it
115
+ become a spurious "nan" type. Returns the same object when nothing is dropped,
116
+ a copy otherwise -- so callers must rebind. Raises ``KeyError`` (listing the
117
+ available columns) when the annotation column is missing.
118
+ """
119
+ if cell_type_key not in adata_sc.obs:
120
+ raise KeyError(
121
+ f"cell_type_key {cell_type_key!r} not in obs; "
122
+ f"available: {list(adata_sc.obs.columns)}"
123
+ )
124
+ labelled = adata_sc.obs[cell_type_key].notna().to_numpy()
125
+ if labelled.all():
126
+ return adata_sc
127
+ logger.warning(
128
+ "Dropping %d of %d cell(s) with no %r label",
129
+ int((~labelled).sum()),
130
+ adata_sc.n_obs,
131
+ cell_type_key,
132
+ )
133
+ return adata_sc[labelled].copy()
134
+
135
+
136
+ def set_start_clusters_from_annotation(
137
+ adata_sc: AnnData, cell_type_key: str
138
+ ) -> list[str]:
139
+ """Use the annotation in ``obs[cell_type_key]`` as the start clusters, in place.
140
+
141
+ The annotated types take the place of the Leiden over-clustering: their
142
+ categorical codes (contiguous 0..L-1, in ``categories`` order) become the start
143
+ cluster of every cell, so no Leiden over-clustering is computed. Run
144
+ :func:`drop_unannotated` first -- an unlabelled cell would get code -1 here.
145
+
146
+ Adds: adata_sc.obs[OBS_START_CLUSTER], adata_sc.uns[UNS_N_START_CLUSTERS],
147
+ adata_sc.uns[UNS_START_CLUSTER_NAMES]. Returns the type names.
148
+ """
149
+ types = pd.Categorical(adata_sc.obs[cell_type_key].astype(str))
150
+ names = [str(c) for c in types.categories]
151
+
152
+ adata_sc.obs[OBS_START_CLUSTER] = np.asarray(types.codes, dtype=np.int64)
153
+ adata_sc.uns[UNS_N_START_CLUSTERS] = len(names)
154
+ adata_sc.uns[UNS_START_CLUSTER_NAMES] = names
155
+ logger.info(
156
+ "Start clusters from annotation %r: %d type(s)", cell_type_key, len(names)
157
+ )
158
+ return names
159
+
160
+
161
+ def build_neighbor_graph_shared_genes(
162
+ adata_sc: AnnData,
163
+ n_pca_comps: int = 30,
164
+ ) -> None:
165
+ """
166
+ PCA + KNN graph of the reference restricted to the shared genes, in place on
167
+ ``adata_sc``. PCA and neighbors run inside a throwaway shared-gene AnnData, then
168
+ the results are copied back under the shared-gene keys.
169
+
170
+ Requires: adata_sc.uns[UNS_SHARED_GENES], adata_sc.obsm[OBSM_LOGNORM_SHARED_GENES].
171
+ Adds: adata_sc.obsm[OBSM_PCA_SHARED_GENES], adata_sc.uns[UNS_NEIGHBORS_SHARED_GENES],
172
+ adata_sc.obsp[OBSP_DISTANCES_SHARED_GENES], adata_sc.obsp[OBSP_CONNECTIVITIES_SHARED_GENES].
173
+ """
174
+ shared_genes = adata_sc.uns[UNS_SHARED_GENES]
175
+ adata_shared = AnnData(
176
+ X=adata_sc.obsm[OBSM_LOGNORM_SHARED_GENES],
177
+ obs=adata_sc.obs[[]].copy(),
178
+ var=pd.DataFrame(index=shared_genes),
179
+ )
180
+
181
+ n = min(n_pca_comps, adata_shared.n_obs - 1, adata_shared.n_vars - 1)
182
+ sc.pp.pca(adata_shared, n_comps=n, key_added=OBSM_PCA_SHARED_GENES)
183
+
184
+ sc.pp.neighbors(
185
+ adata_shared,
186
+ use_rep=OBSM_PCA_SHARED_GENES,
187
+ key_added=UNS_NEIGHBORS_SHARED_GENES,
188
+ )
189
+
190
+ # Copy the shared-gene PCA and neighbor graph back onto adata_sc. The copied
191
+ # uns entry carries the connectivities/distances key names, so scanpy tools
192
+ # (leiden, umap) can run on adata_sc with neighbors_key=UNS_NEIGHBORS_SHARED_GENES.
193
+ adata_sc.obsm[OBSM_PCA_SHARED_GENES] = adata_shared.obsm[OBSM_PCA_SHARED_GENES]
194
+ adata_sc.uns[UNS_NEIGHBORS_SHARED_GENES] = adata_shared.uns[
195
+ UNS_NEIGHBORS_SHARED_GENES
196
+ ]
197
+ adata_sc.obsp[OBSP_DISTANCES_SHARED_GENES] = adata_shared.obsp[
198
+ OBSP_DISTANCES_SHARED_GENES
199
+ ]
200
+ adata_sc.obsp[OBSP_CONNECTIVITIES_SHARED_GENES] = adata_shared.obsp[
201
+ OBSP_CONNECTIVITIES_SHARED_GENES
202
+ ]
203
+
204
+
205
+ def run_leiden_clustering_shared_genes(
206
+ adata_sc: AnnData,
207
+ resolution: float,
208
+ n_pca_comps: int = 30,
209
+ ):
210
+ """
211
+ Leiden-cluster the reference restricted to the shared genes, in place on ``adata_sc``.
212
+
213
+ This is a property of the reference, not a starting partition: it is what
214
+ ``modularity_shared_leiden`` measures and what the shared-gene UMAP is coloured
215
+ by, so it runs whichever way the start clusters were obtained.
216
+
217
+ Requires: adata_sc.uns[UNS_SHARED_GENES], adata_sc.obsm[OBSM_LOGNORM_SHARED_GENES].
218
+ Adds: everything :func:`build_neighbor_graph_shared_genes` adds, plus
219
+ adata_sc.obs[OBS_LEIDEN_SHARED_GENES], adata_sc.uns[UNS_LEIDEN_RESOLUTION_SHARED_GENES],
220
+ adata_sc.uns[UNS_LEIDEN_NUMBER_STATES_SHARED_GENES].
221
+ """
222
+ build_neighbor_graph_shared_genes(adata_sc, n_pca_comps=n_pca_comps)
223
+
224
+ sc.tl.leiden(
225
+ adata_sc,
226
+ resolution=resolution,
227
+ flavor="igraph",
228
+ n_iterations=2,
229
+ neighbors_key=UNS_NEIGHBORS_SHARED_GENES,
230
+ key_added=OBS_LEIDEN_SHARED_GENES,
231
+ )
232
+ number_of_clusters = len(
233
+ np.unique(adata_sc.obs[OBS_LEIDEN_SHARED_GENES].astype(int).values)
234
+ )
235
+ logger.info(
236
+ "Leiden clustering on shared genes (resolution=%.2f): %d clusters",
237
+ resolution,
238
+ number_of_clusters,
239
+ )
240
+
241
+ adata_sc.uns[UNS_LEIDEN_RESOLUTION_SHARED_GENES] = resolution
242
+ adata_sc.uns[UNS_LEIDEN_NUMBER_STATES_SHARED_GENES] = number_of_clusters
aim/data/__init__.py ADDED
File without changes
File without changes