Skip to content

Fused-trunk multitask model with a low-rank per-setup head - #114

Merged
RobbinBouwmeester merged 9 commits into
mainfrom
feat/multitask-flexcnn-v7b
Aug 20, 2026
Merged

Fused-trunk multitask model with a low-rank per-setup head#114
RobbinBouwmeester merged 9 commits into
mainfrom
feat/multitask-flexcnn-v7b

Conversation

@RobbinBouwmeester

Copy link
Copy Markdown
Member

What this adds

FlexCNNMultitaskModel, an alternative multitask backbone, plus the model trained on it and the loading machinery it needs.

The existing MultitaskDeepLCModel runs four independent branches over four feature arrays and concatenates the flattened results (1,484 values). This one passes atom counts through a pointwise stem, concatenates a learned residue embedding, runs a convolutional stack, and pools over the valid length. Pooling instead of flattening also makes it length-agnostic.

Accuracy

Same training data, same rank-64 head, same 60 epochs, so only the encoder differs:

test MAE median AE p95 AE per-setup median
four-branch (deeplc_fixed) 1.2625 0.5053 3.9310 1.5344
fused trunk (flex_cnn_fixed) 0.8790 0.2629 2.6329 1.0753

Better on 98.0 % of the 6,541 LC setups with more than 20 test observations, median per-setup ratio 0.71. The bundled model is the same architecture trained 200 epochs: 0.8233 min MAE, 0.2370 min median, 2.4464 min p95.

The head

FactorHead computes pred[:, j] = (proj(trunk) · embedding[j]) * scale[j] + shift[j]. The projection is shared, so a setup owns rank + 2 parameters — 66 at rank 64. Adding an LC setup is a 66-value fit with the encoder frozen, against roughly 1.7 M parameters for an adapter MLP over a 6,543-wide head vector.

I have not wired this into calibrate(). The model works through the existing path (predict the full matrix, pick the best-correlating head, fit a spline), and adapting calibration to fit the 66 parameters directly is a natural follow-up but a separate change.

Self-describing checkpoints

A checkpoint may now record architecture, encoder_kwargs, head_kwargs, feature_spec and target_units, so load_model builds the right class instead of inferring it from tensor shapes. Bare state dicts still load unchanged, and there is a regression test for that.

This is load-bearing rather than cosmetic. The trunk fixes the width of the global feature vector: this model needs the 67-dimensional form produced with add_terminal_composition=True, and the 55-dimensional default must fail on shape rather than silently mispredict. So predict() now loads the model before encoding features, and DeepLCDataset gained add_terminal_composition.

Units

Training normalises each setup's retention times, so a raw checkpoint predicts in normalised space. Both transforms are affine and compose exactly, so packaging folds the normalisation into scale and shift; the shipped model returns minutes with no side-car arrays. Worth noting the existing multitask_model.pt returns roughly 0–1.3 on the same peptides and needs calibration to become minutes, whereas this one does not.

Not the default

DEFAULT_MODEL is unchanged. The new file is exposed as deeplc.core.FLEXCNN_MULTITASK_MODEL. Switching the default would change every prediction and should be a deliberate decision, ideally after calibration exploits the low-rank head.

Testing

tests/test_flexcnn.py, 18 tests, and the existing 68 still pass (86 total). ruff check clean on deeplc/ and the new test file.

Beyond the unit tests, two checks that seemed worth doing before trusting the port:

Numerical equivalence. The same weights loaded into this implementation and into the training implementation, run on identical inputs. Differences were exactly zero across peptide lengths 1, 2, 3, 8–40, and 60, and for a batch mixing 1 to 60. The port reorganises the module tree, drops unused options and takes one-hot input where the original took integer indices, so this was measured rather than assumed.

End to end through DeepLC's own extractor, on 2,400 held-out test peptidoforms from six well-populated setups:

