Phase 03 - Lesson 06
Optimizadores
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
El descenso de gradiente te dice en qué dirección moverte. No dice nada sobre qué tan lejos o qué tan rápido. El SGD es una brújula. Adam es un GPS con datos de tráfico.
Tipo: Build Lenguajes: Python Requisitos previos: Lección 03.05 (Funciones de Pérdida) Tiempo: ~75 minutos
Objetivos de Aprendizaje
- Implementar los optimizadores SGD, SGD con momentum, Adam y AdamW desde cero en Python
- Explicar cómo la corrección de sesgo de Adam compensa las estimaciones de momento inicializadas en cero durante los primeros pasos del entrenamiento
- Demostrar por qué AdamW produce mejor generalización que Adam con regularización L2 en la misma tarea
- Seleccionar el optimizador apropiado y los hiperparámetros predeterminados para transformers, CNN, GAN y fine-tuning
El Problema
Calculaste los gradientes. Sabes que el peso #4.721 debería disminuir en 0,003 para reducir la pérdida. Pero 0,003 ¿en qué unidades? ¿Escalado por qué? ¿Y deberías moverte la misma cantidad en el paso 1 que en el paso 1.000?
El descenso de gradiente común aplica la misma tasa de aprendizaje a cada parámetro en cada paso: w = w - lr * gradient. Esto crea tres problemas que hacen que el entrenamiento de redes neuronales sea doloroso en la práctica.
Primero, oscilación. El relieve de la función de pérdida rara vez tiene la forma de un tazón suave. Se parece más a un valle largo y estrecho. El gradiente apunta a través del valle (dirección empinada), no a lo largo de él (dirección poco profunda). El descenso de gradiente rebota de un lado a otro en la dimensión estrecha mientras avanza muy poco en la dimensión útil. Ya lo has visto: la pérdida cae rápido y luego se estanca, no porque el modelo haya convergido sino porque está oscilando.
Segundo, una sola tasa de aprendizaje para todos los parámetros es incorrecta. Algunos pesos necesitan grandes actualizaciones (están en la etapa inicial de subajuste). Otros necesitan actualizaciones minúsculas (están cerca de su valor óptimo). Una tasa de aprendizaje que funciona para los primeros destruye a los segundos, y viceversa.
Tercero, puntos de silla. En altas dimensiones, el relieve de la función de pérdida tiene vastas regiones planas donde el gradiente es cercano a cero. El SGD común se arrastra por estas regiones a la velocidad del gradiente, que es efectivamente cero. El modelo parece atascado. No está atascado -- está en una región plana con un descenso útil del otro lado. Pero el SGD no tiene ningún mecanismo para empujar a través de ella.
Adam resuelve los tres. Mantiene dos promedios móviles por parámetro -- el gradiente medio (momentum, maneja la oscilación) y el gradiente cuadrático medio (tasa adaptativa, maneja escalas diferentes). Combinado con la corrección de sesgo para los primeros pasos, te da un único optimizador que funciona en el 80% de los problemas con los hiperparámetros predeterminados. Esta lección lo construye desde cero para que entiendas exactamente cuándo y por qué falla en el otro 20%.
El Concepto
Descenso de Gradiente Estocástico (SGD)
El optimizador más simple. Calcula el gradiente en un mini-lote y da un paso en la dirección opuesta.
w = w - lr * gradient
El "estocástico" significa que usas un subconjunto aleatorio (mini-lote) de los datos para estimar el gradiente, en lugar del conjunto de datos completo. Ese ruido es, de hecho, útil -- ayuda a escapar de mínimos locales agudos. Pero el ruido también causa oscilación.
La tasa de aprendizaje es la única perilla. Demasiado alta: la pérdida diverge. Demasiado baja: el entrenamiento tarda una eternidad. El valor óptimo depende de la arquitectura, los datos, el tamaño del lote y la etapa actual del entrenamiento. Para el SGD común en redes modernas, los valores típicos van de 0,01 a 0,1. Pero incluso dentro de una sola ejecución de entrenamiento, la tasa de aprendizaje ideal cambia.
Momentum
La analogía de la bola rodando cuesta abajo está sobreutilizada pero es precisa. En lugar de dar un paso solo por el gradiente, mantienes una velocidad que acumula los gradientes pasados.
m_t = beta * m_{t-1} + gradient
w = w - lr * m_t
Beta (típicamente 0,9) controla cuánto historial conservar. Con beta = 0,9, el momentum es aproximadamente el promedio de los últimos 10 gradientes (1 / (1 - 0,9) = 10).
Por qué esto corrige la oscilación: los gradientes que apuntan en la misma dirección se acumulan. Los gradientes que invierten de dirección se cancelan. En ese valle estrecho, la componente "transversal" invierte el signo en cada paso y se amortigua. La componente "longitudinal" se mantiene consistente y se amplifica. El resultado es una aceleración suave en la dirección útil.
Números reales: el SGD solo en un relieve de pérdida mal condicionado podría tomar 10.000 pasos. El SGD con momentum (beta=0,9) típicamente toma de 3.000 a 5.000 pasos en el mismo problema. La aceleración no es marginal.
RMSProp
El primer método de tasa de aprendizaje adaptativa por parámetro que realmente funcionó. Propuesto por Hinton en una clase de Coursera (nunca publicado formalmente).
s_t = beta * s_{t-1} + (1 - beta) * gradient^2
w = w - lr * gradient / (sqrt(s_t) + epsilon)
s_t rastrea el promedio móvil de los gradientes cuadráticos. Los parámetros con gradientes consistentemente grandes se dividen por un número grande (tasa de aprendizaje efectiva menor). Los parámetros con gradientes pequeños se dividen por un número pequeño (tasa de aprendizaje efectiva mayor).
Esto resuelve el problema de la "una tasa de aprendizaje para todos los parámetros". Un peso que ya viene recibiendo grandes actualizaciones probablemente está cerca de su objetivo -- ralentízalo. Un peso que viene recibiendo actualizaciones minúsculas podría estar subentrenado -- aceléralo.
Epsilon (típicamente 1e-8) evita la división por cero cuando un parámetro no ha sido actualizado.
Adam: Momentum + RMSProp
Adam combina ambas ideas. Mantiene dos promedios móviles exponenciales por parámetro:
m_t = beta1 * m_{t-1} + (1 - beta1) * gradient (primer momento: media)
v_t = beta2 * v_{t-1} + (1 - beta2) * gradient^2 (segundo momento: varianza)
La corrección de sesgo es el detalle clave que la mayoría de las explicaciones omiten. En el paso 1, m_1 = (1 - beta1) * gradient. Con beta1 = 0,9, eso es 0,1 * gradient -- diez veces demasiado pequeño. El promedio móvil aún no se ha calentado. La corrección de sesgo compensa:
m_hat = m_t / (1 - beta1^t)
v_hat = v_t / (1 - beta2^t)
En el paso 1 con beta1 = 0,9: m_hat = m_1 / (1 - 0,9) = m_1 / 0,1 = el gradiente real. En el paso 100: (1 - 0,9^100) es aproximadamente 1,0, por lo que la corrección desaparece. La corrección de sesgo importa en los primeros ~10 pasos y es irrelevante después de ~50.
La actualización:
w = w - lr * m_hat / (sqrt(v_hat) + epsilon)
Valores predeterminados de Adam: lr = 0,001, beta1 = 0,9, beta2 = 0,999, epsilon = 1e-8. Estos valores predeterminados funcionan para el 80% de los problemas. Cuando no lo hacen, cambia primero la lr. Luego beta2. Casi nunca cambies beta1 ni epsilon.
AdamW: Weight Decay Bien Hecho
La regularización L2 agrega lambda * w^2 a la pérdida. En el SGD común, esto es equivalente al weight decay (restar lambda * w del peso en cada paso). En Adam, esta equivalencia se rompe.
El hallazgo de Loshchilov & Hutter: cuando agregas L2 a la pérdida y luego Adam procesa el gradiente, la tasa de aprendizaje adaptativa escala también el término de regularización. Los parámetros con gran varianza de gradiente reciben menos regularización. Los parámetros con poca varianza reciben más. Esto no es lo que quieres -- quieres una regularización uniforme, independientemente de las estadísticas del gradiente.
AdamW corrige esto aplicando el weight decay directamente a los pesos, después de la actualización de Adam:
w = w - lr * m_hat / (sqrt(v_hat) + epsilon) - lr * lambda * w
El término de weight decay (lr * lambda * w) no es escalado por el factor adaptativo de Adam. Cada parámetro recibe el mismo encogimiento proporcional.
Esto parece un detalle menor. No lo es. AdamW converge a mejores soluciones que Adam + regularización L2 en prácticamente todas las tareas. Es el optimizador predeterminado en PyTorch para entrenar transformers, modelos de difusión y la mayoría de las arquitecturas modernas. BERT, GPT, LLaMA, Stable Diffusion -- todos entrenados con AdamW.
Tasa de Aprendizaje: El Hiperparámetro Más Importante
graph TD
LR["Tasa de Aprendizaje"] --> TooHigh["Demasiado alta (lr > 0.01)"]
LR --> JustRight["En su punto justo"]
LR --> TooLow["Demasiado baja (lr < 0.00001)"]
TooHigh --> Diverge["La pérdida explota<br/>Pesos NaN<br/>El entrenamiento se cae"]
JustRight --> Converge["La pérdida decrece de forma constante<br/>Alcanza un buen mínimo<br/>Generaliza bien"]
TooLow --> Stall["La pérdida decrece lentamente<br/>Se queda atascada en un mínimo subóptimo<br/>Desperdicia cómputo"]
JustRight --> Schedule["Generalmente necesita programación"]
Schedule --> Warmup["Warmup: rampa de 0 al máximo<br/>Primeros 1-10% del entrenamiento"]
Schedule --> Decay["Decay: reduce con el tiempo<br/>Coseno o lineal"]
Si ajustas un solo hiperparámetro, ajusta la tasa de aprendizaje. Un cambio de 10x en la tasa de aprendizaje importa más que cualquier decisión arquitectónica que vayas a tomar. Valores predeterminados comunes:
- SGD: lr = 0,01 a 0,1
- Adam/AdamW: lr = 1e-4 a 3e-4
- Fine-tuning de modelos preentrenados: lr = 1e-5 a 5e-5
- Warmup de la tasa de aprendizaje: rampa lineal en los primeros 1-10% de los pasos
Comparación de Optimizadores
flowchart LR
subgraph "Ruta de Optimización"
SGD_P["SGD<br/>Oscila por el valle<br/>Lento, pero encuentra mínimos planos"]
Mom_P["SGD + Momentum<br/>Ruta más suave<br/>3x más rápido que SGD"]
Adam_P["Adam<br/>Adapta por parámetro<br/>Convergencia rápida"]
AdamW_P["AdamW<br/>Adam + decay apropiado<br/>Mejor generalización"]
end
SGD_P --> Mom_P --> Adam_P --> AdamW_P
Cuándo Gana Cada Optimizador
flowchart TD
Task["¿Qué estás entrenando?"] --> Type{"¿Tipo de modelo?"}
Type -->|"Transformer / LLM"| AdamW["AdamW<br/>lr=1e-4, wd=0.01-0.1"]
Type -->|"CNN / ResNet"| SGD_M["SGD + Momentum<br/>lr=0.1, momentum=0.9"]
Type -->|"GAN"| Adam2["Adam<br/>lr=2e-4, beta1=0.5"]
Type -->|"Fine-tuning"| AdamW2["AdamW<br/>lr=2e-5, wd=0.01"]
Type -->|"Aún no sé"| Default["Empieza con AdamW<br/>lr=3e-4, wd=0.01"]
Constrúyelo
Paso 1: SGD Común
class SGD:
def __init__(self, lr=0.01):
self.lr = lr
def step(self, params, grads):
for i in range(len(params)):
params[i] -= self.lr * grads[i]
Paso 2: SGD con Momentum
class SGDMomentum:
def __init__(self, lr=0.01, beta=0.9):
self.lr = lr
self.beta = beta
self.velocities = None
def step(self, params, grads):
if self.velocities is None:
self.velocities = [0.0] * len(params)
for i in range(len(params)):
self.velocities[i] = self.beta * self.velocities[i] + grads[i]
params[i] -= self.lr * self.velocities[i]
Paso 3: Adam
import math
class Adam:
def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.epsilon = epsilon
self.m = None
self.v = None
self.t = 0
def step(self, params, grads):
if self.m is None:
self.m = [0.0] * len(params)
self.v = [0.0] * len(params)
self.t += 1
for i in range(len(params)):
self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i]
self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2
m_hat = self.m[i] / (1 - self.beta1 ** self.t)
v_hat = self.v[i] / (1 - self.beta2 ** self.t)
params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)
Paso 4: AdamW
class AdamW:
def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8, weight_decay=0.01):
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.epsilon = epsilon
self.weight_decay = weight_decay
self.m = None
self.v = None
self.t = 0
def step(self, params, grads):
if self.m is None:
self.m = [0.0] * len(params)
self.v = [0.0] * len(params)
self.t += 1
for i in range(len(params)):
self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i]
self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2
m_hat = self.m[i] / (1 - self.beta1 ** self.t)
v_hat = self.v[i] / (1 - self.beta2 ** self.t)
params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)
params[i] -= self.lr * self.weight_decay * params[i]
Paso 5: Comparación de Entrenamiento
Entrena la misma red de dos capas en el conjunto de datos de círculo de la lección 05 con los cuatro optimizadores. Compara la convergencia.
import random
def sigmoid(x):
x = max(-500, min(500, x))
return 1.0 / (1.0 + math.exp(-x))
def make_circle_data(n=200, seed=42):
random.seed(seed)
data = []
for _ in range(n):
x = random.uniform(-2, 2)
y = random.uniform(-2, 2)
label = 1.0 if x * x + y * y < 1.5 else 0.0
data.append(([x, y], label))
return data
class OptimizerTestNetwork:
def __init__(self, optimizer, hidden_size=8):
random.seed(0)
self.hidden_size = hidden_size
self.optimizer = optimizer
self.w1 = [[random.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
self.b1 = [0.0] * hidden_size
self.w2 = [random.gauss(0, 0.5) for _ in range(hidden_size)]
self.b2 = 0.0
def get_params(self):
params = []
for row in self.w1:
params.extend(row)
params.extend(self.b1)
params.extend(self.w2)
params.append(self.b2)
return params
def set_params(self, params):
idx = 0
for i in range(self.hidden_size):
for j in range(2):
self.w1[i][j] = params[idx]
idx += 1
for i in range(self.hidden_size):
self.b1[i] = params[idx]
idx += 1
for i in range(self.hidden_size):
self.w2[i] = params[idx]
idx += 1
self.b2 = params[idx]
def forward(self, x):
self.x = x
self.z1 = []
self.h = []
for i in range(self.hidden_size):
z = self.w1[i][0] * x[0] + self.w1[i][1] * x[1] + self.b1[i]
self.z1.append(z)
self.h.append(max(0.0, z))
self.z2 = sum(self.w2[i] * self.h[i] for i in range(self.hidden_size)) + self.b2
self.out = sigmoid(self.z2)
return self.out
def compute_grads(self, target):
eps = 1e-15
p = max(eps, min(1 - eps, self.out))
d_loss = -(target / p) + (1 - target) / (1 - p)
d_sigmoid = self.out * (1 - self.out)
d_out = d_loss * d_sigmoid
grads = [0.0] * (self.hidden_size * 2 + self.hidden_size + self.hidden_size + 1)
idx = 0
for i in range(self.hidden_size):
d_relu = 1.0 if self.z1[i] > 0 else 0.0
d_h = d_out * self.w2[i] * d_relu
grads[idx] = d_h * self.x[0]
grads[idx + 1] = d_h * self.x[1]
idx += 2
for i in range(self.hidden_size):
d_relu = 1.0 if self.z1[i] > 0 else 0.0
grads[idx] = d_out * self.w2[i] * d_relu
idx += 1
for i in range(self.hidden_size):
grads[idx] = d_out * self.h[i]
idx += 1
grads[idx] = d_out
return grads
def train(self, data, epochs=300):
losses = []
for epoch in range(epochs):
total_loss = 0.0
correct = 0
for x, y in data:
pred = self.forward(x)
grads = self.compute_grads(y)
params = self.get_params()
self.optimizer.step(params, grads)
self.set_params(params)
eps = 1e-15
p = max(eps, min(1 - eps, pred))
total_loss += -(y * math.log(p) + (1 - y) * math.log(1 - p))
if (pred >= 0.5) == (y >= 0.5):
correct += 1
avg_loss = total_loss / len(data)
accuracy = correct / len(data) * 100
losses.append((avg_loss, accuracy))
if epoch % 75 == 0 or epoch == epochs - 1:
print(f" Epoch {epoch:3d}: loss={avg_loss:.4f}, accuracy={accuracy:.1f}%")
return losses
Úsalo
Los optimizadores de PyTorch manejan grupos de parámetros, clipping de gradiente y programación de la tasa de aprendizaje:
import torch
import torch.optim as optim
model = torch.nn.Sequential(
torch.nn.Linear(784, 256),
torch.nn.ReLU(),
torch.nn.Linear(256, 10),
)
optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)
for epoch in range(100):
optimizer.zero_grad()
output = model(torch.randn(32, 784))
loss = torch.nn.functional.cross_entropy(output, torch.randint(0, 10, (32,)))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
scheduler.step()
El patrón es siempre: zero_grad, forward, loss, backward, (clip), step, (schedule). Memoriza este orden. Equivocarse (por ejemplo, llamar a scheduler.step() antes de optimizer.step()) es una fuente común de errores sutiles.
Para las CNN, muchos profesionales aún prefieren SGD + momentum (lr=0,1, momentum=0,9, weight_decay=1e-4) con una programación por paso o coseno. El SGD encuentra mínimos más planos, que a menudo generalizan mejor. Para transformers y LLM, AdamW con warmup + decaimiento por coseno es el valor predeterminado universal. No pelees contra el consenso sin una razón medida.
Entrégalo
Esta lección produce:
outputs/prompt-optimizer-selector.md-- un prompt de decisión para elegir el optimizador y la tasa de aprendizaje correctos para cualquier arquitectura
Ejercicios
Implementa el momentum de Nesterov, donde calculas el gradiente en la posición "anticipada" (w - lr * beta * v) en lugar de la posición actual. Compara la convergencia con el momentum estándar en el conjunto de datos de círculo.
Implementa una programación de warmup de la tasa de aprendizaje: rampa lineal de 0 a max_lr en los primeros 10% de los pasos de entrenamiento, luego decaimiento por coseno hasta 0. Entrena con Adam + warmup vs Adam sin warmup. Mide cuántas épocas tarda en alcanzar el 90% de exactitud en el conjunto de datos de círculo.
Rastrea la tasa de aprendizaje efectiva para cada parámetro durante el entrenamiento con Adam. La tasa efectiva es lr * m_hat / (sqrt(v_hat) + eps). Grafica la distribución de las tasas efectivas después de 10, 50 y 200 pasos. ¿Todos los parámetros se actualizan a la misma velocidad?
Implementa el clipping de gradiente (clip por norma global). Establece la norma máxima del gradiente en 1,0. Entrena con y sin clipping usando una tasa de aprendizaje alta (lr=0,01 para Adam). Cuenta cuántas ejecuciones divergen (la pérdida llega a NaN) con y sin clipping a lo largo de 10 semillas aleatorias.
Compara Adam vs AdamW en una red con pesos grandes. Inicializa todos los pesos con valores aleatorios en [-5, 5] (mucho mayores que lo normal). Entrena durante 200 épocas con weight_decay=0,1. Grafica la norma L2 de los pesos a lo largo del entrenamiento para ambos optimizadores. AdamW debería mostrar un encogimiento de pesos más rápido.
Términos Clave
| Término | Lo que dice la gente | Lo que realmente significa |
|---|---|---|
| Tasa de aprendizaje | "Tamaño del paso" | El multiplicador escalar en la actualización del gradiente; el hiperparámetro de mayor impacto en el entrenamiento |
| SGD | "Descenso de gradiente básico" | Descenso de gradiente estocástico: actualiza los pesos restando lr * gradient, calculado en un mini-lote |
| Momentum | "Analogía de la bola rodando" | Promedio móvil exponencial de los gradientes pasados; amortigua la oscilación y acelera las direcciones consistentes |
| RMSProp | "Tasa de aprendizaje adaptativa" | Divide el gradiente de cada parámetro por el RMS móvil de sus gradientes recientes; iguala las tasas de aprendizaje |
| Adam | "El optimizador predeterminado" | Combina momentum (primer momento) y RMSProp (segundo momento) con corrección de sesgo para los pasos iniciales |
| AdamW | "Adam bien hecho" | Adam con weight decay desacoplado; aplica la regularización directamente a los pesos en lugar de a través del gradiente |
| Corrección de sesgo | "Warmup para promedios móviles" | Dividir por (1 - beta^t) para compensar la inicialización en cero de las estimaciones de momento de Adam |
| Weight decay | "Encoger los pesos" | Restar una fracción del valor del peso en cada paso; un regularizador que penaliza pesos grandes |
| Programación de la tasa de aprendizaje | "Cambiar la lr con el tiempo" | Una función que ajusta la tasa de aprendizaje durante el entrenamiento; warmup + decaimiento por coseno es el valor predeterminado moderno |
| Clipping de gradiente | "Limitar la norma del gradiente" | Reducir la escala del vector de gradiente cuando su norma excede un umbral; previene actualizaciones de gradiente explosivas |
Lectura Adicional
- Kingma & Ba, "Adam: A Method for Stochastic Optimization" (2014) -- el artículo original de Adam con análisis de convergencia y la derivación de la corrección de sesgo
- Loshchilov & Hutter, "Decoupled Weight Decay Regularization" (2017) -- demostró que la regularización L2 y el weight decay no son equivalentes en Adam, y propuso AdamW
- Smith, "Cyclical Learning Rates for Training Neural Networks" (2017) -- introdujo la prueba de rango de LR y las programaciones cíclicas que eliminan la necesidad de ajustar una tasa de aprendizaje fija
- Ruder, "An Overview of Gradient Descent Optimization Algorithms" (2016) -- el mejor estudio único de todas las variantes de optimizadores, con comparaciones e intuiciones claras