Back to Posts

트위터로 바라본 지카바이러스 사태

Posted in Data Mining Image Source | Felipe Dana/AP

들어가며

내가 저널리즘과 같은 커뮤니케이션에 관심을 갖게 된 것은 다른 사람들에게 그들이 알지 못하는, 그렇지만 살아가는데 꼭 필요한 것들을 알려주는 것이 중요하다는 생각이 든 때부터이다. 지금은 정치, 사회, 국제와 관련된 이슈들에 대해서도 관심을 갖고 있지만, 제일 먼저 관심을 가졌던 이슈는 과학이었다. 과학에 관심이 갖던 이유는 과학을 전공하면서 과학 발전에 비해 사회적 책임의식이 성숙하지 못하는 현실에 대한 회의감 때문이었다. 이를 시작으로 과학기술의 위험과 그로 인해 발생할 수 있는 사회적인 파장을 많은 사람에게 알리고 싶어서 과학커뮤니케이션학이라는 전공을 설계하고 이수하였다.

과학커뮤니이션학을 공부하며 품었던 목표 중 하나는 기존의 커뮤니케이션 연구방법론이 아닌 다른 방법론으로 커뮤니케이션을 분석해보는 것이었다. 과학과 공학 같이 내가 잘할 수 있는 것을 가지고 관심 분야의 연구를 해보고 싶었던 것이다. 그렇게 해서 뉴스에 대한 텍스트마이닝이라는 아이디어가 떠올랐다. 기존의 뉴스 수용자들에 대한 커뮤니케이션학 연구가 지나치게 통제된 환경에서 실험처럼 진행되고 있는 문제점을 해결할 수 있는 하나의 방법이 될 수 있겠다고 생각했다.

이렇게 시작된 이 프로젝트의 목표는 지카바이러스 사태에 대해서 실제로 사람들이 어떠한 뉴스를 접하고 이야기하는지를 살펴보는 것이다. 이를 위해 정보원을 기반으로(resource-related) 소통이 일어나며 텍스트 데이터를 얻기 쉬운 트위터를 연구 대상으로 선정하였다. 분석을 통해서 살펴보려는 효과는 낙관적 편향(optimistic bias)1으로, 위험커뮤니케이션에서 자주 다루는 주제다. 또한 사람들이 이야기하는 주제가 어떤 감정을 가지고 있는지를 분석해서 보다 입체적으로 사람들의 이야기에 대한 반응을 살펴보고 싶었다.

분석 환경

Python을 사용하였으며 8GB RAM이 달린 Macbook Pro Retina 13인치 모델로 분석을 진행했다.

트위터 크롤링2

트위터를 Python으로 크롤링하기 위해 사용한 패키지는 twitter이다. 먼저 Twitter의 API에 접속해야한다. 이를 위해서는 Twitter Developers에 접속해서 app을 생성한 뒤, CONUMER_KEY, CONUMER_SECRET, OAUTH_TOKEN , OAUTH_TOKEN_SECRET을 받아서 입력하면 된다.

  • 예시 코드
import twitter
import json
import time

CONSUMER_KEY ='MY CONSUMER KEY'
CONSUMER_SECRET ='MY CONSUMER SECRET'
OAUTH_TOKEN = 'MY TOKEN'
OAUTH_TOKEN_SECRET = 'MY TOKEN SECRET'

auth = twitter.oauth.OAuth(OAUTH_TOKEN, OAUTH_TOKEN_SECRET, CONSUMER_KEY, CONSUMER_SECRET)

twitter_api = twitter.Twitter(auth=auth)

여기서 사용한 API는 Search API로 검색어를 입력하면 그 단어를 가지고 있는 트윗을 크롤링해주는 API이다. 9일 전 까지의 트윗만 검색이 가능하며 관련성이 높고 최신의 트윗을 먼저 검색한다.

  • 지카바이러스가 미국과 같은 영어권 국가에서 많이 언급되고 있음을 감안하여 검색어는 zika, zikavirus를 사용하였고, 같은 이름을 가진 해쉬태그(#)도 검색하였다.
  • 영어로 텍스트마이닝을 할 것이기 때문에 검색된 트윗 중에서 영어로 된 것만 추출하였다.
  • 한번에 검색하는 트윗의 개수를 대략 1만 개 정도로 제한하여 검색하였다.
  • 트윗의 데이터 형식은 JSON이다.

이렇게 7월 2일에서 7월 19일 동안 모은 트윗은 316,466개3이다.

q = ['zika', 'zikavirus', '#zika', '#zikavirus']
count = 10000

for i in range(4):
	search_results = twitter_api.search.tweets(q=q[i], count=count)
	statuses = search_results['statuses']

	# search 
	for _ in range(1000):
		print "Searched statuses :", len(statuses)
		try:
			next_results = search_results['search_metadata']['next_results']

		# no more results when next_results doesn't exist
		except KeyError, e: 
			break

		#create a dictionary from next_results, which has the following for:
		kwrags = dict([kv.split('=') for kv in next_results[1:].split("&")])

		search_results = twitter_api.search.tweets(**kwrags)
		statuses += search_results['statuses']

# filtering English tweets
en_statuses = [s for s in statuses if s["lang"]=="en"]

# saving tweets in json format
with open('zika_tweet.json', 'w') as f:
		json.dump(en_statuses, f, indent=1)

수집된 트윗 한 개의 데이터 형태는 다음과 같다.

{
  "contributors": null, 
  "truncated": false, 
  "text": "Zika Can Spread Sexually From Women to Men https://t.co/mB81ZL3rlQ", 
  "is_quote_status": false, 
  "in_reply_to_status_id": null, 
  "id": 754484869430779904, 
  "favorite_count": 0, 
  "source": "<a href=\"http://twitter.com/download/iphone\" rel=\"nofollow\">Twitter for iPhone</a>", 
  "retweeted": false, 
  "coordinates": null, 
  "entities": {
   "symbols": [], 
   "user_mentions": [], 
   "hashtags": [], 
   "urls": [
    {
     "url": "https://t.co/mB81ZL3rlQ", 
     "indices": [
      43, 
      66
     ], 
     "expanded_url": "http://time.com/4407861/zika-spread-sexually-women-men-std/", 
     "display_url": "time.com/4407861/zika-s\u2026"
    }
   ]
  }, 
  "in_reply_to_screen_name": null, 
  "id_str": "754484869430779904", 
  "retweet_count": 0, 
  "in_reply_to_user_id": null, 
  "favorited": false, 
  "user": {
   "follow_request_sent": false, 
   "has_extended_profile": false, 
   "profile_use_background_image": true, 
   "id": 416487367, 
   "verified": false, 
   "profile_text_color": "892DD8", 
   "profile_image_url_https": "https://pbs.twimg.com/profile_images/1647204157/houdinis-skeptical-advice_1_normal.jpg", 
   "profile_sidebar_fill_color": "1D1D1D", 
   "is_translator": false, 
   "entities": {
    "url": {
     "urls": [
      {
       "url": "http://t.co/507OzGJGdR", 
       "indices": [
        0, 
        22
       ], 
       "expanded_url": "http://ahuramazdah.wordpress.com", 
       "display_url": "ahuramazdah.wordpress.com"
      }
     ]
    }, 
    "description": {
     "urls": []
    }
   }, 
   "followers_count": 111, 
   "protected": false, 
   "location": "Canc\u00fan, Quintana Roo, M\u00e9xico", 
   "default_profile_image": false, 
   "id_str": "416487367", 
   "lang": "es", 
   "utc_offset": -18000, 
   "statuses_count": 5953, 
   "description": "Terr\u00edcola, esc\u00e9ptico, agn\u00f3stico, cient\u00edfico, c\u00e1ustico, ir\u00f3nico, sarc\u00e1stico, estoc\u00e1stico y otros calificativos esdr\u00fajulos.", 
   "friends_count": 116, 
   "profile_background_image_url_https": "https://pbs.twimg.com/profile_background_images/535630684/xac2ce3003660376d7ed0eb16df4026e.jpg", 
   "profile_link_color": "E35716", 
   "profile_image_url": "http://pbs.twimg.com/profile_images/1647204157/houdinis-skeptical-advice_1_normal.jpg", 
   "notifications": false, 
   "geo_enabled": true, 
   "profile_background_color": "8C8B91", 
   "profile_banner_url": "https://pbs.twimg.com/profile_banners/416487367/1392302971", 
   "profile_background_image_url": "http://pbs.twimg.com/profile_background_images/535630684/xac2ce3003660376d7ed0eb16df4026e.jpg", 
   "name": "Keith Coors", 
   "is_translation_enabled": false, 
   "profile_background_tile": false, 
   "favourites_count": 232, 
   "screen_name": "Ahuramazddah", 
   "url": "http://t.co/507OzGJGdR", 
   "created_at": "Sat Nov 19 19:18:19 +0000 2011", 
   "contributors_enabled": false, 
   "time_zone": "Mexico City", 
   "profile_sidebar_border_color": "FFFFFF", 
   "default_profile": false, 
   "following": false, 
   "listed_count": 16
  }, 
  "geo": null, 
  "in_reply_to_user_id_str": null, 
  "possibly_sensitive": false, 
  "lang": "en", 
  "created_at": "Sun Jul 17 01:16:25 +0000 2016", 
  "in_reply_to_status_id_str": null, 
  "place": {
   "country_code": "MX", 
   "url": "https://api.twitter.com/1.1/geo/id/6eb95eddb81a6b4b.json", 
   "country": "Mexico", 
   "place_type": "city", 
   "bounding_box": {
    "type": "Polygon", 
    "coordinates": [
     [
      [
       -87.322724, 
       20.734206
      ], 
      [
       -86.740605, 
       20.734206
      ], 
      [
       -86.740605, 
       21.363232
      ], 
      [
       -87.322724, 
       21.363232
      ]
     ]
    ]
   }, 
   "contained_within": [], 
   "full_name": "Benito Ju\u00e1rez, Quintana Roo", 
   "attributes": {}, 
   "id": "6eb95eddb81a6b4b", 
   "name": "Benito Ju\u00e1rez"
  }, 
  "metadata": {
   "iso_language_code": "en", 
   "result_type": "recent"
  }
 }, 
 {
  "contributors": null, 
  "truncated": false, 
  "text": "Golfers skipping Olympics beacuse of cash, not Zika: head of Rio Olympics https://t.co/nxiXHbwQCx https://t.co/GZ30Tulwsh #Olympic2016", 
  "is_quote_status": false, 
  "in_reply_to_status_id": null, 
  "id": 754484861830848512, 
  "favorite_count": 0, 
  "source": "<a href=\"http://ifttt.com\" rel=\"nofollow\">IFTTT</a>", 
  "retweeted": false, 
  "coordinates": null, 
  "entities": {
   "symbols": [], 
   "user_mentions": [], 
   "hashtags": [
    {
     "indices": [
      122, 
      134
     ], 
     "text": "Olympic2016"
    }
   ], 
   "urls": [
    {
     "url": "https://t.co/nxiXHbwQCx", 
     "indices": [
      74, 
      97
     ], 
     "expanded_url": "http://olympics.cbc.ca/news/article/golfers-skipping-olympics-beacuse-cash-not-zika-head-rio-olympics.html", 
     "display_url": "olympics.cbc.ca/news/article/g\u2026"
    }
   ], 
   "media": [
    {
     "source_user_id": 1060751816, 
     "source_status_id_str": "754482407487700992", 
     "expanded_url": "http://twitter.com/CBCOlympics/status/754482407487700992/photo/1", 
     "display_url": "pic.twitter.com/GZ30Tulwsh", 
     "url": "https://t.co/GZ30Tulwsh", 
     "media_url_https": "https://pbs.twimg.com/media/Cnh1rnhXgAAM6VS.jpg", 
     "source_user_id_str": "1060751816", 
     "source_status_id": 754482407487700992, 
     "id_str": "754482329788317696", 
     "sizes": {
      "large": {
       "h": 664, 
       "w": 1180, 
       "resize": "fit"
      }, 
      "small": {
       "h": 383, 
       "w": 680, 
       "resize": "fit"
      }, 
      "medium": {
       "h": 664, 
       "w": 1180, 
       "resize": "fit"
      }, 
      "thumb": {
       "h": 150, 
       "w": 150, 
       "resize": "crop"
      }
     }, 
     "indices": [
      98, 
      121
     ], 
     "type": "photo", 
     "id": 754482329788317696, 
     "media_url": "http://pbs.twimg.com/media/Cnh1rnhXgAAM6VS.jpg"
    }
   ]
  }, 
  "in_reply_to_screen_name": null, 
  "id_str": "754484861830848512", 
  "retweet_count": 0, 
  "in_reply_to_user_id": null, 
  "favorited": false, 
  "user": {
   "follow_request_sent": false, 
   "has_extended_profile": false, 
   "profile_use_background_image": true, 
   "id": 2292526496, 
   "verified": false, 
   "profile_text_color": "333333", 
   "profile_image_url_https": "https://pbs.twimg.com/profile_images/733194168621752320/aKWlzVzO_normal.jpg", 
   "profile_sidebar_fill_color": "DDEEF6", 
   "is_translator": false, 
   "entities": {
    "description": {
     "urls": []
    }
   }, 
   "followers_count": 1198, 
   "protected": false, 
   "location": "", 
   "default_profile_image": false, 
   "id_str": "2292526496", 
   "lang": "nl", 
   "utc_offset": null, 
   "statuses_count": 2247, 
   "description": "#TeamNL #Olympic sports prof #olympics #DutchRio2016 Please share your news with us. #DutchOlympics2016 we'll share your news #rio2016 #olympic2016 #HHH2016", 
   "friends_count": 3322, 
   "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png", 
   "profile_link_color": "0084B4", 
   "profile_image_url": "http://pbs.twimg.com/profile_images/733194168621752320/aKWlzVzO_normal.jpg", 
   "notifications": false, 
   "geo_enabled": false, 
   "profile_background_color": "C0DEED", 
   "profile_banner_url": "https://pbs.twimg.com/profile_banners/2292526496/1463558005", 
   "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png", 
   "name": "Dutch Olympic news", 
   "is_translation_enabled": false, 
   "profile_background_tile": false, 
   "favourites_count": 710, 
   "screen_name": "dutch_travel", 
   "url": null, 
   "created_at": "Wed Jan 15 10:51:40 +0000 2014", 
   "contributors_enabled": false, 
   "time_zone": null, 
   "profile_sidebar_border_color": "C0DEED", 
   "default_profile": true, 
   "following": false, 
   "listed_count": 34
  }, 
  "geo": null, 
  "in_reply_to_user_id_str": null, 
  "possibly_sensitive": false, 
  "lang": "en", 
  "created_at": "Sun Jul 17 01:16:23 +0000 2016", 
  "in_reply_to_status_id_str": null, 
  "place": null, 
  "metadata": {
   "iso_language_code": "en", 
   "result_type": "recent"
  }
 } 

데이터 전처리

수집한 트윗의 텍스트를 뽑아서 텍스트마이닝을 하기 위해서는 먼저 JSON 형식의 데이터에서 텍스트만을 추출했다. 이는 위의 데이터에서 text entity로 주어져있다.

그렇게 추출한 데이터를 분석에 사용하기 위해서는 몇 가지 전처리가 더 필요하다.

  • UTF-8로 인코딩이 되지 않는특수 문자들을 제거해준다(\u2013, \u2014, \u2019, \u2026).
  • 문자가 아닌 이모티콘을 정규 표현식 각각의 유니코드를 찾아서 제거한다.
  • 트윗의 내용이 아닌 리트윗(RT), 사용자이름(@NAME), 링크(http), 특수문자(\u)를 제거한다.
  • 해쉬태그(#)의 경우 사용자가 전달하고 싶은 내용이 함축적으로 담긴 단어라고 판단, 분석이 가능하도록 #만 제거한다.
  • 몇몇 특수문자의 인코딩을 변경해준다.
import json
import re

with open('zika_tweets.json', 'r') as r:
	statuses=json.load(r)

## extract 'text' entity
status_texts = [status["text"].replace(u'"',"").replace(u"\u2014","").replace(u"\u2026", "").replace(u"\u2019", "'").replace(u"\u2013", "").replace("&amp;", "") for status in statuses]

# find emoji list using re
emoji = re.compile(u'('
                   u'\ud83c[\udf00-\udfff]|'
                   u'\ud83d[\udc00-\ude4f\ude80-\udeff]|'
                   u'[\u2600-\u26FF\u2700-\u27BF])+',
                   re.UNICODE)

clean_status_text = []

## clean words in tweet
for i in status_texts:
	raw_text = i.lower()
	split_text = raw_text.split()
	rt_text = [i for i in split_text if "rt" not in i]
	link_text = [i for i in rt_text if "http" not in i]
	unicode_text = [i for i in link_text if "\u" not in i]
	user_text = [i for i in unicode_text if "@" not in i]
	hashtag_text = [i.replace('#','') if '#' in i else i for i in user_text]
	emoji_text = [emoji.sub('',i) for i in hashtag_text]
	encode_text = [i.replace(u'\ufe0f','-').replace(u'\u00ab','').replace(u'\u00bb','').replace(u'\u201d','').replace(u'\u201c','').encode('utf-7') for i in emoji_text]
	cleaned_text = [" ".join(encode_text)]
	clean_status_text = clean_status_text + cleaned_text

with open('tweets_text.txt','w') as t:
    json.dump(clean_status_text[:], t, indent=1)

앞서 전처리가 분석할 수 없는 문자들에 대한 전처리였다면, 이 과정은 자연어 처리와 관련된 전처리다. 이때 주로 사용되는 패키지는 NLTK(Natural Language ToolKit)으로, 자연어 처리에 가장 많이 사용되는 Python 패키지이다.

  • Tokenization: LDA를 사용하기 위해서는 텍스트 데이터를 말뭉치(bag-of-words)로 바꾸어야한다. 이것은 트윗을 단어로 쪼개어서 list의 형식으로 묶어둔 형태다.
  • stop words : 의미를 가지고 있지 않은 조동사, 전치사 등의 단어를 제거한다.
  • Stemming : 단어를 기본형으로 바꾸어준다. 대표적으로 복수형은 단수형으로, 과거형은 현재형으로 바꾸는 과정이 있다.
import json

with open('tweets_text.txt', 'r') as r:
    clean_status_text=json.load(r)

# tokenization
from nltk.tokenize import RegexpTokenizer
tokenizer = RegexpTokenizer(r'\w+')

# remove stop-words
from nltk.corpus import stopwords
en_stop = stopwords.words('english')

# stemming 
from nltk.stem.porter import PorterStemmer
p_stemmer = PorterStemmer()

texts=[]
count=0
for i in clean_status_text:
    tokens = tokenizer.tokenize(i)
    stopped_tokens = [i for i in tokens if not i in en_stop+['zika','virus']]
    stemmed_tokens = [p_stemmer.stem(i) for i in stopped_tokens]
    texts.append(stemmed_tokens)
    count += len(stemmed_tokens)

with open('cleaned_tweet_texts.txt', 'a') as f:
    json.dump(texts, f, indent=1)

LDA : 트위터에서 많이 언급되는 주제는?

앞의 전처리 과정을 거쳐 본격적으로 텍스트마이닝을 실시한다. 사람들이 어떤 주제로 이야기를 하고 있는지를 추출하는 텍스트마이닝 기법을 토픽 모델링(topic modelling)이라고 하는데, 가장 대표적인 방법이 LDA(Latent Dirchlet Allocation)이다.

LDA는 Generative Model의 일종으로, 주어진 문서를 가지고 이 문서 더미들을 만들어낼 수 있을 토픽과 그 토픽에 포함되는 단어들의 확률분포를 만들어내는 모델이다. 처음에 모델을 학습시킬 때 사람이 토픽의 개수와 사전 확률 분포 α(하나의 문서 내에서 토픽의 확률분포)를 결정해주어야한다.

LDA Plate Notation LDA의 Plate Notation. α는 각 문서 내 토픽들의 Dirichlet 사전확률을 나타내며, β는 토픽 당 단어 분포의 Dirichlet 사전확률로 모델링 전에 상수로 정하는 값이다. α를 통해 추정하는 θ(d)는 문서 d 내 토픽에 대한 다항 분포이고, 여기서 문서 d 에 대한 토큰의 집합인 Nd에 대해 토픽 z를 추출한다. β를 통해 추정하는 Φ(z) 는 전체 토픽 T 중 토픽 z의 단어 분포를 나타낸다. 이렇게 문서 내 토픽 분포를 통해 토픽을, 토픽 내 단어 분포를 통해 단어를 선택함으로써 최종적으로 단어 w를 추정한다. LDA는 w와 α, β에 대해 θ와 z를 추정하는 과정이다4.

Python에서는 gensim 패키지를 통해서 LDA를 수행할 수 있다. LDA는 빈도를 기반으로 하고 있기 때문에 각 문서별 단어가 몇 번 나오는지를 알아야하며, 따라서 이에 대한 사전(coupus)가 필요하다. gensim은 이런 사전을 만드는 기능도 제공하고 있다. 또한 계산의 편의성을 위하여 각 단어를 숫자에 대응하는 사전(dictionary)도 쉽게 만들 수 있다.

몇 번의 반복을 거쳐 토픽의 개수는 20개로 설정했으며, α의 값은 0.00001로 설정5했다.

# construct document-term matrix
from gensim import corpora, models

dictionary = corpora.Dictionary(texts)
dictionary.save('zika_tweet_lda.dictionary')

corpus = [dictionary.doc2bow(text) for text in texts]

## Building a new LDA Model
ldamodel = models.LdaModel(corpus, num_topics=20, id2word = dictionary, passes=20, iterations=100, alpha=0.00001)
print ldamodel.print_topics(num_topics=20, num_words=20)
ldamodel.save('zika_tweet_lda.model')

분석 결과

Topic Label words
1 politics block, help, control, look, gop, sciencemagazine, read, bill
2 science research, test, track, trun, vaccine
3 society cdc, question, answer, contract, seanyoungphd, case, state
4 transmission outbreak, mosquito, bite, sexual, healthcare
6 olympic olympic, rio, get, athlete, pull, man, go, game
7 society spread, drug, prevent, left, provide, tool, world
8 case new, patient, utah, caregive, continent
10 politics rnc, fight, one, talk, recent, zikaprevent
11 society investigate, infect, 2016, mystery, brazil, microcephaly
12 transmission mosquito, kill, annual, took, guy
13 transmission health, transmiss
14 politics fund, congress, use, doyourjob, blame
17 pregenancy fetus, women, journey, prganant, way
18 society summer, threat, might, bug
19 case case, florida, first, travel, miami, relate, use
20 transmission fear, blood, commit

위의 표는 각 토픽별로 추출확률이 높은 단어를 나열하고, 해당 토픽에서 중요하다고 생각하는 단어를 뽑아서 나타낸 것이다. 또한 의미가 불분명한 topic은 제외하였다. 라벨(Label)은 토픽에 속하는 단어를 보고 직접 붙인 것이다. LDA는 어떠한 사전 지식 없이 순수하게 문서와 그 문서에 포함된 단어의 빈도만을 가지고 토픽을 묶어주기 때문에 이해하기 쉬운 토픽이 있는 반면 이해하기 어려운 토픽6도 있다.

트위터 사용자들이 지카바이러스에 관해 관심있는 범주는 크게 7가지로 나뉜다. 과학(science), 전염(transmission), 유행(epidemic), 임신(preganancy), 올림픽(olympic), 정치사회(politics & society), 사건(case)이 그것이다. 과학 범주에서는 백신 연구(#2)에 대한 토픽이 등장했다. 전염 범주에서는 성관계와 모기에 의한 전파 경로에 대한 토픽들(#4, #12, #13)이 등장했다. 그리고 수혈에 의한 전염이 토픽(#20)으로 관찰되었다. 임신 범주에 대한 트윗의 토픽은 임신이라는 단어가 등장한 토픽(#17)이 소두증보다 태아를 가진 임산부의 여행에 더 초점을 맞추고 있었다. 올림픽 범주의 트윗 토픽은 올림픽으로 인한 위험 자체 보다는 올림픽에 참가하는 선수들에 대한 이야기(#6)가 주를 이루었다. 정치사회 범주의 경우 URL 텍스트 보다 훨씬 더 많은 토픽이 관찰되었다. 미국 공화당의 지카 바이러스 관련 논의(#1)가 시의적인 단어(RNC; 공화당 전당대회)와 함께 등장하였다. 또한 의회에 예산을 요청하는 별도의 토픽(#10)도 등장하였다. 사회적 측면에서는 미국 CDC의 발표에 대한 정보가 질의응답이라는 구체적인 표현으로 등장하였고(#3), 지카바이러스에 대한 조사(#11)에 대한 토픽도 등장했다. 또 지카바이러스 퇴치를 위해 특정 약물(durg)의 사용을 허가한다는 뉴스(#7)와 여름에 조심해야할 사항과 관련된 토픽(#18)도 등장한다. 마지막으로 사건 범주에서는 플로리다의 지카 바이러스 사태에 대한 토픽(#19)이 등장하였다. 또한 유타 주의 환자에 대한 사태도 별도의 토픽(#8)으로 등장하였다.

트윗 데이터에서 발견되는 지카바이러스와 관련 없는 토픽을 살펴보면, 총기 폭력(gunviolence)에 대한 토픽(#9)이 있다. 이 토픽에서 발견되는 다른 단어가 속보(break news)임을 감안하면 트윗을 통해 전파되는 속보에 최근에 있었던 미국의 총기 난사 사건들과 지카바이러스가 함께 묶여 등장한 것이라 판단된다. 이외 두 개의 토픽은 주제의 파악이 매우 어려운 단어들로 구성되어 있었다.

토픽 내 단어의 특성으로는 일반적으로 붙여쓰지 않는 단어들이 나타난다는 점이다. sciencemagazine, seanyoungphd, zikaprevent, doyourjob 등이 그것이다. 이는 단어를 붙여서 써야만 하는 트위터의 해쉬태그로 보여진다. 이와 같은 단어들은 짧은 트윗의 주제를 함축적으로 나타내는 단어로서 토픽을 분석하는데 유용한 정보가 된다.

토픽별 문서의 비율

앞서 살펴본 LDA의 토픽별 단어 확률분포 못지 않게 중요한 것은, 우리가 데이터로 사용한 트윗들이 각각 어떤 토픽에 속하는가를 알아보는 것이다. gensim의 LdaModel은 문서의 토픽 분포를 알려주는 함수도 내장하고 있어 이 과정을 쉽게 진행할 수 있다. 각 문서별로 속할 확률이 30%가 넘는 토픽만 추출하고, 그 확률을 해당 토픽에 대한 가중치로 생각해서 토픽의 문서 비율로 누적하였다. 예를 들어 한 문서가 5번 토픽에 45%, 17번 토픽에 30%의 확률로 속하게 된다면 5번에 0.45, 17번 토픽에 0.3을 더하는 식이다.

from gensim import corpora, models
import json

with open('cleaned_tweet_texts.txt', 'r') as t:
	texts = json.load(t)

dictionary = corpora.Dictionary.load('zika_tweet_lda.dictionary')
ldamodel = models.LdaModel.load('zika_tweet_lda.model')

# ratio of topic distribution
topic_dist ={}
doc_topics =[]
doc_count = {}

for i in range(20):
	topic_dist[i]=0
	doc_count[i]=0

for num, text in enumerate(texts):
	doc_topic=ldamodel.get_document_topics(dictionary.doc2bow(text), minimum_probability=0.3)
	doc_topics = doc_topics + [doc_topic]
	for topic in doc_topic:
		for i in range(20):
			if topic[0] == i:
				topic_dist[i]+= topic[1]
				doc_count[i]+=1

이렇게 구한 각 토픽별 문서의 비율을 살펴보면 다음과 같다.

topic-document distribution

비율이 높은 것부터 시계방향으로 표시하였다

트윗들의 토픽 분포를 살펴보면 가장 많은 비율을 차지하는 것은 #12(약 19%)로, 지카바이러스가 모기에 의해 전파될 수 있는 가능성에 대해 논의하는 토픽이다. 처음 지카바이러스의 이슈가 등장했을 때 모기에 의한 전염을 주요한 내용으로 다루었다는 점에서 18%에 가까운 높은 비율을 차지했음을 이해할 수 있다. 이후 #19, #8의 두 토픽이 전체의 약 22% 가량을 차지하고 있는 것으로 나타난다. 이 토픽들은 플로리다와 유타에서 발생한 환자에 대한 토픽이다. 트윗에서 13%를 차지한다는 점에서 주목할만하다. 이는 사용자들이 미국 내에서의 발생하고 있는 지카바이러스 실제 피해 상황을 널리 알리고자 하는 행동으로 해석할 수 있다. 그다음으로 많은 비율을 차지하는 #11, #6(전체의 약 15%)은 각각 사회와 올림픽 범주에 속하는데, 토픽 내 단어를 살펴보면 곧 열릴 올림픽으로 인한 지카바이러스의 유행과 두 토픽의 관련성을 확인할 수 있다. 다음으로 많은 비중을 차지하는 #1, #18(전체의 약 12%)은 정치사회 범주의 토픽으로, 이들이 차지하는 비율과 앞서 다른 토픽들의 비율을 고려하였을 때 지카바이러스 이슈에 대한 트위터 사용자들의 관심은 직접적인 감염 위험에 초점이 맞춰져 있다고 생각할 수 있다.

VADER : 이 주제는 얼마나 부정적일까?

여태까지 분석한 것은 지카바이러스에 대해서 트윗에서 언급되고 있는 주제가 어떤 것인지 요약하는 과정이었다면, 지금부터는 각 주제들이 어떤 감정상태에 있는지를 분석하는 과정이다. 이와 같이 텍스트에 담겨있는 긍정 혹은 부정적인 감정을 분석하는 기법을 감정 분석(sentiment analysis)라고 한다.

감정 분석은 보통 사전을 기반(lexicon-based)로 진행한다. 즉, 텍스트에서 ‘나쁘다’라는 단어가 나오면 부정적인 감정에 점수를 주는 식이다. 이렇게 분석을 하기 위해서는 미리 감정 사전을 구축해두어야하며, 영어의 경우 꽤 많은 사전이 구축되어 있다.

여기서 사용한 VADER(Valence Aware Dictionary and sEntiment Reasoner)는 2014년에 등장한 감정 사전으로, SNS 텍스트에 대한 분석에 초점을 맞춘 것이 특징이다. 이 사전에는 SNS의 은어도 고려하고 있으며, 단어를 모두 대문자로 쓴 경우, 느낌표와 같은 구두점을 많이 쓴 경우와 같이 SNS 상에서 특정 단어를 강조하기 위해 사용하는 문법에 대해 감정의 강도도 고려한다. 이러한 특징으로 인해 트위터를 분석한 본 프로젝트에 적절한 사전이라고 판단이 되었다.

트윗 본연의 의미를 모두 살리기 위해 VADER를 이용할 때는 원래의 트윗을 데이터로 사용하였다. 그 뒤 점수가 나오면, 앞에서 구한 트윗이 속할 토픽의 확률을 가중치로 해서 각 토픽에 감정 점수로 더해주고, 마지막에는 평균해서 나타냈다.

# sentiment of document
from nltk.sentiment.vader import SentimentIntensityAnalyzer as Vader

with open('tweets_text.txt', 'r') as t:
	raw_texts = json.load(t)

topic_sentiment_dist={}
doc_sentiments = []

for i in range(20):
	topic_sentiment_dist[i]= {}
	topic_sentiment_dist[i]['compound'] = 0


for num, text in enumerate(raw_texts):
	doc_sentiment=Vader().polarity_scores(text)
	doc_sentiments= doc_sentiments + [doc_sentiment]

# sentiment of each topics
doc_topic_sentiment =[ [t,p] for t, p in zip(doc_topics, doc_sentiments)] # 앞에서 구한 토픽별 문서 비율

for num, doc in enumerate(doc_topic_sentiment):
	for doc_top in doc[0]:
		for i in range(20):
			if doc_top[0] ==i:
					topic_sentiment_dist[i]['compound'] += doc[1]['compound']*doc_top[1]

for i in range(20):
	topic_sentiment_dist[i]['compound'] = topic_sentiment_dist[i]['compound'] / doc_count[i]

	print "Topic#", i
	print topic_sentiment_dist[i]
	print "\n"

with open('tweet_topic_distribution.txt', 'a') as f:
	json.dump([topic_dist], f, indent=1)
	json.dump([topic_sentiment_dist], f, indent=1)

위의 방법으로 토픽별 감정점수를 산출한 결과는 다음과 같다.

topic sentiment

양수이면 긍정을 나타내고, 음수이면 부정을 나타낸다.

트윗 토픽의 감정 분포를 살펴보면 1개의 긍정과 2개의 중립 토픽을 제외하고는 모든 토픽이 부정적인 감정을 나타내고 있음을 알 수 있었다. 눈에 띄게 부정적 감정이 심한 토픽은 #12, #13으로 둘다 전염 범주에 속하는 토픽이다. 이들 토픽의 부정적 감정이 높고 특히 #12가 차지하는 문서의 토픽 비율이 가장 높았다는 것을 고려하면 지카바이러스의 전염에 대해 다수의 트위터 사용자들이 부정적 감정을 느끼고 있으며, 그러한 감정을 전파하려는 경향이 높다는 것을 알 수 있다. 거의 중립에 해당하는 #3과 #4의 경우 이에 해당하는 문서들이 감정적인 의견을 제시하기보다는 일어난 사건에 대한 사실을 전달하는 트윗으로 구성되어 있음을 추측하게 한다. 유일하게 긍정적 감정이 높은 #7은 토픽 내에 등장하는 단어들을 고려하였을 때 해당 토픽 내의 문서들이 지카바이러스에 대한 예방 조치를 비교적 긍정적인 평가를 내리고 있을 것이라 생각해볼 수 있다.

결론

분석 결과, 20개의 토픽 대부분은 사회에서 발생한 문제들에 관한 것이었다. 이는 위험커뮤니케이션에서 말하는 낙관적 편향(optimistic bias)가 현실에서 일어나고 있는 사례라고 할 수 있다. 자신의 위험 수준보다 타인의 위험수준을 더 높게 평가하는 경향을 나타내는 이 개념은 그동안 피험자를 모집해서 피험자에게 설문조사를 하는 식으로 진행되어 왔었는데, 이 분석을 통해 실제 트위터라는 공간에서 사람들의 행동에서도 낙관적 편향이 일어남을 확인했다는 점에서 의의가 있다.

이와 같은 트위터의 토픽 모델링을 발전시켜서 추후에는 실시간 트위터의 키워드를 추출하고, 이를 신문사의 뉴스의 키워드와 비교해서 기사에서 등장하지 않은 키워드를 중심으로 기사 소재를 제안하는 프로젝트를 진행해볼까 생각하고 있다.

+덧. 트윗으로 공유하는 글들에 대한 분석

트윗 자체에 대한 텍스트마이닝뿐 아니라, 트윗을 통해서 공유하는 글들을 크롤링해서 토픽 모델링을 진행해보기도 했다. 트윗 데이터에서 url entity를 추출하였고, 그 url을 통해 트윗에 공유된 글들을 긁어오기 위해 python-goose 패키지를 이용하였다.

위에서 사용한 표와 같은 형식은 사실 LDA의 토픽 간에 어떤 관계가 있는지에 대한 정보와 각 토픽별로 특별하게 등장하는 단어를 파악하기 어려운 단점이 있다. 이를 극복하기 위해 사용하는 LDA 시각화 패키지로 pyLDAvis가 있다. pyLDAvis를 이용하여 공유된 글의 LDA를 살펴보면 다음과 같다.



1. 한 개인이 특정한 위험에 대해서 자신의 위험수준보다 사회의 위험수준을 더 높게 평가하는 경향
2. Russell, Matthew A. Mining the Social Web: Data Mining Facebook, Twitter, LinkedIn, Google+, GitHub, and More. “ O’Reilly Media, Inc.”, 2013.를 참고하였다.
3. 아래의 코드에 반복문을 넣고 API의 rate limit을 넘지 않도록 하며 하루에 12시간 정도씩 트윗을 긁었다.
4. Steyvers, Mark, and Tom Griffiths. “Probabilistic topic models.” Handbook of latent semantic analysis 427.7 (2007): 424-440.
5. α의 값이 1보다 작을수록 한 문서가 하나의 토픽에 매우 치우치도록 토픽 z를 뽑아준다. 트윗과 같은 짧은 텍스트의 경우 대체적으로 토픽을 하나만 가지고 있기 때문에 α를 매우 작게 지정한다.
6. LDA의 성능을 판단되는 척도로 일반적으로 perplexity를 사용하는데, 여기서 이 척도를 사용하지는 않았다. 이 척도가 인간이 이해할 수 있도록 토픽을 나누었다는 증거로 사용하기 적절한지에 대해서 의견이 분분하다.

Dongmin Shin

Word Embedding, Reducing Discrimination on ML, NLP

Read Next

녹조를 미리 예측할 수 없을까?