케라스 창시자에게 배우는 딥러닝/ 텍스트와 시퀀스를 위한 딥러닝

  • 이 장에서는 텍스트(단어의 시퀀스 또는 문자의 시퀀스), 시계열 또는 일반적인 시퀀스(sequence) 데이터를 처리할 수 있는 딥러닝 모델을 살펴보겠다.
  • 시퀀스 데이터를 처리하는 기본적인 딥러닝 모델은 순환 신경망(recurrent neural network)과 1D 컨브넷(1D convnet) 두 가지이다.
    • 1D 컨브넷은 이전 장에서 다룬 2D 컨브넷의 1차원 버전이다.
  • 다음 애플리케이션들이 이런 알고리즘들을 사용한다.
    • 문서 분류나 시계열 분류, 예컨대 글의 주제나 책의 저자 식별하기
    • 시계열 비교, 예컨대 두 문서나 두 주식 가격이 얼마나 밀접하게 관련되어 있는지 추정하기
    • 시퀀스-투-시퀀스 학습, 예컨대 영어 문장을 프랑스어로 변환하기
    • 감성 분석, 예컨대 트윗이나 영화 리뷰가 긍정적인지 부정적인지 분류하기
    • 시계열 예측, 예컨대 어떤 지역의 최근 날씨 데이터가 주어졌을 때 향후 날씨 예측하기

텍스트 데이터 다루기

  • 텍스트는 가장 흔한 시퀀스 형태의 데이터이다. 텍스트는 단어의 시퀀스나 문자의 시퀀스로 이해할 수 있다. 보통 단어 수준으로 작업하는 경우가 많다.
    • 다음 절에 소개할 시퀀스 처리용 딥러닝 모델은 텍스트를 사용하여 기초적인 자연어 이해(natural language understanding) 문제를 처리할 수 있다.
    • 이런 모델은 문서 분류, 감성 분석, 저자 식별, (제한된 범위의) 질문 응답(Question Answering, QA) 등의 애플리케이션에 적합하다.
    • 물론 이런 딥러닝 모델이 사람처럼 진짜 텍스트를 이해하는 것은 아니다. 이런 모델은 문자 언어(written language)에 통계적 구조를 만들어 간단한 텍스트 문제를 해결한다.
    • 컴퓨터 비전이 픽셀에 적용한 패턴 인식(pattern recognition)인 것처럼 자연어 처리(natural language processing)를 위한 딥러닝은 단어, 문장, 문단에 적용한 패턴 인식이다.
  • 다른 모든 신경망과 마찬가지로 텍스트 원본을 입력으로 사용하지 못한다. 딥러닝 모델은 수치형 텐서만 다룰 수 있기 때문이다. 텍스트를 수치형 텐서로 변환하는 과정을 텍스트 벡터화(vectorizing text)라고 한다. 여기에는 여러 방식이 있다.
    • 텍스트를 단어로 나누고 각 단어를 하나의 벡터로 변환한다.
    • 텍스트를 문자로 나누고 각 문자를 하나의 벡터로 변환한다.
    • 텍스트에서 단어나 문자의 n-그램(n-gram)을 추출하여 각 n-그램을 하나의 벡터로 변환한다. n-그램은 연속된 단어나 문자의 그룹으로 텍스트에서 단어나 문자를 하나씩 이동하면서 추출한다.
  • 텍스트를 나누는 이런 단위(단어, 문자, n-그램)를 토큰(token)이라고 한다. 그리고 텍스트를 토큰으로 나누는 작업을 토큰화(tokenization)라고 한다.
    • 모든 텍스트 벡터화 과정은 어떤 종류의 토큰화를 적용하고 생성된 토큰에 수치형 벡터를 연결하는 것으로 이루어진다. 
    • 이런 벡터는 시퀀스 텐서로 묶여져서 심층 신경망에 주입된다. 
  • 토큰과 벡터를 연결하는 방법은 여러 가지가 있는데 여기서는 두 가지 주요한 방법을 소개하겠다.
    • 토큰의 원-핫 인코딩(one-hot encoding)과 토큰 임베딩(token embedding)(일반적으로 단어에 대해서만 사용되므로 단어 임베딩(word embedding)이라고도 부른다) 그것이다.

Note) n-그램과 BoW

  • 단어 n-그램은 문장에서 추출한 N개(또는 그 이하)의 연속된 단어 그룹이다. 같은 개념이 단어 대신 문자에도 적용될 수 있다.
  • “The cat sat on the mat”라는 문장을 예로 들어 2-그램 집합으로 분해하면 다음과 같다.
    • { “The”, “The cat”, “cat”, “cat sat”, “sat”, “sat on”, “on”, “on the”, “the”, “the mat”, “mat” }
  • 같은 문장을 3그램 집합으로 분해하면 다음과 같다.
    • { “The”, “The cat”, “The cat sat”, “cat”, “cat sat”, “cat sat on”, “sat”, “sat on”, “sat on the”, “on”, “on the”, “on the mat”, “the”, “the mat”, “mat” }
  • 이런 집합을 각각 2-그램 가방(bag of 2-gram) 또는 3-그램 가방(bag of 3-gram)이라고 한다.
    • 가방(bag)이란 용어는 다루고자 하는 것이 리스트나 시퀀스가 아니라 토큰의 집합이라는 사실을 의미한다. 
    • 이 토큰에는 특정한 순서가 없다. 이런 종류의 토큰화 방법을 BoW(Back-of-Words)라고 한다.
  • BoW가 순서 없는 토큰화 방법이기 때문에(생성된 토큰은 시퀀스가 아니라 집합으로 간주되고 문장의 일반적인 구조가 사라진다) 딥러닝 모델보다 얕은 학습 방법의 언어 처리 모델에 사용되는 경향이 있다.
    • n-그램을 추출하는 것은 일종의 특성 공학이다. 딥러닝은 유연하지 못하고 이런 방식을 계층적인 특성 학습으로 대체한다. 나중에 소개할 순환 신경망과 1D 컨브넷으로 단어와 문자 그룹에 대한 특성을 학습할 수 있다.
    • 이 방식들은 그룹들을 명시적으로 알려 주지 않아도 연속된 단어나 문자의 시퀀스를 봄으로써 학습한다.
  • 이런 이유 때문에 이 책에서는 n-그램을 더 다루지 않는다. 하지만 로지스틱 회귀나 랜덤 포레스트 같은 얕은 학습 방법의 텍스트 처리 모델을 사용할 때는 강력하고 아주 유용한 특성 공학 방법임을 기억해 두자.

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

  • 원-핫 인코딩은 토큰을 벡터로 변환하는 가장 일반적이고 기본적인 방법이다.
    • 3장에서 IMDB와 로이터 예제에서 살펴보았는데, 모든 단어에 고유한 정수 인덱스를 부여하고 이 정수 인덱스 i를 크기가 N인 이진 벡터로 변환한다.
    • 이 벡터는 i번째 원소만 1이고 나머지는 모두 0이다.
  • 물론 원-핫 인코딩은 문자 수준에서도 적용할 수 있다. 원-핫 인코딩이 무엇이고 어떻게 하는지 명확하기 위해 아래 단어와 문자에 대한 간단한 예를 만들었다.
### 단어 수준의 원-핫 인코딩 예
import numpy as np

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

token_index = {}

for sample in samples:
for word in sample.split():
if word not in token_index:
token_index[word] = len(token_index) + 1

max_length = 10

results = np.zeros(shape=(len(samples), max_length, max(token_index.values()) + 1))

for i, sample in enumerate(samples):
for j, word in list(enumerate(sample.split()))[:max_length]:
index = token_index.get(word)
results[i, j, index] = 1.

### 문자 수준의 원-핫 인코딩 예
import string

samples = ['The cat sat on the mat.', 'The dog ate my homework.']
characters = string.printable
token_index = dict(zip(characters, range(1, len(characters) + 1)))

max_length = 50
results = np.zeros((len(samples), max_length, max(token_index.values()) + 1))

for i, sample in enumerate(samples):
for j, character in enumerate(sample):
index = token_index.get(character)
results[i, j, index] = 1.
  • 케라스에는 원본 텍스트 데이터를 단어 또는 문자 수준의 원-핫 인코딩으로 변환해 주는 유틸리티가 있다. 특수 문자를 제거하거나 빈도가 높은 N개의 단어만 선택(입력 벡터 공간이 너무 커지지 않도록 하기 위한 일반적인 제한 방법)하는 등 여러 가지 중요한 기능들이 있기 때문에 이 유틸리티를 사용하는 편이 좋다.
from keras.preprocessing.text import Tokenizer

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

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('%s개의 고유한 토큰을 찾았습니다' % len(word_index))
  • 원-핫 인코딩의 변종 중 하나는 원-핫 해싱(one-hot hashing) 기법이다. 이 방식은 어휘 사전에 있는 고유한 토큰의 수가 너무 커서 모두 다루기 어려울 때 사용한다.
    • 각 단어에 명시적으로 인덱스를 할당하고 이 인덱스를 딕셔너리에 저장하는 대신 단어를 해싱하여 고정된 크기의 벡터로 변환한다. 일반적으로 간단한 해싱 함수를 사용한다.
    • 이 방식의 주요 장점은 명시적인 단어 인덱스가 필요 없기 때문에 메모리를 절약하고 온라인 방식으로 데이터를 이노딩할 수 있다.
    • 한 가지 단점은 해시 충돌(hash collision)이다. 2개의 단어가 같은 해시를 만들면 이를 바라보는 머신 러닝 모델은 단어 사이의 차이를 인식하지 못한다. 해싱 공간 차원의 해싱될 고유 토큰의 전체 개수보다 훨씬 크면 해시 충돌의 가능성은 감소한다.
samples = ['The cat sat on the mat.', 'The dog ate my homework.']

dimensionality = 1000
max_length

results = np.zeros((len(samples), max_length, dimensionality))

for i, sample in enumerate(samples):
for j, word in list(enumerate(sample.split()))[:max_length]:
index = abs(hash(word)) % dimensionality
results[i, j, index] = 1.

