Saltar a contenido

Capítulo 4 — Visión por computador con CNNs

Módulo 03 — DEEP LEARNING · AI Master Academy

En el capítulo 1 te prometimos que las redes profundas aprenden jerarquías de conceptos: bordes → texturas → partes → objetos. En este capítulo vas a ver exactamente cómo ocurre eso, vas a calcular convoluciones a mano, vas a construir una CNN completa en PyTorch y vas a aprender la técnica más rentable de toda la visión por computador profesional: el transfer learning.


Índice

  1. El problema: los MLP no escalan con imágenes
  2. La convolución a fondo
  3. Pooling: reducir sin perder lo importante
  4. Anatomía de una CNN completa
  5. Arquitecturas que hicieron historia
  6. Data augmentation: más datos gratis
  7. Transfer learning en visión
  8. Más allá de clasificar: detección, segmentación, OCR y multimodal
  9. Ejemplo integrador: clasificador de defectos industriales
  10. Caso empresarial: control de calidad visual en fábrica
  11. Buenas prácticas
  12. Malas prácticas
  13. Errores comunes
  14. FAQ — Preguntas frecuentes
  15. Resumen del capítulo
  16. Bibliografía y recursos

1. El problema: los MLP no escalan con imágenes

En el capítulo 2 construiste un MLP (perceptrón multicapa) y en el capítulo 3 lo entrenaste con PyTorch. La tentación natural es aplicar el mismo enfoque a imágenes: aplanar los píxeles en un vector gigante y meterlo en capas densas. Vamos a ver por qué esa idea fracasa por tres razones estructurales, y cómo la convolución resuelve las tres de golpe.

1.1 Razón 1: explosión de parámetros

Hagamos la cuenta con una imagen normal y corriente, de las que salen de cualquier móvil redimensionada al tamaño estándar de las redes de visión:

  • Tamaño: 224 × 224 píxeles, con 3 canales (rojo, verde, azul).
  • Al aplanarla: 224 × 224 × 3 = 150.528 valores de entrada.

Ahora conectemos eso a una primera capa densa de solo 1000 neuronas (nada exagerado):

Parámetros de la capa = entradas × neuronas + sesgos
                      = 150.528 × 1.000 + 1.000
                      = 150.529.000 ≈ 150 MILLONES de parámetros

Ciento cincuenta millones de parámetros en UNA sola capa. Para ponerlo en contexto:

Modelo Parámetros totales Comentario
MLP: 1 capa densa 224×224×3 → 1000 ~150M Solo la primera capa, sin hacer nada útil aún
ResNet-18 completa ~11,7M Clasifica 1000 clases de ImageNet con alta precisión
ResNet-50 completa ~25,6M Estado del arte durante años
EfficientNet-B0 ~5,3M Más precisa que ResNet-50 en su época

Una CNN completa que resuelve el problema real usa 13 veces menos parámetros que la primera capa de nuestro hipotético MLP. Más parámetros significa más memoria, más datos necesarios para no sobreajustar y más lentitud. Es un camino sin salida.

1.2 Razón 2: pérdida de la estructura espacial

Cuando aplanas una imagen a un vector, destruyes la información de vecindad. El píxel (10, 10) y el píxel (10, 11) son vecinos —probablemente forman parte del mismo borde o textura— pero para el MLP son simplemente las entradas número 3.010 y 3.011: dos números sin relación especial entre sí.

Un MLP tendría que aprender desde cero, con datos, que los píxeles cercanos están correlacionados. Es como darle a alguien un puzle de 150.000 piezas desordenadas y pedirle que deduzca que las piezas contiguas van juntas. La estructura 2D es información gratuita que estamos tirando a la basura.

1.3 Razón 3: sin invarianza a traslación

Supón que el MLP aprende a detectar un gato en la esquina superior izquierda. Los pesos que ha aprendido están anclados a esas posiciones concretas del vector de entrada. Si el gato aparece en la esquina inferior derecha, esos pesos no sirven de nada: la red tiene que aprender a detectar el gato otra vez, para cada posición posible. Es una redundancia brutal.

Lo que queremos es un detector de "orejas puntiagudas" que funcione en cualquier lugar de la imagen. A esa propiedad se le llama equivarianza a traslación (si el patrón se mueve, la detección se mueve con él), y combinada con pooling se convierte en invarianza local (a la red le da igual si el patrón está unos píxeles más allá).

1.4 La solución: convoluciones

La operación de convolución ataca los tres problemas a la vez con dos ideas:

  1. Conectividad local: cada neurona solo mira una pequeña ventana de la imagen (p. ej. 3×3 píxeles), no la imagen entera. Los patrones visuales básicos (bordes, esquinas, manchas de color) son locales, así que no perdemos nada importante.
  2. Pesos compartidos: el mismo detector (el mismo conjunto de pesos) se desliza por toda la imagen. Un solo detector de bordes verticales de 3×3×3 tiene 27 pesos + 1 sesgo = 28 parámetros, y sirve para toda la imagen, esté el borde donde esté.
Problema del MLP Cómo lo resuelve la convolución
Explosión de parámetros Pesos compartidos: 28 parámetros por filtro en vez de millones
Pérdida de estructura espacial La ventana deslizante opera sobre vecindarios 2D reales
Sin invarianza a traslación El mismo filtro se aplica en todas las posiciones

Nota

las CNN no son "un truco de ingeniería": son la formalización de un sesgo inductivo

correcto sobre las imágenes (localidad + estacionariedad de estadísticas). Cuando el sesgo inductivo encaja con los datos, necesitas muchísimos menos datos y parámetros. Esta idea —elegir la arquitectura que refleja la estructura del problema— es uno de los principios más profundos del deep learning.


2. La convolución a fondo

2.1 Qué es un filtro (kernel)

Un filtro o kernel es una matriz pequeña de pesos (típicamente 3×3 o 5×5) que actúa como detector de un patrón local. La operación es simple: colocas el filtro sobre una zona de la imagen, multiplicas cada peso por el píxel que tiene debajo, sumas todo, y ese número es la "respuesta" del filtro en esa posición.

  • Respuesta alta → la zona de la imagen se parece al patrón que busca el filtro.
  • Respuesta cercana a cero o negativa → no hay parecido.

Al deslizar el filtro por toda la imagen obtienes un mapa de features (feature map): una nueva "imagen" donde cada píxel indica cuánto se activa el detector en esa zona.

Nota

en deep learning la operación se llama convolución, pero técnicamente es una

correlación cruzada (no se voltea el kernel como en la convolución matemática estricta). Da igual: como los pesos se aprenden, la red aprendería el kernel volteado si hiciera falta. PyTorch, TensorFlow y todos los frameworks implementan correlación cruzada.

2.2 La operación deslizante (ASCII art)

Así se desliza un kernel 3×3 sobre una entrada 5×5 (sin padding, stride 1):

 Entrada 5×5                 Kernel 3×3          Salida 3×3

 ┌───┬───┬───┬───┬───┐
 │▓▓▓│▓▓▓│▓▓▓│   │   │       ┌───┬───┬───┐      ┌───┬───┬───┐
 ├───┼───┼───┼───┼───┤       │ w │ w │ w │      │ ● │   │   │
 │▓▓▓│▓▓▓│▓▓▓│   │   │   ×   ├───┼───┼───┤  →   ├───┼───┼───┤
 ├───┼───┼───┼───┼───┤       │ w │ w │ w │      │   │   │   │
 │▓▓▓│▓▓▓│▓▓▓│   │   │       ├───┼───┼───┤      ├───┼───┼───┤
 ├───┼───┼───┼───┼───┤       │ w │ w │ w │      │   │   │   │
 │   │   │   │   │   │       └───┴───┴───┘      └───┴───┴───┘
 ├───┼───┼───┼───┼───┤
 │   │   │   │   │   │      El kernel se coloca sobre la zona ▓▓▓,
 └───┴───┴───┴───┴───┘      se multiplica elemento a elemento,
                            se suma todo → un número de la salida ●

 Paso siguiente: el kernel se desliza 1 columna a la derecha →

 ┌───┬───┬───┬───┬───┐
 │   │▓▓▓│▓▓▓│▓▓▓│   │                          ┌───┬───┬───┐
 ├───┼───┼───┼───┼───┤                          │ ● │ ● │   │
 │   │▓▓▓│▓▓▓│▓▓▓│   │            →             ├───┼───┼───┤
 ├───┼───┼───┼───┼───┤                          │   │   │   │
 │   │▓▓▓│▓▓▓│▓▓▓│   │                          ├───┼───┼───┤
 ├───┼───┼───┼───┼───┤                          │   │   │   │
 │   │   │   │   │   │                          └───┴───┴───┘
 ├───┼───┼───┼───┼───┤
 │   │   │   │   │   │      ... y así hasta recorrer toda la imagen:
 └───┴───┴───┴───┴───┘      3 posiciones horizontales × 3 verticales = salida 3×3

2.3 Ejemplo numérico completo a mano

Vamos a calcular cada celda de una convolución. Entrada 5×5, kernel 3×3, stride 1, sin padding (convolución valid), por lo que la salida será 3×3.

Entrada X (5×5):

c0 c1 c2 c3 c4
f0 1 2 3 0 1
f1 0 1 2 3 1
f2 3 0 1 2 2
f3 2 3 0 1 0
f4 1 2 3 0 1

Kernel K (3×3) — detector de bordes verticales (tipo Prewitt):

1 0 -1
1 0 -1
1 0 -1

La lógica del kernel: suma la columna izquierda, resta la columna derecha. Si la izquierda es brillante y la derecha oscura (o viceversa), la respuesta es grande en valor absoluto → hay un borde vertical. Si la zona es uniforme, izquierda ≈ derecha → respuesta ≈ 0.

Cálculo celda a celda. Para la salida Y[i][j], superponemos el kernel con su esquina superior izquierda en X[i][j]:

Y[0][0] — ventana filas 0–2, columnas 0–2:

Ventana × Kernel Producto
1, 2, 3 1, 0, -1 1, 0, -3
0, 1, 2 1, 0, -1 0, 0, -2
3, 0, 1 1, 0, -1 3, 0, -1

Suma: 1 - 3 + 0 - 2 + 3 - 1 =-2

Y[0][1] — ventana filas 0–2, columnas 1–3:

Ventana × Kernel Producto
2, 3, 0 1, 0, -1 2, 0, 0
1, 2, 3 1, 0, -1 1, 0, -3
0, 1, 2 1, 0, -1 0, 0, -2

Suma: 2 + 0 + 1 - 3 + 0 - 2 =-2

Y[0][2] — ventana filas 0–2, columnas 2–4:

Ventana × Kernel Producto
3, 0, 1 1, 0, -1 3, 0, -1
2, 3, 1 1, 0, -1 2, 0, -1
1, 2, 2 1, 0, -1 1, 0, -2

Suma: 3 - 1 + 2 - 1 + 1 - 2 =2

Y[1][0] — ventana filas 1–3, columnas 0–2:

Ventana × Kernel Producto
0, 1, 2 1, 0, -1 0, 0, -2
3, 0, 1 1, 0, -1 3, 0, -1
2, 3, 0 1, 0, -1 2, 0, 0

Suma: 0 - 2 + 3 - 1 + 2 - 0 =2

Y[1][1] — ventana filas 1–3, columnas 1–3:

Ventana × Kernel Producto
1, 2, 3 1, 0, -1 1, 0, -3
0, 1, 2 1, 0, -1 0, 0, -2
3, 0, 1 1, 0, -1 3, 0, -1

