Machine Learning — Módulo 9: validación y tuning — elegir bien sin autoengañarse
Módulo avanzado del Curso de Machine Learning: por qué un solo train/test split miente, la validación cruzada (k-fold) para medir con menos ruido, el tuning de hiperparámetros con GridSearchCV y RandomizedSearchCV, el rol del conjunto de validación vs el de test, y cómo escalar dentro del CV para no filtrar datos.
☰ Índice del contenido
Cerramos el módulo 8 con un pipeline que empaqueta preprocesamiento y modelo en un solo objeto. Ahora surge una pregunta incómoda: cuando dijiste “mi modelo da 87% de accuracy”, ¿de dónde salió ese número? Casi siempre de partir los datos una vez en entrenamiento y test, medir, y creerle. El problema es que ese 87% es un número sacado de una partición al azar — y si vuelves a partir con otra suerte, te sale 84% o 90%. Este módulo se trata de medir bien, y de elegir los ajustes del modelo sin hacerte trampa a ti mismo.
La tesis del módulo
Un solo train/test split es una foto con ruido: mide tanto tu modelo como tu suerte al partir. La validación cruzada promedia varias particiones para separar señal de azar. Y hay una línea sagrada: el conjunto de test se toca una sola vez, al final — si tuneas contra él, dejó de medir nada.
Por qué un solo split miente
En el módulo 4 partiste los datos en dos: entrenamiento (el modelo aprende) y test (mides con datos que nunca vio). Correcto, pero frágil. Si tienes 1.000 filas y apartas 200 para test, ese 20% te tocó por sorteo. Si por azar cayeron las filas fáciles, tu accuracy sale inflada; si cayeron las difíciles, sale castigada. El número baila según la semilla aleatoria.
La solución no es partir mejor: es partir muchas veces y promediar. Ahí entra la validación cruzada.
Validación cruzada (k-fold)
La validación cruzada (cross-validation) parte los datos en k pedazos iguales llamados folds (pliegues). Después rota: entrena con k−1 folds y valida con el que quedó afuera, y repite hasta que cada fold haya sido el de validación exactamente una vez. Con k=5 entrenas 5 modelos, cada uno validado en un 20% distinto, y promedias los 5 resultados.
El k-fold (partir en k pedazos y rotar cuál valida) te da dos cosas que un split solo no puede: un promedio más estable (menos dependiente del azar) y su desviación estándar, que te dice cuánto varía. Un modelo con 85% ± 2% es confiable; uno con 85% ± 12% es una lotería.
Datos completos (k = 5 folds)
┌────┬────┬────┬────┬────┐
│ F1 │ F2 │ F3 │ F4 │ F5 │
└────┴────┴────┴────┴────┘
ronda 1 ┌────┐
│ F1 │ valida F2 F3 F4 F5 entrenan
└────┘
ronda 2 ┌────┐
│ F2 │ vald F1 F3 F4 F5 entrenan
└────┘
ronda 3 ┌────┐
│ F3 │ F1 F2 F4 F5 entrenan
└────┘
ronda 4 ┌────┐
│ F4 │ resto entrena
└────┘
ronda 5 ┌────┐
│ F5 │ resto entrena
└────┘
resultado = promedio de las 5 rondas (± su desviación)
En código, cross_val_score hace toda la rotación por ti. La clave: le pasas el pipeline completo del módulo 8, no el modelo pelado — ya verás por qué es crítico.
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
# El pipeline del módulo 8: escalar + modelo, todo junto.
pipe = Pipeline([
("scaler", StandardScaler()),
("modelo", LogisticRegression(max_iter=1000)),
])
# StratifiedKFold mantiene la proporción de clases en cada fold
# (clave en clasificación desbalanceada; ver módulo 11).
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipe, X, y, cv=cv, scoring="f1")
print(f"F1 por fold: {scores}")
print(f"F1 promedio: {scores.mean():.3f} ± {scores.std():.3f}")
Ojo: en clasificación usa siempre
StratifiedKFold(no elKFoldnormal). Si una clase es el 10% de los datos, un fold al azar podría quedar casi sin ella y romper la medición. El estratificado reparte las clases proporcionalmente en cada fold.
Tuning de hiperparámetros: validación vs test
Un modelo tiene hiperparámetros: ajustes que tú eliges antes de entrenar y que no se aprenden solos (la profundidad de un árbol, el C de una regresión logística, cuántos árboles en un Random Forest). El tuning es probar combinaciones para quedarte con la mejor.
Y aquí aparece la trampa mortal. Si pruebas 50 combinaciones y eliges la que mejor puntúa en el test, ese test ya no mide nada: lo usaste para decidir, así que tu modelo está optimizado para él. Por eso se necesitan tres conjuntos, no dos:
| Conjunto | Para qué | Cuántas veces lo miras |
|---|---|---|
| Entrenamiento | El modelo aprende sus pesos | Cada entrenamiento |
| Validación | Elegir hiperparámetros (tunear) | Muchas veces, al comparar |
| Test | Estimar el rendimiento real final | Una sola vez, al terminar |
En la práctica no apartas un tercer bloque fijo: la validación cruzada es tu conjunto de validación. Tuneas usando el CV sobre el entrenamiento, eliges la mejor combinación, y recién ahí mides una vez contra el test intacto.
GridSearchCV y RandomizedSearchCV
GridSearchCV prueba todas las combinaciones de una grilla que tú defines, cada una evaluada por validación cruzada. Exhaustivo y ordenado, pero explota: 4 valores × 4 valores × 3 valores = 48 combinaciones, cada una con 5 folds = 240 entrenamientos.
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier
pipe = Pipeline([
("scaler", StandardScaler()),
("modelo", RandomForestClassifier(random_state=42)),
])
# El prefijo "modelo__" apunta al paso del pipeline: así se tunea
# un hiperparámetro DENTRO del pipeline, sin desarmarlo.
grid = {
"modelo__n_estimators": [100, 300, 500],
"modelo__max_depth": [5, 10, 20, None],
"modelo__min_samples_leaf": [1, 2, 4],
}
busqueda = GridSearchCV(
pipe, grid, cv=5, scoring="f1", n_jobs=-1
)
busqueda.fit(X_train, y_train) # solo el entrenamiento; el test ni aparece
print("Mejores hiperparámetros:", busqueda.best_params_)
print(f"Mejor F1 (en CV): {busqueda.best_score_:.3f}")
# Recién AHORA, una única vez, el test intacto:
mejor_modelo = busqueda.best_estimator_
print(f"F1 en test: {mejor_modelo.score(X_test, y_test):.3f}")
RandomizedSearchCV no prueba todo: sortea n combinaciones al azar del espacio de búsqueda. Suena peor, pero con presupuesto limitado suele encontrar algo casi tan bueno mucho más rápido — porque en la mayoría de los modelos solo unos pocos hiperparámetros mueven de verdad la aguja, y el sorteo los explora bien sin gastar en el resto.
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint
# Aquí puedes dar rangos/distribuciones, no solo listas fijas.
espacio = {
"modelo__n_estimators": randint(100, 800),
"modelo__max_depth": [5, 10, 20, 30, None],
"modelo__min_samples_leaf": randint(1, 8),
}
busqueda = RandomizedSearchCV(
pipe, espacio, n_iter=40, cv=5,
scoring="f1", n_jobs=-1, random_state=42
)
busqueda.fit(X_train, y_train)
| GridSearchCV | RandomizedSearchCV | |
|---|---|---|
| Qué prueba | Todas las combinaciones | n_iter sorteadas al azar |
| Costo | Explota con cada dimensión | Lo fijas tú con n_iter |
| Espacio | Solo listas discretas | Listas y distribuciones |
| Cuándo | Pocos hiperparámetros, grilla chica | Muchos hiperparámetros, presupuesto acotado |
| Riesgo | Carísimo de más | Puede saltarse el óptimo exacto |
La regla práctica: empieza con RandomizedSearchCV amplio para ubicar la zona buena, y si quieres afinar, cierra con un GridSearchCV chico alrededor de esa zona.
El pecado capital: data leakage al tunear
Acá está la razón real de que insistamos con el pipeline. Data leakage (fuga de datos) es cuando información del conjunto de validación se cuela al entrenamiento sin que te des cuenta, inflando el resultado con optimismo falso. La forma más común de provocarlo: escalar o transformar los datos ANTES del CV.
Míralo así: si haces StandardScaler().fit(X) sobre todos los datos y después haces validación cruzada, cada fold de validación fue escalado usando el promedio y la desviación calculados con esas mismas filas de validación incluidas. El modelo, indirectamente, ya “vio” datos de validación. La medición sale mejor de lo que será en producción — te autoengañaste.
# MAL: escalar fuera del CV filtra estadísticas de validación
X_escalado = StandardScaler().fit_transform(X) # usó TODO X, incluida validación
scores = cross_val_score(modelo, X_escalado, y, cv=5) # optimista, mentiroso
# BIEN: el escalado va DENTRO del pipeline, y el pipeline va al CV.
# Así, en cada fold, el scaler hace fit SOLO con el train de ese fold.
pipe = Pipeline([("scaler", StandardScaler()), ("modelo", modelo)])
scores = cross_val_score(pipe, X, y, cv=5) # honesto
Por eso el módulo 8 no fue decorativo: el pipeline no es solo orden, es la herramienta que hace que la validación cruzada mida bien. Cuando le pasas el pipeline a cross_val_score o GridSearchCV, scikit-learn hace el fit del preprocesamiento por separado en cada fold, con solo el train de ese fold. Sin fuga.
Ojo: esto vale para todo lo que “aprenda” del conjunto: escaladores, imputación de faltantes, selección de features, PCA, encoders. Todo eso va dentro del pipeline. Si lo haces una vez sobre
Xcompleto antes de partir, filtraste.
Errores comunes
- Tunear contra el test. El error que arruina proyectos enteros. El test se mira una vez, al final. Si lo usaste para elegir hiperparámetros, tu estimación de rendimiento es ficción optimista.
- Preprocesar fuera del CV. Escalar, imputar o hacer PCA sobre todo
Xantes de la validación cruzada filtra datos. Todo preprocesamiento va dentro del pipeline. - Usar
KFolden clasificación desbalanceada. Sin estratificar, un fold puede quedar sin la clase rara y romper la métrica. UsaStratifiedKFold. - Reportar solo el promedio del CV. Sin la desviación estándar no sabes si el modelo es estable o una lotería. Reporta siempre
media ± std. - Grid gigante por si acaso. Una grilla de miles de combinaciones cuesta horas y rara vez mejora sobre una búsqueda aleatoria bien pensada. Empieza random, afina con grid.
- Confiar en un CV con muy pocos datos. Con k folds sobre 60 filas, cada fold tiene 12 datos y la varianza es enorme. El CV ayuda, pero no fabrica datos que no tienes.
La frase para llevarse
“Un split te da un número; la validación cruzada te da un número con su margen de error. Y el test es sagrado: se toca una vez, al final. Si lo usaste para decidir, ya no mide nada.”
Qué viene
Módulo 10: aprendizaje no supervisado — clustering y PCA. Hasta ahora siempre tuvimos la respuesta correcta (la etiqueta) para aprender. ¿Qué haces cuando no la tienes? Veremos cómo agrupar datos por su cuenta con k-means y DBSCAN, y cómo comprimir muchas features en pocas con PCA para poder visualizar y simplificar.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.