首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Polars Rust 第 6 课:分组聚合(GroupBy & Agg)

Polars Rust 第 6 课:分组聚合(GroupBy & Agg)

作者头像
不吃草的牛德
发布2026-04-23 13:06:34
发布2026-04-23 13:06:34
890
举报
文章被收录于专栏:RustRust

一、开篇引入

如果你用过 SQL,一定写过这样的语句:

代码语言:javascript
复制
SELECT category, SUM(amount), AVG(price)
FROM sales
GROUP BY category;

这就是分组聚合——按照某个维度(如类别、部门、月份)把数据分成若干组,然后对每组数据进行汇总计算。它是数据分析的"瑞士军刀",几乎所有的统计报表、数据看板都离不开它。

在 Polars 中,分组聚合通过 group_by() + agg() 实现,配合 Lazy API 的表达式系统,不仅能完成 SQL GROUP BY 的所有功能,还能实现更复杂的条件聚合、窗口计算等高级操作。

本节课我们将从基础语法出发,逐步深入到自定义表达式、窗口函数、长宽表转换等进阶内容。学完这节课,你就能用 Rust + Polars 优雅地处理绝大多数分组聚合场景了!💪


二、基础 GroupBy

2.1 基本语法

在 Polars 的 Lazy API 中,分组聚合的链式调用非常直观:

代码语言:javascript
复制
.lazy()
.group_by([col("分组列")])
.agg([
    col("数值列").sum(),
    // ... 更多聚合表达式
])

核心概念

  • group_by() 接收一个表达式数组,指定按哪些列分组
  • agg() 接收一个表达式数组,指定对每组数据做什么聚合计算
  • • 最终通过 .collect() 执行整个惰性计划

2.2 常用聚合函数

Polars 提供了丰富的聚合函数,覆盖了绝大多数统计需求:

聚合函数

说明

适用类型

sum()

求和

数值型

mean()

求平均值

数值型

median()

求中位数

数值型

min()

最小值

数值型 / 字符串

max()

最大值

数值型 / 字符串

count()

计数(非空值)

任意类型

first()

取第一个值

任意类型

last()

取最后一个值

任意类型

std()

标准差

数值型

var()

方差

数值型

n_unique()

唯一值计数

任意类型

代码语言:javascript
复制
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let df = df!(
        "num" => &[Some(1), Some(2), None, Some(4)],
        "str" => &[Some("apple"), Some("banana"), Some("apple"), None],
    )?;

    let result = df.lazy()
        .group_by([lit(1)])
        .agg([
            col("num").sum().alias("sum"),
            col("num").mean().alias("mean"),
            col("num").median().alias("median"),
            col("num").min().alias("min_num"),
            col("num").max().alias("max_num"),
            col("str").min().alias("min_str"),   // 支持字符串
            col("str").max().alias("max_str"),
            col("num").count().alias("count"),   // 非空计数
            col("num").n_unique().alias("n_unique"),
            col("num").first().alias("first"),
            col("num").last().alias("last"),
            col("num").std(1).alias("std"),      // ddof=1
            col("num").var(1).alias("var"),
        ])
        .collect()?;

    println!("{}", result);

    Ok(())
}

输出

代码语言:javascript
复制
shape: (1, 14)
┌─────────┬─────┬──────────┬────────┬───┬───────┬──────┬──────────┬──────────┐
│ literal ┆ sum ┆ mean     ┆ median ┆ … ┆ first ┆ last ┆ std      ┆ var      │
│ ---     ┆ --- ┆ ---      ┆ ---    ┆   ┆ ---   ┆ ---  ┆ ---      ┆ ---      │
│ i32     ┆ i32 ┆ f64      ┆ f64    ┆   ┆ i32   ┆ i32  ┆ f64      ┆ f64      │
╞═════════╪═════╪══════════╪════════╪═══╪═══════╪══════╪══════════╪══════════╡
│ 1       ┆ 7   ┆ 2.333333 ┆ 2.0    ┆ … ┆ 1     ┆ 4    ┆ 1.527525 ┆ 2.333333 │
└─────────┴─────┴──────────┴────────┴───┴───────┴──────┴──────────┴──────────┘

2.3 alias() 重命名

聚合后的列名默认是原列名(如 amount),当同一列做多种聚合时会产生冲突。使用 .alias("新名称") 可以为聚合结果指定新列名:

代码语言:javascript
复制
col("amount").sum().alias("total_amount")

2.4 完整代码示例

代码语言:javascript
复制
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    // 创建示例数据:水果销售记录
    let df = df![
        "fruit"   => ["苹果", "香蕉", "苹果", "橙子", "香蕉", "苹果", "橙子", "香蕉"],
        "city"    => ["北京", "上海", "北京", "广州", "上海", "北京", "广州", "上海"],
        "amount"  => [100, 200, 150, 80, 300, 120, 90, 250],
        "price"   => [5.0, 3.0, 5.5, 4.0, 3.5, 5.0, 4.2, 3.0],
    ]?;

    // 按水果分组,计算销售总量、平均价格、销售次数
    let result = df
        .lazy()
        .group_by([col("fruit")])
        .agg([
            col("amount").sum().alias("total_amount"),       // 销售总量
            col("price").mean().alias("avg_price"),           // 平均价格
            col("amount").count().alias("sale_count"),        // 销售次数
            col("price").min().alias("min_price"),            // 最低价格
            col("price").max().alias("max_price"),            // 最高价格
        ])
        .sort("total_amount", SortOptions {
            descending: true,          // 降序排列
            nulls_last: false,
            maintain_order: false,
        })
        .collect()?;

    println!("{}", result);
    Ok(())
}

