Capítulo 5 de 16 9 secciones 17 min

La campana, y cuándo no aplica

La distribución normal, cómo se comprueba de verdad, y por qué el test de normalidad dice que no en cuanto tienes datos.

La distribución normal es la campana simétrica donde el 68% de los datos cae a una desviación del promedio. Se comprueba con el histograma, la asimetría y la curtosis, no solo con una prueba: las pruebas de normalidad rechazan casi cualquier cosa en cuanto tienes muchos datos. En este archivo, unidades pasa la prueba con 50 filas y la suspende con 1.000, siendo exactamente los mismos datos.

La campana de Gauss es la figura más famosa de la estadística, y también la más aplicada donde no toca 🔔

f(x)=1σ2πe12(xμσ)2

la altura de la campana en cada punto, que solo depende de a cuántas desviaciones del centro estás

Importa por una razón muy concreta: media docena de herramientas del libro la dan por supuesta. La regla del 68%, los intervalos de confianza clásicos, la prueba t, la detección de atípicos por desviaciones. Si tus datos no son campana, esas herramientas no se rompen con un error rojo: te devuelven un número equivocado con toda la calma 😶

Así que vamos a aprender a comprobarlo. Y de paso vamos a ver que la forma "oficial" de comprobarlo, la prueba de normalidad, es bastante inútil.

Dos distribuciones lado a lado: una campana simétrica y una con cola larga a la derecha. En la simétrica media y mediana coinciden; en la sesgada la media queda a la derecha de la mediana.
Este es el atajo dibujado. Si media y mediana se parecen, estás en el caso de la izquierda y da igual cuál reportes. Si se separan, estás en el de la derecha y la media ya no describe a nadie.

Primero, mirarlo

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)


def histograma(serie, tramos=10):
    """Un histograma de texto, que se ve igual de bien y no necesita gráficos."""
    cuenta, bordes = np.histogram(serie.dropna(), bins=tramos)
    for i in range(tramos):
        print('%8.0f a %8.0f | %4d %s'
              % (bordes[i], bordes[i + 1], cuenta[i], '#' * int(cuenta[i] / 20)))


histograma(v['monto'])
   -2498 a    -1824 |    2
   -1824 a    -1151 |    0
   -1151 a     -477 |    3
    -477 a      196 |  474 #######################
     196 a      869 | 1571 ##############################################################################
     869 a     1543 |  440 ######################
    1543 a     2216 |  288 ##############
    2216 a     2890 |  173 ########
    2890 a     3563 |   43 ##
    3563 a     4237 |    6

Con eso ya sabes que monto no es una campana, y no hizo falta ninguna prueba 👀

Una campana sube y baja simétrica. Esto sube de golpe, tiene su pico entre 196 y 869, y luego baja con una cola larga por la derecha que llega hasta 4.237. Y por la izquierda hay cinco filas sueltas en territorio negativo.

Los dos números que resumen la forma

for col in ['monto', 'unidades', 'satisfaccion', 'descuento']:
    s = v[col].dropna()
    print('%-13s asimetria %7.4f | curtosis %7.4f' % (col, s.skew(), s.kurtosis()))
monto         asimetria  1.3405 | curtosis  1.5458
unidades      asimetria  0.1843 | curtosis -0.3351
satisfaccion  asimetria -0.0149 | curtosis -1.3089
descuento     asimetria -0.0157 | curtosis -1.1604

La asimetría dice hacia dónde cae la cola. Cero es simétrico, positivo es cola a la derecha, negativo a la izquierda. Como regla de campo, entre -0,5 y 0,5 es bastante simétrico y por encima de 1 la cola ya es evidente.

La curtosis (la que devuelve pandas es la de exceso) dice si la campana es puntiaguda o achatada. Cero es como la normal, positivo es más picuda con colas más pesadas, negativo es más plana.

Ahora léelo como si fueran cuatro personas 🙂

  • 💰 monto: asimetría 1,3405. Cola a la derecha, confirmado.
  • 📦 unidades: 0,1843 y -0,3351. Esto sí parece una campana.
  • satisfaccion: simétrica pero con curtosis -1,3089, o sea muy plana. Eso no es campana, es una meseta: los cinco valores salen casi igual de veces.
  • 🏷️ descuento: lo mismo, plana.

Ahora la prueba oficial, y la decepción

La prueba de Shapiro-Wilk contesta "¿podrían estos datos venir de una normal?". Si el valor p es chico, la respuesta es no.

Se la aplicamos a unidades, la que mejor pinta tenía:

