본문 바로가기

Deep Learning(강의 및 책)/밑바닥부터 시작하는 딥러닝 2

7. RNN을 사용한 문장 생성

728x90

바닥부터 시작하는 딥러닝 2를 바탕으로 제작하였습니다.

 

이번에는 언어 모델을 사용해 '문장 생성'을 해보겠습니다. 구체적으로는 우선 말뭉치를 사용해 학습한 언어 모델을 이용하여 새로운 문장을 만들겠습니다. 그다음 개선된 언어 모델을 이용하여 더 자연스러운 문장을 생성하겠습니다. 

 

7.1. 언어 모델을 사용한 문장 생성

 ■ RNN을 사용한 문장 생성의 순서

 

LSTM 계층을 이용하여 언어 모델을 구현하면 위와 같이 그릴 수 있습니다. 시계열 데이터를 (T개분 만큼) 모아 처리하는 Time LSTM과 TIme Affine 계층 등을 만들었습니다.

이제 언어 모델에게 문장을 생성시키는 순서를 보겠습니다. "you say goodbye and I say hello."라는 말뭉치로 학습한 언어 모델을 예로 생각해보겠습니다. 이 학습된 언어모델에 "I"라는 단어를 입력으로 주면

 

위와 같은 확률분포를 출력합니다. 언어 모델은 지금까지 주어진 단어들에서 다음에 출현하는 단어의 확률분포를 출력합니다. 그럼 다음 단어를 새로 생성하려면 어떻게 해야 할 지 알아보겠습니다.

첫 번째로, 확률이 가장 높은 단어를 선택하는 방법을 떠올릴 수 있습니다. 확률이 가장 높은 단어를 선택할 뿐이므로 결과가 일정하게 정해지는 '결정적'인 방법입니다.

또한, '확률적'으로 선택하는 방법도 생각할 수 있습니다. 각 후보 단어의 확률에 맞게 선택하는 것으로, 확률이 높은 단어는 선택되기 쉽고, 확률이 낮은 단어는 선택되기 어려워집니다. 이 방식에서는 선택되는 단어(샘플링 단어)가 매번 다를 수 있습니다.

앞으로 확률적으로 선택하는 방법을 선택할 것이고 매번 샘플링 단어를 다르게 하겠습니다.

 

위 그림은 확률분포로부터 샘필링을 수행한 결과로 "say"가 선택된 경우입니다. 확률분포에서는 "say"의 확률이 가장 높기 때문에 "say"가 샘플링될 확률이 가장 높습니다. 다만, 필연적이지는 않고 '확률적'으로 결정된다는 점이 중요합니다. 즉 다른 단어들도 해당 단어의 출현 확률에 따라 정해진 비율만큼 샘플링될 수 있습니다.

이어서 두 번째 단어를 샘플링해보겠습니다. 방금 생성한 단어인 "say"를 언어 모델에 입력하여 다음 단어의 확률분포를 얻으면 됩니다.

 

위와 같은 모습을 보여줄 것입니다. 이러한 작업을 원하는 만큼(또는 <eos> 같은 종결 기호가 나타날 때까지) 반복하면 새로운 문장을 생성할 수 있습니다. 여기서 주목할 점은 이렇게 생성한 문장은 훈련 데이터에는 존재하지 않는, 말 그대로 새로 생성된 문장이라는 것입니다. 왜냐하면 언어 모델은 훈련 데이터를 암기한 것이 아니라, 훈련 데이터에서 사용된 단어의 정렬 패턴을 학습한 것이기 때문입니다. 만약 언어 모델이 말뭉치로부터 단어의 출현 패턴을 올바르게 학습할 수 있다면, 그 모델이 새로 생성하는 문장은 우리 인간에게도 자연스럽고 의미가 통하는 문장일 것입니다.

 

 ■ 문장 생성 구현

전에 구현한 Rnnlm 클래스를 상속해 RnnlmGen 클래스를 통해 구현하겠습니다.

 

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, skip_ids, sample_size)입니다. 인수 중 start_id는 최초로 주는 단어의 ID, sample_size는 샘플링하는 단어의 수를 의미합니다. 그리고 skip_ids는 단어 ID의 리스트인데, 이 리스트에 속하는 단어 ID는 샘플링되지 않도록 합니다. 이 인수는 PTB 데이터셋에 있는 <unk>나 N 등, 전처리된 단어를 샘플링하지 않게 하는 용도로 사용합니다.

