텍스트 데이터 + 순환 신경망 + 시퀀스 데이터의 처리

본 문서는 [케라스 창시자에게 배우는 딥러닝] 책을 기반으로 하고 있으며, subinium(본인)이 정리하고 추가한 내용입니다. 생략된 부분과 추가된 부분이 있으니 추가/수정하면 좋을 것 같은 부분은 댓글로 이야기해주시면 감사하겠습니다.

이번 장에는 텍스트(단어의 시퀀스 또는 문자의 시퀀스), 시계열 또는 일반적인 시퀀스 데이터를 처리할 수 있는 딥러닝 모델을 살펴봅니다. 순환 신경망(recurrent neural network)과 1D 컨브넷(1D convnet) 두가지입니다.

다음 문제에서 이런 알고리즘을 사용합니다.

  • 문서 분류나 시계열 분류, 예를 들어 글의 주제나 책의 저자 식별하기
  • 시계열 비교, 예를 들어 두 문서나 두 주식 가격이 얼마나 밀접하게 관련이 있는지 추정하기
  • 시퀀스-투-시퀀스 학습. 예를 들어 영어 문장을 프랑스어로 변환하기
  • 감성 분석. 예를 들어 트윗이나 영화 리뷰가 긍정적인지 부정적인지 분류하기
  • 시계열 예측. 예를 들어 어떤 지역의 최근 날씨 데이터가 주어졌을 때 향후 날씨 예측하기

2가지 문제를 집중하여 다룹니다. IMDB 데이터셋의 감정 분석과 기온 예측입니다.

6.1 텍스트 데이터 다루기

텍스트는 가장 흔한 시퀀스의 형태입니다. 보통은 단어 수준으로 작업하는 경우가 많습니다. 이 장에서는 기초적인 자연어 이해 문제를 처리할 수 있습니다.

  • 문서 분류
  • 감정 분석
  • 저자 식별
  • 제한된 질문 응답

텍스트를 이해하는 것이 아닌 통계적 구조를 통해 문제를 해결합니다. 컴퓨터 비전과 같이 패턴 인식 중 하나입니다.

모든 신경망과 마찬가지로 전처리를 통해 입력할 수 있는 데이터 형태로 만들어야 합니다. 이렇게 텍스트를 수치형 텐서로 변환하는 과정을 텍스트 벡터화(vectorizing text) 라고 부릅니다. 이는 다음과 같은 방법들이 있습니다.

  • 텍스트를 단어로 나누고 각 단어를 하나의 벡터로 변환합니다.
  • 텍스트를 문자로 나누고 각 문자를 하나의 벡터로 변환합니다.
  • 텍스트에서 단어나 문자의 n-gram 을 추출하여 각 n-그램을 하나의 벡터로 변환합니다. n-gram은 연속된 단어나 문자의 그룹으로 텍스트에서 단어나 문자를 하나씩 이동하면서 추출합니다.

텍스트를 나누는 이런 단위를 토큰(token) 이라고 합니다. 그리고 텍스트를 토큰으로 나누는 것을 토큰화(tokenization) 라고 합니다. 텍스트 데이터는 이런 토큰화를 적용하고, 수치형 벡터에 연결하는 작업이 필요합니다. 여기서 2가지 방법을 소개합니다.

  1. 원-핫 인코딩(one-hot encoding)
  2. 토큰 임베딩(token embedding) 또는 단어 임베딩(word embedding)

6.1.1 단어와 문자의 원-핫 인코딩

원-핫 인코딩은 이미 3장에서 IMDB에서 사용해본 방법입니다. 단어 별로 인덱스를 부여하고 이 정수 인덱스 i를 크기가 N(어휘 사전 크기)인 이진 벡터로 변환합니다. i번째 원소만 1이고 나머지는 모두 0입니다.

케라스에는 원본 텍스트 데이터를 단어 또는 문자 수준의 원-핫 인코딩으로 변환해주는 유틸리티가 있습니다. 특수 문자를 제거하거나 빈도가 높은 단어만 사용하는 등 기능이 있습니다.

# 코드 6-3 케라스를 사용한 단어 수준의 원-핫 인코딩하기

from keras.preprocessing.text import Tokenizer

samples = ['The cat sat on the mat.', 'The dog ate my homework.']

# 가장 빈도가 높은 1,000개의 단어만 선택하도록 Tokenizer 객체를 만듭니다.
tokenizer = Tokenizer(num_words=1000)
# 단어 인덱스를 구축합니다.
tokenizer.fit_on_texts(samples)

# 문자열을 정수 인덱스의 리스트로 변환합니다.
sequences = tokenizer.texts_to_sequences(samples)

