seq2seq 모델에 대한 이해

박재한·2022년 8월 12일
1

Deep Learning

목록 보기
21/22
post-custom-banner

참고문헌은 다음과 같다.
참고1
참고2
참고3

이번 글은 RNN(Recurrent Neural Network)에 대해 이미 알고 있다는 가정하에 썼습니다. 따라서 RNN의 개념을 잘 모르면 이것부터 먼저 이해하고 읽기 바란다.
NLP(Natural Language Processing) 분야에서 seq2seq는 기계 번역, 문장 생성, 질의응답, 메일 자동 응답 등에 활용되는 모델이다. 우선 기계 번역에 대해 알아보겠다. 예전의 기계 번역은 주로 규칙 기반(rule based)이었습다. 문맥을 전체적으로 고려하지 않고 단어와 단어를 1:1로 번역하기 때문에 결과가 좋지 않았었다. 극단적인 예이긴 하지만 아래와 같다.

성능이 좋지 않은 규칙 기반 번역:  나는 그곳에 갔다 -> I there went  ??

'나는'을 I, '그곳에'를 there, '갔다'를 went로 단어 간 1:1 번역이기 때문에 결과가 좋지않다.

하지만 시간이 지나 통계 기반(statistical based) 번역이 새로 등장했다. 통계 기반 번역 모델을 구축하기 위해서는 동일한 텍스트를 2가지 이상의 언어로 표현하고 있는 데이터가 필요합니다. 이를 병렬 말뭉치(병렬 코퍼스)라고 한다.

'나는 그곳에 갔다'라는 한국어 문장과 'I went there'이라는 영어 문장을 이미 가지고 있는 상태에서 언어 모델의 입력(input)으로 한국어 문장을 넣어주고 타깃(target)으로 영어 문장을 넣어준다. 이런 식으로 동일한 의미를 지닌 2가지 이상의 언어 텍스트를 대량으로 활용하여 언어 모델을 학습시킨다. 이제 훈련이 완료된 언어 모델은 '나는 그곳에 갔다'를 보면 'I went there'이 번역된 문장일 확률이 높다고 판단하고 해당 번역문을 출력해준다. 2000년 초 구글 번역기도 통계 기반이었다고 한다. 하지만 통계 기반 번역도 여러모로 손이 많이가 크게 성공적이진 못했었다.

하지만 NLP(Natural Lanaguage Processing)가 등장하며 기계 번역은 성능이 크게 향상되었다. 본격적으로 기계 번역으로 많이 활용되는 seq2seq에 대해 알아보겠다.

1. seq2seq 모델이란?

sequence-to-sequence(seq2seq) 모델은 한 문장(시퀀스)을 다른 문장(시퀀스)으로 변환하는 모델을 의미한다. 아래는 seq2seq 모델을 통해 한 문장을 다른 문장으로 변환하는 예시이다.

"I am a student" -> [Seq2Seq model] -> "je suis étudiant"

번역기와 같은 방식이라고 보면 된다. 영어 문장을 입력(input)으로 seq2seq 모델에 넣으면 불어 문장을 출력(output)한다. 이제 seq2seq 내부 구조에 대해 알아보겠다.

2. seq2seq 내부 구조

seq2seq를 Encoder-Decoder 모델이라고도 한다. 아래와 같이 seq2seq 모델은 인코더(Encoder)와 디코더(Decoder)로 구성되어 있기 때문이다. 문자 그대로 인코더는 입력 데이터를 인코딩(부호화)하고, 디코더는 인코딩 된 데이터를 디코딩(복호화)한다. 즉, 인코더는 입력을 처리하고 디코더는 결과를 생성한다.
Imgur
인코더와 디코더는 각각 어떤 역할을 할까? 인코더는 'I am a student'라는 입력 문장을 받아 Context 벡터를 만든다. Context 벡터는 'I am a student'에 대한 정보를 압축하고 있으며 컴퓨터가 이해할 수 있는 숫자로 이루어진 벡터이다. Context 벡터는 다시 디코더로 전달되며 디코더는 이를 활용하여 최종적으로 'je suis étudiant'라는 불어 문장을 생성한다. 즉, 인코더는 문장을 가지고 Context 벡터를 만들어주는데, 이 Context 벡터에는 문장에 대한 정보가 응축되어 있다. 반면 디코더는 정보가 응축되어 있는 Context 벡터로부터 다른 문장을 생성해준다. 참고로, 간단한 seq2seq 모델에서 Context 벡터는 인코더의 마지막 스텝이 출력한 은닉(hidden) 상태와 같다.(RNN 모델에서의 hidden state와 같다.)
인코더와 디코더를 한층 더 들여다 보면 다음과 같다.
Imgur
인코더나 디코더는 RNN으로 구성되어 있다. 그중 LSTM이나 GRU가 사용되는데 위 예시에서는 LSTM을 사용했다. 인코더의 LSTM은 단어 순으로 입력을 받다. 아까 언급했던 것처럼 인코더 LSTM의 마지막 은닉 상태가 바로 Context 벡터이다.

