Phase 01 - Lesson 08

Otimização

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

Treinar uma rede neural nada mais é do que encontrar o fundo de um vale.

Tipo: Build Linguagem: Python Pré-requisitos: Fase 1, Lições 04-05 (Derivadas, Gradientes) Tempo: ~75 minutos

Objetivos de Aprendizagem

  • Implementar o gradiente descendente puro, o SGD com momento e o Adam do zero
  • Comparar a convergência dos otimizadores na função de Rosenbrock e explicar por que o Adam adapta as taxas de aprendizado por peso
  • Distinguir paisagens de perda convexas de não convexas e explicar o papel dos pontos de sela em altas dimensões
  • Configurar agendamentos de taxa de aprendizado (decaimento por degrau, recozimento por cosseno, aquecimento) para a estabilidade do treinamento

O Problema

Você tem uma função de perda. Ela te diz o quão errado seu modelo está. Você tem gradientes. Eles te dizem qual direção torna a perda pior. Agora você precisa de uma estratégia para descer a ladeira.

A abordagem ingênua é simples: mova-se na direção oposta ao gradiente. Escale o passo por um número chamado taxa de aprendizado. Repita. Isto é o gradiente descendente, e ele funciona. Mas "funciona" tem ressalvas. Taxa de aprendizado grande demais e você passa direto pelo vale, quicando entre as paredes. Pequena demais e você se arrasta em direção à resposta ao longo de milhares de passos desnecessários. Atinja um ponto de sela e você para de se mover mesmo sem ter encontrado um mínimo.

Todo otimizador em deep learning é uma resposta para a mesma pergunta: como chegar ao fundo do vale mais rápido e de forma mais confiável?

O Conceito

O que otimização significa

Otimização é encontrar os valores de entrada que minimizam (ou maximizam) uma função. Em machine learning, a função é a perda. As entradas são os pesos do modelo. O treinamento é otimização.

minimizar L(w) onde:
  L = funcao de perda
  w = pesos do modelo (podem ser milhoes de parametros)

Gradiente descendente (puro)

O otimizador mais simples. Calcule o gradiente da perda em relação a cada peso. Mova cada peso na direção oposta ao seu gradiente. Escale o passo pela taxa de aprendizado.

w = w - lr * gradient

Esse é o algoritmo inteiro. Uma linha.

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

Taxa de aprendizado: o hiperparâmetro mais importante

A taxa de aprendizado controla o tamanho do passo. Ela determina tudo sobre a convergência.

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

Não há fórmula para a taxa de aprendizado certa. Você a encontra por experimentação. Pontos de partida comuns: 0.001 para Adam, 0.01 para SGD com momento.

SGD vs batch vs mini-batch

O gradiente descendente puro calcula o gradiente sobre o conjunto de dados inteiro antes de dar um passo. Isso é chamado de gradiente descendente em batch. É estável mas lento.

O gradiente descendente estocástico (SGD) calcula o gradiente em uma única amostra aleatória e dá o passo imediatamente. É ruidoso mas rápido.

O gradiente descendente em mini-batch fica no meio-termo. Calcule o gradiente sobre um pequeno batch (32, 64, 128, 256 amostras), depois dê o passo. É isso que todo mundo de fato usa.

Variante Tamanho do batch Qualidade do gradiente Velocidade por passo Ruído
GD em batch Conjunto de dados inteiro Exato Lento Nenhum
SGD 1 amostra Muito ruidoso Rápido Alto
Mini-batch 32-256 Boa estimativa Equilibrado Moderado

O ruído no SGD e no mini-batch não é um bug. Ele ajuda a escapar de mínimos locais rasos e de pontos de sela.

Momento: a bola rolando ladeira abaixo

O gradiente descendente puro só olha para o gradiente atual. Se o gradiente faz ziguezague (comum em vales estreitos), o progresso é lento. O momento corrige isso acumulando gradientes passados em um termo de velocidade.

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

