dplyr 1.0.0を使ってみる: across(), rename_with()

across() の紹介はググればすでに記事がいくつもあるので私が書くことはあまりないんですが、知られてなさそうな点をかいつまんで紹介します。

group_by() にも使える

あまり気付かれてませんが、 group_by() は select のセマンティクスではなく mutate のセマンティクスです。「mutate のセマンティクスとは...?」という人は、ちょっとコードが古いですがこのスライドを読んでください。 要は dplyr にはざっくり select() に列を指定するような記法と mutate() に列を指定するような記法の 2 パターンがあって、 group_by() は後者ですよ、ということです。

たとえば、select() は列を文字列で指定できるので、列名を変数に入れておいてそれを使って列を絞り込むことができます。

library(dplyr, warn.conflicts = FALSE)

set.seed(10)
d <- tibble(
  a = sample(letters[1:3], 10, replace = TRUE),
  b = sample(LETTERS[1:3], 10, replace = TRUE)
)

cols <- c("a", "b")

d %>% 
  select(!!cols)
#> # A tibble: 10 x 2
#>    a     b    
#>    <chr> <chr>
#>  1 c     B    
#>  2 a     C    
#>  3 b     B    
#>  4 c     B    
#>  5 b     A    
#>  6 c     C    
#>  7 c     B    
#>  8 c     C    
#>  9 c     B    
#> 10 c     C

一方、 group_by() ではできません。

d %>% 
  group_by(!!cols)
#> Error: Problem with `mutate()` input `..1`.
#> x Input `..1` can't be recycled to size 10.
#> ℹ Input `..1` is `<chr>`.
#> ℹ Input `..1` must be size 10 or 1, not 2.

こういうことをするものとしてこれまで用意されていたのが group_by_at() という関数でした。dplyr 1.0.0 からは across() を使うことができます。 across()mutate()summarise() で使うもの、と覚えていると見落としがちですが、このように単に列を選択するためにも使うことができます。

(警告っぽいのが出てますがここではとりあえず無視します。存在しない列名があったときにエラーにしたいなら all_of() 、エラーにしたくなければ any_of() を使うという感じです。)

d %>% 
  group_by(across(cols))
#> Note: Using an external vector in selections is ambiguous.
#> ℹ Use `all_of(cols)` instead of `cols` to silence this message.
#> ℹ See <https://tidyselect.r-lib.org/reference/faq-external-vector.html>.
#> This message is displayed once per session.
#> # A tibble: 10 x 2
#> # Groups:   a, b [5]
#>    a     b    
#>    <chr> <chr>
#>  1 c     B    
#>  2 a     C    
#>  3 b     B    
#>  4 c     B    
#>  5 b     A    
#>  6 c     C    
#>  7 c     B    
#>  8 c     C    
#>  9 c     B    
#> 10 c     C

ちなみに、 mutate のセマンティクスなので、その場で変数を加工することもできます。 これもマイナーな機能ですが、 group_by() には、グループ化変数が factor のとき、 .drop = FALSE を指定すると直積を取ってくれるという便利機能があります。 いちいち factor に変換するのめんどくさかったんですが、 across() があれば第二引数に as.factor() を指定するだけです。

d %>% 
  group_by(
    across(cols, as.factor),
    .drop = FALSE
  ) %>% 
  count()
#> # A tibble: 9 x 3
#> # Groups:   a, b [9]
#>   a     b         n
#>   <fct> <fct> <int>
#> 1 a     A         0
#> 2 a     B         0
#> 3 a     C         1
#> 4 b     A         1
#> 5 b     B         1
#> 6 b     C         0
#> 7 c     A         0
#> 8 c     B         4
#> 9 c     C         3

ちなみにこれもあまり知られていない気がしますが、 ungroup() にはグループ化を解除する変数を指定することができます。 これは、 group_by() とは違い select のセマンティクスなので(分かりづらい...)、文字列をそのまま指定できます。

d %>% 
  group_by(across(cols)) %>% 
  ungroup(!!cols[1])
#> # A tibble: 10 x 2
#> # Groups:   b [3]
#>    a     b    
#>    <chr> <chr>
#>  1 c     B    
#>  2 a     C    
#>  3 b     B    
#>  4 c     B    
#>  5 b     A    
#>  6 c     C    
#>  7 c     B    
#>  8 c     C    
#>  9 c     B    
#> 10 c     C

rename_with()

across().names 引数で操作後の列名を指定できますが、細かなコントロールはできません。たとえば、

みたいなときはどうすればいいのでしょう? rename_with() はそういうときのための関数です。 列名をリネームする関数を指定すると、すべての列名を一気に変えてくれます。

set.seed(10)
d <- tibble(
  a_id    = 1:3,
  a_value = runif(3),
  a_group = sample(LETTERS, 3, replace = TRUE)
)

d %>% 
  rename_with(~ stringr::str_remove(., "^a_"))
#> # A tibble: 3 x 3
#>      id value group
#>   <int> <dbl> <chr>
#> 1     1 0.507 P    
#> 2     2 0.307 L    
#> 3     3 0.427 W

一部だけリネームしたい場合は絞り込むことも可能です。たとえば a_id 以外の列をリネームしたいときは以下のように指定します(! は、tidyselect 1.0.0 から - の代わりに使えるようになった記法です。参考:Changelog • tidyselect)。

d %>% 
  rename_with(~ stringr::str_remove(., "^a_"), !a_id)
#> # A tibble: 3 x 3
#>    a_id value group
#>   <int> <dbl> <chr>
#> 1     1 0.507 P    
#> 2     2 0.307 L    
#> 3     3 0.427 W

across() はデータフレームを返す関数

公式 vignette には filter()across() を使う場合の例が載っていますが、ちょっとトリッキーなので解説します。

たとえば、こういうデータがあって、v1 列〜 v3 列のいずれかの値が 0.8 以上の行に絞り込みたい、という場合を考えてみます。

set.seed(100)
df <- tibble(
  id = as.character(1:10),
  v1 = runif(10),
  v2 = runif(10),
  v3 = runif(10)
)

df
#> # A tibble: 10 x 4
#>    id        v1    v2    v3
#>    <chr>  <dbl> <dbl> <dbl>
#>  1 1     0.308  0.625 0.536
#>  2 2     0.258  0.882 0.711
#>  3 3     0.552  0.280 0.538
#>  4 4     0.0564 0.398 0.749
#>  5 5     0.469  0.763 0.420
#>  6 6     0.484  0.669 0.171
#>  7 7     0.812  0.205 0.770
#>  8 8     0.370  0.358 0.882
#>  9 9     0.547  0.359 0.549
#> 10 10    0.170  0.690 0.278

across() を使わずに列をひとつひとつ指定するとこうですね。

df %>%
  filter(
    v1 > 0.8 | v2 > 0.8 | v3 > 0.8
  )
#> # A tibble: 3 x 4
#>   id       v1    v2    v3
#>   <chr> <dbl> <dbl> <dbl>
#> 1 2     0.258 0.882 0.711
#> 2 7     0.812 0.205 0.770
#> 3 8     0.370 0.358 0.882

で、 across() を使うとどうなるのでしょう。結論から言うと、こうなります。

df %>%
  filter(
    rowSums(across(where(is.numeric), ~ .x > 0.8)) > 0
  )
#> # A tibble: 3 x 4
#>   id       v1    v2    v3
#>   <chr> <dbl> <dbl> <dbl>
#> 1 2     0.258 0.882 0.711
#> 2 7     0.812 0.205 0.770
#> 3 8     0.370 0.358 0.882

rowSums()...??

何が起こっているか分かるでしょうか? これを理解するために、 rowSums() に何が渡されているのかを見てみましょう。

rowSums() の代わりに、 渡されたデータを print() してエラーで終わる関数を定義して使ってみます。すると...

f <- function(x) {
  print(x)
  rlang::abort("ストップ")
}

df %>% 
  filter(
    f(across(where(is.numeric), ~ .x > 0.8))
  )
#> # A tibble: 10 x 3
#>    v1    v2    v3   
#>    <lgl> <lgl> <lgl>
#>  1 FALSE FALSE FALSE
#>  2 FALSE TRUE  FALSE
#>  3 FALSE FALSE FALSE
#>  4 FALSE FALSE FALSE
#>  5 FALSE FALSE FALSE
#>  6 FALSE FALSE FALSE
#>  7 TRUE  FALSE FALSE
#>  8 FALSE FALSE TRUE 
#>  9 FALSE FALSE FALSE
#> 10 FALSE FALSE FALSE
#> Error: ストップ

f() には is.numeric() を満たす各列に対して ~ .x > 0.8 という関数を適用した結果のデータフレーム(tibble)が引き渡されています。 つまり、 across() とはデータフレームを返す関数なのです。

データフレームなので、データフレームの各行の合計を返す関数 rowSums() が使えて、 TRUE1FALSE0 という暗黙の型変換が行われるので、1 つでも TRUE がある行は

    rowSums(...) > 0

を満たす、というわけです。

summarise() にデータフレームを渡せるようになった、という話は前々回の記事で書きましたが、 filter()mutate() などもデータフレームを渡せば勝手に展開してくれるという挙動になっているからこそ across() を使えているんですね。 (そういえば dplyr の issue でそんな議論してたなあ、とふと思い出しました)

その他

dplyr 1.0.0 についてはいくつか記事を書いたので興味あればこっちも読んでみてください。