23  계층적 데이터

23.1 소개

이 장에서는 데이터 직사각형화(rectangling) 기술을 배울 것입니다. 이는 근본적으로 계층적이거나 트리 형태인 데이터를 행과 열로 구성된 직사각형 데이터 프레임으로 변환하는 것입니다. 계층적 데이터는 놀라울 정도로 흔하며, 특히 웹에서 가져온 데이터로 작업할 때 더욱 그렇기 때문에 이 작업은 중요합니다.

직사각형화에 대해 배우려면 먼저 계층적 데이터를 가능하게 하는 데이터 구조인 리스트에 대해 배워야 합니다. 그런 다음 두 가지 중요한 tidyr 함수인 tidyr::unnest_longer()tidyr::unnest_wider()를 배울 것입니다. 그 후 실제 문제를 해결하기 위해 이 간단한 함수들을 반복해서 적용하는 몇 가지 사례 연구를 보여줄 것입니다. 마지막으로 계층적 데이터셋의 가장 빈번한 소스이자 웹에서 데이터 교환을 위한 일반적인 형식인 JSON에 대해 이야기하며 마무리하겠습니다.

23.1.1 선수 지식

이 장에서는 tidyverse의 핵심 멤버인 tidyr의 많은 함수를 사용할 것입니다. 또한 직사각형화 연습을 위한 흥미로운 데이터셋을 제공하는 repurrrsive를 사용하고, 마지막으로 JSON 파일을 R 리스트로 읽기 위해 jsonlite를 사용할 것입니다.

library(tidyverse)
#> Warning: package 'ggplot2' was built under R version 4.5.2
#> Warning: package 'readr' was built under R version 4.5.2
library(repurrrsive)
library(jsonlite)

23.2 리스트(Lists)

지금까지 정수, 숫자, 문자, 날짜-시간 및 팩터와 같은 단순한 벡터를 포함하는 데이터 프레임으로 작업했습니다. 이러한 벡터는 동질적(homogeneous), 즉 모든 요소가 동일한 데이터 유형이기 때문에 단순합니다. 다른 유형의 요소를 동일한 벡터에 저장하려면 list()로 생성하는 리스트(list) 가 필요합니다:

x1 <- list(1:4, "a", TRUE)
x1
#> [[1]]
#> [1] 1 2 3 4
#> 
#> [[2]]
#> [1] "a"
#> 
#> [[3]]
#> [1] TRUE

리스트의 구성 요소 또는 자식(children) 의 이름을 지정하는 것이 종종 편리한데, 이는 티블의 열 이름을 지정하는 것과 같은 방식으로 할 수 있습니다:

x2 <- list(a = 1:2, b = 1:3, c = 1:4)
x2
#> $a
#> [1] 1 2
#> 
#> $b
#> [1] 1 2 3
#> 
#> $c
#> [1] 1 2 3 4

이러한 매우 단순한 리스트의 경우에도 인쇄하면 꽤 많은 공간을 차지합니다. 유용한 대안은 내용보다는 구조(structure)의 압축된 표시를 생성하는 str()입니다:

str(x1)
#> List of 3
#>  $ : int [1:4] 1 2 3 4
#>  $ : chr "a"
#>  $ : logi TRUE
str(x2)
#> List of 3
#>  $ a: int [1:2] 1 2
#>  $ b: int [1:3] 1 2 3
#>  $ c: int [1:4] 1 2 3 4

보시다시피 str()은 리스트의 각 자식을 별도의 줄에 표시합니다. 이름이 있으면 이름을 표시하고, 그 다음 유형의 약어, 그 다음 처음 몇 개의 값을 표시합니다.

23.2.1 계층 구조(Hierarchy)

리스트는 다른 리스트를 포함하여 모든 유형의 객체를 포함할 수 있습니다. 이로 인해 계층적(트리 형태) 구조를 나타내기에 적합합니다:

x3 <- list(list(1, 2), list(3, 4))
str(x3)
#> List of 2
#>  $ :List of 2
#>   ..$ : num 1
#>   ..$ : num 2
#>  $ :List of 2
#>   ..$ : num 3
#>   ..$ : num 4

이것은 평면 벡터를 생성하는 c()와 눈에 띄게 다릅니다:

c(c(1, 2), c(3, 4))
#> [1] 1 2 3 4

x4 <- c(list(1, 2), list(3, 4))
str(x4)
#> List of 4
#>  $ : num 1
#>  $ : num 2
#>  $ : num 3
#>  $ : num 4

리스트가 복잡해짐에 따라 계층 구조를 한눈에 볼 수 있게 해주는 str()이 더 유용해집니다:

x5 <- list(1, list(2, list(3, list(4, list(5)))))
str(x5)
#> List of 2
#>  $ : num 1
#>  $ :List of 2
#>   ..$ : num 2
#>   ..$ :List of 2
#>   .. ..$ : num 3
#>   .. ..$ :List of 2
#>   .. .. ..$ : num 4
#>   .. .. ..$ :List of 1
#>   .. .. .. ..$ : num 5

리스트가 훨씬 더 크고 복잡해지면 결국 str()도 실패하기 시작하고 View()1로 전환해야 합니다. Figure 23.1View(x5)를 호출한 결과를 보여줍니다. 뷰어는 리스트의 최상위 수준만 보여주는 것으로 시작하지만, Figure 23.2 처럼 구성 요소를 대화식으로 확장하여 더 많은 내용을 볼 수 있습니다. RStudio는 또한 Figure 23.3 처럼 해당 요소에 액세스하는 데 필요한 코드를 보여줍니다. 이 코드가 어떻게 작동하는지는 Section 27.3 에서 다시 다룰 것입니다.

리스트 뷰어를 보여주는 RStudio 스크린샷. x5의 두 자식을 보여줍니다:  첫 번째 자식은 double 벡터이고 두 번째 자식은 리스트입니다.  오른쪽을 가리키는 삼각형은 두 번째 자식 자체가 자식을 가지고 있지만  볼 수는 없음을 나타냅니다.
Figure 23.1: RStudio 뷰를 사용하면 복잡한 리스트를 대화식으로 탐색할 수 있습니다. 뷰어는 리스트의 최상위 수준만 보여주며 열립니다.
x5의 두 번째 자식을 확장한 리스트 뷰어의 또 다른 스크린샷.  이것도 두 개의 자식, 즉 double 벡터와 또 다른 리스트를 가지고 있습니다.
Figure 23.2: 오른쪽을 가리키는 삼각형을 클릭하면 리스트의 해당 구성 요소가 확장되어 그 자식들도 볼 수 있습니다.
x5의 손자 수준을 확장하여 두 개의 자식(다시 double 벡터와 리스트)을  보여주는 또 다른 스크린샷.
Figure 23.3: 관심 있는 데이터에 도달할 때까지 이 작업을 필요한 만큼 반복할 수 있습니다. 왼쪽 하단 모서리에 유의하세요: 리스트의 요소를 클릭하면 RStudio가 그것에 액세스하는 데 필요한 부분집합 코드를 제공합니다. 이 경우 x5[[2]][[2]][[2]]입니다.

