본문 바로가기

Deep Learning/Natural Language Processing

Sentiment Analysis < Movie Comment > - (2)

 이로써 데이터의 분포와 각 리뷰에 대한 특성파악은 어느정도 충족이 되었다. 이를 바탕으로 새로운 자연어 데이터에 대해 그 의미를 파악해 긍정과 부정을 분류하는 모델을 구축한다. 자연어 처리는 토큰화, 인코딩 및 패딩과같은 정제가 선행되고 모델링을 거쳐 감성예측 모델링을 수행할 수 있다. 


4. Preprocessing

4.1 Tokenizing

def preprocessing(review, okt, remove_stopwords = False, stop_words = []):
 
    # 한글 및 공백을 제외한 이외의 문자 모두 제거
    review_text = re.sub("[^가-힣ㄱ-ㅎㅏ-ㅣ\\s]", "", review)
    
    # okt 객체를 활용해서 형태소 단위로 분리
    word_review = okt.morphs(review_text, stem=True)
    
    # 불용어 제거
    if remove_stopwords:
        word_review = [token for token in word_review if not token in stop_words]

    return word_review

 투입되는 데이터에 대해 전처리를 수행한다. 정규표현식(re)을 통해 특수문자 및 불필요한 텍스트들을 제거하고 형태소단위로 분리하는 Tokenizing 및 불용어 제거과정을 거친다. Stopwords는 사전에 지정할 수 있으며 remove_stopwords로 사용여부를 제어할 수 있다.

# train set의 모든 리뷰에 대해서 적용함
train_clean = []

for review in tqdm(train['document']):
  if type(review) == str:
    train_clean.append(preprocessing(review, okt, remove_stopwords = True, stop_words=stopwords))
  else:
     train_clean.append([])

# test set의 모든 리뷰에 대해서 적용함
test_clean = []

for review in tqdm(test['document']):
  if type(review) == str:
    test_clean.append(preprocessing(review, okt, remove_stopwords = True, stop_words=stopwords))
  else:
    test_clean.append([]) 

# 종속변수들을 행렬형태로 변환한다.
Y_train=train['label']
Y_test=test['label']

Y_train = np.array(Y_train)
Y_test = np.array(Y_test)

 For문을 통해 Train, Test셋의 모든 행에 대해 동일한 전처리를 적용하며 데이터의 크기가 커서 실행시간이 오래걸린다.

부가적으로 np.save기능을 이용해 전처리된 말뭉치를 저장하게되면 같은 분석을 다시 실행할때 np.load를 통해 불필요한 전처리 시간을 줄일 수 있다.

4.2 Encoding

# tokenize를 통해 단어들에 대해 각각 숫자를 부여한다.
tokenizer = Tokenizer()
tokenizer.fit_on_texts(train_clean)

print(tokenizer.word_index)

 딥러닝 모델은 텍스트 자체를 변수값으로 사용할 수 없다. 따라서 분리된 형태소들에 대해 숫자를 부여하여 단어들을 라벨링해주는 Encoding 작업이 필요하다. 이를 keras의 Tokenizer를 이용해 수행한다. word_index를 보게되면 위와 같이 딕셔너리 형태로 형태소들이 매핑된 일종의 단어 사전이 구축되었음을 알 수 있다.

threshold = 5
total = len(tokenizer.word_index) 
rare = 0 
total_ = 0 
rare_ = 0 

for key, value in tokenizer.word_counts.items():
    total_ = total_ + value

    if(value < threshold):
        rare = rare + 1
        rare_ = rare_ + value

print('단어 집합의 크기 :',total)
print('빈도가 %s번 이하인 희귀 단어 수: %s'%(threshold - 1, rare))
print("집합에서 희귀 단어 비율:", (rare / total)*100)
print("전체 빈도에서 희귀 단어 빈도 비율:", (rare_ / total_)*100)

 이렇게 정제된 Training셋과 Test셋 가운데 지나치게 리뷰가 짧거나 내용이 비어있다면, 해당 리뷰는 잘못된 리뷰이거나 감정분류에 혼선을 줄 우려가 높아진다. 따라서 단어 등장빈도를 측정하고 그 중에서 희귀단어의 빈도비율이 매우낮으면 이를 이상치로 판단하고 제거해줄 수 있다. 전체 단어인 43,747개의 단어중, 출현빈도가 5미만인 단어를 희귀 단어로 지정하였으며 전체 빈도중 이 희귀 단어빈도 비율은 약 3%밖에 되지 않는다. 

