Capítulo 22 de 25 10 secciones 21 min

Para quién funciona peor y cuánto puedes fiarte

El modelo tiene 0,72 de AUC en general y menos de 0,69 dentro de cada segmento. Cómo se mide eso y cómo se dice "no sé".

Un modelo se audita midiendo lo mismo dentro de cada grupo, no solo en total. Aquí el AUC global es 0,7214 y dentro de cada segmento es menor que eso, porque buena parte de lo que el modelo sabe es distinguir Mayoristas de Bodegas. Y con predicción conforme el modelo puede decir "no sé": en las 250 filas donde lo dice acierta 54,8% y en las 500 donde se moja acierta 73,6%.

Hasta aquí el modelo tiene un número: 0,7214 de AUC. Y un número solo se parece mucho a una nota del colegio, que te dice cómo te fue en promedio y no te dice en qué te equivocaste 📋

Este capítulo son dos preguntas incómodas. La primera es para quién funciona peor. La segunda es cuándo el modelo no sabe, que es distinto de cuándo se equivoca.

Y la primera nos va a dar una sorpresa que a mí me tomó un rato entender.

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 accuracy_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['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)
prob = modelo.predict_proba(X_te)[:, 1]
pred = (prob >= 0.5).astype(int)

print('filas de prueba:', len(y_te))
print('acierto:', round(accuracy_score(y_te, pred), 4))
print('AUC    :', round(roc_auc_score(y_te, prob), 4))
filas de prueba: 750
acierto: 0.6733
AUC    : 0.7214

Ese es el modelo del capítulo 11, tal cual. Ahora lo vamos a abrir por grupos.

Lo mismo, pero dentro de cada segmento

Auditar un modelo es aburrido de explicar y fácil de hacer: mides lo mismo que ya mediste, pero una vez por grupo.

def por_grupo(columna):
    filas = []
    for g in sorted(X_te[columna].unique()):
        m = (X_te[columna] == g).values
        filas.append({
            columna: g,
            'n': m.sum(),
            'compra_real': round(y_te[m].mean(), 4),
            'le_dice_si': round(pred[m].mean(), 4),
            'acierto': round(accuracy_score(y_te[m], pred[m]), 4),
            'encuentra': round(recall_score(y_te[m], pred[m]), 4),
            'AUC': round(roc_auc_score(y_te[m], prob[m]), 4),
        })
    return pd.DataFrame(filas)

print(por_grupo('segmento').to_string(index=False))
  segmento   n  compra_real  le_dice_si  acierto  encuentra    AUC
    Bodega 174       0.3391      0.2414   0.6954     0.4068 0.7066
    Horeca 187       0.6096      0.8021   0.6471     0.8684 0.6894
 Mayorista 191       0.7696      0.9110   0.7435     0.9252 0.5625
Minimarket 198       0.5707      0.7172   0.6111     0.7876 0.6695

Léela con calma que hay tres cosas aquí dentro 👀

Uno. El segmento donde el modelo tiene el mejor acierto es Mayorista. Y es el segmento donde tiene el peor AUC. Las dos cosas a la vez.

Eso pasa porque el 76,96% de los Mayoristas de la prueba compran, y el modelo le dice que sí al 91,10% de ellos. Con esa mezcla acierta mucho sin distinguir nada. El acierto se lo regala la tasa base, no el modelo.

Dos. Mira la columna encuentra. De las Bodegas que sí compraron, el modelo encuentra el 40,68%. De los Mayoristas que sí compraron, encuentra el 92,52%. El mismo modelo, el mismo umbral, y una diferencia de 51 puntos según de qué segmento seas.

Tres. Ningún AUC por segmento llega a 0,7214, que es el AUC global. Ninguno. Y eso al principio me pareció un error mío.

La sorpresa: el modelo es peor por dentro que por fuera

No es un error. Es que el AUC global mide si el modelo ordena bien todas las filas juntas, y ahí hay un atajo enorme:

  • Los Mayoristas compran el 72
  • 40% de las veces y las Bodegas el 37
  • 48%
print(datos.groupby('segmento')['compro'].agg(['size', 'mean']).round(4).to_string())
            size    mean
segmento                
Bodega       707  0.3748
Horeca       742  0.6321
Mayorista    750  0.7240
Minimarket   801  0.5693

O sea que buena parte de lo que el modelo hace bien es poner a los Mayoristas arriba y a las Bodegas abajo. Eso es útil y es real. Pero cuando el gerente de la zona te pide a quién llamar entre sus Bodegas, ese atajo ya no sirve: todas son Bodegas.

Y ahí el modelo vale lo que valga por dentro del grupo, que es menos.

Antes de contarlo por ahí conviene medir cuánto de esto es real y cuánto es la lotería de la partición, que es la lección del capítulo 16 aplicada a los grupos:

por_semilla = {}
for s in range(10):
    A_tr, A_te, b_tr, b_te = train_test_split(X, y, test_size=0.25,
                                              random_state=s, stratify=y)
    m = Pipeline([('pre', pre),
                  ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(A_tr, b_tr)
    p = m.predict_proba(A_te)[:, 1]
    por_semilla.setdefault('TODO', []).append(roc_auc_score(b_te, p))
    for g in sorted(A_te['segmento'].unique()):
        msk = (A_te['segmento'] == g).values
        por_semilla.setdefault(g, []).append(roc_auc_score(b_te[msk], p[msk]))

for g, v in por_semilla.items():
    v = np.array(v)
    print(f'{g:11} media={v.mean():.4f}  min={v.min():.4f}  max={v.max():.4f}')
TODO        media=0.7087  min=0.6870  max=0.7419
Bodega      media=0.6835  min=0.6372  max=0.7334
Horeca      media=0.6593  min=0.6220  max=0.7234
Mayorista   media=0.6479  min=0.5901  max=0.7123
Minimarket  media=0.6772  min=0.6319  max=0.7201

Aquí hay dos noticias.

La buena: el hallazgo aguanta. En diez particiones distintas el AUC global promedia 0,7087 y la mejor media por segmento es 0,6835. El modelo es peor dentro de cada grupo que en el total, y no era cosa de la semilla 42 💡

La mala: el 0,5625 de Mayorista que salió arriba era una partición con mala suerte. En diez semillas ese segmento promedia 0,6479 y su peor caso es 0,5901. Sigue siendo el peor de los cuatro, pero no es el desastre que sugería el número suelto.

Por eso una tabla por grupos nunca se entrega con una sola partición. Cada grupo tiene ahí unas 190 filas y con 190 filas los números bailan 🎲

El mismo umbral no trata igual a todos

Volvamos a los 51 puntos de diferencia en encuentra. Eso no viene de que el modelo odie a las bodegas:

  • Viene de que 0
  • 5 es un corte y las bodegas tienen las probabilidades más bajas
  • Así que quedan casi todas del lado del no

Si lo que el negocio quiere es encontrar al 80% de los que van a comprar en cada segmento, el corte tiene que ser distinto en cada uno:

for g in sorted(X_te['segmento'].unique()):
    m = (X_te['segmento'] == g).values
    elegido = 0.5
    for u in np.arange(0.05, 0.96, 0.01):
        if recall_score(y_te[m], (prob[m] >= u).astype(int)) >= 0.80:
            elegido = u
    r = recall_score(y_te[m], (prob[m] >= elegido).astype(int))
    print(f'{g:11} umbral={elegido:.2f}  encuentra={r:.4f}  '
          f'con 0.5 encontraba={recall_score(y_te[m], pred[m]):.4f}')
Bodega      umbral=0.34  encuentra=0.8136  con 0.5 encontraba=0.4068
Horeca      umbral=0.55  encuentra=0.8070  con 0.5 encontraba=0.8684
Mayorista   umbral=0.60  encuentra=0.8027  con 0.5 encontraba=0.9252
Minimarket  umbral=0.49  encuentra=0.8053  con 0.5 encontraba=0.7876

Para las Bodegas hay que bajar el corte a 0,34 y para los Mayoristas subirlo a 0,60. Con eso los cuatro segmentos quedan parejos en el 80%.

Ahora la parte que no es técnica. Emparejar tiene un precio: bajar el corte de las bodegas significa visitar bodegas que no van a comprar, y subir el de los mayoristas significa dejar de visitar mayoristas que sí habrían comprado. Si lo único que te importa es el total de ventas, el umbral único gana.

Si te importa que tu fuerza de ventas no abandone un segmento entero porque el modelo casi nunca lo nombra, entonces emparejar es lo correcto aunque cueste.

Eso lo decide la persona que responde por el negocio, no el modelo. Lo tuyo es poner la tabla sobre la mesa para que la decisión se tome sabiendo 🤝

Curva de calibración con la diagonal perfecta de referencia y tres líneas: la del total, que se le pega bastante, y las de dos segmentos, que se separan hacia lados contrarios.
Estar calibrado en total no es estarlo por grupo. Aquí las dos líneas de segmento caen a lados distintos de la diagonal, o sea que los errores se cancelan entre grupos y el promedio disimula.
Curva de calibración: la probabilidad que predice el modelo en el eje horizontal contra la frecuencia observada en el vertical, comparada con la diagonal de calibración perfecta. La curva del modelo va por encima de la diagonal en las probabilidades bajas.
Si el modelo estuviera calibrado, la curva caería sobre la diagonal. Cuando va por encima en la parte baja, significa que a los casos a los que les pone 0,2 les pasa más a menudo de lo que dice: sus probabilidades ordenan bien y no miden bien.

¿Un 0,7 es de verdad un 70%?

Una probabilidad está bien calibrada si de cada 100 filas a las que les pones 0,7, compran unas 70. Suena obvio y casi ningún modelo lo cumple.

Se comprueba partiendo las predicciones en diez montones y comparando lo que prometió con lo que pasó:

tabla = pd.DataFrame({'prob': prob, 'real': y_te.values})
tabla['monton'] = pd.qcut(tabla['prob'], 10, labels=False)
print(tabla.groupby('monton').agg(n=('real', 'size'),
                                  prometio=('prob', 'mean'),
                                  paso=('real', 'mean')).round(4).to_string())
         n  prometio    paso
monton                      
0       75    0.2442  0.2133
1       75    0.3634  0.3467
2       75    0.4490  0.4267
3       75    0.5162  0.5733
4       75    0.5745  0.5067
5       75    0.6271  0.6267
6       75    0.6711  0.6933
7       75    0.7184  0.8000
8       75    0.7719  0.7733
9       75    0.8451  0.8133

Las dos puntas casi clavan: el montón más bajo prometía 0,2442 y compró el 21,33%, y el más alto prometía 0,8451 y compró el 81,33%. La regresión logística sale bastante calibrada de fábrica porque es lo que optimiza mientras entrena 🎯

En el medio se mueve más. El montón 7 prometía 0,7184 y compró el 80,00%, ocho puntos por encima. Tampoco te alarmes: son 75 filas por montón, y con 75 filas ocho puntos son cuatro personas que compraron de más 🤏

Pero por grupos vuelve a pasar lo de siempre:

for g in sorted(X_te['segmento'].unique()):
    m = (X_te['segmento'] == g).values
    print(f'{g:11} prometio={prob[m].mean():.4f}  paso={y_te[m].mean():.4f}  '
          f'brecha={prob[m].mean() - y_te[m].mean():+.4f}')
Bodega      prometio=0.3889  paso=0.3391  brecha=+0.0498
Horeca      prometio=0.6290  paso=0.6096  brecha=+0.0194
Mayorista   prometio=0.6996  paso=0.7696  brecha=-0.0700
Minimarket  prometio=0.5790  paso=0.5707  brecha=+0.0083

A los Mayoristas les promete 7 puntos menos de lo que pasa y a las Bodegas les promete 5 puntos de más. Está bien calibrado en promedio porque los errores se cancelan entre grupos, que es exactamente la trampa que tiene mirar solo el promedio 🙃

Predicción puntual con una banda de cobertura alrededor sobre una nube de puntos. Donde hay muchos puntos la banda es estrecha, y en el tramo de la derecha, donde hay pocos, la banda se ensancha.
Un modelo que sabe cuándo no sabe. La banda se ensancha justo donde escasean los datos, y esa es la parte que se manda a una persona en vez de resolverla en automático. La garantía es sobre cuántas veces la banda contiene el valor real, no sobre lo estrecha que salga.

Que el modelo diga cuándo no sabe

q=cuantil1α({si}icalibración)

guardas un trozo de datos aparte, mides ahí lo mal que se porta el modelo y usas ese cuantil como frontera, que es lo que hace que la cobertura prometida se cumpla de verdad

Un modelo siempre contesta. Le des la fila que le des, escupe un número entre 0 y 1 y se queda tan tranquilo. No tiene forma de decir "esta no la sé".

Sí la tiene, y es una idea preciosa que se llama predicción conforme. La versión más simple cabe en cinco líneas y funciona así:

  • 🧪 Apartas un trozo de datos que el modelo no vio, la calibración.
  • 📏 En ese trozo mides, para cada fila, cuánta probabilidad le dio a la respuesta que resultó ser la correcta.
  • ✂️ De esos errores te quedas con el percentil que corresponde a la confianza que quieres.
  • 📦 Para una fila nueva devuelves todas las respuestas que superan ese listón, que pueden ser una, las dos, o ninguna.

Cuando devuelve las dos, el modelo te está diciendo que no sabe.

X_ent, X_cal, y_ent, y_cal = train_test_split(X_tr, y_tr, test_size=0.3,
                                              random_state=42, stratify=y_tr)
conf = Pipeline([('pre', pre),
                 ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(X_ent, y_ent)

p_cal = conf.predict_proba(X_cal)
fallo = 1 - p_cal[np.arange(len(y_cal)), y_cal.values]

alpha = 0.20
n = len(fallo)
k = int(np.ceil((n + 1) * (1 - alpha)))
listero = np.sort(fallo)[k - 1]
print('filas de calibracion:', n)
print('listón:', round(1 - listero, 4))
filas de calibracion: 675
listón: 0.3888

Ese 1 - listero es el listón: cualquier clase a la que el modelo le dé al menos esa probabilidad entra en la respuesta.

El (n + 1) y el ceil no son adorno. Son la corrección que hace que la garantía valga con las filas que tienes y no solo en el infinito.

p_te = conf.predict_proba(X_te)
conjunto = p_te >= 1 - listero
tam = conjunto.sum(axis=1)
cubre = conjunto[np.arange(len(y_te)), y_te.values]

print('prometimos cubrir:', 1 - alpha)
print('cubrimos         :', round(cubre.mean(), 4))
print()
print('una sola respuesta:', (tam == 1).sum())
print('las dos (no sé)   :', (tam == 2).sum())
print('ninguna           :', (tam == 0).sum())
prometimos cubrir: 0.8
cubrimos         : 0.824

una sola respuesta: 500
las dos (no sé)   : 250
ninguna           : 0

Prometimos cubrir el 80% de los casos y cubrimos el 82,40%. Eso no es suerte: es lo que la predicción conforme garantiza, sin pedirle nada al modelo ni a los datos más que haber apartado bien la calibración 🪄

Y ahora lo que hace que esto valga la pena de verdad:

for t in [1, 2]:
    m = tam == t
    print(f'conjunto de {t}: n={m.sum():3d}  '
          f'acierta={accuracy_score(y_te[m], pred[m]):.4f}')
conjunto de 1: n=500  acierta=0.7360
conjunto de 2: n=250  acierta=0.5480

En las 500 filas donde el modelo se moja acierta el 73,60%. En las 250 donde dice "no sé" acierta el 54,80%, que es tirar una moneda.

El modelo sabe cuándo no sabe. Solo había que dejarlo decirlo.

Y esto cambia cómo se usa: esas 250 filas no se le mandan al vendedor con una probabilidad al lado como si fuera información. Se le mandan sin recomendación, o no se le mandan.

La garantía es del total, no de cada grupo

Un detalle honesto que casi nadie cuenta. La cobertura del 80% está garantizada en promedio sobre todas las filas. Por grupo, no.

for g in sorted(X_te['segmento'].unique()):
    m = (X_te['segmento'] == g).values
    print(f'{g:11} cubre={cubre[m].mean():.4f}  no_sé={(tam[m] == 2).mean():.4f}')
Bodega      cubre=0.8736  no_sé=0.4080
Horeca      cubre=0.8289  no_sé=0.3636
Mayorista   cubre=0.7958  no_sé=0.1780
Minimarket  cubre=0.8030  no_sé=0.3889

A las Bodegas les cubre 87,36% y a los Mayoristas 79,58%. El promedio cuadra y los grupos se mueven, otra vez.

Mira también la columna no_sé: con los Mayoristas el modelo duda el 17,80% de las veces y con las Bodegas el 40,80%. O sea que es el grupo donde menos duda y el grupo donde peor ordena. Seguridad y acierto no son lo mismo, ni en los modelos ni en las personas 😅

Intentar arreglarlo se llama cobertura condicional por grupo y se hace con un listón por grupo, calculado con las filas de calibración de ese grupo. Es el ejercicio 5, y ahí vas a ver que con estos datos todavía no alcanza.

Lo que no se puede pedir menos de lo que se tiene

Una última cosa antes de los ejercicios. Prueba a pedir una confianza altísima:

alpha = 0.001
k = int(np.ceil((n + 1) * (1 - alpha)))
print('me hace falta la posición', k, 'de', n)
np.sort(fallo)[k - 1]
IndexError: index 675 is out of bounds for axis 0 with size 675

Con 675 filas de calibración no se puede prometer 99,9% de cobertura, y el error lo dice sin rodeos: hace falta la posición 676 de una lista de 675.

La regla sale de despejar: para prometer 1 - alpha necesitas al menos 1/alpha - 1 filas de calibración. Para el 90% te bastan 9; para el 99% necesitas 99; para el 99,9% necesitas 999.

Me encanta este error, sinceramente. Es una librería negándose a prometer algo que no puede cumplir, que es más de lo que hace mucha gente 🙂

Ejercicios

1. La misma auditoría por ciudad

Corre por_grupo sobre la ciudad y mira si hay alguna que salga maltratada.

print(por_grupo('ciudad').to_string(index=False))
  ciudad   n  compra_real  le_dice_si  acierto  encuentra    AUC
arequipa 137       0.5839      0.6350   0.7153     0.8000 0.7680
chiclayo 131       0.5802      0.6870   0.6641     0.8026 0.7136
   cusco 106       0.5472      0.6887   0.6321     0.7931 0.6537
    lima 136       0.6029      0.6544   0.6691     0.7683 0.7448
   piura 138       0.5652      0.7101   0.6522     0.8205 0.6897
trujillo 102       0.5784      0.6961   0.7059     0.8475 0.7442

Aquí las diferencias son bastante más chicas que por segmento y las tasas reales de compra están todas entre 0,54 y 0,61.

El AUC va de 0,6537 en Cusco a 0,7680 en Arequipa, y visto lo que aprendimos con las diez semillas, esa diferencia de 11 puntos sobre 106 y 137 filas puede ser perfectamente ruido. Antes de escribir "el modelo funciona peor en Cusco" hay que repetirlo con varias particiones 🔍

2. Auditar por canal

Lo mismo con el canal de venta.

print(por_grupo('canal').to_string(index=False))
      canal   n  compra_real  le_dice_si  acierto  encuentra    AUC
Marketplace 185       0.4541      0.4216   0.6541     0.5833 0.6952
     Tienda 173       0.5838      0.8035   0.6416     0.8812 0.7028
        Web 209       0.5837      0.6842   0.6794     0.8115 0.6930
   WhatsApp 183       0.6885      0.8087   0.7158     0.8810 0.7544

Marketplace es el único canal donde el modelo le dice que sí a menos de la mitad (42,16%), y coincide con que es el canal con menos compras reales (45,41%). Ahí el modelo está siguiendo a los datos, no inventando.

Y fíjate que encuentra vuelve a partirse: 58,33% en Marketplace contra 88,12% en Tienda. El mismo 0,5 tratando distinto otra vez.

3. El modelo sin la columna del segmento

Si el segmento es el atajo, quítalo y mira qué queda.

SIN_SEG = ['ciudad', 'canal', 'categoria']
pre_ss = ColumnTransformer([
    ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                      ('e', StandardScaler())]), NUMERICAS),
    ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                      ('c', OneHotEncoder(handle_unknown='ignore'))]), SIN_SEG),
])
cols = NUMERICAS + SIN_SEG
ss = Pipeline([('pre', pre_ss),
               ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(
    X_tr[cols], y_tr)
p_ss = ss.predict_proba(X_te[cols])[:, 1]

print('con segmento:', round(roc_auc_score(y_te, prob), 4))
print('sin segmento:', round(roc_auc_score(y_te, p_ss), 4))
for g in sorted(X_te['segmento'].unique()):
    m = (X_te['segmento'] == g).values
    print(f'  dentro de {g:11} {roc_auc_score(y_te[m], p_ss[m]):.4f}')
con segmento: 0.7214
sin segmento: 0.7038
  dentro de Bodega      0.7076
  dentro de Horeca      0.6849
  dentro de Mayorista   0.5733
  dentro de Minimarket  0.6663

Quitar el segmento baja el AUC global, como era de esperar. Pero mira los AUC por dentro de cada segmento: se mueven poquísimo respecto a los del modelo completo.

Eso confirma lo del capítulo: el segmento aporta al ranking entre grupos y casi nada al ranking dentro de un grupo 🧩

4. Un modelo por segmento, a ver si mejora

Entrena cuatro modelos separados, uno por segmento, y compáralos con el modelo único medido dentro de ese mismo segmento.

DENTRO = ['ciudad', 'canal', 'categoria']       # el segmento aquí es constante

for g in sorted(X_te['segmento'].unique()):
    m_tr = (X_tr['segmento'] == g).values
    m_te = (X_te['segmento'] == g).values
    propio = Pipeline([
        ('pre', ColumnTransformer([
            ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                              ('e', StandardScaler())]), NUMERICAS),
            ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                              ('c', OneHotEncoder(handle_unknown='ignore'))]), DENTRO),
        ])),
        ('mod', LogisticRegression(max_iter=1000, random_state=42)),
    ]).fit(X_tr[m_tr][NUMERICAS + DENTRO], y_tr[m_tr])
    p_propio = propio.predict_proba(X_te[m_te][NUMERICAS + DENTRO])[:, 1]
    print(f'{g:11} propio={roc_auc_score(y_te[m_te], p_propio):.4f}  '
          f'unico={roc_auc_score(y_te[m_te], prob[m_te]):.4f}  '
          f'entreno con {m_tr.sum()} filas')