输出结果

代码语言:javascript
复制
shape: (3, 6)
+-------+--------------+-----------+-----------+-----------+-----------+
| fruit | total_amount | avg_price | sale_count| min_price | max_price |
| ---   | ---          | ---       | ---       | ---       | ---       |
| str   | i64          | f64       | u32       | f64       | f64       |
+=======+==============+===========+===========+===========+===========+
| 苹果  | 370          | 5.167     | 3         | 5.0       | 5.5       |
| 香蕉  | 750          | 3.167     | 3         | 3.0       | 3.5       |
| 橙子  | 170          | 4.1       | 2         | 4.0       | 4.2       |
+-------+--------------+-----------+-----------+-----------+-----------+

💡 小贴士group_by() 会自动将分组列保留在结果中,你不需要在 agg() 中再次选择它们。


三、多列聚合

3.1 同时对多个列进行不同聚合

实际业务中,我们经常需要对多个列分别做不同的聚合操作。比如按部门统计:总销售额(amount 的 sum)、平均单价(price 的 mean)、员工数量(name 的 count)。

代码语言:javascript
复制
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    let df = df![
        "department" => ["技术部", "市场部", "技术部", "市场部", "技术部", "市场部"],
        "employee"   => ["张三", "李四", "王五", "赵六", "钱七", "孙八"],
        "sales"      => [5000, 8000, 3000, 12000, 7000, 9500],
        "cost"       => [2000, 3500, 1500, 5000, 3000, 4000],
    ]?;

    // 对不同列分别做不同聚合
    let result = df
        .lazy()
        .group_by([col("department")])
        .agg([
            col("sales").sum().alias("total_sales"),     // 销售总额
            col("cost").sum().alias("total_cost"),       // 成本总额
            col("employee").count().alias("emp_count"),  // 员工数量
            (col("sales").sum() - col("cost").sum()).alias("profit"), // 利润 = 销售 - 成本
        ])
        .collect()?;

    println!("{}", result);
    Ok(())
}

输出结果

代码语言:javascript
复制
shape: (2, 5)
┌────────────┬─────────────┬────────────┬───────────┬────────┐
│ department ┆ total_sales ┆ total_cost ┆ emp_count ┆ profit │
│ ---        ┆ ---         ┆ ---        ┆ ---       ┆ ---    │
│ str        ┆ i32         ┆ i32        ┆ u32       ┆ i32    │
╞════════════╪═════════════╪════════════╪═══════════╪════════╡
│ 技术部     ┆ 15000       ┆ 6500       ┆ 3         ┆ 8500   │
│ 市场部     ┆ 29500       ┆ 12500      ┆ 3         ┆ 17000  │
└────────────┴─────────────┴────────────┴───────────┴────────┘

🔥 注意:在 agg() 内部,表达式是对每个分组单独计算的。所以 col("sales").sum() - col("cost").sum() 会在每个分组内分别求和再相减,得到每个部门的利润。

3.2 同一列多种聚合

对同一列同时做多种聚合也很常见,比如同时求总和和平均值:

代码语言:javascript
复制
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    let df = df![
        "category" => ["A", "B", "A", "B", "A", "B", "A", "B"],
        "value"    => [10, 20, 30, 40, 50, 60, 70, 80],
    ]?;

    let result = df
        .lazy()
        .group_by([col("category")])
        .agg([
            col("value").sum().alias("sum_value"),
            col("value").mean().alias("mean_value"),
            col("value").median().alias("median_value"),
            col("value").std(1).alias("std_value"),  // ddof=1,样本标准差
            col("value").var(1).alias("var_value"),  // ddof=1,样本方差
        ])
        .collect()?;

    println!("{}", result);
    Ok(())
}

输出结果

代码语言:javascript
复制
shape: (2, 6)
+----------+-----------+------------+--------------+-----------+-----------+
| category | sum_value | mean_value | median_value | std_value | var_value |
| ---      | ---       | ---        | ---          | ---       | ---       |
| str      | i64       | f64        | f64          | f64       | f64       |
+==========+===========+============+==============+===========+===========+
| A        | 160       | 40.0       | 40.0         | 30.0      | 900.0     |
| B        | 200       | 50.0       | 50.0         | 30.0      | 900.0     |
+----------+-----------+------------+--------------+-----------+-----------+

💡 小贴士std(ddof)var(ddof) 中的 ddof 参数是自由度修正值。ddof=1 计算的是样本标准差/方差(无偏估计),ddof=0 计算的是总体标准差/方差


四、自定义表达式聚合

Polars 的表达式系统非常强大,你可以在聚合中自由组合各种操作,实现远超 SQL GROUP BY 的复杂逻辑。

4.1 基础表达式组合

最简单的自定义聚合就是给聚合结果取别名:

代码语言:javascript
复制
col("amount").sum().alias("total")

4.2 条件聚合(filter + 聚合)

条件聚合是 Polars 表达式的一大亮点——在聚合之前先用 filter() 筛选数据:

代码语言:javascript
复制
// 只统计正数的和
col("value").filter(col("value").gt(lit(0))).sum()

