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.

Esta aula tem duas abordagens. A fundamental mede o que dá errado quando a regra é mantida à mão e constrói, passo a passo, o objeto que a mantém: um Pipeline, depois um ColumnTransformer para uma tabela real, depois o vazamento que esse arranjo todo evita. A avançada abre esse objeto, salva, estende com passos seus e alcança cada configuração pelo nome, que é do que o ajuste automático precisa.

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.

Como esta aula está organizada

A aula tem duas abordagens. Cada uma tem slides, texto e um lab para fazer em aula; o quiz do fim cobre a fundamental. Comece pela fundamental: a avançada parte dela.

Abordagem fundamental Abordagem avançada
pergunta O que dá errado quando a regra treino/teste é mantida à mão e como um único objeto pode mantê-la por nós? Como abrir, salvar, estender e ajustar esse objeto?
conteúdo o fluxo manual e seus três bugs, medidos; o Pipeline e o que fit e predict fazem em cada passo; ColumnTransformer, remainder, seletores, set_output, categorias desconhecidas; o tamanho de um vazamento, scaler contra target encoder ler o que cada passo aprendeu; um arquivo para a cadeia inteira; FunctionTransformer e um transformador seu; os nomes passo__parametro e o set_params
lab em aula Titanic: das linhas cruas a uma nota honesta, passos 1 a 5 abrir, usar, salvar e estender o pipeline, passos 6 a 9
ao final, você sabe reconhecer os três bugs de um fluxo manual e por que um deles parece certo; dizer o que fit e predict fazem com cada passo; montar um pipeline com ColumnTransformer para uma tabela crua; perceber quando uma nota vazou e por que o tamanho do vazamento depende do passo ler qualquer valor aprendido de um pipeline ajustado; salvar e carregar a cadeia inteira; escrever um passo que aprende, seguindo as convenções do scikit-learn; mudar qualquer configuração pelo nome, como faz uma busca

Os dois labs usam o mesmo notebook do lab: baixe-o e abra no Jupyter ou no Colab (File → Upload notebook). A parte fundamental vai do passo 1 ao 5; a avançada, do 6 ao 9.

Abordagem fundamental

Uma pergunta guia esta parte: o que dá errado quando a regra treino/teste é mantida à mão e como um único objeto pode mantê-la por nós? A resposta é medida na tabela California housing: três bugs clássicos de um fluxo manual, o Pipeline que os elimina por construção, o ColumnTransformer que trata uma tabela real, com texto e buracos, e o tamanho do vazamento que esse arranjo todo evita.

Esta parte segue um caminho em quatro etapas, desenhado 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; 4. uma nota honesta, o vazamento que um pipeline evita e por que o tamanho dele depende do que o passo aprende. Cada etapa leva à sua seção e, 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: um ramo por grupo de colunas
  4. Uma nota honestao vazamento que ele evita, medido

Slides da abordagem fundamental

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

1. 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.)

O simulador percorre a mesma conta um passo por vez: a mediana, os buracos preenchidos, a média, o desvio-padrão e o que chega ao modelo. Os três modos mudam só as linhas em que o fit aprende: as de treino (o jeito certo), de novo as linhas novas (este bug) ou as sete linhas antes da divisão, o vazamento da seção 4.

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.

2. 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, na abordagem avançada). 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.

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

4. O vazamento que um pipeline evita

Etapa 4. 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.

A coluna de brinquedo do bug 1 mostra o mecanismo em miniatura. Ajuste os mesmos dois passos nas sete linhas, as cinco de treino e as duas novas, como faria um fluxo com vazamento antes da divisão:

toy_all = pd.concat([toy_train, toy_new])                  # as 5 linhas de treino + as 2 novas
leaky_toy = make_pipeline(SimpleImputer(strategy='median'), StandardScaler()).fit(toy_all)
print("learned on all 7 rows: median", leaky_toy[0].statistics_,
      "mean", leaky_toy[1].mean_.round(2), "std", leaky_toy[1].scale_.round(2))
print("new rows:", leaky_toy.transform(toy_new).ravel().round(2), "instead of [0. 2.]")
learned on all 7 rows: median [600.] mean [585.71] std [216.65]
new rows: [0.07 1.45] instead of [0. 2.]

