Skip to content

feat(rescore): an out-of-fold prefilter before the classifier (rescore.prefilter_keep_decoys, default 0.35) - #210

Merged
RobbinBouwmeester merged 6 commits into
mainfrom
tims/18-rescore-prefilter
Oct 17, 2026
Merged

RobbinBouwmeester merged 6 commits into
mainfrom
tims/18-rescore-prefilter

Conversation

@RobbinBouwmeester

@RobbinBouwmeester RobbinBouwmeester commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

On a large pool the classifier is the rescore's cost, and most of what it scores is never identified. rescore.prefilter_keep_decoys (default 0.35; 0 turns it off) removes that part before the full feature set is read.

How it works

  • A logistic regression on 32 feature columns (rescore.prefilter_features replaces the list) is fitted per fold on at most 400,000 rows of the other folds: the targets at train_fdr against the decoys, refitted three times. Every PSM is scored by the model of the fold it is held out of, on the rescore's own fold keys.
  • The PSMs above the decoy that completes the given fraction of the decoys go to the classifier, targets and decoys alike. The share of rows kept therefore follows the pool.
  • The other per-row columns are cut to the kept rows, and the feature stream skips the dropped rows (an arrow filter per batch). The report records the cut under prefilter.
  • If the model cannot be fitted (fewer than 10 confident targets in a fold, a missing or non-finite column), every PSM goes to the classifier with a warning.

Why decoys, not rows. A first version cut a fixed share of rows. Keeping 35% lost 0.27% of the peptides on diaPASEF but 0.75% on Astral, where the same 35% kept only 19% of the decoys. The loss followed the share of decoys kept on every pool, so only the decoy-share cut ships.

Measured at 0.35 (pooled rescores of stored competed tables, NN seeds 0-2 except where stated; docs/35 section 16):

pool rows kept peptides at 1%, all -> prefilter wall peak RSS
six HYE diaPASEF runs (seed 0) 36.4% 98,524 -> 98,412 14:56 -> 8:39 51.0 -> 20.6 GB
six Astral HYE runs 48.7% 116,571 -> 116,660 5:49 -> 3:33 7.2 -> 4.1 GB
one HYE Orbitrap run 39.0% 62,960 -> 63,053 3.9 -> 2.2 GB

The model sees the target/decoy labels, so the FDR was checked against nulls it does not see:

  • 22 HeLa runs on a human + yeast + E. coli library: 352,838 against 352,096 human precursors per run, species-entrapment FDP 1.81% against 1.79%. At precursor level 23,734 against 23,781, FDP 1.75% either way. Rescore 6:04 against 10:35, 13.8 against 33.6 GB.
  • 24 non-alkylated single cells (carbamidomethylated targets are false): pooled 169,007 against 168,249 precursors with 379 against 374 carbamidomethylated targets, 2:59 against 5:06. Per file 161,055 against 161,371 with 144 against 147. On 3 of the 24 files the prefilter stood aside, as designed.

Default. 0.35, chosen by the user after the measurements above (fd7fbeb). 0 restores the previous behaviour. CLAUDE.md, docs/35 and the CHANGELOG record it.

Under native_tda (one seed each; it is deterministic) the prefilter is also a sensitivity gain at an unchanged entrapment FDP, because the linear model fits the boundary far better without the weak bulk:

  • one HYE Orbitrap run: 51,118 -> 55,461 peptides at 1%, 5:33 -> 2:04;
  • six Astral runs: 96,840 -> 105,400, 8:06 -> 4:19;
  • 22 HeLa runs: 197,636 -> 254,382 human precursors per run at a species-entrapment FDP of 2.01% against 2.08% (precursors 9,955 -> 14,887 at 2.17% against 2.38%), 39:02 -> 14:36, 55.8 -> 20.8 GB.

Few-decoy guard (0e9ca88): a pool with fewer decoys than rescore.min_train_decoys (100 at least) is not cut. On the smoke fixture's tag-prefilter arm, 1 decoy in 316 PSMs put the cut at that decoy and kept 96 targets. The q floor (decoys + 1) / targets then sat at 1.04%, so nothing passed. ci/smoke.sh passes again.

Still running: the E. coli diaPASEF entrapment run (three seeds) with the prefilter.

The PR also records the diaPASEF gate's validation (from #206) in docs/35 and the CHANGELOG. On the 5-min HYE runs, gate 0.845 gives -1.75% peptides at -48% wall. On the E. coli entrapment run, the FDP is 0.32-0.35% against 0.30-0.38%.

