톨스토이 장편소설 “안나 카레니나”에 나오는 유명한 문구로 행복한 가정 원칙이 있다.

“행복한 가정은 모두 비슷한 이유로 행복하지만 불행한 가정은 저마다의 이유로 불행하다.” (“Все счастливые семьи похожи друг на друга, каждая несчастливая семья несчастлива по-своему”, Happy families are all alike, but every unhappy family is unhappy in its own way.)

유발 하라리도 이를 차용하여 “세상에 실패한 국가의 유형도 각기 저마다의 이유로 다르지만, 성공한 국가는 비슷한 이유를 갖는다”라고 주장했고, 해들리 위컴(Hadley Wickham)도 “Like families, tidy datasets are all alike but every messy dataset is messy in its own way.” Tidy Data에 대한 정의를 내리면서 동일한 주장을 했다.

1 데이터 깔끔화 1 2

깔끔한 데이터(Tidy datasets)이 준비되면, 데이터를 조작하고, 모형화하고, 시각화가 용이하다. 또한 깔끔한 데이터는 특정한 구조를 갖추고 있는데 변수 는 열(column)이고, 관측점 은 행(row)이며, 행과 열이 교차하는 셀(Cell)은 값(Value)이 위치하게 되고 관측단위에 대한 형태 는 테이블(table)로 구성된다.

깔끔한 데이터 원칙은 코드(Codd) 박사의 관계대수(relational algebra) 와 깊은 관련이 있지만, 통계학 전공자에게 친숙한 언어로 표현된다.

해당 데이터셋에 관측점과 변수를 각각 식별하는 것이 쉽게 생각되지만, 일반적으로 변수와 관측점을 정확하게 정의하는 것이 놀랍게도 어렵다. 따라서, 행과 행보다는 변수간 기능적 관계(functional relationship)를 기술하는 것이 더 쉽다. 또한, 칼럼 그룹집단과 집단보다는 관측점 그룹집단 사이 비교를 하는 것이 더 쉽다.

1.1 깔끔한 데이터

깔끔한 데이터는 데이터셋의 의미를 구조에 매칭하는 표준적인 방식이다. 행, 열, 테이블이 관측점, 변수, 형식에 매칭되는 방식에 따라 데이터셋이 깔끔하거나 깔끔하지 않은 것으로 나뉜다.

깔끔한 데이터(tidy data) 는 결국 데이터분석을 쉽게 할 수 있는 데이터다.

  1. 각 변수가 칼럼이 된다.
  2. 각 관측점은 행이 된다.
  3. 각 셀은 값이 된다.
  4. 관측 단위에 대한 형태가 테이블을 구성한다.
저장 구분 의미
테이블/파일(table/file) 데이터셋 (dataset)
행(row) 관측점 (observation)
열(column) 변수 (variable)
셀(Cell) 값 (value)

데이터셋(Dataset)

데이터셋은 정량적이면 숫자형, 정성적이면 문자열로 저장되는 값(value) 의 집합이다. 모든 값은 변수(variable)관측점(observation) 에 속하게 된다. 변수에 모든 값은 동일한 속성을 측정하게 되고 (예를 들어, 키, 온도, 기간 등), 관측점은 속성마다 동일한 단위로 측정되는 값이 담겨진다 (예를 들어, 사람, 종족, 날짜).

1.2 엉망진창 데이터 (Messy Data)

깔끔하지 않는 데이터(messy data) 는 위와는 다른 형태의 데이터를 지칭한다. 중요한 것은 컴퓨터 과학에서 말하는 코드 제3 정규형이지만, 통계적 언어로 다시 표현한 것이다.

또한, 깔끔한 데이터는 R같은 벡터화 프로그래밍 언어에 특히 잘 맞는다. 왜냐하면 동일한 관측점에 대한 서로 다른 변수 값이 항상 짝으로 매칭되는 것을 보장하기 때문이다.

변수와 관측점의 순서가 분석에 영향을 끼치는 것은 아니지만, 순위를 잘 맞춰 놓으면 값을 스캔해서 검색하는 것이 용이하다. 고정된 변수(fixed variable)가 실험계획법에 기술되듯이 먼저 나오고 측정된 값이 뒤에 나오는 것처럼 이와 같이 변수와 값에 대한 순위를 잘 맞춰 정리해 놓는 것이 장점이 많다.

깔끔하지 않은 데이터의 대표적인 문제점을 다음과 같이 5가지로 유형화시켜 정리할 수 있다.

  1. 칼럼 헤더에 변수명이 아닌 값이 온다.
  2. 변수 다수가 한 칼럼에 저장되어 있다.
  3. 변수가 행과 열에 모두 저장되어 있다.
  4. 관측 단위에 대한 다양한 형태가 동일한 테이블에 저장되어 있다.
  5. 한가지 관측 단위가 테이블 다수에 흩어져 저장되어 있다.

깔끔한 데이터가 아닌 보통 데이터

id x y
1 22.19 24.05
2 19.82 22.91
3 19.81 21.19
4 17.49 18.59
5 19.44 19.85

깔끔하게 처리한 데이터

id 변수
1 x 22.19
2 x 19.82
3 x 19.81
4 x 17.49
5 x 19.44
1 y 24.05
2 y 22.91
3 y 21.19
4 y 18.59
5 y 19.85

1.3 동사(Verbs)와 팩키지

깔끔한 데이터(Tidy Data)를 만드는데 동원되는 동사에 해당되는 함수는 다음과 같다. 다음 함수들을 이용하여 엉망진창인 데이터(Messy Data)를 깔끔한 데이터로 만들어서 후속 작업에 큰 도움이 될 수 있다. tidyr 팩키지에 Tidy Data 관련 핵심을 이루는 함수로 다음을 꼽을 수 있다.

  • pivot_longer()
  • pivot_wider()
  • separate(), separate_rows()
  • unite()
  • expand_grid()

