Skip to content

Feat/sensitivity improvements - #44

Merged
RobbinBouwmeester merged 40 commits into
mainfrom
feat/sensitivity-improvements
Jul 23, 2026
Merged

Feat/sensitivity improvements#44
RobbinBouwmeester merged 40 commits into
mainfrom
feat/sensitivity-improvements

Conversation

@RobbinBouwmeester

Copy link
Copy Markdown
Member

No description provided.

RobbinBouwmeester and others added 30 commits July 17, 2026 11:37
…ture values

- peak_bounds: relocate the apex to the profile maximum when the supplied apex
  sits at zero height, so the boundary walks no longer collapse to a zero-width
  window around the wrong scan.
- ms1_ms2_apex_rt_delta: bound to [0,1) via d/(d+width); a degenerate (~0)
  reference width could previously drive it to ~1e9.
- by_ratio_agreement: distinct bounded log-odds metric (tanh), no longer a
  duplicate of by_ratio_consistency and no longer saturating for single-series.
- cosine_robust_trim3: hold the last non-degenerate value instead of collapsing
  to 0.0 when trimming removes all present fragments.
- add n_peak_scans / peak_window_degenerate and both_series_present indicators.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation + index guards

- fdr: (n_decoys+1)/n_targets numerator and collapse tied-score blocks to one q
  (was optimistic and dependent on arbitrary within-tie order).
- rescore grouped_q: assign the group q only to the winning row per key; losing
  sibling targets no longer inherit the winner's low peptide/PG q.
- rescore: precursor-level competition (CompeteGroupBy::PeptidoformCharge),
  experiment-wide multi-file rescore keyed on a unique row index, entrapment
  score readback keyed on row_id (candidate_id repeats across runs).
- rt_im_train: use target-only RT-calibration anchors (decoys were admitted) and
  require enough anchors before trusting the residual window (avoids the ~1s
  overfit window with a handful of seeds).