generate() 메서드는 가장 먼저 model.predict(x)를 호출해 각 단어의 점수를 출력합니다(점수는 정규화되기 전의 값입니다). 그리고 p = softmax(score) 코드에서는 이 점수들을 소프트맥스 함수를 이용해 정규화합니다. 이것으로 목표로 하는 확률분포 p를 얻을 수 있습니다. 그 다음 확률 분포 p로부터 다음 단어를 샘플링합니다. 확률분포로부터 샘플링할 때는 np.random.choice()를 사용합니다. 이 메서드를 사용하면 확률에 맞춰 랜덤하게 선택합니다.

그럼 이어서 문장을 생성하는 코드를 작성하겠습니다.

 

# coding: utf-8
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로 설정한 다음 문장을 생성합니다. 샘플링하지 않을 단어로는 ['N', '<unk>', '$']를 지정했습니다. generate()는 단어 ID들을 배열 형태로 반환하기 때문에 단어 ID 배열을 문장으로 변환해야 합니다. 그 일을 txt = ' '.join([id_to_word[i] for i in word_ids]) 코드가 해줍니다. join() 메서드는 [구분자].join(리스트) 형태로 작성하며, 리스트의 단어들 사이에 구분자를 삽입해 모두 연결합니다. 즉 단어들을 ' '로 구분해 연결합니다.

위 코드에서는 전에 학습했던 가중치 매개변수들을 load_params를 이용해 불러왔습니다. 실행결과를 보면

 

이와 같습니다. 문법적으로 이상하거나 의미가 통하지 않는 문장이 섞여 있지만, 그럴듯한 문장도 있습니다. 주어와 동사를 짝지어 올바른 순서로 배치한 문장들도 있습니다. 형용사와 명사의 사용법도 어느 정도 맞습니다. 그렇다고 완벽한 문장을 생성한 것은 아닙니다.

 

7.2. seq2seq

시계열 데이터를 다른 시계열 데이터로 변환하는 모델에 대해서 생각하겠습니다. 이를 위한 기법으로 2개의 RNN을 이용하는 seq2seq라는 방법을 살펴보겠습니다.

 

 ■ seq2seq 원리

seq2seq를 Encoder - Decoder 모델이라고도 합니다. 문자 그대로 Encoder는 입력 데이터를 인코딩(부호화)하고, Decoder는 인코딩된 데이터를 디코딩(복호화)합니다. 우리말을 영어로 번역하는 예를 사용해 seq2seq의 구조를 살펴보겠습니다.

 

위 그림처럼 먼저 Encoder가 "나는 고양이로소이다"라는 출발어 문장을 인코딩합니다. 이어서 그 인코딩한 정보를 Decoder에 전달하고, Decoder가 도착어 문장을 생성합니다. 이때 Eoncder가 인코딩한 정보에는 번역에 필요한 정보가 응축되어 있고 Decoder는 응축된 정보를 바탕으로 도착어 문장을 생성합니다.

Encoder와 Decoder가 협력하여 시계열 데이터를 다른 시계열 데이터로 변환하는 구조가 seq2seq의 전체 그림입니다. 그리고 Encoder와 Decoder로는 RNN을 사용할 수 있습니다.

먼저 Encoder부터 살펴보겠습니다.

 

계층은 위와 같이 구성됩니다. Encoder는 RNN을 이용해 시계열 데이터를 h라는 은닉 상태 벡터로 변환합니다. 위 예에서는 RNN으로써 LSTM을 이용했지만, 단순한 RNN이나 GRU 등도 이용할 수 있습니다. Encoder가 출발하는 벡터 h는 LSTM 계층의 마지막 은닉 상태입니다. 이 마지막 은닉 상태 h에 입력 문장(출발어)을 번역하는 데 필요한 정보가 인코딩 됩니다. 그리고 LSTM의 은닉 상태 h는 고정 길이 벡터입니다. 그래서 인코딩을 한다면 임의 길이의 문장을 고정 길이 벡터로 변환하는 작업이 됩니다.

 

