OLAP SQL 之美
我的日常工作是构建分析仪表板的后端系统。这些仪表板通过数据可视化,帮助用户深入洞察其业务数据。今天,我将分享一个利用 SQL 编写强大 OLAP 查询的真实案例。
请设想您正在运营一系列披萨品牌,目标是评估您的产品货架份额与主要竞争对手的差距。您最关心的业务问题可能包括:
- 您在披萨品类中的铺货份额(Share of Assortment, SOA)是多少?在沃尔玛分销的披萨产品中,有 50% 是您的吗?还是只有 5%?
- 平均而言,您的披萨产品有多少在 Kroger 进行了分销?您有 5 种不同的产品,但平均只有 2 种有售?是更多还是更少?
- 您的铺货份额随时间推移是如何变化的?与去年/上个月相比,是增加 10% 还是减少 5%?
- 哪些竞争对手占据了大部分货架份额?您与他们的比较如何?
- 谁正在投放新产品?谁在减少投放?
- 您的哪个品牌是成功的?哪个需要撤市或通过广告/更优惠的价格等进行改进?
数据结构如下所示(已匿名化):
| p_id | manufacturer | category | brand | retailer | period | distributed_days |
|---|---|---|---|---|---|---|
| i64 | i64 | i64 | i64 | i64 | str | i64 |
| 6581 | 61 | 20 | 273 | 1 | 2023_52 | 31407 |
| 8563 | 34 | 4 | 274 | 1 | 2023_52 | 27515 |
| 8411 | 11 | 6 | 276 | 1 | 2023_52 | 30693 |
| … | … | … | … | … | … | … |
数据代表了从零售商网站(如 Walmart、Kroger 等)抓取的产品数据的一个子集。在这个具体的案例中,我们对分析每个品牌制造商的产品组合份额相对于不同变量的情况感兴趣。
每行包含有关产品的数据:
- p_id: 产品的唯一 ID。
- manufacturer: 制造商 ID。
- category:
产品类别,例如:
牙膏、除臭剂、酱料等。 - brand: 产品品牌,例如:Dove、Pepsi、KitKat 等。
- retailer 和 period: 零售商 ID 和数据爬取的周(这里的数据代表 2023 年的最后一周和 2024 年的第一周)。
- distributed_days: 在此期间内,产品在该零售商网站上的所有商店中被看到的天数总和。例如,如果沃尔玛在美国有 1000 家商店,并且产品在一整周内在所有商店都有售,那么分销天数将是 1000 * 7 = 7000 天。我们稍后会看到如何使用它来计算铺货份额。
在接下来的内容中,我将使用 DuckDB,一个令人惊叹且强大的嵌入式查询引擎。
1) 这两周内所有零售商中,每个制造商分销了多少种独特产品?
这是一个简单的查询,我们只需获取不同的产品并按制造商分组:
1 | SELECT |
结果示例: 制造商 7、34、2 是主要的参与者。假设制造商 57 是“我”(有 372 种独特产品)。
2) 我的产品在不同类别中的分布情况如何?
只筛选我的产品,然后按产品类别分组并计算独特产品数量。
查看每个类别所占的百分比是很重要的。窗口函数是一个很棒的工具。它可以在一个行窗口上应用类似聚合的计算。
重要提示: 窗口函数考虑的行是经过
WHERE、GROUP BY 和 HAVING
子句(如果有的话)处理后产生的行。你可以认为窗口函数是最后运行的。
nb_unique_products / SUM(nb_unique_products) OVER ()
的含义是:
取当前行的
nb_unique_products,并将其除以窗口中所有行nb_unique_products的总和。这里的窗口是所有类别。
1 | SELECT |
问题: 假设您只负责类别 1、2、3 和
4。您不希望看到其他类别。您可能会尝试添加
AND category IN (1, 2, 3, 4) 到 WHERE
子句中。
1 | SELECT |
这样做会导致百分比(铺货份额)从 54.57% 变为 69.28%。
原因: 如前所述,窗口函数在 WHERE 和
GROUP BY
子句执行后对行进行评估。在这种情况下,意味着您仅针对(1、2、3 和
4)这些类别计算份额。
有两种方法可以解决这个问题:
- 使用子查询: 在外部查询中筛选类别。
1 | SELECT |
- 使用 DuckDB 提供的
QUALIFY: 在计算窗口函数后进行筛选。
1 | SELECT |
使用 QUALIFY
后,结果将显示在所有类别总数中的份额(例如
54.57%),同时只返回您感兴趣的类别行。
3) 在类别 3 和 4 中,我们的竞争对手是谁?
我们主要在两个类别中分销产品(假设是 牙膏 和
漱口水)。我们应该更关注这些类别的竞争对手。
我们先来看一个简单的例子:
1 | SELECT |
这样我们就得到了所有制造商在类别 3 和 4 中分销的独特产品数量。
接下来,我们尝试只筛选出每个类别中排名前三的制造商,因为这些才是我们最需要重点关注的竞争对手。
1 | SELECT |
这样一来,你就能看到(例如)你在类别 4 中排名第五,并且还能知道是谁占据了最高的份额。 我们来拆解一下这个查询:
WHERE: 筛选出类别 3 和 4 的数据。GROUP BY: 获取每个制造商在每个类别中的独特产品数量。row_number(): 对每个制造商在所属类别内进行排名。这是在一个窗口内完成的,该窗口按类别进行分区(partitioned by category),并按我们在第 1 步中计算的独特产品数量进行排序(ordered by…)。我们必须按类别分区,这是为了确保两个类别分开计算。因此,每个制造商将会有 2 个排名:一个在类别 3,另一个在类别 4。(注:原文提到类别 2 和 3,根据上下文应为类别 3 和 4)。SUM() OVER (PARTITION BY category): 计算每个制造商在给定类别中的产品份额。QUALIFY: 仅返回前 3 名竞争对手和制造商 57。
4) 多层次分销产品分析
假设您想获取您的独特产品数量,并从以下多个维度进行汇总:
- 按零售商和类别
- 按零售商(涵盖所有类别)
- 按类别(涵盖所有零售商)
- 按所有零售商和所有类别(总计)
您可以独立计算每一项,然后将所有结果进行合并(UNION),但其实有一个更简单的方法。
1 | SELECT |
GROUPING SETS 等同于将多个不同的 GROUP BY 查询的结果进行合并(UNION)。
对于未包含在分组集中的维度,结果中将显示
NULL。因此,针对分组集
(category)(仅按类别汇总)产生的结果行,其
retailer 字段的值将被设置为 NULL。
如果你需要使用所有可能的分组集(即所有维度及其组合,包括总计),那么你可以使用 CUBE 关键字来简化语法:
1 | SELECT |
ROLLUP 是另一种分组结构,但它与 CUBE
的工作方式不同,因为它会限制可能的分组集。
ROLLUP(x, y) 的定义如下:
(x, y)(x)()
(y) 这一分组集会被 CUBE 包含,但不会被
ROLLUP 包含。
当不同维度之间存在层次关系时,ROLLUP
更加有用,例如:
1 | SUM(population) GROUP BY ROLLUP(county, region, city) -- (国家, 地区, 城市) |
在这种情况下,分组集
(region)(仅按地区汇总)的意义就不大,因为它与
(country, region)(按国家和地区汇总)是等价的,因为一个地区(region)总是隶属于一个国家(county)。
5) 既然我的主战场是类别 3 和 4,我的铺货份额 (SOA)是多少?
在第三问中,我们看到了用来统计类别 3 和 4 中独特的分销产品数量。
但是,拥有更多的独特分销产品数量,与你的铺货份额(Share of Assortment, SOA)并非完全正相关。
想象一下,你拥有 15 种独特的冷冻食品,而你的竞争对手只有 3 种,并且市场上只有你们两家。
如果在某个零售商(比如沃尔玛),他们总是上架竞争对手的 2 种产品和你的 1 种产品。在这种情况下,你的 SOA 只有 1/3 = 33%,而你的竞争对手将占据冷冻食品货架 2/3 = 77% 的份额。尽管你在独特产品数量上拥有巨大的份额 15/18 = 83%,但你在实际货架占比上却被竞争对手超越了。
铺货份额(SOA)可以定义为:
$$\text {SOA} = \frac {\text {SUM}(\text {我的产品的分销天数})}{\text {SUM}(\text {我的产品} + \text {竞争对手产品的分销天数})}$$
(注:这里的“分销天数”(distributed days)是衡量产品实际占有货架空间和时间的关键指标。)
以下是针对类别 3 和 4,按 SOA 排名获取前 3 名竞争对手的查询:
1 | WITH soa_per_category_per_manufacturer AS ( |
该查询使用了 公共表表达式(CTEs, Common Table Expressions),这是一种组织查询的方式,可以使其更具可读性。在某些情况下,如果 CTE 被物化并在多个地方使用,还能避免一遍又一遍地重复计算相同的结果(请查阅 物化 CTEs)。
6) 时间演变!
现在,假设你想比较你的产品类别在当前选定的周2024_1与前一周2023_52之间的铺货份额(SOA)变化。
1 | WITH soa_per_period_category_per_manufacturer AS ( |
这个查询获取了在每个类别中 SOA
变化量最大的制造商。请注意,这里使用了多个 CTE 来组织查询。
LAG(n) 是一个窗口函数,它允许你使用窗口内前第 n 行的值。
7) 并非所有零售商都是平等的!
假设你拥有按零售商计算的 SOA,现在你想计算一个平均值,但不是一个简单的普通平均值。因为零售商的重要性(商店数量、区域、合同等)并不相同,你需要计算一个加权平均值(Weighted Average)。
假设你拥有以下权重(权重数据在 retailer_w 表中):
1 | select * from retailer_w; |
那么,加权平均 SOA 可定义为:
$$\text{avg_soa} = \frac{\sum (\text{零售商}_{i} \times \text{权重}_{i})}{\sum (\text{权重}_{i})} \quad \text{对于所有} (\text{零售商}_{i}, \text{权重}_{i})$$
1 | WITH soa_per_retailer_per_manufacturer AS ( |
全文完!希望你阅读愉快 :)