Suma: 1 - 3 + 0 - 2 + 3 - 1 =-2

Y[1][2] — ventana filas 1–3, columnas 2–4:

Ventana × Kernel Producto
2, 3, 1 1, 0, -1 2, 0, -1
1, 2, 2 1, 0, -1 1, 0, -2
0, 1, 0 1, 0, -1 0, 0, 0

Suma: 2 - 1 + 1 - 2 + 0 - 0 =0

Y[2][0] — ventana filas 2–4, columnas 0–2:

Ventana × Kernel Producto
3, 0, 1 1, 0, -1 3, 0, -1
2, 3, 0 1, 0, -1 2, 0, 0
1, 2, 3 1, 0, -1 1, 0, -3

Suma: 3 - 1 + 2 - 0 + 1 - 3 =2

Y[2][1] — ventana filas 2–4, columnas 1–3:

Ventana × Kernel Producto
0, 1, 2 1, 0, -1 0, 0, -2
3, 0, 1 1, 0, -1 3, 0, -1
2, 3, 0 1, 0, -1 2, 0, 0

Suma: 0 - 2 + 3 - 1 + 2 - 0 =2

Y[2][2] — ventana filas 2–4, columnas 2–4:

Ventana × Kernel Producto
1, 2, 2 1, 0, -1 1, 0, -2
0, 1, 0 1, 0, -1 0, 0, 0
3, 0, 1 1, 0, -1 3, 0, -1

Suma: 1 - 2 + 0 - 0 + 3 - 1 =1

Salida Y (3×3) completa:

c0 c1 c2
f0 -2 -2 2
f1 2 -2 0
f2 2 2 1

Nueve multiplicaciones-suma por celda, nueve celdas: acabas de hacer una convolución completa a mano. Esto —repetido millones de veces en paralelo en la GPU— es todo lo que hace una capa convolucional.

2.4 Filtros clásicos interpretables con NumPy

Antes de que las redes aprendieran sus propios filtros, los ingenieros los diseñaban a mano. Verlos en acción sobre una imagen sintética hace la intuición cristalina:

import numpy as np

# ---------------------------------------------------------------
# 1. Imagen sintética: un cuadrado blanco sobre fondo negro
# ---------------------------------------------------------------
img = np.zeros((10, 10))       # 10×10 píxeles, todo negro (0.0)
img[3:7, 3:7] = 1.0            # cuadrado blanco (1.0) en el centro

# ---------------------------------------------------------------
# 2. Filtros clásicos diseñados a mano
# ---------------------------------------------------------------
kernel_v = np.array([[ 1, 0, -1],     # detector de bordes VERTICALES
                     [ 1, 0, -1],     # (columna izquierda - columna derecha)
                     [ 1, 0, -1]])

kernel_h = np.array([[ 1,  1,  1],    # detector de bordes HORIZONTALES
                     [ 0,  0,  0],    # (fila superior - fila inferior)
                     [-1, -1, -1]])

# ---------------------------------------------------------------
# 3. Convolución 2D 'valid' escrita a mano (sin librerías mágicas)
# ---------------------------------------------------------------
def conv2d(imagen, kernel):
    kh, kw = kernel.shape                       # alto y ancho del kernel
    oh = imagen.shape[0] - kh + 1               # alto de la salida
    ow = imagen.shape[1] - kw + 1               # ancho de la salida
    salida = np.zeros((oh, ow))                 # reservamos la salida
    for i in range(oh):                         # deslizamos verticalmente...
        for j in range(ow):                     # ...y horizontalmente
            ventana = imagen[i:i+kh, j:j+kw]    # recorte bajo el kernel
            salida[i, j] = np.sum(ventana * kernel)  # producto + suma
    return salida

bordes_v = conv2d(img, kernel_v)
bordes_h = conv2d(img, kernel_h)

print("Bordes verticales (respuesta ≠ 0 solo en los LADOS del cuadrado):")
print(bordes_v.astype(int))
print("\nBordes horizontales (respuesta ≠ 0 solo ARRIBA y ABAJO):")
print(bordes_h.astype(int))

Salida (recortada al interior relevante):

Bordes verticales                  Bordes horizontales
... 0  0  0  0  0  0 ...           ... 0  0  0  0  0  0 ...
... 0 -1 -2 ... 2  1 ...           ... 0  1  2  3  2  1 ...   ← borde superior
... 0 -2 ... 0 ... 2 ...           ... 0  0  0  0  0  0 ...   ← interior: uniforme, respuesta 0
... 0 -3  0  0  0  3 ...           ... 0  0  0  0  0  0 ...
... 0 -2 ... 0 ... 2 ...           ... 0 -1 -2 -3 -2 -1 ...   ← borde inferior (signo opuesto)

Observa la simetría preciosa: el detector vertical "se enciende" en los lados izquierdo (negativo) y derecho (positivo) del cuadrado, y da exactamente cero en el interior uniforme. El horizontal hace lo mismo con los bordes superior e inferior.

Consejo profesional

la diferencia entre 1990 y hoy es que **ya no diseñamos los kernels: los

aprende el gradiente**. La primera capa de cualquier CNN entrenada acaba conteniendo detectores de bordes y color asombrosamente parecidos a estos filtros de manual (y a los campos receptivos de la corteza visual V1 de los mamíferos, según Hubel y Wiesel, Nobel 1981). El diseño manual murió; la intuición de qué hace un filtro sigue siendo oro para depurar.

2.5 Stride: el tamaño del paso

El stride es cuántos píxeles se desplaza el kernel en cada paso.

  • stride=1 (por defecto): el kernel avanza de 1 en 1 → salida casi tan grande como la entrada.
  • stride=2: el kernel avanza de 2 en 2 → salida de aproximadamente la mitad de tamaño. Es una forma de reducir resolución mientras se convoluciona ("downsampling aprendido").
stride=1:  posiciones 0,1,2,3,...     stride=2:  posiciones 0,2,4,...
  [▓▓▓░░]→[░▓▓▓░]→[░░▓▓▓]              [▓▓▓░░]→→[░░▓▓▓]
   salida: 3 valores                    salida: 2 valores

2.6 Padding: bordes y tamaño de salida

