На этом шаге мы рассмотрим использование этих связей.
Возможно, вы знакомы с детской игрой "испорченный телефон" (в Великобритании ее называют также "китайским шепотом", а во Франции - "арабским телефоном"), где первоначальное сообщение нашептывается на ухо первому игроку, который затем нашептывает его на ухо следующему и т. д. Последний игрок громко сообщает услышанное им сообщение, зачастую существенно отличающееся от исходной версии. Это - забавная метафора накопления ошибок при последовательной передаче информации по зашумленному каналу.
Как оказалось, обратное распространение в последовательной модели глубокого обучения очень похоже на игру в "испорченный телефон". У вас есть цепочка функций, например:
y = f4(f3(f2(f1(x))))
Цель игры - настроить параметры каждой функции в цепочке, основываясь на ошибке, полученной на выходе f4() (потеря модели). Чтобы настроить f1(), нужно передать информацию об ошибке через f2(), f3() и f4(). Однако каждая следующая функция в цепочке вносит свои искажения. Если цепочка функций слишком глубокая, искажения начинают подавлять информацию о градиенте, и обратное распространение перестает работать. Ваша модель вообще не будет обучаться. Это проблема затухания градиентов.
Решение простое: нужно лишь заставить каждую функцию в цепочке перестать вносить искажения, чтобы сохранить информацию, полученную от предыдущей функции. Самый простой способ реализовать это - использовать остаточные связи. Входные данные слоя или блока слоев добавляются в его выходные данные (рисунок 1).
Рис.1. Остаточная связь в обход блока, выполняющего обработку
Остаточные связи действуют как короткие пути для распространения информации в обход деструктивных блоков или блоков, вносящих существенные искажения (таких как блоки с нежелательными активациями или слоями прореживания), позволяя информации градиента ошибок проходить по глубокой сети без искажений. Этот метод был представлен в 2015 году в семействе моделей ResNet (разработанном Каймином Хе с коллегами в Microsoft).
На практике остаточная связь реализуется следующим образом.
# Некоторый входной тензор x = ... # Сохранить указатель на исходные данные residual = x # Это вычислительный блок, который может вносить искажения x = block(x) # Добавить исходные данные в выход слоя: получившиеся выходные # данные будут содержать полную информацию о входе x = add([x, residual])
Обратите внимание: добавление входных данных блока в выходные подразумевает, что выход должен иметь ту же форму, что и вход. Однако этот прием не подходит для случаев, когда блок включает сверточные слои с увеличенным количеством фильтров или слой выбора максимального по соседям. В таких случаях можно использовать слой Conv2D 1 #× 1 без активации для линейного проецирования остатков в желаемую форму вывода (пример 9.2). Обычно сверточные слои в целевом блоке создаются с аргументом padding="same", чтобы избежать уменьшения пространственного разрешения из-за дополнения, и берутся увеличенные шаги свертки в остаточной проекции, чтобы обеспечить соответствие любому уменьшению пространственного разрешения, вызванному слоем выбора максимального по соседним значениям (пример 9.3) .
from tensorflow import keras from tensorflow.keras import layers inputs = keras.Input(shape=(32, 32, 3)) x = layers.Conv2D(32, 3, activation="relu")(inputs) # Сохранить исходные данные для остаточной связи residual = x # Это слой, в обход которого создается остаточная связь: # он увеличивает количество фильтров на выходе с 32 до 64. Обратите внимание, # что аргумент padding="same" используется здесь для того, чтобы # избежать уменьшения разрешения из-за дополнения x = layers.Conv2D(64, 3, activation="relu", padding="same")(x) # В residual имеется только 32 фильтра, поэтому мы используем слой # Conv2D 1 #× 1 для преобразования в требуемую форму residual = layers.Conv2D(64, 1)(residual) # Теперь выход блока и тензор residual имеют одинаковую форму, # и их можно сложить x = layers.add([x, residual])
inputs = keras.Input(shape=(32, 32, 3)) x = layers.Conv2D(32, 3, activation="relu")(inputs) # Сохранить исходные данные для остаточной связи residual = x # Блок из двух слоев, вокруг которого создается остаточная связь: # он включает слой 2 #× 2 выбора максимального по соседям. Обратите внимание, # что аргумент padding="same" используется здесь в обоих слоях - Conv2D и # MaxPooling2D, - чтобы избежать уменьшения разрешения из-за дополнения x = layers.Conv2D(64, 3, activation="relu", padding="same")(x) x = layers.MaxPooling2D(2, padding="same")(x) # В слое преобразования остатков используется аргумент strides=2, чтобы # обеспечить соответствие с уменьшенным разрешением, созданным слоем # MaxPooling2D residual = layers.Conv2D(64, 1, strides=2)(residual) # Теперь выход блока и тензор residual имеют одинаковую форму, # и их можно сложить x = layers.add([x, residual])
Для большей конкретности ниже приводится пример простой сверточной сети, организованной в серию блоков, каждый из которых состоит из двух сверточных слоев и одного необязательного слоя MaxPooling2D с остаточными связями в обход каждого блока:
inputs = keras.Input(shape=(32, 32, 3)) x = layers.Rescaling(1./255)(inputs) # Вспомогательная функция для создания блока с двумя сверточными слоями и # одним необязательным слоем MaxPooling2D и остаточной связи вокруг него def residual_block(x, filters, pooling=False): residual = x x = layers.Conv2D(filters, 3, activation="relu", padding="same")(x) x = layers.Conv2D(filters, 3, activation="relu", padding="same")(x) if pooling: x = layers.MaxPooling2D(2, padding="same")(x) # Если требуется добавить слой MaxPooling2D, нужно также добавить # сверточный слой с увеличенным шагом свертки, чтобы преобразовать # остатки в необходимую форму residual = layers.Conv2D(filters, 1, strides=2)(residual) elif filters != residual.shape[-1]: # Если слой MaxPooling2D добавлять не требуется, преобразовывать # остатки нужно, только если количество каналов изменилось residual = layers.Conv2D(filters, 1)(residual) x = layers.add([x, residual]) return x # Первый блок x = residual_block(x, filters=32, pooling=True) # Второй блок; обратите внимание, что число фильтров в каждом # блоке увеличивается x = residual_block(x, filters=64, pooling=True) # Последний блок создается без слоя MaxPooling2D, потому что далее # применяется слой глобального усреднения x = residual_block(x, filters=128, pooling=False) x = layers.GlobalAveragePooling2D()(x) outputs = layers.Dense(1, activation="sigmoid")(x) model = keras.Model(inputs=inputs, outputs=outputs) model.summary()
Ниже приводится сводная информация о созданной модели:
Model: "functional" Layer (type) Output Shape Param # Connected to input_layer_2 (None, 32, 32, 3) 0 - (InputLayer) rescaling (None, 32, 32, 3) 0 input_layer_2[0]: (Rescaling) conv2d_6 (Conv2D) (None, 32, 32, 896 rescaling[0][0] 32) conv2d_7 (Conv2D) (None, 32, 32, 9,248 conv2d_6[0][0] 32) max_pooling2d_1 (None, 16, 16, 0 conv2d_7[0][0] (MaxPooling2D) 32) conv2d_8 (Conv2D) (None, 16, 16, 128 rescaling[0][0] 32) add_2 (Add) (None, 16, 16, 0 max_pooling2d_1[: 32) conv2d_8[0][0] conv2d_9 (Conv2D) (None, 16, 16, 18,496 add_2[0][0] 64) conv2d_10 (Conv2D) (None, 16, 16, 36,928 conv2d_9[0][0] 64) max_pooling2d_2 (None, 8, 8, 64) 0 conv2d_10[0][0] (MaxPooling2D) conv2d_11 (Conv2D) (None, 8, 8, 64) 2,112 add_2[0][0] add_3 (Add) (None, 8, 8, 64) 0 max_pooling2d_2[: conv2d_11[0][0] conv2d_12 (Conv2D) (None, 8, 8, 128) 73,856 add_3[0][0] conv2d_13 (Conv2D) (None, 8, 8, 128) 147,584 conv2d_12[0][0] conv2d_14 (Conv2D) (None, 8, 8, 128) 8,320 add_3[0][0] add_4 (Add) (None, 8, 8, 128) 0 conv2d_13[0][0], conv2d_14[0][0] global_average_poo: (None, 128) 0 add_4[0][0] (GlobalAveragePool: dense (Dense) (None, 1) 129 global_average_p: Total params: 297,697 (1.14 MB) Trainable params: 297,697 (1.14 MB) Non-trainable params: 0 (0.00 B)
С остаточными связями можно конструировать сети произвольной глубины, не беспокоясь о затухании градиентов.
Теперь перейдем к следующему важному шаблону архитектуры сверточных сетей - пакетной нормализации.
На следующем шаге мы рассмотрим пакетную нормализацию.