단어 임베딩 사용하기

  • 단어와 벡터를 연관 짓는 강력하고 인기있는 또 다른 방법은 단어 임베딩이라는 밀집 단어 벡터(word vector)를 사용하는 것이다.
    • 원-핫 인코딩으로 만든 벡터는 희소(sparse)하고 (대부분 0으로 채워진다) 고차원이다 (어휘 사전에 있는 단어의 수와 차원이 같다)
    • 반면 단어 임베딩은 저차원의 실수형 벡터(희소 벡터의 반대인 밀집 벡터)이다. 

  • 원-핫 인코딩으로 얻은 단어 벡터와 달리 단어 임베딩은 데이터로부터 학습된다.
    • 보통 256차원, 512차원 또는 큰 어휘 사전을 다룰 때는 1024차원의 단어 임베딩을 사용한다.
    • 반면 원-핫 인코딩은 2만 개의 토큰으로 이루어진 어휘 사전을 만들려면 20,000 차원 또는 그 이상의 벡터일 경우가 많다. 따라서 단어 임베디잉 더 많은 정보를 적은 차원에 저장한다.
  • 단어 임베딩을 만드는 방법은 두 가지이다.
    • (문서 분류나 감성 예측 같은) 관심 대상인 문제와 함께 단어 임베딩을 학습한다. 이런 경우에는 랜덤한 단어 벡터로 시작해서 신경망의 가중치를 학습하는 것과 같은 방식으로 단어 벡터를 학습한다.
    • 풀려는 문제가 아니고 다른 머신 러닝에서 미리 계산된 단어 임베딩을 로드한다. 이를 사전 훈련된 단어 임베딩(pretrained word embedding)이라고 한다.

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

  • 단어와 밀집 벡터를 연관 짓는 가장 간단한 방법은 랜덤하게 벡터를 선택하는 것이다.
    • 이 방식의 문제점은 임베딩 공간이 구조적이지 않다는 것이다. 예컨대 accurate와 exact 단어는 대부분 문장에서 비슷한 의미로 사용되지만 완전히 다른 임베딩을 가진다.
    • 심층 신경망이 이런 임의의 구조적이지 않은 임베딩 공간을 이해하기는 어렵다.
  • 단어 벡터 사이에 좀 더 추상적이고 기하학적인 관계를 얻으려면 단어 사이에 있는 의미 관계를 반영해야 한다. 단어 임베딩은 언어를 기하학적 공간에 매핑하는 것이다.
    • 예컨대 잘 구축된 임베딩 공간에서는 동의어가 비슷한 단어 벡터로 임베딩될 것이다. 일반적으로 두 단어 벡터 사이의 거리(L2 거리)는 이 단어 사이의 의미 거리와 관계되어 있다. (멀리 떨어진 위치에 임베딩된 단어 의미는 서로 달느 반면 비슷한 단어들은 가까이 임베딩 된다)
    • 거리 외에 임베딩 공간의 특정 방향도 의미를 가질 수 있다.
  • 예를 들어보자. 아래 그림에서 4개의 단어 cat, dog, wolf, tiger가 2D 평면에 임베딩되어 있다.
    • 예컨대 cat에서 tiger로 이동하는 것과 dog에서 wolf로 이동하는 것을 같은 벡터로 나타낼 수 있다. 이 벡터는 ‘애완동물에서 야생 동물로 이동’ 하는 것으로 해석할 수 있다.
    • 비슷하게 다른 벡터로 dog에서 cat으로 이동하는 것과 wolf에서 tiger로 이동하는 것을 나타내면 ‘개과에서 고양이과로 이동’하는 벡터로 해석할 수 있다.

  • 실제 단어 임베딩 공간에서 의미 있는 기하학적 변환의 일반적인 예는 ‘성별’ 벡터와 ‘복수(plural)’ 벡터이다. 
    • 예컨대 ‘king’ 벡터에 ‘female’ 벡터를 더하면 ‘queen’ 벡터가 되고 plural 벡터를 더하면 ‘kings’가 된다.
    • 단어 임베딩 공간은 전형적으로 이런 해석이 가능하고 잠재적으로 유용한 수천 개의 벡터를 특성으로 가진다.
  • 사람의 언어를 매핑해서 어떤 자연어 처리 작업에도 사용할 수 있는 이상적인 단어 임베딩 공간이 존재할까? 아마도 가능하겠지만 아직까지 이런 종류의 공간은 만들지 못했다. 
    • 사람의 언어에도 그런 것은 없다. 세상에는 많은 다른 언어가 있고 언어는 특정 문화와 환경을 반영하기 때문에 서로 동일하지 않다.
  • 실제로 좋은 단어 임베딩 공간을 만드는 것은 문제에 따라 크게 달라진다. 
    • 영어로 된 영화 리뷰 감성 분석 모델을 위한 완벽한 단어 임베딩 공간은 영어로 된 법률 문서 분류 모델을 위한 완벽한 임베딩 공간과 다를 것이다. 특정 의미 관계의 중요성이 작업에 따라 다르기 때문이다.
  • 따라서 새로운 작업에는 새로운 임베딩을 학습하는 것이 타당하다. 다행히 역전파를 사용하여 쉽게 만들 수 있고, 케라스를 사용하면 더 쉽다. Embedding 층의 가중치를 학습하면 된다.
from keras.layers import Embedding

embedding_layer = Embedding(1000, 64)
  • Embedding 층을 (특정 단어를 나타내는) 정수 인덱스를 밀집 벡터로 매핑하는 딕셔너리로 이해하는 것이 가장 좋습니다. 정수를 입력으로 받아 내부 딕셔너리에서 이 정수에 연관된 벡터를 찾아 반환한다. 딕셔너리 탐색은 효율적으로 수행된다.
    • 단어 인덱스 -> Embedding 층 -> 연관된 단어 벡터
  • Embedding 층은 크기가 (samples, sequence_length)인 2D 정수 텐서를 입력으로 받는다. 각 샘플은 정수의 시퀀스이다. 가변 길이의 시퀀스를 임베딩에 임베딩할 수 있다.
    • 예컨대 앞 예제의 Embedding 층에 (32, 10) 크기의 배치(길이가 10인 시퀀스 32개로 이루어진 배치)나 (64, 15) 크기의 배치를 주입할 수 있다.
    • 배치에 있는 모든 시퀀스는 길이가 같아야 하므로 (하나의 텐서에 담아야 하기 때문에) 작은 길이의 시퀀스는 0으로 패딩되고 길이가 더 긴 시퀀스는 잘린다.
  • Embedding 층은 크기가 (samples, sequence_length, embedding_dimensionality)인 3D 실수형 텐서를 반환한다.
    • 이런 3D 텐서는 RNN 층이나 1D 합성곱 층에서 처리된다.
  • Embedding 층의 객체를 생성할 때 가중치(토큰 벡터를 위한 내부 딕셔너리)는 다른 층과 마찬가지로 랜덤하게 초기화된다.
    • 훈련하면서 이 단어 벡터는 역전파를 통해 점차 조정되어 이어지는 모델이 사용할 수 있도록 임베딩 공간을 구성한다.
    • 훈련이 끝나면 임베딩 공간은 특정 문제에 특화된 구조를 많이 가지게 된다.
  • IMDB 영화 리뷰 감성 예측 문제에 적용해 보자. 우선 데이터를 준비한다.
    • 영화 리뷰에서 가장 빈도가 높은 1만 개의 단어를 추출하고 리뷰에서 20개 단어 이후는 버린다.
    • 이 네트워크는 1만 개의 단어에 대해 8차원의 임베딩을 학습하여 정수 시퀀스 입력(2D 정수 텐서)을 임베딩 시퀀스(3D 실수형 텐서)로 바꿀 것이다.
    • 그 다음 이 텐서를 2D로 펼쳐서 분류를 위한 Dense 층을 훈련하겠다.
from keras.datasets import imdb
from keras import preprocessing

max_features = 10000
maxlen = 20

(x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=max_features)

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()
model.add(Embedding(10000, 8, input_length=maxlen))

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)

