Lección 9 de 29Módulo 2: Pandas - Manipulación de Datos

09. Limpieza de Datos

Maneja valores faltantes, duplicados y datos inconsistentes

25 minutos

Aprende a manejar valores faltantes, duplicados y datos inconsistentes. La limpieza de datos consume el 60-80% del tiempo de un analista profesional.

Valores faltantes (NaN/None)

Detectar valores nulos

import pandas as pd
import numpy as np

# Dataset con valores faltantes
df = pd.DataFrame({
    'producto': ['Laptop', 'Mouse', 'Teclado', None, 'Monitor'],
    'precio': [800, 25, np.nan, 300, 450],
    'stock': [15, np.nan, 30, 20, None],
    'categoria': ['Computadoras', 'Accesorios', 'Accesorios', 'Monitores', 'Monitores']
})

print("Dataset original:")
print(df)

# Detectar nulos por columna
print("\nValores nulos por columna:")
print(df.isnull().sum())

# Porcentaje de nulos
print("\nPorcentaje de nulos:")
print((df.isnull().sum() / len(df)) * 100)

# Verificar si hay algún nulo
print(f"\n¿Hay valores nulos?: {df.isnull().any().any()}")

# Filas con al menos un nulo
filas_con_nulos = df[df.isnull().any(axis=1)]
print(f"\nFilas con valores nulos:\n{filas_con_nulos}")

Eliminar valores nulos

# Eliminar filas con ANY nulo
df_sin_nulos = df.dropna()
print(f"Filas después de dropna(): {len(df_sin_nulos)}")

# Eliminar filas solo si TODAS las columnas son nulas
df_limpio = df.dropna(how='all')

# Eliminar filas con nulos en columnas específicas
df_limpio = df.dropna(subset=['precio'])

# Eliminar columnas con nulos
df_sin_col = df.dropna(axis=1)

# Eliminar filas con más de N nulos
df_limpio = df.dropna(thresh=3)  # Al menos 3 valores no-nulos

Rellenar valores nulos

# Rellenar con un valor específico
df_filled = df.fillna(0)

# Rellenar por columna
df['precio'] = df['precio'].fillna(df['precio'].mean())
df['stock'] = df['stock'].fillna(df['stock'].median())
df['producto'] = df['producto'].fillna('Desconocido')

# Forward fill (usar valor anterior)
df['precio'] = df['precio'].ffill()

# Backward fill (usar valor siguiente)
df['precio'] = df['precio'].bfill()

# Rellenar con diccionario (valores diferentes por columna)
valores_relleno = {
    'precio': 0,
    'stock': 10,
    'producto': 'N/A'
}
df_filled = df.fillna(valores_relleno)

Interpolación

# Interpolar valores numéricos
ventas = pd.Series([100, np.nan, np.nan, 150, np.nan, 200])
ventas_interpoladas = ventas.interpolate()
print(ventas_interpoladas)
# 0    100.0
# 1    116.7  # Interpolado
# 2    133.3  # Interpolado
# 3    150.0
# 4    175.0  # Interpolado
# 5    200.0

Datos duplicados

Detectar duplicados

# Dataset con duplicados
ventas = pd.DataFrame({
    'fecha': ['2024-01-15', '2024-01-16', '2024-01-15', '2024-01-17'],
    'producto': ['Laptop', 'Mouse', 'Laptop', 'Teclado'],
    'cantidad': [2, 10, 2, 5],
    'precio': [800, 25, 800, 60]
})

# Detectar filas duplicadas
duplicados = ventas.duplicated()
print(f"Filas duplicadas:\n{duplicados}")

# Ver las filas duplicadas
print(f"\nFilas duplicadas completas:\n{ventas[duplicados]}")

# Número de duplicados
print(f"\nTotal duplicados: {duplicados.sum()}")

# Duplicados basados en columnas específicas
duplicados_producto = ventas.duplicated(subset=['producto', 'precio'])

