ibis-framework
DuckDB + BigQuery
pipeline v2.0

Pipeline de Analytics Portable Multi-Backend

Migración de pandas in-memory → Ibis lazy SQL — mismo código en DuckDB local y BigQuery producción
Tabla eventos
50 M filas
RAM antes (pandas)
12 GB OOM
RAM con Ibis
< 200 MB
Backends soportados
DuckDB · BigQuery

Arquitectura Write-Once, Run-Anywhere

La capa Ibis traduce expresiones Python a SQL optimizado para cada backend
Flujo de datos
🐍
Python
Expresiones Ibis lazy
Ibis Core
Compilador SQL portable
🦆
DuckDB
Local / Parquet
|
☁️
BigQuery
Producción
|
🐘
PostgreSQL
Staging
DuckDB (dev)
BigQuery (prod)
PostgreSQL
Snowflake
Spark
1

Factory de Conexión Multi-Backend

Una función cambia el backend según ENV — sin tocar la lógica de analytics
nutrimetrics_analytics.py — conexión
# nutrimetrics_analytics.py
# Pipeline Ibis portable para NutriMetrics SaaS
# -----------------------------------------------
import ibis
from ibis import _
import os

def get_connection() -> ibis.BaseBackend:
    """Devuelve la conexión correcta según ENV=dev|staging|prod."""
    env = os.getenv("NUTRIMETRICS_ENV", "dev")

    if env == "dev":
        # DuckDB local con Parquet — sin levantar ninguna DB
        con = ibis.duckdb.connect("data/nutrimetrics_dev.duckdb")
        con.read_parquet("data/orders.parquet",      table_name="orders")
        con.read_parquet("data/customers.parquet",   table_name="customers")
        con.read_parquet("data/events.parquet",      table_name="events")

    elif env == "staging":
        # PostgreSQL de staging
        con = ibis.postgres.connect(
            url=os.getenv("POSTGRES_URL")
        )

    else:  # prod
        # BigQuery de producción — mismo código, distinto backend
        con = ibis.bigquery.connect(
            project_id="nutrimetrics-prod",
            dataset_id="analytics",
        )

    return con
💡
Zero rewrite: las funciones de analytics de abajo no cambian aunque cambies de DuckDB a BigQuery. Solo cambia esta función.
2

Revenue Mensual por Plan

Expresión lazy que genera SQL optimizado — no carga nada hasta .execute()
nutrimetrics_analytics.py — revenue por plan
def monthly_revenue_by_plan(con: ibis.BaseBackend) -> ibis.Table:
    """Revenue mensual por plan (Starter / Pro / Clinic).
    Portable: DuckDB en dev, BigQuery en prod.
    """
    orders = con.table("orders")

    return (
        orders
        .filter(_.status == "completed")
        .group_by(
            month=_.created_at.truncate("M"),
            plan=_.plan,
        )
        .agg(
            revenue_eur    = _.amount.sum(),
            order_count    = _.count(),
            unique_customers = _.customer_id.nunique(),
            avg_order_value = _.amount.mean(),
            mrr_contribution = _.amount.sum() / 1000,  # normalizado en k€
        )
        .order_by([_.month.desc(), _.revenue_eur.desc()])
    )

# Para depurar el SQL generado:
# ibis.to_sql(monthly_revenue_by_plan(con))
SQL generado por Ibis (DuckDB dialect)
SELECT
    DATE_TRUNC('month', t0.created_at) AS month,
    t0.plan                               AS plan,
    SUM(t0.amount)                         AS revenue_eur,
    COUNT(*)                               AS order_count,
    COUNT(DISTINCT t0.customer_id)       AS unique_customers,
    AVG(t0.amount)                         AS avg_order_value,
    SUM(t0.amount) / 1000                 AS mrr_contribution
FROM orders AS t0
WHERE t0.status = 'completed'
GROUP BY
    DATE_TRUNC('month', t0.created_at),
    t0.plan
ORDER BY month DESC, revenue_eur DESC
Salida de ejemplo — df = monthly_revenue_by_plan(con).execute()
month plan revenue_eur unique_customers
2026-06-01Clinic41 850281
2026-06-01Pro28 420580
2026-06-01Starter12 311648
2026-05-01Clinic38 730260
2026-05-01Pro25 890528
2026-05-01Starter11 180589
MRR acumulado (k€) — últimos 6 meses
mes MRR total MoM growth
Jun 202682 581+6.8%
May 202675 800+5.2%
Abr 202672 050+8.1%
Mar 202666 650+4.9%
Feb 202663 540+3.7%
Ene 202661 280base
3

Segmentación de Clientes + Window Functions

Rank, percentil y segmento (whale/regular/casual) con ibis.case() y ibis.rank()
nutrimetrics_analytics.py — segmentación
def customer_segments(con: ibis.BaseBackend) -> ibis.Table:
    """Segmenta clientes por gasto total con ranking y percentil."""
    orders    = con.table("orders")
    customers = con.table("customers")

    aggregated = (
        orders
        .filter(_.status == "completed")
        .group_by(_.customer_id)
        .agg(
            total_spent   = _.amount.sum(),
            order_count   = _.count(),
            first_order   = _.created_at.min(),
            last_order    = _.created_at.max(),
        )
        .mutate(
            # Ranking global por revenue
            revenue_rank = ibis.rank().over(
                order_by=ibis.desc(_.total_spent)
            ),
            # Segmento por umbral de gasto
            segment = (
                ibis.case()
                    .when(_.total_spent >= 5000, "whale")
                    .when(_.total_spent >= 500,  "regular")
                    .else_("casual")
                    .end()
            ),
            # Días de inactividad
            days_inactive = (
                (ibis.now() - _.last_order).cast("int32") // 86400
            ),
        )
    )

    return (
        aggregated
        .join(customers, _.customer_id == customers.id)
        .select(
            _.customer_id, customers.name,
            customers.plan, _.total_spent,
            _.segment, _.revenue_rank, _.days_inactive,
        )
        .order_by(_.revenue_rank)
        .limit(1000)
    )
