annotuneagent 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.
Files changed (38) hide show
  1. annotuneagent/__init__.py +68 -0
  2. annotuneagent/__main__.py +6 -0
  3. annotuneagent/_version.py +19 -0
  4. annotuneagent/annotate.py +93 -0
  5. annotuneagent/arbitrate.py +141 -0
  6. annotuneagent/cli.py +366 -0
  7. annotuneagent/decide.py +725 -0
  8. annotuneagent/evaluate.py +121 -0
  9. annotuneagent/evidence.py +606 -0
  10. annotuneagent/granularity/__init__.py +28 -0
  11. annotuneagent/granularity/cl_dag.py +179 -0
  12. annotuneagent/granularity/ontology_map.py +108 -0
  13. annotuneagent/granularity/relations.py +96 -0
  14. annotuneagent/hypotheses.py +38 -0
  15. annotuneagent/local_lora.py +706 -0
  16. annotuneagent/marker_plot.py +172 -0
  17. annotuneagent/markers.py +206 -0
  18. annotuneagent/markers.tsv +378 -0
  19. annotuneagent/mltools.py +128 -0
  20. annotuneagent/ontology/cl_isa.json +1 -0
  21. annotuneagent/ontology/granularity_policy.yaml +8 -0
  22. annotuneagent/ontology/ontology_map.jsonl +198 -0
  23. annotuneagent/prepare.py +1385 -0
  24. annotuneagent/py.typed +0 -0
  25. annotuneagent/read.py +76 -0
  26. annotuneagent/tokenizer/LICENSE +202 -0
  27. annotuneagent/tokenizer/merges.txt +247587 -0
  28. annotuneagent/tokenizer/tokenizer.json +495920 -0
  29. annotuneagent/tokenizer/tokenizer_config.json +305 -0
  30. annotuneagent/tokenizer/vocab.json +248046 -0
  31. annotuneagent/votes.py +83 -0
  32. annotuneagent/workflow.py +2037 -0
  33. annotuneagent-0.1.0.dist-info/METADATA +208 -0
  34. annotuneagent-0.1.0.dist-info/RECORD +38 -0
  35. annotuneagent-0.1.0.dist-info/WHEEL +5 -0
  36. annotuneagent-0.1.0.dist-info/entry_points.txt +2 -0
  37. annotuneagent-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. annotuneagent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1385 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Count matrix to a Leiden clustering for annotation.
