Skip to content

Prediction reports: training-set membership, novelty and conformal intervals (v4.4.0) - #118

Merged
RobbinBouwmeester merged 9 commits into
mainfrom
feat/prediction-report
Sep 8, 2026
Merged

Prediction reports: training-set membership, novelty and conformal intervals (v4.4.0)#118
RobbinBouwmeester merged 9 commits into
mainfrom
feat/prediction-report

Conversation

@RobbinBouwmeester

@RobbinBouwmeester RobbinBouwmeester commented Aug 31, 2026

Copy link
Copy Markdown
Member

Stacked on #117 (the report reuses MultiHeadRidgeCalibration and the uses_all_heads flag); the diff shown here is the report feature only. Retarget to main once #117 is merged.

What

prediction_report(psm_list, psm_list_reference=..., coverage=0.90, training_index=...) returns predictions together with what a bare number cannot say, one row per PSM:

column meaning
predicted_rt calibrated prediction, as predict_and_calibrate
ci_lower, ci_upper conformal interval at the requested coverage
in_reference exact peptidoform match in the calibration reference
dist_to_reference Levenshtein distance to the closest reference sequence
in_training * exact match anywhere in the 10,105,640-peptidoform corpus behind the bundled multitask model
in_selected_heads_training * match within the training sets of the setups the calibration selected (all 80 for the multi-head calibration, the single best head otherwise)
dist_to_training * distance to the closest training sequence, exact to 10 edits, capped beyond

* with a TrainingIndex, a ~400 MB memory-mapped artifact (sorted key hashes, per-setup membership CSR, 6.16 M unique sequences) built offline from the training cache by build_training_index.py in the research repo. The report works without it. Hosting to be decided (HF hub?).

The interval

Cross-fitted split-conformal on the reference: five folds, each predicted by a calibration fitted on the other four, half-width = finite-sample quantile of those honest |residuals| per predicted-RT bin (peak width varies along a gradient). No retraining, works for any model and calibration, finite-sample marginal coverage guarantee. Chosen over quantile regression (pinball) for exactly those reasons.

Validated on the eight held-out PRIDE setups (Figure 1b corpus), nominal 90 %:

  • coverage per setup 0.88-0.97 (median 0.91); at nominal 80 %: median 0.80; at 95 %: median 0.96;
  • width 4-10 % of the gradient on well-behaved setups, honestly wide where predictions are bad (35 % on the wheat-gluten out-of-domain setup, 79 % on the run that pools fractions);
  • distance-scaled widths were tested and rejected: error is flat in edit distance (Figure 2), so they only overcover.

End-to-end through this API on three held-out setups: coverage@90 = 0.914 / 0.898 / 0.974, and the membership columns behave (PXD079927: 60 % of test peptidoforms in the corpus but only 1.2 % in the 80 selected setups; PXD081880: 100 % / 95 %).

Per-peptide widths (added after review of the first version)

A per-RT-bin quantile gives a setup five distinct widths, so two peptides predicted at the same retention time always got the same interval — a fair complaint about a "confidence interval". A multi-head calibration combines heads that each estimate the same retention time, and their spread differs per peptide, so Calibration.disagreement now exposes it and the interval divides the honest residuals by it before taking the per-bin quantile (the quantile is multiplied back at prediction time). Ratios are clipped to 0.2-5x the median reference peptide; when the spread carries no signal the ratios collapse to 1 and the method degenerates to the previous behaviour.

Measured through the public API on the six included held-out setups (per_peptide_width True vs False), nominal 90 %:

per RT bin per peptide
worst conditional slice 0.851 0.882
Spearman(width, |error|) 0.151 0.248
coverage 0.909 0.919
relative width 0.0436 0.0478
distinct widths 5 877

The gains are largest where the RT-only width was weakest (PXD080826 0.818 → 0.888, PXD081924 0.814 → 0.845); the cost is 10 % wider intervals and mild overcoverage. Edit distance to the reference was tested as the scale instead and rejected again: three times the width, coverage 0.977, no correlation with the error. per_peptide_width=False restores the previous behaviour, which also stays the behaviour of single-head calibrations.

Canonical keys

canonical_peptidoform_key reproduces the corpus key format (peprec positions, U:<unimod id>, lowercased unmapped names, position-sorted, charge ignored) — verified against real corpus keys, including modified ones.

Verification

  • 167 tests pass; 20 new across tests/test_report.py and tests/test_multihead_calibration.py (key format, interval coverage / adaptivity / thin-bin fallback on synthetic data, per-input widths and their coverage, an interval refusing to drop the score it was fitted with, head disagreement per input and its collapse when heads agree, index membership global and per-setup, capped distances, non-index directory refused, report end-to-end with and without index, per-peptide against per-bin widths, pre-fitted calibration refused, multi-head head listing).
  • ruff check / format clean on the new files.
  • New dependency: rapidfuzz (pure wheel).
  • Version 4.4.0, CHANGELOG and usage docs updated.

