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

최대한 친절하게 쓴 R로 낱말구름, 의미연결망 그리기(feat. tidyverse, KoNLP)


위에 있는 사진은 도널드 트럼프 미국 대통령이 지난해 11월 8일 국회를 찾아 연설한 내용을 낱말구름(워드클라우드) 형태로 정리한 겁니다.


당연히 R를 활용해 텍스트 데이터를 정리했습니다. (최종 결과물을 만들 때는 어도비 포토샵 도움도 받았습니다.)


R는 이렇게 숫자뿐 아니라 텍스트 데이터를 분석하고 정리하는 데도 일가견이 있습니다. 이런 작업을 흔히 텍스트 마이닝(text mining)이라고 부릅니다.


이 포스트를 읽고 계신 분 대다수는 인터넷 포털 사이트 등에 R 관련 검색어를 넣어 찾아오셨을 테니 이미 R가 무엇인지 알고 계실 겁니다.


그래도 혹시 모르시는 분들께 R 홈페이지에 올라온 자기소개를 따라 말씀드리면 "R는 통계 계산과 그래픽에 활용하는 무료 소프트웨어 환경(R is a free software environment for statistical computing and graphics)"입니다.


무료라는 건 누구나 이 페이지에서 원하시는 R 버전을 설치해 사용할 수 있다는 뜻입니다. 2018년 9월 현재 최신 버전은 3.5.1이며 마이크로소프트(MS) 윈도에 이 버전를 설치하고 싶으신 분은 이 링크를 눌러 설치파일을 내려받으실 수 있습니다.


이 포스트 제목에는 분명 '최대한 친절하게'라는 표현이 들어가 있지만 프로그램 설치법까지 모르실 거라고는 생각하지 않으니 이 부분은 넘어가겠습니다.


설치를 끝마치신 다음 R를 실행하시면 아래 화면이 나타납니다. 창 제목에 나온 것처럼 이 화면은  'R 콘솔(Console)'이라고 부릅니다.



R 콜손에서 '>' 표시 뒤에 명령어를 입력하면 프로그래밍(코딩)을 시작할 수 있습니다. 코딩은 '이러저러한 게 하고 싶다'고 컴퓨터에게 알려주는 행위입니다.


이 창에 "print('Hello, World!')"라고 쓴 건 Hello, World!를 (화면에) 출력하고 싶다고 뜻입니다. 이 "Hello, World!" 프로그램은 코딩을 처음 배울 때 제일 많이 쓰는 예제입니다.


그러니까 여러분은 방금 첫 번째 R 명령어(함수)를 배우신 겁니다.


두 번째로 배울 명령어는 'install.packages()'입니다. 이 함수는 의미 그대로 R에 패키지를 설치하라는 뜻입니다.


패키지는 R에 기본으로 들어 있지 않은 각종 기능을 추가할 수 있는 묶음이라고 이해하시면 쉽습니다.


그렇다면 이번에 우리가 설치해야 하는 패키지 이름은 뭘까요? 맞습니다. 'tidyverse'입니다. 


tidyverse는 R를 깔끔한(tidy) 세계(verse)로 바꿔주는 도구입니다. tidyverse는 패키지 하나가 아니라 △dplyr △forcats △ggplot2 △purrr △readr △stringr △tibble △tidyr 등 8개 패키지를 한 곳에 묶은 형태입니다. 이런 이유로 'tidyverse 생태계'라는 표현도 씁니다. 


이 '최대한 진철하게 쓴 R' 시리즈는 기본적으로 tidyverse 생태계를 소개하는 게 목적입니다.


이번 포스트에서도 꼭 이해가 필요한 부분은 최대한 친절하게 설명하겠지만 부족하다고 느끼실 수 있을 겁니다. 이 블로그에 dplyr, ggplot2, tidyr에 대한 소개글은 이미 있으니 각 패키지가 궁금하신 분은 읽어보셔도 좋습니다.


이제 정말 tidyverse 패키지를 설치하겠습니다. 그냥 이렇게 치시면 됩니다.

install.packages('tidyverse')


이렇게 입력하고 나면 R가 어떤 (미러) 사이트에서 패키지를 내려받을 것인지 물어올 겁니다. 지금은 어떤 사이트에서 내려받아도 관계가 없습니다. 그냥 엔터만 치시면 자동으로 설치를 진행할 겁니다.


R에서는 패키지를 설치했다고 곧바로 사용할 수 있게 되는 건 아닙니다. 패키지를 메모리에 불러오는 절차를 한 번 더 거쳐야 합니다. 이때는 'library()'라는 함수를 씁니다.

library('tidyverse')
## -- Attaching packages ------------------------------- tidyverse 1.2.1 --
## √ ggplot2 3.0.0     √ purrr   0.2.5
## √ tibble  1.4.2     √ dplyr   0.7.5
## √ tidyr   0.8.1     √ stringr 1.3.1
## √ readr   1.1.1     √ forcats 0.3.0
## -- Conflicts ---------------------------------- tidyverse_conflicts() --
## x dplyr::filter() masks stats::filter()
## x dplyr::lag()    masks stats::lag()


위에서 말씀드렸던 패키지 여덟 가지를 한 번에 불러왔다는 걸 알 수 있습니다. 아래는 dplyr에서 사용하는 명령어(tidyverse에서는 함수 대신 '동사'라는 표현도 씁니다)가 같아서 이를 덮어썼다는 뜻입니다.


축하드립니다. 이제 여러분은 그 유명한 tidyverse 세계에 입성하시는 데 성공하셨습니다. 이제부터 이 포스트를 따라 한 번 깔끔하게 텍스트 데이터를 처리해 보겠습니다.



문재인 대통령 평양 연설 불러오기

이번에 우리가 분석할 데이터는 문재인 대통령이 19일 평양 5·1 경기장에서 연설한 내용입니다. 이 연설 내용은 아래 텍스트 파일에 들어 있습니다.


moon.txt


윈도를 쓰고 계시다면 별도로 프로그램을 설치하시지 않아도 '메모장'에서 간단히 텍스트 파일을 열어볼 수 있습니다.


R에서도 코드 한 줄이면 텍스트 파일 확인이 가능합니다. 우리는 'moon.txt' 파일을 불러오려는 거니까 이렇게 쓰시면 됩니다. (대소문자에 주의하세요!)

readLines('moon.txt')


우리는 tidyverse 생태계를 배우고 있으니까 같은 기능을 하는 동사를 스겠습니다. readr 패키지에 들어 있는 read_lines()가 같은 구실을 합니다. 

