chap 5,6에 걸쳐 RNN과 LSTM의 구조와 구현을 살펴보았다.
이번 장에서는 언어 모델을 사용해 문장 생성을 수행한다.
구체적으로는 우선 말뭉치를 사용해 학습한 언어 모델을 이용하여 새로운 문장을 만들어낸다.
그 다음 개선된 언어 모델을 이용해 더 자연스러운 문장을 생성하는 모습을 선보일 것이다.
여기까지 해보면 'AI로 글을 쓰게 한다'라는 개념을 알 수 있을 것이다.
seq2seq의 신경망도 다룬다. (from) sequence to sequence(시계열에서 시계열로)를 뜻하는 말로, 한 시계열 데이터를 다른 시계열 데이터로 변환하는 것을 말한다.
seq2seq는 기계 번역, 챗봇, 메일의 자동 답신 등 다양하게 응용될 수 있다.
언어 모델을 사용한 문장 생성
언어 모델은 다양한 애플리케이션에서 활용할 수 있다.
위에서도 언급했지만 기계번역, 음성인식, 문장 생성 등이 있다.
이번 절에서는 언어 모델로 문장을 생성해보려 한다.
RNN을 사용한 문장 생성의 순서
앞장에서는 LSTM계층을 이용하여 언어 모델을 구현했는데, 신경망 구현은 아래 그림처럼 생겼었다.
시계열 데이터를 T개분 만큼 모아 처리하는 Time LSTM과 Time Affine계층 등을 만들었다.

언어 모델에게 문장을 생성시키는 순서를 설명한다.
you say goodbye and I say hello. 라는 말충치로 학습한 언어 모델을 예로 생각해보자.
이 학습된 언어 모델에 'I'를 입력으로 주면 어떻게 될까?
그러면 이 언어 모델은 아래 그림과 같은 확률분포를 출력한다.

언어 모델은 지금까지 주어진 단어들에서 다음에 출현하는 단어의 확률분포를 출력한다.
이 결과를 기초로 다음 단어를 새로 생성하려면 어떻게 해야 할까?
첫번째로 확률이 가장 높은 단어를 선택하는 방법을 떠올릴 수 있다.
확률이 가장 높은 단어를 선택할 뿐이므로 결과가 일정하게 정해지는 '결정적'인 방법이다. 아니면 '확률적'으로 선택하는 방법도 생각해볼 수 있다.
이 책에서는 매번 다른 문장을 생성(후자의 방법)하도록 한다. 그 편이 다양해져서 재미있을 것이다.
아래 그림같이 say 단어가 확률적으로 선택되었다고 가정하자.

위 그림의 확률분포에서는 say의 확률이 가장 높기 때문에 say가 샘플링될 확률이 가장 높다.
다만 필연적이지는 않고 확률적으로 결정된다는 점에 주의하자.
계속해서 두번째 단어를 샘플링해보자. 앞에서 한 작업을 되풀이하면 된다.

