Saltar al contenido

lección 3

Limpiar datos: el 80% del trabajo que nadie te cuenta

NaN, duplicados, tipos incorrectos, outliers — bienvenido al trabajo real del profesional de datos.

60 min

### La verdad incómoda del trabajo con datos

Voy a decirte algo que ningún tutorial de YouTube te dice: el 80% de tu tiempo como profesional de datos lo pasarás limpiando datos. No diseñando arquitecturas elegantes. No optimizando Spark. No escribiendo DAGs sofisticados. Limpiando. Datos. Sucios. Acostúmbrate, porque esto es lo que separa a un junior de un senior: el senior ya sabe que los datos SIEMPRE vienen rotos y tiene un arsenal de técnicas para arreglarlos rápido.

Los datos del mundo real son un desastre porque los generan humanos y sistemas imperfectos. Un formulario web que permite dejar campos vacíos. Un sistema legacy que escribe "N/A" en lugar de NULL. Un proveedor que cambia el formato de fecha a mitad de archivo. Un empleado que escribe "Madrid", "madrid", "MADRID" y "Madird" en la misma columna. Todo esto llega a tu pipeline y es TU problema resolverlo.

### Los 5 jinetes del apocalipsis de datos sucios

  1. 01.Valores nulos (NaN): datos que faltan. El más común y el más traicionero.
  2. 02.Duplicados: la misma fila repetida. A veces obvio, a veces sutil.
  3. 03.Tipos incorrectos: una fecha guardada como texto, un número como string.
  4. 04.Inconsistencias: "Madrid" vs "madrid" vs "MADRID" vs "Madird".
  5. 05.Outliers: valores extremos que pueden ser errores o datos legítimos.
Los 4 pasos de limpieza que aplicarás en CADA dataset que toques

### Jinete 1: Valores nulos (NaN)

NaN significa "Not a Number" pero en Pandas se usa para CUALQUIER valor ausente, no solo números. Es la forma de Pandas de decir "aquí no hay dato". El problema: NaN es contagioso. Si sumas 5 + NaN = NaN. Si haces la media de [10, 20, NaN], Pandas ignora el NaN (devuelve 15), pero si haces operaciones manuales puedes propagar NaN sin darte cuenta.

1import pandas as pd
2import numpy as np
3
4df = pd.DataFrame({
5 'cliente': ['Ana', 'Carlos', None, 'Pedro', 'Ana'],
6 'email': ['ana@mail.com', None, 'lucia@mail.com', 'pedro@mail.com', 'ana@mail.com'],
7 'compras': [5, 12, None, 3, 5],
8 'ciudad': ['Madrid', 'Barcelona', 'Madrid', None, 'Madrid']
9})
10
11# Detectar nulos
12print(df.isnull().sum()) # Nulos por columna
13print(f"Total nulos: {df.isnull().sum().sum()}")
14print(f"% filas con algún nulo: {df.isnull().any(axis=1).mean()*100:.1f}%")
15
16# ESTRATEGIA 1: Eliminar filas con nulos (si son pocas)
17df_sin_nulos = df.dropna()
18
19# ESTRATEGIA 2: Rellenar con un valor por defecto
20df['ciudad'] = df['ciudad'].fillna('Desconocida')
21df['compras'] = df['compras'].fillna(df['compras'].median())
22
23# ESTRATEGIA 3: Rellenar con la media/mediana (solo numéricos)
24df['compras'] = df['compras'].fillna(df['compras'].median())

Tres estrategias para manejar nulos: eliminar, rellenar con constante o con estadístico

¿Cuándo dropna y cuándo fillna? Si el porcentaje de nulos es bajo (<5%) y las filas no son críticas, dropna. Si perderías demasiados datos o si la columna tiene un valor por defecto lógico (ej: ciudad="Desconocida"), fillna. NUNCA rellenes con 0 una columna de ventas — eso falsea las estadísticas. Usa la mediana, que es robusta a outliers.

### Jinete 2: Duplicados

Los duplicados aparecen por muchas razones: el sistema reintentó un insert, un ETL se ejecutó dos veces, o el usuario hizo doble click en "Enviar pedido". Los duplicados exactos son fáciles de detectar. Los duplicados parciales (mismo cliente con email ligeramente distinto) son más complicados.

