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 🤝
¿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 🙃
Que el modelo diga cuándo no sabe
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! 🌸