Skip to content

Commit

Permalink
Add heartbeat detection
Browse files Browse the repository at this point in the history
  • Loading branch information
cbrnr committed Jul 28, 2021
1 parent 197e653 commit a80f54b
Show file tree
Hide file tree
Showing 22 changed files with 1,816 additions and 1 deletion.
81 changes: 81 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Release

on:
release:
types: [published]
workflow_dispatch:
# allow manual runs on branches without a PR


env:
CIBW_TEST_REQUIRES: pytest
CIBW_TEST_EXTRAS: full
CIBW_TEST_COMMAND: pytest {package}
CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014
CIBW_MANYLINUX_I686_IMAGE: manylinux2014
CIBW_SKIP: pp*


jobs:
pre-commit:
name: Pre-commit checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: pre-commit/action@v2.0.3


build_wheels:
name: Build wheels on ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
matrix:
os: [ubuntu-20.04, windows-2019, macos-10.15]

steps:
- uses: actions/checkout@v2

- name: Build wheels
uses: pypa/cibuildwheel@v1.12.0

- uses: actions/upload-artifact@v2
with:
path: ./wheelhouse/*.whl


build_sdist:
name: Build source distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- uses: actions/setup-python@v2
name: Install Python
with:
python-version: 3.7

- name: Build sdist
run: |
python -m pip install numpy
python setup.py sdist
- uses: actions/upload-artifact@v2
with:
path: dist/*.tar.gz


upload_pypi:
needs: [pre-commit, build_wheels, build_sdist]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v2
with:
name: artifact
path: dist

- uses: pypa/gh-action-pypi-publish@v1.4.2
with:
user: __token__
password: ${{ secrets.pypi_password }}
50 changes: 50 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Test

on:
push:
branches:
- main
pull_request:
paths-ignore:
- 'docs/**'
workflow_dispatch:
# allow manual runs on branches without a PR


env:
CIBW_TEST_REQUIRES: pytest
CIBW_TEST_EXTRAS: full
CIBW_TEST_COMMAND: pytest {package}
CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014
CIBW_MANYLINUX_I686_IMAGE: manylinux2014
CIBW_SKIP: pp*


jobs:
pre-commit:
name: Pre-commit checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: pre-commit/action@v2.0.3


build_wheels:
needs: [pre-commit]
name: Build wheels on ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
matrix:
os: [ubuntu-20.04, windows-2019, macos-10.15]

steps:
- uses: actions/checkout@v2

- name: Build wheels
uses: pypa/cibuildwheel@v1.12.0

- uses: actions/upload-artifact@v2
with:
path: ./wheelhouse/*.whl
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
**/__pycache__/
!/.gitignore
**/.ipynb_checkpoints/
**/.vscode
build
dist
*.egg-info
*.pyd
*.so
34 changes: 34 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-yaml
- id: check-toml
- id: end-of-file-fixer
exclude: "svg"
- id: trailing-whitespace
- id: mixed-line-ending
- id: double-quote-string-fixer
- id: check-builtin-literals

- repo: https://github.com/asottile/add-trailing-comma
rev: v2.1.0
hooks:
- id: add-trailing-comma

- repo: https://github.com/pycqa/isort
rev: 5.8.0
hooks:
- id: isort

- repo: https://github.com/pycqa/flake8
rev: 3.9.2
hooks:
- id: flake8

- repo: https://github.com/pycqa/pydocstyle
rev: 6.1.1
hooks:
- id: pydocstyle
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
BSD 3-Clause License

Copyright (c) 2021, Clemens Brunner
Copyright (c) 2021, Florian Hofer, Clemens Brunner
All rights reserved.

