← Voltar pra playlist

Lab Opcional: Gradiente Descendente

Meme de pódio olímpico: nos primeiros cinco quadrinhos, o atleta comemora a medalha de ouro com um gráfico de uma tigela bem comportada, de um único fundo, ao lado. No último quadrinho, o pódio de verdade aparece com um gráfico de uma curva cheia de vales, e quem ganha o ouro, a prata ou o bronze depende de em qual vale cada um caiu

Resumindo os dois posts anteriores: você montou o modelo fw,b(x)=wx+bf_{w,b}(x) = wx + b, depois montou um jeito de medir o quanto ele erra, J(w,b)J(w,b). Só que pra achar o melhor (w,b)(w,b) você ainda tava fazendo a coisa mais primitiva possível: arrastando slider e olhando o número cair. Funciona pra 2 pontos. Pra um dataset de verdade, com milhares de exemplos e dezenas de parâmetros, é impossível.

Esse post fecha o ciclo com o algoritmo que faz essa busca sozinho: o .

w=wαJ(w,b)wb=bαJ(w,b)bw = w - \alpha \frac{\partial J(w,b)}{\partial w} \qquad b = b - \alpha \frac{\partial J(w,b)}{\partial b}

A ideia em uma frase

Lembra da tigela de sopa do post passado? O gradiente descendente é literalmente isso: você começa em algum ponto qualquer da tigela e dá passos ladeira abaixo, sempre na direção que desce mais rápido, até chegar perto do fundo.

O "sentir qual direção desce mais rápido" é o trabalho da derivada, aquele Jw\frac{\partial J}{\partial w} na fórmula. Ela é a inclinação da superfície de custo naquele ponto específico. E α\alpha () é o tamanho do passo que você dá cada vez.

Por que subtrair a derivada

A derivada aponta pra onde o custo cresce. Como você quer o custo menor, você anda pro lado oposto ao que ela aponta. Daí o sinal de menos na fórmula.

SituaçãoSinal da derivadaO que acontece com wαJww - \alpha \frac{\partial J}{\partial w}
ww está à direita do mínimopositivoww diminui, anda pra esquerda
ww está à esquerda do mínimonegativoww aumenta, anda pra direita
ww está exatamente no mínimozeroww não muda mais, o algoritmo parou sozinho

Repara na última linha: o algoritmo não precisa de um "se chegou no mínimo, para". Ele simplesmente para de se mexer sozinho, porque a derivada zera. E como a derivada vai encolhendo conforme você se aproxima do fundo, os passos também vão ficando menores por conta própria, mesmo com α\alpha fixo. Isso é de graça, não é algo que você programa à parte.

As duas derivadas parciais

Pra regressão linear com uma variável, a matemática dá nessas duas fórmulas (eu não precisei decorar a dedução, só entender o padrão):

J(w,b)w=1mi=0m1(fw,b(x(i))y(i))x(i)\frac{\partial J(w,b)}{\partial w} = \frac{1}{m} \sum_{i=0}^{m-1} \left(f_{w,b}(x^{(i)}) - y^{(i)}\right) x^{(i)}

J(w,b)b=1mi=0m1(fw,b(x(i))y(i))\frac{\partial J(w,b)}{\partial b} = \frac{1}{m} \sum_{i=0}^{m-1} \left(f_{w,b}(x^{(i)}) - y^{(i)}\right)

Repara que as duas são quase gêmeas: a de ww tem um x(i)x^{(i)} multiplicando o erro, a de bb não. Faz sentido pensando geometricamente: mudar ww afeta mais forte os exemplos com xx grande (a inclinação pesa mais longe da origem), enquanto bb desloca a reta inteira igualzinho pra todo mundo.

E aquele "2" que a gente colocou em 2m2m no post passado, lembra que eu disse que era só pra simplificar conta? É aqui que ele paga a dívida: ao derivar o termo ao quadrado, sobra um fator 2 que cancela exatamente com o 2 do denominador. Sem aquele 2 lá atrás, essas fórmulas aqui teriam um 2 sobrando.

Atualização simultânea importa. Você calcula as duas derivadas primeiro, usando os valores atuais de ww e bb, e só depois troca os dois parâmetros ao mesmo tempo. Usar o ww novo pra calcular a derivada de bb é um erro clássico que muda o comportamento do algoritmo.

Colocando isso em código

