← Voltar pra playlist

Só pra complementar: Pegadinhas de Engenharia de Features

Esse post não vem de nenhuma seção obrigatória do lab, é o que sobrou depois que eu separei o essencial de engenharia de features e overfitting no post principal. Curiosamente, essa é a primeira vez que eu consigo mostrar de verdade a solução pro problema que o post anterior só diagnosticou.

Normalizo depois de criar a feature, não antes

A ordem importa. Se eu normalizasse xx primeiro e só depois elevasse ao quadrado, chegaria em (xμσ)2\left(\frac{x-\mu}{\sigma}\right)^2, que não é a mesma feature que normalizar x2x^2 direto. As duas funcionam, mas produzem colunas diferentes, e a segunda ordem (criar a feature, normalizar depois) é a que eu uso desde o post principal.

Já adianto o final feliz: o RMSE das duas ordens acaba praticamente igual, então relaxa, não é um erro grave se você inverter. O motivo é que (xμσ)2\left(\frac{x-\mu}{\sigma}\right)^2 dá pra abrir em x22μx+μ2σ2\frac{x^2 - 2\mu x + \mu^2}{\sigma^2}, que é só uma combinação linear de x2x^2, xx e uma constante, três coisas que o modelo já tem à disposição (o próprio xx e o viés bb). Ou seja, normalizar antes ou depois não muda o que o modelo consegue representar, só reescreve a mesma função numa base de coordenadas diferente. O que muda de verdade é a escala dos pesos e a velocidade com que o gradiente descendente converge até eles, não o resultado final. (Detalho a conta inteira no Exercício 2, lá embaixo, se quiser ver isso na régua.)

Features polinomiais são extremamente colineares

xx, x2x^2 e x3x^3 carregam quase a mesma informação num intervalo estreito. Isso deixa a matriz XX\mathbf{X}^\top\mathbf{X} mal condicionada. Calculei a razão entre a amplitude cru da maior e da menor coluna por grau:

GrauAmplitude máx/mín (cru)
11
3361
51.3 × 10⁵
91.7 × 10¹⁰
132.2 × 10¹⁵

No grau 13 essa razão já está perto do limite de precisão do ponto flutuante de 64 bits (~2.2×10⁻¹⁶ de precisão relativa). Não é exagero dizer que a matriz crua nesse grau está no limiar da singularidade numérica.

E "mal condicionada" não é só um adjetivo assustador, tem uma consequência bem concreta pro peso que sai do ajuste. Resolver a equação normal envolve, no fundo, inverter essa matriz, e quando ela está quase singular, essa inversão se parece muito com dividir por um número quase zero: um tremelique pequeno no numerador (um pouquinho de ruído nos dados, um arredondamento de ponto flutuante) vira uma resposta gigante e instável do outro lado. Na prática, isso significa que dois conjuntos de dados quase idênticos podem produzir pesos wjw_j completamente diferentes, às vezes com sinais opostos se cancelando quase perfeitamente, mesmo que a curva final prevista pareça igualzinha. Os pesos individuais deixam de significar algo confiável, só a combinação deles ainda presta.

Normalizar (o que meu componente do post principal já faz por baixo dos panos) melhora isso em várias ordens de grandeza, mas não resolve a colinearidade intrínseca entre as potências, só a diferença de escala entre elas. Pra colinearidade de verdade, o remédio é regularização, que eu mostro mais embaixo.

Guardo μ\mu e σ\sigma, sempre

Bati nessa tecla no post de normalização e bato de novo aqui: sem guardar a média e o desvio-padrão do treino, não dá pra normalizar nenhum dado novo do mesmo jeito, e o modelo vira enfeite. Todo componente que eu construí pra esse post guarda isso internamente antes de prever qualquer coisa.

Mais features nunca piora o erro de treino

Isso não é coincidência, é praticamente um teorema: acrescentar uma coluna só amplia o espaço de modelos possíveis, então o mínimo do custo de treino só pode cair ou empatar, nunca subir. Por isso erro de treino sozinho não serve pra escolher a complexidade do modelo, sempre precisa de um conjunto separado (o que eu já mostrei no post principal).

Engenharia de feature não é só potência

Potência é o exemplo didático mais fácil de mostrar. No mundo real, as features mais úteis costumam vir de conhecimento de domínio: raiz quadrada, log, razões, produtos entre colunas. Testei isso com um alvo y=xy=\sqrt{x}:

FeaturesNº de parâmetrosRMSE
xx20.300
x,x2x, x^230.163
x,x2,x3x, x^2, x^340.104
x\sqrt{x}20.000

Uma única feature x\sqrt{x} zera o erro com 2 parâmetros, enquanto três potências gastam 4 parâmetros e ainda erram. Conhecimento de domínio vale mais que força bruta.

Domando o overfitting: regularização

O post principal terminou com uma pergunta em aberto: dá pra manter toda a flexibilidade de um grau alto sem pagar o preço do overfitting? A resposta é regularização L2, ou Ridge. Em vez de escolher um grau discreto, eu adiciono uma penalidade contínua λ\lambda que castiga pesos grandes:

Jridge(w,b)=J(w,b)+λjwj2J_{ridge}(\mathbf{w}, b) = J(\mathbf{w}, b) + \lambda \sum_j w_j^2