🤖 Generated with Claude Code

@RobbinBouwmeester

Copy link
Copy Markdown
Member Author

Index size addressed in 39b621e: the artifact is now a single 105 MB .dlcidx file instead of a 400 MB directory. Stdlib LZMA zip; the interesting part is the hashes — random bits compress 1.4x at best, so they are truncated to 40 bits in a bucketed layout instead (2^24 bucket counts + 16-bit remainders, ~26 MB), costing a false positive about once per 100,000 membership queries, which a provenance flag can afford. Setup lists 2.8x, sorted sequences 3.1x, CSR pointers stored as row lengths 5.3x. Loads in about a second, answers bit-identical to the raw form, and every index test now runs against both formats.

RobbinBouwmeester and others added 2 commits September 2, 2026 11:27
A prediction is a number with no way to tell whether the model has seen the
peptidoform, merely something like it, or nothing like it, and no statement of
how far off it may be. prediction_report answers all three per PSM.

Membership and novelty: exact match against the calibration reference and the
Levenshtein distance to its closest sequence, always; with a TrainingIndex also
exact match against the 10,105,640-peptidoform corpus behind the bundled
multitask model, membership within the training sets of the setups the
calibration selected, and the distance to the closest training sequence (exact
to ten edits, capped beyond; the error is flat in this distance, so the cap
costs nothing but keeps the search fast). Canonical keys reproduce the corpus
format: peprec positions, Unimod accessions, lowercased unmapped names.

Uncertainty: cross-fitted split-conformal intervals on the reference. Each
reference fold is predicted by a calibration fitted on the other folds and the
half-width is a finite-sample quantile of those honest residuals per
predicted-RT bin. On eight held-out PRIDE setups the 90 % interval covered 0.88
to 0.97 per setup (median 0.91), 4 % of the gradient wide on well-behaved setups
and honestly wide (79 %) on a run that pools fractions. Chosen over quantile
regression because it needs no retraining and carries a finite-sample guarantee;
coverage is marginal, not per-peptide.

The TrainingIndex (~400 MB: sorted key hashes, per-setup membership CSR, unique
sequences) is built offline from the training cache and distributed separately;
the report works without it and then carries the reference columns only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The directory form was 400 MB across seven files, most of it uncompressed
structure: raw 64-bit hashes, int64 pointers, plain text. The packed .dlcidx is
a stdlib LZMA zip that exploits what each component actually is. Sorted hashes
are truncated to 40 bits and stored as 2^24 bucket counts plus 16-bit
remainders, which costs a false positive about once per 100,000 membership
queries and nothing else; a provenance flag does not need exactness beyond
that. CSR pointers become uint16 row lengths (5x under LZMA), the setup lists
and the sorted sequences compress 2.8x and 3.1x. Loading rebuilds the sorted
hash array in about a second; answers are bit-identical to the directory form
on membership, per-setup membership and distances, which the tests now check by
running every index test against both formats.

TrainingIndex reads both forms; the builder emits both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RobbinBouwmeester and others added 2 commits September 3, 2026 14:52
The conformal half-width was a per-RT-bin quantile, so a setup received five
distinct widths and two peptides predicted at the same retention time always got
the same interval.

A multi-head calibration combines heads that each estimate the same retention
time, and how far those estimates lie apart varies per peptide. Calibration
instances can now report that as disagreement(); the conformal interval divides
the honest residuals by it before taking the per-bin quantile and multiplies it
back at prediction time, which keeps the coverage guarantee and the RT structure
while making the width follow the peptide.

Measured through the public API on the six held-out PRIDE setups, against the
per-bin widths: worst conditional slice 0.851 -> 0.882, Spearman of width against
absolute error 0.151 -> 0.248, coverage 0.909 -> 0.919, relative width 0.0436 ->
0.0478, distinct widths 5 -> 877. The gains are largest where the per-bin width
was weakest (PXD080826 0.818 -> 0.888, PXD081924 0.814 -> 0.845). Edit distance
to the reference was tested as the scale instead and rejected: three times the
width, coverage 0.977 and no correlation with the error.

