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,412 @@
1
+ """Stability selection for boosting-based gene selection.
2
+
3
+ Implements Meinshausen & Bühlmann (2010) stability selection wrapped around
4
+ ``allboost``. The selector is re-run on many random subsamples of the cells and a
5
+ gene is called *stable* for a latent dimension when it is selected in at least a
6
+ fraction ``threshold`` of the subsamples. This turns a single, seed-dependent
7
+ gene list into a per-gene selection frequency plus a bound on the expected number
8
+ of false selections.
9
+
10
+ Following the paper, subsamples are drawn of size ``floor(subsample_frac * n)``
11
+ **without replacement** (``subsample_frac=0.5`` is the value the theory is derived
12
+ for). Bootstrap resampling is deliberately not offered: sampling with replacement
13
+ breaks the exchangeability the error bound relies on.
14
+
15
+ Reference
16
+ ---------
17
+ Meinshausen, N. & Bühlmann, P. (2010). Stability selection. *Journal of the Royal
18
+ Statistical Society: Series B*, 72(4), 417-473.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import warnings
24
+ from collections.abc import Iterable
25
+ from dataclasses import dataclass
26
+
27
+ import numpy as np
28
+ from numpy.typing import NDArray
29
+
30
+ from ._boosting import allboost
31
+
32
+ #: Boolean mask array. Named rather than spelled ``NDArray[np.bool_]`` inline:
33
+ #: the trailing underscore in ``np.bool_`` is valid Python but reads as reference
34
+ #: syntax to the documentation builder, which then reports a broken target on
35
+ #: every page that renders the annotation.
36
+ BoolArray = NDArray[np.bool_]
37
+
38
+
39
+ def _mandatory_gene_counts(
40
+ mandatory_features: NDArray[np.intp] | list[NDArray[np.intp]] | None,
41
+ latent_dim: int,
42
+ p_genes: int,
43
+ ) -> NDArray[np.float64]:
44
+ """Per-dimension count of forced features that fall in the gene columns.
45
+
46
+ Mirrors ``allboost``'s ``mandatory_features`` shapes: ``None``, a single array
47
+ applied to every target, or one array per target. Only indices ``< p_genes``
48
+ are counted (nuisance columns beyond the genes are already excluded elsewhere).
49
+ """
50
+ counts = np.zeros(latent_dim, dtype=np.float64)
51
+ if mandatory_features is None:
52
+ return counts
53
+ if isinstance(mandatory_features, list):
54
+ for j, mand in enumerate(mandatory_features):
55
+ counts[j] = int((np.asarray(mand) < p_genes).sum())
56
+ else:
57
+ counts[:] = int((np.asarray(mandatory_features) < p_genes).sum())
58
+ return counts
59
+
60
+
61
+ def _coefficient_statistics(
62
+ coef_sum: NDArray[np.float64],
63
+ coef_sq_sum: NDArray[np.float64],
64
+ positive_count: NDArray[np.float64],
65
+ counts: NDArray[np.float64],
66
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
67
+ """Conditional mean, sd and sign consistency from streaming accumulators.
68
+
69
+ All three condition on the runs in which an entry was actually selected, so an
70
+ entry selected twice out of a hundred reports the spread of those two draws
71
+ rather than being diluted by ninety-eight zeros.
72
+ """
73
+ safe = np.maximum(counts, 1.0)
74
+ cond_mean = np.divide(coef_sum, safe, out=np.zeros_like(coef_sum), where=counts > 0)
75
+ variance = np.maximum(coef_sq_sum / safe - cond_mean**2, 0.0)
76
+ cond_sd = np.where(counts > 1, np.sqrt(variance), 0.0)
77
+ modal = np.maximum(positive_count, counts - positive_count)
78
+ sign_consistency = np.divide(modal, safe, out=np.ones_like(coef_sum), where=counts > 0)
79
+ return cond_mean, cond_sd, sign_consistency
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class StabilitySelectionResult:
84
+ """Result of :func:`structboost.stability_selection`.
85
+
86
+ Attributes
87
+ ----------
88
+ frequency
89
+ Per-gene, per-dimension selection frequency, shape ``(n_genes, latent_dim)``.
90
+ Entry ``(g, j)`` is the fraction of subsamples in which gene ``g`` received
91
+ a nonzero coefficient for latent dimension ``j``.
92
+ stable_support
93
+ Boolean mask ``frequency >= threshold``, shape ``(n_genes, latent_dim)``.
94
+ threshold
95
+ The selection-frequency threshold ``pi`` used for ``stable_support``.
96
+ avg_selected
97
+ Mean number of genes selected per subsample, per dimension, shape
98
+ ``(latent_dim,)``. This is the ``q`` in the error bound below.
99
+ expected_false_positives
100
+ Meinshausen-Bühlmann upper bound on the expected number of falsely stable
101
+ genes per dimension, ``E[V] <= q^2 / ((2*threshold - 1) * p)``, shape
102
+ ``(latent_dim,)``. Here ``q`` is the average number of *competitively*
103
+ selected genes and ``p`` the number of candidate genes; forced (mandatory)
104
+ genes are excluded from both, since they are always selected by
105
+ construction and are not candidates. ``NaN`` when ``threshold <= 0.5`` (the
106
+ bound is undefined there) or when every gene is mandatory. The bound holds
107
+ under the paper's exchangeability and "no worse than random guessing"
108
+ assumptions, which real data may violate; treat it as a guideline, not a
109
+ guarantee.
110
+ n_subsamples
111
+ Number of subsamples drawn. ``0`` for ``mode="iteration"``, which draws no
112
+ subsamples.
113
+ subsample_frac
114
+ Fraction of cells in each subsample. ``NaN`` for ``mode="iteration"``.
115
+ mode
116
+ Which resampling scheme produced the frequencies.
117
+
118
+ ``"subsample"``
119
+ Meinshausen-Bühlmann cell subsampling with the model frozen. Targets
120
+ variance under *cell resampling*. ``frequency`` is per latent dimension
121
+ and ``expected_false_positives`` carries the paper's error bound.
122
+ ``"iteration"``
123
+ Selection frequency across additional *training iterations* of a single
124
+ fit. Targets variance from the optimizer's position on its loss plateau,
125
+ which is a different — and on measured data, larger — source of
126
+ instability. ``frequency`` is per latent dimension, with each
127
+ iteration's dimensions matched to the fitted model's before counting —
128
+ see ``dim_match_quality``. ``expected_false_positives`` is ``NaN``:
129
+ training iterations are neither independent nor exchangeable, so the
130
+ Meinshausen-Bühlmann bound does not apply and no error control is
131
+ claimed.
132
+ n_iterations
133
+ Number of training iterations averaged over. ``0`` for ``mode="subsample"``.
134
+ coefficient_cond_mean
135
+ Shape ``(n_genes, latent_dim)``, or ``None`` when coefficients were not
136
+ collected. Mean coefficient over the runs in which each entry was
137
+ *selected* — reliability-blind but scale-preserving, since its expectation
138
+ equals the per-run coefficient. Feeds :meth:`stable_encoder`.
139
+ coefficient_sd
140
+ Standard deviation of the same conditional distribution. This is a
141
+ **spread, not a standard error**: subsample runs share half their cells by
142
+ construction and iteration runs are autocorrelated, so the effective sample
143
+ size is far below ``n_runs`` and ``sd / sqrt(n_runs)`` would be badly
144
+ overconfident. Reporting it as a precision would require a block bootstrap.
145
+ sign_consistency
146
+ Fraction of selecting runs in which each coefficient took its modal sign.
147
+ A gene selected every time with a sign that flips is not stable, which a
148
+ selection frequency alone cannot reveal. Measured at ~0.999 on real data,
149
+ so in practice a guard rather than a headline.
150
+ dim_match_quality
151
+ ``mode="iteration"`` only: mean absolute cosine similarity between each
152
+ iteration's latent dimensions and the fitted model's, after optimal
153
+ matching. Per-dimension frequencies are only meaningful when dimensions
154
+ keep their identity across iterations, and this quantifies that rather than
155
+ assuming it. Near 1 means the anchoring held; low values mean the
156
+ per-dimension split should not be trusted and ``frequency.max(axis=1)``
157
+ (the flat union) is the safer readout. ``NaN`` for ``mode="subsample"``.
158
+ """
159
+
160
+ frequency: NDArray[np.float64]
161
+ stable_support: BoolArray
162
+ threshold: float
163
+ avg_selected: NDArray[np.float64]
164
+ expected_false_positives: NDArray[np.float64]
165
+ n_subsamples: int
166
+ subsample_frac: float
167
+ mode: str = "subsample"
168
+ n_iterations: int = 0
169
+ dim_match_quality: float = float("nan")
170
+ coefficient_cond_mean: NDArray[np.float64] | None = None
171
+ coefficient_sd: NDArray[np.float64] | None = None
172
+ sign_consistency: NDArray[np.float64] | None = None
173
+
174
+ @property
175
+ def n_runs(self) -> int:
176
+ """Number of resampling runs, whichever mode produced this result."""
177
+ return self.n_iterations if self.mode == "iteration" else self.n_subsamples
178
+
179
+ @property
180
+ def coefficient_mean(self) -> NDArray[np.float64] | None:
181
+ """Mean coefficient over *all* runs, counting unselected runs as zero.
182
+
183
+ Equals ``coefficient_cond_mean * frequency``, so a gene selected in 40% of
184
+ runs is shrunk to 40% of its typical weight — shrinkage proportional to
185
+ reliability. Statistically the more principled estimator, but it changes the
186
+ latent scale the decoder was trained against, which is why
187
+ :meth:`stable_encoder` does not offer it.
188
+ """
189
+ if self.coefficient_cond_mean is None:
190
+ return None
191
+ return self.coefficient_cond_mean * self.frequency
192
+
193
+ def stable_encoder(self) -> NDArray[np.float64]:
194
+ """Aggregate the per-run coefficients into a more reproducible encoder.
195
+
196
+ Among stably selected entries the run-to-run coefficient spread is large
197
+ (CV ~0.34 median on measured data), so the *support* of a fit is more
198
+ reproducible than its *weights*. This returns the conditional mean
199
+ coefficient restricted to the stable support: the frequency threshold
200
+ decides *which* genes, the conditional mean decides *how much*.
201
+
202
+ Measured on simulated data against exact ground truth, with the decoder
203
+ left untouched, it improves on the fitted encoder on every axis that
204
+ matters for selection:
205
+
206
+ ================== ========== ========= ======
207
+ encoder precision FDR genes
208
+ ================== ========== ========= ======
209
+ fitted 0.62 0.38 156
210
+ this **0.74** **0.26** 116
211
+ ================== ========== ========= ======
212
+
213
+ and raises reconstruction from 55% to 59% of the linear ceiling. The same
214
+ ordering holds under batch conditioning (precision 0.754 vs 0.691, FDR
215
+ 0.246 vs 0.309). Because the latent scale is preserved, the result can be
216
+ installed with :meth:`BAE.apply_encoder` **without refitting the decoder**.
217
+
218
+ To trade precision for recall, lower ``threshold`` when calling
219
+ :meth:`BAE.stability_selection` rather than reaching for a different
220
+ estimator — the threshold *is* that dial. An unmasked conditional mean is
221
+ simply this with ``threshold`` at zero, and it was measured to be a worse
222
+ selector at every setting tried (precision 0.47 unconditioned, 0.56
223
+ conditioned) as well as numerically fragile on batch-dominated fits, where
224
+ it produced a reconstruction worse than predicting zero.
225
+
226
+ Returns
227
+ -------
228
+ ndarray of shape ``(n_genes, latent_dim)``
229
+ Same orientation as ``frequency`` and ``adata.varm``.
230
+
231
+ Raises
232
+ ------
233
+ ValueError
234
+ If coefficients were not collected for this result.
235
+ """
236
+ if self.coefficient_cond_mean is None:
237
+ raise ValueError(
238
+ "coefficients were not collected for this result; "
239
+ "stable_encoder() needs a result produced by BAE.stability_selection"
240
+ )
241
+ return np.where(self.stable_support, self.coefficient_cond_mean, 0.0)
242
+
243
+
244
+ def stability_selection(
245
+ sourcemat: NDArray[np.floating],
246
+ targetmat: NDArray[np.floating],
247
+ *,
248
+ n_genes: int | None = None,
249
+ mandatory_features: NDArray[np.intp] | list[NDArray[np.intp]] | None = None,
250
+ mandatory_ridge: float | NDArray[np.floating] = 0.0,
251
+ n_subsamples: int = 100,
252
+ subsample_frac: float = 0.5,
253
+ threshold: float = 0.7,
254
+ stepno: int = 20,
255
+ nu: float = 0.1,
256
+ csf: float = 0.9,
257
+ independent: bool = True,
258
+ seed: int | None = None,
259
+ verbose: bool = False,
260
+ ) -> StabilitySelectionResult:
261
+ """Run stability selection over subsamples of ``allboost``.
262
+
263
+ Parameters
264
+ ----------
265
+ sourcemat
266
+ Predictor matrix, shape ``(n_samples, n_features)``. May include trailing
267
+ nuisance columns; only the first ``n_genes`` columns are scored.
268
+ targetmat
269
+ Target matrix, shape ``(n_samples, latent_dim)``. For BAE these are the
270
+ functional-gradient targets ``z*`` (see :meth:`BAE.stability_selection`),
271
+ not the latent codes themselves — the codes are a sparse linear function of
272
+ the already-selected genes, which makes selecting them nearly circular.
273
+ n_genes
274
+ Number of leading columns of ``sourcemat`` that are genes. Frequencies are
275
+ reported only for these. Defaults to all columns.
276
+ mandatory_features, mandatory_ridge
277
+ Forwarded to :func:`allboost`, so the resampled problem matches the one the
278
+ encoder solved (e.g. batch nuisance regressors). Mandatory columns beyond
279
+ ``n_genes`` never appear in the reported frequencies.
280
+ n_subsamples
281
+ Number of subsamples (``B``). More reduces Monte-Carlo noise in the
282
+ frequencies; cost is linear.
283
+ subsample_frac
284
+ Fraction of cells per subsample, drawn without replacement. ``0.5`` is the
285
+ value Meinshausen-Bühlmann derive the error bound for; other values give
286
+ frequencies but weaken the bound's justification.
287
+ threshold
288
+ Stability threshold ``pi`` in ``(0.5, 1]``. Genes selected in at least this
289
+ fraction of subsamples form the stable support. Must exceed ``0.5`` for the
290
+ error bound to be defined.
291
+ stepno, nu, csf, independent
292
+ Boosting hyperparameters, forwarded to :func:`allboost`. Use the same
293
+ values the encoder was fitted with.
294
+ seed
295
+ Seed for the subsampling RNG.
296
+ verbose
297
+ Show a progress bar over the subsamples. Defaults to False so that
298
+ calling this function directly stays silent;
299
+ :meth:`structboost.BAE.stability_selection` passes its own ``verbose``
300
+ through.
301
+
302
+ Returns
303
+ -------
304
+ StabilitySelectionResult
305
+ """
306
+ sourcemat = np.asarray(sourcemat, dtype=np.float64)
307
+ targetmat = np.asarray(targetmat, dtype=np.float64)
308
+ n = sourcemat.shape[0]
309
+ if targetmat.shape[0] != n:
310
+ raise ValueError(
311
+ f"sourcemat and targetmat must share n_samples, got {n} and {targetmat.shape[0]}"
312
+ )
313
+ latent_dim = targetmat.shape[1]
314
+ p_genes = sourcemat.shape[1] if n_genes is None else int(n_genes)
315
+ if not 1 <= p_genes <= sourcemat.shape[1]:
316
+ raise ValueError(f"n_genes must be in [1, {sourcemat.shape[1]}], got {p_genes}")
317
+ if n_subsamples < 1:
318
+ raise ValueError(f"n_subsamples must be >= 1, got {n_subsamples}")
319
+ if not 0.0 < subsample_frac < 1.0:
320
+ raise ValueError(f"subsample_frac must be in (0, 1), got {subsample_frac}")
321
+ if not 0.0 < threshold <= 1.0:
322
+ raise ValueError(f"threshold must be in (0, 1], got {threshold}")
323
+
324
+ sub_n = int(np.floor(subsample_frac * n))
325
+ if sub_n < 2:
326
+ raise ValueError(
327
+ f"subsample_frac={subsample_frac} gives {sub_n} cells; too few to fit. "
328
+ "Increase subsample_frac or use more cells."
329
+ )
330
+
331
+ rng = np.random.default_rng(seed)
332
+ counts = np.zeros((p_genes, latent_dim), dtype=np.float64)
333
+ selected_per_subsample = np.zeros(latent_dim, dtype=np.float64)
334
+ # Streaming accumulators: the full coefficient trace would be
335
+ # n_subsamples x p_genes x latent_dim floats, which is infeasible at scale.
336
+ # Signs need no alignment here because every subsample fits the same targets.
337
+ coef_sum = np.zeros((p_genes, latent_dim), dtype=np.float64)
338
+ coef_sq_sum = np.zeros((p_genes, latent_dim), dtype=np.float64)
339
+ positive_count = np.zeros((p_genes, latent_dim), dtype=np.float64)
340
+
341
+ # tqdm ships in the `[bae]` extra while this module is part of the NumPy-only
342
+ # core, so it is imported lazily *and* only when a bar was actually asked for.
343
+ # Importing it unconditionally would make a quiet call fail on a core install.
344
+ runs: Iterable[int] = range(n_subsamples)
345
+ if verbose:
346
+ from tqdm import tqdm
347
+
348
+ runs = tqdm(runs, desc="Stability selection (subsample)", unit="run")
349
+
350
+ for _ in runs:
351
+ idx = rng.choice(n, size=sub_n, replace=False)
352
+ # A fresh covcache per subsample is mandatory: the Gram matrix depends on
353
+ # the rows, so the full-data cache would silently produce wrong updates.
354
+ betamat = allboost(
355
+ sourcemat[idx],
356
+ targetmat[idx],
357
+ mandatory_features=mandatory_features,
358
+ mandatory_ridge=mandatory_ridge,
359
+ stepno=stepno,
360
+ nu=nu,
361
+ csf=csf,
362
+ independent=independent,
363
+ )
364
+ beta_genes = betamat[:, :p_genes].T # (n_genes, latent_dim)
365
+ selected = np.abs(betamat[:, :p_genes]) > 0 # (latent_dim, n_genes)
366
+ counts += selected.T
367
+ selected_per_subsample += selected.sum(axis=1)
368
+ coef_sum += beta_genes
369
+ coef_sq_sum += beta_genes**2
370
+ positive_count += beta_genes > 0
371
+
372
+ frequency = counts / n_subsamples
373
+ avg_selected = selected_per_subsample / n_subsamples
374
+ stable_support = frequency >= threshold
375
+
376
+ if threshold > 0.5:
377
+ # Forced (mandatory) genes are selected in every subsample by construction:
378
+ # their frequency-1.0 is not evidence of stability, and they are not
379
+ # candidates competing for selection. Exclude them from both the average
380
+ # model size q and the candidate pool p so the bound reflects the genes
381
+ # that were actually selected competitively.
382
+ n_forced = _mandatory_gene_counts(mandatory_features, latent_dim, p_genes)
383
+ q_eff = np.maximum(avg_selected - n_forced, 0.0)
384
+ p_eff = p_genes - n_forced
385
+ expected_false_positives = np.where(
386
+ p_eff > 0, q_eff**2 / ((2.0 * threshold - 1.0) * p_eff), np.nan
387
+ )
388
+ else:
389
+ warnings.warn(
390
+ "threshold <= 0.5: the Meinshausen-Bühlmann error bound is undefined, "
391
+ "so expected_false_positives is NaN. Use a threshold in (0.5, 1].",
392
+ UserWarning,
393
+ stacklevel=2,
394
+ )
395
+ expected_false_positives = np.full(latent_dim, np.nan)
396
+
397
+ cond_mean, cond_sd, sign_consistency = _coefficient_statistics(
398
+ coef_sum, coef_sq_sum, positive_count, counts
399
+ )
400
+
401
+ return StabilitySelectionResult(
402
+ frequency=frequency,
403
+ stable_support=stable_support,
404
+ threshold=float(threshold),
405
+ avg_selected=avg_selected,
406
+ expected_false_positives=expected_false_positives,
407
+ n_subsamples=int(n_subsamples),
408
+ subsample_frac=float(subsample_frac),
409
+ coefficient_cond_mean=cond_mean,
410
+ coefficient_sd=cond_sd,
411
+ sign_consistency=sign_consistency,
412
+ )