Capítulo 15 de 25 10 secciones 14 min

El umbral, o los S/65.750 que valía mover un número

El 0,5 que viene de fábrica, cuánto cuesta cada error, y por qué un modelo casi nunca decide: ordena.

predict() compara la probabilidad contra 0,5, un número que no eligió nadie. El umbral correcto sale de cuánto cuesta cada error: aquí, con una llamada de más a S/15 y una venta perdida 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 lo mismo. Y como el óptimo llama a 743 de 750, lo que de verdad aporta el modelo no es el sí o el no: es el orden de la lista.

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
Dos campanas solapadas, la de los casos negativos y la de los positivos, con una línea vertical de umbral entre ellas. La zona solapada a la derecha del umbral son falsos positivos y la de la izquierda falsos negativos.
Aquí está el porqué de todo lo anterior: las dos campanas se solapan, y mientras se solapen no existe ningún umbral sin error. Moverlo no quita errores, cambia de cuál te toca. Por eso la pregunta correcta no es cuál es el mejor umbral sino cuál de los dos errores te cuesta más caro.

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?

Curva del costo total de los errores según dónde se ponga el umbral, con una línea de puntos en el 0,5 que trae la librería y otra línea en el umbral que de verdad hace mínimo el costo, bastante a la izquierda.
El 0,5 que trae el modelo por defecto no sale de tu negocio, sale de la librería. El umbral bueno es el que hace mínima esta curva, y depende de cuánto cuesta cada uno de los dos errores.

El costo de cada umbral

Costo(u)=cFPFP(u)+cFNFN(u)

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! 🌸

¿Tienes alguna duda o consulta?