lección 7
Día 4 (tarde) — La tabla gold para María
Construyes la tabla gold/campaign_performance con métricas agregadas. SQL real, decisiones de diseño, un preview para María nerviosa y documentación del pipeline.
⏱ 55 min
### 12:30 PM — Después del incidente: la tarea pendiente
El incidente del DAG te consumió la mañana, pero ahora tienes una tarea igual de importante: construir la tabla gold que María necesita para su presentación de MAÑANA a dirección. Elena te lo dijo ayer en el 1:1: "Mañana te voy a pedir algo más complejo — la tabla gold." Pues hoy es mañana.
Para entender qué es una tabla "gold", recuerda la arquitectura del data lake en capas. Raw es el dato original sin tocar (lo que subiste el lunes). Silver es el dato limpio y normalizado (lo que construiste ayer). Y gold es el dato AGREGADO y listo para consumir por negocio — métricas calculadas, dimensiones precargadas, todo lo que un dashboard o un analista necesita sin tener que escribir queries complejas.
La analogía perfecta: raw es el trigo. Silver es la harina. Gold es el pan en la estantería del supermercado. Nadie compra trigo para hacer una tostada — compra el pan ya hecho. Los stakeholders de negocio son los que compran pan.
### Decisiones de diseño: la granularidad
Antes de escribir una sola línea de SQL, necesitas responder la pregunta más importante del modelado de datos: ¿cuál es la GRANULARIDAD de tu tabla? Es decir, ¿qué representa cada fila?
Tienes varias opciones:
- Una fila por CAMPAÑA (verano, primavera) → 2 filas en total. Demasiado agregado.
- Una fila por CAMPAÑA + DÍA → ~180 filas por campaña. María puede ver evolución diaria. ✓
- Una fila por CAMPAÑA + DÍA + PRODUCTO → miles de filas. Quizás demasiado detallado para un dashboard.
- Una fila por TRANSACCIÓN → eso ya es silver, no gold.
La decisión correcta para este caso es CAMPAÑA + DÍA. María quiere comparar campañas día a día ("¿cuándo fue el pico de ventas de verano? ¿Y el de primavera?"). Si necesita detalle por producto, puede consultar la tabla silver directamente. Gold es la vista resumida — el nivel al que un director mira un dashboard.
Consejo de senior: la granularidad es LA decisión más importante al diseñar una tabla analítica. Si la eliges mal, o agregar datos después es imposible (granularidad fina → gruesa es fácil, al revés no). Jorge diría "¿a qué granularidad?" y tiene razón. Antes de escribir SQL, decide qué representa cada fila.
### Las métricas que necesita María
Revisas el email de María y su mensaje de ayer en Slack. Necesita:
- 01.Revenue total verano vs primavera (bruto y neto — Jorge insiste en ambos)
- 02.Clientes únicos por campaña (que compraron, no solo los que recibieron el email)
- 03.Clientes nuevos captados (primera compra durante la campaña)
- 04.Ticket medio por campaña
- 05.Top 10 productos más vendidos en cada campaña
- 06.Evolución diaria de revenue (para el gráfico de líneas)
Los puntos 1-4 y 6 se resuelven con tu tabla gold a granularidad día+campaña. El punto 5 (top productos) requiere una tabla auxiliar a granularidad producto+campaña. Vamos a construir ambas.
### La query SQL: gold/campaign_performance
1-- ================================================================2-- TABLA GOLD: campaign_performance3-- Granularidad: campaña + día4-- Propósito: métricas diarias por campaña para dashboards y reportes5-- ================================================================67CREATE TABLE IF NOT EXISTS gold.campaign_performance AS89WITH ventas_limpias AS (10 -- Partimos de la tabla silver (ya limpia y validada)11 SELECT12 campana,13 fecha_compra::date AS fecha,14 cliente_id,15 producto_id,16 cantidad,17 precio,18 descuento_pct,19 es_anonimo20 FROM silver.campaign_ventas21 WHERE cantidad > 0 -- Por si acaso (ya filtrado en silver, pero defense in depth)22),2324metricas_diarias AS (25 SELECT26 campana,27 fecha,2829 -- Revenue30 SUM(precio * cantidad) AS revenue_bruto,31 SUM(precio * cantidad * (1 - COALESCE(descuento_pct, 0) / 100.0)) AS revenue_neto,3233 -- Volumen34 COUNT(*) AS num_transacciones,35 SUM(cantidad) AS unidades_vendidas,3637 -- Clientes38 COUNT(DISTINCT CASE WHEN NOT es_anonimo THEN cliente_id END) AS clientes_unicos,39 SUM(CASE WHEN es_anonimo THEN 1 ELSE 0 END) AS ventas_anonimas,4041 -- Productos42 COUNT(DISTINCT producto_id) AS productos_distintos,4344 -- Ticket medio (revenue neto / transacciones identificadas)45 SUM(precio * cantidad * (1 - COALESCE(descuento_pct, 0) / 100.0))46 / NULLIF(COUNT(*), 0) AS ticket_medio4748 FROM ventas_limpias49 GROUP BY campana, fecha50)5152SELECT53 campana,54 fecha,55 revenue_bruto,56 revenue_neto,57 num_transacciones,58 unidades_vendidas,59 clientes_unicos,60 ventas_anonimas,61 productos_distintos,62 ticket_medio,63 -- Métricas acumuladas (running totals)64 SUM(revenue_neto) OVER (65 PARTITION BY campana ORDER BY fecha66 ) AS revenue_neto_acumulado,67 SUM(num_transacciones) OVER (68 PARTITION BY campana ORDER BY fecha69 ) AS transacciones_acumuladas7071FROM metricas_diarias72ORDER BY campana, fecha;
La tabla gold: métricas diarias + running totals para gráficos de evolución
Observa las decisiones en esta query: incluimos TANTO revenue bruto como neto (Jorge estaría orgulloso), calculamos el ticket medio como neto/transacciones (no bruto), mantenemos las ventas anónimas como columna separada (para que se pueda filtrar), y añadimos running totals con window functions para los gráficos de evolución acumulada.
### La tabla auxiliar: top productos por campaña
1-- ================================================================2-- TABLA GOLD AUXILIAR: campaign_top_products3-- Granularidad: campaña + producto (rankeado por revenue)4-- ================================================================56CREATE TABLE IF NOT EXISTS gold.campaign_top_products AS78WITH producto_stats AS (9 SELECT10 v.campana,11 v.producto_id,12 p.nombre AS producto_nombre,13 p.marca,14 cat.nombre AS categoria,15 SUM(v.precio * v.cantidad) AS revenue_bruto,16 SUM(v.cantidad) AS unidades_vendidas,17 COUNT(DISTINCT v.cliente_id) AS compradores_unicos,18 COUNT(*) AS num_transacciones19 FROM silver.campaign_ventas v20 LEFT JOIN silver.productos p ON v.producto_id = p.producto_id21 LEFT JOIN silver.categorias cat ON p.categoria_id = cat.categoria_id22 WHERE v.cantidad > 023 GROUP BY v.campana, v.producto_id, p.nombre, p.marca, cat.nombre24),2526rankeados AS (27 SELECT28 *,29 ROW_NUMBER() OVER (30 PARTITION BY campana31 ORDER BY revenue_bruto DESC32 ) AS ranking33 FROM producto_stats34)3536SELECT * FROM rankeados37WHERE ranking <= 50 -- Top 50 por campaña (María pidió 10 pero guardamos más)38ORDER BY campana, ranking;
Top productos por revenue — ROW_NUMBER para el ranking
### Implementarlo en Python + Pandas (ejecución local)
La query SQL anterior es lo que correríamos en un warehouse (Redshift, DuckDB). Pero como estamos trabajando en local con los CSVs, lo implementas en Python con Pandas. La lógica es idéntica — solo cambia la sintaxis:
1# pipelines/campaign_ingestion/gold.py2"""Construcción de la tabla gold/campaign_performance."""3import logging4from pathlib import Path56import pandas as pd78logger = logging.getLogger(__name__)91011def build_campaign_performance(silver_path: Path) -> pd.DataFrame:12 """Construye la tabla gold con métricas diarias por campaña."""13 # Leer datos de silver14 df = pd.read_parquet(silver_path / "campaign_ventas.parquet")15 logger.info(f"Leídas {len(df):,} filas de silver")1617 # Granularidad: campaña + día18 df["fecha"] = df["fecha_compra"].dt.date1920 gold = df.groupby(["campana", "fecha"]).agg(21 revenue_bruto=("precio", lambda x: (x * df.loc[x.index, "cantidad"]).sum()),22 num_transacciones=("venta_id", "count"),23 unidades_vendidas=("cantidad", "sum"),24 clientes_unicos=("cliente_id", "nunique"),25 productos_distintos=("producto_id", "nunique"),26 ventas_anonimas=("es_anonimo", "sum"),27 ).reset_index()2829 # Revenue neto (con descuento)30 neto = df.copy()31 neto["rev_neto"] = (32 neto["precio"] * neto["cantidad"]33 * (1 - neto["descuento_pct"].fillna(0) / 100.0)34 )35 gold_neto = neto.groupby(["campana", neto["fecha_compra"].dt.date]).agg(36 revenue_neto=("rev_neto", "sum"),37 ).reset_index()38 gold_neto.columns = ["campana", "fecha", "revenue_neto"]3940 # Merge41 gold = gold.merge(gold_neto, on=["campana", "fecha"])4243 # Ticket medio44 gold["ticket_medio"] = gold["revenue_neto"] / gold["num_transacciones"]4546 # Running totals (acumulados)47 gold = gold.sort_values(["campana", "fecha"])48 gold["revenue_neto_acumulado"] = gold.groupby("campana")["revenue_neto"].cumsum()49 gold["transacciones_acumuladas"] = gold.groupby("campana")["num_transacciones"].cumsum()5051 logger.info(f"Tabla gold construida: {len(gold)} filas (días × campañas)")52 return gold535455def build_top_products(silver_path: Path, top_n: int = 50) -> pd.DataFrame:56 """Top N productos por revenue en cada campaña."""57 ventas = pd.read_parquet(silver_path / "campaign_ventas.parquet")58 productos = pd.read_parquet(silver_path / "productos.parquet")5960 ventas["revenue"] = ventas["precio"] * ventas["cantidad"]6162 stats = ventas.groupby(["campana", "producto_id"]).agg(63 revenue_bruto=("revenue", "sum"),64 unidades_vendidas=("cantidad", "sum"),65 compradores_unicos=("cliente_id", "nunique"),66 ).reset_index()6768 # Enriquecer con nombre del producto69 stats = stats.merge(70 productos[["producto_id", "nombre", "marca"]],71 on="producto_id",72 how="left",73 )7475 # Ranking por revenue dentro de cada campaña76 stats["ranking"] = stats.groupby("campana")["revenue_bruto"].rank(77 ascending=False, method="dense"78 ).astype(int)7980 return stats[stats["ranking"] <= top_n].sort_values(["campana", "ranking"])
gold.py — La misma lógica del SQL implementada en Pandas para ejecución local
### 15:00 PM — María está nerviosa: el preview
A las 3 de la tarde, María te escribe. Mañana presenta a dirección (Laura, la directora de marketing, estará ahí) y necesita ver los números antes:
1María García 15:02 PM2Oye!! ¿cómo van los números? Mañana a las 12 presento a dirección3¿Me puedes pasar un preview? Solo necesito:4- Revenue total verano vs primavera5- Clientes nuevos captados en cada campaña6- Top 10 productos más vendidos en cada una7Si puedes mandármelo en un Excel sería 💯8(perdona que sea pesada, es que Laura me está presionando mucho 😅)910Tú 15:15 PM11Tranquila María, lo tengo casi listo. Te mando un resumen por email12antes de las 17:00 con esos 3 puntos.13Una duda: cuando dices "clientes nuevos captados", ¿te refieres a14clientes que se REGISTRARON durante la campaña o clientes que hicieron15su PRIMERA compra durante la campaña?1617María García 15:18 PM18Hmm buena pregunta 🤔19Los dos datos me interesan, pero lo principal es los que COMPRARON20por primera vez. Esos son los que cuenta Laura.2122Tú 15:20 PM23Perfecto, te incluyo ambos. A las 17 lo tienes.
Clarificar definiciones ANTES de dar números — fundamental
Nota cómo hiciste la pregunta correcta. "Clientes nuevos" puede significar 3 cosas diferentes y María no se había planteado cuál quería. Si le hubieras dado el número sin preguntar y luego Laura dice "no, yo quería los que COMPRARON, no los registrados", habrías tenido que rehacer el análisis con la presión del tiempo.
### 16:30 PM — El resumen ejecutivo para no-técnicos
Preparas el resumen que le mandas a María por email. Esto NO es un output de SQL ni un DataFrame crudo — es una comunicación que un directivo de marketing va a leer. Piensa en lo que NECESITAN saber, no en lo que tú CALCULASTE:
1Asunto: Preview datos campañas — para tu presentación del viernes23Hola María,45Aquí van los números que necesitas. Todos calculados sobre datos6validados y limpios (42.735 transacciones de verano, 31.048 de primavera).78━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━91. REVENUE TOTAL10━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━11 Verano Primavera Diferencia12Revenue bruto: €847.234 €612.891 +38.2%13Revenue neto: €721.449 €534.112 +35.1%14Descuento medio: 14.8% 12.8% —1516→ Verano generó un 35% más de revenue neto, aunque también17 se aplicaron más descuentos.1819━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━202. CLIENTES NUEVOS CAPTADOS21━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━22 Verano Primavera23Primera compra: 3.412 2.189 +55.8%24Registro nuevo: 4.891 3.102 +57.6%2526→ De los 15K clientes que recibieron la campaña de verano,27 3.412 hicieron su PRIMERA compra durante la promo.28 Eso es un 22.4% de conversión de new-to-buyer.2930━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━313. TOP 5 PRODUCTOS (por revenue neto)32━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━33Verano:34 1. Pack BBQ premium (€23.450)35 2. Cerveza artesana 12-pack (€18.920)36 3. Helado premium 1L (€15.340)37 4. Gazpacho fresco 2L (€12.890)38 5. Sandía entera (€11.230)3940Primavera:41 1. Aceite oliva virgen extra 1L (€14.230)42 2. Espárragos frescos 500g (€11.890)43 3. Vino rosado (€10.450)44 4. Fresas 1kg (€9.870)45 5. Jamón ibérico 100g (€8.920)4647→ Los tops de cada campaña coinciden con la estacionalidad48 (BBQ en verano, productos frescos primavera).4950━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━51NOTAS TÉCNICAS (por si Laura pregunta):52- Revenue NETO = después de descuentos, ANTES de devoluciones53 (las devoluciones tardan hasta 14 días, no hay datos completos aún)54- "Cliente nuevo" = primera compra durante la campaña55- Ventas anónimas (guest checkout) NO están incluidas en las56 métricas de clientes pero SÍ en el revenue.57━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━5859Si necesitas más detalle o algún corte diferente, dime.60¡Mucha suerte mañana!
Comunicar datos a no-técnicos: claro, con contexto y anticipando preguntas
Consejo de senior: cuando comunicas números a negocio, sigue esta fórmula: (1) el dato crudo, (2) la comparación (vs otra campaña, vs mes anterior, vs objetivo), (3) el "so what" — qué SIGNIFICA ese número para el negocio. Un dato sin contexto es ruido. "€721K de revenue neto" no dice nada. "€721K, un 35% más que primavera, impulsado por BBQ y cerveza artesana" cuenta una historia.
### 17:00 PM — Documentar el pipeline (README)
Elena te pidió documentación. No es glamouroso, pero es lo que separa un proyecto mantenible de un misterio que nadie puede tocar cuando tú estés de vacaciones. Escribes un README pragmático:
1# Pipeline de Ingesta de Campañas de Marketing23## Qué hace4Ingesta CSVs de campañas de Marketing desde raw, los limpia,5valida y deja una tabla gold con métricas diarias por campaña.67## Arquitectura8raw/ (CSVs originales) → silver/ (limpio, Parquet) → gold/ (agregado)910## Cómo ejecutar1112### Pipeline completo (una campaña):13```bash14# 🟦 Windows (PowerShell):15python pipelines\campaign_ingestion\run.py --campaign verano1617# 🍎 Mac (Terminal):18python pipelines/campaign_ingestion/run.py --campaign verano19```2021### Solo tabla gold (si silver ya está listo):22```bash23python pipelines/campaign_ingestion/run.py --only-gold24```2526### Tests:27```bash28pytest pipelines/campaign_ingestion/tests/ -v29```3031## Campañas soportadas32| Campaña | Periodo | Archivos en raw |33|-------------|-------------------|--------------------|34| verano | Jun-Sep 2024 | 3 CSVs de María |35| primavera | Mar-May 2024 | 1 CSV (otro formato)|3637## Añadir una nueva campaña381. Añadir entrada al Enum Campaign en config.py392. Añadir mapping de columnas en COLUMN_MAPPINGS403. Añadir config de limpieza en CAMPAIGN_CONFIG414. Añadir rango de fechas en DATE_RANGES425. Ejecutar con --campaign <nombre>436. Verificar que pytest pasa4445## Problemas conocidos46- Los CSVs de Marketing no tienen formato estándar (cada campaña diferente)47- Las ventas anónimas (guest checkout) no tienen cliente_id48- Las devoluciones NO están descontadas del revenue (tardan 14 días)4950## Owner51- Pipeline: tú52- Datos fuente: María García (Marketing)53- Tabla gold: consumida por dashboards de Metabase + Jorge (análisis)
README pragmático: qué hace, cómo ejecutar, cómo extender, problemas conocidos
La documentación que escribes HOY te salvará a TI MISMO dentro de 3 meses cuando no recuerdes por qué el pipeline de primavera usa un mapping diferente. He visto ingenieros que dicen "no tengo tiempo de documentar" y luego pasan 4 horas intentando entender su propio código del mes pasado. 10 minutos de README ahora = horas de frustración ahorradas después.
### Resumen del Día 4 (tarde)
- Tabla gold/campaign_performance construida con métricas diarias (SQL + Pandas)
- Tabla auxiliar campaign_top_products con ranking por revenue
- Decisión de diseño: granularidad día+campaña para dashboards de evolución
- Preview ejecutivo enviado a María con los 3 puntos que necesita para la demo
- Pipeline documentado con README profesional
## ejercicios
Construir la query gold de métricas por campaña
Escribe una query SQL (compatible con DuckDB o PostgreSQL) que calcule métricas diarias por campaña: revenue_bruto, revenue_neto (con descuento), num_transacciones, clientes_unicos y ticket_medio. Usa la tabla silver.campaign_ventas como fuente. Granularidad: campaña + día.
Calcular el Top 10 productos por campaña con ranking
Escribe una query SQL que devuelva los 10 productos con más revenue por campaña. Usa ROW_NUMBER() para asignar el ranking. Incluye: campaña, ranking, nombre del producto, revenue total y unidades vendidas. Necesitas hacer JOIN con la tabla de productos.
Calcular clientes nuevos por campaña
María necesita saber cuántos clientes hicieron su PRIMERA COMPRA durante cada campaña. Escribe una query que: (1) encuentre la primera fecha de compra de cada cliente en toda la tabla de pedidos, (2) cuente cuántos de esos "primeros compradores" caen dentro del rango de cada campaña (verano: jun-sep 2024, primavera: mar-may 2024).
Generar resumen ejecutivo en Python
Escribe una función que tome el DataFrame de la tabla gold y genere un resumen de texto para un no-técnico. Debe incluir: revenue total por campaña, diferencia porcentual entre ambas, y el día con más ventas de cada campaña. El output debe ser texto legible, no un DataFrame.
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...