10  Ejemplo Completo: Café Norte — Análisis y Construcción de Data Marts (Kimball)

10.1 Propósito y supuestos

Este ejemplo documenta, con un enfoque Kimball, la construcción de dimensiones conformadas y una tabla de hechos a granularidad línea de pedido para evaluar el impacto del lanzamiento de la app (enero de 2023) y del programa de lealtad. Es el cierre práctico de la sección: aplica granularidad y aditividad, esquema estrella y redundancia controlada, y dimensiones conformadas, todo sobre un caso end-to-end.

Hito de intervención (cutoff): 2023-01-01. Se define:

  • Periodo pre: fechas menores a 2023-01-01.
  • Periodo post: fechas mayores o iguales a 2023-01-01.

Se asume que el lector ya cuenta con CSVs provenientes de sistemas operacionales (POS y App) y catálogos maestros.


10.2 Estructura esperada de archivos CSV

Coloque los archivos en un directorio data/ al mismo nivel que este .qmd.

10.2.1 Catálogos maestros (dimensiones)

  • data/clientes.csv Campos mínimos: cliente_id, fecha_alta, tipo_cliente, nivel_lealtad (u otros atributos).

  • data/productos.csv Campos mínimos: sku, nombre, categoria, subcategoria, tamano, activo.

  • data/sucursales.csv Campos mínimos: sucursal_id, nombre, zona, formato.

  • (Opcional) data/promociones.csv Campos mínimos: promo_codigo, campania, descripcion.

10.2.2 Transacciones (hechos)

Se recomienda conservar el encabezado del pedido y las líneas separadas.

  • data/pedidos_pos.csv y data/pedidos_app.csv Campos mínimos: order_id, fecha, cliente_id, sucursal_id, canal (POS o APP).

  • data/lineas_pos.csv y data/lineas_app.csv Campos mínimos: order_id, line_num, sku, cantidad, precio_unitario, promo_codigo, metodo_pago, cancelado_flag.

Nota: si en origen el descuento ya viene desglosado, incluya también descuento_importe. Si no, puede derivarse en el proceso (ver más adelante).


10.3 Carga de datos y validación de esquema

En un pipeline reproducible, la primera etapa consiste en cargar datos y aplicar validaciones: tipos, llaves no nulas, dominios controlados y rangos.

import pandas as pd
import numpy as np
from pathlib import Path

DATA_DIR = Path("../../data")