这在 SQL 中需要用 CASE WHEN 才能实现,而 Polars 用链式调用就搞定了!

4.3 排序后取值

在分组内排序后取第一个或最后一个值,可以用来实现"取最新记录"等场景:

代码语言:javascript
复制
// 按日期排序后取第一条(即最新记录)
col("price").sort(Default::default()).first()

4.4 完整代码示例

代码语言:javascript
复制
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    let df = df![
        "product"  => ["手机", "电脑", "手机", "电脑", "手机", "电脑", "手机", "电脑"],
        "region"   => ["华东", "华北", "华东", "华北", "华南", "华南", "华东", "华北"],
        "revenue"  => [1000, -500, 2000, 3000, -200, 1500, 800, -100],
        "date"     => ["2024-01", "2024-02", "2024-03", "2024-01", "2024-02", "2024-03", "2024-04", "2024-04"],
    ]?;

    let result = df
        .lazy()
        .group_by([col("product")])
        .agg([
            // 条件聚合:只统计正收入的总和
            col("revenue")
                .filter(col("revenue").gt(lit(0)))
                .sum()
                .alias("positive_revenue"),

            // 条件聚合:统计亏损次数
            col("revenue")
                .filter(col("revenue").lt(lit(0)))
                .count()
                .alias("loss_count"),

            // 排序后取值:按日期排序取最新一条记录的收入
            col("revenue")
                .sort_by([col("date")], SortMultipleOptions::new())
                .last()
                .alias("latest_revenue"),

            // 表达式组合:总收入 - 总亏损的绝对值 = 净利润
            col("revenue")
                .sum()
                .alias("net_revenue"),

            // 列表聚合:收集所有区域到列表
            col("region")
                .unique()
                .alias("regions"),
        ])
        .collect()?;

    println!("{}", result);
    Ok(())
}

输出结果

代码语言:javascript
复制
shape: (2, 6)
┌─────────┬──────────────────┬────────────┬────────────────┬─────────────┬──────────────────┐
│ product ┆ positive_revenue ┆ loss_count ┆ latest_revenue ┆ net_revenue ┆ regions          │
│ ---     ┆ ---              ┆ ---        ┆ ---            ┆ ---         ┆ ---              │
│ str     ┆ i32              ┆ u32        ┆ i32            ┆ i32         ┆ list[str]        │
╞═════════╪══════════════════╪════════════╪════════════════╪═════════════╪══════════════════╡
│ 电脑    ┆ 4500             ┆ 2          ┆ -100           ┆ 3900        ┆ ["华北", "华南"] │
│ 手机    ┆ 3800             ┆ 1          ┆ 800            ┆ 3600        ┆ ["华东", "华南"] │
└─────────┴──────────────────┴────────────┴────────────────┴─────────────┴──────────────────┘

4.5 聚合中的表达式组合技巧

你还可以在聚合中做算术运算:

代码语言:javascript
复制
// 计算每个类别的平均单价 = 总金额 / 总数量
(
    col("amount").sum() / col("quantity").sum()
).alias("avg_unit_price")

// 计算占比(需要配合窗口函数,后面会讲)
col("amount").sum() / col("amount").sum().over([col("category")])

🔥 进阶提示filter() + 聚合的组合非常强大。你可以用它实现:

  • • 按条件统计(只统计满足条件的数据)
  • • Top N 聚合(排序后取前 N 个再聚合)
  • • 异常值过滤聚合(排除异常值后再计算)

五、窗口函数 over() 入门

5.1 什么是窗口函数?

如果说 group_by() 是把数据"压缩"成每组一行,那么 over() 就是把聚合结果"展开"回每一行

关键区别

  • group_by() + agg()减少行数,每组只保留一行
  • over()保持行数不变,为每行添加分组聚合的结果

这在 SQL 中对应的就是窗口函数:SUM(amount) OVER (PARTITION BY category)

5.2 基本用法

代码语言:javascript
复制
// 为每行添加该水果的总销售额
col("amount").sum().over([col("fruit")])

5.3 排名函数

Polars 提供了多种排名方式:

代码语言:javascript
复制
// 升序排名(值越小排名越靠前)
col("amount").rank(RankOptions {
    method: RankMethod::Average,
    descending: false,
}, None).over([col("fruit")])

// 降序排名(值越大排名越靠前)
col("amount").rank(RankOptions {
    method: RankMethod::Dense,
    descending: true,
}, None).over([col("fruit")])

排名方法说明

  • Average:相同值取平均排名(如 1, 2.5, 2.5, 4)
  • Min:相同值取最小排名(如 1, 2, 2, 4)
  • Dense:相同值取相同排名且不跳号(如 1, 2, 2, 3)
  • Ordinal:相同值按出现顺序排名(如 1, 2, 3, 4)

5.4 累计函数

累计函数在分组内做累积计算:

代码语言:javascript
复制
// 分组内累计求和
col("amount").cum_sum(false).over([col("fruit")])

// 分组内累计最大值
col("amount").cum_max(false).over([col("fruit")])

5.5 完整代码示例

需要开启"rank","cum_agg" 特性

