tidyverse脳になって階層構造のあるデータフレームを使いこなそう

tidyverseには欠かせない階層構造のあるデータフレームに関する解説です

Shinya Uryu

8 minute read

@yutannihilationが書いたtidyverse記事を眠気眼の状態で読んでしまって、眠気も吹き飛んで3時起きしました。訴訟。

さて、Rアドベントカレンダーの中で @yutannihilation@takeshi0406tidyverseについての記事を続けて書いていることからも、tidyverseの普及具合がわかる昨今ですが、これらの記事の中であまり言及されていないことがあります。それはtidyverseに含まれるパッケージの一つである{dplyr}{tidyr}が提供するnested data frameです。私個人はこれを階層構造のあるデータフレームと呼んでいます。

一人Rアドベントカレンダーの3日目では、tidyverseに欠かせない思想である、この階層構造のあるデータフレームについて説明をしたいと思います。といっても春先に書いたこの記事の再放送的な感じでもあります。

参考) http://uribo.hatenablog.com/entry/2016/03/27/070000

Rにはデータフレームとリストというオブジェクトがあるのはみなさんご存知の通りです。データフレームは特に利用機会の多いオブジェクトで、csvやexcelから読み込んだオブジェクトはデータフレームに変換されます。統計処理やモデリングを実行する関数の多くがデータフレームの変数を対象としており、{dplyr}もデータフレームの操作を前提としています。その特徴は、列をなす変数と行を構成する表構造をとることでしょう。そのためデータフレームでは、1列目は3つの要素、2列目は4つの要素…といったサイズの異なるデータを扱えません。加えて列を構成するのはベクトルオブジェクトであるため、異なる種類のオブジェクトを一つの列の中で扱うことはできません。対してリストオブジェクトはデータフレームと比べると自由度の高いデータ格納形式です。リストには、ベクトルやデータフレーム、行列、さらにはリストまで、Rのオブジェクトを含めることができます。一方でその自由度の高さから、やや扱いに困ることがあります。データフレームと比べるとやはり操作性が悪いです。これらのデータフレーム、リストのそれぞれの特徴を備えたのが階層構造のあるデータフレームとなります。その特徴を見ていきましょう。

階層構造のあるデータフレームでは、変数にリストやデータフレームを含めることが可能です。データフレームの中にデータフレーム、データフレームの中にリスト、このことがnestedの由縁です。具体的にどういうことかというのを{dplyr}{tidyr}を使って示します。またデータの例として{gapminder}パッケージの世界各国の出生時平均余命に関するデータセットを利用します。まずはパッケージを読み込み、gapminderデータセットの大きさを確認しておきましょう。

library(tidyverse)
library(gapminder)

dim(gapminder)
## [1] 1704    6
head(gapminder)
## # A tibble: 6 × 6
##       country continent  year lifeExp      pop gdpPercap
##        <fctr>    <fctr> <int>   <dbl>    <int>     <dbl>
## 1 Afghanistan      Asia  1952  28.801  8425333  779.4453
## 2 Afghanistan      Asia  1957  30.332  9240934  820.8530
## 3 Afghanistan      Asia  1962  31.997 10267083  853.1007
## 4 Afghanistan      Asia  1967  34.020 11537966  836.1971
## 5 Afghanistan      Asia  1972  36.088 13079460  739.9811
## 6 Afghanistan      Asia  1977  38.438 14880372  786.1134

1704行、結構大きなデータサイズですね。一方でこのデータセットは国ごとの5年ごとの人口、出生時平均余命について記録したデータであるので、国 countryをキーとして入れ子にすることが可能です。

gapminder %>% count(continent, country)
## Source: local data frame [142 x 3]
## Groups: continent [?]
## 
##    continent                  country     n
##       <fctr>                   <fctr> <int>
## 1     Africa                  Algeria    12
## 2     Africa                   Angola    12
## 3     Africa                    Benin    12
## 4     Africa                 Botswana    12
## 5     Africa             Burkina Faso    12
## 6     Africa                  Burundi    12
## 7     Africa                 Cameroon    12
## 8     Africa Central African Republic    12
## 9     Africa                     Chad    12
## 10    Africa                  Comoros    12
## # ... with 132 more rows
nest_by_country <- gapminder %>% 
  group_by(country) %>% 
  nest()

階層構造のあるデータフレームを作る方法はいくつかあるのですが、上記のようにdplyr::group_by()によるグループ化を行ったのち、tidyr::nest()を使うのが最も簡単です。あるいは、gapminder %>% tidyr::nest(-country)で直接グループネストを作っても良いかもしれません。

さて、上記で作成したnest_by_countryというオブジェクトを出力すると次の結果が得られます。

