Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,48 @@ public float ReverseDotProduct {
[Key(13)]
public float AcurateMassSimilarity { get; set; }

/// <summary>The value the scoring functions return when there was nothing to compare.</summary>
private const float NotCompared = -1f;

/// <summary>
/// True when the isotope-ratio comparison was attempted, so <see cref="IsotopeSimilarity"/> holds
/// a measurement.
/// </summary>
/// <remarks>
/// MsScanMatching.GetIsotopeRatioSimilarity returns -1 when there is nothing to compare: either
/// side carries no isotopic peaks, or a monoisotopic abundance is not positive. Unlike the dot
/// products there is no clamping getter, so that -1 reaches every consumer unchanged.
///
/// The test is equality with the sentinel rather than a sign test, because this score is 1 minus
/// an accumulated ratio difference and is genuinely signed: a negative value ordinarily means the
/// isotope patterns disagree, which is a measurement and a strong one. Only exactly -1 is the
/// sentinel, and a computed value of exactly -1 is indistinguishable from an unattempted
/// comparison. That ambiguity is resolved in favour of "not computed", because publishing a
/// sentinel as a measurement is the worse of the two errors, and it is narrow: reaching it needs
/// the accumulated difference to land on exactly 2.
/// </remarks>
[IgnoreMember]
public bool IsIsotopeComparisonPerformed => IsotopeSimilarity != NotCompared;

/// <summary>
/// True when <paramref name="similarity"/> holds a measurement from one of the Gaussian
/// similarity terms: retention time, retention index, collision cross-section or accurate mass.
/// </summary>
/// <remarks>
/// A different rule applies to these than to <see cref="IsIsotopeComparisonPerformed"/>, because a
/// different function produces them. MsScanMatching.GetGaussianSimilarity returns
/// exp(-0.5 * ((actual - reference) / tolerance)^2), which is strictly positive for any finite
/// argument until it underflows past roughly 38 tolerance widths, and returns -1 when either
/// value is missing or not positive.
///
/// So a measured term is positive, a term with nothing to compare is -1, and a term the run never
/// enabled is left at the field's default 0. Exactly 0 is therefore either that unset default or a
/// difference so large the score underflowed, and neither is a measurement worth publishing. This
/// is the same test GetTotalScore already applies before adding a term to the total, so the export
/// convention and the scoring convention agree.
/// </remarks>
public static bool IsGaussianTermMeasured(float similarity) => similarity > 0f;

// Link to database
[Key(14)]
public int LibraryID { get; set; } = -1;
Expand Down
24 changes: 15 additions & 9 deletions src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,18 @@ private void WriteCandidate(
BooleanText(candidate.IsSpectrumMatch),
BooleanText(spectrumScored),
Format(candidate.TotalScore),
// The five similarity terms below have no equivalent of the -1 sentinel that marks an
// unattempted spectral comparison, so an unused term is stored as 0 and cannot be told apart
// from a term that was evaluated and scored 0. They are written as stored; a consumer that
// needs the distinction has to read which terms the run's parameter file enabled.
Format(candidate.AcurateMassSimilarity),
Format(candidate.RtSimilarity),
Format(candidate.RiSimilarity),
Format(candidate.CcsSimilarity),
Format(candidate.IsotopeSimilarity),
// These five do carry sentinels, and two different ones, because two different functions
// produce them. The four Gaussian terms are positive when measured, -1 when there was nothing
// to compare, and left at the field's default 0 when the run never enabled the term, so only
// a positive value is a measurement. The isotope term is 1 minus an accumulated ratio
// difference and is genuinely signed, so a negative value there IS a measurement -- it says
// the patterns disagree -- and only exactly -1 is the sentinel. Both rules live on
// MsScanMatchResult so the scoring side and the export side cannot drift apart.
GaussianTerm(candidate.AcurateMassSimilarity),
GaussianTerm(candidate.RtSimilarity),
GaussianTerm(candidate.RiSimilarity),
GaussianTerm(candidate.CcsSimilarity),
candidate.IsIsotopeComparisonPerformed ? Format(candidate.IsotopeSimilarity) : NotApplicable,
Score(spectrumScored, candidate.SimpleDotProduct),
Score(spectrumScored, candidate.WeightedDotProduct),
Score(spectrumScored, candidate.ReverseDotProduct),
Expand All @@ -190,6 +193,9 @@ private static void WriteRow(StreamWriter writer, string[] values)
private static string Score(bool computed, float value)
=> computed ? Format(value) : NotApplicable;

private static string GaussianTerm(float similarity)
=> MsScanMatchResult.IsGaussianTermMeasured(similarity) ? Format(similarity) : NotApplicable;

/// <summary>
/// The stored score fields are Single, and G9 round-trips a Single exactly. The widening to double is
/// deliberate and matches <see cref="AnnotationScoreFormat"/>: .NET Framework formats a Single through
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ public void EveryCandidateIsExportedOnceInRankOrder()
Assert.AreEqual(3, lines.Length);
Assert.AreEqual(
"12\t10\t-1\t1\t2\ttrue\tmsp\t\tMspDB\t1\t7\tQuercetin\tC15H10O7\tflavonoid\tQUERCETIN-KEY\tc1cc(O)ccc1\t" +
"300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.75\t0.5\t0\t0\t0\t0\t0.5\t0.25\t0.75\t12\t0.5",
// rt, ri and ccs are empty: this run never enabled those terms, so the 0 sitting in the field
// is the unset default rather than a measurement. The isotope 0 is written, because only
// exactly -1 is that term's sentinel and 0 there is a real ratio agreement.
"300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.75\t0.5\t\t\t\t0\t0.5\t0.25\t0.75\t12\t0.5",
lines[1]);
Assert.AreEqual(
"12\t10\t-1\t2\t2\tfalse\tmsp\t\tMspDB\t1\t8\tMorin\tC15H10O7\tflavonoid\tMORIN-KEY\tc1cc(O)ccc1\t" +
"300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.5\t0.5\t0\t0\t0\t0\t0.5\t0.25\t0.75\t12\t0.5",
"300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.5\t0.5\t\t\t\t0\t0.5\t0.25\t0.75\t12\t0.5",
lines[2]);
}

Expand Down Expand Up @@ -98,6 +101,57 @@ public void AnUncomparedSpectrumPublishesNoSpectralScore()
Assert.AreEqual("0.5", fields[25], "the aggregate total score is still a measurement");
}

