6.2 Свёртки для изображений
В предыдущем разделе мы разобрали принцип работы свёрточного слоя, теперь посмотрим, как он применяется на практике. Поскольку свёрточные нейронные сети созданы для анализа данных изображений, в этом разделе мы будем рассматривать именно изображения.
6.2.1 Операция взаимной корреляции
Строго говоря, название «свёрточный слой» некорректно, поскольку на самом деле он выполняет взаимную корреляцию (cross-correlation), а не свёртку. Как описано в разделе 6.1, в свёрточном слое входной тензор и ядро свёртки с помощью взаимной корреляции дают выходной тензор.
Сначала временно отвлечёмся от каналов (третьего измерения) и посмотрим, как обрабатывать двумерные данные изображения и скрытые представления. На рис. 6.2.1 входом служит двумерный тензор высотой и шириной (то есть формы ). Высота и ширина ядра свёртки равны , а форма окна свёртки (или окна ядра) определяется высотой и шириной ядра, то есть равна .
Рис. 6.2.1. Двумерная взаимная корреляция. Затенённая часть — первый элемент выхода, а также элементы входного и ядерного тензоров, используемые для его вычисления:
В двумерной взаимной корреляции окно свёртки начинает движение из левого верхнего угла входного тензора и скользит слева направо и сверху вниз. Когда окно переходит в новое положение, входящая в него часть тензора поэлементно умножается на ядро, полученные произведения суммируются в одно скалярное значение, и это значение становится элементом выходного тензора в данной позиции. В приведённом примере четыре элемента выходного тензора получаются двумерной взаимной корреляцией; выход имеет высоту и ширину :
Обратите внимание, что выход немного меньше входа. Это происходит потому, что ширина и высота ядра больше 1, а ядро выполняет взаимную корреляцию только в тех позициях изображения, где оно полностью помещается. Таким образом, размер выхода равен размеру входа , уменьшенному на размер ядра , то есть:
Это связано с тем, что ядру нужно достаточно места, чтобы «двигаться» по изображению. Позже мы увидим, как дополнять границы изображения нулями, чтобы обеспечить место для движения ядра и сохранить размер выхода. А сейчас реализуем описанный процесс в функции corr2d, которая принимает входной тензор X и ядерный тензор K и возвращает выходной тензор Y.
MXNet:
from d2l import mxnet as d2l
from mxnet import autograd, np, npx
from mxnet.gluon import nn
npx.set_np()
PyTorch:
from d2l import torch as d2l
import torch
from torch import nn
PaddlePaddle:
from d2l import paddle as d2l
import warnings
warnings.filterwarnings("ignore")
import paddle
from paddle import nn
MXNet, PyTorch, PaddlePaddle:
def corr2d(X, K): #@save
"""Вычисляет двумерную взаимную корреляцию"""
h, w = K.shape
Y = d2l.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j] = d2l.reduce_sum((X[i: i + h, j: j + w] * K))
return Y
TensorFlow:
from d2l import tensorflow as d2l
import tensorflow as tf
def corr2d(X, K): #@save
"""Вычисляет двумерную взаимную корреляцию"""
h, w = K.shape
Y = tf.Variable(tf.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1)))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j].assign(tf.reduce_sum(
X[i: i + h, j: j + w] * K))
return Y
Используя входной тензор X и ядерный тензор K с рис. 6.2.1, проверим результат описанной двумерной взаимной корреляции.
X = d2l.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
K = d2l.tensor([[0.0, 1.0], [2.0, 3.0]])
corr2d(X, K)
6.2.2 Свёрточные слои
Свёрточный слой выполняет взаимную корреляцию входа и весов ядра и после добавления скалярного смещения формирует выход. Поэтому у свёрточного слоя два обучаемых параметра: веса ядра и скалярное смещение. Как и полносвязные слои ранее, при обучении моделей на основе свёрточных слоёв веса ядра мы инициализируем случайными значениями.
Опираясь на функцию corr2d, определённую выше, реализуем двумерный свёрточный слой. В конструкторе __init__ объявляем weight и bias как два параметра модели. Функция прямого распространения вызывает corr2d и добавляет смещение.
MXNet:
class Conv2D(nn.Block):
def __init__(self, kernel_size, **kwargs):
super().__init__(**kwargs)
self.weight = self.params.get('weight', shape=kernel_size)
self.bias = self.params.get('bias', shape=(1,))
def forward(self, x):
return corr2d(x, self.weight.data()) + self.bias.data()
PyTorch:
class Conv2D(nn.Module):
def __init__(self, kernel_size):
super().__init__()
self.weight = nn.Parameter(torch.rand(kernel_size))
self.bias = nn.Parameter(torch.zeros(1))
def forward(self, x):
return corr2d(x, self.weight) + self.bias
TensorFlow:
class Conv2D(tf.keras.layers.Layer):
def __init__(self):
super().__init__()
def build(self, kernel_size):
initializer = tf.random_normal_initializer()
self.weight = self.add_weight(name='w', shape=kernel_size,
initializer=initializer)
self.bias = self.add_weight(name='b', shape=(1, ),
initializer=initializer)
def call(self, inputs):
return corr2d(inputs, self.weight) + self.bias
PaddlePaddle:
class Conv2D(nn.Layer):
def __init__(self, kernel_size):
super().__init__()
self.weight = paddle.ParamAttr(paddle.rand(kernel_size))
self.bias = paddle.ParamAttr(paddle.zeros(1))
def forward(self, x):
return corr2d(x, self.weight) + self.bias
Ядро с высотой и шириной называют свёрткой или ядром . Свёрточный слой с ядром также называем слоем .
6.2.3 Обнаружение границ объектов на изображении
Ниже приведён простой пример применения свёрточного слоя: по местам, где меняются пиксели, мы обнаруживаем границы разных цветов на изображении. Сначала построим чёрно-белое изображение размером пикселей. Четыре средних столбца чёрные (), остальные пиксели белые ().
MXNet, PyTorch, PaddlePaddle:
X = d2l.ones((6, 8))
X[:, 2:6] = 0
X
TensorFlow:
X = tf.Variable(tf.ones((6, 8)))
X[:, 2:6].assign(tf.zeros(X[:, 2:6].shape))
X
Далее построим ядро K высотой и шириной . При взаимной корреляции выход равен нулю, если два горизонтально соседних элемента одинаковы, и отличен от нуля в противном случае.
K = d2l.tensor([[1.0, -1.0]])
Теперь выполним взаимную корреляцию входа X и ядра K. Как видно ниже, единица в выходе Y соответствует границе от белого к чёрному, а −1 — границе от чёрного к белому; в остальных случаях выход равен .
Y = corr2d(X, K)
Y
Теперь транспонируем входное изображение и снова выполним ту же взаимную корреляцию. Результат показан ниже: вертикальные границы, которые мы обнаружили раньше, исчезли. Неудивительно: ядро K умеет обнаруживать только вертикальные границы и не замечает горизонтальных.
corr2d(d2l.transpose(X), K)
6.2.4 Изучение ядра свёртки
Если нам нужны только чёрно-белые границы, детектора [1, -1] достаточно. Однако когда ядра имеют более сложные значения или когда слоёв свёртки много, вручную проектировать фильтры невозможно. Можно ли выучить ядро, которое по X получает Y?
Проверим, можно ли выучить ядро, которое по X даёт Y, опираясь только на пары «вход — выход». Сначала построим свёрточный слой и инициализируем его ядро случайным тензором. Затем на каждой итерации сравниваем квадратичную ошибку между Y и выходом свёрточного слоя и вычисляем градиенты для обновления ядра. Для простоты используем встроенный двумерный свёрточный слой и не будем учитывать смещение.
MXNet:
# Строим двумерный свёрточный слой с 1 выходным каналом и ядром формы (1, 2)
conv2d = nn.Conv2D(1, kernel_size=(1, 2), use_bias=False)
conv2d.initialize()
# Этот двумерный свёрточный слой использует четырёхмерный формат входа и выхода
# (размер батча, канал, высота, ширина), где размер батча и количество каналов равны 1
X = X.reshape(1, 1, 6, 8)
Y = Y.reshape(1, 1, 6, 7)
lr = 3e-2 # Скорость обучения
for i in range(10):
with autograd.record():
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
l.backward()
# Обновляем ядро свёртки
conv2d.weight.data()[:] -= lr * conv2d.weight.grad()
if (i + 1) % 2 == 0:
print(f'эпоха {i+1}, потери {float(l.sum()):.3f}')
PyTorch:
# Строим двумерный свёрточный слой с 1 выходным каналом и ядром формы (1, 2)
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False)
# Этот двумерный свёрточный слой использует четырёхмерный формат входа и выхода
# (размер батча, канал, высота, ширина), где размер батча и количество каналов равны 1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
lr = 3e-2 # Скорость обучения
for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.zero_grad()
l.sum().backward()
# Обновляем ядро свёртки
conv2d.weight.data[:] -= lr * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'эпоха {i+1}, потери {l.sum():.3f}')
TensorFlow:
# Строим двумерный свёрточный слой с 1 выходным каналом и ядром формы (1, 2)
conv2d = tf.keras.layers.Conv2D(1, (1, 2), use_bias=False)
# Этот двумерный свёрточный слой использует четырёхмерный формат входа и выхода
# (размер батча, высота, ширина, канал), где размер батча и количество каналов равны 1
X = tf.reshape(X, (1, 6, 8, 1))
Y = tf.reshape(Y, (1, 6, 7, 1))
lr = 3e-2 # Скорость обучения
Y_hat = conv2d(X)
for i in range(10):
with tf.GradientTape(watch_accessed_variables=False) as g:
g.watch(conv2d.weights[0])
Y_hat = conv2d(X)
l = (abs(Y_hat - Y)) ** 2
# Обновляем ядро свёртки
update = tf.multiply(lr, g.gradient(l, conv2d.weights[0]))
weights = conv2d.get_weights()
weights[0] = conv2d.weights[0] - update
conv2d.set_weights(weights)
if (i + 1) % 2 == 0:
print(f'эпоха {i+1}, потери {tf.reduce_sum(l):.3f}')
PaddlePaddle:
# Строим двумерный свёрточный слой с 1 выходным каналом и ядром формы (1, 2)
conv2d = nn.Conv2D(1, 1, kernel_size=(1, 2))
# Этот двумерный свёрточный слой использует четырёхмерный формат входа и выхода
# (размер батча, канал, высота, ширина), где размер батча и количество каналов равны 1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
lr = 3e-2 # Скорость обучения
for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.clear_gradients()
l.sum().backward()
# Обновляем ядро свёртки
with paddle.no_grad():
conv2d.weight[:] -= lr * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'эпоха {i+1}, потери {l.sum().item():.3f}')
После итераций ошибка стала достаточно малой. Теперь посмотрим на весовой тензор выученного ядра.
MXNet:
d2l.reshape(conv2d.weight.data(), (1, 2))
PyTorch:
d2l.reshape(conv2d.weight.data, (1, 2))
TensorFlow:
d2l.reshape(conv2d.get_weights()[0], (1, 2))
PaddlePaddle:
d2l.reshape(conv2d.weight, (1, 2))
Внимательный читатель наверняка заметит, что выученные веса ядра очень близки к ядру K, которое мы определили ранее.
6.2.5 Взаимная корреляция и свёртка
Вспомним соответствие между взаимной корреляцией и свёрткой, которое мы наблюдали в разделе 6.1. Чтобы получить результат формальной свёртки, нужно выполнить строгую свёртку, определённую в формуле (6.1.2), а не взаимную корреляцию. К счастью, разница невелика: достаточно отразить двумерный ядерный тензор по горизонтали и по вертикали, а затем выполнить взаимную корреляцию входного тензора.
Стоит заметить, что ядро выучивается из данных, поэтому выход свёрточного слоя не меняется, выполняет ли слой строгую свёртку или взаимную корреляцию. Чтобы это показать, предположим, что слой выполняет взаимную корреляцию и выучивает ядро с рис. 6.2.1, которое здесь обозначено матрицей . При прочих равных условиях, если тот же слой выполняет строгую свёртку, выученное ядро после горизонтального и вертикального отражения будет совпадать с . Иными словами, если свёрточный слой применит строгую свёртку к входу с рис. 6.2.1 и ядру , он получит тот же выход, что и при взаимной корреляции на рис. 6.2.1.
Чтобы соответствовать стандартной терминологии литературы по глубокому обучению, мы и дальше будем называть «взаимную корреляцию» свёрткой, хотя строго говоря это разные операции. Кроме того, веса ядерного тензора будем называть его элементами.
6.2.6 Карты признаков и рецептивные поля
Как говорилось в разделе 6.1.4.1, выход свёрточного слоя с рис. 6.2.1 иногда называют картой признаков (feature map): его можно рассматривать как преобразование, которое переводит вход в пространственное представление для следующего слоя. В свёрточной нейронной сети для любого элемента некоторого слоя рецептивное поле (receptive field) — это все элементы (из всех предыдущих слоёв), которые могут влиять на вычисление в ходе прямого распространения.
Обратите внимание, что рецептивное поле может быть больше самого входа. Поясним это на примере с рис. 6.2.1: при ядре рецептивное поле затенённого выходного элемента со значением — это четыре элемента затенённой части входа. Пусть предыдущий выход имеет размер , и за ним следует свёрточный слой, который получает и выдаёт один элемент . В этом случае рецептивное поле на включает все четыре элемента , а рецептивное поле на входе включает все девять входных элементов. Поэтому, когда какому-либо элементу карты признаков нужно уловить признаки более широкой области входа, можно строить более глубокую сеть.
Резюме
- Ядром двумерного свёрточного слоя является двумерная взаимная корреляция. В простейшем случае она выполняется над двумерным входом и ядром, после чего добавляется смещение.
- Мы можем спроектировать ядро свёртки, которое обнаруживает границы на изображении.
- Параметры ядра можно выучить из данных.
- При обучении ядра выход свёрточного слоя почти не зависит от того, выполняется ли строгая свёртка или взаимная корреляция.
- Чтобы выявить признаки на более широкой области входа, можно строить более глубокие свёрточные сети.
Упражнения
- Постройте изображение
Xс диагональными границами.- Что произойдёт, если применить к
XядроKиз этого раздела? - Что произойдёт при транспонировании
X? - Что произойдёт при транспонировании
K?
- Что произойдёт, если применить к
- Какое сообщение об ошибке появляется при автоматическом дифференцировании созданного нами
Conv2D? - Как, изменяя входной и ядерный тензоры, представить взаимную корреляцию в виде матричного умножения?
- Вручную спроектируйте несколько ядер свёртки.
- Каков вид ядра для второй производной?
- Каков вид ядра для интегрирования?
- Каков минимальный размер ядра для вычисления производной порядка ?
MXNet: Обсуждение
PyTorch: Обсуждение
TensorFlow: Обсуждение
PaddlePaddle: Обсуждение