Limpieza de datos — pipeline completo en un cuaderno

Curso: CC3084 · Ciencia de Datos (UVG)

Se parte de un dataset de ventas capturado “a mano” (data/raw/ventas.csv) con los errores tipicos de captura: tipos mezclados, fechas en formatos distintos, nulos escritos de varias formas, categorias mal escritas y duplicados. El objetivo es dejar un ventas_limpio.csv confiable.

Este cuaderno reune, en un solo lugar, las 4 etapas que en el proyecto viven como scripts separados en src/ (01_ingesta02_deduplicacion03_tipado04_features). Se hace asi para poder leer y explicar todo el flujo de corrido; al final se discute por que, para un proyecto real, conviene volver a separarlo.

El dataset crudo (data/raw/) nunca se modifica: es la fuente de verdad. Todo lo derivado se calcula aqui a partir de el.

0. Configuracion

Imports y localizacion del proyecto. Buscamos hacia arriba la carpeta que contiene data/raw/ventas.csv, para que el cuaderno funcione tanto si se ejecuta desde notebooks/ como desde la raiz del proyecto.

Código
import re
import numpy as np
import pandas as pd
from pathlib import Path

pd.set_option("display.max_columns", None)
pd.set_option("display.width", 120)

# Localizar la raiz del proyecto (la que contiene data/raw/ventas.csv).
def encontrar_raiz(inicio: Path) -> Path:
    for base in [inicio, *inicio.parents]:
        if (base / "data" / "raw" / "ventas.csv").exists():
            return base
    raise FileNotFoundError("No encuentro data/raw/ventas.csv hacia arriba de " + str(inicio))

RAIZ = encontrar_raiz(Path.cwd())
RUTA_CRUDA = RAIZ / "data" / "raw" / "ventas.csv"
RUTA_FINAL = RAIZ / "data" / "processed" / "ventas_limpio.csv"

# Contrato de entrada: columnas que debe traer el crudo.
COLUMNAS_CRUDAS = [
    "id_venta", "fecha", "producto", "categoria", "precio_unitario",
    "cantidad", "cliente_email", "metodo_pago", "en_oferta", "calificacion",
]

print("Raiz del proyecto:", RAIZ)
print("Crudo:", RUTA_CRUDA.relative_to(RAIZ))
Raiz del proyecto: /Users/menene/code/data/01_lipieza_datos
Crudo: data/raw/ventas.csv

Una pequena ayuda para validar entre etapas: si un supuesto no se cumple, queremos enterarnos aqui mismo (fail-fast) y no arrastrar datos malos hasta el final. En los scripts esto detiene el proceso; en el cuaderno lanzamos una excepcion clara.

Código
def afirmar(condicion, mensaje):
    """Validacion tipo assert con mensaje legible."""
    if condicion:
        print(f"[ok]    {mensaje}")
    else:
        raise AssertionError(f"[FALLO] {mensaje}")

1. Carga del crudo y diagnostico

Cargamos todo como texto (dtype=str): todavia no queremos que pandas infiera tipos, para no perder el “antes” ("dos", "Q151.27", "01/05/2023" deben llegar tal cual). Primero miramos el desorden.