Eliminar duplicados

# Eliminar duplicados (mantener primer ocurrencia)
df_unique = ventas.drop_duplicates()

# Mantener última ocurrencia
df_unique = ventas.drop_duplicates(keep='last')

# Eliminar todas las ocurrencias (incluyendo la primera)
df_unique = ventas.drop_duplicates(keep=False)

# Duplicados basados en columnas específicas
df_unique = ventas.drop_duplicates(subset=['producto'])

Limpieza de strings

Métodos str de Pandas

# Dataset con strings sucios
productos = pd.DataFrame({
    'nombre': [
        '  Laptop Pro  ',
        'mouse wireless',
        'TECLADO MECÁNICO',
        'Monitor 27"',
        '  tablet  10"  '
    ],
    'categoria': [
        'Computadoras ',
        ' accesorios',
        'ACCESORIOS',
        'monitores',
        'Tablets '
    ]
})

# Eliminar espacios al inicio/final
productos['nombre'] = productos['nombre'].str.strip()
productos['categoria'] = productos['categoria'].str.strip()

# Convertir a minúsculas
productos['categoria'] = productos['categoria'].str.lower()

# Convertir a mayúsculas
# productos['categoria'] = productos['categoria'].str.upper()

# Title case (primera letra mayúscula)
productos['nombre'] = productos['nombre'].str.title()

print("Productos limpios:")
print(productos)

# Reemplazar texto
productos['nombre'] = productos['nombre'].str.replace('"', ' pulgadas')

# Eliminar caracteres especiales
productos['nombre'] = productos['nombre'].str.replace(r'[^\w\s]', '', regex=True)

Conversión de tipos de datos

# Dataset con tipos incorrectos
datos = pd.DataFrame({
    'fecha': ['2024-01-15', '2024-01-16', '2024-01-17'],
    'precio': ['100.50', '200.75', '150.25'],
    'cantidad': ['10', '20', '15'],
    'activo': ['True', 'False', 'True']
})

print("Tipos originales:")
print(datos.dtypes)

# Convertir a datetime
datos['fecha'] = pd.to_datetime(datos['fecha'])

# Convertir a numérico
datos['precio'] = pd.to_numeric(datos['precio'])
datos['cantidad'] = pd.to_numeric(datos['cantidad'], errors='coerce')

# Convertir a booleano
datos['activo'] = datos['activo'] == 'True'

# Convertir a categorical (eficiente para datos repetitivos)
# datos['categoria'] = datos['categoria'].astype('category')

print("\nTipos convertidos:")
print(datos.dtypes)

Manejo de outliers

# Dataset con outliers
ventas = pd.DataFrame({
    'producto': ['A']*100,
    'precio': np.concatenate([
        np.random.normal(100, 10, 95),  # Datos normales
        [500, 600, 700, 800, 900]       # Outliers
    ])
})

# Método 1: IQR (Interquartile Range)
Q1 = ventas['precio'].quantile(0.25)
Q3 = ventas['precio'].quantile(0.75)
IQR = Q3 - Q1

limite_inferior = Q1 - 1.5 * IQR
limite_superior = Q3 + 1.5 * IQR

outliers = ventas[
    (ventas['precio'] < limite_inferior) |
    (ventas['precio'] > limite_superior)
]
print(f"Outliers detectados: {len(outliers)}")

# Eliminar outliers
ventas_limpio = ventas[
    (ventas['precio'] >= limite_inferior) &
    (ventas['precio'] <= limite_superior)
]

# Método 2: Z-score
from scipy import stats
z_scores = np.abs(stats.zscore(ventas['precio']))
ventas_limpio = ventas[z_scores < 3]  # Mantener solo |z| < 3

# Método 3: Percentiles
p95 = ventas['precio'].quantile(0.95)
ventas_limpio = ventas[ventas['precio'] <= p95]

Proyecto práctico: Limpieza completa

# Dataset realista con problemas de calidad
np.random.seed(42)