read_lines('moon.txt')
##  [1] "북녘 동포 여러분, 남녘의 국민 여러분, 해외 동포 여러분, 전쟁 없는 한반도가 시작되었습니다. 남과 북은 오늘 한반도 전 지역에서 전쟁을 일으킬 수 있는 모든 위험을 없애기로 합의했습니다."
##  [2] ""
##  [3] "남북군사공동위원회를 가동해 군사 분야 합의 사항의 이행을 위한 상시적 협의를 진행하기로 했습니다. 1953년 정전협정으로 포성은 멈췄지만 지난 65년 전쟁은 우리의 삶에서 계속되었습니다. 죽어야 할 이유가 없는 젊은 목숨들이 사라졌고, 이웃들 사이에 보이지 않는 벽이 생겼습니다. 한반도를 항구적 평화지대로 만들어감으로써 우리는 이제 우리의 삶을 정상으로 돌려놓을 수 있게 되었습니다."
##  [4] ""
##  [5] "그동안 전쟁의 위협과 이념의 대결이 만들어온 특권과 부패, 반인권으로부터 벗어나 우리 사회를 온전히 국민의 나라로 복원할 수 있게 되었습니다."
##  [6] "나는 오늘 이 말씀을 드릴 수 있어 참으로 가슴 벅찹니다."
##  [7] ""
##  [8] "남과 북은 처음으로 비핵화 방안도 합의했습니다. 매우 의미 있는 성과입니다."
##  [9] "북측은 동창리 엔진 시험장과 미사일 발사대를 유관국의 전문가들의 참여하에 영구적으로 폐쇄하기로 했습니다. 또한 미국의 상응 조치에 따라 영변 핵시설의 영구 폐기와 같은 추가적 조치도 취해 나가기로 했습니다."
## [10] ""
## [11] "우리 겨레 모두에게 아주 기쁘고 고마운 일입니다. 한반도의 완전한 비핵화가 멀지 않았습니다. 남과 북은 앞으로도 미국 등 국제사회와 비핵화의 최종 달성을 위해 긴밀하게 협의하고 협력해 나가기로 했습니다."
## [12] "우리의 역할도 막중해졌습니다. 국민들의 신뢰와 지지가 어느 때보다 절실합니다."
## [13] ""
## [14] "북녘의 동포 여러분, 남녘의 국민 여러분, 지난 판문점 선언 이후 한반도와 그 주변에는 역사적 사변이라고 해도 좋을 거대한 변화가 일어나고 있습니다. 사상 최초로 북미 정상이 마주 앉아 회담을 하고 합의 사항을 내놓았습니다."
## [15] ""
## [16] "북측은 추가 핵실험과 미사일 실험을 일체 하지 않겠다고 약속했으며 이를 지켰습니다. 한미 양국도 대규모 연합훈련을 중단했습니다."
## [17] "개성에는 남북 공동연락사무소가 설치되었습니다. 상시적으로 우리의 문제를 논의할 수 있는 새로운 남북시대가 열렸습니다."
## [18] ""
## [19] "너무나 꿈같은 일이지만 우리 눈앞에서 분명히 이행되고 있는 일들입니다."
## [20] ""
## [21] "평화와 번영을 바라는 우리 겨레의 마음은 단 한 순간도 멈춘 적이 없습니다. 빠르게 보이지만 결코 빠른 것이 아닙니다. 이러한 일들은 오랫동안 바라고, 오래도록 준비해 온 끝에 오늘 우리 앞에 펼쳐지고 있는 것입니다. 하나로 모인 8천만 겨레의 마음이 평화의 길을 열어냈습니다. 우리는 우리가 만들어낸 이 길을 완전한 비핵화를 완성해 가며 내실 있게 실천해 가야 할 것입니다."
## [22] ""
## [23] "김정은 위원장과 나는 오늘 평양에서 북과 남의 교류와 협력을 더욱 증대시키기로 하였고, 민족 경제를 균형적으로 발전시키기 위한 실질적 대책을 만들어나가기로 했습니다. "
## [24] "남과 북은 올해 안에 동·서해선 철도와 도로 연결을 위한 착공식을 가질 것입니다. 환경이 조성되는 대로 개성공단과 금강산 관광 사업의 정상화도 이루어질 것입니다."
## [25] ""
## [26] "한반도 환경 협력과 전염성 질병의 유입과 확산을 막기 위한 보건의료 분야의 협력은 즉시 추진될 수 있을 것입니다. 금강산 이산가족 상설면회소 복구와 서신 왕래, 화상 상봉은 우선적으로 실현해 나갈 것입니다."
## [27] ""
## [28] "2032년 하계올림픽의 남북 공동 개최 유치에도 함께 협력하기로 했습니다."
## [29] "3.1운동 100주년 공동 행사를 위한 구체적 준비도 시작하기로 했습니다."
## [30] "10월이 되면 평양예술단이 서울에 옵니다. '가을이 왔다' 공연으로 남과 북 사이가 더욱 가까워질 것입니다."
## [31] ""
## [32] "나는 김정은 위원장에게 서울 방문을 요청했고, 김 위원장은 가까운 시일 안에 서울을 방문하기로 했습니다. 여기에서 '가까운 시일 안에'라는 말은 특별한 사정이 없으면 '올해 안에'라는 의미를 담고 있습니다. 김 위원장의 서울 방문은 최초의 북측 최고지도자의 방문이 될 것이며 남북관계의 획기적 전기가 마련될 것입니다."
## [33] ""
## [34] "북녘 동포 여러분, 남녘의 국민 여러분, 해외 동포 여러분, 김정은 위원장은 오늘 한반도 비핵화의 길을 명확히 보여주었고 핵무기도, 핵 위협도, 전쟁도 없는 한반도의 뜻을 같이했습니다. 온 겨레와 세계의 여망에 부응했습니다."
## [35] "김정은 위원장의 결단과 실행에 깊은 경의를 표합니다."
## [36] ""
## [37] "남북관계는 흔들림 없이 이어져갈 것입니다. 이제 평양회담의 성과를 바탕으로 북미 간 대화가 빠르게 재개되기를 기대합니다."
## [38] "북미 양국은 끊임없이 친서를 교환하며 서로 간의 신뢰를 거듭 확인해왔습니다. 양국 간 정상회담이 조속히 이루어지고, 양국이 서로 합의할 수 있는 지점을 찾을 수 있도록 우리의 노력도 다해 나갈 것을 약속합니다."
## [39] ""
## [40] "지난봄, 한반도에는 평화와 번영의 씨앗이 뿌려졌습니다. 오늘 가을의 평양에서 평화와 번영의 열매가 열리고 있습니다."
## [41] "감사합니다."


한번에 이렇게 파일을 불러오는 데 성공한 분도 계실 거고 'Error: 'moon.txt' does not exist in current working directory'라는 에러 메시지를 접한 분도 계실 겁니다.


에러 메시지가 설명하고 있는 것처럼 R는 기본적으로 작업 디렉토리(폴더)에서 파일을 불러옵니다. MS 윈도를 기준에서는 '문서'가 기본 작업 폴더입니다. 그러니 이 텍스트 파일을 문서에 내려받으셨어야 합니다.


물론 꼭 이 방법만 있는 건 아닙니다. 아예 편하신 곳을 작업 폴더로 지정하시는 방법도 있습니다.


가장 손쉬운 방법은 R 메뉴에서 File - Change dir...를 선택하시는 겁니다. 이 메뉴를 선택하시면 폴더 선택창이 뜹니다. 이 다음부터는 설명드리지 않아도 어떻게 하는지 아시겠죠?


코드를 가지고 작업 폴더를 바꾸고 싶으신 분은 'setwd()' 함수를 쓰셔도 됩니다. 괄호 안에 원하는 폴더 이름을 적어 넣으시면 그리로 작업 폴더가 바뀝니다.


이번에만 다른 폴더에서 파일을 불러오고 싶으실 때는 'file.choose()' 함수를 쓰시는 방법도 있습니다.


그러니까 'read_lines(file.choose())'라고 입력하시면 파일을 선택할 수 있는 창이 뜹니다. 그러면 그때 moon.txt를 선택하시면 됩니다.


문제는 이렇게 파일을 불러왔다고 해서 R가 이를 저장하고 있는 건 아니라는 점입니다. 화면에 내용을 출력하고 나면 이 내용은 R 내부에서 사라지고 맙니다.


우리는 계속 이 내용을 쓰고 싶으니까 메모리에 저장해야겠죠? 이럴 때는 메모리 안에 빈 방(공간)을 만들어 담아두면 됩니다.


코딩에서는 이런 방을 '변수'라고 부릅니다. R에서 변수에 어떤 값을 넣으라고 할 때는 '<-' 또는 '=' 기호를 씁니다.


우리는 m이라는 변수에 이 내용을 넣어두도록 하겠습니다. 이렇게 쓰면 됩니다.

m <- read_lines('moon.txt')


원래 아무 반응도 나타나지 않는 거니까 아무 반응도 없다고 걱정하지 않으셔도 됩니다. 


그러면 파일이 잘 들어왔는지는 어떻게 확인할 수 있을까요?


첫 번째 방법은 '>' 다음에 변수 이름을 입력하는 겁니다. 지금 m이라고 치시면 위에서 우리가 본 것하고 똑같은 결과가 나타날 겁니다.


두 번째는 'head()'나 'tail()' 함수를 쓰는 방식입니다. head는 어떤 자료가 있을 때 처음 여섯 줄, tail은 거꾸로 맨 뒤에서 여섯 줄을 보여주는 기능을 합니다.


기본은 여섯 줄씩이지만 'head(변수 이름, 숫자)' 형태로 원하는 만큼만 확인할 수도 있습니다. 첫 번째 한 줄만 볼까요?

head(m, 1)
## [1] "북녘 동포 여러분, 남녘의 국민 여러분, 해외 동포 여러분, 전쟁 없는 한반도가 시작되었습니다. 남과 북은 오늘 한반도 전 지역에서 전쟁을 일으킬 수 있는 모든 위험을 없애기로 합의했습니다."


역시 잘 들어왔습니다. 이제 텍스트까지 불러들였으니 진짜 분석을 시작해 보겠습니다. 좀 지루할 수도 있으니 기지재도 한 번씩 펴시면서 같이 가보시죠 -_-)/



한국어와 영어 차이 혹은 tidytext여 안녕~

R 세계에는 tidyverse 패키지에 들어 있는 여덟 가지 말고도 '깔끔한 접근(tidy approach)'을 추구하는 여러 패키지가 존재합니다. 넓은 의미에서 이들 역시 tidyverse 생태계에 속해 있다고 할 수 있습니다.


텍스트 분석에서는 tidytext 패키지가 바로 여기에 해당합니다.


그렇다면 데이터에 깔끔하게 접근한다는 건 무슨 의미일까요?


첫 번째 단계는 이미 '최대한 친절하게 쓴 R로 데이터 깔끔하게 만들기(feat. tidyr)' 포스트에 쓴 것처럼 다음과 같은 형태로 데이터를 정리하는 겁니다.

  1. 각 변수가 열이 된다(Each Variable is in column).
  2. 각 관측이 행이 된다(Each observation is a row).
  3. 각 결과는 한 셀에 들어간다(Each value is a cell).

