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 ingeniero de datos.

60 min

### La verdad incómoda de la ingeniería de datos

Voy a decirte algo que ningún tutorial de YouTube te dice: el 80% de tu tiempo como ingeniero 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.

## ejercicios

[01]

Limpieza completa de un dataset de ventas

Te dan un DataFrame sucio con todos los problemas: nulos, duplicados, tipos mal y texto inconsistente. Aplica los 4 pasos de limpieza y muestra cuántas filas sobreviven.

💡 Resultado esperado

Filas finales: 4
   pedido_id      cliente  importe      fecha       ciudad
0          1          Ana    150.5 2024-01-15       Madrid
1          2       Carlos    200.0 2024-01-16    Barcelona
Cargando editor...
[02]

Detectar outliers en precios de productos

El equipo de pricing sospecha que hay errores en la base de datos de precios. Usa el método IQR para detectar outliers en la columna precio y marca las filas sospechosas con una columna booleana "es_outlier".

💡 Resultado esperado

Outliers detectados: 1
   producto  precio  es_outlier
6  Sudadera    9999        True
Cargando editor...
[03]

Crear tu función de limpieza

Escribe una función limpiar_clientes() que reciba un DataFrame con columnas (nombre, email, ciudad, fecha_registro) y aplique: strip+title a nombre, strip+lower a email, strip+title a ciudad con fillna, y to_datetime a fecha_registro. Debe retornar el DataFrame limpio.

💡 Resultado esperado

         nombre             email       ciudad fecha_registro
0    Ana García      ana@mail.com       Madrid     2024-01-15
1  Carlos López  carlos@gmail.com  Desconocida     2024-02-15
2         Lucía    lucia@yahoo.es    Barcelona     2024-03-20
Cargando editor...

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...