Sin padding, cada convolución encoge la imagen (5×5 → 3×3 con kernel 3×3) y los píxeles de las esquinas participan en muchas menos ventanas que los del centro (¡el píxel de la esquina solo se ve 1 vez; el del centro, 9!). El padding añade un marco de ceros alrededor de la entrada:

  • valid (padding=0): sin relleno. La salida encoge. Útil cuando quieres reducir.
  • same (padding=k//2 para kernel k, stride 1): la salida tiene el mismo tamaño que la entrada. Para un kernel 3×3, padding=1. Es lo habitual dentro de las CNN modernas: permite apilar muchas capas sin que la imagen desaparezca por los bordes.
padding=1 con kernel 3×3 (same):

  0 0 0 0 0 0 0
  0 ┌─────────┐ 0        La entrada 5×5 se rodea de ceros → 7×7.
  0 │ entrada │ 0        El kernel 3×3 ahora produce 5×5:
  0 │   5×5   │ 0        ¡mismo tamaño que la entrada!
  0 │         │ 0
  0 └─────────┘ 0
  0 0 0 0 0 0 0

2.7 La fórmula del tamaño de salida

Para una dimensión (alto o ancho):

tamaño_salida = ⌊ (tamaño_entrada + 2·padding − tamaño_kernel) / stride ⌋ + 1
Entrada Kernel Padding Stride Cálculo Salida
5 3 0 1 (5+0−3)/1+1 3
5 3 1 1 (5+2−3)/1+1 5 (same)
224 3 1 1 (224+2−3)/1+1 224 (same)
224 3 1 2 ⌊(224+2−3)/2⌋+1 112 (mitad)
224 7 3 2 ⌊(224+6−7)/2⌋+1 112 (1ª capa de ResNet)
32 5 0 1 (32+0−5)/1+1 28 (1ª capa de LeNet)

Memoriza los dos casos de trabajo diario: 3×3, padding 1, stride 1 → mismo tamaño; stride 2 → mitad de tamaño.

2.8 Canales: cómo un kernel procesa RGB

Una imagen en color no es una matriz: es un tensor 3×H×W (3 canales: R, G, B). Entonces, ¿cómo la procesa un kernel "3×3"? Respuesta: el kernel también tiene profundidad. Un kernel "3×3" sobre una entrada RGB es en realidad un bloque 3×3×3 = 27 pesos (+1 sesgo):

        Canal R          Canal G          Canal B
      ┌─────────┐      ┌─────────┐      ┌─────────┐
      │ k k k   │      │ k k k   │      │ k k k   │
      │ k k k   │  +   │ k k k   │  +   │ k k k   │   →  UN solo número
      │ k k k   │      │ k k k   │      │ k k k   │      por posición
      └─────────┘      └─────────┘      └─────────┘
      (9 pesos)        (9 pesos)        (9 pesos)

  En cada posición: 27 multiplicaciones, se suma TODO (los 3 canales)
  → el mapa de salida de un filtro tiene UN solo canal.

Puntos clave que confunden a todo el mundo la primera vez:

  1. Un filtro siempre abarca TODOS los canales de entrada. Su forma real es (canales_entrada, alto_kernel, ancho_kernel).
  2. Un filtro produce UN canal de salida (un mapa de features).
  3. Una capa convolucional tiene MUCHOS filtros (p. ej. 64), cada uno aprendiendo un patrón distinto → la salida tiene 64 canales. Por eso los tensores dentro de la CNN tienen forma (batch, canales, alto, ancho).

Número de parámetros de una capa Conv2d(C_in, C_out, k):

parámetros = C_out × (C_in × k × k) + C_out    (pesos + sesgos)

Ejemplo: Conv2d(3, 64, kernel_size=3)  →  64 × (3×3×3) + 64 = 1.792 parámetros

Compara: 1.792 parámetros frente a los 150 millones de la capa densa de la sección 1.1, procesando la misma imagen 224×224×3. Esta es la magia de los pesos compartidos.

2.9 Múltiples filtros = múltiples mapas de features

Cada filtro de la capa se especializa en un patrón: uno detecta bordes verticales, otro horizontales, otro transiciones verde→rojo, otro pequeñas manchas... La salida de la capa es una pila de mapas de features: una "imagen" de 64 canales donde cada canal responde a "¿dónde aparece mi patrón?". La capa siguiente convoluciona sobre esos 64 canales, combinando patrones simples en patrones más complejos. Así nace la jerarquía.

Ejercicio rápido 1. Una capa Conv2d(in=64, out=128, kernel_size=3, padding=1, stride=2) recibe un tensor (32, 64, 56, 56). ¿Cuál es la forma del tensor de salida y cuántos parámetros tiene la capa?

Ver solución **Forma de salida:** el batch (32) no cambia. Canales de salida = 128. Tamaño espacial: `⌊(56 + 2·1 − 3)/2⌋ + 1 = ⌊55/2⌋ + 1 = 27 + 1 = 28`. → Salida: **(32, 128, 28, 28)**. **Parámetros:** `128 × (64 × 3 × 3) + 128 = 128 × 576 + 128 = 73.728 + 128 = `**73.856**. Fíjate: los parámetros no dependen del tamaño de la imagen (56×56), solo de canales y kernel. Esa es exactamente la propiedad que el MLP no tenía.

3. Pooling: reducir sin perder lo importante

3.1 Max pooling

El max pooling divide el mapa de features en ventanas (típicamente 2×2, stride 2) y se queda con el valor máximo de cada ventana. Dos propósitos:

  1. Reducir la resolución (2×2/stride 2 → mitad de alto y ancho, 4× menos cómputo aguas abajo).
  2. Invarianza local a traslación: si el patrón detectado se mueve 1 píxel dentro de la ventana, el máximo sigue siendo el mismo → la salida no cambia. A la red le importa que el patrón está ahí, no su posición exacta al píxel.

La intuición del máximo: el mapa de features dice "cuánto se parece esta zona a mi patrón". El máximo de una ventana responde a "¿está mi patrón en algún sitio de esta zona?" — que es justo la pregunta que nos interesa.

3.2 Ejemplo numérico a mano

Entrada 4×4, ventana 2×2, stride 2:

 Entrada 4×4                     Max pooling 2×2 (stride 2)

 ┌───┬───╥───┬───┐
 │ 1 │ 3 ║ 2 │ 4 │              ┌───┬───┐
 ├───┼───╫───┼───┤              │ 6 │ 4 │   max(1,3,5,6)=6   max(2,4,1,0)=4
 │ 5 │ 6 ║ 1 │ 0 │      →       ├───┼───┤
 ╞═══╪═══╬═══╪═══╡              │ 8 │ 7 │   max(2,1,4,8)=8   max(0,3,7,2)=7
 │ 2 │ 1 ║ 0 │ 3 │              └───┴───┘
 ├───┼───╫───┼───┤
 │ 4 │ 8 ║ 7 │ 2 │              4×4 → 2×2: mitad de tamaño,
 └───┴───╨───┴───┘              se conserva "lo más activado" de cada zona
Ventana Valores Max Average
Superior izquierda 1, 3, 5, 6 6 3,75
Superior derecha 2, 4, 1, 0 4 1,75
Inferior izquierda 2, 1, 4, 8 8 3,75
Inferior derecha 0, 3, 7, 2 7 3,00

3.3 Average pooling

El average pooling promedia la ventana en lugar de tomar el máximo (columna derecha de la tabla). Suaviza en vez de destacar: responde a "¿cuánto patrón hay en promedio en esta zona?". Se usó en LeNet y hoy sobrevive sobre todo en su versión global.

3.4 Global Average Pooling: el cierre moderno

El Global Average Pooling (GAP) lleva la idea al extremo: promedia todo el mapa espacial de cada canal, convirtiendo un tensor (batch, 512, 7, 7) en (batch, 512) — un solo número por canal: "¿cuánto se activó este detector en toda la imagen?".

¿Por qué es el truco moderno para cerrar la red?

  • Elimina las capas densas gigantes. AlexNet/VGG aplanaban 512×7×7 = 25.088 valores y los conectaban a capas densas de 4096: decenas de millones de parámetros solo en el clasificador (en VGG-16, ~120M de sus ~138M de parámetros están ahí). ResNet usa GAP + una sola capa densa 512 → 1000: medio millón de parámetros. Menos sobreajuste, menos memoria.
  • Acepta cualquier tamaño de entrada. Da igual si el mapa final es 7×7 o 10×10: el promedio siempre produce un vector de 512.

En PyTorch: nn.AdaptiveAvgPool2d(1) (fuerza la salida espacial a 1×1, sea cual sea la entrada).

Nota

las CNN muy modernas a veces sustituyen el max pooling intermedio por convoluciones con

stride=2 ("que la red aprenda cómo reducir"). Ambas opciones son válidas; el patrón clásico conv→pool sigue siendo perfectamente competitivo para redes pequeñas y medianas.

Ejercicio rápido 2. Aplica max pooling 2×2 con stride 2 a la salida Y (3×3) de la sección 2.3. ¿Problema? ¿Y si usas ventana 3×3 global?

Ver solución Con ventana 2×2 y stride 2 sobre un mapa 3×3: `⌊(3−2)/2⌋+1 = 1` → salida 1×1 tomando solo la ventana superior izquierda `[-2,-2; 2,-2]` → max = **2**... pero la última fila y columna quedan fuera (se descartan, como hace PyTorch por defecto al truncar). Por eso se procura que los mapas tengan tamaños pares antes de un pooling 2×2. Con pooling **global** (ventana 3×3 = todo el mapa): max global = **2**; average global = `(-2-2+2+2-2+0+2+2+1)/9 = 3/9 ≈ `**0,33**. Un número por canal: eso es exactamente lo que hace GAP.

4. Anatomía de una CNN completa

4.1 El patrón universal

Prácticamente toda CNN de clasificación sigue el mismo esquema en dos partes:

  1. Extractor de features (backbone): bloques [Conv → BatchNorm → ReLU] × n → Pool repetidos. Con cada bloque, la resolución espacial baja (pooling/stride) y el número de canales sube (más filtros). Intuición: cada vez menos "dónde" y cada vez más "qué".
  2. Clasificador (head): GAP (o Flatten) + una o dos capas densas + salida con tantas neuronas como clases.
flowchart LR
    subgraph Backbone["Extractor de features (backbone)"]
        A["Imagen<br/>3×32×32"] --> B["Conv 3→32<br/>+ BN + ReLU<br/>32×32×32"]
        B --> C["MaxPool 2×2<br/>32×16×16"]
        C --> D["Conv 32→64<br/>+ BN + ReLU<br/>64×16×16"]
        D --> E["MaxPool 2×2<br/>64×8×8"]
        E --> F["Conv 64→128<br/>+ BN + ReLU<br/>128×8×8"]
        F --> G["MaxPool 2×2<br/>128×4×4"]
    end
    subgraph Head["Clasificador (head)"]
        G --> H["Global Avg Pool<br/>128×1×1"]
        H --> I["Flatten<br/>128"]
        I --> J["Linear 128→10<br/>logits"]
    end
    J --> K["Softmax<br/>(en la loss)"]

4.2 La jerarquía aprendida: la promesa del capítulo 1, cumplida

En el capítulo 1 te prometimos que la profundidad permite aprender jerarquías de representaciones. En una CNN esa jerarquía es literal y visualizable (Zeiler & Fergus lo demostraron en 2013 inspeccionando qué activa cada capa):

Profundidad Qué detectan los filtros Campo receptivo típico Ejemplo
Capas 1–2 Bordes, colores, gradientes 3–7 píxeles "línea diagonal", "mancha roja"
Capas intermedias Texturas, motivos, esquinas 15–50 píxeles "pelaje", "rejilla", "ojo genérico"
Capas profundas Partes de objetos 50–150 píxeles "rueda", "cara de perro", "ala"
Últimas capas Objetos / conceptos completos Toda la imagen "coche", "golden retriever"

El mecanismo es el campo receptivo creciente: una neurona de la capa 1 ve 3×3 píxeles; una de la capa 2 ve 3×3 neuronas de la capa 1, es decir, 5×5 píxeles de la imagen original; y así sucesivamente (el pooling acelera el crecimiento). Las neuronas profundas "ven" regiones enormes de la imagen a través de composición de patrones simples. Bordes → texturas → partes → objetos no es una metáfora: es lo que encuentras si visualizas los filtros de una red entrenada.

4.3 nn.Conv2d y nn.MaxPool2d parámetro a parámetro

nn.Conv2d(
    in_channels=3,     # canales de ENTRADA (3 para RGB, o los C_out de la capa anterior)
    out_channels=64,   # número de FILTROS = canales del mapa de salida
    kernel_size=3,     # tamaño de la ventana: 3 → 3×3 (puede ser tupla (3,5))
    stride=1,          # paso del deslizamiento (1 = denso; 2 = reduce a la mitad)
    padding=1,         # ceros añadidos por lado; k//2 con stride 1 → 'same'
                       # (también acepta padding='same' directamente)
    dilation=1,        # separación entre elementos del kernel (kernels "dilatados",
                       #   se usa en segmentación para agrandar el campo receptivo)
    groups=1,          # 1 = convolución normal; groups=in_channels = depthwise
                       #   (base de las redes móviles tipo MobileNet/EfficientNet)
    bias=True,         # sesgo por filtro; se pone False si sigue un BatchNorm
                       #   (BN ya tiene su propio desplazamiento β: el bias sería redundante)
)

nn.MaxPool2d(
    kernel_size=2,     # tamaño de la ventana de pooling
    stride=2,          # por defecto = kernel_size (ventanas sin solaparse)
    padding=0,         # normalmente 0
)
# MaxPool2d NO tiene parámetros aprendibles: es una operación fija.

4.4 Tabla de shapes: sigue el tensor (crítico)

La habilidad número 1 para no sufrir con las CNN es saber en todo momento la forma del tensor. Para la CNN que construiremos sobre CIFAR-10 (imágenes 3×32×32, 10 clases), batch de 64:

# Capa Entrada Salida Parámetros
0 (imagen) (64, 3, 32, 32)
1 Conv2d(3→32, k3, p1) (64, 3, 32, 32) (64, 32, 32, 32) 32·(3·9)+32 = 896
2 BatchNorm2d(32) + ReLU (64, 32, 32, 32) (64, 32, 32, 32) 64
3 MaxPool2d(2) (64, 32, 32, 32) (64, 32, 16, 16) 0
4 Conv2d(32→64, k3, p1) (64, 32, 16, 16) (64, 64, 16, 16) 64·(32·9)+64 = 18.496
5 BatchNorm2d(64) + ReLU (64, 64, 16, 16) (64, 64, 16, 16) 128
6 MaxPool2d(2) (64, 64, 16, 16) (64, 64, 8, 8) 0
7 Conv2d(64→128, k3, p1) (64, 64, 8, 8) (64, 128, 8, 8) 128·(64·9)+128 = 73.856
8 BatchNorm2d(128) + ReLU (64, 128, 8, 8) (64, 128, 8, 8) 256
9 MaxPool2d(2) (64, 128, 8, 8) (64, 128, 4, 4) 0
10 AdaptiveAvgPool2d(1) (64, 128, 4, 4) (64, 128, 1, 1) 0
11 Flatten (64, 128, 1, 1) (64, 128) 0
12 Linear(128→10) (64, 128) (64, 10) 128·10+10 = 1.290

Total: ~94.986 parámetros. Fíjate en el patrón: la resolución baja 32→16→8→4 mientras los canales suben 3→32→64→128. Y compara: toda la red tiene menos parámetros que el 0,07% de la primera capa del MLP de la sección 1.1.

Advertencia

el error más frecuente al escribir CNNs es el desajuste de shapes al pasar del

backbone al clasificador (mat1 and mat2 shapes cannot be multiplied). Antes de escribir el Linear final, imprime la forma del tensor con un print(x.shape) temporal en forward, o usa GAP que te independiza del tamaño espacial. Haz siempre la tabla de shapes en papel antes de codificar.

4.5 Implementación completa: CNN para CIFAR-10 en PyTorch

Reutilizamos las funciones train_epoch y evaluate que escribimos en el capítulo 3 (las repetimos aquí para que el ejemplo sea autocontenido).

import torch
import torch.nn as nn
from torch.utils.data import DataLoader
import torchvision
import torchvision.transforms as T

# ----------------------------------------------------------------------
# 0. Configuración: dispositivo y semilla reproducible
# ----------------------------------------------------------------------
torch.manual_seed(42)                                     # reproducibilidad
device = "cuda" if torch.cuda.is_available() else "cpu"   # GPU si hay

# ----------------------------------------------------------------------
# 1. Datos: CIFAR-10 (60.000 imágenes 3×32×32, 10 clases)
# ----------------------------------------------------------------------
# Medias y desviaciones POR CANAL calculadas sobre el train de CIFAR-10.
stats = ((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))

transform_train = T.Compose([
    T.RandomHorizontalFlip(),      # augmentation (sección 6): espejo aleatorio
    T.RandomCrop(32, padding=4),   # augmentation: recorte con desplazamiento
    T.ToTensor(),                  # PIL [0,255] → tensor float [0,1], forma C×H×W
    T.Normalize(*stats),           # estandariza cada canal: (x - media) / std
])
transform_val = T.Compose([
    T.ToTensor(),                  # ⚠ en validación NO hay augmentation:
    T.Normalize(*stats),           # solo conversión y normalización
])

train_ds = torchvision.datasets.CIFAR10("data", train=True,
                                        download=True, transform=transform_train)
val_ds   = torchvision.datasets.CIFAR10("data", train=False,
                                        download=True, transform=transform_val)

train_dl = DataLoader(train_ds, batch_size=64, shuffle=True,  num_workers=2)
val_dl   = DataLoader(val_ds,   batch_size=256, shuffle=False, num_workers=2)

# ----------------------------------------------------------------------
# 2. El modelo: exactamente la tabla de shapes de la sección 4.4
# ----------------------------------------------------------------------
class PequenaCNN(nn.Module):
    def __init__(self, n_clases: int = 10):
        super().__init__()

        # Bloque reutilizable: Conv → BatchNorm → ReLU
        # bias=False porque BatchNorm ya aporta su desplazamiento β.
        def bloque(c_in, c_out):
            return nn.Sequential(
                nn.Conv2d(c_in, c_out, kernel_size=3, padding=1, bias=False),
                nn.BatchNorm2d(c_out),   # estabiliza y acelera (capítulo 2)
                nn.ReLU(inplace=True),   # no linealidad; inplace ahorra memoria
            )

        self.backbone = nn.Sequential(
            bloque(3, 32),               # (B,3,32,32)  → (B,32,32,32)
            nn.MaxPool2d(2),             #              → (B,32,16,16)
            bloque(32, 64),              #              → (B,64,16,16)
            nn.MaxPool2d(2),             #              → (B,64,8,8)
            bloque(64, 128),             #              → (B,128,8,8)
            nn.MaxPool2d(2),             #              → (B,128,4,4)
        )
        self.head = nn.Sequential(
            nn.AdaptiveAvgPool2d(1),     # GAP: (B,128,4,4) → (B,128,1,1)
            nn.Flatten(),                #                  → (B,128)
            nn.Linear(128, n_clases),    #                  → (B,10) logits
        )

    def forward(self, x):
        x = self.backbone(x)   # extraer features jerárquicas
        return self.head(x)    # clasificar (logits SIN softmax:
                               # CrossEntropyLoss lo aplica internamente)

modelo = PequenaCNN().to(device)
print(sum(p.numel() for p in modelo.parameters()))   # ~95K parámetros

# ----------------------------------------------------------------------
# 3. Funciones de entrenamiento (las del capítulo 3, sin cambios)
# ----------------------------------------------------------------------
def train_epoch(modelo, dataloader, loss_fn, optimizer):
    modelo.train()                          # activa dropout/BN en modo train
    total_loss = 0.0
    for X, y in dataloader:
        X, y = X.to(device), y.to(device)   # mover el batch a la GPU
        logits = modelo(X)                  # forward
        loss = loss_fn(logits, y)           # loss del batch
        optimizer.zero_grad()               # limpiar gradientes anteriores
        loss.backward()                     # backward: calcular gradientes
        optimizer.step()                    # actualizar pesos
        total_loss += loss.item() * X.size(0)
    return total_loss / len(dataloader.dataset)

@torch.no_grad()                            # sin gradientes: evaluación
def evaluate(modelo, dataloader, loss_fn):
    modelo.eval()                           # BN usa estadísticas globales
    total_loss, aciertos = 0.0, 0
    for X, y in dataloader:
        X, y = X.to(device), y.to(device)
        logits = modelo(X)
        total_loss += loss_fn(logits, y).item() * X.size(0)
        aciertos += (logits.argmax(dim=1) == y).sum().item()
    n = len(dataloader.dataset)
    return total_loss / n, aciertos / n

# ----------------------------------------------------------------------
# 4. Entrenamiento
# ----------------------------------------------------------------------
loss_fn   = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(modelo.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)

for epoch in range(20):
    tr_loss = train_epoch(modelo, train_dl, loss_fn, optimizer)
    va_loss, va_acc = evaluate(modelo, val_dl, loss_fn)
    scheduler.step()                        # decaer el learning rate
    print(f"época {epoch+1:2d} | train {tr_loss:.4f} "
          f"| val {va_loss:.4f} | acc {va_acc:.2%}")

# Resultado esperado: ~82-85% de accuracy en CIFAR-10 en ~20 épocas.
# (Un MLP equivalente en parámetros se queda en ~55%. La arquitectura importa.)

Consejo profesional

antes de entrenar durante horas, haz el test del batch único:

entrena la red sobre UN solo batch durante 100 pasos. Si no consigue llegar a loss ≈ 0 (sobreajustarse a 64 imágenes), tienes un bug (shapes, normalización, lr, etiquetas desalineadas). Cuesta 30 segundos y ahorra tardes enteras.

Ejercicio rápido 3. Si cambias CIFAR-10 (3×32×32) por MNIST (1×28×28), ¿qué dos cosas hay que tocar en PequenaCNN y qué formas tendrá el tensor tras cada MaxPool?

Ver solución 1. La primera conv pasa de `bloque(3, 32)` a `bloque(1, 32)` (MNIST es escala de grises: 1 canal). 2. Nada más — gracias al GAP el clasificador no depende del tamaño espacial. Shapes: 28×28 → pool → 14×14 → pool → 7×7 → pool → 3×3 (28 no es divisible por 8; MaxPool trunca 7→3). Luego GAP: (B,128,3,3) → (B,128). Si hubiéramos usado `Flatten` directo con un `Linear(128*4*4, 10)` calculado para CIFAR, la red habría **reventado** con MNIST. GAP = robustez.

5. Arquitecturas que hicieron historia

Cada arquitectura de esta sección enseñó al campo una lección que sigue vigente. Conocerlas no es arqueología: es entender por qué las redes actuales son como son.

5.1 LeNet-5 (1998) — la prueba de concepto

  • Idea clave: conv → pool → conv → pool → densas. El patrón canónico, hace más de 25 años.
  • Por qué importó: Yann LeCun demostró que una red entrenada con backpropagation podía leer dígitos manuscritos con calidad comercial. Llegó a procesar un porcentaje significativo de los cheques bancarios de EE. UU.
  • Qué aprendió el campo: las CNN funcionan... pero en 1998 no había ni datos ni GPUs para escalarlas. La idea hibernó una década.

5.2 AlexNet (2012) — el big bang

  • Idea clave: la misma receta de LeNet, pero a lo grande: 8 capas, 60M de parámetros, ReLU en vez de sigmoides, dropout, data augmentation y —decisivo— entrenada en 2 GPUs sobre 1,2 millones de imágenes de ImageNet.
  • Por qué importó: en el concurso ImageNet 2012 logró un 15,3% de error top-5 cuando el segundo clasificado (métodos clásicos de features manuales) se quedó en 26,2%. Once puntos de golpe: no fue una mejora, fue una demolición. Como vimos en el módulo 01, este momento marca el inicio de la era moderna del deep learning: en dos años, todo el campo de visión abandonó las features manuales.
  • Qué aprendió el campo: datos masivos + GPU + ReLU. La receta del deep learning moderno.

5.3 VGG (2014) — la elegancia de la profundidad

  • Idea clave: simplicidad radical. Nada de kernels 11×11 o 5×5 heterogéneos como AlexNet: solo kernels 3×3, apilados. Dos convs 3×3 encadenadas "ven" 5×5; tres ven 7×7 — con menos parámetros y más no-linealidades intermedias que un kernel grande. 16–19 capas.
  • Por qué importó: demostró que la profundidad regular y homogénea era el camino, y su diseño limpio la convirtió en el backbone favorito para transfer learning durante años.
  • Qué aprendió el campo: 3×3 es el kernel universal (lo sigue siendo). Y también: 138M de parámetros (¡120M en las capas densas finales!) es demasiado → motivó el GAP.

5.4 ResNet (2015) — a fondo: la skip connection

El problema de la degradación. Tras VGG, la lógica decía: si 19 capas van bien, 50 irán mejor. Pero al entrenar redes muy profundas ocurría algo desconcertante: una red de 56 capas tenía peor error de entrenamiento que una de 20. No era sobreajuste (¡el error de train era peor!): era que el optimizador no conseguía entrenar la red profunda. Esto es la degradación.

Lo paradójico: una red de 56 capas debería poder ser al menos tan buena como una de 20 — le bastaría con copiar las 20 capas buenas y hacer que las otras 36 fueran la identidad (no tocar nada). Pero resulta que para una pila de capas no lineales, aprender la identidad es difícil: tienen que ajustar finamente todos sus pesos para que la transformación completa deje la entrada intacta.

La solución de Kaiming He y su equipo: no pidas a las capas que aprendan la transformación completa; pídeles que aprendan solo la corrección respecto a la identidad.

flowchart TB
    X["entrada x"] --> C1["Conv 3×3 + BN + ReLU"]
    C1 --> C2["Conv 3×3 + BN"]
    X -.->|"skip connection<br/>(identidad: copia x tal cual)"|S(("+"))
    C2 -->|"F(x): el residuo"|S
    S --> R["ReLU"]
    R --> Y["salida = ReLU(F(x) + x )"]

En lugar de que el bloque calcule la salida deseada H(x), calcula el residuo F(x) = H(x) − x, y la salida es F(x) + x. Consecuencias:

  1. La identidad es gratis. Si el bloque no aporta nada útil, basta con que aprenda F(x) = 0 (empujar los pesos a cero, lo más fácil del mundo para un optimizador con weight decay). Añadir capas ya nunca puede empeorar: en el peor caso, se "apagan".
  2. El gradiente fluye directo. En el backward, la suma reparte el gradiente por las dos ramas: por la rama de convoluciones y por la skip, sin atenuación alguna. Existe una autopista de gradiente desde la loss hasta la primera capa, saltándose las no-linealidades. Adiós al gradiente que se desvanece con la profundidad (el problema que estudiamos en el capítulo 2).
  3. Refinamiento incremental. La red deja de ser "una transformación gigante" y pasa a ser "una secuencia de pequeñas correcciones sobre la representación". Cada bloque pule un poco más.

Resultado: ResNet-152 (¡152 capas!) ganó ImageNet 2015 con un error top-5 de 3,57% — por debajo del error humano estimado (~5%) en esa tarea. Y se han entrenado ResNets de más de 1000 capas.

Nota — el eco en los Transformers

cuando llegues al capítulo 6 verás que cada bloque de un

Transformer (y por tanto de GPT, Claude, Llama...) tiene la forma x = x + Atención(x) y x = x + MLP(x). Son conexiones residuales. La lección de ResNet —"deja pasar la identidad y aprende solo correcciones"— es posiblemente la idea arquitectónica más influyente del deep learning: sin ella, los modelos de lenguaje de 100+ capas de hoy no se podrían entrenar.

5.5 EfficientNet (2019) — mención

Pregunta: si tienes más cómputo, ¿lo gastas en más capas (profundidad), más canales (anchura) o más resolución de entrada? EfficientNet respondió con el escalado compuesto: escalar las tres cosas a la vez con proporciones fijas encontradas empíricamente, sobre una base diseñada por búsqueda neuronal de arquitecturas con convoluciones depthwise (baratas). Resultado: la mejor precisión por FLOP de su época. Lección: el equilibrio importa más que el tamaño bruto.

5.6 ViT — Vision Transformer (2020): la visión también se transformerizó

  • Idea clave: ¿y si tiramos las convoluciones por completo? ViT corta la imagen en parches de 16×16 píxeles, aplana cada parche y lo trata como un "token" — exactamente igual que las palabras de una frase — y procesa la secuencia de parches con un Transformer estándar (capítulo 6). La atención permite que cada parche "mire" a cualquier otro desde la primera capa: campo receptivo global inmediato, sin esperar a que crezca capa a capa.
  • El matiz honesto: ViT renuncia al sesgo inductivo de las CNN (localidad, invarianza a traslación), así que necesita muchísimos más datos para compensar. Con datasets pequeños, una CNN gana; con pre-entrenamiento gigante (JFT-300M, LAION...), ViT gana. Hoy, los grandes modelos de visión (CLIP, DINOv2, los codificadores visuales de GPT-4o o Claude) son Transformers o híbridos.
  • Qué aprendió el campo: con datos suficientes, la atención puede aprender lo que las convoluciones traían "de fábrica" — y más. Es el puente perfecto hacia el capítulo 6.

5.7 Tabla comparativa histórica

Arquitectura Año Capas Parámetros Error top-5 ImageNet Idea clave Lección para el campo
LeNet-5 1998 5 ~60K — (dígitos) conv→pool→densa Las CNN funcionan
AlexNet 2012 8 60M 15,3% Escala + GPU + ReLU + dropout Datos y cómputo lo cambian todo
VGG-16 2014 16 138M 7,3% Solo 3×3, profundidad homogénea La simplicidad regular escala
GoogLeNet 2014 22 7M 6,7% Módulos Inception multi-escala Eficiencia por diseño
ResNet-152 2015 152 60M 3,57% Skip connections La identidad gratis desbloquea la profundidad
EfficientNet-B7 2019 ~80 66M ~2,9% (top-5 est.) Escalado compuesto Equilibrio anchura/profundidad/resolución
ViT-L/16 2020 24 bloques 307M ~1,9% (con pre-entrenamiento masivo) Parches como tokens + atención Los datos pueden sustituir al sesgo inductivo

6. Data augmentation: más datos gratis

6.1 Por qué funciona

Un gato volteado horizontalmente sigue siendo un gato. Si tu red no lo sabe, tiene que aprenderlo gastando datos; si se lo enseñas generando variantes, obtienes dos ventajas:

  1. Más datos "gratis": de cada imagen real derivas decenas de variantes plausibles. Es el regularizador más barato que existe (capítulo 2: menos sobreajuste).
  2. Invarianzas inyectadas: obligas a la red a ser robusta a las variaciones que declaras irrelevantes (posición, iluminación, espejo). Estás codificando conocimiento del dominio.

Advertencia

las transformaciones deben preservar la etiqueta. Un flip horizontal está

bien para gatos; es catastrófico para dígitos (un 6 volteado no es un 6) o para señales de tráfico ( vs ). Piensa siempre en tu dominio antes de copiar un pipeline de internet.

6.2 Los transforms esenciales de torchvision

Transform Qué hace Cuándo usarlo
RandomHorizontalFlip(p=0.5) Espejo horizontal aleatorio Objetos sin lateralidad (animales, defectos)
RandomCrop(32, padding=4) Rellena y recorta con desplazamiento aleatorio Invarianza a traslación pequeña
RandomResizedCrop(224) Recorta una zona aleatoria y la reescala Estándar en ImageNet: invarianza a escala/encuadre
RandomRotation(15) Rota ±15° Objetos que pueden aparecer inclinados
ColorJitter(0.4,0.4,0.4,0.1) Varía brillo/contraste/saturación/tono Robustez a iluminación (clave en industria)
ToTensor() PIL → tensor float [0,1], C×H×W Siempre (o ToImage+ToDtype en la API v2)
Normalize(mean, std) Estandariza por canal Siempre, al final del pipeline

Los valores de ImageNet que verás en todo el código del mundo:

T.Normalize(mean=[0.485, 0.456, 0.406],   # media de cada canal R, G, B
            std=[0.229, 0.224, 0.225])    # desviación típica de cada canal

¿De dónde salen? Son la media y desviación típica de cada canal calculadas sobre los 1,2M de imágenes de ImageNet (en escala [0,1]). Al restar la media y dividir por la std, cada canal queda centrado en 0 con varianza ~1, que es donde las redes entrenan a gusto (capítulo 2). Regla: si usas un modelo preentrenado en ImageNet, DEBES normalizar con estos valores exactos — el modelo aprendió a ver el mundo así; darle otra distribución es hablarle en otro idioma.

6.3 Pipeline completo: train vs validación

import torchvision.transforms as T

IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD  = [0.229, 0.224, 0.225]

# ------------------- TRAIN: con augmentation -------------------
transform_train = T.Compose([
    T.RandomResizedCrop(224, scale=(0.7, 1.0)),  # recorte aleatorio 70-100% y resize
    T.RandomHorizontalFlip(p=0.5),               # espejo con probabilidad 0.5
    T.RandomRotation(degrees=10),                # inclinación leve ±10°
    T.ColorJitter(brightness=0.3,                # ±30% de brillo
                  contrast=0.3,                  # ±30% de contraste
                  saturation=0.2),               # ±20% de saturación
    T.ToTensor(),                                # → tensor [0,1], C×H×W
    T.Normalize(IMAGENET_MEAN, IMAGENET_STD),    # estandarización ImageNet
])

# ---------------- VALIDACIÓN/TEST: determinista ----------------
transform_val = T.Compose([
    T.Resize(256),               # lado corto a 256 (conserva proporción)
    T.CenterCrop(224),           # recorte central FIJO de 224×224
    T.ToTensor(),
    T.Normalize(IMAGENET_MEAN, IMAGENET_STD),   # ⚠ MISMA normalización que train
])

Advertencia (el error clásico)

augmentation SOLO en train. Si aplicas transformaciones

aleatorias en validación, cada evaluación da un número distinto y no puedes comparar épocas ni modelos; además estás midiendo la red sobre datos distorsionados que no representan producción. La Normalize (y el resize determinista), en cambio, van en ambos pipelines: no son augmentation, son preprocesado.

6.4 Técnicas modernas (mención)

  • Mixup: mezcla dos imágenes píxel a píxel (0.7·gato + 0.3·perro) y mezcla igualmente sus etiquetas. Suaviza las fronteras de decisión y calibra la confianza.
  • Cutout / Random Erasing: borra un rectángulo aleatorio de la imagen. Obliga a la red a no depender de una sola región (si tapas un ojo, que mire las orejas).
  • CutMix: pega un recorte de una imagen sobre otra, mezclando etiquetas proporcionalmente.
  • RandAugment / AutoAugment: políticas de augmentation encontradas automáticamente; en torchvision: T.RandAugment(). Buen valor por defecto cuando no quieres pensar.

Ejercicio rápido 4. Trabajas con radiografías de tórax para detectar neumonía. ¿Cuáles de estos transforms usarías y cuáles NO? RandomHorizontalFlip, RandomRotation(5), ColorJitter(brightness=0.2), RandomVerticalFlip, RandomResizedCrop(scale=(0.3,1.0)).

Ver solución - `RandomRotation(5)` — pequeñas inclinaciones del paciente son realistas. - `ColorJitter(brightness=0.2)` — la exposición del equipo varía; robustez útil. - `RandomHorizontalFlip` discutible — invierte la posición del corazón (¡situs inversus artificial!). Muchos equipos lo evitan en imagen médica; otros lo aceptan. Decisión con el experto de dominio. - `RandomVerticalFlip` — una radiografía boca abajo no existe en producción. - `RandomResizedCrop(scale=(0.3,1.0))` con ese rango — recortar el 30% podría eliminar la región con la patología, corrompiendo la etiqueta. Con `scale=(0.9,1.0)` sería aceptable. Moraleja: la augmentation es **conocimiento del dominio codificado**, no una receta universal.

7. Transfer learning en visión

Esta es, con diferencia, la sección más rentable del capítulo para tu carrera profesional. En la industria casi nunca se entrena una red de visión desde cero.

7.1 Por qué funciona

Las primeras capas de una CNN entrenada en ImageNet detectan bordes, colores y texturas (sección 4.2). Esos patrones son universales: sirven igual para clasificar perros, tumores, tornillos defectuosos o carnés de identidad. Solo las últimas capas son específicas de "las 1000 clases de ImageNet". Entonces:

Toma una red ya entrenada con 1,2M de imágenes, quédate con su extractor de features (millones de dólares de cómputo y conocimiento visual destilado, gratis) y adapta solo la parte final a TU problema.

Beneficios medibles: necesitas 10-100× menos datos, entrenas en minutos en vez de días, y casi siempre obtienes mejor precisión final que desde cero.

7.2 Las dos estrategias

Estrategia A — Feature extraction (extracción de características): congelas TODO el backbone (requires_grad=False) y entrenas únicamente una cabeza nueva (la capa densa final). El backbone actúa como una función fija imagen→vector de 512/2048 features.

  • Rapidísimo (solo se calculan gradientes de la cabeza), imposible de "romper" el conocimiento preentrenado, ideal con pocos datos.

Estrategia B — Fine-tuning (ajuste fino): primero entrenas la cabeza (como en A) y después descongelas parte o todo el backbone y sigues entrenando con un learning rate bajo (10-100× menor que el de la cabeza).

  • Más potente: las features se adaptan a tu dominio. Más peligroso: con lr alto o pocos datos, destruyes los pesos preentrenados ("catastrophic forgetting") y sobreajustas.

7.3 Tabla de decisión: qué estrategia según tus datos

Tus datos Similitud con ImageNet Estrategia recomendada
Pocos (<1K/clase) Similar (fotos naturales) Feature extraction — no toques el backbone
Pocos (<1K/clase) Distinto (médicas, térmicas, radar) Feature extraction desde capas intermedias + augmentation fuerte; considerar backbones autosupervisados (DINOv2)
Muchos (>10K/clase) Similar Fine-tuning completo con lr bajo — máximo rendimiento
Muchos (>10K/clase) Distinto Fine-tuning completo (o largo); desde cero solo se justifica con datos masivos

Nota

"similar a ImageNet" = fotografías naturales RGB de objetos cotidianos. Imágenes

médicas, satelitales, industriales de alta resolución o espectrales son "distintas": las capas tempranas (bordes) siguen sirviendo, pero las profundas ("orejas de perro") aportan menos.

7.4 Código completo, línea por línea

import torch
import torch.nn as nn
from torchvision import models

device = "cuda" if torch.cuda.is_available() else "cpu"
N_CLASES = 4        # nuestro problema: 4 tipos de defecto, por ejemplo

# ----------------------------------------------------------------------
# PASO 1 — Cargar ResNet-18 con pesos preentrenados en ImageNet
# ----------------------------------------------------------------------
pesos = models.ResNet18_Weights.IMAGENET1K_V1    # objeto de pesos (API moderna)
modelo = models.resnet18(weights=pesos)          # descarga y carga los pesos

# Bonus: el objeto de pesos INCLUYE el preprocesado correcto (resize,
# crop y Normalize con los valores de ImageNet). Úsalo para validación:
preprocess_val = pesos.transforms()

# ----------------------------------------------------------------------
# PASO 2 — Congelar TODO el backbone (estrategia A)
# ----------------------------------------------------------------------
for param in modelo.parameters():
    param.requires_grad = False      # sin gradiente → sin actualización:
                                     # el conocimiento de ImageNet queda intacto

# ----------------------------------------------------------------------
# PASO 3 — Reemplazar la cabeza por una nueva para NUESTRAS clases
# ----------------------------------------------------------------------
# En ResNet la capa final se llama .fc: Linear(512, 1000) para ImageNet.
n_features = modelo.fc.in_features          # 512 en ResNet-18
modelo.fc = nn.Linear(n_features, N_CLASES) # capa NUEVA: Linear(512, 4)
# ⚠ Las capas recién creadas nacen con requires_grad=True por defecto:
# la cabeza SÍ se entrenará. Es la única parte entrenable ahora mismo.

modelo = modelo.to(device)

# Verificación: ¿cuántos parámetros son entrenables?
entrenables = sum(p.numel() for p in modelo.parameters() if p.requires_grad)
total       = sum(p.numel() for p in modelo.parameters())
print(f"Entrenables: {entrenables:,} de {total:,}")   # 2.052 de 11.178.564

# ----------------------------------------------------------------------
# PASO 4 — Entrenar SOLO la cabeza (rápido: pocas épocas bastan)
# ----------------------------------------------------------------------
loss_fn = nn.CrossEntropyLoss()
# Pasamos al optimizador SOLO los parámetros entrenables:
optimizer = torch.optim.AdamW(
    (p for p in modelo.parameters() if p.requires_grad),
    lr=1e-3,                        # lr "normal": la cabeza parte de cero
)

for epoch in range(5):                                   # 5 épocas suelen bastar
    tr = train_epoch(modelo, train_dl, loss_fn, optimizer)   # funciones cap. 3
    va, acc = evaluate(modelo, val_dl, loss_fn)
    print(f"[cabeza] época {epoch+1} | val acc {acc:.2%}")

# ----------------------------------------------------------------------
# PASO 5 — (Opcional, estrategia B) Descongelar y afinar con lr BAJO
# ----------------------------------------------------------------------
for param in modelo.parameters():
    param.requires_grad = True       # ahora TODO es entrenable

# Learning rates DIFERENCIADOS: el backbone se mueve despacio (no queremos
# destruir ImageNet), la cabeza puede moverse más rápido:
optimizer = torch.optim.AdamW([
    {"params": (p for n, p in modelo.named_parameters()
                if not n.startswith("fc")), "lr": 1e-5},   # backbone: suave
    {"params": modelo.fc.parameters(),      "lr": 1e-4},   # cabeza: 10× más
], weight_decay=1e-4)

for epoch in range(10):
    tr = train_epoch(modelo, train_dl, loss_fn, optimizer)
    va, acc = evaluate(modelo, val_dl, loss_fn)
    print(f"[fine-tune] época {epoch+1} | val acc {acc:.2%}")

# Patrón profesional completo: congelar → entrenar cabeza → descongelar
# → afinar todo con lr bajo. Dos fases, mejor resultado que cualquiera sola.

Consejo profesional — LA regla

**NUNCA entrenes una red de visión desde cero si existe un

modelo preentrenado razonable. Y en 2026 siempre existe: torchvision trae decenas (models.list_models()), y timm (PyTorch Image Models) trae más de mil, incluidos ViT y backbones autosupervisados. Entrenar desde cero solo se justifica en dominios radicalmente distintos con datos masivos, o por investigación. Si un candidato en una entrevista propone "entrenar una CNN desde cero" para un problema con 2.000 imágenes, esa respuesta suspende.

Advertencia sobre BatchNorm

al hacer fine-tuning con batches pequeños (<16), las estadísticas

de BatchNorm se estiman mal y pueden degradar el modelo. Trucos habituales: mantener las capas BN en modo eval() durante el fine-tuning, o congelar sus parámetros. Es uno de los bugs silenciosos más comunes del transfer learning.


8. Más allá de clasificar: detección, segmentación, OCR y multimodal

Clasificar ("¿qué hay en la imagen?") es solo la primera pregunta. Panorama de las demás, con intuiciones — implementarlas queda para módulos posteriores.

8.1 Detección de objetos: YOLO

Pregunta: ¿qué hay y dónde está? (varias cajas + clase + confianza por imagen).

La idea de YOLO ("You Only Look Once"): los detectores antiguos proponían miles de regiones candidatas y clasificaban cada una (lento). YOLO lo hace en una sola pasada de la CNN: divide la imagen en una rejilla (p. ej. 13×13) y cada celda predice directamente, como salidas de la red, las coordenadas de las cajas que le "pertenecen", su clase y su confianza. Detección como regresión directa → tiempo real (30-100+ fps). Por eso YOLO domina la industria: cámaras de tráfico, retail, seguridad, robótica. Un paso de post-procesado (non-maximum suppression) fusiona cajas duplicadas.

8.2 Segmentación: U-Net

Pregunta: ¿qué es cada píxel? (máscara de clase con la forma exacta del objeto).

La idea de U-Net: una red con forma de U. La rama descendente (encoder) es una CNN normal que comprime: gana "qué" y pierde "dónde". La rama ascendente (decoder) vuelve a expandir hasta la resolución original. El truco genial: skip connections horizontales que copian los mapas de alta resolución del encoder directamente al decoder — así el decoder recupera el detalle espacial fino que la compresión destruyó (¿te suena? sí: otra vez conexiones residuales). Nacida para imagen médica con pocos datos, es el estándar en segmentación... y su arquitectura reapareció como columna vertebral de los modelos de difusión que generan imágenes.

8.3 OCR: leer texto en imágenes

El OCR moderno es un pipeline de dos etapas:

  1. Detección de texto: un modelo (tipo detector/segmentador) localiza las cajas donde hay texto.
  2. Reconocimiento: cada recorte pasa por un modelo que lo transcribe a caracteres (clásicamente CNN + RNN con pérdida CTC — verás las RNN en el capítulo 5 —; hoy, cada vez más, modelos de visión-lenguaje que "leen" directamente).

Librerías prácticas: Tesseract (clásico), EasyOCR, PaddleOCR, y las API multimodales (Claude/GPT leen documentos completos con estructura). Profundizaremos en el módulo 15-MULTIMODAL, donde además construirás el proyecto OCR de la academia: extracción estructurada de datos de facturas.

8.4 Modelos multimodales: cuándo NO entrenar una CNN

Desde 2023-2024, los grandes modelos (Claude, GPT-4o, Gemini) ven imágenes de serie: les mandas una foto por API y responden preguntas, describen, leen texto, cuentan objetos. Esto cambia la decisión de ingeniería fundamental: ¿llamo a una API multimodal o entreno mi propia CNN? Tabla de decisión honesta:

Criterio API multimodal (Claude/GPT) CNN/ViT propio
Tiempo hasta producción Horas (prompt + llamada) Semanas (datos, entrenamiento, despliegue)
Datos etiquetados necesarios 0 (zero-shot) o pocos ejemplos en el prompt Cientos-miles por clase
Coste por imagen ~0,001-0,01 € por llamada, para siempre ~0 marginal (infra propia amortizada)
Latencia 0,5-5 s (red + inferencia remota) 1-50 ms local
Volumen alto (millones/día) Prohibitivo en coste Ideal
Privacidad / datos sensibles Datos salen a un tercero (revisar compliance) Todo en tu infraestructura
Funcionamiento sin internet (edge, fábrica)
Tareas de conocimiento general (describir, leer, razonar) Excelente Solo lo entrenado
Tarea visual muy específica (defecto de 2 mm en TU pieza) Mediocre sin ejemplos Con datos, imbatible
Consistencia/determinismo Variable entre versiones del modelo Total (tú controlas el modelo)

Consejo profesional

la heurística de 2026: prototipa con la API multimodal (validas el

caso de uso en un día); si el volumen, la latencia o la privacidad lo exigen, destila a un modelo propio (a menudo usando la propia API para pre-etiquetar los datos de entrenamiento). Muchos proyectos se quedan felizmente en la fase API; los de alto volumen migran a CNN propia y ahorran órdenes de magnitud en coste unitario.


9. Ejemplo integrador: clasificador de defectos industriales

Juntemos TODO el capítulo en un caso realista: clasificar piezas industriales en 4 clases (correcta, arañazo, mancha, grieta). Como no puedes descargar el dataset de una fábrica, generamos imágenes sintéticas con NumPy — lo que además te enseña exactamente qué "firma visual" tiene cada defecto. (Alternativa perezosa: reetiquetar 4 clases de CIFAR-10 como proxy; el pipeline sería idéntico.)

import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader, random_split
import torchvision.transforms as T
from torchvision import models

torch.manual_seed(0); np.random.seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
CLASES = ["correcta", "arañazo", "mancha", "grieta"]

# ======================================================================
# 1. DATASET SINTÉTICO: piezas metálicas 64×64 con 4 estados
# ======================================================================
def generar_pieza(clase: int) -> np.ndarray:
    """Genera una imagen 64×64×3 float [0,1] de una 'pieza metálica'."""
    # Fondo metálico: gris con ruido gaussiano y gradiente de iluminación
    base = 0.55 + 0.05 * np.random.randn(64, 64)          # gris ruidoso
    grad = np.linspace(-0.05, 0.05, 64)[None, :]          # iluminación lateral
    img = np.clip(base + grad, 0, 1)

    if clase == 1:   # ARAÑAZO: línea fina clara casi recta
        x0, y0 = np.random.randint(5, 25, size=2)         # inicio aleatorio
        largo = np.random.randint(25, 38)                 # longitud del arañazo
        ang = np.random.uniform(0, np.pi)                 # orientación
        for t in range(largo):                            # trazamos la línea
            x = int(x0 + t * np.cos(ang)); y = int(y0 + t * np.sin(ang))
            if 0 <= x < 64 and 0 <= y < 64:
                img[y, x] = 0.95                          # píxel brillante

    elif clase == 2: # MANCHA: círculo difuso oscuro
        cx, cy = np.random.randint(15, 49, size=2)        # centro
        r = np.random.randint(4, 9)                       # radio
        yy, xx = np.ogrid[:64, :64]
        dist = np.sqrt((xx - cx)**2 + (yy - cy)**2)
        img = np.where(dist < r, img * 0.5, img)          # oscurecer el círculo
        img = np.clip(img + 0.02*np.random.randn(64,64), 0, 1)  # difuminar

    elif clase == 3: # GRIETA: línea oscura irregular (paseo aleatorio)
        x, y = np.random.randint(10, 54), 2               # empieza arriba
        while y < 62:                                     # baja serpenteando
            img[y, max(0, min(63, x))] = 0.1              # píxel muy oscuro
            img[y, max(0, min(63, x+1))] = 0.2            # grosor 2 px
            x += np.random.randint(-2, 3)                 # zigzag horizontal
            y += 1
    # clase 0 (correcta): solo el fondo metálico
    return np.stack([img, img, img], axis=-1).astype(np.float32)  # → RGB

class DefectosDataset(Dataset):
    """1200 piezas sintéticas, 300 por clase, generadas una vez en memoria."""
    def __init__(self, n_por_clase=300, transform=None):
        self.datos = [(generar_pieza(c), c)
                      for c in range(4) for _ in range(n_por_clase)]
        self.transform = transform
    def __len__(self):
        return len(self.datos)
    def __getitem__(self, i):
        img, etiqueta = self.datos[i]
        img = T.functional.to_pil_image(img)      # NumPy → PIL (para transforms)
        if self.transform:
            img = self.transform(img)
        return img, etiqueta

# ======================================================================
# 2. AUGMENTATION pensada para ESTE dominio (sección 6)
# ======================================================================
MEAN, STD = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]   # ImageNet (¡ResNet!)
transform_train = T.Compose([
    T.Resize(224),                        # ResNet espera ~224×224
    T.RandomHorizontalFlip(),             # un arañazo espejado sigue siendo arañazo
    T.RandomVerticalFlip(),               # la pieza no tiene "arriba" natural
    T.RandomRotation(20),                 # orientación de la pieza en la cinta
    T.ColorJitter(brightness=0.3, contrast=0.3),  # ¡robustez a iluminación! (ver §10)
    T.ToTensor(),
    T.Normalize(MEAN, STD),
])
transform_val = T.Compose([
    T.Resize(224), T.ToTensor(), T.Normalize(MEAN, STD),   # determinista
])

