Módulo 3 · Aprendizaje supervisado
Validación cruzada y tuning
En esta páginaEl problema
El problema
Tienes el conjunto load_breast_cancer: 569 tumores descritos por 30 medidas, con la etiqueta 0 para maligno y 1 para benigno. Con return_X_y=True la función devuelve directamente la matriz X y el vector y, y np.bincount cuenta las filas de cada clase.
import numpy as np
from sklearn.datasets import load_breast_cancer
X, y = load_breast_cancer(return_X_y=True)
print(X.shape, np.bincount(y))
La salida esperada está oculta.
Un KNN con k = 5 y escalado se evalúa con cinco particiones entrenamiento/prueba que solo cambian la semilla. train_test_split aparta el 25 % para prueba, stratify=y conserva la proporción de clases en ambas partes y random_state decide qué filas caen en cada una. El escalador se ajusta solo con entrenamiento, y score devuelve la exactitud en prueba.
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
for semilla in range(5):
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=semilla
)
escala = StandardScaler().fit(X_tr)
knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(escala.transform(X_tr), y_tr)
exactitud = knn.score(escala.transform(X_te), y_te)
print(semilla, round(exactitud, 3))
La salida esperada está oculta.
El mismo modelo obtiene exactitudes entre 0.951 y 0.972 según qué filas caen en prueba. Además, k = 5 no se eligió con ningún criterio. Si pruebas varios valores de k en la partición de prueba y te quedas con el mejor, esa partición deja de medir datos nuevos.
Validación cruzada K-fold
Primero apartas un holdout estratificado de 25 %. Ese conjunto no participa en ninguna decisión hasta el final. Las filas restantes forman el conjunto de desarrollo. El modelo es un Pipeline: recibe una lista de pasos (nombre, objeto) y los ajusta en orden, así que el escalado y el KNN se entrenan como una sola pieza.
from sklearn.pipeline import Pipeline
X_dev, X_hold, y_dev, y_hold = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=0
)
modelo = Pipeline(
[("escala", StandardScaler()), ("knn", KNeighborsClassifier(n_neighbors=5))]
)
K-fold divide el conjunto de desarrollo en K partes, llamadas folds. Cada fold actúa una vez como validación mientras los otros K − 1 entrenan el modelo. KFold(n_splits=5, shuffle=True, random_state=0) baraja las filas antes de cortarlas, y su método split entrega, para cada fold, los índices de entrenamiento y los de validación.
Pregunta. El conjunto de desarrollo tiene 426 filas y K = 5. ¿Cuántas filas tiene cada fold de validación?
from sklearn.model_selection import KFold
kfold = KFold(n_splits=5, shuffle=True, random_state=0)
print([len(val) for _, val in kfold.split(X_dev)])
La salida esperada está oculta.
cross_val_score repite el ciclo de entrenar y validar sobre esos mismos folds y devuelve un puntaje por fold.
from sklearn.model_selection import cross_val_score
puntajes = cross_val_score(modelo, X_dev, y_dev, cv=kfold)
print(np.round(puntajes, 3))
print(f"media {puntajes.mean():.3f}, desviación {puntajes.std():.3f}")
La salida esperada está oculta.
Estratificación por clase
KFold con shuffle=True reparte las filas al azar sin mirar la etiqueta. StratifiedKFold recibe también y y reparte cada clase por separado entre los folds. La diferencia se nota con una clase rara. El conjunto sintético se genera con make_classification, y weights=[0.9] pide que cerca del 90 % de las filas sean de la clase 0.
Mostrar códigoOcultar código
from sklearn.datasets import make_classification
X_raro, y_raro = make_classification(n_samples=80, weights=[0.9], random_state=0)
Hay 73 filas de la clase 0 y 7 de la clase 1. La clase 1, la positiva, es rara: menos del 9 % de las 80 filas. StratifiedKFold se construye con los mismos argumentos que KFold.
Pregunta. Con cinco folds, ¿puede algún fold de validación de KFold quedarse sin positivos? ¿Cuántos positivos esperas en cada fold de StratifiedKFold?
from sklearn.model_selection import StratifiedKFold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
# con etiquetas 0/1, la suma cuenta los positivos del fold
print("KFold ", [int(y_raro[val].sum()) for _, val in kfold.split(X_raro)])
print(
"StratifiedKFold",
[int(y_raro[val].sum()) for _, val in cv.split(X_raro, y_raro)],
)
La salida esperada está oculta.
Preprocesamiento dentro del fold
StandardScaler aprende medias y desviaciones. SelectKBest(f_classif, k=20) aprende qué 20 columnas se relacionan más con y. Cuando un Pipeline entra a cross_val_score, cada paso se ajusta de nuevo en cada fold. La alternativa es ajustar el preprocesamiento una sola vez con todas las filas y después validar.
Para comparar ambas versiones, se usan datos sin ninguna señal: X_ruido tiene forma (100, 1000), con columnas de ruido normal, y y_ruido tiene 50 etiquetas 0 y 50 etiquetas 1 asignadas al azar. En filas nuevas, ningún modelo puede superar en promedio una exactitud de 0.5.
Mostrar códigoOcultar código
rng = np.random.default_rng(0)
X_ruido = rng.normal(size=(100, 1000))
y_ruido = np.array([0, 1] * 50)
rng.shuffle(y_ruido)
La versión con fuga ajusta SelectKBest con las 100 filas, reduce la tabla con transform y solo después valida el KNN sobre las 20 columnas elegidas.
from sklearn.feature_selection import SelectKBest, f_classif
# la selección ve las etiquetas de todas las filas, también las de validación
seleccion = SelectKBest(f_classif, k=20).fit(X_ruido, y_ruido)
fuera = cross_val_score(
KNeighborsClassifier(n_neighbors=5),
seleccion.transform(X_ruido),
y_ruido,
cv=cv,
)
La versión sin fuga pone la selección como primer paso de un Pipeline y valida el conjunto completo.
modelo_ruido = Pipeline(
[
("seleccion", SelectKBest(f_classif, k=20)),
("knn", KNeighborsClassifier(n_neighbors=5)),
]
)
dentro = cross_val_score(modelo_ruido, X_ruido, y_ruido, cv=cv)
Pregunta. ¿Qué exactitud media esperas de cada versión?
Ver resultado
La tabla da la exactitud de validación cruzada de cada versión sobre los datos de ruido. La columna selección indica dónde se ajustó SelectKBest: con las 100 filas antes de validar (fuera del fold) o dentro del Pipeline en cada fold (dentro del fold). La columna media promedia los cinco folds de cv y la columna desviación mide cuánto cambia el puntaje entre folds. Por ejemplo, la fila «fuera del fold» con 0.830 y 0.068 dice que la versión que ajustó SelectKBest con las 100 filas acierta en promedio 0.830, con una desviación de 0.068 entre folds.
| selección | media | desviación |
|---|---|---|
| fuera del fold | 0.830 | 0.068 |
| dentro del fold | 0.550 | 0.105 |
Pipeline, cada fold elige sus columnas solo con sus filas de entrenamiento. El resultado queda cerca de 0.5, y la diferencia con 0.5 es menor que la desviación entre folds. El modelo de cáncer de mama sigue el mismo principio: ningún paso aprende de las filas de validación. StandardScaler no ve y, así que ajustarlo con todas las filas solo filtraría medias y desviaciones, una fuga mucho menor que la de esta selección.Búsqueda de hiperparámetros
GridSearchCV recibe un modelo, una rejilla de valores y un esquema de validación cruzada. Ejecuta la validación completa para cada candidato y ordena los candidatos por su puntaje medio. Con refit=True, el valor por defecto, reentrena al mejor con todo el conjunto de desarrollo. En un Pipeline, cada hiperparámetro se nombra paso__parámetro, con dos guiones bajos.
from sklearn.model_selection import GridSearchCV
rejilla = {"knn__n_neighbors": [1, 3, 5, 7, 9, 11, 15, 21]}
busqueda = GridSearchCV(modelo, rejilla, cv=cv).fit(X_dev, y_dev)
Después del ajuste, cv_results_ guarda una entrada por candidato con el valor probado, mean_test_score, std_test_score y rank_test_score. n_splits_ es el número de folds, y best_params_ y best_score_ guardan al ganador y su media.
Pregunta. La rejilla tiene 8 valores de k y cv tiene 5 folds. ¿Cuántos ajustes hace la búsqueda contando el reentrenamiento final? ¿La media del mejor k superará a la del segundo por más de una desviación estándar?
import pandas as pd
tabla = pd.DataFrame(
{
"k": busqueda.cv_results_["param_knn__n_neighbors"],
"media": busqueda.cv_results_["mean_test_score"],
"desv": busqueda.cv_results_["std_test_score"],
"rango": busqueda.cv_results_["rank_test_score"],
}
)
print(tabla.round(3).to_string(index=False))
print(len(tabla), "candidatos ×", busqueda.n_splits_, "folds")
print(busqueda.best_params_, round(busqueda.best_score_, 3))
La salida esperada está oculta.
Una rejilla crece como el producto de sus listas. RandomizedSearchCV sortea n_iter candidatos a partir de listas o distribuciones. randint(1, 40) produce enteros de 1 a 39. weights="distance" hace que los vecinos más cercanos pesen más en el voto. Con dos opciones de weights, el espacio tiene 39 × 2 = 78 combinaciones.
Pregunta. Con n_iter=10, ¿su mejor media será claramente mayor que 0.972?
from scipy.stats import randint
from sklearn.model_selection import RandomizedSearchCV
distribuciones = {
"knn__n_neighbors": randint(1, 40),
"knn__weights": ["uniform", "distance"],
}
aleatoria = RandomizedSearchCV(
modelo, distribuciones, n_iter=10, cv=cv, random_state=0
)
aleatoria.fit(X_dev, y_dev)
print(len(aleatoria.cv_results_["params"]), "candidatos")
print(aleatoria.best_params_, round(aleatoria.best_score_, 3))
La salida esperada está oculta.
Mejor puntaje y holdout
best_score_ es el máximo entre varias medias de validación. Para ver qué representa ese máximo, la búsqueda se repite en los datos de ruido. Ahí la selección de columnas ya vive dentro del Pipeline y el rendimiento real es 0.5. La rejilla cruza 5 valores de seleccion__k con 8 de knn__n_neighbors. Después, el método score de la búsqueda mide el modelo reentrenado en 1000 filas nuevas de ruido con etiquetas al azar.
Pregunta. Con 40 candidatos sobre ruido, ¿best_score_ quedará cerca de 0.5, por encima o por debajo?
Ver resultado
La tabla resume la búsqueda sobre los datos de ruido. La columna medida nombra lo que se mide y la columna valor da su resultado. Por ejemplo, la fila best_score_ con 0.59 dice que la mejor media de validación entre los 40 candidatos es 0.59.
| medida | valor |
|---|---|
| candidatos | 40 |
best_params_ | seleccion__k = 5, knn__n_neighbors = 3 |
best_score_ | 0.59 |
| exactitud en las 1000 filas nuevas | 0.498 |
best_score_ supera 0.5. Cada media de validación contiene variación por azar. Al quedarte con la mayor de 40, también eliges al candidato con la variación más favorable. En filas nuevas esa ventaja desaparece. Cuantos más candidatos compares, mayor es el margen para ese sesgo de selección.El holdout de cáncer de mama no participó en ninguna elección. busqueda.score usa el modelo reentrenado con k = 7 sobre todo el conjunto de desarrollo.
Pregunta. ¿La exactitud en X_hold será mayor, igual o menor que best_score_?
print("validación cruzada:", round(busqueda.best_score_, 3))
print("holdout:", round(busqueda.score(X_hold, y_hold), 3))
La salida esperada está oculta.
Práctica
Traza guiada
Sin ejecutar, calcula para esta búsqueda el número de candidatos, los ajustes durante la validación y el total de ajustes con el reentrenamiento final:
traza = GridSearchCV(
modelo,
{"knn__n_neighbors": [3, 5, 7], "knn__weights": ["uniform", "distance"]},
cv=StratifiedKFold(n_splits=4, shuffle=True, random_state=0),
)
Resultado esperado: 6 candidatos, 24 ajustes de validación y 25 en total. Después de traza.fit(X_dev, y_dev), comprueba con len(traza.cv_results_["params"]) y traza.n_splits_. Si tu conteo difiere, multiplica primero las longitudes de las listas y después por el número de folds.
Rejilla con pesos
Añade "knn__weights": ["uniform", "distance"] a rejilla. Antes de ajustar, predice el número de candidatos y si best_score_ superará 0.972 por más de una desviación estándar. Después compara best_score_ con la exactitud en X_hold. Explica la diferencia con los términos de la sección sobre el mejor puntaje y el holdout, y di si esa segunda medición en X_hold sigue siendo una estimación para datos nuevos.
Protocolo con load_wine
Usa load_wine(return_X_y=True) y produce una nota con:
- un holdout estratificado de 25 % con
random_state=0 - un
PipelineconStandardScaleryKNeighborsClassifier StratifiedKFoldde 5 folds, barajado y con semilla- una
GridSearchCVsobreknn__n_neighborscon su tabla de media, desviación y rango best_params_,best_score_y una sola evaluación en el holdout
Comprueba con assert que la suma de los tamaños de los folds de validación es igual al número de filas de desarrollo.
Reto opcional
Pasa la búsqueda completa como modelo a otra validación cruzada:
externa = StratifiedKFold(n_splits=5, shuffle=True, random_state=1)
anidada = cross_val_score(GridSearchCV(modelo, rejilla, cv=cv), X_dev, y_dev, cv=externa)
Cada fold externo repite la búsqueda solo con su parte de entrenamiento y evalúa al ganador en filas que la búsqueda no vio. Esta técnica se llama validación cruzada anidada. Compara la media de anidada con best_score_ y explica cuál de las dos incluye el costo de elegir k.
Errores que revisar
- Si
GridSearchCVlanzaValueErrorconInvalid parameter, escribe el nombre del paso delante del hiperparámetro:knn__n_neighbors, con dos guiones bajos. - Si la validación cruzada da un puntaje mucho mayor que el holdout, busca un
fitofit_transformejecutado con todas las filas antes decross_val_score. Ese paso va dentro delPipeline. - Si un fold de validación no tiene ejemplos de una clase, cambia
KFoldporStratifiedKFoldy pasayasplit. - Si dos candidatos difieren menos que su desviación entre folds, la búsqueda no los distingue aunque
rank_test_scorelos ordene. - Si cambias hiperparámetros después de ver el holdout, ese puntaje ya no es una estimación para datos nuevos.
Cierre
Alguien prueba 200 combinaciones con RandomizedSearchCV, obtiene best_score_ = 0.99 y lo reporta como la exactitud esperada en datos nuevos. Explica qué sesgo contiene ese número y qué medición lo confirmaría.
Lista de comprobación
- ¿Por qué cada fila de desarrollo aparece exactamente una vez en validación con K-fold?
- ¿Por qué
StratifiedKFoldnecesitayyKFoldno? - ¿Qué pasos de un
Pipelinese ajustan de nuevo en cada fold y con qué filas? - ¿Cuántos ajustes hace
GridSearchCVcon 8 candidatos, 5 folds y reentrenamiento final? - ¿Por qué
best_score_supera 0.5 en datos de ruido aunque no haya fuga?
Documentación oficial
- Validación cruzada en scikit-learn:
cross_val_score,KFold,StratifiedKFoldy validación anidada. - Ajuste de hiperparámetros:
GridSearchCV,RandomizedSearchCVy nombrespaso__parámetro. - Pipelines y estimadores compuestos: construcción de
Pipeliney acceso a sus pasos. - Errores comunes: fuga de información por preprocesamiento ajustado fuera de la validación.