霞と側杖を食らう

ほしいものです。なにかいただけるとしあわせです。[https://www.amazon.jp/hz/wishlist/ls/2EIEFTV4IKSIJ?ref_=wl_share]

Rでメモリに乗らないデータを分割して読み込んで処理する方法の学習記録

【学習動機】

Rでメモリに乗らないような大規模データを読み込んで処理するとき, どうすべきかで悩んだ. 昔, 福島『Rによるハイパフォーマンスコンピューティング』

www.amazon.co.jp

は読んだが, 今の状況と違うかもしれない. 最新のプラクティス版が出たら嬉しいところだが, そういう議論が少ないのはメモリを買って解決しているところが多くて需要が減っているからだろうか.

さて, {ff}パッケージと{ffbase}パッケージ, {bigmemory}パッケージなどあるかもしれないが, 少し扱いにくい.

最初の段階ではデータ全体を使わなくてもよくて, メモリに乗るサイズに分割して, それぞれ繰り返し処理してしまえば良いというケースであれば, 全てをメモリに乗せる必要はない. 分割して読み込む方法を考える. 

分割して読み込む方法は以下の記事に書いてあった. 

メモリに乗らないデータを読み込むパッケージの備忘録 - 盆栽日記

メモリに乗らないデータを分割して読み込んでくれるパッケージ - 盆栽日記

しかし, ここに書かれている{readr}パッケージのread_csv_chunked関数で引数chunk_sizeの指定では上からchunk_sizeずつ読み込む仕組みになっている. データのどこで切れてもいいのなら問題ないが, 個票データなどでkeyとなるIDなどがあって, 同じIDのデータが分割されたデータに別々引き離されてしまうのは困るということがありうる. そんな場合にどうすればいいのかと思いあぐねていたら, DataFrameCallback$new()を使うと上手くできそうな気がしたので, コードを書いて実験してみた. 

【学習内容】

データを生成してcsvに書き込み, それを分割して読んでいく.

パッケージ読み込みと乱数固定.

library(tidyverse)
set.seed(2023)

データの生成

まずは実験するために, データを生成.
100万人(コード上では記事の出力のため1000人で回している)のデータを作る. idを1~100万でふる.

# data generation
N <- 1000    # 速度のためにここでは1000人にする
#N <- 1000000
id_unique <- 1:N

100万人がそれぞれ1~10個のcostデータを持つことにする. costは適当に0~10000の値を取る. できたデータセットcsvに書き込む(この時点でメモリに乗っているので, 今回のケースは工夫する必要はないのだが)

N_ids <- sample(x = 1:10, size = N, replace = TRUE)
ids <- rep(id_unique, N_ids)

df <- 
  data.frame(
    id = ids
  )
Ndata <- nrow(df)

df <- df %>% 
  mutate(
    cost = floor(runif(n = Ndata, min = 0, max = 10000))
  )

write.csv(df, file = "df.csv", row.names=FALSE)

データの分割読み込み

書き込んだcsvで今回の本題に入る.

ataFrameCallback$new()を使い方の実験として, idが1~10で読んでみる.

# idを1~10で読む実験
idmin <- 1
idmax <- 10
f_subset_id <- function(x, idmin, idamax) subset(x, idmin <= id & id <= idmax)

df_r_1 <- read_csv_chunked("df.csv", DataFrameCallback$new(f_subset_id))
## 
## ── Column specification ────────────────────────────────────────────────────────
## cols(
##   id = col_double(),
##   cost = col_double()
## )
df_r_1
## # A tibble: 41 × 2
##       id  cost
##    <dbl> <dbl>
##  1     1  8482
##  2     1  3566
##  3     1   448
##  4     1  6724
##  5     1  9164
##  6     2  1288
##  7     2  2621
##  8     2  2820
##  9     2  2908
## 10     2  8297
## # … with 31 more rows

こんな感じで繰り返せば上手くできそう.

1分割内のid数と分割数を決めて, 繰り返しで処理.

N_sep_id <- 10    # 1分割内のid数
N_sub <- N/N_sep_id    # 分割数
for(i in 1:N_sub){
  dfname <- paste("df_r", i, sep = "_")
  idmin <- (i-1)*N_sep_id+1
  idmax <- i*N_sep_id
  # 読み込んでそれぞれデータフレームとして保存
  # read_csv_chunked("df.csv", DataFrameCallback$new(f_subset_id))に
  # 関数をかませて処理して小さくしてから保存すればよい
  # 今回は各idでcostの和を出すことにする.
  assign(dfname, 
         read_csv_chunked("df.csv", DataFrameCallback$new(f_subset_id)) %>% 
           group_by(id) %>% summarise(sum_cost=sum(cost))
         )
  # 必要ならばremoveなど使ってメモリを解放させながら回す
}

1~10の和が出ているか確認.

df_r_1
## # A tibble: 10 × 2
##       id sum_cost
##    <dbl>    <dbl>
##  1     1    28384
##  2     2    30784
##  3     3    48126
##  4     4    13931
##  5     5    36720
##  6     6     9083
##  7     7     7054
##  8     8     7566
##  9     9      705
## 10    10     8520

今回のidは数値が上手く並んでいたし, そのことを前もって知っていたが, idがどういう範囲にあるか分からない場合は以下のようにして工夫すれば良さそう.

# idがどういう範囲にあるか分からない場合

df_id <- read_csv("df.csv", col_select = c("id"))
# などとして, idの列のみ読み込んで, 自然数の値と対応させるようにすれば良さそう.
# また, このidの列から, データのサイズやメモリの量とを考えて, N_sep_idなどを決めると良さそう

【学習予定】

今回書いたコードは対処療法的なものである. 普段の書き方をして, 時間かマシンパワーを使って, なんとかできるというという点が良さそうであるが, ベストな方法でない可能性が多分にある. 新しくてクールな方法はいくらでもあると思う(別の何かに繋いでやるとかそういうやつ). ここ4,5年, 最近のRのトピックをあまり追えていないので, もう少し追いたいところではある.

【追記 : 2023/06/18】

duckdbやarrowというものが使えるかもしれないらしい. ここ数年で名前は見かけたことはあるが, 追いかけてないのでネットの海に転がっている資料を眺めたりしようと思う. 

【追記終 : 2023/06/18】