dplyr 1.0.0を使ってみる: rowwise()

昔こんな記事を書いたんですが、なんと rowwise() は dplyr 1.0.0 で不死身になって還ってきました(予想が外れてすみません...)。そのあたりを解説します。

例によって英語が苦でない人は公式ブログをどうぞ。

rowwise()が必要になりそうな例

たとえば、ある日を与えると次の金曜日を計算してくれる関数find_next_friday()があるとします。

find_next_friday <- function(x) {
  # 1週間探せばどこかは金曜日なので、とりあえず`x`から7日間の Date を生成
  days <- seq.Date(from = x, by = "day", length.out = 7)
  # wday() は曜日を返す関数、 6 が金曜日
  res <- days[lubridate::wday(days) == 6]
  # ほんとはそのまま返せばいいけど、 purrr と組み合わせたときにうまく動かないので文字列に
  as.character(res)
}

動作確認してみましょう。うまく動いてそうですね。

find_next_friday(as.Date("2020-07-01"))
#> [1] "2020-07-03"

しかしこれを dplyr の中で使おうと思うとエラーになります。

library(dplyr, warn.conflicts = FALSE)

d <- data.frame(day = as.Date(c("2020-07-01", "2020-07-29", "2020-08-01")))

d %>% 
  mutate(next_friday = find_next_friday(day))
#> Error: Problem with `mutate()` input `next_friday`.
#> x 'from' must be of length 1
#> ℹ Input `next_friday` is `find_next_friday(day)`.

なぜでしょう。これは、seq.Date() が長さ1の入力しか受け付けない関数なのに対し、 day列の値3つが一気に渡されているからです。 なので、これを動かすには、値を1つづつ渡す必要があります。

purrrを使う場合

やり方はいくつかあると思いますが、ここではpurrr::map_*()を使ってみましょう。 purrr::map_*() は、第一引数のベクトルやリストの要素を、第二引数の関数に1つづつ渡して、最後に結果を結合してくれる関数です。

d %>% 
  mutate(next_friday = purrr::map_chr(day, find_next_friday))
#>          day next_friday
#> 1 2020-07-01  2020-07-03
#> 2 2020-07-29  2020-07-31
#> 3 2020-08-01  2020-08-07

うまくいきました。

rowwise()

こんな感じで、 1つしか値を受け付けない関数をラップすることで事なきを得る、というのが上のやり方でした。

一方、関数をラップしなくても、 dplyr の挙動を「1行1行処理する」というように変更する、という手があります。 これが rowwise() です。

d %>% 
  rowwise() %>% 
  mutate(next_friday = find_next_friday(day))
#> # A tibble: 3 x 2
#> # Rowwise: 
#>   day        next_friday
#>   <date>     <chr>      
#> 1 2020-07-01 2020-07-03 
#> 2 2020-07-29 2020-07-31 
#> 3 2020-08-01 2020-08-07

これだけだと別にpurrrでよくない?って感じだと思いますが、もう少し使い方を見てみましょう。 その前に少し余談をはさみます。

余談: その rowwise() ほんとうに必要ですか?

勘のいい方はすでにお気付きかと思いますが、これは find_next_friday() をベクトル対応させれば済む話です。 たとえばこう(解説は省略しますが、目標の wday までの差を取ってそれを足す、という感じです)。

find_next_friday2 <- function(x) {
  x + lubridate::days(6 - lubridate::wday(x) %% 7)
}

find_next_friday2(as.Date("2020-07-01"))
#> [1] "2020-07-03"

d %>% 
  mutate(next_friday = find_next_friday2(day))
#>          day next_friday
#> 1 2020-07-01  2020-07-03
#> 2 2020-07-29  2020-07-31
#> 3 2020-08-01  2020-08-07

rowwise()purrr::map_() は便利ですが、そもそもベクトルのまま計算できるような処理はベクトルのまま行った方が効率的です。 とはいえ、コードを読んだときのわかりやすさとトレードオフとなることも多いので(今回の例でも、ベクトル対応してないコードの方がわかりやすいですね)悩みどころではあります。

rowwise() が便利な例

さて、 rowwise() の話に戻りましょう。

これは公式ブログのモデルを扱う例がわかりやすかったのでそのまま転載します。

まず、新しい関数を紹介します。nest_by() です。 これを使うと、nest された rowwise なデータフレームをつくることができます。要は tidyr::nest() + rowwise() みたいな関数です。

library(dplyr, warn.conflicts = FALSE)

by_cyl <- mtcars %>% nest_by(cyl)
by_cyl
#> # A tibble: 3 x 2
#> # Rowwise:  cyl
#>     cyl                data
#>   <dbl> <list<tbl_df[,10]>>
#> 1     4           [11 × 10]
#> 2     6            [7 × 10]
#> 3     8           [14 × 10]

このデータフレームを使って、 各 cyl ごとに lm() で線形モデルのあてはめを行って、その結果を broom::tidy() で取り出す、ということをしているのが次のコードです。 (summarise() が複数行の結果を受け取るようになった、という点は前回書いたのでそっちを参照)

by_cyl %>%
  mutate(model = list(lm(mpg ~ wt, data = data))) %>% 
  summarise(broom::tidy(model))
#> `summarise()` regrouping output by 'cyl' (override with `.groups` argument)
#> # A tibble: 6 x 6
#> # Groups:   cyl [3]
#>     cyl term        estimate std.error statistic    p.value
#>   <dbl> <chr>          <dbl>     <dbl>     <dbl>      <dbl>
#> 1     4 (Intercept)    39.6      4.35       9.10 0.00000777
#> 2     4 wt             -5.65     1.85      -3.05 0.0137    
#> 3     6 (Intercept)    28.4      4.18       6.79 0.00105   
#> 4     6 wt             -2.78     1.33      -2.08 0.0918    
#> 5     8 (Intercept)    23.9      3.01       7.94 0.00000405
#> 6     8 wt             -2.19     0.739     -2.97 0.0118

ちなみに、同じものを nest() と purrr を使って書くとこうなります。

library(tidyr)

mtcars %>% 
  group_by(cyl) %>% 
  nest() %>% 
  mutate(model = purrr::map(data, ~ lm(mpg ~ wt, data = .))) %>% 
  summarise(purrr::map_dfr(model, broom::tidy))

どうでしょうか...?

ここは好き嫌いもあると思いますが、個人的には rowwise の方が若干見やすいかな、という気がします(lm() の結果を明示的に list() でラップしないといけないのはちょっと面倒)。

rowwise()group_by() の違い

さて、ここで「このデータフレーム、nest() した時点ですでに各グループ 1 行にまとめられてるから、実は rowwise() しなくてもいいのでは??」と思った方もいるかもしれません。

nest_cyl <- mtcars %>% 
  group_by(cyl) %>% 
  nest()

nest_cyl
#> # A tibble: 3 x 2
#> # Groups:   cyl [3]
#>     cyl data              
#>   <dbl> <list>            
#> 1     6 <tibble [7 × 10]> 
#> 2     4 <tibble [11 × 10]>
#> 3     8 <tibble [14 × 10]>

しかし、やってみるとこれはうまくいきません。なぜでしょうか?

nest_cyl %>% 
  mutate(model = list(lm(mpg ~ wt, data = data)))
#> Error: Problem with `mutate()` input `model`.
#> x object 'mpg' not found
#> ℹ Input `model` is `list(lm(mpg ~ wt, data = data))`.
#> ℹ The error occured in group 1: cyl = 4.

これは渡されているデータの形が違うからです。 str() で中身を覗いてみましょう。こういう関数を用意して、

f <- function(x) {
  str(x, list.len = 3)
  
  # エラーにならないように適当な値を返す
  1
}

mutate()に渡します。それぞれ以下のような結果になります。

# group_by() の方
nest_cyl %>% 
  mutate(model = f(data))
#> List of 1
#>  $ : tibble [11 × 10] (S3: tbl_df/tbl/data.frame)
#>   ..$ mpg : num [1:11] 22.8 24.4 22.8 32.4 30.4 33.9 21.5 27.3 26 30.4 ...
#>   ..$ disp: num [1:11] 108 146.7 140.8 78.7 75.7 ...
#>   ..$ hp  : num [1:11] 93 62 95 66 52 65 97 66 91 113 ...
#>   .. [list output truncated]
#> List of 1
#>  $ : tibble [7 × 10] (S3: tbl_df/tbl/data.frame)
#>   ..$ mpg : num [1:7] 21 21 21.4 18.1 19.2 17.8 19.7
#>   ..$ disp: num [1:7] 160 160 258 225 168 ...
#>   ..$ hp  : num [1:7] 110 110 110 105 123 123 175
#>   .. [list output truncated]
#> List of 1
#>  $ : tibble [14 × 10] (S3: tbl_df/tbl/data.frame)
#>   ..$ mpg : num [1:14] 18.7 14.3 16.4 17.3 15.2 10.4 10.4 14.7 15.5 15.2 ...
#>   ..$ disp: num [1:14] 360 360 276 276 276 ...
#>   ..$ hp  : num [1:14] 175 245 180 180 180 205 215 230 150 150 ...
#>   .. [list output truncated]
#> # A tibble: 3 x 3
#> # Groups:   cyl [3]
#>     cyl data               model
#>   <dbl> <list>             <dbl>
#> 1     6 <tibble [7 × 10]>      1
#> 2     4 <tibble [11 × 10]>     1
#> 3     8 <tibble [14 × 10]>     1


# rowwise() の方
by_cyl %>% 
  mutate(model = f(data))
#> tibble [11 × 10] (S3: tbl_df/tbl/data.frame)
#>  $ mpg : num [1:11] 22.8 24.4 22.8 32.4 30.4 33.9 21.5 27.3 26 30.4 ...
#>  $ disp: num [1:11] 108 146.7 140.8 78.7 75.7 ...
#>  $ hp  : num [1:11] 93 62 95 66 52 65 97 66 91 113 ...
#>   [list output truncated]
#> tibble [7 × 10] (S3: tbl_df/tbl/data.frame)
#>  $ mpg : num [1:7] 21 21 21.4 18.1 19.2 17.8 19.7
#>  $ disp: num [1:7] 160 160 258 225 168 ...
#>  $ hp  : num [1:7] 110 110 110 105 123 123 175
#>   [list output truncated]
#> tibble [14 × 10] (S3: tbl_df/tbl/data.frame)
#>  $ mpg : num [1:14] 18.7 14.3 16.4 17.3 15.2 10.4 10.4 14.7 15.5 15.2 ...
#>  $ disp: num [1:14] 360 360 276 276 276 ...
#>  $ hp  : num [1:14] 175 245 180 180 180 205 215 230 150 150 ...
#>   [list output truncated]
#> # A tibble: 3 x 3
#> # Rowwise:  cyl
#>     cyl                data model
#>   <dbl> <list<tbl_df[,10]>> <dbl>
#> 1     4           [11 × 10]     1
#> 2     6            [7 × 10]     1
#> 3     8           [14 × 10]     1

group_by() の方は tibblelist が、 rowwise() の方は tibble がそのまま渡されているのがわかるでしょうか。 lm()data 引数にはリストではなくデータフレームを渡さないといけないので、group_by() の方はエラーになっていたわけです。

このように、 group_by()[ でサブセットした結果を渡すのに対し、rowwise()[[ でサブセットした結果を渡します。 こういう挙動になっているのは、 [[ では単一の要素しか取り出せないので、必ず単一の要素しか取り出さないとわかっている rowwise() には使えますが、 group_by() は複数の要素を取り出すこともあるので使えないからです。なので、list column を扱う際は rowwise() の方が便利、という状況になっています。

感想

purrr のユースケースをすべて rowwise() が置き換えるのかというとよくわからないんですが、個人的には rowwise() の方が説明しやすそうなので、今後はなるべくこっちを使うかなーという気分になりつつあります。 みなさんはいかがでしょうか。たぶん勝敗が決したわけではないと思うので、purrr が好きな方は purrr が便利な例をどんどんアピールしていけばいいと思います。