Zadanie: Detektor zespołów QRS

Wojciech Kiraga

H6D2S1

In [1]:
# set cell width to 100%
from IPython.core.display import display, HTML
display(HTML("<style>.container { width:100% !important; }</style>"))

Pobranie plików

Wybieramy bazę MIT-svbd Pobieram z niej pliki oraz je przetwarzam.

Pobieranie plików za pomocą biblioteki urllib

In [2]:
import urllib.request

# 850
url = 'https://physionet.org/physiobank/database/svdb/850.hea'
urllib.request.urlretrieve(url, '850.hea')

url = 'https://physionet.org/physiobank/database/svdb/850.dat'
urllib.request.urlretrieve(url, '850.dat')

url = 'https://physionet.org/physiobank/database/svdb/850.atr'
urllib.request.urlretrieve(url, '850.atr')

# 868
url = 'https://physionet.org/physiobank/database/svdb/868.hea'
urllib.request.urlretrieve(url, '868.hea')

url = 'https://physionet.org/physiobank/database/svdb/868.dat'
urllib.request.urlretrieve(url, '868.dat')

url = 'https://physionet.org/physiobank/database/svdb/868.atr'
urllib.request.urlretrieve(url, '868.atr')
Out[2]:
('868.atr', <http.client.HTTPMessage at 0x7f21c8b2b080>)

Pliki zapisów sygnałów EKG zostały pobrane.

Przetwarzanie

In [3]:
import wfdb
import pywt
import matplotlib.pyplot as plt
import numpy as np

Wczytanie rekordu

In [4]:
%matplotlib notebook
record = wfdb.rdrecord('868')
# record = wfdb.rdrecord('850', sampfrom=800, channels=[1, 1])
display(record.__dict__)
{'record_name': '868',
 'n_sig': 2,
 'fs': 128,
 'counter_freq': None,
 'base_counter': None,
 'sig_len': 230400,
 'base_time': None,
 'base_date': None,
 'comments': [],
 'sig_name': ['ECG1', 'ECG2'],
 'p_signal': array([[-0.75,  0.26],
        [-0.71,  0.31],
        [-0.81,  0.26],
        ...,
        [-1.  ,  0.34],
        [-1.06,  0.38],
        [-1.21,  0.41]]),
 'd_signal': None,
 'e_p_signal': None,
 'e_d_signal': None,
 'file_name': ['868.dat', '868.dat'],
 'fmt': ['212', '212'],
 'samps_per_frame': [1, 1],
 'skew': [None, None],
 'byte_offset': [None, None],
 'adc_gain': [200.0, 200.0],
 'baseline': [0, 0],
 'units': ['mV', 'mV'],
 'adc_res': [10, 10],
 'adc_zero': [0, 0],
 'init_value': [-150, 52],
 'checksum': [-30666, -8862],
 'block_size': [0, 0]}

Zapisanie rekordów do zmiennych.

In [5]:
signals_850, fields_850 = wfdb.rdsamp('850', sampfrom=0)
signals_868, fields_868 = wfdb.rdsamp('868', sampfrom=0)

Wyświetlenie części sygnału w celu przedstawienia poprawnego załadowania sygnału.

In [18]:
cutoff = 1000

plt.figure(1, figsize=(10, 5))
plt.plot(signals_850[:cutoff, 1])
plt.show()

Opcjonalne: Skrócenie rekordów na potrzeby szybszego przetwarzania

In [7]:
sig0 = signals_850[:, 0]
sig1 = signals_868[:, 0]

# sig0 = signals_850[500:5000, 0]
# sig1 = signals_868[500:5000, 0]

Ciągła transformata falkowa (CWT)

Używam falki mexican hat.

Pierwszy sygnał przetworzony odwrócono, aby ułatwić dalsze przetwarzanie. Drugi sygnał po transformacie (868) podniesiono do kwadratu, aby ułatwić progowanie.

