10장 RNN - 딥러닝을 이용한 문서 분류

히나·2024년 5월 29일

들어가기에 앞서


카운트 기반의 문서 표현은 통계를 통해 문서를 이해하려는 시도라고 할 수 있습니다. 반면 딥러닝사람처럼 단어 순서를 반영해 문맥을 이해하는 방식으로 작동합니다. RNN은 문장을 이해하기 위해 단어의 순서를 고려하는 가장 직관적인 모형이라고 할 수 있습니다.

10.1 왜 RNN일까?

10.1.1 RNN의 이해

우리말로 순환 신경망이라고도 부르는 RNN시계열 데이터를 다루기 위한 모형으로 알려져 있습니다. 시계열 데이터어떤 변수의 값이 시간에 따라 변화하는 것을 말합니다.

이와 같이 일정한 시간 간격으로 측정한 시계열 데이터에는 특정한 규칙성이나 패턴이 있는 경우에, 이러한 패턴을 기반으로 다음 값을 예측하는 것이 가능합니다. 시계열 예측 모형에서 가장 중요한 가정은 앞의 값들이 뒤의 값에 영향을 미친다는 것입니다.

사인 그래프

위의 그림과 같은 사인 곡선이 있다고 가정해봅시다. 앞에 주어진 값이 하나라면 현재 값이 증가되고 있는지 감소하고 있는지 알기 어렵습니다. 그러나 일정한 간격의 값 두 개가 있다면 값의 증감 여부를 알 수 있습니다.

시계열 모형에서는 입력으로 일정 기간의 연속된 값을 사용합니다.

RNN이 아닌 일반적인 회귀모형이나 신경망은 이렇게 입력값 사이에 순차적으로 미친 영향을 모형이 반영하지 못합니다. RNN은 바로 이러한 순차적인 영향을 표현하기 위한 모형입니다. 아래 그림은 RNN 모형에 대한 가장 일반적인 그림으로, 입력값들이 앞에서부터 순차적으로 다음 값에 미치는 영향의 구조를 은닉층에 구현한 예를 보여줍니다.

RNN 모형

위 그림을 식으로 표현하면 아래와 같습니다.

st=tanh(Uxt+Wst1+bs)s_{t}=tanh(Ux_{t}+Ws_{t-1}+b_{s}), ot=Vst+b0o_{t}=Vs_{t}+b_{0}

위 식에서 xtx_{t}tt 시점의 입력을 말합니다. 그렇다면 과연 RNN 신경망 모형은 몇 개일까요? xtx_{t}하나가 신경망 모형의 한 입력일까요? 아니면 xt1x_{t-1}, xtx_{t}, xt+1x_{t+1} 셋일까요?

결론부터 말하자면 본인이 정한 수의 입력입니다.

또 한 가지 유의할 점은 xtx_{t}가 단일 값일 수도 있으나, 벡터일 수도 있다는 것입니다.

위 그림에서 앞의 값들은 은닉층에 있는 노드를 통해 다음 상태에 영향을 미칩니다. xt1x_{t-1}의 입력값st1s_{t-1}로 압축되고, 이 값은 xtx_{t}의 정보가 압축된 은닉 노드 sts_{t}에 영향을 미칩니다. 이와 같이 시계열의 앞 값들이 뒤에 미치는 영향은닉층의 노드들 간의 연결을 통해서 구현됩니다. sts_{t}는 다시 st+1s_{t+1}에 영향을 미치므로, 이러한 의존성은 앞에서부터 계속 축적된다고 이해하면 됩니다.

RNN의 출력인 oo는 모형을 통해 예측하고 싶은 값, 혹은 라벨로 구성하면 됩니다. 즉, xtx_{t}까지의 이전 관측값을 이용해 xt+1x_{t+1}을 예측하고자 하는 것이므로, oto_{t}xt+1x_{t+1}의 라벨이 됩니다. 위 그림에서는 모든 은닉층의 노드에 대해 출력 노드가 있지만, 이것은 목적에 따라 다르게 구성할 수 있습니다.

일반적인 RNN 모형

위 그림을 보면 출력 노드마지막 은닉 노드로부터 하나만 사용하고 있습니다. 이것은 항상 네 개의 연속된 입력을 이용해 그 이후의 값을 예측하겠다는 의도를 보여주는데, 가장 일반적인 RNN 모형이라고 할 수 있습니다.

