Machine Learning · Insper

Pipelines

Um único objeto que torna impossível quebrar a regra treino/teste sem querer

Três bugs clássicos medidos e os objetos do scikit-learn que os eliminam

A regra da aula de pré-processamento

  • 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.
  • Ele é ajustado apenas nas linhas de treino.
  • Depois é reaplicado, sem mudança, a toda outra linha: o conjunto de teste, um split de validação, os dados do mês que vem em produção.
Escrita à mão, a regra vira uma pilha de chamadas fit e transform que precisa ficar na ordem certa, nos dados certos, para sempre.

O caminho desta aula

  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
Cada etapa responde a uma pergunta: o que dá errado quando a regra é mantida à mão? → como um único objeto mantém a regra por nós? → como entram colunas de tipos diferentes e passos seus? → o que ele aprendeu e a nota é honesta?
Etapa 1 · O que dá errado quando a regra é mantida à mão?

Os dados e o modelo

California housing

  • 20.640 distritos
  • 8 colunas numéricas (total_bedrooms tem buracos)
  • 1 coluna de texto, ocean_proximity
  • alvo: o valor mediano das casas

uma caixa-preta: k-NN (k vizinhos mais próximos)

Prevê um distrito como a média dos 10 distritos de treino mais parecidos.

Duas propriedades importam aqui: ele depende da escala das colunas e é rápido.

Pré-processamento: preencher os buracos com a mediana e depois padronizar. A aula de k-NN estuda o modelo em si.

O fluxo manual cuidadoso


imputer = SimpleImputer(strategy='median')
scaler = StandardScaler()
model = KNeighborsRegressor(n_neighbors=10)

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)
  

fluxo cuidadoso

US$ 43.023
erro absoluto médio (MAE, mean absolute error) em 4.128 distritos de teste

sempre a média do treino

US$ 92.894
o piso que qualquer modelo de verdade precisa superar

Sete chamadas em que a ordem importa

  • fit_transform no treino, transform no teste.
  • Imputador antes do scaler.
  • As mesmas oito colunas, na mesma ordem.

Cada bug abaixo muda uma coisa no código de previsão. O modelo treinado é o mesmo em todos os casos.

Preveja. Qual bug você espera que seja o mais perigoso: um que quebra, um que dá uma nota ruim ou um que dá uma nota boa?

Bug 1 à mão: reajustar nos dados que você prevê

linhas de treinolinhas novas
200, NaN, 400, 600, 800NaN, 900

NaN (not a number) marca um valor faltante.

certo: ajustar no treino

mediana 500 → coluna 200, 500, 400, 600, 800

média 500, desvio \(\sqrt{40{.}000} = 200\)

linhas novas: NaN → 500 → 0; 900 → \((900-500)/200\) = 2

errado: reajustar nas linhas novas

mediana 900 → as duas linhas viram 900

média 900, dispersão zero

linhas novas: 0 e 0

O distrito dois desvios-padrão acima da média do treino agora parece perfeitamente médio.

Bug 1 nos dados reais: depende de como as linhas chegam

o scaler é reajustado em…MAE
todo o conjunto de teste de uma vezUS$ 42.814
o lote de cada regiãoUS$ 65.754
um distrito de cada vezUS$ 116.049
  • Invisível no teste inteiro: 4.128 distritos sorteados têm quase as estatísticas do treino; o MAE sai até US$ 209 menor, por acaso.
  • Lotes por região são recentrados em si mesmos: os vizinhos encontrados deixam de ser os certos.
  • Uma linha tem dispersão zero: todo distrito vira o mesmo ponto, uma única previsão para 4.128 distritos.

Bug 2: um passo esquecido na hora de prever

imputador esquecido

o modelo recusa: Input X contains NaN.

o caso de sorte: um erro

scaler esquecido

US$ 105.978

silencioso: números crus (um total_rooms de 2.000 onde aprendeu valores em torno de 0), uma previsão confiante para cada distrito

O código de treino e o código de previsão são escritos em momentos diferentes, por pessoas diferentes, às vezes em arquivos diferentes.

Bug 3: outra ordem de colunas

O modelo recebe números, não nomes: a coluna 5 é "population" só porque era a coluna 5 no treino. Troque households e population:

enviado como array

US$ 101.517

um distrito tem cerca de três vezes mais moradores que domicílios; o único indício é um UserWarning

enviado como DataFrame

o scikit-learn compara os nomes com os do fit e recusa.

Mantenha os dados num DataFrame até o modelo e deixe o primeiro passo conferir os nomes.

Seis versões do código de previsão

MAE de teste do mesmo k-NN treinado com seis versões do código de previsão; três barras são piores que o baseline que sempre prevê a média

Três das seis barras são piores que ignorar os dados. A mais perigosa é a segunda: o bug que parece certo.

Etapa 2 · Como um único objeto mantém a regra por nós?

Pipeline: um estimador, vários passos


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
  

MAE

