Cómo entrenar redes neuronales mucho más rápido: descenso de gradiente por mini-lotes, promedios exponencialmente ponderados, Momentum, RMSprop, Adam, decaimiento de la tasa de aprendizaje y la verdadera geometría de los óptimos locales en alta dimensión.
Los 10 vídeos de C2W2 publicados por deeplearning.ai en YouTube, en el orden del curso. Cada módulo incluye además la tarjeta de su vídeo (miniatura clicable) justo bajo el título de la sección: al pulsarla se abre el vídeo en YouTube.
En el Curso 1 aprendimos que la vectorización permite procesar los m ejemplos de entrenamiento sin bucles explícitos: apilamos las columnas en X = [x⁽¹⁾ x⁽²⁾ … x⁽ᵐ⁾] de dimensión (nₓ, m) e Y de dimensión (1, m).
Pero ¿qué pasa si m = 5.000.000 de ejemplos? El descenso de gradiente clásico (batch gradient descent) debe procesar los 5 millones de ejemplos completos — forward y backward — antes de dar un solo paso de actualización. Es como si tuvieras que leer una biblioteca entera antes de poder corregir tu primera frase. El entrenamiento se vuelve extremadamente lento.
Dividimos el conjunto de entrenamiento en lotes más pequeños, por ejemplo de 1.000 ejemplos cada uno. Con m = 5.000.000 obtenemos 5.000 mini-lotes:
Lo mismo para las etiquetas: Y{1}, Y{2}, …, Y{5000}. El mini-lote t es el par (X{t}, Y{t}), con X{t} de dimensión (nₓ, 1000) e Y{t} de dimensión (1, 1000).
| Notación | Significado | Ejemplo |
|---|---|---|
| x⁽ⁱ⁾ — paréntesis | i-ésimo ejemplo de entrenamiento | x⁽¹⁰⁰⁰⁾ |
| z[l] — corchetes | valor de la capa l de la red | z[2] |
| X{t} — llaves | t-ésimo mini-lote | X{5000} |
Una época (epoch) es una pasada completa por el conjunto de entrenamiento. Con batch GD, una época = 1 actualización. Con mini-batch GD y 5.000 mini-lotes, una época = 5.000 actualizaciones. Hagamos la contabilidad con números concretos para fijar la idea:
| Escenario (m = 5.000.000, size = 1.000) | Cálculo | Pasos de gradiente |
|---|---|---|
| Batch GD — 1 época | 1 pasada = 1 update | 1 |
| Mini-batch GD — 1 época | 5.000.000 / 1.000 | 5.000 |
| Batch GD — 10 épocas | 10 × 1 | 10 |
| Mini-batch GD — 10 épocas | 10 × 5.000 | 50.000 |
Con el mismo coste computacional (10 pasadas por los datos), mini-batch GD ha corregido los parámetros 50.000 veces mientras batch GD solo lo hizo 10. Por eso aprende tanto más rápido en la práctica. Dentro del bucle todo es idéntico al descenso de gradiente que ya conoces, pero aplicado a X{t}, Y{t}:
Z[1] = W[1]·X{t} + b[1], A[1] = g[1](Z[1]), … hasta A[L]. Sigue vectorizado: procesa 1.000 ejemplos a la vez.
J{t} = (1/1000) Σ L(ŷ⁽ⁱ⁾, y⁽ⁱ⁾) + (λ/(2·1000)) Σ‖W[l]‖²_F — nota que se normaliza por el tamaño del mini-lote, no por m.
Backprop para calcular dW[l], db[l] usando únicamente (X{t}, Y{t}).
W[l] := W[l] − α·dW[l], b[l] := b[l] − α·db[l]. ¡Un paso de gradiente con solo 1.000 ejemplos!
for t in range(1, 5001): # 5000 mini-lotes # Forward prop sobre X{t} (vectorizado, 1000 ejemplos) Z1 = W1 @ X_t + b1 A1 = g(Z1) ... AL = g(ZL) # Coste del mini-lote J_t = (1/1000) * sum(L(AL, Y_t)) + reg # Backprop y actualización dW, db = backprop(X_t, Y_t, cache) W = W - alpha * dW b = b - alpha * db
Para ver exactamente qué ocurre en los pasos 1–4, tomemos un micro-ejemplo totalmente calculable: una regresión logística (red de 1 capa, activación sigmoide) con nₓ = 2 características y un mini-lote de 4 ejemplos. Los mecanismos son idénticos a los de un mini-lote de 1.000 — solo cambia el número de columnas.
Cada columna de Z es el producto de W por la columna correspondiente de X{t} — los 4 ejemplos se procesan a la vez (vectorización):
z⁽¹⁾ = 0.5·1 + (−0.5)·0 = 0.5 · z⁽²⁾ = 0.5·2 + (−0.5)·1 = 0.5 · z⁽³⁾ = 0.5·0 + (−0.5)·3 = −1.5 · z⁽⁴⁾ = 0.5·(−1) + (−0.5)·2 = −1.5
Aplicando σ(z) = 1/(1+e⁻ᶻ): σ(0.5) = 0.6225, σ(−1.5) = 0.1824 ⇒ A = [0.6225 0.6225 0.1824 0.1824]. Las predicciones ya "apuntan" bien: altas donde y=1, bajas donde y=0, pero aún lejos de 1 y 0.
Pérdida por ejemplo:
ej. 1 y 2 (y=1): −ln(0.6225) = 0.4741 cada uno · ej. 3 y 4 (y=0): −ln(1−0.1824) = −ln(0.8176) = 0.2014 cada uno.
J{t} = (0.4741 + 0.4741 + 0.2014 + 0.2014) / 4 = 1.3510 / 4 = 0.3377. Nota el divisor: 4 (tamaño del mini-lote), no m.
dZ = [0.6225−1, 0.6225−1, 0.1824−0, 0.1824−0] = [−0.3775 −0.3775 0.1824 0.1824]
dw₁ = ¼·[(−0.3775)(1) + (−0.3775)(2) + (0.1824)(0) + (0.1824)(−1)] = ¼·(−1.3149) = −0.3287
dw₂ = ¼·[(−0.3775)(0) + (−0.3775)(1) + (0.1824)(3) + (0.1824)(2)] = ¼·(0.5345) = +0.1336
db = ¼·(−0.3775 − 0.3775 + 0.1824 + 0.1824) = ¼·(−0.3902) = −0.0976
w₁ := 0.5 − 0.1·(−0.3287) = 0.5329 (sube: la característica 1 es alta cuando y=1, tiene sentido reforzarla)
w₂ := −0.5 − 0.1·(0.1336) = −0.5134 (baja: la característica 2 es alta cuando y=0)
b := 0 − 0.1·(−0.0976) = 0.0098
¡Un paso de gradiente completado usando solo este mini-lote! El bucle continúa con (X{t+1}, Y{t+1}) partiendo de estos parámetros ya mejorados — así se encadenan las 5.000 actualizaciones de una época.
import numpy as np X_t = np.array([[1, 2, 0, -1], [0, 1, 3, 2]]) # (2, 4): 4 ejemplos en columnas Y_t = np.array([[1, 1, 0, 0]]) W, b, alpha = np.array([[0.5, -0.5]]), 0.0, 0.1 mt = X_t.shape[1] # tamaño del mini-lote = 4 Z = W @ X_t + b # [[ 0.5 0.5 -1.5 -1.5]] A = 1/(1 + np.exp(-Z)) # [[0.6225 0.6225 0.1824 0.1824]] J = -np.mean(Y_t*np.log(A) + (1-Y_t)*np.log(1-A)) print(J) # 0.3377 ← paso 2 ✓ dZ = A - Y_t dW = (dZ @ X_t.T)/mt # [[-0.3287 0.1336]] ← paso 3 ✓ db = np.mean(dZ) # -0.0976 W, b = W - alpha*dW, b - alpha*db # [[0.5329 -0.5134]], 0.0098 ← paso 4 ✓
mt = X_t.shape[1] y no una constante).crear_minibatches() del laboratorio final.Con batch GD, el coste J debe disminuir en cada iteración; si alguna vez sube, algo está mal (p. ej., α demasiado grande). Con mini-batch GD, la curva de J{t} es ruidosa: baja en tendencia, pero oscila. Es normal, porque cada punto se calcula sobre un mini-lote distinto — algunos son "fáciles" (coste bajo) y otros contienen ejemplos difíciles o mal etiquetados (coste alto).
| Tamaño | Nombre | Ventajas | Inconvenientes |
|---|---|---|---|
| size = m | Batch GD | Pasos de bajo ruido, convergencia estable hacia el mínimo | Demasiado tiempo por iteración si m es grande |
| size = 1 | Estocástico (SGD) | Progresa tras cada ejemplo | Pierde toda la aceleración de la vectorización; extremadamente ruidoso, nunca converge — deambula alrededor del mínimo |
| 1 < size < m | Mini-batch | Lo mejor de ambos: vectorización + progreso frecuente | Introduce el hiperparámetro "tamaño de mini-lote"; oscila cerca del mínimo (se mitiga con LR decay) |
Antes de Momentum, RMSprop y Adam necesitamos su ingrediente básico. Andrew Ng usa la temperatura diaria de Londres: θ₁ = 40°F, θ₂ = 49°F, θ₃ = 45°F, …, θ₁₈₀ = 60°F, θ₁₈₁ = 56°F… Los datos son ruidosos día a día, pero queremos ver la tendencia estacional. La solución es una media móvil que se actualiza así:
Con β = 0.9: v₁ = 0.9·v₀ + 0.1·θ₁, v₂ = 0.9·v₁ + 0.1·θ₂, etc. Cada vt mezcla 90 % de historia con 10 % de dato nuevo. El resultado (línea roja) sigue la tendencia suavizada de las temperaturas.
| β | 1/(1−β) | Comportamiento de la curva |
|---|---|---|
| 0.9 | ≈ 10 días | Línea roja — equilibrio típico: suaviza el ruido y sigue la tendencia. |
| 0.98 | ≈ 50 días | Línea verde — mucho más suave, pero desplazada a la derecha: se adapta con retraso (latencia) porque da poco peso (0.02) al dato actual. |
| 0.5 | ≈ 2 días | Línea amarilla — muy ruidosa, reacciona rapidísimo a los cambios pero apenas filtra outliers. |
Expandamos v₁₀₀ con β = 0.9:
Cada día anterior recibe un peso multiplicado por 0.9 adicional: los pesos forman una exponencial decreciente. Es un promedio ponderado donde el pasado se "olvida" geométricamente. Además, la suma de todos los coeficientes es ≈ 1 (salvo el sesgo inicial que veremos en el módulo 4).
(1−ε)^(1/ε) ≈ 1/e ≈ 0.35 con ε = 1−β. Con β = 0.9: 0.9¹⁰ ≈ 0.35 — tras ~10 días el peso ha caído a ~1/e (≈ un tercio) del inicial, así que "contribuyen significativamente" los últimos ~10 días. Con β = 0.98: 0.98⁵⁰ ≈ 1/e ⇒ ventana ≈ 50 días.
La gran ventaja frente a una media móvil clásica de ventana k (que exige guardar los últimos k valores): el EWA solo necesita una variable que se sobrescribe. Por eso se usa masivamente en machine learning.
v_theta = 0 # inicialización for t in range(1, T+1): theta_t = obtener_dato(t) # p.ej. temperatura del día t v_theta = beta * v_theta + (1 - beta) * theta_t # v_theta ≈ promedio de los últimos 1/(1-beta) valores
Como inicializamos v₀ = 0, los primeros valores del EWA quedan sesgados hacia abajo. Con β = 0.98 y θ₁ = 40°F: v₁ = 0.98·0 + 0.02·40 = 0.8 — ¡una pésima estimación de 40! Y v₂ = 0.98·v₁ + 0.02·θ₂ = 0.0196·θ₁ + 0.02·θ₂: los coeficientes suman solo 0.0396, muy lejos de 1. En la gráfica, la curva arranca pegada al suelo (curva morada) en vez de seguir los datos (curva verde).
Verificación numérica (t = 2, β = 0.98): 1 − β² = 1 − 0.9604 = 0.0396. Entonces v₂/0.0396 = (0.0196·θ₁ + 0.02·θ₂)/0.0396 — los coeficientes ahora suman exactamente 1: se convierte en un promedio ponderado legítimo de θ₁ y θ₂, eliminando el sesgo.
Para t grande, βᵗ → 0, así que 1 − βᵗ → 1 y la corrección deja de tener efecto: solo actúa durante el arranque, que es exactamente donde hace falta.
Imagina un coste con contornos elípticos alargados (muy común: distintas escalas entre parámetros). El descenso de gradiente oscila arriba-abajo en la dirección corta (vertical, digamos b) mientras avanza lentamente en la dirección larga (horizontal, digamos W). Esas oscilaciones:
Idea: en lugar de actualizar con el gradiente crudo, actualizamos con su promedio exponencialmente ponderado. En la iteración t (sobre el mini-lote actual):
Por qué funciona: en la dirección vertical los gradientes alternan signo (+, −, +, −…) y el promedio tiende a cero — la oscilación se amortigua. En la dirección horizontal todos apuntan al mínimo y el promedio se mantiene grande — el avance se acelera. Resultado: trayectoria más directa y posibilidad de usar α mayor.
La bola que rueda: imagina el coste como un cuenco. dW/db actúan como la aceleración, vdW/vdb como la velocidad acumulada, y β (<1) como la fricción que evita que la bola acelere sin límite. La bola gana "momento" cuesta abajo — de ahí el nombre.
v_dW = np.zeros_like(W); v_db = np.zeros_like(b) for t in range(num_iteraciones): dW, db = backprop_minibatch(X_t, Y_t) # gradientes del mini-lote actual v_dW = beta * v_dW + (1 - beta) * dW v_db = beta * v_db + (1 - beta) * db W -= alpha * v_dW b -= alpha * v_db # beta = 0.9 funciona muy bien
Mismo escenario que Momentum: oscilamos en b (vertical) y avanzamos lento en W (horizontal). Observación clave: las derivadas son grandes en la dirección que oscila (db grande) y pequeñas en la que progresa (dW pequeña). RMSprop explota esto: mide el "tamaño típico" de cada componente del gradiente y divide por él.
El cuadrado dW² es elemento a elemento (element-wise). Análisis del efecto:
Muchos algoritmos de optimización propuestos en la literatura funcionaban bien solo en problemas concretos. Adam (Adaptive Moment Estimation) es de los pocos que demostró funcionar de forma robusta en una gran variedad de arquitecturas de deep learning, y por eso se convirtió en el estándar. Conceptualmente es simple: toma el v de Momentum, el S de RMSprop, corrige el sesgo de ambos y combina las dos actualizaciones.
Inicializar vdW=0, SdW=0, vdb=0, Sdb=0. En la iteración t, calcular dW, db sobre el mini-lote actual y:
vdW = β₁·vdW + (1−β₁)·dW · vdb = β₁·vdb + (1−β₁)·db
SdW = β₂·SdW + (1−β₂)·dW² · Sdb = β₂·Sdb + (1−β₂)·db²
v̂dW = vdW/(1−β₁ᵗ) · ŜdW = SdW/(1−β₂ᵗ) (igual para db)
W := W − α · v̂dW / (√ŜdW + ε) · b := b − α · v̂db / (√Ŝdb + ε)
| Hiperparámetro | Valor recomendado | ¿Se ajusta? | Rol |
|---|---|---|---|
| α | — | Sí, siempre — es el que hay que tunear | Tasa de aprendizaje |
| β₁ | 0.9 | Casi nunca | EWA de dW (primer momento) |
| β₂ | 0.999 | Casi nunca | EWA de dW² (segundo momento) — valor propuesto por los autores del paper de Adam |
| ε | 10⁻⁸ | No afecta apenas | Estabilidad numérica del denominador |
t += 1 v_dW = beta1*v_dW + (1-beta1)*dW; v_db = beta1*v_db + (1-beta1)*db S_dW = beta2*S_dW + (1-beta2)*dW**2; S_db = beta2*S_db + (1-beta2)*db**2 # corrección de sesgo v_dW_c = v_dW/(1-beta1**t); v_db_c = v_db/(1-beta1**t) S_dW_c = S_dW/(1-beta2**t); S_db_c = S_db/(1-beta2**t) # actualización W -= alpha * v_dW_c/(np.sqrt(S_dW_c)+eps) b -= alpha * v_db_c/(np.sqrt(S_db_c)+eps) # eps = 1e-8
Con mini-lotes y α fijo, el ruido de cada lote hace que el algoritmo nunca converja del todo: acaba deambulando en una zona amplia alrededor del mínimo. Si en cambio α se reduce gradualmente, al principio das pasos grandes (aprendizaje rápido) y al final pasos diminutos que te dejan oscilando en una región muy estrecha junto al mínimo. Puedes "permitirte" pasos pequeños al final porque ya estás cerca.
Recuerda: 1 época = 1 pasada por todos los datos. Ejemplo con α₀ = 0.2 y decay_rate = 1:
| Época | Cálculo | α |
|---|---|---|
| 1 | 0.2 / (1+1·1) | 0.100 |
| 2 | 0.2 / (1+1·2) | 0.067 |
| 3 | 0.2 / (1+1·3) | 0.050 |
| 4 | 0.2 / (1+1·4) | 0.040 |
Tanto α₀ como decay_rate son hiperparámetros que se ajustan.
α = 0.95^epoch · α₀ — decae rápidamente de forma multiplicativa.
α = (k/√epoch)·α₀ o α = (k/√t)·α₀ con t = número de mini-lote.
α se reduce a la mitad cada cierto número de épocas — escalera descendente.
Si entrenas pocos modelos durante horas/días: observas y bajas α a mano. Sorprendentemente común.
En los primeros tiempos del deep learning se temía que el descenso de gradiente quedara atrapado en mínimos locales malos — la imagen mental era un paisaje 2D lleno de valles. Pero esa intuición viene de dibujar J en 2-3 dimensiones, y no se traslada a redes con 20.000 o millones de parámetros.
Para que un punto de gradiente cero sea un mínimo local, la función debe curvarse hacia arriba (∪) en todas las direcciones. Con 20.000 dimensiones, si cada dirección se curva hacia arriba o hacia abajo con probabilidad ~½, la probabilidad de que todas sean ∪ es del orden de 2⁻²⁰⁰⁰⁰ — prácticamente imposible. Lo abrumadoramente probable es que unas direcciones curven hacia arriba y otras hacia abajo: un punto de silla (saddle point), llamado así porque la superficie recuerda a una silla de montar.
Un plateau es una región extensa donde el gradiente es casi cero durante mucho tiempo. El algoritmo avanza lentísimo por la meseta hasta que por fin encuentra la pendiente y "cae" por el lado. Conclusiones de Andrew Ng:
Este script autocontenido implementa mini-batch GD, Momentum, RMSprop y Adam sobre una regresión logística simple, para que compares las curvas de coste tú mismo (ideal para Google Colab):
import numpy as np def crear_minibatches(X, Y, batch_size=64, seed=0): """Baraja y particiona (X, Y) en mini-lotes X{t}, Y{t}.""" rng = np.random.default_rng(seed) m = X.shape[1] perm = rng.permutation(m) # barajar es esencial X_s, Y_s = X[:, perm], Y[:, perm] lotes = [] for k in range(0, m, batch_size): lotes.append((X_s[:, k:k+batch_size], Y_s[:, k:k+batch_size])) return lotes # el último puede ser más pequeño def gradientes(W, b, X_t, Y_t): """Forward + backward de regresión logística sobre el mini-lote.""" mt = X_t.shape[1] Z = W @ X_t + b A = 1/(1 + np.exp(-Z)) coste = -np.mean(Y_t*np.log(A+1e-12) + (1-Y_t)*np.log(1-A+1e-12)) dZ = A - Y_t return (dZ @ X_t.T)/mt, np.mean(dZ), coste def entrenar(X, Y, metodo="adam", alpha=0.05, epocas=30, beta1=0.9, beta2=0.999, eps=1e-8, decay=0.0): nx = X.shape[0] W = np.zeros((1, nx)); b = 0.0 vW = np.zeros_like(W); vb = 0.0 # momentum SW = np.zeros_like(W); Sb = 0.0 # rmsprop t, historial = 0, [] for ep in range(1, epocas+1): a = alpha/(1 + decay*ep) # learning rate decay for X_t, Y_t in crear_minibatches(X, Y, 64, seed=ep): t += 1 dW, db, J = gradientes(W, b, X_t, Y_t) if metodo == "gd": W -= a*dW; b -= a*db elif metodo == "momentum": vW = beta1*vW + (1-beta1)*dW; vb = beta1*vb + (1-beta1)*db W -= a*vW; b -= a*vb elif metodo == "rmsprop": SW = beta2*SW + (1-beta2)*dW**2; Sb = beta2*Sb + (1-beta2)*db**2 W -= a*dW/(np.sqrt(SW)+eps); b -= a*db/(np.sqrt(Sb)+eps) elif metodo == "adam": vW = beta1*vW + (1-beta1)*dW; vb = beta1*vb + (1-beta1)*db SW = beta2*SW + (1-beta2)*dW**2; Sb = beta2*Sb + (1-beta2)*db**2 vWc, vbc = vW/(1-beta1**t), vb/(1-beta1**t) # bias correction SWc, Sbc = SW/(1-beta2**t), Sb/(1-beta2**t) W -= a*vWc/(np.sqrt(SWc)+eps); b -= a*vbc/(np.sqrt(Sbc)+eps) historial.append(J) return W, b, historial # --- demo: dataset sintético --- rng = np.random.default_rng(42) X = rng.normal(size=(2, 4000)) Y = (X[0:1] + 2*X[1:2] > 0).astype(float) for m in ["gd", "momentum", "rmsprop", "adam"]: _, _, h = entrenar(X, Y, metodo=m) print(f"{m:>9}: coste final = {np.mean(h[-20:]):.4f}")
historial con matplotlib para los cuatro métodos y observa: (1) la curva ruidosa característica del mini-batch, (2) cómo Adam converge más rápido, (3) el efecto de decay=0.5 sobre la oscilación final.Responde cada pregunta. Verde = correcta, rojo = incorrecta (se muestra la explicación). Tu puntuación aparece al final.