# Split 80/20. Truco: dos datasets con transforms distintos + índices comunes
full = DefectosDataset(transform=None)
n_val = int(0.2 * len(full))
train_idx, val_idx = random_split(range(len(full)), [len(full)-n_val, n_val])

class ConTransform(Dataset):
    """Envuelve un subconjunto de índices aplicando el transform adecuado."""
    def __init__(self, base, indices, transform):
        self.base, self.indices, self.transform = base, list(indices), transform
    def __len__(self):
        return len(self.indices)
    def __getitem__(self, i):
        img, y = self.base.datos[self.indices[i]]
        return self.transform(T.functional.to_pil_image(img)), y

train_dl = DataLoader(ConTransform(full, train_idx, transform_train),
                      batch_size=32, shuffle=True)
val_dl   = DataLoader(ConTransform(full, val_idx, transform_val),
                      batch_size=64, shuffle=False)

# ======================================================================
# 3. TRANSFER LEARNING con ResNet-18 (sección 7, estrategia A→B)
# ======================================================================
modelo = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
for p in modelo.parameters():
    p.requires_grad = False                        # congelar backbone
modelo.fc = nn.Linear(modelo.fc.in_features, 4)   # cabeza nueva: 4 defectos
modelo = modelo.to(device)

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(modelo.fc.parameters(), lr=1e-3)

