Capítulo 8 de 16 9 secciones 16 min

Tres distribuciones y el teorema que lo explica todo

Binomial, Poisson y normal, con datos de verdad. Y por qué la campana aparece aunque tus datos no la tengan.

Con tres distribuciones cubres casi todo: la binomial para contar éxitos en un número fijo de intentos, la de Poisson para contar sucesos en un periodo, y la normal por el teorema central del límite. Ese teorema dice que los promedios se vuelven acampanados aunque los datos no lo sean: aquí el monto tiene asimetría 1,3405 y los promedios de 100 ventas la tienen de 0,0518.

Una distribución es una forma que se repite. Y lo bonito es que en la práctica casi todo se parece a una de tres 🎴

No te voy a hacer memorizar fórmulas. Vamos a ver cuál encaja con qué parte de estas ventas, y por qué.

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

La binomial: contar éxitos en intentos fijos

La pregunta que contesta: "de 20 visitas comerciales, ¿cuántas van a cerrar?". Hay un número fijo de intentos y cada uno sale bien o mal.

P(X=k)=(nk)pk(1p)nk

la probabilidad de k éxitos en n intentos, contando de cuántas formas pueden repartirse esos k entre los n

p = v['compro'].mean()
n = 20

print('probabilidad de cierre: %.4f' % p)
print()
for k in [8, 10, 12, 14, 16]:
    print('P(cerrar exactamente %2d de 20) = %.4f' % (k, stats.binom.pmf(k, n, p)))
probabilidad de cierre: 0.5777

P(cerrar exactamente  8 de 20) = 0.0503
P(cerrar exactamente 10 de 20) = 0.1380
P(cerrar exactamente 12 de 20) = 0.1761
P(cerrar exactamente 14 de 20) = 0.1013
P(cerrar exactamente 16 de 20) = 0.0237

Lo más probable es cerrar 12, y aun así solo pasa el 17,61% de las veces. Fíjate en eso, que es lo que la gente no espera: ni siquiera el resultado más probable es probable 🎲

Y esto ya sirve para algo práctico. Un comercial cierra 16 de 20 y todo el mundo aplaude. ¿Es mérito o es suerte?

print('media esperada:  %.2f cierres' % (n * p))
print('desviacion:      %.4f' % np.sqrt(n * p * (1 - p)))
print()
print('P(cerrar 16 o mas de 20) = %.6f' % (1 - stats.binom.cdf(15, n, p)))
media esperada:  11.55 cierres
desviacion:      2.2089

P(cerrar 16 o mas de 20) = 0.033334

Un 3,33%. O sea que cerrar 16 de 20 pasa una de cada treinta veces solo por azar, sin que el comercial haga nada especial 😬

Si tienes treinta comerciales, cada mes va a haber uno que lo consiga. Y si lo premias, estás premiando una moneda.

Ese cálculo, "qué probabilidad hay de ver algo así de bueno por pura suerte", es exactamente un valor p. Lo formalizamos en el capítulo 10, pero ya lo acabas de hacer 😎

Poisson: contar sucesos en un periodo

La pregunta cambia: ya no hay 20 intentos. "¿Cuántas ventas voy a tener mañana?". Puede ser cualquier número.

P(X=k)=λkeλk!

la probabilidad de ver k sucesos en un periodo donde en promedio se ven lambda

por_dia = v.groupby(v['fecha'].dt.date).size()

print('dias con actividad: %d' % len(por_dia))
print('media:    %.4f ventas por dia' % por_dia.mean())
print('varianza: %.4f' % por_dia.var())
dias con actividad: 537
media:    5.5866 ventas por dia
varianza: 5.4146

Media 5,5866 y varianza 5,4146. Prácticamente iguales 👀

Eso es la firma de una Poisson, y es su rareza más útil: es la única distribución común donde la media y la varianza son el mismo número. Si las mides y coinciden, tienes una Poisson delante casi seguro.

Vamos a comprobar si de verdad encaja, contando cuántos días hubo con cada cantidad de ventas:

lam = por_dia.mean()