---
Train on 20000 samples, validate on 5000 samples Epoch 1/10 20000/20000 [==============================] - 1s 37us/step - loss: 0.6748 - acc: 0.6086 - val_loss: 0.6285 - val_acc: 0.6978 Epoch 2/10 20000/20000 [==============================] - 1s 33us/step - loss: 0.5490 - acc: 0.7491 - val_loss: 0.5282 - val_acc: 0.7304 Epoch 3/10 20000/20000 [==============================] - 1s 34us/step - loss: 0.4623 - acc: 0.7865 - val_loss: 0.5007 - val_acc: 0.7430 Epoch 4/10 20000/20000 [==============================] - 1s 33us/step - loss: 0.4223 - acc: 0.8091 - val_loss: 0.4940 - val_acc: 0.7496 Epoch 5/10 20000/20000 [==============================] - 1s 34us/step - loss: 0.3960 - acc: 0.8224 - val_loss: 0.4964 - val_acc: 0.7534 Epoch 6/10 20000/20000 [==============================] - 1s 33us/step - loss: 0.3757 - acc: 0.8339 - val_loss: 0.5000 - val_acc: 0.7488 Epoch 7/10 20000/20000 [==============================] - 1s 33us/step - loss: 0.3581 - acc: 0.8445 - val_loss: 0.5059 - val_acc: 0.7502 Epoch 8/10 20000/20000 [==============================] - 1s 33us/step - loss: 0.3408 - acc: 0.8545 - val_loss: 0.5126 - val_acc: 0.7476 Epoch 9/10 20000/20000 [==============================] - 1s 32us/step - loss: 0.3243 - acc: 0.8641 - val_loss: 0.5191 - val_acc: 0.7482 Epoch 10/10 20000/20000 [==============================] - 1s 32us/step - loss: 0.3084 - acc: 0.8715 - val_loss: 0.5257 - val_acc: 0.7486
  • 약 75% 가량의 검증 정확도가 나오는데, 20개의 단어만 사용했음을 생각해 보면 괜찮은 결과이다.
    • 하지만 임베딩 시퀀스를 펼치고 하나의 Dense 층을 훈련했으므로 입력 시퀀스에 있는 각 단어를 독립적으로 다루었다. 단어 사이의 관계나 문장 구조를 고려하지 않았다.
    • 각 시퀀스 전체를 고려한 특성을 학습하도록 임베딩 층 위에 순환 층이나 1D 합성곱을 추가하는 것이 좋다. 이는 다음절에서 다루겠다.

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

  • 풀려는 문제와 함께 단어 임베딩을 학습하는 대신 미리 계산된 임베딩 공간에서 임베딩 벡터를 로드할 수 있다. 이런 임베딩 공간은 뛰어난 구조와 유용한 성질을 가지고 있어서 언어 구조의 일반적인 측면을 잡아낼 수 있다.
    • 자연어 처리에서 사전 훈련된 단어 임베딩을 사용하는 이유는 이미지 분류 문제에서 사전 훈련된 컨브넷을 사용하는 이유와 거의 동일하다.
    • 충분한 데이터가 없어서 자신만의 좋은 특성을 학습하지 못하지만 꽤 일반적인 특성이 필요할 때이다. 이런 경우에는 다른 문제에서 학습한 특성을 재사용하는 것이 합리적이다.
  • 단어 임베딩은 일반적으로 (문장이나 문서에 같이 등장하는 단어를 관찰하는) 단어 출현 통계를 사용해서 계산된다.
    • 여기에는 여러 기법이 있는데 신경망을 사용하는 것도 있고 그렇지 않은 방법도 있다.
    • 단어를 위해 밀집된 저차원 임베딩 공간을 비지도 학습 방법으로 계산하는 아이디어는 요슈아 벤지오 등이 2000년대 초에 조사했다.
    • 연구나 산업 애플리케이션에 적용되기 시작한 것은 Word2Vec 알고리즘(https://code.google.com/archive/p/word2vec)이 등장한 이후이다.
    • 이 알고리즘은 2013년 구글의 토마스 미코로프(Tomas Mikolov)가 개발했으며, 가장 유명하고 성공적인 단어 임베딩 방법이다. Word2Vec의 차원은 성별처럼 구체적인 의미가 있는 속성을 잡아낸다.
  • 케라스의 Embedding 층을 위해 내려 받을 수 있는 미리 계산된 단어 임베딩 데이터베이스가 여럿 있는데, Word2Vec 외에 인기 있는 또 다른 하나는 2014년 스탠포드 대학의 연구자들이 개발한 GloVe(Global Vectors for Word Representation)(https://nlp.stanford.edu/projects/glove)이다.
    • 이 임베딩 기법은 단어의 동시 출현(co-occurrence) 통계를 기록한 행렬을 분해하는 기법을 사용한다.
    • 이 개발자들은 위키피디아 데이터와 커먼 크롤(Common Crawl) 데이터에서 가져온 수백만 개의 영어 토큰에 대해 임베딩을 미리 계산해 놓았다.
  • GloVe 임베딩을 케라스 모델에 어떻게 사용하는지 알아보자. Word2Vec 임베딩이나 다른 단어 임베딩 데이터베이스도 방법은 같다.

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

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

원본 IMDB 텍스트 내려받기

  • http://mng.bz/0tIo에서 IMDB 원본 데이터셋을 내려받고 압축을 해제한다.
  • 훈련용 리뷰 하나를 문자열 하나로 만들어 훈련 데이터를 문자열의 리스트로 구성해 보자. 리뷰 레이블(긍정/부정)도 labels 리스트로 만들겠다.
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)

데이터 토큰화

  • 텍스트를 벡터로 만들고 훈련 세트와 검증 세트로 나누겠다. 사전 훈련된 단어 임베딩은 훈련 데이터가 부족한 문제에 특히 유용하다. (그렇지 않으면 문제에 특화된 임베딩이 훨씬 성능이 좋다)
from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
import numpy as np

maxlen = 100
training_samples = 200
validation_samples = 10000
max_words = 10000

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차원의 임베딩 벡터를 포함하고 있다.

임베딩 전처리

  • 압축 해제한 파일(.txt 팡리)을 파싱하여 단어(즉 문자열)와 이에 상응하는 벡터 표현(즉 숫자 벡터)을 매핑하는 인덱스를 만든다.
glove_dir = './datasets/glove/'

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()

print('%s개의 단어 벡터를 찾았습니다.' % len(embeddings_index))
  • 그 다음 Embedding 층에 주입할 수 있도록 임베딩 행렬을 만든다. 이 행렬의 크기는 (max_words, embedding_dim)이어야 한다.
    • 이 행렬의 i번째 원소는 (토큰화로 만든) 단어 인덱스의 i번째 단어에 상응하는 embedding_dim 차원 벡터이다.
    • 인덱스 0은 어떤 단어나 토큰도 아닐 경우를 나타낸다.
embedding_dim = 100

embedding_matrix = np.zeros((max_words, embedding_dim))

for word, i in word_index.items():
if i < max_words:
embedding_vector = embeddings_index.get(word)
if embedding_vector is not None:
embedding_matrix[i] = embedding_vector

모델 정의하기

  • 이전과 동일한 구조의 모델을 사용한다.
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.summary()
---
Layer (type) Output Shape Param # ================================================================= embedding_1 (Embedding) (None, 100, 100) 1000000 _________________________________________________________________ flatten_1 (Flatten) (None, 10000) 0 _________________________________________________________________ dense_1 (Dense) (None, 32) 320032 _________________________________________________________________ dense_2 (Dense) (None, 1) 33 ================================================================= Total params: 1,320,065 Trainable params: 1,320,065 Non-trainable params: 0

모델에 GloVe 임베딩 로드하기

  • Embedding 층은 하나의 가중치 행렬을 가진다. 이 행렬은 2D 부동소수 행렬이고 각 i번째 원소는 i번째 인덱스에 상응하는 단어 벡터이다.
    • 모델의 첫 번째 층인 Embedding 층에 준비된 GloVe 행렬을 로드하자.
model.layers[0].set_weights([embedding_matrix])
model.layers[0].trainable = False
  • 추가적으로 Embedding 층을 동결한다.
    • 사전 훈련된 컨브넷 특성을 사용할 떄와 같은 이유이다. 모델의 일부는 사전 훈련되고 다른 부분은 랜덤하게 초기화 되었다면 훈련하는 동안 사전 훈련된 부분이 업데이트 되면 안 된다.
    • 이미 알고 있던 정보를 모두 잃게 된다. 랜덤하게 초기화된 층에서 대량의 그래디언트 업데이트가 발생하면 이미 학습된 특성을 오염시키기 때문이다.

모델 훈련과 평가

  • 모델을 컴파일하고 훈련한다.
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('Trainging and validation accuarcy')
plt.legend()

plt.figure()

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

plt.show()

  • 이 모델은 과대적합이 빠르게 시작된다.
    • 훈련 샘플 수가 작기 때문에 놀라운 일은 아니다. 같은 이유로 검증 정확도와 훈련 정확도 사이에 차이가 크다. 검증 정확도는 50% 후반을 달성한 것 같다.
    • 훈련 샘플 수가 적기 때문에 어떤 샘플 200개를 선택했는지에 따라 성능이 크게 좌우된다. 여기서는 샘플들을 랜덤하게 선택했다.
  • 사전 훈련된 단어 임베딩을 사용하지 않거나 임베딩 층을 동결하지 않고 같은 모델을 훈련할 수 있다.
    • 이런 경우 해당 작업에 특화된 입력 토큰의 임베딩을 학습할 것이다. 데이터가 풍부하게 있다면 사전 훈련된 단어 임베딩보다 훨씬 성능이 높다.
    • 훈련 샘플이 200개 뿐이지만 시도해 보자.
    • (그래프 그리는 코드는 계속 동일한 코드가 반복되므로 생략)
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.summary()

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% 초반에 멈추어있다. 이 예제에서는 사전 훈련된 단어 임베딩을 사용하는 것이 더 낫다. 
  • 마지막으로 테스트 데이터에서 모델을 평가해 보자. 우선 테스트 데이터를 토큰화 해야 한다.
test_dir = os.path.join(imdb_dir, 'test')

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)

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)

정리

  • 이제 다음 작업을 할 수 있다.
    • 원본 텍스트를 신경망이 처리할 수 있는 형태로 변환한다.
    • 케라스 모델에 Embedding 층을 추가하여 어떤 작업에 특화된 토큰 임베딩을 학습한다.
    • 데이터가 부족한 자연어 처리 문제에서 사전 훈련된 단어 임베딩을 사용하여 성능 향상을 꾀한다.

순환 신경망 이해하기

  • 완전 연결 네트워크가 컨브넷처럼 지금까지 본 모든 신경망의 특징은 메모리가 없다는 것. 네트워크에 주입되는 입력은 개별적으로 처리되며 입력 간에 유지되는 상태가 없다.
    • 이런 네트워크로 시퀀스나 시계열 데이터 포인트를 처리하려면 네트워크에 전체 시퀀스를 주입해야 한다. 즉 전체 시퀀스를 하나의 데이터 포인트로 변환해야 한다.
    • 예컨대 IMDB 문제에서 영화 리뷰 하나를 큰 벡터 하나로 변환해서 처리했는데, 이런 네트워크를 피드포워드 네트워크(feedforward network)라고 한다.
  • 이와 반대로 사람이 문장을 읽는 것처럼 이전에 나온 것을 기억하면서 단어별로 또는 한눈에 들어오는 만큼씩 처리할 수 있다. 이는 문장에 있는 의미를 자연스럽게 표현하도록 도와준다.
    • 생물학적 지능은 정보처리를 위한 내부 모델을 유지하면서 점진적으로 정보를 처리한다. 
    • 이 모델은 과거 정보를 사용하여 구축되며 새롭게 얻은 정보를 계속 업데이트 한다.
  • 극단적으로 단순화 시킨 버전이지만 순환 신경망(Recurrent Neural Network, RNN)은 같은 원리를 적용한 것이다. 시퀀스의 원소를 순회하면서 지금까지 처리한 정보를 상태(state)에 저장한다.
    • 사실 RNN은 내부에 루프(loop)를 가진 신경망의 한 종류이다. RNN의 상태는 2개의 다른 시퀀스(2개의 다른 IMDB 리뷰)를 처리하는 사이에 재설정된다.
    • 하나의 시퀀스가 여전히 하나의 데이터 포인트로 간주된다. 즉 네트워크에 하나의 입력을 주입한다고 가정한다. 하지만 이 데이터 포인트가 한 번에 처리 되지 않는다는 점이 다르다. 그 대신 네트워크는 시퀀스의 원소를 차례대로 방문한다.

  • 루프와 상태에 대한 개념을 명확히 하기 위해 넘파이로 RNN 정방향 계산을 구현해 보자.
    • RNN은 크기가 (timesteps, input_features)인 2D 텐서로 인코딩된 벡터의 시퀀스를 입력 받는다.
    • 이 시퀀스는 타임스텝을 따라서 반복된다. 각 타임스텝 t에서 현재 상태와 ((input_features,)크기의)입력을 연결하여 출력을 계산한다.
    • 그 다음 이 출력을 다음 스텝의 상태로 설정한다. 첫 번째 타임스텝에서는 이전 출력이 정의되지 않으므로 현재 상태가 없다. 이때는 네트워크의 초기 상태(initial state)인 0 벡터로 상태를 초기화 한다.
  • 의사코드(pseudocode)로 표현하면 RNN은 다음과 같다.
state_t = 0

for input_t in input_sequence:
output_t = f(input_t, state_t)
state_t = output_t ---출력은 다음 반복을 위한 상태가 된다.
  • f 함수는 입력과 상태를 출력으로 변환한다. 이를 2개의 행렬 W와 U 그리고 편향 벡터를 사용하는 변환으로 바꿀 수 있다. 피드포워드 네트워크의 완전 연결 층에서 수행되는 변환과 비슷하다.
state_t = 0

for input_t in input_sequence:
output_t = activation(dot(W, input_t) + dot(U, state_t) + b)
state_t = output_t
  • 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,))

successive_outputs = []

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

final_output_sequence = np.stack(successive_outputs, axis=0)
  • 요약하면 RNN은 반복할 때 이전에 계산한 정보를 재사용하는 for 루프에 지나지 않는다.
    • 물론 이 정의에 맞는 RNN 의 종류는 많다. 이 예는 가장 간단한 RNN의 형태이다. 
    • RNN은 스텝(step) 함수에 의해 특화된다.

