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
- 01.Valores nulos (NaN): datos que faltan. El más común y el más traicionero.
- 02.Duplicados: la misma fila repetida. A veces obvio, a veces sutil.
- 03.Tipos incorrectos: una fecha guardada como texto, un número como string.
- 04.Inconsistencias: "Madrid" vs "madrid" vs "MADRID" vs "Madird".
- 05.Outliers: valores extremos que pueden ser errores o datos legítimos.
### 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 pd2import numpy as np34df = 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})1011# Detectar nulos12print(df.isnull().sum()) # Nulos por columna13print(f"Total nulos: {df.isnull().sum().sum()}")14print(f"% filas con algún nulo: {df.isnull().any(axis=1).mean()*100:.1f}%")1516# ESTRATEGIA 1: Eliminar filas con nulos (si son pocas)17df_sin_nulos = df.dropna()1819# ESTRATEGIA 2: Rellenar con un valor por defecto20df['ciudad'] = df['ciudad'].fillna('Desconocida')21df['compras'] = df['compras'].fillna(df['compras'].median())2223# 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 pd23df = 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})910# Detectar duplicados11print(f"Duplicados exactos: {df.duplicated().sum()}")12print(f"Duplicados por pedido_id: {df.duplicated(subset=['pedido_id']).sum()}")1314# Eliminar duplicados exactos15df_limpio = df.drop_duplicates()1617# Eliminar duplicados por clave, quedándose con el primero18df_limpio = df.drop_duplicates(subset=['pedido_id'], keep='first')1920# Eliminar duplicados quedándose con el último (ej: la versión más reciente)21df_limpio = df.drop_duplicates(subset=['pedido_id'], keep='last')2223print(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 pd23df = 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})910print("ANTES:")11print(df.dtypes)1213# Convertir fecha (maneja formatos mixtos)14df['fecha'] = pd.to_datetime(df['fecha'], format='mixed', dayfirst=True, errors='coerce')1516# Convertir importe (primero reemplazar texto, luego convertir)17df['importe'] = pd.to_numeric(df['importe'], errors='coerce')1819# Convertir booleano normalizado20df['activo'] = df['activo'].str.lower().map({'si': True, 'no': False})2122# Código postal como string (para preservar ceros)23df['codigo_postal'] = df['codigo_postal'].astype(str).str.zfill(5)2425print("\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 pd23df = pd.DataFrame({4 'ciudad': [' Madrid ', 'madrid', 'BARCELONA', 'Barcleona', 'Madrid', 'barcelona'],5 'ventas': [100, 200, 150, 80, 300, 250]6})78# Paso 1: strip (quitar espacios) + lower (minúsculas)9df['ciudad_clean'] = df['ciudad'].str.strip().str.lower()1011# Paso 2: corregir typos conocidos con replace/map12correcciones = {13 'barcleona': 'barcelona',14 'madird': 'madrid',15}16df['ciudad_clean'] = df['ciudad_clean'].replace(correcciones)1718# Ahora el groupby funciona bien19print(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 pd2import numpy as np34df = pd.DataFrame({5 'producto': ['A', 'B', 'C', 'D', 'E', 'F', 'G'],6 'precio': [25, 30, 28, 5000, 22, 31, 27] # 5000 es sospechoso7})89# Método IQR (Interquartile Range) — solo Pandas, sin dependencias extra10Q1 = df['precio'].quantile(0.25)11Q3 = df['precio'].quantile(0.75)12IQR = Q3 - Q113limite_inferior = Q1 - 1.5 * IQR14limite_superior = Q3 + 1.5 * IQR1516outliers = df[(df['precio'] < limite_inferior) | (df['precio'] > limite_superior)]17print(f"Outliers detectados: {len(outliers)}")18print(outliers)1920# 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]2526# 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 pd2import numpy as np34def 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 pasan10 filas_iniciales = len(df)1112 # 1. Eliminar duplicados por ID13 df = df.drop_duplicates(subset=['pedido_id'], keep='last')14 print(f" Duplicados eliminados: {filas_iniciales - len(df)}")1516 # 2. Manejar nulos17 # Importe nulo = eliminar (no podemos inventar ventas)18 df = df.dropna(subset=['importe'])19 # Ciudad nula = marcar como desconocida20 df['ciudad'] = df['ciudad'].fillna('Desconocida')2122 # 3. Corregir tipos23 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 parsear2627 # 4. Normalizar texto28 df['ciudad'] = df['ciudad'].str.strip().str.title()29 df['producto'] = df['producto'].str.strip().str.upper()3031 filas_finales = len(df)32 print(f" Resultado: {filas_iniciales} → {filas_finales} filas ({filas_iniciales - filas_finales} eliminadas)")3334 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...