- index: fail if precursors are not ascending by precursor_mz (the fragment
  index's partition_point search assumes it) and warn on a decoy-free library.
- report: label the count as precursor (peptidoform+charge), log a distinct
  stripped-sequence count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…agments for z2

- MS2PIP charge-1 (TIC-fraction) and native charge-2 fallback intensities are
  max-normalized per charge group before the shared top-N ranking, so MS2PIP
  ions are no longer buried by the differently-scaled native charge-2 values.
- charge2_from_precursor_charge default 3 -> 2: allow charge-2 fragments for
  charge-2 precursors (DIA-NN uses them for ~16% of z2 transitions).
- config: CompeteGroupBy::PeptidoformCharge variant (precursor-level competition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…readback, import sort

- mokapot_worker: use mokapot's out-of-fold confidence estimates (targets +
  decoys) instead of averaging all fold models over all rows (which is not OOF).
  Falls back to fold-model averaging if the confidence API differs.
- entrapment_worker: pass through a unique row_id so the caller maps scores back
  without candidate_id collisions across competed runs.
- entrapment_corrected.py: exclude human decoys from the entrapment-false
  population (add & ~dec) at both counting sites.
- import_diann_lib.py: sort precursors by m/z before assigning candidate_id so a
  direct target-only import is fragment-index-valid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Fit the feature standardizer on each training fold only (was fit on the full
  data before splitting, leaking test-fold statistics into the scaling).
- Cross-validation fold key is now base_peptide_id (was candidate_id % K), so all
  charge/modification variants of a peptide share a fold and none leaks between
  train and test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ex, dead-knob warnings

- extract: emit a chromatogram row for every predicted transition, with a
  zero-intensity trace when the fragment was never observed. The feature families
  (similarity, entropy, ion-series, coelution, interference, nonzero) now see the
  full predicted set and penalize missing strong ions; mass-accuracy is unaffected
  because it already counts only fragments with obs_apex > 0.
- extract: apex tiebreak on the top-K predicted signature ions' observed intensity
  (wires the previously-dead apex_top_fragments; 0 -> default 3) instead of the 3
  brightest observed peaks, so a bright off-signature interferent cannot set the apex.
- config: warn at load when a declared-but-unimplemented knob (precursor_tol_ppm,
  tolerance_regime, k_select, max_fragment_charge, scan_scale, decoy.source,
  decoy.ratio) is set away from its default, instead of silently ignoring it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the gate

- Project the sparse hit-groups onto the real acquisition scan grid (the covering
  isolation-window scans within the RT window) before apex counting and the
  co-elution run. Missing acquisition scans are now count-0 and break a run, so
  "consecutive scans" are truly consecutive (the sparse-group run counter
  previously ignored acquisition gaps, admitting transient/interferent matches).
  Falls back to sparse groups when no covering-window grid exists.
- Compute MS1 apex isotope intensities BEFORE the fragment-Pearson gate and add an
  opt-in rescue (extract.ms1_rescue, default off): a candidate failing the single-
  scan Pearson is kept when it has adequate matched fragments and MS1 isotope
  support (mono present + plausible +1/mono ratio). Off by default because it
  relaxes acceptance; enable with FDR validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…harness

- empirical_reanalyse.py: first DIA-NN-`--reanalyse`-style pass. Learns per
  predicted-rank transition reliability from confident target IDs (fraction
  observed in-peak) and reweights or prunes the library fragments by that rank
  reliability. Applied symmetrically by rank to targets and decoys, so it does not
  by itself distort the target-decoy null; the user re-extracts with the new
  library. (Global-rank version; leakage-free out-of-fold learning is the P3
  refinement.)
- entrapment_holdout.py: honest held-out validation. Splits the human entrapment
  proteins into disjoint train/test halves by a stable protein hash, trains the
  classifier on E. coli targets vs human-TRAIN negatives, and evaluates FDR /
  E. coli sensitivity against the UNSEEN human-TEST null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…th zero vector

emit-all-transitions gave every never-observed predicted fragment a full grid-
length zero trace, which multiplied the chromatogram list-values past arrow's
32-bit ListArray offset limit and panicked (Option::unwrap on None) on large runs
(observed only after ~2 files). Absent transitions now emit an empty trace: the
row is still present so the feature families see pred>0/obs=0, but the values
count stays near the observed-only total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chromatogram rt/intensity were written as an arrow ListArray (32-bit
offsets), capping total list-values at ~2.1B. When extraction accepts a
very large candidate set (correlation gates opened up, or large
multi-file runs), the chrom ListArray offset buffer overflows and the
builder panics mid-write.

Add a Col::LargeListF32 variant (arrow 64-bit LargeList) and write the
chrom rt/intensity columns with it. The list_f32 reader now accepts both
List (existing artifacts) and LargeList encodings, so chrom parquet from
either binary reads back identically. Spectra keep 32-bit ListF32 (small
per-scan peak lists, read via a direct downcast in spectra.rs).

Also gitignore /_archive/ (local, superseded working docs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion-mode + config scaffolding

Foundational, additive, behaviour-preserving building blocks for the sensitivity
program (sensitivity_plan/).

- mumdia-core::rejection::RejectionReason: the spec's earliest-loss reason codes
  (01 §4 / P0.3) with stable string codes, a pipeline-stage ordering, and an
  `earliest()` combinator. Unit tested.
- mumdia::peaks::enumerate_peaks: non-destructive top-K chromatographic peak
  enumeration (local maxima + fractional-height boundaries + evidence rank),
  the core of "retain the correct peak instead of committing to one apex"
  (04 §3 / P1). Pure + unit tested with synthetic chromatograms (single peak,
  dominant interferent + retained true peak, truncation, prominence filter,
  shoulder collapse, determinism).
- config: ExtractConfig.retain_top_peaks (default 1 = legacy) + validation;
  ExtractConfig.emit_candidate_audit (default false); CompetitionMode enum
  (winner_take_all default / none / features_only / unique_evidence /
  margin_gated) + CompeteConfig.mode/margin/unique_evidence_min_fragments/
  emit_competition_audit. All serde-default so existing configs are unchanged.

No default behaviour changes; K=1 and winner_take_all reproduce current output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Non-destructive candidate-level observability. `mumdia audit` joins the artifact
chain (library precursors -> psms -> competed -> scored) and writes
candidate_audit.parquet with, per candidate, the stage flags (candidate_generated,
traces_extracted, peak_generated, peak_selected, variant_selected,
target_decoy_winner, passed_precursor_fdr, passed_peptide_fdr, reported) and the
EARLIEST RejectionReason. Also writes <out>.metrics.json with the identification-
loss waterfall and stage recalls.

Reruns no compute and changes no pipeline output, so it is safe on any completed
search. Extraction losses collapse to NO_PEAK_GROUP at artifact resolution; an
optional in-extract audit sidecar (<psms>.audit.parquet) refines them per
candidate when present (hook in place, emitter is future work).

Verified on the E. coli/HYE run: search_space=8,334,126 extracted=341,754
reported=8,568; waterfall NO_PEAK_GROUP=7,992,372 FAILED_PRECURSOR_FDR=332,120
FAILED_PEPTIDE_FDR=1,066 REPORTED=8,568. Unit tests cover the ladder assignment
and entrapment labelling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CompeteConfig.mode drives within-group resolution via a pure, unit-tested
resolve_competition():
- winner_take_all (default): unchanged legacy behaviour (keep top prelim per
  group; ties -> smallest index, bit-identical to the previous HashMap winner).
- none / features_only: preserve every candidate (FDR/rescoring arbitrates);
  the sensitivity program's "preserve evidence until a calibrated decision".
- unique_evidence: keep a loser with >= unique_evidence_min_fragments unique
  fragments (unique_fragment_count, else n_matched_fragments*(1-contested_frac),
  else n_matched_fragments; winner-take-all fallback when none present).
- margin_gated: remove a loser only when winner_prelim - loser_prelim >= margin.

Target/decoy label stays in the group key in every mode, so a target never
competes its own decoy (null preserved). Optional emit_competition_audit writes
<out>.compete_audit.parquet (loser, winner, scores, OUTCOMPETED_BY_* reason).
ArtifactReport now records mode + removed count. 7 new unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…xt steps

- ARCHITECTURE_MAP.md: 12-stage pipeline map with symbols/line numbers, an
  exhaustive "where candidates are dropped" table (~40 points -> reason codes),
  hook sites for each work item, and code-vs-spec differences (compete is
  within-label winner-take-all before rescore; target-decoy is q-values not a
  compete stage; one apex per candidate).
- FEATURE_REGISTRY.md + feature_registry.yaml: all 360 features across 17
  families with level/direction/source, a leakage audit vs spec 03 §3, and a
  gap table vs spec 03 §8.
- IMPLEMENTATION_STATUS.md: baseline, assumptions, per-task status, tests,
  the candidate-audit waterfall on real data.
- NEXT_STEPS.md: prioritized remaining work with exact hook sites (top-K extract
  wiring, in-extract audit emitter, competition-after-rescore, conflict features,
  new feature families, two-pass calibration, empirical-FDP acceptance loop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When extract.emit_candidate_audit is set, the `run` orchestrator invokes the
audit stage after rescore, joining the run's library/psms/competed/scored
artifacts into candidate_audit.parquet + its waterfall metrics. Off by default
(one cheap join pass; no effect on the production chain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ipts

Non-invasive diagnostics over existing Parquet artifacts (no engine change).

- scripts/reference_apex_topk.py (P0.4 / spec 02 §5 Stage D, peak oracle): builds
  each candidate's consensus fragment-elution profile, enumerates peaks (port of
  mumdia::peaks::enumerate_peaks), and reports where the selected apex ranks among
  peaks (top-1/3/5/10). Optional --diann report gives reference-apex-in-top-K
  recall (auto minutes->seconds). Smoke (20k candidates, E. coli): mean 10.35
  peaks/candidate, 95.2% have >=2 peaks; selected apex is rank-1 only 52.5%
  (top-3 79.2%, top-5 88.3%, top-10 94.8%, in no peak 3.5%). Strong quantitative
  support for top-K retention.
- scripts/feature_ablation.py (P4.3 / spec 03 §4-5): grouped-CV family ablation
  with in-fold standardization (winsorized +/-8 SD), logreg + HistGBT, targets at
  empirical-FDP metric via target-decoy q. Leakage-guarded (meta/q/score excluded,
  group by peptidoform+charge). Smoke (60k rows, logreg): most useful families
  similarity/rt/entropy; on this subset removing interference/rich/coelution
  raises the count (single-subset, one-model caveat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion findings

- BENCHMARK_GUIDE.md: how to run + read the candidate audit, reference-apex top-K
  recall, feature-family ablation, and entrapment-FDP acceptance gate, with the
  real CLIs and the E. coli smoke numbers, plus the end-to-end experiment recipe.
- IMPLEMENTATION_STATUS.md: tasks 6-8 done; records the top-K finding (selected
  apex is rank-1 only 52.5%, top-5 88.3%) and the ablation leads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ure families (P5.1/P5.3)

Two additive Extended-battery families computed from the per-PSM Evidence
(peak-bounded traces, apex, mass errors), interference-resistant (shape/breadth,
not absolute intensity):

- apex_dispersion (13): fragment-apex RT scatter (std/mad/max/mean dev + agree
  fraction), precursor-fragment apex delta, and consensus peak shape (symmetry,
  tailing, local maxima, shoulder, FWHM, truncation, apex position in window).
- mass_uncertainty (10): fragment mass-error distribution (median/abs-median/std/
  IQR/max/range over matched fragments), effective fragment count (inverse
  participation ratio), evidence concentration, and fraction of the top-3/top-5
  predicted ions observed (breadth of the strong ions).

Append-only; the Extended-count test is dynamic so it stays green. 8 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ifest, normalizer (P0.1/P0.2/P4.2/P7.1/P7.2)

Non-invasive Python diagnostics over existing artifacts (no engine change):
- feature_audit.py (P4.2): per-feature missingness/quantiles/constant-flag,
  target/decoy/entrapment separation (Mann-Whitney), redundancy clusters
  (|rho|>=0.9), leakage + intensity-domination warnings, registry cross-check.
- benchmark_report.py (P7.1): self-contained HTML (inlined CSS + base64 PNGs, no
  network) assembling waterfall, audit stratification, top-K recall, ablation,
  entrapment.
- candidate_diagnostics.py (P7.2): per-candidate chromatogram + predicted-vs-
  observed plots + feature dump (pyarrow predicate pushdown, MS1 on twinx).
- search_space_manifest.py (P0.1): effective search-space manifest from the
  library + DIA-NN/declared parity check with --fail-on-mismatch.
- normalize_output.py (P0.2): MuMDIA scored -> spec 02 §4 common schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_evidence_rank)

Wide-window DIA fragment intensity is chimeric, so the tallest scan is often a
co-isolated interferent. When extract.apex_evidence_rank = true, the apex is the
scan with the most DISTINCT co-eluting predicted fragments (breadth of evidence),
with observed signature-ion intensity used only as a sub-integer tiebreak; the RT
prior still applies. Default false keeps the legacy signature-intensity apex
bit-for-bit. The rolling distinct-fragment count still gates qualifying scans in
both modes. Directly expresses the "evidence, not intensity, picks the peak"
principle; validate ID effect with the entrapment gate before enabling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rt_im_train.adaptive_rt_window (default false) replaces the single global
residual-percentile RT half-width with a LOCAL one: calibration anchors are
binned by calibrated RT (adaptive_rt_bins, default 12) and each candidate gets
its RT region's residual percentile, clamped to [rt_window_min_s,
fallback_rt_window_s] and scaled by rt_window_multiplier. Well-calibrated regions
get a tighter window (less interference), poorly-calibrated regions a wider one
(more recall); empty/sparse bins fall back to the global width. Off by default
(single global window unchanged). Validate ID effect with the entrapment gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…backlog status

feature_registry.yaml gains the apex_dispersion (13) and mass_uncertainty (10)
families (383 features total). FEATURE_REGISTRY.md notes them; IMPLEMENTATION_STATUS.md
records the full session-2 done/TODO backlog status and the acceptance-validation
plan (entrapment gate on >=2 datasets before enabling any default-off knob).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ex_topk

Adds a peak-ranking mode that ranks each peak by the number of distinct predicted
fragments co-eluting in its RT window (breadth of evidence), not integrated
intensity. Intensity is chimeric in wide-window DIA, so area-ranking rewards
interferents; evidence-count ranking is interference-resistant. Enables the
area-vs-count comparison for the peak-selection question and the eventual
--diann reference-apex adjudication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When extract.retain_top_peaks > 1, extraction enumerates peak groups over each
candidate's per-scan distinct-fragment COUNT profile (co-eluting breadth, not
intensity) via mumdia::peaks::enumerate_peaks, and writes <out-psms>.peaks.parquet
(candidate_id, peak_rank, apex_rt, start_rt, end_rt, evidence_count, area), one row
per retained peak. The main psms/chrom flow still reports the single selected apex,
so target-decoy FDR is unaffected; the retained peaks are candidate inputs for an
offline peak-selection model (reference_apex_topk.py measures the recall). K=1
(default) writes no peaks table and is byte-identical to before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
search_seed.two_pass_mass_cal (default false): after the median-offset +
95th-percentile tolerance fit, re-fit on only the deviations inside the first-pass
window (outlier rejection), giving a tighter offset + local mass-uncertainty
estimate. Exports frag_ppm_sigma + cal_passes in masscal.json. Falls back to the
single-pass result when too few in-window calibrants remain. Single-pass default
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ars (P2.1/2.2/2.3/P5.5/P6.2/P1.3)

Non-invasive first-pass sidecars over existing artifacts (join into rescoring
experiments; in-core wiring is future work):
- conflict_features.py (P2.1/P2.2/P2.3 + P5.5): bounded fragment-claimant index +
  peak-group conflict graph -> per-candidate contested/unique fragment counts,
  conflict_group_size, shared_intensity_frac, and candidate-ambiguity margins
  (to best alt peptide / best decoy, competitor entropy) from prelim_score.
- localization.py (P6.2): groups localization variants (same stripped seq + mod
  multiset, different sites) and computes site-determining ion count/intensity +
  localization_confidence. On E. coli finds 0 ambiguity groups (Carbamidomethyl
  fixed); validated on a synthetic phospho pair.
- peak_selection_model.py (P1.3): grouped-OOF ranker over the top-K peaks table;
  compares learned vs evidence-count vs area ranking; DIA-NN or weak self-label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…k-selection, conflict, localization)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… state

