在数据分析任务里,我们经常会遇到这样一种结构:每一行代表一个分组主体,其中某一列是列表类型,存放了用户ID、商品编号或者标签集合。业务需求是按某个键分组后,求出每个组内所有列表的共同元素,也就是列表列的分组交集。Polars 作为新一代 DataFrame 库,在列式存储和查询优化上表现突出,但并没有像 SQL 的聚合函数那样直接暴露一个 group_intersection 接口,因此需要结合它的表达式 API 来设计实现路径。

为什么不能直接用 group_by 加一个交集聚合
Polars 的 group_by 操作后面通常接 agg,而 agg 里可用的列表级函数多为 concat_list、list.unique、list.len 等。交集本质上是一个集合运算,要求对比组内每一行的列表并保留同时出现在所有列表中的元素。如果强行在 agg 中写自定义 Python 函数,Polars 会把数据从 Rust 侧丢回 Python 解释器,失去向量化执行优势,且容易触发内存膨胀。
从执行计划角度看,LazyFrame 会把 group_by 下推为散列聚合,而 Python UDF 无法被查询优化器改写。因此更合理的做法是把问题转换为元素级别的操作:要么先展开列表,要么利用列表函数构造掩码。下面我们依次看几种可行方案。
策略一:explode 后按组统计完全覆盖
最直观的思路是把列表列 explode 成多行,这样每个元素独立成行并保留分组键。随后按组和元素计数,若某个元素的出现次数等于该组的总行数,说明它存在于组内每一个原始列表中,即为交集成员。这种方法逻辑清晰,也完全运行在 Rust 内核中。
以下示例构造了一个简单的 DataFrame,按 cat 分组对 ids 列求交集:
import polars as pl
df = pl.DataFrame({
"cat": ["a", "a", "b", "b"],
"ids": [[1, 2, 3], [2, 3, 4], [5, 6], [5, 7]]
})
result = (
df.explode("ids")
.group_by(["cat", "ids"])
.agg(pl.len().alias("cnt"))
.join(
df.group_by("cat").agg(pl.len().alias("group_size")),
on="cat"
)
.filter(pl.col("cnt") == pl.col("group_size"))
.group_by("cat")
.agg(pl.col("ids").sort().alias("inter"))
)
print(result)
这段代码先通过 explode 把列表打平,再统计每个元素在组内的出现次数,并与组大小比较得出交集。它的优点是易读且能利用多线程;缺点是当列表很长时,explode 会产生大量中间行,磁盘或内存压力上升。如果组内列表平均长度为一千,行数会放大一千倍,需要评估资源是否足够。
在只关心交集而不关心频次时,上述写法已经够用。但若数据规模极大,可以考虑在 explode 前先对每行的 ids 做 list.unique,减少重复元素带来的放大效应。
策略二:利用 concat_list 与逐元素过滤
另一种思路是在 agg 里先用 concat_list 把组内列表拼成一个大列表,然后通过表达式判断哪些元素在原始每个子列表中都有出现。Polars 的列表函数支持 list.eval,可以在列表内部执行表达式,但跨列表的状态需要提前准备。
我们可以借助 group_size 与 explode 的结合变体:先 concat_list,再 explode 并去重,最后筛出频次等于组大小的值。相比策略一,它少一次 join,因为聚合时已经知道组大小:
result2 = (
df.group_by("cat")
.agg(
pl.col("ids").explode().alias("flat_ids"),
pl.col("ids").len().alias("group_size")
)
.explode("flat_ids")
.group_by(["cat", "flat_ids"])
.agg(pl.len().alias("cnt"), pl.col("group_size").first().alias("gs"))
.filter(pl.col("cnt") == pl.col("gs"))
.group_by("cat")
.agg(pl.col("flat_ids").sort().alias("inter"))
)
print(result2)
这里在首次 agg 中直接 explode 列表并附带组大小,后续逻辑与策略一类似,但减少了一次对原表的 group_by join。对于宽表而言,这种写法能降低计划复杂度。不过需要注意,agg 内 explode 再后续 explode 的语义容易让人混淆,团队协作时应补充注释。
从性能剖析看,策略二在组数量多但组内行数少的场景下更占优,因为首次聚合已经压缩了键维度。而策略一在组内行数极多、列表短时更直观。实际选型应以 explain 计划的流水线长度为准。
策略三:借助列表集合函数近似处理
如果业务允许一定近似,比如只求出现频率超过阈值(如90%)的元素,可以用 list.eval 配合 pl.element 表达式计算每元素在拼接列表中的占比。虽然严格交集要求100%,但很多推荐场景只需高重合度即可。
示例展示如何计算组内拼接列表中各元素的覆盖率:
approx = (
df.group_by("cat")
.agg(
pl.col("ids").flatten().value_counts().alias("vc")
)
.explode("vc")
.unnest("vc")
)
print(approx)
flatten 是 Polars 中把嵌套列表压平的函数,value_counts 直接给出元素与频次。之后只需结合组大小过滤即可。这种方式代码最短,也最容易迁移到近似交集需求。当数据带有脏列表或缺失值时,建议先在 select 阶段用 fill_null 和 list.unique 清洗。
需要提醒的是,列表列在 Polars 里本质是 Series 的 Series,其类型应统一为 List[Int64] 或 List[Utf8] 等。若混入了 None 元素,explode 会生成空行,可能让计数偏大,从而漏掉真正的交集,因此数据质量校验不可省略。
实践中的内存与调度建议
在 LazyFrame 模式下,上述所有操作都能通过 .explain() 查看是否触发了投影下推与谓词下推。如果交集计算处于长流水线末端,可以考虑用 .collect(streaming=True) 开启流式执行,避免一次性物化 explode 后的巨型中间表。
对于超大规模数据,建议先按分组键做 repartition,再在每个分区内局部求交集,最后做全局归并。Polars 目前对分区聚合的支持仍在演进,但借助 group_by 的并行特性,多数单机场景已能平稳处理上亿行。监控内存可以观察任务管理器中 Rust 线程的分配,若发现频繁落盘,应降低 explode 的膨胀倍数,例如提前截断长列表。
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| explode 后计数 | 逻辑校验、中小数据 | 直观易维护 | 中间行膨胀 |
| concat 加内部过滤 | 多组少行 | 少一次 join | 语义稍复杂 |
| value_counts 近似 | 推荐重合度 | 代码简短 | 非严格交集 |
综合来看,Polars 中列表列分组求交集并没有单一银弹,核心在于把集合语义翻译为元素级聚合,并尽量留在 Rust 执行层。理解数据的形状与查询计划,才能写出既正确又高效的代码。