Al cerrar el módulo anterior prometimos poner la estadística a trabajar sobre datos reales e imperfectos, y aquí cumplimos esa promesa. Los datasets de MercaFresh —como los de cualquier empresa real— llegan con duplicados, errores de captura, valores imposibles y categorías escritas de tres formas distintas. Ningún algoritmo del módulo 4 puede compensar un dato de entrada corrupto: si entrenamos el modelo de churn con clientes duplicados o edades negativas, aprenderá patrones que no existen. En esta lección aprenderás a auditar un dataset, detectar sus problemas y corregirlos de forma sistemática con pandas. Es, según la mayoría de profesionales, la fase donde se invierte más tiempo de un proyecto de ML: hacerla bien marca la diferencia entre un modelo útil y uno engañoso.

Contenido

  1. Por qué los datos reales están sucios
  2. El dataset de clientes de MercaFresh
  3. Auditoría inicial: conocer el terreno
  4. Duplicados: exactos y lógicos
  5. Errores de captura y valores imposibles
  6. Inconsistencias de texto y categorías
  7. Tipos de datos incorrectos
  8. Outliers: detectarlos y decidir qué hacer

Por qué los datos reales están sucios

Los datasets de los tutoriales vienen limpios; los de las empresas, no. Las causas son siempre parecidas:

  • Entrada manual: un teleoperador de MercaFresh teclea "BCN" un día y "Barcelona" al siguiente.
  • Integración de sistemas: la web, la app móvil y el call center guardan los datos con formatos distintos (fechas 2025-03-14 vs 14/03/2025).
  • Errores de software: un bug registró durante dos semanas importes de pedido a 0 €.
  • Migraciones históricas: al cambiar de CRM en 2023, algunos clientes se importaron dos veces.
  • Valores centinela: alguien usó -1 o 999 para decir "no lo sé", y ahora parecen datos reales.

La consecuencia práctica se resume en la frase clásica garbage in, garbage out: un modelo entrenado con basura produce predicciones basura, pero con apariencia de precisión matemática. Por eso la limpieza no es un trámite, sino parte del análisis.

flowchart LR
    A[Datos crudos] --> B[Auditoría]
    B --> C[Duplicados]
    C --> D[Valores imposibles]
    D --> E[Texto y categorías]
    E --> F[Tipos de datos]
    F --> G[Outliers]
    G --> H[Dataset limpio]

Este es el orden que seguiremos: primero entender, luego corregir de lo más obvio a lo más sutil.

El dataset de clientes de MercaFresh

Trabajaremos con un extracto ficticio pero realista de la tabla de clientes, exportada del CRM para el proyecto de churn. Vamos a construirlo en código para que puedas reproducir todo lo que sigue:

import pandas as pd
import numpy as np

datos = {
    "id_cliente": [101, 102, 102, 104, 105, 106, 107, 108, 109, 110],
    "nombre": ["Ana Ruiz", "Luis Gil", "Luis Gil", "Marta Vega", "Joan Pons",
               "Sara Mora", "Pau Serra", "Eva Lima", "Leo Cano", "Iris Bou"],
    "edad": [34, 29, 29, -5, 41, 38, 127, 45, 31, 27],
    "ciudad": ["Barcelona", "BCN", "BCN", " barcelona ", "Valencia",
               "VALENCIA", "Madrid", "madrid", "Sevilla", "Barcelona"],
    "gasto_total": ["1250.50", "890.00", "890.00", "0", "2100.75",
                    "310.20", "15400.00", "670.40", "0", "980.10"],
    "fecha_alta": ["2023-05-10", "2024-01-15", "2024-01-15", "2023-11-02",
                   "2022-07-30", "2027-03-01", "2021-02-14", "2023-09-19",
                   "2024-06-05", "2023-12-28"],
    "num_pedidos": [18, 12, 12, 0, 25, 4, 210, 9, 0, 14],
}

df = pd.DataFrame(datos)
print(df)

A simple vista ya se intuyen problemas: el cliente 102 aparece dos veces, hay una edad de -5 y otra de 127, la ciudad "Barcelona" está escrita de cuatro maneras, el gasto es texto y hay una fecha de alta en 2027 (¡en el futuro!). En un dataset de 10 filas los ves de un vistazo; en uno de 200.000 necesitas método.

Auditoría inicial: conocer el terreno