아직 이게 어떤 건지 감지 알 오지 않으시죠?


tidytext 패키지에서 텍스트를 어떻게 정리하는지 보시면 도움이 될지 모릅니다.


아래처럼 영어로 쓴 네 문장이 있다고 가정하겠습니다. 지금 우리가 m에 가지고 있는 데이터와 큰 차이가 없어 보이기도 하는 게 사실입니다.

## [1] "Because I could not stop for Death -"   "He kindly stopped for me -"            
## [3] "The Carriage held but just Ourselves -" "and Immortality"


tidytext에 들어 있는 'unnest_tokens()' 함수를 이용하면 이 데이터를 이렇게 바꿀 수 있습니다.

## # A tibble: 20 x 2
##     line word   
##       
##  1     1 because
##  2     1 i      
##  3     1 could  
##  4     1 not    
##  5     1 stop   
##  6     1 for    
##  7     1 death  
##  8     2 he     
##  9     2 kindly 
## 10     2 stopped
## # ... with 10 more rows


감이 좀 잡히시나요? 일단 문장을 띄어쓰기 단위로 끊어서 행을 새로 만들었습니다. 그리고 line이라는 열에 원래 이 낱말이 몇 번째 문장에 들어 있었는지도 담고 있습니다.


아, 여기 등장하는 tibble은 tidyverse 생태계에서 쓰는 데이터 프레임(Data Frame)이라고 생각하시면 됩니다. 그러면 데이터 프레임이 뭔지 또 아셔야겠죠?


가장 쉽게 설명드리면 MS 엑셀에서 자료를 다룰 때처럼 행과 열이 있는 테이블 형태로 자료를 담고 있는 형식이 데이터 프레임입니다.


tibble 또는 tbl_df는 이 데이터 프레임이 tidyverse 생태계에 더 잘 달라붙을 수 있도록 튜닝한 형태라고 이해하셔도 무방합니다. 앞으로 자주 쓰게 될 테니 개념 정도는 알고 가셔야 좋습니다.


물론 m에 들어 있는 한국어 문장도 tibble로 만들 수 있고, 띄어쓰기를 기준으로 행갈이를 할 수도 있습니다. 이렇게 말입니다.

## # A tibble: 555 x 2
##     line word  
##      
##  1     1 북녘  
##  2     1 동포  
##  3     1 여러분
##  4     1 남녘의
##  5     1 국민  
##  6     1 여러분
##  7     1 해외  
##  8     1 동포  
##  9     1 여러분
## 10     1 전쟁  
## # ... with 545 more rows


그런데 한국어는 이렇게 하면 안 됩니다.


여기서는 '남녘의'가 대표 사례입니다. 이 표현은 '남녘'이라는 명사에 관형격 조사 '의'가 붙어 있는 형태입니다. 데이터를 분석할 때는 이를 두 가지로 나눠야 합니다.


이런 사례만 있는 게 아닙니다. '감사합니다'라는 말은 어떨까요? 이 말은 '감사+하+ㅂ니다' 형태로 분석할 수 있습니다.


역시 한국어 텍스트를 분석할 때는 이렇게 분해한 형태로 접근하는 게 여러 모로 도움이 됩니다.


이렇게 한국어는 형태론적으로 교착어(膠着語)로 분류할 수 있는 특징을 나타내기 때문에 사실상 고립어로 변한 현대 영어를 분석하려고 만든 tidytext만 쓰는 데는 한계가 있을 수밖에 없습니다. 


그래서 새로운 길을 찾아야 합니다.



안녕, KoNLP! 그리고 파이프(%>%)

물론 여러분이 지금 당장 새로운 길을 만드실 필요는 없습니다.


전희원 님이라는 아주 멋진 분께서 한국어 데이터를 처리할 수 있는 KoNLP(Korean Natural Language Processing) 패키지를 만드셨기 때문입니다. 박수!


패키지가 새로 등장했으니까 우리가 처음 해야 할 일은 패키지를 설치하고 불러오는 겁니다. 기억하고 계시죠?

install.packages('KoNLP')
library('KoNLP')
## Checking user defined dictionary!


여기서 에러를 접한 분이 분명 계실 겁니다. 그리고 이렇게 에러가 나온 이유는 거의 대부분 자바(Java) 때문일 겁니다.


당황하지 마시고 일단 이 링크를 찾아가셔서 자기 운영체제(OS)에 맞는 오프라인 버전을 내려받아 설치합니다.


설치가 끝나면 R로 돌아가서 install.packages('rJava'), library('rJava')를 하신 다음에 KoNLP를 불러오시면 정상 작동할 겁니다.


KoNLP는 사전을 통해 단어를 분류하게 되는데 가장 최근에 이 패키지에 들어간 건 '형태소사전(NIADic)'입니다.


아래 명령어로 이 사전을 불러올 수 있습니다.

useNIADic()
## Backup was just finished!
## 983012 words dictionary was built.


우리가 KoNLP 패키지를 불러온 건 한국어 표현을 분해해서 이해하려는 목적이었습니다.


이렇게 어떤 어절을 분석하는 걸 흔히 '형태소 분석'이라고 합니다. 이 분석 첫 단계는 동사, 명사, 형용사 '품사'를 붙여주는 겁니다.


KoNLP에는 KIAST 품사 태그셋에 따라 텍스트에 품사를 붙일 수 있는 함수 두 가지가 들어 있습니다. 하나는 SimplePos09()고 두 번째는 SimplePos22()입니다.


차이는? 09는 품사를 9개로 구분하고, 22는 22개로 구분한다는 점입니다. 이번 포스트에서는 일단 간단하게 09를 쓰도록 하겠습니다. 


'최대한 친절하게 쓴 R'라는 표현을 SimplePos09()에 넣는 걸로 시작해 볼까요? 

SimplePos09('최대한 친절하게 쓴 R')
## $최대한
## [1] "최대한/M"
##
## $친절하게
## [1] "친절/N+하/X+게/E"
##
## $쓴
## [1] "쓰/P+ㄴ/E"
##
## $R
## [1] "R/F"


뭔가 재미있는 결과가 나타나지 않았습니까?


당연히 SimplePos09()를 한 문장에만 적용할 수 있는 건 아닙니다.


m에 통째로 써볼까요? 그 결과를 mp라는 새 변수에 넣도록 하겠습니다.

mp <- SimplePos09(m)


이번에도 아무 반응이 없습니다. mp라는 변수를 만들라고 했을 뿐 무엇을 출력하라고 명령한 건 아니니까요. 이렇게 한번 확인해 보겠습니다.

mp %>% tail(1)
## [[1]]
## [[1]]$감사합니다
## [1] "감사/N+하/X+ㅂ니다/E"
##
## [[1]]$.
## [1] "./S"


결과가 잘 나온 건 나온 건데 코드가 뭔가 신기하지 않나요?


저기서 '%>%'는 tidyverse에서 쓰는 파이프 기호입니다.


금속이나 플라스틱으로 만든 파이프는 액체나 기체 같은 유체를 다른 곳으로 보내는 구실을 합니다. 마찬가지로 코딩에서 파이프도 변수 내용을 이 함수에서 저 함수로 옮깁니다.


위에 제가 쓴 코드는 'tail(mp, 1)'이라고 써도 똑같은 결과를 내놓습니다.


이렇게 함수를 겹치고 겹쳐 쓰다 보면 괄호 하나만 잘못 써도 처음부터 코드를 다시 작성해야 하는 일이 생깁니다.


또 tail(mp, 1)이라고 쓰는 것보다 mp %>% tail(1) 쪽이 코드 내용을 직관적으로 이해하는 데 더 도움이 됩니다.


이런 이유로 다른 분들이 쓰신 R 코드를 보다 보면 %>%를 어렵지 않게 발견하실 수 있습니다. 물론 이 포스트에서도 앞으로 여러 차례 등장할 겁니다.



tibble도 안녕!

위에서 tidytext는 자료를 tibble이라는 형식으로 다룬다고 말씀드렸습니다. 그러면 mp는 어떤 형식일까요?


이를 알아볼 때는 'class()' 함수 도움을 받으면 됩니다. 당연히 파이프를 써야겠죠?

mp %>% class()
## [1] "list"


리스트(list) 형식이라고 나옵니다. 리스트는 문자 그대로 목록을 담고 있다는 뜻입니다. mp에는 형태소 분석을 마친 목록이 들어 있습니다.


이제 우리가 할 일은 이 리스트를 tibble로 바꾸는 것.


tidyverse 동사만으로 리스트를 tibble로 바꾸는 방법이 없는 건 당연히 아닙니다. 그런데 그보다 더 간단하고 쉬운 길이 있다면 그 쪽을 택하는 게 더 친절할 겁니다.