datos_sucios = pd.DataFrame({
    'cliente_id': ['C001', 'C002', 'C001', None, 'C003', 'C002', 'C004'],
    'nombre': [
        '  Juan Pérez  ',
        'MARÍA GARCÍA',
        '  Juan Pérez  ',
        'carlos lópez',
        'Ana Martínez',
        'MARÍA GARCÍA',
        None
    ],
    'email': [
        'juan@email.com',
        'MARIA@EMAIL.COM',
        'juan@email.com',
        'carlos@',
        'ana@email.com',
        'maria@email.com',
        'luis@email.com'
    ],
    'edad': ['28', '32', '28', 'N/A', '45', '32', '150'],
    'compra': [100.50, np.nan, 100.50, 80.00, None, 95.00, 250.00],
    'fecha': [
        '2024-01-15',
        '15/01/2024',
        '2024-01-15',
        '2024-01-16',
        '01-17-2024',
        '2024-01-18',
        '2024-01-19'
    ]
})

print("="*70)
print("DATOS ORIGINALES (SUCIOS)")
print("="*70)
print(datos_sucios)
print(f"\nProblemas detectados:")
print(f"  - Valores nulos: {datos_sucios.isnull().sum().sum()}")
print(f"  - Duplicados: {datos_sucios.duplicated().sum()}")

# Proceso de limpieza
print("\n" + "="*70)
print("PROCESO DE LIMPIEZA")
print("="*70)

# 1. Copiar dataset original
datos_limpios = datos_sucios.copy()

# 2. Limpiar strings
print("\n1️⃣ Limpiando strings...")
datos_limpios['nombre'] = datos_limpios['nombre'].str.strip()
datos_limpios['nombre'] = datos_limpios['nombre'].str.title()
datos_limpios['email'] = datos_limpios['email'].str.lower().str.strip()

# 3. Manejar valores inválidos en edad
print("2️⃣ Limpiando edades...")
datos_limpios['edad'] = pd.to_numeric(datos_limpios['edad'], errors='coerce')
datos_limpios.loc[datos_limpios['edad'] > 120, 'edad'] = np.nan
datos_limpios['edad'] = datos_limpios['edad'].fillna(datos_limpios['edad'].median())

# 4. Validar emails
print("3️⃣ Validando emails...")
email_valido = datos_limpios['email'].str.contains('@.*\.', na=False, regex=True)
datos_limpios.loc[~email_valido, 'email'] = np.nan

# 5. Unificar formato de fechas
print("4️⃣ Normalizando fechas...")
datos_limpios['fecha'] = pd.to_datetime(datos_limpios['fecha'], format='mixed', errors='coerce')

# 6. Rellenar valores nulos en compra
print("5️⃣ Rellenando valores de compra...")
datos_limpios['compra'] = datos_limpios['compra'].fillna(datos_limpios['compra'].median())

# 7. Rellenar nombres y cliente_id nulos
print("6️⃣ Manejando nulos en identificadores...")
datos_limpios['nombre'] = datos_limpios['nombre'].fillna('Cliente Desconocido')
datos_limpios['cliente_id'] = datos_limpios['cliente_id'].fillna('CXXX')

# 8. Eliminar duplicados exactos
print("7️⃣ Eliminando duplicados...")
datos_limpios = datos_limpios.drop_duplicates()

print("\n" + "="*70)
print("DATOS LIMPIOS")
print("="*70)
print(datos_limpios)
print(f"\nMejoras logradas:")
print(f"  - Valores nulos restantes: {datos_limpios.isnull().sum().sum()}")
print(f"  - Duplicados eliminados: {len(datos_sucios) - len(datos_limpios)}")
print(f"  - Formatos estandarizados: ✅")

# 9. Reporte de calidad de datos
print("\n" + "="*70)
print("REPORTE DE CALIDAD DE DATOS")
print("="*70)