23.2.2 리스트 열(List-columns)

리스트는 티블 내부에도 존재할 수 있으며, 이를 리스트 열이라고 부릅니다. 리스트 열은 일반적으로 티블에 속하지 않을 객체를 티블에 배치할 수 있게 해주기 때문에 유용합니다. 특히 리스트 열은 tidymodels 생태계에서 많이 사용되는데, 모델 출력이나 리샘플링과 같은 것들을 데이터 프레임에 저장할 수 있게 해주기 때문입니다.

다음은 리스트 열의 간단한 예입니다:

df <- tibble(
  x = 1:2, 
  y = c("a", "b"),
  z = list(list(1, 2), list(3, 4, 5))
)
df
#> # A tibble: 2 × 3
#>       x y     z         
#>   <int> <chr> <list>    
#> 1     1 a     <list [2]>
#> 2     2 b     <list [3]>

티블 안의 리스트라고 해서 특별한 점은 없으며, 다른 열과 똑같이 작동합니다:

df |> 
  filter(x == 1)
#> # A tibble: 1 × 3
#>       x y     z         
#>   <int> <chr> <list>    
#> 1     1 a     <list [2]>

리스트 열로 계산하는 것은 더 어렵지만, 그것은 일반적으로 리스트로 계산하는 것이 더 어렵기 때문입니다. 이에 대해서는 Chapter 26 에서 다시 다룰 것입니다. 이 장에서는 리스트 열을 일반 변수로 중첩 해제(unnesting)하여 기존 도구를 사용할 수 있도록 하는 데 집중할 것입니다.

기본 인쇄 방법은 내용의 대략적인 요약만 표시합니다. 리스트 열은 임의로 복잡할 수 있으므로 인쇄하는 좋은 방법이 없습니다. 그 내용을 보고 싶다면 리스트 열 하나만 뽑아내어 위에서 배운 기술 중 하나를 적용해야 합니다. 예: df |> pull(z) |> str() 또는 df |> pull(z) |> View().

Note기본 R

리스트를 data.frame의 열에 넣는 것은 가능하지만, data.frame()이 리스트를 열의 리스트로 취급하기 때문에 훨씬 더 까다롭습니다:

data.frame(x = list(1:3, 3:5))
#>   x.1.3 x.3.5
#> 1     1     3
#> 2     2     4
#> 3     3     5

리스트 I()로 감싸서 data.frame()이 리스트를 행의 리스트로 취급하도록 강제할 수 있지만, 결과가 특별히 잘 인쇄되지는 않습니다:

data.frame(
  x = I(list(1:2, 3:5)), 
  y = c("1, 2", "3, 4, 5")
)
#>         x       y
#> 1    1, 2    1, 2
#> 2 3, 4, 5 3, 4, 5

티블에서 리스트 열을 사용하는 것이 더 쉬운데, tibble()이 리스트를 벡터처럼 취급하고 인쇄 방법이 리스트를 염두에 두고 설계되었기 때문입니다.

23.3 중첩 해제(Unnesting)

리스트와 리스트 열의 기초를 배웠으니 이제 어떻게 일반 행과 열로 되돌릴 수 있는지 알아봅시다. 여기서는 기본 아이디어를 얻을 수 있도록 매우 간단한 샘플 데이터를 사용할 것입니다. 다음 섹션에서는 실제 데이터로 전환하겠습니다.

리스트 열은 일반적으로 명명된 것과 이름이 없는 것의 두 가지 기본 형태가 있습니다. 자식이 이름이 있는(named) 경우, 모든 행에서 동일한 이름을 갖는 경향이 있습니다. 예를 들어 df1에서 리스트 열 y의 모든 요소는 ab라는 이름의 두 요소를 갖습니다. 이름이 있는 리스트 열은 자연스럽게 열로 중첩 해제됩니다: 각 명명된 요소는 새로운 명명된 열이 됩니다.

df1 <- tribble(
  ~x, ~y,
  1, list(a = 11, b = 12),
  2, list(a = 21, b = 22),
  3, list(a = 31, b = 32),
)

자식이 이름이 없는(unnamed) 경우, 요소의 수는 행마다 다른 경향이 있습니다. 예를 들어 df2에서 리스트 열 y의 요소는 이름이 없고 길이가 1에서 3까지 다양합니다. 이름이 없는 리스트 열은 자연스럽게 행으로 중첩 해제됩니다: 각 자식에 대해 하나의 행을 얻게 됩니다.

df2 <- tribble(
  ~x, ~y,
  1, list(11, 12, 13),
  2, list(21),
  3, list(31, 32),
)

tidyr은 이 두 가지 경우를 위해 unnest_wider()unnest_longer()라는 두 가지 함수를 제공합니다. 다음 섹션에서 작동 방식을 설명합니다.

23.3.1 unnest_wider()

df1처럼 각 행이 동일한 이름을 가진 동일한 수의 요소를 가지고 있을 때, unnest_wider()를 사용하여 각 구성 요소를 자체 열에 넣는 것이 자연스럽습니다:

df1 |> 
  unnest_wider(y)
#> # A tibble: 3 × 3
#>       x     a     b
#>   <dbl> <dbl> <dbl>
#> 1     1    11    12
#> 2     2    21    22
#> 3     3    31    32

기본적으로 새 열의 이름은 전적으로 리스트 요소의 이름에서 가져오지만, names_sep 인수를 사용하여 열 이름과 요소 이름을 결합하도록 요청할 수 있습니다. 이는 반복되는 이름의 모호함을 해결하는 데 유용합니다.

df1 |> 
  unnest_wider(y, names_sep = "_")
