Transferring a model to a new dataset¶
Exploratory
Under active development. Alignment, freezing and the diagnostics all work, but
the latent-scale problem below is a live limitation rather than a settled
design: the prior and novel blocks land on incomparable scales, so every
Euclidean consumer has to be handed obsm["X_bae_scaled"] instead of X_bae.
The transferable product of a BAE fit is its encoder matrix: k sparse gene
programs. Not the decoder, not the latent space, but the gene programs.
from_reference() aligns such a matrix to a new gene panel
by gene name, freezes it, and adds dimensions for whatever the prior cannot
explain.
End to end¶
from structboost import BAE, write_encoder_weights
# 1. Export the reference (or pass the fitted model / its AnnData directly)
write_encoder_weights(
reference_model.get_encoder_weights(),
"reference.parquet",
gene_ids=reference_adata.var["gene_ids"],
gene_symbols=reference_adata.var_names,
metadata={"species": "mus_musculus", "annotation_release": "GRCm39.110"},
)
# 2. Build the transfer model against the target panel
model = BAE.from_reference("reference.parquet", target, n_additional_dims=5)
# 3. Fit, settling the decoder against the prior first
model.fit(target, decoder_warmup_epochs=20)
# 4. Read what the new dimensions found
target.uns["bae_transfer"]["novel_variance_share_per_dim"]
model.novel_weights
# 5. Validate on held-out cells
model.transfer_diagnostics(heldout)
from_reference accepts a fitted BAE, an AnnData carrying
varm["BAE_encoder_weights"], a gene-indexed DataFrame, or a .parquet / .csv
path. It rejects a bare array. Without gene identifiers, aligning it to
another panel would be guesswork.
The target AnnData is required at construction, not at fit, because the encoder’s shape depends on the gene panel and because a coverage failure should surface before any training happens.
Gene alignment¶
By default every identifier set present on both sides is tried and the one
matching the most genes wins, so a reference keyed on Ensembl accessions aligns
to a symbol-indexed dataset without intervention. The choice is recorded in
uns["bae_transfer"]["join_key"]. Set join_on= explicitly when a dataset
carries several identifier columns and the automatic choice must not be trusted.
min_coverage=0.5 is the minimum share of a prior dimension’s absolute weight
mass that must be present in the target. Below it, the transfer raises rather
than silently returning an attenuated program.
Frozen or anchored¶
prior_mode="frozen"(default)The prior columns are never boosted. They stay bitwise equal to the reference matrix, so the transferred programs are literally the reference’s.
prior_mode="anchored"The prior columns are boosted too, but each iteration restarts from the fixed original matrix rather than the previous iteration’s result. This is boosting from an offset model in the sense of Bühlmann & Hothorn (2007). Deviation is bounded by
boosting_stepnoand never accumulates.
There is no third option. Unanchored re-fitting is not offered because a prior used merely as a starting point is forgotten within a few hundred iterations, the encoder support random-walks, and two runs end no more similar than two unrelated ones.
Sizing n_additional_dims¶
The default of 5 is a pragmatic starting point, not something derived from your data. How much residual structure a dataset holds is not knowable in advance.
Erring high is the cheaper mistake under prior_mode="frozen": the transferred
programs stay bitwise fixed no matter how many dimensions you add. Too small a
value silently misses structure. 0 is valid and means decoder-only adaptation.
Afterwards, read novel_variance_share_per_dim and the novel dimensions’
stability, a dimension contributing almost nothing is surplus.
Hyperparameters are not inherited¶
latent_dim is the only setting taken from the reference, derived as the prior’s
dimensions plus n_additional_dims. Everything else comes from
BAEConfig defaults unless you pass config= yourself, so a
reference tuned to boosting_stepno=50, max_iterations=200 transfers at 50 and
1000, and a reference fitted with seed=42 transfers unseeded.
# Inherits nothing but latent_dim, and says so.
model = BAE.from_reference(reference_model, target, n_additional_dims=5)
# Carries the settings over.
model = BAE.from_reference(
reference_model,
target,
n_additional_dims=5,
config=BAEConfig(latent_dim=15, boosting_stepno=50, max_iterations=200, seed=42),
)
The first form warns and names the fields that differed. It warns only for a model reference, because a Parquet or CSV prior carries no configuration to compare against, which is also why the settings are not inherited automatically: the same prior would otherwise behave differently depending on whether you passed the model or its exported weights.
decoder_warmup_epochs¶
Trains the decoder against the frozen prior programs before boosting starts.
This is what makes the added dimensions genuinely residual: until the decoder
has converged against the prior, ∂L/∂z still carries signal those programs could
explain, and the new dimensions would simply re-learn it.
The warm-up runs outside the training loop, deliberately. Inside it, it would win
checkpoint selection, and fit would restore an encoder whose novel columns are
all zero, returning the prior unchanged, with no error, reading as “no novel
structure found”. Its loss is recorded separately as
training_history["warmup_loss"] so train_loss stays comparable with an
ordinary fit.
What a transfer fit rejects¶
Three hard errors, each for a reason:
split_softmaxapplies one softmax across all 2·
latent_dimentries, so adding dimensions dilutes every prior entry by a data-dependent amount. A frozen matrix would no longer mean the prior programs act unchanged.init_pca/init_obsmthey override the latent state on the first boosting iteration, which is exactly where the prior programs define the target. Use
decoder_warmup_epochsinstead.decoder_warmup_epochson a non-transfer modelthere is no prior to warm against. Use
init_pretrain_epochs.
Reading the diagnostics¶
target.uns["bae_transfer"]
Carries the alignment provenance (join_key, n_matched_genes,
prior_coverage), support sizes split into prior and novel blocks, and the
variance accounting.
Danger
novel_variance_share on the fitting data is not evidence of novel biology.
It is the rise in reconstruction MSE when the added dimensions are zeroed, and
k free dimensions always reduce in-sample error, so the number is positive
even when the target contains nothing the prior missed. Measured on gene-shuffled
data with no recoverable structure at all, it still reads +0.002 to +0.008 as k
grows from 1 to 4. It also grows with k under the null, so raw values are not
comparable across different n_additional_dims.
Use transfer_diagnostics() on held-out cells. Capacity
that merely fits noise does not generalize: a novel dimension carrying real
structure keeps its share out of sample, one that does not collapses. This needs
nothing but the model and your own data. It needs no access to the reference dataset and no
marker ground truth, which matters, because a prior encoder matrix is often all
that gets shared.
novel_variance_share_per_dim entries do not sum to the aggregate: the dimensions
are not orthogonal, so structure carried by several is counted in each.
Support sizes are reported separately for prior and novel blocks because the union is dominated by the prior, a sparsity check against the combined count would be measuring the reference matrix, not this fit.
Use X_bae_scaled for anything Euclidean¶
Warning
A transfer’s two blocks are on incomparable scales. Hand-authored prior coefficients are written on a human scale, while boosted ones land wherever the gradient targets put them. Measured on one transfer, prior dimensions had a median latent SD of 9.98 against 0.043 for the novel ones, a 232× gap, and because distance is squared, the novel block contributed ~0.00% of the total.
Every Euclidean consumer inherits that: neighbour graphs, Leiden, kNN, kBET,
LISI. Hand them obsm["X_bae_scaled"].
X_bae is deliberately left as the raw projection, because that is what makes
the encoder auditable and the frozen-prior guarantee checkable. The centering
cannot be folded back into the weights anyway, since the encoder has no bias term.
The scaling statistics are estimated on the fitting data and reused by
transform, never re-estimated: new cells must go through the same map, and a
small query set would estimate its own moments badly.
Encoder weight files¶
write_encoder_weights() /
read_encoder_weights() are the file form of a prior.
Parquet is the recommended format. Beyond preserving dtypes and the index, it
is immune to the spreadsheet round-trip that silently rewrites gene symbols such
as SEPT2, MARCH1 and DEC1 as dates. CSV/TSV is accepted on read for interoperability.
At least one identifier column is required: an encoder matrix without gene
identifiers cannot be aligned to another dataset, which is the only reason to
write one out. gene_id (Ensembl) is the default join key. gene_symbol is
carried but never authoritative, since symbols are revised between releases and
collide through aliases.
Add the species and the annotation release to metadata: without the release,
Ensembl accessions are only probably joinable to another dataset.