Un modelo que nadie entiende no se usa. Y esto no es filosofía: es lo que pasa cuando el equipo comercial recibe una lista de 200 nombres y nadie sabe decir por qué están esos y no otros 🤷♀️
En este capítulo abrimos la caja. Y de paso vamos a encontrar una columna que medimos en doce puntos en el capítulo 4 y que al modelo no le aporta nada, lo cual tiene una explicación bonita.
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.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)
print('AUC:', round(modelo.score(X_te, y_te), 4))
AUC: 0.6733
Los coeficientes, que es lo que la logística regala
Una regresión logística le pone un número a cada columna. Ese número es la explicación, no una aproximación de la explicación.
nombres = modelo.named_steps['pre'].get_feature_names_out()
pesos = modelo.named_steps['mod'].coef_[0]
for i in np.argsort(np.abs(pesos))[::-1][:8]:
print(f'{nombres[i]:32} {pesos[i]:+.4f}')
cat__segmento_Bodega -0.6832 cat__segmento_Mayorista +0.4725 cat__canal_Marketplace -0.4651 num__satisfaccion +0.4152 num__sin_compra_previa -0.3747 cat__canal_WhatsApp +0.3265 cat__segmento_Horeca +0.3248 cat__canal_Tienda +0.2874
Se leen así: positivo empuja hacia el sí, negativo hacia el no, y el tamaño dice cuánto.
Ser Bodega es lo que más resta (-0,68) y ser Mayorista lo que más suma (+0,47). Marketplace resta, WhatsApp suma. Todo eso ya lo habíamos visto a mano en el capítulo 1, y ahora sale solo y con todas las columnas a la vez.
Eso último es la gracia: en el capítulo 1 miramos el segmento ignorando el resto. El coeficiente dice cuánto aporta el segmento una vez descontado todo lo demás 🎯
Convertirlos a algo que se pueda decir en voz alta
la misma fórmula al revés: cada peso dice cuánto mueve el logaritmo de la ventaja, y por eso un peso de 0,7 se lee como multiplicar la ventaja por dos
Los coeficientes están en log-odds, que no se explican en una reunión. Se arregla con una exponencial:
for i in np.argsort(np.abs(pesos))[::-1][:6]:
print(f'{nombres[i]:32} multiplica las probabilidades por {np.exp(pesos[i]):.2f}')
cat__segmento_Bodega multiplica las probabilidades por 0.51 cat__segmento_Mayorista multiplica las probabilidades por 1.60 cat__canal_Marketplace multiplica las probabilidades por 0.63 num__satisfaccion multiplica las probabilidades por 1.51 num__sin_compra_previa multiplica las probabilidades por 0.69 cat__canal_WhatsApp multiplica las probabilidades por 1.39
Ahora sí: ser Mayorista multiplica por 1,6 las probabilidades a favor de cerrar; ser Bodega las multiplica por 0,5, o sea las corta a la mitad.
Ojo con una trampa de vocabulario que se comete siempre: eso son odds, no probabilidades. "Multiplica por 1,6 las probabilidades" es incorrecto en sentido estricto; lo correcto es "la razón de probabilidades". En una reunión yo digo lo primero y lo aclaro si alguien pregunta, y en un informe escrito lo pongo bien 📝
Y un aviso más importante: los coeficientes de las numéricas están
escalados por el StandardScaler, así que +0,4152 en
satisfacción significa "por cada desviación típica de satisfacción", no "por cada
punto". Se puede desescalar, y casi siempre no hace falta: para comparar
importancia entre columnas, escalado es justo lo que quieres.
La forma que funciona con cualquier modelo
Los coeficientes solo existen en los modelos lineales. Para un bosque o un boosting hace falta otra cosa, y la buena se llama importancia por permutación.
La idea es preciosa de tan simple: revuelve una columna al azar y mira cuánto empeora el modelo. Si empeora mucho, esa columna importaba. Si no cambia nada, no servía.
from sklearn.inspection import permutation_importance
r = permutation_importance(modelo, X_te, y_te, n_repeats=10,
random_state=42, scoring='roc_auc', n_jobs=-1)
for i in r.importances_mean.argsort()[::-1]:
print(f'{X_te.columns[i]:20} {r.importances_mean[i]:+.4f} ± {r.importances_std[i]:.4f}')
segmento +0.0909 ± 0.0117 satisfaccion +0.0464 ± 0.0076 sin_compra_previa +0.0361 ± 0.0082 canal +0.0334 ± 0.0064 monto +0.0067 ± 0.0019 precio_unitario +0.0060 ± 0.0031 descuento +0.0050 ± 0.0022 visita_numero +0.0008 ± 0.0014 unidades +0.0006 ± 0.0014 sin_descuento -0.0002 ± 0.0003 categoria -0.0002 ± 0.0011 sin_satisfaccion -0.0008 ± 0.0002 ciudad -0.0017 ± 0.0011
Ahí está el ranking honesto, medido en puntos de AUC perdidos:
- 🥇 segmento: 0,0909. Si lo revuelves, el modelo pierde nueve puntos. Es la columna del libro.
- 🥈 satisfacción: 0,0464.
- 🥉 sin_compra_previa: 0,0361, la bandera que construimos en el capítulo 9 con una línea.
- 4️⃣ canal: 0,0334.
Y de ahí para abajo, todo por debajo de 0,007. Cuatro columnas hacen el modelo y las otras nueve están de adorno.
Fíjate en tres ventajas enormes de esta técnica: funciona con cualquier modelo, se mide en la unidad que te importa (aquí AUC) y viene con desviación, así que sabes cuándo dos columnas están empatadas 📐
La columna que valía doce puntos y no aporta nada
Mira sin_descuento en esa lista: -0,0002. Cero.
Y en el capítulo 4 medimos que sin descuento se cierra el 48,1% y con descuento el 60,2%. Doce puntos de diferencia. ¿Cómo puede no aportar nada?
print('la bandera sola:')
print(datos.groupby('sin_descuento')['compro'].mean().round(4))
print()
print('el descuento imputado, por bandera:')
imputado = datos['descuento'].fillna(datos['descuento'].median())
print(pd.DataFrame({'bandera': datos['sin_descuento'],
'descuento': imputado}).groupby('bandera')['descuento'].describe()[['min', 'max']].round(3))
la bandera sola:
sin_descuento
0 0.6017
1 0.4807
Name: compro, dtype: float64
el descuento imputado, por bandera:
min max
bandera
0 0.000 0.250
1 0.127 0.127
Ahí está la explicación: cuando la bandera vale 1, el imputador rellenó el descuento con la mediana, y ese valor exacto (0,127) prácticamente solo aparece en esas filas.
O sea que la columna descuento ya lleva dentro la
información de la bandera. Son redundantes, y al revolver una la otra
sigue diciendo lo mismo.
Esa es la trampa grande de la importancia por permutación y hay que saberla: con dos columnas que dicen lo mismo, las dos salen sin importancia, porque el modelo se apoya en la que quede. No significa que ninguna importe: significa que sobra una 🔍
La forma de comprobarlo es quitarlas de verdad, que es el ejercicio 4.
Explicar UNA predicción
Lo de arriba explica el modelo. Pero en la reunión te van a preguntar por una fila: ¿por qué a este cliente le pusiste 0,85?
fila = X_te.iloc[[3]]
prob = modelo.predict_proba(fila)[0, 1]
transformada = modelo.named_steps['pre'].transform(fila)[0]
aporte = transformada * pesos
print('probabilidad:', round(prob, 4))
print()
for i in np.argsort(np.abs(aporte))[::-1][:6]:
print(f'{nombres[i]:32} {aporte[i]:+.4f}')
probabilidad: 0.6595 cat__canal_WhatsApp +0.3265 num__satisfaccion -0.3075 num__sin_compra_previa +0.1758 cat__categoria_Abarrotes +0.1027 cat__segmento_Minimarket +0.0666 num__visita_numero +0.0579
Eso es una explicación completa de esa fila: cada número dice cuánto empujó cada característica de este cliente.
Y sumado da exactamente el resultado, se puede comprobar:
total = aporte.sum() + modelo.named_steps['mod'].intercept_[0]
print('suma de aportes + intercepto:', round(total, 4))
print('probabilidad reconstruida :', round(1 / (1 + np.exp(-total)), 4))
print('la que dio el modelo :', round(prob, 4))
suma de aportes + intercepto: 0.6613 probabilidad reconstruida : 0.6595 la que dio el modelo : 0.6595
Idénticas 🎯 Eso es lo que hace que un modelo lineal sea auditable de verdad: no hay nada escondido, la predicción es la suma.
Sobre SHAP, que es lo que te van a preguntar
SHAP es la técnica estándar para esto con modelos complicados. Reparte la predicción entre las columnas de una forma que viene de teoría de juegos y tiene propiedades matemáticas bonitas.
Vive en una librería aparte (pip install shap) y no la uso en
este libro por dos razones honestas:
- 📦 No está instalada en el entorno donde se ejecutan estos capítulos, y este libro no publica código que no corre.
- ➕ Para un modelo lineal, los aportes de SHAP son exactamente lo que acabamos de calcular a mano. Con tres líneas y sin dependencias.
Cuándo sí vale la pena instalarlo: cuando el modelo que ganó es un bosque o un boosting y necesitas explicar filas concretas. Para eso no hay atajo y SHAP es lo mejor que hay 💛
Ejercicios
Siete. Intenta antes de abrir 💛
1. Los coeficientes del segmento, ordenados
Saca solo los cuatro segmentos y ordénalos.
seg = [(nombres[i], pesos[i]) for i in range(len(nombres))
if nombres[i].startswith('cat__segmento')]
for n, p in sorted(seg, key=lambda x: -x[1]):
print(f'{n:32} {p:+.4f}')
cat__segmento_Mayorista +0.4725 cat__segmento_Horeca +0.3248 cat__segmento_Minimarket +0.0666 cat__segmento_Bodega -0.6832
Mayorista, Horeca, Minimarket, Bodega, de más a menos. El mismo orden que las tasas del capítulo 1 (72,3%, 63,2%, 56,9%, 37,6%), y eso es una comprobación de sanidad muy útil.
Si el orden del modelo no coincidiera con el de los datos crudos, habría algo raro y valdría la pena mirarlo 🔍
2. Desescalar un coeficiente
Traduce el peso de la satisfacción a "por cada punto de satisfacción".
escalador = modelo.named_steps['pre'].named_transformers_['num'].named_steps['e']
i_sat = NUMERICAS.index('satisfaccion')
desviacion = escalador.scale_[i_sat]
peso_escalado = pesos[list(nombres).index('num__satisfaccion')]
print('desviación típica de satisfacción:', round(desviacion, 4))
print('por desviación:', round(peso_escalado, 4))
print('por punto :', round(peso_escalado / desviacion, 4))
print('odds por punto:', round(np.exp(peso_escalado / desviacion), 4))
desviación típica de satisfacción: 1.3641 por desviación: 0.4152 por punto : 0.3043 odds por punto: 1.3557
Cada punto de satisfacción multiplica las odds por 1,34. Eso sí se puede decir en una reunión, y encima es accionable: si el equipo de atención sube un punto la satisfacción media, esto es lo que pasa 📈
3. La importancia según el bosque, comparada
Pon al lado la importancia del bosque y la de permutación, y mira si coinciden.
from sklearn.ensemble import RandomForestClassifier
bosque = Pipeline([('pre', pre),
('mod', RandomForestClassifier(n_estimators=300, max_depth=5,
random_state=42, n_jobs=-1))]).fit(X_tr, y_tr)
rb = permutation_importance(bosque, X_te, y_te, n_repeats=10,
random_state=42, scoring='roc_auc', n_jobs=-1)
for i in rb.importances_mean.argsort()[::-1][:5]:
print(f'{X_te.columns[i]:20} {rb.importances_mean[i]:+.4f}')
segmento +0.0365 satisfaccion +0.0270 monto +0.0257 sin_compra_previa +0.0213 canal +0.0206
El bosque, medido por permutación, da un ranking parecido al de la logística: segmento arriba del todo.
Y compáralo con lo que decía feature_importances_ en el capítulo
13, donde el monto salía primero. Dos técnicas sobre el mismo modelo
entrenado dan rankings distintos, y la de permutación es la que hay que
creer, porque mide lo que te importa (AUC perdido) en vez de contar cortes 🔍
4. Quitar de verdad las columnas que no aportan
Entrena solo con las cuatro columnas que salieron arriba y compara.
CUATRO_NUM = ['satisfaccion', 'sin_compra_previa']
CUATRO_CAT = ['segmento', 'canal']
pre4 = ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), CUATRO_NUM),
('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))]), CUATRO_CAT),
])
m4 = Pipeline([('pre', pre4),
('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(
X_tr[CUATRO_NUM + CUATRO_CAT], y_tr)
from sklearn.metrics import roc_auc_score
print('con 4 columnas :', round(roc_auc_score(y_te,
m4.predict_proba(X_te[CUATRO_NUM + CUATRO_CAT])[:, 1]), 4))
print('con 13 columnas:', round(roc_auc_score(y_te,
modelo.predict_proba(X_te)[:, 1]), 4))
con 4 columnas : 0.7193 con 13 columnas: 0.7214
0,7193 con cuatro columnas contra 0,7214 con trece. Dos milésimas de diferencia, menos que la desviación de la validación cruzada del capítulo 16.
O sea que las otras nueve columnas no hacen nada. Y un modelo de cuatro columnas es más fácil de explicar, más barato de mantener y menos frágil, así que en producción yo me quedaría con este 🧹
5. La fila que el modelo se equivoca
Busca un caso donde el modelo estaba muy seguro y falló, y explícalo.
prob_te = modelo.predict_proba(X_te)[:, 1]
error = np.abs(prob_te - y_te.values)
peor = error.argmax()
print('el modelo dijo:', round(prob_te[peor], 4))
print('la verdad era :', int(y_te.iloc[peor]))
print()
print(X_te.iloc[peor][['segmento', 'canal', 'satisfaccion', 'monto']])
el modelo dijo: 0.9032 la verdad era : 0 segmento Mayorista canal Tienda satisfaccion 5.0 monto 1796.06 Name: 135, dtype: object
Un caso con todo a favor que no cerró. Y no hay nada que arreglar: pasa.
Mirar los peores errores uno por uno es de las cosas más útiles que existen y casi nadie las hace. A veces encuentras un patrón (todos son de la misma sucursal, todos del mismo mes) y ahí sí hay algo que corregir 🕵️♀️
6. El error de leer los nombres al revés
Intenta emparejar coeficientes con las columnas originales en vez de con las transformadas.
pd.Series(pesos, index=X.columns)
ValueError: Length of values (28) does not match length of index (13)
Trece nombres para veintiocho pesos. Y menos mal que da error, porque si hubiera coincidido el número, habrías publicado una tabla de importancias completamente equivocada y nada te habría avisado 😳
Después de un OneHotEncoder los nombres se piden siempre con
get_feature_names_out(), nunca se asumen.
7. La tabla que entregas con el modelo
Junta coeficiente, odds e importancia en una sola tabla, lista para el informe.
filas = []
for i, col in enumerate(X_te.columns):
filas.append({'columna': col,
'importancia': round(r.importances_mean[i], 4),
'desviacion': round(r.importances_std[i], 4)})
tabla = pd.DataFrame(filas).sort_values('importancia', ascending=False)
print(tabla.head(6).to_string(index=False))
columna importancia desviacion
segmento 0.0909 0.0117
satisfaccion 0.0464 0.0076
sin_compra_previa 0.0361 0.0082
canal 0.0334 0.0064
monto 0.0067 0.0019
precio_unitario 0.0060 0.0031
Seis filas y una columna de desviación al lado, que es lo que dice cuáles están empatadas.
Con esa tabla, cualquiera puede discutir tu modelo sin creerte, y eso es justamente lo que hace que te crean 💛
Comprueba que lo tienes
Una columna tenía doce puntos de diferencia entre grupos y el modelo no la usa. ¿Qué pasó?
- Que otra columna ya traía esa información
- Que el modelo está mal entrenado
- Que la diferencia de doce puntos era mentira
- Que hay que forzar al modelo a usarla
Lo que te llevas
- 📊 En una logística, los coeficientes son la explicación. Positivo empuja al sí, negativo al no.
- 🗣️ Se dicen en voz alta con
exp(): ser Mayorista multiplica las odds por 1,6 y ser Bodega las corta a la mitad. - 🔀 La importancia por permutación funciona con cualquier modelo, se mide en la unidad que te importa y trae desviación.
- 🥇 Cuatro columnas hacen el modelo: segmento, satisfacción, sin_compra_previa y canal. Las otras nueve están de adorno.
- 🔍 Dos columnas que dicen lo mismo salen las dos sin importancia. No sobra información: sobra una columna.
- 🧾 Una predicción concreta se explica sumando aportes, y la suma reconstruye la probabilidad exacta.
- 📦 Para un modelo lineal, SHAP da lo mismo que esas tres líneas. Instálalo cuando el modelo sea un bosque o un boosting.
En el capítulo 22 vamos a lo que el modelo aprendió sin que nadie se lo pidiera: sesgo, grupos que salen peor parados y cuánta confianza merece cada predicción.
Que tengas lindo día! 🌸