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)devolve0.723, um único número de uma única dobra, embaralhada de um jeito diferente a cada chamada (porquenp.random.permutationroda 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 -fold): divide o dado em blocos, usa pra treinar e 1 pra validar, repete vezes trocando qual bloco fica de fora, e faz a média das 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 -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). ComRepeatedKFold(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_meanescaler__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 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á sabia | O que essas três aulas assentaram |
|---|---|
Pipeline evita vazamento entre normalização e modelo | O 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/teste | Um 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 minha | GridSearchCV 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)
| Etapa | Resultado |
|---|---|
| KNN cru, sem normalizar, sem buscar hiperparâmetro | 0.7222 (teste) |
| Melhor combinação encontrada pela grade | metric=manhattan, n_neighbors=9, weights=uniform |
| Nota de validação cruzada da grade | 0.9862 |
| Acurácia no teste, com o melhor pipeline | 0.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.