이번에는 reshape2 패키지에 들어 있는 melt() 함수를 쓸 생각입니다. 새로 패키지가 등장했으니 설치하고 불러들이는 과정부터 거쳐야 합니다.

install.packages('reshape2')
library('reshape2')
##
## Attaching package: 'reshape2'
## The following object is masked from 'package:tidyr':
##
##     smiths


여기서 tidyr가 등장하는 걸 눈여겨 볼 만합니다. tidyverse 생태계에서 reshape2와 제일 비슷한 기능을 하는 게 바로 tidyr입니다.


reshape2에서 melt()는 tidyr에서 gather()처럼 데이터를 롱 포맷(long format)으로 바꿔주는 구실을 합니다. 


두 함수에 차이가 있다면 melt()는 별 다른 옵션을 주지 않아도 mp처럼 리스트 안에 리스트가 들어 있는 자료도 데이터 프레임으로 바꿔준다는 점입니다. 이렇게 말입니다.

mp %>% melt %>% head
##          value     L2  L1
## 1       북녘/N     북녘   1
## 2       동포/N     동포   1
## 3  여러분/N+,/S   여러분,  1
## 4   남녘/N+의/J   남녘의   1
## 5       국민/N     국민   1
## 6  여러분/N+,/S   여러분,  1


사실 우리가 원하는 건 데이터 프레임이 아니라 tibble입니다. 데이터 프레임을 tibble로 바꿀 때는 as_tibble() 함수를 한번 쓰시면 됩니다.


이렇게 자료를 tibble 형태로 바꾼 다음에 이를 m_df 라는 변수에 넣겠습니다. 그리고 내용까지 확인해 보겠습니다.

m_df <- mp %>% melt %>% as_tibble
m_df
## # A tibble: 610 x 3
##    value        L2         L1
##    <fct>        <chr>   <int>
##  1 북녘/N       북녘        1
##  2 동포/N       동포        1
##  3 여러분/N+,/S 여러분,     1
##  4 남녘/N+의/J  남녘의      1
##  5 국민/N       국민        1
##  6 여러분/N+,/S 여러분,     1
##  7 해외/N       해외        1
##  8 동포/N       동포        1
##  9 여러분/N+,/S 여러분,     1
## 10 전쟁/N       전쟁        1
## # ... with 600 more rows


tidytext가 자료를 처리했던 것과 점점 비슷한 모양으로 바뀌고 있습니다.


이걸 더 비슷하게 만들려면 L1을 제일 앞에 넣고 value를 그 다음에 넣으면 될 겁니다. 이럴 때는 어떻게 하면 될까요?


데이터 프레임에서 특정한 위치에 있는 자료만 뽑아 보고 싶을 때는 '데이터 프레임[행, 열]' 순서로 쓰시면 됩니다.


그렇다면 'm_df[, 1]'라고 쓰면 어떻게 될까요? 첫 번째 열인 value만 보여줄 겁니다.

m_df[, 1]
## # A tibble: 610 x 1
##    value       
##    <fct>       
##  1 북녘/N      
##  2 동포/N      
##  3 여러분/N+,/S
##  4 남녘/N+의/J 
##  5 국민/N      
##  6 여러분/N+,/S
##  7 해외/N      
##  8 동포/N      
##  9 여러분/N+,/S
## 10 전쟁/N      
## # ... with 600 more rows


우리는 세 번째, 첫 번째가 필요한 겁니다. 이럴 때는 'm_df[, c(3, 1)]'이 모법답안입니다.


'c()'라는 함수가 새로 등장했습니다. 여기서 c는 '사슬같이 잇다(concatenate)'라는 뜻으로 R에서는 자료 여러 개를 한꺼번에 표시하고 싶을 때 씁니다. 여기서는 세 번째와 첫 번째를 같이 묶어달라는 뜻으로 이렇게 쓴 겁니다.


아래에서는 이 결과를 다시 m_df에 넣었습니다.

m_df <- m_df[, c(3, 1)]
m_df
## # A tibble: 610 x 2
##       L1 value       
##    <int> <fct>       
##  1     1 북녘/N      
##  2     1 동포/N      
##  3     1 여러분/N+,/S
##  4     1 남녘/N+의/J 
##  5     1 국민/N      
##  6     1 여러분/N+,/S
##  7     1 해외/N      
##  8     1 동포/N      
##  9     1 여러분/N+,/S
## 10     1 전쟁/N      
## # ... with 600 more rows


이제 tidytext 부럽지 않은 모양이 됐습니다. 돌다리도 두드려 보고 건너듯 천천히 오느라 엄청 오래 걸린 느낌적인 느낌이 들지만 지금까지 쓴 코드 안에 진짜 텍스트 분석에 필요한 내용이 들어 있는 건 아래가 전부입니다.

library('tidyverse')
library('KoNLP')
useNIADic()
library('reshape2')
m_df <- read_lines('moon.txt') %>%
  SimplePos09 %>%
  melt %>%
  as_tibble %>%
  select(3, 1)


그러니 앞으로도 계속 천천히 가겠지만 너무 겁을 먹거나 어려워 하실 필요 없습니다.


아, 이 코드 맨 끝에 등장하는 select() 함수는 '열을 선택하는' dplyr 패키지 동사입니다.


dplyr 패키지에 대해 더 자세히 알고 싶으신 분은 '최대한 친절하게 쓴 R로 데이터 뽑아내기(feat. dplyr)' 포스트가 도움이 될 수 있습니다.



어떤 새빨간 거짓말

지금까지 말씀드린 건 위에 쓴 것처럼 '돌다리도 두드려 보는' 방식입니다. 어떤 원리로 텍스트 데이터를 처리하는지 보여드리려고 일부러 돌아가는 길을 선택한 것.


사실은 한국어도 unnest_tokens() 함수를 써서 tidytext 스타일로 바꾸는 게 가능합니다. 차이는 'token=SimplePos09'에 있습니다.

m %>% 
  tibble(line=1:41, text=.) %>%
  unnest_tokens(pos, text, token=SimplePos09)
## # A tibble: 610 x 2
##     line pos         
##    <int> <chr>       
##  1     1 북녘/n      
##  2     1 동포/n      
##  3     1 여러분/n+,/s
##  4     1 남녘/n+의/j 
##  5     1 국민/n      
##  6     1 여러분/n+,/s
##  7     1 해외/n      
##  8     1 동포/n      
##  9     1 여러분/n+,/s
## 10     1 전쟁/n      
## # ... with 600 more rows


따라서 어차피 KoNLP나 SimplePos09에 대해 짚고 넘어갔어야 이런 식으로 자료를 정리할 수 있는 이유를 알게 되셨을 겁니다. 



정규식도 왔네?

사실 아직도 깔끔하게 끝이 난 건 아닙니다. 각 낱말이 여전히 품사 태그를 달고 있기 때문입니다. 


이 태그를 떼어 버리려면 '정규식'이라는 걸 조금 공부하셔야 합니다. 


정규식이라면 많은 분이 올해 프로야구 LG에서 방출당한 포수 정규식(28)을 떠올리시겠지만(응?) 여기서 정규식은 '정규 표현식(Regular Expression)'을 줄인 말입니다.


위키피디아는 정규 표현식을 "특정한 규칙을 가진 문자열의 집합을 표현하는 데 사용하는 형식 언어"라고 풀이하고 있습니다.


풀이를 보셔도 잘 모르시겠죠? 여러분만 그런 게 아닙니다.


그래서 아예 R에서 사용하는 정규식을 설명하는 커닝 페이퍼가 따로 있을 정도입니다.


(아래 있는 그림 파일을 내려받으시면 크기를 키우실 수 있습니다. 아니면 앞에 있는 링크에서 PDF 파일을 내려받는 방법도 있습니다.)



이 커닝 페이퍼는 기본적으로 왼쪽에 있는 표현이 오른쪽에서 색칠한 부분을 뜻한다는 내용입니다.


한번 연습해보시죠. 이 페이퍼 맨 처음에 알파벳 한 글자만 쓰면 (당연히) 그 글자를 뜻한다는 내용이 있으니 한 번 해보겠습니다.


확인에 쓸 함수는 stringr 패키지에 들어 있는 str_match()입니다.


문자열은 영어로 'string'이라고 부릅니다. tidyverse 패키지에 들어 있는 stringr 패키지가 바로 문자열을 다루는 용도입니다.


str은 string을 줄인 말이고 match는 일치한다는 뜻이겠죠?


str_match()를 쓰면 str_match(문자열, 조건) 형태로 어떤 조건에 일치하는 문자열만 뽑아내라고 명령할 수 있습니다.


따라서 '최대한 친절하게 쓴 R'이라는 표현에서 R라는 한 글자만 뽑아내고 싶을 때는 아래처럼 쓰면 됩니다.

str_match('최대한 친절하게 쓴 R', 'R')
##      [,1]
## [1,] "R"


