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.
- annotuneagent/__init__.py +68 -0
- annotuneagent/__main__.py +6 -0
- annotuneagent/_version.py +19 -0
- annotuneagent/annotate.py +93 -0
- annotuneagent/arbitrate.py +141 -0
- annotuneagent/cli.py +366 -0
- annotuneagent/decide.py +725 -0
- annotuneagent/evaluate.py +121 -0
- annotuneagent/evidence.py +606 -0
- annotuneagent/granularity/__init__.py +28 -0
- annotuneagent/granularity/cl_dag.py +179 -0
- annotuneagent/granularity/ontology_map.py +108 -0
- annotuneagent/granularity/relations.py +96 -0
- annotuneagent/hypotheses.py +38 -0
- annotuneagent/local_lora.py +706 -0
- annotuneagent/marker_plot.py +172 -0
- annotuneagent/markers.py +206 -0
- annotuneagent/markers.tsv +378 -0
- annotuneagent/mltools.py +128 -0
- annotuneagent/ontology/cl_isa.json +1 -0
- annotuneagent/ontology/granularity_policy.yaml +8 -0
- annotuneagent/ontology/ontology_map.jsonl +198 -0
- annotuneagent/prepare.py +1385 -0
- annotuneagent/py.typed +0 -0
- annotuneagent/read.py +76 -0
- annotuneagent/tokenizer/LICENSE +202 -0
- annotuneagent/tokenizer/merges.txt +247587 -0
- annotuneagent/tokenizer/tokenizer.json +495920 -0
- annotuneagent/tokenizer/tokenizer_config.json +305 -0
- annotuneagent/tokenizer/vocab.json +248046 -0
- annotuneagent/votes.py +83 -0
- annotuneagent/workflow.py +2037 -0
- annotuneagent-0.1.0.dist-info/METADATA +208 -0
- annotuneagent-0.1.0.dist-info/RECORD +38 -0
- annotuneagent-0.1.0.dist-info/WHEEL +5 -0
- annotuneagent-0.1.0.dist-info/entry_points.txt +2 -0
- annotuneagent-0.1.0.dist-info/licenses/LICENSE +201 -0
- annotuneagent-0.1.0.dist-info/top_level.txt +1 -0
annotuneagent/prepare.py
ADDED
|
@@ -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)
|