Phase 04 - Lesson 02
Convoluciones desde Cero
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Una convolución es una diminuta capa densa que deslizas sobre una imagen, compartiendo los mismos pesos en cada ubicación.
Tipo: Construcción Lenguajes: Python Requisitos previos: Fase 3 (Núcleo de Deep Learning), Fase 4 Lección 01 (Fundamentos de Imagen) Tiempo: ~75 minutos
Objetivos de Aprendizaje
- Implementar convolución 2D desde cero usando solo NumPy, incluyendo la versión con bucles anidados y una versión vectorizada con
im2col - Calcular el tamaño espacial de salida para cualquier combinación de tamaño de entrada, tamaño de kernel, padding y stride, y justificar la fórmula
(H - K + 2P) / S + 1 - Diseñar kernels a mano (borde, blur, sharpen, Sobel) y explicar por qué cada uno produce el patrón de activaciones que produce
- Apilar convoluciones en un extractor de características y conectar la profundidad de la pila con el tamaño del campo receptivo
El Problema
Una capa totalmente conectada sobre una imagen RGB de 224x224 necesitaría 224 * 224 * 3 = 150.528 pesos de entrada por neurona. Una sola capa oculta con 1.000 unidades ya son 150 millones de parámetros, antes de haber aprendido algo útil. Peor aún, esa capa no tiene noción de que un perro en la esquina superior izquierda y un perro en la esquina inferior derecha son el mismo patrón. Trata cada posición de píxel como independiente, lo cual es exactamente incorrecto para imágenes: trasladar un gato tres píxeles no debería forzar a la red a reaprender el concepto.
Las dos propiedades que necesita un modelo de imagen son equivarianza a la traslación (la salida se desplaza cuando la entrada se desplaza) y compartición de parámetros (el mismo detector de características corre en todas partes). Las capas densas no dan ninguna de las dos. La convolución da ambas gratis.
La convolución no se inventó para el deep learning. Es la misma operación que impulsa la compresión JPEG, el desenfoque gaussiano en Photoshop, la detección de bordes en la visión industrial y todo filtro de audio jamás lanzado. La razón por la que las CNN dominaron ImageNet de 2012 a 2020 es que la convolución es el prior correcto para datos donde los valores cercanos están relacionados y el mismo patrón puede aparecer en cualquier lugar.
El Concepto
Un kernel, deslizándose
Una convolución 2D toma una pequeña matriz de pesos llamada kernel (o filtro), la desliza sobre la entrada y, en cada ubicación, calcula la suma de los productos elemento a elemento. Esa suma se convierte en un píxel de salida.
flowchart LR
subgraph IN["Entrada (H x W)"]
direction LR
I1["imagen 5 x 5"]
end
subgraph K["Kernel (3 x 3)"]
K1["pesos<br/>aprendidos"]
end
subgraph OUT["Salida (H-2 x W-2)"]
O1["mapa 3 x 3"]
end
I1 --> |"desliza el kernel<br/>calcula el producto punto<br/>en cada posición"| O1
K1 --> O1
style IN fill:#dbeafe,stroke:#2563eb
style K fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
Un ejemplo concreto 3x3 sobre una entrada 5x5 (sin 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
Esa única fórmula — pesos compartidos, localidad, ventana deslizante — es la idea completa. Todo lo demás es contabilidad.
Fórmula del tamaño de salida
Dado el tamaño espacial de entrada H, el tamaño del kernel K, el padding P y el stride S:
H_out = floor( (H - K + 2P) / S ) + 1
Memoriza esto. Lo calcularás docenas de veces por arquitectura.
| Escenario | H | K | P | S | H_out |
|---|---|---|---|---|---|
| Conv válida, sin padding | 32 | 3 | 0 | 1 | 30 |
| Conv "same" (preserva el tamaño) | 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 elegir P de modo que H_out == H cuando S == 1. Para K impar, eso es P = (K - 1) / 2. Por eso dominan los kernels 3x3: son el kernel impar más pequeño que aún tiene un centro.
Padding
Sin padding, toda convolución encoge el mapa de características. Apila 20 de ellas y tu imagen 224x224 se vuelve 184x184, lo cual desperdicia cómputo en el borde y complica las conexiones residuales que necesitan formas 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 encuentras en la práctica: zero (el más común), reflect (refleja el borde, evita bordes duros en modelos generativos), replicate (copia el borde), circular (envuelve alrededor, usado en problemas toroidales).
Stride
El stride es el tamaño del paso del deslizamiento. stride=1 es el valor por defecto. stride=2 reduce a la mitad las dimensiones espaciales y es la forma clásica de hacer downsample dentro de una CNN sin una capa de pooling separada: toda arquitectura moderna (ResNet, ConvNeXt, MobileNet) usa convs con stride en lugar de max-pool en algún punto.
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últiples canales de entrada
Las imágenes reales tienen tres canales. Una convolución 3x3 sobre una entrada RGB es en realidad un volumen 3x3x3: una rebanada 3x3 por canal de entrada. En cada posición espacial, multiplicas y sumas a través de las tres rebanadas y agregas un 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)
Esa última línea es la que calcularás al planificar un modelo. Una conv 3x3 de 64 canales sobre una entrada de 3 canales tiene 64 * 3 * 3 * 3 + 64 = 1.792 parámetros. Barato.
El truco de im2col
Los bucles anidados son fáciles de leer pero lentos. Las GPU quieren grandes multiplicaciones de matrices. El truco: aplanar cada ventana de campo receptivo de la entrada en una columna de una gran matriz, aplanar el kernel en una fila, y toda la convolución se convierte en un solo matmul.
flowchart LR
X["Entrada<br/>(C_in, H, W)"] --> IM2COL["im2col<br/>(extrae parches)"]
IM2COL --> COLS["Matriz de columnas<br/>(C_in * K * K, H_out * W_out)"]
W["Pesos<br/>(C_out, C_in, K, K)"] --> FLAT["Aplanar<br/>(C_out, C_in * K * K)"]
FLAT --> MM["matmul"]
COLS --> MM
MM --> OUT["Salida<br/>(C_out, H_out * W_out)<br/>reshape a (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 implementación de conv de producción es alguna variante de esto más trucos de cache-tiling (conv directa, Winograd, conv por FFT para kernels grandes). Entiende im2col y entiendes el núcleo.
Campo receptivo
Una sola conv 3x3 mira 9 píxeles de entrada. Apila dos convs 3x3 y una neurona en la segunda capa mira 5x5 píxeles de entrada. Tres convs 3x3 dan 7x7. En general:
RF after L stacked K x K convs (stride 1) = 1 + L * (K - 1)
With strides: RF grows multiplicatively with stride along each layer.
La razón completa por la que "3x3 hasta el fondo" funciona (VGG, ResNet, ConvNeXt) es que dos convs 3x3 ven la misma área de entrada que una conv 5x5, pero con menos parámetros y una no linealidad extra en medio.
Constrúyelo
Paso 1: Aplicar padding a un arreglo
Comienza con la primitiva más simple: una función que aplica padding con ceros alrededor de un arreglo 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))
El truco de los ejes finales x.shape[:-2] significa que la misma función funciona en (H, W), (C, H, W) o (N, C, H, W) sin modificación.
Paso 2: Convolución 2D con bucles anidados
La implementación de referencia — lenta, pero inequívoca. Esto es lo que torch.nn.functional.conv2d hace en principio.
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
Cuatro bucles anidados (canal de salida, fila, columna, más la suma implícita sobre C_in, kh, kw). Esta es la verdad fundamental contra la que verificarás cada implementación más rápida.
Paso 3: Verifica con un kernel diseñado a mano
Construye un kernel Sobel vertical, aplícalo a una imagen sintética de escalón y observa cómo se enciende el borde vertical.
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))
Espera valores positivos grandes en la columna 7 (aumento de brillo de izquierda a derecha) y ceros en todo lo demás. Ese único print es tu verificación de sanidad de que las matemáticas están correctas.
Paso 4: im2col
Convierte cada ventana del tamaño del kernel en la entrada en una columna de una matriz. Para C_in=3, K=3, cada columna tiene 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
Sigue siendo un bucle de Python, pero ahora el trabajo pesado será un único matmul vectorizado.
Paso 5: Conv rápida vía im2col + matmul
Reemplaza el bucle cuádruple por una única multiplicación de matrices.
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)
Verificación de correctitud: ejecuta ambas implementaciones y compara.
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 debería estar alrededor de 1e-5 — la diferencia es el orden de acumulación de punto flotante, no un bug.
Paso 6: Un banco de kernels diseñados a mano
Cinco filtros que muestran lo que una sola capa conv puede expresar antes de cualquier entrenamiento.
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 cualquier imagen en escala de grises, el blur suaviza, el sharpen realza los bordes, el Sobel-x enciende los bordes verticales y el Sobel-y enciende los bordes horizontales. Estos son exactamente los patrones que la primera capa conv entrenada en AlexNet y VGG terminó aprendiendo, porque un buen modelo de imagen necesita detectores de bordes y de blobs sin importar qué tarea venga después.
Úsalo
El nn.Conv2d de PyTorch envuelve la misma operación con autograd, kernels CUDA y optimización cuDNN. La semántica de formas es 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)}")
Cambia padding=1 por padding=0 y la salida cae a 222x222. Cambia stride=1 por stride=2 y cae a 112x112. La misma fórmula que memorizaste arriba.
Entrégalo
Esta lección produce:
outputs/prompt-cnn-architect.md— un prompt que, dados el tamaño de entrada, el presupuesto de parámetros y el campo receptivo objetivo, diseña una pila de capasConv2dcon el K/S/P correcto en cada paso.outputs/skill-conv-shape-calculator.md— una skill que recorre una especificación de red capa por capa y devuelve la forma de salida, el campo receptivo y el conteo de parámetros para cada bloque.
Ejercicios
- (Fácil) Dada una entrada 128x128 en escala de grises y una pila de
[Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)], calcula el tamaño espacial de salida y el campo receptivo en cada capa a mano. Verifica con unnn.Sequentialde PyTorch de convs ficticias. - (Medio) Extiende
conv2d_naiveyconv2d_im2colpara aceptar un argumentogroups. Muestra quegroups=C_in=C_outreproduce una convolución depthwise y que su conteo de parámetros esC * K * Ken lugar deC * C * K * K. - (Difícil) Implementa el paso backward de
conv2d_im2cola mano: dado el gradiente de la salida, calcula el gradiente dexyw. Verifica contratorch.autograd.graden las mismas entradas y pesos. El truco: el gradiente de im2col escol2im, y tiene que acumular ventanas superpuestas.
Términos Clave
| Término | Lo que dice la gente | Lo que realmente significa |
|---|---|---|
| Convolución | "Deslizar un filtro" | Un producto punto aprendible aplicado en cada posición espacial con pesos compartidos; matemáticamente es una correlación cruzada, pero todos la llaman convolución |
| Kernel / filtro | "El detector de características" | Un pequeño tensor de pesos de forma (C_in, K, K) cuyo producto punto con una ventana de la entrada produce un píxel de salida |
| Stride | "Qué tan lejos saltas" | El tamaño del paso entre ubicaciones consecutivas del kernel; el stride 2 reduce a la mitad cada dimensión espacial |
| Padding | "Ceros en los bordes" | Valores extra agregados alrededor de la entrada para que el kernel pueda centrarse en los píxeles del borde; el padding same mantiene el tamaño de salida igual al de entrada |
| Campo receptivo | "Cuánto ve la neurona" | El parche de la entrada original del que depende una activación de salida dada, creciendo con la profundidad y el stride |
| im2col | "El truco de GEMM" | Reorganizar cada ventana receptiva en columnas para que la convolución se convierta en una sola multiplicación de matrices — el núcleo de todo kernel de conv rápido |
| Conv depthwise | "Un kernel por canal" | Una conv con groups == C_in, que computa cada canal de salida solo a partir de su canal de entrada correspondiente; la columna vertebral de MobileNet y ConvNeXt |
| Equivarianza a la traslación | "Desplaza en la entrada, desplaza en la salida" | Propiedad de que desplazar la entrada k píxeles desplaza la salida k píxeles; viene gratis con los pesos compartidos |
Lecturas Adicionales
- A guide to convolution arithmetic for deep learning (Dumoulin & Visin, 2016) — los diagramas definitivos de padding/stride/dilatación que todo curso copia silenciosamente
- CS231n: Convolutional Neural Networks for Visual Recognition — las notas de clase canónicas, incluyendo la explicación original de im2col
- The Annotated ConvNet (fast.ai) — un notebook que va de la convolución manual a un clasificador de dígitos entrenado
- Receptive Field Arithmetic for CNNs (Dang Ha The Hien) — el explicador interactivo, de calidad de artículo, de los cálculos de campo receptivo