Phase 01 - Lesson 08

Optimización

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Entrenar una red neuronal no es más que encontrar el fondo de un valle.

Tipo: Build Lenguaje: Python Requisitos previos: Fase 1, Lecciones 04-05 (Derivadas, Gradientes) Tiempo: ~75 minutos

Objetivos de Aprendizaje

  • Implementar el descenso de gradiente puro, el SGD con momento y el Adam desde cero
  • Comparar la convergencia de los optimizadores en la función de Rosenbrock y explicar por que Adam adapta las tasas de aprendizaje por peso
  • Distinguir paisajes de pérdida convexos de no convexos y explicar el rol de los puntos de silla en altas dimensiones
  • Configurar programaciones de tasa de aprendizaje (decaimiento por escalón, recocido por coseno, calentamiento) para la estabilidad del entrenamiento

El Problema

Tienes una función de pérdida. Te dice cuan equivocado esta tu modelo. Tienes gradientes. Te dicen que dirección empeora la pérdida. Ahora necesitas una estrategia para bajar la pendiente.

El enfoque ingenuo es simple: muévete en dirección opuesta al gradiente. Escala el paso por un número llamado tasa de aprendizaje. Repite. Esto es el descenso de gradiente, y funciona. Pero "funciona" tiene salvedades. Una tasa de aprendizaje demasiado grande y te pasas del valle por completo, rebotando entre las paredes. Demasiado pequeña y te arrastras hacia la respuesta a lo largo de miles de pasos innecesarios. Llega a un punto de silla y dejas de moverte aunque no hayas encontrado un mínimo.

Todo optimizador en deep learning es una respuesta a la misma pregunta: ¿cómo llegar al fondo del valle más rápido y de forma más confiable?

El Concepto

Que significa optimización

La optimización es encontrar los valores de entrada que minimizan (o maximizan) una función. En machine learning, la función es la pérdida. Las entradas son los pesos del modelo. El entrenamiento es optimización.

minimizar L(w) donde:
  L = funcion de perdida
  w = pesos del modelo (pueden ser millones de parametros)

Descenso de gradiente (puro)

El optimizador más simple. Calcula el gradiente de la pérdida respecto a cada peso. Mueve cada peso en la dirección opuesta a su gradiente. Escala el paso por la tasa de aprendizaje.

w = w - lr * gradient

Ese es el algoritmo completo. Una línea.

graph TD
    A["* Starting point (high loss)"] --> B["Moving downhill along gradient"]
    B --> C["Approaching minimum"]
    C --> D["o Minimum (low loss)"]

Tasa de aprendizaje: el hiperparámetro más importante

La tasa de aprendizaje controla el tamaño del paso. Determina todo sobre la convergencia.

graph LR
    subgraph TooLarge["Too Large (lr = 1.0)"]
        A1["Step 1"] -->|overshoot| A2["Step 2"]
        A2 -->|overshoot| A3["Step 3"]
        A3 -->|diverging| A4["..."]
    end
    subgraph TooSmall["Too Small (lr = 0.0001)"]
        B1["Step 1"] -->|tiny step| B2["Step 2"]
        B2 -->|tiny step| B3["Step 3"]
        B3 -->|10,000 steps later| B4["Minimum"]
    end
    subgraph JustRight["Just Right (lr = 0.01)"]
        C1["Start"] --> C2["..."] --> C3["Converged in ~100 steps"]
    end

No hay fórmula para la tasa de aprendizaje correcta. La encuentras experimentando. Puntos de partida comunes: 0.001 para Adam, 0.01 para SGD con momento.

SGD vs batch vs mini-batch

El descenso de gradiente puro calcula el gradiente sobre todo el conjunto de datos antes de dar un paso. Esto se llama descenso de gradiente por batch. Es estable pero lento.

El descenso de gradiente estocástico (SGD) calcula el gradiente sobre una sola muestra aleatoria y da el paso de inmediato. Es ruidoso pero rápido.

El descenso de gradiente por mini-batch parte la diferencia. Calcula el gradiente sobre un pequeño batch (32, 64, 128, 256 muestras), luego da el paso. Esto es lo que todos usan en realidad.

Variante Tamaño del batch Calidad del gradiente Velocidad por paso Ruido
GD por batch Conjunto de datos completo Exacto Lento Ninguno
SGD 1 muestra Muy ruidoso Rápido Alto
Mini-batch 32-256 Buena estimación Equilibrado Moderado