def compute_gradient(x, y, w, b):
    """
    Calcula o gradiente da função de custo para regressão linear.

    Args:
      x (ndarray (m,)) : dados de entrada, m exemplos
      y (ndarray (m,)) : valores-alvo
      w, b (scalar)    : parâmetros do modelo

    Returns:
      dj_dw (scalar): derivada parcial do custo em relação a w
      dj_db (scalar): derivada parcial do custo em relação a b
    """
    m = x.shape[0]

    dj_dw = 0
    dj_db = 0

    for i in range(m):
        f_wb = w * x[i] + b
        dj_dw_i = (f_wb - y[i]) * x[i]   # contribuição do exemplo i pra dj_dw
        dj_db_i = f_wb - y[i]            # contribuição do exemplo i pra dj_db
        dj_db += dj_db_i
        dj_dw += dj_dw_i

    dj_dw = dj_dw / m
    dj_db = dj_db / m

    return dj_dw, dj_db

E o laço principal, que repete a atualização até acabar as iterações:

def gradient_descent(x, y, w_in, b_in, alpha, num_iters, cost_function, gradient_function):
    """
    Executa o gradiente descendente pra ajustar w e b.

    Args:
      x, y                : dados de treino
      w_in, b_in (scalar) : valores INICIAIS dos parâmetros
      alpha (float)       : taxa de aprendizado
      num_iters (int)     : quantas iterações executar
      cost_function       : função pra calcular o custo
      gradient_function   : função pra calcular o gradiente

    Returns:
      w, b (scalar)    : parâmetros depois do treino
      J_history (list) : custo a cada iteração
    """
    J_history = []
    w = w_in
    b = b_in

    for i in range(num_iters):
        dj_dw, dj_db = gradient_function(x, y, w, b)

        b = b - alpha * dj_db   # atualização simultânea: as duas derivadas
        w = w - alpha * dj_dw   # já foram calculadas com os valores antigos

        J_history.append(cost_function(x, y, w, b))

    return w, b, J_history

Agora você mesmo, mas com o algoritmo fazendo o trabalho

Chega de arrastar slider até achar o valor certo na mão. Aqui embaixo tem o algoritmo de verdade rodando, com controle total: escolhe uma taxa de aprendizado, dá um passo de cada vez ou roda vários de uma vez, e observa o pontinho vermelho descendo a ladeira sozinho no mapa de calor, na superfície 3D, ou na parábola.

Começa em w=0w = 0, b=0b = 0 (bem longe da resposta) e clica em "Rodar 2000" algumas vezes com o alpha padrão de 0.01. Repara como o custo despenca rápido no começo e depois desacelera sozinho, sem você mexer em nada.

iteração 0 · w = 0.00 · b = 0.00 · custo J = 85000.00

Quando a taxa de aprendizado é grande demais

Agora clica no preset de alpha 0.8 (bem maior que o 0.01 que funcionou) e roda alguns passos. Você vai ver os números de ww e bb ficarem cada vez mais absurdos, e o custo, em vez de cair, sobe.

Isso é , e o motivo é simples de visualizar: o passo é proporcional à derivada. Se α\alpha é grande, o passo ultrapassa o fundo da tigela e pousa do outro lado, só que mais alto do que estava antes. Do lado novo, a derivada é ainda maior (em módulo) e de sinal trocado, então o próximo passo é ainda maior na direção contrária. Vira um ciclo que se alimenta e explode, tipo empurrar um balanço cada vez com mais força até ele virar de cabeça pra baixo.

Na prática, se você tá treinando um modelo de verdade e vê o custo subindo ou indo pra frente e pra trás sem parar, a primeira coisa que eu tento é diminuir α\alpha (dividir por 3 ou por 10, por exemplo). O curso sugere testar uma sequência tipo 0.001, 0.003, 0.01, 0.03, 0.1 e comparar as curvas de custo até achar uma que desça de forma estável.

AlphaO que acontece (depois de 1000 passos, começando em w=0, b=0)
0.0001lento demais, mal saiu do ponto inicial
0.001ainda longe do alvo
0.01bom equilíbrio, é o que a gente usou acima
0.1converge rápido
0.3ainda converge, mas já perto do limite
0.8diverge

Testa esses valores você mesmo no simulador acima e compara com a tabela.

Bônus: nem toda tigela é bonitinha assim

Toda superfície de custo que a gente desenhou até aqui tem a mesma cara de tigela de sopa, porque vem de erro elevado ao quadrado, e isso garante convexidade (post passado). Mas gradiente descendente não vive só de tigela bem-comportada. Aqui vão dois clássicos que todo mundo que estuda otimização esbarra cedo ou tarde, só pra você ver que a dificuldade que a gente viu com o alpha grande é só a ponta do iceberg.

O vale-banana de Rosenbrock

Essa aqui é praticamente um teste de estresse padrão da área, tem até nome próprio: função de Rosenbrock.