깔끔한 데이터 관련 R 팩키지는 해드릴 위컴이 주축이 되어 10년 이상 발전시켜 완성한 개념으로 그 단계 단계마다 개발된 팩키지가 있고 개념을 구현한 함수들도 점점 진화되고 있다.

2 깔끔한 데이터 변형 사례

Pew Research Center Religion & Public Life 웹사이트에서 2015년 다운로드 받았고, GitHub에 데이터가 올라가 있다.

깔끔하지 않은 messy 상태 데이터를 깔끔한 tidy 상태 데이터로 변환을 시켜보자.

깔끔하기 전 데이터

종교 <$10k $10-20k $20-30k $30-40k $40-50k $50-75k
Agnostic 27 34 60 81 76 137
Atheist 12 27 37 52 35 70
Buddhist 27 21 30 34 33 58
Catholic 418 617 732 670 638 1116
모름/거절 15 14 15 11 10 35
Evangel 575 869 1064 982 881 1486
Hindu 1 9 7 9 11 34
Black Prot 228 244 236 238 197 223
여호와의 증인 20 27 24 24 21 30
Jewish 19 19 25 25 30 95

깔끔하게 만든 후 데이터

religion income freq
Agnostic < $10k 27
Agnostic $10-20k 34
Agnostic $20-30k 60
Agnostic $30-40k 81
Agnostic $40-50k 76
Agnostic $50-75k 137
Agnostic $75-100k 122
Agnostic $100-150k 109
Agnostic >$150k 84
Agnostic 모름/거절 96

2.1 깔끔한 데이터 제작 코드

다운로드 받은 pew.sav 파일을 데이터프레임으로 만든 후 범주형 자료분석을 위해서 요인형(factor) 변수로 수준을 정리한 후 count 함수를 빈도수를 산출한다.

library(tidyverse)
library(foreign)

# Load data -----------------------------------------------------------------
pew <- read.spss("data/pew.sav")
pew <- as.data.frame(pew)

religion <- pew %>% 
  select(q16, reltrad, income) %>% 
  mutate(reltrad = as.character(reltrad) %>% str_trim(.)) %>% 
  mutate(reltrad = str_replace(reltrad, " Churches", "")) %>% 
  mutate(reltrad = str_replace(reltrad, " Protestant", " Prot")) %>% 
  mutate(reltrad = str_replace_all(reltrad, " \\(.*?\\)", "")) %>% 
  mutate(reltrad = case_when(q16 == " Atheist (do not believe in God) " ~ "Atheist",
                             q16 == " Agnostic (not sure if there is a God) " ~ "Atheist",
                          TRUE ~ reltrad)) %>% 
  mutate(income = case_when(income == "Less than $10,000" ~ "<$10k", 
                            income == "10 to under $20,000" ~ "$10-20k", 
                            income == "20 to under $30,000" ~ "$20-30k", 
                            income == "30 to under $40,000" ~ "$30-40k", 
                            income == "40 to under $50,000" ~ "$40-50k", 
                            income == "50 to under $75,000" ~ "$50-75k",
                            income == "75 to under $100,000" ~ "$75-100k", 
                            income == "100 to under $150,000" ~ "$100-150k", 
                            income == "$150,000 or more" ~ ">150k", 
                            income == "Don't know/Refused (VOL)" ~ "Don't know/refused")) %>% 
  mutate(income = factor(income, levels = c("<$10k", "$10-20k", "$20-30k", "$30-40k", "$40-50k", "$50-75k", "$75-100k", "$100-150k", ">150k", "Don't know/refused")))

counts <- religion %>% 
  count(reltrad, income) %>% 
  dplyr::rename("religion" = reltrad)

counts %>% 
  DT::datatable()

사람이 보기 편한 형태 wide 표형태 데이터는 다음과 같이 나타낼 수 있다. 과거 spread() 동사를 사용했다면 직관적인 함수인 pivot_wider()를 사용하는 것이 좋다. 인자도 names_from=, values_from= 을 사용해서 머릿속에 담긴 내용이 코드에 직관적으로 담기도록 코딩한다.

counts %>% 
  pivot_wider(names_from=income, values_from = n) %>% 
  DT::datatable()

3 깔끔한 데이터 도구

깔끔한 데이터가 의미가 있으려면 깔끔한 데이터가 입력으로 들어가서 깔끔한 데이터가 입력으로 나와야만 된다.

  • (깔끔한) 데이터 입력 → 함수 f(x) → (깔끔한) 데이터 출력
  • Tidy-input → 함수 f(x) → Tidy-output

3.1 데이터 조작(Manipulation)

과거 plyr 팩키지의 subset(), transform(), aggregate(), summarise(), arrange() 함수를 통해 데이터 조작 기능을 구현했고 이는 dplyr 팩키지에 그대로 이어서 기능과 구문작성이 개선되어 오늘에 이르고 있다. dplyr 팩키지의 데이터 변환 - dplyr을 참고한다. 핵심 동사/함수는 filter, select, arrange, mutate, summarise가 있고 이를 확장한 across() 동사가 최근에 추가되어 데이터 조작의 깊이와 폭을 넓히고 있다.

데이터 조작 5가지 경우: dplyr

filter (관측점 필터링) : 특정 기준을 만족하는 행을 추출한다. select (변수 선택하기) : 변수명으로 특정 칼럼을 추출한다. arrange (다시 정렬하기) : 행을 다시 정렬한다. mutate (변수 추가하기) : 새로운 변수를 추가한다. summarise (변수를 값으로 줄이기) : 변수를 값(스칼라)으로 요약한다.

데이터 조작 5가지 경우: plyr

  • 선택(Select): 데이터프레임에서 필요한 변수를 선택해서 뽑아냄

  • 필터링(Filtering): 특정 조건으로 관측점을 제거하거나 부분집합을 뽑아냄

  • 변환(Translform): 변수 추가 혹은 변경 (단일 변수 혹은 변수 다수를 사용하기도 함)

  • 총합(Aggregate): 다수 값을 값 하나로 축약 (예, 합계 혹은 평균)

  • 정렬(Sort): 관측점 순서를 오름차순 혹은 내림차순으로 변경

3.2 시각화(Visualization)

깔끔한 시각화 도구는 깔끔환 데이터셋과 궁합이 잘 맞는데 왜냐하면 변수와 그래프의 심미적인 속성(위치, 크기, 모양, 색상) 간에 매핑으로 시각화를 구현하기 때문이다. 사실 그래픽 문법(grammar of graphics)의 근본적인 아이디어다. 이를 구현한 팩키지가 ggplot이다.

3.3 모형개발(Modeling)

표준 선형대수 루틴에 수치 형렬을 집어 넣어 모형을 개발하려면 통상적으로 상수항을 추가(1로 구성된 칼럼)하고, 범주형 변수를 이진 가변수로 변환하고, 데이터를 스프라인(spline) 함수 기저에 투영하는 과정이 포함된다. 깔끔한 데이터를 받아 모형에 적합시킨 후에 깔끔한 데이터 형식으로 출력해야 하지만, 다양한 형태로 구현되어 이러한 문제 대응하기 위해서 나온 대표적인 움직임이 broom 3 이다.

4 pivot_*() 동사 4 5 6

해들리 위컴이 언급했듯이, gather/spread는 사라지지 않고 훨씬 더 나은 모습으로 재탄생했습니다. pivot_longer(), pivot_wider() 함수는 gather(), spread() 함수를 개선하면서 다른 팩키지의 최신 기능을 추가시켰다.

  • pivot_longer()data.table 팩키지 melt(), dcast()와 연관됨.
  • cdata 팩키지에 영감을 받아 pivot_longer(), pivot_wider() 명칭으로 통일됨.

기억하기 좋게 인텍스(index)를 갖는 긴 자료형과 데카르트 평면(Cartesian)과 같은 넓은(wide) 자료형으로 구분한다. tidyr이 버전 1.0으로 정식 버전업하면서 생겨난 가장 큰 변화중의 하나가 아닐까 싶다.

5 데카르트 평면 → 인덱스: pivot_longer()

pivot_longer() 함수를 사용해서 데카르트 평면과 같이 넓은 데이터(wide)를 인덱스가 붙은 긴 형태(long) 데이터로 변환시킬 수 있다. relig_income 데이터는 gather/spread 시절부터 자주 예제 데이터로 사용되던 예제 데이터셋이다.

religion 변수를 제외하고 나머지 변수가 names_to를 통해 인덱스 명을 바꿀 수 있고 이들이 담고 있던 값은 values_to로 떨어지게 된다.

dataframe %>% 
  pivot_longer(
    cols,
    ...
  )

cols: dplyr::select() 구문과 동일하게 작성함.

데카르트 평면 넓은 데이터

library(tidyr)

relig_income %>% head
# A tibble: 6 x 11
  religion `<$10k` `$10-20k` `$20-30k` `$30-40k` `$40-50k` `$50-75k` `$75-100k`
  <chr>      <dbl>     <dbl>     <dbl>     <dbl>     <dbl>     <dbl>      <dbl>
1 Agnostic      27        34        60        81        76       137        122
2 Atheist       12        27        37        52        35        70         73
3 Buddhist      27        21        30        34        33        58         62
4 Catholic     418       617       732       670       638      1116        949
5 Don’t k…      15        14        15        11        10        35         21
6 Evangel…     575       869      1064       982       881      1486        949
# … with 3 more variables: `$100-150k` <dbl>, `>150k` <dbl>, `Don't
#   know/refused` <dbl>

인덱스 긴 형식 데이터

relig_income %>% 
  pivot_longer(-religion, names_to="소득", values_to = "명수")
# A tibble: 180 x 3
   religion 소득                명수
   <chr>    <chr>              <dbl>
 1 Agnostic <$10k                 27
 2 Agnostic $10-20k               34
 3 Agnostic $20-30k               60
 4 Agnostic $30-40k               81
 5 Agnostic $40-50k               76
 6 Agnostic $50-75k              137
 7 Agnostic $75-100k             122
 8 Agnostic $100-150k            109
 9 Agnostic >150k                 84
10 Agnostic Don't know/refused    96
# … with 170 more rows

5.1 접두사(prefix) 제거: pivot_longer()

names_prefix를 사용해서 $, <$, > 붙은 값을 제거시킬 수 있다.

relig_income %>% 
  pivot_longer(-religion, names_to="소득", 
               values_to = "명수",
               names_prefix = "\\$|<\\$|>")
# A tibble: 180 x 3
   religion 소득                명수
   <chr>    <chr>              <dbl>
 1 Agnostic 10k                   27
 2 Agnostic 10-20k                34
 3 Agnostic 20-30k                60
 4 Agnostic 30-40k                81
 5 Agnostic 40-50k                76
 6 Agnostic 50-75k               137
 7 Agnostic 75-100k              122
 8 Agnostic 100-150k             109
 9 Agnostic 150k                  84
10 Agnostic Don't know/refused    96
# … with 170 more rows

5.2 자료형 변환: pivot_longer()

빌보드 원본 데이터: 자료변환 전

일단 주간 순위는 어찌해서 만들었지만, 주간 칼럼에 wk가 들어 있어 이를 names_prefix를 통해 날려버리고 나도 숫자로 되어 있는 문자를 다시 숫자형으로 칼럼 자료형을 바꿔야 한다.

billboard %>% 
  pivot_longer(
    cols = starts_with("wk"), 
    names_to = "주간", 
    values_to = "순위",
    values_drop_na = TRUE
  )
# A tibble: 5,307 x 5
   artist  track                   date.entered 주간   순위
   <chr>   <chr>                   <date>       <chr> <dbl>
 1 2 Pac   Baby Don't Cry (Keep... 2000-02-26   wk1      87
 2 2 Pac   Baby Don't Cry (Keep... 2000-02-26   wk2      82
 3 2 Pac   Baby Don't Cry (Keep... 2000-02-26   wk3      72
 4 2 Pac   Baby Don't Cry (Keep... 2000-02-26   wk4      77
 5 2 Pac   Baby Don't Cry (Keep... 2000-02-26   wk5      87
 6 2 Pac   Baby Don't Cry (Keep... 2000-02-26   wk6      94
 7 2 Pac   Baby Don't Cry (Keep... 2000-02-26   wk7      99
 8 2Ge+her The Hardest Part Of ... 2000-09-02   wk1      91
 9 2Ge+her The Hardest Part Of ... 2000-09-02   wk2      87
10 2Ge+her The Hardest Part Of ... 2000-09-02   wk3      92
# … with 5,297 more rows

빌보드 원본 데이터: 자료변환 후

billboard %>% 
  pivot_longer(
    cols = starts_with("wk"), 
    names_to = "주간", 
    values_to = "순위",
    names_prefix = "wk",
    names_ptypes = list(`주간` = as.character()),
    values_drop_na = TRUE
  )
# A tibble: 5,307 x 5
   artist  track                   date.entered 주간   순위
   <chr>   <chr>                   <date>       <chr> <dbl>
 1 2 Pac   Baby Don't Cry (Keep... 2000-02-26   1        87
 2 2 Pac   Baby Don't Cry (Keep... 2000-02-26   2        82
 3 2 Pac   Baby Don't Cry (Keep... 2000-02-26   3        72
 4 2 Pac   Baby Don't Cry (Keep... 2000-02-26   4        77
 5 2 Pac   Baby Don't Cry (Keep... 2000-02-26   5        87
 6 2 Pac   Baby Don't Cry (Keep... 2000-02-26   6        94
 7 2 Pac   Baby Don't Cry (Keep... 2000-02-26   7        99
 8 2Ge+her The Hardest Part Of ... 2000-09-02   1        91
 9 2Ge+her The Hardest Part Of ... 2000-09-02   2        87
10 2Ge+her The Hardest Part Of ... 2000-09-02   3        92
# … with 5,297 more rows

5.3 다수 칼럼: pivot_longer()

who 데이터셋과 같이 다수 칼럼이 포함된 경우가 있을 수 있다. 이런 경우 names_pattern 정규표현식을 적용시켜 변수를 추출할 수 있다. 먼저 who 데이터셋을 살펴보자.

  • new_/new 접두어
  • sp/rel/sp/ep 진단 구분
  • m/f 성별
  • 014/1524/2535/3544/4554/65 연령대

먼저, country, iso2, iso3, year 4개 변수는 정리되어 있어 그대로 두고, 나머지 변수를 dplyr::select 문법에 맞춰 선택하고 이를 변경시킨다. names_to로 변수명 3개를 지정하고, new_/new 접두어는 고정되어 names_pattern에서 정규표현식으로 패턴을 적의한다. 이와 더불어 names_ptypes에서 자료형도 함께 지정한다.

who %>% pivot_longer(
  cols = new_sp_m014:newrel_f65,
  names_to = c("diagnosis", "gender", "age"), 
  names_pattern = "new_?(.*)_(m|f)(.*)",
  names_ptypes = list(
    gender = factor(levels = c("f", "m")),
    age = factor(
      levels = c("014", "1524", "2534", "3544", "4554", "5564", "65"), 
      ordered = TRUE
    )
  ),
  values_to = "count",
)
# A tibble: 405,440 x 8
   country     iso2  iso3   year diagnosis gender age   count
   <chr>       <chr> <chr> <int> <chr>     <fct>  <ord> <int>
 1 Afghanistan AF    AFG    1980 sp        m      014      NA
 2 Afghanistan AF    AFG    1980 sp        m      1524     NA
 3 Afghanistan AF    AFG    1980 sp        m      2534     NA
 4 Afghanistan AF    AFG    1980 sp        m      3544     NA
 5 Afghanistan AF    AFG    1980 sp        m      4554     NA
 6 Afghanistan AF    AFG    1980 sp        m      5564     NA
 7 Afghanistan AF    AFG    1980 sp        m      65       NA
 8 Afghanistan AF    AFG    1980 sp        f      014      NA
 9 Afghanistan AF    AFG    1980 sp        f      1524     NA
10 Afghanistan AF    AFG    1980 sp        f      2534     NA
# … with 405,430 more rows

5.4 한행에 다수 관측점: pivot_longer()

한행에 관측점이 다수 있는 재미있는 데이터도 있다. 즉, 첫번째 가정에 아이가 2명 있는데 첫째 아이 생일과 성별, 둘째 아이 생일과 성별이 한 행에 놓여있는 경우가 이에 해당된다.

family <- tribble(
  ~family,  ~dob_child1,  ~dob_child2, ~gender_child1, ~gender_child2,
       1L, "1998-11-26", "2000-01-29",             1L,             2L,
       2L, "1996-06-22",           NA,             2L,             NA,
       3L, "2002-07-11", "2004-04-05",             2L,             2L,
       4L, "2004-10-10", "2009-08-27",             1L,             1L,
       5L, "2000-12-05", "2005-02-28",             2L,             1L,
)
family
# A tibble: 5 x 5
  family dob_child1 dob_child2 gender_child1 gender_child2
   <int> <chr>      <chr>              <int>         <int>
