dplyrでグループ単位にデータフレームを操作する

by

もう、周知のネタ感はあるけど、dplyrでグループ単位にデータフレームを操作できるのは便利だなと思うので、いくつか例をあげてみる。

summarize, mutate, filterなどの関数の.by引数を使うと、グループごとに計算ができて非常に便利。

group_by関数でも同じことができるけど、使い方をミスると思わぬ挙動に繋がることもある。

このあたりのことをいくつか例に出してみる。

.byによるグループ単位の操作例

summarizeでグループ単位の集計

これが一番よく使われる例かなと思う。たとえば、ペンギンさんの体型に関するデータの平均値を、を種や島ごとに集計する。

palmerpenguins::penguins |>
  dplyr::summarize(
    dplyr::across(dplyr::where(is.numeric), function(x) mean(x, na.rm = TRUE)),
    .by = c(species, island)
  )
#> # A tibble: 5 × 7
#>   species   island    bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
#>   <fct>     <fct>              <dbl>         <dbl>             <dbl>       <dbl>
#> 1 Adelie    Torgersen           39.0          18.4              191.       3706.
#> 2 Adelie    Biscoe              39.0          18.4              189.       3710.
#> 3 Adelie    Dream               38.5          18.3              190.       3688.
#> 4 Gentoo    Biscoe              47.5          15.0              217.       5076.
#> 5 Chinstrap Dream               48.8          18.4              196.       3733.
#> # ℹ 1 more variable: year <dbl>

mutateでグループ単位の計算

データの偏差を求めたい場合、summarizeした結果を元のデータフレームと比較するのは一手。

しかし、mutateにも.by引数があり、これを使うとグループ単位の計算ができるので、偏差を一発で出せる。

palmerpenguins::penguins |>
  dplyr::mutate(
    dplyr::across(
      dplyr::where(is.numeric),
      function(x) x - mean(x, na.rm = TRUE)
    ),
    .by = c(species, island)
  )
#> # A tibble: 344 × 8
#>    species island    bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
#>    <fct>   <fct>              <dbl>         <dbl>             <dbl>       <dbl>
#>  1 Adelie  Torgersen         0.149          0.271            -10.2         43.6
#>  2 Adelie  Torgersen         0.549         -1.03              -5.20        93.6
#>  3 Adelie  Torgersen         1.35          -0.429              3.80      -456. 
#>  4 Adelie  Torgersen        NA             NA                 NA           NA  
#>  5 Adelie  Torgersen        -2.25           0.871              1.80      -256. 
#>  6 Adelie  Torgersen         0.349          2.17              -1.20       -56.4
#>  7 Adelie  Torgersen        -0.0510        -0.629            -10.2        -81.4
#>  8 Adelie  Torgersen         0.249          1.17               3.80       969. 
#>  9 Adelie  Torgersen        -4.85          -0.329              1.80      -231. 
#> 10 Adelie  Torgersen         3.05           1.77              -1.20       544. 
#> # ℹ 334 more rows
#> # ℹ 2 more variables: sex <fct>, year <dbl>

filterでグループ単位の行選択

同じ発想で、filterもグループ単位で実行できる。たとえば、グループごとに中央値以上の体重を持つペンギンのデータだけを抽出なんてことができる。

palmerpenguins::penguins |>
  dplyr::filter(
    body_mass_g >= median(body_mass_g, na.rm = TRUE),
    .by = c(species, island)
  )
#> # A tibble: 180 × 8
#>    species island    bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
#>    <fct>   <fct>              <dbl>         <dbl>             <int>       <int>
#>  1 Adelie  Torgersen           39.1          18.7               181        3750
#>  2 Adelie  Torgersen           39.5          17.4               186        3800
#>  3 Adelie  Torgersen           39.2          19.6               195        4675
#>  4 Adelie  Torgersen           42            20.2               190        4250
#>  5 Adelie  Torgersen           37.8          17.3               180        3700
#>  6 Adelie  Torgersen           38.6          21.2               191        3800
#>  7 Adelie  Torgersen           34.6          21.1               198        4400
#>  8 Adelie  Torgersen           36.6          17.8               185        3700
#>  9 Adelie  Torgersen           42.5          20.7               197        4500
#> 10 Adelie  Torgersen           46            21.5               194        4200
#> # ℹ 170 more rows
#> # ℹ 2 more variables: sex <fct>, year <int>

group_byしてみる

今は、.byという引数を使えるようになったが、昔は group_by を使っていた。最後にungroupしておかないと事故のもとで、一癖あるものの、同じグループに対して複数の操作をしたい時に便利だ。たとえば先程までの例の、偏差を求める操作と、体重が中央値以上のデータを抽出する操作を同時に行うならこんな感じ。

