☰
R语言dplyr核心函数group_by:分组聚合、分组残留与实战用法详解
2026/10/15 23:01:03 网站建设 项目流程

接手一份订单明细表的时候,我最先做的不是急着算汇总,而是先把 dplyr 包加载进来,然后对数据集执行 group_by() 分组。这个习惯是踩了无数次坑之后换来的。说白了,group_by() 的核心作用就是把整个数据集按照给定变量切成逻辑上的一块一块,让后面所有的聚合、排名、筛选都自动以"组"为单元工作。这篇文章不讲虚的,就围绕这个函数把原理、玩法、坑和实战全部摊开讲一遍,无论你是刚接触 R 的新手,还是在 tidyverse 里泡了好几年但偶尔被分组残留问题绊一跤的老手,都能从这里拿走点东西。

1. 分组不是拆分数据,而是给数据贴上分组标签

1.1 先看一次最简单的分组操作

我见过不少初学者,第一次运行 group_by() 之后盯着控制台看了半天,然后冒出一句:"数据怎么没变?" 这其实正是理解这个函数的关键。我用一份极简的模拟数据演示一下。

library(dplyr) df <- tibble( department = c("研发", "研发", "研发", "市场", "市场", "销售", "销售"), employee = c("A", "B", "C", "D", "E", "F", "G"), score = c(85, 92, 78, 88, 95, 70, 82) ) grouped <- df %>% group_by(department) grouped

此时打印 grouped,你会看到表格下方多了一行提示:Groups: department [3]。行数还是 7,列还是 3,数据本身一个字节都没动。区别在于,这张表现在被打上了"按部门分成了 3 组"的标签。R 心里清楚:研发那三行是一组,市场那两行是一组,销售那两行是一组。

所以我会反复跟人说,group_by() 不是真的把数据拆成了三个独立的小表,而是给原表贴上了一层分组信息。这个理解特别重要,因为它决定了你后面所有的操作逻辑。

1.2 分组信息到底存在哪里

如果你好奇这层"分组信息"到底放了什么,可以用 attr() 去看看。

attr(grouped, "groups")

返回的是一张小 tibble,记录着每个组的分组键取值,以及一个名为.rows的列表列,里面是每一组对应的行号。比如研发这一组的.rows就包含c(1, 2, 3)。

打个比方,这就像给一本相册贴彩色标签:红色的标签代表旅行照片,蓝色的代表美食照片。相册的物理顺序没有变,但当你只想要蓝色标签的照片时,你只需要跟着标签去翻对应的那几页。group_by() 就是贴标签的动作,后续的 summarise()、mutate() 就相当于"只处理某种颜色"的筛选动作。

另外值得留意的是,分组后的对象类型会变成grouped_df,它本质上还是一个 data.frame,所以大多数普通的 data.frame 操作仍然能作用在它上面,只是部分函数会根据分组信息表现不同。

1.3 新手第一个常识错误:group_by 不等于汇总

很多人以为执行完 group_by() 就会自动得到一个"每个部门一行、带部门平均值"的结果,这是误解。group_by() 本身不返回任何统计摘要,它只是改变后续操作的执行粒度。想要"每组一行"的汇总结果,必须由 summarize()(或拼写为 summarise())来承担。

df %>% group_by(department) %>% summarise( 平均分 = mean(score), 人数 = n() )

执行之后就能看到研发、市场、销售各一行,这才是大多数人想要的分组汇总结果。所以请记住:分组是过程,汇总才是目的。如果只调用 group_by() 而不接任何后续操作,那你只是给数据贴了标签,但没有利用这些标签。

2. 聚合、变换、筛选、排序:分组之后能做的四类计算

当数据被贴上分组标签之后,dplyr 里有四类操作会默认按组工作,这四类正好对应日常数据分析中最常用的动作。

2.1 聚合:summarise() 让每组变成一行

分组汇总的核心就是 summarise()。它接收一系列聚合表达式,对每一个分组分别计算,然后输出一个新的 tibble,每组占一行。