A mediana vai de 500 para 600, a média de 500 para 585,71 e o desvio-padrão de 200 para 216,65, porque o 900, acima de todo valor de treino, agora faz parte da régua que o mede. O distrito de 900 quartos sai 1,45 desvio-padrão acima da média em vez de 2: parece menos incomum do que é. O terceiro modo do simulador passo a passo percorre esses números. Aqui as duas linhas novas são 2 de 7 e uma delas é extrema, então o vazamento aparece; 4.128 distritos sorteados entre 20.640 se parecem com os outros e o vazamento do scaler encolhe para os US$ 4 medidos acima.

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.

5. Resumo: a regra, mantida por um objeto

  1. Todo passo que aprende (a mediana de um imputador, a média e o desvio-padrão de um scaler, as categorias de um encoder) é ajustado só nas linhas de treino e reaplicado, sem mudança, a toda outra linha.
  2. À mão, a regra quebra de três jeitos clássicos: reajustar nas linhas que se está prevendo (invisível num conjunto de teste grande, US$ 116.049 um distrito por vez), um passo esquecido na hora de prever (silencioso, US$ 105.978) e outra ordem de colunas (US$ 101.517 quando enviadas como array).
  3. Um Pipeline ajusta cada passo na saída do anterior com fit_transform e, na hora de prever, só chama transform. As previsões são as mesmas do código manual cuidadoso e os três bugs somem por construção.
  4. Um ColumnTransformer dá a cada grupo de colunas o seu próprio ramo, escolhe as colunas pelo nome e descarta o que nenhum ramo menciona; o handle_unknown='ignore' transforma uma categoria nova em zeros em vez de um erro.
  5. O tamanho de um vazamento depende do que o passo aprende: US$ 4 para um scaler, US$ 28.300 para um target encoder, nos mesmos dados. Com todo passo ajustado dentro do pipeline, não há nada a medir.
sintoma o que está acontecendo o que fazer
bom no notebook, ruim em produção um passo se reajusta em cada lote que prevê (bug 1) ajustar uma vez, dentro de um pipeline; o predict só transforma
previsão confiante, nenhum erro, nota ruim falta um passo no código de previsão (bug 2) entregar o pipeline inteiro, não só o modelo
X does not have valid feature names um array em outra ordem de colunas (bug 3) manter um DataFrame até o modelo
nota muito melhor que o esperado um passo viu as linhas de teste, pior ainda se ele aprende com o alvo pôr todo passo ajustado dentro do pipeline
Found unknown categories em produção um encoder encontra uma categoria que nunca viu OneHotEncoder(handle_unknown='ignore')

Lab fundamental — das linhas cruas do Titanic a uma nota honesta

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 e aberto para conferir de onde veio cada valor aprendido. Abrir, salvar e estender o pipeline fica para o lab avançado. 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).

Como fazer este lab em aula (cerca de 30 min, em duplas)

  1. Baixe o notebook do lab e abra no Jupyter ou no Colab (File → Upload notebook). Rode as células em ordem, numa mesma sessão.
  2. Este lab corresponde aos passos 1 a 5 do notebook. O lab avançado continua no mesmo notebook, a partir do passo 6.
  3. Cada passo começa com um Preveja: responda com a dupla antes de rodar a célula. Errar a previsão é o que torna o resultado memorável.
  4. Depois de rodar, compare com a saída e a leitura desta página. Toda saída aqui é exatamente o que o código imprime.
  5. As Simulações pedem que vocês escrevam um pequeno experimento em Python: mudar uma entrada e observar o que acontece com a saída. Nada de conta à mão. A solução fica recolhida logo abaixo: tente antes de abrir.
  6. Ritmo sugerido: passos 1 a 3 em 10 min; passo 4 e a primeira simulação em 10 min; passo 5 e a segunda simulação em 10 min.

Passo 1 — carregar e olhar

Preveja. Que colunas de uma lista crua de passageiros você espera que tenham buracos? Quais delas um modelo não conseguiria usar como estão?

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

Preveja. Com stratify=y, quão próximas vão ficar as taxas de sobrevivência das linhas de treino e de teste? Por que manter em X colunas que o modelo nunca vai usar, como Name e Ticket?

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

Preveja. Quais das 11 colunas cruas vão chegar ao modelo e o que acontece com as que nenhum ramo menciona? Alguma coisa foi ajustada quando esta célula termina?

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

Preveja. Quantas chamadas fit separadas o único model.fit dispara? Um k-NN com as configurações padrão vai superar o baseline "sempre não sobreviveu" por muito ou por pouco?

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.

