10  Full Example: Café Norte — Analysis and Construction of Data Marts (Kimball)

10.1 Purpose and Assumptions

Following the Kimball methodology, this end-to-end example documents the construction of conformed dimensions and a line-item granularity fact table to evaluate the impact of a mobile app launch (January 2023) and loyalty program. Serving as the practical capstone of this section, it applies granularity, additivity, star schemas, controlled redundancy, and conformed dimensions in a complete case study.

Intervention Cutoff: 2023-01-01. Defined as:

  • Pre-intervention period: Order dates strictly before 2023-01-01.
  • Post-intervention period: Order dates on or after 2023-01-01.

It is assumed the analyst possesses source CSV datasets exported from operational point-of-sale (POS) systems, mobile application backends, and master catalogs.


10.2 1. Expected CSV File Structure

Place raw CSV files inside a data/ directory located at the same level as this .qmd document.

10.2.1 1.1 Master Catalogs (Dimensions)

  • data/clientes.csv Minimum fields: cliente_id, fecha_alta, tipo_cliente, nivel_lealtad.

  • data/productos.csv Minimum fields: sku, nombre, categoria, subcategoria, tamano, activo.

  • data/sucursales.csv Minimum fields: sucursal_id, nombre, zona, formato.

  • (Optional) data/promociones.csv Minimum fields: promo_codigo, campania, descripcion.

10.2.2 1.2 Transactions (Facts)

It is recommended to maintain order headers and order line items as separate files.

  • data/pedidos_pos.csv and data/pedidos_app.csv Minimum fields: order_id, fecha, cliente_id, sucursal_id, canal (POS or APP).

  • data/lineas_pos.csv and data/lineas_app.csv Minimum fields: order_id, line_num, sku, cantidad, precio_unitario, promo_codigo, metodo_pago, cancelado_flag.

Note: If discount amounts are itemized at source, include descuento_importe. Otherwise, derive it during ETL transformation.


10.3 2. Data Loading and Schema Validation

In a reproducible pipeline, the initial stage ingests raw data and applies strict validation checks: data types, non-null keys, controlled domain constraints, and numeric ranges.

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"File not found: {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")

# Promotions catalog is optional
prom_path = DATA_DIR / "promociones.csv"
promociones = read_csv(prom_path) if prom_path.exists() else None

# Quick inspection
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 2.1 Minimum Quality Checks

Below are baseline validation rules. In a production DW, these assertions feed automated data monitoring frameworks (e.g., Great Expectations).

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}] Missing required columns: {missing}")

# Minimal schema assertions
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")

# Date type casting and validation
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}] Invalid dates in '{col}'. Examples:\n{bad}")

# Non-null operational key enforcement
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}] Null values found in key column: {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")

# Controlled domain validation
valid_channels = {"POS","APP"}
if not set(ped_pos["canal"].unique()).issubset(valid_channels):
    raise ValueError(f"[pedidos_pos] Channel out of domain: {set(ped_pos['canal'].unique()) - valid_channels}")
if not set(ped_app["canal"].unique()).issubset(valid_channels):
    raise ValueError(f"[pedidos_app] Channel out of domain: {set(ped_app['canal'].unique()) - valid_channels}")

# Numeric range assertions
for df, name in [(lin_pos,"lineas_pos"), (lin_app,"lineas_app")]:
    if (df["cantidad"] <= 0).any():
        raise ValueError(f"[{name}] Quantities <= 0 detected")
    if (df["precio_unitario"] < 0).any():
        raise ValueError(f"[{name}] Negative unit prices detected")

print("Minimum quality checks: OK")
Minimum quality checks: OK

10.4 3. Standardization and Source Integration (Integrated Staging)

In Kimball architecture, logical integration is achieved through conformed dimensions and a bus matrix. POS and App datasets are integrated before constructing dimensional models.

10.4.1 3.1 Header and Line Item Integration

# Combine headers and line items
pedidos = pd.concat([ped_pos, ped_app], ignore_index=True)
lineas  = pd.concat([lin_pos, lin_app], ignore_index=True)

# Standardize text values
pedidos["canal"] = pedidos["canal"].str.upper()

# --- GLOBAL UNIQUE KEY ---
# If order_id overlaps between POS and APP, create compound key order_uid
pedidos["order_uid"] = pedidos["canal"].astype(str) + "-" + pedidos["order_id"].astype(str)

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)

# Default values for 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)

# Deduplicate headers: keep most recent record
if pedidos["order_uid"].duplicated().any():
    n_dup = pedidos["order_uid"].duplicated().sum()
    print(f"Warning: {n_dup} duplicate order headers; retaining latest by 'fecha'.")
    pedidos = pedidos.sort_values("fecha").drop_duplicates(subset=["order_uid"], keep="last")