for epoch in range(5):                             # fase 1: solo la cabeza
    tr = train_epoch(modelo, train_dl, loss_fn, optimizer)
    va, acc = evaluate(modelo, val_dl, loss_fn)
    print(f"[fase 1] época {epoch+1} | val acc {acc:.2%}")

for p in modelo.parameters():                      # fase 2: fine-tuning suave
    p.requires_grad = True
optimizer = torch.optim.AdamW(modelo.parameters(), lr=1e-5)
for epoch in range(5):
    tr = train_epoch(modelo, train_dl, loss_fn, optimizer)
    va, acc = evaluate(modelo, val_dl, loss_fn)
    print(f"[fase 2] época {epoch+1} | val acc {acc:.2%}")

# ======================================================================
# 4. MÉTRICAS POR CLASE: matriz de confusión (puente al módulo 02)
# ======================================================================
@torch.no_grad()
def matriz_confusion(modelo, dataloader, n_clases=4):
    modelo.eval()
    M = torch.zeros(n_clases, n_clases, dtype=torch.long)
    for X, y in dataloader:
        pred = modelo(X.to(device)).argmax(dim=1).cpu()
        for real, p in zip(y, pred):
            M[real, p] += 1        # filas = clase real, columnas = predicha
    return M

M = matriz_confusion(modelo, val_dl)
print("        pred:", "  ".join(f"{c[:7]:>8}" for c in CLASES))
for i, fila in enumerate(M):
    print(f"real {CLASES[i][:7]:>8}:", "  ".join(f"{v:8d}" for v in fila))