- CLAUDE.md: pipeline lists `audit`; implementation-status table updated (audit
  row, top-K peaks / apex_evidence_rank / emit_candidate_audit on extract,
  two-pass mass cal, adaptive RT window, competition modes, 383-feature 14-family
  battery); new "Sensitivity program" section; what-needs-doing points at
  sensitivity_plan/NEXT_STEPS.md.
- sensitivity_plan derived docs refreshed (spec files 01-06 untouched):
  ARCHITECTURE_MAP §1 no longer says top-K is unwired (it is wired: peaks.parquet,
  validated K=5); NEXT_STEPS reordered around closing the top-K loop + in-core
  wiring + P6.3 + entrapment-gate validation; BENCHMARK_GUIDE documents all 10
  diagnostic scripts + --rank-by count + the TTOF-invalid note; README gains an
  implementation-status banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ract.gate_coelution)

The min_frag_corr gate compared observed-vs-predicted fragment intensities at the
single apex scan, which chimeric DIA interference inflates. gate_coelution=true
instead thresholds min_frag_corr on the predicted-intensity-weighted mean Pearson
correlation of each matched fragment's XIC to the signature-ion reference profile
over the elution window (the OpenSWATH/DIA-NN co-elution discriminator): an
interferent that only coincides at the apex does not co-elute with the peptide's
own fragments. Default false (legacy single-scan Pearson). 3 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… not the full window

