はじめに

多くの情報提供サービスにおいて、利用者が検索文字列をREST APIで叩き、結果がJSONで返ってくる仕組みが採用されています。

JSONはXMLと比較すると人間にも読みやすい言語であり、ここからリスト構造に変換するのは比較的容易です。ところが、tidyverseが得意とする表形式のデータ(tibble)に持っていくのには、意外と苦労します。この作業を矩形化(Rectangling)と呼ぶ向きがあるようです。

この文書では、tidyrパッケージの関数群を用いてJSONデータを矩形化する方法を記載します。詳しくは、「Rectangling」というVignetteを参照するとよいでしょう。

矩形化時の留意点

Spurという情報提供サービスを例にとってみます。同社のContext APIサービスでは、次のようなJSON文字列が返されるそうです。

{
  "ip": "8.20.122.140",
  "anonymous": true,
  "vpnOperators": {
    "exists": true,
    "operators": [ { "name": "WARP_VPN" } ]
  },
  "deviceBehaviors": {
    "behaviors": [ { "name": "TOR_PROXY_USER" } ],
    "exists": true
  },
  "devices": { "estimate": 50 },
  "proxiedTraffic": { "exists": false },
  "geoPrecision": {
    "city": "Riyadh",
    "country": "SA",
    "exists": true,
    "hash": "th3hvvny",
    "point": {
      "latitude": 24.7698,
      "longitude": 46.6684,
      "radius": 500
    },
    "state": "Riyadh Region"
  }
}

JSONデータを矩形化するときに気を払わなければいけないのは、次の3点です。

  1. 入れ子構造になっている
  2. 配列で定義される場合がある(複数値の存在)
  3. レコードによってはフィールドが存在しない場合がある(N/Aの存在)

たとえば緯度・経度の部分は、geoPrecision/point/latitudeのように入れ子になっています。Rのデータフレーム(tibble)は入れ子構造を許容しますが、仮にすべて展開するなら、表を横方向に伸ばす必要があります。

次に、operatorsやbehaviorsのフィールドは、値が大カッコでくくられています。これは配列を意味していて、今回はたまたま1件しか値が入っていませんが、複数値が返ってくる可能性があります。可変長の値を横方向に伸ばすことはできない(列数が変わってくるから)ので、表を縦方向に伸ばさなければいけません。

また、vpnOperatorフィールドの値がtrueになっていてoperatorsフィールドが後続していますが、仮にvpnOperatorがfalseの場合には、operatorsフィールドは出力されません。複数レコードの矩形化ではN/Aが発生しうるため、必ずしも文字列型や数値型ではなくなる点が要注意です。

仮設例

単純化のために、次のような2レコードのJSONを含むデータフレームを用意しました。 idごとにAPIを叩いて、返ってきたJSON文字列に jsonlite::fromJSON関数を適用し、隣の列にリストとして格納した状況を想定しています。

library(tidyverse)
library(jsonlite)