print(' ventas | observados | esperados por Poisson')
for k in range(0, 12):
    print('   %2d   |    %4d    |   %7.2f'
          % (k, (por_dia == k).sum(), len(por_dia) * stats.poisson.pmf(k, lam)))
 ventas | observados | esperados por Poisson
    0   |       0    |      2.01
    1   |      14    |     11.24
    2   |      32    |     31.41
    3   |      52    |     58.48
    4   |      84    |     81.68
    5   |      93    |     91.26
    6   |      93    |     84.98
    7   |      55    |     67.82
    8   |      55    |     47.36
    9   |      31    |     29.40
   10   |      15    |     16.42
   11   |       5    |      8.34

Encaja bastante bien 🎯 Observados y esperados se siguen la pista en todo el rango.

Y hay una discrepancia que merece la pena mirar: la Poisson espera unos 2 días con cero ventas y en los datos hay cero días con cero ventas.

Eso no es un fallo de la Poisson, es cómo construí la cuenta: agrupé por las fechas que aparecen en el archivo, así que un día sin ninguna venta sencillamente no existe en esa lista. Es un sesgo de selección puesto por mí sin darme cuenta, y de esos hay muchos en el capítulo 14 🕳️

Tres histogramas: la población original muy sesgada, la distribución de las medias de muestras de 5 casos que ya es menos sesgada, y la de las medias de 50 casos, que ya tiene forma de campana.
El panel de la izquierda son tus datos, feos y con cola. El de la derecha son los promedios de muestras de esos mismos datos. Eso es el teorema central del límite, y es el permiso para usar toda la maquinaria de la campana sobre datos que no se le parecen en nada.

La normal y por qué está en todas partes

En el capítulo 5 vimos que el monto no es normal: asimetría 1,3405, cola larga por la derecha.

Y aun así la normal va a aparecer. Mira lo que pasa si en vez de mirar ventas sueltas miro promedios de ventas:

generador = np.random.default_rng(7)
montos = v['monto'].values

print('las ventas sueltas: asimetria %.4f' % v['monto'].skew())
print()
for n in [2, 5, 30, 100]:
    medias = pd.Series([generador.choice(montos, n).mean() for _ in range(2000)])
    print('promedios de %3d ventas -> asimetria %+.4f | desviacion %6.2f'
          % (n, medias.skew(), medias.std()))
las ventas sueltas: asimetria 1.3405

promedios de   2 ventas -> asimetria +1.0091 | desviacion 531.09
promedios de   5 ventas -> asimetria +0.6173 | desviacion 331.92
promedios de  30 ventas -> asimetria +0.3223 | desviacion 138.92
promedios de 100 ventas -> asimetria +0.0518 | desviacion  75.97

Mira la columna de la asimetría bajando: 1,3405, luego 1,0091, 0,6173, 0,3223 y 0,0518 😍

Eso es el teorema central del límite, y es probablemente el resultado más importante de toda la estadística. Dice que:

los promedios se vuelven acampanados aunque los datos de los que salen no lo sean.

Por eso la normal está en todas partes: casi nunca trabajamos con datos sueltos, trabajamos con promedios, proporciones y totales. Y todos esos se comportan como una campana aunque el material de origen sea un desastre.

Fíjate también en la otra columna, la de la desviación, que baja de 531 a 76. No baja de cualquier manera:

for n in [2, 5, 30, 100]:
    print('n=%3d -> formula: %.2f' % (n, v['monto'].std() / np.sqrt(n)))
n=  2 -> formula: 531.74
n=  5 -> formula: 336.30
n= 30 -> formula: 137.29
n=100 -> formula: 75.20

531,74 contra 531,09. 137,29 contra 138,92. Clavado 🎯

Esa fórmula, desviación dividida entre la raíz de n, se llama error estándar y es la pieza central de todo lo que viene. Es literalmente la respuesta a "cuánto se equivoca un promedio".

Y mira lo que dice esa raíz cuadrada: para que tu promedio sea el doble de preciso necesitas cuatro veces más datos. Para diez veces más preciso, cien veces más datos. Es la razón por la que las encuestas serias tienen 1.000 personas y no 100.000: la mejora deja de compensar 💸

