Shixiang Wang

>上士闻道
勤而行之

dplyr 列式计算

王诗翔 · 2021-01-22

分类: r  
标签: dplyr   colwise  

在近期使用 dplyr 进行多列选择性操作,如 mutate_at() 时,发现文档提示一系列的 dplyr 函数变体已经过期,看来后续要退休了,使用 across() 是它们的统一替代品,所以最近抽时间针对性的学习和翻译下,希望给大家带来一些帮助。

本文是第一篇,介绍的是列式计算,后续还会有一篇介绍按行处理数据。

原文来自 [dplyr 文档](Column-wise operations • dplyr (tidyverse.org)) - 2021-01

同时对数据框的多列执行相同的函数操作经常有用,但是通过拷贝和粘贴的方式进行的话既枯燥就容易产生错误。

例如:

df %>% 
  group_by(g1, g2) %>% 
  summarise(a = mean(a), b = mean(b), c = mean(c), d = mean(d))

(如果你想要计算每一行 a, b, c, d 的均值,请看行式计算一文)

本文将向你介绍 across() 函数,它可以帮助你以更加简洁的方式重写上述代码:

df %>% 
  group_by(g1, g2) %>% 
  summarise(across(a:d, mean))

我们将从讨论 across() 的基本用法开始,特别是将其应用于 summarise() 中和展示如何联合多个函数使用它。然后我们将展示一些其他动词的使用。最后我们将简要介绍一下历史,说明为什么我们更喜欢 across() 而不是后一种方法(即 _if(), _at(), _all() 变体函数)以及如何将你的旧代码转换为新的语法实现。

载入包:

library(dplyr, warn.conflicts = FALSE)

基本用法

across() 有两个主要的参数:

下面是联合 across() 和它最喜欢的动词函数 summarise()的一些例子。但你也可以联合 across() 和任意其他的 dplyr 动词函数,我们后面会提及。

starwars %>% 
  summarise(across(where(is.character), ~ length(unique(.x))))
#> # A tibble: 1 x 8
#>    name hair_color skin_color eye_color   sex gender homeworld species
#>   <int>      <int>      <int>     <int> <int>  <int>     <int>   <int>
#> 1    87         13         31        15     5      3        49      38

starwars %>% 
  group_by(species) %>% 
  filter(n() > 1) %>% 
  summarise(across(c(sex, gender, homeworld), ~ length(unique(.x))))
#> `summarise()` ungrouping output (override with `.groups` argument)
#> # A tibble: 9 x 4
#>   species    sex gender homeworld
#>   <chr>    <int>  <int>     <int>
#> 1 Droid        1      2         3
#> 2 Gungan       1      1         1
#> 3 Human        2      2        16
#> 4 Kaminoan     2      2         1
#> # … with 5 more rows

starwars %>% 
  group_by(homeworld) %>% 
  filter(n() > 1) %>% 
  summarise(across(where(is.numeric), ~ mean(.x, na.rm = TRUE)))
#> `summarise()` ungrouping output (override with `.groups` argument)
#> # A tibble: 10 x 4
#>   homeworld height  mass birth_year
#>   <chr>      <dbl> <dbl>      <dbl>
#> 1 Alderaan    176.  64         43  
#> 2 Corellia    175   78.5       25  
#> 3 Coruscant   174.  50         91  
#> 4 Kamino      208.  83.1       31.5
#> # … with 6 more rows

因为 across() 通过和 summarise() 以及 mutate() 结合使用,所以它不会选择分组变量以避免意外地修改它们。

df <- data.frame(g = c(1, 1, 2), x = c(-1, 1, 3), y = c(-1, -4, -9))
df %>% 
  group_by(g) %>% 
  summarise(across(where(is.numeric), sum))
#> `summarise()` ungrouping output (override with `.groups` argument)
#> # A tibble: 2 x 3
#>       g     x     y
#>   <dbl> <dbl> <dbl>
#> 1     1     0    -5
#> 2     2     3    -9

多个函数

你可以通过对第二个参数传入一个函数(包括 lambda 函数)的命名列表来对每个变量同时执行多个函数操作。

min_max <- list(
  min = ~min(.x, na.rm = TRUE), 
  max = ~max(.x, na.rm = TRUE)
)
starwars %>% summarise(across(where(is.numeric), min_max))
#> # A tibble: 1 x 6
#>   height_min height_max mass_min mass_max birth_year_min birth_year_max
#>        <int>      <int>    <dbl>    <dbl>          <dbl>          <dbl>
#> 1         66       264       15     1358              8            896

你可以通过 .names 参数使用 glue 规范来控制新建列名的名字:

starwars %>% summarise(across(where(is.numeric), min_max, .names = "{.fn}.{.col}"))
#> # A tibble: 1 x 6
#>   min.height max.height min.mass max.mass min.birth_year max.birth_year
#>        <int>      <int>    <dbl>    <dbl>          <dbl>          <dbl>
#> 1         66        264       15     1358              8            896

