Capítulo 18 de 25 8 secciones 13 min

Cuando lo que importa pasa el 7% de las veces

Pesos, umbral y remuestreo dan el mismo resultado, y ninguno crea información.

Con 7,6% de positivos, el modelo sin tocar encuentra 0 de 57 casos porque decir que no acierta el 92% de las veces. Las tres soluciones habituales (class_weight balanced, mover el umbral y remuestrear) dan exactamente el mismo resultado: 57 de 57 con 128 falsas alarmas. Y el AUC no se mueve en ninguna, porque no crean información: solo cambian el punto de corte. Lo que sí crea información es tener más casos positivos.

En el capítulo 14 dejamos un modelo tirado: el que predice si una venta va a cerrar por más de S/2.000. Tenía 92,27% de exactitud, un listón de 92,40% y encontraba cero de las 57 ventas grandes 😱

Hoy lo arreglamos. Y de paso vamos a ver que las tres técnicas que todo el mundo recomienda para esto hacen exactamente lo mismo, y que ninguna crea información.

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 (average_precision_score, confusion_matrix,
                             precision_score, recall_score, roc_auc_score)
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['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
    return v

# Sin monto ni precio_unitario: el objetivo se define con el monto, así que
# dárselas sería copiarle la respuesta.
NUMERICAS = ['unidades', 'descuento', 'satisfaccion', 'sin_compra_previa',
             'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']

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

datos = prepara(carga_limpia(URL))
y = ((datos['compro'] == 1) & (datos['monto'] > 2000)).astype(int)
X = datos[NUMERICAS + CATEGORICAS]

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
                                          random_state=42, stratify=y)
print('positivos:', round(y.mean(), 4), '| en train:', int(y_tr.sum()),
      '| en test:', int(y_te.sum()))
positivos: 0.076 | en train: 171 | en test: 57

7,6% de positivos. 171 casos para entrenar y 57 para examinar. Eso ya es desbalance de verdad, y en fraude o en fallas de máquina te vas a encontrar 1% o menos.

Dos curvas de precisión contra recall para un problema con 6% de positivos, con la línea base horizontal en 0,06. Al subir el recall la precisión cae en las dos curvas.
Con clases raras esta curva dice lo que el AUC esconde. Fíjate en la línea de abajo: la base no es 0,5 sino la prevalencia, o sea el 6%. Contra esa referencia se mide si el modelo aporta algo, y la caída de la curva es el precio en falsas alarmas de cada punto de recall.

El problema, una vez más

def informe(m, nombre):
    p = m.predict(X_te)
    prob = m.predict_proba(X_te)[:, 1]
    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()
    print(f'{nombre:24} encontró {tp:2d}/57   falsas alarmas {fp:3d}   '
          f'recall {recall_score(y_te, p, zero_division=0):.4f}   '
          f'precisión {precision_score(y_te, p, zero_division=0):.4f}   '
          f'AUC {roc_auc_score(y_te, prob):.4f}')

crudo = arma().fit(X_tr, y_tr)
informe(crudo, 'sin hacer nada')
sin hacer nada           encontró  0/57   falsas alarmas   1   recall 0.0000   precisión 0.0000   AUC 0.9200

Cero de 57. El modelo aprendió que decir "no" acierta el 92% de las veces y hace eso.

No es que sea tonto: es que le pedimos que maximice los aciertos y eso es lo que maximiza los aciertos. El problema no está en el modelo, está en lo que le pedimos 🎯

Arreglo 1: pesar las clases

La forma más limpia. Le decimos que equivocarse con un positivo cuesta más.

pesado = arma(LogisticRegression(max_iter=1000, random_state=42,
                                 class_weight='balanced')).fit(X_tr, y_tr)
informe(pesado, 'class_weight balanced')
class_weight balanced    encontró 57/57   falsas alarmas 128   recall 1.0000   precisión 0.3081   AUC 0.9200

57 de 57 🎉 Y 128 falsas alarmas, con una precisión de 0,308.

O sea: encuentra absolutamente todas las ventas grandes, a cambio de que de cada tres avisos solo uno sea bueno.

¿Eso es bueno? Depende de lo que cueste cada cosa, que es el capítulo 15. Si perseguir una venta grande cuesta poco y ganarla vale S/2.000, está regalado. Si cuesta una visita presencial a Chiclayo, no.

class_weight='balanced' pone el peso de cada clase inversamente proporcional a cuántos casos tiene. Está en casi todos los modelos de scikit-learn y es una sola palabra 💛

Arreglo 2: mover el umbral

Y ahora lo mismo, sin tocar el modelo, con lo del capítulo 15.

prob = crudo.predict_proba(X_te)[:, 1]
for u in (0.5, 0.2, 0.1, 0.076):
    p = (prob >= u).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()
    print(f'umbral {u:<6} encontró {tp:2d}/57   falsas alarmas {fp:3d}   '
          f'precisión {precision_score(y_te, p, zero_division=0):.4f}')