1import pandas as pd
2
3df = pd.DataFrame({
4 'pedido_id': [1, 2, 3, 2, 4, 3],
5 'cliente': ['Ana', 'Carlos', 'Lucía', 'Carlos', 'Pedro', 'Lucía'],
6 'importe': [100, 250, 75, 250, 180, 75],
7 'fecha': ['2024-01-15', '2024-01-16', '2024-01-16', '2024-01-16', '2024-01-17', '2024-01-16']
8})
9
10# Detectar duplicados
11print(f"Duplicados exactos: {df.duplicated().sum()}")
12print(f"Duplicados por pedido_id: {df.duplicated(subset=['pedido_id']).sum()}")
13
14# Eliminar duplicados exactos
15df_limpio = df.drop_duplicates()
16
17# Eliminar duplicados por clave, quedándose con el primero
18df_limpio = df.drop_duplicates(subset=['pedido_id'], keep='first')
19
20# Eliminar duplicados quedándose con el último (ej: la versión más reciente)
21df_limpio = df.drop_duplicates(subset=['pedido_id'], keep='last')
22
23print(f"Antes: {len(df)} filas → Después: {len(df_limpio)} filas")

Detectar y eliminar duplicados: exactos y por clave

### Jinete 3: Tipos incorrectos

Este es sutil pero destructivo. Pandas a veces infiere mal los tipos: una columna de fechas queda como string, un precio como object porque alguna celda tiene "N/A", un ID numérico que debería ser string. Los tipos incorrectos hacen que filtros, ordenaciones y cálculos fallen silenciosamente.

1import pandas as pd
2
3df = pd.DataFrame({
4 'fecha': ['2024-01-15', '2024-02-20', '15/03/2024', '2024-04-10'],
5 'importe': ['1500.50', '2300', 'N/A', '890.25'],
6 'activo': ['si', 'no', 'si', 'SI'],
7 'codigo_postal': [28001, 8001, 41001, 28020]
8})
9
10print("ANTES:")
11print(df.dtypes)
12
13# Convertir fecha (maneja formatos mixtos)
14df['fecha'] = pd.to_datetime(df['fecha'], format='mixed', dayfirst=True, errors='coerce')
15
16# Convertir importe (primero reemplazar texto, luego convertir)
17df['importe'] = pd.to_numeric(df['importe'], errors='coerce')
18
19# Convertir booleano normalizado
20df['activo'] = df['activo'].str.lower().map({'si': True, 'no': False})
21
22# Código postal como string (para preservar ceros)
23df['codigo_postal'] = df['codigo_postal'].astype(str).str.zfill(5)
24
25print("\nDESPUÉS:")
26print(df.dtypes)
27print(df)

Conversión de tipos: to_datetime, to_numeric, map y astype

Un caso que duele especialmente: una columna de IDs enteros con un solo nulo. Pandas la convierte entera a float64, y ves 4237.0 en vez de 4237 — un id que deja de emparejar en un JOIN. El arreglo: df["id"] = df["id"].astype("Int64") (con I mayúscula). Int64 es el entero nullable de Pandas: admite nulos sin convertir la columna a decimal. Búscalo siempre que veas un id como float.

El parámetro errors="coerce" es tu amigo y tu enemigo. Convierte valores no parseables a NaN silenciosamente. Es útil para no romper el pipeline, pero PELIGROSO si no verificas después cuántos NaN generó. Siempre haz df["columna"].isnull().sum() después de una conversión con coerce.

### Jinete 4: Inconsistencias en texto

El humano es impredecible al escribir texto. La misma ciudad aparece como "Barcelona", "barcelona", "BARCELONA", " Barcelona " (con espacios) y "Barcleona" (typo). Si agrupas por ciudad sin normalizar, tendrás 5 grupos en vez de 1. La solución: normalizar SIEMPRE el texto antes de usarlo.

1import pandas as pd
2
3df = pd.DataFrame({
4 'ciudad': [' Madrid ', 'madrid', 'BARCELONA', 'Barcleona', 'Madrid', 'barcelona'],
5 'ventas': [100, 200, 150, 80, 300, 250]
6})
7
8# Paso 1: strip (quitar espacios) + lower (minúsculas)
9df['ciudad_clean'] = df['ciudad'].str.strip().str.lower()
10
11# Paso 2: corregir typos conocidos con replace/map
12correcciones = {
13 'barcleona': 'barcelona',
14 'madird': 'madrid',
15}
16df['ciudad_clean'] = df['ciudad_clean'].replace(correcciones)
17
18# Ahora el groupby funciona bien
19print(df.groupby('ciudad_clean')['ventas'].sum())

