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.
- aim/__init__.py +47 -0
- aim/__main__.py +6 -0
- aim/adata_schema.py +80 -0
- aim/aggregation.py +118 -0
- aim/aim_config.py +76 -0
- aim/analysis/__init__.py +13 -0
- aim/analysis/analysis.py +72 -0
- aim/analysis/biology.py +205 -0
- aim/analysis/confidence.py +49 -0
- aim/analysis/ksweep.py +184 -0
- aim/analysis/loading.py +70 -0
- aim/analysis/onehot.py +41 -0
- aim/analysis/reconstruction.py +131 -0
- aim/analysis/topology.py +142 -0
- aim/analysis/utils.py +16 -0
- aim/cli.py +371 -0
- aim/clustering.py +242 -0
- aim/data/__init__.py +0 -0
- aim/data/prepare/__init__.py +0 -0
- aim/data/prepare/build_imaging_pairs.py +397 -0
- aim/data/prepare/build_xenium_breast_pair.py +310 -0
- aim/data/prepare/convertTsvToCsv.py +39 -0
- aim/data/prepare/convert_csv_to_h5ad.py +217 -0
- aim/data/prepare/createOnlyHVGDataset.py +69 -0
- aim/data/prepare/createSmallerDataset.py +102 -0
- aim/data/prepare/normalize_gene_names.py +56 -0
- aim/data/prepare/perturb_sc_noise.py +107 -0
- aim/data/prepare/register_st_patches.py +180 -0
- aim/data/prepare/split_st_into_islands.py +551 -0
- aim/data/prepare/split_st_into_patches.py +240 -0
- aim/data/prepare/subset_st_hvg.py +43 -0
- aim/data/validate/__init__.py +35 -0
- aim/data/validate/common.py +141 -0
- aim/data/validate/runner.py +335 -0
- aim/data/validate/validate_pair.py +53 -0
- aim/data/validate/validate_sc.py +103 -0
- aim/data/validate/validate_st.py +74 -0
- aim/gui/__init__.py +8 -0
- aim/gui/__main__.py +77 -0
- aim/gui/app.py +1784 -0
- aim/gui/compute.py +106 -0
- aim/gui/data_access.py +292 -0
- aim/gui/render.py +1679 -0
- aim/gui/scaffold.py +129 -0
- aim/gui/widgets.py +675 -0
- aim/io.py +196 -0
- aim/mapping/__init__.py +14 -0
- aim/mapping/base.py +67 -0
- aim/mapping/confidence.py +51 -0
- aim/mapping/nearest_centroid.py +33 -0
- aim/mapping/reference.py +156 -0
- aim/mapping/wann.py +208 -0
- aim/metrics/__init__.py +26 -0
- aim/metrics/biology.py +69 -0
- aim/metrics/cossim.py +127 -0
- aim/metrics/kselection.py +272 -0
- aim/metrics/onehot.py +54 -0
- aim/metrics/reconstruction.py +73 -0
- aim/metrics/topology.py +62 -0
- aim/reference_aligners/__init__.py +0 -0
- aim/reference_aligners/environment_dot.yml +23 -0
- aim/reference_aligners/environment_tacco.yml +28 -0
- aim/reference_aligners/environment_tangram.yml +16 -0
- aim/reference_aligners/registry.py +350 -0
- aim/reference_aligners/run_dot.R +172 -0
- aim/reference_aligners/run_dot.py +116 -0
- aim/reference_aligners/run_tacco.py +143 -0
- aim/reference_aligners/run_tangram.py +210 -0
- aim/sweep.py +261 -0
- aim/tree.py +86 -0
- spatial_aim-1.0.0.dist-info/METADATA +1005 -0
- spatial_aim-1.0.0.dist-info/RECORD +76 -0
- spatial_aim-1.0.0.dist-info/WHEEL +5 -0
- spatial_aim-1.0.0.dist-info/entry_points.txt +2 -0
- spatial_aim-1.0.0.dist-info/licenses/LICENSE +674 -0
- 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
|