Capítulo 21 de 25 11 secciones 19 min

Cuando la pregunta no es predecir

Coeficientes con su valor p y su intervalo, odds que se dicen en voz alta, y la columna que el boosting adora y la regresión no distingue del ruido.

statsmodels ajusta la misma regresión logística que scikit-learn, con los mismos coeficientes, y añade lo que hace falta para decidir: error estándar, valor p e intervalo. Ser Mayorista multiplica por 3,60 las odds de cerrar (entre 2,33 y 5,55) y sube la probabilidad 27,00 puntos. Y monto tiene p de 0,1561 con un intervalo que cruza el 1, o sea que en línea recta no se distingue del ruido, aunque sea la columna número uno del boosting: su efecto existe pero no es una recta. Una columna que es la respuesta ni siquiera deja ajustar, y eso delata una fuga que scikit-learn se traga sin avisar.

Todo el libro hasta aquí ha ido de predecir: dame una fila y te digo la probabilidad. Este capítulo va de la otra pregunta, la que también llega y que se contesta con otras herramientas 🔬

"¿Ser Mayorista influye de verdad en que cierre la venta, o es que los mayoristas casualmente compran por WhatsApp y lo que influye es el canal?"

Eso no es una predicción. Es una pregunta sobre si el efecto existe, y scikit-learn no está hecho para contestarla. Le pides los coeficientes y te da trece números pelados, sin errores estándar, sin intervalos y sin valores p.

Para eso está statsmodels, que es la librería que usaría alguien de estadística y que en este mundo se nombra poco 💜

Los mismos coeficientes, con lo que les falta

Empecemos por lo importante: no es otro modelo. Es la misma regresión logística del capítulo 13, ajustada igual.

import numpy as np
import pandas as pd
import statsmodels.api as sm
import statsmodels.formula.api as smf

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

ventas = carga_limpia(URL)
ventas['sin_compra_previa'] = ventas['fecha_ultima_compra'].isna().astype(int)
ventas['precio_unitario'] = ventas['monto'] / ventas['unidades']
datos = ventas.dropna(subset=['satisfaccion']).copy()

FORMULA = ('compro ~ satisfaccion + monto + sin_compra_previa '
           '+ C(segmento) + C(canal)')
modelo = smf.logit(FORMULA, data=datos).fit(disp=0)

print('statsmodels', sm.__version__)
print('filas usadas:', int(modelo.nobs))
print(modelo.summary().tables[1])
statsmodels 0.14.6
filas usadas: 2769
=============================================================================================
                                coef    std err          z      P>|z|      [0.025      0.975]
---------------------------------------------------------------------------------------------
Intercept                    -1.7627      0.148    -11.950      0.000      -2.052      -1.474
C(segmento)[T.Horeca]         1.0444      0.134      7.800      0.000       0.782       1.307
C(segmento)[T.Mayorista]      1.2799      0.221      5.782      0.000       0.846       1.714
C(segmento)[T.Minimarket]     0.7989      0.118      6.790      0.000       0.568       1.030
C(canal)[T.Tienda]            0.6429      0.117      5.505      0.000       0.414       0.872
C(canal)[T.Web]               0.4781      0.113      4.235      0.000       0.257       0.699
C(canal)[T.WhatsApp]          0.8502      0.119      7.119      0.000       0.616       1.084
satisfaccion                  0.2868      0.030      9.668      0.000       0.229       0.345
monto                         0.0002      0.000      1.418      0.156   -6.05e-05       0.000
sin_compra_previa            -0.9003      0.107     -8.429      0.000      -1.110      -0.691
=============================================================================================

Esa tabla es lo que statsmodels añade, y son cuatro columnas 🔎

  • 📏 std err: cuánto se movería ese coeficiente si repitieras el estudio con otra muestra. Es la barra de error.
  • z: el coeficiente dividido entre esa barra, o sea cuántas veces cabe el ruido dentro del efecto.
  • 🎲 P>|z|: la probabilidad de ver algo así de grande si el efecto real fuera cero.
  • 📐 [0.025 0.975]: el intervalo donde razonablemente está el valor de verdad.
z=β^ee(β^),IC95%=exp(β^±1,96ee)

