🔠

dplyr の summarize の式が定型の場合に自動生成する

2024/12/17に公開
3

はじめに

dplyr の summarize の式が定型の場合に自動生成したい。
例えば、次のようなデータがあるとする。

library(tidyverse)

set.seed(314)
data <- data.frame(
  group = sample(1:5, 100, replace = TRUE),
  letter = sample(LETTERS, 100, replace = TRUE)
)
head(data)
  group letter
1     4      B
2     3      O
3     4      N
4     3      Q
5     2      Q
6     4      K

1から5までのグループに、ランダムにアルファベット文字が100個割り当てられている。
各グループの中に特定のアルファベット文字が含まれているかどうかを知りたい。

例えば、A, B, C が含まれているかどうかを知るには次のようにする。

data |>
  group_by(group) |>
  summarise(
    exists_A = any(letter == "A"),
    exists_B = any(letter == "B"),
    exists_C = any(letter == "C")
  )
  group exists_A exists_B exists_C
1     1 FALSE    FALSE    FALSE   
2     2 TRUE     TRUE     FALSE   
3     3 TRUE     TRUE     TRUE    
4     4 TRUE     TRUE     FALSE   
5     5 FALSE    TRUE     FALSE

ここで問題となるのは、調べたい文字がAからNまでのように多い場合や、分析ごとに変わるという場合に、式を書くのが面倒になることである。

式の部分は exists_? = any(letter == "?") というように決まった形になっているので、どうにかこれを自動生成できないだろうか?

方針

方針としては、do.call()quote() を使うことにする。
do.call() は関数を実行する関数であり、その際の引数は名前付きリストで与える。
例えば log(1:3) と同じ処理を do.call() を使って書くと次のようになる。

log(1:3)
#> [1] 0.0000000 0.6931472 1.0986123

args <- list(x = 1:3)
do.call(log, args)
#> [1] 0.0000000 0.6931472 1.0986123

また、quote() は式を実行せずに保持することができる。
これらを使うと、グループごとに A, B, C を含むかどうかを調べるコードは次のようになる。

args <- list(
  .data = data,
  .by = quote(group),
  exists_A = quote(any(letter == "A")),
  exists_B = quote(any(letter == "B")),
  exists_C = quote(any(letter == "C"))
)
do.call(summarise, args)
  group exists_A exists_B exists_C
1     1 FALSE    FALSE    FALSE   
2     2 TRUE     TRUE     FALSE   
3     3 TRUE     TRUE     TRUE    
4     4 TRUE     TRUE     FALSE   
5     5 FALSE    TRUE     FALSE

実行結果は同じである。
コードは複雑になったが、定型式をリストの要素として持てるようになったのが利点である。
次はこの定型式を自動で生成してみよう。

for を使う場合

調べたいアルファベット文字を target_letters という変数に格納する。
この変数を変更することで様々なアルファベット文字に対応できる。
まずは式を for 文で生成する場合を考えてみよう。

target_letters <- c("A", "B", "C")

args <- list(
  .data = data,
  .by = quote(group)
)

for (x in target_letters) {
  arg <- eval(parse(text = str_glue('quote(any(letter == "{x}"))')))
  args <- append(args, arg)
}

names(args)[-(1:2)] <- str_glue("exists_{target_letters}")

do.call(summarise, args)

eval(parse(text = ...)) は文字列を評価するためのイディオム(慣用句)なので覚えておいて損はない。
これで生成した式を args に追加していき、do.call() を使って summarize() を実行する。
実行結果は前述のものと同じになる。

ベクトルでやる場合

for があまり好きでないという人(わたし)もいると思うので、ベクトルでやる場合も書いておく。

target_letters <- c("A", "B", "C")

args <- list(
  .data = data,
  .by = quote(group)
)

v_eval <- Vectorize(eval)
exprs <- str_glue('quote(any(letter == "{target_letters}"))')
exprs <- v_eval(parse(text = exprs))
args <- append(args, exprs)

names(args)[-(1:2)] <- str_glue("exists_{target_letters}")

do.call(summarise, args)

eval() をベクトル化して適用している。
もちろん、ベクトル化せずに次のように書くこともできるが、可読性はよくないように思う。

exprs <- str_glue('quote(any(letter == "{target_letters}"))')
exprs <- eval(parse(text = str_glue('list({str_c(exprs, collapse = ",")})')))

まとめ

dplyr の summarize の式が定型の場合に自動生成する方法を書いた。
他にもこんなやり方があるよというのがあれば教えてください。

Discussion

yutannihilationyutannihilation

最近 dplyr さわってなくて最適解が分からないのですが、思いついたやつをとりあえず...

関数のリストを作って across() で適用する

x <- c("A", "B", "C")
names(x) <- x
f <- lapply(x, \(l) \(x) any(x == l))

data |>
  group_by(group) |>
  summarise(across(letter, f))

expression のリストを作って !!! で展開する

x <- c("A", "B", "C")
names(x) <- paste0("exists_", x)
exprs <- lapply(x, \(l) rlang::expr(any(letter == !!l)))

data |>
  group_by(group) |>
  summarise(!!!exprs)
hoxo-mhoxo-m

すごい、ラムダ式を使いこなしている!
そして !!! 使えば do.call 使わないでできるんですね。
これ見てから次でいいことに気づきました。

exprs <- lapply(x, function(x) substitute(any(letter == x), list(x = x)))

across は現代的でいいですね、私は理解できてないので勉強したい。