1      1 1998-11-26 2000-01-29             1             2
2      2 1996-06-22 <NA>                   2            NA
3      3 2002-07-11 2004-04-05             2             2
4      4 2004-10-10 2009-08-27             1             1
5      5 2000-12-05 2005-02-28             2             1

이를 원하는 이름으로 변경시키기 위해서 names_to=.value라는 특수명칭을 사용하여 한 관측점에 다수 관측점 정보가 포함된 문제를 해결한다.

family %>% 
  pivot_longer(
    -family, 
    names_to = c(".value", "child"), 
    names_sep = "_", 
    values_drop_na = TRUE
  ) %>% 
  dplyr::mutate(dob = parse_date(dob))
# A tibble: 9 x 4
  family child  dob        gender
   <int> <chr>  <date>      <int>
1      1 child1 1998-11-26      1
2      1 child2 2000-01-29      2
3      2 child1 1996-06-22      2
4      3 child1 2002-07-11      2
5      3 child2 2004-04-05      2
6      4 child1 2004-10-10      1
7      4 child2 2009-08-27      1
8      5 child1 2000-12-05      2
9      5 child2 2005-02-28      1

5.5 칼럼명이 중복됨: pivot_longer()

종복된 칼럼명이 존재하는 경우 작업하기 까다로운데… pivot_longer()에서 자동으로 칼럼을 추가시켜 문제를 풀어준다.

중복칼럼 데이터셋

df <- tibble(x = 1:3, y = 4:6, y = 5:7, y = 7:9, .name_repair = "minimal")
df
# A tibble: 3 x 4
      x     y     y     y
  <int> <int> <int> <int>
1     1     4     5     7
2     2     5     6     8
3     3     6     7     9

중복칼럼 데이터셋 작업결과

df %>% 
  pivot_longer(-x, names_to = "name", values_to = "value")
# A tibble: 9 x 3
      x name  value
  <int> <chr> <int>
1     1 y         4
2     1 y         5
3     1 y         7
4     2 y         5
5     2 y         6
6     2 y         8
7     3 y         6
8     3 y         7
9     3 y         9

6 인덱스 → 데카르트 평면: pivot_wider()

pivot_wider()는 깔끔한 데이터를 만드는데 그다지 흔한 경우는 아니지만, 요약표를 만드거나 할 때 종종 사용되고, pivot_longer()와 정반대라고 보면 된다.

dataframe %>% 
  pivot_wider(
    names_from,
    values_from,
    ...
  )
  • names_from은 칼럼값을 지정하는 칼럼
  • values-from은 값(value)을 지정하는 칼럼

“Visualizing Fish Encounter Histories”, February 3 2018에 나온 물고기 포획 방류, 재포획 즉, capture-recapture 데이터셋으로 볼 수 있다. pivot_wider() 함수에 names_from, value_from을 지정하여 데카르트 평면에 좌표로 찍듯이 데이터를 펼친다. 결측값이 생기는 것은 values_fill을 사용해서 0으로 채워넣는다.

깔끔한 데이터: 인덱스 긴 형식

fish_encounters
# A tibble: 114 x 3
   fish  station  seen
   <fct> <fct>   <int>
 1 4842  Release     1
 2 4842  I80_1       1
 3 4842  Lisbon      1
 4 4842  Rstr        1
 5 4842  Base_TD     1
 6 4842  BCE         1
 7 4842  BCW         1
 8 4842  BCE2        1
 9 4842  BCW2        1
10 4842  MAE         1
# … with 104 more rows

요약표 형태 데이터

fish_encounters %>% 
  pivot_wider(names_from = station, 
              values_from = seen,
              values_fill = list(seen = 0)) %>% 
  head(10)
# A tibble: 10 x 12
   fish  Release I80_1 Lisbon  Rstr Base_TD   BCE   BCW  BCE2  BCW2   MAE   MAW
   <fct>   <int> <int>  <int> <int>   <int> <int> <int> <int> <int> <int> <int>
 1 4842        1     1      1     1       1     1     1     1     1     1     1
 2 4843        1     1      1     1       1     1     1     1     1     1     1
 3 4844        1     1      1     1       1     1     1     1     1     1     1
 4 4845        1     1      1     1       1     0     0     0     0     0     0
 5 4847        1     1      1     0       0     0     0     0     0     0     0
 6 4848        1     1      1     1       0     0     0     0     0     0     0
 7 4849        1     1      0     0       0     0     0     0     0     0     0
 8 4850        1     1      0     1       1     1     1     0     0     0     0
 9 4851        1     1      0     0       0     0     0     0     0     0     0
10 4854        1     1      0     0       0     0     0     0     0     0     0

6.1 총계(aggregation): pivot_wider()

pivot_wider()를 통해 총계(aggregate)를 내야하는 상황이 발생하곤 한다. 실험계획법이 적용된 데이터 warpbreaks를 보면 wool, tension 두가지 요인으로 총 9번 실험한 결과가 breaks에 담겨진 것을 확인할 수 있다.

warpbreaks <- warpbreaks %>% as_tibble() %>% select(wool, tension, breaks)
warpbreaks %>% 
  count(wool, tension)
# A tibble: 6 x 3
  wool  tension     n
  <fct> <fct>   <int>
1 A     L           9
2 A     M           9
3 A     H           9
4 B     L           9
5 B     M           9
6 B     H           9

현재 인덱스로 잘 정제된 긴형태 데이터를 요약표 형태로 데카르트 평면과 같이 정리하고자 하면 다음과 같이 작업하게 되면 wool, tension 요인별로 총 9개 breaks값이 한 곳에 몰려있는 것을 파악할 수 있다. values_fn을 사용해서 총계로 평균, 최대, 최소 등을 사용해서 하나의 값으로 요약할 수 있다.