In [19]:
coef0, freqs0 = pywt.cwt(sig0, np.arange(1, 50), 'mexh')
coef1, freqs1 = pywt.cwt(sig1, np.arange(1, 50), 'mexh')

my_coef_0 = coef0[3]
my_coef_1 = coef1[1]

my_coef_0 = -my_coef_0
my_coef_1 = np.square(my_coef_1)

plt.figure(2, figsize=(24, 8))

plt.subplot(2, 2, 1)
plt.plot(sig0[:cutoff])
plt.title('Original signal 850')
plt.grid(True)

plt.subplot(2, 2, 2)
plt.plot(sig1[:cutoff])
plt.title('Original signal 868')
plt.grid(True)

plt.subplot(2, 2, 3)
plt.plot(my_coef_0[:cutoff])
plt.title('CWT signal #0')
plt.grid(True)

plt.subplot(2, 2, 4)
plt.plot(my_coef_1[:cutoff])
plt.title('CWT signal #1')
plt.grid(True)

Progowanie

In [9]:
my_coef_0[my_coef_0 < 1.8] = 0

my_coef_1[my_coef_1 < 3.5] = 0

Przedstawienie sygnałów oraz ich przetworzonych wersji już po progowaniu.

In [20]:
plt.figure(3, figsize=(24, 8))

plt.subplot(2, 2, 1)
plt.plot(sig0[:cutoff])
plt.title('Original signal 850')
plt.grid(True)

plt.subplot(2, 2, 2)
plt.plot(sig1[:cutoff])
plt.title('Original signal 868')
plt.grid(True)

plt.subplot(2, 2, 3)
plt.plot(my_coef_0[:cutoff])
plt.title('850 CWT after threshold')
plt.grid(True)

plt.subplot(2, 2, 4)
plt.plot(my_coef_1[:cutoff])
plt.title('868 CWT after threshold')
plt.grid(True)

Wykrycie peaków, wyświetlenie ich oraz ich liczby.

In [11]:
from scipy.signal import find_peaks
peaks0 = find_peaks(my_coef_0, height=0)
peaks1 = find_peaks(my_coef_1, height=0)

print('QRSy dla sygnału 850:')
print(len(peaks0[0]))
print(peaks0)

print('\nQRSy dla sygnału 868:')
print(len(peaks1[0]))
print(peaks1)
QRSy dla sygnału 850:
1836
(array([    13,    136,    258, ..., 230044, 230182, 230397]), {'peak_heights': array([2.63089538, 2.90719454, 2.81884592, ..., 2.78623968, 2.90477876,
       2.92380057])})

QRSy dla sygnału 868:
3572
(array([    66,    138,    209, ..., 230293, 230299, 230378]), {'peak_heights': array([9.14735969, 9.27743577, 9.03211485, ..., 8.95423647, 5.53889138,
       9.02974314])})

Wyświetlenie sygnałów wraz z nałożonymi liniami pionowymi wskazującymi na wykryte załamki QRS (czerwone linie).

In [12]:
%matplotlib inline
plt.figure(4, figsize=(24, 8))

plt.subplot(2, 1, 1)
plt.plot(sig0[:cutoff])
plt.title('Original signal 850')
plt.grid(True)

for peak_pos in peaks0[0][:9]:
    plt.axvline(x=peak_pos, c="r")

plt.subplot(2, 1, 2)
plt.plot(sig1[:cutoff])
plt.title('Original signal 868')
plt.grid(True)

for peak_pos in peaks1[0][:14]:
    plt.axvline(x=peak_pos, c="r")
In [13]:
# Jakich falek można użyć
for i in pywt.wavelist(family=None, kind='continuous'): print(i, end=' ')
cgau1 cgau2 cgau3 cgau4 cgau5 cgau6 cgau7 cgau8 cmor fbsp gaus1 gaus2 gaus3 gaus4 gaus5 gaus6 gaus7 gaus8 mexh morl shan 

Wczytanie adnotacji dla obu sygnałów.

In [14]:
from multiprocessing import freeze_support

import wfdb
import os


