Normalização de Features e Taxa de Aprendizado

Peguei o lab opcional do Curso 1, Semana 2 (o C1_W2_Lab03) esperando só mais um post de continuação, e acabei achando o assunto que mais me irritou até agora na especialização: o gradiente descendente que eu construí no post passado, o mesmo algoritmo, sem nenhuma mudança de código, simplesmente para de funcionar dependendo da unidade que eu uso pra medir uma feature. Troquei metro por centímetro e o algoritmo divergiu. Isso não é bug, é matemática, e depois desse post você nunca mais vai escolher um no escuro sem entender por quê.
O problema: 4 features, escalas bem diferentes
O notebook original usa um dataset de 100 casas (derivado do Ames Housing dataset, o mesmo usado no curso) com 4 features: tamanho em pés², número de quartos, número de andares e idade em anos. A meta é prever o preço de uma casa de 1200 pés², 3 quartos, 1 andar, 40 anos.
| Feature | Mínimo | Máximo | Média | Desvio-padrão | Amplitude |
|---|---|---|---|---|---|
| tamanho (pés²) | 788 | 3194 | 1413.7 | 412.2 | 2406 |
| quartos | 0 | 4 | 2.7 | 0.7 | 4 |
| andares | 1 | 2 | 1.4 | 0.5 | 1 |
| idade (anos) | 12 | 107 | 38.6 | 25.8 | 95 |
Reparei nesse número assim que rodei a célula: a feature de maior amplitude (tamanho) é 2406 vezes maior que a de menor amplitude (andares). Guardei esse número, porque ele é a raiz de tudo que vem a seguir.
Antes de sair modelando, olhei cada feature contra o preço, uma de cada vez:
Tamanho carrega sinal de verdade (a nuvem de pontos sobe claramente da esquerda pra direita). Quartos e andares são discretos e ruidosos, dá pra ver casas de 2 quartos custando mais que casas de 3. Idade puxa o preço pra baixo, mas com bastante dispersão. Isso não significa que quartos e andares são inúteis, só que sozinhas elas explicam pouco, o valor delas aparece quando entram no modelo junto com as outras (o coeficiente negativo de quartos que eu calculo mais na frente é exatamente esse tipo de efeito escondido).
Calculei também a correlação entre cada par de features (e entre cada feature e o preço):
| tamanho | quartos | andares | idade | preço | |
|---|---|---|---|---|---|
| tamanho | 1.00 | 0.56 | 0.60 | -0.27 | 0.86 |
| quartos | 0.56 | 1.00 | 0.38 | -0.06 | 0.29 |
| andares | 0.60 | 0.38 | 1.00 | -0.20 | 0.32 |
| idade | -0.27 | -0.06 | -0.20 | 1.00 | -0.58 |
Tamanho é de longe a feature mais correlacionada com o preço (0.86), o que bate com o gráfico de cima. E repara que tamanho e quartos não são independentes (0.56), casas maiores tendem a ter mais quartos, o que é meio óbvio quando você para pra pensar, mas é sempre bom confirmar com número em vez de achismo.
Recapitulando rápido, com uma feature só
Pra manter a base que a gente já construiu, vou simplificar pra 1 feature só (tamanho) nessa primeira parte, exatamente como nos posts anteriores: , com custo . O gradiente descendente que eu montei dois posts atrás não muda uma linha:
O notebook de verdade roda isso com as 4 features ao mesmo tempo (um pra cada), mas a lição central aparece inteira já com uma feature só, e fica bem mais fácil de visualizar num gráfico 2D. Guarda essa simplificação na cabeça, ela volta lá na frente quando eu mostrar o resultado com as 4 features de verdade.
O gradiente descendente diverge sozinho
Peguei 8 casas reais do dataset do notebook (tamanho em pés², preço em milhares de dólares) pra testar isso na mão:
x_train = [952, 1244, 1947, 1725, 1959, 1314, 864, 1836]
y_train = [271.5, 300, 509.8, 394, 540, 415, 230, 560]
Rodei o mesmo gradient_descent do post passado com três valores de diferentes, só 10 iterações, começando em :
Saída com : custo sobe a cada iteração, de pra .
wtroca de sinal toda hora.Saída com : custo cai de pra , mas
wainda oscila (0 → 0.51 → 0.06 → 0.46 → 0.10...).Saída com : custo cai de pra ,
wsobe direto sem oscilar (0 → 0.06 → 0.11 → 0.15...).
Testa você mesmo no simulador abaixo. Comecei com um alpha bem pequeno de propósito, arrasta o slider pra cima devagar e repara exatamente onde o custo vira de "cai" pra "sobe":
iteração 0 · w = 0.00 · b = 0.00 · custo J = 88073.08
O padrão que eu vi bate certinho com o que o notebook descreve pro dataset completo (com as 100 casas e as 4 features, o limite fica entre e , bem parecido com o que eu achei aqui só com tamanho e um subconjunto menor). Não é coincidência, e a próxima seção explica exatamente por quê.
Por que exatamente ali: o limite e o número de condição
Isso não é tentativa e erro, dá pra calcular. Pra uma função de custo quadrática como a nossa, o gradiente descendente converge se e somente se
onde é a do custo e é o maior autovalor dela. é a curvatura máxima da tigela: quanto mais afiada a direção mais curva, menor o passo permitido antes de pular pro outro lado do vale.
Calculei a Hessiana pro nosso exemplo de 8 casas (uma conta de , já que é só e ) e achei , o que dá crítico . Bate com o que eu vi no simulador: passa desse limite e diverge, fica embaixo e converge.
O notebook original faz essa mesma conta com as 4 features de verdade (a Hessiana vira , contando o ) e chega em crítico , praticamente o mesmo número que eu achei simplificando pra 1 feature. Faz sentido: o tamanho é a feature com a escala mais absurda, então ela sozinha já domina quase toda a curvatura da tigela.
Tem um segundo número que sai dessa mesma conta, o (onde é a menor curvatura). Ele mede o quão "esticada" é a tigela. Achei pro dataset completo de 100 casas com as 4 features. Calculei também quantas iterações seriam necessárias, no melhor caso, só pra reduzir o erro em 10 vezes: cerca de 67 milhões. Foi aí que eu entendi de vez por que "diminui um pouco o alpha e espera mais" não é uma solução de verdade aqui.
Receita prática pra escolher
Nem sempre eu vou querer calcular autovalor. A receita que o Andrew Ng ensina, e que eu já uso desde o post de gradiente descendente:
- Começa pequeno, tipo .
- Multiplica por uns 3 a cada tentativa:
0.001, 0.003, 0.01, 0.03, 0.1... - Roda poucas iterações e olha o gráfico de custo por iteração.
- Pega o maior que ainda dá uma curva decrescente e suave.
| O que aparece no gráfico | Diagnóstico |
|---|---|
Custo sobe, ou vira nan/inf | grande demais |
| Custo desce mas serrilhado, com picos | no limite, reduz |
| Custo desce numa linha quase reta e devagar | pequeno demais |
| Custo desce rápido e depois achata | bom , convergiu |
Rodei essa varredura no dataset completo e achei que a faixa utilizável de (sem normalizar nada) fica entre e . Uma faixa estreitíssima, e que depende inteiramente da unidade que eu usei pra medir tamanho. Se eu tivesse medido em metros quadrados em vez de pés², todos esses números mudariam. Isso é frágil, e não é assim que eu quero treinar modelo nenhum.
As técnicas de normalização
A solução não é caçar um melhor, é consertar a escala das features antes de rodar qualquer coisa. Existem algumas formas de fazer isso:
| Técnica | Fórmula | Faixa resultante |
|---|---|---|
| Dividir pelo máximo | ||
| Min-max | ||
| Normalização de média | , média 0 | |
| Z-score | média 0, desvio-padrão 1 |
Usei z-score daqui pra frente, é a opção mais robusta (não depende só de dois pontos extremos do dataset, como o min-max depende). e são a média e o desvio-padrão da feature , calculados só com os dados de treino. Isso importa: eu guardo esses dois números e reuso pra qualquer dado novo que aparecer depois, nunca recalculo.
def zscore_normalize_features(X):
mu = X.mean(axis=0) # média de cada coluna
sigma = X.std(axis=0) # desvio-padrao de cada coluna
X_norm = (X - mu) / sigma
return X_norm, mu, sigma
Pro nosso exemplo de 8 casas, a média do tamanho é 1480.1 pés² e o desvio-padrão é 414.7. Normalizei e a amplitude, que antes ia de centenas a milhares, virou algo entre -1 e 2, mais ou menos.
Um detalhe que eu quase escorreguei: z-score não deixa o dado com distribuição normal, só reescala. A forma da distribuição continua a mesma, só o centro e a escala mudam.
Gradiente descendente com features normalizadas
Rodei o mesmo algoritmo, no mesmo dataset de 8 casas, só que agora com o tamanho normalizado por z-score, e usei , seis ordens de grandeza maior que os de antes:
iteração 0 · w = 0.00 · b = 0.00 · custo J = 88073.08
Depois de umas 50 iterações o custo já achatou perto do mínimo (achei , quase idêntico ao mínimo real de que eu calculei direto por fórmula fechada). No exemplo cru, com , o custo mal tinha saído de depois de 10 iterações inteiras. Normalizar não mudou onde fica o mínimo, mudou completamente a velocidade de chegar lá.
Reparei também numa coisa boba mas satisfeita de entender: quando está centrado em zero (média zero, por causa do z-score), o ótimo vira exatamente a média dos preços de treino. Faz sentido geometricamente: se todo x é zero na média, a melhor reta passa pela média dos y nesse ponto.
Prevendo uma casa nova e desnormalizando
Ajustei o modelo com as 4 features de verdade (não só tamanho) no dataset completo de 100 casas, por equação normal (a solução exata, sem gradiente descendente):
w = [0.268, -32.90, -67.29, -1.465] # tamanho, quartos, andares, idade
b = 221.50
Previ o preço da casa-alvo (1200 pés², 3 quartos, 1 andar, 40 anos) e cheguei em 318.9 mil dólares.
O que mais me chamou atenção foi o coeficiente de quartos: negativo. Isolado, ter mais quartos parece só coisa boa, mas controlando pelo tamanho da casa (que já está no modelo), mais quartos numa casa do mesmo tamanho normalmente significa quartos menores, o que puxa o preço um pouco pra baixo. É exatamente o tipo de coisa que só aparece quando você olha os coeficientes de um modelo com várias features ao mesmo tempo, não dá pra ver isso num gráfico de quartos-vs-preço sozinho.
Se eu tivesse ajustado com as features normalizadas, os pesos viriam numa escala diferente (cada representaria "quanto o preço muda por 1 desvio-padrão daquela feature", não por 1 unidade real). Pra voltar aos coeficientes em unidade real, a fórmula de desnormalização é:
Conferi isso no nosso exemplo de 1 feature: desnormalizando o e o que o gradiente descendente achou em espaço normalizado, cheguei em e , exatamente os mesmos números que a fórmula fechada dá direto em espaço cru. Os dois caminhos levam ao mesmo lugar, só que um deles convergiu em 50 iterações e o outro precisaria de milhões.
Os contornos: a visão geométrica
Isso tudo que eu descrevi em número, dá pra ver de uma vez só numa imagem. A partir daqui troquei de dataset: uso o de 50 casas que já apareceu nos posts anteriores da playlist (square_feet, num_bedrooms, nota de localização, distância do centro), não mais o de 100 casas do notebook que usei lá em cima. É outro conjunto de casas, então os números de correlação daqui são diferentes dos que calculei na seção "O problema".
Antes de ir pro abstrato (o formato do custo), vale ver o efeito bem mais direto e concreto: o formato dos próprios pontos de dado. Peguei tamanho e número de quartos dessas 50 casas e plotei um contra o outro, cru e depois normalizado:
Pontos, sem normalizar:
Pontos, depois de normalizar (z-score):
Repara que a nuvem de pontos não muda de formato, ela só desloca e reescala pros dois eixos ficarem na mesma unidade (desvio-padrão em vez de pés²/quartos). E é exatamente essa mudança de escala relativa entre os dois eixos que faz toda a diferença lá na frente.
Agora o mapa de custo. Ajustei um modelo com as 4 features (tamanho, quartos, nota de localização, distância do centro), fixei duas delas nos valores ajustados, e desenhei um mapa de calor variando só o peso de tamanho e o peso de quartos, o mesmo truque que o notebook original usa. A bolinha vermelha marca exatamente onde meu ajuste caiu, e a linha logo abaixo do gráfico mostra os valores certinhos.
Sem normalizar:
Depois de normalizar (z-score):
Repara a diferença de forma. No contorno cru, o eixo de tamanho é muito mais sensível que o de quartos (uma mudancinha de 0.5 no peso de tamanho já dispara o custo lá em cima, enquanto o peso de quartos precisa mudar dezenas de unidades pra fazer o mesmo estrago). É um vale bem alongado, quase um corredor, e a bolinha vermelha (o ajuste real) fica espremida lá no fundo dele. No contorno normalizado, as duas direções ficam com sensibilidade parecida, o formato vira bem mais redondo, e a bolinha cai perto do centro de um círculo.
Não é um círculo perfeito (tamanho e número de quartos, nesse dataset de 50 casas, ainda têm uma correlaçãozinha residual, calculei e ficou em torno de 0.02, bem pertinho de zero mas não exatamente zero), mas a diferença de forma é gritante. E isso não é coincidência nem aproximação visual: pra uma única feature normalizada por z-score, dá pra provar que a Hessiana vira exatamente a matriz identidade, , círculo perfeito, resultado exato e não aproximado. Com duas features reais, o resultado fica quase circular, e o "quase" é literalmente o tamanho da correlação entre elas.
Fechando
| O que eu já sabia | O que esse post resolveu |
|---|---|
| Gradiente descendente acha o mínimo sozinho | Ele só acha rápido se a superfície de custo não for um corredor esticado |
| é o parâmetro mais sensível que eu mexo | O "certo" depende inteiramente da escala das features, não é uma constante universal |
| Vale de custo é sempre convexo, pra regressão linear | Convexo não é o mesmo que bem-condicionado: pode ser uma tigela funda e redonda, ou um cânion raso e alongado |
Três ideias pra levar:
- A escala relativa das features decide o formato da tigela de custo, não o algoritmo de otimização em si.
- O número de condição é a métrica que quantifica isso: perto de 1 é uma tigela redonda e rápida de descer, muito maior que 1 é um cânion que qualquer fixo vai demorar uma eternidade pra atravessar.
- Normalizar não muda a resposta, muda o caminho até ela: mesmo mínimo, ordens de magnitude menos iterações pra chegar perto.
Aplicação prática
Mesmo dataset real de imóveis dos posts anteriores (Housing Prices Regression, Kaggle). Já vimos no post de gradiente descendente que o algoritmo cru, com square_feet/100 e price/1000, converge devagar, precisando de e 4000 iterações pra chegar em . Agora normalizei o tamanho por z-score antes de rodar:
tamanho_norm, mu, sigma = zscore_normalize_features(square_feet)
w, b, J_hist = gradient_descent(
tamanho_norm, price,
w_in=0, b_in=0,
alpha=0.3, num_iters=200,
cost_function=compute_cost, gradient_function=compute_gradient)
print(f"(w, b) encontrados: ({w:.1f}, {b:.1f})")
Saída:
(w, b) encontrados: (88.1, 593.0)
O custo final bate exatamente com o do exemplo cru ( nos dois casos, é o mesmo mínimo), só que aqui eu cheguei nele em 200 iterações com , contra as 4000 iterações com de antes. Compara os dois ao vivo:
Carregando dados reais...
Carregando dados reais...
Roda "Rodar 100" algumas vezes nos dois e cronometra na cabeça: um deles já achatou, o outro ainda tá subindo a ladeira. Mesma casa, mesmo preço final, e a única diferença entre os dois simuladores é uma linha de código a mais no começo.