¿Y el famoso "con 30 basta"?

Habrás oído que con 30 datos ya vale. Míralo otra vez en la tabla: con 30 ventas la asimetría todavía es 0,3223. No es cero.

El "30" es una regla de dedo que funciona cuando los datos de partida no están muy torcidos. Con una cola como la del monto hacen falta más. Con 100 ya estamos en 0,0518, que sí es despreciable.

La versión honesta de la regla: cuanto más torcidos los datos, más promedio hace falta. Y se comprueba en tres líneas, como acabamos de hacer 🙌

El error del capítulo

Quiero una muestra de 5.000 ventas distintas para hacer el experimento más grande:

generador.choice(montos, 5000, replace=False)
ValueError: Cannot take a larger sample than population when replace is False

Solo hay 3.000 ventas, no puedo sacar 5.000 sin repetir. Justo 👍

Lo interesante es que el experimento de arriba repetía (replace=True es lo que hace choice por defecto), y eso no es un descuido: es la técnica. Se llama remuestreo, y consiste en tratar tus datos como si fueran la población y sacar muestras con reposición.

Y ojo con el pariente silencioso de este error:

print('P(cerrar 25 de 20 intentos) =', stats.binom.pmf(25, 20, p))
print('media de una binomial con p=1.5:', stats.binom(20, 1.5).mean())
P(cerrar 25 de 20 intentos) = 0.0
media de una binomial con p=1.5: nan

Pedirle 25 éxitos de 20 intentos devuelve 0.0, que es correcto pero se parece mucho a "es muy improbable" cuando en realidad es "eso no puede pasar". Y una probabilidad de 1,5 devuelve nan sin quejarse 😑

Cuatro distribuciones continuas dibujadas una al lado de la otra: la normal simétrica, la t de Student con colas más gruesas, la exponencial que cae desde el origen y la beta acotada entre 0 y 1.
Las cuatro formas que vas a encontrar en datos de negocio. Fíjate en las colas de la t comparadas con la normal: esa diferencia, que parece menor, es la razón de que con muestras chicas se use la t y no la normal.

Cuál usar, en una tabla

Si cuentasDistribuciónSeñal para reconocerla
Éxitos en n intentos fijosBinomialHay un máximo posible
Sucesos en un periodoPoissonMedia y varianza parecidas
Promedios, totales, proporcionesNormalCasi siempre, por el TLC
Tiempo hasta que pase algoExponencialMuchos cortos, pocos larguísimos

Practica 💪

1. ¿Es raro cerrar 16 de 20 dos meses seguidos?

Ya sabemos que cerrar 16 de 20 pasa el 3,33% de las veces. Calcula la probabilidad de que le pase al mismo comercial dos meses seguidos, y la de que le pase a alguno de 30 comerciales en un mes.

una_vez = 1 - stats.binom.cdf(15, 20, p)

print('un comercial, un mes:       %.6f' % una_vez)
print('el mismo, dos meses:        %.6f' % (una_vez ** 2))
print('alguno de 30, en un mes:    %.6f' % (1 - (1 - una_vez) ** 30))
un comercial, un mes:       0.033334
el mismo, dos meses:        0.001111
alguno de 30, en un mes:    0.638347

Los tres números cuentan historias distintas 📖

Que el mismo lo repita dos meses seguidos tiene una probabilidad de 0,11%. Eso ya es difícil de explicar con suerte, y ahí sí empezaría a mirarle la técnica.

Pero mira el tercero: en un equipo de 30, la probabilidad de que alguien lo consiga es del 63,83%. O sea que es lo más normal del mundo.

Y esa es la trampa de las comparaciones múltiples, que tiene capítulo propio (el 14). Un resultado raro deja de ser raro en cuanto le das muchas oportunidades de ocurrir 🎰

2. ¿Encaja Poisson también por segmento?

Comprueba la firma de la Poisson (media parecida a varianza) en las ventas diarias de cada segmento.

for seg, g in v.groupby('segmento'):
    dia = g.groupby(g['fecha'].dt.date).size()
    print('%-11s media %.4f | varianza %.4f | razon %.3f'
          % (seg, dia.mean(), dia.var(), dia.var() / dia.mean()))
