Ir para o conteúdo

Scaling & Normalization

Escala e Normalização

A primeira camada de uma rede calcula \(\sum_j w_j x_j\). Se \(x_1\) é medido na casa das centenas de milhares e \(x_2\) na dos milésimos, então na inicialização — quando todos os \(w_j\) são sorteados da mesma distribuição pequena — a soma é inteiramente \(x_1\), o gradiente é inteiramente sobre \(x_1\) e \(x_2\) efetivamente ainda não existe.

É esse o argumento inteiro. Escalar não é cosmético nem é sobre "deixar os dados bonitos". É sobre tornar as unidades das entradas comparáveis, para que os primeiros passos do otimizador sejam sobre o problema e não sobre o sistema de medidas.

preprocessing accuracy
no_scaling 0.605
StandardScaler 0.892
MinMaxScaler 0.905
RobustScaler 0.897
MaxAbsScaler 0.903
QuantileTransformer 0.891
"""Six columns on six different scales, and a network that cannot cope.

The columns are the same standard normal draw, multiplied by 1, 1000, 0.001,
50, 100000 and 5. The label depends on all six equally — in the *unscaled*
sense of equally, which is the point: whatever the units, each column carries
the same amount of information about y.

A network does not know that. It sees one input in the hundred thousands and
one in the thousandths, and the first gradient step is dominated entirely by
the former. The table is what that costs, and what any of the standard scalers
buys back.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import (MaxAbsScaler, MinMaxScaler, QuantileTransformer,
                                   RobustScaler, StandardScaler)

N, DIM = 4000, 6
UNITS = np.array([1.0, 1000.0, 0.001, 50.0, 100_000.0, 5.0])

rng = np.random.default_rng(31)
Z = rng.normal(size=(N, DIM))                 # the honest, unit-free measurements
X = Z * UNITS                                 # the same thing, as it arrives in the table
weights = rng.normal(size=DIM)
y = ((Z @ weights + 0.8 * rng.normal(size=N)) > 0).astype(int)

net = lambda: MLPClassifier(hidden_layer_sizes=(32, 16), max_iter=600, random_state=0)
cv = StratifiedKFold(5, shuffle=True, random_state=0)

scalers = {
    "no_scaling": None,
    "StandardScaler": StandardScaler(),
    "MinMaxScaler": MinMaxScaler(),
    "RobustScaler": RobustScaler(),
    "MaxAbsScaler": MaxAbsScaler(),
    "QuantileTransformer": QuantileTransformer(
        output_distribution="normal", n_quantiles=500, random_state=0),
}

print("| `preprocessing` | `accuracy` |")
print("|---|---:|")
for name, scaler in scalers.items():
    model = net() if scaler is None else make_pipeline(scaler, net())
    print(f"| `{name}` | **{cross_val_score(model, X, y, cv=cv).mean():.3f}** |")

Seis colunas, todas carregando exatamente a mesma quantidade de informação sobre o rótulo, multiplicadas por 1, 1000, 0,001, 50, 100 000 e 5. Sem escalar, a rede alcança 0,605. Com qualquer scaler, alcança 0,89–0,91. Os 30 pontos não eram um problema de modelagem; eram um problema de unidades.

A regra e a única exceção

Escale toda entrada numérica, sempre — para qualquer coisa baseada em gradiente. A exceção são modelos de árvore (árvores de decisão, florestas aleatórias, gradient boosting), que dividem por limiares dentro de uma coluna só e nunca comparam colunas entre si, então escalar não muda absolutamente nada para eles.

E seja qual for o scaler escolhido: ele aprende parâmetros, então vai dentro do Pipeline. Veja a visão geral do capítulo.


Os cinco que importam

Scaler Fórmula Saída Ajustado a partir de
StandardScaler \(\dfrac{x - \mu}{\sigma}\) média 0, dp 1, sem limites média, desvio padrão
MinMaxScaler \(\dfrac{x - \min}{\max - \min}\) exatamente \([0, 1]\) mínimo, máximo
RobustScaler \(\dfrac{x - \text{med}}{\text{IQR}}\) centrado, sem limites mediana, quartis
MaxAbsScaler \(\dfrac{x}{\max \lvert x \rvert}\) \([-1, 1]\), mantém zeros em zero maior magnitude
QuantileTransformer posto e então mapeamento uniforme ou normal a distribuição empírica inteira

Leia a coluna da direita, porque é ela que prevê o comportamento de cada um. Todo parâmetro ali é ou um momento ou um extremo — e momentos e extremos respondem de maneiras muito diferentes a um valor ruim.

Suba o erro de digitação a partir do zero e observe os dados honestos — todas as linhas exceto a ruim — sendo empurrados para um canto por alguns scalers e deixados completamente em paz por outros.

scaler span_clean span_with_one_typo kept
StandardScaler 4.668 0.300 6.4%
MinMaxScaler 0.706 0.009 1.3%
RobustScaler 3.427 3.428 100.0%
MaxAbsScaler 0.565 0.009 1.7%
QuantileTransformer 0.980 0.978 99.8%
"""What one typo does to each scaler.

A height column, normally distributed around 50, into which a single 5000 has
been typed. Every scaler is fitted on the contaminated column, and the question
is what happened to the other 999 rows — the ones that are fine.

`span_of_the_rest` is how much of the output range the middle 98% of the honest
data occupies after scaling. Large is good: it means the real data still uses
the space. Small means the typo pushed everything else into a corner, where the
differences the model needs are below the resolution of its inputs.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.preprocessing import (MaxAbsScaler, MinMaxScaler, QuantileTransformer,
                                   RobustScaler, StandardScaler)

