Módulo 1: From Flat Tables To Dimensional Models

Pasos 3-4: hechos vs dimensiones, una definición precisa

Descripción

Foundations ya te dio una primera mirada a la diferencia entre hechos y dimensiones, con una prueba de comportamiento: si sumar la columna a través de muchas filas produce un número con sentido de negocio, es una medida de un hecho; si describes algo relativamente estable que usas para agrupar o filtrar, es un atributo de una dimensión. Esa prueba sigue siendo correcta — esta lección no la contradice, la hace precisa, con el vocabulario exacto que usa el Kimball Group y con una distinción que la "primera mirada" de foundations no necesitaba todavía: no todas las medidas se comportan igual cuando las sumas.

Conexión con el módulo. Esta lección resuelve, con el grano de la lección 5 ya declarado y verificado, los pasos 3 y 4 del proceso de Kimball para fact_orders: identificar sus dimensiones y sus hechos, con precisión suficiente para sostener los siete módulos que siguen.

Una analogía: la ficha del paciente y el resultado del análisis

En un consultorio médico, la ficha de un paciente tiene dos tipos de información con comportamientos completamente distintos. Está el contexto: nombre, fecha de nacimiento, tipo de sangre, alergias conocidas — datos que describen quién es el paciente, relativamente estables, que el médico consulta para dar sentido a cualquier resultado nuevo, pero que nunca sumaría entre pacientes distintos (sumar dos tipos de sangre no significa nada). Y están las mediciones: presión arterial, nivel de glucosa, temperatura — números que ocurren en un instante específico, de una consulta específica, y que sí tiene sentido agregar de formas distintas: el promedio de glucosa de un paciente en el último año, la temperatura máxima registrada esta semana en toda la sala de urgencias.

Kimball usa, con precisión, este mismo vocabulario para un modelo dimensional: las dimensiones son el contexto (quién, qué, dónde, cuándo, por qué, cómo — las seis preguntas clásicas del periodismo, que el propio Kimball Group usa para describirlas), y los hechos son las mediciones del proceso de negocio, capturadas en el instante exacto en que ocurrió el evento. Esta lección aplica esa distinción, con el vocabulario preciso, sobre las siete columnas de fact_orders.

Ejemplo trabajado: clasificando fact_orders, columna por columna

Con el grano ya declarado en la lección 5 —una línea de orden—, cada columna de fact_orders se clasifica con un criterio doble: ¿es una medida numérica de ese evento, o es el contexto que describe dónde/qué/cuándo ocurrió?

# classify_columns.py
FACT_ORDERS_COLUMNS = [
    ("order_id",   "dimension degenerada", "Identifica la orden, pero no tiene tabla propia -- vive dentro del hecho"),
    ("store_id",   "llave foranea (FK)",   "Apunta a dim_store -- el 'donde' del evento"),
    ("product_id", "llave foranea (FK)",   "Apunta a dim_product -- el 'que' del evento"),
    ("quantity",   "medida (measure)",     "Aditiva: sumar unidades entre muchas filas tiene sentido"),
    ("unit_price", "medida capturada",     "NO aditiva: sumar precios entre filas no tiene sentido de negocio"),
    ("revenue",    "medida (measure)",     "Aditiva: sumar revenue entre muchas filas tiene sentido"),
    ("order_ts",   "contexto temporal",    "El 'cuando' -- ancla la fila en el tiempo, no se suma"),
]

print("=== Clasificacion precisa de fact_orders (grano: una linea de orden) ===\n")
for column, role, reason in FACT_ORDERS_COLUMNS:
    print(f"{column:12} | {role:22} | {reason}")

additive_measures = [c for c, role, _ in FACT_ORDERS_COLUMNS if role == "medida (measure)"]
print(f"\nMedidas totalmente aditivas: {additive_measures}")

Qué esperar. Al correr python3 classify_columns.py, la salida es exactamente esta:

=== Clasificacion precisa de fact_orders (grano: una linea de orden) ===

order_id     | dimension degenerada   | Identifica la orden, pero no tiene tabla propia -- vive dentro del hecho
store_id     | llave foranea (FK)     | Apunta a dim_store -- el 'donde' del evento
product_id   | llave foranea (FK)     | Apunta a dim_product -- el 'que' del evento
quantity     | medida (measure)       | Aditiva: sumar unidades entre muchas filas tiene sentido
unit_price   | medida capturada       | NO aditiva: sumar precios entre filas no tiene sentido de negocio
revenue      | medida (measure)       | Aditiva: sumar revenue entre muchas filas tiene sentido
order_ts     | contexto temporal      | El 'cuando' -- ancla la fila en el tiempo, no se suma