위 그림에서 보듯 Encoder는 문장을 고정 길이 벡터로 변환합니다. 

이번에는 Decoder에 대해서 알아보겠습니다.

 

Decoder는 앞 절의 신경망과 한 가지만 빼고 같은 구성입니다. LSTM 계층이 벡터 h를 입력받는다는 점이 다릅니다. 앞 절의 신경망은 LSTM 계층이 아무것도 받지 않았습니다.

이제는 Decoder와 Encoder를 연결한 계층 구성을 보겠습니다.

 

seq2seq는 LSTM 두 개(Encoder의 LSTM과 Decoder의 LSTM)로 구성됩니다. 이때 LSTM 계층의 은닉 상태가 Encoder와 Decoder를 이어주는 다리가 됩니다. 순전파 때는 Encoder에서 인코딩된 정보가 LSTM 계층의 은닉 상태를 통해 Decoder로 전해집니다. 그리고 seq2seq의 역전파 때는 이 다리를 통해 기울기가 Decoder로부터 Encoder로 전해집니다.

 

 ■ 시계열 데이터 변환용 장난감 문제

시계열 변환 문제의 예로 '더하기'를 다루겠습니다. 구체적으로는 "57 + 5"와 같은 문자열을 seq2seq에 건네면 "62"라는 정답을 내놓도록 학습시키겠습니다. 참고로, 이와 같이 머신러닝을 평가하고자 만든 문제를 '장난감 문제(toy promblem)'라고 합니다.

 

그림으로 나타내면 위와 같이 정답을 내놓도록 학습하겠습니다. 사실 여기서 다루는 덧셈은 인간에게는 간단합니다. 하지만 seq2seq는 덧셈(덧셈의 논리)에 대해 알지 못합니다. seq2seq는 덧셈의 예로부터, 거기서 사용되는 문자의 패턴을 학습합니다. 이런 식으로 해서 덧셈의 규칙을 올바르게 학습할지가 관건입니다.

이전까지 word2vec이나 언어 모델 등에서 문장을 '단어'단위로 분할했습니다. 하지만 문장을 반드시 단어로 분할해야 하는 건 아니고 이번에는 '문자' 단위로 분할하겠습니다. 예를 들어 "57 + 5"가 입력되면 ['5', '7', '+', '5']라는 리스트로 처리할 것입니다.

 

 ■ 가변 길이 시계열 데이터

덧셈을 문자의 리스트로써 다루기로 했는데 여기서 문제는 덧셈 문장("57 + 5"나 "628 + 521" 등)이나 그 대답("62"나 "1149" 등)의 문자 수가 문제마다 다르다는 것입니다. 덧셈 문제에서는 샘플마다 데이터의 시간 방향 크기가 다릅니다. 즉, '가변 길이 시계열 데이터'를 다루겠다는 뜻입니다. 따라서 신경망 학습 시 '미니배치 처리'를 하려면 무언가 추가해야 합니다.

가변 길이 시계열 데이터를 미니배치로 학습하기 위한 가장 단순한 방법은 패딩(padding)을 사용하는 것입니다. 패딩이란 원래의 데이터에 의미 없는 데이터를 채워 모든 데이터의 길이를 균일하게 맞추는 기법입니다.

 

위 그림을 보면 패딩을 적용한 모습입니다. 모든 입력 데이터의 길이를 통일하고, 남는 공간에는 의미 없는 데이터(여기에서는 공백)를 채운 걸 볼 수 있습니다. 이번 문제에서는 0~999 사이의 숫자 2개만 더하기로 하겠습니다. 따라서 '+'까지 포함하면 입력의 최대 문자 수는 7이 됩니다. 자연스럽게, 덧셈 결과는 최대 4 문자입니다. 더불어 정답 데이터에도 패딩을 수행해 모든 샘플 데이터의 길이를 통일합니다. 그리고 질문과 정답을 구분하기 위해 출력 앞에 구분자로 밑줄(_)을 붙이겠습니다. 그 결과 출력 데이터는 총 5 문자로 통일합니다. 참고로, 이 구분자는 Decoder에 문자열을 생성하라고 알리는 신호로 사용됩니다.

이처럼 패딩을 적용해 데이터 크기를 통일시키면 가변 길이 시계열 데이터도 처리할 수 있습니다. 그러나 원래는 존재하지 않던 패딩용 문자까지 seq2seq가 처리하게 됩니다. 따라서 패딩을 적용해야 하지만 정확성이 중요하다면 seq2seq에 패딩 전용 처리를 추가해야 합니다. 예컨대 Decoder에 입력된 데이터가 패딩이라면 손실의 결과에 반영하지 않도록 합니다(Softmax with Loss 계층에 '마스크' 기능을 추가해 해결할 수 있습니다). 한편 Encoder에 입력된 데이터가 패딩이라면 LSTM 계층이 이전 시각의 입력을 그대로 출력하게 합니다. 즉, LSTM 계층은 마치 처음부터 패딩이 존재하지 않았던 것처럼 인코딩할 수 있습니다.

 

 ■ 덧셈 데이터셋

지금부터 사용할 덧셈 학습 데이터는 책에서 제공해주는 txt 파일에 담겨있습니다. 이 텍스트 파일에는 덧셈 예가 총 50000개가 들어 있고, 예를 들어

 

이와 같은 형태입니다. 이번에는 실제 데이터를 불러오는 코드를 보겠습니다.

 

# 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

 

sequence.load_data(file_name, seed)를 이용해 지정한 텍스트 파일을 익어 텍스트를 문자 ID로 반환하고, 이를 훈련 데이터와 테스트 데이터로 나눠 반환합니다. seed는 이 메서드 내부에서 사용하는 무작위수의 초깃값입니다. 이 메서드는 훈련 데이터와 테스트 데이터로 나누기 전에 전체 데이터를 뒤섞는데, 이때 무작위수를 사용합니다. get_vocab() 메서드는 문자와 문자 ID의 대응 관계를 담은 딕셔너리를 반환합니다. 이처럼 sequence 모듈을 이용하면 seq2seq용 데이터를 간단히 읽어 들일 수 있습니다.

 

7.3. seq2seq 구현

seq2seq는 2개의 RNN을 연결한 신경망입니다. 먼저 두 RNN을 Encoder 클래스와 Decoder 클래스로 각각 구현하겠습니다. 그다음 두 클래스를 연결하는 Seq2seq 클래스를 구현하겠습니다.

 

 ■ Encoder 클래스

 

Encoder 클래스는 위 그림과 같이 문자열을 받아 벡터 h로 변환합니다.

 

여기에서는 LSTM 계층을 사용해 Encoder 계층을 그렸습니다. 위 그림과 같이 Encoder 클래스는 Embedding 계층과 LSTM 계층으로 구성됩니다. Embedding 계층에서는 문자(정확하게는 문자 ID)를 문자 벡터로 변환합니다. 그리고 이 문자 벡터가 LSTM 계층으로 입력됩니다. LSTM 계층은 오른쪽으로는 은닉 상태와 셀을 출력하고 위쪽으로는 은닉 상태만 출력합니다. 이 구성에서 더 위에는 다른 계층이 없으니 LSTM 계층의 위쪽 출력은 폐기됩니다. Encoder에서는 마지막 문자를 처리한 후 LSTM 계층의 은닉 상태 h를 출력합니다. h는 Decoder로 전달됩니다.

그런데 우리는 시간 방향을 한꺼번에 처리하는 계층을 Time LSTM 계층이나 Time Embedding 계층으로 구현했습니다. 이러한 Time 계층을 이용하면 우리의 Encoder는 

 

이와 같은 형태입니다. 이제 Encoder 클래스의 코드를 작성하겠습니다.

 

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

 

먼저 초기화 메서드 먼저 보겠습니다. 초기화 메서드에서는 인수로 vocab_size, wordvec_size, hidden_size를 받았습니다. vocab_size는 어휘 수이며, 여기서 어휘 수는 문자의 종류를 뜻합니다. 참고로 이번에는 0~9의 숫자와 '+', ' '(공백 문자), '_'을 합쳐 총 13가지 문자를 사용합니다. 그리고 wordvec_size는 문자 벡터의 차원 수, hidden_size는 LSTM 계층의 은닉 상태 벡터의 차원 수를 뜻합니다.

