Capítulo 9 de 20 8 secciones 14 min

De dónde salen los datos que estás mirando

Muestreo aleatorio, de conveniencia y estratificado, medidos sobre la misma población. Y por qué más datos no arreglan una muestra torcida.

Todo lo que viene después en este libro supone que tu muestra representa a la población, y casi nunca lo comprueba nadie. Yo lo medí sobre las mismas ventas: una encuesta hecha a quien está más a mano da un ticket de 1.258,98 cuando el real es 803,76. Y recoger más datos no lo arregla, lo empeora 🎯

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? 🐣

De los mismos 3.000 pedidos, cuyo ticket real es 803,76, salen tres muestras: al azar da 803,28 con variación 50,10; preguntando a quien el vendedor visita da 1.258,98 con variación 61,38; y respetando el peso de cada segmento da 804,68 con variación 28,62.
Las tres muestras tienen 200 pedidos y las tres están bien calculadas. Lo único que cambia es quién entró, y eso vale 455 soles en la rama del medio.

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 sacaCuándoQué le pasa
Aleatorio simpleCuando tienes la lista completa de clientesSin sesgo. Es el patrón contra el que se compara todo
EstratificadoCuando hay grupos muy distintos y sabes su peso realSin sesgo y con menos variación. El mejor si puedes
Por conglomeradosCuando visitar cuesta: eliges 3 ciudades y encuestas todo dentroBarato, y con más variación. Vale si eliges los conglomerados al azar
Por convenienciaCuando preguntas a quien está a mano, que es lo que pasa casi siempreSesgo 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.

¿Tienes alguna duda o consulta?