Phase 04 - Lesson 02
Convoluções do Zero
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Uma convolução é uma minúscula camada densa que você desliza sobre uma imagem, compartilhando os mesmos pesos em cada posição.
Tipo: Construção Linguagens: Python Pré-requisitos: Fase 3 (Núcleo de Deep Learning), Fase 4 Lição 01 (Fundamentos de Imagem) Tempo: ~75 minutos
Objetivos de Aprendizagem
- Implementar convolução 2D do zero usando apenas NumPy, incluindo a versão com laços aninhados e uma versão vetorizada com
im2col - Calcular o tamanho espacial de saída para qualquer combinação de tamanho de entrada, tamanho de kernel, padding e stride, e justificar a fórmula
(H - K + 2P) / S + 1 - Projetar kernels à mão (borda, blur, sharpen, Sobel) e explicar por que cada um produz o padrão de ativações que produz
- Empilhar convoluções em um extrator de características e conectar a profundidade da pilha ao tamanho do campo receptivo
O Problema
Uma camada totalmente conectada sobre uma imagem RGB de 224x224 precisaria de 224 * 224 * 3 = 150.528 pesos de entrada por neurônio. Uma única camada oculta com 1.000 unidades já são 150 milhões de parâmetros, antes de você ter aprendido qualquer coisa útil. Pior, essa camada não tem noção de que um cachorro no canto superior esquerdo e um cachorro no canto inferior direito são o mesmo padrão. Ela trata cada posição de pixel como independente, o que está exatamente errado para imagens: transladar um gato em três pixels não deveria forçar a rede a reaprender o conceito.
As duas propriedades que um modelo de imagem precisa são equivariância à translação (a saída se desloca quando a entrada se desloca) e compartilhamento de parâmetros (o mesmo detector de características roda em toda parte). Camadas densas não dão nenhuma das duas. A convolução dá as duas de graça.
A convolução não foi inventada para deep learning. É a mesma operação que alimenta a compressão JPEG, o desfoque gaussiano no Photoshop, a detecção de bordas na visão industrial e todo filtro de áudio já lançado. A razão pela qual as CNNs dominaram o ImageNet de 2012 a 2020 é que a convolução é o prior correto para dados onde valores próximos estão relacionados e o mesmo padrão pode aparecer em qualquer lugar.
O Conceito
Um kernel, deslizando
Uma convolução 2D pega uma pequena matriz de pesos chamada kernel (ou filtro), desliza-a sobre a entrada e, em cada posição, calcula a soma dos produtos elemento a elemento. Essa soma se torna um pixel de saída.
flowchart LR
subgraph IN["Entrada (H x W)"]
direction LR
I1["imagem 5 x 5"]
end
subgraph K["Kernel (3 x 3)"]
K1["pesos<br/>aprendidos"]
end
subgraph OUT["Saída (H-2 x W-2)"]
O1["mapa 3 x 3"]
end
I1 --> |"desliza o kernel<br/>calcula o produto escalar<br/>em cada posição"| O1
K1 --> O1
style IN fill:#dbeafe,stroke:#2563eb
style K fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
Um exemplo concreto 3x3 sobre uma entrada 5x5 (sem padding, stride 1):
Input X (5 x 5): Kernel W (3 x 3):
1 2 0 1 2 1 0 -1
0 1 3 1 0 2 0 -2
2 1 0 2 1 1 0 -1
1 0 2 1 3
2 1 1 0 1
The kernel slides across every valid 3 x 3 window. Output Y is 3 x 3:
Y[0,0] = sum( W * X[0:3, 0:3] )
Y[0,1] = sum( W * X[0:3, 1:4] )
Y[0,2] = sum( W * X[0:3, 2:5] )
Y[1,0] = sum( W * X[1:4, 0:3] )
... and so on
Essa única fórmula — pesos compartilhados, localidade, janela deslizante — é a ideia inteira. Todo o resto é contabilidade.
Fórmula do tamanho de saída
Dado o tamanho espacial de entrada H, o tamanho do kernel K, o padding P e o stride S:
H_out = floor( (H - K + 2P) / S ) + 1
Memorize isto. Você vai calculá-lo dezenas de vezes por arquitetura.
| Cenário | H | K | P | S | H_out |
|---|---|---|---|---|---|
| Conv válida, sem padding | 32 | 3 | 0 | 1 | 30 |
| Conv "same" (preserva tamanho) | 32 | 3 | 1 | 1 | 32 |
| Downsample por 2 | 32 | 3 | 1 | 2 | 16 |
| Pool 2x2 | 32 | 2 | 0 | 2 | 16 |
| Campo receptivo grande | 32 | 7 | 3 | 2 | 16 |
"Padding same" significa escolher P de modo que H_out == H quando S == 1. Para K ímpar, isso é P = (K - 1) / 2. É por isso que os kernels 3x3 dominam: são o menor kernel ímpar que ainda tem um centro.
Padding
Sem padding, toda convolução encolhe o mapa de características. Empilhe 20 delas e sua imagem 224x224 vira 184x184, o que desperdiça computação na borda e complica conexões residuais que precisam de formatos coincidentes.
Zero padding (P = 1) on a 5 x 5 input:
0 0 0 0 0 0 0
0 1 2 0 1 2 0
0 0 1 3 1 0 0
0 2 1 0 2 1 0 Now the kernel can centre on pixel
0 1 0 2 1 3 0 (0, 0) and still have three rows and
0 2 1 1 0 1 0 three columns of values to multiply.
0 0 0 0 0 0 0
Modos que você encontra na prática: zero (o mais comum), reflect (espelha a borda, evita bordas duras em modelos generativos), replicate (copia a borda), circular (envolve ao redor, usado em problemas toroidais).
Stride
Stride é o tamanho do passo do deslize. stride=1 é o padrão. stride=2 reduz pela metade as dimensões espaciais e é a forma clássica de fazer downsample dentro de uma CNN sem uma camada de pooling separada: toda arquitetura moderna (ResNet, ConvNeXt, MobileNet) usa convs com stride no lugar do max-pool em algum ponto.
Stride 1 on a 5 x 5 input, 3 x 3 kernel:
starts: (0,0) (0,1) (0,2) -> output row 0
(1,0) (1,1) (1,2) -> output row 1
(2,0) (2,1) (2,2) -> output row 2
Output: 3 x 3
Stride 2 on the same input:
starts: (0,0) (0,2) -> output row 0
(2,0) (2,2) -> output row 1
Output: 2 x 2
Múltiplos canais de entrada
Imagens reais têm três canais. Uma convolução 3x3 sobre uma entrada RGB é na verdade um volume 3x3x3: uma fatia 3x3 por canal de entrada. Em cada posição espacial, você multiplica e soma através das três fatias e adiciona um bias.
Input: (C_in, H, W) 3 x 5 x 5
Kernel: (C_in, K, K) 3 x 3 x 3 (one kernel)
Output: (1, H', W') 2D map
For a layer that produces C_out output channels, you stack C_out kernels:
Weight: (C_out, C_in, K, K) e.g. 64 x 3 x 3 x 3
Output: (C_out, H', W') 64 x 3 x 3
Parameter count: C_out * C_in * K * K + C_out (the + C_out is biases)
Essa última linha é a que você vai calcular ao planejar um modelo. Uma conv 3x3 de 64 canais sobre uma entrada de 3 canais tem 64 * 3 * 3 * 3 + 64 = 1.792 parâmetros. Barato.
O truque do im2col
Laços aninhados são fáceis de ler mas lentos. As GPUs querem grandes multiplicações de matriz. O truque: achatar cada janela de campo receptivo da entrada em uma coluna de uma grande matriz, achatar o kernel em uma linha, e toda a convolução vira um único matmul.
flowchart LR
X["Entrada<br/>(C_in, H, W)"] --> IM2COL["im2col<br/>(extrai patches)"]
IM2COL --> COLS["Matriz de colunas<br/>(C_in * K * K, H_out * W_out)"]
W["Pesos<br/>(C_out, C_in, K, K)"] --> FLAT["Achatar<br/>(C_out, C_in * K * K)"]
FLAT --> MM["matmul"]
COLS --> MM
MM --> OUT["Saída<br/>(C_out, H_out * W_out)<br/>reshape para (C_out, H_out, W_out)"]
style X fill:#dbeafe,stroke:#2563eb
style W fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
Toda implementação de conv de produção é alguma variante disto mais truques de cache-tiling (conv direta, Winograd, conv via FFT para kernels grandes). Entenda o im2col e você entende o núcleo.
Campo receptivo
Uma única conv 3x3 olha para 9 pixels de entrada. Empilhe duas convs 3x3 e um neurônio na segunda camada olha para 5x5 pixels de entrada. Três convs 3x3 dão 7x7. No geral:
RF after L stacked K x K convs (stride 1) = 1 + L * (K - 1)
With strides: RF grows multiplicatively with stride along each layer.
A razão inteira pela qual "3x3 até o fim" funciona (VGG, ResNet, ConvNeXt) é que duas convs 3x3 enxergam a mesma área de entrada que uma conv 5x5, mas com menos parâmetros e uma não linearidade extra no meio.
Construa
Passo 1: Aplicar padding em um array
Comece com a primitiva mais simples: uma função que aplica padding com zeros ao redor de um array H x W.
import numpy as np
def pad2d(x, p):
if p == 0:
return x
h, w = x.shape[-2:]
out = np.zeros(x.shape[:-2] + (h + 2 * p, w + 2 * p), dtype=x.dtype)
out[..., p:p + h, p:p + w] = x
return out
x = np.arange(9).reshape(3, 3)
print(x)
print()
print(pad2d(x, 1))
O truque dos eixos finais x.shape[:-2] significa que a mesma função funciona em (H, W), (C, H, W) ou (N, C, H, W) sem modificação.
Passo 2: Convolução 2D com laços aninhados
A implementação de referência — lenta, mas inequívoca. Isto é o que torch.nn.functional.conv2d faz em princípio.
def conv2d_naive(x, w, b=None, stride=1, padding=0):
c_in, h, w_in = x.shape
c_out, c_in_w, kh, kw = w.shape
assert c_in == c_in_w
x_pad = pad2d(x, padding)
h_out = (h + 2 * padding - kh) // stride + 1
w_out = (w_in + 2 * padding - kw) // stride + 1
out = np.zeros((c_out, h_out, w_out), dtype=np.float32)
for oc in range(c_out):
for i in range(h_out):
for j in range(w_out):
hs = i * stride
ws = j * stride
patch = x_pad[:, hs:hs + kh, ws:ws + kw]
out[oc, i, j] = np.sum(patch * w[oc])
if b is not None:
out[oc] += b[oc]
return out
Quatro laços aninhados (canal de saída, linha, coluna, mais a soma implícita sobre C_in, kh, kw). Esta é a verdade fundamental contra a qual você vai verificar cada implementação mais rápida.
Passo 3: Verifique com um kernel projetado à mão
Construa um kernel Sobel vertical, aplique-o a uma imagem sintética de degrau e veja a borda vertical se acender.
def synthetic_step_image():
img = np.zeros((1, 16, 16), dtype=np.float32)
img[:, :, 8:] = 1.0
return img
sobel_x = np.array([
[[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]]
], dtype=np.float32)[None]
x = synthetic_step_image()
y = conv2d_naive(x, sobel_x, padding=1)
print(y[0].round(1))
Espere valores positivos grandes na coluna 7 (aumento de brilho da esquerda para a direita) e zeros em todo o resto. Esse único print é sua verificação de sanidade de que a matemática está certa.
Passo 4: im2col
Converta cada janela do tamanho do kernel na entrada em uma coluna de uma matriz. Para C_in=3, K=3, cada coluna tem 27 números.
def im2col(x, kh, kw, stride=1, padding=0):
c_in, h, w = x.shape
x_pad = pad2d(x, padding)
h_out = (h + 2 * padding - kh) // stride + 1
w_out = (w + 2 * padding - kw) // stride + 1
cols = np.zeros((c_in * kh * kw, h_out * w_out), dtype=x.dtype)
col = 0
for i in range(h_out):
for j in range(w_out):
hs = i * stride
ws = j * stride
patch = x_pad[:, hs:hs + kh, ws:ws + kw]
cols[:, col] = patch.reshape(-1)
col += 1
return cols, h_out, w_out
Ainda é um laço em Python, mas agora o trabalho pesado será um único matmul vetorizado.
Passo 5: Conv rápida via im2col + matmul
Substitua o laço quádruplo por uma única multiplicação de matriz.
def conv2d_im2col(x, w, b=None, stride=1, padding=0):
c_out, c_in, kh, kw = w.shape
cols, h_out, w_out = im2col(x, kh, kw, stride, padding)
w_flat = w.reshape(c_out, -1)
out = w_flat @ cols
if b is not None:
out += b[:, None]
return out.reshape(c_out, h_out, w_out)
Verificação de correção: rode ambas as implementações e compare.
rng = np.random.default_rng(0)
x = rng.normal(0, 1, (3, 16, 16)).astype(np.float32)
w = rng.normal(0, 1, (8, 3, 3, 3)).astype(np.float32)
b = rng.normal(0, 1, (8,)).astype(np.float32)
y_naive = conv2d_naive(x, w, b, padding=1)
y_im2col = conv2d_im2col(x, w, b, padding=1)
print(f"max abs diff: {np.max(np.abs(y_naive - y_im2col)):.2e}")
max abs diff deve ficar em torno de 1e-5 — a diferença é a ordem de acumulação de ponto flutuante, não um bug.
Passo 6: Um banco de kernels projetados à mão
Cinco filtros que mostram o que uma única camada conv pode expressar antes de qualquer treinamento.
KERNELS = {
"identity": np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.float32),
"blur_3x3": np.ones((3, 3), dtype=np.float32) / 9.0,
"sharpen": np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32),
"sobel_x": np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32),
"sobel_y": np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32),
}
def apply_kernel(img2d, kernel):
x = img2d[None].astype(np.float32)
w = kernel[None, None]
return conv2d_im2col(x, w, padding=1)[0]
Aplicados a qualquer imagem em tons de cinza, o blur suaviza, o sharpen realça as bordas, o Sobel-x acende as bordas verticais e o Sobel-y acende as bordas horizontais. Esses são exatamente os padrões que a primeira camada conv treinada na AlexNet e na VGG acabou aprendendo, porque um bom modelo de imagem precisa de detectores de borda e de blob não importa qual tarefa venha depois.
Use
O nn.Conv2d do PyTorch envolve a mesma operação com autograd, kernels CUDA e otimização cuDNN. A semântica de formato é idêntica.
import torch
import torch.nn as nn
conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=1, padding=1)
print(conv)
print(f"weight shape: {tuple(conv.weight.shape)} # (C_out, C_in, K, K)")
print(f"bias shape: {tuple(conv.bias.shape)}")
print(f"param count: {sum(p.numel() for p in conv.parameters())}")
x = torch.randn(8, 3, 224, 224)
y = conv(x)
print(f"\ninput shape: {tuple(x.shape)}")
print(f"output shape: {tuple(y.shape)}")
Troque padding=1 por padding=0 e a saída cai para 222x222. Troque stride=1 por stride=2 e ela cai para 112x112. A mesma fórmula que você memorizou acima.
Entregue
Esta lição produz:
outputs/prompt-cnn-architect.md— um prompt que, dados o tamanho de entrada, o orçamento de parâmetros e o campo receptivo alvo, projeta uma pilha de camadasConv2dcom o K/S/P certo em cada passo.outputs/skill-conv-shape-calculator.md— uma skill que percorre uma especificação de rede camada por camada e retorna o formato de saída, o campo receptivo e a contagem de parâmetros para cada bloco.
Exercícios
- (Fácil) Dada uma entrada 128x128 em tons de cinza e uma pilha de
[Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)], calcule o tamanho espacial de saída e o campo receptivo em cada camada à mão. Verifique com umnn.Sequentialdo PyTorch de convs fictícias. - (Médio) Estenda
conv2d_naiveeconv2d_im2colpara aceitar um argumentogroups. Mostre quegroups=C_in=C_outreproduz uma convolução depthwise e que sua contagem de parâmetros éC * K * Kem vez deC * C * K * K. - (Difícil) Implemente o passo backward de
conv2d_im2colà mão: dado o gradiente da saída, calcule o gradiente dexew. Verifique contratorch.autograd.gradnas mesmas entradas e pesos. O truque: o gradiente do im2col é ocol2im, e ele tem que acumular janelas sobrepostas.
Termos-Chave
| Termo | O que as pessoas dizem | O que realmente significa |
|---|---|---|
| Convolução | "Deslizar um filtro" | Um produto escalar aprendível aplicado em cada posição espacial com pesos compartilhados; matematicamente é uma correlação cruzada, mas todo mundo chama de convolução |
| Kernel / filtro | "O detector de características" | Um pequeno tensor de pesos de formato (C_in, K, K) cujo produto escalar com uma janela da entrada produz um pixel de saída |
| Stride | "Quão longe você pula" | O tamanho do passo entre posições consecutivas do kernel; stride 2 reduz pela metade cada dimensão espacial |
| Padding | "Zeros nas bordas" | Valores extras adicionados ao redor da entrada para que o kernel possa se centrar nos pixels de borda; o padding same mantém o tamanho da saída igual ao da entrada |
| Campo receptivo | "Quanto o neurônio enxerga" | O patch da entrada original do qual uma dada ativação de saída depende, crescendo com a profundidade e o stride |
| im2col | "O truque do GEMM" | Reorganizar cada janela receptiva em colunas para que a convolução vire uma única multiplicação de matriz — o núcleo de todo kernel de conv rápido |
| Conv depthwise | "Um kernel por canal" | Uma conv com groups == C_in, computando cada canal de saída apenas a partir do seu canal de entrada correspondente; a espinha dorsal da MobileNet e da ConvNeXt |
| Equivariância à translação | "Desloca na entrada, desloca na saída" | Propriedade de que deslocar a entrada em k pixels desloca a saída em k pixels; vem de graça com os pesos compartilhados |
Leitura Adicional
- A guide to convolution arithmetic for deep learning (Dumoulin & Visin, 2016) — os diagramas definitivos de padding/stride/dilatação que todo curso silenciosamente copia
- CS231n: Convolutional Neural Networks for Visual Recognition — as notas de aula canônicas, incluindo a explicação original do im2col
- The Annotated ConvNet (fast.ai) — um notebook que vai da convolução manual a um classificador de dígitos treinado
- Receptive Field Arithmetic for CNNs (Dang Ha The Hien) — o explicador interativo, de qualidade de artigo, dos cálculos de campo receptivo