🏭 Caso de Uso

Comparativa de Optimizadores Adaptativos

Análisis detallado y comparación práctica de SGD, AdaGrad, RMSProp, AdaDelta, Adam, AdamW, NAdam y RAdam en MNIST.

🐍 Python 📓 Jupyter Notebook

Comparativa de Optimizadores Adaptativos

Analisis detallado y comparacion practica de SGD, AdaGrad, RMSProp, AdaDelta, Adam, AdamW, NAdam y RAdam en MNIST


Motivacion: del learning rate global al aprendizaje adaptativo

En el notebook sobre momentum vimos como acumular gradientes pasados acelera la convergencia. Sin embargo, tanto SGD como SGD+Momentum usan un unico learning rate global para todos los parametros. Esto es suboptimo porque:

  • Parametros asociados a features frecuentes reciben gradientes grandes y constantes. Un learning rate alto les basta.
  • Parametros asociados a features raras reciben gradientes esporadicos. Necesitan pasos mas grandes cuando aparecen, pero un learning rate alto global causaria inestabilidad en los parametros frecuentes.

Los optimizadores adaptativos resuelven esto manteniendo un learning rate individual por parametro, que se ajusta automaticamente segun el historial de gradientes de cada uno.

Los optimizadores que compararemos

1. SGD + Momentum (referencia)

$$v_t = \beta v_{t-1} + \nabla \mathcal{L}(\theta_t)$$ $$\theta_{t+1} = \theta_t - \eta , v_t$$

Learning rate global fijo. Referencia para comparar.

2. AdaGrad (Duchi et al., 2011)

Acumula el cuadrado de todos los gradientes pasados por parametro:

$$G_t = G_{t-1} + (\nabla \mathcal{L})^2$$ $$\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{G_t} + \epsilon} \nabla \mathcal{L}$$

Adapta el learning rate por parametro: features frecuentes reciben lr pequeno, raras lr grande. Problema: $G_t$ crece montonamente, haciendo que el lr efectivo decaiga a cero.

3. RMSProp (Hinton, 2012)

Corrige AdaGrad usando una media movil exponencial en lugar de la suma acumulada:

$$E[g^2]t = \gamma , E[g^2]{t-1} + (1 - \gamma)(\nabla \mathcal{L})^2$$ $$\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{E[g^2]_t} + \epsilon} \nabla \mathcal{L}$$

con $\gamma = 0.99$ tipicamente. El lr efectivo ya no decae a cero.

4. AdaDelta (Zeiler, 2012)

Similar a RMSProp pero elimina la necesidad de especificar un learning rate inicial. Usa la ratio entre la RMS de actualizaciones pasadas y la RMS de gradientes.

5. Adam (Kingma y Ba, 2015)

Combina momentum (media movil del gradiente) con RMSProp (media movil del cuadrado):

$$m_t = \beta_1 m_{t-1} + (1 - \beta_1) \nabla \mathcal{L}, \quad v_t = \beta_2 v_{t-1} + (1 - \beta_2) (\nabla \mathcal{L})^2$$

Con correccion de sesgo: $\hat{m}_t = m_t / (1 - \beta_1^t)$, $\hat{v}_t = v_t / (1 - \beta_2^t)$

$$\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t$$

6. AdamW (Loshchilov y Hutter, 2019)

Aplica decoupled weight decay en lugar de L2 regularization dentro de Adam. Es el optimizador preferido para Transformers y redes modernas.

7. NAdam (Dozat, 2016)

Incorpora Nesterov momentum en Adam, calculando un paso anticipado.

8. RAdam (Liu et al., 2020)

Resuelve la varianza alta de $v_t$ en las primeras iteraciones de Adam. Degenera a SGD con momentum al inicio y transiciona a Adam cuando la varianza se estabiliza.


Diseno del experimento

Componente Eleccion Justificacion
Dataset MNIST (60k train / 10k test) Problema sencillo para aislar el efecto del optimizador
Modelo MLP (784-512-256-128-10) Red profunda para evidenciar diferencias
Optimizadores 8 variantes Cobertura de las principales familias
Epocas 20 Suficientes para convergencia
Batch size 64 Estandar

