From 717f3321af3e507be302165f2b98bd63944e6882 Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:50:27 +0530 Subject: [PATCH] Match each test annotation at most once in compare_annotations When a reference annotation lost a contested test annotation to the next reference, it fell back to the previous test annotation, checking only that the previous reference hadn't taken it. If that reference was unmatched, a test annotation matched earlier could be matched again, giving a negative false positive count and a PPV above 1. Track the last matched test annotation instead. Matches are made in increasing order, so the fallback is only used if it comes after it. --- docs/changes.rst | 3 +++ src/wfdb/processing/evaluate.py | 11 ++++++----- tests/test_processing.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 8c63c074..61b2d003 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -23,6 +23,9 @@ Unreleased **Speed up XQRS backsearch** ``XQRS`` no longer rechecks peaks that an earlier backsearch already rejected. Long stretches without QRS complexes (for example a disconnected lead) made detection take quadratic time, up to hours on long records. The detected QRS locations are unchanged. +**Fix test annotations matched twice in compare_annotations** + ``compare_annotations`` (``Comparitor.compare``) could match one test annotation to two reference annotations, which gave a negative number of false positives and a positive predictivity above 1. Each test annotation is now matched at most once. Results are unchanged when no annotation was matched twice. + **Fix record name and add write directory in csv2ann** ``csv2ann`` now takes the record name from the CSV file name without its directory and extension, as ``csv_to_wfdb`` does. Paths with a directory (such as ``data/x.csv``) used to fail when writing, and ``./x.csv`` gave an empty name. The annotation file is written to the new ``write_dir`` argument, which defaults to the current directory. ``csv2ann`` is now included in the API documentation. diff --git a/src/wfdb/processing/evaluate.py b/src/wfdb/processing/evaluate.py index e65a4257..6c8e41ac 100644 --- a/src/wfdb/processing/evaluate.py +++ b/src/wfdb/processing/evaluate.py @@ -155,6 +155,9 @@ def compare(self): """ test_samp_num = 0 ref_samp_num = 0 + # The last test annotation that was matched. Test annotations are + # matched in increasing order, so none up to this one can be reused. + last_matched_test_num = -1 # Iterate through the reference sample numbers while ref_samp_num < self.n_ref and test_samp_num < self.n_test: @@ -186,11 +189,7 @@ def compare(self): # if not already assigned to a previous sample. # It will be the previous testing sample number in any # possible case (scenario D below), or nothing. - if closest_samp_num and ( - not ref_samp_num - or closest_samp_num - 1 - != self.matching_sample_nums[ref_samp_num - 1] - ): + if closest_samp_num - 1 > last_matched_test_num: # The previous test annotation is inspected closest_samp_num = closest_samp_num - 1 smallest_samp_diff = abs( @@ -200,6 +199,7 @@ def compare(self): # Assign the reference-test pair if close enough if smallest_samp_diff < self.window_width: self.matching_sample_nums[ref_samp_num] = closest_samp_num + last_matched_test_num = closest_samp_num # Set the starting test sample number to inspect # for the next reference sample. test_samp_num = closest_samp_num + 1 @@ -213,6 +213,7 @@ def compare(self): # Assign the reference-test pair if close enough if smallest_samp_diff < self.window_width: self.matching_sample_nums[ref_samp_num] = closest_samp_num + last_matched_test_num = closest_samp_num # Increment the starting test sample number to inspect # for the next reference sample. test_samp_num = closest_samp_num + 1 diff --git a/tests/test_processing.py b/tests/test_processing.py index 60bb7bd0..f1fd5c83 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -244,6 +244,35 @@ def test_rejects_unordered_unsigned(self): processing.compute_hr(sig_len=20, qrs_inds=qrs_inds, fs=1) +class TestCompareAnnotations: + """Tests for compare_annotations.""" + + def test_test_annotation_matched_once(self): + """A test annotation isn't matched to two reference annotations.""" + comparitor = processing.compare_annotations( + np.array([0, 2, 4, 5]), np.array([1, 5]), window_width=6 + ) + + assert comparitor.matching_sample_nums.tolist() == [0, -1, -1, 1] + assert (comparitor.tp, comparitor.fp, comparitor.fn) == (2, 0, 2) + assert comparitor.positive_predictivity == 1.0 + + def test_random_annotations(self): + """Matches are unique, in order and within the window.""" + rng = np.random.default_rng(0) + for _ in range(2000): + ref = np.sort(rng.choice(40, rng.integers(1, 9), replace=False)) + test = np.sort(rng.choice(40, rng.integers(1, 9), replace=False)) + window_width = int(rng.integers(1, 10)) + comparitor = processing.compare_annotations(ref, test, window_width) + + matches = comparitor.matching_sample_nums + matched = matches[matches != -1] + assert np.all(np.diff(matched) > 0) + assert np.all(np.abs(ref[matches != -1] - test[matched]) < window_width) + assert 0 <= comparitor.fp <= len(test) + + class TestQrs: """Testing QRS detectors"""