df %>% group_by(department) %>% summarise( 平均分 = mean(score), 最高分 = max(score), 人数 = n() )

这里有几个高频使用的聚合函数,我整理成了表格,方便你直接查。

函数含义常见场景
n()组内行数统计每组的记录条数
sum(x)组内求和销售额、成本、流量汇总
mean(x)组内均值客单价、平均耗时
median(x)组内中位数对偏态分布更稳健的集中趋势
sd(x)组内标准差稳定性、波动程度
min(x) / max(x)组内极值最低价、最高分
n_distinct(x)组内去重计数活跃用户数、独立门店数
first(x) / last(x)组内首个/末个首单时间、最后状态

需要注意两个细节。第一,summarise() 返回结果里,分组变量会自动作为前几列出现,不用手动 select()。第二,summarise() 会丢掉未在聚合表达式中出现的普通列,这是符合预期的行为,因为那些列在组内有多行取值,无法确定用哪一个。

2.2 变换:mutate() 把计算结果温柔地放回原表

跟 summarise() 不同,mutate() 不会减少行数。它会保留原有每一行,然后把计算结果作为新列加回去。当数据处于分组状态时,mutate() 计算的对象就是每个分组内部。

df %>% group_by(department) %>% mutate( 组内排名 = row_number(desc(score)), 相对均差 = score - mean(score) )

这个操作能玩出的花样非常多。比如你想算每个员工在自己部门内的绩效排名,又不想丢失原始明细,就可以用 group_by() + mutate() + row_number()。再比如电商场景里,你想知道每个商品在自己所属类目下的销售额占比,或者想算每个用户在自己注册月份里的消费排行,都是一回事:先按维度分组,再在组内做计算。

我特别推荐把"分组后的 mutate()"理解为逐组执行。每一组都会拿到一个独立的小环境,组与组之间互不干扰。

2.3 筛选:filter() 和 slice() 只留下满足条件的行

分组筛选最常见的需求是"提取每组里最优的那几条"。这里有两种写法。

# 用 filter():条件写在组内 df %>% group_by(department) %>% filter(score == max(score)) # 用 slice_max():直接按数值取前 n 名 df %>% group_by(department) %>% slice_max(score, n = 1)

两段代码的结果一样:每个部门返回最高分员工的那一行。但 slice_max() 更省心,它内部处理了并列情形的逻辑,而 filter() 里如果恰好有两个人同分,max(score) 会同时返回两行。

类似的还有 slice_min() 取最低,slice_head() 取每组前几行,slice_sample() 每组随机抽几行。这些函数在排行榜、Top N 分析、抽样场景里非常实用。

说句题外话,我最常用的组合其实是 group_by() + summarise() 算出排名指标,再用 filter() 保留行数较少的关键结果,比如"每个门店销售额超过平均水平的品类"。这种筛法对运营处理非常有效。

2.4 排序:别指望分组帮你自动做组内排序

这里有个高频误区,我必须单独拿出来强调。有人以为 group_by() 之后再用 arrange() 就会自动得到"组内排序",于是写下:

# 你以为会得到每个部门内部按分数排好序的结果 df %>% group_by(department) %>% arrange(desc(score))

这个预期的方向是对的,但 arrange() 并不是专门为分组设计的排序工具。它默认按照你传入的排序列做全局排序,是否附带分组效果依赖版本和上下文,非常容易踩坑。更好的做法有两条:一条是把分组变量显式写进排序列,例如arrange(department, desc(score));另一条是先分组,再用 mutate() 生成组内排名,例如:

df %>% group_by(department) %>% mutate(组内排名 = row_number(desc(score))) %>% arrange(department, 组内排名)

说真的,如果你只想"看"数据,那就直接用 arrange() 把分组列和排序列都写清楚;如果你想"用"组内排名参与后续计算,那就老老实实走 mutate() + row_number() 的路线。依赖隐式行为,迟早要吃亏。

3. 多变量分组与分组残留:细节决定成败

3.1 多变量分组:group_by(store, category)

实际数据里,按一个变量分组往往不够。你要按门店看业绩,又想知道门店里哪些品类贡献最大,这就得用两个变量来分组。

