본문 바로가기

Data Science/밑바닥부터시작하는딥러닝(2)

[밑시딥] RNN을 사용한 문장 생성, 그리고 RNN을 이어 붙인 seq2seq(Encoder-Decoder)

반응형

🔊 해당 포스팅은 밑바닥부터 시작하는 딥러닝 2권의 교재 내용을 기반으로 자연어처리 딥러닝 신경망을 Tensorflow, Pytorch와 같은 딥러닝 프레임워크를 사용하지 않고 순수한 Numpy로 구현하면서 자연어 처리의 기초를 탄탄히 하고자 하는 목적 하에 게시되는 포스팅입니다. 내용은 주로 필자가 중요하다고 생각되는 내용 위주로 작성되었음을 알려드립니다.

 

밑바닥부터 시작하는 딥러닝(2)


저번 포스팅까지 해서 RNN에 게이트를 추가한 모델인 LSTM과 GRU에 대해 알아보았다. 또 이러한 RNN 계열 신경망을 활용해 다음에 나올 '단어'를 예측하는 언어 모델(LM, Language Model)의 성능을 개선하는 방법들에 대해서도 알아보았다. 

 

이번 포스팅에서는 다음에 나올 '단어'만을 예측하는 것이 아닌 '문장'을 예측하는 언어 모델을 RNN을 활용해 구현해보도록 하자. 그리고 문장을 좀 더 잘 예측하도록 하기 위해 RNN 계열 모델 1개만을 사용하는 것이 아닌 RNN 계열 모델 2개를 이어 붙이는 모델인 seq2seq(Encoder-Decoder) 모델에 대해서도 알아보자.

1. RNN 모델 1개를 활용'문장'을 생성하는 언어 모델 구현하기

먼저 기존에 배웠던 RNN 모델을 활용해 다음에 나올 '단어'를 생성하는 모델 구조부터 살펴보자. 아래 그림에서는 RNN 모델 중에 LSTM 모델을 예시로 들었다.

 

LSTM 모델로 다음에 나올 단어를 예측

 

위 모델은 단어 또는 문장이 들어갔을 때, 다음에 등장할 단어를 예측하는 RNN 계열 모델의 구조이다. 위 그림으로 예시를 들자면, "I say goodbye and I" 라는 전체 문장이 존재한다고 가정하자. 이 때, 마지막 단어 'I' 를 제외한 "I say goodbye and"  문장을 단어 단위로 모델에 입력되었을 때, 해당 입력 문장 다음에 나올 단어를 'I' 로 예측하고 있는 것이다. 그렇다면 만약에 위 상황에서 'I' 라는 단어를 입력시켰을 때, 다음에 나올 "say goodbye and I" 라는 문장을 생성해야 한다면 모델의 구조가 어떻게 바뀔까? 아래 그림을 보자.

 

LSTM으로 다음에 나올 문장을 생성

 

차이점은 RNN 모델이 순환할 때마다 나오는 은닉 상태 벡터값($h$)(위 그림에서 위쪽 방향으로 출력되는 '예측' 이리고 쓰여져 있는 값들!)를 다음 RNN 모델의 입력으로 넣어준다는 것이다.(참고로 학습 시에는 디코더에 대한 정답 데이터를 이미 알고 있는 상태이기 때문에 학습 시에는 올바른 학습을 위해 예측값(RNN의 은닉 상태 벡터값)이 아닌 실제값(정답)을 넣어준다. 이를 티처 포싱(Teacher Forcing) 방법이라고도 함.)

 

물론 동시에 LSTM 계층 간의 기억 셀($c$)과 은닉 상태 벡터($h$)도 입력으로 넣어준다(그림의 가로방향 회색 화살표) 따라서, 만약 우리가 이미 학습된 어떤 문장 생성 모델을 가지고 테스트를 위해 첫 입력을 "I" 로 넣어준다면, 이미 학습된 문장 생성 모델은 "say goodbye and I" 라는 다음에 나올 문장을 자동으로 생성해줄 것이다.

 

