🥚

R言語のdo.callとReduceでlist型の扱いが簡単になる

に公開

はじめに

R言語を使うとき、繰り返し処理はみなさんはどのように書いていますか?
筆者はR言語で繰り返し処理を lapplyMap 関数を利用することが多いです。

そもそもですが、筆者が扱うデータは配列やデータフレームよりも list で扱うことに適しているため、繰り返し処理は必然として lapply (Map) を利用することになります。
例えば、気象データの (netCDF) のように、属性情報 (気温、湿度など) は異なるが 位置情報 (long, lat) や時間情報が同じである構造化データの値を取り出す場合には list 型を利用する方法が適しているように感じます。
また、以前のバージョンのRでは for だと計算速度が出なかった、という話もありますが、 for だと、値の受け渡しを毎度書く必要があるため、正直面倒です。

今回は lapply (Map) の結果をまとめて data.frame にしたり、集合の計算をまとめて行う方法を記録しておきます。

実行環境

Linuxで実行していますが、Rの初期導入関数を利用するのでどの環境でも結果に変わりはありません。

sessionInfo()

R version 4.2.2 Patched (2022-11-10 r83330)
Platform: x86_64-pc-linux-gnu (64-bit)
Running under: Debian GNU/Linux 12 (bookworm)

Matrix products: default
BLAS:   /usr/lib/x86_64-linux-gnu/blas/libblas.so.3.11.0
LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.11.0

locale:
 [1] LC_CTYPE=ja_JP.UTF-8       LC_NUMERIC=C               LC_TIME=ja_JP.UTF-8        LC_COLLATE=ja_JP.UTF-8     LC_MONETARY=ja_JP.UTF-8   
 [6] LC_MESSAGES=ja_JP.UTF-8    LC_PAPER=ja_JP.UTF-8       LC_NAME=C                  LC_ADDRESS=C               LC_TELEPHONE=C            
[11] LC_MEASUREMENT=ja_JP.UTF-8 LC_IDENTIFICATION=C       

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

loaded via a namespace (and not attached):
[1] compiler_4.2.2 tools_4.2.2

サンプルデータの作成

まずはテスト用のデータを作成します。

id 列がキー列で、ランダムに選択されるようになっています。
これを data.frame として各listの要素に格納します。

d <- lapply(1:5, function(x) {
  data.frame(id = sample(1:10, size = 5),
             value = rnorm(5)
             )
})
str(d)

List of 5
 $ :'data.frame':	5 obs. of  2 variables:
  ..$ id   : int [1:5] 5 6 8 1 10
  ..$ value: num [1:5] 1.296 0.417 1.594 0.453 -1.607
 $ :'data.frame':	5 obs. of  2 variables:
  ..$ id   : int [1:5] 6 2 9 7 4
  ..$ value: num [1:5] 0.487 -0.283 -1.227 -0.304 -0.825
 $ :'data.frame':	5 obs. of  2 variables:
  ..$ id   : int [1:5] 10 9 2 3 6
  ..$ value: num [1:5] -1.3959 0.0378 0.4035 1.2866 0.0731
 $ :'data.frame':	5 obs. of  2 variables:
  ..$ id   : int [1:5] 2 4 8 6 1
  ..$ value: num [1:5] -0.429 -0.548 -0.569 0.899 -0.687
 $ :'data.frame':	5 obs. of  2 variables:
  ..$ id   : int [1:5] 6 2 8 10 1
  ..$ value: num [1:5] 1.207 -0.73 -0.587 0.897 0.14

do.call関数について

筆者は、 do.call をlist型のデータをベクトル (c)、データフレーム化 (data.frame)、行方向の結合(rbind)、列方向の結合(cbind) など変換する作業時に利用しています。

例えば、列方向の結合(cbind) は以下の例のとおりです。
複数のデータフレームの列が結合されています。
当然ながら、データの長さ (nrow) が揃っていないと成功しませんが、揃ってさえいればこのように一括で結合できます。
この操作感は cbind を単体で利用する場合と全く同じです。

do.call(cbind, d)

  id      value id      value id       value id      value id      value
1  5  1.2964818  6  0.4867769 10 -1.39592327  2 -0.4290121  6  1.2068753
2  6  0.4167234  2 -0.2830809  9  0.03782643  4 -0.5477464  2 -0.7298629
3  8  1.5944528  9 -1.2270937  2  0.40350864  8 -0.5687036  8 -0.5872229
4  1  0.4528981  7 -0.3041781  3  1.28658397  6  0.8988091 10  0.8972524
5 10 -1.6067214  4 -0.8245866  6  0.07312669  1 -0.6867530  1  0.1397295

続いて、行方向に結合する場合は、 rbind を使うように、列名が同じであることが条件です。

do.call(rbind, d) |> str()

'data.frame':	25 obs. of  2 variables:
 $ id   : int  5 6 8 1 10 6 2 9 7 4 ...
 $ value: num  1.296 0.417 1.594 0.453 -1.607 ...

関数型プログラミングを実行する場合、 x <- do.call(rbind, lapply(y, function(m) {...}) のように複数の要素 ylapply で並列計算し、その結果返る data.framerbind で行方向に結合して一つのデータセットにしてしまうことができます。

見た目は複雑になりますが、筆者はこの方法をよく利用しています。

do.callでその他関数実行方法

do.call では、第一引数に関数を指定しますが、その書き方は、 do.call(FUN, list(...))) -> FUN(...) と同じ意味になります。

つまり、ある関数 (FUN) の引数を名前付きlist(list(...))として指定することができるということです。

sum(1:5, 3:20, NA, na.rm = TRUE)
do.call(sum, list(1:5, 3:20, NA, na.rm = TRUE))

[1] 222
[1] 222

例えば、新たな関数を定義する際、関数の引数値の指定方法には、名前付き引数を設定する、三点ドット (...) として渡す、メタプログラミングで expression として渡す、文字列で渡してパース (parse(text = ...)) するかの方法があります。
しかしながら、do.callを使うと、ある処理だけに利用する設定 (引数) を制限なく指定することができるため、とても便利になります。

#' @param x データ
#' @param fun 関数
#' @param opt 関数の引数値
testfun <- function(..., sum_opt = list(), mean_opt = list()) {
  do.call(sum, c(list(...), sum_opt)) |> print()
  do.call(mean, c(list(c(...)), mean_opt)) |> print()
}
message("==== test 1 ====")
testfun(1:5, 3:20, NA, sum_opt = list(na.rm = TRUE), mean_opt = list(na.rm = FALSE))
message("==== test 2 ====")
testfun(1:5, 3:20, NA, sum_opt = list(na.rm = TRUE), mean_opt = list(na.rm = TRUE))

==== test 1 ====
[1] 222
[1] NA
==== test 2 ====
[1] 222
[1] 9.652174

Reduce関数について

Reduce 関数は do.call と同じように、第一引数に関数を指定し、第二引数に指定した list データに適用する挙動をしますが、その対象範囲が異なります。

和集合

和集合を考える場合、 do.call では失敗するが Reduce では成功します。

do.call(union, lapply(d, function(x) x$id))

(function (x, y)  でエラー: 
  使われていない引数 (c(10, 9, 2, 3, 6), c(2, 4, 8, 6, 1), c(6, 2, 8, 10, 1))

Reduce(union, lapply(d, function(x) x$id))

[1]  5  6  8  1 10  2  9  7  4  3

結合 (merge)

Reduce(function(x, y) merge(x, y, by = "id", all = TRUE), d)

   id    value.x    value.y    value.x    value.y      value
1   1         NA         NA  0.3462431  1.0542735  0.5674269
2   2 -1.4828797  0.2642513 -0.2009429 -1.4347203         NA
3   3         NA -0.9356619         NA -1.0988169 -1.5468162
4   4         NA         NA         NA         NA -1.2303792
5   5  0.9144786         NA         NA -1.4594198         NA
6   6         NA  0.5397016         NA         NA  0.4838808
7   7  0.1393513 -0.1823246 -0.4386959         NA  3.1292710
8   8         NA         NA -1.3072114         NA         NA
9   9 -0.9732506         NA         NA  0.1738573         NA
10 10 -0.2555061  0.8463972 -0.2037884         NA         NA
 警告メッセージ: 
1:  merge.data.frame(x, y, by = "id", all = TRUE) で: 
  column names ‘value.x’, ‘value.y’ are duplicated in the result
2:  merge.data.frame(x, y, by = "id", all = TRUE) で: 
  column names ‘value.x’, ‘value.y’ are duplicated in the result

do.callとReduceの比較

では、 do.call で試した cbindReduce で試すとどうでしょうか?

Reduce(cbind, d)

  id      value id      value id       value id      value id      value
1  5  1.2964818  6  0.4867769 10 -1.39592327  2 -0.4290121  6  1.2068753
2  6  0.4167234  2 -0.2830809  9  0.03782643  4 -0.5477464  2 -0.7298629
3  8  1.5944528  9 -1.2270937  2  0.40350864  8 -0.5687036  8 -0.5872229
4  1  0.4528981  7 -0.3041781  3  1.28658397  6  0.8988091 10  0.8972524
5 10 -1.6067214  4 -0.8245866  6  0.07312669  1 -0.6867530  1  0.1397295

他にも、 c, data.frame, rbind, list, ... 等の関数が利用できました。
この違いについてAI (gemma3n_e2b) に質問してみると、 do.call引数をまとめて関数に渡す ために利用し、 Reduce繰り返し処理を簡単にする ために利用するそうです。知りませんでした。
つまり、 x <- do.call(rbind, lapply(y, function(m) {...}) ではなく x <- Reduce(rbind, lapply(y, function(m) {...}) が正しい使い方のようです。

おわりに

do.callReduce について見てきましたが、正直どちらも同じような挙動であるため筆者は do.call を多用してきました。
しかしながら、 do.calldo.callでその他関数実行方法 の使い方が本質であり、 list型のデータをまとめて処理する場合には Reduce を使うべきということがわかりました。
LISP のように再帰関数を使ってデータの処理をしているところがありましたが、 Reduce では簡単にできそうです。
長年使っていても知らないことがある、改めて学習し直す必要性を感じました。

Discussion