Рентген грудной клетки (РГК) является неинвазивным и широко используемым диагностическим методом в медицинской практике для оценки структуры и функции легких, сердца и других органов грудной клетки. Однако интерпретация изображений CXR представляет собой сложную и трудоемкую задачу, требующую опыта квалифицированных рентгенологов. В последние годы применение методов машинного обучения, особенно сверточных нейронных сетей (CNN), показало многообещающие результаты в автоматизации классификации изображений CXR. В этом блоге мы обсудим использование CNN для классификации изображений CXR.

Сверточные нейронные сети

CNN — это тип искусственной нейронной сети, предназначенной для обработки изображений и других многомерных данных. CNN состоят из нескольких слоев, включая сверточные слои, объединяющие слои и полносвязные слои. Сверточные слои используют фильтры для извлечения объектов из входного изображения, в то время как объединяющие слои уменьшают размерность карт объектов. Наконец, полносвязные слои используются для классификации входного изображения на основе извлеченных признаков.

Изображения CXR

Изображения CXR обычно классифицируются по разным категориям в зависимости от наличия или отсутствия определенных особенностей или аномалий. Например, изображения CXR можно разделить на нормальные или аномальные категории или более конкретные категории, такие как «плевральный выпот» или «необнаружение».

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

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

Предварительная обработка данных

Наши данные представлены в виде изображений рентгенограмм грудной клетки в оттенках серого (черно-белых). Чтобы эффективно выполнить нашу задачу классификации, нам необходимо выполнить некоторую предварительную обработку данных.

Во-первых, мы загружаем все соответствующие библиотеки.

from skimage import io
import os
import glob
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.simplefilter('ignore')

есть два класса, с которыми мы будем иметь дело.

disease_cls = ['effusion', 'nofinding']

Далее читаем изображения «излияние» и «ненахождение».

#provide DATASET_PATH variable with the path of your folder
effusion_path = os.path.join(DATASET_PATH, disease_cls[0], '*')
effusion = glob.glob(effusion_path)
effusion = io.imread(effusion[0])
 
normal_path = os.path.join(DATASET_PATH, disease_cls[1], '*')
normal = glob.glob(normal_path)
normal = io.imread(normal[0])
 
f, axes = plt.subplots(1, 2, sharey=True)
f.set_figwidth(10)
     
axes[0].imshow(effusion, cmap='gray')
axes[1].imshow(normal, cmap='gray')

print(effusion.shape)
print(normal.shape)

(1024, 1024)

(1024, 1024)

Увеличение данных

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

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

Для данных CXR у нас есть некоторые определенные ограничения:

  1. Вертикальное отражение должно быть установлено как «False». Это связано с тем, что изображения CXR имеют естественную ориентацию — сверху вниз.
  2. Мы не должны делать центральную обрезку для изображений CXR, поскольку аномалия может находиться в области за пределами обрезанной части изображения.
from skimage.transform import rescale
from tensorflow.keras.preprocessing.image import ImageDataGenerator
 
datagen = ImageDataGenerator(
    featurewise_center=True,
    featurewise_std_normalization=True,
    rotation_range=10,
    width_shift_range=0,
    height_shift_range=0,
    vertical_flip=False,)

Предварительная обработка данных — нормализация

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

def preprocess_img(img, mode):
    img = (img - img.min())/(img.max() - img.min())
    img = rescale(img, 0.25, multichannel=True, mode='constant')
     
    if mode == 'train':
        if np.random.randn() > 0:
            img = datagen.random_transform(img)
    return img

Нормализация рентгеновских изображений таким образом имеет несколько преимуществ:

  1. Поскольку изображения CXR не являются «естественными изображениями», мы не используем стратегию «разделить на 255». Вместо этого мы используем максимум-минимальный подход к нормализации. Поскольку вы точно не знаете, что диапазон каждого пикселя составляет 0–255, вы нормализуете, используя минимальные и максимальные значения.
  2. В условном выражении «if mode == train» используйте генератор случайных чисел, чтобы трансформировалась только часть изображений (а не все).