df_list <- read_delim(
  "id|json
taro|{\"person\":\"Taro Yamada\",\"pets\":{\"exists\":true,\"details\":[{\"name\":\"mike\", \"kind\":\"cat\"},{\"name\":\"pochi\", \"kind\":\"dog\"}]}}
jiro|{\"person\":\"Jiro Tanaka\",\"pets\":{\"exists\":false}}
", delim = "|") %>% 
  mutate(
    l1 = map(json, fromJSON, simplifyVector = FALSE)
  ) %>% 
  select(id, l1)

print(df_list)
## # A tibble: 2 x 2
##   id    l1              
##   <chr> <list>          
## 1 taro  <named list [2]>
## 2 jiro  <named list [2]>

taroさんはペットを飼っていて、猫のmikeと犬のpochiとがいます。 jiroさんはペットを飼っていません。これがJSON文字列の内容です。この内容を表形式で表現したいとしましょう。

tidyrのunnest関数を使う

入れ子を解消させていくための関数がtidyrパッケージに用意されています。それがunnest_で始まる関数です(unnest_wider / unnest_longer / unnest_auto)。 unnest_widerが表を横に伸ばす形で入れ子を解消させ、unnest_longerが表を縦に伸ばす形で入れ子を解消させます。autoは自動で見分けてくれるものです。

展開の1例として、次のコード片があります。

df_list %>% 
  unnest_wider(l1) %>% 
  unnest_wider(pets) %>% 
  unnest_longer(details) %>% 
  unnest_wider(details)
## # A tibble: 3 x 5
##   id    person      exists name  kind 
##   <chr> <chr>       <lgl>  <chr> <chr>
## 1 taro  Taro Yamada TRUE   mike  cat  
## 2 taro  Taro Yamada TRUE   pochi dog  
## 3 jiro  Jiro Tanaka FALSE  <NA>  <NA>

たしかにJSON文字列(を変換した入れ子のリスト)が表形式に展開されていますが、初めて見ると何をしているのか理解しづらいでしょう。そこで、1行ずつ見ていくことにします。

逐行解説

まず、中カッコで始まるl1列を横に展開します。

df_list %>% 
  unnest_wider(l1) 
## # A tibble: 2 x 3
##   id    person      pets            
##   <chr> <chr>       <list>          
## 1 taro  Taro Yamada <named list [2]>
## 2 jiro  Jiro Tanaka <named list [1]>

最上位のpersonとpetsが出現しました。petsが入れ子になっているため、続けて展開します。

df_list %>% 
  unnest_wider(l1) %>% 
  unnest_wider(pets)
## # A tibble: 2 x 4
##   id    person      exists details   
##   <chr> <chr>       <lgl>  <list>    
## 1 taro  Taro Yamada TRUE   <list [2]>
## 2 jiro  Jiro Tanaka FALSE  <NULL>

今度はexistsとdetailsとが表出しました。detailsの入れ子が残っているので、さらに展開します。

df_list %>% 
  unnest_wider(l1) %>% 
  unnest_wider(pets) %>% 
  unnest_wider(details)
## New names:
## * `` -> ...1
## * `` -> ...2
## # A tibble: 2 x 5
##   id    person      exists ...1             ...2            
##   <chr> <chr>       <lgl>  <list>           <list>          
## 1 taro  Taro Yamada TRUE   <named list [2]> <named list [2]>
## 2 jiro  Jiro Tanaka FALSE  <NULL>           <NULL>

おやおや、予想しない結果になりました。detailsは大カッコで始まっています。これは配列です(taroさんはペットを2匹飼っている)から、縦方向の展開が必要です。仕切り直して、unnest_wider()ではなくunnest_longer()を使います。

df_list %>% 
  unnest_wider(l1) %>% 
  unnest_wider(pets) %>% 
  unnest_longer(details)
## # A tibble: 3 x 4
##   id    person      exists details         
##   <chr> <chr>       <lgl>  <list>          
## 1 taro  Taro Yamada TRUE   <named list [2]>
## 2 taro  Taro Yamada TRUE   <named list [2]>
## 3 jiro  Jiro Tanaka FALSE  <NULL>

もう一息ですね。detailsの中身は中カッコなので、横に展開しましょう。

df_list %>% 
  unnest_wider(l1) %>% 
  unnest_wider(pets) %>% 
  unnest_longer(details) %>% 
  unnest_wider(details)
## # A tibble: 3 x 5
##   id    person      exists name  kind 
##   <chr> <chr>       <lgl>  <chr> <chr>
## 1 taro  Taro Yamada TRUE   mike  cat  
## 2 taro  Taro Yamada TRUE   pochi dog  
## 3 jiro  Jiro Tanaka FALSE  <NA>  <NA>

これで完全に展開されました。

おわりに

実際のJSON処理では、取得データをすべて展開するのではなく、必要なフィールドだけを取り出したいこともあると思います。詳しくは紹介しませんが、そのようなときには hoist()関数が有用です。下の例から、personの値をname列として引っぱり出していることが見えるでしょうか。

df_list %>% 
  hoist(l1, name = "person")
## # A tibble: 2 x 3
##   id    name        l1              
##   <chr> <chr>       <list>          
## 1 taro  Taro Yamada <named list [1]>
## 2 jiro  Jiro Tanaka <named list [1]>

実は私は去年の暮れまで一連の関数の存在を知りませんでした。たとえばこんな感じで、フィールド1つ1つを手作業で定義して処理していたのです。

  df_list %>% 
  rowwise() %>% 
  mutate(
    pets_name = if_else(is_null(s1 <- pluck(l1, "pets", "details", 1, "name")),
                        NA_character_,
                        s1)
  )

もう少し効率的な方法はないものかとJSON専用パッケージを複数あたってみたものの、いまひとつしっくりきません。途方に暮れていたときに、tidyr 1.0でこれらの関数が提供されていることを知りました。灯台下暗しとは、このことです。