dplyr 分组聚合:group_by、summarise 与 across 实战
论文里的描述统计表大多长这样:三组实验对象,每组一个均值、一个标准差、一个样本量。手算不现实,写 for 循环也难看。dplyr 的做法是把「按什么分组」和「每组算什么」拆开写:group_by() 声明分组变量,summarise() 声明每组的汇总结果。
group_by + summarise 的基本形态
Section titled “group_by + summarise 的基本形态”ToothGrowth 是 R 内置的牙齿生长数据,60 只豚鼠分成两组补给方式(supp:橙汁 OJ / 维生素 C VC),每组又注射三种剂量(dose:0.5、1、2 毫克),每组 10 只,记录牙齿长度 len:
library(dplyr)
ToothGrowth |> group_by(supp, dose) |> summarise( n = n(), mean_len = mean(len), .groups = "drop" )# A tibble: 6 × 4 supp dose n mean_len <chr> <dbl> <int> <dbl>1 OJ 0.5 10 13.22 OJ 1 10 22.73 OJ 2 10 26.14 VC 0.5 10 7.985 VC 1 10 16.86 VC 2 10 26.1读这段代码的关键在于:summarise() 里的 mean(len) 不是对 60 行算均值,而是对当前组的行算。group_by() 之后的每一个动词都换了一套「按组执行」的语义,n() 返回当前组的行数——这是 dplyr 特有的计数函数,只能用在分组数据或 summarise() / mutate() 里,不能单独运行。
从结果能读出实验结论:剂量从 0.5 升到 2,两条曲线都涨;低剂量下橙汁组的牙齿更长(13.2 对 7.98),高剂量下两组几乎持平(26.1 对 26.1)。这属于典型的交互效应,值得做双因素方差分析,见 /r/modeling/anova。
多列分组与 .groups 参数
Section titled “多列分组与 .groups 参数”分组变量多于一个、又不写 .groups 时,R 会额外打印一条提示。把第一个例子里的 .groups = "drop" 删掉再跑一次:
ToothGrowth |> group_by(supp, dose) |> summarise(n = n(), mean_len = mean(len))`summarise()` has grouped output by 'supp'. You can override using the`.groups` argument.# A tibble: 6 × 4# Groups: supp [2] supp dose n mean_len <chr> <dbl> <int> <dbl>1 OJ 0.5 10 13.22 OJ 1 10 22.73 OJ 2 10 26.14 VC 0.5 10 7.985 VC 1 10 16.86 VC 2 10 26.1结果本身没错,但注意输出头部的 # Groups: supp [2]——它还是分组数据框,只不过分组变量从 supp + dose 降成了 supp。summarise() 每次都「剥掉」最内层的分组变量,外层分组继续保留(这叫 peel-off 规则)。这和第一个例子的区别值得记住:那里显式写了 .groups = "drop",所以结果是干净的普通 tibble;这里没写,结果带着分组状态往下走,后面再写 mutate() 或 filter() 时计算基准就变成了同一 supp 下的 30 只豚鼠,而不是当前那一组 10 只。
只有一个分组变量时,提示会换成另一句话:summarise() ungrouping output (override with .groups argument)——因为已经没有外层分组可以保留,结果直接退化为普通 tibble。
.groups 有三个取值:
"drop_last"(默认):去掉最内层分组变量,保留其余;"drop":去掉全部分组,返回普通 tibble;"keep":保留和数据同样的分组结构。
建议在 summarise() 里显式写上 .groups = "drop"。理由很实际:默认行为取决于你有几个分组变量,代码读起来要在脑子里跑一遍 peel-off 规则,不如直接写死。
n() 与 n_distinct()
Section titled “n() 与 n_distinct()”n() 数行数,n_distinct() 数去重后的个数,两个函数经常一起用来看数据的覆盖情况:
mtcars |> group_by(cyl) |> summarise( cars = n(), carb_types = n_distinct(carb), .groups = "drop" )# A tibble: 3 × 3 cyl cars carb_types <dbl> <int> <int>1 4 11 22 6 7 33 8 14 4n_distinct() 默认把 NA 也算作一个不同的值,想排除要写 n_distinct(carb, na.rm = TRUE)。这两个函数都不接受参数用来「作用于某一列」——n() 永远返回当前组行数。想数某个条件成立的行数,用 sum(条件):summarise(n_high = sum(mpg > 25))。
across():一次聚合多列
Section titled “across():一次聚合多列”要同时算多列均值,把 summarise() 写成三行重复代码很啰嗦。across() 接受「选哪些列」和「怎么算」两个参数,一次搞定:
mtcars |> group_by(cyl) |> summarise(across(c(mpg, hp, wt), mean), .groups = "drop")# A tibble: 3 × 4 cyl mpg hp wt <dbl> <dbl> <dbl> <dbl>1 4 26.7 82.6 2.292 6 19.7 122. 3.123 8 15.1 209. 4.00第一列的选择部分支持 select() 的全部辅助函数,所以 across(where(is.numeric), mean) 就是「所有数值列求均值」,across(starts_with("q"), mean) 是「所有以 q 开头的列求均值」。第二部分的函数也可以写成多个,同时输出多列:
ToothGrowth |> group_by(dose) |> summarise(across(len, list(mean = mean, sd = sd)), .groups = "drop")这时输出列名是 len_mean、len_sd。注意 hp、vs 这类列在 mtcars 里是数值但语义上是分类变量,across(where(is.numeric)) 会把它们一起平均——数值编码的顺序变量求均值没有意义,选列时还是明确写出列名更稳妥。
count():计数别写三行
Section titled “count():计数别写三行”只想知道每个组有多少行,group_by() 加 summarise(n = n()) 是杀鸡用牛刀。count() 一步到位:
count(mtcars, cyl)# A tibble: 3 × 2 cyl n <dbl> <int>1 4 112 6 73 8 14count(x, a, b) 等价于 x |> group_by(a, b) |> summarise(n = n(), .groups = "drop"),而且返回的是未分组的 tibble,不会有忘记 ungroup() 的隐患。加上 sort = TRUE 会按计数降序排列,做频数表很顺手。
分组会改变后续动词的含义
Section titled “分组会改变后续动词的含义”group_by() 的效力会一直延续到 ungroup() 为止,中间夹的 mutate()、filter()、slice_*() 全都按组执行——这既是最有用的特性,也是事故高发区。
library(tibble)
cars <- as_tibble(mtcars, rownames = "model")
cars |> select(model, cyl, mpg) |> group_by(cyl) |> slice_max(mpg, n = 1)# A tibble: 3 × 3# Groups: cyl [3] model cyl mpg <chr> <dbl> <dbl>1 Toyota Corolla 4 33.92 Hornet 4 Drive 6 21.43 Pontiac Firebird 8 19.2每个缸数取油耗最高的一辆。如果漏掉 group_by(),slice_max() 只会返回全表最省油的那一行(Toyota Corolla),而不是每组一行。注意结果头部多了 # Groups: cyl [3],提示这个 tibble 还是分组状态。
filter() 在分组下的行为同样值得注意:filter(n() >= 10) 保留样本量不少于 10 的组——这是「按组规模筛选行」的标准写法,等价于先 add_count() 再筛选。反过来,忘记 ungroup() 直接写 filter(mpg > mean(mpg)),比较基准就变成了组内均值而不是全样本均值,结果看起来也合理,只是和你想要的东西不一样。
dplyr 1.1 之后新增了 .by 参数,可以让单次操作临时分组,不产生分组残留:
cars |> summarise(mean_mpg = mean(mpg), .by = cyl)summarise()、mutate()、filter()、slice_*() 都支持 .by。只有一步汇总时用它比 group_by() 更干净,也就没有 .groups 提示信息的困扰。要记住它只作用于当前这一步:.by 的分组不会传给下一个动词,连着几步都要按同一变量分组,还是得回到 group_by()。
分组结果怎么用
Section titled “分组结果怎么用”分组汇总的结果通常直接进下一环:写成论文表格(/r/reporting/tables),或者画成带误差棒的柱状图——绘图前记得 ungroup(),否则有些几何对象会按组重复绘制。
如果你也用 Python,pandas 里对应的是 df.groupby(...).agg(...),可以对照 /python/pandas/groupby-agg 看同一种操作的另一种表达。分组的思想是通用的,差别主要在分组状态的传递方式:pandas 的 groupby 返回一个中间对象,不写 .agg() 就不会落成数据框;dplyr 则是把分组状态挂在数据框上。