setup n MAE median r
Bruderer2017_HeLa 400 1.9189 0.9667 0.9893
PXD052082 400 0.6620 0.4103 0.9995
PXD061539 400 0.2290 0.1599 0.9998
PXD062679 400 0.8618 0.4496 0.9993
PXD064265 400 0.3544 0.2434 0.9998
PXD077854 400 0.0879 0.0607 0.9998
pooled 2400 0.6857 0.2521

Consistent with the run's own figures, which it would not be if the feature contract, the one-hot to index conversion or the folded scaling were wrong.

Things a reviewer should push back on

  • A 10.7 MB binary in the repo. Consistent with multitask_model.pt at 23 MB, but if models should move out of git this is the moment to say so.
  • v4.0.1 dependency. The 67-dimensional vector requires the terminal-composition work from fix(features): correct positional modification indexing, and optional terminal composition #112. Verified bit-identical to the reference implementation for peptides of length ≥ 4; for length 2–3 the two differ, because the positional rows overlap at that length. That is 78 of 10,115,525 training peptidoforms (0.001 %), and the shipped model was trained with the reference behaviour, so 2–3-mers are slightly off-distribution.
  • x_atom_sum is accepted and ignored, to keep the signature interchangeable with MultitaskDeepLCModel. A cleaner signature would break that interchangeability; I picked compatibility, but it is a judgement call.
  • One seed. No repeat runs, so the architecture gap has no error bar. At 1.44× on test MAE that is unlikely to matter, but it is not measured.

🤖 Generated with Claude Code

RobbinBouwmeester and others added 2 commits August 20, 2026 11:14
Adds FlexCNNMultitaskModel, which merges atomic composition with a learned
residue embedding in one convolutional trunk and pools over the valid length,
rather than running four branches and flattening each. On the same training
data, the same rank-64 head and the same 60 epochs it reaches 0.879 min test
MAE against 1.263 for the four-branch backbone, improving 98.0 % of the 6,541
LC setups compared. The bundled model, trained for 200 epochs across 6,543
setups, reaches 0.823 min MAE and 0.237 min median.

The head is FactorHead: a shared projection dotted with a per-setup embedding,
so a setup owns rank + 2 parameters. Adding an LC setup is a 66-value fit with
the encoder frozen, rather than training a head or fitting an adapter over the
full head vector.

Checkpoints may now describe themselves, recording architecture, constructor
arguments, feature specification and target units. Loading no longer infers the
architecture from tensor shapes, and predict() consults the model before
encoding so a model gets the features it was trained on. This matters because
the trunk fixes the width of the global feature vector: the bundled model needs
the 67-dimensional form with terminal composition, and feeding it the
55-dimensional default has to fail on shape rather than silently mispredict.
Bare state dicts continue to load unchanged.

Target scaling is folded into the head at packaging time. Training normalises
each setup's retention times, so a raw checkpoint predicts in normalised space;
both transforms are affine and compose exactly, so the shipped model returns
minutes with no side-car arrays.

The model is not made the default, since switching would change every
prediction and the calibration path has not yet been adapted to exploit the
low-rank head.

Verified by loading the same weights into this implementation and the training
implementation and comparing outputs on identical inputs: bit-identical across
peptide lengths 1 to 60. End-to-end through DeepLC's own feature extraction on
2,400 held-out test peptidoforms from six setups gives 0.686 min MAE and
0.252 min median, with Pearson r from 0.989 to 0.9998.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs `ruff format --check`, not only `ruff check`, and I had verified the
latter. Two constructs in core.py and one in the new test were wrapped across
lines that fit inside the 99-character limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RobbinBouwmeester

Copy link
Copy Markdown
Member Author

Thanks — this was a good review. All six confirmed and fixed in 5b14ad3. I reproduced each before changing anything rather than fixing from the description.

1. Full task matrix on normal predict — fixed. Confirmed the arithmetic: 6,543 columns at a million peptides is ~26 GB of output the caller discards, and _predict_epoch accumulating per-batch tensors before torch.cat roughly doubles the peak. task_idx now threads through _model_ops.predict and _predict_epoch, and return_matrix=False requests a single column. Support is detected from the forward signature via supports_task_subset(), so the four-branch model is untouched. A test asserts the model is actually asked for one column, not that the output merely has one.

