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.
- scstability/__init__.py +22 -0
- scstability/_cluster.py +281 -0
- scstability/_core.py +504 -0
- scstability/_metrics.py +533 -0
- scstability/pl/__init__.py +9 -0
- scstability/pl/_plots.py +658 -0
- scstability/py.typed +0 -0
- scstability-0.1.0.dist-info/METADATA +392 -0
- scstability-0.1.0.dist-info/RECORD +11 -0
- scstability-0.1.0.dist-info/WHEEL +4 -0
- scstability-0.1.0.dist-info/licenses/LICENSE +29 -0
scstability/__init__.py
ADDED
|
@@ -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"]
|
scstability/_cluster.py
ADDED
|
@@ -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
|