聰明不如鈍筆
총명불여둔필
assignment KindeR

R에서 지저분한 데이터 청소하기(feat. janitor)

현직자 인터뷰와 전문가 추산에 따르면 데이터 과학자는 전체 작업 시간 가운데 50~80%를 멋대로 생긴 데이터를 수집하고 준비하는 재미없는 일에 쓴다. ─ 뉴욕타임스 "For Big-Data Scientists, ‘Janitor Work’ Is Key Hurdle to Insight"


맞습니다. 세상에 괜히 '데이터 노가다(土方)'라는 표현이 존재하는 게 아닙니다.

 

그래서 세상에는 거꾸로 이 노가다를 조금이라도 덜 수고스럽게 만들어주는 도구도 존재합니다.

 

예컨대 tidyverse 패키지에 들어 있는 tidyr는 데이터를 롱 폼에서 와이드 폼 또는 그 반대로 '깔끔하게' 바꿀 수 있도록 도와줍니다.

 

그런데 사실 데이터 구조 변경마저도 너무 거창합니다.

 

실제로 데이터를 하다 보면 아주 사소하지만 번거롭고 귀찮은 작업을 진행해야 할 때도 많습니다.

 

이럴 때 도움을 받을 수 있는 패키지가 바로 janitor입니다. (영어 낱말 janitor는 '잡역부'라는 뜻입니다.)

 

janitor 패키지는 △열 이름을 깔끔하게 정리하고 △중복 데이터를 손쉽게 고를 수 있도록 도와주며 △분할표(contingency table) 작성에도 도움을 줍니다.

 

새 패키지가 등장했으니 먼저 패키지를 (설치하고) 불러오는 과정을 거쳐야 합니다.

 

이에 앞서 늘 그렇듯 tidyverse도 같이 (설치하고) 불러오도록 하겠습니다.

 

마지막으로 '스포츠 레퍼런스'에서 데이터를 크롤링해 예제로 쓸 수 있도록 rvest 패키지도 (설치하고) 불러옵니다.

#install.packages('tidyverse')
library('tidyverse')
## -- Attaching packages --------------------------------------- tidyverse 1.3.1 --
## v ggplot2 3.2.1     v purrr   0.3.2
## v tibble  2.1.3     v dplyr   0.8.3
## v tidyr   0.8.3     v stringr 1.4.0
## v readr   1.3.1     v forcats 0.3.0
## -- Conflicts ------------------------------------------ tidyverse_conflicts() --
## x dplyr::filter() masks stats::filter()
## x dplyr::lag()    masks stats::lag()
#install.packages('janitor')
library('janitor')
## 
## Attaching package: 'janitor'
## The following objects are masked from 'package:stats':
## 
##     chisq.test, fisher.test
#install.packages('rvest')
library('rvest')
## 
## Attaching package: 'rvest'
## The following object is masked from 'package:readr':
## 
##     guess_encoding

 

'풋볼 레퍼런스'에서 지난해 K리그1 팀 기록을 긁어와 첫 번째 예제 데이터로 쓰겠습니다.

 

이 데이터를 가져 와 k_league_1이라는 변수에 넣어 놓는 코드는 이렇게 쓰면 됩니다.

read_html('https://fbref.com/en/comps/55/3930/2019-K-League-1-Stats') %>% 
  html_node(xpath = '//*[@id="stats_squads_standard_for"]') %>% 
  html_table() -> k_league_1

 

혹시 이 크롤링 코드가 이해가 가지 않으시면 '최대한 친절하게 쓴 R 크롤러 만들기' 포스트가 도움이 될 수 있습니다.

 

k_league_1을 열어 보면 열 이름이 엉망이라는 사실을 알 수 있습니다.

