Evaluar bien: métricas, matriz de confusión y curva ROC

Tema 9 · Fase 2, Aprender de ejemplos · Toda la ruta

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 20 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; en la ficha Teoría está la explicación de cada uno.

Cuatro maneras de acertar o fallar

import numpy as np

# 12 personas (datos inventados): 1 = enfermo, 0 = sano
y_real = np.array([1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0])
y_pred = np.array([1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0])

vp = np.sum((y_real == 1) & (y_pred == 1))
fp = np.sum((y_real == 0) & (y_pred == 1))
vn = np.sum((y_real == 0) & (y_pred == 0))
fn = np.sum((y_real == 1) & (y_pred == 0))
print(f"VP = {vp}   FP = {fp}   VN = {vn}   FN = {fn}")

# La matriz de confusión: filas = lo real, columnas = lo predicho
matriz = np.array([[vn, fp],
                   [fn, vp]])
print(matriz)
from sklearn.metrics import confusion_matrix

m = confusion_matrix(y_real, y_pred)
print(m)
vn, fp, fn, vp = m.ravel()
print("Iguales a las nuestras:", np.array_equal(m, matriz))

Cuatro métricas

from sklearn.metrics import (accuracy_score, precision_score,
                             recall_score, f1_score)

exactitud = (vp + vn) / (vp + vn + fp + fn)
precision = vp / (vp + fp)
exhaustividad = vp / (vp + fn)
f1 = 2 * precision * exhaustividad / (precision + exhaustividad)

print(f"Exactitud:     {exactitud:.3f}"
      f"   scikit-learn: {accuracy_score(y_real, y_pred):.3f}")
print(f"Precisión:     {precision:.3f}"
      f"   scikit-learn: {precision_score(y_real, y_pred):.3f}")
print(f"Exhaustividad: {exhaustividad:.3f}"
      f"   scikit-learn: {recall_score(y_real, y_pred):.3f}")
print(f"F1:            {f1:.3f}"
      f"   scikit-learn: {f1_score(y_real, y_pred):.3f}")

Un caso real: tumores benignos y malignos

import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import ConfusionMatrixDisplay

datos = load_breast_cancer(as_frame=True)
X = datos.data
# En el original 1 = benigno; lo invertimos: 1 = maligno (positivo)
y = (datos.target == 0).astype(int)
print(f"{len(y)} tumores, {y.sum()} malignos ({y.mean():.0%})")

X_ent, X_prueba, y_ent, y_prueba = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=0)
escala = StandardScaler().fit(X_ent)
X_ent_e = escala.transform(X_ent)
X_prueba_e = escala.transform(X_prueba)

modelo = LogisticRegression().fit(X_ent_e, y_ent)
pred = modelo.predict(X_prueba_e)
print(confusion_matrix(y_prueba, pred))

ConfusionMatrixDisplay.from_predictions(
    y_prueba, pred, display_labels=["benigno", "maligno"],
    cmap="Blues", colorbar=False)
plt.grid(False)
plt.xlabel("Lo que dice el modelo")
plt.ylabel("Lo real")
plt.title("Matriz de confusión (prueba)")
plt.show()
from sklearn.metrics import classification_report

print(classification_report(y_prueba, pred,
                            target_names=["benigno", "maligno"]))

La trampa de la exactitud: clases desbalanceadas

import pandas as pd
from sklearn.datasets import make_classification
from sklearn.dummy import DummyClassifier

# weights=[0.95]: el 95 % de los casos son de la clase 0
X_d, y_d = make_classification(n_samples=2000, weights=[0.95],
                               random_state=0)
print(f"Positivos: {y_d.sum()} de {len(y_d)} ({y_d.mean():.1%})")
Xd_ent, Xd_prueba, yd_ent, yd_prueba = train_test_split(
    X_d, y_d, test_size=0.3, stratify=y_d, random_state=0)

modelos = {
    "tonto": DummyClassifier(strategy="most_frequent"),
    "logística": LogisticRegression(),
    "balanceada": LogisticRegression(class_weight="balanced"),
}
filas = {}
for nombre, mod in modelos.items():
    p = mod.fit(Xd_ent, yd_ent).predict(Xd_prueba)
    filas[nombre] = {
        "exactitud": accuracy_score(yd_prueba, p),
        # zero_division=0: si nunca dice «positivo», precisión 0
        "precisión": precision_score(yd_prueba, p, zero_division=0),
        "exhaustividad": recall_score(yd_prueba, p),
        "F1": f1_score(yd_prueba, p),
    }
tabla = pd.DataFrame(filas).T
print(tabla.round(3))

nombres = list(tabla.columns)[::-1]
pos = np.arange(len(nombres))
plt.barh(pos + 0.2, tabla.loc["tonto", nombres], height=0.4,
         color="C1", label="Modelo tonto")
plt.barh(pos - 0.2, tabla.loc["logística", nombres], height=0.4,
         color="C0", label="Logística")
plt.yticks(pos, nombres)
plt.xlim(0, 1)
plt.xlabel("Valor de la métrica")
plt.title("Exactitud alta, cero detecciones")
plt.legend(loc="upper center", bbox_to_anchor=(0.4, -0.3), ncol=2)
plt.show()

El umbral es una decisión

proba = modelo.predict_proba(X_prueba_e)[:, 1]   # P(maligno)

for umbral in [0.5, 0.3, 0.1]:
    p = (proba >= umbral).astype(int)
    vn, fp, fn, vp = confusion_matrix(y_prueba, p).ravel()
    print(f"umbral {umbral}:  VP={vp} FN={fn} FP={fp} VN={vn}"
          f"   precisión={precision_score(y_prueba, p):.2f}"
          f"  exhaustividad={recall_score(y_prueba, p):.2f}")
from sklearn.metrics import precision_recall_curve

prec, exh, umbrales = precision_recall_curve(y_prueba, proba)

# prec y exh tienen un valor más que umbrales: se descarta el último
plt.plot(umbrales, prec[:-1], color="C0", label="Precisión")
plt.plot(umbrales, exh[:-1], color="C1", label="Exhaustividad")
plt.axvline(0.5, color="C2", linestyle="--", linewidth=1.5)
plt.xlabel("Umbral")
plt.ylabel("Valor de la métrica")
plt.ylim(0.5, 1.02)
plt.title("Precisión y exhaustividad")
plt.legend(loc="lower right", bbox_to_anchor=(0.93, 0.02))
plt.show()

La curva ROC y el AUC

from sklearn.metrics import roc_curve, roc_auc_score

# Un modelo más débil: solo la columna «mean texture»
col = list(X.columns).index("mean texture")
debil = LogisticRegression().fit(X_ent_e[:, [col]], y_ent)
proba_debil = debil.predict_proba(X_prueba_e[:, [col]])[:, 1]

for nombre, pr, color in [("Textura", proba_debil, "C0"),
                          ("30 medidas", proba, "C1")]:
    tfp, tvp, _ = roc_curve(y_prueba, pr)
    auc = roc_auc_score(y_prueba, pr)
    print(f"{nombre}: AUC = {auc:.3f}")
    plt.plot(tfp, tvp, color=color, label=f"{nombre} (AUC {auc:.2f})")

plt.plot([0, 1], [0, 1], color="C2", linestyle="--", linewidth=1.5)
plt.xlabel("Tasa de falsos positivos")
plt.ylabel("Tasa de verdaderos positivos")
plt.title("Curva ROC de dos modelos")
plt.legend(loc="lower right", fontsize=14)
plt.show()
pos = proba[y_prueba == 1]          # puntajes de los malignos
neg = proba[y_prueba == 0]          # puntajes de los benignos
# Una tabla de 64 × 107: ¿el maligno tiene más puntaje que el benigno?
gana = pos[:, None] > neg[None, :]
empate = pos[:, None] == neg[None, :]
print(f"Pares comparados: {gana.size}")
print(f"AUC contando pares: {gana.mean() + 0.5 * empate.mean():.4f}")
print(f"AUC de scikit-learn: {roc_auc_score(y_prueba, proba):.4f}")

Más de dos clases: macro y micro

X3, y3 = make_classification(n_samples=1500, n_classes=3,
                             n_informative=4, weights=[0.8, 0.15, 0.05],
                             random_state=0)
X3_ent, X3_prueba, y3_ent, y3_prueba = train_test_split(
    X3, y3, test_size=0.3, stratify=y3, random_state=0)
p3 = LogisticRegression().fit(X3_ent, y3_ent).predict(X3_prueba)

print(confusion_matrix(y3_prueba, p3))
print(classification_report(y3_prueba, p3))
print(f"F1 macro: {f1_score(y3_prueba, p3, average='macro'):.3f}")
print(f"F1 micro: {f1_score(y3_prueba, p3, average='micro'):.3f}")

Diez clases: los dígitos del 0 al 9

from sklearn.datasets import load_digits

digitos = load_digits()
X10 = digitos.data / 16           # 64 píxeles entre 0 y 1, como en el tema 8
y10 = digitos.target              # el dígito, de 0 a 9
# 60 % para entrenar; el resto, mitad validación y mitad prueba
X10_ent, X10_resto, y10_ent, y10_resto = train_test_split(
    X10, y10, test_size=0.4, stratify=y10, random_state=0)
X10_val, X10_prueba, y10_val, y10_prueba = train_test_split(
    X10_resto, y10_resto, test_size=0.5, stratify=y10_resto,
    random_state=0)
print(len(y10_ent), "para entrenar,", len(y10_val), "de validación y",
      len(y10_prueba), "de prueba")

for C in [0.1, 1, 10, 100]:
    m = LogisticRegression(C=C, max_iter=1000).fit(X10_ent, y10_ent)
    print(f"C = {C:5}: exactitud en validación "
          f"{m.score(X10_val, y10_val):.3f}")
from matplotlib.colors import LinearSegmentedColormap
from matplotlib.patches import Rectangle

log10 = LogisticRegression(C=10, max_iter=1000).fit(X10_ent, y10_ent)
pred10 = log10.predict(X10_prueba)        # la prueba, una sola vez
cm10 = confusion_matrix(y10_prueba, pred10)
print(cm10)

errores10 = cm10.copy()
np.fill_diagonal(errores10, 0)            # borra los aciertos
# argmax da la posición en la tabla aplanada; unravel_index la
# convierte en (fila, columna)
fila, col = np.unravel_index(errores10.argmax(), errores10.shape)
print(f"Confusión más frecuente: un {fila} leído como {col},"
      f" {errores10[fila, col]} veces")

# Escala de blanco a tinta, como en el tema 8
blanco_tinta = LinearSegmentedColormap.from_list("bt", ["white", "C0"])
fig, eje = plt.subplots(figsize=(4.8, 4.8))
disp = ConfusionMatrixDisplay(cm10).plot(
    ax=eje, cmap=blanco_tinta, colorbar=False, text_kw={"fontsize": 12})
# Solo para dibujar: ocultar los ceros y marcar la confusión
for texto in disp.text_.ravel():
    if texto.get_text() == "0":
        texto.set_text("")
eje.add_patch(Rectangle((col - 0.5, fila - 0.5), 1, 1, fill=False,
                        edgecolor="C1", linewidth=3))
eje.grid(False)
eje.set_xlabel("Lo que dice el modelo")
eje.set_ylabel("El dígito real")
eje.set_title("Dígitos de prueba")
plt.show()
print(classification_report(y10_prueba, pred10))

# average=None: una exhaustividad por clase, sin promediar
exh10 = recall_score(y10_prueba, pred10, average=None)
peor = exh10.argmin()
print(f"Exactitud global:   {accuracy_score(y10_prueba, pred10):.3f}")
print(f"Peor exhaustividad: {exh10[peor]:.3f} (el dígito {peor})")
proba10 = log10.predict_proba(X10_prueba)     # una fila por imagen
# La probabilidad del dígito elegido, para cada imagen
seguridad = proba10.max(axis=1)
i = seguridad.argmin()                        # el caso más dudoso
print("Probabilidades, del 0 al 9:")
print(np.round(proba10[i], 3))
print(f"Suman {proba10[i].sum():.3f}; la mayor es la del",
      proba10[i].argmax(), "  predict dice", pred10[i],
      "  y era un", y10_prueba[i])

Medir las probabilidades: log-loss

from sklearn.metrics import log_loss

print(f"Log-loss, 30 medidas:      {log_loss(y_prueba, proba):.3f}")
print(f"Log-loss, solo la textura: {log_loss(y_prueba, proba_debil):.3f}")
# Un modelo que siempre dice «la proporción de malignos»
base = np.full(len(y_prueba), y_ent.mean())
print(f"Log-loss, sin mirar nada:  {log_loss(y_prueba, base):.3f}")

¿Se puede creer en un 80 %? Calibración

from sklearn.neighbors import KNeighborsClassifier
from sklearn.calibration import calibration_curve
from sklearn.metrics import brier_score_loss

Xc, yc = make_classification(n_samples=6000, n_informative=5,
                             random_state=0)
# Mitad para ajustar; el resto, mitad para calibrar y mitad para prueba
Xc_aj, Xc_resto, yc_aj, yc_resto = train_test_split(
    Xc, yc, test_size=0.5, random_state=0)
Xc_cal, Xc_prueba, yc_cal, yc_prueba = train_test_split(
    Xc_resto, yc_resto, test_size=0.5, random_state=0)

log_c = LogisticRegression().fit(Xc_aj, yc_aj)
knn_c = KNeighborsClassifier(n_neighbors=5).fit(Xc_aj, yc_aj)
p_log = log_c.predict_proba(Xc_cal)[:, 1]
p_knn = knn_c.predict_proba(Xc_cal)[:, 1]
# np.unique: los valores distintos, de menor a mayor
print("El k-NN solo dice:", np.unique(p_knn))
for v in [0, 1]:
    print(f"Cuando dice {v}, eran positivos: "
          f"{yc_cal[p_knn == v].mean():.1%}")

for nombre, p, color in [("Logística", p_log, "C0"),
                         ("k-NN, k = 5", p_knn, "C1")]:
    print(f"{nombre}: Brier {brier_score_loss(yc_cal, p):.3f}"
          f"   log-loss {log_loss(yc_cal, p):.3f}")
    # Devuelve primero lo real y después lo que dijo el modelo
    real, dicho = calibration_curve(yc_cal, p, n_bins=10)
    plt.plot(dicho, real, marker="o", color=color, label=nombre)

plt.plot([0, 1], [0, 1], color="C2", linestyle="--", linewidth=1.5)
plt.xlabel("Probabilidad que dice el modelo")
plt.ylabel("Fracción de positivos")
plt.title("Diagrama de fiabilidad")
plt.legend(loc="upper left")
plt.show()
from sklearn.calibration import CalibratedClassifierCV
from sklearn.frozen import FrozenEstimator

modelos_c = {"logística": log_c, "k-NN": knn_c}
for metodo in ["sigmoid", "isotonic"]:
    cal = CalibratedClassifierCV(FrozenEstimator(knn_c), method=metodo)
    modelos_c[f"k-NN {metodo}"] = cal.fit(Xc_cal, yc_cal)

filas = {}
for nombre, mod in modelos_c.items():
    p = mod.predict_proba(Xc_prueba)[:, 1]
    filas[nombre] = {
        "Brier": brier_score_loss(yc_prueba, p),
        "log-loss": log_loss(yc_prueba, p),
        "exactitud": accuracy_score(yc_prueba, p >= 0.5),
        "AUC": roc_auc_score(yc_prueba, p),
    }
print(pd.DataFrame(filas).T.round(3))
p_sig = modelos_c["k-NN sigmoid"].predict_proba(Xc_prueba)[:, 1]
p_knn_prueba = knn_c.predict_proba(Xc_prueba)[:, 1]
for nombre, p, color in [("k-NN", p_knn_prueba, "C0"),
                         ("k-NN calibrado", p_sig, "C1")]:
    real, dicho = calibration_curve(yc_prueba, p, n_bins=10)
    plt.plot(dicho, real, marker="o", color=color, label=nombre)
plt.plot([0, 1], [0, 1], color="C2", linestyle="--", linewidth=1.5)
plt.xlabel("Probabilidad que dice el modelo")
plt.ylabel("Fracción de positivos")
plt.title("El k-NN antes y después de calibrar")
plt.legend(loc="lower right")
plt.show()

Pérdidas y métricas: el mapa

# Lo que el modelo minimizó al entrenar y lo que informamos
print(f"Entropía cruzada (log-loss) en la prueba: "
      f"{log_loss(y10_prueba, proba10):.3f}")
print(f"Exactitud en la prueba: "
      f"{accuracy_score(y10_prueba, pred10):.3f}")