# 직접 원-핫 이진 벡터 표현을 얻을 수 있습니다.
# 원-핫 인코딩 외에 다른 벡터화 방법들도 제공합니다!
one_hot_results = tokenizer.texts_to_matrix(samples, mode='binary')

# 계산된 단어 인덱스를 구합니다.
word_index = tokenizer.word_index
print('Found %s unique tokens.' % len(word_index))

원-핫 인코딩의 변형으로 원-핫 해싱(one-hot-hashging) 기법이 있습니다. 이 방법은 어휘 사전에 있는 고유 토큰 수가 너무 커서 모두 다루기 어려울 때 사용합니다. 이 방식은 단어의 명시적 인덱스가 필요 없기 때문에 메모리를 절약하고 온라인 방식으로 데이터를 인코딩할 수 있습니다. 하지만 해시 충돌 이 있을 수 있으니 잘 조절해야합니다.

# 코드 6-4 해싱 기법을 사용한 단어 수준의 원-핫 인코딩하기(간단한 예)

samples = ['The cat sat on the mat.', 'The dog ate my homework.']

# 단어를 크기가 1,000인 벡터로 저장합니다.
# 1,000개(또는 그이상)의 단어가 있다면 해싱 충돌이 늘어나고 인코딩의 정확도가 감소될 것입니다
dimensionality = 1000
max_length = 10

results = np.zeros((len(samples), max_length, dimensionality))
for i, sample in enumerate(samples):
    for j, word in list(enumerate(sample.split()))[:max_length]:
        # 단어를 해싱하여 0과 1,000 사이의 랜덤한 정수 인덱스로 변환합니다.
        index = abs(hash(word)) % dimensionality
        results[i, j, index] = 1.

6.1.2 단어 임베딩 사용하기

단어와 벡터를 연관 하는 강력하고 인기 있는 방법은 단어 임베딩 이라는 밀집 단어 벡터(word vector) 를 사용하는 것입니다. 원-핫 인코딩으로 만든 벡터는 희소하고 고차원입니다. 하지만 단어 임베딩은 저차원의 실수형 벡터입니다.

단어 임베딩은 데이터로부터 학습됩니다. 보통 256, 512, 1024차원의 단어 임베딩을 사용하고, 원-핫 인코딩은 20000차원 또는 그 이상의 벡터인 경우가 많습니다. 즉 단어 임베딩이 더 많은 정보를 적은 자원에 저장합니다. 단어 임베딩을 만드는 방법은 두 가지입니다.

  • 관심 대상인 문제와 함께 단어 임베딩을 학습합니다. 이런 경우 랜덤한 단어 벡터로 시작해서 신경망의 가중치를 학습하는 것과 같은 방식으로 단어 벡터를 학습합니다.
  • 풀려는 문제가 아니고 다른 머신 러닝 작업에서 미리 계산된 단어 임베딩을 로드합니다. 이를 사전 훈련된 단어 임베딩(pretrained word embedding) 이라고 합니다.

Embedding 층을 사용하여 단어 임베딩 학습하기

단어와 밀집 벡터를 연관 짓는 가장 간단한 방법은 랜덤하게 벡터를 선택하는 것입니다. 이 방식의 문제점은 임베딩 공간이 구조적이지 않다는 것입니다. 의미 관계가 유사하더라도 완전 다른 임베딩을 가집니다. 그렇기에 단어 사이에 있는 의미 관계를 반영하여 임베딩을 진행해야 합니다.

구글링해서 가져온 예시

단어 임베딩은 언어를 기하학적 공간에 매핑하는 것입니다. 일반적으로 두 단어 벡터 사이의 거리(L2 거리)는 이 단어 사이의 의미 거리와 관계되어 있습니다. 거리 외에 임베딩 공간의 특정 방향도 의미를 가질 수 있습니다.

의미있는 기하학적 변환의 예시는 성별, 복수(plural)과 같은 벡터가 있습니다. (‘king’ + ‘female’ => ‘queen’) 단어 임베딩 공간은 전형적으로 이런 해석 가능하고 잠재적으로 유용한 수천 개의 벡터를 특성으로 가집니다.

하지만 사람의 언어를 완벽하게 매핑해서 이상적인 단어 임베딩 공간을 만들기는 어렵습니다. 언어끼리도 종류가 많고 언어는 특정 문화와 환경을 반영하기 때문에 서로 동일하지 않습니다. 그렇기에 각 언어와 상황에 따라 임베딩 공간을 학습하는 것이 타당합니다.

이를 역전파 + 케라스를 이용해서 Embedding 층을 학습할 수 있습니다.

# 코드 6-5 Embedding층의 객체 생성하기

from keras.layers import Embedding

