Rユーザの皆さん、rlangパッケージないしtidy evaluation (tidy eval)についてどれだけご存知でしょうか。rlangパッケージは昨日バージョン0.4.2がリリースされました。まだ1.0.0には至ってはいませんが、CRANに登録されて2年以上経つので、本腰を入れて学んでいきたいと思っているところです。
今回から数回、そんな私自身のrlang、rlangによるtidy evalの学習ついでに、やんわりとした解説をしようという試みで記事を書きます。本来であれば用語の定義や背景についての解説をしなければならないと思います。しかしここでは、まずは、tidy evalを学ぶことでどのようなことが可能になるのか、rlangパッケージを使うとどのような利点があるのか、その雰囲気の理解に重きを置きます。詳細を知りたくなった方はぜひドキュメントや参考資料を読んでください。
私もまだ道半ばなので解説はなし
の精神1です。
シリーズの予定です。
- tidy evaluationの導入(rlangパッケージを使う動機付け)
- tidyverseと一緒に覚えるrlang
- rlang, tidy evaluationを理解するための用語
今日は初回なので、このシリーズの主役となるtidy eval、{rlang}の導入を行います。はじめに、tidy evalが解決しようとするRの処理の問題を紹介し、rlangによる解決策を示します。
rlangないしtidy evalを学ぶことのモチベーションは
- 関数にggplot2, dplyrやtidyrの関数を取り入れたい
- 関数の汎用性を高めたい
この2点が強いと感じます。この記事が皆さんのtidy evaluationを学ぶきっかけとなれば幸いです。一緒に勉強していきましょう。
NSEのジレンマ: dplyrパッケージの関数が苦手な処理
Rにはsubset()
やlm()
など、対象のデータフレームとその変数を引数内で指定して実行する関数がいくつかあります。
# 結果は省略します
lm(Sepal.Length ~ Petal.Width + Species, data = iris)
subset(iris, Species == "setosa", select = c(Sepal.Length, Species))
with(iris, toupper(Species))
これらの関数内でデータフレームの変数を参照する際、引用符で囲わずに直接変数名を与えることができる仕組みを非標準評価 (Non Standard Evaluation: NSE) と呼びます。
この非標準評価を行う関数は{dplyr}, {tidyr}, {ggplot2}でお馴染みです(dplyrパッケージの主要な関数の末尾に_
をつける標準評価版の関数が用意されていますが、これらは廃止される予定です2)。tidyverse界隈の関数では標準と言えます。
library(dplyr)
iris %>%
filter(Species == "setosa") %>%
select(Sepal.Length, Species)
NSEは便利で直感的に使うことができますが、関数の外側で指定された処理が正しく評価できないという問題を抱えています。一つ例を見てみましょう。
library(tibble)
set.seed(123)
df_upper <-
tibble(
X = letters[seq_len(3)],
Y = rnorm(3))
df_lower <-
df_upper %>%
janitor::clean_names()
list(df_upper,
df_lower) %>%
purrr::map(names)
#> [[1]]
#> [1] "X" "Y"
#>
#> [[2]]
#> [1] "x" "y"
XとYを変数にもつデータフレームを用意しました。片方のデータフレームでは変数名が大文字、もう片方は小文字となるようにしておきます。
このデータからX == "a" の行を抽出できるようにしたいので次のコードを書きました。
target_var <- "X"
subset(df_upper, target_var == "a")
#> # A tibble: 0 x 2
#> # … with 2 variables: X <chr>, Y <dbl>
NSEで実行される関数内では、抽出条件の変数名を引用符なしで与える必要がありますが、 target_varは引用符で囲んだ "X" です。そのため正しく変数の指定ができていません。これはdplyr::filter()
でも同様です。
df_upper %>%
filter(target_var == "a")
#> # A tibble: 0 x 2
#> # … with 2 variables: X <chr>, Y <dbl>
そこでtarget_var に引用符をつけない X を指定しようと試みます。
target_var <- X
#> Error: object 'X' not found
おや、オブジェクトXが定義されていないためにエラーが起こりました。これはRでは遅延評価が行われるので、target_varにXを代入しようとした段階でオブジェクトXが不在だったために起こるエラーです。
あとで参照するからといって、環境中に定義されていないオブジェクトを参照することはできないのです。target_var には引用符なしで文字列を与えたい、一方でオブジェクトを作りたくはない。NSEの関数の処理をその関数内で完結しないようにするとこのようなジレンマが発生します。
対象の変数名を関数の外部で指定しておくにはどうすれば良いでしょう?一つの答えが quote()
で評価されない状態を維持したままの値を target_var に保存し、dplyr::filter()
の実行時に評価することです。具体的には以下のようになります。
target_var <- quote(X)
target_var
# X
df_upper %>%
filter(eval(target_var) == "a")
#> # A tibble: 1 x 2
#> X Y
#> <chr> <dbl>
#> 1 a -0.560
自作関数内でNSEの関数を利用する
もう一つの問題は関数内部でNSEの関数を使う場合に生じます。例えば、以下の2つのデータフレームの変数Y, yに対し、絶対値への変換 abs()
を適用したいとします。
df_upper
#> # A tibble: 3 x 2
#> X Y
#> <chr> <dbl>
#> 1 a -0.560
#> 2 b -0.230
#> 3 c 1.56
df_lower
#> # A tibble: 3 x 2
#> x y
#> <chr> <dbl>
#> 1 a -0.560
#> 2 b -0.230
#> 3 c 1.56
パッと思いつく関数は以下のようなものでしょうか。しかしこれはデータフレームの変数名が大文字のYであれば良いですが、小文字のyでは失敗します。dplyr::rename()
等で変数名をあらかじめ変えておくのも手ですが、それは面倒です。
f <- function(df, Y) {
df %>%
dplyr::mutate(Y = abs(Y))
}
f(df_upper, Y)
#> # A tibble: 3 x 2
#> X Y
#> <chr> <dbl>
#> 1 a 0.560
#> 2 b 0.230
#> 3 c 1.56
f(df_lower, x)
#> Error: object 'x' not found
ではこれでどうだと、変数名を引数に与えた次の関数も失敗します。dplyr::mutate()
はNSEを行う関数ですが、ここで書いた f()
の引数varは、先述の理由によりデータフレームの変数として評価されないためです。
f <- function(df, var) {
df %>%
dplyr::mutate(var = abs(var))
}
f(df_upper, Y)
#> Error: object 'Y' not found
先の問題ではNSEの関数に事前に用意した表現を未評価の状態で与えるには quote()
を使って、 eval()
で評価するのでした。
var_q <- quote(Y)
df_upper %>%
mutate(var = abs(eval(var_q)))
むむ。abs(Y)
は適用されましたが変数名が上書きされずに新たな変数を作ってしまいました。新たな問題が生まれましたが、一旦これを関数にも適用してみましょう。
f <- function(df, var) {
var_q <- quote(var)
df %>%
dplyr::mutate(var = abs(eval(var_q)))
}
f(df_upper, Y)
#> Error in eval(var_q) : object 'Y' not found
おお、今度はうまくいきません。なぜでしょう。この問題を探るためには「環境」を理解する必要がありますが、今は深追いしません。
この関数を実装するには quote()
の代わりに substitute()
を用います(変数名の課題は残ります)。
f <- function(df, var) {
var_q <- substitute(var)
df %>%
dplyr::mutate(var = abs(eval(var_q)))
}
f(df_upper, Y)
#> # A tibble: 3 x 3
#> X Y var
#> <chr> <dbl> <dbl>
#> 1 a -0.560 0.560
#> 2 b -0.230 0.230
#> 3 c 1.56 1.56
NSEの関数を自作関数で使うには一筋縄ではいきません。そこでrlangパッケージの登場です。
rlangでNSEの課題を解決
NSEの関数を使った処理の問題の根本は、関数の外側と内側とで、表現式が評価される環境が異なることです。それを解決するには、いい感じに未評価の状態を作り、よしなに環境を用意してくれる処理が求められます(ざっくり)。それを実現しようというのがrlangパッケージの目的の一つです。
と言うわけで、ここまでに見てきた2つの例をrlangで処理しましょう。まずはdplyr::filter()
の例です。
library(rlang)
# quo(), !!演算子はrlangパッケージが提供するものです
target_var <- quo(X)
df_upper %>%
filter(!!target_var == "a")
#> # A tibble: 1 x 2
#> X Y
#> <chr> <dbl>
#> 1 a -0.560
quote()
、eval()
の代わりに quo()
、 !!
を使うイメージです。ついでにこの処理の関数化を試みましょう。
f <- function(df, var) {
target_var <- rlang::enquo(var)
df %>%
dplyr::filter(!!target_var == "a")
}
f(df_upper, X)
#> # A tibble: 1 x 2
#> X Y
#> <chr> <dbl>
#> 1 a -0.560
f(df_lower, x)
#> # A tibble: 1 x 2
#> X Y
#> <chr> <dbl>
#> 1 a -0.560
rlangを用いると変数名を変更することなく関数化できました。これは dplyr::mutate()
の処理にも応用可能です。
f <- function(df, var) {
var <- rlang::enquo(var)
var_name <- rlang::as_label(var)
df %>%
dplyr::mutate(!!var_name := abs(!!var))
}
f(df_upper, Y)
#> # A tibble: 3 x 2
#> X Y
#> <chr> <dbl>
#> 1 a 0.560
#> 2 b 0.230
#> 3 c 1.56
f(df_lower, y)
#> # A tibble: 3 x 2
#> X Y
#> <chr> <dbl>
#> 1 a 0.560
#> 2 b 0.230
#> 3 c 1.56
実装した関数はNSEのように振る舞い、面倒だったNSEを利用した関数の処理をシンプルに解消しています。
quo()
ではなくenquo()
を使っている点や、新しい関数as_label()
については次回、実用的な処理をrlangパッケージの関数を使って実装していくなかで紹介できればと思います。
Enjoy!
参考資料
- NSEとは何か - Qiita https://qiita.com/kohske/items/7dbef6ae3ff34c093ce4
- dplyr再入門(Tidyeval編) https://speakerdeck.com/yutannihilation/dplyrzai-ru-men-tidyevalbian
- Tidy eval in context https://speakerdeck.com/jennybc/tidy-eval-in-context
- Tidy evaluation is one of the major feature of the latest versions of dplyr and tidyr. https://resources.rstudio.com/webinars/tidyeval
-
参考) teramonagiさんのブログ記事 https://teramonagi.hatenablog.com/entry/2018/11/03/225635 ↩
-
参考) メモ:dplyrがStandard evaluationをdeprecatedにしようとしている理由 - Technically, technophobic. https://notchained.hatenablog.com/entry/2017/03/24/225154 ↩