← Voltar pra playlist

Regressão Linear com Scikit-Learn

Meme "Rick and Morty" de três quadrinhos: no primeiro, um robô pergunta "QUAL É O MEU PROPÓSITO?" com o rótulo "scikit-learn". No segundo, Rick responde "VOCÊ SEPARA OS DADOS". No terceiro, o robô diz "AH MEU DEUS"

Depois de quatro posts implementando gradiente descendente, normalização e engenharia de features inteiramente na mão, chegou a hora de usar a ferramenta de verdade. E o primeiro choque foi descobrir que o modelo "padrão" que o curso usa aqui não é bem o que parece.

A convenção da API, mais importante que o modelo em si

O scikit-learn traz implementação pronta e testada de boa parte do que eu já fiz na mão. Mas o que mais importa aprender aqui não é o modelo específico, é a convenção de API, porque ela se repete idêntica em centenas de modelos e transformadores:

MétodoO que fazQuem tem
.fit(X, y)aprende os parâmetros a partir dos dadostodo estimador
.predict(X)usa os parâmetros aprendidos pra prevermodelos preditivos
.transform(X)aplica uma transformação já aprendidatransformadores
.fit_transform(X)atalho pra fit seguido de transformtransformadores
.score(X, y)métrica padrão do estimador (R² em regressão)quase todos

Atributos aprendidos terminam com underscore: coef_, intercept_, mean_, scale_, n_iter_. Esse underscore no fim é a convenção que distingue "aprendido com os dados" de "configurado por mim".

A regra de ouro que vem junto: fit só pode ver dado de treino. Em dado de teste, só transform e predict. Violar isso é vazamento de dado, e é o erro mais comum de quem tá começando (já bati nessa tecla nos dois posts bônus anteriores).

StandardScaler: o z-score que eu já fiz na mão

StandardScaler faz exatamente a conta que eu implementei no post de normalização: xxμσx \leftarrow \frac{x-\mu}{\sigma}, coluna a coluna. Conferi e bate dígito a dígito com o meu z-score manual, inclusive usando a mesma convenção de desvio-padrão populacional (dividir por mm, não por m1m-1) que eu já usava.

SGDRegressor e o que o "S" significa

O curso usa SGDRegressor sem nunca explicar a sigla. Vale parar nisso, porque é a diferença real entre esse modelo e o gradiente descendente que eu implementei nos posts anteriores.

Gradiente descendente batch (o que eu fiz até aqui): cada passo usa todos os mm exemplos pra calcular o gradiente.

wwα1mi=0m1(f(x(i))y(i))x(i)\mathbf{w} \leftarrow \mathbf{w} - \alpha\,\frac{1}{m}\sum_{i=0}^{m-1}\left(f(\mathbf{x}^{(i)}) - y^{(i)}\right)\mathbf{x}^{(i)}

Gradiente descendente estocástico (SGD): cada passo usa um único exemplo, escolhido aleatoriamente.

wwα(f(x(i))y(i))x(i)\mathbf{w} \leftarrow \mathbf{w} - \alpha\left(f(\mathbf{x}^{(i)}) - y^{(i)}\right)\mathbf{x}^{(i)}

Passo baratíssimo, direção ruidosa, trajetória em zigue-zague, mas com mm atualizações pelo preço de uma do batch. Construí um motor de SGD do zero (o mesmo GradientDescentSimulator de sempre, só que trocando a engrenagem por baixo) pra você ver a diferença ao vivo, no mesmo dataset de 8 casas do post de normalização:

Batch:

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

Estocástico:

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

Roda "Rodar 100" nos dois com o mesmo alpha padrão. Depois de 50 passos, o batch já está com custo perto de 922 (bem próximo do mínimo real, 919), enquanto o estocástico, com o mesmo número de passos mas cada um enxergando só uma casa por vez, ainda está balançando em 7102. O caminho do estocástico no gráfico também é visivelmente mais "sujo", vai e volta em vez de descer suave.

Uma passagem completa pelos exemplos se chama época. No scikit-learn, max_iter conta épocas, não atualizações individuais.

Batch GDSGD
custo por atualizaçãoO(mn)O(mn)O(n)O(n)
atualizações por época1mm
trajetóriasuaveruidosa
determinístico?simnão (depende da ordem sorteada)
bom quandomm pequeno/médiomm muito grande

Com taxa de aprendizado constante, o SGD nunca para de tremer em torno do mínimo de vez. É por isso que o scikit-learn usa, por padrão, uma taxa que decresce ao longo do treino.

Duas coisas que o max_iter esconde

Peço max_iter=1000 e o SGDRegressor normalmente para bem antes disso. Não é bug: existe parada antecipada (tol e n_iter_no_change) que detecta quando a perda parou de melhorar de verdade e encerra sozinho. max_iter é um teto, não uma meta.

E sem fixar random_state, cada fit sorteia uma ordem diferente pros exemplos, e o resultado muda de execução pra execução. A variação costuma ser pequena, mas é real, principalmente em dataset menor ou mais difícil. Fixar a semente é o que torna o resultado reproduzível.

A surpresa: por padrão, isso é Ridge, não mínimos quadrados puros

Essa é a que mais me pegou de surpresa. Os padrões do SGDRegressor são penalty='l2' e alpha=0.0001. Ou seja, por padrão ele não minimiza

J(w,b)=12mi(f(x(i))y(i))2J(\mathbf{w},b) = \frac{1}{2m}\sum_i \left(f(\mathbf{x}^{(i)}) - y^{(i)}\right)^2

e sim