#> # A tibble: 3 × 3
#>       x   y_a   y_b
#>   <dbl> <dbl> <dbl>
#> 1     1    11    12
#> 2     2    21    22
#> 3     3    31    32

23.3.2 unnest_longer()

각 행에 이름이 없는 리스트가 포함된 경우, unnest_longer()를 사용하여 각 요소를 자체 행에 넣는 것이 가장 자연스럽습니다:

df2 |> 
  unnest_longer(y)
#> # A tibble: 6 × 2
#>       x     y
#>   <dbl> <dbl>
#> 1     1    11
#> 2     1    12
#> 3     1    13
#> 4     2    21
#> 5     3    31
#> 6     3    32

y 내부의 각 요소에 대해 x가 어떻게 복제되는지 주목하세요: 리스트 열 내부의 각 요소에 대해 하나의 출력 행을 얻습니다. 하지만 다음 예제처럼 요소 중 하나가 비어 있으면 어떻게 될까요?

df6 <- tribble(
  ~x, ~y,
  "a", list(1, 2),
  "b", list(3),
  "c", list()
)
df6 |> unnest_longer(y)
#> # A tibble: 3 × 2
#>   x         y
#>   <chr> <dbl>
#> 1 a         1
#> 2 a         2
#> 3 b         3

출력에서 행이 0개가 되므로 행이 사실상 사라집니다. 해당 행을 보존하여 yNA를 추가하고 싶다면 keep_empty = TRUE를 설정하세요.

23.3.3 일관성 없는 유형

서로 다른 유형의 벡터를 포함하는 리스트 열을 중첩 해제하면 어떻게 될까요? 예를 들어 리스트 열 y에 두 개의 숫자, 문자, 논리형이 포함된 다음 데이터셋을 예로 들어보겠습니다. 이들은 일반적으로 단일 열에 섞일 수 없습니다.

df4 <- tribble(
  ~x, ~y,
  "a", list(1),
  "b", list("a", TRUE, 5)
)

unnest_longer()는 행의 수를 변경하면서 항상 열의 집합을 변경하지 않은 상태로 유지합니다. 그렇다면 어떤 일이 벌어질까요? unnest_longer()는 어떻게 y에 있는 모든 것을 유지하면서 5개의 행을 생성할까요?

df4 |> 
  unnest_longer(y)
#> # A tibble: 4 × 2
#>   x     y        
#>   <chr> <list>   
#> 1 a     <dbl [1]>
#> 2 b     <chr [1]>
#> 3 b     <lgl [1]>
#> 4 b     <dbl [1]>

보시다시피 출력에는 리스트 열이 포함되어 있지만 리스트 열의 모든 요소에는 단일 요소가 포함되어 있습니다. unnest_longer()가 벡터의 공통 유형을 찾을 수 없기 때문에 원래 유형을 리스트 열에 유지합니다. 이것이 모든 열의 요소가 동일한 유형이어야 한다는 계명을 어기는 것인지 궁금할 수 있습니다. 그렇지 않습니다: 내용이 다른 유형이더라도 모든 요소는 리스트입니다.

일관성 없는 유형을 다루는 것은 어렵고 세부 사항은 문제의 정확한 성격과 목표에 달려 있지만, 아마도 Chapter 26 의 도구가 필요할 것입니다.

23.3.4 기타 함수

tidyr에는 이 책에서 다루지 않을 몇 가지 다른 유용한 직사각형화 함수가 있습니다:

  • unnest_auto()는 리스트 열의 구조를 기반으로 unnest_longer()unnest_wider() 중에서 자동으로 선택합니다. 빠른 탐색에는 좋지만, 궁극적으로는 데이터가 어떻게 구성되어 있는지 이해하도록 강제하지 않고 코드를 이해하기 어렵게 만들기 때문에 좋지 않은 생각입니다.
  • unnest()는 행과 열을 모두 확장합니다. 리스트 열에 데이터 프레임과 같은 2차원 구조가 포함된 경우에 유용합니다. 이 책에서는 보지 못하겠지만 tidymodels 생태계를 사용한다면 만날 수도 있습니다.

이러한 함수들은 다른 사람의 코드를 읽거나 더 드문 직사각형화 문제에 직면했을 때 접할 수 있으므로 알고 있는 것이 좋습니다.

23.3.5 연습문제

  1. df2와 같은 이름이 없는 리스트 열에 unnest_wider()를 사용하면 어떻게 됩니까? 이제 어떤 인수가 필요합니까? 결측값은 어떻게 됩니까?

  2. df1과 같은 이름이 있는 리스트 열에 unnest_longer()를 사용하면 어떻게 됩니까? 출력에서 어떤 추가 정보를 얻습니까? 그 추가 세부 정보를 어떻게 억제할 수 있습니까?

  3. 때때로 값이 정렬된 여러 리스트 열이 있는 데이터 프레임을 만나게 됩니다. 예를 들어 다음 데이터 프레임에서 yz의 값은 정렬되어 있습니다(즉, yz는 행 내에서 항상 같은 길이를 가지며, y의 첫 번째 값은 z의 첫 번째 값에 해당합니다). 이 데이터 프레임에 두 번의 unnest_longer() 호출을 적용하면 어떻게 됩니까? xy 사이의 관계를 어떻게 보존할 수 있습니까? (힌트: 문서를 주의 깊게 읽으세요).

    df4 <- tribble(
      ~x, ~y, ~z,
      "a", list("y-a-1", "y-a-2"), list("z-a-1", "z-a-2"),
      "b", list("y-b-1", "y-b-2", "y-b-3"), list("z-b-1", "z-b-2", "z-b-3")
    )

23.4 사례 연구

위에서 사용한 간단한 예제와 실제 데이터의 주요 차이점은 실제 데이터에는 일반적으로 여러 번의 unnest_longer() 및/또는 unnest_wider() 호출이 필요한 여러 수준의 중첩이 포함되어 있다는 것입니다. 이를 실제로 보여주기 위해 이 섹션에서는 repurrrsive 패키지의 데이터셋을 사용하여 세 가지 실제 직사각형화 과제를 해결합니다.

23.4.1 매우 넓은 데이터

gh_repos부터 시작하겠습니다. 이것은 GitHub API를 사용하여 검색된 GitHub 저장소 컬렉션에 대한 데이터가 포함된 리스트입니다. 매우 깊게 중첩된 리스트이므로 이 책에서 구조를 보여주기가 어렵습니다. 계속하기 전에 View(gh_repos)로 직접 탐색해 보는 것을 추천합니다.

