본문 바로가기

Deep Learning/Natural Language Processing

Sentiment Analysis < Movie Comment > - (1)

Sentiment Analysis


 감성 분석(Sentiment Analysis)이란 텍스트에 들어있는 의견이나 감성, 평가, 태도 등의 주관적인 정보를 분석하는 과정이다. 자연어 데이터에 들어있는 감성을 분석하는 일은 오래 전부터 연구되어왔지만 문맥에 대한 고려나 단어들의 중의성 등의 여건 때문에 쉽지 않았던 것이 사실이다. 하지만 딥러닝 기술이 발전하면서 이러한 자연어와 같은 Sequence계열의 데이터에 대한 처리법이 발달하였고 적용할 수 있는 모델들의 성능이 크게 향상되었다.

 

 딥러닝을 이용한 감성분석은 크게 Unsupervised Learning Task와 Supervised Learning Task로 두가지 범주로 나누어질 수 있는데, 이는 머신러닝에서 다루는 범주와 같다. 

 

Unsupervised Learning


 분석 및 예측에 있어서 Y, 즉 타겟 값이 없는 상태에서 수행하는 방법으로써 기존에 구축된 감성분류 사전을 이용해 문장의 감성여부를 파악하는 방법이다. Python에서는 라이브러리 형태로 사전에 구축된 감성사전들 여러가지들을 제공한다. 그 중 자주 사용되는 감성사전은 다음과 같다.

 

SentiWordNet

 

 해당 감성사전의 감성점수는 긍정(P), 부정(N), 중립(O)으로 구성된다. 만약 P = 1이면 해당 문장은은 강렬하게 긍정적인 의미인것이고, 반대로 N = 1이면 강렬하게 부정적인 의미인것을 의미한다. 해당 감성사전은 WordNet이라는 이용하는데 이는 단어들의 품사를 구분해 그들 간의 동의어, 반의어나 하위어 또는 상위어 등의 관계들을 고려해 긍정 / 부정 / 중립 여부들을 파악한다.

 

VADER

 

 VADER은 주로 소설 미디어의 텍스트에 대한 감성 분석을 제공하기 위한 패키지이다. 비교적 빠른 수행 시간을 보장해 대용량 텍스트 데이터에 잘 사용되는 패키지로서 감성분류 문제에 있어서 높은 성능을 보여준다. 긍정, 부정, 중립 여부에 대한 각각의 확률 값을 반환하는 형태로 사용된다.

 

AFINN

 

 AFINN은 Finn Arup Nielsen이 2009 ~ 2011년에 직접 수집한 감성 어휘들에 대해 -5~+5의 점수를 부여한 사전으로 약 2477개의 감성어가 수록되어있다. 해당 패키지는 자연어에대한 별도의 전처리없이 간편하게 사용할 수있다는 장점이 있다. 또한 단순히 긍정, 부정, 중립 여부를 구분하는 것이 아니라 여러개의 범주로 구성된 구체적인 감정에 대한 점수를 부여하는 식으로 산출된다.

 

 이외에도 다양한 감성사전들이 존재하고, 이를 통한 방법들은 모두 사전에 구축되어있는 감성사전에 축적된 감정정보들을 통해 새로운 문장에 대해 감성분류를 시행하는 방법이다.

 

Supervised Learning


 비지도학습과는 반대로 주어진 타겟값 Y에 대해 예측하는 방법으로 이후 사용할 방법이 해당 방법론에 해당한다. 

자연어와 같은 Sequence 데이터를 처리하는데 있어서 대표적으로 사용되는 알고리즘은 주로 Recurrent Nueral Network 기반의 방법이다. 초기 RNN이 발전하면서 여러개의 gate를 가진 LSTM이나 이를 변형한 GRU와 같은 유닛이 주목받기 시작했고 높은 성능을 보여주어왔다. 그 밖의 1D-CNN, SVM등을 이용한 문서분류나 텍스트 분석도 좋은 효용성을 보여주었으나 RNN계열의 모델들이 더 범용적으로 사용되고있으며 여기서 한단계 더 발전된 BERT, GPT등의 모델들이 출현했다. 이 글에서 사용하는 알고리즘은 LSTM을 이용한 딥러닝 방법이다.

 

LSTM