# Merge headers and line items at line granularity
stg = lineas.merge(pedidos.drop(columns=["order_id", "canal"]), on=["order_uid"], how="left", validate="m:1")

# Referential integrity check
if stg["fecha"].isna().any():
    bad = stg[stg["fecha"].isna()][["order_id","order_uid","line_num","canal"]].head(10)
    raise ValueError(f"Line items exist without an associated order header. Examples:\n{bad}")
Warning: 13 duplicate order headers; retaining latest by 'fecha'.

10.4.2 3.2 Derivation of Transactional Metrics

Calculated monetary values should be stored directly in fact tables to prevent dependency on changing master prices over time.

# Gross calculation
stg["importe_bruto"] = (stg["cantidad"] * stg["precio_unitario"]).astype(float)

# Discount amounts
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"]

# Cancellation logic: force net amount to zero for cancelled items
stg.loc[stg["cancelado_flag"].eq(1), "importe_neto"] = 0.0

# Coupon flag
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 4. Building Conformed Dimensions

In a star schema, every dimension table requires:

  • An integer surrogate key (*_key),
  • A natural key from operational systems (cliente_id, sku, etc.),
  • Descriptive attributes.

This example implements SCD Type 1 (overwriting changes). In production environments, dim_cliente and dim_producto typically implement SCD Type 2.

10.5.1 4.1 dim_tiempo (Time Dimension)

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 4.2 Helper Function for Dimensions (Type 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

# Channel dimension
dim_canal = pd.DataFrame({"canal": ["POS","APP"]})
dim_canal.insert(0, "canal_key", [1,2])

# Payment method dimension
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))

# Promotion dimension
if promociones is None:
    dim_promocion = pd.DataFrame({"promo_codigo": sorted(stg["promo_codigo"].fillna("NA").unique())})
else:
    dim_promocion = promociones.copy()
    if "promo_codigo" not in dim_promocion.columns:
        raise ValueError("promociones.csv must contain '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 4.3 dim_cliente, dim_producto, dim_sucursal

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"
)

# Product dimension
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"
)

# Branch store dimension
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 5. Building the Fact Table (fact_linea_pedido)

The fact table is modeled at order line-item granularity, including:

  • Foreign surrogate keys linking to conformed dimensions.
  • Additive measures (cantidad, importe_*) and flags (cancelado_flag, cupon_aplicado_flag).
  • Degenerate key order_id for order-level traceability.
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"))

# Dimensional integrity assertion
for col in ["tiempo_key","cliente_key","sucursal_key","producto_key","canal_key","metodo_pago_key","promocion_key"]:
    if fact[col].isna().any():
        bad = fact[fact[col].isna()][["order_id","line_num"]].head(10)
        raise ValueError(f"Rows found without dimension mapping for '{col}'. Examples:\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 6. Data Marts and Answering Strategic Questions

10.7.1 6.1 Question 1: Monthly Orders Since January 2023, Segmented by Channel

Defined as distinct orders (COUNT DISTINCT order_id) grouped per month.

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)

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"))

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 6.2 Question 2: Top Demanded Categories in the Post-Period

Reports volume (units sold) alongside net revenue.

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 6.3 Question 3: Variations by Region and Customer Type (Pre vs. Post)

Compares net revenue and order deltas (post - pre).

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())

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 6.4 Question 4: Cancellation and Coupon Rates by Month

Percentages are non-additive, and are computed as dynamic ratios.

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 7. Exporting Tables: Dimensions and Facts (Physical Data Mart)

In enterprise setups, these tables materialize as Parquet/CSV files on object storage or as physical tables in an analytical database (PostgreSQL, BigQuery, Snowflake, DuckDB). Here they are exported to an out/ directory.

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)

# Export output data marts
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 8. Technical Discussion: Design Decisions and Extensions

  1. Conformed Dimensions: dim_tiempo, dim_cliente, dim_producto, dim_sucursal, and dim_canal enable querying POS and App sales under a single bus matrix architecture.

  2. Granularity: Modeling at line-item level preserves product mix analysis and promotion evaluation.

  3. SCD Type 2 Extension: To preserve historical changes in categoria or nivel_lealtad, extend dimension tables with valid_from, valid_to, and is_current flags.

  4. Late-Arriving Dimensions: For transactions arriving prior to customer creation in master catalogs, assign an Unknown Member key (*_key = 0) and reconcile during subsequent batch cycles.

  5. Pre/Post Hypothesis Testing: Formally evaluate intervention impacts using non-parametric or bootstrap hypothesis tests on monthly metrics.


10.10 9. Reproducibility and Version Control

Best practices for analytical pipelines:

  • Lock dependency versions (pandas==2.x, numpy==2.x).
  • Keep raw input datasets outside version control repositories.
  • Track pipeline scripts and .qmd files in Git.
  • Document data dictionaries for all facts and dimensions.