Limpieza de datos
La limpieza convierte un dump sucio en una tabla fiable: nulos tratados, tipos correctos, duplicados resueltos y reglas de negocio aplicadas de forma explicita. Sin este paso, groupby y joins solo amplifican el ruido.
Diagnostico inicial
import pandas as pd
orders = pd.read_csv("orders.csv")
orders.shape
orders.info()
orders.isna().sum().sort_values(ascending=False)
orders.duplicated().sum()
orders.describe(include="all")Preguntas utiles:
- Que porcentaje de nulos tiene cada columna?
- Hay claves duplicadas (
order_id)? - Que columnas son
objecty deberian ser numero o fecha? - Hay valores centinela (
-1,9999,"?") ademas deNaN?
Nulos
Detectar
orders.isna()
orders["email"].isna().mean() # fraccion nula
orders.dropna(how="all") # filas totalmente vaciasEliminar
# Solo si la columna es critica y el nulo invalida la fila
orders = orders.dropna(subset=["order_id", "amount", "created_at"])Imputar
orders["country"] = orders["country"].fillna("UNKNOWN")
# Numerico: mediana suele ser mas robusta que la media
orders["amount"] = orders["amount"].fillna(orders["amount"].median())
# Forward / backward fill en series ordenadas (con cuidado)
ts = orders.sort_values("created_at")
ts["temperature"] = ts["temperature"].ffill()No imputes a ciegas: documenta la regla. A veces el nulo es senal (cliente sin email) y conviene un flag:
orders["has_email"] = orders["email"].notna()
orders["email"] = orders["email"].fillna("")Duplicados
orders.duplicated(subset=["order_id"]).sum()
# Ver conflictos
orders[orders.duplicated(subset=["order_id"], keep=False)].sort_values("order_id")
# Conservar la ultima version por timestamp
orders = (
orders
.sort_values("updated_at")
.drop_duplicates(subset=["order_id"], keep="last")
)Define la clave de negocio antes de deduplicar. Filas identicas completas no son lo mismo que misma clave con payloads distintos.
Tipos y valores sucios
orders["amount"] = (
orders["amount"]
.astype("string")
.str.replace(",", ".", regex=False)
.str.replace(r"[^\d.\-]", "", regex=True)
)
orders["amount"] = pd.to_numeric(orders["amount"], errors="coerce")
orders["created_at"] = pd.to_datetime(orders["created_at"], errors="coerce", utc=True)
orders["country"] = (
orders["country"]
.astype("string")
.str.strip()
.str.upper()
.replace({"": pd.NA, "ESP": "ES", "SPA": "ES"})
)Centinelas:
orders["age"] = orders["age"].replace([-1, 999], pd.NA)Espacios, mayusculas y categorias
for col in ["status", "channel"]:
orders[col] = orders[col].astype("string").str.strip().str.lower()
allowed_status = {"paid", "pending", "refunded", "cancelled"}
bad = ~orders["status"].isin(allowed_status) & orders["status"].notna()
print(orders.loc[bad, "status"].value_counts())
orders["status"] = orders["status"].where(orders["status"].isin(allowed_status), pd.NA)
orders["status"] = orders["status"].astype("category")Outliers
No borres extremos sin contexto. Primero inspecciona:
q1 = orders["amount"].quantile(0.25)
q3 = orders["amount"].quantile(0.75)
iqr = q3 - q1
low, high = q1 - 1.5 * iqr, q3 + 1.5 * iqr
suspect = orders[~orders["amount"].between(low, high)]
print(suspect[["order_id", "amount", "country"]].head(20))Opciones: corregir errores de unidad (centimos vs euros), capar (winsorize), o dejarlos y usar metricas robustas (mediana).
Validaciones como contrato
def validate_orders(df: pd.DataFrame) -> pd.DataFrame:
required = ["order_id", "amount", "created_at", "country"]
missing = set(required) - set(df.columns)
if missing:
raise ValueError(f"Faltan columnas: {sorted(missing)}")
if df["order_id"].isna().any():
raise ValueError("order_id nulo")
if df["order_id"].duplicated().any():
raise ValueError("order_id duplicado")
if (df["amount"].dropna() < 0).any():
raise ValueError("amount negativo")
return df
orders = validate_orders(orders)En pipelines, falla rapido ante violaciones duras; registra filas dudosas en un fichero de rechazo en lugar de silenciarlas.
Pipeline de limpieza reproducible
def clean_orders(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
out.columns = [c.strip().lower() for c in out.columns]
out["amount"] = pd.to_numeric(out["amount"], errors="coerce")
out["created_at"] = pd.to_datetime(out["created_at"], errors="coerce", utc=True)
out["country"] = out["country"].astype("string").str.strip().str.upper()
out = out.dropna(subset=["order_id", "amount", "created_at"])
out = (
out.sort_values("created_at")
.drop_duplicates(subset=["order_id"], keep="last")
)
return validate_orders(out)
orders_clean = clean_orders(pd.read_csv("orders.csv"))
orders_clean.to_parquet("orders_clean.parquet", index=False)Errores habituales
fillna(0)en importes donde el nulo significa "desconocido", no cero.- Deduplicar sin ordenar por fecha de actualizacion.
- Borrar outliers sin mirar si son errores de escala.
- Limpiar en celdas sueltas del notebook sin funcion reutilizable.
- Convertir a categoria antes de normalizar strings (acabas con 50 variantes de "ES").
Buenas practicas
- Separa diagnostico, limpieza y validacion.
- Escribe funciones puras
clean_*/validate_*con tests sobre fixtures pequenas. - Guarda el dataset limpio en Parquet y no re-limpies desde CSV cada vez.
- Loguea cuantas filas entran, cuantas salen y cuantas se rechazan.
- Documenta cada regla (por que se imputa, por que se dropea).
Ejercicios
- Genera un CSV con nulos, duplicados de
order_id, amounts como"1.200,50"y paisesesp/ES. - Implementa
clean_ordersque normalice tipos, paises y deduplique. - Anade
validate_orderscon al menos tres reglas. - Calcula el porcentaje de filas descartadas y guardalo en un dict de metricas.
- Exporta limpio a Parquet y un CSV
rejects.csvcon las filas invalidas.
Siguiente paso
Con tablas limpias, el capitulo 6 cubre agregaciones, groupby y joins entre DataFrames.
