← Voltar pra playlist

Pipeline, Validação Cruzada e GridSearch: o Fluxo de Verdade

O resto da aula 4, o "trabalho de verdade" que fica escondido atrás de qualquer número de acurácia que eu já mostrei até aqui. Sem isso, todo K que eu escolhi nos dois posts anteriores foi, sem exagero, chute educado.

Pipeline: o que eu já sabia, agora com classificação

O Pipeline já apareceu na outra playlist: encadeia normalização e modelo num único objeto, então o scaler nunca vê dado que deveria ficar de fora do treino. Aqui o professor confirma a mesma conclusão, só que num problema de classificação com 3 classes:

model = KNeighborsClassifier()
model.fit(X_train, y_train)

Saída (sem normalizar): acurácia 0.67.

Depois ele normaliza de quatro jeitos diferentes (min-max na mão, MinMaxScaler, z-score na mão, StandardScaler) e todos batem:

Saída (qualquer normalização): acurácia 0.92.

E o Pipeline chega no mesmo 0.92, encadeando os dois passos automaticamente:

pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', KNeighborsClassifier())
])
pipeline.fit(X_train, y_train)

Nada novo em termos de mecânica (eu já vi Pipeline de fora), mas o salto de 0.67 pra 0.92 é o maior que eu vi até agora só de normalizar, e reforça o motivo que eu já expliquei: 13 variáveis em escalas bem diferentes, e o KNN decide tudo por distância.

Separando validação de teste

O aula04c começa devagar: separa uma fatia do treino só pra validação (X_tr/X_val), testa vários valores de K, e escolhe o que ganhou na validação:

X_tr, X_val, y_tr, y_val = train_test_split(X_train, y_train, test_size=0.2)

for k in range(1, 21, 2):
    model = KNeighborsClassifier(n_neighbors=k)
    model.fit(X_tr, y_tr)
    acc = accuracy_score(y_val, model.predict(X_val))

Saída: o melhor K encontrado foi k=9, com acurácia de validação 0.759.

Isso já é bem melhor que "testei no teste e vi qual K ganhou" (o que seria fazer exatamente o mesmo erro que qualquer curso de estatística avisa pra não fazer: usar o conjunto de teste pra escolher hiperparâmetro é uma forma de vazamento, você acaba enviesando a estimativa final porque ela deixou de ser sobre dado nunca visto). Mas tem um problema visível aqui: o train_test_split nem no holdout nem na validação fixa random_state, então cada vez que eu rodar essa célula de novo, X_tr/X_val mudam, e o K vencedor pode mudar junto. Uma única divisão de validação é uma amostra só, e pode ter tido sorte ou azar.

Um bug real, escondido num loop

O professor então parte pra validação cruzada, implementada na unha:

def cross_validation(model, X, y, k=3):
    n = int(len(y)/k)
    idx = np.random.permutation(len(y))
    X = X[idx]
    y = y[idx]
    for i in range(k):
        X_tr = np.concatenate([X[:i*n], X[(i+1)*n:]])
        y_tr = np.concatenate([y[:i*n], y[(i+1)*n:]])
        X_val = X[i*n:(i+1)*n]
        y_val = y[i*n:(i+1)*n]
        model.fit(X_tr, y_tr)
        y_pred = model.predict(X_val)
        acc = accuracy_score(y_val, y_pred)
        return acc

Vale a pena ler essa função com atenção, porque ela ensina uma lição que não tem nada a ver com machine learning: o return está dentro do for. O laço roda a variável i de 0 até k-1 pra fazer k dobras diferentes (a ideia certa de validação cruzada: cada dobra vira validação uma vez, o resto vira treino), mas a função sai fora e devolve o resultado assim que termina a primeira iteração (i=0). As outras k-1 dobras nunca chegam a rodar. cross_validation(), apesar do nome, calcula só uma divisão treino/validação, exatamente a mesma limitação da seção anterior, só que escondida atrás de um nome que promete mais do que entrega.

Saída: cross_validation(model, X_train, y_train) devolve 0.723, um único número de uma única dobra, embaralhada de um jeito diferente a cada chamada (porque np.random.permutation roda de novo toda vez).

