scstability 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.
@@ -0,0 +1,22 @@
1
+ """Bootstrap cluster stability for single-cell RNA-seq.
2
+
3
+ Which of your Leiden clusters survive resampling of the cells, and which
4
+ dissolve? Implements the cluster-wise Jaccard stability measure of Hennig
5
+ (2007) over subsampled reclusterings, AnnData-first and scanpy-compatible.
6
+
7
+ References
8
+ ----------
9
+ Hennig, C. (2007). Cluster-wise assessment of cluster stability.
10
+ *Computational Statistics & Data Analysis*, 52(1), 258-271.
11
+ """
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ # _metrics and _cluster stay internal by design: they are the tested core, not
16
+ # part of the user-facing surface. `pl` is imported eagerly so that
17
+ # `import scstability as scs` makes `scs.pl.stability_curve(...)` work, as the
18
+ # documented API promises -- a submodule is not an attribute until imported.
19
+ from . import pl
20
+ from ._core import HENNIG_BANDS, StabilityResult, stability_sweep
21
+
22
+ __all__ = ["HENNIG_BANDS", "StabilityResult", "pl", "stability_sweep"]
@@ -0,0 +1,281 @@
1
+ """Subsample, build the neighbour graph, run Leiden.
2
+
3
+ The only module permitted to hold subsample-space indices. Everything it
4
+ returns is scattered back into original cell space before it leaves, so that
5
+ ``_metrics`` and ``_core`` see one coordinate system and one only.
6
+
7
+ Why subsampling and not a classical bootstrap
8
+ ---------------------------------------------
9
+ Cells are drawn **without replacement**. Sampling with replacement would place
10
+ duplicate cells at distance zero from one another, which corrupts any
11
+ k-nearest-neighbour graph built afterwards: a cell's neighbourhood fills up
12
+ with copies of itself. ``chooseR`` subsamples for the same reason. This is a
13
+ deliberate departure from the textbook bootstrap, not an oversight.
14
+
15
+ Why the embedding is sliced rather than recomputed
16
+ --------------------------------------------------
17
+ The representation is computed once on the full data by the user and then
18
+ indexed. This isolates instability arising from graph construction and
19
+ community detection from instability in the embedding itself.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import numpy as np
25
+ import scanpy as sc
26
+ from anndata import AnnData
27
+ from numpy.typing import ArrayLike, NDArray
28
+
29
+ from ._metrics import NOT_SAMPLED
30
+
31
+ __all__ = [
32
+ "cluster_subsample",
33
+ "derive_seeds",
34
+ "leiden_labels",
35
+ "subsample_indices",
36
+ ]
37
+
38
+ #: Scratch key used inside the throwaway AnnData; never reaches the user's object.
39
+ _LEIDEN_KEY = "_scstability_leiden"
40
+
41
+ #: Upper bound for derived seeds. Kept inside int32 because igraph's random
42
+ #: number generator, which Leiden ultimately seeds, is a 32-bit interface.
43
+ _MAX_SEED = 2**31 - 1
44
+
45
+
46
+ def derive_seeds(random_state: int | None, n_boot: int) -> NDArray[np.int64]:
47
+ """Deterministic per-bootstrap seeds derived from one master seed.
48
+
49
+ Every seed is drawn up front, before any clustering runs. That makes
50
+ bootstrap ``b`` use the same seed regardless of the order the loop happens
51
+ to execute in, which is what keeps results reproducible under a future
52
+ parallel implementation as well as the current serial one.
53
+
54
+ Parameters
55
+ ----------
56
+ random_state
57
+ Master seed. ``None`` gives non-reproducible results.
58
+ n_boot
59
+ How many seeds to derive.
60
+
61
+ Returns
62
+ -------
63
+ ndarray
64
+ Length ``n_boot``, dtype int64, values in ``[0, 2**31 - 1)``.
65
+
66
+ Examples
67
+ --------
68
+ >>> derive_seeds(0, 3) is not None
69
+ True
70
+ >>> bool((derive_seeds(0, 3) == derive_seeds(0, 3)).all())
71
+ True
72
+ >>> bool((derive_seeds(0, 5)[:3] == derive_seeds(0, 3)).all())
73
+ True
74
+ """
75
+ if n_boot < 1:
76
+ raise ValueError(f"n_boot must be at least 1, got {n_boot}")
77
+ rng = np.random.default_rng(random_state)
78
+ return rng.integers(0, _MAX_SEED, size=n_boot, dtype=np.int64)
79
+
80
+
81
+ def subsample_indices(n_obs: int, frac: float, seed: int) -> NDArray[np.int64]:
82
+ """Draw ``round(frac * n_obs)`` cell indices without replacement, sorted.
83
+
84
+ Parameters
85
+ ----------
86
+ n_obs
87
+ Total number of cells.
88
+ frac
89
+ Fraction to keep, in ``(0, 1]``.
90
+ seed
91
+ Seed for this draw.
92
+
93
+ Returns
94
+ -------
95
+ ndarray
96
+ Sorted original-space cell indices.
97
+
98
+ Notes
99
+ -----
100
+ At ``frac=1.0`` the draw is a permutation of every index, and sorting turns
101
+ it back into ``arange(n_obs)``. The sliced matrix is therefore byte-identical
102
+ to the full one, and the bootstrap graph identical to the reference graph.
103
+ That is what makes the ``frac=1.0`` invariant test meaningful: any remaining
104
+ difference in the partition comes from Leiden's seed, not from the data.
105
+
106
+ Examples
107
+ --------
108
+ >>> subsample_indices(10, 0.5, seed=0).size
109
+ 5
110
+ >>> import numpy as np
111
+ >>> bool((subsample_indices(10, 1.0, seed=3) == np.arange(10)).all())
112
+ True
113
+ """
114
+ if not 0.0 < frac <= 1.0:
115
+ raise ValueError(f"frac must be in (0, 1], got {frac}")
116
+ n_sub = round(frac * n_obs)
117
+ if n_sub < 2:
118
+ raise ValueError(
119
+ f"frac={frac} on {n_obs} cells leaves {n_sub} cell(s); at least 2 are "
120
+ f"needed to build a neighbour graph. Raise frac or use more cells."
121
+ )
122
+ rng = np.random.default_rng(seed)
123
+ return np.sort(rng.choice(n_obs, size=n_sub, replace=False))
124
+
125
+
126
+ def effective_n_neighbors(n_neighbors: int, n_obs: int) -> int:
127
+ """Neighbours to request, clamped to what the subsample can support.
128
+
129
+ Small subsamples can have fewer cells than the requested ``n_neighbors``.
130
+ Left to itself, scanpy silently rewrites the value -- and not to ``n - 1``
131
+ but to a fixed fallback of 5, logging ``"n_obs too small: adjusting to
132
+ n_neighbors = 5"`` as it goes. That has two costs: the graph it builds is
133
+ sparser than the one we asked for (on 8 cells, 42 edges instead of 54), and
134
+ the message fires once per bootstrap, which across a sweep is a warning
135
+ storm. Clamping here keeps the graph ours and the log quiet.
136
+
137
+ Parameters
138
+ ----------
139
+ n_neighbors
140
+ Requested neighbours per cell.
141
+ n_obs
142
+ Cells available in this subsample.
143
+
144
+ Returns
145
+ -------
146
+ int
147
+ ``min(n_neighbors, n_obs - 1)`` -- a cell cannot be its own neighbour.
148
+
149
+ Examples
150
+ --------
151
+ >>> effective_n_neighbors(15, 300)
152
+ 15
153
+ >>> effective_n_neighbors(15, 8)
154
+ 7
155
+ """
156
+ return min(int(n_neighbors), n_obs - 1)
157
+
158
+
159
+ def leiden_labels(
160
+ X: ArrayLike, *, resolution: float, n_neighbors: int, seed: int | None
161
+ ) -> NDArray[np.int64]:
162
+ """Build a kNN graph on a coordinate matrix and run Leiden on it.
163
+
164
+ The single place scanpy is called. Isolating it here is what lets the
165
+ scatter-back logic in :func:`cluster_subsample` be tested with a stand-in
166
+ clusterer, deterministically and without running Leiden at all.
167
+
168
+ Parameters
169
+ ----------
170
+ X
171
+ Coordinates, shape ``(n, n_dims)``. Usually a slice of
172
+ ``adata.obsm[use_rep]``.
173
+ resolution
174
+ Leiden resolution.
175
+ n_neighbors
176
+ Neighbours per cell. Clamped to ``n - 1`` when the matrix has fewer
177
+ rows than that, which happens for small subsamples.
178
+ seed
179
+ Seeds both the neighbour search and Leiden.
180
+
181
+ Returns
182
+ -------
183
+ ndarray
184
+ Length ``n`` integer labels, in subsample space. **Callers must not let
185
+ this array escape without scattering it back** -- see
186
+ :func:`cluster_subsample`.
187
+
188
+ Notes
189
+ -----
190
+ ``flavor="igraph"`` with ``n_iterations=2`` is scanpy's recommended path;
191
+ the older ``leidenalg`` flavor is deprecated upstream. Both are pinned
192
+ explicitly rather than left to the default, so a change to scanpy's default
193
+ cannot silently change our numbers.
194
+ """
195
+ X = np.ascontiguousarray(X, dtype=np.float32)
196
+ n = X.shape[0]
197
+ if n < 2:
198
+ raise ValueError(f"need at least 2 cells to cluster, got {n}")
199
+
200
+ adata = AnnData(X)
201
+ sc.pp.neighbors(
202
+ adata,
203
+ n_neighbors=effective_n_neighbors(n_neighbors, n),
204
+ use_rep="X",
205
+ random_state=seed,
206
+ )
207
+ sc.tl.leiden(
208
+ adata,
209
+ resolution=float(resolution),
210
+ flavor="igraph",
211
+ n_iterations=2,
212
+ directed=False,
213
+ random_state=seed,
214
+ key_added=_LEIDEN_KEY,
215
+ )
216
+ return adata.obs[_LEIDEN_KEY].to_numpy().astype(np.int64)
217
+
218
+
219
+ def cluster_subsample(
220
+ X: ArrayLike,
221
+ *,
222
+ frac: float,
223
+ resolution: float,
224
+ n_neighbors: int,
225
+ seed: int,
226
+ ) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
227
+ """Subsample cells, recluster them, and return labels in original cell space.
228
+
229
+ Parameters
230
+ ----------
231
+ X
232
+ Full coordinate matrix, shape ``(n_obs, n_dims)``.
233
+ frac
234
+ Fraction of cells to draw, without replacement.
235
+ resolution, n_neighbors
236
+ Passed through to :func:`leiden_labels`.
237
+ seed
238
+ Seeds the draw and the clustering.
239
+
240
+ Returns
241
+ -------
242
+ labels
243
+ Length ``n_obs``. Position ``i`` is cell ``i``'s bootstrap cluster, or
244
+ ``NOT_SAMPLED`` if the cell was not drawn.
245
+ idx
246
+ The sorted original-space indices that were drawn.
247
+
248
+ Notes
249
+ -----
250
+ The two lines that scatter ``sub_labels`` into ``labels`` are the entire
251
+ translation between subsample space and original cell space. Leiden numbers
252
+ its output ``0..n_sub-1`` by *position in the subsample*, so row 3 of a
253
+ subsample may be cell 57. Comparing those numbers against reference labels
254
+ without translating produces scores that are still in ``[0, 1]`` and still
255
+ look plausible, but are meaningless. Keeping the translation to one place
256
+ means there is exactly one line where that bug could live.
257
+
258
+ Examples
259
+ --------
260
+ >>> import numpy as np
261
+ >>> rng = np.random.default_rng(0)
262
+ >>> X = rng.normal(size=(50, 5))
263
+ >>> labels, idx = cluster_subsample(
264
+ ... X, frac=0.8, resolution=1.0, n_neighbors=10, seed=0
265
+ ... )
266
+ >>> labels.shape
267
+ (50,)
268
+ >>> bool((np.flatnonzero(labels != NOT_SAMPLED) == idx).all())
269
+ True
270
+ """
271
+ X = np.asarray(X)
272
+ n_obs = X.shape[0]
273
+
274
+ idx = subsample_indices(n_obs, frac, seed)
275
+ sub_labels = leiden_labels(
276
+ X[idx], resolution=resolution, n_neighbors=n_neighbors, seed=seed
277
+ )
278
+
279
+ labels = np.full(n_obs, NOT_SAMPLED, dtype=np.int64)
280
+ labels[idx] = sub_labels
281
+ return labels, idx