structboost 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,552 @@
1
+ """Componentwise L2 boosting (pure NumPy port of Julia allboost)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Literal, overload
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ _CovarianceCache = NDArray[np.floating] | dict[int, NDArray[np.float64]]
13
+
14
+
15
+ def _calc_unibeta(
16
+ x: NDArray[np.floating],
17
+ y: NDArray[np.floating],
18
+ col_norms_sq: NDArray[np.floating] | None = None,
19
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
20
+ """Univariate regression coefficients and column squared norms.
21
+
22
+ Parameters
23
+ ----------
24
+ x : ndarray of shape (n_samples, n_features)
25
+ Predictor matrix.
26
+ y : ndarray of shape (n_samples,)
27
+ Target vector.
28
+ col_norms_sq : ndarray of shape (n_features,), optional
29
+ Pre-computed squared column norms ||x_j||^2. If None, computed internally.
30
+
31
+ Returns
32
+ -------
33
+ unibeta : ndarray of shape (n_features,)
34
+ Coefficients beta_j = (x_j'y) / (x_j'x_j).
35
+ col_norms_sq : ndarray of shape (n_features,)
36
+ Squared column norms ||x_j||^2 (returned for reuse).
37
+ """
38
+ if col_norms_sq is None:
39
+ col_norms_sq = (x**2).sum(axis=0)
40
+ return (x.T @ y) / col_norms_sq, col_norms_sq
41
+
42
+
43
+ @dataclass
44
+ class AllboostHistory:
45
+ """Trace information returned by `allboost(..., return_history=True)`.
46
+
47
+ Attributes
48
+ ----------
49
+ selection
50
+ Selected feature index at each step, shape (n_targets, stepno).
51
+ beta_path
52
+ Coefficient path after each step, shape (n_targets, stepno, n_features).
53
+ This can be memory-intensive; only enabled when explicitly requested.
54
+ """
55
+
56
+ selection: NDArray[np.int64]
57
+ beta_path: NDArray[np.float64]
58
+
59
+
60
+ def _validate_mandatory_features(
61
+ mandatory_features: NDArray[np.intp] | list[NDArray[np.intp]] | None,
62
+ n_features: int,
63
+ n_targets: int,
64
+ ) -> list[NDArray[np.intp]]:
65
+ """Normalize and validate mandatory feature indices for each target."""
66
+ if mandatory_features is None:
67
+ return [np.array([], dtype=np.intp) for _ in range(n_targets)]
68
+
69
+ if isinstance(mandatory_features, list):
70
+ if len(mandatory_features) != n_targets:
71
+ raise ValueError(
72
+ "mandatory_features list length must match number of targets; "
73
+ f"got {len(mandatory_features)} and {n_targets}"
74
+ )
75
+ raw_per_target = mandatory_features
76
+ else:
77
+ raw_per_target = [mandatory_features] * n_targets
78
+
79
+ validated: list[NDArray[np.intp]] = []
80
+ for mand in raw_per_target:
81
+ mand_arr = np.asarray(mand)
82
+ if mand_arr.ndim != 1:
83
+ raise ValueError("mandatory_features entries must be 1D integer arrays")
84
+ # An empty selection carries no dtype information (``np.asarray([])`` is
85
+ # float64), so it is accepted as-is; anything non-empty must be integral.
86
+ if mand_arr.size:
87
+ if mand_arr.dtype == np.bool_:
88
+ raise ValueError(
89
+ "mandatory_features must contain integer positions, not a boolean "
90
+ "mask. A mask is silently reinterpreted as the indices 0/1, which "
91
+ "selects the wrong features. Use np.flatnonzero(mask) instead."
92
+ )
93
+ if not np.issubdtype(mand_arr.dtype, np.integer):
94
+ raise ValueError(
95
+ "mandatory_features must have an integer dtype, got "
96
+ f"{mand_arr.dtype}. Non-integer values would be truncated toward "
97
+ "zero (1.9 -> 1), silently selecting a different feature."
98
+ )
99
+ mand_arr = mand_arr.astype(np.intp, copy=False)
100
+ if np.unique(mand_arr).size != mand_arr.size:
101
+ raise ValueError("mandatory_features contains duplicate indices")
102
+ if ((mand_arr < 0) | (mand_arr >= n_features)).any():
103
+ raise ValueError("mandatory_features index out of range")
104
+ validated.append(mand_arr)
105
+
106
+ return validated
107
+
108
+
109
+ def _mandatory_prestep(
110
+ mand_idx: NDArray[np.intp],
111
+ actualnom: NDArray[np.floating],
112
+ beta: NDArray[np.floating],
113
+ col_norms_sq: NDArray[np.floating],
114
+ get_covariance_column: Callable[[int], NDArray[np.floating]],
115
+ ridge: NDArray[np.float64],
116
+ target_index: int = 0,
117
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
118
+ """Joint mandatory update, optionally ridge-stabilized.
119
+
120
+ The unpenalized case is Binder & Schumacher (2008), Approach B. Ridge
121
+ penalties are expressed relative to each predictor's squared norm.
122
+ """
123
+ if mand_idx.size == 0:
124
+ return actualnom, beta
125
+
126
+ mandatory_cov = np.column_stack([get_covariance_column(int(j)) for j in mand_idx])
127
+ c_mm = mandatory_cov[mand_idx]
128
+ penalty = ridge[mand_idx] * col_norms_sq[mand_idx]
129
+ # Include the derivative of the penalty at the current coefficient. Without
130
+ # this term, repeatedly applying a ridge pre-step would converge back to the
131
+ # unpenalized OLS solution as boosting proceeds.
132
+ nom_mand = actualnom[mand_idx] * col_norms_sq[mand_idx] - penalty * beta[mand_idx]
133
+ try:
134
+ gamma_mand = np.linalg.solve(c_mm + np.diag(penalty), nom_mand)
135
+ except np.linalg.LinAlgError as exc:
136
+ # Deliberately not falling back to a pseudo-inverse or auto-adding ridge:
137
+ # either silently fits a different model than the caller specified.
138
+ raise ValueError(
139
+ f"The mandatory feature block is singular for target {target_index}. "
140
+ f"Mandatory features {mand_idx.tolist()} are exactly collinear (for "
141
+ "example a redundant dummy level, or a covariate duplicated across "
142
+ "columns), so their joint unpenalized update has no unique solution. "
143
+ "Drop the redundant column(s), or pass an explicit mandatory_ridge to "
144
+ "stabilize the block — note that ridge changes the estimates, so it "
145
+ "is not applied automatically."
146
+ ) from exc
147
+ beta[mand_idx] += gamma_mand
148
+ actualnom -= (mandatory_cov @ gamma_mand) / col_norms_sq
149
+
150
+ return actualnom, beta
151
+
152
+
153
+ @overload
154
+ def allboost(
155
+ sourcemat: NDArray[np.floating],
156
+ targetmat: NDArray[np.floating],
157
+ *,
158
+ mandatory_features: NDArray[np.intp] | list[NDArray[np.intp]] | None = None,
159
+ mandatory_ridge: float | NDArray[np.floating] = 0.0,
160
+ beta_init: NDArray[np.floating] | None = None,
161
+ covcache: _CovarianceCache | None = None,
162
+ stepno: int = 20,
163
+ nu: float = 0.1,
164
+ csf: float = 0.9,
165
+ independent: bool = True,
166
+ return_history: Literal[False] = False,
167
+ return_covcache: Literal[False] = False,
168
+ ) -> NDArray[np.floating]: ...
169
+
170
+
171
+ @overload
172
+ def allboost(
173
+ sourcemat: NDArray[np.floating],
174
+ targetmat: NDArray[np.floating],
175
+ *,
176
+ mandatory_features: NDArray[np.intp] | list[NDArray[np.intp]] | None = None,
177
+ mandatory_ridge: float | NDArray[np.floating] = 0.0,
178
+ beta_init: NDArray[np.floating] | None = None,
179
+ covcache: _CovarianceCache | None = None,
180
+ stepno: int = 20,
181
+ nu: float = 0.1,
182
+ csf: float = 0.9,
183
+ independent: bool = True,
184
+ return_history: Literal[True],
185
+ return_covcache: Literal[False] = False,
186
+ ) -> tuple[NDArray[np.floating], AllboostHistory]: ...
187
+
188
+
189
+ @overload
190
+ def allboost(
191
+ sourcemat: NDArray[np.floating],
192
+ targetmat: NDArray[np.floating],
193
+ *,
194
+ mandatory_features: NDArray[np.intp] | list[NDArray[np.intp]] | None = None,
195
+ mandatory_ridge: float | NDArray[np.floating] = 0.0,
196
+ beta_init: NDArray[np.floating] | None = None,
197
+ covcache: _CovarianceCache | None = None,
198
+ stepno: int = 20,
199
+ nu: float = 0.1,
200
+ csf: float = 0.9,
201
+ independent: bool = True,
202
+ return_history: Literal[False] = False,
203
+ return_covcache: Literal[True] = ...,
204
+ ) -> tuple[NDArray[np.floating], _CovarianceCache]: ...
205
+
206
+
207
+ @overload
208
+ def allboost(
209
+ sourcemat: NDArray[np.floating],
210
+ targetmat: NDArray[np.floating],
211
+ *,
212
+ mandatory_features: NDArray[np.intp] | list[NDArray[np.intp]] | None = None,
213
+ mandatory_ridge: float | NDArray[np.floating] = 0.0,
214
+ beta_init: NDArray[np.floating] | None = None,
215
+ covcache: _CovarianceCache | None = None,
216
+ stepno: int = 20,
217
+ nu: float = 0.1,
218
+ csf: float = 0.9,
219
+ independent: bool = True,
220
+ return_history: Literal[True],
221
+ return_covcache: Literal[True],
222
+ ) -> tuple[NDArray[np.floating], AllboostHistory, _CovarianceCache]: ...
223
+
224
+
225
+ def allboost(
226
+ sourcemat: NDArray[np.floating],
227
+ targetmat: NDArray[np.floating],
228
+ *,
229
+ mandatory_features: NDArray[np.intp] | list[NDArray[np.intp]] | None = None,
230
+ mandatory_ridge: float | NDArray[np.floating] = 0.0,
231
+ beta_init: NDArray[np.floating] | None = None,
232
+ covcache: _CovarianceCache | None = None,
233
+ stepno: int = 20,
234
+ nu: float = 0.1,
235
+ csf: float = 0.9,
236
+ independent: bool = True,
237
+ return_history: bool = False,
238
+ return_covcache: bool = False,
239
+ ) -> (
240
+ NDArray[np.floating]
241
+ | tuple[NDArray[np.floating], AllboostHistory]
242
+ | tuple[NDArray[np.floating], _CovarianceCache]
243
+ | tuple[NDArray[np.floating], AllboostHistory, _CovarianceCache]
244
+ ):
245
+ """Componentwise L2 boosting for multivariate regression.
246
+
247
+ Parameters
248
+ ----------
249
+ sourcemat : ndarray of shape (n_samples, n_features)
250
+ Predictor matrix. Standardization (z-transform) is recommended but not
251
+ required; the algorithm uses actual column norms internally.
252
+ targetmat : ndarray of shape (n_samples, n_targets)
253
+ Target matrix. For optimal boosting performance, targets should be
254
+ standardized (zero mean, unit variance per column) before passing to
255
+ this function.
256
+ mandatory_features : ndarray of shape (n_mandatory,) or list of ndarrays, optional
257
+ Features that are forcibly updated in each boosting step using a joint
258
+ multivariate OLS pre-step (Binder & Schumacher, 2008, Approach B).
259
+ Must be integer positions; boolean masks and float arrays are rejected
260
+ rather than silently reinterpreted.
261
+ If an ndarray is provided, it is applied to all targets. If a list is
262
+ provided, it must have length n_targets with one 1D index array per target.
263
+
264
+ These features are always part of the unpenalized adjustment block, so
265
+ they are never subject to competitive selection. That is a statement about
266
+ the model specification, not about the fitted values: a mandatory feature
267
+ whose contribution is estimated as zero will have a zero coefficient. If
268
+ you need a guaranteed non-zero support, this is not that mechanism.
269
+ mandatory_ridge : float or ndarray of shape (n_features,), default=0
270
+ Optional non-negative ridge penalty for mandatory coefficients, relative
271
+ to each predictor's squared norm. A scalar applies to all mandatory
272
+ features; an array permits selective stabilization. Zero preserves the
273
+ Binder & Schumacher (2008) unpenalized mandatory update.
274
+ beta_init : ndarray of shape (n_targets, n_features), optional
275
+ Starting coefficients per target. Boosting normally begins from the zero
276
+ model; supplying ``beta_init`` starts it from the offset model
277
+ ``F_0 = sourcemat @ beta_init[t]`` instead, so the procedure fits the
278
+ *correction* to an externally supplied model rather than the model itself.
279
+ This is the classical boosting offset (Bühlmann & Hothorn 2007, Sec. 2)
280
+ and is used by :meth:`BAE.from_reference` to anchor transferred encoder
281
+ weights.
282
+
283
+ The learning-rate and penalty state (``nuvec``/``penvec``) still starts
284
+ fresh, so features carrying a non-zero initial coefficient compete as
285
+ though never selected. Pre-ageing the csf state would require the
286
+ selection counts that produced ``beta_init``, which are not part of the
287
+ coefficient matrix. Deviation from ``beta_init`` is therefore bounded by
288
+ ``stepno`` and does not accumulate across repeated calls.
289
+ covcache : ndarray or dict, optional
290
+ Predictor covariance cache. A pre-computed ``X.T @ X`` ndarray uses the
291
+ full-cache fast path. A dict maps feature indices to covariance columns
292
+ and grows only when a feature is selected. If None, an empty column
293
+ cache is created. Reuse the returned cache with the same sourcemat only.
294
+ stepno : int, default=20
295
+ Number of boosting iterations per target.
296
+ nu : float, default=0.1
297
+ Learning rate. Adapts per feature via csf after each selection.
298
+ csf : float, default=0.9
299
+ Cumulative shrinkage factor.
300
+ After selection: nuvec[j] = 1 - (1 - nuvec[j])^csf.
301
+ csf < 1 promotes diversity, csf > 1 reinforces selected features.
302
+ independent : bool, default=True
303
+ If True, reset learning rate and penalty vectors for each target (no
304
+ cross-target effects). Recommended for marker gene discovery.
305
+ Note: the internal predictor–predictor covariance cache depends only on
306
+ `sourcemat` and is therefore shared across targets for efficiency.
307
+ If False, parameters persist across targets.
308
+ return_history : bool, default=False
309
+ If True, also return an `AllboostHistory` object containing the selected
310
+ feature at each step and the coefficient path (after each step).
311
+ return_covcache : bool, default=False
312
+ If True, also return the (possibly lazily computed) covariance cache.
313
+ Useful when covcache was not provided and you want to reuse it later.
314
+
315
+ Returns
316
+ -------
317
+ betamat : ndarray of shape (n_targets, n_features)
318
+ Coefficient matrix (always returned).
319
+ history : AllboostHistory
320
+ Returned as second element if return_history=True.
321
+ covcache_out : ndarray or dict
322
+ Returned as last element if return_covcache=True. When no cache was
323
+ supplied, this is a dict containing only computed covariance columns.
324
+
325
+ Notes
326
+ -----
327
+ Features are selected by the penalized variance-reduction (score) criterion
328
+ ``(x_j' r)^2 / (||x_j||^2 + penvec_j)`` — the reduction in penalized RSS from a
329
+ ridge step on feature ``j``. This is scale-invariant in the predictor columns
330
+ and gives unbiased model selection (Hofner et al. 2011).
331
+
332
+ The unbiasedness is worth spelling out, because it rests on the *penalty*
333
+ rather than on the criterion alone. Hofner et al. show that boosting selects
334
+ without bias when the base-learners are comparable in degrees of freedom.
335
+ Initializing ``penvec_j = ||x_j||^2 * (1/nu - 1)`` does exactly that: the
336
+ effective ridge degrees of freedom of feature ``j`` are
337
+ ``||x_j||^2 / (||x_j||^2 + penvec_j) = nu``, the same for every feature
338
+ whatever its column norm, and the criterion reduces to
339
+ ``nu * (x_j' r)^2 / ||x_j||^2``. Penalty adaptation (``csf``) then moves
340
+ features off that common footing deliberately, which is the diversity
341
+ mechanism rather than a selection bias. An earlier formulation selected on
342
+ the squared shrunken coefficient ``(x_j' r / (||x_j||^2 + penvec_j))^2``,
343
+ which favors low-norm features and matches the score criterion only for equal
344
+ column norms (standardized predictors). The coefficient *update* is unchanged.
345
+
346
+ References
347
+ ----------
348
+ Binder, H. & Schumacher, M. (2009). Incorporating pathway information into
349
+ boosting estimation of high-dimensional risk prediction models.
350
+ *BMC Bioinformatics* 10, 18. (Source of the algorithm: componentwise L2
351
+ boosting with the ``nu``/``csf`` penalty-adaptation mechanism.)
352
+
353
+ Binder, H. & Schumacher, M. (2008). Allowing for mandatory covariates in
354
+ boosting estimation of sparse high-dimensional survival models.
355
+ *BMC Bioinformatics* 9, 14. (Mandatory-covariate pre-step used by
356
+ ``mandatory_features``.)
357
+
358
+ Hofner, B., Hothorn, T., Kneib, T. & Schmid, M. (2011). A Framework for
359
+ Unbiased Model Selection Based on Boosting. *JCGS* 20(4). (Penalized
360
+ variance-reduction selection criterion.)
361
+
362
+ Bühlmann, P. & Hothorn, T. (2007). Boosting Algorithms: Regularization,
363
+ Prediction and Model Fitting. *Statistical Science* 22(4), 477-505.
364
+ (Offset / initial-model formulation used by ``beta_init``.)
365
+ """
366
+ if stepno < 1:
367
+ # Without this, `range(stepno)` simply never runs and the caller gets an
368
+ # all-zero betamat -- including zero coefficients for mandatory features,
369
+ # which look like a fitted model that selected nothing.
370
+ raise ValueError(f"stepno must be >= 1, got {stepno}")
371
+
372
+ n, p = sourcemat.shape
373
+ k = targetmat.shape[1]
374
+
375
+ if targetmat.shape[0] != n:
376
+ raise ValueError(
377
+ f"sourcemat and targetmat must have same n_samples, got {n} and {targetmat.shape[0]}"
378
+ )
379
+
380
+ mandatory_per_target = _validate_mandatory_features(mandatory_features, p, k)
381
+ ridge = np.asarray(mandatory_ridge, dtype=np.float64)
382
+ if ridge.ndim == 0:
383
+ ridge = np.full(p, float(ridge), dtype=np.float64)
384
+ if ridge.shape != (p,):
385
+ raise ValueError(f"mandatory_ridge must be scalar or shape ({p},), got {ridge.shape}")
386
+ if not np.isfinite(ridge).all() or (ridge < 0).any():
387
+ raise ValueError("mandatory_ridge must contain finite, non-negative values")
388
+
389
+ if beta_init is not None:
390
+ beta_init = np.asarray(beta_init, dtype=np.float64)
391
+ if beta_init.shape != (k, p):
392
+ raise ValueError(
393
+ f"beta_init must have shape ({k}, {p}) (n_targets, n_features), "
394
+ f"got {beta_init.shape}"
395
+ )
396
+ if not np.isfinite(beta_init).all():
397
+ raise ValueError("beta_init must contain only finite values")
398
+
399
+ # Precompute column squared norms (used for penalty scaling and residual updates)
400
+ col_norms_sq = (sourcemat**2).sum(axis=0)
401
+
402
+ # Check for zero-variance columns
403
+ if (col_norms_sq == 0).any():
404
+ raise ValueError(
405
+ "sourcemat contains zero-variance columns. "
406
+ "Remove constant features before calling allboost."
407
+ )
408
+
409
+ betamat = np.zeros((k, p), dtype=np.float64)
410
+
411
+ selection_hist: NDArray[np.int64] | None = None
412
+ beta_path: NDArray[np.float64] | None = None
413
+ if return_history:
414
+ selection_hist = np.full((k, stepno), -1, dtype=np.int64)
415
+ beta_path = np.zeros((k, stepno, p), dtype=np.float64)
416
+
417
+ # Covariance cache: full ndarrays retain the precomputed fast path; the
418
+ # default dict stores only columns that are actually requested.
419
+ if covcache is None:
420
+ covcache = {}
421
+
422
+ if isinstance(covcache, dict):
423
+ _covcache = covcache
424
+ for j, column in _covcache.items():
425
+ if not isinstance(j, (int, np.integer)) or not 0 <= j < p:
426
+ raise ValueError(f"covcache column index out of range: {j!r}")
427
+ if np.asarray(column).shape != (p,):
428
+ raise ValueError(
429
+ f"covcache column {j} must have shape ({p},), got {np.asarray(column).shape}"
430
+ )
431
+
432
+ def get_covariance_column(j: int) -> NDArray[np.floating]:
433
+ column = _covcache.get(j)
434
+ if column is None:
435
+ column = np.asarray(sourcemat.T @ sourcemat[:, j], dtype=np.float64)
436
+ _covcache[j] = column
437
+ return column
438
+
439
+ else:
440
+ if covcache.shape != (p, p):
441
+ raise ValueError(f"covcache must have shape ({p}, {p}), got {covcache.shape}")
442
+ _covcache = covcache
443
+
444
+ def get_covariance_column(j: int) -> NDArray[np.floating]:
445
+ column = _covcache[:, j]
446
+ nan_mask = np.isnan(column)
447
+ if nan_mask.any():
448
+ computed = sourcemat[:, nan_mask].T @ sourcemat[:, j]
449
+ column[nan_mask] = computed
450
+ _covcache[j, nan_mask] = computed
451
+ return column
452
+
453
+ # Initialize shared state (used if independent=False)
454
+ if not independent:
455
+ nuvec = np.full(p, nu, dtype=np.float64)
456
+ penvec = col_norms_sq * (1.0 / nu - 1.0)
457
+
458
+ for t_idx in range(k):
459
+ # Reset parameters for each target if independent
460
+ if independent:
461
+ nuvec = np.full(p, nu, dtype=np.float64)
462
+ penvec = col_norms_sq * (1.0 / nu - 1.0)
463
+
464
+ curtarget = targetmat[:, t_idx]
465
+ if beta_init is None:
466
+ actualnom, _ = _calc_unibeta(sourcemat, curtarget, col_norms_sq)
467
+ beta = np.zeros(p, dtype=np.float64)
468
+ else:
469
+ # Boosting from the offset model F_0 = sourcemat @ beta. `actualnom`
470
+ # must describe the residual *at* beta, not at zero, or the first
471
+ # selection step would re-fit signal the offset already explains.
472
+ # Forming the residual directly costs one O(n*p) matvec; deriving it
473
+ # from the covariance cache instead would cost one column fetch per
474
+ # non-zero initial coefficient.
475
+ beta = beta_init[t_idx].copy()
476
+ actualnom, _ = _calc_unibeta(sourcemat, curtarget - sourcemat @ beta, col_norms_sq)
477
+ mand_idx = mandatory_per_target[t_idx]
478
+
479
+ for step in range(stepno):
480
+ if mand_idx.size > 0:
481
+ actualnom, beta = _mandatory_prestep(
482
+ mand_idx,
483
+ actualnom,
484
+ beta,
485
+ col_norms_sq,
486
+ get_covariance_column,
487
+ ridge,
488
+ t_idx,
489
+ )
490
+
491
+ if mand_idx.size >= p:
492
+ # Every predictor is mandatory, so there is no eligible candidate to
493
+ # select. The pre-step above has already fitted them jointly; going on
494
+ # would take argmax over an all -inf criterion, land on an arbitrary
495
+ # mandatory index and record it in `selection` as though it had been
496
+ # chosen. The coefficients would barely move (the pre-step drives
497
+ # `actualnom` to zero at mandatory positions, so the spurious update is
498
+ # ~0), but the trace would be wrong and the work wasted.
499
+ if beta_path is not None:
500
+ beta_path[t_idx, step:, :] = beta
501
+ break
502
+
503
+ # Selection criterion: penalized variance-reduction (score) criterion
504
+ # criterion_j = (x_j' r)^2 / (||x_j||^2 + penvec_j),
505
+ # with x_j' r = beta_j * ||x_j||^2 (= actualnom * col_norms_sq).
506
+ # This is the reduction in the penalized RSS achieved by a ridge step
507
+ # on feature j (equivalently the penalized score statistic). It is
508
+ # scale-invariant in the predictor columns and yields unbiased feature
509
+ # selection (Hofner et al. 2011).
510
+ #
511
+ # An earlier formulation selected on the *squared shrunken
512
+ # coefficient*:
513
+ # criterion_j = (beta_j * ||x_j||^2 / (||x_j||^2 + penvec_j))^2
514
+ # = ((x_j' r) / (||x_j||^2 + penvec_j))^2,
515
+ # which carries an extra 1/(||x_j||^2 + penvec_j) factor that favors
516
+ # low-norm features. The two criteria rank features identically only
517
+ # when column norms are equal (standardized predictors) and diverge
518
+ # otherwise. Only the *selection* changed; the coefficient update
519
+ # (nuvec * actualnom) is unchanged.
520
+ numer = actualnom * col_norms_sq # x_j' r
521
+ criterion = numer**2 / (col_norms_sq + penvec)
522
+ criterion[mand_idx] = -np.inf
523
+ actualsel = int(np.argmax(criterion))
524
+ if selection_hist is not None:
525
+ selection_hist[t_idx, step] = actualsel
526
+
527
+ # Update the winner only. `actualnom` is refreshed through the cached
528
+ # covariance column rather than by recomputing the residual.
529
+ actualupdate = nuvec[actualsel] * actualnom[actualsel]
530
+ beta[actualsel] += actualupdate
531
+ cov_col = get_covariance_column(actualsel)
532
+ actualnom -= actualupdate * cov_col / col_norms_sq
533
+ # Update adaptive parameters
534
+ nuvec[actualsel] = 1.0 - (1.0 - nuvec[actualsel]) ** csf
535
+ penvec[actualsel] = col_norms_sq[actualsel] * (1.0 / nuvec[actualsel] - 1.0)
536
+
537
+ if beta_path is not None:
538
+ beta_path[t_idx, step, :] = beta
539
+
540
+ betamat[t_idx, :] = beta
541
+
542
+ if return_history and return_covcache:
543
+ assert selection_hist is not None
544
+ assert beta_path is not None
545
+ return betamat, AllboostHistory(selection=selection_hist, beta_path=beta_path), _covcache
546
+ if return_history:
547
+ assert selection_hist is not None
548
+ assert beta_path is not None
549
+ return betamat, AllboostHistory(selection=selection_hist, beta_path=beta_path)
550
+ if return_covcache:
551
+ return betamat, _covcache
552
+ return betamat
@@ -0,0 +1,109 @@
1
+ """BAE Decoder module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+ if TYPE_CHECKING:
11
+ from ._types import BAEConfig
12
+
13
+
14
+ class BAEDecoder(nn.Module):
15
+ """MLP decoder for Boosting Autoencoder.
16
+
17
+ Maps latent space back to gene expression via a multi-layer perceptron.
18
+
19
+ Parameters
20
+ ----------
21
+ n_output
22
+ Number of output features (genes).
23
+ config
24
+ BAE configuration object.
25
+ input_dim_override
26
+ If set, overrides ``config.latent_dim`` for the first layer input
27
+ dimension. Used when split-softmax doubles the latent size to 2d.
28
+ n_covariates
29
+ Number of encoded conditioning covariates. Zero disables conditioning;
30
+ any positive value appends them to the decoder input.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ n_output: int,
36
+ config: BAEConfig,
37
+ *,
38
+ input_dim_override: int | None = None,
39
+ n_covariates: int = 0,
40
+ ) -> None:
41
+ super().__init__()
42
+ self.n_output = n_output
43
+ self.config = config
44
+ self.n_covariates = n_covariates
45
+ if n_covariates < 0:
46
+ raise ValueError(f"n_covariates must be >= 0, got {n_covariates}")
47
+
48
+ # Build activation function
49
+ self.activation = self._get_activation(config.decoder_activation)
50
+
51
+ # Build decoder MLP: effective_latent -> hidden_dims -> n_output
52
+ effective_latent = (input_dim_override or config.latent_dim) + n_covariates
53
+ layers: list[nn.Module] = []
54
+ dims = [effective_latent] + list(config.decoder_hidden_dims)
55
+
56
+ for i in range(len(dims) - 1):
57
+ layers.append(nn.Linear(dims[i], dims[i + 1]))
58
+ if config.decoder_use_batch_norm:
59
+ layers.append(nn.BatchNorm1d(dims[i + 1]))
60
+ layers.append(self.activation)
61
+ if config.decoder_dropout_rate > 0:
62
+ layers.append(nn.Dropout(config.decoder_dropout_rate))
63
+
64
+ self.hidden = nn.Sequential(*layers)
65
+ # Final projection to output space
66
+ self.output_layer = nn.Linear(dims[-1], n_output, bias=True)
67
+
68
+ @staticmethod
69
+ def _get_activation(name: str) -> nn.Module:
70
+ """Get activation module by name."""
71
+ activations = {
72
+ "tanh": nn.Tanh(),
73
+ "relu": nn.ReLU(),
74
+ "leaky_relu": nn.LeakyReLU(0.2),
75
+ "elu": nn.ELU(),
76
+ }
77
+ if name not in activations:
78
+ raise ValueError(f"Unknown activation: {name}")
79
+ return activations[name]
80
+
81
+ def reset_parameters(self) -> None:
82
+ """Reinitialize all learnable parameters."""
83
+ for module in self.modules():
84
+ if isinstance(module, nn.Linear):
85
+ module.reset_parameters()
86
+ elif isinstance(module, nn.BatchNorm1d):
87
+ module.reset_parameters()
88
+
89
+ def forward(self, z: torch.Tensor, covariates: torch.Tensor | None = None) -> torch.Tensor:
90
+ """Decode latent representation to reconstruction.
91
+
92
+ Parameters
93
+ ----------
94
+ z
95
+ Latent tensor of shape (n_cells, latent_dim).
96
+ covariates
97
+ Encoded conditioning covariates, required when conditioning is
98
+ enabled.
99
+
100
+ Returns
101
+ -------
102
+ Reconstruction of shape (n_cells, n_genes).
103
+ """
104
+ if self.n_covariates and covariates is None:
105
+ raise ValueError("Decoder conditioning covariates are required")
106
+ if not self.n_covariates and covariates is not None:
107
+ raise ValueError("Decoder was built without conditioning covariates")
108
+ decoder_input = torch.cat([z, covariates], dim=1) if self.n_covariates else z
109
+ return self.output_layer(self.hidden(decoder_input))