print(stats.shapiro(v['unidades']))
ShapiroResult(statistic=np.float64(0.9869264852139725), pvalue=np.float64(5.180807008091816e-16))

p = 0,00000000000000052 😬

O sea que la prueba dice, con toda la contundencia del mundo, que unidades no es normal. La misma columna que tenía asimetría 0,18 y que en el capítulo 4 cumplía la regla del 68% clavada (68,70%).

¿Quién miente? Ninguno. Mira lo que pasa cuando le doy los mismos datos en trozos de distinto tamaño:

for n in [20, 50, 100, 300, 1000, 3000]:
    muestra = v['unidades'].sample(n, random_state=7)
    print('con %5d filas -> p = %.6f' % (n, stats.shapiro(muestra).pvalue))
con    20 filas -> p = 0.442212
con    50 filas -> p = 0.235741
con   100 filas -> p = 0.045462
con   300 filas -> p = 0.002306
con  1000 filas -> p = 0.000000
con  3000 filas -> p = 0.000000

Ahí está el truco entero 🎩

Los mismos datos. Con 50 filas la prueba dice "normal, adelante". Con 1.000 dice "de ninguna manera". La columna no cambió: cambió cuánta evidencia tienes.

Y no es que Shapiro sea mala. Mira qué pasa con datos que vienen de una normal de verdad:

generador = np.random.default_rng(7)

for n in [100, 1000, 3000]:
    print('normal de verdad con %5d datos -> p = %.4f'
          % (n, stats.shapiro(generador.normal(size=n)).pvalue))
normal de verdad con   100 datos -> p = 0.8599
normal de verdad con  1000 datos -> p = 0.4335
normal de verdad con  3000 datos -> p = 0.3883

Con datos normales de verdad, el p se queda alto por muchos datos que le des ✅

O sea que la prueba funciona perfectamente. Lo que pasa es que responde a una pregunta que no es la tuya:

Lo que crees que preguntasLo que la prueba contesta
¿Mis datos son normales? ¿Tengo evidencia suficiente para afirmar que no lo son exactamente?

Ningún dato real es exactamente normal. Con suficientes filas siempre se nota. Por eso, en la práctica: mira el histograma y la asimetría, y usa la prueba como acompañamiento, nunca como sentencia 🧭

Guárdate esta idea, porque es la misma que va a salir en el capítulo 12 con todas las pruebas: significativo no quiere decir importante. Con muchos datos, todo sale significativo.

La campana de Gauss con la media en el centro y la forma simétrica a ambos lados: la mayoría de los casos se acumula cerca del promedio y la frecuencia cae hacia los extremos.
La forma sale sola cuando muchas causas pequeñas e independientes se suman, y por eso aparece en tantos sitios. Ojo con la palabra independientes: es justo lo que no ocurre en la mayoría de los datos de una empresa.

Lo que sí decide: comparar cuantiles

Mi comprobación favorita, porque contesta la pregunta práctica de verdad: "¿me equivoco mucho si trato esto como normal?"

u = v['unidades']
m, s = u.mean(), u.std()

for q in [0.05, 0.25, 0.50, 0.75, 0.95]:
    print('cuantil %.2f -> observado %6.2f | si fuera normal %6.2f'
          % (q, u.quantile(q), stats.norm.ppf(q, m, s)))
cuantil 0.05 -> observado   2.00 | si fuera normal   2.10
cuantil 0.25 -> observado   7.00 | si fuera normal   7.71
cuantil 0.50 -> observado  12.00 | si fuera normal  11.60
cuantil 0.75 -> observado  15.00 | si fuera normal  15.50
cuantil 0.95 -> observado  21.00 | si fuera normal  21.10

Mira qué cerca 😍 En los extremos, que es donde más duele equivocarse, la diferencia es de una décima de unidad.

Entonces, ¿es unidades normal? Formalmente no, y Shapiro tiene razón. ¿Puedo tratarla como normal para decidir cosas de negocio? Perfectamente.

Esa es la respuesta madura, y es la que quiero que te lleves: la normalidad no es un sí o un no, es cuánto me cuesta suponerla 💡

Cuando no hay campana: transformar

Volvamos a monto, que sí estaba torcido de verdad. Hay un truco clásico para colas por la derecha: aplicar el logaritmo.

positivos = v.loc[v['monto'] > 0, 'monto']

print('filas positivas: %d de %d' % (len(positivos), len(v)))
print('asimetria antes:   %.4f' % positivos.skew())
print('asimetria despues: %.4f' % np.log(positivos).skew())
filas positivas: 2979 de 3000
asimetria antes:   1.4278
asimetria despues: -0.1585

