Обнаружение аномальных звуков сердцебиения на основе записей сердечного ритма

c4041c025ed8a72adacc13c165a51abd.png

c0562a792abba8986d7c5d527e9e9d0c.jpgАвтор статьи: Виктория Ляликова

Всем привет! Сегодня рассмотрим задачу обнаружения аномалий тонов сердца, используя аудиозаписи звуков сердцебиения. Для этого будем использовать библиотеку librosa по работе с аудиофайлами, а также классические алгоритмы машинного обучения и методы глубокого обучения.

Возьмем датасет «Heartbeat Sound», который содержит аудиофрагменты сердечных ритмов различной продолжительности от 1 до 30 секунд, как здоровых пациентов, так и имеющих аномальные звуки сердцебиения. Набор содержит 813 аудиофайл с записями, разбитыми по категориям: artefact, extrastole, murmur, normal и unlabel. Попробуем разобраться, что обозначают эти категории.

Normal — как и следует из названия, нормальное сильное ритмичное сердцебиение.

Murmur — записи звука сердца, где присутствуем какой-то шум, например, свист, рев, урчание. Наличие такого шума может быть симптомом многих заболеваний сердца.

Etrastole — экстрасистолические (дополнительные) записи  звука, которые могут появляться время от времени и могут быть идентифицированы по отсутствию сердечного тона, включающему дополнительные или пропущенные сердечные сокращения. Экстрасистола может не быть признаком заболевания, но в некоторых ситуациях могут быть вызваны заболеваниями сердца.

Artefact — по сути не является сердцебиением, и характеризуется широким спектром различных звуков. В этой категории содержится широкий спектр различных звуков, включая визги, эхо, речь, музыку. Обычно различимые тоны сердца отсутствуют, важно определять эту категорию записей, чтобы можно было повторить исследование.

Теперь кратко ознакомившись с категориями заболеваний, которые имеет наш датасет, можно переходить к анализу, чтобы лучше понять с какой информацией мы будем работать. 

Анализ данных

Загрузим папку с нашими данными и посмотрим на графики сердечного ритма для аудиофайла без аномалий и с аномалиями. Приведен пример кода для построения графика сердечного ритма для normal. 

import os
import matplotlib.pyplot as plt
import librosa.display as ld
data_path ='D:/Heartbeat_sound'
os.listdir(data_path)
['artifact','extrastole', 'murmur', 'normal', 'unlabel']
import matplotlib.pyplot as plt
import librosa.display as ld
sr=22050
signal ='*/normal/normal__103_1305031931979_B.wav'
normal, sr = librosa.load(signal, sr = 22050)
librosa.display.waveshow(normal, sr=sr, label='Normal')

3d781116b4ea7e1d230e169476777f21.png

Что здесь можно увидеть? Первый график, то есть график нормального сердцебиения, показывает равномерное распределение амплитуд и постоянство между ударами и ударами звуковой волны. Если сравнить график шумов в сердце с нормальным , то можно заметить, что он кажется менее последовательным и содержит много дополнительных звуковых волн, проходящих между ударами сердца. Экстрасистолические звуки имеют более высокую амплитуду по сравнению с нормальным сердцебиением, и существует большая неравномерность между различными звуковыми волнами, что указывает на то, что может наблюдаться пропуск сердцебиения. График артефактов (artefact) просто показывает зашумленные данные, с которыми можно столкнуться при неправильной попытке сделать запись. 

Теперь попробуем объединить все эти графики с записями сердечных ритмов в один рисунок.

1e5d73c82303fb18189c708ad25a7658.png

Видно, что экстрасистолические звуки сердцебиения имеют большую амплитуду по сравнению с нормальными тонами сердца, и все различные классификации сердцебиений имеют нерегулярный ритм.

Далее посмотрим на распределение классов, посчитав количество записей в каждой папке и посмотрим на результат, используя диаграмму.

efc06feb25619d6f593107f0ce9c7c5b.png

Видим, что данные очень несбалансированы, попробуем позже учесть этот факт при построении моделей. Теперь посмотрим на длительности каждой записи и как это распределяется по количеству записей. Посмотрим на график

7389a72ebec96531d8e3181a73cb95e2.png

Подавляющее большинство записей имеют длину 9 секунд, но встречаются и другие длительности в диапазоне от 0 до 27 секунд. Также есть записи с 0 длительностью, их уберем из нашего датасета.

Подготовка данных

Мы не можем использовать необработанный аудиосигнал  в качестве входных данных для наших будущих моделей. Для работы с аудиосигналом его необходимо оцифровать, то есть преобразовывать звуковую волну в ряд чисел. Благодаря дискредитации звука из аудио можно извлекать достаточно большое число различных характеристик. Например, такие как мел-кепстральный коэффициенты (MFCCs), спектр, спектральный центроид (Spectral Centroid), спектральный спад (Spectral Rolloff), скорость пересечения нуля и так далее.

MFCC или мел-кепстральные коэффициенты являются широко используемым методом извлечения характеристик из аудиосигнала. В этом нам поможет библиотека librosa, с помощью которой будем обрабатывать наши аудиосигналы.  

У нас имеется 563 размеченных аудиофайла для обучения наших моделей. Расширим этот набор данных за счет генерации синтетических данных. Будем использовать методы растяжения и сужения звукового сигнала во времени.  Таким образом у нас каждый аудиосигнал будет иметь 3 формы, что сделает наш классификатор более устойчивым и поможет в правильной классификации сердечных ритмов. В качестве основных характеристик аудиосигнала будем вычислять мел-кепстральные коэффициенты. Проделаем следующие шаги для обработки наших записей сердечных тонов.

  1. Преобразуем аудиофайлы в файлы с одинаковой длительностью. Для этого будем использовать метод librosa.util.fix_length().

  2. Увеличим наш набор данных, применяя методы растяжения и сужения звукового сигнала с коэффициентами 1.2 и 0.8 соответственно с помощью метода librosa.effects.time_stretch().

  3. Извлечем мел-кепстральные коэффициенты с помощью метода librosa.feature.mfcc() для каждого аудиофайла  и посчитаем среднее по каждому коэффициенту.

Напишем функцию, которая будет обрабатывать аудиофайлы.

def load_file_data (folder, file_names, duration=9, sr=22050):
	input_length=sr*duration
	features = 52
	data = []
	for file_name in file_names:
    	try:
        	sound_file = folder+file_name
        	X, sr = librosa.load( sound_file, sr=sr, duration=duration)
        	dur = librosa.get_duration(y=X, sr=sr)
#        	меням длительность
        	if (round(dur) < duration):
            	print ("fixing audio lenght :", file_name)
            	X = librosa.util.fix_length(data=X, size=input_length)  
           	 
        	# извлекаем mfcc коэффициенты, используя 52 характеристики
        	mfccs = np.mean(librosa.feature.mfcc(y=X, sr=sr, n_mfcc=features).T,axis=0)
        	featuress = np.array(mfccs).reshape([-1,1])
        	data.append(featuress)
       	        	 
    	# сужаем уадиосигнал с коэффициентом 0.8
        	squeeze_data = librosa.effects.time_stretch(y=X, rate=0.8)
        	mfccs_squeeze = np.mean(librosa.feature.mfcc(y=squeeze_data, sr=sr, n_mfcc=features).T,axis=0)
        	feature_1 = np.array(mfccs_squeeze).reshape([-1,1])
        	data.append(feature_1)
       	 
    	# растягиваем сигнал с коэффициентом 1.2    
        	stretch_data = librosa.effects.time_stretch(y=X, rate=1.2)
        	mfccs_stretch = np.mean(librosa.feature.mfcc(y=stretch_data, sr=sr, n_mfcc=features).T,axis=0)
        	feature_2 = np.array(mfccs_stretch).reshape([-1,1])
        	data.append(feature_2)
    	except Exception as e:
        	print("Error encountered while parsing file: ", file_name)   	 
   	 
	return data

И обработаем все файлы, содержащиеся в папках: normal, murmur, artifact, axtrastole, создав при этом массив в метками. 

max_duration=9
artifact_files = fnmatch.filter(os.listdir(artifact_data), 'artifact*.wav')
artifact_sounds = load_file_data (folder=artifact_data, file_names = artifact_files, duration=max_duration)
artifact_labels = [0 for items in artifact_sounds]
murmur_files = fnmatch.filter(os.listdir(murmur_data), 'murmur*.wav')
murmur_sounds = load_file_data(folder=murmur_data,file_names=murmur_files, duration=max_duration)
murmur_labels = [1 for items in murmur_sounds]
extrastole_files = fnmatch.filter(os.listdir(extrastole_data), 'extrastole*.wav')
extrastole_sounds = load_file_data(folder=extrastole_data,file_names=extrastole_files, duration=max_duration)
extrastole_labels = [2 for items in extrastole_sounds]
normal_files = fnmatch.filter(os.listdir(normal_data), 'normal*.wav')
normal_sounds = load_file_data(folder=normal_data,file_names=normal_files, duration=max_duration)
normal_labels = [3 for items in normal_sounds]

Далее определим метки классов. 

classes= ['artifact','murmur','extrastole','normal']
labels = {k:v for v,k in enumerate(classes)}
{'artifact': 0, 'murmur': 1, 'extrastole': 2, 'normal': 3}

Теперь можно собрать все данные и разделить выборку на обучающую, тестовую и валидационную.

x_data = np.concatenate((artifact_sounds, normal_sounds,murmur_sounds,extrastole_sounds))

y_data = np.concatenate((artifact_labels, normal_labels,murmur_labels,extrastole_labels))

from sklearn.model_selection import train_test_split
# shuffle - whether or not to shuffle the data before splitting. If shuffle=False then stratify must be None.

# split data into Train, Validation and Test
x_train, x_test, y_train, y_test = train_test_split(x_data, y_data, train_size=0.8, random_state=42, shuffle=True)
x_train, x_val, y_train, y_val = train_test_split(x_train, y_train, train_size=0.9, random_state=42, shuffle=True)

Теперь у нас есть обработанные данные, разделенные на выборки для обучения, тестирования и валидации, можем приступать к построению моделей.

Начнем с классических моделей машинного обучения. Алгоритм «случайный лес» (RandomForest, RF) у специалистов по обработке данных является одним из самых популярных и надежных методов в задачах классификации. Модель имеет много параметров, настройка которых может существенно повлиять на конечный результат, поэтому переберем некоторые комбинации параметров и выберем один с лучшей оценкой.

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV
forest_grid_search = GridSearchCV(RandomForestClassifier(), {
	'n_estimators': [300,400,500,700,800,1000],
	'max_features': ['sqrt','log2'],
	'max_depth': [3,5,7,9,11,13,15,17,19,21],
	'bootstrap': [True, False]},cv=3,verbose=1, n_jobs=-1 )
forest_grid_search.fit(X_train,y_train)

Посмотрим на параметры лучшей модели

forest_grid_search.best_params_
{'bootstrap': False,
 'max_depth': 19,
 'max_features': 'sqrt',
 'n_estimators': 800}

Оценим теперь точность и эффективность нашей модели, чтобы понять насколько хорошо она работает в разных классах, построив отчет classification report.

072e2c72f5673eef5dab48f0d8d6e801.png

Рассчитав точность алгоритма, получим 0.89645. Значение точности получилась достаточно неплохое, но мы видим, что у алгоритма есть проблемы с классами artifact и extrastole. Низкий recall у класса extrastole говорит о том, что алгоритму случайного леса тяжело обнаружить данный класс вообще. Класс artifact алгоритм может обнаружить, но плохо отличает от других классов.

Теперь рассмотрим алгоритм градиентного бустинга XGBoost и тоже попробуем настроить его параметры.

from sklearn.model_selection import GridSearchCV
from sklearn.metrics import accuracy_score
from xgboost import XGBClassifier
from scipy import stats
xgb_grid_search = GridSearchCV(XGBClassifier(), {
	'n_estimators':[200,300,400,500,700,800,900,1000],
	'max_depth': [3,4,5,6,7,9,11,13,15,17,19,21]},verbose=3, n_jobs=-1 )
xgb_grid_search.fit(X_train,y_train)

7ab2c7aab86b788556c5a21f1f94e455.png

Рассчитав точность, получим 0.85207, что даже немного ниже, чем точность алгоритма случайного леса. И алгоритм также тяжело справляется с классами artifact и extrastole. Но при этом видно, что оба алгоритма путают болезни между собой, но хорошо справляются вообще с обнаружение или не обнаружением болезней.

В качестве нейронной сети возьмем сочетание сверточной сети и сети LSTM. С нейронными сетями наверное интереснее, так как на вход мы можем подавать как характеристики, извлеченные из аудиосигнала и использовать их для классификации, так и, например, спектрограммы, которые мы можем сформировать для каждого сигнала, тем самым преобразовав задачу классификации звука в задачу классификации изображений. Но так как мы сравниваем алгоритмы между собой, то на вход  нейронной сети также будем подавать мел-кепстральные коэффициенты, извлеченные из аудиосигнала.

Попробуем скорректировать несбалансированность в наборе данных с помощью взвешивания классов. Для этого определим веса для каждого класса.

