Faster prediction: length-fitted windows, cheaper calibration fit, fewer heads read - #120
Merged
Merged
Conversation
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
force-pushed
the
perf/calibration-speed
branch
from
September 8, 2026 15:20
8f04bec to
5cba322
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_reportwith a 10,541-peptide reference 13.5 s, of which about11 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 * dilationpositions and the reaches add, which the model now reports aspadding_reach: 4 for the shipped architecture. A window of the longest peptide in a chunkplus 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 checkpointskeep 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.
fitno longer promotes that matrixto float64, which only doubled a 276 MB reference for no gain.
RidgeCVuses its closed-formleave-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:
prediction_reporton GPU: 13.54 to 8.61 s at a 10,541-peptide reference, 7.77 to 5.14 s at5,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 therolling-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=Falseforces one pass in the dataset's own window, for anyone who wants theold 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.pyandcalibration/simple.pyto follow the split.
🤖 Generated with Claude Code