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
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 no | Dijo que sí | |
|---|---|---|
| No compró | 157 aciertos | 160 falsas alarmas |
| Sí compró | 85 se le escaparon | 348 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
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 🔍
El AUC, que ya venía apareciendo
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
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.
Cuál mirar según el caso
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... | Mira | Por qué |
|---|---|---|
| Clases parecidas y los dos errores cuestan igual | Exactitud, con listón | Es la más fácil de explicar |
| Que no se te escape ninguno (fraude, enfermedad) | Recall | Un falso negativo es lo caro |
| Que no molestes a quien no toca (correo, llamadas) | Precisión | Un falso positivo es lo caro |
| Comparar modelos sin decidir el corte | AUC ROC | No depende del umbral |
| La clase positiva es rara (menos del 10%) | AUC PR | El ROC se ve bien aunque el modelo sirva poco |
| Solo puedes atender N casos | Precisión en el top N | Es 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! 🌸