예상한 것처럼 잘 나왔습니다.


난도를 높여보겠습니다. 여기서 알파벳만 뽑아내고 싶을 때는 어떻게 하면 될까요?


위에 [:alpha:]라고 답이 나와 있습니다. 이렇게 써도 되고 그냥 [A-Z]라고 써도 같은 결과가 나옵니다.


이번에는 알파벳이 R밖에 없기 때문에 R를 출력할 겁니다.

str_match('최대한 친절하게 쓴 R', '[A-Z]')
##      [,1]
## [1,] "R"


한글을 출력하고 싶을 때는 어떻게 해야 할까요? 가나다 순서로 정리하면 한글 첫 글자는 '가'이고 마지막 글자는 '힣'입니다. 

str_match('최대한 친절하게 쓴 R', '[가-힣]')
##      [,1]
## [1,] "최"


'최'라고 한 글자만 나온 건 str_match()가 기본적으로 맨 앞에 있는 결과를 출력하기 때문입니다. 만약 모든 결과를 출력하고 싶으실 때는 str_match_all() 함수를 쓰시면 됩니다. 

str_match_all('최대한 친절하게 쓴 R', '[가-힣]')
## [[1]]
##      [,1]
## [1,] "최"
## [2,] "대"
## [3,] "한"
## [4,] "친"
## [5,] "절"
## [6,] "하"
## [7,] "게"
## [8,] "쓴"


그런데 이건 우리가 원하던 결과하고는 조금 다릅니다. 우리가 원하는 건 '최대한'처럼 어절 단위로 끊는 거니까요. 이럴 때는 '+' 기호 도움을 받으면 됩니다.

str_match_all('최대한 친절하게 쓴 R', '[가-힣]+')
## [[1]]
##      [,1]      
## [1,] "최대한"  
## [2,] "친절하게"
## [3,] "쓴"


우리는 이미 어절 단위로 행을 나눈 상태이기 때문에 모든 데이터가 필요하지는 않습니다. 그냥 str_match()를 써서 '최대한'만 뽑아내도 충분합니다.


난도를 더 높여 보겠습니다.


우리가 한글 텍스트를 분석할 때 '몸통'에 해당하는 품사는 뭘까요?


바로 체언(명사, 대명사, 수사)입니다. 괜히 이 품사에 몸 체(體)를 붙인 게 아닐 테니까요.


우리가 쓰고 있는 KAIST 품사 태그는 체언에 '/N'이라는 꼬리표를 붙이고 있습니다.


이렇게 /N이 붙은 행만 골라내는 건 정규식으로 이렇게 쓸 수 있습니다.

str_match('북녘/N', '([가-힣]+)/N')
##      [,1]     [,2]
## [1,] "북녘/N" "북녘"


이 중에서 우리는 /N이 붙지 않은 두 번째 자료가 필요하니까 이렇게 쓰면 꼬리표를 떼어 낸 형태로 데이터를 정리할 수 있습니다.

str_match('북녘/N', '([가-힣]+)/N')[,2]
## [1] "북녘"


거의 다 왔습니다. 우리가 분석할 데이터를 담고 있는 m_df에 이를 똑같이 적용하면 그만입니다.


이 작업을 진행하시기 전에 mutate()라는 함수를 하나 더 배우셔야 편합니다. dplyr에 들어 있는 mutate()는 계산 결과를 담은 열을 추가하라는 뜻입니다. 


이게 무슨 뜻인지는 다음 코드 실행 결과를 보시면 이해가 가시리라 믿습니다. 그래도 부족하시다면 '최대한 친절하게 쓴 R로 데이터 뽑아내기(feat. dplyr)' 포스트가 도움이 될 수 있습니다.


일단 파이프와 mutate()를 활용해 아래처럼 코드를 쓰도록 하겠습니다.


이 코드는 m_df에서 자료를 가져와서 이 가운데 value 열에 들어 있는 자료에 str_match() 함수를 적용해 그 결과를 noun(영어로 명사)이라는 열로 만들어 내라는 뜻입니다. 

m_df %>%
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2])
## # A tibble: 610 x 3
##       L1 value        noun
##    <int> <fct>        <chr>
##  1     1 북녘/N       북녘
##  2     1 동포/N       동포
##  3     1 여러분/N+,/S 여러분
##  4     1 남녘/N+의/J  남녘
##  5     1 국민/N       국민
##  6     1 여러분/N+,/S 여러분
##  7     1 해외/N       해외
##  8     1 동포/N       동포
##  9     1 여러분/N+,/S 여러분
## 10     1 전쟁/N       전쟁
## # ... with 600 more rows


원래 m_df에는 L1과 value 열만 있었는데 noun이 생겼다는 사실을 알 수 있습니다.


이 부분에는 체언이 너무 많아서 눈에 잘 띄지 않지만 4행을 보시면 원래는 '남녘의'였는데 이 중에서 관형격 조사 '의'를 걸러내 '남녘'만 남았다는 사실을 알 수 있습니다.


여기서 주의해야 할 건 어떤 행에는 아예 체언이 없는 일도 있기 때문에 모든 행이 예쁘게 데이터를 담고 있는 건 아니라는 점입니다. 꼬리 부분만 확인해도 이를 알 수 있습니다.

m_df %>%
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>% tail()
## # A tibble: 6 x 3
##      L1 value                noun
##   <int> <fct>                <chr>
## 1    40 열매/N+가/J          열매
## 2    40 열리/P+고/E          <NA>
## 3    40 있/P+습니다/E        <NA>
## 4    40 ./S                  <NA>
## 5    41 감사/N+하/X+ㅂ니다/E 감사
## 6    41 ./S                  <NA>


<NA>는 'Not Applicable' 또는 'Not Available'이라는 의미로 계산 결과가 없다는 뜻입니다. 


이런 행은 아예 빼는 게 낫겠죠? 그럴 때는 na.omit() 함수를 쓰시면 됩니다. 영어 낱말 'omit'는 생략하다, 제거하다는 뜻입니다.

m_df %>% 
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>%
  na.omit %>% tail
## # A tibble: 6 x 3
##      L1 value                noun 
##   <int> <fct>                <chr>
## 1    40 가을/N+의/J          가을 
## 2    40 평양/N+에서/J        평양 
## 3    40 평화/N+와/J          평화 
## 4    40 번영/N+의/J          번영 
## 5    40 열매/N+가/J          열매 
## 6    41 감사/N+하/X+ㅂ니다/E 감사


<NA>가 들어 있던 행이 사라진 걸 알 수 있습니다. 이제 진짜 데이터 손질을 마쳤습니다.


정말 고생하셨습니다. 잠깐 물이라도 한 잔 하시면서 머리 좀 식히세요.



문 대통령은 어떤 낱말을 많이 썼을까?

대통령 등 주요 인물 연설이 끝나면 언론에서 '어떤 낱말을 몇 번 섰다'고 표현하는 걸 어렵지 않게 볼 수 있습니다. 이런 건 어떻게 구할까요? 


제일 쉬운 건 물론 직접 세는 겁니다.


그런데 이렇게 시간을 낭비하는 방식을 별로 추천하고 싶지는 않습니다. dplyr에는 숫자를 세어서 알려주는 count() 함수가 있으니까요.


위에서 썼던 코드를 na.omit() 부분까지 그대로 쓰고 count() 부분만 추가하면 이런 결과를 얻을 수 있습니다.

m_df %>%
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>%
  na.omit %>%
  count(noun, sort=TRUE)
## # A tibble: 238 x 2
##    noun       n
##    <chr>  <int>
##  1 우리      13
##  2 것        12
##  3 한반도     9
##  4 수         8
##  5 여러분     8
##  6 남         6
##  7 오늘       6
##  8 위원장     6
##  9 동포       5
## 10 비핵화     5
## # ... with 228 more rows


마지막 count() 부분을 조금 설명드리면 mutate() 함수로 만든 noun 열에 어떤 자료(낱말)가 제일 많은지 센 다음 이를 정렬해서(sort=TRUE) 보여달라는 뜻입니다. 


사소한 문제가 있다면 '것'이나 '수' 같은 의존명사는 큰 의미가 없는 데도 자주 등장한다는 점입니다.


그래서 텍스트 데이터를 처리할 때는 보통 두 글자 이상만 정리하는 방식으로 이 문제를 해결합니다.


그러려면 글자수를 따로 세어줘야 합니다. stringr에서는 str_length()가 바로 이 구실을 합니다.


'최대한 친절하게 쓴 R'가 몇 글자인지 궁금하다면 이렇게 치시면 됩니다.

str_length('최대한 친절하게 쓴 R')
## [1] 12


12 글자라는 결과를 납득하기 힘든 분도 계실 텐데 빈칸(공백)까지 세어 보시면 12 글자 맞습니다.


이번에도 m_df에 있는 각 행에 글자 숫자를 계산하는 과정이 필요하겠죠? 이렇게 쓰시면 됩니다.

