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_ingesta → 02_deduplicacion → 03_tipado → 04_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 reimport numpy as npimport pandas as pdfrom pathlib import Pathpd.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 baseraiseFileNotFoundError("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:raiseAssertionError(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.
# 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()))
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.nanreturn 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)]ifnot 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.
def parsear_fecha(valor):if pd.isna(valor):return pd.NaTfor formato in FORMATOS_FECHA:try:return pd.to_datetime(valor, format=formato)exceptValueError:continuereturn pd.NaTdef limpiar_precio(valor):if pd.isna(valor):return np.nan texto = valor.replace('Q', '').replace('$', '').replace(',', '').strip()try:returnabs(float(texto)) # un precio negativo es error de capturaexceptValueError:return np.nandef 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:returnabs(int(float(valor)))exceptValueError:return np.nandef limpiar_email(valor):if pd.isna(valor):return np.nan valor = valor.strip()if'@'notin valor:for dominio in DOMINIOS_CONOCIDOS:if valor.endswith(dominio) andlen(valor) >len(dominio): valor = valor[:-len(dominio)] +'@'+ dominiobreakreturn valor if PATRON_EMAIL.match(valor) else np.nandef limpiar_calificacion(valor):if pd.isna(valor):return np.nantry: num =int(float(valor))exceptValueError:return np.nanreturn num if1<= num <=5else 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.nandf_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())
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.py → 02_deduplicacion.py → 03_tipado.py → 04_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).