跳到正文

dplyr 分组聚合:group_by、summarise 与 across 实战

论文里的描述统计表大多长这样:三组实验对象,每组一个均值、一个标准差、一个样本量。手算不现实,写 for 循环也难看。dplyr 的做法是把「按什么分组」和「每组算什么」拆开写: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.2
2 OJ 1 10 22.7
3 OJ 2 10 26.1
4 VC 0.5 10 7.98
5 VC 1 10 16.8
6 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 时,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.2
2 OJ 1 10 22.7
3 OJ 2 10 26.1
4 VC 0.5 10 7.98
5 VC 1 10 16.8
6 VC 2 10 26.1

结果本身没错,但注意输出头部的 # Groups: supp [2]——它还是分组数据框,只不过分组变量从 supp + dose 降成了 suppsummarise() 每次都「剥掉」最内层的分组变量,外层分组继续保留(这叫 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() 数去重后的个数,两个函数经常一起用来看数据的覆盖情况:

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 2
2 6 7 3
3 8 14 4

n_distinct() 默认把 NA 也算作一个不同的值,想排除要写 n_distinct(carb, na.rm = TRUE)。这两个函数都不接受参数用来「作用于某一列」——n() 永远返回当前组行数。想数某个条件成立的行数,用 sum(条件)summarise(n_high = sum(mpg > 25))

要同时算多列均值,把 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.29
2 6 19.7 122. 3.12
3 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_meanlen_sd。注意 hpvs 这类列在 mtcars 里是数值但语义上是分类变量,across(where(is.numeric)) 会把它们一起平均——数值编码的顺序变量求均值没有意义,选列时还是明确写出列名更稳妥。

只想知道每个组有多少行,group_by()summarise(n = n()) 是杀鸡用牛刀。count() 一步到位:

count(mtcars, cyl)
# A tibble: 3 × 2
cyl n
<dbl> <int>
1 4 11
2 6 7
3 8 14

count(x, a, b) 等价于 x |> group_by(a, b) |> summarise(n = n(), .groups = "drop"),而且返回的是未分组的 tibble,不会有忘记 ungroup() 的隐患。加上 sort = TRUE 会按计数降序排列,做频数表很顺手。

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.9
2 Hornet 4 Drive 6 21.4
3 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()

分组汇总的结果通常直接进下一环:写成论文表格(/r/reporting/tables),或者画成带误差棒的柱状图——绘图前记得 ungroup(),否则有些几何对象会按组重复绘制。

如果你也用 Python,pandas 里对应的是 df.groupby(...).agg(...),可以对照 /python/pandas/groupby-agg 看同一种操作的另一种表达。分组的思想是通用的,差别主要在分组状态的传递方式:pandas 的 groupby 返回一个中间对象,不写 .agg() 就不会落成数据框;dplyr 则是把分组状态挂在数据框上。