Simulação: a mesma receita sem os scalers. Monte um segundo ColumnTransformer em que os ramos 'num' e 'fare' ficam só com o imputador (sem logaritmo nem scaler), ponha-o num pipeline com o mesmo KNeighborsClassifier(), ajuste nas linhas de treino e compare a acurácia de teste com o 0,782 do pipeline. Que coluna domina as distâncias quando nada é padronizado?

Solução
bare = ColumnTransformer([
    ('num',  SimpleImputer(strategy='median'), ['Age', 'SibSp', 'Parch']),     # sem scaler
    ('fare', SimpleImputer(strategy='median'), ['Fare']),                       # sem log nem scaler
    ('cat',  make_pipeline(SimpleImputer(strategy='most_frequent'),
                           OneHotEncoder(handle_unknown='ignore', sparse_output=False)),
             ['Pclass', 'Sex', 'Embarked']),
])
unscaled = Pipeline([('prep', bare), ('knn', KNeighborsClassifier())]).fit(X_train, y_train)
print(f"test accuracy · with scalers {model.score(X_test, y_test):.3f} · without {unscaled.score(X_test, y_test):.3f}")
test accuracy · with scalers 0.782 · without 0.676

Sem os scalers a acurácia cai de 0,782 para 0,676, pouco acima dos 0,615 do baseline. As tarifas vão de 0 a mais de £ 500 e as idades até 80, enquanto as colunas do one-hot valem 0 ou 1; assim, as distâncias do k-NN ficam dominadas pela tarifa e pela idade: uma diferença de 1 na coluna do sexo pesa tanto quanto uma diferença de £ 1 na tarifa. Mudar a receita exigiu uma edição num só lugar e as duas versões foram ajustadas nas mesmas linhas de treino. É um experimento para entender por que os scalers estão ali, não um jeito de escolher modelo: o conjunto de teste não serve para escolher.

Passo 5 — o que cada passo aprendeu

Preveja. A mediana da idade dos 891 passageiros é 28,0. O imputador dentro do pipeline aprendeu 28,0 ou outro número? Quantas linhas o scaler viu?

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

Simulação: o pré-processamento ajustado nos 891 passageiros. Reproduza no Titanic o vazamento da seção 4: ajuste uma cópia de preprocess em todo o X (use o clone de sklearn.base), ajuste um KNeighborsClassifier() só nas linhas de treino transformadas e avalie nas linhas de teste transformadas. Que mediana de idade a cópia aprende? A acurácia de teste muda? Depois ajuste uma cópia do model inteiro nas 891 linhas e avalie nas linhas de teste.

Solução
from sklearn.base import clone

prep_all = clone(preprocess).fit(X)                     # VAZAMENTO: o pré-processamento vê as 891 linhas
knn_all = KNeighborsClassifier().fit(prep_all.transform(X_train), y_train)
print("Age median learned on all rows:", prep_all.named_transformers_['num'][0].statistics_[0])
print(f"test accuracy · honest {model.score(X_test, y_test):.3f} · "
      f"preprocessing fit on all rows {knn_all.score(prep_all.transform(X_test), y_test):.3f}")
whole = clone(model).fit(X, y)                          # muito pior: o classificador vê as linhas de teste
print(f"whole pipeline fit on all rows: {whole.score(X_test, y_test):.3f}")
Age median learned on all rows: 28.0
test accuracy · honest 0.782 · preprocessing fit on all rows 0.782
whole pipeline fit on all rows: 0.860

A cópia com vazamento aprende a mediana dos 891 passageiros, 28,0, e a acurácia de teste não se mexe: 0,782 nos dois casos. É de novo o scaler da seção 4, um vazamento pequeno demais para aparecer numa divisão aleatória, e é exatamente por isso que ninguém o nota. Ajustar o pipeline inteiro em todas as linhas é outra história: 0,860, porque agora o k-NN guarda os 179 passageiros de teste e encontra cada um entre os seus próprios 5 vizinhos. Nenhum dos dois números é uma estimativa honesta. Só o primeiro vazamento é o que um pipeline evita em silêncio; o segundo é a razão de as linhas de teste ficarem trancadas.