10.1.2이 문서 분류에 적합한 이유

RNN의 전형적인 예는 시계열입니다. 이것은 RNN이 앞에서부터 순차적으로 미치는 영향을 계속 축적하는 모형이기 때문입니다. 우리가 문맥을 이해하는 것도 이와 유사합니다. 상대방이 하는 말을 순차적으로 들으면서 의미를 이해하죠.

다시 말해서 문맥은 단어들의 순서를 통해서 형성되는데, RNN은 구조상 이와 같은 문맥을 파악하기 좋은 모형입니다. 앞 단어로부터 문맥에 관한 정보가 마지막 노드까지 순차적으로 축적되면 그 정보를 이용해 문서를 분류하면 됩니다. 그림 10.3에서 입력층의 네 개의 노드가 각각 I, am, so, happy라고 가정해봅시다. 가장 먼저 I가 초기 은닉 노드 값과 결합되어 은닉 노드로 변환되면 이 은닉 노드와 am으로부터 다음 은닉 노드가 생성 되고, 이것이 반복되면 최종 은닉 노드에 네 단어의 정보가 순차적으로 모두 축적됩니다. 이를 이용해 출력 노드를 생성해 감성이 긍정인지 부정인지 판단할 수 있다.

10.1.3 RNN의 문서 분류 적용방안

순환신경망은 주어진 입력값들의 순사가 의마가 있을 때, 즉 입력값이 앞의 입력값들에 영향을 받는 경우에 사용하는 신경망으로, 시계열 값의 분석 등에서 활발하게 사용합니다. 자연어 처리에서는 문장을 이루는 단어들의 순서를 반영하기 위해 사용되는데, 근본적으로 문장은 단어들의 순서에 따라 문맥이 결정되므로 문맥 파악에 순환 신경망이 사용되었다고 할 수 있습니다.

RNN 적용 예시

위는 문서 분류에 RNN을 적용하는 예시 입니다. 각 단어는 먼저 원핫 벡터(one-hot vector)로 표현 되었다가 짧은 길이의 밀집 벡터로 다시 변환되어 RNN 모형에 입력으로 사용됩니다.

각 단어의 정보는 은닉 노드로 압축되고 이 과정에서 앞 단어가 뒤 단어에 미치는 영향순차적으로 축적되어 전달된다. 문서를 분류하려면 단어 6개의 정보를 전부 사용해야 하므로, 출력 노드는 모든 정보가 축적된 마지막 은닉 노드에 연결된 것만 사용합니다. 즉 마지막 출력 노드를 이용해 문서를 분류하면 됩니다.

10.2 워드 임베딩의 이해

위에서 단어를 RNN 모형에 입력으로 사용하려면 원핫 벡터로 표현하고, 다시 짧은 길이의 밀집 벡터로 변환한다고 했습니다. 왜 이렇게 하는 것일까요? 딥러닝에서는 기본적으로 문서를 단어의 시퀸스로 표현하고, 각 단어들은 고정된 길이의 벡터가 되어야 합니다. 그래야 처리가 가능하기 때문입니다. 이러한 과정을 워드 임베딩이라고 합니다.

10.2.1 워드 임베딩이란?

우리가 갖고 있는 대부분의 분석 방법론이 수치 형태의 데이터를 다루므로, 범주형 데이터를 분석하려면 먼저 이를 수치로 변환해야 합니다. 일반적으로 범주형 데이터를 수치로 변환하는 방법으로 더미 변수의 이용과 임베딩이 있습니다. 더미 변수는 0과 1만으로 범주형 데이터를 표현합니다.

이와 같이 범주형 데이터를 벡터 형태의 연속된 수치를 변환하는 것을 머신러닝에서는 원한 인코딩이라고 하고, 원핫 벡터라고 칭합니다. 단어에 대해 원핫 인코딩을 수행하면 말뭉치에 사용된 단어 수만큼 더미 변수가 필요합니다. 문서를 카운트 기반의 벡터로 변환했을 때에도 매우 희소한 벡터가 생성되었는데, 단어는 그보다 훨씬 심각하게 희소한 벡터가 됩니다. 왜냐하면 2만 개의 값 중에 오직 하나의 변수만 1의 값을 가지기 때문입니다.

