AulaML Python · C++
Python avanzado · 42 min · Python para Machine Learning

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.

Lección 5 de 7 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es validar un modelo?
  2. ¿Para qué sirve?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis: del split a la validación cruzada
  5. Ejemplo básico: separar antes de mirar
  6. Ejemplo intermedio: comparar dos modelos con validación cruzada
  7. Caso práctico: cribado con 500 pacientes
  8. Visualización: la matriz de confusión
  9. Visualización: el cálculo, paso a paso
  10. ROC-AUC: ordenar antes de decidir
  11. Errores comunes
  12. Buenas prácticas
  13. Ejercicios
  14. Soluciones explicadas
  15. Resumen
  16. 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:

SHELL Instalación en un entorno virtual
python3 -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install scikit-learn numpy
PYTHON esqueleto_evaluacion.py
from 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).

PYTHON basico_evaluacion.py
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.

PYTHON comparar_modelos.py
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.

PYTHON cribado.py
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étricaFórmulaEn este casoQué castiga
Accuracy(TP + TN) / total445 / 500 = 0.890Nada: premia la clase mayoritaria
PrecisionTP / (TP + FP)95 / 125 = 0.760Las alarmas falsas
RecallTP / (TP + FN)95 / 120 = 0.792Los casos que se escapan
EspecificidadTN / (TN + FP)350 / 380 = 0.921Marcar enfermo a quien está sano
F12·P·R / (P + R)0.776Equilibra 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.

Mapa de datos ·Matriz de confusión de los 500 cribados
2 × 2 rango [25, 350] Verdaderos negativos: 350 personas sanas clasificadas como sanas. Es la celda que infla la accuracy.
Pred.: sano
Pred.: enfermo
Real: sano (0)
350
30
Real: enfermo (1)
25
95

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.

Ejecución visual ·De los conteos a las métricas
1 / 7
1 tn, fp, fn, tp = 350, 30, 25, 95
2 precision = tp / (tp + fp)
3 recall = tp / (tp + fn)
4 f1 = 2 * precision * recall / (precision + recall)
5 accuracy = (tp + tn) / (tn + fp + fn + tp)
6 print(f"precision={precision:.3f} recall={recall:.3f}")
7 print(f"f1={f1:.3f} accuracy={accuracy:.3f}")
Se desempaquetan las cuatro celdas en el orden estándar de scikit-learn: TN, FP, FN, TP.
Variables
tn = 350
fp = 30
fn = 25
tp = 95
Salida
—

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.

PYTHON auc.py
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íntomaCausaCómo se corrige
Accuracy 0.95 y no detecta nadaClase positiva minoritariaMira recall, F1 y la matriz
El test rinde peor que la validaciónSe eligió el modelo mirando el testDecide con validación cruzada
Los resultados cambian en cada ejecuciónFalta random_state o hay fugaFija la semilla y revisa el preprocesado
ValueError en scoringNombre de métrica mal escritoUsa "f1", "roc_auc" o una función
roc_auc_score da algo raroLe pasaste etiquetas, no probabilidadesUsa predict_proba(X)[:, 1]
Las métricas caen en producciónEl corte no era representativoValida por tiempo o por grupo
La fuga de datos más frecuente
Incorrecto
PYTHON Escalar antes de separar
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)
Correcto
PYTHON Escalar dentro del pipeline
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 Pipeline para 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

Ejercicio 1

Leer una matriz ajena

Un modelo de fraude deja esta matriz sobre 1 000 transacciones: TN 870, FP 40, FN 60, TP 30. Calcula accuracy, precision, recall y F1, y responde qué porcentaje de fraudes se escapa y por qué la accuracy no basta.
Pista

Los positivos reales son 60 + 30. La precision divide TP entre todas las predicciones positivas.

Completar código Completa la validación cruzada
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.

Ejercicio 2

Pipeline sin fugas y decisión razonada

Con el conjunto de cáncer de mama, construye un 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=y mantiene 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

Evaluación 1

Un modelo predice «sano» para todos los pacientes de un conjunto donde solo el 5 % está enfermo. ¿Qué ocurre?

Evaluación 2

Un filtro borra automáticamente los correos que clasifica como spam. ¿Qué error conviene vigilar primero?

Evaluación 3

¿Cuál de estas prácticas produce fuga de datos?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.