Top clientes por revenue
ranknameplantotal_spentsegment
1Clínica Salud PlusClinic€12 400whale
2NutriConsulta MadridClinic€9 870whale
3Dr. García DietéticaPro€7 210whale
4Centro Bienestar BCNClinic€6 930whale
5Nutrifit StudioPro€4 100regular
6Laura Sanz DietistaStarter€890regular
Distribución de segmentos
segmentclientes% totalrevenue
whale483.1%€198 400
regular38725.0%€143 220
casual1 11271.9%€42 650
4

DAU / WAU / MAU por Plan

Active users sin cargar 50M filas en RAM — todo ejecutado en el warehouse
nutrimetrics_analytics.py — DAU/WAU/MAU
def active_users_by_plan(con: ibis.BaseBackend) -> ibis.Table:
    """DAU / WAU / MAU por plan de suscripción.
    Calcula los últimos 30 días sin levantar todo a memoria.
    """
    events = con.table("events")

    dau = (
        events
        .select(_.user_id, _.event_date, _.plan)
        .distinct()                          # 1 entrada por user/día
        .filter(_.event_date >= ibis.date("2026-05-17"))
        .group_by(event_date=_.event_date, plan=_.plan)
        .agg(dau=_.user_id.nunique())
    )

    mau_window = ibis.trailing_range_window(
        preceding=ibis.interval(days=30),
        order_by=_.event_date,
        group_by=_.plan,
    )

    return (
        dau
        .mutate(mau_30d=_.dau.sum().over(mau_window))
        .mutate(stickiness=(_.dau / _.mau_30d).round(3))
        .order_by([_.event_date.desc(), _.dau.desc()])
    )
Muestra de salida — últimos 7 días (todos los planes)
event_dateplanDAUMAU 30dstickiness
2026-06-16Clinic2814 8200.058
2026-06-16Pro5807 3100.079
2026-06-16Starter6489 1500.071
2026-06-15Clinic2674 7800.056
2026-06-15Pro5517 2400.076
5

Análisis de Cohortes de Retención

Cohortes mensuales para visualizar retención mes a mes — portable a BigQuery sin cambiar una línea
nutrimetrics_analytics.py — cohortes
def cohort_retention(con: ibis.BaseBackend) -> ibis.Table:
    """Análisis de cohortes: % usuarios activos por mes desde el alta."""
    orders = con.table("orders")

    with_cohort = (
        orders
        .filter(_.status == "completed")
        .group_by(_.customer_id)
        .mutate(
            # Mes de alta del cliente
            cohort_month=_.created_at.min().truncate("M"),
        )
        .mutate(
            # Meses desde el alta en cada transacción
            months_since=(
                (_.created_at.truncate("M") - _.cohort_month)
                .cast("int32") // (30 * 86400)
            ),
        )
    )

    cohort_sizes = (
        with_cohort
        .filter(_.months_since == 0)  # Usuarios del primer mes
        .group_by(_.cohort_month)
        .agg(cohort_size=_.customer_id.nunique())
    )

    activity = (
        with_cohort
        .group_by(_.cohort_month, _.months_since)
        .agg(active_users=_.customer_id.nunique())
    )

    return (
        activity
        .join(cohort_sizes, "cohort_month")
        .mutate(retention_pct=(
            _.active_users * 100.0 / _.cohort_size
        ).round(1))
        .order_by([_.cohort_month, _.months_since])
    )
Matriz de retención — cohort_retention(con).execute().pivot(…)
cohortM+0M+1M+2M+3M+4M+5
Ene 2026100%74%58%51%44%41%
Feb 2026100%76%60%53%46%
Mar 2026100%79%63%55%
Abr 2026100%81%66%
May 2026100%83%
Jun 2026100%
Mejora de retención: las cohortes recientes (Mar–Jun) muestran +5pp en M+1 vs Ene, confirmando el impacto del onboarding rediseñado en Marzo 2026.
6

Script principal — ejecución y export

Orquesta todas las funciones, ejecuta en el backend correcto y exporta a Parquet/CSV
run_analytics.py
#!/usr/bin/env python3
# Ejecutar: python run_analytics.py
# Con BigQuery: NUTRIMETRICS_ENV=prod python run_analytics.py

from nutrimetrics_analytics import (
    get_connection,
    monthly_revenue_by_plan,
    customer_segments,
    active_users_by_plan,
    cohort_retention,
)
import ibis

def main():
    con = get_connection()
    print(f"Backend: {con.name}")  # "duckdb" o "bigquery"

    # 1. Revenue mensual
    rev = monthly_revenue_by_plan(con).execute()
    rev.to_parquet("output/revenue.parquet")

    # 2. Segmentos de clientes
    segs = customer_segments(con).execute()
    segs.to_csv("output/segments.csv", index=False)

    # 3. Active users
    dau = active_users_by_plan(con).execute()

    # 4. Cohortes
    cohorts = cohort_retention(con).execute()

    print("Revenue (últimas 3 filas):")
    print(rev.tail(3).to_string())
    print(f"Segmentos procesados: {len(segs):,}")
    print(f"Filas de cohorte: {len(cohorts):,}")

if __name__ == "__main__":
    main()
Dev → Prod en 1 variable: NUTRIMETRICS_ENV=prod hace que Ibis compile todo el SQL para BigQuery. Sin tocar nutrimetrics_analytics.py. El mismo test que pasó con DuckDB garantiza que BigQuery produce el mismo resultado.