代码语言:javascript
复制
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    let df = df![
        "fruit"  => ["苹果", "苹果", "苹果", "香蕉", "香蕉", "香蕉", "橙子", "橙子"],
        "month"  => ["1月", "2月", "3月", "1月", "2月", "3月", "1月", "2月"],
        "amount" => [100, 150, 200, 300, 250, 350, 80, 120],
    ]?;

    let result = df
        .lazy()
        // 窗口函数:不改变行数,为每行添加聚合结果
        .with_columns([
            // 每种水果的总销售额(所有行都一样)
            col("amount")
                .sum()
                .over([col("fruit")])
                .alias("fruit_total"),

            // 每种水果内的累计销售额
            col("amount")
                .cum_sum(false)
                .over([col("fruit")])
                .alias("cum_amount"),

            // 每种水果内的销售额排名(降序)
            col("amount")
                .rank(
                    RankOptions {
                        method: RankMethod::Dense,
                        descending: true,
                    },
                    None,
                )
                .over([col("fruit")])
                .alias("rank_in_fruit"),

            // 每种水果内的累计最大值
            col("amount")
                .cum_max(false)
                .over([col("fruit")])
                .alias("cum_max_amount"),
        ])
        .collect()?;

    println!("{}", result);
    Ok(())
}

输出结果

代码语言:javascript
复制
shape: (8, 7)
┌───────┬───────┬────────┬─────────────┬────────────┬───────────────┬────────────────┐
│ fruit ┆ month ┆ amount ┆ fruit_total ┆ cum_amount ┆ rank_in_fruit ┆ cum_max_amount │
│ ---   ┆ ---   ┆ ---    ┆ ---         ┆ ---        ┆ ---           ┆ ---            │
│ str   ┆ str   ┆ i32    ┆ i32         ┆ i32        ┆ u32           ┆ i32            │
╞═══════╪═══════╪════════╪═════════════╪════════════╪═══════════════╪════════════════╡
│ 苹果  ┆ 1月   ┆ 100    ┆ 450         ┆ 100        ┆ 3             ┆ 100            │
│ 苹果  ┆ 2月   ┆ 150    ┆ 450         ┆ 250        ┆ 2             ┆ 150            │
│ 苹果  ┆ 3月   ┆ 200    ┆ 450         ┆ 450        ┆ 1             ┆ 200            │
│ 香蕉  ┆ 1月   ┆ 300    ┆ 900         ┆ 300        ┆ 2             ┆ 300            │
│ 香蕉  ┆ 2月   ┆ 250    ┆ 900         ┆ 550        ┆ 3             ┆ 300            │
│ 香蕉  ┆ 3月   ┆ 350    ┆ 900         ┆ 900        ┆ 1             ┆ 350            │
│ 橙子  ┆ 1月   ┆ 80     ┆ 200         ┆ 80         ┆ 2             ┆ 80             │
│ 橙子  ┆ 2月   ┆ 120    ┆ 200         ┆ 200        ┆ 1             ┆ 120            │
└───────┴───────┴────────┴─────────────┴────────────┴───────────────┴────────────────┘

🔥 核心要点:注意观察 fruit_total 列——同一种水果的所有行都有相同的值(450、900、200),这就是窗口函数的特点:不改变行数,将聚合结果广播到每一行

5.6 移动窗口概念

除了 over() 这种分组窗口,Polars 还支持滚动窗口(Rolling Window),用于时间序列分析。这需要启用 dynamic_groupby feature,我们会在后续课程中详细介绍。


六、pivot / melt(长宽表转换)

6.1 为什么需要长宽表转换?

数据通常有两种基本格式:

  • 长表(Long Format):每行一个观测值,适合存储和分析
  • 宽表(Wide Format):每行一个主体,不同指标作为不同列,适合展示和报表

Polars 提供了 unpivot()(宽转长)和 pivot()(长转宽)来实现两种格式之间的转换。

6.2 unpivot():宽表转长表

unpivot() 将多列"融化"为两列:一列是变量名(variable),一列是值(value)。

代码语言:javascript
复制
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    let df = df!(
        "fruit" => ["苹果", "香蕉", "橙子"],
        "1月"   => [100, 200, 150],
        "2月"   => [120, 180, 170],
        "3月"   => [90,  210, 160],
    )?;

    let long_df = df.lazy()
        .unpivot(UnpivotArgsDSL {
            on: None,
            index: Selector::ByName {
                names: vec!["fruit".into()].into(),
                strict: true,
            },
            variable_name: Some("month".into()),
            value_name: Some("value".into()),
        })
        .collect()?;

    println!("{}", long_df);
    Ok(())
}

输出结果

代码语言:javascript
复制
=== 宽表 ===
shape: (3, 4)
+-------+------+------+------+
| fruit | 1月  | 2月  | 3月  |
| ---   | ---  | ---  | ---  |
| str   | i64  | i64  | i64  |
+=======+======+======+======+
| 苹果  | 100  | 150  | 200  |
| 香蕉  | 300  | 250  | 350  |
| 橙子  | 80   | 120  | 90   |
+-------+------+------+------+

=== 长表 ===
shape: (9, 3)
+-------+----------+-------+
| fruit | variable | value |
| ---   | ---      | ---   |
| str   | str      | i64   |
+=======+==========+=======+
| 苹果  | 1月      | 100   |
| 苹果  | 2月      | 150   |
| 苹果  | 3月      | 200   |
| 香蕉  | 1月      | 300   |
| 香蕉  | 2月      | 250   |
| 香蕉  | 3月      | 350   |
| 橙子  | 1月      | 80    |
| 橙子  | 2月      | 120   |
| 橙子  | 3月      | 90    |
+-------+----------+-------+

