Klasifikace Elektrokardiogramu (EKG) jako služba

16. 2. 2024 0:00 Jiří Raška

V tomto článku bych rád navázal na předchozí zabývající se klasifikací EKG signálů s využitím konvolučních neuronových sítí. Na problematiku bych se chtěl ale podívat poněkud z jiného úhlu.

Dejme tomu, že již jsem spokojen s výsledky nějakého modelu a chtěl bych jej začít aktivně používat. Myslím to tak, že bych chtěl vytvořit nějakou webovou službu, do které se pošlou data EKG křivek, a výsledkem by bylo zařazení vzorku do klasifikačních tříd.

A o tom bude tento článek.

Jaká data mám k dispozici

EKG signály a metadata

Je zřejmé, že budu vycházet ze stejné datové sady, jako tomu bylo v předchozích článcích. Jedná se o datovou sadu PTB-XL ECG dataset. Vzhledem k tomu, že jsem již dříve provedl přípravu dat pro modelování a tu jsem si uložil, můžu nyní načíst již předzpracovaná vstupní data jak pro EKG křivky, a metadata, tak také pro zařazení vzorků do klasifikačních tříd.

To budu s výhodou používat při přípravě klienta a testování webové služby.

KERAS model

Druhou věcí, kterou dopředu potřebuji mít k dispozici, je trénovaný model.

Z předchozích článků jsem si pro tento účel vybral kombinovaný model pro EKG křivky a metadata. V předchozích článcích byl tento model označovaný jako X_Y_model.

Pokud se podíváte blíže na postup, jak byl model trénován, tak tam uvidíte příkaz pro uložení hotového modelu:

model04.save('/kaggle/working/model/model04.keras')

Podstatná je přípona .keras, která znamená, že se bude jednat o ZIP soubor obsahující jak konfiguraci modelu (vstupy, výstupy, definované vrstvy a jejich propojení), tak také váhy všech parametrů vypočtených v průběhu trénování.

Tento soubor jsem si pro jednoduchost stáhl a bude přiložen k implementaci serverové služby.

Vytvoření webové služby

Pokusil jsem se vytvořit co nejjednodušší řešení, takže se v podstatě jedná o jediný skript.

Pro jeho spuštění potřebuji doinstalovat ještě tyto knihovny (používám virtuální prostředí):

  • TensorFlow – pro načtení a spuštění modelu

  • Numpy – pro práci s poli všeho druhu

  • Flask – pro vytvoření webového servery

Skript webové služby

Takto vypadá řešení pro webovou službu (skript main.py):


import numpy as np
import keras

from flask import Flask, request, jsonify

KERAS_FILE =
"./data/model04.keras"
COLUMNS = ['NORM', 'MI', 'STTC', 'CD', 'HYP']

app = Flask(__name__)


@app.route('/model-summary', methods=['GET', 'POST'])
def model_summary():
output_lines = []
model.summary(
print_fn=lambda x: output_lines.append(x))
return "\n".join(output_lines)


@app.route('/predict', methods=['POST'])
def predict():
data = request.get_json()
x = np.expand_dims(np.asarray(data['X']), 0)
y = np.expand_dims(np.asarray(data['Y']), 0)

prediction = model.predict([x, y], verbose=0)

return jsonify({k: v for k, v in zip(COLUMNS, np.ndarray.tolist(prediction[0]))})


if __name__ == '__main__':
model = keras.models.load_model(KERAS_FILE)
app.run(
port=8000)

Keras model je stažen do adresáře ./data.

Dále mám definované dvě služby, a sice model-summary a predict.

Služba model-summary

To je jen pomocná služba, kterou si můžu vypsat konfiguraci modelu.

Volá se bez parametrů, a výsledek může vypadat například takto:

[raska@fedora ~]$ curl http://localhost:8000/model-summary
Model: "X_Y_model"
__________________________________________________________________________________________________
 Layer (type)                Output Shape                 Param #   Connected to
