structboost 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {structboost-0.3.0 → structboost-0.4.0}/CHANGELOG.md +46 -0
- {structboost-0.3.0 → structboost-0.4.0}/CITATION.cff +1 -1
- {structboost-0.3.0 → structboost-0.4.0}/PKG-INFO +1 -1
- {structboost-0.3.0 → structboost-0.4.0}/pyproject.toml +1 -1
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_model.py +30 -5
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_types.py +22 -7
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae.py +23 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_stability.py +49 -0
- {structboost-0.3.0 → structboost-0.4.0}/.gitignore +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/CONTRIBUTING.md +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/LICENSE +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/README.md +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/conftest.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/__init__.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_annotation.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_boosting.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_decoder.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_encoder.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_explorer.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_io.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_persistence.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_plotting.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_simulation.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_stability.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_utils.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/src/structboost/py.typed +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_allboost.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_annotation.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_diagnostics.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_init.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_layer.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_persistence.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_transfer.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_batch_integration.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_covcache_precompute.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_explorer.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_linear_ceiling.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_obs_encoding.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_public_api.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_resolve_mandatory.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_rng_isolation.py +0 -0
- {structboost-0.3.0 → structboost-0.4.0}/tests/test_simulation.py +0 -0
|
@@ -3,6 +3,52 @@
|
|
|
3
3
|
Releases follow [semantic versioning](https://semver.org). While the project is
|
|
4
4
|
pre-1.0, a minor bump may break API.
|
|
5
5
|
|
|
6
|
+
### [0.4.0] - 2026-08-12
|
|
7
|
+
|
|
8
|
+
Three defaults change. No configuration fields are added or removed, and anyone
|
|
9
|
+
who sets these explicitly is unaffected.
|
|
10
|
+
|
|
11
|
+
**`enable_early_stopping` now defaults to `False`.** The criterion is a
|
|
12
|
+
convergence check being used as a quality check. There is no validation split, so
|
|
13
|
+
the training loss cannot see a model that is starting to memorize, and patience
|
|
14
|
+
fires long before the model is done: measured against ground truth it stopped at
|
|
15
|
+
iteration 90 on a dataset peaking at 259, and at 196 on a simulated scenario
|
|
16
|
+
peaking at 560, returning marker-recovery F1 0.502 against 0.787. Across three
|
|
17
|
+
real datasets (mouse cortex, human pancreas, human immune) the best iteration
|
|
18
|
+
ranged from 154 to 1975 — always past where patience fires.
|
|
19
|
+
|
|
20
|
+
**No stopping rule replaced it, deliberately.** Latent stability, encoder-support
|
|
21
|
+
overlap and held-out reconstruction were each measured as candidates and each
|
|
22
|
+
rejected. The representation settles long before gene selection does — on one
|
|
23
|
+
dataset consecutive latent codes were rank-identical while the gene set still
|
|
24
|
+
turned over 65% cumulatively — and no observable signal tracks the quality peak.
|
|
25
|
+
A latent-stability rule was built and tuned; it fired at iteration ~109 on all
|
|
26
|
+
three real datasets regardless of where quality peaked, and on one of them it was
|
|
27
|
+
worse than not stopping at all. It is not shipped, not even off by default: an
|
|
28
|
+
option that should never be enabled is pure carrying cost, which is the same
|
|
29
|
+
argument that removed four fields in 0.3.0. `max_iterations=1000` is a defensible
|
|
30
|
+
middle of the measured range, not an optimum.
|
|
31
|
+
|
|
32
|
+
**`BAE.stability_selection(threshold=...)` now defaults to `0.5`, from `0.7`.**
|
|
33
|
+
0.7 is too aggressive whenever the latent representation is still moving: across
|
|
34
|
+
three real datasets it removed 21-52% of recovered marker genes relative to the
|
|
35
|
+
fitted encoder, and 0-7% even when the representation had settled. At 0.5 the
|
|
36
|
+
worst loss over the same six runs was 6%. The standalone
|
|
37
|
+
`structboost.stability_selection` keeps 0.7, because its Meinshausen-Buhlmann
|
|
38
|
+
bound is undefined at or below 0.5.
|
|
39
|
+
|
|
40
|
+
**The `dim_match_quality` warning now fires below 0.85, from 0.5.** It is the gate
|
|
41
|
+
on whether iteration frequencies mean anything: they describe gene-set drift only
|
|
42
|
+
if the counted iterations describe one representation. Runs sitting at 0.70-0.79 —
|
|
43
|
+
comfortably above the old warning — already lost a quarter to a half of their
|
|
44
|
+
recovered markers at the default threshold, while runs at 0.93 and above lost
|
|
45
|
+
none. The guide now documents it as a lookup: at least 0.93, either threshold is
|
|
46
|
+
safe; 0.70-0.79, use 0.3-0.5 and prefer the flat union.
|
|
47
|
+
|
|
48
|
+
Also fixed: the gene-selection guide still showed
|
|
49
|
+
`fit(adata, stability_selection="iteration")`, which stopped being valid in 0.3.0
|
|
50
|
+
when that argument became a bool.
|
|
51
|
+
|
|
6
52
|
### [0.3.0] - 2026-08-11
|
|
7
53
|
|
|
8
54
|
**Breaking.** Five settings are gone and `BAE.stability_selection` has one mode
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: structboost
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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.4.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" }
|
|
@@ -29,6 +29,14 @@ if TYPE_CHECKING:
|
|
|
29
29
|
# "iteration" is generated at the end, not accumulated.
|
|
30
30
|
_REPORT_FIELDS: tuple[str, ...] = tuple(TrainingReport.__dataclass_fields__)
|
|
31
31
|
|
|
32
|
+
# Below this mean matched |cosine| between an iteration's latent dimensions and the
|
|
33
|
+
# fitted model's, iteration-mode stability frequencies stop describing a single
|
|
34
|
+
# representation and a frequency threshold becomes destructive. Raised from 0.5 in
|
|
35
|
+
# 0.4.0: measured runs sitting at 0.70-0.79 already lost a quarter to a half of
|
|
36
|
+
# their recovered markers at the default threshold, while runs at 0.93 and above
|
|
37
|
+
# lost none.
|
|
38
|
+
_DIM_MATCH_WARN: float = 0.85
|
|
39
|
+
|
|
32
40
|
# Per-group reconstruction losses are reported only for obs columns with at most
|
|
33
41
|
# this many levels; beyond it the breakdown is per-cell noise rather than a
|
|
34
42
|
# summary. Columns above the limit are skipped with a warning, never silently.
|
|
@@ -2404,11 +2412,16 @@ class BAE(nn.Module):
|
|
|
2404
2412
|
self.decoder.load_state_dict(saved_decoder)
|
|
2405
2413
|
|
|
2406
2414
|
quality = float(np.mean(match_scores)) if match_scores else float("nan")
|
|
2407
|
-
if np.isfinite(quality) and quality <
|
|
2415
|
+
if np.isfinite(quality) and quality < _DIM_MATCH_WARN:
|
|
2408
2416
|
warnings.warn(
|
|
2409
2417
|
f"Latent dimensions matched the fitted model poorly across iterations "
|
|
2410
|
-
f"(mean |cosine| {quality:.2f}).
|
|
2411
|
-
"
|
|
2418
|
+
f"(mean |cosine| {quality:.2f}, below {_DIM_MATCH_WARN}). The counted "
|
|
2419
|
+
"iterations are not describing one representation, so a frequency "
|
|
2420
|
+
"threshold prunes genes that are simply attached to a different "
|
|
2421
|
+
"version of the latent space: measured on real data this removed "
|
|
2422
|
+
"21-52% of recovered markers at the default threshold. Lower "
|
|
2423
|
+
"`threshold` to 0.3-0.5, and prefer frequency.max(axis=1), the flat "
|
|
2424
|
+
"union, over the per-dimension split.",
|
|
2412
2425
|
UserWarning,
|
|
2413
2426
|
stacklevel=3,
|
|
2414
2427
|
)
|
|
@@ -2429,7 +2442,7 @@ class BAE(nn.Module):
|
|
|
2429
2442
|
adata: AnnData,
|
|
2430
2443
|
*,
|
|
2431
2444
|
n_runs: int = 300,
|
|
2432
|
-
threshold: float = 0.
|
|
2445
|
+
threshold: float = 0.5,
|
|
2433
2446
|
seed: int | None = None,
|
|
2434
2447
|
verbose: bool = True,
|
|
2435
2448
|
):
|
|
@@ -2472,7 +2485,19 @@ class BAE(nn.Module):
|
|
|
2472
2485
|
~0.45 at lag 100 on measured data), so short windows give highly
|
|
2473
2486
|
correlated, near-duplicate samples.
|
|
2474
2487
|
threshold
|
|
2475
|
-
Selection-frequency cutoff for the stable support.
|
|
2488
|
+
Selection-frequency cutoff for the stable support. Default 0.5,
|
|
2489
|
+
lowered from 0.7 in 0.4.0.
|
|
2490
|
+
|
|
2491
|
+
0.7 was measured to be too aggressive whenever the latent
|
|
2492
|
+
representation is still moving: across three real datasets it removed
|
|
2493
|
+
21-52% of recovered marker genes relative to the fitted encoder, and
|
|
2494
|
+
0-7% even when the representation had settled. At 0.5 the worst loss
|
|
2495
|
+
over the same six runs was 6%. Raising it back toward 0.7-0.9 buys
|
|
2496
|
+
precision and is reasonable when ``dim_match_quality`` is high; see
|
|
2497
|
+
that field on the result before doing so.
|
|
2498
|
+
|
|
2499
|
+
Note the standalone :func:`structboost.stability_selection` keeps 0.7,
|
|
2500
|
+
because its Meinshausen-Buhlmann bound is undefined at or below 0.5.
|
|
2476
2501
|
seed
|
|
2477
2502
|
Seeds torch for the continued training iterations.
|
|
2478
2503
|
verbose
|
|
@@ -166,12 +166,27 @@ class BAEConfig:
|
|
|
166
166
|
max_iterations
|
|
167
167
|
Maximum number of training iterations.
|
|
168
168
|
enable_early_stopping
|
|
169
|
-
If True
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
``"
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
If True, stop training early when the checkpoint-selection loss does not
|
|
170
|
+
decrease for `early_stopping_patience` iterations. This is the decoder
|
|
171
|
+
training MSE for ``disentanglement="none"`` and ``"leave_one_out"``; for
|
|
172
|
+
``"correlation"`` it additionally includes the weighted disentanglement
|
|
173
|
+
penalty. **Default False**: train for exactly `max_iterations`.
|
|
174
|
+
|
|
175
|
+
The default changed in 0.4.0 because the criterion is a *convergence*
|
|
176
|
+
check being used as a *quality* check, and it stops far too early. There
|
|
177
|
+
is no validation split, so the training loss cannot see a model that is
|
|
178
|
+
beginning to memorize; measured against ground truth it stopped at
|
|
179
|
+
iteration 90 on one dataset whose marker recovery peaked at 259, and at
|
|
180
|
+
196 on a simulated scenario peaking at 560 (returning F1 0.502 against
|
|
181
|
+
0.787). Across three real datasets the best iteration ranged from 154 to
|
|
182
|
+
1975, always past where patience fires.
|
|
183
|
+
|
|
184
|
+
No stopping rule replaced it, because none was found that works. Latent
|
|
185
|
+
stability, support overlap and held-out reconstruction were each measured
|
|
186
|
+
as candidates; the representation settles long before gene selection
|
|
187
|
+
does, and nothing observable tracks the quality peak. Run the iteration
|
|
188
|
+
budget and let :meth:`BAE.stability_selection` absorb the variance
|
|
189
|
+
instead — that is what it is for.
|
|
175
190
|
early_stopping_patience
|
|
176
191
|
Number of consecutive iterations without checkpoint-selection loss
|
|
177
192
|
improvement required to trigger early stopping. Only used if
|
|
@@ -215,7 +230,7 @@ class BAEConfig:
|
|
|
215
230
|
decoder_weight_decay: float = 0.0
|
|
216
231
|
decoder_updates_per_iteration: int = 10
|
|
217
232
|
max_iterations: int = 1000
|
|
218
|
-
enable_early_stopping: bool =
|
|
233
|
+
enable_early_stopping: bool = False
|
|
219
234
|
early_stopping_patience: int = 50
|
|
220
235
|
batch_size: int = 2**9
|
|
221
236
|
seed: int | None = None
|
|
@@ -1271,6 +1271,29 @@ def test_bae_nuisance_only_needs_no_obs_for_reconstruction():
|
|
|
1271
1271
|
assert model.reconstruct(without_obs).shape == x.shape
|
|
1272
1272
|
|
|
1273
1273
|
|
|
1274
|
+
def test_fit_runs_the_full_budget_by_default():
|
|
1275
|
+
"""Early stopping is off by default, so a fit uses its whole iteration budget.
|
|
1276
|
+
|
|
1277
|
+
Changed in 0.4.0: the training-loss criterion is a convergence check standing in
|
|
1278
|
+
for a quality check, and it fires long before marker recovery peaks.
|
|
1279
|
+
"""
|
|
1280
|
+
_require_bae_deps()
|
|
1281
|
+
import anndata as ad
|
|
1282
|
+
|
|
1283
|
+
from structboost import BAE, BAEConfig
|
|
1284
|
+
|
|
1285
|
+
rng = np.random.default_rng(0)
|
|
1286
|
+
x = rng.normal(size=(40, 12)).astype(np.float32)
|
|
1287
|
+
x = (x - x.mean(axis=0)) / x.std(axis=0)
|
|
1288
|
+
adata = ad.AnnData(x)
|
|
1289
|
+
config = BAEConfig(latent_dim=2, max_iterations=6, early_stopping_patience=1)
|
|
1290
|
+
assert config.enable_early_stopping is False
|
|
1291
|
+
BAE(12, config).fit(adata, verbose=False)
|
|
1292
|
+
|
|
1293
|
+
# Patience 1 would have stopped almost immediately had it been active.
|
|
1294
|
+
assert len(adata.uns["bae"]["training_history"]["train_loss"]) == 6
|
|
1295
|
+
|
|
1296
|
+
|
|
1274
1297
|
def test_variance_explained_is_written_by_a_plain_fit():
|
|
1275
1298
|
"""It is a quality metric, not a covariate one.
|
|
1276
1299
|
|
|
@@ -394,6 +394,55 @@ def test_iteration_loop_matches_the_training_loop():
|
|
|
394
394
|
np.testing.assert_array_equal(supports_cont[0], second_iteration_support)
|
|
395
395
|
|
|
396
396
|
|
|
397
|
+
def test_shipped_defaults_are_the_measured_ones():
|
|
398
|
+
"""The three 0.4.0 defaults, pinned so they cannot drift back silently.
|
|
399
|
+
|
|
400
|
+
Each replaced a value measured to be harmful: patience-50 early stopping stops
|
|
401
|
+
before the quality peak on every dataset tried (best iteration 154-1975);
|
|
402
|
+
threshold 0.7 removes 21-52% of recovered markers when the latent is still
|
|
403
|
+
moving; and the 0.5 dim_match_quality warning never fired on runs at 0.70-0.79
|
|
404
|
+
that had already lost a quarter to a half of their markers.
|
|
405
|
+
"""
|
|
406
|
+
import inspect
|
|
407
|
+
|
|
408
|
+
from structboost import BAE, BAEConfig
|
|
409
|
+
from structboost import stability_selection as standalone
|
|
410
|
+
from structboost._model import _DIM_MATCH_WARN
|
|
411
|
+
|
|
412
|
+
assert BAEConfig().enable_early_stopping is False
|
|
413
|
+
assert inspect.signature(BAE.stability_selection).parameters["threshold"].default == 0.5
|
|
414
|
+
assert _DIM_MATCH_WARN == 0.85
|
|
415
|
+
|
|
416
|
+
# The standalone function keeps 0.7: its Meinshausen-Buhlmann bound is
|
|
417
|
+
# undefined at or below 0.5, so the two defaults differ deliberately.
|
|
418
|
+
assert inspect.signature(standalone).parameters["threshold"].default == 0.7
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_poor_dimension_matching_warns_below_the_gate():
|
|
422
|
+
"""The gate must fire when dimensions stop keeping their identity.
|
|
423
|
+
|
|
424
|
+
A short fit on structured data permutes its dimensions enough to land under the
|
|
425
|
+
gate, which is the case the warning exists for: the counted iterations are then
|
|
426
|
+
describing different representations, and a frequency threshold prunes genes
|
|
427
|
+
attached to the wrong one.
|
|
428
|
+
"""
|
|
429
|
+
_require_bae()
|
|
430
|
+
from structboost import BAE, BAEConfig, sim_scrnaseq_anndata
|
|
431
|
+
from structboost._model import _DIM_MATCH_WARN
|
|
432
|
+
|
|
433
|
+
a = sim_scrnaseq_anndata(n=300, n_genes=120, stageno=4, stagep=10, seed=2)
|
|
434
|
+
model = BAE(
|
|
435
|
+
a.n_vars,
|
|
436
|
+
BAEConfig(latent_dim=4, max_iterations=20, enable_early_stopping=False, seed=0),
|
|
437
|
+
)
|
|
438
|
+
model.fit(a, verbose=False)
|
|
439
|
+
|
|
440
|
+
with pytest.warns(UserWarning, match="matched the fitted model poorly") as caught:
|
|
441
|
+
res = model.stability_selection(a, n_runs=6, seed=0, verbose=False)
|
|
442
|
+
assert res.dim_match_quality < _DIM_MATCH_WARN
|
|
443
|
+
assert f"below {_DIM_MATCH_WARN}" in str(caught[0].message)
|
|
444
|
+
|
|
445
|
+
|
|
397
446
|
def test_iteration_mode_rejects_bad_arguments():
|
|
398
447
|
pytest.importorskip("torch")
|
|
399
448
|
adata = _tiny_adata()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|