케라스의 순환 층

  • 넘파이로 간단하게 구현한 과정이 실제 케라스의 SimpleRNN 층에 해당한다. SimpleRNN이 한 가지 다른 점은 넘파이 예제처럼 하나의 시퀀스가 아니라 다른 케라스 층와 마찬가지로 시퀀스 배치를 처리한다는 것이다. 
    • 즉 (timesteps, input_features) 크기가 아니라 (batch_size, timesteps, input_features) 크기의 입력을 받는다.
  • 케라스에 있는 모든 순환 층과 마찬가지로 SimpleRNN은 두 가지 모드로 실행할 수 있다.
    • 각 타임스텝의 출력을 모은 전체 시퀀스를 반환하거나 (크기가 (batch_size, timesteps, output_features)인 3D 텐서), 입력 시퀀스에 대한 마지막 출력만 반환할 수 있다 (크기가 (batch_size, output_features)인 2D 텐서)
    • 이 모드는 객체를 생성할 때 return_sequences 매개변수로 선택할 수 있다. 
  • SimpleRNN을 사용하여 마지막 타임스텝의 출력만 얻는 예제를 살펴보자.
from keras.models import Sequential
from keras.layers import Embedding, SimpleRNN

model = Sequential()
model.add(Embedding(10000, 32))
model.add(SimpleRNN(32))
model.summary()
---
Layer (type) Output Shape Param # ================================================================= embedding_1 (Embedding) (None, None, 32) 320000 _________________________________________________________________ simple_rnn_1 (SimpleRNN) (None, 32) 2080 ================================================================= Total params: 322,080 Trainable params: 322,080 Non-trainable params: 0
  • 다음 예는 전체 상태 시퀀스를 반환한다.
model = Sequential()
model.add(Embedding(10000, 32))
model.add(SimpleRNN(32, return_sequences=True))
model.summary()
---
Layer (type) Output Shape Param # ================================================================= embedding_2 (Embedding) (None, None, 32) 320000 _________________________________________________________________ simple_rnn_2 (SimpleRNN) (None, None, 32) 2080 ================================================================= Total params: 322,080 Trainable params: 322,080 Non-trainable params: 0
  • 네트워크의 표현력을 증가시키기 위해 여러 개의 순환 층을 차례대로 쌓는 것이 유용할 때가 있다. 이런 설정에서는 중간층들이 전체 출력 시퀀스를 반환하도록 설정해야 한다.
model = Sequential()
model.add(Embedding(10000, 32))
model.add(SimpleRNN(32, return_sequences=True))
model.add(SimpleRNN(32, return_sequences=True))
model.add(SimpleRNN(32, return_sequences=True))
model.add(SimpleRNN(32))
model.summary()
---
Layer (type) Output Shape Param # ================================================================= embedding_3 (Embedding) (None, None, 32) 320000 _________________________________________________________________ simple_rnn_3 (SimpleRNN) (None, None, 32) 2080 _________________________________________________________________ simple_rnn_4 (SimpleRNN) (None, None, 32) 2080 _________________________________________________________________ simple_rnn_5 (SimpleRNN) (None, None, 32) 2080 _________________________________________________________________ simple_rnn_6 (SimpleRNN) (None, 32) 2080 ================================================================= Total params: 328,320 Trainable params: 328,320 Non-trainable params: 0
  • IMDB 영화 리뷰 분류 문제에 적용해 보자. 우선 데이터를 전처리한다.
from keras.datasets import imdb
from keras.preprocessing import sequence

max_features = 10000
maxlen = 500
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 층을 사용하여 간단한 순환 네트워크를 훈련시켜보자
from keras.models import Sequential
from keras.layers import Embedding, SimpleRNN, 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)
  • 이제 훈련과 검증의 손실과 정확도를 그래프로 그린다.
    • (그래프 그리는 코드는 계속 동일한 코드가 반복되므로 생략)

  • 3장에서 이 데이터셋을 사용한 첫 번째 모델에서 얻은 테스트 정확도는 87%였다. 안타깝지만 간단한 순환 네트워크는 이 기준 모델 보다 성능이 높지 않다. (85%)
    • 이런 원인은 전체 시퀀스가 아니라 처음 500개의 단어만 입력에 사용했기 때문이다. 이 RNN은 기준 모델보다 얻은 정보가 적다.
    • 다른 이유는 SimpleRNN이 텍스트처럼 긴 시퀀스를 처리하는데 적합하지 않기 때문이다.
    • 더 잘 작동하는 다른 순환층이 있으므로 살펴보자

LSTM과 GRU 층 이해하기

  • 케라스에는 SimpleRNN 외에 다른 순환층도 있는데 LSTM과 GRU가 그것이다. 실전에서는 항상 이 둘 중 하나를 사용할 것이다. SimpleRNN은 실전에 쓰기에는 너무 단순하기 때문이다.
    • SimpleRNN은 이론적으로 시간 t에서 이전의 모든 타임스텝의 정보를 유지할 수 있지만 실제로 긴 시간에 걸친 의존성은 학습할 수 없는 것이 문제이다.
    • 층이 많은 일반 네트워크(피드포워드 네트워크)에서 나타나는 것과 비슷한 현상인 그래디언트 소실 문제(vanishing gradient problem) 때문이다. 
  • 피드포워드 네트워크에 층을 많이 추가할 수록 훈련하기 어려운 것과 같다. 1990년대 초 호크라이더(Hochreiter), 슈미트후버(Schmidhuber), 벤지오(Bengio)가 이런 현상에 대한 이론적인 원인을 연구했다. 이 문제를 해결하기 위해 고안된 것이 LSTM과 GRU 층이다.
  • LSTM 층을 살펴보자. 장, 단기 메모리(Long Short-Term Memory, LSTM) 알고리즘은 호크라이더와 슈미트후버가 1997년 개발했다. 이 알고리즘은 그래디언트 소실 문제에 대한 연구의 결정체이다.
    • 이 층은 앞서 보았던 SimpleRNN의 한 변종이다. 정보를 여러 타임스텝에 걸쳐 나르는 방법이 추가된다.
    • 처리할 시퀀스에 나란히 작동하는 컨베이어 벨트를 생각해보자. 시퀀스 어느 지점에서 추출된 정보가 컨베이어 벨트 위로 올라가 필요한 시점의 타임스텝으로 이동하여 떨군다.
    • 이것이 LSTM이 하는 일이다. 나중을 위해 정보를 저장함으로써 처리 과정에서 오래된 시그널이 점차 소실되는 것을 막아 준다.
  • 이를 자세히 이해하기 위해 SimpleRNN 셀(cell)부터 그려보자.
    • 가중치가 여러 개가 나오므로 출력(output)을 타나내는 문자 o로 셀에 있는 W와 U 행렬을 표현하겠다. (Wo와 Uo)

  • 이 그림에 타임스텝을 가로질러 정보를 나르는 데이터 흐름을 추가해 보자.
    • 타임스텝 t에서 이 값을 이동 상태 c_t라고 부르겠다. 여기서 c는 이동(carry)를 의미한다.
    • 이 정보를 사용하여 셀이 다음과 같이 바뀐다. 입력 연결과 순환 연결(상태)로부터 이 정보가 합성된다(완전 연결층과 같은 변환: 가중치 행렬과 점곱한 후 편향을 더하고 활성화 함수를 적용한다)
    • 그러고는 다음 타임스텝으로 전달될 상태를 변경시킨다(활성화 함수와 곱셈을 통해서)
    • 개념적으로 보면 데이터를 실어 나르는 이 흐름이 다음 출력과 상태를 조절한다.

  • 여기까지는 간단하다. 이제 복잡한 부분은 데이터 흐름에서 다음 이동 상태(c_t+1)이 계산되는 방식이다. 여기에는 3개의 다른 변환이 관련되어 있다. 3개 모두 SimpleRNN과 같은 형태를 가진다.
y = activation(dot(state_t, U) + dot(input_t, W) + b)
  • 3개의 변환 모두 자신만의 가중치 행렬을 가진다. 각각 i, f, k로 표시하겠다. 다음이 지금까지 설명한 내용이다.
output_t = activation(c_t) * activation(dot(input_t, Wo) + dot(state_t, 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)
  • i_t, f_t, k_t를 결합하여 새로운 이동 상태 (c_t+1)을 구한다.
c_t + 1 = i_t * f_t * k_t
  • 아래 그림에 이를 추가했다.
    • (SimpleRNN이 단순히 이전 출력만을 이용하는 반면, LSMT에서는 carry 되는 값을 추가로 기존 결과에 곱해준다.)
    • (문제는 이 carray 되는 값을 어떻게 구하느냐 인데, 그것은 i, f, k라는 3개의 다른 가중치 행렬을 이용해서 output을 각각 구하고, 그것들의 합하여 최종적으로 carry 되는 값을 만든다)
    • (최종적으로 그렇게 구해진 carry되는 값을 원래 그 시점에 구해야 하는 output과 곱해서 최종 output을 만든다.)

  • 이 연산들이 하는 일을 해석하면 각 의미에 대해 통찰을 얻을 수 있다.
    • 예컨대 c_t와 f_t의 곱셈은 이동을 위한 데이터 흐름에서 관련이 적은 정보를 의도적으로 삭제한다고 할 수 있다.
    • 한편 i_t와 k_t는 현재에 대한 정보를 제공하고 이동 트랙을 새로운 정보로 업데이트 한다.
    • 하지만 결국 이런 해석은 큰 의미가 없다. 이 연산들이 실제로 하는 일은 연산에 관련된 가중치 행렬에 따라 결정되기 때문이다.
    • 이 가중치는 end-to-end 방식으로 학습된다. 이 과정은 훈련 반복마다 매번 새로 시작되며 이런저런 연산들에 특정 목적을 부여하기가 불가능하다.
  • RNN 셀의 사양(specification)은 가설 공간을 결정한다. 훈련할 때 이 공간에서 좋은 모델 파라미터를 찾는다.
    • 셀의 사양이 셀이 하는 일을 결정하지 않는다. 이는 셀의 가중치에 달려 있다.  같은 셀이라도 다른 가중치를 가지는 경우 매우 다른 작업을 수행한다.
    • 따라서 RNN 셀을 구성하는 연산 조합은 엔지니어링 적인 설계가 아니라 가설 공간의 제약 조건으로 해석하는 것이 낫다.
  • 연구자에게는 RNN 셀의 구현 방법 같은 제약 조건의 선택을 엔지니어보다 (유전 알고리즘이나 강화 학습 알고리즘 같으) 최적화 알고리즘에 맡기면 더 나아보일 것이다. 미래에는 이런 식으로 네트워크를 만들게 될 것이다.
    • 요약하면 LSTM 셀의 구체적인 구조에 대해 이해할 필요가 전혀 없고 우리가 해야 할 일도 아니다. LSTM 셀의 역할만 기억하면 된다. 바로 과거 정보를 나중에 다시 주입하여 그래디언트 소실 문제를 해결하는 것이다.

케라스를 사용한 LSTM 예제

  • 이제 LSTM 층으로 모델을 구성하고 IMDB 데이터에서 훈련해 보자.
    • 이 네트워크는 SimpleRNN에서 사용했던 모델과 비슷하다. LSTM 층은 출력 차원만 지정하고 다른 매개변수는 케라스의 기본값으로 남겨두었다.
    • 케라스는 좋은 기본값을 가지고 있어서 직접 매개변수를 튜닝하는데 시간을 쓰지 않고도 거의 항상 어느 정도 작동하는 모델을 얻을 수 있다.
from keras.models import Sequential
from keras.layers import Embedding, Dense,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)

  • 이번에는 88% 정도의 검증 정확도를 달성했다. SimpleRNN 보다 확실히 더 낫다. LSTM이 그래디언트 소실 문제로부터 덜 영향을 받기 때문이다.
    • 3장의 완전 연결 네트워크보다 조금 더 낫다.
  • 하지만 많은 계산을 사용한 것치고는 획기적인 결과는 아니다. 왜 LSTM의 성능이 더 높지 않을까? 
    • 한 가지 이유는 임베딩 차원이나 LSTM 출력 차원 같은 하이퍼파라미터를 전혀 튜닝하지 않았기 때문이다. 또 하나는 규제가 없기 때문이다.
    • 솔직히 말하면 가장 큰 이유는 리뷰를 전체적으로 길게 분석하는 것(LSTM이 잘하는 일)은 감성 분류 문제에 도움이 되지 않기 때문이다.
    • 이런 간단한 문제는 각 리뷰에 어떤 단어가 나타나고 얼마나 등장하는지를 보는 것이 낫다. 바로 첫 번째 완전 연결 네트워크가 사용한 방법이다.
    • 하지만 훨씬 더 복잡한 자연어 처리 문제들에서는 LSTM 능력이 드러난다. 특히 질문-응답과 기계 번역 분야이다.

