はじめに
こんにちは、事業会社で働いているデータサイエンティストです。
この記事は、最近とあるプロジェクトで直面した R言語の「静かに牙をむく仕様」 についての話です。
まずは背景から説明します。
そのプロジェクトは佳境に差し掛かっており、もともと手元の R 環境で推定していた Stan のベイズモデルを、Google Cloud Run 上で API 化してデプロイすることになりました。
当然ですが、このとき重要になるのが再現性です。
- 手元の R
- 手元マシン上の Docker コンテナ内の R
- Google Cloud Run が実行する R
これらすべてが吐き出す CSV の同一性を担保しなければなりません。同じ入力に対して同じ結果を返せないのであれば、ベイズモデルのビジネスインパクト云々を議論する以前に、プログラミングの品質として失格です。リリースなど論外ですね。これは当たり前の話です。
そこで私は、readr::read_csv() を使って3つの CSV を読み込み、identical() 関数で同一性をチェックしました。
すると──
> identical(a, b)
[1] FALSE
、、、え?
何かがおかしい。
そこからが地獄の始まりでした。
30分以上かけてコードの同一性を再確認(Gitでバージョン管理しているので、理屈の上では問題ないはず)、R本体や各パッケージのバージョンを総点検、最後には、CSV をすべて Excel で開いて目検。
しかし、どこにも差分は見当たりません。
それでも identical() は頑なに FALSE を返し続けます。
そこでさらに調べていった結果、ようやく真犯人が判明しました。
── readr::read_csv()さんです。
今日は、この
「一見すると正しく動いているように見えるが、気づかないうちに地雷を仕込んでくる read_csv の怖い話」
について、じっくり語っていきます。
再現実験
もちろん、会社の実データをお見せすることはできないので、ここではデモ用の CSV を用意します。
tibble::tibble(a = 1:3) |> readr::write_csv("mini_df.csv")
まずは、この CSV を読み込みましょう。
> a <- readr::read_csv("mini_df.csv")
Rows: 3 Columns: 1
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
dbl (1): a
ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
続いて、まったく同じ CSVをもう一度読み込みます。
> b <- readr::read_csv("mini_df.csv")
Rows: 3 Columns: 1
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
dbl (1): a
ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
よーし!同じファイルを、同じ関数で、2回読み込みました。
となれば当然、identical() 関数で同一性を確認しますよね?TRUE が返ってくるに決まっています。
、、、、、、と思うじゃないですか?
ここで少しだけ、R 言語にあまり馴染みのない方のために背景知識を補足しておきます。
R 言語の == 演算子は、オブジェクト全体の比較ではなく、要素ごとの比較を行います。
つまり、「完全に同じオブジェクトか?」を判定したい場合には、identical() を使うのが正解です。
この違いを分かりやすくするために、Python と比較してみましょう。
まずは Python からです:
>>> [1,1,1] == [1,1,2]
False
[1,1,1] と [1,1,2] は違うリストなので、False になります。一方で R はどうなるかというと:
> c(1,1,1) == c(1,1,2)
[1] TRUE TRUE FALSE
このように、要素ごとの比較を行います。この仕様の良し悪しはさておき、オブジェクト全体の比較がしたい場合は identical 関数を利用します:
> identical(c(1,1,1), c(1,1,2))
[1] FALSE
では、まったく同じ CSV から作られたデータフレームなのですから、identical(a, b) の結果は 当然 TRUE になりますよね?
> identical(a, b)
[1] FALSE
ええええええええええええええええええええ!??!?!?!?!?!?!?!!?
rep("え", 1000000000000000) |> stringr::str_c(collapse = "え") |> cat()
(R の叫び)
print("え".join(["え" for i in range(1000000000000000)]))
(Python の叫び)
嘘だろ!!!!!!!!!!
でも、実際 FALSE 出てしまったから、CSV 間違えた可能性がありますね、、、、、、目検確認しますか?
> a
# A tibble: 3 × 1
a
<dbl>
1 1
2 2
3 3
> b
# A tibble: 3 × 1
a
<dbl>
1 1
2 2
3 3
同一すぎるwww
マジで何が起きてんの?
仕方ないから、str 関数で内部構造でも確認しますか?
> str(a)
spc_tbl_ [3 × 1] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
$ a: num [1:3] 1 2 3
- attr(*, "spec")=
.. cols(
.. a = col_double()
.. )
- attr(*, "problems")=<externalptr>
あれ?データフレイムって、こんな構造でしたっけ?
> str(tibble::tibble(a = 1:3))
tibble [3 × 1] (S3: tbl_df/tbl/data.frame)
$ a: int [1:3] 1 2 3
あれ?構造が違うぞ!
ここで、R ユーザー以外の方に向けて、もう一つだけ補足しておきます。
実は R 言語にもオブジェクト指向プログラミングの仕組みがあります。しかも一つではなく、複数あります。正直、Python ユーザーから見るとかなりややこしいです💦
その中で、最もよく使われているのが S3 という仕組みです。
さて、str(a) の出力を見てみると、次のような表記があります:S3: spec_tbl_df/tbl_df/tbl/data.frame。これは何を意味しているかというと、
-
aはdata.frameクラスであり、 - その子クラスが
tbl、 - その孫クラスが
tbl_df、 - そのひ孫クラスが
spec_tbl_df
というクラスの継承関係を表しています。
一方で、str(tibble::tibble(a = 1:3)) の出力をよく見てください。この場合、クラスは tbl_df で止まっています。つまり、こちらは spec_tbl_df クラスではありません。
同じように見えるデータフレイムでも、readr の read_csv で作られたものと、tibble で直接作られたものは、クラス構造が違うのです。
ここまで来ると、読者の頭の中で「じゃあ、その spec_tbl_df って何者なんだ?」という疑問が自然に湧く流れになっています。完璧です。
詳細は readr パッケージのドキュメント(リンク)を参照していただきたいですが、read_csv で作られたデータフレイムの中には、CSV の構文解析時に発生した問題を裏で持っているとの記述があります。もう一度、上の str(a) の出力を見ていただきたいですが:
> str(a)
spc_tbl_ [3 × 1] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
$ a: num [1:3] 1 2 3
- attr(*, "spec")=
.. cols(
.. a = col_double()
.. )
- attr(*, "problems")=<externalptr>
最後に attr(*, "problems")=<externalptr> があって、構文解析時の問題(problems)は外部ポインタ(<externalptr>)に紐づいています。問題は readr::problems 関数で確認できます:
> readr::problems(a)
# A tibble: 0 × 5
# ℹ 5 variables: row <int>, col <int>, expected <chr>, actual <chr>, file <chr>
空のデータフレイムで問題ないですね。すごくシンプルな CSV で作成したから問題がはいはずですね。
ただ、直接 attr 関数でアクセスしようとすると:
> attr(a, "problems")
<pointer: 0x135efaad0>
ポインタのアドレスが表示されています。b も同じように問題ない CSV から作成したデータフレイムなので a と同じ空のデータフレイムが表示されていますが:
> readr::problems(b)
# A tibble: 0 × 5
# ℹ 5 variables: row <int>, col <int>, expected <chr>, actual <chr>, file <chr>
ポインタのアドレスが
> attr(b, "problems")
<pointer: 0x135edd630>
違うんです。
そう、ポインタのアドレスが違うから、identical 関数が a と b の同一性の判断に FALSE を下したんです。
では、3行しかないデータフレイムだったらまだ目検できますが、本番環境に生きている巨大な、数テラバイトのデータフレイムに、私たちはどうやって R でその同一性を担保すればいいでしょうか?ここではいくつかの解決策を紹介します。
解決策
まず、私が実際に業務で使った解決策から説明します。実は、dplyr::mutate を一回噛ませたら、data.frame クラスの孫クラスの spec_tbl_df が消えてくれます:
> a |> dplyr::mutate() |> str()
tibble [3 × 1] (S3: tbl_df/tbl/data.frame)
$ a: num [1:3] 1 2 3
dplyr::mutate はデータフレイムに変更を加えるときに利用する関数です:
> a |> dplyr::mutate(a_plus_one = a + 1)
# A tibble: 3 × 2
a a_plus_one
<dbl> <dbl>
1 1 2
2 2 3
3 3 4
dplyr::mutate の中身に a_plus_one = a + 1 のような既存もしくは新規のカラムに対する定義がなければ、元のデータフレイムが帰ってきます:
> a |> dplyr::mutate()
# A tibble: 3 × 1
a
<dbl>
1 1
2 2
3 3
ただ、a |> dplyr::mutate() |> str() でお見せしたように、spec_tbl_df クラスが剥奪され、
> identical(dplyr::mutate(a), dplyr::mutate(b))
[1] TRUE
の結果が TRUE になってくれます。よかったですー
ではなぜ、readr 関数の開発チームが、こんな設計にしているのでしょうか?以下は私の仮説ですが、おそらく事実から遠くないと思います。
まず、なぜ外部ポインタが使われているのかについて考えてみます。
これはおそらく、デバッグのしやすさを保ちつつ、R のデータフレームをできるだけ軽量に扱うためです。構文解析時の問題を R オブジェクトとして保持すること自体は、設計として十分にあり得ます。しかし、その場合、すべてのデータフレームが余計な情報を抱え込むことになり、結果としてオブジェクトが重くなってしまいます。
そこで readr は、構文解析時の問題を外部ポインタとして保持し、本当に必要になったときだけ読み出すという設計を採用しているのではないかと思われます。これにより、通常のデータ操作では余計なコストを払わずに済むわけです。
次に、なぜ dplyr::mutate() を通すと、構文解析時の問題を保持していたデータフレームでなくなるのかについてです。これも推測になりますが、設計思想としてはおそらく、
すでにデータフレームを加工し始めている以上、
元の CSV の構文解析時の問題は、もはや意味を持たない
という判断なのではないでしょうか。
dplyr::mutate() を実行した時点で、行の順番や列の意味は変わり得ます。その状態で「この行は CSV の読み込み時に問題があった」という情報を保持し続けるのは、むしろ誤解を生む可能性があります。
そのため、dplyr は readr 由来のメタデータを意図的に落とし、通常の tibble に戻す。これは一見不親切に見えますが、設計としてはかなり筋が通っています。
では、dplyr::mutate 以外のやり方についても紹介します。まず、all.equal 関数はどうやら外部ポインタを無視してくれるようです:
> all.equal(a,b)
[1] TRUE
ただ、all.equal 関数は古い Base R の他の関数のように、かなり危険です。all.equal 関数はほぼ同じ(near equality)かどうかという、現代のプログラミングでは考えられないふわっとした定義に基づいた検証をしているので、避けてください。
最後に、readr パッケージの read_csv 関数ではなく、Base R の read.csv 関数を利用する方法もあります:
> c <- read.csv("mini_df.csv")
> d <- read.csv("mini_df.csv")
> identical(c, d)
[1] TRUE
やっぱり Base R がいいですね!と言いたくなるかもしれないですが、Base R が作るデータフレイムは自由かつ勝手にバグを隠すくらい優しい(笑)ので、あまり使わないでください。Base R のデータフレイムについてはこちらの記事を確認していただければと思います:
結論
今回の話をまとめると、起きていたことは決して「R のバグ」でも「readr の欠陥」でもありませんでした。
問題の正体は、
-
readr::read_csv()が
CSV の構文解析時のメタデータをspec_tbl_dfとして保持する - そのメタデータの一部が外部ポインタ(
externalptr)として管理されている - 外部ポインタはアドレスが異なる限り、
identical()では同一と判定されない
という、R のオブジェクトモデルと tidyverse の設計思想が交差した結果でした。
値だけを見れば同じ。見た目も完全に同じ。しかし、オブジェクトとしては同じではない。
これは一見すると直感に反しますが、readr 側の設計を考えれば、実はかなり筋が通っています。
- 巨大な CSV を高速に読むため
- 通常のデータ操作を軽量に保つため
- 構文解析時の問題を「必要なときだけ」参照できるようにするため
そのために、あえて外部ポインタという仕組みが使われているわけです。
そして dplyr::mutate() が spec_tbl_df を剥奪するのも、
もはや元の CSV の構文解析時の情報は意味を持たない
という、これまた合理的な判断に基づいています。
この一件から得られる、実務的な教訓はとてもシンプルです。
- R のオブジェクトは「値」だけでできていない
-
identical()は「中身が同じか」ではなく
完全に同一のオブジェクトかを判定する関数である - tidyverse の関数は、
意図的にメタデータを持ち、意図的にそれを捨てる
という前提を知らないと、本番環境で普通にハマります。
逆に言えば、この前提を理解していれば、「R (tidyverse)は怖い言語」ではなく、よく考えられた、だが容赦のない言語だということが分かるはずです。
R は、黙って危険なことをしてくれる言語ではありません。
ただし、こちらがオブジェクトの正体を理解していないと、静かに牙を剥いてくるだけです。
この記事が、「identical() が FALSE になるのはなぜ?」という素朴な疑問の裏にある、R の設計思想を理解する助けになれば幸いです!
最後に、私たちと一緒に働きたい方はぜひ下記のリンクもご確認ください: