Módulo 7 · Visión por computadora

Lección 1 de 5

Convoluciones y clasificación

En esta páginaEl problema

El problema

En Misión de entrenamiento, un MLP 64 → 64 → 10 con dropout 0.2 clasificó los dígitos 8 × 8 de load_digits. Con las semillas 0, 1 y 2 falló 9, 7 y 9 de las 360 imágenes de validación.

Ahora cada imagen de validación se desplaza una columna a la derecha. La columna izquierda se rellena con ceros. Este es el 0 de la segunda imagen de validación, antes y después del desplazamiento:

original                     desplazada
 0  0 10 15  6  0  0  0       0  0  0 10 15  6  0  0
 0  2 16 14 16  7  0  0       0  0  2 16 14 16  7  0
 0  4 16  8  5 16  1  0       0  0  4 16  8  5 16  1
 0  5 12  0  0 12  8  0       0  0  5 12  0  0 12  8
 0  8  9  0  0 12  8  0       0  0  8  9  0  0 12  8
 0  5 12  0  1 15  3  0       0  0  5 12  0  1 15  3
 0  4 13  4 12 13  0  0       0  0  4 13  4 12 13  0
 0  0  9 16 13  4  0  0       0  0  0  9 16 13  4  0

Una persona reconoce el mismo 0. Los mismos tres MLP, entrenados solo con imágenes originales, fallan 183, 194 y 200 de las 360 imágenes desplazadas. Aplanada, la imagen es una lista de 64 características, y el píxel (3, 2) ocupa la posición 26. Después del desplazamiento, ese valor pasa a la posición 27, que el MLP multiplica por otros pesos. Nada en el MLP indica que las posiciones 26 y 27 son vecinas en la imagen.

Imagen como tensor

PyTorch guarda una imagen como un tensor (C, H, W): canales, alto y ancho. Una imagen en escala de grises tiene un canal. Una imagen en color RGB tiene tres: rojo, verde y azul. Un batch añade un eje al inicio: (N, C, H, W).

load_digits ofrece los mismos datos en dos formas. images conserva cada imagen como una matriz 8 × 8. data guarda cada imagen aplanada fila por fila. unsqueeze(i) añade un eje de tamaño 1 en la posición i. np.ravel_multi_index convierte un índice (fila, columna) de una matriz en su posición dentro del vector aplanado.

Pregunta. Escribe la forma de digitos.images, la de digitos.data y la de imagenes después de unsqueeze(1). El píxel de la fila 2 y la columna 5 de la primera imagen, ¿en qué posición de digitos.data[0] aparece?

Python 3 · ejemplo reproducible
import numpy as np
import torch
from torch import nn
from sklearn.datasets import load_digits

digitos = load_digits()
print(digitos.images.shape)
print(digitos.data.shape)

# Los píxeles van de 0 a 16; se escalan a [0, 1].
imagenes = torch.tensor(digitos.images / 16.0, dtype=torch.float32).unsqueeze(1)
print(imagenes.shape)

posicion = np.ravel_multi_index((2, 5), (8, 8))
print(posicion, digitos.images[0][2, 5], digitos.data[0][posicion])
Salida esperada

La salida esperada está oculta.

NumPy y Matplotlib suelen guardar imágenes en color como (H, W, C). Para pasar a (C, H, W) hay que reordenar ejes, no solo cambiar la forma. La imagen siguiente mide 2 × 3 píxeles. La fila superior es rojo, rojo y azul. La inferior es rojo, verde y azul.

Pregunta. permute(2, 0, 1) y reshape(3, 2, 3) producen la misma forma. ¿Cuál de los dos deja en el canal 0 los valores del rojo de cada píxel?