Bodega      media 1.8556 | varianza 1.0607 | razon 0.572
Horeca      media 1.7966 | varianza 0.8517 | razon 0.474
Mayorista   media 1.8337 | varianza 0.9282 | razon 0.506
Minimarket  media 1.9632 | varianza 1.2296 | razon 0.626

Aquí la razón está entre 0,47 y 0,63, no en 1. Ya no es Poisson 🤔

Una varianza menor que la media significa que los días se parecen entre ellos más de lo que el azar puro permitiría. A eso se le llama subdispersión.

Y la explicación es la misma trampa de antes: al agrupar por segmento, los días sin ninguna venta de ese segmento desaparecen de la cuenta. Estoy recortando justo los días flojos, así que los que quedan se parecen demasiado.

Buena lección: cuando una distribución deja de encajar, sospecha antes de tus datos que de la teoría 🔍

3. El TLC con la peor columna posible

Aplica el mismo experimento a algo mucho más torcido que el monto: la columna compro, que solo tiene ceros y unos. ¿Cuántos hacen falta?

ceros_y_unos = v['compro'].values

for n in [5, 30, 100, 500]:
    medias = pd.Series([generador.choice(ceros_y_unos, n).mean()
                        for _ in range(2000)])
    print('promedios de %3d -> asimetria %+.4f | valores distintos %d'
          % (n, medias.skew(), medias.nunique()))
promedios de   5 -> asimetria -0.1134 | valores distintos 6
promedios de  30 -> asimetria -0.0899 | valores distintos 18
promedios de 100 -> asimetria -0.0079 | valores distintos 34
promedios de 500 -> asimetria -0.0074 | valores distintos 70

Esto me sorprendió: converge más rápido que el monto, aunque los datos de partida son mucho más feos 😲

Con solo 5 la asimetría ya está en -0,11, que al monto le costó llegar con 30 y pico. Y con 100 estamos en -0,0079, diez veces más cerca de cero que el monto con las mismas 100.

El motivo es que lo que frena al TLC no es que los datos sean raros, sino que tengan cola. Una columna 0/1 no tiene cola: no hay ningún valor extremo que pueda arrastrar un promedio. El monto sí, y por eso tarda.

Mira también la última columna, la de valores distintos: con n=5 solo hay 6 promedios posibles (0, 0.2, 0.4...). La campana está ahí pero dibujada a brochazos 🖌️

4. El error estándar, y cuánto cuesta mejorarlo

Calcula cuántas ventas necesitas para que el error estándar del promedio baje a 50, a 25 y a 10 soles.

s = v['monto'].std()

for objetivo in [100, 50, 25, 10]:
    print('para un error estandar de %3d soles hacen falta %8.0f ventas'
          % (objetivo, (s / objetivo) ** 2))
para un error estandar de 100 soles hacen falta       57 ventas
para un error estandar de  50 soles hacen falta      226 ventas
para un error estandar de  25 soles hacen falta      905 ventas
para un error estandar de  10 soles hacen falta     5655 ventas

De 100 a 50 soles de precisión: pasas de 57 a 226 ventas. De 50 a 25: de 226 a 905. Cada vez que quieres el doble de precisión, cuadruplicas la muestra 💸

Ese es el cálculo que hay detrás de "¿cuántos clientes encuesto?", y es de lo más útil que te llevas del libro. Lo normal es que alguien pida una precisión absurda sin saber que cuesta cinco mil respuestas.

Fíjate en que la fórmula solo necesita dos cosas: la desviación que esperas y la precisión que quieres. Puedes hacerlo antes de recoger un solo dato 📋

5. Simula un mes de ventas

Con la Poisson de las ventas diarias y la distribución de montos, simula 30 días y mira cuánto varía el total mensual.

lam = por_dia.mean()
totales = []

for _ in range(2000):
    ventas_del_mes = generador.poisson(lam, 30).sum()
    totales.append(generador.choice(montos, ventas_del_mes).sum())

