54  Validación y selección de modelos

Autor/a

Diego Villalba

El capítulo anterior respondió qué medir. Este responde cómo medirlo sin engañarse. Un número de desempeño se usa para tres cosas distintas, y las tres exigen datos que el modelo no haya visto:

  1. Estimar cuánto acertará el modelo en producción.
  2. Seleccionar: elegir entre algoritmos, hiperparámetros, variables o umbrales.
  3. Comparar dos modelos y decidir si la diferencia es real o producto del azar.

El problema de fondo es que los datos etiquetados son escasos y cada uso los «gasta»: un dato que sirvió para elegir algo ya no es un juez imparcial de esa elección. Este capítulo presenta las herramientas para administrar ese presupuesto: el holdout, la validación cruzada, la validación anidada, y las trampas más comunes, empezando por la fuga de datos (Hastie et al. 2009; Kohavi 1995).

54.1 Intuición: ¿qué clasificador elegirías?

Volvamos a los diez clientes del capítulo introductorio, pero ahora supongamos que no hay cinco clientes nuevos de prueba: solo tenemos los diez. Queremos elegir entre el tocón «endeudamiento ≤ \(t\)», el 1-NN y el 3-NN. La exactitud de entrenamiento recomienda el 1-NN (10/10), y ya sabemos que es la peor recomendación posible: el 1-NN memoriza.

La idea de todo este capítulo cabe en una frase: para juzgar a un modelo hay que esconderle datos, entrenarlo con el resto y preguntarle por los escondidos.

54.2 Construcción paso a paso

54.2.1 Paso 1. Esconder algunos datos: el holdout

Separamos tres clientes como validación, entrenamos con los siete restantes y contamos aciertos en los tres escondidos. El resultado depende mucho de cuáles tres se esconden:

Clientes escondidos Tocón 1-NN 3-NN ¿Cuál gana?
2, 5, 8 3/3 1/3 2/3 tocón
1, 4, 7 3/3 3/3 3/3 empate
6, 9, 10 2/3 2/3 1/3 tocón y 1-NN
Tabla 54.1: Tres particiones holdout distintas de los mismos diez clientes.

Con la segunda partición los tres clasificadores parecen igual de buenos; con la tercera, el 1-NN empata con el tocón. Una sola partición produce una estimación con varianza alta: con pocos datos de validación, el azar de la partición pesa tanto como la calidad del modelo.

54.2.2 Paso 2. Esconder cada dato una vez: dejar uno fuera

Para no depender de una partición, repetimos el procedimiento diez veces, escondiendo un cliente cada vez y entrenando con los otros nueve. Es la validación cruzada dejando uno fuera (LOOCV, leave-one-out). Para el 1-NN es fácil a mano: basta buscar el vecino más cercano de cada cliente sin contarlo a él mismo, usando las distancias del capítulo introductorio.

Cliente Clase Vecino más cercano (sin él) Distancia 1-NN 3 vecinos 3-NN Tocón
1 sí 2 11.18 sí ✓ 2, 3, 5 sí ✓ sí ✓
2 sí 3 8.60 sí ✓ 3, 1, 4 sí ✓ sí ✓
3 sí 2 8.60 sí ✓ 2, 4, 1 sí ✓ sí ✓
4 sí 3 11.66 sí ✓ 3, 2, 10 sí ✓ sí ✓
5 sí 2 14.14 sí ✓ 2, 10, 7 no ✗ sí ✓
6 sí 9 6.40 no ✗ 9, 8, 10 no ✗ no ✗
7 no 8 11.18 no ✓ 8, 5, 10 no ✓ no ✓
8 no 6 8.25 sí ✗ 6, 7, 9 no ✓ no ✓
9 no 6 6.40 sí ✗ 6, 8, 10 no ✓ no ✓
10 no 6 10.20 sí ✗ 6, 8, 5 sí ✗ no ✓
6/10 7/10 9/10
Tabla 54.2: Validación cruzada dejando uno fuera con los diez clientes.

Para el tocón hay que reajustar el corte cada vez: sin el cliente 5, el mejor corte se mueve a 41.5; sin el 10, a 47.5; en los demás casos sigue en 44. Solo falla con el cliente 6, la excepción.

