データフレームの行ごとに処理をするRコードをどう書くか in 2021

 特に趣味とかもないもので、せっかくの日曜の朝なのに、仕事の都合で書いたコードを見直していた次第である。つまらない人生だ。
 で、自分が書いたRコードを眺めていてふと思ったんだけど、「ここどう書こうかな…」と考えあぐねることが多い場面のひとつとして、「データフレームの行ごとに処理をする」場面があると思う。

 他の方々はどうなのかわからないけれど、私ぐらいの素人になると、仕事の中で書くデータ処理のコードというのは、いくつかの定型的な処理の組み合わせに過ぎないことが多い。ここでいう定型的処理とは、たとえば「ある個体変量行列を変数Zで層別し、変数X1, X2, …のそれぞれについて、各変数の値が集合Cに含まれる割合を層別に求める」というような粒度の処理を想定しております。
 自分の書いたコードを見直していると、そういう定型的処理と、それに対応するRコードのテンプレートのようなものが、自分の心の中で一対一に対応しているような気がする。さっきの例に対応するRコードを思い浮かべると、えーと、pivot_longer()でX1, X2, …を縦にして→値がCに含まれるかどうかのフラグを立てて→変数名と層別変数でgroup_byしてフラグの平均をとって→pivot_wider()でX1, X2, …を横にする、だな。summarize_at()やacross()は使わない。これは良し悪しの問題ではなくて、自分の中でそのように固定している、ということである。
 この一対一対応が利用できる限りにおいて、私はぼーっとしながらコードを書いている。普段からぼけーっとした間抜け面しておりますが、それは仕事のせいで仕方なくそうしているだけなんです。ええ。

 いっぽう、こうした対応が心の中に成立していなくて、自分のなかで書き方が決まっていない場面というのは、いささかの集中力が必要な場面であり、ミスが多くなる場面でもある。困る。
 その代表例である「データフレームの行ごとに処理をする」場面について、いろいろな書き方を洗い出し、自分のなかでのスタンダードを決めておきたい。

 というわけで、以下は自分のための覚え書きであります。参考にするのは、tidyverseの神・Hadleyさんによるdplyrパッケージのvignette”Row-wise operations“。

 神様いわく、行ごとの処理が必要となるよくある場面が3つある。

  • A. 行ごとに集計するとき。たとえば、x, y, zの平均を求めるとか。
  • B. ある関数を、引数を変えながら何度もコールするとき。
  • C. リスト列を扱うとき。リスト列とは最近のdplyrに特有な話で、データフレームのある列が、そのデータフレームの行数を長さとするベクトルではなくて、行数を要素数とするリストになっていることを指す。

なるほど。

ループを回す
 まず、明示的にループを回す、という非常にベタな方法がありえますね。こんな感じ:

# 場面A
dfIn <- data.frame(x = 11:13, y = 101:103, z = 1001:1003, dummy = NA)
agOut <- rep(NA, nrow(dfIn))
for (i in seq_len(nrow(dfIn))){
  agOut[i] <- sum(as.matrix(dfIn[i, c("x", "y", "z")]))
}
# 場面B
dfIn <- data.frame(size = c(10, 100, 1000))
lOut <- list(rep(c(), nrow(dfIn)))
for (i in seq_len(nrow(dfIn))){
  lOut[[i]] <- runif(dfIn$size[i])
}
# 場面C
lIn <- list(1, 2:3, 4:6)
anOut <- rep(NA, length(lIn))
for (i in seq_along(lIn)){
  anOut[i] <- length(lIn[[i]])
}

 いま試しに書いてみて思ったんだけど、こういう書き方って、ほとんどやったことがないな... なんというか、別のプログラミング言語に手を出したようで新鮮であった。気分転換にはいいかもしれないが、効率は非常に悪そうだ。

apply系関数を使う
 クラシカルな書き方ではこれが王道ではないかと思う。私はapply(), lapply(), sapply(), tapply()をよく使っています。mapply()は実戦では一度も使ったことがない...

# 場面A
dfIn <- data.frame(x = 11:13, y = 101:103, z = 1001:1003, dummy = NA)
agOut <- apply(dfIn[, c("x", "y", "z")], 1, sum)
# 場面B
dfIn <- data.frame(size = c(10, 100, 1000))
lOut <- lapply(dfIn$size, runif)
# 場面C
lIn <- list(1, 2:3, 4:6)
anOut <- sapply(lIn, length)

上の例に限って言えば、場面AではrowSums()を使った方が早いし、場面Cではlengths()を使った方が早いが、話の都合上apply系の関数で書いてみた。

purrr::map系関数を使う
 tidyverse流に書く場合、行ごとの処理についてはいろいろ変遷があったと思うんだけど、最近は、「これからpurrr::pmap系関数を使うのがトレンドだぜ」的な解説を目にすることが多かった。私も気が弱いもんで、時流に乗り遅れまいと試行錯誤していたのです...

library(tidyverse)
# 場面A
dfIn <- data.frame(x = 1:3, y = 11:13, z = 101:103, dummy = NA)
dfOut <- dfIn %>%
  mutate(gSum = pmap_dbl(.l = dplyr::select(., -dummy), .f = function(...) sum(...)))
# 場面B
dfIn <- data.frame(size = c(10, 100, 1000))
subfun <- function(size) runif(size)
dfOut <- dfIn %>% 
  mutate(lOut = pmap(.l = ., .f = subfun))
# 場面C
dfIn <- tibble(lIn = list(1, 2:3, 4:6))
subfun <- function(x) length(x)
dfOut <- dfIn %>%
  mutate(nOut = map_int(.x = lIn, .f = subfun))

 pmap系関数の内側はもっと簡単に書けるけど、なにが起きているかを自分で納得するためにくどく書いた。
 正直なところ、map系関数の使い方がいまだに腑に落ちずにいる。上の場面Cの場合、subfunの引数名xは別に他の名前でもよい。ところが場面Bの場合、subfunの引数名sizeは、pmap()で.lに与えたリスト(ここではdfInがリストとみなされている)の要素名と一致していないといけないのだ。でもさ、subfunの中身がどうなっているかなんて、本来pmap()の知ったことじゃないはずじゃあありませんか? ううう、なんだか気持ち悪い...

dplyr::rowwise()を使う
実はdplyrパッケージには以前からrowwise()というのが用意されていたんだけど、Hadley神の心変わりにより過去の遺物とみなされていた。ところがどっこい、近年のHadley神の再度の心変わりにより、rowwise()はふたたび現役選手となった。びっくり。

library(tidyverse)
# 場面A
dfIn <- data.frame(x = 1:3, y = 11:13, z = 101:103, dummy = NA)
dfOut <- dfIn %>%
  rowwise() %>%
  mutate(gSum = sum(c_across(-dummy))) %>%
  ungroup()
# ま、以下のほうが効率的だけどね
# dfOut <- dfIn %>%
#   mutate(gSum = rowSums(across(-dummy)) )
# 場面B
dfIn <- data.frame(size = c(10, 100, 1000))
dfOut <- dfIn %>%
  rowwise() %>%
  mutate(lOut = list(runif(size))) %>%
  ungroup()
# 場面C
dfIn <- tibble(lIn = list(1, 2:3, 4:6))
dfOut <- dfIn %>%
  rowwise() %>%
  mutate(nOut = length(lIn)) %>%
  ungroup()

 いちいちungroup()しているのは私の趣味です。データフレームがgrouped_dfになっているのを失念したせいでなんども痛い目に遭ったので、group_by()のあとでは必ずungroup()する癖があるのです。rowwise()やnest_by()はgrouped_dfではなくrowwise_dfというクラスのデータフレームを返すのだが、その解除にもungroup()を用いる由。
 rowwise()は1行ごとにgroup_by()するのとは意味合いが違う。場面Cでは、変数 nOut は上から1, 2, 3となる(各行の 変数lIn の値の長さ)。もしrowwise()じゃなくて、1行ごとにgroup_by()していたら、変数 nOut はすべて1になるはずである(各グループにおける変数 lIn の長さ)。

 こうして振り返ると、行ごとの処理の際の私の書き方は、apply系関数が8割、purrrが2割、ってところだったような気がする。細かくいうと、場面Aならクラシカルにapply系関数、ないしrowSums()かrowMeans()。場面Bならlapply()の内側で毎回同じ構造のtibbleを返して最後にbind_rows()。場面Cならpurrr::map系関数。というのが多かったかな。
 神の変心に振り回されているような気もするけれど、場面AでrowSums()とかrowMeans()とかが使える場合を別にして、どの場面についてもrowwise()に統一しちゃうのが楽かもしれないなあ...