# Embedding 층은 적어도 두 개의 매개변수를 받습니다.
# 가능한 토큰의 개수(여기서는 1,000으로 단어 인덱스 최댓값 + 1입니다)와 임베딩 차원(여기서는 64)입니다
# 인덱스는 0을 사용하지 않으므로 단어 인덱스는 1~999사이의 정수입니다
embedding_layer = Embedding(1000, 64)

Embedding 층을 정수 인덱스를 밀집 벡터로 매핑하는 딕셔너리로 이해하는 것이 좋습니다. 정수를 입력으로 받아 내부 딕셔너리에서 이 정수에 연관된 벡터를 찾아 반환합니다. 딕셔너리 탐색은 효율적으로 수행됩니다. (텐서플로 백엔드에서는 tf.nn.embedding_lookup()함수를 사용하여 병렬 처리)

단어 인덱스 -> Embdding 층 -> 연관된 단어 벡터

Embedding 층은 크기가 (samples, sequences_length)인 2D 정수 텐서를 입력으로 받습니다. 각 샘플은 정수의 시퀀스입니다. 가변 길이의 경우, 제로패딩 또는 자름으로 크기를 맞춥니다.

Embedding 층은 크기가 (samples, sequences_length, embedding_dimensionality)인 3D 정수 텐서를 반환합니다. 이런 3D 텐서는 RNN 층이나 1D 합성곱 층에서 처리됩니다.

객체를 생성할 때 가중치는 다른 층과 마찬가지로 랜덤으로 초기화됩니다. 훈련하면서 이 단어 벡터는 역전파를 통해 점차 조정되어 이어지는 모델이 사용할 수 있도록 임베딩 공간을 구성합니다. 훈련이 끝나면 임베딩 공간은 특정 문제에 특화된 구조를 가지게 됩니다.

이제 이를 IMDB 영화 리뷰 감성 예측 문제에 적용합니다.

  • 빈도 높은 10000단어를 추출하고 리뷰에서 20개 단어 이후는 버림
  • 8차원 임베딩
  • 정수 시퀸스 입력을 임베딩 시퀀스로 바꿈 (2D -> 3D)
  • 2D로 펼쳐 분류를 위한 Dense층 훈련
# 코드 6-6 Embedding 층에 사용할 IMDB 데이터 로드하기
# 코드 6-7 IMDB 데이터에 Embedding 층과 분류기 사용하기
from keras.datasets import imdb
from keras import preprocessing

# 특성으로 사용할 단어의 수
max_features = 10000
# 사용할 텍스트의 길이(가장 빈번한 max_features 개의 단어만 사용합니다)
maxlen = 20

# 정수 리스트로 데이터를 로드합니다.
(x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=max_features)

# 리스트를 (samples, maxlen) 크기의 2D 정수 텐서로 변환합니다.
x_train = preprocessing.sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = preprocessing.sequence.pad_sequences(x_test, maxlen=maxlen)

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

model = Sequential()
# 나중에 임베딩된 입력을 Flatten 층에서 펼치기 위해 Embedding 층에 input_length를 지정합니다.
model.add(Embedding(10000, 8, input_length=maxlen))
# Embedding 층의 출력 크기는 (samples, maxlen, 8)가 됩니다.

# 3D 임베딩 텐서를 (samples, maxlen * 8) 크기의 2D 텐서로 펼칩니다.
model.add(Flatten())

# 분류기를 추가합니다.
model.add(Dense(1, activation='sigmoid'))
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
model.summary()

history = model.fit(x_train, y_train,
                    epochs=10,
                    batch_size=32,
                    validation_split=0.2)

각 에포크당 2초정도 소모되었습니다. 약 74~75%의 검증 정확도를 가집니다. 하지만 여기서는 단어를 독립적으로 다뤘고, 단어 사이의 관계 그리고 문장 구조는 무시하였습니다.

각 시퀀스 전체를 고려한 특성을 학습하도록 임베딩 층 위에 순환 층이나 1D 합성곱 층을 추가하는 것이 좋습니다. 다음 절에서 이에 관해 다룹니다.

사전 훈련된 단어 임베딩 사용하기

컨브넷과 마찬가지로 사전 훈련된 단어 임베딩을 사용할 수 있습니다. 데이터가 적을 때 매우 유용합니다. 단어 임베딩은 일반적으로 단어 출현 통계를 사용하여 계산합니다. 신경망을 사용하는 것도 있고 그렇지 않은 방법도 존재합니다. 단어를 위해 밀집된 저차원 임베딩 공간을 비지도 학습 방법으로 계산하는 방법도 연구되고 있습니다.

