AI/정책 댓글 반응 NLP

[4] 포털 댓글 감성 분석_1. 순환신경망_1. 사전 작업

eraser 2020. 4. 17. 19:48
반응형

 앞 단에서 감성어 사전(0417 현재 기준 ver3),  긍/부정 라벨링된 커뮤니티 게시글 및 댓글 데이터셋을 구축했다. 감성분석 모델링을 수행할 준비를 마쳤다. 이제 네이버, 다음, 유튜브 등 포털에서 수집한 180만 건의 댓글 데이터를 대상으로 감성분석을 수행한다.

 

 우선 자연어 처리에 많이 활용되는 딥러닝의 순환신경망 알고리즘을 사용한다. 그 중에서도 RNN 알고리즘을 개선/변형한 LSTM, GRU 알고리즘을 적용한다.

(각 모델에 대한 공부는 StudyLog에서...)

 

 본격적으로 모델을 설계하고 적용하기에 앞서, 문장 토큰화, 불용어 처리 등의 작업을 진행한다. 

  

참고 : 「케라스 창시자에게 배우는 딥러닝」,

「딥러닝을 이용한 자연어 처리 입문」 

 


# 사전 작업

 

# install KoNLPy

! pip3 install konlpy
! wget https://bitbucket.org/eunjeon/mecab-ko/downloads/mecab-0.996-ko-0.9.2.tar.gz
! tar xvfz mecab-0.996-ko-0.9.2.tar.gz > /dev/null 2>&1
! ./configure > /dev/null 2>&1
! make > /dev/null 2>&1
! make check > /dev/null 2>&1
! make install > /dev/null 2>&1
! ldconfig > /dev/null 2>&1
! wget https://bitbucket.org/eunjeon/mecab-ko-dic/downloads/mecab-ko-dic-2.1.1-20180720.tar.gz
! tar xvfz mecab-ko-dic-2.1.1-20180720.tar.gz > /dev/null 2>&1
! ./configure > /dev/null 2>&1
! make > /dev/null 2>&1
! make install > /dev/null 2>&1
! apt-get update > /dev/null 2>&1
! apt-get upgrade > /dev/null 2>&1
! apt install curl > /dev/null 2>&1
! apt install git > /dev/null 2>&1
! bash <(curl -s https://raw.githubusercontent.com/konlpy/konlpy/master/scripts/mecab.sh)  > /dev/null 2>&1

# module import

import pandas as pd
import numpy as np
from sklearn.model_selection import StratifiedShuffleSplit
import matplotlib.pyplot as plt
from konlpy.tag import Mecab
import tensorflow as tf
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
from tensorflow.keras.models import Sequential, Model, load_model
from tensorflow.keras.layers import Dense, Embedding, LSTM, GRU, Bidirectional, Dropout
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint

 


 

# 데이터 준비

 

 경로를 설정한 뒤, 데이터를 로드한다.

 개인적인 편의를 위해 로드한 데이터에서 컬럼 명을 바꿨다. 커뮤니티 데이터셋의 경우, 라벨링을 진행할 때 데이터셋을 바로 사용할 수 있도록 전처리를 마쳐 놓았기 때문에 컬럼명을 바꾸는 것 말고 따로 전처리를 진행한 것은 없다.

 원본 데이터가 필요할 상황을 대비해, 원본 데이터도 저장했다.

 

my_path = 'PATH' # 개인 작업 경로
project_path = 'PATH' # 작업 결과 파일을 저장할 공유 프로젝트 경로

def load_data(dir, file_name):
    df = pd.read_csv(os.path.join(dir, file_name))
    return df

train_raw = load_data(my_path, 'file_name')

# 학습에 사용할 데이터
train_df = train_raw.copy()

# column engineering
train_df = train_df[["content", "label"]]
train_df.rename(columns = {'content':'document'}, inplace=True)

# check data
display(train_df.head(10))

 


 

#  트레인 셋, 테스트 셋 분리

 

 학습에 사용할 데이터를 학습용 train set과 테스트용 test set으로 분리한다. 이전 라벨링 단계에서도 확인했지만, 애초에 훈련 데이터 내 라벨의 분포가 매우 불균형하다. 따라서 계층적 샘플링을 진행했다.

 test set을 여러 개로 형성한 후 test를 진행할 수도 있지만, 시간과 비용이 많이 들 것이라 예상해 하나의 train set과 test set만을 나눈다. 테스트에는 전체의 30% 데이터를 활용하고, 재현을 위해 난수 시드를 고정했다.

 

# split train data
splitter = StratifiedShuffleSplit(n_splits=1, test_size=0.3, random_state=42)

for train_index, test_index in splitter.split(train_df, train_df["label"]):
    train_data = train_df.loc[train_index]
    test_data = train_df.loc[test_index]

# rearrange indices
train_data.index = pd.RangeIndex(len(train_data.index))
test_data.index = pd.RangeIndex(len(test_data.index))

 

 훈련 데이터 전체의 라벨 분포를 확인한 뒤, 계층적 샘플링을 진행하고 난 뒤의 라벨 분포를 확인하고, 트레인 데이터와 테스트 데이터의 수를 확인한다.

 

# check proportion
print(train_df.label.value_counts()/len(train_df))
print(train_data.label.value_counts()/len(train_data))
print(teset_data.label.value_counts()/len(test_data))

# data 확인
print(f"train data의 개수 : {len(train_data)}")
display(train_data.head(10))
print(f"test data의 개수 : {len(test_data)}")
display(test_data.head(10))

 

 train data의 개수는 55755개, test data의 개수는 23896개이며, 각각 라벨의 비율 분포는 다음과 같다.

 

라벨 원본 train set test set
0 0.522994 0.523002 0.522975
-1 0.339255 0.339252 0.339262
1 0.137751 0.137745 0.137764

 


# 라벨 원핫 인코딩

 

 입력 데이터(X)와 라벨(y)을 분리한다. 

 분류 문제이고, 라벨이 범주형 데이터이기 때문에 원핫 인코딩을 진행한다. Keras의 to_categorical 함수를 이용해  class 개수인 3을 인자로 넘겨주기만 하면 된다. 결과를 확인하면, 중립(기존 라벨 0)은 [1., 0., 0,]으로, 긍정(기존 라벨 1)은 [0., 1., 0.,]으로, 부정(기존 라벨 -1)은 [0., 0., 1.,]의 벡터로 인코딩된다.

 

# split labels
y_train = train_data[["label"]]
y_test = test_data[["label"]]

# one_hot encoding
y_train = to_categorica(y_train, 3)
y_test = to_categorical(y_test, 3)

 


 

 입력 데이터로 사용할 문장은 자연어이다. 컴퓨터가 인간의 언어를 이해할 수 있도록 자연어를 숫자 벡터로 변환해야 한다. 크게 다음과 같은 과정을 거친다.

 

1. 문장 토크나이징(+ 불용어 처리, 어휘 집합 수 결정)
2. 정수 인코딩
3. 문장 패딩(+ 최대 시퀀스 길이 결정)
4. 임베딩

 

 지금 단계에서는 Keras의 임베딩 층을 사용해 임베딩을 진행할 예정이다. 이후 팀 차원에서 Word2Vec, GloVe, FastText 등 임베딩 알고리즘을 적용하는 과정을 완료한다면, 모델에 다른 임베딩을 적용할 예정이다.

 


# 형태소 단위 토큰화

 

 형태소 분석기를 이용해 트레인 셋과 테스트 셋에 있는 문장들을 형태소 단위로 분석한다. 팀 차원에서 정의한 불용어 리스트에 포함되지 않는 단어들만을 대상으로 한다. 감성어 사전, 커뮤니티 데이터 셋을 구축할 때 Mecab 형태소 분석기를 사용했으므로, 이번 단계에서도 Mecab 분석기를 사용한다. 다만, 모델에 입력할 때 품사가 필요한 것은 아니므로, 품사 태깅은 진행하지 않는다.

 

# 불용어 리스트
def get_stopwords(filePath):
    stopwords = []
    with open(filePath, 'r') as f:
        while True:
            line = f.readline()
            if not line:
                break
            stopwords.append(line.strip('\n'))
    return stopwords

# 형태소 분석
def get_morphs(data, tagger, stopwords):
    tagger = tagger
    morphs_list = []
    for sentence in data:
        temp_X = []
        temp_X = tagger.morphs(sentence)
        temp_X = [word for word in temp_X if not word in stopwords]
        morphs_list.append(temp_X)
    return morphs_list

# tokenize sentences
stopwords = get_stopwords(os.path.join(project_path, 'stopwords.txt'))
X_train = get_morphs(train_data.document, Mecab(), stopwords)
X_test = get_morphs(test_data.document, Mecab(), stopwords)

# check data
print(f"train data 개수 : {len(X_train)}")
print(X_train[:3])
print(f"test data 개수 : {len(X_test)}")
print(X_test[:3])

 

 형태소 분석된 결과를 확인하면 다음과 같다.

 

>>> train data 개수 : 55755
>>> [['근데', '재', '계위', '금융', '회사', '다니', '직원', '아', '는데', '친구', '토일', '계속', '출근', '평일', '매일', '야근', '달', '일요일', '쉬', '다', '라구', '요', '눈치', '보여서', '특근', '비', '처리', '맘대로', '못', '올린다고', '했었', '는데', '작년', '었', '던', '이야기', '로'], ['길', '어서', '패스'], ['그건', '아니', '냐']]
>>> test data 개수 : 23896
>>> [['댓글', '영향', '력', '흐를수록', '감소', '했', '다', '뉴스', '댓글', '을', '읽', '고', '즉각', '여론', '을', '추정', '할', '찬성', '댓글', '을', '읽', '이용자', '반대', '댓글', '을', '읽', '이용자', '보다', '전체', '찬성', '여론', '을', '높', '게', '추정', '했으나', '여분', '후', '집단', '간', '여론', '추정', '치', '차이', '줄어들', '었', '다', '부분', '아마', '연장', '해서', '실험', '해서', '실제로', '지나', '면', '제한', '인', '영향', '만', '었', '던', '현상', '을', '관찰', '했', '을', '네요'], ['난', '파릇파릇', '중학', '생', '야'], ['진짜', '할', '없', '네요', '세상', '어떻게', '돌아가', '는지', '조금', '만', '들여다보', '세요', '정의', '찾', '고', '그럴', '아니', '에요']]

 


 

# 토크나이징

 

 Keras의 Tokenizer를 이용해 원본 형태소 데이터를 정수로 변환한다. Tokenizer 객체는 어휘 집합의 수를 인자로 받아 빈도가 높은 단어만 사용할 수 있게 한다. 실제 학습 데이터에 있는 모든 단어를 사용하면 속도, 메모리 측면에서 비효율적일 뿐만 아니라, 중요하지 않은 단어까지 사용하게 됨으로써 모델의 성능이 떨어질 수 있다.

 

 따라서 Tokenizer에 어휘 집합의 수를 지정해야 한다. 이를 위해, 입력으로 사용할 형태소 데이터 셋에서, 각 형태소의 등장 빈도가 어떻게 되는지, 희귀 단어의 등장 빈도 수를 어떻게 지정해야 하는지 확인한다.

 

# 등장 빈도 확인
def check_frequency(num, tokenizer):
    threshold = num # 등장 빈도가 threshold보다 낮으면 희귀 단어로 분류
    total_cnt = len(tokenizer.word_index) # tokenizing된 모든 단어의 개수
    rare_cnt = 0 # 희귀 단어 개수
    total_freq = 0 # 입력 데이터의 전체 단어 빈도
    rare_freq = 0 # 희귀 단어 등장 빈도 합
    
    for word, freq in tokenizer.word_counts.item():
        total_freq += freq # 전체 빈도
        if total_freq < threshold : # 전체 등장 빈도가 threshold보다 낮으면
            rare_cnt += 1 # 희귀 단어로 간주
            rare_freq += freq # 희귀 단어 등장 빈도 업데이트
     
     print(f'최소 등장 빈도 : {threshold-1}')
     print(f'어휘 집합 크기 : {total_cnt}')
     print(f'희귀 단어 수 :  {rare_cnt}')
     print(f'어휘 집합에서 희귀 단어의 비중 : {(rare_cnt/total_cnt)*100}'
     print(f'전체 단어 빈도에서 희귀 단어 빈도 : {(rare_freq/total_freq)*100}'
     
     return total_cnt, rare_cnt

# check frequency
test_tokenizer = Tokenizer()
test_tokenizer.fit_on_texts(X_train)

for THRESHOLD in range(1, 10):
    check_frequency(THRESHOLD, test_tokenizer)
    print("="*70)

 

 결과를 확인하면 다음과 같다.

 

최소 등장 빈도 : 1
어휘 집합 크기 : 41564
희귀 단어 수 : 15379
어휘 집합에서 희귀 단어의 비중 : 37.000769897026274
전체 단어 빈도에서 희귀 단어 빈도 : 1.0858506159319752 
====================================================================== 
최소 등장 빈도 : 2
어휘 집합 크기 : 41564
희귀 단어 수 : 21400
어휘 집합에서 희귀 단어의 비중 : 51.48686363198922
전체 단어 빈도에서 희귀 단어 빈도 : 1.9360888054795953
====================================================================== 
최소 등장 빈도 : 3
어휘 집합 크기 : 41564
희귀 단어 수 : 24659
어휘 집합에서 희귀 단어의 비중 : 59.327783658935616
전체 단어 빈도에서 희귀 단어 빈도 : 2.626404266300645
====================================================================== 
최소 등장 빈도 : 4
어휘 집합 크기 : 41564
희귀 단어 수 : 26841
어휘 집합에서 희귀 단어의 비중 : 64.57751900683284
전체 단어 빈도에서 희귀 단어 빈도 : 3.2426539688726117
====================================================================== 
(하략)

 

 보통 자연어 처리 과정에서 어휘 집합의 수를 2만 개~3만 개 정도로 사용함을 감안할 때, 최소 등장 빈도가 3, 4이면 어휘집합의 크기가 너무 작아질 수 있을 것이라 보인다. 또한 아무래도 등장 빈도가 1회인 단어의 경우, 훈련에 사용될 입력 데이터에서 차지하는 비중이 1.93% 정도밖에 차지하지 않기 때문에 별로 중요하지 않을 것이라 보인다.

 따라서 등장 빈도가 2회 이하인 단어들을 정수 인코딩 과정에서 배제한다.

 

 어휘 집합의 수를 결정해 Tokenizer 객체에 인자로 넘겨 주고, 토크나이징을 진행한다. Keras Tokenizer 객체의 texts_to_sequences 메서드를 사용한다. test set에 있는 문장들 역시 훈련 데이터와 같은 방식으로 토크나이징한다.

 

# get vocab size
threshold = 3
total_cnt, rare_cnt = check_frequency(threshold, test_tokenizer)
vocab_size = total_cnt - rare_cnt + 1

# tokenizer
myTokenizer = Tokenizer(vocab_size)
myTokenizer.fit_on_texts(X_train)
print(myTokenizer.index_word)

# encode to integer sequences
X_train = myTokenizer.texts_to_sequences(X_train)
X_test = myTokenizer.texts_to_sequenceS(X_test)

# check data
print(X_train[0])
print(X_test[0])

 

 Tokenizer에 포함된 어휘 집합, 이 어휘 집합을 기준으로 토크나이징된 입력 데이터를 확인한 결과는 다음과 같다.

 

>>> {... 4603: '디테일', 4604: '회견', 4605: '중임', 4606: '명령', 4607: '저해', 4608: '청문회', 4609: '납기', ...}
>>> [125, 799, 773, 38, 201, 139, 24, 14, 558, 3636, 220, 233, 904, 552, 89, 106, 1199, 205, 3, 5856, 84, 1116, 4696, 535, 238, 869, 2949, 46, 5351, 1004, 14, 545, 26, 55, 161, 8]
>>> [244, 515, 3870, 430, 17, 3, 314, 244, 2, 525, 1, 5595, 857, 2, 3849, 16, 912, 244, 2, 525, 6953, 259, 244, 2, 525, 6953, 73, 482, 912, 857, 2, 173, 5, 3849, 4106, 299, 1222, 196, 857, 3849, 209, 435, 575, 26, 3, 270, 940, 295, 45, 2413, 45, 608, 999, 4, 523, 20, 515, 9, 26, 55, 1233, 2, 8600, 17, 2, 27]

 

 


# 빈 데이터 처리

 

 어휘 집합의 수를 제한했기 때문에, 희귀 단어로만 구성된 문장이 있다면, 그 문장은 토크나이징되지 않는다. 즉, 토크나이징 된 문장이 빈 리스트가 된다. 이러한 결측치는 두 가지의 방식으로 처리할 수 있다. 첫째, 기존 데이터에서 삭제한다. 둘째, "No Text", [NO] 등의 문자열로 바꿔 놓는다.

 

 우선 빈 데이터가 무엇인지 확인한다. 트레인 셋, 테스트 셋에서 비어 있는 데이터의 인덱스를 받고, 원본 데이터에서 무슨 문장이었는지 확인한다.

 

# get empty indices
empty_train = [idx for idx, sent in enumerate(X_train) if len(sent) < 1]
empty_test = [idx for idx, sent in enumerate(X_test) if len(sent) < 1]
print(f"삭제된 train data index : {len(empty_train)}개, {empty_train}")
print(f"삭제된 test data index : {len(empty_test)}개, {empty_test}")

# check empty data
display(train_data[157:160])
display(test_data[130:133])

 

 확인 결과, 트레인 셋에서 570개, 테스트 셋에서 236개의 데이터가 삭제되었음을 알 수 있다. 각각의 데이터는 다음과 같다.

 

 트레인 셋에서 빈 문장 예
테스트 셋에서 빈 문장 예

 

 정책 반응을 분석하고자 했던 원 취지에 맞지 않는 데이터(정책과 관련 없는 문장, 고유명사, 외국어, 숫자 등)가 많으며, 형태소 분석 후 희귀 단어로 간주되었다면 라벨이 0일 확률이 높기 때문에 삭제하기로 했다.

 

# delete empty data
# train set
X_train = np.delete(X_train, empty_train, axis = 0)
y_train = np.delete(y_train, empty_train, axis = 0)

# test set
X_test = np.delete(X_test, empty_test, axis = 0)
y_test = np.delete(y_test, empty_test, axis = 0)

 


 

# 문장 패딩

 

 훈련 셋, 테스트 셋에서 모든 입력 데이터의 길이가 다르다. 서로 다른 길이의 시퀀스들의 길이를 동일하게 맞춰 주는 패딩이 필요하다. Keras의 pad_sequences를 활용한다.

 기준 길이를 어떻게 잡을지에 따라 패딩이 달라진다. 시퀀스 내에 있는 모든 문장의 정보를 활용하고 싶을 수 있다. 그러나, 기준 길이를 길게 잡을수록 짧은 문장에 패딩되는 0의 개수가 늘어난다. 메모리와 훈련 시간 측면에서 비효율적이다. 뿐만 아니라, 뒤로 갈수록 문장 내에 별로 필요하지 않은 정보가 포함되어 있을 수도 있다. (물론 한국어는 끝까지 들어봐야 안다지만...)

 

 전체적인 데이터의 길이 분포를 보고, 적절한 샘플 길이를 선택해주어야 한다. 히스토그램을 그려보자.

 

# draw histogram
plt.hist([len(sent) for sent in X_train], bins=50)
plt.show()

# check length
print('문장의 최대 길이 :',max(len(l) for l in X_train))
print('문장의 평균 길이 :',sum(map(len, X_train))/len(X_train))

 

전체 문장 길이 분포

 

 

 최대 문장의 길이는 6608인 반면(...), 문장의 평균 길이는 25.168이다.

 최대 시퀀스 길이를 정하기 위해 최대 문장의 길이를 바꿨을 때 포함되는 데이터의 비율이 얼마나 되는지 확인한다.

 

# check length
def below_len(max_len, sentences):
  cnt = 0
  for sent in sentences:
    if len(sent) <= max_len:
        cnt += 1
  print(f'전체 문장 중 길이가 {max_len} 이하인 샘플의 비율: {(cnt/len(sentences))*100)}')

for MAX in range(0, 200, 10):
    below_len(MAX, X_train)

 

 결과는 다음과 같다.

 

(상략)
>>> 전체 문장 중 길이가 30 이하인 샘플의 비율: 84.77484823774576
>>> 전체 문장 중 길이가 40 이하인 샘플의 비율: 89.65660958593821
>>> 전체 문장 중 길이가 50 이하인 샘플의 비율: 92.49977348917278
>>> 전체 문장 중 길이가 60 이하인 샘플의 비율: 94.19588656337773
>>> 전체 문장 중 길이가 70 이하인 샘플의 비율: 95.35924617196702
>>> 전체 문장 중 길이가 80 이하인 샘플의 비율: 96.10220168524054
>>> 전체 문장 중 길이가 90 이하인 샘플의 비율: 96.66394853674005
(하략)

 

 문장의 길이가 70 이상이 되면서 비율 증가세가 완만해진다. 최대 길이를 70으로 정하고, pad_sequences에 maxlen 옵션으로 넘긴다. truncating 옵션에 따라 패딩이 앞에 붙을지 뒤에 붙을지 결정할 수 있는데, 이 단계에서는 default를 적용해 앞에 패딩을 붙인다.

 

# define max_lentgh
MAX_LEN = 70

# pad
X_train = pad_sequences(X_train, maxlen = MAX_LEN)
X_test = pad_sequences(X_test, maxlen = MAX_LEN)

# 데이터 확인
print(X_train[103])
print(X_test[54])

 

 아무 데이터나 확인한 결과, 패딩이 잘 진행되었음을 알 수 있다.

 

>>> [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 775 4027 2030 4185 169 30 74 253 1782 8 8513 178 119 3640 610 13124]
>>> [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 194 194 2 244 234]

 


 

 이제 모델링을 진행하기 위한 앞 단의 작업이 모두 완료되었다.  train set의 입력 데이터, 라벨, test set의 입력 데이터, 라벨의 shape이 맞는지 확인한다.

 

# check data
print(X_train.shape)
print(y_train.shape)
print(X_test.shape)
print(y_test.shape)
print(X_predict.shape)

 

 

반응형