Skip to content

Commit adf00ae

Browse files
authored
Match each test annotation at most once in compare_annotations (#606)
`compare_annotations` can match one test annotation to two reference annotations: ```python c = processing.compare_annotations(np.array([0, 2, 4, 5]), np.array([1, 5]), window_width=6) c.matching_sample_nums # [0, -1, 0, 1]: test annotation 0 is used twice c.fp, c.positive_predictivity # -1, 1.5 ``` When a reference annotation loses a contested test annotation to the next reference, `compare` falls back to the previous test annotation. It only checks that the *previous reference* didn't take it, so if that reference was unmatched, a test annotation matched earlier on can be matched again. **Fix:** keep track of the last matched test annotation. Matches are made in increasing order, so the fallback is used only if it comes after that one. This replaces the old check (which it covers). **Checking that nothing else changes:** - On 200,000 random small cases, the new code gives exactly the same matches as `main` whenever `main` doesn't match a test annotation twice (about 6% of the cases did). With the fix, none do. - On record 100 (gqrs and xqrs against the reference beats, two window sizes), the matches are identical. **Tests:** the example above, plus a randomized check that matches are unique, in order and within the window, and that `fp` is never negative. Both fail on `main`. `pytest -m "not network"` and the docs build pass. This is separate from #566, which handles empty inputs in `_calc_stats`.
2 parents 0f12cca + 717f332 commit adf00ae

3 files changed

Lines changed: 38 additions & 5 deletions

File tree

‎docs/changes.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Unreleased
2323
**Speed up XQRS backsearch**
2424
``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.
2525

26+
**Fix test annotations matched twice in compare_annotations**
27+
``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.
28+
2629
**Fix record name and add write directory in csv2ann**
2730
``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.
2831

‎src/wfdb/processing/evaluate.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ def compare(self):
155155
"""
156156
test_samp_num = 0
157157
ref_samp_num = 0
158+
# The last test annotation that was matched. Test annotations are
159+
# matched in increasing order, so none up to this one can be reused.
160+
last_matched_test_num = -1
158161

159162
# Iterate through the reference sample numbers
160163
while ref_samp_num < self.n_ref and test_samp_num < self.n_test:
@@ -186,11 +189,7 @@ def compare(self):
186189
# if not already assigned to a previous sample.
187190
# It will be the previous testing sample number in any
188191
# possible case (scenario D below), or nothing.
189-
if closest_samp_num and (
190-
not ref_samp_num
191-
or closest_samp_num - 1
192-
!= self.matching_sample_nums[ref_samp_num - 1]
193-
):
192+
if closest_samp_num - 1 > last_matched_test_num:
194193
# The previous test annotation is inspected
195194
closest_samp_num = closest_samp_num - 1
196195
smallest_samp_diff = abs(
@@ -200,6 +199,7 @@ def compare(self):
200199
# Assign the reference-test pair if close enough
201200
if smallest_samp_diff < self.window_width:
202201
self.matching_sample_nums[ref_samp_num] = closest_samp_num
202+
last_matched_test_num = closest_samp_num
203203
# Set the starting test sample number to inspect
204204
# for the next reference sample.
205205
test_samp_num = closest_samp_num + 1
@@ -213,6 +213,7 @@ def compare(self):
213213
# Assign the reference-test pair if close enough
214214
if smallest_samp_diff < self.window_width:
215215
self.matching_sample_nums[ref_samp_num] = closest_samp_num
216+
last_matched_test_num = closest_samp_num
216217
# Increment the starting test sample number to inspect
217218
# for the next reference sample.
218219
test_samp_num = closest_samp_num + 1

‎tests/test_processing.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,35 @@ def test_rejects_unordered_unsigned(self):
244244
processing.compute_hr(sig_len=20, qrs_inds=qrs_inds, fs=1)
245245

246246

247+
class TestCompareAnnotations:
248+
"""Tests for compare_annotations."""
249+
250+
def test_test_annotation_matched_once(self):
251+
"""A test annotation isn't matched to two reference annotations."""
252+
comparitor = processing.compare_annotations(
253+
np.array([0, 2, 4, 5]), np.array([1, 5]), window_width=6
254+
)
255+
256+
assert comparitor.matching_sample_nums.tolist() == [0, -1, -1, 1]
257+
assert (comparitor.tp, comparitor.fp, comparitor.fn) == (2, 0, 2)
258+
assert comparitor.positive_predictivity == 1.0
259+
260+
def test_random_annotations(self):
261+
"""Matches are unique, in order and within the window."""
262+
rng = np.random.default_rng(0)
263+
for _ in range(2000):
264+
ref = np.sort(rng.choice(40, rng.integers(1, 9), replace=False))
265+
test = np.sort(rng.choice(40, rng.integers(1, 9), replace=False))
266+
window_width = int(rng.integers(1, 10))
267+
comparitor = processing.compare_annotations(ref, test, window_width)
268+
269+
matches = comparitor.matching_sample_nums
270+
matched = matches[matches != -1]
271+
assert np.all(np.diff(matched) > 0)
272+
assert np.all(np.abs(ref[matches != -1] - test[matched]) < window_width)
273+
assert 0 <= comparitor.fp <= len(test)
274+
275+
247276
class TestQrs:
248277
"""Testing QRS detectors"""
249278

0 commit comments

Comments
 (0)