Word2Vec 알고리즘은 성공적인 단어 임베딩 방법으로 성별처럼 구체적인 의미가 있는 속성을 잡아냅니다. 다른 단어 임베딩 데이터베이스로 스탠포드에서 개발한 GloVe 가 있습니다. 이 기법은 동시 출현 통계를 기록한 행렬을 분해하는 기법을 사용합니다. 이 개발자들은 위키피디아 데이터와 커먼 크롤 데이터에서 가져온 수백만 개의 영어 토큰에 대해서 임베딩을 계산했습니다. 다음 절에서 GloVe 임베딩을 케라스 모델에 적용해봅시다.

6.1.3 모든 내용을 적용하기: 원본 텍스트에서 단어 임베딩까지

케라스에 포함된 IMDB 데이터는 이미 토큰화가 되어 있습니다. 이를 사용하는 대신 원본 텍스트 데이터를 내려받아 처음부터 시작하겠습니다.

원본 IMDB 텍스트 내려받기

링크에서 IMDB 원본 데이터셋(60MB)을 내려받고 압축을 해제합니다. 저는 다운받고 코랩에 올려서 압축을 해제하였습니다. (생각보다 업로드 시간이 걸립니다.)

훈련용 리뷰 하나를 문자열 만들어 훈련 데이터를 문자열 리스트로 구성합니다. 리뷰 레이블(긍정/부정)도 labels 리스트로 만들겠습니다.

# 코드 6-8 IMDB 원본 데이터 전처리하기

import os

imdb_dir = './datasets/aclImdb'
train_dir = os.path.join(imdb_dir, 'train')

labels = []
texts = []

for label_type in ['neg', 'pos']:
    dir_name = os.path.join(train_dir, label_type)
    for fname in os.listdir(dir_name):
        if fname[-4:] == '.txt':
            f = open(os.path.join(dir_name, fname), encoding='utf8')
            texts.append(f.read())
            f.close()
            if label_type == 'neg':
                labels.append(0)
            else:
                labels.append(1)

데이터 토큰화

텍스트를 벡터로 만들고 훈련 세트와 검증 세트로 나눕니다. 사전 훈련된 단어 임베딩은 훈련 데이터가 부족한 문제에서 유용합니다. 그래서 다음과 같이 훈련 데이터를 처음 200개의 샘플로 제한합니다. 이 모델은 200개의 샘플을 학습한 후 영화 리뷰를 분류할 것 입니다.

from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
import numpy as np

maxlen = 100  # 100개 단어 이후는 버립니다
training_samples = 200  # 훈련 샘플은 200 -> 200개입니다
validation_samples = 10000  # 검증 샘플은 10,000개입니다
max_words = 10000  # 데이터셋에서 가장 빈도 높은 10,000개의 단어만 사용합니다

tokenizer = Tokenizer(num_words=max_words)
tokenizer.fit_on_texts(texts)
sequences = tokenizer.texts_to_sequences(texts)

word_index = tokenizer.word_index
print('%s개의 고유한 토큰을 찾았습니다.' % len(word_index))

data = pad_sequences(sequences, maxlen=maxlen)

labels = np.asarray(labels)
print('데이터 텐서의 크기:', data.shape)
print('레이블 텐서의 크기:', labels.shape)

# 데이터를 훈련 세트와 검증 세트로 분할합니다.
# 샘플이 순서대로 있기 때문에 (부정 샘플이 모두 나온 후에 긍정 샘플이 옵니다)
# 먼저 데이터를 섞습니다.
indices = np.arange(data.shape[0])
np.random.shuffle(indices)
data = data[indices]
labels = labels[indices]

x_train = data[:training_samples]
y_train = labels[:training_samples]
x_val = data[training_samples: training_samples + validation_samples]
y_val = labels[training_samples: training_samples + validation_samples]

GloVe 단어 임베딩 내려받기

https://nlp.stanford.edu/projects/glove 에 2014년 영문 위키피디아를 사용하여 사전에 계산된 임베딩을 내려받습니다. 파일은 glove.6B.zip(823MB)으로 제가 링크를 연결해두었습니다. 40만 개의 단어에 대한 100차원의 임베딩 벡터를 포함하고 있습니다.

저는 여기서 속도 문제로 포기했습니다. 드라이브를 코랩과 연결하고, 드라이브에서 unzip하고 하면되지만 그 정도의 노력은 필요없는 문제라고 생각하고 코드를 읽고 이해하는 것에 초점을 두었습니다.

임베딩 전처리

압축 해제한 txt파일을 파싱하여 단어와 이에 상응하는 벡터 표현을 매핑하는 인덱스를 만듭니다.

# 코드 6-10 GloVe 단어 임베딩 파일 파싱하기

glove_dir = './datasets/'