m_df %>% 
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>%
  na.omit %>% 
  mutate(length=str_length(noun))
## # A tibble: 386 x 4
##       L1 value        noun   length
##    <int> <fct>        <chr>   <int>
##  1     1 북녘/N       북녘        2
##  2     1 동포/N       동포        2
##  3     1 여러분/N+,/S 여러분      3
##  4     1 남녘/N+의/J  남녘        2
##  5     1 국민/N       국민        2
##  6     1 여러분/N+,/S 여러분      3
##  7     1 해외/N       해외        2
##  8     1 동포/N       동포        2
##  9     1 여러분/N+,/S 여러분      3
## 10     1 전쟁/N       전쟁        2
## # ... with 376 more rows


이제는 length가 2이상인 것만 걸러내면 됩니다.


거르는 건 영어로 filtering(필터링). dplyr에서 행을 골라낼 때 쓰는 함수가 filter()인 이유입니다. filter()는 filter(열, 조건) 형태로 써서 원하는 행만 골라내도록 합니다.


이번에도 아래 예시를 보시면 이게 무슨 뜻인지 이해하시리라 믿지만 그래도 어딘가 이상하다면 '최대한 친절하게 쓴 R로 데이터 뽑아내기(feat. dplyr)' 포스트를 읽어보시는 게 좋습니다.


앞에서는 어떤 결과가 나오는지 보여드리려고 mutate()를 썼지만 사실 저 열이 꼭 필요한 건 아닙니다. 그냥 filter에서 조건 안에 저 내용을 넣어도 충분합니다.


그래서 코드를 이런 식으로 쓰도록 하겠습니다. 아예 많이 나온 낱말을 뽑아내는 것까지 한 번에.

m_df %>%
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>%
  na.omit %>%
  filter(str_length(noun)>=2) %>%
  count(noun, sort=TRUE)
## # A tibble: 206 x 2
##    noun       n
##    <chr>  <int>
##  1 우리      13
##  2 한반도     9
##  3 여러분     8
##  4 오늘       6
##  5 위원장     6
##  6 동포       5
##  7 비핵화     5
##  8 전쟁       5
##  9 겨레       4
## 10 국민       4
## # ... with 196 more rows


'남북 정상회담에 참가한 대통령이라면 이런 낱말을 많이 쓰지 않을까'하고 예상할 수 있는 그런 낱말이 실제로 많이 나왔습니다. 우리 분석이 틀리지 않았다는 뜻일 겁니다.


이걸로 정말 낱말구름을 그릴 수 있는 준비가 끝났습니다.



낱말구름을 그려보자

R는 사용자가 낱말구름을 그릴 수 있도록 wordcloud 패키지를 마련하고 있습니다. 그리고 이 패키지를 업데이트한 wordcloud2 패키지도 있습니다. 기왕이면 새 버전을 쓰는 게 낫겠죠?


패키지가 나왔으니 설치 및 로드 진행하고 계시죠?

install.packages('wordcloud2')
library('wordcloud2')


낱말구름을 그리는 함수 이름도 wordcloud2()입니다. 이 함수는 wordcloud2(데이터, 빈도)가 기본 형식입니다.


우리는 이미 어떤 낱말이 몇 번 나왔는지(빈도) 알려주는 데이터를 가지고 있으니까 그냥 곧바로 적용하면 됩니다. 이 코드를 실행하면 기본 인터넷 브라우저가 열리면서 아래 그림 같은 낱말구름을 표시합니다.

m_df %>% 
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>%
  na.omit %>% 
  filter(str_length(noun)>=2) %>% 
  count(noun, sort=TRUE) %>%
  wordcloud2()


이 낱말구름을 조금 손질하도록 하겠습니다.


먼저 낱말이 너무 많은 느낌이 드니까 두 번 이상 등장한 낱말만 고르도록 하겠습니다. 빈도를 나타내는 열은 n이니까 filter(n>=2)를 쓰면 됩니다.


또 글씨는 이 블로그 기본 글꼴인 'Noto Sans CJK KR Bold'를 쓰고 색깔도 하늘색(skyblue)으로 바꾸겠습니다.


글씨도 읽기 좋게 회전하지 말라고 명령하는 것까지 해보겠습니다.


이상을 종합하면 코드를 이렇게 쓸 수 있습니다.

m_df %>% 
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>%
  na.omit %>% 
  filter(str_length(noun)>=2) %>% 
  count(noun, sort=TRUE) %>%
  filter(n>=2) %>%
  wordcloud2(fontFamily='Noto Sans CJK KR Bold', color='skyblue', minRotation=0, maxRotation=0)


이런 식으로 속성에 변화를 주면 다양한 형태로 낱말구름을 그릴 수 있습니다.


이번 포스트는 낱말구름을 그리는 방법을 소개해 드리는 게 목적이고, 저는 제 미적 기준을 믿지 못하기 때문에 새로 낱말구름을 추가하지는 않겠습니다.


그래도 2% 부족하다는 생각에 좀 더 다양한 예시를 보고 싶으신 분은 이 페이지를 찾아 보시면 도움이 되리라고 생각합니다.


※ ggplot() 문법으로 워드클라우드를 그려보고 싶으신 분이라면 'ggplot 스타일로 워드클라우드 그리기(feat. ggwordcloud)' 포스트가 도움이 될 수 있습니다.



의미연결망도 그려보자

이제 마지막으로 의미연결망을 그릴 차례입니다. 의미연결망이라는 표현 자체는 낯선 분도 언론에서 아래 같은 그림을 보신 적은 있으실 겁니다.



이 그림은 제가 주간동에 의뢰로 지난해(2017년) 청와대 국민청원 및 제안 게시판을 분석해 정리한 의미연결망입니다.


(저는 스케치 형태로 제공했고 실제 그래픽 작업은 주간동아 그래픽 팀에서 맡았습니다.)


이런 그림을 그려 보면 어떤 텍스트에서 어떤 내용을 강조하고 있는지 파악할 수 있습니다.


예를 들어 이 그래프에서 '이명박'이라는 낱말은 '대통령'과 가까이에 있지만 '문재인'은 '대통령님'과 더 가까이에 있다는 걸 파악하는 게 가능합니다.


의미연결망은 문자열에 사회관계망 분석(SNA) 기법을 적용해 그립니다. 그래서 SNA 기본 지식이 부족하면 내용 이해에 어려움이 따르는 게 당연한 일입니다. 


그렇다고 포기하시라는 게 아니라 이해가 잘 가지 않으시면 가지 않으시는 대로 흘려보내셔도 된다는 뜻입니다. 대신 제가 약간 불친절해도 이해해 주시기 바랍니다.


혹시 이번 기회에 SNA에 대해 공부하고 싶으신 분은 '최대한 친절하게 쓴 R로 사회연결망 분석하기(feat. tidygraph, ggraph)' 포스트가 도움이 될 수도 있습니다.


(정말 길고 긴 포스트니까, 심지어 지금까지 이 포스트에 쓴 것보다 더 기니까 페이지를 옮겨 가기 전에 주의하세요!)


의미연결망을 그리려면 일단 자료를 좀 쳐낼 필요가 있습니다.


지금 우리 데이터에는 두 글자 이상만 뽑아도 200개가 넘는 낱말이 들어 있습니다. 200개가 넘는 점을 서로 잇는 선을 그리면 뭐가 뭔지 알아보기 쉽지 않습니다.


그런 이유로 제일 많이 등장한 낱말 15개만 고른 다음 작업을 진행하도록 하겠습니다.


꼭 15개를 고르셔야 하는 건 아닙니다. 이 숫자는 순전히 제가 제 마음대로 고른 숫자입니다. 필요와 희망에 따라서 보기 좋은(?) 숫자를 고르시면 됩니다.


이 15개는 이렇게 고르는 방법은 이렇습니다.


먼저 낱말 빈도를 알려주는 tibble을 하나 만듭니다. 그러면 이 tibble에서 맨 처음 15개가 바로 가장 많이 나온 낱말 15개가 됩니다.


그다음 원본 데이터하고 비교하는 과정을 거칩니다. 어떤 데이터가 이 15개 안에 들어 있는지 확인하는 것. 


이를 코드로 쓰면 이렇습니다. 먼저 m_count라는 변수에 가장 많이 나온 낱말 15개를 담아보도록 하겠습니다.

m_count <- m_df %>%
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>%
  na.omit %>%
  filter(str_length(noun)>=2) %>%
  count(noun, sort=TRUE) %>%
  head(15)
m_count
## # A tibble: 15 x 2
##    noun       n
##    <chr>  <int>
##  1 우리      13
##  2 한반도     9
##  3 여러분     8
##  4 오늘       6
##  5 위원장     6
##  6 동포       5
##  7 비핵화     5
##  8 전쟁       5
##  9 겨레       4
## 10 국민       4
## 11 김정은     4
## 12 서울       4
## 13 양국       4
## 14 평화       4
## 15 합의       4