Python 3 · ejemplo reproducible
rgb_hwc = torch.tensor(
    [
        [[255, 0, 0], [255, 0, 0], [0, 0, 255]],
        [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
    ]
)
chw = rgb_hwc.permute(2, 0, 1)
mezclado = rgb_hwc.reshape(3, 2, 3)

print(rgb_hwc.shape, chw.shape, mezclado.shape)
print(chw[0])
print(mezclado[0])
Salida esperada

La salida esperada está oculta.

Convolución a mano

Una convolución desliza un filtro pequeño sobre la imagen. En cada posición multiplica el filtro por la ventana de píxeles que cubre y suma los productos. El resultado es un mapa de activación: un número por posición.

El filtro de abajo resta la columna izquierda de la ventana y suma la columna derecha. La imagen de 5 × 5 tiene un trazo vertical en la columna 2, de la fila 1 a la 3.

Python 3 · ejemplo reproducible
imagen = torch.zeros(5, 5)
imagen[1:4, 2] = 1.0  # filas 1 a 3 de la columna 2
filtro = torch.tensor([[-1.0, 0.0, 1.0]] * 3)  # tres filas iguales
print(imagen)
Salida esperada

La salida esperada está oculta.

convolucion_a_mano recorre con dos bucles todas las posiciones donde cabe la ventana completa. En cada una, ventana * filtro multiplica elemento a elemento y .sum() suma los nueve productos.

Pregunta. Sin un borde que rellene la imagen, ¿cuántas posiciones caben para una ventana 3 × 3 en cada eje? Calcula a mano la salida en (1, 0), (1, 1) y (1, 2).

Python 3 · ejemplo reproducible
def convolucion_a_mano(imagen, filtro):
    k = filtro.shape[0]
    alto = imagen.shape[0] - k + 1
    ancho = imagen.shape[1] - k + 1
    salida = torch.zeros(alto, ancho)
    for i in range(alto):
        for j in range(ancho):
            ventana = imagen[i : i + k, j : j + k]
            salida[i, j] = (ventana * filtro).sum()
    return salida


print(convolucion_a_mano(imagen, filtro))
Salida esperada

La salida esperada está oculta.

nn.Conv2d es la capa de PyTorch para esta operación. Sus pesos tienen forma (canales de salida, canales de entrada, alto, ancho) y su entrada es un batch (N, C, H, W) o una sola imagen (C, H, W). PyTorch no invierte el filtro antes de deslizarlo. En matemáticas, esa operación se llama correlación cruzada, pero en redes neuronales se le llama convolución.

La función auxiliar desplazar mueve el contenido una columna a la derecha y rellena con ceros, como en el problema.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
def desplazar(x):
    salida = torch.zeros_like(x)
    salida[..., 1:] = x[..., :-1]
    return salida

bias=False quita el sesgo, así que la capa solo suma productos. weight.copy_ sobrescribe los pesos aleatorios con el filtro. Va dentro de torch.no_grad() porque los pesos son parámetros que registran gradientes. .detach() separa la salida del grafo para imprimirla sin grad_fn. torch.equal devuelve True solo si dos tensores tienen la misma forma y los mismos valores.

Pregunta. ¿nn.Conv2d con el mismo filtro da exactamente la misma salida? Si el trazo se mueve a la columna 3, ¿qué valores quedan en la fila 1 de la salida?

Python 3 · ejemplo reproducible
conv = nn.Conv2d(1, 1, kernel_size=3, bias=False)
with torch.no_grad():
    conv.weight.copy_(filtro.reshape(1, 1, 3, 3))  # (salida, entrada, alto, ancho)

entrada = imagen.reshape(1, 1, 5, 5)  # batch de una imagen de un canal
print(conv.weight.shape)
print(conv(entrada).shape)
print(torch.equal(conv(entrada)[0, 0], convolucion_a_mano(imagen, filtro)))

desplazada = desplazar(entrada)
print(conv(desplazada)[0, 0].detach())
Salida esperada

La salida esperada está oculta.

Tamaño de salida

El padding añade un borde de ceros alrededor de la imagen antes de deslizar el filtro. Con padding=1, la imagen 5 × 5 se convierte en 7 × 7 y las ventanas también se centran en los píxeles del borde. La capa conv_padding copia el filtro de conv.

Pregunta. Con padding=1, ¿qué forma tiene la salida para la imagen desplazada? ¿Aparece la respuesta negativa del borde derecho del trazo?

Python 3 · ejemplo reproducible
conv_padding = nn.Conv2d(1, 1, kernel_size=3, padding=1, bias=False)
with torch.no_grad():
    conv_padding.weight.copy_(conv.weight)

print(conv_padding(desplazada)[0, 0].detach())
Salida esperada

La salida esperada está oculta.

El stride es el salto entre dos posiciones consecutivas del filtro. Con stride=2, la ventana avanza de dos en dos y la salida tiene aproximadamente la mitad de posiciones por eje. Para una entrada de tamaño n, un filtro k, padding p y stride s, el tamaño de salida en cada eje es:

salida = ⌊(n + 2p − k) / s⌋ + 1

⌊ ⌋ redondea hacia abajo. En Python, la fórmula se escribe con división entera: (n + 2 * p - k) // s + 1. En nn.Conv2d, el stride se pasa como argumento junto al tamaño del filtro y el padding:

Python 3 · ejemplo reproducible
nn.Conv2d(8, 16, kernel_size=3, padding=1, stride=2)  # la ventana avanza de dos en dos

La tabla siguiente compara la fórmula con la forma que produce nn.Conv2d sobre una entrada (1, 1, 8, 8).

Pregunta. Para una entrada 8 × 8, calcula la salida de las cinco combinaciones (k, p, s): (3, 0, 1), (3, 1, 1), (3, 1, 2), (5, 0, 1) y (2, 0, 2).

Ver resultado

Cada fila es una combinación para una entrada 8 × 8. k, p y s son el tamaño del filtro, el padding y el stride. La columna «fórmula» da el tamaño por eje según ⌊(n + 2p − k) / s⌋ + 1, y la última da el alto y el ancho de la salida de nn.Conv2d. La fila 3, 1, 2 dice que la fórmula da 4 y que nn.Conv2d produce una salida de (4, 4).

kpsfórmulaalto y ancho con nn.Conv2d
3016(6, 6)
3118(8, 8)
3124(4, 4)
5014(4, 4)
2024(4, 4)
Lectura. La fórmula coincide con nn.Conv2d en las cinco combinaciones. Con (3, 1, 2) hay 4 posiciones porque ⌊(8 + 2 − 3) / 2⌋ + 1 = 3 + 1. La división entera descarta la última ventana cuando no cabe completa. La fórmula no depende de los valores de la imagen ni del número de canales.
Explorador

Ventana de convolución

Avanza la ventana paso a paso y compara los nueve productos con la celda que se llena en la salida.

Qué probar

Cambia el filtro o haz clic en un píxel de la entrada y observa qué celdas de la salida se marcan.

Entrada 8 × 8Salida 6 × 6
Parámetros
Filtro 3 × 3
Editar pesos
−1
0
1
−1
0
1
−1
0
1
Padding
Stride

Tamaño de salida por eje⌊(8 + 2·0 − 3) / 1⌋ + 1 = 6

Haz clic en un píxel de la entrada para cambiarlo entre 0 y 1. Con el teclado, las flechas pasan de un píxel a otro y Enter lo cambia.

Posición (0, 0), 1 de 36: la ventana cubre las filas 0 a 2 y las columnas 0 a 2. La suma de los 9 productos es −1.5 y llena la salida (0, 0). Faltan 35 posiciones.

El pooling reduce el tamaño de un mapa de activación. nn.MaxPool2d(2) divide el mapa en bloques 2 × 2 sin solapamiento y conserva el máximo de cada bloque. nn.AvgPool2d(2) conserva el promedio. Los dos usan kernel_size=2 y stride=2, así que la fórmula anterior también da su tamaño de salida. parameters() recorre los tensores de pesos de una capa y numel() cuenta los elementos de cada uno.

Pregunta. Escribe la salida 2 × 2 de cada pooling para este mapa 4 × 4. ¿Cuántos parámetros tiene nn.MaxPool2d(2)?

Python 3 · ejemplo reproducible
mapa = torch.tensor(
    [
        [1.0, 3.0, 0.0, 2.0],
        [4.0, 2.0, 1.0, 0.0],
        [0.0, 1.0, 5.0, 2.0],
        [2.0, 0.0, 3.0, 1.0],
    ]
).reshape(1, 1, 4, 4)

max_pool = nn.MaxPool2d(2)
print(max_pool(mapa)[0, 0])
print(nn.AvgPool2d(2)(mapa)[0, 0])
print(sum(p.numel() for p in max_pool.parameters()))
Salida esperada

La salida esperada está oculta.

CNN sobre dígitos

Una CNN (red neuronal convolucional) apila capas convolucionales, activaciones y pooling, y termina con una capa densa que produce los logits. La partición es la misma de Misión de entrenamiento: 360 imágenes de prueba y, del resto, 360 de validación, con stratify y random_state=0. La comparación usa validación. La prueba queda reservada, como en Misión de entrenamiento.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
from sklearn.model_selection import train_test_split
from torch.utils.data import DataLoader, TensorDataset

X = digitos.data / 16.0
y = digitos.target
X_resto, X_prueba, y_resto, y_prueba = train_test_split(
    X, y, test_size=360, stratify=y, random_state=0
)
X_ent, X_val, y_ent, y_val = train_test_split(
    X_resto, y_resto, test_size=360, stratify=y_resto, random_state=0
)

X_ent_t = torch.tensor(X_ent, dtype=torch.float32).reshape(-1, 1, 8, 8)
X_val_t = torch.tensor(X_val, dtype=torch.float32).reshape(-1, 1, 8, 8)
y_ent_t = torch.tensor(y_ent)
y_val_t = torch.tensor(y_val)
X_val_desplazada = desplazar(X_val_t)

X_ent_t tiene forma (1077, 1, 8, 8). X_val_t y X_val_desplazada tienen forma (360, 1, 8, 8). Son 1077 imágenes de entrenamiento y 360 de validación, con píxeles entre 0 y 1. X_val_desplazada contiene las mismas 360 imágenes de validación desplazadas una columna con desplazar.

crear_cnn usa dos bloques convolución–ReLU–pooling. La primera convolución produce 8 mapas y la segunda 16. nn.Flatten aplana los mapas finales antes de la capa densa.

Python 3 · ejemplo reproducible
def crear_cnn(semilla):
    torch.manual_seed(semilla)  # mismos pesos iniciales para la misma semilla
    return nn.Sequential(
        nn.Conv2d(1, 8, kernel_size=3, padding=1),
        nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Conv2d(8, 16, kernel_size=3, padding=1),
        nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Flatten(),
        nn.Linear(16 * 2 * 2, 10),
    )

contar_parametros suma los elementos de todos los tensores de pesos de un modelo. Un nn.Sequential se puede recorrer capa por capa, y type(capa).__name__ da el nombre de la clase de cada capa.

Pregunta. Con la fórmula de tamaño de salida, escribe la forma después de cada capa para un batch de una imagen (1, 1, 8, 8). Calcula los parámetros de la primera convolución: 8 filtros de 1 × 3 × 3 más un sesgo por filtro.

Python 3 · ejemplo reproducible
def contar_parametros(modelo):
    return sum(p.numel() for p in modelo.parameters())


h = X_val_t[:1]  # batch de una imagen: (1, 1, 8, 8)
print(f"{'entrada':<10}{tuple(h.shape)}")
for capa in crear_cnn(0):
    h = capa(h)
    print(f"{type(capa).__name__:<10}{str(tuple(h.shape)):<16}{contar_parametros(capa)}")
Salida esperada

La salida esperada está oculta.

El MLP es el de Misión de entrenamiento, con nn.Flatten al inicio para que reciba la misma entrada (N, 1, 8, 8).

Python 3 · ejemplo reproducible
def crear_mlp(semilla):
    torch.manual_seed(semilla)
    return nn.Sequential(
        nn.Flatten(),
        nn.Linear(64, 64),
        nn.ReLU(),
        nn.Dropout(0.2),
        nn.Linear(64, 10),
    )

Las funciones auxiliares siguientes repiten Misión de entrenamiento. entrenar minimiza perdida_fn, la entropía cruzada, con Adam, lr=0.01, batches de 32 y 30 épocas. errores compara el argmax de los logits con la etiqueta y cuenta las imágenes mal clasificadas.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
perdida_fn = nn.CrossEntropyLoss()


def entrenar(modelo, semilla, epocas=30):
    generador = torch.Generator().manual_seed(semilla)
    cargador = DataLoader(
        TensorDataset(X_ent_t, y_ent_t),
        batch_size=32,
        shuffle=True,
        generator=generador,
    )
    optimizador = torch.optim.Adam(modelo.parameters(), lr=0.01)
    for _ in range(epocas):
        modelo.train()
        for xb, yb in cargador:
            optimizador.zero_grad()
            perdida_fn(modelo(xb), yb).backward()
            optimizador.step()
    modelo.eval()
    return modelo


def errores(modelo, X_t, y_t):
    with torch.no_grad():
        return (modelo(X_t).argmax(dim=1) != y_t).sum().item()

Cada modelo se entrena con las semillas 0, 1 y 2, y se cuentan sus errores en la validación original y en la desplazada. Cada error de la tabla sale de dos llamadas como estas:

Python 3 · ejemplo reproducible
modelo = entrenar(crear_cnn(semilla), semilla)
errores(modelo, X_val_desplazada, y_val_t)  # o X_val_t para las originales

Pregunta. ¿Qué modelo tiene más parámetros? ¿La CNN comete menos errores que el MLP en las imágenes originales? ¿Y en las desplazadas?

Ver resultado

Cada fila es un modelo entrenado con una semilla. «parámetros» cuenta sus pesos y sesgos. Las dos últimas columnas cuentan las imágenes mal clasificadas de las 360 de validación, sin desplazar y desplazadas una columna. La fila CNN 1 dice que la CNN entrenada con la semilla 1 tiene 1898 parámetros, falla 12 imágenes originales y falla 97 desplazadas.

modelosemillaparámetroserrores originaleserrores desplazadas
MLP048109183
MLP148107194
MLP248109200
CNN0189810149
CNN118981297
CNN218988128
Lectura. El MLP reproduce los 9, 7 y 9 errores de Misión de entrenamiento. La CNN comete 10, 12 y 8 con el 40 % de los parámetros: en imágenes originales, la diferencia entre modelos es del tamaño de la diferencia entre semillas. Con las imágenes desplazadas, la CNN falla entre 97 y 149 y el MLP entre 183 y 200. Las convoluciones detectan el mismo trazo en otra columna, pero la tolerancia es parcial. Después del segundo pooling quedan 2 × 2 posiciones, y la capa densa final asigna un peso distinto a cada una.

Bloque residual

Apilar más capas convolucionales permite detectar patrones más grandes y más compuestos. En la práctica, las redes profundas sin atajos entrenaban peor. En 2015, He y coautores reportaron que una red simple de 56 capas tenía más error de entrenamiento que una de 20 en CIFAR-10. Una red más profunda puede representar lo mismo que una menos profunda: bastaría que las capas extra copiaran su entrada. El optimizador no encontraba esa solución.

Su propuesta, ResNet, suma la entrada de cada bloque a su salida: salida = ReLU(x + F(x)). En ResNet-18 y ResNet-34, F son dos convoluciones 3 × 3. Esa suma es una conexión residual. Los bloques de ResNet también llevan BatchNorm después de cada convolución. ResNet-18, ResNet-50 y ResNet-152 son versiones con 18, 50 y 152 capas.

Para sumar x y F(x), las dos formas deben coincidir. Con padding=1, stride=1 y el mismo número de canales de entrada y de salida, cada convolución conserva la forma.

BloqueResidual hereda de nn.Module. __init__ declara las dos convoluciones, residuo calcula F(x) y forward define la salida del bloque.

Python 3 · ejemplo reproducible
class BloqueResidual(nn.Module):
    def __init__(self, canales):
        super().__init__()
        self.conv1 = nn.Conv2d(canales, canales, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(canales, canales, kernel_size=3, padding=1)

    def residuo(self, x):
        return self.conv2(torch.relu(self.conv1(x)))

    def forward(self, x):
        return torch.relu(x + self.residuo(x))

La prueba usa BloqueResidual(1), creado con semilla 0, y las 4 primeras imágenes de dígitos, x = imagenes[:4]. torch.equal(bloque(x), x) compara la salida con la entrada, primero con los pesos aleatorios y después con los pesos y el sesgo de conv2 en cero. zero_() pone en cero un tensor sin crear otro:

Python 3 · ejemplo reproducible
with torch.no_grad():
    bloque.conv2.weight.zero_()
    bloque.conv2.bias.zero_()

Pregunta. Si los pesos y el sesgo de conv2 valen cero, ¿qué devuelve el bloque para una imagen de dígitos? ¿Y con los pesos iniciales aleatorios?

Ver resultado

Cada fila es un estado de conv2. La segunda columna da la forma de la salida del bloque y la tercera dice si la salida es idéntica a la entrada. La fila «aleatorios» dice que, con los pesos iniciales, la salida tiene forma (4, 1, 8, 8) y no es igual a x.

pesos y sesgo de conv2forma de bloque(x)torch.equal(bloque(x), x)
aleatorios(4, 1, 8, 8)False
en cero(4, 1, 8, 8)True
Lectura. El bloque conserva la forma (4, 1, 8, 8). Con pesos aleatorios, la salida difiere de la entrada. Con conv2 en cero, F(x) = 0 y el bloque devuelve ReLU(x). Los píxeles de los dígitos son no negativos, así que ReLU(x) = x. Añadir un bloque residual con residuo cero no cambia lo que la red ya calculaba.

crear_profunda construye una red con la misma entrada y la misma salida para cualquier tipo de bloque: una convolución inicial de 8 canales, n bloques y una capa densa. BloquePlano hereda de BloqueResidual y solo reemplaza forward: es el mismo bloque sin la suma de x.

Python 3 · ejemplo reproducible
class BloquePlano(BloqueResidual):
    def forward(self, x):
        return torch.relu(self.residuo(x))


def crear_profunda(bloque, n):
    torch.manual_seed(0)
    return nn.Sequential(
        nn.Conv2d(1, 8, kernel_size=3, padding=1),
        nn.ReLU(),
        *[bloque(8) for _ in range(n)],  # n bloques sueltos dentro de Sequential
        nn.Flatten(),
        nn.Linear(8 * 8 * 8, 10),  # 8 mapas de 8 × 8, sin pooling
    )

Para cada red se calcula la pérdida en 64 imágenes de entrenamiento. Después de backward(), red[0].weight.grad guarda el gradiente de la primera convolución y norm() mide su tamaño:

Python 3 · ejemplo reproducible
red = crear_profunda(BloquePlano, 10)
perdida_fn(red(X_ent_t[:64]), y_ent_t[:64]).backward()
red[0].weight.grad.norm()  # primera convolución

Pregunta. Con 1, 5 y 10 bloques, ¿qué red conserva un gradiente de tamaño parecido en la primera capa?

Ver resultado

La tabla da la norma del gradiente de la primera convolución. Cada fila es un número de bloques y cada columna un tipo de bloque. La fila 5 dice que, con 5 bloques, la norma vale 7.6e-07 en la red plana y 7.6e-02 en la residual.

bloquesBloquePlanoBloqueResidual
17.6e-038.4e-02
57.6e-077.6e-02
102.2e-111.2e-01
Lectura. En la red plana, el gradiente de la primera capa cae de 7.6 × 10⁻³ a 2.2 × 10⁻¹¹ al pasar de 1 a 10 bloques. Con ese gradiente, step() casi no cambia la primera capa. En la red residual se mantiene entre 7.6 × 10⁻² y 1.2 × 10⁻¹ con las tres profundidades. El gradiente de x + F(x) respecto a x es 1 + F'(x). El término 1 deja pasar el gradiente hacia las primeras capas sin multiplicarlo por los pesos del bloque.

Práctica

Traza guiada

Antes de procesar, escribe la forma después de cada capa y los parámetros de cada convolución para un batch de 4 imágenes RGB de 32 × 32.

Python 3 · ejemplo reproducible
torch.manual_seed(0)
red_traza = nn.Sequential(
    nn.Conv2d(3, 6, kernel_size=5),
    nn.ReLU(),
    nn.MaxPool2d(2),
    nn.Conv2d(6, 16, kernel_size=5),
    nn.ReLU(),
    nn.MaxPool2d(2),
    nn.Flatten(),
)
lote = torch.rand(4, 3, 32, 32)

Comprueba recorriendo red_traza capa por capa, como en «CNN sobre dígitos». Si tu conteo de la segunda convolución no coincide, revisa que cada filtro cubre los 6 canales de entrada.

Stride en lugar de pooling

Copia crear_cnn, quita cada nn.MaxPool2d(2) y pon stride=2 en la convolución anterior. Antes de procesar, predice con la fórmula la forma después de cada convolución, el número de parámetros y si los errores con imágenes desplazadas suben o bajan. Comprueba con assert que la entrada de la capa densa sigue teniendo 64 valores. Entrena con las semillas 0, 1 y 2 y compara la tabla con la de la CNN original.

Red residual para dígitos

Construye crear_cnn_residual(semilla) que:

  1. empiece con nn.Conv2d(1, 8, kernel_size=3, padding=1) y ReLU
  2. añada un BloqueResidual(8)
  3. termine con nn.MaxPool2d(2), la segunda convolución, ReLU, nn.MaxPool2d(2), nn.Flatten() y nn.Linear(64, 10), como crear_cnn

Antes de entrenar, calcula cuántos parámetros añade el bloque y compruébalo con assert contar_parametros(...) == contar_parametros(crear_cnn(0)) + …. Entrena con entrenar y las semillas 0, 1 y 2. Escribe si la diferencia de errores frente a crear_cnn es mayor que la diferencia entre semillas.

Reto opcional

Elige una permutación fija de los 64 píxeles con torch.randperm(64, generator=torch.Generator().manual_seed(0)) y aplícala a todas las imágenes de entrenamiento y de validación. Entrena el MLP y la CNN sobre las imágenes permutadas con las semillas 0, 1 y 2. Predice antes qué modelo cambia poco y cuál empeora, y explícalo con la vecindad entre píxeles.

Errores que revisar

  • Si nn.Conv2d(1, 8, 3) lanza expected input[1, 5, 8, 8] to have 1 channels, but got 5 channels instead con un batch (5, 8, 8), falta el eje de canales. La capa interpreta un tensor de tres ejes como una sola imagen (C, H, W) y toma el batch como canales. Añade el eje con unsqueeze(1).
  • Si la imagen en color queda con franjas o colores mezclados después de pasar de (H, W, C) a (C, H, W), usaste reshape. Reordena los ejes con permute(2, 0, 1).
  • Si la capa densa lanza mat1 and mat2 shapes cannot be multiplied, el número de valores después de nn.Flatten no coincide con su entrada. Calcula el tamaño con la fórmula o pasa un batch de una imagen capa por capa.
  • Si la suma de un bloque residual lanza un error de tamaños, x y F(x) tienen formas distintas. Revisa que cada convolución del bloque use padding=1, stride=1 y el mismo número de canales de entrada y de salida.

Cierre

Con los números de la comparación, explica por qué la CNN usa menos parámetros que el MLP y por qué aun así falla con muchas imágenes desplazadas una columna.

Lista de comprobación

  • ¿Qué forma tiene un batch de 16 imágenes RGB de 32 × 32 en PyTorch?
  • ¿Qué tamaño de salida da una capa con k=3, p=0 y s=2 sobre una entrada de 9 × 9?
  • ¿Por qué el número de parámetros de nn.Conv2d no depende del alto ni del ancho de la imagen?
  • ¿Qué conserva nn.MaxPool2d(2) de cada bloque 2 × 2 y cuántos parámetros tiene?
  • ¿Qué devuelve un bloque residual cuando F(x) = 0 y por qué eso facilita entrenar redes profundas?

Documentación oficial

Cargando progreso…