Bodega      propio=0.7117  unico=0.7066  entreno con 533 filas
Horeca      propio=0.6167  unico=0.6894  entreno con 555 filas
Mayorista   propio=0.5710  unico=0.5625  entreno con 559 filas
Minimarket  propio=0.6280  unico=0.6695  entreno con 603 filas

Empate técnico y por un mal camino. En Bodega y en Mayorista el modelo propio gana por 0,0051 y 0,0085, que es nada. En Horeca y en Minimarket pierde por 0,0727 y 0,0415, que ya es algo. Sumando, el modelo único va mejor.

Y tiene sentido: cada modelo propio entrena con unas 550 filas en vez de 2.250, así que lo que gana en especialización lo pierde en datos. Con cuatro veces más filas por segmento la respuesta podría cambiar, y por eso la pregunta se vuelve a hacer cuando el dataset crece.

Es la respuesta a una pregunta que siempre sale en reuniones ("¿y si hacemos uno para cada tipo de cliente?"). La respuesta es: mídelo, casi nunca gana con pocos datos 📊

Un detalle del código que me costó un rato: aquí armo un ColumnTransformer nuevo dentro del bucle en vez de reusar pre. Y no es por ordenado.

Un Pipeline no clona sus pasos: los usa tal cual y los deja entrenados. Si le paso el pre de arriba, al terminar el bucle ese objeto queda ajustado al último segmento, y el conf del apartado anterior, que comparte el mismo objeto, se rompe con un ValueError de features que no cuadran. Objeto compartido, estado compartido 🔁