Medidas totalmente aditivas: ['quantity', 'revenue']

Ahora, la parte que hace esta clasificación algo más que una tabla de opiniones: verifica con una consulta real, sobre el fact_orders de la lección 5, por qué unit_price está marcado como "no aditivo" mientras que quantity y revenue sí lo están.

# additive_vs_not.py -- continua sobre el con y fact_orders de la leccion 5
print("=== Sumar una medida ADITIVA: revenue (tiene sentido de negocio) ===")
print(con.sql("SELECT ROUND(SUM(revenue), 2) AS total_revenue FROM fact_orders"))

print("=== Sumar quantity (tambien aditiva) ===")
print(con.sql("SELECT SUM(quantity) AS total_units FROM fact_orders"))

print("=== Sumar unit_price directamente (sin sentido de negocio) ===")
print(con.sql("SELECT ROUND(SUM(unit_price), 2) AS suma_sin_sentido FROM fact_orders"))

Qué esperar. Al correr python3 additive_vs_not.py, la salida es exactamente esta:

=== Sumar una medida ADITIVA: revenue (tiene sentido de negocio) ===
┌───────────────┐
│ total_revenue │
│    double     │
├───────────────┤
│        106.15 │
└───────────────┘

=== Sumar quantity (tambien aditiva) ===
┌─────────────┐
│ total_units │
│   int128    │
├─────────────┤
│         102 │
└─────────────┘

=== Sumar unit_price directamente (sin sentido de negocio) ===
┌──────────────────┐
│ suma_sin_sentido │
│      double      │
├──────────────────┤
│            57.55 │
└──────────────────┘

106.15 de revenue total y 102 unidades totales son números que cualquier gerente reconocería y usaría —de hecho, 106.15 es exactamente el revenue total que ya viste en foundations—. Pero 57.55, la suma de todos los unit_price de las cuarenta filas, no significa absolutamente nada: no es "el precio total de nada", no es una cifra que la gerente de Kiosko pudiera usar para ninguna decisión. Es la suma aritmética de cuarenta números que, cada uno, describe el precio de un producto específico en un instante específico — sumarlos mezcla peras con naranjas, aunque técnicamente SQL lo permita sin quejarse.

Diagrama: las tres categorías de aditividad

┌──────────────────────────────────────────────────────────────────┐
│  ADITIVA                                                            │
│  Se puede sumar a traves de CUALQUIER dimension sin perder sentido. │
│  Ejemplo en Kiosko: quantity, revenue                                │
│                                                                       │
│  SEMI-ADITIVA                                                       │
│  Se puede sumar a traves de ALGUNAS dimensiones, pero no todas.     │
│  Ejemplo clasico de Kimball: un saldo de cuenta bancaria se puede    │
│  sumar entre cuentas distintas (saldo total del banco), pero NO      │
│  entre dias distintos de la misma cuenta (sumar el saldo del lunes   │
│  con el del martes no da el saldo real).                            │
│                                                                       │
│  NO ADITIVA                                                         │
│  Nunca tiene sentido sumarla, sin importar la dimension.             │
│  Ejemplo en Kiosko: unit_price. Se puede PROMEDIAR o tomar el ULTIMO │
│  valor, pero sumarla nunca produce un numero con significado.        │
└──────────────────────────────────────────────────────────────────┘

Profundización: por qué unit_price vive en el hecho, aunque no sea aditivo

Si unit_price no se puede sumar con sentido, ¿por qué está en fact_orders en vez de en dim_product? La pregunta es legítima, y la respuesta es una de las decisiones más importantes de todo el modelado dimensional: unit_price describe el precio al momento exacto de esa venta puntual, no el precio "actual" del producto en general. Kiosko podría vender P001 a 0.55 un lunes y, si sube el precio la semana siguiente, a 0.60 el lunes después — cada fila de fact_orders necesita conservar el precio real que se cobró en ese instante, no el precio de catálogo de hoy.

Esta es exactamente la razón por la que Kimball llama a unit_price una medida capturada (a veces "fact attribute"): vive en la tabla de hechos, con la forma de un número, pero su función no es sumarse — es preservar, fila por fila, un valor que podría cambiar con el tiempo en la dimensión que describe (dim_product). Hoy, en el fact_orders de Kiosko, unit_price no cambia entre órdenes del mismo producto —lo verificaste, sin proponértelo, en el ejercicio de la lección 5—, así que la distinción se siente casi teórica. Pero el módulo 4 de esta guía —Slowly Changing Dimensions— construye exactamente el escenario donde el precio de un producto cambia a mitad de la semana, y ahí la razón de capturar unit_price en cada fila del hecho, en vez de solo leerlo de dim_product, se vuelve completamente concreta: sin esa captura, no podrías calcular el revenue histórico correcto de las órdenes que ocurrieron antes del cambio de precio.