/// <summary>
/// The isotope term has its own sentinel and its own rule. -1 means the comparison was never
/// attempted; every other negative value is a measurement saying the patterns disagree.
/// </summary>
[TestMethod]
public void AnUnattemptedIsotopeComparisonPublishesNoIsotopeSimilarity()
{
// GetIsotopeRatioSimilarity returns -1 when either side carries no isotopic peaks. Unlike the
// dot products there is no clamping getter, so the -1 reaches the export unchanged. On one real
// reference library 495 of 4972 candidate rows carried it, all of them in-house records with no
// isotopic pattern deposited.
var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f);
candidate.IsotopeSimilarity = -1f;

var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t');

Assert.AreEqual("", fields[30], "isotope_similarity");
}

[TestMethod]
public void AnIsotopePatternThatDisagreesIsAMeasurement()
{
// 1 minus an accumulated ratio difference, so this is genuinely signed. Discarding it would
// throw away the strongest negative evidence the term produces.
var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f);
candidate.IsotopeSimilarity = -0.75f;

var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t');

Assert.AreEqual("-0.75", fields[30], "isotope_similarity");
}

/// <summary>
/// The four Gaussian terms follow the opposite rule, because a different function produces them.
/// </summary>
[TestMethod]
public void AGaussianTermPublishesNothingUnlessItWasMeasured()
{
var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f);
candidate.AcurateMassSimilarity = 0.5f; // measured
candidate.RtSimilarity = 0f; // the run never enabled retention-time scoring
candidate.RiSimilarity = -1f; // attempted, nothing to compare
candidate.CcsSimilarity = 0.25f; // measured

var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t');

CollectionAssert.AreEqual(
new[] { "0.5", "", "", "0.25" },
new[] { fields[26], fields[27], fields[28], fields[29] });
}

[TestMethod]
public void AnUnannotatedSpotContributesNoRow()
{
Expand Down
Loading