embeddings_index = {}
f = open(os.path.join(glove_dir, 'glove.6B.100d.txt'), encoding="utf8")
for line in f:
    values = line.split()
    word = values[0]
    coefs = np.asarray(values[1:], dtype='float32')
    embeddings_index[word] = coefs
f.close()

그 다음 임베딩 층에 주입할 수 있도록 임베딩 행렬을 만듭니다. 이 행렬의 크기는 (max_words, embedding_dim)이어야 합니다. 이 행렬의 i번째 원소는 단어 인덱스의 i번째 단어에 상응하는 embedding_dim차원 벡터입니다. 인덱스 0은 어떤 단어나 토큰도 아닌 경우입니다.

# 코드 6-11 GloVe 단어 임베딩 행렬 준비하기
embedding_dim = 100

embedding_matrix = np.zeros((max_words, embedding_dim))
for word, i in word_index.items():
    embedding_vector = embeddings_index.get(word)
    if i < max_words:
        if embedding_vector is not None:
            # 임베딩 인덱스에 없는 단어는 모두 0이 됩니다.
            embedding_matrix[i] = embedding_vector

모델 정의하기

이전과 동일한 구조의 모델을 사용합니다.

# 코드 6-12 모델 정의하기
from keras.models import Sequential
from keras.layers import Embedding, Flatten, Dense

model = Sequential()
model.add(Embedding(max_words, embedding_dim, input_length=maxlen))
model.add(Flatten())
model.add(Dense(32, activation='relu'))
model.add(Dense(1, activation='sigmoid'))

모델에 GloVe 임베딩 로드하기

Embedding 층은 하나의 가중치 행렬을 가집니다. 이 행렬은 2D 부동 소수 행렬이고 각 i번째 원소는 i번째 인데스에 상응하는 단어 벡터입니다. 모델에 첫번째 층인 Embdding 층에 준비된 GloVe 행렬을 로드합니다. 여기서도 전과 같이 동결을 하여 사저 학습된 데이터에 대한 손실을 줄입니다.

# 코드 6-13 사전 훈련된 단어 임베딩을 Embdding 층에 로드하기
model.layers[0].set_weights([embedding_matrix])
model.layers[0].trainable = False

모델 훈련과 평가

모델을 컴파일하고 훈련한 후 그래프를 그려봅니다.

# 코드 6-14 훈련과 평가하기
# 코드 6-15 결과 그래프 그리기
model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['acc'])
history = model.fit(x_train, y_train,
                    epochs=10,
                    batch_size=32,
                    validation_data=(x_val, y_val))
model.save_weights('pre_trained_glove_model.h5')

import matplotlib.pyplot as plt

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(1, len(acc) + 1)

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()

plt.figure()

plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()

plt.show()
사전 훈련된 단어 임베딩을 사용했을 때 훈련 손실과 검증 손실
사전 훈련된 단어 임베딩을 사용했을 때 훈련 정확도와 검증 정확도

이 모델은 과대적합이 빠르게 시작됩니다. 훈련 샘플 수가 작기 때문에 당연한 진행입니다. 하지만 검증 정확도는 50% 후반에 머무릅니다. 이정도면 운 좋게 반반 나눈 정도와 같습니다. 데이터에 따라서는 더 안좋게도 나올 가능성이 큽니다.

그렇기에 이번엔 사전 훈련된 단어 임베딩을 사용하지 않거나 임베딩 층을 동결하지 않고 모델을 훈련할 수 있습니다. 일반적으로 데이터가 많다면 사전 훈련된 단어 임베딩보다 그냥 하는 것이 성능이 훨씬 높습니다. 200개라 거의 성능이 비슷할 것 같지만 진행해봅시다.

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

model = Sequential()
model.add(Embedding(max_words, embedding_dim, input_length=maxlen))
model.add(Flatten())
model.add(Dense(32, activation='relu'))
model.add(Dense(1, activation='sigmoid'))

model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['acc'])
history = model.fit(x_train, y_train,
                    epochs=10,
                    batch_size=32,
                    validation_data=(x_val, y_val))

진행하면 다음과 같은 결과가 나옵니다.

사전 훈련된 단어 임베딩을 사용하지 않았을 때 훈련 손실과 검증 손실
사전 훈련된 단어 임베딩을 사용하지 않았을 때 훈련 정확도와 검증 정확도

50% 초의 정확도에 머무는 것을 알 수 있습니다. 도토리 키재기이긴 하나 사전 훈련된 임베딩을 사용하는 것이 더 좋습니다. 하지만 훈련 샘플이 2000개이상이면 70%에 가까운 검증 정확도를 얻을 수 있다고 합니다.

