lección 4
Día 2 (tarde) — Los datos nunca están limpios
Limpieza completa del CSV de ventas con Python: fechas mixtas, precios como string, duplicados. Y el imprevisto: María manda otro CSV con formato diferente.
⏱ 50 min
### 12:00 PM — Empieza la limpieza de verdad
Ya entregaste lo de Jorge. Ahora toca lo que Elena necesita para cerrar el ticket DATA-147: transformar los datos crudos de la campaña en algo usable. "Zona silver" en el lenguaje del data lake. El proceso es: tomar el CSV tal cual está en raw (con todos sus problemas) y producir una versión limpia, tipada y consistente.
La limpieza de datos es el 80% del trabajo real de un ingeniero de datos. No es glamuroso. No es sexy. No sale en los títulos de LinkedIn. Pero es lo que separa un dashboard que miente de uno que dice la verdad. Cada problema que no resuelves aquí se arrastra hasta el informe del CEO.
### Filosofía de limpieza: una función por problema
La tentación del junior es escribir un script monolítico de 200 líneas que "hace todo". La forma correcta es: una función por cada tipo de problema. Cada función recibe un DataFrame y devuelve un DataFrame limpio. Así puedes testear cada paso por separado, reutilizarlas y saber exactamente dónde falla si algo se rompe.
1# limpiar_ventas.py — Pipeline de limpieza del CSV de ventas2import pandas as pd3import logging45logging.basicConfig(6 level=logging.INFO,7 format='%(asctime)s | %(levelname)s | %(message)s'8)9logger = logging.getLogger(__name__)101112def normalizar_fechas(df: pd.DataFrame) -> pd.DataFrame:13 """14 Convierte fecha_compra de 3 formatos a datetime unificado.15 Formatos encontrados: YYYY-MM-DD, DD/MM/YYYY, Mon DD, YYYY16 """17 logger.info("Normalizando fechas...")18 antes_nulas = df['fecha_compra'].isna().sum()1920 df['fecha_compra'] = pd.to_datetime(21 df['fecha_compra'],22 format='mixed',23 dayfirst=True # para que DD/MM/YYYY se parsee bien24 )2526 despues_nulas = df['fecha_compra'].isna().sum()27 nuevas_nulas = despues_nulas - antes_nulas28 if nuevas_nulas > 0:29 logger.warning(f" {nuevas_nulas} fechas no se pudieron parsear")30 else:31 logger.info(" ✓ Todas las fechas parseadas correctamente")3233 return df343536def limpiar_precio(df: pd.DataFrame) -> pd.DataFrame:37 """38 Convierte precio de string '2,45€' a float 2.4539 """40 logger.info("Limpiando columna precio...")4142 df['precio'] = (43 df['precio']44 .astype(str)45 .str.replace('€', '', regex=False)46 .str.replace(',', '.')47 .astype(float)48 )4950 negativos = (df['precio'] < 0).sum()51 if negativos > 0:52 logger.warning(f" {negativos} precios negativos detectados")5354 logger.info(f" ✓ Precio convertido. Rango: {df['precio'].min():.2f} - {df['precio'].max():.2f}")55 return df
Cada problema tiene su función — testeable, legible y reutilizable
1def marcar_anonimos(df: pd.DataFrame) -> pd.DataFrame:2 """3 Marca ventas sin cliente_id como anónimas.4 NO las elimina — son ventas reales de guest checkout.5 """6 logger.info("Marcando ventas anónimas...")7 df['es_anonimo'] = df['cliente_id'].isna()8 n_anonimos = df['es_anonimo'].sum()9 logger.info(f" ✓ {n_anonimos} ventas anónimas marcadas ({100*n_anonimos/len(df):.1f}%)")10 return df111213def eliminar_duplicados(df: pd.DataFrame) -> pd.DataFrame:14 """15 Elimina filas completamente duplicadas (mismo venta_id, mismos datos).16 """17 logger.info("Eliminando duplicados...")18 antes = len(df)19 df = df.drop_duplicates()20 eliminados = antes - len(df)21 logger.info(f" ✓ {eliminados} duplicados eliminados")22 return df232425def filtrar_cantidad_cero(df: pd.DataFrame) -> pd.DataFrame:26 """27 Filtra ventas con cantidad = 0 (bug de la app, no son ventas reales).28 """29 logger.info("Filtrando filas con cantidad = 0...")30 antes = len(df)31 df = df[df['cantidad'] > 0]32 eliminados = antes - len(df)33 logger.info(f" ✓ {eliminados} filas con cantidad=0 eliminadas")34 return df
Funciones atómicas: una responsabilidad cada una
### Orquestar la limpieza: el pipeline completo
1def limpiar_ventas_campana(ruta_csv: str) -> pd.DataFrame:2 """3 Pipeline completo de limpieza para el CSV de ventas de campaña.4 Aplica cada función en orden y loguea el resultado.5 """6 logger.info(f"=== Iniciando limpieza: {ruta_csv} ===")78 # Cargar9 df = pd.read_csv(ruta_csv)10 logger.info(f"Filas cargadas: {len(df):,}")1112 # Aplicar limpieza en orden13 df = eliminar_duplicados(df)14 df = normalizar_fechas(df)15 df = limpiar_precio(df)16 df = marcar_anonimos(df)17 df = filtrar_cantidad_cero(df)1819 logger.info(f"=== Limpieza completada. Filas finales: {len(df):,} ===")20 return df212223# Ejecutar24if __name__ == '__main__':25 ventas_limpias = limpiar_ventas_campana('datos/campana_verano_ventas.csv')2627 # Guardar en zona silver (como Parquet en un proyecto real)28 ventas_limpias.to_csv('output/silver_ventas_verano.csv', index=False)29 print(f"\n✅ Archivo limpio guardado: output/silver_ventas_verano.csv")30 print(f" Filas originales: 42,891")31 print(f" Filas finales: {len(ventas_limpias):,}")
El pipeline de limpieza orquesta cada función en orden — limpio y trazable
Consejo de senior: pon LOGGING desde el día 1. No es opcional, no es "luego lo añado". Cuando tu pipeline falle a las 3AM un martes (y fallará), los logs son tu ÚNICA herramienta para saber qué pasó. Un buen log te dice: cuántas filas entraron, cuántas salieron de cada paso, y dónde se descartaron. Sin logs estás ciego.
### 15:30 PM — El imprevisto: María cambia los requisitos
Estás satisfecho con tu pipeline de limpieza. Funciona, tiene logs, cada paso es una función. Y entonces llega el mensaje que todo data engineer conoce demasiado bien:
1María García 15:32 PM2Ey! Se me olvidó decirte3Laura (mi jefa) quiere que incluyamos también los datos de la campaña4de PRIMAVERA para comparar resultados. Te adjunto el CSV.5¿Lo puedes tener para mañana? Es que el jueves presentamos a6dirección 😬78María García 15:33 PM9[Adjunto: campana_primavera_ventas.csv]10Ah, y este archivo viene de otro formato porque lo exportó Pedro que11ya no está12Creo que las columnas se llaman diferente pero es lo mismo13básicamente
Los requisitos SIEMPRE cambian. No es negligencia — es la realidad del negocio.
Abres el CSV de primavera y confirmas tus sospechas: las columnas tienen nombres completamente diferentes. Donde verano dice "venta_id", primavera dice "id_transaccion". Donde verano dice "precio" (con € y coma), primavera dice "importe" (ya numérico). Es el MISMO concepto pero con un esquema distinto porque lo exportó otra persona.
1# Comparar esquemas: verano vs primavera2import pandas as pd34verano = pd.read_csv('datos/campana_verano_ventas.csv', nrows=3)5primavera = pd.read_csv('datos/campana_primavera_ventas.csv', nrows=3)67print("Columnas VERANO:", list(verano.columns))8# ['venta_id', 'cliente_id', 'producto_id', 'fecha_compra',9# 'cantidad', 'precio', 'descuento_pct', 'tienda_id', 'campana']1011print("Columnas PRIMAVERA:", list(primavera.columns))12# ['id_transaccion', 'id_cliente', 'id_producto', 'fecha_venta',13# 'unidades', 'importe', 'descuento', 'almacen', 'promocion']1415print("\n→ Mismo concepto, nombres diferentes. Necesitamos un MAPPING.")
Dos archivos con la misma información pero nombres de columnas diferentes
### 16:00 PM — Consultas a Elena sobre el cambio
No te lanzas a codear como un loco. Primero preguntas a Elena cómo gestionar esto — porque la decisión afecta a la arquitectura del pipeline:
1Tú 16:05 PM2Elena, María acaba de mandarme otro CSV (campaña de primavera) con3columnas diferentes. ¿Cómo gestionamos esto? ¿Hago el mismo pipeline4pero con un mapping de columnas? ¿O le digo a María que me lo pase5en el mismo formato?67Elena Torres 16:12 PM8Buena pregunta. En un mundo ideal le pedirías a Marketing un formato9estándar. En la práctica, María no va a cambiar sus CSVs. Haz un10mapping en tu script. Algo tipo:1112COLUMN_MAPPINGS = {13 'primavera': {14 'id_transaccion': 'venta_id',15 'id_cliente': 'cliente_id',16 'id_producto': 'producto_id',17 'fecha_venta': 'fecha_compra',18 'unidades': 'cantidad',19 'importe': 'precio',20 'descuento': 'descuento_pct',21 'almacen': 'tienda_id',22 'promocion': 'campana'23 },24 'verano': {} # ya viene con los nombres correctos25}2627Así cuando llegue la siguiente campaña solo añades una entrada.28Pon un test que valide que después del mapping todas las columnas29obligatorias existen.
Elena te da la solución arquitectónica — mapping configurable
NUNCA le digas a un stakeholder "pásame el archivo en otro formato". Puede que funcione una vez, pero a la tercera se frustran, dejan de colaborar y empiezan a hacer las cosas sin ti. Tu trabajo como data engineer es ABSORBER la variabilidad de los datos — no exigir que el mundo se adapte a tu código. Diseña para la flexibilidad.
### Implementar el column mapping
La idea es simple pero poderosa: un diccionario que traduce los nombres de columnas de cada campaña al esquema estándar de tu pipeline. Así, no importa cómo llame María (o Pedro, o quien sea) a las columnas — tu código las normaliza antes de limpiar.
1# config.py — Configuración centralizada del pipeline de campañas23# Esquema estándar que espera nuestro pipeline de limpieza4COLUMNAS_OBLIGATORIAS = [5 'venta_id', 'cliente_id', 'producto_id', 'fecha_compra',6 'cantidad', 'precio', 'descuento_pct', 'tienda_id', 'campana'7]89# Mappings: cómo se llaman las columnas en cada archivo de origen10COLUMN_MAPPINGS = {11 'verano': {12 # Verano ya viene con los nombres correctos, no necesita mapping13 },14 'primavera': {15 'id_transaccion': 'venta_id',16 'id_cliente': 'cliente_id',17 'id_producto': 'producto_id',18 'fecha_venta': 'fecha_compra',19 'unidades': 'cantidad',20 'importe': 'precio',21 'descuento': 'descuento_pct',22 'almacen': 'tienda_id',23 'promocion': 'campana',24 },25}2627# Particularidades por campaña28CAMPANA_CONFIG = {29 'verano': {30 'precio_es_string': True, # "2,45€" → necesita limpiar31 'fechas_mixtas': True, # 3 formatos diferentes32 'anonimo_es_null': True, # cliente_id NULL = anónimo33 },34 'primavera': {35 'precio_es_string': False, # ya es float36 'fechas_mixtas': False, # solo DD/MM/YYYY37 'anonimo_es_null': False, # usa -1 para anónimos38 },39}
Configuración centralizada — añadir una campaña nueva es añadir un dict
1# normalizar.py — Aplicar el mapping de columnas2import pandas as pd3from config import COLUMN_MAPPINGS, COLUMNAS_OBLIGATORIAS, CAMPANA_CONFIG456def aplicar_mapping(df: pd.DataFrame, campana: str) -> pd.DataFrame:7 """8 Renombra columnas según el mapping de la campaña.9 Valida que todas las columnas obligatorias existen después.10 """11 mapping = COLUMN_MAPPINGS.get(campana, {})1213 if mapping:14 df = df.rename(columns=mapping)1516 # Validar que tenemos todas las columnas obligatorias17 faltantes = set(COLUMNAS_OBLIGATORIAS) - set(df.columns)18 if faltantes:19 raise ValueError(20 f"Campaña '{campana}': faltan columnas después del mapping: {faltantes}"21 )2223 return df242526def normalizar_anonimos(df: pd.DataFrame, campana: str) -> pd.DataFrame:27 """28 Unifica la representación de clientes anónimos.29 Verano: NULL = anónimo. Primavera: -1 = anónimo.30 Resultado: siempre NULL + columna 'es_anonimo'.31 """32 config = CAMPANA_CONFIG[campana]3334 if not config['anonimo_es_null']:35 # Primavera usa -1 para anónimos → convertir a NULL36 df.loc[df['cliente_id'] == -1, 'cliente_id'] = None3738 df['es_anonimo'] = df['cliente_id'].isna()39 return df404142# Uso:43# df = pd.read_csv('datos/campana_primavera_ventas.csv')44# df = aplicar_mapping(df, 'primavera')45# df = normalizar_anonimos(df, 'primavera')46# → Ahora tiene el mismo esquema que verano
El mapping normaliza cualquier formato al esquema estándar de tu pipeline
### Adaptando la limpieza para primavera
El CSV de primavera tiene particularidades diferentes al de verano. El precio ya es numérico (no necesita quitar "€"), las fechas son consistentes (solo DD/MM/YYYY), y los anónimos se representan con -1 en vez de NULL. Nuestras funciones de limpieza deben ser lo suficientemente flexibles para manejar ambos:
1def limpiar_precio_flexible(df: pd.DataFrame, campana: str) -> pd.DataFrame:2 """3 Limpia la columna precio según la configuración de la campaña.4 - Verano: string '2,45€' → float 2.455 - Primavera: ya es float, no hacer nada6 """7 config = CAMPANA_CONFIG[campana]89 if config['precio_es_string']:10 df['precio'] = (11 df['precio']12 .astype(str)13 .str.replace('€', '', regex=False)14 .str.replace(',', '.')15 .astype(float)16 )17 else:18 df['precio'] = df['precio'].astype(float)1920 return df212223def normalizar_fechas_flexible(df: pd.DataFrame, campana: str) -> pd.DataFrame:24 """25 Normaliza fechas según la campaña.26 - Verano: 3 formatos mezclados → format='mixed'27 - Primavera: solo DD/MM/YYYY → dayfirst=True28 """29 config = CAMPANA_CONFIG[campana]3031 if config['fechas_mixtas']:32 df['fecha_compra'] = pd.to_datetime(33 df['fecha_compra'], format='mixed', dayfirst=True34 )35 else:36 df['fecha_compra'] = pd.to_datetime(37 df['fecha_compra'], dayfirst=True38 )3940 return df
Funciones que se adaptan según la configuración — misma interfaz, diferente comportamiento
Consejo de senior: este patrón de "comportamiento configurable" es uno de los más valiosos que puedes aprender. En vez de hacer if/else dentro de cada función, externalizas las diferencias en un diccionario de configuración. Cuando llegue la campaña de otoño (y llegará), solo añades una entrada al dict. No tocas la lógica. Esto es lo que separa código que dura 6 meses de código que dura 6 años.
### El pipeline completo parametrizado
1# run_pipeline.py — Pipeline unificado para cualquier campaña2import pandas as pd3import logging4from config import COLUMN_MAPPINGS, COLUMNAS_OBLIGATORIAS, CAMPANA_CONFIG56logging.basicConfig(level=logging.INFO, format='%(asctime)s | %(message)s')7logger = logging.getLogger(__name__)8910def pipeline_campana(ruta_csv: str, campana: str) -> pd.DataFrame:11 """12 Pipeline completo de ingesta y limpieza para cualquier campaña.1314 Args:15 ruta_csv: Ruta al archivo CSV original16 campana: Nombre de la campaña ('verano', 'primavera', etc.)1718 Returns:19 DataFrame limpio con esquema estándar20 """21 logger.info(f"{'='*50}")22 logger.info(f"Pipeline: {campana} | Archivo: {ruta_csv}")23 logger.info(f"{'='*50}")2425 # 1. Cargar26 df = pd.read_csv(ruta_csv)27 logger.info(f"[CARGA] {len(df):,} filas cargadas")2829 # 2. Aplicar mapping de columnas30 df = aplicar_mapping(df, campana)31 logger.info(f"[MAPPING] Columnas normalizadas al esquema estándar")3233 # 3. Eliminar duplicados34 antes = len(df)35 df = df.drop_duplicates()36 logger.info(f"[DUPLICADOS] {antes - len(df)} eliminados")3738 # 4. Normalizar fechas39 df = normalizar_fechas_flexible(df, campana)40 logger.info(f"[FECHAS] Normalizadas a datetime")4142 # 5. Limpiar precio43 df = limpiar_precio_flexible(df, campana)44 logger.info(f"[PRECIO] Convertido a float")4546 # 6. Normalizar anónimos47 df = normalizar_anonimos(df, campana)48 n_anon = df['es_anonimo'].sum()49 logger.info(f"[ANONIMOS] {n_anon} ventas anónimas marcadas")5051 # 7. Filtrar cantidad = 052 antes = len(df)53 df = df[df['cantidad'] > 0]54 logger.info(f"[CANTIDAD] {antes - len(df)} filas con cantidad=0 eliminadas")5556 logger.info(f"[RESULTADO] {len(df):,} filas finales")57 return df585960# Ejecutar para ambas campañas61if __name__ == '__main__':62 verano = pipeline_campana('datos/campana_verano_ventas.csv', 'verano')63 primavera = pipeline_campana('datos/campana_primavera_ventas.csv', 'primavera')6465 # Unificar66 unificado = pd.concat([verano, primavera], ignore_index=True)67 unificado.to_csv('output/silver_ventas_unificado.csv', index=False)68 logger.info(f"\n✅ Total unificado: {len(unificado):,} filas → silver_ventas_unificado.csv")
Un solo pipeline que procesa cualquier campaña — solo necesita nombre y ruta
### Por qué Parquet y no CSV en silver
Elena te dijo que la zona silver debe tener los datos en Parquet, no en CSV. ¿Por qué? Porque Parquet es columnar (lee solo las columnas que necesitas), comprimido (ocupa 5-10x menos), tipado (no pierdes tipos como en CSV) y rápido (las herramientas analíticas lo leen mucho más rápido). En nuestros ejercicios usamos CSV para que puedas inspeccionarlos fácilmente, pero en un proyecto real el output de silver sería:
1# En un proyecto real, el output es Parquet:2verano.to_parquet(3 'freshmart-lake/silver/campana_ventas/campana=verano/data.parquet',4 index=False,5 engine='pyarrow'6)7# → 4.8 MB en CSV se reduce a ~1.2 MB en Parquet8# → Los tipos (datetime, float, int) se preservan9# → Athena/Spark/DuckDB lo leen 10x más rápido que CSV
Parquet es el formato estándar en silver/gold — CSV es solo para humanos
### Resumen del día 2 (tarde)
- Implementaste un pipeline de limpieza con funciones atómicas y logging
- Cada problema de datos tiene su función dedicada — testeable y reutilizable
- María cambió los requisitos a mitad de tarea (esto es NORMAL)
- Consultaste a Elena antes de decidir la arquitectura del cambio
- Implementaste un column mapping configurable para absorber formatos diferentes
- Tu pipeline ahora procesa CUALQUIER campaña con solo añadir un dict de config
Un error común de juniors: hacer la limpieza "a mano" en un notebook y luego copiar los datos limpios. Eso funciona UNA vez. Cuando María te mande los datos de otoño (y lo hará), tendrás que repetir todo. El pipeline parametrizado que has construido es REUTILIZABLE. Esa es la diferencia entre un analista y un ingeniero de datos: el ingeniero construye procesos, no resultados puntuales.
## ejercicios
Función de limpieza: convertir precio de string a float
El CSV de ventas de verano tiene la columna "precio" con formato "2,45€". Escribe una función limpiar_precio(df) que: (1) quite el símbolo €, (2) reemplace la coma por punto, (3) convierta a float. Aplícala al DataFrame de ventas y muestra estadísticas básicas del precio limpio (min, max, media, mediana).
Función de limpieza: normalizar 3 formatos de fecha
La columna fecha_compra del CSV de verano tiene 3 formatos: "2024-07-15", "15/07/2024" y "Jul 15, 2024". Escribe una función normalizar_fechas(df) que los unifique a datetime usando pd.to_datetime con format="mixed". Valida que no queden NaT (fechas que no se pudieron parsear).
Implementar el column mapping para primavera
El CSV de primavera tiene columnas con nombres diferentes (id_transaccion, id_cliente, fecha_venta, unidades, importe, descuento, almacen, promocion). Escribe una función aplicar_mapping(df, campana) que renombre las columnas al esquema estándar usando el diccionario de mapping. Valida que después del rename existan todas las columnas obligatorias.
Pipeline de limpieza completo para ambas campañas
Junta todo: escribe un script que (1) cargue los CSVs de verano y primavera, (2) aplique el mapping a cada uno, (3) ejecute las funciones de limpieza adaptadas, (4) unifique ambos en un solo DataFrame, y (5) muestre un resumen con filas por campaña, total de anónimos y rango de fechas.
Regístrate para guardar tu progreso.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...