gh_repos는 리스트이지만 우리의 도구는 리스트 열과 함께 작동하므로 먼저 티블에 넣는 것으로 시작하겠습니다. 나중에 설명할 이유 때문에 이 열을 json이라고 부릅니다.

repos <- tibble(json = gh_repos)
repos
#> # A tibble: 6 × 1
#>   json       
#>   <list>     
#> 1 <list [30]>
#> 2 <list [30]>
#> 3 <list [30]>
#> 4 <list [26]>
#> 5 <list [30]>
#> 6 <list [30]>

이 티블에는 gh_repos의 각 자식에 대해 하나씩 6개의 행이 포함되어 있습니다. 각 행에는 26개 또는 30개의 행이 있는 이름이 없는 리스트가 포함되어 있습니다. 이들은 이름이 없으므로 각 자식을 자체 행에 넣기 위해 unnest_longer()로 시작하겠습니다:

repos |> 
  unnest_longer(json)
#> # A tibble: 176 × 1
#>   json             
#>   <list>           
#> 1 <named list [68]>
#> 2 <named list [68]>
#> 3 <named list [68]>
#> 4 <named list [68]>
#> 5 <named list [68]>
#> 6 <named list [68]>
#> # ℹ 170 more rows

처음에는 상황이 개선되지 않은 것처럼 보일 수 있습니다: 행은 더 많아졌지만(6개 대신 176개) json의 각 요소는 여전히 리스트입니다. 하지만 중요한 차이점이 있습니다: 이제 각 요소가 이름이 있는 리스트이므로 unnest_wider()를 사용하여 각 요소를 자체 열에 넣을 수 있습니다:

repos |> 
  unnest_longer(json) |> 
  unnest_wider(json) 
#> # A tibble: 176 × 68
#>         id name        full_name         owner        private html_url       
#>      <int> <chr>       <chr>             <list>       <lgl>   <chr>          
#> 1 61160198 after       gaborcsardi/after <named list> FALSE   https://github…
#> 2 40500181 argufy      gaborcsardi/argu… <named list> FALSE   https://github…
#> 3 36442442 ask         gaborcsardi/ask   <named list> FALSE   https://github…
#> 4 34924886 baseimports gaborcsardi/base… <named list> FALSE   https://github…
#> 5 61620661 citest      gaborcsardi/cite… <named list> FALSE   https://github…
#> 6 33907457 clisymbols  gaborcsardi/clis… <named list> FALSE   https://github…
#> # ℹ 170 more rows
#> # ℹ 62 more variables: description <chr>, fork <lgl>, url <chr>, …

작동은 했지만 결과가 약간 압도적입니다: 열이 너무 많아서 티블이 전부 인쇄하지도 못할 정도입니다! names()로 모두 볼 수 있으며, 여기서는 처음 10개를 살펴봅니다:

repos |> 
  unnest_longer(json) |> 
  unnest_wider(json) |> 
  names() |> 
  head(10)
#>  [1] "id"          "name"        "full_name"   "owner"       "private"    
#>  [6] "html_url"    "description" "fork"        "url"         "forks_url"

흥미로워 보이는 것 몇 개를 뽑아보겠습니다:

repos |> 
  unnest_longer(json) |> 
  unnest_wider(json) |> 
  select(id, full_name, owner, description)
#> # A tibble: 176 × 4
#>         id full_name               owner             description             
#>      <int> <chr>                   <list>            <chr>                   
#> 1 61160198 gaborcsardi/after       <named list [17]> Run Code in the Backgro…
#> 2 40500181 gaborcsardi/argufy      <named list [17]> Declarative function ar…
#> 3 36442442 gaborcsardi/ask         <named list [17]> Friendly CLI interactio…
#> 4 34924886 gaborcsardi/baseimports <named list [17]> Do we get warnings for …
#> 5 61620661 gaborcsardi/citest      <named list [17]> Test R package and repo…
#> 6 33907457 gaborcsardi/clisymbols  <named list [17]> Unicode symbols for CLI…
#> # ℹ 170 more rows

이것을 사용하여 gh_repos가 어떻게 구성되었는지 거꾸로 이해할 수 있습니다: 각 자식은 자신이 만든 최대 30개의 GitHub 저장소 리스트를 포함하는 GitHub 사용자였습니다.

owner는 또 다른 리스트 열이며 이름이 있는 리스트를 포함하므로 unnest_wider()를 사용하여 값을 가져올 수 있습니다:

repos |> 
  unnest_longer(json) |> 
  unnest_wider(json) |> 
  select(id, full_name, owner, description) |> 
  unnest_wider(owner)
#> Error in `unnest_wider()`:
#> ! Can't duplicate names between the affected columns and the original
#>   data.
#> ✖ These names are duplicated:
#>   ℹ `id`, from `owner`.
#> ℹ Use `names_sep` to disambiguate using the column name.
#> ℹ Or use `names_repair` to specify a repair strategy.

이런, 이 리스트 열에도 id 열이 포함되어 있고 동일한 데이터 프레임에 두 개의 id 열을 가질 수 없습니다. 제안된 대로 names_sep을 사용하여 문제를 해결해 보겠습니다:

repos |> 
  unnest_longer(json) |> 
  unnest_wider(json) |> 
  select(id, full_name, owner, description) |> 
  unnest_wider(owner, names_sep = "_")
#> # A tibble: 176 × 20
#>         id full_name               owner_login owner_id owner_avatar_url     
#>      <int> <chr>                   <chr>          <int> <chr>                
#> 1 61160198 gaborcsardi/after       gaborcsardi   660288 https://avatars.gith…
#> 2 40500181 gaborcsardi/argufy      gaborcsardi   660288 https://avatars.gith…
#> 3 36442442 gaborcsardi/ask         gaborcsardi   660288 https://avatars.gith…
#> 4 34924886 gaborcsardi/baseimports gaborcsardi   660288 https://avatars.gith…
#> 5 61620661 gaborcsardi/citest      gaborcsardi   660288 https://avatars.gith…
#> 6 33907457 gaborcsardi/clisymbols  gaborcsardi   660288 https://avatars.gith…
#> # ℹ 170 more rows
#> # ℹ 15 more variables: owner_gravatar_id <chr>, owner_url <chr>, …

이렇게 하면 또 다른 넓은 데이터셋이 생성되지만, owner에 저장소를 “소유”한 사람에 대한 많은 추가 데이터가 포함되어 있음을 알 수 있습니다.