Antes de tocar nada, hay que radiografiar el dataset. Tres funciones de pandas cubren el 80 % de la auditoría inicial:

# 1) Estructura: columnas, tipos y valores no nulos
df.info()

info() responde tres preguntas: ¿cuántas filas hay?, ¿de qué tipo es cada columna?, ¿cuántos valores no nulos tiene cada una? Aquí descubrimos el primer problema silencioso: gasto_total es de tipo object (texto), no numérico. Un object donde esperabas números es una alarma roja.

# 2) Resumen estadístico de las columnas numéricas
print(df.describe())

describe() nos da media, desviación, mínimo, máximo y cuartiles — exactamente los estadísticos del módulo 2 (lección 02-01). Su gran utilidad en limpieza es mirar min y max: una edad mínima de -5 y máxima de 127 no necesitan más análisis para saber que algo va mal.

# 3) Cardinalidad: cuántos valores distintos tiene cada columna
print(df.nunique())
print(df["ciudad"].unique())

nunique() cuenta valores distintos por columna. Si sabemos que MercaFresh opera en 4 ciudades pero ciudad tiene 7 valores únicos, hay inconsistencias de escritura. unique() nos las enseña: ['Barcelona' 'BCN' ' barcelona ' 'Valencia' 'VALENCIA' 'Madrid' 'madrid' 'Sevilla'].

Función Pregunta que responde Problema que destapa
info() ¿Qué tipos y cuántos nulos? Tipos incorrectos, columnas incompletas
describe() ¿Qué rangos toman los números? Valores imposibles, escalas extrañas
nunique() / unique() ¿Cuántas categorías distintas? Categorías duplicadas por escritura

Duplicados: exactos y lógicos

Duplicados exactos

Un duplicado exacto es una fila idéntica a otra en todas sus columnas. En MercaFresh, el cliente 102 (Luis Gil) se importó dos veces durante la migración del CRM:

# ¿Cuántas filas duplicadas exactas hay?
print(df.duplicated().sum())        # 1

# Verlas (keep=False muestra TODAS las copias, no solo la segunda)
print(df[df.duplicated(keep=False)])

# Eliminarlas conservando la primera aparición
df = df.drop_duplicates()
print(len(df))                      # 9 filas

Puntos clave de drop_duplicates:

  • keep="first" (por defecto) conserva la primera copia; keep="last", la última; keep=False elimina todas.
  • Devuelve un DataFrame nuevo: hay que reasignarlo (df = df.drop_duplicates()).

¿Por qué importa para el modelo de churn? Un cliente duplicado pesa el doble en el entrenamiento: el modelo "verá" dos veces sus patrones y sesgará sus conclusiones hacia ese perfil.

Duplicados lógicos

Más peligrosos son los duplicados lógicos: filas que representan la misma entidad pero no son idénticas byte a byte. Por ejemplo, el mismo cliente dado de alta dos veces con el gasto repartido entre ambas fichas, o el mismo pedido registrado por la web y por la app con ids distintos. Para cazarlos, se busca duplicidad solo en las columnas que definen la identidad:

# ¿Hay ids de cliente repetidos aunque el resto de columnas difiera?
print(df.duplicated(subset=["id_cliente"]).sum())

# O identidad por nombre + fecha de alta
sospechosos = df[df.duplicated(subset=["nombre", "fecha_alta"], keep=False)]

Con duplicados lógicos, borrar sin mirar es arriesgado: a veces lo correcto es fusionar (sumar los pedidos de ambas fichas) en lugar de eliminar. Es una decisión de negocio, no solo técnica: pregunta siempre qué representa cada fila.

Errores de captura y valores imposibles

Un valor imposible es aquel que viola las reglas del mundo real o del negocio. La técnica consiste en definir reglas de validez explícitas y comprobar cuántas filas las incumplen:

# Regla 1: la edad debe estar entre 18 y 100 (MercaFresh exige mayoría de edad)
edades_invalidas = df[(df["edad"] < 18) | (df["edad"] > 100)]
print(edades_invalidas[["id_cliente", "edad"]])
# 104 -> -5  (error de signo o de tecleo)
# 107 -> 127 (¿teclearon 12 y 7 juntos? ¿es 27?)

# Regla 2: un cliente con pedidos debe tener gasto > 0
# (primero convertiremos gasto_total a número; lo vemos en el apartado de tipos)

