Port OspreySharp pass-2 (transfer-compete, protein-compact frozen) + learned pick, off by default - #57
Conversation
Port the OspreySharp (C#) learned linear peak-pick model into the CWT candidate ranking, replacing the product-form rank (coelution * rt_penalty * ln(1+intensity)) with a standardized linear combination of four raw terms: coelution, ln_intensity, rt_penalty, and a per-candidate median-polish cosine. The model is the DEFAULT, keyed by resolution: unit-resolution data uses the Stellar-trained weights, HRAM the Astral-trained weights (verbatim from the C# PickLdaModel). New `pick_lda` module holds the frozen z-score weights and the env precedence, resolved once per process: 1. OSPREY_PICK_LDA_MODEL (JSON path) -> that model (test override) 2. OSPREY_PICK_LEGACY (set, not "0") -> the pure product pick 3. default -> the resolution-keyed model run_search computes the per-candidate median-polish term the same way the C# CandidateLibCosine does (tukey_median_polish over the peak slice -> median_polish_libcosine, neutral 1.0 on failure); the argmax + total-order tie-break are unchanged. Making the model the default diverges from the historical product pick, so it stales the regression golden exactly as it does in OspreySharp; OSPREY_PICK_LEGACY=1 restores the product pick for regenerating the golden and for legacy parity. Verified byte-identical to OspreySharp end-to-end at 1e-9 on Stellar (Compare-EndToEnd-Crossimpl): 47,639 precursors both impls with the model on (vs 47,726 legacy), Stage 7 + blib PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the OspreySharp OSPREY_PASS2_QVALUE=transfer-compete pass-2 FDR into the
merge-node second pass. Instead of retraining the 2nd-pass SVM on the
decoy-depleted reconciled+compacted pool, apply the FROZEN 1st-pass model to
the reconciled survivors and recompute run/experiment q + PEP by a fresh
target-decoy competition over the FULL pre-compaction population (the per-file
.1st-pass.fdr_scores.bin scalar sidecars), with the reconciled survivors'
frozen-model scores swapped in. This restores the FDR control the retrain's
decoy-depleted null loses on entrapment libraries.
The default is unchanged (percolator = retrain), so nothing happens unless
OSPREY_PASS2_QVALUE=transfer-compete is set. A new Pass2QValueMode enum + env
read (OnceLock) selects the mode.
New `pass2_qvalue` module:
- FrozenLinearModel: the fold-averaged 1st-pass weights + bias + standardizer,
reproducing byte-for-byte the averaged-model scoring run_percolator_fdr
already applies, so a survivor re-scored from it matches its 1st-pass
sidecar score.
- compute_full_population_fdr_streaming: a faithful port of the C#
ComputeFullPopulationPrecursorFdrStreaming, reusing the existing
compete_from_indices + compute_conservative_qvalues + PepEstimator
primitives. One file's scalars resident at a time -> flat in file count.
Takes an optional stratum_base_ids hook for the upcoming protein-compact
mode (a constrained competition); None here is the full-population path.
pipeline.rs: run_percolator_fdr gains a frozen_out capture param (the 1st-pass
call captures the averaged model into frozen_first_pass_model); a new
read_fdr_scores_scalars reads the raw entry_id+svm_score from the v3 sidecar;
compute_pass2_transfer_compete frozen-scores the survivors, streams the
full-population competition, and maps q/PEP back onto the survivor stubs
(skipping off-stratum entries when a stratum is supplied). Falls back to the
retrain if the frozen model or 1st-pass sidecars are absent.
Verified byte-identical to OspreySharp end-to-end at 1e-9 on Stellar with
OSPREY_PASS2_QVALUE=transfer-compete (Compare-EndToEnd-Crossimpl): single-file
37,131 precursors (94,044 survivors) and 3-file 43,805 (398,314 survivors),
both impls equal, transfer-compete engaged (no retrain), Stage 7 + blib PASS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the OspreySharp OSPREY_PASS2_QVALUE=protein-compact pass-2 FDR. A protein-constrained second pass recovers the peptide DEPTH of proteins already detected in the first pass: the compaction gate is expanded to admit every peptide of a protein with >= 2 first-pass-detected peptides, even when that peptide failed first-pass FDR. Admitted peptides survive compaction, so they flow into RT consensus + reconciliation + the second-pass retrain and get reported at honest FDR (reduced multiple testing / Bourgon 2010 independent filtering — the candidate pool is shrunk to present-protein peptides). The stratum's ONLY effect is the compaction-gate expansion. The projection second pass then RETRAINS over the stratum-expanded compacted pool (it does not use the frozen model — see FirstJoinTask.RunPercolatorFdr's projection overload, which passes no frozen model to PercolatorEngine.RunPercolatorFdr). So protein-compact is the normal percolator retrain plus the expanded gate; it does not take transfer-compete's frozen-model / full-population path. New Pass2QValueMode::ProteinCompact + build_protein_compact_stratum (a literal port of C# FirstJoinTask.BuildProteinCompactStratum: from the raw library protein assignments and the first-pass detected-peptide set, count distinct detected peptides per protein, keep >= 2, and collect the base_ids of all their library precursors). The stratum is built in the first-pass protein-FDR block (where the detected-peptide set and library are in scope) and unioned into first_pass_base_ids at the compaction gate. Default stays percolator, so nothing changes unless OSPREY_PASS2_QVALUE=protein-compact is set. Verified byte-identical to OspreySharp end-to-end at 1e-9 on Stellar 3-file (Compare-EndToEnd-Crossimpl): Rust = C# = 49,059 precursors (+5,254 over the transfer-compete baseline of 43,805 — the recovered present-protein depth), identical stratum (5,677 proteins >= 2 peptides -> 207,964 base_ids), Stage 7 + blib PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…th C#) protein-compact previously fell through to the second-pass retrain (the stratum's only effect was the compaction-gate expansion). It now recomputes q/PEP with the FROZEN first-pass model over the protein stratum, via compute_full_population_fdr_streaming's stratum_base_ids argument (streamed one file at a time), mirroring the C# streaming frozen second pass (Pass2FdrSidecar.ComputePass2TransferCompeteFull). uses_frozen_model() now includes ProteinCompact; the dispatch passes the stratum and requires it (falls back to the retrain if the stratum or frozen model is absent). Also break shared-boundary run_qvalue ties between gap-filled charge states by lowest charge in build_shared_boundaries_from_plan, matching C# BuildSharedBoundaries, so the blib RetentionTimes stay byte-identical cross-impl (fixes a 20-row Astral transfer-compete divergence). Adds test_shared_boundaries_tie_broken_by_lowest_charge. Verified Rust == C# end-to-end bit-parity at 1e-9 on Stellar 3-file with OSPREY_PASS2_QVALUE=protein-compact (45,900 precursors; Stage 7 + blib PASS). fmt / clippy -D warnings / tests green. Co-Authored-By: Claude <noreply@anthropic.com>
…LDA) The learned resolution-keyed pick model is now off by default; OSPREY_PICK_LDA (set and not "0") opts into it, matching the C# default flip. The pure product-form pick is the default again -- Rust cross-impl parity + the committed regression golden; turning the model on by default is a coordinated golden re-baseline. Renames PickMode::Default -> ResolutionModel now that it is the opt-in rather than the default. OSPREY_PICK_LDA_MODEL still overrides. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Ports OspreySharp’s optional “pass-2” FDR strategies (frozen-model transfer-compete and protein-compact) plus an opt-in learned peak-picking model, aiming for cross-implementation byte parity while keeping legacy behavior unchanged unless env-vars are set.
Changes:
- Adds
OSPREY_PASS2_QVALUE-controlled second-pass behavior, including a frozen 1st-pass model capture and a streaming full-population target/decoy recompute path. - Introduces
OSPREY_PICK_LDA/OSPREY_PICK_LDA_MODELlearned peak-pick scoring (resolution-keyed defaults), integrated intorun_searchwhen enabled. - Makes shared-boundary selection deterministic on run-q ties by breaking ties on lowest charge to avoid cross-impl ordering drift.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/osprey/src/pipeline.rs | Implements frozen-model capture + pass-2 recompute wiring, protein-compact stratum construction, deterministic shared-boundary tie-break, and learned pick integration in run_search. |
| crates/osprey/src/pick_lda.rs | Adds the learned peak-pick model implementation and env-var based selection. |
| crates/osprey/src/pass2_qvalue.rs | Adds pass-2 mode selection and the streaming full-population FDR recompute helper used by transfer-compete/protein-compact. |
| crates/osprey/src/lib.rs | Exposes the new pick_lda and pass2_qvalue modules. |
| crates/osprey/Cargo.toml | Adds osprey-ml dependency for PEP estimation/standardizer support in pass-2 recompute. |
| Cargo.lock | Locks the new dependency edge for osprey-ml. |
Comments suppressed due to low confidence (1)
crates/osprey/src/pipeline.rs:6276
compute_pass2_transfer_competeusesread_fdr_scores_scalars(...).unwrap_or_default(), which silently turns an unreadable/invalid sidecar into an empty population and can yield bogus q-values/PEPs while still returningtrue. This should trigger a fallback to the 2nd-pass retrain instead.
// Stream the full-population competition; only one file's scalars resident at a time.
let read_file = |file_idx: usize| -> (Vec<u32>, Vec<f64>) {
read_fdr_scores_scalars(&sidecar_paths[file_idx]).unwrap_or_default()
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if data.len() < FDR_SIDECAR_HEADER_LEN | ||
| || &data[0..8] != FDR_SIDECAR_MAGIC | ||
| || data[8] != FDR_SIDECAR_VERSION | ||
| { | ||
| return None; | ||
| } | ||
| let count = u64::from_le_bytes(data[16..24].try_into().ok()?) as usize; | ||
| if data.len() != FDR_SIDECAR_HEADER_LEN + count * FDR_SIDECAR_RECORD_LEN { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
Added a checked_mul/checked_add guard in 85ebc47, so an oversized/corrupt header count now returns None (caller falls back to the retrain) instead of wrapping in release.
On the pass byte: read_fdr_scores_scalars is only ever called with the explicitly-constructed {file}.1st-pass.fdr_scores.bin path (compute_pass2_transfer_compete builds it by name), so a 2nd-pass file cannot be selected here, and the magic + version + exact-size checks reject anything else. The pass-byte validation lives in load_fdr_scores_sidecar, which is the reader that takes an expected_pass.
| let file_overlay = overlay.get(file_name.as_str()); | ||
| let file_features = per_file_cache_paths | ||
| .get(file_name.as_str()) | ||
| .and_then(|p| load_pin_features_from_parquet(p).ok()); |
There was a problem hiding this comment.
Two different reads here; splitting them:
6254 (PIN feature load) — not a correctness risk. frozen is the 1st-pass model that wrote the sidecar scalars, so for a non-rescored survivor frozen.score(features) reproduces its sidecar score exactly — the override is a no-op. The survivors that genuinely need re-scoring are the reconciliation-rescored ones, which take the overlay path (independent of this parquet load). So a failed load cannot corrupt q-values. I added a warn! for observability in 85ebc47 rather than falling back to the retrain.
6276 (full-population scalar sidecar, the suppressed comment) — this one had FDR consequences and is fixed. unwrap_or_default() silently turned a present-but-corrupt sidecar into an empty population, understating the null and making q-values anti-conservative while still returning true. It now flags the read failure and falls back to the 2nd-pass retrain, matching the "missing sidecar -> retrain" contract in the function docstring.
| // Rank score. With a learned pick model active (default), | ||
| // replace the product form with a standardized linear | ||
| // combination of the same four raw terms — coelution, | ||
| // ln_intensity, rt_penalty, and a per-candidate | ||
| // median-polish cosine. Without a model (the default; the | ||
| // model is opt-in via OSPREY_PICK_LDA) it stays the pure | ||
| // product form used for cross-impl parity + the regression golden. |
There was a problem hiding this comment.
Fixed in 85ebc47. The "(default)" was stale from when the LDA pick was the default; reworded so the comment states the product form is the default and the learned model is opt-in via OSPREY_PICK_LDA.
Addresses Copilot review on #57: - read_fdr_scores_scalars: guard the header-count size arithmetic with checked_mul/checked_add so a corrupt/oversized count is rejected (-> None) instead of wrapping in release builds. - compute_pass2_transfer_compete: a present-but-corrupt 1st-pass sidecar no longer silently contributes an empty population to the competition (which would understate the null and make q-values anti-conservative). A read failure now flags and falls back to the 2nd-pass retrain, matching the existing "missing sidecar -> retrain" contract in the docstring. - Surface a warning when a survivor file's PIN features fail to load (non-rescored survivors correctly keep their identical 1st-pass sidecar score; reconciliation-rescored survivors use the overlay path, so a failed load cannot corrupt q-values). - Fix the self-contradicting "(default)" pick-rank comment: the learned pick model is opt-in via OSPREY_PICK_LDA; the product form is the default.
Self-review follow-up on #57. - An explicitly requested frozen pass-2 mode (OSPREY_PASS2_QVALUE= transfer-compete / protein-compact) no longer silently degrades to the anti-conservative retrain when its prerequisites are absent. A warm rerun that loads cached SVM scores skips 1st-pass training, leaving the frozen model (and, for protein-compact, the stratum) unbuilt; a present-but-corrupt 1st-pass sidecar likewise can't be honored. All three now abort with a ConfigError and actionable guidance instead of reporting looser FDR than a cold run under the same flag. The default (percolator) path is untouched. - Port OSPREY_PROTEIN_COMPACT_RETRAIN (default off) to match C# OspreyEnvironment.Pass2ProteinCompactRetrain: when set, protein-compact retrains the 2nd-pass SVM over the stratum-expanded pool (the frozen-vs- retrain A/B lever) instead of taking the frozen path; it is also the one frozen prerequisite whose absence is intended, so it is exempt from fail-fast. Happy path unchanged (same frozen recompute call); only the can't-honor branches and the retrain opt-in are new. fmt + clippy + 183 tests green.
Mirrors the Copilot review fix on ProteoWizard/pwiz #4446 so the two pick-model loaders stay identical: - load_from_json now requires the JSON `features` array and checks it lists [coelution, ln_intensity, rt_penalty, median_polish] in that exact order, matching the C# PickLdaModel.ExpectedFeatures check. The weights are positional, so a re-ordered or older-schema model would otherwise silently score the wrong term. Only the OSPREY_PICK_LDA_MODEL override path is affected; the hardcoded resolution models and the default product pick are untouched. fmt + clippy + tests green.
* maccoss/osprey#57 landed as a3d6c3a, so C# and Rust are back in step Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Requested by Mike.
Test plan
Co-Authored-By: Claude noreply@anthropic.com