sales %>% group_by(store, category) %>% summarise(销售额 = sum(revenue))

group_by() 里传入多个变量时,它会把它们组合成笛卡尔式交叉分组。举个例子,如果有 3 个门店和 4 个品类,那就是 3 × 4 = 12 个组。summarise() 输出里,store 和 category 分别作为前两列,每个组合占一行。

多变量分组在业务上非常常见,"区域 × 品类"、"日期 × 渠道"、"用户 × 月份"都是典型组合。你唯一要注意的是,分组变量太多时,组数会指数级膨胀,遇到数据量又大又稀疏的情况,性能会明显下降。

3.2 .drop 参数:因子里的空组要不要保留

当分组变量是因子(factor)时,group_by() 默认会丢弃那些"在数据里没有出现的水平"。但有些业务场景恰恰需要保留这些空组,比如做完整的月度报表,某个月某条产品线完全没销量,你仍然希望那一行出现在结果里,数值为 0。

df <- tibble( month = factor(c(1, 1, 2), levels = 1:3), value = c(10, 20, 30) ) # 默认情况下,月份 3 会被丢弃 df %>% group_by(month) %>% summarise(total = sum(value)) # 保留空组 df %>% group_by(month, .drop = FALSE) %>% summarise(total = sum(value))

加上.drop = FALSE之后,月份 3 也会作为一组出现在结果里,total 为 0。这个参数在补齐时间序列、周度月度对齐、分类别汇总时非常实用,很多人到很晚才会发现它。

3.3 查看分组状态的几个实用函数

清楚了分组标签之后,有时候你想知道当前数据被分成了几组、每组行数多少、分组键分别是什么。dplyr 提供了几个专门的小函数。

group_vars(sales) # 返回分组变量名 n_groups(sales) # 返回组数 group_keys(sales) # 返回分组键的唯一组合 group_size(sales) # 返回每组的行数

这些函数最大的价值在于调试。当你在管道里写了很长的链式操作,不确定分组状态是什么样的时候,随手敲一下 group_vars(),就能立刻知道当前对象是"按什么分组"的。我写复杂管道时,几乎每写几步就会用它们排查一次。

3.4 最容易被忽略的"分组残留"问题

summarise() 有一个默认行为,它在多变量分组之后只移除部分分组信息,而不是全部移除。具体来说,如果原始分组是 store + category,然后你执行 summarise(),默认情况下结果会继续保留 category 这个分组。

result <- sales %>% group_by(store, category) %>% summarise(销售额 = sum(revenue))

此时 result 仍是按 category 分组的 grouped tibble。如果你没有意识到这一点,紧接着在 result 上做 mutate() 或者 filter(),计算会自动按 category 逐组进行,而不是按你直觉里的"整个 result 表"进行。这一条就是无数人踩过的分组残留坑。

解决办法很简单:在 summarise() 里显式指定.groups参数,或者干脆在管道末尾追加 ungroup()。.groups有四个取值,我列在下面。

取值行为
"drop_last"默认值,移除最后一个分组变量,上面的例子会保留 category
"drop"完全移除分组信息,恢复普通 tibble
"keep"保留全部分组信息
"rowwise"转成逐行模式,每行一组

我的习惯是:只要 summarise() 后面还要做全局计算,就立刻写.groups = "drop",省得后面被分组残留坑到。如果你喜欢更直白的风格,summarise() 之后紧跟 ungroup() 效果一样。

4. 实战:一份门店销售数据,从分组到结论的全流程

理论讲再多,不如跑一遍真实案例。下面我构造一份模拟的门店销售明细数据,把 group_by() 的各种玩法串起来走一遍。

4.1 造一份能动手的模拟数据

set.seed(2024) sales <- tibble( store = rep(c("门店A", "门店B", "门店C"), each = 24), region = rep(c("北区", "东区", "南区"), each = 24), category = rep(rep(c("数码", "家电", "服饰", "食品"), each = 6), times = 3), month = rep(1:6, times = 12), revenue = round(runif(72, 50, 200), 1), cost = round(runif(72, 30, 120), 1) )