Código
crudo = pd.read_csv(RUTA_CRUDA, dtype=str)
print(f"Crudo: {crudo.shape[0]} filas, {crudo.shape[1]} columnas")
crudo.head(10)
Crudo: 187 filas, 10 columnas
id_venta fecha producto categoria precio_unitario cantidad cliente_email metodo_pago en_oferta calificacion
0 81 01/05/2023 Teclado Mecanico Electronica Q151.27 1 cliente81@gmail.com EFECTIVO No -1
1 5 06-27-2023 Licuadora HOGAR $40.24 uno cliente5uvg.edu.gt TARJETA true NaN
2 112 21/09/2023 Set de Sartenes HOGAR -45.73 tres cliente112@uvg.edu.gt TARJETA NaN 1
3 109 01-14-2024 Chumpa Impermeable ropa 29.65 tres NaN EFECTIVO SI 1
4 75 ? Teclado Mecanico Electronica 164.77 3 NaN cash 0 4
5 156 2023-04-24 Set de Sartenes HOGAR -46.58 -4 cliente156gmail.com Transferencia 1 3
6 58 2024-11-01 Balon de Futbol Deportes 66.76 tres cliente58@uvg.edu.gt Transferencia si -1
7 172 2023-06-21 Licuadora hogar -40.29 4.0 cliente172@gmail.com Efectivo TRUE 4
8 64 Mar 28, 2024 Bicicleta Montaña deportes -63.31 -4 cliente64hotmail.com efectivo false 3
9 168 2023-03-14 Laptop HP 15 electronica -145.23 4.0 cliente168@gmail.com Transferencia Si 1
Código
# Sintomas de captura manual que vamos a corregir etapa por etapa.
print("Categorias crudas :", sorted(crudo['categoria'].dropna().unique())[:12])
print("Metodos de pago   :", sorted(crudo['metodo_pago'].dropna().unique()))
print("Ejemplos de fecha :", crudo['fecha'].dropna().unique()[:6])
print("Ejemplos cantidad :", crudo['cantidad'].dropna().unique()[:10])
print("id_venta duplicados:", int(crudo['id_venta'].duplicated().sum()))
Categorias crudas : [' DEPORTES', ' Electronica', ' Ropa', 'Deportes', 'ELECTRONICA', 'Electronica', 'Electronica ', 'HOGAR', 'Hogar', 'Hogar ', 'ROPA ', 'Ropa']
Metodos de pago   : ['?', 'EFECTIVO', 'Efectivo', 'TARJETA', 'Tarjeta', 'Transferencia', 'cash', 'credit card', 'efectivo', 'tarjeta', 'transferencia']
Ejemplos de fecha : <StringArray>
['01/05/2023', '06-27-2023', '21/09/2023', '01-14-2024', '?', '2023-04-24']
Length: 6, dtype: str
Ejemplos cantidad : <StringArray>
['1', 'uno', 'tres', '3', '-4', '4.0', '-2', '5.0', '1.0', 'dos']
Length: 10, dtype: str
id_venta duplicados: 7

2. Etapa 1 — Ingesta + normalizacion de nulos

Una sola responsabilidad: dejar los datos “parejos” a nivel de texto. Los muchos “no hay dato” ("", NA, N/A, null, -, ?, …) se unifican a un unico NaN. Todavia no se convierten tipos ni se quitan duplicados.

Código
MARCADORES_NULOS = {"", "NA", "N/A", "null", "NULL", "-", "?", "n/a"}

def normalizar_texto(valor):
    """Quita espacios y convierte cualquier marcador de nulo en NaN."""
    if pd.isna(valor):
        return np.nan
    valor = valor.strip()
    if valor in MARCADORES_NULOS:
        return np.nan
    return valor

# Validar el contrato de entrada antes de tocar nada.
afirmar(list(crudo.columns) == COLUMNAS_CRUDAS,
        "el crudo trae exactamente las columnas esperadas")

df_ingesta = crudo.copy()
nulos_antes = df_ingesta.isna().sum().sum()
for col in df_ingesta.columns:
    df_ingesta[col] = df_ingesta[col].map(normalizar_texto)
nulos_despues = df_ingesta.isna().sum().sum()

print(f"Nulos reconocidos: {nulos_antes} -> {nulos_despues} "
      f"(+{nulos_despues - nulos_antes} marcadores de texto convertidos a NaN)")

# Validar la salida: ningun marcador de nulo debe sobrevivir como texto.
afirmar(not df_ingesta.isin(MARCADORES_NULOS - {''}).any().any(),
        "no quedan marcadores de nulo escritos como texto")
[ok]    el crudo trae exactamente las columnas esperadas
Nulos reconocidos: 82 -> 101 (+19 marcadores de texto convertidos a NaN)
[ok]    no quedan marcadores de nulo escritos como texto

3. Etapa 2 — Deduplicacion

Una sola responsabilidad: que cada venta aparezca una sola vez. Se eliminan filas duplicadas exactas y se garantiza id_venta unico (conservando la primera ocurrencia). Empezamos validando lo que dejo la etapa anterior.

Código
# Validar contrato de la etapa 1.
afirmar(df_ingesta['id_venta'].notna().all(), "ninguna fila viene sin id_venta")

df_dedup = df_ingesta.copy()
filas_antes = len(df_dedup)
df_dedup = df_dedup.drop_duplicates()
print(f"Filas duplicadas exactas eliminadas: {filas_antes - len(df_dedup)}")

dups_id = df_dedup[df_dedup.duplicated(subset='id_venta', keep=False)]
if not dups_id.empty:
    print(f"id_venta con registros distintos: {dups_id['id_venta'].nunique()} "
          f"(se conserva el primero)")
df_dedup = df_dedup.drop_duplicates(subset='id_venta', keep='first')

afirmar(not df_dedup.duplicated(subset='id_venta').any(),
        "id_venta es unico despues de deduplicar")
[ok]    ninguna fila viene sin id_venta
Filas duplicadas exactas eliminadas: 6
id_venta con registros distintos: 1 (se conserva el primero)
[ok]    id_venta es unico despues de deduplicar

4. Etapa 3 — Tipado + limpieza por columna

Una sola responsabilidad: convertir cada columna a su tipo correcto y a su dominio de valores validos. Aqui viven las reglas de negocio: reparar correos a los que se les cayo la @, pasar "dos" a 2, quitar el simbolo de moneda, descartar calificaciones fuera de la escala 1-5, etc.

Código
# Tablas de referencia (dominios validos).
FORMATOS_FECHA = ["%Y-%m-%d", "%d/%m/%Y", "%m-%d-%Y", "%b %d, %Y"]
CATEGORIAS_VALIDAS = {"Electronica", "Ropa", "Hogar", "Deportes"}
PALABRAS_A_NUMERO = {"uno": 1, "dos": 2, "tres": 3, "cuatro": 4, "cinco": 5}
PATRON_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# Dominios conocidos: reparar correos sin '@' (cliente131gmail.com -> cliente131@gmail.com).
DOMINIOS_CONOCIDOS = ["gmail.com", "hotmail.com", "uvg.edu.gt"]
MAPA_METODO_PAGO = {
    "tarjeta": "tarjeta", "credit card": "tarjeta",
    "efectivo": "efectivo", "cash": "efectivo",
    "transferencia": "transferencia",
}
MAPA_BOOLEANO = {
    "si": True, "s\u00ed": True, "1": True, "true": True,
    "no": False, "0": False, "false": False,
}
Código
def parsear_fecha(valor):
    if pd.isna(valor):
        return pd.NaT
    for formato in FORMATOS_FECHA:
        try:
            return pd.to_datetime(valor, format=formato)
        except ValueError:
            continue
    return pd.NaT

def limpiar_precio(valor):
    if pd.isna(valor):
        return np.nan
    texto = valor.replace('Q', '').replace('$', '').replace(',', '').strip()
    try:
        return abs(float(texto))  # un precio negativo es error de captura
    except ValueError:
        return np.nan

def limpiar_cantidad(valor):
    if pd.isna(valor):
        return np.nan
    valor = valor.lower().strip()
    if valor in PALABRAS_A_NUMERO:
        return PALABRAS_A_NUMERO[valor]
    try:
        return abs(int(float(valor)))
    except ValueError:
        return np.nan

def limpiar_email(valor):
    if pd.isna(valor):
        return np.nan
    valor = valor.strip()
    if '@' not in valor:
        for dominio in DOMINIOS_CONOCIDOS:
            if valor.endswith(dominio) and len(valor) > len(dominio):
                valor = valor[:-len(dominio)] + '@' + dominio
                break
    return valor if PATRON_EMAIL.match(valor) else np.nan

def limpiar_calificacion(valor):
    if pd.isna(valor):
        return np.nan
    try:
        num = int(float(valor))
    except ValueError:
        return np.nan
    return num if 1 <= num <= 5 else np.nan
Código
# Validar contrato de la etapa 2.
afirmar(not df_dedup.duplicated(subset='id_venta').any(),
        "id_venta llega unico desde la etapa 2")

