Capítulo 19 de 25 9 secciones 13 min

Abrir la caja: qué mira el modelo y por qué

Coeficientes, odds e importancia por permutación, y la columna que valía doce puntos y no aporta nada.

En una regresión logística los coeficientes son la explicación: positivo empuja al sí y negativo al no, y con exp() se dicen en voz alta (ser Mayorista multiplica las odds por 1,6). Para cualquier otro modelo se usa la importancia por permutación, que revuelve una columna y mide cuánto AUC se pierde. Aquí cuatro columnas hacen el modelo y las otras nueve están de adorno: con cuatro se saca 0,7193 contra 0,7214 con trece.

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

log(p1p)=b+jwjxj

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.

Barras horizontales con cuánto mueve cada columna la predicción de un modelo de boosting, medido con SHAP y ordenado de menor a mayor, con el monto y el segmento arriba del todo.
SHAP reparte la predicción de cada fila entre las columnas que la empujaron, y el promedio de esos empujes es lo que se ve aquí. A diferencia de los coeficientes, funciona con cualquier modelo.

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! 🌸

¿Tienes alguna duda o consulta?