A analogia: uma bola rolando ladeira abaixo. Ela não para e reinicia a cada elevação. Ela ganha velocidade em direções consistentes e amortece oscilações.

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 (tipicamente 0.9) controla quanto do histórico manter. Beta maior significa mais momento, caminhos mais suaves, mas resposta mais lenta a mudanças de direção.

Adam: taxas de aprendizado adaptativas

Pesos diferentes precisam de taxas de aprendizado diferentes. Um peso que raramente recebe gradientes grandes deve dar passos maiores quando finalmente os recebe. Um peso que recebe gradientes enormes constantemente deve dar passos menores.

O Adam (Adaptive Moment Estimation) rastreia duas coisas por peso:

  1. Primeiro momento (m): média movel dos gradientes (como o momento)
  2. Segundo momento (v): média movel dos gradientes ao quadrado (magnitude do 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)

A divisão por sqrt(v_hat) é a ideia central. Pesos com gradientes grandes são divididos por um número grande (passo efetivo pequeno). Pesos com gradientes pequenos são divididos por um número pequeno (passo efetivo grande). Cada peso recebe sua própria taxa de aprendizado adaptativa.

Hiperparâmetros padrão: lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8. Esses padrões funcionam bem para a maioria dos problemas.

Agendamentos de taxa de aprendizado

Uma taxa de aprendizado fixa é um meio-termo. No início do treinamento, você quer passos grandes para fazer progresso rápido. No fim do treinamento, você quer passos pequenos para o ajuste fino perto do mínimo.

Agendamentos comuns:

Agendamento Fórmula Caso de uso
Decaimento por degrau lr = lr * fator a cada N épocas Simples, controle manual
Decaimento exponencial lr = lr_0 * decay^t Redução suave
Recozimento por cosseno lr = lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * t / T)) Transformers, treinamento moderno
Aquecimento + decaimento Rampa linear de subida, depois decaimento Modelos grandes, evita instabilidade inicial

Convexo vs não convexo

Uma função convexa tem um mínimo. O gradiente descendente sempre o encontra. Uma quadrática como f(x) = x^2 é convexa.

As funções de perda de redes neurais são não convexas. Elas têm muitos mínimos locais, pontos de sela e regiões 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

Na prática, mínimos locais em redes neurais de alta dimensão raramente são um problema. A maioria dos mínimos locais tem valores de perda próximos do mínimo global. Os pontos de sela (planos em algumas direções, curvos em outras) são o verdadeiro obstáculo. O momento e o ruído dos mini-batches ajudam a escapar deles.

Visualização da paisagem de perda

A perda é uma função de todos os pesos. Para um modelo com 1 milhão de pesos, a paisagem de perda vive em um espaço de 1.000.001 dimensões. Nós a visualizamos escolhendo duas direções aleatórias no espaço de pesos e plotando a perda ao longo dessas direções, produzindo uma superfície 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

Mínimos agudos generalizam mal. Mínimos planos generalizam bem. Esta é uma das razões pelas quais o SGD com momento frequentemente supera o Adam na acurácia final de teste: seu ruído evita o assentamento em mínimos agudos.

Construa

Passo 1: Definir uma função de teste

A função de Rosenbrock é um benchmark clássico de otimização. Seu mínimo está em (1, 1) dentro de um vale curvo e estreito que é fácil de encontrar mas 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]

Passo 2: Gradiente descendente 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)]

Passo 3: SGD com 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)]

Passo 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)
        ]

Passo 5: Rodar e 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}")

Saída esperada: o Adam converge mais rápido. O SGD com momento segue um caminho mais suave. O GD puro faz progresso lento ao longo do vale estreito.

Use

Na prática, use os otimizadores do PyTorch ou JAX. Eles cuidam de grupos de parâmetros, weight decay, recorte de gradiente e aceleração 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)