정리

  • NN이 무엇이고 동작하는 방법
  • LSTM이 무엇이고 긴 시퀀스에서 단순한 RNN보다 더 잘 작동하는 이유
  • 케라스의 RNN 층을 사용해서 시퀀스 데이터를 처리하는 방법

순환 신경망의 고급 사용법

  • 여기서는 순환 신경망의 성능과 일반화 능력을 향상시키기 위한 세 가지 고급 기술을 살펴보겠다. 온도 예측 문제로 세 가지 개념을 모두 시연해 보겠다. 이 문제는 시계열 데이터에서 일반적으로 나타나는 여러 어려운 점을 갖고 있는데, 전형적이고 꽤 도전적인 문제이다.
  • 다음 기법들을 적용하겠다.
    • 순환 드롭아웃(recurrent dropout): 순환 층에서 과대적합을 방지하기 위해 케라스에 내장되어 있는 드롭아웃을 사용한다.
    • 스태킹 순환 층(stacking recurrent layer): 네트워크의 표현 능력(representational power)를 증가시킨다. (그 대신 계산 비용이 많이 든다)
    • 양방향 순환 층(bidirectional recurrent layer): 순환 네트워크에 같은 정보를 다른 방향으로 주입하여 정확도를 높이고 기억을 좀 더 오래 유지시킨다.

기온 예측 문제

  • 지금까지 다룬 시퀀스 데이터는 IMDB 데이터셋이나 로이터 데이터셋처럼 텍스트 데이터이다. 시퀀스 데이터는 이런 언어 처리 뿐만 아니라 훨씬 많은 문제에서 등장한다.
    • 이 절에 있는 모든 예제는 날씨 시계열 데이터셋을 사용한다. 이 데이터는 독일 예나(Jena) 시에 있는 막스 플랑크 생물지구화학 연구소의 지상 관측소에서 수집한 것이다.
  • 다음 경로에서 데이터를 내려받고 압축을 불자.
import os

data_dir = 'E:/Study/Keras Deep Learning/datasets/jena/'
fname = os.path.join(data_dir, 'jena_climate_2009_2016.csv')

f = open(fname)
data = f.read()
f.close()

lines = data.split('\n')
header = lines[0].split(',')
lines = lines[1:]
  • line 수를 출력해 보면 42만 개가 나온다. 데이터 전체를 넘파이 배열로 바꾸자.
import numpy as np

float_data = np.zeros((len(lines), len(header) - 1))

for i, line in enumerate(lines):
values = [float(x) for x in line.split(',')[1:]]
float_data[i, :] = values
  • 온도를 그래프로 그려보자.
from matplotlib import pyplot as plt

temp = float_data[:, 1]
plt.plot(range(len(temp)), temp)

  • 기간을 좁혀서 처음 10일간 온도 데이터를 나타내면 다음과 같다.

데이터 준비

  • 이 문제의 정확한 정의는 다음과 같다. lookback 타임스텝만큼 이전으로 돌아가서 매 steps 타임스텝마다 샘플링한다. 이 데이터를 바탕으로 delay 타임스텝 이후의 온도를 예측할 수 있을까? 사용할 변수는 다음과 같다.
    • lookback = 1440: 10일 전 데이터로 돌아간다.
    • steps = 6: 1시간마다 데이터 포인트 하나를 샘플링한다.
    • delay = 144 : 24시간이 지난 데이터가 타깃이 된다.
  • 시작하기 전에 두 가지 작업을 해야 한다.
    • 신경망에 주입할 수 있는 형태로 데이터를 전처리한다. 데이터가 이미 수치형이므로 추가적인 벡터화는 필요하지 않다. 하지만 데이터에 있는 각 시계열 특성의 범위가 서로 다르므로 시계열 특성을 개별적으로 정규화하여 비슷한 범위를 가진 작은 값으로 바꾸자.
    • float_date 배열을 받아 과거 데이터의 배치와 미래 타깃 온도를 추출하는 파이썬 제너레이터(generator)를 만든다. 이 데이터셋에 있는 샘플은 중복이 많다. (샘플 N과 샘플 N+1은 대부분 타임스텝이 비슷하다) 모든 샘플을 각기 메모리에 적재하는 것은 낭비가 심하므로 대신 원본 데이터를 사용하여 그때그때 배치를 만들자.
  • 각 시계열 특성에 대해 평균을 빼고 표준 편차로 나누어 전처리한다. 
    • 처음 20만개만 훈련 데이터로 사용할 것이므로 전체 데이터에서 20만 개만 사용해서 평균과 표준편차를 구한다.
data = float_data[:200000]
mean = data.mean(axis=0)
float_data -= mean
std = data.std(axis=0)
float_data /= std
  • 아래 코드는 여기서 사용할 제너레이터이다. 이 제너레이터 함수는 (samples, targets) 튜플을 반복적으로 반환한다. samples는 입력 데이터로 사용할 배치고, targets은 이에 대응되는 타깃 온도의 배열이다. 이 제네레이터 함수에는 다음 매개변수가 있다.
    • data: 정규화한 부동 소수 데이터로 이루어진 원본 배열
    • lookback: 입력으로 사용하기 위해 거슬러 올라갈 타임스텝
    • delay: 타깃으로 사용할 미래의 타임스텝
    • min_index와 max_index: 추출할 타임스텝의 범위를 지정하기 위한 data 배열의 인덱스. 검증 데이터와 테스트 데이터를 분리하는데 사용한다.
    • shuffle: 샘플을 섞을지 시간 순서대로 추출할지 결정한다.
    • batch_size: 배치의 샘플 수
    • step: 데이터를 샘플링할 타임스텝 간격. 1시간에 하나의 데이터 포인트를 추출하기 위해 6으로 지정한다.
def generator(data, lookback, delay, min_index, max_index, shuffle=False, batch_size=128, step=6):
if max_index is None:
max_index = len(data) - delay - 1

i = min_index + lookback

while 1:
if shuffle:
rows = np.random.randint(min_index + lookback, max_index, size=batch_size)
else:
if i + batch_size >= max_index:
i = min_index + lookback

rows = np.arange(i, min(i + batch_size, max_index))
i += len(rows)