5. Un listón por grupo

Calcula el listón conforme por separado dentro de cada segmento y mira si la cobertura se empareja.

alpha_g = 0.20
for g in sorted(X_cal['segmento'].unique()):
    c = (X_cal['segmento'] == g).values
    t = (X_te['segmento'] == g).values
    f_g = fallo[c]
    kg = int(np.ceil((c.sum() + 1) * (1 - alpha_g)))
    lg = np.sort(f_g)[kg - 1]
    cj = p_te[t] >= 1 - lg
    cb = cj[np.arange(t.sum()), y_te[t].values]
    print(f'{g:11} n_cal={c.sum():3d}  cubre={cb.mean():.4f}  '
          f'no_sé={(cj.sum(axis=1) == 2).mean():.4f}')
Bodega      n_cal=161  cubre=0.8851  no_sé=0.4368
Horeca      n_cal=170  cubre=0.8503  no_sé=0.4064
Mayorista   n_cal=158  cubre=0.8325  no_sé=0.2565
Minimarket  n_cal=186  cubre=0.7727  no_sé=0.3333

Y aquí es donde el libro te tiene que decir la verdad en vez de la teoría: no funcionó. Con el listón único las coberturas iban de 79,58% a 87,36%, y con un listón por grupo van de 77,27% a 88,51%. Quedaron más separadas, no menos.

