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.
@@ -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