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.
- {structboost-0.4.0 → structboost-0.6.0}/CHANGELOG.md +228 -0
- {structboost-0.4.0 → structboost-0.6.0}/CITATION.cff +2 -2
- {structboost-0.4.0 → structboost-0.6.0}/PKG-INFO +63 -11
- {structboost-0.4.0 → structboost-0.6.0}/README.md +59 -10
- {structboost-0.4.0 → structboost-0.6.0}/pyproject.toml +11 -1
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/__init__.py +13 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_model.py +137 -44
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_persistence.py +10 -2
- structboost-0.6.0/src/structboost/_plotting.py +1608 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_types.py +94 -22
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_utils.py +67 -30
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae.py +196 -59
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_init.py +10 -2
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_persistence.py +24 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_transfer.py +35 -2
- structboost-0.6.0/tests/test_plotting_dimensions.py +506 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_stability.py +112 -11
- structboost-0.4.0/src/structboost/_plotting.py +0 -301
- {structboost-0.4.0 → structboost-0.6.0}/.gitignore +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/CODE_OF_CONDUCT.md +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/CONTRIBUTING.md +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/LICENSE +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/conftest.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_annotation.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_boosting.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_decoder.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_encoder.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_explorer.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_io.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_simulation.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/_stability.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/src/structboost/py.typed +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_allboost.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_annotation.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_diagnostics.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_bae_layer.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_batch_integration.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_covcache_precompute.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_explorer.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_linear_ceiling.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_obs_encoding.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_public_api.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_resolve_mandatory.py +0 -0
- {structboost-0.4.0 → structboost-0.6.0}/tests/test_rng_isolation.py +0 -0
- {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.
|
|
6
|
-
date-released: "2026-08-
|
|
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.
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
| [
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
| [
|
|
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.
|
|
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
|
}
|