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/_types.py ADDED
@@ -0,0 +1,393 @@
1
+ """Type definitions and configuration for BAE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, Literal
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+
11
+ # Type aliases
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ import torch
14
+
15
+ ArrayLike = npt.NDArray[np.floating] | torch.Tensor
16
+ Device = torch.device | str
17
+ else:
18
+ ArrayLike = npt.NDArray[np.floating] | Any
19
+ Device = str | Any
20
+
21
+
22
+ @dataclass
23
+ class BAEConfig:
24
+ """Configuration for Boosting Autoencoder.
25
+
26
+ The revised BAE architecture uses a linear encoder (optimized via boosting)
27
+ and an MLP decoder (optimized via SGD).
28
+
29
+ Attributes
30
+ ----------
31
+ latent_dim
32
+ Number of latent dimensions.
33
+ decoder_hidden_dims
34
+ Hidden layer sizes for the decoder MLP, in forward order from the latent
35
+ code to the gene output. Default ``(64,)`` — a single hidden layer.
36
+
37
+ On simulated data with planted gene programs, one hidden layer of 64 units
38
+ recovered markers as well as or better than ``(128,)`` (marker-recovery F1
39
+ 0.99–1.00 across four scenarios spanning 500–3000 genes), reconstructed to
40
+ ~91–95% of the linear ceiling, and trained faster. A *linear* decoder
41
+ (``()``) is a valid faster, more interpretable choice but loses recall on
42
+ harder data (F1 down to 0.83, reconstruction 65–73% of ceiling). Deeper or
43
+ wider decoders did not help and often hurt marker recovery: a funnel such
44
+ as ``(128, 64)`` dropped to F1 0.80 because a narrow layer immediately
45
+ before the gene output distorts the reconstruction gradient that the
46
+ boosting target is built from — a BAE-specific effect, since that gradient
47
+ is what the encoder is fitted against. If more capacity is needed, widen
48
+ (``(64, 128)``) rather than narrowing toward the output. These are
49
+ simulation results; confirm on real data before assuming ``(64,)`` has
50
+ enough capacity for genuinely nonlinear expression structure.
51
+ decoder_activation
52
+ Activation function for decoder hidden layers. ``"tanh"`` (default) is the
53
+ most stable; ``"elu"`` matched it; ``"relu"`` was unstable at larger widths
54
+ (marker-recovery F1 collapsing to 0.80 at 256 units).
55
+ decoder_dropout_rate
56
+ Dropout rate for decoder (0.0 = no dropout). Benchmarks showed no benefit;
57
+ the default is 0.0.
58
+ decoder_use_batch_norm
59
+ Whether to use batch normalization in the decoder. Default False, and
60
+ changing it is not recommended: batch norm *lowers* reconstruction MSE but
61
+ badly degrades marker recovery (F1 0.59–0.73 in benchmarks). BAE computes
62
+ the boosting target with the decoder in eval mode (running statistics) and
63
+ then updates it in train mode (batch statistics), so batch norm makes the
64
+ target inconsistent with the decoder that produced it.
65
+ split_softmax
66
+ If True, apply split-softmax transformation between encoder and decoder.
67
+ Each latent dimension z_i is paired with −z_i (interleaved) and softmax-
68
+ normalized onto the 2d-dimensional simplex:
69
+ σ_split(z) = softmax((z_1, −z_1, ..., z_d, −z_d)).
70
+ The decoder then receives a 2d-dimensional compositional input,
71
+ enabling soft clustering of cells into 2d groups.
72
+ Default is False (standard unconstrained latent space).
73
+ boosting_stepno
74
+ Number of boosting iterations per training iteration.
75
+ boosting_nu
76
+ Boosting learning rate.
77
+ boosting_csf
78
+ Cumulative shrinkage factor for boosting.
79
+ boosting_independent
80
+ If True, reset boosting state for each latent dimension (recommended).
81
+ nuisance_ridge
82
+ Ridge strength for the batch covariates when batch_integration_mode
83
+ includes "encoder", expressed relative to each encoded column's
84
+ squared norm. Mandatory *gene* coefficients are never penalized. Default
85
+ 0 preserves exact least squares.
86
+
87
+ This is a numerical-stability knob rather than a modelling one. Boosting
88
+ refits the mandatory block jointly at every step, and that block is the
89
+ mandatory genes together with the batch columns. When two of those are
90
+ collinear the solve has no unique answer and raises; when they are merely
91
+ close to collinear it returns a large, sign-unstable answer and raises
92
+ nothing. Reaching for a small value here, 1e-3 say, is the remedy in
93
+ the second case. It is never applied automatically, because ridge changes
94
+ the estimates and doing so silently would fit a different model than the
95
+ one asked for.
96
+ prior_mode
97
+ How the prior encoder weight matrix is treated when the model was built
98
+ by :meth:`BAE.from_reference`; ignored otherwise. ``"frozen"`` (default)
99
+ never boosts the prior columns, so they remain bitwise equal to the
100
+ reference matrix and the transferred gene programs are literally the
101
+ reference's. ``"anchored"`` boosts them too, but restarts each iteration
102
+ from the fixed original matrix rather than from the previous iteration's
103
+ result — boosting from an offset model, so deviation is bounded by
104
+ ``boosting_stepno`` and never accumulates. Unanchored re-fitting is not
105
+ offered: a prior used merely as a starting point is forgotten within a
106
+ few hundred iterations, since the encoder support random-walks and two
107
+ runs end no more similar than two unrelated ones.
108
+ boosting_precompute_covcache
109
+ If True, compute the full p×p covariance matrix before training, which
110
+ costs 8·p² bytes (3.2 GB at p=20,000).
111
+ If False (default), `allboost` keeps a dict-backed column cache: a
112
+ covariance column is computed the first time its feature is selected and
113
+ reused across targets and training iterations. Memory then scales with
114
+ the number of *distinct selected* features (8·p bytes per column), not
115
+ with p², which is a large saving because boosting selects far fewer
116
+ features than are available. Precompute only when p is small enough that
117
+ the full matrix is comfortable and many features will be selected anyway.
118
+ disentanglement
119
+ Latent-dimension disentanglement method. ``"none"`` (default) applies no
120
+ constraint. ``"correlation"`` adds a soft, differentiable squared-
121
+ correlation penalty to the functional-gradient target objective and is the
122
+ recommended method. ``"leave_one_out"`` retains the earlier experimental
123
+ target residualization method, in which each target column is regressed on
124
+ all other original target columns. Leave-one-out reduces some redundancies
125
+ but does not mathematically produce mutually orthogonal residuals.
126
+ disentanglement_lambda
127
+ Strength of the soft correlation penalty. The penalty is normalized over
128
+ latent-dimension pairs and scaled with the number of cells so that this
129
+ value has the same meaning at different dataset sizes. Default ``1e-4`` is
130
+ a conservative starting point from simulations; it is not universally
131
+ optimal. A useful tuning grid is ``0, 1e-5, 3e-5, 1e-4, 3e-4, 1e-3``.
132
+ Only used when ``disentanglement="correlation"``.
133
+ disentanglement_standardize
134
+ If True with ``disentanglement="leave_one_out"``, standardize each target
135
+ column before residualization. This is unavailable for the correlation
136
+ method, whose loss already operates on standardized correlations.
137
+ standardize_targets
138
+ If True, standardize boosting targets (zero mean, unit variance per column)
139
+ before passing to allboost. Default is False.
140
+
141
+ This is a modelling choice, not a numerical convenience. For a fixed
142
+ decoder it does not change which genes `allboost` selects — rescaling a
143
+ target column by c scales the selection criterion by c², leaving every
144
+ argmax untouched and the coefficients scaled by exactly c — but it does
145
+ change the encoder magnitude, and therefore every later decoder update and
146
+ every later target. It equalises the target variance of strong and weak
147
+ latent dimensions, which constrains the latent scale and can stabilise
148
+ split-softmax, at the cost of promoting near-empty dimensions and
149
+ discarding the scale of an ``init_pca`` or ``init_obsm`` warm start. On
150
+ simulated data with planted gene programs it raises selection precision
151
+ but roughly halves recall relative to the default.
152
+ target_optim_lr
153
+ Step size for computing boosting targets via gradient descent on z.
154
+ Targets are computed as z* = z - lr * ∂L_target/∂z (single gradient step),
155
+ where L_target sums the squared error over cells and averages it over
156
+ genes. That convention — rather than the elementwise mean used for the
157
+ decoder update and every reported loss — makes a cell's target step
158
+ independent of how many cells the dataset contains, so a given
159
+ ``target_optim_lr`` means the same thing at every dataset size.
160
+
161
+ .. note::
162
+ An earlier formulation took the target gradient from the elementwise
163
+ mean MSE, making it a factor ``n_cells`` smaller. A value ported from
164
+ such a setup must be divided by the number of cells.
165
+ decoder_lr
166
+ Learning rate for decoder SGD optimization.
167
+ decoder_weight_decay
168
+ Weight decay (L2 penalty) for the AdamW decoder optimizer.
169
+ Default is 0.0 (no penalty). Increase for decoder weight
170
+ regularisation (typical range: 1e-5 to 1e-2).
171
+ decoder_updates_per_iteration
172
+ Number of decoder SGD steps per training iteration.
173
+ max_iterations
174
+ Maximum number of training iterations.
175
+ enable_early_stopping
176
+ If True (default), stop training early when the checkpoint-selection loss
177
+ does not decrease for `early_stopping_patience` iterations. This is the
178
+ decoder training MSE for ``disentanglement="none"`` and
179
+ ``"leave_one_out"``; for ``"correlation"`` it additionally includes the
180
+ weighted disentanglement penalty. If False, train for exactly
181
+ `max_iterations`.
182
+ early_stopping_patience
183
+ Number of consecutive iterations without checkpoint-selection loss
184
+ improvement required to trigger early stopping. Only used if
185
+ enable_early_stopping is True. Default is 50.
186
+ batch_size
187
+ Minibatch size for decoder SGD updates.
188
+ seed
189
+ Random seed for reproducibility. If None, no seed is set.
190
+ Controls train/val split, weight initialization, and SGD shuffling.
191
+ device
192
+ Device for computation ('cpu', 'cuda', or torch.device).
193
+ diagnostics
194
+ If True, collect per-iteration training diagnostics into a
195
+ :class:`TrainingReport` (see `BAE.training_report`) and additionally show
196
+ the relative encoder weight change (``dW``) and the number of selected
197
+ genes (``n_sel``) in the progress bar. This adds full-data forward and
198
+ backward passes per iteration, so it is off by default and
199
+ `diagnostics=False` costs exactly what training cost before. Diagnostics
200
+ are read-only: enabling them does not change the fitted model.
201
+ """
202
+
203
+ latent_dim: int = 10
204
+ # Decoder MLP
205
+ decoder_hidden_dims: tuple[int, ...] = (64,)
206
+ decoder_activation: Literal["tanh", "relu", "leaky_relu", "elu"] = "tanh"
207
+ decoder_dropout_rate: float = 0.0
208
+ decoder_use_batch_norm: bool = False
209
+ split_softmax: bool = False
210
+ # Boosting parameters
211
+ boosting_stepno: int = 50
212
+ boosting_nu: float = 0.1
213
+ boosting_csf: float = 0.9
214
+ boosting_independent: bool = True
215
+ boosting_precompute_covcache: bool = False
216
+ nuisance_ridge: float = 0.0
217
+ prior_mode: Literal["frozen", "anchored"] = "frozen"
218
+ disentanglement: Literal["none", "correlation", "leave_one_out"] = "none"
219
+ disentanglement_lambda: float = 1e-4
220
+ disentanglement_standardize: bool = False
221
+ standardize_targets: bool = False
222
+ # Target computation
223
+ target_optim_lr: float = 1.0
224
+ # Training parameters
225
+ decoder_lr: float = 1e-3
226
+ decoder_weight_decay: float = 0.0
227
+ decoder_updates_per_iteration: int = 10
228
+ max_iterations: int = 1000
229
+ enable_early_stopping: bool = True
230
+ early_stopping_patience: int = 50
231
+ batch_size: int = 2**9
232
+ seed: int | None = None
233
+ # Device
234
+ device: Device = "cpu"
235
+ # Diagnostics
236
+ diagnostics: bool = False
237
+
238
+ def __post_init__(self) -> None:
239
+ """Validate configuration."""
240
+ if self.latent_dim < 1:
241
+ raise ValueError("latent_dim must be >= 1")
242
+ if not 0.0 <= self.decoder_dropout_rate < 1.0:
243
+ raise ValueError("decoder_dropout_rate must be in [0.0, 1.0)")
244
+ if self.boosting_stepno < 1:
245
+ raise ValueError("boosting_stepno must be >= 1")
246
+ if not np.isfinite(self.nuisance_ridge) or self.nuisance_ridge < 0:
247
+ raise ValueError("nuisance_ridge must be finite and >= 0")
248
+ if self.prior_mode not in {"frozen", "anchored"}:
249
+ raise ValueError("prior_mode must be 'frozen' or 'anchored'")
250
+ if not 0.0 < self.boosting_nu <= 1.0:
251
+ raise ValueError("boosting_nu must be in (0.0, 1.0]")
252
+ if self.disentanglement not in {"none", "correlation", "leave_one_out"}:
253
+ raise ValueError(
254
+ "disentanglement must be one of 'none', 'correlation', or 'leave_one_out'"
255
+ )
256
+ if not np.isfinite(self.disentanglement_lambda) or self.disentanglement_lambda < 0:
257
+ raise ValueError("disentanglement_lambda must be finite and >= 0")
258
+ if self.disentanglement_standardize and self.disentanglement != "leave_one_out":
259
+ raise ValueError(
260
+ "disentanglement_standardize is only available with disentanglement='leave_one_out'"
261
+ )
262
+ if self.decoder_weight_decay < 0:
263
+ raise ValueError("decoder_weight_decay must be >= 0")
264
+ if self.max_iterations < 1:
265
+ raise ValueError("max_iterations must be >= 1")
266
+ if self.early_stopping_patience < 1:
267
+ raise ValueError("early_stopping_patience must be >= 1")
268
+ if self.batch_size < 1:
269
+ raise ValueError("batch_size must be >= 1")
270
+ if isinstance(self.device, str):
271
+ try:
272
+ import torch
273
+
274
+ self.device = torch.device(self.device)
275
+ except Exception:
276
+ pass
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class TrainingReport:
281
+ """Per-iteration training diagnostics collected by ``BAE.fit``.
282
+
283
+ Produced only when ``diagnostics=True``. Every field is a NumPy array whose
284
+ first axis indexes the training iteration; per-dimension fields have shape
285
+ ``(n_iterations, latent_dim)``.
286
+
287
+ Each BAE iteration alternates two optimizers, giving three points at which
288
+ the full-data reconstruction loss can be measured:
289
+
290
+ ``A`` (``loss_pre_boost``)
291
+ before anything changes, i.e. encoder from the previous iteration.
292
+ ``B`` (``loss_post_boost``)
293
+ after the encoder is re-fit by boosting, before the decoder moves.
294
+ ``C`` (``loss_post_decoder``)
295
+ after the decoder SGD steps.
296
+
297
+ The differences ``encoder_delta = B - A`` and ``decoder_delta = C - B``
298
+ attribute the loss change of each iteration to the two halves of the
299
+ alternation, which is otherwise invisible: a single loss curve cannot say
300
+ whether the boosting step helps or whether the decoder is merely repairing
301
+ the damage it does.
302
+
303
+ Attributes
304
+ ----------
305
+ iteration
306
+ 0-based iteration index.
307
+ train_loss
308
+ Running average minibatch MSE during the decoder update. This is the
309
+ historical metric and a noisier proxy for ``loss_post_decoder``. Early
310
+ stopping uses it directly unless soft correlation disentanglement is
311
+ enabled, in which case the corresponding
312
+ ``training_history["selection_loss"]`` also includes that penalty.
313
+ loss_pre_boost
314
+ Full-data reconstruction MSE at point A above.
315
+ loss_post_boost
316
+ Full-data reconstruction MSE at point B above.
317
+ loss_post_decoder
318
+ Full-data reconstruction MSE at point C above.
319
+ encoder_delta
320
+ Loss change attributable to the boosting step; negative means it reduced
321
+ the loss.
322
+ decoder_delta
323
+ Loss change attributable to the decoder step; negative means it reduced
324
+ the loss.
325
+ target_grad_norm
326
+ Norm of the boosting-target gradient, ``||dL/dz||``. This is the signal
327
+ the encoder is fitted against; if it collapses toward zero the boosting
328
+ targets carry no information.
329
+ decoder_grad_norm
330
+ Norm of the full-data decoder parameter gradient.
331
+ boosting_r2
332
+ R^2 of the encoder output against the boosting targets, i.e. how well
333
+ ``allboost`` fitted what it was asked to fit.
334
+ encoder_weight_norm
335
+ Mean absolute encoder weight. Typically grows by orders of magnitude
336
+ during training, so weight *changes* must be read relative to it.
337
+ weight_change_rel
338
+ Mean absolute change of the encoder weights against the previous
339
+ iteration, divided by ``encoder_weight_norm``. The primary convergence
340
+ signal. ``NaN`` in the first iteration, which has no predecessor.
341
+ support_jaccard
342
+ Jaccard overlap of the selected-gene set against the previous iteration.
343
+ Answers whether the *gene set* has settled, which is coarser and noisier
344
+ than ``weight_change_rel`` but directly interpretable. ``NaN`` in the
345
+ first iteration.
346
+ min_dim_cosine
347
+ Smallest per-latent-dimension cosine similarity between consecutive
348
+ encoder weight matrices; scale-invariant, and identifies which dimension
349
+ is still moving. ``NaN`` in the first iteration.
350
+ n_selected
351
+ Number of genes with a nonzero weight in any latent dimension.
352
+ n_selected_per_dim
353
+ Number of genes selected per latent dimension.
354
+ latent_var_per_dim
355
+ Variance of each latent dimension across cells. Compare dimensions with
356
+ each other rather than against an absolute scale: the overall latent
357
+ magnitude is set by the boosting shrinkage and is small by construction.
358
+ """
359
+
360
+ iteration: npt.NDArray[np.intp]
361
+ train_loss: npt.NDArray[np.float64]
362
+ loss_pre_boost: npt.NDArray[np.float64]
363
+ loss_post_boost: npt.NDArray[np.float64]
364
+ loss_post_decoder: npt.NDArray[np.float64]
365
+ encoder_delta: npt.NDArray[np.float64]
366
+ decoder_delta: npt.NDArray[np.float64]
367
+ target_grad_norm: npt.NDArray[np.float64]
368
+ decoder_grad_norm: npt.NDArray[np.float64]
369
+ boosting_r2: npt.NDArray[np.float64]
370
+ encoder_weight_norm: npt.NDArray[np.float64]
371
+ weight_change_rel: npt.NDArray[np.float64]
372
+ support_jaccard: npt.NDArray[np.float64]
373
+ min_dim_cosine: npt.NDArray[np.float64]
374
+ n_selected: npt.NDArray[np.intp]
375
+ n_selected_per_dim: npt.NDArray[np.intp]
376
+ latent_var_per_dim: npt.NDArray[np.float64]
377
+
378
+ @property
379
+ def n_iterations(self) -> int:
380
+ """Number of recorded training iterations."""
381
+ return int(self.iteration.shape[0])
382
+
383
+ def to_dict(self) -> dict[str, npt.NDArray[Any]]:
384
+ """Return the report as a plain dict of arrays, ready for ``adata.uns``."""
385
+ return {f: getattr(self, f) for f in self.__dataclass_fields__}
386
+
387
+ @classmethod
388
+ def from_dict(cls, data: dict[str, npt.NDArray[Any]]) -> TrainingReport:
389
+ """Rebuild a report from :meth:`to_dict` output, e.g. read back from h5ad."""
390
+ missing = [f for f in cls.__dataclass_fields__ if f not in data]
391
+ if missing:
392
+ raise KeyError(f"missing training report fields: {missing}")
393
+ return cls(**{f: np.asarray(data[f]) for f in cls.__dataclass_fields__})