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 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
+ ]