그런데 여기서 언어 모델 내부에서 다음에 나올 단어를 예측할 때, 즉, 하나의 LSTM 계층에서 예측값을 내뱉을 때, 크게 2가지 방법이 존재한다. 첫 번째는 결정적인(Deterministic) 방법이다. 즉, LSTM 계층에서 내뱉은 각 단어별 Softmax 값 중 가장 큰 값만을 채택하는 방식이다. 따라서 매번 가장 큰 Softmax 값인 단어만 선택하게 되어 다음에 나올 단어가 매번 동일하게 나온다. 이는 곧 Softmax 값이 낮은 단어는 아예 다음에 나올 단어로 고려조차 하지 않는다는 것이다.

 

두 번째는 확률적인(Stochastic) 방법이다. LSTM 계층에서 내뱉은 각 단어별 Softmax 값을 확률 분포로 하여(Softmax 값은 0.0 ~ 1.0 사이의 실수값이기 때문) 샘플링하는 방법이다. 즉, 결정적인 방법과 달리, 확률 값에 기반하긴 하지만 다음에 나올 단어를 '샘플링' 하기 때문에 Softmax 값이 낮은 단어도 다음에 나올 단어의 대상으로 고려대상이 되긴 한다는 것이다. 따라서 경우에 따라 다음에 나올 단어가 달라질 가능성이 존재한다. 문장 생성 모델에서는 확률적인 방법을 선택하기로 한다.

 

그런데 모델이 문장 생성을 할 때, 문장을 무한대로 계속 생성할 순 없을 것이다. 언젠가는 문장 생성을 종결해야 하는데, 이를 모델이 파악하도록 하기 위해서 <eos> (end of sentence 로 문장의 '끝'을 의미) 와 같은 문자열이 나타나면 문장 생성을 종결하도록 학습 데이터에서 추가로 적용해주어야 한다. 그래야 학습된 문장 생성 모델이 테스트 데이터에 대해서 문장 생성을 할 때, 각 LSTM 계층별로 예측값을 출력하다가 <eos> 라는 단어의 Softmax 값이 가장 높게 나온다면 알아서 모델이 문장 생성을 종결할 것이기 때문이다.

 

이제 그동안 만들어왔던 RNN 소스코드를 활용해서 문장을 생성하는 RNN 언어모델을 넘파이로 구현해보자. 참고로 PTB 벤치마크 데이터셋으로 저자가 이미 학습시킨 모델의 파라미터를 활용했다. 

 

import numpy as np
from common.functions import softmax
from rnnlm import Rnnlm
from better_rnnlm import BetterRnnlm


# time step을 1개로 하는(바로 다음에 나올 단어를 예측하는) 문장 생성 클래스
class RnnlmGen(Rnnlm):
    def generate(self, start_id, skip_ids=None, sample_size=100):
        """ 문장 생성하는 메소드
        
        Args:
            start_id: 가장 최초로 입력되는 단어 ID
            skip_ids: 해당 리스트에 속하는 ID값의 단어는 Softmax 결과값에서 샘플링할 때 포함시키지 않을 단어들
            sample_size: 해당 time_step으로 예측할 단어 개수
        
        """
        word_ids = [start_id]
        
        x = start_id
        while len(word_ids) < sample_size:
            x = np.array(x).reshape(1, 1)  # 미니 배치 데이터로 처리하기 위해 2d-array로 변환
            score = self.predict(x)
            prob = softmax(score.flatten())
            
            pred_id = np.random.choice(len(prob), size=1, p=prob)
            if (skip_ids is None) or (pred_id not in skip_ids):
                x = pred_id
                word_ids.append(int(x))
        return word_ids
from dataset import ptb
import random

corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)
corpus_size = len(corpus)

# model
model = RnnlmGen()
model.load_params('./Rnnlm.pkl')


# 최초 입력 단어, skip할 단어 정의
start_word = "you"
#start_word = random.choice(list(word_to_id.keys()))
start_id = word_to_id[start_word]
skip_words = ['N', '<unk>', '$']
skip_ids = [word_to_id[s] for s in skip_words]

# 문장 생성!
word_ids = model.generate(start_id, skip_ids, sample_size=100)
# word_id -> word로 변환
result = ' '.join([id_to_word[i] for i in word_ids])
result = result.replace('<eos>', '. \n')
print('# Start word:', start_word)
print(result)

2. RNN 2개를 연결시키자, seq2seq!

seq2seq 모델은 입력으로 시퀀스(시계열) 데이터를 넣어주면 또 다른 시퀀스 데이터를 출력으로 내뱉는 데 자주 사용되는 모델이다. 예를 들어, 구글 번역기, 파파고와 같은 기계 번역, 음성 인식, 챗봇이나 컴파일러(소스코드를 기계여로 변환) 작업같은 것들이다.

 

[1. 목차]에서 살펴본 문장을 생성하는 언어 모델은 RNN 1개만을 이용한 모델이었다. seq2seq 모델은 RNN 2개를 이어붙여서 문장을 생성할 수 있는 언어모델을 구현할 수 있다. seq2seq 모델은 Encoder-Decoder 이라고도 불리는데, 여기서 Encoder, Decoder가 이어붙힌 RNN 1개씩을 의미한다. 우선 seq2seq 모델의 대략적인 구조를 살펴보자.

 

seq2seq(Encoder-Decoder) 구조

 

seq2seq 모델은 [Encoder ➡️ Decoder] 방향으로 진행된다. 그리고 Encoder에는 '출발어'라고 불리는 시계열 데이터가 입력으로 들어간다. 위 그림에서는 "나는 고양이로소이다" 가 출발어가 되겠다. 이 출발어가 들어간 Encoder는 정말 이름 그대로 입력 데이터를 '인코딩' 하는 기능을 한다. 여기서 인코딩이란, 부호화라고도 하며, 입력으로 들어온 정보를 어떤 규칙에 따라 변환하는 것을 의미한다. 이런 역할을 하는 Encoder가 내부적인 어떤 계산 과정을 거치고(이 내부적인 과정은 밑에서 알아본다) 난 후, $h$라는 응집된 정보를 Decoder에게 전달한다. 이 때, $h$는 아마 입력 시계열 데이터에 대한 정보를 담고 있을 것이다. 

 

다음으로 Decoder는 복호화하는 기능을 수행한다. 즉, Encoder에서 인코딩한 정보를 원래의 정보로 되돌리는 역할을 수행한다. 이러한 역할을 하는 Decoder는 Encoder가 제공해준 $h$를 기반으로 출력값을 내뱉게 된다.

 

자, seq2seq 모델이 큰 그림 상으로 Encoder-Decoder 구조로 구성되어 있는 것도 알았고 각 요소의 역할도 알아보았다. 이제 그러면 Encoder-Decoder가 구체적으로 어떤 모델로 구성되어 있는지 알아보자.

2-1. RNN 신경망으로 구성되는 Encoder

결론부터 말하면, Encoder-Decoder 모델은 모두 RNN 계열 신경망 모델로 구성된다. 우리는 이전 시간까지 RNN 동작 과정을 충분히 배워왔으므로 배운 내용을 그대로 적용하기만 하면 된다. 먼저 출발어를 인코딩(부호화)해주는 Encoder 모델의 구조에 대해 살펴보자. 하단의 그림에서는 LSTM 모델을 사용했다고 가정한다.

 

Encoder 모델의 구조

 

위 그림을 보니 평소에 봐왔던 순환하는 LSTM 모델을 옆으로 펼쳐놓은 그림과 똑같다. 위 그림에서는 단어 단위로 분할하여 모델의 입력으로 넣어주었다고 가정한 상황이다. 일반적인 LSTM 모델처럼 순환하면서 결국 마지막에 $h$라는 마지막(으로 순환하는) LSTM 계층의 은닉상태 벡터를 출력한다. 그리고 이 $h$에는 입력 문장에 대한 필요한 정보가 인코딩 되어 있다. 그리고 Encoder는 (그림 자료 상)위쪽으로 $h$를 출력하지 않도록 한다.

 

여기서 중요한 점은 하나의 Encoder 모델이 내뱉는 $h$ 벡터의 길이는 어떤 길이의 입력이 Encoder 모델에 들어왔다고 해도 반드시 $h$ 벡터의 길이는 동일해야 한다. 아래처럼 말이다.

 

입력 문장의 길이가 달라도 Encoder 모델이 내뱉는 $h$ 벡터의 길이는 항상 고정이어야 한다!

2-2. Encoder의 정보를 전달 받는 RNN 신경망, Decoder

