Todo lo que hemos hecho hasta ahora tenía un 0,5 escondido dentro 🎚️
Cuando llamas a predict(), el modelo calcula una probabilidad y
la compara contra 0,5. Si pasa, dice que sí. Ese número no lo eligió nadie: es
el que viene de fábrica.
Y en este capítulo vamos a ver que elegirlo bien vale S/65.750 sobre 750 oportunidades, sin tocar el modelo.
El punto de partida
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
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']
pre = ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NUMERICAS),
('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
])
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 = Pipeline([('pre', pre),
('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(X_tr, y_tr)
probabilidad = modelo.predict_proba(X_te)[:, 1]
print('750 oportunidades, probabilidades entre',
round(probabilidad.min(), 3), 'y', round(probabilidad.max(), 3))
750 oportunidades, probabilidades entre 0.08 y 0.947
Los dos precios
Del capítulo 2, y esto no lo decide quien programa:
- 🔔 Falso positivo: llamamos a alguien que no iba a comprar. Cuesta quince minutos del vendedor, digamos S/15.
- 💸 Falso negativo: no llamamos a alguien que sí iba a comprar. Se pierde la venta entera, digamos S/800.
Cincuenta y tres veces más caro uno que el otro. Y ahora la pregunta: ¿por qué íbamos a cortar en el medio?
El costo de cada umbral
el umbral bueno no es 0,5, es el que hace más chica la suma de lo que cuestan los dos errores en tu negocio
COSTO_FP, COSTO_FN = 15, 800
def costo(umbral):
prediccion = (probabilidad >= umbral).astype(int)
tn, fp, fn, tp = confusion_matrix(y_te, prediccion, labels=[0, 1]).ravel()
return fp, fn, fp * COSTO_FP + fn * COSTO_FN
for u in (0.15, 0.3, 0.5, 0.7, 0.9):
fp, fn, total = costo(u)
print(f'umbral {u:.2f} falsas alarmas {fp:3d} ventas perdidas {fn:3d} S/{total:,}')
umbral 0.15 falsas alarmas 310 ventas perdidas 0 S/4,650 umbral 0.30 falsas alarmas 266 ventas perdidas 13 S/14,390 umbral 0.50 falsas alarmas 160 ventas perdidas 85 S/70,400 umbral 0.70 falsas alarmas 45 ventas perdidas 259 S/207,875 umbral 0.90 falsas alarmas 1 ventas perdidas 430 S/344,015
Léelo de arriba abajo, porque es de las tablas más útiles de todo el libro.
Con el 0,5 de fábrica perdemos S/70.400. Con 0,15, S/4.650.
Quince veces menos. Mismo modelo, mismos datos, mismo día. Solo movimos un número 😳
Buscarlo bien
candidatos = [(costo(u)[2], round(u, 2)) for u in np.arange(0.01, 0.99, 0.01)]
mejor_costo, mejor_umbral = min(candidatos)
print('umbral óptimo:', mejor_umbral)
print('costo ahí :', f'S/{mejor_costo:,}')
print('costo en 0,5 :', f'S/{costo(0.5)[2]:,}')
print('ahorro :', f'S/{costo(0.5)[2] - mejor_costo:,}')
umbral óptimo: 0.14 costo ahí : S/4,650 costo en 0,5 : S/70,400 ahorro : S/65,750
0,14 y S/65.750 de ahorro sobre 750 oportunidades. Anualizado sobre el archivo entero, es una cifra que hace que valga la pena el proyecto entero 💰
Y no costó ni una línea de modelo. Costó preguntar cuánto vale cada error, que es la pregunta del capítulo 2.
Lo que sale de ahí, y que incomoda un poco
print('a cuántos llama con umbral 0,14:', int((probabilidad >= 0.14).sum()), 'de 750')
print('a cuántos llama con umbral 0,50:', int((probabilidad >= 0.50).sum()), 'de 750')
a cuántos llama con umbral 0,14: 743 de 750 a cuántos llama con umbral 0,50: 508 de 750
743 de 750. O sea que, con esos precios, la respuesta correcta es llamar a casi todo el mundo.
Y tiene toda la lógica: si una llamada cuesta S/15 y una venta vale S/800, no llamar es una apuesta malísima. El modelo no hacía falta para decidir a quién llamar.
Esto pasa de verdad y hay que decirlo en la reunión en vez de esconderlo. Pero no significa que el modelo no sirva, y la razón es la del capítulo 2: nadie puede hacer 743 llamadas 📞
Y ahí es donde el modelo sí vale
Cuando la capacidad es la que manda, la decisión ya no es un umbral: es un orden.
orden = np.argsort(probabilidad)[::-1]
print('capacidad con modelo al azar ventas de más')
for k in (100, 200, 300, 500):
con_modelo = int(y_te.iloc[orden[:k]].sum())
al_azar = round(k * y_te.mean())
print(f'{k:9d} {con_modelo:10d} {al_azar:7.0f} {con_modelo - al_azar:+3d}'
f' S/{(con_modelo - al_azar) * 800:,}')
capacidad con modelo al azar ventas de más
100 79 58 +21 S/16,800
200 159 115 +44 S/35,200
300 231 173 +58 S/46,400
500 343 289 +54 S/43,200
Con 200 llamadas: 159 ventas usando el modelo contra 115 llamando al azar. Cuarenta y cuatro ventas más por el mismo trabajo, o sea S/35.200 🎯
Y fíjate en la última fila: con 500 llamadas la ganancia baja a 54. Cuanto más llamas, menos aporta ordenar, porque al final llamas a todos igual. La ganancia de un modelo de ranking es más grande cuanto más apretada esté la capacidad.
Cuándo el 0,5 sí es correcto
Vamos a mover los precios y ver qué le pasa al umbral óptimo.
def optimo(costo_fp, costo_fn):
mejor = None
for u in np.arange(0.01, 0.99, 0.01):
p = (probabilidad >= u).astype(int)
tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()
total = fp * costo_fp + fn * costo_fn
if mejor is None or total < mejor[0]:
mejor = (total, round(u, 2))
return mejor
for cfp in (15, 100, 400, 800):
total, u = optimo(cfp, 800)
print(f'falso positivo S/{cfp:3d} contra falso negativo S/800 -> umbral {u}')
falso positivo S/ 15 contra falso negativo S/800 -> umbral 0.14 falso positivo S/100 contra falso negativo S/800 -> umbral 0.14 falso positivo S/400 contra falso negativo S/800 -> umbral 0.33 falso positivo S/800 contra falso negativo S/800 -> umbral 0.49
Ahí está la respuesta completa: el umbral sube según el falso positivo se acerca al falso negativo, y cuando los dos cuestan igual, el óptimo es 0,49, o sea el 0,5 de fábrica.
Así que ese 0,5 no está mal: está bien para un caso concreto, el de los errores que cuestan lo mismo. Lo que está mal es usarlo sin preguntarse si es tu caso 🎚️
Cómo se hace bien, que es lo que se olvida
Un aviso importante: el umbral se elige en validación, no en el examen. Si lo buscas sobre los mismos datos donde lo mides, estás haciendo lo mismo que el capítulo 12 y el número sale optimista.
La forma correcta es partir en tres: entrenar, elegir umbral, y medir. O usar validación cruzada para elegirlo, que es el capítulo 16.
Aquí lo hice sobre el examen para que se vea la idea con menos código, y te lo digo en vez de tapártelo 💛
Ahora hagámoslo bien, que son cuatro líneas más, y de paso medimos cuánto nos estaba mintiendo el atajo:
X_ent, X_val, y_ent, y_val = train_test_split(X_tr, y_tr, test_size=0.3,
random_state=42, stratify=y_tr)
bien = Pipeline([
('pre', ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NUMERICAS),
('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
])),
('mod', LogisticRegression(max_iter=1000, random_state=42)),
]).fit(X_ent, y_ent)
p_val = bien.predict_proba(X_val)[:, 1]
p_test = bien.predict_proba(X_te)[:, 1]
def costo_de(prob, objetivo, umbral):
tn, fp, fn, tp = confusion_matrix(objetivo, (prob >= umbral).astype(int),
labels=[0, 1]).ravel()
return fp * COSTO_FP + fn * COSTO_FN
umbrales = np.arange(0.05, 0.96, 0.01)
elegido = umbrales[np.argmin([costo_de(p_val, y_val, u) for u in umbrales])]
tramposo = umbrales[np.argmin([costo_de(p_test, y_te, u) for u in umbrales])]
print('umbral elegido en validación:', round(elegido, 2))
print(' lo que costó en validación: S/', costo_de(p_val, y_val, elegido))
print(' lo que cuesta en el examen: S/', costo_de(p_test, y_te, elegido))
print('umbral elegido haciendo trampa:', round(tramposo, 2),
' cuesta S/', costo_de(p_test, y_te, tramposo))
umbral elegido en validación: 0.12 lo que costó en validación: S/ 4230 lo que cuesta en el examen: S/ 4680 umbral elegido haciendo trampa: 0.14 cuesta S/ 4650
El umbral honesto sale 0,12 y el tramposo 0,14. Y la diferencia de costo entre los dos, medida en el examen, es de S/30 sobre S/4.650.
O sea que aquí el atajo casi no mintió, y lo digo aunque me deje sin moraleja dramática 😅
Pero fíjate en la otra línea, que es la que sí importa: en validación el umbral parecía costar S/4.230 y en el examen cuesta S/4.680. Ese es el optimismo de verdad, y no está en el umbral elegido sino en la cifra que ibas a reportar.
La regla se queda igual: el umbral se elige en validación y el número que prometes sale del examen. Que el atajo salga barato en un dataset no significa que salga barato en el tuyo 🎚️
Ejercicios
Siete. Intenta antes de abrir 💛
1. El umbral elegido como se debe
Parte en tres, elige el umbral en el trozo del medio y mídelo en el último.
X_ent, X_resto, y_ent, y_resto = train_test_split(X, y, test_size=0.4,
random_state=42, stratify=y)
X_val, X_fin, y_val, y_fin = train_test_split(X_resto, y_resto, test_size=0.5,
random_state=42, stratify=y_resto)
print(len(X_ent), len(X_val), len(X_fin))
m3 = Pipeline([('pre', pre),
('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(X_ent, y_ent)
def costo_en(prob, y_v, u):
p = (prob >= u).astype(int)
tn, fp, fn, tp = confusion_matrix(y_v, p, labels=[0, 1]).ravel()
return fp * 15 + fn * 800
p_val = m3.predict_proba(X_val)[:, 1]
u_elegido = min(np.arange(0.01, 0.99, 0.01), key=lambda u: costo_en(p_val, y_val, u))
p_fin = m3.predict_proba(X_fin)[:, 1]
print('umbral elegido en validación:', round(u_elegido, 2))
print('costo con ese umbral:', f'S/{costo_en(p_fin, y_fin, u_elegido):,}')
print('costo con 0,5 :', f'S/{costo_en(p_fin, y_fin, 0.5):,}')
1800 600 600 umbral elegido en validación: 0.18 costo con ese umbral: S/4,430 costo con 0,5 : S/57,105
El umbral elegido en un trozo funciona igual de bien en otro que no vio nadie. Eso es lo que quieres comprobar: que la decisión no estaba pegada a esas 750 filas concretas ✅
2. El costo por oportunidad
Pasa el costo total a soles por oportunidad, que es como se presenta.
for u in (0.14, 0.5):
fp, fn, total = costo(u)
print(f'umbral {u}: S/{total:,} en {len(y_te)} oportunidades'
f' = S/{total / len(y_te):.2f} por oportunidad')
umbral 0.14: S/4,650 en 750 oportunidades = S/6.20 por oportunidad umbral 0.5: S/70,400 en 750 oportunidades = S/93.87 por oportunidad
S/6,20 contra S/93,87 por oportunidad. Ese es el número que va en la propuesta, porque se multiplica solo por el volumen del cliente 💼
3. Cuando el falso positivo es el caro
Al revés: enviar un cupón de S/200 a quien iba a comprar igual, contra perder una venta de S/300.
total, u = optimo(200, 300)
print('umbral óptimo:', u)
print('a cuántos llega:', int((probabilidad >= u).sum()), 'de 750')
umbral óptimo: 0.39 a cuántos llega: 609 de 750
El umbral sube de 0,14 a 0,39 y el modelo pasa de avisar 743 veces a 609. Tiene todo el sentido: si regalar el cupón cuesta dos tercios de lo que vale la venta, hay que ser bastante más selectiva.
Es el mismo modelo, los mismos datos y el mismo día, contestando dos preguntas de negocio distintas. Lo único que cambió fueron dos números que da el cliente 🎁
4. La ganancia, no el costo
Dale la vuelta: en vez de contar lo que se pierde, cuenta lo que se gana.
MARGEN = 250
def ganancia(u):
p = (probabilidad >= u).astype(int)
tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()
return tp * MARGEN - (tp + fp) * 15
for u in (0.14, 0.3, 0.5, 0.7):
print(f'umbral {u}: S/{ganancia(u):,}')
umbral 0.14: S/97,105 umbral 0.3: S/94,710 umbral 0.5: S/79,380 umbral 0.7: S/40,215
Contar la ganancia en vez del costo da el mismo orden y se presenta mucho mejor: nadie se emociona con "perdimos menos", pero "ganamos S/97.105" sí se entiende 💰
El truco está en que cada venta cerrada deja margen y cada llamada cuesta, la haya cerrado o no.
5. El error de comparar probabilidades con etiquetas
Calcula la matriz de confusión pasándole las probabilidades sin cortar.
confusion_matrix(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, el mismo del capítulo 14. Y aquí duele más, porque es justo el paso que se olvida: la probabilidad hay que cortarla antes de compararla.
Esa comparación (probabilidad >= umbral).astype(int) es todo lo
que hay dentro de predict(). Nada más 🎚️
6. La tabla que se lleva a la reunión
Cinco umbrales con todo lo que pregunta un gerente.
print('umbral llama a cierra precisión costo')
for u in (0.14, 0.25, 0.4, 0.5, 0.65):
p = (probabilidad >= u).astype(int)
tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()
prec = tp / (tp + fp) if (tp + fp) else 0
print(f'{u:6.2f} {tp + fp:7d} {tp:6d} {prec:9.3f} S/{fp * 15 + fn * 800:,}')
umbral llama a cierra precisión costo 0.14 743 433 0.583 S/4,650 0.25 718 428 0.596 S/8,350 0.40 600 391 0.652 S/36,735 0.50 508 348 0.685 S/70,400 0.65 299 231 0.773 S/162,620
Cada fila es una política comercial distinta, y la elige el negocio, no tú. Tu trabajo es poner la tabla encima de la mesa con los soles al lado 📋
7. El modelo no decide, el modelo ordena
Junta las dos ideas del capítulo: con capacidad de 200, cuál es el corte de probabilidad que corresponde.
corte = np.sort(probabilidad)[::-1][199]
print('probabilidad del puesto 200:', round(corte, 4))
print('llamando por umbral 0,14:', int((probabilidad >= 0.14).sum()))
print('llamando por capacidad :', 200)
probabilidad del puesto 200: 0.7109 llamando por umbral 0,14: 743 llamando por capacidad : 200
Con capacidad de 200, el umbral efectivo es 0,7109. No lo elegiste tú: lo eligió el tamaño del equipo comercial.
Y esa es la conclusión del capítulo, que quiero que se te quede: un modelo de clasificación en la vida real casi nunca decide sí o no, casi siempre ordena una lista que alguien va a recorrer hasta donde le alcance 📞
Comprueba que lo tienes
¿De dónde sale el 0,5 que usa el modelo para decidir?
- De la librería, y casi nunca es el que le conviene a tu negocio
- De la teoría estadística
- De los datos de entrenamiento
- Es el valor que maximiza la exactitud
Lo que te llevas
- 🎚️
predict()lleva un 0,5 escondido que no eligió nadie. - 💰 Con falso positivo a S/15 y falso negativo a S/800, el óptimo es 0,14 y ahorra S/65.750 sobre 750 oportunidades. Sin tocar el modelo.
- ⚖️ El 0,5 solo es correcto cuando los dos errores cuestan igual: con costos iguales el óptimo sale 0,49.
- 😐 Con esos precios el óptimo llama a 743 de 750, o sea a casi todos. Hay que decirlo, no esconderlo.
- 📞 Y ahí entra la capacidad: con 200 llamadas, el modelo trae 159 ventas contra 115 al azar. Cuarenta y cuatro más por el mismo trabajo.
- 🔒 El umbral se elige en validación, nunca en el examen.
- 📋 Tu trabajo es poner la tabla de umbrales con soles al lado. La política la elige el negocio.
En el capítulo 16 dejamos de fiarnos de una sola partición: validación cruzada, búsqueda de hiperparámetros y cómo no engañarse a una misma.
Que tengas lindo día! 🌸