LSTM Unit

 

 RNN은 학습이 진행됨에따라 역전파시 Gradient가 점차 줄어드는 Gradient Vanishing Problem으로 인해 학습능력이 크게 저하되는 것으로 알려져있다. 이 문제를 극복하기 위해서 고안된 것이 LSTM이다. LSTM은 RNN의 히든 state에 cell-state를 추가한 구조이다. 주요한 역할을 하는 게이트 유닛에 대항 설명은 다음과 같다.

 

 Forget gate ‘과거 정보를 잊기’를 판단하기 위한 게이트이다. 이전 상태의 정보와 새로 입력된 정보들을 받아 sigmoid를 취해준 값이 바로 forget gate가 내보내는 값이 된다. sigmoid 함수의 출력 범위는 0에서 1 사이이므로 값이 0이라면 이전 상태의 정보는 잊고, 1이라면 이전 상태의 정보를 온전히 기억하게 된다.

 

 Input gate ‘현재 정보를 기억하기’ 위한 게이트이다. 마찬가지로 이전상태의 정보와 새로 입력된 정보들을 받아 sigmoid를 취하고, 또 같은 입력으로 tanh를 취해준 다음 Hadamard product 연산을 한 값이 바로 input gate가 내보내는 값이 된다. 이에 따라 현재 정보를 반영할지를 결정하게된다.

 

 마지막으로 Output gate를 통해 값이 산출된다. 즉, 과거의 정보에 대한 기여도를 forgetting gate를 통해서 결정하고, 현재의 Input값이 반영여부는 Input gate에서 결정되고, 최종적으로 더해져서, 다음 cell state의 입력값으로 들어가게된다. 이에 따라 과거로부터 오는 정보들을 더 오래 보존할 수 있었으며 RNN이 가지는 고질적인 한계를 피할 수 있다는 장점이 있다.


1. Load Datasets and Setting

import pandas as pd
import numpy as np
import seaborn as sns 
import matplotlib.pyplot as plt
import matplotlib as mpl    #그래프 옵션
import warnings
warnings.filterwarnings('ignore')

import re
import nltk
from wordcloud import WordCloud
from konlpy.tag import Okt
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences

from tqdm import tqdm

plt.rc('font', family='NanumGothic') 

#그래프 시각화 옵션 설정함수
%matplotlib inline

#그래프의 한글을 더욱 선명하게 출력
from IPython.display import set_matplotlib_formats
set_matplotlib_formats('retina')

#그래프에서 음수 값이 나올 때, 깨지는 현상 방지
mpl.rc('axes',unicode_minus=False)
train=pd.read_csv('ratings_train.txt',sep='\t')
test=pd.read_csv('ratings_test.txt',sep='\t')
# 데이터의 결측치 행들을 제거
train.dropna(inplace=True)
test.dropna(inplace=True)

 사용되는 데이터는 그 자체가 학습셋과 테스트셋으로 구분되어있으며 각각 15만개, 5만개정도의 리뷰데이터를 가진다.

확인결과 Train, Test 셋 모두 약 1:1의 균일한 Label의 분포를 가지게 되어 긍정과 부정의 Label이 고르게 분포한다. 따라서 Accuracy를 추후 성능지표로 사용하는 것이 바람직하다.

2. EDA

2.1 기초 통계량 및 분포 파악

reviews = list(train['document'])

reviews_split_space = [r.split() for r in reviews]  # 공백을 기준으로 문자열을 나누어 저장
review_len_space = [len(t) for t in reviews_split_space] # 나뉜 리스트에 대해서 해당 리뷰들의 길이를 저장

review_fulllen = [len(k.replace(' ', '')) for k in reviews] # 공백을 제거한후 리뷰들의 길이를 저장
# 데이터들의 길이의 빈도수를 Histogram으로 시각화
plt.figure(figsize = (12,5))

plt.hist(review_fulllen, bins = 50, alpha=0.5, color="b", label="Full length")
plt.hist(review_len_space, bins = 50, alpha=0.5, color="r", label="Split by space")

plt.yscale('log', nonposy = 'clip')
plt.title('Review Length Histogram')
plt.xlabel('review length')
plt.ylabel('number of reviews')
plt.legend()

 reviews_len_space는 공백을 기준으로 문자열을 나누어 해당 길이를 측정한 것이다. 그래프에서는 Split by space로 표현된다. review_fulllen은 공백을 제거한뒤 문자열의 길이를 측정한것으로 쉽게말해 공백을 제거한 글자수라고 할 수 있다. 위 과정을 통해 대체적으로 데이터가 어느정도의 문장길이를 가지는 지 파악하고, 추후 토큰화 작업이 어느정도의 길이가 적정한지를 예상해보는 하나의 지표로 생각할 수 있다.

print('--- Full Length Case ---')
print('문장 최대 길이 : {}'.format(np.max(review_fulllen)))
print('문장 최소 길이 : {}'.format(np.min(review_fulllen)))
print('문장 평균 길이 : {:.2f}'.format(np.mean(review_fulllen)))
print('문장 길이 표준편타 : {:.2f}'.format(np.std(review_fulllen)))
print('문장 중간 길이 : {}'.format(np.median(review_fulllen)))
print('제 1사분위 길이 : {}'.format(np.percentile(review_fulllen, 25)))
print('제 3사분위 길이 : {}'.format(np.percentile(review_fulllen, 75)))

print('--- Split by Space Length Case ---')
print('문장 최대 길이 : {}'.format(np.max(review_len_space)))
print('문장 최소 길이 : {}'.format(np.min(review_len_space)))
print('문장 평균 길이 : {:.2f}'.format(np.mean(review_len_space)))
print('문장 길이 표준편타 : {:.2f}'.format(np.std(review_len_space)))
print('문장 중간 길이 : {}'.format(np.median(review_len_space)))
print('제 1사분위 길이 : {}'.format(np.percentile(review_len_space, 25)))
print('제 3사분위 길이 : {}'.format(np.percentile(review_len_space, 75)))

3. Word Coherence Analysis

 해당 분석은 본격적인 자연어 처리에 앞서, 어떤 단어들로 텍스트가 구성되어있고 리뷰의 성격에 따라 어떤 특성을 보이는지를 시각화하고 분석하기 위한 사전분석에 해당한다. 단어들을 형태소 분석을 통해 품사별로 정리하고, 빈도분석을 실시하고 시각화한다. 이에따라 긍정, 부정의 여부에 따라 다르게 나타나는 주요 단어들의 양상을 탐색해 어떠한 불만점과 만족점이 나타나는 지를 파악하는 분석이다.

# 긍정, 부정리뷰 매핑
pos_review=train[train['label']==1]['document'].to_list()
neg_review=train[train['label']==0]['document'].to_list()
# 형태소 분석기 Okt 모듈을 사용
okt = Okt()

# 리뷰들을 통해 형태소 구분을 통해 품사별로 단어들을 매핑하는 함수
# 리스트로 반환되며, 각 단어가 어떤 품사에 속하는지 알려주는 ( 단어, 품사 ) 의 형태로 반환
def making_morphs(review):
  morphs = []
  for sentence in review:
    morphs.append(okt.pos(sentence))
  return morphs

# 위에서 추출된 morphs를 통해 원하는 품사에 해당하는 단어들만 추출해 리스트로 반환하는 함수
def extract_by_tag(morph,tag_name):
  extracted_list = []
  for i in morph:
    for word, tag in i:
      if tag in[tag_name]:
        extracted_list.append(word)
  return extracted_list
# 형태소가 태깅된 리스트를 긍정리뷰는 pos_morphs에, 부정 리뷰는 neg_morphs에 저장
pos_morphs=making_morphs(pos_review)
neg_morphs=making_morphs(neg_review)
# 품사중 명사에 해당하는 단어들만 추출
pos_nouns=extract_by_tag(pos_morphs,'Noun')
neg_nouns=extract_by_tag(neg_morphs,'Noun')

# 품사중 형용사에 해당하는 단어들만 추출
pos_adj=extract_by_tag(pos_morphs, 'Adjective')
neg_adj=extract_by_tag(neg_morphs, 'Adjective')

 긍정 부정 리뷰를 각각 Okt모듈을 이용해 품사들을 태깅한다. making_morphs에서 각 문장들은 형태소단위로 분리되며, morphs에는 형태소와 그에 해당하는 품사가 같이 매칭된 형태로 반환되게 된다. 이후 extract_by_tag를 통해 지정한 품사에 해당하는 형태소들만 추출하고 이를 토대로 이후 빈도분석과 시각화가 이루어지게된다. 여기서는 감정을 대표할 수 있는 '명사'와 '형용사'의 품사만 지정하여 추출했다.

# 추출된 리스트를 출현단어 빈도수를 매핑하여 정렬
from collections import Counter

def sorting(review):
  count = Counter(review)
  words = (dict(count.most_common()))
  return words
# 제거할 불용어를 정의
stopwords = ['과','도','를','으로','자','에','와','한','하다','의','가','이','은','들','는','좀','잘','수','것','만','그','인','안', '입니다', '있는','이런','같은'
             '있고','있어서','있다는'] 

# 지정한 불용어를 각각 리스트에서 제거함
pos_nouns = [token for token in pos_nouns if not token in stopwords]
neg_nouns = [token for token in neg_nouns if not token in stopwords]

pos_adj = [token for token in pos_adj if not token in stopwords]
neg_adj = [token for token in neg_adj if not token in stopwords]
# 추출된 리스트를 출현단어 빈도수를 매핑하여 정렬
pos_nouns_count=sorting(pos_nouns)
neg_nouns_count=sorting(neg_nouns)

pos_adj_count=sorting(pos_adj)
neg_adj_count=sorting(neg_adj)

 품사기반으로 추출된 단어들에서 불용어를 지정해 빈번하게 나타나는 무의미한 단어들을 제거하고, 출현 빈도수를 기준으로 정렬한다.

3.1 Positive review case

def view_wordcloud(counter_class):
  # 단어 빈도수를 기준으로 WordCloud를 생성함
  wordcloud = WordCloud(font_path='/usr/share/fonts/truetype/nanum/NanumGothic.ttf',
  		       background_color='white',colormap = "Accent_r",width=3000, height=2000,
                       stopwords=stopwords).generate_from_frequencies(counter_class) 
  plt.figure(figsize=(10,8))
  plt.imshow(wordcloud)
  plt.tight_layout(pad=0)
  plt.axis('off')
  plt.show()

  # 출현 빈도수를 그래프로 표현
  freq=nltk.FreqDist(counter_class)
  plt.figure(figsize=[15,8])
  freq.plot(30,cumulative=False)  # 빈도수 기준 상위 30개의 단어를 보여줌

 해당 함수를 통해 정렬된 긍정, 부정 단어의 count들을 기반으로 WordCloud를 실행하며 Frequency Plot을 생성한다.  WordCloud함수는 generate와 generate_from_frequencies라는 함수 두가지가 있는데 이중 후자를 사용하면 빈도수가 나타나있는 형태의 Counter Class를 인자로 투입하여 워드 클라우드를 생성할 수 있다.

view_wordcloud(pos_nouns_count)

Case1 : Positive Nouns

view_wordcloud(pos_adj_count)

Case2 : Positive Adjectives

< Positive Reviews >

 

 긍정적이라고 분류된 리뷰들은 다양한 감탄사들로 나타나고있으며, 주로 '재미' 와 관련된 형용사들이 다수 나타난다. 그외에도 '평점', '연기', '명작' 과 같은 단어들이 빈번하게 검출되며 특히 배우의 연기력에 대한 평가가 눈에 두드러지는 경향을 보인다. 따라서 위와 같은 단어들이 나타날때 추후 학습된 모델은 리뷰를 긍정적인 리뷰로 구분할 확률이 높다.

3.2 Negative review case

view_wordcloud(neg_nouns_count)

Case3 : Negative Nouns

view_wordcloud(neg_adj_count)

Case3 : Negative Adjectives

< Negative Reviews >

 

 부정적이라고 분류된 리뷰들은 명사 단어에서 '쓰레기', '왜', '그냥' 이라는 단어들이 다수를 차지한다. 동시에 '연기' 키워드와 관련되어 연기에 대한 혹평이 예상된다. 형용사에서는 부재의 의미를 나타내는 '없다' 와 같은 계열의 단어들이 상당히 많은 비중을 보이고 '아니다' 와 같은 부정적인 어휘들 역시 빈번하게 확인된다. 동시에 영화에 대한 평가에 있어서 '아깝다' 라는 어휘들이 많이 등장하는 것으로 보아 명사부분에서 검출된 '시간', '평점' 에대한 아까움과 연결지어 생각해 볼 수 있다.