I did not add a public head=/task= argument. It is the right idea, but choosing a setup by name needs the task_names list surfaced properly, and that felt like a separate change. predict_kwargs={"task_idx": [...]} works in the meantime.

2. save_model round-trip — fixed, and it did fail exactly as described.

KeyError: 'heads.b2'

Rather than teaching save_model about each architecture, the model now describes itself: FlexCNNMultitaskModel.describe() returns architecture, constructor arguments, feature spec, target units, task names and weights, and save_model uses it when present. Your point about the test was the important one — the original tests hand-built the described dictionary and so could never have caught this. The new test goes through the public save_model and then predict.

3. finetune — now rejects explicitly. It raises NotImplementedError naming the model and pointing at predict() with calibrate(). I chose rejection over implementing the 66-parameter fit because that deserves its own PR with its own tests, and you are right that AttributeError: ... has no attribute add_adapter is not an acceptable public failure. Agreed the architecture currently promises something no public path delivers; that is the follow-up.

4. Device — fixed. Taken from predict_kwargs before loading, so a caller asking for CPU no longer has the checkpoint placed on a GPU first. Test spies on load_model to assert the device reaches it.

5. feature_spec partially honoured — padding_length now threaded through DeepLCDataset to encode_peptidoform. Your reasoning for why this one is dangerous rather than merely incomplete is exactly right and I have put it in the code: because the trunk pools rather than flattens, a mismatch changes the representation without changing any shape, so it fails silently. The remaining encode_peptidoform parameters (positions, dict_index, and the other vocabulary arguments) are still not reconstructed from the spec — those describe the encoding vocabulary rather than its layout, and passing them through means deciding how to serialise dictionaries in a checkpoint. I have left them out deliberately rather than by oversight.

6. The padding test was vacuous — you are right, and it was worse than useless. padded = [t.clone() for t in short] compared a prediction against itself while the docstring claimed otherwise, so it read as coverage of the masking path while testing nothing. It now places the same ten residues in a length-20 and a length-60 array.

Bundled-model test added, as suggested: asserts 6,543 tasks, task_names length, target_units == "minutes", finite predictions, minute-scale range, and that the single-column path matches column 0 of the matrix. I did not pin exact prediction values — it would catch drift, but would also fail on any legitimate retrain, and the range plus scale assertions catch the packaging failures I was actually worried about. Happy to pin them if you would rather have the tighter check.

93 tests pass, ruff check and ruff format --check clean.

RobbinBouwmeester and others added 3 commits August 20, 2026 11:50
Six issues from review, all confirmed before fixing.

Normal prediction no longer evaluates every task. predict(return_matrix=False)
built a column per LC setup and then discarded all but one: for 6,543 setups
that is roughly 26 GB of intermediate output at a million peptides, made worse
by _predict_epoch accumulating per-batch tensors before concatenating.
task_idx now threads through _model_ops.predict and _predict_epoch, and the
single-column path requests one column. Support is detected from the forward
signature, so models without it are unaffected.

save_model produced a checkpoint load_model could not read. It wrote a bare
state dict for every model, and the loader then fell back to inferring the
architecture, failing with KeyError: 'heads.b2'. Serialisation is now the
model's own business: FlexCNNMultitaskModel.describe() returns architecture,
constructor arguments, feature spec, target units, task names and weights, and
save_model uses it when present. Confirmed by a test that goes through the
public save_model rather than hand-building the dictionary.

finetune() called add_adapter() unconditionally, which this architecture does
not implement, so it raised AttributeError. It now raises NotImplementedError
naming the model and pointing at predict() with calibrate(). Fitting the
per-setup parameters directly is the better answer and remains follow-up work.

predict() loaded the model before reading the requested device, so asking for
CPU on a CUDA machine placed the checkpoint on the GPU first and could fail
with an out-of-memory error for a caller who wanted neither. The device is now
taken from predict_kwargs before loading.