23.4.2 관계형 데이터

중첩된 데이터는 때때로 우리가 보통 여러 데이터 프레임에 분산시킬 데이터를 나타내는 데 사용됩니다. 예를 들어 왕좌의 게임(Game of Thrones) 책과 TV 시리즈에 등장하는 인물에 대한 데이터가 포함된 got_chars를 예로 들어보겠습니다. gh_repos와 마찬가지로 리스트이므로 먼저 티블의 리스트 열로 변환하는 것으로 시작합니다:

chars <- tibble(json = got_chars)
chars
#> # A tibble: 30 × 1
#>   json             
#>   <list>           
#> 1 <named list [18]>
#> 2 <named list [18]>
#> 3 <named list [18]>
#> 4 <named list [18]>
#> 5 <named list [18]>
#> 6 <named list [18]>
#> # ℹ 24 more rows

json 열에는 이름이 있는 요소가 포함되어 있으므로 먼저 넓게 만드는 것으로 시작하겠습니다:

chars |> 
  unnest_wider(json)
#> # A tibble: 30 × 18
#>   url                    id name            gender culture    born           
#>   <chr>               <int> <chr>           <chr>  <chr>      <chr>          
#> 1 https://www.anapio…  1022 Theon Greyjoy   Male   "Ironborn" "In 278 AC or …
#> 2 https://www.anapio…  1052 Tyrion Lannist… Male   ""         "In 273 AC, at…
#> 3 https://www.anapio…  1074 Victarion Grey… Male   "Ironborn" "In 268 AC or …
#> 4 https://www.anapio…  1109 Will            Male   ""         ""             
#> 5 https://www.anapio…  1166 Areo Hotah      Male   "Norvoshi" "In 257 AC or …
#> 6 https://www.anapio…  1267 Chett           Male   ""         "At Hag's Mire"
#> # ℹ 24 more rows
#> # ℹ 12 more variables: died <chr>, alive <lgl>, titles <list>, …

그리고 읽기 쉽게 몇 개의 열을 선택합니다:

characters <- chars |> 
  unnest_wider(json) |> 
  select(id, name, gender, culture, born, died, alive)
characters
#> # A tibble: 30 × 7
#>      id name              gender culture    born              died           
#>   <int> <chr>             <chr>  <chr>      <chr>             <chr>          
#> 1  1022 Theon Greyjoy     Male   "Ironborn" "In 278 AC or 27… ""             
#> 2  1052 Tyrion Lannister  Male   ""         "In 273 AC, at C… ""             
#> 3  1074 Victarion Greyjoy Male   "Ironborn" "In 268 AC or be… ""             
#> 4  1109 Will              Male   ""         ""                "In 297 AC, at…
#> 5  1166 Areo Hotah        Male   "Norvoshi" "In 257 AC or be… ""             
#> 6  1267 Chett             Male   ""         "At Hag's Mire"   "In 299 AC, at…
#> # ℹ 24 more rows
#> # ℹ 1 more variable: alive <lgl>

이 데이터셋에는 많은 리스트 열도 포함되어 있습니다:

chars |> 
  unnest_wider(json) |> 
  select(id, where(is.list))
#> # A tibble: 30 × 8
#>      id titles    aliases    allegiances books     povBooks tvSeries playedBy
#>   <int> <list>    <list>     <list>      <list>    <list>   <list>   <list>  
#> 1  1022 <chr [2]> <chr [4]>  <chr [1]>   <chr [3]> <chr>    <chr>    <chr>   
#> 2  1052 <chr [2]> <chr [11]> <chr [1]>   <chr [2]> <chr>    <chr>    <chr>   
#> 3  1074 <chr [2]> <chr [1]>  <chr [1]>   <chr [3]> <chr>    <chr>    <chr>   
#> 4  1109 <chr [1]> <chr [1]>  <NULL>      <chr [1]> <chr>    <chr>    <chr>   
#> 5  1166 <chr [1]> <chr [1]>  <chr [1]>   <chr [3]> <chr>    <chr>    <chr>   
#> 6  1267 <chr [1]> <chr [1]>  <NULL>      <chr [2]> <chr>    <chr>    <chr>   
#> # ℹ 24 more rows

titles 열을 살펴보겠습니다. 이름이 없는 리스트 열이므로 행으로 중첩 해제합니다:

chars |> 
  unnest_wider(json) |> 
  select(id, titles) |> 
  unnest_longer(titles)
#> # A tibble: 59 × 2
#>      id titles                                              
#>   <int> <chr>                                               
#> 1  1022 Prince of Winterfell                                
#> 2  1022 Lord of the Iron Islands (by law of the green lands)
#> 3  1052 Acting Hand of the King (former)                    
#> 4  1052 Master of Coin (former)                             
#> 5  1074 Lord Captain of the Iron Fleet                      
#> 6  1074 Master of the Iron Victory                          
#> # ℹ 53 more rows

이 데이터는 필요에 따라 인물 데이터에 조인하기 쉬울 것이기 때문에 자체 테이블에서 볼 수 있을 것이라고 예상할 수 있습니다. 그렇게 해보겠습니다. 약간의 정리가 필요합니다: 빈 문자열을 포함하는 행을 제거하고, 각 행에 이제 단일 제목만 포함되므로 titlestitle로 이름을 바꿉니다.

titles <- chars |> 
  unnest_wider(json) |> 
  select(id, titles) |> 
  unnest_longer(titles) |> 
  filter(titles != "") |> 
  rename(title = titles)
titles
#> # A tibble: 52 × 2
#>      id title                                               
#>   <int> <chr>                                               
#> 1  1022 Prince of Winterfell                                
#> 2  1022 Lord of the Iron Islands (by law of the green lands)
#> 3  1052 Acting Hand of the King (former)                    
#> 4  1052 Master of Coin (former)                             
#> 5  1074 Lord Captain of the Iron Fleet                      
#> 6  1074 Master of the Iron Victory                          
#> # ℹ 46 more rows

각 리스트 열에 대해 이와 같은 테이블을 만든 다음, 필요에 따라 조인을 사용하여 인물 데이터와 결합하는 것을 상상할 수 있습니다.

23.4.3 깊게 중첩된 데이터

