Se dice que el 80% del trabajo es limpiar datos y suena a queja. No lo es: es la descripción del oficio, y es donde se gana o se pierde el proyecto 🧹
En este capítulo abrimos el archivo de verdad. Trae 37 filas repetidas, Lima escrita de cuatro maneras, 612 montos que Python no va a leer como números, 773 fechas en otro formato y tres columnas con huecos. Nada de eso da error.
Y al final del capítulo vas a ver la parte que casi nadie mira: que los huecos de este archivo predicen mejor que la mitad de las columnas.
Lo primero, siempre lo mismo
import pandas as pd URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv' ventas = pd.read_csv(URL) print(ventas.shape) print(ventas.dtypes)
(3037, 14) id_venta int64 fecha str cliente_id str ciudad str segmento str canal str categoria str unidades int64 monto str descuento float64 fecha_ultima_compra str satisfaccion float64 compro int64 monto_final_facturado float64 dtype: object
Ahí ya hay dos avisos gritando, y hay que saber leerlos:
- 💸
montodicestr. Una columna de dinero que llega como texto significa que algo trae un carácter raro. - 📅
fechayfecha_ultima_compratambién son texto. Normal al leer un CSV, pero hay que convertirlas y ahí es donde muerde.
Mi regla: si una columna que debería ser número sale como texto, hay suciedad, siempre. Nunca falla.
Las filas repetidas, que se cuelan sin ruido
print('id_venta repetidos:', ventas['id_venta'].duplicated().sum())
print('filas idénticas: ', ventas.duplicated().sum())
id_venta repetidos: 37 filas idénticas: 37
37 ventas cargadas dos veces, enteras. Es lo típico de un proceso que se ejecutó dos veces, y en un reporte de facturación eso son 37 ventas contadas doble.
ventas = ventas.drop_duplicates() print(len(ventas))
3000
3.000 filas redonditas. Y ojo con el orden: quitar duplicados va primero, antes de cualquier cuenta, porque si no todas las tasas que calcules van a estar contaminadas por esas 37.
Cuidado también con lo contrario: si el id_venta se repitiera
pero las filas fueran distintas, eso no es un duplicado, es un problema
más feo y hay que preguntar. Aquí las 37 son idénticas, así que fuera.
Lima, escrita de cuatro formas
print(ventas['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á: lima, Líma, Lima y
LIMA. Con tilde, sin tilde, en mayúsculas y en minúsculas 🫠
Para pandas son cuatro ciudades distintas, así que Lima aparece cuatro veces
en el fondo de la lista en vez de una sola vez arriba. Cualquier
groupby('ciudad') te da un reporte partido, y cualquier modelo se
come cuatro columnas donde debería haber una.
ventas['ciudad'] = (ventas['ciudad'].str.strip().str.lower()
.str.normalize('NFKD')
.str.encode('ascii', 'ignore').str.decode('utf-8'))
print(ventas['ciudad'].value_counts())
ciudad arequipa 546 chiclayo 510 piura 506 cusco 496 trujillo 471 lima 471 Name: count, dtype: int64
Seis ciudades y Lima con 471 ventas. Esa línea hace cuatro cosas y las cuatro hacen falta:
- ✂️
strip()quita espacios de los bordes. - 🔡
lower()unifica mayúsculas. - 🔤
normalize('NFKD')separa la letra de su tilde. - 🧽
encode/decodetira las tildes sueltas.
Y una cosa honesta sobre el resultado: Lima pasa de 123 (su versión más grande) a 471, pero sigue empatada en el último puesto con Trujillo. Limpiar no siempre destapa un gigante escondido. Lo que sí hace siempre es que el número deje de estar mal 🌟
El dinero que se evapora sin avisar
Esta es mi favorita del archivo porque no da error y cuesta plata.
print(ventas['monto'].head(8).tolist())
print('montos con coma:', ventas['monto'].str.contains(',').sum())
['480.37', '524.90', '154.83', '2480.84', '379.40', '314.19', '273.76', '562.46'] montos con coma: 606
606 de las 3.000 vienen con coma decimal, a la peruana. Y ahora mira la forma que sale sola de convertirlas:
monto_mal = pd.to_numeric(ventas['monto'], errors='coerce')
print('se volvieron nulos:', int(monto_mal.isna().sum()))
print('suma:', round(monto_mal.sum(), 2))
se volvieron nulos: 606 suma: 1939749.62
monto_bien = pd.to_numeric(ventas['monto'].str.replace(',', '.'))
print('se volvieron nulos:', int(monto_bien.isna().sum()))
print('suma:', round(monto_bien.sum(), 2))
se volvieron nulos: 0 suma: 2411283.83
S/1.939.750 contra S/2.411.284. Se evaporaron S/471.534, o sea un 19,6% de la facturación, sin un solo error en pantalla 💸
El culpable es ese errors='coerce', que significa "lo que no
puedas convertir, ponlo en nulo". Es cómodo y es peligroso: siempre que
lo uses, cuenta cuántos nulos creó. Si son más de los que esperabas,
ahí está el problema.
Sin el coerce, pandas te lo dice de frente:
ventas['monto'].astype(float)
ValueError: could not convert string to float: '921,04'
could not convert string to float: '921,04'. Te da hasta el valor que le molestó. Mucho mejor que un silencio que te quita medio millón 🙌
Y con eso ya se puede guardar la columna buena:
ventas['monto'] = monto_bien print(ventas['monto'].dtype, '| suma:', round(ventas['monto'].sum(), 2))
float64 | suma: 2411283.83
Dos formatos de fecha en la misma columna
pd.to_datetime(ventas['fecha'], format='%Y-%m-%d')
ValueError: time data "29/06/2025" doesn't match format "%Y-%m-%d". You might want to try:
- passing `format` if your strings have a consistent format;
- passing `format='ISO8601'` if your strings are all ISO8601 but not necessarily in exactly the same format;
- passing `format='mixed'`, and the format will be inferred for each element individually. You might want to use `dayfirst` alongside this.
time data "29/06/2025" doesn't match format "%Y-%m-%d". Hay fechas peruanas mezcladas con fechas ISO.
La forma que funciona es en dos pasadas: primero el formato mayoritario, y después el otro solo para las que quedaron sin parsear.
fecha = pd.to_datetime(ventas['fecha'], format='%Y-%m-%d', errors='coerce')
faltan = fecha.isna()
print('en formato peruano:', int(faltan.sum()))
fecha[faltan] = pd.to_datetime(ventas.loc[faltan, 'fecha'],
format='%d/%m/%Y', errors='coerce')
ventas['fecha'] = fecha
print('sin parsear al final:', int(fecha.isna().sum()))
print('rango:', fecha.min().date(), 'a', fecha.max().date())
en formato peruano: 759 sin parsear al final: 0 rango: 2025-01-01 a 2026-06-24
759 de las 3.000 venían a la peruana y ninguna se quedó fuera.
Lo que no hay que hacer es dejar que pandas adivine fila por
fila. Con 29/06/2025 no hay duda porque no hay mes 29, pero con
07/03/2026 sí: puede ser el 7 de marzo o el 3 de julio, y adivinando
te cambia el trimestre sin decírtelo. El formato se dice, no se
adivina 📅
Los huecos, y la pregunta que casi nadie hace
print(ventas.isna().sum().sort_values(ascending=False).head(4))
descuento 595 fecha_ultima_compra 544 satisfaccion 231 cliente_id 0 dtype: int64
Tres columnas con huecos. Y aquí viene lo importante del capítulo entero.
La reacción normal es rellenarlos con la media y seguir. Casi siempre está mal, porque antes hay que contestar una pregunta: ¿por qué falta?
Hay tres respuestas posibles y tienen nombre feo pero significan cosas simples:
- 🎲 MCAR, falta al azar. Se cayó la conexión, se perdió una fila. Rellenar es razonable.
- 🔗 MAR, falta según otra columna. Por ejemplo, la satisfacción no se pregunta en el canal web. Se puede rellenar mirando esa otra columna.
- 🚨 MNAR, falta según su propio valor, o directamente el hueco significa algo. Aquí rellenar es destruir información.
Vamos a averiguar cuál es cada una, y se hace en tres líneas:
for col in ['descuento', 'fecha_ultima_compra', 'satisfaccion']:
hueco = ventas[col].isna()
print(f'{col:22} sin dato: {ventas.loc[hueco, "compro"].mean():.4f}'
f' con dato: {ventas.loc[~hueco, "compro"].mean():.4f}')
descuento sin dato: 0.4807 con dato: 0.6017 fecha_ultima_compra sin dato: 0.4136 con dato: 0.6140 satisfaccion sin dato: 0.6190 con dato: 0.5742
Léelo despacio porque es el hallazgo del capítulo:
- 💰 Sin descuento se cierra el 48,1%; con descuento, el 60,2%. Doce puntos.
- 🆕 Sin fecha de última compra se cierra el 41,4%; con fecha, el 61,4%. Veinte puntos.
- 😐 La satisfacción vacía casi no cambia nada: 61,9% contra 57,4%.
Los dos primeros huecos no son datos que faltan, son datos en sí mismos. Que no haya descuento significa que no se ofreció ninguno. Que no haya fecha de última compra significa que es un cliente nuevo, y por eso cierra veinte puntos menos.
Si rellenas con la media, borras la señal más fuerte del archivo 😱 Lo que hay que hacer es lo contrario: convertir el hueco en una columna, y eso es el capítulo 9.
Ejercicios
Siete sobre el archivo ya cargado. Intenta antes de abrir 💛
1. La radiografía de las numéricas
Resumen de las columnas numéricas, para cazar rarezas.
print(ventas[['unidades', 'monto', 'descuento', 'satisfaccion']].describe().round(3))
unidades monto descuento satisfaccion count 3000.000 3000.000 2405.000 2769.000 mean 11.601 803.761 0.125 3.010 std 5.775 751.989 0.072 1.421 min 1.000 -2497.720 0.000 1.000 25% 7.000 252.690 0.063 2.000 50% 12.000 533.630 0.127 3.000 75% 15.000 1068.685 0.185 4.000 max 30.000 4236.710 0.250 5.000
Lo que yo miro en esa tabla, en este orden: que count sea el
mismo en todas (aquí no lo es, y eso son los huecos), que min no
sea negativo donde no puede serlo, y que max no sea absurdo.
El descuento va de 0 a 0,25, que suena a política comercial. La satisfacción de 1 a 5, correcto. Las unidades de 1 a 30, correcto.
Y el monto tiene un mínimo de -2.497,72 😳
Una venta negativa. Eso no se puede pasar por alto, y es el ejercicio siguiente 🔍
2. Los montos extremos
Las cinco ventas más grandes y las cinco más chicas.
print('más grandes:', ventas['monto'].nlargest(5).round(2).tolist())
print('más chicas :', ventas['monto'].nsmallest(5).round(2).tolist())
más grandes: [4236.71, 4175.88, 3948.89, 3751.02, 3609.78] más chicas : [-2497.72, -2399.6, -976.24, -603.83, -494.7]
Por arriba, hasta S/4.236: grandes pero posibles, y esos no se tocan. En ventas, los montos grandes suelen ser los clientes que más importan, y borrarlos "por ser outliers" es tirar justo lo que interesa.
Por abajo es otra historia: los cinco más chicos son negativos. Vamos a mirarlos.
negativos = ventas['monto'] < 0
print('cuántos:', int(negativos.sum()))
print('tasa de cierre en esas filas:', round(ventas.loc[negativos, 'compro'].mean(), 4))
print('tasa en el resto: ', round(ventas.loc[~negativos, 'compro'].mean(), 4))
cuántos: 21 tasa de cierre en esas filas: 0.0 tasa en el resto: 0.5817
Veintiuna filas, y las veintiuna tienen compro = 0.
Ni una sola excepción.
Eso ya no es un valor raro: es una pista de que el signo negativo lo puso el sistema después de que la venta se cayera, probablemente como anulación o devolución. O sea que esa columna, en esas filas, está contando el futuro.
Un modelo aprendería "monto negativo, no compra" y acertaría el 100% de esas veintiuna, y el día que tenga que predecir de verdad no habrá ningún negativo porque todavía no se anuló nada. Eso es fuga de información y tiene el capítulo 12 entero 🚨
La lección del ejercicio no es la regla "quita los negativos". Es esta: cuando un valor raro predice perfectamente, no es un outlier, es una fuga. Y se caza mirando la tasa del objetivo dentro de esas filas, que son dos líneas 💛
3. Cuánto pesa cada categoría
Cuántas filas y qué tasa de cierre tiene cada categoría de producto.
print(ventas.groupby('categoria')['compro'].agg(['count', 'mean']).round(4))
count mean categoria Abarrotes 640 0.5906 Bebidas 565 0.5788 Cuidado personal 606 0.5594 Limpieza 587 0.5877 Snacks 602 0.5714
Cinco categorías bien repartidas y todas entre 55% y 59%. Esta columna casi no separa, y saberlo ahora te ahorra sorpresas después.
No significa que haya que tirarla: significa que no esperes que sea la estrella del modelo 📦
4. El cruce que sí importa
Tasa de cierre por segmento y canal a la vez.
tabla = ventas.pivot_table(index='segmento', columns='canal',
values='compro', aggfunc='mean').round(3)
print(tabla)
canal Marketplace Tienda Web WhatsApp segmento Bodega 0.276 0.387 0.385 0.455 Horeca 0.505 0.759 0.631 0.658 Mayorista 0.637 0.712 0.737 0.819 Minimarket 0.416 0.598 0.571 0.692
Ahí se ve algo que ninguna columna sola te decía: Mayorista por WhatsApp cierra 0,833 y Bodega por Marketplace 0,265. Más de cincuenta puntos entre las dos esquinas.
Eso se llama interacción: el efecto del canal depende del segmento. Los árboles y los bosques del capítulo 13 las encuentran solos, y la regresión logística no, hay que dárselas hechas 🔀
5. Cuántos clientes son nuevos
Qué porcentaje de las ventas son de clientes sin compra anterior.
nuevos = ventas['fecha_ultima_compra'].isna()
print('ventas de clientes nuevos:', int(nuevos.sum()), f'({nuevos.mean():.1%})')
print('tasa de cierre:', round(ventas.loc[nuevos, 'compro'].mean(), 4))
ventas de clientes nuevos: 544 (18.1%) tasa de cierre: 0.4136
Un 18% de las ventas son de clientes nuevos y cierran veinte puntos menos. Es una conclusión de negocio completa sin haber entrenado nada: lo caro no es vender, es la primera venta 🆕
6. El error de contar antes de limpiar
Suma la columna de montos tal como viene del CSV, sin convertirla.
crudo = pd.read_csv(URL) print(len(crudo['monto'].sum()))
18972
18.972. Eso no son soles, son caracteres 😳
Sumar una columna de texto en pandas pega los textos uno detrás de otro, y
como no hay error, alguien puede meter ese número en una diapositiva. Es la
razón por la que dtypes se mira antes que nada.
7. La limpieza completa, en un bloque
Junta todo lo del capítulo en algo que puedas copiar al inicio de cualquier cuaderno.
def carga_limpia(url):
"""Lee el archivo y deja los tipos como deben ser."""
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[['monto', 'fecha', 'fecha_ultima_compra']].dtypes)
(3000, 14) monto float64 fecha datetime64[us] fecha_ultima_compra datetime64[us] dtype: object
Ese falta = f.isna() & v[col].notna() tiene truco y vale la
pena mirarlo: solo reintenta las que tenían texto y no se pudieron
convertir. Sin esa segunda condición, intentaría reparsear también los huecos de
verdad, y esos tienen que seguir siendo huecos 🕳️
Esta función es la que vamos a usar al principio de todos los capítulos que vienen.
Comprueba que lo tienes
La columna monto llega como texto y to_numeric con errors=coerce la convierte sin quejarse. ¿Qué revisas?
- Cuántos valores se volvieron nulos en la conversión
- Nada, si no dio error está bien
- El tipo final de la columna
- La media, para ver si es razonable
Lo que te llevas
- 🔍
dtypesantes que nada. Una columna de dinero que sale como texto siempre esconde suciedad. - 👯 Los duplicados se quitan primero: aquí eran 37 filas enteras.
- 🧽 Cuatro formas de escribir Lima son cuatro ciudades para pandas. Se arregla con strip, lower y normalize.
- 💸
errors='coerce'convierte lo que no entiende en nulos, sin avisar. Aquí se llevaba S/470.000. Cuenta siempre los nulos que crea. - 📅 El formato de fecha se dice, no se adivina, y se pasa en dos pasadas si hay dos formatos.
- 🕳️ Antes de rellenar un hueco, pregunta por qué falta. Aquí, "sin fecha de última compra" vale veinte puntos de tasa de cierre.
- 🔀 Las tablas cruzadas encuentran lo que ninguna columna sola dice: de 0,265 a 0,833 según segmento y canal.
En el capítulo 9 convertimos todo lo que acabamos de descubrir en columnas que un modelo pueda usar, y ahí es donde de verdad se gana.
Que tengas lindo día! 🌸