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/_model.py ADDED
@@ -0,0 +1,3483 @@
1
+ """BAE main model class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import warnings
7
+ from dataclasses import fields, replace
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ import numpy as np
12
+ import scipy.sparse as sp
13
+ import torch
14
+ from torch import nn
15
+ from torch.utils.data import DataLoader, TensorDataset
16
+ from tqdm import tqdm
17
+
18
+ from ._boosting import allboost
19
+ from ._decoder import BAEDecoder
20
+ from ._encoder import BAEEncoder, SplitSoftmax
21
+ from ._types import BAEConfig, TrainingReport
22
+
23
+ if TYPE_CHECKING:
24
+ from anndata import AnnData
25
+
26
+ from ._types import Device
27
+
28
+ # Diagnostic series collected per iteration when `diagnostics=True`.
29
+ # "iteration" is generated at the end, not accumulated.
30
+ _REPORT_FIELDS: tuple[str, ...] = tuple(TrainingReport.__dataclass_fields__)
31
+
32
+ # Per-group reconstruction losses are reported only for obs columns with at most
33
+ # this many levels; beyond it the breakdown is per-cell noise rather than a
34
+ # summary. Columns above the limit are skipped with a warning, never silently.
35
+ _MAX_GROUP_LEVELS = 50
36
+
37
+ # The correlation method includes a small relative-variance barrier so that
38
+ # reducing correlation cannot be achieved solely by making dimensions constant.
39
+ # These are deliberately scale-relative: BAE's latent magnitude is set by
40
+ # boosting shrinkage and should not be forced to an arbitrary unit variance.
41
+ _DISENTANGLEMENT_VARIANCE_WEIGHT = 0.1
42
+ _DISENTANGLEMENT_MIN_STD_RATIO = 0.25
43
+ _DISENTANGLEMENT_RELATIVE_EPS = 1e-4
44
+
45
+
46
+ #: ``var`` columns that conventionally hold a gene identifier. Real datasets index
47
+ #: on one convention and keep the other in a column — CellRanger and scanpy put
48
+ #: symbols in ``var_names`` and Ensembl accessions in ``var["gene_ids"]``, while
49
+ #: cellxgene census does the reverse via ``feature_name``. A prior written with
50
+ #: Ensembl as the join key would otherwise fail to match a symbol-indexed dataset
51
+ #: even though both identifier sets are present on both sides.
52
+ _IDENTIFIER_COLUMNS: tuple[str, ...] = (
53
+ "gene_ids",
54
+ "gene_id",
55
+ "gene_symbols",
56
+ "gene_symbol",
57
+ "feature_name",
58
+ "feature_id",
59
+ "ensembl_id",
60
+ "symbol",
61
+ )
62
+
63
+
64
+ def _identifier_sets(adata: AnnData) -> dict[str, np.ndarray]:
65
+ """Every usable gene identifier column on an AnnData, keyed by name."""
66
+ sets: dict[str, np.ndarray] = {"var_names": np.asarray(adata.var_names, dtype=object)}
67
+ for column in _IDENTIFIER_COLUMNS:
68
+ if column in adata.var.columns:
69
+ sets[column] = np.asarray(adata.var[column], dtype=object)
70
+ return sets
71
+
72
+
73
+ def _choose_join(
74
+ prior_sets: dict[str, np.ndarray],
75
+ target_sets: dict[str, np.ndarray],
76
+ ) -> tuple[str, str, np.ndarray, np.ndarray]:
77
+ """Pick the identifier pair that actually matches, not the first one offered.
78
+
79
+ Scored by number of matched genes over every (prior, target) identifier pair.
80
+ Ties keep the earlier prior key, so the reference's own join key wins whenever
81
+ it works and the fallback only engages when it does not.
82
+ """
83
+ best: tuple[int, str, str] = (-1, "", "")
84
+ for prior_key, prior_ids in prior_sets.items():
85
+ prior_lookup = {str(v) for v in prior_ids.tolist()}
86
+ for target_key, target_ids in target_sets.items():
87
+ overlap = sum(1 for v in target_ids.tolist() if str(v) in prior_lookup)
88
+ if overlap > best[0]:
89
+ best = (overlap, prior_key, target_key)
90
+ _, prior_key, target_key = best
91
+ return prior_key, target_key, prior_sets[prior_key], target_sets[target_key]
92
+
93
+
94
+ def _resolve_prior_weights(
95
+ reference: object,
96
+ ) -> tuple[np.ndarray, dict[str, np.ndarray], dict[str, object]]:
97
+ """Extract ``(weights, identifiers, metadata)`` from any accepted prior form.
98
+
99
+ Several identifier sets may be returned — a Parquet file carries Ensembl
100
+ accessions *and* symbols, an AnnData carries ``var_names`` plus any identifier
101
+ columns — so the caller can join on whichever actually matches the target.
102
+
103
+ A bare array is refused: without gene identifiers the matrix cannot be
104
+ aligned to another dataset, and silently assuming positional correspondence
105
+ between two gene panels would produce a plausible-looking but meaningless
106
+ encoder.
107
+ """
108
+ from pathlib import Path
109
+
110
+ if isinstance(reference, (str, Path)):
111
+ from ._io import read_encoder_weights
112
+
113
+ frame = read_encoder_weights(reference)
114
+ meta = dict(frame.attrs.get("metadata", {}))
115
+ meta["source"] = str(reference)
116
+ sets = {frame.attrs.get("join_key", "gene_id"): frame.index.to_numpy(dtype=object)}
117
+ if "gene_symbol" in frame.attrs:
118
+ sets.setdefault("gene_symbol", np.asarray(frame.attrs["gene_symbol"], dtype=object))
119
+ return frame.to_numpy(dtype=np.float64), sets, meta
120
+
121
+ if isinstance(reference, BAE):
122
+ if reference._var_names is None:
123
+ raise ValueError(
124
+ "This BAE carries no gene names, so its encoder cannot be aligned to "
125
+ "another dataset. Gene names are recorded by `fit`; pass the AnnData "
126
+ "the reference was fitted on instead, or an unfitted model is not a "
127
+ "usable prior."
128
+ )
129
+ weights = reference.get_encoder_weights().astype(np.float64)
130
+ return (
131
+ weights,
132
+ {"var_names": np.asarray(reference._var_names, dtype=object)},
133
+ {"source": "BAE"},
134
+ )
135
+
136
+ # AnnData: duck-typed so this module keeps working without anndata installed.
137
+ if hasattr(reference, "varm") and hasattr(reference, "var_names"):
138
+ if "BAE_encoder_weights" not in reference.varm:
139
+ raise ValueError(
140
+ "AnnData has no varm['BAE_encoder_weights']. Fit a BAE on it first, "
141
+ "or pass the weight matrix directly."
142
+ )
143
+ return (
144
+ np.asarray(reference.varm["BAE_encoder_weights"], dtype=np.float64),
145
+ _identifier_sets(reference),
146
+ {"source": "AnnData"},
147
+ )
148
+
149
+ # DataFrame: duck-typed on the index/columns pair.
150
+ if hasattr(reference, "index") and hasattr(reference, "columns"):
151
+ return (
152
+ reference.to_numpy(dtype=np.float64),
153
+ {str(reference.index.name or "index"): np.asarray(reference.index, dtype=object)},
154
+ {"source": "DataFrame"},
155
+ )
156
+
157
+ if isinstance(reference, np.ndarray):
158
+ raise TypeError(
159
+ "A bare array cannot be used as a prior encoder matrix: it carries no "
160
+ "gene identifiers, so it cannot be aligned to the target panel. Pass a "
161
+ "gene-indexed DataFrame, an AnnData with varm['BAE_encoder_weights'], a "
162
+ "fitted BAE, or a .parquet/.csv written by write_encoder_weights."
163
+ )
164
+ raise TypeError(f"Unsupported reference type for a prior encoder matrix: {type(reference)!r}")
165
+
166
+
167
+ def _align_prior_to_panel(
168
+ weights: np.ndarray,
169
+ identifiers: np.ndarray,
170
+ target: np.ndarray,
171
+ *,
172
+ min_coverage: float,
173
+ ) -> tuple[np.ndarray, np.ndarray, int]:
174
+ """Reindex a prior weight matrix onto the target gene panel.
175
+
176
+ Genes absent from the target contribute zero, which attenuates the affected
177
+ program rather than breaking it. Per-dimension coverage — the share of each
178
+ column's absolute weight mass that survived — reports by how much, since a
179
+ program that lost half its genes is not the same program.
180
+
181
+ Returns
182
+ -------
183
+ aligned
184
+ Shape ``(len(target), latent_dim)``.
185
+ coverage
186
+ Shape ``(latent_dim,)``, retained absolute weight mass per dimension.
187
+ n_matched
188
+ How many reference genes were found in the target panel.
189
+ """
190
+ if weights.ndim != 2:
191
+ raise ValueError(f"Prior weights must be 2-D (n_genes, latent_dim), got {weights.shape}")
192
+ if weights.shape[0] != identifiers.shape[0]:
193
+ raise ValueError(
194
+ f"Prior weights has {weights.shape[0]} rows but {identifiers.shape[0]} "
195
+ "gene identifiers were supplied"
196
+ )
197
+
198
+ # First occurrence wins: duplicated gene names are common in real panels and
199
+ # a dict-based lookup keeps this total rather than raising on the duplicate.
200
+ position = {}
201
+ for index, name in enumerate(target.tolist()):
202
+ position.setdefault(str(name), index)
203
+
204
+ aligned = np.zeros((target.shape[0], weights.shape[1]), dtype=np.float64)
205
+ matched = np.zeros(identifiers.shape[0], dtype=bool)
206
+ for row, name in enumerate(identifiers.tolist()):
207
+ index = position.get(str(name))
208
+ if index is not None:
209
+ aligned[index] = weights[row]
210
+ matched[row] = True
211
+
212
+ total_mass = np.abs(weights).sum(axis=0)
213
+ kept_mass = np.abs(weights[matched]).sum(axis=0)
214
+ coverage = np.divide(kept_mass, total_mass, out=np.ones_like(total_mass), where=total_mass > 0)
215
+
216
+ if total_mass.min() == 0:
217
+ dead = np.flatnonzero(total_mass == 0).tolist()
218
+ raise ValueError(
219
+ f"Prior dimensions {dead} have no non-zero weights, so they encode nothing "
220
+ "and cannot be transferred. Drop them from the reference matrix."
221
+ )
222
+ if coverage.min() < min_coverage:
223
+ worst = int(np.argmin(coverage))
224
+ raise ValueError(
225
+ f"Only {coverage.min():.1%} of the weight mass of prior dimension {worst} "
226
+ f"is present in the target panel, below min_coverage={min_coverage:.0%}. "
227
+ f"{int(matched.sum())}/{matched.size} reference genes matched overall. "
228
+ "Identifier sets on both sides were tried automatically, so this is a "
229
+ "genuine panel difference rather than an Ensembl-vs-symbol mismatch. "
230
+ "Pass join_on= to force a particular adata.var column, or lower "
231
+ "min_coverage to accept the attenuated program."
232
+ )
233
+ if coverage.min() < 0.9:
234
+ warnings.warn(
235
+ f"Prior dimension {int(np.argmin(coverage))} retains only "
236
+ f"{coverage.min():.1%} of its weight mass on the target panel; its latent "
237
+ "values are attenuated accordingly. Per-dimension coverage is recorded in "
238
+ "adata.uns['bae_transfer']['prior_coverage'].",
239
+ UserWarning,
240
+ stacklevel=3,
241
+ )
242
+ return aligned, coverage, int(matched.sum())
243
+
244
+
245
+ def _build_allboost_mandatory(
246
+ resolved_mandatory: np.ndarray | list[np.ndarray] | None,
247
+ n_dummies: int,
248
+ n_genes: int,
249
+ ) -> np.ndarray | list[np.ndarray] | None:
250
+ """Combine gene mandatory indices with obs covariate mandatory indices."""
251
+ obs_indices = np.arange(n_genes, n_genes + n_dummies, dtype=np.intp) if n_dummies > 0 else None
252
+
253
+ if resolved_mandatory is None and obs_indices is None:
254
+ return None
255
+
256
+ if resolved_mandatory is None:
257
+ return obs_indices
258
+
259
+ if obs_indices is None:
260
+ return resolved_mandatory
261
+
262
+ # Both present: merge
263
+ if isinstance(resolved_mandatory, list):
264
+ # Per-target: append obs indices to each sub-list
265
+ return [np.concatenate([sub, obs_indices]) for sub in resolved_mandatory]
266
+ return np.concatenate([resolved_mandatory, obs_indices])
267
+
268
+
269
+ class _FitLayer:
270
+ """Sentinel: "whichever layer this model was fitted on"."""
271
+
272
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
273
+ return "<fit-time layer>"
274
+
275
+
276
+ #: Default for the ``layer`` argument of :meth:`BAE.transform` and
277
+ #: :meth:`BAE.reconstruct`. A plain ``None`` default cannot express this, because
278
+ #: ``None`` already means ``adata.X`` — which a model fitted on a layer must be
279
+ #: able to be told explicitly.
280
+ FIT_LAYER = _FitLayer()
281
+
282
+ #: Sentinel default for ``batch_integration_mode``. Behaves as ``"both"``, but is
283
+ #: distinguishable from a caller who typed ``"both"``, so that a mode given
284
+ #: without a ``batch_key`` can be reported as the mistake it is instead of
285
+ #: silently doing nothing.
286
+ _MODE_UNSET = "__unset__"
287
+
288
+ #: Which mechanisms each mode switches on. The covariate is never an encoder
289
+ #: *input*: ``"encoder"`` means it enters the boosting fit as a mandatory
290
+ #: regressor, so gene selection is not confounded by it.
291
+ _BATCH_MODES: dict[str, tuple[bool, bool]] = {
292
+ # mode: (conditions the decoder, regresses inside the boosting fit)
293
+ "none": (False, False),
294
+ "decoder": (True, False),
295
+ "encoder": (False, True),
296
+ "both": (True, True),
297
+ }
298
+
299
+
300
+ def _expression_matrix(adata: AnnData, layer: str | None):
301
+ """Return the matrix BAE should read, raw and possibly sparse.
302
+
303
+ Every read of expression data in this module goes through here, so the choice
304
+ of layer is made in exactly one place and cannot drift between ``fit`` and
305
+ the methods that consume a fitted model.
306
+ """
307
+ if layer is None:
308
+ return adata.X
309
+ if layer not in adata.layers:
310
+ # anndata >= 0.13 exposes ``.X`` as ``layers[None]``, so iterating the
311
+ # mapping yields a ``None`` key alongside the real names. Sorting that
312
+ # mixed list raises TypeError and buries this KeyError, turning a clear
313
+ # "no such layer" message into a comparison error from the error path.
314
+ available = sorted(name for name in adata.layers if name is not None)
315
+ raise KeyError(f"adata has no layer {layer!r}; available layers: {available}")
316
+ return adata.layers[layer]
317
+
318
+
319
+ def _torch_rng_state() -> tuple[torch.Tensor, list[torch.Tensor]]:
320
+ """Snapshot the global torch RNG state, CPU and every visible CUDA device."""
321
+ cuda_states = (
322
+ [torch.cuda.get_rng_state(i) for i in range(torch.cuda.device_count())]
323
+ if torch.cuda.is_available()
324
+ else []
325
+ )
326
+ return torch.get_rng_state(), cuda_states
327
+
328
+
329
+ def _restore_torch_rng_state(state: tuple[torch.Tensor, list[torch.Tensor]]) -> None:
330
+ """Put back a state captured by :func:`_torch_rng_state`."""
331
+ cpu_state, cuda_states = state
332
+ torch.set_rng_state(cpu_state)
333
+ for device, device_state in enumerate(cuda_states):
334
+ torch.cuda.set_rng_state(device_state, device)
335
+
336
+
337
+ def _isolates_torch_rng(*, config_fallback: bool = False):
338
+ """Restore the caller's global torch RNG state around a *seeded* call.
339
+
340
+ Seeding is what makes a BAE fit reproducible, and it necessarily goes through
341
+ the global torch generator: the decoder's ``reset_parameters`` and the
342
+ ``DataLoader``'s shuffling both draw from it, and neither accepts a local
343
+ generator without changing the draws. Left alone, that seeding leaks — a
344
+ caller who fits a model finds their own ``torch`` stream reset underneath
345
+ them. This wrapper puts the stream back afterwards, so the isolation is
346
+ invisible in the fitted model and every seed keeps producing exactly the
347
+ results it did before.
348
+
349
+ Deliberately inert when no seed is in effect. An unseeded call does not seed
350
+ either; restoring state around it would make two successive unseeded fits on
351
+ the same model return *identical* results, quietly removing the variability
352
+ an unseeded fit is supposed to have.
353
+
354
+ Parameters
355
+ ----------
356
+ config_fallback
357
+ Whether an absent ``seed`` argument falls back to ``self.config.seed``.
358
+ True for :meth:`BAE.fit`, which applies that fallback itself; False for
359
+ :meth:`BAE._iteration_support_frequency`, whose seed comes only from
360
+ :meth:`BAE.stability_selection` and never from the config.
361
+ """
362
+
363
+ def decorate(method):
364
+ @functools.wraps(method)
365
+ def wrapper(self, *args, **kwargs):
366
+ seed = kwargs.get("seed")
367
+ if seed is None and config_fallback:
368
+ seed = self.config.seed
369
+ if seed is None:
370
+ return method(self, *args, **kwargs)
371
+ state = _torch_rng_state()
372
+ try:
373
+ return method(self, *args, **kwargs)
374
+ finally:
375
+ _restore_torch_rng_state(state)
376
+
377
+ return wrapper
378
+
379
+ return decorate
380
+
381
+
382
+ def _mandatory_genes_for_uns(spec: object) -> np.ndarray | dict[str, np.ndarray]:
383
+ """Render a ``mandatory_genes`` specification into an h5ad-writable value.
384
+
385
+ A per-dimension specification is a list of lists, and the sub-lists need not
386
+ be the same length. Storing that raw makes the whole ``AnnData`` unwritable:
387
+ ``adata.write_h5ad`` tries to build one array from it and raises
388
+ ``ValueError: setting an array element with a sequence``. Equal-length
389
+ sub-lists happen to coerce to a 2-D array and survive, so the failure only
390
+ appears once a user asks for different marker counts per dimension.
391
+
392
+ Per-dimension specifications therefore become a dict of one array per
393
+ dimension, keyed ``dim_0``, ``dim_1``, ... — the same key convention the
394
+ encoder weight files use (:mod:`structboost._io`). A flat specification stays
395
+ a single array, which is what it already round-tripped as.
396
+ """
397
+ is_per_dimension = (
398
+ isinstance(spec, (list, tuple))
399
+ and len(spec) > 0
400
+ and all(isinstance(sub, (list, tuple, np.ndarray)) for sub in spec)
401
+ )
402
+ if is_per_dimension:
403
+ return {f"dim_{i}": np.asarray(sub) for i, sub in enumerate(spec)}
404
+ return np.asarray(spec)
405
+
406
+
407
+ class BAE(nn.Module):
408
+ """Boosting Autoencoder for interpretable dimensionality reduction.
409
+
410
+ Combines a linear encoder (optimized via componentwise boosting) with an
411
+ MLP decoder (optimized via SGD). The hybrid training procedure alternates
412
+ between boosting-based encoder updates and gradient-based decoder updates.
413
+
414
+ Parameters
415
+ ----------
416
+ n_genes
417
+ Number of input genes.
418
+ config
419
+ BAE configuration. If None, uses defaults.
420
+
421
+ Examples
422
+ --------
423
+ >>> import anndata as ad
424
+ >>> adata = ad.read_h5ad("data.h5ad")
425
+ >>> model = BAE(adata.n_vars)
426
+ >>> model.fit(adata) # Stores results in adata.obsm["X_bae"]
427
+ >>> latent = adata.obsm["X_bae"]
428
+ """
429
+
430
+ def __init__(self, n_genes: int, config: BAEConfig | None = None) -> None:
431
+ super().__init__()
432
+ self.config = config or BAEConfig()
433
+ self.n_genes = n_genes
434
+
435
+ # Build components
436
+ self.encoder = BAEEncoder(n_genes, self.config)
437
+ self.split_softmax_layer = SplitSoftmax() if self.config.split_softmax else None
438
+ decoder_input_dim = 2 * self.config.latent_dim if self.config.split_softmax else None
439
+ self.decoder = BAEDecoder(n_genes, self.config, input_dim_override=decoder_input_dim)
440
+
441
+ # State
442
+ self._is_fitted = False
443
+ self._training_history: dict[str, list[float]] = {
444
+ "train_loss": [],
445
+ "selection_loss": [],
446
+ }
447
+ self._training_report: TrainingReport | None = None
448
+ self._latent_init: dict[str, str | int] = {"method": "zero", "pretrain_epochs": 0}
449
+ #: One encoding drives both mechanisms. Which of them are active is
450
+ #: recorded by `_batch_integration_mode` rather than by two attributes.
451
+ self._batch_encoding = None
452
+ self._batch_integration_mode: str = "none"
453
+ self._batch_weights: np.ndarray | None = None
454
+ self._balance_obs: str | None = None
455
+ self._mandatory_genes = None
456
+ #: Layer `fit` read expression from; None means ``adata.X``. Later calls
457
+ #: default to it so a model always reads the representation it learned on.
458
+ self._layer: str | None = None
459
+ self._var_names: np.ndarray | None = None
460
+ # Transfer state: set by `from_reference`, None for an ordinary model.
461
+ self._prior_weights: np.ndarray | None = None
462
+ self._prior_info: dict[str, object] = {}
463
+ self._latent_scaling: dict[str, np.ndarray] | None = None
464
+
465
+ # Move to device
466
+ self.to(self.config.device)
467
+
468
+ @classmethod
469
+ def from_reference(
470
+ cls,
471
+ reference: object,
472
+ adata: AnnData,
473
+ *,
474
+ n_additional_dims: int = 5,
475
+ prior_mode: Literal["frozen", "anchored"] | None = None,
476
+ min_coverage: float = 0.5,
477
+ join_on: str | None = None,
478
+ config: BAEConfig | None = None,
479
+ ) -> BAE:
480
+ """Build a model that carries a reference encoder matrix onto new data.
481
+
482
+ The transferable product of a BAE fit is its encoder weight matrix: k0
483
+ sparse gene programs. This constructor aligns such a matrix to ``adata``'s
484
+ gene panel, places it in the first k0 latent dimensions, and appends
485
+ ``n_additional_dims`` zero-initialized dimensions for variance the prior
486
+ programs cannot explain.
487
+
488
+ Fitting then runs in two phases. The decoder is first trained against the
489
+ prior programs alone (``fit(..., decoder_warmup_epochs=...)``), which is
490
+ what makes the added dimensions *residual*: the boosting target is
491
+ ``z* = z - lr * dL/dz``, so until the decoder has converged against the
492
+ prior programs the gradient still carries signal those programs could
493
+ explain, and the new dimensions would merely re-learn them. Boosting then
494
+ starts, restricted to the new dimensions or anchored on the prior ones
495
+ according to ``prior_mode``.
496
+
497
+ Parameters
498
+ ----------
499
+ reference
500
+ The prior encoder matrix. Accepts a fitted :class:`BAE`, an
501
+ :class:`~anndata.AnnData` carrying ``varm["BAE_encoder_weights"]``, a
502
+ gene-indexed :class:`pandas.DataFrame`, or a path to a ``.parquet`` /
503
+ ``.csv`` written by :func:`~structboost.write_encoder_weights`. A bare
504
+ array is rejected: it carries no gene identifiers, so aligning it to
505
+ another panel would be guesswork.
506
+ adata
507
+ Target dataset. Required here rather than at ``fit`` time because the
508
+ encoder's shape depends on its gene panel, and because a coverage
509
+ failure should surface before any training happens.
510
+ n_additional_dims
511
+ Number of new latent dimensions, default 5. ``0`` is valid and useful:
512
+ it adapts the decoder (and, under ``"anchored"``, the programs) to the
513
+ new data without adding capacity.
514
+
515
+ This is a user choice, and the default is a pragmatic starting point
516
+ rather than one derived from the data — how much residual structure a
517
+ dataset holds is not knowable in advance. Erring high is the cheaper
518
+ mistake under ``prior_mode="frozen"``, where the transferred programs
519
+ stay bitwise fixed no matter how many dimensions are added, and each
520
+ novel column's gene set can be read or ignored independently without
521
+ refitting. Too small a value silently misses structure instead.
522
+
523
+ To check the choice after fitting, read
524
+ ``novel_variance_share_per_dim`` and the novel dimensions' stability:
525
+ a dimension contributing almost nothing is surplus. Evaluate on
526
+ held-out cells via :meth:`transfer_diagnostics` — in-sample every
527
+ dimension appears to contribute, because free dimensions always reduce
528
+ training error.
529
+ prior_mode
530
+ ``"frozen"`` (default) or ``"anchored"``; see :class:`BAEConfig`.
531
+ Overrides ``config.prior_mode`` when given.
532
+ min_coverage
533
+ Minimum share of a prior dimension's absolute weight mass that must be
534
+ present in ``adata``. Below this the transfer raises rather than
535
+ silently returning an attenuated program.
536
+ join_on
537
+ Column of ``adata.var`` to align on. By default every identifier set
538
+ available on both sides is tried and the one matching the most genes
539
+ wins — a reference keyed on Ensembl accessions therefore aligns to a
540
+ symbol-indexed dataset without intervention, provided either side
541
+ carries the other convention (CellRanger and scanpy put symbols in
542
+ ``var_names`` and accessions in ``var["gene_ids"]``). The pair used is
543
+ recorded in ``adata.uns["bae_transfer"]["join_key"]``. Set this
544
+ explicitly when a dataset carries several identifier columns and the
545
+ automatic choice must not be trusted.
546
+ config
547
+ Base configuration. ``latent_dim`` is overwritten with
548
+ ``k0 + n_additional_dims``, since the layout is determined by the
549
+ reference matrix rather than chosen. Defaults to a fresh
550
+ :class:`BAEConfig` and is **not** inherited from the reference: the
551
+ reference's ``seed``, ``max_iterations`` and stopping rule describe how
552
+ that model was fitted, not how this one should be. Pass the reference's
553
+ config explicitly to reuse its boosting hyperparameters.
554
+
555
+ Returns
556
+ -------
557
+ An unfitted model with the aligned prior installed.
558
+ """
559
+ if n_additional_dims < 0:
560
+ raise ValueError(f"n_additional_dims must be >= 0, got {n_additional_dims}")
561
+ if not 0.0 < min_coverage <= 1.0:
562
+ raise ValueError(f"min_coverage must be in (0, 1], got {min_coverage}")
563
+
564
+ weights, prior_sets, info = _resolve_prior_weights(reference)
565
+ if join_on is not None:
566
+ if join_on not in adata.var.columns:
567
+ raise ValueError(
568
+ f"join_on={join_on!r} is not a column of adata.var. Available: "
569
+ f"{list(adata.var.columns)}"
570
+ )
571
+ target_sets = {join_on: np.asarray(adata.var[join_on], dtype=object)}
572
+ else:
573
+ target_sets = _identifier_sets(adata)
574
+ prior_key, target_key, identifiers, target = _choose_join(prior_sets, target_sets)
575
+ info["join_key"] = f"{prior_key}->{target_key}"
576
+ aligned, coverage, n_matched = _align_prior_to_panel(
577
+ weights, identifiers, target, min_coverage=min_coverage
578
+ )
579
+
580
+ n_prior = aligned.shape[1]
581
+ latent_dim = n_prior + n_additional_dims
582
+ if config is not None and config.latent_dim != latent_dim:
583
+ # Mirrors the warm-start path, which warns for the same reason: a
584
+ # silently overridden latent_dim is a model of a different size than
585
+ # the caller asked for.
586
+ warnings.warn(
587
+ f"latent_dim was changed from {config.latent_dim} to {latent_dim} to "
588
+ f"match the reference matrix: {n_prior} prior dimensions plus "
589
+ f"n_additional_dims={n_additional_dims}. The layout is determined by "
590
+ "the reference, so latent_dim is not independently settable here.",
591
+ UserWarning,
592
+ stacklevel=2,
593
+ )
594
+ prior_mode_kw = {"prior_mode": prior_mode} if prior_mode is not None else {}
595
+ resolved = replace(config or BAEConfig(), latent_dim=latent_dim, **prior_mode_kw)
596
+ if config is None and isinstance(reference, BAE):
597
+ # Only `latent_dim` is carried over from the prior, so the call reads
598
+ # as "the reference plus k dimensions" while every other setting
599
+ # silently reverts to `BAEConfig()`. A reference tuned to
600
+ # `max_iterations=200` transfers at 1000 with nothing to show for it.
601
+ #
602
+ # Inheriting instead was rejected: a Path or array reference carries
603
+ # no config, so the same prior would behave differently depending on
604
+ # whether it was passed as a model or as its exported weights.
605
+ #
606
+ # Diffing two `replace` results rather than the raw configs drops
607
+ # `latent_dim` and `prior_mode` on its own, since both sides force
608
+ # them identically. No exclusion list to keep in sync.
609
+ inherited = replace(reference.config, latent_dim=latent_dim, **prior_mode_kw)
610
+ differing = [
611
+ (f.name, getattr(inherited, f.name), getattr(resolved, f.name))
612
+ for f in fields(BAEConfig)
613
+ if getattr(inherited, f.name) != getattr(resolved, f.name)
614
+ ]
615
+ if differing:
616
+ changes = ", ".join(f"{name} {was!r} -> {now!r}" for name, was, now in differing)
617
+ warnings.warn(
618
+ f"from_reference used BAEConfig defaults, not the reference's: {changes}.",
619
+ UserWarning,
620
+ stacklevel=2,
621
+ )
622
+ model = cls(adata.n_vars, resolved)
623
+ model._prior_weights = aligned
624
+ model._prior_info = {
625
+ **info,
626
+ "n_prior_dims": n_prior,
627
+ "n_additional_dims": int(n_additional_dims),
628
+ "prior_coverage": coverage.tolist(),
629
+ "n_reference_genes": int(weights.shape[0]),
630
+ "n_matched_genes": n_matched,
631
+ "min_coverage": float(min_coverage),
632
+ }
633
+ return model
634
+
635
+ def save(self, path: str | Path) -> Path:
636
+ """Write the fitted model to a single checkpoint file.
637
+
638
+ The checkpoint holds everything needed to reproduce :meth:`transform`
639
+ and :meth:`reconstruct`, to use the model as a ``reference`` for
640
+ :meth:`from_reference`, and to read back ``training_history`` /
641
+ ``training_report``.
642
+
643
+ Two things are deliberately excluded. The decoder's optimizer state is
644
+ not written, so a loaded model is deployable but cannot resume a
645
+ training run mid-flight — a fresh :meth:`fit` still works, since it
646
+ rebuilds the decoder and optimizer regardless. And the training-set
647
+ covariate design matrix (``ObsCovariateEncoding.encoded``) is not
648
+ written, so a shipped model file carries no cell-level training data and
649
+ its size does not grow with the training set; only the encoding
650
+ *parameters* needed to encode new data are kept.
651
+
652
+ The payload contains only tensors and plain Python values, which is what
653
+ lets :meth:`load` read it with ``weights_only=True``: loading a
654
+ structboost checkpoint cannot execute code from the file.
655
+
656
+ Parameters
657
+ ----------
658
+ path
659
+ Destination file. ``.pt`` is the conventional suffix. Parent
660
+ directories are created if needed.
661
+
662
+ Returns
663
+ -------
664
+ The path written.
665
+
666
+ See Also
667
+ --------
668
+ BAE.load : Read a checkpoint back.
669
+ structboost.write_encoder_weights : Persist only the gene programs, in a
670
+ readable, shareable format.
671
+
672
+ Raises
673
+ ------
674
+ RuntimeError
675
+ If the model has not been fitted.
676
+ ValueError
677
+ If a conditioned fit used an obs column whose categorical levels
678
+ cannot be persisted (for example datetimes). Converting the column
679
+ to string, integer or boolean before fitting resolves it.
680
+
681
+ Examples
682
+ --------
683
+ >>> model.fit(adata) # doctest: +SKIP
684
+ >>> model.save("bae_model.pt") # doctest: +SKIP
685
+ """
686
+ from ._persistence import build_payload
687
+
688
+ if not self._is_fitted:
689
+ raise RuntimeError(
690
+ "Model not fitted; there is nothing to save. Call fit() first, or use "
691
+ "write_encoder_weights() to persist the prior encoder matrix of a "
692
+ "from_reference() model."
693
+ )
694
+ destination = Path(path)
695
+ destination.parent.mkdir(parents=True, exist_ok=True)
696
+ torch.save(build_payload(self), destination)
697
+ return destination
698
+
699
+ @classmethod
700
+ def load(cls, path: str | Path, *, device: Device | None = None) -> BAE:
701
+ """Load a model written by :meth:`save`.
702
+
703
+ Parameters
704
+ ----------
705
+ path
706
+ Checkpoint file.
707
+ device
708
+ Device to place the model on. If None, the device recorded at save
709
+ time is used when it is available, and CPU otherwise — the fallback
710
+ warns rather than relocating silently.
711
+
712
+ Returns
713
+ -------
714
+ The restored model, ready for :meth:`transform` and :meth:`reconstruct`.
715
+
716
+ Raises
717
+ ------
718
+ FileNotFoundError
719
+ If `path` does not exist.
720
+ ValueError
721
+ If the file is not a structboost checkpoint, or was written by a
722
+ newer checkpoint format than this version understands.
723
+
724
+ Examples
725
+ --------
726
+ >>> model = BAE.load("bae_model.pt") # doctest: +SKIP
727
+ >>> latent = model.transform(adata) # doctest: +SKIP
728
+ """
729
+ from ._persistence import (
730
+ CHECKPOINT_FORMAT,
731
+ MAGIC,
732
+ MIN_CHECKPOINT_FORMAT,
733
+ restore_payload,
734
+ )
735
+
736
+ source = Path(path)
737
+ if not source.exists():
738
+ raise FileNotFoundError(f"No BAE checkpoint at {source}")
739
+
740
+ # Always read onto CPU first: the file may name a device this machine
741
+ # does not have, and `restore_payload` moves the model afterwards.
742
+ payload = torch.load(source, map_location="cpu", weights_only=True)
743
+
744
+ if not isinstance(payload, dict) or payload.get("magic") != MAGIC:
745
+ raise ValueError(f"{source} is not a structboost BAE checkpoint (written by BAE.save).")
746
+ written_format = int(payload.get("format_version", 0))
747
+ if written_format > CHECKPOINT_FORMAT:
748
+ raise ValueError(
749
+ f"{source} uses checkpoint format {written_format}, written by structboost "
750
+ f"{payload.get('structboost_version', 'unknown')}; this install understands "
751
+ f"up to {CHECKPOINT_FORMAT}. Upgrade structboost to load it."
752
+ )
753
+ if written_format < MIN_CHECKPOINT_FORMAT:
754
+ raise ValueError(
755
+ f"{source} uses checkpoint format {written_format}, which predates the first "
756
+ f"public release; this install reads format {MIN_CHECKPOINT_FORMAT} and newer. "
757
+ "Refit the model to write a current checkpoint."
758
+ )
759
+ resolved = cls._resolve_load_device(device, source, payload)
760
+ return restore_payload(cls, payload, resolved)
761
+
762
+ @staticmethod
763
+ def _resolve_load_device(device: Device | None, source: Path, payload: dict) -> torch.device:
764
+ """Pick the device for a load, warning when the saved one is unavailable."""
765
+ if device is not None:
766
+ return torch.device(device)
767
+ requested = torch.device(payload.get("config", {}).get("device", "cpu"))
768
+ mps = getattr(torch.backends, "mps", None)
769
+ available = {
770
+ "cuda": torch.cuda.is_available(),
771
+ "mps": bool(mps is not None and mps.is_available()),
772
+ }
773
+ if not available.get(requested.type, True):
774
+ warnings.warn(
775
+ f"{source} was saved on {requested}, which is not available here; "
776
+ "loading onto CPU instead. Pass device= to choose explicitly.",
777
+ UserWarning,
778
+ stacklevel=3,
779
+ )
780
+ return torch.device("cpu")
781
+ return requested
782
+
783
+ def _warn_on_panel_mismatch(self, adata: AnnData, method: str) -> None:
784
+ """Warn when `adata`'s gene panel differs from the one fitted on.
785
+
786
+ The encoder is a plain matrix product against gene columns in a fixed
787
+ order, so a panel of the same width in a different order produces
788
+ plausible, wrong numbers rather than an error. That was hard to trigger
789
+ while a fitted model lived only in the session that produced it; a model
790
+ loaded from disk months later makes it easy.
791
+ """
792
+ if self._var_names is None:
793
+ return
794
+ incoming = np.asarray(adata.var_names, dtype=object)
795
+ if incoming.shape == self._var_names.shape and np.array_equal(incoming, self._var_names):
796
+ return
797
+ if incoming.shape != self._var_names.shape:
798
+ detail = f"{incoming.size} genes against {self._var_names.size} at fit time"
799
+ else:
800
+ positions = np.flatnonzero(incoming != self._var_names)
801
+ shown = ", ".join(
802
+ f"{int(i)}: {self._var_names[i]!r}->{incoming[i]!r}" for i in positions[:3]
803
+ )
804
+ more = "" if positions.size <= 3 else f" (+{positions.size - 3} more)"
805
+ detail = f"{positions.size} positions differ ({shown}{more})"
806
+ warnings.warn(
807
+ f"BAE.{method} received a gene panel that does not match the fitted one: "
808
+ f"{detail}. The encoder maps gene columns by position, so the result is "
809
+ "only meaningful if the panel is identical. Use BAE.from_reference to align "
810
+ "a model to a different panel.",
811
+ UserWarning,
812
+ stacklevel=3,
813
+ )
814
+
815
+ def _transfer_boosting_inputs(
816
+ self,
817
+ targets: np.ndarray,
818
+ n_features: int,
819
+ prior_dims: int,
820
+ mandatory: np.ndarray | list[np.ndarray] | None,
821
+ ) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | list[np.ndarray] | None]:
822
+ """Boosting inputs for one iteration: targets, offset, mandatory spec.
823
+
824
+ ``"frozen"`` withholds the prior columns from boosting entirely.
825
+ ``"anchored"`` boosts them, but from the fixed original matrix as a
826
+ boosting offset, so the fitted correction is bounded by ``boosting_stepno``
827
+ and is discarded at the start of the next iteration rather than compounding.
828
+
829
+ A per-dimension ``mandatory`` list is sliced to match the columns actually
830
+ boosted; ``allboost`` requires one entry per target.
831
+ """
832
+ if self._prior_weights is None:
833
+ return targets, None, mandatory
834
+ if self.config.prior_mode == "frozen":
835
+ sliced = mandatory[prior_dims:] if isinstance(mandatory, list) else mandatory
836
+ return targets[:, prior_dims:], None, sliced
837
+ beta_init = np.zeros((self.config.latent_dim, n_features), dtype=np.float64)
838
+ beta_init[:prior_dims, : self.n_genes] = self._prior_weights.T
839
+ return targets, beta_init, mandatory
840
+
841
+ def _expand_transfer_betamat(
842
+ self, betamat: np.ndarray, n_features: int, prior_dims: int
843
+ ) -> np.ndarray:
844
+ """Restore the full ``(latent_dim, n_features)`` layout after frozen boosting.
845
+
846
+ Nuisance coefficients stay zero for the prior rows: those dimensions were
847
+ never fitted this iteration, so there is no nuisance estimate to report for
848
+ them.
849
+ """
850
+ if self._prior_weights is None or self.config.prior_mode != "frozen":
851
+ return betamat
852
+ full = np.zeros((self.config.latent_dim, n_features), dtype=np.float64)
853
+ full[:prior_dims, : self.n_genes] = self._prior_weights.T
854
+ full[prior_dims:] = betamat
855
+ return full
856
+
857
+ def _validate_transfer_fit(
858
+ self, adata: AnnData, *, init_obsm: str | None, init_pca: bool
859
+ ) -> None:
860
+ """Reject fit settings whose semantics conflict with a prior encoder matrix."""
861
+ if adata.n_vars != self.n_genes:
862
+ raise ValueError(
863
+ f"This model was aligned to a {self.n_genes}-gene panel by "
864
+ f"from_reference, but adata has {adata.n_vars} genes. Rebuild with "
865
+ "from_reference against the dataset you intend to fit."
866
+ )
867
+ if self.config.split_softmax:
868
+ raise ValueError(
869
+ "split_softmax is not supported with a prior encoder matrix. It "
870
+ "applies one softmax across all 2*latent_dim entries, so adding "
871
+ "dimensions dilutes every prior entry by a data-dependent amount — a "
872
+ "frozen matrix would no longer mean the prior programs act unchanged."
873
+ )
874
+ if self.config.standardize_targets and self.config.prior_mode == "anchored":
875
+ raise ValueError(
876
+ "standardize_targets is not supported with prior_mode='anchored'. "
877
+ "Rescaling each target column to unit variance puts the fitted "
878
+ "coefficients on a per-iteration scale that the fixed anchor does not "
879
+ "share. Use prior_mode='frozen', or standardize_targets=False."
880
+ )
881
+ if init_pca or init_obsm is not None:
882
+ raise ValueError(
883
+ "init_pca/init_obsm cannot be combined with a prior encoder matrix: "
884
+ "they override the latent state on the first boosting iteration, which "
885
+ "is exactly where the prior programs define the target. Use "
886
+ "decoder_warmup_epochs to settle the decoder against the prior instead."
887
+ )
888
+
889
+ @property
890
+ def prior_weights(self) -> np.ndarray | None:
891
+ """The *anchor*: aligned reference matrix ``(n_genes, k0)``, or None.
892
+
893
+ This is what was transferred in, not what was fitted. Under
894
+ ``prior_mode="frozen"`` the two are identical; under ``"anchored"`` compare
895
+ against :attr:`fitted_prior_weights` to see how far the data moved the
896
+ programs.
897
+ """
898
+ return None if self._prior_weights is None else self._prior_weights.copy()
899
+
900
+ @property
901
+ def n_prior_dims(self) -> int:
902
+ """Number of transferred dimensions; ``0`` for an ordinary model."""
903
+ return 0 if self._prior_weights is None else int(self._prior_weights.shape[1])
904
+
905
+ @property
906
+ def fitted_prior_weights(self) -> np.ndarray | None:
907
+ """Fitted transferred block ``(n_genes, k0)``, or None if not a transfer."""
908
+ if self._prior_weights is None:
909
+ return None
910
+ return self.get_encoder_weights()[:, : self.n_prior_dims]
911
+
912
+ @property
913
+ def novel_weights(self) -> np.ndarray | None:
914
+ """Fitted novel block ``(n_genes, k1)``, or None if not a transfer.
915
+
916
+ These are the gene programs the transfer *added* — the residual structure
917
+ the reference matrix could not represent. Empty (``k1 == 0``) when the
918
+ model was built for decoder-only adaptation.
919
+ """
920
+ if self._prior_weights is None:
921
+ return None
922
+ return self.get_encoder_weights()[:, self.n_prior_dims :]
923
+
924
+ @property
925
+ def training_history(self) -> dict[str, list[float]]:
926
+ """Per-iteration reconstruction and checkpoint-selection losses."""
927
+ return self._training_history
928
+
929
+ @property
930
+ def training_report(self) -> TrainingReport | None:
931
+ """Per-iteration diagnostics, or None if ``diagnostics`` was off."""
932
+ return self._training_report
933
+
934
+ def forward(
935
+ self,
936
+ x: torch.Tensor,
937
+ obs_covariates: torch.Tensor | None = None,
938
+ ) -> tuple[torch.Tensor, torch.Tensor]:
939
+ """Forward pass: encode then decode.
940
+
941
+ Parameters
942
+ ----------
943
+ x
944
+ Input tensor of shape (n_cells, n_genes).
945
+ obs_covariates
946
+ Optional obs covariate tensor for cVAE conditioning.
947
+
948
+ Returns
949
+ -------
950
+ x_recon
951
+ Reconstructed input.
952
+ z
953
+ Latent representation.
954
+ """
955
+ z = self.encoder(x)
956
+ h = self.split_softmax_layer(z) if self.split_softmax_layer else z
957
+ x_recon = self.decoder(h, obs_covariates)
958
+ return x_recon, z
959
+
960
+ def get_latent(self, x: torch.Tensor) -> torch.Tensor:
961
+ """Get latent representation.
962
+
963
+ Parameters
964
+ ----------
965
+ x
966
+ Input tensor of shape (n_cells, n_genes).
967
+
968
+ Returns
969
+ -------
970
+ Latent representation of shape (n_cells, latent_dim).
971
+ """
972
+ self.eval()
973
+ with torch.no_grad():
974
+ return self.encoder(x)
975
+
976
+ # --- Hybrid Training Helpers ---
977
+
978
+ @staticmethod
979
+ def _recon_loss(
980
+ x_recon: torch.Tensor,
981
+ x: torch.Tensor,
982
+ sample_weights: torch.Tensor | None = None,
983
+ ) -> torch.Tensor:
984
+ """Reconstruction MSE, optionally weighted per cell.
985
+
986
+ Without weights this is a single fused reduction over all elements. That
987
+ matters beyond speed: reducing per cell and then averaging reassociates the
988
+ float32 sum and shifts results by ~1e-7 relative against releases that
989
+ predate per-cell weighting, for every user who never asked for weights.
990
+ The per-cell path is taken only when weights are actually supplied.
991
+ """
992
+ if sample_weights is None:
993
+ return nn.functional.mse_loss(x_recon, x)
994
+ per_cell = (x_recon - x).square().mean(dim=1)
995
+ return (per_cell * sample_weights).sum() / sample_weights.sum()
996
+
997
+ @staticmethod
998
+ def _correlation_disentanglement_loss(
999
+ z: torch.Tensor,
1000
+ sample_weights: torch.Tensor | None = None,
1001
+ ) -> tuple[torch.Tensor, torch.Tensor]:
1002
+ """Squared latent correlation and a relative anti-collapse barrier.
1003
+
1004
+ The first term is the mean squared off-diagonal correlation. Unlike a
1005
+ covariance penalty, it cannot be reduced merely by shrinking every latent
1006
+ dimension and letting the decoder compensate with larger weights. The
1007
+ second term discourages an individual dimension from becoming nearly
1008
+ constant relative to the mean latent standard deviation, without imposing
1009
+ an arbitrary unit-variance scale.
1010
+
1011
+ This adapts the covariance-reduction and variance-preservation principles
1012
+ of DeCov [1]_ and VICReg [2]_ to BAE's deterministic latent codes.
1013
+
1014
+ Parameters
1015
+ ----------
1016
+ z
1017
+ Latent codes of shape ``(n_cells, latent_dim)``.
1018
+ sample_weights
1019
+ Optional non-negative cell weights. When supplied, the weighted mean
1020
+ and covariance describe the same balanced pseudo-population used by
1021
+ the reconstruction target loss.
1022
+
1023
+ Returns
1024
+ -------
1025
+ correlation_loss
1026
+ Mean squared off-diagonal latent correlation.
1027
+ variance_loss
1028
+ Relative minimum-variance barrier.
1029
+
1030
+ References
1031
+ ----------
1032
+ .. [1] Cogswell et al. (2016), "Reducing Overfitting in Deep Networks by
1033
+ Decorrelating Representations", arXiv:1511.06068.
1034
+ .. [2] Bardes, Ponce & LeCun (2022), "VICReg: Variance-Invariance-
1035
+ Covariance Regularization for Self-Supervised Learning", ICLR 2022.
1036
+ """
1037
+ if z.ndim != 2:
1038
+ raise ValueError(f"z must be 2-D, got shape {tuple(z.shape)}")
1039
+
1040
+ if sample_weights is None:
1041
+ centered = z - z.mean(dim=0, keepdim=True)
1042
+ covariance = centered.T @ centered / z.shape[0]
1043
+ else:
1044
+ if sample_weights.ndim != 1 or sample_weights.shape[0] != z.shape[0]:
1045
+ raise ValueError(
1046
+ f"sample_weights must have shape (n_cells,), got {tuple(sample_weights.shape)}"
1047
+ )
1048
+ weight_sum = sample_weights.sum()
1049
+ mean = (z * sample_weights[:, None]).sum(dim=0, keepdim=True) / weight_sum
1050
+ centered = z - mean
1051
+ covariance = centered.T @ (centered * sample_weights[:, None]) / weight_sum
1052
+
1053
+ variances = torch.diagonal(covariance)
1054
+ relative_eps = variances.mean().detach() * _DISENTANGLEMENT_RELATIVE_EPS + z.new_tensor(
1055
+ 1e-12
1056
+ )
1057
+ denominator = torch.sqrt(
1058
+ (variances[:, None] + relative_eps) * (variances[None, :] + relative_eps)
1059
+ )
1060
+ correlation = covariance / denominator
1061
+ off_diagonal = correlation - torch.diag_embed(torch.diagonal(correlation))
1062
+ n_pairs = max(z.shape[1] * (z.shape[1] - 1), 1)
1063
+ correlation_loss = off_diagonal.square().sum() / n_pairs
1064
+
1065
+ std = torch.sqrt(variances + relative_eps)
1066
+ reference_std = std.mean().detach()
1067
+ minimum_std = _DISENTANGLEMENT_MIN_STD_RATIO * reference_std
1068
+ variance_loss = torch.relu(minimum_std - std).square().mean() / (
1069
+ reference_std.square() + z.new_tensor(1e-12)
1070
+ )
1071
+ return correlation_loss, variance_loss
1072
+
1073
+ def _compute_boosting_targets(
1074
+ self,
1075
+ X: torch.Tensor,
1076
+ *,
1077
+ lr: float = 1.0,
1078
+ obs_covariates: torch.Tensor | None = None,
1079
+ sample_weights: torch.Tensor | None = None,
1080
+ stats: dict[str, float] | None = None,
1081
+ z_override: torch.Tensor | None = None,
1082
+ ) -> np.ndarray:
1083
+ """Compute boosting targets via single gradient step from current z.
1084
+
1085
+ Computes targets = z_current - lr * ∂L_target/∂z, representing one step of
1086
+ functional gradient descent on the latent representation. With
1087
+ ``config.disentanglement="correlation"``, ``L_target`` additionally
1088
+ includes the soft latent-correlation and relative-variance terms returned
1089
+ by :meth:`_correlation_disentanglement_loss`.
1090
+
1091
+ ``L_target`` sums the squared error over cells and averages it over genes::
1092
+
1093
+ L_target = (1 / n_genes) * sum_cells sum_genes (x_hat - x)^2
1094
+ = n_cells * L_mean
1095
+
1096
+ where ``L_mean`` is the plain elementwise MSE used for the decoder update
1097
+ and reported reconstruction loss. Checkpoint selection also uses
1098
+ ``L_mean`` unless correlation disentanglement is active, in which case it
1099
+ adds the normalized constraint. The two reconstruction conventions differ
1100
+ only by the constant ``n_cells``, but that constant matters here: under
1101
+ ``L_mean`` each cell's share of the gradient is divided by ``n_cells``, so
1102
+ the same cell in a larger dataset receives a proportionally smaller target
1103
+ step, and ``target_optim_lr`` silently means something different at every
1104
+ dataset size. Summing over cells makes each cell's step depend only on its
1105
+ own reconstruction error; averaging over genes keeps it stable when
1106
+ ``n_genes`` changes.
1107
+
1108
+ This holds per cell only because the decoder runs in eval mode below, so
1109
+ batch-norm uses running statistics and does not couple cells together.
1110
+
1111
+ Parameters
1112
+ ----------
1113
+ X
1114
+ Full input data tensor of shape (n_cells, n_genes).
1115
+ lr
1116
+ Step size for gradient descent direction.
1117
+ obs_covariates
1118
+ Optional obs covariate tensor for cVAE decoder conditioning.
1119
+ sample_weights
1120
+ Optional non-negative per-cell weights with mean one.
1121
+ stats
1122
+ Optional dict filled in place with ``"target_grad_norm"`` and
1123
+ ``"loss_pre_boost"``. Both quantities are already computed here, so
1124
+ collecting them costs nothing. ``"loss_pre_boost"`` is the *mean* MSE,
1125
+ keeping it comparable with ``loss_post_boost`` and
1126
+ ``loss_post_decoder``; ``"target_grad_norm"`` is the norm of the
1127
+ ``L_target`` gradient that the encoder is actually fitted against.
1128
+ z_override
1129
+ Latent state to start from instead of ``encoder(X)``. Used once, on
1130
+ the first iteration, to warm-start training from a supplied
1131
+ representation.
1132
+
1133
+ Returns
1134
+ -------
1135
+ Target latent codes as numpy array of shape (n_cells, latent_dim).
1136
+ """
1137
+ self.decoder.eval()
1138
+ # Get current encoder output (detached) and create leaf tensor for grad
1139
+ if z_override is not None:
1140
+ z_current = z_override
1141
+ else:
1142
+ with torch.no_grad():
1143
+ z_current = self.encoder(X)
1144
+ z = z_current.clone().detach().requires_grad_(True)
1145
+
1146
+ # Compute gradient of reconstruction loss w.r.t. z (through split-softmax)
1147
+ h = self.split_softmax_layer(z) if self.split_softmax_layer else z
1148
+ x_recon = self.decoder(h, obs_covariates)
1149
+ loss = self._recon_loss(x_recon, X, sample_weights)
1150
+ # Sum over cells, mean over genes. See the docstring: this is what makes a
1151
+ # cell's target step independent of how many other cells are in the dataset.
1152
+ # Unweighted, that is exactly `n_cells` times the elementwise mean.
1153
+ target_loss = (
1154
+ loss * X.shape[0]
1155
+ if sample_weights is None
1156
+ else ((x_recon - X).square().mean(dim=1) * sample_weights).sum()
1157
+ )
1158
+ # Only dL/dz is needed. `torch.autograd.grad` skips accumulating gradients
1159
+ # into the decoder parameters, which `loss.backward()` would compute and
1160
+ # leave in `.grad` for the decoder optimizer to discard on its next
1161
+ # `zero_grad()`. Same convention as `_full_recon_loss`.
1162
+ if self.config.disentanglement == "correlation":
1163
+ correlation_loss, variance_loss = self._correlation_disentanglement_loss(
1164
+ z, sample_weights
1165
+ )
1166
+ # The reconstruction target loss sums over cells. Correlation and
1167
+ # variance are population averages, so multiply them by n_cells to
1168
+ # keep the per-cell regularizer gradient, and therefore lambda's
1169
+ # meaning, independent of dataset size.
1170
+ target_loss = target_loss + X.shape[0] * self.config.disentanglement_lambda * (
1171
+ correlation_loss + _DISENTANGLEMENT_VARIANCE_WEIGHT * variance_loss
1172
+ )
1173
+ (z_grad,) = torch.autograd.grad(target_loss, z)
1174
+
1175
+ # Target = current z moved in negative gradient direction
1176
+ with torch.no_grad():
1177
+ targets = z - lr * z_grad
1178
+ if stats is not None:
1179
+ stats["target_grad_norm"] = float(z_grad.norm())
1180
+ # Report the mean MSE, not `target_loss`: the A/B/C loss
1181
+ # decomposition in TrainingReport compares this against
1182
+ # `loss_post_boost` and `loss_post_decoder`, which are both means.
1183
+ stats["loss_pre_boost"] = float(loss.detach())
1184
+
1185
+ return targets.cpu().numpy()
1186
+
1187
+ def _update_decoder(
1188
+ self,
1189
+ X: torch.Tensor,
1190
+ optimizer: torch.optim.Optimizer,
1191
+ k_steps: int,
1192
+ *,
1193
+ D_train: torch.Tensor | None = None,
1194
+ sample_weights: torch.Tensor | None = None,
1195
+ ) -> float:
1196
+ """Update decoder via minibatch SGD.
1197
+
1198
+ Parameters
1199
+ ----------
1200
+ X
1201
+ Full input data tensor.
1202
+ optimizer
1203
+ Optimizer for decoder parameters.
1204
+ k_steps
1205
+ Number of SGD steps to perform.
1206
+ D_train
1207
+ Optional obs covariate tensor for cVAE decoder conditioning.
1208
+ sample_weights
1209
+ Optional non-negative per-cell weights with mean one.
1210
+
1211
+ Returns
1212
+ -------
1213
+ Average loss over the k steps.
1214
+ """
1215
+ self.decoder.train()
1216
+ tensors = [X]
1217
+ if D_train is not None:
1218
+ tensors.append(D_train)
1219
+ if sample_weights is not None:
1220
+ tensors.append(sample_weights)
1221
+ dataset = TensorDataset(*tensors)
1222
+ loader = DataLoader(dataset, batch_size=self.config.batch_size, shuffle=True)
1223
+ total_loss = 0.0
1224
+ steps_done = 0
1225
+
1226
+ for _ in range(k_steps):
1227
+ for batch_data in loader:
1228
+ if D_train is not None:
1229
+ x_batch, d_batch = batch_data[0], batch_data[1]
1230
+ w_batch = batch_data[2] if sample_weights is not None else None
1231
+ else:
1232
+ x_batch = batch_data[0]
1233
+ d_batch = None
1234
+ w_batch = batch_data[1] if sample_weights is not None else None
1235
+ optimizer.zero_grad()
1236
+ # The encoder is fitted by boosting and is in no optimizer, so its
1237
+ # gradient is never read. Detaching keeps backward from computing a
1238
+ # (latent_dim, n_genes) gradient per minibatch and from accumulating
1239
+ # it into `encoder.linear.weight.grad`, which nothing ever zeroes.
1240
+ with torch.no_grad():
1241
+ z = self.encoder(x_batch)
1242
+ h = self.split_softmax_layer(z) if self.split_softmax_layer else z
1243
+ x_recon = self.decoder(h, d_batch)
1244
+ loss = self._recon_loss(x_recon, x_batch, w_batch)
1245
+ loss.backward()
1246
+ optimizer.step()
1247
+ total_loss += loss.item()
1248
+ steps_done += 1
1249
+ if steps_done >= k_steps:
1250
+ break
1251
+ if steps_done >= k_steps:
1252
+ break
1253
+
1254
+ return total_loss / max(steps_done, 1)
1255
+
1256
+ def _checkpoint_selection_loss(
1257
+ self,
1258
+ train_loss: float,
1259
+ X: torch.Tensor,
1260
+ sample_weights: torch.Tensor | None,
1261
+ ) -> float:
1262
+ """Objective used for early stopping and best-state restoration.
1263
+
1264
+ Reconstruction-only fits preserve the historical ``train_loss`` criterion.
1265
+ Correlation-constrained fits add the same dimensionless disentanglement
1266
+ penalty used to construct their boosting targets; otherwise restoring the
1267
+ best reconstruction checkpoint can silently undo the constraint.
1268
+ """
1269
+ if self.config.disentanglement != "correlation":
1270
+ return train_loss
1271
+ with torch.no_grad():
1272
+ z = self.encoder(X)
1273
+ correlation_loss, variance_loss = self._correlation_disentanglement_loss(
1274
+ z, sample_weights
1275
+ )
1276
+ penalty = self.config.disentanglement_lambda * (
1277
+ correlation_loss + _DISENTANGLEMENT_VARIANCE_WEIGHT * variance_loss
1278
+ )
1279
+ return train_loss + float(penalty)
1280
+
1281
+ def _resolve_latent_init(
1282
+ self,
1283
+ adata: AnnData,
1284
+ X_np: np.ndarray,
1285
+ *,
1286
+ init_obsm: str | None,
1287
+ init_pca: bool,
1288
+ is_standardized: bool,
1289
+ ) -> tuple[np.ndarray | None, str]:
1290
+ """Resolve the requested warm-start latent state.
1291
+
1292
+ Returns the initial latent matrix (or None for the default zero start)
1293
+ together with a short provenance string.
1294
+ """
1295
+ if init_obsm is not None and init_pca:
1296
+ raise ValueError("init_obsm and init_pca are mutually exclusive; pass only one")
1297
+
1298
+ if init_pca:
1299
+ from ._utils import _pca_scores
1300
+
1301
+ k = self.config.latent_dim
1302
+ n_max = min(X_np.shape)
1303
+ if k > n_max:
1304
+ raise ValueError(
1305
+ f"init_pca needs latent_dim <= min(n_cells, n_genes) = {n_max}, "
1306
+ f"got latent_dim={k}"
1307
+ )
1308
+ # Never rescale, and skip centering when the data is already z-transformed
1309
+ # so that no further transformation is applied to it.
1310
+ return _pca_scores(X_np, k, center=not is_standardized), "pca"
1311
+
1312
+ if init_obsm is None:
1313
+ return None, "zero"
1314
+
1315
+ if init_obsm not in adata.obsm:
1316
+ raise ValueError(
1317
+ f"init_obsm key {init_obsm!r} not found in adata.obsm. "
1318
+ f"Available keys: {list(adata.obsm.keys())}"
1319
+ )
1320
+ z_init = np.asarray(adata.obsm[init_obsm], dtype=np.float64)
1321
+ if z_init.ndim != 2:
1322
+ raise ValueError(f"adata.obsm[{init_obsm!r}] must be 2-D, got shape {z_init.shape}")
1323
+ if z_init.shape[0] != adata.n_obs:
1324
+ raise ValueError(
1325
+ f"adata.obsm[{init_obsm!r}] has {z_init.shape[0]} rows but adata has "
1326
+ f"{adata.n_obs} observations"
1327
+ )
1328
+ if z_init.shape[1] < 1:
1329
+ raise ValueError(f"adata.obsm[{init_obsm!r}] must have at least one column")
1330
+ if not np.isfinite(z_init).all():
1331
+ raise ValueError(f"adata.obsm[{init_obsm!r}] contains non-finite values")
1332
+ return z_init, f"obsm:{init_obsm}"
1333
+
1334
+ def _pretrain_decoder(
1335
+ self,
1336
+ Z: torch.Tensor,
1337
+ X: torch.Tensor,
1338
+ optimizer: torch.optim.Optimizer,
1339
+ n_epochs: int,
1340
+ *,
1341
+ D: torch.Tensor | None = None,
1342
+ sample_weights: torch.Tensor | None = None,
1343
+ verbose: bool = False,
1344
+ desc: str = "Pre-training decoder",
1345
+ ) -> float:
1346
+ """Fit the decoder to map a fixed latent state to X, before BAE training.
1347
+
1348
+ The encoder is not involved: `Z` is held fixed, so this teaches the decoder
1349
+ to read the warm-start latent space. Must be called after the decoder is
1350
+ final (`fit` re-initializes it under the seed) and after `optimizer` has
1351
+ been built over its parameters.
1352
+
1353
+ Parameters
1354
+ ----------
1355
+ verbose
1356
+ Show a per-epoch progress bar with the running reconstruction loss.
1357
+ Worth having: a transfer's warm-up can run for hundreds of epochs
1358
+ before the training loop's own bar appears, and silence there is
1359
+ indistinguishable from a hang.
1360
+ desc
1361
+ Progress bar label.
1362
+
1363
+ Returns
1364
+ -------
1365
+ Mean reconstruction loss over the final epoch.
1366
+ """
1367
+ tensors = [Z, X]
1368
+ if D is not None:
1369
+ tensors.append(D)
1370
+ if sample_weights is not None:
1371
+ tensors.append(sample_weights)
1372
+ dataset = TensorDataset(*tensors)
1373
+ loader = DataLoader(dataset, batch_size=self.config.batch_size, shuffle=True)
1374
+ self.decoder.train()
1375
+ epoch_loss = 0.0
1376
+ progress = tqdm(range(n_epochs), desc=desc, disable=not verbose, unit="epoch")
1377
+ for _ in progress:
1378
+ total, steps = 0.0, 0
1379
+ for batch in loader:
1380
+ z_batch, x_batch = batch[0], batch[1]
1381
+ h = self.split_softmax_layer(z_batch) if self.split_softmax_layer else z_batch
1382
+ optimizer.zero_grad()
1383
+ covariates = batch[2] if D is not None else None
1384
+ weight_index = 3 if D is not None else 2
1385
+ weights = batch[weight_index] if sample_weights is not None else None
1386
+ loss = self._recon_loss(self.decoder(h, covariates), x_batch, weights)
1387
+ loss.backward()
1388
+ optimizer.step()
1389
+ total += loss.item()
1390
+ steps += 1
1391
+ epoch_loss = total / max(steps, 1)
1392
+ progress.set_postfix(loss=f"{epoch_loss:.4f}")
1393
+ progress.close()
1394
+ return epoch_loss
1395
+
1396
+ def _full_recon_loss(
1397
+ self,
1398
+ X: torch.Tensor,
1399
+ D: torch.Tensor | None,
1400
+ *,
1401
+ sample_weights: torch.Tensor | None = None,
1402
+ grad: bool = False,
1403
+ ) -> tuple[float, float]:
1404
+ """Full-data reconstruction MSE, and optionally the decoder gradient norm.
1405
+
1406
+ Always runs the decoder in eval mode so dropout and batch-norm statistics
1407
+ are untouched and no RNG is consumed; gradients are taken with
1408
+ ``torch.autograd.grad`` so nothing accumulates into ``.grad``. Together
1409
+ these keep diagnostics read-only with respect to the fitted model.
1410
+ """
1411
+ was_training = self.decoder.training
1412
+ self.decoder.eval()
1413
+ try:
1414
+ if not grad:
1415
+ with torch.no_grad():
1416
+ z = self.encoder(X)
1417
+ h = self.split_softmax_layer(z) if self.split_softmax_layer else z
1418
+ loss = self._recon_loss(self.decoder(h, D), X, sample_weights)
1419
+ return float(loss), float("nan")
1420
+
1421
+ z = self.encoder(X)
1422
+ h = self.split_softmax_layer(z) if self.split_softmax_layer else z
1423
+ loss = self._recon_loss(self.decoder(h, D), X, sample_weights)
1424
+ params = [p for p in self.decoder.parameters() if p.requires_grad]
1425
+ grads = torch.autograd.grad(loss, params, retain_graph=False)
1426
+ norm = float(torch.sqrt(sum((g**2).sum() for g in grads)))
1427
+ return float(loss.detach()), norm
1428
+ finally:
1429
+ self.decoder.train(was_training)
1430
+
1431
+ def _collect_diagnostics(
1432
+ self,
1433
+ series: dict[str, list],
1434
+ stats: dict[str, float],
1435
+ *,
1436
+ X: torch.Tensor,
1437
+ D: torch.Tensor | None,
1438
+ sample_weights: torch.Tensor | None,
1439
+ targets: np.ndarray,
1440
+ prev_W: np.ndarray | None,
1441
+ ) -> np.ndarray:
1442
+ """Record one iteration of diagnostics; returns the current encoder weights.
1443
+
1444
+ Called after the encoder has been re-fit and the decoder updated, so the
1445
+ boosting-target comparison uses the same targets the encoder was fitted to.
1446
+ """
1447
+ # .copy() is required: numpy() shares storage with the tensor, and
1448
+ # set_weights writes in place, so a view would silently track the
1449
+ # current weights and make every convergence metric read as "no change".
1450
+ W = self.encoder.linear.weight.detach().cpu().numpy().copy()
1451
+
1452
+ # How well did boosting fit the targets it was given?
1453
+ with torch.no_grad():
1454
+ z = self.encoder(X).cpu().numpy()
1455
+ ss_res = float(((targets - z) ** 2).sum())
1456
+ ss_tot = float(((targets - targets.mean(axis=0)) ** 2).sum())
1457
+ series["boosting_r2"].append(1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan"))
1458
+ series["latent_var_per_dim"].append(z.var(axis=0))
1459
+
1460
+ # Sparsity and encoder scale.
1461
+ selected = np.abs(W).sum(axis=0) > 0
1462
+ series["n_selected"].append(int(selected.sum()))
1463
+ series["n_selected_per_dim"].append((np.abs(W) > 0).sum(axis=1))
1464
+ w_norm = float(np.abs(W).mean())
1465
+ series["encoder_weight_norm"].append(w_norm)
1466
+
1467
+ # Convergence against the previous iteration. The encoder magnitude grows
1468
+ # by orders of magnitude during training, so the weight change is only
1469
+ # meaningful relative to it.
1470
+ if prev_W is None:
1471
+ series["weight_change_rel"].append(float("nan"))
1472
+ series["support_jaccard"].append(float("nan"))
1473
+ series["min_dim_cosine"].append(float("nan"))
1474
+ else:
1475
+ delta = float(np.abs(W - prev_W).mean())
1476
+ series["weight_change_rel"].append(delta / w_norm if w_norm > 0 else float("nan"))
1477
+ prev_sel = np.abs(prev_W).sum(axis=0) > 0
1478
+ union = int((selected | prev_sel).sum())
1479
+ series["support_jaccard"].append(
1480
+ float((selected & prev_sel).sum()) / union if union else float("nan")
1481
+ )
1482
+ num = (W * prev_W).sum(axis=1)
1483
+ den = np.linalg.norm(W, axis=1) * np.linalg.norm(prev_W, axis=1)
1484
+ cos = np.divide(num, den, out=np.full_like(num, np.nan), where=den > 0)
1485
+ series["min_dim_cosine"].append(
1486
+ float(np.nanmin(cos)) if np.isfinite(cos).any() else float("nan")
1487
+ )
1488
+
1489
+ # Loss decomposition across the two halves of the alternation.
1490
+ loss_c, dec_grad = self._full_recon_loss(X, D, sample_weights=sample_weights, grad=True)
1491
+ loss_a = stats["loss_pre_boost"]
1492
+ loss_b = stats["loss_post_boost"]
1493
+ series["loss_pre_boost"].append(loss_a)
1494
+ series["loss_post_boost"].append(loss_b)
1495
+ series["loss_post_decoder"].append(loss_c)
1496
+ series["encoder_delta"].append(loss_b - loss_a)
1497
+ series["decoder_delta"].append(loss_c - loss_b)
1498
+ series["target_grad_norm"].append(stats["target_grad_norm"])
1499
+ series["decoder_grad_norm"].append(dec_grad)
1500
+ return W
1501
+
1502
+ @staticmethod
1503
+ def _check_standardized(X: np.ndarray, tol: float = 0.1) -> bool:
1504
+ """Check if data appears to be standardized (z-transformed)."""
1505
+ col_means = np.abs(X.mean(axis=0))
1506
+ col_stds = X.std(axis=0)
1507
+ mean_ok = np.median(col_means) < tol
1508
+ std_ok = np.abs(np.median(col_stds) - 1.0) < tol
1509
+ return mean_ok and std_ok
1510
+
1511
+ #: Columns with a standard deviation below this are treated as constant and
1512
+ #: left unscaled. Matches the guard in ``disentangle_boosting_targets``.
1513
+ _STD_EPS: float = 1e-12
1514
+
1515
+ @staticmethod
1516
+ def _standardize_targets(targets: np.ndarray) -> np.ndarray:
1517
+ """Standardize targets to zero mean and unit variance per column.
1518
+
1519
+ Columns whose standard deviation falls below ``_STD_EPS`` are left
1520
+ unscaled. Testing ``std == 0`` is not enough: a latent dimension carrying
1521
+ almost no signal has a tiny but nonzero standard deviation, and dividing
1522
+ by it inflates pure numerical noise to unit variance, handing a dead
1523
+ dimension the same weight in the boosting step as a real one.
1524
+
1525
+ Parameters
1526
+ ----------
1527
+ targets
1528
+ Target matrix of shape (n_samples, latent_dim).
1529
+
1530
+ Returns
1531
+ -------
1532
+ Standardized target matrix.
1533
+ """
1534
+ mean = targets.mean(axis=0, keepdims=True)
1535
+ std = targets.std(axis=0, keepdims=True)
1536
+ std = np.where(std < BAE._STD_EPS, 1.0, std)
1537
+ return (targets - mean) / std
1538
+
1539
+ # --- AnnData Integration (scverse conventions) ---
1540
+
1541
+ @staticmethod
1542
+ def _to_tensor(X: np.ndarray | sp.spmatrix, device: torch.device) -> torch.Tensor:
1543
+ """Convert array or sparse matrix to tensor."""
1544
+ if sp.issparse(X):
1545
+ X = X.toarray()
1546
+ return torch.from_numpy(np.asarray(X, dtype=np.float32)).to(device)
1547
+
1548
+ @_isolates_torch_rng(config_fallback=True)
1549
+ def fit(
1550
+ self,
1551
+ adata: AnnData,
1552
+ *,
1553
+ layer: str | None = None,
1554
+ mandatory_genes: list[str] | list[int] | np.ndarray | list | None = None,
1555
+ batch_key: str | list[str] | None = None,
1556
+ batch_integration_mode: Literal["encoder", "decoder", "both"] = _MODE_UNSET, # type: ignore[assignment]
1557
+ balance_obs: str | None = None,
1558
+ max_iterations: int | None = None,
1559
+ early_stopping_patience: int | None = None,
1560
+ enable_early_stopping: bool | None = None,
1561
+ seed: int | None = None,
1562
+ verbose: bool = True,
1563
+ diagnostics: bool | None = None,
1564
+ init_obsm: str | None = None,
1565
+ init_pca: bool = False,
1566
+ init_pretrain_epochs: int = 0,
1567
+ decoder_warmup_epochs: int = 0,
1568
+ stability_selection: str | bool | None = None,
1569
+ ) -> BAE:
1570
+ """Fit BAE using hybrid boosting+SGD training.
1571
+
1572
+ The training loop alternates between:
1573
+
1574
+ 1. Computing boosting targets via gradient step: z* = z - lr * ∂L_target/∂z,
1575
+ where L_target sums the squared error over cells and averages it over
1576
+ genes (see ``_compute_boosting_targets``)
1577
+ 2. (Optional) Applying leave-one-out target residualization
1578
+ 3. (Optional) Standardizing targets for optimal boosting convergence
1579
+ 4. Resetting encoder weights to zero
1580
+ 5. Fitting encoder via allboost to map X → z*
1581
+ 6. Updating decoder via minibatch SGD
1582
+
1583
+ Parameters
1584
+ ----------
1585
+ adata
1586
+ AnnData object with gene expression. Data should be standardized
1587
+ (z-transformed).
1588
+ layer
1589
+ Read expression from ``adata.layers[layer]`` instead of ``adata.X``.
1590
+ The choice is remembered: :meth:`transform`, :meth:`reconstruct` and
1591
+ the diagnostics default to the same layer, so a fitted model always
1592
+ reads the representation it learned on. Pass the same layer to
1593
+ :func:`~structboost.linear_ceiling` when comparing reconstruction
1594
+ quality against its achievable maximum.
1595
+ mandatory_genes
1596
+ Gene names (str) or column indices (int) placed in the unpenalized
1597
+ adjustment block of the boosting fit, so they are never subject to
1598
+ competitive selection. Can be a flat list (applied to all latent dims)
1599
+ or a list of lists (per latent dimension).
1600
+
1601
+ This forces them into the model *specification*, not into the fitted
1602
+ support: a gene whose contribution is estimated as zero still ends up
1603
+ with a zero encoder weight. Do not rely on this to guarantee that a
1604
+ marker appears in the selected gene set.
1605
+ batch_key
1606
+ Obs column, or several, holding the covariate to integrate over.
1607
+ ``None`` means no integration is performed.
1608
+ batch_integration_mode
1609
+ Which of the two mechanisms to apply. Defaults to ``"both"``.
1610
+
1611
+ ``"decoder"``
1612
+ The encoded covariate is concatenated to the decoder input, so
1613
+ the decoder can explain covariate-driven variation directly and
1614
+ the latent code does not have to carry it.
1615
+ ``"encoder"``
1616
+ The encoded covariate is added to the boosting design as a
1617
+ mandatory regressor, so a covariate-correlated gene is not
1618
+ selected *because of* the covariate.
1619
+ ``"both"``
1620
+ Both of the above. This is the usual choice.
1621
+
1622
+ **The covariate is never an encoder input.** ``"encoder"`` names the
1623
+ half of the model it protects, not a tensor it is fed to.
1624
+ :meth:`transform` stays gene-only and needs no covariate labels under
1625
+ any mode, which is what makes a fitted encoder deployable on data
1626
+ carrying no covariate annotation. :meth:`reconstruct` needs them only
1627
+ under ``"decoder"`` and ``"both"``.
1628
+
1629
+ Passing a mode without a ``batch_key`` raises, rather than silently
1630
+ integrating nothing.
1631
+
1632
+ The ridge that stabilizes the ``"encoder"`` mechanism when covariates
1633
+ are near-collinear lives on :class:`BAEConfig` as ``nuisance_ridge``.
1634
+ balance_obs
1635
+ Optional categorical obs column used to inverse-frequency weight the
1636
+ *reconstruction losses*, giving each observed level equal total weight
1637
+ so that a large group cannot dominate the fit.
1638
+
1639
+ Scope, precisely: the weights enter the boosting-target gradient, the
1640
+ decoder update, the reported losses and the diagnostics. They do **not**
1641
+ enter the ``allboost`` fit itself, which remains ordinary (unweighted)
1642
+ least squares. So the targets the encoder chases are balanced, but the
1643
+ projection of those targets onto genes is not, and gene selection still
1644
+ leans toward the larger group. Making it a true weighted least squares
1645
+ would require weighted column norms, weighted inner products and a
1646
+ weight-dependent covariance cache throughout ``_boosting.py``.
1647
+
1648
+ Measured effect on a deliberately imbalanced 500/90/45 design: the
1649
+ spread in per-group reconstruction MSE fell from 0.231 to 0.150.
1650
+ Check ``adata.uns["bae"]["reconstruction_loss_by_obs"]`` to see whether
1651
+ it helped on your data — and check first whether group size actually
1652
+ predicts fit quality, because uneven per-group reconstruction has causes
1653
+ other than imbalance.
1654
+ max_iterations
1655
+ Maximum training iterations (overrides config).
1656
+ early_stopping_patience
1657
+ Early stopping patience (overrides config).
1658
+ enable_early_stopping
1659
+ Enable/disable early stopping (overrides config).
1660
+ seed
1661
+ Random seed for reproducibility (overrides config).
1662
+ verbose
1663
+ Print training progress.
1664
+ diagnostics
1665
+ Collect per-iteration training diagnostics (overrides config). Adds
1666
+ full-data forward/backward passes per iteration; see
1667
+ :attr:`training_report`. Also adds the relative encoder weight change
1668
+ (``dW``, which converges toward 0) and the number of selected genes
1669
+ (``n_sel``) to the progress bar. Does not change the fitted model.
1670
+ init_obsm
1671
+ Warm-start the latent state from ``adata.obsm[init_obsm]`` instead of
1672
+ from zero. If that representation has a different number of columns
1673
+ than ``config.latent_dim``, the representation wins: ``latent_dim`` is
1674
+ overwritten for this fit and a ``UserWarning`` is emitted. Mutually
1675
+ exclusive with ``init_pca``.
1676
+ init_pca
1677
+ Warm-start from a PCA of ``adata.X`` keeping ``config.latent_dim``
1678
+ components. The data is never rescaled, and it is mean-centered only
1679
+ when it is not already z-transformed, so no transformation is applied
1680
+ on top of one the caller already performed. Mutually exclusive with
1681
+ ``init_obsm``.
1682
+ init_pretrain_epochs
1683
+ Before training, fit the decoder to map the warm-start latent state to
1684
+ ``adata.X`` for this many full passes over the cells. Only valid with
1685
+ ``init_obsm`` or ``init_pca``; passing a positive value without one
1686
+ raises. Default 0 (no pre-training); 20 is a conservative value.
1687
+ Measured trade-off: this lowers the initial loss substantially but does
1688
+ not improve the converged loss, and large values noticeably reduce
1689
+ gene-selection precision, because the decoder is tuned to a latent code
1690
+ the sparse encoder cannot exactly reproduce.
1691
+ decoder_warmup_epochs
1692
+ Transfer models only (:meth:`from_reference`). Trains the decoder
1693
+ against the frozen prior programs for this many passes before boosting
1694
+ starts. This is what makes the added dimensions *residual*: until the
1695
+ decoder has converged against the prior, ``dL/dz`` still carries signal
1696
+ those programs could explain, and the new dimensions would re-learn it.
1697
+ stability_selection
1698
+ Run :meth:`stability_selection` once after training and store its
1699
+ results in ``adata``. One of:
1700
+
1701
+ ``None`` (default)
1702
+ Skip it.
1703
+ ``"subsample"``
1704
+ Meinshausen-Bühlmann cell resampling; error-controlled.
1705
+ ``"iteration"``
1706
+ Frequency over further training iterations; targets the larger
1707
+ variance source on measured data but provides no error bound.
1708
+
1709
+ A single argument rather than a flag plus a mode, so the combination
1710
+ "disabled, but with a mode" cannot be expressed. ``True`` is accepted as
1711
+ a deprecated alias for ``"subsample"``. Call the method directly for
1712
+ control over ``n_runs`` and ``threshold``.
1713
+
1714
+ Returns
1715
+ -------
1716
+ Self for method chaining.
1717
+
1718
+ Notes
1719
+ -----
1720
+ The warm start is applied once, on the first iteration only: it sets the
1721
+ boosting targets, so the encoder learns the supplied representation and the
1722
+ decoder is then trained against the encoder's output. With
1723
+ ``standardize_targets=True`` the warm-start targets are standardized like
1724
+ any others, preserving the structure of the representation but not its scale.
1725
+
1726
+ With ``config.disentanglement="correlation"``, the soft penalty is applied
1727
+ inside step 1 rather than as a target transformation. Early stopping and
1728
+ best-state restoration then use reconstruction loss plus the weighted
1729
+ disentanglement penalty. ``training_history["train_loss"]`` remains the
1730
+ unregularized decoder MSE; ``training_history["selection_loss"]`` records
1731
+ the checkpoint-selection objective.
1732
+ """
1733
+ # Resolve mandatory gene names to indices
1734
+ from ._utils import resolve_mandatory_genes
1735
+
1736
+ # Resolve the batch arguments. No `batch_key` means no integration, and a
1737
+ # mode named without one is a mistake worth reporting rather than a
1738
+ # silent no-op. An additive conditioning alternative was evaluated and
1739
+ # rejected as strictly dominated; the CHANGELOG records the measurements.
1740
+ if batch_integration_mode is not _MODE_UNSET and batch_integration_mode not in _BATCH_MODES:
1741
+ raise ValueError(
1742
+ f"batch_integration_mode must be one of 'encoder', 'decoder', 'both', "
1743
+ f"got {batch_integration_mode!r}"
1744
+ )
1745
+ if batch_key is None:
1746
+ if batch_integration_mode is not _MODE_UNSET:
1747
+ raise ValueError(
1748
+ f"batch_integration_mode={batch_integration_mode!r} was given without a "
1749
+ "batch_key, so there is no covariate to integrate over. Pass "
1750
+ "batch_key, or drop the mode."
1751
+ )
1752
+ batch_columns: list[str] | None = None
1753
+ batch_mode = "none"
1754
+ else:
1755
+ batch_columns = [batch_key] if isinstance(batch_key, str) else list(batch_key)
1756
+ if not batch_columns:
1757
+ raise ValueError("batch_key must name at least one obs column")
1758
+ batch_mode = "both" if batch_integration_mode is _MODE_UNSET else batch_integration_mode
1759
+ conditions_decoder, regresses_encoder = _BATCH_MODES[batch_mode]
1760
+ nuisance_ridge = self.config.nuisance_ridge
1761
+ if stability_selection not in (None, False, True, "subsample", "iteration"):
1762
+ raise ValueError(
1763
+ "stability_selection must be None, 'subsample' or 'iteration', "
1764
+ f"got {stability_selection!r}"
1765
+ )
1766
+
1767
+ resolved_mandatory = resolve_mandatory_genes(mandatory_genes, adata)
1768
+ self._mandatory_genes = mandatory_genes
1769
+ # Recorded before the first read below, which resolves through it, so the
1770
+ # whole fit and every later call share one source of expression.
1771
+ self._layer = layer
1772
+
1773
+ # Resolve parameters. Compare against None rather than truthiness so that an
1774
+ # explicit 0 overrides the config instead of silently falling back to it.
1775
+ max_iter = max_iterations if max_iterations is not None else self.config.max_iterations
1776
+ patience = (
1777
+ early_stopping_patience
1778
+ if early_stopping_patience is not None
1779
+ else self.config.early_stopping_patience
1780
+ )
1781
+ use_early_stopping = (
1782
+ enable_early_stopping
1783
+ if enable_early_stopping is not None
1784
+ else self.config.enable_early_stopping
1785
+ )
1786
+ collect_diagnostics = diagnostics if diagnostics is not None else self.config.diagnostics
1787
+ seed = seed if seed is not None else self.config.seed
1788
+
1789
+ # Mirror the BAEConfig bounds, which these arguments bypass.
1790
+ if max_iter < 1:
1791
+ raise ValueError(f"max_iterations must be >= 1, got {max_iter}")
1792
+ if patience < 1:
1793
+ raise ValueError(f"early_stopping_patience must be >= 1, got {patience}")
1794
+ if decoder_warmup_epochs < 0:
1795
+ raise ValueError(f"decoder_warmup_epochs must be >= 0, got {decoder_warmup_epochs}")
1796
+
1797
+ is_transfer = self._prior_weights is not None
1798
+ if is_transfer:
1799
+ self._validate_transfer_fit(adata, init_obsm=init_obsm, init_pca=init_pca)
1800
+ elif decoder_warmup_epochs:
1801
+ raise ValueError(
1802
+ "decoder_warmup_epochs only applies to a model built by "
1803
+ "BAE.from_reference; there is no prior encoder matrix to warm the "
1804
+ "decoder against. Use init_pretrain_epochs with init_pca/init_obsm."
1805
+ )
1806
+
1807
+ # Set random seeds for reproducibility. The decoder is *not* re-initialized
1808
+ # here: it is rebuilt further down (its input width depends on conditioning
1809
+ # and on a warm start that may change latent_dim) and seeded there, so that
1810
+ # its weights depend only on `seed` and not on how much RNG the intervening
1811
+ # construction happened to consume.
1812
+ #
1813
+ # Only torch is seeded. Nothing in this package draws from the global NumPy
1814
+ # generator — `_boosting.py` is deterministic, and `_stability.py`,
1815
+ # `_simulation.py` and `_explorer.py` all use their own `default_rng` — so
1816
+ # seeding it changed no result here and only clobbered the caller's stream.
1817
+ # `@_isolates_torch_rng` puts the torch stream back when the fit returns.
1818
+ if seed is not None:
1819
+ torch.manual_seed(seed)
1820
+ if torch.cuda.is_available():
1821
+ torch.cuda.manual_seed_all(seed)
1822
+
1823
+ # Prepare data
1824
+ matrix = _expression_matrix(adata, self._layer)
1825
+ X_np = matrix.toarray() if sp.issparse(matrix) else np.asarray(matrix)
1826
+ X_np = X_np.astype(np.float32)
1827
+
1828
+ # Warn if data doesn't appear standardized. The result also decides whether
1829
+ # a PCA warm start needs to center the data (it must not re-transform data
1830
+ # that is already z-scored).
1831
+ is_standardized = self._check_standardized(X_np)
1832
+ if not is_standardized:
1833
+ warnings.warn(
1834
+ "Input data does not appear to be standardized (mean≈0, std≈1). "
1835
+ "Standardization is recommended for optimal performance.",
1836
+ UserWarning,
1837
+ stacklevel=2,
1838
+ )
1839
+
1840
+ # Use full dataset for training (no validation split)
1841
+ X_train = self._to_tensor(X_np, self.config.device)
1842
+ raw_weights = self._balance_weights(adata, balance_obs)
1843
+ sample_weights: torch.Tensor | None = (
1844
+ torch.from_numpy(raw_weights).to(self.config.device)
1845
+ if raw_weights is not None
1846
+ else None
1847
+ )
1848
+
1849
+ # Covariance cache for boosting (uses training data only)
1850
+ X_train_np = X_train.cpu().numpy()
1851
+
1852
+ # --- Latent state initialization (warm start) ---
1853
+ if init_pretrain_epochs < 0:
1854
+ raise ValueError(f"init_pretrain_epochs must be >= 0, got {init_pretrain_epochs}")
1855
+ z_init_np, init_desc = self._resolve_latent_init(
1856
+ adata,
1857
+ X_np,
1858
+ init_obsm=init_obsm,
1859
+ init_pca=init_pca,
1860
+ is_standardized=is_standardized,
1861
+ )
1862
+ if init_pretrain_epochs > 0 and z_init_np is None:
1863
+ raise ValueError(
1864
+ "init_pretrain_epochs requires a warm start; pass init_obsm or init_pca"
1865
+ )
1866
+
1867
+ if z_init_np is not None and z_init_np.shape[1] != self.config.latent_dim:
1868
+ new_dim = int(z_init_np.shape[1])
1869
+ warnings.warn(
1870
+ f"latent_dim was changed from {self.config.latent_dim} to {new_dim} for "
1871
+ f"training to match the {new_dim} columns of the supplied representation "
1872
+ f"({init_desc}).",
1873
+ UserWarning,
1874
+ stacklevel=2,
1875
+ )
1876
+ self.config.latent_dim = new_dim
1877
+ # The encoder bakes latent_dim into its linear layer, so it must be rebuilt.
1878
+ self.encoder = BAEEncoder(self.n_genes, self.config).to(self.config.device)
1879
+
1880
+ z_init = (
1881
+ torch.from_numpy(z_init_np.astype(np.float32)).to(self.config.device)
1882
+ if z_init_np is not None
1883
+ else None
1884
+ )
1885
+
1886
+ # --- Batch covariates: one encoding, two mechanisms it can drive ---
1887
+ self._batch_encoding = None
1888
+ self._batch_integration_mode = batch_mode
1889
+ D_condition: torch.Tensor | None = None
1890
+ D_nuisance_np: np.ndarray | None = None
1891
+ n_nuisance = 0
1892
+ if batch_columns is not None:
1893
+ from ._utils import encode_obs_covariates
1894
+
1895
+ self._batch_encoding = encode_obs_covariates(adata, batch_columns)
1896
+ if conditions_decoder:
1897
+ condition_np = self._batch_encoding.encoded.astype(np.float32)
1898
+ D_condition = torch.from_numpy(condition_np).to(self.config.device)
1899
+ if regresses_encoder:
1900
+ D_nuisance_np = self._batch_encoding.encoded.astype(np.float32)
1901
+ n_nuisance = self._batch_encoding.n_columns
1902
+
1903
+ # Always rebuild so repeated fits cannot retain a stale conditioning shape.
1904
+ decoder_input = (
1905
+ 2 * self.config.latent_dim if self.config.split_softmax else self.config.latent_dim
1906
+ )
1907
+ n_condition = self._batch_encoding.n_columns if conditions_decoder else 0
1908
+ self.decoder = BAEDecoder(
1909
+ self.n_genes,
1910
+ self.config,
1911
+ input_dim_override=decoder_input,
1912
+ n_covariates=n_condition,
1913
+ ).to(self.config.device)
1914
+
1915
+ if seed is not None:
1916
+ # Re-seed immediately before the reset so the decoder's initial weights
1917
+ # are a function of `seed` alone. Constructing BAEDecoder above draws
1918
+ # from the global RNG, and how much it draws depends on the conditioning
1919
+ # width; without this, enabling conditioning would perturb the weights of
1920
+ # the *unconditioned* part of the model as a side effect.
1921
+ torch.manual_seed(seed)
1922
+ if torch.cuda.is_available():
1923
+ torch.cuda.manual_seed_all(seed)
1924
+ self.decoder.reset_parameters()
1925
+
1926
+ # Build augmented sourcemat for allboost
1927
+ if D_nuisance_np is not None:
1928
+ sourcemat_aug = np.hstack([X_train_np, D_nuisance_np])
1929
+ else:
1930
+ sourcemat_aug = X_train_np
1931
+
1932
+ # Build combined mandatory indices (genes + obs covariates)
1933
+ allboost_mandatory = _build_allboost_mandatory(resolved_mandatory, n_nuisance, self.n_genes)
1934
+ mandatory_ridge = np.zeros(sourcemat_aug.shape[1], dtype=np.float64)
1935
+ if n_nuisance:
1936
+ mandatory_ridge[self.n_genes :] = nuisance_ridge
1937
+
1938
+ if self.config.boosting_precompute_covcache:
1939
+ from ._utils import compute_covariance_cache
1940
+
1941
+ covcache = compute_covariance_cache(sourcemat_aug)
1942
+ else:
1943
+ covcache = None # Lazy computation during allboost
1944
+
1945
+ # Optimizer for decoder only
1946
+ decoder_optimizer = torch.optim.AdamW(
1947
+ self.decoder.parameters(),
1948
+ lr=self.config.decoder_lr,
1949
+ weight_decay=self.config.decoder_weight_decay,
1950
+ )
1951
+
1952
+ # Optional decoder pre-training on the warm-start latent state. This must run
1953
+ # after the decoder is final: `fit` re-initializes it under the seed above and
1954
+ # may have just rebuilt it, either of which would discard earlier pre-training.
1955
+ if init_pretrain_epochs > 0 and z_init is not None:
1956
+ self._pretrain_decoder(
1957
+ z_init,
1958
+ X_train,
1959
+ decoder_optimizer,
1960
+ init_pretrain_epochs,
1961
+ D=D_condition,
1962
+ sample_weights=sample_weights,
1963
+ verbose=verbose,
1964
+ desc="Decoder pre-training (warm start)",
1965
+ )
1966
+
1967
+ # --- Phase 1: install the prior encoder matrix and settle the decoder ---
1968
+ # Deliberately outside the training loop below. The decoder converges here
1969
+ # against the prior programs alone, so this phase's loss is typically lower
1970
+ # than the first phase-2 iterations, where the new dimensions switch on and
1971
+ # temporarily worsen the reconstruction. Inside the loop it would win
1972
+ # checkpoint selection, and `fit` would restore an encoder whose novel
1973
+ # columns are all zero — returning the prior unchanged, with no error, and
1974
+ # reading as "no novel structure found".
1975
+ prior_dims = self._prior_weights.shape[1] if is_transfer else 0
1976
+ warmup_loss: float | None = None
1977
+ if is_transfer:
1978
+ W_start = np.zeros((self.config.latent_dim, self.n_genes), dtype=np.float32)
1979
+ W_start[:prior_dims] = self._prior_weights.T
1980
+ self.encoder.set_weights(torch.from_numpy(W_start).to(self.config.device))
1981
+
1982
+ if decoder_warmup_epochs > 0:
1983
+ self.eval()
1984
+ with torch.no_grad():
1985
+ z_prior = self.encoder(X_train)
1986
+ warmup_loss = self._pretrain_decoder(
1987
+ z_prior,
1988
+ X_train,
1989
+ decoder_optimizer,
1990
+ decoder_warmup_epochs,
1991
+ D=D_condition,
1992
+ sample_weights=sample_weights,
1993
+ verbose=verbose,
1994
+ desc="Decoder warm-up (prior only)",
1995
+ )
1996
+
1997
+ # Early stopping state (based on the checkpoint-selection objective)
1998
+ best_selection_loss = float("inf")
1999
+ patience_counter = 0
2000
+ best_encoder_weights = None
2001
+ best_decoder_state = None
2002
+ best_batch_weights = None
2003
+
2004
+ # Training loop
2005
+ self._training_history = {"train_loss": [], "selection_loss": []}
2006
+ # Recorded separately so `train_loss` stays comparable with an ordinary
2007
+ # fit and is never mistaken for an iteration of the alternation.
2008
+ if warmup_loss is not None:
2009
+ self._training_history["warmup_loss"] = [warmup_loss]
2010
+ self._training_report = None
2011
+ diag_series: dict[str, list] = {f: [] for f in _REPORT_FIELDS if f != "iteration"}
2012
+ prev_W: np.ndarray | None = None
2013
+
2014
+ # Progress bar setup
2015
+ pbar = tqdm(
2016
+ range(max_iter),
2017
+ desc="Training BAE",
2018
+ disable=not verbose,
2019
+ unit="iter",
2020
+ )
2021
+
2022
+ for iteration in pbar:
2023
+ # STEP 1: Compute boosting targets via gradient step from current z
2024
+ # targets = z - lr * ∂L_target/∂z (functional gradient descent).
2025
+ # The warm start applies once: it seeds z on the first iteration only.
2026
+ diag_stats: dict[str, float] = {}
2027
+ targets = self._compute_boosting_targets(
2028
+ X_train,
2029
+ lr=self.config.target_optim_lr,
2030
+ obs_covariates=D_condition,
2031
+ sample_weights=sample_weights,
2032
+ stats=diag_stats if collect_diagnostics else None,
2033
+ z_override=z_init if iteration == 0 else None,
2034
+ )
2035
+
2036
+ # STEP 2 (optional): retain the earlier leave-one-out residualization
2037
+ # method. The recommended correlation method is already part of the
2038
+ # differentiable target objective computed in step 1.
2039
+ if self.config.disentanglement == "leave_one_out":
2040
+ from ._utils import disentangle_boosting_targets
2041
+
2042
+ targets = disentangle_boosting_targets(
2043
+ targets, standardize=self.config.disentanglement_standardize
2044
+ )
2045
+
2046
+ # STEP 3 (optional): Standardize targets for optimal boosting convergence
2047
+ if self.config.standardize_targets:
2048
+ targets = self._standardize_targets(targets)
2049
+
2050
+ # STEP 4: Reset encoder weights before boosting (rebuild from scratch)
2051
+ self.encoder.reset_weights()
2052
+
2053
+ # STEP 5: Fit encoder via boosting to map X → targets. On a transfer
2054
+ # model the prior columns are either withheld ("frozen") or boosted
2055
+ # from the fixed original matrix as an offset ("anchored").
2056
+ fit_targets, beta_init, fit_mandatory = self._transfer_boosting_inputs(
2057
+ targets, sourcemat_aug.shape[1], prior_dims, allboost_mandatory
2058
+ )
2059
+ if fit_targets.shape[1] == 0:
2060
+ # Frozen transfer with no additional dimensions: nothing competes
2061
+ # for selection, and only the decoder adapts to the new data.
2062
+ betamat = np.zeros((0, sourcemat_aug.shape[1]), dtype=np.float64)
2063
+ elif covcache is None:
2064
+ betamat, covcache = allboost(
2065
+ sourcemat_aug,
2066
+ fit_targets,
2067
+ covcache=covcache,
2068
+ stepno=self.config.boosting_stepno,
2069
+ nu=self.config.boosting_nu,
2070
+ csf=self.config.boosting_csf,
2071
+ independent=self.config.boosting_independent,
2072
+ mandatory_features=fit_mandatory,
2073
+ mandatory_ridge=mandatory_ridge,
2074
+ beta_init=beta_init,
2075
+ return_covcache=True,
2076
+ )
2077
+ else:
2078
+ betamat = allboost(
2079
+ sourcemat_aug,
2080
+ fit_targets,
2081
+ covcache=covcache,
2082
+ stepno=self.config.boosting_stepno,
2083
+ nu=self.config.boosting_nu,
2084
+ csf=self.config.boosting_csf,
2085
+ independent=self.config.boosting_independent,
2086
+ mandatory_features=fit_mandatory,
2087
+ mandatory_ridge=mandatory_ridge,
2088
+ beta_init=beta_init,
2089
+ )
2090
+ betamat = self._expand_transfer_betamat(betamat, sourcemat_aug.shape[1], prior_dims)
2091
+
2092
+ # Extract gene weights only; obs weights are nuisance (discarded)
2093
+ W_genes = betamat[:, : self.n_genes]
2094
+ batch_weights = betamat[:, self.n_genes :].copy() if D_nuisance_np is not None else None
2095
+ W = torch.from_numpy(W_genes.astype(np.float32)).to(self.config.device)
2096
+ self.encoder.set_weights(W)
2097
+
2098
+ # Loss with the new encoder but the old decoder: isolates the
2099
+ # boosting step's effect from the decoder's.
2100
+ if collect_diagnostics:
2101
+ diag_stats["loss_post_boost"] = self._full_recon_loss(
2102
+ X_train, D_condition, sample_weights=sample_weights
2103
+ )[0]
2104
+
2105
+ # STEP 6: Update decoder via minibatch SGD
2106
+ train_loss = self._update_decoder(
2107
+ X_train,
2108
+ decoder_optimizer,
2109
+ self.config.decoder_updates_per_iteration,
2110
+ D_train=D_condition,
2111
+ sample_weights=sample_weights,
2112
+ )
2113
+ self._training_history["train_loss"].append(train_loss)
2114
+ selection_loss = self._checkpoint_selection_loss(train_loss, X_train, sample_weights)
2115
+ self._training_history["selection_loss"].append(selection_loss)
2116
+
2117
+ if collect_diagnostics:
2118
+ diag_series["train_loss"].append(train_loss)
2119
+ prev_W = self._collect_diagnostics(
2120
+ diag_series,
2121
+ diag_stats,
2122
+ X=X_train,
2123
+ D=D_condition,
2124
+ sample_weights=sample_weights,
2125
+ targets=targets,
2126
+ prev_W=prev_W,
2127
+ )
2128
+
2129
+ # STEP 7: Check early stopping criteria and store the best model. For
2130
+ # correlation-constrained fits, selection_loss includes the constraint
2131
+ # so restoration cannot quietly select a less-disentangled checkpoint.
2132
+ if selection_loss < best_selection_loss:
2133
+ best_selection_loss = selection_loss
2134
+ patience_counter = 0
2135
+ best_encoder_weights = self.encoder.linear.weight.detach().clone()
2136
+ best_decoder_state = {k: v.clone() for k, v in self.decoder.state_dict().items()}
2137
+ best_batch_weights = batch_weights
2138
+ else:
2139
+ patience_counter += 1
2140
+
2141
+ postfix = {"train_loss": f"{train_loss:.4f}"}
2142
+ if self.config.disentanglement == "correlation":
2143
+ postfix["selection_loss"] = f"{selection_loss:.4f}"
2144
+ if collect_diagnostics:
2145
+ # Two of the 17 diagnostic series are worth watching live: relative
2146
+ # encoder weight change (converges toward 0) and how many genes are
2147
+ # selected. dW has no predecessor on the first iteration.
2148
+ dw = diag_series["weight_change_rel"][-1]
2149
+ postfix["dW"] = "n/a" if not np.isfinite(dw) else f"{dw:.3f}"
2150
+ postfix["n_sel"] = str(diag_series["n_selected"][-1])
2151
+ if use_early_stopping:
2152
+ postfix["patience"] = f"{patience_counter}/{patience}"
2153
+ pbar.set_postfix(**postfix)
2154
+
2155
+ if use_early_stopping and patience_counter >= patience:
2156
+ pbar.set_description("Training BAE (early stop)")
2157
+ break
2158
+
2159
+ pbar.close()
2160
+
2161
+ self._latent_init = {"method": init_desc, "pretrain_epochs": int(init_pretrain_epochs)}
2162
+
2163
+ if collect_diagnostics:
2164
+ n_iter = len(diag_series["train_loss"])
2165
+ int_fields = {"n_selected", "n_selected_per_dim"}
2166
+ self._training_report = TrainingReport(
2167
+ iteration=np.arange(n_iter, dtype=np.intp),
2168
+ **{
2169
+ name: np.asarray(values, dtype=np.intp if name in int_fields else np.float64)
2170
+ for name, values in diag_series.items()
2171
+ },
2172
+ )
2173
+
2174
+ # Restore best model state
2175
+ if best_encoder_weights is not None:
2176
+ self.encoder.set_weights(best_encoder_weights)
2177
+ if best_decoder_state is not None:
2178
+ self.decoder.load_state_dict(best_decoder_state)
2179
+ self._batch_weights = best_batch_weights
2180
+ self._balance_obs = balance_obs
2181
+
2182
+ self._is_fitted = True
2183
+
2184
+ # Store results in AnnData
2185
+ self._store_results(adata)
2186
+
2187
+ # Optional stability selection of the encoder's gene sets. Off by default:
2188
+ # it adds a fraction of one fit's cost (see BAE.stability_selection).
2189
+ if stability_selection is True:
2190
+ warnings.warn(
2191
+ "stability_selection=True is deprecated; pass the mode explicitly, "
2192
+ 'e.g. stability_selection="subsample".',
2193
+ FutureWarning,
2194
+ stacklevel=2,
2195
+ )
2196
+ stability_selection = "subsample"
2197
+ if stability_selection:
2198
+ self.stability_selection(adata, mode=stability_selection, verbose=verbose)
2199
+ return self
2200
+
2201
+ @property
2202
+ def _conditions_decoder(self) -> bool:
2203
+ """Whether the batch covariate is concatenated to the decoder input."""
2204
+ return _BATCH_MODES[self._batch_integration_mode][0]
2205
+
2206
+ @property
2207
+ def _regresses_encoder(self) -> bool:
2208
+ """Whether the batch covariate enters the boosting design as a regressor."""
2209
+ return _BATCH_MODES[self._batch_integration_mode][1]
2210
+
2211
+ def _resolve_layer(self, layer: str | None | _FitLayer) -> str | None:
2212
+ """Resolve a per-call ``layer`` override against the fit-time layer."""
2213
+ return self._layer if isinstance(layer, _FitLayer) else layer
2214
+
2215
+ def transform(self, adata: AnnData, *, layer: str | None | _FitLayer = FIT_LAYER) -> np.ndarray:
2216
+ """Transform data to the gene-only latent space.
2217
+
2218
+ Parameters
2219
+ ----------
2220
+ adata
2221
+ AnnData object with the same genes used during fitting. Batch or
2222
+ other obs labels are deliberately not required because they are not
2223
+ part of the deployable encoder.
2224
+ layer
2225
+ Where to read expression from. Defaults to the layer the model was
2226
+ fitted on, so a model applied to new data reads the same
2227
+ representation it was trained on. Pass a name to override, or
2228
+ ``None`` to force ``adata.X``.
2229
+
2230
+ Returns
2231
+ -------
2232
+ Latent representation of shape (n_cells, latent_dim).
2233
+
2234
+ See Also
2235
+ --------
2236
+ BAE.reconstruct : Reconstruct expression; needs the conditioning columns.
2237
+ BAE.fit_transform : Fit and return the latent in one call.
2238
+
2239
+ """
2240
+ if not self._is_fitted:
2241
+ raise RuntimeError("Model not fitted. Call fit() first.")
2242
+
2243
+ self._warn_on_panel_mismatch(adata, "transform")
2244
+ matrix = _expression_matrix(adata, self._resolve_layer(layer))
2245
+ X = self._to_tensor(matrix, self.config.device)
2246
+
2247
+ z = self.get_latent(X)
2248
+ latent = z.cpu().numpy()
2249
+
2250
+ # Update adata. The scaling statistics are *reused*, never re-estimated:
2251
+ # new cells must go through the same map the fitted model defines, and a
2252
+ # small query set would estimate its own moments badly.
2253
+ adata.obsm["X_bae"] = latent
2254
+ self._store_scaled_latent(adata, latent)
2255
+ return latent
2256
+
2257
+ def reconstruct(
2258
+ self, adata: AnnData, *, layer: str | None | _FitLayer = FIT_LAYER
2259
+ ) -> np.ndarray:
2260
+ """Reconstruct expression using the fitted, conditioned decoder.
2261
+
2262
+ Unlike :meth:`transform`, this method requires the decoder-conditioning
2263
+ obs columns used during fitting and rejects unseen categorical levels.
2264
+
2265
+ Parameters
2266
+ ----------
2267
+ adata
2268
+ AnnData object with expression and, when applicable, the fitted
2269
+ batch columns, when the mode conditions the decoder.
2270
+ layer
2271
+ Where to read expression from. Defaults to the layer the model was
2272
+ fitted on. Pass a name to override, or ``None`` to force ``adata.X``.
2273
+
2274
+ Returns
2275
+ -------
2276
+ Reconstructed expression of shape ``(n_cells, n_genes)``.
2277
+
2278
+ See Also
2279
+ --------
2280
+ BAE.transform : Gene-only latent projection; needs no obs labels.
2281
+ """
2282
+ if not self._is_fitted:
2283
+ raise RuntimeError("Model not fitted. Call fit() first.")
2284
+
2285
+ self._warn_on_panel_mismatch(adata, "reconstruct")
2286
+ D: torch.Tensor | None = None
2287
+ if self._conditions_decoder:
2288
+ from ._utils import transform_obs_covariates
2289
+
2290
+ encoded = transform_obs_covariates(adata, self._batch_encoding)
2291
+ D = self._to_tensor(encoded, self.config.device)
2292
+ matrix = _expression_matrix(adata, self._resolve_layer(layer))
2293
+ X = self._to_tensor(matrix, self.config.device)
2294
+ self.eval()
2295
+ with torch.no_grad():
2296
+ reconstruction, _ = self.forward(X, D)
2297
+ return reconstruction.cpu().numpy()
2298
+
2299
+ @staticmethod
2300
+ def _balance_weights(adata: AnnData, balance_obs: str | None) -> np.ndarray | None:
2301
+ """Inverse-frequency per-cell weights giving each level equal total weight."""
2302
+ if balance_obs is None:
2303
+ return None
2304
+ if balance_obs not in adata.obs.columns:
2305
+ raise ValueError(f"Column {balance_obs!r} not found in adata.obs")
2306
+ groups = adata.obs[balance_obs]
2307
+ if groups.isna().any():
2308
+ raise ValueError(f"Column {balance_obs!r} contains missing values")
2309
+ counts = groups.value_counts(sort=False)
2310
+ if len(counts) < 2:
2311
+ raise ValueError(f"Column {balance_obs!r} must contain at least two levels")
2312
+ if len(counts) > 50:
2313
+ raise ValueError(
2314
+ f"Column {balance_obs!r} has {len(counts)} levels; "
2315
+ "balance_obs must identify a discrete batch variable"
2316
+ )
2317
+ weights = {lvl: len(groups) / (len(counts) * c) for lvl, c in counts.items()}
2318
+ return groups.map(weights).to_numpy(dtype=np.float32)
2319
+
2320
+ @_isolates_torch_rng()
2321
+ def _iteration_support_frequency(
2322
+ self,
2323
+ adata: AnnData,
2324
+ *,
2325
+ n_iterations: int,
2326
+ seed: int | None,
2327
+ verbose: bool = True,
2328
+ ) -> tuple[np.ndarray, np.ndarray, float, tuple[np.ndarray, np.ndarray, np.ndarray]]:
2329
+ """Selection frequency over ``n_iterations`` further training iterations.
2330
+
2331
+ Continues the alternating optimization from the fitted state, recording the
2332
+ encoder support after each boosting step, then **restores the model**, so the
2333
+ call is non-destructive like :meth:`stability_selection` in subsample mode.
2334
+
2335
+ Why this exists: the encoder support does not converge even when the
2336
+ reconstruction loss does. On measured data the loss plateaus at ~95% of the
2337
+ achievable linear ceiling while consecutive iterations share only about a
2338
+ third of their selected genes, with the support autocorrelation still ~0.45
2339
+ at lag 100 and no periodicity — a slow random walk over a plateau rather than
2340
+ a limit cycle. A single fit therefore reports one arbitrary position on that
2341
+ walk. Averaging over iterations targets exactly that variance, which cell
2342
+ subsampling cannot reach because it holds the model fixed.
2343
+
2344
+ Frequencies are counted **per latent dimension**, but only after each
2345
+ iteration's dimensions are matched to the fitted model's by maximum absolute
2346
+ cosine similarity (Hungarian assignment). Anchoring to the fitted model is
2347
+ what gives a dimension index a stable meaning: it answers "how often does
2348
+ *this* dimension of the model I have select gene g", rather than comparing
2349
+ indices that could permute. On measured data the mean matched similarity is
2350
+ ~0.84 and the identity permutation is retained in every iteration, so the
2351
+ matching is usually a no-op — but it is cheap insurance, and the realized
2352
+ quality is returned so a caller can tell when the anchoring did not hold.
2353
+
2354
+ Returns
2355
+ -------
2356
+ frequency
2357
+ Shape ``(n_genes, latent_dim)`` — fraction of recorded iterations in
2358
+ which each gene had a nonzero weight in each matched dimension.
2359
+ avg_selected
2360
+ Mean support size per dimension across the recorded iterations.
2361
+ dim_match_quality
2362
+ Mean matched absolute cosine similarity to the fitted model.
2363
+ coefficients
2364
+ ``(conditional mean, conditional sd, sign consistency)``, each
2365
+ ``(n_genes, latent_dim)``, from streaming accumulators — the full
2366
+ coefficient trace would be ``n_iterations x n_genes x latent_dim``
2367
+ floats, which is infeasible at realistic sizes.
2368
+
2369
+ Notes
2370
+ -----
2371
+ The decoder optimizer is rebuilt here, so AdamW moment estimates start from
2372
+ zero rather than continuing those from ``fit``. The alternation is otherwise
2373
+ identical to the training loop; ``tests/test_stability.py`` pins that
2374
+ equivalence so the two cannot silently diverge.
2375
+ """
2376
+ from ._utils import resolve_mandatory_genes, transform_obs_covariates
2377
+
2378
+ if n_iterations < 1:
2379
+ raise ValueError(f"n_iterations must be >= 1, got {n_iterations}")
2380
+
2381
+ if seed is not None:
2382
+ torch.manual_seed(seed)
2383
+
2384
+ matrix = _expression_matrix(adata, self._layer)
2385
+ X_np = matrix.toarray() if sp.issparse(matrix) else np.asarray(matrix)
2386
+ X_np = X_np.astype(np.float32)
2387
+ X_train = self._to_tensor(X_np, self.config.device)
2388
+
2389
+ D_condition = None
2390
+ if self._conditions_decoder:
2391
+ D_condition = self._to_tensor(
2392
+ transform_obs_covariates(adata, self._batch_encoding), self.config.device
2393
+ )
2394
+ raw_weights = self._balance_weights(adata, self._balance_obs)
2395
+ sample_weights = (
2396
+ torch.from_numpy(raw_weights).to(self.config.device)
2397
+ if raw_weights is not None
2398
+ else None
2399
+ )
2400
+
2401
+ sourcemat_aug = X_np.astype(np.float64)
2402
+ n_nuisance = 0
2403
+ if self._regresses_encoder:
2404
+ D_nuisance = transform_obs_covariates(adata, self._batch_encoding)
2405
+ sourcemat_aug = np.hstack([sourcemat_aug, np.asarray(D_nuisance, dtype=np.float64)])
2406
+ n_nuisance = self._batch_encoding.n_columns
2407
+
2408
+ resolved_mandatory = resolve_mandatory_genes(self._mandatory_genes, adata)
2409
+ allboost_mandatory = _build_allboost_mandatory(resolved_mandatory, n_nuisance, self.n_genes)
2410
+ mandatory_ridge = np.zeros(sourcemat_aug.shape[1], dtype=np.float64)
2411
+ if n_nuisance:
2412
+ mandatory_ridge[self.n_genes :] = self.config.nuisance_ridge
2413
+
2414
+ # Snapshot so the fitted model is unchanged when this returns.
2415
+ saved_encoder = self.encoder.linear.weight.detach().clone()
2416
+ saved_decoder = {k: v.detach().clone() for k, v in self.decoder.state_dict().items()}
2417
+
2418
+ decoder_optimizer = torch.optim.AdamW(
2419
+ self.decoder.parameters(),
2420
+ lr=self.config.decoder_lr,
2421
+ weight_decay=self.config.decoder_weight_decay,
2422
+ )
2423
+
2424
+ from scipy.optimize import linear_sum_assignment
2425
+
2426
+ reference = saved_encoder.detach().cpu().numpy()
2427
+
2428
+ def _unit_rows(matrix: np.ndarray) -> np.ndarray:
2429
+ norms = np.linalg.norm(matrix, axis=1, keepdims=True)
2430
+ return matrix / np.where(norms > 0, norms, 1.0)
2431
+
2432
+ reference_unit = _unit_rows(reference)
2433
+
2434
+ covcache = None
2435
+ latent_dim = reference.shape[0]
2436
+ prior_dims = self._prior_weights.shape[1] if self._prior_weights is not None else 0
2437
+ counts = np.zeros((self.n_genes, latent_dim), dtype=np.float64)
2438
+ coef_sum = np.zeros((self.n_genes, latent_dim), dtype=np.float64)
2439
+ coef_sq_sum = np.zeros((self.n_genes, latent_dim), dtype=np.float64)
2440
+ positive_count = np.zeros((self.n_genes, latent_dim), dtype=np.float64)
2441
+ support_sizes: list[np.ndarray] = []
2442
+ match_scores: list[float] = []
2443
+
2444
+ try:
2445
+ for _ in tqdm(
2446
+ range(n_iterations),
2447
+ desc="Stability selection (iteration)",
2448
+ disable=not verbose,
2449
+ unit="run",
2450
+ ):
2451
+ # Mirrors the fit loop, steps 1-6. Kept in the same order; see
2452
+ # `fit` for the authoritative sequence.
2453
+ targets = self._compute_boosting_targets(
2454
+ X_train,
2455
+ lr=self.config.target_optim_lr,
2456
+ obs_covariates=D_condition,
2457
+ sample_weights=sample_weights,
2458
+ )
2459
+ if self.config.disentanglement == "leave_one_out":
2460
+ from ._utils import disentangle_boosting_targets
2461
+
2462
+ targets = disentangle_boosting_targets(
2463
+ targets, standardize=self.config.disentanglement_standardize
2464
+ )
2465
+ if self.config.standardize_targets:
2466
+ targets = self._standardize_targets(targets)
2467
+
2468
+ self.encoder.reset_weights()
2469
+ fit_targets, beta_init, fit_mandatory = self._transfer_boosting_inputs(
2470
+ targets, sourcemat_aug.shape[1], prior_dims, allboost_mandatory
2471
+ )
2472
+ if fit_targets.shape[1] == 0:
2473
+ betamat = np.zeros((0, sourcemat_aug.shape[1]), dtype=np.float64)
2474
+ else:
2475
+ result = allboost(
2476
+ sourcemat_aug,
2477
+ fit_targets,
2478
+ covcache=covcache,
2479
+ stepno=self.config.boosting_stepno,
2480
+ nu=self.config.boosting_nu,
2481
+ csf=self.config.boosting_csf,
2482
+ independent=self.config.boosting_independent,
2483
+ mandatory_features=fit_mandatory,
2484
+ mandatory_ridge=mandatory_ridge,
2485
+ beta_init=beta_init,
2486
+ return_covcache=covcache is None,
2487
+ )
2488
+ if covcache is None:
2489
+ betamat, covcache = result
2490
+ else:
2491
+ betamat = result
2492
+ betamat = self._expand_transfer_betamat(betamat, sourcemat_aug.shape[1], prior_dims)
2493
+
2494
+ W_genes = betamat[:, : self.n_genes]
2495
+ self.encoder.set_weights(
2496
+ torch.from_numpy(W_genes.astype(np.float32)).to(self.config.device)
2497
+ )
2498
+
2499
+ # Match this iteration's dimensions to the fitted model's before
2500
+ # counting; comparing raw indices would conflate dimensions whenever
2501
+ # the optimizer permutes them. On a transfer model the prior
2502
+ # dimensions are pinned to their own indices and only the novel
2503
+ # block is matched: prior dimensions cannot permute (they are the
2504
+ # reference matrix), and matching them anyway would let a novel
2505
+ # dimension be assigned into a prior slot.
2506
+ signed = _unit_rows(W_genes) @ reference_unit.T
2507
+ similarity = np.abs(signed)
2508
+ if prior_dims:
2509
+ sub_rows, sub_cols = linear_sum_assignment(
2510
+ -similarity[prior_dims:, prior_dims:]
2511
+ )
2512
+ pinned = np.arange(prior_dims)
2513
+ rows = np.concatenate([pinned, sub_rows + prior_dims])
2514
+ cols = np.concatenate([pinned, sub_cols + prior_dims])
2515
+ # Reported over the novel block only: the pinned prior
2516
+ # dimensions score 1.0 against themselves and would inflate it.
2517
+ novel = similarity[rows[prior_dims:], cols[prior_dims:]]
2518
+ match_scores.append(float(novel.mean()) if novel.size else 1.0)
2519
+ else:
2520
+ rows, cols = linear_sum_assignment(-similarity)
2521
+ match_scores.append(float(similarity[rows, cols].mean()))
2522
+
2523
+ selected = np.abs(W_genes) > 0 # (latent_dim, n_genes)
2524
+ if prior_dims:
2525
+ # `|W| > 0` is tautologically true across the prior support:
2526
+ # frozen columns never change, and anchored columns start from
2527
+ # the anchor's non-zero coefficients. Counting it would report
2528
+ # frequency 1.0 for every prior gene and read as overwhelming
2529
+ # evidence. A prior dimension's selection is therefore movement
2530
+ # away from the anchor — identically zero under "frozen", which
2531
+ # is the honest answer for a dimension that cannot vary.
2532
+ selected[:prior_dims] = W_genes[:prior_dims] != self._prior_weights.T
2533
+ counts[:, cols] += selected[rows].T
2534
+ support_sizes.append(selected.sum(axis=1)[rows][np.argsort(cols)])
2535
+
2536
+ # Coefficients are sign-aligned to the fitted encoder before being
2537
+ # accumulated: a dimension whose best match is negative would
2538
+ # otherwise cancel itself out under averaging.
2539
+ flip = np.sign(signed[rows, cols])
2540
+ flip[flip == 0] = 1.0
2541
+ aligned = (W_genes[rows] * flip[:, None]).T # (n_genes, matched dim)
2542
+ coef_sum[:, cols] += aligned
2543
+ coef_sq_sum[:, cols] += aligned**2
2544
+ positive_count[:, cols] += aligned > 0
2545
+
2546
+ self._update_decoder(
2547
+ X_train,
2548
+ decoder_optimizer,
2549
+ self.config.decoder_updates_per_iteration,
2550
+ D_train=D_condition,
2551
+ sample_weights=sample_weights,
2552
+ )
2553
+ finally:
2554
+ self.encoder.set_weights(saved_encoder)
2555
+ self.decoder.load_state_dict(saved_decoder)
2556
+
2557
+ quality = float(np.mean(match_scores)) if match_scores else float("nan")
2558
+ if np.isfinite(quality) and quality < 0.5:
2559
+ warnings.warn(
2560
+ f"Latent dimensions matched the fitted model poorly across iterations "
2561
+ f"(mean |cosine| {quality:.2f}). Per-dimension frequencies are unreliable "
2562
+ "here; use frequency.max(axis=1) for the flat union instead.",
2563
+ UserWarning,
2564
+ stacklevel=3,
2565
+ )
2566
+ from ._stability import _coefficient_statistics
2567
+
2568
+ cond_mean, cond_sd, sign_consistency = _coefficient_statistics(
2569
+ coef_sum, coef_sq_sum, positive_count, counts
2570
+ )
2571
+ return (
2572
+ counts / n_iterations,
2573
+ np.mean(np.vstack(support_sizes), axis=0),
2574
+ quality,
2575
+ (cond_mean, cond_sd, sign_consistency),
2576
+ )
2577
+
2578
+ def stability_selection(
2579
+ self,
2580
+ adata: AnnData,
2581
+ *,
2582
+ mode: str = "iteration",
2583
+ n_runs: int = 300,
2584
+ subsample_frac: float = 0.5,
2585
+ threshold: float = 0.7,
2586
+ seed: int | None = None,
2587
+ n_subsamples: int | None = None,
2588
+ n_iterations: int | None = None,
2589
+ verbose: bool = True,
2590
+ ):
2591
+ """Stability-select genes for each latent dimension of the fitted model.
2592
+
2593
+ The model is frozen. Its functional-gradient targets ``z*`` are recomputed
2594
+ once, then :func:`~structboost.stability_selection` re-runs the encoder's
2595
+ boosting problem on many subsamples of the cells and records, per latent
2596
+ dimension, how often each gene is selected. This costs a fraction of one
2597
+ fit and needs no refitting — a single converged model is enough.
2598
+
2599
+ Targets are ``z*`` rather than the latent codes: the codes are a sparse
2600
+ linear function of the already-selected genes, so selecting them back is
2601
+ nearly circular, whereas ``z*`` carries the decoder's full reconstruction
2602
+ gradient. The resampled problem reuses the model's boosting
2603
+ hyperparameters, mandatory genes, and (if fitted) nuisance regressors,
2604
+ conditioning and balancing, so it matches the selection the encoder solved.
2605
+
2606
+ Scope. This measures stability under *cell resampling, conditional on the
2607
+ learned representation*. It does not capture the variability from
2608
+ re-initializing and refitting the autoencoder to a different local optimum,
2609
+ which full refits would. On simulated data the per-gene selection
2610
+ frequencies track a full-refit gold standard closely (correlation ~0.95),
2611
+ so it is a good, far cheaper proxy; but where the model has several
2612
+ competing optima, high-stakes marker claims may still warrant a handful of
2613
+ full refits as a cross-check.
2614
+
2615
+ Parameters
2616
+ ----------
2617
+ adata
2618
+ The data the model was fitted on (same genes; obs columns required only
2619
+ if the model used conditioning, nuisance or balancing covariates).
2620
+ mode
2621
+ Which source of instability to measure.
2622
+
2623
+ ``"iteration"`` (default)
2624
+ Selection frequency across ``n_runs`` further training
2625
+ iterations. Answers "would these genes still be selected if the
2626
+ optimizer had stopped somewhere else on its loss plateau?" The two
2627
+ are complementary, not alternatives: they measure different variance
2628
+ sources, and on measured data the second is the larger one, because
2629
+ the encoder support keeps moving long after the loss has converged.
2630
+
2631
+ Costs ``n_runs`` further training steps and needs no refitting or
2632
+ subsampling. It is the default because it is the mode that measures
2633
+ the variance that actually dominates: on measured data the
2634
+ reconstruction loss converges while the encoder support keeps
2635
+ moving. On simulated data with exact ground truth it also gives the
2636
+ lowest false-discovery rate of the three readouts (0.26, against
2637
+ 0.31 for subsampling and 0.38 for a single fit), and its
2638
+ frequencies are empirically well calibrated — genes selected in
2639
+ 90-100% of iterations are markers 88% of the time.
2640
+
2641
+ Provides **no formal error control**; see
2642
+ :class:`~structboost.StabilitySelectionResult`.
2643
+ ``"subsample"``
2644
+ Meinshausen-Bühlmann cell subsampling with the model frozen. Answers
2645
+ "would these genes still be selected on a different sample of cells?"
2646
+
2647
+ .. warning::
2648
+ ``expected_false_positives`` reports the Meinshausen-Bühlmann
2649
+ bound, and on simulated data where the truth is known that bound
2650
+ is **violated by roughly an order of magnitude** (mean realized
2651
+ 2.57 false positives per dimension against a bound of 0.20;
2652
+ satisfied in 15 of 60 dimensions). The likely cause is a scope
2653
+ error rather than an implementation bug: the bound assumes
2654
+ exchangeable subsamples of an inference problem fixed in advance,
2655
+ whereas the targets ``z*`` here come from a model fitted on the
2656
+ same cells being resampled. Treat the number as a diagnostic,
2657
+ not a guarantee. This mode is retained for investigation.
2658
+ n_runs
2659
+ How many resampling runs to average over — cell subsamples in subsample
2660
+ mode, further training iterations in iteration mode. One name because it
2661
+ plays the same role in both.
2662
+
2663
+ The default of 300 is set by iteration mode's requirement: the support
2664
+ autocorrelation decays slowly (still ~0.45 at lag 100 on measured data),
2665
+ so short windows give highly correlated, near-duplicate samples.
2666
+ Subsample mode is well served by fewer — 100 was this method's previous
2667
+ default — so pass ``n_runs=100`` there if the threefold cost matters.
2668
+ subsample_frac
2669
+ Subsample mode only. ``0.5`` is the value Meinshausen & Bühlmann (2010)
2670
+ derive the bound for.
2671
+ threshold
2672
+ Selection-frequency cutoff for the stable support, in both modes.
2673
+ seed
2674
+ Seeds the subsampling RNG (subsample mode) or torch (iteration mode).
2675
+ n_subsamples, n_iterations
2676
+ Deprecated aliases for ``n_runs``, kept so existing calls keep working.
2677
+ verbose
2678
+ Show a progress bar. On by default: the iteration mode's ``n_runs``
2679
+ defaults to 300 further training steps, which is a long silence.
2680
+
2681
+ Returns
2682
+ -------
2683
+ StabilitySelectionResult
2684
+ Subsample mode stores ``adata.varm["BAE_selection_frequency"]``;
2685
+ iteration mode stores ``adata.varm["BAE_iteration_frequency"]``. Both
2686
+ are ``(n_genes, latent_dim)``: iteration mode matches each iteration's
2687
+ dimensions to the fitted model's before counting, so a dimension index
2688
+ keeps its meaning — see ``dim_match_quality``, and fall back to
2689
+ ``frequency.max(axis=1)`` when it is low. Both write a summary under
2690
+ ``adata.uns["bae"]["stability_selection"]``, tagged with the mode that
2691
+ produced it.
2692
+ """
2693
+ if not self._is_fitted:
2694
+ raise RuntimeError("Model not fitted. Call fit() first.")
2695
+ if mode not in {"subsample", "iteration"}:
2696
+ raise ValueError(f"mode must be 'subsample' or 'iteration', got {mode!r}")
2697
+ for old_name, old_value in (("n_subsamples", n_subsamples), ("n_iterations", n_iterations)):
2698
+ if old_value is not None:
2699
+ warnings.warn(
2700
+ f"{old_name} is deprecated; use n_runs, which names the same "
2701
+ "quantity in both modes.",
2702
+ FutureWarning,
2703
+ stacklevel=2,
2704
+ )
2705
+ n_runs = old_value
2706
+ if n_runs < 1:
2707
+ raise ValueError(f"n_runs must be >= 1, got {n_runs}")
2708
+ if not 0.0 < threshold <= 1.0:
2709
+ raise ValueError(f"threshold must be in (0, 1], got {threshold}")
2710
+
2711
+ if mode == "iteration":
2712
+ from ._stability import StabilitySelectionResult
2713
+
2714
+ frequency, avg_selected, quality, coefficients = self._iteration_support_frequency(
2715
+ adata, n_iterations=n_runs, seed=seed, verbose=verbose
2716
+ )
2717
+ result = StabilitySelectionResult(
2718
+ frequency=frequency,
2719
+ stable_support=frequency >= threshold,
2720
+ threshold=float(threshold),
2721
+ avg_selected=avg_selected,
2722
+ # Training iterations are neither independent nor exchangeable, so
2723
+ # the Meinshausen-Bühlmann bound does not apply. Deliberately NaN
2724
+ # rather than a number that would look like error control.
2725
+ expected_false_positives=np.full(frequency.shape[1], np.nan),
2726
+ n_subsamples=0,
2727
+ subsample_frac=float("nan"),
2728
+ mode="iteration",
2729
+ n_iterations=int(n_runs),
2730
+ dim_match_quality=quality,
2731
+ coefficient_cond_mean=coefficients[0],
2732
+ coefficient_sd=coefficients[1],
2733
+ sign_consistency=coefficients[2],
2734
+ )
2735
+ adata.varm["BAE_iteration_frequency"] = result.frequency
2736
+ uns = adata.uns.setdefault("bae", {})
2737
+ uns["stability_selection"] = {
2738
+ "mode": "iteration",
2739
+ "threshold": result.threshold,
2740
+ "n_iterations": result.n_iterations,
2741
+ "n_stable_per_dim": result.stable_support.sum(axis=0).astype(np.intp),
2742
+ "avg_selected_per_dim": result.avg_selected,
2743
+ "dim_match_quality": quality,
2744
+ "expected_false_positives_per_dim": result.expected_false_positives,
2745
+ }
2746
+ return result
2747
+
2748
+ from ._stability import stability_selection as _run_stability
2749
+ from ._utils import resolve_mandatory_genes, transform_obs_covariates
2750
+
2751
+ matrix = _expression_matrix(adata, self._layer)
2752
+ X_np = matrix.toarray() if sp.issparse(matrix) else np.asarray(matrix)
2753
+ X_np = X_np.astype(np.float32)
2754
+ X_t = torch.from_numpy(X_np).to(self.config.device)
2755
+
2756
+ # Rebuild the same covariate context the encoder was fitted under, so the
2757
+ # resampled selection problem is the one the model actually solved.
2758
+ D_condition = None
2759
+ if self._conditions_decoder:
2760
+ D_condition = self._to_tensor(
2761
+ transform_obs_covariates(adata, self._batch_encoding), self.config.device
2762
+ )
2763
+ weights_np = self._balance_weights(adata, self._balance_obs)
2764
+ weights_t = (
2765
+ torch.from_numpy(weights_np).to(self.config.device) if weights_np is not None else None
2766
+ )
2767
+
2768
+ targets = self._compute_boosting_targets(
2769
+ X_t,
2770
+ lr=self.config.target_optim_lr,
2771
+ obs_covariates=D_condition,
2772
+ sample_weights=weights_t,
2773
+ )
2774
+
2775
+ sourcemat = X_np.astype(np.float64)
2776
+ n_nuisance = 0
2777
+ if self._regresses_encoder:
2778
+ D_nuisance = transform_obs_covariates(adata, self._batch_encoding)
2779
+ sourcemat = np.hstack([sourcemat, np.asarray(D_nuisance, dtype=np.float64)])
2780
+ n_nuisance = self._batch_encoding.n_columns
2781
+
2782
+ resolved_mandatory = resolve_mandatory_genes(self._mandatory_genes, adata)
2783
+ mandatory = _build_allboost_mandatory(resolved_mandatory, n_nuisance, self.n_genes)
2784
+ mandatory_ridge = np.zeros(sourcemat.shape[1], dtype=np.float64)
2785
+ if n_nuisance:
2786
+ mandatory_ridge[self.n_genes :] = self.config.nuisance_ridge
2787
+
2788
+ result = _run_stability(
2789
+ sourcemat,
2790
+ targets,
2791
+ n_genes=self.n_genes,
2792
+ mandatory_features=mandatory,
2793
+ mandatory_ridge=mandatory_ridge,
2794
+ n_subsamples=n_runs,
2795
+ subsample_frac=subsample_frac,
2796
+ threshold=threshold,
2797
+ stepno=self.config.boosting_stepno,
2798
+ nu=self.config.boosting_nu,
2799
+ csf=self.config.boosting_csf,
2800
+ independent=self.config.boosting_independent,
2801
+ seed=seed,
2802
+ verbose=verbose,
2803
+ )
2804
+
2805
+ adata.varm["BAE_selection_frequency"] = result.frequency
2806
+ uns = adata.uns.setdefault("bae", {})
2807
+ uns["stability_selection"] = {
2808
+ "mode": "subsample",
2809
+ "threshold": result.threshold,
2810
+ "n_subsamples": result.n_subsamples,
2811
+ "subsample_frac": result.subsample_frac,
2812
+ "n_stable_per_dim": result.stable_support.sum(axis=0).astype(np.intp),
2813
+ "avg_selected_per_dim": result.avg_selected,
2814
+ "expected_false_positives_per_dim": result.expected_false_positives,
2815
+ }
2816
+ return result
2817
+
2818
+ def apply_encoder(
2819
+ self,
2820
+ weights: np.ndarray,
2821
+ adata: AnnData | None = None,
2822
+ *,
2823
+ preserve_prior: bool = True,
2824
+ ) -> BAE:
2825
+ """Install aggregated encoder weights, replacing the fitted ones.
2826
+
2827
+ Deliberately separate from :meth:`stability_selection`, which stays
2828
+ non-destructive. A diagnostic that silently swapped the encoder would make
2829
+ ``fit()`` followed by a reliability check produce a different model than
2830
+ ``fit()`` alone, and would compound if called twice. It is also not a
2831
+ strictly better encoder but a **choice**: ``"masked_cond_mean"`` buys
2832
+ precision (0.74 vs 0.62 for the fitted encoder on simulated data) at the
2833
+ cost of recall (0.27 vs 0.31), and that trade belongs to the caller — tune
2834
+ it with ``stability_selection(threshold=...)``.
2835
+
2836
+ The decoder is left untouched.
2837
+ :meth:`~structboost.StabilitySelectionResult.stable_encoder` preserves the
2838
+ latent scale, and on simulated data the aggregate *improved* reconstruction
2839
+ relative to the fitted encoder (59% of the linear ceiling against 55%), so
2840
+ no refit is required.
2841
+
2842
+ Parameters
2843
+ ----------
2844
+ weights
2845
+ Shape ``(n_genes, latent_dim)`` — the orientation returned by
2846
+ ``stable_encoder`` and stored in ``adata.varm``.
2847
+ adata
2848
+ If given, refresh ``varm["BAE_encoder_weights"]`` and ``obsm["X_bae"]``
2849
+ so the stored results match the installed encoder rather than the
2850
+ superseded one.
2851
+ preserve_prior
2852
+ On a model built by :meth:`from_reference`, keep the transferred block
2853
+ rather than taking it from ``weights``. Default ``True``, because the
2854
+ obvious call — installing
2855
+ :meth:`~structboost.StabilitySelectionResult.stable_encoder` — would
2856
+ otherwise **delete the transferred programs**: that estimator zeroes
2857
+ every entry outside the stable support, and under
2858
+ ``prior_mode="frozen"`` the prior columns have selection frequency
2859
+ zero by construction (they cannot vary, so there is nothing to be
2860
+ stable about). The result would be an encoder whose prior block is
2861
+ all zeros, with no error raised. Pass ``False`` only to overwrite the
2862
+ transferred programs deliberately.
2863
+
2864
+ Returns
2865
+ -------
2866
+ Self, for chaining.
2867
+
2868
+ Examples
2869
+ --------
2870
+ >>> res = model.stability_selection(adata)
2871
+ >>> model.apply_encoder(res.stable_encoder(), adata) # doctest: +SKIP
2872
+ """
2873
+ if not self._is_fitted:
2874
+ raise RuntimeError("Model not fitted. Call fit() first.")
2875
+ weights = np.asarray(weights)
2876
+ expected = (self.n_genes, self.config.latent_dim)
2877
+ if weights.shape != expected:
2878
+ raise ValueError(
2879
+ f"weights must have shape {expected} (n_genes, latent_dim), got {weights.shape}"
2880
+ )
2881
+ if preserve_prior and self._prior_weights is not None:
2882
+ weights = weights.copy()
2883
+ weights[:, : self.n_prior_dims] = self.fitted_prior_weights
2884
+ if not np.isfinite(weights).all():
2885
+ raise ValueError("weights must be finite")
2886
+
2887
+ previous = self.encoder.linear.weight.detach().clone()
2888
+ self.encoder.set_weights(
2889
+ torch.from_numpy(np.ascontiguousarray(weights.T, dtype=np.float32)).to(
2890
+ self.config.device
2891
+ )
2892
+ )
2893
+ if adata is not None:
2894
+ self._warn_if_integration_degraded(adata, previous)
2895
+ adata.varm["BAE_encoder_weights"] = weights.astype(np.float32)
2896
+ matrix = _expression_matrix(adata, self._layer)
2897
+ latent = self.get_latent(self._to_tensor(matrix, self.config.device)).cpu().numpy()
2898
+ adata.obsm["X_bae"] = latent
2899
+ # The encoder changed, so the scaling statistics belong to a
2900
+ # superseded model. Re-estimating here is what stops X_bae_scaled
2901
+ # from silently describing weights that are no longer installed.
2902
+ if self._prior_weights is not None:
2903
+ self._refit_latent_scaling(latent)
2904
+ self._store_scaled_latent(adata, latent)
2905
+ adata.uns.setdefault("bae", {})["encoder_source"] = "aggregated"
2906
+ return self
2907
+
2908
+ def transfer_diagnostics(self, adata: AnnData) -> dict[str, object]:
2909
+ """Transfer diagnostics evaluated on ``adata``, which need not be the fit data.
2910
+
2911
+ ``fit`` writes these for the cells it trained on, where
2912
+ ``novel_variance_share`` is positive by construction: k free dimensions
2913
+ reduce in-sample reconstruction error whether or not the data contains
2914
+ anything the prior programs missed.
2915
+
2916
+ Passing **held-out cells** is what turns the number into evidence. Capacity
2917
+ that merely fits noise does not generalize, so a novel dimension carrying
2918
+ real structure keeps its share out of sample while one that does not
2919
+ collapses. This needs nothing but the model and your own data — no access
2920
+ to the reference dataset, and no marker ground truth — which matters
2921
+ because a prior encoder matrix is often all that is shared.
2922
+
2923
+ Parameters
2924
+ ----------
2925
+ adata
2926
+ Cells to evaluate on, over the same gene panel the model was aligned
2927
+ to. Hold these out of ``fit`` for an out-of-sample reading.
2928
+
2929
+ Returns
2930
+ -------
2931
+ The same mapping ``fit`` stores in ``adata.uns["bae_transfer"]``.
2932
+
2933
+ Raises
2934
+ ------
2935
+ ValueError
2936
+ If the model carries no prior matrix, or the panel does not match.
2937
+ """
2938
+ if self._prior_weights is None:
2939
+ raise ValueError(
2940
+ "transfer_diagnostics is only defined for a model built by "
2941
+ "BAE.from_reference; an ordinary fit has no prior/novel split."
2942
+ )
2943
+ if adata.n_vars != self.n_genes:
2944
+ raise ValueError(
2945
+ f"adata has {adata.n_vars} genes but the model was aligned to "
2946
+ f"{self.n_genes}. Pass cells over the same panel."
2947
+ )
2948
+ X = self._to_tensor(_expression_matrix(adata, self._layer), self.config.device)
2949
+ weights = self.encoder.linear.weight.detach().cpu().numpy().T
2950
+ return self._transfer_diagnostics(adata, X, weights)
2951
+
2952
+ #: obsm key for the per-dimension standardized latent, written only by
2953
+ #: transfer models. See ``_refit_latent_scaling``.
2954
+ SCALED_LATENT_KEY: str = "X_bae_scaled"
2955
+
2956
+ def _refit_latent_scaling(self, latent: np.ndarray) -> None:
2957
+ """Re-estimate the per-dimension standardization from a training latent.
2958
+
2959
+ Called whenever the *encoder* changes on the data it was fitted to — at
2960
+ the end of ``fit`` and on ``apply_encoder``. Not called by ``transform``:
2961
+ mapping new cells must reuse these statistics, or the transformation
2962
+ would differ per call and be poorly estimated on small held-out sets.
2963
+ """
2964
+ mean = latent.mean(axis=0)
2965
+ scale = latent.std(axis=0)
2966
+ scale = np.where(scale < BAE._STD_EPS, 1.0, scale)
2967
+ self._latent_scaling = {"mean": mean, "scale": scale}
2968
+
2969
+ def _store_scaled_latent(self, adata: AnnData, latent: np.ndarray) -> None:
2970
+ """Write the standardized latent alongside the raw one, transfers only.
2971
+
2972
+ ``obsm["X_bae"]`` stays exactly ``X @ varm["BAE_encoder_weights"]``, which
2973
+ is what makes the encoder auditable and the frozen-prior guarantee
2974
+ checkable end to end. It is deliberately *not* overwritten here.
2975
+
2976
+ A transfer's two blocks are on incomparable scales: hand-authored prior
2977
+ coefficients are written on a human scale, boosted ones land wherever the
2978
+ gradient targets put them. Measured on the Tasic transfer, the prior
2979
+ dimensions had a median latent SD of 9.98 against 0.043 for the novel
2980
+ ones — a 232x gap, and because distance is squared the novel block
2981
+ contributed ~0.00% of the total. Every Euclidean consumer inherits that:
2982
+ neighbour graphs, Leiden, kNN, kBET/LISI. This key is the one to hand
2983
+ those tools.
2984
+
2985
+ Centering also cannot be folded back into the weights, since the encoder
2986
+ has no bias term — another reason this is a separate key rather than a
2987
+ rescaled ``X_bae``.
2988
+ """
2989
+ if self._prior_weights is None or self._latent_scaling is None:
2990
+ return
2991
+ scaling = self._latent_scaling
2992
+ adata.obsm[BAE.SCALED_LATENT_KEY] = (latent - scaling["mean"]) / scaling["scale"]
2993
+ info = adata.uns.setdefault("bae_transfer", {})
2994
+ info["latent_mean"] = scaling["mean"]
2995
+ info["latent_scale"] = scaling["scale"]
2996
+
2997
+ def _transfer_diagnostics(
2998
+ self, adata: AnnData, X: torch.Tensor, encoder_weights: np.ndarray
2999
+ ) -> dict[str, object]:
3000
+ """Summary of a transfer fit, written to ``adata.uns["bae_transfer"]``.
3001
+
3002
+ ``novel_variance_share`` is the rise in reconstruction MSE when the added
3003
+ dimensions are zeroed. **On the fitting data it is not evidence of novel
3004
+ biology:** k free dimensions always reduce in-sample reconstruction error,
3005
+ so the number is positive even when the target contains nothing the prior
3006
+ programs missed — measured on gene-shuffled data with no recoverable
3007
+ structure at all, it still reads +0.002 to +0.008 as k grows from 1 to 4.
3008
+ It also grows with k under the null, so raw values are not comparable
3009
+ across different ``n_additional_dims``.
3010
+
3011
+ Use :meth:`transfer_diagnostics` on **held-out cells** to make it
3012
+ interpretable; that requires only the model and your own data. Where the
3013
+ reference dataset is also available, a transfer onto held-out *reference*
3014
+ cells gives a second, stricter null: the prior programs were fitted on
3015
+ that population, so whatever share the novel dimensions still claim there
3016
+ is capacity fitting noise rather than structure the prior missed.
3017
+
3018
+ ``novel_variance_share_per_dim`` is the same quantity computed by dropping
3019
+ one novel dimension at a time. The entries do not sum to the aggregate:
3020
+ the dimensions are not orthogonal, so structure carried by several of them
3021
+ is counted in each. A dimension near zero is a candidate for reducing
3022
+ ``n_additional_dims``.
3023
+
3024
+ Support sizes are reported separately for the prior and novel blocks
3025
+ because the union is dominated by the prior: a sparsity check against the
3026
+ combined count would be measuring the reference matrix, not this fit.
3027
+ """
3028
+ assert self._prior_weights is not None
3029
+ prior_dims = self._prior_weights.shape[1]
3030
+
3031
+ covariates = None
3032
+ if self._conditions_decoder:
3033
+ from ._utils import transform_obs_covariates
3034
+
3035
+ covariates = self._to_tensor(
3036
+ transform_obs_covariates(adata, self._batch_encoding), self.config.device
3037
+ )
3038
+
3039
+ def reconstruction_mse(weights: torch.Tensor) -> float:
3040
+ saved = self.encoder.linear.weight.detach().clone()
3041
+ try:
3042
+ self.encoder.set_weights(weights)
3043
+ self.eval()
3044
+ with torch.no_grad():
3045
+ z = self.encoder(X)
3046
+ h = self.split_softmax_layer(z) if self.split_softmax_layer else z
3047
+ return float(nn.functional.mse_loss(self.decoder(h, covariates), X))
3048
+ finally:
3049
+ self.encoder.set_weights(saved)
3050
+
3051
+ full = self.encoder.linear.weight.detach().clone()
3052
+ prior_only = full.clone()
3053
+ prior_only[prior_dims:] = 0.0
3054
+ mse_full = reconstruction_mse(full)
3055
+ mse_prior_only = reconstruction_mse(prior_only)
3056
+
3057
+ # Leave-one-out per novel dimension: the aggregate share cannot say which
3058
+ # added dimension is doing the work, and a dimension contributing ~0 is a
3059
+ # candidate for reducing `n_additional_dims`. These do not sum to the
3060
+ # aggregate — the dimensions are not orthogonal, so shared structure is
3061
+ # counted by every dimension carrying it.
3062
+ per_dim = []
3063
+ for j in range(prior_dims, full.shape[0]):
3064
+ dropped = full.clone()
3065
+ dropped[j] = 0.0
3066
+ per_dim.append(reconstruction_mse(dropped) - mse_full)
3067
+
3068
+ prior_block = encoder_weights[:, :prior_dims]
3069
+ novel_block = encoder_weights[:, prior_dims:]
3070
+ return {
3071
+ **{k: v for k, v in self._prior_info.items() if k != "reference_panel"},
3072
+ "prior_mode": self.config.prior_mode,
3073
+ "n_selected_prior": int((np.abs(prior_block) > 0).any(axis=1).sum()),
3074
+ "n_selected_novel": int((np.abs(novel_block) > 0).any(axis=1).sum()),
3075
+ "n_selected_novel_per_dim": [int(c) for c in (np.abs(novel_block) > 0).sum(axis=0)],
3076
+ "variance_explained": 1.0 - mse_full,
3077
+ "variance_explained_prior_only": 1.0 - mse_prior_only,
3078
+ "novel_variance_share": mse_prior_only - mse_full,
3079
+ "novel_variance_share_per_dim": per_dim,
3080
+ # Compared at float32, the encoder's storage precision. A float64
3081
+ # prior read from Parquet generally is NOT float32-representable
3082
+ # (2.6 becomes 2.5999999046325684), so comparing at float64 reports
3083
+ # drift for every such prior even though nothing moved.
3084
+ "prior_weights_unchanged": bool(
3085
+ np.array_equal(
3086
+ prior_block.astype(np.float32),
3087
+ self._prior_weights.astype(np.float32),
3088
+ )
3089
+ ),
3090
+ }
3091
+
3092
+ def _warn_if_integration_degraded(
3093
+ self, adata: AnnData, previous_weights: torch.Tensor, tolerance: float = 0.05
3094
+ ) -> None:
3095
+ """Warn when an aggregated encoder reintroduces covariate signal.
3096
+
3097
+ Batch integration is a property of the encoder *as a whole*: the nuisance
3098
+ regressors absorb covariate signal during boosting, and the surviving gene
3099
+ coefficients partially cancel each other's residual. Thresholding that
3100
+ vector is therefore **not a covariate-neutral operation** — dropping
3101
+ low-frequency genes can remove exactly the terms providing the cancellation.
3102
+
3103
+ Whether that actually happens depends on the fit. A first, underpowered
3104
+ check (60 training iterations, 10 stability runs) showed the
3105
+ ``"masked_cond_mean"`` encoder raising the maximum per-dimension batch R^2
3106
+ from 0.021 to 0.281. A better-powered rerun (300 iterations, 100 runs) did
3107
+ **not** reproduce it: every aggregate slightly *lowered* batch R^2 relative
3108
+ to the fitted encoder (0.228 fitted, 0.200 masked, 0.166 unmasked). The
3109
+ likely reading is that a coarse frequency estimate from few runs produces a
3110
+ crude mask, not that masking is inherently unsafe.
3111
+
3112
+ Which is exactly why this measures the change per call instead of warning
3113
+ unconditionally: the hazard is real but conditional, so a caller whose
3114
+ integration survives is never nagged.
3115
+ """
3116
+ encoding = self._batch_encoding
3117
+ if encoding is None:
3118
+ return
3119
+ from ._utils import transform_obs_covariates
3120
+
3121
+ try:
3122
+ design = transform_obs_covariates(adata, encoding)
3123
+ except Exception: # noqa: BLE001 - diagnostic only; never block installation
3124
+ return
3125
+
3126
+ def covariate_r2(weights: torch.Tensor) -> float:
3127
+ saved = self.encoder.linear.weight.detach().clone()
3128
+ try:
3129
+ self.encoder.set_weights(weights)
3130
+ matrix = _expression_matrix(adata, self._layer)
3131
+ z = self.get_latent(self._to_tensor(matrix, self.config.device)).cpu().numpy()
3132
+ finally:
3133
+ self.encoder.set_weights(saved)
3134
+ centered = z - z.mean(axis=0, keepdims=True)
3135
+ fitted = design @ np.linalg.lstsq(design, centered, rcond=None)[0]
3136
+ ss_total = (centered**2).sum(axis=0)
3137
+ ss_residual = ((centered - fitted) ** 2).sum(axis=0)
3138
+ # `where=` has to guard the division itself. Writing
3139
+ # `np.divide(1.0 - ss_residual / ss_total, 1.0, where=ss_total > 0)`
3140
+ # evaluates the inner `/` eagerly over every column, so a dead latent
3141
+ # dimension still divides by zero and warns before `where` discards
3142
+ # the result. Same values, no warning.
3143
+ has_variance = ss_total > 0
3144
+ ratio = np.divide(
3145
+ ss_residual, ss_total, out=np.zeros_like(ss_total), where=has_variance
3146
+ )
3147
+ r2 = np.where(has_variance, 1.0 - ratio, 0.0)
3148
+ return float(np.max(r2))
3149
+
3150
+ before = covariate_r2(previous_weights)
3151
+ after = covariate_r2(self.encoder.linear.weight.detach().clone())
3152
+ if after - before > tolerance:
3153
+ warnings.warn(
3154
+ f"The installed encoder reintroduced covariate signal into the latent "
3155
+ f"space: maximum per-dimension R^2 against the conditioned covariates "
3156
+ f"rose from {before:.3f} to {after:.3f}. Integration is a property of the "
3157
+ "whole coefficient vector, so thresholding it can remove terms that were "
3158
+ "cancelling residual covariate effects. Consider a lower "
3159
+ "stability_selection(threshold=...), which keeps more of them, and "
3160
+ 'check adata.uns["bae"]["latent_obs_r2_per_dim"].',
3161
+ UserWarning,
3162
+ stacklevel=3,
3163
+ )
3164
+
3165
+ def transform_splitsoftmax(self, adata: AnnData) -> np.ndarray:
3166
+ """Transform data to split-softmax representation.
3167
+
3168
+ Computes the split-softmax compositional representation h in Delta^{2d-1}
3169
+ from the encoder output z. Each latent dimension z_i is paired with its
3170
+ negation -z_i (interleaved) and softmax-normalized:
3171
+
3172
+ h = softmax((z_1, -z_1, ..., z_d, -z_d))
3173
+
3174
+ Can be called on models trained with or without split_softmax=True.
3175
+ When split_softmax was not enabled during training, a warning is emitted
3176
+ and the transformation is applied post-hoc.
3177
+
3178
+ Parameters
3179
+ ----------
3180
+ adata
3181
+ AnnData object.
3182
+
3183
+ Returns
3184
+ -------
3185
+ Split-softmax representation of shape (n_cells, 2 * latent_dim).
3186
+
3187
+ Raises
3188
+ ------
3189
+ RuntimeError
3190
+ If model is not fitted.
3191
+ """
3192
+ if not self._is_fitted:
3193
+ raise RuntimeError("Model not fitted. Call fit() first.")
3194
+
3195
+ ssm = self.split_softmax_layer
3196
+ if ssm is None:
3197
+ warnings.warn(
3198
+ "Model was not trained with split_softmax=True. "
3199
+ "Applying split-softmax transformation post-hoc.",
3200
+ UserWarning,
3201
+ stacklevel=2,
3202
+ )
3203
+ ssm = SplitSoftmax()
3204
+
3205
+ X = self._to_tensor(_expression_matrix(adata, self._layer), self.config.device)
3206
+ self.eval()
3207
+ with torch.no_grad():
3208
+ z = self.encoder(X)
3209
+ h = ssm(z)
3210
+
3211
+ h_np = h.cpu().numpy()
3212
+ adata.obsm["X_bae_splitsoftmax"] = h_np
3213
+
3214
+ # Store clipped split-softmax encoder weights
3215
+ adata.varm["bae_program_weights"] = self.get_splitsoftmax_encoder_weights(
3216
+ clip_negative=True, as_numpy=True
3217
+ )
3218
+
3219
+ return h_np
3220
+
3221
+ def fit_transform(self, adata: AnnData, **fit_kwargs) -> np.ndarray:
3222
+ """Fit model and return latent representation.
3223
+
3224
+ Parameters
3225
+ ----------
3226
+ adata
3227
+ AnnData object.
3228
+ **fit_kwargs
3229
+ Arguments passed to fit().
3230
+
3231
+ Returns
3232
+ -------
3233
+ Latent representation.
3234
+ """
3235
+ self.fit(adata, **fit_kwargs)
3236
+ return adata.obsm["X_bae"]
3237
+
3238
+ def _reconstruction_stats(self, adata: AnnData) -> tuple[np.ndarray, float]:
3239
+ """Per-cell reconstruction MSE, and the fraction of variance explained.
3240
+
3241
+ Streams over the cells in ``batch_size`` chunks. The obvious
3242
+ implementation — reconstruct everything, densify ``adata.X``, subtract —
3243
+ holds two ``(n_cells, n_genes)`` arrays at once, which is ~8 GB at 100,000
3244
+ cells by 20,000 genes and is allocated at the very *end* of an otherwise
3245
+ successful fit. Streaming keeps the peak at one chunk.
3246
+
3247
+ Returns
3248
+ -------
3249
+ residual
3250
+ Mean squared error per cell, shape ``(n_cells,)``.
3251
+ variance_explained
3252
+ ``1 - SSE/SST``, where SST is computed about the per-gene mean. On the
3253
+ z-transformed input BAE expects, SST per element is ~1, so this is
3254
+ close to ``1 - MSE``; computing it properly keeps the number honest if
3255
+ the input was not standardized.
3256
+ """
3257
+ from ._utils import transform_obs_covariates
3258
+
3259
+ D_all = None
3260
+ if self._conditions_decoder:
3261
+ D_all = transform_obs_covariates(adata, self._batch_encoding)
3262
+
3263
+ X = _expression_matrix(adata, self._layer)
3264
+ col_means = np.asarray(X.mean(axis=0)).ravel()
3265
+ means_t = torch.from_numpy(col_means.astype(np.float32)).to(self.config.device)
3266
+
3267
+ residual = np.empty(adata.n_obs, dtype=np.float64)
3268
+ ss_error = 0.0
3269
+ ss_total = 0.0
3270
+ step = max(int(self.config.batch_size), 1)
3271
+
3272
+ self.eval()
3273
+ with torch.no_grad():
3274
+ for start in range(0, adata.n_obs, step):
3275
+ stop = min(start + step, adata.n_obs)
3276
+ chunk = X[start:stop]
3277
+ x = self._to_tensor(chunk, self.config.device)
3278
+ d = (
3279
+ self._to_tensor(D_all[start:stop], self.config.device)
3280
+ if D_all is not None
3281
+ else None
3282
+ )
3283
+ recon, _ = self.forward(x, d)
3284
+ squared_error = (recon - x).square()
3285
+ residual[start:stop] = squared_error.mean(dim=1).cpu().numpy()
3286
+ ss_error += float(squared_error.sum())
3287
+ ss_total += float((x - means_t).square().sum())
3288
+
3289
+ explained = 1.0 - ss_error / ss_total if ss_total > 0 else float("nan")
3290
+ return residual, explained
3291
+
3292
+ def _store_results(self, adata: AnnData) -> None:
3293
+ """Store results in AnnData (scverse convention).
3294
+
3295
+ Two of the recorded metrics answer different questions and are easy to
3296
+ confuse:
3297
+
3298
+ ``latent_obs_r2_per_dim``
3299
+ Fraction of each latent dimension's variance explained by the
3300
+ conditioned obs columns. This is the **integration** metric — it says
3301
+ whether the covariate signal is gone from the representation. Values
3302
+ near zero are the goal; a single high dimension is a residual
3303
+ covariate axis worth inspecting.
3304
+ ``reconstruction_loss_by_obs``
3305
+ Reconstruction MSE per group. This is a **fairness** metric — it says
3306
+ whether the model fits all groups comparably, not whether it
3307
+ integrated them. A group can be reconstructed poorly in a perfectly
3308
+ integrated model, and vice versa.
3309
+
3310
+ ``variance_explained`` anchors the raw losses, which are otherwise
3311
+ unreadable: on the z-transformed input BAE expects, an MSE of 1.0 is what
3312
+ predicting zero everywhere scores. Compare it with
3313
+ :func:`~structboost.linear_ceiling` for the achievable maximum.
3314
+ """
3315
+ X = self._to_tensor(_expression_matrix(adata, self._layer), self.config.device)
3316
+
3317
+ # Recorded so a fitted model is itself a usable prior for a later transfer:
3318
+ # `from_reference` needs gene identifiers, which the weight matrix lacks.
3319
+ self._var_names = np.asarray(adata.var_names, dtype=object)
3320
+
3321
+ # Latent embedding: adata.obsm["X_bae"] — purely gene-based
3322
+ z = self.get_latent(X)
3323
+ latent = z.cpu().numpy()
3324
+ adata.obsm["X_bae"] = latent
3325
+ if self._prior_weights is not None:
3326
+ self._refit_latent_scaling(latent)
3327
+
3328
+ # Encoder weights: adata.varm["BAE_encoder_weights"]
3329
+ # Shape: (n_genes, latent_dim) — transposed from (latent_dim, n_genes)
3330
+ encoder_weights = self.encoder.linear.weight.detach().cpu().numpy().T
3331
+ adata.varm["BAE_encoder_weights"] = encoder_weights
3332
+
3333
+ # Metadata: adata.uns["bae"]
3334
+ uns_dict: dict = {
3335
+ "latent_dim": self.config.latent_dim,
3336
+ "is_fitted": self._is_fitted,
3337
+ "training_history": self._training_history,
3338
+ "latent_init": self._latent_init,
3339
+ "disentanglement": self.config.disentanglement,
3340
+ }
3341
+ # Which matrix the fit read. Absent when it was `adata.X`: `uns` cannot
3342
+ # hold None, and no key is the honest encoding of "the default".
3343
+ if self._layer is not None:
3344
+ uns_dict["layer"] = self._layer
3345
+ if self.config.disentanglement == "correlation":
3346
+ uns_dict["disentanglement_lambda"] = self.config.disentanglement_lambda
3347
+ elif self.config.disentanglement == "leave_one_out":
3348
+ uns_dict["disentanglement_standardize"] = self.config.disentanglement_standardize
3349
+ if self._training_report is not None:
3350
+ uns_dict["training_report"] = self._training_report.to_dict()
3351
+ if hasattr(self, "_mandatory_genes") and self._mandatory_genes is not None:
3352
+ uns_dict["mandatory_genes"] = _mandatory_genes_for_uns(self._mandatory_genes)
3353
+ uns_dict["batch_integration_mode"] = self._batch_integration_mode
3354
+ if self._batch_encoding is not None:
3355
+ uns_dict["batch_key"] = self._batch_encoding.obs_columns
3356
+ uns_dict["batch_columns"] = self._batch_encoding.encoded_columns
3357
+ if self._batch_weights is not None:
3358
+ uns_dict["batch_weights"] = self._batch_weights
3359
+ uns_dict["nuisance_ridge"] = self.config.nuisance_ridge
3360
+ if self._balance_obs is not None:
3361
+ uns_dict["balance_obs"] = self._balance_obs
3362
+ diagnostic_encoding = self._batch_encoding
3363
+ if diagnostic_encoding is not None:
3364
+ from ._utils import transform_obs_covariates
3365
+
3366
+ design = transform_obs_covariates(adata, diagnostic_encoding)
3367
+ centered_z = adata.obsm["X_bae"] - adata.obsm["X_bae"].mean(axis=0, keepdims=True)
3368
+ fitted_z = design @ np.linalg.lstsq(design, centered_z, rcond=None)[0]
3369
+ ss_total = (centered_z**2).sum(axis=0)
3370
+ ss_residual = ((centered_z - fitted_z) ** 2).sum(axis=0)
3371
+ latent_r2 = np.divide(
3372
+ ss_residual,
3373
+ ss_total,
3374
+ out=np.full_like(ss_total, np.nan),
3375
+ where=ss_total > 0,
3376
+ )
3377
+ uns_dict["latent_obs_r2_per_dim"] = 1.0 - latent_r2
3378
+
3379
+ group_columns = set()
3380
+ if self._batch_encoding is not None:
3381
+ group_columns.update(self._batch_encoding.obs_columns)
3382
+ if self._balance_obs is not None:
3383
+ group_columns.add(self._balance_obs)
3384
+ if group_columns:
3385
+ residual, explained = self._reconstruction_stats(adata)
3386
+ # A bare MSE is not interpretable on its own: on the z-transformed input
3387
+ # BAE expects, 1.0 is what predicting zero everywhere scores, so "0.85"
3388
+ # reads as a good fit when it means 15% of variance explained. Record the
3389
+ # normalized figure next to it. See `linear_ceiling` for the companion
3390
+ # question of how much a latent_dim-dimensional model could explain.
3391
+ uns_dict["variance_explained"] = float(explained)
3392
+ kept = sorted(c for c in group_columns if adata.obs[c].nunique() <= _MAX_GROUP_LEVELS)
3393
+ skipped = sorted(set(group_columns) - set(kept))
3394
+ if skipped:
3395
+ warnings.warn(
3396
+ "Per-group reconstruction losses were not computed for "
3397
+ f"{skipped}: each has more than {_MAX_GROUP_LEVELS} levels. "
3398
+ "adata.uns['bae']['reconstruction_loss_by_obs'] therefore does "
3399
+ "not cover every conditioned column.",
3400
+ UserWarning,
3401
+ stacklevel=2,
3402
+ )
3403
+ uns_dict["reconstruction_loss_by_obs"] = {
3404
+ column: {
3405
+ str(level): float(residual[np.asarray(adata.obs[column] == level)].mean())
3406
+ for level in adata.obs[column].unique()
3407
+ }
3408
+ for column in kept
3409
+ }
3410
+ adata.uns["bae"] = uns_dict
3411
+
3412
+ if self._prior_weights is not None:
3413
+ adata.uns["bae_transfer"] = self._transfer_diagnostics(adata, X, encoder_weights)
3414
+ # After uns["bae_transfer"] exists, so the statistics land beside the
3415
+ # rest of the transfer metadata rather than creating a stub dict.
3416
+ self._store_scaled_latent(adata, latent)
3417
+
3418
+ def get_encoder_weights(self, as_numpy: bool = True) -> np.ndarray | torch.Tensor:
3419
+ """Get encoder weight matrix.
3420
+
3421
+ Parameters
3422
+ ----------
3423
+ as_numpy
3424
+ If True, return numpy array; else torch tensor.
3425
+
3426
+ Returns
3427
+ -------
3428
+ Encoder weights of shape (n_genes, latent_dim).
3429
+ """
3430
+ W = self.encoder.linear.weight.detach().T # (latent_dim, n_genes) -> (n_genes, latent_dim)
3431
+ if as_numpy:
3432
+ return W.cpu().numpy()
3433
+ return W
3434
+
3435
+ def get_splitsoftmax_encoder_weights(
3436
+ self, *, clip_negative: bool = True, as_numpy: bool = True
3437
+ ) -> np.ndarray | torch.Tensor:
3438
+ """Get encoder weights mapped to split-softmax dimensions.
3439
+
3440
+ Returns the effective per-gene weights for each of the 2 * latent_dim
3441
+ split-softmax dimensions. For split-softmax dimension 2i (positive
3442
+ direction of latent dim i), the weights are W[:, i]. For dimension
3443
+ 2i+1 (negative direction), the weights are -W[:, i].
3444
+
3445
+ The columns are interleaved in the same order as the split-softmax
3446
+ output: (W_1, -W_1, W_2, -W_2, ..., W_d, -W_d).
3447
+
3448
+ Can be called on models trained with or without split_softmax=True.
3449
+ When split_softmax was not enabled during training, a warning is emitted.
3450
+
3451
+ Parameters
3452
+ ----------
3453
+ clip_negative
3454
+ If True (default), clip all negative weights to zero. This retains
3455
+ only the genes that positively contribute to each split-softmax
3456
+ dimension, improving interpretability.
3457
+ as_numpy
3458
+ If True, return numpy array; else torch tensor.
3459
+
3460
+ Returns
3461
+ -------
3462
+ Encoder weights of shape (n_genes, 2 * latent_dim).
3463
+ """
3464
+ if self.split_softmax_layer is None:
3465
+ warnings.warn(
3466
+ "Model was not trained with split_softmax=True. "
3467
+ "Returning split-softmax encoder weights computed post-hoc.",
3468
+ UserWarning,
3469
+ stacklevel=2,
3470
+ )
3471
+
3472
+ # W shape: (n_genes, latent_dim)
3473
+ W = self.encoder.linear.weight.detach().T
3474
+
3475
+ # Interleave: (W_1, -W_1, W_2, -W_2, ..., W_d, -W_d)
3476
+ W_split = torch.stack([W, -W], dim=-1).reshape(W.shape[0], -1)
3477
+
3478
+ if clip_negative:
3479
+ W_split = torch.clamp(W_split, min=0.0)
3480
+
3481
+ if as_numpy:
3482
+ return W_split.cpu().numpy()
3483
+ return W_split