이런 식으로 작업을 원하는 만큼 반복한다.(또는 <eos>와 같은 종결 기호가 나타날 때까지 반복)
여기에서 주목할 것은 이렇게 생성한 문장은 훈련 데이터에는 존재하지 않는, 말 그대로 새로운 문장이다.
왜냐면 언어 모델은 훈련 데이터를 암기한 것이 아니라, 훈련 데이터에서 사용된 단어의 정렬 패턴을 학습한 것이기 때문이다. 만약 언어 모델이 말뭉치로부터 단어의 출현 패턴을 올바르게 학습할 수 있다면, 그 모델이 새로 생성하는 문장은 우리 인간에게도 자연스럽고 의미가 통하는 문장일 것으로 기대할 수 있다.
문장 생성 구현
앞장에서 구현한 Rnnlm클래스를 상속해 RnnlmGen클래스를 만들고, 이 클래스에 문장 생성 메서드를 추가한다.
RnnlmGen클래스 코드를 보자.
# -*- coding: utf-8 -*-
"""
You can do it
"""
import sys
sys.path.append('..')
import numpy as np
from common.functions import softmax
from ch06.rnnlm import Rnnlm
from ch06.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
generate 메서드에서 문장 생성을 수행한다.
start_id는 최초로 주는 단어 ID, sample_size는 샘플링하는 단어의 수, skip_ids는 단어 ID의 리스트(예 [12,20])인데, 이 리스트에 속하는 단어 ID는 샘플링되지 않도록 해준다. 이 인수는 PTB 데이터셋에 있는 <unk>나 N 등, 전처리된 단어를 샘플링하지 않게 하는 용도로 사용한다.
PTB데이터셋은 원래의 문장들에 이미 전처리를 해둔 것으로, 희소한 단어는 <unk>, 숫자는 N으로 대체해놨다.
참고로, 이 책에서는 각 문장을 구분하는데 <eos>를 사용한다.
가장먼저 model.predict를 호출해 각 단어의 점수를 출력한다.(정규화 전의 값)
그리고 softmax를 통해 정규화한다. 이를 통해 확률분포 p를 얻을 수 있다.
p로부터 단어를 샘플링한다. 참고로, 확률분포로부터 샘플링할때는 네거티브 샘플링을 사용한 것이다.
이 클래스를 사용해 문장을 생성해보자. 아무런 학습을 수행하지 않은 상태에서(즉, 가중치 매개변수는 무작위 초깃값인 상태에서) 문장을 생성한다. 코드를 참고하자.
# -*- coding: utf-8 -*-
"""
You can do it
"""
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('../ch06/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)
여기서는 첫 단어를 you로 하고, 이 단어 ID를 start_id로 설정 후 다음 문장을 생성한다.
참고로, 문장을 생성하는 이 메서드는 단어 ID를 배열 형태로 반환한다.
그래서 이 단어 ID 배열을 문장으로 변환해야 하는데, txt = ' '.join([id_to_word[i] for i in word_ids]) 이 코드가 그 역할을 한다.

보다싶이 단어들을 엉터리로 나열한 결과를 출력한다. 당연하게도, 모델의 가중치 초깃값으로 무작위한 값을 사용했기 때문에 그렇다.
그렇다면, 학습을 수행한 언어 모델의 경우를 보자. 주석 처리해둔 부분의 주석을 해제한 후의 실행 결과를 보자.

문법적으로 이상하거나 의미가 통하지 않는 문장이 섞여 있지만, 그럴듯한 문장들도 있다.
개선점은 여전히 존재하므로, 더 나은 모델을 쓰면 된다.
더 좋은 문장으로
좋은 언어 모델이 있으면 좋은 문장을 기대할 수 있다.
앞장에서는 단순한 RNNLM을 개선해 더 좋은 RNNLM을 구현했다.
BetterRnnlm 클래스로 문장을 생성해보자. 첫 문자로 you를 제시해본다.
# -*- coding: utf-8 -*-
"""
You can do it
"""
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('../ch6_gate_RNN/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)

점선 뒤에는 더 좋은 언어모델에 첫 문장을 주고 이어지는 말을 생성하라고 제시한 결과이다.
seq2seq
세상에는 시계열 데이터가 넘쳐난다. 언어, 음성, 동영상 데이터는 모두 시계열 데이터이다.
입력과 출력이 시게열 데이터인 문제는 아주 많다.
시계열 데이터를 다른 시계열 데이터로 변환하는 기법, 2개의 RNN을 이용하는 seq2seq(sequence to sequence) 방법을 살펴보자.
seq2seq의 원리
seq2seq를 Encoder-Decoder 모델이라고도 한다. 이름이 말해주듯 여기에는 Encoder에서 입력 데이터를 인코딩(부호화)하고, Decoder에서 인코딩된 데이터를 디코딩(복호화)한다.
"나는 고양이로소이다" 라는 문장을 "I am a cat"으로 번역하는 예를 들어보자.
아래 그림에서 보듯, Encoder과 Decoder가 시계열 데이터를 변환한다.

인코더와 디코더로는 RNN을 사용할 수 있다.
우선 Encoder의 처리에 집중해보자. Encoder의 계층은 다음 그림처럼 구성된다.

그림처럼 Encoder는 RNN을 이용해 시계열 데이터를 h라는 은닉 상태 벡터로 변환한다.
지금 예에서는 RNN중 LSTM을 이용했지만, 단순한 RNN이나 GRU 등도 이용할 수 있다.
벡터 h는 LSTM계층의 마지막 은닉 상태이다.
h에 입력 문장(출발어)을 번역하는 데 필요한 정보가 인코딩된다.
여기서 중요한 점은 LSTM의 은닉 상태 h는 고정 길이 벡터라는 사실이다.
그래서 인코딩한다라 함은 결국 임의 길이의 문장을 고정 길이 벡터로 변환하는 작업이 된다.

이어서 Decoder를 보자.

LSTM계층이 은닉 벡터 h를 받는다는 차이점이 있다.
이어서 Decoder와 Encoder를 연결한 계층 구성을 살펴보자.

LSTM계층의 은닉 상태가 Encoder와 Decoder를 이어주는 다리가 된다.
시계열 데이터 변환용 장난감 문제
시계열 변환 문제의 예로 더하기를 다뤄보자. 57 + 5 문자열을 seq2seq에게 건네면 62를 도출할 수 있도록 학습시켜 보자.

덧셈은 인간에게는 쉬운 문제이지만, seq2seq은 덧셈의 논리에 대해 아무것도 모른다.
seq2seq는 덧셈의 예로부터 사용되는 문자의 패턴을 파악한다. 이런 식으로 하면 덧셈의 규칙을 올바르게 학습할 수 있는 것일까?
우리는 지금까지 word2vec이나 언어 모델 등에서 문장을 단어 단위로 분할해왔다.
하지만 문장을 반드시 단어로 분할해야 하는건 아니다. 문자 단위(['5','7','+'.'5"])로 분할해보자.
가변 길이 시계열 데이터
우리는 덧셈을 문자 단위 분할을 해보기로 하였다. 예를 들어 57+5는 4문자이고, 628+512은 7문자이다.
이처럼 덧셈 문장마다 문자 단위가 달라진다.
이처럼 덧셈 문제에서는 샘플마다 데이터의 시간 방향 크기가 다르다. 가변 길이 시계열 데이터를 다룬다는 뜻이다.
따라서 신경망 학습 시 미니배치 처리를 하려면 무언가 추가적인 노력이 필요하게 된다.
미니배치로 학습할 때는 다수의 샘플을 한꺼번에 처리하는데, 한 미니배치에 속한 샘플들의 데이터 형상이 모두 똑같아야 한다.
가변 길이 시계열 데이터를 미니배치로 학습시키기 위한 가장 단순한 방법은 padding(패딩)을 이용하는 것이다.
딩이란 원래의 데이터에 의미없는 데이터를 채워 모든 데이터의 길이를 균일하게 맞추는 기법이다.
다음 그림은 패딩을 덧셈 문제에 적용해본 모습이다.

이번 문제에서는 0~999사이의 숫자 2개만 더하기로 설정했다. 따라서 덧셈 기호 '+'까지 포함하면 입력의 최대 문자 수는 7이 된다. 덧셈 결과는 최대 4문자이다.
정답 데이터에서 패딩을 수행해 모든 샘플 데이터의 길이를 통일한다.
그 결과, 출력 데이터는 총 5문자로 통일한다. 구분자는 Decoder에 문자열을 생성하라고 알리는 신호로 사용된다.(구분자)
이처럼 패딩을 적용해 데이터 크기를 통일시키면 가변 길이 시계열 데이터도 처리할 수 있다.
그러나 원래는 존재하지 않던 패딩용 문자까지 seq2seq가 처리하게 된다.
따라서 패딩을 적용해야 하지만 정확성이 중요하다면 seq2seq에 패딩 전용 처리를 추가해야 한다.
예컨대 Decoder에 입력된 데이터가 패딩이라면 손실의 결과에 반영하지 않도록 한다.(Softmax with Loss계층에 마스크(mask)기능을 추가해 해결할 수 있다.
한편 Encoder에 입력된 데이터가 패딩이라면 LSTM 계층이 이전 시각의 입력을 그대로 출력하게 된다.
즉, LSTM 계층은 마치 처음부터 패딩이 존재하지 않았던 것처럼 인코딩할 수 있다.
덧셈 데이터셋
학습할 덧셈 데이터는 dataset/addition.txt에 담겨 있다. 이 텍스트 파일에는 총 50000개의 덧셈 예가 있고, 다음 그림과 같은 형태이다.

위와 같은 학습 데이터를 쉽게 처리하도록 전용 모듈(dataset/sequence.py)을 제공한다.
이를 실제로 사용하는 예를 살펴보자.
# -*- coding: utf-8 -*-
"""
You can do it
"""
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

x_train과 t_train에는 문자ID가 저장되어 있다. 문자ID의 대응 관계는 char_to_id와 id_to_char를 이용해 상호변환할 수 있다.
정석대로라면 데이터셋을 3개(훈련,검증,테스트)로 나눠 사용해야한다. 훈련데이터로 학습을 하고, 검증 데이터로 하이퍼파라미터를 튜닝한다. 마지막으로 테스트 데이터에서는 모델의 성능을 평가한다.
seq2seq 구현
seq2seq은 앞서 설명했듯이 2개의 RNN을 연결한 신경망이다.
먼저 두 RNN을 Encoder 클래스와 Decoder 클래스로 각각 구현하겠다.
그런 다음 두 클래스를 연결하는 Seq2seq클래스를 구현하는 흐름으로 진행해보자.
Encoder 클래스
Encoder클래스는 다음 그림처럼 문자열을 받아 벡터 h로 변환한다.

앞서 언급했듯, RNN의 LSTM을 이용해 Encoder를 구성한다.

Enbedding 계층에서는 문자ID를 문자 벡터로 변환한다. 그리고 이 문자 벡터가 LSTM 계층으로 입력된다.
LSTM계층의 시간방향(오른쪽)으로는 은닉 상태와 셀을 출력하고 위쪽으로는 은닉 상태만 출력한다.
이 구성에서 상위 계층이 없으니 LSTM 계층의 위쪽 출력은 폐기된다.
또한, 마지막 문자를 처리한 후 LSTM계층의 은닉 상태 h를 출력한다.
이 은닉 상태 h가 Decoder로 전달된다.
Encoder에서는 LSTM의 은닉 상태만을 Decoder에 전달한다.
LSTM의 셀도 Decoder에 전달할 수는 있지만, 흔치 않다.
LSTM의 셀은 자기 자신만 사용한다는 전제로 설계되었기 때문이다.
그런데 우리는 시간 방향을 한꺼번에 처리하는 계층을 Time LSTM계층이나 Time Embedding계층으로 구현했다.
이러한 Time계층을 이용하면 우리의 Encoder는 다음 그림처럼 된다.

Encoder클래스의 코드를 보자.
차례대로 초기화,순전파,역전파를 담당하는 메서드를 제공한다.
# -*- coding: utf-8 -*-
"""
You can do it
"""
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, :]
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
초기화 메서드의 인수는 어휘 수(문자의 종류, 0~9의 숫자와 +, 공백 문자, _ 를 합쳐 13가지 문자를 사용한다.), 문자 벡터의 차원 수, LSTM 계층의 은닉 상태 벡터의 차원 수를 인수로 받는다.
초기화 메서드에서는 가중치 초기화 및 필요한 계층을 생성한다.
Time LSTM계층이 상태를 유지하지 않기 때문에 stateful = False로 설정한다.
이전 챕터의 언어 모델은 긴 시계열 데이터가 하나뿐인 문제를 다뤘다. 그래서 stateful을 True로 설정하여 은닉 상태를 유지한 채로 긴 시계열 데이터를 처리한 것이다.
한편, 이번에는 짧은 시계열 데이터가 여러개인 문제이다. 따라서 문제마다 LSTM의 은닉 상태를 다시 초기화한 상태(영벡터)로 설정한다.
이어서 순전파, 역전파 메서드를 보자.
Encoder의 순전파에서는 Time Embedding 계층과 Time LSTM계층의 forward메서드를 호출한다.
그리고 Time LSTM계층의 마지막 시각의 은닉 상태만을 추출해, 그 값을 Encoder의 forward메서드의 출력으로 반환한다.
역전파에서는 LSTM계층의 마지막 은닉 상태에 대한 기울기가 dh인수로 전해진다.
dh는 Decoder가 전해주는 기울기이다. 역전파 구현에서는 원소가 모두 0인 텐서dhs를 생성하고 dh를 dhs의 해당 위치에 할당한다. 그 다음은 Time LSTM과 Time Embedding계층의 backward메서드를 호출할 뿐이다.
Decoder 클래스
Decoder클래스는 아래 그림에서 보듯, Encoder클래스가 출력한 h를 받아 목적으로 하는 다른 문자열을 출력한다.

앞 절에서 설명한 것처럼 Decoder는 RNN으로 구현할 수 있다.
Encoder과 마찬가지로 LSTM계층을 사용하면 되며, 계층 구성은 다음과 같다.

여기에서 정답 데이터는 '_62'이지만, 입력 데이터를 ['_', '6', '2', ' ']를 주고 이에 대응하는 출력은 ['6', '2', ' ', ' ']이 되도록 학습시킨다.
RNN으로 문장을 생성할 때, 학습 시와 생성 시의 데이터 부여 방법이 다르다.
학습 시는 정답을 알고 있기 때문에 시계열 방향의 데이터를 한꺼번에 줄 수 있다.
한편 추론시(새로운 문자열 생성할 때)에는 최초 시작을 알리는 구분 문자('_') 하나만 준다.
그 출력으로부터 문자를 하나 샘플링하여, 그 샘플링한 문자를 다음 입력으로 사용하는 과정을 반복하는 것이다.
앞에서 문장을 생성할 때에는 Softmax함수의 확률분포를 바탕으로 샘플링을 수행했기 때문에 생성되는 문장이 확률에 따라 달라졌다.
이와 달리 이번 문제는 덧셈이므로 확률적인 비결정성을 배제하고 결정적인 답을 생성하고자 한다.
이번에는 점수가 가장 높은 문자 하나만 고르는, 즉 확률적이 아닌 결정적으로 선택한다.
Decoder가 문자열을 생성시키는 흐름을 보자.

argmax라는 못보던 노드가 새로 등장한다.
최댓값을 가진 원소의 인덱스(이번 예에서는 문자ID)를 선택하는 노드이다.
위 그림의 구성은 앞 절에서 본 문장 생성때의 구성과 같다.
다만 이번에는 Softmax계층을 사용하지 않고, Affine계층이 출력하는 점수가 가장 큰 문자 ID를 선택한다.
Softmax계층은 입력된 벡터를 정규화한다. 정규화 과정에서 벡터의 각 원소의 값이 달라지지만, 대소관계는 바뀌지 않는다. 따라서 위 그림의 경우 Softmax계층을 생략할 수 있다.
설명한 것처럼 Decoder에서는 학습시와 생성시에 Softmax계층을 다르게 취급한다.
그래서 Softmax with Loss계층은 Seq2seq클래스에서 처리하고, Decoder클래스는 Time Softmax with Loss계층의 앞까지만 담당하는 것으로 하자.

이와 같은 구성의 Decoder클래스의 구현 코드를 확인하자.
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
역전파 부분을 보자. backward메서드는 위쪽의 Softmax with Loss계층으로부터 기울기 dscore을 받아 Time Affine, Time LSTM, Time Embedding계층 순서로 전파시킨다.
이때 Time LSTM계층의 시간 방향으로의 기울기는 TimeLSTM클래스의 dh에 저장되어 있다.(chap6. LSTM구현 참고)
그래서 이 시간 방향의 기울기 dh를 꺼내 Decoder클래스의 backward의 출력으로 반환한다.
앞서 언급한 것처럼, Decoder클래스는 학습 시와 문장 생성 시의 동작이 다르다.
위의 forward메서드는 학습할때 사용된다. 다음으로 문장 생성을 담당하는 generate메서드를 구현해보자.
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
generate메서드는 은닉상태 h, 최초로 주어지는 문자ID, 생성하는 문자 수를 인수로 받는다.
여기에서는 문자를 1개씩 주고, Affine계층이 출력하는 점수가 가장 큰 문자ID를 선택하는 작업을 반복한다.
이번 구현에서는 Encoder의 출력 h를 Decoder의 Time LSTM계층의 상태로 설정했다.
즉, Time LSTM계층은 상태를 갖도록(stateful = True) 한 것이다.
단, 한번 설정된 이 은닉 상태는 재설정되지 않고, Encoder의 h를 유지하면서 순전파가 이뤄진다.
Seq2seq 클래스
마지막은 Seq2seq클래스의 구현이다.
이 클래스는 Encoder와 Decoder를 연결하고, Time Softmax with Loss 계층을 이용해 손실을 계산하는 것이다.
코드를 보자.
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
주가 되는 처리는 이미 구현했기 때문에, 그 기능들을 제대로 연결하기만 하면 된다.
다음으로 Seq2seq클래스를 사용해 덧셈 문제에 도전해보자.
seq2seq 평가
seq2seq의 학습은 기본적인 신경망의 학습과 같은 흐름으로 이어진다.
- 학습 데이터에서 미니배치를 선택하고
- 미니배치로부터 기울기를 계산하고
- 기울기를 사용하여 매개변수를 갱신한다.
Trainer클래스를 사용해 위 규칙대로 작업을 수행한다.
또한, 매 epoch마다 seq2seq가 테스트 데이터를 풀게 하여(문자열 생성) 학습 중간중간 정답률을 측정한다.
코드를 보자.
# -*- coding: utf-8 -*-
"""
You can do it
"""
import sys
sys.path.append('..')
import numpy as np
import matplotlib.pyplot as plt
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
hidden_size = 128
batch_size = 128
max_epoch = 25
max_grad = 5.0
# 일반 혹은 엿보기(Peeky) 설정 =====================================
model = Seq2seq(vocab_size, wordvec_size, hidden_size)
# model = PeekySeq2seq(vocab_size, wordvec_size, hidden_size)
# ================================================================
optimizer = Adam()
trainer = Trainer(model, optimizer)
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)
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)
acc_list.append(acc)
print('검증 정확도 %.3f%%' % (acc * 100))
# 그래프 그리기
x = np.arange(len(acc_list))
plt.plot(x, acc_list, marker='o')
plt.xlabel('에폭')
plt.ylabel('정확도')
plt.ylim(0, 1.0)
plt.show()


에폭마다의 정답률을 그래프로 출력하였는데, 에폭을 거듭함에 따라 정답률이 올라가는 것을 확인할 수 있다.
25에폭 기준 정답률이 약10%정도이다.
덧셈 문제를 더 잘 학습할 수 있도록 seq2seq을 개선해보자.
seq2seq 개선
앞 절의 seq2seq을 세분화하여 학습속도를 개선해보자. 두가지 개선안이 있다.
입력 데이터 반전(Reverse)
첫번째 개선안은 아주 쉬운 트릭으로, 아래 그림처럼 입력 데이터의 순서를 반전시키는 것이다.

입력 데이터를 반전시키면, 많은 경우 학습 진행이 빨라져서, 결과적으로 최종 정확도도 좋아진다고 한다.
아까의 코드에서 is_reverse = True로 설정한다.
# 데이터셋 읽기
(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]
# ================================================================

25에폭에 이르자 정답률은 50%정도까지 올랐다.
입력 데이터를 반전시킨 것뿐인데, 이만큼 차이가 난다는 것이 놀랍다.
그렇다면 왜 입력 데이터를 반전시키는 것만으로 학습의 진행이 빨라지고 정확도가 향상되는 걸까?
직관적으로는 기울기 전파가 원활해지기 때문이라고 생각된다.
엿보기(Peeky)
주제로 들어가기 전에, seq2seq의 Encoder 동작을 한번 더 살펴보자.
앞에서 설명했듯이 Encoder는 입력 문장(문제 문장)을 고정 길이 벡터 h로 변환한다. 이때 h안에는 Decoder에 필요한 정보가 모두 담겨 있다.
현재의 seq2seq는 아래 그림과 같이 최초 시각의 LSTM계층만이 벡터 h를 이용하고 있다.
h를 더 활용할 방법은 없을까?

여기서 두번째 개선안이 등장한다. h를 Decoder의 다른 계층에게도 전해주는 것이다.
다음 그림과 같은 모습이 될 것이다.

위 그림과 같이 모든 시각의 Affine, LSTM계층에 Encoder의 출력 h를 전해준다.
중요한 정보를 한 사람이 독점하는게 아닌, 많은 사람과 공유한다면 더 올바른 결정을 내릴 가능성이 커질 것이다.
이 개선안은 인코딩된 정보를 Decoder의 다른 계층에도 전해주는 기법이다.
엿보기 = peek이기 때문에, Peeky Decoder, Peeky seq2seq라고 한다.
그런데 위 그림에서는 LSTM계층과 Affine계층에 입력되는 벡터가 2개씩이 되었다.
이는 실제로 두 벡터가 연결(concatenate)된 것을 의미한다.
따라서 앞의 그림은 두 벡터를 연결시키는 concat노드를 이용해 다음 그림처럼 그려야 정확한 계산 그래프가 된다.

Peeky Decoder 클래스의 구현을 살펴보자.
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)
out = np.concatenate((hs, out), axis=2)
out = self.lstm.forward(out)
out = np.concatenate((hs, out), axis=2)
score = self.affine.forward(out)
self.cache = H
return score
역전파와 문장생성 메서드는 어려운 점이 없기에 생략한다.
forward메서드를 보자. h를 np.repeat으로 시계열만큼 복제해 hs에 저장한다.
np.concatenate를 이용해 hs와 Embedding 계층의 출력을 연결하고, 이를 LSTM 계층에 입력한다.
마찬가지로 Affine 계층에서도 hs와 LSTM 계층의 출력을 연결한 것을 입력한다.
마지막으로, PeekySeq2seq을 구현한다.
이 클래스는 앞 절의 Seq2seq과 거의 같지만, Decoder계층만이 차이가 있다.
# 일반 혹은 엿보기(Peeky) 설정 =====================================
#model = Seq2seq(vocab_size, wordvec_size, hidden_size)
model = PeekySeq2seq(vocab_size, wordvec_size, hidden_size)
# ================================================================
optimizer = Adam()
trainer = Trainer(model, optimizer)
자, 학습을 진행해보자. Reverse(입력 반전)도 적용 후 수행하였다.

실행 결과에서 보듯 Peeky를 추가로 적용하자 10에폭을 넘어서면서 90%를 넘는 정답률을 기록하고, 최종적으로는 100%에 가까워졌다.
이 실험 결과에서 Reverse와 Peeky가 효과적으로 함께 작동하고 있음을 알 수 있다.
참고로, 이번 절의 실험은 주의해야 할 점이 있다.
Peeky를 사용하게 되면 우리의 신경망은 가중치 매개변수가 커져 계산량도 늘어난다.
따라서 이번 절의 실험 결과는 커진 매개변수만큼의 핸디캡을 감안해야 한다.
또, seq2seq의 정확도는 하이퍼파라미터에 영향을 크게 받는다.
seq2seq을 이용하는 애플리케이션
seq2seq는 한 시계열 데이터를 다른 시계열 데이터로 변환한다.
이 시계열 데이터를 변환하는 프레임워크는 다양한 문제에 적용할 수 있다.
구체적인 예들을 보자.
- 기계 번역: 한 언어의 문장을 다른 언어의 문장으로 변환
- 자동 요약: 긴 문장을 짧게 요약된 문장으로 변환
- 질의응답: 질문을 응답으로 변환
- 메일 자동 응답: 받은 메일의 문장을 답변 글로 변환
게다가 얼핏 보기에는 seq2seq가 적용될수 없을 것 같은 문제라도 입/출력 데이터를 전처리하면 seq2seq를 적용할 수 있는 경우도 있다.
챗봇
챗봇에도 seq2seq을 사용할 수 있다.

알고리즘 학습
이번 챕터에서 수행한 실험은 간단한 덧셈문제였다.
원리적으로는 더 고차원적인 문제도 처리할 수 있다. 파이썬 코드를 처리하는 예를 보자.

소스 코드도 자연어와 마찬가지로 문자로 쓰여진 시계열 데이터이다.
몇줄에 걸친 코드라도 하나의 문장으로 처리할 수 있다.
하지만 위 그림에서 보듯, for나 if문이 포함된 문제는 일반적으로 잘 풀리지 않을 것이다.
이 문제도 seq2seq의 틀에서 처리할 수 있고, 구조를 개선한다면 이런 문제도 해결할 것으로 예상할 수 있다.
이미지 캡셔닝
지금까지는 seq2seq가 텍스트를 다루는 예만을 보았다. 하지만 seq2seq은 텍스트 외에도 이미지나 음성 등 다양한 데이터를 처리할 수 있다.
이미지 캡셔닝(image captioning)은 이미지를 문장으로 변환한다.
이 문제도 다음 그림과 같이 seq2seq의 틀 안에서 해결할 수 있다.

Encoder가 LSTM에서 합성곱 신경망(CNN, Convolutional Neural Network)으로 바뀌었다.
이 예에서는 이미지 인코딩을 CNN이 수행한다. 이때 CNN의 최종 출력은 특징 맵(feature map)이다.
특징 맵은 3차원(높이,폭,채널)이므로 이를 Decoder의 LSTM이 처리할 수 있도록 전처리 과정을 거친다.
그래서 CNN의 특징 맵을 1차원으로 평탄화(flattening) 후 완전연결인 Affine 계층에서 변환한다.
위 그림의 CNN에서는 VGG나 ResNet 등의 입증된 신경망을 사용하고, 가중치로는 다른 이미지 데이터셋(ImageNet 등)으로 학습을 끝낸 것을 이용한다. 이렇게 하면 좋은 인코딩을 얻을 수 있고, 좋은 문장을 생성할 수 있다.
seq2seq가 이미지 캡셔닝을 수행한 예를 몇가지 보자.
im2txt라는 텐서플로 코드로 생성된 예이다.

정리
- RNN을 이용한 언어 모델은 새로운 문장을 생성할 수 있다.
- 문장을 생성할 때는 하나의 단어(혹은 문자)를 주고 모델의 출력(확률분포)에서 샘플링하는 과정을 반복한다.
- RNN을 2개 조합함으로써 시계열 데이터를 다른 시계열 데이터로 변환할 수 있다.
- seq2seq는 Encoder가 출발어 입력문을 인코딩하고, 인코딩된 정보를 Decoder가 받아 디코딩하여 도착어 출력문을 얻는다.
- 입력문을 반전시키는 기법 Reverse, 또는 인코딩된 정보를 Decoder의 여러 계층에 전달하는 기법 Peeky는 seq2seq의 정확도 향상에 효과적이다.
- 기계 번역, 챗봇, 이미지 캡셔닝 등 seq2seq는 다양한 애플리케이션에 이용할 수 있다.
※dataset 및 코드 제공 - 밑바닥부터 시작하는 딥러닝 GitHub:https://github.com/WegraLee/deep-learning-from-scratch-2scratch/tree/master
GitHub - kchcoo/WegraLee-deep-learning-from-scratch
Contribute to kchcoo/WegraLee-deep-learning-from-scratch development by creating an account on GitHub.
github.com
'공부 > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글
밑바닥부터 시작하는 딥러닝2 - Chap8. 어텐션(Attention) (4) | 2025.01.07 |
---|---|
밑바닥부터 시작하는 딥러닝2 - Chap6. 게이트가 추가된 RNN (3) | 2025.01.01 |
밑바닥부터 시작하는 딥러닝2 - Chap5. 순환 신경망(RNN) (8) | 2024.12.27 |
밑바닥부터 시작하는 딥러닝2 - Chap4. word2vec 속도 개선 (3) | 2024.12.26 |
밑바닥부터 시작하는 딥러닝2 - Chap3. word2vec (6) | 2024.12.23 |