structboost 0.1.2__tar.gz → 0.2.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.2.0/CHANGELOG.md +136 -0
  2. {structboost-0.1.2 → structboost-0.2.0}/CITATION.cff +2 -2
  3. {structboost-0.1.2 → structboost-0.2.0}/PKG-INFO +1 -1
  4. {structboost-0.1.2 → structboost-0.2.0}/pyproject.toml +1 -1
  5. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/__init__.py +2 -1
  6. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_boosting.py +123 -54
  7. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_model.py +57 -8
  8. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_stability.py +17 -2
  9. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_types.py +30 -10
  10. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_utils.py +62 -1
  11. structboost-0.2.0/tests/test_covcache_precompute.py +250 -0
  12. structboost-0.1.2/CHANGELOG.md +0 -66
  13. {structboost-0.1.2 → structboost-0.2.0}/.gitignore +0 -0
  14. {structboost-0.1.2 → structboost-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  15. {structboost-0.1.2 → structboost-0.2.0}/CONTRIBUTING.md +0 -0
  16. {structboost-0.1.2 → structboost-0.2.0}/LICENSE +0 -0
  17. {structboost-0.1.2 → structboost-0.2.0}/README.md +0 -0
  18. {structboost-0.1.2 → structboost-0.2.0}/conftest.py +0 -0
  19. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_annotation.py +0 -0
  20. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_decoder.py +0 -0
  21. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_encoder.py +0 -0
  22. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_explorer.py +0 -0
  23. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_io.py +0 -0
  24. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_persistence.py +0 -0
  25. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_plotting.py +0 -0
  26. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_simulation.py +0 -0
  27. {structboost-0.1.2 → structboost-0.2.0}/src/structboost/py.typed +0 -0
  28. {structboost-0.1.2 → structboost-0.2.0}/tests/test_allboost.py +0 -0
  29. {structboost-0.1.2 → structboost-0.2.0}/tests/test_annotation.py +0 -0
  30. {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae.py +0 -0
  31. {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_diagnostics.py +0 -0
  32. {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_init.py +0 -0
  33. {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_layer.py +0 -0
  34. {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_persistence.py +0 -0
  35. {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_transfer.py +0 -0
  36. {structboost-0.1.2 → structboost-0.2.0}/tests/test_batch_integration.py +0 -0
  37. {structboost-0.1.2 → structboost-0.2.0}/tests/test_explorer.py +0 -0
  38. {structboost-0.1.2 → structboost-0.2.0}/tests/test_linear_ceiling.py +0 -0
  39. {structboost-0.1.2 → structboost-0.2.0}/tests/test_obs_encoding.py +0 -0
  40. {structboost-0.1.2 → structboost-0.2.0}/tests/test_public_api.py +0 -0
  41. {structboost-0.1.2 → structboost-0.2.0}/tests/test_resolve_mandatory.py +0 -0
  42. {structboost-0.1.2 → structboost-0.2.0}/tests/test_rng_isolation.py +0 -0
  43. {structboost-0.1.2 → structboost-0.2.0}/tests/test_simulation.py +0 -0
  44. {structboost-0.1.2 → structboost-0.2.0}/tests/test_stability.py +0 -0
@@ -0,0 +1,136 @@
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.2.0] - 2026-08-05
7
+
8
+ The boosting loop got faster without changing what it computes. Measured
9
+ end-to-end on real preprocessed scRNA-seq (20,000 cells, `latent_dim=10`):
10
+ **1.86x at p=2,000, 2.20x at p=3,000, 1.80x at p=8,000**, and 1.46x on a
11
+ batch-integrated fit with 20 covariate dummies.
12
+
13
+ **This is not a methodological improvement.** Across eighteen configurations —
14
+ plain, all three `batch_integration_mode` values, flat and per-dimension
15
+ `mandatory_genes`, `balance_obs`, `split_softmax`, both disentanglement methods,
16
+ `standardize_targets`, frozen/anchored/zero-dimension transfers and both
17
+ stability modes — the selected gene set is *identical* and the reconstruction
18
+ loss agrees to the sixth decimal. Nothing here recovers a marker that was
19
+ previously missed. What it buys is more iterations and more stability runs for
20
+ the same budget, which is what a method whose encoder support keeps drifting
21
+ actually needs.
22
+
23
+ Stability selection benefits too, and for the default `n_runs=300` that is the
24
+ larger absolute saving: **1.79–1.88x** for iteration mode and **1.31–1.39x** for
25
+ subsample mode at p=2,000–3,000, with peak memory during a subsample run falling
26
+ from 360 MB to 5 MB at p=3,000 — that figure is exactly the float64 copy of the
27
+ expression matrix described below. Subsample mode gains less by construction:
28
+ every run draws a different subset of cells, so its column norms genuinely change
29
+ and cannot be hoisted, and it uses the lazy column cache rather than the full
30
+ matrix.
31
+
32
+ Precomputing the covariance matrix *inside* subsample mode was measured and
33
+ rejected. It runs 3.07x faster at p=2,000 and 2.69x at p=3,000, but 0.73–0.85x
34
+ **slower** from p=6,000 upwards: the number of distinct columns a subsample
35
+ selects stays roughly flat as p grows, so the full p x p product stops paying for
36
+ itself. A memory guard cannot separate those cases — the 800 MB matrix at
37
+ p=10,000 fits comfortably and still loses — so that path stays lazy at every p.
38
+
39
+ `boosting_precompute_covcache` now defaults to `"auto"` and applies to every
40
+ boosting path, including iteration-mode stability selection, which previously
41
+ ignored the setting entirely. `"auto"` builds the full p x p covariance matrix
42
+ whenever it fits a conservative share of system memory; `True` and `False` are
43
+ still honoured exactly, and the resolved decision is recorded in
44
+ `adata.uns["bae"]["boosting_precompute_covcache"]`.
45
+
46
+ The flag was documented as a memory-versus-recomputation trade, which undersold
47
+ it. Building all `p` columns at once is one compute-bound matrix product near
48
+ hardware peak; fetching them one at a time is a sequence of memory-bound
49
+ matrix-vector products. Precomputing wins from roughly `p/59` distinct selected
50
+ features onwards — a threshold a fit passes in its first iteration.
51
+
52
+ Three quantities that never change were being recomputed. `col_norms_sq` was
53
+ rebuilt on every training iteration although the design matrix is fixed for the
54
+ whole fit, allocating a full-size temporary each time; the mandatory-covariate
55
+ block was rebuilt on every *boosting step* although it depends only on the
56
+ target; and the covariance cache's NaN check re-scanned columns that had already
57
+ been verified. The starting residual correlations are now formed for all latent
58
+ dimensions in one matrix product rather than one per dimension.
59
+
60
+ A float64 target passed against float32 predictors used to make NumPy promote
61
+ the *design matrix*, materializing a full float64 copy — measured 2.1x slower,
62
+ and it silently produced a different answer. The target is now aligned to the
63
+ predictors instead.
64
+
65
+ The three entry points to the same boosting problem ran at two different
66
+ precisions: `fit` in float32, both stability paths in float64 on top of a
67
+ float32 copy they had already made. They now agree on float32, which removes two
68
+ full-size copies of the expression matrix. The precision this gives up was
69
+ measured at about one gene in 380 — far inside the run-to-run support variation
70
+ this method documents for itself.
71
+
72
+ Results are **not bit-identical to 0.1.2**. Coefficients move by ~1e-6 relative
73
+ and the selected support does not change; a fixed seed no longer reproduces
74
+ 0.1.2 output exactly.
75
+
76
+ ### [0.1.2] - 2026-08-03
77
+
78
+ Project metadata gains `Documentation` and `Changelog` links. PyPI renders
79
+ `project.urls` as the sidebar next to the project description, and it carried
80
+ only the repository and the issue tracker — so the documentation site, where
81
+ every substantive explanation lives, was reachable from the README body but not
82
+ from the navigation beside it. Both targets are verified live.
83
+
84
+ As with 0.1.1, nothing in the package changed: `project.urls` reaches users only
85
+ through an upload, so correcting it in the repository has no effect until a
86
+ release carries it.
87
+
88
+ ### [0.1.1] - 2026-08-03
89
+
90
+ A metadata release. No code in `structboost` changed; every difference is in
91
+ what the package says about itself.
92
+
93
+ The PyPI page is the reason for it. A project's long description is baked into
94
+ the uploaded artifacts and is immutable per release, so 0.1.0 shipped with a
95
+ README announcing "Not on PyPI yet" and directing readers to a pinned TestPyPI
96
+ install. Correcting the file in git does not touch the published page — only a
97
+ new release does. A `0.1.0.post1` would have expressed "packaging only" more
98
+ precisely, but post-releases are handled inconsistently by downstream tooling
99
+ and the versioning policy here is plain `MAJOR.MINOR.PATCH`.
100
+
101
+ Python 3.13 is tested and advertised. `requires-python = ">=3.10"` never had an
102
+ upper bound, so pip already installed on 3.13 while the CI matrix stopped at
103
+ 3.12 — support permitted but never exercised. The matrix now covers it, and the
104
+ classifier list says what the requirement already allowed. 3.14 is left out
105
+ until the `[bae]` extra's wheels are dependably available there.
106
+
107
+ `CITATION.cff` gains `version` and `date-released`, which a citation file for a
108
+ released version cannot do without, plus a `url` for the documentation site.
109
+
110
+ ### [0.1.0] - 2026-07-31
111
+
112
+ First public release.
113
+
114
+ Batch integration is one argument. `BAE.fit(batch_key=...)` names the covariate,
115
+ following scVI's spelling, and `batch_integration_mode` chooses between
116
+ `"decoder"`, `"encoder"` and `"both"`, defaulting to `"both"`. No `batch_key`
117
+ means no integration, and naming a mode without one raises rather than quietly
118
+ integrating nothing.
119
+
120
+ `"encoder"` names the half of the model the mechanism protects, not a tensor the
121
+ covariate is fed to: it enters the boosting design as a mandatory regressor so
122
+ gene selection is not confounded by it. `transform` remains gene-only and needs
123
+ no covariate labels under any mode.
124
+
125
+ The ridge that stabilizes near-collinear covariates is `BAEConfig.nuisance_ridge`.
126
+ It is a numerical knob rather than a modelling one, so it sits with the other
127
+ algorithm settings.
128
+
129
+ The `test` extra pulls the runtime dependencies. The sdist carries `tests/` and
130
+ `conftest.py` so that downstream packagers can run the suite at build time, and
131
+ with pytest alone that did not work: 360 of the 411 test functions sit behind an
132
+ `importorskip` for torch or anndata, so `pip install .[test] && pytest` ran ~51
133
+ tests, skipped the rest and reported success. Installing `[test]` now brings in
134
+ `[bae]`, so a green build means the suite actually ran. Documenting the
135
+ requirement in `CONTRIBUTING.md` instead was rejected, because the reader who
136
+ 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.2.0"
6
+ date-released: "2026-08-05"
7
7
  license: MIT
8
8
  authors:
9
9
  - family-names: Brunn
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structboost
3
- Version: 0.1.2
3
+ Version: 0.2.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
@@ -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.2.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
 
@@ -15,7 +15,7 @@ from torch import nn
15
15
  from torch.utils.data import DataLoader, TensorDataset
16
16
  from tqdm import tqdm
17
17
 
18
- from ._boosting import allboost
18
+ from ._boosting import allboost, column_norms_sq
19
19
  from ._decoder import BAEDecoder
20
20
  from ._encoder import BAEEncoder, SplitSoftmax
21
21
  from ._types import BAEConfig, TrainingReport
@@ -461,6 +461,8 @@ class BAE(nn.Module):
461
461
  self._prior_weights: np.ndarray | None = None
462
462
  self._prior_info: dict[str, object] = {}
463
463
  self._latent_scaling: dict[str, np.ndarray] | None = None
464
+ #: Whether the last fit built a full covariance matrix; recorded in uns.
465
+ self._precomputed_covcache: bool = False
464
466
 
465
467
  # Move to device
466
468
  self.to(self.config.device)
@@ -1935,13 +1937,33 @@ class BAE(nn.Module):
1935
1937
  if n_nuisance:
1936
1938
  mandatory_ridge[self.n_genes :] = nuisance_ridge
1937
1939
 
1938
- if self.config.boosting_precompute_covcache:
1940
+ # A frozen transfer with no added dimensions never calls allboost at all
1941
+ # (there is nothing left to select), so building a covariance matrix for
1942
+ # it would be pure waste -- 8*p^2 bytes and an O(n*p^2) product for a
1943
+ # cache no one reads.
1944
+ boosts_anything = not (
1945
+ is_transfer
1946
+ and self.config.prior_mode == "frozen"
1947
+ and self._prior_weights.shape[1] >= self.config.latent_dim
1948
+ )
1949
+ from ._utils import resolve_precompute_covcache
1950
+
1951
+ precompute_covcache = boosts_anything and resolve_precompute_covcache(
1952
+ self.config.boosting_precompute_covcache, sourcemat_aug.shape[1]
1953
+ )
1954
+ self._precomputed_covcache = precompute_covcache
1955
+ if precompute_covcache:
1939
1956
  from ._utils import compute_covariance_cache
1940
1957
 
1941
1958
  covcache = compute_covariance_cache(sourcemat_aug)
1942
1959
  else:
1943
1960
  covcache = None # Lazy computation during allboost
1944
1961
 
1962
+ # `sourcemat_aug` is fixed for the whole fit, so its column norms are too.
1963
+ # allboost recomputes them on every call otherwise -- an O(n*p) pass and a
1964
+ # full-size temporary, once per training iteration.
1965
+ boosting_col_norms_sq = column_norms_sq(sourcemat_aug)
1966
+
1945
1967
  # Optimizer for decoder only
1946
1968
  decoder_optimizer = torch.optim.AdamW(
1947
1969
  self.decoder.parameters(),
@@ -2065,6 +2087,7 @@ class BAE(nn.Module):
2065
2087
  sourcemat_aug,
2066
2088
  fit_targets,
2067
2089
  covcache=covcache,
2090
+ col_norms_sq=boosting_col_norms_sq,
2068
2091
  stepno=self.config.boosting_stepno,
2069
2092
  nu=self.config.boosting_nu,
2070
2093
  csf=self.config.boosting_csf,
@@ -2079,6 +2102,7 @@ class BAE(nn.Module):
2079
2102
  sourcemat_aug,
2080
2103
  fit_targets,
2081
2104
  covcache=covcache,
2105
+ col_norms_sq=boosting_col_norms_sq,
2082
2106
  stepno=self.config.boosting_stepno,
2083
2107
  nu=self.config.boosting_nu,
2084
2108
  csf=self.config.boosting_csf,
@@ -2373,7 +2397,12 @@ class BAE(nn.Module):
2373
2397
  identical to the training loop; ``tests/test_stability.py`` pins that
2374
2398
  equivalence so the two cannot silently diverge.
2375
2399
  """
2376
- from ._utils import resolve_mandatory_genes, transform_obs_covariates
2400
+ from ._utils import (
2401
+ compute_covariance_cache,
2402
+ resolve_mandatory_genes,
2403
+ resolve_precompute_covcache,
2404
+ transform_obs_covariates,
2405
+ )
2377
2406
 
2378
2407
  if n_iterations < 1:
2379
2408
  raise ValueError(f"n_iterations must be >= 1, got {n_iterations}")
@@ -2398,11 +2427,15 @@ class BAE(nn.Module):
2398
2427
  else None
2399
2428
  )
2400
2429
 
2401
- sourcemat_aug = X_np.astype(np.float64)
2430
+ # float32, matching `fit`. This path used to promote to float64 on top of
2431
+ # the float32 copy above -- three copies of the expression matrix resident
2432
+ # at once -- buying a precision difference measured at about one gene in
2433
+ # 380, far inside the run-to-run support variation this method documents.
2434
+ sourcemat_aug = X_np
2402
2435
  n_nuisance = 0
2403
2436
  if self._regresses_encoder:
2404
2437
  D_nuisance = transform_obs_covariates(adata, self._batch_encoding)
2405
- sourcemat_aug = np.hstack([sourcemat_aug, np.asarray(D_nuisance, dtype=np.float64)])
2438
+ sourcemat_aug = np.hstack([sourcemat_aug, np.asarray(D_nuisance, dtype=np.float32)])
2406
2439
  n_nuisance = self._batch_encoding.n_columns
2407
2440
 
2408
2441
  resolved_mandatory = resolve_mandatory_genes(self._mandatory_genes, adata)
@@ -2410,6 +2443,17 @@ class BAE(nn.Module):
2410
2443
  mandatory_ridge = np.zeros(sourcemat_aug.shape[1], dtype=np.float64)
2411
2444
  if n_nuisance:
2412
2445
  mandatory_ridge[self.n_genes :] = self.config.nuisance_ridge
2446
+ # Fixed across every iteration recorded here, exactly as in `fit`.
2447
+ boosting_col_norms_sq = column_norms_sq(sourcemat_aug)
2448
+ # Honour the same covariance-cache setting `fit` resolved. Without this,
2449
+ # the method documented as mirroring the fit loop would run a different
2450
+ # cache strategy from the fit it is analysing.
2451
+ if resolve_precompute_covcache(
2452
+ self.config.boosting_precompute_covcache, sourcemat_aug.shape[1]
2453
+ ):
2454
+ covcache_initial = compute_covariance_cache(sourcemat_aug)
2455
+ else:
2456
+ covcache_initial = None
2413
2457
 
2414
2458
  # Snapshot so the fitted model is unchanged when this returns.
2415
2459
  saved_encoder = self.encoder.linear.weight.detach().clone()
@@ -2431,7 +2475,7 @@ class BAE(nn.Module):
2431
2475
 
2432
2476
  reference_unit = _unit_rows(reference)
2433
2477
 
2434
- covcache = None
2478
+ covcache = covcache_initial
2435
2479
  latent_dim = reference.shape[0]
2436
2480
  prior_dims = self._prior_weights.shape[1] if self._prior_weights is not None else 0
2437
2481
  counts = np.zeros((self.n_genes, latent_dim), dtype=np.float64)
@@ -2476,6 +2520,7 @@ class BAE(nn.Module):
2476
2520
  sourcemat_aug,
2477
2521
  fit_targets,
2478
2522
  covcache=covcache,
2523
+ col_norms_sq=boosting_col_norms_sq,
2479
2524
  stepno=self.config.boosting_stepno,
2480
2525
  nu=self.config.boosting_nu,
2481
2526
  csf=self.config.boosting_csf,
@@ -2772,11 +2817,12 @@ class BAE(nn.Module):
2772
2817
  sample_weights=weights_t,
2773
2818
  )
2774
2819
 
2775
- sourcemat = X_np.astype(np.float64)
2820
+ # float32, matching `fit`; see _iteration_support_frequency.
2821
+ sourcemat = X_np
2776
2822
  n_nuisance = 0
2777
2823
  if self._regresses_encoder:
2778
2824
  D_nuisance = transform_obs_covariates(adata, self._batch_encoding)
2779
- sourcemat = np.hstack([sourcemat, np.asarray(D_nuisance, dtype=np.float64)])
2825
+ sourcemat = np.hstack([sourcemat, np.asarray(D_nuisance, dtype=np.float32)])
2780
2826
  n_nuisance = self._batch_encoding.n_columns
2781
2827
 
2782
2828
  resolved_mandatory = resolve_mandatory_genes(self._mandatory_genes, adata)
@@ -3351,6 +3397,9 @@ class BAE(nn.Module):
3351
3397
  if hasattr(self, "_mandatory_genes") and self._mandatory_genes is not None:
3352
3398
  uns_dict["mandatory_genes"] = _mandatory_genes_for_uns(self._mandatory_genes)
3353
3399
  uns_dict["batch_integration_mode"] = self._batch_integration_mode
3400
+ # The *resolved* decision, not the setting: "auto" is the default, so
3401
+ # without this a run does not record which strategy it actually used.
3402
+ uns_dict["boosting_precompute_covcache"] = bool(self._precomputed_covcache)
3354
3403
  if self._batch_encoding is not None:
3355
3404
  uns_dict["batch_key"] = self._batch_encoding.obs_columns
3356
3405
  uns_dict["batch_columns"] = self._batch_encoding.encoded_columns
@@ -303,8 +303,12 @@ def stability_selection(
303
303
  -------
304
304
  StabilitySelectionResult
305
305
  """
306
- sourcemat = np.asarray(sourcemat, dtype=np.float64)
307
- targetmat = np.asarray(targetmat, dtype=np.float64)
306
+ # Dtype is the caller's; allboost aligns the target to the predictors.
307
+ # Forcing float64 here used to double the memory of every BAE-driven call
308
+ # (the caller already holds a float32 copy) for a precision difference
309
+ # measured at about one gene in 380.
310
+ sourcemat = np.asarray(sourcemat)
311
+ targetmat = np.asarray(targetmat)
308
312
  n = sourcemat.shape[0]
309
313
  if targetmat.shape[0] != n:
310
314
  raise ValueError(
@@ -351,6 +355,17 @@ def stability_selection(
351
355
  idx = rng.choice(n, size=sub_n, replace=False)
352
356
  # A fresh covcache per subsample is mandatory: the Gram matrix depends on
353
357
  # the rows, so the full-data cache would silently produce wrong updates.
358
+ #
359
+ # Deliberately lazy, unlike `BAE.fit`, which precomputes the whole Gram by
360
+ # default. Building it per subsample was measured and rejected: on real
361
+ # data at n=15,000 it runs 3.07x faster at p=2,000 and 2.69x at p=3,000,
362
+ # but 0.73-0.85x *slower* from p=6,000 upwards. The crossover is governed
363
+ # by the number of distinct columns a subsample actually selects, which
364
+ # stays roughly flat (~110-240) as p grows, so the full p x p product
365
+ # stops paying for itself. A memory guard cannot separate those cases --
366
+ # the 800 MB Gram at p=10,000 fits comfortably and still loses -- and any
367
+ # p threshold would be calibrated on one dataset's support size. Left
368
+ # lazy, which is the safe direction at every p.
354
369
  betamat = allboost(
355
370
  sourcemat[idx],
356
371
  targetmat[idx],
@@ -106,15 +106,30 @@ class BAEConfig:
106
106
  few hundred iterations, since the encoder support random-walks and two
107
107
  runs end no more similar than two unrelated ones.
108
108
  boosting_precompute_covcache
109
- If True, compute the full p×p covariance matrix before training, which
110
- costs 8·p² bytes (3.2 GB at p=20,000).
111
- If False (default), `allboost` keeps a dict-backed column cache: a
112
- covariance column is computed the first time its feature is selected and
113
- reused across targets and training iterations. Memory then scales with
114
- the number of *distinct selected* features (8·p bytes per column), not
115
- with p², which is a large saving because boosting selects far fewer
116
- features than are available. Precompute only when p is small enough that
117
- the full matrix is comfortable and many features will be selected anyway.
109
+ Whether to compute the full p×p covariance matrix before training.
110
+ ``"auto"`` (default) does so whenever the 8·p² byte matrix fits a
111
+ conservative share of system memory; ``True`` and ``False`` are honoured
112
+ exactly. The resolved decision is recorded in
113
+ ``adata.uns["bae"]["boosting_precompute_covcache"]``.
114
+
115
+ This is a *speed* setting first and a memory setting second, which is not
116
+ obvious. The alternative is a dict-backed column cache: a covariance
117
+ column is computed the first time its feature is selected and reused
118
+ across targets and iterations, so memory scales with the number of
119
+ *distinct selected* features (8·p bytes per column) rather than with p².
120
+ That sounds strictly cheaper, and in memory it is — but building all p
121
+ columns at once is a single compute-bound matrix product running near
122
+ hardware peak, while fetching them one at a time is a sequence of
123
+ memory-bound matrix-vector products. Measured on real data, precomputing
124
+ wins from roughly ``p/59`` distinct selected features onwards, and a fit
125
+ passes that within its first iteration, where up to
126
+ ``boosting_stepno × latent_dim`` distinct features can enter. End-to-end
127
+ it measured 1.7–2.3× faster at p=3,000–10,000.
128
+
129
+ The cost is memory, and it is real: 8·p² is 32 MB at p=2,000, 800 MB at
130
+ p=10,000 and 3.2 GB at p=20,000, transiently doubled while the matrix is
131
+ built. That is what ``"auto"`` guards against; set ``False`` explicitly on
132
+ a memory-constrained machine.
118
133
  disentanglement
119
134
  Latent-dimension disentanglement method. ``"none"`` (default) applies no
120
135
  constraint. ``"correlation"`` adds a soft, differentiable squared-
@@ -212,7 +227,7 @@ class BAEConfig:
212
227
  boosting_nu: float = 0.1
213
228
  boosting_csf: float = 0.9
214
229
  boosting_independent: bool = True
215
- boosting_precompute_covcache: bool = False
230
+ boosting_precompute_covcache: bool | Literal["auto"] = "auto"
216
231
  nuisance_ridge: float = 0.0
217
232
  prior_mode: Literal["frozen", "anchored"] = "frozen"
218
233
  disentanglement: Literal["none", "correlation", "leave_one_out"] = "none"
@@ -245,6 +260,11 @@ class BAEConfig:
245
260
  raise ValueError("boosting_stepno must be >= 1")
246
261
  if not np.isfinite(self.nuisance_ridge) or self.nuisance_ridge < 0:
247
262
  raise ValueError("nuisance_ridge must be finite and >= 0")
263
+ if self.boosting_precompute_covcache not in (True, False, "auto"):
264
+ raise ValueError(
265
+ "boosting_precompute_covcache must be True, False or 'auto', got "
266
+ f"{self.boosting_precompute_covcache!r}"
267
+ )
248
268
  if self.prior_mode not in {"frozen", "anchored"}:
249
269
  raise ValueError("prior_mode must be 'frozen' or 'anchored'")
250
270
  if not 0.0 < self.boosting_nu <= 1.0:
@@ -64,7 +64,68 @@ def compute_covariance_cache(
64
64
  np.dot(sourcemat.T, sourcemat, out=out)
65
65
  return out
66
66
 
67
- return np.dot(sourcemat.T, sourcemat).astype(np.float64, copy=False)
67
+ # Fortran order, so that ``covcache[:, j]`` -- the only way allboost ever
68
+ # reads this matrix -- is a contiguous vector rather than a stride of 8*p
69
+ # bytes. Reading a column of the C-ordered equivalent touches one cache line
70
+ # per element and measured 4-9x slower inside the boosting loop.
71
+ #
72
+ # The values are identical either way; only the layout differs. numpy will
73
+ # not write a gemm result straight into an F-ordered ``out``, so this costs
74
+ # one transient copy of the p x p matrix at build time -- negligible against
75
+ # the O(n*p^2) product itself, but it does mean the peak allocation here is
76
+ # briefly twice the returned size.
77
+ return np.asfortranarray(np.dot(sourcemat.T, sourcemat), dtype=np.float64)
78
+
79
+
80
+ #: Fraction of total system memory the automatic covariance-cache decision is
81
+ #: willing to spend on the p x p Gram matrix. Deliberately conservative: the
82
+ #: matrix is transiently doubled while it is built (see
83
+ #: :func:`compute_covariance_cache`), and a fit needs room for the expression
84
+ #: matrix and the decoder besides.
85
+ _PRECOMPUTE_MEMORY_FRACTION = 0.25
86
+
87
+ #: Used when the platform does not expose its physical memory (non-POSIX).
88
+ _PRECOMPUTE_FALLBACK_BUDGET = 2 * 1024**3
89
+
90
+
91
+ def _total_memory_bytes() -> int | None:
92
+ """Physical memory, or None where the platform will not say."""
93
+ try:
94
+ import os
95
+
96
+ return int(os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES"))
97
+ except (ValueError, AttributeError, OSError): # pragma: no cover - platform dependent
98
+ return None
99
+
100
+
101
+ def resolve_precompute_covcache(setting: bool | str, n_features: int) -> bool:
102
+ """Decide whether to build the full covariance matrix up front.
103
+
104
+ ``True``/``False`` are honoured exactly; ``"auto"`` precomputes whenever the
105
+ ``8 * n_features**2`` byte matrix fits the memory budget above.
106
+
107
+ Precomputing is not merely a way to avoid recomputing columns. Building all
108
+ ``p`` columns at once is a single compute-bound matrix product running near
109
+ hardware peak, whereas fetching them one at a time is a sequence of
110
+ memory-bound matrix-vector products; measured on real data the former wins
111
+ from roughly ``p/59`` distinct selected features onwards, and a BAE fit
112
+ passes that in its first iteration (``boosting_stepno * latent_dim``
113
+ candidates). The decision here is therefore about memory, not about how many
114
+ columns will be needed.
115
+ """
116
+ if setting is True or setting is False:
117
+ return bool(setting)
118
+ if setting != "auto":
119
+ raise ValueError(
120
+ f"boosting_precompute_covcache must be True, False or 'auto', got {setting!r}"
121
+ )
122
+ total = _total_memory_bytes()
123
+ budget = (
124
+ int(total * _PRECOMPUTE_MEMORY_FRACTION)
125
+ if total is not None
126
+ else _PRECOMPUTE_FALLBACK_BUDGET
127
+ )
128
+ return 8 * int(n_features) ** 2 <= budget
68
129
 
69
130
 
70
131
  def disentangle_boosting_targets(
@@ -0,0 +1,250 @@
1
+ """Contracts for the covariance-cache strategy and the boosting dtype rules.
2
+
3
+ These pin the *mechanism* behind the speed of the boosting loop rather than a
4
+ wall-clock number: how often an invariant quantity is recomputed, whether the
5
+ design matrix is silently promoted, and which cache strategy a fit resolved to.
6
+ Timing assertions would be flaky in CI; call counts and dtypes are exact.
7
+ """
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ from structboost import BAEConfig, allboost, compute_covariance_cache
13
+ from structboost._boosting import column_norms_sq
14
+ from structboost._utils import resolve_precompute_covcache
15
+
16
+
17
+ def _require_bae():
18
+ pytest.importorskip("torch")
19
+ pytest.importorskip("anndata")
20
+
21
+
22
+ @pytest.fixture
23
+ def f32_data():
24
+ """float32 predictors and targets, the dtypes ``BAE.fit`` actually uses."""
25
+ rng = np.random.default_rng(7)
26
+ x = rng.standard_normal((300, 60)).astype(np.float32)
27
+ x = (x - x.mean(axis=0)) / x.std(axis=0)
28
+ y = rng.standard_normal((300, 4)).astype(np.float32)
29
+ return x, y
30
+
31
+
32
+ class TestCovarianceCacheEquivalence:
33
+ def test_lazy_and_precomputed_agree_in_float32(self, f32_data):
34
+ """The float64 fixture elsewhere hides this: at float32 the two cache
35
+ strategies differ in the last bits, so the contract is that they select
36
+ the same genes, not that they produce identical coefficients."""
37
+ x, y = f32_data
38
+ lazy = allboost(x, y, stepno=15)
39
+ pre = allboost(x, y, covcache=compute_covariance_cache(x), stepno=15)
40
+
41
+ np.testing.assert_array_equal(np.abs(lazy) > 0, np.abs(pre) > 0)
42
+ np.testing.assert_allclose(lazy, pre, rtol=1e-5, atol=1e-7)
43
+
44
+ def test_precomputed_cache_is_column_contiguous(self):
45
+ """allboost only ever reads ``covcache[:, j]``; a C-ordered Gram makes
46
+ that a strided read touching one cache line per element."""
47
+ rng = np.random.default_rng(0)
48
+ cache = compute_covariance_cache(rng.standard_normal((80, 25)))
49
+ assert cache.flags.f_contiguous
50
+ assert cache[:, 3].flags.contiguous
51
+
52
+
53
+ class TestColumnNormsHoist:
54
+ def test_hoisted_norms_reproduce_the_internal_value(self, f32_data):
55
+ """Passing ``col_norms_sq`` must be indistinguishable from letting
56
+ allboost compute it -- that is what makes hoisting it out of a training
57
+ loop safe."""
58
+ x, y = f32_data
59
+ internal = allboost(x, y, stepno=12)
60
+ hoisted = allboost(x, y, stepno=12, col_norms_sq=column_norms_sq(x))
61
+ np.testing.assert_array_equal(internal, hoisted)
62
+
63
+ def test_wrong_shape_rejected(self, f32_data):
64
+ x, y = f32_data
65
+ with pytest.raises(ValueError, match="col_norms_sq must have shape"):
66
+ allboost(x, y, stepno=5, col_norms_sq=np.ones(3))
67
+
68
+ def test_computed_once_per_fit_not_once_per_iteration(self):
69
+ _require_bae()
70
+ import anndata as ad
71
+
72
+ import structboost._model as model_module
73
+ from structboost import BAE
74
+
75
+ rng = np.random.default_rng(0)
76
+ adata = ad.AnnData(rng.standard_normal((200, 40), dtype=np.float32))
77
+ calls = []
78
+ original = model_module.column_norms_sq
79
+ model_module.column_norms_sq = lambda m: (calls.append(1), original(m))[1]
80
+ try:
81
+ BAE(40, BAEConfig(latent_dim=2, seed=0, max_iterations=6)).fit(
82
+ adata, verbose=False, enable_early_stopping=False
83
+ )
84
+ finally:
85
+ model_module.column_norms_sq = original
86
+ assert sum(calls) == 1, f"recomputed {sum(calls)}x across 6 iterations"
87
+
88
+
89
+ class TestMandatoryBlockHoist:
90
+ def test_block_built_once_per_target(self):
91
+ """Nothing in the mandatory block depends on the boosting step, so it
92
+ must be built ``n_targets`` times, not ``n_targets * stepno``."""
93
+ import structboost._boosting as boosting
94
+
95
+ rng = np.random.default_rng(1)
96
+ x = rng.standard_normal((200, 30))
97
+ y = rng.standard_normal((200, 3))
98
+ built = []
99
+ original = boosting._MandatoryBlock
100
+
101
+ class Counting(original):
102
+ def __init__(self, *args, **kwargs):
103
+ built.append(1)
104
+ super().__init__(*args, **kwargs)
105
+
106
+ boosting._MandatoryBlock = Counting
107
+ try:
108
+ allboost(x, y, stepno=9, mandatory_features=np.array([0, 4], dtype=np.intp))
109
+ finally:
110
+ boosting._MandatoryBlock = original
111
+ assert sum(built) == 3, f"built {sum(built)}x for 3 targets x 9 steps"
112
+
113
+
114
+ class TestTargetDtypeAlignment:
115
+ def test_float64_target_does_not_promote_the_design_matrix(self, f32_data):
116
+ """A float64 target against float32 predictors used to make numpy promote
117
+ *sourcemat*, materializing a full float64 copy per latent dimension. The
118
+ target is aligned to the predictors instead, so both give one answer."""
119
+ x, y = f32_data
120
+ from_f32 = allboost(x, y, stepno=12)
121
+ from_f64 = allboost(x, y.astype(np.float64), stepno=12)
122
+ np.testing.assert_array_equal(from_f32, from_f64)
123
+
124
+ def test_integer_targets_still_accepted(self, f32_data):
125
+ x, _ = f32_data
126
+ y = np.arange(x.shape[0] * 2, dtype=np.int64).reshape(-1, 2) % 5
127
+ assert np.isfinite(allboost(x, y, stepno=4)).all()
128
+
129
+
130
+ class TestPrecomputeResolver:
131
+ def test_auto_precomputes_when_the_matrix_is_small(self):
132
+ assert resolve_precompute_covcache("auto", 500) is True
133
+
134
+ def test_auto_declines_when_the_matrix_cannot_fit(self):
135
+ # 8 * p^2 is well beyond any machine's memory at this width.
136
+ assert resolve_precompute_covcache("auto", 400_000) is False
137
+
138
+ @pytest.mark.parametrize("setting", [True, False])
139
+ def test_explicit_setting_always_wins(self, setting):
140
+ assert resolve_precompute_covcache(setting, 400_000) is setting
141
+ assert resolve_precompute_covcache(setting, 10) is setting
142
+
143
+ def test_invalid_setting_rejected(self):
144
+ with pytest.raises(ValueError, match="must be True, False or 'auto'"):
145
+ resolve_precompute_covcache("sometimes", 10)
146
+
147
+ def test_config_validates_the_field(self):
148
+ with pytest.raises(ValueError, match="must be True, False or 'auto'"):
149
+ BAEConfig(boosting_precompute_covcache="yes")
150
+
151
+ def test_default_is_auto(self):
152
+ assert BAEConfig().boosting_precompute_covcache == "auto"
153
+
154
+
155
+ class TestFitRecordsResolvedStrategy:
156
+ @pytest.mark.parametrize("setting,expected", [("auto", True), (True, True), (False, False)])
157
+ def test_uns_records_the_resolved_decision(self, setting, expected):
158
+ """ "auto" is the default, so without recording it a run does not say
159
+ which strategy it actually used."""
160
+ _require_bae()
161
+ import anndata as ad
162
+
163
+ from structboost import BAE
164
+
165
+ rng = np.random.default_rng(0)
166
+ adata = ad.AnnData(rng.standard_normal((150, 30), dtype=np.float32))
167
+ BAE(
168
+ 30,
169
+ BAEConfig(latent_dim=2, seed=0, max_iterations=3, boosting_precompute_covcache=setting),
170
+ ).fit(adata, verbose=False, enable_early_stopping=False)
171
+ assert adata.uns["bae"]["boosting_precompute_covcache"] is expected
172
+
173
+
174
+ class TestTransferSkipsUnusedCache:
175
+ def test_frozen_transfer_without_new_dims_builds_no_gram(self):
176
+ """A frozen transfer with no added dimensions never calls allboost, so
177
+ building an 8*p^2 covariance matrix for it is pure waste."""
178
+ _require_bae()
179
+ import anndata as ad
180
+
181
+ import structboost._utils as utils
182
+ from structboost import BAE
183
+
184
+ rng = np.random.default_rng(0)
185
+ adata = ad.AnnData(rng.standard_normal((150, 30), dtype=np.float32))
186
+ adata.var_names = [f"g{i}" for i in range(30)]
187
+ cfg = dict(latent_dim=2, seed=0, max_iterations=3)
188
+ reference = BAE(30, BAEConfig(**cfg)).fit(
189
+ adata.copy(), verbose=False, enable_early_stopping=False
190
+ )
191
+
192
+ built = []
193
+ original = utils.compute_covariance_cache
194
+ utils.compute_covariance_cache = lambda *a, **k: (built.append(1), original(*a, **k))[1]
195
+ try:
196
+ target = adata.copy()
197
+ BAE.from_reference(
198
+ reference,
199
+ target,
200
+ n_additional_dims=0,
201
+ prior_mode="frozen",
202
+ config=BAEConfig(**cfg),
203
+ ).fit(target, verbose=False, enable_early_stopping=False)
204
+ assert sum(built) == 0
205
+
206
+ target2 = adata.copy()
207
+ BAE.from_reference(
208
+ reference,
209
+ target2,
210
+ n_additional_dims=2,
211
+ prior_mode="frozen",
212
+ config=BAEConfig(**cfg),
213
+ ).fit(target2, verbose=False, enable_early_stopping=False)
214
+ assert sum(built) >= 1
215
+ finally:
216
+ utils.compute_covariance_cache = original
217
+
218
+
219
+ class TestStabilityPathsMatchFit:
220
+ def test_iteration_mode_runs_the_same_dtype_as_fit(self):
221
+ """The three entry points to the same boosting problem used to run at two
222
+ different precisions, with the stability paths holding an extra float64
223
+ copy of the whole expression matrix."""
224
+ _require_bae()
225
+ import anndata as ad
226
+
227
+ import structboost._model as model_module
228
+ from structboost import BAE
229
+
230
+ rng = np.random.default_rng(0)
231
+ adata = ad.AnnData(rng.standard_normal((200, 30), dtype=np.float32))
232
+ model = BAE(30, BAEConfig(latent_dim=2, seed=0, max_iterations=4)).fit(
233
+ adata, verbose=False, enable_early_stopping=False
234
+ )
235
+
236
+ seen = {}
237
+ original = model_module.allboost
238
+
239
+ def probe(sourcemat, targetmat, **kwargs):
240
+ seen.setdefault("source", sourcemat.dtype)
241
+ seen.setdefault("target", targetmat.dtype)
242
+ return original(sourcemat, targetmat, **kwargs)
243
+
244
+ model_module.allboost = probe
245
+ try:
246
+ model.stability_selection(adata, mode="iteration", n_runs=2, seed=0, verbose=False)
247
+ finally:
248
+ model_module.allboost = original
249
+ assert seen["source"] == np.float32
250
+ assert seen["target"] == np.float32
@@ -1,66 +0,0 @@
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.1.2] - 2026-08-03
7
-
8
- Project metadata gains `Documentation` and `Changelog` links. PyPI renders
9
- `project.urls` as the sidebar next to the project description, and it carried
10
- only the repository and the issue tracker — so the documentation site, where
11
- every substantive explanation lives, was reachable from the README body but not
12
- from the navigation beside it. Both targets are verified live.
13
-
14
- As with 0.1.1, nothing in the package changed: `project.urls` reaches users only
15
- through an upload, so correcting it in the repository has no effect until a
16
- release carries it.
17
-
18
- ### [0.1.1] - 2026-08-03
19
-
20
- A metadata release. No code in `structboost` changed; every difference is in
21
- what the package says about itself.
22
-
23
- The PyPI page is the reason for it. A project's long description is baked into
24
- the uploaded artifacts and is immutable per release, so 0.1.0 shipped with a
25
- README announcing "Not on PyPI yet" and directing readers to a pinned TestPyPI
26
- install. Correcting the file in git does not touch the published page — only a
27
- new release does. A `0.1.0.post1` would have expressed "packaging only" more
28
- precisely, but post-releases are handled inconsistently by downstream tooling
29
- and the versioning policy here is plain `MAJOR.MINOR.PATCH`.
30
-
31
- Python 3.13 is tested and advertised. `requires-python = ">=3.10"` never had an
32
- upper bound, so pip already installed on 3.13 while the CI matrix stopped at
33
- 3.12 — support permitted but never exercised. The matrix now covers it, and the
34
- classifier list says what the requirement already allowed. 3.14 is left out
35
- until the `[bae]` extra's wheels are dependably available there.
36
-
37
- `CITATION.cff` gains `version` and `date-released`, which a citation file for a
38
- released version cannot do without, plus a `url` for the documentation site.
39
-
40
- ### [0.1.0] - 2026-07-31
41
-
42
- First public release.
43
-
44
- Batch integration is one argument. `BAE.fit(batch_key=...)` names the covariate,
45
- following scVI's spelling, and `batch_integration_mode` chooses between
46
- `"decoder"`, `"encoder"` and `"both"`, defaulting to `"both"`. No `batch_key`
47
- means no integration, and naming a mode without one raises rather than quietly
48
- integrating nothing.
49
-
50
- `"encoder"` names the half of the model the mechanism protects, not a tensor the
51
- covariate is fed to: it enters the boosting design as a mandatory regressor so
52
- gene selection is not confounded by it. `transform` remains gene-only and needs
53
- no covariate labels under any mode.
54
-
55
- The ridge that stabilizes near-collinear covariates is `BAEConfig.nuisance_ridge`.
56
- It is a numerical knob rather than a modelling one, so it sits with the other
57
- algorithm settings.
58
-
59
- The `test` extra pulls the runtime dependencies. The sdist carries `tests/` and
60
- `conftest.py` so that downstream packagers can run the suite at build time, and
61
- with pytest alone that did not work: 360 of the 411 test functions sit behind an
62
- `importorskip` for torch or anndata, so `pip install .[test] && pytest` ran ~51
63
- tests, skipped the rest and reported success. Installing `[test]` now brings in
64
- `[bae]`, so a green build means the suite actually ran. Documenting the
65
- requirement in `CONTRIBUTING.md` instead was rejected, because the reader who
66
- needs it is an automated build script rather than a person.
File without changes
File without changes
File without changes
File without changes
File without changes