読者です 読者をやめる 読者になる 読者になる

ウェブAPIから返ってくるネストしまくったJSONをpurrrの力でdata.frameに変換する

某ウェブサービスのデータをAPI経由でRから取ってくるkntnrというパッケージをつくりました。

このパッケージを作るのは、実は2年間に3度くらい作りかけてはあきらめて、というのを繰り返してきました。というのも、JSONをdata.frameに変換するのが大変すぎて、少し前の私ではカラテが足りなかったからです。今もそんなにきれいに扱えるわけではないんですが、この悪戦苦闘がひょっとして誰かの参考になるかもしれないと思ってブログに書き残しておきます。(パッケージの紹介については会社ブログに書いたのが後日公開されます)

例1: 単一のレコード

まずは簡単な例として以下のJSONを、

{
    "field1": {
        "type": "SINGLE_LINE_TEXT",
        "value": "テスト"
    },
    "field2": {
        "type": "DATE",
        "value": "2017-01-01"
    },
    "field3": {
        "type": "NUMBER",
        "value": "10"
    }
}

以下のlistに変換する方法を考えてみましょう。

list(field1 = "テスト", field2 = "DATE", field3 = 10)

ひとつのフィールドにはtypevalueがあります。typeはそのフィールドの型を示しています。これによって、valueをどう変換するかが決まります。上の例だと、

  • SINGLE_LINE_TEXT: characterに変換
  • DATE: Dateに変換
  • NUMBER: numericに変換

となります。まずはcharacterとして読み込まれるので、SINGLE_LINE_TEXTについては変換する必要はありません。DATEas.Date()を、NUMBERas.numeric()を使って変換しましょう。

JSONをパースするのにはjsonlite::fromJSON()を使います。jsonliteは、可能な場合には自動的にlistをdata.frameやベクトルに変換してくれますが、今回は細かい挙動も自分でコントロールしたいのでsimplifyVector引数にFALSEを指定します。

l1 <- jsonlite::fromJSON("example1.json", simplifyVector = FALSE)

typeによって処理を変えるにはswitch()を使います(switchは文字列のマッチングしかできないので、もう少し複雑なパターン分けをしたい場合はpurrr::when()を使いましょう)。具体的には以下のようなコードになるでしょう。

parse_field <- function(x) {
  switch(x$type,
         DATE = as.Date(x$value, origin = "1970-01-01"),
         NUMBER = as.numeric(x$value),
         x$value)}
)

parse_field(l1[[1]])
#> [1] "テスト"
parse_field(l1[[2]])
#> [1] "2017-01-01"
parse_field(l1[[3]])
#> [1] 10

これをlapply()purrr::map()を使ってl1の各要素に適用すると、求めるリストが出来上がります。

l1_converted <- purrr::map(l1, parse_field)
l1_converted
#> $field1
#> [1] "テスト"
#> 
#> $field2
#> [1] "2017-01-01"
#> 
#> $field3
#> [1] 10

これにtibble::as_tibble()などを使うと、data.frame(tibble)に変換できます。

tibble::as_tibble(l1_converted)

例2: 複数のレコード

次に、上のレコードが複数ある場合を考えます。具体的には以下のようなJSONをdata.frameに変換します。

[
  {
    "field1": {
      "type": "SINGLE_LINE_TEXT",
      "value": "テスト"
    },
    "field2": {
      "type": "DATE",
      "value": "2017-01-01"
    },
    "field3": {
      "type": "NUMBER",
      "value": "10"
    }
  },
  {
    "field1": {
      "type": "SINGLE_LINE_TEXT",
      "value": "テステス"
    },
    "field2": {
      "type": "DATE",
      "value": "2016-12-31"
    },
    "field3": {
      "type": "NUMBER",
      "value": "100"
    }
  }
]

まずはJSONを読み込みましょう。

l2 <- jsonlite::fromJSON("example2.json", simplifyVector = FALSE)

それぞれのレコードをパースする処理は先ほどと同じです。1つ目を取り出してpurrr::map()parse_fieldを使えば、先ほどと同じ結果になっていることがわかります。

purrr::map(l2[[1]], parse_field)
#> $field1
#> [1] "テスト"
#> 
#> $field2
#> [1] "2017-01-01"
#> 
#> $field3
#> [1] 10