임베딩은 범주형 데이터를 연속적인 값을 갖는, 상대적으로 작은 크기의 벡터로 변환하는 작업을 의미합니다. 워드 임베딩은 보통 1)단어에 대해 원핫 인코딩을 먼저 수행하고 이를 다시 2)연속형 값을 갖는 축소된 벡터로 변환하는 과정을 의미합니다. 기존 연구 결과를 보면, 이때 축소된 벡터의 크기는 100~300 정도이고, 이러한 벡터를 희소 벡터의 반대 개념인 밀집 벡터라고 합니다. 즉, 수만 개의 더미 변수가 100~300개의 연속형 변수로 압축하는 대신 대부분의 변수가 0이 아닌 연속적인 값을 갖습니다. 아래 그림은 위 RNN 모형에서 워드 임베딩 부분만 다시 그린 것입니다.

RNN모형에서의 워드 임베딩

그렇다면 이와 같은 임베딩은 왜 하는 것일까요?

  1. 차원이 큰 원핫 벡터를 그대로 쓰면 연산이 비효율적입니다.

  2. 임베딩을 하면 대상 간의 의미적 유사도를 계산할 수 있게 됩니다.
    단어 사이에는 분명 어떤 유사도가 존재합니다. 예를 들어 사과는 과일이라는 점에서 책상보다 귤에 더 가깝습니다. 이러한 유사도를 표현하고 계산할 수 있다면 이러한 잘못된 가정들이 사라집니다.

  3. 단어가 의미적인 정보를 함축함으로써 연산이 가능해질 수 있습니다.
    Word2Vec을 통해 단어간의 관계를 이용함으로써 연산을 통한 일종의 추론이 가능해집니다.

  4. 전이학습을 가능하게 합니다.
    전이학습은 기존에 학습된 모형을 새로운 환경에서 재사용함으로써 학습의 속도와 효과를 높이는 방법입니다. 임베딩의 과정 중 학습된 가중치 행렬을 이용하여 밀집 벡터를 계산합니다.

10.2.2 BOW와 문서 임베딩

BOW 혹은 카운트 기반의 문서 표현에서 문서는 사용된 단어들의 빈도를 벡터 형태로 표현합니다. 카운트 기반의 문서 표현에서는 희소 벡터를 밀집 벡터로 변환하는 과정이 없으므로 넓게 보면 이것도 임베딩이라고 볼 수 있습니다.

그러나 카운트 기반의 문서 표현은 코사인 유사도와 같은 방법으로 희소 벡터 자체로 유사도를 계산할 수 있습니다. 따라서 어느 정도 문서의 의미가 반영되어있으므로 임베딩이라고 봐도 무방할 것 같습니다. 다만 단어가 아닌 문서 단위로 임베딩이 이루어집니다. 이 과정에서 단어의 순서 정보를 잃어버리므로 사실상 문맥에 대한 파악은 이뤄지지 않는다고 할 수 있습니다.

10.2.3 워드 임베딩과 딥러닝

대부분의 딥러닝 기반의 자연어 처리 기법에서는 문서를 단어의 시퀸스로 표현합니다. 따라서 문서를 직접 임베딩하기보다는 단어를 임베딩하고, 임베딩된 단어의 시퀸스로 문서를 표현합니다. BOW와의 가장 큰 차이점은 바로 단어의 순서를 고려하여 문맥을 파악한다는 점입니다. 이로 인해 문서에 대한 표현에서도 큰 차이가 발생하는데, BOW에서는 문서가 1차원 벡터로 표현되는 반면, 워드 임베딩을 이용하게 되면 문서가 2차원 행렬 혹은 1차원 벡터의 리스트로 표현됩니다. 이는 워드 임베딩 결과가 이미 1차원 벡터이기 때문입니다. 말뭉치로부터 입력값을 만들면 여러 개의 문서를 변환해서 결과적으로 3차원 행렬이 됩니다.

워드 임베딩에 기반한 딥러닝 자연어 처리의 핵십은 단어의 순서로부터 어떻게 문맥정보를 추출해낼지에 달려있다고 할 수 있습니다. RNN은 시계열과 같은 순서 패턴을 분석하므로 단어가 쓰여진 순서의 패턴으로 문맥을 파악할 수 있을 것입니다.

10.3 RNN을 이용한 문서 분류 - NLTK 영화 리뷰 감성분석

RNN을 이용한 문서분류를 실습하기 위해 우리가 익숙한 데이터를 활용해봅시다. NLTK가 제공하는 영화 리뷰에 대한 감성 분석을 해보기로 합시다. RNN 모형을 설계할 때는 출력을 명확하게 하는 것이 가장 중요합니다. 출력의 형태는 RNN 모형의 뒤 단과 손실 함수를 결정합니다.

