1. 项目概述当数据不再是一张“平铺直叙”的表格你有没有遇到过这样的场景销售部门要按季度、按区域、按产品大类看毛利同时还要对比去年同期财务团队需要把成本拆解到“部门-项目-费用类型-发生月份”四个维度再筛选出超预算的组合甚至一个简单的用户行为分析都要交叉统计“新老用户 × 设备类型 × 页面路径深度 × 当日活跃时段”。这时候Excel 的透视表点到第三层就开始卡顿SQL 里写个 GROUP BY 加上 CASE WHEN 嵌套三层自己都快看不懂了——这已经不是“汇总”问题而是多维聚合Multi-Dimensional Aggregation的实战现场。本篇标题中的 “Part 20: Data Manipulation in Multi-Dimensional Aggregation”绝非教科书里抽象的“高维数组”概念它直指现代数据分析中一个最硬核、也最容易被低估的环节如何在保留原始数据颗粒度的前提下自由、高效、可复现地对多个维度进行任意组合、切片、钻取与比较。核心关键词——多维聚合、数据操作、维度建模、OLAP思维、分组聚合、交叉分析——全部围绕一个现实目标让数据从“静态报表”变成“可交互的决策仪表盘”。它适合三类人一是刚从单表 GROUP BY 走出来、面对宽表和星型模型有点懵的初级数据工程师二是业务分析师手握 BI 工具却总被“为什么这个数和我 Excel 算的不一样”反复拷问三是想把 Python 或 SQL 脚本升级为可维护、可审计、可嵌入 pipeline 的中高级从业者。这不是讲理论是讲怎么在真实项目里用最少的代码、最稳的逻辑、最不容易出错的方式把“按 A、B、C 三个字段分组求和”这件事做成能扛住业务方随时加维度、换口径、拉时间范围的生产级能力。2. 多维聚合的本质拆解为什么“GROUP BY a,b,c”只是起点而非终点2.1 从二维表格到立方体理解“维度”与“度量”的物理意义很多人一听到“多维”下意识就想到“高维空间”“数学抽象”其实完全不必。我们先回到最朴素的物理世界一张标准的超市销售小票。小票上印着商品名称薯片、品类零食、收银台编号A03、交易时间2024-05-12 14:32:18、数量2、单价5.5、实付金额11.0。这里面“商品名称”“品类”“收银台编号”“交易时间”就是维度Dimension——它们是描述“什么”的标签是分类的锚点是你可以用来“切一刀”的位置而“数量”“实付金额”就是度量Measure——它们是你要“算一算”的数值是业务关心的结果。单看一张小票维度和度量混在一起但当你把成千上万张小票汇总问题就来了你想知道“零食类在 A03 台的小时销量”这就需要把“品类”和“收银台编号”作为分组依据把“数量”加总并按“交易时间”的小时部分做分组。此时“交易时间”这个维度你用的不是完整时间戳而是它的派生属性——“小时”。这就是多维聚合的第一个关键认知维度不是固定字段而是可派生、可分层、可折叠的语义层级。比如“交易时间”可以展开为年→季度→月→周→日→小时→分钟“商品名称”可以向上归并为“品类→子品类→品牌”。这种层级关系就是维度建模Dimensional Modeling的核心它决定了你后续所有聚合操作的灵活性和可解释性。我见过太多项目一开始直接用原始时间戳做 GROUP BY结果业务方一说“我要看上周同比”开发就得重写整个 SQL因为原始时间戳里没有“上周”这个语义。而如果提前建好“日期维度表”里面包含 date_key、year、quarter、month、week_of_year、is_weekend 等字段那么“上周同比”就变成一个 JOIN WHERE 的简单操作而不是一场灾难性的逻辑重构。2.2 “GROUP BY a,b,c” 的三大隐形陷阱性能、语义、可维护性现在我们把目光聚焦到最常用的 SQL 写法SELECT a, b, c, SUM(d) FROM t GROUP BY a, b, c。这句话看起来干净利落但它在多维聚合场景下埋着三颗雷第一颗雷是性能陷阱。假设你的事实表有 1 亿行a、b、c 分别有 100、50、20 个唯一值理论上分组组合最多 10 万种。但实际执行时数据库必须扫描全表为每一行计算 (a,b,c) 的哈希值再分配到内存或磁盘的分组桶里。如果内存不够就会触发外部排序External SortI/O 成倍增加。更糟的是如果你后续又想加一个维度 d或者把 c 换成 c 的上级分类 c_parent整个查询计划就得重编译缓存失效。我在一个电商项目里亲眼见过一个原本 2 秒返回的GROUP BY category, region, month查询在业务方临时要求加上“用户等级”维度后响应时间飙升到 47 秒因为“用户等级”字段在事实表里是 NULL 值占比 35%导致分组键基数暴增优化器彻底放弃哈希聚合改用代价极高的嵌套循环。第二颗雷是语义陷阱。GROUP BY a,b,c返回的是一个扁平化的结果集它告诉你“a1,b1,c1 对应 sum_d100”但没告诉你这个 100 是怎么来的它是 a1 下所有 b 的总和还是 b1 下所有 c 的总和还是 a1 和 b1 的交集换句话说它丢失了维度间的层级关系和聚合路径。当业务方问“为什么华东区的销售额比‘华东华北’还高”你得花半小时去查证是不是某个维度的值存在歧义比如“华东区”在 region 字段里是字符串但在另一个系统里是编码JOIN 时没对齐。真正的多维聚合应该像 OLAP联机分析处理引擎那样明确区分“切片Slice”、“切块Dice”、“钻取Drill-down”、“上卷Roll-up”这些操作语义。切片是固定某些维度如 region华东切块是选取某几个维度的子集如 region in (华东,华北) and month in (2024-04,2024-05)而钻取是从“年”下钻到“月”上卷是从“商品”上卷到“品类”。这些操作背后是清晰的维度层级定义而不是一串 GROUP BY 字段。第三颗雷是可维护性陷阱。一个复杂的多维聚合脚本往往伴随着几十行的 CASE WHEN、SUBSTRING、DATE_PART 嵌套用来从原始字段里“抠”出维度值。比如为了得到“工作日/周末”你写了CASE WHEN EXTRACT(DOW FROM order_time) IN (0,6) THEN 周末 ELSE 工作日 END为了得到“新客/老客”你又 JOIN 了一个用户首次下单表再用CASE WHEN first_order_date order_date - INTERVAL 90 days THEN 新客 ELSE 老客 END。这些逻辑散落在 SQL 各处没人敢动因为改一处可能影响十几个报表。它违背了软件工程最基本的“单一职责原则”维度逻辑应该和度量逻辑分离维度的派生规则应该集中管理、版本化、可测试。这也是为什么成熟的数仓架构一定要有独立的维度表Dim_Date, Dim_Customer, Dim_Product而不是把所有逻辑都塞进事实表的 GROUP BY 里。2.3 解决方案选型为什么不是所有工具都叫“多维聚合”面对上述陷阱市面上的解决方案五花八门但并非都适配“Part 20”所指的真实工程场景。我们来快速过一遍主流选项及其适用边界纯 SQLPostgreSQL/MySQL优点是零学习成本、直接、透明。缺点是维度逻辑分散、难以复用、缺乏内置的层级钻取能力。它适合一次性分析或逻辑极其简单的场景但一旦维度超过 3 个或需要频繁切换口径维护成本会指数级上升。我自己的经验是SQL 是多维聚合的“汇编语言”你必须亲手管理每一个寄存器即每一个字段的处理逻辑效率高但易出错。BI 工具Tableau/Power BI优点是交互性强、可视化直观、拖拽式上手快。缺点是逻辑黑盒化、性能不可控、难以嵌入自动化 pipeline。当业务方在 Tableau 里随意拖拽维度后台生成的 SQL 可能是 N 层嵌套子查询DBA 看了直摇头。它解决了“最后一公里”的展示问题但没解决“第一公里”的数据准备问题。OLAP 引擎Apache Kylin / Druid / ClickHouse这是真正为多维聚合而生的引擎。Kylin 预计算 Cube查询飞快但构建 Cube 的 ETL 过程复杂灵活性差Druid 实时性好但 SQL 支持弱学习曲线陡峭ClickHouse 则是“暴力美学”的代表用极致的列存和向量化执行硬刚复杂 GROUP BY对硬件要求高。它们共同的优点是原生支持维度层级、内置 Roll-up/Drill-down 语义、查询性能有保障。但代价是引入了新的技术栈运维成本高。Python 生态Pandas / Polars / Dask这是本篇最想强调的路径。Pandas 的groupby().agg()看似简单但配合pd.cut()分箱、pd.qcut()分位数分箱、pd.Grouper()时间频率分组、pivot_table()交叉制表就能构建出非常灵活的多维分析流水线。Polars 作为新兴的 Rust 编写引擎性能碾压 Pandas语法更函数式特别适合在 ETL 中做中间层聚合。Dask 则解决了单机内存瓶颈。它们的优势在于逻辑完全可控、可单元测试、可版本化、可与任何数据源CSV/DB/API对接、学习曲线平缓。对于大多数中小规模、逻辑复杂的多维聚合需求Python 不是“玩具”而是最务实的生产级选择。我目前维护的 7 个核心业务指标 pipeline全部基于 Polars 构建从原始日志解析、维度派生、多维聚合、到结果写入 DB一行 SQL 都不用写全部是.groupby([region, product_category, hour]).agg([pl.col(revenue).sum(), pl.col(order_count).count()])这样的链式调用清晰、健壮、易调试。综上本篇的“Data Manipulation in Multi-Dimensional Aggregation”其核心思路不是追求某种炫酷的新技术而是回归数据本质用工程化思维把维度建模、度量计算、结果组织这三个环节拆解、封装、标准化。接下来我们就进入最硬核的实操环节。3. 核心细节解析维度建模、度量计算与结果组织的黄金三角3.1 维度建模不是建表而是建“语义字典”维度建模的第一步永远不是打开数据库建表而是拿出一张白纸画出你的业务实体及其层级关系。以电商为例核心维度至少有四个时间Time、用户Customer、商品Product、渠道Channel。每个维度都需要回答三个问题它有哪些自然层级它的属性有哪些它的变化类型是什么时间维度这是最标准的退化维度Degenerate Dimension。它的层级是刚性的年 → 季度 → 月 → 周 → 日 → 小时。属性包括date_key主键格式 20240512、year、quarter、month_num、month_name、week_of_year、day_of_week、is_holiday、is_workday 等。变化类型是“缓慢变化维度SCD类型 0”即永不变化。因此一个完备的dim_date表应该预先生成未来 10 年的所有日期并填充好所有属性。我通常用 Python 的pandas.date_range()生成基础日期再用numpy.where()和自定义函数填充is_holiday调用国家法定节假日 API和is_workday排除周末和节假日。这样做的好处是所有关于时间的计算都变成了一个简单的JOIN dim_date ON fact.order_date dim_date.date_key而不是在每个 SQL 里重复写EXTRACT(MONTH FROM order_date)。用户维度这是典型的 SCD 类型 2 维度。用户的属性会变地址、手机号、会员等级、所属城市。当一个用户升级为 VIP你不能覆盖旧记录而要插入一条新记录带上生效日期valid_from和失效日期valid_to并用一个代理键surrogate_key作为主键。这样当你分析“VIP 用户在 2024 年 4 月的消费”你 JOIN 的条件是fact.order_date BETWEEN dim_user.valid_from AND dim_user.valid_to确保拿到的是该用户在那个时间点的真实状态。我在一个金融项目里吃过亏没做 SCD2直接用用户 ID 关联结果发现“2023 年的贷款客户”在 2024 年被标记为“已注销”导致历史报表全错。维度建模不是炫技是为数据的“时间旅行”能力打地基。商品维度同样 SCD2。商品的价格、类目、供应商都可能变更。一个关键技巧是在维度表里除了存储当前状态还要存储“有效版本号version”。比如商品 A 在 2024-01-01 版本 1类目手机2024-04-01 版本 2类目数码配件。那么当你分析“2024 年 Q2 手机类销售额”你就必须用WHERE dim_product.category 手机 AND dim_product.version 1否则会把版本 2 的数据也混进来。这个 version 字段是保证历史分析准确性的“时间戳保险丝”。渠道维度这往往是“桥接表Bridge Table”的用武之地。一个订单可能来自多个渠道用户先在微信看到广告渠道 A然后通过短信链接下单渠道 B最后用支付宝支付渠道 C。如果强行把所有渠道塞进一个字段用逗号分隔那GROUP BY channel就会失效。正确做法是建一个fact_order_channel桥接表结构为(order_id, channel_id, channel_role)其中channel_role标明是“曝光渠道”、“转化渠道”还是“支付渠道”。这样你可以灵活地GROUP BY channel_role, channel_name分析不同角色渠道的贡献。提示维度建模的终极检验标准不是 ER 图画得多漂亮而是问业务方一个问题“如果我想看‘2024 年 Q1华东区新客通过抖音曝光并最终在 APP 下单的iPhone 15 的销售额’你能用不超过 3 个 JOIN 和 1 个 GROUP BY 写出来吗” 如果能说明你的维度设计是成功的如果需要写 5 个子查询和一堆字符串函数那就该回去重画白纸了。3.2 度量计算从“SUM”到“有业务含义的聚合”度量Measure是多维聚合的心脏但很多项目把它简化成了“SUM、COUNT、AVG”三板斧。这远远不够。一个合格的度量必须具备三个属性原子性、可加性、业务可解释性。原子性度量必须是事实表中最细粒度的、不可再分的数值。比如订单事实表里的order_amount订单实付金额是原子的而avg_order_amount平均客单价就不是它是派生的应该在聚合层计算。我坚持一个原则事实表只存原子度量所有派生度量如转化率、复购率、毛利率都在聚合脚本或视图里计算。这样当业务逻辑变更比如“客单价”定义从“实付金额”改为“商品总价”你只需要改一处聚合逻辑而不是去翻遍所有事实表。可加性这是多维聚合的基石。一个度量必须能在所有维度上自由相加结果才有意义。revenue收入是完全可加的华东区收入 华北区收入 华东华北总收入4 月收入 5 月收入 4-5 月总收入。但avg_revenue_per_order单均收入就不是可加的你不能把“华东单均”和“华北单均”直接相加。处理不可加度量的唯一正解是存储其分子和分母两个原子度量。例如要计算“新客转化率”事实表里必须同时有new_customer_count新客数和total_visitor_count总访客数两个字段。聚合时你GROUP BY region, month得到每个分组的SUM(new_customer_count)和SUM(total_visitor_count)最后在应用层或 BI 工具里用SUM(nc)/SUM(tv)计算比率。这样无论你按什么维度切片比率都是准确的。我曾在一个项目里因为直接存储了conversion_rate字段导致当业务方想看“华东区各城市的转化率”时数据严重失真——因为城市级的比率不能由省级比率简单平均而来。业务可解释性这是最容易被忽视的一点。SUM(revenue)很清楚但SUM(revenue) * 0.85是什么是扣除了平台佣金后的净收入还是剔除了退款的收入一个专业的度量必须有清晰的命名和文档。我的团队强制规定所有度量字段名必须是蛇形命名且后缀体现其业务含义。例如revenue_gross毛收入、revenue_net_after_refund退款后净收入、revenue_net_after_commission佣金后净收入、order_count_unique_buyer去重买家数。并且每个字段在数据字典里必须有一句不超过 20 字的业务定义比如“revenue_net_after_commission: 订单实付金额减去支付给平台的佣金不含退款。” 这看似琐碎但能避免 80% 的跨团队沟通成本。有一次市场部和财务部因为“GMV”定义不一致一个含运费一个不含吵了整整一周最后发现根源就是数据字典里没写清楚。3.3 结果组织从“扁平表格”到“可交互的立方体”当维度和度量都准备就绪最后一步是把聚合结果组织成对业务友好的形式。这里有两个常见误区一是认为结果必须是宽表Wide Table把所有维度都作为列二是认为结果必须是长表Long Table每行一个维度组合。其实最佳实践是根据下游使用场景动态选择组织方式并提供统一的访问接口。宽表模式适用于 BI 报表这是 Power BI/Tableau 最喜欢的格式。核心是pivot操作。假设你有一个基础聚合结果[region, product_category, month, revenue_sum]。业务方想要一个“各区域各品类的月度收入矩阵”那么你需要pivot把month作为列region和product_category作为行索引。在 Polars 中一行代码搞定result.pivot(valuesrevenue_sum, index[region, product_category], columnsmonth)。结果是一个宽表列名为2024-04,2024-05,2024-06...行是(华东, 手机),(华东, 电脑)...。这种格式BI 工具可以直接拖拽生成热力图、堆叠柱状图。但要注意宽表的列数不能无限增长。如果month有 100 个值宽表就有 100 列不仅难看而且某些 BI 工具会报错。所以宽表只适用于维度值较少、且业务方明确要求“横向对比”的场景。长表模式适用于 API 和机器学习这是更通用、更灵活的模式。它保持了“维度-度量”的范式每一行代表一个唯一的维度组合及其对应的度量值。格式为[region, product_category, month, revenue_sum, order_count]。这种格式的好处是无限可扩展。你可以轻松地在这个基础上再加一个维度user_segment只需在GROUP BY里加上它结果结构不变下游代码几乎不用改。它也是机器学习特征工程的理想输入因为scikit-learn的fit()方法就要求输入是(n_samples, n_features)的二维数组长表天然契合。我在一个用户流失预测项目中所有特征都来自一个统一的长表聚合结果特征工程脚本只负责从这张表里SELECT出需要的列然后pivot成宽表喂给模型逻辑清晰迭代飞快。统一访问层API 接口无论你内部用宽表还是长表对外都应该提供一个 RESTful API。这个 API 的设计哲学是让业务方用自然语言思考而不是 SQL 思维。例如不要暴露/api/v1/aggregates?group_byregion,product_categorymetricrevenue_sumtime_range2024-04,2024-05这样脆弱的参数。而是设计成/api/v1/reports/sales_summary?region华东product_category手机periodlast_quarter。后端 API 接收到请求后将其解析为一个预定义的“报告模板”Report Template模板里已经固化了该报告关联哪些维度表、哪些事实表、哪些度量、默认的时间范围、以及如何处理缺失值比如某个区域某个月没数据是返回 0 还是 null。这样业务方不需要懂技术只需要知道“我要看什么”而数据团队只需要维护一套模板就能支撑几十个业务报表。这个 API就是多维聚合能力的“水龙头”拧开它数据就流出来。4. 实操过程用 Polars 构建一个生产级多维聚合 Pipeline4.1 环境准备与数据模拟告别“Hello World”拥抱真实数据流我们不从空的 CSV 开始而是模拟一个真实的、带噪声的电商日志流。假设你每天凌晨 2 点会收到一个压缩包orders_20240512.gz里面是前一天的全部订单日志格式为 JSON Lines每行一个 JSON 对象。一个典型的日志条目如下{ order_id: ORD-20240512-00001, user_id: U-123456, product_id: P-789012, order_time: 2024-05-12T14:32:1808:00, revenue: 5999.0, quantity: 1, channel: wechat, device: ios }第一步安装核心依赖。我们弃用臃肿的pandas选用轻量、极速的polarsv0.20.19和pyarrow用于高效读写 Parquetpip install polars pyarrow requests第二步创建一个config.py集中管理所有配置这是工程化的起点# config.py from dataclasses import dataclass from datetime import datetime, timedelta dataclass class Config: # 数据源配置 raw_log_path: str /data/raw/orders_{date}.gz # {date} 会被替换为 YYYYMMDD # 维度表路径 dim_date_path: str /data/dim/dim_date.parquet dim_user_path: str /data/dim/dim_user.parquet dim_product_path: str /data/dim/dim_product.parquet # 输出路径 output_path: str /data/aggregated/sales_{start}_{end}.parquet # 时间范围用于本次聚合 start_date: str 2024-05-01 end_date: str 2024-05-12 # 全局配置实例 cfg Config()第三步生成一个最小可用的dim_date表。我们不调用外部 API而是用 Polars 自己生成确保环境隔离、可重现# generate_dim_date.py import polars as pl from datetime import datetime, timedelta def generate_dim_date(start_date: str 2024-01-01, end_date: str 2030-12-31): # 生成日期序列 dates pl.date_range( startdatetime.strptime(start_date, %Y-%m-%d), enddatetime.strptime(end_date, %Y-%m-%d), interval1d, eagerTrue ) # 转为 DataFrame df pl.DataFrame({date: dates}) # 添加各种属性 df df.with_columns([ pl.col(date).dt.year().alias(year), pl.col(date).dt.month().alias(month_num), pl.col(date).dt.strftime(%B).alias(month_name), pl.col(date).dt.weekday().alias(day_of_week), # 1Monday, 7Sunday pl.col(date).dt.strftime(%A).alias(day_name), # 判断是否为周末周六或周日 (pl.col(date).dt.weekday().is_in([6, 7])).alias(is_weekend), # 判断是否为工作日周一至周五且非法定假日 # 这里简化假设无假日实际项目需集成 holiday API (pl.col(date).dt.weekday().is_in([1, 2, 3, 4, 5])).alias(is_workday), # 日期主键格式 YYYYMMDD pl.col(date).dt.strftime(%Y%m%d).alias(date_key) ]) # 保存为 Parquet列存高压缩 df.write_parquet(cfg.dim_date_path, compressionzstd) print(fGenerated dim_date with {len(df)} rows.) if __name__ __main__: generate_dim_date()运行python generate_dim_date.py你会得到一个 7 年、2557 行的dim_date.parquet文件。注意我们用了zstd压缩这是 Polars 默认推荐的比snappy压缩率更高解压速度也足够快。4.2 核心聚合脚本从日志解析到多维聚合的完整链路现在我们编写主聚合脚本aggregate_sales.py。它将完成1) 读取指定日期范围的原始日志2) 解析并派生维度3) JOIN 维度表4) 执行多维聚合5) 写入结果。全程使用 Polars 的惰性执行LazyFrame确保内存友好和性能最优。# aggregate_sales.py import polars as pl from config import cfg from datetime import datetime, timedelta import glob import os def load_raw_logs(start_date: str, end_date: str) - pl.LazyFrame: 加载指定日期范围内的所有原始日志文件 # 将字符串日期转为 datetime 对象 start_dt datetime.strptime(start_date, %Y-%m-%d) end_dt datetime.strptime(end_date, %Y-%m-%d) # 生成所有需要的日期列表 date_list [] current start_dt while current end_dt: date_list.append(current.strftime(%Y%m%d)) current timedelta(days1) # 构建文件路径列表 file_paths [cfg.raw_log_path.format(datedate) for date in date_list] # 使用 glob 处理通配符确保文件存在 valid_files [] for path in file_paths: if glob.glob(path): valid_files.extend(glob.glob(path)) else: print(fWarning: No file found for {path}) if not valid_files: raise FileNotFoundError(No raw log files found.) # Polars 读取 JSON Lines自动推断 schema # 注意lazy_json() 是 Polars 0.20 的新方法比 read_json() 更高效 lf pl.scan_ndjson(valid_files, infer_schema_length10000) # 添加一个 log_date 字段便于后续分区 lf lf.with_columns([ pl.lit(start_date).alias(log_date_start), pl.lit(end_date).alias(log_date_end) ]) return lf def enrich_dimensions(lf: pl.LazyFrame) - pl.LazyFrame: 丰富维度解析时间、用户、商品等并 JOIN 维度表 # 1. 解析 order_time 为 date_key 和 hour lf lf.with_columns([ # 提取日期部分转为 YYYYMMDD 格式 pl.col(order_time).str.strptime(pl.Datetime, %Y-%m-%dT%H:%M:%S%z, strictFalse) .dt.date() .dt.strftime(%Y%m%d) .alias(date_key), # 提取小时部分 pl.col(order_time).str.strptime(pl.Datetime, %Y-%m-%dT%H:%M:%S%z, strictFalse) .dt.hour() .alias(hour) ]) # 2. JOIN dim_date 获取更多时间属性 dim_date pl.scan_parquet(cfg.dim_date_path) lf lf.join(dim_date, ondate_key, howleft) # 3. 模拟 JOIN dim_user 和 dim_product实际项目中这里会是真正的 JOIN # 为简化我们直接在事实表里添加一些派生维度 lf lf.with_columns([ # 模拟用户等级根据 user_id 的哈希值简单分组 (pl.col(user_id).hash(seed42) % 3).replace({0: 新客, 1: 老客, 2: VIP}).alias(user_segment), # 模拟商品大类根据 product_id 的前缀 pl.col(product_id).str.slice(0, 1).replace({P: 手机, L: 电脑, A: 配件}).alias(product_category) ]) return lf def perform_aggregation(lf: pl.LazyFrame) - pl.DataFrame: 执行核心多维聚合 # 定义我们要聚合的维度组合 group_cols [year, month_num, region, product_category, user_segment, hour] # 定义我们要计算的度量 agg_exprs [ pl.col(revenue).sum().alias(revenue_sum), pl.col(revenue).count().alias(order_count), pl.col(revenue).mean().alias(revenue_avg), # 计算一个业务指标高价值订单占比订单金额 5000 (pl.col(revenue) 5000).sum().alias(high_value_order_count), # 计算一个复合指标客单价注意这里是 SUM(revenue)/COUNT(order)不是 AVG(revenue)因为 AVG 会受 NULL 影响 (pl.col(revenue).sum() / pl.col(revenue).count()).alias(revenue_per_order) ] # 执行聚合 # 注意我们在这里加入了 region但原始日志里没有 # 这是为了演示在真实项目中region 来自 dim_user 表的 city 字段通过映射表转换而来 # 这里我们用一个简单的映射来模拟 region_mapping { U-1: 华东, U-2: 华北, U-3: 华南, U-4: 西南, U-5: 西北, U-6: 东北 } # 由于 Polars 的 map_dict 不支持 LazyFrame我们先 collect 一小部分做映射或用 join # 这里用一个更工程化的方式创建一个映射表然后 JOIN user_region_map pl.DataFrame({ user_id: list(region_mapping.keys()), region: list(region_mapping.values()) }) # 先 JOIN 映射表 lf lf.join(user_region_map, onuser_id, howleft) # 现在可以安全地 GROUP BY 了 result_lf lf.group_by(group_cols).agg(agg_exprs) # 执行计算返回 DataFrame result_df result_lf.collect() return result_df def main(): 主函数执行完整的聚合流程 print(fStarting aggregation for {cfg.start_date} to {cfg.end_date}...) # 步骤1加载日志 lf load_raw_logs(cfg.start_date, cfg.end_date) print(fLoaded {lf.select(pl.count()).collect().item()} raw log records.) # 步骤2丰富维度 lf enrich_dimensions(lf) # 步骤3执行聚合 result_df perform_aggregation(lf) print(fAggregation completed. Result shape: {result_df.shape}) # 步骤4写入