Isso explica uma coisa estranha que aparece logo depois: repeated_cross_validation, que chama cross_validation() dez vezes e tira média e desvio padrão, funciona meio que por acidente. Ela não está fazendo "10 repetições de validação cruzada de verdade" (que seria dobra completa, repetida 10 vezes), está fazendo holdout repetido: 10 divisões aleatórias diferentes, cada uma avaliada uma vez só. A média das 10 (0.717, desvio 0.057, calculada logo depois no notebook) ainda é uma estimativa mais estável que uma única divisão, porque reduz a variância de "eu tive sorte ou azar numa divisão só". Só não é validação cruzada no sentido técnico do termo: nenhum ponto de treino nunca vira validação em mais de uma dessas 10 rodadas por acaso, não por garantia de cobertura completa como o k-fold de verdade promete.

O Bishop descreve exatamente esse k-fold de verdade (ele chama de SS-fold): divide o dado em SS blocos, usa S1S-1 pra treinar e 1 pra validar, repete SS vezes trocando qual bloco fica de fora, e faz a média das SS notas. A garantia importante que o cross_validation() do professor perde com o bug: no k-fold de verdade, todo ponto vira validação exatamente uma vez, cobrindo o dataset inteiro sem sobreposição. Com holdout repetido (mesmo que repetido várias vezes), alguns pontos podem nunca cair na validação, e outros podem cair repetidas vezes, por puro acaso da amostragem aleatória.

Fazendo do jeito certo

O scikit-learn já implementa o SS-fold do Bishop direitinho:

from sklearn.model_selection import KFold

kf = KFold(n_splits=3, shuffle=True)
accs = []
for train_index, val_index in kf.split(X_train):
    model = KNeighborsClassifier(n_neighbors=5)
    model.fit(X_train[train_index], y_train[train_index])
    accs.append(accuracy_score(y_train[val_index], model.predict(X_train[val_index])))

print(np.mean(accs))

Saída: 0.726, com KFold(n_splits=3). Com RepeatedKFold(n_splits=3, n_repeats=10) (o k-fold de verdade, repetido 10 vezes com embaralhamentos diferentes, pra reduzir ainda mais a variância da estimativa): 0.676. E o atalho de uma linha só, cross_val_score, bate os dois: 0.704 e 0.701 respectivamente.

Os números não são muito diferentes do holdout repetido "com bug" (0.717), e isso é esperado: mesmo com a implementação errada, a ideia geral (testar em pedaços que não foram usados pra treinar) já capturava a maior parte do sinal. O ganho do k-fold de verdade é robustez, não necessariamente um número dramaticamente diferente nesse dataset específico. Mas você não sabe disso sem comparar, e é exatamente por isso que vale a pena implementar (ou usar) a coisa certa em vez de confiar que "deu um número plausível" significa "o código está certo".

GridSearchCV: automatizando a busca

Em vez de escrever um for k in range(1, 21, 2) toda vez, o GridSearchCV faz a busca (e a validação cruzada por trás dela) automaticamente:

params = {'n_neighbors': range(1, 21, 2)}
grid = GridSearchCV(KNeighborsClassifier(), params, scoring='accuracy')
grid.fit(X_train, y_train)
print(grid.best_params_, grid.best_score_)

Saída: {'n_neighbors': 1}, com nota de validação cruzada 0.761.

E expandindo a busca pra três hiperparâmetros de uma vez (n_neighbors, weights, metric), com KFold(n_splits=5):

Saída: {'metric': 'manhattan', 'n_neighbors': 11, 'weights': 'distance'}, nota 0.803.

O Bishop já avisa exatamente esse tipo de situação no capítulo 1.3: quando você tem mais de um hiperparâmetro pra ajustar, testar cada combinação manualmente vira uma explosão combinatória rápido. GridSearchCV é força bruta organizada: testa toda combinação da grade, valida cada uma com k-fold, e devolve a melhor.

Pipeline + GridSearch: aí sim decola

A virada de chave do post inteiro: colocar o GridSearchCV dentro de um Pipeline, com o normalizador junto, e ajustar até os hiperparâmetros do próprio KNeighborsClassifier (usando o prefixo model__ pra apontar qual etapa do pipeline cada parâmetro pertence):

pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', KNeighborsClassifier())
])
params = {
    'model__n_neighbors': range(1, 21, 2),
    'model__weights': ['uniform', 'distance'],
    'model__metric': ['euclidean', 'manhattan', 'minkowski'],
}
grid = GridSearchCV(pipeline, params, scoring='accuracy', cv=KFold(n_splits=5, shuffle=True))
scores = cross_val_score(grid, X_train, y_train, cv=KFold(n_splits=5, shuffle=True))
print(np.mean(scores))