el coeficiente dividido entre su error estándar dice cuántas veces cabe el ruido dentro del efecto, y el intervalo se lee en odds al pasarlo por la exponencial

Si esas palabras te suenan a chino, están todas explicadas de cero en el libro de estadística, que es el que va antes que este 📐

Lo mismo con sklearn, para que no haya duda

from sklearn.linear_model import LogisticRegression

X = pd.get_dummies(
    datos[['satisfaccion', 'monto', 'sin_compra_previa', 'segmento', 'canal']],
    drop_first=True).astype(float)
sk = LogisticRegression(max_iter=5000, C=1e9).fit(X, datos['compro'])

print(pd.Series(sk.coef_[0], index=X.columns).round(4).to_string())
print()
print('satisfaccion  statsmodels', round(modelo.params['satisfaccion'], 4),
      '| sklearn', round(float(sk.coef_[0][list(X.columns).index('satisfaccion')]), 4))
satisfaccion           0.2867
monto                  0.0002
sin_compra_previa     -0.8998
segmento_Horeca        1.0441
segmento_Mayorista     1.2790
segmento_Minimarket    0.7985
canal_Tienda           0.6426
canal_Web              0.4783
canal_WhatsApp         0.8493

satisfaccion  statsmodels 0.2868 | sklearn 0.2867

Idénticos hasta la cuarta cifra 🤝

Ese C=1e9 es lo único que hay que saber para que coincidan: scikit-learn regulariza por defecto y statsmodels no, así que hay que apagar la regularización poniendo la C altísima.

Y ojo, que ese detalle explica una cosa que confunde a mucha gente: si sacas coeficientes de scikit-learn sin tocar la C, están encogidos hacia cero a propósito. Sirven para predecir. Para contarlos en una reunión, no.

Los odds, que es como se dice en voz alta

Un coeficiente de 1,2799 no se puede decir en una reunión. Pasado por la exponencial, sí.

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

tabla = pd.DataFrame({'odds': np.exp(modelo.params), 'p': modelo.pvalues})
intervalo = np.exp(modelo.conf_int())
tabla['ic_bajo'], tabla['ic_alto'] = intervalo[0], intervalo[1]
print(tabla.round(4).to_string())
                             odds       p  ic_bajo  ic_alto
Intercept                  0.1716  0.0000   0.1285   0.2291
C(segmento)[T.Horeca]      2.8417  0.0000   2.1858   3.6945
C(segmento)[T.Mayorista]   3.5961  0.0000   2.3303   5.5494
C(segmento)[T.Minimarket]  2.2232  0.0000   1.7653   2.7998
C(canal)[T.Tienda]         1.9019  0.0000   1.5128   2.3911
C(canal)[T.Web]            1.6129  0.0000   1.2928   2.0124
C(canal)[T.WhatsApp]       2.3401  0.0000   1.8517   2.9572
satisfaccion               1.3322  0.0000   1.2569   1.4120
monto                      1.0002  0.1561   0.9999   1.0004
sin_compra_previa          0.4065  0.0000   0.3297   0.5011

Ahora sí se puede leer en voz alta 🗣️

  • 🏭 Ser Mayorista multiplica por 3,60 las probabilidades a favor de que cierre, comparado con Bodega. Y el intervalo va de 2,33 a 5,55, así que aunque el número exacto no lo sepamos, que multiplica al menos por dos sí.
  • ⭐ Cada punto de satisfacción multiplica por 1,33.
  • 🆕 Ser cliente nuevo multiplica por 0,41, o sea que las reduce a menos de la mitad.
  • 📱 WhatsApp multiplica por 2,34 contra Marketplace, que es la categoría de referencia.

Ese "comparado con Bodega" y ese "contra Marketplace" no son un detalle: en una variable categórica todos los coeficientes se leen contra la categoría que falta, que es la primera por orden alfabético. Si te olvidas de decirlo, la frase queda mal 🏷️

La que no se distingue de nada

Hay una fila de esa tabla que no se parece a las demás.

cruza = tabla[(tabla['ic_bajo'] < 1) & (tabla['ic_alto'] > 1)]
print(cruza.round(4).to_string() if len(cruza) else 'ninguna')
         odds       p  ic_bajo  ic_alto