Normalización de texto: strip + lower + corrección de typos

### Jinete 5: Outliers

Un outlier es un valor que se sale de lo normal. Un pedido de 500.000€ cuando la media es 50€. Una edad de 200 años. Un timestamp del año 1970 (epoch reset). El dilema: ¿es un error de datos o un evento real? Un pedido de 500K puede ser un cliente corporativo legítimo. Necesitas contexto de negocio para decidir.

1import pandas as pd
2import numpy as np
3
4df = pd.DataFrame({
5 'producto': ['A', 'B', 'C', 'D', 'E', 'F', 'G'],
6 'precio': [25, 30, 28, 5000, 22, 31, 27] # 5000 es sospechoso
7})
8
9# Método IQR (Interquartile Range) — solo Pandas, sin dependencias extra
10Q1 = df['precio'].quantile(0.25)
11Q3 = df['precio'].quantile(0.75)
12IQR = Q3 - Q1
13limite_inferior = Q1 - 1.5 * IQR
14limite_superior = Q3 + 1.5 * IQR
15
16outliers = df[(df['precio'] < limite_inferior) | (df['precio'] > limite_superior)]
17print(f"Outliers detectados: {len(outliers)}")
18print(outliers)
19
20# Alternativa: Z-score manual (sin scipy, solo numpy)
21media = df['precio'].mean()
22std = df['precio'].std()
23df['z_score'] = np.abs((df['precio'] - media) / std)
24outliers_z = df[df['z_score'] > 3]
25
26# Decidir: ¿eliminar o marcar?
27df['es_outlier'] = (df['precio'] < limite_inferior) | (df['precio'] > limite_superior)

Detección de outliers con IQR y Z-score manual (solo Pandas + NumPy, sin dependencias extra)

Consejo de senior: NUNCA elimines outliers automáticamente sin revisar. He visto equipos borrar las ventas del Black Friday porque "eran outliers". Marca los outliers con una columna booleana, investiga manualmente los primeros 10, y decide con el equipo de negocio. El contexto lo es todo.

### Función de limpieza reutilizable

En un pipeline real, no limpias datos una vez: los limpias cada día cuando llega el archivo nuevo. Por eso encapsulas la lógica en una función reutilizable. Este es el patrón que usarás en CADA proyecto:

1import pandas as pd
2import numpy as np
3
4def limpiar_ventas(df: pd.DataFrame) -> pd.DataFrame:
5 """
6 Limpia el DataFrame de ventas aplicando los 4 pasos estándar.
7 Retorna un DataFrame limpio y loguea las transformaciones.
8 """
9 df = df.copy() # Nunca modifiques el DataFrame que te pasan
10 filas_iniciales = len(df)
11
12 # 1. Eliminar duplicados por ID
13 df = df.drop_duplicates(subset=['pedido_id'], keep='last')
14 print(f" Duplicados eliminados: {filas_iniciales - len(df)}")
15
16 # 2. Manejar nulos
17 # Importe nulo = eliminar (no podemos inventar ventas)
18 df = df.dropna(subset=['importe'])
19 # Ciudad nula = marcar como desconocida
20 df['ciudad'] = df['ciudad'].fillna('Desconocida')
21
22 # 3. Corregir tipos
23 df['fecha'] = pd.to_datetime(df['fecha'], errors='coerce')
24 df['importe'] = pd.to_numeric(df['importe'], errors='coerce')
25 df = df.dropna(subset=['fecha', 'importe']) # Los que no se pudieron parsear
26
27 # 4. Normalizar texto
28 df['ciudad'] = df['ciudad'].str.strip().str.title()
29 df['producto'] = df['producto'].str.strip().str.upper()
30
31 filas_finales = len(df)
32 print(f" Resultado: {filas_iniciales}{filas_finales} filas ({filas_iniciales - filas_finales} eliminadas)")
33
34 return df.reset_index(drop=True)

Función de limpieza reutilizable: el patrón que aplicarás en cada pipeline

SIEMPRE documenta cuántas filas pierdes en cada paso de limpieza. Si pierdes más del 10% de datos, algo está mal: o los datos de origen son peores de lo que crees, o tu limpieza es demasiado agresiva. En ambos casos, necesitas hablarlo con el equipo.

Regístrate para guardar tu progreso.

## comentarios

Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.

Inicia sesión para comentar y responder.

cargando comentarios...