Skip to content

Faster prediction: length-fitted windows, cheaper calibration fit, fewer heads read - #120

Merged
RobbinBouwmeester merged 4 commits into
mainfrom
perf/calibration-speed
Sep 9, 2026
Merged

Faster prediction: length-fitted windows, cheaper calibration fit, fewer heads read#120
RobbinBouwmeester merged 4 commits into
mainfrom
perf/calibration-speed

Conversation

@RobbinBouwmeester

@RobbinBouwmeester RobbinBouwmeester commented Sep 7, 2026

Copy link
Copy Markdown
Member

Rebuilt on current main, which now carries the calibration split (#121), the prediction
report (#118) and the head column source (#123). Four commits; none changes what a model
predicts beyond float32 noise.

Prediction was profiled stage by stage first, and the result contradicted the intuition that
the network is the slow part. Per 10,000 peptides on a GPU box: encoding 0.8 s, the forward
pass 0.10 s, and prediction_report with a 10,541-peptide reference 13.5 s, of which about
11 s was calibration fitting because the conformal cross-fitting repeats it once per fold. On
CPU the forward pass was 2.41 s, and the head layer was not the reason: 80 heads instead of
6,543 saved 6 %, because every peptide was convolved in a 60-position window whatever its
length, and the median peptide is 14-16 residues.

The commits

Short peptides run in a window that fits them. The trunk masks its output by the true
residue count and the encoded features are bit-identical across window lengths, so only the
convolutions can carry padding into a valid position. Each reaches
(kernel - 1) // 2 * dilation positions and the reaches add, which the model now reports as
padding_reach: 4 for the shipped architecture. A window of the longest peptide in a chunk
plus that reach is therefore exact, and it was measured exact — margin +2 still changes
predictions, +4 gives 0.00e+00 over 1,500 peptides per length band. Models that pool or stride
across positions report no reach and are left alone.

Chunks are cut by length band, not batch size. Length-sorted chunks of a fixed 4,096
peptides inherit their longest member's window, so the last chunk runs thousands of ordinary
peptides in the window a handful of long ones need: 35 % of the throughput on a mixed
20,000-peptide set. A chunk is now cut as soon as its longest peptide exceeds its shortest by
more than four residues. Enforcing a minimum chunk size, so the sparse tail rides in a wider
window, measured worse and was dropped.

Batches are encoded in one pass, and the unused rolling sum is not built. Four arrays and
four tensors per peptide, collated by a DataLoader, cost several copies of a few hundred bytes
each with a lot of Python around them; writing the encoder output into batch buffers measured
1.7x faster over the feature path (0.153 against 0.089 ms per peptide). The rolling-sum matrix
is about a tenth of the encoding work and the fused trunk deletes it on the first line of its
forward; models declare whether they read it through uses_rolling_sum, so old checkpoints
keep the array they were trained with.

The calibration fit is cheaper. Head ranking no longer materialises centred copies of the
(n, 6543) matrix — the centred target sums to zero, so the covariance is a dot product per
block and the variance follows from the block's own sums. fit no longer promotes that matrix
to float64, which only doubled a 276 MB reference for no gain. RidgeCV uses its closed-form
leave-one-out route on references of at least 2,000 peptides (0.08 s against 0.84 s at 10,541,
accuracy neutral over ten setups, median MAE ratio 1.0000); smaller references keep the fold
search, where collinear columns make leave-one-out jumpy. And the spline calibration predicts
its trails only for points outside the fitted range.

Measured

CPU, 20,000 peptidoforms including encoding, before the length work and after:

threads before after
4 1,465 pf/s 3,115 pf/s
8 1,780 pf/s 3,702 pf/s
32 2,424 pf/s 5,260 pf/s

prediction_report on GPU: 13.54 to 8.61 s at a 10,541-peptide reference, 7.77 to 5.14 s at
5,000, 4.65 to 4.04 s at 2,000.

Equivalence

201 tests pass, including new ones asserting that a bucketed prediction equals the full
window, that a batch encodes to what __getitem__ produces item by item, and that the
rolling-sum flag leaves every other feature untouched.

Checked end to end against a per-peptide dump written before any of this work: on PXD079349
aggregate MAE is 0.1754 against 0.1756 min and coverage 0.9052 against 0.9044, with
per-peptide predictions within 0.049 min and a median difference of 0.002; on PXD081880, whose
230-peptide reference keeps the fold-based alpha search, predictions agree to 0.003 min.

length_buckets=False forces one pass in the dataset's own window, for anyone who wants the
old path.

What changed since the first version of this PR

The head-subsetting commit is gone: #123 solves that properly, by letting a calibration index
a lazy head source rather than having the caller predict fewer heads and branch. The
calibration-fit optimisations moved into calibration/multihead.py and calibration/simple.py
to follow the split.

🤖 Generated with Claude Code

Base automatically changed from feat/prediction-report to main September 8, 2026 15:13
RobbinBouwmeester and others added 4 commits September 8, 2026 17:17
Every peptide was encoded and convolved in a 60-position window whatever its
length, and the median peptide is 16 residues, so most of the trunk's
arithmetic ran on padding that the mask removes again before pooling. On CPU
that was the whole cost of prediction: 1,780 peptidoforms/s on eight threads,
of which the forward pass was about 85 %.

The trunk masks its output by the true residue count and the encoded features
do not depend on the window at all, so the only route from padding to a valid
position is the convolutions. Each reaches (kernel - 1) // 2 * dilation
positions and the reaches add, which the model now reports as
``padding_reach``: 4 for the shipped architecture, two pointwise stem layers
plus two convolutions of width five. A batch encoded in a window of its
longest peptide plus that reach therefore has to give the same answer, and it
does - over 50,000 peptides the largest difference was 1.2e-4 min, which is
float32 noise.

``predict`` now sorts by length and gives each chunk its own window. The
property is checked for the architecture and asserted end to end in
tests/test_flexcnn.py, and the path is skipped for models that pool or stride
across positions (they report no reach) and when the longest peptide already
fills the window.

CPU, 20,000 peptidoforms, including encoding:

  threads   fixed 60      bucketed     speedup
        4   1,465 pf/s    3,115 pf/s     2.13x
        8   1,780 pf/s    3,702 pf/s     2.08x
       32   2,424 pf/s    5,260 pf/s     2.17x

On GPU the forward pass was never the cost and the numbers are unchanged.
Pass length_buckets=False to force one pass in the dataset's own window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Length-sorted chunks of a fixed 4,096 peptides inherit the window of their
longest member, so the last chunk of a set runs thousands of ordinary
peptides in the window a handful of long ones need. On a 20,000-peptide set
that cost 35 % of the throughput, and it made a peptide-length cap look
attractive for the wrong reason: capping at 30 residues appeared to gain
37 % with fixed-size chunks and gains 2 % once the chunks are cut by length.

A chunk is now cut as soon as its longest peptide exceeds its shortest by
more than four residues, or at the batch size, whichever comes first. The
dense middle of a length distribution still fills a batch; the sparse tail
gets small chunks with tight windows, which measured better than padding it
into larger ones (1,546 peptidoforms/s against 1,503 at a floor of 512 and
1,449 at 2,048).

The early return that switched bucketing off whenever any single peptide
filled the window is gone too: one long peptide should cost one small chunk,
not the whole optimisation.

Measured on a machine busy with other work, so the absolute rates are low,
but every condition ran back to back: fixed-size chunks 1,142 pf/s, length
bands 1,546 pf/s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two changes to the feature path, which after the trunk work is the largest
remaining cost of prediction.

The dataset can now encode a whole batch into one buffer per feature.
Building four small arrays and four tensors per peptide and letting a
DataLoader collate them costs several copies of a few hundred bytes each with
a lot of Python around them; writing the encoder output straight into batch
buffers measured 1.7x faster over the feature path, 0.153 against 0.089 ms
per peptide, for the same values. The DataLoader is kept for worker processes
and for datasets that are not a DeepLCDataset.

The rolling-sum matrix is no longer built for models that ignore it. The
fused trunk reads the per-position matrix directly and deletes this one on
the first line of its forward, yet the array was built, converted to a
tensor, collated and moved to the device for every peptide: about a tenth of
the encoding work. Models declare it through ``uses_rolling_sum`` and
``predict`` passes it to the dataset, so old checkpoints keep the array they
were trained with.

Checked end to end against the holdout_v5 dump, which was written before any
of the performance commits: on PXD079349 the aggregate MAE is 0.1754 against
0.1756 min and coverage 0.9052 against 0.9044, with per-peptide predictions
within 0.049 min (median 0.002); on PXD081880, whose 230-peptide reference
keeps the fold-based alpha search, predictions agree to 0.003 min.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The conformal cross-fitting in prediction_report refits the calibration once
per fold, so its cost is paid five times over, and with a 10,541-peptide
reference that was about 11 s of a 13.5 s report. Three changes to the fit and
one to the spline it uses, none of which alters the selected heads:

- Rank the heads without materialising centred copies of the (n, 6543)
  matrix. The centred target sums to zero, so the covariance is a dot product
  per block of heads and the variance follows from the block's own sums.
  Blocks of 512 keep the accumulation in float64 while holding a slice rather
  than the whole matrix.
- Stop promoting that matrix to float64 in fit(). It arrives as float32 and
  every column is cast back to float32 for its spline, so the promotion only
  doubled a 276 MB reference to 552 MB. np.asarray on a head source still
  materialises it, which is what ranking needs.
- Let RidgeCV use its closed-form leave-one-out route on references of at
  least 2,000 peptides: 0.08 s against 0.84 s at 10,541, and accuracy neutral
  over ten held-out setups (median MAE ratio 1.0000, better on five and worse
  on five). Smaller references keep the fold-based search, where the collinear
  columns make a leave-one-out estimate jumpy: on a 725-peptide reference it
  chose alpha 1 against 316 and cost 3.7 % of accuracy, and the fold search
  costs 0.15 s there anyway.
- The spline calibration predicts its left and right trails only for the
  points outside the fitted range, instead of for every point and then
  discarding.

Checked end to end against a per-peptide dump written before any of the
performance work: on PXD079349 MAE 0.1754 against 0.1756 min and coverage
0.9052 against 0.9044, per-peptide predictions within 0.049 min; on PXD081880,
whose 230-peptide reference keeps the fold-based search, within 0.003 min.
201 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RobbinBouwmeester
RobbinBouwmeester merged commit 52a62d7 into main Sep 9, 2026
5 checks passed
@RobbinBouwmeester
RobbinBouwmeester deleted the perf/calibration-speed branch September 9, 2026 11:27
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