Para fechar a parte fundamental. Em uma frase cada: (1) por que o bug que reajusta um scaler no conjunto de teste é o mais perigoso dos três? (2) O que o model.predict faz com cada transformador e por que isso torna o tamanho do lote irrelevante? (3) Por que ajustar o scaler em todas as linhas quase não mudou a nota, enquanto o target encoder a fez parecer cerca de três vezes melhor?

Sua vez (fundamental)

Para casa:

  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. O tamanho de um vazamento. Repita o experimento do target encoding da seção 4 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ê?
  4. 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?

Abordagem avançada

A parte fundamental construiu um objeto honesto e mediu o que ele evita. Esta parte o abre: lê o que cada passo aprendeu e salva a cadeia inteira como um arquivo, acrescenta passos seus, sem nada a aprender ou com um estado aprendido nas linhas de treino, e alcança pelo nome cada configuração de cada passo, que é do que uma busca precisa. Os blocos de código continuam os das seções 1 a 4.

Esta parte segue um caminho em três etapas, desenhado abaixo: 1. abrir e salvar o pipeline ajustado; 2. passos seus, sem estado e com estado; 3. ajustar através dele, com os nomes passo__parametro. Cada etapa leva à sua seção e, nos slides, uma faixa embaixo mostra em que etapa você está.

  1. Abrir e salvarpassos pelo nome, valores aprendidos, um arquivo
  2. Passos seusFunctionTransformer e uma classe que aprende
  3. Ajustar através delepasso__parametro e set_params

Slides da abordagem avançada

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

6. Inspeção e persistência

Etapa 1. 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.

7. Seus próprios passos

Etapa 2. A parte fundamental usou só os passos que vêm com o scikit-learn. 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.

8. Olhando adiante: ajuste através do pipeline

Etapa 3. 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.

9. Resumo: abrir, estender, ajustar

  1. Um pipeline ajustado é um contêiner: named_steps, a indexação de lista e o named_transformers_ alcançam qualquer passo, todo valor aprendido pode ser lido e o full[:-1] é exatamente o que o modelo recebe.
  2. Um arquivo guarda a cadeia inteira: o joblib.dump salva cada passo com o que ele aprendeu e o pipeline carregado recebe linhas cruas e devolve as mesmas previsões.
  3. Um passo sem nada a aprender é um FunctionTransformer. Um passo que aprende é uma classe cujo __init__ só guarda hiperparâmetros, cujo fit aprende atributos terminados em _ e devolve self e cujo transform os aplica.
  4. Toda configuração é acessível como passo__parametro e uma busca que reajusta o pipeline inteiro reaprende o pré-processamento nas suas próprias linhas de treino para cada candidato.

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.

Lab avançado — abrir, usar, salvar e estender o pipeline

O lab fundamental terminou com um pipeline que acerta 0,782 dos passageiros de teste e cujos valores aprendidos vêm todos dos 712 passageiros de treino. Esta parte olha o que esse pipeline entrega ao modelo, usa-o em passageiros novos crus, salva-o em disco e o estende com um transformador seu. Continue no mesmo notebook, na mesma sessão: os passos abaixo usam preprocess, model, prep, X_train e X_test dos passos 1 a 5.

Como fazer este lab em aula (cerca de 30 min, em duplas)

  1. Continue no notebook do lab, a partir do passo 6. Se a sessão caiu, rode antes as células dos passos 1 a 5.
  2. Como no lab fundamental: Preveja antes de rodar, compare com esta página e tente as Simulações antes de abrir a solução.
  3. Ritmo sugerido: passos 6 e 7 e a primeira simulação em 12 min; passo 8 em 5 min; passo 9 e a segunda simulação em 13 min.

Passo 6 — o que o modelo vê

Preveja. Quantas colunas chegam ao modelo: as 7 colunas que os ramos usam ou mais? Um homem da terceira classe terá um 1 numa coluna de Pclass ou em várias?

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

Preveja. Um passageiro novo embarcou num porto que os dados de treino nunca viram e outro não tem idade. O pipeline vai quebrar ou responder? Que número vai substituir a idade faltante?

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.

Simulação: um encoder que recusa categorias novas. Faça uma cópia de model (com clone) cujo encoder one-hot do ramo 'cat' tenha handle_unknown='error'; o caminho é prep__cat__onehotencoder__handle_unknown, mudado com set_params. Ajuste nas linhas de treino e preveja os dois primeiros passageiros novos, depois os três. O que acontece? O que isso significaria para um serviço que responde um pedido por vez?

