-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
22 changed files
with
1,816 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
[build-system] | ||
requires = ["setuptools", "wheel", "numpy"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.