umbral 0.5    encontró  0/57   falsas alarmas   1   precisión 0.0000
umbral 0.2    encontró 46/57   falsas alarmas 101   precisión 0.3129
umbral 0.1    encontró 56/57   falsas alarmas 127   precisión 0.3060
umbral 0.076  encontró 57/57   falsas alarmas 128   precisión 0.3081

Mira la última fila: umbral 0,076 (que es justo la tasa de positivos) encuentra las 57 con 128 falsas alarmas y precisión 0,3081.

Exactamente el mismo resultado que class_weight. Hasta el último decimal.

No es casualidad: pesar las clases y mover el umbral son dos formas de escribir la misma decisión. Cambian dónde está la raya, no lo que el modelo sabe 🎚️

Arreglo 3: remuestrear

El tercero es el que más se recomienda en internet y el que menos me gusta: cambiar los datos para que haya la misma cantidad de cada clase.

entrenamiento = X_tr.copy()
entrenamiento['objetivo'] = y_tr.values

positivos = entrenamiento[entrenamiento['objetivo'] == 1]
negativos = entrenamiento[entrenamiento['objetivo'] == 0].sample(
    n=len(positivos), random_state=42)
balanceado = pd.concat([positivos, negativos]).sample(frac=1, random_state=42)

print('de', len(entrenamiento), 'filas a', len(balanceado))

submuestreo = arma().fit(balanceado[NUMERICAS + CATEGORICAS], balanceado['objetivo'])
informe(submuestreo, 'submuestreo')
de 2250 filas a 342
submuestreo              encontró 57/57   falsas alarmas 128   recall 1.0000   precisión 0.3081   AUC 0.9052

Otra vez 57 de 57 y 128 falsas alarmas. Lo mismo.

Y fíjate en el precio que pagó: pasó de entrenar con 2.250 filas a hacerlo con 342. Tiró 1.908 filas de datos buenos, y el AUC bajó de 0,92 a 0,9052.

El sobremuestreo (repetir los positivos hasta igualar) es lo contrario y tampoco regala nada:

