はじめに

複数のログを分析しているとき、共通するフィールドの値の分布が気になることがあります。たとえば、あるIPアドレスはログAとログBとに出てくるが、ログCには出てこない、など。

フィールドとしてsrc_ipしか持たない単純なログのデータフレームで具体的に示しましょう。

dfA <- tibble(src_ip = c("192.168.1.3", "192.168.1.1", "192.168.1.2"))
dfB <- tibble(src_ip = c("192.168.1.5", "192.168.1.3", "192.168.1.4", "192.168.1.5"))
dfC <- tibble(src_ip = c("192.168.1.2", "192.168.1.5", "192.168.1.6"))

と3つのデータフレームがあったとき、src_ipの値を結合して一意にし、それぞれのIPアドレス(ここでは192.168.1.1~192.168.1.6)が各データフレームに存在するかどうかをTRUE/FALSEで表したいのです。今回の場合では、以下の結果を得たいとします。

この表のことを、ここでは星取表(Comparison Table)と呼ぶことにします。

## # A tibble: 6 × 4
##   src_ip      dfA   dfB   dfC 
##   <chr>       <lgl> <lgl> <lgl>
## 1 192.168.1.1 TRUE  FALSE FALSE
## 2 192.168.1.2 TRUE  FALSE TRUE 
## 3 192.168.1.3 TRUE  TRUE  FALSE
## 4 192.168.1.4 FALSE TRUE  FALSE
## 5 192.168.1.5 FALSE TRUE  TRUE 
## 6 192.168.1.6 FALSE FALSE TRUE

パイプラインを作る

最終的には関数を作りたいわけですが、第一段階として、 dfA~dfCが処理できるパイプラインを作ることにします。

まず、dfA~dfCを名前付きリストに格納します。ここで、tibbleパッケージlst()関数を使うことが重要です。 Base Rのlist()関数では、データフレームの名前が失われてしまうからです。

dfs <- lst(dfA, dfB, dfC)
glimpse(dfs)
## List of 3
##  $ dfA: tibble [3 × 1] (S3: tbl_df/tbl/data.frame)
##   ..$ src_ip: chr [1:3] "192.168.1.3" "192.168.1.1" "192.168.1.2"
##  $ dfB: tibble [4 × 1] (S3: tbl_df/tbl/data.frame)
##   ..$ src_ip: chr [1:4] "192.168.1.5" "192.168.1.3" "192.168.1.4" "192.168.1.5"
##  $ dfC: tibble [3 × 1] (S3: tbl_df/tbl/data.frame)
##   ..$ src_ip: chr [1:3] "192.168.1.2" "192.168.1.5" "192.168.1.6"

次に、データフレームを結合して縦持ちの表を作ります。このとき、.idオプションを使って、特定の行がどのデータフレームに由来するのかを判断可能にしておきます。さらに、各sourceが持っているsrc_ipにvalue_existsフィールドを与え、TRUEを設定します。

途中でselect()とdistinct()とを入れているのは、現実のログではフィールドが1つということはないからです。注目すべきは(src_ip, source)の組み合わせだけですので、その他を落として一意にします。

df_exists <- 
  bind_rows(dfs, .id = "source") |> 
  select(src_ip, source) |> 
  distinct() |> 
  mutate(value_exists = TRUE)

df_exists
src_ip source value_exists
192.168.1.3 dfA TRUE
192.168.1.1 dfA TRUE
192.168.1.2 dfA TRUE
192.168.1.5 dfB TRUE
192.168.1.3 dfB TRUE
192.168.1.4 dfB TRUE
192.168.1.2 dfC TRUE
192.168.1.5 dfC TRUE
192.168.1.6 dfC TRUE

ここからが少し凝っています。このテーブルをsourceを各列にした横持ちに変えます。それにはpivot_wider()関数を使います。

df_exists |> 
  pivot_wider(names_from = source, values_from = value_exists)
src_ip dfA dfB dfC
192.168.1.3 TRUE TRUE NA
192.168.1.1 TRUE NA NA
192.168.1.2 TRUE NA TRUE
192.168.1.5 NA TRUE TRUE
192.168.1.4 NA TRUE NA
192.168.1.6 NA NA TRUE

望む形に近づいてきましたが、NAは避けたいところですね。そこで、pivot_wider()関数のvalues_fillオプションを使って、NAを「FALSE」に置き換えましょう。最後にsrc_ip順に並ぶように、arrange()関数を加えておきます。

