Phase 04 - Lesson 03
CNNs — da LeNet à ResNet
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Toda CNN importante dos últimos trinta anos é a mesma receita conv-nao-linearidade-downsample com uma ideia nova acoplada. Aprenda as ideias em ordem.
Tipo: Aprender + Construir Linguagens: Python Pré-requisitos: Fase 3 Lição 11 (PyTorch), Fase 4 Lição 01 (Fundamentos de Imagem), Fase 4 Lição 02 (Convoluções do Zero) Tempo: ~75 minutos
Objetivos de Aprendizagem
- Traçar a linhagem arquitetural LeNet-5 -> AlexNet -> VGG -> Inception -> ResNet e enunciar a única ideia nova que cada família contribuiu
- Implementar a LeNet-5, um bloco no estilo VGG e um BasicBlock de ResNet em PyTorch, cada um com menos de 40 linhas
- Explicar por que as conexões residuais transformam uma rede de 1.000 camadas de intreinável em estado da arte
- Ler um backbone moderno (ResNet-18, ResNet-50) e prever sua forma de saída, campo receptivo e contagem de parâmetros antes de olhar o código-fonte
O Problema
Em 2011, o melhor classificador da ImageNet alcançava cerca de 74% de acurácia top-5. Em 2012 a AlexNet alcançou 85%. Em 2015 a ResNet alcançou 96%. Sem dados novos. Sem nova geração de GPU. Os ganhos vieram de ideias arquiteturais. Um engenheiro de visão atuante precisa saber qual ideia veio de qual artigo, porque todo backbone de produção que você entrega em 2026 é uma recombinação dessas mesmas peças — e porque as ideias continuam transferindo: convoluções agrupadas foram das CNNs para os transformers, as conexões residuais foram da ResNet para todo LLM em existência, a normalização em lote vive nos modelos de difusão.
Estudar essas redes em ordem também imuniza você contra um erro comum: pegar o maior modelo disponível quando uma rede do tamanho de uma LeNet resolveria o problema. O MNIST não precisa de uma ResNet. Conhecer a curva de escala de cada família diz onde se posicionar nela.
O Conceito
As quatro ideias que mudaram a visão computacional
timeline
title Quatro ideias, quatro famílias
1998 : LeNet-5 : Conv + pool + FC para dígitos, treinada em CPU, 60k params
2012 : AlexNet : Mais profunda + ReLU + dropout + duas GPUs, venceu a ImageNet por 10 pontos
2014 : VGG / Inception : pilhas 3x3 (VGG), tamanhos de filtro paralelos (Inception)
2015 : ResNet : Conexões de salto por identidade liberam o treino de 100+ camadas
Nada mais na visão clássica importou tanto quanto esses quatro saltos.
LeNet-5 (1998)
O reconhecedor de dígitos de Yann LeCun. 60.000 parâmetros. Dois blocos conv-pool, duas camadas totalmente conectadas, ativações tanh. Ela definiu o template que toda CNN herda:
input (1, 32, 32)
conv 5x5 -> (6, 28, 28)
avg pool 2x2 -> (6, 14, 14)
conv 5x5 -> (16, 10, 10)
avg pool 2x2 -> (16, 5, 5)
flatten -> 400
dense -> 120
dense -> 84
dense -> 10
Tudo o que o mundo moderno chama de CNN — convoluções e downsampling alternados alimentando uma pequena cabeça classificadora — é a LeNet com mais camadas, canais maiores e ativações melhores.
AlexNet (2012)
Três mudanças que juntas quebraram a ImageNet:
- ReLU no lugar de tanh. Os gradientes param de desaparecer. O treino acelera por um fator de seis.
- Dropout na cabeça totalmente conectada. A regularização vira uma camada, não um truque.
- Profundidade e largura. Cinco camadas conv, três camadas densas, 60M parâmetros, treinada em duas GPUs com o modelo dividido entre elas.
A Figura 2 do artigo ainda mostra a divisão entre GPUs como dois fluxos paralelos. Esse paralelismo foi uma gambiarra de hardware, não um insight arquitetural — mas as três ideias acima continuam em todo modelo que você usa.
VGG (2014)
A VGG perguntou: o que acontece se você usar apenas convoluções 3x3 e for fundo?
stack: conv 3x3 -> conv 3x3 -> pool 2x2
repeat: 16 or 19 conv layers
Duas convoluções 3x3 enxergam a mesma área de entrada 5x5 que uma única conv 5x5, mas com menos parâmetros (29C^2 = 18C^2 vs 25*C^2) e uma ReLU extra no meio. A VGG transformou essa observação em uma arquitetura inteira. A simplicidade — um único tipo de bloco, repetido — fez dela o ponto de referência para tudo o que veio depois.
Custo: 138M parâmetros, lenta para treinar, cara na inferência.
Inception (2014, mesmo ano)
A resposta do Google para "qual tamanho de kernel devo usar?" foi: todos eles, em paralelo.
flowchart LR
IN["Mapa de características de entrada"] --> A["conv 1x1"]
IN --> B["conv 3x3"]
IN --> C["conv 5x5"]
IN --> D["max pool 3x3"]
A --> CAT["Concatenar<br/>ao longo do eixo de canais"]
B --> CAT
C --> CAT
D --> CAT
CAT --> OUT["Próximo bloco"]
style IN fill:#dbeafe,stroke:#2563eb
style CAT fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
Cada ramo se especializa — 1x1 para mistura de canais, 3x3 para textura local, 5x5 para padrões maiores, pooling para características invariantes a deslocamento — e a concatenação deixa a camada seguinte escolher qualquer ramo que seja útil. A Inception v1 usou convoluções 1x1 dentro de cada ramo como gargalo para manter a contagem de parâmetros sob controle.
O problema da degradação
Em 2015, a VGG-19 funcionava e a VGG-32 não. A profundidade deveria ajudar, mas além de ~20 camadas tanto a perda de treino quanto a de teste pioravam. Isso não é overfitting. É o otimizador falhando em encontrar pesos úteis porque os gradientes encolhem multiplicativamente ao longo de cada camada.
Plain deep network:
y = f_L( f_{L-1}( ... f_1(x) ... ) )
Gradient wrt early layer:
dL/dW_1 = dL/dy * df_L/df_{L-1} * ... * df_2/df_1 * df_1/dW_1
Each multiplicative term has magnitude roughly (weight magnitude) * (activation gain).
Stack 100 of them with gains < 1 and the gradient is effectively zero.
A VGG funcionou com 19 camadas porque a normalização em lote (publicada simultaneamente) mantinha as ativações bem escaladas. Mas nem mesmo a normalização em lote conseguia resgatar profundidades além de umas 30 camadas.
ResNet (2015)
He, Zhang, Ren e Sun propuseram uma única mudança que consertou tudo:
standard block: y = F(x)
residual block: y = F(x) + x
O + x significa que a camada pode sempre escolher não fazer nada, levando F(x) a zero. Uma ResNet de 1.000 camadas agora é, no pior caso, tão ruim quanto uma rede de 1 camada, porque cada bloco extra tem uma saída de emergência trivial. Com essa garantia, o otimizador se dispõe a tornar cada bloco ligeiramente útil — e ligeiramente útil, empilhado 100 vezes, é estado da arte.
flowchart LR
X["Entrada x"] --> F["F(x)<br/>conv + BN + ReLU<br/>conv + BN"]
X -.->|salto por identidade| PLUS(["+"])
F --> PLUS
PLUS --> RELU["ReLU"]
RELU --> OUT["y"]
style X fill:#dbeafe,stroke:#2563eb
style PLUS fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
Duas variantes do bloco aparecem em todo lugar:
- BasicBlock (ResNet-18, ResNet-34): duas convoluções 3x3, salto em torno de ambas.
- Bottleneck (ResNet-50, -101, -152): 1x1 para baixo, 3x3 no meio, 1x1 para cima, salto em torno do trio. Mais barato quando as contagens de canais são altas.
Quando o salto precisa atravessar um downsample (stride=2), o caminho de identidade é substituído por uma conv 1x1 stride=2 para casar as formas.
Por que os residuais importam além da visão
A ideia não era realmente sobre classificação de imagens. Era sobre transformar redes profundas de "cruze os dedos e torça para que os gradientes sobrevivam" em uma ferramenta de engenharia confiável e escalável. Todo transformer sobre o qual você vai ler na próxima fase tem exatamente a mesma conexão de salto em cada bloco. Sem a ResNet, não há GPT.
Construa
Passo 1: LeNet-5
Uma LeNet mínima e fiel. Ativações tanh, average pooling. A única concessão à modernidade é que usamos nn.CrossEntropyLoss mais adiante em vez das conexões gaussianas originais.
import torch
import torch.nn as nn
import torch.nn.functional as F
class LeNet5(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(1, 6, kernel_size=5)
self.conv2 = nn.Conv2d(6, 16, kernel_size=5)
self.pool = nn.AvgPool2d(2)
self.fc1 = nn.Linear(16 * 5 * 5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, num_classes)
def forward(self, x):
x = self.pool(torch.tanh(self.conv1(x)))
x = self.pool(torch.tanh(self.conv2(x)))
x = torch.flatten(x, 1)
x = torch.tanh(self.fc1(x))
x = torch.tanh(self.fc2(x))
return self.fc3(x)
net = LeNet5()
x = torch.randn(1, 1, 32, 32)
print(f"output: {net(x).shape}")
print(f"params: {sum(p.numel() for p in net.parameters()):,}")
Saída esperada: output: torch.Size([1, 10]), params: 61,706. Esse é o classificador de dígitos inteiro que iniciou a visão computacional moderna.
Passo 2: Um bloco VGG
Um bloco reutilizável: duas convoluções 3x3, ReLU, normalização em lote, max pool.
class VGGBlock(nn.Module):
def __init__(self, in_c, out_c):
super().__init__()
self.conv1 = nn.Conv2d(in_c, out_c, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(out_c)
self.conv2 = nn.Conv2d(out_c, out_c, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(out_c)
self.pool = nn.MaxPool2d(2)
def forward(self, x):
x = F.relu(self.bn1(self.conv1(x)))
x = F.relu(self.bn2(self.conv2(x)))
return self.pool(x)
class MiniVGG(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.stack = nn.Sequential(
VGGBlock(3, 32),
VGGBlock(32, 64),
VGGBlock(64, 128),
)
self.head = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(128, num_classes),
)
def forward(self, x):
return self.head(self.stack(x))
net = MiniVGG()
x = torch.randn(1, 3, 32, 32)
print(f"output: {net(x).shape}")
print(f"params: {sum(p.numel() for p in net.parameters()):,}")
Três blocos VGG sobre entrada do tamanho do CIFAR, um pool adaptativo, uma camada linear. ~290k parâmetros. Mais que suficiente para o CIFAR-10.
Passo 3: Um BasicBlock de ResNet
O bloco de construção central da ResNet-18 e da ResNet-34.
class BasicBlock(nn.Module):
def __init__(self, in_c, out_c, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_c, out_c, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_c)
self.conv2 = nn.Conv2d(out_c, out_c, kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_c)
if stride != 1 or in_c != out_c:
self.shortcut = nn.Sequential(
nn.Conv2d(in_c, out_c, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_c),
)
else:
self.shortcut = nn.Identity()
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out = out + self.shortcut(x)
return F.relu(out)
bias=False nas camadas conv é uma convenção da normalização em lote — o parâmetro beta da BN já lida com o viés, então carregar também o viés da conv é desperdício. O shortcut só precisa de uma conv de verdade quando o stride ou a contagem de canais muda; caso contrário, é uma identidade sem operação.
Passo 4: Uma ResNet minúscula
Empilhe quatro grupos de BasicBlocks para obter uma ResNet funcional para entradas do tamanho do CIFAR.
class TinyResNet(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.stem = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
)
self.layer1 = self._make_group(32, 32, num_blocks=2, stride=1)
self.layer2 = self._make_group(32, 64, num_blocks=2, stride=2)
self.layer3 = self._make_group(64, 128, num_blocks=2, stride=2)
self.layer4 = self._make_group(128, 256, num_blocks=2, stride=2)
self.head = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(256, num_classes),
)
def _make_group(self, in_c, out_c, num_blocks, stride):
blocks = [BasicBlock(in_c, out_c, stride=stride)]
for _ in range(num_blocks - 1):
blocks.append(BasicBlock(out_c, out_c, stride=1))
return nn.Sequential(*blocks)
def forward(self, x):
x = self.stem(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
return self.head(x)
net = TinyResNet()
x = torch.randn(1, 3, 32, 32)
print(f"output: {net(x).shape}")
print(f"params: {sum(p.numel() for p in net.parameters()):,}")
Quatro grupos de dois blocos cada. Stride 2 no início dos grupos 2, 3 e 4. A contagem de canais dobra a cada downsample. Cerca de 2,8M parâmetros. Essa é a receita padrão que escala de forma limpa até a ResNet-152.
Passo 5: Compare a eficiência parâmetro-por-característica
Passe a mesma entrada pelas três redes e compare as contagens de parâmetros.
def summary(name, net, x):
y = net(x)
params = sum(p.numel() for p in net.parameters())
print(f"{name:12s} input {tuple(x.shape)} -> output {tuple(y.shape)} params {params:>10,}")
x = torch.randn(1, 3, 32, 32)
summary("LeNet5", LeNet5(), torch.randn(1, 1, 32, 32))
summary("MiniVGG", MiniVGG(), x)
summary("TinyResNet", TinyResNet(), x)
Três modelos, três eras, três ordens de magnitude na contagem de parâmetros. Para acurácia no CIFAR-10, você precisa aproximadamente de: LeNet 60%, MiniVGG 89%, TinyResNet 93% após algumas épocas de treino.
Use
torchvision.models te dá versões pré-treinadas de tudo acima. A assinatura de chamada é idêntica entre as famílias, que é exatamente o ponto da abstração de backbone.
from torchvision.models import resnet18, ResNet18_Weights, vgg16, VGG16_Weights
r18 = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
r18.eval()
print(f"ResNet-18 params: {sum(p.numel() for p in r18.parameters()):,}")
print(r18.layer1[0])
print()
v16 = vgg16(weights=VGG16_Weights.IMAGENET1K_V1)
v16.eval()
print(f"VGG-16 params: {sum(p.numel() for p in v16.parameters()):,}")
A ResNet-18 tem 11,7M parâmetros. A VGG-16 tem 138M. Acurácia top-1 na ImageNet semelhante (69,8% vs 71,6%). As conexões residuais te dão um ganho de eficiência de parâmetros de 12x. É por isso que as variantes da ResNet dominaram de 2016 até a chegada do ViT em 2021 — e ainda dominam as implantações do mundo real onde a computação é a restrição.
Para aprendizado por transferência, a receita é sempre a mesma: carregue o pré-treinado, congele o backbone, substitua a cabeça classificadora.
for p in r18.parameters():
p.requires_grad = False
r18.fc = nn.Linear(r18.fc.in_features, 10)
Três linhas. Você agora tem um classificador CIFAR de 10 classes que herda as representações que a ImageNet pagou para obter.
Entregue
Esta lição produz:
outputs/prompt-backbone-selector.md— um prompt que escolhe a família de CNN certa (LeNet/VGG/ResNet/MobileNet/ConvNeXt) dado a tarefa, o tamanho do dataset e o orçamento de computação.outputs/skill-residual-block-reviewer.md— uma skill que lê um módulo PyTorch e sinaliza erros de conexão de salto (shortcut ausente em mudança de stride, ordem de ativação do shortcut, posicionamento da BN em relação à soma).
Exercícios
- (Fácil) Conte os parâmetros à mão para a
TinyResNetcamada por camada. Compare comsum(p.numel() for p in net.parameters()). Onde fica a maioria do orçamento de parâmetros — nas convoluções, na BN ou na cabeça classificadora? - (Médio) Implemente o bloco Bottleneck (1x1 -> 3x3 -> 1x1 com salto) e use-o para construir uma rede no estilo ResNet-50 para CIFAR. Compare os parâmetros com a
TinyResNet. - (Difícil) Remova a conexão de salto do
BasicBlock, treine uma rede "plana" de 34 blocos e uma ResNet de 34 blocos no CIFAR-10 por 10 épocas cada. Plote a perda de treino vs época para ambas. Reproduza o resultado da Figura 1 de He et al., em que a rede profunda plana converge para uma perda mais alta do que sua gêmea mais rasa.
Termos-Chave
| Termo | O que as pessoas dizem | O que realmente significa |
|---|---|---|
| Backbone | "O modelo" | A pilha de blocos convolucionais que produz o mapa de características alimentado à cabeça da tarefa |
| Conexão residual | "Conexão de salto" | y = F(x) + x; permite que o otimizador aprenda a identidade ajustando F para zero, o que torna treinável qualquer profundidade |
| BasicBlock | "Duas convoluções 3x3 com um salto" | O bloco de construção da ResNet-18/34: conv-BN-ReLU-conv-BN-soma-ReLU |
| Bottleneck | "1x1 para baixo, 3x3, 1x1 para cima" | O bloco da ResNet-50/101/152; barato em altas contagens de canais porque a 3x3 roda sobre uma largura reduzida |
| Problema da degradação | "Mais profundo é pior" | Além de ~20 camadas conv planas, tanto o erro de treino quanto o de teste aumentam; resolvido por conexões residuais, não por mais dados |
| Stem | "A primeira camada" | A conv inicial que converte a entrada de 3 canais na largura base de características; geralmente 7x7 stride 2 para ImageNet, 3x3 stride 1 para CIFAR |
| Head | "O classificador" | As camadas após o bloco final do backbone: pool adaptativo, flatten, linear(es) |
| Aprendizado por transferência | "Pesos pré-treinados" | Carregar um backbone treinado na ImageNet e ajustar apenas a cabeça na sua tarefa |
Leitura Complementar
- Deep Residual Learning for Image Recognition (He et al., 2015) — o artigo da ResNet; cada figura vale o estudo
- Very Deep Convolutional Networks (Simonyan & Zisserman, 2014) — o artigo da VGG; ainda a melhor referência para "por que 3x3"
- ImageNet Classification with Deep CNNs (Krizhevsky et al., 2012) — AlexNet; o artigo que encerrou a era das características feitas à mão
- Going Deeper with Convolutions (Szegedy et al., 2014) — Inception v1; a ideia de filtros paralelos que ainda aparece nos vision transformers