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.
Files changed (44) hide show
  1. structboost-0.3.0/CHANGELOG.md +206 -0
  2. {structboost-0.1.2 → structboost-0.3.0}/CITATION.cff +2 -2
  3. {structboost-0.1.2 → structboost-0.3.0}/PKG-INFO +3 -3
  4. {structboost-0.1.2 → structboost-0.3.0}/README.md +1 -1
  5. {structboost-0.1.2 → structboost-0.3.0}/pyproject.toml +1 -1
  6. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/__init__.py +2 -1
  7. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_boosting.py +123 -54
  8. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_decoder.py +0 -6
  9. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_model.py +199 -465
  10. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_persistence.py +12 -6
  11. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_stability.py +17 -2
  12. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_types.py +37 -49
  13. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_utils.py +72 -13
  14. {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae.py +43 -105
  15. {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_diagnostics.py +1 -9
  16. {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_layer.py +1 -1
  17. {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_persistence.py +0 -13
  18. {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_transfer.py +4 -12
  19. structboost-0.3.0/tests/test_covcache_precompute.py +250 -0
  20. {structboost-0.1.2 → structboost-0.3.0}/tests/test_rng_isolation.py +1 -1
  21. {structboost-0.1.2 → structboost-0.3.0}/tests/test_stability.py +66 -149
  22. structboost-0.1.2/CHANGELOG.md +0 -66
  23. {structboost-0.1.2 → structboost-0.3.0}/.gitignore +0 -0
  24. {structboost-0.1.2 → structboost-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  25. {structboost-0.1.2 → structboost-0.3.0}/CONTRIBUTING.md +0 -0
  26. {structboost-0.1.2 → structboost-0.3.0}/LICENSE +0 -0
  27. {structboost-0.1.2 → structboost-0.3.0}/conftest.py +0 -0
  28. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_annotation.py +0 -0
  29. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_encoder.py +0 -0
  30. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_explorer.py +0 -0
  31. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_io.py +0 -0
  32. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_plotting.py +0 -0
  33. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/_simulation.py +0 -0
  34. {structboost-0.1.2 → structboost-0.3.0}/src/structboost/py.typed +0 -0
  35. {structboost-0.1.2 → structboost-0.3.0}/tests/test_allboost.py +0 -0
  36. {structboost-0.1.2 → structboost-0.3.0}/tests/test_annotation.py +0 -0
  37. {structboost-0.1.2 → structboost-0.3.0}/tests/test_bae_init.py +0 -0
  38. {structboost-0.1.2 → structboost-0.3.0}/tests/test_batch_integration.py +0 -0
  39. {structboost-0.1.2 → structboost-0.3.0}/tests/test_explorer.py +0 -0
  40. {structboost-0.1.2 → structboost-0.3.0}/tests/test_linear_ceiling.py +0 -0
  41. {structboost-0.1.2 → structboost-0.3.0}/tests/test_obs_encoding.py +0 -0
  42. {structboost-0.1.2 → structboost-0.3.0}/tests/test_public_api.py +0 -0
  43. {structboost-0.1.2 → structboost-0.3.0}/tests/test_resolve_mandatory.py +0 -0
  44. {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.1.2"
6
- date-released: "2026-08-03"
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: structboost
3
- Version: 0.1.2
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, mode="iteration")
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, mode="iteration")
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.1.2"
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 _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.
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
- 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.
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
- 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).
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
- 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
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
- get_covariance_column: Callable[[int], NDArray[np.floating]],
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(c_mm + np.diag(penalty), nom_mand)
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 -= (mandatory_cov @ gamma_mand) / col_norms_sq
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
- # Precompute column squared norms (used for penalty scaling and residual updates)
400
- col_norms_sq = (sourcemat**2).sum(axis=0)
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
- 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
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
- 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)
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 mand_idx.size > 0:
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
- get_covariance_column,
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.