Los ocho capítulos anteriores dan por hecho una cosa que nadie comprueba: que los datos que tienes delante representan a lo que quieres saber 🎯
Y ese supuesto es el que más veces se cae. Un promedio bien calculado sobre una muestra torcida es un número perfecto de algo que no te interesa.
Aquí tenemos una ventaja que en la vida real no vas a tener nunca: conocemos la población entera. Son 3.000 pedidos y podemos mirarlos todos. Así que podemos sacar muestras de tres maneras distintas y comparar cada una contra la verdad.
import numpy as np import pandas as pd 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).dropna(subset=['monto']) POBLACION = v['monto'].mean() print('pedidos en total: %d' % len(v)) print('ticket promedio de TODOS: %.2f' % POBLACION) print() print(v.groupby('segmento')['monto'].agg(['count', 'mean']).round(2) .sort_values('mean').to_string())
pedidos en total: 3000
ticket promedio de TODOS: 803.76
count mean
segmento
Bodega 707 178.70
Minimarket 801 414.92
Horeca 742 755.18
Mayorista 750 1856.34
Guárdate esa tabla, que es el motor de todo lo que viene 🔑
Un mayorista compra diez veces lo que una bodega: 1.856,34 contra 178,70. Cuando dentro de tu población hay grupos así de distintos, a quién metes en la muestra deja de ser un detalle administrativo.
Y antes de seguir, contéstate esto: ¿hacia dónde se te iría el número si preguntaras solo a los clientes que tienes más a mano? 🐣
Lo que pasa cuando se saca bien
Muestreo aleatorio simple: cada pedido tiene exactamente la misma probabilidad de entrar. Lo sacamos 500 veces para no juzgar por una sola:
def promedios(sacar, veces=500): """El ticket promedio de `veces` muestras sacadas con `sacar`.""" return np.array([sacar(s)['monto'].mean() for s in range(veces)]) al_azar = promedios(lambda s: v.sample(200, random_state=s)) print('poblacion: %.2f' % POBLACION) print('promedio de 500 muestras: %.2f' % al_azar.mean()) print('sesgo: %+.2f' % (al_azar.mean() - POBLACION)) print('cuanto varian entre si: %.2f' % al_azar.std())
poblacion: 803.76 promedio de 500 muestras: 803.28 sesgo: -0.49 cuanto varian entre si: 50.10
Cuarenta y nueve céntimos de sesgo sobre 803 soles. Eso es cero 💚
Y fíjate en que hay dos números distintos y no uno, porque son dos problemas distintos:
- 📍 El sesgo es si le apuntas al sitio correcto. Aquí, sí.
- 📏 La variación es lo que se mueve una muestra de otra. Cincuenta soles, que no es poco.
La variación se arregla con más datos, y eso ya lo sabías por el capítulo 10. El sesgo no. Y esa diferencia es el capítulo entero.
Y ahora una muestra como se saca de verdad
Nadie encuesta al azar. Se encuesta a quien contesta, a quien pasa por la tienda, a quien el vendedor visita. Y el vendedor no visita a todos igual:
VISIBILIDAD = {'Bodega': 1, 'Minimarket': 2, 'Horeca': 4, 'Mayorista': 8}
peso = v['segmento'].map(VISIBILIDAD)
conveniencia = promedios(
lambda s: v.sample(200, weights=peso, replace=True, random_state=s))
print('lo que el vendedor visita, por cada bodega:')
for seg, cuantas in VISIBILIDAD.items():
print(' %-11s %d' % (seg, cuantas))
print()
print('poblacion: %.2f' % POBLACION)
print('lo que mide la encuesta: %.2f' % conveniencia.mean())
print('sesgo: %+.2f soles' % (conveniencia.mean() - POBLACION))
lo que el vendedor visita, por cada bodega: Bodega 1 Minimarket 2 Horeca 4 Mayorista 8 poblacion: 803.76 lo que mide la encuesta: 1258.98 sesgo: +455.21 soles
Cuatrocientos cincuenta y cinco soles 😱
El ticket real es 803,76 y la encuesta dice 1.258,98. Un 57% de más, y ni una sola línea del cálculo está mal: el promedio está bien hecho, la muestra tiene 200 pedidos de verdad, todo corre. Lo que está mal es quién entró.
Y no hace falta que nadie mienta ni haga trampa. Basta con que el vendedor pase más por donde le compran más, que es lo que hace cualquier vendedor sensato.
La tabla que hay que enseñarle a quien pide "más datos"
Cuando un número no cuadra, lo primero que dice todo el mundo es "hay que recoger más". Vamos a ver si eso arregla algo:
print('%8s %12s %10s %12s' % ('n', 'promedio', 'sesgo', 'variacion')) for n in [100, 400, 1600, 6400]: m = promedios(lambda s: v.sample(n, weights=peso, replace=True, random_state=s), 200) print('%8d %12.2f %+10.2f %12.2f' % (n, m.mean(), m.mean() - POBLACION, m.std()))
n promedio sesgo variacion
100 1256.80 +453.04 86.37
400 1255.06 +451.30 42.72
1600 1254.39 +450.63 20.46
6400 1255.88 +452.12 10.75
Léela de arriba abajo dos veces 😬
La columna de la derecha baja bien: de 86 a 11. Con 6.400 encuestas tu número es ocho veces más estable que con 100.
Y la del medio no se mueve. 453, 451, 450, 452. Sesenta y cuatro veces más datos y el error sigue ahí entero.
O sea que recoger más datos de la misma manera torcida te deja más seguro de un número equivocado. Que es peor que estar inseguro, porque ahora lo defiendes en la reunión con un intervalo estrecho al lado.
Más datos arreglan la variación y no arreglan el sesgo. Una muestra torcida con muchas filas es una muestra torcida con un intervalo estrecho.
Cómo se arregla, que es más fácil de lo que parece
Si el problema es que unos grupos entran de más y otros de menos, la solución es sacar de cada grupo lo que le toca. Eso se llama muestreo estratificado:
reparto = v['segmento'].value_counts(normalize=True) print('como es el negocio de verdad:') print((reparto * 100).round(1).to_string()) def estratificada(s): trozos = [v[v['segmento'] == seg].sample(int(round(200 * w)), random_state=s) for seg, w in reparto.items()] return pd.concat(trozos) estrato = promedios(estratificada) print() print('%-22s %10s %10s %10s' % ('como se saca', 'promedio', 'sesgo', 'variacion')) for nombre, m in [('al azar', al_azar), ('por conveniencia', conveniencia), ('estratificada', estrato)]: print('%-22s %10.2f %+10.2f %10.2f' % (nombre, m.mean(), m.mean() - POBLACION, m.std()))
como es el negocio de verdad: segmento Minimarket 26.7 Mayorista 25.0 Horeca 24.7 Bodega 23.6 como se saca promedio sesgo variacion al azar 803.28 -0.49 50.10 por conveniencia 1258.98 +455.21 61.38 estratificada 804.68 +0.92 28.62
Mira la última fila entera, que tiene dos buenas noticias 💜
El sesgo se fue, como era de esperar. Pero además la variación baja de 50,10 a 28,62, un 43% menos, con exactamente el mismo número de encuestas.
Eso no es magia: al forzar que cada segmento aparezca en su proporción, quitas de en medio la lotería de cuántos mayoristas te tocaron esta vez. Estratificar no solo corrige, también afina gratis, y por eso es lo que hacen las encuestas serias.
La condición para poder hacerlo es una y hay que decirla: tienes que conocer el reparto real de la población. Aquí lo conocemos porque tenemos la base entera. En una encuesta de calle se saca del censo, del padrón de clientes o de lo que haya, y si no lo tienes, no puedes estratificar.
El error que sale al inventarse los pesos
Y este sale el primer día que alguien decide pesar por algo:
v.sample(200, weights=v['monto'] - POBLACION, random_state=0)
ValueError: weight vector many not include negative values
Pesar por "cuánto se aleja del promedio" suena razonable hasta que lo escribes: la mitad de los pedidos están por debajo, así que la mitad de los pesos salen negativos. Y un peso negativo no significa nada, porque no puedes meter a alguien menos de cero veces 🚩
Lo de many en vez de may es una errata de pandas, no mía. La dejo tal cual porque es lo que vas a ver en tu pantalla.
Los cuatro muestreos que se usan, en una tabla
| Cómo se saca | Cuándo | Qué le pasa |
|---|---|---|
| Aleatorio simple | Cuando tienes la lista completa de clientes | Sin sesgo. Es el patrón contra el que se compara todo |
| Estratificado | Cuando hay grupos muy distintos y sabes su peso real | Sin sesgo y con menos variación. El mejor si puedes |
| Por conglomerados | Cuando visitar cuesta: eliges 3 ciudades y encuestas todo dentro | Barato, y con más variación. Vale si eliges los conglomerados al azar |
| Por conveniencia | Cuando preguntas a quien está a mano, que es lo que pasa casi siempre | Sesgo desconocido. Lo medido aquí: +455 soles |
La cuarta fila es la que de verdad usas, y no pasa nada por usarla: lo que no puedes es olvidarte de que la usaste. Una encuesta de conveniencia se reporta diciendo a quién se preguntó, y entonces quien lee decide cuánto se lo cree 🔍
Y hay un quinto caso que no es un método sino una avería: la no respuesta. Mandas la encuesta al azar, bien sacada, y contesta el 12%. Ese 12% ya no es una muestra aleatoria, es una muestra de conveniencia de la gente a la que le apetece contestar. El sesgo entra por ahí aunque el diseño fuera perfecto.
Si quieres el detalle formal, el INEI publica las fichas técnicas de sus encuestas con el diseño muestral entero, y son de lo mejor que hay para ver esto escrito en serio y sobre el Perú 📄
La trampa
Un equipo quiere una encuesta estratificada y escribe los pesos a mano. La muestra sale, el código no se queja, y el ticket promedio les da 547,81 cuando el real es 803,76.
PESOS = {'Bodega': 1, 'Minimarket': 2, 'Horeca': 4}
peso = v['segmento'].map(PESOS)
muestra = v.sample(200, weights=peso, replace=True, random_state=0)
print(muestra['monto'].mean()) # 547.81
Qué está mal
Falta Mayorista en el diccionario. Al mapear, esas 750 filas se quedan en NaN, y pandas trata un peso ausente como un cero: el segmento que más factura, 1.856,34 de ticket, queda excluido de la muestra sin un solo aviso. Por eso el promedio se cae a 547,81. Esto es peor que el sesgo de conveniencia del capítulo, porque allí al menos sabías que estabas preguntando a quien tenías a mano. La comprobación que lo caza cabe en una línea y hay que hacerla siempre después de muestrear: muestra["segmento"].value_counts(). Si falta una categoría que existe en la población, ahí está el problema.
Ejercicios
Seis. El 1 es la trampa de arriba ejecutada, y el 4 es el que te va a servir en el trabajo 💛
1. Olvídate de un segmento a propósito
Quita Mayorista del diccionario de pesos y mira
qué sale.
PESOS = {'Bodega': 1, 'Minimarket': 2, 'Horeca': 4}
mala = v.sample(200, weights=v['segmento'].map(PESOS), replace=True, random_state=0)
print('promedio:', round(mala['monto'].mean(), 2))
print(mala['segmento'].value_counts().to_string())
La segunda línea es la que hay que interiorizar: después de sacar una muestra, cuenta siempre qué hay dentro. Es donde se ve lo que no está.
2. Cambia lo que el vendedor visita
Prueba con VISIBILIDAD al revés, o sea que
visite ocho veces más a las bodegas.
El sesgo tiene que darse la vuelta y salir negativo. Y eso enseña algo que no es obvio: el sesgo no siempre exagera. Una encuesta mal sacada puede hacerte creer que vendes menos de lo que vendes, y ese error nadie lo sospecha porque las malas noticias se creen antes.
3. Estratifica por ciudad en vez de por segmento
Repite el estratificado usando ciudad y compara
la variación con la del estratificado por segmento.
Va a bajar mucho menos. La razón es la tabla del principio: las ciudades se parecen entre sí (de 780 a 842) y los segmentos no (de 178 a 1.856). Estratificar sirve cuando los grupos son distintos entre ellos y parecidos por dentro, y si eliges la columna equivocada no aporta nada.
4. La comprobación que se hace en el trabajo
Escríbela como función y déjala puesta: comparar el reparto de tu muestra contra el de la población.
def cuadra(muestra, columna='segmento'): tabla = pd.DataFrame({ 'poblacion': v[columna].value_counts(normalize=True), 'muestra': muestra[columna].value_counts(normalize=True), }) tabla['diferencia'] = (tabla['muestra'] - tabla['poblacion']) * 100 return tabla.round(3) print(cuadra(v.sample(200, weights=peso, replace=True, random_state=0)))
Esta es la que de verdad vas a usar. No te dice si tu muestra es buena, que eso no se puede saber del todo, pero te dice si está torcida en las columnas que sí puedes mirar, y eso ya caza casi todo.
5. La no respuesta, simulada
Saca una muestra aleatoria bien hecha de 500 y después quita al azar el 88%, pero con más probabilidad de quedarse los mayoristas.
El diseño era perfecto y el resultado sale torcido igual. Es lo que pasa en toda encuesta de verdad, y por eso las fichas técnicas serias reportan la tasa de respuesta: sin ese número no se puede juzgar nada.
6. Cuánto cuesta el sesgo en soles
Si tomaras una decisión de compra de inventario con el promedio de la encuesta de conveniencia en vez del real, ¿de cuánto sería el error para 10.000 pedidos previstos?
Son 455,21 por 10.000. Escríbelo en soles y ponlo en la primera línea del informe, que es donde la gente sí lo lee. Esa traducción a dinero es la misma que hace el capítulo 19 💰
7. Cuánto se aleja una muestra según su tamaño
Saca trescientas muestras de cada tamaño y mide, en soles, a qué distancia queda su promedio del promedio real.
for cuantas in [30, 100, 300, 1000]: lejos = np.abs(promedios(lambda s: v.sample(cuantas, random_state=s), veces=300) - POBLACION) print('con %4d filas: se aleja %6.2f soles de media, y hasta %7.2f' % (cuantas, lejos.mean(), lejos.max()))
con 30 filas: se aleja 110.81 soles de media, y hasta 393.56 con 100 filas: se aleja 58.20 soles de media, y hasta 209.94 con 300 filas: se aleja 32.93 soles de media, y hasta 113.40 con 1000 filas: se aleja 14.60 soles de media, y hasta 53.62
Mira la última columna, que es la que asusta 😨
Con treinta filas hubo una muestra que se fue 393 soles del promedio real, o sea casi la mitad del promedio entero. Y no era una muestra mala ni mal sacada: era al azar, como debe ser.
El tamaño no cambia si aciertas, cambia cuánto te puedes equivocar sin darte cuenta. Y fíjate en que pasar de 30 a 1000 filas, que es multiplicar por treinta y tres, solo divide el error entre siete y medio. Por eso conseguir datos cuesta tanto 💸
8. Al azar y estratificada, una al lado de la otra
Saca 120 filas de las dos maneras y compara el reparto de segmentos de cada una contra el reparto real.
reparto = v['segmento'].value_counts(normalize=True).sort_index() una = v.sample(120, random_state=3)['segmento'].value_counts(normalize=True).sort_index() otra = estratificada(120)['segmento'].value_counts(normalize=True).sort_index() print('%-12s %9s %9s %9s' % ('segmento', 'real', 'al azar', 'estrat.')) for seg in reparto.index: print('%-12s %9.3f %9.3f %9.3f' % (seg, reparto[seg], una.get(seg, 0), otra.get(seg, 0)))
segmento real al azar estrat. Bodega 0.236 0.300 0.236 Horeca 0.247 0.200 0.246 Mayorista 0.250 0.258 0.251 Minimarket 0.267 0.242 0.266
La tercera columna clava el reparto hasta el tercer decimal 🎯
Y la del medio no: sacó 30 por ciento de bodegas cuando en realidad son 23,6 por ciento, y se quedó corta de horeca. Con 120 filas eso pasa sin que nadie haga nada mal.
Acuérdate de lo que separa el segmento en estos datos, que lo viste en 1: si la columna que más separa te sale descuadrada en la muestra, el promedio que calcules encima ya nace torcido. Estratificar cuesta una línea y quita ese riesgo 🧺
Comprueba que lo tienes
Tu encuesta a 6.000 clientes dice que el ticket promedio es 1.250 soles, y la contabilidad dice 800. ¿Qué miras primero?
- A quién se le preguntó, y si ese reparto se parece al de tus clientes
- Si 6.000 son suficientes
- Si hay atípicos que estén subiendo el promedio
- Si la encuesta se hizo en un mes raro
Lo que te llevas
- 🎯 Sesgo y variación son dos cosas distintas. Sesgo es apuntar mal, variación es que te tiemble el pulso.
- 📈 Más datos arreglan la variación (de 86 a 11) y no tocan el sesgo (453 y sigue en 452 con 64 veces más filas).
- 🚪 Una muestra de conveniencia da 1.258,98 donde la verdad es 803,76, y todo el cálculo está bien hecho.
- 🧱 Estratificar quita el sesgo y además baja la variación un 43% con el mismo número de encuestas, si conoces el reparto real.
- 🔍 Después de muestrear, cuenta siempre qué hay dentro de la muestra. Ahí se ve lo que falta.
El capítulo 10 le pone un intervalo a lo que mediste, y ahora ya sabes que ese intervalo solo vale si la muestra estaba bien sacada. El sesgo no entra en ningún intervalo.
Y si quieres el vocabulario suelto de todo esto, está definido en dos líneas por término en el glosario de IA 📖
Que tengas lindo día! 🌸
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab y no hay que instalar nada.