J(w,b)=12mi(f(x(i))y(i))2+αw2J(\mathbf{w},b) = \frac{1}{2m}\sum_i \left(f(\mathbf{x}^{(i)}) - y^{(i)}\right)^2 + \alpha\|\mathbf{w}\|^2

O termo extra é exatamente a regularização L2 que eu implementei do zero no post bônus anterior. Com α=0.0001\alpha=0.0001 (o padrão) o efeito é pequeno demais pra notar neste dataset, mas o mecanismo é real. Ajustei com α=1\alpha=1 (bem mais forte, só pra deixar visível) no nosso dataset de 100 casas:

ModeloRMSENorma de w\mathbf{w}
Sem regularização20.96123.25
Ridge (α=1\alpha=1)21.04120.39

O peso encolhe, o erro de treino piora um pouquinho, exatamente o trade-off que eu já vi no post de Ridge. Conheça os padrões da biblioteca que você usa: um SGDRegressor() chamado sem argumento nenhum não é "regressão linear crua", é Ridge com um α\alpha discreto e pequeno.

Prevendo, e um jeito frágil de comparar

Duas contas matematicamente equivalentes podem diferir no último bit de precisão por causa da ordem das operações de ponto flutuante. Comparar previsões com == é frágil (0.1 + 0.2 == 0.3 é false em qualquer linguagem que usa ponto flutuante de 64 bits, pelo mesmo motivo). O jeito certo é conferir se a diferença fica dentro de uma tolerância pequena, não exigir igualdade exata.

Métricas: quão bom é esse modelo, de verdade

Até aqui eu tinha usado custo JJ pra treinar, mas nunca uma métrica pensada pra ser lida por gente. Ajustei o modelo com as 4 features no dataset completo de 100 casas e calculei três métricas padrão:

MétricaValorO que mede
RMSE20.96 mil US$erro típico, pune erro grande desproporcionalmente
MAE16.91 mil US$erro absoluto médio, mais robusto a outlier
0.9594fração da variância do preço explicada pelo modelo

Pra ter uma referência: um modelo "burro" que sempre chuta a média erra com RMSE de 104.07. O nosso erra 4.96 vezes menos. É esse o ganho real de ter um modelo, não só o número de R² sozinho.

SGDRegressor ou LinearRegression? A escolha de verdade

O curso apresenta o SGDRegressor como "a regressão linear do scikit-learn", mas na prática ela é a escolha menos comum. LinearRegression resolve a equação normal em forma fechada, θ=(XX)1Xy\boldsymbol{\theta} = (\mathbf{X}^\top\mathbf{X})^{-1}\mathbf{X}^\top\mathbf{y}, sem iteração, sem taxa de aprendizado, sem semente aleatória. O custo cresce com o cubo do número de features, O(n3)O(n^3). SGDRegressor itera, custa O(mn)O(mn) por época, e nunca precisa carregar tudo na memória de uma vez.

CritérioLinearRegressionSGDRegressor
exatidãosolução exataaproximada
determinismototaldepende da semente
precisa normalizar?nãosim, obrigatoriamente
nn de features enormefica caro (n3n^3)tudo bem
mm de exemplos giganteprecisa caber na memóriaescala bem, aceita partial_fit
aprendizado incrementalnãosim

Regra prática: uso LinearRegression (ou Ridge) por padrão. Só vou pro SGDRegressor quando o dado não cabe na memória, chega em fluxo, ou o número de features é gigante.

Fechando

O que eu já sabiaO que esse post resolveu
Gradiente descendente usa todo o dataset a cada passoExiste uma versão que usa um exemplo por vez, mais barata por passo, mais ruidosa
Eu implementei tudo isso na mão até aquiA biblioteca padrão da área faz a mesma coisa, com a mesma convenção de API em centenas de modelos
Ridge é uma escolha explícita que eu fizO modelo "padrão" de gradiente estocástico do scikit-learn já vem com Ridge ligado, sem avisar

Três ideias pra levar:

  1. A convenção fit/predict/transform/score vale mais que decorar um modelo específico, ela se repete em toda a biblioteca.
  2. SGD troca precisão por velocidade por passo: mesma direção geral, caminho mais barato e mais ruidoso.
  3. Conheça os padrões do que você usa: penalty='l2' ligado por padrão no SGDRegressor é o tipo de detalhe que muda o que o seu código realmente está fazendo.

Aplicação prática

Mesmo dataset real de imóveis dos posts anteriores. Já sei que o gradiente descendente batch, com square_feet/100 e price/1000, alpha=0.01, precisa de 4000 iterações completas pra chegar em (w,b)=(116.5,398.3)(w,b) = (116.5, 398.3), custo final 5189.72. Cada uma dessas 4000 iterações olha as 50 casas inteiras, então são 200 mil avaliações de exemplo ao todo.

Rodei o estocástico com o mesmo alpha, só que contando passos individuais em vez de iterações completas:

w, b, hist = sgd_gradient_descent(
    square_feet_norm, price,
    w_in=0, b_in=0,
    alpha=0.01, num_steps=4000)  # 4000 exemplos vistos, não 4000 passagens completas

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

Saída: (w, b) encontrados: (112.1, 396.9), custo final 5233.79

Praticamente o mesmo resultado do batch (custo 5233.79 contra 5189.72), só que usando 50 vezes menos avaliações de exemplo (4000 contra 200 mil). Compara os dois ao vivo:

Carregando dados reais...

Carregando dados reais...

Roda "Rodar 2000" em cada um e repara: o estocástico chega numa vizinhança boa bem mais rápido em termos de trabalho total, mesmo com o caminho mais bagunçado no gráfico.