54.2.3 Paso 3. Leer el resultado

Clasificador Exactitud en entrenamiento Exactitud LOOCV Exactitud en los 5 clientes nuevos
Tocón 0.90 0.90 0.80
1-NN 1.00 0.60 0.60
3-NN 0.90 0.70 0.60
Tabla 54.3: Entrenamiento, validación cruzada y prueba.

La validación cruzada invierte el orden que sugería el entrenamiento y coincide con lo que después mostraron los clientes nuevos: el tocón generaliza mejor. La tabla también explica por qué el 1-NN falla: el cliente 6, la excepción, es el vecino más cercano de los clientes 8, 9 y 10, y los «contagia». El 3-NN diluye parte de ese contagio; el tocón lo ignora.

54.2.4 Paso 4. De uno en uno a \(K\) pliegues

LOOCV entrena el modelo \(n\) veces, algo inviable con un millón de registros o modelos caros. La validación cruzada de \(K\) pliegues (K-fold) divide los datos al azar en \(K\) grupos del mismo tamaño; cada grupo se esconde una vez mientras se entrena con los otros \(K - 1\). Con \(K = 5\) y nuestros diez clientes, se esconderían dos clientes cada vez y se entrenaría cinco veces. LOOCV es el caso extremo \(K = n\).

54.3 Formalización

54.3.1 El estimador de validación cruzada

Sea \(\kappa(i) \in \{1, \ldots, K\}\) el pliegue al que pertenece la observación \(i\) y \(\hat{f}^{-k}\) el modelo entrenado sin el pliegue \(k\). La estimación por validación cruzada del riesgo es

\[ \widehat{R}_{\text{CV}} = \frac{1}{n} \sum_{i=1}^{n} L\big(y_i, \hat{f}^{-\kappa(i)}(\mathbf{x}_i)\big), \tag{54.1}\]

con \(L\) la pérdida 0-1 o cualquier otra métrica. Cada observación se predice exactamente una vez con un modelo que no la vio (Stone 1974).

El estimador tiene dos fuentes de error:

  • Sesgo. Cada modelo se entrena con \((K-1)n/K\) observaciones, no con \(n\); como los modelos suelen mejorar con más datos, \(\widehat{R}_{\text{CV}}\) tiende a ser algo pesimista. El sesgo es grande con \(K = 2\) y casi nulo con LOOCV.
  • Varianza. Depende de la partición aleatoria y del tamaño de cada pliegue. LOOCV no depende del azar, pero sus \(n\) modelos son casi idénticos y sus errores están muy correlacionados, lo que también puede dar varianza alta.

El estudio clásico de Kohavi (1995) recomienda \(K = 10\) estratificado como un buen compromiso; \(K = 5\) es la elección habitual cuando entrenar es costoso. Repetir la validación cruzada con varias particiones aleatorias (por ejemplo, 5 × 10 pliegues) y promediar reduce la varianza debida a la partición.

Estratificación. En clasificación conviene que cada pliegue conserve la proporción de clases del total; si no, con clases desbalanceadas algún pliegue puede quedarse sin positivos. Es la opción por defecto de scikit-learn para clasificadores.

Incertidumbre. Con los errores por pliegue \(e_1, \ldots, e_K\) suele reportarse \(\bar{e} \pm s_e/\sqrt{K}\), con \(s_e\) su desviación estándar. Es una aproximación: los pliegues comparten datos de entrenamiento y no son independientes, así que este error estándar subestima la incertidumbre real.

54.3.2 Tres conjuntos y un presupuesto

Cuando hay que elegir algo (hiperparámetros, variables, umbral) y además estimar el desempeño del elegido, hacen falta datos distintos para cada tarea:

Conjunto Para qué se usa Cuántas veces se mira
Entrenamiento Ajustar parámetros Muchas
Validación (o validación cruzada sobre el entrenamiento) Elegir hiperparámetros, variables, umbral Muchas
Prueba Estimar el desempeño del modelo final Una
Tabla 54.4: Papel de cada conjunto de datos.

La regla de oro: cualquier decisión tomada mirando un conjunto contamina a ese conjunto como estimador. Si se prueban cien configuraciones y se reporta la mejor exactitud de validación, ese número es optimista, porque la mejor configuración ganó en parte por suerte (Cawley y Talbot 2010).