# Recall por clase: de las piezas REALMENTE defectuosas, ¿cuántas cazamos?
for i, c in enumerate(CLASES):
    recall = M[i, i].item() / M[i].sum().item()
    print(f"recall {c}: {recall:.2%}")

# ======================================================================
# 5. ANÁLISIS DE ERRORES: MIRA las imágenes mal clasificadas
# ======================================================================
@torch.no_grad()
def recolectar_errores(modelo, dataloader, max_errores=16):
    """Devuelve (imagen, real, predicha, confianza) de los fallos."""
    modelo.eval(); errores = []
    for X, y in dataloader:
        probs = torch.softmax(modelo(X.to(device)), dim=1).cpu()
        pred = probs.argmax(dim=1)
        for j in range(len(y)):
            if pred[j] != y[j]:                    # ¡fallo!
                errores.append((X[j], y[j].item(),
                                pred[j].item(), probs[j].max().item()))
            if len(errores) >= max_errores:
                return errores
    return errores

errores = recolectar_errores(modelo, val_dl)
for _, real, pred, conf in errores:
    print(f"real={CLASES[real]:8s} pred={CLASES[pred]:8s} conf={conf:.2f}")
# Patrón típico que descubrirás MIRANDO las imágenes: grietas finas confundidas
# con arañazos (ambas son líneas). Solución: más datos de esa frontera,
# o augmentation que exagere la diferencia (contraste), o fusionar clases
# si el negocio no las distingue. El análisis de errores DECIDE el siguiente paso.