monto  1.0002  0.1561   0.9999   1.0004

monto, con p de 0,1561 y un intervalo que va de 0,9999 a 1,0004, o sea que cruza el 1 😐

Cruzar el 1 en odds es lo mismo que cruzar el cero en el coeficiente: con estos datos no se puede distinguir de no tener efecto. Y fíjate en cómo lo he dicho, porque la frase importa: no es "el monto no influye". Es "con estas 2.769 filas no lo puedo separar del ruido".

Y ahora el detalle que a mí me parece lo mejor de todo el libro 🤯

En el capítulo 20, monto era la columna número uno del LightGBM, con 0,2342 de SHAP. Aquí no llega a distinguirse de cero.

No se contradicen. Dicen cosas distintas sobre la misma columna:

  • 📈 La regresión pregunta si el monto empuja en línea recta: más soles, más probabilidad. Y en línea recta no dice nada.
  • 🪜 El árbol puede trocear el monto donde quiera y quedarse con "por debajo de 300 soles pasa una cosa y por encima de 1.500 otra". Eso sí lo encuentra.

La lección práctica: un valor p alto no significa que la columna sea inútil para predecir. Significa que su efecto no es una recta. Tirar columnas por su valor p es de los errores que más modelos empeora 🚮

Lo que de verdad se dice en la reunión

Los odds son correctos y siguen sin ser lo que la gente entiende. Nadie piensa en "multiplicar las probabilidades a favor". La gente piensa en puntos porcentuales.

marginal = modelo.get_margeff(at='overall')
print(marginal.summary().tables[1])
=============================================================================================
                               dy/dx    std err          z      P>|z|      [0.025      0.975]
---------------------------------------------------------------------------------------------
C(segmento)[T.Horeca]         0.2204      0.027      8.142      0.000       0.167       0.273
C(segmento)[T.Mayorista]      0.2700      0.046      5.915      0.000       0.181       0.359
C(segmento)[T.Minimarket]     0.1686      0.024      7.012      0.000       0.121       0.216
C(canal)[T.Tienda]            0.1356      0.024      5.616      0.000       0.088       0.183
C(canal)[T.Web]               0.1009      0.024      4.285      0.000       0.055       0.147
C(canal)[T.WhatsApp]          0.1794      0.024      7.363      0.000       0.132       0.227
satisfaccion                  0.0605      0.006     10.304      0.000       0.049       0.072
monto                      3.345e-05   2.36e-05      1.420      0.156   -1.27e-05    7.96e-05
sin_compra_previa            -0.1899      0.021     -8.851      0.000      -0.232      -0.148
=============================================================================================

Esto ya es castellano 🎯

  • ⭐ Cada punto de satisfacción sube la probabilidad de cierre 6,05 puntos.
  • 🏭 Ser Mayorista en vez de Bodega la sube 27,00 puntos.
  • 🆕 Ser cliente nuevo la baja 18,99 puntos.

El efecto marginal es la pendiente media: cuánto se mueve la probabilidad si esa variable sube una unidad y todo lo demás se queda quieto.

Y digo "media" a propósito, porque en una logística la pendiente no es la misma en todos lados: donde la probabilidad ya es 0,95 casi no se puede subir más. El número de arriba es el promedio de todas las filas.

Más datos afilan lo real y no arreglan lo que no está

La demostración que enseña qué es de verdad un valor p 🔬

for n in (500, 1000, 2000, len(datos)):
    trozo = datos.sample(n=min(n, len(datos)), random_state=7)
    m = smf.logit(FORMULA, data=trozo).fit(disp=0)
    print(f'{len(trozo):5d} filas -> monto coef {m.params["monto"]:+.6f} '
          f'p {m.pvalues["monto"]:.4f}   satisfaccion p {m.pvalues["satisfaccion"]:.2e}')
  500 filas -> monto coef -0.000358 p 0.2091   satisfaccion p 5.44e-06
 1000 filas -> monto coef -0.000017 p 0.9299   satisfaccion p 3.91e-11
 2000 filas -> monto coef +0.000120 p 0.3582   satisfaccion p 1.21e-17
 2769 filas -> monto coef +0.000159 p 0.1561   satisfaccion p 4.12e-22

