Capítulo 4 de 16 8 secciones 15 min

La dispersión: dos negocios con el mismo promedio

Desviación estándar, rango intercuartílico y coeficiente de variación. Y el ddof que cambia el número sin que lo pidas.

La dispersión mide cuánto se separan los datos de su centro, y sin ella un promedio no significa nada: dos vendedores con el mismo promedio de 500 soles pueden tener desviaciones de 6,55 y de 405,32. La desviación estándar se usa cuando los datos son simétricos; el rango intercuartílico cuando hay extremos; y el coeficiente de variación cuando comparas cosas de escalas distintas.

Dos vendedores. Los dos venden 500 soles de promedio 💰

Uno vende 500 todos los días como un reloj. El otro vende 50 un día y 950 al siguiente. El promedio no los distingue, y sin embargo son dos personas completamente distintas: al segundo no puedes prometerle a nadie que mañana factura 500.

Eso es la dispersión, y por eso un promedio sin su dispersión al lado es medio dato.

import pandas as pd
import numpy as np

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)

reloj = pd.Series([500, 510, 495, 505, 490, 500, 505, 495])
montania = pd.Series([100, 900, 50, 950, 200, 800, 150, 850])

print('el reloj:    media %.1f | desviacion %.2f' % (reloj.mean(), reloj.std()))
print('la montaña:  media %.1f | desviacion %.2f' % (montania.mean(), montania.std()))
el reloj:    media 500.0 | desviacion 6.55
la montaña:  media 500.0 | desviacion 405.32

Mismo promedio, desviaciones de 6,55 y 405,32. Ahí sí se ven dos negocios distintos 👀

Las cuatro medidas, de la peor a la mejor

El rango es lo primero que se le ocurre a cualquiera: el máximo menos el mínimo.

print('rango: %.2f  (de %.2f a %.2f)'
      % (v['monto'].max() - v['monto'].min(), v['monto'].min(), v['monto'].max()))
rango: 6734.43  (de -2497.72 a 4236.71)

Y ya se ve el problema: ese número lo deciden dos filas de tres mil. Las otras 2.998 no opinan. Además una de las dos es una venta negativa, que ya sabemos que es rara y que miramos en el capítulo 6.

La varianza es la media de las distancias al cuadrado. Se elevan al cuadrado para que las que están por debajo no cancelen a las que están por encima:

s2=1n1i=1n(xix¯)2

promedias las distancias al cuadrado de cada dato al centro, dividiendo entre n menos uno

print('varianza:  %.2f' % v['monto'].var())
print('desviacion: %.2f' % v['monto'].std())
print('la desviacion es la raiz de la varianza:',
      round(np.sqrt(v['monto'].var()), 2) == round(v['monto'].std(), 2))
varianza:  565487.47
desviacion: 751.99
la desviacion es la raiz de la varianza: True

Y aquí está por qué la varianza casi nunca se reporta: sus unidades son soles al cuadrado. 565.487 soles cuadrados no significa nada para nadie 🤷 La desviación estándar deshace el cuadrado con la raíz y vuelve a soles, que sí se entienden: "las ventas se separan del promedio unos 752 soles".

El ddof, o por qué numpy y pandas no coinciden

Esto pilla a todo el mundo una vez, y luego ya no se olvida:

print('pandas: %.6f' % v['monto'].std())
print('numpy:  %.6f' % np.std(v['monto']))
print('diferencia: %.6f' % (v['monto'].std() - np.std(v['monto'])))
pandas: 751.989011
numpy:  751.863669
diferencia: 0.125342

Los dos calculan "la desviación estándar" de la misma columna y dan números distintos 😑

La razón es el ddof, que decide si divides entre n o entre n-1. pandas divide entre n-1 por defecto y numpy entre n.

Y no es un capricho. Se divide entre n-1 cuando tus datos son una muestra y quieres estimar la dispersión de la población entera. Una muestra siempre parece un poquito menos dispersa que la población de la que salió, así que se corrige haciendo el divisor más chico. Se divide entre n cuando tus datos son toda la población y no estimas nada.