# ======================================================================
# 6. DECISIÓN DE UMBRAL para la línea de producción
# ======================================================================
# Política: dejar pasar una pieza defectuosa cuesta 50€ (llega al cliente);
# revisar a mano una buena cuesta 2€. NO usamos el argmax a secas:
# solo damos por BUENA una pieza si P(correcta) supera un umbral alto.
@torch.no_grad()
def evaluar_umbral(modelo, dataloader, umbral):
    modelo.eval()
    fugas, falsas_alarmas, total = 0, 0, 0
    for X, y in dataloader:
        p_correcta = torch.softmax(modelo(X.to(device)), dim=1)[:, 0].cpu()
        acepta = p_correcta >= umbral            # pieza declarada BUENA
        fugas += ((y != 0) & acepta).sum().item()          # defecto que pasa
        falsas_alarmas += ((y == 0) & ~acepta).sum().item() # buena a revisión
        total += len(y)
    coste = fugas * 50 + falsas_alarmas * 2
    return fugas, falsas_alarmas, coste

for u in [0.5, 0.7, 0.9, 0.95, 0.99]:
    f, fa, c = evaluar_umbral(modelo, val_dl, u)
    print(f"umbral {u:.2f} | fugas {f:3d} | a revisión {fa:3d} | coste {c:5d}€")
# El umbral óptimo NO es 0.5: con costes asimétricos (50€ vs 2€) conviene
# ser exigente (p. ej. 0.95) y mandar los casos dudosos a revisión humana.
# Esto conecta con las curvas precision-recall del módulo 02 y es EXACTAMENTE
# el diseño del caso empresarial que viene a continuación.

Consejo profesional

el paso 5 (mirar los errores uno a uno) es la técnica que separa a los

profesionales de los aficionados. Las métricas agregadas te dicen cuánto fallas; las imágenes mal clasificadas te dicen por qué — etiquetas erróneas, clases ambiguas, condiciones de captura no representadas. Diez minutos mirando errores valen más que diez horas probando hiperparámetros.


10. Caso empresarial: control de calidad visual en fábrica

Caso empresarial

MetalPro S.L., fabricante de componentes de aluminio para automoción,

produce 40.000 piezas/día en dos turnos.

Situación de partida (2024). La inspección final la hacían 2 inspectores por turno mirando piezas en la cinta: cada uno revisaba ~2 piezas/minuto con fatiga creciente. Auditoría interna: 8% de defectos se escapaban al cliente (con penalizaciones contractuales de ~180.000 €/año) y un 6% de piezas buenas se rechazaban por precaución. Contratar más inspectores no escalaba.

El proyecto (6 meses, equipo de 2 personas + 1 integrador).

  1. Captura: cámara industrial + iluminación LED controlada sobre la cinta; 1 foto por pieza.
  2. Datos: 12.000 imágenes etiquetadas por los propios inspectores (¡ellos eran los expertos de dominio!) en 4 clases, con doble etiquetado del 20% para medir acuerdo (kappa = 0,91).
  3. Modelo: exactamente la receta de la sección 9 — ResNet-18 preentrenada, augmentation con ColorJitter agresivo, fine-tuning en dos fases. Nada exótico. 2 días de experimentación.
  4. Despliegue: CNN + revisión humana de casos dudosos. La red decide sola cuando P(correcta) > 0,95 o P(defecto) > 0,95; el 7% de piezas "dudosas" va a una pantalla donde UN inspector revisa solo esos casos. Los inspectores pasaron de mirar 40.000 piezas a ~2.800.

Cifras tras 6 meses en producción:

Métrica Antes (2 inspectores/turno) Después (CNN + revisión)
Defectos que llegan al cliente 8,0% 0,9%
Piezas buenas rechazadas 6,0% 1,5%
Piezas revisadas por humanos 100% 7%
Coste anual de penalizaciones ~180.000 € ~21.000 €
Tiempo de inspección por pieza ~30 s 80 ms (+ dudosas)
Personas en inspección 4 2 (reubicadas a etiquetado y auditoría)

Los problemas reales (la parte que no sale en los tutoriales).

  • Mes 3: el modelo "se rompió" un lunes. La precisión cayó del 98% al 81% de un día para otro. ¿La causa? Mantenimiento había sustituido dos tubos LED de la estación de captura por otros de temperatura de color distinta. Las imágenes eran ligeramente más azuladas: suficiente para desplazar la distribución de entrada fuera de lo que la red conocía. Esto es data drift en visión — el equivalente visual del drift que estudiaste en el módulo 02. Solución en dos capas: (a) reentrenar con imágenes de la nueva iluminación + ColorJitter más agresivo; (b) instaurar un monitor de drift: estadísticas de brillo/color por hora y alarma si se desvían, más una tarjeta de calibración de color fotografiada cada mañana.
  • Nuevos defectos no contemplados: apareció un tipo de rebaba que no existía en el dataset. La red, obligada a elegir entre 4 clases, la clasificaba con confianza baja → gracias al diseño de umbral, esas piezas caían en "dudosas" y las cazaba el humano. Se etiquetaron 400 ejemplos y se reentrenó con una 5ª clase. El sistema de revisión humana no era un parche: era la red de seguridad que permitió evolucionar el modelo.
  • Proceso permanente: reentrenamiento trimestral con las piezas dudosas etiquetadas (las más informativas — active learning de facto), validación contra un conjunto de test congelado y auditado, y despliegue con rollback automático si la métrica cae.

Lecciones del caso: (1) el modelo es el 20% del proyecto; datos, umbrales, monitorización y proceso humano son el 80%; (2) diseña SIEMPRE el circuito de casos dudosos → humano; (3) el data drift no es teórico: es un tubo LED un lunes por la mañana.


