Tienes 3.000 ventas y calculas que el monto promedio es 803,76 soles.
Pero esas 3.000 no son todas las ventas del mundo: son las que te tocaron. Si el mes que viene sacas otras 3.000, el promedio va a salir distinto. Entonces, ¿de cuánto es el promedio de verdad? 🤷
La respuesta honesta no es un número, es un rango. Eso es un intervalo de confianza, y este capítulo va de calcularlo, entenderlo y no contarlo mal.
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)
m = v['monto']
n = len(m)
error_estandar = m.std() / np.sqrt(n)
print('promedio: %.4f' % m.mean())
print('error estandar: %.4f' % error_estandar)
promedio: 803.7613 error estandar: 13.7294
Ese error estándar es el del capítulo 8: cuánto se mueve un promedio de muestra en muestra. 13,73 soles.
cuánto se mueve un promedio de muestra en muestra, que baja con la raíz de cuántos datos tengas
El intervalo, en una línea
bajo, alto = stats.t.interval(0.95, n - 1, m.mean(), error_estandar)
print('promedio: %.2f' % m.mean())
print('intervalo del 95%%: de %.2f a %.2f' % (bajo, alto))
print('margen de error: +/- %.2f soles' % ((alto - bajo) / 2))
promedio: 803.76 intervalo del 95%: de 776.84 a 830.68 margen de error: +/- 26.92 soles
Ahí lo tienes: el monto promedio está entre 776,84 y 830,68 soles 📊
Ese 1,96 que multiplica el error estándar (aquí lo pone la t por dentro) es el número mágico del 95%. Si quisieras el 99% sería 2,58, y el intervalo saldría más ancho: más seguridad cuesta más rango.
La regla rápida que puedes hacer de cabeza: margen de error igual a dos errores estándar 🧮
el promedio más menos dos errores estándar, que es de donde sale el margen de error de las encuestas
Lo que significa, y lo que no
Esta es la parte que casi todo el mundo cuenta mal, incluida yo durante años 🙋
Mal: "hay un 95% de probabilidad de que el promedio real esté entre 776,84 y 830,68".
Bien: "si repitiera este cálculo muchas veces con muestras distintas, el 95% de los intervalos que saldrían contendrían el promedio real".
La diferencia parece de abogados y no lo es. El promedio real es un número fijo: o está dentro de tu intervalo o no está. Lo que tiene el 95% es el método, no el intervalo concreto que te salió.
Y como esto se entiende mejor viéndolo, vamos a hacerlo:
generador = np.random.default_rng(7)
verdadera = m.mean()
tamano = 50
contiene = 0
for _ in range(1000):
muestra = generador.choice(m.values, tamano)
ee = muestra.std(ddof=1) / np.sqrt(tamano)
lo, hi = stats.t.interval(0.95, tamano - 1, muestra.mean(), ee)
contiene += (lo <= verdadera <= hi)
print('de 1000 intervalos del 95%%, contienen el promedio real: %d' % contiene)
print('o sea el %.1f%%' % (contiene / 10))
de 1000 intervalos del 95%, contienen el promedio real: 929 o sea el 92.9%
92,9% en vez de 95%. Se queda corto 🤔
Y ese "casi" tiene explicación, que es de las cosas más útiles del capítulo. Vamos a probar con distintos tamaños:
for tamano in [20, 50, 200, 1000]:
contiene = 0
for _ in range(1000):
muestra = generador.choice(m.values, tamano)
ee = muestra.std(ddof=1) / np.sqrt(tamano)
lo, hi = stats.t.interval(0.95, tamano - 1, muestra.mean(), ee)
contiene += (lo <= verdadera <= hi)
print('con muestras de %4d -> cobertura real %.1f%%' % (tamano, contiene / 10))
con muestras de 20 -> cobertura real 93.4% con muestras de 50 -> cobertura real 93.1% con muestras de 200 -> cobertura real 94.7% con muestras de 1000 -> cobertura real 95.9%
Con muestras de 20 o 50, tu intervalo del 95% acierta el 93% 😬
O sea que te está mintiendo un poco: crees que fallas 1 de cada 20 y fallas 1 de cada 15. Y no hay ningún error en el cálculo. Solo cuando llegas a 1.000 la cobertura sube al 95,9% y la promesa se cumple.
La causa es la cola del capítulo 5. La fórmula supone que los promedios ya se han acampanado, y con 20 ventas de una distribución tan torcida todavía no lo están (capítulo 8: con 30 la asimetría seguía en 0,3223).
Compáralo con unidades, que sí era acampanada:
u = v['unidades'].values
verdadera_u = u.mean()
for tamano in [20, 50]:
contiene = 0
for _ in range(1000):
muestra = generador.choice(u, tamano)
ee = muestra.std(ddof=1) / np.sqrt(tamano)
lo, hi = stats.t.interval(0.95, tamano - 1, muestra.mean(), ee)
contiene += (lo <= verdadera_u <= hi)
print('unidades, muestras de %3d -> cobertura %.1f%%' % (tamano, contiene / 10))
unidades, muestras de 20 -> cobertura 95.2% unidades, muestras de 50 -> cobertura 95.0%
95,2% con solo 20 datos. Justo lo prometido, con la cuarta parte de la muestra que le hacía falta al monto 🔔
La forma de los datos importa más que la cantidad. Y date cuenta de lo que eso significa en la práctica: el mismo tamaño de muestra te da un intervalo honesto o uno optimista según qué columna estés midiendo.
El intervalo de una proporción
El caso más frecuente en la vida real: "el 57,77% de las ventas se cierra, ¿con cuánto margen?".
Aquí el error estándar tiene su propia fórmula, la que sacamos en el capítulo 4:
el error estándar de un porcentaje, que es máximo cuando el reparto está en la mitad y se encoge cuando se acerca a cero o a uno
p = v['compro'].mean()
ee_p = np.sqrt(p * (1 - p) / n)
print('proporcion: %.4f' % p)
print('error estandar: %.6f' % ee_p)
print('intervalo del 95%%: %.4f a %.4f' % (p - 1.96 * ee_p, p + 1.96 * ee_p))
print('o sea: %.2f%% +/- %.2f puntos' % (100 * p, 100 * 1.96 * ee_p))
proporcion: 0.5777 error estandar: 0.009018 intervalo del 95%: 0.5600 a 0.5953 o sea: 57.77% +/- 1.77 puntos
57,77% con un margen de 1,77 puntos. Ese "±1,8 puntos" es exactamente lo que sale en las encuestas electorales 🗳️
El error del capítulo
stats.t.interval(95, n - 1, m.mean(), error_estandar)
ValueError: alpha must be between 0 and 1 inclusive
Le pedí el 95 en vez del 0,95. Menos mal que avisa 🙏
Porque el mismo despiste en la otra dirección no avisa:
print('pidiendo 0.95:', np.round(stats.t.interval(0.95, n - 1, m.mean(), error_estandar), 2))
print('pidiendo 0.05:', np.round(stats.t.interval(0.05, n - 1, m.mean(), error_estandar), 2))
pidiendo 0.95: [776.84 830.68] pidiendo 0.05: [802.9 804.62]
Si por error pides 0,05 en vez de 0,95, te devuelve un intervalo estrechísimo y perfectamente válido: el intervalo del 5% 😱
Y ese sí que se cuela en un informe, porque un margen de error chiquito es justo lo que uno quiere ver. Cuidado con los resultados que te gustan demasiado 🍬
La trampa de los intervalos que se solapan
Y ahora lo más importante del capítulo, que es un error que he visto en informes serios.
Comparamos dos segmentos:
for seg in ['Horeca', 'Minimarket']:
g = v.loc[v['segmento'] == seg, 'compro']
pp = g.mean()
e = np.sqrt(pp * (1 - pp) / len(g))
print('%-11s n=%3d p=%.4f IC [%.4f, %.4f]'
% (seg, len(g), pp, pp - 1.96 * e, pp + 1.96 * e))
Horeca n=742 p=0.6321 IC [0.5974, 0.6668] Minimarket n=801 p=0.5693 IC [0.5350, 0.6036]
Los intervalos se solapan: Horeca llega hasta 0,5974 por abajo y Minimarket sube hasta 0,6036.
La conclusión que casi todo el mundo saca: "se solapan, así que no hay diferencia". Vamos a comprobarlo calculando el intervalo de la diferencia, que es lo que había que hacer:
a = v.loc[v['segmento'] == 'Horeca', 'compro']
b = v.loc[v['segmento'] == 'Minimarket', 'compro']
pa, pb = a.mean(), b.mean()
ea = np.sqrt(pa * (1 - pa) / len(a))
eb = np.sqrt(pb * (1 - pb) / len(b))
ee_dif = np.sqrt(ea ** 2 + eb ** 2)
dif = pa - pb
print('diferencia: %.4f' % dif)
print('intervalo del 95%%: [%.4f, %.4f]'
% (dif - 1.96 * ee_dif, dif + 1.96 * ee_dif))
print('contiene el cero:', (dif - 1.96 * ee_dif) <= 0 <= (dif + 1.96 * ee_dif))
diferencia: 0.0628 intervalo del 95%: [0.0140, 0.1116] contiene el cero: False
El intervalo de la diferencia no contiene el cero. O sea que la diferencia es real, aunque los intervalos individuales se solapen 🤯
Por qué pasa: el error estándar de la diferencia no es la suma de los dos, es la raíz de la suma de los cuadrados, que sale más chica. Sumar dos incertidumbres "a lo bruto" exagera.
la incertidumbre de una diferencia no es la suma de las dos, es la raíz de la suma de sus cuadrados, y por eso sale más chica
Regla: para comparar dos grupos, calcula el intervalo de la diferencia. Nunca mires si los individuales se tocan 📏
Practica 💪
1. El margen de error de cada segmento
Calcula el intervalo de la tasa de compra de los cuatro segmentos. ¿Cuál tiene el margen más ancho y por qué?
for seg, g in v.groupby('segmento'):
pp = g['compro'].mean()
e = np.sqrt(pp * (1 - pp) / len(g))
print('%-11s n=%3d %.2f%% +/- %.2f puntos'
% (seg, len(g), 100 * pp, 100 * 1.96 * e))
Bodega n=707 37.48% +/- 3.57 puntos Horeca n=742 63.21% +/- 3.47 puntos Mayorista n=750 72.40% +/- 3.20 puntos Minimarket n=801 56.93% +/- 3.43 puntos
Los cuatro rondan los 3,4 puntos, y el más estrecho es Mayorista 🎯
Lo interesante es por qué. Mayorista no tiene más filas que Minimarket (750 contra 801) y aun así su margen es más chico.
La razón está en la fórmula: p(1-p) es máximo cuando p vale 0,5 y baja cuando te alejas. Mayorista está en 0,724, o sea lejos de la mitad, así que hay menos incertidumbre que estimar.
Es lo que decíamos en el capítulo 4: una votación peleada necesita más gente que una que se ve clara 🗳️
2. ¿Cuántos datos necesito para un margen de 1 punto?
Despeja n de la fórmula del margen de error de una proporción y calcula cuántas ventas hacen falta para varios márgenes.
p = v['compro'].mean()
for margen in [0.05, 0.03, 0.02, 0.01]:
necesarias = (1.96 ** 2) * p * (1 - p) / (margen ** 2)
print('para +/- %.0f puntos hacen falta %7.0f ventas'
% (100 * margen, necesarias))
para +/- 5 puntos hacen falta 375 ventas para +/- 3 puntos hacen falta 1041 ventas para +/- 2 puntos hacen falta 2343 ventas para +/- 1 puntos hacen falta 9372 ventas
Ahí está el famoso "1.000 encuestados" de los medios: es justo lo que hace falta para un margen de 3 puntos 📰
Y fíjate en el salto final: bajar de 2 a 1 punto pasa de 2.344 a 9.375. Cuatro veces más gente para la mitad de margen, la raíz cuadrada de siempre.
Truco de campo: si no sabes qué p esperar, usa 0,5. Es el peor caso, o sea que el número que te salga te va a alcanzar seguro 🛡️
3. Un intervalo sin fórmulas: bootstrap
La mediana no tiene fórmula sencilla de error estándar. Calcula su intervalo remuestreando.
medianas = [np.median(generador.choice(m.values, n)) for _ in range(2000)]
print('mediana observada: %.2f' % m.median())
print('intervalo del 95%%: [%.2f, %.2f]'
% (np.percentile(medianas, 2.5), np.percentile(medianas, 97.5)))
print('error estandar de la mediana: %.2f' % np.std(medianas))
mediana observada: 533.63 intervalo del 95%: [509.82, 552.86] error estandar de la mediana: 11.36
El bootstrap es de las ideas más bonitas que hay, y en tres líneas 🥾
Lo que hace es tratar tu muestra como si fuera la población: saca muestras de ella con reposición, calcula el estadístico en cada una, y mira cómo varía. Sin fórmulas, sin suponer normalidad, y funciona con cualquier cosa que sepas calcular.
Fíjate en un detalle: el error estándar de la mediana sale 11,36 y el de la media era 13,73. La mediana es más precisa que la media aquí, y tiene sentido: es la medida robusta, la cola no la zarandea.
4. El intervalo de una diferencia de medias
¿Cuánto más vende un mayorista que una bodega? Da la diferencia con su intervalo.
may = v.loc[v['segmento'] == 'Mayorista', 'monto']
bod = v.loc[v['segmento'] == 'Bodega', 'monto']
dif = may.mean() - bod.mean()
ee = np.sqrt(may.var() / len(may) + bod.var() / len(bod))
print('mayorista: %.2f | bodega: %.2f' % (may.mean(), bod.mean()))
print('diferencia: %.2f soles' % dif)
print('intervalo del 95%%: [%.2f, %.2f]' % (dif - 1.96 * ee, dif + 1.96 * ee))
mayorista: 1856.34 | bodega: 178.70 diferencia: 1677.64 soles intervalo del 95%: [1625.89, 1729.39]
Un mayorista compra entre 1.625 y 1.729 soles más que una bodega, por venta 💰
Y esa forma de decirlo es la buena. Compara:
- 😐 "Hay diferencia significativa entre segmentos".
- 😍 "Un mayorista compra entre 1.625 y 1.729 soles más por venta que una bodega".
La segunda tiene el tamaño, la unidad y la incertidumbre. Con eso alguien puede decidir algo. Con la primera, no 🤝
Esa es la diferencia entre reportar significancia y reportar un intervalo, y es el motivo por el que muchos estadísticos prefieren los intervalos a las pruebas de hipótesis del capítulo siguiente.
5. Fabrica el gráfico de cobertura a mano
Genera 20 intervalos de muestras de 100 ventas y dibuja cuáles contienen el promedio real.
verdadera = m.mean()
fallos = 0
for i in range(20):
muestra = generador.choice(m.values, 100)
ee = muestra.std(ddof=1) / np.sqrt(100)
lo, hi = stats.t.interval(0.95, 99, muestra.mean(), ee)
dentro = lo <= verdadera <= hi
fallos += not dentro
print('%2d: [%7.2f, %7.2f] %s' % (i + 1, lo, hi, 'ok' if dentro else 'FALLA'))
print()
print('fallaron %d de 20' % fallos)
1: [ 616.29, 926.35] ok 2: [ 741.65, 1053.20] ok 3: [ 698.04, 1016.73] ok 4: [ 825.21, 1140.85] FALLA 5: [ 588.81, 855.16] ok 6: [ 677.77, 983.14] ok 7: [ 657.62, 932.26] ok 8: [ 655.14, 932.56] ok 9: [ 729.09, 1025.84] ok 10: [ 708.92, 981.22] ok 11: [ 554.88, 829.98] ok 12: [ 695.73, 997.46] ok 13: [ 720.68, 1049.35] ok 14: [ 698.92, 964.96] ok 15: [ 607.22, 902.92] ok 16: [ 639.63, 905.61] ok 17: [ 622.90, 921.65] ok 18: [ 710.77, 1015.38] ok 19: [ 672.77, 981.01] ok 20: [ 654.24, 940.69] ok fallaron 1 de 20
Uno de veinte. Justo el 5% que promete el método 🎯
Mira el número 4: va de 825,21 a 1.140,85, y el promedio real es 803,76. Se quedó por debajo del intervalo entero.
Y aquí está la gracia: ese intervalo se ve exactamente igual de bien que los otros diecinueve. Es igual de ancho, está igual de centrado en su propia muestra, y no tiene ninguna marca que lo delate.
Por eso la interpretación correcta habla del método y no de tu intervalo concreto. Tú tienes uno solo y no sabes si es de los 19 o es el 1 😅
6. El resumen que sí se puede llevar a una reunión
Escribe una función que dé una proporción con su margen en lenguaje de persona.
def con_margen(serie, nombre):
s = serie.dropna()
pp = s.mean()
e = np.sqrt(pp * (1 - pp) / len(s))
return ('%s: %.1f%% (entre %.1f%% y %.1f%%, con %d casos)'
% (nombre, 100 * pp, 100 * (pp - 1.96 * e),
100 * (pp + 1.96 * e), len(s)))
print(con_margen(v['compro'], 'tasa de cierre'))
print(con_margen(v.loc[v['canal'] == 'WhatsApp', 'compro'], 'cierre por WhatsApp'))
print(con_margen(v.loc[v['ciudad'] == 'lima', 'compro'], 'cierre en Lima'))
print(con_margen(v.loc[(v['ciudad'] == 'lima') & (v['canal'] == 'WhatsApp'), 'compro'],
'cierre en Lima por WhatsApp'))
tasa de cierre: 57.8% (entre 56.0% y 59.5%, con 3000 casos) cierre por WhatsApp: 65.5% (entre 62.0% y 69.0%, con 719 casos) cierre en Lima: 58.4% (entre 53.9% y 62.8%, con 471 casos) cierre en Lima por WhatsApp: 62.9% (entre 54.4% y 71.4%, con 124 casos)
Mira cómo se abre el intervalo según vas filtrando 📈
Con 3.000 casos el margen es de menos de 2 puntos. Al llegar a "Lima por WhatsApp" quedan 124 filas y el intervalo va de 54,4% a 71,4%, o sea 17 puntos de ancho.
Eso quiere decir que con ese trozo de datos no puedes distinguir "cierra igual que la media" de "cierra muchísimo mejor". No es que no haya efecto: es que no tienes datos para verlo.
Y aquí está la costumbre que quiero dejarte del capítulo entero: cada vez que cortes los datos en trocitos, pon el número de casos al lado. Un porcentaje sin su n no significa nada 🙏
Comprueba que lo tienes
Dos segmentos tienen intervalos de confianza que se solapan. ¿Hay diferencia entre ellos?
- No se sabe: hay que calcular el intervalo de la diferencia
- No, porque si se solapan la diferencia no es real
- Sí, porque los centros son distintos
- Depende de cuánto se solapen
Lo que te llevas
- 📏 Margen de error = dos errores estándar. Aquí el monto promedio es 803,76 con un margen de ±26,92.
- 🎯 El 95% es del método, no de tu intervalo. Tú tienes uno y no sabes si es de los buenos.
- ⚠️ Con muestras chicas de una columna con cola, el intervalo del 95% acierta
el 93%. Con
unidades, que es acampanada, acierta el 95,2% ya con 20 datos. - 🔗 Intervalos que se solapan NO significan que no haya diferencia. Horeca y Minimarket se solapan y su diferencia va de 0,0140 a 0,1116, sin tocar el cero.
- 🥾 El bootstrap da intervalos de cualquier cosa sin fórmulas. La mediana salió más precisa que la media: 11,36 contra 13,73.
- 🧮 Para un margen de 3 puntos hacen falta 1.042 casos; para 1 punto, 9.375.
- 🔢 Un porcentaje sin su número de casos no significa nada.
Qué viene ahora
Con el intervalo de la diferencia ya casi hemos hecho una prueba de hipótesis sin darnos cuenta: mirábamos si el cero estaba dentro. En el capítulo 10 le ponemos nombre, y sobre todo desmontamos el valor p, que es el número peor entendido de toda la estadística 🎓