Построение модели

Мы будем использовать предварительно обученный Resnet-18 для обучения нашей модели.

ResNet-18 — это архитектура глубокой сверточной нейронной сети (CNN), представленная в 2015 году Kaiming He et al. в своей статье «Глубокое остаточное обучение для распознавания изображений». ResNet расшифровывается как «Residual Network», что означает использование остаточных блоков в сети.

ResNet-18 — это относительно небольшая сеть по сравнению с некоторыми более крупными архитектурами ResNet, такими как ResNet-50 или ResNet-101, но она по-прежнему хорошо справляется с рядом задач классификации изображений. Сеть имеет 18 уровней, включая сверточный уровень, уровень максимального объединения, четыре набора остаточных блоков и полностью связанный уровень. Каждый остаточный блок содержит два или три сверточных слоя, за которыми следует пропускное соединение, которое добавляет исходный ввод к выводу блока. Это пропущенное соединение позволяет сети изучать остаточные отображения, что может помочь решить проблему исчезающих градиентов в очень глубоких сетях.

import resnet
 
img_channels = 1
img_rows = 256
img_cols = 256
 
nb_classes = 2
import numpy as np
import tensorflow as tf
 
class AugmentedDataGenerator(tf.keras.utils.Sequence):
    'Generates data for Keras'
    def __init__(self, mode='train', ablation=None, disease_cls = ['nofinding', 'effusion'], batch_size=32, dim=(256, 256), n_channels=1, shuffle=True):
        'Initialization'
        self.dim = dim
        self.batch_size = batch_size
        self.labels = {}
        self.list_IDs = []
        self.mode = mode
         
        for i, cls in enumerate(disease_cls):
            paths = glob.glob(os.path.join(DATASET_PATH, cls, '*'))
            brk_point = int(len(paths)*0.8)
            if self.mode == 'train':
                paths = paths[:brk_point]
            else:
                paths = paths[brk_point:]
            if ablation is not None:
                paths = paths[:int(len(paths)*ablation/100)]
            self.list_IDs += paths
            self.labels.update({p:i for p in paths})
         
             
        self.n_channels = n_channels
        self.n_classes = len(disease_cls)
        self.shuffle = shuffle
        self.on_epoch_end()
 
    def __len__(self):
        'Denotes the number of batches per epoch'
        return int(np.floor(len(self.list_IDs) / self.batch_size))
 
    def __getitem__(self, index):
        'Generate one batch of data'
 
        indexes = self.indexes[index*self.batch_size:(index+1)*self.batch_size]
        list_IDs_temp = [self.list_IDs[k] for k in indexes]
 
        X, y = self.__data_generation(list_IDs_temp)
 
        return X, y
 
    def on_epoch_end(self):
        'Updates indexes after each epoch'
        self.indexes = np.arange(len(self.list_IDs))
        if self.shuffle == True:
            np.random.shuffle(self.indexes)
 
    def __data_generation(self, list_IDs_temp):
        'Generates data containing batch_size samples' # X : (n_samples, *dim, n_channels)
        # Initialization
        X = np.empty((self.batch_size, *self.dim, self.n_channels))
        y = np.empty((self.batch_size), dtype=int)
         
        delete_rows = []
 
        # Generate data
        for i, ID in enumerate(list_IDs_temp):
            img = io.imread(ID)
            img = img[:, :, np.newaxis]
            if img.shape == (1024, 1024,1):
                img = preprocess_img(img, self.mode)
                X[i,] = img
                y[i] = self.labels[ID]
            else:
                delete_rows.append(i)
                continue
                 
        X = np.delete(X, delete_rows, axis=0)
        y = np.delete(y, delete_rows, axis=0)
         
        return X, tf.keras.utils.to_categorical(y, num_classes=self.n_classes)

Прогон абляции

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

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

Первый запуск абляции с эпохой = 1 должен проверить, хорошо ли модель работает с данными поезда или нет:

model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)
model.compile(loss='categorical_crossentropy',optimizer='SGD',
              metrics=['accuracy'])