11. Buenas prácticas

  • Empieza siempre con transfer learning. ResNet-18/50 o un ViT pequeño preentrenado; desde cero solo con justificación fuerte.
  • Haz la tabla de shapes en papel antes de escribir el modelo, y verifica con un print(x.shape) o torchinfo.summary(modelo, (1,3,224,224)).
  • Test del batch único antes de cualquier entrenamiento largo: sobreajusta 1 batch → si no llega a loss ~0, hay bug.
  • Augmentation adaptada al dominio y solo en train; Normalize idéntica en train y val, con los valores de ImageNet si el backbone es preentrenado.
  • GAP en vez de Flatten+densas gigantes para cerrar la red: menos parámetros, tamaño de entrada flexible.
  • Mira las imágenes mal clasificadas después de cada entrenamiento serio. Matriz de confusión por clase, no solo accuracy global.
  • Decide umbrales con los costes del negocio, no con el 0,5 por defecto; deja los casos dudosos a revisión humana.
  • Monitoriza drift en producción: estadísticas de las imágenes de entrada (brillo, color, nitidez) y de la distribución de confianzas del modelo.
  • Fija semillas y versiona datos + código + pesos para poder reproducir cualquier resultado.

12. Malas prácticas

  • Aplanar imágenes y usar un MLP para cualquier cosa mayor que MNIST (sección 1: 150M de parámetros para nada).
  • Aplicar augmentation en validación/test, o normalizar train y val con estadísticas distintas.
  • Fine-tuning con learning rate alto (1e-3) sobre todo el backbone: destruyes los pesos preentrenados en unas pocas iteraciones.
  • Copiar pipelines de augmentation sin pensar en el dominio (flip horizontal en dígitos o señales de tráfico).
  • Reportar solo accuracy global con clases desbalanceadas (99% de piezas buenas → un modelo que dice siempre "buena" tiene 99% de accuracy y es inútil).
  • Ignorar BatchNorm en fine-tuning con batches pequeños (estadísticas ruidosas que degradan).
  • Elegir el modelo por el benchmark de moda en lugar de por latencia/coste/datos de TU problema.
  • No mirar nunca los datos ni los errores: entrenar "a ciegas" contra una métrica.

13. Errores comunes

Error Síntoma Causa Solución
Shapes desajustadas backbone→head mat1 and mat2 shapes cannot be multiplied Linear dimensionado a mano mal calculado Tabla de shapes; usar nn.AdaptiveAvgPool2d(1)
Canales de entrada erróneos expected input to have 3 channels, but got 1 Imagen en gris con red RGB (o viceversa) Ajustar in_channels o convertir la imagen (convert("RGB"))
Olvidar model.eval() al evaluar Métricas de validación ruidosas/incoherentes BatchNorm/Dropout siguen en modo train model.eval() + torch.no_grad() en evaluación
Normalización inconsistente Preentrenado rinde peor que aleatorio No usar mean/std de ImageNet con backbone ImageNet weights.transforms() o Normalize con valores correctos
Augmentation en validación La accuracy de val "baila" entre evaluaciones Transforms aleatorios en el pipeline de val Pipeline determinista para val/test
lr alto en fine-tuning La accuracy se desploma al descongelar Gradientes grandes destruyen pesos preentrenados lr 10-100× menor para el backbone; entrenar cabeza primero
Sobreajuste evidente train 99%, val 70% Pocos datos, poca augmentation, modelo grande Más augmentation, weight decay, congelar más capas
Olvidar optimizer.zero_grad() Loss caótica que no baja Gradientes se acumulan entre batches zero_grad() en cada iteración (capítulo 3)
Tamaño de imagen incoherente Error de shape solo con algunas imágenes Dataset con resoluciones mixtas sin Resize Resize/RandomResizedCrop al inicio del pipeline
Fugas train→test Métricas de test irreales que caen en producción Duplicados o la misma pieza fotografiada en ambos splits Split por entidad (pieza/paciente), no por imagen

14. FAQ — Preguntas frecuentes

1. ¿Por qué kernels 3×3 y no 7×7 o 11×11? Dos 3×3 apilados cubren el mismo campo receptivo que un 5×5, y tres cubren un 7×7 — con menos parámetros (27 vs 49 por canal) y más no-linealidades intermedias (más expresividad). Es la lección de VGG y sigue vigente. Los kernels grandes solo sobreviven en la primera capa de algunas redes y en diseños modernos específicos (ConvNeXt usa 7×7 depthwise, que es barato).

2. ¿Cuándo debería usar padding same y cuándo valid? same (padding=k//2) dentro de la red para controlar la resolución explícitamente con pooling/stride en vez de perderla sin querer por los bordes. valid cuando reducir es intencional o en la última capa de pipelines tipo encoder. En la práctica: same en el 95% de los casos.

3. ¿Max pooling o convolución con stride 2 para reducir? Empate técnico en redes modernas. MaxPool: gratis en parámetros, invarianza local explícita, ideal en redes pequeñas. Conv stride 2: la reducción se aprende, común en ResNet y posteriores. No es la decisión que va a cambiar tu proyecto; el transfer learning y los datos sí.

4. ¿Cuántas imágenes necesito para entrenar un clasificador? Con transfer learning (feature extraction): desde ~50-100 por clase se obtienen resultados útiles; con 500-1000 por clase, resultados sólidos. Fine-tuning completo: miles por clase. Desde cero: decenas de miles. Y con una API multimodal: cero (zero-shot) — por eso es el mejor prototipo.

5. ¿Las CNN están muertas ahora que existen los ViT? No. Los ViT dominan cuando hay pre-entrenamiento masivo y presupuesto de cómputo; las CNN siguen ganando en datasets pequeños/medianos, en dispositivos edge (móviles, cámaras industriales) y en latencias exigentes. Además, arquitecturas como ConvNeXt demostraron que una CNN modernizada compite de tú a tú con ViT. En la industria de 2026 conviven; para el 90% de proyectos de visión "normales", una ResNet/EfficientNet preentrenada sigue siendo la opción sensata.

6. Mi modelo va genial en validación y fatal en producción. ¿Qué reviso primero? En este orden: (1) preprocesado idéntico — resize, orden de canales RGB/BGR, normalización, rango [0,1] vs [0,255]; (2) drift de captura — cámara, iluminación, encuadre distintos a los del dataset; (3) fuga de datos en el split (imágenes casi duplicadas entre train y val); (4) distribución de clases real vs la del dataset. El 80% de las veces es el punto 1 o el 2.

7. ¿Cómo sé qué está "mirando" mi CNN al decidir? Técnicas de atribución: Grad-CAM es la estándar — usa los gradientes de la clase respecto al último mapa de features para pintar un mapa de calor sobre la imagen. Si tu clasificador de perros "mira" el césped y no el perro, tienes un atajo espurio (shortcut learning) que fallará en producción. Herramientas: pytorch-grad-cam, Captum. Hazlo siempre antes de desplegar en dominios sensibles.

8. ¿Qué GPU/memoria necesito para este capítulo? Para CIFAR-10 y la ResNet-18 del ejemplo: cualquier GPU de 4+ GB, o Google Colab gratis, o incluso CPU (lento pero viable con feature extraction, donde solo entrena la cabeza). El fine-tuning completo de modelos grandes (ViT-L) ya pide 16+ GB. Truco de bajo recurso: batch pequeño + torch.cuda.amp (precisión mixta) reduce la memoria a la mitad.

9. ¿Puedo usar un modelo preentrenado en ImageNet para imágenes en escala de grises? Sí, con adaptación: o replicas el canal gris 3 veces (lo más simple y sorprendentemente efectivo), o reemplazas la primera conv por una de 1 canal inicializando sus pesos como la media de los 3 canales originales. Las features de bordes/texturas siguen sirviendo perfectamente.

10. ¿En qué se diferencia esto de lo que hace Claude cuando "ve" una imagen? Los modelos multimodales usan un codificador visual (típicamente un ViT) que convierte la imagen en una secuencia de embeddings de parches, que se insertan en el contexto del modelo de lenguaje como si fueran tokens. Es la idea de ViT (sección 5.6) + el puente visión-lenguaje que verás en el módulo 15-MULTIMODAL. La base conceptual —features jerárquicas de una imagen— es la que acabas de aprender.

15. Resumen del capítulo

  • Los MLP no escalan a imágenes: 150M de parámetros para una sola capa con 224×224×3, sin estructura espacial ni invarianza a traslación. La convolución resuelve las tres cosas con conectividad local + pesos compartidos.
  • Un kernel es un detector local de patrones que se desliza por la imagen produciendo un mapa de features. Tamaño de salida: ⌊(n + 2p − k)/s⌋ + 1. Un kernel sobre RGB es 3×3×3; una capa tiene muchos filtros → muchos canales de salida.
  • El pooling reduce resolución y aporta invarianza local; el Global Average Pooling cierra las redes modernas eliminando las capas densas gigantes.
  • Una CNN es [Conv→BN→ReLU→Pool]×n + GAP + Linear: la resolución baja, los canales suben, y la red aprende la jerarquía bordes → texturas → partes → objetos.
  • Historia = lecciones: AlexNet (escala+GPU), VGG (3×3 profundo), ResNet (skip connections: la identidad gratis que desbloqueó las redes de 100+ capas y que heredan los Transformers), EfficientNet (escalado equilibrado), ViT (parches como tokens: puente al capítulo 6).
  • Data augmentation = regularización + invarianzas del dominio; solo en train; Normalize con los valores de ImageNet si usas backbones preentrenados.
  • Transfer learning es la regla profesional: feature extraction con pocos datos, fine-tuning con lr bajo cuando hay más. Nunca desde cero si existe un preentrenado.
  • Más allá de clasificar: YOLO (detección en una pasada), U-Net (segmentación con skips), OCR (detectar + reconocer) y la decisión API multimodal vs modelo propio (coste/latencia/privacidad/volumen).
  • En producción mandan los umbrales con costes de negocio, la revisión humana de dudosos, el análisis de errores mirando imágenes y la vigilancia del data drift (un tubo LED puede tumbar tu modelo un lunes).

16. Bibliografía y recursos

  • Krizhevsky, A., Sutskever, I., Hinton, G. (2012). ImageNet Classification with Deep Convolutional Neural Networks (AlexNet). NeurIPS 2012. — El paper del big bang.
  • Simonyan, K., Zisserman, A. (2015). Very Deep Convolutional Networks for Large-Scale Image Recognition (VGG). ICLR 2015.
  • He, K., Zhang, X., Ren, S., Sun, J. (2016). Deep Residual Learning for Image Recognition (ResNet). CVPR 2016. — Probablemente el paper más citado del deep learning.
  • Dosovitskiy, A. et al. (2021). An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale (ViT). ICLR 2021.
  • Tan, M., Le, Q. (2019). EfficientNet: Rethinking Model Scaling for CNNs. ICML 2019.
  • LeCun, Y. et al. (1998). Gradient-Based Learning Applied to Document Recognition (LeNet-5).
  • Redmon, J. et al. (2016). You Only Look Once: Unified, Real-Time Object Detection (YOLO).
  • Ronneberger, O. et al. (2015). U-Net: Convolutional Networks for Biomedical Image Segmentation. MICCAI 2015.
  • Zeiler, M., Fergus, R. (2014). Visualizing and Understanding Convolutional Networks. — La visualización de la jerarquía bordes→partes→objetos.
  • CS231n — Convolutional Neural Networks for Visual Recognition (Stanford): https://cs231n.github.io/ — el curso de referencia; sus notas sobre convoluciones son insuperables.
  • Documentación de torchvision: https://pytorch.org/vision/stable/ — modelos preentrenados (torchvision.models), transforms (torchvision.transforms.v2) y datasets.
  • timm (PyTorch Image Models): https://github.com/huggingface/pytorch-image-models — más de 1000 backbones preentrenados listos para transfer learning.

Anterior Módulo Siguiente
Capítulo 3: PyTorch 03-DEEP-LEARNING Capítulo 5: Secuencias: RNNs y embeddings