Pular para conteúdo

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á.

  1. O fluxo manualsete chamadas, três bugs, medidos
  2. Um Pipelinefit e predict, passo a passo
  3. Colunas reaisColumnTransformer e passos seus
  4. 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])
scaler forgotten: $105,978
imputer forgotten: Input X contains NaN.

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):

MAE de teste do mesmo k-NN treinado com seis versões do código de previsão: fluxo cuidadoso US$ 43,0 mil; fit_transform no teste inteiro US$ 42,8 mil (invisível); em cada lote de região US$ 65,8 mil; um distrito de cada vez US$ 116,0 mil; scaler esquecido US$ 106,0 mil; duas colunas trocadas US$ 101,5 mil; o baseline que sempre prevê a média fica em US$ 92,9 mil

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.predict nunca chama fit num 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):

A coluna de brinquedo passando por um pipeline de dois passos: o fit aprende mediana 500, média 500 e desvio 200 em 5 linhas de treino; o predict os reutiliza e manda 0 e 2 ao modelo; o bug do fit_transform reaprende mediana 900 e média 900 nas 2 linhas novas e manda 0 e 0

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 pares fit + 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 chama fit. 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):

Oito distritos de teste crus entram; saem 13 colunas: oito números padronizados, entre cerca de −1,5 e 2,4, mais um único 1 entre as cinco colunas do one-hot de cada linha; o distrito com total_bedrooms faltante recebe a mediana de treino 437, padronizada para −0,2

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:

preprocess.set_output(transform='pandas')
print(preprocess.transform(X_test.head(3)).round(2).T)
                                 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())
reversed columns, same predictions: True
[[0. 0. 0. 0. 0.]] [90030.]

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())
skewness before: [3.85, 3.32, 5.17, 3.26]
skewness after:  [-1.09, -1.0, -1.04, -1.06]

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. O BaseEstimator os lê dali para oferecer get_params, set_params e a representação impressa.
  • O fit aprende e devolve self. Tudo o que é aprendido com os dados ganha um nome terminado em sublinhado (lower_, upper_), a mesma convenção de mean_ e statistics_. O validate_data converte a entrada num array e registra o número e os nomes das colunas.
  • O transform aplica o que foi aprendido, depois de conferir que o objeto foi ajustado e que as colunas são as mesmas.
  • O TransformerMixin oferece o fit_transform (e o set_output); o OneToOneFeatureMixin oferece o get_feature_names_out para 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}")
true value $65,600 · correct row $90,030
with the typo: no clipper $251,020 · clipper $145,690

Variar o multiplicador de 1 a 1.000 mostra de onde vem a diferença (script da figura):

Valor previsto de um distrito quando seu total_rooms é multiplicado de 1 a 1000: sem o Clipper a previsão sobe de forma errática até US$ 251 mil; com ele, para em US$ 146 mil assim que total_rooms passa do limite superior aprendido, 11.248

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)))
3.9 MB on disk
identical predictions after the round trip: True

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):

MAE de teste em 20 splits: para o scaler e o imputador, ajustar em todas as linhas ou dentro do pipeline dá os mesmos US$ 42,3 mil; para o target encoder, ajustar em todas as linhas reporta US$ 14,5 mil contra US$ 42,8 mil honestos

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

  1. À 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 de StandardScaler() aprendem, bem como os dois valores novos transformados. Confira com um pipeline. Depois calcule o que o fit_transform nas duas linhas novas daria.
  2. O bug 1 com outro scaler. Repita o experimento dos lotes por região do bug 1 com MinMaxScaler no lugar de StandardScaler, 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?
  3. 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; em feature_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.
  4. 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, 11) (179, 11)
survival rate: train 0.383 · test 0.385

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}")
test accuracy · pipeline 0.782 · always 'did not survive' 0.615

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)))
162 kB
same test predictions: True
same probabilities for the new rows: True

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

  1. 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 transforma Cabin no deck, com 'U' para faltante, seguido de one-hot. Escreva a conversão primeiro como uma lambda dentro de um FunctionTransformer, ajuste e tente joblib.dump no modelo. O que acontece? Por que uma função definida com def resolve?
  2. Escolha o k de forma honesta. Separe um split de validação de dentro de X_train (25% dele, estratificado). Para k em 1, 5, 15, 45, use model.set_params(knn__n_neighbors=k), ajuste no resto das linhas de treino e avalie nas linhas de validação. Escolha o melhor k, reajuste em todo o X_train e só então olhe a acurácia de teste, uma vez. (A aula de validação cruzada vai tornar essa escolha mais robusta.)
  3. Quebre de propósito. Faça previsões em X_test com as colunas em ordem inversa; em X_test.to_numpy(); e em X_test.drop(columns='Cabin'). Quais das três funcionam e quais falham? O que no pipeline explica cada resultado?
  4. Corte a tarifa. Troque o log1p do ramo 'fare' pelo Clipper da aula (percentis 1 e 99). Imprima o upper_ 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

Quiz