Capítulo 9 de 16 8 secciones 16 min

De la muestra a la población: el margen de error

Intervalos de confianza, lo que de verdad significan, y por qué dos intervalos que se solapan pueden ser una diferencia real.

Un intervalo de confianza del 95% es un rango construido con un método que acierta el 95% de las veces si lo repites muchas veces. No significa que haya un 95% de probabilidad de que el valor real esté ahí dentro. Y dos intervalos que se solapan no implican que no haya diferencia: aquí Horeca y Minimarket tienen intervalos solapados y su diferencia es real.

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.

EE=sn

cuánto se mueve un promedio de muestra en muestra, que baja con la raíz de cuántos datos tengas

Histograma de las medias de dos mil remuestras con la banda del intervalo de confianza del 95% sombreada y la media original marcada con una línea vertical.
Cada barra es el promedio de una remuestra de tus propios datos. El ancho de ese montón es, literalmente, cuánto se mueve tu promedio, y recortando el 2,5% de cada lado sale el intervalo. Sin fórmulas y sin suponer que los datos son normales.

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 🧮

IC95%=x¯±1,96·sn

el promedio más menos dos errores estándar, que es de donde sale el margen de error de las encuestas

Línea de cobertura real del intervalo del 95% según el tamaño de la muestra, con una raya horizontal en el 95% prometido. Con muestras chicas la línea va por debajo y solo se acerca a la raya con mil datos.
El intervalo del 95% no cubre el 95% cuando la muestra es chica y los datos tienen cola: se queda alrededor del 93%. La promesa se cumple recién con muestras grandes.

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:

EEp=p(1p)n

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.

EEd=EE12+EE22

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 🎓

¿Tienes alguna duda o consulta?