df_tipado = df_dedup.copy()
df_tipado['id_venta'] = df_tipado['id_venta'].astype(int)
df_tipado['fecha'] = df_tipado['fecha'].map(parsear_fecha)
df_tipado['categoria'] = df_tipado['categoria'].str.strip().str.lower().str.capitalize()
df_tipado.loc[~df_tipado['categoria'].isin(CATEGORIAS_VALIDAS), 'categoria'] = np.nan
df_tipado['precio_unitario'] = df_tipado['precio_unitario'].map(limpiar_precio)
df_tipado['cantidad'] = df_tipado['cantidad'].map(limpiar_cantidad)
df_tipado['cliente_email'] = df_tipado['cliente_email'].map(limpiar_email)
df_tipado['metodo_pago'] = df_tipado['metodo_pago'].str.lower().str.strip().map(MAPA_METODO_PAGO)
df_tipado['en_oferta'] = df_tipado['en_oferta'].str.lower().str.strip().map(MAPA_BOOLEANO)
df_tipado['calificacion'] = df_tipado['calificacion'].map(limpiar_calificacion)

print('Tipos de dato:')
print(df_tipado.dtypes)
[ok]    id_venta llega unico desde la etapa 2
Tipos de dato:
id_venta                    int64
fecha              datetime64[us]
producto                      str
categoria                     str
precio_unitario           float64
cantidad                  float64
cliente_email                 str
metodo_pago                   str
en_oferta                  object
calificacion              float64
dtype: object
Código
# Validar la salida: los dominios quedaron limpios.
afirmar(pd.api.types.is_integer_dtype(df_tipado['id_venta']), 'id_venta quedo entero')
afirmar((df_tipado['precio_unitario'].dropna() >= 0).all(), 'precio_unitario sin negativos')
afirmar((df_tipado['cantidad'].dropna() >= 0).all(), 'cantidad sin negativos')
afirmar(df_tipado['categoria'].dropna().isin(CATEGORIAS_VALIDAS).all(),
        'categoria solo tiene valores del dominio valido')
afirmar(df_tipado['calificacion'].dropna().between(1, 5).all(),
        'calificacion dentro del rango 1-5')
[ok]    id_venta quedo entero
[ok]    precio_unitario sin negativos
[ok]    cantidad sin negativos
[ok]    categoria solo tiene valores del dominio valido
[ok]    calificacion dentro del rango 1-5

5. Etapa 4 — Features + dataset final

Una sola responsabilidad: enriquecer el dataset ya limpio y dejarlo listo para analisis. Calculamos total_venta = precio_unitario * cantidad y ordenamos por id_venta.

Código
# Validar contrato de la etapa 3.
afirmar(pd.api.types.is_numeric_dtype(df_tipado['precio_unitario']),
        'precio_unitario llega como numero')
afirmar(pd.api.types.is_numeric_dtype(df_tipado['cantidad']),
        'cantidad llega como numero')

df_final = df_tipado.copy()
df_final['total_venta'] = (df_final['precio_unitario'] * df_final['cantidad']).round(2)
df_final = df_final.sort_values('id_venta').reset_index(drop=True)

# Validar la salida final.
calc = df_final['precio_unitario'].notna() & df_final['cantidad'].notna()
esperado = (df_final.loc[calc, 'precio_unitario'] * df_final.loc[calc, 'cantidad']).round(2)
afirmar((df_final.loc[calc, 'total_venta'] == esperado).all(),
        'total_venta = precio_unitario * cantidad en las filas calculables')