size = total - rare + 1 
print('전체 단어들 중 빈도가 낮은 희귀 단어를 제외한 단어 집합 크기 :',size)

 최종적인 size를 설정한다. Tokenizer 시행시 모든 단어들에대해 Encoding을 하는 것이 아닌, 몇개의 단어를 토큰화할 것인지에 대한 기준점을 설정한다. Tokenizer 함수의 num_words의 인자로 사용될 것인데, 이는 빈도를 기준으로 주어진 size에 대해 단어사전을 구축하므로 rare를 전체에서 제외하게되면 희귀단어들은 자연스레 단어사전 구축에서 제거된다.

# train셋과 test셋 모두 토큰화를 진행한다.
tokenizer = Tokenizer(size) 
tokenizer.fit_on_texts(train_clean)

token_train = tokenizer.texts_to_sequences(train_clean)
token_test = tokenizer.texts_to_sequences(test_clean)

 선택된 size로 다시 Tokenizer를 시행한다. 주의할 점은 Training셋에 기반해 fit_on_texts를 실행한뒤 fitting된 Tokenizer로 Test셋에 texts_to_sequences를 적용해야한다. Training셋을 통해 Test셋을 예측하는 머신러닝의 기본원리에 따라, Training셋의 단어사전과 Test셋의 단어사전은 같은 사전을 이용해야한다.

# 리뷰의 길이가 0인 리뷰들은 모두 제거
def delete_process(reviews_train, reviews_test,train_label,test_label):
  drop1=[index for index, sentence in enumerate(reviews_train) if len(sentence) < 1]
  drop2=[index for index, sentence in enumerate(reviews_test) if len(sentence) < 1]
  
  reviews_train = np.delete(reviews_train, drop1, axis=0)
  train_label = np.delete(train_label, drop1, axis=0)
  
  reviews_test = np.delete(reviews_test, drop2, axis=0)
  test_label = np.delete(test_label, drop2, axis=0)

  return reviews_train,reviews_test,train_label, test_label
token_train, token_test, Y_train, Y_test = delete_process(token_train,token_test,Y_train,Y_test)

4.3. Padding

 전처리에 있어서 마지막으로 max_len, 즉 리뷰의 최대길이를 산정해 리뷰들의 길이를 모두 동일하게 맞춰주는 Padding 과정이 필요하다. 전체 리뷰의 평균길이와 어느정도의 산포를 가지는지 확인한후 적절한 최대 길이를 설정하는 것이 선행되어야한다.

print('--- Train Case ---')
print('Max length :',max(len(l) for l in token_train))
print('Mean length :',sum(map(len, token_train))/len(token_train))

print('\n--- Test Case ---')
print('Max length :',max(len(l) for l in token_test))
print('Mean length :',sum(map(len, token_test))/len(token_test))

plt.figure(figsize=[12,8])
plt.hist([len(s) for s in token_train], bins=50, alpha=0.5, color="b", label="Train lenght")
plt.hist([len(s) for s in token_test], bins=50, alpha=0.5, color="r", label="Test lenght")

plt.xlabel('length')
plt.ylabel('count')
plt.legend()
plt.show()

def under(max_len, list):
    a = 0
    for s in list:
        if(len(s) <= max_len):
            a = a + 1
    print('길이가 %s 이하인 비율: %s'%(max_len, (a / len(list))*100))
    
    
max_len = 25
print('--- Train Case ---')
under(max_len, token_train)

print('--- Test Case ---')
under(max_len, token_test)

 데이터셋의 리뷰길이에 대한 분포를 확인해보면 Training셋과 Test셋이 매우 유사한 분포를 보여주어 매우 이상적이라고 할 수 있다. 두 케이스의 리뷰 모두 평균길이는 10.26 정도로 유사하다.

 최대길이를 25로 설정했을때, 이를 초과하는 리뷰들은 약 7.5%를 차지한다. 절대적인 최대길이에 대한 기준은 정해져있지않으나, 해당 길이 정도이면 리뷰의 감성분석에있어서 필요한 정보들을 대부분 구분했을 것으로 예상된다. 따라서 최대길이를 25로 설정하고 이를 초과하게되면 초과하는 부분의 단어는 제외, 모자란 부분은 0으로 채워주는 Padding 작업을 실시한다. 마찬가지로 Training셋과 Test셋에 동일한 max_len의 적용이 필수적이다.