마지막으로 테스트 데이터에서 모델을 평가합니다.

test_dir = os.path.join(imdb_dir, 'test')

labels = []
texts = []

for label_type in ['neg', 'pos']:
    dir_name = os.path.join(test_dir, label_type)
    for fname in sorted(os.listdir(dir_name)):
        if fname[-4:] == '.txt':
            f = open(os.path.join(dir_name, fname), encoding="utf8")
            texts.append(f.read())
            f.close()
            if label_type == 'neg':
                labels.append(0)
            else:
                labels.append(1)

sequences = tokenizer.texts_to_sequences(texts)
x_test = pad_sequences(sequences, maxlen=maxlen)
y_test = np.asarray(labels)

model.load_weights('pre_trained_glove_model.h5')
model.evaluate(x_test, y_test)

토큰화하고 평가를 하면 겨우 50% 정도의 정확도 입니다. 적은 데이터에서는 역시 답이 없… 책을 만드신 분도 당황했을 것 같네요. :-p

6.1.4 정리

생략

6.2 순환 신경망 이해하기

완전 연결 네트워크나 컨브넷처럼 지금까지 본 모든 신경망의 특징은 메모리가 없다는 것입니다. 네트워크로 주입되는 입력은 개별적으로 처리되며 입력 간에 유지되는 상태가 없습니다. 이런 네트워크로 시계열, 시퀀스 데이터를 처리하려면 전체 시퀀스를 주입해야합니다. 이런 네트워크를 피드포워드 네트워크(feedforward network) 라고 합니다.

하지만 실제 문장을 읽는 것처럼 이전에 나온 데이터를 사용하며 내무 모델을 계속 유지하며 업데이트할 수도 있습니다. 극단적으로 단순화시킨 버전이지만 순환 신경망(Recurrent Neural Network, RNN) 은 같은 원리를 적용한 것입니다. 시퀀스의 원소를 순회하면서 지금까지 처리한 정보를 상태(state) 에 저장합니다.

순환 신경망은 내부에 루프가 있는 신경망의 한 종류입니다. 하나의 시퀀스가 하나의 데이터 포인터로 간주됩니다. 이 네트워크는 시퀀스의 원소를 차례대로 방문합니다.

RNN example

루프와 상태에 대한 개념을 명확히 하기 위해 RNN 정방향 계산을 구현해봅시다.

RNN은 크기가 (timesteps, input_features)인 2D 텐서로 인코딩된 벡터의 시퀀스를 입력받습니다. 이 시퀀스는 타임스텝을 따라서 반복됩니다. 각 타임스텝 t에서 현재 상태와 입력을 연결하여 출력을 계산합니다. 그 다음 출력을 다음 스텝의 상태로 둡니다. 그렇기에 첫 번째는 상태가 없으니 초기 상태(initial state) 인 0 벡터로 시작합니다.

파이썬스럽게 pseudo-code를 작성하면 다음과 같습니다.

# 코드 6-19 의사코드로 표현한 RNN

state_t = 0
for input_t in input_seqeunce:
  output_t = f(input_t, stat_t)
  state_t = output_t

이를 행렬 W와 U 그리고 편향 벡터를 사용하여 좀 더 구체적으로 코드를 작성해보면 다음과 같습니다.

# 코드 6-20 좀 더 자세한 의사코드로 표현한 RNN

state_t = 0
for input_t in input_seqeunce:
  output_t = activation(dot(W, input_t) + dot(U, state_t) + b)
  state_t = output_t

완벽하게 설명하기 위해 간단한 RNN의 정방향 계산을 넘파이로 구현하면 다음과 같습니다.

# 코드 6-21 넘파이로 구현한 간단한 RNN

import numpy as np

timesteps = 100
input_features = 32
output_features = 64

inputs = np.random.random((timesteps, input_features))

state_t = np.zeros((output_features,))

W = np.random.random((output_features, input_features))
U = np.random.random((output_features, output_features))
b = np.random.random((output_features,))

surccesive_outputs = []

for input_t in inputs:
  output_t = np.tanh(np.dot(W, input_t) + np.dot(U, state_t) + b)
  surccesive_outputs.append(output_t)
  state_t = output_t

final_output_sequence = np.stack(surccesive_outputs, axis=0)

하다보니 block 암호와 비슷한 구조같다는 생각이 드네요.

6.2.1 케라스의 순환 층

넘파이로 간단하게 구현한 과정은 실제로 케라스의 SimpleRNN 층에 해당합니다.

from keras.layers import SimpleRNN