Only two of the recorded feature flags were honoured. padding_length is now
threaded through DeepLCDataset to encode_peptidoform, which matters here
because the trunk pools rather than flattens: a mismatch changes the
representation without changing any shape, so it would not raise.

test_padding_does_not_change_prediction did not test padding. It cloned the
same tensors and compared a prediction against itself, so it could not have
caught a masking regression. It now places the same ten residues in a length-20
and a length-60 array.

Adds tests for each of the above, and one against the bundled checkpoint rather
than only the synthetic model, covering task count, task-name length, target
units and prediction scale. That last one catches packaging drift the synthetic
round-trip cannot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…k head

finetune() previously refused this architecture, pointing at calibration instead.
It now fits the new setup's own rank + 2 parameters with the encoder and every
pretrained setup frozen: 66 values at rank 64, against roughly 1.7 million for an
adapter over a 6,543-wide head vector. That was the stated reason for the head's
design and it had no public path until now.

FactorHead.add_task() attaches the new setup's parameters as separate tensors
rather than extra rows, so freezing the pretrained setups is a matter of
requires_grad and needs no per-row gradient masking. Output collapses to one
column while a new task is attached, so the training loop and predict() need no
special case.

Two details that are wrong in the obvious form and are documented in place:

The affine part is initialised from the trained setups' own scale and shift, not
from the target retention times. scale multiplies a dot product in the normalised
space the model was trained in, roughly 0 to 100, so seeding it with a spread
measured in minutes overshoots about thirtyfold; on a held-out setup that put the
first prediction 600 minutes out.

Sixty-six parameters need a much larger step than the whole-network default. At
lr 1e-3 the fit is still well short after 25 epochs, 1.32 min against 0.86 on a
held-out setup, so this path defaults to 0.05.

finetune() also now builds its datasets from the model's recorded feature
specification, as predict() already did, and loads the model once instead of twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Benchmarked on six LC setups from PRIDE projects submitted after the training
crawl, so unseen by construction. Fine-tuning was worse than calibration below
roughly 500 reference peptidoforms and better above 700, monotone across the six
with no exceptions. The worst case took a 133-minute gradient with 230 reference
peptides from 1.47 to 91.7 minutes.

The mechanism is not overfitting. The affine part of the head collapses: the
output range shrank to 17 minutes against a 133-minute gradient and every
prediction piled up near the mean retention time, while the correlation stayed
above 0.9 because the peptide ordering was never the problem. scale decays toward
zero and shift absorbs the mean.

scale and shift are linear in the prediction, so for a given embedding the best
values follow in closed form. They are now solved by least squares on the
reference data before training rather than learned, which both prevents the
collapse and gives the embedding a sane error to descend from. On the setup above
that takes the fine-tuned error from 11.4 to 2.2 minutes for the fused-trunk
model, with the correlation back to 0.975.

Also warns below MIN_FINETUNE_REFERENCE and widens the validation split there,
since the default 10 % left 23 PSMs to early-stop against on that setup, which is
noise rather than a stopping signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RobbinBouwmeester

RobbinBouwmeester commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Held-out benchmark, and two things to look at before 4.1.0

Benchmarked this against the two released versions on LC setups none of them was trained on. Every project is numbered above PXD079334, the highest accession the training crawl reached, so they are unseen by construction rather than by filtering a list. Six setups, one per project, the deepest run of each, across four search engines.

Each version runs with its own model and its own feature code, since that is what installing it gives you. 4.0.0 and 4.0.1 ship byte-identical weights (sha256 6d9faa9a…) and differ only in _features.py, so that pair isolates the feature change exactly.

Results, pooled over all scored observations

metric 4.0.0 cal 4.0.0 ft 4.0.1 cal 4.0.1 ft PR cal PR ft
mean AE 1.2695 3.3175 1.6349 3.3790 0.6392 0.8317
median AE 0.5316 0.5059 0.5699 0.4959 0.3917 0.3102
p95 AE 5.0168 4.3443 7.6123 4.4424 1.8413 2.1380
Pearson r 0.9916 0.6613 0.9872 0.6485 0.9979 0.9873
median AE, modified 1.1122 0.7957 1.7039 0.8053 0.4764 0.4165