Una nota de vocabulario, porque la vas a encontrar en cualquier lectura seria sobre modelado dimensional: order_id, en la clasificación de esta lección, se marcó como dimensión degenerada — un identificador que funcionalmente actúa como una dimensión (agrupa, identifica una transacción completa), pero que no tiene ninguna tabla propia: vive directamente como una columna de texto dentro de fact_orders, sin ningún dim_order que unir. Esta guía nombra el concepto aquí, sobre la marcha, pero lo desarrolla a fondo —con su justificación completa y sus casos de uso— en el módulo 7.

Errores comunes

Concluir que "medida capturada" es lo mismo que "medida aditiva". Qué pasa: alguien ve que unit_price es un número dentro de fact_orders, junto a quantity y revenue, y asume que las tres se pueden tratar igual —sumarlas, promediarlas, lo que haga falta—, sin distinguir su comportamiento real. Por qué pasa: las tres son columnas numéricas en la misma tabla, y esa similitud visual esconde una diferencia de comportamiento real. Cómo detectarlo: antes de escribir SUM() sobre cualquier columna numérica de un hecho, pregúntate si el resultado, sumado a través de muchas filas, produciría un número con significado de negocio — la prueba que ya diste en foundations, ahora aplicada con más cuidado. Cómo corregirlo: usa el diagrama de las tres categorías de esta lección — quantity y revenue son aditivas (suma siempre); unit_price es una medida capturada, no aditiva (promedia, o toma el último valor, nunca sumes).

Pensar que una dimensión degenerada "debería tener su propia tabla". Qué pasa: alguien, al ver order_id clasificado como "dimensión degenerada", intenta crear una tabla dim_order con una sola columna (order_id) para "hacerlo bien", como si toda dimensión necesitara su propia tabla. Por qué pasa: el patrón "cada dimensión es una tabla propia" es tan común en modelado dimensional que se siente como una regla universal. Cómo detectarlo: si tu dim_order no tiene ningún atributo descriptivo más allá del propio order_id (nada que agrupar, nada que filtrar que no sea el identificador mismo), esa tabla no aporta ningún valor real — solo agrega un join innecesario. Cómo corregirlo: cuando un identificador no tiene ningún atributo propio más allá de sí mismo, la práctica correcta —y con nombre propio en Kimball— es dejarlo como dimensión degenerada dentro del hecho, exactamente como está order_id en fact_orders hoy. El módulo 7 retoma esto con más profundidad.

Clasificar order_ts como una medida, porque "es un número" (un timestamp). Qué pasa: alguien, al ver que order_ts se puede representar internamente como un número (segundos desde una fecha de referencia), lo trata como una medida más, candidata a sumarse o promediarse. Por qué pasa: técnicamente, cualquier fecha se puede convertir a un número, y es fácil olvidar que "se puede representar como número" no es lo mismo que "tiene sentido sumarlo". Cómo detectarlo: pregúntate qué significaría sumar dos timestamps entre sí — la respuesta es "nada con sentido de negocio", la misma señal que ya usaste para descartar unit_price como medida aditiva. Cómo corregirlo: order_ts es contexto temporal —el "cuándo" del evento—, no una medida. Se usa para filtrar, ordenar y, más adelante en el módulo 2, para unir contra dim_date — nunca para sumarse directamente.

Ejercicios

Ejercicio 1 — Clasifica tres columnas nuevas de dim_product. Usando el vocabulario preciso de esta lección (no la prueba general de foundations), clasifica estas tres columnas de dim_product: product_name, category, unit_cost. Ninguna es parte de fact_orders — son atributos de una dimensión —, pero explica en una frase cada una qué tipo de atributo es.

Ver solución
  • product_name: atributo descriptivo — texto que identifica el producto para un humano, se usa para mostrar, nunca para sumar ni agrupar numéricamente (aunque sí se puede agrupar como texto, ej. GROUP BY product_name).
  • category: atributo descriptivo, usado típicamente para agrupar o filtrar (GROUP BY category) — el mismo rol que ya viste en foundations al agrupar revenue por categoría.
  • unit_cost: un caso interesante — es un número, pero vive en la dimensión, no en el hecho, porque describe una propiedad relativamente estable del producto (lo que Kiosko paga al comprarlo), no algo que ocurra en cada venta puntual. No es una medida del proceso de venta — es un atributo del catálogo que, eventualmente, se usa para calcular una medida derivada (el margen), pero la columna en sí vive en la dimensión.