54.3.3 Selección de hiperparámetros

Para un hiperparámetro \(\lambda\) (el \(k\) de \(k\)-NN, la profundidad de un árbol), se elige

\[ \hat{\lambda} = \arg\min_{\lambda \in \Lambda} \widehat{R}_{\text{CV}}(\lambda), \]

y después se reentrena con todo el conjunto de entrenamiento usando \(\hat{\lambda}\). Una variante más conservadora es la regla de un error estándar (Hastie et al. 2009): elegir el modelo más simple cuyo error de validación esté a menos de un error estándar del mínimo. Prefiere la simplicidad cuando la diferencia no es distinguible del ruido.

\(\Lambda\) puede recorrerse con una rejilla (todas las combinaciones de una lista de valores) o con búsqueda aleatoria, que muestrea combinaciones al azar. Cuando solo unos pocos hiperparámetros importan, la búsqueda aleatoria encuentra buenas configuraciones con muchas menos evaluaciones (Bergstra y Bengio 2012).

54.3.4 Validación anidada

Si no hay un conjunto de prueba separado y se quiere estimar el desempeño del procedimiento completo (incluida la selección de hiperparámetros), se usa validación cruzada anidada: un ciclo externo de \(K\) pliegues estima el desempeño y, dentro de cada pliegue externo, un ciclo interno elige los hiperparámetros solo con los datos de entrenamiento de ese pliegue. El número del ciclo externo estima qué tan bien funciona «entrenar con búsqueda de hiperparámetros», no un modelo concreto.

54.3.5 Datos con estructura

La validación cruzada supone que las observaciones son intercambiables. Dos casos frecuentes violan ese supuesto:

  • Grupos. Si hay varias filas por paciente, cliente o tienda, las filas del mismo grupo deben ir juntas al mismo pliegue (GroupKFold); si no, el modelo «reconoce» al paciente en lugar de aprender el patrón.
  • Tiempo. Con series temporales se entrena con el pasado y se valida con el futuro (TimeSeriesSplit). Mezclar al azar permite predecir el pasado con datos del futuro, algo imposible en producción.

54.4 Laboratorio: ¿cuánto se mueve la estimación?

El laboratorio usa los 120 puntos de entrenamiento y 400 de prueba del capítulo introductorio y el clasificador \(k\)-NN. El panel izquierdo repite 200 veces cada método de validación con particiones aleatorias distintas y muestra la distribución de las estimaciones; la línea verde es el error del mismo modelo entrenado con todos los datos y medido en los 400 puntos de prueba, nuestra mejor aproximación al error real. El panel derecho usa validación cruzada de 10 pliegues para elegir \(k\) sin mirar la prueba, la tarea que quedó pendiente en el capítulo introductorio.

Laboratorio · Validación

¿Cuánto se mueve la estimación y qué k elige la validación cruzada?

Elige un método de validación y observa la dispersión de sus estimaciones. Cambia el tamaño de la muestra y pide nuevas particiones.

Método de validación
Azar
Promedio de las estimaciones
Desviación estándar
Error en los 400 de prueba
k elegido por 10 pliegues

200 estimaciones del error

estimacionespromedioerror en prueba

Curva de validación (10 pliegues)

validación cruzadaprueba (solo para comparar)mínimo CVregla de 1 error estándar

Resumen de los cinco métodos (mismo k y n)

Guía del laboratorio.

  1. Con Holdout 30 %, observa el ancho del histograma. Si te tocara una sola de esas 200 estimaciones, ¿entre qué valores podría estar?
  2. Predice antes de mover: ¿qué método tendrá la menor desviación estándar, 5 o 10 pliegues? Compruébalo en la tabla de resumen.
  3. Compara el promedio de Holdout 50 % con el error de prueba. ¿Por qué el holdout del 50 % es pesimista? Pista: ¿con cuántos datos entrena cada modelo?
  4. Baja \(n\) a 40. ¿Qué le pasa a la dispersión de todos los métodos? ¿Y a la curva de validación?
  5. Pulsa Nuevas particiones varias veces y anota el \(k\) que elige la validación cruzada. ¿Cambia? ¿Cambia mucho el error de prueba del \(k\) elegido?
  6. Explica por qué la curva morada (prueba) se muestra solo para comparar y no debe usarse para elegir \(k\).