De 1,4278 a -0,1585. El logaritmo aplasta la cola y deja algo casi simétrico 🪄

Funciona porque el logaritmo comprime los números grandes mucho más que los chicos: la distancia entre 100 y 1.000 pasa a ser la misma que entre 1.000 y 10.000.

Fíjate en el precio de la operación: hay que tirar 21 filas, las que tienen monto negativo o cero, porque el logaritmo de un número negativo no existe. Nunca transformes sin contar cuántas filas te dejas 🧾

Y hay un regalo escondido en esto:

print('mediana de las positivas:  %.2f' % positivos.median())
print('exp(media del logaritmo):  %.2f' % np.exp(np.log(positivos).mean()))
mediana de las positivas:  536.65
exp(media del logaritmo):  538.39

Casi el mismo número. Lo que hiciste al promediar en escala logarítmica y volver fue calcular la media geométrica, la del capítulo 3. Y en una distribución con cola por la derecha, la media geométrica cae encima de la mediana 🎯

Lo que cuesta transformar es la interpretación. Después de la transformación tus resultados están en "log de soles", que no significa nada para nadie. Hay que deshacerla para contar el resultado, y ahí es donde se cometen errores. Yo transformo solo cuando la herramienta lo necesita de verdad.

El error del capítulo

Vamos a intentar partir la satisfacción en diez tramos iguales, que es lo que harías para ver su forma con más detalle:

pd.qcut(v['satisfaccion'], 10)
ValueError: Bin edges must be unique: Index([1.0, 1.0, 1.0, 2.0, 3.0, 3.0, 4.0, 4.0, 5.0, 5.0, 5.0], dtype='float64', name='satisfaccion').
You can drop duplicate edges by setting the 'duplicates' kwarg

Este error es de los buenos, porque te está enseñando algo del dato y no del código 🎓

qcut parte en tramos con la misma cantidad de filas cada uno. Para hacer diez tramos necesita diez cortes distintos, y satisfaccion solo tiene cinco valores posibles. Al buscar el borde del decil 10 y del decil 20 encuentra el mismo 1.0 en los dos, y se planta.

La tentación es hacerle caso al mensaje y poner duplicates='drop'. No lo hagas sin pensar: eso taparía el problema y te dejaría tramos de tamaños muy distintos disfrazados de deciles.

Lo correcto es aceptar lo que el error te está diciendo: esa columna no tiene diez tramos, tiene cinco categorías. Se cuenta con value_counts y se acabó.

Practica 💪

1. ¿De qué forma es la satisfacción?

Píntala con el histograma de texto y di qué forma tiene.

print(v['satisfaccion'].value_counts().sort_index())
satisfaccion
1.0    560
2.0    537
3.0    551
4.0    558
5.0    563
Name: count, dtype: int64

560, 537, 551, 558, 563. Es plana: los cinco valores salen prácticamente las mismas veces 📏

A eso se le llama distribución uniforme, y es lo contrario de una campana. En una campana el centro tiene muchos más casos que los extremos; aquí un 1 es tan frecuente como un 3.

Esto tiene una lectura de negocio incómoda: si tus clientes puntúan absolutamente al azar entre 1 y 5, la encuesta no está midiendo satisfacción, está midiendo ruido. Una encuesta real casi siempre sale torcida hacia arriba, porque la gente contenta responde más.

Y explica el -1,3089 de curtosis del principio. Vamos a comprobar que ese número es justo el de una uniforme:

generador = np.random.default_rng(7)
uniforme = pd.Series(generador.integers(1, 6, size=3000))

print('satisfaccion -> curtosis %.4f | asimetria %.4f'
      % (v['satisfaccion'].kurtosis(), v['satisfaccion'].skew()))
print('uniforme 1-5 -> curtosis %.4f | asimetria %.4f'
      % (uniforme.kurtosis(), uniforme.skew()))
satisfaccion -> curtosis -1.3089 | asimetria -0.0149
uniforme 1-5 -> curtosis -1.2944 | asimetria -0.0219

-1,3089 contra -1,2944. Clavado 🎯

O sea que la curtosis no solo te dice "no es normal": te dice qué es. Un -1,3 en una variable de cinco valores es la firma de una uniforme.

2. La normalidad dentro de un segmento

En el capítulo 3 vimos que dentro de cada segmento la media y la mediana coincidían. ¿Quiere eso decir que cada segmento es normal?

