На этом шаге мы закончим изучение этого вопроса.
Давайте реализуем функцию градиентного восхождения с помощью GradientTape. Обратите внимание, что для ускорения мы будем использовать декоратор @tf.function.
Иногда для ускорения процесса градиентного спуска используется неочевидный трюк - нормализация градиентного тензора делением на его L2-норму (квадратный корень из усредненных квадратов значений в тензоре). Это гарантирует, что величина обновлений во входном изображении всегда будет находиться в одном диапазоне.
@tf.function def gradient_ascent_step(image, filter_index, learning_rate): with tf.GradientTape() as tape: # Явно передать для наблюдения тензор с изображением, потому что это # не объект Variable (автоматически под наблюдение попадают только # объекты Variable) tape.watch(image) # Вычислить скаляр потерь, показывающий, насколько текущее # изображение активирует фильтр loss = compute_loss(image, filter_index) # Вычислить градиенты потерь по отношению к изображению grads = tape.gradient(loss, image) # Применить "трюк нормализации градиента" grads = tf.math.l2_normalize(grads) # Немного сдвинуть изображение в направлении наибольшей # активации целевого фильтра image += learning_rate * grads # Вернуть обновленное изображение, чтобы дать возможность # вызывать эту функцию в цикле return image
Теперь у нас есть все необходимые элементы. Объединим их в функцию на Python, которая будет принимать имя слоя и индекс фильтра и возвращать тензор, представляющий собой шаблон, который максимизирует активацию заданного фильтра.
img_width = 200 img_height = 200 def generate_filter_pattern(filter_index): # Количество шагов градиентного восхождения iterations = 30 # Амплитуда одного шага learning_rate = 10. image = tf.random.uniform( minval=0.4, maxval=0.6, # Инициализировать тензор изображения случайными значениями # (модель Xception принимает входные значения в диапазоне [0, 1], # поэтому здесь мы выбираем диапазон с центром в точке # со значением 0,5) shape=(1, img_width, img_height, 3)) for i in range(iterations): # В цикле обновлять значения тензора с изображением, # чтобы максимизировать функцию потерь image = gradient_ascent_step(image, filter_index, learning_rate) return image[0].numpy()
Полученный тензор с изображением - это массив с формой (200, 200, 3) и вещественными значениями, которые могут быть нецелочисленными, в диапазоне [0, 255]. Поэтому нужно дополнительно его обработать, чтобы превратить в изображение, пригодное для показа. Сделаем это с помощью простой вспомогательной функции.
import numpy as np def deprocess_image(image): # Нормализовать значения в тензоре приведением их в диапазон [0, 255] image -= image.mean() image /= image.std() image *= 64 image += 128 image = np.clip(image, 0, 255).astype("uint8") # Центрировать результат, чтобы избежать артефактов на границах image = image[25:-25, 25:-25, :] return image
Взглянем на получившееся изображение (рисунок 1):
import matplotlib.pyplot as plt plt.axis("off") plt.imshow(deprocess_image(generate_filter_pattern(filter_index=2)))
Рис.1. Шаблон, на который второй канал в слое block3_sepconv1 дает максимальный отклик
Похоже, что фильтр с индексом 0 в слое block3_sepconv1 отвечает за узор из горизонтальных линий, немного похожий на водную гладь или на мех.
А теперь самое интересное: мы можем визуализировать все фильтры в слое или даже все фильтры во всех слоях модели.
all_images = [] # Сгенерировать и сохранить изображения для первых 64 фильтров в слое for filter_index in range(64): print(f"Processing filter {filter_index}") image = deprocess_image( generate_filter_pattern(filter_index)) all_images.append(image) # Подготовить чистый холст для добавления изображений фильтров margin = 5 n = 8 cropped_width = img_width - 25 * 2 cropped_height = img_height - 25 * 2 width = n * cropped_width + (n - 1) * margin height = n * cropped_height + (n - 1) * margin stitched_filters = np.zeros((width, height, 3)) # Заполнить изображение сохраненными фильтрами for i in range(n): for j in range(n): image = all_images[i * n + j] row_start = (cropped_width + margin) * i row_end = (cropped_width + margin) * i + cropped_width column_start = (cropped_height + margin) * j column_end = (cropped_height + margin) * j + cropped_height stitched_filters[ row_start: row_end, column_start: column_end, :] = image # Сохранить холст на диск keras.utils.save_img( f"filters_for_layer_{layer_name}.png", stitched_filters)
Эти визуальные представления фильтров (рисунок 2) могут многое рассказать о том, как слои сверточной нейронной сети видят мир: каждый слой в сети обучает свою коллекцию фильтров так, чтобы их входы можно было выразить в виде комбинации фильтров. Это напоминает преобразование Фурье, разлагающее сигнал в пакет косинусоидных функций. Фильтры в таких пакетах фильтров сверточной нейронной сети становятся все сложнее с увеличением слоя в модели:
Рис.2. Некоторые шаблоны фильтров из слоев block2_sepconv1, block4_sepconv1 и block8_sepconv1
На следующем шаге мы рассмотрим визуализацию тепловых карт активации класса.