Redistribution and use in source and binary forms, with or without
Expand Down
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
![Py Version](https://img.shields.io/pypi/pyversions/sleepecg.svg?logo=python&logoColor=white)
[![PyPI Version](https://img.shields.io/pypi/v/sleepecg)](https://pypi.org/project/sleepecg/)

# sleepecg
This package provides tools for sleep stage classification when [EEG](https://en.wikipedia.org/wiki/Electroencephalography) signals are not available. Based only on [ECG](https://en.wikipedia.org/wiki/Electrocardiography) (and to a lesser extent also movement data), it will feature a functional interface for
- downloading and reading open polysomnography datasets (TODO),
- detecting heartbeats from ECG signals, and
- classifying sleep stages (which includes the complete preprocessing, feature extraction, and classification pipeline) (TODO).


## Installation
You can install sleepecg from PyPI using pip:
```
pip install sleepecg
```


## Heartbeat detection
Since ECG-based sleep staging relies on heartrate variability, a reliable and efficient heartbeat detector is essential. This package provides a detector based on the approach described by [Pan & Tompkins (1985)](https://doi.org/10.1109/TBME.1985.325532). Outsourcing performance-critical code to a C extension (wheels are provided) leads to substantially faster detections as compared to implementations in other Python packages. Benchmarks on [MITDB](https://physionet.org/content/mitdb/1.0.0/), [LTDB](https://physionet.org/content/ltdb/1.0.0/), and [GUDB](https://github.com/berndporr/ECG-GUDB) show that our implementation produces highly reliable detections and runtime scales linearly with signal length or sampling frequency.


### Usage
The function `detect_heartbeats()` finds heartbeats in an unfiltered ECG signal `ecg` with sampling frequency `fs`. It returns a one-dimensional NumPy array containing the indices of the detected heartbeats. A complete example including visualization and performance evaluation is available in `examples/heartbeat_detection.py`.
```python
from sleepecg import detect_heartbeats
detection = detect_heartbeats(ecg, fs)
```


### Performance
We evaluated detector runtime using slices of different lengths from [LTDB](https://physionet.org/content/ltdb/1.0.0/) records which are at least 20 hours long. Error bars in the plot below correspond to the standard errors of the mean.

![LTDB runtimes](https://raw.githubusercontent.com/cbrnr/sleepecg/main/img/ltdb_runtime_logscale.svg)

For the plots below, we evaluated detectors on all [MITDB](https://physionet.org/content/mitdb/1.0.0/) records. We defined a successful detection if a detection and corresponding annotation are within 100ms (i.e. 36 samples). Using a tolerance here is necessary because annotations usually do not coincide with the exact R peak locations. In terms of recall (i.e. sensitivity), our detector is on the same level as BioSPPy's hamilton-detector, NeuroKit's neurokit-detector and WFDB's xqrs. MNE generally finds fewer peaks than other detectors, so there are fewer false positives and higher precision. Comparing F1-scores shows that sleepecg performs as well as other commonly used Python heartbeat detectors.

![MITDB metrics](https://raw.githubusercontent.com/cbrnr/sleepecg/main/img/mitdb_metrics.svg)

For analysis of heartrate variability, detecting the exact location of heartbeats is essential. As a measure of how well a detector can replicate correct RR intervals (RRI), we computed Pearson's correlation coefficient between resampled RRI time series deduced from annotated and detected beat locations from all [GUDB](https://github.com/berndporr/ECG-GUDB) records. In contrast to other databases, GUDB has annotations that are exactly at R peak locations. Our implementation detects peaks in the bandpass-filtered ECG signal, so it is able to produce stable RRI time series without any post-processing. MNE and pan_tompkins_detector from pyecgdetectors often detect an S-peak instead of the R-peak, leading to noisy RR intervals (and thus lower correlation).

![GUDB pearson correlation](https://raw.githubusercontent.com/cbrnr/sleepecg/main/img/gudb_pearson.svg)


We used the following detector calls for all benchmarks:
```python
# mne
import mne # https://pypi.org/project/mne/
detection = mne.preprocessing.ecg.qrs_detector(fs, ecg, verbose=False)

# wfdb_xqrs
import wfdb.processing # https://pypi.org/project/wfdb/
detection = wfdb.processing.xqrs_detect(ecg, fs, verbose=False)

# pyecg_pan_tompkins
import ecgdetectors # https://pypi.org/project/py-ecg-detectors/
detection = ecgdetectors.Detectors(fs).pan_tompkins_detector(ecg)

# biosppy_hamilton
import biosppy # https://pypi.org/project/biosppy/
detection = biosppy.signals.ecg.hamilton_segmenter(ecg, fs)[0]

# heartpy
import heartpy # https://pypi.org/project/heartpy/
wd, m = heartpy.process(ecg, fs)
detection = np.array(wd['peaklist'])[wd['binary_peaklist'].astype(bool)]

# neurokit2_nk
import neurokit2 # https://pypi.org/project/neurokit2/
clean_ecg = neurokit2.ecg.ecg_clean(ecg, int(fs), method='neurokit')
peak_indices = neurokit2.ecg.ecg_findpeaks(clean_ecg, int(fs), method='neurokit')['ECG_R_Peaks']

# neurokit2_kalidas2017
import neurokit2 # https://pypi.org/project/neurokit2/
clean_ecg = neurokit2.ecg.ecg_clean(ecg, int(fs), method='kalidas2017')
peak_indices = neurokit2.ecg.ecg_findpeaks(clean_ecg, int(fs), method='kalidas2017')['ECG_R_Peaks']

# sleepecg
import sleepecg # https://pypi.org/project/sleepecg/
detection = sleepecg.heartbeat_detection.detect_heartbeats(ecg, fs)
```
59 changes: 59 additions & 0 deletions examples/heartbeat_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# %% Imports
import matplotlib.pyplot as plt
import numpy as np

from sleepecg import compare_heartbeats, detect_heartbeats
from sleepecg.io import read_mitbih

# %% Download and read data, run detector
record = list(read_mitbih('./datasets/', 'mitdb', '234'))[1]
detection = detect_heartbeats(record.ecg, record.fs)


# %% Evaluation and visualization
TP, FP, FN = compare_heartbeats(detection, record.annotation, int(record.fs/10))

t = np.arange(len(record.ecg)) / record.fs

fig, ax = plt.subplots(3, sharex=True, figsize=(10, 8))

ax[0].plot(t, record.ecg, color='k', zorder=1, label='ECG')
ax[0].scatter(
record.annotation/record.fs, record.ecg[record.annotation],
marker='o', color='g', s=50, zorder=2, label='annotation',
)
ax[0].set_ylabel('raw signal in mV')

ax[1].eventplot(
detection / record.fs, linelength=0.5, linewidth=0.5,
color='k', zorder=1, label='detection',
)
ax[1].scatter(
FN/record.fs, np.ones_like(FN), marker='x',
color='r', s=70, zorder=2, label='FN',
)
ax[1].scatter(
FP/record.fs, np.ones_like(FP), marker='+',
color='orange', s=70, zorder=2, label='FP',
)
ax[1].set_yticks([])
ax[1].set_ylabel('heartbeat events')

ax[2].plot(
detection[1:] / record.fs, 60 / (np.diff(detection) / record.fs),
label='heartrate in bpm',
)
ax[2].set_ylabel('beats per minute')
ax[2].set_xlabel('time in seconds')

for ax_ in ax.flat:
ax_.legend(loc='upper right')
ax_.grid(axis='x')

fig.suptitle(
f'Record ID: {record.id}, lead: {record.lead}\n' +
f'Recall: {len(TP) / (len(TP) + len(FN)):.2%}, ' +
f'Precision: {len(TP) / (len(TP) + len(FP)):.2%}',
)

plt.show()
1 change: 1 addition & 0 deletions img/gudb_pearson.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions img/ltdb_runtime_logscale.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions img/mitdb_metrics.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build-system]
requires = ["setuptools", "wheel", "numpy"]
64 changes: 64 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
[metadata]
name = sleepecg
version = 0.1.0
url = https://github.com/cbrnr/sleepecg
author = Florian Hofer
author_email = hofaflo@gmail.com
maintainer = Clemens Brunner
maintainer_email = clemens.brunner@gmail.com
classifiers =
License :: OSI Approved :: BSD License
Programming Language :: Python :: 3
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: Implementation :: CPython
license = BSD 3-Clause License
description = A toolbox for sleep stage classification using ECG data
long_description = file: README.md
long_description_content_type = text/markdown
keywords = sleep, ecg, qrs, peak


[options]
include_package_data = True
packages = find:
python_requires = >=3.7
install_requires =
numpy>=1.20.0
requests>=2.25.0
scipy>=1.6.0
tqdm>=4.59.0


[options.extras_require]
full =
pandas>=1.2.0
wfdb>=3.3.0


[options.package_data]
* = *.pyi


[options.packages.find]
exclude =
examples


[flake8]
max-line-length = 92
max-doc-length = 75
exclude =
./build/*


[isort]
line_length = 92
multi_line_output = 3
include_trailing_comma = true


[pydocstyle]
convention = numpy
add-ignore = D100,D104
Loading

0 comments on commit a80f54b

Please sign in to comment.