print(f"\nCompletitud por columna:")
for col in datos_limpios.columns:
    completitud = (1 - datos_limpios[col].isnull().sum() / len(datos_limpios)) * 100
    print(f"  {col}: {completitud:.1f}%")

print(f"\nEstadísticas de edad:")
print(datos_limpios['edad'].describe())

print(f"\nEstadísticas de compra:")
print(datos_limpios['compra'].describe())

Función reutilizable de limpieza

def limpiar_dataset(df):
    """
    Función genérica para limpieza básica de datos.
    """
    print("🧹 Iniciando limpieza de datos...")

    df_limpio = df.copy()
    reporte = {}

    # 1. Eliminar duplicados
    duplicados_antes = df_limpio.duplicated().sum()
    df_limpio = df_limpio.drop_duplicates()
    reporte['duplicados_eliminados'] = duplicados_antes

    # 2. Limpiar strings
    columnas_string = df_limpio.select_dtypes(include=['object']).columns
    for col in columnas_string:
        df_limpio[col] = df_limpio[col].str.strip()

    # 3. Convertir tipos numéricos donde sea posible
    for col in df_limpio.columns:
        try:
            df_limpio[col] = pd.to_numeric(df_limpio[col], errors='ignore')
        except:
            pass

    # 4. Reportar nulos
    nulos_por_col = df_limpio.isnull().sum()
    reporte['columnas_con_nulos'] = nulos_por_col[nulos_por_col > 0].to_dict()

    print("✅ Limpieza completada")
    print(f"\nReporte:")
    print(f"  Duplicados eliminados: {reporte['duplicados_eliminados']}")
    print(f"  Columnas con nulos: {len(reporte['columnas_con_nulos'])}")

    return df_limpio, reporte

# Usar la función
df_limpio, reporte = limpiar_dataset(datos_sucios)

Buenas prácticas

1. Siempre trabajar con una copia

# ❌ Mal: modificar original
df_original.fillna(0, inplace=True)

# ✅ Bien: trabajar con copia
df_limpio = df_original.copy()
df_limpio = df_limpio.fillna(0)

2. Documentar decisiones de limpieza

# Crear log de limpieza
limpieza_log = {
    'fecha_limpieza': pd.Timestamp.now(),
    'registros_originales': len(df_original),
    'registros_finales': len(df_limpio),
    'duplicados_eliminados': len(df_original) - len(df_limpio),
    'estrategia_nulos': 'Median para numéricos, Unknown para categóricos',
    'outliers_removidos': 5
}

import json
with open('limpieza_log.json', 'w') as f:
    json.dump(limpieza_log, f, indent=2, default=str)

3. Validar después de limpiar

def validar_limpieza(df):
    """Validar que la limpieza fue exitosa"""
    assert df.isnull().sum().sum() == 0, "❌ Aún hay valores nulos"
    assert df.duplicated().sum() == 0, "❌ Aún hay duplicados"
    assert len(df) > 0, "❌ DataFrame vacío"
    print("✅ Validación exitosa")

validar_limpieza(df_limpio)

Resumen de la lección

dropna() elimina valores nulos, fillna() los rellena ✅ drop_duplicates() elimina filas duplicadas ✅ Métodos str limpian y estandarizan strings ✅ Detectar outliers con IQR, Z-score o percentiles ✅ Siempre trabajar con copias del dataset original ✅ Documentar decisiones de limpieza para reproducibilidad


🎯 Próxima lección: 10. Transformación de Datos

En la siguiente lección aprenderás apply, map, merge y reshaping de DataFrames. 🚀

Checkpoint de comprensión

3 preguntas para verificar lo aprendido. No afecta tu nota del examen final.

1¿Cómo detectás valores nulos (NaN) en un DataFrame?
2¿Qué método NO es estrategia válida para manejar valores nulos?
3Detectás duplicados en tu dataset. ¿Cómo los eliminás?

¿Completaste esta lección?

Marca esta lección como completada. Tu progreso se guardará en tu navegador.