Skip to content

Commit 466a7fc

Browse files
Merge pull request #11 from OpenMS/f5/library-coverage-identifiable-denominator
Fix OpenSwath library coverage denominator
2 parents a17e252 + ade4ea9 commit 466a7fc

4 files changed

Lines changed: 204 additions & 21 deletions

File tree

‎benchmark/openswath_metrics.py‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
`_precursor_mz` metric names.
2323
- feature one picked chromatographic feature (a top-level <feature>). The
2424
runner owns the feature count; this module does not duplicate it.
25-
- peptide a distinct TraML <Peptide>.
25+
- peptide a distinct TraML <Peptide>. A declared peptide with at least
26+
one transition is "identifiable"; one with none is an "orphan".
27+
library_peptides counts BOTH (it is the declared population);
28+
library_peptides_identifiable and library_peptides_orphan split
29+
it, and library_coverage is measured over the identifiable half.
2630
- protein a distinct TraML <Protein> reached through a peptide's
2731
ProteinRef.
2832
@@ -63,6 +67,14 @@
6367
# scalars (overall_quality_sum rounds to 6 decimals).
6468
COVERAGE_DECIMALS = 6
6569

70+
# library_peptides_identifiable is the base of library_coverage. A TraML
71+
# peptide with no transitions is still declared in the library (it counts
72+
# towards library_peptides, matching upstream TargetedExperiment::getSummary())
73+
# but it has no precursor, so no transition, so no native_id a feature could
74+
# carry: it can never be identified. Dividing by such an entry would cap
75+
# coverage at a ceiling fixed by library construction rather than by the tool,
76+
# so it is excluded from the ratio and reported separately instead.
77+
6678

6779
class OpenSwathMetricsError(Exception):
6880
"""Raised for a structurally unusable TraML/featureXML, or a feature whose
@@ -163,10 +175,16 @@ def count_identifications(library, featurexml_path):
163175
"""Join a featureXML against a parsed library and return the counts.
164176
165177
Returns (all integers unless noted):
166-
library_peptides, library_precursor_mz, library_transitions,
178+
library_peptides, library_peptides_identifiable,
179+
library_peptides_orphan, library_precursor_mz, library_transitions,
167180
identified_peptides, identified_precursor_mz, identified_proteins,
168181
transitions_used, library_coverage (float, identified_peptides /
169-
library_peptides rounded to COVERAGE_DECIMALS)
182+
library_peptides_identifiable rounded to COVERAGE_DECIMALS)
183+
184+
library_peptides stays the full declared count so an orphan library entry
185+
stays visible; library_coverage is divided by the identifiable subset
186+
only, because an orphan is structurally incapable of being identified and
187+
would otherwise impose a ceiling no tool result can exceed.
170188
171189
Raises OpenSwathMetricsError if the featureXML cannot be parsed or a
172190
feature references a transition the library does not define.
@@ -208,10 +226,18 @@ def count_identifications(library, featurexml_path):
208226
identified_proteins.update(peptide["proteins"])
209227

