7.1 언어 모델을 사용한 문장 생성
7.1.1 RNN을 사용한 문장 생성의 순서
Language Model(LM)은 지금까지 주어진 단어들에서 다음에 출현하는 단어의 확률분포를 출력한다.
위의 그림을 토대로 다음 단어를 새로 생성하기 위한 방법은 다음과 같은 방법들이 있다.
- 확률이 가장 높은 단어를 선택한다 → "결정적(deterministic)"인 방법
- 확률이 높은 단어는 잘 선택되고, 확률이 낮은 단어는 덜 선택한다 → "확률적(probabilistic)"인 방법
7.1.2 문장 생성 구현
- 아래의 코드에서 문장 생성을 수행하는 메서드는 generate(start_id, skip_ids, sample_size)다.
- start_id: 최초로 주는 단어의 ID
- skip_ids: 단어 ID의 리스트 (ex. [12, 20]). 이 리스트에 속하는 단어 ID는 샘플링 되지 않도록 한다.
- sample_size: 샘플링하는 단어 수
- generate() 메서드는 가장 먼저 model.predict(x)를 호출해 각 단어의 점수(score, softmax 전 값)를 출력한다.
- p = softmax(score)코드에서 이 점 수들을 소프트맥스 함수를 이용해 정규화한다.
- 2번에서 얻은 확률분포 p로부터 다음 단어를 샘플링한다.
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')
print(txt)
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 문자와 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')
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
7.2.1 seq2seq의 원리
- seq2seq를 Encoder-Decoder 모델이라고도 한다.
- Encoder는 입력 데이터를 인코딩(부호화)하고, Decoder는 인코딩된 데이터를 디코딩(복호화)한다.
인코딩(부호화)이란 정보를 어떤 규칙에 따라 변환하는 것을 말하며, 디코딩(복호화)이란 인코딩된 정보를 원래의 정보로 되돌리는 것을 말한다.
- seq2seq는 Encoder와 Decoder를 RNN 기반 신경망으로 구성한다.
Encoder 부분
- Encoder는 RNN(LSTM, GRU 등)을 이용해 시계열 데이터를 ℎ라는 hidden state 벡터로 변환한다.
- 아래의 [그림 7-6]은 Encoder가 출력하는 벡터 ℎ는 LSTM 계층의 마지막 hidden state이다.
- 이 마지막 hidden state ℎ에 입력 문장(출발어)을 번역하는 데 필요한 정보가 인코딩 된다.
- 여기서 중요한 점은 hidden state ℎ는 고정 길이 벡터라는 것이며, 결국 인코딩한다라는 것은 임의의 길이의 문장을 고정 길이 벡터로 변환하는 작업이 된다.
Decoder 부분
- Encoder에서 출력된 벡터 ℎ를 입력으로 받아 문장을 생성한다.
- <eos>라는 구분 기호(특수 문자)를 사용헀는데 이 기호는 '구분자'이며, Decoder에 문장 생성의 시작을 알리는 신호로 이용된다.
- 또한 Decoder가 <eos>를 출력할 때까지 단어를 샘플링하도록 하기 위한 종료 신호이기도 하다.
- <eos>를 Decoder의 '시작/종료'를 알리는 구분자로 이용한다.
7.2.2 시계열 데이터 변환용 장난감 문제
- 위의 문제를 해결하기 위해, '문자' 단위로 분할한다.
- "57+5"가 입력되면 ['5', '7', '+', '5']로 분할한 리스트로 처리하는 것을 말한다.
7.2.3 가변 길이 시계열 데이터
- 위의 예제에서 데이터 마다 문자 수가 다르다는 것을 확인할 수 있다.
- "57+5"는 총 4 문자이고, "628+521"은 총 7
- 이렇게 데이터에 따라 timstep 크기가 다른 경우가 대부분이기 때문에 '미니배치 처리'를 위해 별도의 작업을 추가해줘야 한다.
미니배치로 학습할 때는 다수의 샘플을 한꺼번에 처리하므로, 한 미니배치에 속한 샘플들의 데이터 형상(shape)이 모두 똑같아야 한다.
- 가변 길이 시계열 데이터를 미니배치로 학습하기 위한 가장 단순한 방법은 패딩(padding)을 사용하는 것이다.
- 패딩이란 원래의 데이터에 의미없는 데이터를 채워 모든 데이터의 길이를 균일하게 맞추는 기법
- 아래의 그림은 각 데이터에 패딩을 적용한 것이며, 질문과 정답을 구분하기 위해 _를 추가해준 것이다.
- 위의 그림처럼 패딩을 적용하면 가변 길이 시계열 데이터도 처리할 수 있다.
- 하지만, 원래 존재하지 않던 패딩용 문자까지 seq2seq가 처리하게 되는 문제가 발생하게 된다.
- 이러한 문제를 해결하기 위해, Decoder에 입력된 데이터가 패딩일 경우 손실(loss)의 결과에 반영하지 않도록 하는 방법이 있다.
- Softmax with Loss 레이어에 '마스크'(Mask) 기능을 추가
- 그렇게 되면 Encoder 부분에서 패딩이 존재하지 않았던 것처럼 인코딩할 수 있다.
7.2.4 덧셈 데이터셋
# 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 클래스는 아래의 그림처럼 문자열을 입력으로 받아 벡터 ℎ로 변환한다.
- 아래의 그림에서 볼 수 있듯이 Encoder에서는 마지막 문자를 처리한 후 LSTM 계층의 hidden state ℎ를 출력하고, 이 ℎ가 Decoder로 전달된다.
# chap07/seq2seq.py
# coding: utf-8
import sys
sys.path.append('..')
from common.time_layers import *
from common.base_model import BaseModel
class Encoder:
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')
self.embed = TimeEmbedding(embed_W)
self.lstm = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=False)
self.params = self.embed.params + self.lstm.params # 리스트
self.grads = self.embed.grads + self.lstm.grads # 리스트
self.hs = None
def forward(self, xs):
xs = self.embed.forward(xs)
hs = self.lstm.forward(xs)
self.hs = hs
return hs[:, -1, :] # 마지막 hidden state
def backward(self, dh):
dhs = np.zeros_like(self.hs)
dhs[:, -1, :] = dh
dout = self.lstm.backward(dhs)
dout = self.embed.backward(dout)
return dout
7.3.2 Decoder 클래스
- Decoder 클래스는 아래의 그림과 같이, Encoder가 출력한 ℎ를 입력으로 받아 목적으로 하는 다른 문자열을 출력하는 것이다.
seq2seq에서 문장을 생성하는 Task에서는 Encoder와 Decoder에 입력으로 넣어주는 데이터에 대한 부여 방법이 다르다.
- 학습(train)시에는 평소에 우리가 알고있는 학습 시킬 데이터를 넣어주면 된다.
- 한편, 추론(inference)시에는 최초 시작을 알리는 구분 문자(여기서는 '_') 하나만 넣어주고, 그 후부터는 샘플링한 결과를 입력으로 넣어준다.
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 평가
- seq2seq 또한 다음과 같은 방식으로 학습이 이루어진다.
- 학습 데이터에서 미니배치를 선택하고,
- 미니배치로 부터 기울기를 선택하고,
- 기울기를 사용하여 매개변수를 갱신한다.
# coding: utf-8
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)
입력 데이터의 순서를 반전 -> 입력 문장의 첫 부분에서 기울기가 더 잘 전해져서 학습 효율이 좋아짐?
https://arxiv.org/abs/1409.3215
# coding: utf-8
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 = 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)
reverse_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)
reverse_acc_list.append(acc)
print('검증 정확도 %.3f%%' % (acc * 100))
# 그래프 그리기
x_reverse = np.arange(len(reverse_acc_list))
plt.plot(x_reverse, reverse_acc_list, marker='o')
plt.xlabel('에폭')
plt.ylabel('정확도')
plt.ylim(0, 1.0)
plt.show()
- 아래의 그림에서처럼, "나 는 고양이 로소 이다" → "이다 로소 고양이 는 나"로 바뀜에따라 "I"에서 "나"로 바로 기울기가 전달 되기 때문에 학습이 더 잘 된다고 볼 수 있다.
7.4.2 엿보기(Peeky)
- 일반적인 seq2seq는 Encoder의 마지막 hidden state ℎ 하나만이 Decoder에서 첫 timestep의 입력으로 들어간다.
- Peeky Decoder는 Cho et.al이 제안한 아이디어를 기반으로 만들어진 Decoder이다.
- 아래의 그림과 같이 모든 timestep의 Affine 레이어와 LSTM 레이어에 Encoder의 출력인 ℎ를 전달한다.
- 즉, 인코딩된 정보를 Decoder의 다른 레이어에도 전달해 주는 기법이다.
- 하지만, peeky를 사용하게 되면 학습시켜야할 가중치 매개변수가 커지기 때문에 계산량이 많아지는 단점이 있다.
# chap07/peeky_seq2seq.py
# coding: utf-8
import sys
sys.path.append('..')
from common.time_layers import *
from seq2seq import Seq2seq, Encoder
class PeekyDecoder:
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(H + D, 4 * H) / np.sqrt(H + 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 + H, V) / np.sqrt(H + 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
self.cache = None
def forward(self, xs, h):
N, T = xs.shape
N, H = h.shape
self.lstm.set_state(h)
out = self.embed.forward(xs)
hs = np.repeat(h, T, axis=0).reshape(N, T, H) # 매 timestep 입력
out = np.concatenate((hs, out), axis=2) # concat 후 LSTM으로 입력
out = self.lstm.forward(out)
out = np.concatenate((hs, out), axis=2) # concat 후 Affine으로 입력
score = self.affine.forward(out)
self.cache = H
return score
def backward(self, dscore):
H = self.cache
dout = self.affine.backward(dscore)
dout, dhs0 = dout[:, :, H:], dout[:, :, :H]
dout = self.lstm.backward(dout)
dembed, dhs1 = dout[:, :, H:], dout[:, :, :H]
self.embed.backward(dembed)
dhs = dhs0 + dhs1
dh = self.lstm.dh + np.sum(dhs, axis=1)
return dh
def generate(self, h, start_id, sample_size):
sampled = []
char_id = start_id
self.lstm.set_state(h)
H = h.shape[1]
peeky_h = h.reshape(1, 1, H)
for _ in range(sample_size):
x = np.array([char_id]).reshape((1, 1))
out = self.embed.forward(x)
out = np.concatenate((peeky_h, out), axis=2)
out = self.lstm.forward(out)
out = np.concatenate((peeky_h, out), axis=2)
score = self.affine.forward(out)
char_id = np.argmax(score.flatten())
sampled.append(char_id)
return sampled
class PeekySeq2seq(Seq2seq):
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 = PeekyDecoder(V, D, H)
self.softmax = TimeSoftmaxWithLoss()
self.params = self.encoder.params + self.decoder.params
self.grads = self.encoder.grads + self.decoder.grads
7.5 seq2seq를 이용하는 애플리케이션
seq2seq: 한 시계열 데이터를 다른 시계열 데이터로 변환
- 기계 번역
- 자동 요약
- 질의응답(MRC, RAG)
- 메일 자동 응답
7.5.1 챗봇
7.5.2 알고리즘 학습
7.5.3 이미지 캡셔닝
seq2seq는 텍스트 외에도 이미지와 음성 등 다양한 데이터를 처리할 수 있다.
이미지 -> 문장 : 이미지 캡셔닝
달라진 점: Encoder가 LSTM-> CNN으로 바뀐 것
7.6 정리
- RNN을 이용한 언어 모델은 새로운 문장을 생성할 수 있다.
- 문장을 생성할 때는 하나의 단어(혹은 문자)를 주고 모델의 출력(확률분포)에서 샘플링하는 과정을 반복한다.
- RNN을 2개 조합함으로써 시계열 데이터를 다른 시계열 데이터로 변환할 수 있다.
- seq2seq는 Encoder가 출발어 입력문을 인코딩하고, 인코딩된 정보를 Decoder가 받아 디코딩하여 도착어 출력문을 얻는다.
- 입력문을 반전시키는 기법(Reverse), 또는 인코딩된 정보를 Decoder의 여러 계층에 전달하는 기법(Peeky)는 seq2seq의 정확도 향상에 효과적이다.
- 기계 번역, 챗봇, 이미지 캡셔닝 등 seq2seq는 다양한 애플리케이션에 이용할 수 있다.
댓글