54.5 Implementación en Python

54.5.1 La validación cruzada a mano y con scikit-learn

cross_val_score y cross_val_predict implementan la Ecuación 54.1 con cualquier esquema de partición. Reproducimos la Tabla 54.2 con LeaveOneOut:

Mostrar código
import numpy as np
import pandas as pd
from sklearn.model_selection import LeaveOneOut, cross_val_predict, cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier

clientes = pd.DataFrame({
    "ingreso": [20, 25, 32, 38, 15, 30, 12, 22, 35, 28],
    "deuda":   [20, 30, 25, 35, 40, 58, 55, 60, 62, 48],
    "paga":    [1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
}, index=range(1, 11))
X, y = clientes[["ingreso", "deuda"]], clientes["paga"]

modelos = {"Tocón": DecisionTreeClassifier(max_depth=1, random_state=0),
           "1-NN": KNeighborsClassifier(n_neighbors=1),
           "3-NN": KNeighborsClassifier(n_neighbors=3)}
pred = pd.DataFrame({nombre: cross_val_predict(m, X, y, cv=LeaveOneOut()) for nombre, m in modelos.items()},
                    index=clientes.index)
pred.insert(0, "real", y)
print(pred.T.to_string())
print("\nExactitud LOOCV:", {n: int((pred[n] == y).sum()) for n in modelos}, "de 10")
       1   2   3   4   5   6   7   8   9   10
real    1   1   1   1   1   1   0   0   0   0
Tocón   1   1   1   1   1   0   0   0   0   0
1-NN    1   1   1   1   1   0   0   1   1   1
3-NN    1   1   1   1   0   0   0   0   0   1

Exactitud LOOCV: {'Tocón': 9, '1-NN': 6, '3-NN': 7} de 10

54.5.2 Varianza de las estimaciones

Con los datos del laboratorio comparamos la dispersión del holdout y de la validación cruzada repitiendo cada método con 200 semillas.

Mostrar código
import plotly.graph_objects as go
from sklearn.model_selection import train_test_split, KFold

def prob_clase1(X):
    return 1 / (1 + np.exp(-1.6 * (X[:, 1] - 5 - 2 * np.sin(0.8 * X[:, 0]))))

def generar(n, semilla):
    rng = np.random.default_rng(semilla)
    X = rng.uniform(0, 10, size=(n, 2)).round(2)
    return X, (rng.uniform(size=n) < prob_clase1(X)).astype(int)

X_big, y_big = generar(120, 1)
X_test, y_test = generar(400, 2)
knn5 = KNeighborsClassifier(n_neighbors=5, algorithm="brute")
error_real = 1 - knn5.fit(X_big, y_big).score(X_test, y_test)

holdout, kfold = [], []
for s in range(200):
    Xa, Xb, ya, yb = train_test_split(X_big, y_big, test_size=0.3, random_state=s)
    holdout.append(1 - knn5.fit(Xa, ya).score(Xb, yb))
    kfold.append(1 - cross_val_score(knn5, X_big, y_big, cv=KFold(10, shuffle=True, random_state=s)).mean())
loo = 1 - cross_val_score(knn5, X_big, y_big, cv=LeaveOneOut()).mean()

fig = go.Figure()
fig.add_trace(go.Histogram(x=holdout, name=f"holdout 30 % (desv. {np.std(holdout, ddof=1):.3f})", opacity=0.6, marker_color="#ea580c", xbins=dict(size=0.015)))
fig.add_trace(go.Histogram(x=kfold, name=f"10 pliegues (desv. {np.std(kfold, ddof=1):.3f})", opacity=0.6, marker_color="#2563eb", xbins=dict(size=0.015)))
fig.add_vline(x=error_real, line=dict(color="#16a34a", dash="dash"), annotation_text=f"prueba: {error_real:.3f}")
fig.update_layout(barmode="overlay", xaxis_title="error estimado", yaxis_title="repeticiones", height=360,
                  margin=dict(l=10, r=10, t=20, b=40), legend=dict(x=0.55, y=0.95))
fig.show()
print(f"LOOCV (determinista): {loo:.3f}")
Figura 54.1: Distribución de 200 estimaciones del error de 5-NN con holdout del 30 % y con validación cruzada de 10 pliegues repetida. La línea verde es el error del modelo entrenado con los 120 puntos y medido en los 400 de prueba.
LOOCV (determinista): 0.108

54.5.3 Curva de validación y curva de aprendizaje

Una curva de validación grafica el error de entrenamiento y de validación cruzada contra un hiperparámetro; una curva de aprendizaje, contra el tamaño del entrenamiento. La primera diagnostica la complejidad; la segunda responde si conviene conseguir más datos: si las dos curvas ya convergieron, más datos no ayudarán; si siguen separadas, sí.

Mostrar código
from plotly.subplots import make_subplots
from sklearn.model_selection import validation_curve, learning_curve, StratifiedKFold

cv10 = StratifiedKFold(10, shuffle=True, random_state=0)
ks = np.arange(1, 40, 2)
tr_sc, va_sc = validation_curve(KNeighborsClassifier(algorithm="brute"), X_big, y_big,
                                param_name="n_neighbors", param_range=ks, cv=cv10)
X_all, y_all = np.vstack([X_big, X_test]), np.concatenate([y_big, y_test])
tamanos, tr_lc, va_lc = learning_curve(KNeighborsClassifier(15), X_all, y_all, cv=cv10,
                                       train_sizes=np.linspace(0.1, 1.0, 8), shuffle=True, random_state=0)

fig = make_subplots(rows=1, cols=2, subplot_titles=("Curva de validación (k-NN)", "Curva de aprendizaje (15-NN)"))
for col, x, a, b in [(1, ks, tr_sc, va_sc), (2, tamanos, tr_lc, va_lc)]:
    fig.add_trace(go.Scatter(x=x, y=1 - a.mean(1), name="entrenamiento", line=dict(color="#2563eb"), showlegend=col == 1), row=1, col=col)
    fig.add_trace(go.Scatter(x=x, y=1 - b.mean(1), name="validación cruzada", line=dict(color="#7c3aed"),
                             error_y=dict(type="data", array=b.std(1) / np.sqrt(10)), showlegend=col == 1), row=1, col=col)
fig.update_xaxes(title_text="k", row=1, col=1); fig.update_xaxes(title_text="tamaño del entrenamiento", row=1, col=2)
fig.update_yaxes(title_text="error", row=1, col=1)
fig.update_layout(height=380, margin=dict(l=10, r=10, t=40, b=40), legend=dict(orientation="h", y=-0.25))
fig.show()
Figura 54.2: Izquierda: curva de validación de k-NN (10 pliegues estratificados). Derecha: curva de aprendizaje de 15-NN en los mismos datos ampliados a 520 puntos. La brecha entre entrenamiento y validación es la varianza; el nivel al que convergen, el sesgo más el error irreducible.

54.5.4 Búsqueda de hiperparámetros y validación anidada

Usamos el conjunto breast cancer (569 tumores, 30 variables) y un pipeline que escala y aplica \(k\)-NN. GridSearchCV hace la validación cruzada interna; envolverlo en cross_val_score da la validación anidada.

Mostrar código
from sklearn.datasets import load_breast_cancer
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from scipy.stats import randint

Xc, yc = load_breast_cancer(return_X_y=True)
pipe = make_pipeline(StandardScaler(), KNeighborsClassifier())
rejilla = {"kneighborsclassifier__n_neighbors": list(range(1, 32, 2)),
           "kneighborsclassifier__weights": ["uniform", "distance"]}
interna, externa = StratifiedKFold(5, shuffle=True, random_state=1), StratifiedKFold(5, shuffle=True, random_state=2)

busqueda = GridSearchCV(pipe, rejilla, cv=interna, scoring="balanced_accuracy").fit(Xc, yc)
anidada = cross_val_score(GridSearchCV(pipe, rejilla, cv=interna, scoring="balanced_accuracy"),
                          Xc, yc, cv=externa, scoring="balanced_accuracy")
aleatoria = RandomizedSearchCV(pipe, {"kneighborsclassifier__n_neighbors": randint(1, 40),
                                      "kneighborsclassifier__weights": ["uniform", "distance"]},
                               n_iter=10, cv=interna, scoring="balanced_accuracy", random_state=0).fit(Xc, yc)

print(f"Rejilla: {len(busqueda.cv_results_['params'])} configuraciones; mejor {busqueda.best_params_}")
print(f"  mejor exactitud balanceada de validación (optimista): {busqueda.best_score_:.4f}")
print(f"  validación anidada (honesta): {anidada.mean():.4f} ± {anidada.std(ddof=1) / np.sqrt(5):.4f}")
print(f"Búsqueda aleatoria con 10 configuraciones: mejor {aleatoria.best_score_:.4f}")
Rejilla: 32 configuraciones; mejor {'kneighborsclassifier__n_neighbors': 9, 'kneighborsclassifier__weights': 'uniform'}
  mejor exactitud balanceada de validación (optimista): 0.9617
  validación anidada (honesta): 0.9604 ± 0.0129
Búsqueda aleatoria con 10 configuraciones: mejor 0.9640

La diferencia entre el mejor puntaje de la rejilla y la validación anidada es el optimismo de la selección. Aquí es pequeña porque la rejilla es pequeña y el problema fácil; con cientos de configuraciones y pocos datos puede ser de varios puntos porcentuales.

54.6 La fuga de datos

La fuga de datos ocurre cuando información de los datos de validación o prueba se filtra al entrenamiento. Sus formas más comunes son:

  • Preprocesar antes de separar: escalar, imputar o seleccionar variables con todos los datos.
  • Variables del futuro: usar una columna que solo se conoce después del evento a predecir (la fecha de cierre de una cuenta para predecir si se cerrará).
  • Duplicados o grupos repartidos entre entrenamiento y prueba.
  • Elegir con la prueba: probar muchos modelos y reportar el mejor resultado de prueba.

El siguiente experimento es un clásico (Hastie et al. 2009). Generamos 100 casos con 5000 variables de puro ruido y una clase al azar: ningún clasificador puede superar el 50 %. Si seleccionamos las 20 variables más correlacionadas con la clase antes de la validación cruzada, la estimación es espectacular; si la selección va dentro del pipeline, vuelve a la realidad.

Mostrar código
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression

rng = np.random.default_rng(0)
X_ruido = rng.normal(size=(100, 5000))
y_ruido = rng.integers(0, 2, size=100)
cv5 = StratifiedKFold(5, shuffle=True, random_state=0)

# Mal: la selección ve todas las etiquetas, incluidas las de validación
X_sel = SelectKBest(f_classif, k=20).fit_transform(X_ruido, y_ruido)
mal = cross_val_score(LogisticRegression(max_iter=1000), X_sel, y_ruido, cv=cv5).mean()

# Bien: la selección se reajusta dentro de cada pliegue
bien = cross_val_score(make_pipeline(SelectKBest(f_classif, k=20), LogisticRegression(max_iter=1000)),
                       X_ruido, y_ruido, cv=cv5).mean()
print(f"Selección fuera del pipeline: exactitud CV = {mal:.3f}   <- fuga")
print(f"Selección dentro del pipeline: exactitud CV = {bien:.3f}  <- honesta (azar = 0.5)")
Selección fuera del pipeline: exactitud CV = 0.870   <- fuga
Selección dentro del pipeline: exactitud CV = 0.500  <- honesta (azar = 0.5)

La regla práctica: todo paso que aprenda algo de los datos va dentro del pipeline, para que se reajuste en cada pliegue con los datos de entrenamiento de ese pliegue.

54.7 Clases desbalanceadas

Cuando la clase positiva es rara, además de elegir bien la métrica (capítulo anterior) hay cuatro palancas:

  1. Estratificar todas las particiones.
  2. Pesar las clases en la función de pérdida (class_weight="balanced"), de modo que cada positivo cuente tanto como \(n_-/n_+\) negativos.
  3. Remuestrear el entrenamiento: submuestrear la clase mayoritaria, sobremuestrear la minoritaria o generar positivos sintéticos con SMOTE, que interpola entre vecinos de la clase minoritaria (Chawla et al. 2002). El remuestreo se aplica solo dentro del entrenamiento de cada pliegue; remuestrear antes de separar es otra forma de fuga.
  4. Mover el umbral, como en el capítulo de métricas.

Con frecuencia, pesar las clases o mover el umbral logra lo mismo que remuestrear, sin alterar los datos:

Mostrar código
from sklearn.datasets import make_classification
from sklearn.model_selection import cross_validate

Xd, yd = make_classification(n_samples=3000, n_features=12, n_informative=6, n_redundant=2,
                             weights=[0.95, 0.05], flip_y=0.01, class_sep=0.8, random_state=3)
cv = StratifiedKFold(5, shuffle=True, random_state=0)
metricas = ["accuracy", "balanced_accuracy", "recall", "precision", "average_precision"]
filas = []
for nombre, modelo in [("sin ajuste", LogisticRegression(max_iter=1000)),
                       ("class_weight='balanced'", LogisticRegression(max_iter=1000, class_weight="balanced"))]:
    r = cross_validate(make_pipeline(StandardScaler(), modelo), Xd, yd, cv=cv, scoring=metricas)
    filas.append({"modelo": nombre, **{m: r[f"test_{m}"].mean() for m in metricas}})
print(f"Prevalencia de la clase positiva: {yd.mean():.3f}")
pd.DataFrame(filas).set_index("modelo").round(3)
Prevalencia de la clase positiva: 0.056
accuracy balanced_accuracy recall precision average_precision
modelo
sin ajuste 0.955 0.636 0.276 0.831 0.534
class_weight='balanced' 0.804 0.786 0.766 0.191 0.493

Pesar las clases sube la sensibilidad y la exactitud balanceada a costa de precisión, mientras que la precisión promedio, que no depende del umbral, cambia poco: el ranking del modelo es casi el mismo; lo que cambia es dónde se corta.

54.8 Calibración de probabilidades

Un clasificador está calibrado si, entre los casos a los que asigna probabilidad 0.3, alrededor del 30 % son positivos. La calibración importa cuando la probabilidad se usa como tal: para fijar precios, combinar modelos o aplicar el umbral teórico \(C_{FP}/(C_{FP} + C_{FN})\). Se diagnostica con un diagrama de calibración: se agrupan los casos por probabilidad predicha y se grafica la fracción real de positivos de cada grupo; un modelo calibrado sigue la diagonal.

Muchos clasificadores no están calibrados: Naive Bayes empuja las probabilidades a los extremos; los bosques aleatorios y las máquinas de soporte vectorial las concentran en el centro (Niculescu-Mizil y Caruana 2005). Se corrigen con un recalibrador ajustado en datos de validación: la escala de Platt ajusta una regresión logística sobre el puntaje (Platt 1999) y la regresión isotónica ajusta una función escalonada creciente, más flexible pero más necesitada de datos.

Mostrar código
from sklearn.naive_bayes import GaussianNB
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
from sklearn.metrics import brier_score_loss

Xk, yk = make_classification(n_samples=6000, n_features=20, n_informative=4, n_redundant=10,
                             random_state=4)
Xk_tr, Xk_te, yk_tr, yk_te = train_test_split(Xk, yk, test_size=0.5, random_state=0)
modelos_cal = {"Naive Bayes": GaussianNB(),
               "Naive Bayes calibrado (isotónico)": CalibratedClassifierCV(GaussianNB(), method="isotonic", cv=5),
               "Regresión logística": LogisticRegression(max_iter=1000)}
fig = go.Figure()
fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], line=dict(color="gray", dash="dash"), name="calibración perfecta"))
for (nombre, m), color in zip(modelos_cal.items(), ["#ea580c", "#2563eb", "#16a34a"]):
    p = m.fit(Xk_tr, yk_tr).predict_proba(Xk_te)[:, 1]
    frac, media = calibration_curve(yk_te, p, n_bins=10)
    fig.add_trace(go.Scatter(x=media, y=frac, mode="lines+markers", line=dict(color=color),
                             name=f"{nombre} (Brier {brier_score_loss(yk_te, p):.3f})"))
fig.update_layout(xaxis_title="probabilidad predicha (promedio del grupo)", yaxis_title="fracción real de positivos",
                  height=400, margin=dict(l=10, r=10, t=20, b=40), legend=dict(x=0.01, y=0.99))
fig.show()
Figura 54.3: Diagrama de calibración en datos de prueba. Naive Bayes es demasiado extremo; tras la calibración isotónica (con validación cruzada interna), sus probabilidades siguen mucho mejor la diagonal y su puntaje de Brier baja.

54.9 Comparar dos clasificadores

Si el modelo A acierta 87 % y el B 85 % en el mismo conjunto de prueba, ¿A es mejor? Depende de cuántos casos haya y de en qué casos difieren. La prueba de McNemar (McNemar 1947) compara dos clasificadores evaluados en los mismos casos usando solo los desacuerdos. Supongamos 200 casos de prueba:

B acierta B falla
A acierta 150 \(n_{10} = 25\)
A falla \(n_{01} = 10\) 15
Tabla 54.5: Aciertos y fallos de dos clasificadores sobre los mismos 200 casos.

Si ambos fueran igual de buenos, los 35 desacuerdos se repartirían mitad y mitad. El estadístico con corrección de continuidad es

\[ \chi^2 = \frac{(|n_{10} - n_{01}| - 1)^2}{n_{10} + n_{01}} = \frac{(15 - 1)^2}{35} = 5.6, \]