Mira las dos columnas por separado, que cuentan historias opuestas.

La satisfacción va de p 5,44e-06 a 4,12e-22. Cada vez más seguro. Eso es lo que hace un efecto que existe: con más datos la barra de error se encoge y el efecto se ve más claro.

El monto pasea. Su coeficiente empieza en −0,000358, se va a −0,000017, sube a +0,000120 y acaba en +0,000159. Cambia de signo por el camino. Y su p va de 0,21 a 0,93 a 0,36 a 0,16, sin acercarse nunca a nada.

Un efecto que existe se afila con datos. Uno que no existe se pasea, y ese paseo es exactamente lo que mide el valor p 🎲

El error: cuando el modelo se niega a ajustar

Vamos a meterle la trampa del capítulo 12: una columna que es la respuesta.

con_soplon = datos.copy()
con_soplon['soplon'] = con_soplon['compro']
smf.logit('compro ~ soplon + satisfaccion', data=con_soplon).fit(disp=0)
LinAlgError: Singular matrix

Esto es precioso y no lo esperaba la primera vez 🤩

Se llama separación perfecta: existe una columna que separa las dos clases sin errores, así que el coeficiente que mejor ajusta es infinito. Y como no hay número que sirva, el álgebra se rompe y sale un LinAlgError.

Compara eso con lo que pasó en el capítulo de fuga: scikit-learn cogió la columna soplona, entrenó tan contento y devolvió 0,9994 de AUC. Nadie avisó de nada.

Statsmodels se niega a ajustar. Y aunque sea molesto, es la mejor detección de fuga que hay: si tu regresión no converge, mira si alguna columna sabe demasiado antes de tocar nada más 🚨

Y el silencio que sí duele

solo_satisfaccion = smf.logit('compro ~ satisfaccion', data=ventas).fit(disp=0)
print('filas del archivo :', len(ventas))
print('filas que usó     :', int(solo_satisfaccion.nobs))
print('descartadas       :', len(ventas) - int(solo_satisfaccion.nobs))
filas del archivo : 3000
filas que usó     : 2769
descartadas       : 231

231 filas fuera y ni un aviso 😨

Statsmodels borra las filas con nulos y sigue. Le pasé el archivo entero y ajustó con 2.769, o sea que el 7,7% de las ventas no participó en las conclusiones y en ningún sitio del summary() lo dice a gritos.

Por eso en este capítulo el dropna va escrito arriba, a mano. Que la decisión de qué filas entran la tomes tú y no la librería, y que quede por escrito cuántas eran 📋

Y acuérdate del capítulo 9: esas filas no son un descuido, son la columna sin_satisfaccion. Si el hueco significa algo, descartar las filas te lo tira a la basura.

Ejercicios

1. ¿Se pisan las columnas entre ellas?

Si dos columnas dicen casi lo mismo, sus coeficientes se vuelven locos y sus intervalos se ensanchan. Eso se mide con el VIF.

from statsmodels.stats.outliers_influence import variance_inflation_factor

M = datos[['satisfaccion', 'monto', 'unidades', 'precio_unitario',
           'sin_compra_previa']].astype(float)
M = sm.add_constant(M)
vif = pd.Series([variance_inflation_factor(M.values, i) for i in range(M.shape[1])],
                index=M.columns)
print(vif.round(2).to_string())
const                12.05
satisfaccion          1.00
monto                 1.33
unidades              1.28
precio_unitario       1.60
sin_compra_previa     1.00

La regla de andar por casa es que por encima de 5 hay que mirarlo y por encima de 10 hay problema. Aquí lo más alto es 1,60 🎉

Y eso que precio_unitario se calcula dividiendo monto entre unidades, que suena a estar contando lo mismo tres veces. Pues no: una división no es una combinación lineal, y el VIF solo ve relaciones lineales.

La constante sale en 12,05 y esa no cuenta. Su VIF alto solo dice que las columnas no están centradas en cero, que es lo normal.

2. Meter las tres y ver si algo se mueve

El VIF dice que no hay problema. Compruébalo de la forma que de verdad importa: añadiendo las columnas y mirando si los demás coeficientes se mueven.

