多くの情報提供サービスにおいて、利用者が検索文字列を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点です。
たとえば緯度・経度の部分は、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_で始まる関数です(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でこれらの関数が提供されていることを知りました。灯台下暗しとは、このことです。