Estructura del notebook

  1. Importaciones y configuracion
  2. Carga de MNIST
  3. Definicion del MLP
  4. Configuracion de los 8 optimizadores
  5. Entrenamiento comparativo
  6. Comparacion de curvas de loss y accuracy
  7. Velocidad de convergencia: epocas para alcanzar 95% y 97%
  8. Ranking final y tiempo de entrenamiento
  9. Robustez ante distintos learning rates
  10. Conclusiones y recomendaciones

1. Importaciones y configuracion

[1]
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np
import copy
import time
import warnings
warnings.filterwarnings('ignore')

SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Dispositivo: {device}")
print(f"PyTorch: {torch.__version__}")
Dispositivo: cuda
PyTorch: 2.10.0+cu128

2. Dataset: MNIST

[2]
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,)),
])

train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=0)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=0)

print(f"Train: {len(train_dataset):,} imagenes")
print(f"Test:  {len(test_dataset):,} imagenes")
100.0%
100.0%
100.0%
100.0%
Train: 60,000 imagenes
Test:  10,000 imagenes

3. Definicion del MLP

Usamos una red de 4 capas para tener mas profundidad y evidenciar las diferencias entre optimizadores. Todos comparten exactamente los mismos pesos iniciales.

[3]
class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(),
            nn.Linear(784, 512),
            nn.ReLU(),
            nn.Linear(512, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, 10),
        )

    def forward(self, x):
        return self.net(x)


torch.manual_seed(SEED)
base_model = MLP()
initial_weights = copy.deepcopy(base_model.state_dict())
n_params = sum(p.numel() for p in base_model.parameters())
print(f"Arquitectura: 784 -> 512 -> 256 -> 128 -> 10")
print(f"Parametros:   {n_params:,}")
Arquitectura: 784 -> 512 -> 256 -> 128 -> 10
Parametros:   567,434

4. Configuracion de los 8 optimizadores

Definimos cada optimizador con sus hiperparametros por defecto (segun el paper original). El learning rate base es 0.001, excepto para SGD (que necesita un lr mas alto) y AdaDelta (que no recibe lr como entrada).

[4]
def create_optimizers(model):
    """Crea un diccionario de optimizadores para el modelo dado."""
    return {
        'SGD + Momentum': optim.SGD(model.parameters(), lr=0.01, momentum=0.9),
        'AdaGrad': optim.Adagrad(model.parameters(), lr=0.01),
        'RMSProp': optim.RMSprop(model.parameters(), lr=0.001, alpha=0.99),
        'AdaDelta': optim.Adadelta(model.parameters(), rho=0.9),
        'Adam': optim.Adam(model.parameters(), lr=0.001),
        'AdamW': optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01),
        'NAdam': optim.NAdam(model.parameters(), lr=0.001),
        'RAdam': optim.RAdam(model.parameters(), lr=0.001),
    }

# Mostrar la configuracion
dummy_model = MLP()
optimizers_info = create_optimizers(dummy_model)
print(f"{'Optimizador':<18} {'Tipo':>25} {'LR':>8}")
print("=" * 53)
for name, opt in optimizers_info.items():
    lr = opt.defaults.get('lr', 'N/A')
    lr_str = f"{lr:.4f}" if isinstance(lr, float) else str(lr)
    print(f"{name:<18} {type(opt).__name__:>25} {lr_str:>8}")
del dummy_model
Optimizador                             Tipo       LR
=====================================================
SGD + Momentum                           SGD   0.0100
AdaGrad                              Adagrad   0.0100
RMSProp                              RMSprop   0.0010
AdaDelta                            Adadelta   1.0000
Adam                                    Adam   0.0010
AdamW                                  AdamW   0.0010
NAdam                                  NAdam   0.0010
RAdam                                  RAdam   0.0010

5. Entrenamiento comparativo

Entrenamos 8 copias del modelo (mismos pesos iniciales) con cada optimizador. Registramos loss, accuracy y tiempo por epoca.

[5]
EPOCHS = 20