210228
library_peptides = library["library_peptides"]
211-
coverage = (len(identified_peptides) / library_peptides
212-
if library_peptides else 0.0)
229+
# Identifiable = declared peptides that at least one transition references.
230+
# Intersecting with the declared set keeps a transition pointing at an
231+
# undeclared peptide out of the denominator; the join above still fails
232+
# loudly if a feature reaches such a peptide.
233+
identifiable = {t["peptide"] for t in transitions.values()} & set(peptides)
234+
identifiable_count = len(identifiable)
235+
coverage = (len(identified_peptides) / identifiable_count
236+
if identifiable_count else 0.0)
213237
return {
214238
"library_peptides": library_peptides,
239+
"library_peptides_identifiable": identifiable_count,
240+
"library_peptides_orphan": library_peptides - identifiable_count,
215241
"library_precursor_mz": library["library_precursor_mz"],
216242
"library_transitions": library["library_transitions"],
217243
"identified_peptides": len(identified_peptides),

‎benchmark/report/report_generate.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,13 @@ def _fmt_metric(name, value):
201201
return f"{value:.2f} s"
202202
if name.endswith("peak_rss_kb"):
203203
return fmt_bytes(float(value) * 1024)
204+
# library_coverage is a fraction of a population, not a bare scalar:
205+
# render it as a percentage so it cannot be misread as an absolute
206+
# count or an error magnitude. The numerator/denominator are shown as
207+
# their own rows (identified_peptides / library_peptides_identifiable),
208+
# so the fraction itself stays reconstructible from the table.
209+
if name.endswith("library_coverage"):
210+
return f"{value * 100:.1f}%"
204211
if name.endswith("_s"):
205212
return fmt_seconds(value)
206213
if abs(value) < 10:
@@ -1132,11 +1139,27 @@ def normalize_smoke(args):
11321139
#
11331140
# PSM / FDR counts are absent by design: DIA emits no spectrum-level
11341141
# identifications. See benchmark/openswath_metrics.py for the definitions.
1142+
#
1143+
# library_coverage is measured over library_peptides_identifiable, NOT over
1144+
# library_peptides. A declared peptide with no transitions has no precursor,
1145+
# so no transition, so no native_id a feature could carry: it can never be
1146+
# identified, and counting it in the ratio capped coverage at a ceiling fixed
1147+
# by library construction rather than by the tool. library_peptides stays the
1148+
# full declared count so such an orphan stays visible, and
1149+
# library_peptides_orphan reports it outright.
1150+
#
1151+
# NOTE FOR TRENDS: this changed library_coverage's MEANING, not just its
1152+
# accuracy. Historical values are identified/library_peptides and are NOT
1153+
# comparable with values recorded after this change, which are
1154+
# identified/library_peptides_identifiable. Runs recorded either side of the
1155+
# change will plot as one series with a step at the change point.
11351156
_OPENSWATH_CORRECTNESS_METRICS = {
11361157
"actual_features": "identified_features",
11371158
"overall_quality_sum": "feature_quality_sum",
11381159
"total_intensity": "total_intensity",
11391160
"library_peptides": "library_peptides",
1161+
"library_peptides_identifiable": "library_peptides_identifiable",
1162+
"library_peptides_orphan": "library_peptides_orphan",
11401163
"library_precursor_mz": "library_precursor_mz",
11411164
"library_transitions": "library_transitions",
11421165
"identified_peptides": "identified_peptides",

‎benchmark/report/test_generic_renderer.py‎

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import json
2121
import math
2222
import os
23+
import re
2324
import sys
2425
import tempfile
2526
import shutil
@@ -1450,7 +1451,12 @@ def test_render_trends_single_run_and_missing_metric():
14501451

14511452
def _library_correctness():
14521453
"""The correctness block run_openswath_benchmark.sh records after the
1453-
TraML <-> featureXML join (see benchmark/openswath_metrics.py)."""
1454+
TraML <-> featureXML join (see benchmark/openswath_metrics.py).
1455+
1456+
Mirrors the reference fixture: 7 declared peptides, one of which
1457+
(PEPTIDEA_Extra) carries no transitions, so 6 are identifiable and all 6
1458+
were identified -> library_coverage 1.0 over the identifiable base.
1459+
"""
14541460
return {
14551461
"expected_features": 6,
14561462
"actual_features": 6,
@@ -1459,13 +1465,15 @@ def _library_correctness():
14591465
"total_intensity": 149891.59,
14601466
"qc_charge_distribution": [[1, 19], [2, 19], [3, 19]],
14611467
"library_peptides": 7,
1468+
"library_peptides_identifiable": 6,
1469+
"library_peptides_orphan": 1,
14621470
"library_precursor_mz": 6,
14631471
"library_transitions": 18,
14641472
"identified_peptides": 6,
14651473
"identified_precursor_mz": 6,
14661474
"identified_proteins": 1,
14671475
"transitions_used": 18,
1468-
"library_coverage": 0.857143,
1476+
"library_coverage": 1.0,
14691477
}
14701478

14711479

@@ -1495,13 +1503,17 @@ def test_normalize_openswath_lifts_library_metrics():
14951503
# same names, straight from the library join
14961504
assert m["total_intensity"] == 149891.59, m
14971505
assert m["library_peptides"] == 7, m
1506+
assert m["library_peptides_identifiable"] == 6, m
1507+
assert m["library_peptides_orphan"] == 1, m
14981508
assert m["library_precursor_mz"] == 6, m
14991509
assert m["library_transitions"] == 18, m
15001510
assert m["identified_peptides"] == 6, m
15011511
assert m["identified_precursor_mz"] == 6, m
15021512
assert m["identified_proteins"] == 1, m
15031513
assert m["transitions_used"] == 18, m
1504-
assert m["library_coverage"] == 0.857143, m
1514+
# coverage is over the identifiable base, so a run that finds every
1515+
# searchable peptide reads 1.0 while the orphan stays visible above
1516+
assert m["library_coverage"] == 1.0, m
15051517

15061518
# not measurements -> not lifted
15071519
assert "expected_features" not in m, m
@@ -1546,6 +1558,8 @@ def test_openswath_library_metrics_reach_trends():
15461558
cols = {c["name"]: c["kind"] for c in series[0]["columns"]}
15471559
for key in ("identified_peptides", "identified_precursor_mz",
15481560
"identified_proteins", "library_peptides",
1561+
"library_peptides_identifiable",
1562+
"library_peptides_orphan",
15491563
"library_coverage", "identified_features",
15501564
"feature_quality_sum", "total_intensity"):
15511565
assert key in cols, (key, sorted(cols))
@@ -1554,8 +1568,10 @@ def test_openswath_library_metrics_reach_trends():
15541568
# the run that predates the metrics keeps a None (rendered as a dash)
15551569
assert series[0]["values"]["identified_peptides"] == [None, 6], \
15561570
series[0]["values"]["identified_peptides"]
1557-
assert series[0]["values"]["library_coverage"] == [None, 0.857143], \
1571+
assert series[0]["values"]["library_coverage"] == [None, 1.0], \
15581572
series[0]["values"]["library_coverage"]
1573+
assert series[0]["values"]["library_peptides_orphan"] == [None, 1], \
1574+
series[0]["values"]["library_peptides_orphan"]
15591575

15601576
# and the values reach the rendered HTML through the generic path
15611577
import datetime as _dt
@@ -1566,11 +1582,36 @@ def test_openswath_library_metrics_reach_trends():
15661582
html = fh.read()
15671583
assert "identified_peptides" in html, "metric column not rendered"
15681584
assert "library_coverage" in html, "metric column not rendered"
1569-
# the cell text is whatever the shared formatter produces for the
1570-
# metric, not the raw JSON number (_fmt_metric applies its own
1571-
# precision rules), so assert against the formatter itself.
1572-
assert _fmt_metric("library_coverage", 0.857143) in html, \
1573-
"formatted metric value not rendered"
1585+
assert "library_peptides_identifiable" in html, "metric column not rendered"
1586+
assert "library_peptides_orphan" in html, "metric column not rendered"
1587+
# Assert against a LITERAL expected rendering, not a live
1588+
# _fmt_metric() call. Comparing against the formatter's own output
1589+
# passes for the wrong reason: any formatter change, including a total
1590+
# regression, would satisfy it. Instead the coverage CELL is extracted
1591+
# from the rendered row and compared to the expected literal, so the
1592+
# formatter is genuinely protected. library_coverage is a fraction of
1593+
# a population, so it must render as a percentage (1.0 -> "100.0%")
1594+
# and never as a bare "1.000" scalar.
1595+
cell = re.search(
1596+
r"<td class='mono'>library_coverage</td><td class='num'>([^<]*)</td>",
1597+
html)
1598+
assert cell, "library_coverage row not found in rendered HTML"
1599+
assert cell.group(1) == "100.0%", (
1600+
"library_coverage rendered as %r, expected '100.0%%'" % cell.group(1))
1601+
# same expectation for the comparison table, whose key is namespaced.
1602+
# That row is [key | baseline | current | delta]; the baseline run
1603+
# predates the library metrics, so its cell is an em dash and the
1604+
# current run's cell is the formatted value.
1605+
cell_cmp = re.search(
1606+
r"<td class='mono'>correctness\.library_coverage</td>"
1607+
r"<td class='num'>([^<]*)</td><td class='num'>([^<]*)</td>", html)
1608+
assert cell_cmp, "correctness.library_coverage row not found"
1609+
assert cell_cmp.group(1) == "—", (
1610+
"expected an em dash for the pre-metrics baseline, got %r"
1611+
% cell_cmp.group(1))
1612+
assert cell_cmp.group(2) == "100.0%", (
1613+
"correctness.library_coverage rendered as %r, expected '100.0%%'"
1614+
% cell_cmp.group(2))
15741615
print(" PASS: OpenSwath library metrics reach the generic Trends table")
15751616
finally:
15761617
shutil.rmtree(tmpdir)

‎benchmark/test_openswath_metrics.py‎

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,17 @@ def test_feature_join_counts():
189189
assert counts["identified_precursor_mz"] == 2, counts # one m/z each
190190
assert counts["identified_proteins"] == 2, counts # ProtA, ProtB
191191
assert counts["transitions_used"] == 3, counts
192-
assert counts["library_peptides"] == 4, counts # incl. orphan
192+
assert counts["library_peptides"] == 4, counts # incl. orphans
193+
# Only PepA and PepB carry transitions; PepC and PepOrphan are both
194+
# declared-but-transition-less, so 4 declared splits 2 + 2.
195+
assert counts["library_peptides_identifiable"] == 2, counts # PepA, PepB
196+
assert counts["library_peptides_orphan"] == 2, counts # PepC, PepOrphan
193197
assert counts["library_precursor_mz"] == 2, counts
194198
assert counts["library_transitions"] == 3, counts
195-
# 2 of 4 library peptides
196-
assert counts["library_coverage"] == 0.5, counts
199+
# both IDENTIFIABLE peptides were found, so coverage is 1.0 over the
200+
# identifiable base even though the library declares twice as many
201+
# peptides as can actually be searched.
202+
assert counts["library_coverage"] == 1.0, counts
197203
print("PASS: feature join (distinct peptides/precursors/proteins, coverage)")
198204

199205

@@ -235,6 +241,10 @@ def test_empty_and_missing_inputs():
235241
assert counts["identified_peptides"] == 0, counts
236242
assert counts["library_coverage"] == 0.0, counts
237243
assert counts["library_peptides"] == 2, counts
244+
# PepB is declared but transition-less, so only 1 peptide is
245+
# identifiable even though the library declares 2.
246+
assert counts["library_peptides_identifiable"] == 1, counts
247+
assert counts["library_peptides_orphan"] == 1, counts
238248

239249
for bad in (os.path.join(tmp, "nope.featureXML"),
240250
os.path.join(tmp, "broken.TraML")):
@@ -254,8 +264,11 @@ def test_empty_and_missing_inputs():
254264

255265
def test_reference_fixture_join():
256266
"""Regression guard on the real committed OpenSwath fixtures: the vendored
257-
library has 7 peptides but only 6 precursor m/z values, because one library
258-
peptide carries no transitions and therefore can never be identified."""
267+
library declares 7 peptides but only 6 carry transitions, because
268+
PEPTIDEA_Extra has no transitions and therefore can never be identified.
269+
The run is perfect on every other axis (18/18 transitions, 6/6 precursor
270+
m/z), so coverage must be 6/6 = 1.0 over the identifiable subset, while
271+
the orphan stays visible as its own count instead of penalising it."""
259272
traml = os.path.join(FIXTURES, "OpenSwathWorkflow_1_input.TraML")
260273
feat = os.path.join(FIXTURES, "OpenSwathWorkflow_1_output.featureXML")
261274
lib = om.parse_traml(traml)
@@ -268,12 +281,91 @@ def test_reference_fixture_join():
268281
assert counts["identified_precursor_mz"] == 6, counts
269282
assert counts["identified_proteins"] == 1, counts
270283
assert counts["transitions_used"] == 18, counts
271-
assert counts["library_coverage"] == 0.857143, counts
284+
# declared / identifiable / orphan must all stay observable
285+
assert counts["library_peptides"] == 7, counts
286+
assert counts["library_peptides_identifiable"] == 6, counts
287+
assert counts["library_peptides_orphan"] == 1, counts
288+
assert counts["library_peptides_orphan"] == (
289+
counts["library_peptides"] - counts["library_peptides_identifiable"]), counts
290+
assert counts["library_coverage"] == 1.0, counts
272291
# No PSM/FDR key may ever appear: DIA emits no spectrum-level
273292
# identifications, so such a count would be fabricated.
274293
assert not [k for k in counts if "psm" in k.lower()], counts
275294
assert not [k for k in counts if "fdr" in k.lower()], counts
276-
print("PASS: reference fixture join (7 peptides / 6 precursor m/z / 6 identified)")
295+
print("PASS: reference fixture join (7 declared / 6 identifiable / 6 identified "
296+
"-> coverage 1.0)")
297+
298+
299+
def test_orphan_peptide_does_not_penalise_coverage():
300+
"""F5 regression guard: a declared-but-transition-less peptide must not
301+
lower library_coverage.
302+
303+
The fixture library carries one such orphan (PEPTIDEA_Extra). Two runs
304+
that find every IDENTIFIABLE peptide must both report coverage 1.0,
305+
whether or not the library also declares orphans. Before this guard the
306+
metric divided by all declared peptides, so the same perfect run reported
307+
6/7 = 0.857143 and looked like a 14% identification failure caused by the
308+
tool. If this test ever fails, either the denominator changed again or the
309+
orphan counts changed -- both require the module docstring to change too.
310+
"""
311+
traml = os.path.join(FIXTURES, "OpenSwathWorkflow_1_input.TraML")
312+
feat = os.path.join(FIXTURES, "OpenSwathWorkflow_1_output.featureXML")
313+
lib = om.parse_traml(traml)
314+
counts = om.count_identifications(lib, feat)
315+
316+
# every identifiable peptide was found
317+
assert counts["identified_peptides"] == counts["library_peptides_identifiable"], \
318+
counts
319+
# so coverage is exactly 1.0 despite the orphan being declared
320+
assert counts["library_peptides_orphan"] > 0, counts # the orphan is present
321+
assert counts["library_coverage"] == 1.0, counts
322+
323+
# Same result with an orphan-free library: 3 declared, 3 identifiable,
324+
# all 3 identified -> coverage 1.0. Identifiable coverage is therefore
325+
# independent of how many orphans the library happens to declare.
326+
with tempfile.TemporaryDirectory() as tmp:
327+
lib2 = os.path.join(tmp, "noorphan.TraML")
328+
make_traml(
329+
lib2,
330+
proteins=[("ProtA", "uniprot_a")],
331+
peptides=[("PepA", "AAAA", "2", "ProtA"),
332+
("PepB", "BBBB", "2", "ProtA"),
333+
("PepC", "CCCC", "2", "ProtA")],
334+
transitions=[("t1", "PepA", "400.5"),
335+
("t2", "PepB", "500.5"),
336+
("t3", "PepC", "600.5")],
337+
)
338+
feat2 = os.path.join(tmp, "all.featureXML")
339+
make_featurexml(feat2, [["t1"], ["t2"], ["t3"]])
340+
c2 = om.count_identifications(om.parse_traml(lib2), feat2)
341+
assert c2["library_peptides"] == 3, c2
342+
assert c2["library_peptides_identifiable"] == 3, c2
343+
assert c2["library_peptides_orphan"] == 0, c2
344+
assert c2["library_coverage"] == 1.0, c2
345+
346+
# A genuine miss must still be penalised: drop one peptide's feature and
347+
# coverage falls below 1.0. This is what stops the orphan exclusion from
348+
# degenerating into "coverage is always 1.0".
349+
with tempfile.TemporaryDirectory() as tmp:
350+
lib3 = os.path.join(tmp, "miss.TraML")
351+
make_traml(
352+
lib3,
353+
proteins=[("ProtA", "uniprot_a")],
354+
peptides=[("PepA", "AAAA", "2", "ProtA"),
355+
("PepB", "BBBB", "2", "ProtA"),
356+
("PepC", "CCCC", "2", "ProtA")],
357+
transitions=[("t1", "PepA", "400.5"),
358+
("t2", "PepB", "500.5"),
359+
("t3", "PepC", "600.5")],
360+
)
361+
feat3 = os.path.join(tmp, "partial.featureXML")
362+
make_featurexml(feat3, [["t1"], ["t2"]]) # PepC not identified
363+
c3 = om.count_identifications(om.parse_traml(lib3), feat3)
364+
assert c3["identified_peptides"] == 2, c3
365+
assert c3["library_peptides_identifiable"] == 3, c3
366+
assert c3["library_coverage"] == 0.666667, c3
367+
368+
print("PASS: orphan peptide does not penalise coverage; real misses still do")
277369

278370

279371
def test_isobaric_peptides_share_one_precursor_mz():
@@ -325,6 +417,7 @@ def main():
325417
test_unmatched_transition_fails_loudly()
326418
test_empty_and_missing_inputs()
327419
test_reference_fixture_join()
420+
test_orphan_peptide_does_not_penalise_coverage()
328421
test_isobaric_peptides_share_one_precursor_mz()
329422
print("\nAll openswath_metrics tests passed.")
330423

0 commit comments

Comments
 (0)