Evaluar bien: métricas, matriz de confusión y curva ROC
Los 12 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; 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
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}")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}")