이 초기화 메서드에서는 가중치 매개변수를 초기화하고, 필요한 계층을 생성합니다. 마지막으로, 가중치 매개변수와 기울기를 인스턴스 변수 params와 grads 리스트에 각각 보관합니다. 그리고 이번에는 Time LSTM 계층이 상태를 유지하지 않기(그 전에는 긴 시계열 데이터를 처리했기 때문에 상태를 유지해야 했지만 이번 문제에는 짧은 시계열 데이터 여러 개를 처리하는 문제이기 때문에 상태를 유지할 필요가 없다) 때문에 stateful = False로 설정합니다.

이어서 forward()와 backward() 메서드를 보겠습니다.

 

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

 

Encoder의 순전파에서는 Time Embedding 계층과 Time LSTM 계층의 forward() 메서드를 호출합니다. 그리고 Time LSTM 계층의 마지막 시각의 은닉 상태만을 추출해, 그 값을 Encoder의 forward() 메서드의 출력으로 반환합니다.

Encoder의 역전파에서는 LSTM 계층의 마지막 은닉 상태에 대한 기울기가 dh인수로 전해집니다. 이 dh는 Decoder가 전해주는 기울기입니다. 역전파 구현에서는 원소가 모두 0인 텐서 dhs를 생성하고 dh를 dhs의 해당 위치에 할당합니다. 그다음은 Time LSTM 계층과 Time Embedding 계층의 backward() 메서드를 호출합니다.

 

 ■ Decoder 클래스

 

Decoder 클래스는 위 그림과 같이 Encoder 클래스가 출력한 h를 받아 목적으로 하는 다른 문자열을 출력합니다.

 

위 그림과 같이 Decoder는 Encoder와 마찬가지로 LSTM을 사용해 구현할 수 있습니다. 여기에서 정답 데이터는 "_62"입니다만, 입력 데이터를 ['_', '6', '2', '']로 주고, 이에 대응하는 출력은 ['6', '2', '', '']이 되도록 학습시킵니다. 이 전에 문장을 생성할 때는 소프트맥스 함수의 확률분포를 바탕으로 샘플링을 수행했기 때문에 생성되는 문장이 확률에 따라 달라졌습니다. 이와 달리 이번에는 '덧셈'이므로 확률적인 '비결정성'을 배제하고 '결정적'인 답을 생성하겠습니다. 그래서 이번에는 점수가 가장 높은 문자 하나만 고르겠습니다. 즉, '확률적'이 아닌'결정적'으로 선택합니다.

 

위 그림은 Deocder가 문자열을 생성시키는 흐름을 보여줍니다. 'argmax'라는 못 보던 노드가 새로 등장합니다. 바로 최댓값을 가진 원소의 인덱스를 선택하는 노드입니다. 전에 봤던 구조와는 다르게 Softmax 계층을 사용하지 않고, Affine 계층이 출력하는 점수가 가장 큰 문자 ID를 선택합니다. Softmax 계층은 입력된 벡터를 정규화합니다. 이 정규화 과정에서 벡터의 각 원소의 값이 달라지지만, 대소 관계는 바뀌지 않습니다. 따라서 위 그림의 경우 Softmax 계층을 생략할 수 있습니다.

위에서 본 것처럼 Decoder에서는 학습 시와 생성 시에 Softmax 계층을 다르게 취급합니다. 그러니 Softmax with Loss 계층은 이후에 구현하는 Seq2seq 클래스에서 처리하기로 하고, Decoder 클래스는 Time Softmax with Loss 계층의 앞까지만 담당하기로 하겠습니다.

 

위 그림은 Decoder 클래스의 구성을 보여줍니다. Decoder 클래스는 Time Embedding, Time LSTM, Time Affine의 3가지 계층으로 구성됩니다. 이제 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

 

일단 초기화 메서드와 forward(), backward() 메서드를 보겠습니다. 초기화 메서드와 순전파는 Encoder와 비슷한 형태를 보여줍니다. backward() 메서드를 보면 위쪽의 Softmax with Loss 계층으로부터 기울기 dscore를 받아 Time Affine 계층, Time LSTM 계층, Time Embedding 계층 순서로 전파시킵니다. 이때 Time LSTM 계층의 시간 방향으로의 기울기는 TImeLSTM 클래스의 인스턴스 변수 dh에 저장되어 있습니다. 그래서 이 시간 방향의 기울기 dh를 꺼내서 Decoder 클래스의 backward()의 출력으로 반환합니다.

Decoder 클래스는 학습 시와 문장 생성 시의 동작이 다릅니다. 앞의 forward() 메서드는 학습할 때 사용된다고 가정했습니다. 이번에는 Decoder 클래스에 문장 생성을 담당하는 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() 메서드는 인수를 3개 받습니다. 차례로, Encoder로부터 받는 은닉 상태인 h, 최초로 주어지는 문자 ID인 start_id, 생성하는 문자 수인 sample_size입니다. 여기에서는 문자를 1개씩 주고, Affine 계층이 출력하는 점수가 가장 큰 문자 ID를 선택하는 작업을 반복합니다.

 

 ■ 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

 

사실 주가 되는 처리는 Encoder와 Decoder에서 이미 구현되어 있어 그 기능들을 연결하는 코드입니다

 

 ■ seq2seq 평가

seq2seq의 학습은 기본적인 신경망의 학습과 같이

  1. 학습 데이터에서 미니배치를 선택
  2. 미니배치로부터 기울기를 계산
  3. 기울기를 사용하여 매개변수를 갱신

순서로 이뤄집니다. 코드를 구현하겠습니다.

 

# coding: utf-8
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()

 

이번에는 평가 척도를 정답률로 했습니다. 에폭마다 테스트 데이터의 문제 중 몇 개를 풀게 하여 올바르게 답했는지를 채점합니다. 채점을 위해 eval_seq2seq(model, question, correct, id_to_char, verbose, is_reverse) 메서드를 사용했는데 문제를 모델에 주고, 문자열을 생성하게 하여 그것이 답과 갖다면 1을, 다르면 0을 return 합니다.

실행 결과를 보겠습니다.

 

터미널에는 에폭 별 결과가 출력되고 일부분을 캡처한 모습입니다. "Q 975 + 164" 줄이 문제 문장이고, 그 아래에 "T 1139"가 정답입니다. 그리고 "X 1189"가 우리 모델이 내놓은 답입니다. 아래에 "Q 582 + 84"문제에 대해 우리 모델이 내놓은 답과 정답이 같습니다. 그래서 모델이 내놓은 답이 "X"가 아닌 "O"로 시작하는 것을 볼 수 있습니다.

학습이 진행될수록 조금씩 정답에 가까워지면서, 몇 개씩 맞히기 시작합니다. 25 에폭에서 중단했는데, 그 시점의 정답률은 10% 정도입니다.

 

 

 

 

 

7.4. seq2seq 개선

이번에는 seq2seq를 세분화하여 학습 속도를 개선하겠습니다.

 

 ■ 입력 데이터 반전(Reverse)

학습 속도를 개선하는 첫 번째 방법으로 아주 간단한 트릭입니다.

 

위 그림과 같이 입력 데이터의 순서를 반전시키는 것입니다. 이 입력 데이터를 반전시키는 트릭을 사용하면 학습 진행이 빨라져서, 결과적으로 최종 정확도도 좋아집니다. 실제로 구현하기 위해서 아까 구현한 코드에 한 줄만 추가해주면 됩니다.

 

(x_train, t_train), (x_test, t_test) = sequence.load_data('addition.txt')
x_train, x_test = x_train[:, ::-1], x_test[:, ::-1]

 

이와 같이 마지막 줄을 추가해주면 됩니다. 배열의 행을 반전시키기 위해 x_train[:, ::-1]라는 표기법을 사용했습니다. 이 문장을 추가했을 때 정확도를 보겠습니다.

 

입력 데이터를 반전시킨 것만으로 학습 진행이 개선됐습니다. 25 에폭에 이르자 정답률은 50% 정도까지 올랐습니다. 데이터를 반전시킨 것뿐만으로 이러한 유의미한 차이를 보일 수 있습니다.