def read_csv(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(f"No se encontró el archivo: {path.resolve()}")
    return pd.read_csv(path)

clientes   = read_csv(DATA_DIR / "clientes.csv")
productos  = read_csv(DATA_DIR / "productos.csv")
sucursales = read_csv(DATA_DIR / "sucursales.csv")

ped_pos = read_csv(DATA_DIR / "pedidos_pos.csv")
ped_app = read_csv(DATA_DIR / "pedidos_app.csv")
lin_pos = read_csv(DATA_DIR / "lineas_pos.csv")
lin_app = read_csv(DATA_DIR / "lineas_app.csv")

# Promociones es opcional
prom_path = DATA_DIR / "promociones.csv"
promociones = read_csv(prom_path) if prom_path.exists() else None

# Visualización rápida
clientes.head(), productos.head(), sucursales.head()
(  cliente_id  fecha_alta tipo_cliente nivel_lealtad
 0    C000001  2021-09-10      lealtad         Plata
 1    C000002  2022-02-15  sin_lealtad           NaN
 2    C000003  2022-06-01      lealtad        Bronce
 3    C000004  2022-11-20  sin_lealtad           NaN
 4    C000005  2023-01-05      lealtad           Oro,
        sku           nombre          categoria subcategoria tamano  activo
 0  SKU0001        Americano  Bebidas Calientes    Americano      M       1
 1  SKU0002            Latte  Bebidas Calientes        Latte      G       1
 2  SKU0003        Capuccino  Bebidas Calientes    Capuccino      M       1
 3  SKU0004        Cold Brew      Bebidas Frías    Cold Brew      M       1
 4  SKU0005  Frappé Vainilla      Bebidas Frías       Frappé      G       1,
   sucursal_id      nombre      zona formato
 0        S001    Centro 1    Centro  Tienda
 1        S002     Norte 1     Norte  Tienda
 2        S003       Sur 1       Sur  Tienda
 3        S004   Oriente 1   Oriente  Kiosco
 4        S005  Poniente 1  Poniente  Kiosco)

10.3.1 Chequeos mínimos de calidad

A continuación se implementan reglas mínimas. En un DW real, estas reglas alimentan un sistema de monitoreo (por ejemplo, Great Expectations) y generan reportes de calidad.

def assert_columns(df: pd.DataFrame, required: list[str], name: str):
    missing = [c for c in required if c not in df.columns]
    if missing:
        raise ValueError(f"[{name}] Faltan columnas requeridas: {missing}")

# Esquema mínimo
assert_columns(clientes,  ["cliente_id", "fecha_alta"], "clientes")
assert_columns(productos, ["sku", "nombre", "categoria"], "productos")
assert_columns(sucursales,["sucursal_id", "nombre", "zona"], "sucursales")

assert_columns(ped_pos, ["order_id","fecha","cliente_id","sucursal_id","canal"], "pedidos_pos")
assert_columns(ped_app, ["order_id","fecha","cliente_id","sucursal_id","canal"], "pedidos_app")

assert_columns(lin_pos, ["order_id","line_num","sku","cantidad","precio_unitario"], "lineas_pos")
assert_columns(lin_app, ["order_id","line_num","sku","cantidad","precio_unitario"], "lineas_app")

# Tipos: fechas
for df, col, name in [
    (clientes, "fecha_alta", "clientes"),
    (ped_pos, "fecha", "pedidos_pos"),
    (ped_app, "fecha", "pedidos_app"),
]:
    df[col] = pd.to_datetime(df[col], errors="coerce")
    if df[col].isna().any():
        bad = df[df[col].isna()].head(5)
        raise ValueError(f"[{name}] Fechas inválidas en '{col}'. Ejemplos:\n{bad}")

# No nulos en llaves operacionales
def assert_not_null(df: pd.DataFrame, cols: list[str], name: str):
    for c in cols:
        if df[c].isna().any():
            raise ValueError(f"[{name}] Hay nulos en la columna clave: {c}")

assert_not_null(ped_pos, ["order_id","cliente_id","sucursal_id"], "pedidos_pos")
assert_not_null(ped_app, ["order_id","cliente_id","sucursal_id"], "pedidos_app")
assert_not_null(lin_pos, ["order_id","sku"], "lineas_pos")
assert_not_null(lin_app, ["order_id","sku"], "lineas_app")

# Dominios controlados
canales_validos = {"POS","APP"}
if not set(ped_pos["canal"].unique()).issubset(canales_validos):
    raise ValueError(f"[pedidos_pos] Canal fuera de dominio: {set(ped_pos['canal'].unique()) - canales_validos}")
if not set(ped_app["canal"].unique()).issubset(canales_validos):
    raise ValueError(f"[pedidos_app] Canal fuera de dominio: {set(ped_app['canal'].unique()) - canales_validos}")

# Rangos numéricos
for df, name in [(lin_pos,"lineas_pos"), (lin_app,"lineas_app")]:
    if (df["cantidad"] <= 0).any():
        raise ValueError(f"[{name}] Se encontraron cantidades <= 0")
    if (df["precio_unitario"] < 0).any():
        raise ValueError(f"[{name}] Se encontraron precios negativos")

print("Validaciones mínimas: OK")
Validaciones mínimas: OK

10.4 Homologación e integración de fuentes (Staging Integrado)

En Kimball, la integración lógica se garantiza mediante dimensiones conformadas y una bus matrix. Antes de construir dimensiones y hechos, se unifican las fuentes POS y App.

10.4.1 Integración de encabezados y líneas

# Unificar encabezados y líneas
pedidos = pd.concat([ped_pos, ped_app], ignore_index=True)
lineas  = pd.concat([lin_pos, lin_app], ignore_index=True)

# Normalizaciones
pedidos["canal"] = pedidos["canal"].str.upper()

# --- CLAVE ÚNICA GLOBAL (DW) ---
# Si order_id puede repetirse entre POS y APP, usamos (canal, order_id).
# Creamos un identificador único: order_uid
pedidos["order_uid"] = pedidos["canal"].astype(str) + "-" + pedidos["order_id"].astype(str)

# Las líneas no traen canal; se lo pegamos desde el header con un join previo (por order_id y canal si aplica)
# Si tus líneas sí traen canal, úsalo; si no, asumimos que lineas_pos -> POS y lineas_app -> APP
lin_pos_ = lin_pos.copy()
lin_pos_["canal"] = "POS"
lin_app_ = lin_app.copy()
lin_app_["canal"] = "APP"

lineas = pd.concat([lin_pos_, lin_app_], ignore_index=True)
lineas["canal"] = lineas["canal"].str.upper()
lineas["order_uid"] = lineas["canal"].astype(str) + "-" + lineas["order_id"].astype(str)

# Flags
lineas["promo_codigo"] = lineas.get("promo_codigo", pd.Series(["NA"]*len(lineas))).fillna("NA")
lineas["cancelado_flag"] = lineas.get("cancelado_flag", 0)
lineas["cancelado_flag"] = lineas["cancelado_flag"].fillna(0).astype(int)

# Encabezados duplicados (reenvíos desde origen): se conserva el más reciente.
# En una extracción real, el mismo pedido puede llegar más de una vez (reintentos,
# recargas parciales); quedarse con la versión más reciente por 'fecha' es la
# convención más simple, documentada aquí para que sea auditable.
if pedidos["order_uid"].duplicated().any():
    n_dup = pedidos["order_uid"].duplicated().sum()
    print(f"Aviso: {n_dup} encabezados de pedido duplicados (reenvíos); se conserva el más reciente por 'fecha'.")
    pedidos = pedidos.sort_values("fecha").drop_duplicates(subset=["order_uid"], keep="last")

# Integración header + lines a granularidad línea usando order_uid
# (order_id y canal ya vienen en 'lineas'; se descartan de 'pedidos' para evitar sufijos _x/_y)
stg = lineas.merge(pedidos.drop(columns=["order_id", "canal"]), on=["order_uid"], how="left", validate="m:1")

# Chequeo de integridad
if stg["fecha"].isna().any():
    bad = stg[stg["fecha"].isna()][["order_id","order_uid","line_num","canal"]].head(10)
    raise ValueError(f"Existen líneas sin encabezado de pedido asociado. Ejemplos:\n{bad}")
Aviso: 13 encabezados de pedido duplicados (reenvíos); se conserva el más reciente por 'fecha'.

10.4.2 Derivación de métricas transaccionales

Se recomienda almacenar importes en el hecho para evitar dependencia histórica de catálogos (p. ej., cambios de precio de lista).

# Medidas base
stg["importe_bruto"] = (stg["cantidad"] * stg["precio_unitario"]).astype(float)

# Descuento: si no viene explícito, se asume 0 (ajuste si aplica)
if "descuento_importe" not in stg.columns:
    stg["descuento_importe"] = 0.0
else:
    stg["descuento_importe"] = stg["descuento_importe"].fillna(0.0).astype(float)

stg["importe_neto"] = stg["importe_bruto"] - stg["descuento_importe"]

# Convención simple para cancelaciones:
# - Importe neto se fuerza a 0 para líneas canceladas.
stg.loc[stg["cancelado_flag"].eq(1), "importe_neto"] = 0.0

# Cupón aplicado (bandera)
stg["cupon_aplicado_flag"] = (stg["promo_codigo"].fillna("NA") != "NA").astype(int)

stg[["order_id","line_num","cantidad","precio_unitario","importe_bruto","descuento_importe","importe_neto",
     "cancelado_flag","promo_codigo","cupon_aplicado_flag"]].head()
order_id line_num cantidad precio_unitario importe_bruto descuento_importe importe_neto cancelado_flag promo_codigo cupon_aplicado_flag
0 P000007 1 2 31.47 62.94 0.0 62.94 0 APP10 1
1 P000007 2 1 36.67 36.67 0.0 36.67 0 NA 0
2 P000001 1 3 51.26 153.78 0.0 153.78 0 NA 0
3 P000001 2 3 78.11 234.33 0.0 234.33 0 NA 0
4 P000010 1 3 72.02 216.06 0.0 216.06 0 NA 0

10.5 Construcción de dimensiones conformadas

En un DW tipo estrella, todas las dimensiones deben tener:

  • llave sustituta (*_key, entero),
  • llave natural (clave operacional como cliente_id, sku, etc.),
  • atributos descriptivos.

Para un libro y un ejemplo didáctico, se implementa SCD Tipo 1 (sobrescritura). En producción, dim_cliente y dim_producto suelen requerir SCD Tipo 2, como se vio en el capítulo anterior.

10.5.1 dim_tiempo

# Rango de fechas basado en staging
min_date = stg["fecha"].min().normalize()
max_date = stg["fecha"].max().normalize()

dim_tiempo = pd.DataFrame({"fecha": pd.date_range(min_date, max_date, freq="D")})
dim_tiempo["anio"] = dim_tiempo["fecha"].dt.year
dim_tiempo["mes"] = dim_tiempo["fecha"].dt.month
dim_tiempo["trimestre"] = dim_tiempo["fecha"].dt.quarter
dim_tiempo["semana_iso"] = dim_tiempo["fecha"].dt.isocalendar().week.astype(int)
dim_tiempo["dia_semana"] = dim_tiempo["fecha"].dt.dayofweek + 1
dim_tiempo["es_fin_semana"] = dim_tiempo["dia_semana"].isin([6,7]).astype(int)

dim_tiempo.insert(0, "tiempo_key", np.arange(1, len(dim_tiempo)+1, dtype=int))
dim_tiempo.head()
tiempo_key fecha anio mes trimestre semana_iso dia_semana es_fin_semana
0 1 2022-07-11 2022 7 3 28 1 0
1 2 2022-07-12 2022 7 3 28 2 0
2 3 2022-07-13 2022 7 3 28 3 0
3 4 2022-07-14 2022 7 3 28 4 0
4 5 2022-07-15 2022 7 3 28 5 0

10.5.2 Función auxiliar para dimensiones (Tipo 1)

def build_dim_type1(df: pd.DataFrame, natural_key: str, attrs: list[str], sk_name: str) -> pd.DataFrame:
    out = df[[natural_key] + attrs].drop_duplicates().reset_index(drop=True)
    out.insert(0, sk_name, np.arange(1, len(out)+1, dtype=int))
    return out

# Dimensiones básicas
dim_canal = pd.DataFrame({"canal": ["POS","APP"]})
dim_canal.insert(0, "canal_key", [1,2])

# Metodo de pago: si no existe en staging, se crea "NA"
if "metodo_pago" not in stg.columns:
    stg["metodo_pago"] = "NA"
dim_metodo_pago = pd.DataFrame({"metodo_pago": sorted(stg["metodo_pago"].fillna("NA").unique())})
dim_metodo_pago.insert(0, "metodo_pago_key", np.arange(1, len(dim_metodo_pago)+1, dtype=int))

# Promoción
if promociones is None:
    # Mínimo viable: solo códigos observados
    dim_promocion = pd.DataFrame({"promo_codigo": sorted(stg["promo_codigo"].fillna("NA").unique())})
else:
    # Con catálogo maestro: se prioriza catálogo, pero garantizamos NA
    dim_promocion = promociones.copy()
    if "promo_codigo" not in dim_promocion.columns:
        raise ValueError("promociones.csv debe incluir 'promo_codigo'")
    if "NA" not in set(dim_promocion["promo_codigo"].astype(str).unique()):
        dim_promocion = pd.concat([pd.DataFrame({"promo_codigo":["NA"]}), dim_promocion], ignore_index=True)

dim_promocion["promo_codigo"] = dim_promocion["promo_codigo"].astype(str).fillna("NA")
dim_promocion = dim_promocion.drop_duplicates(subset=["promo_codigo"]).reset_index(drop=True)
dim_promocion.insert(0, "promocion_key", np.arange(1, len(dim_promocion)+1, dtype=int))
dim_promocion.head()
promocion_key promo_codigo campania descripcion
0 1 NA NaN NaN
1 2 nan Sin promo Sin descuento
2 3 APP10 Lanzamiento App 10% en app
3 4 2X1FRIO Bebidas frías 2x1 bebidas frías
4 5 DULCE15 Postres 15% en postres

10.5.3 dim_cliente, dim_producto, dim_sucursal

# Ajustes básicos de tipos
if "nivel_lealtad" not in clientes.columns:
    clientes["nivel_lealtad"] = "NA"
if "tipo_cliente" not in clientes.columns:
    clientes["tipo_cliente"] = "NA"

dim_cliente = build_dim_type1(
    clientes,
    natural_key="cliente_id",
    attrs=["tipo_cliente","nivel_lealtad","fecha_alta"],
    sk_name="cliente_key"
)

# Productos
for col in ["subcategoria","tamano","activo"]:
    if col not in productos.columns:
        productos[col] = "NA" if col != "activo" else 1

dim_producto = build_dim_type1(
    productos,
    natural_key="sku",
    attrs=["nombre","categoria","subcategoria","tamano","activo"],
    sk_name="producto_key"
)

# Sucursales
for col in ["formato"]:
    if col not in sucursales.columns:
        sucursales[col] = "NA"

dim_sucursal = build_dim_type1(
    sucursales,
    natural_key="sucursal_id",
    attrs=["nombre","zona","formato"],
    sk_name="sucursal_key"
)

dim_cliente.head(), dim_producto.head(), dim_sucursal.head()
(   cliente_key cliente_id tipo_cliente nivel_lealtad fecha_alta
 0            1    C000001      lealtad         Plata 2021-09-10
 1            2    C000002  sin_lealtad           NaN 2022-02-15
 2            3    C000003      lealtad        Bronce 2022-06-01
 3            4    C000004  sin_lealtad           NaN 2022-11-20
 4            5    C000005      lealtad           Oro 2023-01-05,
    producto_key      sku           nombre          categoria subcategoria  \
 0             1  SKU0001        Americano  Bebidas Calientes    Americano   
 1             2  SKU0002            Latte  Bebidas Calientes        Latte   
 2             3  SKU0003        Capuccino  Bebidas Calientes    Capuccino   
 3             4  SKU0004        Cold Brew      Bebidas Frías    Cold Brew   
 4             5  SKU0005  Frappé Vainilla      Bebidas Frías       Frappé   
 
   tamano  activo  
 0      M       1  
 1      G       1  
 2      M       1  
 3      M       1  
 4      G       1  ,
    sucursal_key sucursal_id      nombre      zona formato
 0             1        S001    Centro 1    Centro  Tienda
 1             2        S002     Norte 1     Norte  Tienda
 2             3        S003       Sur 1       Sur  Tienda
 3             4        S004   Oriente 1   Oriente  Kiosco
 4             5        S005  Poniente 1  Poniente  Kiosco)

10.6 Construcción de la tabla de hechos (fact_linea_pedido)

El hecho se define a granularidad línea de pedido, con:

  • Llaves sustitutas hacia dimensiones conformadas.
  • Medidas aditivas (cantidad, importe_*) y banderas (cancelado_flag, cupon_aplicado_flag).
  • Llave degenerada order_id para trazabilidad.
fact = (stg
        .merge(dim_tiempo[["tiempo_key","fecha"]], on="fecha", how="left")
        .merge(dim_cliente[["cliente_key","cliente_id"]], on="cliente_id", how="left")
        .merge(dim_sucursal[["sucursal_key","sucursal_id"]], on="sucursal_id", how="left")
        .merge(dim_producto[["producto_key","sku"]], on="sku", how="left")
        .merge(dim_canal[["canal_key","canal"]], on="canal", how="left")
        .merge(dim_metodo_pago[["metodo_pago_key","metodo_pago"]], on="metodo_pago", how="left")
        .merge(dim_promocion[["promocion_key","promo_codigo"]], on="promo_codigo", how="left"))

# Integridad dimensional (unknown members)
for col in ["tiempo_key","cliente_key","sucursal_key","producto_key","canal_key","metodo_pago_key","promocion_key"]:
    if fact[col].isna().any():
        # En DW real: asignar key=0 a Unknown y registrar warning.
        bad = fact[fact[col].isna()][["order_id","line_num"]].head(10)
        raise ValueError(f"Hay filas sin mapeo dimensional para '{col}'. Ejemplos:\n{bad}")

fact_linea_pedido = fact[[
    "order_id","line_num",
    "tiempo_key","cliente_key","sucursal_key","producto_key",
    "canal_key","metodo_pago_key","promocion_key",
    "cantidad","precio_unitario","importe_bruto","descuento_importe","importe_neto",
    "cancelado_flag","cupon_aplicado_flag"
]].copy()

fact_linea_pedido.head()
order_id line_num tiempo_key cliente_key sucursal_key producto_key canal_key metodo_pago_key promocion_key cantidad precio_unitario importe_bruto descuento_importe importe_neto cancelado_flag cupon_aplicado_flag
0 P000007 1 245 8 1 7 1 2 3 2 31.47 62.94 0.0 62.94 0 1
1 P000007 2 245 8 1 7 1 2 1 1 36.67 36.67 0.0 36.67 0 0
2 P000001 1 528 1 2 3 1 1 1 3 51.26 153.78 0.0 153.78 0 0
3 P000001 2 528 1 2 5 1 2 1 3 78.11 234.33 0.0 234.33 0 0
4 P000010 1 356 5 4 5 1 1 1 3 72.02 216.06 0.0 216.06 0 0

10.7 Data Marts y respuestas a preguntas estratégicas

10.7.1 Pregunta 1: Pedidos mensuales desde enero 2023, segmentado por canal

Se define como número de pedidos únicos (COUNT DISTINCT order_id) por mes.

APP_LAUNCH = pd.Timestamp("2023-01-01")

fact_time = fact_linea_pedido.merge(
    dim_tiempo[["tiempo_key","fecha","anio","mes"]],
    on="tiempo_key", how="left"
)
fact_time["yyyymm"] = fact_time["fecha"].dt.to_period("M").astype(str)

# Desde enero 2023
mask_2023 = fact_time["fecha"] >= APP_LAUNCH

q1 = (fact_time.loc[mask_2023]
      .groupby("yyyymm")
      .agg(pedidos=("order_id","nunique"),
           lineas=("order_id","size"),
           ingreso_neto=("importe_neto","sum"))
      .reset_index()
      .sort_values("yyyymm"))

# Segmentación por canal
fact_time_canal = fact_time.merge(dim_canal, on="canal_key", how="left")
q1_canal = (fact_time_canal.loc[mask_2023]
            .groupby(["yyyymm","canal"])
            .agg(pedidos=("order_id","nunique"),
                 ingreso_neto=("importe_neto","sum"))
            .reset_index()
            .sort_values(["yyyymm","canal"]))

q1.head(), q1_canal.head()
(    yyyymm  pedidos  lineas  ingreso_neto
 0  2023-02        1       3        389.25
 1  2023-03        1       3        257.86
 2  2023-04        2       4        533.00
 3  2023-05        2       7        797.46
 4  2023-06        3       7       1129.59,
     yyyymm canal  pedidos  ingreso_neto
 0  2023-02   APP        1        389.25
 1  2023-03   POS        1        257.86
 2  2023-04   APP        2        533.00
 3  2023-05   APP        1        360.25
 4  2023-05   POS        1        437.21)

10.7.2 Pregunta 2: Categorías con mayor demanda en el periodo post

Se recomienda reportar tanto unidades (volumen) como ingreso neto.

fact_prod = fact_time.merge(dim_producto[["producto_key","categoria","subcategoria"]], on="producto_key", how="left")
q2 = (fact_prod.loc[fact_prod["fecha"] >= APP_LAUNCH]
      .groupby("categoria")
      .agg(unidades=("cantidad","sum"),
           ingreso_neto=("importe_neto","sum"),
           pedidos=("order_id","nunique"))
      .reset_index()
      .sort_values(["ingreso_neto","unidades"], ascending=False))

q2.head(10)
categoria unidades ingreso_neto pedidos
1 Bebidas Calientes 49 2545.44 13
2 Bebidas Frías 28 2037.89 8
4 Postres 35 1692.36 12
0 Accesorios 15 1386.63 7
3 Panadería 27 894.85 11

10.7.3 Pregunta 3: Variaciones por zona y tipo de cliente (pre vs post)

Se propone comparar delta (post - pre) en pedidos e ingreso neto.

fact_zona = fact_prod.merge(dim_sucursal[["sucursal_key","zona"]], on="sucursal_key", how="left")
fact_zona = fact_zona.merge(dim_cliente[["cliente_key","tipo_cliente"]], on="cliente_key", how="left")

fact_zona["periodo"] = np.where(fact_zona["fecha"] >= APP_LAUNCH, "post", "pre")

q3 = (fact_zona
      .groupby(["periodo","zona","tipo_cliente"])
      .agg(pedidos=("order_id","nunique"),
           ingreso_neto=("importe_neto","sum"))
      .reset_index())

# Pivot para deltas
q3_pivot = (q3.pivot_table(index=["zona","tipo_cliente"], columns="periodo",
                           values=["pedidos","ingreso_neto"], aggfunc="sum")
              .fillna(0))

q3_pivot[("pedidos","delta")] = q3_pivot[("pedidos","post")] - q3_pivot[("pedidos","pre")]
q3_pivot[("ingreso_neto","delta")] = q3_pivot[("ingreso_neto","post")] - q3_pivot[("ingreso_neto","pre")]

q3_delta = q3_pivot.reset_index()
q3_delta.columns = ["_".join([c for c in col if c]) if isinstance(col, tuple) else col for col in q3_delta.columns]

q3_delta.sort_values("ingreso_neto_delta", ascending=False).head(15)
zona tipo_cliente ingreso_neto_post ingreso_neto_pre pedidos_post pedidos_pre pedidos_delta ingreso_neto_delta
8 Sur sin_lealtad 1919.22 0.00 5.0 0.0 5.0 1919.22
0 Centro lealtad 1700.87 201.77 5.0 1.0 4.0 1499.10
4 Oriente lealtad 1208.98 272.56 2.0 1.0 1.0 936.42
7 Sur lealtad 858.78 0.00 2.0 0.0 2.0 858.78
3 Norte sin_lealtad 854.44 0.00 2.0 0.0 2.0 854.44
2 Norte lealtad 1006.74 426.19 2.0 1.0 1.0 580.55
5 Oriente sin_lealtad 477.21 137.77 2.0 1.0 1.0 339.44
6 Poniente lealtad 104.19 0.00 1.0 0.0 1.0 104.19
1 Centro sin_lealtad 426.74 407.78 1.0 1.0 0.0 18.96

10.7.4 Pregunta 4: Cancelación y cupones (tasas) por mes

Las tasas son no aditivas, por lo que deben calcularse como promedios o cocientes.

q4 = (fact_time
      .groupby("yyyymm")
      .agg(
          tasa_cancelacion=("cancelado_flag","mean"),
          tasa_cupon=("cupon_aplicado_flag","mean"),
          pedidos=("order_id","nunique"),
          lineas=("order_id","size"),
          ingreso_neto=("importe_neto","sum"),
      )
      .reset_index()
      .sort_values("yyyymm"))

q4.tail(12)
yyyymm tasa_cancelacion tasa_cupon pedidos lineas ingreso_neto
3 2022-12 0.000000 0.333333 1 3 272.56
4 2023-02 0.000000 0.333333 1 3 389.25
5 2023-03 0.000000 0.666667 1 3 257.86
6 2023-04 0.000000 0.000000 2 4 533.00
7 2023-05 0.000000 0.285714 2 7 797.46
8 2023-06 0.000000 0.714286 3 7 1129.59
9 2023-07 0.000000 0.000000 1 5 822.09
10 2023-08 0.166667 0.166667 1 6 696.85
11 2023-09 0.125000 0.375000 2 8 653.90
12 2023-10 0.142857 0.428571 5 14 1545.01
13 2023-11 0.000000 0.428571 3 7 961.61
14 2023-12 0.000000 0.000000 1 5 770.55

10.8 Exportación de tablas: Dimensiones y Hechos (Data Mart físico)

En una implementación típica, estas tablas se materializan como CSV/Parquet en un lakehouse o como tablas físicas en un motor analítico (PostgreSQL, BigQuery, Snowflake, DuckDB, etc.). Aquí se exportan a out/.

OUT_DIR = Path("out")
OUT_DIR.mkdir(exist_ok=True)

dim_tiempo.to_csv(OUT_DIR / "dim_tiempo.csv", index=False)
dim_cliente.to_csv(OUT_DIR / "dim_cliente.csv", index=False)
dim_producto.to_csv(OUT_DIR / "dim_producto.csv", index=False)
dim_sucursal.to_csv(OUT_DIR / "dim_sucursal.csv", index=False)
dim_canal.to_csv(OUT_DIR / "dim_canal.csv", index=False)
dim_metodo_pago.to_csv(OUT_DIR / "dim_metodo_pago.csv", index=False)
dim_promocion.to_csv(OUT_DIR / "dim_promocion.csv", index=False)

fact_linea_pedido.to_csv(OUT_DIR / "fact_linea_pedido.csv", index=False)

# Data marts de salida (consultas)
q1.to_csv(OUT_DIR / "mart_pedidos_mensuales.csv", index=False)
q1_canal.to_csv(OUT_DIR / "mart_pedidos_mensuales_canal.csv", index=False)
q2.to_csv(OUT_DIR / "mart_demanda_categorias_post.csv", index=False)
q3_delta.to_csv(OUT_DIR / "mart_variacion_zona_cliente_pre_post.csv", index=False)
q4.to_csv(OUT_DIR / "mart_tasas_cancelacion_cupon_mes.csv", index=False)

sorted([p.name for p in OUT_DIR.glob("*.csv")])
['dim_canal.csv',
 'dim_cliente.csv',
 'dim_metodo_pago.csv',
 'dim_producto.csv',
 'dim_promocion.csv',
 'dim_sucursal.csv',
 'dim_tiempo.csv',
 'fact_linea_pedido.csv',
 'mart_demanda_categorias_post.csv',
 'mart_pedidos_mensuales.csv',
 'mart_pedidos_mensuales_canal.csv',
 'mart_tasas_cancelacion_cupon_mes.csv',
 'mart_variacion_zona_cliente_pre_post.csv']

10.9 Discusión técnica: decisiones de diseño y extensiones

  1. Dimensiones conformadas: dim_tiempo, dim_cliente, dim_producto, dim_sucursal, dim_canal permiten integrar Ventas POS y Pedidos App en una misma semántica de consulta (bus architecture).

  2. Granularidad: al modelar a nivel de línea, se preserva el análisis de mezcla de productos y el efecto de promociones en el importe.

  3. SCD Tipo 2 (extensión recomendada): si se desea preservar historia de cambios en categoria o en nivel_lealtad, se debe:

    • añadir vigencia_inicio, vigencia_fin, vigente_flag,
    • resolver la clave dimensional “as-of” en la fecha del hecho.
  4. Late Arriving Dimensions: en escenarios reales, pueden llegar transacciones cuyo cliente aún no existe en el catálogo. La práctica estándar es asignar Unknown Member (*_key = 0) y corregir al cierre del ciclo de carga.

  5. Prueba estadística pre/post (opcional): para un tratamiento académico, se sugiere formalizar el cambio con pruebas no paramétricas o bootstrap sobre series mensuales (p. ej., pedidos o tasa de cancelación).


10.10 Reproducibilidad y control de versiones

Para un libro académico, se recomienda:

  • fijar versiones de dependencias (p. ej., pandas==2.x, numpy==2.x),
  • mantener data/ como directorio externo (no versionar datos sensibles),
  • versionar el pipeline y el .qmd (Git),
  • documentar el diccionario de datos final (metadatos de cada dimensión y hecho).