training_generator = AugmentedDataGenerator('train', ablation=5)
validation_generator = AugmentedDataGenerator('val', ablation=5)
 
model.fit(training_generator, epochs=1, validation_data=validation_generator)

1/1 [==============================] — 20 с 20 с/шаг — потеря: 1,3713 — точность: 0,9032

Второй запуск абляции с эпохой = 5 и validation_data = None должен проверить, есть ли какая-либо тихая ошибка или нет:

model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)
model.compile(loss='categorical_crossentropy',optimizer='SGD',
              metrics=['accuracy'])
 
training_generator = AugmentedDataGenerator('train', ablation=5)
validation_generator = AugmentedDataGenerator('val', ablation=5)
 
model.fit(training_generator, epochs=5, validation_data=None)

Эпоха 1/5 1/1 [==============================] — 20 с 20 с/шаг — потеря: 1,5056 — точность : 0,8710

Эпоха 2/5 1/1 [==============================] — 15 с 15 с/шаг — потеря: 1,3769 — точность : 0,9062

Эпоха 3/5 1/1 [==============================] — 14 с 14 с/шаг — потеря: 1,3626 — точность : 0,8710

Эпоха 4/5 1/1 [==============================] — 15с 15с/шаг — потеря: 1.3420 — точность : 0,8750

Эпоха 5/5 1/1 [==============================] — 15с 15с/шаг — потеря: 1.3224 — точность : 0,8750

потеря снижается с 1,5 до 1,32, что показывает, что модель не только работает, но и функционально работает и изучает закономерность.

Но, как можно заметить, точность застопорилась на 0,8750. Причина в том, что модель начала предсказывать все как «0». Но почему это происходит?

Это связано с тем, что класс данных сильно несбалансирован. Соотношение «выпот» и «не обнаружение» составляет почти 10 (107/1000). Поскольку большая часть данных относится только к одному классу, простое обучение в этом сценарии не сработает, так как модель в основном будет обучаться и классифицировать большую часть данных как «ненаходимые», что приводит к высокой точности. Если вы заметили, около 90% (1000/107) данных являются «ненаходимыми», и если он классифицирует все данные как одинаковые, точность будет 90%, что близко к 87% точности, которую мы получили. Таким образом, задача правильно классифицировать «выпот» не выполнена. Высокая точность явно вводит нас в заблуждение, и поэтому мы будем использовать AUC для проверки результата.

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

from sklearn.metrics import roc_auc_score
from tensorflow.keras import optimizers
from tensorflow.keras.callbacks import *
 
class roc_callback(Callback):
     
    def on_train_begin(self, logs={}):
        logs['val_auc'] = 0
 
    def on_epoch_end(self, epoch, logs={}):
        y_p = []
        y_v = []
        for i in range(len(validation_generator)):
            x_val, y_val = validation_generator[i]
            y_pred = self.model.predict(x_val)
            y_p.append(y_pred)
            y_v.append(y_val)
        y_p = np.concatenate(y_p)
        y_v = np.concatenate(y_v)
        roc_auc = roc_auc_score(y_v, y_p)
        print ('\nVal AUC for epoch{}: {}'.format(epoch, roc_auc))
        logs['val_auc'] = roc_auc
model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)
model.compile(loss='categorical_crossentropy',optimizer='SGD',
              metrics=['accuracy'])
 
training_generator = AugmentedDataGenerator('train', ablation=20)
validation_generator = AugmentedDataGenerator('val', ablation=20)
 
auc_logger = roc_callback()
 
model.fit(training_generator, epochs=5, validation_data=validation_generator, callbacks=[auc_logger])

Эпоха 1/5 1/1 [==============================] — 3 с 3 с/шаг Значение AUC для эпохи 0: 0,45977011494252873 5/5 [=============================] — 97 с 19 с/шаг — потеря: 1,5244 — точность: 0,7078 — val_loss : 3,0099 — val_accuracy: 0,9062 — val_auc: 0,4598