f(w,b)=(1w)2+100(bw2)2f(w,b) = (1-w)^2 + 100(b - w^2)^2

O mínimo global é em (1,1)(1,1), custo zero, mas repara na forma:

Não é uma tigela redonda, é um vale curvo, tipo uma banana. Isso é um problema de verdade pro gradiente descendente: a direção que desce mais rápido quase nunca aponta pro fundo do vale, aponta pra parede mais próxima. O algoritmo fica ricocheteando de um lado pro outro da banana, avançando bem pouco a cada zigue-zague, mesmo perto do fundo.

Testa o alpha 0.01 aqui (o mesmo que funcionou liso no nosso exemplo de imóveis) e olha o que acontece:

iteração 0 · w = -1.00 · b = 1.00 · custo J = 4.00

Alpha 0.01 diverge quase na hora aqui, mesmo valor que era o "bom equilíbrio" lá em cima. Não existe alpha universal, ele depende inteiro do formato da superfície que você tá descendo. Baixa pra 0.002 e roda "Rodar 2000" algumas vezes: agora sim, o ponto vermelho serpenteia devagar pelo vale até chegar perto de (1,1)(1,1).

O ponto de sela

f(w,b)=w2b2f(w,b) = w^2 - b^2

Gira essa aqui com calma. É um mínimo se você olhar só pro eixo ww, e um máximo se olhar só pro eixo bb, ao mesmo tempo, no mesmo ponto. Isso se chama , o ponto vermelho no centro marca exatamente ele.

Lembra da regra "quando a derivada zera, o algoritmo para sozinho"? Pois é, nesse ponto ela zera igualzinho. Se o gradiente descendente chegasse exatamente ali, ia achar que tinha terminado, sem ter terminado nada, só tá equilibrado no topo de um pico de cavalo. Qualquer empurrãozinho pra fora do centro exato, e ele escorrega ladeira abaixo na direção que desce (o eixo bb), longe de qualquer mínimo de verdade.

Na nossa regressão isso nunca acontece (a tigela é sempre convexa, sem sela nenhuma escondida), mas é exatamente esse tipo de superfície que aparece direto em modelos mais complexos, tipo rede neural. Guarda esse nome, ele volta.

Fechando a trilogia

PostO que ficou pronto
Lab 02o modelo, fw,b(x)=wx+bf_{w,b}(x) = wx + b
Lab 03a medida do erro, J(w,b)J(w,b)
Lab 04 (esse aqui)o algoritmo que minimiza esse erro sozinho, gradiente descendente

Três ideias pra levar:

  1. O gradiente aponta pra onde o custo cresce, então o algoritmo sempre anda no sentido contrário.
  2. A atualização é simultânea, calcula as duas derivadas primeiro, troca os parâmetros depois.
  3. A taxa de aprendizado é o parâmetro mais sensível que você vai mexer: pequena demais é lento, grande demais diverge.

No próximo post: tudo que você viu até aqui foi com uma feature só (o tamanho da casa). Na Semana 2 do curso, o modelo ganha várias features de uma vez, e fazer conta com um laço for deixa de dar conta do recado. Antes de mexer no modelo com várias features, o próximo post é sobre a ferramenta que torna isso viável: NumPy e vetorização.

Aplicação prática

Mesmo dataset real de imóveis dos dois posts anteriores (Housing Prices Regression, Kaggle), mesma escala (tamanho em "centenas de pés²", preço em "milhares de dólares"). Bora ver o algoritmo achar sozinho, em dado real, o ajuste que nos posts 1 e 2 você foi atrás na mão.

w, b, J_hist = gradient_descent(
    x_sqft, y_price,          # as 50 casas reais
    w_in=0, b_in=0,           # começando do zero, igual no exemplo de brinquedo
    alpha=0.01, num_iters=4000,
    cost_function=compute_cost, gradient_function=compute_gradient)

print(f"(w, b) encontrados: ({w:.1f}, {b:.1f})")

Saída: (w, b) encontrados: (116.5, 398.3)

Bate certinho com o ajuste que a gente citou nos dois posts anteriores. Clica em "Rodar 2000" duas vezes no simulador abaixo e confere ao vivo:

Carregando dados reais...

Repara que a convergência aqui é bem mais devagar que no exemplo de brinquedo de 2 pontos, mesmo já usando a mesma escala pequena de sempre. Isso acontece porque ww e bb ainda vivem em faixas bem diferentes uma da outra (um vai até 300, o outro até 600), o que estica o vale de custo. É exatamente esse tipo de situação que a normalização de features, mais pra frente no curso, resolve de vez.