El capítulo anterior contestó qué mira el modelo en general. Este contesta la pregunta que de verdad hacen en la reunión: ¿y por qué este cliente? 🙋♀️
Son preguntas distintas y se responden con herramientas distintas. La importancia por permutación te dice que el segmento pesa mucho en las 750 filas. No te dice nada sobre la señora de la bodega de Villa El Salvador que sale séptima en tu lista.
Para eso está SHAP, que ya lo instalamos y hasta ahora solo lo habíamos nombrado 💜
Qué es un valor SHAP
La idea viene de teoría de juegos y es de 1953, mucho antes de que existiera nada de esto. La pregunta original era cómo repartir un premio entre los jugadores de un equipo de forma justa.
Aquí el equipo son las columnas y el premio es la predicción. Un valor SHAP es cuánto aportó esa columna a ESTA fila, medido en la moneda del modelo.
la predicción de una fila es lo que el modelo creía antes de mirarla más lo que aportó cada columna, y esa suma cuadra exacta, no aproximada
Esa igualdad es lo que separa a SHAP de todo lo demás: no es una aproximación ni un ranking, es un reparto que suma exactamente. Y lo vamos a comprobar en las 750 filas, no de palabra.
La forma de calcular cuánto aporta cada columna es quitarla y ver cuánto cambia la predicción, pero en todos los órdenes posibles y promediando:
el aporte de una columna es cuánto cambia la predicción al añadirla, medido en todos los grupos de columnas posibles y promediado
Con trece columnas eso serían muchísimas combinaciones. La gracia de
TreeExplainer es que en un modelo de árboles esa cuenta tiene
atajo, y por eso pasa de imposible a instantánea 🌳
El punto de partida
El LightGBM afinado del capítulo 17, que es donde SHAP se gana el sueldo: la logística ya se explica con sus coeficientes.
import numpy as np
import pandas as pd
import shap
from scipy.special import expit
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
import lightgbm as lgb
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']
MEJOR = dict(n_estimators=439, learning_rate=0.0134, num_leaves=9,
min_child_samples=200, subsample=0.6737, subsample_freq=1,
colsample_bytree=0.5664, reg_lambda=0.0123)
pre = ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NUMERICAS),
('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
])
modelo = Pipeline([('pre', pre),
('mod', lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR))])
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.fit(X_tr, y_tr)
print('shap', shap.__version__)
print('columnas despues del one-hot:', len(modelo.named_steps['pre'].get_feature_names_out()))
shap 0.51.0 columnas despues del one-hot: 28
El error que vas a cometer en el primer intento
Lo natural es pasarle el modelo entero. No funciona.
shap.TreeExplainer(modelo)
InvalidModelError: Model type not yet supported by TreeExplainer: <class 'sklearn.pipeline.Pipeline'>
SHAP quiere el árbol, no el pipeline. Y tiene sentido: el
atajo que hace que esto sea rápido es específico de los árboles, y un
ColumnTransformer no es un árbol.
Así que hay que partirlo en dos: el preprocesado transforma, y el explicador mira solo el modelo. El precio de partirlo es que a partir de aquí trabajas con las 28 columnas de después del one-hot y no con las 13 de antes 🔧
preparador = modelo.named_steps['pre']
arbol = modelo.named_steps['mod']
nombres = list(preparador.get_feature_names_out())
X_te_t = preparador.transform(X_te)
explicador = shap.TreeExplainer(arbol)
valores = np.array(explicador.shap_values(X_te_t))
if valores.ndim == 3: # segun la version, sale una capa por clase
valores = valores[:, :, 1]
base = float(np.ravel(explicador.expected_value)[-1])
print('forma:', valores.shape)
print('valor base (log-odds):', round(base, 4))
print('convertido a probabilidad:', round(float(expit(base)), 4))
forma: (750, 28) valor base (log-odds): 0.3612 convertido a probabilidad: 0.5893
Ese valor base es lo que el modelo predice sin saber nada de la fila. Fíjate en que se parece muchísimo a la tasa de compra del archivo, que era 0,5773. Tiene que ser así: es el punto de partida antes de que ninguna columna empuje 📍
Lo primero: comprobar que la suma cuadra
Antes de contarle nada a nadie, se comprueba la propiedad que hace que esto valga: base más aportes tiene que dar la predicción. Exacta.
probabilidad = modelo.predict_proba(X_te)[:, 1]
reconstruida = expit(base + valores.sum(axis=1))
print('diferencia maxima en las 750 filas:', float(np.abs(reconstruida - probabilidad).max()))
diferencia maxima en las 750 filas: 6.661338147750939e-16
Ese número es cero con dieciséis ceros detrás. No es "casi": es el error de redondeo de los decimales de la máquina 🎯
Y no es un detalle de purista. Significa que cuando le digas a alguien "este cliente sale arriba por la satisfacción y por no ser nuevo", esos dos números son literalmente de dónde salió la predicción, no una historia razonable construida encima.
Lo global, que es lo que ya sabíamos hacer
importancia = pd.Series(np.abs(valores).mean(axis=0), index=nombres) print(importancia.sort_values(ascending=False).head(10).round(4).to_string())
num__satisfaccion 0.3100 num__monto 0.2342 num__sin_compra_previa 0.2300 cat__canal_Marketplace 0.1634 cat__segmento_Bodega 0.1272 num__descuento 0.1182 num__precio_unitario 0.1070 num__unidades 0.0735 cat__segmento_Mayorista 0.0468 num__visita_numero 0.0436
Promediar el valor absoluto de los aportes da una importancia global, y sale parecida a la del capítulo 19 pero no igual.
Vale la pena mirar por qué, porque es la confusión más común con estas dos herramientas.
SHAP y permutación no miden lo mismo
Las dos sobre el mismo modelo entrenado, agrupando el one-hot para poder compararlas columna con columna.
from sklearn.inspection import permutation_importance
def agrupa(serie):
"""Suma las columnas del one-hot para volver a la columna original."""
fuera = {}
for clave, valor in serie.items():
base_col = clave.split('__', 1)[1]
if clave.startswith('cat__'):
base_col = base_col.rsplit('_', 1)[0]
fuera[base_col] = fuera.get(base_col, 0) + valor
return pd.Series(fuera)
r = permutation_importance(modelo, X_te, y_te, n_repeats=10,
random_state=42, scoring='roc_auc')
tabla = pd.DataFrame({
'shap': agrupa(importancia),
'permutacion': pd.Series(r.importances_mean, index=X_te.columns),
}).sort_values('shap', ascending=False)
tabla['puesto_shap'] = tabla['shap'].rank(ascending=False).astype(int)
tabla['puesto_perm'] = tabla['permutacion'].rank(ascending=False).astype(int)
print(tabla.round(4).to_string())
shap permutacion puesto_shap puesto_perm satisfaccion 0.3100 0.0360 1 2 canal 0.2527 0.0234 2 4 monto 0.2342 0.0394 3 1 sin_compra_previa 0.2300 0.0322 4 3 segmento 0.1885 0.0232 5 5 descuento 0.1182 0.0048 6 7 precio_unitario 0.1070 0.0071 7 6 unidades 0.0735 0.0000 8 9 visita_numero 0.0436 -0.0006 9 13 categoria 0.0334 -0.0003 10 11 ciudad 0.0125 0.0000 11 8 sin_descuento 0.0083 -0.0005 12 12 sin_satisfaccion 0.0000 0.0000 13 10
Mira los tres primeros puestos, que no coinciden 👀
Para SHAP manda satisfaccion y monto es tercera.
Para la permutación manda monto y satisfaccion es
segunda. Y canal pasa del segundo puesto al cuarto.
No es que una esté mal. Es que contestan preguntas distintas:
- 🔍 SHAP mide cuánto usa el modelo esa columna para mover sus predicciones. Es una propiedad del modelo.
- 💥 La permutación mide cuánto acierto pierde si le rompes esa columna. Es una propiedad del modelo y de los datos juntos.
Una columna que el modelo usa mucho pero que está correlacionada con otra
puede tener SHAP alto y permutación baja: la rompes y el modelo se apoya en la
gemela sin despeinarse. Es el mismo fenómeno que en el capítulo
19 dejó a sin_descuento en cero.
Regla práctica: si vas a explicar, SHAP. Si vas a decidir qué columna puedes dejar de recoger, permutación 📋
Y ahora sí: por qué ESTE cliente
Aquí es donde SHAP deja atrás a todo lo demás. Dos clientes con prácticamente la misma probabilidad, buscados a propósito.
orden = np.argsort(probabilidad)
mejor = None
for a in range(len(orden)):
for b in range(a + 1, min(a + 40, len(orden))):
i, j = orden[a], orden[b]
if abs(probabilidad[i] - probabilidad[j]) > 0.002:
continue
distancia = np.abs(valores[i] - valores[j]).sum()
if mejor is None or distancia > mejor[0]:
mejor = (distancia, i, j)
_d, i, j = mejor
print(f'cliente A: {probabilidad[i]:.4f} cliente B: {probabilidad[j]:.4f}')
for fila, quien in ((i, 'A'), (j, 'B')):
top = pd.Series(valores[fila], index=nombres).sort_values(key=abs, ascending=False)
print(f'\n cliente {quien}')
for nombre, valor in top.head(4).items():
print(f' {nombre.split("__")[1]:22s} {valor:+.3f}')
cliente A: 0.4604 cliente B: 0.4614
cliente A
satisfaccion +0.641
monto -0.391
canal_Marketplace -0.260
segmento_Bodega -0.247
cliente B
sin_compra_previa -0.692
satisfaccion -0.429
monto +0.258
unidades +0.106
Dos clientes con 0,46 de probabilidad y razones opuestas 🤯
Al cliente A la satisfacción lo empuja fuerte hacia arriba (+0,641) y el monto lo tira hacia abajo. Al cliente B lo hunde ser nuevo (−0,692) y encima la satisfacción también le juega en contra.
El número que ve el comercial es el mismo. La conversación no puede serlo.
A uno hay que llamarlo hablando del producto que ya le gustó. Al otro hay que tratarlo como lo que es, un cliente nuevo, y eso en la práctica es otro guion, otra oferta y hasta otra persona del equipo llamando 📞
Esto es literalmente imposible de sacar de una tabla de importancias globales, y es la razón por la que este capítulo existe.
Qué aprendió el modelo sobre una columna
Agrupando el aporte de una columna por su valor sale lo que el modelo entendió de ella, sin que nadie se lo dijera.
columna = nombres.index('num__satisfaccion')
d = pd.DataFrame({'satisfaccion': X_te['satisfaccion'].values,
'aporte': valores[:, columna]})
print(d.dropna().groupby(d.dropna()['satisfaccion'].round())['aporte']
.agg(['count', 'mean']).round(4).to_string())
hueco = d['satisfaccion'].isna()
print('\nfilas sin satisfaccion:', int(hueco.sum()))
print('aporte medio de esas :', round(float(d.loc[hueco, 'aporte'].mean()), 4))
count mean satisfaccion 1.0 144 -0.4692 2.0 128 -0.1995 3.0 140 -0.1316 4.0 144 0.2281 5.0 138 0.5866 filas sin satisfaccion: 56 aporte medio de esas : -0.1285
Esto es de lo más bonito del libro y no lo programó nadie 💛
El modelo aprendió el orden de la satisfacción él solo: −0,4692 con un 1, subiendo sin saltarse ningún escalón hasta +0,5866 con un 5. Nunca le dijimos que 5 fuera más que 1; para él eran números y punto.
Y mira dónde cae el hueco: −0,1285, entre el 2 y el 3. O sea que no saber la satisfacción de un cliente es mala señal, aunque no tan mala como saber que es un 1.
Tiene sentido si te acuerdas de que el imputador rellena con la mediana, que
en esta columna es 3. Pero el aporte no es el del 3 exacto, así que algo más
está pasando ahí, y ese algo es sin_satisfaccion, la bandera que
construimos en el capítulo 9 justo para esto 🕳️
PDP: la forma de la relación, y una trampa silenciosa
SHAP explica filas. El PDP (gráfico de dependencia parcial) hace otra cosa: mueve una columna por todo su rango y mira qué le pasa a la predicción media.
from sklearn.inspection import partial_dependence
p = partial_dependence(modelo, X_te, ['satisfaccion'], kind='average',
grid_resolution=5)
print('valores probados:', p['grid_values'][0])
print('probabilidad media:', np.round(p['average'][0], 4))
valores probados: [nan nan nan nan nan] probabilidad media: [0.558 0.558 0.558 0.558 0.558]
Ahí tienes la trampa, y da escalofrío: la rejilla es nan
y la curva es una línea plana. Sin error, sin aviso, sin nada.
Lo que pasó es que satisfaccion tiene 56 nulos en el examen, y
scikit-learn arma la rejilla con percentiles. Un percentil con nulos dentro es
nan, así que probó cinco veces el valor nan y le salió
cinco veces lo mismo.
Quien no mire la rejilla se lleva a la reunión un gráfico plano y concluye que la satisfacción no importa. Justo al revés de lo que acabamos de medir con SHAP 😨
La solución es decirle a mano qué valores probar:
p = partial_dependence(modelo, X_te, ['satisfaccion'], kind='both',
custom_values={'satisfaccion': [1., 2., 3., 4., 5.]})
print('valores probados:', p['grid_values'][0])
print('probabilidad media:', np.round(p['average'][0], 4))
curvas = p['individual'][0]
cambio = curvas[:, -1] - curvas[:, 0]
print('\nfilas donde sube:', int((cambio > 0).sum()),
'| donde baja:', int((cambio < 0).sum()))
print('subida minima', round(float(cambio.min()), 4),
'maxima', round(float(cambio.max()), 4))
valores probados: [1. 2. 3. 4. 5.] probabilidad media: [0.4817 0.5436 0.558 0.6356 0.7079] filas donde sube: 750 | donde baja: 0 subida minima 0.1385 maxima 0.2951
Ahora sí: de 0,4817 a 0,7079. Veintitrés puntos de probabilidad entre un cliente de satisfacción 1 y uno de 5, con todo lo demás igual.
Y esa segunda parte es el ICE, que dibuja una curva por fila en vez de la media. Sirve para cazar el caso en que la media miente: si la mitad sube y la otra mitad baja, el promedio sale plano y no hay efecto ninguno que reportar.
Aquí sube en las 750, así que la media no tapaba nada. Lo digo porque es el resultado que salió, no el que hacía más bonita la explicación 💛
Ejercicios
1. La ficha de un cliente, lista para el comercial
Convierte los aportes de una fila en tres frases en castellano. Eso es lo que se pega en el CRM, no una tabla de números.
def explica(fila, cuantas=3):
aportes = pd.Series(valores[fila], index=nombres)
aportes = aportes.sort_values(key=abs, ascending=False).head(cuantas)
frases = []
for nombre, valor in aportes.items():
limpio = nombre.split('__')[1].replace('_', ' ')
verbo = 'a favor' if valor > 0 else 'en contra'
frases.append(f'{limpio} ({verbo}, {abs(valor):.2f})')
return f'probabilidad {probabilidad[fila]:.0%}. Pesa: ' + '; '.join(frases)
for fila in np.argsort(probabilidad)[-3:][::-1]:
print(explica(fila))
probabilidad 89%. Pesa: satisfaccion (a favor, 0.56); monto (a favor, 0.30); precio unitario (a favor, 0.20) probabilidad 89%. Pesa: satisfaccion (a favor, 0.57); monto (a favor, 0.32); sin compra previa (a favor, 0.18) probabilidad 89%. Pesa: satisfaccion (a favor, 0.56); monto (a favor, 0.29); precio unitario (a favor, 0.20)
Las tres de arriba de tu lista, cada una con su motivo escrito 📇
Y fíjate en que las tres no dicen lo mismo aunque las tres sean las mejores. Esa es exactamente la información que hace que una lista se use en vez de mirarse una vez y olvidarse.
2. Contra quién se compara este cliente
Un aporte se lee contra el valor base. Comprueba que el mismo cliente en un modelo entrenado con otra tasa de compra tendría los mismos aportes relativos pero otra base.
flojos = y_tr == 0
X_sesgado = pd.concat([X_tr[flojos], X_tr[~flojos].head(200)])
y_sesgado = pd.concat([y_tr[flojos], y_tr[~flojos].head(200)])
otro = 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', lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR))])
otro.fit(X_sesgado, y_sesgado)
ex2 = shap.TreeExplainer(otro.named_steps['mod'])
base2 = float(np.ravel(ex2.expected_value)[-1])
print('tasa de compra original:', round(float(y_tr.mean()), 4),
'-> base', round(base, 4))
print('tasa de compra sesgada :', round(float(y_sesgado.mean()), 4),
'-> base', round(base2, 4))
tasa de compra original: 0.5778 -> base 0.3612 tasa de compra sesgada : 0.1739 -> base -1.6999
El valor base se mueve con la tasa de la clase, porque es literalmente eso: lo que el modelo cree antes de mirar la fila.
Por eso un aporte SHAP nunca es "este cliente es bueno". Es "este cliente es mejor que la media de los que entrenaron este modelo", y cambiar el conjunto de entrenamiento cambia la frase entera 📏
3. ¿La satisfacción pesa igual en todos los segmentos?
Aquí es donde SHAP encuentra interacciones sin que nadie las busque: se agrupa el aporte de una columna por otra.
columna = nombres.index('num__satisfaccion')
cruce = pd.DataFrame({
'aporte': valores[:, columna],
'segmento': X_te['segmento'].values,
'satisfaccion': X_te['satisfaccion'].values,
}).dropna()
print(cruce.pivot_table(index=cruce['satisfaccion'].round(), columns='segmento',
values='aporte', aggfunc='mean').round(3).to_string())
segmento Bodega Horeca Mayorista Minimarket satisfaccion 1.0 -0.448 -0.474 -0.479 -0.476 2.0 -0.194 -0.207 -0.206 -0.188 3.0 -0.130 -0.134 -0.133 -0.130 4.0 0.196 0.241 0.244 0.230 5.0 0.590 0.589 0.575 0.590
Y la respuesta es que no hay interacción: las cuatro columnas dicen lo mismo en cada fila. Con satisfacción 1 el aporte ronda −0,47 seas Bodega o Mayorista, y con 5 ronda +0,58 igual.
Eso no es un ejercicio fallido, es el resultado. Y encaja con lo del capítulo 17: si el boosting no le ganó a una regresión logística fue porque en este archivo no hay reglas del tipo "esto solo pasa cuando se juntan estas dos cosas". Aquí lo estás viendo directamente 🔍
El día que esta tabla salga con una columna que va al revés que las demás, ya tienes localizada la interacción y sabes qué columna construir.
4. Las cuentas del comercial, en soles
Junta SHAP con el umbral del capítulo 15: quién entra en las 200 llamadas y por qué.
lista = pd.DataFrame({
'probabilidad': probabilidad,
'segmento': X_te['segmento'].values,
'motivo': [pd.Series(valores[k], index=nombres)
.sort_values(key=abs, ascending=False).index[0].split('__')[1]
for k in range(len(probabilidad))],
}).sort_values('probabilidad', ascending=False).head(200)
print('aciertos reales en las 200:',
int(y_te.values[np.argsort(probabilidad)[::-1][:200]].sum()), 'de 200')
print()
print(lista['motivo'].value_counts().to_string())
aciertos reales en las 200: 157 de 200 motivo satisfaccion 102 monto 87 canal_Marketplace 6 precio_unitario 2 descuento 1 unidades 1 sin_compra_previa 1
Esa segunda tabla es la que cambia una reunión: no es "el modelo eligió a estos 200", es cuántos entraron por cada razón 📊
Y si mañana una de esas razones deja de llegar limpia desde el sistema, ya sabes cuántos nombres de tu lista se caen con ella.
5. Cuando SHAP tarda: la muestra
Con 750 filas es instantáneo. Con dos millones no. Mide cuánto cambia la importancia global si la calculas sobre una muestra.
global_completa = pd.Series(np.abs(valores).mean(axis=0), index=nombres)
for cuantas in (50, 150, 400, 750):
trozo = valores[:cuantas]
parcial = pd.Series(np.abs(trozo).mean(axis=0), index=nombres)
correlacion = global_completa.corr(parcial, method='spearman')
mismos = sum(1 for a, b in zip(
global_completa.sort_values(ascending=False).head(5).index,
parcial.sort_values(ascending=False).head(5).index) if a == b)
print(f'{cuantas:4d} filas: correlacion de puestos {correlacion:.4f}, '
f'coinciden {mismos}/5 en el top 5')
50 filas: correlacion de puestos 0.9973, coinciden 3/5 en el top 5 150 filas: correlacion de puestos 0.9989, coinciden 5/5 en el top 5 400 filas: correlacion de puestos 0.9989, coinciden 3/5 en el top 5 750 filas: correlacion de puestos 1.0000, coinciden 5/5 en el top 5
Con 50 filas la correlación de puestos ya es 0,9973. O sea que para saber qué columnas mandan no hace falta explicar el conjunto entero, y eso convierte a SHAP en algo que se puede correr en producción.
Pero mira la última columna antes de fiarte del todo: con 150 filas coinciden las cinco de arriba, con 400 solo tres, y con 750 otra vez las cinco. El orden exacto del podio va y viene aunque la correlación esté clavada en 0,999 🎲
La conclusión honesta es esa: con una muestra sabes qué grupo de columnas manda, no cuál es la número uno. Si vas a escribir "la variable más importante es X" en un informe, usa el conjunto entero.
Ojo con no confundir las dos cosas: para la ficha de un cliente sí hay que calcular esa fila, siempre. Lo que se puede muestrear es el resumen, no la explicación individual 🎯
6. El PDP de una columna sin huecos, para ver la diferencia
La trampa del nan solo aparece con columnas
que tienen nulos. Compruébalo con una que no los tiene.
for columna in ('precio_unitario', 'satisfaccion'):
p = partial_dependence(modelo, X_te, [columna], kind='average',
grid_resolution=4)
rejilla = p['grid_values'][0]
print(f'{columna:16s} nulos {int(X_te[columna].isna().sum()):3d} '
f'rejilla {np.round(rejilla, 2)}')
print(f'{"":16s} curva {np.round(p["average"][0], 4)}')
precio_unitario nulos 0 rejilla [ 9.71 170.26 330.81 491.37]
curva [0.5468 0.6088 0.6139 0.6139]
satisfaccion nulos 56 rejilla [nan nan nan nan]
curva [0.558 0.558 0.558 0.558]
Con precio_unitario, que no tiene ni un hueco, la rejilla sale
con números y la curva se mueve. Con satisfaccion, la misma llamada
da nan y una recta.
La regla que me llevo: mirar siempre la rejilla antes que la curva. Es una línea de código y evita presentar un gráfico que dice lo contrario de lo que pasa 🚩
Y un aviso que me costó un rato: aquí puse precio_unitario y no
unidades porque scikit-learn se niega a hacer PDP sobre una columna
de enteros. Ese sí avisa, con un ValueError que dice "contains
integer data". Si te toca una así, la conviertes a float antes de
pedirle el gráfico 🔢
7. El que más se equivocó, explicado
Los aportes también sirven para depurar. Busca la fila donde el modelo estuvo más seguro y falló, y mira quién lo engañó.
error = np.abs(y_te.values - probabilidad)
peor = int(np.argmax(error))
print(f'realidad {y_te.values[peor]}, el modelo dijo {probabilidad[peor]:.4f}')
print()
top = pd.Series(valores[peor], index=nombres).sort_values(key=abs, ascending=False)
for nombre, valor in top.head(5).items():
print(f' {nombre.split("__")[1]:22s} {valor:+.3f}')
print()
print(X_te.iloc[peor][['segmento', 'canal', 'satisfaccion', 'monto',
'sin_compra_previa']].to_string())
realidad 0, el modelo dijo 0.8918 satisfaccion +0.557 monto +0.300 precio_unitario +0.204 sin_compra_previa +0.180 canal_Marketplace +0.121 segmento Mayorista canal Tienda satisfaccion 5.0 monto 1796.06 sin_compra_previa 0
Y fíjate en quién es: es la misma fila que el modelo puntuó más alto de las 750, la del 0,8918. Mayorista, en tienda, satisfacción 5, 1.796 soles de monto y cliente con historial. Todo a favor. No compró.
Los cinco aportes van en la misma dirección y los cinco están bien calculados. Lo que falla no es el modelo: es que nada de lo que el archivo guarda explica por qué esa venta no cerró 🤷♀️
Mirar las filas donde el modelo se equivocó con más confianza es de las cosas que más enseñan sobre un conjunto de datos, y casi nadie lo hace.
A veces es una fila mal escrita en el origen. A veces es un caso real que el modelo no puede saber, y entonces la conclusión no es tocar el modelo: es que a esa parte del negocio le falta una columna que nadie está midiendo 🕵️♀️
Comprueba que lo tienes
Dos clientes de tu lista tienen 0,46 de probabilidad. ¿Qué te dice eso de por qué salieron ahí?
- Nada: la misma probabilidad puede venir de razones opuestas
- Que se parecen, porque el modelo los puntuó igual
- Que pesó la columna más importante del modelo en los dos
- Que están en el mismo segmento
Lo que te llevas
- 🧾 Un valor SHAP es cuánto aportó una columna a esa fila, no al modelo. Es la diferencia entre "el segmento importa" y "a esta señora la sube su segmento".
- ✅ Base más aportes da la predicción exacta. En las 750 filas la diferencia máxima fue del orden de 10⁻¹⁶, que es el redondeo de la máquina.
- 🔌
TreeExplainerquiere el modelo, no el pipeline. Hay que partirlo: el preparador transforma y el explicador mira solo el árbol. - 🎭 Dos clientes con 0,46 de probabilidad por razones opuestas: a uno lo sube la satisfacción, al otro lo hunde ser nuevo. Mismo número, otra llamada.
- 🔀 SHAP y permutación no coinciden ni en el podio, y no tienen por qué: una mide cuánto usa el modelo la columna y la otra cuánto acierto pierde si se la rompes.
- 📈 El modelo aprendió solo el orden de la satisfacción, de −0,4692 con un 1 a +0,5866 con un 5, sin que nadie le dijera que 5 es más que 1.
- 🕳️ Y colocó el hueco en −0,1285, entre el 2 y el 3: no saber la satisfacción de alguien es mala señal, pero menos que saber que es un 1.
- 🚨 El PDP de una columna con nulos sale plano y con la rejilla en
nan, sin error ninguno. Mira la rejilla antes que la curva, o pasa los valores a mano concustom_values. - 🧊 Con la rejilla arreglada, de 0,4817 a 0,7079 según la satisfacción. Y el ICE dice que sube en las 750 filas, así que la media no tapaba nada.
Qué viene ahora
Ya sabemos por qué el modelo dice lo que dice. Falta lo que aprendió sin que nadie se lo pidiera 😬
El capítulo 22 abre el modelo por grupos y hace la pregunta incómoda: ¿acierta igual con las bodegas que con los mayoristas? Y después, cuánta confianza merece cada predicción por separado.
Lo que verás allí:
- ⚖️ Las métricas del capítulo 14 calculadas dentro de cada segmento, que es donde salen las diferencias que el promedio esconde.
- 🎲 Cuánto de esa diferencia es real y cuánto es la lotería de la partición, medido con diez sorteos.
- 📏 Calibración: si el modelo dice 70%, ¿de cada diez cierran siete?
- 🛡️ Y la predicción conforme, que en vez de un número da un conjunto y promete cubrir la respuesta el 90% de las veces.