감성분석은 긍정과 부정 중 하나를 선택하는 이진 분류 문제이므로, 신경망의 최종 출력단 크기는 1, 활성화 함수는 시그모이드가 됩니다. 또한 손실 함수는 binary_crossentropy를 사용합니다.

10.3.1 워드 임베딩을 위한 데이터 준비

분석의 첫 단계로 nltk 데이터를 가져와서 리뷰는 reviews에, 긍정 부정에 대한 라벨은 categories에 저장합니다.

import nltk
nltk.download('movie_reviews')

from nltk.corpus import movie_reviews
fileids = movie_reviews.fileids() 

reviews = [movie_reviews.raw(fileid) for fileid in fileids]

categories = [movie_reviews.categories(fileid)[0] for fileid in fileids]

print('Reviews count :', len(reviews))
print('Length of the first review ', len(reviews[0]))
print('Labels ', set(categories))

실행 결과

Reviews count : 2000
Length of the first review  4043
Labels  {'pos', 'neg'}

RNN 모형은 케라스를 이용해 구축합니다. 따라서 케라스가 제공하는 토크나이저를 사용해 RNN 모형에 적합한 형태로 입력 데이터를 변환합니다. 케라스가 제공하는 토크나이저는 기본적인 토큰화 기능 외에, 토큰에 인덱스를 부여하여 사전을 작성하고, 이 인덱스를 이용해 주어진 문장을 변환하는 기능을 제공합니다.

아래 예에서 Tokenizer객체를 생성할 때 num_words를 이용하여 모형에 사용할 단어 수를 결정할 수 있습니다. fit_on_texts()를 수행하면 주어진 말뭉치에 대해 단어 인덱스 사전이 구축되고, texts_to_sequences()는 이사전을 이용하여 문서들을 인덱스의 시퀀스로 반환합니다. 이때 실제 말뭉치에 사용된 단어 수가 num_words에 지정된 값보다 큰 경우에는, 사전에 포함되지 않는 단어를 빼고 시퀀스를 생성합니다.

이 단어들을 생략하고 싶지 않다면 oov_token 매개변수를 이용하여 단어들을 변환할 문자열을 할당할 수 있고, 이 경우에는 사전에 없는 단어들이 생략되는 대신 공통의 값으로 변환되어 돌아갑니다.

또한 사전에서 인덱스 0은 사용되지 않습니다. 왜냐하면 null값 처럼 나중에 길이가 짧은 문서에 채워 넣을 값으로 0을 사용하기 때문입니다.

import numpy as np
import tensorflow as tf
from tensorflow.keras.preprocessing.text import Tokenizer

np.random.seed(7)
tf.random.set_seed(7)

max_words = 10000 

tokenizer = Tokenizer(num_words = max_words, oov_token='UNK')
tokenizer.fit_on_texts(reviews)

X = tokenizer.texts_to_sequences(reviews)
print('Lengths of first 10 documents:', [ len(doc) for doc in X[:10]])

실행 결과

Lengths of first 10 documents: [710, 240, 486, 479, 733, 671, 550, 584, 702, 776]

토크나이저가 생성한 사전에서 단어에 대한 인덱스를 확인하고 싶다면 Tokenizer객체의 word_index()를 사용하면 됩니다.

import numpy as np

print("Index of 'the'", tokenizer.word_index["the"])
print("Index of 'review'", tokenizer.word_index["review"])
print("Index of out-of-vocabulary words", tokenizer.word_index["UNK"])

실행 결과

Index of 'the' 2
Index of 'review' 466
Index of out-of-vocabulary words 1

각 리뷰의 길이가 다르므로 위에서 확인한 바와 같이 문서로부터 변환된 인덱스 시퀀스의 길이도 가변적일 수밖에 없습니다. 그러나 신경망 모형의 입력은 길이가 일정해야 하므로, 다음과 같이 pad_sequences()를 이용하여 입력 시퀀스의 길이를 매개변수 maxlen의 인수만큼 동일하게 변환합니다.

이때 시퀀스의 크기가 maxlen보다 크면 남는 부분을 잘라내야 하는데, truncating 매개변수는 잘라낼 부분의 위치를 지정합니다. pre면 시퀀스의 앞을 잘라내고, post면 뒤를 잘라냅니다. 크기가 maxlen보다 작으면 남는 부분을 0으로 채웁니다.