ann_850 = wfdb.rdann('850', 'atr')
sampleAnnotation_850 = [(sample, ann_850.symbol[i]) for i, sample in enumerate(ann_850.sample)]

ann_868 = wfdb.rdann('868', 'atr')
sampleAnnotation_868 = [(sample, ann_868.symbol[i]) for i, sample in enumerate(ann_868.sample)]

Funkcja wyświetlająca czułość, precyzję oraz TP, FP, FN.

Sprawdzanie skuteczności detektora polega na przyjęciu przedziałów referencyjnych o szerokości 20 próbek, co przy częstotliwości próbkowania 128Hz odpowiada przedziale około 150ms. Środkami przedziałów referencyjnych są pobrane adnotacje (poprawne miejsca załamków). Następnie wyznaczono liczności zbiorów TP, FP, FN.

  • TP: gdy wyliczony załamek trafia do przedziału
  • FP: gdy wyliczony załamek
  • FN:
In [15]:
def get_TPR_PPV(annotation, peaks):

    TP, FP, FN = 0, 0, 0

    for ann in annotation:
        middle = ann[0]
        # range: <start; end>
        start = middle-10
        end = middle+10

        a = peaks[0]
        hits = ((start <= a) & (a <= end)).sum()

        if hits > 0:
            TP += 1

        if hits > 1:
            FP += hits-1

        if hits == 0:
            FN += 1

    print("|TP|={}, |FP|={}, |FN|={}\n".format(TP, FP, FN))

    # Sensitivity / True positive value / czułość
    TPR = TP / (TP + FN) * 100

    # Precision / Positive predictive value / precyzja
    PPV = TP / (TP + FP) * 100

    print("Czułość: {}%".format(np.around(TPR, 2)))
    print("Precyzja: {}%".format(np.around(PPV, 2)))
    
    return TPR, PPV

Czułość to stosunek wyników prawdziwie dodatnich do sumy prawdziwie dodatnich i fałszywie ujemnych. Czułość 100% w przypadku testu medycznego oznaczałaby, że wszystkie osoby chore lub ogólnie z konkretnymi poszukiwanymi zaburzeniami zostaną rozpoznane. $${\displaystyle \mathrm {TPR} ={\frac {\mathrm {TP} }{P}}={\frac {\mathrm {TP} }{\mathrm {TP} +\mathrm {FN} }}}$$

Precyzja to stosunek prawdziwie pozytywnych wyników do wszystkich pozytywnych wyników. $${\displaystyle \mathrm {PPV} ={\frac {\mathrm {TP} }{\mathrm {TP} +\mathrm {FP} }}}$$

Wysoka czułość oznacza dużą ilość wykryć QRSów.

Wysoka precyzja oznacza dużą poprawną ilość wykryć QRSów.

In [16]:
get_TPR_PPV(sampleAnnotation_850, peaks0)
|TP|=1822, |FP|=0, |FN|=18

Czułość: 99.02%
Precyzja: 100.0%
Out[16]:
(99.02173913043478, 100.0)

W przypadku badania 850 czułość wynosi 99.02%, a precyzja 100%.

Oznacza to, że algorytm znajduje prawie wszystkie załamki QRS, natomiast precyzja równa 100% oznacza że nie było błędnych wykryć QRSów.

In [17]:
get_TPR_PPV(sampleAnnotation_868, peaks1)
|TP|=3369, |FP|=219, |FN|=76

Czułość: 97.79%
Precyzja: 93.9%
Out[17]:
(97.79390420899855, 93.89632107023411)

W przypadku badania 868 czułość wynosi 97.79%, a precyzja 93.9%.

W algorytmie precyzja jest mniejsza od czułości, gdyż niekiedy algorytm dodatkowo wykrywał QRSy. Algorytm wykrył prawie wszystkie załamki QRS. Niższa precyzja wskazuje, że nie wszystkie załamki jakie wykrył były poprawne. Możliwe, że kilka razy wykrywał ten sam załamek, co jest błędem.