Checked: cargo fmt, clippy -D warnings, workspace tests, cargo doc -D warnings, config reference regenerated.

🤖 Generated with Claude Code

RobbinBouwmeester and others added 4 commits October 17, 2026 15:26
…e.prefilter_keep, opt-in)

The classifier's cost grows with the pool, and on diaPASEF the pool is large
(28.5 M PSMs on six HYE runs). rescore.prefilter_keep keeps that fraction of
the competed PSMs: a cross-validated linear model on a few feature columns
(rescore.prefilter_features, or a default list of agreement, co-elution,
intensity, matched-count, retention-time, MS1 and peak-shape scores) is
fitted per experiment, on the rescore's own fold keys: for each fold a
logistic regression of the targets at train_fdr against the decoys of a
400,000-row sample of the other folds, refitted three times, and every PSM is
scored by the model of the fold it is held out of. The top fraction is kept
whatever the label; every per-row column is cut to it and the feature stream
skips the dropped rows (an arrow filter per batch), so the classifier, the q
values and the scored table see the kept rows only. When the prefilter cannot
fit (no column, a non-finite value, too few confident targets) every PSM goes
to the classifier with a warning. The report records the cut.

0 (default) changes nothing. Benchmark-gated: a cut on a model trained
against the decoys must be checked with an entrapment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…coy share

A fixed share of rows does not carry across acquisitions: keeping 35% of the
PSMs cost 0.27% of the peptides on the six-run diaPASEF pool (33.6% of the
decoys kept) but 0.4-1.0% on the six-run Astral pool (19.1% of the decoys
kept), where most targets rank above most decoys. Keeping 50% on Astral
(36.6% of the decoys) was neutral. prefilter_keep_decoys keeps the
highest-scoring PSMs down to the one that brings in that fraction of the
decoys, so the share of rows kept follows the pool. At most one of the two
cuts may be set; 0 (default) is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ements

Drop rescore.prefilter_keep, the cut by a fixed share of rows: keeping 35%
lost 0.75% of the peptides on six Astral runs, while the cut by decoy share
(rescore.prefilter_keep_decoys) was neutral on every pool tested. One knob
remains, still opt-in.

docs/35 section 16 records the prefilter measurements (diaPASEF, Astral,
Orbitrap HYE; HeLa species entrapment; single cells with the
carbamidomethyl null) and the diaPASEF gate's validation on the 5-min HYE
runs and the E. coli entrapment run. CHANGELOG entries for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At 0.35 the prefilter kept every pool within 0.11% of its peptides at 1%
(six HYE diaPASEF runs 98,412 against 98,524; six Astral runs 116,660
against 116,571 and one HYE Orbitrap run 63,053 against 62,960, means over
three NN seeds), left the species-entrapment FDP of 22 HeLa runs at 1.81%
against 1.79% and the carbamidomethyl null of 24 unalkylated single cells at
379 against 374, and cut the rescore's wall time by 41-45% and its peak
memory by 54-60%. 0 restores the previous behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RobbinBouwmeester RobbinBouwmeester changed the title feat(rescore): an out-of-fold prefilter before the classifier (rescore.prefilter_keep_decoys, opt-in) feat(rescore): an out-of-fold prefilter before the classifier (rescore.prefilter_keep_decoys, default 0.35) Oct 17, 2026
RobbinBouwmeester and others added 2 commits October 17, 2026 15:46
On the smoke fixture's tag-prefilter arm the pool had 316 PSMs and one
decoy. A cut at 35% of the decoys still keeps that one decoy, so it fell
there and kept 96 targets; the target-decoy q floor (decoys + 1) / targets
then sat at 1.04% and nothing passed 1%. The prefilter now needs at least
rescore.min_train_decoys decoys, and 100 at least, the classifier's own
minimum; below that every PSM goes to the classifier with a warning.
ci/smoke.sh passes again (151 of 152 planted peptides).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under native_tda the prefilter raises sensitivity at an unchanged
entrapment FDP: six Astral runs 96,840 -> 105,400 peptides at 1%, the 22
HeLa runs 197,636 -> 254,382 human precursors per run at a
species-entrapment FDP of 2.01% against 2.08%, in 14:36 instead of 39:02.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RobbinBouwmeester
RobbinBouwmeester merged commit 78746b1 into main Oct 17, 2026
12 checks passed
@RobbinBouwmeester
RobbinBouwmeester deleted the tims/18-rescore-prefilter branch October 18, 2026 09:22
@RobbinBouwmeester RobbinBouwmeester mentioned this pull request Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant