

Автор статьи: Виктория Ляликова
Всем привет! Сегодня рассмотрим задачу обнаружения аномалий тонов сердца, используя аудиозаписи звуков сердцебиения. Для этого будем использовать библиотеку 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')

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

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

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

Подавляющее большинство записей имеют длину 9 секунд, но встречаются и другие длительности в диапазоне от 0 до 27 секунд. Также есть записи с 0 длительностью, их уберем из нашего датасета.
Подготовка данных
Мы не можем использовать необработанный аудиосигнал в качестве входных данных для наших будущих моделей. Для работы с аудиосигналом его необходимо оцифровать, то есть преобразовывать звуковую волну в ряд чисел. Благодаря дискредитации звука из аудио можно извлекать достаточно большое число различных характеристик. Например, такие как мел-кепстральный коэффициенты (MFCCs), спектр, спектральный центроид (Spectral Centroid), спектральный спад (Spectral Rolloff), скорость пересечения нуля и так далее.
MFCC или мел-кепстральные коэффициенты являются широко используемым методом извлечения характеристик из аудиосигнала. В этом нам поможет библиотека librosa, с помощью которой будем обрабатывать наши аудиосигналы.
У нас имеется 563 размеченных аудиофайла для обучения наших моделей. Расширим этот набор данных за счет генерации синтетических данных. Будем использовать методы растяжения и сужения звукового сигнала во времени. Таким образом у нас каждый аудиосигнал будет иметь 3 формы, что сделает наш классификатор более устойчивым и поможет в правильной классификации сердечных ритмов. В качестве основных характеристик аудиосигнала будем вычислять мел-кепстральные коэффициенты. Проделаем следующие шаги для обработки наших записей сердечных тонов.
Преобразуем аудиофайлы в файлы с одинаковой длительностью. Для этого будем использовать метод
librosa.util.fix_length().Увеличим наш набор данных, применяя методы растяжения и сужения звукового сигнала с коэффициентами 1.2 и 0.8 соответственно с помощью метода
librosa.effects.time_stretch().Извлечем мел-кепстральные коэффициенты с помощью метода
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.

Рассчитав точность алгоритма, получим 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)

Рассчитав точность, получим 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))

Рассчитав точность нашей нейросетевой модели на валидационной выборке, получим 0,9438. Видим, что наша нейронная сеть очень хорошо справилась с задачей. И она значительно лучше справляется с распределением по классам artifact и extrastole.
Давайте подведем итоги. Скорее всего, если немного еще поработать с выборкой, может поработать со спектрограммой, может расширить набор признаков, увеличить набор датасета, изменить параметры нейронной сети, то мы получим еще более лучший результат. Считаю, что в дальнейшем стоит поработать с построенной моделью нейронной сети и рассмотреть другие методы для устранения дисбаланса классов для получения более точных показателей.
Кстати, у моих коллег из OTUS скоро пройдет бесплатный вебинар про уникальный эксперимент, который начался в 2020-м году в Москве, где городские поликлиники подключены к единой базе данных, а тысячи рентгенограмм ежедневно анализируются искусственным интеллектом. Регистрируйтесь, будет интересно!