요인별 원데이터

warpbreaks %>% 
  pivot_wider(
    names_from = wool,
    values_from = breaks
  )
# A tibble: 3 x 3
  tension A         B        
  <fct>   <list>    <list>   
1 L       <dbl [9]> <dbl [9]>
2 M       <dbl [9]> <dbl [9]>
3 H       <dbl [9]> <dbl [9]>

요인별 총계: 최대값

warpbreaks %>% 
  pivot_wider(
    names_from = wool,
    values_from = breaks,
    values_fn = list(breaks = max)
  )
# A tibble: 3 x 3
  tension     A     B
  <fct>   <dbl> <dbl>
1 L          70    44
2 M          36    42
3 H          43    28

6.2 재미있는 패턴

연락처 관련하여 데이터를 정리해보자. 두행에 걸처 동일한 사람의 정보가 반복되고 있다.

contacts <- tribble(
  ~field, ~value,
  "name", "Jiena McLellan",
  "company", "Toyota", 
  "name", "John Smith", 
  "company", "google", 
  "email", "john@google.com",
  "name", "Huxley Ratcliffe"
)
contacts
# A tibble: 6 x 2
  field   value           
  <chr>   <chr>           
1 name    Jiena McLellan  
2 company Toyota          
3 name    John Smith      
4 company google          
5 email   john@google.com 
6 name    Huxley Ratcliffe

두행에 걸쳐있는 한사람 정보를 유일무이하게 식별할 수 있는 식별자를 붙인다.

contacts <- contacts %>% 
  mutate(
    person_id = cumsum(field == "name")
  )
contacts
# A tibble: 6 x 3
  field   value            person_id
  <chr>   <chr>                <int>
1 name    Jiena McLellan           1
2 company Toyota                   1
3 name    John Smith               2
4 company google                   2
5 email   john@google.com          2
6 name    Huxley Ratcliffe         3

이제 pivot_wider() 함수를 사용해서 데이터를 정리한다.

contacts %>% 
  pivot_wider(names_from = field, values_from = value)
# A tibble: 3 x 4
  person_id name             company email          
      <int> <chr>            <chr>   <chr>          
1         1 Jiena McLellan   Toyota  <NA>           
2         2 John Smith       google  john@google.com
3         3 Huxley Ratcliffe <NA>    <NA>           

7 separate()unite()

7.1 칼럼에 변수 두개 포함

칼럼 하나에 다수 변수가 포함된 경우를 흔히 발견할 수 있다. dplyr 팩키지에는 starwars 데이터셋에 등장하는 인물에 대한 인적정보가 담겨있다. 예를 들어, name 칼럼은 두 변수가 숨어 있다. 하나는 성(last name) 다른 하나는 이름(first name)이다. 이를 두개로 쪼개어 두는 것이 Tidy Data를 만든다고 볼 수 있다. 꼭 그런 것은 아니고 경우에 따라 차이가 있지만, 개념적으로 그렇다는 것이다. 상황에 맞춰 유연하게 사용한다.

starwars_name_df <- starwars %>% 
  select(name, species, height, mass) %>% 
  filter(str_detect(species, "Human"))

starwars_name_df
# A tibble: 35 x 4
   name               species height  mass
   <chr>              <chr>    <int> <dbl>
 1 Luke Skywalker     Human      172    77
 2 Darth Vader        Human      202   136
 3 Leia Organa        Human      150    49
 4 Owen Lars          Human      178   120
 5 Beru Whitesun lars Human      165    75
 6 Biggs Darklighter  Human      183    84
 7 Obi-Wan Kenobi     Human      182    77
 8 Anakin Skywalker   Human      188    84
 9 Wilhuff Tarkin     Human      180    NA
10 Han Solo           Human      180    80
# … with 25 more rows

separate() 함수를 사용해서 칼럼을 두개로 쪼갠다. 이런 경우 sep= 인자를 통해 구분자를 지정한다. 공백, ;, , 등 문제에 따라 구분자를 달리 사용한다.

starwars_name_df %>% 
  separate(name, into = c("first_name", "last_name"), sep=" ")
# A tibble: 35 x 5
   first_name last_name   species height  mass
   <chr>      <chr>       <chr>    <int> <dbl>
 1 Luke       Skywalker   Human      172    77
 2 Darth      Vader       Human      202   136
 3 Leia       Organa      Human      150    49
 4 Owen       Lars        Human      178   120
 5 Beru       Whitesun    Human      165    75
 6 Biggs      Darklighter Human      183    84
 7 Obi-Wan    Kenobi      Human      182    77
 8 Anakin     Skywalker   Human      188    84
 9 Wilhuff    Tarkin      Human      180    NA
10 Han        Solo        Human      180    80
# … with 25 more rows

7.2 칼럼에 변수 다수 포함

TidyTuesday에서 나왔던 칵테일 데이터를 보게 되면 각 칵테일을 제작하는데 필요한 재표가 ingredient 칼럼 안에 콤마(,)로 묶여있다. 이런 데이터는 절대로 Tidy한 데이터가 아니라서 적절한 조치가 필요하다.

cocktail_data <- tidytuesdayR::tt_load('2020-05-26')

    Downloading file 1 of 2: `cocktails.csv`
    Downloading file 2 of 2: `boston_cocktails.csv`
cocktail_df <- cocktail_data$cocktails %>% 
  select(drink, ingredient)

cocktail_tbl <- cocktail_df %>% 
  group_by(drink) %>% 
  nest() %>% 
  mutate(ingredient_tmp = map(data, function(df) df %>%  select(ingredient) %>% pull) ) %>% 
  mutate(ingredient = map_chr(ingredient_tmp, paste, collapse = ", ")) %>% 
  ungroup 