==================================================================================================
 Y_inputs (InputLayer)       [(None, 1000, 12)]           0         []

 Y_norm (Normalization)      (None, 1000, 12)             25        ['Y_inputs[0][0]']

 Y_conv_1 (Conv1D)           (None, 1000, 64)             5440      ['Y_norm[0][0]']

 Y_norm_1 (BatchNormalizati  (None, 1000, 64)             256       ['Y_conv_1[0][0]']
 on)

 Y_relu_1 (ReLU)             (None, 1000, 64)             0         ['Y_norm_1[0][0]']

 Y_pool_1 (MaxPooling1D)     (None, 500, 64)              0         ['Y_relu_1[0][0]']

 Y_conv_2 (Conv1D)           (None, 500, 128)             24704     ['Y_pool_1[0][0]']

 Y_norm_2 (BatchNormalizati  (None, 500, 128)             512       ['Y_conv_2[0][0]']
 on)

 Y_relu_2 (ReLU)             (None, 500, 128)             0         ['Y_norm_2[0][0]']

 X_inputs (InputLayer)       [(None, 7)]                  0         []

 Y_pool_2 (MaxPooling1D)     (None, 250, 128)             0         ['Y_relu_2[0][0]']

 X_norm (Normalization)      (None, 7)                    15        ['X_inputs[0][0]']

 Y_conv_3 (Conv1D)           (None, 250, 256)             98560     ['Y_pool_2[0][0]']

 X_dense_1 (Dense)           (None, 32)                   256       ['X_norm[0][0]']

 Y_norm_3 (BatchNormalizati  (None, 250, 256)             1024      ['Y_conv_3[0][0]']
 on)

 X_drop_1 (Dropout)          (None, 32)                   0         ['X_dense_1[0][0]']

 Y_relu_3 (ReLU)             (None, 250, 256)             0         ['Y_norm_3[0][0]']

 X_dense_2 (Dense)           (None, 32)                   1056      ['X_drop_1[0][0]']

 Y_aver (GlobalAveragePooli  (None, 256)                  0         ['Y_relu_3[0][0]']
 ng1D)

 X_drop_2 (Dropout)          (None, 32)                   0         ['X_dense_2[0][0]']

 Y_drop (Dropout)            (None, 256)                  0         ['Y_aver[0][0]']

 Z_concat (Concatenate)      (None, 288)                  0         ['X_drop_2[0][0]',
                                                                     'Y_drop[0][0]']

 Z_dense_1 (Dense)           (None, 128)                  36992     ['Z_concat[0][0]']

 Z_dense_2 (Dense)           (None, 128)                  16512     ['Z_dense_1[0][0]']

 Z_drop_1 (Dropout)          (None, 128)                  0         ['Z_dense_2[0][0]']

 Z_outputs (Dense)           (None, 5)                    645       ['Z_drop_1[0][0]']

==================================================================================================
Total params: 185997 (726.56 KB)
Trainable params: 185061 (722.89 KB)
Non-trainable params: 936 (3.66 KB)
__________________________________________________________________________________________________

Služba predict

To je ta služba, o kterou mně vlastně jde.

Na jejím vstupu by měla být data EKG křivek a metadata k jednomu vzorku a to ve formátu JSON.

Služba tedy příjme obsah požadavku a převede jej z JSON do interní Python reprezentace ve formě slovníku s klíči X pro metadata a Y pro křivky. Dalším krokem je převedení metadat a křivek na Numpy pole a rozšíření polí o jednu dimenzi (ta je potřeba, protože se předpokládá možnost predikce pro více vzorků najednou).

Tato pole pak slouží jako vstupní data pro vyvolání metody predict na modelu.

Výsledek predikce, což je jednorozměrné pole pravděpodobností zařazení do klasifikační skupiny, ještě převedu na slovník s názvy skupin. A to je výsledek služby, opět převeden do JSON.

Ověřovací klient

Pro ověření webové služby predikce jsem si vytvořil jednoduchého klienta.