这份数据一共 72 行:3 个门店,每个门店 4 个品类,每个品类 6 个月。字段有门店、所属区域、品类、月份、销售额、成本。结构足够简单,但已经能覆盖绝大多数分组需求。

4.2 任务一:按门店汇总业绩,计算成本率

第一个需求,看每个门店整体的销售额、成本和成本率。

store_summary <- sales %>% group_by(store) %>% summarise( 总销售额 = sum(revenue), 总成本 = sum(cost), 订单数 = n(), .groups = "drop" ) %>% mutate(成本率 = 总成本 / 总销售额) store_summary

这里我的习惯是一口气在 summarise() 里完成所有聚合,然后用.groups = "drop"把分组清掉,再接 mutate() 计算成本率。如果不加.groups = "drop",结果仍然带着门店分组,后面的 mutate() 也会安然执行,看起来没区别,但一旦后续逻辑复杂起来,残留分组就会是隐患。

4.3 任务二:门店内各品类的销售额占比

第二个需求,看每个门店内部,哪个品类贡献更高。最容易犯的错误就是忽略 summarise() 的分组残留默认值。

# 错误示范:依赖默认分组残留 bad_result <- sales %>% group_by(store, category) %>% summarise(销售额 = sum(revenue)) %>% mutate(占比 = 销售额 / sum(销售额))

这段代码里,summarise() 之后结果默认还按照 category 分组,所以 mutate() 里的sum(销售额)是"按品类跨门店合计",而不是"该门店的合计"。最后算出来的占比完全不是你想表达的意思。

正确做法是先把分组清掉,再按 store 分组一次,或者直接用 ungroup():

sales %>% group_by(store, category) %>% summarise(销售额 = sum(revenue), .groups = "drop") %>% group_by(store) %>% mutate(占比 = 销售额 / sum(销售额)) %>% arrange(store, desc(占比))

这才是"门店内部结构占比"的准确解法。

4.4 任务三:提取每个门店销售额最高的两个品类

这个需求用 slice_max() 非常顺手。

sales %>% group_by(store, category) %>% summarise(销售额 = sum(revenue), .groups = "drop") %>% group_by(store) %>% slice_max(销售额, n = 2)

先按 store + category 汇总,得到每个门店每个品类的小计,然后再按 store 分组,用 slice_max() 取每组前两名。整个过程读起来跟念一句话一样,这就是 dplyr 管道表达力强的地方。

4.5 任务四:按月看每个品类的环比增速

最后一个需求,想知道每一个品类每月的销售额环比增长情况。这里需要先把数据折叠到"品类 × 月份"的粒度,再按品类分组,用 lag() 做环比。

sales %>% group_by(category, month) %>% summarise(销售额 = sum(revenue), .groups = "drop") %>% group_by(category) %>% arrange(category, month) %>% mutate(环比增速 = 销售额 / lag(销售额) - 1)

这里的核心逻辑是:按 category 分组之后,在组内用 arrange() 把月份排好,lag() 取上个月的销售额,从而算出环比。如果漏掉分组,lag() 会跨品类取上一行,结果整个就乱了。

4.6 这套流程背后的一点思考

把四个任务串起来看,你会发现所有需求都逃不开同一个模式:先想清楚"以什么为组",再用 summarise() 聚合、用 mutate() 在组内计算、用 slice_max() 提取关键行。这里面的关键不是代码多花哨,而是你心里对这个"组"的边界足够清晰。我在实际做项目时,凡是需要多层嵌套的分组逻辑,都会先在草稿纸上写下"第一层按什么分组、第二层按什么分组、哪一步清空分组",能省下大量调试时间。

分组残留问题在这套流程里尤其突出。因为环节一多,你很容易忘记当前对象到底带着哪一层分组,于是后面的 mutate() 或 filter() 就悄悄改变了语义。调试半天才反应过来,其实查一下 group_vars() 就能立刻定位。

5. 踩坑合集与性能思考:真实数据里活下来的经验

