Capítulo 4 de 25 8 secciones 13 min

Abrir el archivo y pelearse con la suciedad

37 filas repetidas, Lima escrita de cuatro formas, medio millón de soles que se evaporan sin dar error, y unos huecos que predicen.

La limpieza es el 80% del trabajo y no es una queja: aquí son 37 filas repetidas, cuatro formas de escribir Lima, 612 montos con coma decimal que errors="coerce" convierte en nulos llevándose S/470.000 sin dar error, y 773 fechas en formato peruano. Y lo que casi nadie mira: antes de rellenar un hueco hay que preguntar por qué falta, porque aquí "sin fecha de última compra" vale veinte puntos de tasa de cierre.

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.

Mapa de datos faltantes de cinco columnas: cada fila del conjunto es una franja y las franjas magenta son huecos. Debajo de cada columna, el porcentaje que falta. Las que más faltan son edad y distrito.
Este mapa es lo primero que dibujo al abrir un conjunto nuevo, y toma una línea. Lo que se busca no es el porcentaje: es si los huecos de dos columnas caen en las mismas filas, porque eso significa que faltan por una razón y no por descuido.

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:

  • 💸 monto dice str. Una columna de dinero que llega como texto significa que algo trae un carácter raro.
  • 📅 fecha y fecha_ultima_compra tambié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/decode tira 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 tres tipos de datos faltantes: MCAR cuando falta al azar, MAR cuando depende de otra columna y MNAR cuando depende de su propio valor. En MNAR el hecho de faltar es información y hay que marcarlo con una bandera en vez de borrarlo.
Antes de rellenar un dato faltante hay que preguntarse por qué falta. Que un cliente no tenga fecha de última compra no es un hueco que tapar: es el dato más importante del conjunto.

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

  • 🔍 dtypes antes 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! 🌸

¿Tienes alguna duda o consulta?