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

dplyr 1.0.0 がリリースされてもう1ヶ月。日本語でもちらほら紹介のブログ記事やスライドが出てきています。

が、意外と summarise() の挙動変更に触れたやつないなと思って、軽く紹介します。 ちなみに、この記事で取り上げた quantile() の活用例は公式ブログに載っているものです。英語が苦でない方はこっちを読んだほうが早いかもです。

そもそも summarise() の挙動って?

まずは、これまでの dplyr の挙動を確認しておきましょう。 summarise() は、

  1. グループ化されたデータフレームの値を集約し、グループ化を1つ解除する
  2. 集約された結果は単一の値になっている必要がある

という動き方をします。

1. グループ化されたデータフレームの値を集約し、グループ化を1つ解除する

まず1点目。summarise() はすべてのグループ化を解除するわけではありません。

例として、2013 年ニューヨークのフライトのデータ nycflights13::flights から、日毎のフライト数を計算する場合を考えてみましょう。 まずは、 group_by() でグループ化されたデータフレーム(grouped_df)をつくります。このデータフレームをコンソールに表示してみると、Groups: month, day [365] となっていて、 monthday の2つでグループ化されていることがわかります。

library(dplyr, warn.conflicts = FALSE)
library(nycflights13)

packageVersion("dplyr")
#> [1] '0.8.5'

d_grouped <- flights %>%
  # yearはどうせすべて2013なので無視
  group_by(month, day)

d_grouped
#> # A tibble: 336,776 x 19
#> # Groups:   month, day [365]
#>     year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>    <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#>  1  2013     1     1      517            515         2      830            819
#>  2  2013     1     1      533            529         4      850            830
#>  3  2013     1     1      542            540         2      923            850
#>  4  2013     1     1      544            545        -1     1004           1022
#>  5  2013     1     1      554            600        -6      812            837
#>  6  2013     1     1      554            558        -4      740            728
#>  7  2013     1     1      555            600        -5      913            854
#>  8  2013     1     1      557            600        -3      709            723
#>  9  2013     1     1      557            600        -3      838            846
#> 10  2013     1     1      558            600        -2      753            745
#> # … with 336,766 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>,
#> #   air_time <dbl>, distance <dbl>, hour <dbl>, minute <dbl>, time_hour <dttm>

では、これを summarise() して日毎のフライトを数えてみましょう。各グループの行数を取得するにはn()です。

d_summary1 <- d_grouped %>%
  summarise(flights = n())

これを表示すると、グループ化の状態が # Groups: month [12] に変わっています。元の d_grouped と見比べると、 day が消えていることがわかります。 このように、 summarise() はグループ化を1つだけ解除します。解除されるのは、一番うしろのグループ化変数です。

d_summary1
#> # A tibble: 365 x 3
#> # Groups:   month [12]
#>    month   day flights
#>    <int> <int>   <int>
#>  1     1     1     842
#>  2     1     2     943
#>  3     1     3     914
#>  4     1     4     915
#>  5     1     5     720
#>  6     1     6     832
#>  7     1     7     933
#>  8     1     8     899
#>  9     1     9     902
#> 10     1    10     932
#> # … with 355 more rows

この挙動が便利なことは実はそれほどないんですが、まだ month でグループ化されたままなので、たとえば月内のフライト数の累積割合を出すとかできます。 やってみましょう。

d_summary1 %>% 
  # cumsum() には順序が重要なので念のため day で並べ替えておく(たぶん不要)
  arrange(day) %>% 
  mutate(cum_flights_within_month = cumsum(flights) / sum(flights))
#> # A tibble: 365 x 4
#> # Groups:   month [12]
#>    month   day flights cum_flights_within_month
#>    <int> <int>   <int>                    <dbl>
#>  1     1     1     842                   0.0312
#>  2     1     2     943                   0.0661
#>  3     1     3     914                   0.0999
#>  4     1     4     915                   0.134 
#>  5     1     5     720                   0.160 
#>  6     1     6     832                   0.191 
#>  7     1     7     933                   0.226 
#>  8     1     8     899                   0.259 
#>  9     1     9     902                   0.293 
#> 10     1    10     932                   0.327 
#> # … with 355 more rows

これで、各月のフライト数の増加の傾向を見比べることができるようになります。 結果、「うん、ぜんぶ似たように単調増加してる!」みたいなことしかわからないのでぜんぜん面白くないんですが、戯れにプロットしてみましょう。

library(ggplot2)

d_summary1 %>% 
  arrange(day) %>% 
  mutate(cum_flights_within_month = cumsum(flights) / sum(flights)) %>% 
  ggplot() +
  geom_area(aes(day, cum_flights_within_month)) +
  facet_wrap(vars(month)) +
  scale_y_continuous(label = scales::percent)

どうでしょうか。まあ今回は面白いことにはならなかったけど、役に立つこともあるならグループ化を残しておいて損はないかな...

と思うかも知れませんが、実際には、グループ化を残しておくと役に立つよりトラブルの元になることの方が多いです。

たとえば、集計された各日のフライト数が全体の平均と比べてどれくらい多いのか/少ないのかを把握するために mean(flight) で割ってみるとします。 このとき、先程の結果をそのまま使うと、全体の平均ではなく月ごとの平均で割られてしまいます。

d_summary1 %>% 
  mutate(rel_flights = flights / mean(flights))
#                                ^^^^^^^^^^^^^
#                                全体の平均ではなく月ごとの平均!

#> # A tibble: 365 x 4
#> # Groups:   month [12]
#>    month   day flights rel_flights
#>    <int> <int>   <int>       <dbl>
#>  1     1     1     842       0.967
#>  2     1     2     943       1.08 
#>  3     1     3     914       1.05 
#>  4     1     4     915       1.05 
#>  5     1     5     720       0.827
#>  6     1     6     832       0.955
#>  7     1     7     933       1.07 
#>  8     1     8     899       1.03 
#>  9     1     9     902       1.04 
#> 10     1    10     932       1.07 
#> # … with 355 more rows

以下が ungroup() でグループ化を解除した正しい結果です。見比べると値が違っているのがわかりますね。

d_summary1 %>% 
  ungroup() %>% 
  mutate(rel_flights = flights / mean(flights))
#> # A tibble: 365 x 4
#>    month   day flights rel_flights
#>    <int> <int>   <int>       <dbl>
#>  1     1     1     842       0.913
#>  2     1     2     943       1.02 
#>  3     1     3     914       0.991
#>  4     1     4     915       0.992
#>  5     1     5     720       0.780
#>  6     1     6     832       0.902
#>  7     1     7     933       1.01 
#>  8     1     8     899       0.974
#>  9     1     9     902       0.978
#> 10     1    10     932       1.01 
#> # … with 355 more rows

また、同じ結果になる場合でも、グループ化されていないデータフレームでは全体で一気に計算されるのに対して、グループ化されているとグループごとに計算が行われるので処理が遅くなります。

このようなトラブルを避けるため、 summarise() した後はすぐに ungroup() してグループ化を解除しましょう、というのが長らく暗黙のベストプラクティスとして存在してきました。

さて、これが dplyr 1.0.0 でどのように改善されたか気になるところですが、その前に 2 点目の「集約された結果は単一の値になっている必要がある」というのもさらっと見ておきましょう。すぐなので。

2. 集約された結果は単一の値になっている必要がある

データを集約する関数にはいろいろ種類があります。たとえば、 quantile() はデータを「分位点」という要素に集約してくれる便利な関数ですが、これを summarise() に使うことはできませんでした。 結果が長さ1ではないからです。具体的には次のようなエラーになります

# 全体の遅延の分布
quantile(flights$dep_delay, na.rm = TRUE)
#>   0%  25%  50%  75% 100% 
#>  -43   -5   -2   11 1301

# origin ごとに遅延の分布を見たかった
flights %>% 
  group_by(origin) %>% 
  summarise(dep_delay = quantile(dep_delay, na.rm = TRUE))
#> Error: Column `dep_delay` must be length 1 (a summary value), not 5

さて、これでこれまでの挙動が確認できたので、いよいよ1.0.0を見ていきましょう。

summarise()の新機能その1: 親切なメッセージ

先ほどと同じコードを 1.0.0 で実行してみると、まず気付くことがあります。

library(dplyr, warn.conflicts = FALSE)
library(nycflights13)

packageVersion("dplyr")
#> [1] '1.0.0'

d_grouped <- flights %>%
  # yearはどうせすべて2013なので無視
  group_by(month, day)

d_summary1 <- d_grouped %>%
  summarise(flights = n())
#> `summarise()` regrouping output by 'month' (override with `.groups` argument)

最後の行に、 `summarise()` regrouping output by 'month' (override with `.groups` argument) というメッセージが出ていますね。 これは、これまで summarise() が黙ってグループ化を1つ解除していたのが、明示的にメッセージが出るようになった、ということです。 summarise() の挙動は何も変わっていません。

もしうるさいと感じた場合は、以下のオプションを指定しておくと表示されなくなります。

options(dplyr.summarise.inform = FALSE)

さて、メッセージは「気に入らないなら.groups 引数を指定してくれ」的なことを言っていますが、いったい .groups 引数とは何者なんでしょう?

summarise()の新機能その2: .groups 引数

.groups 引数は、 summarise() がグループ化をどう変更するかを制御するものです。以下の 4 種類が設定できます。

  • "drop_last": 一番うしろのグループ化変数を解除します。これまでと同じ挙動で、バージョン1.0.0でも基本は(後述)これがデフォルトです。
  • "drop": すべてのグループ化を解除します。
  • "keep": グループ化をそのまま維持します。
  • "rowwise": rowwise な状態にします。便利なんですが、この記事の本題ではないのでまた日を改めて...

具体的にはそれぞれ次のような結果になります。

options(tibble.print_min = 0)

d_grouped <- flights %>%
  group_by(month, day)

d_grouped %>%
  summarise(flights = n(), .groups = "drop_last")
#> # A tibble: 365 x 3
#> # Groups:   month [12]
#> # … with 365 more rows, and 3 variables: month <int>, day <int>, flights <int>

d_grouped %>%
  summarise(flights = n(), .groups = "drop")
#> # A tibble: 365 x 3
#> # … with 365 more rows, and 3 variables: month <int>, day <int>, flights <int>

d_grouped %>%
  summarise(flights = n(), .groups = "keep")
#> # A tibble: 365 x 3
#> # Groups:   month, day [365]
#> # … with 365 more rows, and 3 variables: month <int>, day <int>, flights <int>

d_grouped %>%
  summarise(flights = n(), .groups = "rowwise")
#> # A tibble: 365 x 3
#> # Rowwise:  month, day
#> # … with 365 more rows, and 3 variables: month <int>, day <int>, flights <int>

2つ目の結果、 .groups = "drop" のケースでは Groups: ... の表示が消えているのがわかるでしょうか。 これが私たちが求めていたものです。これまでは、意図しないグループ化を取り除くために

d_grouped %>%
  summarise(flights = n()) %>%
  ungroup()

と、わざわざ summarise() したら ungroup() していたのが、

d_grouped %>%
  summarise(flights = n(), .groups = "drop")

と1行少なく書けるようになった、という話です。便利!

ちなみに余談ですが、dplyr 1.0.0 では with_groups() という関数もこっそり追加されていて、こんな感じで一時的にグループ化することもできます。

flights %>%
  with_groups(c(month, day),
    ~ summarise(., flights = n())
  )

さて、上にも書いたように、基本はこれまでの挙動と同じ .groups = "drop_last" がデフォルトです。...基本は?

ドキュメントを見てみましょう。

When .groups is not specified, you either get "drop_last" when all the results are size 1, or "keep" if the size varies.

なにやら "keep" がデフォルトになることがあるようです。 if the size varies とはどういう場合なのかを次に見てみましょう。

summarise()の新機能その3: 長さ1以上の結果を返す集約関数も使えるようになった

以前の sumarise() の挙動を確認する中で、 quantile() がエラーになることを見ました。quantile() が返す結果は長さが 1 以上であるためでした。

その同じコードをバージョン1.0.0で実行してみると...

flights %>% 
  group_by(origin) %>% 
  summarise(dep_delay = quantile(dep_delay, na.rm = TRUE))
#> `summarise()` regrouping output by 'origin' (override with `.groups` argument)
#> # A tibble: 15 x 2
#> # Groups:   origin [3]
#>    origin dep_delay
#>    <chr>      <dbl>
#>  1 EWR          -25
#>  2 EWR           -4
#>  3 EWR           -1
#>  4 EWR           15
#>  5 EWR         1126
#>  6 JFK          -43
#>  7 JFK           -5
#>  8 JFK           -1
#>  9 JFK           10
#> 10 JFK         1301
#> 11 LGA          -33
#> 12 LGA           -6
#> 13 LGA           -3
#> 14 LGA            7
#> 15 LGA          911

なんと成功します! quantile()prob 引数を指定しないと0%、25%、50%、75%、100%の5つの分位点を返しますが、 それが縦方向に並べられています。

でもこれだとどの分位点かわからなくならない?という話があるかと思いますが、それはいったん脇に置いて、 まず先程の "keep" の疑問を解決しましょう。

結果のデータフレームを見てください。 Groups: origin [3] という表記があります。これまでのルールから考えると、これは変です。 origin でグループ化されたデータフレームを summarise() を通したのに、グループ化が1つも解除されていません。 これが、ドキュメントにあった

or "keep" if the size varies.

という挙動です。結果が複数の値のときは、各グループがまだ1行に集約されきっていない?のでグループ化を残す、という挙動になっているのです。 なんとも難しいですね...

summarise()の新機能その4: データフレームを渡せる

(ここはちょっと上級者向けの使い方なので理解できなくても大丈夫だと思います)

でもこれだとどの分位点かわからなくならない?、という問題に戻りましょう。 単純には、 prob と同じものを別の列に渡しておく、という解決策があります。こんな感じ。

flights %>% 
  group_by(origin) %>% 
  summarise(
    q = scales::percent(c(0.3, 0.6, 0.9)),
    dep_delay = quantile(dep_delay, prob = c(0.3, 0.6, 0.9), na.rm = TRUE)
  )
#> `summarise()` regrouping output by 'origin' (override with `.groups` argument)
#> # A tibble: 9 x 3
#> # Groups:   origin [3]
#>   origin q     dep_delay
#>   <chr>  <chr>     <dbl>
#> 1 EWR    30%          -4
#> 2 EWR    60%           2
#> 3 EWR    90%          57
#> 4 JFK    30%          -4
#> 5 JFK    60%           0
#> 6 JFK    90%          46
#> 7 LGA    30%          -5
#> 8 LGA    60%          -1
#> 9 LGA    90%          43

これでひとまずわかるようにはなりましたが、見る分位点を変えたい時に 2 箇所を書き直さないといけないのは不安ですね。 30% と表示されているけど実際に quantile() に渡されているのは 0.5 だった、みたいな事故が起こることが容易に想像できます。 ここで、分位点とその値をペアで返してくれるものが欲しくなります。

値のペア。そう、データフレームですね。

quibble <- function(x, probs) {
  tibble(
    q = scales::percent(probs),
    value = quantile(x, probs = probs, na.rm = TRUE)
  )
}

そしてこれを summarise() に引数名なしで渡すと勝手に展開してくれます(古参 dplyr ユーザーの方は、 do() みたいなものだと思えばいいです)。

flights %>% 
  group_by(origin) %>% 
  summarise(quibble(dep_time, c(0.3, 0.6, 0.9)))
#> `summarise()` regrouping output by 'origin' (override with `.groups` argument)
#> # A tibble: 9 x 3
#> # Groups:   origin [3]
#>   origin q     value
#>   <chr>  <chr> <dbl>
#> 1 EWR    30%     957
#> 2 EWR    60%    1520
#> 3 EWR    90%    2015
#> 4 JFK    30%    1021
#> 5 JFK    60%    1620
#> 6 JFK    90%    2029
#> 7 LGA    30%     956
#> 8 LGA    60%    1454
#> 9 LGA    90%    1937

ちなみに、引数名ありで渡すと、世界のatusyも大好きな data.frame column をつくれます。

flights %>% 
  group_by(origin) %>% 
  summarise(q_df = quibble(dep_time, c(0.3, 0.6, 0.9)))
# (結果は省略)

冒頭にも書いたように、このへんは公式ブログのぱくrを参考にして書いたものです。 tidy eval の使い方とか参考になるので気になる方は本家のも読んでみてください。 今回は触れなかった rowwise() の便利さの片鱗を垣間見ることもできます。

まとめ

まとめると、 summarise() の挙動は何も考えずに使えばこれまでと特に変わっていません。 むしろ、 .groups 引数でいろいろ挙動を制御できるようになった分だけ便利になっています。 長さ1以上の結果を受け付ける方は間違うと事故が起こるかもですが(1つの値に集約したつもりができてなかった、とか)、まあ使いこなせば便利でしょう。 おそれずに使っていきましょう。