# Regla 3: la fecha de alta no puede ser futura
fechas = pd.to_datetime(df["fecha_alta"])
futuras = df[fechas > pd.Timestamp("2026-08-24")]
print(futuras[["id_cliente", "fecha_alta"]])   # 106 -> 2027-03-01

¿Y qué hacemos con ellos? Hay tres opciones, en orden de preferencia:

  1. Corregir, si conocemos el error: una edad de -5 con signo cambiado puede ser 5 (imposible, menor de edad) o un teclazo; si el CRM original tiene el dato bueno, se recupera de allí.
  2. Marcar como faltante (np.nan), si no podemos saber el valor real: es honesto reconocer que no lo sabemos, y en la próxima lección veremos cómo tratar esos nulos.
  3. Eliminar la fila, solo si el registro entero es irrecuperable y son pocos casos.
# Opción 2 aplicada: convertir imposibles en NaN
df.loc[(df["edad"] < 18) | (df["edad"] > 100), "edad"] = np.nan
df.loc[pd.to_datetime(df["fecha_alta"]) > pd.Timestamp("2026-08-24"),
       "fecha_alta"] = np.nan

Fíjate en df.loc[condición, columna] = valor: es la forma segura en pandas de modificar un subconjunto (evita el famoso SettingWithCopyWarning).

Los importes a 0 merecen reflexión aparte: un gasto_total de 0 con num_pedidos de 0 puede ser legítimo (cliente registrado que nunca compró — ¡muy relevante para churn!), mientras que gasto 0 con 12 pedidos es un error. La misma cifra puede ser dato válido o error según el contexto: valida combinaciones de columnas, no columnas aisladas.

Inconsistencias de texto y categorías

La columna ciudad es el ejemplo perfecto: "Barcelona", "BCN" y " barcelona " son la misma ciudad para un humano, pero tres categorías distintas para pandas y para cualquier modelo. Si no lo arreglamos, el futuro one-hot encoding (lección 03-04) creará tres columnas donde debería haber una.

La normalización sigue una receta en tres pasos con los métodos de cadena str.*:

# Paso 1: quitar espacios sobrantes al principio y al final
df["ciudad"] = df["ciudad"].str.strip()

# Paso 2: unificar mayúsculas/minúsculas
df["ciudad"] = df["ciudad"].str.lower()
print(df["ciudad"].unique())
# ['barcelona' 'bcn' 'valencia' 'madrid' 'sevilla']

# Paso 3: mapear alias y abreviaturas a un valor canónico
alias = {"bcn": "barcelona", "vlc": "valencia", "mad": "madrid"}
df["ciudad"] = df["ciudad"].replace(alias)

# Toque final estético: capitalizar
df["ciudad"] = df["ciudad"].str.title()
print(df["ciudad"].value_counts())
# Barcelona    3
# Madrid       2
# Valencia     2
# Sevilla      1

Detalle importante: str.strip() y str.lower() transforman el texto carácter a carácter, mientras que replace() sustituye valores completos usando un diccionario. Para sustituciones dentro del texto (por ejemplo, quitar puntos de "S.L.") existe str.replace("...", "..."), que es otro método distinto — confundirlos es un clásico.

Para descubrir alias que no conocías, value_counts() es tu aliado: ordena las categorías por frecuencia y las variantes raras aparecen al final de la lista con conteos de 1 o 2.

Tipos de datos incorrectos

Que un número esté almacenado como texto es un problema invisible hasta que explota: no puedes calcular medias, los ordenamientos son alfabéticos ("15400" < "890" porque "1" < "8") y describe() lo ignora.

# gasto_total llegó como texto: convertirlo a float
df["gasto_total"] = df["gasto_total"].astype(float)

# Si hubiera valores no convertibles ("N/D", "error"), astype fallaría.
# to_numeric con errors="coerce" los convierte en NaN en vez de romper:
df["gasto_total"] = pd.to_numeric(df["gasto_total"], errors="coerce")

# Las fechas en texto se convierten con to_datetime
df["fecha_alta"] = pd.to_datetime(df["fecha_alta"], errors="coerce")
print(df.dtypes)
Situación Herramienta Comportamiento ante errores
Texto numérico limpio astype(float) / astype(int) Lanza excepción
Texto numérico con basura pd.to_numeric(..., errors="coerce") Convierte basura en NaN
Fechas en texto pd.to_datetime(..., errors="coerce") Convierte basura en NaT
Formato de fecha ambiguo pd.to_datetime(..., format="%d/%m/%Y") Exige el formato indicado