nest_by_country
## # A tibble: 142 × 2
##        country               data
##         <fctr>             <list>
## 1  Afghanistan <tibble [12 × 5]>
## 2      Albania <tibble [12 × 5]>
## 3      Algeria <tibble [12 × 5]>
## 4       Angola <tibble [12 × 5]>
## 5    Argentina <tibble [12 × 5]>
## 6    Australia <tibble [12 × 5]>
## 7      Austria <tibble [12 × 5]>
## 8      Bahrain <tibble [12 × 5]>
## 9   Bangladesh <tibble [12 × 5]>
## 10     Belgium <tibble [12 × 5]>
## # ... with 132 more rows

countryというのはグループ化に用いたキーですが、dataというのは何を示しているのでしょう。また、どうしてデータサイズが変わっているのでしょうか。これこそが階層構造のあるデータフレームの特徴となります。

nest_by_countryオブジェクト自体はデータフレームなので、変数の値を参照することができます。試しにdata変数の第一要素を参照してみましょう。

nest_by_country$data[[1]]
## # A tibble: 12 × 5
##    continent  year lifeExp      pop gdpPercap
##       <fctr> <int>   <dbl>    <int>     <dbl>
## 1       Asia  1952  28.801  8425333  779.4453
## 2       Asia  1957  30.332  9240934  820.8530
## 3       Asia  1962  31.997 10267083  853.1007
## 4       Asia  1967  34.020 11537966  836.1971
## 5       Asia  1972  36.088 13079460  739.9811
## 6       Asia  1977  38.438 14880372  786.1134
## 7       Asia  1982  39.854 12881816  978.0114
## 8       Asia  1987  40.822 13867957  852.3959
## 9       Asia  1992  41.674 16317921  649.3414
## 10      Asia  1997  41.763 22227415  635.3414
## 11      Asia  2002  42.129 25268405  726.7341
## 12      Asia  2007  43.828 31889923  974.5803

元のgapminderデータが持っていたデータに近い値が出力されました。この値は実は、nest_by_country$country[1]のAfghanistanに含まれるデータを抽出したものとなっています。それは{dplyr}の次のコードを実行することで確認できます。

nest_by_country$country[1]
## [1] Afghanistan
## 142 Levels: Afghanistan Albania Algeria Angola Argentina ... Zimbabwe

identical(nest_by_country$data[[1]],
          gapminder %>% filter(country == "Afghanistan") %>% 
  select(-country))
## [1] TRUE

つまり階層構造のあるデータフレームとは、データフレームに含まれる任意の変数をキーとし、そのキーに含まれる値を別のオブジェクトとし、キーと紐づいた変数に格納することを示しているのです。

多くのデータは、1つ以上のキーあるいはカテゴリ変数をもっています。そしてこれらのキーに応じて、キーに含まれる項目の数を集計したり、キーごとに処理を行ったり、統計モデリングを適用したりといったことが頻繁に行われます。これらの出力形式はさまざまですが、キーを基準としている以上、処理結果をキーと紐づけることが可能です。ですが、格納形式が厳密なデータフレームではそれができません。そのため、階層構造のあるデータフレームはリストのようにデータフレームの中にさらにオブジェクトを含めることを可能としています。これにより、元のデータ(キー)と処理結果を簡単に紐付け、再びデータフレームとして操作することができます。

また上記で、入れ子にされるオブジェクトを別のオブジェクトと書きましたが、これは先のようにデータフレームでなく、リストや統計解析のためのオブジェクトであってもネスト可能であることを示しています。今度はdplyr::do()を用いて線形回帰を行ってみましょう。dplyr::do()はグループに対し共通の処理を施すのに適しています。

(do_by_country <- gapminder %>% 
  group_by(country) %>% 
  do(data = lm(lifeExp ~ year, data = .)))
## Source: local data frame [142 x 2]
## Groups: <by row>
## 
## # A tibble: 142 × 2
##        country     data
## *       <fctr>   <list>
## 1  Afghanistan <S3: lm>
## 2      Albania <S3: lm>
## 3      Algeria <S3: lm>
## 4       Angola <S3: lm>
## 5    Argentina <S3: lm>
## 6    Australia <S3: lm>
## 7      Austria <S3: lm>
## 8      Bahrain <S3: lm>
## 9   Bangladesh <S3: lm>
## 10     Belgium <S3: lm>
## # ... with 132 more rows

countryごとにlm()を適用した結果が格納されました。

all.equal(
  do_by_country$data[[1]],
  gapminder %>% filter(country == "Afghanistan") %>% 
  select(-country) %>% 
  lm(lifeExp ~ year, data = .)
)
## [1] TRUE

入れ子構造を解除するにはどうするのでしょうか。ご心配なく。{tidyr}にはnest()と対をなすunnest()という関数が用意されています。しかしそれには入れ子となっているオブジェクトがデータフレームである必要があります。なので先の線形回帰の結果を{broom}パッケージのtidy()によりデータフレームとして扱えるようにしてから入れ子構造を解除します。