Con 3.000 filas la diferencia es de 12 céntimos y da igual. Pero mira lo que pasa con pocos datos:

tres = pd.Series([9, 10, 11])
print('con 3 datos -> pandas %.4f | numpy %.4f' % (tres.std(), np.std(tres)))
print('diferencia relativa: %.1f%%'
      % (100 * (tres.std() - np.std(tres)) / np.std(tres)))
con 3 datos -> pandas 1.0000 | numpy 0.8165
diferencia relativa: 22.5%

22,5% de diferencia por un parámetro que no pusiste 😳

Si trabajas con grupos chicos, encuestas de 10 personas o resultados de 5 sucursales, esto sí te cambia el número que llevas a la reunión. La regla práctica: en análisis de datos casi siempre tienes una muestra, así que casi siempre quieres n-1, o sea el de pandas. Si usas numpy, pídelo a mano con np.std(x, ddof=1).

El rango intercuartílico, que es el hermano robusto

Igual que la mediana era la versión robusta de la media, el IQR es la versión robusta de la desviación. Se queda con el 50% central y descarta las dos puntas:

q1 = v['monto'].quantile(0.25)
q3 = v['monto'].quantile(0.75)

print('Q1 (25%%): %.2f' % q1)
print('Q3 (75%%): %.2f' % q3)
print('IQR:      %.2f' % (q3 - q1))
Q1 (25%): 252.69
Q3 (75%): 1068.68
IQR:      815.99

Se lee así: el 50% de las ventas está entre 252,69 y 1.068,68 soles. Y eso, para contárselo a alguien que no sabe estadística, es muchísimo más claro que "la desviación estándar es 752" 🗣️

Ojo con la escala de los cuantiles, que se equivoca todo el mundo:

v['monto'].quantile(1.5)
ValueError: percentiles should all be in the interval [0, 1]

En pandas los cuantiles van de 0 a 1, no de 0 a 100. El 75% es 0.75. En numpy, en cambio, np.percentile va de 0 a 100. Es de las incoherencias más molestas que hay entre las dos librerías 🙃

El coeficiente de variación, y lo que encontró

Última medida, y es la que más uso cuando comparo cosas distintas.

Problema: ¿varían más las ventas de Bodega o las de Mayorista? La desviación no te sirve para contestar, porque una vende a 178 soles y la otra a 1.856. La grande va a tener una desviación más grande siempre, solo por ser grande.

El coeficiente de variación arregla eso dividiendo la desviación entre la media. Queda un número sin unidades, o sea comparable:

CV=sx¯

la dispersión medida en veces la media, que sale sin unidades y por eso se puede comparar entre cosas distintas

por_segmento = v.groupby('segmento')['monto'].agg(['mean', 'std'])
por_segmento['cv'] = por_segmento['std'] / por_segmento['mean']

print(por_segmento.round(3))
                mean      std     cv
segmento
Bodega       178.698   69.587  0.389
Horeca       755.179  277.276  0.367
Mayorista   1856.337  719.551  0.388
Minimarket   414.917  148.680  0.358

Esto me pareció precioso cuando salió 😍

Las desviaciones van de 69,59 a 719,55, o sea que una es diez veces la otra. Y los coeficientes de variación van de 0,358 a 0,389: prácticamente el mismo número en los cuatro.

Lo que eso dice del negocio es concreto y bonito: los cuatro segmentos se comportan igual, solo que a escalas distintas. Todos tienen ventas que se desvían alrededor de un 37% de su propio promedio. Un mayorista es una bodega multiplicada por diez, no un animal diferente.

Y encaja con lo del capítulo 3, donde vimos que dentro de cada segmento la media y la mediana coincidían. Cuatro poblaciones con la misma forma y distinto tamaño 🪆