5.1 坑一:summarise 后的分组残留

这个坑我在前面已经反复提到,因为它真的太普遍了。尤其是多个分组变量时,summarise() 默认只移除最后一个分组变量以外的分组,剩下的分组会悄悄影响后续操作。我的建议是:要么在 summarise() 里写死.groups = "drop",要么管道末尾加 ungroup(),不要心存侥幸,觉得"这次不会出事"。

5.2 坑二:忘记 ungroup() 导致下游工具行为异常

分组残留的影响不止于 dplyr 内部。把做过 group_by() 的 tibble 直接交给 ggplot2 画图、交给 purrr 做 map 遍历、或者转成 data.frame 后再做其他处理时,有些操作会"感知"到分组信息,逐组执行,有些则不会。这种不确定性会让你在排查问题时非常痛苦。

一个典型的场景是连环画折线图,你想画多条门店的月度趋势线,结果却只画出了一条线或者线的连接关系莫名其妙。这时候大概率是数据还带着分组属性。解决办法就是在传给绘图函数之前,明确调用 ungroup(),把它变成普通 tibble。

5.3 坑三:sort 依赖隐式行为

前面反复强调,组内排序不要依赖 group_by() 对 arrange() 的隐式影响。你想要的组内排名,用 mutate() + row_number() 去生成排名列;你想要的展示顺序,用 arrange() 显式写出所有排序键。两步分开,逻辑清清楚楚。

5.4 坑四:把 n() 当成普通函数

n() 是专门用于分组上下文的计数函数,它只能出现在 summarise()、mutate()、filter() 这类支持分组表达式的调用里,不接收任何参数。如果你尝试在全局环境里单独调用 n(),会直接报错。很多人刚开始不习惯这种"只能在特定上下文中使用"的函数,但这正是理解 dplyr 分组逻辑的突破口。

5.5 性能思考:数据量上去了怎么办

分组操作本身的开销不大,真正影响性能的是组数和聚合函数的复杂度。比如几百万行的数据,分成几十组做 sum() 和 mean(),dplyr 完全扛得住;如果分成几十万个组,每个组还要做复杂的计算,那速度就会明显下滑。

遇到这种情况,我一般先做三件事:

第一,检查是不是做了不必要的过度分组。业务上很多分组维度是可以裁剪的,比如"每个用户每一秒"这种粒度往往没必要。

第二,考虑用 data.table 的by语法替代。同样的聚合逻辑,DT[, .(total = sum(x)), by = group]在超大表上的速度通常比 dplyr 快,而且内存控制更好。我并不会因为自己是 dplyr 用户就排斥 data.table,不同场景该换就换。

第三,如果数据本身在数据库里,直接用 dbplyr 把 group_by() 和 summarise() 下推到 SQL 执行,避免把几千万行全部加载进内存再分组。让数据库引擎去承担计算压力,往往是最稳妥的方案。

5.6 顺手到值得养成的几个小技巧

最后分享几个我长期使用的小习惯,单个看起来不起眼,组合起来能省不少事。

第一个是 group_by() 配合 across() 批量聚合。你想同时求多个数值列的均值,可以这样写:

sales %>% group_by(store) %>% summarise( across(c(revenue, cost), mean, .names = "平均{.col}") )

第二个是用 cur_group_id() 给每组打一个数字编号。当你需要把分组结果传给自定义函数时,这个编号可以作为组标识:

sales %>% group_by(store, category) %>% mutate(group_id = cur_group_id())

第三个是我个人的强制习惯:任何管道里只要出现过 group_by(),我在写下一个"需要全局计算"的步骤之前,一定会先确认当前对象的分组状态。最省心的做法就是该分组时分组,算完之后立刻 ungroup() 恢复普通表,绝不让分组残留拖到下一个环节。

group_by() 从来不是个复杂的函数,它做的只是给数据贴标签,但它带来的思考方式转变却非常关键:数据不再是一整块,而是由一组一组的小块构成,所有计算都以小组为单位展开。把这个心智模型建立起来,dplyr 这台机器才算真正开顺手了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询