ampliado = smf.logit(FORMULA + ' + unidades + precio_unitario',
                     data=datos).fit(disp=0)
comparar = pd.DataFrame({'sin': modelo.params, 'con': ampliado.params,
                         'p_sin': modelo.pvalues, 'p_con': ampliado.pvalues})
print(comparar.round(4).to_string())
                              sin     con   p_sin   p_con
C(canal)[T.Tienda]         0.6429  0.6414  0.0000  0.0000
C(canal)[T.Web]            0.4781  0.4781  0.0000  0.0000
C(canal)[T.WhatsApp]       0.8502  0.8470  0.0000  0.0000
C(segmento)[T.Horeca]      1.0444  1.0438  0.0000  0.0000
C(segmento)[T.Mayorista]   1.2799  1.2829  0.0000  0.0000
C(segmento)[T.Minimarket]  0.7989  0.7931  0.0000  0.0000
Intercept                 -1.7627 -1.8732  0.0000  0.0000
monto                      0.0002  0.0001  0.1561  0.4575
precio_unitario               NaN  0.0005     NaN  0.0618
satisfaccion               0.2868  0.2881  0.0000  0.0000
sin_compra_previa         -0.9003 -0.8961  0.0000  0.0000
unidades                      NaN  0.0094     NaN  0.2492

Los coeficientes que ya estaban casi no se enteran: Tienda pasa de 0,6429 a 0,6414 y la satisfacción de 0,2868 a 0,2881. Eso es lo que se espera cuando no hay colinealidad 👍

Pero mira monto: su p pasa de 0,1561 a 0,4575 al entrar precio_unitario. Las dos comparten información y se reparten el poco efecto que había, así que las dos quedan más borrosas.

Es la versión suave del problema: no rompe nada, pero si tu pregunta era justo sobre el monto, acabas de contestarla peor por añadir una columna 🤷‍♀️

3. La categoría de referencia, cambiada a propósito

Todos los coeficientes se leen contra Bodega porque es la primera del abecedario. Ponla contra Mayorista y mira qué cambia.

otra = smf.logit(
    'compro ~ satisfaccion + monto + sin_compra_previa '
    "+ C(segmento, Treatment(reference='Mayorista')) + C(canal)",
    data=datos).fit(disp=0)

print('contra Bodega:')
print(np.exp(modelo.params.filter(like='segmento')).round(4).to_string())
print('\ncontra Mayorista:')
print(np.exp(otra.params.filter(like='segmento')).round(4).to_string())
print('\nsatisfaccion en los dos:', round(modelo.params['satisfaccion'], 4),
      'y', round(otra.params['satisfaccion'], 4))
contra Bodega:
C(segmento)[T.Horeca]        2.8417
C(segmento)[T.Mayorista]     3.5961
C(segmento)[T.Minimarket]    2.2232

contra Mayorista:
C(segmento, Treatment(reference='Mayorista'))[T.Bodega]        0.2781
C(segmento, Treatment(reference='Mayorista'))[T.Horeca]        0.7902
C(segmento, Treatment(reference='Mayorista'))[T.Minimarket]    0.6182

satisfaccion en los dos: 0.2868 y 0.2868

Los odds del segmento cambian todos y los de la satisfacción no se mueven ni una milésima. Es el mismo modelo contado desde otro sitio 🔄

Elige la referencia que haga la frase útil. Si la reunión va de si vale la pena ir a por bodegas, la referencia es Bodega. Si va de si conviene dejar de atenderlas, ponla contra el segmento fuerte y que se vea la distancia.

4. Un modelo dentro de otro

¿Aporta algo el canal, sabiendo ya el segmento? Se compara el modelo con y sin él, con una prueba de razón de verosimilitud.

from scipy import stats

sin_canal = smf.logit('compro ~ satisfaccion + monto + sin_compra_previa '
                      '+ C(segmento)', data=datos).fit(disp=0)

estadistico = 2 * (modelo.llf - sin_canal.llf)
grados = int(modelo.df_model - sin_canal.df_model)
p = stats.chi2.sf(estadistico, grados)