코드에서 이해가 잘 가지 않는다는 부분 없으시죠? 앞에서 이미 했던 작업 결과에 맨 앞에 있는 15줄을 뽑아 달라는 뜻에서 head(15)만 추가한 거니까요. 


두 번째 작업은 더 쉽습니다. 계속 지켜봤던 녀석을 m_df2라는 변수에 넣는 것뿐이니까요.


단, 이제 정말 품사 꼬리표는 필요가 없으니까 아예 그 녀석까지 제외하도록 하겠습니다.

m_df2 <- m_df %>%
  mutate(noun=str_match(value, '([가-힣]+)/N')[,2]) %>%
  na.omit %>%
  filter(str_length(noun)>=2) %>%
  select(3, 1)
m_df2
## # A tibble: 316 x 2
##    noun      L1
##    <chr>  <int>
##  1 북녘       1
##  2 동포       1
##  3 여러분     1
##  4 남녘       1
##  5 국민       1
##  6 여러분     1
##  7 해외       1
##  8 동포       1
##  9 여러분     1
## 10 전쟁       1
## # ... with 306 more row


이제 이 가운데 noun에 있는 낱말이 m_count에서 같은 열에 있을 때만 골라내는 작업을 진행하면 됩니다.


이번에도 행을 뽑아 내니까 filter()를 써야겠죠?


R에는 어떤 값이 어떤 변수에 들어 있는지 여부를 알려주는 '%in%'이라는 연산자가 있습니다.


그래서 이런 식으로 쓰면 그만입니다.

m_df3 <- m_df2 %>%
  filter(noun %in% m_count$noun)
m_df3
## # A tibble: 85 x 2
##    noun      L1
##    <chr>  <int>
##  1 동포       1
##  2 여러분     1
##  3 국민       1
##  4 여러분     1
##  5 동포       1
##  6 여러분     1
##  7 전쟁       1
##  8 한반도     1
##  9 오늘       1
## 10 한반도     1
## # ... with 75 more rows


아, 여태 '$' 기호를 말씀드린 적이 없었네요. $는 데이터 프레임에서 특정한 열을 뽑아낼 때 쓰는 기호입니다. '데이터 프레임$열 이름' 형태로 쓰시면 됩니다.


m_count 변수에는 noun뿐만 아니라 'n' 열도 있기 때문에 알려준 겁니다. 


이제 데이터 정리를 마쳤으니 진짜 SNA를 진행할 차례입니다.


R에서 SNA에 제일 많이 쓰는 패키지는 igraph입니다. 패키지가 등장했으니 할 일은?

install.packages('igraph')
library('igraph')


igraph는 이름 그대로 (네트워크) 그래프를 다루는 패키지입니다. 그래서 m_df3에 담아둔 자료를 그래프 형태로 바꿔줘야 합니다.


이때 사용하는 함수 이름은 graph_from_data_frame()입니다. 무슨 의미인지 한 번에 아시겠죠?


mg라는 변수에 그래프를 넣고 어떻게 생겼는지 확인해 보겠습니다.

mg <- graph_from_data_frame(m_df3)
mg
## IGRAPH 6336ea9 DN-- 35 85 -- 
## + attr: name (v/c)
## + edges from 6336ea9 (vertex names):
##  [1] 동포  ->1  여러분->1  국민  ->1  여러분->1  동포  ->1  여러분->1 
##  [7] 전쟁  ->1  한반도->1  오늘  ->1  한반도->1  전쟁  ->1  합의  ->1 
## [13] 합의  ->3  전쟁  ->3  우리  ->3  한반도->3  우리  ->3  우리  ->3 
## [19] 전쟁  ->5  우리  ->5  국민  ->5  오늘  ->6  비핵화->8  합의  ->8 
## [25] 우리  ->11 겨레  ->11 한반도->11 비핵화->11 비핵화->11 우리  ->12
## [31] 동포  ->14 여러분->14 국민  ->14 여러분->14 한반도->14 합의  ->14
## [37] 양국  ->16 우리  ->17 우리  ->19 평화  ->21 우리  ->21 겨레  ->21
## [43] 오늘  ->21 우리  ->21 겨레  ->21 평화  ->21 우리  ->21 우리  ->21
## + ... omitted several edges


각 낱말이 특정한 번호를 향하고 있습니다. 이 번호는 문 대통령 연설 가운데 몇 번째 문장이었는지를 나타냅니다.


텍스트를 이렇게 분석하는 건 같은 문장에 들어 있는 낱말끼리 관계가 더욱 깊다고 간주하기 때문입니다.


문제는 여기서 낱말과 문장 번호는 서로 성질이 다른 변수라는 점입니다.


이런 식으로 정리한 그래프를 '이분(Bipartite) 그래프'라고 부릅니다. 이분 그래프는 다음과 같은 과정을 거쳐서 분해할 수 있습니다.

V(mg)$type <- bipartite_mapping(mg)$type
mm <- as_incidence_matrix(mg) %*% t(as_incidence_matrix(mg))
diag(mm) <- 0
mg <- graph_from_adjacency_matrix(mm)


자세한 내용은 '최대한 친절하게 쓴 R로 사회연결망 분석하기(feat. tidygraph, ggraph)'를 참고해야 한다는 것 알고 계시죠?


자료 정리가 끝났으니까 일단 R에 기본적으로 들어 있는 시각화 함수 plot()를 써서 의미연결망을 그려보면 이렇게 생겼다는 걸 알 수 있습니다.

plot(mg)


성공입니다. 여러분은 (아마도 대부분) 인생 처음으로 낱말 의미연결망 그래프를 그리셨습니다. 이제 여러분은 텍스트 자료만 있으면 낱말구름과 의미연결망을 모두 그릴 줄 아는 능력자이십니다.



깔끔하게 의미연결망을 그려보자

tidyverse 스타일로 텍스트 데이터를 분석하게 해주는 tidytext 패키지가 있는데 SNA라고 달라야 하나요?


눈치가 빠르신 분은 이미 위에서 두 번 언급한 포스트 제목에 tidygraph와 ggraph라는 녀석이 들어 있었다고 짐작하고 계실 겁니다.


tidygraph는 igraph처럼 네트워크 그래프를 분석할 수 있도록 돕는 도구고, ggraph는 그래프를 시각화하는 패키지입니다.


패키지가 나왔으니 제일 먼저 해야 할 일은? 네, 이제 따로 말씀 드리지 않아도 아실 겁니다.

install.packages('tidygraph')
install.packages('ggraph')
library('tidygraph')
library('ggraph')


tidygraph는 tbl_graph라는 형태로 그래프 데이터를 처리합니다. 데이터 프레임이나 행렬 또는 igraph 데이터 모두 as_tbl_graph()라는 함수 안에 넣으면 이 형태로 바꿀 수 있습니다.


우리는 이미 igraph에서 처리한 mg 변수가 있기 때문에 걱정할 필요가 없습니다.


ggraph는 ggplot2 스타일로 사회관계망을 그릴 수 있도록 만듭니다.


ggplot2는 tidyverse 생태계뿐 아니라 아마 R 생태계 전체를 통틀어서도 가장 유명한 시각화 패키지라고 할 수 있습니다.


ggplot는 기본적으로 데이터에 기하학적(geometric) 요소를 더하는 방식으로 그래프를 완성합니다. ggraph 같은 문법 스타일을 따라한다고 생각하시면 쉽습니다.


이번에도 ggplot2에 대해 조금 더 알고 싶으신 분은 '최대한 친절하게 쓴 R로 그래프 그리기(feat. ggplot2)'에서 도움을 받으실 수 있습니다.


정리하면 제일 먼저 해야 할 일은 mg를 as_tbl_graph()에 넣는 것이고 이어서 ggraph가 나오면 tidyverse 스타일로 위에서 본 그래프를 다시 그릴 수 있게 되는 겁니다. 그렇죠?


이를 R 코드로 표현하면 이렇게 쓸 수 있습니다.

mg %>% as_tbl_graph() %>%
  ggraph() +
  geom_edge_link(aes(start_cap = label_rect(node1.name), end_cap = label_rect(node2.name))) +
  geom_node_text(aes(label=name))


이 그래프를 보고 처음 한 생각은 '15개도 너무 많았다'였습니다.


그래프에 별다른 미적 효과를 주지 않아서 좀 허전해 보이기도 하지만 이 정도면 어떤 낱말이 다른 낱말과 어떤 관계를 맺고 있는지 파악하는 데는 큰 어려움이 없을 겁니다.


ggraph 코드를 조금 더 말씀드리면 geom_edge_link는 이 그래프에서 점과 점을 어떻게 이어야 할지 정의하고 있습니다. 기하학적으로(geom) 점과 점을 이어서 모서리를 만드는데(edge) 그 형태를 선(link)으로 하라는 게 이 함수 뜻입니다.