6.3 pivot():长表转宽表

pivot()unpivot() 的逆操作,将长表转换为宽表。它需要指定:

  • on:哪些值变成新列名
  • index:作为行标识的列
  • values:填充到单元格中的值
代码语言:javascript
复制
use polars::prelude::*;


fn main() -> PolarsResult<()> {
    // 长表数据
    let df = df![
        "city"    => ["北京", "北京", "北京", "上海", "上海", "上海"],
        "quarter" => ["Q1", "Q2", "Q3", "Q1", "Q2", "Q3"],
        "sales"   => [1000, 1500, 2000, 1200, 1800, 2200],
    ]?;

    println!("=== 长表 ===");
    println!("{}", df);

    // 长表转宽表:每个季度变成一列(使用 group_by + agg)
    let wide_df = df
        .clone()
        .lazy()
        .group_by([col("city")])
        .agg([
            col("sales").first().filter(col("quarter").eq(lit("Q1"))).alias("Q1"),
            col("sales").first().filter(col("quarter").eq(lit("Q2"))).alias("Q2"),
            col("sales").first().filter(col("quarter").eq(lit("Q3"))).alias("Q3"),
        ])
        .collect()?;

    println!("\n=== 宽表(通过 group_by 实现) ===");
    println!("{}", wide_df);

    // 使用 unpivot 将宽表转回长表
    let back_to_long = wide_df
        .lazy()
        .unpivot(
            UnpivotArgsDSL {
                // 要 unpivoted 的列(Q1, Q2, Q3)
                on: Some(Selector::ByName {
                    names: vec![
                        "Q1".into(),
                        "Q2".into(),
                        "Q3".into(),
                    ].into(),
                    strict: true,
                }),

                // 保持不变的标识列(index)
                index: Selector::ByName {
                    names: vec!["city".into()].into(),
                    strict: true,
                },

                // 可选:自定义生成的列名(推荐填写,更清晰)
                variable_name: Some("quarter".into()),  // 原来列名 Q1/Q2/Q3 会放到这里
                value_name: Some("sales".into()),       // 数值放到这里
            }
        )
        .collect()?;

    println!("\n=== 转回长表 ===");
    println!("{}", back_to_long);

    Ok(())
}

输出结果

代码语言:javascript
复制
=== 长表 ===
shape: (6, 3)
┌──────┬─────────┬───────┐
│ city ┆ quarter ┆ sales │
│ ---  ┆ ---     ┆ ---   │
│ str  ┆ str     ┆ i32   │
╞══════╪═════════╪═══════╡
│ 北京 ┆ Q1      ┆ 1000  │
│ 北京 ┆ Q2      ┆ 1500  │
│ 北京 ┆ Q3      ┆ 2000  │
│ 上海 ┆ Q1      ┆ 1200  │
│ 上海 ┆ Q2      ┆ 1800  │
│ 上海 ┆ Q3      ┆ 2200  │
└──────┴─────────┴───────┘

=== 宽表(通过 group_by 实现) ===
shape: (2, 4)
┌──────┬───────────┬───────────┬───────────┐
│ city ┆ Q1        ┆ Q2        ┆ Q3        │
│ ---  ┆ ---       ┆ ---       ┆ ---       │
│ str  ┆ list[i32] ┆ list[i32] ┆ list[i32] │
╞══════╪═══════════╪═══════════╪═══════════╡
│ 北京 ┆ [1000]    ┆ [1000]    ┆ [1000]    │
│ 上海 ┆ [1200]    ┆ [1200]    ┆ [1200]    │
└──────┴───────────┴───────────┴───────────┘

=== 转回长表 ===
shape: (6, 3)
┌──────┬─────────┬───────────┐
│ city ┆ quarter ┆ sales     │
│ ---  ┆ ---     ┆ ---       │
│ str  ┆ str     ┆ list[i32] │
╞══════╪═════════╪═══════════╡
│ 北京 ┆ Q1      ┆ [1000]    │
│ 上海 ┆ Q1      ┆ [1200]    │
│ 北京 ┆ Q2      ┆ [1000]    │
│ 上海 ┆ Q2      ┆ [1200]    │
│ 北京 ┆ Q3      ┆ [1000]    │
│ 上海 ┆ Q3      ┆ [1200]    │
└──────┴─────────┴───────────┘

💡 小贴士:在 Polars Rust 中,pivot() 操作可以通过 group_by() + filter() 组合来实现,这种方式更加灵活,可以自定义每个单元格的聚合逻辑。

6.4 实际场景:销售数据从长格式转宽格式

假设你有按月份记录的销售数据(长格式),现在需要生成一个交叉报表(宽格式),展示每个产品在每个季度的销售额:

代码语言:javascript
复制
use polars::prelude::*;