k_league_1
## # A tibble: 13 x 20
##    ``             ``    ``    ``     `Playing Time` `Playing Time` `Playing Time`
##    <chr>          <chr> <chr> <chr>  <chr>          <chr>          <chr>         
##  1 Squad          # Pl  Age   "Poss" MP             Starts         Min           
##  2 Daegu          31    25.8  ""     38             418            3,420         
##  3 FC Seoul       28    27.4  ""     38             418            3,420         
##  4 Gangwon        29    27.7  ""     38             418            3,420         
##  5 Gyeongnam      33    28.3  ""     38             418            3,420         
##  6 Incheon United 41    26.0  ""     38             418            3,420         
##  7 Jeju United    38    27.8  ""     38             418            3,420         
##  8 Jeonbuk        31    28.5  ""     38             418            3,420         
##  9 Pohang         31    26.2  ""     38             418            3,420         
## 10 Sangju Sangmu  40    26.5  ""     38             418            3,420         
## 11 Seongnam       35    26.5  ""     38             418            3,420         
## 12 Suw Bluewings  40    27.4  ""     38             418            3,420         
## 13 Ulsan Hyundai  29    29.1  ""     38             418            3,420         
## # ... with 13 more variables: Playing Time <chr>, Performance <chr>,
## #   Performance <chr>, Performance <chr>, Performance <chr>, Performance <chr>,
## #   Performance <chr>, Performance <chr>, Per 90 Minutes <chr>,
## #   Per 90 Minutes <chr>, Per 90 Minutes <chr>, Per 90 Minutes <chr>,
## #   Per 90 Minutes <chr>

 

우리가 예상하는 열 이름이 첫 행에도 들어 있는 상태입니다.

 

이런 일이 생긴 건 풋볼 레퍼런스에서 표 제목이 두 줄이기 때문입니다.

 

이번 데이터에서는 첫 줄보다 두 번째 줄이 열 이름에 더 잘 어울린다는 사실을 확인할 수 있습니다.

 

마이크로소프트(MS) 엑셀 데이터를 불러올 때도 이렇게 열 이름을 두 줄로 나타날 때가 있습니다.

 

이럴 때는 특정 행 데이터를 열 이름으로 바꿔주는 row_to_names() 함수를 쓰면 됩니다.

 

우리는 첫 줄을 열 이름으로 올리면 되니까 'row_number = 1'이라고 옵션을 주면 됩니다.

k_league_1 %>% 
  row_to_names(row_number = 1)
## Warning in row_to_names(., row_number = 1): Row 1 does not provide unique names.
## Consider running clean_names() after row_to_names().
## # A tibble: 12 x 20
##    Squad    `# Pl` Age   Poss  MP    Starts Min   `90s` Gls   Ast   `G-PK` PK   
##    <chr>    <chr>  <chr> <chr> <chr> <chr>  <chr> <chr> <chr> <chr> <chr>  <chr>
##  1 Daegu    31     25.8  ""    38    418    3,420 38.0  46    30    43     3    
##  2 FC Seoul 28     27.4  ""    38    418    3,420 38.0  52    36    48     4    
##  3 Gangwon  29     27.7  ""    38    418    3,420 38.0  55    29    51     4    
##  4 Gyeongn~ 33     28.3  ""    38    418    3,420 38.0  42    22    38     4    
##  5 Incheon~ 41     26.0  ""    38    418    3,420 38.0  33    17    30     3    
##  6 Jeju Un~ 38     27.8  ""    38    418    3,420 38.0  44    22    39     5    
##  7 Jeonbuk  31     28.5  ""    38    418    3,420 38.0  69    45    66     3    
##  8 Pohang   31     26.2  ""    38    418    3,420 38.0  49    34    44     5    
##  9 Sangju ~ 40     26.5  ""    38    418    3,420 38.0  48    30    40     8    
## 10 Seongnam 35     26.5  ""    38    418    3,420 38.0  29    16    25     4    
## 11 Suw Blu~ 40     27.4  ""    38    418    3,420 38.0  46    30    41     5    
## 12 Ulsan H~ 29     29.1  ""    38    418    3,420 38.0  69    38    66     3    
## # ... with 8 more variables: PKatt <chr>, CrdY <chr>, CrdR <chr>, Gls <chr>,
## #   Ast <chr>, G+A <chr>, G-PK <chr>, G+A-PK <chr>

 

일단 열 이름을 바꾸는 데 성공했습니다.

 

에러 메시지가 뒤따르는 건 'Gls', 'Ast', 'G-PK'라는 열 이름이 누적 기록과 90분 환산 기록에 모두 들어 있기 때문입니다.

 

메시지에서 지시하는 대로 clean_names() 함수를 적용해보겠습니다.

 

이 결과를 다시 k_league_1 변수에 넣는 작업까지 같이 진행합니다.

 

(아직도 '<-' 기호로만 자료를 넣을 수 있다고 알고 계시는 건 아니시죠?)

k_league_1 %>% 
  row_to_names(row_number = 1) %>% 
  clean_names() -> k_league_1
