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

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

En esta ficha · 12 secciones
  1. La pregunta
  2. Actividad: el umbral
  3. Cuatro maneras de acertar o fallar
  4. Cuatro métricas
  5. Un caso real: tumores benignos y malignos
  6. La trampa de la exactitud: clases desbalanceadas
  7. El umbral es una decisión
  8. La curva ROC y el AUC
  9. ¿Qué error cuesta más?
  10. Más de dos clases: macro y micro
  11. Medir las probabilidades: log-loss
  12. Lo que aprendiste y lo que sigue

La pregunta

Una empresa ofrece una prueba rápida para detectar una enfermedad y la anuncia así: «acierta en el 95 % de los casos». ¿Es una buena prueba?

Depende. Imagina que en tu ciudad 5 de cada 100 personas tienen esa enfermedad. Una «prueba» que a todo el mundo le dice «estás sano», sin mirar nada, acierta con las 95 personas sanas y falla solo con las 5 enfermas: también acierta en el 95 % de los casos. Y no detecta a ningún enfermo, que era justamente para lo que servía.

Un solo número de aciertos esconde qué tipo de error comete un modelo, y en los problemas reales los errores no cuestan lo mismo. Decirle a una persona sana que podría estar enferma es un susto y un análisis más; decirle a una persona enferma que está sana puede costarle el tratamiento a tiempo. En este tema vas a aprender a medir un clasificador según lo que importa en el problema.

En este tema vas a:

  • construir e interpretar una matriz de confusión;
  • calcular la exactitud, la precisión, la exhaustividad y F1, y elegir la métrica según el costo de cada error;
  • trazar una curva ROC, calcular el AUC y elegir un umbral;
  • reconocer por qué la exactitud engaña cuando una clase es mucho más rara que la otra.

Necesitas lo del tema 8, «Clasificar: vecinos cercanos y regresión logística»: LogisticRegression, predict_proba y la idea de que el umbral 0,5 es una decisión, no una ley. Del tema 7 usamos train_test_split para separar los datos de prueba.

Actividad: el umbral

Un clasificador como la regresión logística no responde «sí» o «no»: da un puntaje, una probabilidad entre 0 y 1. Recién al compararlo con un umbral se convierte en una decisión. En la actividad, las dos campanas muestran los puntajes que un modelo imaginario les da a 1000 personas: la campana de trazo punteado es la de las personas sanas y la de trazo continuo, la de las enfermas. Todo lo que queda a la derecha de la línea vertical, el umbral, el modelo lo marca como «enfermo». Los números son sintéticos: salen de dos campanas de Gauss, no de pacientes reales.

El umbral

Este es el estado inicial, con el umbral en 0,5. Con JavaScript activado puedes mover el umbral y separar las campanas.

sanosenfermosdice «enfermo» →
AUC = 0,92
Matriz de confusión de 1000 personas
Dice «sano»Dice «enfermo»
Sanos (500)VN 421FP 79
Enfermos (500)FN 79VP 421
Exactitud0,84
Precisión0,84
Exhaustividad0,84
F10,84

Tasa de falsos positivos: 0,16 AUC: 0,92

Mueve el umbral: todo lo que queda a su derecha, el modelo lo marca como «enfermo».

Prueba esto, en orden:

  1. Mueve el umbral hacia la derecha. ¿Qué pasa con los falsos positivos (sanos marcados como enfermos)? ¿Y con los enfermos que se escapan?
  2. Busca un umbral con el que no se escape casi ningún enfermo. ¿Cuánto vale la precisión ahí?
  3. Mira la curva ROC, la gráfica con el punto coral: cada umbral es un punto de la curva. ¿Dónde queda el punto con el umbral en 0? ¿Y en 1?
  4. Junta las campanas hasta que se tapen por completo. ¿Qué forma toma la curva y cuánto vale el AUC? Sepáralas del todo.
  5. Cambia a «1 de cada 10 está enferma» sin mover nada más. ¿Qué métrica cambia mucho y cuál no cambia nada? ¿Se mueve la curva?

Lo que acabas de ver es la idea central del tema: la métrica depende de la decisión, no solo del modelo. El modelo pone los puntajes; tú eliges el umbral según qué error te cuesta más. Y la separación entre las campanas, que resume el AUC, es lo único que ningún umbral puede mejorar.

Cuatro maneras de acertar o fallar

En un problema con dos clases, a una la llamamos positiva (la que buscamos: enfermo, correo basura, fraude) y a la otra negativa. «Positivo» no quiere decir «bueno»: es solo la clase que el modelo intenta encontrar. Cada predicción cae en uno de cuatro casos:

  • Verdadero positivo (VP): era positivo y el modelo dijo positivo. Un enfermo detectado.
  • Falso positivo (FP): era negativo y el modelo dijo positivo. Una falsa alarma.
  • Verdadero negativo (VN): era negativo y el modelo dijo negativo. Un sano tranquilo.
  • Falso negativo (FN): era positivo y el modelo dijo negativo. Un enfermo que se escapa.

En inglés se llaman true positive, false positive, true negative y false negative, y vas a ver sus siglas TP, FP, TN y FN en casi todo lo que leas. Contémoslos a mano en un ejemplo pequeño: los resultados (inventados) de una prueba aplicada a 12 personas. Un 1 es «enfermo» y un 0 es «sano».

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)
VP = 3   FP = 2   VN = 6   FN = 1
[[6 2]
 [1 3]]

Esa tabla de 2 × 2 es la matriz de confusión (confusion matrix). Cada fila es una clase real y cada columna, una clase predicha. Lo que está en la diagonal son aciertos; lo que está fuera, errores. Su nombre viene de que muestra qué confunde el modelo con qué:

Predicho: sano (0) Predicho: enfermo (1)
Real: sano (0) VN = 6 FP = 2
Real: enfermo (1) FN = 1 VP = 3

scikit-learn la calcula con confusion_matrix, en ese mismo orden: la clase 0 primero, la fila de arriba y la columna de la izquierda. Con ravel() se aplana para sacar los cuatro números de una vez.

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))
[[6 2]
 [1 3]]
Iguales a las nuestras: True

Cuidado con el orden: algunos libros ponen lo predicho en las filas y lo real en las columnas, o la clase positiva primero. Antes de leer una matriz ajena, fíjate qué es cada eje.

Cuatro métricas

Con los cuatro conteos se arman las métricas. Cada una responde una pregunta distinta.

Exactitud (accuracy): de todas las predicciones, ¿qué fracción acertó? Es la tasa de aciertos que usaste en el tema 8.

exactitud=VP+VNVP+VN+FP+FN

Precisión (precision): de los que el modelo marcó como positivos, ¿cuántos lo eran? Mide cuánto puedes confiar en una alarma.

precisión=VPVP+FP

Exhaustividad (recall, también llamada sensibilidad): de los que eran positivos, ¿cuántos encontró el modelo? Mide cuántos se le escapan.

exhaustividad=VPVP+FN

F1: un solo número que combina las dos anteriores con la media armónica. A diferencia del promedio común, la media armónica se acerca al valor más bajo: si una de las dos es mala, F1 también lo es.

F1=2·precisión·exhaustividadprecisión+exhaustividad

Ojo con el nombre: en el habla de todos los días «precisión» y «exactitud» son casi sinónimos, pero aquí son dos métricas distintas. La exactitud mira todas las predicciones; la precisión mira solo las que dijeron «positivo».

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}")
Exactitud:     0.750   scikit-learn: 0.750
Precisión:     0.600   scikit-learn: 0.600
Exhaustividad: 0.750   scikit-learn: 0.750
F1:            0.667   scikit-learn: 0.667

Léelo en palabras: el modelo acertó con 9 de las 12 personas; de las 5 alarmas que dio, 3 eran enfermos de verdad (precisión 0,6); de los 4 enfermos, encontró 3 (exhaustividad 0,75). F1 queda entre las dos, más cerca de la más baja.

Un caso real: tumores benignos y malignos

Volvamos a los tumores del tema 8. El dataset load_breast_cancer, incluido en scikit-learn, tiene 30 medidas de los núcleos de las células en imágenes de 569 biopsias de tumores de mama, y para cada una si el tumor resultó benigno o maligno. Hay un detalle que conviene revisar siempre: en este dataset la clase 1 es benigno. Como lo que queremos encontrar son los malignos, invertimos la etiqueta para que 1 = maligno sea la clase positiva.

Separamos un 30 % para prueba con stratify=y, que hace que la proporción de malignos sea la misma en las dos partes (tema 7). Estandarizamos con StandardScaler, ajustado solo con los datos de entrenamiento, y entrenamos una regresión logística como en el tema 8.

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()
569 tumores, 212 malignos (37%)
[[103   4]
 [  4  60]]

Matriz de confusión de 2 por 2 con los tumores de prueba. Fila benigno: 103 predichos benignos y 4 predichos malignos. Fila maligno: 4 predichos benignos y 60 predichos malignos. Las celdas de la diagonal, con más casos, son las más oscuras.

De 171 tumores de prueba, el modelo se equivocó con 8: marcó como malignos 4 tumores benignos (falsos positivos) y dejó pasar 4 malignos (falsos negativos). classification_report da todas las métricas de una vez, para cada clase:

from sklearn.metrics import classification_report

print(classification_report(y_prueba, pred,
                            target_names=["benigno", "maligno"]))
              precision    recall  f1-score   support

     benigno       0.96      0.96      0.96       107
     maligno       0.94      0.94      0.94        64

    accuracy                           0.95       171
   macro avg       0.95      0.95      0.95       171
weighted avg       0.95      0.95      0.95       171

Cada fila trata a esa clase como si fuera la positiva: la fila «maligno» es la que nos interesa. support es cuántos casos reales hay de esa clase. Las dos filas de abajo, los promedios, las vas a ver en la sección de varias clases. Una exhaustividad de 0,94 para «maligno» quiere decir que, de cada 100 tumores malignos, el modelo deja pasar unos 6. ¿Es aceptable? Eso no lo decide el modelo: lo decide el problema, y lo vas a poder ajustar con el umbral.

Un aviso importante, como en el tema 8: esto es un ejercicio de aprendizaje con un conjunto de datos público. Un modelo así no reemplaza el diagnóstico de un profesional de la salud.

La trampa de la exactitud: clases desbalanceadas

Volvamos a la pregunta del inicio. Creamos con make_classification un problema sintético con 2000 casos donde cerca del 5 % es positivo (por ejemplo, transacciones fraudulentas), y comparamos tres modelos:

  • un modelo tonto, DummyClassifier, que siempre responde la clase más frecuente sin mirar los datos;
  • una regresión logística normal;
  • una regresión logística con class_weight="balanced", que en la pérdida le da más peso a cada error con la clase rara, como si hubiera tantos positivos como negativos.
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()
Positivos: 110 de 2000 (5.5%)
            exactitud  precisión  exhaustividad     F1
tonto           0.945      0.000          0.000  0.000
logística       0.993      1.000          0.879  0.935
balanceada      0.977      0.721          0.939  0.816

Barras horizontales de cuatro métricas para dos modelos. El modelo tonto, en coral, tiene una exactitud de casi 0,95 y precisión, exhaustividad y F1 en cero. La logística tiene las cuatro métricas altas, todas por encima de 0,87.

El modelo tonto tiene un 94,5 % de exactitud y no encuentra ni un solo positivo. Con clases desbalanceadas la exactitud se ve buena casi siempre, porque la dominan los negativos, que son la mayoría. Por eso:

  • Compara siempre con el modelo tonto. Si tu modelo no le gana con claridad, no aprendió nada útil.
  • Mira la precisión y la exhaustividad de la clase rara, no solo la exactitud.
  • Divide con stratify=y: con solo 33 positivos en la prueba, una división al azar podría dejar muy pocos y las métricas variarían mucho.
  • class_weight="balanced" cambia el equilibrio: aquí la exhaustividad sube de 0,879 a 0,939 (encuentra 31 de 33 en lugar de 29) a cambio de más falsas alarmas, y la precisión baja de 1 a 0,721. Ninguno de los dos es «el mejor»: depende de qué error cuesta más.

El umbral es una decisión

predict usa el umbral 0,5: dice «positivo» si predict_proba da 0,5 o más. En el tema 8 ya viste que nada obliga a usar 0,5; ahora vamos a medir el efecto con las métricas. Si dejar pasar un tumor maligno es mucho peor que pedir una biopsia de más, conviene bajar el umbral para que el modelo marque más casos.

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}")
umbral 0.5:  VP=60 FN=4 FP=4 VN=103   precisión=0.94  exhaustividad=0.94
umbral 0.3:  VP=61 FN=3 FP=10 VN=97   precisión=0.86  exhaustividad=0.95
umbral 0.1:  VP=63 FN=1 FP=14 VN=93   precisión=0.82  exhaustividad=0.98

Con el umbral en 0,1 se escapa un solo tumor maligno de 64, a cambio de 14 falsas alarmas en lugar de 4. Es el mismo modelo, con los mismos puntajes: lo único que cambió fue la decisión. precision_recall_curve prueba todos los umbrales posibles de una vez y devuelve la precisión y la exhaustividad de cada uno:

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()

Dos curvas escalonadas frente al umbral, de 0 a 1. La precisión sube desde abajo del gráfico hasta 1 a medida que el umbral crece; la exhaustividad, en coral, baja despacio desde 1 y cae de golpe cerca del umbral 1. Una línea punteada vertical marca el umbral 0,5, donde las dos valen cerca de 0,94.

Las dos métricas tiran en sentidos contrarios: subir el umbral hace al modelo más exigente, así que sus alarmas son más confiables (más precisión) pero encuentra menos casos (menos exhaustividad). Si en lugar del umbral pones la exhaustividad en el eje horizontal y la precisión en el vertical, tienes la curva precisión–exhaustividad, que muestra directamente cuánta precisión pierdes por cada caso más que encuentras.

La curva ROC y el AUC

La curva ROC (del inglés receiver operating characteristic, un nombre que viene de los radares) es otra forma de ver todos los umbrales a la vez. Para cada umbral dibuja un punto:

  • en el eje vertical, la tasa de verdaderos positivos: la exhaustividad, VP / (VP + FN);
  • en el eje horizontal, la tasa de falsos positivos: qué fracción de los negativos recibió una falsa alarma, FP / (FP + VN).

Con el umbral en 1 el modelo no marca a nadie y el punto está en (0, 0). Con el umbral en 0 marca a todos y el punto está en (1, 1). Entre medio, la curva de un buen modelo sube casi vertical hacia la esquina de arriba a la izquierda: encuentra muchos positivos antes de dar falsas alarmas. Un modelo que adivina al azar queda sobre la diagonal.

El AUC (area under the curve) es el área bajo la curva ROC: 1 es perfecto y 0,5 es lo mismo que tirar una moneda. Comparemos nuestra logística con otra que solo mira una de las 30 medidas, la textura media del núcleo:

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()
Textura: AUC = 0.810
30 medidas: AUC = 0.992

Dos curvas ROC escalonadas y la diagonal punteada del azar. La del modelo con 30 medidas, en coral, sube casi vertical hasta cerca de 1 y luego sigue horizontal: AUC 0,99. La del modelo que solo usa la textura, en tinta, sube más despacio, entre la diagonal y la otra curva: AUC 0,81.

El AUC tiene una lectura muy concreta: es la probabilidad de que, si tomas al azar un caso positivo y uno negativo, el modelo le dé más puntaje al positivo. Podemos comprobarlo comparando todos los pares:

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}")
Pares comparados: 6848
AUC contando pares: 0.9917
AUC de scikit-learn: 0.9917

Por eso el AUC no depende del umbral: mide qué tan bien ordena el modelo a los casos, antes de decidir dónde cortar. Es una buena métrica para comparar modelos; pero para usar uno hay que elegir un punto de la curva, es decir, un umbral. Eso es lo que hiciste en la actividad al mover la línea: el AUC no cambiaba, la matriz de confusión sí.

Un aviso: con clases muy desbalanceadas la curva ROC puede verse muy bien aunque la precisión sea baja, porque la tasa de falsos positivos se divide entre muchísimos negativos. En la actividad lo viste al pasar a «1 de cada 10»: la curva no se movió, pero la precisión cayó. En esos casos conviene mirar también la curva precisión–exhaustividad.

¿Qué error cuesta más?

No hay una métrica correcta para todo. La pregunta es siempre la misma: ¿qué pasa cuando el modelo se equivoca de cada forma?

  • Si un falso negativo es grave (una enfermedad sin detectar, un incendio sin alarma), importa la exhaustividad: baja el umbral aunque aumenten las falsas alarmas.
  • Si un falso positivo es grave (un correo importante que termina en la carpeta de basura, una persona inocente señalada), importa la precisión: sube el umbral.
  • Si los dos errores importan parecido y las clases están desbalanceadas, F1 es un buen resumen.
  • La exactitud sirve cuando las clases están más o menos equilibradas y los dos errores cuestan lo mismo.
  • Para comparar modelos sin haber elegido todavía un umbral, el AUC.