Paired over setups, the PR model calibrated is at 0.809× 4.0.0 calibrated, better on 83%. It also beats 4.0.0's fine-tuned result while merely calibrated, so the architecture is doing the work rather than the adaptation.

1. finetune() could return a much worse model, and now does not

This is the one that matters for a release. Fine-tuning per setup, against reference-set size:

project reference PSMs 4.0.0 cal → ft PR cal → ft
PXD081880 230 1.472 → 91.709 1.280 → 11.367
PXD079795 300 0.373 → 0.484 0.229 → 0.410
PXD079655 735 1.131 → 1.065 1.192 → 1.107
PXD081924 1,103 0.439 → 0.414 0.364 → 0.273
PXD079927 2,056 3.196 → 1.715 0.895 → 0.821
PXD080826 4,133 0.611 → 0.578 0.481 → 0.381

Monotone, no exceptions: worse below ~500 reference peptidoforms, better above ~700.

The mechanism is not overfitting. On PXD081880 the fine-tuned output range collapsed to 17 minutes against a 133-minute gradient, with every prediction near the mean RT, while r stayed at 0.92 — the peptide ordering was never the problem, the scale was. scale decays toward zero and shift absorbs the mean.

Since scale and shift are linear in the prediction, fb5ff0e solves them by least squares on the reference data before training instead of learning them. On that setup the fused-trunk model goes from 11.4 to 2.2 minutes with r back to 0.975. It also warns below MIN_FINETUNE_REFERENCE = 500 and widens the validation split there, because the default 10% left 23 PSMs to early-stop against, which is noise.

I have only applied the least-squares anchor to the low-rank head. The adapter path is still exposed to the same failure — 4.0.0 went to 91.7 min and I have not touched that code. Worth deciding whether 4.1.0 should guard it too.

2. 4.0.1 looks like a regression on modified peptides

Reproduced across three independent designs (30 setups, 18 setups, 6 setups): median ratio 1.017 against 4.0.0, better on one of six setups. Worst on the TMT phosphopeptide project, 3.196 → 4.686 min calibrated, +47%.

Unmodified peptides are unaffected — the two agree to within 0.005 min. Comparing feature output directly, matrix_global is identical between the versions for unmodified peptides and differs for modified ones, same length so nothing raises. Combined with byte-identical weights, that means the corrected features are being fed to a model trained on the uncorrected ones.

The correction itself looks right; what is missing is a retrain. I would treat this as needing an independent check before acting — it is one benchmark, six setups, one seed — but if it holds it argues for retraining the bundled model against the corrected features rather than shipping the correction alone.

Caveats I would not drop

  • Six setups, one split, one seed. The per-setup table is the robust part; the pooled decimals are not.
  • Fine-tuning is unstable run to run. PXD081924 gave 3.30 min in one run and 0.27 in another, same data, same 30 epochs, no seed set.
  • Two datasets excluded, for stated reasons. PXD082486 gives r ≈ 0.35 for every version, and reading unmodified peptides straight from its source SQLite with no conversion still gives r = 0.565, so its retention times are not predictable by any model. PXD080314 is peptidomics of non-specific cleavage products and sits near 5.8 min for everything.
  • Coverage is bounded by what converted. 11 of 20 downloaded files produced no tasks, including every mzIdentML file and a DIA-NN Parquet report, so this covers mzTab, DIA-NN, FragPipe and MaxQuant only.
  • Effort is not symmetric. Fine-tuning fits ~1.7 M parameters for the released versions and 66 for this one, at the same 30 epochs.

96 tests pass, ruff check and ruff format --check clean.

RobbinBouwmeester and others added 4 commits August 20, 2026 17:21
Three defects compounded, each hiding the next.

train() started with best_val_loss at infinity, so the first epoch always became
the best however bad it was, and the state training began from was discarded. It
now scores that starting point first, which means a fit can never be returned
that is worse than what it started with.

