Módulo 3 · Aprendizaje supervisado

Lección 4 de 9

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.

Python 3 · ejemplo reproducible
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))
Salida esperada

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.

Python 3 · ejemplo reproducible
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))
Salida esperada

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.

Python 3 · ejemplo reproducible
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?

Python 3 · ejemplo reproducible
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)])
Salida esperada

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.

Python 3 · ejemplo reproducible
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}")
Salida esperada

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ódigoPython
Python 3 · ejemplo reproducible
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?

Python 3 · ejemplo reproducible
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)],
)
Salida esperada

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ódigoPython
Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
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ónmediadesviación
fuera del fold0.8300.068
dentro del fold0.5500.105
Lectura. Fuera del fold, la selección vio las etiquetas de validación. Eligió columnas que, por azar, separan esas mismas filas. La validación mide esa coincidencia y no un patrón real. Dentro del 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.

Python 3 · ejemplo reproducible
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?

Python 3 · ejemplo reproducible
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))
Salida esperada

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?

Python 3 · ejemplo reproducible
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))
Salida esperada

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.

medidavalor
candidatos40
best_params_seleccion__k = 5, knn__n_neighbors = 3
best_score_0.59
exactitud en las 1000 filas nuevas0.498
Lectura. Aquí no hay fuga y aun así 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_?

Python 3 · ejemplo reproducible
print("validación cruzada:", round(busqueda.best_score_, 3))
print("holdout:", round(busqueda.score(X_hold, y_hold), 3))
Salida esperada

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:

Python 3 · ejemplo reproducible
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:

  1. un holdout estratificado de 25 % con random_state=0
  2. un Pipeline con StandardScaler y KNeighborsClassifier
  3. StratifiedKFold de 5 folds, barajado y con semilla
  4. una GridSearchCV sobre knn__n_neighbors con su tabla de media, desviación y rango
  5. 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:

Python 3 · ejemplo reproducible
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 GridSearchCV lanza ValueError con Invalid 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 fit o fit_transform ejecutado con todas las filas antes de cross_val_score. Ese paso va dentro del Pipeline.
  • Si un fold de validación no tiene ejemplos de una clase, cambia KFold por StratifiedKFold y pasa y a split.
  • Si dos candidatos difieren menos que su desviación entre folds, la búsqueda no los distingue aunque rank_test_score los 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é StratifiedKFold necesita y y KFold no?
  • ¿Qué pasos de un Pipeline se ajustan de nuevo en cada fold y con qué filas?
  • ¿Cuántos ajustes hace GridSearchCV con 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

Cargando progreso…