structboost 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.
- structboost/__init__.py +94 -0
- structboost/_annotation.py +266 -0
- structboost/_boosting.py +552 -0
- structboost/_decoder.py +109 -0
- structboost/_encoder.py +88 -0
- structboost/_explorer.py +842 -0
- structboost/_io.py +302 -0
- structboost/_model.py +3483 -0
- structboost/_persistence.py +326 -0
- structboost/_plotting.py +301 -0
- structboost/_simulation.py +867 -0
- structboost/_stability.py +412 -0
- structboost/_types.py +393 -0
- structboost/_utils.py +509 -0
- structboost/py.typed +0 -0
- structboost-0.1.0.dist-info/METADATA +219 -0
- structboost-0.1.0.dist-info/RECORD +19 -0
- structboost-0.1.0.dist-info/WHEEL +4 -0
- structboost-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
"""Simulation utilities for generating synthetic scRNA-seq count data.
|
|
2
|
+
|
|
3
|
+
Generates negative-binomial UMI counts with block/stage structure, useful for
|
|
4
|
+
testing and benchmarking BAE and boosting algorithms. Cells are assigned to
|
|
5
|
+
contiguous stages; each stage over-expresses a sliding window of marker genes.
|
|
6
|
+
Optional technical effects (library size, batch, ambient RNA, dropout) can be
|
|
7
|
+
layered on top, and two orthogonal knobs control the noise level:
|
|
8
|
+
``effect_size`` (signal strength) and ``dispersion`` (overdispersion).
|
|
9
|
+
|
|
10
|
+
References
|
|
11
|
+
----------
|
|
12
|
+
Hess, M. et al. (2020) Bioinformatics — original block/stage simulation design
|
|
13
|
+
that the marker-window geometry follows.
|
|
14
|
+
|
|
15
|
+
Zappia, L., Phipson, B. & Oshlack, A. (2017) Splatter: simulation of single-cell
|
|
16
|
+
RNA sequencing data. Genome Biology 18:174 — gamma-distributed gene means,
|
|
17
|
+
gamma-Poisson counts, log-normal library sizes, multiplicative batch effects,
|
|
18
|
+
and the mean-dependent logistic dropout model.
|
|
19
|
+
|
|
20
|
+
Young, M. D. & Behjati, S. (2020) SoupX removes ambient RNA contamination from
|
|
21
|
+
droplet-based single-cell RNA sequencing data. GigaScience 9(12) — the pooled
|
|
22
|
+
ambient "soup" contamination model.
|
|
23
|
+
|
|
24
|
+
Notes
|
|
25
|
+
-----
|
|
26
|
+
Counts are generated in row chunks of ``_ROW_CHUNK``. This constant is part of
|
|
27
|
+
the reproducibility contract: changing it changes the random stream for a fixed
|
|
28
|
+
seed, even though the distribution is unaffected.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
from numpy.typing import NDArray
|
|
38
|
+
|
|
39
|
+
#: Boolean mask array. Named rather than spelled ``NDArray[np.bool_]`` inline:
|
|
40
|
+
#: the trailing underscore in ``np.bool_`` is valid Python but reads as reference
|
|
41
|
+
#: syntax to the documentation builder, which then reports a broken target on
|
|
42
|
+
#: every page that renders the annotation.
|
|
43
|
+
BoolArray = NDArray[np.bool_]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
import anndata as ad
|
|
48
|
+
|
|
49
|
+
_ROW_CHUNK = 2048
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SimulationResult:
|
|
54
|
+
"""Simulated counts together with the ground truth used to generate them.
|
|
55
|
+
|
|
56
|
+
Attributes
|
|
57
|
+
----------
|
|
58
|
+
counts
|
|
59
|
+
Integer UMI counts, shape (n, n_genes).
|
|
60
|
+
stage_labels
|
|
61
|
+
Stage index per cell, shape (n,). ``-1`` marks leftover background cells
|
|
62
|
+
that belong to no stage (possible when ``n`` is not divisible by
|
|
63
|
+
``stageno``).
|
|
64
|
+
stage_sizes
|
|
65
|
+
Number of cells per stage, shape (stageno,).
|
|
66
|
+
gene_level
|
|
67
|
+
Shape ``(n_genes,)``. Which level of the cell-type hierarchy each gene
|
|
68
|
+
marks — ``0`` is the broadest — or ``-1`` for a noise gene. With
|
|
69
|
+
``hierarchy=None`` every marker is level ``0``.
|
|
70
|
+
leaf_paths
|
|
71
|
+
Shape ``(stageno, n_levels)``. Ancestry of each leaf population, so
|
|
72
|
+
``leaf_paths[k, d]`` is the node index of population ``k`` at depth ``d``.
|
|
73
|
+
These become the per-level cell labels in the AnnData wrapper.
|
|
74
|
+
hierarchy
|
|
75
|
+
The branching factors used, or ``None`` for the flat layout.
|
|
76
|
+
marker_mask
|
|
77
|
+
Boolean marker indicator, shape (stageno, n_genes). ``marker_mask[k, j]``
|
|
78
|
+
is True when gene j is a marker of stage k. Genes in the overlap between
|
|
79
|
+
two consecutive stages are True in both rows.
|
|
80
|
+
gene_means
|
|
81
|
+
Baseline expression mean per gene (lambda_j), shape (n_genes,).
|
|
82
|
+
size_factors
|
|
83
|
+
Per-cell library size factor, shape (n,). All exactly 1.0 when
|
|
84
|
+
``lib_size_sd == 0``.
|
|
85
|
+
batch_labels
|
|
86
|
+
Batch index per cell, shape (n,).
|
|
87
|
+
params
|
|
88
|
+
Scalar simulation parameters. Contains no ``None`` values so it can be
|
|
89
|
+
stored in ``adata.uns`` and round-tripped through h5ad.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
counts: NDArray[np.int32]
|
|
93
|
+
stage_labels: NDArray[np.intp]
|
|
94
|
+
stage_sizes: NDArray[np.intp]
|
|
95
|
+
marker_mask: BoolArray
|
|
96
|
+
gene_means: NDArray[np.float64]
|
|
97
|
+
size_factors: NDArray[np.float64]
|
|
98
|
+
batch_labels: NDArray[np.intp]
|
|
99
|
+
params: dict[str, int | float | bool | str]
|
|
100
|
+
gene_level: NDArray[np.intp] = None # type: ignore[assignment]
|
|
101
|
+
leaf_paths: NDArray[np.intp] = None # type: ignore[assignment]
|
|
102
|
+
hierarchy: tuple[int, ...] | None = None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def marker_genes(self) -> BoolArray:
|
|
106
|
+
"""Genes that are a marker of at least one stage, shape (n_genes,)."""
|
|
107
|
+
return self.marker_mask.any(axis=0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _marker_mask(stageno: int, n_genes: int, stagep: int, stageoverlap: int) -> BoolArray:
|
|
111
|
+
"""Build the (stageno, n_genes) marker indicator.
|
|
112
|
+
|
|
113
|
+
Stage ``k`` covers the window ``[curp, curp + stagep)``, where ``curp``
|
|
114
|
+
advances by ``stagep - stageoverlap`` between stages, so consecutive stages
|
|
115
|
+
share exactly ``stageoverlap`` marker genes.
|
|
116
|
+
"""
|
|
117
|
+
mask = np.zeros((stageno, n_genes), dtype=np.bool_)
|
|
118
|
+
curp = 0
|
|
119
|
+
for k in range(stageno):
|
|
120
|
+
mask[k, curp : curp + stagep] = True
|
|
121
|
+
curp += stagep - stageoverlap
|
|
122
|
+
return mask
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _hierarchical_marker_mask(
|
|
126
|
+
hierarchy: tuple[int, ...],
|
|
127
|
+
n_genes: int,
|
|
128
|
+
markers_per_level: tuple[int, ...],
|
|
129
|
+
) -> tuple[BoolArray, NDArray[np.intp], NDArray[np.intp]]:
|
|
130
|
+
"""Build a nested marker structure over a cell-type tree.
|
|
131
|
+
|
|
132
|
+
Real cell taxonomies are hierarchical: a marker such as *Vip* labels an entire
|
|
133
|
+
subclass, while *Mybpc1* distinguishes one type within it — which is why Tasic
|
|
134
|
+
et al. (2016) name their clusters ``<subclass> <marker>``. This reproduces that
|
|
135
|
+
structure, in contrast to the flat layout built by :func:`_marker_mask`, where
|
|
136
|
+
populations only share genes with their immediate neighbours (a trajectory
|
|
137
|
+
model rather than a taxonomy).
|
|
138
|
+
|
|
139
|
+
``hierarchy`` gives the branching factor at each level, outermost first, so
|
|
140
|
+
``(2, 5)`` is two classes of five types each and produces ``2 * 5 = 10`` leaf
|
|
141
|
+
populations. Every leaf expresses the union of its ancestors' marker blocks:
|
|
142
|
+
a cell of type ``(1, 3)`` carries class 1's markers *and* type (1,3)'s markers.
|
|
143
|
+
Marker blocks are disjoint across the whole tree, so a gene belongs to exactly
|
|
144
|
+
one node and its level is unambiguous.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
hierarchy
|
|
149
|
+
Branching factors, outermost first. ``len(hierarchy)`` is the tree depth.
|
|
150
|
+
n_genes
|
|
151
|
+
Total genes; anything not assigned to a node is a noise gene.
|
|
152
|
+
markers_per_level
|
|
153
|
+
Marker-block size at each level, same length as ``hierarchy``.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
mask
|
|
158
|
+
``(n_leaves, n_genes)`` boolean marker indicator, unions taken over ancestors.
|
|
159
|
+
gene_level
|
|
160
|
+
``(n_genes,)`` level index each gene marks, ``-1`` for noise genes.
|
|
161
|
+
leaf_paths
|
|
162
|
+
``(n_leaves, n_levels)`` ancestry, so ``leaf_paths[k, d]`` is the node index
|
|
163
|
+
of leaf ``k`` at depth ``d``. Used to build the per-level cell labels.
|
|
164
|
+
"""
|
|
165
|
+
n_levels = len(hierarchy)
|
|
166
|
+
n_leaves = int(np.prod(hierarchy))
|
|
167
|
+
|
|
168
|
+
# Ancestry of every leaf: the mixed-radix expansion of its index.
|
|
169
|
+
leaf_paths = np.zeros((n_leaves, n_levels), dtype=np.intp)
|
|
170
|
+
for k in range(n_leaves):
|
|
171
|
+
rest = k
|
|
172
|
+
for d in range(n_levels - 1, -1, -1):
|
|
173
|
+
leaf_paths[k, d] = rest % hierarchy[d]
|
|
174
|
+
rest //= hierarchy[d]
|
|
175
|
+
|
|
176
|
+
mask = np.zeros((n_leaves, n_genes), dtype=np.bool_)
|
|
177
|
+
gene_level = np.full(n_genes, -1, dtype=np.intp)
|
|
178
|
+
|
|
179
|
+
cursor = 0
|
|
180
|
+
for depth in range(n_levels):
|
|
181
|
+
block = markers_per_level[depth]
|
|
182
|
+
# Nodes at this depth are the distinct ancestry prefixes of length depth+1.
|
|
183
|
+
prefixes = np.unique(leaf_paths[:, : depth + 1], axis=0)
|
|
184
|
+
for prefix in prefixes:
|
|
185
|
+
members = np.all(leaf_paths[:, : depth + 1] == prefix, axis=1)
|
|
186
|
+
mask[members, cursor : cursor + block] = True
|
|
187
|
+
gene_level[cursor : cursor + block] = depth
|
|
188
|
+
cursor += block
|
|
189
|
+
|
|
190
|
+
return mask, gene_level, leaf_paths
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _default_hierarchy(stageno: int) -> tuple[int, ...]:
|
|
194
|
+
"""A balanced two-level taxonomy over ``stageno`` leaf populations.
|
|
195
|
+
|
|
196
|
+
Picks the divisor of ``stageno`` closest to its square root, so ten
|
|
197
|
+
populations become two classes of five rather than a lopsided split. Prime
|
|
198
|
+
counts have no non-trivial factorisation and fall back to a single level,
|
|
199
|
+
which still gives disjoint marker blocks per population — the hierarchy is
|
|
200
|
+
simply flat for that count.
|
|
201
|
+
"""
|
|
202
|
+
if stageno < 4:
|
|
203
|
+
return (stageno,)
|
|
204
|
+
divisors = [d for d in range(2, stageno) if stageno % d == 0]
|
|
205
|
+
if not divisors:
|
|
206
|
+
return (stageno,)
|
|
207
|
+
target = stageno**0.5
|
|
208
|
+
best = min(divisors, key=lambda d: (abs(d - target), d))
|
|
209
|
+
return (best, stageno // best)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _resolve_markers_per_level(
|
|
213
|
+
hierarchy: tuple[int, ...], n_genes: int, stagep: int
|
|
214
|
+
) -> tuple[int, ...]:
|
|
215
|
+
"""Split the per-population marker budget across hierarchy levels.
|
|
216
|
+
|
|
217
|
+
A hierarchy needs many more distinct genes than a flat layout: each leaf owns
|
|
218
|
+
a private block *and* inherits one from every ancestor, so the total is
|
|
219
|
+
``sum_d n_nodes(d) * block(d)`` rather than one window per population. An even
|
|
220
|
+
split across levels therefore overflows ``n_genes`` easily.
|
|
221
|
+
|
|
222
|
+
Starts from an even split holding ``sum(block) == stagep`` (so each population
|
|
223
|
+
carries the requested number of markers) and, while the total does not fit,
|
|
224
|
+
shifts one marker from the deepest level to the broadest. That direction is
|
|
225
|
+
what makes the layout cheaper: the deepest level has the most nodes, so each
|
|
226
|
+
marker moved out of it frees the most genes, and shared programs grow rather
|
|
227
|
+
than shrink — which is the biologically sensible way to spend a tight budget.
|
|
228
|
+
|
|
229
|
+
Raises
|
|
230
|
+
------
|
|
231
|
+
ValueError
|
|
232
|
+
If no allocation fits, i.e. even one private marker per leaf plus one
|
|
233
|
+
shared marker per higher-level node exceeds ``n_genes``.
|
|
234
|
+
"""
|
|
235
|
+
depth = len(hierarchy)
|
|
236
|
+
nodes = [int(np.prod(hierarchy[: d + 1])) for d in range(depth)]
|
|
237
|
+
|
|
238
|
+
def total(blocks: list[int]) -> int:
|
|
239
|
+
return sum(nodes[d] * blocks[d] for d in range(depth))
|
|
240
|
+
|
|
241
|
+
base = max(1, stagep // depth)
|
|
242
|
+
blocks = [base] * depth
|
|
243
|
+
blocks[0] += max(0, stagep - base * depth)
|
|
244
|
+
|
|
245
|
+
while total(blocks) > n_genes:
|
|
246
|
+
# Only levels below the root can donate: moving a marker from level 0 to
|
|
247
|
+
# level 0 is a no-op and would spin forever.
|
|
248
|
+
deepest = max((d for d in range(1, depth) if blocks[d] > 1), default=None)
|
|
249
|
+
if deepest is None:
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"hierarchical marker layout does not fit: {hierarchy} needs at least "
|
|
252
|
+
f"{total([1] * depth)} genes for one marker per node, got n_genes={n_genes}. "
|
|
253
|
+
"Increase n_genes, or reduce the hierarchy depth or branching."
|
|
254
|
+
)
|
|
255
|
+
blocks[deepest] -= 1
|
|
256
|
+
blocks[0] += 1
|
|
257
|
+
return tuple(blocks)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _resolve_stage_sizes(
|
|
261
|
+
n: int, stageno: int, stagen: int | None, imbalanced: bool, rng: np.random.Generator
|
|
262
|
+
) -> NDArray[np.intp]:
|
|
263
|
+
"""Cells per stage: Dirichlet-distributed when imbalanced, else uniform."""
|
|
264
|
+
if imbalanced:
|
|
265
|
+
proportions = rng.dirichlet(np.ones(stageno))
|
|
266
|
+
sizes = np.maximum(1, np.round(proportions * n).astype(np.intp))
|
|
267
|
+
diff = n - sizes.sum()
|
|
268
|
+
if diff != 0:
|
|
269
|
+
order = np.argsort(sizes)[::-1]
|
|
270
|
+
for i in range(abs(diff)):
|
|
271
|
+
sizes[order[i % stageno]] += np.sign(diff)
|
|
272
|
+
return sizes
|
|
273
|
+
if stagen is None:
|
|
274
|
+
stagen = n // stageno
|
|
275
|
+
return np.full(stageno, stagen, dtype=np.intp)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _validate(
|
|
279
|
+
*,
|
|
280
|
+
n: int,
|
|
281
|
+
n_genes: int,
|
|
282
|
+
stageno: int,
|
|
283
|
+
stagep: int | None,
|
|
284
|
+
stagen: int | None,
|
|
285
|
+
stageoverlap: int,
|
|
286
|
+
imbalanced: bool,
|
|
287
|
+
hierarchy: tuple[int, ...] | None = None,
|
|
288
|
+
markers_per_level: tuple[int, ...] | None = None,
|
|
289
|
+
base_mean: float,
|
|
290
|
+
gene_mean_shape: float,
|
|
291
|
+
effect_size: float,
|
|
292
|
+
effect_size_sd: float,
|
|
293
|
+
dispersion: float,
|
|
294
|
+
lib_size_sd: float,
|
|
295
|
+
n_batches: int,
|
|
296
|
+
batch_effect_sd: float,
|
|
297
|
+
ambient_frac: float,
|
|
298
|
+
dropout_mid: float | None,
|
|
299
|
+
dropout_shape: float,
|
|
300
|
+
) -> int:
|
|
301
|
+
"""Validate all parameters before any random number is drawn.
|
|
302
|
+
|
|
303
|
+
Returns the resolved ``stagep``.
|
|
304
|
+
"""
|
|
305
|
+
if n < 1:
|
|
306
|
+
raise ValueError(f"n must be >= 1, got {n}")
|
|
307
|
+
if n_genes < 1:
|
|
308
|
+
raise ValueError(f"n_genes must be >= 1, got {n_genes}")
|
|
309
|
+
if stageno < 1:
|
|
310
|
+
raise ValueError(f"stageno must be >= 1, got {stageno}")
|
|
311
|
+
if stageno > n:
|
|
312
|
+
raise ValueError(f"stageno must be <= n, got stageno={stageno} and n={n}")
|
|
313
|
+
if stagep is None:
|
|
314
|
+
stagep = n_genes // stageno
|
|
315
|
+
if stagep < 1:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"stagep must be >= 1, got {stagep} "
|
|
318
|
+
f"(n_genes={n_genes} // stageno={stageno} is 0; increase n_genes)"
|
|
319
|
+
)
|
|
320
|
+
if stagep > n_genes:
|
|
321
|
+
raise ValueError(f"stagep must be <= n_genes, got stagep={stagep}, n_genes={n_genes}")
|
|
322
|
+
if hierarchy is not None:
|
|
323
|
+
if any(b < 1 for b in hierarchy) or len(hierarchy) < 1:
|
|
324
|
+
raise ValueError(f"hierarchy branching factors must all be >= 1, got {hierarchy}")
|
|
325
|
+
if int(np.prod(hierarchy)) != stageno:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
f"hierarchy {hierarchy} implies {int(np.prod(hierarchy))} leaf populations "
|
|
328
|
+
f"but stageno={stageno}. Set one or the other, not both."
|
|
329
|
+
)
|
|
330
|
+
if markers_per_level is not None:
|
|
331
|
+
if len(markers_per_level) != len(hierarchy):
|
|
332
|
+
raise ValueError(
|
|
333
|
+
f"markers_per_level must have one entry per level: got "
|
|
334
|
+
f"{len(markers_per_level)} for a depth-{len(hierarchy)} hierarchy"
|
|
335
|
+
)
|
|
336
|
+
if any(m < 1 for m in markers_per_level):
|
|
337
|
+
raise ValueError(f"markers_per_level entries must be >= 1, got {markers_per_level}")
|
|
338
|
+
needed = sum(
|
|
339
|
+
int(np.prod(hierarchy[: d + 1])) * markers_per_level[d]
|
|
340
|
+
for d in range(len(hierarchy))
|
|
341
|
+
)
|
|
342
|
+
if needed > n_genes:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"hierarchical marker layout does not fit: {hierarchy} with "
|
|
345
|
+
f"{markers_per_level} markers per level needs n_genes >= {needed}, "
|
|
346
|
+
f"got {n_genes}"
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
# Flat sliding-window layout only; `stageoverlap` has no meaning otherwise.
|
|
350
|
+
if not 0 <= stageoverlap < stagep:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"stageoverlap must satisfy 0 <= stageoverlap < stagep, "
|
|
353
|
+
f"got stageoverlap={stageoverlap} and stagep={stagep}"
|
|
354
|
+
)
|
|
355
|
+
needed = (stageno - 1) * (stagep - stageoverlap) + stagep
|
|
356
|
+
if needed > n_genes:
|
|
357
|
+
raise ValueError(
|
|
358
|
+
f"stage layout does not fit: {stageno} stages of {stagep} genes with "
|
|
359
|
+
f"overlap {stageoverlap} need n_genes >= {needed}, got {n_genes}"
|
|
360
|
+
)
|
|
361
|
+
if imbalanced and stagen is not None:
|
|
362
|
+
raise ValueError("stagen must be None when imbalanced=True; stage sizes are drawn")
|
|
363
|
+
if stagen is not None:
|
|
364
|
+
if stagen < 1:
|
|
365
|
+
raise ValueError(f"stagen must be >= 1, got {stagen}")
|
|
366
|
+
if stagen * stageno > n:
|
|
367
|
+
raise ValueError(f"stagen * stageno must be <= n, got {stagen} * {stageno} > {n}")
|
|
368
|
+
if base_mean <= 0:
|
|
369
|
+
raise ValueError(f"base_mean must be > 0, got {base_mean}")
|
|
370
|
+
if gene_mean_shape <= 0:
|
|
371
|
+
raise ValueError(f"gene_mean_shape must be > 0, got {gene_mean_shape}")
|
|
372
|
+
if effect_size <= 0:
|
|
373
|
+
raise ValueError(f"effect_size must be > 0, got {effect_size}")
|
|
374
|
+
if effect_size_sd < 0:
|
|
375
|
+
raise ValueError(f"effect_size_sd must be >= 0, got {effect_size_sd}")
|
|
376
|
+
if dispersion < 0:
|
|
377
|
+
raise ValueError(f"dispersion must be >= 0, got {dispersion}")
|
|
378
|
+
if lib_size_sd < 0:
|
|
379
|
+
raise ValueError(f"lib_size_sd must be >= 0, got {lib_size_sd}")
|
|
380
|
+
if not 1 <= n_batches <= n:
|
|
381
|
+
raise ValueError(f"n_batches must satisfy 1 <= n_batches <= n, got {n_batches}")
|
|
382
|
+
if batch_effect_sd < 0:
|
|
383
|
+
raise ValueError(f"batch_effect_sd must be >= 0, got {batch_effect_sd}")
|
|
384
|
+
if not 0.0 <= ambient_frac < 1.0:
|
|
385
|
+
raise ValueError(f"ambient_frac must be in [0, 1), got {ambient_frac}")
|
|
386
|
+
if dropout_mid is not None and not np.isfinite(dropout_mid):
|
|
387
|
+
raise ValueError(f"dropout_mid must be finite or None, got {dropout_mid}")
|
|
388
|
+
if not np.isfinite(dropout_shape):
|
|
389
|
+
raise ValueError(f"dropout_shape must be finite, got {dropout_shape}")
|
|
390
|
+
return stagep
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def sim_scrnaseq_data(
|
|
394
|
+
*,
|
|
395
|
+
n: int = 1000,
|
|
396
|
+
n_genes: int = 50,
|
|
397
|
+
stageno: int = 10,
|
|
398
|
+
stagep: int | None = None,
|
|
399
|
+
stagen: int | None = None,
|
|
400
|
+
stageoverlap: int | None = None,
|
|
401
|
+
hierarchy: tuple[int, ...] | bool | None = True,
|
|
402
|
+
markers_per_level: tuple[int, ...] | None = None,
|
|
403
|
+
imbalanced: bool = False,
|
|
404
|
+
base_mean: float = 2.0,
|
|
405
|
+
gene_mean_shape: float = 4.0,
|
|
406
|
+
effect_size: float = 10.0,
|
|
407
|
+
effect_size_sd: float = 0.0,
|
|
408
|
+
dispersion: float = 0.2,
|
|
409
|
+
lib_size_sd: float = 0.0,
|
|
410
|
+
n_batches: int = 1,
|
|
411
|
+
batch_effect_sd: float = 0.0,
|
|
412
|
+
ambient_frac: float = 0.0,
|
|
413
|
+
dropout_mid: float | None = None,
|
|
414
|
+
dropout_shape: float = -1.0,
|
|
415
|
+
seed: int = 1,
|
|
416
|
+
) -> SimulationResult:
|
|
417
|
+
"""Simulate negative-binomial scRNA-seq counts with block/stage structure.
|
|
418
|
+
|
|
419
|
+
Cells are assigned to ``stageno`` contiguous stages in row order. Each stage
|
|
420
|
+
over-expresses a sliding window of ``stagep`` marker genes by a factor of
|
|
421
|
+
``effect_size``; consecutive windows share ``stageoverlap`` genes. Counts are
|
|
422
|
+
drawn from a gamma-Poisson (negative binomial) mixture, so
|
|
423
|
+
``Var[c] = mu + dispersion * mu**2``.
|
|
424
|
+
|
|
425
|
+
All technical effects are neutral by default, so the base call produces clean,
|
|
426
|
+
well-separated block structure.
|
|
427
|
+
|
|
428
|
+
Parameters
|
|
429
|
+
----------
|
|
430
|
+
n
|
|
431
|
+
Number of cells.
|
|
432
|
+
n_genes
|
|
433
|
+
Number of genes.
|
|
434
|
+
stageno
|
|
435
|
+
Number of stages (cell populations).
|
|
436
|
+
stagep
|
|
437
|
+
Marker genes per stage. Defaults to ``n_genes // stageno``.
|
|
438
|
+
stagen
|
|
439
|
+
Cells per stage. Defaults to ``n // stageno``. Must be None when
|
|
440
|
+
``imbalanced=True``.
|
|
441
|
+
stageoverlap
|
|
442
|
+
Marker genes shared between consecutive stages. Must be < ``stagep``.
|
|
443
|
+
imbalanced
|
|
444
|
+
If True, draw stage sizes from a symmetric Dirichlet instead of using
|
|
445
|
+
equal sizes.
|
|
446
|
+
base_mean
|
|
447
|
+
Expected baseline expression mean across genes. Gene means are drawn as
|
|
448
|
+
``Gamma(gene_mean_shape, base_mean / gene_mean_shape)``.
|
|
449
|
+
gene_mean_shape
|
|
450
|
+
Shape of the gene-mean gamma; the coefficient of variation of gene means
|
|
451
|
+
is ``1 / sqrt(gene_mean_shape)``.
|
|
452
|
+
effect_size
|
|
453
|
+
Marker-to-background mean ratio. This is the signal-strength knob: lower
|
|
454
|
+
values make stages harder to separate.
|
|
455
|
+
effect_size_sd
|
|
456
|
+
Log-normal spread of the per-gene fold change around ``effect_size``.
|
|
457
|
+
0.0 gives every marker gene exactly ``effect_size``.
|
|
458
|
+
dispersion
|
|
459
|
+
Negative-binomial overdispersion; the biological coefficient of variation
|
|
460
|
+
is ``sqrt(dispersion)``. This is the noise knob, orthogonal to
|
|
461
|
+
``effect_size``. 0.0 gives exact Poisson counts.
|
|
462
|
+
lib_size_sd
|
|
463
|
+
Log-normal standard deviation of the per-cell size factor. 0.0 gives
|
|
464
|
+
uniform sequencing depth.
|
|
465
|
+
n_batches
|
|
466
|
+
Number of technical batches. Cells are assigned round-robin, so batch is
|
|
467
|
+
orthogonal to stage.
|
|
468
|
+
batch_effect_sd
|
|
469
|
+
Log-normal standard deviation of the per-gene, per-batch multiplicative
|
|
470
|
+
shift. The shifts are normalized to unit geometric mean per gene, so
|
|
471
|
+
enabling batch effects does not move marginal gene means.
|
|
472
|
+
ambient_frac
|
|
473
|
+
Fraction of each cell's counts replaced by draws from a pooled ambient
|
|
474
|
+
profile. Library size is preserved exactly.
|
|
475
|
+
dropout_mid
|
|
476
|
+
Midpoint of the mean-dependent logistic dropout curve, on the log-mean
|
|
477
|
+
scale. ``None`` disables dropout entirely.
|
|
478
|
+
dropout_shape
|
|
479
|
+
Steepness of the dropout curve. Negative values (the default) mean
|
|
480
|
+
high-expression genes drop out less.
|
|
481
|
+
seed
|
|
482
|
+
Random seed. Independent sub-streams are spawned per component, so
|
|
483
|
+
changing one parameter does not perturb the others' draws.
|
|
484
|
+
|
|
485
|
+
Returns
|
|
486
|
+
-------
|
|
487
|
+
SimulationResult
|
|
488
|
+
Counts plus the ground truth (stage labels, marker mask, gene means).
|
|
489
|
+
|
|
490
|
+
Raises
|
|
491
|
+
------
|
|
492
|
+
ValueError
|
|
493
|
+
If any parameter is out of range or the stage layout does not fit in
|
|
494
|
+
``n_genes``.
|
|
495
|
+
|
|
496
|
+
References
|
|
497
|
+
----------
|
|
498
|
+
The count model reimplemented here — gamma-distributed gene means,
|
|
499
|
+
gamma-Poisson counts, log-normal library sizes, multiplicative batch effects
|
|
500
|
+
and mean-dependent logistic dropout — follows Splatter: Zappia, L.,
|
|
501
|
+
Phipson, B. & Oshlack, A. (2017). *Splatter: simulation of single-cell RNA
|
|
502
|
+
sequencing data.* Genome Biology 18, 174.
|
|
503
|
+
|
|
504
|
+
Examples
|
|
505
|
+
--------
|
|
506
|
+
>>> from structboost import sim_scrnaseq_data
|
|
507
|
+
>>> res = sim_scrnaseq_data(n=100, n_genes=20, stageno=4, seed=0)
|
|
508
|
+
>>> res.counts.shape
|
|
509
|
+
(100, 20)
|
|
510
|
+
>>> res.marker_mask.shape
|
|
511
|
+
(4, 20)
|
|
512
|
+
"""
|
|
513
|
+
if hierarchy is True:
|
|
514
|
+
hierarchy = _default_hierarchy(stageno)
|
|
515
|
+
elif hierarchy is False:
|
|
516
|
+
hierarchy = None
|
|
517
|
+
hierarchical = hierarchy is not None
|
|
518
|
+
if hierarchical and stageoverlap not in (None, 0):
|
|
519
|
+
raise ValueError(
|
|
520
|
+
"stageoverlap is not used when hierarchy is set: overlap between "
|
|
521
|
+
"populations comes from shared ancestor markers instead. Pass "
|
|
522
|
+
"hierarchy=None for the flat sliding-window layout."
|
|
523
|
+
)
|
|
524
|
+
stageoverlap = 0 if hierarchical else (2 if stageoverlap is None else stageoverlap)
|
|
525
|
+
|
|
526
|
+
stagep = _validate(
|
|
527
|
+
n=n,
|
|
528
|
+
n_genes=n_genes,
|
|
529
|
+
stageno=stageno,
|
|
530
|
+
stagep=stagep,
|
|
531
|
+
stagen=stagen,
|
|
532
|
+
stageoverlap=stageoverlap,
|
|
533
|
+
imbalanced=imbalanced,
|
|
534
|
+
hierarchy=hierarchy,
|
|
535
|
+
markers_per_level=markers_per_level,
|
|
536
|
+
base_mean=base_mean,
|
|
537
|
+
gene_mean_shape=gene_mean_shape,
|
|
538
|
+
effect_size=effect_size,
|
|
539
|
+
effect_size_sd=effect_size_sd,
|
|
540
|
+
dispersion=dispersion,
|
|
541
|
+
lib_size_sd=lib_size_sd,
|
|
542
|
+
n_batches=n_batches,
|
|
543
|
+
batch_effect_sd=batch_effect_sd,
|
|
544
|
+
ambient_frac=ambient_frac,
|
|
545
|
+
dropout_mid=dropout_mid,
|
|
546
|
+
dropout_shape=dropout_shape,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Independent sub-streams so each knob can be swept without perturbing others.
|
|
550
|
+
seeds = np.random.SeedSequence(seed).spawn(7)
|
|
551
|
+
rng_struct, rng_gene, rng_effect, rng_tech, rng_count, rng_ambient, rng_dropout = (
|
|
552
|
+
np.random.default_rng(s) for s in seeds
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# --- Phase A: deterministic structure ------------------------------------
|
|
556
|
+
stage_sizes = _resolve_stage_sizes(n, stageno, stagen, imbalanced, rng_struct)
|
|
557
|
+
stage_labels = np.full(n, -1, dtype=np.intp)
|
|
558
|
+
start = 0
|
|
559
|
+
for k, size in enumerate(stage_sizes):
|
|
560
|
+
stop = min(start + int(size), n)
|
|
561
|
+
stage_labels[start:stop] = k
|
|
562
|
+
start = stop
|
|
563
|
+
|
|
564
|
+
if hierarchical:
|
|
565
|
+
if markers_per_level is None:
|
|
566
|
+
markers_per_level = _resolve_markers_per_level(hierarchy, n_genes, stagep)
|
|
567
|
+
_validate(
|
|
568
|
+
n=n,
|
|
569
|
+
n_genes=n_genes,
|
|
570
|
+
stageno=stageno,
|
|
571
|
+
stagep=stagep,
|
|
572
|
+
stagen=stagen,
|
|
573
|
+
stageoverlap=stageoverlap,
|
|
574
|
+
imbalanced=imbalanced,
|
|
575
|
+
base_mean=base_mean,
|
|
576
|
+
gene_mean_shape=gene_mean_shape,
|
|
577
|
+
effect_size=effect_size,
|
|
578
|
+
effect_size_sd=effect_size_sd,
|
|
579
|
+
dispersion=dispersion,
|
|
580
|
+
lib_size_sd=lib_size_sd,
|
|
581
|
+
n_batches=n_batches,
|
|
582
|
+
batch_effect_sd=batch_effect_sd,
|
|
583
|
+
ambient_frac=ambient_frac,
|
|
584
|
+
dropout_mid=dropout_mid,
|
|
585
|
+
dropout_shape=dropout_shape,
|
|
586
|
+
hierarchy=hierarchy,
|
|
587
|
+
markers_per_level=markers_per_level,
|
|
588
|
+
)
|
|
589
|
+
mask, gene_level, leaf_paths = _hierarchical_marker_mask(
|
|
590
|
+
hierarchy, n_genes, markers_per_level
|
|
591
|
+
)
|
|
592
|
+
else:
|
|
593
|
+
mask = _marker_mask(stageno, n_genes, stagep, stageoverlap)
|
|
594
|
+
gene_level = np.where(mask.any(axis=0), 0, -1).astype(np.intp)
|
|
595
|
+
leaf_paths = np.arange(stageno, dtype=np.intp)[:, None]
|
|
596
|
+
|
|
597
|
+
gene_means = rng_gene.gamma(gene_mean_shape, base_mean / gene_mean_shape, size=n_genes)
|
|
598
|
+
|
|
599
|
+
# Fold-change matrix; row 0 is the background row, rows 1.. are the stages.
|
|
600
|
+
z_eff = rng_effect.normal(size=(stageno, n_genes))
|
|
601
|
+
fold_stage = effect_size * np.exp(effect_size_sd * z_eff - effect_size_sd**2 / 2)
|
|
602
|
+
fold = np.ones((stageno + 1, n_genes), dtype=np.float64)
|
|
603
|
+
fold[1:] = np.where(mask, fold_stage, 1.0)
|
|
604
|
+
|
|
605
|
+
batch_labels = (np.arange(n) % n_batches).astype(np.intp)
|
|
606
|
+
log_batch = batch_effect_sd * rng_tech.normal(size=(n_batches, n_genes))
|
|
607
|
+
log_batch -= log_batch.mean(axis=0, keepdims=True) # unit geometric mean per gene
|
|
608
|
+
batch_factor = np.exp(log_batch)
|
|
609
|
+
|
|
610
|
+
size_factors = np.exp(lib_size_sd * rng_tech.normal(size=n) - lib_size_sd**2 / 2)
|
|
611
|
+
size_factors /= size_factors.mean()
|
|
612
|
+
|
|
613
|
+
if not np.isfinite(fold).all() or not np.isfinite(batch_factor).all():
|
|
614
|
+
raise ValueError(
|
|
615
|
+
"non-finite expression multipliers; reduce effect_size_sd or batch_effect_sd"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Ambient soup profile: the mean expression per gene, in closed form.
|
|
619
|
+
soup_profile: NDArray[np.float64] | None = None
|
|
620
|
+
if ambient_frac > 0.0:
|
|
621
|
+
flat = (stage_labels + 1) * n_batches + batch_labels
|
|
622
|
+
weights = np.bincount(
|
|
623
|
+
flat, weights=size_factors, minlength=(stageno + 1) * n_batches
|
|
624
|
+
).reshape(stageno + 1, n_batches)
|
|
625
|
+
mean_expr = gene_means / n * np.einsum("sg,sj,gj->j", weights, fold, batch_factor)
|
|
626
|
+
total = mean_expr.sum()
|
|
627
|
+
soup_profile = mean_expr / total if total > 0 else np.full(n_genes, 1.0 / n_genes)
|
|
628
|
+
|
|
629
|
+
# --- Phase B: stochastic pipeline, chunked over rows ---------------------
|
|
630
|
+
counts = np.empty((n, n_genes), dtype=np.int32)
|
|
631
|
+
for a in range(0, n, _ROW_CHUNK):
|
|
632
|
+
b = min(a + _ROW_CHUNK, n)
|
|
633
|
+
mu = (
|
|
634
|
+
gene_means[None, :]
|
|
635
|
+
* fold[stage_labels[a:b] + 1, :]
|
|
636
|
+
* batch_factor[batch_labels[a:b], :]
|
|
637
|
+
* size_factors[a:b, None]
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
rate = rng_count.gamma(1.0 / dispersion, dispersion * mu) if dispersion > 0 else mu
|
|
641
|
+
chunk = rng_count.poisson(rate)
|
|
642
|
+
|
|
643
|
+
if soup_profile is not None:
|
|
644
|
+
kept = rng_ambient.binomial(chunk, 1.0 - ambient_frac)
|
|
645
|
+
n_ambient = chunk.sum(axis=1) - kept.sum(axis=1)
|
|
646
|
+
chunk = kept + rng_ambient.multinomial(n_ambient, soup_profile)
|
|
647
|
+
|
|
648
|
+
if dropout_mid is not None:
|
|
649
|
+
mu_tilde = (1.0 - ambient_frac) * mu
|
|
650
|
+
if soup_profile is not None:
|
|
651
|
+
mu_tilde = mu_tilde + ambient_frac * mu.sum(axis=1, keepdims=True) * soup_profile
|
|
652
|
+
logit = dropout_shape * (np.log(np.maximum(mu_tilde, 1e-12)) - dropout_mid)
|
|
653
|
+
keep_prob = 1.0 / (1.0 + np.exp(logit)) # == 1 - sigmoid(logit)
|
|
654
|
+
chunk = chunk * (rng_dropout.random(chunk.shape) < keep_prob)
|
|
655
|
+
|
|
656
|
+
counts[a:b] = chunk.astype(np.int32, copy=False)
|
|
657
|
+
|
|
658
|
+
params: dict[str, int | float | bool | str] = {
|
|
659
|
+
"n": int(n),
|
|
660
|
+
"n_genes": int(n_genes),
|
|
661
|
+
"stageno": int(stageno),
|
|
662
|
+
"stagep": int(stagep),
|
|
663
|
+
"stageoverlap": int(stageoverlap),
|
|
664
|
+
"hierarchy": str(hierarchy),
|
|
665
|
+
"markers_per_level": str(markers_per_level),
|
|
666
|
+
"imbalanced": bool(imbalanced),
|
|
667
|
+
"base_mean": float(base_mean),
|
|
668
|
+
"gene_mean_shape": float(gene_mean_shape),
|
|
669
|
+
"effect_size": float(effect_size),
|
|
670
|
+
"effect_size_sd": float(effect_size_sd),
|
|
671
|
+
"dispersion": float(dispersion),
|
|
672
|
+
"lib_size_sd": float(lib_size_sd),
|
|
673
|
+
"n_batches": int(n_batches),
|
|
674
|
+
"batch_effect_sd": float(batch_effect_sd),
|
|
675
|
+
"ambient_frac": float(ambient_frac),
|
|
676
|
+
"dropout": bool(dropout_mid is not None),
|
|
677
|
+
"dropout_mid": float("nan") if dropout_mid is None else float(dropout_mid),
|
|
678
|
+
"dropout_shape": float(dropout_shape),
|
|
679
|
+
"seed": int(seed),
|
|
680
|
+
"design": "negative-binomial",
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return SimulationResult(
|
|
684
|
+
counts=counts,
|
|
685
|
+
stage_labels=stage_labels,
|
|
686
|
+
gene_level=gene_level,
|
|
687
|
+
leaf_paths=leaf_paths,
|
|
688
|
+
hierarchy=tuple(hierarchy) if hierarchy is not None else None,
|
|
689
|
+
stage_sizes=stage_sizes,
|
|
690
|
+
marker_mask=mask,
|
|
691
|
+
gene_means=gene_means,
|
|
692
|
+
size_factors=size_factors,
|
|
693
|
+
batch_labels=batch_labels,
|
|
694
|
+
params=params,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def sim_scrnaseq_anndata(
|
|
699
|
+
*,
|
|
700
|
+
n: int = 1000,
|
|
701
|
+
n_genes: int = 50,
|
|
702
|
+
stageno: int = 10,
|
|
703
|
+
stagep: int | None = None,
|
|
704
|
+
stagen: int | None = None,
|
|
705
|
+
stageoverlap: int | None = None,
|
|
706
|
+
hierarchy: tuple[int, ...] | bool | None = True,
|
|
707
|
+
markers_per_level: tuple[int, ...] | None = None,
|
|
708
|
+
imbalanced: bool = False,
|
|
709
|
+
base_mean: float = 2.0,
|
|
710
|
+
gene_mean_shape: float = 4.0,
|
|
711
|
+
effect_size: float = 10.0,
|
|
712
|
+
effect_size_sd: float = 0.0,
|
|
713
|
+
dispersion: float = 0.2,
|
|
714
|
+
lib_size_sd: float = 0.0,
|
|
715
|
+
n_batches: int = 1,
|
|
716
|
+
batch_effect_sd: float = 0.0,
|
|
717
|
+
ambient_frac: float = 0.0,
|
|
718
|
+
dropout_mid: float | None = None,
|
|
719
|
+
dropout_shape: float = -1.0,
|
|
720
|
+
seed: int = 1,
|
|
721
|
+
target_sum: float = 1e4,
|
|
722
|
+
standardize: bool = True,
|
|
723
|
+
) -> ad.AnnData:
|
|
724
|
+
"""Simulate scRNA-seq counts and package them as an AnnData object.
|
|
725
|
+
|
|
726
|
+
Wraps :func:`sim_scrnaseq_data` and applies the standard preprocessing
|
|
727
|
+
pipeline. Raw counts and library-size-normalized log1p values are always
|
|
728
|
+
kept as layers, so ``adata.X`` can be swapped without re-simulating.
|
|
729
|
+
|
|
730
|
+
Only the two preprocessing arguments are documented below; every other
|
|
731
|
+
parameter is passed through to :func:`sim_scrnaseq_data` unchanged.
|
|
732
|
+
|
|
733
|
+
Parameters
|
|
734
|
+
----------
|
|
735
|
+
target_sum
|
|
736
|
+
Library size each cell is normalized to before ``log1p``.
|
|
737
|
+
standardize
|
|
738
|
+
If True, ``adata.X`` holds the per-gene z-score of the log1p-normalized
|
|
739
|
+
values, ready for :class:`~structboost.BAE`. If False, ``adata.X`` holds
|
|
740
|
+
the log1p-normalized values themselves.
|
|
741
|
+
|
|
742
|
+
Returns
|
|
743
|
+
-------
|
|
744
|
+
anndata.AnnData
|
|
745
|
+
Shape (n, n_genes) with:
|
|
746
|
+
|
|
747
|
+
- ``X`` — z-scored log1p values, or log1p values when
|
|
748
|
+
``standardize=False``
|
|
749
|
+
- ``layers["counts"]`` — raw integer UMI counts
|
|
750
|
+
- ``layers["lognorm"]`` — log1p of library-size-normalized counts
|
|
751
|
+
- ``obs["stage"]`` — ground-truth stage label per cell
|
|
752
|
+
- ``obs["stage_id"]``, ``obs["batch"]``, ``obs["size_factor"]``,
|
|
753
|
+
``obs["total_counts"]``
|
|
754
|
+
- ``var["is_marker"]``, ``var["marker_stages"]``,
|
|
755
|
+
``var["n_marker_stages"]``, ``var["base_mean"]``
|
|
756
|
+
- ``varm["marker_mask"]`` — (n_genes, stageno) ground-truth marker matrix
|
|
757
|
+
- ``uns["simulation"]`` — simulation parameters
|
|
758
|
+
|
|
759
|
+
Raises
|
|
760
|
+
------
|
|
761
|
+
ImportError
|
|
762
|
+
If anndata is not installed.
|
|
763
|
+
ValueError
|
|
764
|
+
If ``target_sum <= 0`` or any parameter of :func:`sim_scrnaseq_data` is
|
|
765
|
+
out of range.
|
|
766
|
+
|
|
767
|
+
Examples
|
|
768
|
+
--------
|
|
769
|
+
>>> from structboost import sim_scrnaseq_anndata
|
|
770
|
+
>>> adata = sim_scrnaseq_anndata(n=100, n_genes=20, stageno=4, seed=0)
|
|
771
|
+
>>> adata.obs["stage"].nunique()
|
|
772
|
+
4
|
|
773
|
+
"""
|
|
774
|
+
try:
|
|
775
|
+
import anndata as ad
|
|
776
|
+
import pandas as pd
|
|
777
|
+
except ImportError as exc: # pragma: no cover - exercised only without anndata
|
|
778
|
+
raise ImportError(
|
|
779
|
+
"anndata is required for sim_scrnaseq_anndata. "
|
|
780
|
+
"Install with: pip install structboost[bae]"
|
|
781
|
+
) from exc
|
|
782
|
+
|
|
783
|
+
if target_sum <= 0:
|
|
784
|
+
raise ValueError(f"target_sum must be > 0, got {target_sum}")
|
|
785
|
+
|
|
786
|
+
result = sim_scrnaseq_data(
|
|
787
|
+
n=n,
|
|
788
|
+
n_genes=n_genes,
|
|
789
|
+
stageno=stageno,
|
|
790
|
+
stagep=stagep,
|
|
791
|
+
stagen=stagen,
|
|
792
|
+
stageoverlap=stageoverlap,
|
|
793
|
+
hierarchy=hierarchy,
|
|
794
|
+
markers_per_level=markers_per_level,
|
|
795
|
+
imbalanced=imbalanced,
|
|
796
|
+
base_mean=base_mean,
|
|
797
|
+
gene_mean_shape=gene_mean_shape,
|
|
798
|
+
effect_size=effect_size,
|
|
799
|
+
effect_size_sd=effect_size_sd,
|
|
800
|
+
dispersion=dispersion,
|
|
801
|
+
lib_size_sd=lib_size_sd,
|
|
802
|
+
n_batches=n_batches,
|
|
803
|
+
batch_effect_sd=batch_effect_sd,
|
|
804
|
+
ambient_frac=ambient_frac,
|
|
805
|
+
dropout_mid=dropout_mid,
|
|
806
|
+
dropout_shape=dropout_shape,
|
|
807
|
+
seed=seed,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
counts = result.counts
|
|
811
|
+
totals = counts.sum(axis=1, keepdims=True).astype(np.float64)
|
|
812
|
+
lognorm = np.log1p(counts / np.maximum(totals, 1.0) * target_sum).astype(np.float32)
|
|
813
|
+
|
|
814
|
+
adata = ad.AnnData(lognorm.copy())
|
|
815
|
+
adata.obs_names = [f"Cell_{i}" for i in range(n)]
|
|
816
|
+
adata.var_names = [f"Gene_{j}" for j in range(counts.shape[1])]
|
|
817
|
+
adata.layers["counts"] = counts
|
|
818
|
+
adata.layers["lognorm"] = lognorm
|
|
819
|
+
|
|
820
|
+
adata.obs["stage"] = pd.Categorical(
|
|
821
|
+
["Background" if label < 0 else f"Stage_{label}" for label in result.stage_labels]
|
|
822
|
+
)
|
|
823
|
+
adata.obs["stage_id"] = result.stage_labels.astype(np.int32)
|
|
824
|
+
if result.hierarchy is not None:
|
|
825
|
+
# One categorical per hierarchy level, so subgroup detection can be scored
|
|
826
|
+
# at every resolution the taxonomy defines rather than only at the leaves.
|
|
827
|
+
# Labels carry the full ancestry path: the within-parent index alone would
|
|
828
|
+
# collide across parents, silently merging distinct subgroups.
|
|
829
|
+
for depth in range(result.leaf_paths.shape[1]):
|
|
830
|
+
labels = []
|
|
831
|
+
for stage in result.stage_labels:
|
|
832
|
+
if stage < 0:
|
|
833
|
+
labels.append("Background")
|
|
834
|
+
else:
|
|
835
|
+
path = result.leaf_paths[stage, : depth + 1]
|
|
836
|
+
joined = "-".join(str(int(v)) for v in path)
|
|
837
|
+
labels.append(f"L{depth}_{joined}")
|
|
838
|
+
adata.obs[f"level_{depth}"] = pd.Categorical(labels)
|
|
839
|
+
|
|
840
|
+
adata.obs["batch"] = pd.Categorical([f"Batch_{g}" for g in result.batch_labels])
|
|
841
|
+
adata.obs["size_factor"] = result.size_factors.astype(np.float32)
|
|
842
|
+
adata.obs["total_counts"] = totals.ravel().astype(np.float32)
|
|
843
|
+
|
|
844
|
+
mask = result.marker_mask
|
|
845
|
+
adata.var["base_mean"] = result.gene_means.astype(np.float32)
|
|
846
|
+
adata.var["is_marker"] = mask.any(axis=0)
|
|
847
|
+
adata.var["marker_level"] = result.gene_level.astype(np.int32)
|
|
848
|
+
adata.var["n_marker_stages"] = mask.sum(axis=0).astype(np.int32)
|
|
849
|
+
adata.var["marker_stages"] = [
|
|
850
|
+
",".join(f"Stage_{k}" for k in np.flatnonzero(mask[:, j])) for j in range(mask.shape[1])
|
|
851
|
+
]
|
|
852
|
+
adata.varm["marker_mask"] = mask.T.copy()
|
|
853
|
+
|
|
854
|
+
if standardize:
|
|
855
|
+
mean = lognorm.mean(axis=0, keepdims=True)
|
|
856
|
+
std = lognorm.std(axis=0, keepdims=True)
|
|
857
|
+
std = np.where(std < 1e-12, 1.0, std) # constant genes stay all-zero
|
|
858
|
+
adata.X = ((lognorm - mean) / std).astype(np.float32)
|
|
859
|
+
|
|
860
|
+
adata.uns["simulation"] = {
|
|
861
|
+
**result.params,
|
|
862
|
+
"stage_sizes": np.asarray(result.stage_sizes, dtype=np.int64),
|
|
863
|
+
"target_sum": float(target_sum),
|
|
864
|
+
"standardize": bool(standardize),
|
|
865
|
+
"x_content": "zscore" if standardize else "lognorm",
|
|
866
|
+
}
|
|
867
|
+
return adata
|