매우 깊게 중첩되어 있어 반복적인 unnest_wider()unnest_longer() 호출이 필요한 리스트 열인 gmaps_cities로 이 사례 연구를 마무리하겠습니다. 이것은 5개의 도시 이름과 구글의 지오코딩 API를 사용하여 해당 위치를 확인한 결과가 포함된 두 개의 열로 된 티블입니다:

gmaps_cities
#> # A tibble: 5 × 2
#>   city       json            
#>   <chr>      <list>          
#> 1 Houston    <named list [2]>
#> 2 Washington <named list [2]>
#> 3 New York   <named list [2]>
#> 4 Chicago    <named list [2]>
#> 5 Arlington  <named list [2]>

json은 내부 이름이 있는 리스트 열이므로 unnest_wider()로 시작합니다:

gmaps_cities |> 
  unnest_wider(json)
#> # A tibble: 5 × 3
#>   city       results    status
#>   <chr>      <list>     <chr> 
#> 1 Houston    <list [1]> OK    
#> 2 Washington <list [2]> OK    
#> 3 New York   <list [1]> OK    
#> 4 Chicago    <list [1]> OK    
#> 5 Arlington  <list [2]> OK

이렇게 하면 statusresults를 얻게 됩니다. 상태가 모두 OK이므로 상태 열은 삭제하겠습니다. 실제 분석에서는 status != "OK"인 모든 행을 캡처하고 무엇이 잘못되었는지 파악하고 싶을 것입니다. results는 하나 또는 두 개의 요소를 가진 이름이 없는 리스트이므로(이유는 곧 알게 될 것입니다) 행으로 중첩 해제합니다:

gmaps_cities |> 
  unnest_wider(json) |> 
  select(-status) |> 
  unnest_longer(results)
#> # A tibble: 7 × 2
#>   city       results         
#>   <chr>      <list>          
#> 1 Houston    <named list [5]>
#> 2 Washington <named list [5]>
#> 3 Washington <named list [5]>
#> 4 New York   <named list [5]>
#> 5 Chicago    <named list [5]>
#> 6 Arlington  <named list [5]>
#> # ℹ 1 more row

이제 results는 이름이 있는 리스트이므로 unnest_wider()를 사용합니다:

locations <- gmaps_cities |> 
  unnest_wider(json) |> 
  select(-status) |> 
  unnest_longer(results) |> 
  unnest_wider(results)
locations
#> # A tibble: 7 × 6
#>   city       address_components formatted_address   geometry        
#>   <chr>      <list>             <chr>               <list>          
#> 1 Houston    <list [4]>         Houston, TX, USA    <named list [4]>
#> 2 Washington <list [2]>         Washington, USA     <named list [4]>
#> 3 Washington <list [4]>         Washington, DC, USA <named list [4]>
#> 4 New York   <list [3]>         New York, NY, USA   <named list [4]>
#> 5 Chicago    <list [4]>         Chicago, IL, USA    <named list [4]>
#> 6 Arlington  <list [4]>         Arlington, TX, USA  <named list [4]>
#> # ℹ 1 more row
#> # ℹ 2 more variables: place_id <chr>, types <list>

이제 왜 두 도시가 두 개의 결과를 얻었는지 알 수 있습니다: Washington은 워싱턴 주와 워싱턴 DC 모두와 일치했고, Arlington은 버지니아 주 알링턴과 텍사스 주 알링턴과 일치했습니다.

여기서부터 갈 수 있는 몇 가지 다른 방향이 있습니다. geometry 리스트 열에 저장된 일치 항목의 정확한 위치를 확인하고 싶을 수 있습니다:

locations |> 
  select(city, formatted_address, geometry) |> 
  unnest_wider(geometry)
#> # A tibble: 7 × 6
#>   city       formatted_address   bounds           location     location_type
#>   <chr>      <chr>               <list>           <list>       <chr>        
#> 1 Houston    Houston, TX, USA    <named list [2]> <named list> APPROXIMATE  
#> 2 Washington Washington, USA     <named list [2]> <named list> APPROXIMATE  
#> 3 Washington Washington, DC, USA <named list [2]> <named list> APPROXIMATE  
#> 4 New York   New York, NY, USA   <named list [2]> <named list> APPROXIMATE  
#> 5 Chicago    Chicago, IL, USA    <named list [2]> <named list> APPROXIMATE  
#> 6 Arlington  Arlington, TX, USA  <named list [2]> <named list> APPROXIMATE  
#> # ℹ 1 more row
#> # ℹ 1 more variable: viewport <list>

그러면 새로운 bounds(직사각형 영역)와 location(점)을 얻게 됩니다. location을 중첩 해제하여 위도(lat)와 경도(lng)를 볼 수 있습니다:

locations |> 
  select(city, formatted_address, geometry) |> 
  unnest_wider(geometry) |> 
  unnest_wider(location)
#> # A tibble: 7 × 7
#>   city       formatted_address   bounds             lat    lng location_type
#>   <chr>      <chr>               <list>           <dbl>  <dbl> <chr>        
#> 1 Houston    Houston, TX, USA    <named list [2]>  29.8  -95.4 APPROXIMATE  
#> 2 Washington Washington, USA     <named list [2]>  47.8 -121.  APPROXIMATE  
#> 3 Washington Washington, DC, USA <named list [2]>  38.9  -77.0 APPROXIMATE  
#> 4 New York   New York, NY, USA   <named list [2]>  40.7  -74.0 APPROXIMATE  
#> 5 Chicago    Chicago, IL, USA    <named list [2]>  41.9  -87.6 APPROXIMATE  
#> 6 Arlington  Arlington, TX, USA  <named list [2]>  32.7  -97.1 APPROXIMATE  
#> # ℹ 1 more row
#> # ℹ 1 more variable: viewport <list>

경계(bounds)를 추출하려면 몇 단계가 더 필요합니다:

locations |> 
  select(city, formatted_address, geometry) |> 
  unnest_wider(geometry) |> 
  # 관심 있는 변수에 집중
  select(!location:viewport) |>
  unnest_wider(bounds)
#> # A tibble: 7 × 4
#>   city       formatted_address   northeast        southwest       
#>   <chr>      <chr>               <list>           <list>          
#> 1 Houston    Houston, TX, USA    <named list [2]> <named list [2]>
#> 2 Washington Washington, USA     <named list [2]> <named list [2]>
#> 3 Washington Washington, DC, USA <named list [2]> <named list [2]>
#> 4 New York   New York, NY, USA   <named list [2]> <named list [2]>
#> 5 Chicago    Chicago, IL, USA    <named list [2]> <named list [2]>
#> 6 Arlington  Arlington, TX, USA  <named list [2]> <named list [2]>
#> # ℹ 1 more row