samples = np.zeros((len(rows), lookback // step, data.shape[-1]))
targets = np.zeros((len(rows),))

for j, row in enumerate(rows):
indices = range(rows[j] - lookback, rows[j], step)
samples[j] = data[indices]
targets[j] = data[rows[j] + delay][1]

yield samples, targets
  • 이제 generator 함수를 사용하여 훈련용, 검증용, 테스트용으로 3개의 제너레이터를 만들어 보자.
    • 각 제너레이터는 원본 데이터에서 다른 시간대를 사용한다. 훈련 제너레이터는 처음 20만 개 타임스텝을 사용하고, 검증 제너레이터는 그 다음 10만 개, 테스트 제너레이터는 나머지를 사용한다.
lookback = 1440
step = 6
delay = 144
batch_size = 128

train_gen = generator(float_data, lookback=lookback, delay=delay, min_index=0, max_index=200000, shuffle=True, step=step, batch_size=batch_size)
validation_gen = generator(float_data, lookback=lookback, delay=delay, min_index=200001, max_index=300000, shuffle=True, step=step, batch_size=batch_size)
test_gen = generator(float_data, lookback=lookback, delay=delay, min_index=300001, max_index=None, shuffle=True, step=step, batch_size=batch_size)

val_steps = (300000 - 200001 - lookback) // batch_size
test_steps = (len(float_data) - 300001 - lookback) // batch_size

상식 수준의 기준점

  • 예측 문제를 풀기 전에 상식 수준의 해법을 시도해 보자. 이는 정상 여부 확인을 위한 용도고 고수준 머신 러닝 모델이라면 뛰어넘어야 할 기준점을 만든다.
    • 이런 상식 수준의 해법은 알려진 해결책이 없는 새로운 문제를 다루어야 할 때 유용하다.
    • 일부 클래스가 월등히 많아 불균형한 분류 문제가 고전적인 예이다. 데이터셋에 클래스 A의 샘플이 90%, 클래스 B의 샘플이 10%가 있다면, 이 분류 문제에 대한 상식 수준의 접근법은 새로운 샘플을 항상 클래스 ‘A’라고 예측하는 것이다.
    • 이 분류기는 전반적으로 90%의 정확도를 낼 것이다. 이따금 이런 기본적인 기준점을 넘어서기가 아주 어려운 경우가 있다.
  • 이 경우 온도 시계열 데이터는 연속성이 있고 일자별로 주기성을 가진다고 가정할 수 있다. 그렇기 때문에 상식 수준의 해결책은 지금으로부터 24시간 후 온도는 지금과 동일하다고 예측하는 것이다.
    • 이 방법을 평균 절댓값 오차(MAE)로 평가해 보자.
def evaluate_naive_method():
batch_maes = []

for step in range(val_steps):
samples, targets = next(validation_gen)
preds = samples[:, -1, 1]
mae = np.mean(np.abs(preds - targets))
batch_maes.append(mae)

print(np.mean(batch_maes))

evaluate_naive_method()
---
0.28904772281241886
  • 출력된 MAE는 0.29이다. 이 온도 데이터는 평균이 0이고 표준 편차가 1이므로 결괏값이 바로 와닿지는 않는다. 평균 절댓값 오차 0.29에 표준 편차를 곱하면 섭씨 2.57이 된다.
  • 이제 딥러닝 모델이 더 나은지 시도해 보자

기본적인 머신 러닝 방법

  • 머신 러닝 모델을 시도하기 전에 상식 수준의 기준점을 세웠다. 비슷하게 RNN 처럼 복잡하고 연산 비용이 많이 드는 모델을 시도하기 전에 간단하고 손쉽게 만들 수 있는 머신 러닝 모델(예컨대 소규모의 완전 연결 네트워크)을 먼저 만드는 것이 좋다.
    • 이를 바탕으로 더 복잡한 방법을 도입하는 근거가 마련되고 실제적인 이득도 얻게 될 것이다.
  • 아래 코대는 데이터를 펼쳐서 2개의 Dense 층을 통과시키는 완전 연결 네트워크를 보여준다.
    • 전형적인 회귀 문제이므로 마지막 Dense 층에 활성화 함수를 두지 않았다. 손실 함수는 MAE 이다.
    • 상식 수준의 방법에서 사용한 것과 동일한 데이터와 지표를 사용했으므로 결과를 바로 비교해 볼 수 있다.
from keras.models import Sequential
from keras import layers
from keras.optimizers import RMSprop

model = Sequential()
model.add(layers.Flatten(input_shape=(lookback // step, float_data.shape[-1])))
model.add(layers.Dense(32, activation='relu'))
model.add(layers.Dense(1))

model.compile(optimizer=RMSprop(), loss='mae')

history = model.fit_generator(train_gen, steps_per_epoch=500, epochs=20, validation_data=validation_gen, validation_steps=val_steps)
  • 훈련 손실과 검증 손실의 그래프를 그려보자.
import matplotlib.pyplot as plt

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

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

plt.figure()

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

plt.show()

  • 일부 검증 손실은 학습을 사용하지 않은 기준점에 가깝지만 안정적이지 못하다.
    • 앞서 기준 모델을 만든 것이 도움이 된다. 이 문제는 기준 모델의 성능을 앞지르기가 쉽지 않다. 우리가 적용한 상식에는 머신 러닝 모델이 찾지 못한 핵심 정보가 많이 포함되어 있다.

첫 번째 순환 신경망

  • 첫 번째 완전 연결 네트워크는 잘 작동하지 않았다. 그렇다고 이 문제에 머신 러닝이 적합하지 않다는 뜻은 아니다. 
    • 앞선 모델은 시계열 데이터를 펼쳤기 때문에 입력 데이터에서 시간 개념을 잃어버렸다. 그 대신 인과 관계와 순서가 의미 있는 시퀀스 데이터를 그대로 사용해 보겠다.
    • 이런 시퀀스 데이터에 아주 잘 들어맞는 순환 시퀀스 모델을 시도해 보겠다. 이 모델은 앞선 모델과 달리 데이터 포인터의 시간 순서를 사용한다.
  • 이전 절에서 소개한 LSTM 층 대신 2014년에 정준영 등이 개발한 GRU 층을 사용하겠다.
    • GRU(Gated Recurrent Unit) 층은 LSTM 층과 같은 원리로 작동하지만 좀 더 간결하고 그래서 계산 비용이 덜 든다. (LSTM 만큼 표현 학습 능력이 높지는 않을 수 있다)
    • 계산 비용과 표현 학습 능력 사이의 트레이드오프(trade-off)는 머신 러닝 어디에서나 등장한다.
from keras.models import Sequential
from keras import layers
from keras.optimizers import RMSprop

model = Sequential()
model.add(layers.GRU(32, input_shape=(None, float_data.shape[-1])))
model.add(layers.Dense(1))

model.compile(optimizer=RMSprop(), loss='mae')

history = model.fit_generator(train_gen, steps_per_epoch=500, epochs=20, validation_data=validation_gen, validation_steps=val_steps)
  • 결과는 다음과 같다.

  • 상식 수준의 모델을 크게 앞질렀다. 이 그림은 시퀀스를 펼쳐서 처리하는 완전 연결 네트워크에 비해 순환 네트워크가 이런 종류의 작업에 훨씬 뛰어나다는 것과 머신 러닝의 가치를 보여준다.
  • 새로운 검증 MAE는 0.265 이하(크게 과대적합되기 시작하는 곳)이고 정규화되기 전인 섭씨로 복원하면 MAE는 2.35이다. 초기 에러 2.57보다 확실히 낫지만 더 개선할 수 있다.

과대적합을 감소하기 위해 순환 드롭아웃 사용하기

  • 훈련 손실과 검증 손실 곡선을 보면 모델이 과대적합인지 알 수 있다. 몇 번의 에포크 이후 훈련 손실과 검증 손실이 현저히 벌어지기 시작한다.
    • 이런 문제를 해결하기 위해 잘 알려진 드롭아웃 기법을 이미 보았다. 훈련 데이터를 층에 주입할 때 데이터에 있는 우연한 상관관계를 깨뜨리기 위해 입력 층의 유닛을 랜덤하게 끄는 기법이다.
    • 순환 신경망에 드롭아웃을 올바르게 적용하는 것은 간단하지 않다. 순환 층 이전에 드롭아웃을 적용하면 규제에 도움되는 것보다 학습에 더 방해되는 것으로 오랫동안 알려졌다.
  • 2015년 야린 갈(Yarin Gal)이 베이지안 딥러닝에 관한 박사 논문에서 순환 네트워크에 적절하게 드롭아웃을 사용하는 방법을 알아냈다.
    • 타임스텝마다 랜덤하게 드롭아웃 마스크를 바꾸는 것이 아니라 동일한 드롭아웃 마스크(동일한 유닛의 드롭 패턴)를 모든 타임스텝에 적용해야 한다.
    • GRU나 LSTM 같은 순환 게이트에 의해 만들어지는 표현을 규제하려면 순환 층 내부 계산에 사용된 활성화 함수에 타임스텝마다 동일한 드롭아웃 마스크를 적용해야 한다(순환 드롭 아웃 마스크)
    • 모든 타임스텝에 동일한 드롭 아웃 마스크를 적용하면 네트워크가 학습 오차를 타임스텝에 걸쳐 적절하게 전파시킬 것이다.
    • 타임스텝마다 랜덤한 드롭아웃 마스크를 적용하면 오차 신호가 전파되는 것을 방해하고 학습 과정에 해를 끼친다.
  • 야린 갈을 케라스를 사용해서 연구했고 케라스 순환 층에 이 기능을 구현하는데 도움을 주었다. 카레스에 있는 모든 순환 층은 2개의 드롭아웃 매개변수를 가지고 있다.
    • dropout은 층의 입력에 대한 드롭아웃 비율을 정하는 부동 소수 값이다. recurrent_dropout은 순환 상태의 드롭아웃 비율을 정한다.
  • GRU 층에 드롭아웃과 순환 드롭아웃을 적용하여 과대적합에 어떤 영향을 미치는지 살펴보자. 드롭아웃으로 규제된 네트워크는 언제나 완전히 수렴하는데 더 오래 걸린다. 에포크를 2배 더 늘려 네트워크를 훈련하자.
model = Sequential()
model.add(layers.GRU(32, dropout=0.2, recurrent_dropout=0.2, input_shape=(None, float_data.shape[-1])))
model.add(layers.Dense(1))

model.compile(optimizer=RMSprop(), loss='mae')

history = model.fit_generator(train_gen, steps_per_epoch=500, epochs=40, validation_data=validation_gen, validation_steps=val_steps)
  • 결과는 다음과 같다.

  • 30번 에포크까지 과대적합이 일어나지 않았다. 평가 점수는 안정적이지만 이전보다 더 나아지지는 않았다.

스태킹 순환 층

  • 과대적합은 더는 없지만 성능상 병목이 있는 것 같으므로 네트워크의 용량을 늘려야 한다.
    • 일반적인 머신 러닝 작업 흐름을 기억하자. 과대적합이 일어날 때까지 네트워크의 용량을 늘리는 것이 좋다. 너무 많이 과대적합되지 않는 한 아직 충분한 용량에 도달한 것이 아니다.
  • 네트워크의 용량을 늘리려면 일반적으로 층에 있는 유닛의 수를 늘리거나 층을 더 많이 추가한다.
    • 순환 층 스태킹은 더 강력한 순환 네트워크를 만드는 고전적인 방법이다.
    • 예컨대 구글 번역 알고리즘의 현재 성능은 7개 대규모 LSTM 층을 쌓은 대규모 모델에서 나온 것이다.
    • 케라스에서 순환 층을 차례대로 쌓으려면 모든 중간층은 마지막 타임스텝 출력만 아니고 전체 시퀀스(3D 텐서)를 출력해야 한다. return_sequence = True로 지정하면 된다.
model = Sequential()
model.add(layers.GRU(32, dropout=0.2, recurrent_dropout=0.2, return_sequences = True, input_shape=(None, float_data.shape[-1])))
model.add(layers.GRU(64, activation='relu', dropout=0.1, recurrent_dropout=0.5))
model.add(layers.Dense(1))

model.compile(optimizer=RMSprop(), loss='mae')

history = model.fit_generator(train_gen, steps_per_epoch=500, epochs=40, validation_data=validation_gen, validation_steps=val_steps)
  • 결과는 다음과 같다.

  • 층을 추가하여 성능을 조금 향상시켰지만, 크지는 않다. 여기서 두 가지 결론을 얻을 수 있다.
    • 아직 충분히 과대적합을 만들지 못했기 때문에 검증 손실을 향상하기 위해 층의 크기를 늘릴 수 있다. 하지만 적지 않은 계산 비용이 추가된다.
    • 층을 추가한 만큼 도움이 되지 않았으므로 여기서는 네트워크의 용량을 늘리는 것이 도움이 되지 않는다고 볼 수 있다.

양방향 RNN 사용하기

  • 이 절에서 소개할 마지막 기법은 양방향 RNN(bidirectionarl RNN)이다. 양방향 RNN은 RNN의 한 변종이고 특정 작업에서 기본 RNN 보다 훨씬 좋은 성능을 낸다. 자연어 처리에서는 맥가이버 칼이라고 할 정도로 즐겨 사용된다.
  • RNN은 특히 순서 또는 시간에 민감하다. 즉 입력 시퀀스의 타임스텝 순서대로 처리한다.
    • 타임스텝을 섞거나 거꾸로 하면 RNN이 시퀀스에서 학습하는 표현을 완전히 바꾸어 버린다. 이는 온도 예측처럼 순서에 의미가 있는 문제에 잘 맞는 이유이기도 하다.
  • 양방향 RNN은 RNN이 순서에 민감하다는 성질을 사용한다. 앞서 보았던 GRU나 LSTM 같은 RNN 2개를 사용한다. 각 RNN은 입력 시퀀스를 한 방향으로 처리한 후 각 표현을 합친다.
    • 시퀀스를 양쪽 방향으로 처리하기 떄문에 양방향 RNN은 단방향 RNN이 놓치기 쉬운 패턴을 감지할 수 있다.
  • 놀랍게도 이 절에 있는 RNN 층이 시간의 순서대로 시퀀서를 처리하는 것은 근거 없는 결정이다. 시간의 반대 방향으로 입력 시퀀스를 처리하면 만족할만한 RNN 성능을 낼 수 있을까? 
    • 실제 해보고 결과를 확인해 보자. 해야 할 일은 입력 시퀀스를 시간 차원을 따라 거꾸로 생성하는 데이터 제너레이터를 만드는 것 뿐이다. (제너레이터 함수의 마지막 줄을 yield samples[:, ::-1, :], targets)로 바꾼다.
  • 결과는 다음과 같다.

  • 순서를 뒤집은 GRU는 상식 수준의 기준점보다 성능이 낮다. 이 경우에는 시간 순서대로 처리하는 것이 중요하다.
    • 같은 기법을 IMDB에 적용해 보자.
from keras.datasets import imdb
from keras.preprocessing import sequence
from keras.models import Sequential
from keras import layers

max_features = 10000
maxlen = 500

(x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=max_features)

x_train = [x[::-1] for x in x_train]
x_test = [x[::-1] for x in x_test]

x_train = sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = sequence.pad_sequences(x_test, maxlen=maxlen)

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

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

history = model.fit(x_train, y_train, epochs=10, batch_size=128, validation_split=0.2)
  • 시간 순서로 훈련한 LSTM과 거의 동일한 성능을 얻을 수 있다. 놀랍게도 이런 텍스트 데이터셋에는 순서를 뒤집어 처리하는 것이 시간 순서대로 처리하는 것과 거의 동일하게 잘 작동한다.
    • 이는 언어를 이해하는데 단어의 순서가 중요하지만 결정적이지 않다는 가정을 뒷받침한다. 거꾸로 된 시퀀스에서 훈련한 RNN은 원래 시퀀스에서 훈련한 것과는 다른 표현을 학습한다.
    • 이와 비슷하게 시작할 때 죽고 마지막 날 태어나는 삶처럼 실제 세상의 시간이 거꾸로 흘러간다면 우리의 정신 세계는 달라질 것이다.
    • 머신 러닝에서 다른 표현이 유용하다면 항상 사용할 가치가 있다. 이 표현이 많이 다를수록 더 좋다. 이 표현이 데이터를 바라보는 새로운 시각을 제공하고 다른 방식에서는 놓칠 수 있는 데이터의 특징을 잡아낸다.
    • 이런 표현은 작업의 성능을 올리는데 도움을 준다. 이것이 7장에서 살펴볼 앙상블(ensemble) 개념이다.
  • 양방향 RNN은 이 아이디어를 사용해서 시간 순서대로 처리하는 RNN의 성능을 향상 시킨다. 
    • 입력 시퀀스를 양쪽 방향으로 바라보기 때문에 드러나지 않은 다양한 표현을 얻어 시간 순서대로 처리할 때 놓칠 수 있는 패턴을 잡아낸다.

  • 케라스에서는 Bidirectional 층을 사용하여 양방향 RNN을 만든다. 이 클래스는 첫 번째 매개변수로 순환 층의 객체를 전달 받는다.
    • Bidirectional 클래스는 전달받은 순환 층으로 새로운 두 번째 객체를 만든다.
    • 하나는 시간 순서대로 입력 시퀀스를 처리하고 다른 하나는 반대 순서로 입력 시퀀스를 처리한다.
  • IMDB에 이를 적용해 보자.
model = Sequential()
model.add(layers.Embedding(max_features, 32))
model.add(layers.Bidirectionarl(layers.LSTM(32)))
model.add(layers.Dense(1, activation='sigmoid'))

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

history = model.fit(x_train, y_train, epochs=10, batch_size=128, validation_split=0.2)
  • 여기서 얻은 검증 정확도는 88% 정도로 이전 절에서 사용했던 일반 LSTM 보다 성능이 조금 더 높다. 이 모델은 조금 더 일찍 과대적합되는 것 같다.
    • 양방향 순환 층이 단방향 LSTM 보다 모델 파라미터가 2배 많기 때문에 놀라운 일은 아니다.
    • 규제를 조금 추가한다면 양방향 순환층을 사용하는 것이 이 작업에 더 적합해 보인다.
  • 이제 동일한 방식을 온도 예측 문제에 적용해 보자.
from keras.optimizers import RMSprop

model = Sequential()
model.add(layers.Bidirectionarl(layers.GRU(32), input_shape=(None, float_data.shape[-1])))
model.add(layers.Dense(1))

model.compile(optimizer=RMSprop(), loss='mae')

history = model.fit_generator(train_gen, steps_per_epoch=500, epochs=40, validation_data=validation_gen, validation_steps=val_steps)
  • 이 네트워크는 GRU 층와 비슷한 성능을 낸다. 이유는 쉽게 이해할 수 있는데, 모든 예측 성능은 시간 순서대로 처리하는 네트워크의 절반에서 온다. 시간 반대 순서로 처리하는 절반은 이런 작업에 성능이 매우 좋지 않기 때문이다.

더 나아가서

  • 온도 예측 문제의 성능을 향상시키기 위해 시도해 볼 수 있는 것들이 많이 있다.
    • 스태킹한 각 순환 층의 유닛 수를 조정한다. 지금 설정은 대부분 임의로 한 것이라 최적화가 덜 되었을 것이다.
    • RMSprop 옵티마이저가 사용한 학습률을 조정한다.
    • GRU 대신 LSTM 층을 사용한다.
    • 순환 층 위에 용량이 큰 완전 연결된 회귀 층을 사용한다. 즉 유닛 수가 많은 Dense 층이나 Dense 층을 스태킹 한다.
    • 최종적으로 (검증 MAE 기준으로 보았을 때) 최선의 모델을 테스트 세트에서 확인해야 한다. 이를 잊으면 과대적합된 네트워크 구조를 만들게 될 것이다.
  • 늘 그렇듯이 딥러닝은 과학보다 예술에 가깝다. 어떤 문제에 적합하거나 그렇지 않은 가이드라인은 제시할 수 있지만 결국 모든 문제는 다르다. 경험을 바탕으로 다른 전략들을 시도해 보아야 한다. 
    • 현재는 문제를 해결하는 최선의 방법을 미리 알 수 있는 이론이 없다. 반복해서 시도해야 한다.

정리

  • 장에서 처음 배운 것처럼 새로운 문제를 해결할 때는 선택한 지표에서 상식 수준의 기준점을 설정하는 것이 좋다. 기준점을 가지고 있지 않으면 실제로 향상되었는지 알 수 없다.
  • 계산 비용을 추가할지 판단하기 위해 비용이 비싼 모델 전에 간단한 모델을 시도한다.
  • 시간 순서가 중요한 데이터가 있다면 순환 층이 적합하다. 시계열 데이터를 펼쳐서 처리하는 모델의 성능을 쉽게 앞지를 것이다.
  • 순환 네트워크에 드롭아웃을 사용하려면 타임스텝 동안 일정한 드롭아웃 마스크와 순환 드롭아웃 마스크를 사용해야 한다. 둘 다 케라스 순환 층에 포함되어 있다. 순환 층에 있는 dropout과 recurrent_dropout 매개변수를 사용하면 된다.
  • 스태킹 RNN은 단일 RNN 층보다 더 강력한 표현 능력을 제공한다. 하지만 계산 비용이 많이 들기 때문에 항상 시도할 가치가 있지는 않다. (기계 번역 같은) 복잡한 문제에서 확실히 도움이 되지만 작고 간단한 문제에서는 항상 그렇지 않다.
  • 양쪽 방향으로 시퀀스를 바라보는 양방향 RNN은 자연어 처리 문제에 유용하다. 하지만 최근 정보가 오래된 것보다 훨씬 의미 있는 시퀀스 데이터에는 잘 작동하지 않는다.

컨브넷을 사용한 시퀀스 처리

  • 1D 컨브넷은 특정 시퀀스 처리 문제에서 RNN과 견줄 만하다. 일반적으로 계산 비용이 훨씬 싸다. 1D 컨브넷은 전형적으로 팽창된 커널(dilated kernel)과 함께 사용된다.
    • 최근 오디오 생성과 기계 번역 분야에서 큰 성공을 거두었다. 이런 특정 분야의 성공 이외에도 텍스트 분류나 시계열 예측 같은 간단한 문제에서 작은 1D 컨브넷이 RNN을 대신하여 빠르게 처리할 수 있다고 알려져있다.

시퀀스 데이터를 위한 1D 합성곱 이해하기

  • 앞서 소개한 합성곱 층은 2D 합성곱이다. 이미지 텐서에 2D 패치를 추출하고 모든 패치에 동일한 변환을 적용한다. 같은 방식으로 시퀀스에서 1D 패치(부분 시퀀스)를 추출하여 1D 합성곱을 적용한다.

  • 이런 1D 합성곱 층은 시퀀스에 있는 지역 패턴을 인식할 수 있다. 동일한 변환이 시퀀스에 있는 모든 패치에 적용되기 때문에 특정 위치에서 학습한 패턴을 나중에 다른 위치에서 인식할 수 있다.
    • 이는 1D 컨브넷에 (시간의 이동에 대한) 이동 불변성(translation invariant)을 제공한다.
    • 예컨대 크기 5인 윈도우를 사용하여 문자 시퀀스를 처리하는 1D 컨브넷은 5개 이하의 단어나 단어의 부분을 학습한다. 
    • 이 컨브넷은 이 단어가 입력 시퀀스의 어느 문장에 있더라도 인식할 수 있다. 따라서 문자 수준의 1D 컨브넷은 단어 형태학(word morphology)에 관해 학습할 수 있다.

시퀀스 데이터를 위한 1D 풀링

  • 컨브넷에서 이미지 텐서의 크기를 다운샘플링하기 위해 사용하는 평균 풀링이나 맥스 풀링 같은 2D 풀링 연산을 배웠다.
    • 1D 풀링 연산은 2D 풀링 연산과 동일하다. 입력에서 1D 패치(부분 시퀀스)를 추출하고 최댓값(최대 풀링)을 출력하거나 평균값(평균 풀링)을 출력한다.
    • 2D 컨브넷과 마찬가지로 1D 입력의 길이를 줄이기 위해 사용한다(서브샘플링(subsampling))

1D 컨브넷 구현

  • 케라스에서 1D 컨브넷은 Conv1D 층을 사용해서 구현한다. Conv1D는 Conv2D와 인터페이스가 비슷하다.
    • (samples, time, features) 크기의 3D 텐서를 입력으로 받고 비슷한 형태의 3D 텐서를 반환한다.
    • 합성곱 윈도우는 시간 축의 1D 윈도우이다. 즉 입력 텐서의 두 번째 축이다.
  • 간단한 2개의 층으로 된 1D 컨브넷을 만들어 IMDB 감성 분류 문제에 적용해 보자.
from keras.datasets import imdb
from keras.preprocessing import sequence

max_features = 10000
maxlen = 500
batch_size = 32

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

print('시퀀스 패딩 (samples x time)')

x_train = sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = sequence.pad_sequences(x_test, maxlen=maxlen)
print('input_train 크기:', x_train.shape)
print('input_test 크기:', x_test.shape)
  • 1D 컨브넷은 5장에서 사용한 2D 컨브넷과 비슷한 방식으로 구성한다.
    • Conv1D와 MaxPooling1D 층을 쌓고 전역 풀링 층이나 Flatten 층으로 마친다.
    • 이 구조는 3D 입력을 2D 출력으로 바꾸므로 분류나 회귀를 위해 모델에 하나 이상의 Dense 층을 추가할 수 있다.
  • 한 가지 다른 점은 1D 컨브넷에 큰 합성곱 윈도우를 사용할 수 있다는 것이다.
    • 2D 합성곱 층에서 3 x 3 합성곱 윈도우는 3 x 3 = 9 특성을 고려한다.
    • 1D 합성곱 층에서 크기 3인 합성곱 윈도우는 3개의 특성만 고려한다. 그래서 1D 합성곱에 크기 7이나 9의 윈도우를 사용할 수 있다.
  • 다음은 IMDB 데이터셋을 위한 1D 컨브넷의 예이다.
from keras.models import Sequential
from keras import layers
from keras.optimizers import RMSprop

model = Sequential()
model.add(layers.Embedding(max_features, 128, input_length=maxlen))
model.add(layers.Conv1D(32, 7, activation='relu'))
model.add(layers.MaxPooling1D(5))
model.add(layers.Conv1D(32, 7, activation='relu'))
model.add(layers.GlobalMaxPooling1D())
model.add(layers.Dense(1))
model.summary()

model.compile(optimizer=RMSprop(lr=1e-4), loss='binary_crossentropy', metrics=['acc'])

history = model.fit(x_train, y_train, epochs=10, batch_size=128, validation_split=0.2)
  • 결과는 다음과 같다.

  • 검증 정확도는 LSTM 보다 조금 낮지만 CPU나 GPU에서 더 빠르게 실행된다. 여기에서 적절한 에포크 수(4개)로 모델을 다시 훈련하고 테스트 세트에서 확인할 수 있다.
    • 이 예는 단어 수준의 감성 분류 작업에 순환 네트워크를 대신하여 빠르고 경제적인 1D 컨브넷을 사용할 수 있음을 보여준다.

CNN과 RNN을 연결하여 긴 시퀀스를 처리하기

  • 1D 컨브넷이 입력 패치를 독립적으로 처리하기 때문에 RNN과 달리 (합성곱 윈도우 크기의 범위를 넘어서는) 타임스텝의 순서에 민감하지 않다.
    • 물론 장기간 패턴을 인식하기 위해 많은 합성곱 층과 풀링 층을 쌓을 수 있다. 상위 층은 원본 입력에서 긴 범위를 보게 될 것이다. 이런 방법은 순서를 감지하기에 부족하다.
    • 온도 예측 문제에 1D 컨브넷을 적용해서 이를 확인해 보자. 이 문제는 순서를 감지해야 좋은 예측을 만들어 낼 수 있다.
from keras.models import Sequential
from keras import layers
from keras.optimizers import RMSprop

model = Sequential()
model.add(layers.Conv1D(32, 5, activation='relu', input_shape=(None, float_data.shape[-1])))
model.add(layers.MaxPooling1D(3))
model.add(layers.Conv1D(32, 5, activation='relu'))
model.add(layers.MaxPooling1D(3))
model.add(layers.Conv1D(32, 5, activation='relu'))
model.add(layers.GlobalMaxPooling1D())
model.add(layers.Dense(1))

model.compile(optimizer=RMSprop(), loss='mae')

history = model.fit_generator(train_gen, steps_per_epoch=500, epochs=20, validation_data=val_gen, validation_steps=val_steps)
  • 다음은 훈련 MAE와 검증 MAE를 보여준다.

  • 검증 MAE는 0.4 대에 머물러 있다. 작은 컨브넷을 사용해서 상식 수준의 기준점을 넘지 못했다. 이는 컨브넷이 입력 시계열에 있는 패턴을 보고 이 패턴의 식나 축의 위치를 고려하지 않기 때문이다.
    • 최근 데이터 포인트일수록 오래된 데이터 포인트와는 다르게 해석해야 하기 때문에 컨브넷이 의미 있는 결과를 만들지 못한다.
    • 이런 컨브넷의 한계는 IMDB 데이터에서는 문제가 되지 않는데, 긍정 또는 부정적인 감성과 연관된 키워드 패턴의 중요성은 입력 시퀀스에 나타난 위치와 무관하기 때문이다.
  • 컨브넷의 속도와 경량함을 RNN의 순서 감지 능력과 결합하는 한 가지 전략은 1D 컨브넷을 RNN 이전에 전처리 단계로 사용하는 것이다.
    • 수천 개의 스텝을 가진 시퀀스 같이 RNN으로 처리하기에는 현실적으로 너무 긴 시퀀스를 다룰 때 특별히 도움이 된다.
    • 컨브넷이 긴 입력 시퀀스를 더 짧은 고수준 특성의 (다운 샘플된) 시퀀스로 변환한다.
    • 추출된 특성의 시퀀스는 RNN 파트의 입력이 된다.

  • 이 기법이 연구 논문이나 실전 애플리케이션에 자주 등장하지는 않는데 아마도 널리 알려지지 않았기 때문일 것이다. 이 방법은 효과적이므로 많이 사용되기를 바란다.
  • 온도 에측 문제에 적용해 보자. 이 전략은 훨씬 긴 시퀀스를 다룰 수 있으므로 더 오래 전 데이터를 바라보거나 시게열 데이터를 더 촘촘히 바라볼 수 있다.
    • 여기서는 그냥 step을 절반으로 줄여서 사용하겠다. 온도 데이터가 30분마다 1포인트씩 샘플링되기 때문에 결과 시계열 데이터는 2배로 길어진다.
step = 3
lookback = 1440
delay = 144

train_gen = generator(float_data, lookback=lookback, delay=delay, min_index=0, max_index=200000, shuffle=True, step=step, batch_size=batch_size)
val_gen = generator(float_data, lookback=lookback, delay=delay, min_index=200001, max_index=300000, shuffle=True, step=step, batch_size=batch_size)
test_gen = generator(float_data, lookback=lookback, delay=delay, min_index=300001, max_index=None, shuffle=True, step=step, batch_size=batch_size)


val_steps = (300000 - 200001 - lookback) // 128
test_steps = (len(float_data) - 300001 - lookback) // 128
  • 이 모델은 2개의 Conv1D 층 다음에 GRU 층을 놓았다.
model = Sequential()
model.add(layers.Conv1D(32, 5, activation='relu', input_shape=(None, float_data.shape[-1])))
model.add(layers.MaxPooling1D(3))
model.add(layers.Conv1D(32, 5, activation='relu'))
model.add(layers.GRU(32, dropout=0.1, recurrent_dropout=0.5))
model.add(layers.Dense(1))

model.summary()

model.compile(optimizer=RMSprop(), loss='mae')

history = model.fit_generator(train_gen, steps_per_epoch=500, epochs=20, validation_data=val_gen, validation_steps=val_steps)
  • 결과는 다음과 같다.

  • 검증 손실로 비교해 보면 이 설정은 규제가 있는 GRU 모델 만큼 좋지는 않다. 하지만 훨씬 빠르기 때문에 데이터를 2배 더 많이 처리할 수 있다. 
    • 여기서는 도움이 많이 되지 않았지만 다른 데이터셋에서는 중요할 수 있다.

정리

  • 2D 컨브넷이 2D 공간의 시각적 패턴을 잘 처리하는 것처럼 1D 컨브넷은 시간에 따른 패턴을 잘 처리한다. 1D 컨브넷은 특정 자연어 처리 같은 일부 문제에 RNN을 대신할 수 있는 빠른 모델이다.
  • 전형적으로 1D 컨브넷은 컴퓨터 비전 분야의 2D 컨브넷과 비슷하게 구성한다. Conv1D 층과 Max-Pooling1D 층을 쌓고 마지막에 전역 풀링 연산이나 Flatten 층을 둔다.
  • RNN으로 아주 긴 시퀀스를 처리하려면 계산 비용이 많이 든다. 1D 컨브넷은 비용이 적게 든다. 따라서 1D 컨브넷을 RNN 이전의 전처리 단계로 사용하는 것은 좋은 생각이다. 시퀀스 길이를 줄이고 RNN이 처리할 유용한 표현을 추출해 줄 것이다.

요약

  • 이 장에서는 다음 기법들을 배웠다. 이 기법들은 텍스트에서 시계열까지 다양한 시쿠너스 데이터셋에 폭넓게 적용할 수 있다.
    • 텍스트를 토큰화 하는 방법
    • 단어 임베딩과 이를 사용하는 방법
    • 순환 네트워크와 이를 사용하는 방법
    • 더 강력한 시퀀스 처리 모델을 만들기 위해 RNN 층을 스태킹 하는 방법과 양방향 RNN을 사용하는 방법
    • 시퀀스를 처리하기 위해 1D 컨브넷을 사용하는 방법
    • 긴 시퀀스를 처리하기 위해 1D 컨브넷과 RNN을 연결하는 방법
  • 시계열 데이터를 사용한 회귀 (미래 값을 예측), 시계열 분류, 시게열에 있는 이상치 감지, 시퀀스 레이블링(문장에서 이름이나 날짜를 식별하기 등)에 RNN을 사용할 수 있다.
  • 비슷하게 기계 번역(SliceNet 같은 시퀀스-투-시퀀스 합성곱 모델), 문서 분류, 맞춤법 정정 등에 1D 컨브넷을 사용할 수 있다.
  • 시퀀스 데이터에서 전반적인 순서가 중요하다면 순환 네트워크를 사용하여 처리하는 것이 좋다. 최근의 정보가 오래된 과거보다 더 중요한 시계열 데이터가 전형적인 경우이다.
  • 전반적인 순서가 큰 의미가 없다면 1D 컨브넷이 적어도 동일한 성능을 내면서 비용도 적을 것이다. 텍스트 데이터가 종종 이에 해당한다. 문장 처음에 있는 키워드가 마지막에 있는 키워드와 같은 의미를 가진다.
[ssba]

The author

지성을 추구하는 사람/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.