← Voltar pra playlist

Só pra complementar: Pegadinhas de Normalização

Esse post não vem de nenhuma seção obrigatória do lab, é o que sobrou depois que eu separei o essencial de normalização no post principal. São as pegadinhas que eu só ia esbarrar mesmo num projeto de verdade, fora do conforto de um notebook educacional.

Vazamento de dado: normalizar antes de separar treino e teste

O erro mais comum de todos. Se eu calculo μ\mu e σ\sigma usando o dataset inteiro (treino + teste juntos) antes de separar, informação do conjunto de teste vaza pro treino, e minha métrica de validação fica otimista demais sem eu perceber.

Separei o dataset de 100 casas em 80 treino / 20 teste e comparei a média do tamanho calculada dos dois jeitos:

mu_todos = X_train.mean(axis=0)        # ERRADO: usa treino + teste
mu_so_treino = X_train[i_tr].mean(axis=0)  # CERTO: só o treino

Saída: média com tudo junto = 1413.7, média só do treino = 1385.3, diferença de 28.4.

Não parece muito, mas plantei um cenário pior: uma casa gigante de 9000 pés² aparecendo só no conjunto de teste. Se eu calculasse μ\mu com o dataset inteiro (incluindo essa casa "invisível" que deveria estar trancada no teste), a média pularia pra 1488.8, um desvio de mais de 100 unidades só por causa de uma linha que eu não deveria nem ter olhado ainda.

Regra que eu levo comigo: fit só no treino, transform em todo mundo. Isso vale pra qualquer coisa que aprende estatística dos dados antes de usar (normalização, redução de dimensionalidade, seleção de feature), não só pra regressão linear. O Pipeline do scikit-learn (que eu mostro mais embaixo) existe basicamente pra tornar esse erro impossível de cometer sem querer.

Feature constante: divisão por zero

Se uma coluna tem σj=0\sigma_j = 0 (todo mundo com o mesmo valor), a fórmula de z-score divide por zero e gera inf ou nan.

mu, sigma = 1413.71, 0.0   # sigma zerado, coluna constante
(1500 - mu) / sigma

Saída sem proteção: inf

def zscore_seguro(X, eps=1e-12):
    mu = X.mean(axis=0)
    sigma = X.std(axis=0)
    constantes = sigma < eps
    sigma_safe = np.where(constantes, 1.0, sigma)  # forca sigma=1 nas colunas constantes
    return (X - mu) / sigma_safe, mu, sigma_safe

Saída com proteção: 86.29 (a coluna constante vira zero em vez de inf, sem quebrar o resto da conta)

Uma coluna constante não carrega informação nenhuma pro modelo de qualquer forma, então travar o σ\sigma em 1 é só uma forma segura de "desligar" essa feature sem derrubar o programa inteiro.

Quando eu NÃO normalizo

  • Árvore de decisão, Random Forest, Gradient Boosting: esses modelos dividem por limiares em cada feature isolada. Escala não importa nada pra eles, normalizar só gasta tempo de processamento à toa.
  • Equação normal / LinearRegression: resolve em forma fechada, sem passo iterativo nenhum. Não precisa de normalização (embora escalas muito extremas ainda possam causar problema numérico de precisão).
  • Quando a unidade importa pra interpretação: se eu preciso dizer "cada pé² a mais vale X dólares", tenho que desnormalizar os coeficientes de volta (fiz isso no post principal, seção de desnormalização).

Sempre normalizo pra: gradiente descendente, SVM, k-NN, k-means, PCA, rede neural, e qualquer modelo com regularização L1/L2 (senão a penalidade cai injustamente em cima das features de escala pequena).

E o alvo yy? Não é obrigatório normalizar pra regressão linear com gradiente descendente, mas ajuda se yy tiver magnitude muito grande (evita inf no custo). Se eu normalizar o alvo, preciso lembrar de desnormalizar as previsões antes de reportar qualquer métrica pra alguém.

Conferindo com uma solução exata

Minha implementação de gradiente descendente está certa? Eu não preciso confiar cegamente. Regressão linear tem solução fechada (a equação normal), então dá pra comparar meu resultado iterativo contra a resposta exata.

theta_exato = np.linalg.lstsq(X_aug, y_train, rcond=None)[0]
w_exato, b_exato = theta_exato[:4], theta_exato[4]

No post principal eu já mostrei que, com uma feature só, desnormalizar o resultado do meu gradiente descendente (w=0.267w = 0.267, b=7.16b = 7.16) bate dígito a dígito com o que a fórmula fechada dá direto em espaço cru. Fiz a mesma checagem com as 4 features de verdade: o custo mínimo teórico da equação normal e o custo do meu gradiente descendente depois de 1000 iterações ficam a uma distância desprezível um do outro. As duas contas concordam, o que me deixa bem mais confiante de que não escrevi nenhum bug silencioso na implementação.

Não tenho o scikit-learn instalado nesse ambiente pra rodar um terceiro método de comparação (LinearRegression/SGDRegressor), mas o princípio é o mesmo do notebook original: rodar a mesma conta por métodos independentes e conferir se todos concordam é a forma mais barata de pegar um bug de implementação antes de confiar num número.