그런 다음 southwestnortheast(직사각형의 모서리)의 이름을 변경하여 names_sep을 사용하여 짧으면서도 기억하기 쉬운 이름을 만들 수 있습니다:

locations |> 
  select(city, formatted_address, geometry) |> 
  unnest_wider(geometry) |> 
  select(!location:viewport) |>
  unnest_wider(bounds) |> 
  rename(ne = northeast, sw = southwest) |> 
  unnest_wider(c(ne, sw), names_sep = "_") 
#> # A tibble: 7 × 6
#>   city       formatted_address   ne_lat ne_lng sw_lat sw_lng
#>   <chr>      <chr>                <dbl>  <dbl>  <dbl>  <dbl>
#> 1 Houston    Houston, TX, USA      30.1  -95.0   29.5  -95.8
#> 2 Washington Washington, USA       49.0 -117.    45.5 -125. 
#> 3 Washington Washington, DC, USA   39.0  -76.9   38.8  -77.1
#> 4 New York   New York, NY, USA     40.9  -73.7   40.5  -74.3
#> 5 Chicago    Chicago, IL, USA      42.0  -87.5   41.6  -87.9
#> 6 Arlington  Arlington, TX, USA    32.8  -97.0   32.6  -97.2
#> # ℹ 1 more row

unnest_wider()에 변수 이름 벡터를 제공하여 두 열을 동시에 중첩 해제하는 방법에 유의하세요.

관심 있는 구성 요소에 도달하는 경로를 발견했다면 또 다른 tidyr 함수인 hoist()를 사용하여 직접 추출할 수 있습니다:

locations |> 
  select(city, formatted_address, geometry) |> 
  hoist(
    geometry,
    ne_lat = c("bounds", "northeast", "lat"),
    sw_lat = c("bounds", "southwest", "lat"),
    ne_lng = c("bounds", "northeast", "lng"),
    sw_lng = c("bounds", "southwest", "lng"),
  )

이러한 사례 연구가 실제 직사각형화에 대한 흥미를 유발했다면 vignette("rectangling", package = "tidyr")에서 몇 가지 예제를 더 볼 수 있습니다.

23.4.4 연습문제

  1. gh_repos가 언제 생성되었는지 대략적으로 추정해 보세요. 왜 날짜를 대략적으로만 추정할 수 있습니까?

  2. 각 소유자가 많은 저장소를 가질 수 있기 때문에 gh_repoowner 열에는 중복된 정보가 많이 포함되어 있습니다. 각 소유자에 대해 하나의 행을 포함하는 owners 데이터 프레임을 구성할 수 있습니까? (힌트: distinct()list-cols와 함께 작동합니까?)

  3. titles에 사용된 단계를 따라 왕좌의 게임 인물들의 별칭(aliases), 충성(allegiances), 책(books) 및 TV 시리즈(TV series)에 대한 유사한 테이블을 만드세요.

  4. 다음 코드를 한 줄씩 설명하세요. 왜 흥미롭습니까? 왜 got_chars에서는 작동하지만 일반적으로는 작동하지 않을 수 있습니까?

    tibble(json = got_chars) |> 
      unnest_wider(json) |> 
      select(id, where(is.list)) |> 
      pivot_longer(
        where(is.list), 
        names_to = "name", 
        values_to = "value"
      ) |>  
      unnest_longer(value)
  5. gmaps_cities에서 address_components에는 무엇이 들어 있습니까? 왜 행마다 길이가 다릅니까? 그것을 파악하기 위해 적절하게 중첩 해제하세요. (힌트: types는 항상 두 개의 요소를 포함하는 것으로 보입니다. unnest_longer()보다 unnest_wider()가 작업하기 더 쉬운가요?)

23.5 JSON

이전 섹션의 모든 사례 연구는 야생에서 수집된 JSON에서 가져온 것입니다. JSON은 javascript object notation의 약자로 대부분의 웹 API가 데이터를 반환하는 방식입니다. JSON과 R의 데이터 유형은 꽤 비슷하지만 완벽한 1대1 매핑은 아니므로 문제가 발생할 경우를 대비해 JSON에 대해 조금 이해하는 것이 중요합니다.

23.5.1 데이터 유형

JSON은 인간이 아니라 기계가 쉽게 읽고 쓸 수 있도록 설계된 간단한 형식입니다. 6가지 주요 데이터 유형이 있습니다. 그중 4개는 스칼라(scalar)입니다:

  • 가장 간단한 유형은 null(null)로 R의 NA와 같은 역할을 합니다. 데이터의 부재를 나타냅니다.
  • 문자열(string) 은 R의 문자열과 매우 유사하지만 항상 큰따옴표를 사용해야 합니다.
  • 숫자(number) 는 R의 숫자와 유사합니다: 정수(예: 123), 소수(예: 123.45) 또는 지수(예: 1.23e3) 표기법을 사용할 수 있습니다. JSON은 Inf, -Inf 또는 NaN을 지원하지 않습니다.
  • 불리언(boolean) 은 R의 TRUEFALSE와 유사하지만 소문자 truefalse를 사용합니다.

JSON의 문자열, 숫자 및 불리언은 R의 문자형, 수치형 및 논리형 벡터와 매우 유사합니다. 주요 차이점은 JSON의 스칼라는 단일 값만 나타낼 수 있다는 것입니다. 여러 값을 나타내려면 나머지 두 가지 유형인 배열과 객체 중 하나를 사용해야 합니다.

배열과 객체는 모두 R의 리스트와 유사합니다. 차이점은 이름이 있는지 여부입니다. 배열(array) 은 이름이 없는 리스트와 같으며 []로 씁니다. 예를 들어 [1, 2, 3]은 3개의 숫자를 포함하는 배열이고, [null, 1, "string", false]는 null, 숫자, 문자열 및 불리언을 포함하는 배열입니다. 객체(object) 는 이름이 있는 리스트와 같으며 {}로 씁니다. 이름(JSON 용어로는 키)은 문자열이므로 따옴표로 둘러싸야 합니다. 예를 들어 {"x": 1, "y": 2}x를 1에, y를 2에 매핑하는 객체입니다.

