En el capítulo 1 vimos que las ventas se mueven 14,41% al mes, y que había nueve ciudades donde tenía que haber seis 🙃
Este capítulo arregla eso y contesta una pregunta que parece de manual y es de las más prácticas del libro: ¿qué puedo calcular con cada columna?
Porque tu computadora no lo sabe. Le pides la media de lo que sea y te la da. El problema es que la mitad de esas medias no significan nada, y no hay ningún mensaje de error que te avise 😶
Primero la limpieza, que si no todo lo demás miente
Tres cosas por orden.
Uno: las filas repetidas. Se van primero, porque una fila duplicada cuenta dos veces en todas las medidas que vengan después.
import pandas as pd
URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'
v = pd.read_csv(URL)
print('filas:', len(v), '| duplicadas enteras:', v.duplicated().sum())
v = v.drop_duplicates()
print('quedan:', len(v))
filas: 3037 | duplicadas enteras: 37 quedan: 3000
37 filas idénticas. No es que se parezcan: son la misma venta escrita dos veces, con el mismo identificador y todo.
Dos: las cuatro Limas.
print(v['ciudad'].value_counts())
ciudad Arequipa 546 Chiclayo 510 Piura 506 Cusco 496 Trujillo 471 lima 123 Líma 118 Lima 118 LIMA 112 Name: count, dtype: int64
Ahí está la trampa entera: lima, Líma,
Lima y LIMA. Para ti son la misma ciudad. Para pandas
son cuatro textos distintos, y ninguno se parece más al otro que a
Piura.
Se arregla en una línea encadenada:
v['ciudad'] = (v['ciudad'].str.strip().str.lower()
.str.normalize('NFKD')
.str.encode('ascii', 'ignore').str.decode('utf-8'))
print(v['ciudad'].value_counts())
ciudad arequipa 546 chiclayo 510 piura 506 cusco 496 trujillo 471 lima 471 Name: count, dtype: int64
Seis ciudades 🎉 Y Lima pasa de aparentar 123 ventas a tener 471, que la pone empatada con Trujillo.
Lo que hace cada trozo, por si te lo preguntas: strip quita
espacios de los bordes, lower pasa todo a minúscula, y ese
normalize('NFKD') con el encode/decode es el truco que
convierte í en i. Descompone la letra acentuada en
letra más tilde y luego tira lo que no sea ASCII.
Tres: los números que llegaron como texto.
print(v.dtypes.head(9))
id_venta int64 fecha str cliente_id str ciudad str segmento str canal str categoria str unidades int64 monto str dtype: object
monto es str, o sea texto. Y ya sabemos por qué:
trae comas decimales al estilo peruano, y pandas ante la duda no toca nada.
v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
print(v['monto'].dtype, '| media: %.2f' % v['monto'].mean())
float64 | media: 803.76
Los cuatro tipos, con lo que puedes hacer con cada uno
Ahora la parte que te va a servir para el resto del libro. Cada columna es de uno de estos cuatro tipos 👇
| Tipo | Qué es | En este archivo | Qué puedes calcular |
|---|---|---|---|
| Nominal | Categorías sin orden | ciudad, segmento, canal, categoria | Frecuencias y moda. Nada más. |
| Ordinal | Categorías con orden, pero sin distancia fija | satisfaccion (1 a 5) | Mediana, percentiles, moda |
| Discreta | Números que se cuentan, sin partes | unidades | Todo, y la media puede salir con decimales |
| Continua | Cualquier valor dentro de un rango | monto, descuento | Todo: media, desviación, correlación |
La frontera que más se discute es la de ordinal. Satisfacción va de 1 a 5 y tiene orden clarísimo: 5 es mejor que 4. Pero la distancia no es fija. Pasar de 1 a 2 no es lo mismo, para el cliente, que pasar de 4 a 5. Y como no lo es, sumar y dividir es discutible.
Yo la uso así, y te lo digo como criterio práctico y no como ley: la mediana de satisfacción es la medida honesta; la media se puede reportar al lado, pero no se pelea por dos décimas 🤷
Ahora el error de verdad
Pídele a pandas la media de una columna de texto:
v['ciudad'].mean()
TypeError: Cannot perform reduction 'mean' with string dtype
Perfecto, ¿no? Te para en seco. Ese error es tu amigo.
El problema es el caso de al lado, que es el mismo disparate y no para nada:
print(v[['id_venta', 'unidades', 'monto', 'satisfaccion', 'compro']]
.describe().round(2))
id_venta unidades monto satisfaccion compro count 3000.00 3000.00 3000.00 2769.00 3000.00 mean 11499.50 11.60 803.76 3.01 0.58 std 866.17 5.77 751.99 1.42 0.49 min 10000.00 1.00 -2497.72 1.00 0.00 25% 10749.75 7.00 252.69 2.00 0.00 50% 11499.50 12.00 533.63 3.00 1.00 75% 12249.25 15.00 1068.68 4.00 1.00 max 12999.00 30.00 4236.71 5.00 1.00
Mira la primera columna. La media de id_venta es
11499.50.
Eso es el número de factura promedio. No existe. No significa nada. No se puede hacer nada con él. Y ahí está, con dos decimales, tan formal como los demás 😅
id_venta es un identificador: son números por comodidad, pero
funcionan como nombres. Es tan nominal como ciudad. La diferencia
es que ciudad se escribe con letras y por eso pandas te frena, y este se escribe
con dígitos y por eso pandas calcula.
La lección: que una cuenta salga no quiere decir que signifique algo. El único que sabe qué es cada columna eres tú.
De paso, en esa misma tabla hay dos cosas más que vale la pena que veas:
- 💸 El mínimo de
montoes -2497.72. Hay ventas negativas. Las miramos en el capítulo 6. - 🕳️ El
countdesatisfacciones 2769 y no 3000. Faltan 231 valores, y la media se calculó ignorándolos sin decir ni pío.
Cómo se detecta el tipo cuando no conoces los datos
Truco de campo, y de los que más uso: cuenta cuántos valores distintos tiene cada columna 🔎
print(v.nunique().sort_values())
compro 2 canal 4 segmento 4 categoria 5 satisfaccion 5 ciudad 6 unidades 30 descuento 251 fecha_ultima_compra 399 cliente_id 617 fecha 937 monto_final_facturado 1723 monto 2964 id_venta 3000 dtype: int64
Léelo de arriba abajo y el archivo se te ordena solo:
- 🔢 Lo de arriba, con 2 a 6 valores distintos, es categórico. Da igual que
comproysatisfaccionestén escritos con números. - 📈 Lo de abajo, con cientos o miles de valores distintos, es continuo o identificador.
- 🚩 Y hay una bandera roja evidente:
id_ventatiene 3000 valores distintos en 3000 filas. Una columna con tantos valores distintos como filas es un identificador, siempre.
Una media que sí significa algo, y mucho
Para que no te quedes con que las medias de columnas 0/1 son sospechosas, mira este caso, que es justo al revés:
print('media de compro: %.4f' % v['compro'].mean())
print('cuantos compraron: %d de %d' % (v['compro'].sum(), len(v)))
print('proporcion: %.4f' % (v['compro'].sum() / len(v)))
media de compro: 0.5777 cuantos compraron: 1733 de 3000 proporcion: 0.5777
La media de una columna que solo tiene ceros y unos es la proporción. Exactamente el mismo número.
Y eso es utilísimo: cada vez que quieras un porcentaje de "cuántos cumplen X", conviertes a 0/1 y pides la media. Lo vamos a usar en todo el libro, y en el capítulo 11 esa proporción es la que se compara entre grupos.
O sea que compro es nominal, su media técnicamente no debería
significar nada, y significa muchísimo. La regla no es mecánica: es que
tienes que saber qué representa el número 🧠
Practica 💪
1. ¿Cuántos clientes hay de verdad?
El archivo tiene 3.000 ventas. ¿De cuántos clientes distintos? ¿Y cuántas ventas hace un cliente típico?
print('clientes distintos:', v['cliente_id'].nunique())
print('ventas por cliente: %.2f' % (len(v) / v['cliente_id'].nunique()))
print(v['cliente_id'].value_counts().describe().round(2))
clientes distintos: 617 ventas por cliente: 4.86 count 617.00 mean 4.86 std 2.19 min 1.00 25% 3.00 50% 5.00 75% 6.00 max 14.00 Name: count, dtype: float64
617 clientes y 3.000 ventas: 4,86 ventas por cliente.
Esto cambia cómo hay que leer el archivo entero, y es de las cosas que más se pasan por alto. Las 3.000 filas no son 3.000 clientes independientes: hay uno que aparece 14 veces.
Cuando el mismo cliente sale muchas veces, sus filas se parecen entre ellas más de lo normal, y eso hace que cualquier prueba estadística crea que tiene más información de la que tiene. Es una de las cosas que invalidan análisis y sale en el capítulo 14.
2. Caza las categóricas disfrazadas de números
Escribe algo que recorra las columnas numéricas y te avise de cuáles son sospechosas de ser categorías.
for col in v.select_dtypes('number').columns:
distintos = v[col].nunique()
aviso = ' <-- categorica disfrazada' if distintos <= 10 else ''
print('%-24s %5d distintos%s' % (col, distintos, aviso))
id_venta 3000 distintos unidades 30 distintos monto 2964 distintos descuento 251 distintos satisfaccion 5 distintos <-- categorica disfrazada compro 2 distintos <-- categorica disfrazada monto_final_facturado 1723 distintos
Caza las dos que buscábamos. Y fíjate que unidades, con 30
valores, se queda fuera: es discreta de verdad, no una categoría.
El umbral de 10 es mío y es discutible, que quede claro. Sirve como primer filtro para mirar, no como sentencia. Lo que este código hace es ahorrarte abrir 14 columnas a mano 🙌
3. La media que ignora los huecos
La columna descuento tiene vacíos. Calcula su
media, y luego calcúlala tratando los vacíos como cero. ¿Cuál está bien?
print('filas totales: ', len(v))
print('descuentos con dato:', v['descuento'].count())
print('media ignorando huecos: %.4f' % v['descuento'].mean())
print('media con huecos = 0: %.4f' % v['descuento'].fillna(0).mean())
filas totales: 3000 descuentos con dato: 2405 media ignorando huecos: 0.1252 media con huecos = 0: 0.1004
12,52% contra 10,04%. Una diferencia de dos puntos y medio, decidida enteramente por lo que supongas de 595 celdas vacías.
¿Cuál está bien? Depende de qué signifique el vacío, y eso no está en los datos. Si el sistema deja el campo en blanco cuando no hubo descuento, el vacío es un cero y la buena es 10,04%. Si el campo se llena siempre y esos 595 se perdieron al migrar, el vacío es un desconocido y la buena es 12,52%.
Lo que no puedes hacer es no decidir. Porque mean() decide por
ti: ignora los nulos en silencio y te devuelve 12,52% sin avisarte de que se
saltó el 20% de las filas.
Esto se pregunta a quien mantiene el sistema. Es la clase de pregunta que distingue a alguien que analiza datos de alguien que ejecuta celdas 😌
4. Fabrica una variable continua: la recencia
Con fecha y fecha_ultima_compra,
calcula cuántos días hacía que ese cliente no compraba. Mira bien el resultado
antes de darlo por bueno.
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
dias = (v['fecha'] - v['fecha_ultima_compra']).dt.days
print(dias.describe().round(2))
print('negativos:', (dias < 0).sum())
count 2456.00 mean 72.82 std 190.33 min -385.00 25% -65.00 50% 69.00 75% 213.25 max 522.00 dtype: float64 negativos: 904
Aquí hay que parar 🛑
904 valores negativos. O sea 904 ventas donde la "última compra" del cliente ocurrió después de la venta que estamos mirando.
Eso no es un dato raro, es un dato imposible tal como lo estamos leyendo. La explicación más probable es que la columna no sea "la compra anterior a esta" sino "la última compra registrada del cliente hasta hoy", que es un dato del cliente y no de la venta. Con ese significado los negativos son normales: la venta que miramos es vieja y el cliente volvió después.
La conclusión práctica es dura y es la correcta: esta columna no sirve como recencia y hay que preguntar antes de usarla. Si la metes en un análisis tal cual, la mitad de tus filas dice que el cliente compró hace -65 días.
Lo que hicimos aquí, mirar el describe() de una variable recién
creada, es lo que evitó publicarlo. Cuesta diez segundos y salva informes 🙏
5. Ordinal: ¿media o mediana?
Compara la satisfacción entre segmentos con las dos medidas. ¿Cuál cuenta mejor lo que pasa?
print(v.groupby('segmento')['satisfaccion']
.agg(['mean', 'median', 'count']).round(4))
mean median count segmento Bodega 2.9603 3.0 655 Horeca 3.0332 3.0 692 Mayorista 2.9927 3.0 684 Minimarket 3.0474 3.0 738
Las medias van de 2,96 a 3,05. Las medianas son todas 3.
Y las dos están contando lo mismo: aquí no pasa nada. La satisfacción no distingue segmentos.
Fíjate en lo fácil que sería hacer el ridículo con esto. Con las medias en la mano, alguien escribe "Minimarket es nuestro segmento más satisfecho, con 3,05 frente al 2,96 de Bodega". Suena a hallazgo. Son nueve centésimas en una escala de 1 a 5, con setecientas filas por grupo.
La mediana, al ser toda 3, te lo deja más difícil de contar mal. Por eso me gusta para ordinales: no te deja fingir precisión que no tienes 🎯
En el capítulo 12 le ponemos número a esto y sale que la diferencia es minúscula incluso comparada con lo que varía la satisfacción dentro de un mismo segmento.
6. La columna que solo existe si la venta se cerró
Saca la media de monto_final_facturado sobre
todas las filas y luego solo sobre las que compraron. Explica la diferencia.
print('media sobre las 3000 filas: %.2f' % v['monto_final_facturado'].mean())
print('media solo sobre los que compraron: %.2f'
% v.loc[v['compro'] == 1, 'monto_final_facturado'].mean())
print('cuantos ceros:', (v['monto_final_facturado'] == 0).sum())
media sobre las 3000 filas: 481.26 media solo sobre los que compraron: 833.10 cuantos ceros: 1267
481 contra 833. Casi el doble, y las dos son "la media de la misma columna" 🫠
Lo que pasa es que 1.267 filas tienen un cero, y esos ceros no son ventas de
cero soles: son ventas que no ocurrieron. La columna solo se llena cuando
compro vale 1.
El número que un negocio quiere oír, el ticket promedio, es el de 833,10, y se calcula solo sobre las ventas cerradas. El 481,26 no es el ticket de nada: es una mezcla de tickets y de no-ventas.
Y ojo con esta columna, porque tiene otra trampa peor que la usamos en el libro de machine learning: como solo existe si la venta se cerró, meterla en un modelo que predice si la venta se va a cerrar es hacer trampa. Se llama fuga de información y está contada en el capítulo de fuga del libro de ML.
7. La limpieza completa, en una función
Junta todo lo del capítulo en algo que puedas pegar al inicio de cualquier cuaderno.
def carga_limpia(url):
"""Lee el archivo y deja cada columna en su tipo."""
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
limpio = carga_limpia(URL)
print(limpio.shape)
print(limpio['ciudad'].nunique(), 'ciudades |',
limpio['monto'].dtype, '| fechas:', limpio['fecha'].dtype)
(3000, 14) 6 ciudades | float64 | fechas: datetime64[us]
Esta función es la que abre todos los capítulos que vienen. Cópiala 📌
Fíjate en ese falta = f.isna() & v[col].notna(): solo
reintenta las fechas que tenían texto y no se pudieron convertir. Sin
la segunda condición intentaría rellenar también los huecos de verdad, y esos
tienen que seguir siendo huecos 🕳️
Comprueba que lo tienes
describe() te devuelve que la media de id_venta es 11499,50. ¿Qué haces?
- Nada, es un identificador y esa media no significa nada
- La reporto como el número de factura promedio
- Reviso si hay un error en los datos
- La uso para detectar facturas fuera de rango
Lo que te llevas
- 🧹 Duplicados primero, textos normalizados después, tipos al final. En ese orden, porque cada paso ensucia al siguiente si se hace al revés.
- 🏙️ Cuatro formas de escribir Lima son cuatro ciudades para el software. strip, lower y normalize lo arreglan en una línea.
- 📊 Nominal solo admite frecuencias y moda; ordinal, mediana; discreta y continua, todo.
- 🤖 El software no conoce esa diferencia.
describe()te dará la media de un número de factura y se quedará tan tranquilo. - 🔎
nunique()es el mejor detector de tipos que hay. Pocos valores distintos, categoría; tantos valores como filas, identificador. - 0️⃣ La media de una columna de ceros y unos es la proporción, y esa sí significa muchísimo.
- 🕳️
mean()ignora los nulos sin avisar. Aquí eran 595 celdas y movían el descuento medio dos puntos y medio.
Qué viene ahora
En el capítulo 3 nos metemos con la medida más usada y peor usada del mundo: la media. Con un caso donde la media dice 803 soles, la mediana dice 533 y la diferencia entre las dos es una decisión de negocio 💰