per_peptide_width=False restores the previous behaviour, which also remains the
behaviour of single-head calibrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from feat/multihead-calibration to main September 8, 2026 06:45
RobbinBouwmeester and others added 2 commits September 8, 2026 15:40
main split calibration.py into a package (#121): simple.py keeps the naive
classes, multihead.py holds the MultiHeadCalibration ABC and the relocated
MultiHeadRidgeCalibration, every caller now hands over the full (n, n_heads)
matrix, and the uses_all_heads branching is gone. This branch had added a
disagreement hook to the old single module, so the two collided in
calibration/simple.py.

Resolved by following the new layout rather than the diff:

- simple.py takes main's version. The disagreement hook moves to the
  MultiHeadCalibration ABC, where it belongs: only a calibration that
  combines several estimates of one retention time has a spread to report,
  and a naive single-series class never did.
- MultiHeadRidgeCalibration.disagreement lands in multihead.py, sharing the
  new _calibrated_columns helper with transform.
- report.py loses both uses_all_heads branches. _crossfit_residuals and
  prediction_report now fit and transform on the full matrix for every
  calibration, and a naive one passed by a caller goes through
  upgrade_calibration first, so the report follows the same contract as
  calibrate() and predict_and_calibrate().
- selected_heads for the membership columns comes from the calibration's own
  _head_idx, falling back to selected_model_head for a single-head fit.

Both paths verified on PXD081924: MultiHeadRidgeCalibration gives MAE 0.272
min, coverage 0.925 and 1,051 distinct interval widths, matching this branch
before the merge; a naive SplineTransformerCalibration, auto-upgraded, gives
MAE 0.351, coverage 0.932 and the five retention-time-binned widths expected
when a single head reports no disagreement. 187 tests pass, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A multitask model returns one column per LC setup, so the query matrix is
26 kB per peptide at 6,543 setups, and a fitted calibration reads a few dozen
of those columns: eighty for MultiHeadRidgeCalibration, one for a spline. The
rest were predicted, promoted to float64 and never looked at, which at 10,000
queries is 262 MB from the model and 523 MB after the cast.

The caller still hands over exactly one source and branches on nothing, which
is what dropping uses_all_heads bought. What changes is that the source may be
a column provider rather than a materialised matrix:

- take_columns(source, indices) asks a provider for those heads, or indexes a
  matrix, and every MultiHeadCalibration reads its columns through it. The
  request is made once for all the heads a calibration uses, so a provider
  needs one forward pass rather than one per head.
- HeadColumnSource in core.py is that provider for a model and a peptide list.
  It reports its shape without predicting anything, caches the last head set
  it was asked for (prediction_report reads the same heads twice, once to
  transform and once for the head disagreement), and implements __array__ so
  code that genuinely needs every head, such as ranking them in fit(), still
  gets the whole matrix.
- predict_and_calibrate and prediction_report hand over that source for their
  queries. References still pass a real matrix: ranking reads every head, and
  a reference is small.

The blanket float64 promotion at the top of each transform goes with it, since
the columns are cast after they are selected rather than before.

Verified on PXD081924: MultiHeadRidgeCalibration gives MAE 0.2718 and coverage
0.9248 either way, and a naive SplineTransformerCalibration gives 0.3513 and
0.9320 while running 1.18 s against 6.14 s, because it no longer predicts
6,543 heads to read one. 189 tests pass, including two that assert a lazy
source and a matrix agree and that only the used heads are requested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RobbinBouwmeester and others added 3 commits September 8, 2026 16:44
Reading the shape off the source, rather than coercing it first, withdrew two
things the previous ``np.asarray(source, dtype=np.float64)`` had quietly
provided.

A list or tuple of predictions raised AttributeError, because only arrays and
Series carry ``.shape``. ``source_shape`` now falls back to ``np.asarray``
when a source does not report its own shape, so anything numpy accepts works
again while a lazy provider is still asked rather than materialised.

A pandas DataFrame was mistaken for a lazy provider: it has a ``columns``
attribute, so the duck-typing check found it and tried to call it. The
provider method is now ``head_columns``, which nothing else is likely to
define, and the check requires it to be callable.

Both are covered by a parametrised test over the forms a caller can hand in:
two-dimensional array, one-dimensional array from a single-task model, list,
tuple, integer dtype, Series and DataFrame. 196 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first version of this rewrote both transform methods around two helpers,
which is more divergence than the change needs. The coercion at the top of
each one now survives character for character, moved into as_head_matrix and
skipped only for a source that marks itself with is_head_source:

    if getattr(source, "is_head_source", False):
        return source
    source = np.asarray(source, dtype=np.float64)
    if source.ndim == 1:
        source = source[:, None]
    return source

Everything after that line is left as written - the shape checks, their error
messages, the empty-source check, the column stacking - because a head source
answers .shape and source[:, heads] the way an array does. HeadColumnSource
therefore implements __getitem__ instead of a bespoke accessor, and the two
calibrations index it exactly as they index a matrix. fit() keeps the real
coercion: ranking reads every head, and np.asarray on a head source yields
the whole matrix through __array__.

The only other change to a body is that MultiHeadRidgeCalibration asks for
its heads in one slice rather than one per head, so a lazy source needs a
single forward pass; for an array that is the same slice.

That drops the divergence from Ralf's file to 30 added and 12 removed lines,
of which 17 are the new helper and its docstring. Same numbers on PXD081924
(MAE 0.2718, coverage 0.9248, 1,050 widths; naive spline 0.3513 and 0.9320),
every array-like still accepted including a DataFrame, 196 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Let a calibration pull the head columns it reads
@RobbinBouwmeester
RobbinBouwmeester merged commit cd3eab1 into main Sep 8, 2026
5 checks passed
@RobbinBouwmeester
RobbinBouwmeester deleted the feat/prediction-report branch September 8, 2026 15:13
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