Эпоха 2/5 1/1 [==============================] — 4 с 4 с/шаг Значение AUC для эпохи 1: 0,3660714285714286 5/5 [=============================] — 89 с 18 с/шаг — потеря: 1,2936 — точность: 0,9032 — val_loss : 2,5718 — val_accuracy: 0,9062 — val_auc: 0,3661

Эпоха 3/5 1/1 [==============================] — 3 с 3 с/шаг Значение AUC для эпохи 2: 0,41379310344827586 5/5 [=============================] — 84 с 17 с/шаг — потеря: 1,2920 — точность: 0,8961 — val_loss : 2,6343 — val_accuracy: 0,8750 — val_auc: 0,4138

Эпоха 4/5 1/1 [==============================] — 3 с 3 с/шаг Значение AUC для эпохи 3: 0,33035714285714285 5/5 [=============================] — 87 с 18 с/шаг — потеря: 1,2486 — точность: 0,9167 — val_loss : 1,9420 — val_accuracy: 0,9062 — val_auc: 0,3304

Эпоха 5/5 1/1 [==============================] — 4 с 4 с/шаг Значение AUC для эпохи 4: 0,183333333333333335 5/5 [==============================] — 87 с 17 с/шаг — потеря: 1,2531 — точность: 0,9091 — val_loss : 2,0484 — val_accuracy: 0,8750 — val_auc: 0,1833

Плохая оценка AUC связана с проблемой распространенности. Просто в наборе данных не так много аномальных случаев. Эта проблема будет возникать почти во всех проблемах с медицинской визуализацией (и в этом отношении в большинстве наборов данных, которые имеют дисбаланс классов). Чтобы решить эту проблему, мы ввели «взвешенную категориальную кросс-энтропию». Это мера потерь, которая применяет веса к различным формам ошибок.

Взвешенная кросс-энтропия

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

Поэтому каждый раз, когда модель допускает ошибку в аномальном классе (в данном случае «выпот»), мы сильно наказываем ее, умножая потерю на большое значение весов. Это приводит к увеличению потерь для неправильно классифицированных классов и, следовательно, к большему изменению весов из-за обратного распространения. Таким образом, кривая обучения для весов, ответственных за ошибочную классификацию, больше.

Допустим, «отсутствие находок» относится к классу 0, а «выпот» — к классу 1.

bin_weights[0,0]:Фактический класс: 0, Прогнозируемый класс: 0, поэтому никаких штрафов, просто нормальный вес 1.

bin_weights[1,1]:Фактический класс: 1, Прогнозируемый класс: 1, поэтому никаких штрафов, просто обычный вес 1.

В случае аномалии:

bin_weights[1,0] — фактический класс равен 1, прогнозируемый класс равен 0, оштрафован на вес 5.

bin_weights[0,1] — фактический класс равен 0, прогнозируемый класс равен 1, штрафовать по весу 5.

from functools import partial
import tensorflow.keras.backend as K
from itertools import product
 
def w_categorical_crossentropy(y_true, y_pred, weights):
    nb_cl = len(weights)
    final_mask = K.zeros_like(y_pred[:, 0])
    y_pred_max = K.max(y_pred, axis=1)
    y_pred_max = K.reshape(y_pred_max, (K.shape(y_pred)[0], 1))
    y_pred_max_mat = K.cast(K.equal(y_pred, y_pred_max), K.floatx())
    for c_p, c_t in product(range(nb_cl), range(nb_cl)):
        final_mask += (weights[c_t, c_p] * y_pred_max_mat[:, c_p] * y_true[:, c_t])
    cross_ent = K.categorical_crossentropy(y_true, y_pred, from_logits=False)
    return cross_ent * final_mask
 
bin_weights = np.ones((2,2))
bin_weights[0, 1] = 5
bin_weights[1, 0] = 5
ncce = partial(w_categorical_crossentropy, weights=bin_weights)
ncce.__name__ ='w_categorical_crossentropy'
model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)
model.compile(loss=ncce, optimizer='SGD',
              metrics=['accuracy'])
 