cocktail_tbl %>% 
  select(drink, ingredient)
# A tibble: 546 x 2
   drink                     ingredient                                         
   <chr>                     <chr>                                              
 1 '57 Chevy with a White L… Creme de Cacao, Vodka                              
 2 1-900-FUK-MEUP            Absolut Kurant, Grand Marnier, Chambord raspberry …
 3 110 in the shade          Lager, Tequila                                     
 4 151 Florida Bushwacker    Malibu rum, Light rum, 151 proof rum, Dark Creme d…
 5 155 Belmont               Dark rum, Light rum, Vodka, Orange juice           
 6 24k nightmare             Goldschlager, Jägermeister, Rumple Minze, 151 proo…
 7 252                       151 proof rum, Wild Turkey                         
 8 3 Wise Men                Jack Daniels, Johnnie Walker, Jim Beam             
 9 3-Mile Long Island Iced … Gin, Light rum, Tequila, Triple sec, Vodka, Coca-C…
10 410 Gone                  Peach Vodka                                        
# … with 536 more rows

먼저 stringr 팩키지 str_split() 함수로 ingredient 칼럼을 ,을 구분자로 삼아 쪼갠 후에 list-column 형태 칼럼(ingredient_lc)으로 저장시킨다. 그리고 나서 unnest() 함수로 중첩된 것을 풀게 되면 다음과 같은 티블 데이터프레임이 되어 Tidy Data로 변환된다.

cocktails_tbl <- cocktail_tbl %>% 
  select(drink, ingredient) %>% 
  mutate(ingredient_lc = str_split(ingredient, ", ")) %>% 
  unnest(ingredient_lc)

cocktails_tbl
# A tibble: 2,104 x 3
   drink               ingredient                             ingredient_lc     
   <chr>               <chr>                                  <chr>             
 1 '57 Chevy with a W… Creme de Cacao, Vodka                  Creme de Cacao    
 2 '57 Chevy with a W… Creme de Cacao, Vodka                  Vodka             
 3 1-900-FUK-MEUP      Absolut Kurant, Grand Marnier, Chambo… Absolut Kurant    
 4 1-900-FUK-MEUP      Absolut Kurant, Grand Marnier, Chambo… Grand Marnier     
 5 1-900-FUK-MEUP      Absolut Kurant, Grand Marnier, Chambo… Chambord raspberr…
 6 1-900-FUK-MEUP      Absolut Kurant, Grand Marnier, Chambo… Midori melon liqu…
 7 1-900-FUK-MEUP      Absolut Kurant, Grand Marnier, Chambo… Malibu rum        
 8 1-900-FUK-MEUP      Absolut Kurant, Grand Marnier, Chambo… Amaretto          
 9 1-900-FUK-MEUP      Absolut Kurant, Grand Marnier, Chambo… Cranberry juice   
10 1-900-FUK-MEUP      Absolut Kurant, Grand Marnier, Chambo… Pineapple juice   
# … with 2,094 more rows

자, 이제 깔끔한 데이터의 힘을 느껴보자. 칵테일 중에 가장 다양한 재료가 포함된 칵테일은 무엇인가? 라는 질문에 단순한 dplyr 동사로 확인이 바로 가능하다.

cocktails_tbl %>% 
  group_by(drink) %>% 
  summarise(num_ingredients = n()) %>% 
  arrange(desc(num_ingredients))
# A tibble: 546 x 2
   drink                       num_ingredients
   <chr>                                 <int>
 1 Angelica Liqueur                         12
 2 Amaretto Liqueur                         11
 3 Egg Nog #4                               11
 4 Arizona Twister                           9
 5 1-900-FUK-MEUP                            8
 6 151 Florida Bushwacker                    8
 7 3-Mile Long Island Iced Tea               8
 8 Apple Cider Punch #1                      8
 9 Artillery Punch                           8
10 Cherry Electric Lemonade                  8
# … with 536 more rows

7.3 separate_rows() 함수로 빠르게

상기 과정이 다소 많은 동사를 조합해서 결과를 도출하고 있다고 생각된다면 separate_rows() 함수를 사용해서 깔끔한 데이터를 만든 후에 count 함수와 sort = TRUE를 활용하여 동일한 작업을 간단히 마무리 할 수 있다.

cocktail_tbl %>% 
  separate_rows(ingredient, sep = ", ") %>% 
  count(drink, sort = TRUE)
# A tibble: 546 x 2
   drink                           n
   <chr>                       <int>
 1 Angelica Liqueur               12
 2 Amaretto Liqueur               11
 3 Egg Nog #4                     11
 4 Arizona Twister                 9
 5 1-900-FUK-MEUP                  8
 6 151 Florida Bushwacker          8
 7 3-Mile Long Island Iced Tea     8
 8 Apple Cider Punch #1            8
 9 Artillery Punch                 8
10 Cherry Electric Lemonade        8
# … with 536 more rows

8 결측 데이터

data.world Nuclear Weapon Explosions BY THOMAS DRÄBING 웹사이트에서 핵폭탄 실험 데이터를 다운로드 받을 수 있다. 데이터는 완벽하지만 논리상 문제가 생긴다. 즉 1945년 이후 데이터를 보자면 결측값이 보인다. 확인이 안된 것 한건 빼면 7개국이 핵폭탄 실험을 한 것으로 나오는데 1945년에는 아무런 기록이 없다. 데이터프레임에는 정상인 깔끔한 데이터지만 사실 1945년 0 건이 예를 들어 중국이나 러시아가 포함되어 있는 것이 맞다.

library(lubridate)