Solução
from sklearn.base import clone

strict = clone(model).set_params(prep__cat__onehotencoder__handle_unknown='error')
strict.fit(X_train, y_train)
print("first two new passengers:", strict.predict(new.head(2)))
try:
    strict.predict(new)
except ValueError as err:
    print("all three:", str(err).splitlines()[0])
first two new passengers: [0 1]
all three: Found unknown categories ['B'] in column 2 during transform

Os dois primeiros passageiros recebem as mesmas previsões de antes. Acrescente o terceiro, que embarcou em "B", e a chamada inteira falha: um valor desconhecido numa linha derruba a previsão de todas as linhas enviadas com ela. Um serviço que responde pedidos devolveria um erro para cada passageiro de Belfast; um processamento em lote perderia o lote inteiro. Com handle_unknown='ignore' a mesma linha recebe zeros nas três colunas de Embarked e uma resposta. Qual comportamento é o certo depende do problema (às vezes o erro é a resposta honesta), mas deve ser uma decisão, não uma surpresa em produção.

Passo 8 — salvar, carregar, comparar

Preveja. O arquivo salvo terá alguns kilobytes ou centenas? O modelo carregado vai dar exatamente as mesmas probabilidades ou só aproximadamente?

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

Preveja. A lista de títulos "frequentes" deve ser aprendida nas linhas de treino ou pode ser escrita à mão a partir dos 891 passageiros? O título vai melhorar a acurácia? 179 passageiros de teste bastam para ter certeza?

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 (item 3 de Sua vez (avançado)) ou a validação cruzada que você vai conhecer mais adiante.

Simulação: quão raro é "Rare"? O min_count é um hiperparâmetro do TitleExtractor; por isso o pipeline o alcança como prep__title__titleextractor__min_count. Para min_count em 1, 5, 10 e 40, ajuste uma cópia de model2 (com clone e set_params) e imprima os títulos que ela aprendeu e a acurácia de teste. Que títulos sobrevivem a cada limiar? A acurácia diz qual limiar é o melhor?

Solução
for k in [1, 5, 10, 40]:
    m = clone(model2).set_params(prep__title__titleextractor__min_count=k).fit(X_train, y_train)
    titles = m['prep'].named_transformers_['title'][0].titles_
    print(f"min_count {k:2d}: {len(titles):2d} titles · test accuracy {m.score(X_test, y_test):.3f} · {titles}")
min_count  1: 14 titles · test accuracy 0.810 · ['Col', 'Don', 'Dr', 'Jonkheer', 'Lady', 'Major', 'Master', 'Miss', 'Mlle', 'Mr', 'Mrs', 'Ms', 'Rev', 'Sir']
min_count  5:  6 titles · test accuracy 0.816 · ['Dr', 'Master', 'Miss', 'Mr', 'Mrs', 'Rev']
min_count 10:  4 titles · test accuracy 0.816 · ['Master', 'Miss', 'Mr', 'Mrs']
min_count 40:  3 titles · test accuracy 0.816 · ['Miss', 'Mr', 'Mrs']

Com min_count=1, todo título visto no treino ganha a sua coluna, 14 ao todo, inclusive títulos de um único passageiro (Don, Jonkheer, Lady, Sir); com 40 só sobram Miss, Mr e Mrs e até Master vira "Rare". A acurácia quase não se mexe: 0,810 com os 14 títulos e 0,816 nos outros três limiares, um passageiro em 179 de diferença. Duas lições. A configuração é estado aprendido como qualquer outro, reajustado nas linhas de treino de cada cópia, e o caminho pelo pipeline a alcança como alcança knn__n_neighbors. E o conjunto de teste não consegue ordenar essas escolhas: uma diferença de um passageiro é ruído. Escolher o min_count é trabalho para um split de validação ou para a aula de validação cruzada.

Para fechar a aula. Em uma frase cada: (1) por que salvar o pipeline inteiro e não só o classificador treinado? (2) Quando um passo seu precisa ser uma classe em vez de um FunctionTransformer? (3) Por que uma busca que testa configurações pelo set_params nunca deixa as linhas de avaliação entrarem no pré-processamento?

Sua vez (avançado)

Para casa, com mais tempo:

  1. 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 da seção 3 e compare o MAE de teste com os US$ 41.552 da aula.
  2. 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?
  3. 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.)
  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

O quiz cobre a abordagem fundamental.