O jeito idiomático de fazer tudo isso em produção é um Pipeline:

from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import SGDRegressor
from sklearn.pipeline import make_pipeline

pipe = make_pipeline(
    StandardScaler(),                                   # equivale ao meu zscore_normalize_features
    SGDRegressor(max_iter=2000, tol=1e-6, eta0=0.1),
)
pipe.fit(X_train, y_train)

O Pipeline garante que o StandardScaler só é ajustado no treino em cada fold de validação, eliminando vazamento de dado automaticamente. É a forma correta de fazer isso na prática, em vez de normalizar na mão como eu fiz nesse post pra entender a mecânica por baixo.

Por que aprender gradiente descendente, se LinearRegression resolve exato? Porque a equação normal só existe pra modelos lineares, e custa O(n3)O(n^3) pra inverter a matriz, inviável com milhões de features. Gradiente descendente é o algoritmo que de fato treina rede neural, regressão logística, SVM e praticamente tudo mais. Regressão linear é só o playground onde dá pra ver a mecânica funcionando com uma resposta exata pra conferir.

Exercícios

Tenta antes de abrir a resposta.

Exercício 1: sensibilidade à unidade

Converte size de pés² pra metros quadrados (1 pé² = 0.092903 m²) e roda o gradiente descendente nos dados crus com α=9×107\alpha = 9\times10^{-7}. O que acontece? Depois normaliza e roda com α=0.1\alpha = 0.1.

Resposta

Nos dados crus, mudar a unidade muda a escala de size por uns 10x, o que muda LL e portanto o α\alpha crítico. O α\alpha que funcionava antes pode passar a divergir ou ficar lentíssimo. Nos dados normalizados nada muda: z-score é invariante a mudança de unidade linear, porque (axaμ)/(aσ)=(xμ)/σ(ax - a\mu)/(a\sigma) = (x-\mu)/\sigma. Esse é o argumento mais forte a favor de normalizar: o resultado deixa de depender de uma escolha arbitrária de unidade.

Exercício 2: min-max vs z-score

Normaliza com min-max em vez de z-score e roda o gradiente descendente com α=0.1\alpha = 0.1, 1000 iterações. Compara o custo final e o κ\kappa. Depois insere um outlier (uma casa de 20000 pés²) e repete.

Resposta

Sem outlier, min-max funciona quase tão bem quanto z-score (features em [0,1][0,1], κ\kappa bem menor que no dado cru). Mas repara que min-max não centraliza em zero, o que deixa uma correlação residual entre os wjw_j e o bb, e o κ\kappa costuma ficar pior que o do z-score.

Com o outlier, min-max colapsa: size vira quase 0 pra todas as casas normais e 1 só pro outlier, destruindo a resolução da feature pras casas de verdade. Z-score também sofre (média e desvio-padrão não são robustos a outlier), mas bem menos. Solução robusta de verdade: sklearn.preprocessing.RobustScaler (usa mediana e IQR em vez de média e desvio-padrão).

Exercício 3: implementa o critério de parada

Meu gradient_descent sempre roda num_iters iterações inteiras. Adiciona uma parada antecipada quando J<tol\|\nabla J\|_\infty < \texttt{tol}.

Resposta
grad_norm = max(np.max(np.abs(dj_dw)), abs(dj_db))
if grad_norm < tol:
    print(f"Convergiu na iteracao {i}: |grad|_inf = {grad_norm:.2e}")
    break

Um critério pela melhora do custo (abs(J_ant - J_novo) / max(abs(J_ant), 1e-12) < tol) também é comum, mas tem um risco: com α\alpha pequeno demais, o custo também melhora pouco por iteração, e o algoritmo para achando que convergiu sem ter convergido de verdade. O critério pelo gradiente é mais confiável.

Exercício 4: prevê o α\alpha crítico

Sem rodar o gradiente descendente, calcula o α\alpha crítico pros dados normalizados por min-max. Depois confirma empiricamente rodando com 0.9×0.9\times e 1.1×1.1\times esse valor.

Resposta
Xmm = minmax_scaling(X_train)
Ha = (np.column_stack([Xmm, np.ones(m)]).T @ np.column_stack([Xmm, np.ones(m)])) / m
a_crit = 2 / np.linalg.eigvalsh(Ha)[-1]

Funciona porque JJ é exatamente quadrática, o que faz a Hessiana ser constante em qualquer ponto. Pra modelos não lineares (rede neural) a curvatura LL muda a cada ponto, e essa conta só vale localmente, é exatamente por isso que existem otimizadores adaptativos como Adam.

Exercício 5: feature nova

Adiciona a feature tamanho_por_quarto = size / bedrooms, normaliza e treina. O erro melhora?

Resposta

Engenharia de features é o tema do próximo lab. O ponto de atenção aqui: features derivadas costumam ficar fortemente correlacionadas com as originais, o que aumenta κ\kappa e pode deixar a convergência mais lenta mesmo depois de normalizar. Normalização resolve diferença de escala, não resolve colinearidade, pra isso existem regularização (Ridge) e PCA.