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.
- structboost-0.2.0/CHANGELOG.md +136 -0
- {structboost-0.1.2 → structboost-0.2.0}/CITATION.cff +2 -2
- {structboost-0.1.2 → structboost-0.2.0}/PKG-INFO +1 -1
- {structboost-0.1.2 → structboost-0.2.0}/pyproject.toml +1 -1
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/__init__.py +2 -1
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_boosting.py +123 -54
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_model.py +57 -8
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_stability.py +17 -2
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_types.py +30 -10
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_utils.py +62 -1
- structboost-0.2.0/tests/test_covcache_precompute.py +250 -0
- structboost-0.1.2/CHANGELOG.md +0 -66
- {structboost-0.1.2 → structboost-0.2.0}/.gitignore +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/CODE_OF_CONDUCT.md +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/CONTRIBUTING.md +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/LICENSE +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/README.md +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/conftest.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_annotation.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_decoder.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_encoder.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_explorer.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_io.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_persistence.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_plotting.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/_simulation.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/src/structboost/py.typed +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_allboost.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_annotation.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_diagnostics.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_init.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_layer.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_persistence.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_bae_transfer.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_batch_integration.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_explorer.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_linear_ceiling.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_obs_encoding.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_public_api.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_resolve_mandatory.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_rng_isolation.py +0 -0
- {structboost-0.1.2 → structboost-0.2.0}/tests/test_simulation.py +0 -0
- {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.
|
|
6
|
-
date-released: "2026-08-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
structboost-0.1.2/CHANGELOG.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|