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 に対応し、sumFUN に対応します。
よって、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 を使っているので sumFUN です。
よって、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))
}
  1. もちろん for 文を使った方が良い場面・使わないといけない場面もありますよ! 

  2. ヘタな書き方をするよりは速くなりますが・・・