Capítulo 14 de 16 9 secciones 16 min

Los errores que invalidan un análisis entero

Simpson fabricado con ventas de verdad, regresión a la media, p-hacking y las filas que no son independientes.

Cinco errores que no dan error y tiran un análisis entero: la paradoja de Simpson (una tendencia global que se invierte dentro de cada grupo), la regresión a la media (los extremos vuelven al centro solos), el p-hacking (buscar hasta encontrar), las filas que no son independientes, y el sesgo de selección. Ninguno se arregla con más estadística: se arreglan pensando antes de calcular.

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.
  • 👻 groupby tira 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.

¿Tienes alguna duda o consulta?