US$ 43.023
idêntico ao fluxo cuidadoso

um de cada vez

4.128 previsões uma a uma = todas de uma vez

colunas trocadas

recusadas: os nomes precisam bater com os do fit

Os três bugs, eliminados por construção

  • Bug 1. O pipe.predict nunca chama fit num transformador: o tamanho do lote deixa de importar.
  • 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.
As regras: todo passo exceto o último é um transformador (fit + transform); o último pode ser qualquer coisa com fit. O pipeline expõe os métodos do último passo: predict, score, predict_proba.

O que fit e predict fazem, passo a passo

A coluna de brinquedo passando por um pipeline de dois passos: o fit aprende mediana 500, média 500 e desvio 200; o predict os reutiliza e manda 0 e 2; o bug do fit_transform manda 0 e 0

fit: cada transformador é ajustado no que recebe e o transforma para o seguinte; o último passo é só ajustado. predict: só transform, com os parâmetros congelados no ajuste.

Um espião entre os passos

Um passo que não faz nada e relata, para total_bedrooms, os faltantes e a média.

chamadalinhasfaltantesmédia
fit · cru16.512163538,92
fit · imputado16.5120537,92
predict · cru4.12844533,66
predict · padronizado4.1280−0,01
Preveja. A coluna de teste padronizada deveria ter média exatamente 0?

Não: as linhas de teste são medidas com a régua das linhas de treino. Uma média de exatamente 0,00 no teste seria o sintoma do bug 1.

Durante o fit, só existem as 16.512 linhas de treino. Os 44 buracos do teste recebem a mediana do treino.

Pipeline ou make_pipeline


pipe_named = Pipeline([('impute', SimpleImputer(strategy='median')),
                       ('scale', StandardScaler()),
                       ('model', KNeighborsRegressor(n_neighbors=10))])
  
  • O make_pipeline dá a cada passo o nome da sua classe: 'simpleimputer', 'standardscaler', 'kneighborsregressor'.
  • No Pipeline, você mesmo dá os nomes.

O mesmo tipo de objeto; os nomes só importam quando você se refere a um passo depois. Nomes curtos e explícitos valem a pena em pipelines longos.

Etapa 3 · Como entram colunas de tipos diferentes e passos seus?

Colunas diferentes, tratamentos diferentes: ColumnTransformer


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
  

com ocean_proximity

US$ 41.552
antes, US$ 43.023

cada ramo

uma tripla (nome, transformador, colunas); um ramo pode ser ele mesmo um pipeline

Veja as colunas viajarem

O que realmente sai

Oito distritos de teste crus entram; saem 13 colunas: oito números padronizados e um único 1 entre as cinco colunas do one-hot de cada linha

O get_feature_names_out diz o que é cada uma das 13 colunas: o prefixo é o ramo (num__, cat__); as colunas do one-hot trazem a categoria. O total_bedrooms faltante recebe a mediana de treino, 437.

Que colunas vão para onde

remainder

As colunas que nenhum ramo menciona são descartadas por padrão: saem 2 colunas.

O 'passthrough' as anexa sem mudança: 9 colunas, mas sem escala (e uma é texto).

seletores

make_column_selector(dtype_include='number') escolhe as colunas pelo tipo no ajuste.

Eles confiam nos tipos: um CEP guardado como número cai no ramo numérico.

O 'drop' é o padrão seguro: um identificador esquecido nunca chega ao modelo em silêncio. Listas explícitas dizem exatamente o que acontece.

Robusto à ordem das colunas e a categorias novas

  • As nove colunas em ordem inversa: previsões idênticas. O ColumnTransformer escolhe pelo nome, então o bug 3 desaparece.
  • Um distrito rebatizado de 'LAKE', uma categoria nunca vista: zeros nas cinco colunas do one-hot e uma previsão, US$ 90.030, igual à do seu rótulo verdadeiro.
Sem handle_unknown='ignore', a mesma linha levanta Found unknown categories; um valor novo em produção derruba o serviço inteiro. ISLAND tem só 5 distritos na tabela inteira.

Seus próprios passos: sem estado


log = FunctionTransformer(np.log1p, feature_names_out='one-to-one')   # nada a aprender
  
assimetria das quatro colunas de contagem
antes3,85; 3,32; 5,17; 3,26
depois de \(\log(1+x)\)−1,09; −1,0; −1,04; −1,06
O fit dele não faz nada, então ele não tem como vazar. O 'one-to-one' mantém o get_feature_names_out funcionando.

Seus próprios passos: um transformador que aprende

Um cortador (clipper): aprende os percentis 1 e 99 de cada coluna nas linhas de treino e puxa todo valor fora deles de volta para o limite.

  • O __init__ só guarda os hiperparâmetros.
  • O fit aprende e devolve self; o estado aprendido termina em _ (lower_, upper_).
  • O transform aplica o que foi aprendido, depois de conferir que houve ajuste.
  • O TransformerMixin dá o fit_transform; o OneToOneFeatureMixin, os nomes das colunas.

