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/_boosting.py
ADDED
|
@@ -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
|
structboost/_decoder.py
ADDED
|
@@ -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))
|