nuclear_df <- read_csv("data/nuclear_weapon_explosions_1945-1998.csv")

nuclear_bombs_df <- nuclear_df %>% 
  janitor::clean_names() %>% 
  mutate(date_time = lubridate::parse_date_time(datetime, orders = c("%m/%d/%Y %H:%M:%S %p"))) %>% 
  mutate(year = year(date_time)) %>% 
  count(country, year, name = "n_bombs") %>% 
  arrange(year) %>% 
  filter(year <= 1954)

nuclear_bombs_df 
# A tibble: 13 x 3
   country  year n_bombs
   <chr>   <dbl>   <int>
 1 USA      1945       3
 2 USA      1946       2
 3 USA      1948       3
 4 Russia   1949       1
 5 Russia   1951       2
 6 USA      1951      16
 7 England  1952       1
 8 USA      1952      10
 9 England  1953       2
10 Russia   1953       5
11 USA      1953      11
12 Russia   1954      10
13 USA      1954       6

8.1 expand_grid()

상기와 같은 문제를 해결하기 위해 expand_grid() 함수를 사용해서 핵실험을 수행한 모든 국가에 대해 해당 년도를 모두 생성할 필요가 있다. 3개 국가에 대해 10년을 expand_grid() 함수로 조합하게 되면 30개 관측점이 생기는데 미국이 핵실험을 하지 않은 1947년의 경우 NA 값이 생긴다. 이를 채워주어야 한다.

country_year_df <- expand_grid(country = c("USA", "Russia", "England"),
            year = seq(1945, 1954, 1))

nuclear_full_df <- country_year_df %>% 
  left_join(nuclear_bombs_df)

nuclear_full_df
# A tibble: 30 x 3
   country  year n_bombs
   <chr>   <dbl>   <int>
 1 USA      1945       3
 2 USA      1946       2
 3 USA      1947      NA
 4 USA      1948       3
 5 USA      1949      NA
 6 USA      1950      NA
 7 USA      1951      16
 8 USA      1952      10
 9 USA      1953      11
10 USA      1954       6
# … with 20 more rows

8.2 replace_na()

앞서 expand_grid() 함수로 생성된 Tidy Data를 염두에 두고 이를 left_join() 함수와 조인을 걸어 나중에 치환시킬 데이터프레임을 사전 제작한다. 결측값을 특정 값으로 채워 넣고자 하는 경우 replace_na() 함수를 사용한다. 먼저 replace_na() 함수를 사용해서 n_bombs 변수에 NA 결측값을 0으로 채워넣는다.

nuclear_full_df %>% 
  replace_na(list(n_bombs = 0))
# A tibble: 30 x 3
   country  year n_bombs
   <chr>   <dbl>   <dbl>
 1 USA      1945       3
 2 USA      1946       2
 3 USA      1947       0
 4 USA      1948       3
 5 USA      1949       0
 6 USA      1950       0
 7 USA      1951      16
 8 USA      1952      10
 9 USA      1953      11
10 USA      1954       6
# … with 20 more rows

8.3 fill()

또 다른 사례로 발생되는 결측값의 유형으로 위와 같이 특정 결측값으로 채워넣는 대신 누적합과 같은 경우 이전 값이나 이후 값을 참조하여 채워넣는 것이 더 유의미한 결측값을 채워넣는 사례가 된다.

nuclear_cumsum_df <- nuclear_bombs_df %>%
  arrange(country, year) %>% 
  group_by(country) %>% 
  mutate(cumsum_bombs = cumsum(n_bombs))

nuclear_cumsum_df  
# A tibble: 13 x 4
# Groups:   country [3]
   country  year n_bombs cumsum_bombs
   <chr>   <dbl>   <int>        <int>
 1 England  1952       1            1
 2 England  1953       2            3
 3 Russia   1949       1            1
 4 Russia   1951       2            3
 5 Russia   1953       5            8
 6 Russia   1954      10           18
 7 USA      1945       3            3
 8 USA      1946       2            5
 9 USA      1948       3            8
10 USA      1951      16           24
11 USA      1952      10           34
12 USA      1953      11           45
13 USA      1954       6           51

연도별 누적 핵폭탄 투하 실험 횟수는 fill() 함수에 .direction = "down"을 넣어 방향도 지정하여 의미있는 결측값 치완방식이 되도록 코드를 작업한다.

country_year_df %>% 
  left_join(nuclear_cumsum_df) %>% 
  fill(cumsum_bombs, .direction = "down") %>% 
  replace_na(list(n_bombs = 0))
# A tibble: 30 x 4
   country  year n_bombs cumsum_bombs
   <chr>   <dbl>   <dbl>        <int>
 1 USA      1945       3            3
 2 USA      1946       2            5
 3 USA      1947       0            5
 4 USA      1948       3            8
 5 USA      1949       0            8
 6 USA      1950       0            8
 7 USA      1951      16           24
 8 USA      1952      10           34
 9 USA      1953      11           45
10 USA      1954       6           51
# … with 20 more rows

8.4 drop_na()

다른 것 다 모르겠고 데이터프레임에 결측값이 있으면 안되기 때문에 그냥 제거한다. 이럴 때 사용하는 함수가 drop_na()다.

nuclear_full_df %>% 
  drop_na(n_bombs)
# A tibble: 13 x 3
   country  year n_bombs
   <chr>   <dbl>   <int>
 1 USA      1945       3
 2 USA      1946       2
 3 USA      1948       3
 4 USA      1951      16
 5 USA      1952      10
 6 USA      1953      11
 7 USA      1954       6
 8 Russia   1949       1
 9 Russia   1951       2
10 Russia   1953       5
11 Russia   1954      10
12 England  1952       1
13 England  1953       2
 

데이터 과학자 이광춘 저작

kwangchun.lee.7@gmail.com