つまり、この処理をl2の各要素にpurrr::map()で適用すれば求める結果に近づきます。

l2_converted <- purrr::map(l2,
                           ~ purrr::map(., parse_field))
#> [[1]]
#> [[1]]$field1
#> [1] "テスト"
#> 
#> [[1]]$field2
#> [1] "2017-01-01"
#> 
#> [[1]]$field3
#> [1] 10
#> 
#> 
#> [[2]]
#> [[2]]$field1
#> [1] "テステス"
#> 
#> [[2]]$field2
#> [1] "2016-12-31"
#> 
#> [[2]]$field3
#> [1] 100

分かりづらければ、以下のようにparse_record()という関数を定義してもいいかもしれません。

parse_record <- function(x) { purrr::map(x, parse_field) }

purrr::map(l2, parse_record)

このリストをdata.frameに変換するには、dplyr::bind_rows()か、または上でpurrr::map()の代わりにpurrr::map_df()を使います。

dplyr::bind_rows(l2_converted)
#> # A tibble: 2 × 3
#>     field1     field2 field3
#>      <chr>     <date>  <dbl>
#> 1   テスト 2017-01-01     10
#> 2 テステス 2016-12-31    100
# これでも同じ結果
purrr::map_df(l2, ~ purrr::map(., parse_field))

目的のdata.frameができました。

例3: 複数の値を含むフィールド

さて、ここまでは簡単でした。なぜかというと、各フィールドにあるのは単一の値だったためです。しかし、こんなフィールドもあります。

{
    "type": "CHECK_BOX",
    "value": [
        "sample1",
        "sample2"
    ]
}

これを上と同じやり方で処理するとどうなるか、やってみましょう。例として以下のJSONを考えます。

[
  {
    "field1": {
      "type": "SINGLE_LINE_TEXT",
      "value": "テスト"
    },
    "field4": {
    "type": "CHECK_BOX",
    "value": [
        "sample1",
        "sample2"
      ]
    }
  },
  {
    "field1": {
      "type": "SINGLE_LINE_TEXT",
      "value": "テステス"
    },
    "field4": {
    "type": "CHECK_BOX",
    "value": [
        "sample3"
      ]
    }
  }
]

これを上と同じやり方で処理すると、こうなります。

l3 <- jsonlite::fromJSON("example3.json", simplifyVector = FALSE)
l3_converted <- purrr::map(l3, ~ purrr::map(., parse_field))
dplyr::bind_rows(l3_converted)
#> Error in eval(substitute(expr), envir, enclos) : 
#>   incompatible sizes (1 != 2)

エラーになりました。これは、field4に複数の要素が入っているためです。

l3_converted[[1]]
$field1
[1] "テスト"

$field4
$field4[[1]]
[1] "sample1"

$field4[[2]]
[1] "sample2"

実は、これはウェブAPIではよくあるパターンです。これに対処する方法は、複数の要素を持つカラムはlistに詰め込むことです。list columnの扱いについては以下の「Data Rectangling」というスライドが詳しいです。

具体的には、先ほどのparse_field()関数を修正して、CHECK_BOXlistで包むようにします。その前に、JSONの配列もリストになっている(fromJSON()simplifyVector = FALSEを指定したため)ので、unlist()とかpurrr::flatten_chr()でcharacterのベクトルにする必要がある点にも注意してください。

parse_field <- function(x) {
  switch(x$type,
         DATE = as.Date(x$value, origin = "1970-01-01"),
         NUMBER = as.numeric(x$value),
         CHECK_BOX = list(purrr::flatten_chr(x$value)),
         x$value)
}

これを使うと、以下のようにdata.frameを得ることができました。

l3_converted <- purrr::map(l3, ~ purrr::map(., parse_field))
dplyr::bind_rows(l3_converted)
#> # A tibble: 2 × 2
#>     field1    field4
#>      <chr>    <list>
#> 1   テスト <chr [2]>
#> 2 テステス <chr [1]>

ちなみに、listのカラムはtidyr::unnest()を使うと展開することができます。こんな感じで、あとで変形させることはdplyr/tidyr/purrrあたりにツールが充実しているので、とりあえずlistに詰め込みましょう、ということです。

