Todos los errores de este capítulo tienen algo en común: no dan error 😶
Los cálculos salen, los números se ven bien, el informe se entrega. Y la conclusión es la contraria de la verdad.
import pandas as pd
import numpy as np
from scipy import stats
URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'
def carga_limpia(url):
"""La misma del capítulo 2."""
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
v = carga_limpia(URL)
print('filas:', len(v))
filas: 3000
1. La paradoja de Simpson
Empiezo por la buena noticia: este archivo no la tiene. Lo comprobé antes de escribir el capítulo:
tasas = v.pivot_table(index='canal', columns='segmento',
values='compro', aggfunc='mean')
print(tasas.round(4))
print()
print('composicion de cada canal:')
print(pd.crosstab(v['canal'], v['segmento'], normalize='index').round(4))
segmento Bodega Horeca Mayorista Minimarket canal Marketplace 0.2762 0.5050 0.6368 0.4158 Tienda 0.3871 0.7590 0.7120 0.5981 Web 0.3846 0.6310 0.7374 0.5708 WhatsApp 0.4545 0.6578 0.8187 0.6919 composicion de cada canal: segmento Bodega Horeca Mayorista Minimarket canal Marketplace 0.2372 0.2647 0.2490 0.2490 Tienda 0.2135 0.2287 0.2631 0.2948 Web 0.2462 0.2361 0.2500 0.2677 WhatsApp 0.2448 0.2601 0.2378 0.2573
Mira la segunda tabla: los cuatro canales tienen prácticamente la misma mezcla de segmentos, entre el 21% y el 29% de cada uno. Cuando la composición está equilibrada, Simpson no puede aparecer 🛡️
Así que vamos a fabricarlo. Y lo voy a hacer con ventas de verdad de este archivo: no invento ni un dato, solo elijo cuáles miro.
mp = v[v['canal'] == 'Marketplace']
wa = v[v['canal'] == 'WhatsApp']
# Marketplace casi solo mayoristas, WhatsApp casi solo bodegas.
marketplace = pd.concat([mp[mp['segmento'] == 'Mayorista'],
mp[mp['segmento'] == 'Bodega'].head(20)])
whatsapp = pd.concat([wa[wa['segmento'] == 'Bodega'],
wa[wa['segmento'] == 'Mayorista'].head(20)])
print('EN TOTAL')
print(' Marketplace: %.4f (n=%d)' % (marketplace['compro'].mean(), len(marketplace)))
print(' WhatsApp: %.4f (n=%d)' % (whatsapp['compro'].mean(), len(whatsapp)))
EN TOTAL Marketplace: 0.6000 (n=210) WhatsApp: 0.5051 (n=196)
Conclusión evidente: Marketplace cierra mejor que WhatsApp, 60,00% contra 50,51%. Casi diez puntos.
Ahora lo mismo, mirando dentro de cada segmento:
for seg in ['Bodega', 'Mayorista']:
a = marketplace[marketplace['segmento'] == seg]
b = whatsapp[whatsapp['segmento'] == seg]
print('%-10s Marketplace %.4f (n=%3d) | WhatsApp %.4f (n=%3d)'
% (seg, a['compro'].mean(), len(a), b['compro'].mean(), len(b)))
Bodega Marketplace 0.2500 (n= 20) | WhatsApp 0.4545 (n=176) Mayorista Marketplace 0.6368 (n=190) | WhatsApp 0.9500 (n= 20)
Se dio la vuelta 🤯
En Bodega gana WhatsApp: 45,45% contra 25,00%. En Mayorista gana WhatsApp: 95,00% contra 63,68%. WhatsApp gana en los dos grupos y pierde en el total.
Eso es la paradoja de Simpson, y lo que la produce está en la composición:
print('composicion de Marketplace:')
print((marketplace['segmento'].value_counts(normalize=True) * 100).round(1))
print()
print('composicion de WhatsApp:')
print((whatsapp['segmento'].value_counts(normalize=True) * 100).round(1))
composicion de Marketplace: segmento Mayorista 90.5 Bodega 9.5 Name: proportion, dtype: float64 composicion de WhatsApp: segmento Bodega 89.8 Mayorista 10.2 Name: proportion, dtype: float64
Marketplace está lleno de mayoristas, que cierran mucho de por sí. WhatsApp está lleno de bodegas, que cierran poco. El total no compara canales: compara clientelas 🎭
Y cuidado con la moraleja fácil, que suele ser "hay que mirar por grupos". No siempre 🙅 Si el canal fuera la causa de que lleguen unos u otros clientes, partir por segmento estaría borrando justo el efecto que buscas.
La regla honesta: hay que decidir qué se controla antes de mirar los datos, y con una razón. Si eliges después, siempre puedes encontrar el corte que te da la respuesta que querías.
2. La regresión a la media
Este me parece el más bonito de todos, y el que más decisiones estropea 📉
Ordeno las ventas de cada cliente y miro qué pasa después de una venta muy grande o muy chica:
orden = v.sort_values(['cliente_id', 'fecha']).copy()
orden['siguiente'] = orden.groupby('cliente_id')['monto'].shift(-1)
con_siguiente = orden.dropna(subset=['siguiente'])
alto = con_siguiente['monto'].quantile(0.8)
bajo = con_siguiente['monto'].quantile(0.2)
mejores = con_siguiente[con_siguiente['monto'] >= alto]
peores = con_siguiente[con_siguiente['monto'] <= bajo]
print('media de todas: %.2f' % con_siguiente['monto'].mean())
print()
print('el 20%% mas alto: esta venta %.2f -> la siguiente %.2f'
% (mejores['monto'].mean(), mejores['siguiente'].mean()))
print('el 20%% mas bajo: esta venta %.2f -> la siguiente %.2f'
% (peores['monto'].mean(), peores['siguiente'].mean()))
media de todas: 800.52 el 20% mas alto: esta venta 2108.22 -> la siguiente 794.42 el 20% mas bajo: esta venta 131.62 -> la siguiente 782.16
Los que compraron 2.108 bajan a 794. Los que compraron 131 suben a 782. Los dos aterrizan en la media de 800,52 🛬
Y ahora imagina el informe: "los clientes que atendimos con el programa premium bajaron un 62%". O al revés: "la campaña de recuperación subió las ventas de los clientes flojos un 494%".
Las dos frases serían falsas, y las dos saldrían de datos correctos. No pasó nada: los extremos vuelven al centro solos.
print('correlacion entre una venta y la siguiente del mismo cliente: %.4f'
% stats.pearsonr(con_siguiente['monto'], con_siguiente['siguiente'])[0])
correlacion entre una venta y la siguiente del mismo cliente: -0.0099
Cero. La venta de hoy no dice nada de la siguiente, así que después de una venta enorme lo esperable es una venta normal, que comparada con la enorme parece una caída 📊
La defensa: si seleccionaste a alguien por ser extremo, necesitas un grupo de control. Sin comparar contra clientes igual de extremos que no recibieron el programa, no se puede decir nada.
3. El p-hacking, o buscar hasta encontrar
Ya lo vimos por partes en los capítulos 10, 11 y 13. Aquí está entero:
from itertools import product
encontrados = []
for col, col2 in product(['ciudad', 'canal', 'categoria'], repeat=2):
if col == col2:
continue
for val, val2 in product(v[col].unique(), v[col2].unique()):
sub = v[(v[col] == val) & (v[col2] == val2)]
if len(sub) < 80:
continue
a = sub.loc[sub['segmento'] == 'Mayorista', 'compro']
b = sub.loc[sub['segmento'] == 'Bodega', 'compro']
pv = stats.ttest_ind(a, b, equal_var=False).pvalue
encontrados.append((pv, col, val, col2, val2, len(sub)))
encontrados.sort()
print('subgrupos probados: %d' % len(encontrados))
print()
for pv, col, val, col2, val2, n in encontrados[:3]:
print('%s=%s y %s=%s (n=%d) -> p = %.3g' % (col, val, col2, val2, n, pv))
subgrupos probados: 144 canal=Web y categoria=Snacks (n=167) -> p = 3.53e-10 categoria=Snacks y canal=Web (n=167) -> p = 3.53e-10 canal=WhatsApp y ciudad=lima (n=124) -> p = 4.17e-06
"En Web, categoría Snacks, la diferencia entre mayoristas y bodegas es abrumadora: p = 0,00000000035" 🎉
Suena a hallazgo. Y es basura, porque probé 144 subgrupos y reporté el mejor. Con 144 intentos al 5%, encontrar algo espectacular está garantizado.
Lo peor de este error es que casi nadie lo hace a propósito. Se hace probando cosas, que es lo que uno debe hacer, y luego contando solo lo que salió.
Las defensas, por orden de eficacia:
- 📝 Escribe la hipótesis antes de mirar.
- 🔢 Di cuántas cosas probaste. Siempre.
- ➗ Corrige el alfa (Bonferroni, capítulo 11).
- 🔁 Guarda datos que no miraste y comprueba el hallazgo ahí.
4. Las filas que no son independientes
Todas las pruebas del libro suponen que cada fila es un caso independiente. Y en el capítulo 2 vimos que aquí hay 617 clientes en 3.000 ventas.
por_cliente = (v.groupby(['cliente_id', 'segmento'])['compro']
.mean().reset_index())
a_ventas = v.loc[v['segmento'] == 'Horeca', 'compro']
b_ventas = v.loc[v['segmento'] == 'Minimarket', 'compro']
a_clientes = por_cliente.loc[por_cliente['segmento'] == 'Horeca', 'compro']
b_clientes = por_cliente.loc[por_cliente['segmento'] == 'Minimarket', 'compro']
print('contando ventas: n = %d y %d -> p = %.5f'
% (len(a_ventas), len(b_ventas),
stats.ttest_ind(a_ventas, b_ventas, equal_var=False).pvalue))
print('contando clientes: n = %d y %d -> p = %.5f'
% (len(a_clientes), len(b_clientes),
stats.ttest_ind(a_clientes, b_clientes, equal_var=False).pvalue))
contando ventas: n = 742 y 801 -> p = 0.01181 contando clientes: n = 438 y 448 -> p = 0.00716
Aquí me llevé una sorpresa y la publico tal cual 😅
Yo esperaba que al contar clientes en vez de ventas el resultado se debilitara, porque la muestra baja de 742 a 438. Y pasó al revés: el p mejoró, de 0,01181 a 0,00716.
La explicación es que promediar las ventas de cada cliente quita ruido, y ese ruido pesaba más que las filas perdidas.
Pero que aquí saliera bien no salva la regla, y es importante: en general, tratar 3.000 ventas de 617 clientes como 3.000 casos independientes infla la confianza. Aquí no lo hizo, y en tu tabla puede hacerlo 🎯
La comprobación cuesta cinco líneas, como acabas de ver. Hazla siempre que la misma persona, tienda o máquina aparezca varias veces.
5. El sesgo de selección
Este ya salió en el capítulo 8 y fue culpa mía, así que lo repito aquí como ejemplo 🙋
dias_con_ventas = v.groupby(v['fecha'].dt.date).size()
rango = (v['fecha'].max() - v['fecha'].min()).days + 1
print('dias que aparecen en el archivo: %d' % len(dias_con_ventas))
print('dias que hay en el periodo: %d' % rango)
print('dias sin ninguna venta: %d' % (rango - len(dias_con_ventas)))
print()
print('ventas por dia contando solo los dias con ventas: %.4f'
% dias_con_ventas.mean())
print('ventas por dia contando todos los dias: %.4f'
% (len(v) / rango))
dias que aparecen en el archivo: 537 dias que hay en el periodo: 540 dias sin ninguna venta: 3 ventas por dia contando solo los dias con ventas: 5.5866 ventas por dia contando todos los dias: 5.5556
Tres días desaparecidos, y con ellos la respuesta cambia: 5,5866 ventas diarias contando solo los días con actividad, 5,5556 contando todos.
Aquí la diferencia es chica porque solo faltan 3 días de 540. Pero el mecanismo es el importante: agrupar por lo que existe borra lo que no existe, y nadie lo ve porque las filas que faltan no están ahí para protestar 👻
Es el mismo error que mirar solo a los clientes que siguen contigo, o solo a los proyectos que terminaron, o solo a las empresas que sobrevivieron.
El error del capítulo
El que más análisis rompe de verdad, y no da error:
print('filas del archivo: %d' % len(v))
print('filas que ve un groupby normal: %d' % v.groupby('satisfaccion').size().sum())
print('con dropna=False: %d'
% v.groupby('satisfaccion', dropna=False).size().sum())
filas del archivo: 3000 filas que ve un groupby normal: 2769 con dropna=False: 3000
groupby tira las filas cuya clave es nula, en silencio 🕳️
231 ventas desaparecen de cualquier análisis que agrupe por satisfacción, y no hay ningún aviso. Si además sumas montos por grupo, el total no cuadra con el total del archivo y te vas a volver loca buscando dónde.
Y el que sí frena, por comparar:
otro = pd.DataFrame({'cliente_id': range(10), 'nivel': 1})
v.merge(otro, on='cliente_id')
ValueError: You are trying to merge on str and int64 columns for key 'cliente_id'. If you wish to proceed you should use pd.concat
Ese te para porque los tipos no coinciden. Menos mal, porque un merge mal hecho es de los que más daño hacen: si la tabla de la derecha tiene claves repetidas, te devuelve más filas de las que tenías y todos los promedios cambian sin que nadie lo note.
La costumbre que salva: print(len(df)) antes y después de cada
merge 📏
Practica 💪
1. Fabrica tú un Simpson al revés
Construye el caso contrario: que Marketplace gane en los dos segmentos y pierda en el total.
marketplace2 = pd.concat([mp[mp['segmento'] == 'Bodega'],
mp[mp['segmento'] == 'Mayorista'].head(15)])
whatsapp2 = pd.concat([wa[wa['segmento'] == 'Mayorista'],
wa[wa['segmento'] == 'Bodega'].head(15)])
print('EN TOTAL')
print(' Marketplace %.4f (n=%d) | WhatsApp %.4f (n=%d)'
% (marketplace2['compro'].mean(), len(marketplace2),
whatsapp2['compro'].mean(), len(whatsapp2)))
print()
for seg in ['Bodega', 'Mayorista']:
x = marketplace2[marketplace2['segmento'] == seg]
y = whatsapp2[whatsapp2['segmento'] == seg]
print('%-10s Marketplace %.4f | WhatsApp %.4f' % (seg, x['compro'].mean(),
y['compro'].mean()))
EN TOTAL Marketplace 0.3010 (n=196) | WhatsApp 0.7957 (n=186) Bodega Marketplace 0.2762 | WhatsApp 0.5333 Mayorista Marketplace 0.6000 | WhatsApp 0.8187
Aquí no se dio la vuelta: WhatsApp gana en el total y en los dos segmentos 🤷
Y eso también enseña algo. La paradoja no aparece siempre que desbalanceas: hace falta que el desbalance vaya justo en contra de la diferencia real.
En este montaje puse a Marketplace las bodegas (que cierran poco) y a WhatsApp los mayoristas (que cierran mucho), o sea que el desbalance refuerza la ventaja que WhatsApp ya tenía en lugar de darle la vuelta.
Para conseguir la paradoja hay que empujar en dirección contraria, que es lo que hicimos arriba 🎯
2. La regresión a la media, mes a mes
Repite el experimento con meses en vez de ventas: coge los mejores clientes de un mes y mira el siguiente.
mensual = (v.assign(mes=v['fecha'].dt.to_period('M'))
.groupby(['cliente_id', 'mes'])['monto'].sum().reset_index())
mensual = mensual.sort_values(['cliente_id', 'mes'])
mensual['siguiente'] = mensual.groupby('cliente_id')['monto'].shift(-1)
m = mensual.dropna(subset=['siguiente'])
top = m[m['monto'] >= m['monto'].quantile(0.9)]
print('media general: %.2f' % m['monto'].mean())
print('el 10%% mejor: este mes %.2f -> el siguiente %.2f'
% (top['monto'].mean(), top['siguiente'].mean()))
print('caida aparente: %.1f%%'
% (100 * (top['siguiente'].mean() / top['monto'].mean() - 1)))
media general: 916.54 el 10% mejor: este mes 2813.82 -> el siguiente 905.51 caida aparente: -67.8%
Una caída del 67,8% en tus mejores clientes 😱
Y no pasó absolutamente nada. Fíjate en que el mes siguiente, 905,51, está prácticamente en la media general de 916,54. O sea que siguen siendo buenos clientes, solo que ya no están en su mejor mes.
Este es exactamente el informe que hunde a un equipo comercial: se elige a los mejores del trimestre, se les mide el siguiente, y todos "empeoran". La selección misma garantiza el resultado 🪤
3. Cuánto infla el p-hacking, medido
Con datos donde no hay ningún efecto, prueba veinte subgrupos y quédate con el mejor. ¿Qué p obtienes?
generador = np.random.default_rng(7)
v['ruido'] = generador.normal(size=len(v))
mejores_p = []
for _ in range(200):
grupo = generador.integers(0, 2, size=len(v))
ps = []
for corte in range(20):
sub = v['ruido'][generador.integers(0, 2, size=len(v)).astype(bool)]
g2 = grupo[:len(sub)]
ps.append(stats.ttest_ind(sub[g2 == 1], sub[g2 == 0]).pvalue)
mejores_p.append(min(ps))
mejores_p = np.array(mejores_p)
print('probando 20 subgrupos sin ningun efecto real:')
print(' el mejor p es menor que 0.05 en el %.1f%% de los intentos'
% (100 * (mejores_p < 0.05).mean()))
print(' mediana del mejor p: %.4f' % np.median(mejores_p))
probando 20 subgrupos sin ningun efecto real: el mejor p es menor que 0.05 en el 64.0% de los intentos mediana del mejor p: 0.0331
El 64,0% de las veces encuentras algo "significativo" donde no hay nada 🎰
Y mira la mediana: 0,0331. O sea que el resultado típico de esta búsqueda ya pasa el corte de 0,05.
Con veinte subgrupos, encontrar un hallazgo es lo normal, no la excepción. Y quien lee tu informe no ve los diecinueve que no reportaste.
Por eso la pregunta más útil que se le puede hacer a cualquier análisis es "¿cuántas cosas probaste?". Si la respuesta es "no sé", el resultado no se puede evaluar 🔍
4. El groupby que se come las filas, en euros
Suma el monto agrupando por satisfacción y comprueba si cuadra con el total del archivo.
total = v['monto'].sum()
por_satisfaccion = v.groupby('satisfaccion')['monto'].sum().sum()
print('total del archivo: %.2f' % total)
print('sumando por grupos: %.2f' % por_satisfaccion)
print('se perdieron: %.2f soles (%.1f%%)'
% (total - por_satisfaccion, 100 * (total - por_satisfaccion) / total))
total del archivo: 2411283.83 sumando por grupos: 2213729.23 se perdieron: 197554.60 soles (8.2%)
197.554,60 soles evaporados, el 8,2% de la facturación 💸
Son las 231 ventas cuya satisfacción está vacía. Y no hay ningún aviso: la tabla por grupos se ve perfecta, suma bien dentro de sí misma, y simplemente no incluye ese dinero.
Si esa tabla va a un informe al lado del total del archivo, alguien va a preguntar por qué no cuadran y nadie va a saber contestar.
La costumbre: después de cualquier groupby, compara el total contra el original. Una línea, y te ahorra la tarde 🧾
5. El merge que multiplica filas
Fabrica una tabla de clientes con claves repetidas y comprueba qué le hace a tu archivo.
clientes = pd.DataFrame({
'cliente_id': list(v['cliente_id'].unique()[:5]) * 2,
'nivel': range(10),
})
afectadas = v['cliente_id'].isin(clientes['cliente_id']).sum()
unido = v.merge(clientes, on='cliente_id')
print('clientes en la tabla nueva: %d (pero solo %d distintos)'
% (len(clientes), clientes['cliente_id'].nunique()))
print('ventas de esos clientes: %d' % afectadas)
print('filas despues del merge: %d' % len(unido))
print()
print('media del monto antes: %.2f' % v.loc[v['cliente_id'].isin(clientes['cliente_id']), 'monto'].mean())
print('media del monto despues: %.2f' % unido['monto'].mean())
clientes en la tabla nueva: 10 (pero solo 5 distintos) ventas de esos clientes: 34 filas despues del merge: 68 media del monto antes: 627.80 media del monto despues: 627.80
34 ventas entraron y salieron 68 😬
Cada venta se duplicó porque cada cliente aparecía dos veces en la tabla de la derecha. El merge no se inventó nada: hizo exactamente lo que se le pidió.
Aquí la media no se mueve (627,80 antes y después), porque duplicar todo por igual no cambia un promedio. Pero cualquier suma se duplica, cualquier conteo se duplica, y cualquier prueba estadística cree que tiene el doble de evidencia de la que tiene.
La defensa es de una línea y no me la salto nunca:
print(len(df)) antes y después. Si el número sube, la tabla de la
derecha tiene claves repetidas 🔑
6. Tu lista de comprobación
Escribe una función que reciba un DataFrame y avise de las cosas de este capítulo.
def revisa(df, id_repetido=None):
avisos = []
if id_repetido and df[id_repetido].nunique() < len(df):
avisos.append('%d filas para %d %s distintos: las filas no son independientes'
% (len(df), df[id_repetido].nunique(), id_repetido))
nulos = df.isna().sum()
for col in nulos[nulos > 0].index:
avisos.append('%s tiene %d nulos: un groupby por esa columna perdera esas filas'
% (col, nulos[col]))
return avisos
for aviso in revisa(v, id_repetido='cliente_id'):
print('-', aviso)
- 3000 filas para 617 cliente_id distintos: las filas no son independientes - descuento tiene 595 nulos: un groupby por esa columna perdera esas filas - fecha_ultima_compra tiene 544 nulos: un groupby por esa columna perdera esas filas - satisfaccion tiene 231 nulos: un groupby por esa columna perdera esas filas
Los avisos que importan, en cuatro líneas de código 🙌
Los cuatro avisos son ciertos, y el tercero es uno que no habíamos mirado en
todo el libro: fecha_ultima_compra tiene 544 nulos, así que
cualquier análisis de recencia pierde el 18% de las ventas 👀
Fíjate en lo poco que hace esta función: contar filas distintas y contar nulos. Con eso sola ya cubre dos de los cinco errores del capítulo, que son los dos que más se cuelan porque no dejan rastro 🧾
Comprueba que lo tienes
Elegiste a los mejores clientes del trimestre y el siguiente bajaron un 62,6%. ¿Qué pasó?
- Nada: los extremos vuelven al centro solos, y hacía falta un grupo de control
- El programa que se les aplicó les hizo daño
- Se cansaron de comprar
- Hay un error en los datos
Lo que te llevas
- 🎭 Simpson: WhatsApp ganaba en los dos segmentos (45,45% contra 25,00% y 95,00% contra 63,68%) y perdía en el total. Lo produce la composición, no los canales.
- 📉 Regresión a la media: el 20% de ventas más altas promediaba 2.108,22 y su siguiente venta 794,42. La correlación entre una venta y la siguiente es -0,0099. Por meses, el 10% mejor "cae" un 67,8%.
- 🎰 P-hacking: probando 20 subgrupos sin ningún efecto real, el 64,0% de las veces encuentras algo significativo, y la mediana del mejor p es 0,0331.
- 👥 3.000 ventas de 617 clientes no son 3.000 casos independientes. Aquí contar por cliente mejoró el p, y en general lo empeora.
- 👻
groupbytira las filas con clave nula sin avisar: 231 ventas y 197.554,60 soles, el 8,2% de la facturación. - 🔑 Un merge con claves repetidas duplica filas. 34 entraron, 68 salieron.
Qué viene ahora
Se acabó la teoría 🎓 En el capítulo 15 hacemos el proyecto completo de principio a fin: una pregunta de negocio, los datos, el análisis, y el informe que se puede defender en una reunión.