Repara na estrutura: tem um cross_val_score por fora de um GridSearchCV inteiro. Isso é validação cruzada aninhada: o laço de fora mede o quão bem o processo completo (normalizar, buscar os melhores hiperparâmetros, treinar) generaliza, e o laço de dentro (dentro do GridSearchCV) é só pra escolher os hiperparâmetros. Sem esse aninhamento, a nota de validação cruzada do próprio GridSearchCV (best_score_) fica levemente otimista, porque o mesmo dado que escolheu os hiperparâmetros também foi usado pra avaliar o resultado final.

Saída: média de 0.957 (contra 0.81 sem normalizar dentro do pipeline, e contra 0.68 do KNN cru sem grade nenhuma). Adicionando scaler__with_mean e scaler__with_std à grade de busca (deixando até a normalização ser parte do que é otimizado): 0.979.

Do KNN cru (0.67-0.68) pro pipeline completo com busca de hiperparâmetro aninhada (0.979): a distância inteira que separa "rodei o modelo default" de "fiz isso direito".

Mais rápido que grade completa: busca aleatória

RandomizedSearchCV testa só uma amostra aleatória de combinações (aqui, 20) em vez de todas:

grid = RandomizedSearchCV(pipeline, params, scoring='accuracy', cv=KFold(n_splits=5, shuffle=True), n_iter=20)

Saída: 0.957, praticamente empatado com a grade completa, testando bem menos combinações.

Faz sentido quando a grade de busca é grande demais pra testar tudo (aqui já são 10×2×3=6010 \times 2 \times 3 = 60 combinações, cada uma com 5 dobras, 300 ajustes de modelo, e com mais hiperparâmetros isso explode rápido). O notebook ainda dá uma espiada rápida no Optuna, uma biblioteca de busca bayesiana que escolhe a próxima combinação a testar com base no que já funcionou até agora, em vez de sortear ou testar tudo às cegas, mas isso é só uma menção de passagem, não o foco da aula.

Fechando

O que eu já sabiaO que essas três aulas assentaram
Pipeline evita vazamento entre normalização e modeloO mesmo vale pra classificação, não só regressão, e o ganho aqui foi enorme (0.67 → 0.92)
Validação cruzada existe pra dar uma estimativa mais estável que uma única divisão treino/testeUm return no lugar errado transforma "validação cruzada" em holdout repetido sem eu perceber, então vale a pena ler o próprio código de validação, não só confiar no nome da função
Hiperparâmetro é escolha minhaGridSearchCV automatiza a busca, e colocado dentro de um Pipeline, com validação cruzada aninhada por fora, dá a estimativa mais honesta de generalização que eu já produzi nessa playlist

Aplicação Prática

Reproduzo o pipeline completo (normalização + busca de hiperparâmetro + validação cruzada aninhada) no mesmo dataset de vinhos, com semente fixa pra um resultado reprodutível, coisa que o notebook original não tem em nenhuma das buscas.

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

pipeline = Pipeline([('scaler', StandardScaler()), ('model', KNeighborsClassifier())])
params = {
    'model__n_neighbors': range(1, 21, 2),
    'model__weights': ['uniform', 'distance'],
    'model__metric': ['euclidean', 'manhattan', 'minkowski'],
}
grid = GridSearchCV(pipeline, params, scoring='accuracy', cv=KFold(n_splits=5, shuffle=True, random_state=42))
grid.fit(X_train, y_train)
EtapaResultado
KNN cru, sem normalizar, sem buscar hiperparâmetro0.7222 (teste)
Melhor combinação encontrada pela grademetric=manhattan, n_neighbors=9, weights=uniform
Nota de validação cruzada da grade0.9862
Acurácia no teste, com o melhor pipeline0.9722
Validação cruzada aninhada (estimativa honesta)0.9791

A nota de validação cruzada aninhada (0.9791) e a acurácia no teste (0.9722) ficam bem próximas uma da outra, e é isso que eu quero ver: significa que a validação cruzada aninhada não estava sendo otimista demais, ela realmente previu como o pipeline se sairia num dado que nunca tinha visto. Contra o KNN cru (0.7222), a diferença inteira (25 pontos percentuais) veio só de normalizar e escolher hiperparâmetro direito, sem tocar no algoritmo em si.