Skip to content

Skip the MS/MS term when no spectrum was compared - #789

Merged
htsugawa merged 1 commit into
masterfrom
fix/total-score-uncomputed-msms-term
Sep 4, 2026
Merged

Skip the MS/MS term when no spectrum was compared#789
htsugawa merged 1 commit into
masterfrom
fix/total-score-uncomputed-msms-term

Conversation

@htsugawa

@htsugawa htsugawa commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #786. Built on top of #785, which is now merged, so this targets master directly.

This needs a scientific sign-off, not only a code review. The negative exported number was the visible symptom, but restoring the guard also un-inverts a second rule that #589 broke. That changes the suggested compound for 786 of 3,419 precursor-only rows on the FastLC demo, and the alignment spot count from 2,512 to 2,553. Everything is measured below. The high-confidence tiers do not move at all: reference-matched 618 → 618 and low-score 230 → 230 alignment spots, and zero changed cells on any reference-matched or low-score row.

The guard cannot fail any more

MsReferenceScorer and the four MSP annotators gate the MS/MS term of the total score on:

if (result.WeightedDotProduct >= 0 && result.SimpleDotProduct >= 0 && result.ReverseDotProduct >= 0)

MsScanMatching.GetWeightedDotProduct and its siblings return -1 when there is nothing to compare, so that guard was written to skip the MS/MS term for a candidate with no product-ion spectrum. #589 (4b39b845e, released as 5.5.250625) made the dot products derived properties clamped with Math.Max(squared, 0f), so the guard is now unconditionally true. MatchedPeaksPercentage has no such clamp and is still -1, so the term is:

Site Shape Effect for a precursor-only candidate
MsReferenceScorer weighted mean including MatchedPeaksPercentage × factor term is negative; Total score = -0.143 on the demo
MassAnnotator, Dims/Imms/Lcimms MspAnnotator unweighted average of the terms present a spurious 0 term halves the average