입력 데이터를 반전시키는 것만으로 학습의 진행이 빨라지고 정확도가 향상된 이유는 기울기 전파가 원활해지기 때문입니다. 예를 들어 "나는 고양이로소이다"를 "I am a cat"으로 번역하는 문제에서, "나"라는 단어가 "I"로 변환되는 과정을 보겠습니다. 이때 "나"로부터 "I"까지 가려면 "는", "고양이", "로소", "이다"까지 총 네 단어 분량의 LSTM 계층을 거치지 않으면 안 됩니다. 따라서 역전파 시 "I"로부터 전해지는 기울기가 "나"에 도달하기까지, 그 먼 거리만큼 영향을 더 받게 됩니다. 여기서 입력문을 "이다 로소 고양이 는" 순으로 반전시킨다면 "나"와 "I"가 바로 옆이 되었습니다. 그렇기 때문에 기울기가 직접 전해집니다. 이처럼 입력 문장의 첫 부분에서는 반전 덕분에 대응하는 변환 후 단어와 가까우므로, 기울기가 더 잘 전해져서 학습 효율이 좋아집니다. 다만, 입력 데이터를 반전해도 단어 사이의 '평균'적인 거리는 그대로입니다.

 

 ■ 엿보기(Peeky)

이어서 두 번째 개선입니다. 일단 seq2seq의 Encoder 동작을 한번 더 보겠습니다.

 

Encoder는 입력 문장을 고정 길이 벡터 h로 변환합니다. 이때 h 안에는 Decoder에게 필요한 정보가 모두 담겨 있습니다. 즉, h가 Decoder에 있어서는 유일한 정보인 셈입니다. 그러나 현재 seq2seq는 최초 시각의 LSTM 계층만이 벡터 h를 사용하고 있습니다.

 

위와 같이 모든 시각의 Affine 계층과 LSTM 계층에 Encoder의 출력 h를 전해줍니다. 그럼 기존에 하나의 LSTM만이 소유하던 중요 정보 h를 여러 계층이 공유함을 알 수 있습니다. 즉, 중요한 정보를 한 사람이 독점하는 게 아니라 많은 사람과 공유한다면 더 올바른 결정을 내릴 가능성이 큰 것처럼 중요한 정보 h를 여러 계층에 공유하면 더 좋은 결과를 얻을 수 있습니다.

이러한 과정을 거치면 LSTM 계층과 Affine 계층에 입력되는 벡터가 2개씩이 되었습니다. 이는 실제로는 두 벡터가 연결된 것을 의미합니다.

 

위 그림과 같이 계산 그래프가 그려집니다. 이제 PeekyDecoder 클래스를 구현하겠습니다.

 

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

    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

 

PeekyDecoder의 초기화는 앞 절의 Decoder와 거의 같습니다. 다른 점은 LSTM 계층의 가중치와 Affine 계층의 가중치의 형상뿐입니다. Encoder가 인코딩한 벡터도 입력되기 때문에 가중치 매개변수의 형상이 그만큼 커집니다.

forward() 메서드는 먼저 h를 np.repeat()로 시계열만큼 복제해 hs에 저장합니다. 다음 np.concatenate()를 이용해 그 hs와 Embedding 계층의 출력을 연결하고, 이를 LSTM 계층에 입력합니다. 마찬가지로 Affine 계층에도 hs와 LSTM 계층의 출력을 연결한 것을 입력합니다.

마지막으로 PeekySeq2seq를 구현합니다. 이 클래스는 앞 절의 Seq2seq 클래스와 거의 같지만 Decoder 계층에 차이가 있습니다. 앞 절의 Seq2seq 클래스가 Decoder 클래스를 사용하던 것에 반해 이번에는 PeekyDecoder를 사용합니다. 그다음은 동일합니다.

 


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

 

이와 같이 PeekySeq2 seq 클래스를 구현할 수 있습니다. 이제 마지막으로 Peeky와 reverse를 둘 다 적용한 학습 결과를 보겠습니다.

 

