Módulo 7: Messy Domains And Medallion At Depth
Dimensiones junk: agrupando banderas de baja cardinalidad
Descripción
A partir de esta semana, el punto de venta de Kiosko empieza a capturar dos atributos nuevos por cada orden: payment_method (cómo pagó el cliente: cash, card o wallet) y channel (por qué canal se hizo la compra: in_store o app). Ninguno de los dos existía en fact_orders hasta ahora —esta guía los introduce en este módulo, como atributos fijos y deterministas de cada orden, exactamente como declaró el diseño de esta guía—. La lección 3 ya estableció el criterio: un atributo con información propia no puede quedarse "degenerado" como texto libre sin tratamiento. Esta lección resuelve la pregunta que sigue: ¿dos columnas de texto sueltas dentro de fact_orders, o una dimensión pequeña y compartida, con un solo flag_key? Vas a construir dim_order_flags de verdad —el producto cartesiano completo de las tres formas de pago y los dos canales, seis filas—, y vas a medir la diferencia contra la alternativa de columnas sueltas.
Conexión con el módulo. Esta es la lección central del módulo, la que el diseño de esta guía nombra explícitamente como resultado ejecutable: dim_order_flags con columnas flag_key, payment_method, channel, unida por flag_key en vez de columnas sueltas. Junto con la dimensión degenerada de la lección 3, completa el segundo tipo de dimensión "no estándar" —además del star y el snowflake ya vistos en los módulos 2 y 3— que un dominio real necesita.
Una analogía: el cajón de misceláneos, no un cajón por cada cosa suelta
Piensa en cómo organizas objetos pequeños de baja variedad en una casa: las pilas, las ligas, los clips, un par de llaves de repuesto. Nadie le dedica un cajón entero a cada uno de esos objetos —serían demasiados cajones para cosas que casi nunca cambian y que casi nunca se buscan por separado—. La solución práctica es un solo cajón de misceláneos, organizado en compartimentos pequeños: uno para pilas, uno para ligas, uno para clips. Cuando necesitas una liga, abres un cajón, no cinco. El cajón de misceláneos no es descuido —es la forma correcta de guardar varias cosas pequeñas y de baja variedad sin multiplicar el mueble completo.
Una dimensión junk es ese cajón. payment_method (3 valores posibles) y channel (2 valores posibles) son, cada uno por separado, demasiado pequeños y de muy baja variedad para justificar su propia tabla de dimensión completa —eso sería como dedicarle un mueble entero a las ligas—. Pero tampoco pueden quedarse sueltos como texto libre dentro del hecho, porque sí describen algo real sobre cada orden. La solución: un solo cajón pequeño, dim_order_flags, con un compartimento por combinación posible de los dos atributos, y una sola llave —flag_key— para encontrarlo.
El material que necesitas
Necesitas, en la misma carpeta: kiosko.py y raw_orders.py (idénticos a los módulos 1, 2 y 4). No necesitas ningún archivo adicional — payment_method y channel no vienen en ningún archivo de datos: esta lección los declara como un mapeo fijo, exactamente como el módulo 6 declaró el mapeo sesión-tienda.
Ejemplo trabajado: dim_order_flags, de punta a punta
Parte 1 — Declarar los dominios fijos y asignar cada orden
payment_method y channel no existen en ningún archivo fuente de Kiosko —ni en raw_orders.py, ni en ningún CSV de foundations—. Esta guía los introduce aquí como atributos fijos y deterministas: cada orden recibe su payment_method y su channel según su posición dentro de la semana fija de cuarenta órdenes (RAW_ORDERS, en el mismo orden de lunes a domingo que usa esta guía desde el módulo 1), rotando sobre los dos dominios. Nada de random: la misma orden produce, siempre, el mismo par de valores.
# order_flags.py
from datetime import datetime
import duckdb
from kiosko import DIM_PRODUCT, DIM_STORE, Order, transform_fact_orders
from raw_orders import RAW_ORDERS
PAYMENT_METHODS = ["cash", "card", "wallet"]
CHANNELS = ["in_store", "app"]
def payment_method_for_order(order_index: int) -> str:
"""order_index: posicion 1-based de la orden dentro de RAW_ORDERS (orden fijo,
lunes a domingo). Mapeo deterministico, sin random ni hashing -- la misma
posicion siempre produce el mismo payment_method."""
return PAYMENT_METHODS[(order_index - 1) % len(PAYMENT_METHODS)]
def channel_for_order(order_index: int) -> str:
return CHANNELS[(order_index - 1) % len(CHANNELS)]
order_flags_rows = [
(r[0], payment_method_for_order(i), channel_for_order(i))
for i, r in enumerate(RAW_ORDERS, start=1)
]
con = duckdb.connect()
con.execute("CREATE TABLE order_flags_staging (order_id VARCHAR, payment_method VARCHAR, channel VARCHAR)")
con.executemany("INSERT INTO order_flags_staging VALUES (?, ?, ?)", order_flags_rows)
print("=== order_flags_staging, primeras 6 filas ===")
print(con.sql("SELECT * FROM order_flags_staging ORDER BY order_id LIMIT 6"))
Qué esperar.
=== order_flags_staging, primeras 6 filas ===
┌──────────┬────────────────┬──────────┐
│ order_id │ payment_method │ channel │
│ varchar │ varchar │ varchar │
├──────────┼────────────────┼──────────┤
│ ORD-1001 │ cash │ in_store │
│ ORD-1002 │ card │ app │
│ ORD-1003 │ wallet │ in_store │
│ ORD-1004 │ cash │ app │
│ ORD-1005 │ card │ in_store │
│ ORD-1006 │ wallet │ app │
└──────────┴────────────────┴──────────┘
ORD-1001 (la primera orden de la semana, order_index = 1) recibe cash (posición 0 de PAYMENT_METHODS) e in_store (posición 0 de CHANNELS). ORD-1002 (order_index = 2) avanza ambas rotaciones: card, app. La rotación de payment_method (3 valores) y la de channel (2 valores) avanzan a ritmos distintos —cada seis órdenes consecutivas, exactamente mcm(3, 2) = 6, se completa un ciclo completo de las dos rotaciones a la vez—, así que con las cuarenta órdenes de Kiosko, cada una de las seis combinaciones posibles va a aparecer varias veces.
Parte 2 — Construir dim_order_flags: el producto cartesiano precomputado
# dim_order_flags.py -- continua sobre con y order_flags_staging de la Parte 1
DIM_ORDER_FLAGS_ROWS = []
next_flag_key = 1
for payment_method in PAYMENT_METHODS:
for channel in CHANNELS:
DIM_ORDER_FLAGS_ROWS.append((next_flag_key, payment_method, channel))
next_flag_key += 1
con.execute("CREATE TABLE dim_order_flags (flag_key INTEGER, payment_method VARCHAR, channel VARCHAR)")
con.executemany("INSERT INTO dim_order_flags VALUES (?, ?, ?)", DIM_ORDER_FLAGS_ROWS)
print("=== dim_order_flags: el producto cartesiano completo (3 payment_method x 2 channel) ===")
print(con.sql("SELECT * FROM dim_order_flags ORDER BY flag_key"))
flag_count = con.sql("SELECT COUNT(*) FROM dim_order_flags").fetchone()[0]
assert flag_count == len(PAYMENT_METHODS) * len(CHANNELS), "dim_order_flags no tiene el producto cartesiano completo"
print(f"Verificacion: dim_order_flags tiene {flag_count} filas == 3 payment_method x 2 channel -- OK")
Qué esperar.
=== dim_order_flags: el producto cartesiano completo (3 payment_method x 2 channel) ===
┌──────────┬────────────────┬──────────┐
│ flag_key │ payment_method │ channel │
│ int32 │ varchar │ varchar │
├──────────┼────────────────┼──────────┤
│ 1 │ cash │ in_store │
│ 2 │ cash │ app │
│ 3 │ card │ in_store │
│ 4 │ card │ app │
│ 5 │ wallet │ in_store │
│ 6 │ wallet │ app │
└──────────┴────────────────┴──────────┘
Verificacion: dim_order_flags tiene 6 filas == 3 payment_method x 2 channel -- OK
Esta es, con precisión, la definición operativa de una dimensión junk: precomputar todas las combinaciones posibles de los atributos de baja cardinalidad —no solo las que aparecen en el dato hoy, sino el producto cartesiano completo—, de una sola vez, antes de que ninguna orden la consulte. Con solo dos atributos de cardinalidad 3 y 2, seis filas cubren cualquier combinación que Kiosko pueda necesitar, para siempre —agregar una orden nueva nunca requiere agregar una fila nueva a dim_order_flags, porque las seis combinaciones posibles ya existen.
Parte 3 — Resolver flag_key por orden, y usarlo en vez de columnas sueltas
# resolve_flags.py -- continua sobre con, order_flags_staging y dim_order_flags
con.execute("""
CREATE TABLE order_flags_resolved AS
SELECT s.order_id, f.flag_key, s.payment_method, s.channel
FROM order_flags_staging s
JOIN dim_order_flags f ON s.payment_method = f.payment_method AND s.channel = f.channel
""")
resolved_count = con.sql("SELECT COUNT(*) FROM order_flags_resolved").fetchone()[0]
print(f"Verificacion: order_flags_resolved tiene {resolved_count} filas == 40 ordenes, cada una con su flag_key -- OK")
assert resolved_count == 40
print("\n=== Cuantas ordenes usan cada una de las 6 combinaciones ===")
print(con.sql("""
SELECT f.flag_key, f.payment_method, f.channel, COUNT(*) AS orders_using_this_flag
FROM order_flags_resolved r JOIN dim_order_flags f ON r.flag_key = f.flag_key
GROUP BY f.flag_key, f.payment_method, f.channel
ORDER BY f.flag_key
"""))
distinct_flags_used = con.sql("SELECT COUNT(DISTINCT flag_key) FROM order_flags_resolved").fetchone()[0]
print(f"\nCombinaciones distintas realmente usadas por las 40 ordenes: {distinct_flags_used} de {flag_count} posibles")
Qué esperar.
Verificacion: order_flags_resolved tiene 40 filas == 40 ordenes, cada una con su flag_key -- OK
=== Cuantas ordenes usan cada una de las 6 combinaciones ===
┌──────────┬────────────────┬──────────┬────────────────────────┐
│ flag_key │ payment_method │ channel │ orders_using_this_flag │
│ int32 │ varchar │ varchar │ int64 │
├──────────┼────────────────┼──────────┼────────────────────────┤
│ 1 │ cash │ in_store │ 7 │
│ 2 │ cash │ app │ 7 │
│ 3 │ card │ in_store │ 6 │
│ 4 │ card │ app │ 7 │
│ 5 │ wallet │ in_store │ 7 │
│ 6 │ wallet │ app │ 6 │
└──────────┴────────────────┴──────────┴────────────────────────┘
Combinaciones distintas realmente usadas por las 40 ordenes: 6 de 6 posibles
7 + 7 + 6 + 7 + 7 + 6 = 40 — cada una de las cuarenta órdenes quedó resuelta a exactamente un flag_key, y las seis combinaciones posibles aparecen todas, con un reparto casi uniforme (seis o siete órdenes cada una). Ahora, la razón de negocio por la que esto vale la pena: analizar revenue por forma de pago y canal, uniendo fact_orders contra order_flags_resolved y dim_order_flags — nunca contra columnas de texto sueltas dentro del propio hecho.
# revenue_by_flag.py -- continua sobre con, con fact_orders ya reconstruido
print("=== revenue por payment_method y channel, via flag_key (sin columnas sueltas en fact_orders) ===")
print(con.sql("""
SELECT f.flag_key, f.payment_method, f.channel, COUNT(*) AS orders, ROUND(SUM(o.revenue), 2) AS revenue
FROM fact_orders o
JOIN order_flags_resolved r ON o.order_id = r.order_id
JOIN dim_order_flags f ON r.flag_key = f.flag_key
GROUP BY f.flag_key, f.payment_method, f.channel
ORDER BY f.flag_key
"""))
Qué esperar.
=== revenue por payment_method y channel, via flag_key (sin columnas sueltas en fact_orders) ===
┌──────────┬────────────────┬──────────┬────────┬─────────┐
│ flag_key │ payment_method │ channel │ orders │ revenue │
│ int32 │ varchar │ varchar │ int64 │ double │
├──────────┼────────────────┼──────────┼────────┼─────────┤
│ 1 │ cash │ in_store │ 7 │ 14.2 │
│ 2 │ cash │ app │ 7 │ 14.8 │
│ 3 │ card │ in_store │ 6 │ 15.7 │
│ 4 │ card │ app │ 7 │ 23.8 │
│ 5 │ wallet │ in_store │ 7 │ 19.55 │
│ 6 │ wallet │ app │ 6 │ 18.1 │
└──────────┴────────────────┴──────────┴────────┴─────────┘
14.2 + 14.8 + 15.7 + 23.8 + 19.55 + 18.1 = 106.15 — el mismo revenue total que ya conoces desde el módulo 1, esta vez desglosado por forma de pago y canal, sin que fact_orders haya necesitado ninguna columna de texto adicional. El único costo fue dos JOIN extra (order_flags_resolved, dim_order_flags), a cambio de nunca repetir el texto "wallet" o "in_store" cuarenta veces dentro del hecho principal.
Diagrama: dos columnas sueltas vs una dimensión junk
flowchart LR
subgraph Malo["Alternativa: columnas sueltas"]
FO1["fact_orders\n+ payment_method (texto)\n+ channel (texto)\n40 filas x 2 columnas de texto repetido"]
end
subgraph Bueno["dim_order_flags: la dimension junk"]
FO2["fact_orders\n+ flag_key (entero)"] --> DOF["dim_order_flags\n6 filas: flag_key, payment_method, channel"]
end
Profundización: midiendo el costo, no solo describiéndolo
El módulo 3 de esta guía ya te enseñó a medir, no solo argumentar, la diferencia entre formas de un modelo. Aplica el mismo criterio aquí: si Kiosko hubiera agregado payment_method y channel como dos columnas de texto sueltas, directamente dentro de fact_orders, ¿cuánto texto repetido habría almacenado, comparado con la dimensión junk?
# junk_vs_loose_columns.py
loose_columns_text_values = 40 * 2 # 40 ordenes x 2 columnas de texto sueltas
junk_dimension_text_values = 6 * 2 # 6 filas de dim_order_flags x 2 columnas de texto
JUNK_VS_LOOSE = {
"loose_columns": {
"new_columns_in_fact_orders": 2,
"column_types": ["VARCHAR", "VARCHAR"],
"text_values_stored": loose_columns_text_values,
},
"junk_dimension": {
"new_columns_in_fact_orders": 0,
"new_table": "dim_order_flags (6 filas, 3 columnas)",
"text_values_stored": junk_dimension_text_values,
"extra_join_cost": 1,
},
}
print("=== columnas sueltas vs dimension junk ===")
for approach, data in JUNK_VS_LOOSE.items():
print(f"{approach}: {data}")
reduction_pct = round(100 * (1 - junk_dimension_text_values / loose_columns_text_values), 1)
print(f"\nReduccion de valores de texto repetidos: {reduction_pct}%")
Qué esperar.
=== columnas sueltas vs dimension junk ===
loose_columns: {'new_columns_in_fact_orders': 2, 'column_types': ['VARCHAR', 'VARCHAR'], 'text_values_stored': 80}
junk_dimension: {'new_columns_in_fact_orders': 0, 'new_table': 'dim_order_flags (6 filas, 3 columnas)', 'text_values_stored': 12, 'extra_join_cost': 1}
Reduccion de valores de texto repetidos: 85.0%
Con columnas sueltas, fact_orders almacenaría ochenta valores de texto —"cash", "card", "wallet", "in_store", "app", repetidos cuarenta veces cada combinación de dos—. Con la dimensión junk, esos mismos seis valores de texto distintos (tres formas de pago, dos canales) se almacenan una sola vez cada uno, doce en total, y fact_orders en su lugar tiene un solo entero por fila. Esta diferencia crece, no se queda fija, con el volumen de datos: en un warehouse de producción con millones de órdenes, la diferencia entre ochenta mil valores de texto repetidos y doce valores de texto únicos es la diferencia entre gigabytes y kilobytes en la tabla de hechos —el mismo argumento espacio-vs-normalización que el módulo 3 ya midió para dim_category, ahora aplicado a atributos de cardinalidad todavía más baja.
Errores comunes
Crear una dimensión completa por cada flag, en vez de una sola dimensión junk. Qué pasa: alguien, siguiendo el patrón de dim_store o dim_product, crea dim_payment_method (3 filas) y dim_channel (2 filas) por separado, cada una con su propia llave sustituta, y agrega dos llaves foráneas a fact_orders. Por qué pasa: "una tabla por cada dimensión" es el patrón que domina el resto de esta guía, así que se siente como la respuesta consistente. Cómo detectarlo: si tu diseño tiene dos tablas nuevas de una sola columna descriptiva cada una (dim_payment_method con solo payment_method, dim_channel con solo channel), estás pagando el costo de dos JOIN adicionales para dos atributos que, juntos, solo tienen seis combinaciones posibles. Cómo corregirlo: cuando varios atributos son, cada uno, de muy baja cardinalidad y no tienen ninguna relación jerárquica entre sí (a diferencia de category dentro de dim_product, que sí describe al producto), agrúpalos en una sola dimensión junk con el producto cartesiano precomputado —exactamente lo que hizo esta lección con dim_order_flags—.
Construir dim_order_flags solo con las combinaciones que aparecen hoy en el dato, en vez del producto cartesiano completo. Qué pasa: alguien, en vez de generar las seis combinaciones posibles con el doble for, construye dim_order_flags con SELECT DISTINCT payment_method, channel FROM order_flags_staging — que hoy también da seis filas, por coincidencia, pero por una razón distinta. Por qué pasa: parece más "eficiente" solo guardar lo que realmente se usa. Cómo detectarlo: si mañana Kiosko agrega una orden con una combinación que hoy no existe en los datos de la semana —por ejemplo, si las cuarenta órdenes de esta semana nunca tuvieran wallet+app juntos—, un dim_order_flags construido con DISTINCT sobre el dato actual tendría menos de seis filas, y esa combinación fallaría al resolver su flag_key. Cómo corregirlo: una dimensión junk bien construida precomputa todas las combinaciones posibles de los dominios declarados —PAYMENT_METHODS x CHANNELS—, no solo las que el dato de hoy usa. Esto solo es viable, precisamente, porque la cardinalidad es baja: con dos atributos de 3 y 2 valores, seis filas cubren cualquier futuro sin ningún costo adicional.
Confundir dim_order_flags con una dimensión conformada como dim_store o dim_date. Qué pasa: alguien, al ver que dim_order_flags tiene una llave sustituta (flag_key) igual que las dimensiones del star, espera que sirva a más de un hecho, como dim_store demostró en la lección 2. Por qué pasa: la forma física —llave sustituta, tabla pequeña, unida por JOIN— es idéntica a la de cualquier otra dimensión de esta guía. Cómo detectarlo: si intentas unir fact_sessions o fact_store_activity contra dim_order_flags, no vas a encontrar ninguna columna real que los conecte — payment_method y channel son atributos específicos de una orden (un evento de compra completado), no de una sesión de navegación ni de la actividad diaria de una tienda. Cómo corregirlo: no toda dimensión necesita ser conformada para ser correcta — dim_order_flags, igual que dim_product (según viste en la lección 2 de este módulo), es una dimensión legítima de un solo hecho, y eso no le resta ningún valor.
Ejercicios
Ejercicio 1 — Verifica que ningún order_id quedó sin resolver. Usando order_flags_staging y order_flags_resolved, escribe una consulta con LEFT JOIN y WHERE ... IS NULL que confirme que las cuarenta órdenes de order_flags_staging encontraron todas su flag_key correspondiente.
Ver solución
unresolved = con.sql("""
SELECT s.order_id
FROM order_flags_staging s
LEFT JOIN order_flags_resolved r ON s.order_id = r.order_id
WHERE r.flag_key IS NULL
""").fetchall()
print(f"Ordenes sin resolver: {len(unresolved)}")
assert len(unresolved) == 0
Salida esperada:
Ordenes sin resolver: 0
Cero órdenes sin resolver — confirmando que las seis combinaciones precomputadas de dim_order_flags cubren, sin excepción, cualquier combinación de payment_method/channel que las cuarenta órdenes de Kiosko produjeron.
Ejercicio 2 — Calcula qué canal generó más revenue: in_store o app. Usando order_flags_resolved, dim_order_flags y fact_orders, agrega por channel (sin payment_method) y compara el revenue total de cada canal.
Ver solución
print(con.sql("""
SELECT f.channel, COUNT(*) AS orders, ROUND(SUM(o.revenue), 2) AS revenue
FROM fact_orders o
JOIN order_flags_resolved r ON o.order_id = r.order_id
JOIN dim_order_flags f ON r.flag_key = f.flag_key
GROUP BY f.channel
ORDER BY f.channel
"""))
Salida esperada:
┌──────────┬────────┬─────────┐
│ channel │ orders │ revenue │
│ varchar │ int64 │ double │
├──────────┼────────┼─────────┤
│ app │ 20 │ 56.7 │
│ in_store │ 20 │ 49.45 │
└──────────┴────────┴─────────┘
Veinte órdenes por cada canal —una división exactamente pareja, consecuencia de que CHANNELS tiene solo dos valores y cuarenta es múltiplo de dos—, pero con revenue distinto: app generó 56.7 contra 49.45 de in_store. 56.7 + 49.45 = 106.15, el mismo total de siempre. Esta pregunta —¿qué canal genera más revenue?— es exactamente el tipo de análisis que dim_order_flags habilita sin que fact_orders haya necesitado cargar ninguna columna de texto adicional.
Ejercicio 3 — Explica, de memoria, por qué agregar un tercer atributo de baja cardinalidad (por ejemplo, is_first_purchase, booleano) no duplicaría el trabajo de diseño. En 2-3 frases, explica cómo cambiaría dim_order_flags si Kiosko agregara un tercer atributo booleano, y cuántas filas tendría la dimensión resultante.
Ver solución
Agregar un tercer atributo de baja cardinalidad a una dimensión junk existente no requiere ningún rediseño — solo extiende el producto cartesiano: con payment_method (3 valores), channel (2 valores) e is_first_purchase (2 valores, true/false), dim_order_flags pasaría de seis a 3 x 2 x 2 = 12 filas, cada una todavía identificada por un único flag_key. El patrón —precomputar todas las combinaciones posibles de los dominios declarados— es exactamente el mismo sin importar cuántos atributos de baja cardinalidad se agreguen, siempre que el producto de sus cardinalidades se mantenga pequeño; si esa cardinalidad combinada creciera mucho (por ejemplo, agregando un atributo de cien valores posibles), dejaría de tener sentido como dimensión junk y necesitaría reconsiderarse.
Resumen y siguiente paso
Esta lección construyó dim_order_flags de punta a punta: seis filas —el producto cartesiano completo de tres formas de pago y dos canales—, precomputadas de una sola vez, con flag_key como llave sustituta. Las cuarenta órdenes de Kiosko se resolvieron, sin excepción, a una de esas seis combinaciones, y el análisis de revenue por forma de pago y canal —106.15 desglosado en seis filas— se hizo sin que fact_orders necesitara ninguna columna de texto adicional. Medido en números: ochenta valores de texto repetidos con la alternativa de columnas sueltas, contra doce con la dimensión junk —una reducción del 85%—.
Antes de avanzar deberías poder: construir de memoria el producto cartesiano de dos dominios de baja cardinalidad con un doble for; explicar por qué una dimensión junk precomputa todas las combinaciones posibles, no solo las observadas; y justificar, con números, por qué agrupar atributos de baja cardinalidad en una tabla pequeña es mejor que multiplicar columnas sueltas o multiplicar dimensiones de una sola columna.
La lección 5 da un paso atrás del modelado dimensional puro y formaliza algo que esta guía —y foundations, antes que ella— usa desde el principio sin haberlo puesto nunca por escrito: el contrato entre bronze, silver y gold. validate_gold_schema() es la función que confirma, con evidencia y no con revisión manual, que cada tabla gold de Kiosko —incluyendo dim_order_flags, que acabas de construir— mantiene exactamente las columnas que el resto del warehouse espera.
Recursos
- Kimball Group — "Junk Dimensions" — la definición formal que sostiene toda esta lección: agrupar banderas y atributos de baja cardinalidad en una sola dimensión pequeña, con el producto cartesiano precomputado. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/junk-dimension. En inglés.
- "The Data Warehouse Toolkit", 3ra edición (Kimball & Ross, Wiley) — el capítulo sobre dimensiones junk documenta el mismo patrón cartesiano —flags de baja cardinalidad, combinaciones precomputadas— que
dim_order_flagsimplementa. wiley.com/en-jp/The+Data+Warehouse+Toolkit. En inglés. - Fivetran — "Star Schema vs. OBT for Data Warehouse Performance" — el mismo argumento cuantitativo de espacio-vs-normalización del módulo 3, aplicado aquí a atributos de cardinalidad todavía más baja. fivetran.com/blog/star-schema-vs-obt. En inglés.
- DuckDB — documentación oficial del cliente Python, la interfaz que ejecuta cada consulta de esta lección. duckdb.org/docs/current/clients/python/overview. En inglés.