La campana partida en franjas por desviaciones estándar: el 68% de los casos cae a una desviación del promedio, el 95% a dos y el 99,7% a tres.
De aquí sale el criterio de dato raro sin tener que calcular nada. Y de aquí sale también el error de aplicarlo a datos con cola larga, donde hay muchísimos más casos lejos del promedio de los que estas tres franjas predicen.

La regla del 68, 95 y 99 (y cuándo falla)

Seguro la has oído: en una distribución normal, el 68% de los datos cae a una desviación del promedio, el 95% a dos y el 99,7% a tres.

Vamos a comprobarlo en tres columnas de este archivo:

for col in ['monto', 'unidades', 'satisfaccion']:
    s = v[col].dropna()
    dentro = 100 * (abs(s - s.mean()) < s.std()).mean()
    print('%-13s dentro de 1 desviacion: %.2f%%' % (col, dentro))

print('lo que dice la teoria para una normal:  68.27%')
monto         dentro de 1 desviacion: 81.80%
unidades      dentro de 1 desviacion: 68.70%
satisfaccion  dentro de 1 desviacion: 59.44%
lo que dice la teoria para una normal:  68.27%

Tres columnas del mismo archivo y tres respuestas: 81,80%, 68,70% y 59,44%. La teoría dice 68,27% 🎯

  • 📦 unidades da 68,70%, que es clavado. Esa columna sí tiene forma de campana.
  • 💰 monto da 81,80%, muy por encima. Se concentra más de lo que debería cerca del centro y tiene cola.
  • satisfaccion da 59,44%, muy por debajo. Y tiene sentido: son cinco valores repartidos casi por igual, no una campana.

La regla del 68-95-99 solo vale si tus datos son normales, y la mayoría no lo son. Aplicarla sin comprobar es de los errores más comunes que existen, y por eso el capítulo 5 va entero de eso: cómo saber si tienes una campana antes de usar las herramientas de la campana.

Mientras tanto hay una regla que vale siempre, pase lo que pase con la forma. Se llama desigualdad de Chebyshev y dice que al menos el 75% de los datos está a menos de 2 desviaciones, y al menos el 88,9% a menos de 3:

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

for k, cota in [(2, 75.0), (3, 88.89)]:
    dentro = 100 * (abs(v['monto'] - m) < k * s).mean()
    print('a menos de %d desviaciones: %.2f%%  (Chebyshev garantiza %.2f%%)'
          % (k, dentro, cota))
a menos de 2 desviaciones: 93.57%  (Chebyshev garantiza 75.00%)
a menos de 3 desviaciones: 98.93%  (Chebyshev garantiza 88.89%)

Se cumple con holgura, como tenía que ser. Chebyshev es una garantía mínima, floja pero universal: no hay ninguna distribución en el mundo que la rompa 🛡️

Practica 💪

1. El rango es de cristal

Quita la venta más grande y la más chica y vuelve a calcular el rango. Compara también con el rango entre el percentil 1 y el 99.

orden = v['monto'].sort_values()

print('rango completo:        %.2f' % (orden.iloc[-1] - orden.iloc[0]))
print('sin la mayor y la menor: %.2f' % (orden.iloc[-2] - orden.iloc[1]))
print('entre el 1%% y el 99%%:    %.2f'
      % (v['monto'].quantile(0.99) - v['monto'].quantile(0.01)))
rango completo:        6734.43
sin la mayor y la menor: 6575.48
entre el 1% y el 99%:    3022.95

Quitar dos filas se lleva 159 soles. Pero quitar el 1% de cada punta, o sea 30 filas de cada lado, se lleva más de la mitad del rango: de 6.734 baja a 3.023.

Eso te dice dónde está la acción: en las puntas hay unas pocas ventas muy raras que estiran el rango una barbaridad. Las cazamos en el capítulo 6.

El rango entre percentiles es una medida decente y muy fácil de explicar. La uso mucho cuando alguien me pide "el rango" y sé que el máximo es un error de captura 😅

2. ¿Varían igual todas las ciudades?

Saca media, desviación y coeficiente de variación por ciudad. ¿Se parece al resultado de los segmentos?

