R の apply 徹底解説 〜これで for 文も卒業!〜
「R で for 文使っても良いのは小学生までだよね」と R に慣れてきた人は揃って口にしますよね。1(えっ
いわゆる apply 族と呼ばれる関数の筆頭にあるのが apply 関数なわけですが、R を勉強し始める人にとっては躓きポイントかと思います。
というわけで apply についての解説記事を書いてみました。
apply の基礎
apply の引数は次のようになっています。
> args(apply)
function (X, MARGIN, FUN, ...)
NULL
ドキュメントには引数の意味もちゃんと書いてあるのですが、理解していないと意味不明な気がします。あ、日本語訳は適当です。
引数名 | 説明 |
---|---|
X | 行列、データフレーム、配列などのオブジェクト |
MARGIN | 繰り返し処理においてインデックスを変化させる次元の番号(行列の場合、1が行、2が列)、または次元の名前 |
FUN | 抽出した要素に適用する関数 |
… | FUN に渡す引数 |
MARGIN の説明がわかりにくいと思うので具体例を挙げてみます。
X として次のような3次元配列を使用します。
> (a <- array(1:24, c(3, 4, 2)))
, , 1
[,1] [,2] [,3] [,4]
[1,] 1 4 7 10
[2,] 2 5 8 11
[3,] 3 6 9 12
, , 2
[,1] [,2] [,3] [,4]
[1,] 13 16 19 22
[2,] 14 17 20 23
[3,] 15 18 21 24
X に対して、1つ目の次元のインデックスが同じ要素を全て足し合わせるコードを書くとします。
C 言語的に書くと次のようになるでしょう。
> d <- dim(a)
> sum <- numeric(d[1]) # sum という配列を初期化
> for (i in seq_len(d[1])) {
+ for (j in seq_len(d[2])) {
+ for (k in seq_len(d[3])) {
+ sum[i] <- sum[i] + a[i, j, k]
+ }
+ }
+ }
> sum
[1] 92 100 108
ただ、R では sum という関数が用意されていますし、インデックスを指定しない次元はその次元に関して全ての要素が抽出されるので、次のように書けます。
> d <- dim(a)
> sum <- numeric(d[1]) # sum という配列を初期化
> for (i in seq_len(d[1])) {
+ sum[i] <- sum[i] + sum(a[i, , ])
+ }
> sum
[1] 92 100 108
ここで注目すべきは、a の1番目の次元に対してのみインデックスを指定して抽出した要素に対して sum という関数を適用していることです。
この1番目というのが apply の MARGIN に対応し、sum が FUN に対応します。
よって、apply で書くと次のようになります。
> apply(a, 1, sum)
[1] 92 100 108
もう1つ例を挙げてみます。次は、1つ目の次元と3つ目の次元のインデックスが同じ要素を足し合わせるコードを書くとします。
C 言語的に書くと次のようになります。
> d <- dim(a)
> sum <- matrix(0, d[1], d[3]) # sum という行列を初期化
> for (i in seq_len(d[1])) {
+ for (j in seq_len(d[2])) {
+ for (k in seq_len(d[3])) {
+ sum[i, k] <- sum[i, k] + a[i, j, k]
+ }
+ }
+ }
> sum
[,1] [,2]
[1,] 22 70
[2,] 26 74
[3,] 30 78
R 的に書くと次のようになります。
> d <- dim(a)
> sum <- matrix(0, d[1], d[3]) # sum という行列を初期化
> for (i in seq_len(d[1])) {
+ for (k in seq_len(d[3])) {
+ sum[i, k] <- sum[i, k] + sum(a[i, , k])
+ }
+ }
> sum
[,1] [,2]
[1,] 22 70
[2,] 26 74
[3,] 30 78
for 文では a の1番目と3番目の次元に対してインデックスを指定しているので、apply の MARGIN は1と3になります。関数として sum を使っているので sum が FUN です。
よって、apply で書くと次のようになります。
> apply(a, c(1, 3), sum)
[,1] [,2]
[1,] 22 70
[2,] 26 74
[3,] 30 78
とにかく覚えていただきたいのは、apply は MARGIN で指定した次元のインデックスを変化させて繰り返し処理を行うということです。
for 文を使うよりも apply を使った方がすっきり書くことができるので、なんか R を使いこなしてる感がありますよね!
ただ、apply は面倒な処理をブラックボックス的にやってくれるのですっきり書くことができますが、その分最適な形で書いた for 文よりは遅くなります。
apply を使うことで高速化に繋がると勘違いしている人もチラホラいるので要注意です。2と、昔の自分に言ってやりたいです。
練習問題
適当に練習問題を作ってみました。理解の助けになれば幸いです。解答は一番最後に載せておきました。
練習問題1
次のコードは2つ目と3つ目の次元のインデックスが同じ要素の最小値と最大値を算出するものです。
このコードを apply を使って書き換えてみてください。
a <- array(1:24, c(3, 4, 2))
d <- dim(a)
ans <- array(0, c(2, 4, 2))
for (j in seq_len(d[2])) {
for (k in seq_len(d[3])) {
ans[, j, k] <- range(a[, j, k])
}
}
練習問題2
次のコードは1つ目の次元のインデックスが同じ要素(行列)の列ごとの総和と行ごとの総和を算出するものです。
このコードを for 文を使って書き換えてみてください。
a <- array(1:24, c(3, 4, 2))
apply(a, 1, function(x) {
return(list(colsums = colSums(x), rowsums = rowSums(x)))
})
apply を読み解く
apply の挙動について理解するには、関数の定義を確認するのが一番手っ取り早いでしょう。
本質的な処理とは関係ないなぁと思った処理を省いた上で少し書き換えると apply 関数は次のような関数です。
apply <- function (X, MARGIN, FUN, ...) {
# 諸々初期化
dl <- length(dim(X))
d <- dim(X)
ds <- seq_len(dl)
s.call <- ds[-MARGIN]
s.ans <- ds[MARGIN]
d.call <- d[-MARGIN]
d.ans <- d[MARGIN]
# イメージとしては for (i in seq_len(dim(X)[MARGIN[1]]) for (i in seq_len(dim(X)[MARGIN[2]]) ...
# なので prod(dim(X)[MARGIN])、つまり d2 だけ繰り返しが発生する
d2 <- prod(d.ans)
# 繰り返し処理の必要なものは後の次元に移動させる
newX <- aperm(X, c(s.call, s.ans))
# newX を2次元に変換する。これによって各列の要素に対して FUN を適用することになる
dim(newX) <- c(prod(d.call), d2)
# FUN の結果は ans (長さ d2 の list) に格納する
ans <- vector("list", d2)
if (length(d.call) < 2L) {
# 指定されてない次元が1以下の場合の処理。つまり抽出した要素がベクトルになる場合
for (i in 1L:d2) {
# 列に対して FUN を適用
tmp <- FUN(newX[, i], ...)
if (!is.null(tmp))
ans[[i]] <- tmp
}
} else {
# 指定されてない次元が2以上の場合の処理。つまり抽出した要素が行列や配列になる場合
for (i in 1L:d2) {
# 列の要素を行列や配列に戻した上で FUN を適用
tmp <- FUN(array(newX[, i], d.call), ...)
if (!is.null(tmp))
ans[[i]] <- tmp
}
}
# FUN の返す値は list-like な値かどうか?
ans.list <- is.recursive(ans[[1L]])
l.ans <- length(ans[[1L]])
# FUN の結果が list-like でも長さが同じだったら ans.list は FALSE にする
if (!ans.list)
ans.list <- any(unlist(lapply(ans, length)) != l.ans)
if (ans.list) {
len.a <- d2
} else {
# ans を書き換えるのでメモリのコピーが発生
ans <- unlist(ans, recursive = FALSE)
len.a <- length(ans)
}
# FUN の返す値が list-like だったり長さ1のベクトルを要素とするリストだった場合
if (len.a == d2) {
if (length(MARGIN) == 1L) {
return(ans)
} else {
# array に変換するのでメモリのコピーが発生
return(array(ans, d.ans))
}
}
# FUN の返す値が長さ一定のベクトル(行列や配列はベクトルになる)の場合
if (len.a && len.a %% d2 == 0L) {
# array に変換するのでメモリのコピーが発生
return(array(ans, c(len.a %/% d2, d.ans)))
}
# どれにも属さない場合は list のまま(ここには到達しない気がする・・・)
return(ans)
}
ヘタな書き方をするよりも高速になるポイントは ans という list を初期化した上で FUN の結果を格納しているところです。
繰り返し数は普通に for 文を書く場合と変わりません。
例えば次のような例はその差が顕著です。
> system.time({ ans <- c(); for(i in 1:100000) ans[i] <- i })
user system elapsed
19.708 1.295 21.010
> system.time({ ans <- vector("list", 100000); for(i in 1:100000) ans[[i]] <- i; ans <- unlist(ans) })
user system elapsed
0.238 0.005 0.243
前者はループごとにメモリを確保するので遅いですが、後者は一度にメモリを確保しているので高速です。
apply の場合は FUN がどのような値を返すか事前にわからないので list を使いますが、予め最終結果の型がわかっているのであればその型で初期化した方が高速です。unlist によるメモリのコピーも発生しませんし。
> system.time({ ans <- numeric(100000); for(i in 1:100000) ans[i] <- i })
user system elapsed
0.204 0.002 0.206
まとめ
以上、apply について解説しました。
とりあえず次の2点さえ押さえておけば apply 関数に関しては OK かと思います!
- apply は MARGIN で指定した次元のインデックスを変化させて繰り返し処理を行う
- apply はすっきりした書き方ができるが、余計な処理をしている分最適な形で書いた for 文よりも遅くなる
練習問題解答
練習問題1
a <- array(1:24, c(3, 4, 2))
apply(a, c(2, 3), range)
練習問題2
a <- array(1:24, c(3, 4, 2))
d <- dim(a)
ans <- vector("list", d[1])
for (i in seq_len(d[1])) {
x <- a[i, , ]
ans[[i]] <- list(colsums = colSums(x), rowsums = rowSums(x))
}