해당 포스팅은 '밑바닥부터 시작하는 딥러닝2'를 공부하고 정리, 요약한 글입니다. 모든 내용은 해당 도서를 기준으로 합니다.
◼️ 7.1 언어 모델을 사용한 문장 생성
◾ 7.1.1 RNN을 사용한 문장 생성의 순서
앞 장에서의 LSTM 계층을 이용한 언어 모델은 아래 그림처럼 생겼다.
언어 모델은 다음과 같은 확률분포를 출력한다.
언어 모델은 지금까지 주어진 단어들에서 다음에 출현하는 단어의 확률분포를 출력한다. 이 결과를 기초로 다음 생성하기 위해서는 확률이 가장 높은 단어를 선택하는 방법을 떠올릴 수 있다.
확률이 가장 높은 단어를 선택할 뿐이므로 결과가 일정하게 정해지는 결정적인 방법이다.
또 확률적으로 선택하는 방법도 있다. 각 후보 단어의 확률에 맞게 선택하는 것으로 확률이 높은 단어는 선택되기 쉽고 확률이 낮은 단어는 선택되기 어려워진다.
이 방식에서는 선택되는 단어(샘플링 단어)가 매번 다를 수 있다.
다른 단어들도 해당 단어의 출현 확률에 따라 정해진 비율만큼 샘플링될 가능성이 있다는 뜻이다.
방금 생성한 단어인 'say'를 언어 모델에 입력하여 다음 단어의 확률분포를 얻는다. 그 확률분포를 기초로 다음에 출현할 단어를 샘플링한다.
◾ 7.1.2 문장 생성 구현
# chap07/rnnlm_gen.py
import sys
sys.path.append('..')
import numpy as np
from common.functions import softmax
from rnnlm import Rnnlm
from better_rnnlm import BetterRnnlm
class RnnlmGen(Rnnlm):
def generate(self, start_id, skip_ids=None, sample_size=100):
word_ids = [start_id]
x = start_id
while len(word_ids) < sample_size:
x = np.array(x).reshape(1, 1)
score = self.predict(x)
p = softmax(score.flatten())
sampled = np.random.choice(len(p), size=1, p=p)
if (skip_ids is None) or (sampled not in skip_ids):
x = sampled
word_ids.append(int(x))
return word_ids
def get_state(self):
return self.lstm_layer.h, self.lstm_layer.c
def set_state(self, state):
self.lstm_layer.set_state(*state)
class BetterRnnlmGen(BetterRnnlm):
def generate(self, start_id, skip_ids=None, sample_size=100):
word_ids = [start_id]
x = start_id
while len(word_ids) < sample_size:
x = np.array(x).reshape(1, 1)
score = self.predict(x).flatten()
p = softmax(score).flatten()
sampled = np.random.choice(len(p), size=1, p=p)
if (skip_ids is None) or (sampled not in skip_ids):
x = sampled
word_ids.append(int(x))
return word_ids
def get_state(self):
states = []
for layer in self.lstm_layers:
states.append((layer.h, layer.c))
return states
def set_state(self, states):
for layer, state in zip(self.lstm_layers, states):
layer.set_state(*state)
# chap07/generate_text.py
import sys
sys.path.append('..')
from rnnlm_gen import RnnlmGen
from dataset import ptb
corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)
corpus_size = len(corpus)
model = RnnlmGen()
model.load_params('./Rnnlm.pkl')
# start 문자와 skip 문자 설정
start_word = 'you'
start_id = word_to_id[start_word]
skip_words = ['N', '<unk>', '$']
skip_ids = [word_to_id[w] for w in skip_words]
# 문장 생성
word_ids = model.generate(start_id, skip_ids)
txt = ' '.join([id_to_word[i] for i in word_ids])
txt = txt.replace(' <eos>', '.\n')
◾ 7.1.3 더 좋은 문장으로
# chap07/generate_better_text.py
# coding: utf-8
import sys
sys.path.append('..')
from common.np import *
from rnnlm_gen import BetterRnnlmGen
from dataset import ptb
corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)
corpus_size = len(corpus)
model = BetterRnnlmGen()
model.load_params('./BetterRnnlm.pkl')
start_word = 'you'
start_id = word_to_id[start_word]
skip_words = ['N', '<unk>', '$']
skip_ids = [word_to_id[w] for w in skip_words]
word_ids = model.generate(start_id, skip_ids)
txt = ' '.join([id_to_word[i] for i in word_ids])
txt = txt.replace(' <eos>', '.\n')
print(txt)
model.reset_state()
start_words = 'the meaning of life is'
start_ids = [word_to_id[w] for w in start_words.split(' ')]
for x in start_ids[:-1]:
x = np.array(x).reshape(1, 1)
model.predict(x)
word_ids = model.generate(start_ids[-1], skip_ids)
word_ids = start_ids[:-1] + word_ids
txt = ' '.join([id_to_word[i] for i in word_ids])
txt = txt.replace(' <eos>', '.\n')
print('-' * 50)
print(txt)
◼️ 7.2 seq2seq
시계열 데이터를 다른 시계열 데이터로 변환하는 모델을 생각 해 본다면 2개의 RNN을 이용하는 seq2seq라는 방법을 살펴볼 수 있다.
◾ 7.2.1 seq2seq의 원리
seq2seq를 Encoder-Decoder 모델이라고도 한다.
2개의 모듈, Encoder와 Decoder가 등장한다. 문자 그대로 Encoder는 입력 데이터를 인코딩하고 Decoder는 인코딩 된 데이터를 디코딩 한다.
위 그림처럼 먼저 encoder가 나는 고양이로소이다 라는 출발어 문장을 인코딩한다.
이어서 그 인코딩한 정보를 decoder에 전달하고 decoder가 도착어 문장을 생성한다.
이때 encoder가 인코딩한 정보에는 번역에 필요한 정보가 조밀하게 응축되어 있다. decoder는 조밀하게 응축된 이 정보를 바탕으로 도착어 문장을 생성하는 것이다.
encoder의 계층은 다음처럼 구성된다.
encoder는 RNN을 이용해 시계열 데이터를 h라는 은닉 상태 벡터로 변환한다. 지금 예에서는 RNN으로써 LSTM을 이용했지만 단순한 RNN이나 GRU 등도 물론 이용할 수 있다.
encoder가 출력하는 벡터 h는 LSTM 계층의 마지막 은닉 상태이다. 이 마지막 은닉 상태 h에 입력 문장을 번역하는 데 필요한 정보가 인코딩 된다.
LSTM의 은닉 상태 h는 고정 길이 벡터라는 사실이다. 그래서 인코딩한다라함은 결국 임의 길이의 문장을 고정 길이 벡터로 변환하는 작업이 된다.
encoder는 문장을 고정 길이 벡터로 변환한다.
docoder는 앞 절의 신경망과 완전히 같은 구성이며, LSTM 계층이 벡터 h를 입력받는다는 점이 앞 절과 다르다.
seq2seq는 LSTM 두 개로 구성된다. 이 때 LSTM 계층의 은닉 상태가 Encoder와 Decoder를 이어주는 가교가 된다.
순전파 때는 Encoder에서 인코딩 된 정보가 LSTM 계층의 은닉 상태를 통해 decoder에 전해진다. 그리고 seq2seq의 역전파 때는 이 가교를 통해 기울기가 decoder로부터 encoder로 전해진다.
◾ 7.2.2 시계열 데이터 변환용 장난감 문제
시계열 변환 문제의 예로 더하기를 다룰 것이다.
57+5와 같은 문자열을 seq2seq에 건네면 62라는 정답을 내놓도록 학습 시킬 것이다.
seq2seq는 덧셈에 대해 아무것도 모른다. seq2seq는 덧셈의 예로부터 거기서 사용되는 문자의 패턴을 학습한다.
◾7.2.3 가변 길이 시계열 데이터
덧셈 문제에서는 샘플마다 데이터의 시간 방향 크기가 다르다. 신경망 학습 시 미니배치 처리를 하려면 무언가 추가 노력이 필요하게 된다.
위 그림은 패딩을 이번 덧셈 문제에 적용해 본 모습이다. 모든 입력 데이터의 길이를 통일하고 남는 공간에는 의미 없는 데이터를 채운 걸 볼 수 있다.
+까지 포함하면 입력의 최대 문자 수는 7이 된다. 덧셈 결과는 최대 4문자(999+999=1998)이다. 정답 데이터에도 패딩을 수행해 모든 샘플 데이터의 길이를 통일한다.
질문과 정답을 구분하기 위해 출력 앞에 구분자로 밑줄(_)을 붙이기로 한다.
출력 데이터는 총 5문자로 통일하게 되며 이 구분자는 decoder에 문자열을 생성하라고 알리는 신호로 사용된다.
패딩을 적용해 데이터 크기를 통일시키면 가변 길이 시계열 데이터도 처리할 수 있다.
원래는 존재하지 않던 패딩용 문자까지 seq2seq에 패딩 전용 처리를 추가해야 한다. encoder에 입력 된 데이터가 패딩이라면 LSTM 계층이 이전 시각의 입력을 그대로 출력하게 된다.
LSTM 계층은 마치 처음부터 존재하지 않았던 것처럼 인코딩할 수 있다.
# coding: utf-8
import sys
sys.path.append('..')
from dataset import sequence
(x_train, t_train), (x_test, t_test) = \
sequence.load_data('addition.txt', seed=1984)
char_to_id, id_to_char = sequence.get_vocab()
print(x_train.shape, t_train.shape)
print(x_test.shape, t_test.shape)
# (45000, 7) (45000, 5)
# (5000, 7) (5000, 5)
print(x_train[0])
print(t_train[0])
# [ 3 0 2 0 0 11 5]
# [ 6 0 11 7 5]
print(''.join([id_to_char[c] for c in x_train[0]]))
print(''.join([id_to_char[c] for c in t_train[0]]))
# 71+118
# _189
◼️ 7.3 seq2seq 구현
◾ 7.3.1 Encoder 클래스
encoder 클래스는 다음처럼 문자열을 받아 벡터 h로 변환한다.
RNN을 이용해 encoder를 구성한다. 여기에서는 LSTM 계층을 이용해 보면 다음과 같다.
encoder 클래스는 embedding 계층과 LSTM 계층으로 구성된다. embedding 계층에서는 문자를 문자 벡터로 변환한다. 이 문자 벡터가 LSTM 계층으로 입력된다.
LSTM 계층은 오른쪽으로는 은닉 상태와 셀을 출력하고 위쪽으로는 은닉 상태만 출력한다.
encoder에서는 마지막 문자를 처리한 후 LSTM 계층의 은닉 상태 h를 출력한다. 이 은닉 상태 h가 decoder로 전달된다.
시간 방향을 한꺼번에 처리하는 계층을 Time LSTM 계층이나 Time Embedding 계층으로 구현했다. 이러한 Time 계층을 이용하면 우리의 encoder는 아래처럼 된다.
class Decoder:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
embed_W = (rn(V, D) / 100).astype('f')
lstm_Wx = (rn(D, 4 * H) / np.sqrt(D)).astype('f')
lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
lstm_b = np.zeros(4 * H).astype('f')
affine_W = (rn(H, V) / np.sqrt(H)).astype('f')
affine_b = np.zeros(V).astype('f')
self.embed = TimeEmbedding(embed_W)
self.lstm = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True)
self.affine = TimeAffine(affine_W, affine_b)
self.params, self.grads = [], []
for layer in (self.embed, self.lstm, self.affine):
self.params += layer.params
self.grads += layer.grads
def forward(self, xs, h):
self.lstm.set_state(h)
out = self.embed.forward(xs)
out = self.lstm.forward(out)
score = self.affine.forward(out)
return score
def backward(self, dscore):
dout = self.affine.backward(dscore)
dout = self.lstm.backward(dout)
dout = self.embed.backward(dout)
dh = self.lstm.dh
return dh
def generate(self, h, start_id, sample_size):
sampled = []
sample_id = start_id
self.lstm.set_state(h)
for _ in range(sample_size):
x = np.array(sample_id).reshape((1, 1))
out = self.embed.forward(x)
out = self.lstm.forward(out)
score = self.affine.forward(out)
sample_id = np.argmax(score.flatten())
sampled.append(int(sample_id))
return sampled
◾ 7.3.2 Decoder 클래스
decoder 클래스는 다음과 같이 encoder 클래스가 출력한 h를 받아 목적으로 하는 다른 문자열을 출력한다.
decoder는 RNN으로 구현할 수 있으며 Encoder와 마찬가지로 LSTM 계층을 사용하면 되며 이때 Decoder의 계층 구성은 아래처럼 된다.
이번 문제는 덧셈이므로 이러한 확률적인 비결정성을 배제하고 결정적인 답을 생성하고자 한다.
이번에는 점수가 가장 높은 문자 하나만 고를 것이다. 확률적이 아닌 결정적으로 선택한다.
argmax라는 못 보던 노드가 새로 등장하는데 바로 최댓값을 가진 원소의 인덱스를 선택하는 노드이다.
이번에는 softmax를 사용하지 않고 affine 계층이 출력하는 점수가 가장 큰 문자 ID를 선택한다.
decoder에서는 학습 시와 생성 시에 softmax 계층을 다르게 취급한다. softmax with loss 계층은 이후에 구현하는 seq2seq 클래스에서 처리하기로 하고 decoder 클래스는 아래 그림처럼 Time softmax with loss 계층의 앞까지만 담당한다.
위 그림과 같이 deocder 클래스는 Time Embedding, Time LSTM, Time, Affine의 3가지 계층으로 구성된다.
class Decoder:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
embed_W = (rn(V, D) / 100).astype('f')
lstm_Wx = (rn(D, 4 * H) / np.sqrt(D)).astype('f')
lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
lstm_b = np.zeros(4 * H).astype('f')
affine_W = (rn(H, V) / np.sqrt(H)).astype('f')
affine_b = np.zeros(V).astype('f')
self.embed = TimeEmbedding(embed_W)
self.lstm = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True)
self.affine = TimeAffine(affine_W, affine_b)
self.params, self.grads = [], []
for layer in (self.embed, self.lstm, self.affine):
self.params += layer.params
self.grads += layer.grads
def forward(self, xs, h):
self.lstm.set_state(h)
out = self.embed.forward(xs)
out = self.lstm.forward(out)
score = self.affine.forward(out)
return score
def backward(self, dscore):
dout = self.affine.backward(dscore)
dout = self.lstm.backward(dout)
dout = self.embed.backward(dout)
dh = self.lstm.dh
return dh
def generate(self, h, start_id, sample_size):
sampled = []
sample_id = start_id
self.lstm.set_state(h)
for _ in range(sample_size):
x = np.array(sample_id).reshape((1, 1))
out = self.embed.forward(x)
out = self.lstm.forward(out)
score = self.affine.forward(out)
sample_id = np.argmax(score.flatten())
sampled.append(int(sample_id))
return sampled
◾ 7.3.3 seq2seq 클래스
class Seq2seq(BaseModel):
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
self.encoder = Encoder(V, D, H)
self.decoder = Decoder(V, D, H)
self.softmax = TimeSoftmaxWithLoss()
self.params = self.encoder.params + self.decoder.params
self.grads = self.encoder.grads + self.decoder.grads
def forward(self, xs, ts):
decoder_xs, decoder_ts = ts[:, :-1], ts[:, 1:]
h = self.encoder.forward(xs)
score = self.decoder.forward(decoder_xs, h)
loss = self.softmax.forward(score, decoder_ts)
return loss
def backward(self, dout=1):
dout = self.softmax.backward(dout)
dh = self.decoder.backward(dout)
dout = self.encoder.backward(dh)
return dout
def generate(self, xs, start_id, sample_size):
h = self.encoder.forward(xs)
sampled = self.decoder.generate(h, start_id, sample_size)
return sampled
◾ 7.3.4 seq2seq 평가
import sys
sys.path.append('..')
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
import matplotlib.font_manager as fm
font_path = 'C:/Windows/Fonts/malgun.ttf'
font_name = fm.FontProperties(fname=font_path, size=10).get_name()
plt.rc('font', family=font_name, size=12)
from dataset import sequence
from common.optimizer import Adam
from common.trainer import Trainer
from common.util import eval_seq2seq
from seq2seq import Seq2seq
# from peeky_seq2seq import PeekySeq2seq
# 데이터셋 읽기
(x_train, t_train), (x_test, t_test) = sequence.load_data('addition.txt')
char_to_id, id_to_char = sequence.get_vocab()
# 입력 반전 여부 설정
is_reverse = False # True
if is_reverse:
x_train, x_test = x_train[:, ::-1], x_test[:, ::-1]
# 하이퍼파라미터 설정
vocab_size = len(char_to_id)
wordvec_size = 16
hideen_size = 128
batch_size = 128
max_epoch = 25
max_grad = 5.0
# 일반 혹은 엿보기(Peeky) 설정
model = Seq2seq(vocab_size, wordvec_size, hideen_size)
# model = PeekySeq2seq(vocab_size, wordvec_size, hideen_size)
optimizer = Adam()
trainer = Trainer(model, optimizer)
vanilla_acc_list = []
for epoch in range(max_epoch):
trainer.fit(x_train, t_train, max_epoch=1,
batch_size=batch_size, max_grad=max_grad, eval_interval=150)
correct_num = 0
for i in range(len(x_test)):
question, correct = x_test[[i]], t_test[[i]]
verbose = i < 10
correct_num += eval_seq2seq(model, question, correct,
id_to_char, verbose, is_reverse)
acc = float(correct_num) / len(x_test)
vanilla_acc_list.append(acc)
print('검증 정확도 %.3f%%' % (acc * 100))
# 그래프 그리기
x = np.arange(len(vanilla_acc_list))
plt.plot(x, vanilla_acc_list, marker='o')
plt.xlabel('에폭')
plt.ylabel('정확도')
plt.ylim(0, 1.0)
plt.show()
◼️7.4 seq2seq 개선
◾7.4.1 입력 데이터 반전(Reverse)
첫번째 개선안은 아래 그림에서 보듯 입력 데이터의 순서를 반전 시키는 것이다.
◾7.4.2 엿보기(Peeky)
encoder는 입력 문장을 고정 길이 벡터 h로 변환한다. h 안에는 decoder에게 필요한 정보가 모두 담겨 있다. h가 decoder에 있어서는 유일한 정보인 셈이다. 현재의 seq2seq는 아래 그림처럼 최초 시각의 LSTM 계층만 벡터 h를 이용하고 있다.
여기서 seq2seq의 두번째 개선산이 등장한다. 중요한 정보가 담긴 encoder의 출력 h를 decoder의 다른 계층에게도 전해주는 것이다.
위 그림처럼 모든 시각의 Affine 계층과 LSTM 계층에 Encoder의 출력 h를 전해준다. 기존에는 하나의 LSTM만이 소유하던 중요 정보 h를 여러 계층이 공유함을 알 수 있다.
LSTM 계층과 Affine 계층에 입력되는 벡터가 2개씩이 되어 있다. 이는 실제로는 두 벡터가 연결된 것을 의미한다. 두 벡터를 연결시키는 concat 노드를 이용해 아래 그림처럼 그려야 정확한 계산 그래프이다.
◼️ 7.5 Seq2seq를 이용하는 애플리케이션
◾ 7.5.1 챗봇
◾7.5.2 알고리즘 학습
◾ 7.5.3 이미지 캡셔닝
이미지를 문장으로 변환하는 이미지 캡셔닝도 사용할 수 있다.
이지미의 인코딩을 CNN이 수행하고 CNN의 최종 출력은 feature map으로 3차원이기 때문에 decoder의 LSTM이 처리할 수 있도록 손질해야 한다.
CNN의 feature map을 1차원으로 평탄화한 후 완전연결인 Affine 계층에서 변환한다. 변환 된 데이터를 decoder에 전달하면 지금까지와 같은 문장 생성을 수행할 수 있다.
◼️ 7.6 정리
- RNN을 이용한 언어 모델은 새로운 문장을 생성할 수 있다.
- 문장을 생성할 때는 하나의 단어를 주고 모델의 출력에서 샘플링하는 과정을 반복한다.
- RNN을 2개 조합함으로써 시계열 데이터를 다른 시계열 데이터로 변환할 수 있다.
- seq2seq는 Encoder가 출발어 입력문을 인코딩하고 인코딩된 정보를 decoder가 받아 디코딩하여 도착어 출력문을 얻는다.
- 입력문을 반전시키는 기법(reverse), 또는 인코딩 된 정보를 decoder의 여러 계층에 전달하는 기법(peeky)는 seq2seq의 정확도 향상에 효과적이다.
- 기계 번역, 챗봇, 이미지 캡셔닝 등 seq2seq는 다양한 애플리케이션에 이용할 수 있다.