Módulo 09 — Limpiar datos: nulos, duplicados, tipos y texto

Los datos reales vienen sucios. Faltan valores, hay filas repetidas, los números llegan como texto, las fechas en cinco formatos distintos y los nombres con espacios de sobra. Limpiar es el 80% del trabajo real con datos, no una nota a pie de página. Este módulo te da las herramientas.

El dataset sucio de trabajo

import pandas as pd
import numpy as np

df = pd.DataFrame({
    "nombre":   ["  Ada", "Luis", "MARTA", "Juan", "Ada", "Eva ", None],
    "edad":     [20, 22, None, 23, 20, 19, 25],
    "ciudad":   ["Madrid", "sevilla", "Madrid", "Bilbao", "Madrid", "Sevilla", "Bilbao"],
    "salario":  ["1.200", "1.500", "2.000", None, "1.200", "1.350", "1.800"],
})

Tiene de todo: espacios, mayúsculas inconsistentes, un nulo, una fila duplicada y una columna numérica guardada como texto.

Detectar valores faltantes

Pandas representa los huecos como NaN (Not a Number) o None. Primero los localizas:

df.isnull()           # tabla de True/False: True donde falta
df.isnull().sum()     # cuántos nulos por columna  <- el comando clave
df.isnull().sum().sum()  # total de nulos en toda la tabla

df.isnull().sum() es lo primero que ejecutas con cualquier dataset nuevo. Te dice de un vistazo dónde están los agujeros:

# nombre     1
# edad       1
# ciudad     0
# salario    1

Tratar los faltantes: eliminar o rellenar

Tienes dos estrategias. Eliminar las filas (o columnas) con huecos:

df.dropna()                    # elimina filas con ALGÚN nulo
df.dropna(subset=["edad"])     # elimina solo si falta la edad
df.dropna(axis=1)              # elimina columnas con nulos (rara vez se quiere)

O rellenar los huecos con un valor sensato:

df["edad"].fillna(df["edad"].mean())   # rellena con la media
df["edad"].fillna(0)                   # rellena con un fijo
df["ciudad"].fillna("Desconocida")     # para texto
df["edad"].fillna(df["edad"].median()) # la mediana resiste mejor los valores extremos

¿Cuál elegir? Depende. Si te sobran datos y faltan pocos, elimina. Si cada fila cuenta, rellena con un valor que no distorsione (la mediana para números suele ser más prudente que la media). No hay regla universal; piensa qué tiene sentido para tus datos.

Cuidado: muchos métodos de Pandas no modifican el DataFrame original, devuelven una copia. Para que el cambio persista, reasigna:

df["edad"] = df["edad"].fillna(df["edad"].median())

Eliminar duplicados

df.duplicated()         # marca True las filas repetidas (a partir de la 2ª)
df.duplicated().sum()   # cuántas hay
df = df.drop_duplicates()             # elimina filas idénticas
df = df.drop_duplicates(subset=["nombre"])  # duplicados según una columna

Antes de buscar duplicados, conviene normalizar el texto: “Ada” y “ Ada” con espacio no se consideran iguales hasta que limpias los espacios. Por eso el orden importa: primero normaliza, luego deduplica.

Limpiar texto con .str

El accesor .str aplica métodos de cadena a la columna entera. Es tu mejor amigo para normalizar:

df["nombre"] = df["nombre"].str.strip()       # quita espacios de los extremos
df["nombre"] = df["nombre"].str.title()        # Primera Letra En Mayúscula
df["ciudad"] = df["ciudad"].str.capitalize()   # solo la primera del texto
df["ciudad"] = df["ciudad"].str.lower()        # todo minúsculas
df["nombre"] = df["nombre"].str.replace(".", "", regex=False)

Tras strip() y title(), “ Ada”, “Ada” y todo lo demás convergen a “Ada”, y ahora sí puedes deduplicar bien.

Convertir tipos: cuando los números llegan como texto

La columna salario tiene valores como "1.200": es texto, no puedes sumarlo ni promediarlo. Hay que convertirlo. Primero quitas el separador de miles, luego conviertes:

df["salario"] = df["salario"].str.replace(".", "", regex=False)  # "1.200" -> "1200"
df["salario"] = pd.to_numeric(df["salario"], errors="coerce")     # texto -> número

pd.to_numeric con errors="coerce" convierte lo que puede y pone NaN en lo que no (en vez de reventar). Es la forma robusta. Para tipos concretos también tienes astype:

df["edad"] = df["edad"].astype("int")      # a entero (falla si hay NaN)
df["salario"] = df["salario"].astype("float")
df["ciudad"] = df["ciudad"].astype("category")  # para columnas con pocos valores repetidos

astype("category") ahorra memoria cuando una columna tiene pocos valores distintos repetidos muchas veces (ciudades, categorías, sí/no).

Renombrar columnas

Las columnas de datos reales vienen con nombres horribles: espacios, mayúsculas, acentos. Renómbralas:

df = df.rename(columns={"salario": "salario_eur", "edad": "edad_anios"})

# O reemplazar todos los nombres de golpe:
df.columns = ["nombre", "edad", "ciudad", "salario"]

# Truco para normalizar todos a la vez:
df.columns = df.columns.str.strip().str.lower().str.replace(" ", "_")

Reemplazar y mapear valores

Para sustituir valores concretos o traducir categorías:

