structboost 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structboost/__init__.py +94 -0
- structboost/_annotation.py +266 -0
- structboost/_boosting.py +552 -0
- structboost/_decoder.py +109 -0
- structboost/_encoder.py +88 -0
- structboost/_explorer.py +842 -0
- structboost/_io.py +302 -0
- structboost/_model.py +3483 -0
- structboost/_persistence.py +326 -0
- structboost/_plotting.py +301 -0
- structboost/_simulation.py +867 -0
- structboost/_stability.py +412 -0
- structboost/_types.py +393 -0
- structboost/_utils.py +509 -0
- structboost/py.typed +0 -0
- structboost-0.1.0.dist-info/METADATA +219 -0
- structboost-0.1.0.dist-info/RECORD +19 -0
- structboost-0.1.0.dist-info/WHEEL +4 -0
- structboost-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|