lección 3
Día 2 (mañana) — El cruce para Jorge
Escribes tu primera query SQL de segmentación real, descubres que 6.782 clientes no tienen pedidos, y comunicas el hallazgo a un stakeholder.
⏱ 45 min
### 9:30 AM — Standup async
Hoy es martes. El standup es async por Slack — Elena prefiere no hacer videollamada cuando todo el mundo tiene claro en qué está:
1#data-team23Elena Torres 9:31 AM4Standup:5- Yo: revisando el modelo de dbt para el Q4, PR abierta6- Raúl: sigue con el pipeline de inventario (¿para cuándo?)7- tú: ayer subió los datos de campaña a raw. Hoy: limpieza +8 lo de Jorge9¿Algo bloqueado?1011Raúl Vega 9:38 AM12lo del inventario espero hoy, me falta un edge case con los productos13descatalogados1415Tú 9:42 AM16Nada bloqueado. Voy con el cruce para Jorge y la limpieza de los17datos de campaña.
Standup async — nadie pierde tiempo si no hay bloqueos
### 10:00 AM — Planteando el cruce de segmentación
Jorge quiere saber: de los 15K clientes de la campaña, ¿cuántos son premium (>6 pedidos en 3 meses), regulares (2-6 pedidos) o nuevos (<2 pedidos)? Para responder vamos a usar tres datasets: campana_verano_clientes.csv (la lista de la campaña, con el cliente_id), pedidos.csv (el historial de pedidos del warehouse) y clientes.csv (la tabla maestra del CRM, de donde sacaremos el nombre y la fecha_registro, porque el archivo de campaña solo trae IDs y engagement). Esto es un par de LEFT JOIN con una agregación.
Piénsalo como un problema de la vida real: tienes dos listas. La primera es "personas invitadas a la fiesta" (clientes de la campaña). La segunda es "registro de compras del supermercado en los últimos 3 meses" (pedidos). Quieres saber, para cada invitado, cuántas veces ha comprado. Algunos invitados puede que NUNCA hayan comprado — y esos son los interesantes porque representan captación fallida.
La query se construye en 3 pasos lógicos: (1) contar pedidos por cliente en los últimos 3 meses, (2) cruzar esa cuenta con la lista de clientes de la campaña, (3) clasificar en segmentos según el número de pedidos. Vamos a usar CTEs (Common Table Expressions) para que cada paso sea legible:
1-- segmentacion_campana.sql2-- Cruce para Jorge: segmentación de clientes de la campaña de verano34-- Paso 1: Contar pedidos recientes por cliente5WITH pedidos_recientes AS (6 SELECT7 cliente_id,8 COUNT(*) AS pedidos_3m9 FROM pedidos10 WHERE fecha_pedido >= '2024-06-16' -- últimos 3 meses desde sept 202411 AND estado NOT IN ('cancelado')12 GROUP BY cliente_id13),1415-- Paso 2: Cruzar con los clientes de la campaña y la tabla maestra16-- LEFT JOIN: queremos TODOS los de la campaña, tengan pedidos o no17-- JOIN con clientes (maestra) porque nombre/email/fecha_registro NO están18-- en campana_verano_clientes.csv (ahí solo está el cliente_id de la campaña)19segmentacion AS (20 SELECT21 c.cliente_id,22 m.nombre,23 m.email,24 m.fecha_registro,25 COALESCE(p.pedidos_3m, 0) AS pedidos_ultimos_3m,26 CASE27 WHEN COALESCE(p.pedidos_3m, 0) > 6 THEN 'premium'28 WHEN COALESCE(p.pedidos_3m, 0) >= 2 THEN 'regular'29 ELSE 'nuevo'30 END AS segmento31 FROM campana_verano_clientes c32 LEFT JOIN clientes m ON c.cliente_id = m.cliente_id33 LEFT JOIN pedidos_recientes p ON c.cliente_id = p.cliente_id34)3536-- Paso 3: Resultado ordenado por actividad37SELECT * FROM segmentacion38ORDER BY pedidos_ultimos_3m DESC;
La query completa en 3 CTEs — clara, legible, explicable en una servilleta
### Por qué LEFT JOIN y no INNER JOIN
Este es un punto donde muchos juniors se equivocan. Si usas INNER JOIN, solo obtienes los clientes que TIENEN pedidos. Pero Jorge quiere saber TAMBIÉN cuántos NO tienen pedidos — esos son los "nuevos" que captó la campaña pero no convirtieron. Con LEFT JOIN, todos los clientes de la campaña aparecen en el resultado; los que no tienen pedidos muestran NULL en la columna de conteo, que convertimos a 0 con COALESCE.
### 11:00 AM — Comunicar el hallazgo a Jorge
Tienes el resultado. Pero antes de mandarlo sin más, haces algo que diferencia a un ingeniero bueno de uno mediocre: comunicas el HALLAZGO, no solo el archivo. Los 6.782 clientes sin pedidos son un dato relevante para negocio.
1Tú 11:05 AM2Jorge, te dejo el CSV en el canal #mkt-data.3Un dato: hay 6.782 clientes de la campaña que no tienen ningún4pedido en los últimos 3 meses. ¿Es normal? ¿Pueden ser cuentas5nuevas que se registraron pero no compraron?67Jorge Ruiz 11:12 AM8Buena observación, y me temo que no es lo que los dos pensábamos.9Yo daba por hecho que serían los captados en la campaña de junio.10Mírales la fecha de registro antes de darlo por bueno: si la mayoría11son de junio, es captación que no convirtió y se arregla con un12remarketing. Si son clientes de hace años que han dejado de comprar,13es otra cosa y bastante peor.14¿Me puedes añadir la fecha de registro al CSV?15Así distingo "captados pre-campaña" de "ya eran clientes".1617Tú 11:15 AM18Claro, te lo actualizo en 20 min.
Jorge no lo da por bueno de memoria — pide el dato que lo decide
Fíjate en lo que pasó: comunicaste un dato anómalo, y en lugar de confirmártelo de memoria, Jorge te pidió el dato que lo decide. Eso es exactamente lo que hay que hacer con una corazonada, la tuya o la suya. Actualizas la query añadiendo m.fecha_registro al SELECT, reexportas — y el resultado desmiente a los dos: de los 6.782, solo 349 se registraron a partir de junio. Los otros 6.433 son clientes de siempre que han dejado de comprar. La campaña no falló captando: la empresa está perdiendo clientes antiguos, que es un problema mucho más caro.
Consejo de senior: cuando encuentres un dato que "no cuadra", SIEMPRE comunícalo al stakeholder antes de asumir que es un error y "corregirlo". He visto juniors que eliminaron filas con cliente_id sin pedidos pensando que eran errores — y resultó que esos eran EXACTAMENTE los clientes que el negocio quería analizar. Los datos raros no son bugs hasta que alguien del negocio te confirma que lo son.
### Ejecutar la query con Python (Pandas + SQL)
Como estamos trabajando en local con CSVs descargados, vamos a usar Pandas para simular el cruce SQL. En un entorno real harías esta query directamente contra PostgreSQL con sqlalchemy o psycopg2. Pero la lógica es exactamente la misma:
1# cruce_segmentacion.py — Lo que Jorge necesita2import pandas as pd3from datetime import datetime, timedelta45# Cargar datos6clientes_campana = pd.read_csv('datos/campana_verano_clientes.csv')7clientes_maestra = pd.read_csv('datos/clientes.csv') # nombre, fecha_registro, email...8pedidos = pd.read_csv('datos/pedidos.csv')910# Convertir fechas: pedidos.csv trae ISO con hora y un 4% en DD/MM/YYYY.11# OJO con el atajo format='mixed', dayfirst=True: con dayfirst=True Pandas12# lee '2024-06-03' como año-día-mes y te manda 16.834 pedidos a otro mes.13# Dos pasadas: primero ISO, y lo que quede, con el día delante.14fechas = pd.to_datetime(pedidos['fecha_pedido'], format='ISO8601',15 errors='coerce')16resto = fechas.isna()17fechas[resto] = pd.to_datetime(pedidos.loc[resto, 'fecha_pedido'],18 dayfirst=True, errors='coerce')19pedidos['fecha_pedido'] = fechas2021# Paso 1: Filtrar pedidos últimos 3 meses (desde 2024-06-16)22fecha_corte = pd.Timestamp('2024-06-16')23pedidos_recientes = pedidos[24 (pedidos['fecha_pedido'] >= fecha_corte) &25 (pedidos['estado'] != 'cancelado')26]2728# Paso 2: Contar pedidos por cliente29conteo = (30 pedidos_recientes31 .groupby('cliente_id')32 .size()33 .reset_index(name='pedidos_3m')34)3536# Paso 3: LEFT JOIN con clientes de la campaña37resultado = clientes_campana.merge(38 conteo, on='cliente_id', how='left'39)40resultado['pedidos_3m'] = resultado['pedidos_3m'].fillna(0).astype(int)4142# Paso 4: Clasificar en segmentos43def clasificar(pedidos):44 if pedidos > 6:45 return 'premium'46 elif pedidos >= 2:47 return 'regular'48 else:49 return 'nuevo'5051resultado['segmento_calculado'] = resultado['pedidos_3m'].apply(clasificar)5253# Paso 5: Enriquecer con datos de la tabla maestra (nombre, fecha_registro)54# OJO: campana_verano_clientes.csv NO trae nombre ni fecha_registro,55# solo el cliente_id. Hay que cruzarlo con clientes.csv.56# Y OJO otra vez: clientes.csv tiene 301 cliente_id repetidos. Si cruzas57# sin deduplicar, el LEFT JOIN devuelve 15.552 filas en vez de 15.247 —58# cada id repetido multiplica su fila. Una fila por cliente y listo.59maestra = clientes_maestra.drop_duplicates(subset=['cliente_id'])60resultado = resultado.merge(61 maestra[['cliente_id', 'nombre', 'fecha_registro']],62 on='cliente_id', how='left'63)6465# Resumen66print("Distribución de segmentos:")67print(resultado['segmento_calculado'].value_counts())68print(f"\nClientes sin pedidos: {(resultado['pedidos_3m'] == 0).sum()}")6970# Exportar para Jorge (con fecha_registro incluida)71cols_jorge = ['cliente_id', 'nombre', 'fecha_registro',72 'pedidos_3m', 'segmento_calculado']73resultado[cols_jorge].to_csv('output/segmentacion_jorge.csv', index=False)74print("\nArchivo exportado: output/segmentacion_jorge.csv")
El mismo cruce SQL pero con Pandas — misma lógica, mismo resultado
### Reflexión: SQL vs Pandas para cruces
¿Por qué mostramos tanto la query SQL como el código Pandas? Porque en la vida real usarás AMBOS. Cuando los datos están en una base de datos (PostgreSQL, Redshift, BigQuery), el cruce lo haces en SQL — es más rápido, el motor optimiza por ti. Cuando los datos son archivos locales (CSVs que te manda María), usas Pandas. La lógica es idéntica: LEFT JOIN + GROUP BY + CASE. Solo cambia la sintaxis.
Cuidado con hacer cruces grandes en Pandas cuando podrías hacerlos en SQL. Un merge de dos DataFrames de 1M filas puede consumir toda tu RAM y tardar minutos. La misma query en PostgreSQL tarda segundos porque usa índices y un optimizador sofisticado. Regla general: si los datos ya están en una base de datos, hazlo en SQL. Si son archivos locales pequeños (<100K filas), Pandas está bien.
### El COALESCE: tu mejor amigo con LEFT JOINs
Cuando haces un LEFT JOIN, las filas de la tabla izquierda que NO tienen match en la derecha aparecen con NULL en todas las columnas del lado derecho. Si estás contando pedidos, esos NULLs significan "cero pedidos". COALESCE(valor, 0) convierte NULL a 0. En Pandas el equivalente es .fillna(0). Parece un detalle menor, pero si no lo haces, tu CASE WHEN no clasificará bien esos registros (NULL > 6 es NULL, no False).
### Resumen de la mañana
- Escribiste una query SQL de segmentación real con CTEs y LEFT JOIN
- Descubriste que 6.782 clientes de la campaña no tienen pedidos recientes — y que el 95 % de ellos no son captación fallida, sino clientes antiguos que se han ido
- Comunicaste el hallazgo a Jorge ANTES de asumir que era un error
- Jorge iteró pidiendo un campo adicional — y respondiste en 20 minutos
- Ejecutaste la misma lógica en Pandas para trabajar con archivos locales
Consejo de senior: una buena query SQL se lee como una historia. Las CTEs son los capítulos. Si tu query tiene 100 líneas sin una sola CTE, probablemente es ilegible. Divide, nombra cada paso, y cualquier persona del equipo podrá entenderla en 30 segundos. "¿Qué hace esta query?" → "Primero cuenta pedidos recientes, luego cruza con la campaña, luego clasifica en segmentos." Así de claro debe ser.
Regístrate para guardar tu progreso.