이 함수 안에 들어 있는 각종 속성은 선과 선을 그을 때 점(node) 이름이 들어갈 정도로 여백을 주라는 뜻입니다.


geom_node_text는 텍스트로 점(node)을 잡으라는 뜻입니다.


ase는 에스테틱할 때 '미적(aesthetic)'을 줄인 말로 ggplot2 계열에서는 데이터가 달라지면 다른 값을 반영하라는 뜻입니다. 여기서는 노드마다 텍스트 이름이 달라지기 때문에 이런 코드를 썼습니다.


그런데 낱말과 낱말 사이 관계를 규정하는 방식이 이렇게 같은 낱말이 등장한 문장 사이를 연결하는 것밖에 없을까요?


물론 아닙니다. 그냥 낱말 다음에 어떤 낱말이 나오는지를 곧바로 살펴봐도 됩니다. 마지막으로 이런 작업은 어떻게 하는지 살펴보겠습니다.



바이그램, 안녕!

(제 학부 전공인) 언어학에서는 어떤 낱말과 낱말이 연속해 등장할 때 n그램이라는 표현을 씁니다. 두 낱말이 연속해서 등장하는 건 바이그램(bigram)입니다.


이번 도전 과제가 바로 바이그램 연결망 그리기입니다.


바이그램 연결망을 그리려면 일단 바이그램부터 찾아봐야할 겁니다. 한번 다음처럼 코드를 입력해 보겠습니다.

m_df2 %>%
  select(noun) %>%
  mutate(lead=lead(noun))
## # A tibble: 316 x 2
##    noun   lead
##    <chr>  <chr>
##  1 북녘   동포
##  2 동포   여러분
##  3 여러분 남녘
##  4 남녘   국민
##  5 국민   여러분
##  6 여러분 해외
##  7 해외   동포
##  8 동포   여러분
##  9 여러분 전쟁
## 10 전쟁   한반도
## # ... with 306 more rows


이 코드는 무슨 뜻일까요? m_df2에서 명사가 들어 있는 noun 열을 선택한 다음 lead()라는 함수를 적용해 lead라는 열을 새로 만들어 붙이라는 뜻입니다.


동포는 원래 noun에서 두 번째 행에 있던 값인데 lead에서는 맨 첫 행으로 자리를 바꿨습니다. 나머지도 한 칸 씩 위로 자리를 옮겼다는 걸 알 수 있습니다.


dplyr 패키지에 있는 lead() 함수는 이렇게 바로 아래 행에서 자료를 가져오라는 뜻입니다. 참고로 lag() 함수는 반대로 위에서 아래로 자료를 가져 옵니다.


이렇게 자료를 정리하면 '북녘 동포', '동포 여러분'처럼 바이그램이 눈에 들어옵니다. 이 두 낱말 사이에 선을 그으면 바이그램 의미연결망이 나올 겁니다.


문제는 이러면 너무 많다는 것. 일단 바이그램이 316개 있다고 나오는데 이 중에는 똑같은 표현도 있을 거고 - '동포 여러분'을 한 번만 쓰지는 않았을 테니까요 - 우리는 체언과 체언만 연결했기 때문에 중간에 있는 낱말을 건너 뛰어서 같은 낱말만 남아 있는 케이스도 있을 겁니다. 


그런 이유로 정제 과정을 거쳐야 합니다. 이번에도 많이 나온 연결 고리만 정리하는 걸로 해보겠습니다.


그러려면 일단 어떤 바이그램이 많이 나왔는지 알아야겠죠? 많이 나온 낱말을 정리할 때랑 같은 방법을 쓰면 됩니다.


단, 지금은 낱말 두 개가 따로 따로 있기 때문에 이를 합칠 필요가 있습니다. 이때 쓰는 함수는 unite()입니다. 

m_df2 %>%
  na.omit() %>%
  select(noun) %>%
  mutate(lead=lead(noun)) %>%
  unite(bigram, c(noun, lead), sep=' ') 
## # A tibble: 316 x 1
##    bigram
##    <chr>
##  1 북녘 동포
##  2 동포 여러분
##  3 여러분 남녘
##  4 남녘 국민
##  5 국민 여러분
##  6 여러분 해외
##  7 해외 동포
##  8 동포 여러분
##  9 여러분 전쟁
## 10 전쟁 한반도
## # ... with 306 more rows


코드 끝 부분에 들어 있는 "sep=' '"는 열과 열을 연결할 때 둘 사이를 공백으로 구분하라는 뜻입니다.


어떤 바이그램이 많이 등장하는지는 위에서 살펴 본 count()로 확인할 수 있습니다.

m_df2 %>%
  na.omit() %>%
  select(noun) %>%
  mutate(lead=lead(noun)) %>%
  unite(bigram, c(noun, lead), sep=' ') %>% 
  count(bigram, sort=TRUE)
## # A tibble: 286 x 2
##    bigram            n
##     <chr>          <int>
##  1 동포 여러분       5
##  2 김정은 위원장     4
##  3 국민 여러분       3
##  4 남녘 국민         3
##  5 북녘 동포         3
##  6 여러분 남녘       3
##  7 위원장 서울       3
##  8 평화 번영         3
##  9 겨레 마음         2
## 10 서울 방문         2
## # ... with 276 more rows


조금 더 확인해 보면 19번까지 두 번 등장한 바이그램이 차지하고 있습니다. 그러면 맨 위에서 19개만 뽑으면 되겠죠?


그리고 다시 bigram을 낱말 두 개로 분리하겠습니다. 이 때는 separate() 함수를 쓰시면 됩니다.


이 결과를 bigram_df에 넣고 확인해보겠습니다.

bigram_df <- m_df2 %>%
  na.omit() %>%
  select(noun) %>%
  mutate(lead=lead(noun)) %>%
  unite(bigram, c(noun, lead), sep=" ") %>%
  count(bigram, sort=TRUE) %>%
  head(19) %>%
  separate(bigram, c('word1', 'word2'), sep=' ')
bigram_df
## # A tibble: 19 x 3
##    word1  word2      n
##    <chr>  <chr>  <int>
##  1 동포   여러분     5
##  2 김정은 위원장     4
##  3 국민   여러분     3
##  4 남녘   국민       3
##  5 북녘   동포       3
##  6 여러분 남녘       3
##  7 위원장 서울       3
##  8 평화   번영       3
##  9 겨레   마음       2
## 10 서울   방문       2
## 11 여러분 해외       2
## 12 오늘   한반도     2
## 13 완전한 비핵화     2
## 14 우리   겨레       2
## 15 우리   우리       2
## 16 위원장 오늘       2
## 17 전쟁   한반도     2
## 18 합의   사항       2
## 19 해외   동포       2


15행에 '우리 우리'가 있습니다. 이 행을 빼주는 게 모양새가 더 나을 겁니다. 이건 이렇게 간단하게 뺄 수 있습니다.

bigram_df <- bigram_df[-15,]


이제 그래프를 그릴 준비를 모두 마쳤습니다. 이번에도 위에서 쓴 ggraph 코드를 그냥 뒤에다가 붙이기만 하면 됩니다.

bigram_df %>%
  as_tbl_graph %>%
  ggraph() +
  geom_edge_link(aes(start_cap = label_rect(node1.name), end_cap = label_rect(node2.name))) +
  geom_node_text(aes(label=name))


깔끔하게 나왔습니다. 굳이 옥에 티를 찾자면 '완전한'은 체언이 아닌데 '완전한 비핵화'에 살아 남아 있습니다.


한국어 특성상 실제 데이터를 처리하다 보면 이렇게 100% 완벽하지는 않은 결과가 나올 때가 많습니다.


그럴 때는 소위 '노가다'를 하는 게 가장 빠른 해결책입니다. 이를 테면 이번에는 '완전한'을 그냥 '완전'으로 바꾸면 그만입니다.



더 깔끔한 텍스트 분석을 기대하면서

물론 텍스트 마이닝 기법에 낱말구름과 의미연결망 분석만 있는 건 아닙니다.


이 글은 텍스트를 분석해 보고 싶으신 초보자 분께 도움을 드리려는 목적이라 제일 유명한 두 기법을 소개했을 뿐입니다.


이 문장을 읽고 계시다면 'R에서 텍스트를 이렇게 다루는구나'하고 감은 잡으셨으리라 믿습니다.


긴 글 읽어주시느라 고생하셨고, 이 포스트를 읽으시다가 잘못 됐거나 이해가 잘 가지 않는 부분이 있으시다면 언제든지 알려주세요. 함께 공부하면서 바로잡도록 하겠습니다.


다시 한 번 긴 글 읽어주셔서 감사합니다.


텍스트 마이닝 공부를 시작하시는 분들께 이 포스트가 도움이 되었기를 바랍니다.


댓글,

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