SimpleRNN이 한 가지 다른 점은 넘파이 예제처럼 하나의 시퀀스가 아니라 다른 케라스 층과 마찬가지로 시퀀스 배치를 처리한다는 것입니다. 즉 (timesteps, input_features) 크기가 아니라 (batch_size, timesteps, input_features) 크기의 입력을 받습니다.

케라스에 있는 모든 순환 층과 마찬가지로 SimpleRNN은 두 가지 모드로 실행할 수 있습니다. 각 타임스탭의 출력을 모은 전체 시퀀스를 반환하거나, 입력 시퀸스에 대한 마지막 출력만 반환할 수 있습니다. 이는 return sequence 매개변수로 설정할 수 있습니다.

네트워크의 표현력을 증가시키기 위해 여러 개의 순환 층을 차례대로 쌓는 것이 유용할 때가 있습니다. 이런 설정에서는 중간층들이 전체 출력 시퀀스를 반환하도록 설정해야합니다.

이제 IMDB 영화 리뷰 문제를 시작합니다, 먼저 데이터를 전처리합니다.

# 코드 6-22 IMDB 데이터 전처리하기

from keras.datasets import imdb
from keras.preprocessing import sequence

max_features = 10000  # 특성으로 사용할 단어의 수
maxlen = 500  # 사용할 텍스트의 길이(가장 빈번한 max_features 개의 단어만 사용합니다)
batch_size = 32

print('데이터 로딩...')
(input_train, y_train), (input_test, y_test) = imdb.load_data(num_words=max_features)
print(len(input_train), '훈련 시퀀스')
print(len(input_test), '테스트 시퀀스')

print('시퀀스 패딩 (samples x time)')
input_train = sequence.pad_sequences(input_train, maxlen=maxlen)
input_test = sequence.pad_sequences(input_test, maxlen=maxlen)
print('input_train 크기:', input_train.shape)
print('input_test 크기:', input_test.shape)

Embedding 층과 SimpleRNN 층을 사용하여 간단한 순환 네트워크를 훈련시킵니다.

# 코드 6-23 Embedding 층과 SimpleRNN 층을 사용한 모델 훈련하기

from keras.models import Sequential
from keras.layers import Embedding, SimpleRNN

from keras.layers import Dense

model = Sequential()
model.add(Embedding(max_features, 32))
model.add(SimpleRNN(32))
model.add(Dense(1, activation='sigmoid'))

model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
history = model.fit(input_train, y_train,
                    epochs=10,
                    batch_size=128,
                    validation_split=0.2)