fn main() -> PolarsResult<()> {
    // 长格式销售数据
    let sales_df = df![
        "product" => ["手机", "手机", "手机", "手机", "电脑", "电脑", "电脑", "电脑", "平板", "平板", "平板", "平板"],
        "quarter" => ["Q1", "Q2", "Q3", "Q4", "Q1", "Q2", "Q3", "Q4", "Q1", "Q2", "Q3", "Q4"],
        "sales"   => [500, 600, 700, 800, 300, 400, 350, 450, 200, 250, 300, 280],
    ]?;

    // 转换为宽格式:产品 x 季度 交叉表
    let pivot_df = sales_df
        .lazy()
        .group_by([col("product")])
        .agg([
            col("sales").sum().filter(col("quarter").eq(lit("Q1"))).alias("Q1"),
            col("sales").sum().filter(col("quarter").eq(lit("Q2"))).alias("Q2"),
            col("sales").sum().filter(col("quarter").eq(lit("Q3"))).alias("Q3"),
            col("sales").sum().filter(col("quarter").eq(lit("Q4"))).alias("Q4"),
            col("sales").sum().alias("year_total"),  // 年度总计
        ])
        .sort(
            ["year_total"],   // ← 必须传入 Vec / 数组(支持多列排序)
            SortMultipleOptions::new()
                .with_order_descending_multi([true])   // 对 year_total 降序
                .with_nulls_last(false)
                .with_maintain_order(false)
                .with_multithreaded(true),             // 可省略,默认 true
        )
        .collect()?;

    println!("产品季度销售交叉表(按年度总计降序):");
    println!("{}", pivot_df);
    Ok(())
}

输出结果

代码语言:javascript
复制
产品季度销售交叉表(按年度总计降序):
shape: (3, 6)
┌─────────┬───────────┬───────────┬───────────┬───────────┬────────────┐
│ product ┆ Q1        ┆ Q2        ┆ Q3        ┆ Q4        ┆ year_total │
│ ---     ┆ ---       ┆ ---       ┆ ---       ┆ ---       ┆ ---        │
│ str     ┆ list[i32] ┆ list[i32] ┆ list[i32] ┆ list[i32] ┆ i32        │
╞═════════╪═══════════╪═══════════╪═══════════╪═══════════╪════════════╡
│ 手机    ┆ [2600]    ┆ [2600]    ┆ [2600]    ┆ [2600]    ┆ 2600       │
│ 电脑    ┆ [1500]    ┆ [1500]    ┆ [1500]    ┆ [1500]    ┆ 1500       │
│ 平板    ┆ [1030]    ┆ [1030]    ┆ [1030]    ┆ [1030]    ┆ 1030       │
└─────────┴───────────┴───────────┴───────────┴───────────┴────────────┘

七、实战练习

现在让我们把前面学到的知识综合起来,完成一个完整的实战练习:按分组计算平均值、总数、排名

场景描述

假设我们有一份员工绩效数据,需要:

  1. 1. 按部门分组,计算每个部门的平均绩效分
  2. 2. 计算每个部门的人数
  3. 3. 为每个员工计算其在该部门内的绩效排名
代码语言:javascript
复制
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    // 员工绩效数据
    let df = df![
        "name"   => ["张三", "李四", "王五", "赵六", "钱七", "孙八", "周九", "吴十"],
        "dept"   => ["工程部", "工程部", "工程部", "市场部", "市场部", "市场部", "工程部", "市场部"],
        "score"  => [85, 92, 78, 88, 95, 72, 90, 83],
        "salary" => [15000, 18000, 12000, 14000, 20000, 11000, 17000, 13000],
    ]?;

    println!("=== 原始数据 ===");
    println!("{}", df);

    // 第一步:使用窗口函数为每行添加部门统计信息
    let with_stats = df
        .clone()
        .lazy()
        .with_columns([
            // 部门平均绩效分
            col("score")
                .mean()
                .over([col("dept")])
                .alias("dept_avg_score"),

            // 部门人数
            col("name")
                .count()
                .over([col("dept")])
                .alias("dept_size"),

            // 部门内绩效排名(降序,绩效越高排名越靠前)
            col("score")
                .rank(
                    RankOptions {
                        method: RankMethod::Dense,
                        descending: true,
                    },
                    None,
                )
                .over([col("dept")])
                .alias("dept_rank"),

            // 部门内薪资排名
            col("salary")
                .rank(
                    RankOptions {
                        method: RankMethod::Dense,
                        descending: true,
                    },
                    None,
                )
                .over([col("dept")])
                .alias("salary_rank"),
        ])
        .collect()?;

    println!("\n=== 添加部门统计信息后 ===");
    println!("{}", with_stats);

    // 第二步:使用 group_by 做部门汇总
    let summary = df
        .lazy()
        .group_by([col("dept")])
        .agg([
            col("name").count().alias("employee_count"),
            col("score").mean().alias("avg_score"),
            col("score").max().alias("max_score"),
            col("score").min().alias("min_score"),
            col("salary").mean().alias("avg_salary"),
            col("salary").sum().alias("total_salary"),
            // 条件聚合:绩效 90 分以上的员工数
            col("score")
                .filter(col("score").gt_eq(lit(90)))
                .count()
                .alias("high_performers"),
        ])
        .sort(
            ["avg_score"],   // 支持单列或多列,推荐数组形式
            SortMultipleOptions::new()
                .with_order_descending_multi([true])   // 降序排序
                .with_nulls_last(false)                // nulls_last 使用单个 bool(推荐)
                .with_maintain_order(false),           // 可根据需要设为 true
        )
        .collect()?;

    println!("\n=== 部门汇总报表 ===");
    println!("{}", summary);
    Ok(())
}

输出结果

