dplyr 0.8.0を使ってみた(group_by()のbreaking changes編)

dplyr 0.8.0がもうすぐ(来年1月上旬?)リリースされます。

この公式記事を読んでおけばいいかと思ったんですが、今回は、group_by()に大きな変更がいくつもあります。 まじで大きな変更なので、「もう俺たちが知っているgroup_by()じゃない」くらいのつもりで臨みましょう。 便利な新機能もあるんですが、それは日を改める*1ことにして、 breaking changeに絞って書きます。

factorの扱い

今のdplyrは、factorを文字列と同じように扱って、そのベクトル中に存在する値を見てグループをつくります。 一方、0.8.0からはfactorのlevelごとにグループをつくるようになります。ベクトル中に存在しないlevelに対しては空のグループができることになります。

例えば、以下の例ではx列にcというデータはありませんが、factorのlevelとして存在しているのでグループになります。 summarise()の結果にcも登場しているのに注目してください。

library(dplyr, warn.conflicts = FALSE)

d <- data.frame(x = factor(c("a", "b", "b"),
                           levels = c("a", "b", "c")),
                val = 1:3)

d %>%
  group_by(x) %>%
  summarise(n = n(), mean = mean(val))
#> # A tibble: 3 x 3
#>   x         n  mean
#>   <fct> <int> <dbl>
#> 1 a         1   1  
#> 2 b         2   2.5
#> 3 c         0 NaN

あと、もう一つ注目すべきは、cグループのmeanNaNになっていることです。 これは、mean(numeric(0))の結果がNaNなのでこうなります。 このように、長さゼロのベクトルをうまく扱えない集計関数もあるので、 そのあたりで予期せぬエラーが起こるかもしれません。注意しましょう。

グループ情報の持ち方

これまで、group_by()によってつくられるグループ化されたデータフレーム(grouped_df)は、 グループ化に使う列名をメタデータとして持っていました。 そして、mutate()filter()などを実行するタイミングでその列を見てグループ分けを計算していました。

しかし、0.8.0からは、基本的にgroup_by()した時点でグループ分けが決定され、 明示的に指定しない限りそのグループ分けがずっと使われます。

grouped_dfが持っているグループ情報はgroup_data()を使うと見ることができます(他にもいろいろ関数がありますが後述)。やってみましょう。

d <- data.frame(group_id = c(1L, 1L, 2L, 2L),
                value    = 1:4)

g <- d %>%
  group_by(group_id)

group_data(g)
#> # A tibble: 2 x 2
#>   group_id .rows    
#>      <int> <list>   
#> 1        1 <int [2]>
#> 2        2 <int [2]>

.rowsというのはそのグループに所属する行です。

さて、このデータからgroup_id1の行をfilter()で取り除くとグループ情報がどう変わるか見てみましょう。

g1 <- g %>%
  filter(value >= 3, .preserve = TRUE)

group_data(g1)
#> # A tibble: 2 x 2
#>   group_id .rows    
#>      <int> <list>   
#> 1        1 <int [0]>
#> 2        2 <int [2]>

<int [0]>と表示されているように、もうこのグループに所属する行ありません。 にも関わらず、グループとしては存在し続けています。

.preserveという見慣れない引数が登場していますが、これはグループ分けをそのまま使うかやり直すかを指定する引数です。 今の開発版はデフォルトがTRUEですが、後方互換性のためFALSEをデフォルトにすることになりそうな雰囲気です。 FALSEだと、filter()した後の結果でグループ分けをやり直すので、以下のように空のグループは消えます。

g2 <- g %>%
  filter(value >= 3, .preserve = FALSE)

group_data(g2)
#> # A tibble: 1 x 2
#>   group_id .rows    
#>      <int> <list>   
#> 1        2 <int [2]>

ただし、factorの場合はグループ分けをやり直してもfactorのlevelは変わっていないわけなので、空のグループは消えません。 .preserve = FALSEは名前的に「空のグループを消す」オプションと思ってしまうかもしれませんが、「グループ分けをやり直す」というものです。注意しましょう。

d <- data.frame(group_id = factor(c("a", "a", "b", "b")),
                value    = 1:4)

d %>%
  group_by(group_id) %>% 
  filter(value >= 3, .preserve = FALSE) %>%
  group_data()
#> # A tibble: 2 x 2
#>   group_id .rows    
#>   <fct>    <list>   
#> 1 a        <int [0]>
#> 2 b        <int [2]>

もし空のグループを消したければ、一度グループ化を解除してからその列をcharacterに変換するかforcats::fct_drop()でlevelを落とすかしかありません(公式記事を参照)。

filter()slice()の結果の順序

これは公式記事のやつがわかりやすかったので似たようなコードを載せておきます。 xでグループ化したgrouped_dffilter()slice()を使うと、行がグループごとに寄せられた結果になります。

d <- tibble(
  x = c(1,   2, 1,  2, 1, 2), 
  y = c(1, 100, 3, 10, 5, 1)
)

d
#> # A tibble: 6 x 2
#>       x     y
#>   <dbl> <dbl>
#> 1     1     1
#> 2     2   100
#> 3     1     3
#> 4     2    10
#> 5     1     5
#> 6     2     1

d %>% 
  group_by(x) %>% 
  slice(1:2)
#> # A tibble: 4 x 2
#> # Groups:   x [2]
#>       x     y
#>   <dbl> <dbl>
#> 1     1     1
#> 2     1     3
#> 3     2   100
#> 4     2    10

グループ内の順序は入れ替わったりしないので通常は問題にならないと思いますが、 グループごとの計算結果で行を絞り込む(グループ内の順位がn位以上の行、とか)処理をしている場合は注意が必要です。 行の順序が問題になるような処理をする場合は、ちゃんと直前でarrange()するようにしましょう。

感想

2番目がほんとにbreaking changeだなーと思うのは、「group_by()とはこういう動きをする」という認識を改めないといけないからです。dplyrに慣れているひとほど頭の切り替えに苦しむでしょう。 幸いなことに、まだdplyrのリリースまで少なくとも1カ月くらいあるので、がんばって慣れていきましょう(あるいはGitHubに凸って文句を言っていきましょう)。

*1:つまり・・・・我々がその気になればこの記事を書くのは10年20年後ということも可能だろう・・・ということ・・・・!