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.
Files changed (43) hide show
  1. {structboost-0.3.0 → structboost-0.4.0}/CHANGELOG.md +46 -0
  2. {structboost-0.3.0 → structboost-0.4.0}/CITATION.cff +1 -1
  3. {structboost-0.3.0 → structboost-0.4.0}/PKG-INFO +1 -1
  4. {structboost-0.3.0 → structboost-0.4.0}/pyproject.toml +1 -1
  5. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_model.py +30 -5
  6. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_types.py +22 -7
  7. {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae.py +23 -0
  8. {structboost-0.3.0 → structboost-0.4.0}/tests/test_stability.py +49 -0
  9. {structboost-0.3.0 → structboost-0.4.0}/.gitignore +0 -0
  10. {structboost-0.3.0 → structboost-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  11. {structboost-0.3.0 → structboost-0.4.0}/CONTRIBUTING.md +0 -0
  12. {structboost-0.3.0 → structboost-0.4.0}/LICENSE +0 -0
  13. {structboost-0.3.0 → structboost-0.4.0}/README.md +0 -0
  14. {structboost-0.3.0 → structboost-0.4.0}/conftest.py +0 -0
  15. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/__init__.py +0 -0
  16. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_annotation.py +0 -0
  17. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_boosting.py +0 -0
  18. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_decoder.py +0 -0
  19. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_encoder.py +0 -0
  20. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_explorer.py +0 -0
  21. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_io.py +0 -0
  22. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_persistence.py +0 -0
  23. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_plotting.py +0 -0
  24. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_simulation.py +0 -0
  25. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_stability.py +0 -0
  26. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/_utils.py +0 -0
  27. {structboost-0.3.0 → structboost-0.4.0}/src/structboost/py.typed +0 -0
  28. {structboost-0.3.0 → structboost-0.4.0}/tests/test_allboost.py +0 -0
  29. {structboost-0.3.0 → structboost-0.4.0}/tests/test_annotation.py +0 -0
  30. {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_diagnostics.py +0 -0
  31. {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_init.py +0 -0
  32. {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_layer.py +0 -0
  33. {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_persistence.py +0 -0
  34. {structboost-0.3.0 → structboost-0.4.0}/tests/test_bae_transfer.py +0 -0
  35. {structboost-0.3.0 → structboost-0.4.0}/tests/test_batch_integration.py +0 -0
  36. {structboost-0.3.0 → structboost-0.4.0}/tests/test_covcache_precompute.py +0 -0
  37. {structboost-0.3.0 → structboost-0.4.0}/tests/test_explorer.py +0 -0
  38. {structboost-0.3.0 → structboost-0.4.0}/tests/test_linear_ceiling.py +0 -0
  39. {structboost-0.3.0 → structboost-0.4.0}/tests/test_obs_encoding.py +0 -0
  40. {structboost-0.3.0 → structboost-0.4.0}/tests/test_public_api.py +0 -0
  41. {structboost-0.3.0 → structboost-0.4.0}/tests/test_resolve_mandatory.py +0 -0
  42. {structboost-0.3.0 → structboost-0.4.0}/tests/test_rng_isolation.py +0 -0
  43. {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
@@ -2,7 +2,7 @@ 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.3.0"
5
+ version: "0.4.0"
6
6
  date-released: "2026-08-11"
7
7
  license: MIT
8
8
  authors:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: structboost
3
- Version: 0.3.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.3.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 < 0.5:
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}). Per-dimension frequencies are unreliable "
2411
- "here; use frequency.max(axis=1) for the flat union instead.",
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.7,
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 (default), stop training early when the checkpoint-selection loss
170
- does not decrease for `early_stopping_patience` iterations. This is the
171
- decoder training MSE for ``disentanglement="none"`` and
172
- ``"leave_one_out"``; for ``"correlation"`` it additionally includes the
173
- weighted disentanglement penalty. If False, train for exactly
174
- `max_iterations`.
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 = True
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