training_generator = AugmentedDataGenerator('train', ablation=5)
validation_generator = AugmentedDataGenerator('val', ablation=5)
 
model.fit(training_generator, epochs=1, validation_data=None)

1/1 [==============================] — 24 с 24 с/шаг — потери: 6,2068 — точность: 0,0645

Последний запуск

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

УМЕНЬШЕНИЕ СКОРОСТИ ОБУЧЕНИЯ

DecayLR (Decay Learning Rate) — это метод, используемый в глубоком обучении для регулировки скорости обучения во время обучения. Скорость обучения — это гиперпараметр, который определяет, насколько обновляются веса нейронной сети во время обратного распространения.

Метод DecayLR постепенно снижает скорость обучения с течением времени, что может помочь модели более эффективно сходиться, позволяя делать небольшие обновления весов по мере приближения к оптимальному решению.

class DecayLR(tf.keras.callbacks.Callback):
    def __init__(self, base_lr=0.01, decay_epoch=1):
        super(DecayLR, self).__init__()
        self.base_lr = base_lr
        self.decay_epoch = decay_epoch 
        self.lr_history = []
         
    def on_train_begin(self, logs={}):
        K.set_value(self.model.optimizer.lr, self.base_lr)
 
    def on_epoch_end(self, epoch, logs={}):
        new_lr = self.base_lr * (0.5 ** (epoch // self.decay_epoch))
        self.lr_history.append(K.get_value(self.model.optimizer.lr))
        K.set_value(self.model.optimizer.lr, new_lr)

ПРОВЕРКА МОДЕЛИ

Мы сохраняем веса модели только тогда, когда увеличивается «точность проверки». Метод сохранения весов модели называется контрольная точка и вызывается с использованием обратного вызова keras.

model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)
sgd = optimizers.SGD(lr=0.005)
 
bin_weights = np.ones((2,2))
bin_weights[1, 1] = 10
bin_weights[1, 0] = 10
ncce = partial(w_categorical_crossentropy, weights=bin_weights)
ncce.__name__ ='w_categorical_crossentropy'
 
model.compile(loss=ncce,optimizer= sgd,
              metrics=['accuracy'])
training_generator = AugmentedDataGenerator('train', ablation=None)
validation_generator = AugmentedDataGenerator('val', ablation=None)
 
auc_logger = roc_callback()
filepath = 'models/best_model.hdf5'
checkpoint = ModelCheckpoint(filepath, monitor='val_auc', verbose=1, save_best_only=True, mode='max')
 
decay = DecayLR()
 
model.fit(training_generator, epochs=20, validation_data=validation_generator, callbacks=[auc_logger, decay, checkpoint])

Эпоха 1: значение val_auc улучшено с -inf до 0,37035, модель сохранена в models/best_model.hdf5.

Эпоха 2: значение val_auc улучшено с 0,37035 до 0,45026, модель сохранена в models/best_model.hdf5.

Эпоха 3: значение val_auc улучшено с 0,45026 до 0,49041, модель сохранена в models/best_model.hdf5.

….

Эпоха 19: значение val_auc не улучшилось с 0,70817.

Эпоха 20: значение val_auc улучшено с 0,70817 до 0,74477, модель сохранена в models/best_model.hdf5.

Составление прогноза

val_model = resnet.ResnetBuilder.build_resnet_18((img_channels, img_rows, img_cols), nb_classes)
val_model.load_weights('models/best_model.hdf5')

effusion_path = os.path.join(DATASET_PATH, disease_cls[0], '*')
effusion = glob.glob(effusion_path)
effusion = io.imread(effusion[-8])
plt.imshow(effusion,cmap='gray')

img = preprocess_img(effusion[:, :, np.newaxis], 'validation')
val_model.predict(img[np.newaxis,:])

1/1 [==============================] — 1с 594мс/шаг
array([[0.3067983, 0.6932017]], dtype=float32)
Модель предсказывает, что изображение, принадлежащее выпоту, является выпотом.

Заключение

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

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