El ruido en el SGD y el mini-batch no es un bug. Ayuda a escapar de mínimos locales poco profundos y de puntos de silla.

Momento: la pelota rodando cuesta abajo

El descenso de gradiente puro solo mira el gradiente actual. Si el gradiente zigzaguea (común en valles estrechos), el progreso es lento. El momento corrige esto acumulando gradientes pasados en un término de velocidad.

v = beta * v + gradient
w = w - lr * v

La analogía: una pelota rodando cuesta abajo. No se detiene y reinicia en cada bache. Gana velocidad en direcciones consistentes y amortigua las oscilaciones.

graph TD
    subgraph Without["Without Momentum (zigzag, slow)"]
        W1["Start"] -->|left| W2[" "]
        W2 -->|right| W3[" "]
        W3 -->|left| W4[" "]
        W4 -->|right| W5[" "]
        W5 -->|left| W6[" "]
        W6 --> W7["Minimum"]
    end
    subgraph With["With Momentum (smooth, fast)"]
        M1["Start"] --> M2[" "] --> M3[" "] --> M4["Minimum"]
    end

beta (típicamente 0.9) controla cuanto historial conservar. Un beta más alto significa más momento, caminos más suaves, pero una respuesta más lenta a los cambios de dirección.

Adam: tasas de aprendizaje adaptativas

Pesos distintos necesitan tasas de aprendizaje distintas. Un peso que rara vez recibe gradientes grandes deberia dar pasos más grandes cuando finalmente los recibe. Un peso que recibe gradientes enormes constantemente deberia dar pasos más pequeños.

Adam (Adaptive Moment Estimation) rastrea dos cosas por peso:

  1. Primer momento (m): promedio móvil de gradientes (como el momento)
  2. Segundo momento (v): promedio móvil de gradientes al cuadrado (magnitud del gradiente)
m = beta1 * m + (1 - beta1) * gradient
v = beta2 * v + (1 - beta2) * gradient^2

m_hat = m / (1 - beta1^t)    bias correction
v_hat = v / (1 - beta2^t)    bias correction

w = w - lr * m_hat / (sqrt(v_hat) + epsilon)

La división por sqrt(v_hat) es la idea clave. Los pesos con gradientes grandes se dividen por un número grande (paso efectivo pequeño). Los pesos con gradientes pequeños se dividen por un número pequeño (paso efectivo grande). Cada peso obtiene su propia tasa de aprendizaje adaptativa.

Hiperparámetros por defecto: lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8. Estos valores por defecto funcionan bien para la mayoría de los problemas.

Programaciones de tasa de aprendizaje

Una tasa de aprendizaje fija es un compromiso. Al inicio del entrenamiento, quieres pasos grandes para avanzar rápido. Al final del entrenamiento, quieres pasos pequeños para el ajuste fino cerca del mínimo.

Programaciones comunes:

Programación Fórmula Caso de uso
Decaimiento por escalón lr = lr * factor cada N épocas Simple, control manual
Decaimiento exponencial lr = lr_0 * decay^t Reducción suave
Recocido por coseno lr = lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * t / T)) Transformers, entrenamiento moderno
Calentamiento + decaimiento Rampa lineal de subida, luego decaimiento Modelos grandes, evita la inestabilidad inicial

Convexo vs no convexo

Una función convexa tiene un mínimo. El descenso de gradiente siempre lo encuentra. Una cuadrática como f(x) = x^2 es convexa.

Las funciones de pérdida de las redes neuronales son no convexas. Tienen muchos mínimos locales, puntos de silla y regiones planas.

graph LR
    subgraph Convex["Convex: One valley, one answer"]
        direction TB
        CV1["High loss"] --> CV2["Global minimum"]
    end
    subgraph NonConvex["Non-convex: Multiple valleys, saddle points"]
        direction TB
        NC1["Start"] --> NC2["Local minimum"]
        NC1 --> NC3["Saddle point"]
        NC1 --> NC4["Global minimum"]
    end

En la practica, los mínimos locales en redes neuronales de alta dimensión rara vez son un problema. La mayoría de los mínimos locales tienen valores de pérdida cercanos al mínimo global. Los puntos de silla (planos en algunas direcciones, curvos en otras) son el verdadero obstáculo. El momento y el ruido de los mini-batches ayudan a escapar de ellos.

Visualización del paisaje de pérdida