훈련은 각 에포크당 40초 걸렸습니다. 왜 전 책 예시보다 2~3배 걸리는걸까 의문입니다. :-( 그리고 이제 항상하듯이 훈련과 검증의 손실 그리고 정확도 그래프를 그려봅시다.

import matplotlib.pyplot as plt

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(1, len(acc) + 1)

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()

plt.figure()

plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()

plt.show()
SimpleRNN을 사용한 IMDB 문제의 훈련 손실과 검증 손실
SimpleRNN을 사용한 IMDB 문제의 훈련 정확도와 검증 정확도

3번째부터 과대적합이 시작되고, 83%에서 정확도가 머무는 것을 확인할 수 있습니다. 3장에서 이미 87~88%를 도달했는데 아직 이 모델의 성능은 거기에 미치지 못했습니다.

이런 원인은 전체 시퀀스가 아니라 처음 500개의 단어만 입력에 사용했기 때문입니다. 이 RNN은 기준 모델보다 얻은 정보가 적습니다. 또 다른 원인은 SimpleRNN이 텍스트 시퀀스에 적합하지 않습니다. 이제 더 잘 작동하는 다른 순환 층을 살펴봅시다.

6.2.2 LSTM과 GRU 층 이해하기

케라스에는 SimpleRNN 외에 다른 순환 층도 있습니다. LSTM과 GRU 2개입니다. 실전에서는 항상 둘 중 하나를 사용합니다. SimpleRNN은 실전에 쓰기에는 너무 단순하기 때문입니다.

SimpleRNN은 이론적으로 시간 t에서 이전의 모든 타임스텝의 정보를 유지할 수 있습니다. 실제로는 긴 시간에 걸친 의존성은 학습할 수 없는 것이 문제입니다. 층이 많은 일반 네트워크에서 나타나는 것과 비슷한 현상인 그래디언트 소실 문제(vanishing gradient problem) 때문입니다.

이런 문제를 해결하기 위해 고안된 것이 LSTM과 GRU 층입니다. 그 중 LSTM을 살펴봅시다. LSTM은 Long Short-Term Memory의 약자입니다. 이 알고리즘은 위의 문제를 해결하기 위해 만들어졌습니다. 우선 이 층은 SimpleRNN의 변종입니다. 정보를 여러 타임스텝에 걸쳐 나르는 방법이 추가됩니다.

SimpleRNN을 사용한 IMDB 문제의 훈련 손실과 검증 손실

위 그림에서는 $c_t$라는 cell state가 추가되고, $x_t$가 input, $h_t$가 output입니다.

cell state는 타입스텝을 가로지르는 정보를 나르는 데이터 흐름입니다. 입력 연결과 순환 연결로부터 이 정보가 합성됩니다. 그러고는 다음 타임스텝으로 전달될 상태를 변경시킵니다. 이 흐름이 다음 출려과 상태를 조정하는 것입니다.

이제 여기서 가질 수 있는 의문은 $c_t$에서 $c_{t+1}$으로 넘어가는 방식입니다. 여기에는 총 3가지 변환이 관련되어 있습니다. 3개 모두 SimpleRNN과 같은 형태를 가집니다. 3개의 변환을 각각 i, f, t로 표시하겠습니다.

# 코드 6-25 LSTM 구조의 의사코드 (1/2)
output_t = asctivation(c_t) * activation(dot(input_t,Wo) + dot(state_tm Uo) + bo)

i_t = activation(dot(state_t, Ui) + dot(input_t, Wi) + bi)
f_t = activation(dot(state_t, Uf) + dot(input_t, Wf) + bf)
k_t = activation(dot(state_t, Uk) + dot(input_t, Wk) + bk)

이제 이를 이용해서 $c_{t+1}$ 를 구합니다.

# 코드 6-26 LSTM 구조의 의사코드 (2/2)

c_t+1 = i_t * k_t + c_t * f_t

이 연산들이 하는 일을 해석하면 다음과 같은 통찰을 얻을 수 있습니다.

  • c_t와 f_t의 곱셈은 이동을 위한 데이터 흐름에서 관련이 적은 정보를 의도적으로 삭제
  • i_t와 k_t는 현재에 대한 정보를 제공하고 이동 트랙을 새로운 정보로 업데이트

하지만 결국은 가중치 행렬에 따라 값들이 변경됩니다. 가중치 행렬은 엔드-투-엔드 방식으로 학습됩니다. 이 과정은 훈련 반복마다 새로 시작되며 이런저런 연산들에 목적을 부여하기가 불가능 합니다. LSTM셀의 구체적인 구조에 대해 이해하기 보다는 역할을 기억하라고 합니다. 이 부분은 후에 더 구체적으로 공부해보면서 맞는 말인지 점검해봐야겠습니다.

6.2.3 케라스를 사용한 LSTM 예제

LSTM층으로 모델을 구성해서 훈련을 진행합니다. 매개변수에 대한 특별한 설정없이 해도 케라스 자체의 초기값이 괜찮다고 합니다.

from keras.layers import LSTM

model = Sequential()
model.add(Embedding(max_features, 32))
model.add(LSTM(32))
model.add(Dense(1, activation='sigmoid'))

model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['acc'])
history = model.fit(input_train, y_train,
                    epochs=10,
                    batch_size=128,
                    validation_split=0.2)

비교적 복잡한 순환 반복 층이라 시간이 오래 걸립니다. 에포크당 155초 정도 소모되었습니다. 다음과 같은 결과가 나옵니다.

LSTM을 사용한 IMDB 문제의 훈련 손실과 검증 손실
LSTM을 사용한 IMDB 문제의 훈련 손실과 검증 손실

이번에는 88%정도의 정확도를 달성했습니다. SimpleRNN보다는 확실히 좋은 것을 알 수 있습니다. 3장에서 사용했던 완전 연결 네트워크보다도 적은 데이터를 사용하고 좋은 결과를 얻을 수 있었습니다.

하지만 많은 계산을 사용한 것치고는 좋은 결과는 아닙니다. 그 이유는 임베딩 차원이나 LSTM 출력 차원 같은 하이퍼파라미터를 전혀 튜닝하지 않았고 규제가 없었습니다.

또한 리뷰를 전체적으로 길게 분석하는 것은 감성 분류에 도움이 되지 않습니다. 그렇기에 간단한 문제는 단어 그리고 빈도수로 측정하는 게 더 좋습니다. (완전 연결 네트워크)

하지만 훨씬 복잡한 자연어 처리 문제에서는 유용합니다. 특히 질문-응답과 기계 번역 분야입니다.

6.2.4 정리

  • RNN이 무엇이고 동작하는 방법
  • LSTM이 무엇이고, 긴 시퀀스에서 더 효율적인 이유
  • 케라스에서 RNN 층을 사용하여 시퀀스 데이터를 처리하는 방법

다음 정리에서는 연결하여 RNN의 고급 기능을 살펴봅니다.

Leave a Comment