인코더의 LSTM 계층은 오른쪽(시간 방향)으로도 은닉 상태를 출력하고 위쪽으로도 은닉 상태를 출력한다. 이 구성에서 더 위에는 다른 계층이 없으니 LSTM 계층의 위쪽 출력은 폐기된다. 위 그림과 같이 인코더에서는 마지막 문자를 처리한 후 LSTM 계층의 은닉 상태 층인 Context 벡터를 디코더로 전달한다.

디코더는 입력 문장을 통해 출력 문장을 예측하는 언어 모델 형식이다. 위 그림에서 <sos>는 문장의 시작(start of string)을 뜻하고 <eos>는 문장의 끝(end of string)을 뜻한다. 인코더로부터 전달받은 Context 벡터와 <sos>가 입력되면 그다음에 등장할 확률이 가장 높은 단어('je')를 예측한다. 다음 스텝에서는 이전 스텝의 예측 값인 'je'가 입력되고 'je' 다음에 등장할 확률이 가장 높은 단어('suis')를 예측한다. 이런 식으로 문장 내 모든 단어에 대해 반복한다. 하지만 이는 모델의 학습 후 Test 단계에서의 디코더 작동 원리이다.

Training 단계에서는 교사 강요(teacher forcing) 방식으로 디코더 모델을 훈련시킨다.

교사 강요란?
보통 RNN은 (n-1) 스텝에서의 출력 값을 n 스텝의 입력값으로 사용한다. 즉, (n-1) 스텝에서 RNN 모델이 예측한 값을 n 스텝의 입력값으로 사용한다는 것이다. 하지만 교사 강요는 이와 다르다. 교사 강요는 (n-1) 스텝의 예측 값을 n 스텝의 입력값으로 사용하는 것이 아니라 (n-1) 스텝의 실제값을 n 스텝의 입력값으로 넣어주는 방식이다. 왜 이렇게 할까?

(n-1) 스텝의 예측값이 실제값과 다를 수 있기 때문이다. 예측은 예측일 뿐 실제와 다를 수 있다. 따라서 정확한 데이터로 훈련하기 위해 예측값을 다음 스텝으로 넘기는 것이 아니라 실제값을 매번 입력값으로 사용하는 것이다. 이런 방식을 교사 강요라고 한다.

다시 말하면 디코더의 훈련 단계에서는 교사 강요 방식으로 훈련하지만 테스트 단계에서는 일반적인 RNN 방식으로 예측한다. 즉, 테스트 단계에서는 Context 벡터를 입력값으로 받아 이미 훈련된 디코더로 다음 단어를 예측하고, 그 단어를 다시 다음 스텝의 입력값으로 넣어준다. 이렇게 반복하여 최종 예측 문장을 생성하는 것이다.

헷갈릴까봐 다시 설명하자면 디코더의 훈련 단계에서는 필요한 데이터가 Context 벡터와 <sos>, je, suis, étudiant이다. 하지만 테스트 단계에서는 Context 벡터와 <sos>만 필요하다. 훈련 단계에서는 교사 강요를 하기 위해 <sos>뿐만 아니라 je, suis, étudiant 모두가 필요한 것이다. 하지만 테스트 단계에서는 Context 벡터와 <sos>만으로 첫 단어를 예측하고, 그 단어를 다음 스텝의 입력으로 넣는다.

이제 단어 입력 부분을 더 세분화해서 살펴보겠다.
Imgur
딥러닝 모델은 문자보다 숫자에 대한 성능이 더 좋다. 따라서 모든 문자는 숫자화해야 하며, 이를 워드 임베딩(Word Embeddings)이라고 한다. I, am, a, student라는 문자도 모두 숫자로 즉, 벡터로 표현해야 한다. 아래는 I, am, a, student에 대해 워드 임베딩을 한 결과인 벡터를 나타낸다. 아래는 각 단어에 대해 4차원으로 표현했지만 실제는 몇백 차원으로도 표현한다고 한다.
Imgur
잠시 RNN에 대해 복습해보겠다.
Imgur
RNN은 각각의 스텝마다 두 개의 입력값을 받는다. 하나는 (n-1) 스텝에서의 출력 값이고 다른 하나는 t스텝에서의 입력값이다. 따라서 첫 번째 스텝에서의 출력값은 두번째 스텝에 입력이 되고, 두번째 스텝에서의 출력값은 세번째 스텝에 입력되고.. (n-1)번째 스텝에서의 출력값은 n번째 스텝에서의 입력이 된다. 따라서 n번째 스텝에서의 최종 출력값에는 첫번째부터 (n-1)번재 스텝에 대한 모든 정보가 녹아 있다.

이와 마찬가지로 테스트 단계에서 디코더는 인코더의 마지막 RNN 은닉층인 Context 벡터와 <sos>를 입력값으로 받는다. 디코더의 첫번째 RNN 셀은 Context 벡터와 <sos>를 통해 첫 단어를 예측한다. 이 단어는 두 번째 스텝의 RNN 셀의 입력값이 된다. 두 번째 스텝의 RNN 셀은 첫 번째 스텝의 RNN 셀이 예측한 단어와 두 번째 스텝에서의 입력값을 받아 두 번째 단어를 예측한다. 이런 식으로 최종 예측 값이 <eos> 일 때까지 반복한다.

이제 디코더의 출력층을 더 자세히 알아보겠다.
Imgur
디코더 RNN 셀의 출력으로 다양한 단어에 대한 벡터 값이 나올 것이다. 그중 확률이 가장 높은 단어를 선택하기 위해 softmax를 취해준다. 이를 통해 최종 예측 단어를 생성한다.

이제 마무리로 seq2seq에 대해 정리해보겠다. seq2seq는 인코더와 디코더로 구성되어 있으며, 인코더는 입력 문장의 정보를 압축하는 기능을 한다. 압축된 정보는 Context 벡터라는 형식으로 디코더에 전달된다. 디코더는 훈련 단계에서는 교사 방식(teaching force)으로 훈련되며, 테스트 단계에서는 인코더가 전달해준 Context 벡터와 를 입력값으로 하여 단어를 예측하는 것을 반복하며 문장을 생성한다.

이상으로 seq2seq에 대해 알아보았다.

3. Example : 문자 레벨 기계 번역기 구현

실제 성능이 좋은 기계 번역기를 구현하려면 정말 방대한 데이터가 필요하므로 여기서는 방금 배운 seq2seq를 실습해보는 수준에서 아주 간단한 기계 번역기를 구축해보겠다. 기계 번역기를 훈련시키기 위해서는 훈련 데이터로 병렬 코퍼스(parallel corpus)가 필요합니다. 병렬 코퍼스란, 두 개 이상의 언어가 병렬적으로 구성된 코퍼스를 의미한다.
다운로드 링크
이번 실습에서는 프랑스-영어 병렬 코퍼스인 fra-eng.zip 파일을 사용할 것이다. 위의 링크에서 해당 파일을 다운받으시면 된다. 해당 파일의 압축을 풀면 fra.txt라는 파일이 있는데 이 파일이 이번 실습에서 사용할 파일이다.

3.1 병렬 코퍼스 데이터에 대한 이해와 전처리

우선 병렬 코퍼스 데이터에 대한 이해를 해보겠다. 병렬 데이터라고 하면 앞서 수행한 태깅 작업의 데이터를 생각할 수 있지만, 앞서 수행한 태깅 작업의 병렬 데이터와 seq2seq가 사용하는 병렬 데이터는 성격이 조금 다르다. 태깅 작업의 병렬 데이터는 쌍이 되는 모든 데이터가 길이가 같았지만 여기서는 쌍이 된다고 해서 길이가 같지는 않다.

실제 번역기를 생각해보면 구글 번역기에 '나는 학생이다.'라는 토큰의 개수가 2인 문장을 넣었을 때 'I am a student.'라는 토큰의 개수가 4인 문장이 나오는 것과 같은 이치이다. seq2seq는 기본적으로 입력 시퀀스와 출력 시퀀스의 길이가 다를 수 있다고 가정한다. 지금은 기계 번역기가 예제지만 seq2seq의 또 다른 유명한 예제 중 하나인 챗봇을 만든다고 가정해보면, 대답의 길이가 질문의 길이와 항상 똑같아야 한다고하면 그 또한 이상하다.

Watch me.           Regardez-moi !

여기서 사용할 fra.txt 데이터는 위와 같이 왼쪽의 영어 문장과 오른쪽의 프랑스어 문장 사이에 탭으로 구분되는 구조가 하나의 샘플이다. 그리고 이와 같은 형식의 약 16만개의 병렬 문장 샘플을 포함하고 있다. 해당 데이터를 읽고 전처리를 진행해보겠다. 앞으로의 코드에서 src는 source의 줄임말로 입력 문장을 나타내며, tar는 target의 줄임말로 번역하고자 하는 문장을 나타낸다.

import os
import shutil
import zipfile

import pandas as pd
import tensorflow as tf
import urllib3
from tensorflow.keras.preprocessing.sequence import pad_sequences
from tensorflow.keras.utils import to_categorical
http = urllib3.PoolManager()
url ='http://www.manythings.org/anki/fra-eng.zip'
filename = 'fra-eng.zip'
path = os.getcwd()
zipfilename = os.path.join(path, filename)
with http.request('GET', url, preload_content=False) as r, open(zipfilename, 'wb') as out_file:       
    shutil.copyfileobj(r, out_file)

with zipfile.ZipFile(zipfilename, 'r') as zip_ref:
    zip_ref.extractall(path)
lines = pd.read_csv('fra.txt', names=['src', 'tar', 'lic'], sep='\t')
del lines['lic']
print('전체 샘플의 개수 :',len(lines))

전체 샘플의 개수 : 191954

전체 샘플의 개수는 총 약 19만 2천개이다.

lines = lines.loc[:, 'src':'tar']
lines = lines[0:60000] # 6만개만 저장
lines.sample(10)

해당 데이터는 약 19만 2천개의 병렬 문장 샘플로 구성되어있지만 여기서는 간단히 60,000개의 샘플만 가지고 기계 번역기를 구축해보도록 하겠다. 우선 전체 데이터 중 60,000개의 샘플만 저장하고 현재 데이터가 어떤 구성이 되었는지 확인해보겠다.
Imgur
위의 테이블은 랜덤으로 선택된 10개의 샘플을 보여준다. 번역 문장에 해당되는 프랑스어 데이터는 앞서 배웠듯이 시작을 의미하는 심볼 <sos>과 종료를 의미하는 심볼 <eos>을 넣어주어야 한다. 여기서는 <sos>와 <eos> 대신 '\t'를 시작 심볼, '\n'을 종료 심볼로 간주하여 추가하고 다시 데이터를 출력해보겠다.
여기서 lines.tar = lines['tar']이다.

lines.tar = lines.tar.apply(lambda x : '\t '+ x + ' \n')
lines.sample(10)

Imgur
랜덤으로 10개의 샘플을 선택하여 출력하였다. 프랑스어 데이터에서 시작 심볼과 종료 심볼이 추가된 것을 볼 수 있다. 문자 집합을 생성해보겠다. 단어 집합이 아니라 문자 집합이라고 하는 이유는 토큰 단위가 단어가 아니라 문자이기 때문이다.

# 문자 집합 구축
src_vocab = set()
for line in lines.src: # 1줄씩 읽음
    for char in line: # 1개의 문자씩 읽음
        src_vocab.add(char)

tar_vocab = set()
for line in lines.tar:
    for char in line:
        tar_vocab.add(char)

문자 집합의 크기를 보겠다.

src_vocab_size = len(src_vocab)+1
tar_vocab_size = len(tar_vocab)+1
print('source 문장의 char 집합 :',src_vocab_size)
print('target 문장의 char 집합 :',tar_vocab_size)
source 문장의 char 집합 : 79
target 문장의 char 집합 : 105

영어와 프랑스어는 각각 79개와 105개의 문자가 존재한다. 이 중에서 인덱스를 임의로 부여하여 일부만 출력해보다. 현 상태에서 인덱스를 사용하려고 하면 에러가 난다. 하지만 정렬하여 순서를 정해준 뒤에 인덱스를 사용하여 출력해주면 된다.

src_vocab = sorted(list(src_vocab))
tar_vocab = sorted(list(tar_vocab))
print(src_vocab[45:75])
print(tar_vocab[45:75])
['W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']
['T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w']

문자 집합에 문자 단위로 저장된 것을 확인할 수 있다. 각 문자에 인덱스를 부여하겠다.

src_to_index = dict([(word, i+1) for i, word in enumerate(src_vocab)])
tar_to_index = dict([(word, i+1) for i, word in enumerate(tar_vocab)])
print(src_to_index)
print(tar_to_index)
  
{' ': 1, '!': 2, '"': 3, '$': 4, '%': 5, ... 중략 ... 'x': 73, 'y': 74, 'z': 75, 'é': 76, '’': 77, '€': 78}
{'\t': 1, '\n': 2, ' ': 3, '!': 4, '"': 5, ... 중략 ... 'û': 98, 'œ': 99, 'С': 100, '\u2009': 101, '‘': 102, '’': 103, '\u202f': 104}

인덱스가 부여된 문자 집합으로부터 갖고있는 훈련 데이터에 정수 인코딩을 수행한다. 우선 인코더의 입력이 될 영어 문장 샘플에 대해서 정수 인코딩을 수행해보고, 5개의 샘플을 출력해보자.

encoder_input = []

# 1개의 문장
for line in lines.src:
  encoded_line = []
  # 각 줄에서 1개의 char
  for char in line:
    # 각 char을 정수로 변환
    encoded_line.append(src_to_index[char])
  encoder_input.append(encoded_line)
print('source 문장의 정수 인코딩 :',encoder_input[:5])
  
source 문장의 정수 인코딩 : [[30, 64, 10], [30, 64, 10], [30, 64, 10], [31, 58, 10], [31, 58, 10]]

정수 인코딩이 수행된 것을 볼 수 있다. 디코더의 입력이 될 프랑스어 데이터에 대해서 정수 인코딩을 수행해보겠다.

decoder_input = []
for line in lines.tar:
  encoded_line = []
  for char in line:
    encoded_line.append(tar_to_index[char])
  decoder_input.append(encoded_line)
print('target 문장의 정수 인코딩 :',decoder_input[:5])
  
target 문장의 정수 인코딩 : [[1, 3, 48, 53, 3, 4, 3, 2], [1, 3, 39, 53, 70, 55, 60, 57, 14, 3, 2], [1, 3, 28, 67, 73, 59, 57, 3, 4, 3, 2], [1, 3, 45, 53, 64, 73, 72, 3, 4, 3, 2], [1, 3, 45, 53, 64, 73, 72, 14, 3, 2]]

정상적으로 정수 인코딩이 수행된 것을 볼 수 있다. 아직 정수 인코딩을 수행해야 할 데이터가 하나 더 남았다. 디코더의 예측값과 비교하기 위한 실제값이 필요하다. 그런데 이 실제값에는 시작 심볼에 해당되는 <sos>가 있을 필요가 없다. 이해가 되지 않는다면 이전 페이지의 그림으로 돌아가 Dense와 Softmax 위에 있는 단어들을 다시 보시기 바란다. 그래서 이번에는 정수 인코딩 과정에서 <sos>를 제거합니다. 즉, 모든 프랑스어 문장의 맨 앞에 붙어있는 '\t'를 제거하도록 한다.

decoder_target = []
for line in lines.tar:
  timestep = 0
  encoded_line = []
  for char in line:
    if timestep > 0:
      encoded_line.append(tar_to_index[char])
    timestep = timestep + 1
  decoder_target.append(encoded_line)
print('target 문장 레이블의 정수 인코딩 :',decoder_target[:5])
  
target 문장 레이블의 정수 인코딩 : [[3, 48, 53, 3, 4, 3, 2], [3, 39, 53, 70, 55, 60, 57, 14, 3, 2], [3, 28, 67, 73, 59, 57, 3, 4, 3, 2], [3, 45, 53, 64, 73, 72, 3, 4, 3, 2], [3, 45, 53, 64, 73, 72, 14, 3, 2]]

앞서 먼저 만들었던 디코더의 입력값에 해당되는 decoder_input 데이터와 비교하면 decoder_input에서는 모든 문장의 앞에 붙어있던 숫자 1이 decoder_target에서는 제거된 것을 볼 수 있다. '\t'가 인덱스가 1이므로 정상적으로 제거된 것이다. 모든 데이터에 대해서 정수 인덱스로 변경하였으니 패딩 작업을 수행한다. 패딩을 위해서 영어 문장과 프랑스어 문장 각각에 대해서 가장 길이가 긴 샘플의 길이를 확인한다.

max_src_len = max([len(line) for line in lines.src])
max_tar_len = max([len(line) for line in lines.tar])
print('source 문장의 최대 길이 :',max_src_len)
print('target 문장의 최대 길이 :',max_tar_len)
  
source 문장의 최대 길이 : 23
target 문장의 최대 길이 : 76

각각 23와 76의 길이를 가진다. 이번 병렬 데이터는 영어와 프랑스어의 길이는 하나의 쌍이라고 하더라도 전부 다르므로 패딩을 할 때도 이 두 개의 데이터의 길이를 전부 동일하게 맞춰줄 필요는 없다. 영어 데이터는 영어 샘플들끼리, 프랑스어는 프랑스어 샘플들끼리 길이를 맞추어서 패딩하면 된다. 여기서는 가장 긴 샘플의 길이에 맞춰서 영어 데이터의 샘플은 전부 길이가 23이 되도록 패딩하고, 프랑스어 데이터의 샘플은 전부 길이가 76이 되도록 패딩 할 것이다.

encoder_input = pad_sequences(encoder_input, maxlen=max_src_len, padding='post')
decoder_input = pad_sequences(decoder_input, maxlen=max_tar_len, padding='post')
decoder_target = pad_sequences(decoder_target, maxlen=max_tar_len, padding='post')

모든 값에 대해서 원-핫 인코딩을 수행한다. 문자 단위 번역기므로 워드 임베딩은 별도로 사용되지 않으며, 예측값과의 오차 측정에 사용되는 실제값뿐만 아니라 입력값도 원-핫 벡터를 사용하겠다.

encoder_input = to_categorical(encoder_input)
decoder_input = to_categorical(decoder_input)
decoder_target = to_categorical(decoder_target)

데이터에 대한 전처리가 모두 끝났습니다. 본격적으로 seq2seq 모델을 설계해보겠다.

3.2 교사 강요(Teacher forcing)

이제 decoder_input에 대해 설명하고자 한다. 현재 시점의 디코더 셀의 입력은 오직 이전 디코더 셀의 출력을 입력으로 받는다고 설명하였는데 decoder_input이 왜 필요할까?

훈련 과정에서는 이전 시점의 디코더 셀의 출력을 현재 시점의 디코더 셀의 입력으로 넣어주지 않고, 이전 시점의 실제값을 현재 시점의 디코더 셀의 입력값으로 하는 방법을 사용할 것이다. 그 이유는 이전 시점의 디코더 셀의 예측이 틀렸는데 이를 현재 시점의 디코더 셀의 입력으로 사용하면 현재 시점의 디코더 셀의 예측도 잘못될 가능성이 높고 이는 연쇄 작용으로 디코더 전체의 예측을 어렵게 한다. 이런 상황이 반복되면 훈련 시간이 느려진다. 만약 이 상황을 원하지 않는다면 이전 시점의 디코더 셀의 예측값 대신 실제값을 현재 시점의 디코더 셀의 입력으로 사용하는 방법을 사용할 수 있다. 이와 같이 RNN의 모든 시점에 대해서 이전 시점의 예측값 대신 실제값을 입력으로 주는 방법을 교사 강요(teacher force)라고 한다.

3.3 seq2seq 기계 번역기 훈련시키기

seq2seq 모델을 설계하고 교사 강요를 사용하여 훈련시켜보도록 하겠다.

from tensorflow.keras.layers import Input, LSTM, Embedding, Dense
from tensorflow.keras.models import Model
import numpy as np
  
encoder_inputs = Input(shape=(None, src_vocab_size))
encoder_lstm = LSTM(units=256, return_state=True)

# encoder_outputs은 여기서는 불필요
encoder_outputs, state_h, state_c = encoder_lstm(encoder_inputs)

# LSTM은 바닐라 RNN과는 달리 상태가 두 개. 은닉 상태와 셀 상태.
encoder_states = [state_h, state_c]

인코더를 주목해보면 functional API를 사용한다는 것 외에는 앞서 다른 실습에서 본 LSTM 설계와 크게 다르지는 않다. 우선 LSTM의 은닉 상태(hidden state) 크기는 256으로 선택하였다. 인코더의 내부 상태를 디코더로 넘겨주어야 하기 때문에 return_state=True로 설정한다. 인코더에 입력을 넣으면 내부 상태를 리턴합니다.

LSTM에서 state_h, state_c를 리턴받는데, 이는 각각 LSTM을 설명할 때 언급하였던 배운 은닉 상태(hidden state)와 셀 상태(cell state)에 해당된다. 앞서 이론을 설명할 때는 셀 상태는 설명에서 생략하고 은닉 상태만 언급하였으나 사실 LSTM은 은닉 상태와 셀 상태라는 두 가지 상태를 가진다는 사실을 기억해야 한다. 갑자기 어려워진 게 아니다. 단지 은닉 상태만 전달하는 게 아니라 은닉 상태와 셀 상태 두 가지를 전달한다고 생각하면 된다. 이 두 가지 상태를 encoder_states에 저장합니다. encoder_states를 디코더에 전달하므로서 이 두 가지 상태 모두를 디코더로 전달합니다. 이것이 앞서 배운 컨텍스트 벡터(context vector)이다.

decoder_inputs = Input(shape=(None, tar_vocab_size))
decoder_lstm = LSTM(units=256, return_sequences=True, return_state=True)

# 디코더에게 인코더의 은닉 상태, 셀 상태를 전달.
decoder_outputs, _, _= decoder_lstm(decoder_inputs, initial_state=encoder_states)

decoder_softmax_layer = Dense(tar_vocab_size, activation='softmax')
decoder_outputs = decoder_softmax_layer(decoder_outputs)

model = Model([encoder_inputs, decoder_inputs], decoder_outputs)
model.compile(optimizer="rmsprop", loss="categorical_crossentropy")

디코더는 인코더의 마지막 은닉 상태를 초기 은닉 상태로 사용한다. 위에서 initial_state의 인자값으로 encoder_states를 주는 코드가 이에 해당된다. 또한 동일하게 디코더의 은닉 상태 크기도 256으로 주었다. 디코더도 은닉 상태, 셀 상태를 리턴하기는 하지만 훈련 과정에서는 사용하지 않는다. 그 후 출력층에 프랑스어의 단어 집합의 크기만큼 뉴런을 배치한 후 소프트맥스 함수를 사용하여 실제값과의 오차를 구한다.

Model.fit(x=[encoder_input, decoder_input], y=decoder_target, batch_size=64, epochs=40, validation_split=0.2)

입력으로는 인코더 입력과 디코더 입력이 들어가고, 디코더의 실제값인 decoder_target도 필요하다. 배치 크기는 64로 하였으며 총 40 에포크를 학습한다. 위에서 설정한 은닉 상태의 크기와 에포크 수는 실제로는 훈련 데이터에 과적합 상태를 불러온다. 중간부터 검증 데이터에 대한 오차인 val_loss의 값이 올라가는데, 사실 이번 실습에서는 주어진 데이터의 양과 태스크의 특성으로 인해 훈련 과정에서 훈련 데이터의 정확도와 과적합 방지라는 두 마리 토끼를 동시에 잡기에는 쉽지 않다. 여기서는 우선 seq2seq의 메커니즘과 짧은 문장과 긴 문장에 대한 성능 차이에 대한 확인을 중점으로 두고 훈련 데이터에 과적합 된 상태로 동작 단계로 넘어간다.

3.4 seq2seq 기계 번역기 동작시키기

앞서 seq2seq는 훈련할 때와 동작할 때의 방식이 다르다고 언급한 바 있다. 이번에는 입력한 문장에 대해서 기계 번역을 하도록 모델을 조정하고 동작시켜보도록 하겠다.

전체적인 번역 동작 단계를 정리하면 아래와 같다.

  1. 번역하고자 하는 입력 문장이 인코더에 들어가서 은닉 상태와 셀 상태를 얻는다.
  2. 인코더의 상태와 <SOS>에 해당하는 '\t'를 디코더로 보낸다.
  3. 디코더가 <EOS>에 해당하는 '\n'이 나올 때까지 다음 문자를 예측하는 행동을 반복한다.
encoder_model = Model(inputs=encoder_inputs, outputs=encoder_states)

우선 인코더를 정의한다. encoder_inputs와 encoder_states는 훈련 과정에서 이미 정의한 것들을 재사용하는 것이다. 디코더를 설계해보겠다.

# 이전 시점의 상태들을 저장하는 텐서
decoder_state_input_h = Input(shape=(256,))
decoder_state_input_c = Input(shape=(256,))
decoder_states_inputs = [decoder_state_input_h, decoder_state_input_c]

# 문장의 다음 단어를 예측하기 위해서 초기 상태(initial_state)를 이전 시점의 상태로 사용.
# 뒤의 함수 decode_sequence()에 동작을 구현 예정
decoder_outputs, state_h, state_c = decoder_lstm(decoder_inputs, initial_state=decoder_states_inputs)

# 훈련 과정에서와 달리 LSTM의 리턴하는 은닉 상태와 셀 상태를 버리지 않음.
decoder_states = [state_h, state_c]
decoder_outputs = decoder_softmax_layer(decoder_outputs)
decoder_model = Model(inputs=[decoder_inputs] + decoder_states_inputs, outputs=[decoder_outputs] + decoder_states)
index_to_src = dict((i, char) for char, i in src_to_index.items())
index_to_tar = dict((i, char) for char, i in tar_to_index.items())

단어로부터 인덱스를 얻는 것이 아니라 인덱스로부터 단어를 얻을 수 있는 index_to_src와 index_to_tar를 만들었다.

def decode_sequence(input_seq):
  # 입력으로부터 인코더의 상태를 얻음
  states_value = encoder_model.predict(input_seq)

  # <SOS>에 해당하는 원-핫 벡터 생성
  target_seq = np.zeros((1, 1, tar_vocab_size))
  target_seq[0, 0, tar_to_index['\t']] = 1.

  stop_condition = False
  decoded_sentence = ""

  # stop_condition이 True가 될 때까지 루프 반복
  while not stop_condition:
    # 이점 시점의 상태 states_value를 현 시점의 초기 상태로 사용
    output_tokens, h, c = decoder_model.predict([target_seq] + states_value)

    # 예측 결과를 문자로 변환
    sampled_token_index = np.argmax(output_tokens[0, -1, :])
    sampled_char = index_to_tar[sampled_token_index]

    # 현재 시점의 예측 문자를 예측 문장에 추가
    decoded_sentence += sampled_char

    # <eos>에 도달하거나 최대 길이를 넘으면 중단.
    if (sampled_char == '\n' or
        len(decoded_sentence) > max_tar_len):
        stop_condition = True

    # 현재 시점의 예측 결과를 다음 시점의 입력으로 사용하기 위해 저장
    target_seq = np.zeros((1, 1, tar_vocab_size))
    target_seq[0, 0, sampled_token_index] = 1.

    # 현재 시점의 상태를 다음 시점의 상태로 사용하기 위해 저장
    states_value = [h, c]

  return decoded_sentence
for seq_index in [3,50,100,300,1001]: # 입력 문장의 인덱스
  input_seq = encoder_input[seq_index:seq_index+1]
  decoded_sentence = decode_sequence(input_seq)
  print(35 * "-")
  print('입력 문장:', lines.src[seq_index])
  print('정답 문장:', lines.tar[seq_index][2:len(lines.tar[seq_index])-1]) # '\t'와 '\n'을 빼고 출력
  print('번역 문장:', decoded_sentence[1:len(decoded_sentence)-1]) # '\n'을 빼고 출력

출력 결과

-----------------------------------
입력 문장: Hi.
정답 문장: Salut ! 
번역 문장: Salut. 
-----------------------------------
입력 문장: I see.
정답 문장: Aha. 
번역 문장: Je change. 
-----------------------------------
입력 문장: Hug me.
정답 문장: Serrez-moi dans vos bras ! 
번역 문장: Serre-moi dans vos patents ! 
-----------------------------------
입력 문장: Help me.
정답 문장: Aidez-moi. 
번역 문장: Aidez-moi. 
-----------------------------------
입력 문장: I beg you.
정답 문장: Je vous en prie. 
번역 문장: Je vous en prie. 

지금까지 문자 단위의 seq2seq를 구현하였다. 다음 번에서 이번 실습에서 배운 내용을 바탕으로 문자 단위에서 단어 단위로 확장해서 기계 번역기를 구현해 보는 것과 이번 실습을 기반으로 챗봇을 만들어 보는 것을 해 보겠다.

끝가지 읽어주셔서 감사합니다!

profile
바쁘게 부지런하게 논리적으로 살자!!!
post-custom-banner

0개의 댓글