The first co-elution-gate cut computed the correlation over the entire (up to
~1177 s) extraction window, which is mostly zeros with a narrow peak -> noisy,
unstable correlation that over-rejected real peptides (-2028 on E. coli). Now the
score is computed only over the contiguous scans around the signature-reference
apex above 10% of its height (the actual elution peak), where co-elution is
meaningful.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RobbinBouwmeester and others added 10 commits July 20, 2026 14:39
…im, soft competition

All additions default-off so the production chain stays byte-identical.

Gate:
- GateMode enum (ApexPearson default / PeakSpectral / SpectralEntropy / Coelution /
  Combined) replacing the gate_coelution bool; min_frag_corr thresholds the active
  mode. peak_window + peak_spectral_score helpers; spectral_entropy_similarity_sqrt
  exposed from features::entropy (shared kernel, no duplication).
- emit_gate_diagnostics knob: the four gate_* diagnostic columns (and their extra
  per-candidate compute) only when set. Investigation found apex Pearson is the best
  single gate metric; peak-integrated/entropy/coelution regress at matched pool.

Rescorer:
- RescorerKind::NnTorch: PyTorch semi-supervised MLP sidecar (scripts/
  nn_rescore_worker.py) over the shared positional-CLI PIN contract; run_mokapot
  refactored to run_pin_sidecar(script). doctor probes torch when selected.