mayor que 3.84, el valor crítico de una \(\chi^2\) con un grado de libertad al 5 %: la diferencia es significativa. Observa que las exactitudes son \(175/200 = 0.875\) y \(160/200 = 0.80\), pero lo que decide es cómo se reparten los 35 casos en que discrepan.

Para comparar algoritmos (no un par de modelos ya entrenados) con validación cruzada, la diferencia por pliegue tiene varianza subestimada porque los pliegues comparten datos; Dietterich (1998) recomienda la prueba \(5 \times 2\) CV o McNemar sobre un conjunto de prueba. En la práctica, además de la significancia hay que preguntarse si la diferencia importa al negocio.

Mostrar código
from scipy.stats import chi2, binomtest
n10, n01 = 25, 10
estadistico = (abs(n10 - n01) - 1) ** 2 / (n10 + n01)
print(f"McNemar: chi2 = {estadistico:.2f}, p = {chi2.sf(estadistico, df=1):.4f}")
print(f"Versión exacta (binomial): p = {binomtest(n10, n10 + n01, 0.5).pvalue:.4f}")
McNemar: chi2 = 5.60, p = 0.0180
Versión exacta (binomial): p = 0.0167

54.10 Lista de verificación

  1. Definir clase positiva, métrica y referencia antes de mirar resultados.
  2. Separar la prueba primero; estratificar; respetar grupos y tiempo.
  3. Meter todo el preprocesamiento en un pipeline.
  4. Elegir hiperparámetros, variables y umbral con validación cruzada sobre el entrenamiento.
  5. Reportar la estimación con su incertidumbre; usar validación anidada si no hay prueba independiente.
  6. Revisar calibración si las probabilidades se usarán como tales.
  7. Evaluar una vez en prueba y comparar con la referencia y con un modelo simple.

54.11 Resumen

  • Estimar, seleccionar y comparar exigen datos que el modelo no haya visto; cada decisión «gasta» los datos con que se tomó.
  • El holdout es simple pero de varianza alta. La validación cruzada de \(K\) pliegues (5 o 10, estratificada) usa cada dato una vez para validar; LOOCV es el caso \(K = n\).
  • Los hiperparámetros se eligen con validación cruzada; la regla de un error estándar prefiere el modelo más simple compatible con el mínimo. El mejor puntaje de una búsqueda es optimista: la validación anidada lo corrige.
  • La fuga de datos produce estimaciones espectaculares y falsas; todo lo que aprende de los datos va dentro del pipeline.
  • Con desbalance: estratificar, pesar clases o remuestrear dentro de cada pliegue, y mover el umbral.
  • La calibración se diagnostica con un diagrama de calibración y se corrige con Platt o regresión isotónica.
  • Para comparar dos clasificadores en los mismos casos, la prueba de McNemar usa solo los desacuerdos.

Referencias