Skip to content

fix: guard against ZeroDivisionError in Comparitor._calc_stats - #566

Merged
tompollard merged 3 commits into
MIT-LCP:mainfrom
haoyu-haoyu:fix/evaluate-zero-division
Nov 2, 2026
Merged

tompollard merged 3 commits into
MIT-LCP:mainfrom
haoyu-haoyu:fix/evaluate-zero-division

Conversation

@haoyu-haoyu

Copy link
Copy Markdown
Contributor

Summary

Guard against ZeroDivisionError when comparing annotations with empty reference or test arrays.

Bug

Comparitor._calc_stats() divides tp by (tp + fn) for sensitivity and by n_test for positive predictivity without checking for zero denominators. When either annotation array is empty, this raises ZeroDivisionError.

Fix

Return float("nan") when the denominator is zero, matching the convention used by sklearn.metrics for undefined ratios.

Tests

Three module-level test functions covering all edge cases:

  • Empty reference → sensitivity=NaN, PPV=0.0
  • Empty test → sensitivity=0.0, PPV=NaN
  • Both empty → both NaN

Fixes #278

@tompollard

Copy link
Copy Markdown
Member

Thanks for picking this up, the crash in #278 is still there on main, so a fix would be welcome.

Could you rebase on main? The package has moved to src/wfdb/, so the PR doesn't apply cleanly any more.

When I applied the change to current main by hand, only the empty-reference case was fixed. The empty-test and both-empty tests still fail with:

IndexError: arrays used as indices must be of integer (or boolean) type

That error comes from a few lines earlier in _calc_stats: np.array(range(self.n_test)) gives a float array when n_test is 0. Changing it to np.arange(self.n_test) fixes it, and all three tests then pass. The empty-test case matters most, because it's what you get when the detector finds no beats.

A few small things while you're there:

  • Please remove the pass added at the end of TestQrs, and the comment about the class above being skipped. That's no longer true.
  • Please move import math to the top of the file.
  • Could you add a short entry to docs/changes.rst?

tompollard added a commit that referenced this pull request Oct 31, 2026
`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`.
haoyu-haoyu and others added 3 commits November 2, 2026 09:44
When there are no reference annotations (tp + fn == 0) or no test
annotations (n_test == 0), computing sensitivity and positive
predictivity raises ZeroDivisionError. Return NaN instead, matching
the convention used by sklearn.metrics for undefined ratios.

Fixes MIT-LCP#278
Cover three boundary scenarios that previously raised ZeroDivisionError:
- Empty reference, non-empty test → sensitivity=NaN, PPV=0.0
- Non-empty reference, empty test → sensitivity=0.0, PPV=NaN
- Both empty → both NaN
np.array(range(0)) is a float array, which can't be used as an index,
so an empty set of test annotations still failed with an IndexError
before reaching the zero division check. Use np.arange instead, and
add a changelog entry.
@tompollard
tompollard force-pushed the fix/evaluate-zero-division branch from 387e12c to dec7750 Compare November 2, 2026 14:46
@tompollard

Copy link
Copy Markdown
Member

Thanks for the fix! To get this merged, I've rebased it onto main, moved the tests into TestCompareAnnotations, changed np.array(range(self.n_test)) to np.arange(self.n_test) so that empty test annotations work too, and added a changelog entry.

@tompollard
tompollard merged commit 0ecb200 into MIT-LCP:main Nov 2, 2026
18 checks passed
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.

ZeroDivisionError in evaluate.py

2 participants