- Streaming memmap backend in the worker (auto >4GB PIN, or MUMDIA_NN_STREAM=1) so
  multi-run experiment-wide rescoring never loads the full PIN into RAM.
- scripts/nn_semisupervised_rescore.ipynb: standalone PIN -> NN rescore notebook.

Extraction:
- extract_twopass_windows: parallelize the two-pass co-elution peak-claim across
  isolation windows (~4x faster, far lower memory; within-window arbitration).
- --restrict-candidates allowlist for gate-first-then-compete; routes non-coelution
  claim strategies through the restrict-aware single-pass.
- Soft competition features (default-off behind emit_contested_features):
  peak_contested_count_frac + peak_apportioned_frac from a Contested stats struct
  (won/lost intensity + counts + co-elution apportioned share). Extended 379 -> 381.

81 lib tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ound_from_confident, default on)

The feature-stage elution peak that restricts trace-based feature calculation
was detected per-candidate from each candidate's own top-3-predicted-fragment
profile. That boundary is noisy and manipulable for chimeric decoys: their
window can shrink onto a chance-aligned scan or balloon over noise (measured
p10-p90 width 8.75-46.6s on HYE A_02, vs 11.6-43.9s for targets).

Add features.bound_from_confident: learn a single pair of left/right elution
half-widths once from the confident seed PSMs (spectrum_q <= 0.01, target-only,
the same anchor set used for RT calibration / DeepLC fine-tune), at the
features.bound_confident_pct percentile (default 50 = median), and apply it to
every candidate around its own apex. Half-widths are asymmetric (8.71s left,
14.53s right on HYE A_02) so the real chromatographic tail is preserved. If the
seed yields < 20 confident anchors the stage warns and falls back to
per-candidate detection.