aprendido em 16.512 distritos

total_rooms: de 162,2 a 11.248,0

US$ 41.361
MAE, um pouco melhor que US$ 41.552

Para que o cortador serve de verdade

Preveja. Um distrito novo chega com o total_rooms multiplicado por 1.000 (um erro de unidade). O que acontece com a previsão dele, com e sem o cortador?
valor verdadeiroUS$ 65.600
linha corretaUS$ 90.030
erro, sem cortadorUS$ 251.020
erro, com cortadorUS$ 145.690
Valor previsto de um distrito quando o total_rooms é multiplicado de 1 a 1000: sem o cortador sobe até US$ 251 mil; com ele, para em US$ 146 mil

Continua errado: nenhum transformador conserta uma entrada ruim. Mas o estrago tem limite, um limite aprendido só com dados de treino.

Etapa 4 · O que ele aprendeu e a nota é honesta?

Inspeção e persistência

abrir o contêiner

  • full.named_steps ou a indexação de uma lista: full[-1]
  • named_transformers_['num'][0].statistics_: as oito medianas de treino (437 quartos)
  • full[:-1] = só o pré-processamento: (2, 13)

um arquivo

joblib.dump(full, 'housing-knn.joblib')

3,9 MB (o k-NN guarda todos os distritos de treino); previsões idênticas depois de salvar e carregar.

Quem o carrega não tem como esquecer um passo, reajustar um scaler ou reordenar as colunas. Versões e carregamento seguro são o assunto da aula de MLOps (operações de machine learning).

O vazamento que um pipeline evita

Ajuste um passo em todos os 20.640 distritos antes da divisão ou dentro do pipeline. Dois tipos de passo:

um scaler

aprende médias e desvios-padrão; nunca olha para o preço

um target encoder

troca cada zona pelo preço médio daquela zona (zonas: coordenadas arredondadas, células de ~1 km)

12.590 zonas · 8.237 com um único distrito · 44% dos distritos de teste em zonas nunca vistas no treino

Preveja. Qual vazamento é maior? Pense num distrito de teste sozinho na sua zona.

O tamanho de um vazamento depende do que o passo aprende

encoder ajustado em todas as linhasUS$ 14.567
encoder dentro do pipelineUS$ 42.552
sem zona nenhumaUS$ 60.383
MAE de teste em 20 splits: para o scaler, todas as linhas ou pipeline dão os mesmos US$ 42,3 mil; para o target encoder, todas as linhas reporta US$ 14,5 mil contra US$ 42,8 mil honestos
  • Um distrito sozinho na sua zona: o "preço da zona" dele é o próprio preço e o modelo lê a resposta na entrada.
  • Em 20 splits: o vazamento do scaler muda o MAE em US$ 4 na média; o do encoder faz o erro parecer US$ 28.300 menor.
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.

Simulador: fit em quais linhas?

Preveja. Passe para teste = período posterior e depois para fit em todas as linhas. As linhas de teste parecem mais ou menos deslocadas? O modelo ficou melhor?

Olhando adiante: ajuste através do pipeline


full.get_params()        # 46 parâmetros, p. ex. columntransformer__num__simpleimputer__strategy
full.set_params(kneighborsregressor__n_neighbors=20)
  
  • Toda configuração de todo passo é acessível como passo__parametro.
  • Uma busca que reajusta o pipeline inteiro reaprende o pré-processamento nas linhas de treino de cada tentativa.
Nunca escolha uma configuração olhando a nota de teste: a aula de validação cruzada faz isso de forma honesta, com o GridSearchCV e esses nomes.

Resumo

  1. Passos ajustados: ajustar no treino, reaplicar sem mudança. À mão, a regra quebra.
  2. O bug mais perigoso é o que parece certo num conjunto de teste de notebook.
  3. Pipeline: duas chamadas, as mesmas previsões, os três bugs eliminados por construção.
  4. ColumnTransformer: um ramo por grupo de colunas, escolhidas pelo nome.
  5. Seus próprios passos: FunctionTransformer ou uma classe com fit/transform.
  6. Todo passo ajustado dentro do pipeline: nenhum vazamento a medir.
Hábito de projeto: escreva primeiro o esqueleto do pipeline. O objeto que você avalia é o objeto que você entrega.
Lab

Das linhas cruas do Titanic a um modelo salvo

  • Baixe o notebook do lab e abra no Jupyter ou no Colab (File → Upload notebook).
  • 891 passageiros crus; um classificador k-NN como caixa-preta.
  • Rode os blocos em ordem, numa mesma sessão.

1. carregar e olhar
2. dividir uma vez
3. o pré-processamento, coluna por coluna

4. ajustar no treino, avaliar no teste
5. o que cada passo aprendeu
6. o que o modelo vê

7. passageiros novos, crus
8. salvar, carregar, comparar
9. um transformador seu: o título