Feature Types
Tipos de Features
Uma rede não vê uma coluna. Ela vê números e números vêm com duas propriedades, quer você quisesse ou não: uma ordem e uma distância. 7 é maior que 3, está a quatro de 3 e a um de 6. Não há como dizer à rede que não é assim — esses fatos estão embutidos na aritmética e aritmética é tudo o que ela faz.
Então este capítulo não é bem sobre taxonomia. Chamar uma coluna de "nominal" não muda nada. O que muda as coisas é a codificação e escolher uma é fazer uma afirmação:
O que é uma codificação
Codificar uma feature é afirmar quais comparações entre os valores dela são significativas.
Entregue cidade = 12 a uma rede e você afirmou que a cidade 12 é maior que a cidade 11 e vizinha da cidade 13. Entregue hora = 23 e você afirmou que 23h está tão longe da meia-noite quanto duas horas podem estar. Nenhuma dessas afirmações está nos seus dados. As duas estão na sua codificação e o modelo vai acreditar nas duas.
A taxonomia abaixo é só um jeito de descobrir quais afirmações são seguras para uma dada coluna.
O mapa
graph TD
D[uma coluna] --> Q1{"é uma<br/>quantidade?"}
Q1 -->|sim| N["<b>numérica</b><br/><small>ordem ✓ distância ✓</small>"]
Q1 -->|não| Q2{"os valores<br/>têm ordem?"}
Q2 -->|sim| O["<b>ordinal</b><br/><small>ordem ✓ distância ✗</small>"]
Q2 -->|não| Q3{"ela dá<br/>a volta?"}
Q3 -->|sim| C["<b>cíclica</b><br/><small>distância ✓ mas circular</small>"]
Q3 -->|não| M["<b>nominal</b><br/><small>ordem ✗ distância ✗</small>"]
D --> Q4{"é um<br/>identificador?"}
Q4 -->|sim| I["<b>id</b><br/><small>não é feature</small>"]
D --> U["<b>não estruturada</b><br/><small>texto · imagem · áudio · grafo</small>"]
classDef ok fill:#e6f4ea,stroke:#3fb950,color:#14532d
classDef warn fill:#fff4e5,stroke:#f0883e,color:#7c2d12
classDef bad fill:#fdeaea,stroke:#ff7b72,color:#7f1d1d
classDef q fill:#eef2f7,stroke:#8b949e,color:#1f2937
class N,O,C ok
class M warn
class I bad
class D,Q1,Q2,Q3,Q4,U q | Tipo | Ordem é real? | Distância é real? | Codificação segura |
|---|---|---|---|
| Contínua — preço, temperatura | sim | sim | escalar |
| Discreta / contagem — pedidos, cômodos | sim | sim | escalar, muitas vezes depois de log1p |
| Ordinal — P/M/G, escolaridade | sim | não | inteiros, sabendo que você afirmou espaçamento igual |
| Cíclica — hora, mês, ângulo | sim | sim, mas circular | sen e cos |
| Nominal — cidade, cor, SKU | não | não | one-hot, ou um embedding |
| Binária — sim/não | trivialmente | trivialmente | 0/1 |
| Identificador — id de usuário, número da linha | não | não | não é feature |
Vendo a afirmação
Escolha uma codificação para hora_do_dia e olhe o que ela afirma. O painel da esquerda é onde as 24 horas caem no espaço codificado; o da direita é a distância que a codificação afirma entre cada par, com a célula de 23h contra a meia-noite destacada em branco.
O número lá embaixo é a concordância de postos entre as distâncias que a codificação afirma e as circulares reais. O inteiro marca cerca de 0,72 — acerta a maioria dos pares mais ou menos e erra catastroficamente justo aquele que dá a volta. O one-hot marca 0,00, porque afirma que todo par é equidistante. sen/cos marca 0,997.
1. Numérica
O caso fácil: a ordem é real e as distâncias são reais. A única coisa a consertar é a escala, porque uma coluna medida na casa das centenas de milhares e outra medida em dezenas não contribuem igualmente para um gradiente, não importa qual das duas importa mais ao problema.
from sklearn.preprocessing import StandardScaler
# dentro de um Pipeline — a visão geral do capítulo diz por quê
Dois subcasos que vale separar:
- Contagens (
n_pedidos,n_visitas) são numéricas, mas quase sempre assimétricas à direita: a maioria das linhas perto de zero, uma cauda longa.log1pantes de escalar costuma ajudar e a página de distribuições cobre quando não ajuda. - Quantidades limitadas (percentuais, probabilidades, intensidades de pixel) já têm faixa natural, então min-max para \([0,1]\) costuma ser mais fiel do que padronizar.
O tratamento completo de qual scaler escolher está em pré-processamento.
2. Ordinal
A ordem é real: pequeno < médio < grande. A codificação inteira a preserva e é exatamente isso que se quer.
from sklearn.preprocessing import OrdinalEncoder
enc = OrdinalEncoder(categories=[['pequeno', 'médio', 'grande']]) # escreva a ordem
Você afirmou mais do que a ordem
0, 1, 2 diz que pequeno→médio é o mesmo passo que médio→grande. Para tamanhos de camisa, mais ou menos verdade. Para {leve, grave, crítico} numa coluna de triagem, muito falso — o salto para crítico não é de uma unidade. Se o espaçamento importa e você não o conhece, use one-hot: perde-se a ordem, mas para-se de inventar distâncias. Ou codifique os dois e deixe a rede escolher o que quer.
Sempre passe as categorias explicitamente. O OrdinalEncoder sem categories ordena alfabeticamente, o que transforma {pequeno, médio, grande} em grande < médio < pequeno.
3. Cíclica
A armadilha de que trata o painel acima. Hora, dia da semana, mês, direção do vento, ângulo de fase: valores que dão a volta, em que o maior e o menor são vizinhos.
import numpy as np
df['hora_sen'] = np.sin(2 * np.pi * df['hora'] / 24)
df['hora_cos'] = np.cos(2 * np.pi * df['hora'] / 24)
Duas colunas em vez de uma e o período vai no denominador: 24 para horas, 7 para dias da semana, 12 para meses, 360 para graus. Você precisa de sen e cos — qualquer um sozinho é ambíguo, já que \(\sin\) assume o mesmo valor em duas horas diferentes.
4. Nominal
Nenhuma ordem. {Recife, Curitiba, Belém} não tem "meio". A codificação não pode inventar um e o custo de não inventar são colunas.
Uma coluna por categoria, todos os pares equidistantes. A escolha honesta abaixo de mais ou menos 20–50 categorias. Acima disso vira um bloco larguíssimo e esparsíssimo que a primeira camada vai ter de aprender a comprimir de qualquer jeito.
import torch.nn as nn
# 40 000 SKUs → um vetor aprendido de 32 dimensões para cada
emb = nn.Embedding(num_embeddings=40_000, embedding_dim=32)
A resposta do aprendizado profundo à cardinalidade alta: a rede aprende onde cada categoria pertence, então categorias parecidas acabam perto umas das outras porque a tarefa diz isso, não porque você decidiu. Para a dimensão, as regras de bolso publicadas discordam entre si — \(k^{1/4}\) e \(1{,}6\,k^{0,56}\) circulam as duas — o que é bom sinal de que nenhuma delas está trabalhando muito. Comece perto de 16 ou 32 e ajuste como qualquer hiperparâmetro.
from sklearn.preprocessing import TargetEncoder
# troca cada categoria por uma média de y, suavizada em direção à média global
Uma coluna em vez de quarenta mil. É também o jeito mais fácil de vazar: a codificação é calculada a partir dos rótulos, então ajustá-la antes da divisão entrega a resposta ao modelo. Pertence ao Pipeline, sempre — veja vazamento.
contagens = X_train['cidade'].value_counts(normalize=True)
X['cidade_freq'] = X['cidade'].map(contagens).fillna(0)
Troca a categoria por quão comum ela é. Barato, sem risco de vazamento a partir dos rótulos e surpreendentemente útil muitas vezes — mas colapsa no mesmo valor cada par de categorias que por acaso seja igualmente comum.
A que nunca está certa
OLabelEncoder existe para codificar o alvo, não as features. Aplicado a uma entrada nominal, ele fabrica uma ordenação a partir de um acaso alfabético e o laboratório abaixo mede quanto isso custa. 5. Identificadores não são features
id_usuario, id_transacao, numero_da_linha, numero_do_paciente. São chaves, não medidas — e a razão para ser rigoroso com isso é que eles funcionam. Ids são atribuídos em blocos: por data de cadastro, por sistema de origem, por hospital. Então o id correlaciona com o alvo, o modelo o encontra, a pontuação melhora e nada foi aprendido.
Esse é um dos casos documentados de vazamento no capítulo de vazamento: no KDD Cup 2008, o id do paciente estava entre as features mais preditivas disponíveis.
Identificadores têm um uso legítimo: definir os grupos que a divisão precisa respeitar, para que todas as linhas de um paciente caiam do mesmo lado.
6. Não estruturadas
| Tipo | Forma | Como vira número | Modelo típico |
|---|---|---|---|
| Texto | comprimento variável | tokens de subpalavra → ids → embeddings | Transformer |
| Imagem | A × L × C | pixels, escalados para \([0,1]\) ou padronizados por canal | CNN, ViT |
| Áudio | T amostras | forma de onda, ou um espectrograma (T × F) | Conv1D, Transformer |
| Grafo | N nós, E arestas | adjacência + features por nó | GNN |
| Série temporal | T × F | segmentos em janelas, features estritamente para trás | LSTM, TCN, Transformer |
A pergunta da codificação não desaparece aqui — ela se muda para dentro do modelo. Um tokenizador é uma afirmação sobre quais sequências de caracteres são unidades de sentido; um espectrograma é uma afirmação de que o conteúdo de frequência importa mais que a fase. A diferença é que essas codificações são em geral padronizadas e em geral aprendidas, em vez de escolhidas coluna a coluna.
Prática
Laboratório: quanto custa uma codificação errada
Passo 1 — inventar uma ordem que não existe
Cada bairro tem seu próprio nível de preço e os níveis não estão em ordem alguma. A codificação por rótulo entrega um número à rede e pede que ela aprenda uma função que salta arbitrariamente a cada inteiro; o one-hot entrega um interruptor por categoria. Pontuado por \(R^2\) em validação cruzada de 5 dobras.
categories | label_int | one_hot |
|---|---|---|
| 10 | 0.795 | 0.940 |
| 40 | 0.163 | 0.963 |
| 100 | 0.077 | 0.959 |
"""An integer code is an order, and the network believes it.
Each neighbourhood has its own price level, and the levels are in no particular
order — neighbourhood 7 is not between neighbourhood 6 and neighbourhood 8 in
any sense at all. Label encoding hands the network a single number and asks it
to learn a function that jumps arbitrarily at every integer. One-hot hands it
one switch per category and asks for nothing.
Watch what happens as the number of categories grows: the harder the invented
ordering is to unlearn, the further the label-encoded model falls behind.
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 KFold, cross_val_score
from sklearn.neural_network import MLPRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
N = 1500
model = lambda: make_pipeline(
StandardScaler(), MLPRegressor(hidden_layer_sizes=(32,), max_iter=4000, random_state=0)
)
cv = KFold(5, shuffle=True, random_state=0)
print("| `categories` | `label_int` | `one_hot` |")
print("|---:|---:|---:|")
for k in (10, 40, 100):
rng = np.random.default_rng(9)
level = rng.normal(size=k) * 2.0 # each category sits where it sits
category = rng.integers(0, k, N)
price = level[category] + 0.4 * rng.normal(size=N)
as_integer = category.reshape(-1, 1).astype(float) # 0, 1, 2, … — an invented order
as_one_hot = np.eye(k)[category] # one switch per category
r2 = lambda X: cross_val_score(model(), X, price, cv=cv, scoring="r2").mean()
print(f"| {k} | **{r2(as_integer):.3f}** | **{r2(as_one_hot):.3f}** |")
Com 10 categorias a rede mais ou menos dá conta: 0,795 contra os 0,940 do one-hot. Ela tem capacidade suficiente para aprender dez saltos numa única entrada. Com 40 categorias desaba para 0,163 e com 100 para 0,077 — enquanto o one-hot fica em 0,96 o tempo todo, porque para ele nada ficou mais difícil: cada categoria continua com seu próprio interruptor.
É essa a forma do estrago. A codificação por rótulo não falha alto; falha como um vazamento lento que piora quanto mais categorias existirem e o único sintoma é um modelo que rende menos sem motivo visível.
Passo 2 — a hora que dá a volta
A demanda segue a hora do dia e o período movimentado atravessa a meia-noite. O modelo é então perguntado sobre duas horas que nunca viu no treino: primeiro o par que atravessa a virada, depois um par no meio do dia. Pontuado pelo erro absoluto médio, contra o piso de ruído de 0,279 que modelo nenhum consegue bater.
encoding | unseen_23_00 | unseen_11_12 |
|---|---|---|
integer | 0.671 | 0.450 |
sin_cos | 0.356 | 0.303 |
one_hot | 2.945 | 3.325 |
noise_floor | 0.279 | 0.279 |
"""Midnight is next to 23:00, and only one of these encodings knows that.
Demand follows the hour of the day, and the busy period runs across midnight.
The model is then asked about two hours it never saw in training — first the
pair that straddles the wrap, then a pair in the middle of the day — and scored
by mean absolute error against the noise floor.
Three claims about geometry, three results:
integer 23 and 0 are as far apart as two hours can be
sin_cos the hours lie on a circle, so 23 and 0 are neighbours
one_hot no hour is near any other, and an unseen hour is a column of zeros
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.metrics import mean_absolute_error
from sklearn.neural_network import MLPRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
N, NOISE = 4000, 0.35
rng = np.random.default_rng(5)
hour = rng.integers(0, 24, N)
angle = 2 * np.pi * hour / 24
demand = 3.0 * np.cos(angle) + 1.0 * np.cos(2 * angle) + NOISE * rng.normal(size=N)
encodings = {
"integer": hour.reshape(-1, 1).astype(float),
"sin_cos": np.c_[np.sin(angle), np.cos(angle)],
"one_hot": np.eye(24)[hour],
}
holdouts = {"unseen_23_00": [23, 0], "unseen_11_12": [11, 12]}
def error(X, held):
test = np.isin(hour, held)
fitted = make_pipeline(
StandardScaler(), MLPRegressor(hidden_layer_sizes=(16,), max_iter=5000, random_state=0)
).fit(X[~test], demand[~test])
return mean_absolute_error(demand[test], fitted.predict(X[test]))
print(f"| `encoding` | `{'` | `'.join(holdouts)}` |")
print("|---|---:|---:|")
for name, X in encodings.items():
scores = " | ".join(f"**{error(X, held):.3f}**" for held in holdouts.values())
print(f"| `{name}` | {scores} |")
floor = NOISE * np.sqrt(2 / np.pi) # E|noise|, the best any model can do
print(f"| `noise_floor` | {floor:.3f} | {floor:.3f} |")
Três codificações, três falhas diferentes:
sin_cosfica em 0,356 e 0,303, perto do piso de 0,279. As horas que ela nunca viu estão cercadas por horas que viu, então ela interpola ao longo do círculo.integeré pior em todo lugar e pior justo na virada: 0,671 contra 0,450 no meio do dia. A meia-noite tem vizinhos de um lado só, porque a codificação pôs o outro lado a 23 unidades.one_hoterra por uma ordem de grandeza — 2,945 e 3,325 — e isso não é sutileza. Uma categoria não vista é uma coluna que vale zero em toda linha de treino. A rede nunca viu aquela entrada acender, então não tem o que dizer. O one-hot abre mão da ordenação e, com ela, de qualquer capacidade de generalizar para um valor que não encontrou.
O que o laboratório ensina
Não existe codificação que esteja sempre certa e os modos de falha são opostos. A codificação inteira inventa estrutura que não existe. O one-hot se recusa a assumir estrutura alguma, o que custa toda generalização que a estrutura teria comprado. A pergunta nunca é "qual codificação é a melhor" — é quais comparações entre estes valores são de fato significativas e, então, qual codificação afirma essas e só essas.