from tensorflow.keras.preprocessing.sequence import pad_sequences
maxlen = 500

X = pad_sequences(X, maxlen=maxlen, truncating='pre')

이제 케라스 모형에 맞게 입력을 변환했다면 이제 출력도 케라스 신경망 모형에 맞게 변환합니다. NLTK의 라벨은 긍정과 부정을 각각 pos, neg 문자열로 표현하고 있는데, 케라스의 이진분류 모형은 0과 1의 값을 요구하므로 아래와 같이 라벨을 변경합니다.

import numpy as np

label_dict = {'pos': 1, 'neg':0} # label 값 변환
y = np.array([label_dict[c] for c in categories])
print(set(y))

실행 결과

{0, 1}

모형을 검증하기 위해 다음과 같이 학습 데이터셋과 테스트 데이터셋으로 분리합니다.

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=10)

print('Train set count:', len(X_train))
print('Test set count:', len(X_test))
print('Test samples:', y_test[:20])

실행 결과

Train set count: 1600
Test set count: 400
Test samples: [0 1 1 0 1 1 0 0 0 0 1 1 0 0 1 0 0 1 1 1]

10.3.2 RNN이 아닌 일반적인 신경망 모형을 이용한 분류

본격적으로 RNN 모형을 구축하기 전에, 단어 임베딩에 대해 이해하고 RNN의 효과를 비교하기 위해 먼저 로지스틱 회귀 모형을 구현해서 시퀀스 정보 없이 예측을 수행해봅니다. 케라스가 제공하는 Sequential모형은 단순하게 순차적으로 층을 쌓아서 신경망 모형을 구성하는 데 활용할 수 있는 쉽고 단순한 도구입니다. 층이 순차적으로 쌓이지 않는 복잡한 모형이 아니라면 Sequential모형으로 쉽고 빠르게 신경망 모형을 만들 수 있습니다.

아래 예제는 임베딩 레이어, Flatten() 레이어 그리고 Dense() 레이어로 이루어진 시퀀셜 모형을 만드는 예시입니다.

먼저 임베딩 레이어는 RNN을 위해 만든 입력을 받아 원핫 인코딩과 워드 임베딩을 수행하는 레이어입니다. 현재 문서는 500개의 단어로 이루어져있고, 총 사용 단어는 10,000개로 지정되어 있습니다.

워드 임베딩은 원핫 인코딩된 단어들을 일정한 크기의 밀집 벡터로 변환합니다. 아래 예에서는 벡터의 크기를 32로 지정하고 있습니다. 즉 크기가 10,000이었던 단어 벡터는 32의 크기로 압축되는데, 이를 위해서는 10,000 * 32만큼의 파라미터, 혹은 가중치 변수가 요구됩니다. 모든 단어에 동일한 가중치를 적용하여 워드 임베딩을 수행하므로 문서 크기인 500은 파라미터 수에 반영되지 않습니다. 모형을 만들고 summary() 메서드를 이용하면 아래와 같이 층별로 출력의 모양과 파라미터의 수를 확인할 수 있습니다.

아래 예에서 볼 수 있듯이 워드 임베딩을 한 결과는 (단어의 수, 단어를 표현하는 밀집 벡터의 크기)의 2차원 행렬이 됩니다. 아래 Output Shape의 가장 앞에 있는 None은 학습에 사용되는 입력의 수를 나타냅니다. 한 번의 학습에 사용되는 입력의 수는 가변적이므로, 모형을 정의하는 단계에서는 설정하지 않습니다.

Flatten()레이어는 2차원 행렬의 형태로 된 값을 펼쳐서 1차원 벡터로 변환합니다. 형태만 변경하므로 별도의 파라미터는 사용하지 않습니다. Flatten()으로 500개의 단어에 대한 각 32개의 값들을 펼쳐서 총 500*32개의 독립적인 입력 변수로 변환한 후, 이를 이용해 이진 로지스틱 회귀분석을 실시합니다. 이렇게 하면 순서를 유지하며 문서를 표현한 보람이 없으나, RNN과의 비교 및 워드 임베딩을 위해 해보는 것으로 합니다.