# Padding
X_token_train = pad_sequences(token_train, maxlen = max_len)
X_token_test = pad_sequences(token_test, maxlen = max_len)

X_token_train

Final Data

5. Modeling

 Custom으로 Model을 정의한다. Embedding, LSTM, Dropout, Dense Layer를 통해 Model을 구성하고 각 Layer에 속한 Hyper-parameter들은 외부로부터 setting이라는 인자를 받아 처리하게된다.

# Custom Model
class LSTMClassifier(tf.keras.Model):
  def __init__(self, **setting):
    super(LSTMClassifier, self).__init__(name=setting['model_name'])
    self.embedding = layers.Embedding(input_dim=setting['vocab_size'],
                                 output_dim=setting['embedding_size'])
    self.dropout = layers.Dropout(setting['dropout_rate'])
    self.dense = layers.Dense(units=setting['output_dimension'],
                       activation=tf.keras.activations.sigmoid)
    self.lstm = layers.LSTM(units=setting['lstm_size'])

    
  def call(self, x):
    x = self.embedding(x)
    x = self.lstm(x)
    x = self.dropout(x)
    x = self.dense(x)
    
    return x
model_name = 'LSTM_Analyzer'
BATCH_SIZE = 100
NUM_EPOCHS = 10
VALID_SPLIT = 0.2

setting = {
    'model_name': model_name,
    'vocab_size': size,
    'embedding_size': 100,   # 임베딩을 100차원으로 정의
    'dropout_rate': 0.3,    # 과적합을 방지하고자 Dropout을 통해 무작위로 30%를 drop
    'output_dimension': 1,   # 출력층 정의
    'lstm_size': 128  # 128개의 노드를 가지는 은닉층을 구성
}
model = LSTMClassifier(**setting)
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])

 Model의 optimizer는 rmsprop, loss는 0과 1의 이진분류로써 binary_crossentropy를 사용하며, 평가지표로는 정확도를 사용한다. 언급했듯이 setting 딕셔너리를 통해 학습양상에따라 모델의 Hyper-parameter들을 조정할 수 있다.

# Train data로 학습시키며, 해당 과정에서 20%를 validation data로 분할하여 val_accuracy를 측정
history = model.fit(X_token_train, Y_train, batch_size=BATCH_SIZE, epochs=NUM_EPOCHS,validation_split=VALID_SPLIT)

Fitting Process

model.evaluate(X_token_test,Y_test)[1]

Final Test Accuracy

 마지막 epoch에 val_acc는 0.8452에 도달한다. Epoch가 진행됨에 따라 Training acc 지속적으로 증가하나, val_acc는 뚜렷한 증가추세를 보이지는 못해 약간의 과적합에 대한 우려가 보인다. 따라서 10번의 Epoch로 설정하였으며 해당모델은 Test셋에 대해 약 84.47%의 정확도로 올바른 감정으로 분류하고 있음을 볼 수 있다. 더 높은 정확도를 확보하기위해서는 Layer, node, batch size, dropout rate등의 Hyper-parameter들을 조정하거나 Bidirectional RNN을 도입해보는 등의 시도가 효과적일 수 있다.

6. Applying Function Module

 다음은 학습된 모델을 토대로 실제 다양한 구문을 대입하여 예측을 실행하기위한 함수모듈이다. 기존 모델에 투입하기 위한 전처리를 새로운 데이터에 대해 수행하고 모델에 삽입 후 예측하고, 해당 확률값을 표시한다. 다르게 말해 해당 함수에는 현재까지 모델링을 위한 전처리가 동일하게 적용되어있다.

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('시간이 너무 안가서 혼났다. 정말 이게 무슨 영화인가 싶다..')

Result

 단순히 긍정적인 단어들만이 삽입되었을때 뿐만 아니라, 긍정과 부정의 단어들이 혼재한 경우 역시 리뷰의 성격을 잘 파악하고 있다. 이는 사전에 실시한 Word Coherence 분석에서의 빈도가 높은 단어들만을 기반으로 단순히 모델이 단어들을 암기하는 학습한 것이 아니라, 문맥적인 의미나 단어간의 연계들을 파악하여 학습하였음을 보여준다.