df["ciudad"] = df["ciudad"].replace("Madird", "Madrid")   # corregir un error
df["nivel"] = df["edad"].map(lambda x: "joven" if x < 21 else "adulto")

map aplica una función a cada valor de la columna. Aquí usamos una función lambda (una función anónima de una línea): lambda x: ... es una mini-función sin nombre, cómoda para transformaciones rápidas. Para reglas con diccionario:

traduccion = {"DAW": "Desarrollo Web", "ASIR": "Sistemas", "DAM": "Multiplataforma"}
df["ciclo_largo"] = df["ciclo"].map(traduccion)

apply: transformaciones a medida

Cuando la transformación es más compleja, apply ejecuta una función sobre cada elemento de una columna (o cada fila):

def clasificar(nota):
    if nota >= 9:   return "Sobresaliente"
    elif nota >= 7: return "Notable"
    elif nota >= 5: return "Aprobado"
    else:           return "Suspenso"

df["calificacion"] = df["nota"].apply(clasificar)

apply es flexible pero más lento que las operaciones vectorizadas. Si puedes hacer algo con &, | o aritmética de columnas, hazlo así; reserva apply para lo que no se puede vectorizar fácil.

Una rutina de limpieza típica, de principio a fin

# 1. Inspeccionar
print(df.info())
print(df.isnull().sum())

# 2. Normalizar texto
df["nombre"] = df["nombre"].str.strip().str.title()
df["ciudad"] = df["ciudad"].str.strip().str.capitalize()

# 3. Convertir tipos
df["salario"] = pd.to_numeric(df["salario"].str.replace(".", "", regex=False),
                              errors="coerce")

# 4. Tratar nulos
df["edad"] = df["edad"].fillna(df["edad"].median())
df = df.dropna(subset=["nombre"])

# 5. Eliminar duplicados
df = df.drop_duplicates()

# 6. Comprobar
print(df.isnull().sum())
print(df.dtypes)

Este esqueleto te sirve para casi cualquier dataset. Adáptalo y guárdalo.


Ejercicios

Parte del df sucio del principio del módulo.

09.1 — Cuenta cuántos valores nulos hay en cada columna y cuántos en total.

09.2 — Normaliza la columna nombre: quita los espacios y pon cada nombre con la primera letra en mayúscula. Luego comprueba cuántos nombres únicos quedan.

09.3 — Normaliza la columna ciudad para que todas tengan el mismo formato (primera letra mayúscula, resto minúscula). ¿Cuántas ventas hay por ciudad después?

09.4 — Convierte la columna salario a números de verdad (quita el punto de los miles y conviértela a numérica). Calcula el salario medio.

09.5 — Rellena los valores faltantes de edad con la mediana de la columna, y los de salario con su media.

09.6 — Tras normalizar el texto, elimina las filas duplicadas. ¿Cuántas filas quedan?

09.7 — Crea una columna franja que clasifique la edad en “menor de 21” o “21 o más”, usando apply o map.

09.8 — Elimina las filas donde más del 30% de las columnas son nulas. Usa el parámetro thresh de dropna calculando el mínimo de valores no nulos requeridos.

09.9 — Normaliza una columna numérica con min-max: (x - min) / (max - min). Hazlo con operaciones vectorizadas y comprueba que el mínimo resultante es 0.0 y el máximo es 1.0.

09.10 — Tienes una columna con teléfonos mal formateados (" 666-123 456", "(+34) 666.123.456"). Crea una columna telefono_limpio con solo los dígitos, usando .str.replace con regex.


Soluciones

09.1

print(df.isnull().sum())
print("Total:", df.isnull().sum().sum())   # 3

09.2

df["nombre"] = df["nombre"].str.strip().str.title()
print(df["nombre"].nunique())   # cuenta nombres únicos (los None no cuentan)

09.3

df["ciudad"] = df["ciudad"].str.strip().str.capitalize()
print(df["ciudad"].value_counts())
# Madrid     3
# Bilbao     2
# Sevilla    2

09.4

df["salario"] = df["salario"].str.replace(".", "", regex=False)
df["salario"] = pd.to_numeric(df["salario"], errors="coerce")
print(df["salario"].mean())   # ~1508.33 (ignora el NaN)

09.5

df["edad"] = df["edad"].fillna(df["edad"].median())
df["salario"] = df["salario"].fillna(df["salario"].mean())
print(df.isnull().sum())

09.6

df["nombre"] = df["nombre"].str.strip().str.title()
df = df.drop_duplicates()
print(len(df))

Tras normalizar, “ Ada” y “Ada” pasan a ser iguales, así que la fila repetida de Ada se elimina.


09.7

df["franja"] = df["edad"].apply(lambda x: "menor de 21" if x < 21 else "21 o más")
print(df[["edad", "franja"]])

09.8

n_columnas = df.shape[1]
min_no_nulos = int(n_columnas * 0.7)
df_limpio = df.dropna(thresh=min_no_nulos)
print(f"Filas antes: {len(df)}, después: {len(df_limpio)}")

09.9

col = df["salario"]
normalizada = (col - col.min()) / (col.max() - col.min())
df["salario_norm"] = normalizada
print(f"Mín: {normalizada.min():.1f}, Máx: {normalizada.max():.1f}")
# Mín: 0.0, Máx: 1.0

09.10

df["telefono_limpio"] = df["telefono"].str.replace(r"[^\d]", "", regex=True)
print(df[["telefono", "telefono_limpio"]].head())

[^\d] elimina cualquier carácter que no sea dígito.