Validación y evaluación de modelos
Cómo medir si un modelo servirá mañana y no solo si acertó ayer
Entrenar un modelo es la mitad del trabajo; la otra mitad es demostrar que generaliza. Aprenderás a separar train, validación y test, a usar validación cruzada, a leer una matriz de confusión y a calcular accuracy, precision, recall, F1 y ROC-AUC sin engañarte con fugas de datos.
- Separar los datos en entrenamiento, validación y test y justificar cada corte
- Ejecutar validación cruzada estratificada y comparar modelos por media y desviación
- Calcular e interpretar accuracy, precision, recall, F1 y ROC-AUC sobre una matriz de confusión
- Detectar fugas de datos típicas: escalado previo al split, duplicados y variables del futuro
- Elegir la métrica adecuada según el coste real de cada tipo de error
Antes necesitas: Regresión lineal y logística · Árboles de decisión y Random Forest
En esta lección
- ¿Qué es validar un modelo?
- ¿Para qué sirve?
- ¿Cómo funciona por dentro?
- Sintaxis: del split a la validación cruzada
- Ejemplo básico: separar antes de mirar
- Ejemplo intermedio: comparar dos modelos con validación cruzada
- Caso práctico: cribado con 500 pacientes
- Visualización: la matriz de confusión
- Visualización: el cálculo, paso a paso
- ROC-AUC: ordenar antes de decidir
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Un modelo que acierta el 89 % de las veces puede ser peor que otro que acierta el 84 %. La diferencia no está en el porcentaje, sino en qué errores comete y en cómo se midió. Un clasificador evaluado con los datos que usó para entrenar siempre parece excelente.
Esta lección trata de la parte que decide si un modelo llega a producción: la evaluación. Verás cómo repartir los datos, cómo medir con varias particiones y por qué una métrica puede mejorar mientras el modelo empeora.
¿Qué es validar un modelo?
Validar es estimar el rendimiento sobre datos que el modelo no ha visto. Un modelo generaliza cuando mantiene su calidad en ejemplos nuevos; si solo funciona con los que memorizó, ha hecho sobreajuste. La validación es lo que te permite elegir modelo, hiperparámetros y umbral sin contaminar la estimación final.
Para eso se usan tres conjuntos con papeles distintos. El de entrenamiento ajusta los parámetros. El de validación se usa para elegir; puedes mirarlo tantas veces como quieras. El de test se reserva para una única medición final y responde a «¿cuánto rendirá?».
¿Para qué sirve?
- Elegir un modelo: comparar regresión logística, bosque aleatorio y boosting con el mismo criterio.
- Ajustar hiperparámetros: buscar la profundidad que mejor generaliza, no la que mejor memoriza.
- Fijar un umbral: decidir a partir de qué probabilidad un caso se marca como positivo.
- Estimar el coste real: traducir falsos positivos y falsos negativos a dinero o riesgo clínico.
- Detectar problemas: una brecha entre entrenamiento y validación delata sobreajuste.
- Comunicar resultados: una matriz con métricas y desviación es defendible ante otra persona.
¿Cómo funciona por dentro?
train_test_split baraja los índices y devuelve subconjuntos disjuntos. Un reparto habitual es 60 % entrenamiento, 20 % validación y 20 % test. Con clases desbalanceadas, stratify=y conserva la proporción de positivos en cada parte; sin él, un corte al azar puede dejar la validación casi sin casos positivos.
La validación cruzada sustituye un único corte por varios. Con k=5, los datos se dividen en cinco bloques y el modelo se entrena cinco veces, cada vez con cuatro y validando con el quinto. Cada observación se valida una vez y obtienes cinco puntajes: su media estima el nivel y su desviación, cuánto depende del reparto.
El test se toca una sola vez. Cada consulta para decidir algo lo convierte en validación y su estimación deja de ser limpia. Si necesitas comparar muchas variantes, hazlo con validación cruzada sobre el entrenamiento.
Sintaxis: del split a la validación cruzada
Estas son las piezas de scikit-learn que usarás en casi todos los proyectos. Instálala antes; no viene con Python:
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install scikit-learn numpyfrom sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
X, y = load_breast_cancer(return_X_y=True)
modelo = LogisticRegression(max_iter=5000)
# 1. Separar una vez; el test no se vuelve a mirar hasta el final
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
# 2. Comparar candidatos con validación cruzada sobre el train
corte = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
puntajes = cross_val_score(modelo, X_tr, y_tr, cv=corte, scoring="f1")
print(puntajes.mean().round(3))
# 3. Medir una sola vez en el test
modelo.fit(X_tr, y_tr)
print(f"F1 en test: {f1_score(y_te, modelo.predict(X_te)):.3f}")test_size=0.2 deja el 20 % para test y random_state fija el barajado. En el paso 2, modelo aún no está entrenado con todo el train: cross_val_score lo clona y lo entrena en cada partición. En el paso 3 sí lo entrenas completo y mides una vez.
Ejemplo básico: separar antes de mirar
El conjunto de cáncer de mama de scikit-learn tiene 569 muestras, 30 variables y clases moderadamente desbalanceadas (212 malignos frente a 357 benignos).
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split
X, y = load_breast_cancer(return_X_y=True)
# 60 % train y un 40 % que luego se parte en dos mitades
X_tr, X_resto, y_tr, y_resto = train_test_split(
X, y, test_size=0.4, stratify=y, random_state=42
)
X_va, X_te, y_va, y_te = train_test_split(
X_resto, y_resto, test_size=0.5, stratify=y_resto, random_state=42
)
print(X_tr.shape, X_va.shape, X_te.shape) # (341, 30) (114, 30) (114, 30)
modelo = LogisticRegression(max_iter=5000).fit(X_tr, y_tr)
print("val: ", round(f1_score(y_va, modelo.predict(X_va)), 3))
print("test:", round(f1_score(y_te, modelo.predict(X_te)), 3))El primer corte reserva el 40 % y el segundo lo divide en dos mitades iguales. stratify se aplica en cada paso con las etiquetas disponibles. El F1 de validación sirve para decidir; el de test solo se informa. Verás valores por encima de 0.90 en ambos; si fueran 1.000 exactos, sospecha de fuga de datos.
Ejemplo intermedio: comparar dos modelos con validación cruzada
Comparar con un solo corte es frágil: un reparto afortunado hace que el peor modelo parezca el mejor. cross_validate calcula varias métricas en una pasada, reutilizando los mismos pliegues.
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
X, y = load_breast_cancer(return_X_y=True)
corte = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
candidatos = {
"logistica": Pipeline([
("escalado", StandardScaler()),
("modelo", LogisticRegression(max_iter=1000)),
]),
"bosque": RandomForestClassifier(n_estimators=300, random_state=42),
}
for nombre, modelo in candidatos.items():
r = cross_validate(modelo, X, y, cv=corte, scoring=["accuracy", "f1", "roc_auc"])
print(f"{nombre:10s} f1={r['test_f1'].mean():.3f} ± {r['test_f1'].std():.3f}")
print(f"{'':10s} auc={r['test_roc_auc'].mean():.3f}")La clave es el Pipeline: el escalado vive dentro del modelo, así que en cada pliegue se ajusta solo con las cuatro quintas partes de entrenamiento. Si escalaras antes de validar, la media de las variables de validación habría influido en el ajuste y el resultado sería optimista.
Caso práctico: cribado con 500 pacientes
Una clínica evalúa un modelo de cribado sobre 500 pacientes de alto riesgo, de los cuales 120 tienen la enfermedad (24 %). El modelo ya está entrenado y congelado. Reproducimos las etiquetas para que las cifras se puedan comprobar.
import numpy as np
from sklearn.metrics import confusion_matrix, f1_score, precision_score, recall_score
# 500 pacientes ya clasificados: 0 = sano, 1 = enfermo
y_real = np.array([0] * 380 + [1] * 120)
y_pred = np.array([0] * 350 + [1] * 30 + [0] * 25 + [1] * 95)
mc = confusion_matrix(y_real, y_pred)
print(mc)
print(f"precision: {precision_score(y_real, y_pred):.3f}")
print(f"recall: {recall_score(y_real, y_pred):.3f}")
print(f"f1: {f1_score(y_real, y_pred):.3f}")La matriz sale [[350, 30], [25, 95]]: 350 sanos acertados, 30 sanos marcados como enfermos, 25 enfermos no detectados y 95 detectados. La precisión es 95 / 125 = 0.760, el recall 95 / 120 = 0.792 y el F1 0.776. Nada de eso se deduce de la accuracy sola.
| Métrica | Fórmula | En este caso | Qué castiga |
|---|---|---|---|
| Accuracy | (TP + TN) / total | 445 / 500 = 0.890 | Nada: premia la clase mayoritaria |
| Precision | TP / (TP + FP) | 95 / 125 = 0.760 | Las alarmas falsas |
| Recall | TP / (TP + FN) | 95 / 120 = 0.792 | Los casos que se escapan |
| Especificidad | TN / (TN + FP) | 350 / 380 = 0.921 | Marcar enfermo a quien está sano |
| F1 | 2·P·R / (P + R) | 0.776 | Equilibra precisión y recall |
Un modelo que respondiera «sano» a todo acertaría 380 de 500, un 76 %. El nuestro sube al 89 %, pero pierde 25 enfermos de 120. Si el tratamiento es urgente, ese 20,8 % no detectado es la cifra que importa. Bajando el umbral se detectan más enfermos a costa de más falsos positivos.
Visualización: la matriz de confusión
La matriz de confusión es la tabla de la que salen todas las métricas de clasificación binaria. Las filas son la realidad y las columnas, la predicción. Recórrela celda por celda: cada posición tiene un nombre y un coste distinto.
Recorre los pasos para ver cómo cambian las celdas.
Los falsos negativos son 25 y los falsos positivos 30: cantidades parecidas, gravedad distinta. La matriz no decide por ti; te da los números para decidir con contexto. Cambiar el umbral mueve casos entre columnas, nunca cambia el total.
Visualización: el cálculo, paso a paso
Las fórmulas se entienden mejor ejecutándolas. Este programa no usa scikit-learn: calcula las métricas a mano desde los mismos conteos, para que veas de dónde sale cada decimal.
tn, fp, fn, tp = 350, 30, 25, 95precision = tp / (tp + fp)recall = tp / (tp + fn)f1 = 2 * precision * recall / (precision + recall)accuracy = (tp + tn) / (tn + fp + fn + tp)print(f"precision={precision:.3f} recall={recall:.3f}")print(f"f1={f1:.3f} accuracy={accuracy:.3f}")—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
La precisión sale de un denominador de 125 y el recall de uno de 120. Esa diferencia de cinco casos explica por qué no coinciden. Un recall alto con una precisión baja significa que el modelo marca mucho para no dejar escapar nada.
ROC-AUC: ordenar antes de decidir
Todo lo anterior depende del umbral. La curva ROC barre todos los umbrales y dibuja la tasa de verdaderos positivos (recall) frente a la de falsos positivos. El ROC-AUC mide la capacidad de ordenar: 0.5 es azar y 1.0 es separación perfecta.
from sklearn.metrics import RocCurveDisplay, roc_auc_score
prob = modelo.predict_proba(X_te)[:, 1] # probabilidad de la clase positiva
print(f"AUC: {roc_auc_score(y_te, prob):.3f}")
RocCurveDisplay.from_predictions(y_te, prob)Pasa prob, no pred. Una AUC de 0.90 significa que, tomando un positivo y un negativo al azar, el modelo da más probabilidad al positivo el 90 % de las veces; no significa que acierte el 90 % ni que sus probabilidades estén calibradas.
Errores comunes
| Síntoma | Causa | Cómo se corrige |
|---|---|---|
| Accuracy 0.95 y no detecta nada | Clase positiva minoritaria | Mira recall, F1 y la matriz |
| El test rinde peor que la validación | Se eligió el modelo mirando el test | Decide con validación cruzada |
| Los resultados cambian en cada ejecución | Falta random_state o hay fuga | Fija la semilla y revisa el preprocesado |
ValueError en scoring | Nombre de métrica mal escrito | Usa "f1", "roc_auc" o una función |
roc_auc_score da algo raro | Le pasaste etiquetas, no probabilidades | Usa predict_proba(X)[:, 1] |
| Las métricas caen en producción | El corte no era representativo | Valida por tiempo o por grupo |
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
esc = StandardScaler().fit(X) # ve todo el dataset
X_esc = esc.transform(X)
X_tr, X_te, y_tr, y_te = train_test_split(X_esc, y, test_size=0.2)from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
tuberia = Pipeline([("esc", StandardScaler()), ("modelo", modelo)])
tuberia.fit(X_tr, y_tr)La media y la desviación del escalado son parámetros aprendidos. Si se calculan con todo el dataset, el test deja de ser invisible: su distribución ya influyó en el modelo. El efecto es pequeño con muchas muestras y enorme con pocas, y siempre invalida la estimación.
Buenas prácticas
- Separa el test al principio y no lo mires hasta tener una sola candidata.
- Elige la métrica antes de entrenar, según el coste de cada error.
- Reporta media y desviación: una diferencia menor que la desviación no es una diferencia.
- Usa
Pipelinepara que el preprocesado se ajuste dentro de cada pliegue. - Con series temporales, valida con
TimeSeriesSplit: barajar el futuro es fuga. - Comprueba duplicados antes de separar y documenta datos, semilla y métricas.
Ejercicios
Leer una matriz ajena
Pista
Los positivos reales son 60 + 30. La precision divide TP entre todas las predicciones positivas.
from sklearn.model_selection import cross_val_score puntajes = cross_val_score(modelo, X, y, cv=, scoring=) print(puntajes.mean(), puntajes.std())
El número de pliegues es un entero pequeño; la métrica se nombra con la cadena que entiende scikit-learn.
Pipeline sin fugas y decisión razonada
Pipeline con StandardScaler y LogisticRegression, evalúalo con 5 pliegues usando F1 y AUC e imprime media y desviación. Explica después qué harías si un falso negativo costara diez veces más que un falso positivo.Pista
Usa cross_validate con scoring=["f1", "roc_auc"]. Para la decisión, piensa en bajar el umbral con predict_proba.
Soluciones explicadas
En el ejercicio del fraude, el paso decisivo es separar denominadores. La accuracy divide entre 1 000 transacciones; la precision, entre las 70 que el modelo marcó; el recall, entre las 90 que eran fraude. Un modelo puede tener 0.900 de accuracy y 0.333 de recall sin contradicción: acierta casi todo porque casi todo es negativo.
En el segundo, la pregunta no es «qué F1 es más alto» sino «qué umbral corresponde al coste de mis errores». El pipeline hace honesta la comparación y el AUC confirma que el modelo ordena bien aunque el umbral no sea óptimo. Bajar a 0.3 sube el recall y baja la precision: es un intercambio deliberado.
Resumen
- Entrena, valida y prueba con conjuntos disjuntos; el test se usa una vez.
stratify=ymantiene la proporción de clases y conviene casi siempre.- La validación cruzada da k estimaciones: media para el nivel, desviación para la estabilidad.
- Accuracy sola engaña con clases desbalanceadas; añade precision, recall y F1.
- Precision castiga falsos positivos; recall, falsos negativos; F1 combina ambos.
- El ROC-AUC mide orden y no umbral, pero no mide calibración.
- Toda transformación aprendida se ajusta dentro del pipeline, nunca antes del split.
Evaluación
Un modelo predice «sano» para todos los pacientes de un conjunto donde solo el 5 % está enfermo. ¿Qué ocurre?
Un filtro borra automáticamente los correos que clasifica como spam. ¿Qué error conviene vigilar primero?
¿Cuál de estas prácticas produce fuga de datos?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.