# class weight
train_count = 563
count_0 = 40  #artifact
count_1 = 127 #murmur
count_2 = 46 #extrastole
count_3 = 350 #normal
weight_for_0 = train_count / (4 * COUNT_0)
weight_for_1 = train_count / (4 * COUNT_1)
weight_for_2 = train_count / (4 * COUNT_2)
weight_for_3 = train_count / (4 * COUNT_3)
class_weight = {0: weight_for_0, 1: weight_for_1, 2: weight_for_2,3: weight_for_3}

{0: 3.5375,
 1: 1.0968992248062015,
 2: 3.0760869565217392,
 3: 0.4031339031339031}

Модель нейронной сети имеет 3 блока с несколькими сверточными слоями (Conv1D), слоями максимального объединения (MaxPooling1D) и слоями BatchNormalization, которые нормализуют данные перед каждым входом в слой, 2 полносвязных слоя (Dense) со слоями Dropout для предотвращения переобучения модели и выходной слой с 4 выходами в соответствии с количеством классов эмоций. Выходной слой имеет активационную функцию softmax, так как она возвращает распределение вероятностей по целевым классам в задаче многоклассовой классификации.  Во всех сверточных слоях используется активационная функция relu.

lstm_model = Sequential()

lstm_model.add(Conv1D(1024, kernel_size=5, strides=1, padding='same', activation='relu', input_shape=(52, 1)))
lstm_model.add(MaxPooling1D(pool_size=2, strides = 2, padding = 'same'))
lstm_model.add(BatchNormalization())

lstm_model.add(Conv1D(512, kernel_size=5, strides=1, padding='same', activation='relu'))
lstm_model.add(MaxPooling1D(pool_size=2, strides = 2, padding = 'same'))
lstm_model.add(BatchNormalization())

lstm_model.add(Conv1D(256, kernel_size=5, strides=1, padding='same', activation='relu'))
lstm_model.add(MaxPooling1D(pool_size=2, strides = 2, padding = 'same'))
lstm_model.add(BatchNormalization())

lstm_model.add(LSTM(128, return_sequences=True))
lstm_model.add(LSTM(128))

lstm_model.add(Dense(64, activation='relu'))
lstm_model.add(Dropout(0.3))

lstm_model.add(Dense(32, activation='relu'))
lstm_model.add(Dropout(0.3))

lstm_model.add(Dense(4, activation='softmax')) #4 класса для классификации
lstm_model.summary()

Определим оптимизатор Адама, настроим шаг обучения, установим функцию потерь и скомпилируем модель.

optimiser = tf.keras.optimizers.Adam(learning_rate = 0.0001)
lstm_model1.compile(optimizer=optimiser,
              	loss='categorical_crossentropy',
              	metrics=['accuracy'])
cb = [EarlyStopping(patience=20,monitor='val_accuracy',mode='max',restore_best_weights=True)]

Запускаем обучение нашей сети, не забыв указать веса для каждого класса и точку для ранней остановки.

history = lstm_model.fit(x_train, y_train,
                     	validation_data=(x_val, y_val),
                     	batch_size=8, epochs=250,
                     	class_weight=class_weight,callbacks = cb )

По окончании обучения построим отчет.

from sklearn.metrics import classification_report
classes = ["artifact" ,"murmur ", 'exrastole', "normal"]

preds = lstm_model.predict(x_test)
classpreds = [ np.argmax(t) for t in preds ]
y_testclass = [np.argmax(t) for t in y_test]
print(classification_report(y_testclass, classpreds, target_names=classes))

d77c85758d913f87047b7478b1551e67.png

Рассчитав точность нашей нейросетевой модели на валидационной выборке, получим  0,9438. Видим, что наша нейронная сеть очень хорошо справилась с задачей. И она значительно лучше справляется с распределением по классам artifact и extrastole.

Давайте подведем итоги. Скорее всего, если немного еще поработать с выборкой, может поработать со спектрограммой, может расширить набор признаков, увеличить набор датасета, изменить параметры нейронной сети, то мы получим еще более лучший результат. Считаю, что в дальнейшем стоит поработать с построенной моделью нейронной сети и рассмотреть другие методы для устранения дисбаланса классов для получения более точных показателей.

Кстати, у моих коллег из OTUS скоро пройдет бесплатный вебинар про уникальный эксперимент, который начался в 2020-м году в Москве, где городские поликлиники подключены к единой базе данных, а тысячи рентгенограмм ежедневно анализируются искусственным интеллектом. Регистрируйтесь, будет интересно!

© Habrahabr.ru