print(f"Dataset final: {df_final.shape[0]} filas, {df_final.shape[1]} columnas")
df_final.head(10)
[ok]    precio_unitario llega como numero
[ok]    cantidad llega como numero
[ok]    total_venta = precio_unitario * cantidad en las filas calculables
Dataset final: 180 filas, 11 columnas
id_venta fecha producto categoria precio_unitario cantidad cliente_email metodo_pago en_oferta calificacion total_venta
0 1 2024-12-01 Bicicleta Montaña Deportes 58.49 5.0 cliente1@uvg.edu.gt tarjeta True 2.0 292.45
1 2 2023-09-08 Mouse Logitech Electronica 155.49 5.0 NaN transferencia True 4.0 777.45
2 3 2023-06-07 Pantalon Jeans Ropa 22.42 5.0 cliente3@gmail.com tarjeta True 3.0 112.10
3 4 2024-05-18 Cafetera Hogar 36.15 3.0 cliente4@gmail.com transferencia False 2.0 108.45
4 5 2023-06-27 Licuadora Hogar 40.24 1.0 cliente5@uvg.edu.gt tarjeta True NaN 40.24
5 6 2023-12-21 Cafetera Hogar 40.73 3.0 cliente6@hotmail.com efectivo False 5.0 122.19
6 7 2024-08-21 Chumpa Impermeable Ropa 26.62 5.0 cliente7@uvg.edu.gt efectivo False 4.0 133.10
7 8 2024-09-05 Chumpa Impermeable Ropa 41.53 2.0 cliente8@uvg.edu.gt transferencia False 4.0 83.06
8 9 2023-05-17 Balon de Futbol Deportes 77.12 3.0 cliente9@hotmail.com tarjeta True 3.0 231.36
9 10 2023-03-17 Laptop HP 15 Electronica 160.22 3.0 cliente10@uvg.edu.gt efectivo True 5.0 480.66
Código
# Guardar el dataset limpio (se regenera; data/processed no se versiona).
RUTA_FINAL.parent.mkdir(parents=True, exist_ok=True)
df_final.to_csv(RUTA_FINAL, index=False)
print('Guardado en:', RUTA_FINAL.relative_to(RAIZ))
Guardado en: data/processed/ventas_limpio.csv

6. Verificacion final

Tipos correctos y un panorama de los NaN que quedan a proposito: no se imputan aqui porque como tratar un faltante (descartar, imputar, etc.) es una decision de analisis que se documenta en el EDA, no en la limpieza. Ver codebook.md para el detalle.

Código
print('Tipos de dato finales:')
print(df_final.dtypes)
print('\nValores faltantes por columna:')
print(df_final.isna().sum())
Tipos de dato finales:
id_venta                    int64
fecha              datetime64[us]
producto                      str
categoria                     str
precio_unitario           float64
cantidad                  float64
cliente_email                 str
metodo_pago                   str
en_oferta                  object
calificacion              float64
total_venta               float64
dtype: object

Valores faltantes por columna:
id_venta            0
fecha               3
producto            0
categoria           0
precio_unitario     4
cantidad            6
cliente_email      46
metodo_pago         5
en_oferta          18
calificacion       51
total_venta        10
dtype: int64

7. Nota final: cuaderno unico vs. scripts vs. varios cuadernos

Este cuaderno hace todo el pipeline de corrido, lo cual es comodo para leer y explicar el flujo completo de un vistazo. Pero para un proyecto real no es la mejor forma de organizarlo, y conviene decirlo explicitamente:

  • Lo ideal: scripts encadenados (como en src/: 01_ingesta.py02_deduplicacion.py03_tipado.py04_features.py). Cada etapa tiene una sola responsabilidad, se ejecuta sola, deja un dataset intermedio auditable en data/processed/, valida la entrada de la anterior (fail-fast) y permite reejecutar solo el paso que cambio. Ademas versiona y testea mejor: un .py produce diffs limpios en git, un .ipynb no.

  • Mejor que un cuaderno unico: varios cuadernos, uno por etapa — un 01_ingesta.ipynb, 02_deduplicacion.ipynb, 03_tipado.ipynb, 04_features.ipynb. Se conserva la parte didactica del notebook (narrativa

    • salidas visibles) pero cada uno cuenta una sola historia, se ejecuta y revisa por separado, y encadenarlos se vuelve explicito (cada cuaderno lee el CSV que dejo el anterior).
  • Cuaderno unico (esto): util como resumen o material de estudio, pero a medida que crece se vuelve fragil: estado global compartido, dificil de testear, un error a la mitad obliga a recorrer todo, y el orden de ejecucion de las celdas se puede desincronizar.

Regla practica: para entender y presentar el flujo, un cuaderno; para producir el dato de forma confiable y repetible, scripts (o, como paso intermedio, un cuaderno por etapa).