print('log-verosimilitud sin canal:', round(sin_canal.llf, 2))
print('log-verosimilitud con canal:', round(modelo.llf, 2))
print(f'chi2 = {estadistico:.2f} con {grados} grados de libertad, p = {p:.3e}')
print('pseudo R2:', round(sin_canal.prsquared, 4), '->', round(modelo.prsquared, 4))
log-verosimilitud sin canal: -1718.59
log-verosimilitud con canal: -1689.7
chi2 = 57.77 con 3 grados de libertad, p = 1.758e-12
pseudo R2: 0.0901 -> 0.1054

Esta es la forma correcta de preguntar "¿este bloque de columnas aporta?", y sirve para cualquier grupo: las tres columnas del canal a la vez, no una por una 🧱

Es lo mismo que hace la validación cruzada del capítulo 16 pero contestando otra cosa: allí se pregunta si predice mejor, aquí si el ajuste mejora más de lo que mejoraría por azar al añadir parámetros.

5. Los residuos, para encontrar filas raras

Un residuo grande es una fila que el modelo no explica. Míralas, que suelen contar algo.

residuos = modelo.resid_pearson
extremos = datos.assign(residuo=residuos.values,
                        ajustado=modelo.fittedvalues.values)
extremos = extremos.reindex(extremos['residuo'].abs().sort_values(ascending=False).index)

print(extremos[['segmento', 'canal', 'satisfaccion', 'monto', 'compro', 'residuo']]
      .head(6).round(3).to_string(index=False))
print()
print('residuos por encima de |3|:', int((np.abs(residuos) > 3).sum()), 'de', len(residuos))
 segmento    canal  satisfaccion   monto  compro  residuo
Mayorista WhatsApp           5.0 3440.83       0   -3.233
Mayorista   Tienda           5.0 1991.81       0   -2.599
Mayorista   Tienda           5.0 1980.53       0   -2.596
Mayorista   Tienda           5.0 1796.06       0   -2.559
Mayorista WhatsApp           4.0 2135.48       0   -2.526
Mayorista   Tienda           5.0 1588.35       0   -2.517

residuos por encima de |3|: 1 de 2769

Las seis de arriba son la misma película: mayoristas con satisfacción 5 y montos altos que no compraron. Y solo una pasa de |3| en las 2.769, así que el modelo no está roto: son casos que el archivo no sabe explicar 🕵️‍♀️

Ahora fíjate en la cuarta fila, la de 1.796,06 soles. Es exactamente la misma que salió en el capítulo 20 como la predicción más segura y equivocada del LightGBM.

Dos modelos distintos, dos formas distintas de buscar y la misma venta arriba. Eso ya no es casualidad: es que a esa venta le falta una columna que nadie está midiendo, y ninguna herramienta la va a inventar 🔍

6. Predecir con statsmodels, y por qué no lo harías

Se puede, claro. Mide su AUC contra la logística de scikit-learn del resto del libro.

from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split

entrena, examen = train_test_split(datos, test_size=0.25, random_state=42,
                                   stratify=datos['compro'])
m = smf.logit(FORMULA, data=entrena).fit(disp=0)
p = m.predict(examen)

print('AUC de statsmodels:', round(roc_auc_score(examen['compro'], p), 4))
print('filas de examen   :', len(examen))
print()
print('lo que NO trae de fabrica:')
for cosa in ('pipeline', 'validacion cruzada', 'imputacion',
             'one-hot con handle_unknown', 'predict_proba'):
    print('  -', cosa)
AUC de statsmodels: 0.7343
filas de examen   : 693

lo que NO trae de fabrica:
  - pipeline
  - validacion cruzada
  - imputacion
  - one-hot con handle_unknown
  - predict_proba

El AUC es del mismo orden que el del resto del libro, porque es el mismo modelo. Lo que no trae es toda la maquinaria de producción 🏭

Y esa es la regla con la que yo trabajo: statsmodels para entender y para el informe, scikit-learn para el pipeline que se despliega. No compiten. Se usan en momentos distintos del mismo proyecto.

7. La misma pregunta sin regresión, para comprobar

Antes de creerte un coeficiente, mira el número crudo. Si no se parecen, algo pasa.

cruda = datos.groupby('segmento')['compro'].agg(['mean', 'count'])
cruda['odds'] = cruda['mean'] / (1 - cruda['mean'])
cruda['odds_vs_bodega'] = cruda['odds'] / cruda.loc['Bodega', 'odds']

