
如果你用过 SQL,一定写过这样的语句:
SELECT category, SUM(amount), AVG(price)
FROM sales
GROUP BY category;这就是分组聚合——按照某个维度(如类别、部门、月份)把数据分成若干组,然后对每组数据进行汇总计算。它是数据分析的"瑞士军刀",几乎所有的统计报表、数据看板都离不开它。
在 Polars 中,分组聚合通过 group_by() + agg() 实现,配合 Lazy API 的表达式系统,不仅能完成 SQL GROUP BY 的所有功能,还能实现更复杂的条件聚合、窗口计算等高级操作。
本节课我们将从基础语法出发,逐步深入到自定义表达式、窗口函数、长宽表转换等进阶内容。学完这节课,你就能用 Rust + Polars 优雅地处理绝大多数分组聚合场景了!💪
在 Polars 的 Lazy API 中,分组聚合的链式调用非常直观:
.lazy()
.group_by([col("分组列")])
.agg([
col("数值列").sum(),
// ... 更多聚合表达式
])核心概念:
group_by() 接收一个表达式数组,指定按哪些列分组agg() 接收一个表达式数组,指定对每组数据做什么聚合计算.collect() 执行整个惰性计划Polars 提供了丰富的聚合函数,覆盖了绝大多数统计需求:
聚合函数 | 说明 | 适用类型 |
|---|---|---|
sum() | 求和 | 数值型 |
mean() | 求平均值 | 数值型 |
median() | 求中位数 | 数值型 |
min() | 最小值 | 数值型 / 字符串 |
max() | 最大值 | 数值型 / 字符串 |
count() | 计数(非空值) | 任意类型 |
first() | 取第一个值 | 任意类型 |
last() | 取最后一个值 | 任意类型 |
std() | 标准差 | 数值型 |
var() | 方差 | 数值型 |
n_unique() | 唯一值计数 | 任意类型 |
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(())
}
输出
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 │
└─────────┴─────┴──────────┴────────┴───┴───────┴──────┴──────────┴──────────┘聚合后的列名默认是原列名(如 amount),当同一列做多种聚合时会产生冲突。使用 .alias("新名称") 可以为聚合结果指定新列名:
col("amount").sum().alias("total_amount")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(())
}输出结果:
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()中再次选择它们。
实际业务中,我们经常需要对多个列分别做不同的聚合操作。比如按部门统计:总销售额(amount 的 sum)、平均单价(price 的 mean)、员工数量(name 的 count)。
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(())
}输出结果:
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()会在每个分组内分别求和再相减,得到每个部门的利润。
对同一列同时做多种聚合也很常见,比如同时求总和和平均值:
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(())
}输出结果:
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 的复杂逻辑。
最简单的自定义聚合就是给聚合结果取别名:
col("amount").sum().alias("total")条件聚合是 Polars 表达式的一大亮点——在聚合之前先用 filter() 筛选数据:
// 只统计正数的和
col("value").filter(col("value").gt(lit(0))).sum()这在 SQL 中需要用 CASE WHEN 才能实现,而 Polars 用链式调用就搞定了!
在分组内排序后取第一个或最后一个值,可以用来实现"取最新记录"等场景:
// 按日期排序后取第一条(即最新记录)
col("price").sort(Default::default()).first()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(())
}输出结果:
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 ┆ ["华东", "华南"] │
└─────────┴──────────────────┴────────────┴────────────────┴─────────────┴──────────────────┘你还可以在聚合中做算术运算:
// 计算每个类别的平均单价 = 总金额 / 总数量
(
col("amount").sum() / col("quantity").sum()
).alias("avg_unit_price")
// 计算占比(需要配合窗口函数,后面会讲)
col("amount").sum() / col("amount").sum().over([col("category")])🔥 进阶提示:
filter()+ 聚合的组合非常强大。你可以用它实现:
如果说 group_by() 是把数据"压缩"成每组一行,那么 over() 就是把聚合结果"展开"回每一行。
关键区别:
group_by() + agg():减少行数,每组只保留一行over():保持行数不变,为每行添加分组聚合的结果这在 SQL 中对应的就是窗口函数:SUM(amount) OVER (PARTITION BY category)。
// 为每行添加该水果的总销售额
col("amount").sum().over([col("fruit")])Polars 提供了多种排名方式:
// 升序排名(值越小排名越靠前)
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)累计函数在分组内做累积计算:
// 分组内累计求和
col("amount").cum_sum(false).over([col("fruit")])
// 分组内累计最大值
col("amount").cum_max(false).over([col("fruit")])需要开启"rank","cum_agg" 特性
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(())
}输出结果:
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),这就是窗口函数的特点:不改变行数,将聚合结果广播到每一行。
除了 over() 这种分组窗口,Polars 还支持滚动窗口(Rolling Window),用于时间序列分析。这需要启用 dynamic_groupby feature,我们会在后续课程中详细介绍。
数据通常有两种基本格式:
Polars 提供了 unpivot()(宽转长)和 pivot()(长转宽)来实现两种格式之间的转换。
unpivot() 将多列"融化"为两列:一列是变量名(variable),一列是值(value)。
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(())
}
输出结果:
=== 宽表 ===
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 |
+-------+----------+-------+pivot() 是 unpivot() 的逆操作,将长表转换为宽表。它需要指定:
on:哪些值变成新列名index:作为行标识的列values:填充到单元格中的值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(())
}输出结果:
=== 长表 ===
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()组合来实现,这种方式更加灵活,可以自定义每个单元格的聚合逻辑。
假设你有按月份记录的销售数据(长格式),现在需要生成一个交叉报表(宽格式),展示每个产品在每个季度的销售额:
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(())
}
输出结果:
产品季度销售交叉表(按年度总计降序):
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 │
└─────────┴───────────┴───────────┴───────────┴───────────┴────────────┘现在让我们把前面学到的知识综合起来,完成一个完整的实战练习:按分组计算平均值、总数、排名。
假设我们有一份员工绩效数据,需要:
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(())
}输出结果:
=== 原始数据 ===
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 │
└────────┴────────────────┴───────────┴───────────┴───────────┴────────────┴──────────────┴─────────────────┘🎉 实战要点:
over() 适合"给每行打标签"的场景(如排名、占比)group_by() + agg() 适合"生成汇总报表"的场景over() 计算排名,再用 group_by() 做汇总给定以下销售数据,完成以下任务:
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],
]?;任务要求:
col("date").str().slice(0, 7) 截取 YYYY-MM 部分sort() + slice() 或 head() 实现group_by() + filter() 方法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_join、left_join、outer_join 等各种连接方式,以及如何处理连接后的数据去重和冲突问题。