본문 바로가기

ML & AI Theory

RNN 실전실습 : 시퀀스 모델링을 위한 다층 RNN구현 : IMDB 영화리뷰 구현

반응형

다음은 다대일(Many-to-Once)구조로 감성 분석을 위한 다층 RNN을 구현해 보겠습니다.

 


1.데이터준비

 

import pyprind
import pandas as pd
from string import punctuation
import re
import numpy as np

df = pd.read_csv('./data/aclImdb/movie_data.csv', encoding='utf-8')

데이터프레임 df에는 'review'와 'sentiment' 두 개의 컬럼이 있습니다. RNN모델을 만들어서 시퀀스 단어를 처리하고 마지막에 전체 시퀀스를 0또는 1클래스로 분류하겠습니다.

 

신경망에 주입할 입력 데이터를 준비하기 위해 텍스트를 정수 값으로 인코딩해야 합니다. 이를 위해 전체 데이터셋에서 고유한 단어를 먼저 찾아야 합니다. 파이썬의 집합을 사용할 수 있지만 대규모 데이터셋에서 고유한 단어를 찾는 데 집합을 사용하는 것은 효율적이지 않습니다. 더 좋은 방법은 collection패키지에 있는 Counter를 사용하는 것입니다.


참고! Counter 클래스는 파이썬 딕셔너리의 서브클래스로 반복 가능한 객체(문자열 또는 리스트)나 매핑된 객체(딕셔너리)에서 원소를 카운트하여 딕셔너리에 저장합니다. Counter클래스를 사용하는 예를 간단히 살펴보겠습니다.

 

from collections import Counter
c = Counter()
c.update('abc')

print(c)
print(c['c'])

c.update(['a','b'])
print(c)
c.update({'c':3})
print(c)
c.most_common()

결과는 아래와 같습니다.

Counter({'a': 1, 'b': 1, 'c': 1})
1
Counter({'a': 2, 'b': 2, 'c': 1})
Counter({'c': 4, 'a': 2, 'b': 2})
[('c', 4), ('a', 2), ('b', 2)]

 

다음 코드에서 Counter클래스의 counts객체를 정의하고 텍스트에 있는 모든 고유한 단어의 등장 횟수를 수집합니다. 특히 이 애플리케이션은 (BoW 모델과 달리) 고유한 단어의 집합만 관심 대상이고 부수적으로 생성된 단어 카운트는 필요하지 않습니다.

 

그다음 데이터셋의 고유한 각 단어를 정수 숫자로 매핑한 딕셔너리를 만듭니다. 이 word_to_int 딕셔너리를 사용하여 전체 리뷰 텍스트를 정수 리스트로 변환하겠습니다. 고유한 단어가 카운트 순으로 정렬되어 있지만 순서는 최종 결과에 영향을 미치지 않습니다. 

from collections import Counter

counts = Counter()
pbar = pyprind.ProgBar(len(df['review']), title='단어의 등장 횟수를 카운트 합니다.')
for i, review in enumerate(df['review']):
    text = ''.join([c if c not in punctuation else ' ' + c + ' ' for c in review]).lower()
    df.loc[i, 'review'] = text
    pbar.update()
    counts.update(text.split())
    
    
##고유한 각 단어를 정수로 매핑하는 딕셔너리를 만듭니다.
word_counts = sorted(counts, key=counts.get, reverse=True)
print(word_counts[:5])
word_to_int = {word: ii for ii, word in enumerate(word_counts, 1)}

mapped_reviews = []
pbar = pyprind.ProgBar(len(df['review']), title='리뷰를 정수로 매핑합니다.')
for review in df['review']:
    mapped_reviews.append([word_to_int[word] for word in review.split()])
    pbar.update()

지금까지 단어의 시퀀스를 정수 시퀀스로 변환했습니다. 한 가지 풀어야 할 문제가 있습니다. 이 시퀀스들은 길이가 서로 다릅니다. RNN구조에 맞도록 입력 데이터를 생성하려면 모든 시퀀스가 동일한 길이를 가져야 합니다.

 

이를 위해 sequence_length 파라미터를 정의하고 200으로 값을 설정합니다. 200개의 단어보다 적은 시퀀스는 왼쪽에 0으로 패딩될 것입니다. 반대로 200개의 단어보다 긴 시퀀스는 마지막 200개의 단어만 사용하도록 잘라 냅니다. 

 

1. 행 길이가 시퀀스 크기 200에 해당하는 행렬을 만들고 0으로 채웁니다.

2. 행렬 오른쪽부터 시퀀스의 단어 인덱스를 채웁니다. 시퀀스 길이가 150이면 이 행의 처음 50개의 원소는 0으로 남게 됩니다.

 

사실 sequence_length는 하이퍼파라미터이므로 최적의 성능을 위해 튜닝해야 합니다. 

sequence_length = 200
sequences = np.zeros((len(mapped_reviews), sequence_length), dtype=int)

for i, row in enumerate(mapped_reviews):
    review_arr = np.array(row)
    sequences[i, -len(row):] = review_arr[-sequence_length]

 

데이터셋을 전처리한 후 데이터를 훈련 세트로 나뉩니다. 이미 데이터가 무작위로 섞여 있기 때문에 처음 75%를 훈련 세트로 선택하고 나머지 25%를 테스트 세트로 사용합니다. 

X_train = sequences[:37500,:]
y_train = df.loc[:37499, 'sentiment'].values
X_test = sequences[37500:,:]
y_test = df.loc[37500:, 'sentiment'].values

print(X_train, y_train, X_test, y_test)
n_words = len(word_to_int) + 1
print(n_words)

 


2.임베딩

 

데이터 준비 단계에서 동일한 길이의 시퀀스를 생성했습니다. 이 시퀀스의 원소는 고유한 단어의 인덱스에 해당하는 정수 숫자입니다.

이런 단어 인덱스를 입력 특성을 변한하는 몇 가지 방법이 있습니다. 간단하게 원-핫 인코딩을 적용하여 인덱스를 0또는 1로 이루어진 벡터로 변환할 수 있습니다. 각 단어는 전체 데이터셋의 고유한 단어의 수에 해당하는 크기를 가진 벡터로 변환합니다. 고유한 단어이 수가 2만 개라면 입력 특성 개수는 2만 개가 됩니다.

 

이렇게 많은 특성에서 훈련된 모델은 차원의 저주(curse of dimensionality)(참조)로 인한 영향을 받습니다. 또 하나를 제외하고 모든 원소가 0이므로 특성 벡터가 매우 희소해집니다.

 

좀 더 고급스러운 방법은 각 단어를 실수 값을 가진 고정된 길이의 벡터로 변환하는 것입니다. 원-핫 인코딩과 달리 고정된 길이의 벡터를 사용하여 무한히 많은 실수를 표현할 수 있습니다. 

 

임베딩(embedding)이라고 하는 특성 학습 기법을 사용하여 데이터셋에 있는 단어를 표현하는 데 중요한 특성을 자동으로 학습할 수 있습니다. 고유한 단어 수를 unique_words라고 하면 고유 단어의 수보다 훨씬 작게(embedding_size << unique_words) 임베딩 벡터 크기를 선택하여 전체 어휘를 입력 특성으로 나타냅니다.

 

원-핫 인코딩에 비해 임베딩의 장점은 다음과 같습니다.

  • 특정 공간의 차원이 축소되므로 차원의 저주로 인한 영향을 감소시킵니다.
  • 신경망에서 임베딩 층이 훈현되기 때문에 중요한 특성이 추출됩니다.

 

텐서플로에는 고유한 단어에 해당하는 정수 인덱스를 훈련 가능한 임베딩 행으로 매핑해 주는 tf.keras.layers.Embedding 클래스가 구현되어 있습니다. 예를 들어 정수 1이 첫 번째 행으로 매핑되고, 정수 2는 두 번째 행에 매핑되는 식입니다. <0, 5, 3, 4, 19, 2.....>처럼 정수 시퀀스가 주어지면 시퀀스의 각 원소에 해당하는 행을 찾습니다.

 

 

이제부터 실제 임베딩 층을 어떻게 만드는지 알아보겠습니다. Sequential 모델을 만들고 [n_words x embedding_size]크기의 Embedding층을 추가하면 됩니다.

from tensrflow.keras import models, layers
model = models.Sequential()
model.add(layers.Embedding(n_words, 200, embeddings_regularizer='12'))

Embedding클래스의 첫 번째 매개변수는 입력 차원으로 어휘 사전의 크기가 됩니다. 앞서 word_to_int 크기에 1을 더해 n_words를 구했습니다. 두 번째 매개변수는 출력 차원입니다. 여기서는 200차원의 벡터로 단어를 임베딩합니다.

다른 층과 마찬가지로 임베딩 층도 가중치를 규제할 수 있는 매개변수를 지원합니다. 이 예제에서는 L2규제를 추가했습니다. 가중치 초기화는 기본적으로 균등 분포를 사용합니다. embeddings_initializer매개변수에서 다른 초기화 방법을 지정할 수 있습니다.

 

임베딩 층을 추가한 후에 summary메소드로 모델 구조를 출력해 보겠습니다.

model.summary()

 

임베딩 층의 출력은 3차원 텐서입니다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 타임 스텝입니다. 마지막 차원이 임베딩 벡터의 차원입니다.  앞서 n_words크기가 10만 2967이었으므로 200차원을 곱하면 전체 모델 파라미터의 개수는 2059만 3400입니다.

Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_1 (Embedding)      (None, None, 200)         20593400  
=================================================================
Total params: 20,593,400
Trainable params: 20,593,400
Non-trainable params: 0
_________________________________________________________________

 


RNN 모델 만들기

 

이제 본격적으로 RNN층을 추가할 차례입니다. 여기서는 긴 시퀀스를 학습하는데 유리한 tf.karas.ayers.LSTM 층을 사용하겠습니다. 이 LSTM층은 16개의 순환 유닛을 사용합니다.

 

model.add(layers.LSTM(16))

LSTM층의 첫 번째 매개변수는 유닛 개수입니다. 나머지 매개변수는 모두 기본값을 사용합니다. 몇 가지 언급할 만한 매개변수로 actiation매개변수는 히든 상태(층의 출력)에 사용할 활성화 함수를 지정합니다. 기본값은 'tanh'입니다. recurrent_activation은 셀 상태에 사용할 활성화 함수를 지정합니다. 기본값은 'hard_sigmoid'입니다.


hard_sigmoid는 다음과 같이 정의됩니다.

  • x<-2.5일 때는 0
  • x >2.5일 때는 1
  • -2.5 <= x <= 2.5일 때는 0.2*x+0.5

hard_sigmoid를 사용하는 이유는 계산 효율성 때문입니다. 최신 cuDNN라이브러리가 시그모이드 함수를 기본으로 지원하기 때문에 향후 버전에서 CPU버전과 모두 기본값이 sigmoid로 바뀔 수 있습니다.

 


순환층에도 드롭아웃을 추가할 수 있습니다. dropout매개변수는 히든 상태를 위한 드롭아웃 비율을 지정하며, recurrent_dropout은 셀 상태를 위한 드롭아웃 비율을 지정합니다. 기본값은 0입니다.

 

기본적으로 순환층은 마지막 타임 스텝의 히든 상태만 출력합니다. 이는 마지막 출력 값을 사용하여 모델을 평가하는 데 사용하기 때문입니다. 만약 두 개 이상의 순환층을 쌓는다면 아래층에서 만든 모든 스텝의 출력이 위층 입력으로 전달되어야 합니다. 이렇게 하려면 return_sequences매개변수를 True로 지정해야 합니다

 

순환층을 추가한 후에는 출력층에 연결하기 위해 펼쳐야 합니다. 앞서 합성곱 신경망에서 보았던 것과 매우 유사합니다. 감성 분석은 긍정 또는 부정 리뷰를 판단하는 것이므로 출력층의 유닛은 하나이고, 활성화 함수는 시그모이드 함수를 사용합니다. 

model.add(layers.Flatten())
model.add(layers.Dense(1, activation='sigmoid'))

 

순환 신경망 모델을 만들었습니다! 완전 연결 신경망이나 합성곱 신경망을 만들었을 때보다 많이 어렵지 않습니다. 전체 모델 구조를 summary메소드로 출력해 보겠습니다.

model.summary()

 

Model: "sequential_4"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding_4 (Embedding)      (None, None, 200)         20593400  
_________________________________________________________________
lstm_1 (LSTM)                (None, 16)                13888     
_________________________________________________________________
flatten_1 (Flatten)          (None, 16)                0         
_________________________________________________________________
dense_1 (Dense)              (None, 1)                 17        
=================================================================
Total params: 20,607,305
Trainable params: 20,607,305
Non-trainable params: 0
_________________________________________________________________

 


감성 분석 RNN 모델 훈련

 

모델 구성을 완료했으므로 Adam옵티마이저를 사용하여 모델을 컴파일하도록 하겠습니다. 감성 분석 문제는 이진 분류 문제이므로 손실 함수는 binary_crossentropy로 지정합니다. 

model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['acc'])

 

가장 좋은 검증 점수의 모델 파라미터를 체크포인트로 저장하겠습니다. 또 텐서보드를 위한 출력을 지정하겠습니다. 

import time
from tensorflow.keras.callbacks import ModelCheckpoint, TensorBoard

callback_list = [ModelCheckpoint(filepath='./data/log/sentimnt_rnn_checkpoint.h5', monitor='val_loss', save_best_only=True),
                 TensorBoard(log_dir='./data/log/og/datasentiment_rnn_log/{}'.format(time.asctime()))
                ]

 

이제 모델을 훈련할 단계입니다. 배치 크기는 64로 지정하고 열 번 에포크 동안 훈련하게습니다. validation_split 매개변수를 0.3으로 지정하여 전체 훈련 세트의 30%를 검증 세트로 사용합니다.

history = model.fit(X_train, y_train, batch_size=64, epochs=10, validation_split=0.3, callbacks=callback_list)

 

fit메소드에서 반환된 history객체에서 손실과 정확도를 추출하여 그래프로 그려 보겠습니다. 먼저 손실 점수에 대한 그래프를 그립니다.

 

import matplotlib.pyplot as plt

epochs = np.arange(1,11)
plt.plot(epochs, history.history['loss'])
plt.plot(epochs, history.history['val_loss'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

import matplotlib.pyplot as plt

epochs = np.arange(1,11)
plt.plot(epochs, history.history['acc'])
plt.plot(epochs, history.history['val_acc'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

 


감성 분석 RNN 모델 평가

 

훈련 과정에서 만들어진 최상의 체크포인트 파일을 복원하여 테스트 세트에서 성능을 평가해 보겠습니다. 체크포인트를 복원하려면 모델의 load_weights메서드를 사용합니다.

model.load_weights('./data/log/sentiment_rnn_checkpoint.h5')
model.evaluate(X_test, y_test)

 

evaluate메서드는 기본적으로 손실 점수를 반환합니다. 만약 compile메서드의 metrics매개변수에 측정 지표를 추가했다면 반환되는 값이 늘어납니다. 앞서 반환된 결과의 첫 번째 원소는 손실 점수고, 두 번째는 정확도입니다. LSTM층 하나로 테스트 세트에서 87%정도의 정확도를 달성했습니다. 

[0.475051611661911, 0.8720800280570984]

 

샘플의 감성 분석 결과를 출력하려면 predict메서드를 사용합니다. 이전 장에서 보았듯이 predict메서드는 확률 값을 반환합니다. 감성 분석 예제는 이진 분류 문제이므로 양성 클래스, 즉 긍정 리뷰일 확률을 반환합니다. Sequential클래스는 predict메서드와 동일하게 확률을 반환하는 predict_proba메서드를 제공합니다. 

 

model.predict_proba(X_test[:10])

 

다중 분류에서는 가장 큰 확률의 레이블이 예측 클래스가 되고 이진 분류 문제에서는 0.5보다 크면 양성 클래스가 됩니다. 간단하게 0.5보다 큰 값을 구분할 수 있지만 Sequential클래스는 친절하게 이를 위한 predict_classes메소드를 제공합니다.

 

model.predict_classes(X_test[:10])

최적화를 위해 LSTM층의 유닛 개수, 타임 스텝의 길이, 임베딩 크기 같은 모델으 하이퍼파라미터를 튜닝하면 더 높은 일반화 성능을 얻을 수 있습니다.  주의할 점은 테스트 데이터를 사용하여 편향되지 않은 성능을 어으려면 평가를 위해 테스트 세트를 반복적으로 사용하면 안 된다는 점에 주의하여야 합니다. 

반응형