purrrを使ってみる

Rのパッケージpurrrは、リスト/ベクトルに対して反復処理を行う関数を提供するパッケージである。 これどう呼ぶのが正しいのだろうか。ひとまず「ぴゅるるる」と呼ぼう。

Rでループ処理を行うときは、処理速度が極端に遅いため極力forを使わないようにすべしというのは、ある程度Rを使っている方であればご存知かと思う。 他にはapplyもあるが、個人的にあまり使いやすさを感じたことはない。 purrrは、速度そして可読性の両方を兼ね備えたパッケージである。

基本的な使い方

purrrのメインとなる関数はpurrr::mapである。purrr::map(x, f)として、リスト/ベクトル/行列/データフレームxに対して関数fを繰り返し作用させ、計算結果をリストに格納する。 基本的にはx %>% purrr::map(f)のように、リスト/ベクトルを渡して関数で処理するというパイプラインにするのが一般的かと思う。

例えば、標準正規分布から10個の乱数を取ってきて、それらを5つのリストに格納するコードは以下のようにかける。

library(magrittr)
1:5 %>% purrr::map(rnorm, n = 5)
[[1]]
[1]  1.2644874 -0.1058268  1.1207076  0.9976939  0.1906772

[[2]]
[1] 0.3033158 1.4178929 2.4092054 1.6336716 2.7202050

[[3]]
[1] 2.293005 3.507343 3.918410 2.050489 2.193373

[[4]]
[1] 3.697953 4.015712 5.237348 4.090736 4.329179

[[5]]
[1] 6.763691 4.920744 4.854612 4.336114 5.076095

私の場合、このpurrrを使うシチュエーションは主に2つある。

1. 同じグラフをそれぞれのカテゴリー/グループに対してサクッと作成し、サクッと取り出せるようにしたいとき

1つ目は、同じ種類のグラフ (ヒストグラム、散布図など) を、違うカテゴリー/グループごとに作成したいときである。 ggplot2facet_gridなどを使って、1つの図にまとめる方法もあるが、カテゴリーの数が多すぎるとグラフが小さくなり問題である。 自分の好きなカテゴリーのグラフを手軽に取り出したいときもある。

そこでpurrrの出番である。 irisデータを用いて、アヤメの種類ごとガクの長さ (sepal length) のヒストグラムを作成することを考える。

library(ggplot2)

draw_hist <- function(df, column) {
  g <- ggplot(data = df, aes(x = !!dplyr::enquo(column))) + geom_histogram()
  return(g)
}

hist <- 
  iris %>%
  split(.$Species) %>%
  purrr::map(draw_hist, column = Sepal.Length)

こうすることで、それぞれのアヤメの種類ごとにガクの長さを、例えばhist$setosaのようにして、すぐにsetosaのヒストグラムを呼び出すことができる。

もっというと、irisをlong形式に変換し、各カテゴリー・各変数ごとにsplitしてpurrr::mapしてしまえば、全てのグループと変数の組み合わせについてヒストグラムを作成することができる。

hist_all <- 
  tibble::rowid_to_column(iris, var = "id") %>%
  tidyr::pivot_longer(-c(id, Species), 
                      names_to = "variable", values_to = "value") %>%
  split(list(.$Species, .$variable)) %>%
  purrr::map(draw_hist, column = value)

hist_all$setosa.Petal.Width

データサイズが大きくなったり、分割するカテゴリーが増えたりするとメモリー不足にならないか心配であるが、とりあえずこれまではうまく動いている。

ちなみに上の自作関数の中にある!!dplyr::enquoはnon-standard evaluation (NSE) と呼ばれる黒魔術で、今は無視してほしい。*1

2. 違うデータフレームを続けて結合したいとき

2つ目は、あるデータセットに対して、複数の異なるデータセットを次々と結合させたいときである。 例えば、上で使用したアヤメのデータセットが、アヤメの種類 (Species) 、ガクの長さ (Sepal.Length) 、ガクの幅 (Sepal.Width) 、花弁の長さ (Petal.Length )、花弁の幅 (Petal.Width) の5つに分割されており、これらは全てあるid変数で結合できるとしよう。

df_iris <- tibble::rowid_to_column(iris, var = "id")
df_species <- dplyr::select(df_iris, c(id, Species))
df_sepallength <- dplyr::select(df_iris, c(id, Sepal.Length))
df_sepalwidth <- dplyr::select(df_iris, c(id, Sepal.Width))
df_petallength <- dplyr::select(df_iris, c(id, Petal.Length))
df_petalwidth <- dplyr::select(df_iris, c(id, Petal.Width))

(このコードもpurrrでスマートに書けそうな気がするが、ひとまず力技で押し通した)

これらを元通りに結合するとき、1つのやり方は素直にdplyr::left_joinを繰り返し行うことだろう。

df <- 
  dplyr::left_join(df_species, df_sepallength, by = "id") %>%
  dplyr::left_join(., df_sepalwidth, by = "id") %>%
  dplyr::left_join(., df_petallength, by = "id") %>%
  dplyr::left_join(., df_petalwidth, by = "id")
head(df)
   id Species Sepal.Length Sepal.Width Petal.Length Petal.Width
1  1  setosa          5.1         3.5          1.4         0.2
2  2  setosa          4.9         3.0          1.4         0.2
3  3  setosa          4.7         3.2          1.3         0.2
4  4  setosa          4.6         3.1          1.5         0.2
5  5  setosa          5.0         3.6          1.4         0.2
6  6  setosa          5.4         3.9          1.7         0.4

ただ、コードとしては冗長な感は否めない。どのデータフレームをjoinしたか、混乱することもしばしばある。

私も最近知ったのだが、これはpurrr::reduceを用いるとスッキリと書くことができる。

df <- 
  list(df_species, 
       df_sepallength, 
       df_sepalwidth, 
       df_petallength, 
       df_petalwidth) %>%
  purrr::reduce(dplyr::left_join, by = "id")
head(df)
  id Species Sepal.Length Sepal.Width Petal.Length Petal.Width
1  1  setosa          5.1         3.5          1.4         0.2
2  2  setosa          4.9         3.0          1.4         0.2
3  3  setosa          4.7         3.2          1.3         0.2
4  4  setosa          4.6         3.1          1.5         0.2
5  5  setosa          5.0         3.6          1.4         0.2
6  6  setosa          5.4         3.9          1.7         0.4

繰り返しにはforeachもよい?

forは使うなと冒頭で述べたが、私はちょくちょくforeachを使う。並列処理もforeachで行うことができ、便利である。

標準正規分布から10個の乱数を取ってきて、それらを5つのリストに格納するコードは、foreachを用いて以下のようにも書ける。

library(foreach)
foreach(i = 1:5) %do% {
      rnorm(n = 10)
    }

が、purrrを用いた方が処理速度は速い。それはそうか。

rbenchmark::benchmark(
  use_purrr = {
    1:100 %>% purrr::map(rnorm, n = 1000)
  },
  use_foreach = {
    foreach(i = 1:100) %do% {
      rnorm(n = 1000)
    }
  }
)
         test replications elapsed relative user.self sys.self 
2 use_foreach          100   2.959    3.595     2.935    0.014
1   use_purrr          100   0.823    1.000     0.810    0.011

foreachについてはまた別のポストで書いてみたい。 特に並列処理を行うとき、なぜnamespace::functionと普段から書いておいたほうが良いかがわかる。

*1:いつも雰囲気で何とかしている。