如果你更喜欢将所有具有相同函数的摘要放到在一起(就是下面的 min 结果都在左侧,而 max 都在右侧),你必须自己进行扩展调用:

starwars %>% summarise(
  across(where(is.numeric), ~min(.x, na.rm = TRUE), .names = "min_{.col}"),
  across(where(is.numeric), ~max(.x, na.rm = TRUE), .names = "max_{.col}")
)
#> # A tibble: 1 x 9
#>   min_height min_mass min_birth_year max_height max_mass max_birth_year
#>        <int>    <dbl>          <dbl>      <int>    <dbl>          <dbl>
#> 1         66       15              8        264     1358            896
#> # … with 3 more variables: max_min_height <int>, max_min_mass <dbl>,
#> #   max_min_birth_year <dbl>

(可能有一天这种操作会通过 across() 的一个参数进行支持,但目前我们还没找到解决方案)

当前列

如果需要,你可以通过调用 cur_column() 来获取当前列的名字。如果你想执行一些语境依赖的相关转换,这可能会很有用:

df <- tibble(x = 1:3, y = 3:5, z = 5:7)
mult <- list(x = 1, y = 10, z = 100)

# df 每列乘以 mult 对应列的值
df %>% mutate(across(all_of(names(mult)), ~ .x * mult[[cur_column()]]))
#> # A tibble: 3 x 3
#>       x     y     z
#>   <dbl> <dbl> <dbl>
#> 1     1    30   500
#> 2     2    40   600
#> 3     3    50   700

陷阱

注意组合 is.numeric() 和数值汇总的使用:

df <- data.frame(x = c(1, 2, 3), y = c(1, 4, 9))

df %>% 
  summarise(n = n(), across(where(is.numeric), sd))
#>    n x        y
#> 1 NA 1 4.041452

这里 n 变成 NA 是因为 n 是数值的,所以 across() 会计算它的标准差,3(常量) 的标准差是 NA,你可以最后计算 n() 来解决这个问题:

df %>% 
  summarise(across(where(is.numeric), sd), n = n())
#>   x        y n
#> 1 1 4.041452 3

另外,你可以显式的将 n 排除掉来解决该问题:

df %>% 
  summarise(n = n(), across(where(is.numeric) & !n, sd))
#>   n x        y
#> 1 3 1 4.041452

其他动词

到目前为止,我们聚焦于 across()summarise() 的组合使用,但它也可以和其他 dplyr 动词函数一起工作:

对一些像 group_by()count()distinct() 这样的动词,你可以省略汇总函数:

across() 不能与 select() 或者 rename() 一起工作,因为后面两个函数已经支持 tidy 选择语法。如果你想要通过函数转换列名,可以使用 rename_with()

_if, _at, _all

dplyr 以前的版本允许以不同的方式将函数应用到多个列:使用带有_if_at_all后缀的函数。这些功能解决了迫切的需求而被许多人使用,但现在被取代了。这意味着它们会一直存在,但不会获得任何新功能,只会修复关键的bug。

为什么我们喜欢 across()

为什么我们决定从上面的函数迁移到 across()?理由如下:

  1. across() 使它能够表达以前不可能表达的有用的汇总:

    df %>%
      group_by(g1, g2) %>% 
      summarise(
        across(where(is.numeric), mean), 
        across(where(is.factor), nlevels),
        n = n(), 
      )
    
  2. across() 减少 dplyr 需要提供的函数数量。这使 dplyr 更容易使用(因为需要记住的函数更少),也使我们更容易实现新的动词(因为我们只需要实现一个函数,而不是四个)。

  3. across() 统一了 _if_at 的语义让我们可以随心按照位置、名字和类型选择变量,甚至是随心所欲地组合它们,这在以前是不可能的。例如,你现在可以转换以 x 开头的数值列: across(where(is.numeric) & starts_with("x")).

  4. across() 不需要使用 vars()_at() 函数是 dplyr 中唯一你需要手动引用变量名的地方,这让它们比较奇怪且难以记忆。

为什么过了这么久才发现 across()

令人失望的是,我们没有早点发现 across(),而是经历了几个错误的尝试(首先没有意识到这是一个常见的问题,然后是使用_each()函数,最后是使用_if()/_at()/_all()函数)。但是 across() 的开发工作离不开以下三个最新发现:

你如何转移已经存在的代码?

幸运的是,将已有的代码转换为使用 across() 实现通常是非常直观的:

例如:

df %>% mutate_if(is.numeric, mean, na.rm = TRUE)
# ->
df %>% mutate(across(where(is.numeric), mean, na.rm = TRUE))

df %>% mutate_at(vars(c(x, starts_with("y"))), mean)
# ->
df %>% mutate(across(c(x, starts_with("y")), mean, na.rm = TRUE))

df %>% mutate_all(mean)
# ->
df %>% mutate(across(everything(), mean))

这个规则有些意外情况:

小结

dplyr 的开发者们通过 across() 简化了 dplyr 对于一些数据复杂操作的处理逻辑,提高了整体的学习和使用效率,让我们使用者更关注于逻辑而非实现上。点个赞!