1. 개발환경 세팅하기
저는 로컬에서 miniconda로 가상환경을 생성하고, 프로젝트를 진행하였습니다.
형태소 분석기로는 이전 프로젝트를 진행했을 때, 비교적 효과가 좋았던 kiwipiepy(https://github.com/bab2min/kiwipiepy)를 사용하였고, 이외의 필요한 라이브러리는 다음과 같습니다.
pip install kiwipiepy
conda install numpy pandas matplotlib scikit-learn tensorflow
2. 네이버 쇼핑 리뷰 데이터 다운로드 및 전처리
주로 제품 리뷰 유튜브 댓글이 타겟인 감성 분석 모델을 생성하기 위해서
네이버 쇼핑 리뷰 데이터를 통해 학습을 진행하였습니다.
다운로드 : https://github.com/bab2min/corpus/tree/master/sentiment
import pandas as pd
import numpy as np
import re
import matplotlib.pyplot as plt
import urllib.request
from kiwipiepy import Kiwi
from collections import Counter
from sklearn.model_selection import train_test_split
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
1) 데이터 로드하기
위의 링크로부터 네이버 쇼핑 리뷰 데이터에 해당하는 naver_shopping.txt를 다운로드 받습니다.
urllib.request.urlretrieve("https://raw.githubusercontent.com/bab2min/corpus/master/sentiment/naver_shopping.txt", filename="naver_shopping.txt")
해당 데이터를 Data Frame으로 불러오고 columns을 명시합니다. 총 데이터 갯수는 200000개입니다.
df = pd.read_csv("naver_shopping.txt", sep="\t", header=None, encoding="utf-8-sig")
df.columns = ['score', 'reviews']
print(len(df))
df.head()
2) 훈련 데이터와 테스트 데이터 분리하기
갖고 있는 데이터는 긍•부정의 레이블을 갖고 있지 않기 때문에, 평점이 4, 5인 리뷰에는 레이블 1을, 평점이 1,2인 리뷰에는 레이블 0 을 저장합니다. (주어진 데이터에 3점은 존재하지 않습니다.)
df['label'] = np.select([df.score > 3], [1], default=0)
df.head()
각 열의 중복된 리뷰와 한글이 아닌 문자는 제거합니다.
df.drop_duplicates(subset=['reviews'], inplace=True) # Drop duplicate reviews
print(df.isnull().values.any())
print(len(df))
df['reviews'] = df['reviews'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣]", "")
df['reviews'].replace('', np.nan, inplace=True)
print(df.isnull().sum())
이렇게 생성된 데이터를 훈련데이터와 시험데이터로 분류합니다.
train_data, test_data = train_test_split(df, test_size=0.2, random_state=97)
print("Train Reviews : ", len(train_data))
print("Test_Reviews : ", len(test_data))
train_data['label'].value_counts().plot(kind='bar')
print(train_data.groupby('label').size().reset_index(name = 'count'))
test_size와 random_state는 원하는 대로 바꾸셔도 무방하나, test_size의 경우 일반적으로 7:3 혹은 8:2로 split 합니다.
훈련 데이터의 레이블 분포를 보았을 때, 각각 약 80000개로 50:50 비율을 갖고 있습니다.
3) 토큰화 및 정제
형태소 분석기 kiwi를 사용하여 토큰화 작업을 수행하고, 불용어에 포함된 단어들은 제외합니다.
# Tokenize
kiwi = Kiwi()
stopwords = ['도', '는', '다', '의', '가', '이', '은', '한', '에', '하', '고', '을', '를', '인', '듯', '과', '와', '네', '들', '듯', '지', '임', '게']
def tokenizing(sentence):
try:
if not sentence:
raise ValueError
words = [token[0] for token in kiwi.tokenize(sentence) if token[0] not in stopwords]
except ValueError as e:
print(e)
return words
train_data['tokenized']=train_data['reviews'].apply(tokenizing)
print("train finished")
test_data['tokenized']=test_data['reviews'].apply(tokenizing)
print("test finished")
4) 리뷰의 단어와 길이 분포 확인하기
negative_words = np.hstack(train_data[train_data.label == 0]['tokenized'].values)
positive_words = np.hstack(train_data[train_data.label == 1]['tokenized'].values)
부정적 리뷰의 word counter 결과입니다.
negative_word_count = Counter(negative_words)
print(negative_word_count.most_common(20))
긍정적 리뷰의 word counter 결과입니다.
positive_word_count = Counter(positive_words)
print(positive_word_count.most_common(20))
긍•부정 리뷰의 길이를 비교한 결과입니다.
fig,(ax1,ax2) = plt.subplots(1,2,figsize=(10,5))
text_len = train_data[train_data['label']==1]['tokenized'].map(lambda x: len(x))
ax1.hist(text_len, color='red')
ax1.set_title('Positive Reviews')
ax1.set_xlabel('length of samples')
ax1.set_ylabel('number of samples')
print('긍정 리뷰의 평균 길이 :', np.mean(text_len))
text_len = train_data[train_data['label']==0]['tokenized'].map(lambda x: len(x))
ax2.hist(text_len, color='blue')
ax2.set_title('Negative Reviews')
fig.suptitle('Words in texts')
ax2.set_xlabel('length of samples')
ax2.set_ylabel('number of samples')
print('부정 리뷰의 평균 길이 :', np.mean(text_len))
plt.show()
상대적으로 부정리뷰의 평균 길이가 높은 것을 볼 수 있습니다.
X_train = train_data['tokenized'].values
Y_train = train_data['label'].values
X_test = test_data['tokenized'].values
Y_test = test_data['label'].values
5) 정수 인코딩
기계가 텍스트를 숫자로 처리할 수 있도록 훈련 데이터와 시험 데이터에 정수 인코딩을 수행해야 합니다. 훈련 데이터에 대해서 단어 집합(vocaburary)를 keras.preprocess.texts의 Tokenizer로 만들어 봅시다.
tokenizer = Tokenizer()
tokenizer.fit_on_texts(X_train)
print(len(tokenizer.word_index))
전처리기를 통해 추출된 corpus를 vector 공간에 위치시키기 위해서 dict 형식으로 index를 매긴 것입니다.
총 35844개의 index가 생성되었습니다.
threshold=2
total_cnt = len(tokenizer.word_index)
rare_cnt = 0
total_freq = 0
rare_freq = 0
for key, value in tokenizer.word_counts.items():
total_freq = total_freq + value
if value < threshold:
rare_cnt += 1
rare_freq = rare_freq + value
print("Size of Vocabulary :", total_cnt)
print(f'등장빈도가 {threshold-1}번 이하인 희귀 단어의 수 : {rare_cnt}')
print("단어 집합에서 희귀 단어의 비율 : ", (rare_cnt / total_cnt)*100)
print('전체 등장 빈도에서 희귀 단어 등장 빈도 비율 :', (rare_freq/total_freq)*100)
35844개의 단어 중 등장빈도가 threshold 값인 2회 미만. 즉, 1회인 단어들은 단어 집합에서 56%를 차지합니다.
하지만, 실제로 훈련 데이터에서 등장 빈도로 차지하는 비중은 0.77%로 중요하지 않다고 판단하여, 정수 인코딩 과정에서 배제 시킵니다.
등장 빈도수가 1인 단어들의 수를 제외한 단어의 개수를 단어 집합의 최대 크기로 제한합니다.
vocab_size = total_cnt - rare_cnt + 2
print('단어 집합의 크기 : ', vocab_size)
단어 집합의 크기는 15,562개입니다. 이를 토크나이저의 인자로 넘겨주면 이보다 큰 숫자가 부여된 단어들은 OOV로 변환됩니다. 또한 tokenizer에 정수 인코딩된 text는 추후 저장된 모델을 가지고 감성 분석을 할때, 다시 쓰이므로 json으로 저장하게 되는데 이는 뒤에 저장하겠습니다.
# 정수 인코딩 과정에서 vocab_size 보다 큰 숫자가 부여된 단어들은 OOV로 변환
# Out Of Vocabulary
tokenizer = Tokenizer(vocab_size, oov_token='OOV')
tokenizer.fit_on_texts(X_train)
X_train = tokenizer.texts_to_sequences(X_train)
X_test = tokenizer.texts_to_sequences(X_test)
6) 패딩
서로 다른 길이의 샘플들의 길이를 동일하게 마줘주는 패딩 작업을 진행하겠습니다.
전체 데이터의 길이 분포는 다음과 같습니다.
print('리뷰의 최대 길이 :',max(len(review) for review in X_train))
print('리뷰의 평균 길이 :',sum(map(len, X_train))/len(X_train))
plt.hist([len(review) for review in X_train], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()
def below_threshold_len(max_len, nested_list):
count = 0
for sentence in nested_list:
if len(sentence) <= max_len:
count += 1
print(f'길이가 {max_len}이하인 샘플의 비율 {(count)/len(nested_list)*100}')
max_len = 75
below_threshold_len(max_len, X_train)
최대 길이가 77이므로 75로 패딩할 경우 99.99%의 길이를 갖기 때문에 훈련용 리뷰를 길이 75이하로 패딩합니다.
X_train = pad_sequences(X_train, maxlen=max_len)
X_test = pad_sequences(X_test, maxlen=max_len)
7) GRU로 네이버 쇼핑 리뷰 감성 분류하기
하이퍼파라미터인 임베딩 벡터의 차원은 100, 은닉 상태의 크기는 128입니다. 모델은 다 대 일 구조의 LSTM를 사용합니다. 해당 모델은 마지막 시점에서 두 개의 선택지 중 하나를 예측하는 이진 분류 문제를 수행하는 모델입니다. 이진 분류 문제의 경우, 출력층에 로지스틱 회귀를 사용해야 하므로 활성화 함수로는 시그모이드 함수를 사용하고, 손실 함수로 크로스 엔트로피 함수를 사용합니다. 하이퍼파라미터인 배치 크기는 64이며, 15 에포크를 수행합니다.
EarlyStopping(monitor='val_loss', mode='min', verbose=1, patience=4)는 검증 데이터 손실(val_loss)이 증가하면, 과적합 징후므로 검증 데이터 손실이 4회 증가하면 정해진 에포크가 도달하지 못하였더라도 학습을 조기 종료(Early Stopping)한다는 의미입니다. ModelCheckpoint를 사용하여 검증 데이터의 정확도(val_acc)가 이전보다 좋아질 경우에만 모델을 저장합니다. validation_split=0.2을 사용하여 훈련 데이터의 20%를 검증 데이터로 분리해서 사용하고, 검증 데이터를 통해서 훈련이 적절히 되고 있는지 확인합니다. 검증 데이터는 기계가 훈련 데이터에 과적합되고 있지는 않은지 확인하기 위한 용도로 사용됩니다.
from tensorflow.keras.layers import Embedding, Dense, GRU
from tensorflow.keras.models import Sequential
from tensorflow.keras.models import load_model
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
embedding_dim = 100
hidden_units = 128
model = Sequential()
model.add(Embedding(vocab_size, embedding_dim))
model.add(GRU(hidden_units))
model.add(Dense(1, activation='sigmoid'))
es = EarlyStopping(monitor='var_loss', mode='min', verbose=1, patience=4)
mc = ModelCheckpoint('best_model.h5', monitor='val_acc', mode='max', verbose=1, save_best_only=True)
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
history = model.fit(X_train, Y_train, epochs=15, callbacks=[es, mc], batch_size=64, validation_split=0.2)
저는 15 epoch를 모두 실행되었습니다.
loaded_model = load_model('best_model.h5')
print('테스트 정확도 : %.4f' % (loaded_model.evaluate(X_test, Y_test)[1]))
테스트정확도는 91%로 비교적 높은 정확도를 갖고 있습니다.
def sentiment_predict(new_sentence:str):
new_sentence = re.sub('r[^ㄱ-ㅎㅏ-ㅣ가-힣]', '', new_sentence)
new_sentence = tokenizing(new_sentence)
encoded = tokenizer.texts_to_sequences([new_sentence])
pad_new = pad_sequences(encoded, maxlen = max_len)
score = float(loaded_model.predict(pad_new))
if score > 0.5:
print("{:.2f}% 확률로 긍정 리뷰입니다.".format(score*100))
else:
print("{:.2f}% 확률로 부정 리뷰입니다.".format((1-score)*100))
레이블 값이 1일 확률에 대한 결과로 score가 반환되기 때문에 0.5보다 낮으면 레이블 값이 0 일 확률이 높다는 의미이므로, 이를 통해 긍•부정을 표현 할 수 있습니다.
sentiment_predict('이 상품 진짜 좋아요... 저는 강추합니다. 대박')
sentiment_predict('진짜 배송도 늦고 개짜증나네요. 뭐 이런 걸 상품이라고 만듬?')
앞서 말했던 Tokenizer에 fit_text된 데이터를 json 형식으로 저장합니다.
# Save Tokenizer pad_sequence
import json
with open("tokenize.json", "w") as json_file:
json.dump(tokenizer.to_json(), json_file)
딥러닝을 활용한 자연어 처리 입문 위키독스의 예제를 따라가면서 실행해본 결과입니다.추후 사용된 LSTM(GRU)기법에 대해 알아보면서 다른 기법을 사용하면 성능을 향상시킬지, parameter tunning을 통해 성능을 더욱 높일 수 있는지에 대해 고민하고자 합니다.
특히 저장된 모델을 다시 사용하기 위해서 fit_texts 데이터를 저장해야 되며, 이를 통해서 지속적인 모델 학습을 이룰 수 있지 않을까? 생각해 보았습니다.
다음 글은 Youtube 댓글 API 사용법을 통해 감성분석 모델을 적용하는 방법을 포스팅 하도록 하겠습니다.
댓글은 항상 환영입니다. 감사합니다.
[참조 : https://github.com/bab2min/corpus/tree/master/sentiment]
[참조 : https://wikidocs.net/94600]