Attention mechanism을 사용한 Seq2seq 구현

Vallina Seq2seq

  • tf.function을 사용하기 위해 tensorflow 2.0.0-beta1버전을 설치한다.

  • 한글 텍스트의 형태소분석을 위해 konlpy에서 Okt(Original Korean tag, Twitter에서 공개한 오픈소스 라이브러리)를 사용하기 위해 설치해준다.

1
2
!pip install tensorflow==2.0.0-beta1
!pip install konlpy
  • 필요한 라이브러리 import
1
2
3
import random
import tensorflow as tf
from konlpy.tag import Okt
  • tensorflow 버전이 맞는지 확인
1
print(tf.__version__)

하이퍼 파라미터 설정

1
2
3
EPOCHS = 200
# 가장 많이 사용된 2000개를 사용하기 위해
NUM_WORDS = 2000

Encoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Encoder(tf.keras.Model):
def __init__(self):
super(Encoder, self).__init__()
# 2000개의 단어들을 64크기의 vector로 Embedding해줌.
self.emb = tf.keras.layers.Embedding(NUM_WORDS, 64)
# return_state는 return하는 Output에 최근의 state를 더해주느냐에 대한 옵션
# 즉, Hidden state와 Cell state를 출력해주기 위한 옵션이라고 볼 수 있다.
# default는 False이므로 주의하자!
self.lstm = tf.keras.layers.LSTM(512, return_state=True)

def call(self, x, training=False, mask=None):
x = self.emb(x)
_, h, c = self.lstm(x)
return h, c

Decoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Decoder(tf.keras.Model):
def __init__(self):
super(Decoder, self).__init__()
self.emb = tf.keras.layers.Embedding(NUM_WORDS, 64)
# return_sequence는 return 할 Output을 full sequence 또는 Sequence의 마지막에서 출력할지를 결정하는 옵션
# False는 마지막에만 출력, True는 모든 곳에서의 출력
self.lstm = tf.keras.layers.LSTM(512, return_sequences=True, return_state=True)
self.dense = tf.keras.layers.Dense(NUM_WORDS, activation='softmax')

def call(self, inputs, training=False, mask=None):
x, h, c = inputs
x = self.emb(x)

# initial_state는 셀의 첫 번째 호출로 전달 될 초기 상태 텐서 목록을 의미
# 이전의 Encoder에서 만들어진 Hidden state와 Cell state를 입력으로 받아야 하므로
x, h, c = self.lstm(x, initial_state=[h, c])
return self.dense(x), h, c

Seq2seq

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class Seq2seq(tf.keras.Model):
def __init__(self, sos, eos):
super(Seq2seq, self).__init__()
self.enc = Encoder()
self.dec = Decoder()
self.sos = sos
self.eos = eos

def call(self, inputs, training=False, mask=None):
if training is True:
# 학습을 하기 위해서는 우리가 입력과 출력 두가지를 다 알고 있어야 한다.
# 출력이 필요한 이유는 Decoder단의 입력으로 shited_ouput을 넣어주게 되어있기 때문이다.
x, y = inputs

# LSTM으로 구현되었기 때문에 Hidden State와 Cell State를 출력으로 내준다.
h, c = self.enc(x)

# Hidden state와 cell state, shifted output을 초기값으로 입력 받고
# 출력으로 나오는 y는 Decoder의 결과이기 때문에 전체 문장이 될 것이다.
y, _, _ = self.dec((y, h, c))
return y

else:
x = inputs
h, c = self.enc(x)

# Decoder 단에 제일 먼저 sos를 넣어주게끔 tensor화시키고
y = tf.convert_to_tensor(self.sos)
# shape을 맞춰주기 위한 작업이다.
y = tf.reshape(y, (1, 1))

# 최대 64길이 까지 출력으로 받을 것이다.
seq = tf.TensorArray(tf.int32, 64)

# tf.keras.Model에 의해서 call 함수는 auto graph모델로 변환이 되게 되는데,
# 이때, tf.range를 사용해 for문이나 while문을 작성시 내부적으로 tf 함수로 되어있다면
# 그 for문과 while문이 굉장히 효율적으로 된다.
for idx in tf.range(64):
y, h, c = self.dec([y, h, c])
# 아래 두가지 작업은 test data를 예측하므로 처음 예측한값을 다시 다음 step의 입력으로 넣어주어야하기에 해야하는 작업이다.
# 위의 출력으로 나온 y는 softmax를 지나서 나온 값이므로
# 가장 높은 값의 index값을 tf.int32로 형변환해주고
# 위에서 만들어 놓았던 TensorArray에 idx에 y를 추가해준다.
y = tf.cast(tf.argmax(y, axis=-1), dtype=tf.int32)
# 위의 값을 그대로 넣어주게 되면 Dimension이 하나밖에 없어서
# 실제로 네트워크를 사용할 때 Batch를 고려해서 사용해야 하기 때문에 (1,1)으로 설정해 준다.
y = tf.reshape(y, (1, 1))
seq = seq.write(idx, y)

if y == self.eos:
break
# stack은 그동안 TensorArray로 받은 값을 쌓아주는 작업을 한다.
return tf.reshape(seq.stack(), (1, 64))

학습, 테스트 루프 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Implement training loop
@tf.function
def train_step(model, inputs, labels, loss_object, optimizer, train_loss, train_accuracy):
# output_labels는 실제 output과 비교하기 위함
# shifted_labels는 Decoder부분에 입력을 넣기 위함
output_labels = labels[:, 1:]
shifted_labels = labels[:, :-1]
with tf.GradientTape() as tape:
predictions = model([inputs, shifted_labels], training=True)
loss = loss_object(output_labels, predictions)
gradients = tape.gradient(loss, model.trainable_variables)

optimizer.apply_gradients(zip(gradients, model.trainable_variables))
train_loss(loss)
train_accuracy(output_labels, predictions)

# Implement algorithm test
@tf.function
def test_step(model, inputs):
return model(inputs, training=False)

데이터셋 준비

  • http://www.aihub.or.kr에서 text데이터 중 AI chatbot 데이터를 사용할 것이다. 이 데이터를 다운받아 필자는 google storage 서비스를 이용해서 기존의 생성해놓았던 버킷을 통해 데이터를 업로드 한 후, 받아와서 사용할 것이다. 이 방법은 google storage에서 파일을 받아 사용하는 gsutil 방식이며 빠르다는 점이 장점이지만 현재 세션이 종료되거나 새로시작할 경우 다시 실행 시켜주어야 하는 방식이다. 또한 필자처럼 google colab이 아닌 자신의 로컬PC로 실행할 경우 아래 단계는 건너 뛰어도 상관없다.
1
2
3
4
5
from google.colab import auth
auth.authenticate_user()

import pandas as pd
!gsutil cp gs://kaggle_key/chatbot_data.csv chatbot_data.csv
  • chatbot_data.csv 파일이 현재 path에 존재하는지 확인

    1
    %ls
  • chatbot_data.csv파일을 pandas DataFrame으로 읽어 어떤 데이터들이 존재하고 추후에 x(Question)와 y(Answer)로 나눠주려면 패턴을 찾아야 하기 때문에 모든 데이터를 볼 것이다. 전체 데이터는 999개이기 떄문에 출력되어지는 row의 수를 1000개로 맞춰준다.

1
pd.options.display.max_rows = 1000
1
2
chatbot_data = pd.read_csv('chatbot_data.csv',header=0)
chatbot_data

chatbot_data

  • 위에서 pandas로 불러들인 QA(Question & Answer) data를 보면 Question과 Answer로 이루어져있다. 즉, 순차적인 데이터인 것이다. 또한 대화의 끝이 나누어져 있지 않아 입력으로 넣어주려면 Data를 Question과 Answer 쌍으로 가공해주어야 할 것이다. 맨처음 줄부터 Question 그다음은 Answer 이순으로 되어있다는 것을 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
dataset_file = 'chatbot_data.csv'
okt = Okt()

with open(dataset_file, 'r') as file:
lines = file.readlines()
# okt 라이브러리를 통해 형태소 분석을 한줄씩 진행하였고
# 나누어진 형태소들을 하나의 sequence로 묶어주기위해
# 구분자는 공백을 사용해서 join해주었다.
# 구분자를 space로 한 이유는 나중에 사용할 tokenizer에서 space를 기준으로 단어를 구분하기 때문이다.
seq = [" ".join(okt.morphs(line)) for line in lines]

questions = seq[::2]
# tap은 Decoder단에서 Shifted Output을 입력으로 받을때 시작점을 알려주기 위한 SOS로 tap(\t)을 사용
answers = ['\t' + lines for lines in seq[1::2]]

