Skip to content

Commit 534e522

Browse files
authored
Skip already-rejected peaks in XQRS backsearch (#596)
When backsearch runs and finds nothing, `last_qrs_peak_num` stays put. On the next peak backsearch runs again over the same range plus one more peak, and so on. A long stretch with no QRS complexes (lead off, flat line, low-amplitude noise) is therefore rescanned at every peak, which is quadratic in the length of the stretch. I think this is what #352 is seeing: most records are fine, the ones with a long bad section take hours. Record 100 with a gap of low-amplitude noise inserted: | gap | before | after | |---|---|---| | 10 min | 1.7 s | 0.4 s | | 40 min | 23.4 s | 1.3 s | The fix remembers the last peak a backsearch checked and starts the next one after it. A peak's backsearch result only depends on `qrs_thr`, `last_qrs_ind` and the signal, which only change in `_update_qrs`, and `_update_qrs` always moves `last_qrs_peak_num` up to the current peak. So the skipped peaks would have been rejected again, and the detector ends up in exactly the same state. Testing: - New test: inserts a 2-minute noise gap into record 100 and checks backsearch never checks the same peak twice, and that beats on both sides are found. Fails on the old code. - Compared old vs new on 300 random cases (sample-data records with inserted noise gaps, flat sections and amplitude changes; random `hr_init`, `ref_period`, `t_inspect_period`, with and without learning). `qrs_inds`, `backsearch_qrs_inds` and all running parameters were identical. - I also wrote a short Lean proof of the "same state after every peak" argument: https://github.com/TheNetherWatcher/wfdb-backsearch-proof. The detector is modelled abstractly there, so it relies on the three properties above, which I checked by reading the code. Fixes the slowdown in #352.
2 parents b8914e1 + 715371f commit 534e522

3 files changed

Lines changed: 40 additions & 1 deletion

File tree

‎docs/changes.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Unreleased
2020
**Build with uv_build**
2121
The package is now built with ``uv_build`` instead of ``hatchling``. The version number is specified in ``pyproject.toml`` only, and ``wfdb.__version__`` is read from the installed package metadata (it is ``"unknown"`` if the package is run from a source tree without being installed).
2222

23+
**Speed up XQRS backsearch**
24+
``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.
25+
2326
**Use Ruff for formatting and linting**
2427
The ``dev`` extra now includes ``ruff`` instead of ``black`` and ``pylint``, and the code is formatted with a line length of 88 (the default).
2528

‎src/wfdb/processing/qrs.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,11 +610,16 @@ def _backsearch(self):
610610
611611
"""
612612
if self.last_qrs_peak_num is not None:
613-
for peak_num in range(self.last_qrs_peak_num + 1, self.peak_num + 1):
613+
# Skip peaks a previous backsearch already rejected. Their result
614+
# can only change after a QRS update, which moves
615+
# last_qrs_peak_num past them.
616+
start = max(self.last_qrs_peak_num, self._backsearch_peak_num) + 1
617+
for peak_num in range(start, self.peak_num + 1):
614618
if self._is_qrs(peak_num=peak_num, backsearch=True):
615619
self._update_qrs(peak_num=peak_num, backsearch=True)
616620
# No need to update noise parameters if it was classified as
617621
# noise. It would have already been updated.
622+
self._backsearch_peak_num = self.peak_num
618623

619624
def _run_detection(self):
620625
"""Run the QRS detection after the signals and parameters have been set.
@@ -635,6 +640,8 @@ def _run_detection(self):
635640
self.qrs_inds = []
636641
# QRS indices found via backsearch
637642
self.backsearch_qrs_inds = []
643+
# Last peak number checked by a backsearch
644+
self._backsearch_peak_num = -1
638645

639646
# Iterate through MWI signal peak indices
640647
for self.peak_num in range(self.n_peaks_i):

‎tests/test_processing.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,35 @@ def test_xqrs(self):
192192
assert comparitor.sensitivity > 0.99
193193
assert comparitor.positive_predictivity > 0.99
194194

195+
def test_xqrs_backsearch_checks_each_peak_once(self):
196+
"""Backsearch does not recheck rejected peaks in a gap (issue #352)."""
197+
sig, fields = wfdb.rdsamp("sample-data/100", channels=[0])
198+
fs = fields["fs"]
199+
x = sig[:, 0]
200+
# Beats, then low-amplitude noise (as from a disconnected lead), then beats
201+
rng = np.random.default_rng(0)
202+
gap = 0.01 * rng.standard_normal(120 * fs)
203+
y = np.concatenate([x[: 30 * fs], gap, x[30 * fs : 60 * fs]])
204+
205+
xqrs = processing.XQRS(sig=y, fs=fs)
206+
checked = []
207+
is_qrs = xqrs._is_qrs
208+
209+
def spy(peak_num, backsearch=False):
210+
if backsearch:
211+
checked.append(peak_num)
212+
return is_qrs(peak_num, backsearch=backsearch)
213+
214+
with mock.patch.object(xqrs, "_is_qrs", side_effect=spy):
215+
xqrs.detect(verbose=False)
216+
217+
assert len(checked) > 0
218+
assert len(checked) == len(set(checked))
219+
# Beats on both sides of the gap are still found
220+
n_before = np.sum(xqrs.qrs_inds < 30 * fs)
221+
n_after = np.sum(xqrs.qrs_inds > 150 * fs)
222+
assert n_before > 30 and n_after > 30
223+
195224

196225
class TestXQRSDetect:
197226
"""Tests for the xqrs_detect convenience wrapper."""

0 commit comments

Comments
 (0)