Regras gerais:

  • Comece com Adam (lr=0.001). Ele funciona para a maioria dos problemas sem ajuste fino.
  • Mude para SGD com momento (lr=0.01, momentum=0.9) quando precisar da melhor acurácia final e puder arcar com mais ajuste.
  • Use AdamW (Adam com weight decay desacoplado) para transformers.
  • Sempre use um agendamento de taxa de aprendizado para execuções de treinamento mais longas que algumas épocas.
  • Se o treinamento estiver instável, reduza a taxa de aprendizado. Se o treinamento estiver lento demais, aumente-a.

Entregue

Esta lição produz um prompt para escolher o otimizador certo. Veja outputs/prompt-optimizer-guide.md.

As classes de otimizador construidas aqui reaparecem na Fase 3 quando treinamos uma rede neural do zero.

Exercícios

  1. Varredura da taxa de aprendizado. Rode o gradiente descendente puro na função de Rosenbrock com taxas de aprendizado [0.0001, 0.0005, 0.001, 0.005, 0.01]. Plote ou imprima a perda final após 5000 passos para cada uma. Encontre a maior taxa de aprendizado que ainda converge.

  2. Comparação de momento. Rode o SGD com valores de momento [0.0, 0.5, 0.9, 0.99] na função de Rosenbrock. Acompanhe a perda em cada passo. Qual valor de momento converge mais rápido? Qual passa direto?

  3. Escape de ponto de sela. Defina a função f(x, y) = x^2 - y^2 (um ponto de sela na origem). Comece em (0.01, 0.01). Compare como o GD puro, o SGD com momento e o Adam se comportam. Qual escapa do ponto de sela?

  4. Implemente o decaimento da taxa de aprendizado. Adicione um agendamento de decaimento exponencial a classe GradientDescent: lr = lr_0 * 0.999^step. Compare a convergência com e sem decaimento na função de Rosenbrock.

Termos-Chave

Termo O que as pessoas dizem O que realmente significa
Gradiente descendente "Vá ladeira abaixo" Atualizar os pesos subtraindo o gradiente escalado pela taxa de aprendizado. O otimizador mais básico.
Taxa de aprendizado "Tamanho do passo" Um escalar que controla o quão longe cada atualização move os pesos. Grande demais causa divergência. Pequena demais desperdica computação.
Momento "Continue rolando" Acumular gradientes passados em um vetor de velocidade. Amortece oscilações e acelera o movimento por direções consistentes.
SGD "Amostragem aleatória" Gradiente descendente estocástico. Calcular o gradiente em um subconjunto aleatório em vez do conjunto de dados completo. Quase sempre significa SGD em mini-batch na prática.
Mini-batch "Um pedaço de dados" Um pequeno subconjunto dos dados de treinamento (32-256 amostras) usado para estimar o gradiente. Equilibra velocidade e acurácia do gradiente.
Adam "O otimizador padrão" Adaptive Moment Estimation. Rastreia médias móveis por peso de gradientes e gradientes ao quadrado para dar a cada peso sua própria taxa de aprendizado.
Correção de viés "Conserte a partida a frio" O primeiro e o segundo momento do Adam são inicializados em zero. A correção de viés divide por (1 - beta^t) para compensar durante os passos iniciais.
Agendamento de taxa de aprendizado "Mudar a lr ao longo do tempo" Uma função que ajusta a taxa de aprendizado durante o treinamento. Passos grandes no início, passos pequenos no fim.
Função convexa "Um vale" Uma função onde qualquer mínimo local é o mínimo global. O gradiente descendente sempre o encontra. As perdas de redes neurais não são convexas.
Ponto de sela "Plano mas não é um mínimo" Um ponto onde o gradiente é zero, mas é um mínimo em algumas direções e um máximo em outras. Comum em altas dimensões.
Paisagem de perda "O terreno" A função de perda plotada sobre o espaço de pesos. Visualizada fatiando-a ao longo de duas direções aleatórias.
Convergência "Chegar lá" O otimizador alcançou um ponto onde passos adicionais não reduzem significativamenté a perda.

Leitura Adicional

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