seq2seq의 결과가 월등히 좋아진 것을 볼 수 있습니다. 10 에폭을 넘으면서 정답률이 이미 90%를 넘고, 최종적으로 100%에 가까워집니다. 입력 문장을 반전시키는 Reverse, 그리고 Encoder의 정보를 널리 퍼지게 하는 Peeky, 이 두 기법 덕분에 우리는 만족스러운 결과를 얻었습니다. 단, peeky를 이용하게 되면 우리의 신경망은 가중치 매개변수가 커져서 계산량도 늘어납니다.

 

7.5. seq2seq를 이용하는 애플리케이션

seq2seq는 '한 시계열 데이터'를 '다른 시계열 데이터'로 변환합니다. 이 시계열 데이터를 변환하는 프레임 워크는 다양한 문제에 적용할 수 있습니다.

  • 기계 번역 : '한 언어의 문장'을 '다른 언어의 문장'으로 변환
  • 자동 요약 : '긴 문장'을 '짧게 요약된 문장'으로 변환
  • 질의응답 : '질문'을 '응답'으로 변환
  • 메일 자동 응답 : '받은 메일의 문장'을 '답변 글'로 변환

이처럼 seq2seq는 2개가 짝을 이루는 시계열 데이터를 다루는 문제에 이용할 수 있습니다. 자연어 외에도 음성이나 영상 등에도 이용할 수 있습니다. 게다가 얼핏 보기에는 seq2seq가 적용될 수 없을 것 같은 문제라도 입력, 출력 데이터를 전처리하면 seq2seq를 적용할 수 있는 경우도 있습니다.

 

 ■ 챗봇

챗봇은 사람과 컴퓨터가 텍스트로 대화를 나누는 프로그램입니다. 벌써 페이스북과 트위터 등, 다양한 서비스에서 챗봇이 활용되고 있습니다. 대화라는 것은 '상대의 말'과 '자신의 말'로 구성되기 때문에 '상대의 말'을 '자신의 말'로 변환하는 문제로 볼 수 있고 seq2seq를 학습시킬 수 있습니다.

 

 ■ 알고리즘 학습

우리가 수행한 실험은 '덧셈'과 같은 간단한 문제였지만, 원리적으로는 더 고차원적인 문제도 처리할 수 있습니다. 예를 들어 파이썬 코드를 처리할 수도 있습니다.

 

소스 코드도 (자연어와 마찬가지로) 문자로 쓰인 시계열 데이터입니다. 몇 줄에 걸친 코드라도 하나의 문장으로 처리할 수도 있고 따라서 소스 코드를 그대로 seq2seq에 입력할 수 있으며, 원하는 답과 대조하여 학습시킬 수 있습니다. 위 그림과 같이 for 문이나 if 문이 포함된 문제는 일반적으로 잘 풀리지 않을 것입니다. 하지만 seq2seq의 틀에서 처리할 수 있습니다.

 

 ■ 이미지 캡셔닝

지금까지 seq2seq가 텍스트를 다루는 예만 봤지만, seq2seq는 텍스트 외에도 이미지나 음성 등 다양한 데이터를 처리할 수 있습니다. 이미지 캡셔닝은 '이미지'를 '문장'으로 변환합니다.

 

위 그림은 seq2seq의 틀로 구현된 모습입니다. Encoder가 LSTM에서 CNN으로 바뀐 모습입니다. CNN의 최종 출력은 특징 맵입니다. 특징 맵은 3차원 (높이, 폭, 채널)이므로 이를 Decoder의 LSTM이 처리할 수 있도록 처리해야 합니다. 그래서 CNN의 특징 맵을 1차원으로 평탄화(flattening)한 후 완전연결인 Affine 계층에서 변환합니다. 그런 다음 변환된 데이터를 Decoder에 전달하면 지금까지와 같은 문장 생성을 수행할 수 있습니다.

'Deep Learning(강의 및 책) > 밑바닥부터 시작하는 딥러닝 2' 카테고리의 다른 글

8. 어텐션  (0) 2022.03.05
6. 게이트가 추가된 RNN  (0) 2022.03.03
5. 순환 신경망(RNN)  (0) 2022.03.02
4. word2vec 속도 개선  (0) 2022.02.26
3. word2vec  (0) 2022.02.23