N, TYPO = 1000, 5000.0

rng = np.random.default_rng(3)
clean = rng.normal(50, 10, N)
contaminated = clean.copy()
contaminated[0] = TYPO                        # one slipped decimal point

scalers = {
    "StandardScaler": StandardScaler(),
    "MinMaxScaler": MinMaxScaler(),
    "RobustScaler": RobustScaler(),
    "MaxAbsScaler": MaxAbsScaler(),
    "QuantileTransformer": QuantileTransformer(n_quantiles=500, random_state=0),
}


def span(column, scaler, skip_first):
    scaled = scaler.fit_transform(column.reshape(-1, 1)).ravel()
    honest = scaled[1:] if skip_first else scaled
    low, high = np.percentile(honest, [1, 99])
    return high - low


print("| `scaler` | `span_clean` | `span_with_one_typo` | `kept` |")
print("|---|---:|---:|---:|")
for name, scaler in scalers.items():
    before = span(clean, scaler, skip_first=False)
    after = span(contaminated, scaler, skip_first=True)
    print(f"| `{name}` | {before:.3f} | **{after:.3f}** | **{after / before:.1%}** |")

Um erro de digitação — um único 5000 numa coluna que mora em torno de 50 — e a coluna kept diz quanto de resolução as outras 999 linhas ainda têm:

  • MinMaxScaler: 1,3%. O erro vira 1,0 e tudo o que é real é esmagado no primeiro centésimo da faixa. MaxAbsScaler: 1,7%, pelo mesmo motivo.
  • StandardScaler: 6,4%. Melhor, porque uma média e um desvio padrão são movidos por um valor de forma menos violenta que um máximo — mas ainda é uma perda de resolução de quinze vezes.
  • RobustScaler: 100,0%. A mediana e os quartis não se mexeram, então nada aconteceu com as linhas honestas. É isso que "robusto" significa, dito como número.
  • QuantileTransformer: 99,8%, por construção: ele só olha a ordenação e o erro é simplesmente o último da fila.

Escolhendo

flowchart TD
    A["uma coluna numérica"] --> B{"limitada por natureza?<br/><small>pixels, percentuais, probabilidades</small>"}
    B -->|sim| MM["<b>MinMaxScaler</b><br/><small>os limites são reais, então use-os</small>"]
    B -->|não| C{"outliers, ou cauda pesada?"}
    C -->|"sim, são erros"| R["<b>RobustScaler</b><br/><small>depois de corrigir o que der</small>"]
    C -->|"sim, são reais"| Q["<b>QuantileTransformer</b><br/><small>ou um log — veja transformações</small>"]
    C -->|não| S["<b>StandardScaler</b><br/><small>o padrão — e um bom padrão</small>"]
    A --> D{"matriz esparsa?<br/><small>one-hot, tf-idf</small>"}
    D -->|sim| MA["<b>MaxAbsScaler</b><br/><small>o único que mantém zeros em zero</small>"]

    classDef ok fill:#e6f4ea,stroke:#3fb950,color:#14532d
    classDef q  fill:#eef2f7,stroke:#8b949e,color:#1f2937
    class MM,R,Q,S,MA ok
    class A,B,C,D q

Algumas decisões que o diagrama comprime:

  • Sigmoide ou tanh na primeira camada favorecem MinMax para \([0,1]\) ou \([-1,1]\): essas ativações saturam fora de uma faixa estreita e uma entrada padronizada com alguns valores em \(\pm 4\) vai cair na região plana, onde o gradiente é quase zero. Com ReLU isso importa muito menos.
  • Dados esparsos — blocos one-hot, tf-idf — não podem ser centrados, porque subtrair uma média transforma cada zero estrutural num não zero e uma matriz esparsa numa densa que pode não caber na memória. MaxAbsScaler, ou StandardScaler(with_mean=False).
  • O QuantileTransformer não é de graça. Ele força qualquer distribuição na forma que você pedir, o que conserta assimetria e outliers de uma vez — e joga fora toda a informação de distância, guardando só a ordenação. Se os intervalos entre valores significam algo, isso é uma perda real.

O conjunto de teste é transformado, nunca ajustado

scaler.fit_transform(X_train)   # aprende mu e sigma aqui
scaler.transform(X_test)        # e só aplica aqui
Uma linha de teste maior que qualquer coisa do treino vai sair acima de 1 num MinMaxScaler e isso está correto: o scaler está reportando, com precisão, que esse valor está fora da escala sobre a qual ele foi construído. Reajustar para esconder isso é vazamento.


Escalar o alvo

Em regressão, o alvo também tem unidades e elas fixam a escala da perda. Um alvo na casa dos milhões produz um erro quadrático médio na casa dos trilhões e gradientes à altura.

from sklearn.compose import TransformedTargetRegressor

model = TransformedTargetRegressor(
    regressor=pipeline,
    func=np.log1p, inverse_func=np.expm1,     # ou transformer=StandardScaler()
)

O invólucro importa: ele inverte a transformação antes de pontuar, então o erro reportado fica nas unidades originais e continua sendo um número sobre o qual alguém consegue agir. Escalar y na mão e esquecer de inverter é um jeito comum e silencioso de reportar uma métrica sem sentido.


Onde escalar não basta

Escalar é um mapa linear — subtrair algo, dividir por algo. Isso desloca e estica uma distribuição; não muda a forma dela. Uma coluna assimétrica à direita continua assimétrica à direita depois de padronizada, com exatamente o mesmo coeficiente de assimetria.

Quando a própria forma é o problema, é preciso uma transformação não linear: é a próxima página.