d <- dplyr::bind_rows(l3_converted)
tidyr::unnest(d, field4)
#> # A tibble: 3 × 2
#>     field1  field4
#>      <chr>   <chr>
#> 1   テスト sample1
#> 2   テスト sample2
#> 3 テステス sample3

例4: サブテーブル

この某APIには、さらに強敵がいます。サブテーブルという型です。こういうやつです。

{
  "type": "SUBTABLE",
  "value": [
    {
        "id": "1",
        "value": {
            "field11": {
                "type": "SINGLE_LINE_TEXT",
                "value": ""
            },
            "field2": {
                "type": "NUMBER",
                "value": "1000"
            }
        }
    },
    {
        "id": "2",
        "value": {
            "field1": {
                "type": "SINGLE_LINE_TEXT",
                "value": "shibuyeeeeeaaaaahhhhh"
            },
            "field2": {
                "type": "NUMBER",
                "value": "2000"
            }
        }
    }
  ]
}

なんということでしょう。valueに複数のレコードがさらに入っています。

少し注意が必要なのは、先ほどまでの例のJSONとは少し形式が違っていて、各レコードにidvalueという要素があります。このうち、valueだけを取り出してパースする必要があります。

parse_subtable <- function(x) {
  d <- purrr::map(x,
                  ~purrr::map(.$value, parse_field))
  dplyr::bind_rows(d)
}

そして、parse_field()関数を修正して、SUBTABLEの時にはこの関数を使うようにします。CHECK_BOXのときと同じくlistでラップするのも忘れずに。

parse_field <- function(x) {
  switch(x$type,
         DATE = as.Date(x$value, origin = "1970-01-01"),
         NUMBER = as.numeric(x$value),
         CHECK_BOX = list(purrr::flatten_chr(x$value)),
         SUBTABLE = list(parse_subtable(x$value)),
         x$value)
}

いよいよこのJSONをパースしてみます。

[
    {
        "field1": {
            "type": "SINGLE_LINE_TEXT",
            "value": "テスト"
        },
        "field5": {
            "type": "SUBTABLE",
            "value": [
                {
                    "id": "1",
                    "value": {
                        "field11": {
                            "type": "SINGLE_LINE_TEXT",
                            "value": ""
                        },
                        "field12": {
                            "type": "NUMBER",
                            "value": "1000"
                        }
                    }
                },
                {
                    "id": "2",
                    "value": {
                        "field11": {
                            "type": "SINGLE_LINE_TEXT",
                            "value": "shibuyeeeeeaaaaahhhhh"
                        },
                        "field12": {
                            "type": "NUMBER",
                            "value": "2000"
                        }
                    }
                }
            ]
        }
    },
    {
        "field1": {
            "type": "SINGLE_LINE_TEXT",
            "value": "テステス"
        },
        "field5": {
            "type": "SUBTABLE",
            "value": [
                {
                    "id": "4",
                    "value": {
                        "field11": {
                            "type": "SINGLE_LINE_TEXT",
                            "value": "a"
                        },
                        "field12": {
                            "type": "NUMBER",
                            "value": "0"
                        }
                    }
                }
            ]
        }
    }
]

長い...。ではやってみます。

l4 <- jsonlite::fromJSON("example4.json", simplifyVector = FALSE)
l4_converted <- purrr::map(l4, ~ purrr::map(., parse_field))
dplyr::bind_rows(l4_converted)
#> # A tibble: 2 × 2
#>     field1            field5
#>      <chr>            <list>
#> 1   テスト <tibble [2 × 2]>
#> 2 テステス <tibble [1 × 2]>

ちゃんとパースできました。先ほどと同じくtidyr::unnest()することもできます。

d <- dplyr::bind_rows(l4_converted)
tidyr::unnest(d, field5)
# A tibble: 3 × 3
#>     field1               field11 field12
#>      <chr>                 <chr>   <dbl>
#> 1   テスト                          1000
#> 2   テスト shibuyeeeeeaaaaahhhhh    2000
#> 3 テステス                     a       0

NULLの扱いに注意

ときたま、

{
   "type": "DATE",
   "value": null
}

