Saltar a contenido

Capítulo 2: Backpropagation y el arte de entrenar redes

Módulo 03 — DEEP LEARNING · AI Master Academy Tiempo estimado de estudio: 4-6 horas · Nivel: intermedio Requisitos: haber completado el Capítulo 1 (MLP con forward pass en NumPy) y la regla de la cadena del módulo 01.


En el capítulo anterior construiste una red neuronal desde cero y la entrenaste con gradiente numérico: perturbar cada parámetro un poquito, medir cómo cambia la pérdida, y usar ese cociente como aproximación de la derivada. Funcionó... pero tardaba una eternidad. En este capítulo descubrirás el algoritmo que hizo posible el deep learning moderno: backpropagation. Y no nos quedaremos ahí: aprenderás a entrenar de verdad — optimizadores modernos, diagnóstico de curvas, inicialización, normalización, regularización — todo lo que separa a quien "ejecuta un script" de quien sabe entrenar redes.


Índice

  1. El problema: el gradiente numérico no escala
  2. Backpropagation a fondo
  3. 2.1 La regla de la cadena revisitada: una neurona, paso a paso
  4. 2.2 El grafo computacional
  5. 2.3 Backprop en el MLP completo: las 4 ecuaciones
  6. 2.4 Implementación completa en NumPy
  7. 2.5 Gradient checking: verificación profesional
  8. 2.6 Comparación de velocidad
  9. 2.7 La intuición final: repartir la culpa
  10. Descenso de gradiente en sus variantes
  11. Optimizadores modernos
  12. Problemas del entrenamiento profundo y sus curas
  13. Regularización en deep learning
  14. El recetario práctico del entrenamiento
  15. Caso empresarial: las tres semanas perdidas
  16. Buenas prácticas
  17. Malas prácticas
  18. Errores comunes
  19. FAQ
  20. Resumen
  21. Bibliografía

1. El problema: el gradiente numérico no escala

Recordemos qué hicimos en el capítulo 1. Para cada parámetro θ de la red calculábamos:

∂L/∂θ ≈ [L(θ + ε) − L(θ − ε)] / (2ε)

Esto se llama diferencia central y es una aproximación excelente de la derivada. El problema no es la precisión: es el coste. Cada evaluación de L requiere un forward pass completo por toda la red. Y necesitamos dos por parámetro (uno con θ + ε y otro con θ − ε).

Hagamos las cuentas para redes de distintos tamaños:

Red Parámetros Forwards por paso de entrenamiento Coste relativo vs. backprop
XOR (2-2-1) 9 18 ~9× más lento
Dos lunas (2-16-16-1) ~340 680 ~340× más lento
MNIST pequeño (784-128-10) ~101 000 202 000 ~100 000× más lento
ResNet-50 ~25 000 000 50 000 000 ~25 000 000× más lento
GPT pequeño ~1 000 000 000 2 000 000 000 astronómico

Concretemos el caso que pide la intuición: una red de 1 millón de parámetros donde un forward pass tarda 1 milisegundo. Un solo paso de gradiente numérico requiere:

2 forwards/parámetro × 1 000 000 parámetros × 1 ms = 2 000 000 ms ≈ 33 minutos POR PASO

Un entrenamiento típico necesita decenas de miles de pasos. Con gradiente numérico, entrenar esa red modesta llevaría más de un año. Con backpropagation, el mismo paso cuesta aproximadamente lo mismo que 2-3 forward passes en total (no por parámetro: en total). El mismo paso pasa de 33 minutos a ~3 milisegundos.

Nota

Esta es la observación clave que debes retener: backpropagation calcula TODOS los gradientes de la red — millones de ellos — en un único recorrido hacia atrás cuyo coste es comparable al de un forward pass. No es una aproximación: es el gradiente exacto, calculado de forma inteligente reutilizando cálculos intermedios.

¿Cómo es posible ese milagro? La respuesta es una idea que ya conoces: la regla de la cadena, aplicada con disciplina y sin repetir trabajo.


2. Backpropagation a fondo

2.1 La regla de la cadena revisitada: una neurona, paso a paso

Antes de atacar una red completa, vamos a destripar una sola neurona con números concretos. Si entiendes esta sección, entiendes backpropagation; el resto es aplicar lo mismo muchas veces.

Nuestra neurona:

entrada:  x = 2.0
peso:     w = 0.5
sesgo:    b = 0.1
objetivo: y = 1.0

Y su cálculo, descompuesto en operaciones elementales:

u = w · x                  (multiplicación)
z = u + b                  (suma → preactivación)
a = σ(z) = 1/(1+e^(−z))    (activación sigmoide)
L = ½ (a − y)²             (pérdida cuadrática)

Paso 1 — Forward pass (calculamos y GUARDAMOS cada valor intermedio):

u = 0.5 · 2.0            = 1.0
z = 1.0 + 0.1            = 1.1
a = σ(1.1)               = 0.7503
L = ½ (0.7503 − 1.0)²    = ½ (−0.2497)² = 0.0312

La red predice 0.7503 pero queríamos 1.0. La pregunta del millón: ¿cuánto debo mover w para reducir L? Es decir, ¿cuánto vale ∂L/∂w?

Paso 2 — La cadena de dependencias. w no afecta a L directamente: w afecta a u, que afecta a z, que afecta a a, que afecta a L. La regla de la cadena dice que las sensibilidades se multiplican a lo largo de la cadena:

∂L/∂w = (∂L/∂a) · (∂a/∂z) · (∂z/∂u) · (∂u/∂w)

Paso 3 — Backward pass (calculamos cada factor, de la pérdida hacia atrás):

Paso Derivada local Fórmula Valores Resultado
1 ∂L/∂a a − y 0.7503 − 1.0 −0.2497
2 ∂a/∂z σ(z)·(1−σ(z)) = a·(1−a) 0.7503 · 0.2497 0.1874
3 ∂z/∂u 1 (la suma pasa el gradiente tal cual) 1.0
4 ∂u/∂w x (deriv. de w·x respecto a w) 2.0
Total ∂L/∂w producto de todo −0.2497 · 0.1874 · 1.0 · 2.0 −0.0936

Y de regalo, casi gratis, el gradiente del sesgo (comparte los tres primeros factores, solo cambia el último: ∂z/∂b = 1):

∂L/∂b = (−0.2497) · (0.1874) · 1 = −0.0468

Lectura del resultado: ∂L/∂w = −0.0936 significa "si aumento w una pizca, la pérdida baja a un ritmo de 0.0936 por unidad". Como queremos bajar la pérdida, movemos w en dirección contraria al gradiente: w ← w − η·(−0.0936), es decir, subimos w. Tiene todo el sentido: la predicción (0.75) estaba por debajo del objetivo (1.0), así que empujar z hacia arriba acerca la predicción.

Sigamos el flujo del gradiente hacia atrás como si fuera un mensajero que va acumulando el producto:

El mensajero está en... Gradiente acumulado hasta aquí Interpretación
L 1.0 "La pérdida es sensible a sí misma con factor 1"
a −0.2497 "Subir a en 0.01 baja L en ~0.0025"
z −0.2497 · 0.1874 = −0.0468 "La sigmoide amortigua el mensaje (×0.1874)"
u −0.0468 · 1 = −0.0468 "La suma no altera el mensaje"
w −0.0468 · 2.0 = −0.0936 "La multiplicación escala por la entrada x"

Consejo profesional

Fíjate en el patrón del mensajero: cada nodo recibe el gradiente que viene de arriba (gradiente global hasta ese punto), lo multiplica por su derivada local (que solo depende de valores guardados en el forward) y lo pasa hacia atrás. Backpropagation entero es este patrón repetido: gradiente_saliente = gradiente_entrante × derivada_local. Cuando programes un backward a mano, o cuando depures uno, piensa siempre en esos dos factores.

Ejercicio rápido 1. Con los mismos valores, calcula ∂L/∂x (la sensibilidad de la pérdida respecto a la entrada). Pista: solo cambia el último factor de la cadena.

Ver solución La cadena es la misma hasta `u`, y `∂u/∂x = w = 0.5`:
∂L/∂x = (−0.2497) · (0.1874) · 1 · 0.5 = −0.0234
Esto puede parecer inútil (no podemos cambiar los datos), pero es **exactamente** lo que una capa anterior necesitaría: para la capa previa, `x` sería su activación de salida, y este valor le diría cómo ajustar sus pesos. Además, gradientes respecto a la entrada son la base de técnicas como los mapas de saliencia y los ejemplos adversarios.

2.2 El grafo computacional

La forma moderna de pensar en backprop (la que usan PyTorch, TensorFlow y JAX internamente) es el grafo computacional: cada operación es un nodo, cada valor una arista. Nuestra neurona es este grafo:

graph LR
    x["x = 2.0"] --> MUL["multiplicar"]
    w["w = 0.5<br/>(parámetro)"] --> MUL
    MUL -- "u = 1.0"--> ADD["sumar"]
    b["b = 0.1<br/>(parámetro)"] --> ADD
    ADD -- "z = 1.1"--> SIG["σ sigmoide"]
    SIG -- "a = 0.7503"--> LOSS["½(a−y)²"]
    y["y = 1.0<br/>(objetivo)"] --> LOSS
    LOSS -- "L = 0.0312"--> FIN(("Pérdida"))

    style w fill:#e8f4d4,stroke:#4a7c1f
    style b fill:#e8f4d4,stroke:#4a7c1f
    style FIN fill:#f4d4d4,stroke:#7c1f1f

Las dos reglas del grafo:

  1. Forward pass: se recorre el grafo de izquierda a derecha calculando cada nodo, y cada nodo guarda los valores que necesitará su derivada local (la sigmoide guarda a, la multiplicación guarda x y w). Esta memoria intermedia es la famosa "cache de activaciones" que llena la VRAM de tu GPU al entrenar.
  2. Backward pass: se recorre de derecha a izquierda. Cada nodo recibe ∂L/∂(su salida), lo multiplica por su derivada local y reparte el resultado a sus entradas. Si un valor alimenta a varios nodos, los gradientes que le llegan se suman (regla de la cadena multivariable).

Y así se ve el grafo de una red 2-2-1 completa — la que resolvió XOR en el capítulo 1. El forward va de izquierda a derecha; backprop recorre exactamente las mismas flechas en sentido contrario:

graph LR
    subgraph ENTRADA["Capa de entrada"]
        x1(("x₁"))
        x2(("x₂"))
    end
    subgraph OCULTA["Capa oculta (tanh)"]
        h1(("h₁"))
        h2(("h₂"))
    end
    subgraph SALIDA["Capa de salida (σ)"]
        o(("ŷ"))
    end
    L["L = BCE(ŷ, y)"]

    x1 -- "w₁₁"--> h1
    x1 -- "w₁₂"--> h2
    x2 -- "w₂₁"--> h1
    x2 -- "w₂₂"--> h2
    h1 -- "v₁"--> o
    h2 -- "v₂"--> o
    o --> L

    style L fill:#f4d4d4,stroke:#7c1f1f

Observa un detalle crucial: h₁ alimenta a la salida ŷ, así que en el backward h₁ recibe gradiente a través de v₁. Si hubiera dos neuronas de salida, h₁ recibiría dos contribuciones de gradiente y las sumaría. Esa suma de contribuciones es lo que, en forma matricial, se convierte en el producto W^T δ que veremos ahora.

Nota

El nombre técnico de este algoritmo es reverse-mode automatic differentiation (diferenciación automática en modo inverso). Es eficiente cuando hay muchas entradas (parámetros) y una sola salida (la pérdida) — exactamente el caso de las redes neuronales. Su hermano, el modo directo (forward-mode), es eficiente en el caso contrario. Por eso todos los frameworks de deep learning implementan el modo inverso.

2.3 Backprop en el MLP completo: las 4 ecuaciones

Generalicemos a una red de L capas. Notación (por capa l):

  • Z^l = A^(l−1) W^l + b^l — preactivación
  • A^l = g(Z^l) — activación (g = tanh, ReLU, sigmoide...)
  • A^0 = X — la entrada
  • δ^l ≡ ∂L/∂Z^l — el "error" de la capa l: cuánto cambia la pérdida si muevo la preactivación de esa capa

Convención de dimensiones (la que usaremos en el código): X tiene forma (m, n_entrada) con m ejemplos en filas, W^l tiene forma (n_entrada_capa, n_salida_capa) y b^l forma (1, n_salida_capa).

Backpropagation completo son cuatro ecuaciones. Memorízalas entendiéndolas; son el corazón de todo el deep learning:


Ecuación 1 — El error en la capa de salida:

δ^L = ∂L/∂A^L ⊙ g'(Z^L)

Intuición: ¿cuánto duele la preactivación final? Es la sensibilidad de la pérdida a la predicción (∂L/∂A^L) filtrada por la pendiente de la activación (g'). Si la activación está saturada (pendiente ≈ 0), el error no pasa: la neurona "no escucha".

Caso especial de oro: con sigmoide + entropía cruzada binaria (o softmax + entropía cruzada), las derivadas se cancelan elegantemente y queda δ^L = A^L − y. La predicción menos el objetivo. Limpio, estable numéricamente, y la razón por la que esa pareja pérdida-activación es canónica.

Dimensiones: δ^L tiene forma (m, n_salida) — un error por ejemplo y por neurona de salida.


Ecuación 2 — Propagar el error hacia atrás:

δ^l = (δ^(l+1) · (W^(l+1))^T) ⊙ g'(Z^l)

Intuición: el error de la capa l es el error de la capa siguiente redistribuido hacia atrás a través de los pesos. W^T invierte el sentido de las conexiones: si en el forward la neurona i influía en la neurona j con peso w_ij, en el backward la neurona i recibe una parte del error de j proporcional a w_ij. Cuanto más contribuiste, más culpa recibes. Después, ⊙ g'(Z^l) vuelve a filtrar por la pendiente de la activación local: una neurona saturada no recibe culpa porque moverla no habría cambiado nada.

Dimensiones: (m, n_l+1) · (n_l+1, n_l) = (m, n_l). Cuadra.


Ecuación 3 — Gradiente de los pesos:

∂L/∂W^l = (A^(l−1))^T · δ^l / m

Intuición: el gradiente de un peso w_ij es (activación de entrada i) × (error de salida j), promediado sobre el batch. Un peso es culpable si conecta una entrada que estaba activa con una neurona que se equivocó. Si la entrada era 0, ese peso no participó y su gradiente es 0 — no se le puede culpar de nada. Esta es la versión con gradientes de la vieja regla de Hebb: "las conexiones entre neuronas coactivas se refuerzan".

Dimensiones: (n_l−1, m) · (m, n_l) = (n_l−1, n_l) — exactamente la forma de W^l. El producto matricial además suma sobre los m ejemplos del batch gratis; por eso dividimos por m para promediar.


Ecuación 4 — Gradiente de los sesgos:

∂L/∂b^l = suma de δ^l sobre los ejemplos / m

Intuición: el sesgo entra directamente en z con coeficiente 1, así que su gradiente es simplemente el error de su neurona, promediado sobre el batch.

Dimensiones: de (m, n_l) colapsamos las filas → (1, n_l) — la forma de b^l.


El algoritmo completo, en pseudocódigo:

1. Forward: calcular y guardar Z^1..Z^L, A^1..A^L
2. δ^L = A^L − y                      (Ecuación 1, caso sigmoide+BCE)
3. Para l = L, L−1, ..., 1:
      dW^l = (A^(l−1))^T δ^l / m      (Ecuación 3)
      db^l = mean(δ^l, filas)          (Ecuación 4)
      si l > 1:
         δ^(l−1) = (δ^l W^l^T) ⊙ g'(Z^(l−1))   (Ecuación 2)
4. Actualizar: W^l ← W^l − η dW^l ;  b^l ← b^l − η db^l

Un solo recorrido hacia atrás, y salen todos los gradientes. El coste: dos productos matriciales por capa (uno para dW, otro para propagar δ), comparable al único producto por capa del forward. De ahí la regla empírica: un paso completo de entrenamiento cuesta ≈ 3 forward passes (1 forward + backward ≈ 2 forwards).

Ejercicio rápido 2. En una red con capas de tamaños 3 → 5 → 2, entrenando con un batch de m = 32 ejemplos, ¿qué dimensiones tienen δ^1, δ^2, dW^1 y dW^2?

Ver solución - `δ^2` (capa de salida): `(32, 2)` — error por ejemplo y neurona de salida. - `δ^1` (capa oculta): `(32, 5)`. - `dW^2`: `(5, 2)` — misma forma que `W^2`. - `dW^1`: `(3, 5)` — misma forma que `W^1`. Regla mnemotécnica profesional: **el gradiente de un tensor siempre tiene la misma forma que el tensor**. Si no cuadra, tienes un bug. Verificar formas es la primera línea de defensa al escribir un backward.

2.4 Implementación completa en NumPy

Vamos a reescribir la red del capítulo 1 con backward analítico. El código está pensado para leerse de arriba abajo; cada línea relevante está comentada.

import numpy as np
import time

np.random.seed(42)                       # Reproducibilidad: mismos números siempre

# ----------------------------------------------------------------------
# Activaciones y sus derivadas
# ----------------------------------------------------------------------
def sigmoide(z):
    # np.clip evita overflow en exp() con z muy negativo (estabilidad numérica)
    return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))

def deriv_tanh(a):
    # Truco: la derivada de tanh se expresa con su PROPIA salida: 1 - tanh(z)^2.
    # Por eso en el forward guardamos 'a' y no necesitamos recalcular tanh.
    return 1.0 - a ** 2

# ----------------------------------------------------------------------
# Pérdida: entropía cruzada binaria (BCE)
# ----------------------------------------------------------------------
def bce(y_pred, y_true, eps=1e-12):
    # eps evita log(0), que daría -inf y rompería el entrenamiento
    y_pred = np.clip(y_pred, eps, 1.0 - eps)
    return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))

# ----------------------------------------------------------------------
# El MLP con forward Y backward analítico
# ----------------------------------------------------------------------
class MLP:
    def __init__(self, dims, seed=42):
        """dims = [n_entrada, n_oculta_1, ..., n_salida], p.ej. [2, 8, 1]"""
        rng = np.random.default_rng(seed)
        self.n_capas = len(dims) - 1
        # Inicialización Xavier (sección 5.2): escala 1/sqrt(n_entradas_capa)
        self.W = [rng.normal(0.0, np.sqrt(1.0 / dims[i]), size=(dims[i], dims[i + 1]))
                  for i in range(self.n_capas)]
        # Los sesgos SÍ pueden empezar en cero (no hay problema de simetría con ellos)
        self.b = [np.zeros((1, dims[i + 1])) for i in range(self.n_capas)]

    def forward(self, X):
        """Forward pass. GUARDA activaciones: el backward las necesitará."""
        self.A = [X]                     # A[0] = entrada; A[l] = salida de la capa l
        A = X
        for l in range(self.n_capas):
            Z = A @ self.W[l] + self.b[l]           # preactivación: (m, n_out)
            if l == self.n_capas - 1:
                A = sigmoide(Z)          # última capa: sigmoide (clasif. binaria)
            else:
                A = np.tanh(Z)           # capas ocultas: tanh
            self.A.append(A)             # ← la "cache" del grafo computacional
        return A

    def backward(self, y):
        """Backward pass: las 4 ecuaciones. Devuelve (dW, db) de TODAS las capas."""
        m = y.shape[0]                   # tamaño del batch (para promediar)
        dW = [None] * self.n_capas
        db = [None] * self.n_capas

        # === Ecuación 1: error de salida ===
        # Sigmoide + BCE se simplifican a "predicción − objetivo". Dividimos por m
        # aquí una sola vez, así el promedio ya viaja dentro de delta.
        delta = (self.A[-1] - y) / m                  # forma: (m, n_salida)

        # Recorremos las capas HACIA ATRÁS: L-1, L-2, ..., 0
        for l in reversed(range(self.n_capas)):
            # === Ecuación 3: gradiente de los pesos ===
            # (entrada de la capa)^T @ (error de la capa) suma sobre el batch
            dW[l] = self.A[l].T @ delta               # forma: igual que W[l]

            # === Ecuación 4: gradiente de los sesgos ===
            db[l] = delta.sum(axis=0, keepdims=True)  # forma: igual que b[l]

            if l > 0:
                # === Ecuación 2: propagar el error a la capa anterior ===
                # Paso 1: repartir la culpa a través de los pesos (W^T)
                dA = delta @ self.W[l].T              # forma: (m, n_capa_anterior)
                # Paso 2: filtrar por la pendiente de tanh (usando la 'a' guardada)
                delta = dA * deriv_tanh(self.A[l])    # nuevo error, capa l-1
        return dW, db

    def paso_sgd(self, dW, db, lr):
        """Descenso de gradiente: mover cada parámetro CONTRA su gradiente."""
        for l in range(self.n_capas):
            self.W[l] -= lr * dW[l]
            self.b[l] -= lr * db[l]

Nota

Observa la simetría deliberada entre forward y backward: el forward hace A @ W y el backward hace delta @ W.T. El forward guarda A; el backward la consume. Cuando en el capítulo 3 llames a loss.backward() en PyTorch, esto —exactamente esto, con las mismas cuatro ecuaciones— es lo que ocurrirá bajo el capó, solo que construido automáticamente a partir del grafo.

Entrenamos con XOR (el problema que un perceptrón no puede resolver):

# Los 4 puntos de XOR: no separables linealmente
X_xor = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
y_xor = np.array([[0.], [1.], [1.], [0.]])

red = MLP([2, 4, 1], seed=42)            # 2 entradas, 4 ocultas, 1 salida

for epoca in range(3000):
    y_pred = red.forward(X_xor)          # 1. forward (guarda activaciones)
    dW, db = red.backward(y_xor)         # 2. backward (TODOS los gradientes)
    red.paso_sgd(dW, db, lr=0.7)         # 3. actualizar parámetros
    if epoca % 500 == 0:
        print(f"época {epoca:4d} | pérdida = {bce(y_pred, y_xor):.6f}")

print("\nPredicciones finales:")
for xi, yi, pi in zip(X_xor, y_xor, red.forward(X_xor)):
    print(f"  {xi} -> {pi[0]:.3f}  (objetivo {yi[0]:.0f})")

Salida típica:

época    0 | pérdida = 0.693147
época  500 | pérdida = 0.083326
época 1000 | pérdida = 0.021666
época 1500 | pérdida = 0.011713
época 2000 | pérdida = 0.007841
época 2500 | pérdida = 0.005834

Predicciones finales:
  [0. 0.] -> 0.006  (objetivo 0)
  [0. 1.] -> 0.994  (objetivo 1)
  [1. 0.] -> 0.994  (objetivo 1)
  [1. 1.] -> 0.007  (objetivo 0)