{broom}パッケージは、Rの統計解析の関数の出力結果を扱いやすいデータフレームへと整形してくれる便利なパッケージです。例えばlm()の結果に対しbroom::tidy()を実行すると切片や係数、p値などをデータフレーム形式で取得できます。

do_by_country <- gapminder %>% 
  group_by(country) %>% 
  do(data = lm(lifeExp ~ year, data = .) %>% broom::tidy()) %>% 
  tidyr::unnest()

do_by_country %>% colnames()
## [1] "country"   "term"      "estimate"  "std.error" "statistic" "p.value"

データフレームなので、{dplyr}の関数を使って特定の国の結果のみを取り出すことができます。

do_by_country %>% filter(country == "Afghanistan")
## # A tibble: 2 × 6
##       country        term     estimate   std.error statistic
##        <fctr>       <chr>        <dbl>       <dbl>     <dbl>
## 1 Afghanistan (Intercept) -507.5342716 40.48416195 -12.53661
## 2 Afghanistan        year    0.2753287  0.02045093  13.46289
## # ... with 1 more variables: p.value <dbl>

しかし、全ての入れ子がデータフレームにできるわけではありません。そのような時には{purrr}による関数型プログラミングによるデータ操作が効果を発揮します。

by_country <- nest_by_country %>% 
  dplyr::mutate(model = purrr::map(data, ~ lm(lifeExp ~ year, data = .)))

by_country %>% unnest(model %>% purrr::map(broom::tidy))
## # A tibble: 284 × 6
##        country        term      estimate    std.error  statistic
##         <fctr>       <chr>         <dbl>        <dbl>      <dbl>
## 1  Afghanistan (Intercept)  -507.5342716 40.484161954 -12.536613
## 2  Afghanistan        year     0.2753287  0.020450934  13.462890
## 3      Albania (Intercept)  -594.0725110 65.655359062  -9.048348
## 4      Albania        year     0.3346832  0.033166387  10.091036
## 5      Algeria (Intercept) -1067.8590396 43.802200843 -24.379118
## 6      Algeria        year     0.5692797  0.022127070  25.727749
## 7       Angola (Intercept)  -376.5047531 46.583370599  -8.082385
## 8       Angola        year     0.2093399  0.023532003   8.895964
## 9    Argentina (Intercept)  -389.6063445  9.677729641 -40.258031
## 10   Argentina        year     0.2317084  0.004888791  47.395847
## # ... with 274 more rows, and 1 more variables: p.value <dbl>
by_country %>% unnest(model %>% purrr::map(broom::augment))
## # A tibble: 1,704 × 10
##        country lifeExp  year  .fitted   .se.fit      .resid       .hat
##         <fctr>   <dbl> <int>    <dbl>     <dbl>       <dbl>      <dbl>
## 1  Afghanistan  28.801  1952 29.90729 0.6639995 -1.10629487 0.29487179
## 2  Afghanistan  30.332  1957 31.28394 0.5799442 -0.95193823 0.22494172
## 3  Afghanistan  31.997  1962 32.66058 0.5026799 -0.66358159 0.16899767
## 4  Afghanistan  34.020  1967 34.03722 0.4358337 -0.01722494 0.12703963
## 5  Afghanistan  36.088  1972 35.41387 0.3848726  0.67413170 0.09906760
## 6  Afghanistan  38.438  1977 36.79051 0.3566719  1.64748834 0.08508159
## 7  Afghanistan  39.854  1982 38.16716 0.3566719  1.68684499 0.08508159
## 8  Afghanistan  40.822  1987 39.54380 0.3848726  1.27820163 0.09906760
## 9  Afghanistan  41.674  1992 40.92044 0.4358337  0.75355828 0.12703963
## 10 Afghanistan  41.763  1997 42.29709 0.5026799 -0.53408508 0.16899767
## # ... with 1,694 more rows, and 3 more variables: .sigma <dbl>,
## #   .cooksd <dbl>, .std.resid <dbl>

あるいは次のようにsplit()でグループを分割し、各グループに対してlm() %>% broom::tidy()を適用するのも一つの方法です。

map_by_country <- gapminder %>% 
  split(.$country) %>% 
  purrr::map(., ~ lm(lifeExp ~ year, data = .) %>% broom::tidy())

map_by_country$Afghanistan
##          term     estimate   std.error statistic          p.value
## 1 (Intercept) -507.5342716 40.48416195 -12.53661 0.00000019340553
## 2        year    0.2753287  0.02045093  13.46289 0.00000009835213

今回紹介したパッケージは、データセットを提供した{gapminder}をのぞいて、{tidyverse}に含まれているものです。すごい設計思想ですね。俺たちのtidyverseはこれからだ!

Enjoy!

comments powered by Disqus