totales = pd.Series(totales)
print('total mensual simulado: media %.0f | desviacion %.0f' % (totales.mean(), totales.std()))
print('el 90%% de los meses cae entre %.0f y %.0f'
      % (totales.quantile(0.05), totales.quantile(0.95)))
total mensual simulado: media 134673 | desviacion 14433
el 90% de los meses cae entre 111598 y 158882

Ahora compáralo con lo que medimos en el capítulo 1: los meses reales iban de 103.474 a 164.313 soles 🤯

La simulación dice que el 90% de los meses debería caer entre 111.598 y 158.882, con una media de 134.673.

La simulación, que no sabe nada de meses y solo conoce la tasa diaria y la forma de los montos, cae casi encima del rango real. Se queda un poco corta en las dos puntas, y tiene sentido: los meses de verdad tienen distinto número de días laborables y campañas, cosas que la simulación no sabe.

Y eso cierra el círculo del capítulo 1. Aquella variación mensual del 14,41% que parecía tan dramática es exactamente lo que produce el azar con esta tasa de ventas y estos montos. No hacía falta ninguna explicación de negocio, y ahora tenemos la prueba en vez de la sospecha 🎯

6. ¿Cuántas ventas negativas esperabas?

Sabemos que hay 21 ventas negativas en 3.000. Si fueran cosa del azar con esa tasa, ¿cuánto variarían de un archivo a otro?

tasa = (v['monto'] < 0).mean()
esperadas = tasa * len(v)

print('tasa: %.5f -> esperadas %.1f' % (tasa, esperadas))
print('desviacion de una Poisson: %.2f' % np.sqrt(esperadas))
print()
print('rango tipico: de %.0f a %.0f'
      % (esperadas - 2 * np.sqrt(esperadas), esperadas + 2 * np.sqrt(esperadas)))
print('P(ver 40 o mas) = %.6f' % (1 - stats.poisson.cdf(39, esperadas)))
tasa: 0.00700 -> esperadas 21.0
desviacion de una Poisson: 4.58

rango tipico: de 12 a 30
P(ver 40 o mas) = 0.000144

La Poisson tiene una propiedad muy cómoda: su desviación es la raíz de su media. Con 21 esperadas, la desviación es 4,58.

O sea que si el mes que viene aparecen 28 ventas negativas, tranqui: entra dentro de lo normal. Si aparecen 40, ahí sí ha pasado algo, porque eso tiene una probabilidad de 0,014% 🚨

Este cálculo es la forma más barata que conozco de montar una alerta. No hace falta ningún modelo: cuentas lo que suele pasar, sacas la raíz, y avisas cuando te separas más de dos o tres de esas 📟

Comprueba que lo tienes

Las ventas diarias tienen media 5,5866 y varianza 5,4146. ¿Qué te dice eso?

  • Que se comportan como una Poisson, que es la única común donde media y varianza coinciden
  • Que la varianza está mal calculada
  • Que los datos son normales
  • Que hay poca variación día a día

Lo que te llevas

  • 🎯 Binomial para éxitos en intentos fijos. Cerrar 16 de 20 pasa el 3,33% de las veces por puro azar, y en un equipo de 30 le pasa a alguien el 63,83% de los meses.
  • 📅 Poisson para sucesos en un periodo. Su firma es media parecida a varianza: aquí 5,5866 y 5,4146.
  • 🔔 El teorema central del límite: los promedios se acampanan aunque los datos no. La asimetría bajó de 1,3405 a 0,0518 promediando de a 100.
  • 📐 Error estándar = desviación entre raíz de n. Para el doble de precisión, cuatro veces más datos.
  • ⚠️ El "con 30 basta" es una regla de dedo. Con esta cola, a los 30 la asimetría todavía era 0,3223.
  • 🎰 Simulando solo con la tasa diaria y la forma de los montos salió un rango mensual de 111.598 a 158.882, y los meses reales fueron de 103.474 a 164.313.

Qué viene ahora

Ya tenemos el error estándar, que es la pieza que faltaba. En el capítulo 9 la usamos para lo que todo el mundo quiere: poner un margen de error a un número y decir "está entre esto y esto" 📊

¿Tienes alguna duda o consulta?