Y con las dos lunas, el dataset no lineal del capítulo 1 (lo generamos en NumPy puro para no depender de scikit-learn):

def dos_lunas(n=400, ruido=0.10, seed=0):
    """Dos medialunas entrelazadas: el 'hola mundo' de la no linealidad."""
    rng = np.random.default_rng(seed)
    n2 = n // 2
    t = rng.uniform(0, np.pi, n2)                       # ángulos aleatorios
    luna_arriba = np.column_stack([np.cos(t), np.sin(t)])
    luna_abajo  = np.column_stack([1 - np.cos(t), 0.5 - np.sin(t)])
    X = np.vstack([luna_arriba, luna_abajo])
    X += rng.normal(0, ruido, X.shape)                  # ruido gaussiano
    y = np.vstack([np.zeros((n2, 1)), np.ones((n2, 1))])
    return X, y

X_lunas, y_lunas = dos_lunas(n=400, seed=0)

# ⚠ Normalizar SIEMPRE la entrada (media 0, desviación 1) — sección 8 explica por qué
X_lunas = (X_lunas - X_lunas.mean(axis=0)) / X_lunas.std(axis=0)

red_lunas = MLP([2, 16, 16, 1], seed=42)                # red más profunda

for epoca in range(2000):
    y_pred = red_lunas.forward(X_lunas)
    dW, db = red_lunas.backward(y_lunas)
    red_lunas.paso_sgd(dW, db, lr=0.5)

acierto = np.mean((red_lunas.forward(X_lunas) > 0.5) == y_lunas)
print(f"Precisión en dos lunas: {acierto:.1%}")         # ≈ 99-100%

2.5 Gradient checking: verificación profesional

¿Cómo sabemos que nuestro backward no tiene un bug sutil? Un signo equivocado, una traspuesta olvidada, un A[l] donde iba A[l-1]... El backward puede estar casi bien y la red incluso puede casi aprender, lo que hace estos bugs diabólicamente difíciles de detectar a ojo.

La técnica profesional es gradient checking: comparar el gradiente analítico (backprop) con el numérico (diferencias centrales) en unos pocos parámetros. El gradiente numérico es lentísimo pero casi imposible de programar mal — es la referencia de verdad.

def comprobar_gradientes(red, X, y, n_muestras=25, eps=1e-6):
    """Compara backprop contra diferencias centrales. Imprime el error relativo."""
    red.forward(X)
    dW_ana, db_ana = red.backward(y)     # gradientes analíticos (a verificar)

    rng = np.random.default_rng(7)
    errores = []
    for l in range(red.n_capas):          # revisamos cada capa
        for _ in range(n_muestras):       # ... en varios pesos al azar
            i = rng.integers(red.W[l].shape[0])
            j = rng.integers(red.W[l].shape[1])

            original = red.W[l][i, j]     # guardamos el valor para restaurarlo

            red.W[l][i, j] = original + eps            # perturbar hacia arriba
            L_mas = bce(red.forward(X), y)
            red.W[l][i, j] = original - eps            # perturbar hacia abajo
            L_menos = bce(red.forward(X), y)
            red.W[l][i, j] = original                   # ¡restaurar!

            grad_num = (L_mas - L_menos) / (2 * eps)   # diferencia central
            grad_ana = dW_ana[l][i, j]

            # Error RELATIVO: robusto a la escala del gradiente
            err = abs(grad_ana - grad_num) / max(abs(grad_ana) + abs(grad_num), 1e-12)
            errores.append(err)

    peor = max(errores)
    print(f"Peor error relativo: {peor:.2e}")
    if peor < 1e-6:
        print("✅ PERFECTO: backward correcto.")
    elif peor < 1e-4:
        print("⚠ Aceptable (posible imprecisión de float64, revisa si usas float32).")
    else:
        print("❌ HAY UN BUG en el backward. No entrenes hasta arreglarlo.")

comprobar_gradientes(red_lunas, X_lunas, y_lunas)
# → Peor error relativo: 3.1e-09
# → ✅ PERFECTO: backward correcto.

Interpretación de los umbrales (regla clásica del curso CS231n):

Error relativo Diagnóstico
< 1e-7 Correcto con total seguridad
1e-7 a 1e-4 Probablemente bien; sospecha de precisión numérica o de no-diferenciabilidad (kink de ReLU en 0)
1e-4 a 1e-2 Incómodo: casi seguro que hay un bug
> 1e-2 Bug garantizado

Advertencia

Tres trampas clásicas del gradient checking: (1) olvida restaurar el parámetro tras perturbarlo y todos los checks posteriores quedan contaminados; (2) usar eps demasiado pequeño (1e-10) introduce error de redondeo en la resta — 1e-6 o 1e-7 es el punto dulce en float64; (3) hacerlo con dropout o cualquier fuente de aleatoriedad activa: fija el modo determinista antes de comprobar.

2.6 Comparación de velocidad

Medimos ambos métodos sobre la red de las dos lunas ([2, 16, 16, 1] → 337 parámetros):

def gradiente_numerico_completo(red, X, y, eps=1e-6):
    """El método del capítulo 1: 2 forwards POR CADA parámetro."""
    dW = [np.zeros_like(w) for w in red.W]
    for l in range(red.n_capas):
        for i in range(red.W[l].shape[0]):
            for j in range(red.W[l].shape[1]):
                orig = red.W[l][i, j]
                red.W[l][i, j] = orig + eps; L1 = bce(red.forward(X), y)
                red.W[l][i, j] = orig - eps; L2 = bce(red.forward(X), y)
                red.W[l][i, j] = orig
                dW[l][i, j] = (L1 - L2) / (2 * eps)
    return dW

# --- cronometramos ---
t0 = time.perf_counter()
for _ in range(10):
    red_lunas.forward(X_lunas)
    red_lunas.backward(y_lunas)
t_backprop = (time.perf_counter() - t0) / 10

t0 = time.perf_counter()
gradiente_numerico_completo(red_lunas, X_lunas, y_lunas)
t_numerico = time.perf_counter() - t0

print(f"Backprop:  {t_backprop*1000:8.2f} ms por paso")
print(f"Numérico:  {t_numerico*1000:8.2f} ms por paso")
print(f"Aceleración: {t_numerico/t_backprop:.0f}×")

Resultado típico en un portátil corriente:

Método Tiempo por paso Forwards equivalentes
Gradiente numérico ~180 ms 674 forwards (2 × 337 parámetros)
Backpropagation ~0.45 ms ~2-3 forwards
Aceleración ~400×

Y recuerda: esta red es minúscula. La aceleración crece linealmente con el número de parámetros. Para GPT-4 la diferencia no es 400×: es de nueve órdenes de magnitud. Sin backprop, el deep learning simplemente no existiría.

2.7 La intuición final: repartir la culpa

Si tuvieras que explicar backpropagation en un ascensor, sería esto:

Backpropagation es un sistema de reparto de culpas. La red comete un error en la salida. Ese error viaja hacia atrás, capa por capa, y en cada conexión se divide proporcionalmente a la responsabilidad: los pesos grandes (que influyeron mucho) reciben mucha culpa; los pequeños, poca; las neuronas saturadas (que no podían haber hecho nada distinto) quedan absueltas. Al final, cada uno de los millones de parámetros sabe exactamente qué fracción del error es suya — y en qué dirección moverse para enmendarla.