Ejercicio 2 — Calcula el promedio correcto de unit_price. Ya viste que SUM(unit_price) no tiene sentido. Escribe la consulta que sí tiene sentido de negocio para unit_price: el precio promedio de venta por producto, usando AVG() en vez de SUM().

Ver solución
print(con.sql("""
    SELECT product_id, ROUND(AVG(unit_price), 2) AS avg_price, COUNT(*) AS times_sold
    FROM fact_orders
    GROUP BY product_id
    ORDER BY product_id
"""))

Salida esperada:

┌────────────┬───────────┬─────────────┐
│ product_id │ avg_price │ times_sold  │
│  varchar   │  double   │    int64    │
├────────────┼───────────┼─────────────┤
│ P001       │      0.55 │          16 │
│ P002       │       1.2 │          10 │
│ P003       │      0.75 │           7 │
│ P004       │       4.5 │           7 │
└────────────┴───────────┴─────────────┘

El avg_price de cada producto coincide, exactamente, con su unit_price único —porque hoy ningún producto de Kiosko cambió de precio durante la semana—. AVG() sí es una operación con sentido sobre unit_price (a diferencia de SUM()), precisamente porque promedia en vez de acumular — cuando el módulo 4 introduzca un cambio de precio real, este mismo AVG() vas a verlo arrojar un número distinto al precio de catálogo actual, y esa diferencia es una pista real de que el precio cambió durante el período.

Ejercicio 3 — Explica la diferencia entre aditivo y no aditivo con un ejemplo propio, fuera de Kiosko. Usando el ejemplo del saldo bancario semi-aditivo del diagrama de esta lección como inspiración, propón (sin código, solo en prosa) un ejemplo propio de una medida no aditiva en un contexto distinto a Kiosko o a un banco.

Ver solución

Una respuesta razonable: en un sistema de monitoreo de servidores, la temperatura del procesador, registrada cada minuto, es una medida no aditiva — sumar la temperatura de las últimas 60 lecturas de un servidor no produce ningún número útil (no existe "la temperatura total del último minuto"); lo que sí tiene sentido es promediarla, o tomar el máximo registrado en un período, exactamente el mismo patrón que unit_price en esta lección. Cualquier medida que represente un estado en un instante (temperatura, precio, saldo, nivel de inventario) tiende a ser no aditiva o semi-aditiva; las medidas que representan un evento que ocurre y se acumula (una venta, una unidad producida, un clic) tienden a ser completamente aditivas.

Resumen y siguiente paso

En esta lección resolviste los pasos 3 y 4 del proceso de Kimball para fact_orders, con precisión: store_id y product_id son llaves foráneas hacia sus dimensiones; order_id es una dimensión degenerada, sin tabla propia; quantity y revenue son medidas completamente aditivas; unit_price es una medida capturada, no aditiva, que existe en el hecho para preservar el precio real de cada venta puntual, no para sumarse; y order_ts es el contexto temporal que ancla cada fila en el tiempo. Verificaste con una consulta real, no solo en teoría, por qué sumar unit_price produce un número sin sentido (57.55) mientras que sumar revenue (106.15) sí lo tiene.

Antes de avanzar deberías poder: clasificar cualquier columna nueva de fact_orders usando el vocabulario preciso de esta lección (dimensión degenerada, llave foránea, medida aditiva, medida capturada, contexto temporal); explicar por qué unit_price vive en el hecho aunque no sea aditivo; y nombrar, sin ayuda, el concepto de "dimensión degenerada" y a qué columna de Kiosko aplica.

La lección 7 da un paso atrás y pregunta, en concreto, qué cambia en la práctica —no solo en el vocabulario— cuando el grano deja de ser una intuición informal y se convierte en una declaración explícita y verificada.

Recursos

  • Kimball Group — "Star Schema / OLAP Cube" — la fuente que define el vocabulario de medidas y contexto descriptivo ("who, what, where, when, why, and how") usado en esta lección. kimballgroup.com/.../star-schema-olap-cube. En inglés.
  • "The Data Warehouse Toolkit", 3ra edición (Kimball & Ross, Wiley) — el capítulo sobre tipos de hechos desarrolla en detalle la distinción entre medidas aditivas, semi-aditivas y no aditivas usada en esta lección. wiley.com/en-jp/The+Data+Warehouse+Toolkit. En inglés.
  • DuckDB — documentación de funciones de agregación (SUM, AVG, COUNT), usadas en esta lección para verificar aditividad con consultas reales. duckdb.org/docs/current/sql/functions/aggregates. En inglés.