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:
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:
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 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% 🎯
- 📦
unidadesda 68,70%, que es clavado. Esa columna sí tiene forma de campana. - 💰
montoda 81,80%, muy por encima. Se concentra más de lo que debería cerca del centro y tiene cola. - ⭐
satisfaccionda 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 🔔