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

Сверточная нейронная сеть для распознавания собак и кошек

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

model = Sequential()
model.add(Conv2D(32, (3, 3), input_shape=(150, 150, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Conv2D(32, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Conv2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Flatten())
model.add(Dense(64))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(1))
model.add(Activation('sigmoid'))

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

Сеть обучена на данных из соревнования Kaggle и сохранена в файлы cats_and_dogs_cnn.json (архитектура сети) и cats_and_dogs_cnn.h5 (веса обученной сети).

Готовим сеть для получения данных из сверточного слоя

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

На первом этапе загружаем обученную ранее сеть:

# Загружаем данные об архитектуре сети из файла json
json_file = open("cats_and_dogs_cnn.json", "r")
loaded_model_json = json_file.read()
json_file.close()
# Создаем модель на основе загруженных данных
loaded_model = model_from_json(loaded_model_json)
# Загружаем веса в модель
loaded_model.load_weights("cats_and_dogs_cnn.h5")

Для проверки печатаем информацию по загруженной модели:

loaded_model.summary()
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_1 (Conv2D)            (None, 148, 148, 32)      896       
_________________________________________________________________
activation_1 (Activation)    (None, 148, 148, 32)      0         
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 74, 74, 32)        0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 72, 72, 32)        9248      
_________________________________________________________________
activation_2 (Activation)    (None, 72, 72, 32)        0         
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 36, 36, 32)        0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 34, 34, 64)        18496     
_________________________________________________________________
activation_3 (Activation)    (None, 34, 34, 64)        0         
_________________________________________________________________
max_pooling2d_3 (MaxPooling2 (None, 17, 17, 64)        0         
_________________________________________________________________
flatten_1 (Flatten)          (None, 18496)             0         
_________________________________________________________________
dense_1 (Dense)              (None, 64)                1183808   
_________________________________________________________________
activation_4 (Activation)    (None, 64)                0         
_________________________________________________________________
dropout_1 (Dropout)          (None, 64)                0         
_________________________________________________________________
dense_2 (Dense)              (None, 1)                 65        
_________________________________________________________________
activation_5 (Activation)    (None, 1)                 0         
=================================================================
Total params: 1,212,513
Trainable params: 1,212,513
Non-trainable params: 0
_________________________________________________________________

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

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

activation_model = Model(inputs=loaded_model.input, 
                         outputs=loaded_model.layers[0].output)

Для класса Model нужно указать, какие слои будет использоваться в качестве входного и выходного. Входным слоем (параметр inputs) будет входной слой в нашей загруженной модели, а выходным слоем (параметр outputs) – слой загруженной модели с номером 0 (задается выражением loaded_model.layers[0].output). В загруженной модели слой с номером 0 – это как раз первый сверточный слой. Другие сверточные слои имеют индексы 3 и 6.

Напечатаем информацию по “обрезанной” модели:

activation_model.summary()
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
conv2d_1_input (InputLayer)  (None, 150, 150, 3)       0
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 148, 148, 32)      896
=================================================================
Total params: 896
Trainable params: 896
Non-trainable params: 0
_________________________________________________________________

Видно, что модель содержит всего два слоя. Первый слой – это входной слой в исходную нейронную сеть. Размер данных на выходе: 150х150х3 , такой же, как у исходных изображений. Второй слой – интересующий нас сверточный слой. На выходе из него получаем данные размером 148х148х3. Размер на выходе из сверточного слоя меньше, чем у исходных изображений, т.к. мы не можем обрабатывать сверточным ядром крайние пиксели, у которых нет соседей.

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

Тестовое изображение

Давайте попробуем подать на вход в сеть вот такое изображение кота:

Тестовая фотография кота

Загружаем изображение и преобразуем в массив numpy для передачи в модель:

image_file_name = 'testing_cat.jpg'
img = image.load_img(image_file_name, target_size=(150, 150))
img_array = image.img_to_array(img)
img_array = np.expand_dims(img_array, axis=0)
img_array /= 255.

Визуализируем данные на выходе из первого сверточного слоя

Давайте подадим наше тестовой изображение на вход в нейронную сеть:

activation = activation_model.predict(img_array)
print(activation.shape)
(1, 148, 148, 32)

На выходе мы получаем массив numpy activation размерностью 148x148x32 – 32 карты признаков размером 148x148, как раз то, что выдает первый сверточный слой. Визуализировать эти данные можно с помощью библиотеки matplotlib. Для примера выведем четвертую карту признаков:

plt.matshow(activation[0, :, :, 4], cmap='viridis')

Четвертая карта признаков первого сверточного слоя

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

Посмотрим карту признаков с номером 18:

plt.matshow(activation[0, :, :, 18], cmap='viridis')

Восемнадцатая карта признаков первого сверточного слоя

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

Давайте визуализируем все карты признаков на выходе из первого сверточного слоя:

images_per_row = 16
n_filters = activation.shape[-1]
size = activation.shape[1]
n_cols = n_filters // images_per_row

display_grid = np.zeros((n_cols * size, images_per_row * size))

for col in range(n_cols):
    for row in range(images_per_row):
        channel_image = activation[0, :, :, col * images_per_row + row]
        channel_image -= channel_image.mean()
        channel_image /= channel_image.std()
        channel_image *= 64
        channel_image += 128
        channel_image = np.clip(channel_image, 0, 255).astype('uint8')
        display_grid[col * size : (col + 1) * size, row * size : (row + 1) * size] = channel_image

scale = 1. / size
plt.figure(figsize=(scale * display_grid.shape[1], scale * display_grid.shape[0]))
plt.grid(False)
plt.imshow(display_grid, aspect='auto', cmap='viridis')

Здесь мы создаем display_grid с картами признаков по 16 изображений в ряд, а затем в цикле копируем карты признаков в него. При этом производится обработка изображения для наглядности. Результаты выглядят так:

все карты признаков первого сверточного слоя

Визуализируем второй и третий сверточные слои

Давайте посмотрим, что изучает сеть на втором сверточном слое. Для этого нам нужно “обрезать” исходную модель не после нулевого, а после третьего слоя:

activation_model = Model(inputs=loaded_model.input, 
                         outputs=loaded_model.layers[3].output)

Теперь “обрезанная” модель будет выглядеть следующим образом:

activation_model.summary()
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
conv2d_1_input (InputLayer)  (None, 150, 150, 3)       0
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 148, 148, 32)      896
_________________________________________________________________
activation_1 (Activation)    (None, 148, 148, 32)      0
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 74, 74, 32)        0
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 72, 72, 32)        9248
=================================================================
Total params: 10,144
Trainable params: 10,144
Non-trainable params: 0
_________________________________________________________________

В сети уже пять слоев, выходной слой – это второй сверточный слой. Его размерность 72x72x32 - 32 карты признаков размером 72x72 каждая. Размер карты признаков на выходе второго сверточного слоя меньше, чем на выходе из первого, т.к. между ними находится слой подвыборки max_pooling2d, в котором размер данных уменьшается в два раза.

Передаем тестовое изображение в нашу измененную модель:

activation = activation_model.predict(img_array)
print(activation.shape)
(1, 72, 72, 32)

На выходе получаем 32 карты признаков размером 72х72, как и ожидалось. Визуализируем для примера одну из карт признаков:

plt.matshow(activation[0, :, :, 0], cmap='viridis')

Нулевая карта признаков второго сверточного слоя

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

Все карты признаков второго сверточного слоя

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

activation_model = Model(inputs=loaded_model.input, 
                         outputs=loaded_model.layers[6].output)

Печатаем информацию по модели:

activation_model.summary()

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_1_input (InputLayer)  (None, 150, 150, 3)       0         
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 148, 148, 32)      896       
_________________________________________________________________
activation_1 (Activation)    (None, 148, 148, 32)      0         
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 74, 74, 32)        0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 72, 72, 32)        9248      
_________________________________________________________________
activation_2 (Activation)    (None, 72, 72, 32)        0         
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 36, 36, 32)        0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 34, 34, 64)        18496     
=================================================================
Total params: 28,640
Trainable params: 28,640
Non-trainable params: 0
_________________________________________________________________

Теперь в сети уже восемь слоев и последним идет третий сверточный слой. На выходе у него 64 карты признаков размерности 34х34. Размер опять уменьшен в два раза, по сравнению с предыдущим сверточным слоем из-за уменьшения размерности на слое подвыборки. Дополнительно размер уменьшен еще на 2 пикселя, т.к. с помощью свертки нельзя обработать крайние пиксели в изображении.

Снова передадим наше тестовое изображение в измененную нейронную сеть:

activation = activation_model.predict(img_array)
print(activation.shape)
(1, 34, 34, 64)

На выходе получили 64 карты признаков размером 34х34, что и ожидалось. Визуализируем одну из карт признаков:

plt.matshow(activation[0, :, :, 18], cmap='viridis')

Восемнадцатая карта признаков третьего сверточного слоя

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

Выведем все карты признаков на выходе из третьего сверточного слоя:

Все карты признаков третьего сверточного слоя

Итоги

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

В качестве домашнего задания попытайтесь изменить структуру нейронной сети для распознавания собак и кошек, а затем визуализировать выходные данные сверточных слоев в новой сети. Например, можете добавить еще несколько сверточных слоев в сеть, или вставлять слой подвыборки не после каждого сверточного слоя, а после двух. С помощью визуализации попытайтесь разобраться, как количество слоев влияет на “понимание” сетью изображений. Также попробуйте визуализировать предварительно обученные нейронные сети, например, VGG16.

Пишите в комментариях, что у вас получилось визуализировать и помогло ли это понять процесс обучения нейронной сети!

Полезные ссылки

  1. Полный текст кода из статьи.
  2. Соревнования Kaggle Dogs vs Cats.
  3. Как подготовить набор изображений для обучения нейронной сети в Keras.
  4. Сеть VGG16.
  5. Книга Франсуа Шолле: Глубокое обучение на Python.