for seg in ['Bodega', 'Mayorista']:
    s = v.loc[v['segmento'] == seg, 'monto']
    print('%-10s asimetria %7.4f | curtosis %7.4f | minimo %8.2f'
          % (seg, s.skew(), s.kurtosis(), s.min()))
Bodega     asimetria -0.7752 | curtosis  3.4590 | minimo  -224.61
Mayorista  asimetria -0.4560 | curtosis  3.0510 | minimo -2497.72

Pues no, y la respuesta es más interesante de lo que esperaba 🤔

Los dos segmentos tienen asimetría negativa, o sea cola a la izquierda, justo al revés que el total. Y curtosis alrededor de 3, que es mucha: colas pesadas.

La explicación está en la tercera columna: los mínimos son negativos. Esas ventas negativas, que son poquísimas, tiran de la cola izquierda dentro de cada segmento.

Y en el total esa cola izquierda desaparecía porque la aplastaba la cola derecha enorme que creaba la mezcla de segmentos.

Conclusión práctica: simétrico no es lo mismo que normal. Que la media y la mediana coincidan es necesario pero no suficiente. Hay que mirar también las colas 👀

3. Cuánto te cuesta suponer normalidad en el monto

Haz con monto la comparación de cuantiles que hicimos con unidades. ¿Dónde se rompe más?

m, s = v['monto'].mean(), v['monto'].std()

for q in [0.05, 0.25, 0.50, 0.75, 0.95, 0.99]:
    obs = v['monto'].quantile(q)
    teo = stats.norm.ppf(q, m, s)
    print('cuantil %.2f -> observado %8.2f | normal %8.2f | error %+8.2f'
          % (q, obs, teo, obs - teo))
cuantil 0.05 -> observado   119.03 | normal  -433.15 | error  +552.18
cuantil 0.25 -> observado   252.69 | normal   296.55 | error   -43.86
cuantil 0.50 -> observado   533.63 | normal   803.76 | error  -270.13
cuantil 0.75 -> observado  1068.68 | normal  1310.97 | error  -242.29
cuantil 0.95 -> observado  2431.76 | normal  2040.67 | error  +391.08
cuantil 0.99 -> observado  3058.59 | normal  2553.15 | error  +505.44

Aquí sí duele 💥

Suponer normalidad dice que el 5% de las ventas debería estar por debajo de -433,15 soles. En la realidad el percentil 5 es +119,03, o sea que la normal se equivoca en 552 soles justo ahí. Y por arriba hace lo contrario: el percentil 99 real es 3.058,59 y la normal lo pone en 2.553,15, o sea que se queda 505 soles corta.

Fíjate en el patrón, que es la firma de una cola por la derecha: la normal se inventa valores muy negativos que no existen y se queda corta en los muy grandes que sí existen.

El motivo es de fondo: una normal va de menos infinito a más infinito, y una venta no puede ser muy negativa. Siempre que tu variable tenga un tope natural (no puede bajar de cero, no puede pasar de 100), la normal va a mentir en esa punta.

Compáralo con unidades, donde el error era de una décima. Misma suposición, dos costes completamente distintos ⚖️

4. Normaliza tú un dato: la puntuación z

Convierte el monto a "cuántas desviaciones estoy del promedio" y comprueba qué le pasa a la media y a la desviación.

z = (v['monto'] - v['monto'].mean()) / v['monto'].std()

print('media de z:      %.6f' % z.mean())
print('desviacion de z: %.6f' % z.std())
print('asimetria de z:  %.4f' % z.skew())
print()
print('la venta mas grande esta a %.2f desviaciones' % z.max())
print('la mas chica, a %.2f' % z.min())
media de z:      -0.000000
desviacion de z: 1.000000
asimetria de z:  1.3405

la venta mas grande esta a 4.57 desviaciones
la mas chica, a -4.39

La media queda en 0 y la desviación en 1, que es lo que hace la puntuación z: poner cualquier variable en la misma escala 📐

Pero mira la tercera línea, que es la importante: la asimetría sigue siendo 1,3405, exactamente la de antes.

Estandarizar no normaliza. Mueve y estira, pero no cambia la forma. Es un malentendido de los grandes, y lo alimenta el nombre de StandardScaler en scikit-learn, que muchísima gente cree que convierte los datos en normales.

La z sirve para comparar variables de escalas distintas y para los modelos que lo necesitan. No sirve para arreglar una cola 🚫

5. El test que se rinde con muchos datos

Reproduce el fenómeno del capítulo con datos fabricados: genera una normal con un defecto minúsculo y mira desde qué tamaño lo detecta Shapiro.