def train_model(model, optimizer, train_loader, test_loader, epochs):
    """Entrena un modelo y devuelve historial completo."""
    criterion = nn.CrossEntropyLoss()
    history = {
        'train_loss': [], 'test_loss': [],
        'train_acc': [], 'test_acc': [],
        'epoch_times': [],
    }

    for epoch in range(epochs):
        start = time.time()

        # Entrenamiento
        model.train()
        total_loss = 0.0
        correct = 0
        total = 0
        for images, labels in train_loader:
            images, labels = images.to(device), labels.to(device)
            optimizer.zero_grad()
            outputs = model(images)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()
            total_loss += loss.item() * images.size(0)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

        history['train_loss'].append(total_loss / total)
        history['train_acc'].append(100.0 * correct / total)

        # Evaluacion
        model.eval()
        total_loss = 0.0
        correct = 0
        total = 0
        with torch.no_grad():
            for images, labels in test_loader:
                images, labels = images.to(device), labels.to(device)
                outputs = model(images)
                loss = criterion(outputs, labels)
                total_loss += loss.item() * images.size(0)
                _, predicted = torch.max(outputs, 1)
                total += labels.size(0)
                correct += (predicted == labels).sum().item()

        history['test_loss'].append(total_loss / total)
        history['test_acc'].append(100.0 * correct / total)
        history['epoch_times'].append(time.time() - start)

    return history


# Entrenar con cada optimizador
all_results = {}
opt_names = ['SGD + Momentum', 'AdaGrad', 'RMSProp', 'AdaDelta',
             'Adam', 'AdamW', 'NAdam', 'RAdam']

for name in opt_names:
    print(f"Entrenando con {name}...", end=' ')
    torch.manual_seed(SEED)
    model = MLP().to(device)
    model.load_state_dict(copy.deepcopy(initial_weights))

    # Crear optimizador
    opt_dict = create_optimizers(model)
    optimizer = opt_dict[name]

    history = train_model(model, optimizer, train_loader, test_loader, EPOCHS)
    all_results[name] = history
    total_time = sum(history['epoch_times'])
    print(f"Test Acc: {history['test_acc'][-1]:.2f}% | Tiempo: {total_time:.1f}s")

print("\nEntrenamiento completado.")
Entrenando con SGD + Momentum... Test Acc: 98.41% | Tiempo: 66.7s
Entrenando con AdaGrad... Test Acc: 98.41% | Tiempo: 67.7s
Entrenando con RMSProp... Test Acc: 98.27% | Tiempo: 67.8s
Entrenando con AdaDelta... Test Acc: 98.30% | Tiempo: 68.3s
Entrenando con Adam... Test Acc: 98.00% | Tiempo: 68.6s
Entrenando con AdamW... Test Acc: 97.73% | Tiempo: 68.9s
Entrenando con NAdam... Test Acc: 97.97% | Tiempo: 69.3s
Entrenando con RAdam... Test Acc: 97.95% | Tiempo: 69.6s

Entrenamiento completado.

6. Comparacion de curvas de loss y accuracy

[6]
# Paleta de colores para 8 optimizadores
colors_8 = {
    'SGD + Momentum': '#e74c3c',
    'AdaGrad': '#e67e22',
    'RMSProp': '#f1c40f',
    'AdaDelta': '#95a5a6',
    'Adam': '#0984E3',
    'AdamW': '#00B894',
    'NAdam': '#6c5ce7',
    'RAdam': '#d63031',
}

epochs_range = range(1, EPOCHS + 1)

fig, axes = plt.subplots(2, 2, figsize=(16, 11))

# Train Loss
ax = axes[0, 0]
for name in opt_names:
    ax.plot(epochs_range, all_results[name]['train_loss'],
            label=name, color=colors_8[name], linewidth=1.8)
ax.set_title('Train Loss', fontweight='bold')
ax.set_xlabel('Epoca')
ax.set_ylabel('Cross-Entropy Loss')
ax.legend(fontsize=7, ncol=2)
ax.grid(True, alpha=0.3)

# Test Loss
ax = axes[0, 1]
for name in opt_names:
    ax.plot(epochs_range, all_results[name]['test_loss'],
            label=name, color=colors_8[name], linewidth=1.8)
ax.set_title('Test Loss', fontweight='bold')
ax.set_xlabel('Epoca')
ax.set_ylabel('Cross-Entropy Loss')
ax.legend(fontsize=7, ncol=2)
ax.grid(True, alpha=0.3)

# Train Accuracy
ax = axes[1, 0]
for name in opt_names:
    ax.plot(epochs_range, all_results[name]['train_acc'],
            label=name, color=colors_8[name], linewidth=1.8)
ax.set_title('Train Accuracy', fontweight='bold')
ax.set_xlabel('Epoca')
ax.set_ylabel('Accuracy (%)')
ax.legend(fontsize=7, ncol=2)
ax.grid(True, alpha=0.3)