La idea es correcta y lo que falla es el tamaño. Cada listón se calcula ahora con unas 160 filas en vez de con las 675 de antes, así que cada uno trae su propio temblor y el remedio mete tanto ruido como el que quita 🌡️

Fíjate además en Minimarket: 77,27% cuando le prometimos 80%. Con 186 filas de calibración eso entra dentro de lo normal, y esa es justamente la razón por la que el remedio no alcanza todavía.

Lo que yo haría con estos datos es quedarme con el listón único, reportar la tabla de cobertura por grupo tal cual está, y volver a intentar el listón por grupo cuando haya unas mil filas de calibración en cada uno. Un método que necesita más datos de los que tienes no es un método malo: es un método para más adelante 🎁

6. ¿Y si el grupo es diminuto?

Arma un grupo de pocas filas y mira qué le pasa a las métricas.

chico = X_te[(X_te['segmento'] == 'Bodega') &
             (X_te['ciudad'] == 'cusco')].index
m = X_te.index.isin(chico)
print('filas:', m.sum())
print('compraron:', int(y_te[m].sum()))
print('acierto:', round(accuracy_score(y_te[m], pred[m]), 4))
print('AUC:', round(roc_auc_score(y_te[m], prob[m]), 4))
filas: 27
compraron: 12
acierto: 0.6667
AUC: 0.6056