The output layer's scale was learned rather than solved. Both adaptation paths
end in a layer linear in its input, so for given earlier layers the best values
follow in closed form; they are now solved on the reference data before training.

The adapter's ReLU stack is largely dead at PyTorch's default initialisation:
before training its output was 0.0 to 0.1 for every peptide. That leaves the
activations reaching the output layer rank deficient, and torch.linalg.lstsq on
CUDA requires full rank, so the solve above returned non-finite values and
declined by design without saying so. It now uses a ridge-regularised normal
equation on the CPU, which tolerates rank deficiency.

Measured on the held-out setup that failed worst, a 133-minute gradient with 230
reference peptides. The adapter path went from 92.1 minutes to 1.63 and the
low-rank head from 11.4 to 2.05, against 1.47 and 1.28 for calibration. Before
the fix the adapter predicted a 26-minute range for a 133-minute gradient while
keeping a correlation above 0.9, because the peptide ordering was never what
broke.

Also reports, at error level, a fit whose validation error is a large fraction of
the reference retention-time span. A collapsed fit leaves both the loss curve and
the correlation looking unremarkable, so nothing else flags it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add_terminal_composition and padding_length descriptions were spliced into
each other in both DeepLCDataset.__init__ and from_psm_list, so each parameter
carried half of the other one's text and two "Default is" lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e 4.0.1

df4f3f6 corrected where modification deltas land in the positional block: rows
are laid out as sorted(positions), so raw indexing put an N-terminal delta four
residues from the C-terminus, and an if/elif reached only one row where the base
residue occupied two. That correction shipped in 4.0.1 and changes the encoding
of every modified peptidoform, so any model trained before it now sees input it
was not trained on.

legacy_positional_deltas reproduces the old placement for those models. It
threads through encode_peptidoform, DeepLCDataset and feature_spec, defaults to
False, and is a no-op on unmodified peptidoforms.

Verified against a real v4.0.0 checkout: 4,760 feature arrays from 1,190
peptidoforms, covering lengths 2 to 70, every modified position, terminal and
both-terminal modifications, are bit-identical with the flag set. The corrected
path differs from v4.0.0 on 525 of them, all matrix_global, confirming the flag
is the only difference.

End to end, IM2Deep 2.0.2 passing the flag through its single
DeepLCDataset.from_psm_list call reproduces its v4.0.0 CCS predictions exactly
(max |delta| 0 over nine peptidoforms); without it, modified peptides move up to
7.6 A^2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d on

4.0.1 corrected where modification deltas land in the positional block, which
changed the encoding of every modified peptidoform. Every model released up to
that point was trained on the old encoding, so from 4.0.1 they were predicting
modified peptides from input they had never seen, silently and with no error.

The layout a model expects is now resolved from the specification it carries, in
one helper used by predict() and finetune(). A checkpoint with no specification
was written before 4.1.0, hence before the correction, so it gets the old
placement; all five bundled checkpoints are bare state dicts and fall in that
class. A checkpoint that records a specification is read literally, and every
checkpoint this version writes records the encoding it used. The bundled
FlexCNN model now declares legacy_positional_deltas: false, which is what it
was trained with.

DeepLCDataset defaults to the old placement, because a dataset exists to feed a
model and the models in the field expect it. encode_peptidoform keeps
defaulting to the corrected placement, since its job is correct featurisation.
train() passes the corrected placement explicitly, so new models are unaffected.

Verified against a v4.0.0 checkout, both without changing anything downstream:
the default bundled RT model reproduces v4.0.0 predictions exactly on nine
peptidoforms including phospho, oxidation and TMT, and pristine IM2Deep 2.0.2
reproduces its v4.0.0 CCS predictions exactly, where under 4.0.1 they had moved
by up to 7.6 A^2. Re-saving the FlexCNN checkpoint with its declaration leaves
its own predictions bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RobbinBouwmeester
RobbinBouwmeester merged commit a151abd into main Aug 20, 2026
5 checks passed
@RobbinBouwmeester
RobbinBouwmeester deleted the feat/multitask-flexcnn-v7b branch August 20, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant