Capítulo 14 de 25 9 secciones 15 min

Las métricas, y el 92% que es peor que no hacer nada

Matriz de confusión, precisión, recall y AUC, con un modelo que gana al de antes en el número y pierde contra el listón.

La exactitud es un número solo y un modelo se equivoca de dos maneras que casi nunca cuestan lo mismo. La precisión responde "cuando el modelo avisa, cuánto le creo" y el recall "a cuántos buenos se me escapan". El AUC mide si ordena bien, sin depender del corte. Y el ejemplo que lo resume: un modelo con 92,27% de exactitud contra un listón de 92,40%, que encuentra cero de las 57 ventas grandes y sin embargo tiene 0,92 de AUC. No está roto: está mal cortado.

Llevamos siete capítulos diciendo "exactitud 0,6733" como si eso fuera el modelo. No lo es 📐

La exactitud es un número solo, y un modelo se equivoca de dos maneras distintas que casi nunca cuestan lo mismo. En este capítulo vamos a separarlas, y al final vas a ver un modelo con 92% de exactitud que es peor que no hacer nada.

El punto de partida

import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

def carga_limpia(url):
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y', errors='coerce')
        v[col] = f
    return v

def prepara(v):
    v = v.sort_values(['cliente_id', 'fecha']).copy()
    v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)
    v['sin_descuento'] = v['descuento'].isna().astype(int)
    v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)
    v['precio_unitario'] = v['monto'] / v['unidades']
    v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
    return v

NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
             'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']

def arma(numericas, categoricas):
    pre = ColumnTransformer([
        ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                          ('e', StandardScaler())]), numericas),
        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                          ('c', OneHotEncoder(handle_unknown='ignore'))]), categoricas),
    ])
    return Pipeline([('pre', pre),
                     ('mod', LogisticRegression(max_iter=1000, random_state=42))])

datos = prepara(carga_limpia(URL))
X = datos[NUMERICAS + CATEGORICAS]
y = datos['compro']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
                                          random_state=42, stratify=y)
modelo = arma(NUMERICAS, CATEGORICAS).fit(X_tr, y_tr)
print('exactitud:', round(modelo.score(X_te, y_te), 4))
exactitud: 0.6733
Matriz de confusión de dos por dos con la realidad en las filas y la predicción en las columnas: 86 verdaderos positivos, 14 falsos negativos, 9 falsos positivos y 91 verdaderos negativos.
Precisión y recall salen de aquí y por eso conviene leer la tabla antes que las métricas. La precisión se lee por columnas y el recall por filas: de los que marqué, cuántos eran, contra de los que había, cuántos pillé.

Las cuatro casillas

Todo empieza aquí, y esta tabla es la que hay que saber de memoria.

from sklearn.metrics import confusion_matrix

prediccion = modelo.predict(X_te)
print(confusion_matrix(y_te, prediccion))
[[157 160]
 [ 85 348]]

Se lee así: las filas son la verdad y las columnas lo que dijo el modelo.

Dijo que noDijo que sí
No compró157 aciertos160 falsas alarmas
Sí compró85 se le escaparon348 aciertos

Los nombres formales, que vas a ver en todos lados:

  • Verdaderos positivos (348): dijo sí y era sí.
  • Verdaderos negativos (157): dijo no y era no.
  • 🔔 Falsos positivos (160): dijo sí y era no. La falsa alarma. Aquí, una llamada que no valía la pena.
  • 💸 Falsos negativos (85): dijo no y era sí. El que se escapó. Aquí, una venta perdida.

La pregunta que hay que hacer siempre: ¿cuál de los dos errores duele más? Aquí una llamada de más cuesta quince minutos y una venta perdida cuesta S/800. No son ni parecidos, y sin embargo la exactitud los cuenta igual 😳

Los dos números que separan los dos errores

Precisión=VPVP+FP,Recall=VPVP+FN

de los que marcaste cuántos acertaste, y de los que había cuántos cazaste, que son dos preguntas distintas y casi siempre en conflicto

from sklearn.metrics import precision_score, recall_score, f1_score

print('precisión:', round(precision_score(y_te, prediccion), 4))
print('recall   :', round(recall_score(y_te, prediccion), 4))
print('F1       :', round(f1_score(y_te, prediccion), 4))
precisión: 0.685
recall   : 0.8037
F1       : 0.7396

Y en cristiano:

  • 🎯 Precisión 0,685. De los que dije que sí, acerté el 68,5%. Contesta: cuando el modelo avisa, ¿cuánto le creo?
  • 🕸️ Recall 0,8037. De todos los que sí compraron, atrapé el 80,4%. Contesta: ¿a cuántos de los buenos se me escapan?
  • ⚖️ F1 0,7396. El promedio de los dos, para cuando quieres un solo número. Yo casi nunca lo uso para decidir: prefiero mirar los dos.

La forma de acordarse: la precisión mira la columna del "dijo que sí"; el recall mira la fila del "sí compró".

Y siempre están en tensión. Si quieres atrapar a más compradores, tienes que avisar más veces y te vas a equivocar más. Si quieres equivocarte menos, avisas menos y se te escapan más. Eso se maneja con el umbral, que es el capítulo 15 🎚️

from sklearn.metrics import classification_report

print(classification_report(y_te, prediccion, digits=4))
              precision    recall  f1-score   support

           0     0.6488    0.4953    0.5617       317
           1     0.6850    0.8037    0.7396       433

    accuracy                         0.6733       750
   macro avg     0.6669    0.6495    0.6507       750
weighted avg     0.6697    0.6733    0.6644       750

Ese informe es el que yo pego en el correo. Fíjate en que da los números para las dos clases: el recall de la clase 0 es 0,4953, o sea que de los que no iban a comprar solo detecta la mitad. La exactitud sola te escondía eso 🔍

Curva ROC del modelo del libro, que se despega de la diagonal de tirar una moneda pero sin llegar a la esquina superior izquierda, con el AUC anotado dentro de la figura.
La diagonal es lo que consigue tirar una moneda. Cuanto más se despega la curva de esa diagonal, mejor ordena el modelo, y el área que queda debajo es exactamente el AUC.
Tres curvas ROC con áreas de 0,58, 0,70 y 0,94 sobre la diagonal del azar, que vale 0,50. Cuanto más se despega la curva de la diagonal hacia la esquina superior izquierda, mayor es el área.
La diagonal es tirar una moneda. Cada punto de una curva es un umbral distinto, así que el área resume el modelo entero sin comprometerse con ninguno: te dice si sirve, no dónde cortar. Un AUC de 0,58 no es un modelo flojo, es prácticamente azar.

El AUC, que ya venía apareciendo

AUC=P(p^+>p^)

la probabilidad de que el modelo le dé más puntaje a un caso positivo que a uno negativo tomados al azar, y por eso 0,5 es tirar una moneda

from sklearn.metrics import roc_auc_score, average_precision_score

probabilidad = modelo.predict_proba(X_te)[:, 1]
print('AUC ROC:', round(roc_auc_score(y_te, probabilidad), 4))
print('AUC PR :', round(average_precision_score(y_te, probabilidad), 4))
AUC ROC: 0.7214
AUC PR : 0.7542

Las tres métricas de arriba dependen de dónde pongas el corte. El AUC no: mide si el modelo ordena bien, o sea si a los que compran les da más probabilidad que a los que no.

Se lee fácil: 0,5 es tirar una moneda y 1,0 es perfecto. Y hay una interpretación preciosa: 0,7214 significa que si tomas al azar un comprador y un no comprador, el 72% de las veces el modelo le da más probabilidad al comprador.

El AUC PR (0,7542) es su primo para cuando la clase positiva es rara. Lo vamos a necesitar en la sección siguiente 📐

El 92% que es peor que no hacer nada

L=1ni=1n[yilogpi+(1yi)log(1pi)]

castiga estar seguro y equivocarse mucho más que estar dudando, que es justo lo que se quiere de una probabilidad

Y ahora el ejemplo que vale el capítulo entero.

Cambiamos la pregunta a una más útil para el negocio: ¿esta oportunidad va a cerrar por más de S/2.000? Y le quitamos al modelo las columnas de monto, porque si no estaría copiando la respuesta.

y_grande = ((datos['compro'] == 1) & (datos['monto'] > 2000)).astype(int)
print('qué porcentaje son:', round(y_grande.mean(), 4))

SIN_MONTO = [c for c in NUMERICAS if c not in ('monto', 'precio_unitario')]
Xg = datos[SIN_MONTO + CATEGORICAS]
Xg_tr, Xg_te, yg_tr, yg_te = train_test_split(Xg, y_grande, test_size=0.25,
                                              random_state=42, stratify=y_grande)

grande = arma(SIN_MONTO, CATEGORICAS).fit(Xg_tr, yg_tr)
print('exactitud:', round(grande.score(Xg_te, yg_te), 4))
qué porcentaje son: 0.076
exactitud: 0.9227

92,27% de exactitud 🎉 Un número que en una diapositiva se ve espectacular.

Ahora el listón:

from sklearn.dummy import DummyClassifier

tonto = DummyClassifier(strategy='most_frequent').fit(Xg_tr, yg_tr)
print('el listón:', round(tonto.score(Xg_te, yg_te), 4))
el listón: 0.924

92,40%. El modelo es PEOR que decir "no" a todo el mundo 😱

Y la matriz explica por qué:

pred_g = grande.predict(Xg_te)
print(confusion_matrix(yg_te, pred_g))
print('recall:', round(recall_score(yg_te, pred_g, zero_division=0), 4))
[[692   1]
 [ 57   0]]
recall: 0.0

De las 57 ventas grandes de verdad, encontró cero. El modelo aprendió que decir "no" siempre acierta el 92% de las veces, y hace exactamente eso.

Ese es el modelo con 92% de exactitud que no sirve para nada, y no es un ejemplo inventado: es lo que pasa cada vez que alguien mide un problema desbalanceado con exactitud.

Pero el modelo no es malo, y esto es lo bonito

prob_g = grande.predict_proba(Xg_te)[:, 1]
print('AUC ROC:', round(roc_auc_score(yg_te, prob_g), 4))
print('AUC PR :', round(average_precision_score(yg_te, prob_g), 4))
AUC ROC: 0.92
AUC PR : 0.3741

AUC 0,92. El modelo ordena estupendamente: sabe perfectamente quién tiene más pinta de venta grande.

Y mira el AUC PR: 0,3741 cuando la clase positiva es solo el 7,6%. Ese número se compara contra la tasa base, así que 0,3741 contra 0,076 es cinco veces mejor que el azar. Ahí se ve para qué sirve esta métrica: el ROC dice 0,92 y suena a perfecto, y el PR pone el logro en su tamaño 📐

Lo que está mal no es el modelo, es el corte en 0,5. Con un 7,6% de positivos, casi nadie llega a 0,5 de probabilidad, así que todo cae del lado del "no".

Dos conclusiones y las dos son de las importantes del libro:

  • 📏 La exactitud sola no se reporta nunca. Siempre con el listón al lado.
  • 🎚️ Un modelo con buen AUC y mal recall no está roto: está mal cortado. Eso se arregla y es el capítulo 15.
Cómo elegir métrica y umbral según qué error cuesta más: si duele el falso negativo se prioriza recall bajando el umbral, y si duele el falso positivo se prioriza precisión subiéndolo. La exactitud sola miente con clases desbalanceadas.
El umbral 0,5 que trae el modelo por defecto no sale de tu negocio, sale de la librería. Elegirlo es una decisión de costos, y es de las pocas partes del proyecto donde manda quien conoce el negocio y no quien programa.

Cuál mirar según el caso

F1=2Precisión·RecallPrecisión+Recall

la media armónica de las dos, que se hunde en cuanto una de ellas es mala en vez de disimularlo como haría un promedio normal

Si tu problema es...MiraPor qué
Clases parecidas y los dos errores cuestan igualExactitud, con listónEs la más fácil de explicar
Que no se te escape ninguno (fraude, enfermedad)RecallUn falso negativo es lo caro
Que no molestes a quien no toca (correo, llamadas)PrecisiónUn falso positivo es lo caro
Comparar modelos sin decidir el corteAUC ROCNo depende del umbral
La clase positiva es rara (menos del 10%)AUC PREl ROC se ve bien aunque el modelo sirva poco
Solo puedes atender N casosPrecisión en el top NEs la que corresponde a la capacidad del capítulo 2

Ejercicios

Siete. Intenta antes de abrir 💛

1. La matriz en soles

Pon precio a las cuatro casillas: una llamada de más cuesta S/15 y una venta perdida S/800.

tn, fp, fn, tp = confusion_matrix(y_te, prediccion).ravel()
print('falsas alarmas :', fp, '->', f'S/{fp * 15:,}')
print('ventas perdidas:', fn, '->', f'S/{fn * 800:,}')
print('costo total    :', f'S/{fp * 15 + fn * 800:,}')
falsas alarmas : 160 -> S/2,400
ventas perdidas: 85 -> S/68,000
costo total    : S/70,400

S/2.400 de llamadas de más contra S/68.000 de ventas perdidas. El error caro es veintiocho veces el barato, y la exactitud los contaba igual.

Esa cuenta de tres líneas es la que convierte una métrica en una conversación de negocio, y es la base del capítulo 15 💰

2. La precisión en el top 200

Del capítulo 2: el equipo hace 200 llamadas. ¿Cuántas de esas 200 cierran?

import numpy as np

orden = np.argsort(probabilidad)[::-1]
top = orden[:200]
print('cierran en el top 200:', int(y_te.iloc[top].sum()), 'de 200')
print('precisión en el top  :', round(y_te.iloc[top].mean(), 4))
print('tasa general         :', round(y_te.mean(), 4))
cierran en el top 200: 159 de 200
precisión en el top  : 0.795
tasa general         : 0.5773

Llamando a los 200 mejores según el modelo se cierra el 79,5% (159 ventas), contra el 57,7% de llamar al azar. Veintidós puntos de mejora, y esa es la métrica que de verdad le importa a quien paga 📞

Traducido: las mismas 200 llamadas traen 159 ventas en vez de 115. Cuarenta y cuatro ventas más por el mismo trabajo, y ahí sí se puede poner un número de soles en la propuesta 💰

Fíjate en que no aparece por ningún lado la palabra exactitud.

3. La curva ROC, en tres puntos

Qué pasa con precisión y recall en tres umbrales distintos.

for u in (0.3, 0.5, 0.7):
    p = (probabilidad >= u).astype(int)
    print(f'umbral {u}  precisión {precision_score(y_te, p, zero_division=0):.4f}  '
          f'recall {recall_score(y_te, p):.4f}  avisa {p.sum():4d} veces')
umbral 0.3  precisión 0.6122  recall 0.9700  avisa  686 veces
umbral 0.5  precisión 0.6850  recall 0.8037  avisa  508 veces
umbral 0.7  precisión 0.7945  recall 0.4018  avisa  219 veces

Ahí está la tensión en una tabla. Con el umbral en 0,3 el recall sube a 0,97 y la precisión baja a 0,61, y avisa 686 veces. Con 0,7 la precisión sube a 0,79 y el recall se cae a 0,40, avisando solo 219 veces.

El modelo es el mismo en las tres filas. Lo único que cambió es dónde pusimos la raya 🎚️

4. El listón, para las dos preguntas

Compara modelo y listón en los dos problemas del capítulo, uno al lado del otro.

t1 = DummyClassifier(strategy='most_frequent').fit(X_tr, y_tr)
print('cierra          modelo', round(modelo.score(X_te, y_te), 4),
      '| listón', round(t1.score(X_te, y_te), 4))
print('cierra > S/2000 modelo', round(grande.score(Xg_te, yg_te), 4),
      '| listón', round(tonto.score(Xg_te, yg_te), 4))
cierra          modelo 0.6733 | listón 0.5773
cierra > S/2000 modelo 0.9227 | listón 0.924

En el primero ganamos diez puntos. En el segundo perdemos por poquito, con un número más alto.

Un 92% peor que un 67%. Si alguna vez dudas de por qué el listón es obligatorio, esta es la tabla 📏

5. Los dos errores de medir

Pásale a f1_score las probabilidades en vez de las predicciones.

f1_score(y_te, probabilidad)
ValueError: Classification metrics can't handle a mix of binary and continuous targets

can't handle a mix of binary and continuous targets. Las métricas de clasificación quieren etiquetas (0 y 1), no probabilidades.

La regla para no confundirse: predict() para precisión, recall, F1 y la matriz; predict_proba() solo para el AUC y para la curva. Confundirlos es de los errores más frecuentes, y menos mal que este avisa.

El otro error de medir es peor porque no avisa:

print('al derecho:', round(precision_score(y_te, prediccion), 4))
print('al revés  :', round(precision_score(prediccion, y_te), 4))
al derecho: 0.685
al revés  : 0.8037

0,685 y 0,8037. Los dos son números creíbles y solo uno es la precisión: el segundo es en realidad el recall, porque al cambiar el orden le dijiste a scikit-learn que la predicción era la verdad 🔄

El orden es siempre (verdad, predicción), en todas las métricas y sin excepción.

6. Cuando la clase rara es la que importa

Sobre el problema de las ventas grandes, mira el informe completo en vez de la exactitud.

print(classification_report(yg_te, pred_g, digits=4, zero_division=0))
              precision    recall  f1-score   support

           0     0.9239    0.9986    0.9598       693
           1     0.0000    0.0000    0.0000        57

    accuracy                         0.9227       750
   macro avg     0.4619    0.4993    0.4799       750
weighted avg     0.8537    0.9227    0.8868       750

Mira la fila de la clase 1: precisión 0, recall 0, F1 0, y 57 casos de soporte. El informe te lo dice en la cara.

Y mira el macro avg, que promedia las dos clases sin pesarlas: 0,48. Ese número sí refleja lo que pasa, y por eso en problemas desbalanceados se mira el macro y no el weighted 🎯

7. Tu informe de una línea

Una función que devuelva todo lo que hay que reportar de un modelo, para no olvidarte nada nunca más.

def informe(m, X, y_verdad, nombre=''):
    """Todo lo que va en el correo, con el listón incluido."""
    p = m.predict(X)
    prob = m.predict_proba(X)[:, 1]
    tonto = y_verdad.value_counts(normalize=True).max()
    print(f'--- {nombre}')
    print(f'  listón     {tonto:.4f}')
    print(f'  exactitud  {m.score(X, y_verdad):.4f}')
    print(f'  precisión  {precision_score(y_verdad, p, zero_division=0):.4f}')
    print(f'  recall     {recall_score(y_verdad, p, zero_division=0):.4f}')
    print(f'  AUC ROC    {roc_auc_score(y_verdad, prob):.4f}')
    print(f'  AUC PR     {average_precision_score(y_verdad, prob):.4f}')

informe(modelo, X_te, y_te, 'cierra la venta')
informe(grande, Xg_te, yg_te, 'cierra por más de S/2000')
--- cierra la venta
  listón     0.5773
  exactitud  0.6733
  precisión  0.6850
  recall     0.8037
  AUC ROC    0.7214
  AUC PR     0.7542
--- cierra por más de S/2000
  listón     0.9240
  exactitud  0.9227
  precisión  0.0000
  recall     0.0000
  AUC ROC    0.9200
  AUC PR     0.3741

Seis números y el listón arriba del todo, que es donde tiene que estar.

Con esos seis, cualquiera que lea tu correo puede juzgar el modelo sin confiar en ti, y eso es exactamente lo que quieres 💛

Comprueba que lo tienes

Tu modelo tiene 92% de exactitud y el 92% de los casos son de una sola clase. ¿Es bueno?

  • No se sabe: decir siempre la clase mayoritaria da lo mismo
  • Sí, 92% es alto
  • No, porque 92% es poco para producción
  • Depende de la velocidad del modelo

Lo que te llevas

  • 🔢 La matriz de confusión primero: 348 aciertos, 160 falsas alarmas y 85 que se escaparon.
  • 🎯 Precisión es "cuando avisa, cuánto le creo" (0,685). Recall es "a cuántos buenos se me escapan" (0,8037).
  • 📐 El AUC no depende del umbral: mide si el modelo ordena. 0,7214 significa que acierta el orden el 72% de las veces.
  • 😱 El modelo de ventas grandes tiene 92,27% de exactitud, el listón 92,40%, y encuentra cero de 57. La exactitud sola no se reporta nunca.
  • 🎚️ Ese mismo modelo tiene 0,92 de AUC: no está roto, está mal cortado.
  • 💰 Las cuatro casillas se convierten en soles y ahí se ve que el error caro es veintiocho veces el barato.
  • 📞 Con capacidad limitada, la métrica es la precisión en el top N: 84% en las 200 mejores contra 57,7% al azar.

En el capítulo 15 movemos el umbral y ponemos precio a cada error, que es donde el modelo se convierte en una decisión de negocio.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?