理解 LOD:先按什么计算,再按什么展示¶
LOD 用来处理这类计算:先按一种分组算出结果,再把这些结果用到另一种分组的查询里。 它让“中间怎么算”和“最后怎么展示”可以分别定义。
本页从概念和选型开始,说明关键能力边界,再用完整模型运行第一条查询,解释过滤如何影响计算。 编写模型时,逐条规则见 LOD 编写参考; 一个能完整运行的例子见电商示例。
LOD 是什么¶
LOD 是 Level of Detail 的缩写,通常译为“详细级别”。在指标计算中,可以把它理解为 按什么对象、分到多细来计算。这就是“计算粒度”。
先看一个小问题:广告渠道带来的付费用户,平均每人累计消费多少? 在电商示例截至 2030 年 5 月 31 日的数据中,这个渠道有两位买家:
| 买家 | 订单数 | 累计消费 |
|---|---|---|
| U01 | 4 | 500 元 |
| U04 | 1 | 300 元 |
答案是 (500 + 300) / 2 = 400 元。需要先按买家汇总消费,再对两位买家的累计值求平均。
不能因为 U01 的订单多,就让这位买家在平均值中多参与几次。
这里有两种分组:中间按买家计算,最后按获客渠道展示。
前者是计算粒度,后者是展示粒度。用于分组的字段,也称为“维度”;
查询中的 group_by 决定展示分组,LOD 声明决定需要单独计算的那一步。
在 Dosi 中,LOD 写在语义模型里。查询仍然选择指标和分组字段, Dosi 负责生成分步计算的 SQL。它是 Apache Ossie(原 OSI)的 DATUS 扩展, 不需要数据库提供名为 LOD 的函数。
什么时候需要 LOD¶
如果只需要按查询分组求和、计数或求平均,普通聚合通常就够用。 出现下面几种需求时,再考虑 LOD:
- 用汇总结果分类或筛选。 先算每位买家的购买次数,再据此区分一次购买和多次购买。
- 对中间结果再次汇总。 先算每位买家的累计消费,再按渠道求用户均值。
- 同时比较不同分组或范围的结果。 在每天的 App 销售额旁边,显示当天全站销售额或 App 月总额。
选择之前,先写清“哪些记录参与计算”和“每个对象应该算几次”。 仅仅让 SQL 返回一个数,并不能保证它回答了原来的业务问题。
四种用法怎么选¶
先判断需要的是一个可继续使用的字段,还是查询直接返回的指标。 字段可以继续用于分类、筛选、明细展示或再次聚合;指标是查询选出的统计结果。
| 想做什么 | 使用方式 | 模型里的声明(电商示例) |
|---|---|---|
| 把每个对象的汇总值作为字段继续使用 | 固定字段 fixed |
fixed(paid_total, orders.user_id):每位买家的累计实付 |
| 在当前展示分组内部先细分,再求平均、最大值等 | 指标 include |
AVG(include(order_count, orders.user_id)):先按买家数订单,再求平均 |
| 在当前筛选范围内取得更粗的小计 | 指标 exclude |
exclude(paid_total, orders.paid_date):去掉日期,得到渠道的期间总额 |
| 取得按指定字段计算、不会被普通展示筛选缩小的基准 | 固定指标 fixed |
fixed(paid_total, orders.paid_date):每天的全站销售额 |
声明的第一个参数是已经定义好的指标,称为基础指标,这里的 paid_total 是实付总额,
order_count 是订单数。后面的参数是分组字段。
fixed 出现两次,是因为结果用途不同。买家累计消费需要作为字段,再交给平均指标;
当天全站销售额则可以直接作为指标,与当天 App 销售额一起显示。
先确定结果怎么用,再决定把 fixed 放在字段还是指标上。
定义在维度表上的固定字段,例如 customers 上每位客户的订单数,也可以给任何沿多对一路径
关联到该维度的指标做分组或筛选:按大客户、小客户统计订单数或毛利。这个值按查询的上下文和
时间范围,从维度自己的行算出,再放到每条事实行旁边读取。维度行在被聚合的一侧没有记录时,
计数读作 0,其他聚合读作 NULL。事实行在维度表里找不到对应的行(键对不上或为 NULL)时,
读到 NULL,和维度的其他字段一样。
include 适合“先细分、再汇总”:按渠道展示平均购买次数时,先在每个渠道内按买家计数,
再求平均。exclude 适合“少分几层”:报表按渠道和日期展示,去掉日期后,得到所选期间的渠道总额。
这些定义可以放在同一个模型中。不过,并非任意指标都能放在同一次查询里, 具体组合见能力边界。
设计查询前先看能力边界¶
LOD 定义可以共存于一个模型中,但不一定能放进同一次查询。 选择报表列之前,先确认下面几条限制:
- LOD 不能嵌套。 基础指标和分组键都不能依赖其他 LOD 值,间接依赖也不行。
- Include 指标要和同一事实表的其他指标分开查询。 不能和这张表的普通指标、exclude/fixed 指标或读取 LOD 的字段共查;别的事实表的指标可以。多个 include 指标共查时,基础数据集、维度列表及顺序必须一致。
- 固定字段再聚合会改变计数单位。 这类指标不能与原始行指标共查,一次查询只能再聚合一组固定键的值。固定字段仅用于分组或筛选时,仍可查询普通指标。
- 带键的 fixed 指标要求键原样出现在
group_by中。 添加:day等时间粒度后就不是原来的键。需要向更粗粒度汇总时,改用固定字段加普通聚合。 - LOD 不能与窗口指标、
time_ranges共查。 需要分开执行。Exclude/fixed 指标也不能与 LOD 字段共查。别的事实表的指标可以和任何 LOD 指标共查:每张事实表各自计算,结果按分组字段对齐。 - 使用 datus 模式。 Basic 模式不会执行等价的 LOD 计算。LOD 也不会自动补齐缺失日期或对象。
完整的支持组合、限制和替代写法见能力边界参考。
运行一个最小完整模型¶
先用两位买家、三笔订单看清计算过程。将下面的 SQL 保存为 lod-minimal-seed.sql:
CREATE TABLE orders (
order_id VARCHAR PRIMARY KEY,
user_id VARCHAR,
paid_amount DECIMAL(12,2)
);
INSERT INTO orders VALUES
('O01', 'U01', 100),
('O02', 'U01', 300),
('O03', 'U02', 200);
将下面的完整模型保存为 lod-minimal.yaml,无须其他模型文件。
paid_total 汇总实付金额;buyer_lifetime_paid 按买家固定这个汇总值;
mean_lifetime_paid 再对每位买家的累计值求平均。
version: 0.2.0.dev0
semantic_model:
- name: lod_minimal
datasets:
- name: orders
source: orders
primary_key:
- order_id
fields:
- name: order_id
expression:
dialects:
- dialect: ANSI_SQL
expression: order_id
datatype: String
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.11","is_dimension":false}'
- name: user_id
expression:
dialects:
- dialect: ANSI_SQL
expression: user_id
datatype: String
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.11","is_dimension":false}'
- name: paid_amount
expression:
dialects:
- dialect: ANSI_SQL
expression: paid_amount
datatype: Decimal
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.11","is_dimension":false}'
- name: buyer_lifetime_paid
expression:
dialects:
- dialect: ANSI_SQL
expression: SUM(orders.paid_amount)
datatype: Decimal
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(paid_total, orders.user_id)"}}'
metrics:
- name: paid_total
expression:
dialects:
- dialect: ANSI_SQL
expression: SUM(orders.paid_amount)
- name: mean_lifetime_paid
expression:
dialects:
- dialect: ANSI_SQL
expression: AVG(orders.buyer_lifetime_paid)
安装 Dosi 和 DuckDB CLI 后,在这两个文件所在目录执行下面的命令。 初始化时使用新数据库文件:
duckdb lod-minimal.duckdb < lod-minimal-seed.sql
dosi validate --model lod-minimal.yaml --strict
dosi query --model lod-minimal.yaml --metrics mean_lifetime_paid \
--execute --db lod-minimal.duckdb --format json
返回的 mean_lifetime_paid 为 300:U01 的累计消费是 400,U02 是 200,
每位买家只参与一次,因此 (400 + 200) / 2 = 300。
直接对三笔订单金额求平均会得到 200,回答的是另一个问题。
虽然每笔订单都能读取买家的固定值,再聚合时仍按买家计算一次。
固定声明放在字段的 custom_extensions 中,第一个参数引用已有基础指标。
字段的普通 expression 重复基础指标的完整 SQL,data 是 JSON 编码的字符串。
定义 include、exclude 或 fixed 指标时,则把声明放在指标 DATUS JSON 的
derive: {type: "lod", expr: ...} 中。Include 的核心 SQL 用声明的外层聚合包住基础聚合;
exclude/fixed 指标统一使用 SUM 包装,即使基础指标是 COUNT 或 MAX。
例如 fixed(paid_total, orders.paid_date) 对应 SUM(SUM(orders.paid_amount))。
基础指标是 compose、比值或表达式时,包装里放的是基础指标的整条式子,比如
exclude(net_revenue) 对应 SUM(COALESCE(SUM(orders.amount), 0) - COALESCE(SUM(refunds.amount), 0))。
这种嵌套 SQL 用于让 Dosi 核对并生成分步计算,不能直接交给数据库执行。
完整规则见表达式一致性,
各种形式的模型定义见电商建模实践。
过滤决定“拿哪些记录来算”¶
分组决定怎么计算,过滤决定哪些记录参与。fixed 固定的是分组方式,不是无条件计算全部历史。
查询里有两类条件:
- 上下文条件(
context_filter,CLI 的--context-filter)和时间范围(time_range) 限定计算输入,固定计算也受它们约束。每个计算都在自己汇总的表上读它们:时间范围落在 该表自己的时间字段上,没有时间字段的表读全部数据,并给出警告;context_filter的条件 只在该表能通过已声明关系或键对应读到的地方生效,读不到的跳过,并在结果的warnings里说明。和查询期间不同的独立期间写在各自的基础指标中,见跨表规则。 - 普通筛选(
where_sql,CLI 的--where)挑选“最后展示哪些行”。 它会缩小普通指标和 include/exclude 的输入,但不缩小fixed的输入。
继续看 App 日报。2030 年 4 月 2 日,App 销售额为 160 元,全站为 500 元; App 整个 4 月的销售额为 570 元。同一行报表需要这三个不同范围的值:
| 结果 | 应包含哪些记录 | 使用方式 |
|---|---|---|
| 当天 App 销售额 160 | 4 月 2 日的 App 订单 | 普通金额指标,按日期和渠道展示 |
| 当天全站销售额 500 | 4 月 2 日全部渠道的订单 | 按日期固定的指标,保留全站范围 |
| App 月总额 570 | 整个 4 月的 App 订单 | exclude 去掉日期,保留渠道 |
查询把“4 月”作为时间范围,把“只展示 App”作为普通筛选。
时间范围进入所有这些计算;普通 App 筛选影响当天 App 金额和渠道月小计,
但不进入固定的全站日基准。因此当天 App 的全站占比是 160 / 500 = 0.32。
这条查询的完整命令和结果见电商示例的第 4 题。
写查询时,可以按下面的区别选择条件位置:
| 条件的目的 | 使用位置 | 对固定计算的影响 |
|---|---|---|
| 限定参与计算的记录或时间 | context_filter、time_range |
限定固定计算的输入;时间范围落在各表自己的时间字段上,条件只在对应表读得到时生效 |
| 只挑选最终要展示的普通字段值 | where_sql |
不缩小固定计算的输入;会限定 include/exclude 的输入 |
| 根据已经算出的固定值或已选指标筛选 | where_sql |
等对应值计算出来后再筛选 |
后两行都写在 where_sql 里,不需要另外标明。Dosi 看条件引用了什么字段来决定在哪一步执行:
只引用普通字段的条件在计算前筛选展示记录;引用固定字段或已选指标的条件,
等这些值算出来之后再筛选。比如 orders.channel = 'app' 属于前者,
orders.buyer_order_count >= 2 属于后者。
数据集 source 本身的限制始终生效。独立观察期需要通过参数定义,
混合 AND/OR 条件、关联维度及时间编码的细则见过滤的详细规则。
从业务问题开始建模¶
先用自然语言写清下面五件事,再选择表达式:
- 一条原始记录是什么? 声明真实主键。会重复的编号不能为了通过校验就声明成唯一键。
- 哪些对象进入结果? 区分“没有活动”和“不在本次分析范围内”;明确无记录时应显示 0 还是保留 NULL。
- 先按什么算,最后按什么展示? 如果中间值还要用于分类、筛选或再次聚合,优先考虑固定字段。
- 每个对象参与几次? 平均每人、平均每笔、平均每天是不同口径。比例也要明确分子、分母分别包含什么。
- 哪些条件改变计算,哪些只筛结果? 不同观察期分别定义,跨表计算还要写明字段之间的对应关系。
先选择能保持口径的简单表达。总额除以人数,只有在参与对象和计数方式一致时,才等价于逐人计算后求平均。 模型完成后,用原始数据独立核对,并改变数据和查询参数再次验证。