다음은 Decoder 모델이다. Decoder 모델은 Encoder의 최종 은닉 상태 벡터인 $h$를 전달받는 RNN 신경망 모델이다. 즉, Encoder가 전달해준 $h$를 RNN 입력으로 넣어주어 '도착어'를 생성하게 된다. 기존에 배웠던 RNN 신경망과 차이점이라고 한다면, 기존 RNN은 최초의 데이터를 입력할 때, 이전 계층으로부터 전달받는 은닉 상태 벡터($h$)가 없었다. 왜냐하면 RNN이 1개뿐이였기 때문이다. 하지만 Decoder는  이전 RNN 모델인 Encoder 모델에서 출력한 $h$를 전달받은 상태에서 최초의 입력이 시작되기 때문에 이러한 면에서 기존 RNN과 차이점이 있다고 할 수 있다. 그렇다면 Decoder 모델의 구조도 살펴보자.

 

Decoder 모델의 구조

 

Decoder도 매우 익숙한 그림이다. 바로 [1. 목차]에서 RNN 모델이 1개일 때, 문장을 생성하는 경우의 구조와 동일하다. 단, Decoder에서는 Encoder에서 전달해준 $h$를 최초 입력에 넣어준다는 점만 다르다! 

 

참고로 위 그림을 보면 알겠지만 Decoder 모델에서 최초로 입력되는 데이터로는 Encoder에서 전달해준 $h$ 값과 <eos> 라는 기호가 있다. 이 때, <eos> 는 문장의 끝을 알리는 기호로, 즉, 노란색 밑줄<eos>는 결국 출발어의 마지막 단어 이자 도착어의 최초 단어를 의미한다. 그래서 이를 같이 입력으로 넣어줌으로써 Decoder의 도착어 생성이 시작된다. 또 도착어의 종결 시(그림의 맨 오른쪽 상단의 <eos>)에도 이 <eos> 기호를 기준으로 사용한다. 

 

그렇다면 이제 Encoder - Decoder 구조를 합친 전체 구조 그림을 살펴보자.

 

Encoder - Decoder의 전체 구조

 

seq2seq 모델은 RNN 동작과정만 잘 알고 있다면 위 그림을 이해하는 데 큰 어려움이 없을 것이다. seq2seq 모델도 역전파를 수행할 때, RNN 처럼 BPTT(Backpropagation Through Time)를 사용한다. 그리고 순전파를 수행할 때 Encoder가 Decoder에게 전달해주는 $h$ 값 덕분에 역전파를 수행할 때도, 이 길을 이용하여 Encoder까지 역전파를 수행할 수 있게 된다.

 

해당 포스팅에서 Encoder-Decoder를 넘파이로 구현한 소스코드는 자세히 살펴보진 않는다. 궁금하신 분들은 필자가 직접 따라치면서 정리한 코드를 참조하도록하자. 참고로 Seq2Seq를 넘파이로 구현하기 위해서 그동안 RNN 계열 신경망들을 배워오면서 구현했던 TimeLSTM, TimeEmbedding 등과 같은 이미 구현해놓은 클래스를 활용하기 때문에 순차적으로 포스팅을 읽어오면서 구현했던 분들에게는 쉽게 이해가 될 것이다!

3. seq2seq 모델 개선하기

이번 목차에서는 지금까지 배운 seq2seq 모델의 학습 속도를 개선하면서 정확도도 개선할 수 있는 방법 2가지에 대해 알아보자. 한 가지는 Encoder에 적용되는 방법, 나머지 한 가지는 Decoder에 적용되는 방법이다. 그 중 첫 번째는 Encoder에 입력으로 들어가는 시퀀스 데이터에 무엇인가를 적용하는 기법이다.

3-1. Encoder에 들어가는 입력 시퀀스 데이터(출발어)를 뒤집자, Reverse!

첫 번째 방법으로는 Encoder 모델로 들어가는 입력 시퀀스 데이터 즉, 출발어의 순서를 뒤집은 후 Encoder 모델에 넣어주는 것이다. 결론부터 말하면 이 방법을 사용하면 학습 속도가 빨라지면서 결과적으로 seq2seq 모델의 최종 정확도도 향상시킨다고 알려져 있다. 입력 시퀀스 데이터를 반전한다는 의미는 아래와 같다.

 

기존 입력 시퀀스와 뒤집은 입력 시퀀스

 

이렇게 입력 시퀀스 데이터를 뒤집어서 Encoder로 넣어주게 되면 대부분의 문제에 모델의 학습 속도 향상과 정확도 향상이라는 긍정적인 결과를 얻을 수 있다고 한다. 그런데 대체 어떻게 입력 시퀀스를 '뒤집는' 것 자체가 모델의 학습 속도와 정확도 향상을 이루어낼 수 있을까?

 

책의 저자도 이에 대한 구체적인 이론적인 근거는 제시하지 않지만 직관적인 근거를 언급하며 설명한다. 그것은 바로 입력 시퀀스를 뒤집어서 넣게 되면 역전파 시, 기울기의 전파가 더 원활하게 이루어지기 때문이라고 한다. 우선은 기존처럼 입력 시퀀스를 뒤집지 않고 역전파를 수행할 때는 아래 그림처럼 될 것이다.

 

기존처럼 입력 시퀀스를 그대로 넣어주었을 경우

 

위 그림에서 주황색 글씨로 진하게 표시되어 있는 입력 시퀀스에서의 '나' 라는 단어에 대한 순전파, 역전파를 수행한다고 해보자. 그러면 처음에 순전파를 수행해서 도출해낸 예측값과 정답인 'I' 단어와 비교를 하고 난 후 역전파를 수행할 것이다. 이 때, 'I' 라는 단어에서 '나' 라는 목표 단어까지 역전파를 수행하려면 중간에 '는', '고양이', '로소', '이다' 총 4가지 단어에 대한 역전파 단계를 거쳐야만 한다. 이 말은 곧 목표 단어인 '나'에 전달해야 할 기울기 값이 중간 역전파 단계(다른 4가지 단어들)를 거치면서 약해지거나 소실될 수 있다는 것이다.

 

그렇다면 입력 시퀀스를 뒤집어서 Encoder로 넣어주었을 때는 어떤 식으로 순전파, 역전파가 전개될까?

 

입력 시퀀스를 뒤집은 후 넣어주었을 경우

 

위 그림에서도 똑같이 '나' 라는 단어에 대한 역전파를 수행한다고 가정해보자. 이번에는 정답인 'I' 단어와 비교를 한 후 '나' 단어에 대한 역전파를 수행해줄 때, 중간 단어들('는', '고양이', '로소', '이다')을 거치지 않고 바로 기울기를 전달해줄 수 있다.

 

이렇게 입력 시퀀스의 첫 부분에서는 '뒤집는' 과정 덕분에 대응하는 단어('나' - 'I')와 거리가 가까워지며 그에 따라 역전파 시 기울기의 정보를 잃지 않고 최대한 보존하면서 전달해주게 된다. 기울기를 최대한 잘 전달해주니 학습 효율이 좋아지고 동시에 정확도까지 향상되는 것이다. 다만, 위와 같이 입력 시퀀스를 뒤집는다고 해서 시퀀스 내의 모든 단어들이 변환 후 대응되는 단어들과의 거리가 모두 줄어드는 것은 아니다. 즉, 평균 거리값은 입력 시퀀스를 뒤집기 전/후가 동일하다는 것은 알고있자.

3-2. Decoder의 모든 RNN 신경망이 Encoder 정보를 엿보도록 하자, Peeky!

두 번째 방법은 Decoder에 적용되는 기법이다. [2-2. 목차]에서 배웠던 것처럼 Decoder는 Encoder가 최종 출력시키는 은닉 상태 벡터($h$)를 최초로 입력받아서 순환하는 RNN 신경망이라고 했다. 다시 Decoder의 구조를 살펴보자.

 

기존 Decoder의 구조

 

노란색으로 칠해진 것 중 $h$라는 것은 Decoder의 최초 LSTM 계층으로 입력되어 들어간다. 그런데 잘 살펴보면 최초 LSTM 계층을 제외한 (순환하는) 나머지 LSTM 계층들은 Encoder가 전달하는 은닉 상태 벡터 $h$를 직접적으로는 전달받고 있지 않다. 이러한 점에 착안하여 Decoder의 다른 LSTM 계층도 Encoder의 $h$ 정보를 직접적으로 전달받을 수 있도록 개선한다. 다른 LSTM 계층들도 $h$를 참조 혹은 엿본다(peek)고 하여 Peeky 라고 이름이 붙혀졌다.

 

그러면 이를 적용한 Peeky Decoder의 구조를 살펴보자.

 

Peeky Decoder의 구조

 

위에서 Encoder가 전달하는 정보인 $h$를 다른 LSTM 계층(초록색 네모칸)에 전달한다고 했다. 그리고 추가적으로 $h$를 Affine(행렬 곱) 계층(파란색 네모칸)에다가도 직접적으로 전달해준다. 이렇게 함으로써 Encoder 정보를 좀 더 다양한 계층들이 직접적으로 활용할 수 있게 되기 때문에 모델의 정확도가 향상될 것이라고 직관적으로 예상할 수 있다.

 

그런데 위 Peeky Decoder를 구현할 때, 한 가지 추가적으로 봐두어야 할 점이 있다. 위 Peeky Decoder 그림을 잘보면 여러 계층들이 추가적으로 직접 $h$를 입력 데이터로 받는 것을 볼 수 있다. LSTM 계층과 Affine 계층 모두 그렇다. 따라서 이를 넘파이로 구현하기 위해서 실질적으로는 입력받는 2개의 데이터를 연결(concatenate) 해주어 하나의 입력으로 이어붙인 후 각각 LSTM 계층, Affine 계층에 넣어주어야 한다. 이 말을 이해하기 위해서 Peeky Decoder의 구조 일부분을 확대해서 살펴보자.

 

Peeky Decoder의 부분을 확대해보자

 

위 그림을 보면 실질적으로 Peeky Decoder에서 입력 데이터가 어떻게 변환되어서 들어가는지 이해가 될 것이다. Peeky Decoder를 넘파이로 구현한 소스코드, 그리고 이를 기반으로 구현한 Peeky Seq2Seq 구현 소스코드는 여기를 참조하도록 하자. 각 계층마다 입력 데이터를 받는 형상이 달라지기 때문에 기존 파라미터보다는 더 큰 형상으로 바꾸어준다는 부분(왜냐하면 입력 2개가 concatenate 함으로써 늘어나니깐!) 을 제외하고는 기존의 Decoder 클래스와 거의 동일하다.

 

마지막으로 위에서 알아본 2가지 개선 기법을 적용한 전/후의 모델 성능 그래프를 보고 마무리하자. 해당 그래프는 책에서 제공하는 자료를 사용했다.

 

기존 seq2seq VS 입력 데이터를 반전시킨 seq2seq VS 입력 데이터 반전과 Peeky Decoder 사용한 seq2seq

 

그래프를 보면 2가지 기법을 같이 적용한 모델이 압도적으로 성능 향상이 발생한 것을 볼 수 있다. 하지만 이는 책에서 소개하는 덧셈 연산 데이터셋(이 데이터셋에 대한 자세한 설명은 책을 참고하세요!)에서 유난히 큰 성능 향상을 이룬 것이다. 


입력 데이터를 반전시키는 기법은 괜찮지만 Peeky Decoder를 사용하면서 추가적으로 계산해야 할 파라미터 양이 늘었다. 왜냐하면 Decoder의 다른 LSTM, Affine 계층도 Encoder가 전해주는 정보 $h$를 사용함으로써 이에 해당하는 파라미터 값들 형상이 기존보다 늘어났기 때문이다. 따라서 파라미터가 늘어남에 따라 계산량도 늘어날 수 밖에 없다는 단점을 감안해야 한다. 또한 기존 Seq2Seq 모델 뿐만 아니라 지금 목차에서 알아본 개선된 Seq2Seq 모델의 성능은 하이퍼파라미터에 매우 민감하다는 단점이 존재한다. 그래서 실제 다양한 도메인의 데이터 성격에 따라 성능이 왔다갔다하는 불안정한 모습을 자주 보인다.

 

그래서 이러한 2가지 '작은' 개선 기법을 Seq2Seq 모델에 도입하는 것 이외에 '크게' Seq2Seq 모델을 개선하는 방법이 있다. 그것이 바로 대망의 어텐션(Attention)이라는 기술이다! 이는 다음인 마지막 챕터에서 알아본다. 마지막 고지를 향해 계속 달려보자!

 

 

 

반응형