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.
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í.
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.
- 😐
montotiene 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.