Con un puñado de filas el AUC es un número, sí, pero no significa nada: una sola fila que cambie de lado lo mueve entero.

Mi regla de trabajo es no reportar métricas de grupos con menos de 100 filas sin poner al lado un rango, y para eso está el bootstrap que sale en el libro de estadística. Un número solo, en un grupo chico, es una opinión disfrazada 📉

7. El error de calibrar con lo que el modelo ya vio

Calcula el listón conforme con las mismas filas con las que entrenaste y mira qué promete.

p_mal = conf.predict_proba(X_ent)
fallo_mal = 1 - p_mal[np.arange(len(y_ent)), y_ent.values]
k_mal = int(np.ceil((len(fallo_mal) + 1) * (1 - 0.20)))
liston_mal = np.sort(fallo_mal)[k_mal - 1]

conj_mal = p_te >= 1 - liston_mal
cubre_mal = conj_mal[np.arange(len(y_te)), y_te.values]
print('listón bueno :', round(1 - listero, 4))
print('listón malo  :', round(1 - liston_mal, 4))
print('prometió cubrir 0.8 y cubrió:', round(cubre_mal.mean(), 4))
listón bueno : 0.3888
listón malo  : 0.4047
prometió cubrir 0.8 y cubrió: 0.804

Este no revienta, que es lo peligroso. Sale un listón parecido y una cobertura parecida, y te vas a casa tan feliz.

Con este modelo casi no se nota porque una regresión logística con trece columnas apenas se aprende de memoria las filas de entrenamiento. Prueba lo mismo con el bosque sin podar del capítulo 13, que en entrenamiento acertaba 1,0: ahí el listón sale ridículamente bajo y la garantía se cae entera.

La calibración tiene que ser de filas que el modelo no vio. Sin eso, la predicción conforme deja de ser una garantía y pasa a ser un adorno 🚫

Comprueba que lo tienes

El modelo está bien calibrado en total y mal por segmento. ¿Cómo puede ser?

  • Porque los errores de unos grupos cancelan los de otros
  • Es imposible: si está bien en total está bien en todos
  • Porque la muestra total es más grande
  • Porque la calibración solo funciona con muchos datos

Lo que te llevas

  • 🔎 Un número global es un promedio. Auditar es repetir la misma medición dentro de cada grupo.
  • 🪞 El mejor acierto y el peor AUC pueden estar en el mismo grupo. Mayorista acierta 0,7435 y ordena a 0,5625.
  • 🧩 El AUC global sale más alto que el de cualquier segmento, porque parte de lo que el modelo sabe es distinguir segmentos.
  • ⚖️ Un umbral único trata distinto a cada grupo: encuentra el 40,68% de las Bodegas que compran y el 92,52% de los Mayoristas.
  • 🎯 Calibrado en promedio no es calibrado por grupo: aquí los errores de Mayorista y Bodega se cancelan entre sí.
  • 📦 La predicción conforme deja que el modelo diga "no sé", y donde lo dice acierta 54,80% contra 73,60% donde se moja.
  • 📐 La cobertura conforme está garantizada en total, no por grupo, y para prometer 1-alpha necesitas al menos 1/alpha - 1 filas de calibración.
  • 🚫 Calibrar con filas que el modelo vio no da error y arruina la garantía en silencio.

En el capítulo 23 sacamos el modelo del cuaderno: guardarlo, servirlo y vigilar que el mundo no se mueva debajo de él.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?