lección 2
Limpiar datos: el 80% del trabajo que nadie te cuenta
NaN, duplicados, tipos incorrectos, outliers — bienvenido al trabajo real del profesional de datos.
⏱ 30 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 escribiendo la consulta perfecta. No presentando conclusiones brillantes. 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 tus manos 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 proceso de carga 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 proceso a mitad, 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 el trabajo 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.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...
Regístrate para guardar tu progreso.