On HYE A_02 (reverse decoys, single-seed NN rescore) this lifts 1% FDR peptides
57,991 -> 58,368 (+377) with decoy fraction unchanged at 0.99%. A percentile
sweep is monotonic (pct 50 > 75 > 90): a tighter uniform window excludes more
interference, so the median is the right default. Enabled by default; falls back
safely and is deterministic.

Also adds features.bound_peak_grace (default 0, legacy behaviour): number of
consecutive sub-threshold scans to bridge when walking the per-candidate peak
boundary. grace=1 was neutral (57,669) so the default stays 0.

Helpers elution_peak_rt_bounds (shared per-candidate boundary logic) and
global_bound_indices (map a global half-width onto the scan grid). A peak-shape
plausibility feature family (native width vs the confident norm) was prototyped
and dropped: hard decoys have target-identical peak widths (AUC 0.47), so the
width axis cannot separate the decoys that survive to 1% FDR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ence, q_train, auto-scaled batch)

The DeepLC multitask fine-tune sidecar was invoked with positional args only, so
its epochs (25), patience (10), confidence threshold (hardcoded spectrum_q <= 0.01),
and batch size (512) were fixed. Two of those defaults are actively harmful:

- Batch 512 underfits small confident seeds. An E.coli run with ~4k confident
  anchors gets ~8 gradient steps/epoch and never converges (held-out RT MAD 165s
  at 50 epochs, vs ~30s at batch 32). deeplc_finetune.py now takes `--batch 0`
  (default) which auto-scales to clamp(n_ref // 30, 16, 512) so every epoch runs
  >= ~30 steps regardless of seed size.
- The fine-tune reference threshold was hardcoded, so rt_im_train.q_train never
  reached it. Added `--q-train`.

Thread all four through as typed config fields on RtImTrainConfig
(finetune_epochs=25, finetune_patience=10, finetune_batch=0=auto) and
rt_im_train.q_train (already existed) via sidecar::run_deeplc_finetune into
run.rs. Defaults reproduce the prior behaviour except the batch auto-scale, which
only changes small-seed runs (previously broken). No change when
finetune_deeplc is off.

Also adds two diagnostic harnesses used to characterize the RT predictors:
- ft_epoch_eval.py: fine-tune epoch sweep with train vs held-out RT MAD (random
  or q-band split), optional raw-lib baseline. Established that raw DIA-NN iRT is
  locally noisy (~110s MAD) and fine-tune tightens it ~8x (HYE) / ~4x (E.coli).
- ft_calibrate_eval.py: DeepLC calibration vs fine-tune vs raw baseline on a
  common split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ross-run quant

quant filtered candidates on peptide_q_value. That is per-run for a single-run
rescore, but under an experiment-wide rescore peptide_q_value is a GLOBAL value
carried on the single best PSM per peptide across all runs. Splitting the pooled
scored table by run and quantifying each run then keeps only the peptides whose
global-best PSM lands in that run and drops the rest, so per-run quant sets come
out disjoint and the cross-run intensity matrix is empty (0 complete-case, NaN
size factors, blank ProteoBench submission).

Add QuantQColumn { PeptideQ (default), PsmQ } and quant.q_filter. PsmQ filters on
the per-PSM q_value (per run) and is the correct choice for a precursor-level
cross-run quant such as a ProteoBench submission. Default PeptideQ preserves
existing single-run behaviour. Recorded in the quant report params.

Verified empirically: on the 6-run HYE set, PeptideQ gave 0 cross-run precursor
overlap (empty matrix); the per-run q gives ~54k overlap and a fully populated
84k-precursor submission with on-target A/B species ratios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First working match-between-runs, validated on the 6-run HYE set before build (see
mbr_plan.md). Transfers identifications across runs to boost per-run sensitivity and
fill the cross-run quant matrix, under a controlled false-transfer FDR.

Scope of this commit: the RESCUABLE tier -- precursors confident (q<=q_anchor) in
>= min_anchor_runs OTHER runs but sub-threshold in a target run where they WERE
already extracted, so no re-extraction is needed. Steps M1-M4:
- M1 anchors: per-run confident set from the experiment-wide scored table.
- M2 expected RT: predict a precursor's RT in the target run from the median of the
  other runs' binned-median-aligned apex RTs. Leave-target-out residual ~17 s p95
  (~15x tighter than the search window).
- M3 transfer test: accept when the observed apex sits within the learned window.
- M4 FDR: permuted-RT decoy-transfer null -> transfer q-value by target/decoy
  competition on the RT residual.

Implemented as a Python sidecar (scripts/mbr_worker.py) over a positional contract,
matching the mokapot/deeplc/entrapment sidecar pattern, plus a typed config block
(MbrStrategy {None default / EmpiricalLibrary / RtTransfer / Full}, DecoyTransfer,
MbrConfig) and a `mumdia mbr` CLI command (sidecar::run_mbr). Default strategy None
is byte-identical; the command errors if strategy is None, < 2 runs, or mbr.python
is unset.

Validated on HYE (6 runs, q_transfer<=0.01): 25,278 accepted target transfers
(+5.7-9.9%/run), empirical decoy fraction 0.00%, RT window 20.2 s. Complete-case
precursors (present in all 6 runs) 33,740 -> 39,275 (+16.4%), the ProteoBench quant
metric. `mumdia mbr` reproduces the worker exactly.

Follow-ups (next commits): fold accepted transfers into the scored set + requant
(M5); the re-extraction tier for precursors absent from a run (65,805 candidates)
via extract --restrict-candidates at the transfer window; a fragment-consensus
correlation guard against RT-concordant interference; multi-run orchestration (M6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mbr_worker.py --out-scored writes the input scored_combined with each accepted
transfer's (candidate_id, source) row q_value lowered to its transfer_q and an
is_transferred flag added. A downstream quant/report with quant.q_filter=psm_q then
includes the transfers, realizing the gain: on HYE, per-run confident 336,100 ->
361,378 (+25,278) and complete-case precursors (present in all 6 runs) 33,740 ->
39,275 (+16.4%), which is the ProteoBench quant metric. FDR-controlled at
transfer q <= 1% (0% empirical decoy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two additions to the MBR worker + CLI, both validated on the 6-run HYE set.

Fragment-consensus guard (M4 enhancement, mbr.consensus_corr_min). The rescuable
transfer alone fills the cross-run matrix (+16.4% complete-case) but DEGRADES quant
precision: transferred precursors are sub-threshold because their signal is weak or
interfered, and RT-concordant interference (human background at the predicted RT)
compresses yeast/ecoli ratios toward 1:1 (ECOLI ratio IQR 0.72 -> 1.30, median
-1.89 -> -1.74). The guard correlates each accepted transfer's fragment pattern in
the target run against the empirical consensus (mean L1-normalized pattern over its
confident runs) and rejects those below consensus_corr_min. At >=0.8 it keeps 14,469
of 25,278 transfers and recovers precision (ECOLI IQR back to 0.91, median -1.86;
YEAST IQR 0.57 -> 0.41), so MBR becomes a completeness gain WITH preserved precision.
mbr_worker.py takes --frag-csv + --consensus-corr-min; wired through
sidecar::run_mbr and `mumdia mbr --frag ...` (+ --out-scored exposed).

Re-extraction-tier target emission (M1 of the absent tier). mbr_worker.py
--emit-transfer-targets writes per-run run_windows-format tables
(candidate_id, rt_pred_cal, rt_lo, rt_hi) for the ABSENT set -- precursors confident
in >= min_anchor_runs other runs but not extracted in a run (65,805 on HYE) -- at the
tight cross-run-predicted RT window (+/- rt_window), plus a permuted-RT decoy-target
file. These feed `extract --restrict-candidates <f> --run-windows <f>` to re-extract
the absent precursors, the second sensitivity tier. Emission validated
(extract-compatible schema, 65,805 targets); the extract + score + FDR loop over
them is the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…13/C14)

- constants: ISOTOPE_SPACING to the true 13C-12C mass difference 1.003354835
  (was 1.00286864, ~485 ppm off); shifts MS1 isotope-XIC m/z, so stored
  baselines must be regenerated
- features: use the shared constants::PROTON in ms1/novel instead of local
  duplicate constants
- fdr: entrapment_q gets tied-score-block collapse (determinism) and the +1
  finite-sample pseudocount matching target_decoy_q; add validate_labels
  (target/decoy only), the helper used by the label whitelist
- predict-frag: count and warn on DeepLC iRT misses (was a silent 0.0 anchor)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- rescore: emit run_psm_q (per-run PSM TDA, deterministic BTree split),
  experiment_psm_q (pooled), and precursor_q (peptidoform+charge) alongside the
  existing q_value/peptide_q_value/pg_q_value/global_q_value, which stay
  byte-identical; add the target_precursors_at_1pct stat; validate PSM labels;
  export the NN sidecar hyperparameters (MUMDIA_NN_FOLDS/ITERS/TRAIN_FDR) so
  the worker honours the config and the report records the values used
- schema: bump psms_scored 1 -> 2 (additive columns)
- align: validate PSM labels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-committed from the working tree after intentionally resetting the branch to
ca01ee5. Covers:
- config/quant: QuantQColumn::RunPsmQ per-run quant filter (the correct choice
  for cross-run quant off an experiment-wide rescore)
- config: extract.min_frag_corr default 0.5 -> 0.2 to relax the hard single-scan
  Pearson gate (comment.md S1), plus finite/[0,1] validation
- main/convert: fold the convert peak caps into the convert config_hash and
  record top_peaks_ms2/ms1 in the report (comment.md A2/C4); top-peaks CLI docs
- extract: record gate_mode / gate_coelution_min in the report params
- search_seed: test that top_n_peaks=0 selects all and the seed cap keeps only
  the top-intensity peaks
- main: CLI-parse tests for the peak-cap args
- README: doc updates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@RobbinBouwmeester
RobbinBouwmeester merged commit 2f207d5 into main Jul 23, 2026
3 checks passed
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