por_ciudad = v.groupby('ciudad')['monto'].agg(['mean', 'std'])
por_ciudad['cv'] = por_ciudad['std'] / por_ciudad['mean']
print(por_ciudad.round(3))
             mean      std     cv
ciudad
arequipa  801.917  752.519  0.938
chiclayo  813.908  760.953  0.935
cusco     780.696  705.237  0.903
lima      792.737  752.954  0.950
piura     792.161  725.125  0.915
trujillo  842.688  816.312  0.969

Dos cosas, y la segunda es la interesante 🤓

La primera: las seis ciudades se parecen muchísimo entre ellas, tanto en media como en dispersión. Otra vez ciudad no separa nada, igual que en el capítulo 1.

La segunda: el CV por ciudad es 0,9 y pico, y el CV por segmento era 0,37. ¡Dos veces y media más!

¿Por qué? Porque dentro de una ciudad hay bodegas y mayoristas mezclados, y eso mete una variabilidad enorme. Dentro de un segmento no: todos venden a la misma escala.

O sea que la dispersión no es una propiedad de los datos, es una propiedad de cómo los agrupas. Agrupar bien reduce el ruido, y eso es la mitad de un buen análisis 🧠

3. La desviación de un solo dato

Calcula la desviación estándar de una serie con un solo valor. Antes de correrlo, piensa qué debería salir.

print('con un dato:  ', pd.Series([500.0]).std())
print('con dos datos:', pd.Series([500.0, 500.0]).std())
con un dato:   nan
con dos datos: 0.0

Con un dato sale nan, sin error y sin aviso 🫥

Y es lo correcto, aunque asuste: con ddof=1 estás dividiendo entre n-1, o sea entre cero. Un solo dato no tiene dispersión que estimar, porque no hay nada de qué separarse.

Con dos datos iguales sale 0,0, que también es correcto.

Esto importa de verdad cuando agrupas: si tienes una categoría con una sola fila, su desviación va a ser nan y va a contaminar cualquier promedio que hagas después. Por eso, siempre que agrupes, pide el count al lado 📋

4. Compara la variabilidad de cosas incomparables

¿Qué varía más en este archivo: el monto, las unidades, el descuento o la satisfacción? Están en unidades distintas, así que la desviación no sirve.

for col in ['monto', 'unidades', 'descuento', 'satisfaccion']:
    s = v[col].dropna()
    print('%-13s media %8.3f | desviacion %8.3f | CV %.4f'
          % (col, s.mean(), s.std(), s.std() / s.mean()))
monto         media  803.761 | desviacion  751.989 | CV 0.9356
unidades      media   11.601 | desviacion    5.775 | CV 0.4978
descuento     media    0.125 | desviacion    0.072 | CV 0.5731
satisfaccion  media    3.010 | desviacion    1.421 | CV 0.4720

El monto es el que más varía con diferencia: CV de 0,9356, casi el doble que cualquier otro.

Y fíjate en lo que habría pasado mirando las desviaciones a secas: monto 752, unidades 5,78. Habrías dicho "el monto varía 130 veces más", que es comparar soles con cajas y no significa nada 🍎🍐

Con el CV la respuesta es "el monto varía el doble", que sí se puede defender.

Un aviso: el CV solo tiene sentido si la variable tiene un cero natural y no toma valores negativos. Con temperaturas en grados Celsius, por ejemplo, no sirve, porque el cero es arbitrario y la media puede quedar cerca de cero y disparar el cociente.

5. La desviación de una proporción

Calcula la desviación estándar de compro, que es 0/1. Luego comprueba que coincide con la fórmula de la proporción.

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

print('desviacion calculada:  %.6f' % v['compro'].std())
print('formula sqrt(p*(1-p)): %.6f' % np.sqrt(p * (1 - p)))
print('con la correccion n-1: %.6f' % np.sqrt(p * (1 - p) * n / (n - 1)))
desviacion calculada:  0.494013
formula sqrt(p*(1-p)): 0.493931
con la correccion n-1: 0.494013