## Warning in row_to_names(., row_number = 1): Row 1 does not provide unique names.
## Consider running clean_names() after row_to_names().
k_league_1 
## # A tibble: 12 x 20
##    squad  number_pl age   poss  mp    starts min   x90s  gls   ast   g_pk  pk   
##    <chr>  <chr>     <chr> <chr> <chr> <chr>  <chr> <chr> <chr> <chr> <chr> <chr>
##  1 Daegu  31        25.8  ""    38    418    3,420 38.0  46    30    43    3    
##  2 FC Se~ 28        27.4  ""    38    418    3,420 38.0  52    36    48    4    
##  3 Gangw~ 29        27.7  ""    38    418    3,420 38.0  55    29    51    4    
##  4 Gyeon~ 33        28.3  ""    38    418    3,420 38.0  42    22    38    4    
##  5 Inche~ 41        26.0  ""    38    418    3,420 38.0  33    17    30    3    
##  6 Jeju ~ 38        27.8  ""    38    418    3,420 38.0  44    22    39    5    
##  7 Jeonb~ 31        28.5  ""    38    418    3,420 38.0  69    45    66    3    
##  8 Pohang 31        26.2  ""    38    418    3,420 38.0  49    34    44    5    
##  9 Sangj~ 40        26.5  ""    38    418    3,420 38.0  48    30    40    8    
## 10 Seong~ 35        26.5  ""    38    418    3,420 38.0  29    16    25    4    
## 11 Suw B~ 40        27.4  ""    38    418    3,420 38.0  46    30    41    5    
## 12 Ulsan~ 29        29.1  ""    38    418    3,420 38.0  69    38    66    3    
## # ... with 8 more variables: p_katt <chr>, crd_y <chr>, crd_r <chr>,
## #   gls_2 <chr>, ast_2 <chr>, g_a <chr>, g_pk_2 <chr>, g_a_pk <chr>

 

메시지는 사라지지 않았지만 문제 자체는 해결한 상태입니다.

 

90분 쪽에는 '_2'가 붙어서 열 이름이 gls_2, ast_2, g_pk_2라고 바뀐 겁니다.

 

이를 통해 열 이름이 전부 소문자로 바뀌었다는 사실도 알 수 있습니다.

 