# Test Accuracy
ax = axes[1, 1]
for name in opt_names:
    ax.plot(epochs_range, all_results[name]['test_acc'],
            label=name, color=colors_8[name], linewidth=1.8)
ax.set_title('Test Accuracy', fontweight='bold')
ax.set_xlabel('Epoca')
ax.set_ylabel('Accuracy (%)')
ax.legend(fontsize=7, ncol=2)
ax.grid(True, alpha=0.3)

plt.suptitle('Comparativa de 8 optimizadores (MNIST, MLP 784-512-256-128-10)',
             fontweight='bold', fontsize=13, y=1.02)
plt.tight_layout()
plt.show()
Output

7. Velocidad de convergencia

Un aspecto clave es cuantas epocas necesita cada optimizador para alcanzar ciertos umbrales de accuracy. Un optimizador que alcanza 97% en la epoca 3 es mas eficiente que uno que lo logra en la epoca 15, incluso si ambos terminan con la misma accuracy final.

[7]
# Epocas para alcanzar umbrales de accuracy en test
thresholds = [90, 95, 97, 98]

print(f"{'Optimizador':<18}", end='')
for t in thresholds:
    print(f"  {t}% test acc", end='')
print(f"  {'Final':>8}")
print("=" * 75)

convergence_data = {}
for name in opt_names:
    row = []
    print(f"{name:<18}", end='')
    for t in thresholds:
        epoch_reached = None
        for ep, acc in enumerate(all_results[name]['test_acc'], 1):
            if acc >= t:
                epoch_reached = ep
                break
        row.append(epoch_reached)
        if epoch_reached:
            print(f"  {'ep ' + str(epoch_reached):>10}", end='')
        else:
            print(f"  {'---':>10}", end='')
    convergence_data[name] = row
    print(f"  {all_results[name]['test_acc'][-1]:>7.2f}%")
Optimizador         90% test acc  95% test acc  97% test acc  98% test acc     Final
===========================================================================
SGD + Momentum            ep 1        ep 1        ep 2        ep 8    98.41%
AdaGrad                   ep 1        ep 1        ep 2        ep 4    98.41%
RMSProp                   ep 1        ep 1        ep 2        ep 6    98.27%
AdaDelta                  ep 1        ep 1        ep 3        ep 6    98.30%
Adam                      ep 1        ep 1        ep 3        ep 6    98.00%
AdamW                     ep 1        ep 1        ep 2       ep 12    97.73%
NAdam                     ep 1        ep 1        ep 4        ep 5    97.97%
RAdam                     ep 1        ep 1        ep 4        ep 6    97.95%
[8]
# Visualizar velocidad de convergencia
fig, ax = plt.subplots(figsize=(12, 6))

x = np.arange(len(thresholds))
width = 0.1
offsets = np.arange(len(opt_names)) - len(opt_names)/2 + 0.5

for i, name in enumerate(opt_names):
    vals = [v if v is not None else EPOCHS+1 for v in convergence_data[name]]
    bars = ax.bar(x + offsets[i]*width, vals, width,
                  label=name, color=colors_8[name], alpha=0.8)

ax.set_xlabel('Umbral de accuracy en test')
ax.set_ylabel('Epocas necesarias')
ax.set_title('Velocidad de convergencia por optimizador', fontweight='bold')
ax.set_xticks(x)
ax.set_xticklabels([f'{t}%' for t in thresholds])
ax.legend(fontsize=7, ncol=2)
ax.grid(True, alpha=0.3, axis='y')
ax.set_ylim(0, EPOCHS + 2)

plt.tight_layout()
plt.show()
Output

8. Ranking final y tiempo de entrenamiento

[9]
# Ranking por accuracy final
ranking = sorted(opt_names, key=lambda n: all_results[n]['test_acc'][-1], reverse=True)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6))

# Accuracy final (barras horizontales)
final_accs = [all_results[n]['test_acc'][-1] for n in ranking]
colors_ranked = [colors_8[n] for n in ranking]
bars = ax1.barh(range(len(ranking)), final_accs, color=colors_ranked, alpha=0.8)
ax1.set_yticks(range(len(ranking)))
ax1.set_yticklabels(ranking)
ax1.set_xlabel('Test Accuracy (%)')
ax1.set_title('Ranking por accuracy final', fontweight='bold')
ax1.set_xlim(min(final_accs) - 2, max(final_accs) + 0.5)
for bar, acc in zip(bars, final_accs):
    ax1.text(bar.get_width() + 0.1, bar.get_y() + bar.get_height()/2,
             f'{acc:.2f}%', va='center', fontsize=9)