df_exists |> 
  pivot_wider(names_from = source, values_from = value_exists, values_fill = FALSE) |> 
  arrange(src_ip)
src_ip dfA dfB dfC
192.168.1.1 TRUE FALSE FALSE
192.168.1.2 TRUE FALSE TRUE
192.168.1.3 TRUE TRUE FALSE
192.168.1.4 FALSE TRUE FALSE
192.168.1.5 FALSE TRUE TRUE
192.168.1.6 FALSE FALSE TRUE

では、以上を1つのパイプラインに統合します。

bind_rows(dfs, .id = "source") |> 
  select(src_ip, source) |> 
  distinct() |> 
  mutate(value_exists = TRUE) |> 
  pivot_wider(names_from = source, values_from = value_exists, values_fill = FALSE) |> 
  arrange(src_ip)
src_ip dfA dfB dfC
192.168.1.1 TRUE FALSE FALSE
192.168.1.2 TRUE FALSE TRUE
192.168.1.3 TRUE TRUE FALSE
192.168.1.4 FALSE TRUE FALSE
192.168.1.5 FALSE TRUE TRUE
192.168.1.6 FALSE FALSE TRUE

うまくいきました。

関数にする

いよいよ、先に作ったパイプラインから関数を組み立てます。ちょっと天下り式で申し訳ないのですが、関数定義と使用例を先に示します。

check_field_existence_across_dfs <- function(dfs, column_name) {
  bind_rows(dfs, .id = "source") |> 
    select({{ column_name }}, source) |>
    mutate(value_exists = TRUE) |>
    distinct() |> 
    pivot_wider(names_from = source, values_from = value_exists, values_fill = FALSE) |> 
    arrange({{ column_name }})
}

check_field_existence_across_dfs(dfs, src_ip)
src_ip dfA dfB dfC
192.168.1.1 TRUE FALSE FALSE
192.168.1.2 TRUE FALSE TRUE
192.168.1.3 TRUE TRUE FALSE
192.168.1.4 FALSE TRUE FALSE
192.168.1.5 FALSE TRUE TRUE
192.168.1.6 FALSE FALSE TRUE

まず、関数定義の中で、引数としてとった変数を利用するときに {{ 二重 }}波カッコ(curly-curly)で囲まれていることに着目してください。これは、関数の使用例の中で「src_ip」というフィールド名を二重引用符でくくって「いない」ことと関係しています。

Tidyverseの関数群では、フィールド名を変数として使う場合に二重引用符を使わなくて済むように評価機構が整備されています。利用者は意識しませんが、本来

check_field_existence_across_dfs(dfs, src_ip)

のdfsとsrc_ipとでは意味が違います。前者が通常の変数であるのに対し、後者はデータフレームのフィールド名です。一般的な言語に慣れている人なら、フィールド名「src_ip」は文字列なのだから、二重引用符を付けたくなるのが自然な感覚でしょう。しかしTidyverseでは、この種の変数をData Variablesと呼び、二重引用符を使わずに済むように評価機構(Tidy Evaluation)が整備されています。(Splunkを使っている人は、SPLのevalコマンドやwhereコマンドの引数の値が、引用符なしだとフィールド名として扱われるのと似ていると感じるかもしれません。)

関数を使う側が手を抜ける分、作る側は引用符の取り扱いに気を払う必要があります。しかしrlang 0.4.0(2019-06-25)からcurly-curlyが導入され、簡単になりました。多くのケースでは、二重引用符なしで関数の引数にフィールド名が渡されるときには、関数定義の中で参照するときに{{ 二重 }}波カッコを使えばよいだけです。

おわりに

実は、このコードの素案は私自身が思いついたのではなく、ChatGPTが生み出したものです。 ChatGPTのコードはChatGPT自身が意図した通りには動かず修正が必要でしたが、表を縦持ちに結合し、横持ちに変換するときにFALSEで埋めるという発想は、私は持っていませんでした。(私はもっと愚直にjoin系の関数を組み合わせようとしていました。)

参考文献

詳しくはdplyrのVignette「Programming with dplyr」か、できれば「Advanced R」の第20章をご参照ください。

利用者の観点では、ExcelとRでデータ分析の「rlangのtidyeval 使用例とともに徹底解剖する」という記事が整理されているので、一読をお薦めします。