The guard now asks MsScanMatchResult.IsSpectrumComparisonPerformed (added in #785), which reads the sentinel from the raw Squared* fields where it survives.

Left alone on purpose: MsScanMatching.GetTotalScore uses strict > 0 and sums rather than averages, so the clamp does not reach it. LcmsTextDBAnnotator has its own scorer that never touches the spectral fields, so text-database annotations are unaffected. GC-MS CompareEIMSScanProperties gates IsReferenceMatched on IsSpectrumMatch, so a precursor-only case cannot be reference-matched there.

The ranking contract does not depend on the negative total

Worth stating explicitly, because it is the first thing to worry about. Both orderings put the flags above the score:

// MsScanMatchResultContainer.ResultOrder
Tuple.Create(result.IsManuallyModified, result.IsReferenceMatched, result.IsAnnotationSuggested, result.Priority, result.TotalScore)

// MsScanMatchResultEvaluator.SelectTopHit
result => (result.IsReferenceMatched, result.IsAnnotationSuggested, result.TotalScore)

Tuple comparison is lexicographic, so a spectrum-matched candidate outranks a precursor-only one whatever the totals are. TotalScore only breaks ties inside one flag and priority bucket. SpectrumMatchedCandidateOutranksPrecursorOnlyWithAHigherTotalScore pins this with a precursor-only candidate deliberately given the higher score, and the demo confirms it empirically.

Why the diff is wide: #589 also inverted the structure penalty

MsReferenceScorer ends with:

if (result.InChIKey.IsEmptyOrNull()) result.TotalScore = result.TotalScore * 0.9F;

That penalises a reference with no structure. On a negative total, multiplying by 0.9 makes the number larger, so since 5.5.250625 the penalty has been a bonus, and a structure-less in-house record won every precursor-only row it competed against. Restoring the guard makes the totals non-negative, so the rule applies as designed.

Worked example, 20230406_blank_NEG_1.mdpeak peak 62. Both candidates come from the same LBM annotator, so Priority ties and TotalScore decides:

Candidate m/z similarity before after
RIKEN N-VS1 ID-1030 from Mouse_Eye_fads2KO_N_Ctr, InChIKey empty 0.990 0.9 × (0.990 − 1.286)/2 = −0.133wins 0.9 × 0.990 = 0.891
AAHFA 20:3;O2|AAHFA 5:0/15:2;O, InChIKey UNHJTVBXAZEAAC-… 0.941 (0.941 − 1.286)/2 = −0.172 0.941wins

After the fix the structurally defined record wins despite a slightly worse precursor match, because the 10% structure penalty outweighs a 0.05 difference in mass similarity. That is the existing scoring design, not a new rule. Whether 10% is the right weight is a separate question and is not touched here.

Measured on the FastLC demo

Console built Release/net48 from this branch, compared against a Console built the same way from master (0d2325c55), same 7 SCIEX WIFF files and the demo's own method.txt. The master run is byte-identical to the #785 run for all seven .mdpeak files, so #783 and #784 do not move this dataset and the whole diff is attributable to this commit. The pipeline is bit-deterministic on this dataset (control run in #785).

Identity changes, all of them on precursor-only rows:

File rows no MS2: rows winning candidate changed
20230406_blank_NEG_1 618 94 10
20230406_feces_1_NEG 2,793 832 205
20230406_feces_2_NEG 2,777 815 196
20230406_feces_3_NEG 2,801 840 217
20230407_plasma_1_NEG 1,411 278 52
20230407_plasma_2_NEG 1,406 268 53
20230407_plasma_3_NEG 1,387 292 53
total 3,419 786 (23.0%)

Changed rows by annotation outcome: precursor-only 3,419 (every one changes Total score), Unknown 88 (adduct and isotope cascade only). Reference-matched: 0. Low-score: 0.

Changed cells by column:

Column cells why
Total score 3,419 the fix itself
Name, Formula, InChIKey, SMILES, Ontology, Reference RT 786 each a different candidate wins
Reference m/z 776 same, where the two references differ in m/z
m/z similarity 592 follows the new reference m/z
Adduct 230 SetMoleculeMsPropertyAsSuggested assigns the winning reference's adduct
MS1 isotopes 22 follows the changed charge

Direction of every one of the 786 changes:

before after
winner had an empty InChIKey 786 0
winner had Ontology = Unknown 786 0
winner came from a different annotator 0 0

So the change is entirely within one annotator's candidate list, and it always replaces a structure-less in-house record with a structurally defined one.

Alignment. 230 adduct changes include cases such as [M-H]-[M-2H]2- for GD2 46:1;O2. A doubly charged ganglioside in negative mode is chemically reasonable and [M-2H]2- is in the demo's searched adduct list, but the changed charge propagates into isotope and adduct linking, so the spot list moves:

.mdalign master this PR
total spots 2,512 2,553
reference match 618 618
low score 230 230
no MS2: 1,021 1,071
Unknown 643 634

.mdmsp, .mzTab and .qa.tsv change consistently with the new identities and the new spot list.

Tests

New tests/MSDIAL5/MsdialCoreTests/Algorithm/Annotation/UncomputedMsMsTermTests.cs:

Suites run, all passing: MsdialCoreTests 307, CommonStandardTests 844, MsdialLcMsApiTests 66, MsdialDimsCoreTests 33, MsdialImmsCoreTests 56, MsdialLcImMsApiTests 52, MsdialGcMsApiTests 6, MsdialCoreTestAppTests 12.

The repository CI test job is red on master itself for an unrelated reason, MSB3923 on http://prime.psc.riken.jp/compms/code/InchikeyClassyfireDB-VS5.icd in MsfinderCommonStandard.csproj. A fix for that is in progress separately.

Also fixed here

EnhancedDotProduct = (float)Math.Sqrt(sqenhancedDotProduct) stored NaN when the underlying value was the -1 sentinel. It now keeps -1, consistent with its siblings. No concrete accessor keeps that column in its header today, so nothing exported it.

If the identity change is not acceptable

The alternative is to fix Total score but keep the old winners, by not applying the InChIKey penalty to precursor-only rows. That special-cases the penalty twice over and I would not recommend it, but it is a one-line variant if the 23% identity shift is unwelcome for now.

🤖 Generated with Claude Code

Fixes #786.

MsReferenceScorer and the four MSP annotators guard the MS/MS term of the
total score with

    if (result.WeightedDotProduct >= 0 && result.SimpleDotProduct >= 0 && result.ReverseDotProduct >= 0)

because MsScanMatching returns -1 from those functions when there is nothing
to compare. The squared-metrics rename in #589 made the dot products derived
properties clamped with Math.Max(squared, 0f), so the guard can no longer
fail and the MS/MS term is now always added. MatchedPeaksPercentage has no
such clamp and is still -1, so for a precursor-only candidate the term is
negative in MsReferenceScorer and a spurious 0 in the four annotators, whose
score is an average.

The guard now asks MsScanMatchResult.IsSpectrumComparisonPerformed, which
reads the sentinel from the raw squared fields where it survives. A
precursor-only candidate is scored from precursor m/z and retention alone
again, as it was before #589.

The ranking contract does not depend on the negative total. Both
MsScanMatchResultContainer.ResultOrder and MsScanMatchResultEvaluator
SelectTopHit order on (IsManuallyModified, IsReferenceMatched,
IsAnnotationSuggested, Priority, TotalScore) lexicographically, so a
spectrum-matched candidate still outranks a precursor-only one whatever the
totals are. A regression test pins that.

#589 also inverted a second rule through the same negative totals. The
scorer applies

    if (result.InChIKey.IsEmptyOrNull()) result.TotalScore = result.TotalScore * 0.9F;

to penalise a reference with no structure. On a negative total, multiplying
by 0.9 makes the number larger, so the penalty became a bonus and a
structure-less in-house record won every precursor-only row it competed for.
Restoring the guard makes the totals non-negative, so the penalty applies as
designed. That is why the demo diff is much wider than the exported number:
786 of 3,419 precursor-only rows change their winning candidate, every one of
them from an empty InChIKey and Ontology "Unknown" to a real structure.

EnhancedDotProduct is also assigned Math.Sqrt of a value that can be the -1
sentinel, which stored NaN. It now keeps the sentinel, consistent with its
siblings. No exporter currently keeps that column in its header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@htsugawa
htsugawa merged commit 7cecc9b into master Sep 4, 2026
9 checks passed
@htsugawa
htsugawa deleted the fix/total-score-uncomputed-msms-term branch September 4, 2026 14:10
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.

Total score adds the MS/MS term for precursor-only candidates because #589 clamped the -1 guard

1 participant