Pipelines
A aula de pré-processamento estabeleceu uma regra: todo passo que aprende algo com os dados (a mediana de um imputador, a média e o desvio-padrão de um scaler, a lista de categorias de um encoder) é ajustado apenas nas linhas de treino e depois reaplicado, sem mudança, a toda outra linha que o modelo vier a ver: o conjunto de teste, um split de validação, os dados do mês que vem em produção. Escrita à mão, essa regra vira uma pilha de chamadas fit e transform que precisa ficar na ordem certa, nos dados certos, para sempre. Esta aula mede o que dá errado quando uma dessas chamadas escorrega; depois, troca a pilha por um único objeto, o Pipeline do scikit-learn, que torna impossível quebrar a regra sem querer.
A aula conta essa história em quatro etapas, desenhadas abaixo: 1. o fluxo manual, sete chamadas e três bugs, cada um medido; 2. um Pipeline, que elimina os três bugs por construção; 3. colunas reais, com o ColumnTransformer mandando cada grupo de colunas para o seu tratamento e com passos seus; 4. inspecionar e confiar, abrindo e salvando o objeto ajustado e medindo o vazamento que ele evita. Cada etapa leva à sua seção; nos slides, uma faixa embaixo mostra em que etapa você está.
- O fluxo manualsete chamadas, três bugs, medidos
- Um Pipelinefit e predict, passo a passo
- Colunas reaisColumnTransformer e passos seus
- Inspecionar e confiarinspeção, persistência, o vazamento evitado
Todo número abaixo vem da execução do código desta página; cada figura é produzida por um script curto que fica ao lado dela.
Slides
Clique nos slides e use as setas ← → para navegar; F abre em tela cheia e Esc mostra todos os slides. Abrir os slides numa aba nova
O problema que os pipelines resolvem
O fluxo manual
Etapa 1. Os dados são a tabela California housing da aula de EDA: 20.640 distritos, oito colunas numéricas (uma delas, total_bedrooms, com valores faltantes), uma coluna categórica (ocean_proximity) e o alvo, o valor mediano das casas do distrito. Para começar simples, as primeiras seções usam só os oito números. O pré-processamento é o da aula anterior: preencher os buracos com a mediana e depois padronizar.
import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsRegressor
from sklearn.preprocessing import StandardScaler
url = "https://raw.githubusercontent.com/hsandmann/biblio/refs/heads/main/ml/aula02/housing.csv"
housing = pd.read_csv(url)
X = housing.drop(columns='median_house_value') # 9 colunas: 8 números + ocean_proximity
y = housing['median_house_value']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
num = ['longitude', 'latitude', 'housing_median_age', 'total_rooms',
'total_bedrooms', 'population', 'households', 'median_income']
# o fluxo manual: três objetos, cada um ajustado no treino e reaplicado no teste
imputer = SimpleImputer(strategy='median')
scaler = StandardScaler()
model = KNeighborsRegressor(n_neighbors=10) # caixa-preta: veja a nota abaixo
A_train = scaler.fit_transform(imputer.fit_transform(X_train[num])) # aprende no treino
model.fit(A_train, y_train)
A_test = scaler.transform(imputer.transform(X_test[num])) # reaplica no teste
pred = model.predict(A_test)
def mae(p):
return f"${mean_absolute_error(y_test, p):,.0f}"
print(f"{len(X_train)} training districts, {len(X_test)} test districts")
print("MAE, careful manual workflow:", mae(pred))
print("MAE, always the training mean:", mae(np.full(len(y_test), y_train.mean())))
16512 training districts, 4128 test districts
MAE, careful manual workflow: $43,023
MAE, always the training mean: $92,894
O modelo é uma caixa-preta nesta aula
O KNeighborsRegressor prevê o valor de um distrito como a média dos 10 distritos de treino mais parecidos; "parecido" quer dizer próximo nos oito números padronizados. Você vai estudá-lo de verdade na aula de k-NN; aqui ele só precisa de duas propriedades. Ele depende da escala das colunas, então erros de pré-processamento aparecem nas previsões. E é rápido. Qualquer outro modelo serviria para a mecânica dos pipelines.
A métrica é o erro absoluto médio (MAE, de mean absolute error): em média, a previsão erra o valor verdadeiro de um distrito de teste por US$ 43.023. Um "modelo" que ignora o distrito e sempre responde a média do treino erra por US$ 92.894. Esse é o piso que qualquer modelo de verdade precisa superar.
A versão cuidadosa exige sete chamadas em três objetos; a ordem importa nos dois sentidos: fit_transform no treino, transform no teste, imputador antes do scaler, as mesmas oito colunas na mesma ordem. Cada um dos três bugs clássicos abaixo muda uma coisa no código de previsão. O modelo treinado é o mesmo em todos os casos.
Bug 1 — reajustar nos dados que você prevê
O deslize mais comum é uma palavra: fit_transform onde deveria ser transform. O passo então reaprende suas estatísticas com as linhas que ele deveria apenas transformar. É mais fácil ver à mão. Considere uma coluna com cinco linhas de treino e duas linhas novas:
| linhas de treino | linhas novas |
|---|---|
| 200, NaN, 400, 600, 800 | NaN, 900 |
Ajuste nas linhas de treino. A mediana dos quatro valores conhecidos é \((400 + 600)/2 = 500\), então a coluna imputada fica 200, 500, 400, 600, 800. A média dela é 500 e o desvio-padrão é \(\sqrt{(300^2 + 0^2 + 100^2 + 100^2 + 300^2)/5} = \sqrt{40{.}000} = 200\). As linhas novas viram: NaN → 500 → \((500 - 500)/200 = 0\) e 900 → \((900 - 500)/200 = 2\). O segundo distrito novo está dois desvios-padrão acima da média do treino — uma informação que o modelo pode usar.
Agora reajuste nas linhas novas. A mediana delas é 900 (o único valor conhecido), então as duas linhas viram 900; a média é 900 e a dispersão é zero; as duas são transformadas em 0. O distrito com 900 quartos, dois desvios-padrão acima da média do treino, agora parece perfeitamente médio. O scikit-learn concorda:
toy_train = pd.DataFrame({'total_bedrooms': [200, np.nan, 400, 600, 800]})
toy_new = pd.DataFrame({'total_bedrooms': [np.nan, 900]})
imp, sc = SimpleImputer(strategy='median'), StandardScaler()
sc.fit(imp.fit_transform(toy_train)) # aprende nas 5 linhas de treino
print("learned: median", imp.statistics_, "mean", sc.mean_, "std", sc.scale_)
print("right: transform ", sc.transform(imp.transform(toy_new)).ravel())
print("wrong: fit_transform again", StandardScaler().fit_transform(
SimpleImputer(strategy='median').fit_transform(toy_new)).ravel())
learned: median [500.] mean [500.] std [200.]
right: transform [0. 2.]
wrong: fit_transform again [0. 0.]
(Quando uma coluna não tem dispersão nenhuma, o StandardScaler divide por 1 em vez de 0, então o resultado é 0 e não um erro.)
Quanto isso custa nos dados reais depende de como as linhas chegam para a previsão. Num notebook, o conjunto de teste inteiro chega de uma vez. Em produção, as linhas chegam em lotes (o escritório de uma região manda os seus distritos) ou uma de cada vez (um usuário preenche um formulário). A função abaixo reproduz o bug: o modelo treinado é o mesmo, mas o scaler é reajustado em cada lote que ele prevê.
def predict_bug1(batch):
"""BUG 1: fit_transform onde deveria ser transform: o scaler reaprende no lote."""
return model.predict(StandardScaler().fit_transform(imputer.transform(batch[num])))
print("re-fit on the whole test set: ", mae(predict_bug1(X_test)))
by_region = pd.concat([pd.Series(predict_bug1(batch), index=batch.index)
for _, batch in X_test.groupby('ocean_proximity')])
print("re-fit on each region's batch: ", mae(by_region[X_test.index]))
one_by_one = [predict_bug1(X_test.iloc[[i]])[0] for i in range(len(X_test))]
print("re-fit on one district at a time:", mae(one_by_one),
f"({len(set(one_by_one))} distinct prediction)")
re-fit on the whole test set: $42,814
re-fit on each region's batch: $65,754
re-fit on one district at a time: $116,049 (1 distinct prediction)
Três resultados, um só bug:
- No conjunto de teste inteiro o bug é invisível. 4.128 distritos sorteados têm quase as mesmas médias e desvios-padrão dos 16.512 distritos de treino, então reajustar quase não muda nada; o MAE sai até US$ 209 menor que o correto, por acaso. É por isso que o bug passa pela revisão de código: o notebook parece certo.
- Em lotes por região ele custa US$ 22.731 por distrito. Cada lote é recentrado em si mesmo. Os distritos do interior são medidos contra outros distritos do interior, então uma renda modesta para os padrões do estado vira "média"; a latitude e a longitude de cada lote são levadas para o centro do mapa. Os vizinhos que o modelo encontra deixam de ser os certos.
- Um distrito de cada vez, o modelo fica pior que inútil. Uma única linha tem dispersão zero, então toda coluna vira 0 (a tabelinha acima). Todo distrito vira o mesmo ponto, recebe os mesmos 10 vizinhos e a mesma previsão: um único valor para 4.128 distritos e um MAE de US$ 116.049, pior do que sempre prever a média.
Bug 2 — um passo esquecido na hora de prever
O código de treino e o código de previsão costumam ser escritos em momentos diferentes, por pessoas diferentes, às vezes em arquivos diferentes. Um passo que está em um e falta no outro é o segundo clássico:
print("scaler forgotten:", mae(model.predict(imputer.transform(X_test[num]))))
try:
model.predict(scaler.transform(X_test[num])) # imputador esquecido
except ValueError as err:
print("imputer forgotten:", str(err).splitlines()[0])
Esquecer o imputador é o caso de sorte: os valores faltantes chegam ao modelo e ele os recusa com um erro. Esquecer o scaler é silencioso. O modelo recebe números crus (um total_rooms de 2.000 onde aprendeu valores de poucas unidades em torno de 0), calcula distâncias dominadas pelas colunas maiores e devolve uma previsão confiante para cada distrito. O MAE, US$ 105.978, é de novo pior do que prever a média; nada na tela avisa.
Bug 3 — outra ordem de colunas
O modelo recebe números, não nomes: a coluna 5 do array é "population" só porque era a coluna 5 no treino. Se os dados chegam em outra ordem (uma nova exportação do banco de dados, outro cliente da API), cada coluna recebe as estatísticas e os vizinhos de outra. Aqui duas colunas de contagem trocam de lugar:
swapped = ['longitude', 'latitude', 'housing_median_age', 'total_rooms',
'total_bedrooms', 'households', 'population', 'median_income'] # as duas penúltimas trocadas
X_new = X_test[swapped]
print("swapped, sent as an array:", mae(model.predict(scaler.transform(imputer.transform(X_new.to_numpy())))))
try:
imputer.transform(X_new) # enviado como DataFrame
except ValueError as err:
print("swapped, sent as a DataFrame:", " ".join(str(err).splitlines()[:2]))
swapped, sent as an array: $101,517
swapped, sent as a DataFrame: The feature names should match those that were passed during fit. Feature names must be in the same order as they were in fit.
Como array, a troca custa US$ 101.517 por distrito. Um distrito tem cerca de três vezes mais moradores que domicílios, então a coluna "households" fica de repente três vezes maior do que qualquer coisa que o modelo viu e a coluna "population" três vezes menor. O único indício é um UserWarning ("X does not have valid feature names") no meio dos avisos de sempre do notebook. Como DataFrame, o scikit-learn compara os nomes das colunas com os que viu no fit e recusa. Mantenha os dados num DataFrame até o modelo e deixe o primeiro passo conferir os nomes.
A figura põe os seis resultados lado a lado (script da figura):
Três das seis barras são piores que o baseline que ignora os dados; a barra mais perigosa é a segunda, o bug que parece certo.
Pipeline: um estimador, vários passos
Etapa 2. Um Pipeline é uma lista de passos que se comporta como um único estimador. O make_pipeline monta um a partir dos passos e dá nome a eles automaticamente:
from sklearn.pipeline import make_pipeline
pipe = make_pipeline(SimpleImputer(strategy='median'), StandardScaler(),
KNeighborsRegressor(n_neighbors=10))
pipe.fit(X_train[num], y_train) # todo passo aprende com as linhas de treino
pred_pipe = pipe.predict(X_test[num]) # todo passo reaplica o que aprendeu
print("MAE:", mae(pred_pipe), "· identical to the careful manual workflow:",
np.array_equal(pred_pipe, pred))
one_by_one = [pipe.predict(X_test[num].iloc[[i]])[0] for i in range(len(X_test))]
print("one district at a time == all at once:", np.allclose(one_by_one, pred_pipe))
try:
pipe.predict(X_new) # as colunas trocadas do bug 3
except ValueError as err:
print("swapped columns:", str(err).splitlines()[0])
print(list(pipe.named_steps))
MAE: $43,023 · identical to the careful manual workflow: True
one district at a time == all at once: True
swapped columns: The feature names should match those that were passed during fit.
['simpleimputer', 'standardscaler', 'kneighborsregressor']
Duas chamadas, fit e predict, substituem sete. As previsões são exatamente as do fluxo manual cuidadoso e os três bugs somem por construção:
- Bug 1. O
pipe.predictnunca chamafitnum transformador, então o tamanho do lote deixa de importar: 4.128 previsões feitas um distrito por vez são idênticas às 4.128 feitas de uma vez. - Bug 2. Não existe código de previsão separado em que esquecer um passo. Os passos viajam dentro do objeto.
- Bug 3. O pipeline entrega o DataFrame ao primeiro passo, que confere os nomes das colunas e recusa a tabela trocada.
As regras de composição são poucas. Todo passo exceto o último precisa ser um transformador (tem fit e transform); o último pode ser qualquer coisa com fit, em geral um modelo com predict. O pipeline então expõe os métodos do último passo: pipe.predict, pipe.score e, para um classificador, pipe.predict_proba.
O que fit e predict fazem, passo a passo
O pipe.fit(X, y) percorre a lista de cima para baixo. Cada transformador é ajustado no que recebe e em seguida o transforma (fit_transform); sua saída é a entrada do passo seguinte; o último passo é só ajustado. O pipe.predict(X) percorre a mesma lista chamando apenas transform (com os parâmetros congelados no ajuste) e termina com o predict do modelo. A coluna de brinquedo do bug 1, desenhada como pipeline (script da figura):
Para ver isso acontecer nos dados reais, coloque entre os passos de verdade um passo que não faz nada e relata o que vê (como escrever uma classe assim é o assunto de seus próprios passos, mais abaixo). Ele imprime o formato dos dados e, para a coluna total_bedrooms, o número de faltantes e a média naquele ponto:
from sklearn.base import BaseEstimator, TransformerMixin
class Spy(BaseEstimator, TransformerMixin):
"""Um passo que não muda nada e relata o que passa por ele."""
def __init__(self, label=''):
self.label = label
def fit(self, X, y=None):
self._report('fit', X)
return self
def transform(self, X):
self._report('transform', X)
return X
def _report(self, call, X):
X = np.asarray(X, dtype=float)
col = X[:, 4] # total_bedrooms
print(f" {self.label:8s} {call:9s} {str(X.shape):11s} "
f"missing {np.isnan(col).sum():3d} · mean {np.nanmean(col):7.2f}")
traced = make_pipeline(Spy('raw'), SimpleImputer(strategy='median'), Spy('imputed'),
StandardScaler(), Spy('scaled'), KNeighborsRegressor(n_neighbors=10))
print("traced.fit(X_train, y_train)")
traced.fit(X_train[num], y_train)
print("traced.predict(X_test)")
_ = traced.predict(X_test[num])
traced.fit(X_train, y_train)
raw fit (16512, 8) missing 163 · mean 538.92
raw transform (16512, 8) missing 163 · mean 538.92
imputed fit (16512, 8) missing 0 · mean 537.92
imputed transform (16512, 8) missing 0 · mean 537.92
scaled fit (16512, 8) missing 0 · mean 0.00
scaled transform (16512, 8) missing 0 · mean 0.00
traced.predict(X_test)
raw transform (4128, 8) missing 44 · mean 533.66
imputed transform (4128, 8) missing 0 · mean 532.63
scaled transform (4128, 8) missing 0 · mean -0.01
Leia de cima para baixo:
- Durante o
fit, só existem linhas de treino. Todo passo vê 16.512 linhas, nunca 4.128. Cada passo é ajustado e logo em seguida transforma (os paresfit+transform), porque o passo seguinte precisa ser ajustado em dados de treino já transformados: o scaler aprende a média a partir dos valores imputados (537,92), não dos crus com buracos. - O imputador preenche 163 buracos com a mediana do treino, o que baixa a média da coluna em 1 quarto, de 538,92 para 537,92: a mediana (437) fica abaixo da média porque a coluna tem uma cauda longa à direita.
- Durante o
predict, ninguém chamafit. Os 44 buracos do conjunto de teste são preenchidos com a mediana do treino e o scaler subtrai a média do treino. É por isso que a coluna de teste padronizada tem média −0,01 e não exatamente 0: as linhas de teste são medidas com a régua das linhas de treino, que é o procedimento honesto. Uma média de exatamente 0,00 no teste seria o sintoma do bug 1.
Pipeline ou make_pipeline
O make_pipeline dá a cada passo o nome da sua classe, em minúsculas: 'simpleimputer', 'standardscaler', 'kneighborsregressor' (e 'spy-1', 'spy-2', ... quando uma classe se repete). No Pipeline, você mesmo dá os nomes, como uma lista de pares (nome, passo):
from sklearn.pipeline import Pipeline
pipe_named = Pipeline([('impute', SimpleImputer(strategy='median')),
('scale', StandardScaler()),
('model', KNeighborsRegressor(n_neighbors=10))])
Os dois produzem o mesmo tipo de objeto; os nomes só importam quando você se refere a um passo depois (para inspecioná-lo ou mudar uma configuração dele). Nomes curtos e explícitos valem a pena em pipelines longos.
Colunas heterogêneas: ColumnTransformer
Etapa 3. Um pipeline aplica todo passo a todas as colunas. Tabelas reais precisam de tratamentos diferentes para colunas diferentes: aqui os oito números precisam da mediana e do scaler, enquanto ocean_proximity, uma coluna de texto, precisa de one-hot. Um ColumnTransformer manda cada grupo de colunas para o seu próprio ramo e põe os resultados lado a lado:
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
preprocess = ColumnTransformer([
('num', make_pipeline(SimpleImputer(strategy='median'), StandardScaler()), num),
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), ['ocean_proximity']),
])
full = make_pipeline(preprocess, KNeighborsRegressor(n_neighbors=10))
full.fit(X_train, y_train) # entra a tabela crua de 9 colunas
print("MAE with ocean_proximity:", mae(full.predict(X_test)))
print(preprocess.get_feature_names_out())
MAE with ocean_proximity: $41,552
['num__longitude' 'num__latitude' 'num__housing_median_age'
'num__total_rooms' 'num__total_bedrooms' 'num__population'
'num__households' 'num__median_income' 'cat__ocean_proximity_<1H OCEAN'
'cat__ocean_proximity_INLAND' 'cat__ocean_proximity_ISLAND'
'cat__ocean_proximity_NEAR BAY' 'cat__ocean_proximity_NEAR OCEAN']
Cada ramo é uma tripla (nome, transformador, colunas); um ramo pode ser ele mesmo um pipeline, como o 'num'. O conjunto todo é um único transformador e, portanto, entra num pipeline como qualquer outro passo. O modelo agora recebe a tabela crua de 9 colunas: a coluna de texto deixa de ser um problema e usá-la baixa o MAE de US$ 43.023 para US$ 41.552.
O get_feature_names_out diz o que é cada coluna de saída. O prefixo é o nome do ramo que a produziu (num__, cat__); as colunas do one-hot trazem a categoria (cat__ocean_proximity_INLAND). Sem isso, a entrada do modelo é um bloco de 13 colunas de números anônimos. O que realmente sai, para oito distritos de teste (script da figura):
Que colunas vão para onde: listas, remainder, seletores
As colunas que nenhum ramo menciona são descartadas por padrão (remainder='drop'). Com remainder='passthrough' elas são anexadas sem mudança:
two = ColumnTransformer([('num', StandardScaler(), ['median_income', 'housing_median_age'])])
print("remainder='drop': ", two.fit_transform(X_train).shape)
two_plus = ColumnTransformer([('num', StandardScaler(), ['median_income', 'housing_median_age'])],
remainder='passthrough')
print("remainder='passthrough':", two_plus.fit_transform(X_train).shape)
print(two_plus.get_feature_names_out()[:4])
remainder='drop': (16512, 2)
remainder='passthrough': (16512, 9)
['num__median_income' 'num__housing_median_age' 'remainder__longitude'
'remainder__latitude']
O 'drop' é o padrão seguro: uma coluna de identificador ou um campo de texto livre de que você se esqueceu nunca chega ao modelo em silêncio. O 'passthrough' é para colunas que já estão prontas. Aqui seria um erro, porque as sete colunas repassadas chegam sem escala (e ocean_proximity chega como texto).
Em vez de listas de nomes, um ramo pode receber um seletor, que escolhe as colunas pelo tipo quando o transformador é ajustado:
from sklearn.compose import make_column_selector
by_type = ColumnTransformer([
('num', make_pipeline(SimpleImputer(strategy='median'), StandardScaler()),
make_column_selector(dtype_include='number')),
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False),
make_column_selector(dtype_exclude='number')),
])
by_type.fit(X_train)
for name, _, columns in by_type.transformers_:
print(name, columns)
num ['longitude', 'latitude', 'housing_median_age', 'total_rooms', 'total_bedrooms', 'population', 'households', 'median_income']
cat ['ocean_proximity']
O transformador ajustado (transformers_, com o sublinhado no final) registra as colunas que cada seletor escolheu. Seletores são práticos em tabelas largas, mas confiam nos tipos: um CEP guardado como número cai no ramo numérico. Listas explícitas dão mais trabalho, mas dizem exatamente o que acontece.
Vendo a saída: nomes das colunas e set_output
Por padrão um transformador devolve um array do NumPy. O set_output(transform='pandas') faz com que ele devolva um DataFrame com os nomes do get_feature_names_out como colunas, que é o jeito mais fácil de conferir um pré-processamento a olho:
20046 3024 15663
num__longitude -1.41 0.60 1.16
num__latitude 2.36 -0.84 -1.36
num__housing_median_age -1.47 0.51 0.82
num__total_rooms -0.17 -0.67 0.21
num__total_bedrooms -0.23 -0.80 -0.26
num__population -0.14 -0.73 -0.22
num__households -0.16 -0.76 -0.18
num__median_income -0.87 0.45 1.96
cat__ocean_proximity_<1H OCEAN 0.00 1.00 0.00
cat__ocean_proximity_INLAND 1.00 0.00 0.00
cat__ocean_proximity_ISLAND 0.00 0.00 0.00
cat__ocean_proximity_NEAR BAY 0.00 0.00 0.00
cat__ocean_proximity_NEAR OCEAN 0.00 0.00 1.00
Três distritos de teste, transpostos para que cada coluna da saída seja uma linha aqui. O distrito 20046 fica no interior, 2,36 desvios-padrão ao norte do distrito de treino médio, com renda 0,87 desvio-padrão abaixo da média. O índice do DataFrame é preservado, então toda linha transformada pode ser rastreada até a sua linha crua. (A saída em pandas exige arrays densos, daí o sparse_output=False no encoder.)
Robusto à ordem das colunas e a categorias novas
Um ColumnTransformer ajustado num DataFrame escolhe suas colunas pelo nome, então o bug 3 desaparece de vez, em vez de virar um erro. E um encoder com handle_unknown='ignore' transforma uma categoria que nunca viu numa linha de zeros em vez de quebrar:
reversed_cols = X_test[X_test.columns[::-1]] # mesmas colunas, ordem inversa
print("reversed columns, same predictions:", np.array_equal(full.predict(reversed_cols), full.predict(X_test)))
new = X_test.head(1).assign(ocean_proximity='LAKE') # uma categoria nunca vista no treino
print(preprocess.transform(new).filter(like='ocean').to_numpy(), full.predict(new).round())
Com as nove colunas em ordem inversa, as previsões são idênticas. O distrito rebatizado de 'LAKE' recebe zeros nas cinco colunas de ocean_proximity e uma previsão, US$ 90.030, igual à que recebe com o seu rótulo verdadeiro, INLAND: seus 10 vizinhos mais próximos estão tão perto nos oito números que perder uma coluna de categoria não os mudou. Sem handle_unknown='ignore', a mesma linha levantaria ValueError: Found unknown categories; um valor novo em produção derrubaria o serviço inteiro. Categorias raras tornam isso provável mesmo sem erros de digitação: ISLAND tem 5 distritos na tabela inteira e, com este split, todos caíram no treino.
Seus próprios passos
Qualquer coisa que aprende com os dados e os transforma pode virar um passo de pipeline. Há dois caminhos, dependendo de o passo ter algo a aprender ou não.
Sem estado: FunctionTransformer
A aula de pré-processamento recomendou \(\log(1 + x)\) para colunas assimétricas à direita. Um logaritmo não aprende nada com os dados, então o FunctionTransformer transforma a função num passo diretamente:
from sklearn.preprocessing import FunctionTransformer
counts = ['total_rooms', 'total_bedrooms', 'population', 'households']
log = FunctionTransformer(np.log1p, feature_names_out='one-to-one') # nada a aprender
print("skewness before:", X_train[counts].skew().round(2).to_list())
print("skewness after: ", log.fit_transform(X_train[counts]).skew().round(2).to_list())
As quatro colunas de contagem vão de uma assimetria forte à direita (de 3,3 a 5,2) para uma assimetria mais leve à esquerda (cerca de −1). O fit dele não faz nada, então ele não tem como vazar. O feature_names_out='one-to-one' avisa ao passo que a coluna de saída i é a coluna de entrada i; assim, o get_feature_names_out continua funcionando depois dele.
Com estado: um transformador seu
Quando o passo precisa aprender algo, ele precisa de uma classe com fit e transform. O exemplo é um "cortador" (clipper). O total_rooms vai até 39.320 numa tabela cuja mediana é cerca de 2.100; um único valor extremo (ou um erro de digitação nos dados novos) pode dominar as distâncias do modelo. O cortador aprende os percentis 1 e 99 de cada coluna só nas linhas de treino e puxa todo valor fora dessa faixa de volta para o limite mais próximo:
from sklearn.base import OneToOneFeatureMixin
from sklearn.utils.validation import check_is_fitted, validate_data
class Clipper(OneToOneFeatureMixin, TransformerMixin, BaseEstimator):
"""Corta cada coluna nos percentis aprendidos nos dados de treino."""
def __init__(self, low=1, high=99):
self.low = low # hiperparâmetros: guardados como vieram, nada mais
self.high = high
def fit(self, X, y=None):
X = validate_data(self, X) # vira array; guarda os nomes e o número de colunas
self.lower_ = np.percentile(X, self.low, axis=0) # o estado aprendido termina em "_"
self.upper_ = np.percentile(X, self.high, axis=0)
return self
def transform(self, X):
check_is_fitted(self)
X = validate_data(self, X, reset=False) # mesmas colunas do fit ou erro
return np.clip(X, self.lower_, self.upper_)
clipped = make_pipeline(
ColumnTransformer([
('num', make_pipeline(SimpleImputer(strategy='median'), Clipper(), StandardScaler()), num),
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), ['ocean_proximity'])]),
KNeighborsRegressor(n_neighbors=10))
clipped.fit(X_train, y_train)
clip = clipped[0].named_transformers_['num'][1]
print(pd.DataFrame({'lower_': clip.lower_, 'upper_': clip.upper_}, index=num).round(1))
print("MAE:", mae(clipped.predict(X_test)))
lower_ upper_
longitude -123.2 -116.3
latitude 32.7 40.6
housing_median_age 4.0 52.0
total_rooms 162.2 11248.0
total_bedrooms 35.0 2245.6
population 88.0 5825.3
households 30.0 2006.6
median_income 1.1 10.7
MAE: $41,361
As convenções são o que fazem de uma classe um passo do scikit-learn:
- O
__init__só guarda os hiperparâmetros, com os mesmos nomes dos argumentos; não faz mais nada. OBaseEstimatoros lê dali para oferecerget_params,set_paramse a representação impressa. - O
fitaprende e devolveself. Tudo o que é aprendido com os dados ganha um nome terminado em sublinhado (lower_,upper_), a mesma convenção demean_estatistics_. Ovalidate_dataconverte a entrada num array e registra o número e os nomes das colunas. - O
transformaplica o que foi aprendido, depois de conferir que o objeto foi ajustado e que as colunas são as mesmas. - O
TransformerMixinoferece ofit_transform(e oset_output); oOneToOneFeatureMixinoferece oget_feature_names_outpara um passo que mantém suas colunas uma a uma.
A tabela é o estado aprendido: os limites vêm dos 16.512 distritos de treino. Neste conjunto de teste o cortador melhora o MAE só um pouco, de US$ 41.552 para US$ 41.361, porque poucos valores de teste ficam fora dos limites. O trabalho de verdade dele é proteger contra valores que ninguém viu. Tome o primeiro distrito de teste e multiplique o total_rooms dele por 1.000, um erro de unidade do tipo que aparece quando uma fonte de dados nova começa a mandar os cômodos em outra unidade:
typo = X_test.head(1).copy()
typo['total_rooms'] *= 1000 # um erro de unidade numa linha nova
print(f"true value ${y_test.iloc[0]:,.0f} · correct row ${full.predict(X_test.head(1))[0]:,.0f}")
print(f"with the typo: no clipper ${full.predict(typo)[0]:,.0f} · clipper ${clipped.predict(typo)[0]:,.0f}")
Variar o multiplicador de 1 a 1.000 mostra de onde vem a diferença (script da figura):
Até 11.248 cômodos (o upper_ aprendido) os dois pipelines quase concordam. Depois disso, o pipeline sem corte segue o erro de digitação para regiões dos dados em que os únicos vizinhos são distritos enormes e a previsão vagueia até US$ 251.020. O pipeline com corte vê no máximo 11.248 e fica em US$ 145.690. Continua sendo uma resposta errada para um distrito que vale US$ 65.600: nenhum transformador conserta uma entrada ruim. Mas o estrago tem limite, um limite aprendido só com dados de treino.
Inspeção e persistência
Etapa 4. Um pipeline ajustado é um contêiner que você pode abrir. Os passos são acessados pelo nome (named_steps) ou pela posição, com a mesma indexação de uma lista do Python; uma fatia é um pipeline menor:
print(list(full.named_steps))
print(full[-1]) # indexação, como numa lista
print(full.named_steps['columntransformer'].named_transformers_['num'][0].statistics_.round(1))
print(full[:-1].transform(X_test.head(2)).shape) # tudo menos o modelo
['columntransformer', 'kneighborsregressor']
KNeighborsRegressor(n_neighbors=10)
[-118.5 34.2 29. 2129. 437. 1169. 411. 3.5]
(2, 13)
O named_transformers_ faz o mesmo dentro de um ColumnTransformer, então qualquer valor aprendido pode ser lido: aqui as oito medianas de treino que o imputador vai usar para preencher buracos (437 quartos, como na figura). O full[:-1] é "só o pré-processamento", útil para ver exatamente o que o modelo recebe.
No Jupyter, um pipeline deixado como última expressão de uma célula é desenhado como um diagrama interativo (set_config(display='diagram'), o padrão), com cada caixa expansível até os seus parâmetros. O print dá a versão em texto:
from sklearn import set_config
set_config(display='diagram') # o padrão: um diagrama clicável no Jupyter
print(full)
Pipeline(steps=[('columntransformer',
ColumnTransformer(transformers=[('num',
Pipeline(steps=[('simpleimputer',
SimpleImputer(strategy='median')),
('standardscaler',
StandardScaler())]),
['longitude', 'latitude',
'housing_median_age',
'total_rooms',
'total_bedrooms',
'population', 'households',
'median_income']),
('cat',
OneHotEncoder(handle_unknown='ignore',
sparse_output=False),
['ocean_proximity'])])),
('kneighborsregressor', KNeighborsRegressor(n_neighbors=10))])
Só aparecem as configurações diferentes do padrão.
Como a cadeia inteira é um objeto só, ela é salva como um arquivo, que contém o estado aprendido de todos os passos:
import os
import joblib
joblib.dump(full, 'housing-knn.joblib') # um arquivo: pré-processamento + modelo
loaded = joblib.load('housing-knn.joblib')
print(f"{os.path.getsize('housing-knn.joblib') / 1e6:.1f} MB on disk")
print("identical predictions after the round trip:",
np.array_equal(loaded.predict(X_test), full.predict(X_test)))
O pipeline carregado recebe linhas cruas e devolve as mesmas previsões, até o último dígito. Quem o usa não tem como esquecer um passo, reajustar um scaler ou reordenar as colunas: os três bugs da primeira seção precisam de pedaços de código separados para acontecer, mas aqui não existe nenhum. (O arquivo é grande porque o k-NN guarda os 16.512 distritos de treino para achar vizinhos; a maioria dos modelos guarda bem menos.) Um pipeline salvo também traz riscos próprios, como as versões das bibliotecas e a segurança de carregar o arquivo de outra pessoa; esses riscos são o assunto de MLOps.
O vazamento que um pipeline evita
A aula de pré-processamento afirmou que ajustar um passo em todos os dados antes da divisão vaza informação do conjunto de teste. Quanto depende do que o passo aprende. Compare dois passos.
O primeiro é o scaler. Vinte splits diferentes, cada um feito duas vezes: uma com o imputador e o scaler ajustados nos 20.640 distritos antes da divisão, outra dentro do pipeline. O segundo é um passo que aprende com o alvo: um target encoder, que troca cada categoria de uma coluna pela média do alvo naquela categoria nos dados de treino. É a ferramenta padrão para uma coluna categórica com milhares de valores, em que o one-hot criaria milhares de colunas. A tabela não tem uma coluna assim, então construímos uma a partir das coordenadas, arredondadas para duas casas decimais (células de cerca de 1 km, fazendo o papel de um CEP):
from sklearn.preprocessing import TargetEncoder
zone = housing['latitude'].round(2).astype(str) + '_' + housing['longitude'].round(2).astype(str)
Z = X[['median_income', 'housing_median_age']].assign(zone=zone) # células de ~1 km, como um CEP
Z_train, Z_test = Z.loc[X_train.index], Z.loc[X_test.index]
print(zone.nunique(), "zones ·", (zone.value_counts() == 1).sum(), "with a single district ·",
f"{(~Z_test['zone'].isin(Z_train['zone'])).mean():.0%} of test zones unseen in training")
# VAZAMENTO: o encoder aprende o preço médio de cada zona com TODAS as linhas, inclusive as de teste
enc = TargetEncoder(target_type='continuous', random_state=0).fit(Z[['zone']], y)
def encode(D):
return D.drop(columns='zone').assign(zone_price=enc.transform(D[['zone']])[:, 0])
leaky = make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=10))
leaky.fit(encode(Z_train), y_train)
print("encoder fit on all rows: ", mae(leaky.predict(encode(Z_test))))
# HONESTO: o encoder é um passo do pipeline, então só vê preços de treino
honest = make_pipeline(
ColumnTransformer([('zone', TargetEncoder(target_type='continuous', random_state=0), ['zone'])],
remainder='passthrough'),
StandardScaler(), KNeighborsRegressor(n_neighbors=10))
honest.fit(Z_train, y_train)
print("encoder inside the pipeline:", mae(honest.predict(Z_test)))
no_zone = make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=10))
no_zone.fit(Z_train.drop(columns='zone'), y_train)
print("no zone at all: ", mae(no_zone.predict(Z_test.drop(columns='zone'))))
12590 zones · 8237 with a single district · 44% of test zones unseen in training
encoder fit on all rows: $14,567
encoder inside the pipeline: $42,552
no zone at all: $60,383
A versão com vazamento reporta um MAE de US$ 14.567, quase três vezes menor que qualquer outro erro desta aula, a partir de duas colunas e um código de zona. O mecanismo é simples. 8.237 das 12.590 zonas têm um único distrito. Quando o encoder é ajustado em todas as linhas, o "preço médio da zona" de um distrito de teste desses é o próprio preço dele (puxado um pouco em direção à média geral pela suavização do encoder) e o modelo lê a resposta na própria entrada. Dentro do pipeline o encoder só vê preços de treino: 44% dos distritos de teste caem em zonas que ele nunca viu e recebem a média geral; os outros recebem a média de outros distritos da sua zona. O MAE honesto é US$ 42.552. A zona continua útil (sem ela, US$ 60.383), só não é mágica.
(Um detalhe deste encoder: no fit_transform, que é o que um pipeline chama durante o fit, o TargetEncoder codifica cada linha de treino com médias calculadas sem aquela linha, usando uma divisão interna. Isso impede o modelo de aprender a confiar demais na codificação. O fit seguido de transform, como na versão com vazamento, não faz isso.)
Repetido em 20 splits, o contraste entre os dois tipos de passo é estável (script da figura):
Para o scaler, o vazamento muda o MAE em US$ 4 na média e em no máximo US$ 96 em qualquer split: a média e o desvio-padrão de 16.512 ou de 20.640 distritos são praticamente os mesmos números; além disso, o scaler nunca olha para o preço. Para o target encoder, o vazamento faz o erro parecer US$ 28.300 menor na média e pelo menos US$ 27.100 menor em todo split. A regra que sai daqui não é "scalers são seguros". É: você não tem como saber o tamanho de um vazamento sem medir as duas versões; com todo passo ajustado dentro do pipeline, não há nada a medir. Quanto mais um passo usa o alvo (target encoding, seleção de atributos, sobreamostragem), maior o vazamento possível; a aula de validação cruzada e vazamento de dados mede outros.
Uma coluna só basta para ver por que o scaler quase não vazou numa divisão aleatória e quando ele deixaria de ser inofensivo. O simulador ajusta os dois passos da aula (imputação pela mediana, depois o scaler) numa coluna com buracos, de um de dois jeitos: só nas linhas de treino (como o pipeline faz) ou em todas as linhas antes da divisão. Compare os dois modos numa divisão aleatória; depois troque o teste por um período posterior, em que os preços subiram.
Numa divisão aleatória as linhas de teste se parecem com as de treino, então somá-las ao fit quase não muda as estatísticas: é o vazamento pequeno do scaler, medido acima. Quando o teste vem de um período posterior, a média e o desvio-padrão vazados já contêm os preços novos: o deslocamento parece menor do que é e os buracos preenchidos ficam um pouco mais perto da verdade (a mediana resiste a uma minoria de valores altos). O modelo não melhorou em nada; só a avaliação melhorou. Dentro do pipeline, as linhas de teste nunca participam de um fit, seja qual for a divisão.
Olhando adiante: ajuste através do pipeline
Toda configuração de todo passo é acessível de fora por um caminho com duplo sublinhado, passo__parametro, aninhado tão fundo quanto o pipeline (columntransformer__num__simpleimputer__strategy). O get_params lista todos e o set_params muda qualquer um sem reconstruir o pipeline:
params = full.get_params()
print(len(params), "parameters, e.g.:")
for name in params:
if name.endswith(('n_neighbors', 'strategy', 'handle_unknown')):
print(" ", name, "=", params[name])
full.set_params(kneighborsregressor__n_neighbors=20)
print(full[-1])
46 parameters, e.g.:
columntransformer__num__simpleimputer__strategy = median
columntransformer__cat__handle_unknown = ignore
kneighborsregressor__n_neighbors = 10
KNeighborsRegressor(n_neighbors=20)
Depois do set_params, o próximo fit usa o valor novo. É isso que torna possível o ajuste automático: uma busca experimenta vários valores de kneighborsregressor__n_neighbors ou de columntransformer__num__simpleimputer__strategy. Como cada tentativa reajusta o pipeline inteiro, o pré-processamento é reaprendido para cada candidato nas linhas usadas para treinar, nunca nas usadas para avaliar. Escolher esses valores de forma honesta exige avaliá-los em dados que não são nem o treino nem o teste; o GridSearchCV do scikit-learn faz isso com o pipeline e esses nomes; ele é o assunto da aula de validação cruzada. Até lá, uma regra: nunca escolha uma configuração olhando a nota de teste.
Hábito de projeto
Comece todo projeto escrevendo o esqueleto do pipeline, antes mesmo de escolher o modelo. Isso impõe a disciplina treino/teste desde a primeira linha de código. Além disso, o modelo pode ser trocado mudando uma linha e o objeto que você avalia é exatamente o que você vai entregar.
Exercícios
- À mão. Uma coluna tem valores de treino 10, NaN, 30, 50 e valores novos NaN, 70. Calcule a mediana, a média e o desvio-padrão que
SimpleImputer(strategy='median')seguido deStandardScaler()aprendem, bem como os dois valores novos transformados. Confira com um pipeline. Depois calcule o que ofit_transformnas duas linhas novas daria. - O bug 1 com outro scaler. Repita o experimento dos lotes por região do bug 1 com
MinMaxScalerno lugar deStandardScaler, no modelo e no bug. O estrago fica maior ou menor? Que estatística de um lote o min–max reaprende? Por que ela é mais frágil que uma média? - Razões como um passo. Cômodos por domicílio, quartos por cômodo e pessoas por domicílio dizem mais que as contagens cruas. Escreva-as como um
FunctionTransformer(use uma função que recebe um DataFrame e devolve outro com as três colunas novas; emfeature_names_out, passe uma função que devolve os nomes delas), coloque-o no ramo numérico e compare o MAE de teste com os US$ 41.552 da aula. - O tamanho de um vazamento. Repita o experimento do target encoding com zonas arredondadas para uma casa decimal (cerca de 10 km) em vez de duas. Quantas zonas têm um único distrito agora? A distância entre o MAE com vazamento e o honesto cresce ou diminui? Por quê?
Lab: das linhas cruas do Titanic a um modelo salvo
O roteiro de pré-processamento terminou com um ColumnTransformer que transforma os 891 passageiros crus do Titanic numa matriz de números, sem treinar modelo nenhum. Este lab termina o trabalho: o mesmo tipo de pré-processamento, agora num pipeline com um modelo no final, ajustado nas linhas de treino, avaliado uma vez nas linhas de teste, aberto para conferir o que aprendeu, usado em passageiros novos crus, salvo em disco e estendido com um transformador seu. O modelo é de novo uma caixa-preta, um classificador k-NN com as configurações padrão (ele prevê "sobreviveu" quando a maioria dos 5 passageiros de treino mais parecidos sobreviveu; veja k-NN).
Rode os blocos em ordem, numa mesma sessão. Toda saída abaixo é o que o código imprime. Para trabalhar direto nele, baixe o notebook do lab e abra no Jupyter ou no Colab (File → Upload notebook).
Passo 1 — carregar e olhar
import numpy as np
import pandas as pd
url = "https://raw.githubusercontent.com/hsandmann/biblio/refs/heads/main/ml/aula04/titanic/train.csv"
df = pd.read_csv(url)
print(df.shape)
print("missing:", df.isna().sum()[lambda s: s > 0].to_dict())
print(df[['Name', 'Sex', 'Age', 'Fare', 'Cabin', 'Embarked']].head(3).to_string())
(891, 12)
missing: {'Age': 177, 'Cabin': 687, 'Embarked': 2}
Name Sex Age Fare Cabin Embarked
0 Braund, Mr. Owen Harris male 22.0 7.2500 NaN S
1 Cumings, Mrs. John Bradley (Florence Briggs Thayer) female 38.0 71.2833 C85 C
2 Heikkinen, Miss. Laina female 26.0 7.9250 NaN S
891 passageiros e 12 colunas: identificadores (PassengerId, Name, Ticket), números (Age, SibSp, Parch, Fare), categorias guardadas como texto ou como número (Sex, Embarked, Pclass), a cabine e o alvo Survived. Três colunas têm buracos: Age em 177 linhas (20%), Cabin em 687 (77%) e Embarked em 2. As três primeiras linhas já mostram tudo o que o pré-processamento precisa tratar: texto, NaN e tarifas numa escala assimétrica (7,25 contra 71,28). Nada é limpo à mão. O pipeline recebe esta tabela como ela é, agora e em produção.
Passo 2 — dividir uma vez
from sklearn.model_selection import train_test_split
X = df.drop(columns='Survived') # as 11 colunas cruas, com texto e buracos
y = df['Survived']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
print(X_train.shape, X_test.shape)
print(f"survival rate: train {y_train.mean():.3f} · test {y_test.mean():.3f}")
712 passageiros para treinar e 179 para testar, os dois com taxa de sobrevivência de cerca de 38% graças ao stratify. O X mantém as 11 colunas cruas, inclusive as que o modelo não vai usar: decidir o que usar é trabalho do pipeline; assim, a tabela entregue a ele na hora de prever pode ser a mesma tabela crua. As linhas de teste não são tocadas até a nota do passo 4.
Passo 3 — o pré-processamento, coluna por coluna
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import FunctionTransformer, OneHotEncoder, StandardScaler
preprocess = ColumnTransformer([
('num', make_pipeline(SimpleImputer(strategy='median'), StandardScaler()),
['Age', 'SibSp', 'Parch']),
('fare', make_pipeline(SimpleImputer(strategy='median'),
FunctionTransformer(np.log1p, feature_names_out='one-to-one'),
StandardScaler()),
['Fare']),
('cat', make_pipeline(SimpleImputer(strategy='most_frequent'),
OneHotEncoder(handle_unknown='ignore', sparse_output=False)),
['Pclass', 'Sex', 'Embarked']),
])
model = Pipeline([('prep', preprocess), ('knn', KNeighborsClassifier())])
used = [c for _, _, cols in preprocess.transformers for c in cols]
for name, _, cols in preprocess.transformers:
print(f"{name:4s} <- {cols}")
print("dropped by remainder='drop':", [c for c in X.columns if c not in used])
num <- ['Age', 'SibSp', 'Parch']
fare <- ['Fare']
cat <- ['Pclass', 'Sex', 'Embarked']
dropped by remainder='drop': ['PassengerId', 'Name', 'Ticket', 'Cabin']
Três ramos, como no roteiro: os números recebem a mediana e o scaler; a tarifa, muito assimétrica, recebe a mediana, depois \(\log(1 + x)\), depois o scaler; as categorias recebem o valor mais frequente e o one-hot (Pclass é tratada como categoria, sem supor que a 2ª classe fica "no meio" entre a 1ª e a 3ª). O laço imprime o roteamento. As quatro colunas que nenhum ramo menciona são descartadas pelo remainder='drop': os três identificadores e Cabin. Elas estão em X e nunca vão chegar ao modelo. Nada foi ajustado ainda: model é uma receita. No Jupyter, uma célula só com model o desenha como diagrama.
Passo 4 — ajustar no treino, avaliar no teste
from sklearn.dummy import DummyClassifier
model.fit(X_train, y_train)
baseline = DummyClassifier(strategy='most_frequent').fit(X_train, y_train)
print(f"test accuracy · pipeline {model.score(X_test, y_test):.3f} · "
f"always 'did not survive' {baseline.score(X_test, y_test):.3f}")
Uma chamada a fit ajusta três imputadores, dois scalers, o passo do logaritmo, o encoder e o classificador, na ordem certa, todos nos 712 passageiros de treino. Nos 179 passageiros de teste o pipeline acerta 78,2% das vezes, contra 61,5% de um "modelo" que sempre responde a classe majoritária. Não havia nada a ajustar (o classificador fica com as configurações padrão), então o conjunto de teste é usado uma vez, aqui.
Passo 5 — o que cada passo aprendeu
prep = model.named_steps['prep']
num_imputer, num_scaler = prep.named_transformers_['num']
print("Age, SibSp, Parch medians:", num_imputer.statistics_)
print("means after imputing: ", num_scaler.mean_.round(3))
print("standard deviations: ", num_scaler.scale_.round(3))
print("rows the scaler saw: ", num_scaler.n_samples_seen_)
print("Age median, train only vs all 891 rows:", X_train['Age'].median(), "vs", X['Age'].median())
fare_imputer, _, fare_scaler = prep.named_transformers_['fare']
print(f"Fare: median {fare_imputer.statistics_[0]:.2f} · mean of log1p(Fare) {fare_scaler.mean_[0]:.3f}")
print("categories:", [c.tolist() for c in prep.named_transformers_['cat'][-1].categories_])
Age, SibSp, Parch medians: [28.5 0. 0. ]
means after imputing: [29.556 0.493 0.39 ]
standard deviations: [13.016 1.06 0.838]
rows the scaler saw: 712.0
Age median, train only vs all 891 rows: 28.5 vs 28.0
Fare: median 14.45 · mean of log1p(Fare) 2.951
categories: [[1, 2, 3], ['female', 'male'], ['C', 'Q', 'S']]
Todo valor aprendido pode ser lido e cada um prova onde foi aprendido:
- A mediana da idade é 28,5, não 28,0. 28,0 é a mediana dos 891 passageiros; 28,5 é a mediana dos 712 passageiros de treino. O imputador nunca viu as linhas de teste. O
n_samples_seen_confirma para o scaler: 712. - O scaler aprendeu com valores imputados. A idade média, 29,556, é a média das idades de treino depois que os buracos foram preenchidos com 28,5, porque o pipeline ajusta cada passo na saída do anterior.
- O ramo da tarifa aprendeu nas suas próprias unidades. O imputador dele aprendeu uma tarifa mediana de £ 14,45 e o scaler aprendeu a média de \(\log(1 + \text{tarifa})\), 2,951, porque o scaler fica depois do logaritmo.
- O encoder aprendeu três listas de categorias. Qualquer outro valor na hora de prever é "desconhecido" (passo 7).
Passo 6 — o que o modelo vê
names = prep.get_feature_names_out()
print(len(names), "columns reach the model:", list(names))
prep.set_output(transform='pandas')
print(prep.transform(X_test.head(3)).round(2).T)
12 columns reach the model: ['num__Age', 'num__SibSp', 'num__Parch', 'fare__Fare', 'cat__Pclass_1', 'cat__Pclass_2', 'cat__Pclass_3', 'cat__Sex_female', 'cat__Sex_male', 'cat__Embarked_C', 'cat__Embarked_Q', 'cat__Embarked_S']
565 160 553
num__Age -0.43 1.11 -0.58
num__SibSp 1.42 -0.47 -0.47
num__Parch -0.47 0.73 -0.47
fare__Fare 0.28 -0.11 -0.86
cat__Pclass_1 0.00 0.00 0.00
cat__Pclass_2 0.00 0.00 0.00
cat__Pclass_3 1.00 1.00 1.00
cat__Sex_female 0.00 0.00 0.00
cat__Sex_male 1.00 1.00 1.00
cat__Embarked_C 0.00 0.00 1.00
cat__Embarked_Q 0.00 0.00 0.00
cat__Embarked_S 1.00 1.00 0.00
Chegam 12 colunas ao modelo: 3 números, a tarifa e 3 + 2 + 3 colunas de one-hot. Os três passageiros de teste mostrados são homens da terceira classe. Uma conferência à mão com os números do passo 5: o primeiro passageiro tem dois irmãos ou cônjuges a bordo, \((2 - 0{,}493)/1{,}060 = 1{,}42\); o segundo viaja com um pai, mãe ou filho, \((1 - 0{,}390)/0{,}838 = 0{,}73\); um passageiro sem pais nem filhos recebe \((0 - 0{,}390)/0{,}838 = -0{,}47\). Com o set_output, todo valor transformado tem nome e toda linha mantém seu índice (565, 160, 553), então qualquer número suspeito pode ser rastreado até a linha crua.
Passo 7 — passageiros novos, crus
new = pd.DataFrame({
'PassengerId': [901, 902, 903], 'Pclass': [3, 1, 2],
'Name': ['Doe, Mr. John', 'Roe, Mrs. Jane', 'Poe, Miss. Ann'],
'Sex': ['male', 'female', 'female'], 'Age': [25, np.nan, 30],
'SibSp': [0, 1, 0], 'Parch': [0, 0, 0], 'Ticket': ['A1', 'B2', 'C3'],
'Fare': [8.05, 80.0, 15.0], 'Cabin': [np.nan, 'C85', np.nan],
'Embarked': ['S', 'C', 'B'], # 'B' (Belfast) nunca aparece nos dados de treino
})
print(prep.transform(new)[['num__Age', 'cat__Embarked_C', 'cat__Embarked_Q', 'cat__Embarked_S']].round(2))
print("P(survived):", model.predict_proba(new)[:, 1].round(2), "· prediction:", model.predict(new))
num__Age cat__Embarked_C cat__Embarked_Q cat__Embarked_S
0 -0.35 0.0 0.0 1.0
1 -0.08 1.0 0.0 0.0
2 0.03 0.0 0.0 0.0
P(survived): [0. 1. 1.] · prediction: [0 1 1]
Três passageiros inventados, no formato cru da tabela original: 11 colunas, com texto, uma idade faltante e um porto que não existe nos dados. O pipeline não se importa com as colunas que descarta (Name, Ticket, Cabin), preenche a idade faltante do segundo passageiro com a mediana do treino (\((28{,}5 - 29{,}556)/13{,}016 = -0{,}08\)) e dá ao terceiro, que embarcou em "B", zeros nas três colunas de Embarked em vez de um erro. As probabilidades são a fração de sobreviventes entre os 5 passageiros de treino mais próximos, então andam de 0,2 em 0,2; aqui os vizinhos são unânimes: o homem da terceira classe não sobreviveu, as duas mulheres sobreviveram.
Passo 8 — salvar, carregar, comparar
import os
import joblib
joblib.dump(model, 'titanic-knn.joblib')
loaded = joblib.load('titanic-knn.joblib')
print(f"{os.path.getsize('titanic-knn.joblib') / 1e3:.0f} kB")
print("same test predictions:", np.array_equal(loaded.predict(X_test), model.predict(X_test)))
print("same probabilities for the new rows:",
np.array_equal(loaded.predict_proba(new), model.predict_proba(new)))
O arquivo guarda a cadeia inteira (imputadores, scalers, encoder e classificador, com tudo o que aprenderam) em 162 kB, a maior parte deles os 712 passageiros de treino que o k-NN guarda. Carregado de volta, ele dá as mesmas previsões e as mesmas probabilidades, até o último dígito, a partir das mesmas linhas cruas. Esse arquivo é o que um serviço carregaria para responder pedidos. Carregá-lo exige uma versão compatível do scikit-learn; além disso, carregar um arquivo de alguém em quem você não confia é um risco de segurança: as duas coisas são discutidas em MLOps.
Passo 9 — um transformador seu: o título
from sklearn.base import BaseEstimator, TransformerMixin
class TitleExtractor(TransformerMixin, BaseEstimator):
"""Nome -> título ('Mr', 'Mrs', ...); títulos com menos de min_count no TREINO viram 'Rare'."""
def __init__(self, min_count=10):
self.min_count = min_count
def _titles(self, X):
names = pd.Series(np.asarray(X).ravel())
return names.str.extract(r',\s*([^.]+)\.')[0].str.strip()
def fit(self, X, y=None):
counts = self._titles(X).value_counts()
self.titles_ = sorted(counts[counts >= self.min_count].index) # aprendido no treino
return self
def transform(self, X):
t = self._titles(X)
return t.where(t.isin(self.titles_), 'Rare').to_frame('title')
def get_feature_names_out(self, input_features=None):
return np.array(['title'])
prep2 = ColumnTransformer(preprocess.transformers + [
('title', make_pipeline(TitleExtractor(), OneHotEncoder(handle_unknown='ignore', sparse_output=False)),
['Name'])])
model2 = Pipeline([('prep', prep2), ('knn', KNeighborsClassifier())]).fit(X_train, y_train)
print("titles learned on train:", model2['prep'].named_transformers_['title'][0].titles_)
print("titles of the new rows: ", TitleExtractor().fit(X_train[['Name']]).transform(new[['Name']])['title'].to_list())
print(f"test accuracy · without title {model.score(X_test, y_test):.3f} · with title {model2.score(X_test, y_test):.3f}")
titles learned on train: ['Master', 'Miss', 'Mr', 'Mrs']
titles of the new rows: ['Mr', 'Mrs', 'Miss']
test accuracy · without title 0.782 · with title 0.816
O TitleExtractor tem algo a aprender, então é uma classe e não um FunctionTransformer: no fit ele conta os títulos dos passageiros de treino e guarda os que têm pelo menos 10 (Master, Miss, Mr, Mrs); no transform, qualquer outro título (Dr, Rev, Col, ...) vira 'Rare'. Um título frequente só no conjunto de teste também viraria 'Rare', que é o comportamento honesto. O get_feature_names_out permite que o encoder seguinte dê nome às suas colunas. O ramo novo é somado aos três existentes e a acurácia vai de 78,2% para 81,6%: "Master" marca meninos e "Miss", com frequência, moças solteiras; por isso, o título carrega informação de idade mesmo para passageiros cuja idade falta. Uma ressalva: 3,4 pontos em 179 passageiros são 6 passageiros. Decidir manter o título por causa desse número seria uma decisão tomada no conjunto de teste; o jeito honesto é um split de validação (exercício 2 abaixo) ou a validação cruzada que você vai conhecer mais adiante.
Sua vez
- O deck. A primeira letra de
Cabiné o deck (de A a G, além de T) e uma cabine faltante também é informação (quase sempre terceira classe). Acrescente um ramo que transformaCabinno deck, com'U'para faltante, seguido de one-hot. Escreva a conversão primeiro como umalambdadentro de umFunctionTransformer, ajuste e tentejoblib.dumpno modelo. O que acontece? Por que uma função definida comdefresolve? - Escolha o
kde forma honesta. Separe um split de validação de dentro deX_train(25% dele, estratificado). Parakem 1, 5, 15, 45, usemodel.set_params(knn__n_neighbors=k), ajuste no resto das linhas de treino e avalie nas linhas de validação. Escolha o melhork, reajuste em todo oX_traine só então olhe a acurácia de teste, uma vez. (A aula de validação cruzada vai tornar essa escolha mais robusta.) - Quebre de propósito. Faça previsões em
X_testcom as colunas em ordem inversa; emX_test.to_numpy(); e emX_test.drop(columns='Cabin'). Quais das três funcionam e quais falham? O que no pipeline explica cada resultado? - Corte a tarifa. Troque o
log1pdo ramo'fare'peloClipperda aula (percentis 1 e 99). Imprima oupper_dele e compare com a maior tarifa das linhas de treino. A acurácia de teste muda? Você esperaria que o k-NN fosse mais sensível aos valores extremos da tarifa do que um modelo que só compara um valor com um limiar?
Referências
- Buitinck, L.; Louppe, G.; Blondel, M.; Pedregosa, F.; Mueller, A.; Grisel, O.; Niculae, V.; Prettenhofer, P.; Gramfort, A.; Grobler, J.; Layton, R.; VanderPlas, J.; Joly, A.; Holt, B.; Varoquaux, G. "API design for machine learning software: experiences from the scikit-learn project." ECML PKDD Workshop: Languages for Data Mining and Machine Learning (2013): 108–122 — as interfaces de estimador, transformador e pipeline usadas nesta aula e a razão de serem como são. arXiv
- Pedregosa, F.; Varoquaux, G.; Gramfort, A.; Michel, V.; Thirion, B.; Grisel, O.; Blondel, M.; Prettenhofer, P.; Weiss, R.; Dubourg, V.; Vanderplas, J.; Passos, A.; Cournapeau, D.; Brucher, M.; Perrot, M.; Duchesnay, É. "Scikit-learn: Machine Learning in Python." Journal of Machine Learning Research 12 (2011): 2825–2830. texto completo
- Micci-Barreca, D. "A Preprocessing Scheme for High-Cardinality Categorical Attributes in Classification and Prediction Problems." ACM SIGKDD Explorations 3, n. 1 (2001): 27–32 — target encoding com suavização, o método por trás do
TargetEncoder. DOI - Kaufman, S.; Rosset, S.; Perlich, C.; Stitelman, O. "Leakage in Data Mining: Formulation, Detection, and Avoidance." ACM TKDD 6, n. 4 (2012) — uma taxonomia do vazamento com casos reais de competições. DOI
- Sculley, D.; Holt, G.; Golovin, D.; Davydov, E.; Phillips, T.; Ebner, D.; Chaudhary, V.; Young, M.; Crespo, J.-F.; Dennison, D. "Hidden Technical Debt in Machine Learning Systems." Advances in Neural Information Processing Systems 28 (NeurIPS 2015): 2503–2511 — as "selvas de pipelines" e o custo de um código de treino e um código de serving que se afastam. texto completo