scfair 0.5.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.
- scfair/__init__.py +25 -0
- scfair/_utils.py +287 -0
- scfair/_version.py +21 -0
- scfair/pp/__init__.py +43 -0
- scfair/pp/_auto_n.py +1842 -0
- scfair/pp/_diagnosis.py +1345 -0
- scfair/pp/_granularity.py +659 -0
- scfair/pp/_highly_variable_genes.py +4922 -0
- scfair/pp/_options.py +215 -0
- scfair/pp/_raw_counts.py +502 -0
- scfair/py.typed +0 -0
- scfair-0.5.0.dist-info/METADATA +121 -0
- scfair-0.5.0.dist-info/RECORD +16 -0
- scfair-0.5.0.dist-info/WHEEL +5 -0
- scfair-0.5.0.dist-info/licenses/LICENSE +202 -0
- scfair-0.5.0.dist-info/top_level.txt +1 -0
scfair/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""scFair — fair highly variable gene selection for single-cell RNA-seq.
|
|
2
|
+
|
|
3
|
+
Public usage::
|
|
4
|
+
|
|
5
|
+
import scfair as scf
|
|
6
|
+
scf.pp.highly_variable_genes(adata) # default: n_top_genes=2000 + append
|
|
7
|
+
# structure-aware k (opt-in; multi-seed, slower):
|
|
8
|
+
# scf.pp.highly_variable_genes(adata, n_top_genes="auto")
|
|
9
|
+
# optional pre-call planning from known labels:
|
|
10
|
+
scf.pp.diagnose_from_labels(adata.obs["cell_type"])
|
|
11
|
+
# optional, label-free: how many populations the data supports
|
|
12
|
+
scf.pp.estimate_n_populations(adata)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from . import pp
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .pp import diagnose_from_labels, estimate_n_populations, highly_variable_genes
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"pp",
|
|
22
|
+
"highly_variable_genes",
|
|
23
|
+
"diagnose_from_labels",
|
|
24
|
+
"estimate_n_populations",
|
|
25
|
+
]
|
scfair/_utils.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Internal helpers for raw-count storage and alignment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import anndata as ad
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import scipy.sparse as sparse
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Namespace key for package metadata in adata.uns
|
|
17
|
+
UNS_KEY = "scfair"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Full integer check is cheap (~tens of ms at 1e7 floats). Above this, subsample.
|
|
21
|
+
_INTEGER_COUNTS_FULL_CHECK_UPTO = 10_000_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_integer_counts_like(
|
|
25
|
+
X: Any,
|
|
26
|
+
max_check: int = 100_000,
|
|
27
|
+
atol: float = 1e-6,
|
|
28
|
+
*,
|
|
29
|
+
full_check_upto: int = _INTEGER_COUNTS_FULL_CHECK_UPTO,
|
|
30
|
+
) -> bool:
|
|
31
|
+
"""Return True if the matrix looks like non-negative integer counts.
|
|
32
|
+
|
|
33
|
+
Tolerant of float storage of integer values (common after aggregation).
|
|
34
|
+
|
|
35
|
+
- ``nnz ≤ full_check_upto`` (default 1e7): check **all** stored values so a
|
|
36
|
+
single fractional entry at the end of a large sparse ``.data`` is not
|
|
37
|
+
missed by strided sampling.
|
|
38
|
+
- larger: two deterministic strided passes (``max_check`` samples).
|
|
39
|
+
|
|
40
|
+
Uses **atol only** (``rtol=0``): a relative tolerance would accept e.g.
|
|
41
|
+
``100000.5`` as integer-like (``rtol * 1e5 ≈ 1``).
|
|
42
|
+
"""
|
|
43
|
+
if sparse.issparse(X):
|
|
44
|
+
data = X.data
|
|
45
|
+
if data.size == 0:
|
|
46
|
+
return True
|
|
47
|
+
if not np.all(np.isfinite(data)):
|
|
48
|
+
return False
|
|
49
|
+
vals = data
|
|
50
|
+
else:
|
|
51
|
+
arr = np.asarray(X)
|
|
52
|
+
if not np.all(np.isfinite(arr)):
|
|
53
|
+
return False
|
|
54
|
+
vals = arr.ravel()
|
|
55
|
+
|
|
56
|
+
if vals.size == 0:
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
n_full = int(full_check_upto)
|
|
60
|
+
if vals.size > n_full:
|
|
61
|
+
# Two strided passes with different offsets — O(max_check) memory, not
|
|
62
|
+
# O(nnz). rng.choice(..., replace=False) would copy the full .data array.
|
|
63
|
+
half = max(1, max_check // 2)
|
|
64
|
+
stride = max(1, vals.size // half)
|
|
65
|
+
stride_vals = vals[::stride][:half]
|
|
66
|
+
offset = int(stride // 2)
|
|
67
|
+
second = vals[offset::stride][: max_check - stride_vals.size]
|
|
68
|
+
vals = np.concatenate([stride_vals, second]) if second.size else stride_vals
|
|
69
|
+
|
|
70
|
+
rounded = np.round(vals)
|
|
71
|
+
return bool(np.all(vals >= 0) and np.allclose(vals, rounded, atol=atol, rtol=0.0))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _clear_log_preprocess_metadata(adata: ad.AnnData) -> None:
|
|
75
|
+
"""Drop scanpy log-transform markers after restoring raw counts into .X."""
|
|
76
|
+
adata.uns.pop("log1p", None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_raw_snapshot(adata: ad.AnnData) -> dict[str, Any] | None:
|
|
80
|
+
"""Return the label-indexed raw snapshot from ``uns['scfair']``, or None."""
|
|
81
|
+
meta = adata.uns.get(UNS_KEY)
|
|
82
|
+
if not isinstance(meta, dict):
|
|
83
|
+
return None
|
|
84
|
+
snap = meta.get("raw_snapshot")
|
|
85
|
+
if not isinstance(snap, dict):
|
|
86
|
+
return None
|
|
87
|
+
has_inline = "X" in snap
|
|
88
|
+
is_ondisk = snap.get("backend") == "ondisk" and snap.get("path")
|
|
89
|
+
if not (has_inline or is_ondisk):
|
|
90
|
+
return None
|
|
91
|
+
return snap
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_snapshot_matrix(snap: dict[str, Any]) -> Any:
|
|
95
|
+
"""Return the full count matrix for a snapshot, loading from disk if needed."""
|
|
96
|
+
if snap.get("backend") == "ondisk":
|
|
97
|
+
path = snap.get("path")
|
|
98
|
+
if not path or not os.path.exists(path):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"On-disk raw_snapshot file not found at {path!r}. It may have been moved "
|
|
101
|
+
"or deleted; re-run store_raw_counts(sidecar='ondisk', snapshot_path=...)."
|
|
102
|
+
)
|
|
103
|
+
snap_ad = ad.read_h5ad(path)
|
|
104
|
+
X = snap_ad.X
|
|
105
|
+
else:
|
|
106
|
+
X = snap["X"]
|
|
107
|
+
|
|
108
|
+
if sparse.issparse(X):
|
|
109
|
+
return X.tocsr()
|
|
110
|
+
return X
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _slice_snapshot_matrix(mat: Any, row_idx: np.ndarray, col_idx: np.ndarray) -> Any:
|
|
114
|
+
"""Slice a snapshot matrix by row/column index arrays, preserving format."""
|
|
115
|
+
if sparse.issparse(mat):
|
|
116
|
+
return mat[row_idx][:, col_idx]
|
|
117
|
+
return np.asarray(mat)[np.ix_(row_idx, col_idx)]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _snapshot_alignment_indices(
|
|
121
|
+
adata: ad.AnnData, snap: dict[str, Any], *, full_genes: bool
|
|
122
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
123
|
+
"""Compute ``(row_idx, col_idx, var_names_out)`` aligning a snapshot to ``adata``."""
|
|
124
|
+
snap_obs = pd.Index(np.asarray(snap["obs_names"]).astype(str))
|
|
125
|
+
snap_var = pd.Index(np.asarray(snap["var_names"]).astype(str))
|
|
126
|
+
|
|
127
|
+
if not snap_obs.is_unique or not adata.obs_names.is_unique:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
"raw_snapshot restore requires unique cell names on both the snapshot and "
|
|
130
|
+
"the current object. Call adata.obs_names_make_unique() before "
|
|
131
|
+
"store_raw_counts() (and keep names stable) to enable label-aligned restore."
|
|
132
|
+
)
|
|
133
|
+
if not full_genes and (not snap_var.is_unique or not adata.var_names.is_unique):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"raw_snapshot restore requires unique gene names. Call "
|
|
136
|
+
"adata.var_names_make_unique() before store_raw_counts()."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
row_idx = snap_obs.get_indexer(adata.obs_names.astype(str))
|
|
140
|
+
if (row_idx < 0).any():
|
|
141
|
+
n_missing = int((row_idx < 0).sum())
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"raw_snapshot is missing {n_missing} of {adata.n_obs} current cells "
|
|
144
|
+
"(obs_names not found). The snapshot may originate from a different object."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if full_genes:
|
|
148
|
+
col_idx = np.arange(snap_var.size)
|
|
149
|
+
var_names_out = snap_var.to_numpy()
|
|
150
|
+
else:
|
|
151
|
+
col_idx = snap_var.get_indexer(adata.var_names.astype(str))
|
|
152
|
+
if (col_idx < 0).any():
|
|
153
|
+
n_missing = int((col_idx < 0).sum())
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"raw_snapshot is missing {n_missing} of {adata.n_vars} current genes "
|
|
156
|
+
"(var_names not found). Re-run store_raw_counts() on the current object."
|
|
157
|
+
)
|
|
158
|
+
var_names_out = adata.var_names.to_numpy()
|
|
159
|
+
|
|
160
|
+
return row_idx, col_idx, var_names_out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _align_snapshot_counts(
|
|
164
|
+
adata: ad.AnnData,
|
|
165
|
+
*,
|
|
166
|
+
full_genes: bool = False,
|
|
167
|
+
require_integer: bool = False,
|
|
168
|
+
) -> tuple[Any, np.ndarray] | None:
|
|
169
|
+
"""Align the ``uns`` raw count snapshot to ``adata`` by label.
|
|
170
|
+
|
|
171
|
+
Returns ``(X, var_names)`` or None when no usable snapshot exists.
|
|
172
|
+
"""
|
|
173
|
+
snap = _get_raw_snapshot(adata)
|
|
174
|
+
if snap is None:
|
|
175
|
+
return None
|
|
176
|
+
if require_integer and not bool(snap.get("is_integer", False)):
|
|
177
|
+
return None
|
|
178
|
+
row_idx, col_idx, var_names_out = _snapshot_alignment_indices(
|
|
179
|
+
adata, snap, full_genes=full_genes
|
|
180
|
+
)
|
|
181
|
+
X = _load_snapshot_matrix(snap)
|
|
182
|
+
return _slice_snapshot_matrix(X, row_idx, col_idx), var_names_out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resolve_aligned_raw_counts(
|
|
186
|
+
adata: ad.AnnData,
|
|
187
|
+
*,
|
|
188
|
+
layer: str = "counts",
|
|
189
|
+
require_integer: bool = True,
|
|
190
|
+
) -> Any | None:
|
|
191
|
+
"""Return a count matrix aligned to current cells/genes, or None if unsafe.
|
|
192
|
+
|
|
193
|
+
Prefers the label-indexed uns snapshot (survives HVG/cell subset), then
|
|
194
|
+
``layers[layer]``, then integer-looking ``adata.raw`` **column-aligned by
|
|
195
|
+
``var_names``** (so a full-gene ``.raw`` still works after HVG subset —
|
|
196
|
+
the usual scanpy pattern where ``raw.n_vars > adata.n_vars``).
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
snap_result = _align_snapshot_counts(
|
|
200
|
+
adata, full_genes=False, require_integer=require_integer
|
|
201
|
+
)
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
logger.warning(
|
|
204
|
+
"raw_snapshot present but could not be aligned (%s); falling back to "
|
|
205
|
+
"layers/.raw for count resolution.",
|
|
206
|
+
exc,
|
|
207
|
+
)
|
|
208
|
+
snap_result = None
|
|
209
|
+
if snap_result is not None:
|
|
210
|
+
snap_mat, _ = snap_result
|
|
211
|
+
logger.debug("Using label-aligned raw_snapshot from uns.")
|
|
212
|
+
return snap_mat
|
|
213
|
+
|
|
214
|
+
candidates: list[tuple[str, Any]] = []
|
|
215
|
+
if layer in adata.layers:
|
|
216
|
+
candidates.append((f"layers['{layer}']", adata.layers[layer]))
|
|
217
|
+
raw = getattr(adata, "raw", None)
|
|
218
|
+
if raw is not None and hasattr(raw, "var_names") and raw.X is not None:
|
|
219
|
+
# Align .raw columns to current genes (superset is the common case).
|
|
220
|
+
try:
|
|
221
|
+
raw_var = pd.Index(np.asarray(raw.var_names).astype(str))
|
|
222
|
+
cur_var = pd.Index(adata.var_names.astype(str))
|
|
223
|
+
except Exception:
|
|
224
|
+
raw_var = None
|
|
225
|
+
cur_var = None
|
|
226
|
+
if (
|
|
227
|
+
raw_var is not None
|
|
228
|
+
and cur_var is not None
|
|
229
|
+
and raw_var.is_unique
|
|
230
|
+
and cur_var.is_unique
|
|
231
|
+
and int(getattr(raw, "n_obs", raw.X.shape[0])) == int(adata.n_obs)
|
|
232
|
+
):
|
|
233
|
+
col_idx = raw_var.get_indexer(cur_var)
|
|
234
|
+
if (col_idx >= 0).all():
|
|
235
|
+
Xr = raw.X
|
|
236
|
+
if sparse.issparse(Xr):
|
|
237
|
+
mat = Xr[:, col_idx]
|
|
238
|
+
else:
|
|
239
|
+
mat = np.asarray(Xr)[:, col_idx]
|
|
240
|
+
candidates.append(("adata.raw", mat))
|
|
241
|
+
else:
|
|
242
|
+
logger.debug(
|
|
243
|
+
"adata.raw present but missing %d of %d current genes; not used.",
|
|
244
|
+
int((col_idx < 0).sum()),
|
|
245
|
+
int(adata.n_vars),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
for source_name, mat in candidates:
|
|
249
|
+
n_cols = mat.shape[1] if hasattr(mat, "shape") else 0
|
|
250
|
+
if n_cols != adata.n_vars:
|
|
251
|
+
logger.warning(
|
|
252
|
+
"Counts from %s have %d columns but adata has %d genes; skipping.",
|
|
253
|
+
source_name,
|
|
254
|
+
n_cols,
|
|
255
|
+
adata.n_vars,
|
|
256
|
+
)
|
|
257
|
+
continue
|
|
258
|
+
if require_integer and not _is_integer_counts_like(mat):
|
|
259
|
+
logger.warning(
|
|
260
|
+
"Counts from %s do not look like raw integer counts; skipping.",
|
|
261
|
+
source_name,
|
|
262
|
+
)
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
raw_gene_list = adata.uns.get(UNS_KEY, {}).get("raw_gene_list")
|
|
266
|
+
if raw_gene_list is not None and source_name.startswith("layers"):
|
|
267
|
+
stored = np.asarray(raw_gene_list)
|
|
268
|
+
current = adata.var_names.to_numpy()
|
|
269
|
+
if len(stored) == adata.n_vars and not np.array_equal(stored, current):
|
|
270
|
+
logger.warning(
|
|
271
|
+
"Counts from %s match n_vars but stored raw_gene_list order differs from "
|
|
272
|
+
"adata.var_names. Refusing misaligned counts. "
|
|
273
|
+
"Re-run store_raw_counts() on the current object.",
|
|
274
|
+
source_name,
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
if len(stored) != adata.n_vars:
|
|
278
|
+
logger.info(
|
|
279
|
+
"raw_gene_list (%d genes) differs from current n_vars (%d). "
|
|
280
|
+
"Using %s aligned to current genes.",
|
|
281
|
+
len(stored),
|
|
282
|
+
adata.n_vars,
|
|
283
|
+
source_name,
|
|
284
|
+
)
|
|
285
|
+
return mat
|
|
286
|
+
|
|
287
|
+
return None
|
scfair/_version.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Package version (single source of truth for setuptools dynamic version).
|
|
2
|
+
|
|
3
|
+
Bump ``__version__`` here when preparing a release. Packaging
|
|
4
|
+
(``pyproject.toml`` dynamic version), ``scfair.__version__``, and the docs
|
|
5
|
+
``release`` string all read this module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__", "version", "version_tuple"]
|
|
11
|
+
|
|
12
|
+
__version__ = "0.5.0"
|
|
13
|
+
version = __version__
|
|
14
|
+
|
|
15
|
+
_parts: list[int | str] = []
|
|
16
|
+
for _seg in __version__.split("."):
|
|
17
|
+
try:
|
|
18
|
+
_parts.append(int(_seg))
|
|
19
|
+
except ValueError:
|
|
20
|
+
_parts.append(_seg)
|
|
21
|
+
version_tuple: tuple[int | str, ...] = tuple(_parts)
|
scfair/pp/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Preprocessing for scFair.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
- :func:`highly_variable_genes` — main user-facing entry point.
|
|
6
|
+
- :func:`diagnose_from_labels` — pre-call imbalance / suitability tips from
|
|
7
|
+
known cell labels (does not run HVG).
|
|
8
|
+
- :func:`estimate_n_populations` — how many populations the data's density
|
|
9
|
+
field supports, read off a 3D embedding (advisory; does not run HVG).
|
|
10
|
+
|
|
11
|
+
Raw-count snapshot / restore helpers live in :mod:`scfair.pp._raw_counts` and
|
|
12
|
+
are **private**; they run automatically inside ``highly_variable_genes``.
|
|
13
|
+
Post-call diagnosis is written to ``adata.uns['scfair']['hvg']['diagnosis']``
|
|
14
|
+
when ``diagnose=True`` (default).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._auto_n import (
|
|
18
|
+
estimate_n_top_structure,
|
|
19
|
+
explain_structure_rule,
|
|
20
|
+
select_n_top_from_structure,
|
|
21
|
+
)
|
|
22
|
+
from ._diagnosis import (
|
|
23
|
+
diagnose_from_labels,
|
|
24
|
+
recommend_cluster_resolution,
|
|
25
|
+
resolve_cluster_resolution,
|
|
26
|
+
resolve_hvg_mode,
|
|
27
|
+
)
|
|
28
|
+
from ._granularity import estimate_n_populations
|
|
29
|
+
from ._highly_variable_genes import highly_variable_genes
|
|
30
|
+
from ._options import HVGOptions
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"highly_variable_genes",
|
|
34
|
+
"HVGOptions",
|
|
35
|
+
"diagnose_from_labels",
|
|
36
|
+
"recommend_cluster_resolution",
|
|
37
|
+
"resolve_cluster_resolution",
|
|
38
|
+
"resolve_hvg_mode",
|
|
39
|
+
"estimate_n_populations",
|
|
40
|
+
"estimate_n_top_structure",
|
|
41
|
+
"explain_structure_rule",
|
|
42
|
+
"select_n_top_from_structure",
|
|
43
|
+
]
|