Phase 04 - Lesson 03
CNN — de LeNet a ResNet
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Toda CNN importante de los últimos treinta años es la misma receta conv-no linealidad-downsample con una idea nueva acoplada. Aprende las ideas en orden.
Tipo: Aprender + Construir Lenguajes: Python Prerrequisitos: Fase 3 Lección 11 (PyTorch), Fase 4 Lección 01 (Fundamentos de Imagen), Fase 4 Lección 02 (Convoluciones desde Cero) Tiempo: ~75 minutos
Objetivos de Aprendizaje
- Trazar el linaje arquitectónico LeNet-5 -> AlexNet -> VGG -> Inception -> ResNet y enunciar la única idea nueva que aportó cada familia
- Implementar LeNet-5, un bloque al estilo VGG y un BasicBlock de ResNet en PyTorch, cada uno en menos de 40 líneas
- Explicar por qué las conexiones residuales convierten una red de 1.000 capas de inentrenable a estado del arte
- Leer un backbone moderno (ResNet-18, ResNet-50) y predecir su forma de salida, campo receptivo y conteo de parámetros antes de mirar el código fuente
El Problema
En 2011, el mejor clasificador de ImageNet alcanzaba alrededor del 74% de precisión top-5. En 2012 AlexNet alcanzó el 85%. En 2015 ResNet alcanzó el 96%. Sin datos nuevos. Sin nueva generación de GPU. Las mejoras vinieron de ideas arquitectónicas. Un ingeniero de visión en activo debe saber qué idea vino de qué artículo, porque todo backbone de producción que despliegues en 2026 es una recombinación de esas mismas piezas — y porque las ideas siguen transfiriéndose: las convoluciones agrupadas pasaron de las CNN a los transformers, las conexiones residuales pasaron de ResNet a todo LLM en existencia, la normalización por lotes vive en los modelos de difusión.
Estudiar estas redes en orden también te inmuniza contra un error común: recurrir al modelo más grande disponible cuando una red del tamaño de LeNet resolvería el problema. MNIST no necesita una ResNet. Conocer la curva de escalado de cada familia te dice dónde ubicarte en ella.
El Concepto
Las cuatro ideas que cambiaron la visión por computadora
timeline
title Cuatro ideas, cuatro familias
1998 : LeNet-5 : Conv + pool + FC para dígitos, entrenada en CPU, 60k params
2012 : AlexNet : Más profunda + ReLU + dropout + dos GPU, ganó ImageNet por 10 puntos
2014 : VGG / Inception : pilas 3x3 (VGG), tamaños de filtro paralelos (Inception)
2015 : ResNet : Conexiones de salto por identidad habilitan el entrenamiento de más de 100 capas
Nada más en la visión clásica importó tanto como estos cuatro saltos.
LeNet-5 (1998)
El reconocedor de dígitos de Yann LeCun. 60.000 parámetros. Dos bloques conv-pool, dos capas totalmente conectadas, activaciones tanh. Definió la plantilla que toda CNN hereda:
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
Todo lo que el mundo moderno llama CNN — convoluciones y downsampling alternados que alimentan una pequeña cabeza clasificadora — es LeNet con más capas, canales más grandes y mejores activaciones.
AlexNet (2012)
Tres cambios que juntos rompieron ImageNet:
- ReLU en lugar de tanh. Los gradientes dejan de desaparecer. El entrenamiento se acelera por un factor de seis.
- Dropout en la cabeza totalmente conectada. La regularización se convierte en una capa, no en un truco.
- Profundidad y ancho. Cinco capas conv, tres capas densas, 60M de parámetros, entrenada en dos GPU con el modelo dividido entre ellas.
La Figura 2 del artículo todavía muestra la división de GPU como dos flujos paralelos. Ese paralelismo fue un parche de hardware, no una idea arquitectónica — pero las tres ideas anteriores siguen en todo modelo que usas.
VGG (2014)
VGG se preguntó: ¿qué pasa si solo usas convoluciones 3x3 y te vas en profundidad?
stack: conv 3x3 -> conv 3x3 -> pool 2x2
repeat: 16 or 19 conv layers
Dos convoluciones 3x3 ven la misma área de entrada 5x5 que una sola conv 5x5, pero con menos parámetros (29C^2 = 18C^2 vs 25*C^2) y una ReLU adicional en el medio. VGG convirtió esta observación en una arquitectura entera. La simplicidad — un solo tipo de bloque, repetido — la convirtió en el punto de referencia para todo lo que vino después.
Costo: 138M de parámetros, lenta de entrenar, cara en inferencia.
Inception (2014, mismo año)
La respuesta de Google a "¿qué tamaño de kernel debería usar?" fue: todos, en 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/>a lo largo del eje de canales"]
B --> CAT
C --> CAT
D --> CAT
CAT --> OUT["Siguiente bloque"]
style IN fill:#dbeafe,stroke:#2563eb
style CAT fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
Cada rama se especializa — 1x1 para mezcla de canales, 3x3 para textura local, 5x5 para patrones más grandes, pooling para características invariantes al desplazamiento — y la concatenación permite que la capa siguiente elija la rama que sea útil. Inception v1 usó convoluciones 1x1 dentro de cada rama como cuello de botella para mantener el conteo de parámetros bajo control.
El problema de la degradación
Para 2015, VGG-19 funcionaba y VGG-32 no. Se suponía que la profundidad ayudaría, pero más allá de ~20 capas tanto la pérdida de entrenamiento como la de prueba empeoraban. Eso no es sobreajuste. Es el optimizador fallando en encontrar pesos útiles porque los gradientes se encogen multiplicativamente a través de cada capa.
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.
VGG funcionó con 19 capas porque la normalización por lotes (publicada simultáneamente) mantenía las activaciones bien escaladas. Pero ni siquiera la normalización por lotes podía rescatar la profundidad más allá de unas 30 capas.
ResNet (2015)
He, Zhang, Ren y Sun propusieron un único cambio que arregló todo:
standard block: y = F(x)
residual block: y = F(x) + x
El + x significa que la capa siempre puede elegir no hacer nada, llevando F(x) a cero. Una ResNet de 1.000 capas ahora es, en el peor de los casos, tan mala como una red de 1 capa, porque cada bloque adicional tiene una salida de emergencia trivial. Con esa garantía, el optimizador está dispuesto a hacer cada bloque ligeramente útil — y ligeramente útil, apilado 100 veces, es estado del arte.
flowchart LR
X["Entrada x"] --> F["F(x)<br/>conv + BN + ReLU<br/>conv + BN"]
X -.->|salto por identidad| 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
Dos variantes del bloque aparecen en todas partes:
- BasicBlock (ResNet-18, ResNet-34): dos convoluciones 3x3, salto alrededor de ambas.
- Bottleneck (ResNet-50, -101, -152): 1x1 hacia abajo, 3x3 en el medio, 1x1 hacia arriba, salto alrededor del trío. Más barato cuando los conteos de canales son altos.
Cuando el salto debe atravesar un downsample (stride=2), el camino de identidad se reemplaza por una conv 1x1 stride=2 para hacer coincidir las formas.
Por qué los residuales importan más allá de la visión
La idea en realidad no era sobre clasificación de imágenes. Era sobre convertir las redes profundas de "cruza los dedos y espera que los gradientes sobrevivan" en una herramienta de ingeniería confiable y escalable. Todo transformer sobre el que leerás en la próxima fase tiene exactamente la misma conexión de salto en cada bloque. Sin ResNet, no hay GPT.
Constrúyelo
Paso 1: LeNet-5
Una LeNet mínima y fiel. Activaciones tanh, average pooling. La única concesión a la modernidad es que usamos nn.CrossEntropyLoss más adelante en lugar de las conexiones gaussianas originales.
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()):,}")
Salida esperada: output: torch.Size([1, 10]), params: 61,706. Ese es el clasificador de dígitos completo que inició la visión por computadora moderna.
Paso 2: Un bloque VGG
Un bloque reutilizable: dos convoluciones 3x3, ReLU, normalización por lotes, 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()):,}")
Tres bloques VGG sobre una entrada del tamaño de CIFAR, un pool adaptativo, una capa lineal. ~290k parámetros. Más que suficiente para CIFAR-10.
Paso 3: Un BasicBlock de ResNet
El bloque de construcción central de ResNet-18 y 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 en las capas conv es una convención de la normalización por lotes — el parámetro beta de la BN ya maneja el sesgo, así que llevar también el sesgo de la conv es un desperdicio. El shortcut solo necesita una conv real cuando cambia el stride o el conteo de canales; de lo contrario, es una identidad sin operación.
Paso 4: Una ResNet diminuta
Apila cuatro grupos de BasicBlocks para obtener una ResNet funcional para entradas del tamaño de 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()):,}")
Cuatro grupos de dos bloques cada uno. Stride 2 al inicio de los grupos 2, 3 y 4. El conteo de canales se duplica en cada downsample. Aproximadamente 2,8M de parámetros. Esa es la receta estándar que escala limpiamente hasta ResNet-152.
Paso 5: Compara la eficiencia parámetro por característica
Pasa la misma entrada por las tres redes y compara los conteos 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)
Tres modelos, tres eras, tres órdenes de magnitud en el conteo de parámetros. Para la precisión en CIFAR-10, necesitas aproximadamente: LeNet 60%, MiniVGG 89%, TinyResNet 93% tras algunas épocas de entrenamiento.
Úsalo
torchvision.models te da versiones preentrenadas de todo lo anterior. La firma de llamada es idéntica entre familias, que es exactamente el punto de la abstracción 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()):,}")
ResNet-18 tiene 11,7M de parámetros. VGG-16 tiene 138M. Precisión top-1 similar en ImageNet (69,8% vs 71,6%). Las conexiones residuales te dan una ganancia de eficiencia de parámetros de 12x. Por eso las variantes de ResNet dominaron desde 2016 hasta la llegada del ViT en 2021 — y siguen dominando los despliegues del mundo real donde el cómputo es la restricción.
Para el aprendizaje por transferencia, la receta es siempre la misma: carga el preentrenado, congela el backbone, reemplaza la cabeza clasificadora.
for p in r18.parameters():
p.requires_grad = False
r18.fc = nn.Linear(r18.fc.in_features, 10)
Tres líneas. Ahora tienes un clasificador CIFAR de 10 clases que hereda las representaciones que ImageNet pagó por obtener.
Despliégalo
Esta lección produce:
outputs/prompt-backbone-selector.md— un prompt que elige la familia de CNN correcta (LeNet/VGG/ResNet/MobileNet/ConvNeXt) dada la tarea, el tamaño del dataset y el presupuesto de cómputo.outputs/skill-residual-block-reviewer.md— una skill que lee un módulo de PyTorch y señala errores de conexión de salto (shortcut ausente ante cambio de stride, orden de activación del shortcut, ubicación de la BN respecto a la suma).
Ejercicios
- (Fácil) Cuenta los parámetros a mano para
TinyResNetcapa por capa. Compara contrasum(p.numel() for p in net.parameters()). ¿Dónde queda la mayoría del presupuesto de parámetros — en las convoluciones, la BN o la cabeza clasificadora? - (Medio) Implementa el bloque Bottleneck (1x1 -> 3x3 -> 1x1 con salto) y úsalo para construir una red al estilo ResNet-50 para CIFAR. Compara los parámetros contra
TinyResNet. - (Difícil) Elimina la conexión de salto de
BasicBlock, entrena una red "plana" de 34 bloques y una ResNet de 34 bloques en CIFAR-10 durante 10 épocas cada una. Grafica la pérdida de entrenamiento vs época para ambas. Reproduce el resultado de la Figura 1 de He et al., donde la red profunda plana converge a una pérdida más alta que su gemela más superficial.
Términos Clave
| Término | Lo que dice la gente | Lo que realmente significa |
|---|---|---|
| Backbone | "El modelo" | La pila de bloques convolucionales que produce el mapa de características que alimenta la cabeza de la tarea |
| Conexión residual | "Conexión de salto" | y = F(x) + x; permite que el optimizador aprenda la identidad ajustando F a cero, lo que hace entrenable cualquier profundidad |
| BasicBlock | "Dos convoluciones 3x3 con un salto" | El bloque de construcción de ResNet-18/34: conv-BN-ReLU-conv-BN-suma-ReLU |
| Bottleneck | "1x1 hacia abajo, 3x3, 1x1 hacia arriba" | El bloque de ResNet-50/101/152; barato en conteos de canales altos porque la 3x3 corre sobre un ancho reducido |
| Problema de degradación | "Más profundo es peor" | Más allá de ~20 capas conv planas, tanto el error de entrenamiento como el de prueba aumentan; se resuelve con conexiones residuales, no con más datos |
| Stem | "La primera capa" | La conv inicial que convierte la entrada de 3 canales en el ancho base de características; usualmente 7x7 stride 2 para ImageNet, 3x3 stride 1 para CIFAR |
| Head | "El clasificador" | Las capas posteriores al bloque final del backbone: pool adaptativo, flatten, lineal(es) |
| Aprendizaje por transferencia | "Pesos preentrenados" | Cargar un backbone entrenado en ImageNet y afinar solo la cabeza en tu tarea |
Lecturas Complementarias
- Deep Residual Learning for Image Recognition (He et al., 2015) — el artículo de ResNet; cada figura vale la pena estudiarla
- Very Deep Convolutional Networks (Simonyan & Zisserman, 2014) — el artículo de VGG; sigue siendo la mejor referencia para "por qué 3x3"
- ImageNet Classification with Deep CNNs (Krizhevsky et al., 2012) — AlexNet; el artículo que terminó la era de las características hechas a mano
- Going Deeper with Convolutions (Szegedy et al., 2014) — Inception v1; la idea de filtros paralelos que aún aparece en los vision transformers