← Voltar pra playlist

Normalização de Features e Taxa de Aprendizado

Meme de uma criança confusa com as mãos abertas e a legenda "como assim eu preciso de scaling?"

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 α\alpha 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.

FeatureMínimoMáximoMédiaDesvio-padrãoAmplitude
tamanho (pés²)78831941413.7412.22406
quartos042.70.74
andares121.40.51
idade (anos)1210738.625.895

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):

tamanhoquartosandaresidadepreço
tamanho1.000.560.60-0.270.86
quartos0.561.000.38-0.060.29
andares0.600.381.00-0.200.32
idade-0.27-0.06-0.201.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: fw,b(x)=wx+bf_{w,b}(x) = wx + b, com custo J(w,b)=12m(fw,b(x(i))y(i))2J(w,b) = \frac{1}{2m}\sum(f_{w,b}(x^{(i)}) - y^{(i)})^2. O gradiente descendente que eu montei dois posts atrás não muda uma linha:

w=wαJwb=bαJbw = w - \alpha \frac{\partial J}{\partial w} \qquad b = b - \alpha \frac{\partial J}{\partial b}

O notebook de verdade roda isso com as 4 features ao mesmo tempo (um ww 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 α\alpha diferentes, só 10 iterações, começando em w=0,b=0w=0, b=0:

Saída com α=9×107\alpha = 9\times10^{-7}: custo sobe a cada iteração, de 8.8×1048.8\times10^4 pra 9.4×1059.4\times10^5. w troca de sinal toda hora.

Saída com α=8×107\alpha = 8\times10^{-7}: custo cai de 8.8×1048.8\times10^4 pra 9.4×1039.4\times10^3, mas w ainda oscila (0 → 0.51 → 0.06 → 0.46 → 0.10...).

Saída com α=1×107\alpha = 1\times10^{-7}: custo cai de 8.8×1048.8\times10^4 pra 1.3×1031.3\times10^3, w sobe 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 9×1079\times10^{-7} e 9.9×1079.9\times10^{-7}, 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 2/L2/L 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

0<α<2L,L=λmax(H)0 < \alpha < \frac{2}{L}, \qquad L = \lambda_{\max}(\mathbf{H})

onde H\mathbf{H} é a do custo e λmax\lambda_{\max} é o maior autovalor dela. LL é 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 2×22\times2, já que é só ww e bb) e achei L2.36×106L \approx 2.36\times10^6, o que dá α\alpha crítico 8.46×107\approx 8.46\times10^{-7}. Bate com o que eu vi no simulador: 9×1079\times10^{-7} passa desse limite e diverge, 8×1078\times10^{-7} fica embaixo e converge.

O notebook original faz essa mesma conta com as 4 features de verdade (a Hessiana vira 5×55\times5, contando o bb) e chega em α\alpha crítico 9.22×107\approx 9.22\times10^{-7}, 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 κ=L/μ\kappa = L/\mu (onde μ\mu é a menor curvatura). Ele mede o quão "esticada" é a tigela. Achei κ5.8×107\kappa \approx 5.8\times10^7 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 α\alpha

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:

  1. Começa pequeno, tipo α=0.001\alpha = 0.001.
  2. Multiplica por uns 3 a cada tentativa: 0.001, 0.003, 0.01, 0.03, 0.1...
  3. Roda poucas iterações e olha o gráfico de custo por iteração.
  4. Pega o maior α\alpha que ainda dá uma curva decrescente e suave.
O que aparece no gráficoDiagnóstico
Custo sobe, ou vira nan/infα\alpha grande demais
Custo desce mas serrilhado, com picosα\alpha no limite, reduz
Custo desce numa linha quase reta e devagarα\alpha pequeno demais
Custo desce rápido e depois achatabom α\alpha, convergiu

Rodei essa varredura no dataset completo e achei que a faixa utilizável de α\alpha (sem normalizar nada) fica entre 10810^{-8} e 10610^{-6}. 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 α\alpha melhor, é consertar a escala das features antes de rodar qualquer coisa. Existem algumas formas de fazer isso:

TécnicaFórmulaFaixa resultante
Dividir pelo máximoxjxj/max(xj)x_j \leftarrow x_j / \max(x_j)[0,1][0, 1]
Min-maxxjxjmin(xj)max(xj)min(xj)x_j \leftarrow \frac{x_j - \min(x_j)}{\max(x_j) - \min(x_j)}[0,1][0, 1]
Normalização de médiaxjxjμjmax(xj)min(xj)x_j \leftarrow \frac{x_j - \mu_j}{\max(x_j) - \min(x_j)}[1,1][-1, 1], média 0
Z-scorexjxjμjσjx_j \leftarrow \frac{x_j - \mu_j}{\sigma_j}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). μj\mu_j e σj\sigma_j são a média e o desvio-padrão da feature jj, 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 α=0.1\alpha = 0.1, seis ordens de grandeza maior que os 10710^{-7} 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 J919J \approx 919, quase idêntico ao mínimo real de 919.37919.37 que eu calculei direto por fórmula fechada). No exemplo cru, com α=107\alpha = 10^{-7}, o custo mal tinha saído de 1.3×1031.3\times10^3 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 xx está centrado em zero (média zero, por causa do z-score), o bb ó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 wjw_j 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 é:

wjoriginal=wjnormalizadoσjboriginal=bnormalizadojwjnormalizadoμjσjw_j^{original} = \frac{w_j^{normalizado}}{\sigma_j} \qquad b^{original} = b^{normalizado} - \sum_j \frac{w_j^{normalizado} \cdot \mu_j}{\sigma_j}

Conferi isso no nosso exemplo de 1 feature: desnormalizando o ww e o bb que o gradiente descendente achou em espaço normalizado, cheguei em w=0.267w = 0.267 e b=7.16b = 7.16, 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, κ=1\kappa = 1, 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á sabiaO que esse post resolveu
Gradiente descendente acha o mínimo sozinhoEle só acha rápido se a superfície de custo não for um corredor esticado
α\alpha é o parâmetro mais sensível que eu mexoO α\alpha "certo" depende inteiramente da escala das features, não é uma constante universal
Vale de custo é sempre convexo, pra regressão linearConvexo 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:

  1. A escala relativa das features decide o formato da tigela de custo, não o algoritmo de otimização em si.
  2. O número de condição κ\kappa é 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 α\alpha fixo vai demorar uma eternidade pra atravessar.
  3. 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 α=0.01\alpha=0.01 e 4000 iterações pra chegar em (w,b)=(116.5,398.3)(w,b) = (116.5, 398.3). 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 (J5189.6J \approx 5189.6 nos dois casos, é o mesmo mínimo), só que aqui eu cheguei nele em 200 iterações com α=0.3\alpha = 0.3, contra as 4000 iterações com α=0.01\alpha = 0.01 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.