generador = np.random.default_rng(11)

for n in [100, 500, 2000, 5000]:
    casi = np.concatenate([
        generador.normal(size=int(n * 0.99)),
        generador.normal(loc=6, size=int(n * 0.01)),
    ])
    print('n=%6d con 1%% de intrusos -> p = %.6f'
          % (n, stats.shapiro(casi).pvalue))
n=   100 con 1% de intrusos -> p = 0.000005
n=   500 con 1% de intrusos -> p = 0.000000
n=  2000 con 1% de intrusos -> p = 0.000000
n=  5000 con 1% de intrusos -> p = 0.000000

Con solo un 1% de datos "intrusos" el test ya se cae con 100 filas 😮

Y aquí está lo que hay que sacar de todo esto. La prueba de normalidad es extremadamente sensible: detecta desviaciones que a ti te dan exactamente igual para tomar una decisión.

Un p chiquito no significa "esto está mal, no lo uses". Significa "hay algo que no es exactamente normal". Que eso importe o no lo decides tú, mirando la comparación de cuantiles del ejercicio 3 🧑‍⚖️

6. Tu chequeo de forma, en una función

Junta todo en algo que puedas correr sobre cualquier columna y te diga qué tienes delante.

def que_forma_tiene(serie, nombre):
    s = serie.dropna()
    a, k = s.skew(), s.kurtosis()

    if abs(a) > 1:
        forma = 'cola larga hacia la ' + ('derecha' if a > 0 else 'izquierda')
    elif k < -1:
        forma = 'plana, parece uniforme'
    elif abs(a) < 0.5 and abs(k) < 1:
        forma = 'razonablemente acampanada'
    else:
        forma = 'algo torcida, mirala'

    return ('%-13s asimetria %+7.4f | curtosis %+7.4f -> %s'
            % (nombre, a, k, forma))


for col in ['monto', 'unidades', 'satisfaccion', 'descuento']:
    print(que_forma_tiene(v[col], col))
monto         asimetria +1.3405 | curtosis +1.5458 -> cola larga hacia la derecha
unidades      asimetria +0.1843 | curtosis -0.3351 -> razonablemente acampanada
satisfaccion  asimetria -0.0149 | curtosis -1.3089 -> plana, parece uniforme
descuento     asimetria -0.0157 | curtosis -1.1604 -> plana, parece uniforme

Cuatro columnas etiquetadas en una pasada 🙌

Los umbrales (1 para la asimetría, 1 para la curtosis, 0,5) son convenciones de campo, no leyes. Lo importante es que estén escritos y que los uses igual siempre, en vez de decidir a ojo según el día.

Y fíjate en lo que no hace esta función: no llama a ninguna prueba de normalidad. Después de lo de arriba, ya sabes por qué 😌

Comprueba que lo tienes

Shapiro suspende unidades con p = 5,2e-16 y la aprueba con p = 0,4422 si le das solo 20 filas. ¿Qué concluyes?

  • Que la prueba mide cuánta evidencia tengo, no si los datos son normales
  • Que con 20 filas la columna sí es normal y con 3.000 deja de serlo
  • Que la prueba de Shapiro está mal implementada
  • Que hay que usar siempre muestras de 20 para estas pruebas

Lo que te llevas

  • 👀 El histograma decide más que cualquier prueba. Diez líneas de texto y ya sabías que monto no era una campana.
  • 📐 Asimetría dice hacia dónde cae la cola; curtosis, si es picuda o plana. Una curtosis de -1,3 en cinco valores es la firma de una uniforme.
  • 🎭 Las pruebas de normalidad no contestan lo que crees. Los mismos datos dan p = 0,44 con 50 filas y p = 0,000000 con 1.000.
  • 🧭 La pregunta útil no es "¿es normal?" sino "¿cuánto me cuesta suponer que lo es?". En unidades el error de cuantiles era de una décima; en monto, la normal se inventaba ventas de -433 soles.
  • 🪄 El logaritmo endereza colas por la derecha: la asimetría del monto pasó de 1,4278 a -0,1585. Pero cuesta 21 filas y complica la interpretación.
  • 📏 Estandarizar no normaliza. La z deja media 0 y desviación 1, y la asimetría intacta.

Qué viene ahora

Han salido tres veces ya: las ventas negativas y las de cuatro mil soles. En el capítulo 6 vamos a por ellas, con las tres formas de detectarlas y la pregunta que casi nadie hace, que es si hay que quitarlas o no 🎯

¿Tienes alguna duda o consulta?