5
+
6
+ AmbiDose ambient subtraction is off unless ``denoise`` is set, and then it
7
+ needs the unfiltered droplet matrix. Harmony, Scanorama, scVI, scFair, and
8
+ AmbiDose are optional: a missing package prints an install line and prepare
9
+ returns the same matrix. Highly variable genes are Scanpy
10
+ ``seurat_v3`` and the Leiden resolution is 0.4. ``scfair=True`` uses scFair
11
+ for both. Batch correction defaults to none. Harmony, ComBat, Scanorama and
12
+ scVI change the neighbor representation. ``X`` stays log1p.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ import time
21
+ import warnings
22
+ from collections.abc import Iterator
23
+ from contextlib import contextmanager
24
+ from importlib.util import find_spec
25
+ from pathlib import Path
26
+
27
+ import anndata as ad
28
+ import numpy as np
29
+ import scanpy as sc
30
+ from anndata import AnnData
31
+ from scipy import sparse, stats
32
+ from scipy.special import expit, logit
33
+
34
+ from .evidence import require_unique_var_names
35
+
36
+ _SPECIES = ("human", "mouse")
37
+ _MT = {"human": ("MT-",), "mouse": ("mt-", "Mt-")}
38
+ _RESOLUTION_SCALE = 0.5
39
+ _DEFAULT_RESOLUTION = 0.4
40
+ _DEFAULT_N_TOP_GENES = 2000
41
+ _MAD_SCALE = 5.0
42
+ _MT_NMADS = 3.0
43
+ _MT_MIN_PCT = 8.0
44
+ _TOP_GENES = 20
45
+ _PCA_CAP = 50
46
+ _RIBO = {"human": ("RPS", "RPL"), "mouse": ("Rps", "Rpl", "RPS", "RPL")}
47
+ _HB = {
48
+ "human": r"^HB[ABDEGMQZ]",
49
+ "mouse": r"^(?:HB|Hb)[ABDEGMQZabdegmqz]",
50
+ }
51
+ _BATCH_CORRECTION = ("none", "harmony", "combat", "scanorama", "scvi", "scvi+harmony")
52
+ _NORMS = ("log1p", "shifted")
53
+ _DOUBLET_METHODS = ("none", "scrublet", "solo")
54
+ _DOUBLET_CHIPS = ("10k", "20k")
55
+ # Selected only. None of these are installed with the package.
56
+ _OPTIONAL = {
57
+ "harmonypy": "pip install harmonypy",
58
+ "scanorama": "pip install scanorama",
59
+ "scvi": "pip install 'annotuneagent[scvi]'",
60
+ "scfair": "pip install 'annotuneagent[scfair]'",
61
+ "ambidose": "pip install ambidose",
62
+ }
63
+ # 10x CG000422, Next GEM Single Cell 3' v3.1. Standard chip: 0.8% multiplets per
64
+ # 1,000 recovered cells. HT chip: 0.4% per 1,000. Either chip is about 8% at its
65
+ # rated load (10,000 or 20,000 recovered cells).
66
+ _MULTIPLETS_PER_CELL = {"10k": 0.008 / 1000, "20k": 0.004 / 1000}
67
+ RAW_REQUIRED = "No unfiltered droplet matrix. Pass raw, or leave AmbiDose off."
68
+
69
+
70
+ @contextmanager
71
+ def _timed(name: str) -> Iterator[dict[str, str]]:
72
+ """Print a step when it starts and its elapsed seconds when it finishes."""
73
+ print(f"{name} ...", flush=True)
74
+ started = time.perf_counter()
75
+ note: dict[str, str] = {}
76
+ try:
77
+ yield note
78
+ finally:
79
+ detail = note.get("detail", "")
80
+ suffix = f" {detail}" if detail else ""
81
+ print(f"{name} {time.perf_counter() - started:.1f}s{suffix}", flush=True)
82
+
83
+
84
+ def prepare(
85
+ adata: AnnData,
86
+ *,
87
+ species: str,
88
+ batch_key: str | None = None,
89
+ batch_correction: str = "none",
90
+ doublet_method: str = "none",
91
+ doublet_chip: str = "10k",
92
+ solo_expected_rates: dict[str, float] | None = None,
93
+ scvi_model_dir: str | Path | None = None,
94
+ denoise: bool = False,
95
+ raw: str | Path | AnnData | dict[str, str | Path | AnnData] | None = None,
96
+ resolution: float | None = None,
97
+ n_pcs: int | None = None,
98
+ n_top_genes: int | None = None,
99
+ scfair: bool = False,
100
+ max_pct_mt: float | None = None,
101
+ cluster_key: str = "leiden",
102
+ n_neighbors: int = 15,
103
+ nmads: float = _MAD_SCALE,
104
+ norm: str = "log1p",
105
+ show: bool = False,
106
+ ) -> AnnData:
107
+ """Filter, select genes, and cluster. The result is natural log1p in ``X``.
108
+
109
+ ``norm="log1p"`` scales each cell to 10,000 counts and applies ``log1p``.
110
+ ``norm="shifted"`` is the shifted logarithm: scale each cell to the median
111
+ library size, then ``log1p``. Counts stay in ``layers["counts"]``.
112
+
113
+ Highly variable genes are Scanpy ``seurat_v3`` and the Leiden resolution is
114
+ 0.4. ``scfair=True`` uses scFair for both. ``batch_correction`` is ``none``,
115
+ ``harmony``, ``combat``, ``scanorama``, ``scvi``, or ``scvi+harmony``.
116
+ ``scvi+harmony`` trains scVI, then runs Harmony on that latent space.
117
+ Correction changes the neighbor graph only, and ``X`` stays log1p.
118
+ ``max_pct_mt`` is a percent, so 10 keeps
119
+ cells with at most 10% mitochondrial counts. QC thresholds, Scrublet, and
120
+ SOLO are fit separately inside each sample. ``batch_key`` names that column.
121
+ When it is omitted and ``obs['sample']`` exists, that column is used.
122
+ ``doublet_method`` selects no removal, Scrublet, or SOLO. Both use the
123
+ filtered matrix already in ``adata``: unnormalized UMI counts of called
124
+ cells, not ``raw_feature_bc_matrix``. ``doublet_chip`` is ``10k`` (0.8%
125
+ multiplets per 1,000 cells in that sample) or ``20k`` (0.4% per 1,000).
126
+ That rate is Scrublet's prior. SOLO uses it too, unless
127
+ ``solo_expected_rates`` gives one fraction per sample. Denoising fits
128
+ one raw matrix per sample. With several samples, ``raw`` is the parent
129
+ folder (``base/<sample>/raw_feature_bc_matrix.h5``, or the mtx directory
130
+ when no h5 is present), a dict keyed by the sample name in ``batch_key``,
131
+ or one AnnData that has that same column. One sample may be a single h5,
132
+ mtx directory, or AnnData.
133
+ Each step prints when it starts and how many
134
+ seconds it took. ``show=True`` then prints the QC rules and draws the QC
135
+ figures, the variance elbow, and the UMAP.
136
+ """
137
+ if species not in _SPECIES:
138
+ raise ValueError(f"species must be human or mouse, got {species!r}")
139
+ require_unique_var_names(adata)
140
+ if max_pct_mt is not None and not 0 < max_pct_mt <= 100:
141
+ raise ValueError(f"max_pct_mt is a percent from 0 to 100, got {max_pct_mt!r}")
142
+ if max_pct_mt is not None and max_pct_mt < 1:
143
+ warnings.warn(
144
+ f"max_pct_mt={max_pct_mt} is {max_pct_mt}%, not a fraction; "
145
+ f"{max_pct_mt * 100:g} means {max_pct_mt * 100:g}%",
146
+ UserWarning,
147
+ stacklevel=2,
148
+ )
149
+ correction = batch_correction.strip().casefold()
150
+ if correction not in _BATCH_CORRECTION:
151
+ allowed = ", ".join(_BATCH_CORRECTION)
152
+ raise ValueError(f"batch_correction must be one of {allowed}, got {batch_correction!r}")
153
+ if doublet_method not in _DOUBLET_METHODS:
154
+ raise ValueError(f"doublet_method must be one of {', '.join(_DOUBLET_METHODS)}")
155
+ chip = doublet_chip.strip().casefold()
156
+ if chip not in _DOUBLET_CHIPS:
157
+ allowed = ", ".join(_DOUBLET_CHIPS)
158
+ raise ValueError(f"doublet_chip must be one of {allowed}, got {doublet_chip!r}")
159
+ transform = norm.strip().casefold()
160
+ if transform not in _NORMS:
161
+ raise ValueError(f"norm must be one of {', '.join(_NORMS)}, got {norm!r}")
162
+ sample_key = _resolve_sample_key(adata, batch_key)
163
+ if scvi_model_dir is not None and correction not in ("scvi", "scvi+harmony"):
164
+ raise ValueError("scvi_model_dir requires batch_correction='scvi' or 'scvi+harmony'")
165
+ if solo_expected_rates is not None and doublet_method != "solo":
166
+ raise ValueError("solo_expected_rates requires doublet_method='solo'")
167
+ if solo_expected_rates is not None:
168
+ samples = {"all"} if sample_key is None else set(adata.obs[sample_key].astype(str))
169
+ if set(solo_expected_rates) != samples:
170
+ raise ValueError(
171
+ "solo_expected_rates must specify every sample exactly once; "
172
+ f"missing={sorted(samples - set(solo_expected_rates))}, "
173
+ f"unknown={sorted(set(solo_expected_rates) - samples)}"
174
+ )
175
+ if any(
176
+ not isinstance(rate, (int, float)) or not np.isfinite(rate) or not 0 <= rate < 1
177
+ for rate in solo_expected_rates.values()
178
+ ):
179
+ raise ValueError("SOLO expected rates must be finite fractions in [0, 1)")
180
+ if (
181
+ correction in ("scvi", "scvi+harmony") or doublet_method == "solo"
182
+ ) and not adata.obs_names.is_unique:
183
+ raise ValueError("scVI/SOLO requires unique cell names in adata.obs_names")
184
+ if denoise and raw is None:
185
+ raise ValueError(RAW_REQUIRED)
186
+ if correction in ("harmony", "combat", "scanorama", "scvi+harmony"):
187
+ _require_batches(adata, batch_key)
188
+ if install_hint(
189
+ batch_correction=batch_correction,
190
+ doublet_method=doublet_method,
191
+ scfair=scfair,
192
+ denoise=denoise,
193
+ ):
194
+ return adata
195
+ n_in = int(adata.n_obs)
196
+ n_genes_in = int(adata.n_vars)
197
+ started = time.perf_counter()
198
+ if denoise:
199
+ n_samples = 1 if sample_key is None else int(adata.obs[sample_key].nunique())
200
+ with _timed("prepare denoise") as note:
201
+ adata = _denoise(adata, raw=raw, sample_key=sample_key)
202
+ grouped = f"{n_samples} samples, " if n_samples > 1 else ""
203
+ note["detail"] = f"{grouped}{adata.n_obs} cells"
204
+ elif not _integer_counts(adata.X):
205
+ raise ValueError("prepare expects raw integer counts in adata.X")
206
+ if (correction in ("scvi", "scvi+harmony") or doublet_method == "solo") and not _integer_counts(
207
+ adata.X
208
+ ):
209
+ raise ValueError("scVI and SOLO require integer raw counts after denoising")
210
+ n_after_denoise = int(adata.n_obs)
211
+ n_genes_after_denoise = int(adata.n_vars)
212
+ with _timed("prepare drop genes with no counts") as note:
213
+ n_unobserved = _drop_unobserved_genes(adata)
214
+ note["detail"] = f"{n_unobserved} genes"
215
+ with _timed("prepare QC") as note:
216
+ qc = _filter_qc(adata, species, sample_key, nmads, max_pct_mt)
217
+ grouped = f", per {sample_key}" if sample_key else ""
218
+ note["detail"] = f"{qc['n_removed']} cells removed, {adata.n_obs} remain{grouped}"
219
+ n_qc = int(qc["n_removed"])
220
+ if doublet_method != "none":
221
+ with _timed("prepare doublets") as note:
222
+ doublets = (
223
+ _drop_solo_doublets(adata, sample_key, solo_expected_rates, chip)
224
+ if doublet_method == "solo"
225
+ else _drop_doublets(adata, sample_key, chip)
226
+ )
227
+ note["detail"] = (
228
+ f"{doublets['n_removed']} removed in {len(doublets['batches'])} samples"
229
+ )
230
+ else:
231
+ doublets = {"enabled": False, "method": None, "n_removed": 0, "batches": []}
232
+ n_doublet = int(doublets["n_removed"])
233
+ if adata.n_obs < 3 or adata.n_vars < 2:
234
+ raise ValueError("too few cells or genes remain after filtering")
235
+ with _timed("prepare highly variable genes") as note:
236
+ n_genes = _select_genes(adata, n_top_genes, sample_key, scfair=scfair)
237
+ note["detail"] = f"{n_genes} genes"
238
+ with _timed(f"prepare normalize {transform}") as note:
239
+ adata.layers["counts"] = adata.X.copy()
240
+ _normalize(adata, transform)
241
+ note["detail"] = transform
242
+ latent_dims = None
243
+ if correction in ("scvi", "scvi+harmony"):
244
+ if correction == "scvi+harmony":
245
+ _require_batches(adata, batch_key)
246
+ with _timed("prepare scVI") as note:
247
+ latent_dims = 30 if n_pcs is None else int(n_pcs)
248
+ if latent_dims < 1:
249
+ raise ValueError("n_pcs must be at least 1 for scVI latent dimensions")
250
+ _fit_scvi(adata, batch_key, latent_dims, scvi_model_dir)
251
+ note["detail"] = f"n_latent {latent_dims}"
252
+ chosen_pcs = 0
253
+ use_rep = "X_scVI"
254
+ if correction == "scvi+harmony":
255
+ with _timed("prepare harmony") as note:
256
+ adata.obsm["X_scVI_harmony"] = _run_harmony(
257
+ adata.obsm["X_scVI"], adata.obs, batch_key
258
+ )
259
+ use_rep = "X_scVI_harmony"
260
+ note["detail"] = use_rep
261
+ else:
262
+ with _timed("prepare PCA") as note:
263
+ chosen_pcs = _fit_pcs(adata, n_pcs)
264
+ note["detail"] = f"n_pcs {chosen_pcs}"
265
+ if correction not in ("none", "scvi", "scvi+harmony"):
266
+ with _timed(f"prepare {correction}") as note:
267
+ use_rep = _integrate(adata, correction, batch_key, chosen_pcs)
268
+ note["detail"] = use_rep
269
+ elif correction == "none":
270
+ use_rep = _integrate(adata, correction, batch_key, chosen_pcs)
271
+ neighbors = min(n_neighbors, int(adata.n_obs) - 1)
272
+ n_graph = (
273
+ int(adata.obsm[use_rep].shape[1])
274
+ if correction in ("scvi", "scvi+harmony")
275
+ else min(chosen_pcs, int(adata.obsm[use_rep].shape[1]))
276
+ )
277
+ with _timed("prepare neighbors"):
278
+ sc.pp.neighbors(
279
+ adata,
280
+ n_neighbors=neighbors,
281
+ n_pcs=n_graph,
282
+ use_rep=None if use_rep == "X_pca" else use_rep,
283
+ random_state=0,
284
+ )
285
+ used_resolution, resolution_source = annotation_resolution(adata, resolution, scfair=scfair)
286
+ with _timed("prepare Leiden") as note:
287
+ sc.tl.leiden(
288
+ adata,
289
+ resolution=used_resolution,
290
+ key_added=cluster_key,
291
+ flavor="igraph",
292
+ n_iterations=2,
293
+ directed=False,
294
+ random_state=0,
295
+ )
296
+ note["detail"] = (
297
+ f"resolution {used_resolution:g} ({resolution_source}), "
298
+ f"{adata.obs[cluster_key].nunique()} clusters"
299
+ )
300
+ with _timed("prepare UMAP"):
301
+ sc.tl.umap(adata, random_state=0)
302
+ print(
303
+ f"prepare done {time.perf_counter() - started:.1f}s "
304
+ f"{adata.n_obs} cells, {adata.obs[cluster_key].nunique()} clusters",
305
+ flush=True,
306
+ )
307
+ adata.uns["annotune_prep"] = {
308
+ "species": species,
309
+ "batch_key": batch_key,
310
+ "sample_key": sample_key,
311
+ "batch_correction": correction,
312
+ "doublet_method": doublet_method,
313
+ "denoise": bool(denoise),
314
+ "raw": None if raw is None or isinstance(raw, AnnData) else str(raw),
315
+ "norm": transform,
316
+ "scfair": bool(scfair),
317
+ "n_top_genes": int(n_genes),
318
+ "n_pcs": int(chosen_pcs),
319
+ "n_latent": None if latent_dims is None else int(latent_dims),
320
+ "scvi_model_dir": None if scvi_model_dir is None else str(scvi_model_dir),
321
+ "n_neighbors": int(neighbors),
322
+ "resolution": float(used_resolution),
323
+ "resolution_source": resolution_source,
324
+ "cluster_key": cluster_key,
325
+ "nmads": float(nmads),
326
+ "max_pct_mt": None if max_pct_mt is None else float(max_pct_mt),
327
+ "n_cells_in": n_in,
328
+ "n_cells_denoise": n_in - n_after_denoise,
329
+ "n_cells_after_denoise": n_after_denoise,
330
+ "n_cells_qc": n_qc,
331
+ "n_cells_doublet": int(n_doublet),
332
+ "n_cells_out": int(adata.n_obs),
333
+ "n_genes_in": n_genes_in,
334
+ "n_genes_denoise": n_genes_in - n_genes_after_denoise,
335
+ "n_genes_unobserved": n_unobserved,
336
+ "n_genes_out": int(adata.n_vars),
337
+ "qc": qc,
338
+ "doublets": doublets,
339
+ }
340
+ if show:
341
+ _show_prepared(adata, cluster_key)
342
+ return adata
343
+
344
+
345
+ def _show_prepared(adata: AnnData, cluster_key: str) -> None:
346
+ import matplotlib.pyplot as plt
347
+
348
+ print(_format_filter_report(adata.uns["annotune_prep"]))
349
+ sc.pl.highest_expr_genes(adata, n_top=20, layer="counts", show=True)
350
+ _qc_violin(adata)
351
+ sc.pl.scatter(adata, x="total_counts", y="n_genes_by_counts", show=True)
352
+ sc.pl.scatter(adata, x="total_counts", y="pct_counts_mt", show=True)
353
+ sc.pl.highly_variable_genes(adata, show=True)
354
+ n_pcs = int(adata.uns["annotune_prep"]["n_pcs"])
355
+ if n_pcs:
356
+ ratio = adata.uns["pca"]["variance_ratio"]
357
+ fig, ax = plt.subplots(figsize=(4.2, 3.2))
358
+ ax.plot(range(1, len(ratio) + 1), ratio, color="0.2")
359
+ ax.axvline(n_pcs, color="C0", ls="--", label=f"n_pcs = {n_pcs}")
360
+ ax.set_xlabel("principal component")
361
+ ax.set_ylabel("variance ratio")
362
+ ax.legend(frameon=False)
363
+ fig.tight_layout()
364
+ plt.show()
365
+ print(adata.obs[cluster_key].value_counts().sort_index())
366
+ sc.pl.umap(adata, color=cluster_key, legend_loc="on data", title=cluster_key, show=True)
367
+
368
+
369
+ def _qc_violin(adata: AnnData) -> None:
370
+ """Gene count, UMI count, and mitochondrial percent, split by sample when present."""
371
+ import matplotlib.pyplot as plt
372
+
373
+ keys = ["n_genes_by_counts", "total_counts", "pct_counts_mt"]
374
+ sample_key = adata.uns["annotune_prep"]["qc"].get("sample_key")
375
+ if not sample_key or sample_key not in adata.obs.columns:
376
+ sc.pl.violin(adata, keys, jitter=0.4, multi_panel=True, show=True)
377
+ return
378
+ axes = sc.pl.violin(
379
+ adata,
380
+ keys,
381
+ groupby=sample_key,
382
+ jitter=0.4,
383
+ multi_panel=True,
384
+ rotation=45,
385
+ show=False,
386
+ )
387
+ if axes is None:
388
+ return
389
+ plt.tight_layout()
390
+ plt.show()
391
+
392
+
393
+ def annotation_resolution(
394
+ adata: AnnData,
395
+ resolution: float | None,
396
+ *,
397
+ scfair: bool = False,
398
+ ) -> tuple[float, str]:
399
+ """User resolution is kept. The default is 0.4 unless ``scfair`` is set."""
400
+ if resolution is not None:
401
+ return float(resolution), "user"
402
+ if not scfair:
403
+ return _DEFAULT_RESOLUTION, "default"
404
+ from scfair.pp._diagnosis import resolve_cluster_resolution
405
+
406
+ suggested = float(resolve_cluster_resolution(adata)["resolution"])
407
+ return suggested * _RESOLUTION_SCALE, "scfair/2"
408
+
409
+
410
+ def _chord_gap(values: np.ndarray, minimum: int) -> tuple[int, float]:
411
+ """1-based index of the largest vertical gap under the endpoint chord."""
412
+ n = int(values.size)
413
+ if n <= minimum:
414
+ return n, 0.0
415
+ x = np.linspace(0.0, 1.0, n)
416
+ chord = values[0] + (values[-1] - values[0]) * x
417
+ start = max(minimum - 1, 1)
418
+ gap = chord - values
419
+ index = start + int(np.argmax(gap[start:]))
420
+ return index + 1, float(gap[index])
421
+
422
+
423
+ def elbow_count(scores: np.ndarray, *, minimum: int = 2) -> int:
424
+ """How many leading scores to keep.
425
+
426
+ The largest gap under the chord is the steep drop. Components after that
427
+ drop can still fall. When that remainder sits well below its own chord,
428
+ keep up to the bend of the remainder. A straight remainder keeps the
429
+ first drop.
430
+ """
431
+ values = np.asarray(scores, dtype=float)
432
+ n = int(values.size)
433
+ if n <= minimum:
434
+ return n
435
+ first, _gap = _chord_gap(values, minimum)
436
+ if first >= n - 1:
437
+ return first
438
+ tail = values[first - 1 :]
439
+ local, second_gap = _chord_gap(tail, 2)
440
+ span = float(tail[0] - tail[-1])
441
+ if span <= 0 or second_gap / span < 0.2:
442
+ return first
443
+ return first - 1 + local
444
+
445
+
446
+ def _require_batches(adata: AnnData, batch_key: str | None) -> None:
447
+ if batch_key is None or batch_key not in adata.obs.columns:
448
+ raise ValueError("batch correction needs batch_key")
449
+ if adata.obs[batch_key].nunique(dropna=False) < 2:
450
+ raise ValueError("batch correction needs at least two batches")
451
+
452
+
453
+ def _integrate(adata: AnnData, method: str, batch_key: str | None, n_pcs: int) -> str:
454
+ """Representation for the neighbor graph. Expression in ``X`` is unchanged."""
455
+ if method == "none":
456
+ return "X_pca"
457
+ _require_batches(adata, batch_key)
458
+ if method == "harmony":
459
+ _harmony(adata, batch_key, n_pcs)
460
+ return "X_pca_harmony"
461
+ if method == "scanorama":
462
+ _scanorama(adata, batch_key, n_pcs)
463
+ return "X_scanorama"
464
+ _combat_pca(adata, batch_key, n_pcs)
465
+ return "X_pca_combat"
466
+
467
+
468
+ def _harmony(adata: AnnData, batch_key: str, n_pcs: int) -> None:
469
+ """Correct the selected principal components. ``obsm`` stays cells by components."""
470
+ pcs = np.asarray(adata.obsm["X_pca"], dtype=np.float64)[:, :n_pcs]
471
+ if pcs.shape != (adata.n_obs, n_pcs):
472
+ raise ValueError(f"X_pca shape {pcs.shape} is not {(adata.n_obs, n_pcs)}")
473
+ adata.obsm["X_pca_harmony"] = _run_harmony(pcs, adata.obs, batch_key)
474
+
475
+
476
+ def _run_harmony(matrix, obs, batch_key: str) -> np.ndarray:
477
+ """Return Harmony output as cells by features.
478
+
479
+ harmonypy 0.2 returns ``Z_corr`` as cells by components. The matrix is
480
+ transposed only when it comes back as components by cells.
481
+ """
482
+ import harmonypy
483
+
484
+ values = np.asarray(matrix, dtype=np.float64)
485
+ if values.ndim != 2:
486
+ raise ValueError(f"Harmony input shape {values.shape} is not a matrix")
487
+ if not np.isfinite(values).all():
488
+ raise ValueError("Harmony input contains NaN or Inf")
489
+ n_cells, n_features = values.shape
490
+ corrected = _cells_by_features(
491
+ harmonypy.run_harmony(
492
+ values,
493
+ obs,
494
+ batch_key,
495
+ max_iter_harmony=20,
496
+ random_state=0,
497
+ ).Z_corr,
498
+ n_cells,
499
+ n_features,
500
+ )
501
+ if not np.isfinite(corrected).all():
502
+ raise ValueError("Harmony corrected matrix contains NaN or Inf")
503
+ return np.ascontiguousarray(corrected, dtype=np.float32)
504
+
505
+
506
+ def _cells_by_features(matrix, n_cells: int, n_features: int) -> np.ndarray:
507
+ if hasattr(matrix, "detach"):
508
+ matrix = matrix.detach().cpu().numpy()
509
+ values = np.asarray(matrix)
510
+ if values.shape == (n_cells, n_features):
511
+ return values
512
+ if values.shape == (n_features, n_cells):
513
+ return values.T
514
+ raise ValueError(
515
+ f"Harmony output shape {values.shape} is neither "
516
+ f"{(n_cells, n_features)} nor {(n_features, n_cells)}"
517
+ )
518
+
519
+
520
+ def _scanorama(adata: AnnData, batch_key: str, n_pcs: int) -> None:
521
+ labels = adata.obs[batch_key].astype(str).to_numpy()
522
+ order = np.argsort(labels, kind="mergesort")
523
+ obs = adata.obs.iloc[order][[batch_key]].copy()
524
+ small = AnnData(X=np.zeros((order.size, 1), dtype=np.float32), obs=obs)
525
+ small.obsm["X_pca"] = np.asarray(adata.obsm["X_pca"])[order, :n_pcs]
526
+ sc.external.pp.scanorama_integrate(small, batch_key)
527
+ back = np.empty_like(small.obsm["X_scanorama"])
528
+ back[order] = np.asarray(small.obsm["X_scanorama"])
529
+ adata.obsm["X_scanorama"] = back
530
+
531
+
532
+ def _combat_pca(adata: AnnData, batch_key: str, n_pcs: int) -> None:
533
+ keep = adata.var["highly_variable"].to_numpy()
534
+ if int(keep.sum()) <= n_pcs:
535
+ raise ValueError("not enough highly variable genes for ComBat")
536
+ sub = adata[:, keep].copy()
537
+ sc.pp.combat(sub, key=batch_key)
538
+ sc.pp.pca(sub, n_comps=n_pcs, svd_solver="arpack", random_state=0)
539
+ adata.obsm["X_pca_combat"] = np.asarray(sub.obsm["X_pca"])
540
+
541
+
542
+ def _denoise(
543
+ adata: AnnData,
544
+ *,
545
+ raw: str | Path | AnnData | dict[str, str | Path | AnnData],
546
+ sample_key: str | None,
547
+ ) -> AnnData:
548
+ """Estimate and subtract ambient RNA inside one sample at a time."""
549
+ labels = _sample_labels(adata, sample_key)
550
+ names = list(dict.fromkeys(labels.tolist()))
551
+ pieces = []
552
+ for name in names:
553
+ idx = np.flatnonzero(labels == name)
554
+ sub = adata[idx].copy()
555
+ if sample_key is not None and sub.obs[sample_key].astype(str).nunique() != 1:
556
+ raise RuntimeError(f"denoise subset {name} contains more than one sample")
557
+ raw_one = _raw_for_sample(raw, str(name), n_samples=len(names), sample_key=sample_key)
558
+ if len(names) > 1:
559
+ print(f"prepare denoise {name} {sub.n_obs} cells {raw_one}", flush=True)
560
+ pieces.append(_denoise_one(sub, raw_one, sample=None if sample_key is None else str(name)))
561
+ if len(pieces) == 1:
562
+ return pieces[0]
563
+ merged = ad.concat(pieces, join="outer", merge="same")
564
+ keep = [cell for cell in adata.obs_names if cell in merged.obs_names]
565
+ merged = merged[keep].copy()
566
+ if sample_key is not None:
567
+ merged.obs[sample_key] = merged.obs[sample_key].astype("category")
568
+ return merged
569
+
570
+
571
+ def _denoise_one(adata: AnnData, raw: str | Path | AnnData, *, sample: str | None) -> AnnData:
572
+ import ambidose
573
+
574
+ names = [str(cell) for cell in adata.obs_names]
575
+ stripped, restore = _strip_sample_suffix(names, sample)
576
+ if restore:
577
+ adata.obs_names = stripped
578
+ denoised = ambidose.denoise(adata, raw=raw, sample_key=None)
579
+ out = ambidose.analysis_ready(denoised)
580
+ if restore:
581
+ out.obs_names = [f"{cell}-{sample}" for cell in out.obs_names.astype(str)]
582
+ return out
583
+
584
+
585
+ def _strip_sample_suffix(names: list[str], sample: str | None) -> tuple[list[str], bool]:
586
+ """Drop the ``read_10x`` sample suffix so barcodes match the raw matrix."""
587
+ if sample is None:
588
+ return names, False
589
+ suffix = f"-{sample}"
590
+ if not all(name.endswith(suffix) for name in names):
591
+ return names, False
592
+ if sample.isdigit() and all(name.count("-") == 1 for name in names):
593
+ return names, False
594
+ stripped = [name[: -len(suffix)] for name in names]
595
+ if any(name == "" for name in stripped) or len(set(stripped)) != len(stripped):
596
+ return names, False
597
+ return stripped, True
598
+
599
+
600
+ def _raw_for_sample(
601
+ raw: str | Path | AnnData | dict[str, str | Path | AnnData],
602
+ sample: str,
603
+ *,
604
+ n_samples: int,
605
+ sample_key: str | None,
606
+ ) -> str | Path | AnnData:
607
+ if isinstance(raw, dict):
608
+ try:
609
+ return raw[sample]
610
+ except KeyError:
611
+ raise KeyError(f"raw has no matrix for sample {sample!r}") from None
612
+ if isinstance(raw, AnnData):
613
+ if sample_key is not None and sample_key in raw.obs.columns:
614
+ chosen = raw[raw.obs[sample_key].astype(str).to_numpy() == sample].copy()
615
+ if chosen.n_obs == 0:
616
+ raise ValueError(f"raw has no droplets for sample {sample!r}")
617
+ return chosen
618
+ if n_samples == 1:
619
+ return raw
620
+ raise ValueError("raw AnnData has no sample column; pass each sample its own raw matrix")
621
+ path = Path(raw)
622
+ if n_samples == 1 and (path.is_file() or _contains_raw(path)):
623
+ return path
624
+ folder = path / sample
625
+ if folder.is_dir():
626
+ return _find_count_matrix(folder, "raw_feature_bc_matrix.h5", "raw_feature_bc_matrix")
627
+ raise FileNotFoundError(f"no raw matrix for sample {sample!r} under {path}")
628
+
629
+
630
+ def _contains_raw(path: Path) -> bool:
631
+ if not path.is_dir():
632
+ return False
633
+ try:
634
+ _find_count_matrix(path, "raw_feature_bc_matrix.h5", "raw_feature_bc_matrix")
635
+ except FileNotFoundError:
636
+ return False
637
+ return True
638
+
639
+
640
+ def _find_count_matrix(folder: Path, h5_name: str, mtx_name: str) -> Path:
641
+ found: list[Path] = []
642
+ for current, dirnames, filenames in os.walk(folder):
643
+ dirnames[:] = [name for name in dirnames if not name.startswith(".")]
644
+ here = Path(current)
645
+ if h5_name in filenames:
646
+ found.append(here / h5_name)
647
+ mtx = here / mtx_name
648
+ if mtx_name in dirnames and any(
649
+ (mtx / name).is_file() for name in ("matrix.mtx", "matrix.mtx.gz")
650
+ ):
651
+ found.append(mtx)
652
+ if not found:
653
+ raise FileNotFoundError(f"{folder} has no {h5_name} or {mtx_name}")
654
+ return min(found, key=lambda path: (len(path.parts), path.suffix != ".h5", str(path)))
655
+
656
+
657
+ def _normalize(adata: AnnData, transform: str) -> None:
658
+ """Library-size normalization followed by a natural log1p.
659
+
660
+ ``shifted`` follows the sc-best-practices shifted logarithm: the target
661
+ is the median library size. ``log1p`` keeps the fixed target of 10,000.
662
+ """
663
+ if not sparse.issparse(adata.X):
664
+ adata.X = sparse.csr_matrix(adata.X)
665
+ else:
666
+ adata.X = adata.X.tocsr()
667
+ if transform == "shifted":
668
+ totals = np.asarray(adata.X.sum(axis=1)).ravel()
669
+ if totals.size == 0 or not np.isfinite(totals).all() or float(np.median(totals)) <= 0:
670
+ raise ValueError("shifted logarithm needs a positive median library size")
671
+ sc.pp.normalize_total(adata, target_sum=None)
672
+ else:
673
+ sc.pp.normalize_total(adata, target_sum=1e4)
674
+ sc.pp.log1p(adata)
675
+
676
+
677
+ def _integer_counts(matrix) -> bool:
678
+ values = matrix.data if sparse.issparse(matrix) else np.asarray(matrix).ravel()
679
+ if values.size == 0:
680
+ return True
681
+ values = np.asarray(values)
682
+ return bool(
683
+ np.isfinite(values).all()
684
+ and (values >= 0).all()
685
+ and np.max(np.abs(values - np.rint(values))) < 1e-6
686
+ )
687
+
688
+
689
+ def _drop_unobserved_genes(adata: AnnData) -> int:
690
+ totals = np.asarray(adata.X.sum(axis=0)).ravel()
691
+ keep = totals > 0
692
+ n_removed = int((~keep).sum())
693
+ if n_removed:
694
+ adata._inplace_subset_var(keep)
695
+ return n_removed
696
+
697
+
698
+ def _resolve_sample_key(adata: AnnData, batch_key: str | None) -> str | None:
699
+ """Column that splits QC and doublet detection into samples."""
700
+ if batch_key is not None:
701
+ if batch_key not in adata.obs.columns:
702
+ raise KeyError(f"batch_key={batch_key!r} not in adata.obs")
703
+ return batch_key
704
+ if "sample" in adata.obs.columns:
705
+ return "sample"
706
+ return None
707
+
708
+
709
+ def _sample_labels(adata: AnnData, sample_key: str | None) -> np.ndarray:
710
+ if sample_key is None:
711
+ return np.full(adata.n_obs, "all", dtype=object)
712
+ return adata.obs[sample_key].astype(str).to_numpy()
713
+
714
+
715
+ def _filter_qc(
716
+ adata: AnnData,
717
+ species: str,
718
+ sample_key: str | None,
719
+ nmads: float,
720
+ max_pct_mt: float | None,
721
+ ) -> dict:
722
+ names = adata.var_names.astype(str)
723
+ adata.var["mt"] = names.str.startswith(_MT[species])
724
+ adata.var["ribo"] = names.str.startswith(_RIBO[species])
725
+ adata.var["hb"] = names.str.contains(_HB[species], regex=True)
726
+ top_n = _TOP_GENES if adata.n_vars >= _TOP_GENES else None
727
+ sc.pp.calculate_qc_metrics(
728
+ adata,
729
+ qc_vars=["mt", "ribo", "hb"],
730
+ percent_top=None if top_n is None else [top_n],
731
+ log1p=True,
732
+ inplace=True,
733
+ )
734
+ total_mt = adata.obs["total_counts_mt"].to_numpy(dtype=float)
735
+ total = adata.obs["total_counts"].to_numpy(dtype=float)
736
+ fraction = np.clip((total_mt + 1.0) / (total + 2.0), 1e-6, 1.0 - 1e-6)
737
+ adata.obs["logit_mt_fraction"] = logit(fraction)
738
+ labels = _sample_labels(adata, sample_key)
739
+ outlier = np.zeros(adata.n_obs, dtype=bool)
740
+ percent = None if max_pct_mt is None else float(max_pct_mt)
741
+ metrics = [
742
+ ("log1p_total_counts", float(nmads), False),
743
+ ("log1p_n_genes_by_counts", float(nmads), False),
744
+ ]
745
+ if top_n is not None:
746
+ metrics.append((f"pct_counts_in_top_{top_n}_genes", float(nmads), False))
747
+ reported: list[dict] = []
748
+ for batch in np.unique(labels):
749
+ mask = labels == batch
750
+ idx = np.flatnonzero(mask)
751
+ rules = []
752
+ batch_outlier = np.zeros(idx.shape[0], dtype=bool)
753
+ for metric, scale, upper in metrics:
754
+ rule, hit = _mad_rule(adata.obs[metric].to_numpy()[mask], scale, upper=upper)
755
+ rule["metric"] = metric
756
+ rule["side"] = "upper" if upper else "both"
757
+ rule["nmads"] = float(scale)
758
+ rules.append(rule)
759
+ batch_outlier |= hit
760
+ logit_values = adata.obs["logit_mt_fraction"].to_numpy()[mask]
761
+ mt_rule, mt_hit = _mad_rule(logit_values, _MT_NMADS, upper=True)
762
+ pct = adata.obs["pct_counts_mt"].to_numpy()[mask]
763
+ above_min = pct > _MT_MIN_PCT
764
+ mt_removed = mt_hit & above_min
765
+ mt_rule["metric"] = "logit_mt_fraction"
766
+ mt_rule["side"] = "upper"
767
+ mt_rule["nmads"] = float(_MT_NMADS)
768
+ mt_rule["min_pct"] = float(_MT_MIN_PCT)
769
+ mt_rule["n_mad"] = int(mt_hit.sum())
770
+ mt_rule["n_above_min_pct"] = int(above_min.sum())
771
+ mt_rule["n_removed"] = int(mt_removed.sum())
772
+ rules.append(mt_rule)
773
+ batch_outlier |= mt_removed
774
+ n_cap = 0 if percent is None else int((pct > percent).sum())
775
+ if percent is not None:
776
+ batch_outlier |= pct > percent
777
+ outlier[idx] |= batch_outlier
778
+ reported.append(
779
+ {
780
+ "batch": str(batch),
781
+ "n_cells": int(mask.sum()),
782
+ "rules": rules,
783
+ "n_max_pct_mt": n_cap,
784
+ "n_removed": int(batch_outlier.sum()),
785
+ }
786
+ )
787
+ n_removed = int(outlier.sum())
788
+ if n_removed:
789
+ adata._inplace_subset_obs(~outlier)
790
+ return {
791
+ "nmads": float(nmads),
792
+ "mt_nmads": float(_MT_NMADS),
793
+ "mt_min_pct": float(_MT_MIN_PCT),
794
+ "sample_key": sample_key,
795
+ "batch_key": sample_key,
796
+ "mt_prefixes": list(_MT[species]),
797
+ "max_pct_mt": None if max_pct_mt is None else float(max_pct_mt),
798
+ "max_pct_mt_percent": None if percent is None else float(percent),
799
+ "batches": reported,
800
+ "n_removed": n_removed,
801
+ }
802
+
803
+
804
+ def _mad_rule(values: np.ndarray, nmads: float, *, upper: bool) -> tuple[dict, np.ndarray]:
805
+ values = np.asarray(values, dtype=float)
806
+ med = float(np.median(values))
807
+ mad = float(stats.median_abs_deviation(values))
808
+ rule = {
809
+ "median": med,
810
+ "mad": mad,
811
+ "low": None,
812
+ "high": None,
813
+ "n_low": 0,
814
+ "n_high": 0,
815
+ "n_removed": 0,
816
+ }
817
+ if not np.isfinite(mad) or mad == 0:
818
+ return rule, np.zeros(values.shape[0], dtype=bool)
819
+ if upper:
820
+ high = med + nmads * mad
821
+ hit = values > high
822
+ rule["high"] = float(high)
823
+ rule["n_high"] = int(hit.sum())
824
+ else:
825
+ low = med - nmads * mad
826
+ high = med + nmads * mad
827
+ below = values < low
828
+ above = values > high
829
+ hit = below | above
830
+ rule["low"] = float(low)
831
+ rule["high"] = float(high)
832
+ rule["n_low"] = int(below.sum())
833
+ rule["n_high"] = int(above.sum())
834
+ rule["n_removed"] = int(hit.sum())
835
+ return rule, hit
836
+
837
+
838
+ def _expected_doublet_rate(n_cells: int, chip: str) -> float:
839
+ """10x multiplet rate for cells recovered on a 10k or 20k chip."""
840
+ rate = _MULTIPLETS_PER_CELL[chip] * int(n_cells)
841
+ if rate >= 1:
842
+ raise ValueError(f"{n_cells} cells on a {chip} chip implies a multiplet rate above 1")
843
+ return float(rate)
844
+
845
+
846
+ def _overall_doublet_rate(obs: np.ndarray, sim: np.ndarray, threshold: float) -> float:
847
+ """Scrublet's estimated doublet rate at one score cutoff.
848
+
849
+ Observed calls divided by the fraction of simulated doublets above the
850
+ cutoff. ``call_doublets`` compares this with ``expected_doublet_rate``.
851
+ """
852
+ detectable = int(np.count_nonzero(sim > threshold))
853
+ if detectable == 0 or len(obs) == 0:
854
+ return float("inf")
855
+ detected = int(np.count_nonzero(obs > threshold)) / len(obs)
856
+ return detected / (detectable / len(sim))
857
+
858
+
859
+ def _align_scrublet_threshold(
860
+ obs: np.ndarray,
861
+ sim: np.ndarray,
862
+ expected: float,
863
+ automatic: float | None,
864
+ ) -> float:
865
+ """Move a cutoff that calls too few doublets toward the 10x rate.
866
+
867
+ A cutoff that already reaches the expected rate stays. The replacement
868
+ minimizes the gap between Scrublet's overall rate and ``expected``.
869
+ """
870
+ obs = np.asarray(obs, dtype=float)
871
+ sim = np.asarray(sim, dtype=float)
872
+ if automatic is not None and np.isfinite(automatic):
873
+ current = _overall_doublet_rate(obs, sim, float(automatic))
874
+ if np.isfinite(current) and current >= 0.75 * expected:
875
+ return float(automatic)
876
+ scores = np.unique(np.concatenate([obs, sim]))
877
+ scores = scores[np.isfinite(scores)]
878
+ best_t = None
879
+ best_key = None
880
+ for value in scores:
881
+ rate = _overall_doublet_rate(obs, sim, float(value))
882
+ if not np.isfinite(rate):
883
+ continue
884
+ key = (abs(rate - expected), -float(value))
885
+ if best_key is None or key < best_key:
886
+ best_key = key
887
+ best_t = float(value)
888
+ if best_t is not None:
889
+ return best_t
890
+ n_call = int(round(expected * len(obs)))
891
+ if n_call <= 0:
892
+ return float(np.nanmax(obs)) + 1.0
893
+ cutoff = np.partition(obs, len(obs) - n_call)[len(obs) - n_call]
894
+ return float(np.nextafter(np.float64(cutoff), -np.inf))
895
+
896
+
897
+ def _scrublet_sim_scores(adata: AnnData) -> np.ndarray | None:
898
+ info = adata.uns.get("scrublet")
899
+ if not isinstance(info, dict):
900
+ return None
901
+ sim = info.get("doublet_scores_sim")
902
+ if sim is None:
903
+ return None
904
+ values = np.asarray(sim, dtype=float).reshape(-1)
905
+ if values.size == 0 or not np.isfinite(values).any():
906
+ return None
907
+ return values
908
+
909
+
910
+ def _drop_doublets(adata: AnnData, sample_key: str | None, chip: str) -> dict:
911
+ """Scrublet fits and calls doublets inside one sample at a time."""
912
+ labels = _sample_labels(adata, sample_key)
913
+ predicted = np.zeros(adata.n_obs, dtype=bool)
914
+ scores = np.full(adata.n_obs, np.nan)
915
+ position = {cell: index for index, cell in enumerate(adata.obs_names)}
916
+ batches = []
917
+ for name in np.unique(labels):
918
+ idx = np.flatnonzero(labels == name)
919
+ sub = adata[idx].copy()
920
+ if sample_key is not None and sub.obs[sample_key].astype(str).nunique() != 1:
921
+ raise RuntimeError(f"Scrublet subset {name} contains more than one sample")
922
+ expected = _expected_doublet_rate(sub.n_obs, chip)
923
+ sc.pp.scrublet(sub, expected_doublet_rate=expected, random_state=0)
924
+ if "predicted_doublet" not in sub.obs.columns:
925
+ raise RuntimeError(f"scrublet did not write predicted_doublet for {name}")
926
+ called = sub.obs["predicted_doublet"].fillna(False).to_numpy().astype(bool)
927
+ score = sub.obs["doublet_score"].to_numpy(dtype=float)
928
+ found = _scrublet_thresholds(sub)
929
+ threshold = found.get(str(name), found.get("all"))
930
+ sim = _scrublet_sim_scores(sub)
931
+ if sim is not None:
932
+ current = (
933
+ 0.0 if threshold is None else _overall_doublet_rate(score, sim, float(threshold))
934
+ )
935
+ if not (np.isfinite(current) and current >= 0.75 * expected):
936
+ threshold = _align_scrublet_threshold(score, sim, expected, threshold)
937
+ called = score > threshold
938
+ for cell, flag, value in zip(sub.obs_names, called, score, strict=True):
939
+ at = position[cell]
940
+ predicted[at] = bool(flag)
941
+ scores[at] = float(value)
942
+ batches.append(
943
+ {
944
+ "batch": str(name),
945
+ "n_cells": int(idx.size),
946
+ "n_removed": int(predicted[idx].sum()),
947
+ "threshold": None if threshold is None else float(threshold),
948
+ "expected_rate": expected,
949
+ }
950
+ )
951
+ adata.obs["doublet_score"] = scores
952
+ adata.obs["predicted_doublet"] = predicted
953
+ n_removed = int(predicted.sum())
954
+ if n_removed:
955
+ adata._inplace_subset_obs(~predicted)
956
+ return {
957
+ "enabled": True,
958
+ "method": "scrublet",
959
+ "chip": chip,
960
+ "n_removed": n_removed,
961
+ "batches": batches,
962
+ }
963
+
964
+
965
+ def _is_missing(name: str) -> bool:
966
+ """True when ``name`` cannot be imported.
967
+
968
+ A stand-in already stored in ``sys.modules`` has no ``__spec__``.
969
+ ``find_spec`` raises on that object, so it counts as present.
970
+ """
971
+ loaded = sys.modules.get(name)
972
+ if loaded is not None and getattr(loaded, "__spec__", None) is None:
973
+ return False
974
+ return find_spec(name) is None
975
+
976
+
977
+ def install_hint(
978
+ *,
979
+ batch_correction: str = "none",
980
+ doublet_method: str = "none",
981
+ scfair: bool = False,
982
+ denoise: bool = False,
983
+ ) -> bool:
984
+ """Print an install line for each selected package that is missing.
985
+
986
+ Returns True when at least one is missing. The caller leaves the matrix
987
+ unchanged.
988
+ """
989
+ correction = batch_correction.strip().casefold()
990
+ needed: list[str] = []
991
+ if correction in ("harmony", "scvi+harmony"):
992
+ needed.append("harmonypy")
993
+ if correction == "scanorama":
994
+ needed.append("scanorama")
995
+ if correction in ("scvi", "scvi+harmony") or doublet_method == "solo":
996
+ needed.append("scvi")
997
+ if scfair:
998
+ needed.append("scfair")
999
+ if denoise:
1000
+ needed.append("ambidose")
1001
+ missing = [name for name in needed if _is_missing(name)]
1002
+ for name in missing:
1003
+ print(f"{name} is not installed. Install it with: {_OPTIONAL[name]}", flush=True)
1004
+ return bool(missing)
1005
+
1006
+
1007
+ def _scvi_module():
1008
+ import scvi
1009
+
1010
+ return scvi
1011
+
1012
+
1013
+ def _drop_solo_doublets(
1014
+ adata: AnnData,
1015
+ batch_key: str | None,
1016
+ expected_rates: dict[str, float] | None,
1017
+ chip: str = "10k",
1018
+ ) -> dict:
1019
+ """SOLO fits and calls doublets inside one sample at a time."""
1020
+ sample_key = _resolve_sample_key(adata, batch_key)
1021
+ labels = _sample_labels(adata, sample_key)
1022
+ names = set(np.unique(labels))
1023
+ if expected_rates is not None and names - set(expected_rates):
1024
+ raise ValueError(
1025
+ f"SOLO expected rates missing batches: {sorted(names - set(expected_rates))}"
1026
+ )
1027
+ scvi = _scvi_module()
1028
+ scores = np.empty(adata.n_obs, dtype=float)
1029
+ predictions = np.empty(adata.n_obs, dtype=object)
1030
+ removed = np.zeros(adata.n_obs, dtype=bool)
1031
+ batches = []
1032
+ for name in np.unique(labels):
1033
+ idx = np.flatnonzero(labels == name)
1034
+ sub = adata[idx].copy()
1035
+ if sample_key is not None and sub.obs[sample_key].astype(str).nunique() != 1:
1036
+ raise RuntimeError(f"SOLO subset {name} contains more than one sample")
1037
+ scvi.model.SCVI.setup_anndata(sub)
1038
+ vae = scvi.model.SCVI(sub, gene_likelihood="nb")
1039
+ vae.train(early_stopping=True)
1040
+ solo = scvi.external.SOLO.from_scvi_model(vae)
1041
+ solo.train(early_stopping=True)
1042
+ soft = solo.predict(soft=True)
1043
+ hard = solo.predict(soft=False)
1044
+ batch_scores = soft.loc[sub.obs_names, "doublet"].to_numpy(dtype=float)
1045
+ batch_labels = np.asarray(hard.loc[sub.obs_names]).reshape(-1).astype(str)
1046
+ if not np.isfinite(batch_scores).all():
1047
+ raise ValueError(f"SOLO returned non-finite doublet probabilities for {name}")
1048
+ scores[idx] = batch_scores
1049
+ predictions[idx] = batch_labels
1050
+ batch_removed = batch_labels == "doublet"
1051
+ predicted_rate = float(batch_removed.mean())
1052
+ if expected_rates is None:
1053
+ rate = _expected_doublet_rate(int(idx.size), chip)
1054
+ else:
1055
+ rate = float(expected_rates[str(name)])
1056
+ threshold = None
1057
+ if rate is not None:
1058
+ n_top = int(np.ceil(rate * idx.size))
1059
+ if n_top:
1060
+ ranked = np.argsort(batch_scores, kind="stable")[-n_top:]
1061
+ batch_removed[ranked] = True
1062
+ threshold = float(batch_scores[ranked].min())
1063
+ removed[idx] = batch_removed
1064
+ batches.append(
1065
+ {
1066
+ "batch": str(name),
1067
+ "n_cells": int(idx.size),
1068
+ "n_removed": int(batch_removed.sum()),
1069
+ "predicted_rate": predicted_rate,
1070
+ "removed_rate": float(batch_removed.mean()),
1071
+ "expected_rate": rate,
1072
+ "threshold": threshold,
1073
+ }
1074
+ )
1075
+ adata.obs["solo_score"] = scores
1076
+ adata.obs["solo_prediction"] = predictions
1077
+ adata.obs["is_doublet"] = removed
1078
+ n_removed = int(removed.sum())
1079
+ if n_removed:
1080
+ adata._inplace_subset_obs(~removed)
1081
+ return {
1082
+ "enabled": True,
1083
+ "method": "solo",
1084
+ "chip": chip if expected_rates is None else None,
1085
+ "n_removed": n_removed,
1086
+ "batches": batches,
1087
+ }
1088
+
1089
+
1090
+ def _fit_scvi(
1091
+ adata: AnnData, batch_key: str | None, n_latent: int, model_dir: str | Path | None
1092
+ ) -> None:
1093
+ scvi = _scvi_module()
1094
+ scvi.settings.seed = 0
1095
+ genes = adata.var["highly_variable"].to_numpy(dtype=bool)
1096
+ train = adata[:, genes].copy()
1097
+ scvi.model.SCVI.setup_anndata(train, layer="counts", batch_key=batch_key)
1098
+ model = scvi.model.SCVI(train, n_layers=2, n_latent=n_latent, gene_likelihood="nb")
1099
+ model.train(early_stopping=True)
1100
+ adata.obsm["X_scVI"] = np.asarray(model.get_latent_representation())
1101
+ if model_dir is not None:
1102
+ model.save(str(model_dir), overwrite=True)
1103
+
1104
+
1105
+ def _scrublet_thresholds(adata: AnnData) -> dict[str, float]:
1106
+ info = adata.uns.get("scrublet")
1107
+ if not isinstance(info, dict):
1108
+ return {}
1109
+ found: dict[str, float] = {}
1110
+ batches = info.get("batches")
1111
+ if isinstance(batches, dict):
1112
+ for name, batch in batches.items():
1113
+ threshold = batch.get("threshold") if isinstance(batch, dict) else None
1114
+ if threshold is not None:
1115
+ found[str(name)] = float(threshold)
1116
+ return found
1117
+ threshold = info.get("threshold")
1118
+ if threshold is not None:
1119
+ found["all"] = float(threshold)
1120
+ return found
1121
+
1122
+
1123
+ def _format_filter_report(prep: dict) -> str:
1124
+ """QC rules and the cell and gene counts at each filtering step."""
1125
+ after_denoise = int(prep["n_cells_after_denoise"])
1126
+ after_qc = after_denoise - int(prep["n_cells_qc"])
1127
+ lines = [
1128
+ _step("batch correction", prep.get("batch_correction", "none")),
1129
+ "",
1130
+ "Cells",
1131
+ _step("input", _count(prep["n_cells_in"])),
1132
+ ]
1133
+ if prep["denoise"]:
1134
+ lines.append(
1135
+ _step("AmbiDose", f"-{_count(prep['n_cells_denoise'])} -> {_count(after_denoise)}")
1136
+ )
1137
+ else:
1138
+ lines.append(_step("AmbiDose", "off"))
1139
+ lines.append(_step("QC", f"-{_count(prep['n_cells_qc'])} -> {_count(after_qc)}"))
1140
+ lines.extend(_qc_threshold_lines(prep))
1141
+ doublets = prep.get("doublets") or {
1142
+ "enabled": False,
1143
+ "n_removed": prep.get("n_cells_doublet", 0),
1144
+ }
1145
+ if doublets.get("enabled"):
1146
+ method = "SOLO" if doublets.get("method") == "solo" else "Scrublet"
1147
+ lines.append(
1148
+ _step(method, f"-{_count(doublets['n_removed'])} -> {_count(prep['n_cells_out'])}")
1149
+ )
1150
+ lines.extend(f" {line}" for line in _doublet_batch_lines(doublets))
1151
+ else:
1152
+ lines.append(_step("doublets", "off"))
1153
+ lines.extend(
1154
+ [
1155
+ _step("output", _count(prep["n_cells_out"])),
1156
+ "",
1157
+ "Genes",
1158
+ _step("input", _count(prep["n_genes_in"])),
1159
+ ]
1160
+ )
1161
+ if prep["denoise"] and prep["n_genes_denoise"]:
1162
+ after_gene_denoise = int(prep["n_genes_in"]) - int(prep["n_genes_denoise"])
1163
+ lines.append(
1164
+ _step("AmbiDose", f"-{_count(prep['n_genes_denoise'])} -> {_count(after_gene_denoise)}")
1165
+ )
1166
+ lines.extend(
1167
+ [
1168
+ _step(
1169
+ "zero total",
1170
+ f"-{_count(prep['n_genes_unobserved'])} -> {_count(prep['n_genes_out'])}",
1171
+ ),
1172
+ "",
1173
+ f"Mitochondrial genes: {', '.join(prep['qc']['mt_prefixes'])}.",
1174
+ "Ribosomal and hemoglobin fractions are recorded, not filtered.",
1175
+ ]
1176
+ )
1177
+ cap = prep["qc"]["max_pct_mt_percent"]
1178
+ if cap is None:
1179
+ lines.append(" mitochondrial percent cap: off")
1180
+ else:
1181
+ lines.append(f" mitochondrial percent cap: {_fmt(cap)}")
1182
+ lines.extend(["", format_doublets(doublet_summary(prep))])
1183
+ return "\n".join(lines)
1184
+
1185
+
1186
+ def _qc_threshold_lines(prep: dict) -> list[str]:
1187
+ """Per-sample cutoffs in the units a methods section can quote."""
1188
+ qc = prep.get("qc") or {}
1189
+ lines = []
1190
+ cap = qc.get("max_pct_mt_percent")
1191
+ for batch in qc.get("batches") or []:
1192
+ lines.append(
1193
+ f"{batch['batch']}: {_count(batch['n_cells'])} cells, "
1194
+ f"{_count(batch['n_removed'])} removed"
1195
+ )
1196
+ lines.extend(f" {_format_rule(rule)}" for rule in batch["rules"])
1197
+ if cap is not None:
1198
+ lines.append(
1199
+ f" percent mitochondrial counts above {_fmt(cap)}: "
1200
+ f"{_count(batch['n_max_pct_mt'])} removed"
1201
+ )
1202
+ return lines
1203
+
1204
+
1205
+ def doublet_summary(prep: dict | str | None) -> dict:
1206
+ """Doublet removal recorded by ``prepare``. Missing prep means it was not run."""
1207
+ if isinstance(prep, str):
1208
+ prep = json.loads(prep)
1209
+ if not isinstance(prep, dict):
1210
+ return {"enabled": None, "method": None, "n_removed": None, "batches": []}
1211
+ stored = prep.get("doublets")
1212
+ if isinstance(stored, dict):
1213
+ return stored
1214
+ return {"enabled": None, "method": None, "n_removed": None, "batches": []}
1215
+
1216
+
1217
+ def format_doublets(doublets: dict) -> str:
1218
+ """Text block for the doublet step. An unknown step says it was not recorded."""
1219
+ if doublets.get("enabled") is None:
1220
+ return "Doublets\n not recorded"
1221
+ if not doublets.get("enabled"):
1222
+ return "Doublets\n off"
1223
+ method = "SOLO" if doublets.get("method") == "solo" else "Scrublet"
1224
+ lines = ["Doublets", f" {method} removed {_count(doublets['n_removed'])}"]
1225
+ lines.extend(_doublet_batch_lines(doublets))
1226
+ return "\n".join(lines)
1227
+
1228
+
1229
+ def _doublet_batch_lines(doublets: dict) -> list[str]:
1230
+ lines = []
1231
+ for batch in doublets.get("batches") or []:
1232
+ threshold = batch.get("threshold")
1233
+ cutoff = "no threshold" if threshold is None else f"threshold {_fmt(threshold)}"
1234
+ expected = batch.get("expected_rate")
1235
+ if expected is not None:
1236
+ cutoff = f"{cutoff}, expected {100 * float(expected):.1f}%"
1237
+ if batch.get("n_cells") is None:
1238
+ lines.append(f"{batch['batch']}: {cutoff}")
1239
+ continue
1240
+ lines.append(
1241
+ f"{batch['batch']}: {_count(batch['n_cells'])} cells, "
1242
+ f"{_count(batch['n_removed'])} removed, {cutoff}"
1243
+ )
1244
+ return lines
1245
+
1246
+
1247
+ def _qc_label(metric: str) -> str:
1248
+ if metric == "log1p_total_counts":
1249
+ return "total counts"
1250
+ if metric == "log1p_n_genes_by_counts":
1251
+ return "genes per cell"
1252
+ if metric.startswith("pct_counts_in_top_") and metric.endswith("_genes"):
1253
+ n = metric.removeprefix("pct_counts_in_top_").removesuffix("_genes")
1254
+ return f"percent counts in top {n} genes"
1255
+ if metric == "logit_mt_fraction":
1256
+ return "mitochondrial percent"
1257
+ return metric
1258
+
1259
+
1260
+ def _on_count_scale(metric: str, value: float | None) -> float | None:
1261
+ """log1p cutoffs become UMI or gene counts. The mito logit becomes a percent."""
1262
+ if value is None or not np.isfinite(value):
1263
+ return None
1264
+ if metric.startswith("log1p_"):
1265
+ return float(np.expm1(value))
1266
+ if metric == "logit_mt_fraction":
1267
+ return float(100.0 * expit(value))
1268
+ return float(value)
1269
+
1270
+
1271
+ def _fmt_threshold(metric: str, value: float | None) -> str:
1272
+ scaled = _on_count_scale(metric, value)
1273
+ if scaled is None:
1274
+ return "none"
1275
+ if metric.startswith("log1p_"):
1276
+ return f"{int(round(scaled)):,}"
1277
+ if metric.startswith("pct_") or metric == "logit_mt_fraction":
1278
+ text = f"{scaled:.1f}"
1279
+ return text[:-2] if text.endswith(".0") else text
1280
+ return _fmt(scaled)
1281
+
1282
+
1283
+ def _format_rule(rule: dict) -> str:
1284
+ metric = rule["metric"]
1285
+ label = _qc_label(metric)
1286
+ removed = _count(rule["n_removed"])
1287
+ if not np.isfinite(rule["mad"]) or rule["mad"] == 0:
1288
+ return f"{label}: no cutoff, {removed} removed"
1289
+ if metric == "logit_mt_fraction":
1290
+ high = _fmt_threshold(metric, rule["high"])
1291
+ floor = _fmt(rule["min_pct"])
1292
+ return f"{label}: {high} and {floor}, {removed} removed"
1293
+ if rule["side"] == "both":
1294
+ low = _fmt_threshold(metric, rule["low"])
1295
+ high = _fmt_threshold(metric, rule["high"])
1296
+ return (
1297
+ f"{label}: {low} to {high}, {removed} removed "
1298
+ f"({_count(rule['n_low'])} below, {_count(rule['n_high'])} above)"
1299
+ )
1300
+ return f"{label}: {_fmt_threshold(metric, rule['high'])}, {removed} removed"
1301
+
1302
+
1303
+ def _step(label: str, detail: str) -> str:
1304
+ return f" {label:<16} {detail}"
1305
+
1306
+
1307
+ def _count(value: int) -> str:
1308
+ return f"{int(value):,}"
1309
+
1310
+
1311
+ def _fmt(value: float) -> str:
1312
+ value = float(value)
1313
+ if abs(value - round(value)) < 1e-6:
1314
+ return f"{int(round(value)):,}"
1315
+ if abs(value) >= 100:
1316
+ return f"{value:,.1f}"
1317
+ return f"{value:.4g}"
1318
+
1319
+
1320
+ def _select_genes(
1321
+ adata: AnnData,
1322
+ n_top_genes: int | None,
1323
+ batch_key: str | None,
1324
+ *,
1325
+ scfair: bool,
1326
+ ) -> int:
1327
+ if scfair:
1328
+ import scfair as scf
1329
+
1330
+ options = scf.pp.HVGOptions(
1331
+ batch_key=batch_key,
1332
+ append_budget=None if n_top_genes is None else 0,
1333
+ )
1334
+ scf.pp.highly_variable_genes(
1335
+ adata,
1336
+ n_top_genes="auto" if n_top_genes is None else int(n_top_genes),
1337
+ options=options,
1338
+ )
1339
+ else:
1340
+ count = _DEFAULT_N_TOP_GENES if n_top_genes is None else int(n_top_genes)
1341
+ if count < 1:
1342
+ raise ValueError("n_top_genes must be at least 1")
1343
+ count = min(count, int(adata.n_vars) - 1)
1344
+ if count < 1:
1345
+ adata.var["highly_variable"] = True
1346
+ else:
1347
+ try:
1348
+ sc.pp.highly_variable_genes(
1349
+ adata,
1350
+ n_top_genes=count,
1351
+ flavor="seurat_v3",
1352
+ batch_key=batch_key,
1353
+ )
1354
+ except ValueError:
1355
+ if batch_key is None:
1356
+ raise
1357
+ # A tiny batch makes the seurat_v3 loess fit singular.
1358
+ sc.pp.highly_variable_genes(
1359
+ adata,
1360
+ n_top_genes=count,
1361
+ flavor="seurat_v3",
1362
+ batch_key=None,
1363
+ )
1364
+ if "highly_variable" not in adata.var.columns or not adata.var["highly_variable"].any():
1365
+ adata.var["highly_variable"] = True
1366
+ return int(adata.var["highly_variable"].sum())
1367
+
1368
+
1369
+ def _fit_pcs(adata: AnnData, n_pcs: int | None) -> int:
1370
+ n_hvg = (
1371
+ int(adata.var["highly_variable"].sum())
1372
+ if "highly_variable" in adata.var.columns
1373
+ else adata.n_vars
1374
+ )
1375
+ cap = min(_PCA_CAP, int(adata.n_obs) - 1, max(n_hvg, 2) - 1, int(adata.n_vars) - 1)
1376
+ if cap < 2:
1377
+ raise ValueError("not enough cells or genes for PCA")
1378
+ sc.pp.pca(adata, n_comps=cap, mask_var="highly_variable", svd_solver="arpack", random_state=0)
1379
+ if n_pcs is not None:
1380
+ chosen = int(n_pcs)
1381
+ if chosen < 1 or chosen > cap:
1382
+ raise ValueError(f"n_pcs must be from 1 to {cap}")
1383
+ return chosen
1384
+ ratio = np.asarray(adata.uns["pca"]["variance_ratio"], dtype=float)
1385
+ return min(elbow_count(ratio), cap)