Las cuatro ecuaciones son la burocracia exacta de ese reparto:

  1. Ecuación 1 calcula la culpa inicial en la salida.
  2. Ecuación 2 la redistribuye hacia atrás a través de los pesos (W^T), filtrada por quién podía cambiar algo (g').
  3. Ecuaciones 3 y 4 convierten la culpa de cada neurona en instrucciones concretas para sus pesos y sesgos.

Y el descenso de gradiente es el acto de contrición: cada parámetro se corrige un poquito en la dirección que reduce su parte del error. Repetido decenas de miles de veces, ese proceso ciego y local produce redes que traducen idiomas y detectan tumores. Esa es la magia — y ahora sabes que no es magia.


3. Descenso de gradiente en sus variantes

Ya sabemos calcular gradientes. La siguiente pregunta: ¿con cuántos datos calculamos cada gradiente? Hay tres respuestas, y la historia de cuál ganó es instructiva.

Batch (o "full-batch") gradient descent. Usa el dataset entero para cada actualización. El gradiente es exacto (respecto al dataset), la trayectoria es suave y determinista... pero cada paso cuesta un pase por todos los datos. Con 10 millones de imágenes, das una actualización por cada 10 millones de forwards. Además, no cabe en memoria y, sorprendentemente, la falta de ruido perjudica: tiende a caer en mínimos "afilados" que generalizan peor.

Estocástico (SGD puro). Usa un solo ejemplo por actualización. Rapidísimo por paso y con ruido que ayuda a escapar de puntos de silla... pero el gradiente es tan ruidoso que la trayectoria zigzaguea violentamente, y desaprovecha por completo el paralelismo de las GPUs (que multiplican matrices grandes casi al mismo coste que pequeñas).

Mini-batch. El punto medio: usa un subconjunto aleatorio de B ejemplos (típicamente 32-512). Gradiente razonablemente estable, ruido saludable, y — la razón decisiva — satura el hardware: un batch de 256 ejemplos se procesa en una GPU casi tan rápido como uno de 1. Por eso ganó.

Criterio Batch completo Estocástico (1 ejemplo) Mini-batch (B ejemplos)
Calidad del gradiente Exacta Muy ruidosa Buena aproximación
Coste por actualización Todo el dataset 1 ejemplo B ejemplos
Actualizaciones por época 1 N N/B
Uso de GPU Bueno pero no cabe Pésimo (desperdicia paralelismo) Óptimo
Ruido (escapar de sillas/mínimos malos) Ninguno Excesivo Saludable
Convergencia Suave, lenta en la práctica Errática Estable y rápida
Memoria Prohibitiva en datasets grandes Mínima Ajustable
Uso hoy Casi nunca (solo datasets diminutos) Casi nunca Estándar universal

Nota

Confusión terminológica clásica: cuando un paper o una librería dice "SGD", casi siempre se refiere a mini-batch SGD. El "estocástico" alude a que el batch se muestrea al azar, no a que sea de tamaño 1.

Efectos del batch size — no es un detalle menor, es un hiperparámetro con carácter:

Batch size Ruido del gradiente Velocidad (GPU) Generalización Memoria Cuándo usarlo
8-32 Alto Infrautiliza GPU A menudo mejor (el ruido regulariza) Baja Datasets pequeños, poca VRAM, fine-tuning
64-256 Moderado Punto dulce Buena Media Elección por defecto
512-4096 Bajo Máxima (con LR ajustado) Puede empeorar (mínimos afilados) Alta Preentrenamiento a gran escala, multi-GPU

Consejo profesional

Si duplicas el batch size, considera escalar el learning rate (la regla lineal de Goyal et al. sugiere duplicarlo también, con warmup). Un batch grande con el LR de un batch pequeño desperdicia la ventaja; un batch pequeño con LR de batch grande explota. Batch size y learning rate son un matrimonio: no cambies uno sin avisar al otro.

Épocas e iteraciones — aclaremos la terminología de una vez con un ejemplo numérico:

Supón un dataset de 10 000 ejemplos y batch size 100.

  • Una iteración (o step) = una actualización de parámetros = procesar un mini-batch. Aquí: 100 ejemplos.
  • Una época (epoch) = un pase completo por el dataset = 10 000 / 100 = 100 iteraciones.
  • Entrenar 50 épocas = 50 × 100 = 5 000 iteraciones = 5 000 actualizaciones de parámetros.
Concepto Definición En el ejemplo
Iteración / step 1 mini-batch procesado, 1 actualización 100 ejemplos
Época 1 pase por todo el dataset 100 iteraciones
Entrenamiento completo E épocas 50 × 100 = 5 000 iteraciones

Advertencia

Los papers de LLMs suelen reportar en steps (iteraciones) y ni siquiera completan una época (¡el dataset es tan grande que cada ejemplo se ve menos de una vez!). Los tutoriales clásicos hablan en épocas. Cuando compares configuraciones, convierte siempre a la misma unidad o sacarás conclusiones equivocadas.

El bucle mini-batch canónico en NumPy (con barajado por época, que es obligatorio):

def entrenar_minibatch(red, X, y, lr=0.5, batch_size=32, epocas=200, seed=0):
    rng = np.random.default_rng(seed)
    n = X.shape[0]
    historial = []
    for epoca in range(epocas):
        # 1. BARAJAR cada época: si no, la red ve siempre el mismo orden
        #    y los gradientes de batches consecutivos quedan correlacionados
        idx = rng.permutation(n)
        X_bar, y_bar = X[idx], y[idx]

        # 2. Recorrer el dataset en trozos de batch_size
        for inicio in range(0, n, batch_size):
            xb = X_bar[inicio: inicio + batch_size]
            yb = y_bar[inicio: inicio + batch_size]
            red.forward(xb)                       # forward SOLO del batch
            dW, db = red.backward(yb)             # gradiente del batch
            red.paso_sgd(dW, db, lr)              # una ITERACIÓN

        # 3. Pérdida de la época (sobre todo el dataset, para monitorizar)
        historial.append(bce(red.forward(X), y))
    return historial

Ejercicio rápido 3. Tienes 48 000 imágenes, batch size 64 y entrenas 30 épocas. (a) ¿Cuántas iteraciones por época? (b) ¿Cuántas actualizaciones totales? (c) Si cambias a batch size 256 manteniendo 30 épocas, ¿cuántas actualizaciones haces ahora y qué implicación tiene?

Ver solución (a) `48 000 / 64 = 750` iteraciones por época. (b) `750 × 30 = 22 500` actualizaciones. (c) `48 000 / 256 = 187.5 → 188` iteraciones/época → `5 640` actualizaciones totales. **Cuatro veces menos actualizaciones** viendo los mismos datos: cada paso usa mejor gradiente, pero das muchos menos pasos. Por eso al subir el batch size se suele subir el learning rate y/o entrenar más épocas — de lo contrario, la red con batch grande queda infraentrenada y alguien concluye erróneamente que "el batch grande funciona peor".

4. Optimizadores modernos

El SGD "vainilla" (θ ← θ − η∇L) funciona, pero tiene dos debilidades: zigzaguea en valles estrechos (el gradiente apunta a la pared del valle, no hacia la salida) y usa el mismo learning rate para todos los parámetros, aunque unos necesiten pasos grandes y otros diminutos. Los optimizadores modernos atacan exactamente esas dos debilidades.

4.1 Momentum: la bola que rueda

La física como inspiración: en vez de una partícula sin masa que sigue al gradiente instantáneo, imagina una bola con inercia rodando por la superficie de la pérdida. La bola acumula velocidad en las direcciones donde el gradiente es consistente y cancela por promedio las oscilaciones que cambian de signo a cada paso.

v ← β·v + ∇L          (acumular velocidad; β ≈ 0.9)
θ ← θ − η·v           (moverse según la velocidad, no según el gradiente crudo)

Con β = 0.9, la velocidad es aproximadamente un promedio móvil de los últimos 1/(1−β) = 10 gradientes. En un valle estrecho, las componentes que oscilan (pared-pared) se cancelan y la componente que persiste (hacia la salida) se amplifica hasta 10×. Resultado: menos zigzag, más avance.

class SGDMomentum:
    def __init__(self, red, lr=0.1, beta=0.9):
        self.red, self.lr, self.beta = red, lr, beta
        # Una "velocidad" por parámetro, inicializada a cero
        self.vW = [np.zeros_like(w) for w in red.W]
        self.vb = [np.zeros_like(b) for b in red.b]

    def paso(self, dW, db):
        for l in range(self.red.n_capas):
            # Acumular: 90% de la velocidad anterior + el gradiente nuevo
            self.vW[l] = self.beta * self.vW[l] + dW[l]
            self.vb[l] = self.beta * self.vb[l] + db[l]
            # Actualizar con la velocidad (la memoria de gradientes pasados)
            self.red.W[l] -= self.lr * self.vW[l]
            self.red.b[l] -= self.lr * self.vb[l]

4.2 RMSProp: un learning rate por parámetro

Segunda debilidad: parámetros distintos necesitan pasos distintos. RMSProp (Hinton, en una diapositiva de curso que nunca fue paper) mantiene un promedio móvil del cuadrado de cada gradiente y divide por su raíz:

s ← ρ·s + (1−ρ)·(∇L)²           (ρ ≈ 0.99; magnitud histórica del gradiente)
θ ← θ − η · ∇L / (√s + ε)

Intuición: si un parámetro recibe gradientes históricamente grandes, √s es grande y su paso efectivo se encoge (frenamos al que va lanzado). Si recibe gradientes pequeños y raros (una feature poco frecuente), su paso se agranda (no dejamos atrás al rezagado). Es un learning rate adaptativo, individualizado y automático.

4.3 Adam a fondo: la combinación ganadora

Adam (Adaptive Moment Estimation, Kingma & Ba, 2015) combina las dos ideas: la inercia de momentum (primer momento m) y la adaptación por parámetro de RMSProp (segundo momento v):

m ← β₁·m + (1−β₁)·∇L             (media móvil del gradiente;   β₁ = 0.9)
v ← β₂·v + (1−β₂)·(∇L)²          (media móvil del cuadrado;    β₂ = 0.999)
m̂ = m / (1 − β₁ᵗ)                (corrección de sesgo ┐
v̂ = v / (1 − β₂ᵗ)                 corrección de sesgo ┘ ver nota)
θ ← θ − η · m̂ / (√v̂ + ε)         (ε = 1e-8)

Nota sobre la corrección de sesgo

m y v empiezan en 0, así que en los primeros pasos están sesgados hacia cero (son promedios de "casi nada"). En el paso t=1 con β₁=0.9, m vale solo el 10% del gradiente real. Dividir por (1−β₁ᵗ) corrige exactamente ese déficit: en t=1 divide por 0.1 (multiplica ×10), y a medida que t crece el factor tiende a 1 y desaparece. Sin esta corrección, Adam daría pasos minúsculos al principio, justo cuando más margen de avance hay.

¿Por qué Adam es el default? Porque es extraordinariamente robusto a hiperparámetros: lr = 3e-4 (o 1e-3) funciona razonablemente en una variedad enorme de problemas sin ajuste fino, mientras que SGD exige afinar el LR con cuidado. Adam convierte "hacer que la red aprenda algo" en casi un trámite — lo cual, cuando estás depurando la arquitectura o los datos, no tiene precio.

AdamW: el estándar actual. Adam tiene un defecto sutil con la regularización L2. En Adam clásico, el weight decay se suma al gradiente (∇L + λθ) antes de la división por √v̂ — así que los parámetros con gradientes históricos grandes reciben menos regularización de la prevista, acoplando dos cosas que deberían ser independientes. AdamW (Loshchilov & Hutter, 2019) desacopla el weight decay: lo aplica directamente al parámetro, fuera de la maquinaria adaptativa:

θ ← θ − η · ( m̂/(√v̂+ε)  +  λ·θ )      ← el decay λ·θ NO pasa por √v̂

Intuición: "optimiza con Adam, y aparte, encoge todos los pesos un poquito cada paso, a todos por igual". Esa separación limpia mejora la generalización de forma consistente, y por eso AdamW es el optimizador estándar para entrenar Transformers (GPT, LLaMA, BERT y compañía se entrenan con AdamW, típicamente con λ = 0.01-0.1).

Implementación de Adam en NumPy sobre nuestra red:

class Adam:
    def __init__(self, red, lr=1e-3, beta1=0.9, beta2=0.999, eps=1e-8):
        self.red, self.lr = red, lr
        self.b1, self.b2, self.eps = beta1, beta2, eps
        self.t = 0                                    # contador de pasos
        # Primer momento (media del gradiente) y segundo (media del cuadrado)
        self.mW = [np.zeros_like(w) for w in red.W]
        self.vW = [np.zeros_like(w) for w in red.W]
        self.mb = [np.zeros_like(b) for b in red.b]
        self.vb = [np.zeros_like(b) for b in red.b]

    def paso(self, dW, db):
        self.t += 1
        for l in range(self.red.n_capas):
            # --- primer momento: inercia (como momentum) ---
            self.mW[l] = self.b1 * self.mW[l] + (1 - self.b1) * dW[l]
            self.mb[l] = self.b1 * self.mb[l] + (1 - self.b1) * db[l]
            # --- segundo momento: escala por parámetro (como RMSProp) ---
            self.vW[l] = self.b2 * self.vW[l] + (1 - self.b2) * dW[l] ** 2
            self.vb[l] = self.b2 * self.vb[l] + (1 - self.b2) * db[l] ** 2
            # --- corrección de sesgo (crucial en los primeros pasos) ---
            mW_hat = self.mW[l] / (1 - self.b1 ** self.t)
            vW_hat = self.vW[l] / (1 - self.b2 ** self.t)
            mb_hat = self.mb[l] / (1 - self.b1 ** self.t)
            vb_hat = self.vb[l] / (1 - self.b2 ** self.t)
            # --- actualización: paso adaptado parámetro a parámetro ---
            self.red.W[l] -= self.lr * mW_hat / (np.sqrt(vW_hat) + self.eps)
            self.red.b[l] -= self.lr * mb_hat / (np.sqrt(vb_hat) + self.eps)

# Uso: idéntico bucle, cambiando solo el optimizador
red_adam = MLP([2, 16, 16, 1], seed=42)
opt = Adam(red_adam, lr=0.01)
for epoca in range(500):
    red_adam.forward(X_lunas)
    dW, db = red_adam.backward(y_lunas)
    opt.paso(dW, db)
print(f"Pérdida con Adam tras 500 épocas: {bce(red_adam.forward(X_lunas), y_lunas):.4f}")
# Adam típicamente alcanza en ~500 épocas lo que SGD tarda 2000 en lograr aquí

4.4 Learning rate schedules

El learning rate óptimo no es constante: al principio conviene explorar con pasos grandes; al final, afinar con pasos pequeños. Los schedules automatizan esa transición:

  • Step decay: dividir el LR por 10 en hitos fijos (p. ej., épocas 30 y 60). Simple, clásico en visión (así se entrenaron las ResNets).
  • Cosine decay: el LR desciende siguiendo media onda de coseno desde η_max hasta ~0. Suave, sin hiperparámetros de hitos, y empíricamente excelente. Muy popular hoy.
  • Warmup: empezar con LR ≈ 0 y subirlo linealmente durante los primeros cientos/miles de pasos, antes de aplicar el decay. ¿Por qué? Al inicio del entrenamiento los pesos son aleatorios, los gradientes son grandes y caóticos, y — clave para Adam — las estimaciones m y v se basan en poquísimas muestras, así que son poco fiables. Un LR grande sobre estimaciones malas puede lanzar la red a una región horrible de la que no se recupera. El warmup deja que las estadísticas del optimizador "se asienten" antes de pisar el acelerador. Por eso prácticamente todos los LLMs se entrenan con warmup + cosine decay — es la receta de GPT-3, LLaMA y sucesores.
def lr_warmup_cosine(paso, pasos_warmup=500, pasos_total=10000,
                     lr_max=3e-4, lr_min=3e-5):
    """El schedule de los LLMs: subida lineal + bajada en coseno."""
    if paso < pasos_warmup:
        return lr_max * (paso + 1) / pasos_warmup          # rampa lineal 0 → lr_max
    progreso = (paso - pasos_warmup) / (pasos_total - pasos_warmup)
    return lr_min + 0.5 * (lr_max - lr_min) * (1 + np.cos(np.pi * progreso))
lr ▲
   │      ____
   │     /    ‾‾--__
   │    /           ‾‾--__
   │   /                  ‾‾--___
   │  /                          ‾‾‾---____
   │ /                                     ‾‾‾------
   └─────────────────────────────────────────────────▶ pasos
     warmup │            cosine decay

4.5 Tabla comparativa de optimizadores

Optimizador Idea central Hiperparámetros típicos Ventajas Inconvenientes Cuándo usarlo
SGD Seguir el gradiente lr (¡crítico!) Simple; con buen LR y schedule, excelente generalización Sensible al LR; lento en valles Casi nunca solo; base didáctica
SGD + Momentum Inercia (media móvil de gradientes) lr, β=0.9 Menos zigzag; estado del arte en visión (CNNs) con schedule Aún requiere afinar LR Visión por computador, cuando puedes tunear
RMSProp LR adaptativo por parámetro lr=1e-3, ρ=0.99 Maneja escalas dispares Superado por Adam en general RNNs (histórico); hoy raro
Adam Momentum + adaptación + corrección de sesgo lr=3e-4, β₁=0.9, β₂=0.999 Robusto sin apenas tuning; converge rápido Weight decay mal acoplado; a veces generaliza algo peor que SGD tuneado Default para empezar cualquier proyecto
AdamW Adam + weight decay desacoplado lr=3e-4, λ=0.01-0.1 Regularización limpia; mejor generalización que Adam Un hiperparámetro más (λ) Transformers y LLMs; el estándar actual

Consejo profesional

Receta pragmática de 2026: empieza siempre con AdamW, lr = 3e-4, λ = 0.01, warmup corto + cosine decay. Solo si el proyecto lo justifica (visión con CNNs, papers a batir) explora SGD+momentum con schedule afinado. El tiempo de ingeniero vale más que el 0.3% de precisión.

Ejercicio rápido 4. En Adam, en el paso t = 2 con β₂ = 0.999, ¿por cuánto se divide v en la corrección de sesgo? ¿Qué pasaría sin esa corrección?

Ver solución El factor es `1 − 0.999² = 1 − 0.998001 = 0.001999 ≈ 0.002`. Es decir, `v̂ = v / 0.002 = 500·v`. Sin corrección, `v` estaría enormemente subestimado (casi cero), `√v̂` sería diminuto... y el paso `η·m̂/√v̂` sería **gigantesco e inestable** en los primeros pasos — justo lo contrario del comportamiento prudente que se busca. La corrección de sesgo (junto con el warmup) es lo que hace civilizado el arranque de Adam.

5. Problemas del entrenamiento profundo y sus curas

Apilar capas trae problemas nuevos que las redes someras no tienen. Cada problema tiene síntoma, causa y cura. Este es el catálogo esencial.

5.1 Vanishing y exploding gradients

Síntoma (vanishing): la pérdida baja unas décimas y se estanca; las primeras capas de la red tienen gradientes ~0 y sus pesos apenas cambian (puedes verificarlo imprimiendo np.abs(dW[l]).mean() por capa: verás valores decrecientes hacia atrás, a veces por órdenes de magnitud).

Síntoma (exploding): la pérdida se dispara a valores enormes o NaN en pocas iteraciones.

Causa: la ecuación 2 de backprop multiplica el error, capa tras capa, por W^T y por g'(Z). En una red de n capas, el gradiente de la primera capa contiene un producto de ~n factores. Si esos factores son sistemáticamente menores que 1, el producto se desvanece exponencialmente; si son mayores que 1, explota.

Demostración numérica. La derivada de la sigmoide es σ'(z) = σ(z)(1−σ(z)), cuyo máximo es 0.25 (en z=0). En una red de 10 capas con sigmoide, el mejor caso posible del factor acumulado de activaciones es:

0.25¹⁰ = 0.00000095  ≈ 1e-6

¡El gradiente llega a la primera capa un millón de veces más débil, en el mejor de los casos! Con activaciones saturadas (σ' ≈ 0.01) es directamente cero a efectos prácticos. En sentido contrario, si los pesos son grandes y cada factor efectivo vale 1.5:

1.5¹⁰ = 57.7        1.5²⁰ = 3325        1.5⁵⁰ ≈ 637 621 500

Explosión garantizada. Compruébalo tú mismo:

np.random.seed(0)
factores_sigmoide = np.full(10, 0.25)      # mejor caso de sigmoid, 10 capas
factores_grandes  = np.full(10, 1.5)       # pesos demasiado grandes
print(np.prod(factores_sigmoide))          # 9.5e-07  → vanishing
print(np.prod(factores_grandes))           # 57.66    → exploding (y creciendo)

Cura 1 — ReLU en vez de sigmoide en capas ocultas: ReLU'(z) = 1 para z > 0. Un producto de unos no se desvanece. Esta simple sustitución (más la inicialización correcta) es lo que permitió pasar de redes de 3 capas a redes de 100+ y detonó la revolución del deep learning en 2012. La sigmoide queda relegada a la capa de salida (probabilidades) y a las puertas de las LSTM.

Cura 2 — inicialización correcta (siguiente sección). Cura 3 — normalización (5.3). Cura 4 — gradient clipping para explosiones (5.4). Y en arquitecturas muy profundas, las conexiones residuales (capítulo de CNNs) crean autopistas por las que el gradiente fluye sin atenuarse.

5.2 Inicialización correcta: Xavier/Glorot y He

Síntoma de mala inicialización: con pesos demasiado pequeños, las activaciones colapsan hacia 0 capa tras capa; demasiado grandes, saturan las no-linealidades. En ambos casos, gradientes muertos desde la época 0. Con pesos todos iguales (¡o todos cero!), algo peor: simetría — todas las neuronas de una capa computan lo mismo, reciben el mismo gradiente y permanecen idénticas para siempre. La red equivale a tener una sola neurona por capa.

La intuición de la varianza. Piensa en la señal atravesando la red como una corriente por una cadena de amplificadores: queremos que cada capa preserve la "potencia" (la varianza) de la señal — ni la amplifique (explosión) ni la atenúe (colapso). Si z = Σ wᵢxᵢ suma n_in términos independientes, entonces Var(z) = n_in · Var(w) · Var(x). Para que Var(z) ≈ Var(x) necesitamos:

Var(w) = 1 / n_in

De ahí salen las dos recetas estándar:

Inicialización Fórmula (normal) Pensada para Razón
Xavier/Glorot (2010) W ~ N(0, 1/n_in) (variante: 2/(n_in+n_out)) tanh, sigmoide Preserva varianza con activaciones simétricas ≈ lineales cerca de 0
He (2015) W ~ N(0, 2/n_in) ReLU y familia ReLU anula la mitad de las activaciones → se compensa con el factor 2
Sesgos b = 0 todas No hay problema de simetría en los sesgos
def init_xavier(n_in, n_out, rng):
    """Para tanh/sigmoide: varianza 1/n_in."""
    return rng.normal(0.0, np.sqrt(1.0 / n_in), size=(n_in, n_out))

def init_he(n_in, n_out, rng):
    """Para ReLU: varianza 2/n_in — el 2 compensa la mitad de neuronas apagadas."""
    return rng.normal(0.0, np.sqrt(2.0 / n_in), size=(n_in, n_out))

Nota

Nuestro MLP ya usaba Xavier (np.sqrt(1.0 / dims[i])) porque sus ocultas son tanh. Si cambiaras las ocultas a ReLU, deberías cambiar a He. Este emparejamiento activación↔inicialización no es opcional: He et al. muestran en su paper que una red de 30 capas con ReLU directamente no arranca con Xavier y sí con He.

5.3 Batch Normalization y LayerNorm

Qué hace BatchNorm (Ioffe & Szegedy, 2015): normaliza las preactivaciones de cada neurona usando las estadísticas del mini-batch actual, y luego les devuelve libertad con dos parámetros aprendibles:

μ_B = media del batch          σ²_B = varianza del batch
ẑ = (z − μ_B) / √(σ²_B + ε)    ← normalizar: media 0, varianza 1
y = γ·ẑ + β                    ← re-escalar y desplazar (γ, β se APRENDEN)

Por qué ayuda: mantiene las distribuciones de activaciones estables capa a capa durante todo el entrenamiento (sin ella, cada capa persigue un objetivo móvil porque las capas anteriores cambian bajo sus pies). En la práctica: permite learning rates mucho mayores, hace la red robusta a la inicialización, acelera la convergencia notablemente y aporta una ligera regularización (el ruido de las estadísticas del batch). Dónde va: típicamente entre la transformación lineal y la activación: Linear → BatchNorm → ReLU.

Advertencia — el bug clásico de BatchNorm

BatchNorm se comporta distinto en entrenamiento y en evaluación, y confundir los modos es uno de los bugs más frecuentes del deep learning aplicado:

  • Train: normaliza con las estadísticas del batch actual, y va acumulando medias móviles (running_mean, running_var).
  • Eval: normaliza con las medias móviles acumuladas (fijas). Nunca con el batch de test.

Si evalúas con el modo train activo (model.train() en vez de model.eval() en PyTorch), las predicciones dependen de qué otros ejemplos comparten batch — con batch de 1 la varianza del batch es 0 y sale basura absoluta. Síntoma característico: métricas excelentes en validación durante el entrenamiento, resultados incoherentes o erráticos al servir el modelo. Lo verás de nuevo en el caso empresarial de la sección 8.

LayerNorm (Ba, Kiros & Hinton, 2016) — la de los Transformers. Cambia la dirección de la normalización: en vez de normalizar cada neurona a través de los ejemplos del batch, normaliza cada ejemplo a través de sus propias features:

Aspecto BatchNorm LayerNorm
Normaliza sobre... El batch (cada neurona vs. otros ejemplos) Las features (cada ejemplo consigo mismo)
¿Depende del batch size? (falla con batches pequeños) No
¿Modos train/eval distintos? (medias móviles) — fuente de bugs No (mismo cálculo siempre)
¿Sirve con secuencias de longitud variable? Mal (estadísticas por posición incómodas) Perfectamente
Dominio típico CNNs / visión Transformers / NLP

Por qué en NLP se usa LayerNorm: las secuencias tienen longitud variable y los tokens de un batch no están "alineados" de forma comparable posición a posición, así que promediar estadísticas a través del batch mezcla peras con manzanas; además los LLMs a veces entrenan con micro-batches pequeños por limitaciones de memoria, donde las estadísticas de batch son ruido. LayerNorm normaliza cada token con su propio vector de features: independiente del batch, idéntico en train y eval, perfecto para secuencias. Cada bloque de un Transformer lleva dos.

def layernorm(x, gamma, beta, eps=1e-5):
    """x: (batch, features). Normaliza CADA FILA (ejemplo) por separado."""
    mu = x.mean(axis=1, keepdims=True)          # media de las features del ejemplo
    var = x.var(axis=1, keepdims=True)          # varianza de las features del ejemplo
    x_norm = (x - mu) / np.sqrt(var + eps)      # media 0, var 1 por ejemplo
    return gamma * x_norm + beta                # γ, β aprendibles (forma: (features,))

5.4 Gradient clipping: la línea que salva entrenamientos

Síntoma: entrenamiento estable durante horas y, de repente, la pérdida salta a NaN — típico en RNNs y Transformers, donde un batch desafortunado puede producir un gradiente monstruoso que catapulta los pesos a una zona irrecuperable.

Cura: limitar la norma global del gradiente antes de actualizar. Si el vector de todos los gradientes concatenados mide más de un umbral, se re-escala para que mida exactamente el umbral (misma dirección, magnitud acotada):

def clip_gradientes(dW, db, max_norma=1.0):
    """Recorta la norma GLOBAL del gradiente. Una línea que salva entrenamientos."""
    # Norma L2 de TODOS los gradientes como un único vector gigante
    norma = np.sqrt(sum((g ** 2).sum() for g in dW) + sum((g ** 2).sum() for g in db))
    if norma > max_norma:
        factor = max_norma / (norma + 1e-6)     # factor < 1: encoger sin girar
        dW = [g * factor for g in dW]
        db = [g * factor for g in db]
    return dW, db

# En el bucle de entrenamiento, entre backward y el paso del optimizador:
# dW, db = clip_gradientes(dW, db, max_norma=1.0)

En PyTorch es literalmente una línea: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0). Prácticamente todos los LLMs se entrenan con clipping a norma 1.0. Es un seguro barato: cuando no hace falta no hace nada, y cuando hace falta evita un NaN que destruiría días de cómputo.


6. Regularización en deep learning

Las redes profundas tienen tantos parámetros que pueden memorizar el conjunto de entrenamiento (incluso con etiquetas aleatorias, como demostró un famoso paper de 2017). Regularizar es limitar esa capacidad de memorización para forzar generalización.

6.1 L2 / weight decay

Penalizar pesos grandes añadiendo λ/2 · Σw² a la pérdida — o equivalentemente (en SGD), encoger cada peso un poquito en cada paso:

# Como penalización en el gradiente (L2 clásico):
dW[l] += lambda_l2 * red.W[l]

# Como weight decay desacoplado (estilo AdamW, preferido hoy):
red.W[l] *= (1 - lr * lambda_wd)     # encoger ANTES del paso del optimizador

Intuición: pesos grandes crean funciones abruptas y nerviosas, que suelen ser síntoma de memorizar ruido. Preferimos la explicación suave. Nota: los sesgos no se regularizan (no aportan complejidad a la forma de la función).

6.2 Dropout a fondo

La intuición del equipo: imagina un equipo donde la estrella resuelve todo. El resto se acomoda, y el día que la estrella falta, el equipo colapsa. Dropout es el entrenador que cada día deja en el banquillo a un subconjunto aleatorio del equipo: nadie puede depender de un compañero concreto y todos desarrollan capacidades propias y redundantes. En términos de red: ninguna neurona puede apoyarse en la presencia de otra específica → se rompen las co-adaptaciones frágiles y las representaciones se vuelven robustas y redundantes. (Otra lectura válida: entrenar con dropout equivale a entrenar un ensemble exponencial de sub-redes que comparten pesos.)

Cómo funciona:

  • Entrenamiento: cada neurona se apaga con probabilidad p en cada forward. Las que sobreviven se escalan por 1/(1−p) para que la magnitud esperada de la señal no cambie (versión "inverted dropout", la que usan todos los frameworks).
  • Inferencia: dropout se apaga por completo. Ninguna neurona se elimina, ningún escalado extra (ya quedó compensado en train). Predicciones deterministas con la red completa.

Valores típicos de p: 0.5 en capas densas grandes (el valor del paper original), 0.1-0.3 en redes modernas y Transformers, 0 (nada) en capas convolucionales pequeñas. Nunca en la capa de salida.

def dropout_forward(A, p, entrenando, rng):
    """Inverted dropout. A: activaciones (m, n). p: prob. de APAGAR."""
    if not entrenando or p == 0.0:
        return A, None                       # inferencia: la red completa, sin tocar
    # Máscara binaria: 1 = sobrevive (prob 1-p), 0 = apagada. Escalado 1/(1-p)
    mascara = (rng.random(A.shape) >= p) / (1.0 - p)
    return A * mascara, mascara              # guardar la máscara para el backward

def dropout_backward(dA, mascara):
    """Las neuronas apagadas no propagan gradiente; las vivas, escalado incluido."""
    return dA * mascara

Advertencia

El bug simétrico al de BatchNorm: dejar dropout activo en inferencia. Síntoma: predicciones que cambian entre llamadas idénticas y son sistemáticamente peores. En PyTorch, model.eval() desactiva dropout y pone BatchNorm en modo eval de una vez — por eso ese eval() olvidado es el bug dos-en-uno más caro del deep learning aplicado.

6.3 Early stopping

La regularización más barata: parar cuando la validación deja de mejorar. El truco está en la paciencia: la curva de validación es ruidosa, así que no paramos al primer empeoramiento, sino tras paciencia épocas sin récord — y nos quedamos con los pesos del mejor momento, no los del final:

def entrenar_con_early_stopping(red, X_tr, y_tr, X_val, y_val,
                                lr=0.5, epocas_max=5000, paciencia=50):
    mejor_val = np.inf
    sin_mejora = 0
    mejores_pesos = None
    for epoca in range(epocas_max):
        red.forward(X_tr)
        dW, db = red.backward(y_tr)
        red.paso_sgd(dW, db, lr)

        perdida_val = bce(red.forward(X_val), y_val)     # medir en VALIDACIÓN
        if perdida_val < mejor_val - 1e-5:               # ¿nuevo récord?
            mejor_val = perdida_val
            sin_mejora = 0
            # snapshot de los mejores pesos (copia real, no referencia)
            mejores_pesos = ([w.copy() for w in red.W], [b.copy() for b in red.b])
        else:
            sin_mejora += 1
            if sin_mejora >= paciencia:                  # paciencia agotada
                print(f"Early stopping en época {epoca} (mejor val: {mejor_val:.4f})")
                break
    if mejores_pesos is not None:                        # restaurar el mejor momento
        red.W, red.b = mejores_pesos
    return red

6.4 Data augmentation

Crear ejemplos nuevos transformando los existentes sin cambiar su etiqueta: rotar/recortar/voltear imágenes, añadir ruido a audio, sustituir sinónimos en texto. Es la regularización más efectiva cuando aplica, porque ataca la raíz del sobreajuste (pocos datos) en vez del síntoma, y además inyecta conocimiento del dominio ("un gato girado 10° sigue siendo un gato"). La veremos a fondo en el módulo de visión por computador.

6.5 Tabla resumen de regularización

Técnica Cuándo usarla Efecto Coste
L2 / weight decay Casi siempre (default λ=0.01 con AdamW) Funciones más suaves, pesos contenidos Gratis
Dropout Capas densas grandes; sobreajuste claro Representaciones redundantes y robustas Entrena algo más lento
Early stopping Siempre (no tiene contraindicaciones) Se queda con el mejor modelo real Gratis (requiere set de validación)
Data augmentation Datos limitados + dominio con invariancias conocidas Multiplica los datos efectivos Barato en CPU/pipeline
Batch/LayerNorm Redes profundas (ligero efecto regularizador colateral) Estabiliza + regulariza un poco Gratis
Más datos reales Siempre que sea posible El mejor regularizador que existe El que más cuesta conseguir

Ejercicio rápido 5. Una capa con dropout p = 0.5 en modo entrenamiento recibe la activación A = [2.0, 4.0, 6.0, 8.0] y la máscara aleatoria apaga la segunda y la cuarta neurona. ¿Qué sale de la capa? ¿Y en inferencia?

Ver solución Entrenamiento (inverted dropout): las supervivientes se escalan por `1/(1−0.5) = 2`:
salida = [2.0·2, 0, 6.0·2, 0] = [4.0, 0, 12.0, 0]
Inferencia: dropout desactivado, sale `[2.0, 4.0, 6.0, 8.0]` tal cual. Gracias al escalado ×2 en train, el valor **esperado** de cada neurona durante el entrenamiento (`0.5 · 2·a = a`) coincide con su valor en inferencia — las escalas de la red son coherentes entre ambos modos, que es todo el sentido del "inverted".

7. El recetario práctico del entrenamiento

Esta sección es oro puro: lo que distingue a quien entrena redes con criterio de quien relanza el script cambiando números al azar. El instrumento fundamental de diagnóstico son las curvas de pérdida de train y validación. Apréndete estos seis escenarios como un médico se aprende los síntomas.

7.1 Los 6 escenarios de las curvas

Escenario 1 — No aprende (pérdida plana desde el inicio):

pérdida
  │────────────────────────  train
  │────────────────────────  val
  └──────────────────────────▶ épocas

Diagnóstico: la señal no fluye. Remedios en orden: ¿learning rate demasiado bajo (o 0 por un bug)? ¿gradientes muertos (mala init, sigmoides saturadas, ReLUs muertas)? ¿bug en el pipeline (etiquetas desalineadas de sus ejemplos, datos sin normalizar)? ¿la pérdida inicial es la esperada? (para BCE balanceado debe ser ln 2 ≈ 0.693; si empieza en otro valor raro, hay bug antes de la primera actualización).

Escenario 2 — Overfitting (train baja, val sube):

pérdida
  │\
  │ \            ____----‾‾  val (¡vuelve a subir!)
  │  \___    ---
  │      \‾‾----________
  │              ‾‾‾‾----___  train (sigue bajando)
  └──────────────────────────▶ épocas

Diagnóstico: la red memoriza el train. Es la firma de la brecha creciente train/val. Remedios: más datos o augmentation (lo mejor), luego regularización (dropout, weight decay ↑), red más pequeña, early stopping (que ya te habrá salvado el mejor checkpoint).

Escenario 3 — Underfitting (ambas altas y estancadas):

pérdida
  │\
  │ \__
  │    ‾‾‾───────────────────  val
  │    ‾‾‾───────────────────  train  (¡alta también!)
  └──────────────────────────▶ épocas

Diagnóstico: la red no tiene capacidad (o tiempo) ni para el train. Remedios: red más grande/profunda, entrenar más épocas, subir un poco el LR, quitar regularización (un dropout de 0.5 en una red pequeña la estrangula), mejores features.

Escenario 4 — La pérdida explota (sube o da NaN):

pérdida
  │                    /│ NaN
  │                   /
  │                  /
  │\                /
  │ \______________/
  └──────────────────────────▶ épocas

Diagnóstico: pasos demasiado grandes o inestabilidad numérica. Remedios: baja el LR (÷10 y prueba), gradient clipping, revisa datos corruptos (un inf/NaN en la entrada, división por cero en tu normalización), revisa log(0) en la pérdida (falta el eps).

Escenario 5 — La pérdida oscila violentamente:

pérdida
  │ /\    /\      /\
  │/  \  /  \    /  \  /\
  │    \/    \  /    \/  \/\
  │           \/            ‾  (baja de media, pero a bandazos)
  └──────────────────────────▶ épocas

Diagnóstico: LR alto (rebota en las paredes del valle) o batch demasiado pequeño (gradiente ruidoso). Remedios: baja el LR, sube el batch size, añade momentum/Adam si no lo usas, suaviza con un LR schedule. Ojo: oscilación pequeña en mini-batch es normal y sana.

Escenario 6 — Aprende y se estanca (meseta prematura):

pérdida
  │\
  │ \
  │  \__
  │     ‾‾──____
  │             ‾‾───────────  (plana, pero sospechas que puede más)
  └──────────────────────────▶ épocas

Diagnóstico: el LR actual ya no puede afinar más, o el optimizador está atrapado en una zona plana. Remedios: baja el LR (schedule: aquí brillan step/cosine decay), entrena más con LR menor, prueba warm restarts, o acepta que llegaste al límite de esos datos/arquitectura y cambia algo estructural.

Escenario Firma visual Primer remedio a probar
No aprende Ambas planas desde el inicio Verificar pérdida inicial esperada + subir LR + revisar datos
Overfitting Train ↓, val ↑ (brecha crece) Más datos/augmentation, luego regularización
Underfitting Ambas altas y planas Red más grande, menos regularización
Explota Sube bruscamente / NaN LR ÷ 10, gradient clipping
Oscila Dientes de sierra grandes LR ↓, batch ↑
Se estanca Baja y hace meseta LR schedule (decay)

7.2 El protocolo de Karpathy: "overfittea primero un batch pequeño"

Del recetario de Andrej Karpathy (A Recipe for Training Neural Networks, 2019), la prueba de humo más valiosa del oficio:

Antes de entrenar en serio, coge un solo batch minúsculo (2-16 ejemplos) y comprueba que tu red puede llevar su pérdida a ~0 sobreajustándolo por completo.

La lógica es aplastante: una red con miles de parámetros tiene que poder memorizar 8 ejemplos. Si no puede, el problema no son los hiperparámetros — es un bug: etiquetas desalineadas, pérdida mal escrita, gradientes que no llegan, datos sin normalizar, la activación equivocada en la salida. Este test aísla el bug antes de que quemes horas de GPU. Y si sí puede, tienes la garantía de que el pipeline completo (datos → forward → pérdida → backward → update) funciona de extremo a extremo.

# Prueba de humo: sobreajustar 8 ejemplos hasta pérdida ~0
X_mini, y_mini = X_lunas[:8], y_lunas[:8]
red_test = MLP([2, 16, 16, 1], seed=1)
opt_test = Adam(red_test, lr=0.01)
for i in range(500):
    red_test.forward(X_mini)
    dW, db = red_test.backward(y_mini)
    opt_test.paso(dW, db)
perdida_final = bce(red_test.forward(X_mini), y_mini)
print(f"Pérdida sobre el mini-batch: {perdida_final:.6f}")
assert perdida_final < 0.01, "⚠ BUG en el pipeline: una red así DEBE memorizar 8 puntos"

7.3 Learning rate finder (la idea)

Popularizado por fast.ai: en vez de adivinar el LR, medirlo. Se lanza un mini-entrenamiento de unos cientos de iteraciones aumentando el LR exponencialmente (de 1e-7 a 10) y se grafica pérdida vs. LR:

pérdida
  │‾‾‾‾‾\
  │      \            ← aquí empieza a bajar
  │       \
  │        \____
  │             \__        /│
  │                ‾‾\____/ │  ← aquí explota
  └──────┬───────────┬──────┴──▶ LR (escala log)
       muy bajo   ✅ elegir aquí (algo antes del mínimo)

Se elige un LR en plena pendiente de bajada, típicamente un orden de magnitud por debajo del punto donde la pérdida es mínima (justo antes de la zona de explosión). Diez minutos de cómputo que ahorran días de prueba y error.

7.4 El flujo de trabajo completo

flowchart TD
    A["1. Inspecciona los DATOS a mano<br/>(distribuciones, etiquetas, duplicados)"] --> B["2. Pipeline mínimo de extremo a extremo<br/>+ baseline tonto (clase mayoritaria)"]
    B --> C["3. ¿Pérdida inicial = la esperada?<br/>(BCE balanceado ≈ 0.693)"]
    C -->|no|BUG1["Bug antes de entrenar:<br/>revisa pérdida/salida/init"]
    C -->|sí|D["4. Overfittea UN batch pequeño<br/>hasta pérdida ≈ 0"]
    D -->|no lo logra|BUG2["Bug en el pipeline:<br/>datos, pérdida o backward"]
    D -->|lo logra|E["5. Entrena en todo el dataset<br/>AdamW lr=3e-4, monitoriza train Y val"]
    E --> F{"6. ¿Qué dicen<br/>las curvas?"}
    F -->|overfitting|G["Más datos / augmentation /<br/>regularización"]
    F -->|underfitting|H["Red más grande /<br/>más épocas / menos reg."]
    F -->|explota / oscila|I["LR ↓ / clipping / batch ↑"]
    F -->|va bien|J["7. Afina: LR schedule,<br/>early stopping, ensembles"]
    G --> F
    H --> F
    I --> F

Consejo profesional

El orden importa. El 80% de los "misterios de entrenamiento" que verás en tu carrera se resuelven en los pasos 1-4, antes de tocar un solo hiperparámetro. La tentación de saltar directamente a "probar otro learning rate" es fortísima y casi siempre equivocada: primero descarta bugs, luego optimiza.


8. Caso empresarial: las tres semanas perdidas

Caso empresarial

Una fintech de tamaño medio (llamémosla PagoSeguro) montó un equipo de tres personas para construir un modelo de detección de fraude con una red densa sobre features tabulares de transacciones (importe, hora, país, historial del comercio, etc.). El plan eran dos semanas. Llevaban tres y el modelo "no aprendía": el AUC en validación rondaba 0.55, apenas mejor que lanzar una moneda. La presión subía, se habló de "contratar un consultor" y de "cambiar a XGBoost y olvidarse de las redes".

Una ingeniera senior de otro equipo dedicó dos días a depurar con método en vez de relanzar entrenamientos. Su bitácora, paso a paso:

Día 1, mañana — mirar los datos, no el modelo. Imprimió estadísticas de las features de entrada: importe variaba entre 0.5 y 250 000; hora_del_dia entre 0 y 23; varias flags binarias entre 0 y 1. Nadie había normalizado. Con entradas en escalas tan dispares, la neurona que recibe importe produce preactivaciones de decenas de miles: las tanh/sigmoides de la primera capa estaban saturadas al 100% desde la época 0 (recuerda la sección 5.1: activación saturada ⇒ g'≈0 ⇒ gradiente muerto). Añadió estandarización (media 0, desviación 1, calculada solo sobre train y aplicada a val/test con esas mismas estadísticas). La pérdida por fin empezó a moverse... pero a los pocos cientos de iteraciones explotaba a NaN.

Día 1, tarde — la pérdida que explota. Curva de escenario 4 (sección 7.1). El equipo había estado usando lr = 0.1 con Adam "porque con 3e-4 iba muy lento" (iba lento porque los gradientes estaban muertos por la falta de normalización — habían compensado un bug con un hiperparámetro extremo). Bajó a lr = 1e-3, añadió gradient clipping a norma 1.0 y warmup de 500 pasos. El entrenamiento se volvió estable: pérdida de train bajando limpia, pérdida de validación bajando también. AUC de validación durante el entrenamiento: 0.91. Celebración... breve.

Día 2 — el modelo "bueno" que fallaba en producción. Al evaluar el modelo guardado sobre el conjunto de test con el script de inferencia, el AUC caía a 0.62, y peor: la predicción para una misma transacción cambiaba según cuántas transacciones se enviaran juntas en el lote de scoring. Ese síntoma —predicciones que dependen de los vecinos de batch— apunta directo a un sospechoso: BatchNorm en modo train durante la evaluación. En efecto: el script de inferencia construía el modelo y cargaba los pesos, pero nadie llamaba a model.eval(), así que BatchNorm normalizaba cada lote de scoring con las estadísticas de ese lote (y de propina, el dropout de 0.3 seguía activo apagando neuronas al azar en producción). Una línea — model.eval() — y el AUC de test subió a 0.90, consistente y determinista.

El desenlace. Tres bugs, ninguno exótico, todos de este capítulo: (1) datos sin normalizar → gradientes muertos; (2) LR desorbitado compensando el bug anterior → explosión; (3) modo train en evaluación → resultados incoherentes. Ninguna cantidad de "probar otros hiperparámetros" los habría arreglado, porque ninguno era un problema de hiperparámetros.

El equipo adoptó un checklist de entrenamiento obligatorio antes de cualquier experimento, pegado en el README del repositorio:

CHECKLIST PRE-ENTRENAMIENTO (PagoSeguro ML) — ningún experimento sin esto:
[ ] 1. Estadísticas de cada feature impresas y revisadas (media, std, min, max, NaNs)
[ ] 2. Entradas estandarizadas con estadísticas de TRAIN (guardadas junto al modelo)
[ ] 3. Pérdida inicial ≈ valor teórico (BCE balanceado: ln 2 ≈ 0.693)
[ ] 4. Overfit de un batch de 16 ejemplos hasta pérdida < 0.01 (test de Karpathy)
[ ] 5. Gradient clipping activado (norma 1.0) + warmup
[ ] 6. Curvas de train Y val registradas en cada experimento (nunca solo train)
[ ] 7. Evaluación SIEMPRE con model.eval() + verificación de determinismo:
       la misma entrada, dos veces, debe dar EXACTAMENTE la misma salida
[ ] 8. Semillas fijadas y configuración versionada junto al código

El punto 7 incluye un test automático que corre en CI: pasa un ejemplo fijo dos veces por el modelo servido y falla si las salidas difieren. Coste: 15 líneas. Bugs de modo train/eval que ha cazado desde entonces: tres. Semanas de ingeniería ahorradas: incontables.

Moralejas: los datos primero, el modelo después; las curvas de entrenamiento son un instrumento de diagnóstico, no un adorno; y los modos train/eval son la fuente de bugs silenciosos más cara del deep learning aplicado.


9. Buenas prácticas

  • Fija las semillas (np.random.seed, y en frameworks, también las de GPU) y versiona la configuración de cada experimento. Un resultado que no puedes reproducir no es un resultado.
  • Normaliza siempre las entradas con estadísticas calculadas solo en train, y guárdalas como parte del modelo (en producción necesitarás exactamente las mismas).
  • Verifica la pérdida inicial contra su valor teórico antes de entrenar. Es un test de 1 línea que caza bugs de pérdida, de salida y de inicialización.
  • Overfittea un batch pequeño antes de cada arquitectura nueva (protocolo de Karpathy). Es el test unitario del deep learning.
  • Haz gradient checking cuando implementes un backward a mano (capas propias, pérdidas propias). Los frameworks te lo dan gratis; tu código no.
  • Monitoriza train y validación juntas desde la iteración 1, y guarda el checkpoint del mejor valor de validación (early stopping con paciencia).
  • Empieza con AdamW + lr 3e-4 + warmup + cosine decay + clipping 1.0. Es la configuración con mejor relación resultado/esfuerzo de 2026. Optimiza a partir de ahí, no antes de tener algo que funcione.
  • Empareja activación e inicialización: He para ReLU, Xavier para tanh/sigmoide.
  • Escribe el test de determinismo en inferencia (misma entrada ⇒ misma salida) y ponlo en CI. Caza los bugs de train/eval de BatchNorm y dropout automáticamente.
  • Cambia una sola cosa por experimento. Si cambias LR, batch size y arquitectura a la vez y mejora, no has aprendido nada.

10. Malas prácticas

  • Tocar hiperparámetros antes de descartar bugs. El 80% de los "no aprende" son bugs de datos o de pipeline, no de learning rate.
  • Mirar solo la pérdida de train. Sin la curva de validación estás ciego ante el overfitting — el fallo más común y más caro.
  • Copiar hiperparámetros de otro problema sin criterio. El LR óptimo depende de la arquitectura, el batch size, la normalización y los datos. Úsalos como punto de partida, no como verdad.
  • Regularizar una red que underfittea. Dropout 0.5 en una red que ni siquiera ajusta el train es estrangular a un corredor lento. Primero capacidad, luego regularización.
  • Evaluar sin model.eval() (o servir con dropout activo). Ya sabes cómo acaba: sección 8.
  • Cambiar el batch size sin tocar el learning rate. Van en pareja (sección 3).
  • Entrenar sin clipping en arquitecturas propensas a explosiones (RNNs, Transformers) para "ahorrar" una línea de código.
  • Ignorar los NaN esperando que se arreglen solos. Un NaN en los pesos es irreversible: detecta (np.isnan(loss)), para, y depura.
  • Usar sigmoide en capas ocultas profundas. Vanishing gradient garantizado (sección 5.1). ReLU/GELU en ocultas; la sigmoide, para la salida.

11. Errores comunes

Error Síntoma Causa Solución
Datos sin normalizar Pérdida plana o zigzag salvaje desde el inicio Activaciones saturadas, gradientes muertos o desproporcionados Estandarizar con estadísticas de train
LR demasiado alto Pérdida oscila o explota a NaN Pasos que sobrepasan el mínimo ÷10 hasta estabilizar; LR finder; clipping
LR demasiado bajo Aprende pero exasperantemente lento Pasos diminutos ×10 con cuidado; warmup + cosine
Olvidar model.eval() Buenas métricas en train, incoherentes al servir; predicciones no deterministas BatchNorm con estadísticas de batch y dropout activo en inferencia model.eval() + test de determinismo en CI
Traspuesta equivocada en backward manual Error de dimensiones, o peor: entrena "raro" sin error Confundir A @ W vs delta @ W.T Gradient checking; verificar que cada gradiente tiene la forma de su parámetro
Olvidar barajar los datos por época Convergencia pobre; pérdida con patrón periódico Batches correlacionados (p. ej., datos ordenados por clase) rng.permutation(n) cada época
Inicializar todos los pesos iguales La red aprende como si tuviera 1 neurona por capa Simetría: neuronas idénticas reciben gradientes idénticos Inicialización aleatoria (Xavier/He)
Dropout/regularización excesivos Underfitting: ni el train baja Capacidad efectiva estrangulada Reducir p/λ; primero ajustar train, luego regularizar
log(0) en la pérdida NaN inmediato o esporádico Predicción exactamente 0 o 1 dentro del log np.clip(y_pred, eps, 1-eps)
Comparar "épocas" entre configs con distinto batch size Conclusiones erróneas ("batch grande aprende peor") Distinto número de actualizaciones por época Comparar en iteraciones o ajustar épocas/LR

12. FAQ

1. ¿Backpropagation y descenso de gradiente son lo mismo? No, y confundirlos es común. Backprop calcula los gradientes (es un algoritmo de diferenciación); el descenso de gradiente (o Adam, etc.) los usa para actualizar los parámetros (es un algoritmo de optimización). Puedes usar backprop con cualquier optimizador, y descenso de gradiente con gradientes calculados de cualquier manera (numéricos, por ejemplo — como hicimos en el capítulo 1).

2. ¿El gradiente que da backprop es aproximado? No: es exacto (hasta la precisión del float). La aproximación era el gradiente numérico. Backprop aplica la regla de la cadena, que es una identidad matemática, solo que organizada para no repetir cálculos.

3. ¿Por qué el backward cuesta más memoria que el forward de inferencia? Porque el backward necesita las activaciones intermedias del forward (las derivadas locales dependen de ellas: deriv_tanh(a), A[l].T @ delta...). En inferencia puedes descartar cada activación al pasar a la capa siguiente; en entrenamiento debes retenerlas todas hasta que el backward las consuma. Por eso entrenar consume mucha más VRAM que servir, y por eso existen técnicas como gradient checkpointing (recalcular activaciones en el backward para ahorrar memoria a cambio de cómputo).

4. ¿Adam siempre es mejor que SGD? No. Adam converge más rápido y casi sin tuning, pero SGD+momentum bien afinado (LR schedule incluido) iguala o supera a Adam en generalización en varios dominios, notablemente en visión con CNNs. La receta pragmática: Adam/AdamW para desarrollar y para Transformers; considera SGD+momentum cuando busques exprimir el último punto de precisión en visión y tengas presupuesto de tuning.

5. ¿Qué batch size elijo? Empieza en 32-128 (o el mayor que quepa en memoria hasta ~256). Ajusta el LR si lo cambias mucho (van en pareja). Batch pequeño = más ruido = a veces mejor generalización pero peor uso de GPU; batch enorme = rápido por época pero puede necesitar warmup, LR escalado y más épocas.

6. ¿Puedo usar dropout y BatchNorm a la vez? Se puede, pero conviven regular: BatchNorm calcula estadísticas que el dropout perturba, y la combinación a veces rinde peor que cada una por separado (hay literatura sobre su "desarmonía"). Patrón moderno: en CNNs, BatchNorm sin dropout (o dropout solo al final); en Transformers, LayerNorm + dropout moderado (0.1). Si usas ambas, valida empíricamente.

7. ¿Cada cuánto debo hacer gradient checking? Solo cuando implementas diferenciación a mano: una capa custom, una pérdida custom, un backward educativo como el de este capítulo. Con autograd de PyTorch/JAX no hace falta para el uso normal (el framework está testeado), aunque torch.autograd.gradcheck existe para tus Function personalizadas. Y nunca lo dejes activo en el entrenamiento real: es lentísimo.

8. ¿Por qué mi pérdida empieza exactamente en 0.693? ¡Buena señal! ln 2 ≈ 0.693 es la BCE de un clasificador binario que predice 0.5 (máxima incertidumbre) en un dataset balanceado — exactamente lo que debe hacer una red bien inicializada antes de aprender nada. Si empezara mucho más alto, tu inicialización está sesgada o hay un bug; si empezara más bajo, sospecha fuga de información. Conocer la pérdida inicial teórica de tu problema es un mini-test gratuito.

9. ¿El warmup solo sirve para LLMs? No, ayuda en general con Adam y batches grandes (estabiliza los momentos m y v cuando aún son estimaciones pobres, y evita pasos tempranos catastróficos). Simplemente en LLMs es imprescindible por la escala y el coste de un arranque fallido. En redes pequeñas con SGD suele ser prescindible.

10. ¿Qué hago si veo NaN en la pérdida? Para el entrenamiento (no "a ver si se arregla": no se arregla). Busca en este orden: (1) LR demasiado alto → ÷10; (2) log(0) o división por 0 en pérdida/normalización → clip/eps; (3) datos corruptos (np.isnan(X).any(), np.isinf(X).any()); (4) explosión de gradientes → clipping; (5) overflow en exp → estabilización numérica (clip de z, log-sum-exp).

13. Resumen

  • El gradiente numérico necesita 2 forwards por parámetro: inviable más allá de redes de juguete. Backpropagation calcula todos los gradientes, exactos, en un backward cuyo coste es ~2 forwards en total: es la regla de la cadena organizada sobre el grafo computacional para reutilizar cada cálculo.
  • El patrón universal del backward: gradiente_saliente = gradiente_entrante × derivada_local, usando valores guardados en el forward. Las 4 ecuaciones (error de salida; propagación con W^T ⊙ g'; dW = A^T δ; db = suma de δ) son ese patrón en forma matricial. Backprop = repartir la culpa del error hacia atrás en proporción a la responsabilidad.
  • Gradient checking (comparar contra diferencias centrales, error relativo < 1e-6) es la técnica profesional para verificar cualquier backward escrito a mano.
  • Mini-batch ganó a batch completo y a SGD puro por equilibrio entre calidad de gradiente, ruido saludable y aprovechamiento de la GPU. Época = pase por el dataset; iteración = un batch. Batch size y learning rate se ajustan en pareja.
  • Momentum añade inercia (cancela zigzag), RMSProp adapta el paso por parámetro, Adam combina ambos con corrección de sesgo y es el default robusto; AdamW desacopla el weight decay y es el estándar en Transformers. Los schedules (warmup + cosine) gobiernan el LR en el tiempo; los LLMs usan warmup porque el arranque de Adam es frágil.
  • Problemas del entrenamiento profundo: vanishing/exploding gradients (productos encadenados de factores <1 o >1; sigmoide agrava, ReLU cura), inicialización Xavier/He (preservar la varianza; He lleva el factor 2 por ReLU), BatchNorm (estabiliza; modos train/eval distintos) y LayerNorm (por-ejemplo, sin dependencia del batch: la de los Transformers), gradient clipping (una línea que evita NaN).
  • Regularización: weight decay, dropout (apagar neuronas al azar en train con escalado 1/(1−p); red completa en inferencia), early stopping con paciencia, data augmentation. Más datos reales sigue siendo el mejor regularizador.
  • El diagnóstico se hace leyendo las curvas de train/val (6 escenarios) y el protocolo profesional empieza por los datos y por overfittear un batch pequeño antes de tocar hiperparámetros.

14. Bibliografía

  • Rumelhart, D., Hinton, G., & Williams, R. (1986). Learning representations by back-propagating errors. Nature, 323, 533-536. — El paper que popularizó backpropagation.
  • Kingma, D. P., & Ba, J. (2015). Adam: A Method for Stochastic Optimization. ICLR 2015. arXiv:1412.6980 — El optimizador default del deep learning.
  • Loshchilov, I., & Hutter, F. (2019). Decoupled Weight Decay Regularization. ICLR 2019. arXiv:1711.05101 — AdamW, el estándar de los Transformers.
  • Glorot, X., & Bengio, Y. (2010). Understanding the difficulty of training deep feedforward neural networks. AISTATS 2010. — Inicialización Xavier/Glorot.
  • He, K., Zhang, X., Ren, S., & Sun, J. (2015). Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification. ICCV 2015. arXiv:1502.01852 — Inicialización He para ReLU.
  • Srivastava, N., Hinton, G., Krizhevsky, A., Sutskever, I., & Salakhutdinov, R. (2014). Dropout: A Simple Way to Prevent Neural Networks from Overfitting. JMLR, 15(56), 1929-1958. — El paper de dropout.
  • Ioffe, S., & Szegedy, C. (2015). Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift. ICML 2015. arXiv:1502.03167 — BatchNorm.
  • Ba, J. L., Kiros, J. R., & Hinton, G. E. (2016). Layer Normalization. arXiv:1607.06450 — LayerNorm, la de los Transformers.
  • Karpathy, A. (2019). A Recipe for Training Neural Networks. karpathy.github.io/2019/04/25/recipe — El recetario práctico que inspiró la sección 7. Lectura obligatoria.
  • Nielsen, M. (2015). Neural Networks and Deep Learning, cap. 2. neuralnetworksanddeeplearning.com — La derivación clásica de las 4 ecuaciones.

Anterior Módulo Siguiente
Capítulo 1: Redes neuronales desde cero 03-DEEP-LEARNING Capítulo 3: PyTorch