ax1.grid(True, alpha=0.3, axis='x')

# Tiempo total de entrenamiento
total_times = [sum(all_results[n]['epoch_times']) for n in ranking]
bars2 = ax2.barh(range(len(ranking)), total_times, color=colors_ranked, alpha=0.8)
ax2.set_yticks(range(len(ranking)))
ax2.set_yticklabels(ranking)
ax2.set_xlabel('Tiempo total (s)')
ax2.set_title('Tiempo de entrenamiento (20 epocas)', fontweight='bold')
for bar, t in zip(bars2, total_times):
    ax2.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2,
             f'{t:.1f}s', va='center', fontsize=9)
ax2.grid(True, alpha=0.3, axis='x')

plt.suptitle('Comparativa final de optimizadores',
             fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
Output

9. Robustez ante distintos learning rates

Un buen optimizador deberia funcionar razonablemente bien con un rango amplio de learning rates. Entrenemos cada optimizador con 5 learning rates diferentes (exceptuando AdaDelta, que no depende del lr) y veamos como varia la accuracy final.

[10]
learning_rates = [0.0001, 0.001, 0.005, 0.01, 0.05]
# Excluimos AdaDelta porque su lr no se usa de la misma manera
opt_for_lr = [n for n in opt_names if n != 'AdaDelta']
EPOCHS_LR = 10  # Menos epocas para este analisis

lr_results = {name: [] for name in opt_for_lr}

for lr in learning_rates:
    print(f"\nLR = {lr}:")
    for name in opt_for_lr:
        torch.manual_seed(SEED)
        model = MLP().to(device)
        model.load_state_dict(copy.deepcopy(initial_weights))

        # Crear optimizador con lr especifico
        if name == 'SGD + Momentum':
            opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
        elif name == 'AdaGrad':
            opt = optim.Adagrad(model.parameters(), lr=lr)
        elif name == 'RMSProp':
            opt = optim.RMSprop(model.parameters(), lr=lr, alpha=0.99)
        elif name == 'Adam':
            opt = optim.Adam(model.parameters(), lr=lr)
        elif name == 'AdamW':
            opt = optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
        elif name == 'NAdam':
            opt = optim.NAdam(model.parameters(), lr=lr)
        elif name == 'RAdam':
            opt = optim.RAdam(model.parameters(), lr=lr)

        criterion = nn.CrossEntropyLoss()
        diverged = False
        for epoch in range(EPOCHS_LR):
            model.train()
            for images, labels in train_loader:
                images, labels = images.to(device), labels.to(device)
                opt.zero_grad()
                outputs = model(images)
                loss = criterion(outputs, labels)
                if torch.isnan(loss):
                    diverged = True
                    break
                loss.backward()
                opt.step()
            if diverged:
                break

        if diverged:
            test_acc = 0.0
        else:
            model.eval()
            correct = total = 0
            with torch.no_grad():
                for images, labels in test_loader:
                    images, labels = images.to(device), labels.to(device)
                    outputs = model(images)
                    _, predicted = torch.max(outputs, 1)
                    total += labels.size(0)
                    correct += (predicted == labels).sum().item()
            test_acc = 100.0 * correct / total

        lr_results[name].append(test_acc)
        print(f"  {name:<18} -> {test_acc:.1f}%")
LR = 0.0001:
  SGD + Momentum     -> 89.6%
  AdaGrad            -> 89.6%
  RMSProp            -> 98.1%
  Adam               -> 97.6%
  AdamW              -> 97.7%
  NAdam              -> 97.8%
  RAdam              -> 97.8%

LR = 0.001:
  SGD + Momentum     -> 97.0%
  AdaGrad            -> 95.8%
  RMSProp            -> 98.2%
  Adam               -> 98.0%
  AdamW              -> 98.1%
  NAdam              -> 98.2%
  RAdam              -> 97.9%

LR = 0.005:
  SGD + Momentum     -> 98.0%
  AdaGrad            -> 98.2%
  RMSProp            -> 96.4%
  Adam               -> 97.2%
  AdamW              -> 97.0%
  NAdam              -> 96.8%
  RAdam              -> 97.0%

LR = 0.01:
  SGD + Momentum     -> 98.1%
  AdaGrad            -> 98.4%
  RMSProp            -> 95.4%
  Adam               -> 96.3%
  AdamW              -> 96.3%
  NAdam              -> 96.5%
  RAdam              -> 96.4%

LR = 0.05:
  SGD + Momentum     -> 98.0%
  AdaGrad            -> 97.8%
  RMSProp            -> 9.8%
  Adam               -> 21.0%
  AdamW              -> 10.1%
  NAdam              -> 10.1%
  RAdam              -> 10.1%
[11]
# Visualizar robustez ante lr
fig, ax = plt.subplots(figsize=(12, 6))

for name in opt_for_lr:
    ax.plot(learning_rates, lr_results[name], 'o-', color=colors_8[name],
            label=name, linewidth=2, markersize=6)

ax.set_xscale('log')
ax.set_xlabel('Learning Rate')
ax.set_ylabel('Test Accuracy (%) tras 10 epocas')
ax.set_title('Robustez ante distintos learning rates', fontweight='bold')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
ax.set_ylim(0, 100)

plt.tight_layout()
plt.show()

# Tabla resumen
print(f"\n{'Optimizador':<18}", end='')
for lr in learning_rates:
    print(f" lr={lr:<7}", end='')
print(f"  {'Rango':>8}")
print("=" * 75)
for name in opt_for_lr:
    print(f"{name:<18}", end='')
    valid = [a for a in lr_results[name] if a > 10]
    for acc in lr_results[name]:
        print(f" {acc:>7.1f}%", end='')
    rango = max(valid) - min(valid) if len(valid) > 1 else 0
    print(f"  {rango:>7.1f}%")
Output
Optimizador        lr=0.0001  lr=0.001   lr=0.005   lr=0.01    lr=0.05        Rango
===========================================================================
SGD + Momentum        89.6%    97.0%    98.0%    98.1%    98.0%      8.5%
AdaGrad               89.6%    95.8%    98.2%    98.4%    97.8%      8.8%
RMSProp               98.1%    98.2%    96.4%    95.4%     9.8%      2.8%
Adam                  97.6%    98.0%    97.2%    96.3%    21.0%     77.0%
AdamW                 97.7%    98.1%    97.0%    96.3%    10.1%     88.0%
NAdam                 97.8%    98.2%    96.8%    96.5%    10.1%     88.1%
RAdam                 97.8%    97.9%    97.0%    96.4%    10.1%     87.8%

10. Conclusiones y recomendaciones

Optimizador Ventaja principal Desventaja Cuando usarlo
SGD + Momentum Mejor generalizacion, simple Requiere ajuste fino de lr Cuando se puede hacer HPO del lr y se prioriza generalizacion
AdaGrad Bueno para features dispersas (NLP) lr decae a cero Problemas con embeddings dispersos (word2vec)
RMSProp Resuelve el decaimiento de AdaGrad Dos hiperparametros ($\eta$, $\gamma$) RNNs, problemas no estacionarios
AdaDelta No requiere lr Convergencia mas lenta Cuando no se quiere elegir lr (poco usado en la practica)
Adam Convergencia rapida, robusto Puede generalizar peor que SGD Punto de partida por defecto para la mayoria de problemas
AdamW Weight decay correcto Ligeramente mas lento Recomendado para redes modernas (Transformers, etc.)
NAdam Lookahead de Nesterov en Adam Marginal sobre Adam Cuando NAG mejora sobre momentum en el mismo problema
RAdam Estable en las primeras iteraciones Complejo conceptualmente Cuando se observa inestabilidad al inicio del entrenamiento

Guia practica

  1. Punto de partida: usar AdamW con lr=0.001 y weight_decay=0.01
  2. Si AdamW no generaliza bien: probar SGD + Nesterov con lr schedule
  3. Para NLP con embeddings: considerar AdaGrad o Adam
  4. Para RNNs: RMSProp o Adam suelen funcionar mejor que SGD
  5. Para Transformers: AdamW es el estandar de facto

En el siguiente notebook veremos como combinar estos optimizadores con schedulers de learning rate para obtener lo mejor de ambos mundos.