La lección anterior terminó con una inquietud: todas nuestras métricas se calcularon sobre una única división train/test, y esa división depende del azar de un random_state. ¿Y si con otra semilla el F1 hubiera sido notablemente distinto? En esta lección primero demostraremos que esa variabilidad es real, y después la resolveremos con la validación cruzada (cross-validation, CV): en lugar de evaluar una vez, evaluar K veces rotando los datos, y reportar media y desviación. Es la técnica estándar para comparar modelos de forma honesta —la usaremos para enfrentar por fin a los algoritmos del módulo 4 sobre el churn de MercaFresh— y la base sobre la que se construye la búsqueda de hiperparámetros que veremos en 07-05.
Contenido
- El problema: una división, un número, mucha varianza
- K-fold paso a paso
cross_val_scoreycross_validateen la prácticaStratifiedKFoldpara el churn- Interpretar media ± desviación: ¿es significativa la diferencia?
- Torneo honesto: los modelos del módulo 4 frente a frente
- Pipeline dentro de la CV: evaluación sin fugas
- Variantes y coste computacional
El problema: una división, un número, mucha varianza
Comprobémoslo empíricamente: mismo modelo, mismos datos, distinta semilla de división.
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
import numpy as np
resultados = []
for semilla in range(20): # 20 divisiones distintas
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.2, random_state=semilla, stratify=y
)
modelo = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
resultados.append(f1_score(y_te, modelo.predict(X_te)))
resultados = np.array(resultados)
print(f"F1 mínimo : {resultados.min():.3f}")
print(f"F1 máximo : {resultados.max():.3f}")
print(f"F1 medio : {resultados.mean():.3f} ± {resultados.std():.3f}")Una salida típica con un dataset mediano: mínimo 0.58, máximo 0.69, media 0.63 ± 0.03. Once puntos porcentuales de diferencia entre la peor y la mejor semilla, sin cambiar absolutamente nada del modelo. Dos consecuencias:
- Un único número ("mi F1 es 0.66") esconde una lotería: quizá te tocó una semilla afortunada.
- Comparar dos modelos con divisiones distintas (o incluso con la misma, si la diferencia es pequeña) puede llevar a conclusiones erróneas: la diferencia observada puede ser puro azar de la partición.
El bucle anterior ya apunta a la solución: evaluar varias veces y resumir con media y desviación. La validación cruzada hace esto mismo, pero de forma más eficiente: sin desperdiciar datos y garantizando que cada fila se usa exactamente una vez como test.
K-fold paso a paso
La validación cruzada K-fold funciona así:
- Se divide el conjunto de datos (el de entrenamiento; el test final de 06-01 sigue en su caja fuerte) en K bloques ("folds") del mismo tamaño. Valores habituales: K = 5 o K = 10.
- Se repiten K rondas. En la ronda i, el fold i actúa de conjunto de evaluación y los K−1 restantes, de entrenamiento.
- Se obtienen K métricas, una por ronda, y se reporta su media y desviación estándar.
flowchart TD
D["Datos de entrenamiento<br/>divididos en 5 folds: F1 F2 F3 F4 F5"] --> R1
subgraph Rondas["5 rondas de entrenamiento y evaluación"]
R1["Ronda 1: entrena F2-F5, evalúa F1 → métrica m1"]
R2["Ronda 2: entrena F1,F3-F5, evalúa F2 → métrica m2"]
R3["Ronda 3: entrena F1,F2,F4,F5, evalúa F3 → métrica m3"]
R4["Ronda 4: entrena F1-F3,F5, evalúa F4 → métrica m4"]
R5["Ronda 5: entrena F1-F4, evalúa F5 → métrica m5"]
end
R1 --> M["Resultado: media(m1..m5) ± desviación(m1..m5)"]
R2 --> M
R3 --> M
R4 --> M
R5 --> M
Ventajas frente a la división única:
- Cada fila se evalúa exactamente una vez (y se entrena K−1 veces): ningún dato se "desperdicia" como test permanente.
- La media de K medidas es mucho más estable que una medida suelta.
- La desviación estándar cuantifica la incertidumbre: sabemos cuánto fiarnos del número.
Importante: la CV produce K modelos desechables cuyo único fin es estimar el rendimiento. El modelo final que iría a producción se entrena después con todos los datos de entrenamiento.
cross_val_score y cross_validate en la práctica
scikit-learn automatiza todo el proceso:
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression
modelo = LogisticRegression(max_iter=1000)
# 5-fold CV midiendo F1; cv=5 usa StratifiedKFold automáticamente en clasificación
scores = cross_val_score(modelo, X_train, y_train, cv=5, scoring="f1")
print("F1 por fold:", np.round(scores, 3)) # p. ej. [0.61 0.65 0.63 0.60 0.66]
print(f"F1: {scores.mean():.3f} ± {scores.std():.3f}")Detalles clave:
scoringacepta las métricas de 06-02 por nombre:"accuracy","precision","recall","f1","roc_auc"(la veremos en 06-04),"neg_mean_absolute_error","r2"... Las de error van en negativo (neg_) porque scikit-learn siempre maximiza: un MAE de 18 aparece como −18.cross_val_scorerecibe el modelo sin entrenar: él se encarga de clonar, entrenar y evaluar en cada fold.
Cuando se quieren varias métricas a la vez (y los tiempos), se usa cross_validate:
from sklearn.model_selection import cross_validate
res = cross_validate(
modelo, X_train, y_train, cv=5,
scoring=["f1", "recall", "precision", "accuracy"],
return_train_score=True, # útil para diagnosticar overfitting (06-05)
)
print(f"F1 val : {res['test_f1'].mean():.3f} ± {res['test_f1'].std():.3f}")
print(f"Recall : {res['test_recall'].mean():.3f}")
print(f"F1 train : {res['train_f1'].mean():.3f}") # brecha train-val → 06-05
print(f"Tiempo/fold: {res['fit_time'].mean():.2f} s")StratifiedKFold para el churn
Con el churn al 20 %, los folds deben conservar esa proporción, por la misma razón que estratificábamos en 06-01: un fold que por azar tuviera un 12 % de churn daría una métrica no comparable. StratifiedKFold estratifica cada fold:
from sklearn.model_selection import StratifiedKFold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(modelo, X_train, y_train, cv=cv, scoring="f1")Dos apuntes:
- En clasificación, pasar
cv=5(un entero) ya usaStratifiedKFoldpor debajo; construir el objeto explícito permite fijarshuffle=Trueyrandom_state(recomendable: baraja antes de cortar los folds y hace el experimento reproducible). - En regresión,
cv=5usaKFoldsin estratificar, que es lo apropiado.
Interpretar media ± desviación: ¿es significativa la diferencia?
Supón dos modelos de churn con 5-fold CV:
- Regresión logística: F1 = 0.63 ± 0.02
- Árbol de decisión (
max_depth=5): F1 = 0.61 ± 0.04
¿Es la logística "mejor"? Aquí reaparece la lección 02-04: una media muestral lleva incertidumbre, y antes de declarar un ganador hay que preguntarse si la diferencia es significativa o compatible con el azar. La intuición práctica, en el espíritu de los intervalos de confianza:
- Si los rangos media ± desviación se solapan ampliamente (como aquí: 0.61–0.65 frente a 0.57–0.65), la evidencia de que un modelo sea mejor es débil.
- Si un modelo queda claramente por encima incluso contando la desviación (p. ej., 0.70 ± 0.02 frente a 0.61 ± 0.03), la diferencia es difícilmente atribuible al azar.
- Para afinar más se pueden comparar los resultados fold a fold (ambos modelos evaluados sobre los mismos folds forman pares, la misma idea del t-test pareado de 02-04), aunque con solo 5 valores el contraste formal tiene poca potencia; en la práctica profesional la regla del solapamiento, usada con prudencia, resuelve la mayoría de decisiones.
Moraleja: reporta siempre media ± desviación, y desconfía de rankings decididos por diferencias de milésimas.
Torneo honesto: los modelos del módulo 4 frente a frente
En el módulo 4 presentamos cada algoritmo por separado, con divisiones instrumentales. Ahora podemos compararlos con rigor: misma CV, mismos folds, misma métrica.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.neural_network import MLPClassifier
from sklearn.model_selection import StratifiedKFold, cross_validate
modelos = {
"Regresión logística": LogisticRegression(max_iter=1000),
"Árbol (max_depth=5)": DecisionTreeClassifier(max_depth=5, random_state=42),
"SVM (RBF)": SVC(),
"K-NN (k=15)": KNeighborsClassifier(n_neighbors=15),
"Naive Bayes": GaussianNB(),
"MLP": MLPClassifier(hidden_layer_sizes=(32,), max_iter=1000,
random_state=42),
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # mismos folds para todos
for nombre, clf in modelos.items():
pipe = Pipeline([("scaler", StandardScaler()), ("clf", clf)]) # ver sección siguiente
res = cross_validate(pipe, X_train, y_train, cv=cv, scoring=["f1", "recall"])
print(f"{nombre:22s} F1 = {res['test_f1'].mean():.3f} ± {res['test_f1'].std():.3f}"
f" Recall = {res['test_recall'].mean():.3f}")Un resultado típico sobre el churn de MercaFresh:
| Modelo | F1 (media ± std) | Recall | Comentario |
|---|---|---|---|
| Regresión logística | 0.63 ± 0.02 | 0.60 | Sólida, estable, interpretable |
| SVM (RBF) | 0.63 ± 0.03 | 0.58 | Empata, con más coste de cómputo |
| MLP | 0.62 ± 0.04 | 0.59 | Similar y más variable |
| Árbol (max_depth=5) | 0.61 ± 0.04 | 0.62 | Competitivo; sensible a la partición |
| K-NN (k=15) | 0.59 ± 0.03 | 0.55 | Algo por detrás |
| Naive Bayes | 0.56 ± 0.03 | 0.66 | Mejor recall, peor precision |
(Los números concretos dependerán de tus datos; lo transferible es el método.) Lecturas: con los solapamientos de las desviaciones, la logística, la SVM y el MLP están empatados en la práctica — y entonces deciden otros criterios: interpretabilidad, velocidad, simplicidad (la logística gana los tres en MercaFresh). Nótese también que cada modelo usa aquí sus hiperparámetros "razonables" sin optimizar; la búsqueda sistemática que podría cambiar este ranking se apoya en esta misma CV y es el tema de 07-05 (GridSearchCV).
Pipeline dentro de la CV: evaluación sin fugas
Atención a un detalle del código anterior: el StandardScaler va dentro del Pipeline que se pasa a cross_validate. No es estética, es la regla anti-fugas de 06-01 aplicada a la CV:
# ❌ INCORRECTO: escalar antes de la CV
X_esc = StandardScaler().fit_transform(X_train) # ve TODOS los folds a la vez
cross_val_score(LogisticRegression(max_iter=1000), X_esc, y_train, cv=5)
# ✅ CORRECTO: el Pipeline re-ajusta el escalador en cada ronda,
# solo con los folds de entrenamiento de esa ronda
pipe = Pipeline([("scaler", StandardScaler()),
("clf", LogisticRegression(max_iter=1000))])
cross_val_score(pipe, X_train, y_train, cv=5)En la versión incorrecta, el escalador se ajusta con datos que en cada ronda harán de "test", contaminando las K evaluaciones. Con imputación, selección de features o codificaciones dependientes del target, la fuga puede inflar seriamente las métricas. Regla de oro: todo paso que aprenda de los datos va dentro del Pipeline, y el Pipeline entero va dentro de la CV. Es la recompensa del trabajo del módulo 3: nuestros preprocesamientos ya estaban empaquetados en Pipelines/ColumnTransformers, así que encajan aquí sin cambios.
Variantes y coste computacional
Dos variantes que conviene conocer:
- LeaveOneOut (LOO): el caso extremo K = n (cada fila es un fold). Aprovecha al máximo datasets muy pequeños, pero exige entrenar n modelos y su estimación tiene alta varianza. Rara vez compensa frente a un 5-fold o 10-fold repetido.
- TimeSeriesSplit: para datos temporales como la demanda de MercaFresh, donde el K-fold clásico es inválido (entrenaría con el futuro para evaluar el pasado, la fuga temporal de 06-01). Genera cortes crecientes que siempre entrenan con el pasado y evalúan con el bloque siguiente: entrena [1], evalúa [2]; entrena [1,2], evalúa [3]; entrena [1,2,3], evalúa [4]...
from sklearn.model_selection import TimeSeriesSplit
cv_temporal = TimeSeriesSplit(n_splits=5)
scores = cross_val_score(modelo_demanda, X_demanda, y_demanda,
cv=cv_temporal, scoring="neg_mean_absolute_error")Sobre el coste: la CV multiplica el tiempo de entrenamiento por K (y por el número de modelos comparados, y más adelante por cada combinación de hiperparámetros de 07-05). Guía práctica:
| Situación | Estrategia razonable |
|---|---|
| Dataset pequeño/medio, modelos rápidos | 5-fold o 10-fold sin dudar |
| Modelos costosos (SVM/MLP grandes, mucho dato) | 5-fold, o 3-fold en exploración inicial |
| Dataset enorme (millones de filas) | Una división única bien hecha ya es estable |
| Series temporales | TimeSeriesSplit |
| Dataset diminuto (< 200 filas) | 10-fold o LOO, asumiendo la varianza |
cross_validate(..., n_jobs=-1) paraleliza los folds entre los núcleos de la CPU, un alivio casi gratuito.
Errores Comunes y Consejos
- Hacer CV sobre todo el dataset, test incluido. La CV sustituye al conjunto de validación, no al de test: se aplica sobre los datos de entrenamiento, y el test de 06-01 sigue reservado para el veredicto final.
- Preprocesar antes de la CV. La fuga silenciosa más frecuente en notebooks reales. Pipeline dentro de la CV, siempre.
- Comparar modelos con folds distintos. Usa el mismo objeto
StratifiedKFold(misma semilla) para todos los contendientes; si no, parte de la diferencia será ruido de partición. - Reportar solo la media. Sin la desviación no se puede juzgar si una diferencia entre modelos importa.
- Usar K-fold barajado en series temporales. Es evaluar prediciendo el pasado con el futuro; usa
TimeSeriesSplit. - Olvidar reentrenar al final. Tras elegir modelo con CV, el modelo definitivo se entrena con todos los datos de entrenamiento antes de la evaluación final en test (y antes de producción).
- Consejo: fija un "protocolo de evaluación" al inicio del proyecto (CV, métrica, semilla) y no lo cambies a mitad de camino; cambiar las reglas del juego cuando ya has visto resultados invita al autoengaño.
Ejercicios
Ejercicio 1
Explica por qué este código sobreestima el rendimiento y corrígelo:
from sklearn.impute import SimpleImputer
X_imp = SimpleImputer(strategy="mean").fit_transform(X_train)
X_esc = StandardScaler().fit_transform(X_imp)
scores = cross_val_score(LogisticRegression(max_iter=1000), X_esc, y_train,
cv=5, scoring="f1")Ejercicio 2
Con 5-fold CV obtienes: modelo A, recall = 0.64 ± 0.05; modelo B, recall = 0.66 ± 0.06. Tu jefe en MercaFresh quiere anunciar que "B es mejor". ¿Qué le responderías, y qué harías para aumentar la confianza en la comparación?
Ejercicio 3
Escribe el código que compara DecisionTreeClassifier con max_depth 3, 5 y 10 sobre el churn usando el mismo StratifiedKFold(5, shuffle=True, random_state=42) y scoring="f1", imprimiendo media ± desviación de cada uno. (Estás haciendo a mano, en miniatura, lo que GridSearchCV automatizará en 07-05.)
Soluciones
Solución 1. El imputador y el escalador se ajustan con todo X_train antes de la CV, de modo que en cada ronda el fold de evaluación ya ha influido en la media de imputación y en los parámetros de escalado: fuga de datos que infla las métricas. Corrección — meter ambos pasos en un Pipeline que se re-ajuste dentro de cada ronda:
from sklearn.pipeline import Pipeline
pipe = Pipeline([
("imputer", SimpleImputer(strategy="mean")),
("scaler", StandardScaler()),
("clf", LogisticRegression(max_iter=1000)),
])
scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring="f1")Solución 2. La diferencia (0.02) es mucho menor que las desviaciones (0.05–0.06): los rangos 0.59–0.69 y 0.60–0.72 se solapan casi por completo, así que la ventaja de B es perfectamente compatible con el azar de la partición — como vimos en 02-04, una diferencia no es una conclusión hasta descartar que sea ruido. Para ganar confianza: (a) evaluar ambos con los mismos folds y comparar fold a fold (comparación pareada); (b) repetir la CV con varias semillas (p. ej., RepeatedStratifiedKFold) para tener más medidas; (c) si tras eso siguen empatados, elegir por otros criterios (coste, interpretabilidad, recall mínimo garantizado).
Solución 3.
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for profundidad in [3, 5, 10]:
arbol = DecisionTreeClassifier(max_depth=profundidad, random_state=42)
scores = cross_val_score(arbol, X_train, y_train, cv=cv, scoring="f1")
print(f"max_depth={profundidad:2d} → F1 = {scores.mean():.3f} ± {scores.std():.3f}")Al usar el mismo objeto cv, los tres árboles se evalúan sobre folds idénticos y la comparación es justa. Un patrón típico: 3 se queda corto, 10 sobreajusta y 5 gana — el porqué de esa forma de "U invertida" lo veremos con las curvas de validación en 06-05, y la automatización de esta búsqueda, en 07-05.
Conclusión
La validación cruzada resuelve la fragilidad de la división única: K-fold rota los datos para que cada fila sea evaluada una vez, cross_val_score/cross_validate lo automatizan, StratifiedKFold preserva la proporción de churn en cada fold, y el resultado se lee como media ± desviación, aplicando el escepticismo estadístico de 02-04 antes de declarar vencedores. Con ella hemos podido celebrar el primer torneo justo entre los modelos del módulo 4, con el Pipeline dentro de la CV como garantía anti-fugas, y hemos dejado apuntadas las variantes (LeaveOneOut, TimeSeriesSplit) y el coste computacional. Pero en todo este torneo los clasificadores decidían con su umbral por defecto de 0.5, y ya sabemos desde 04-02 que ese umbral es una palanca de negocio. La próxima lección la explota a fondo: recorreremos todos los umbrales posibles a la vez con la curva ROC y resumiremos el resultado en un solo número, el AUC.
Curso de Machine Learning
Módulo 1: Introducción al Machine Learning
- ¿Qué es el Machine Learning?
- Historia y evolución del Machine Learning
- Tipos de Machine Learning
- Aplicaciones del Machine Learning
- El flujo de trabajo de un proyecto de Machine Learning
Módulo 2: Fundamentos de Estadística y Probabilidad
- Conceptos básicos de estadística
- Distribuciones de probabilidad
- Correlación y covarianza
- Inferencia estadística
- Teorema de Bayes
Módulo 3: Preprocesamiento de Datos
- Limpieza de datos
- Manejo de datos faltantes
- Transformación de datos
- Codificación de variables categóricas
- Normalización y estandarización
- Ingeniería de características
Módulo 4: Algoritmos de Machine Learning Supervisado
- Regresión lineal
- Regresión logística
- Árboles de decisión
- Máquinas de soporte vectorial (SVM)
- K-Vecinos más cercanos (K-NN)
- Naive Bayes
- Redes neuronales
Módulo 5: Algoritmos de Machine Learning No Supervisado
- Clustering: K-means
- Clustering jerárquico
- Análisis de componentes principales (PCA)
- Análisis de agrupamiento DBSCAN
- Visualización de datos con t-SNE y UMAP
Módulo 6: Evaluación y Validación de Modelos
- División de datos: entrenamiento, validación y prueba
- Métricas de evaluación
- Validación cruzada
- Curva ROC y AUC
- Overfitting y underfitting
Módulo 7: Técnicas Avanzadas y Optimización
- Regularización: Ridge, Lasso y Elastic Net
- Ensemble Learning
- Gradient Boosting
- Redes neuronales profundas (Deep Learning)
- Optimización de hiperparámetros
Módulo 8: Implementación y Despliegue de Modelos
- Frameworks y bibliotecas populares
- Implementación de modelos en producción
- Mantenimiento y monitoreo de modelos
- Consideraciones éticas y de privacidad
Módulo 9: Proyectos Prácticos
- Proyecto 1: Predicción de precios de viviendas
- Proyecto 2: Clasificación de imágenes
- Proyecto 3: Análisis de sentimientos en redes sociales
- Proyecto 4: Detección de fraudes
- Proyecto 5: Segmentación de clientes