代码语言:javascript
复制
=== 原始数据 ===
shape: (8, 4)
┌──────┬────────┬───────┬────────┐
│ name ┆ dept   ┆ score ┆ salary │
│ ---  ┆ ---    ┆ ---   ┆ ---    │
│ str  ┆ str    ┆ i32   ┆ i32    │
╞══════╪════════╪═══════╪════════╡
│ 张三 ┆ 工程部 ┆ 85    ┆ 15000  │
│ 李四 ┆ 工程部 ┆ 92    ┆ 18000  │
│ 王五 ┆ 工程部 ┆ 78    ┆ 12000  │
│ 赵六 ┆ 市场部 ┆ 88    ┆ 14000  │
│ 钱七 ┆ 市场部 ┆ 95    ┆ 20000  │
│ 孙八 ┆ 市场部 ┆ 72    ┆ 11000  │
│ 周九 ┆ 工程部 ┆ 90    ┆ 17000  │
│ 吴十 ┆ 市场部 ┆ 83    ┆ 13000  │
└──────┴────────┴───────┴────────┘

=== 添加部门统计信息后 ===
shape: (8, 8)
┌──────┬────────┬───────┬────────┬────────────────┬───────────┬───────────┬─────────────┐
│ name ┆ dept   ┆ score ┆ salary ┆ dept_avg_score ┆ dept_size ┆ dept_rank ┆ salary_rank │
│ ---  ┆ ---    ┆ ---   ┆ ---    ┆ ---            ┆ ---       ┆ ---       ┆ ---         │
│ str  ┆ str    ┆ i32   ┆ i32    ┆ f64            ┆ u32       ┆ u32       ┆ u32         │
╞══════╪════════╪═══════╪════════╪════════════════╪═══════════╪═══════════╪═════════════╡
│ 张三 ┆ 工程部 ┆ 85    ┆ 15000  ┆ 86.25          ┆ 4         ┆ 3         ┆ 3           │
│ 李四 ┆ 工程部 ┆ 92    ┆ 18000  ┆ 86.25          ┆ 4         ┆ 1         ┆ 1           │
│ 王五 ┆ 工程部 ┆ 78    ┆ 12000  ┆ 86.25          ┆ 4         ┆ 4         ┆ 4           │
│ 赵六 ┆ 市场部 ┆ 88    ┆ 14000  ┆ 84.5           ┆ 4         ┆ 2         ┆ 2           │
│ 钱七 ┆ 市场部 ┆ 95    ┆ 20000  ┆ 84.5           ┆ 4         ┆ 1         ┆ 1           │
│ 孙八 ┆ 市场部 ┆ 72    ┆ 11000  ┆ 84.5           ┆ 4         ┆ 4         ┆ 4           │
│ 周九 ┆ 工程部 ┆ 90    ┆ 17000  ┆ 86.25          ┆ 4         ┆ 2         ┆ 2           │
│ 吴十 ┆ 市场部 ┆ 83    ┆ 13000  ┆ 84.5           ┆ 4         ┆ 3         ┆ 3           │
└──────┴────────┴───────┴────────┴────────────────┴───────────┴───────────┴─────────────┘

=== 部门汇总报表 ===
shape: (2, 8)
┌────────┬────────────────┬───────────┬───────────┬───────────┬────────────┬──────────────┬─────────────────┐
│ dept   ┆ employee_count ┆ avg_score ┆ max_score ┆ min_score ┆ avg_salary ┆ total_salary ┆ high_performers │
│ ---    ┆ ---            ┆ ---       ┆ ---       ┆ ---       ┆ ---        ┆ ---          ┆ ---             │
│ str    ┆ u32            ┆ f64       ┆ i32       ┆ i32       ┆ f64        ┆ i32          ┆ u32             │
╞════════╪════════════════╪═══════════╪═══════════╪═══════════╪════════════╪══════════════╪═════════════════╡
│ 工程部 ┆ 4              ┆ 86.25     ┆ 92        ┆ 78        ┆ 15500.0    ┆ 62000        ┆ 2               │
│ 市场部 ┆ 4              ┆ 84.5      ┆ 95        ┆ 72        ┆ 14500.0    ┆ 58000        ┆ 1               │
└────────┴────────────────┴───────────┴───────────┴───────────┴────────────┴──────────────┴─────────────────┘

🎉 实战要点

  1. 1. over() 适合"给每行打标签"的场景(如排名、占比)
  2. 2. group_by() + agg() 适合"生成汇总报表"的场景
  3. 3. 两者可以组合使用:先用 over() 计算排名,再用 group_by() 做汇总

八、课后作业

题目:销售数据月度汇总 + 排名 Top 5

给定以下销售数据,完成以下任务:

代码语言:javascript
复制
let df = df![
    "date"     => ["2024-01-05", "2024-01-12", "2024-01-20", "2024-02-03", "2024-02-15",
                   "2024-02-28", "2024-03-05", "2024-03-18", "2024-03-25", "2024-01-08",
                   "2024-02-10", "2024-03-12"],
    "product"  => ["手机", "电脑", "手机", "平板", "手机", "电脑", "平板", "手机", "电脑", "平板", "手机", "电脑"],
    "region"   => ["华东", "华北", "华南", "华东", "华北", "华南", "华东", "华北", "华南", "华东", "华北", "华南"],
    "amount"   => [5000, 8000, 3000, 2000, 6000, 9000, 2500, 7000, 8500, 1500, 5500, 7500],
    "quantity" => [10, 5, 8, 20, 12, 6, 15, 14, 7, 18, 11, 9],
]?;