import sys
import json

import numpy as np
import pandas as pd
import requests

Dále si načtu vzorky dat z předpřipravené datové sady:

PREPROCESSED_DATA_FILE = './data/data.npz'

thismodule = sys.modules[__name__]

with np.load(PREPROCESSED_DATA_FILE) as data:
    for k in ('X_test', 'Y_test', 'Z_test'):
        setattr(thismodule, k, data[k].astype(float))

COLUMNS = ['NORM', 'MI', 'STTC', 'CD', 'HYP']

Z = pd.DataFrame(Z_test, columns=COLUMNS, dtype=int)

A nakonec si udělám jednu funkci pro ověření vzorku dat:

def check_sample(sample_id):
    print(f"SAMPLE: id={sample_id}")
    print(f"  target values: {Z_test[sample_id]}")

    data = json.dumps({
        'X': X_test[sample_id].tolist(),
        'Y': Y_test[sample_id].tolist()
        })
    headers = {'Content-type': 'application/json'}

    print(f"  prediction:")
    prediction = requests.post("http://localhost:8000/predict", data=data, headers=headers).json()
    for diag in COLUMNS:
        print(f"    {diag:4} = {prediction[diag]:.03f}")

Do funkce se zadá číslo vzorku, který chci testovat.

Zobrazím si skutečné třídy, které tomuto vzorku byly přiřazeny ve zdrojové datové sadě pro ověření.

Dále si připravím tělo dotazu, a to jako dictionary s klíči X a Y a přiřazenými daty daného vzorku (převedeno z Array na List[float] případně List[List[float]]).

Nyní již stačí zavolat dříve zmiňovanou službu predict a naformátovat odpovědi.

Několik pokusů na závěr

Dříve, než začnu něco zkoušet, tak je dobré si vybrat nějaké vzorky. Takto jsem si vybral ty, které mají více jak jednu klasifikační třídu:

In [4]:
z = pd.DataFrame(Z_test, columns=COLUMNS, dtype=int)
z[z.sum(axis=1) >=2]
Out[4]:
NORM
MI
STTC
CD
HYP
24
0
1
1
1
1
33
1
0
0
1
0
39
0
1
1
0
1
40
0
1
1
0
1
41
0
0
1
0
1
...
...
...
...
...
...
2191
0
0
1
0
1
2194
0
0
1
0
1
2195
0
0
1
1
1
2196
0
1
0
1
0
2197
0
1
0
1
0
511 rows × 5 columns

A nyní nějaké pokusy:

In [5]:
check_sample(33)
SAMPLE: id=33
  target values: [1. 0. 0. 1. 0.]
  prediction:
    NORM = 0.940
    MI   = 0.003
    STTC = 0.012
    CD   = 0.046
    HYP  = 0.020

Výsledkem predikce jsou pravděpodobnosti zařazení vzorku do dané skupiny. Z cílových hodnot je zřejmé, že by vzorek měl být zařazen do třídy NORM a CD. Predikce ale našla pouze jednu třídu, a sice NORM.

In [6]:
check_sample(24)
SAMPLE: id=24
  target values: [0. 1. 1. 1. 1.]
  prediction:
    NORM = 0.000
    MI   = 0.970
    STTC = 0.585
    CD   = 0.975
    HYP  = 0.331

Zde se jedná o vzorek, který by měl být zařazen do všech tříd s výjimkou NORM. Pokud budu za limitní pravděpodobnost považovat hodnotu 0.5, pak mně bude opět chybět jedna skupina, v tomto případě HYP.

A ještě jeden vzorek:

In [7]:
check_sample(2197)
SAMPLE: id=2197
  target values: [0. 1. 0. 1. 0.]
  prediction:
    NORM = 0.000
    MI   = 0.999
    STTC = 0.038
    CD   = 0.886
    HYP  = 0.077

Zde již zařazení do skupin MI a CD vypadá skutečně přesvědčivě.

A to je dnes vše.

Sdílet