JSON에는 날짜나 날짜-시간을 나타내는 기본 방법이 없으므로 종종 문자열로 저장되며, 올바른 데이터 구조로 변환하려면 readr::parse_date() 또는 readr::parse_datetime()을 사용해야 합니다. 마찬가지로 JSON에서 부동 소수점 숫자를 나타내는 규칙은 약간 부정확하므로 때때로 문자열에 저장된 숫자를 발견할 수도 있습니다. 올바른 변수 유형을 얻기 위해 필요에 따라 readr::parse_double()을 적용하세요.

23.5.2 jsonlite

JSON을 R 데이터 구조로 변환하려면 Jeroen Ooms가 만든 jsonlite 패키지를 권장합니다. 여기서는 두 가지 jsonlite 함수인 read_json()parse_json()만 사용합니다. 실제 생활에서는 디스크에서 JSON 파일을 읽기 위해 read_json()을 사용하게 될 것입니다. 예를 들어 repurrrsive 패키지는 gh_user에 대한 소스를 JSON 파일로 제공하며 read_json()으로 읽을 수 있습니다:

# 패키지 내부의 json 파일 경로:
gh_users_json()
#> [1] "/Users/jinhwan/Library/R/arm64/4.5/library/repurrrsive/extdata/gh_users.json"

# read_json()으로 읽기
gh_users2 <- read_json(gh_users_json())

# 이전에 사용하던 데이터와 동일한지 확인
identical(gh_users, gh_users2)
#> [1] TRUE

이 책에서는 parse_json()도 사용하는데, JSON을 포함하는 문자열을 인수로 받기 때문에 간단한 예제를 생성하는 데 좋기 때문입니다. 시작하기 위해 숫자 하나로 시작하여 배열에 몇 개의 숫자를 넣고, 그 배열을 객체에 넣는 세 가지 간단한 JSON 데이터셋이 있습니다:

str(parse_json('1'))
#>  int 1
str(parse_json('[1, 2, 3]'))
#> List of 3
#>  $ : int 1
#>  $ : int 2
#>  $ : int 3
str(parse_json('{"x": [1, 2, 3]}'))
#> List of 1
#>  $ x:List of 3
#>   ..$ : int 1
#>   ..$ : int 2
#>   ..$ : int 3

jsonlite에는 fromJSON()이라는 또 다른 중요한 함수가 있습니다. 여기서는 사용하지 않는데, 자동 단순화(simplifyVector = TRUE)를 수행하기 때문입니다. 이것은 특히 간단한 경우에 잘 작동하지만, 무슨 일이 일어나는지 정확히 알 수 있고 가장 복잡한 중첩 구조를 더 쉽게 처리할 수 있도록 직접 직사각형화를 수행하는 것이 더 낫다고 생각합니다.

23.5.3 직사각형화 프로세스 시작하기

대부분의 경우 JSON 파일은 최상위 배열 하나를 포함합니다. 여러 “것”(예: 여러 페이지, 여러 레코드 또는 여러 결과)에 대한 데이터를 제공하도록 설계되었기 때문입니다. 이 경우 각 요소가 행이 되도록 tibble(json)으로 직사각형화를 시작합니다:

json <- '[
  {"name": "John", "age": 34},
  {"name": "Susan", "age": 27}
]'
df <- tibble(json = parse_json(json))
df
#> # A tibble: 2 × 1
#>   json            
#>   <list>          
#> 1 <named list [2]>
#> 2 <named list [2]>

df |> 
  unnest_wider(json)
#> # A tibble: 2 × 2
#>   name    age
#>   <chr> <int>
#> 1 John     34
#> 2 Susan    27

더 드문 경우로, JSON 파일이 하나의 “것”을 나타내는 최상위 JSON 객체 하나로 구성되는 경우가 있습니다. 이 경우 티블에 넣기 전에 리스트로 감싸서 직사각형화 프로세스를 시작해야 합니다.

json <- '{
  "status": "OK", 
  "results": [
    {"name": "John", "age": 34},
    {"name": "Susan", "age": 27}
 ]
}
'
df <- tibble(json = list(parse_json(json)))
df
#> # A tibble: 1 × 1
#>   json            
#>   <list>          
#> 1 <named list [2]>

df |> 
  unnest_wider(json) |> 
  unnest_longer(results) |> 
  unnest_wider(results)
#> # A tibble: 2 × 3
#>   status name    age
#>   <chr>  <chr> <int>
#> 1 OK     John     34
#> 2 OK     Susan    27

대안으로 파싱된 JSON 내부를 파고들어 실제로 관심 있는 부분부터 시작할 수 있습니다:

df <- tibble(results = parse_json(json)$results)
df |> 
  unnest_wider(results)
#> # A tibble: 2 × 2
#>   name    age
#>   <chr> <int>
#> 1 John     34
#> 2 Susan    27

23.5.4 연습문제

  1. 아래의 df_coldf_row를 직사각형화하세요. 이들은 JSON에서 데이터 프레임을 인코딩하는 두 가지 방법을 나타냅니다.

    json_col <- parse_json('
      {
        "x": ["a", "x", "z"],
        "y": [10, null, 3]
      }
    ')
    json_row <- parse_json('
      [
        {"x": "a", "y": 10},
        {"x": "x", "y": null},
        {"x": "z", "y": 3}
      ]
    ')
    
    df_col <- tibble(json = list(json_col)) 
    df_row <- tibble(json = json_row)

23.6 요약

이 장에서는 리스트가 무엇인지, JSON 파일에서 어떻게 리스트를 생성할 수 있는지, 그리고 그것들을 어떻게 직사각형 데이터 프레임으로 변환하는지 배웠습니다. 놀랍게도 리스트 요소를 행에 넣기 위한 unnest_longer()와 리스트 요소를 열에 넣기 위한 unnest_wider()라는 두 개의 새로운 함수만 필요했습니다. 리스트 열이 얼마나 깊게 중첩되어 있는지는 중요하지 않습니다. 이 두 함수를 반복해서 호출하기만 하면 됩니다.

JSON은 웹 API가 반환하는 가장 일반적인 데이터 형식입니다. 웹사이트에 API가 없지만 웹사이트에서 원하는 데이터를 볼 수 있다면 어떻게 될까요? 그것이 다음 장의 주제입니다: 웹 스크래핑, 즉 HTML 웹페이지에서 데이터를 추출하는 것입니다.


  1. 이것은 RStudio 기능입니다.↩︎