print(cruda.round(4).to_string())
print()
print('lo que dice el modelo, descontando canal, satisfaccion y lo demas:')
print(np.exp(modelo.params.filter(like='segmento')).round(4).to_string())
              mean  count    odds  odds_vs_bodega
segmento                                         
Bodega      0.3740    655  0.5976          1.0000
Horeca      0.6315    692  1.7137          2.8679
Mayorista   0.7149    684  2.5077          4.1965
Minimarket  0.5678    738  1.3135          2.1981

lo que dice el modelo, descontando canal, satisfaccion y lo demas:
C(segmento)[T.Horeca]        2.8417
C(segmento)[T.Mayorista]     3.5961
C(segmento)[T.Minimarket]    2.2232

Los dos números contestan preguntas distintas y por eso no coinciden 📊

La tabla cruda dice cuánto cierran los mayoristas tal como están, con su canal y su satisfacción incluidos. El coeficiente dice cuánto cierran de más a igualdad de todo lo demás.

Y sale más chico: en crudo el mayorista multiplica por 4,20 y descontando lo demás por 3,60. Una sexta parte de su ventaja no era suya, era del canal por el que compra y de la satisfacción que reporta.

Eso cambia la decisión, y no poco: significa que empujar el canal puede servirle también a los demás segmentos, y que "los mayoristas cierran más" es verdad pero no por lo que parecía 💡

Cuando el coeficiente y el número crudo se separan mucho más que esto, la señal es más seria: algo de lo que estás midiendo se explica por otra cosa. Ahí es donde empieza la conversación sobre causalidad, que no es este libro.

Comprueba que lo tienes

Un coeficiente sale con p de 0,16 y su intervalo de odds va de 0,9999 a 1,0004. ¿Qué escribes en el informe?

  • Que con estos datos no se distingue de no tener efecto
  • Que esa variable no influye en la compra
  • Que influye poco, porque el odds es casi 1
  • Que hace falta quitarla del modelo

Lo que te llevas

  • 🧮 statsmodels no es otro modelo: es la misma regresión logística con lo que le falta a scikit-learn. Error estándar, z, valor p e intervalo.
  • 🔧 Para que los coeficientes coincidan hay que apagar la regularización de scikit-learn con C=1e9. Sin eso están encogidos a propósito y no se cuentan en una reunión.
  • 🗣️ Los odds se dicen en voz alta: Mayorista multiplica por 3,60 (entre 2,33 y 5,55), cada punto de satisfacción por 1,33 y ser cliente nuevo por 0,41.
  • 🏷️ Todo coeficiente categórico se lee contra la categoría que falta, que es la primera del abecedario. Sin decirlo, la frase queda mal.
  • 🎯 El efecto marginal es lo que de verdad entiende la gente: cada punto de satisfacción sube 6,05 puntos de probabilidad y ser cliente nuevo baja 18,99.
  • 😐 monto tiene p de 0,1561 y su intervalo cruza el 1. Eso es "con estos datos no lo distingo del ruido", no es "no influye".
  • 🤯 Y esa misma columna era la número uno del boosting en el capítulo 20. No se contradicen: la regresión pregunta si empuja en línea recta y el árbol puede trocearla. Tirar columnas por su valor p empeora modelos.
  • 🔬 Con más filas, la satisfacción pasa de p 5,44e-06 a 4,12e-22 y el monto se pasea hasta cambiar de signo. Un efecto real se afila con datos; uno que no está, no.
  • 🚨 Una columna que es la respuesta hace que la regresión ni ajuste: separación perfecta y LinAlgError. Es la mejor detección de fuga que hay, y scikit-learn en el mismo caso devolvía 0,9994 de AUC tan contento.
  • 🕳️ Y el silencio que sí duele: statsmodels borra las filas con nulos sin avisar. Le pasé 3.000 y ajustó con 2.769.

Qué viene ahora

Ya sabemos predecir, explicar el modelo entero, explicar una fila y decir si un efecto existe. Falta la pregunta más incómoda de todas 😬

El capítulo 22 abre el modelo por grupos: ¿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.
¿Tienes alguna duda o consulta?