La pérdida es una función de todos los pesos. Para un modelo con 1 millón de pesos, el paisaje de pérdida vive en un espacio de 1.000.001 dimensiones. Lo visualizamos eligiendo dos direcciones aleatorias en el espacio de pesos y graficando la pérdida a lo largo de esas direcciones, produciendo una superficie 2D.

graph TD
    HL["High loss region"] --> SP["Saddle point"]
    HL --> LM["Local minimum"]
    SP --> LM
    SP --> GM["Global minimum"]
    LM -.->|"shallow barrier"| GM
    style HL fill:#ff6666,color:#000
    style SP fill:#ffcc66,color:#000
    style LM fill:#66ccff,color:#000
    style GM fill:#66ff66,color:#000

Los mínimos agudos generalizan mal. Los mínimos planos generalizan bien. Esta es una de las razones por las que el SGD con momento a menudo supera a Adam en la precisión final de prueba: su ruido evita asentarse en mínimos agudos.

Construye

Paso 1: Definir una función de prueba

La función de Rosenbrock es un benchmark clásico de optimización. Su mínimo está en (1, 1) dentro de un valle curvo y estrecho que es fácil de encontrar pero difícil de seguir.

f(x, y) = (1 - x)^2 + 100 * (y - x^2)^2
def rosenbrock(params):
    x, y = params
    return (1 - x) ** 2 + 100 * (y - x ** 2) ** 2

def rosenbrock_gradient(params):
    x, y = params
    df_dx = -2 * (1 - x) + 200 * (y - x ** 2) * (-2 * x)
    df_dy = 200 * (y - x ** 2)
    return [df_dx, df_dy]

Paso 2: Descenso de gradiente puro

class GradientDescent:
    def __init__(self, lr=0.001):
        self.lr = lr

    def step(self, params, grads):
        return [p - self.lr * g for p, g in zip(params, grads)]

Paso 3: SGD con momento

class SGDMomentum:
    def __init__(self, lr=0.001, momentum=0.9):
        self.lr = lr
        self.momentum = momentum
        self.velocity = None

    def step(self, params, grads):
        if self.velocity is None:
            self.velocity = [0.0] * len(params)
        self.velocity = [
            self.momentum * v + g
            for v, g in zip(self.velocity, grads)
        ]
        return [p - self.lr * v for p, v in zip(params, self.velocity)]

Paso 4: Adam

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

        self.m = [
            self.beta1 * m + (1 - self.beta1) * g
            for m, g in zip(self.m, grads)
        ]
        self.v = [
            self.beta2 * v + (1 - self.beta2) * g ** 2
            for v, g in zip(self.v, grads)
        ]

        m_hat = [m / (1 - self.beta1 ** self.t) for m in self.m]
        v_hat = [v / (1 - self.beta2 ** self.t) for v in self.v]

        return [
            p - self.lr * mh / (vh ** 0.5 + self.epsilon)
            for p, mh, vh in zip(params, m_hat, v_hat)
        ]

Paso 5: Ejecutar y comparar

def optimize(optimizer, func, grad_func, start, steps=5000):
    params = list(start)
    history = [params[:]]
    for _ in range(steps):
        grads = grad_func(params)
        params = optimizer.step(params, grads)
        history.append(params[:])
    return history

start = [-1.0, 1.0]

gd_history = optimize(GradientDescent(lr=0.0005), rosenbrock, rosenbrock_gradient, start)
sgd_history = optimize(SGDMomentum(lr=0.0001, momentum=0.9), rosenbrock, rosenbrock_gradient, start)
adam_history = optimize(Adam(lr=0.01), rosenbrock, rosenbrock_gradient, start)

for name, history in [("GD", gd_history), ("SGD+M", sgd_history), ("Adam", adam_history)]:
    final = history[-1]
    loss = rosenbrock(final)
    print(f"{name:6s} -> x={final[0]:.6f}, y={final[1]:.6f}, loss={loss:.8f}")

Salida esperada: Adam converge más rápido. El SGD con momento sigue un camino más suave. El GD puro avanza lentamente a lo largo del valle estrecho.

Usa

En la practica, usa los optimizadores de PyTorch o JAX. Manejan grupos de parámetros, weight decay, recorte de gradiente y aceleración por GPU.

import torch

model = torch.nn.Linear(784, 10)

