NIRVANA

[밑바닥부터 시작하는 딥러닝2] 7장. RNN을 사용한 문장 생성 본문

AI

[밑바닥부터 시작하는 딥러닝2] 7장. RNN을 사용한 문장 생성

녜잉 2024. 3. 21. 00:19

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

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

"I"라는 단어를 주었을 때, 다음 단어를 생성하는 방법

  • 확률이 가장 높은 단어를 선택
    • 확률이 가장 높은 단어를 선택할 뿐 → 결과가 일정하게 정해지는 '결정적' 방법이 될 것 
  • 확률적으로 선택하는 방법
    • 각 후보 단어의 확률에 맞게 선택 → 선택되는 단어가 매번 다를 수 있음
    • 필연적이지 않고, '확률적'으로 결정됨을 주의 = 다른 단어들도 해당 단어의 출현 확률에 따라 정해진 비율만큼 샘플링될 가능성이 있다는 뜻
  • 종결 기호가 나오기 전까지 반복하다 보면, 새로운 문장을 생성할 수 있게 됨 

문장 생성 구현 

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 

calss RnnlmGen(Rnnlm):
#문장 생성 수행 메서드 
def generate(self, start_id, skip_ids=None, sample_size=100): #start_id: 최초로 주는 단어 ID / sample_size: 샘플링하는 단어 수 / skip_ids: 단어 ID의 리스트 
  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

 

문장 생성 코드 

import sys
sys.path.append('..')
from rnnlm_gen import RnnlmGen
fromd 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()

#시작 문자와 건너뜀 문자 설정
satart_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)

 

seq2seq

  • 시계열 데이터를 다른 시계열 데이터로 변환하는 모델, 2개의 RNN을 사용함 

seq2seq의 원리 

  • 입력 데이터를 인코딩하는 Encoder와 데이터를 디코딩하는 Decoder가 존재함 
    • 인코더와 디코더는 각각 시계열 데이터를 변환하는 역할을 맡음 
  • 인코더와 디코더가 협력하여 시계열 데이터를 다른 시계열 데이터로 변환하는 것 

ex) '나는 고양이로소이다"를 " I am a cat"으로 번역하기 

  • '나는 고양이로소이다'라는 출발어 문장을 인코딩 진행 
  • 인코딩한 정보를 디코더에 전달, 디코더가 도착어 문장을 생성
    • 인코더가 인코딩한 정보에는 번역에 필요한 정보가 조밀하게 응축된 상태
    • 디코더는 조밀하게 응축된 이 정보를 바탕으로 도착어 문장을 생성함 

 

인코더와 디코더로 RNN사용하기 

  • 인코더가 RNN을 이용해 시계열 데이터를 h라는 은닉 상태 벡터로 변환함
    • 인코더가 출력하는 벡터h는 LSTM 계층의 마지막 은닉 상태가 됨 
    • LSTM으 은닉 상태 h는 고정 길이 벡터,
      • 인코딩 한다 = 임의 길이의 문장을 고정 길이 벡터로 변환
  • 디코더에서는 앞에서 배운 신경망과 완전히 같은 구성이지만, LSTM 계층이 벡터 h를 입력받는다는 점에 차이가 존재함
  • seq2seq는 LSTM 두 개(Encoder의 LSTM과 Decoder의 LSTM)로 구성되고, LSTM 계층의 은닉 상태가 Encoder와 Decoder를 이어주는 가교 역할을 함
    • 순전파 시에는 Encoder에서 인코딩된 정보가 LSTM 계층의 은닉 상태를 통해 Decoder에 전해짐
    • seq2seq의 역전파 때는 가교를 통해 기울기가 Decoder로부터 Encoder로 전해짐 

 

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

  • word2vec이나 언어 모델 등에서 문장을 '단어' 단위로 분할했지만, 문장을 반드시 단어로 분할할 필요는 없음 

=> 문자 단위로 나누어서 덧셈 문제 풀이를 진행 

 

가변 길이 시계열 데이터 

  • 가변 길이 시계열 데이터를 미니배치로 학습하기 위한 가장 단순한 방법은 패딩을 사용하는 것
    • 패딩: 원래의 데이터에 의미 없는 데이터를 채워 모든 데이터의 길이를 균일하게 맞추는 기법
  • 패딩용 문자까지 seq2seq 모델이 처리하게 되면서 seq2seq에 패딩 전용 처리를 추가해야 함
    • 디코더에 입력된 데이터가 패딩이라면 손실의 결과에 반영하지 않도록 함
      • softmax with Loss 계층에 '마스크' 기능을 추가하여 해결 가능 
    • 인코더에 입력된 데이터가 패딩이라면 LSTM 계층이 이전 시각의 입력을 그대로 출력하게 함 
      • LSTM 계층은 처음부터 패딩이 존재하지 않았던 것처럼 인코딩 가능 

 

seq2seq 구현

Encoder 클래스 

  • RNN-LSTM을 이용하여 구현
    • 임베딩 계층에서는 문자(문자ID)를 문자 벡터로 변환 후 LSTM 계층으로 전달
    • LSTM 계층에서는 오른쪽(시간 방향)으로는 은닉 상태와 셀을 출력하고, 위쪽으로는 은닉 상태만 출력 
    • 인코더에서 마지막 문자 처리 후 LSTM 계층의 은닉 상태 h를 출력
    • 은닉 상태h를 디코더로 전달 

초기화 메서드 