Coincide hasta el último decimal cuando le pones la corrección 🎯

Lo interesante es que en una variable 0/1 la dispersión no es libre: la decide la proporción. Si el 57,77% compra, la desviación tiene que ser 0,494013. No hay otra opción.

Eso tiene una consecuencia que se usa muchísimo: la dispersión es máxima cuando p vale 0,5 (mitad y mitad, máxima incertidumbre) y se va a cero cuando p se acerca a 0 o a 1. Por eso una encuesta de "sí o no" necesita más gente cuando el resultado está peleado, y menos cuando está claro 🗳️

Esta fórmula es la que usaremos en el capítulo 9 para poner el margen de error de una proporción.

6. Un resumen honesto en una línea

Escribe una función que resuma una columna con centro y dispersión, eligiendo las medidas según si hay cola o no.

def resumen(serie, nombre):
    s = serie.dropna()
    media, mediana = s.mean(), s.median()
    if abs(media - mediana) / mediana < 0.10:
        return ('%s: %.2f de media, +/- %.2f'
                % (nombre, media, s.std()))
    q1, q3 = s.quantile(0.25), s.quantile(0.75)
    return ('%s: mediana %.2f, y la mitad central entre %.2f y %.2f'
            % (nombre, mediana, q1, q3))


print(resumen(v['monto'], 'monto'))
print(resumen(v['unidades'], 'unidades'))
print(resumen(v.loc[v['segmento'] == 'Bodega', 'monto'], 'monto en Bodega'))
monto: mediana 533.63, y la mitad central entre 252.69 y 1068.68
unidades: 11.60 de media, +/- 5.77
monto en Bodega: 178.70 de media, +/- 69.59

Tres columnas, tres frases, cada una con las medidas que le tocan 🙌

Esta función la puedes pegar en cualquier cuaderno y usarla como primer vistazo. Lo que hace, en el fondo, es tomar por ti la decisión del capítulo 3 (media o mediana) y arrastrar la dispersión que le corresponde a cada una.

Porque eso es lo que no se puede mezclar: media con desviación estándar, mediana con cuartiles. Ver una media con un rango intercuartílico al lado es señal de que alguien copió y pegó sin pensar 😬

Comprueba que lo tienes

Los cuatro segmentos tienen desviaciones de 69,59 a 719,55 y todos un coeficiente de variación de 0,37. ¿Qué dice eso del negocio?

  • Que son el mismo negocio a distinta escala
  • Que Mayorista es mucho más impredecible que Bodega
  • Que hay que analizarlos por separado siempre
  • Que las desviaciones están mal calculadas

Lo que te llevas

  • 📏 Un promedio sin dispersión es medio dato. Dos vendedores de 500 soles con desviaciones de 6,55 y 405,32 son dos negocios distintos.
  • 🔢 ddof: pandas divide entre n-1 y numpy entre n. Con 3.000 filas da igual; con 3 datos son 22,5% de diferencia.
  • 📦 El IQR se explica solo: el 50% de las ventas está entre 252,69 y 1.068,68.
  • ⚖️ El coeficiente de variación compara escalas distintas. Los cuatro segmentos tienen desviaciones de 69 a 719 y todos el mismo CV de 0,37: son el mismo negocio a distinto tamaño.
  • 🧺 El CV por ciudad es 0,9 y por segmento 0,37. La dispersión depende de cómo agrupas, no solo de los datos.
  • 🔔 La regla del 68% solo vale si hay campana. Aquí dio 68,70% en unidades, 81,80% en monto y 59,44% en satisfacción.
  • 🛡️ Chebyshev sí vale siempre: al menos el 75% a dos desviaciones, pase lo que pase con la forma.

Qué viene ahora

Llevamos dos capítulos usando la palabra "campana" sin comprobar si la hay. En el capítulo 5 la miramos de frente: qué es la distribución normal, cómo se comprueba si tus datos la siguen, y qué se rompe cuando no 🔔

¿Tienes alguna duda o consulta?