sgd = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
adam = torch.optim.Adam(model.parameters(), lr=0.001)
adamw = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(adam, T_max=100)

Reglas generales:

  • Empieza con Adam (lr=0.001). Funciona para la mayoría de los problemas sin ajuste.
  • Cambia a SGD con momento (lr=0.01, momentum=0.9) cuando necesites la mejor precisión final y puedas permitirte más ajuste.
  • Usa AdamW (Adam con weight decay desacoplado) para transformers.
  • Usa siempre una programación de tasa de aprendizaje para ejecuciones de entrenamiento más largas que unas pocas épocas.
  • Si el entrenamiento es inestable, reduce la tasa de aprendizaje. Si el entrenamiento es demasiado lento, auméntala.

Entrega

Esta lección produce un prompt para elegir el optimizador correcto. Ver outputs/prompt-optimizer-guide.md.

Las clases de optimizador construidas aquí reaparecen en la Fase 3 cuando entrenamos una red neuronal desde cero.

Ejercicios

  1. Barrido de tasa de aprendizaje. Ejecuta el descenso de gradiente puro en la función de Rosenbrock con tasas de aprendizaje [0.0001, 0.0005, 0.001, 0.005, 0.01]. Grafica o imprime la pérdida final tras 5000 pasos para cada una. Encuentra la mayor tasa de aprendizaje que aún converge.

  2. Comparación de momento. Ejecuta el SGD con valores de momento [0.0, 0.5, 0.9, 0.99] en la función de Rosenbrock. Rastrea la pérdida en cada paso. ¿Qué valor de momento converge más rápido? Cual se pasa?

  3. Escape de punto de silla. Define la función f(x, y) = x^2 - y^2 (un punto de silla en el origen). Empieza en (0.01, 0.01). Compara como se comportan el GD puro, el SGD con momento y Adam. ¿Cuál escapa del punto de silla?

  4. Implementa el decaimiento de la tasa de aprendizaje. Agrega una programación de decaimiento exponencial a la clase GradientDescent: lr = lr_0 * 0.999^step. Compara la convergencia con y sin decaimiento en la función de Rosenbrock.

Términos Clave

Término Lo que dice la gente Lo que realmente significa
Descenso de gradiente "Ve cuesta abajo" Actualizar los pesos restando el gradiente escalado por la tasa de aprendizaje. El optimizador más básico.
Tasa de aprendizaje "Tamaño del paso" Un escalar que controla cuanto mueve los pesos cada actualización. Demasiado grande causa divergencia. Demasiado pequeña desperdicia cómputo.
Momento "Sigue rodando" Acumular gradientes pasados en un vector de velocidad. Amortigua las oscilaciones y acelera el movimiento en direcciones consistentes.
SGD "Muestreo aleatorio" Descenso de gradiente estocástico. Calcular el gradiente sobre un subconjunto aleatorio en lugar del conjunto de datos completo. Casi siempre significa SGD por mini-batch en la practica.
Mini-batch "Un trozo de datos" Un pequeño subconjunto de los datos de entrenamiento (32-256 muestras) usado para estimar el gradiente. Equilibra velocidad y precisión del gradiente.
Adam "El optimizador por defecto" Adaptive Moment Estimation. Rastrea promedios móviles por peso de gradientes y gradientes al cuadrado para dar a cada peso su propia tasa de aprendizaje.
Corrección de sesgo "Arregla el arranque en frío" El primer y segundo momento de Adam se inicializan en cero. La corrección de sesgo divide por (1 - beta^t) para compensar durante los pasos iniciales.
Programación de tasa de aprendizaje "Cambiar la lr con el tiempo" Una función que ajusta la tasa de aprendizaje durante el entrenamiento. Pasos grandes al inicio, pasos pequeños al final.
Función convexa "Un valle" Una función donde cualquier mínimo local es el mínimo global. El descenso de gradiente siempre lo encuentra. Las pérdidas de redes neuronales no son convexas.
Punto de silla "Plano pero no es un mínimo" Un punto donde el gradiente es cero, pero es un mínimo en algunas direcciones y un máximo en otras. Común en altas dimensiones.
Paisaje de pérdida "El terreno" La función de pérdida graficada sobre el espacio de pesos. Visualizada cortándola a lo largo de dos direcciones aleatorias.
Convergencia "Llegar ahí" El optimizador ha alcanzado un punto donde los pasos adicionales no reducen significativamente la pérdida.

Lectura Adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).