Por que isso funciona, na prática? Um polinômio de grau alto decorando 10 pontos de treino precisa, em algum lugar da curva, subir muito rápido e descer muito rápido pra passar exatamente em cima de cada ponto ruidoso, e isso só é possível se alguns pesos wjw_j ficarem enormes (frequentemente com sinais opostos se cancelando na maior parte do domínio, e "descancelando" bem na hora de fazer aquela curva colar num ponto específico). Se eu simplesmente proíbo os pesos de ficarem grandes demais, cobrando um preço (λjwj2\lambda \sum_j w_j^2) proporcional ao tamanho deles ao quadrado, a curva perde a capacidade física de fazer essas curvas fechadas em volta de cada ponto individual. Ela é forçada a "escolher" uma tendência mais suave que passa perto da maioria dos pontos, em vez de exatamente em cima de cada um, o que é outra forma de dizer: ela para de decorar o ruído e passa a acompanhar o sinal.

Implementei isso na minha própria solução exata (somando λ\lambda à diagonal de XX\mathbf{X}^\top\mathbf{X}, excluindo o viés, que é exatamente a forma fechada que o Ridge do scikit-learn resolve por baixo dos panos). Não tenho scikit-learn instalado nesse ambiente, mas aqui está o equivalente calculado de verdade, não só o princípio: mesmo caso ruidoso de 10 pontos, grau 9, do post principal, variando λ\lambda:

λ\lambdaRMSE treinoRMSE teste
0 (sem regularização)0.0001.717
0.010.2810.296
0.10.4780.646
0.50.5870.652
1.00.6310.650
2.00.6660.658
5.00.6960.674

Repara no padrão: sem regularização o treino vai a zero (10 parâmetros pra 10 pontos, ajuste exato) e o teste é o pior de todos. Conforme λ\lambda cresce, o treino piora e o teste melhora, até um ponto (λ0.01\lambda \approx 0.01 aqui) onde o teste passa a piorar de novo porque o modelo já ficou rígido demais. É o mesmo trade-off viés-variância do post principal, só que controlado por um botão contínuo em vez da escolha discreta de grau.

Testa você mesmo, arrastando λ\lambda:

RMSE de treino ≈ 0.0000 · RMSE de teste ≈ 1.7168

Repara como a curva para de decorar cada ponto de treino e vira parecida com o formato verdadeiro assim que λ\lambda sai de zero, sem eu precisar mexer no grau.

Exercícios

Tenta antes de abrir a resposta.

Exercício 1: descubra a transformação

Gere y = 3 * log(x + 1) + 2 pra x de 0 a 19. Ajusta (a) só com x, (b) com x, x², x³ e (c) com log(x+1). Compara os RMSE.

Resposta

A feature log(x+1) acerta praticamente na mosca com um único peso, porque o alvo é linear nela por construção. O polinômio de grau 3 chega perto (todo polinômio aproxima o log num intervalo limitado), mas gasta 3 parâmetros e erra fora da faixa. Só x fica ruim. A moral: quando você conhece a forma da relação, a feature certa vale mais que qualquer quantidade de potências.

Exercício 2: a ordem da normalização importa?

Compara ajustar zscore(x)² contra zscore(x²). Os RMSE finais são iguais? E os pesos?

Resposta

O RMSE final é praticamente igual, mas os pesos e a velocidade de convergência não são. O motivo: (xμσ)2=x22μx+μ2σ2\left(\frac{x-\mu}{\sigma}\right)^2 = \frac{x^2 - 2\mu x + \mu^2}{\sigma^2}, ou seja, é uma combinação linear de x2x^2, xx e uma constante. Como o modelo já tem xx e o viés disponíveis, o espaço de funções alcançáveis é o mesmo, muda só a base em que ele está escrito.

Exercício 3: quantos pontos são necessários?

Mantendo o grau 13, repete a análise treino/teste com 40, 100 e 500 pontos em [0,19][0, 19]. O overfitting some?

Resposta

Some em grande medida. Overfitting é uma relação entre capacidade do modelo e quantidade de dados, não uma propriedade do modelo sozinho. Com 500 pontos, 14 parâmetros deixam de ser demais. Isso dá as três saídas clássicas pra overfitting: mais dados, menos features, ou regularização.

Exercício 4: interações entre variáveis

Com duas variáveis de entrada, features polinomiais de grau 2 geram x1, x2, x1², x1x2, x2². O termo x1x2 é uma interação. Constrói um alvo em que a interação seja essencial (y = x1 * x2) e mostra que um modelo sem ela falha.

Resposta

Sem o termo cruzado, o modelo é aditivo e não consegue representar "o efeito de x1 depende de x2". Interações são a forma mais comum e mais útil de engenharia de features em dados tabulares reais, mais até que potências puras.

Exercício 5: critério de parada honesto

Se eu tivesse usado gradiente descendente em vez de solução exata, como eu pararia de forma confiável?

Resposta

Pela norma do gradiente (max(|dj_dw|, |dj_db|) < tol), não pela melhora do custo. Com α\alpha pequeno demais, o custo também melhora pouco por iteração, e um critério baseado nele faria eu parar achando que convergi sem ter convergido de verdade.