Capítulo 4 de 29 10 secciones 17 min

Compartir

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.

A mí me costó años aceptar que limpiar es el 80% del trabajo y no una queja. En este archivo son 37 filas repetidas, cuatro maneras distintas de escribir Lima, y 612 montos con coma decimal que se convierten en nulos sin dar ni un error, llevándose S/470.000 por el desagüe. Nada de eso avisa 🧽

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 🧹

Una pregunta antes de abrir nada: ¿cuánto tiempo llevas peleándote con un archivo que alguien más llenó a mano? Porque eso no es mala suerte tuya, es el estado normal de los datos de cualquier empresa 🫠

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 atípicos, y por qué no hay una respuesta

Este es el punto donde más gente aplica una receta sin mirar lo que hace, así que vamos a mirar 🔬

Hay dos formas estándar de marcar un valor como atípico, las dos salen en cualquier tutorial, y las dos son razonables. Aplicadas al mismo monto:

m = pd.to_numeric(ventas['monto'].astype(str).str.replace(',', '.'),
                  errors='coerce').dropna()

q1, q3 = m.quantile([.25, .75])
ri = q3 - q1
por_rango = ((m < q1 - 1.5 * ri) | (m > q3 + 1.5 * ri))

z = (m - m.mean()) / m.std()
por_desviacion = z.abs() > 3

print(f'por el rango intercuartil : {por_rango.sum():4d}  ({por_rango.mean():.2%})')
print(f'por tres desviaciones     : {por_desviacion.sum():4d}  ({por_desviacion.mean():.2%})')
print(f'coinciden en              : {(por_rango & por_desviacion).sum():4d}')
print()
print('mínimo:', round(m.min(), 2), ' máximo:', round(m.max(), 2))
por el rango intercuartil :  203  (6.77%)
por tres desviaciones     :   32  (1.07%)
coinciden en              :   32

mínimo: -2497.72  máximo: 4236.71

203 contra 32. Seis veces más, con los mismos datos y las dos recetas que todo el mundo usa 😳

Y fíjate en la tercera línea: los 32 están todos dentro de los 203. No son dos grupos distintos, es que uno es mucho más estricto que el otro.

El motivo es que el segundo método usa la media y la desviación, y esas dos las mueven los propios atípicos. Un monto de 4.236 estira la desviación, y al estirarla hace que el umbral de "tres desviaciones" se aleje, así que ese mismo monto tiene menos probabilidad de ser marcado. El método se protege solo 🙃

El primero usa cuartiles, que no se mueven por lo que pasa en los extremos. Por eso marca más.

Entonces, ¿cuál es el bueno?

Ninguno. Y esa es la respuesta, no una evasiva 🤷‍♀️

"Atípico" no es una propiedad del dato, es una decisión tuya sobre qué consideras normal en este negocio. Los datos no la contestan porque no la saben.

Lo que sí se puede contestar mirando:

  • 💀 Un monto de −2.497,72 es imposible si una venta no puede ser negativa. Ese no es atípico, es un error, y hay que preguntar qué pasó.
  • 💰 Un monto de 4.236,71 puede ser perfectamente real: un mayorista haciendo un pedido grande. Tirarlo sería tirar justo al cliente que más importa.
  • 📊 Los 203 del rango intercuartil son el 6,8% de la tabla. Si tiras el 6,8% de tus ventas por una fórmula, más vale que sepas a quién tiraste.

Mi orden, que es el único que me ha funcionado: primero separo lo imposible de lo raro, y con lo imposible voy a preguntar. Lo raro se queda y se marca, porque un modelo que no ha visto pedidos grandes no sabe qué hacer cuando llegue uno 🏷️

Y si al final decides recortar, se recorta comprobando: entrena con y sin ellos y mira si el número cambia. Si no cambia, la decisión daba igual y te ahorras defenderla 🧪

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

Y una que corre sin quejarse

La trampa

El clásico de los viernes. Hay nulos en descuento, se rellenan con cero y a otra cosa. Es una línea, es lo que hace todo el mundo, y cuesta dinero.

ventas['descuento'] = (ventas['descuento']
                       .fillna(0))

# "un nulo en descuento es que no hubo
#  descuento, obvio"
Qué está mal

Puede que sea obvio y aun así te acabas de comer la mejor señal del archivo. Las 603 filas sin descuento cierran el 47,93% y las que sí lo traen cierran el 60,15%: doce puntos de diferencia, que es más de lo que consigue el primer modelo entero del capítulo 1 🕳️

Con fillna(0) esas 603 filas pasan a ser indistinguibles de las que tuvieron descuento cero de verdad, y los doce puntos desaparecen sin dejar rastro. Antes de rellenar un hueco se pregunta por qué falta, y si la respuesta importa, el hueco se convierte en columna propia en vez de taparse.

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.

Y si de todo el capítulo te llevas una sola frase, que sea esta:

Los datos sucios no dan error. Por eso hay que ir a buscarlos.

Casi todo lo de este capítulo es pandas puro, así que si alguna línea te costó, el libro de Python desde cero la explica desde el principio 🐍

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! 🌸

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 de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?