El parámetro format merece atención: "14/03/2025" puede ser 14 de marzo o (en convención americana) un error. Especificar format="%d/%m/%Y" elimina la ambigüedad. Convertir las fechas a datetime real además desbloquea operaciones que usaremos en la lección 03-03: extraer el mes, calcular antigüedad, restar fechas.

Outliers: detectarlos y decidir qué hacer

Los outliers ya los conoces del módulo 2: en 02-01 los viste asomar en los boxplots de importes de pedido, y en 02-02 aprendiste que un z-score alto señala una rareza. Ahora los aplicamos como herramientas de limpieza sobre gasto_total.

Detección con z-score

gasto = df["gasto_total"].dropna()
z = (gasto - gasto.mean()) / gasto.std()
print(df.loc[z.abs() > 3, ["id_cliente", "gasto_total"]])

Recordatorio de 02-02: si los datos fueran normales, solo un 0,3 % de los valores caería más allá de |z| > 3. El cliente 107, con 15.400 € de gasto y 210 pedidos, se dispara. El z-score tiene una debilidad: la media y la desviación que usa se contaminan por los propios outliers, así que en muestras pequeñas o muy sesgadas puede fallar.

Detección con IQR

El método del rango intercuartílico (02-01) es más robusto porque los cuartiles apenas se inmutan ante valores extremos:

q1 = gasto.quantile(0.25)
q3 = gasto.quantile(0.75)
iqr = q3 - q1
limite_inf = q1 - 1.5 * iqr
limite_sup = q3 + 1.5 * iqr
outliers = df[(df["gasto_total"] < limite_inf) | (df["gasto_total"] > limite_sup)]
print(outliers[["id_cliente", "gasto_total"]])

Es exactamente la regla que dibuja los "bigotes" del boxplot que ya conoces.

La decisión: ¿error o realidad?

Aquí está el matiz que separa la limpieza mecánica de la buena limpieza: un outlier no es necesariamente un error. El cliente 107 podría ser un restaurante que compra a diario en MercaFresh — un cliente valiosísimo, no un dato corrupto. Las opciones:

Situación Acción recomendada
Outlier claramente erróneo (edad 127) Corregir o convertir en NaN
Outlier real pero atípico (cliente-restaurante) Conservar; quizá marcar con una columna es_empresa
Outlier real que distorsiona el modelo Acotar (capping/winsorizing: recortar al percentil 99) o transformar la variable (lección 03-03)
Muchos outliers estructurales Detectarlos es en sí un objetivo de negocio (anomalías, módulo 5)
# Ejemplo de capping al percentil 99
p99 = df["gasto_total"].quantile(0.99)
df["gasto_total"] = df["gasto_total"].clip(upper=p99)

La regla de oro: nunca elimines un outlier sin poder explicar por qué. Documenta cada decisión — tu yo del futuro (y el modelo de churn) te lo agradecerán.

Errores Comunes y Consejos

  • Limpiar sin auditar primero. Ejecutar drop_duplicates "por si acaso" antes de entender los datos puede borrar registros legítimos. Primero info(), describe(), nunique(); después, actuar.
  • Olvidar reasignar. df.drop_duplicates() sin df = no cambia nada: la mayoría de operaciones de pandas devuelven una copia.
  • Confundir replace() con str.replace(). El primero sustituye valores completos de la Serie; el segundo, subcadenas dentro de cada texto.
  • Eliminar outliers automáticamente con |z| > 3. Es un detector, no un juez: cada outlier merece un diagnóstico (¿error o cliente excepcional?).
  • Validar columnas de forma aislada. Gasto 0 puede ser válido o no según num_pedidos: las reglas de validez potentes cruzan columnas.
  • No documentar los cambios. Guarda el número de filas eliminadas y las reglas aplicadas; en un proyecto real te pedirán cuentas de cada registro descartado.
  • Consejo: escribe la limpieza como una función reproducible (def limpiar_clientes(df): ...) en lugar de celdas sueltas. Cuando llegue la exportación del mes siguiente, la aplicarás en segundos.

