Transforming Shape
Transformando a Forma
Escalar é um mapa linear: subtrair, dividir. Isso desloca uma distribuição e a estica, mas não muda a forma dela — uma coluna assimétrica à direita padroniza para uma coluna assimétrica à direita, com coeficiente de assimetria idêntico.
Quando a própria forma é o problema, é preciso uma transformação não linear. E a primeira coisa a acertar é o que "o problema" significa, porque a resposta usual está errada.
Para que serve uma transformação
O reflexo é: transformar até o histograma ficar simétrico. Esse critério é mensurável, satisfatório e em boa parte irrelevante.
Uma transformação não é para o histograma. É para a relação entre a feature e o alvo.
A que você quer é a que endireita essa relação e a forma da feature sozinha não consegue dizer qual é. O laboratório abaixo mostra a transformação que minimiza a assimetria sendo a pior escolha disponível.
Vendo isso
Escolha uma relação que os dados de fato têm, depois escolha uma transformação e veja a nuvem endireitar ou se recusar a isso.
A transformação rank é a coisa mais simétrica à oferta — ela força a coluna a ser perfeitamente uniforme — e nunca é a melhor opção, sendo de longe a pior quando o alvo é linear em \(x\).
As candidatas
| Transformação | Fórmula | Trata | Cuidado com |
|---|---|---|---|
| log | \(\log(x)\) | assimetria à direita, efeitos multiplicativos | \(x \le 0\) é indefinido |
| log1p | \(\log(1 + x)\) | o mesmo, permitindo zeros | ainda falha com negativos |
| sqrt | \(\sqrt{x}\) | assimetria leve à direita, contagens | mais suave que o log; só \(x \ge 0\) |
| recíproca | \(1/x\) | taxas e durações ("tempo por unidade" ↔ "unidades por tempo") | inversão de sinal; \(x = 0\) |
| Box-Cox | \(\dfrac{x^\lambda - 1}{\lambda}\), com \(\lambda\) ajustado | assimetria, escolhida automaticamente | só dados estritamente positivos |
| Yeo-Johnson | uma variante de Box-Cox | o mesmo, mais negativos | \(\lambda\) é parâmetro ajustado — pertence ao Pipeline |
| Quantil | posto e então mapeamento para uma forma-alvo | qualquer coisa, inclusive outliers | destrói toda informação de distância |
| logit | \(\log\frac{p}{1-p}\) | proporções acumuladas contra 0 ou 1 | indefinida exatamente em 0 e em 1 |
from sklearn.preprocessing import FunctionTransformer, PowerTransformer, QuantileTransformer
FunctionTransformer(np.log1p, inverse_func=np.expm1) # explícita e barata
PowerTransformer(method='yeo-johnson') # escolhe lambda por você, lida com negativos
QuantileTransformer(output_distribution='normal') # a marreta
PowerTransformer e QuantileTransformer aprendem parâmetros
O \(\lambda\) é estimado dos dados e o mapa de quantis é os dados. Ajuste qualquer um dos dois no conjunto completo antes da divisão e você vazou. O FunctionTransformer(np.log1p) é a exceção — não tem nada a aprender — o que é boa razão para preferi-lo quando você já sabe qual é a transformação certa.
Qual delas, medido
transform | skew_after | y~log(x) | y~x | y~sqrt(x) |
|---|---|---|---|---|
identity | 4.53 | 0.539 | 0.935 | 0.811 |
log1p | 0.15 | 0.941 | 0.591 | 0.856 |
sqrt | 1.64 | 0.827 | 0.816 | 0.938 |
yeo_johnson | 0.01 | 0.943 | 0.563 | 0.837 |
quantile_normal | 0.00 | 0.933 | 0.571 | 0.836 |
"""The transform that makes the histogram symmetric is not the transform you want.
One skewed input column, three different truths about how the target depends on
it, five candidate transforms. `skew_after` is the usual criterion — how close
to symmetric the column looks once transformed — and the three columns beside
it are what a straight line through the transformed column actually achieves.
Read `skew_after` against the three, and the usual criterion falls apart:
yeo_johnson and quantile_normal symmetrize best and are the *worst* option when
the target is linear in x. The transform to want is the one that straightens
your relationship, and a histogram cannot tell you which that is.
Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""
import numpy as np
from scipy.stats import skew
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import KFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import (FunctionTransformer, PowerTransformer,
QuantileTransformer, StandardScaler)
N = 800
rng = np.random.default_rng(23)
x = np.exp(rng.normal(3.0, 1.0, N)) # a lognormal column: heavily right-skewed
X = x.reshape(-1, 1)
truths = {
"y~log(x)": 2.0 * np.log(x),
"y~x": 0.05 * x,
"y~sqrt(x)": 0.6 * np.sqrt(x),
}
transforms = {
"identity": FunctionTransformer(),
"log1p": FunctionTransformer(np.log1p),
"sqrt": FunctionTransformer(np.sqrt),
"yeo_johnson": PowerTransformer(method="yeo-johnson"),
"quantile_normal": QuantileTransformer(
output_distribution="normal", n_quantiles=400, random_state=0),
}
targets = {name: base + 0.25 * np.std(base) * rng.normal(size=N) for name, base in truths.items()}
cv = KFold(5, shuffle=True, random_state=0)
print("| `transform` | `skew_after` | `" + "` | `".join(targets) + "` |")
print("|---|---:|" + "---:|" * len(targets))
for name, transform in transforms.items():
after = skew(transform.fit_transform(X).ravel())
scores = []
for y in targets.values():
model = make_pipeline(transform, StandardScaler(), LinearRegression())
scores.append(cross_val_score(model, X, y, cv=cv, scoring="r2").mean())
best = max(scores)
cells = " | ".join(f"**{s:.3f}**" if s == best else f"{s:.3f}" for s in scores)
print(f"| `{name}` | {after:.2f} | {cells} |")
Uma coluna assimétrica, três verdades diferentes sobre como \(y\) depende dela. Leia skew_after contra as três colunas ao lado:
yeo_johnsonequantile_normalganham a disputa de simetria de lavada — assimetria 0,01 e 0,00.- Quando o alvo é linear em \(\log x\), elas também são as melhores preditoras (0,943, 0,933), que é por que o reflexo sobrevive: em dados assimétricos a relação muitas vezes é logarítmica e aí os dois critérios por acaso concordam.
- Quando o alvo é linear em \(x\), elas desabam para 0,563 e 0,571, enquanto não fazer nada marca 0,935. A transformação mais simétrica é a pior disponível.
- Quando o alvo é linear em \(\sqrt x\), a
sqrtganha com 0,938 — e deixa uma assimetria de 1,64, que pelo critério usual é um fracasso.
A vencedora acompanha a verdade, não o histograma. Na diagonal, cada transformação ganha exatamente quando ela é a inversa da relação presente nos dados.
Como escolher de verdade
- Plote \(y\) contra \(x\) e contra \(\log x\), \(\sqrt x\), \(1/x\). A que parecer mais reta é a sua resposta e isso leva um minuto.
- Se o mecanismo é conhecido, use-o. Efeitos que multiplicam pedem um log. Áreas pedem raiz quadrada. Tempos e taxas são recíprocos um do outro.
- Se você realmente não sabe, coloque duas ou três candidatas num
GridSearchCVe deixe a validação cruzada escolher — isso é um hiperparâmetro legítimo. - Para uma rede profunda com dados de sobra, lembre da medição na página de distribuições: a transformação vale muito com \(n = 100\) e quase nada com \(n = 2000\). Não é ali que a sua acurácia está escondida, a menos que você precise extrapolar.
Discretização
Discretizar corta uma coluna contínua em intervalos e trata cada um como categoria. É a transformação de reputação mais confusa e o laboratório separa os dois casos com clareza.
model | raw_column | 4_bins | 10_bins | 32_bins |
|---|---|---|---|---|
linear_regression | -0.002 | 0.191 | 0.776 | 0.956 |
small_network | 0.975 | 0.191 | 0.776 | 0.956 |
"""Binning buys a linear model what it lacks, and takes from a network what it had.
One input, uniform on [0, 10]; a target that follows a sine of it, so the
relationship is smooth and strongly non-monotone. The feature is given to each
model raw, and then cut into 4, 10 and 32 quantile bins and one-hot encoded.
A straight line cannot follow a sine at all — R² of about zero — and binning
rescues it completely, because a one-hot block of bins turns a linear model
into a piecewise-constant one. A network needed no rescuing: it fits the curve
from the raw column, and every bin boundary you impose is resolution taken away
from it.
Most preprocessing advice about discretization was written for the first row.
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.linear_model import LinearRegression
from sklearn.model_selection import KFold, cross_val_score
from sklearn.neural_network import MLPRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import KBinsDiscretizer, StandardScaler
N, BIN_COUNTS = 2000, (4, 10, 32)
rng = np.random.default_rng(7)
X = rng.uniform(0, 10, N).reshape(-1, 1)
y = 3 * np.sin(1.6 * X.ravel()) + 0.3 * rng.normal(size=N)
models = {
"linear_regression": lambda: LinearRegression(),
"small_network": lambda: MLPRegressor(hidden_layer_sizes=(32,), max_iter=3000, random_state=0),
}
cv = KFold(5, shuffle=True, random_state=0)
score = lambda pipe: cross_val_score(pipe, X, y, cv=cv, scoring="r2").mean()
print("| `model` | `raw_column` | `" + "` | `".join(f"{b}_bins" for b in BIN_COUNTS) + "` |")
print("|---|---:|" + "---:|" * len(BIN_COUNTS))
for name, make in models.items():
row = [score(make_pipeline(StandardScaler(), make()))]
for bins in BIN_COUNTS:
cut = KBinsDiscretizer(n_bins=bins, encode="onehot-dense", strategy="quantile",
quantile_method="averaged_inverted_cdf")
row.append(score(make_pipeline(cut, make())))
best = max(row) # mark whichever actually won
cells = " | ".join(f"**{v:.3f}**" if v == best else f"{v:.3f}" for v in row)
print(f"| `{name}` | {cells} |")
A relação é um seno — suave e fortemente não monótona.
- A regressão linear marca −0,002 na coluna crua. Ela não consegue seguir um seno de jeito nenhum. Corte a coluna em 32 faixas, aplique one-hot e ela chega a 0,956, porque um bloco de indicadores de faixa transforma um modelo linear num modelo constante por partes. A discretização comprou a ela a flexibilidade que estruturalmente lhe faltava.
- A rede marca 0,975 na coluna crua e toda discretização a torna pior: 0,956 com 32 faixas, 0,776 com 10, 0,191 com 4. Ela nunca precisou da ajuda e cada fronteira de faixa é resolução tirada dela.
A maior parte do conselho sobre discretização foi escrita para a primeira linha
Discretizar é um jeito de dar a um modelo a não linearidade que ele não produz sozinho. Uma rede produz não linearidade por profissão. Cortar idade em décadas antes de alimentar uma rede joga fora tudo o que existe dentro de cada década em troca de uma flexibilidade que a rede já tinha.
As razões legítimas para discretizar em aprendizado profundo são outras: as fronteiras são externamente significativas (um limite legal de idade, um ponto de corte clínico), a coluna já é ordinal e fingir o contrário é ficção, ou você precisa que a saída seja explicável em termos sobre os quais alguém de fora da equipe consiga agir.
Features que você constrói, em vez de transformar
As transformações acima reescrevem uma coluna. Estas criam colunas novas — e, para uma rede, o cálculo de novo é diferente do caso do modelo linear.
| Movimento | Exemplo | Vale para uma rede? |
|---|---|---|
| Razões | divida / renda, preco / area | muitas vezes sim — uma razão é uma divisão e uma rede aproxima divisão mal |
| Diferenças | fim - inicio, valor - base | sim, se a diferença é a quantidade significativa |
| Interações | \(x_1 x_2\) | raramente — camadas ocultas encontram os produtos de que precisam |
| Polinômios | PolynomialFeatures(degree=2) | raramente e isso explode a contagem de colunas |
| Agregações de domínio | gasto_ultimos_30d, visitas_por_mes | sim — é aqui que moram os ganhos reais |
| Cíclicas | sen/cos de uma hora | sim — veja tipos de features |
O padrão: uma rede é muito boa em compor funções suaves das entradas dela e ruim em aritmética para a qual nunca recebeu as peças. Entregar uma razão poupa trabalho dela; entregar \(x_1 x_2\) em geral não.
Qualquer coisa calculada entre linhas é um vazamento esperando para acontecer
gasto_ultimos_30d por cliente, uma média por grupo, um target encoding — todos agregam sobre linhas e calculá-los antes da divisão deixa linhas de teste contribuírem para features de treino. Dentro do Pipeline, ou calculados estritamente a partir da dobra de treino. O capítulo de vazamento mede quanto isso custa.