palmerpenguins::penguins |>
  dplyr::group_by(species, island) |>
  dplyr::mutate(
    dplyr::across(
      dplyr::where(is.numeric),
      function(x) x - mean(x, na.rm = TRUE),
      .names = "deviation_{.col}"
    )
  ) |>
  dplyr::filter(body_mass_g >= median(body_mass_g, na.rm = TRUE)) |>
  dplyr::ungroup()
#> # A tibble: 180 × 13
#>    species island    bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
#>    <fct>   <fct>              <dbl>         <dbl>             <int>       <int>
#>  1 Adelie  Torgersen           39.1          18.7               181        3750
#>  2 Adelie  Torgersen           39.5          17.4               186        3800
#>  3 Adelie  Torgersen           39.2          19.6               195        4675
#>  4 Adelie  Torgersen           42            20.2               190        4250
#>  5 Adelie  Torgersen           37.8          17.3               180        3700
#>  6 Adelie  Torgersen           38.6          21.2               191        3800
#>  7 Adelie  Torgersen           34.6          21.1               198        4400
#>  8 Adelie  Torgersen           36.6          17.8               185        3700
#>  9 Adelie  Torgersen           42.5          20.7               197        4500
#> 10 Adelie  Torgersen           46            21.5               194        4200
#> # ℹ 170 more rows
#> # ℹ 7 more variables: sex <fct>, year <int>, deviation_bill_length_mm <dbl>,
#> #   deviation_bill_depth_mm <dbl>, deviation_flipper_length_mm <dbl>,
#> #   deviation_body_mass_g <dbl>, deviation_year <dbl>

ちなみにgroup_byしたままのデータを表示すると、# Groups: species[3]のようにグループ分けされたデータフレームであることが分かる。

palmerpenguins::penguins |>
  dplyr::group_by(species, island)
#> # A tibble: 344 × 8
#> # Groups:   species, island [5]
#>    species island    bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
#>    <fct>   <fct>              <dbl>         <dbl>             <int>       <int>
#>  1 Adelie  Torgersen           39.1          18.7               181        3750
#>  2 Adelie  Torgersen           39.5          17.4               186        3800
#>  3 Adelie  Torgersen           40.3          18                 195        3250
#>  4 Adelie  Torgersen           NA            NA                  NA          NA
#>  5 Adelie  Torgersen           36.7          19.3               193        3450
#>  6 Adelie  Torgersen           39.3          20.6               190        3650
#>  7 Adelie  Torgersen           38.9          17.8               181        3625
#>  8 Adelie  Torgersen           39.2          19.6               195        4675
#>  9 Adelie  Torgersen           34.1          18.1               193        3475
#> 10 Adelie  Torgersen           42            20.2               190        4250
#> # ℹ 334 more rows
#> # ℹ 2 more variables: sex <fct>, year <int>

ungroup忘れに起因する想定外の挙動の例として、purrr::mapでプログレスバーを出したいのだけれど、なぜか出ないという相談を最近受けた。最小構成は以下のような感じ

data.frame(x = 1:2) |>
  dplyr::group_by(x) |> # ここをコメントアウトするとプログレスバーが見れる
  dplyr::mutate(y = purrr::map(x, \(...) Sys.sleep(1), .progress = TRUE))
#> # A tibble: 2 × 2
#> # Groups:   x [2]
#>       x y     
#>   <int> <list>
#> 1     1 <NULL>
#> 2     2 <NULL>

group_byをコメントアウトする(あるいはungroupしておく)と、プログレスバーが出る。 purrr::map(.progress = TRUE)のプログレスバーは処理に一定以上の時間がかかる場合でかつ、2つ以上の要素を処理する場合に発生するようだ。

先の例だと、group_byのせいで、1行ごとにグループを作ってしまっていた。つまり、実質的には行数が1のデータフレームごとにmapをかけてから、結合したような処理だ。すると、.progress = TRUEを指定していても、要素数が1しかないため、プログレスバーが出なかったというわけ。

イメージとしては以下のような感じ。

list(data.frame(x = 1), data.frame(x = 2)) |>
  purrr::map(
    \(df) {
      dplyr::mutate(
        df,
        y = purrr::map(x, \(...) Sys.sleep(1), .progress = TRUE)
      )
    }
  ) |>
  dplyr::bind_rows() |>
  tibble::as_tibble()
#> # A tibble: 2 × 2
#>       x y     
#>   <dbl> <list>
#> 1     1 <NULL>
#> 2     2 <NULL>

ENJOY!