structboost 0.1.2__tar.gz → 0.3.0__tar.gz
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-0.3.0/CHANGELOG.md +206 -0
- {structboost-0.1.2 → structboost-0.3.0}/CITATION.cff +2 -2
- {structboost-0.1.2 → structboost-0.3.0}/PKG-INFO +3 -3
- {structboost-0.1.2 → structboost-0.3.0}/README.md +1 -1
- {structboost-0.1.2 → structboost-0.3.0}/pyproject.toml +1 -1
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/__init__.py +2 -1
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_boosting.py +123 -54
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_decoder.py +0 -6
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_model.py +199 -465
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_persistence.py +12 -6
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_stability.py +17 -2
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_types.py +37 -49
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_utils.py +72 -13
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae.py +43 -105
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_diagnostics.py +1 -9
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_layer.py +1 -1
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_persistence.py +0 -13
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_transfer.py +4 -12
- structboost-0.3.0/tests/test_covcache_precompute.py +250 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_rng_isolation.py +1 -1
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_stability.py +66 -149
- structboost-0.1.2/CHANGELOG.md +0 -66
- {structboost-0.1.2 → structboost-0.3.0}/.gitignore +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/CONTRIBUTING.md +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/LICENSE +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/conftest.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_annotation.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_encoder.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_explorer.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_io.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_plotting.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_simulation.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/src/structboost/py.typed +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_allboost.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_annotation.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_init.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_batch_integration.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_explorer.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_linear_ceiling.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_obs_encoding.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_public_api.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_resolve_mandatory.py +0 -0
- {structboost-0.1.2 → structboost-0.3.0}/tests/test_simulation.py +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
## Changelog
|
|
2
|
+
|
|
3
|
+
Releases follow [semantic versioning](https://semver.org). While the project is
|
|
4
|
+
pre-1.0, a minor bump may break API.
|
|
5
|
+
|
|
6
|
+
### [0.3.0] - 2026-08-11
|
|
7
|
+
|
|
8
|
+
**Breaking.** Five settings are gone and `BAE.stability_selection` has one mode
|
|
9
|
+
instead of two. Every removed option was off by default, so a fit that took the
|
|
10
|
+
defaults is unaffected: the encoder matrix is bitwise identical across a plain
|
|
11
|
+
fit, a batch-integrated fit, and both disentanglement methods. What changes is the
|
|
12
|
+
surface you have to reason about.
|
|
13
|
+
|
|
14
|
+
`boosting_nu` stays at `0.1`. Raising it to `0.3` was measured and deferred: at
|
|
15
|
+
the default `stepno=50` it matches `0.1`'s marker recovery three to four times
|
|
16
|
+
faster and wins outright on low-signal data, but then degrades if training
|
|
17
|
+
continues, and the training-MSE stopping rule cannot see it happening. The guide
|
|
18
|
+
records the numbers; the two changes belong together and will land together.
|
|
19
|
+
|
|
20
|
+
**Checkpoints written by 0.2.0 no longer load.** `restore_payload` splats the
|
|
21
|
+
stored config into `BAEConfig`, so a dropped field is an unexpected keyword
|
|
22
|
+
argument rather than a missing one. The format is bumped to 5 and the loader
|
|
23
|
+
refuses 4 by name. No migration is written: the method is under active
|
|
24
|
+
development and a checkpoint is cheap to regenerate, whereas a compatibility
|
|
25
|
+
shim for options that no longer exist is not.
|
|
26
|
+
|
|
27
|
+
Removed from `BAEConfig`:
|
|
28
|
+
|
|
29
|
+
- `decoder_dropout_rate` and `decoder_use_batch_norm`. Batch norm was already
|
|
30
|
+
documented as harmful (marker-recovery F1 0.59-0.73) because the boosting
|
|
31
|
+
target is computed with the decoder in eval mode and the update applied in
|
|
32
|
+
train mode. Dropout has exactly the same inconsistency and it was never written
|
|
33
|
+
down: the target comes from the full network, the update from a thinned one.
|
|
34
|
+
With both gone the decoder is a deterministic per-cell function, which is the
|
|
35
|
+
property `_compute_boosting_targets` has always relied on.
|
|
36
|
+
- `disentanglement_standardize`. Both `disentanglement` methods stay.
|
|
37
|
+
- `standardize_targets`. It was a genuine trade-off — higher selection precision,
|
|
38
|
+
roughly half the recall — but it is superseded by
|
|
39
|
+
`stability_selection(threshold=...)` -> `stable_encoder()` -> `apply_encoder()`,
|
|
40
|
+
which is the same trade with a dial that reports what it is doing. Two knobs for
|
|
41
|
+
one trade-off is worse than one.
|
|
42
|
+
|
|
43
|
+
Removed from `fit`:
|
|
44
|
+
|
|
45
|
+
- `balance_obs`, and with it the `sample_weights` parameter that threaded through
|
|
46
|
+
nine methods and both stability paths. Its own documentation conceded the
|
|
47
|
+
limit: the weights never reached the `allboost` fit, so gene selection stayed
|
|
48
|
+
unbalanced no matter what they were set to. The measured effect was modest (per
|
|
49
|
+
group reconstruction-MSE spread 0.231 -> 0.150) for a mechanism that promised
|
|
50
|
+
more than it delivered.
|
|
51
|
+
|
|
52
|
+
`BAE.stability_selection` is now iteration mode only; `mode`, `subsample_frac`,
|
|
53
|
+
`n_subsamples` and `n_iterations` are gone, and `fit(stability_selection=...)`
|
|
54
|
+
takes a bool. Iteration mode was already the default and measures the lower
|
|
55
|
+
false-discovery rate (0.26 against 0.31). The subsample path stays available as
|
|
56
|
+
the standalone `structboost.stability_selection`, which is where it belongs: it
|
|
57
|
+
resamples cells in the Meinshausen-Buhlmann scheme and works on an `allboost`
|
|
58
|
+
problem, so supervised users with no training loop to iterate over still have it.
|
|
59
|
+
The Meinshausen-Buhlmann bound it reports was measured to be violated by roughly
|
|
60
|
+
an order of magnitude when the targets come from a model fitted on the same
|
|
61
|
+
cells, and that warning moved with it.
|
|
62
|
+
|
|
63
|
+
`uns["bae"]["variance_explained"]` is now written by every fit. It used to appear
|
|
64
|
+
only when a covariate argument was passed, which left the quality workflow the
|
|
65
|
+
guide documents — compare it against `linear_ceiling` — raising `KeyError` on a
|
|
66
|
+
plain `fit(adata)`. The metric has nothing to do with covariates; only the
|
|
67
|
+
per-group breakdown does, and that stays behind the covariate guard.
|
|
68
|
+
|
|
69
|
+
Two fixes found along the way: the `allboost` example in the guide passed
|
|
70
|
+
`mode="standard"`, an argument `allboost` has never accepted, so it raised
|
|
71
|
+
`TypeError` as written. And `disentangle_boosting_targets` projects through the
|
|
72
|
+
origin, with no intercept — exact residualization only on centered targets. That
|
|
73
|
+
was masked by `disentanglement_standardize`, which centered them; with the flag
|
|
74
|
+
gone the assumption is documented instead.
|
|
75
|
+
|
|
76
|
+
### [0.2.0] - 2026-08-05
|
|
77
|
+
|
|
78
|
+
The boosting loop got faster without changing what it computes. Measured
|
|
79
|
+
end-to-end on real preprocessed scRNA-seq (20,000 cells, `latent_dim=10`):
|
|
80
|
+
**1.86x at p=2,000, 2.20x at p=3,000, 1.80x at p=8,000**, and 1.46x on a
|
|
81
|
+
batch-integrated fit with 20 covariate dummies.
|
|
82
|
+
|
|
83
|
+
**This is not a methodological improvement.** Across eighteen configurations —
|
|
84
|
+
plain, all three `batch_integration_mode` values, flat and per-dimension
|
|
85
|
+
`mandatory_genes`, `balance_obs`, `split_softmax`, both disentanglement methods,
|
|
86
|
+
`standardize_targets`, frozen/anchored/zero-dimension transfers and both
|
|
87
|
+
stability modes — the selected gene set is *identical* and the reconstruction
|
|
88
|
+
loss agrees to the sixth decimal. Nothing here recovers a marker that was
|
|
89
|
+
previously missed. What it buys is more iterations and more stability runs for
|
|
90
|
+
the same budget, which is what a method whose encoder support keeps drifting
|
|
91
|
+
actually needs.
|
|
92
|
+
|
|
93
|
+
Stability selection benefits too, and for the default `n_runs=300` that is the
|
|
94
|
+
larger absolute saving: **1.79–1.88x** for iteration mode and **1.31–1.39x** for
|
|
95
|
+
subsample mode at p=2,000–3,000, with peak memory during a subsample run falling
|
|
96
|
+
from 360 MB to 5 MB at p=3,000 — that figure is exactly the float64 copy of the
|
|
97
|
+
expression matrix described below. Subsample mode gains less by construction:
|
|
98
|
+
every run draws a different subset of cells, so its column norms genuinely change
|
|
99
|
+
and cannot be hoisted, and it uses the lazy column cache rather than the full
|
|
100
|
+
matrix.
|
|
101
|
+
|
|
102
|
+
Precomputing the covariance matrix *inside* subsample mode was measured and
|
|
103
|
+
rejected. It runs 3.07x faster at p=2,000 and 2.69x at p=3,000, but 0.73–0.85x
|
|
104
|
+
**slower** from p=6,000 upwards: the number of distinct columns a subsample
|
|
105
|
+
selects stays roughly flat as p grows, so the full p x p product stops paying for
|
|
106
|
+
itself. A memory guard cannot separate those cases — the 800 MB matrix at
|
|
107
|
+
p=10,000 fits comfortably and still loses — so that path stays lazy at every p.
|
|
108
|
+
|
|
109
|
+
`boosting_precompute_covcache` now defaults to `"auto"` and applies to every
|
|
110
|
+
boosting path, including iteration-mode stability selection, which previously
|
|
111
|
+
ignored the setting entirely. `"auto"` builds the full p x p covariance matrix
|
|
112
|
+
whenever it fits a conservative share of system memory; `True` and `False` are
|
|
113
|
+
still honoured exactly, and the resolved decision is recorded in
|
|
114
|
+
`adata.uns["bae"]["boosting_precompute_covcache"]`.
|
|
115
|
+
|
|
116
|
+
The flag was documented as a memory-versus-recomputation trade, which undersold
|
|
117
|
+
it. Building all `p` columns at once is one compute-bound matrix product near
|
|
118
|
+
hardware peak; fetching them one at a time is a sequence of memory-bound
|
|
119
|
+
matrix-vector products. Precomputing wins from roughly `p/59` distinct selected
|
|
120
|
+
features onwards — a threshold a fit passes in its first iteration.
|
|
121
|
+
|
|
122
|
+
Three quantities that never change were being recomputed. `col_norms_sq` was
|
|
123
|
+
rebuilt on every training iteration although the design matrix is fixed for the
|
|
124
|
+
whole fit, allocating a full-size temporary each time; the mandatory-covariate
|
|
125
|
+
block was rebuilt on every *boosting step* although it depends only on the
|
|
126
|
+
target; and the covariance cache's NaN check re-scanned columns that had already
|
|
127
|
+
been verified. The starting residual correlations are now formed for all latent
|
|
128
|
+
dimensions in one matrix product rather than one per dimension.
|
|
129
|
+
|
|
130
|
+
A float64 target passed against float32 predictors used to make NumPy promote
|
|
131
|
+
the *design matrix*, materializing a full float64 copy — measured 2.1x slower,
|
|
132
|
+
and it silently produced a different answer. The target is now aligned to the
|
|
133
|
+
predictors instead.
|
|
134
|
+
|
|
135
|
+
The three entry points to the same boosting problem ran at two different
|
|
136
|
+
precisions: `fit` in float32, both stability paths in float64 on top of a
|
|
137
|
+
float32 copy they had already made. They now agree on float32, which removes two
|
|
138
|
+
full-size copies of the expression matrix. The precision this gives up was
|
|
139
|
+
measured at about one gene in 380 — far inside the run-to-run support variation
|
|
140
|
+
this method documents for itself.
|
|
141
|
+
|
|
142
|
+
Results are **not bit-identical to 0.1.2**. Coefficients move by ~1e-6 relative
|
|
143
|
+
and the selected support does not change; a fixed seed no longer reproduces
|
|
144
|
+
0.1.2 output exactly.
|
|
145
|
+
|
|
146
|
+
### [0.1.2] - 2026-08-03
|
|
147
|
+
|
|
148
|
+
Project metadata gains `Documentation` and `Changelog` links. PyPI renders
|
|
149
|
+
`project.urls` as the sidebar next to the project description, and it carried
|
|
150
|
+
only the repository and the issue tracker — so the documentation site, where
|
|
151
|
+
every substantive explanation lives, was reachable from the README body but not
|
|
152
|
+
from the navigation beside it. Both targets are verified live.
|
|
153
|
+
|
|
154
|
+
As with 0.1.1, nothing in the package changed: `project.urls` reaches users only
|
|
155
|
+
through an upload, so correcting it in the repository has no effect until a
|
|
156
|
+
release carries it.
|
|
157
|
+
|
|
158
|
+
### [0.1.1] - 2026-08-03
|
|
159
|
+
|
|
160
|
+
A metadata release. No code in `structboost` changed; every difference is in
|
|
161
|
+
what the package says about itself.
|
|
162
|
+
|
|
163
|
+
The PyPI page is the reason for it. A project's long description is baked into
|
|
164
|
+
the uploaded artifacts and is immutable per release, so 0.1.0 shipped with a
|
|
165
|
+
README announcing "Not on PyPI yet" and directing readers to a pinned TestPyPI
|
|
166
|
+
install. Correcting the file in git does not touch the published page — only a
|
|
167
|
+
new release does. A `0.1.0.post1` would have expressed "packaging only" more
|
|
168
|
+
precisely, but post-releases are handled inconsistently by downstream tooling
|
|
169
|
+
and the versioning policy here is plain `MAJOR.MINOR.PATCH`.
|
|
170
|
+
|
|
171
|
+
Python 3.13 is tested and advertised. `requires-python = ">=3.10"` never had an
|
|
172
|
+
upper bound, so pip already installed on 3.13 while the CI matrix stopped at
|
|
173
|
+
3.12 — support permitted but never exercised. The matrix now covers it, and the
|
|
174
|
+
classifier list says what the requirement already allowed. 3.14 is left out
|
|
175
|
+
until the `[bae]` extra's wheels are dependably available there.
|
|
176
|
+
|
|
177
|
+
`CITATION.cff` gains `version` and `date-released`, which a citation file for a
|
|
178
|
+
released version cannot do without, plus a `url` for the documentation site.
|
|
179
|
+
|
|
180
|
+
### [0.1.0] - 2026-07-31
|
|
181
|
+
|
|
182
|
+
First public release.
|
|
183
|
+
|
|
184
|
+
Batch integration is one argument. `BAE.fit(batch_key=...)` names the covariate,
|
|
185
|
+
following scVI's spelling, and `batch_integration_mode` chooses between
|
|
186
|
+
`"decoder"`, `"encoder"` and `"both"`, defaulting to `"both"`. No `batch_key`
|
|
187
|
+
means no integration, and naming a mode without one raises rather than quietly
|
|
188
|
+
integrating nothing.
|
|
189
|
+
|
|
190
|
+
`"encoder"` names the half of the model the mechanism protects, not a tensor the
|
|
191
|
+
covariate is fed to: it enters the boosting design as a mandatory regressor so
|
|
192
|
+
gene selection is not confounded by it. `transform` remains gene-only and needs
|
|
193
|
+
no covariate labels under any mode.
|
|
194
|
+
|
|
195
|
+
The ridge that stabilizes near-collinear covariates is `BAEConfig.nuisance_ridge`.
|
|
196
|
+
It is a numerical knob rather than a modelling one, so it sits with the other
|
|
197
|
+
algorithm settings.
|
|
198
|
+
|
|
199
|
+
The `test` extra pulls the runtime dependencies. The sdist carries `tests/` and
|
|
200
|
+
`conftest.py` so that downstream packagers can run the suite at build time, and
|
|
201
|
+
with pytest alone that did not work: 360 of the 411 test functions sit behind an
|
|
202
|
+
`importorskip` for torch or anndata, so `pip install .[test] && pytest` ran ~51
|
|
203
|
+
tests, skipped the rest and reported success. Installing `[test]` now brings in
|
|
204
|
+
`[bae]`, so a green build means the suite actually ran. Documenting the
|
|
205
|
+
requirement in `CONTRIBUTING.md` instead was rejected, because the reader who
|
|
206
|
+
needs it is an automated build script rather than a person.
|
|
@@ -2,8 +2,8 @@ cff-version: 1.2.0
|
|
|
2
2
|
message: "If you use structboost in your research, please cite it."
|
|
3
3
|
title: "structboost"
|
|
4
4
|
type: software
|
|
5
|
-
version: "0.
|
|
6
|
-
date-released: "2026-08-
|
|
5
|
+
version: "0.3.0"
|
|
6
|
+
date-released: "2026-08-11"
|
|
7
7
|
license: MIT
|
|
8
8
|
authors:
|
|
9
9
|
- family-names: Brunn
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: structboost
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Boosting Autoencoders (BAE) and componentwise L2 boosting utilities for scverse-style workflows.
|
|
5
5
|
Project-URL: Homepage, https://github.com/NiklasBrunn/structboost
|
|
6
6
|
Project-URL: Documentation, https://niklasbrunn.github.io/structboost
|
|
@@ -153,7 +153,7 @@ is not identifiable: many different sparse gene sets reconstruct the data about
|
|
|
153
153
|
equally well, and a fit returns one of them.
|
|
154
154
|
|
|
155
155
|
```python
|
|
156
|
-
res = model.stability_selection(adata
|
|
156
|
+
res = model.stability_selection(adata)
|
|
157
157
|
genes = [adata.var_names[res.stable_support[:, j]] for j in range(res.frequency.shape[1])]
|
|
158
158
|
```
|
|
159
159
|
|
|
@@ -78,7 +78,7 @@ is not identifiable: many different sparse gene sets reconstruct the data about
|
|
|
78
78
|
equally well, and a fit returns one of them.
|
|
79
79
|
|
|
80
80
|
```python
|
|
81
|
-
res = model.stability_selection(adata
|
|
81
|
+
res = model.stability_selection(adata)
|
|
82
82
|
genes = [adata.var_names[res.stable_support[:, j]] for j in range(res.frequency.shape[1])]
|
|
83
83
|
```
|
|
84
84
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "structboost"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Boosting Autoencoders (BAE) and componentwise L2 boosting utilities for scverse-style workflows."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -16,7 +16,7 @@ from ._annotation import (
|
|
|
16
16
|
extract_gene_rankings,
|
|
17
17
|
write_annotations_to_h5ad,
|
|
18
18
|
)
|
|
19
|
-
from ._boosting import AllboostHistory, allboost
|
|
19
|
+
from ._boosting import AllboostHistory, allboost, column_norms_sq
|
|
20
20
|
from ._io import looks_like_ensembl, read_encoder_weights, write_encoder_weights
|
|
21
21
|
from ._simulation import SimulationResult, sim_scrnaseq_anndata, sim_scrnaseq_data
|
|
22
22
|
from ._stability import StabilitySelectionResult, stability_selection
|
|
@@ -51,6 +51,7 @@ __all__ = [
|
|
|
51
51
|
"StabilitySelectionResult",
|
|
52
52
|
"TrainingReport",
|
|
53
53
|
"allboost",
|
|
54
|
+
"column_norms_sq",
|
|
54
55
|
"compute_covariance_cache",
|
|
55
56
|
"encode_obs_covariates",
|
|
56
57
|
"export_interactive_html",
|
|
@@ -12,32 +12,23 @@ from numpy.typing import NDArray
|
|
|
12
12
|
_CovarianceCache = NDArray[np.floating] | dict[int, NDArray[np.float64]]
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def
|
|
16
|
-
|
|
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.
|
|
15
|
+
def column_norms_sq(sourcemat: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
16
|
+
"""Squared column norms ``||x_j||^2`` of the boosting design matrix.
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
18
|
+
Exposed so a caller that invokes :func:`allboost` repeatedly against the
|
|
19
|
+
*same* ``sourcemat`` -- which is what :meth:`structboost.BAE.fit` does, once
|
|
20
|
+
per training iteration -- can compute this once and pass it back in. It is
|
|
21
|
+
otherwise recomputed on every call, an O(n*p) pass that also allocates a full
|
|
22
|
+
``(n_samples, n_features)`` temporary.
|
|
30
23
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
Using this helper rather than open-coding the expression is what guarantees a
|
|
25
|
+
hoisted value is bit-for-bit the one ``allboost`` would have computed itself.
|
|
26
|
+
|
|
27
|
+
``einsum`` rather than ``(sourcemat**2).sum(axis=0)``: the latter materializes
|
|
28
|
+
a full ``(n_samples, n_features)`` squared copy, 1.4 GB at 60k x 6k, purely to
|
|
29
|
+
reduce it away again.
|
|
37
30
|
"""
|
|
38
|
-
|
|
39
|
-
col_norms_sq = (x**2).sum(axis=0)
|
|
40
|
-
return (x.T @ y) / col_norms_sq, col_norms_sq
|
|
31
|
+
return np.einsum("ij,ij->j", sourcemat, sourcemat)
|
|
41
32
|
|
|
42
33
|
|
|
43
34
|
@dataclass
|
|
@@ -106,13 +97,41 @@ def _validate_mandatory_features(
|
|
|
106
97
|
return validated
|
|
107
98
|
|
|
108
99
|
|
|
100
|
+
class _MandatoryBlock:
|
|
101
|
+
"""The per-target constants of the mandatory pre-step.
|
|
102
|
+
|
|
103
|
+
``mand_idx`` is fixed for the whole of a target's boosting run, so the
|
|
104
|
+
covariance columns of the mandatory features, the Gram submatrix among them
|
|
105
|
+
and the ridge penalty do not change between steps -- only the right-hand side
|
|
106
|
+
does. Building them once per target instead of once per *step* is what this
|
|
107
|
+
exists for: the ``column_stack`` alone costs ~270 ms per training iteration at
|
|
108
|
+
20 mandatory features and p=4000, repeated ``stepno`` times for nothing.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
__slots__ = ("cov", "lhs", "penalty")
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
mand_idx: NDArray[np.intp],
|
|
116
|
+
col_norms_sq: NDArray[np.floating],
|
|
117
|
+
get_covariance_column: Callable[[int], NDArray[np.floating]],
|
|
118
|
+
ridge: NDArray[np.float64],
|
|
119
|
+
) -> None:
|
|
120
|
+
self.cov = np.column_stack([get_covariance_column(int(j)) for j in mand_idx])
|
|
121
|
+
self.penalty = ridge[mand_idx] * col_norms_sq[mand_idx]
|
|
122
|
+
# The solve's left-hand side is constant across steps too. Deliberately
|
|
123
|
+
# kept as a matrix rather than a factorization: `np.linalg.solve` is
|
|
124
|
+
# O(n_mandatory^3) and therefore free next to the block construction,
|
|
125
|
+
# while swapping in a Cholesky would change results in the last bits.
|
|
126
|
+
self.lhs = self.cov[mand_idx] + np.diag(self.penalty)
|
|
127
|
+
|
|
128
|
+
|
|
109
129
|
def _mandatory_prestep(
|
|
110
130
|
mand_idx: NDArray[np.intp],
|
|
111
131
|
actualnom: NDArray[np.floating],
|
|
112
132
|
beta: NDArray[np.floating],
|
|
113
133
|
col_norms_sq: NDArray[np.floating],
|
|
114
|
-
|
|
115
|
-
ridge: NDArray[np.float64],
|
|
134
|
+
block: _MandatoryBlock,
|
|
116
135
|
target_index: int = 0,
|
|
117
136
|
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
118
137
|
"""Joint mandatory update, optionally ridge-stabilized.
|
|
@@ -123,15 +142,12 @@ def _mandatory_prestep(
|
|
|
123
142
|
if mand_idx.size == 0:
|
|
124
143
|
return actualnom, beta
|
|
125
144
|
|
|
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
145
|
# Include the derivative of the penalty at the current coefficient. Without
|
|
130
146
|
# this term, repeatedly applying a ridge pre-step would converge back to the
|
|
131
147
|
# unpenalized OLS solution as boosting proceeds.
|
|
132
|
-
nom_mand = actualnom[mand_idx] * col_norms_sq[mand_idx] - penalty * beta[mand_idx]
|
|
148
|
+
nom_mand = actualnom[mand_idx] * col_norms_sq[mand_idx] - block.penalty * beta[mand_idx]
|
|
133
149
|
try:
|
|
134
|
-
gamma_mand = np.linalg.solve(
|
|
150
|
+
gamma_mand = np.linalg.solve(block.lhs, nom_mand)
|
|
135
151
|
except np.linalg.LinAlgError as exc:
|
|
136
152
|
# Deliberately not falling back to a pseudo-inverse or auto-adding ridge:
|
|
137
153
|
# either silently fits a different model than the caller specified.
|
|
@@ -145,7 +161,7 @@ def _mandatory_prestep(
|
|
|
145
161
|
"is not applied automatically."
|
|
146
162
|
) from exc
|
|
147
163
|
beta[mand_idx] += gamma_mand
|
|
148
|
-
actualnom -= (
|
|
164
|
+
actualnom -= (block.cov @ gamma_mand) / col_norms_sq
|
|
149
165
|
|
|
150
166
|
return actualnom, beta
|
|
151
167
|
|
|
@@ -159,6 +175,7 @@ def allboost(
|
|
|
159
175
|
mandatory_ridge: float | NDArray[np.floating] = 0.0,
|
|
160
176
|
beta_init: NDArray[np.floating] | None = None,
|
|
161
177
|
covcache: _CovarianceCache | None = None,
|
|
178
|
+
col_norms_sq: NDArray[np.floating] | None = None,
|
|
162
179
|
stepno: int = 20,
|
|
163
180
|
nu: float = 0.1,
|
|
164
181
|
csf: float = 0.9,
|
|
@@ -177,6 +194,7 @@ def allboost(
|
|
|
177
194
|
mandatory_ridge: float | NDArray[np.floating] = 0.0,
|
|
178
195
|
beta_init: NDArray[np.floating] | None = None,
|
|
179
196
|
covcache: _CovarianceCache | None = None,
|
|
197
|
+
col_norms_sq: NDArray[np.floating] | None = None,
|
|
180
198
|
stepno: int = 20,
|
|
181
199
|
nu: float = 0.1,
|
|
182
200
|
csf: float = 0.9,
|
|
@@ -195,6 +213,7 @@ def allboost(
|
|
|
195
213
|
mandatory_ridge: float | NDArray[np.floating] = 0.0,
|
|
196
214
|
beta_init: NDArray[np.floating] | None = None,
|
|
197
215
|
covcache: _CovarianceCache | None = None,
|
|
216
|
+
col_norms_sq: NDArray[np.floating] | None = None,
|
|
198
217
|
stepno: int = 20,
|
|
199
218
|
nu: float = 0.1,
|
|
200
219
|
csf: float = 0.9,
|
|
@@ -213,6 +232,7 @@ def allboost(
|
|
|
213
232
|
mandatory_ridge: float | NDArray[np.floating] = 0.0,
|
|
214
233
|
beta_init: NDArray[np.floating] | None = None,
|
|
215
234
|
covcache: _CovarianceCache | None = None,
|
|
235
|
+
col_norms_sq: NDArray[np.floating] | None = None,
|
|
216
236
|
stepno: int = 20,
|
|
217
237
|
nu: float = 0.1,
|
|
218
238
|
csf: float = 0.9,
|
|
@@ -230,6 +250,7 @@ def allboost(
|
|
|
230
250
|
mandatory_ridge: float | NDArray[np.floating] = 0.0,
|
|
231
251
|
beta_init: NDArray[np.floating] | None = None,
|
|
232
252
|
covcache: _CovarianceCache | None = None,
|
|
253
|
+
col_norms_sq: NDArray[np.floating] | None = None,
|
|
233
254
|
stepno: int = 20,
|
|
234
255
|
nu: float = 0.1,
|
|
235
256
|
csf: float = 0.9,
|
|
@@ -291,6 +312,14 @@ def allboost(
|
|
|
291
312
|
full-cache fast path. A dict maps feature indices to covariance columns
|
|
292
313
|
and grows only when a feature is selected. If None, an empty column
|
|
293
314
|
cache is created. Reuse the returned cache with the same sourcemat only.
|
|
315
|
+
col_norms_sq : ndarray of shape (n_features,), optional
|
|
316
|
+
Pre-computed squared column norms, as returned by :func:`column_norms_sq`.
|
|
317
|
+
Computed internally when None. Supplying it skips an O(n*p) pass and a
|
|
318
|
+
full ``(n_samples, n_features)`` temporary per call, which matters when
|
|
319
|
+
``allboost`` is called repeatedly against an unchanging ``sourcemat`` --
|
|
320
|
+
once per training iteration, in :meth:`structboost.BAE.fit`. It carries
|
|
321
|
+
the same staleness contract as ``covcache``: reuse it with the same
|
|
322
|
+
``sourcemat`` only.
|
|
294
323
|
stepno : int, default=20
|
|
295
324
|
Number of boosting iterations per target.
|
|
296
325
|
nu : float, default=0.1
|
|
@@ -377,6 +406,20 @@ def allboost(
|
|
|
377
406
|
f"sourcemat and targetmat must have same n_samples, got {n} and {targetmat.shape[0]}"
|
|
378
407
|
)
|
|
379
408
|
|
|
409
|
+
# Match the target's dtype to the predictors'. Left alone, a float32
|
|
410
|
+
# `sourcemat` against a float64 `targetmat` makes numpy promote *sourcemat*
|
|
411
|
+
# inside every predictor-target product -- materializing a full float64 copy
|
|
412
|
+
# of the design matrix, measured 20-25x slower than the matched-dtype path.
|
|
413
|
+
# Casting the (n_samples, n_targets) target instead is the cheap direction,
|
|
414
|
+
# and it fixes the precision of the fit at the predictors', which is where
|
|
415
|
+
# the O(n) accumulations that actually limit accuracy happen.
|
|
416
|
+
if (
|
|
417
|
+
np.issubdtype(sourcemat.dtype, np.floating)
|
|
418
|
+
and np.issubdtype(targetmat.dtype, np.floating)
|
|
419
|
+
and sourcemat.dtype != targetmat.dtype
|
|
420
|
+
):
|
|
421
|
+
targetmat = targetmat.astype(sourcemat.dtype, copy=False)
|
|
422
|
+
|
|
380
423
|
mandatory_per_target = _validate_mandatory_features(mandatory_features, p, k)
|
|
381
424
|
ridge = np.asarray(mandatory_ridge, dtype=np.float64)
|
|
382
425
|
if ridge.ndim == 0:
|
|
@@ -396,8 +439,16 @@ def allboost(
|
|
|
396
439
|
if not np.isfinite(beta_init).all():
|
|
397
440
|
raise ValueError("beta_init must contain only finite values")
|
|
398
441
|
|
|
399
|
-
#
|
|
400
|
-
|
|
442
|
+
# Column squared norms (used for penalty scaling and residual updates). A
|
|
443
|
+
# caller looping over the same `sourcemat` can hoist this out with
|
|
444
|
+
# `column_norms_sq` and hand it back; it is otherwise an O(n*p) pass plus a
|
|
445
|
+
# full (n_samples, n_features) temporary on every single call.
|
|
446
|
+
if col_norms_sq is None:
|
|
447
|
+
col_norms_sq = column_norms_sq(sourcemat)
|
|
448
|
+
else:
|
|
449
|
+
col_norms_sq = np.asarray(col_norms_sq)
|
|
450
|
+
if col_norms_sq.shape != (p,):
|
|
451
|
+
raise ValueError(f"col_norms_sq must have shape ({p},), got {col_norms_sq.shape}")
|
|
401
452
|
|
|
402
453
|
# Check for zero-variance columns
|
|
403
454
|
if (col_norms_sq == 0).any():
|
|
@@ -441,15 +492,38 @@ def allboost(
|
|
|
441
492
|
raise ValueError(f"covcache must have shape ({p}, {p}), got {covcache.shape}")
|
|
442
493
|
_covcache = covcache
|
|
443
494
|
|
|
495
|
+
# A column needs its NaN scan only once per call: after the first look it
|
|
496
|
+
# is either clean or has just been filled in below, and neither state can
|
|
497
|
+
# revert -- a later fill of some other column writes values, never NaNs.
|
|
498
|
+
# Re-scanning on every access spends an O(p) pass per boosting step for
|
|
499
|
+
# nothing, and the mandatory block alone touches its columns `stepno`
|
|
500
|
+
# times per target.
|
|
501
|
+
_verified = np.zeros(p, dtype=bool)
|
|
502
|
+
|
|
444
503
|
def get_covariance_column(j: int) -> NDArray[np.floating]:
|
|
445
504
|
column = _covcache[:, j]
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
505
|
+
if not _verified[j]:
|
|
506
|
+
nan_mask = np.isnan(column)
|
|
507
|
+
if nan_mask.any():
|
|
508
|
+
computed = sourcemat[:, nan_mask].T @ sourcemat[:, j]
|
|
509
|
+
column[nan_mask] = computed
|
|
510
|
+
_covcache[j, nan_mask] = computed
|
|
511
|
+
_verified[j] = True
|
|
451
512
|
return column
|
|
452
513
|
|
|
514
|
+
# Starting residual correlations for *every* target at once. One
|
|
515
|
+
# (n_features, n_targets) matrix product instead of one matrix-vector product
|
|
516
|
+
# per target: same quantity, but a compute-bound gemm rather than k
|
|
517
|
+
# memory-bound gemv passes over sourcemat, measured ~3.8x faster. With
|
|
518
|
+
# `beta_init` the residual is taken at the offset model rather than at zero --
|
|
519
|
+
# otherwise the first selection step would re-fit signal the offset already
|
|
520
|
+
# explains -- and that too batches into a single product.
|
|
521
|
+
if beta_init is None:
|
|
522
|
+
residuals = targetmat
|
|
523
|
+
else:
|
|
524
|
+
residuals = targetmat - sourcemat @ beta_init.T
|
|
525
|
+
initial_nom = (sourcemat.T @ residuals) / col_norms_sq[:, None]
|
|
526
|
+
|
|
453
527
|
# Initialize shared state (used if independent=False)
|
|
454
528
|
if not independent:
|
|
455
529
|
nuvec = np.full(p, nu, dtype=np.float64)
|
|
@@ -461,30 +535,25 @@ def allboost(
|
|
|
461
535
|
nuvec = np.full(p, nu, dtype=np.float64)
|
|
462
536
|
penvec = col_norms_sq * (1.0 / nu - 1.0)
|
|
463
537
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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)
|
|
538
|
+
# `.copy()` is required: actualnom is updated in place below.
|
|
539
|
+
actualnom = initial_nom[:, t_idx].copy()
|
|
540
|
+
beta = np.zeros(p, dtype=np.float64) if beta_init is None else beta_init[t_idx].copy()
|
|
477
541
|
mand_idx = mandatory_per_target[t_idx]
|
|
542
|
+
# Built once per target: nothing in it depends on the boosting step.
|
|
543
|
+
mand_block = (
|
|
544
|
+
_MandatoryBlock(mand_idx, col_norms_sq, get_covariance_column, ridge)
|
|
545
|
+
if mand_idx.size > 0
|
|
546
|
+
else None
|
|
547
|
+
)
|
|
478
548
|
|
|
479
549
|
for step in range(stepno):
|
|
480
|
-
if
|
|
550
|
+
if mand_block is not None:
|
|
481
551
|
actualnom, beta = _mandatory_prestep(
|
|
482
552
|
mand_idx,
|
|
483
553
|
actualnom,
|
|
484
554
|
beta,
|
|
485
555
|
col_norms_sq,
|
|
486
|
-
|
|
487
|
-
ridge,
|
|
556
|
+
mand_block,
|
|
488
557
|
t_idx,
|
|
489
558
|
)
|
|
490
559
|
|
|
@@ -55,11 +55,7 @@ class BAEDecoder(nn.Module):
|
|
|
55
55
|
|
|
56
56
|
for i in range(len(dims) - 1):
|
|
57
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
58
|
layers.append(self.activation)
|
|
61
|
-
if config.decoder_dropout_rate > 0:
|
|
62
|
-
layers.append(nn.Dropout(config.decoder_dropout_rate))
|
|
63
59
|
|
|
64
60
|
self.hidden = nn.Sequential(*layers)
|
|
65
61
|
# Final projection to output space
|
|
@@ -83,8 +79,6 @@ class BAEDecoder(nn.Module):
|
|
|
83
79
|
for module in self.modules():
|
|
84
80
|
if isinstance(module, nn.Linear):
|
|
85
81
|
module.reset_parameters()
|
|
86
|
-
elif isinstance(module, nn.BatchNorm1d):
|
|
87
|
-
module.reset_parameters()
|
|
88
82
|
|
|
89
83
|
def forward(self, z: torch.Tensor, covariates: torch.Tensor | None = None) -> torch.Tensor:
|
|
90
84
|
"""Decode latent representation to reconstruction.
|