Ejercicios

Ejercicio 1

Con el DataFrame df original de la lección (antes de limpiar), escribe el código que: (a) cuente los duplicados exactos, (b) los elimine conservando la primera aparición y (c) compruebe si queda algún id_cliente repetido.

Ejercicio 2

El equipo de MercaFresh te pasa esta columna de una nueva exportación: ["Madrid", "MAD", " madrid", "Màdrid", "madriz"]. Normalízala para que todos los valores acaben siendo "Madrid". Pista: necesitarás strip, lower y un diccionario de alias que incluya los errores tipográficos.

Ejercicio 3

La tabla de pedidos tiene una columna importe con estos valores: [22.5, 19.0, 24.3, 21.7, 480.0, 23.1, 20.9, 18.4]. Detecta outliers con el método IQR y razona (en un comentario) si el valor detectado debería eliminarse, conservarse o investigarse, sabiendo que MercaFresh también sirve a pequeños restaurantes.

Soluciones

Ejercicio 1

# (a) Contar duplicados exactos
print(df.duplicated().sum())            # 1

# (b) Eliminar conservando la primera aparición
df = df.drop_duplicates(keep="first")

# (c) ¿Quedan ids repetidos? (duplicados lógicos)
print(df.duplicated(subset=["id_cliente"]).sum())   # 0 en este caso

Si (c) devolviera más de 0, habría que inspeccionar esas filas con keep=False y decidir si fusionar o eliminar.

Ejercicio 2

s = pd.Series(["Madrid", "MAD", " madrid", "Màdrid", "madriz"])

s = s.str.strip().str.lower()           # 'madrid', 'mad', 'madrid', 'màdrid', 'madriz'
alias = {"mad": "madrid", "màdrid": "madrid", "madriz": "madrid"}
s = s.replace(alias)
s = s.str.title()
print(s.unique())                       # ['Madrid']

Los errores tipográficos (madriz) no se arreglan con reglas generales: hay que descubrirlos con value_counts() y añadirlos al diccionario de alias uno a uno.

Ejercicio 3

importes = pd.Series([22.5, 19.0, 24.3, 21.7, 480.0, 23.1, 20.9, 18.4])

q1, q3 = importes.quantile(0.25), importes.quantile(0.75)
iqr = q3 - q1
lim_inf, lim_sup = q1 - 1.5 * iqr, q3 + 1.5 * iqr
print(importes[(importes < lim_inf) | (importes > lim_sup)])   # 480.0

# Razonamiento: 480 € es un outlier estadístico claro, pero MercaFresh sirve
# a restaurantes: podría ser un pedido mayorista legítimo. Antes de eliminar,
# investigar: ¿el cliente tiene historial de pedidos grandes? ¿Coincide con
# un cliente de tipo empresa? Si es legítimo, se conserva (y quizá se marca
# con una columna 'pedido_mayorista'); solo se elimina si se confirma error.

Conclusión

En esta lección has aprendido a convertir un dataset caótico en uno confiable: auditar con info(), describe() y nunique(); eliminar duplicados exactos y diagnosticar los lógicos con drop_duplicates; definir reglas de validez para cazar valores imposibles; unificar categorías con str.strip/lower y replace; corregir tipos con astype, to_numeric y to_datetime; y detectar outliers con z-score e IQR sabiendo que detectarlos no es sentenciarlos. La idea central: la limpieza es análisis, no trámite — cada corrección es una decisión que debe poder explicarse.

Habrás notado que varias veces la solución honesta fue convertir un valor sospechoso en NaN: hemos ido acumulando huecos deliberadamente. En la próxima lección abordamos precisamente eso: los datos faltantes de MercaFresh, por qué faltan (que no siempre es azar) y las estrategias para eliminarlos o imputarlos sin engañar al modelo.

Curso de Machine Learning

Módulo 1: Introducción al Machine Learning

Módulo 2: Fundamentos de Estadística y Probabilidad

Módulo 3: Preprocesamiento de Datos

Módulo 4: Algoritmos de Machine Learning Supervisado

Módulo 5: Algoritmos de Machine Learning No Supervisado

Módulo 6: Evaluación y Validación de Modelos

Módulo 7: Técnicas Avanzadas y Optimización

Módulo 8: Implementación y Despliegue de Modelos

Módulo 9: Proyectos Prácticos

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados