dplyr 1.0.0 がリリースされてもう1ヶ月。日本語でもちらほら紹介のブログ記事やスライドが出てきています。
が、意外と summarise()
の挙動変更に触れたやつないなと思って、軽く紹介します。
ちなみに、この記事で取り上げた quantile()
の活用例は公式ブログに載っているものです。英語が苦でない方はこっちを読んだほうが早いかもです。
そもそも summarise()
の挙動って?
まずは、これまでの dplyr の挙動を確認しておきましょう。 summarise()
は、
- グループ化されたデータフレームの値を集約し、グループ化を1つ解除する
- 集約された結果は単一の値になっている必要がある
という動き方をします。
1. グループ化されたデータフレームの値を集約し、グループ化を1つ解除する
まず1点目。summarise()
はすべてのグループ化を解除するわけではありません。
例として、2013 年ニューヨークのフライトのデータ nycflights13::flights
から、日毎のフライト数を計算する場合を考えてみましょう。
まずは、 group_by()
でグループ化されたデータフレーム(grouped_df
)をつくります。このデータフレームをコンソールに表示してみると、Groups: month, day [365]
となっていて、 month
とday
の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つの値に集約したつもりができてなかった、とか)、まあ使いこなせば便利でしょう。
おそれずに使っていきましょう。