마지막의 Dense()레이어는 일반적인 신경망의 은닉층으로 알고 있는, 모든 노드들이 연결되는 밀집 층을 생성합니다. 여기서는 출력의 크기를 1로 하고 활성화 함수로 sigmoid를 지정하는데, 이는 감성분석을 위해 이진 분류를 해야 하기 때문입니다. 이렇게 하면 감성의 확률을 나타내는 0과 1 사이의 값 하난를 출력합니다.

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Flatten, Dense, Embedding

model = Sequential([
    Embedding(max_words, 32, input_length=maxlen),
    Flatten(),
    Dense(1, activation='sigmoid')
])

model.summary() # 모델 요약 정보!

실행 결과

Model: "sequential_1"
_______________________________________________________________

 Layer (type)                Output Shape              Param #   
===============================================================
 embedding_1 (Embedding)     (None, 500, 32)           320000    
                                                                 
 flatten_1 (Flatten)         (None, 16000)             0         
                                                                 
 dense_1 (Dense)             (None, 1)                 16001     
                                                                 
===============================================================
Total params: 336001 (1.28 MB)
Trainable params: 336001 (1.28 MB)
Non-trainable params: 0 (0.00 Byte)
______________________________________________________________

모형이 만들어졌다면 이제 학습을 수행해봅시다. 학습을 하려면 손실 함수와 옵티마이저를 설정해야 합니다. 모형의 compile() 메서드를 이용해서 이 둘을 지정할 수 있습니다. 이진 분류이므로 손실 함수는 binary_crossentropy로 지정하고, 옵티마이저는 많이 사용되는 'rmsprop'을 사용합니다. metrics 매개변수는 모형을 평가하기 위해 기본적으로 사용하는 loss 외에 추가로 사용할 지쵸를 설정하기 위해 사용합니다. 이 값을 주지 않으면 loss만 출력됩니다.

fit()메서드는 학습 데이터셋을 이용하여 학습을 수행합니다. 학습을 위한 입력과 라벨을 반드시 지정해야 하벼, epochs는 학습의 반복 횟수, verbose는 학습 진행 중 학습 현황의 추력 여부, validation_split은 학습을 하면서 사용할 검증 데이터셋의 비율을 지정합니다.

아래 예에서는 학습을 10번 수행하고, 학습 도중 현황을 출력하도록 했으며, 검증 데이터셋의 비율은 학습 데이터셋의 20%로 설정했습니다.

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

history = model.fit(X_train, y_train,
    epochs=10,
    verbose=1,
    validation_split=0.2)

학습이 종료되면 성능을 그래프로 그려 잘 되었는지 확인해봅시다.

%matplotlib inline
import matplotlib.pyplot as plt

def plot_results(history, metric):
    plt.plot(history.history[metric], 'b', label='Training ' + metric)
    plt.plot(history.history['val_'+metric], 'r--', label='Validation ' + metric)
    plt.title('Training and validation '+metric)
    plt.xlabel('Epochs')
    plt.ylabel(metric)

plot_results(history, 'acc')

실행 결과
실행 결과

위 그래프를 보면 학습 데이터셋에 대한 정확도는 에포크 4 정도에서 최대치를 도달하고, 검증 데이터셋에 대한 정확도는 4 이후에도 조금씩 향상되고 있지만 큰 진전은 없는 것을 볼 수 있습니다.

또한 두 데이터셋에 대한 정확도 격차가 큰 편이어서 과대적합의 가능성이 있어보입니다. 마지막으로 최종성능을 평가해봅시다.

score = model.evaluate(X_test, y_test)
print(f'#Test accuracy:{score[1]:.3f}')

실행 결과

13/13 [==============================] - 0s 3ms/step - loss: 0.6403 - acc: 0.6650
#Test accuracy:0.665

최종 성능은 저자는 72.3%, 필자는 66.5%로 저자가 기존에 만들어 둔 BOW기반의 로지스틱 회귀 분석이 보인 성능인 85.5%에 비해 많이 떨어집니다. 단어를 500개로 제한한 것, 검증 데이터셋으로 인해 학습 데이터셋의 20%가 학습에 사용되지 못한 것, 과대적합, 단어 순서 사라짐 등을 원인으로 생각할 수 있을 것입니다.

10.3.3 문서의 순서 정보를 활용하는 RNN 기반 문서 분류

이제 RNN 모형을 적용함으로써 문백의 정보를 활용한 문서 분류를 해 봅시다. 아서 사용한 모형과 가장 큰 차이점은 SimpleRNN() 레이어를 사용함으로써 가장 기본적인 RNN 층을 추가하는 것입니다.

이전 모형과 또 다른 점으로 SimpleRNN() 레이어 뒤에 Dense() 레이어를 추가했는데, 최종 노드에 대해 은닉층 하나를 추가해 모형의 성능을 높인 것입니다. 속도를 위해 활성화 함수는relu로 지정했습니다.

from tensorflow.keras.layers import SimpleRNN
from tensorflow.keras.optimizers import Adam
model = Sequential([
    Embedding(max_words, 32),
    SimpleRNN(32),
    Dense(32, activation='relu'),
    Dense(1, activation='sigmoid')
])

model.summary()

adam = Adam(learning_rate=1e-4)
model.compile(optimizer=adam, loss='binary_crossentropy', metrics=['acc'])
history = model.fit(X_train, y_train,
            epochs=10,
            verbose=0,
            validation_split=0.2)

plot_results(history, 'acc')

score = model.evaluate(X_test, y_test)

print(f'Test accuracy:{score[1]:.3f}')

실행 결과

Model: "sequential_3"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 embedding_3 (Embedding)     (None, None, 32)          320000    
                                                                 
 simple_rnn_1 (SimpleRNN)    (None, 32)                2080      
                                                                 
 dense_4 (Dense)             (None, 32)                1056      
                                                                 
 dense_5 (Dense)             (None, 1)                 33        
                                                                 
=================================================================
Total params: 323169 (1.23 MB)
Trainable params: 323169 (1.23 MB)
Non-trainable params: 0 (0.00 Byte)
_________________________________________________________________
13/13 [==============================] - 1s 28ms/step - loss: 0.7201 - acc: 0.5125
Test accuracy:0.512

실행 결과

위 결과를 보면 학습이 거의 이뤄지지 않은 것을 볼 수 있습니다. 학습 데이터셋에 대한 정확도 1.0 근처까지 꾸준하게 올라갔으나, 검증 데이터셋에 대한 정확도는 0.5 근처에서 벗어나지 못하고 있습니다. 즉 과대적합만 이뤄지고 있을 뿐 실제 학습은 거의 되지 않고 있습니다.

왜 이런 현상이 일어나는 것일까요? 가장 중요한 원인은 딥러닝의 가장 큰 문제점인 경사소실에 있습니다.

10.4 LSTM, Bi-LSTM과 GRU를 이용한 성능 개선

LSTM의 목적은 장기의존성과 단기의존성을 모두 표현하고 학습하는 것입니다. 구조적 원리는 감쇠, 즉 기억 손실이 없는 컨베이어벨트를 추가로 만들었다고 요약할 수 있습니다. 중요한 것은 LSTM은 장기 기억정보를 추가함으로써 장기의존성을 학습한다는 개념입니다.

GRU는 LSTM을 간소화한 모형으로 계산량이 적고 속도가 빠르며 좋은 성능을 나타내는 것으로 알려져 있습니다.

RNN을 개선하는 또 다른 방법으 양방향으로 모형을 구축하는 것입니다. 사실 언어를 살펴보면 뒤의 단어가 앞의 단어에 영향을 미치기도 합니다. 즉 이러한 역방향을 함께 구현하기 위해 사용되는 것이 양방향 LSTM입니다. 즉 두 개 층의 LSTM을 구현하고 각 층의 은닉 노드를 연결해서 완전한 은닉 노드를 만듭니다.

케라스를 이용하면 이렇게 이론적으로 복잡하고 어려운 내용도 쉽게 구현할 수 있습니다. LSTM을 적용하고 싶다면 SimpleRNN 대신에 LSTM을 임포트해서 쓰면 됩니다. 또한 양방향 LSTM을 적용하고 싶다면 다음 예제와 같이 Bidirectional로 LSTM 레이어로 감싸주면 됩니다.

다음 예에서는 워드 임베딩 벡터의 크기를 64로 늘리고, 양방향 LSTM을 구현했습니다.

from tensorflow.keras.layers import LSTM, Bidirectional
model = Sequential([
Embedding(max_words, 64),
Bidirectional(LSTM(64)),
Dense(64, activation='relu'),
Dense(1, activation='sigmoid')
])

model.summary()

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

history = model.fit(X_train, y_train,
                    epochs=8,
                    verbose=0,
                    validation_split=0.2)

plot_results(history, 'acc')

score = model.evaluate(X_test, y_test)
print(f'#Test accuracy:{score[1]:.3f}')

실행 결과

Model: "sequential_5"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 embedding_6 (Embedding)     (None, None, 64)          640000    
                                                                 
 bidirectional_2 (Bidirecti  (None, 128)               66048     
 onal)                                                           
                                                                 
 dense_8 (Dense)             (None, 64)                8256      
                                                                 
 dense_9 (Dense)             (None, 1)                 65        
                                                                 
=================================================================
Total params: 714369 (2.73 MB)
Trainable params: 714369 (2.73 MB)
Non-trainable params: 0 (0.00 Byte)
_________________________________________________________________
Total params: 714369 (2.73 MB)
Trainable params: 714369 (2.73 MB)
Non-trainable params: 0 (0.00 Byte)
_________________________________________________________________
13/13 [==============================] - 2s 80ms/step - loss: 0.9426 - acc: 0.7475
#Test accuracy:0.748

실행 결과
실행 결과

위 결과를 보면 검증 데이터셋에 대한 정확도가 저자는 0.8 수준, 필자는 1.0 수준까지 올라가는 것을 볼 수 있습니다. 테스트 데이터셋에 대한 정확도가 0.74까지 올라갔지만, 문맥에 대한 학습을 통해 훨씬 좋은 성능이 나올것이라는 기대에 미치지는 못했습니다.

여기에는 몇 가지 이유가 있는데, 우선 딥러닝은 더 많은 데이터를 요구합니다. 워드 임베딩 단계에서 각 단어들은 말뭉치 안에서 의미를 학습하여 밀집 벡터로 변환하는데, 말뭉치가 충분하지 않으면 제대로 학습이 이뤄지지 않습니다. NLTK는 학습 데이터가 고작해야 2000개의 문서밖에 되지 않으며, 학습 데이터 셋으로 1600개가 할당되었으며, 다시 그 안에서 검증 제이터셋 20%를 제외하면 1280개의 문서만으로 학습을 해야 합니다.

이를 개선하기 위해서 워드 임베딩 벡터에 대해 학습된 가중치를 Word2Vec과 같은 기존이 학습된 모형으로부터 가져올 수 있다. 그러나 이 또한 항상 좋은 결과를 보인다고 하기 어려운데, 기존의 학습된 모형은 일반적인 문서들을 대상으로 학습된 반면, 현재의 모형은 영화 리뷰에 대한 감성 분석이라는 특별한 목표를 갖고 있기 때문입니다.

다만 RNN을 이용한 분류에 해당하는 부분은 여전히 학습이 부족한 상태로 남아있어, 딥러닝 학습을 목표로 한다면 충분한 양의 데이터를 확보하는 것이 바람직합니다.

이 외에도 앞서 말한 것과 같이, 단어의 수가 500개 이상인 문서들은 내용이 잘리는 것, 검증 데이터셋으로 인해 머신러닝에 비해 더 적은 수의 학습 데이터로 학습한다는 것, 과대적합 등이 원인이 될 수 있습니다.

학습된 모형을 이용하여 결과를 예측하고 싶다면 modelpredict()메서드를 이용합니다. 위에서 만든 모형은 확률을 예측하므로 0과 1로 변환하고 싶다면 임곗값을 설정하여 1과 0으로 변환합니다. 아래에서는 반올림을 사용하였습니다.

y_pred = np.round(model.predict(X_test[:10]))
for pred, y_t in zip(y_pred, y_test[:10]):
    print(
        f'predicted value: {pred[0]}, true value: {y_t}, so the prediction is {pred[0] == y_t}'
)

실행 결과

1/1 [==============================] - 1s 936ms/step
predicted value: 1.0, true value: 0, so the prediction is False
predicted value: 1.0, true value: 1, so the prediction is True
predicted value: 1.0, true value: 1, so the prediction is True
predicted value: 1.0, true value: 0, so the prediction is False
predicted value: 0.0, true value: 1, so the prediction is False
predicted value: 1.0, true value: 1, so the prediction is True
predicted value: 1.0, true value: 0, so the prediction is False
predicted value: 1.0, true value: 0, so the prediction is False
predicted value: 1.0, true value: 0, so the prediction is False
predicted value: 0.0, true value: 0, so the prediction is True
profile
git블로그와 티스토리를 떠돌다 벨로그에 정착하다.

0개의 댓글