En la práctica se suele fijar una condición («no dejar pasar más del 5 % de los positivos») y, entre los umbrales que la cumplen, elegir el que da más precisión. Lo vas a hacer en el mini-proyecto.

Más de dos clases: macro y micro

Con varias clases, la matriz de confusión tiene una fila y una columna por clase, y la precisión y la exhaustividad se calculan para cada clase, tratándola como la positiva y a todas las demás como negativas. Para resumirlas en un número hay dos maneras:

  • Promedio macro: se calcula la métrica de cada clase y se promedian, todas con el mismo peso. Una clase rara pesa igual que una frecuente.
  • Promedio micro: se juntan los VP, FP y FN de todas las clases y se calcula la métrica una sola vez. Pesan más las clases frecuentes. Cuando cada caso tiene una sola clase, la precisión, la exhaustividad y F1 micro son iguales a la exactitud.

classification_report muestra además el promedio weighted, que pondera cada clase por cuántos casos tiene. Veámoslo en un problema sintético con tres clases desbalanceadas:

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}")
[[338  18   2]
 [ 34  30   5]
 [ 10   5   8]]
              precision    recall  f1-score   support

           0       0.88      0.94      0.91       358
           1       0.57      0.43      0.49        69
           2       0.53      0.35      0.42        23

    accuracy                           0.84       450
   macro avg       0.66      0.58      0.61       450
weighted avg       0.82      0.84      0.82       450

F1 macro: 0.609
F1 micro: 0.836

La exactitud (0,84) y el F1 micro dicen que el modelo va bastante bien, pero el F1 macro (0,61) avisa que con las clases 1 y 2 le va mal: de los 23 casos de la clase 2 solo encuentra 8. Si las clases raras son las que importan, el promedio macro es el que lo muestra.

Medir las probabilidades: log-loss

Todas las métricas anteriores, salvo el AUC, miran la decisión final. Pero dos modelos que deciden igual pueden dar probabilidades muy distintas: uno dice 0,51 y el otro 0,99. La log-loss (pérdida logarítmica) es la entropía cruzada del tema 8 usada como métrica: castiga mucho a un modelo que da una probabilidad alta a la clase equivocada. Cuanto más baja, mejor.

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}")
Log-loss, 30 medidas:      0.107
Log-loss, solo la textura: 0.548
Log-loss, sin mirar nada:  0.661

El modelo de 30 medidas no solo acierta más: además está muy seguro cuando acierta y rara vez está seguro cuando se equivoca, por eso su log-loss es tan baja. El que solo mira la textura queda entre él y el que no mira nada, que siempre dice «37 % de probabilidad de maligno».

Y si el problema no es de clasificación sino de regresión (predecir un número), las métricas son las del tema 6: el MAE y el RMSE, en las unidades del objetivo, y el R², que compara con el modelo que siempre predice el promedio. La idea es la misma: compararse siempre con un modelo tonto.

Lo que aprendiste y lo que sigue

  • La matriz de confusión cuenta los cuatro resultados posibles: verdaderos positivos y negativos (aciertos) y falsos positivos y negativos (errores). En scikit-learn, las filas son lo real y las columnas, lo predicho.
  • La exactitud mide los aciertos en total; la precisión, cuánto confiar en una alarma; la exhaustividad, cuántos positivos se encuentran; F1 combina las dos últimas.
  • Con clases desbalanceadas la exactitud engaña: un modelo tonto puede tener 95 % sin servir para nada. Compárate con él, estratifica al dividir y mira las métricas de la clase rara.
  • El umbral es una decisión: bajarlo sube la exhaustividad y baja la precisión. Se elige según el costo de cada error.
  • La curva ROC muestra todos los umbrales a la vez y el AUC mide qué tan bien ordena el modelo a los casos, sin depender del umbral.
  • Con varias clases, el promedio macro trata igual a todas y el micro se deja llevar por las frecuentes.

En el tema 10 vas a preparar los datos antes de entrenar (limpiarlos, transformar variables y armar pipelines) sin hacerte trampa, y desde ahí todos los modelos que veas se van a medir con las herramientas de este tema.