Phase 03 - Lesson 06
Otimizadores
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
O gradiente descendente diz em qual direção se mover. Não diz nada sobre o quão longe ou o quão rápido. O SGD é uma bússola. O Adam é um GPS com dados de trânsito.
Tipo: Build Linguagens: Python Pré-requisitos: Lição 03.05 (Funções de Perda) Tempo: ~75 minutos
Objetivos de Aprendizagem
- Implementar os otimizadores SGD, SGD com momentum, Adam e AdamW do zero em Python
- Explicar como a correção de viés do Adam compensa as estimativas de momento inicializadas em zero nos primeiros passos do treinamento
- Demonstrar por que o AdamW produz melhor generalização do que o Adam com regularização L2 na mesma tarefa
- Selecionar o otimizador apropriado e os hiperparâmetros padrão para transformers, CNNs, GANs e fine-tuning
O Problema
Você calculou os gradientes. Você sabe que o peso #4.721 deve diminuir em 0,003 para reduzir a perda. Mas 0,003 em que unidades? Escalado por quê? E você deveria se mover na mesma quantidade no passo 1 e no passo 1.000?
O gradiente descendente comum aplica a mesma taxa de aprendizado a cada parâmetro em cada passo: w = w - lr * gradient. Isso cria três problemas que tornam o treinamento de redes neurais doloroso na prática.
Primeiro, oscilação. O relevo da função de perda raramente tem o formato de uma tigela suave. É mais parecido com um vale longo e estreito. O gradiente aponta para o outro lado do vale (direção íngreme), não ao longo dele (direção rasa). O gradiente descendente fica saltando de um lado para o outro na dimensão estreita enquanto faz um progresso minúsculo na dimensão útil. Você já viu isso: a perda cai rápido e depois estabiliza, não porque o modelo convergiu, mas porque está oscilando.
Segundo, uma única taxa de aprendizado para todos os parâmetros está errada. Alguns pesos precisam de grandes atualizações (estão no estágio inicial de subajuste). Outros precisam de atualizações minúsculas (estão perto de seu valor ótimo). Uma taxa de aprendizado que funciona para os primeiros destrói os segundos, e vice-versa.
Terceiro, pontos de sela. Em altas dimensões, o relevo da função de perda tem vastas regiões planas onde o gradiente é próximo de zero. O SGD comum rasteja por essas regiões na velocidade do gradiente, que é efetivamente zero. O modelo parece travado. Ele não está travado -- está em uma região plana com descida útil do outro lado. Mas o SGD não tem nenhum mecanismo para empurrar através dela.
O Adam resolve os três. Ele mantém duas médias móveis por parâmetro -- o gradiente médio (momentum, lida com a oscilação) e o gradiente quadrático médio (taxa adaptativa, lida com escalas diferentes). Combinado com a correção de viés para os primeiros passos, ele oferece um único otimizador que funciona em 80% dos problemas com os hiperparâmetros padrão. Esta lição o constrói do zero para que você entenda exatamente quando e por que ele falha nos outros 20%.
O Conceito
Gradiente Descendente Estocástico (SGD)
O otimizador mais simples. Calcule o gradiente em um mini-lote e dê um passo na direção oposta.
w = w - lr * gradient
O "estocástico" significa que você usa um subconjunto aleatório (mini-lote) dos dados para estimar o gradiente, em vez do conjunto de dados completo. Esse ruído é, na verdade, útil -- ele ajuda a escapar de mínimos locais acentuados. Mas o ruído também causa oscilação.
A taxa de aprendizado é o único botão. Muito alta: a perda diverge. Muito baixa: o treinamento leva uma eternidade. O valor ótimo depende da arquitetura, dos dados, do tamanho do lote e do estágio atual do treinamento. Para o SGD comum em redes modernas, os valores típicos variam de 0,01 a 0,1. Mas mesmo dentro de uma única execução de treinamento, a taxa de aprendizado ideal muda.
Momentum
A analogia da bola rolando ladeira abaixo é usada em excesso, mas é precisa. Em vez de dar um passo apenas pelo gradiente, você mantém uma velocidade que acumula os gradientes passados.
m_t = beta * m_{t-1} + gradient
w = w - lr * m_t
O beta (tipicamente 0,9) controla quanto histórico manter. Com beta = 0,9, o momentum é aproximadamente a média dos últimos 10 gradientes (1 / (1 - 0,9) = 10).
Por que isso corrige a oscilação: gradientes que apontam na mesma direção se acumulam. Gradientes que invertem de direção se cancelam. Naquele vale estreito, a componente "transversal" inverte o sinal a cada passo e é amortecida. A componente "longitudinal" permanece consistente e é amplificada. O resultado é uma aceleração suave na direção útil.
Números reais: o SGD sozinho em um relevo de perda mal condicionado pode levar 10.000 passos. O SGD com momentum (beta=0,9) tipicamente leva de 3.000 a 5.000 passos no mesmo problema. A aceleração não é marginal.
RMSProp
O primeiro método de taxa de aprendizado adaptativa por parâmetro que realmente funcionou. Proposto por Hinton em uma aula do Coursera (nunca publicado formalmente).
s_t = beta * s_{t-1} + (1 - beta) * gradient^2
w = w - lr * gradient / (sqrt(s_t) + epsilon)
s_t rastreia a média móvel dos gradientes quadráticos. Parâmetros com gradientes consistentemente grandes são divididos por um número grande (taxa de aprendizado efetiva menor). Parâmetros com gradientes pequenos são divididos por um número pequeno (taxa de aprendizado efetiva maior).
Isso resolve o problema da "uma taxa de aprendizado para todos os parâmetros". Um peso que já vem recebendo grandes atualizações provavelmente está perto de seu alvo -- desacelere-o. Um peso que vem recebendo atualizações minúsculas pode estar subtreinado -- acelere-o.
O epsilon (tipicamente 1e-8) evita a divisão por zero quando um parâmetro não foi atualizado.
Adam: Momentum + RMSProp
O Adam combina as duas ideias. Ele mantém duas médias móveis exponenciais por parâmetro:
m_t = beta1 * m_{t-1} + (1 - beta1) * gradient (primeiro momento: média)
v_t = beta2 * v_{t-1} + (1 - beta2) * gradient^2 (segundo momento: variância)
A correção de viés é o detalhe-chave que a maioria das explicações pula. No passo 1, m_1 = (1 - beta1) * gradient. Com beta1 = 0,9, isso é 0,1 * gradient -- dez vezes pequeno demais. A média móvel ainda não aqueceu. A correção de viés compensa:
m_hat = m_t / (1 - beta1^t)
v_hat = v_t / (1 - beta2^t)
No passo 1 com beta1 = 0,9: m_hat = m_1 / (1 - 0,9) = m_1 / 0,1 = o gradiente real. No passo 100: (1 - 0,9^100) é aproximadamente 1,0, então a correção desaparece. A correção de viés importa nos primeiros ~10 passos e é irrelevante depois de ~50.
A atualização:
w = w - lr * m_hat / (sqrt(v_hat) + epsilon)
Padrões do Adam: lr = 0,001, beta1 = 0,9, beta2 = 0,999, epsilon = 1e-8. Esses padrões funcionam para 80% dos problemas. Quando não funcionam, mude a lr primeiro. Depois o beta2. Quase nunca mude o beta1 ou o epsilon.
AdamW: Weight Decay Feito Direito
A regularização L2 adiciona lambda * w^2 à perda. No SGD comum, isso é equivalente ao weight decay (subtrair lambda * w do peso a cada passo). No Adam, essa equivalência se quebra.
O insight de Loshchilov & Hutter: quando você adiciona L2 à perda e então o Adam processa o gradiente, a taxa de aprendizado adaptativa escala também o termo de regularização. Parâmetros com grande variância de gradiente recebem menos regularização. Parâmetros com pequena variância recebem mais. Isso não é o que você quer -- você quer uma regularização uniforme, independentemente das estatísticas do gradiente.
O AdamW corrige isso aplicando o weight decay diretamente aos pesos, após a atualização do Adam:
w = w - lr * m_hat / (sqrt(v_hat) + epsilon) - lr * lambda * w
O termo de weight decay (lr * lambda * w) não é escalado pelo fator adaptativo do Adam. Todo parâmetro recebe o mesmo encolhimento proporcional.
Isso parece um detalhe menor. Não é. O AdamW converge para soluções melhores do que o Adam + regularização L2 em praticamente todas as tarefas. É o otimizador padrão no PyTorch para treinar transformers, modelos de difusão e a maioria das arquiteturas modernas. BERT, GPT, LLaMA, Stable Diffusion -- todos treinados com AdamW.
Taxa de Aprendizado: O Hiperparâmetro Mais Importante
graph TD
LR["Taxa de Aprendizado"] --> TooHigh["Muito alta (lr > 0.01)"]
LR --> JustRight["Na medida certa"]
LR --> TooLow["Muito baixa (lr < 0.00001)"]
TooHigh --> Diverge["A perda explode<br/>Pesos NaN<br/>Treinamento trava"]
JustRight --> Converge["A perda decresce de forma constante<br/>Atinge um bom mínimo<br/>Generaliza bem"]
TooLow --> Stall["A perda decresce lentamente<br/>Fica presa em um mínimo subótimo<br/>Desperdiça computação"]
JustRight --> Schedule["Geralmente precisa de agendamento"]
Schedule --> Warmup["Warmup: rampa de 0 até o máximo<br/>Primeiros 1-10% do treinamento"]
Schedule --> Decay["Decay: reduz ao longo do tempo<br/>Cosseno ou linear"]
Se você ajustar um único hiperparâmetro, ajuste a taxa de aprendizado. Uma mudança de 10x na taxa de aprendizado importa mais do que qualquer decisão arquitetural que você venha a tomar. Padrões comuns:
- SGD: lr = 0,01 a 0,1
- Adam/AdamW: lr = 1e-4 a 3e-4
- Fine-tuning de modelos pré-treinados: lr = 1e-5 a 5e-5
- Warmup da taxa de aprendizado: rampa linear nos primeiros 1-10% dos passos
Comparação de Otimizadores
flowchart LR
subgraph "Caminho de Otimização"
SGD_P["SGD<br/>Oscila pelo vale<br/>Lento, mas encontra mínimos planos"]
Mom_P["SGD + Momentum<br/>Caminho mais suave<br/>3x mais rápido que SGD"]
Adam_P["Adam<br/>Adapta por parâmetro<br/>Convergência rápida"]
AdamW_P["AdamW<br/>Adam + decay apropriado<br/>Melhor generalização"]
end
SGD_P --> Mom_P --> Adam_P --> AdamW_P
Quando Cada Otimizador Vence
flowchart TD
Task["O que você está treinando?"] --> 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 -->|"Ainda não sei"| Default["Comece com AdamW<br/>lr=3e-4, wd=0.01"]
Construa
Passo 1: SGD Comum
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]
Passo 2: SGD com 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]
Passo 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)
Passo 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]
Passo 5: Comparação de Treinamento
Treine a mesma rede de duas camadas no conjunto de dados de círculo da lição 05 com os quatro otimizadores. Compare a convergência.
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
Use
Os otimizadores do PyTorch lidam com grupos de parâmetros, clipping de gradiente e agendamento da taxa de aprendizado:
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()
O padrão é sempre: zero_grad, forward, loss, backward, (clip), step, (schedule). Memorize essa ordem. Errá-la (por exemplo, chamar scheduler.step() antes de optimizer.step()) é uma fonte comum de bugs sutis.
Para CNNs, muitos profissionais ainda preferem SGD + momentum (lr=0,1, momentum=0,9, weight_decay=1e-4) com um agendamento por passo ou cosseno. O SGD encontra mínimos mais planos, que muitas vezes generalizam melhor. Para transformers e LLMs, o AdamW com warmup + decaimento por cosseno é o padrão universal. Não brigue com o consenso sem um motivo medido.
Entregue
Esta lição produz:
outputs/prompt-optimizer-selector.md-- um prompt de decisão para escolher o otimizador e a taxa de aprendizado corretos para qualquer arquitetura
Exercícios
Implemente o momentum de Nesterov, onde você calcula o gradiente na posição "antecipada" (w - lr * beta * v) em vez da posição atual. Compare a convergência com o momentum padrão no conjunto de dados de círculo.
Implemente um agendamento de warmup da taxa de aprendizado: rampa linear de 0 até max_lr nos primeiros 10% dos passos de treinamento, depois decaimento por cosseno até 0. Treine com Adam + warmup vs Adam sem warmup. Meça quantas épocas leva para atingir 90% de acurácia no conjunto de dados de círculo.
Rastreie a taxa de aprendizado efetiva para cada parâmetro durante o treinamento com Adam. A taxa efetiva é lr * m_hat / (sqrt(v_hat) + eps). Plote a distribuição das taxas efetivas após 10, 50 e 200 passos. Todos os parâmetros estão sendo atualizados na mesma velocidade?
Implemente o clipping de gradiente (clip pela norma global). Defina a norma máxima do gradiente como 1,0. Treine com e sem clipping usando uma taxa de aprendizado alta (lr=0,01 para o Adam). Conte quantas execuções divergem (a perda vai para NaN) com e sem clipping ao longo de 10 sementes aleatórias.
Compare Adam vs AdamW em uma rede com pesos grandes. Inicialize todos os pesos com valores aleatórios em [-5, 5] (muito maiores que o normal). Treine por 200 épocas com weight_decay=0,1. Plote a norma L2 dos pesos ao longo do treinamento para ambos os otimizadores. O AdamW deve mostrar um encolhimento de pesos mais rápido.
Termos-Chave
| Termo | O que as pessoas dizem | O que de fato significa |
|---|---|---|
| Taxa de aprendizado | "Tamanho do passo" | O multiplicador escalar na atualização do gradiente; o hiperparâmetro de maior impacto no treinamento |
| SGD | "Gradiente descendente básico" | Gradiente descendente estocástico: atualiza os pesos subtraindo lr * gradient, calculado em um mini-lote |
| Momentum | "Analogia da bola rolando" | Média móvel exponencial dos gradientes passados; amortece a oscilação e acelera as direções consistentes |
| RMSProp | "Taxa de aprendizado adaptativa" | Divide o gradiente de cada parâmetro pelo RMS móvel de seus gradientes recentes; equaliza as taxas de aprendizado |
| Adam | "O otimizador padrão" | Combina momentum (primeiro momento) e RMSProp (segundo momento) com correção de viés para os passos iniciais |
| AdamW | "Adam feito direito" | Adam com weight decay desacoplado; aplica a regularização diretamente aos pesos em vez de através do gradiente |
| Correção de viés | "Warmup para médias móveis" | Dividir por (1 - beta^t) para compensar a inicialização em zero das estimativas de momento do Adam |
| Weight decay | "Encolher os pesos" | Subtrair uma fração do valor do peso a cada passo; um regularizador que penaliza pesos grandes |
| Agendamento da taxa de aprendizado | "Mudar a lr ao longo do tempo" | Uma função que ajusta a taxa de aprendizado durante o treinamento; warmup + decaimento por cosseno é o padrão moderno |
| Clipping de gradiente | "Limitar a norma do gradiente" | Reduzir a escala do vetor de gradiente quando sua norma excede um limiar; previne atualizações de gradiente explosivas |
Leitura Complementar
- Kingma & Ba, "Adam: A Method for Stochastic Optimization" (2014) -- o artigo original do Adam com análise de convergência e a derivação da correção de viés
- Loshchilov & Hutter, "Decoupled Weight Decay Regularization" (2017) -- provou que a regularização L2 e o weight decay não são equivalentes no Adam, e propôs o AdamW
- Smith, "Cyclical Learning Rates for Training Neural Networks" (2017) -- introduziu o teste de faixa de LR e os agendamentos cíclicos que eliminam a necessidade de ajustar uma taxa de aprendizado fixa
- Ruder, "An Overview of Gradient Descent Optimization Algorithms" (2016) -- o melhor levantamento único de todas as variantes de otimizadores, com comparações e intuições claras