(2) [Tensorflow] 네이버 영화 리뷰 데이터를 활용한 기본적인 Text classification
한국어로 된 네이버 영화 리뷰 데이터를 학습시켜서 새로운 리뷰가 들어왔을 때
긍정적인 리뷰인지 부정적인 리뷰인지 (binary) 판단 가능하도록 하는 분류기를 만들어 보겠습니다.
학습 모델은 기존에 텍스트 분류에 좋다고 잘 알려진 LSTM이나 Pre-trained 된 모델들을 쓰지 않고
앞선 Image classification과 동일하게 간단한 Hidden layer 층만 가지고 진행해 보도록 하겠습니다.
wikidoc.net에 같은 데이터로 분류한 것이 있는데, 상당히 복잡하므로 여기서는 제외할 것은 제외하고 최대한 간단하게 진행하겠습니다.
먼저 쓰일 패키지들을 import 해줍니다.
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
import pandas as pd
import numpy as np
%matplotlib inline
import matplotlib.pyplot as plt
import re
import urllib.request
Okt(Open Korean Text)는 트위터에서 만든 한국어 형태소 분석기입니다.
패키지 설치 후 추가적으로 import 해줍니다.
!pip install konlpy
from konlpy.tag import Okt
다음은 네이버 영화 리뷰 데이터를 다운로드하여 pandas를 통해 DataFrame 형태로 변경해줍니다.
!git clone https://github.com/e9t/nsmc.git
urllib.request.urlretrieve("https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt", filename="ratings_train.txt")
urllib.request.urlretrieve("https://raw.githubusercontent.com/e9t/nsmc/master/ratings_test.txt", filename="ratings_test.txt")
train_data = pd.read_table('ratings_train.txt')
test_data = pd.read_table('ratings_test.txt')
train data를 한번 확인해보니, 정상적으로 데이터를 부른 것을 확인했습니다.
이 dataset은 train : 150000개, test : 50000개로 이루어져 있습니다.
train_data[:5]
train data 중에 중복이 있는지 확인합니다.
약 4000개 정도의 중복 리뷰가 있는 것을 확인할 수 있고 label은 0 또는 1 정상적으로 들어가 있음을 확인했습니다.
바로 중복 리뷰들을 제거합니다.
train_data['document'].nunique(), train_data['label'].nunique()
train_data.drop_duplicates(subset=['document'], inplace=True)
다음은 null값이 있는지 확인해보고, null값을 제거합니다.
다시 확인해보니 null값이 다 제거된 것을 확인했습니다. (False)
print(train_data.isnull().sum())
train_data = train_data.dropna(how = 'any')
print(train_data.isnull().values.any())
train data에 한글과 공백을 제외하고 모두 제거합니다.
일반적으로 unicode "[^ㄱ-ㅎㅏ-ㅣ가-힣 ]"으로 사용하면 커버가 가능합니다.
변경 후 ... 들이 없는 것을 보니 잘 적용이 되었습니다.
(참고)
https://www.unicode.org/charts/PDF/U3130.pdf
https://www.unicode.org/charts/PDF/UAC00.pdf
train_data['document'] = train_data['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","")
train_data[:5]
위와 같이 전처리를 진행했으면 특수문자만으로 쓰인 리뷰는 내용이 다 사라지므로 다시 null값이 발생할 수 있습니다.
다시 확인하니 789개가 있었고 이것도 제거해 줍니다.
train_data['document'] = train_data['document'].str.replace('^ +', "")
train_data['document'].replace('', np.nan, inplace=True)
print(train_data.isnull().sum())
train_data = train_data.dropna(how = 'any')
print(len(train_data))
train data에 적용하였던 전처리 들을 test data에도 동일하게 적용해줍니다.
test_data.drop_duplicates(subset = ['document'], inplace=True)
test_data['document'] = test_data['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","")
test_data['document'] = test_data['document'].str.replace('^ +', "")
test_data['document'].replace('', np.nan, inplace=True)
test_data = test_data.dropna(how='any')
print('전처리 후 테스트용 샘플의 개수 :',len(test_data))
이제부터는 토큰화를 하도록 하겠습니다.
stopwords(불용어)는 정의하기 나름이지만 주로 한국어의 조사, 접속사로 정의합니다.
다음으로는 okt를 통해 형태소 분석을 통해 토큰화를 진행할 것입니다.
okt.morphs의 stem을 True로 주면 '이런'이 '이렇다'로 되는 등 정규화처럼 변환시킬 수 있습니다.
두 가지 단계를 시행하여 X_train 리스트에 넣은 후 데이터를 확인해보겠습니다.
stopwords = ['의','가','이','은','들','는','좀','잘','걍','과','도','를','으로','자','에','와','한','하다']
okt = Okt()
X_train = []
for sentence in train_data['document']:
temp_X = okt.morphs(sentence, stem=True)
temp_X = [word for word in temp_X if not word in stopwords]
X_train.append(temp_X)
print(X_train[:3])
test data도 동일하게 적용해줍니다.
X_test = []
for sentence in test_data['document']:
temp_X = okt.morphs(sentence, stem=True)
temp_X = [word for word in temp_X if not word in stopwords]
X_test.append(temp_X)
다음으로는 토큰화 된 데이터 리스트를 기계가 학습할 수 있도록 정수 형식으로 인코딩하겠습니다.
먼저 각 단어에 고유한 Number를 부여합니다.
tokenizer = Tokenizer()
tokenizer.fit_on_texts(X_train)
빈도수가 낮은 단어들은 자연어 처리에서 배제하고자 합니다.
등장 빈도수가 3회 미만인 단어들이 이 데이터에서 얼마큼의 비중을 차지하는지 확인해봅시다.
단어의 등장 빈도수가 낮은 단어는 2%도 채 되지 않습니다.
threshold = 3
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 = rare_cnt + 1
rare_freq = rare_freq + value
print('단어 집합(vocabulary)의 크기 :',total_cnt)
print('등장 빈도가 %s번 이하인 희귀 단어의 수: %s'%(threshold - 1, rare_cnt))
print("단어 집합에서 희귀 단어의 비율:", (rare_cnt / total_cnt)*100)
print("전체 등장 빈도에서 희귀 단어 등장 빈도 비율:", (rare_freq / total_freq)*100)
전체 단어 개수 중 빈도수 2 이하인 단어는 제거합니다.
0번 padding 토큰을 고려하여 1을 더해줍니다.
최종적으로 모델을 학습시킬 때 첫 embedding에 들어가는 vocab_size는 19416개가 되었습니다.
vocab_size = total_cnt - rare_cnt + 1
print('단어 집합의 크기 :',vocab_size)
이제 keras의 Tokenizer를 통해 해당 인자들을 넘겨주면,
텍스트로 되어있던 시퀀스를 정수형 시퀀스로 변환해줍니다.
tokenizer = Tokenizer(vocab_size)
tokenizer.fit_on_texts(X_train)
X_train = tokenizer.texts_to_sequences(X_train)
X_test = tokenizer.texts_to_sequences(X_test)
조금 전에 했던 전처리에서 빈도수가 낮은 단어들로만 이루어져 있던 리뷰가 있었다면, empty가 되었을 것입니다.
문장의 길이가 0이면 제거해줍니다.
drop_train = [index for index, sentence in enumerate(X_train) if len(sentence) < 1]
X_train = np.delete(X_train, drop_train, axis=0)
y_train = np.delete(y_train, drop_train, axis=0)
print(len(X_train))
print(len(y_train))
리뷰의 길이의 분포를 한번 확인해보겠습니다. 리뷰 길이가 당연히 제각각입니다.
모델이 잘 처리할 수 있도록 리뷰의 길이를 특정 길이로 제한하는 작업이 필요합니다.
print('리뷰의 최대 길이 :',max(len(l) for l in X_train))
print('리뷰의 평균 길이 :',sum(map(len, X_train))/len(X_train))
plt.hist([len(s) for s in X_train], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()
길이가 max_len보다 작을 때 어느 정도의 비율을 가지는지 함수를 만들고,
확인해본 결과 30 정도가 적당함을 확인했고, data들의 max_len을 30으로 변경해줍니다.
def below_threshold_len(max_len, nested_list):
cnt = 0
for s in nested_list:
if(len(s) <= max_len):
cnt = cnt + 1
print('전체 샘플 중 길이가 %s 이하인 샘플의 비율: %s'%(max_len, (cnt / len(nested_list))*100))
max_len = 30
below_threshold_len(max_len, X_train)
X_train = pad_sequences(X_train, maxlen = max_len)
X_test = pad_sequences(X_test, maxlen = max_len)
드디어 전처리들이 다 끝나고 모델링 부분입니다.
vocab size는 19416이며 임베딩 벡터의 차원은 100으로 설정하였습니다.
GlobalAveragePooling1D층은 각 샘플에 대해 고정된 차원의 벡터를 반환해줍니다. 길이를 맞추기 위해 사용하였습니다.
간단하게 하기 위해 FC layer는 16개의 Hidden Unit만 부여하였고, binary 문제이기 때문에 sigmoid를 주었습니다.
model = keras.Sequential()
model.add(keras.layers.Embedding(vocab_size, 100, input_shape=(None,)))
model.add(keras.layers.GlobalAveragePooling1D())
model.add(keras.layers.Dense(16, activation='relu'))
model.add(keras.layers.Dense(1, activation='sigmoid'))
model.summary()
compile층을 쌓은 후 model을 돌려보았습니다.
다만 학습을 계속해도 train_loss는 떨어지지만 val_loss는 83%에서 더 이상 올라가지 않았습니다.
epoch 10 이하에서 가장 best_model이었으며 이 이상은 overfitting일 것으로 예상됩니다.
LSTM모델을 사용하면 성능이 2~3% 정도 더 올라감을 확인하였습니다.
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])
model.fit(X_train, y_train, epochs=20, batch_size=256, validation_split=0.2)
test data로 모델일 evaluate 해보도록 하겠습니다.
val과 동일하게 82% 정도로 나오는 것을 확인했습니다.
print("\n 테스트 정확도: %.4f" % (model.evaluate(X_test, y_test)[1]))
다음은 리뷰가 들어왔을 때, 바로 예측하여 긍정인지 부정인지 판단할 수 있는 함수를 하나 만들어보겠습니다.
전처리 방식을 그대로 적용해주고, 바로 예측하여 결과를 반환해주는 함수입니다.
def sentiment_predict(new_sentence):
new_sentence = okt.morphs(new_sentence, stem=True)
new_sentence = [word for word in new_sentence if not word in stopwords]
encoded = tokenizer.texts_to_sequences([new_sentence])
pad_new = pad_sequences(encoded, maxlen = max_len)
score = float(model.predict(pad_new))
if(score > 0.5):
print("{:.2f}% 확률로 긍정 리뷰입니다.\n".format(score * 100))
else:
print("{:.2f}% 확률로 부정 리뷰입니다.\n".format((1 - score) * 100))
몇 가지 실행 결과입니다.
구분하기 나름 어렵다고 생각되는 말들도 나름 잘 구분하는 것으로 확인되었습니다.
sentiment_predict('이 영화를 보고 암이 암에걸려 암이 나았습니다')
sentiment_predict('나만 볼수 없지')
sentiment_predict('몇번을 봐도 질리지 않는 영화')
sentiment_predict('영화관에서 2시간동안 자고나옴')
기본적인 text classification을 알아보았습니다.
확실히 영어보다는 한국어가 전처리 진행에 있어서 까다롭다는 것을 알 수 있습니다.
성능을 더 올리기 위해서는 잘 알려진 한국어 처리 NLP 모델을 사용하거나 (ex. Kobert)
전처리할 때 휴리스틱적으로 했던 부분들을 변경해보면 조금 성능이 올라갈 수 있을 것 같고
맞추지 못한 것들을 리스트화 하여 문제 상황에 맞게 적용한다면 더 좋은 성능을 낼 수 있을 것입니다.
text classification, 더 나아가 sentiment analysis를 하기 위해 연습할 수 있는 가벼운 예제였습니다.
출처 1) : https://www.tensorflow.org/tutorials/keras/text_classification
출처 2) : https://wikidocs.net/44249