특수 문자(#)도 사라졌고 숫자로 시작하는 열 이름도 `90s`에서 'x90s'로 달라졌습니다.

 

이렇게 clean_names()는 이름 그래도 열 이름을 보기 좋고 입력하기에도 편한 형태로 바꿔주는 구실을 합니다.

 

개인적으로는 clean_names() 함수 하나만으로도 janitor 패키지를 이용해야 할 이유가 충분하다고 생각합니다.

 

대문자를 입력하려고 시프트를 누르는 일만 줄어들어도 세상에 얼마나 편해지는지 모릅니다.

 

janitor 패키지에는 중복 데이터를 알려주는 get_dupes() 함수도 들어 있습니다.

 

이번에는 '베이스볼 레퍼런스'에서 지난해 프로야구 팀 타격 기록을 불러와서 예제 데이터로 쓰겠습니다.

 

(어른들 사정으로 베이스볼 레퍼런스는 크롤링 코드가 조금 더 복잡한 형태입니다.)

'https://www.baseball-reference.com/register/league.cgi?id=6a2bec36' %>% 
  read_html() %>% 
  paste0(collapse = '') %>% 
  str_remove_all(c('<!--|-->')) %>% 
  read_html() %>% 
  html_node('table#league_batting') %>% 
  html_table() -> kbo_batting
kbo_batting
## # A tibble: 11 x 27
##    Finals     Aff   BatAge `R/G`     G    PA    AB     R     H  `2B`  `3B`    HR
##    <chr>      <lgl>  <dbl> <dbl> <int> <int> <int> <int> <int> <int> <int> <int>
##  1 Kiwoom He~ NA      26.5  5.42   144  5658  4991   780  1405   251    38   112
##  2 Doosan Be~ NA      29.5  5.11   144  5670  4913   736  1364   235    31    84
##  3 NC Dinos   NA      28.7  4.68   144  5631  4968   674  1383   249    26   128
##  4 SK Wyverns NA      30.8  4.55   144  5541  4919   655  1290   218    14   117
##  5 KT Wiz     NA      28.2  4.51   144  5582  4965   650  1375   187    20   103
##  6 LG Twins   NA      29.5  4.45   144  5536  4928   641  1316   233    15    94
##  7 Samsung L~ NA      28.8  4.32   144  5533  4866   622  1245   230    26   122
##  8 Hanwha Ea~ NA      28.9  4.22   144  5482  4882   607  1250   223    16    88
##  9 Kia Tigers NA      28.1  4.2    144  5505  4874   605  1286   248    19    76
## 10 Lotte Gia~ NA      29.3  4.01   144  5488  4919   578  1231   214    22    90
## 11 League To~ NA      28.8  4.55  1440 55626 49225  6548 13145  2288   227  1014
## # ... with 15 more variables: RBI <int>, SB <int>, CS <int>, BB <int>,
## #   SO <int>, BA <dbl>, OBP <dbl>, SLG <dbl>, OPS <dbl>, TB <int>, GDP <int>,
## #   HBP <int>, SH <int>, SF <int>, IBB <int>

 

get_dupes() 함수를 쓰실 때는 괄호 안에다 어떤 열이 겹치는지 확인해 달라고 알려줘야 합니다.

 

도루(SB) 숫자가 똑같은 팀이 있는지  알아보는 코드는 이렇게 쓸 수 있습니다.

 

물론 clean_names() 함수를 쓰고 나면 열 이름도 그냥 소문자로 치면 됩니다.

kbo_batting %>% 
  clean_names() %>% 
  get_dupes('sb')
## # A tibble: 2 x 28
##      sb dupe_count finals     aff   bat_age   r_g     g    pa    ab     r     h
##   <int>      <int> <chr>      <lgl>   <dbl> <dbl> <int> <int> <int> <int> <int>
## 1    87          2 NC Dinos   NA       28.7  4.68   144  5631  4968   674  1383
## 2    87          2 Kia Tigers NA       28.1  4.2    144  5505  4874   605  1286
## # ... with 17 more variables: x2b <int>, x3b <int>, hr <int>, rbi <int>,
## #   cs <int>, bb <int>, so <int>, ba <dbl>, obp <dbl>, slg <dbl>, ops <dbl>,
## #   tb <int>, gdp <int>, hbp <int>, sh <int>, sf <int>, ibb <int>

 

NC와 KIA가 똑같이 도루를 72개씩 기록했다는 사실을 확인할 수 있습니다.

 

기준 여러 개가 똑같은 자료를 찾고 싶을 때는 get_dupes() 안에 열 이름을 계속 붙여 넣기면 하면 됩니다.

 

분할표는 특정한 범수를 기준으로 관측값이 몇 개가 있는지 알려주는 표입니다.

 

janitor 패키지에서는 tabyl() 함수가 분할표를 그리는 기능을 담당합니다.

 

R 기본 데이터 세트 mtcars에 실린더(cyl) 숫자별 차량이 몇 대나 들어 있는지는 이렇게 쓸 수 있습니다.

mtcars %>% 
  tabyl(cyl)
>##  cyl  n percent
##    4 11 0.34375
##    6  7 0.21875
##    8 14 0.43750

 

기준이 여러 개여도 좋습니다. 실린더와 기어(gear) 숫자별로 차량 대수를 구해보겠습니다.

mtcars %>% 
  tabyl(cyl, gear)
##  cyl  3 4 5
##    4  1 8 2
##    6  2 4 1
##    8 12 0 2

 

아, 마지막으로 소개해 드릴 함수가 하나 더 있습니다.

 

MS 엑셀 데이터를 불러오다 보면 엑셀에서는 분명 날짜가 날짜였는데 R에서는 평범한 숫자로 바뀔 때가 있습니다.

 

이건 엑셀에서 1900년 1월 1일 = 1이라고 기준을 정해 날짜를 계산하는 방식을 채택하고 있기 때문입니다.

 

오늘(2020년 1월 31일)은 이날로부터 4만3861일이 지난 날이라 43861에 해당합니다.

 

excel_number_to_date() 함수에 이 숫자를 넣으면 오늘 날짜로 바꿀 수 있습니다.

excel_numeric_to_date(43861)
## [1] "2020-01-31"

 

이 함수를 쓰실 일이 그리 많을 것 같지는 않지만 이런 함수야 말로 알고 계실 때와 모를 때 '노가다' 수준이 천지차이가 아닐까요?

 

그럼 모두들 Happy Tidyversing -_-)/

 

데이터 과학 입문서 '친절한 R with 스포츠 데이터'를 썼습니다

어쩌다 보니 '한국어 사용자에게 도움이 될지도 모르는 R 언어 기초 회화 교재'를 세상에 내놓게 됐습니다. 책 앞 부분은 '한국어 tidyverse 사투리' 번역, 뒷부분은 'tidymodels 억양 따라하기'에 초점

kuduz.tistory.com

 

댓글,

KindeR | 카테고리 다른 글 더 보기