본문 바로가기

책/밑바닥부터 시작하는 딥러닝 2

7장 RNN을 사용한 문장 생성 (1)

"you say goodbye and I say hello."라는 말뭉치로 학습한 언어 모델을 예로 생각해보자. 이 모델에 "I"라는 단어를 입력으로 주면 모델은 다음과 같은 확률분포를 출력한다.

 

우리는 확률이 가장 높은 단어를 선택하는 결정적인 방법과 확률적으로 샘플링하는 방법을 사용할 수 있다. 여기서는 후자를 사용한다.

 

샘플링을 하였을 때 "say"가 선택되었다면 이것을 다시 모델에 입력하여 다음 단어의 확률분포를 얻는 식으로 문장을 생성해낼 수 있다. 주로 일정 길이나 "end of sentence"를 의미하는 eos 토큰이 나타날 때 까지 반복한다. 그럼 문장 생성을 구현해보자.

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)
  • 이전에 구현한 Rnnlm 클래스를 상속받아 RnnlmGen 클래스를 만들었다. 여기에 generate 메서드를 추가하여 문장을 생성한다.
  • start_id는 최초로 주는 단어 ID, sample_size는 최대로 샘플링 할 수 있는 단어의 수이다. skip_ids는 샘플링에서 제외하고 싶은 단어 ID의 리스트이다. 예를 들어 PTB 데이터셋에 있는 <unk>나 N 등의 ID를 사용할 수 있다.
  • 모델은 배치를 고려한 2차원 형상의 입력을 받기 때문에 x를 reshape 해준 뒤 차례로 score와 확률분포 p를 구한다. 
  • 그런다음 np.random.choice를 사용해 단어 id를 sampling한다.
  • 샘플링된 id가 skip_ids에 포함되어 있지 않다면 word_ids에 이를 append 해주면서 sample_size 만큼 반복한다. 

이를 가지고 문장을 생성하는 코드는 다음과 같다.

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 learn that even though commerce costs killing is all levels.
 but i 'd sometimes want to have option capital the most difference of dollars and computer seymour.
 mr. lawrence close to a major anc ruling around the tree does n't realize number of policy cities.
 if the second important approach might get a sad security bill custom will have to produce a major investment in parts of the soviet press and bench covering fears of the bugs provided for newcomers.
 the concern said it is down again image unusually high dividend.
 the energetic maker

문법적으로 이상한 것 의미가 통하지 않는 문장이 많지만 주어, 동사, 형용사의 쓰임새가 올바른 부분이 있다. 성능을 더 개선하기 위해서는 당연히 더 좋은 언어 모델을 사용하면 된다.

 

seq2seq

시계열 데이터를 다른 시계열 데이터로 변환하는 모델을 일컫는다. 또 일반적으로 seq2seq를 인코더-디코더 모델이라고도 한다. 기계번역을 예로 들어보면 간단하게 다음과 같이 표현할 수 있다.

 

 

그럼 먼저 LSTM을 사용한 encoder를 살펴보자.

 

 

매 타입스텝 마다 단어 하나씩을 입력받고 마지막 은닉 상태 $h$를 출력한다. $h$에는 번역하는데 필요한 정보가 인코딩 되어있다. 그리고 $h$는 고정 길이 벡터이다. 그래서 인코딩한다라함은 결국 임의 길이의 문장을 고정길이 벡터로 변환하는 작업이 된다.

 

이번엔 decoder를 살펴보자.

 

encoder와 차이점은 LSTM 계층이 $h$를 입력받는다는 점이 다르다. 즉 인코더 LSTM의 초기 은닉상태는 영벡터이고 디코더 LSTM의 초기 은닉 상태는 인코더의 마지막 은닉 상태 $h$라는 것이다.

 

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

seq2seq가 덧셈 문제를 풀 수 있도록 학습시켜볼 것이다. 앞에서는 주로 단어 단위로 분할하였지만 이번엔 문자 단위로 분할한다. 즉 57+5가 입력되면 [57, +, 5]가 아니라 [5, 7, +, 5]라는 리스트로 처리할 것이다.

 

가변 길이 시계열 데이터

우리가 풀려는 문제는 입출력의 길이가 문제마다 다르다. 즉, 가변 길이 시계열 데이터이다. 그래서 시계열 데이터를 미니배치로 학습하기 위해 패딩을 사용한다. 이번 문제에서는 0~999 사이의 숫자 2개만 더하기로 하자. 그러면 입력은 최대 7자리이고 출력은 최대(언더바를 포함하여) 5자리가 된다. 언더바는 질문과 정답을 구분하기 위해 출력 앞에 사용하는 것이다. start of sequence 토큰이라고 볼 수 있다.

 

패딩을 사용시 한 가지 문제점은 원래는 존재하지 않던 패딩용 문자까지 seq2seq가 처리하게 된다. 이를 방지하기 위해 마스킹을 사용하여 decoder에 입력된 데이터가 패딩이라면 손실의 결과에 반영하지 않도록 해야한다. 한편 encoder에서는 입력된 데이터가 패딩이라면 LSTM 계층이 이전 시각의 입력을 그대로 출력하게 한다.

 

seq2seq 구현

패딩과 관련한 부분을 처리하지 않고 우선 이해를 돕기위해 간단하게 구현해보자. 먼저 인코드 클래스이다.

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

forward 부분을 보면 마지막 타임 스텝의 은닉 상태를 리턴한다. 이어서 디코더 클래스이다.

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

forward 부분을 보면 먼저 인코더의 은닉 상태를 인수로 입력받고 set_state 메서드를 사용해 초기 은닉 상태를 설정한다.

이 둘을 결합한 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

디코더를 학습시킬 때는 예를들어 입력으로 ['_', '6', '2', '']를 준다면 정답으로는 ['6', '2', '', '']이 되도록 학습시킨다. 이 부분이 forward 메서드에서

decoder_xs, decoder_ts = ts[:, :-1], ts[:, 1:]

이다.

 

학습 코드는 생략

' > 밑바닥부터 시작하는 딥러닝 2' 카테고리의 다른 글

8장 어텐션 (1)  (0) 2023.11.03
7장 RNN을 사용한 문장 생성 (2)  (0) 2023.11.02
6장 게이트가 추가된 RNN (2)  (0) 2023.10.31
6장 게이트가 추가된 RNN (1)  (0) 2023.10.27
5장 RNN (2)  (0) 2023.09.27