みたいに、NULLが入ってくることがあります。これは厄介です。NULLの扱いは関数によって異なりますが、以下のようにエラーになることが多いです。

as.Date(NULL, origin = "1970-01-01")
#> Error in as.Date.default(NULL, origin = "1970-01-01") : 
#>    'NULL' からクラス “Date” へ変換は定義されていません 

こういうときには、purrrパッケージの%||%演算子が便利です(ほかのパッケージにもあるやつだと思います)。

...
         DATE = as.Date(x$value %||% NA, origin = "1970-01-01"),
...

効率化のためにはtranspose()

これまでのコードだと、フィールドを列方向にひとつひとつ処理していますが、これはあまり効率的ではありません。以下の記事に書きましたが、速度を求めるのであれば一度purrr::transpose()してから処理を行う方がいいでしょう。

例えば、以下のようにfieldという1つのフィールドのレコードが3つ並んでいるJSONを考えます。

[
    {
        "field": {
            "type": "NUMBER",
            "value": "1"
        }
    },
    {
        "field": {
            "type": "NUMBER",
            "value": "2"
        }
    },
    {
        "field": {
            "type": "NUMBER",
            "value": "3"
        }
    }
]

これをtranspose()してみます。列ごとにまとめることができます。

l5 <- jsonlite::fromJSON("example5.json", simplifyVector = FALSE)

show_json <- function(x) jsonlite::prettify(jsonlite::toJSON(x, auto_unbox = TRUE))
show_json(purrr::transpose(l5))
#> {
#>     "field": [
#>         {
#>             "type": "NUMBER",
#>             "value": "1"
#>         },
#>         {
#>             "type": "NUMBER",
#>             "value": "2"
#>         },
#>         {
#>             "type": "NUMBER",
#>             "value": "3"
#>         }
#>     ]
#> }

しかし、typevalueに分かれているので、まだちょっと処理しづらいです。もう一つ下の階層までtranspose()してみましょう。

show_json(purrr::map(purrr::transpose(l5), purrr::transpose))
#> {
#>     "field": {
#>         "type": [
#>             "NUMBER",
#>             "NUMBER",
#>             "NUMBER"
#>         ],
#>         "value": [
#>             "1",
#>             "2",
#>             "3"
#>         ]
#>     }
#> }

この形に直した後は、以下のように列方向にパースする関数を使うことができます。

parse_col <- function(x) {
  # type はすべて同じはずなので1つだけ見ればいい
  type <- x$type[[1]]

  switch(type,
         NUMBER = as.numeric(purrr::flatten_chr(x$value)),
         ...
         purrr::flatten_chr(x$value))
}

l5_trans <- purrr::map(purrr::transpose(l5), purrr::transpose)
tibble::as_tibble(purrr::map(l5_trans, parse_col))
#> # A tibble: 3 × 1
#>   field
#>   <dbl>
#> 1     1
#> 2     2
#> 3     3

ただし、この例では...で省略しましたが、CHECK_BOXSUBTABLEのようにatomicではない列に関しては要素をひとつづつ処理する必要があります(purrr::flatten_chr()できない)。興味があればkntnrのコードを覗いてみてください。

ちなみに...

これは某ウェブサービススキーマ可変のデータベース的なものなのでこういうJSONが返ってくるんですが、一般的にはフィールドごとに型を明示する必要はないはずです。もしJSONがこういう形のものであったなら、

[
    {
        "field1": 1,
        "field2": "a",
        "field3": [
            "test1",
            "test2",
            "test3"
        ]
    },
    {
        "field1": 0,
        "field2": "b",
        "field3": [
            "test1"
        ]
    }
]

simplifyVector = FALSEにしてこんなに自前で頑張らなくても、jsonliteがだいたいいい感じにdata.frameにしてくれます。何も考えずにfromJSON()しましょう。

jsonlite::fromJSON("example6.json")
#>   field1 field2              field3
#> 1      1      a test1, test2, test3
#> 2      0      b               test1

なんてお手軽...

まとめ

  • ウェブAPIから返ってくるネストしまくったJSONもdata.frameに突っ込める
  • たまにものすごいネストしまくったJSONもあるけど、希望を捨てなければdata.frameに突っ込める
  • でもだいたいjsonliteに任せとけば大丈夫