repetidos = pd.concat([positivos] * (len(entrenamiento[entrenamiento['objetivo'] == 0]) // len(positivos)))
sobre = pd.concat([entrenamiento[entrenamiento['objetivo'] == 0], repetidos]).sample(frac=1, random_state=42)

sobremuestreo = arma().fit(sobre[NUMERICAS + CATEGORICAS], sobre['objetivo'])
informe(sobremuestreo, 'sobremuestreo')
sobremuestreo            encontró 57/57   falsas alarmas 127   recall 1.0000   precisión 0.3098   AUC 0.9200

AUC 0,92, igual que el original. Lo único que hizo fue tardar más en entrenar.

La conclusión, que es incómoda y liberadora

Mira los AUC de los cuatro: 0,9200 sin hacer nada, 0,9200 con pesos, 0,9052 con submuestreo, 0,9200 con sobremuestreo.

Ninguna técnica de balanceo mejoró la capacidad del modelo para ordenar. Lo único que hicieron fue mover el punto de corte, y eso ya sabíamos hacerlo desde el capítulo 15 con una línea.

Lo digo claro porque se enseña al revés en muchos sitios: balancear no crea información. Si el modelo no sabe distinguir, duplicar filas no le va a enseñar nada nuevo.

Lo que sí funciona con clases raras, en orden de rentabilidad:

  1. 1️⃣ Medir con la métrica correcta. AUC PR y recall, nunca exactitud.
  2. 2️⃣ Elegir el umbral por costo. Capítulo 15.
  3. 3️⃣ Conseguir más casos positivos, aunque sea de otro periodo. Esto sí crea información.
  4. 4️⃣ Construir columnas mejores. Capítulo 9.
  5. 5️⃣ Y si acaso, class_weight='balanced', que es gratis y no estorba.

El SMOTE, que es la técnica que genera positivos sintéticos interpolando entre los que hay, vive en una librería aparte (imbalanced-learn) y no está instalada aquí. Merece un aviso: inventa filas que no existieron, y en datos de negocio con muchas categóricas suele generar combinaciones imposibles, tipo un mayorista de Cusco que compra por un canal que Cusco no tiene 🤨

Ejercicios

Siete. Intenta antes de abrir 💛

1. El AUC PR, que es el que hay que mirar

Compara AUC ROC y AUC PR de los cuatro modelos.

for nombre, m in [('sin nada', crudo), ('pesado', pesado),
                  ('submuestreo', submuestreo), ('sobremuestreo', sobremuestreo)]:
    pr = m.predict_proba(X_te)[:, 1]
    print(f'{nombre:14} ROC {roc_auc_score(y_te, pr):.4f}   '
          f'PR {average_precision_score(y_te, pr):.4f}')
sin nada       ROC 0.9200   PR 0.3741
pesado         ROC 0.9200   PR 0.3685
submuestreo    ROC 0.9052   PR 0.3711
sobremuestreo  ROC 0.9200   PR 0.3704

Los ROC casi idénticos y los PR también, entre 0,34 y 0,38.

Y acuérdate de contra qué se compara el PR: la tasa base es 0,076, así que 0,37 es casi cinco veces mejor que el azar. El ROC de 0,92 suena a casi perfecto y el PR pone el logro en su tamaño real 📐

2. El umbral por costo, aquí

Una venta grande deja S/600 de margen y perseguirla cuesta S/40. ¿Dónde está el corte?

def ganancia(u):
    p = (prob >= u).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()
    return tp * 600 - (tp + fp) * 40

for u in (0.076, 0.15, 0.25, 0.4, 0.5):
    print(f'umbral {u:<6} S/{ganancia(u):,}')
umbral 0.076  S/26,800
umbral 0.15   S/23,320
umbral 0.25   S/19,960
umbral 0.4    S/5,080
umbral 0.5    S/-40

Con esos precios el máximo está abajo del todo: perseguirlas todas es lo mejor, porque cada acierto deja S/600 y cada intento cuesta S/40. El 0,5 de fábrica deja S/-40, o sea que se pierde plata.

Y ahora súbele el costo de perseguir, que es lo que pasa si en vez de una llamada hay que ir a visitar:

def ganancia_con(costo, u):
    p = (prob >= u).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()
    return tp * 600 - (tp + fp) * costo

for costo in (40, 150, 250):
    mejor = max((ganancia_con(costo, u), round(u, 2))
                for u in np.arange(0.05, 0.95, 0.01))
    print(f'perseguir cuesta S/{costo:3d}  ->  umbral {mejor[1]}   S/{mejor[0]:,}')
perseguir cuesta S/ 40  ->  umbral 0.09   S/26,840
perseguir cuesta S/150  ->  umbral 0.25   S/7,200
perseguir cuesta S/250  ->  umbral 0.36   S/1,150

El umbral óptimo se mueve de 0,09 a 0,36 según lo que cueste perseguir.

Ahí está la lección: el balanceo perfecto (0,076) no es el óptimo de negocio, y que coincidan en el primer caso es casualidad de esos números. El óptimo lo dan los soles, no la proporción de las clases 💰

3. El peso, a mano

En vez de 'balanced', pon tú el peso y mira qué pasa.

for peso in (1, 3, 12, 50):
    m = arma(LogisticRegression(max_iter=1000, random_state=42,
                                class_weight={0: 1, 1: peso})).fit(X_tr, y_tr)
    p = m.predict(X_te)
    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()
    print(f'peso {peso:3d}  encontró {tp:2d}/57  falsas alarmas {fp:3d}')
peso   1  encontró  0/57  falsas alarmas   1
peso   3  encontró 41/57  falsas alarmas  74
peso  12  encontró 57/57  falsas alarmas 127
peso  50  encontró 57/57  falsas alarmas 128

El peso es otro dial exactamente igual que el umbral. Con 12 (que es más o menos 92/8, la proporción de las clases) se llega al mismo sitio que 'balanced'.

Tres formas de girar la misma perilla, y por eso conviene quedarse con la más simple: el umbral, porque se explica en una frase y se cambia sin reentrenar 🎚️

4. Cuántos positivos hacen falta

Entrena con 25, 50, 100 y los 171 positivos y mira el AUC.

negs = entrenamiento[entrenamiento['objetivo'] == 0]
for n in (25, 50, 100, 171):
    trozo = pd.concat([positivos.head(n), negs])
    m = arma(LogisticRegression(max_iter=1000, random_state=42,
                                class_weight='balanced')).fit(
        trozo[NUMERICAS + CATEGORICAS], trozo['objetivo'])
    pr = m.predict_proba(X_te)[:, 1]
    print(f'{n:3d} positivos  AUC {roc_auc_score(y_te, pr):.4f}  '
          f'PR {average_precision_score(y_te, pr):.4f}')
 25 positivos  AUC 0.9113  PR 0.3313
 50 positivos  AUC 0.9145  PR 0.3551
100 positivos  AUC 0.9162  PR 0.3640
171 positivos  AUC 0.9200  PR 0.3685

De 25 a 171 positivos el AUC sube y se estabiliza. Eso sí es información nueva, a diferencia de duplicar los que ya tienes.

Es el argumento con el que se pide presupuesto: "con el doble de casos positivos etiquetados, el modelo mejora esto". Con una tabla 📈

5. Cuando la clase rara es rarísima

Parte sin stratify con una clase de solo dos casos y mira qué te devuelve.

y_rarisima = ((datos['compro'] == 1) & (datos['monto'] > 4000)).astype(int)
print('positivos en total:', int(y_rarisima.sum()))

a, b, c, d = train_test_split(X, y_rarisima, test_size=0.25, random_state=0)
print('positivos en train:', int(c.sum()), '| en test:', int(d.sum()))
print(roc_auc_score(d, arma().fit(a, c).predict_proba(b)[:, 1]))
positivos en total: 2
positivos en train: 2 | en test: 0
nan

nan. Los dos positivos cayeron en entrenamiento, el examen se quedó con una sola clase, y el AUC no da error: devuelve nada 😐

Otra vez el patrón del libro. Si ese nan se cuela en un promedio de validación cruzada, se lleva el resultado entero por delante y nadie se entera.

Con estratificar tampoco se arregla si la clase es demasiado rara:

y_unica = ((datos['compro'] == 1) & (datos['monto'] > 4236)).astype(int)
print('positivos:', int(y_unica.sum()))
train_test_split(X, y_unica, test_size=0.25, random_state=0, stratify=y_unica)
ValueError: The least populated classes in y have only 1 member, which is too few. The minimum number of groups for any class cannot be less than 2. Classes with too few members are: [1]

Ese sí avisa, y avisa bien: con un solo caso positivo no se puede ni partir. Con clases raras, stratify=y no es opcional, y si ni con eso alcanza, es que no hay casos suficientes para modelar y eso hay que decirlo en vez de entregar un número 🚩

6. La matriz de las cuatro versiones

Las cuatro matrices de confusión juntas, para verlo de un vistazo.

for nombre, m in [('sin nada', crudo), ('pesado', pesado),
                  ('submuestreo', submuestreo), ('sobremuestreo', sobremuestreo)]:
    tn, fp, fn, tp = confusion_matrix(y_te, m.predict(X_te), labels=[0, 1]).ravel()
    print(f'{nombre:14} tn {tn:3d}  fp {fp:3d}  fn {fn:2d}  tp {tp:2d}')
sin nada       tn 692  fp   1  fn 57  tp  0
pesado         tn 565  fp 128  fn  0  tp 57
submuestreo    tn 565  fp 128  fn  0  tp 57
sobremuestreo  tn 566  fp 127  fn  0  tp 57

Tres de las cuatro filas son idénticas. La primera es la del modelo que no encuentra nada.

Esa tabla es la que yo llevaría a la reunión: no hay que elegir entre tres técnicas, hay que elegir un punto de corte 🎯

7. La receta para clases raras

Escribe el flujo completo, del que puedas copiar en tu próximo proyecto.

1. MIDE con AUC PR y recall. Nunca con exactitud.
   El listón del PR es la tasa de positivos, no 0,5.

2. ENTRENA normal, sin tocar los datos.
   class_weight='balanced' si quieres, es gratis.

3. ELIGE el umbral con los costos del negocio (capítulo 15),
   en validación, no en el examen.

4. REPORTA cuántos casos positivos había.
   Con menos de 50 en el examen, cualquier métrica se mueve muchísimo.

5. SI NO ALCANZA, pide más casos positivos.
   Es lo único de esta lista que crea información.

Fíjate en lo que no está: SMOTE, submuestreo y sobremuestreo. No es que estén prohibidos, es que en este problema no aportaron nada y añaden una pieza más que puede fallar.

Si en el tuyo sí aportan, la forma de saberlo es medir con validación cruzada (capítulo 16) y comparar contra no hacer nada 💛

Comprueba que lo tienes

Tu clase positiva es el 3% de los datos. ¿Qué haces primero?

  • Cambio la métrica antes de tocar los datos
  • Remuestreo hasta equilibrar las clases
  • Genero datos sintéticos de la clase rara
  • Uso un modelo más potente

Lo que te llevas

  • 😱 Con 7,6% de positivos, el modelo sin tocar encuentra 0 de 57.
  • ⚖️ class_weight='balanced' encuentra las 57, con 128 falsas alarmas y precisión 0,308.
  • 🎚️ Mover el umbral a 0,076 da exactamente el mismo resultado. Son la misma decisión escrita de dos formas.
  • 🚫 Balancear no crea información: los AUC son 0,9200, 0,9200, 0,9052 y 0,9200.
  • ✂️ El submuestreo tiró 1.908 filas buenas y bajó el AUC.
  • 💰 El óptimo de negocio (umbral 0,15) no es el balanceo perfecto (0,076) ni el de fábrica (0,5).
  • 📈 Lo único que crea información es tener más casos positivos de verdad.

En el capítulo 19 abrimos la caja: qué mira el modelo, cómo se explica un coeficiente y por qué la importancia que trae el bosque no es de fiar.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?