num_sample = len(questions)

perm = list(range(num_sample))
random.seed(0)
random.shuffle(perm)

train_q = list()
train_a = list()
test_q = list()
test_a = list()

for idx, qna in enumerate(zip(questions, answers)):
q, a = qna
if perm[idx] > num_sample//5:
train_q.append(q)
train_a.append(a)

else:
test_q.append(q)
test_a.append(a)

# filters의 default에는 \t,\n도 제거하기 때문에 이 둘을 제외하고 나머지 문장기호들만 제거하게끔 변경해주었다.
tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=NUM_WORDS,
filters='!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~')

# 시퀀스 목록을 기반으로 내부 어휘를 업데이트한다.
tokenizer.fit_on_texts(train_q + train_a)

# 위에서 업데이트한 어휘를 기반으로 실수형태의 벡터 형태로 나타내 준다.
# 출력을 통해 나타나는 실수는 count의 수를 나타내는 것은 아니다!
train_q_seq = tokenizer.texts_to_sequences(train_q)
train_a_seq = tokenizer.texts_to_sequences(train_a)

test_q_seq = tokenizer.texts_to_sequences(test_q)
test_a_seq = tokenizer.texts_to_sequences(test_a)

# y값에는 maxlen=65인 이유는 앞에 SOS와 뒤에 EOS가 붙어 있는 상황이므로 학습시에는 앞에 하나를 떼고
# 학습하므로 실제로는 64길이만 사용하는 것과 동일하게 된다.
x_train = tf.keras.preprocessing.sequence.pad_sequences(train_q_seq,
value=0,
padding='pre',
maxlen=64)

y_train = tf.keras.preprocessing.sequence.pad_sequences(train_a_seq,
value=0,
padding='post',
maxlen=65)


x_test = tf.keras.preprocessing.sequence.pad_sequences(test_q_seq,
value=0,
padding='pre',
maxlen=64)

y_test = tf.keras.preprocessing.sequence.pad_sequences(test_a_seq,
value=0,
padding='post',
maxlen=65)

# prefetch(1024)는 GPU에 미리 1024개의 데이터를 미리 fetch하는 기능!
# 근데 batch size도 아니고 왜 1024개??
train_ds = tf.data.Dataset.from_tensor_slices((x_train, y_train)).shuffle(10000).batch(32).prefetch(1024)
test_ds = tf.data.Dataset.from_tensor_slices((x_test, y_test)).batch(1).prefetch(1024)

학습 환경 정의

모델 생성, 손실 함수, 최적화 알고리즘, 평가지표 정의

1
2
3
4
5
6
7
8
9
10
11
# 모델 생성
model = Seq2seq(sos=tokenizer.word_index['\t'],
eos=tokenizer.word_index['\n'])

# 손실함수 및 최적화 기법 정의
loss_object = tf.keras.losses.SparseCategoricalCrossentropy()
optimizer = tf.keras.optimizers.Adam()

# 성능 지표 정의
train_loss = tf.keras.metrics.Mean(name='train_loss')
train_accuracy = tf.keras.metrics.SparseCategoricalAccuracy(name='train_accuracy')

학습 루프 동작

1
2
3
4
5
6
7
8
for epoch in range(EPOCHS):
for seqs, labels in train_ds:
train_step(model, seqs, labels, loss_object, optimizer, train_loss, train_accuracy)

template='Epoch {}, Loss: {}, Accuracy:{}'
print(template.format(epoch + 1,
train_loss.result(),
train_accuracy.result() * 100))

train data 성능

테스트 루프

1
2
3
4
5
6
7
8
9
10
11
for test_seq, test_labels in test_ds:
prediction = test_step(model, test_seq)
test_text = tokenizer.sequences_to_texts(test_seq.numpy())
# ground_truth
gt_text = tokenizer.sequences_to_texts(test_labels.numpy())
# prediction
texts = tokenizer.sequences_to_texts(prediction.numpy())
print('_')
print('q: ', test_text)
print('a: ', gt_text)
print('p: ', texts)
  • 예측된 값들을 보면 train data에 과적합된 것을 충분히 알 수 있을 것이다.

test data를 통한 예측 결과

이제 여기서 Attention mechanism을 적용시켜보자.

Encoder, Decoder, Seq2seq 부분을 수정하면된다.

Encoder

  • 이전과 다르게 LSTM 구조에서 return_sequences=True를 넣어 전체 Hidden State를 출력하게 해주었다. 이를 Key-Value로 사용할 것이다.

  • Embedding 결과의 Dimension : (32(batch_szie), 64(sequence의 길이), 64(Embedding Feature의 수))

  • LSTM의 결과의 Dimension : (32(batch_szie), 64(sequence의 길이), 512(LSTM unit의 갯수))

    • H : 32(batch size) 64(sequence의 길이) 512(LSTM unit수)
    • h(s0) : 32(batch size) * 512 (LSTM unit수)
    • c(c0) : 32(batch size) * 512 (LSTM unit수)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      class Encoder(tf.keras.Model):
      def __init__(self):
      super(Encoder, self).__init__()
      # 2000개의 단어들을 64크기의 vector로 Embedding해줌.
      self.emb = tf.keras.layers.Embedding(NUM_WORDS, 64)
      # return_state는 return하는 Output에 최근의 state를 더해주느냐에 대한 옵션
      # 즉, Hidden state와 Cell state를 출력해주기 위한 옵션이라고 볼 수 있다.
      # default는 False이므로 주의하자!
      # return_sequence=True로하는 이유는 Attention mechanism을 사용할 때 우리가 key와 value는
      # Encoder에서 나오는 Hidden state 부분을 사용했어야 했다. 그러므로 모든 Hidden State를 사용하기 위해 바꿔준다.
      self.lstm = tf.keras.layers.LSTM(512, return_sequences=True, return_state=True)

      def call(self, x, training=False, mask=None):
      x = self.emb(x)
      H, h, c = self.lstm(x)
      return H, h, c

Decoder

  • LSTM 다음에 Attention 구조를 넣어주고, Encoder의 출력 중 모든 sequence의 Hidden State를 모아놓은 H와 s0, c0, shifted Output을 받아서 Attention value를 구하기 위한 코드를 수정시킨다.

  • Dimension :

    • x : shifted_labels로 맨마지막을 제외한 나머지데이터들 => 32(batch szie) * 64(sequence의 길이)
    • s0 : 이전 step의 hidden state => 32(batch size) * 512(LSTM의 Unit 갯수)
    • c0 : 이전 step의 cell state => 32(batch size) * 512(LSTM의 Unit 갯수)
    • H : Encoder단의 모든 Hidden state를 모은 것 => 32(batch size) 64(sequence의 길이) 512(LSTM의 Feature의 갯수)
  • embedding 결과 => 32(batch size) 64(sequence의 길이) 64(Embedding Feature의 수)

  • LSTM의 결과의 Dimension : (32(batch_szie), 64(sequence의 길이), 512(LSTM unit의 갯수))

    • S : 32(batch size) 64(sequence의 길이) 512(LSTM unit수)
    • h : 32(batch size) * 512 (LSTM unit수)
    • c : 32(batch size) * 512 (LSTM unit수)
  • S_의 Dimension: 32(batch size) 64(sequence의 길이) 512(LSTM unit 수)

  • A의 Dimension: 32(batch size) 64(sequence의 길이) 512(LSTM unit 수)
  • y의 Dimension: 32(batch size) 64(sequence의 길이) 1024
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Decoder(tf.keras.Model):
def __init__(self):
super(Decoder, self).__init__()
self.emb = tf.keras.layers.Embedding(NUM_WORDS, 64)
# return_sequence는 return 할 Output을 full sequence 또는 Sequence의 마지막에서 출력할지를 결정하는 옵션
# False는 마지막에만 출력, True는 모든 곳에서의 출력
self.lstm = tf.keras.layers.LSTM(512, return_sequences=True, return_state=True)
# LSTM 출력에다가 Attention value를 dense에 넘겨주는 것이 Attention mechanism이므로
self.att = tf.keras.layers.Attention()
self.dense = tf.keras.layers.Dense(NUM_WORDS, activation='softmax')

def call(self, inputs, training=False, mask=None):
# x : shifted output, s0 : Decoder단의 처음들어오는 Hidden state
# c0 : Decoder단의 처음들어오는 cell state H: Encoder단의 Hidden state(Key와 value로 사용)
x, s0, c0, H = inputs
x = self.emb(x)

# initial_state는 셀의 첫 번째 호출로 전달 될 초기 상태 텐서 목록을 의미
# 이전의 Encoder에서 만들어진 Hidden state와 Cell state를 입력으로 받아야 하므로
# S : Hidden state를 전부다 모아놓은 것이 될 것이다.(Query로 사용)
S, h, c = self.lstm(x, initial_state=[s0, c0])

# Query로 사용할 때는 하나 앞선 시점을 사용해줘야 하므로
# s0가 제일 앞에 입력으로 들어가는데 현재 Encoder 부분에서의 출력이 batch 크기에 따라서 length가 현재 1이기 때문에 2차원형태로 들어오게 된다.
# 그러므로 이제 3차원 형태로 확장해 주기 위해서 newaxis를 넣어준다.
# 또한 decoder의 S(Hidden state) 중에 마지막은 예측할 다음이 없으므로 배제해준다.
S_ = tf.concat([s0[:, tf.newaxis, :], S[:, :-1, :]], axis=1)

# Attention 적용
# 아래 []안에는 원래 Query, Key와 value 순으로 입력해야하는데 아래처럼 두가지만 입력한다면
# 마지막 것을 Key와 value로 사용한다.
A = self.att([S_, H])

y = tf.concat([S, A], axis=-1)
return self.dense(y), h, c

Seq2seq

  • 이전의 코드에서 encoder의 출력에 전체 Hidden State를 모아놓은 것과 decoder의 입력으로 이값을 받는 코드를 추가해주었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class Seq2seq(tf.keras.Model):
def __init__(self, sos, eos):
super(Seq2seq, self).__init__()
self.enc = Encoder()
self.dec = Decoder()
self.sos = sos
self.eos = eos

def call(self, inputs, training=False, mask=None):
if training is True:
# 학습을 하기 위해서는 우리가 입력과 출력 두가지를 다 알고 있어야 한다.
# 출력이 필요한 이유는 Decoder단의 입력으로 shited_ouput을 넣어주게 되어있기 때문이다.
x, y = inputs

# LSTM으로 구현되었기 때문에 Hidden State와 Cell State를 출력으로 내준다.
H, h, c = self.enc(x)

# Hidden state와 cell state, shifted output을 초기값으로 입력 받고
# 출력으로 나오는 y는 Decoder의 결과이기 때문에 전체 문장이 될 것이다.
y, _, _ = self.dec((y, h, c, H))
return y

else:
x = inputs
H, h, c = self.enc(x)

# Decoder 단에 제일 먼저 sos를 넣어주게끔 tensor화시키고
y = tf.convert_to_tensor(self.sos)
# shape을 맞춰주기 위한 작업이다.
y = tf.reshape(y, (1, 1))

# 최대 64길이 까지 출력으로 받을 것이다.
seq = tf.TensorArray(tf.int32, 64)

# tf.keras.Model에 의해서 call 함수는 auto graph모델로 변환이 되게 되는데,
# 이때, tf.range를 사용해 for문이나 while문을 작성시 내부적으로 tf 함수로 되어있다면
# 그 for문과 while문이 굉장히 효율적으로 된다.
for idx in tf.range(64):
y, h, c = self.dec([y, h, c, H])
# 아래 두가지 작업은 test data를 예측하므로 처음 예측한값을 다시 다음 step의 입력으로 넣어주어야하기에 해야하는 작업이다.
# 위의 출력으로 나온 y는 softmax를 지나서 나온 값이므로
# 가장 높은 값의 index값을 tf.int32로 형변환해주고
# 위에서 만들어 놓았던 TensorArray에 idx에 y를 추가해준다.
y = tf.cast(tf.argmax(y, axis=-1), dtype=tf.int32)
# 위의 값을 그대로 넣어주게 되면 Dimension이 하나밖에 없어서
# 실제로 네트워크를 사용할 때 Batch를 고려해서 사용해야 하기 때문에 (1,1)으로 설정해 준다.
y = tf.reshape(y, (1, 1))
seq = seq.write(idx, y)

if y == self.eos:
break
# stack은 그동안 TensorArray로 받은 값을 쌓아주는 작업을 한다.
return tf.reshape(seq.stack(), (1, 64))

Attention mechanism을 사용한 Seq2seq의 train data 성능

Attention mechanism을 사용한 Seq2seq의 test 결과