structboost 0.4.0__tar.gz → 0.6.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 (45) hide show
  1. {structboost-0.4.0 → structboost-0.6.0}/CHANGELOG.md +228 -0
  2. {structboost-0.4.0 → structboost-0.6.0}/CITATION.cff +2 -2
  3. {structboost-0.4.0 → structboost-0.6.0}/PKG-INFO +63 -11
  4. {structboost-0.4.0 → structboost-0.6.0}/README.md +59 -10
  5. {structboost-0.4.0 → structboost-0.6.0}/pyproject.toml +11 -1
  6. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/__init__.py +13 -0
  7. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_model.py +137 -44
  8. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_persistence.py +10 -2
  9. structboost-0.6.0/src/structboost/_plotting.py +1608 -0
  10. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_types.py +94 -22
  11. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_utils.py +67 -30
  12. {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae.py +196 -59
  13. {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_init.py +10 -2
  14. {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_persistence.py +24 -0
  15. {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_transfer.py +35 -2
  16. structboost-0.6.0/tests/test_plotting_dimensions.py +506 -0
  17. {structboost-0.4.0 → structboost-0.6.0}/tests/test_stability.py +112 -11
  18. structboost-0.4.0/src/structboost/_plotting.py +0 -301
  19. {structboost-0.4.0 → structboost-0.6.0}/.gitignore +0 -0
  20. {structboost-0.4.0 → structboost-0.6.0}/CODE_OF_CONDUCT.md +0 -0
  21. {structboost-0.4.0 → structboost-0.6.0}/CONTRIBUTING.md +0 -0
  22. {structboost-0.4.0 → structboost-0.6.0}/LICENSE +0 -0
  23. {structboost-0.4.0 → structboost-0.6.0}/conftest.py +0 -0
  24. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_annotation.py +0 -0
  25. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_boosting.py +0 -0
  26. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_decoder.py +0 -0
  27. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_encoder.py +0 -0
  28. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_explorer.py +0 -0
  29. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_io.py +0 -0
  30. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_simulation.py +0 -0
  31. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_stability.py +0 -0
  32. {structboost-0.4.0 → structboost-0.6.0}/src/structboost/py.typed +0 -0
  33. {structboost-0.4.0 → structboost-0.6.0}/tests/test_allboost.py +0 -0
  34. {structboost-0.4.0 → structboost-0.6.0}/tests/test_annotation.py +0 -0
  35. {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_diagnostics.py +0 -0
  36. {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_layer.py +0 -0
  37. {structboost-0.4.0 → structboost-0.6.0}/tests/test_batch_integration.py +0 -0
  38. {structboost-0.4.0 → structboost-0.6.0}/tests/test_covcache_precompute.py +0 -0
  39. {structboost-0.4.0 → structboost-0.6.0}/tests/test_explorer.py +0 -0
  40. {structboost-0.4.0 → structboost-0.6.0}/tests/test_linear_ceiling.py +0 -0
  41. {structboost-0.4.0 → structboost-0.6.0}/tests/test_obs_encoding.py +0 -0
  42. {structboost-0.4.0 → structboost-0.6.0}/tests/test_public_api.py +0 -0
  43. {structboost-0.4.0 → structboost-0.6.0}/tests/test_resolve_mandatory.py +0 -0
  44. {structboost-0.4.0 → structboost-0.6.0}/tests/test_rng_isolation.py +0 -0
  45. {structboost-0.4.0 → structboost-0.6.0}/tests/test_simulation.py +0 -0
@@ -3,6 +3,234 @@
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.6.0] - 2026-09-09
7
+
8
+ **Not breaking.** Nothing existing changes behaviour; this adds a way to read a
9
+ fitted model.
10
+
11
+ **New `plot_latent_dimensions`: one row per latent dimension, up to three
12
+ panels.** The sorted score curve, the per-gene contribution distributions, and
13
+ the scores split by a grouping you name. It exists because a fitted BAE has been
14
+ readable in principle — the encoder *is* the gene list — and awkward in practice:
15
+ `extract_gene_rankings` gives ranked lists, `export_interactive_html` gives an
16
+ explorer, and neither says whether a dimension describes a subgroup or a
17
+ gradient, nor which cells sit at which end.
18
+
19
+ **New `plot_dimension_gene_umaps`: a UMAP grid, dimensions by top genes**, drawn
20
+ through `scanpy.pl.umap` so the panels match a scanpy figure. It answers the one
21
+ question the other panels structurally cannot — whether a dimension's genes light
22
+ up the *same* cells — because both the score curve and the contribution violins
23
+ have already summed over cells. On real data it separates a coherent programme
24
+ from a dimension summing unrelated signals.
25
+
26
+ **A `"shares"` panel and a new `plot_dimension_correlation`.** The contribution
27
+ panel shows a dimension's top handful of genes; the shares panel plots *every*
28
+ selected gene's share, sorted, which is the only view that says whether a
29
+ dimension rests on three genes or spreads evenly over forty — both legitimate,
30
+ and read differently. Ordering it by `rank_by="weight"` while the axis stays on
31
+ the share draws the disagreement between the two rankings directly.
32
+
33
+ The correlation heatmap answers a question no per-dimension panel can: whether
34
+ two dimensions are near-duplicates, and therefore whether they can be read as two
35
+ findings or one. Pearson by default, Spearman on tie-averaged ranks as an option —
36
+ ties are not incidental here, since a sparse encoder leaves many cells at exactly
37
+ one value on a subgroup dimension. Signed values get the diverging map centred on
38
+ zero; `absolute=True` plots `|r|` with a sequential one, since a magnitude has no
39
+ midpoint.
40
+
41
+ **Genes are ranked by variance share, not `|weight|`,** exposed as the new
42
+ `gene_variance_shares`. From `Var(s) = Cov(s, s)` with `s = X @ w` it follows
43
+ that `w_g Cov(X_g, s) / Var(s)` decomposes a dimension's variance exactly and
44
+ sums to 1, needing no orthogonality assumption and splitting credit correctly
45
+ between correlated genes. The weight alone ignores how much a gene actually
46
+ varies: measured on 59k PBMCs the two rankings agree on 8.6 of 10 genes per
47
+ dimension, and where they differ `|w|` promotes genes that barely move — SCT
48
+ ranked #4 by weight and #35 of 38 by share, being near-absent in that tissue.
49
+ The share is a **magnitude**: because a negative-weight gene is anti-correlated
50
+ with the score, the product is positive either way and only ~1% of genes come out
51
+ negative. Direction is therefore the weight's sign, carried by the colour of the
52
+ gene name, and the share prints without one.
53
+
54
+ **The score panel paints in a seeded random order.** Markers are wider than the
55
+ spacing between adjacent ranks — about 112 cells overlap at a given x on 59k —
56
+ so whatever is drawn last wins every overlap. Painting in score order hands each
57
+ contest to the higher-scoring cell, and since score correlates with group, one
58
+ group systematically covers its neighbours. Every point keeps its own
59
+ `(rank, score)`, so the curve is unchanged and only the paint order is permuted.
60
+ This is the *opposite* choice from a UMAP panel, where rare groups are drawn last
61
+ on purpose: there position is data and overlap is unavoidable, here every cell
62
+ has its own x and any deterministic order is a bias.
63
+
64
+ **Seven hues, then grey, and the palette is measured rather than chosen** — see
65
+ the new `palette_audit`. The package's existing Okabe-Ito set separates colours
66
+ from each other under colour-blindness but does not hold contrast against white
67
+ at small mark sizes: its `#F0E442` measures 1.32 WCAG contrast against a floor of
68
+ 3.0, so a cell type holding 5.9% of cells was invisible as 8pt dots while its
69
+ violin read perfectly well. **Contrast requirements scale with mark size, so a
70
+ palette validated on one mark is not validated on another.** At twelve hues
71
+ nothing separates anyway — 1.5 ΔE under simulated deuteranopia against a target
72
+ of 8; Kelly's "20 colours of maximum contrast" reaches 0.4, scanpy's `default_20`
73
+ 0.5. Twenty reliably distinguishable hues do not exist. The default therefore
74
+ follows the group count (`tab10`'s first five, then `tab10`, then scanpy's
75
+ `default_20`), groups past the palette are grey, and they lose nothing because
76
+ the violin panel labels every row — the score panel, which has no labels, is
77
+ where the cap binds.
78
+
79
+ **Signed quantities get a diverging map centred on zero; sequential is for
80
+ magnitudes.** The score always, and the gene panels as soon as `scale="zscore"`,
81
+ where a sequential map would put its neutral colour at the data mean rather than
82
+ at zero. The encoder is bias-free, so zero is a real level — no selected gene
83
+ departs from its mean in that cell — and the point at which a dimension's sign
84
+ flips.
85
+
86
+ `scanpy` becomes a new optional extra, needed only by `plot_dimension_gene_umaps`
87
+ and imported inside it. Absent, that one function raises naming
88
+ `plot_latent_dimensions` as the matplotlib-only alternative; everything else is
89
+ unaffected.
90
+
91
+ Two things are deliberately *not* offered. `c_ig / score_ik`, the intuitive
92
+ "fraction of this cell's score", is unusable: the flat plateau of a subgroup
93
+ dimension is exactly the cells whose score is ≈0, so the ratio diverges (measured
94
+ −8,921 to +1,191 for one gene), flips sign with the score, and inflates without
95
+ bound wherever contributions cancel — `normalize="cell"` divides by total
96
+ *absolute* contribution instead, which is bounded. And nothing here is
97
+ inferential: every number is an exact algebraic decomposition or a descriptive
98
+ statistic.
99
+
100
+ ### [0.5.0] - 2026-08-21
101
+
102
+ **Breaking**, in two parts: the decoder update became one pass over the cells,
103
+ and the disentanglement default changed.
104
+
105
+ **`BAEConfig.decoder_updates_per_iteration` is gone.** Each training
106
+ iteration now gives the decoder **one shuffled pass over the cells** —
107
+ `ceil(n_cells / batch_size)` AdamW steps, with every cell contributing to exactly
108
+ one of them — so the step count is derived from the data rather than set.
109
+
110
+ The reason is an asymmetry that grew with dataset size. The boosting half of the
111
+ alternation is full-batch at every size: `z*` is computed on all cells and the
112
+ encoder is re-solved from zero against all cells, every iteration. A fixed
113
+ decoder step count made a *cell's* participation depend on how many other cells
114
+ existed — at the old `10 × 512` the decoder saw every cell below 5,120 cells,
115
+ 31% at 16,000 and 5% at 100,000, while the decoder that defines the target for
116
+ all of them had been trained on that shrinking slice. Tying the budget to the
117
+ data makes both halves consume the same cells per iteration, and it is the same
118
+ per-cell invariance that `target_optim_lr` already provides on the target side.
119
+
120
+ **No quality claim is attached to this change, because none could be
121
+ established.** On human pancreas (16,382 cells, 2,000 batch-aware HVGs, nine
122
+ protocols, `nu=0.3`, 1000 iterations, one seed) the epoch rule raised in-sample
123
+ variance explained from 0.3100 to 0.3217 at `stepno=50` and 0.3414 to 0.3556 at
124
+ `stepno=100` — but in-sample reconstruction rewards the arm taking 3.2x the
125
+ steps, so that is not evidence. Cell-type silhouette moved the other way,
126
+ 0.298 → 0.172 and 0.327 → 0.169; on an 80/20 split of the same data it moved the
127
+ *opposite* way again (0.265 → 0.333, 0.218 → 0.268). Two conditions, opposite
128
+ orderings, one seed each: within run-to-run variance for a method whose encoder
129
+ support random-walks. The case for the change is the invariance above, not a
130
+ measured improvement.
131
+
132
+ What is established is the cost: **roughly 2x wall clock** at this dataset size
133
+ (99s → 201s at `stepno=50`, 131s → 238s at `stepno=100`), and it grows with cell
134
+ count because `max_iterations` does not decay to compensate. scVI, which is
135
+ epoch-based in the same way, pairs epochs with a `max_epochs` cap that decays as
136
+ 1/n; no such heuristic is added here, so raising `batch_size` is the lever.
137
+
138
+ Keeping a dial (`decoder_epochs_per_iteration = 1.0`, same default, still
139
+ tunable) was considered and rejected: the goal was to remove a setting, and a
140
+ float multiplier on a pass is a second way to say what `batch_size` already says.
141
+ The consequence is that decoder effort per boosting iteration is no longer
142
+ adjustable except through `batch_size`.
143
+
144
+ **Small datasets need a smaller `batch_size` than the default suggests.** Below
145
+ about 1,000 cells the default 512 yields one or two steps per iteration, and
146
+ effects that require the decoder to move *within* an iteration weaken or reverse.
147
+ Measured on 300 cells, a PCA warm start raised the first iteration's loss at one
148
+ step per iteration (1.083 against 1.023 for a zero start), tied at ten, and only
149
+ paid off at thirty-eight (0.920 against 1.000). The guide documents this.
150
+
151
+ **Migration:** drop the argument. `BAEConfig(decoder_updates_per_iteration=10)`
152
+ now raises `TypeError`. Setting `batch_size = ceil(n_cells / 10)` reproduces the
153
+ old *step count*, though not the old batch size, so results will differ either
154
+ way.
155
+
156
+ **`disentanglement="leave_one_out"` is renamed to `"orthogonal"`, and is now the
157
+ default.** It replaces the boosting targets with the nearest mutually orthogonal
158
+ set of the same column norms — symmetric Löwdin orthogonalization, computed from
159
+ the thin SVD as `U @ Vt`, since `T (T'T)^{-1/2} = U V'`. The old name is refused
160
+ with a message naming the new one, rather than falling through to the generic
161
+ "must be one of" error.
162
+
163
+ The previous implementation was not an orthogonalization. It regressed each
164
+ target column on all the others and kept the residual, which yields
165
+ `corr(r_j, r_k) = -rho_{jk|rest}` exactly: the marginal correlation structure
166
+ replaced by the *negated partial* correlation structure rather than removed. In
167
+ two dimensions that reduces to `corr -> -corr`, an exact sign flip achieving
168
+ nothing; in higher dimensions it could amplify a correlation, or manufacture one
169
+ between columns that were near-independent. Measured on human pancreas it reached
170
+ mean `|corr|` 0.048 against 0.033 for a correct orthogonalization, while selecting
171
+ fewer genes (276 against 294) and reconstructing slightly worse.
172
+
173
+ Symmetric rather than sequential (Gram-Schmidt), which also orthogonalizes but
174
+ depends on the order the columns are visited — it returns the last dimension
175
+ untouched and strips the first hardest. Latent indices permute freely, which is
176
+ why `stability_selection` matches them before counting, so an order-dependent
177
+ transform would impose an arbitrary hierarchy. The two measured within seed noise
178
+ of each other on both real datasets. Löwdin is also cheaper: `O(n * d**2)` against
179
+ `O(n * d**3)` for anything running one least-squares solve per column, measured
180
+ 6-16x faster and widening with latent width, and it needs no inverse so a
181
+ rank-deficient target matrix orthogonalizes instead of raising.
182
+
183
+ **New `BAEConfig.disentanglement_alpha`, default `1.0`.** Targets become
184
+ `(1 - alpha) * targets + alpha * orthogonalized`, so decorrelation can be softened
185
+ by choosing an alpha strictly between 0 and 1; `0.0` is equivalent to
186
+ `disentanglement="none"`. Interpolation is meaningful because Löwdin returns the
187
+ *closest* orthogonal matrix, so the endpoints are already sign-aligned.
188
+
189
+ **`disentanglement_lambda` default raised from `1e-4` to `1e-2`.** The old default
190
+ was inert: on simulated data, Tasic mouse cortex and human pancreas it moved the
191
+ mean absolute latent correlation by less than its own seed-to-seed noise, and so
192
+ did `1e-3`. On pancreas, mean `|corr|` ran 0.103 (off), 0.100 (`1e-4`), 0.112
193
+ (`1e-3`), 0.080 (`1e-2`), 0.061 (`1e-1`). The documented sweep grid, which topped
194
+ out at `1e-3`, ended below where the penalty begins to act; the guide now sweeps
195
+ upward from `1e-2`.
196
+
197
+ **Expect the default to cost some biological structure.** Decorrelation is an
198
+ extra constraint and real gene programs are not orthogonal, so it trades fidelity
199
+ to that structure, and the interpretability resting on it, for a less redundant
200
+ representation. Measured against ground truth on simulated data, marker-recovery
201
+ F1 moved from 0.98 to 0.88; on Tasic and pancreas the share of latent variance
202
+ explained by the annotated cell type fell. `disentanglement="none"` remains a
203
+ reasonable choice, and `disentanglement_alpha` exists so the trade can be made
204
+ partially.
205
+
206
+ **Migration:** `disentanglement="leave_one_out"` becomes `"orthogonal"`, or
207
+ `"none"` to restore the 0.4.0 default of no constraint. Note that `"orthogonal"`
208
+ is not a renamed version of the old behaviour — the method itself changed, so a
209
+ 0.4.0 fit is reproduced by `"none"`, not by the new name.
210
+
211
+ **New `BAE.stability_selection(continue_optimizer=...)`, default `False`.** Carries
212
+ the decoder's AdamW moment estimates over from `fit` instead of restarting them at
213
+ zero. A fresh AdamW restarts its step counter, so bias correction begins again and
214
+ `exp_avg_sq` needs on the order of `1/(1 - beta2) = 1000` steps to become a usable
215
+ variance estimate; at `ceil(n_cells / batch_size)` steps per iteration that
216
+ transient spans roughly `1000 * batch_size / n_cells` of the counted iterations —
217
+ brief on a large dataset, most of the window on a small one, and it falls at the
218
+ end where the support is furthest from equilibrium.
219
+
220
+ The state is the one from the iteration `fit` *restored*, not from its last, so the
221
+ moments belong to the decoder actually returned. It is held in memory only: `save`
222
+ excludes optimizer state, so a model read back from a checkpoint warns and falls
223
+ back rather than silently measuring something different. A caller who lowered
224
+ `config.decoder_lr` for the counting phase keeps that change — only the moments are
225
+ carried across. Default `False` preserves the behaviour every earlier result was
226
+ measured under.
227
+
228
+ **Checkpoints written by 0.4.0 no longer load.** `restore_payload` splats the
229
+ stored config into `BAEConfig`, so a format-5 file's `decoder_updates_per_iteration`
230
+ is an unexpected keyword argument. The format is bumped to 6 and the loader
231
+ refuses 5 by name. No migration is written: the setting no longer exists, so
232
+ there is nothing to migrate it to, and a checkpoint is cheap to regenerate.
233
+
6
234
  ### [0.4.0] - 2026-08-12
7
235
 
8
236
  Three defaults change. No configuration fields are added or removed, and anyone
@@ -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.4.0"
6
- date-released: "2026-08-11"
5
+ version: "0.5.0"
6
+ date-released: "2026-08-21"
7
7
  license: MIT
8
8
  authors:
9
9
  - family-names: Brunn
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: structboost
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -64,6 +64,9 @@ Requires-Dist: pandas>=1.5; extra == 'io'
64
64
  Requires-Dist: pyarrow>=12; extra == 'io'
65
65
  Provides-Extra: plot
66
66
  Requires-Dist: matplotlib>=3.7; extra == 'plot'
67
+ Provides-Extra: scanpy
68
+ Requires-Dist: matplotlib>=3.7; extra == 'scanpy'
69
+ Requires-Dist: scanpy>=1.9; extra == 'scanpy'
67
70
  Provides-Extra: test
68
71
  Requires-Dist: anndata>=0.10; extra == 'test'
69
72
  Requires-Dist: pandas>=1.5; extra == 'test'
@@ -85,9 +88,12 @@ The **Boosting Autoencoder (BAE)** pairs a linear encoder fitted by componentwis
85
88
  L2 boosting with an MLP decoder trained by gradient descent. Each training
86
89
  iteration takes a gradient step on the latent code itself and hands the result to
87
90
  the boosting fit as a regression target, so the encoder is fitted against the
88
- negative gradient of the reconstruction loss rather than by backpropagation.
89
- Componentwise boosting adds one gene at a time and shrinks each step, which keeps
90
- the encoder weights sparse by construction rather than by a post-hoc threshold.
91
+ gradient-updated latent code rather than by backpropagation. The target is that
92
+ updated code rather than the gradient alone because the encoder is rebuilt from
93
+ zero every iteration: it has to reproduce where the code should be, not the
94
+ correction to where it already is. Componentwise boosting adds one gene at a time
95
+ and shrinks each step, which keeps the encoder weights sparse by construction
96
+ rather than by a post-hoc threshold.
91
97
 
92
98
  Each latent dimension is therefore a short, signed gene list, and `X_bae` is
93
99
  exactly `X @ varm["BAE_encoder_weights"]`.
@@ -147,16 +153,26 @@ adata.obsm["X_bae"] # (n_cells, 10) latent space
147
153
  adata.varm["BAE_encoder_weights"] # (n_genes, 10), sparse
148
154
  ```
149
155
 
150
- A single fit gives one gene list, and that list is **not reproducible**. In a
151
- high-dimensional feature space with strongly correlated genes the encoder support
152
- is not identifiable: many different sparse gene sets reconstruct the data about
153
- equally well, and a fit returns one of them.
156
+ To integrate over a batch or any other unwanted covariate, name the obs column:
154
157
 
155
158
  ```python
156
- res = model.stability_selection(adata)
157
- genes = [adata.var_names[res.stable_support[:, j]] for j in range(res.frequency.shape[1])]
159
+ model.fit(adata, batch_key="batch") # or ["batch", "donor"]
160
+
161
+ adata.uns["bae"]["latent_obs_r2_per_dim"] # near zero means it worked
158
162
  ```
159
163
 
164
+ By default this both conditions the decoder on the covariate and adds it to the
165
+ boosting design as a mandatory regressor, so a batch-correlated gene is not
166
+ selected *because of* the batch. The covariate is never an encoder input, so
167
+ `transform` stays gene-only and needs no batch labels on new data.
168
+
169
+ A single fit gives one gene list, and that list is **not reproducible**: in a
170
+ high-dimensional feature space with strongly correlated genes the encoder support
171
+ is not identifiable, so many different sparse gene sets reconstruct the data about
172
+ equally well and a fit returns one of them. See
173
+ [gene selection](https://niklasbrunn.github.io/structboost/guide/tasks/gene-selection.html)
174
+ before trusting a single list.
175
+
160
176
  Componentwise L2 boosting on its own, no autoencoder involved:
161
177
 
162
178
  ```python
@@ -173,11 +189,26 @@ Each of these has a guide page.
173
189
  | | |
174
190
  | --- | --- |
175
191
  | [Batch integration](https://niklasbrunn.github.io/structboost/guide/tasks/batch-integration.html) | `batch_key` names the covariate and `batch_integration_mode` chooses whether it conditions the decoder, protects gene selection, or both. `transform` stays gene-only and needs no batch labels. |
176
- | [Transfer](https://niklasbrunn.github.io/structboost/guide/tasks/transfer.html) | Carry a trained encoder matrix onto a new dataset with `from_reference`, aligned by gene name, with the prior programs frozen. |
192
+ | [Mandatory features](https://niklasbrunn.github.io/structboost/guide/tasks/gene-selection.html) | `mandatory_genes` puts known markers in boosting's unpenalized adjustment block, so they are never subject to competitive selection. Flat or per latent dimension. It forces them into the *specification*, not into the fitted support. |
193
+ | [Reading a fit](https://niklasbrunn.github.io/structboost/guide/tasks/reading-dimensions.html) | `plot_latent_dimensions` draws one row per latent dimension — the sorted score curve, the per-gene contributions, and the scores split by any grouping — plus a UMAP grid and a dimension-correlation heatmap. Genes are ranked by their exact share of a dimension's variance, not by coefficient size. |
177
194
  | [Persistence](https://niklasbrunn.github.io/structboost/guide/tasks/persistence.html) | `save` and `load` a fitted model as one checkpoint, readable with `weights_only=True`. |
178
195
  | [Interpretation](https://niklasbrunn.github.io/structboost/guide/tasks/interpreting.html) | Ranked gene lists per dimension, stored functional annotations, and a self-contained interactive HTML explorer. |
179
196
  | [Simulation](https://niklasbrunn.github.io/structboost/guide/tasks/simulating.html) | Negative-binomial counts with planted gene programs and a cell-type hierarchy, so marker recovery can be scored against ground truth. |
180
197
 
198
+ ## Exploratory features
199
+
200
+ The whole package is pre-1.0, but these four are **under active development** and
201
+ less settled than the rest. They work, and each is documented with what is known
202
+ about it — but their behaviour, defaults and APIs are more likely to change, and
203
+ results from them warrant more scepticism than the core fit does.
204
+
205
+ | Feature | Why it is still exploratory |
206
+ | --- | --- |
207
+ | **Stability selection** (`BAE.stability_selection`) | Provides **no formal error control** — `expected_false_positives` is deliberately `NaN`, because training iterations are neither independent nor exchangeable. Per-dimension frequencies are only meaningful when dimensions keep their identity, which `dim_match_quality` reports and does not guarantee. |
208
+ | **Disentanglement** (`disentanglement=`) | On by default since 0.5.0, and both methods are provisional. Decorrelation is an extra constraint that real gene programs do not satisfy, so it costs biological structure — measured at marker-recovery F1 0.98 to 0.88 on simulated data. `disentanglement_alpha` softens it; `"none"` turns it off. |
209
+ | **Starting from an existing representation** (`init_pca`, `init_obsm`) | The warm start is applied once, on the first iteration, and silently overrides `latent_dim` if the supplied representation is a different width. It also depends on the decoder having enough steps in that first iteration to follow it — on few cells at the default `batch_size` the effect reverses. |
210
+ | **Starting from a prior encoder matrix** (`BAE.from_reference`) | Transfer works, but the two latent blocks land on **incomparable scales** — measured at a 232× gap in per-dimension standard deviation — so anything Euclidean must be handed `obsm["X_bae_scaled"]` rather than `X_bae`. `novel_variance_share` is not evidence of novel biology on the fitting data. |
211
+
181
212
  ## Citation
182
213
 
183
214
  If you use the **BAE**:
@@ -204,6 +235,27 @@ Everything committed was reviewed, and the behavioural claims in the docstrings
204
235
  and the user guide are backed by the test suite or by the measurements cited
205
236
  alongside them.
206
237
 
238
+ ## Open points
239
+
240
+ Known gaps and planned work.
241
+
242
+ - [ ] **Revise the early-stopping criterion.** Early stopping is off by default
243
+ because the training-loss rule is a convergence check being used as a quality
244
+ check, and it stops well before gene selection has settled. No replacement has
245
+ been found yet.
246
+ - [ ] **Stability selection.** Iteration mode carries no formal error control, and
247
+ its per-dimension frequencies are only interpretable when `dim_match_quality` is
248
+ high. A scheme with a defensible bound under a model fitted on the same cells is
249
+ still open.
250
+ - [ ] **Multimodal architecture** — reconstruction-based, with a shared latent
251
+ space across modalities, for paired single-cell data.
252
+ - [ ] **Contrastive objective**, as an alternative or addition to the
253
+ reconstruction target the boosting step is currently fitted against.
254
+ - [ ] **Stochastic gradient boosting** ([Friedman
255
+ 2002](https://doi.org/10.1016/S0167-9473(01)00065-2)): subsample the cells at
256
+ each boosting step, which is both a regularizer and a route to cheaper
257
+ iterations on large datasets.
258
+
207
259
  ## Contributing
208
260
 
209
261
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the versioning
@@ -10,9 +10,12 @@ The **Boosting Autoencoder (BAE)** pairs a linear encoder fitted by componentwis
10
10
  L2 boosting with an MLP decoder trained by gradient descent. Each training
11
11
  iteration takes a gradient step on the latent code itself and hands the result to
12
12
  the boosting fit as a regression target, so the encoder is fitted against the
13
- negative gradient of the reconstruction loss rather than by backpropagation.
14
- Componentwise boosting adds one gene at a time and shrinks each step, which keeps
15
- the encoder weights sparse by construction rather than by a post-hoc threshold.
13
+ gradient-updated latent code rather than by backpropagation. The target is that
14
+ updated code rather than the gradient alone because the encoder is rebuilt from
15
+ zero every iteration: it has to reproduce where the code should be, not the
16
+ correction to where it already is. Componentwise boosting adds one gene at a time
17
+ and shrinks each step, which keeps the encoder weights sparse by construction
18
+ rather than by a post-hoc threshold.
16
19
 
17
20
  Each latent dimension is therefore a short, signed gene list, and `X_bae` is
18
21
  exactly `X @ varm["BAE_encoder_weights"]`.
@@ -72,16 +75,26 @@ adata.obsm["X_bae"] # (n_cells, 10) latent space
72
75
  adata.varm["BAE_encoder_weights"] # (n_genes, 10), sparse
73
76
  ```
74
77
 
75
- A single fit gives one gene list, and that list is **not reproducible**. In a
76
- high-dimensional feature space with strongly correlated genes the encoder support
77
- is not identifiable: many different sparse gene sets reconstruct the data about
78
- equally well, and a fit returns one of them.
78
+ To integrate over a batch or any other unwanted covariate, name the obs column:
79
79
 
80
80
  ```python
81
- res = model.stability_selection(adata)
82
- genes = [adata.var_names[res.stable_support[:, j]] for j in range(res.frequency.shape[1])]
81
+ model.fit(adata, batch_key="batch") # or ["batch", "donor"]
82
+
83
+ adata.uns["bae"]["latent_obs_r2_per_dim"] # near zero means it worked
83
84
  ```
84
85
 
86
+ By default this both conditions the decoder on the covariate and adds it to the
87
+ boosting design as a mandatory regressor, so a batch-correlated gene is not
88
+ selected *because of* the batch. The covariate is never an encoder input, so
89
+ `transform` stays gene-only and needs no batch labels on new data.
90
+
91
+ A single fit gives one gene list, and that list is **not reproducible**: in a
92
+ high-dimensional feature space with strongly correlated genes the encoder support
93
+ is not identifiable, so many different sparse gene sets reconstruct the data about
94
+ equally well and a fit returns one of them. See
95
+ [gene selection](https://niklasbrunn.github.io/structboost/guide/tasks/gene-selection.html)
96
+ before trusting a single list.
97
+
85
98
  Componentwise L2 boosting on its own, no autoencoder involved:
86
99
 
87
100
  ```python
@@ -98,11 +111,26 @@ Each of these has a guide page.
98
111
  | | |
99
112
  | --- | --- |
100
113
  | [Batch integration](https://niklasbrunn.github.io/structboost/guide/tasks/batch-integration.html) | `batch_key` names the covariate and `batch_integration_mode` chooses whether it conditions the decoder, protects gene selection, or both. `transform` stays gene-only and needs no batch labels. |
101
- | [Transfer](https://niklasbrunn.github.io/structboost/guide/tasks/transfer.html) | Carry a trained encoder matrix onto a new dataset with `from_reference`, aligned by gene name, with the prior programs frozen. |
114
+ | [Mandatory features](https://niklasbrunn.github.io/structboost/guide/tasks/gene-selection.html) | `mandatory_genes` puts known markers in boosting's unpenalized adjustment block, so they are never subject to competitive selection. Flat or per latent dimension. It forces them into the *specification*, not into the fitted support. |
115
+ | [Reading a fit](https://niklasbrunn.github.io/structboost/guide/tasks/reading-dimensions.html) | `plot_latent_dimensions` draws one row per latent dimension — the sorted score curve, the per-gene contributions, and the scores split by any grouping — plus a UMAP grid and a dimension-correlation heatmap. Genes are ranked by their exact share of a dimension's variance, not by coefficient size. |
102
116
  | [Persistence](https://niklasbrunn.github.io/structboost/guide/tasks/persistence.html) | `save` and `load` a fitted model as one checkpoint, readable with `weights_only=True`. |
103
117
  | [Interpretation](https://niklasbrunn.github.io/structboost/guide/tasks/interpreting.html) | Ranked gene lists per dimension, stored functional annotations, and a self-contained interactive HTML explorer. |
104
118
  | [Simulation](https://niklasbrunn.github.io/structboost/guide/tasks/simulating.html) | Negative-binomial counts with planted gene programs and a cell-type hierarchy, so marker recovery can be scored against ground truth. |
105
119
 
120
+ ## Exploratory features
121
+
122
+ The whole package is pre-1.0, but these four are **under active development** and
123
+ less settled than the rest. They work, and each is documented with what is known
124
+ about it — but their behaviour, defaults and APIs are more likely to change, and
125
+ results from them warrant more scepticism than the core fit does.
126
+
127
+ | Feature | Why it is still exploratory |
128
+ | --- | --- |
129
+ | **Stability selection** (`BAE.stability_selection`) | Provides **no formal error control** — `expected_false_positives` is deliberately `NaN`, because training iterations are neither independent nor exchangeable. Per-dimension frequencies are only meaningful when dimensions keep their identity, which `dim_match_quality` reports and does not guarantee. |
130
+ | **Disentanglement** (`disentanglement=`) | On by default since 0.5.0, and both methods are provisional. Decorrelation is an extra constraint that real gene programs do not satisfy, so it costs biological structure — measured at marker-recovery F1 0.98 to 0.88 on simulated data. `disentanglement_alpha` softens it; `"none"` turns it off. |
131
+ | **Starting from an existing representation** (`init_pca`, `init_obsm`) | The warm start is applied once, on the first iteration, and silently overrides `latent_dim` if the supplied representation is a different width. It also depends on the decoder having enough steps in that first iteration to follow it — on few cells at the default `batch_size` the effect reverses. |
132
+ | **Starting from a prior encoder matrix** (`BAE.from_reference`) | Transfer works, but the two latent blocks land on **incomparable scales** — measured at a 232× gap in per-dimension standard deviation — so anything Euclidean must be handed `obsm["X_bae_scaled"]` rather than `X_bae`. `novel_variance_share` is not evidence of novel biology on the fitting data. |
133
+
106
134
  ## Citation
107
135
 
108
136
  If you use the **BAE**:
@@ -129,6 +157,27 @@ Everything committed was reviewed, and the behavioural claims in the docstrings
129
157
  and the user guide are backed by the test suite or by the measurements cited
130
158
  alongside them.
131
159
 
160
+ ## Open points
161
+
162
+ Known gaps and planned work.
163
+
164
+ - [ ] **Revise the early-stopping criterion.** Early stopping is off by default
165
+ because the training-loss rule is a convergence check being used as a quality
166
+ check, and it stops well before gene selection has settled. No replacement has
167
+ been found yet.
168
+ - [ ] **Stability selection.** Iteration mode carries no formal error control, and
169
+ its per-dimension frequencies are only interpretable when `dim_match_quality` is
170
+ high. A scheme with a defensible bound under a model fitted on the same cells is
171
+ still open.
172
+ - [ ] **Multimodal architecture** — reconstruction-based, with a shared latent
173
+ space across modalities, for paired single-cell data.
174
+ - [ ] **Contrastive objective**, as an alternative or addition to the
175
+ reconstruction target the boosting step is currently fitted against.
176
+ - [ ] **Stochastic gradient boosting** ([Friedman
177
+ 2002](https://doi.org/10.1016/S0167-9473(01)00065-2)): subsample the cells at
178
+ each boosting step, which is both a regularizer and a route to cheaper
179
+ iterations on large datasets.
180
+
132
181
  ## Contributing
133
182
 
134
183
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the versioning
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "structboost"
7
- version = "0.4.0"
7
+ version = "0.6.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" }
@@ -41,6 +41,16 @@ bae = [
41
41
  plot = [
42
42
  "matplotlib>=3.7",
43
43
  ]
44
+ # scanpy, for `plot_dimension_gene_umaps` alone. It draws through `sc.pl.umap` so
45
+ # its panels match a scanpy figure rather than approximating one, and it is
46
+ # imported inside that function: without it the function raises naming
47
+ # `plot_latent_dimensions` as the matplotlib-only alternative, and nothing else in
48
+ # the package notices. Kept out of `plot` deliberately -- scanpy pulls a far larger
49
+ # tree than matplotlib, and the other plot functions do not need it.
50
+ scanpy = [
51
+ "scanpy>=1.9",
52
+ "structboost[plot]",
53
+ ]
44
54
  # Parquet encoder-weight files (imported lazily; CSV needs only pandas)
45
55
  io = [
46
56
  "pandas>=1.5",
@@ -56,9 +56,14 @@ __all__ = [
56
56
  "encode_obs_covariates",
57
57
  "export_interactive_html",
58
58
  "extract_gene_rankings",
59
+ "gene_variance_shares",
59
60
  "linear_ceiling",
60
61
  "looks_like_ensembl",
62
+ "palette_audit",
61
63
  "plot_boosting_coefficient_paths",
64
+ "plot_dimension_correlation",
65
+ "plot_dimension_gene_umaps",
66
+ "plot_latent_dimensions",
62
67
  "plot_top_boosting_coefficients",
63
68
  "plot_training_diagnostics",
64
69
  "read_encoder_weights",
@@ -70,9 +75,17 @@ __all__ = [
70
75
  "write_encoder_weights",
71
76
  ]
72
77
 
78
+ #: Resolved lazily through `__getattr__`. `gene_variance_shares` and
79
+ #: `palette_audit` live in `_plotting` beside their callers but need no
80
+ #: matplotlib -- that module imports it inside the drawing functions only.
73
81
  _PLOT_FUNCTIONS = frozenset(
74
82
  {
83
+ "gene_variance_shares",
84
+ "palette_audit",
75
85
  "plot_boosting_coefficient_paths",
86
+ "plot_dimension_correlation",
87
+ "plot_dimension_gene_umaps",
88
+ "plot_latent_dimensions",
76
89
  "plot_top_boosting_coefficients",
77
90
  "plot_training_diagnostics",
78
91
  }