6.2. Convolução para Imagens¶ Open the notebook in SageMaker Studio Lab
Agora que entendemos como as camadas convolucionais funcionam na teoria, estamos prontos para ver como eles funcionam na prática. Com base na nossa motivação de redes neurais convolucionais como arquiteturas eficientes para explorar a estrutura em dados de imagem, usamos imagens como nosso exemplo de execução.
6.2.1. A Operação de Correlação Cruzada¶
Lembre-se de que, estritamente falando, as camadas convolucionais são um nome impróprio, uma vez que as operações que elas expressam são descritos com mais precisão como correlações cruzadas. Com base em nossas descrições de camadas convolucionais em Section 6.1, em tal camada, um tensor de entrada e um tensor de kernel são combinados para produzir um tensor de saída por meio de uma operação de correlação cruzada.
Vamos ignorar os canais por enquanto e ver como isso funciona com dados bidimensionais e representações ocultas. Em Fig. 6.2.1, a entrada é um tensor bidimensional com altura de 3 e largura de 3. Marcamos a forma do tensor como \(3 \times 3\) or (\(3\), \(3\)). A altura e a largura do kernel são 2. A forma da janela do kernel (ou janela de convolução) é dada pela altura e largura do kernel (aqui é \(2 \times 2\)).
Fig. 6.2.1 Operação de correlação cruzada bidimensional. As partes sombreadas são o primeiro elemento de saída, bem como os elementos tensores de entrada e kernel usados para o cálculo de saída: \(0\times0+1\times1+3\times2+4\times3=19\).¶
Na operação de correlação cruzada bidimensional, começamos com a janela de convolução posicionada no canto superior esquerdo do tensor de entrada e o deslizamos pelo tensor de entrada, ambos da esquerda para a direita e de cima para baixo. Quando a janela de convolução desliza para uma determinada posição, o subtensor de entrada contido nessa janela e o tensor do kernel são multiplicados elemento a elemento e o tensor resultante é resumido produzindo um único valor escalar. Este resultado fornece o valor do tensor de saída no local correspondente. Aqui, o tensor de saída tem uma altura de 2 e largura de 2 e os quatro elementos são derivados de a operação de correlação cruzada bidimensional:
Observe que ao longo de cada eixo, o tamanho da saída é ligeiramente menor que o tamanho de entrada. Como o kernel tem largura e altura maiores que um, só podemos calcular corretamente a correlação cruzada para locais onde o kernel se encaixa totalmente na imagem, o tamanho da saída é dado pelo tamanho da entrada \(n_h \times n_w\) menos o tamanho do kernel de convolução \(k_h \times k_w\) através da
Este é o caso, pois precisamos de espaço suficiente para “deslocar” o
kernel de convolução na imagem. Mais tarde, veremos como manter o
tamanho inalterado preenchendo a imagem com zeros em torno de seu limite
para que haja espaço suficiente para mudar o kernel. Em seguida,
implementamos este processo na função corr2d
, que aceita um tensor
de entrada X
e um tensor de kernel K
e retorna um tensor de
saída Y
.
from mxnet import autograd, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l
npx.set_np()
def corr2d(X, K): #@save
"""Compute 2D cross-correlation."""
h, w = K.shape
Y = np.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] = (X[i:i + h, j:j + w] * K).sum()
return Y
import torch
from torch import nn
from d2l import torch as d2l
def corr2d(X, K): #@save
"""Compute 2D cross-correlation."""
h, w = K.shape
Y = torch.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] = (X[i:i + h, j:j + w] * K).sum()
return Y
import tensorflow as tf
from d2l import tensorflow as d2l
def corr2d(X, K): #@save
"""Compute 2D cross-correlation."""
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
Podemos construir o tensor de entrada X
e o tensor do kernelK
from Fig. 6.2.1 para validar o resultado da
implementação acima da operação de correlação cruzada bidimensional.
X = np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
K = np.array([[0.0, 1.0], [2.0, 3.0]])
corr2d(X, K)
array([[19., 25.],
[37., 43.]])
X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
K = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
corr2d(X, K)
tensor([[19., 25.],
[37., 43.]])
X = tf.constant([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
K = tf.constant([[0.0, 1.0], [2.0, 3.0]])
corr2d(X, K)
<tf.Variable 'Variable:0' shape=(2, 2) dtype=float32, numpy=
array([[19., 25.],
[37., 43.]], dtype=float32)>
6.2.2. Camadas Convolucionais¶
Uma camada convolucional correlaciona a entrada e o kernel e adiciona um bias escalar para produzir uma saída. Os dois parâmetros de uma camada convolucional são o kernel e o bias escalar. Ao treinar modelos com base em camadas convolucionais, normalmente inicializamos os kernels aleatoriamente, assim como faríamos com uma camada totalmente conectada.
Agora estamos prontos para implementar uma camada convolucional
bidimensional com base na função corr2d
definida acima. Na função
construtora __init__
, declaramos weight
ebias
como os dois
parâmetros do modelo. A função de propagação direta chama a função
corr2d
e adiciona o viés.
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()
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
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
Na convolução \(h \times w\) ou um kernel de convolução \(h \times w\) a altura e a largura do kernel de convolução são \(h\) e \(w\), respectivamente. Também nos referimos a uma camada convolucional com um kernel de convolução \(h \times w\) simplesmente como uma camada convolucional \(h \times w\)
6.2.3. Detecção de Borda de Objeto em Imagens¶
Vamos analisar uma aplicação simples de uma camada convolucional: detectar a borda de um objeto em uma imagem encontrando a localização da mudança de pixel. Primeiro, construímos uma “imagem” de \(6\times 8\) pixels. As quatro colunas do meio são pretas (0) e as demais são brancas (1).
X = np.ones((6, 8))
X[:, 2:6] = 0
X
array([[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.]])
X = torch.ones((6, 8))
X[:, 2:6] = 0
X
tensor([[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.]])
X = tf.Variable(tf.ones((6, 8)))
X[:, 2:6].assign(tf.zeros(X[:, 2:6].shape))
X
<tf.Variable 'Variable:0' shape=(6, 8) dtype=float32, numpy=
array([[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.]], dtype=float32)>
Em seguida, construímos um kernel K
com uma altura de 1 e uma
largura de 2. Quando realizamos a operação de correlação cruzada com a
entrada, se os elementos horizontalmente adjacentes forem iguais, a
saída é 0. Caso contrário, a saída é diferente de zero.
K = np.array([[1.0, -1.0]])
K = torch.tensor([[1.0, -1.0]])
K = tf.constant([[1.0, -1.0]])
Estamos prontos para realizar a operação de correlação cruzada com os
argumentos X
(nossa entrada) eK
(nosso kernel). Como você pode
ver, detectamos 1 para a borda do branco ao preto e -1 para a borda do
preto ao branco. Todas as outras saídas assumem o valor 0.
Y = corr2d(X, K)
Y
array([[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.]])
Y = corr2d(X, K)
Y
tensor([[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.]])
Y = corr2d(X, K)
Y
<tf.Variable 'Variable:0' shape=(6, 7) dtype=float32, numpy=
array([[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.]], dtype=float32)>
Agora podemos aplicar o kernel à imagem transposta. Como esperado, ele
desaparece. O kernel K
detecta apenas bordas verticais.
corr2d(d2l.transpose(X), K)
array([[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]])
corr2d(X.t(), K)
tensor([[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]])
corr2d(tf.transpose(X), K)
<tf.Variable 'Variable:0' shape=(8, 5) dtype=float32, numpy=
array([[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]], dtype=float32)>
6.2.4. Aprendendo um Kernel¶
Projetar um detector de borda por diferenças finitas [1, -1]
é legal
se sabemos que é exatamente isso que estamos procurando. No entanto,
quando olhamos para kernels maiores, e considere camadas sucessivas de
convoluções, pode ser impossível especificar exatamente o que cada
filtro deve fazer manualmente.
Agora vamos ver se podemos aprender o kernel que gerou Y
deX
olhando apenas para os pares de entrada–saída. Primeiro construímos uma
camada convolucional e inicializamos seu kernel como um tensor
aleatório. A seguir, em cada iteração, usaremos o erro quadrático para
comparar Y
com a saída da camada convolucional. Podemos então
calcular o gradiente para atualizar o kernel. Por uma questão de
simplicidade, na sequência nós usamos a classe embutida para camadas
convolucionais bidimensionais e ignorar o bias.
# Construct a two-dimensional convolutional layer with 1 output channel and a
# kernel of shape (1, 2). For the sake of simplicity, we ignore the bias here
conv2d = nn.Conv2D(1, kernel_size=(1, 2), use_bias=False)
conv2d.initialize()
# The two-dimensional convolutional layer uses four-dimensional input and
# output in the format of (example, channel, height, width), where the batch
# size (number of examples in the batch) and the number of channels are both 1
X = X.reshape(1, 1, 6, 8)
Y = Y.reshape(1, 1, 6, 7)
for i in range(10):
with autograd.record():
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
l.backward()
# Update the kernel
conv2d.weight.data()[:] -= 3e-2 * conv2d.weight.grad()
if (i + 1) % 2 == 0:
print(f'batch {i + 1}, loss {float(l.sum()):.3f}')
batch 2, loss 4.949
batch 4, loss 0.831
batch 6, loss 0.140
batch 8, loss 0.024
batch 10, loss 0.004
[04:01:54] src/base.cc:49: GPU context requested, but no GPUs found.
# Construct a two-dimensional convolutional layer with 1 output channel and a
# kernel of shape (1, 2). For the sake of simplicity, we ignore the bias here
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False)
# The two-dimensional convolutional layer uses four-dimensional input and
# output in the format of (example channel, height, width), where the batch
# size (number of examples in the batch) and the number of channels are both 1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.zero_grad()
l.sum().backward()
# Update the kernel
conv2d.weight.data[:] -= 3e-2 * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'batch {i + 1}, loss {l.sum():.3f}')
batch 2, loss 9.670
batch 4, loss 2.786
batch 6, loss 0.944
batch 8, loss 0.354
batch 10, loss 0.139
# Construct a two-dimensional convolutional layer with 1 output channel and a
# kernel of shape (1, 2). For the sake of simplicity, we ignore the bias here
conv2d = tf.keras.layers.Conv2D(1, (1, 2), use_bias=False)
# The two-dimensional convolutional layer uses four-dimensional input and
# output in the format of (example channel, height, width), where the batch
# size (number of examples in the batch) and the number of channels are both 1
X = tf.reshape(X, (1, 6, 8, 1))
Y = tf.reshape(Y, (1, 6, 7, 1))
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 the kernel
update = tf.multiply(3e-2, 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'batch {i + 1}, loss {tf.reduce_sum(l):.3f}')
batch 2, loss 13.436
batch 4, loss 2.790
batch 6, loss 0.688
batch 8, loss 0.205
batch 10, loss 0.071
Observe que o erro caiu para um valor pequeno após 10 iterações. Agora daremos uma olhada no tensor do kernel que aprendemos.
conv2d.weight.data().reshape((1, 2))
array([[ 0.9895 , -0.9873705]])
conv2d.weight.data.reshape((1, 2))
tensor([[ 1.0265, -0.9505]])
tf.reshape(conv2d.get_weights()[0], (1, 2))
<tf.Tensor: shape=(1, 2), dtype=float32, numpy=array([[ 1.0083755 , -0.95678693]], dtype=float32)>
Indeed, the learned kernel tensor is remarkably close to the kernel
tensor K
we defined earlier.
6.2.5. Correlação Cruzada e Convolução¶
Lembre-se de nossa observação de Section 6.1 da correspondência entre as operações de correlação cruzada e convolução. Aqui, vamos continuar a considerar as camadas convolucionais bidimensionais. E se essas camadas realizar operações de convolução estritas conforme definido em (6.1.6) em vez de correlações cruzadas? Para obter a saída da operação de convolução estrita, precisamos apenas inverter o tensor do kernel bidimensional tanto horizontal quanto verticalmente e, em seguida, executar a operação de correlação cruzada com o tensor de entrada.
É digno de nota que, uma vez que os kernels são aprendidos a partir de dados no aprendizado profundo, as saídas das camadas convolucionais permanecem inalteradas não importa se tais camadas executam as operações de convolução estrita ou as operações de correlação cruzada.
Para ilustrar isso, suponha que uma camada convolucional execute correlação cruzada e aprenda o kernel em Fig. 6.2.1, que é denotado como a matriz \(\mathbf{K}\) aqui. Supondo que outras condições permaneçam inalteradas, quando esta camada executa convolução estrita em vez disso, o kernel aprendido \(\mathbf{K}'\) será o mesmo que \(\mathbf{K}\) depois que \(\mathbf{K}'\) is é invertido horizontalmente e verticalmente. Quer dizer, quando a camada convolucional executa convolução estrita para a entrada em Fig. 6.2.1 e \(\mathbf{K}'\), a mesma saída em Fig. 6.2.1 (correlação cruzada da entrada e \(\mathbf{K}\)) será obtida.
De acordo com a terminologia padrão da literatura de deep learning, continuaremos nos referindo à operação de correlação cruzada como uma convolução, embora, estritamente falando, seja ligeiramente diferente. Além do mais, usamos o termo elemento para nos referirmos a uma entrada (ou componente) de qualquer tensor que representa uma representação de camada ou um kernel de convolução.
6.2.6. Mapa de Características e Campo Receptivo¶
Conforme descrito em Section 6.1.4.1, a saída da camada convolucional em Fig. 6.2.1 às vezes é chamada de mapa de características, pois pode ser considerado como as representações aprendidas (características) nas dimensões espaciais (por exemplo, largura e altura) para a camada subsequente. Nas CNNs, para qualquer elemento \(x\) de alguma camada, seu campo receptivo refere-se a todos os elementos (de todas as camadas anteriores) que pode afetar o cálculo de \(x\) durante a propagação direta. Observe que o campo receptivo pode ser maior do que o tamanho real da entrada.
Vamos continuar a usar Fig. 6.2.1 para explicar o campo receptivo. Dado o kernel de convolução \(2 \times 2\) o campo receptivo do elemento de saída sombreado (de valor \(19\)) são os quatro elementos na parte sombreada da entrada. Agora, vamos denotar a saída \(2 \times 2\) como \(\mathbf{Y}\) e considere uma CNN mais profunda com uma camada convolucional adicional \(2 \times 2\) que leva \(\mathbf{Y}\) como sua entrada, produzindo um único elemento \(z\). Nesse caso, o campo receptivo de \(z\) em \(\mathbf{Y}\) inclui todos os quatro elementos de \(\mathbf{Y}\), enquanto o campo receptivo na entrada inclui todos os nove elementos de entrada. Por isso, quando qualquer elemento em um mapa de recursos precisa de um campo receptivo maior para detectar recursos de entrada em uma área mais ampla, podemos construir uma rede mais profunda.
6.2.7. Resumo¶
O cálculo central de uma camada convolucional bidimensional é uma operação de correlação cruzada bidimensional. Em sua forma mais simples, isso executa uma operação de correlação cruzada nos dados de entrada bidimensionais e no kernel e, em seguida, adiciona um bias.
Podemos projetar um kernel para detectar bordas em imagens.
Podemos aprender os parâmetros do kernel a partir de dados.
Com os kernels aprendidos a partir dos dados, as saídas das camadas convolucionais permanecem inalteradas, independentemente das operações realizadas por essas camadas (convolução estrita ou correlação cruzada).
Quando qualquer elemento em um mapa de características precisa de um campo receptivo maior para detectar características mais amplas na entrada, uma rede mais profunda pode ser considerada.
6.2.8. Exercícios¶
Construa uma imagem
X
com bordas diagonais.O que acontece se você aplicar o kernel
K
nesta seção a ele?O que acontece se você transpõe
X
?O que acontece se você transpõe
K
?
Quando você tenta encontrar automaticamente o gradiente para a classe
Conv2D
que criamos, que tipo de mensagem de erro você vê?Como você representa uma operação de correlação cruzada como uma multiplicação de matriz, alterando os tensores de entrada e kernel?
Projete alguns kernels manualmente.
Qual é a forma de um kernel para a segunda derivada?
Qual é o kernel de uma integral?
Qual é o tamanho mínimo de um kernel para obter uma derivada de grau \(d\)?