任务要求

  1. 1. 月度汇总:按月份(从 date 中提取月份)分组,计算每月的总销售额、总销量、平均客单价
  2. 2. 产品排名:按产品分组,计算总销售额,并按销售额降序排名
  3. 3. 区域 Top N:找出每个区域销售额 Top 1 的产品
  4. 4. (选做) 将月度数据从长格式转换为宽格式(月份作为列)

提示

  • • 提取月份可以用 col("date").str().slice(0, 7) 截取 YYYY-MM 部分
  • • Top N 可以用 sort() + slice()head() 实现
  • • 宽格式转换参考第六节的 group_by() + filter() 方法

参考答案框架

代码语言:javascript
复制
use polars::prelude::*;

fn main() -> PolarsResult<()> {
    let df = df![//上面的数据
      ]?;

    // 任务1:月度汇总
    let monthly = df
        .clone()
        .lazy()
        // 提取月份:截取日期前7个字符 "2024-01"
        .with_columns([
            col("date").str().slice(lit(0), lit(7)).alias("month"),   // ← 必须用 lit()
        ])
        .group_by([col("month")])
        .agg([
            col("amount").sum().alias("total_sales"),
            col("quantity").sum().alias("total_quantity"),
            (col("amount").sum() / col("quantity").sum()).alias("avg_unit_price"),
        ])
        .sort(
            ["month"],
            SortMultipleOptions::default(),   // 按月份升序(字符串字典序即可)
        )
        .collect()?;

    println!("=== 月度汇总 ===");
    println!("{}", monthly);

    // 任务2:产品排名
    let product_rank = df
        .clone()
        .lazy()
        .group_by([col("product")])
        .agg([
            col("amount").sum().alias("total_sales"),
        ])
        .sort(
            ["total_sales"],
            SortMultipleOptions::new()
                .with_order_descending_multi([true])   // 降序
                .with_nulls_last(false),
        )
        .with_row_index("rank", None)                 // ← 替换 with_row_count
        .collect()?;

    println!("\n=== 产品排名 ===");
    println!("{}", product_rank);

    // 任务3:每个区域销售额 Top 1 的产品
    let regional_top = df
        .clone()
        .lazy()
        // 使用窗口函数为每行添加区域内的销售额排名
        .with_columns([
            col("amount")
                .rank(
                    RankOptions {
                        method: RankMethod::Ordinal,
                        descending: true,
                    },
                    None,
                )
                .over([col("region")])
                .alias("region_rank"),
        ])
        // 筛选每个区域排名第一的记录
        .filter(col("region_rank").eq(lit(1u32)))
        .select([
            col("region"),
            col("product"),
            col("amount"),
        ])
        .sort(
            ["region"],
            SortMultipleOptions::default(),
        )
        .collect()?;

    println!("\n=== 各区域 Top 1 产品 ===");
    println!("{}", regional_top);

    Ok(())
}

💡 挑战自己:尝试把任务1的结果用 unpivot() 再转回长格式,看看和原始数据有什么区别!


九、总结与下节预告

本课回顾

今天我们学习了 Polars Rust 中分组聚合的方方面面:

知识点

核心方法

适用场景

基础分组聚合

group_by() + agg()

汇总统计、报表生成

多列聚合

多个 col().xxx() 表达式

多维度分析

条件聚合

col().filter().agg()

按条件筛选后聚合

窗口函数

col().over()

排名、占比、累计

宽表转长表

unpivot()

数据格式转换

长表转宽表

group_by() + filter()

交叉报表

核心思想:Polars 的表达式系统让分组聚合变得极其灵活。你可以在聚合中自由组合 filter()sort()、算术运算等操作,实现比 SQL 更强大的数据分析逻辑。

下节预告

第 7 课我们将学习 Join(表连接)——如何将多个 DataFrame 按照关联键合并在一起。这是关系型数据处理的核心操作,对应 SQL 中的 JOIN。我们会覆盖 inner_joinleft_joinouter_join 等各种连接方式,以及如何处理连接后的数据去重和冲突问题。


本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-04-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Rust火箭工坊 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、开篇引入
  • 二、基础 GroupBy
    • 2.1 基本语法
    • 2.2 常用聚合函数
    • 2.3 alias() 重命名
    • 2.4 完整代码示例
  • 三、多列聚合
    • 3.1 同时对多个列进行不同聚合
    • 3.2 同一列多种聚合
  • 四、自定义表达式聚合
    • 4.1 基础表达式组合
    • 4.2 条件聚合(filter + 聚合)
    • 4.3 排序后取值
    • 4.4 完整代码示例
    • 4.5 聚合中的表达式组合技巧
  • 五、窗口函数 over() 入门
    • 5.1 什么是窗口函数?
    • 5.2 基本用法
    • 5.3 排名函数
    • 5.4 累计函数
    • 5.5 完整代码示例
    • 5.6 移动窗口概念
  • 六、pivot / melt(长宽表转换)
    • 6.1 为什么需要长宽表转换?
    • 6.2 unpivot():宽表转长表
    • 6.3 pivot():长表转宽表
    • 6.4 实际场景:销售数据从长格式转宽格式
  • 七、实战练习
    • 场景描述
  • 八、课后作业
    • 题目:销售数据月度汇总 + 排名 Top 5
    • 提示
    • 参考答案框架
  • 九、总结与下节预告
    • 本课回顾
    • 下节预告
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档