class Encoder:
#vocab_size: 어휘 수 - 문자의 종류
#wordvec_size: 문자 벡터의 차원 수
#hidden_size: LSTM 계층의 은닉 상태 벡터 차원 수  
    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):
    #TimeEmbeddig 계층과 Time LSTM 계층의 순전파 메서드 호출 
        xs = self.embed.forward(xs)
        hs = self.lstm.forward(xs)
        self.hs = hs 
        return hs[:, -1, :] #마지막 은닉 상태 추출, 엔코더의 순전파 메서드 출력으로 반환 

    def backward(self, dh): #LSTM 계층의 마지막 은닉 상태에 대한 기울기를 dh로 입력 받음 -> decoder가 전달 
        dhs = np.zeros_like(self.hs)
        dhs[:, -1, :] = dh 
		
        #TimeLSTM 계층과 TimeEmbedding 계층의 역전파 메서드 호출 
        dout = self.lstm.backward(dhs)
        dout = self.embed.backward(dout)
        return dout

 

Decoder 클래스

  • 디코더 클래스는 인코더 클래스가 출력한 h를 받아 목적으로 하는 다른 문자열을 출력함 
    • 인코더와 마찬가지로 RNN으로 구현 가능 - LSTM 계층 사용하면 됨 
  • argmax 노드: 최댓값을 가진 원소의 인덱스를 선택하는 노드 
  • 디코더는 학습 시와 생성시에 softmax 계층을 다르게 취급함 << 주의! 
#softmax with loss계층은 이후에 구현할 seq2seq 클래스에서 처리할 예정 
#사유: 디코더는 학습 시와 생성 시에 소프트맥스 계층을 다르게 취급함 
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
  • backforward 메서드는 위쪽의 softmax with Loss 계층으로부터 기울기 dscore를 받아 Time Affine 계층, Time LSTM 계층, Time Embedding 계층 순서로 전파 시킴
    • Time LSTM 계층의 시간 방향으로의 기울기는 TimeLSTM 클래스의 인스턴스 변수 dh에 저장되어 있음
      • 이 시간 방향의 기울기 dh를 꺼내서 디코더 클래스의 백워드의 출력으로 반환함 
#문장 생성 담당 
#h: 인코더로부터 받은 은닉 상태
#start_id: 최초로 주어지는 문자 ID
#sample_size: 생성하는 문자 수
#문자를 1개씩 주고, Affine 계층이 출력하는 점수가 가장 큰 문자 ID를 선택하는 작업을 반복 
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

 

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의 학습은 기본적인 신경망의 학습과 같은 흐름으로 이루어짐
    • 미니 데이터에서 미니 배치 선택
    • 미니 배치로붙 기울기 계산
    • 기울기 사용하여 매개변수 갱신 
# 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()

# 하이퍼파라미터 설정
vocab_size = len(char_to_id)
wordvec_size = 16
hidden_size = 128
batch_size = 128
max_epoch = 25
max_grad = 5.0

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))

 

seq2seq 개선

  • seq2seq를 세분화하여 학습 '속도'를 개선하고자 함 

입력 데이터 반전

  • 말 그대로 입력 데이터의 순서를 반전 시키는 것을 의미함
    • 실제 많은 경우에 학습이 빨라져서 최종 정확도도 좋아짐! 
# 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()

# 하이퍼파라미터 설정
vocab_size = len(char_to_id)
wordvec_size = 16
hidden_size = 128
batch_size = 128
max_epoch = 25
max_grad = 5.0

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()
  • 기울기 전파가 원활해지기 떄문에 학습의 진행 속도가 빨라지고, 정확도가 향상되는 것으로 추정됨 

 

엿보기(Peeky)

  • Encoder는 입력문장을 고정 길이 벡터 h로 변환하는 역할을 맡음 
    • h안에는 Decoder에게 필요한 정보가 모두 담겨져 있음 → h가 Decoder에 있어서는 유일한 정보가 됨 
  • 현재의 seq2seq는 최초 시각의 LSTM 계층만이 벡터h를 이용하고 있음 
  • 중요한 정보가 담긴 Encoder의 출력 h를 Decoder의 다른 계층에도 전해줌 
    • LSTM 계층과 Affine 계층에 입력되는 벡터가 2개씩이 됨 → 실제로는 두 벡터가 연결된 것을 의미함
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) #시계열만큼 복제하여 hs에 저장 
        out = np.concatenate((hs, out), axis=2) #hs와 embedding 계층의 출력을 연결 

        out = self.lstm.forward(out) #lstm 계층에 입력 
        out = np.concatenate((hs, out), axis=2) #affine에도 hs와 lstm계층의 출력 연결한 값을 입력 

        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

 

PeekySeq2Seq 클래스 구현

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

 

Seq2seq를 이용하는 애플리케이션 

  • seq2seq를 이용하는애플리케이션
    • 기계번역: "한 언어의 문장"을 "다른 언어의 문장"으로 변환
    • 자동 요약: "긴 문장"을 "짧게 요약된 문장"으로 변환
    • 질의응답: "질문"을 "응답"으로 변환 
    • 메일 자동 응답: "받은 메일의 문장"을 "답변 글"로 변환 
  • seq2seq는 짝을 이루는 시계열 데이터를 다루는 문제에 이용 가능 

 

챗봇 

  • 대화는 상대의 말과 자신의 말로 구성되므로 상대의 말을 자신의 말로 변환하는 문제로 볼 수 있음 

알고리즘 학습 

  • 소스 코드 역시 문자로 쓰여진 시계열 데이터의 일환임 

이미지 캡셔닝 

  • '이미지'를 '문장'으로 변환하는 것 
    • Encoder가 LSTM에서 합성곱 신경망으로 변경됨  - Decoder는 변경 X
  •  CNN의 결과로 나온 특징 맵을 1차원으로 평탄화한 후, 완전연결인 Affine 계층에서 변환함 
  • 변환한 데이터를 Decoder에 전달