Módulo 6: Accumulating And Cumulative Patterns
Calculando activos de 7 y 30 días por tienda
Descripción
fact_store_activity ya existe, completa y verificada, desde la lección 6. Esta lección la consulta, sin volver a tocar fact_orders, para responder la pregunta de negocio que motivó todo el patrón: ¿qué tienda estuvo más activa en los últimos 7 y 30 días? ¿Cuánto revenue generó cada una en esa ventana? ¿El arreglo de 30 días ya "vale la pena" con solo una semana de historia, o todavía no dice nada que el de 7 días no diga? Esta lección responde las tres, con evidencia ejecutada.
Conexión con el módulo. Esta es la lección de análisis del segundo patrón del módulo — el equivalente, para fact_store_activity, de lo que la lección 3 hizo con el funnel de fact_sessions. La lección 8 integra ambos análisis en el proyecto de cierre.
Ejemplo trabajado: el ranking de tiendas al 2026-08-09
Con fact_store_activity ya cargada (21 filas, de la lección 6), la primera pregunta —qué tienda estuvo más activa— se responde ordenando el snapshot del último día por active_days_7d:
# store_activity_analysis.py -- continua sobre fact_store_activity ya construida (leccion 6)
print("=== Ranking de tiendas por active_days_7d, snapshot del 2026-08-09 ===")
con.sql("""
SELECT store_id, activity_date, active_days_7d, active_days_30d,
ROUND(list_sum(revenue_array_7d), 2) AS revenue_7d
FROM fact_store_activity
WHERE activity_date = DATE '2026-08-09'
ORDER BY active_days_7d DESC, revenue_7d DESC
""").show(max_width=250)
Qué esperar.
=== Ranking de tiendas por active_days_7d, snapshot del 2026-08-09 ===
┌──────────┬───────────────┬────────────────┬─────────────────┬────────────┐
│ store_id │ activity_date │ active_days_7d │ active_days_30d │ revenue_7d │
│ varchar │ date │ int32 │ int32 │ double │
├──────────┼───────────────┼────────────────┼─────────────────┼────────────┤
│ S02 │ 2026-08-09 │ 7 │ 7 │ 38.8 │
│ S01 │ 2026-08-09 │ 7 │ 7 │ 38.3 │
│ S03 │ 2026-08-09 │ 6 │ 6 │ 29.05 │
└──────────┴───────────────┴────────────────┴─────────────────┴────────────┘
S02 (Lima) encabeza el ranking, aunque S01 (Bogotá) y S02 empatan en active_days_7d = 7 — el desempate por revenue_7d (38.8 contra 38.3) le da el primer lugar a S02 por 50 centavos. S03 (Santiago) queda tercera, con un día menos de actividad (6, no 7) y el revenue más bajo de las tres — el mismo día sin ventas (2026-08-05) que ya identificaste en la lección 6 es, exactamente, la causa de ambas diferencias.
Recalculando active_days sin la columna guardada, con list_filter
active_days_7d se guardó como una columna precalculada en la lección 6 (contada en Python, con sum(1 for v in arr if v > 0)). Esta lección la verifica de forma independiente, recalculándola directamente en SQL con list_filter — la función de arreglo de DuckDB que aplica una función lambda a cada elemento y devuelve solo los que cumplen la condición — combinada con len():
print("=== active_days_7d guardado vs recalculado con list_filter + len, en SQL puro ===")
con.sql("""
SELECT store_id, activity_date, active_days_7d AS guardado,
len(list_filter(revenue_array_7d, x -> x > 0)) AS recalculado
FROM fact_store_activity
WHERE activity_date = DATE '2026-08-09'
ORDER BY store_id
""").show(max_width=250)
mismatch = con.sql("""
SELECT COUNT(*) FROM fact_store_activity
WHERE active_days_7d != len(list_filter(revenue_array_7d, x -> x > 0))
""").fetchone()[0]
print(f"\nfilas donde active_days_7d guardado difiere del recalculado (las 21 filas, no solo el snapshot): {mismatch}")
Qué esperar.
=== active_days_7d guardado vs recalculado con list_filter + len, en SQL puro ===
┌──────────┬───────────────┬──────────┬─────────────┐
│ store_id │ activity_date │ guardado │ recalculado │
│ varchar │ date │ int32 │ int64 │
├──────────┼───────────────┼──────────┼─────────────┤
│ S01 │ 2026-08-09 │ 7 │ 7 │
│ S02 │ 2026-08-09 │ 7 │ 7 │
│ S03 │ 2026-08-09 │ 6 │ 6 │
└──────────┴───────────────┴──────────┴─────────────┘
filas donde active_days_7d guardado difiere del recalculado (las 21 filas, no solo el snapshot): 0
Cero diferencias, sobre las 21 filas completas, no solo las tres del snapshot final. list_filter(revenue_array_7d, x -> x > 0) recorre el arreglo y devuelve un arreglo nuevo, más corto, con solo los valores mayores a cero; len(...) cuenta cuántos quedaron. Esta es la alternativa "sin columna guardada" al active_days_7d que la lección 6 calculó en Python — útil para confirmar que la columna precalculada es correcta, y también como patrón reutilizable si en algún momento necesitas ese conteo sobre un arreglo que no tiene una columna dedicada.
¿Vale la pena todavía el arreglo de 30 días?
La tercera pregunta —si el arreglo de 30 días "dice algo distinto" al de 7 días con esta cantidad de historia— se responde con una consulta directa sobre cuántos elementos tiene realmente cada arreglo de 30 días:
print("=== Cuantos elementos tiene revenue_array_30d hoy (deberia ser <= 30) ===")
con.sql("""
SELECT store_id, activity_date, len(revenue_array_30d) AS elementos_en_array_30d
FROM fact_store_activity
WHERE activity_date = DATE '2026-08-09'
ORDER BY store_id
""").show(max_width=200)
Qué esperar.
=== Cuantos elementos tiene revenue_array_30d hoy (deberia ser <= 30) ===
┌──────────┬───────────────┬────────────────────────┐
│ store_id │ activity_date │ elementos_en_array_30d │
│ varchar │ date │ int64 │
├──────────┼───────────────┼────────────────────────┤
│ S01 │ 2026-08-09 │ 7 │
│ S02 │ 2026-08-09 │ 7 │
│ S03 │ 2026-08-09 │ 7 │
└──────────┴───────────────┴────────────────────────┘
Las tres tiendas tienen exactamente 7 elementos en su arreglo de 30 días — el mismo número que en el de 7 días—, porque Kiosko, en este dataset, solo tiene una semana completa de historia. La respuesta honesta a la pregunta es: todavía no, con solo 7 días de datos disponibles, active_days_30d y active_days_7d son necesariamente idénticos para las tres tiendas — el arreglo de 30 días recién empieza a diferenciarse del de 7 días a partir del octavo día de operación, cuando el de 7 días empieza a descartar el día más viejo mientras el de 30 sigue acumulando. Declarar esto explícitamente, en vez de ocultarlo, es parte de la disciplina de esta guía: un número idéntico entre dos columnas con nombres distintos no es un error de cálculo, es una consecuencia honesta de la cantidad de historia disponible.
Diagrama: por qué 7d y 30d convergen con poca historia
Dia 1 Dia 2 Dia 3 Dia 4 Dia 5 Dia 6 Dia 7 Dia 8 (no existe en este dataset)
│ │ │ │ │ │ │ │
└───────┴───────┴───────┴───────┴───────┴───────┘ │
arreglo de 7 dias │
(7 elementos el dia 7, tope alcanzado) │
└───────┴───────┴───────┴───────┴───────┴───────┴─── ... ──┘
arreglo de 30 dias
(7 elementos el dia 7 -- MISMO numero,
porque todavia no hay 30 dias de historia
que llenar. Recien en el dia 8, el arreglo
de 7d empezaria a DESCARTAR el dia 1 mientras
el de 30d lo SIGUE conservando -- ahi divergen.)
Errores comunes
Interpretar active_days_7d == active_days_30d como un error del pipeline. Qué pasa: alguien, al ver que las dos columnas dan el mismo número en las 21 filas de este dataset, asume que la columna de 30 días "no está funcionando" o que hay un bug que copia un valor en el otro. Por qué pasa: dos columnas con nombres distintos que siempre muestran el mismo valor parecen, a primera vista, redundantes o rotas. Cómo detectarlo: si revisas el código de la lección 6, vas a confirmar que ambas columnas se calculan con lógica independiente (new_array_7d y new_array_30d, cada una con su propio recorte) — no hay ningún lugar donde una se copie de la otra. Cómo corregirlo: la igualdad es una consecuencia matemática de tener menos de 7 días de historia, no un error — verifica extendiendo el ejercicio 1 de esta lección a más de 7 días simulados, y vas a ver cómo las dos columnas empiezan a divergir de forma natural.
Usar active_days_7d de una fila que no es la más reciente para responder "cuántos días activos tuvo la tienda esta semana". Qué pasa: alguien consulta active_days_7d de la fila del 2026-08-05 (día 3 de la semana) esperando que responda la pregunta sobre la semana completa. Por qué pasa: todas las filas de fact_store_activity tienen una columna con el mismo nombre, y es fácil olvidar que cada fila responde "los últimos 7 días hasta esa fecha específica", no "los últimos 7 días desde hoy". Cómo detectarlo: si tu pregunta es sobre "la semana completa de Kiosko", y tu consulta no filtra por activity_date = DATE '2026-08-09' (el último día disponible), estás mirando una ventana incompleta. Cómo corregirlo: para responder cualquier pregunta sobre "la actividad más reciente", siempre filtra por la fecha más alta disponible (MAX(activity_date) o una fecha fija conocida, como hace esta lección con 2026-08-09) — cada fila de fact_store_activity es un snapshot válido para su propia fecha, no para cualquier otra.
Comparar revenue_7d entre tiendas sin considerar que el ranking puede cambiar según el criterio de desempate. Qué pasa: alguien reporta "S02 es la tienda más activa" basándose solo en active_days_7d, sin mencionar que S01 tiene exactamente el mismo número y que el desempate depende de una columna distinta (revenue_7d). Por qué pasa: un ranking con un solo criterio se siente más simple de comunicar que uno con desempate explícito. Cómo detectarlo: si tu reporte de "la tienda más activa" no menciona que hay un empate técnico en active_days_7d entre S01 y S02, estás simplificando de más una respuesta que en realidad tiene dos tiendas casi iguales. Cómo corregirlo: cuando un ranking depende de un criterio de desempate, decláralo explícitamente en el reporte — "S02 lidera por revenue, empatada con S01 en días activos" es una afirmación más honesta que "S02 es la más activa" a secas.
Ejercicios
Ejercicio 1 — Simula un octavo día y observa cómo active_days_7d y active_days_30d empiezan a divergir. Agrega una fila ficticia para S01 el 2026-08-10 con daily_revenue = 0.0 (un día sin ventas), aplicando el mismo mecanismo de list_prepend + recorte de la lección 6. Compara active_days_7d y active_days_30d de esa nueva fila.
Ver solución
prev = con.sql("""
SELECT revenue_array_7d, revenue_array_30d FROM fact_store_activity
WHERE store_id = 'S01' AND activity_date = DATE '2026-08-09'
""").fetchone()
new_7d = ([0.0] + list(prev[0]))[:7] # el dia 8.45 (2026-08-03) SALE del arreglo de 7d
new_30d = ([0.0] + list(prev[1]))[:30] # pero SIGUE en el arreglo de 30d -- todavia caben 30
active_7d = sum(1 for v in new_7d if v > 0)
active_30d = sum(1 for v in new_30d if v > 0)
con.execute(
"INSERT INTO fact_store_activity VALUES ('S01', DATE '2026-08-10', 0.0, ?, ?, ?, ?)",
[new_7d, active_7d, new_30d, active_30d],
)
print(con.sql("""
SELECT activity_date, revenue_array_7d, active_days_7d, len(revenue_array_30d) AS len_30d, active_days_30d
FROM fact_store_activity WHERE store_id = 'S01' AND activity_date = DATE '2026-08-10'
"""))
Salida esperada:
┌───────────────┬──────────────────────────────────────────┬────────────────┬─────────┬─────────────────┐
│ activity_date │ revenue_array_7d │ active_days_7d │ len_30d │ active_days_30d │
│ date │ double[] │ int32 │ int64 │ int32 │
├───────────────┼──────────────────────────────────────────┼────────────────┼─────────┼─────────────────┤
│ 2026-08-10 │ [0.0, 1.1, 13.05, 5.0, 6.7, 0.55, 3.45] │ 6 │ 8 │ 7 │
└───────────────┴──────────────────────────────────────────┴────────────────┴─────────┴─────────────────┘
Justo lo que predice el diagrama de esta lección: revenue_array_7d recortó el valor más viejo (8.45, del 2026-08-03) para hacerle espacio al nuevo día, y active_days_7d bajó de 7 a 6 (el día nuevo tiene revenue 0.0). revenue_array_30d, en cambio, todavía tiene espacio (8 elementos, muy por debajo del tope de 30), así que conserva el 8.45 y su active_days_30d se mantiene en 7. Este es exactamente el punto donde las dos ventanas empiezan a contar historias distintas.
Ejercicio 2 — Encuentra la tienda con el revenue promedio más alto por día activo (no por día calendario). Usando list_sum(revenue_array_7d) / active_days_7d, calcula el revenue promedio por día activo (no por los 7 días calendario) para cada tienda, y compara con el promedio simple (list_sum / 7).
Ver solución
print(con.sql("""
SELECT store_id,
ROUND(list_sum(revenue_array_7d) / active_days_7d, 2) AS avg_per_active_day,
ROUND(list_sum(revenue_array_7d) / 7, 2) AS avg_per_calendar_day
FROM fact_store_activity
WHERE activity_date = DATE '2026-08-09'
ORDER BY avg_per_active_day DESC
"""))
Salida esperada:
┌──────────┬─────────────────────┬──────────────────────┐
│ store_id │ avg_per_active_day │ avg_per_calendar_day │
│ varchar │ double │ double │
├──────────┼─────────────────────┼───────────────────────┤
│ S03 │ 4.84 │ 4.15 │
│ S02 │ 5.54 │ 5.54 │
│ S01 │ 5.47 │ 5.47 │
└──────────┴─────────────────────┴───────────────────────┘
S03 es la única tienda donde los dos promedios difieren (4.84 contra 4.15), porque es la única con un día sin ventas dentro de la ventana — dividir por active_days_7d (6) en vez de por 7 le da a S03 un promedio más justo por día realmente trabajado, en vez de diluir su revenue entre un día que no contribuyó nada. S01 y S02, con los 7 días activos, no muestran ninguna diferencia entre ambos promedios — dividir por 6 o por 7 da lo mismo cuando active_days_7d = 7.
Ejercicio 3 — Explica, en tus propias palabras, cuándo active_days_30d empezaría a ser genuinamente más útil que active_days_7d para Kiosko. Sin mirar la profundización de la lección 6, describe en 2-3 frases un escenario de negocio donde la ventana de 30 días revelaría algo que la de 7 días no puede ver.
Ver solución
La ventana de 7 días es sensible a fluctuaciones de corto plazo —un fin de semana flojo puede hacer que una tienda "activa" parezca menos activa esa semana específica—, mientras que la de 30 días suaviza esas fluctuaciones y revela una tendencia más estable. Un escenario concreto: si S03 tuviera dos o tres días flojos dispersos a lo largo de un mes (no consecutivos), su active_days_7d fluctuaría semana a semana dependiendo de en qué días caigan esos días flojos dentro de cada ventana de 7, mientras que active_days_30d daría una lectura más consistente del patrón real de la tienda a lo largo del mes completo, sin que un solo mal día domine el número. Esa es, precisamente, la razón por la que un negocio real —no solo Kiosko— mantiene ambas ventanas en paralelo: la de 7 días para reaccionar rápido, la de 30 para ver la tendencia de fondo.
Resumen y siguiente paso
Esta lección consultó fact_store_activity sin volver a tocar fact_orders, respondiendo tres preguntas de negocio: S02 lidera el ranking de actividad (empatada en días activos con S01, pero con más revenue), active_days_7d cuenta días con ventas reales —no días transcurridos, confirmado con list_filter recalculado de forma independiente y cero diferencias contra la columna guardada—, y active_days_30d todavía no aporta información distinta a active_days_7d porque Kiosko solo tiene una semana de historia, algo que esta lección declaró explícitamente en vez de ocultar.
Antes de avanzar deberías poder: interpretar un ranking con empate y desempate explícito; escribir de memoria el patrón list_filter(arr, x -> x > 0) + len(...) para contar valores positivos en un arreglo; y explicar por qué dos columnas con nombres distintos pueden, legítimamente, mostrar el mismo valor sin que eso sea un error.
La lección 8 —el proyecto de cierre del módulo— integra fact_sessions y fact_store_activity en un solo flujo verificado con assert, documentando ambos patrones en una estructura formal, exactamente como cerraron los módulos 1 y 5.
Recursos
- DataExpert-io — repositorio
cumulative-table-design(Zach Wilson) — "estos arreglos de métricas nos permiten responder fácilmente preguntas sobre la historia de todos los usuarios usando cosas comoARRAY_SUM", la idea que esta lección aplica a Kiosko. github.com/DataExpert-io/cumulative-table-design. En inglés. - DuckDB — documentación de funciones lambda sobre listas (
list_filter, entre otras), la base de la verificación independiente de esta lección. duckdb.org/docs/current/sql/functions/lambda. En inglés. - DuckDB — documentación de funciones de listas (
list_sum,len), la base del resto de las consultas de esta lección. duckdb.org/docs/current/sql/functions/list. En inglés.