본문 바로가기

Deep Learning(강의 및 책)/밑바닥부터 시작하는 딥러닝 2

8. 어텐션

728x90

바닥부터 시작하는 딥러닝 2를 바탕으로 제작하였습니다.

 

8.1. 어텐션의 구조

어텐션이라는 메커니즘 덕분에 seq2seq는 필요한 정보에만 주목할 수 있습니다. 

 

 ■ seq2seq의 문제점

seq2seq에서는 Encoder가 시계열 데이터를 인코딩합니다. 그리고 인코딩 된 정보를 Decoder에 전달합니다. 이때 Encoder의 출력은 '고정 길이의 벡터'였습니다. 그런데 실은 이 '고정 길이'라는 데에 큰 문제가 잠재해 있습니다. 고정 길이 벡터라 함은 입력 문장의 길이에 관계없이(아무리 길어도), 항상 같은 길이의 벡터로 변환합니다. 아무리 긴 문장이 입력되더라도 항상 똑같은 길이의 벡터에 밀어 넣어야 합니다.

 

그림과 같이 길이에 상관없이 똑같은 길이의 벡터에 들어갑니다. 이렇게 된다면 많은 옷가지를 옷장에 욱여넣듯이 억지로 고정 길이의 벡터로 밀어 넣는 것입니다.

 

 ■ Encoder 개선

LSTM 계층의 마지막 은닉 상태만을 Decoder에 전달했습니다. 그러나 Encoder 출력의 길이는 입력 문장의 길이에 따라 바꿔주는 게 좋습니다. 그렇게 하기 위해서 시각별 LSTM 계층의 은닉 상태 벡터를 모두 이용하면 입력된 단어와 같은 수의 벡터를 얻을 수 있습니다. 예를 들어 4개의 단어가 입력되었고, 이때 Encoder는 5개의 벡터를 출력합니다. 이것으로 Encoder는 '하나의 고정 길이 벡터'라는 제약으로부터 해방됩니다.

 

 ■ Decoder 개선 ①

Encoder는 각 단어에 대응하는 LSTM 계층의 은닉 상태 벡터를 hs로 모아 출력합니다. 그리고 이 hs가 Decoder에 전달되어 시계열 변환이 이뤄집니다. 가장 단순한 seq2seq에서는 Encoder의 마지막 은닉 상태 벡터만을 Decoder에 넘겼습니다. Encoder의 마지막 은닉 상태를 Decoder의 LSTM 계층의 첫 은닉 상태로 설정한 것입니다.

 

위 그림에서 보듯 Decoder는 Encoder의 LSTM 계층의  마지막 은닉 상태만을 이용합니다. hs에서 마지막 줄만 빼내어 Decoder에 전달합니다. 이번에는 hs를 전부 활용할 수 있도록 Decoder를 개선해보겠습니다.

목표를 '도착어 단어'와 대응 관계에 있는 '출발어 단어'의 정보를 골라내는 것, 그리고 그 정보를 이용하여 번역을 하는 것으로 잡겠습니다. 다시 말해, 필요한 정보에만 주목하여 그 정보로부터 시계열 변환을 수행하는 것이 목표입니다. 이러한 구조를 '어텐션'이라 부릅니다.

 

위 그림은 우리가 구현하고자 하는 신경망의 계층 구성입니다. 여기에서는 새롭게 '어떤 계산'을 수행하는 계층을 추가했습니다. 이 '어떤 계산'이 받는 입력은 두 가지로, 하나는 Encoder로부터 받는 hs이고, 다른 하나는 시각별 LSTM 계층의 은닉 상태입니다. 그리고 여기서 필요한 정보만 골라 위쪽의 Affine 계층으로 출력합니다. 하지만 신경망으로 하고 싶은 일은 단어들의 얼라인먼트(단어의 대응 관계를 나타내는 정보) 추출입니다. 각 시각에서 Decoder에 입력된 단어와 대응 관계인 단어의 벡터를 hs에서 골라낸다는 뜻입니다. Decoder에서 "I"를 출력할 때, hs에서 "나"에 대응하는 벡터를 선택하면 됩니다. 이러한 '선택 작업'을 '어떤 계산'으로 하겠습니다. 하지만 선택하는 작업은 미분을 할 수 없다는 문제가 있습니다.

이를 해결하기 위해 모든 단어에 대한 중요도를 나타내는 가중치를 별도로 계산하면 됩니다. 각 단어의 중요도를 나타내는 가중치는 확률분포처럼 각 원소가 0.0 ~ 1.0 사이의 스칼라 값이고 모든 원소의 총합은 1입니다. 이런 가중치와 각 단어의 벡터로부터 가중합을 구해 우리가 원하는 벡터를 얻을 수 있습니다.

이러한 과정을 거치면 특정 벡터를 선택하는 작업을 가중합으로 대체할 수 있습니다. 그럼 가중합을 구하는 과정을 구현해보겠습니다.

 

import numpy as np

T, H = 5, 4
hs = np.random.randn(T, H)
a = np.array([0.8, 0.1, 0.03, 0.05 ,0.02])

ar = a.reshape(5,1).repeat(4, axis = 1)
# ar.shape = (5, 4)

t = hs * ar
# t.shape = (5, 4)

c = np.sum(t, aixs = 0)
# c.shape = (4,)

 

위 코드를 보면 시계열의 길이 T = 5, 은닉 상태 벡터의 원소 수를 H = 4로 하여 가중합을 구하는 과정을 보여줍니다. 원소별 곱을 계산하면 np.sum을 이용해 합을 구합니다. axis 인수는 어느 축 방향으로 합을 계산할지를 정하는 역할입니다. 0번째 축을 사라지게 했으므로 형상이 (4,)인 행렬(벡터)이 구해집니다.

그럼 미니배치 처리용 가중합을 구현해보겠습니다.

 

import numpy as np

N ,T, H = 10, 5, 4
hs = np.random.randn(N, T, H)
a = np.random.randn(N, T)
ar = a.reshape(N, T, 1).repeat(H, axis = 2)

t = hs * ar
# t.shape = (10, 5, 4)

c = np.sum(t, axis = 1)
# c.shape = (10, 4)

 

sum과 repeat에서 axis를 잘 정해야 합니다.

위 코드를 보면 repeat으로 a를 복제하고 그 이후에 원소별 곱을 계산하고 더하는 과정을 거칩니다. 이 계산의 역전파를 생각해보겠습니다. repeat의 역전파는 덧셈이고 덧셈의 역전파는 repeat입니다. 이를 바탕으로 위 연산 과정을 진행하는 Weight Sum 계층을 구현하겠습니다.

 

class WeightSum:
    def __init__(self):
        self.params, self.grads = [], []
        self.cache = None
        
    def forward(self, hs, a):
        N, T, H = hs.shape
        
        ar = a.reshape(N, T, 1).repeat(H, axis = 2)
        t = hs * ar
        c = np.sum(t, axis = 1)
        
        self.cache = (hs, ar)
        return c
    
    def backward(self, dc):
        hs, ar = self.cache
        N, T, H = hs.shape
        
        dt = dc.reshape(N, 1, H).repeat(T, axis = 1) # sum의 역전파
        dar = dt * hs
        dhs = dt * ar
        da = np.sum(dar, axis = 2)
        
        return dhs, da

 

이와 같이 구현할 수 있습니다. 이 계층은 학습하는 매개변수가 없으므로, self.params = []로 설정했습니다.

 

 ■ Decoder 개선 ②

각 단어의 중요도를 나타내는 가중치 a가 있다면, 가중합을 이용해 '맥락 벡터'를 얻을 수 있습니다. 이번에는 각 단어의 가중치 a를 구하는 방법을 알아보겠습니다. 우선 Decoder의 첫 번째 LSTM 계층이 은닉 상태 벡터를 출력할 때까지의 처리부터 알아봐야 합니다.

Decoder는 LSTM 계층의 은닉 상태 벡터를 h라 했습니다. 우리의 목표는 h가 hs의 각 단어 벡터와 얼마나 '비슷한가'를 수치로 나타내는 것입니다. 그 방법으로 벡터의 '내적'을 사용하겠습니다.

벡터 a = (a1, a2, a3, ... , an)와 벡터 b = (b1, b2, b3, ... , bn)이 있을 때 두 벡터의 내적은 a1b1 + a2b2 + a3b3 ... + anbn이 됩니다. 이 계산의 직관적인 의미는 두 벡터가 얼마나 같은 방향을 향하고 있는지를 나타내 주는데 이는 유사도를 표현하는 척도로 괜찮은 선택입니다.

 

위 그림은 내적을 이용해 벡터 사이의 유사도를 산출하는 처리를 보여줍니다. 벡터의 내적을 이용해 h와 hs의 각 단어 벡터와의 유사도를 구합니다. 그리고 결과는 s가 되고 아직 s는 정규화 되기 전의 값이고 정규화를 위해서 softmax 함수를 사용하면 됩니다. softmax 함수를 사용하면 출력의 각 원소들이 0.0 ~ 1.0 사이의 값이 됩니다.

이렇게 가중치를 나타내는 코드를 작성해보겠습니다.

 

import sys
sys.path.append('..')
from common.layers import Softmax
import numpy as np

N, T, H = 10, 5, 4
hs = np.random.randn(N, T, H)
h = np.random.randn(N, H)
hr = h.reshape(N, 1, H).repeat(T, axis = 1)

t = hs * hr
# t.shape = (10, 5, 4)

s = np.sum(t, axis = 2)
# s.shape = (10, 5)

softmax = Softmax()
a = softmax.forward(s)
# a.shape = (10, 5)

 

이 구현은 미니배치 처리를 수행하는 코드이고 여기서도 reshape와 repeat 메서드를 사용해 적합한 형상의 hr을 생성합니다.

마지막으로 AttentionWeight 클래스로 위 연산들을 처리하는 코드를 작성하겠습니다.

 

class AttentionWeight:
    def __init__(self):
        self.params, self.grads = [], []
        self.softmax = Softmax()
        self.cache = None

    def forward(self, hs, h):
        N, T, H = hs.shape

        hr = h.reshape(N, 1, H)#.repeat(T, axis=1)
        t = hs * hr
        s = np.sum(t, axis=2)
        a = self.softmax.forward(s)

        self.cache = (hs, hr)
        return a

    def backward(self, da):
        hs, hr = self.cache
        N, T, H = hs.shape

        ds = self.softmax.backward(da)
        dt = ds.reshape(N, T, 1).repeat(H, axis=2)
        dhs = dt * hr
        dhr = dt * hs
        dh = np.sum(dhr, axis=1)

        return dhs, dh

 

이 구현에서도 Weight Sum 계층의 구현과 마찬가지로 Repeat와 Sum 연산이 등장합니다.

 

 ■ Decoder 개선 ③

전에 구현한 Attention Weight 계층과 Weight Sum 계층을 하나로 결합하겠습니다. Attention Weight 계층은 Encoder가 출력하는 각 단어의 벡터 hs에 주목하여 해당 단어의 가중치 a를 구합니다. 이어서 Weight Sum 계층이 a와 hs의 가중합을 구하고, 그 결과를 맥락 벡터 c로 출력합니다.

Encoder가 건네주는 정보 hs에서 중요한 원소에 주목하여, 그것을 바탕으로 맥락 벡터를 구해 위쪽 계층으로 전파합니다. 이 과정을 하는 Attention 계층을 구현하겠습니다.

 

class Attention:
    def __init__(self):
        self.params, self.grads = [], []
        self.attention_weight_layer = AttentionWeight()
        self.weight_sum_layer = WeightSum()
        self.attention_weight = None

    def forward(self, hs, h):
        a = self.attention_weight_layer.forward(hs, h)
        out = self.weight_sum_layer.forward(hs, a)
        self.attention_weight = a
        return out

    def backward(self, dout):
        dhs0, da = self.weight_sum_layer.backward(dout)
        dhs1, dh = self.attention_weight_layer.backward(da)
        dhs = dhs0 + dhs1
        return dhs, dh

 

이 코든느 2개의 계층(Weight Sum 계층과 Attention Weight 계층)에 의한 순전파와 역전파를 수행합니다. 이때 각 단어의 가중치를 나중에 참조할 수 있도록 attention_weight라는 인스턴스 변수에 저장합니다.

이제 이렇게 구현한 attention 계층을 LSTM 계층과 Affine 계층 사이에 삽입하면 됩니다.

 

각 시각의 Attention 계층에는 Encoder의 출력인 hs가 입력됩니다. 또 여기에서는 LSTM 계층의 은닉 상태 벡터를 Affine 계층에 입력합니다. 그리고 Affine 계층에 Attention을 계층을 거친 결과도 입력됩니다.

마지막으로 시계열 바향으로 펼쳐진 다수의 Attention 계층을 Time Attention 계층으로 모아 구현하겠습니다.

 

class TimeAttention:
    def __init__(self):
        self.params, self.grads = [], []
        self.layers = None
        self.attention_weights = None

    def forward(self, hs_enc, hs_dec):
        N, T, H = hs_dec.shape
        out = np.empty_like(hs_dec)
        self.layers = []
        self.attention_weights = []

        for t in range(T):
            layer = Attention()
            out[:, t, :] = layer.forward(hs_enc, hs_dec[:,t,:])
            self.layers.append(layer)
            self.attention_weights.append(layer.attention_weight)

        return out

    def backward(self, dout):
        N, T, H = dout.shape
        dhs_enc = 0
        dhs_dec = np.empty_like(dout)

        for t in range(T):
            layer = self.layers[t]
            dhs, dh = layer.backward(dout[:, t, :])
            dhs_enc += dhs
            dhs_dec[:,t,:] = dh

        return dhs_enc, dhs_dec

 

Attention 계층을 필요한 수만큼 만들고(코드에서 T개), 각각이 순전파와 역전파를 수행합니다. 또한, 각 Attention 계층의 각 단어의 가중치를 attention_weights 리스트에서 보관합니다.

 

8.2. 어텐션을 갖춘 seq2seq 구현

 ■ Encoder 구현

AttentionEncoder 클래스부터 구현하겠습니다. 앞 장에서 구현한 Encoder 클래스와 거의 같습니다. 앞 장에서 Encoder 클래스의 forward() 메서드는 LSTM 계층의 마지막 은닉 상태 벡터만을 반환했습니다. 이번에는 모든 은닉 상태를 반환하겠습니다.

 

import sys
sys.path.append('..')
from common.time_layers import *
from ch07.seq2seq import Encoder, Seq2seq
from ch08.attention_layer import TimeAttention


class AttentionEncoder(Encoder):
    def forward(self, xs):
        xs = self.embed.forward(xs)
        hs = self.lstm.forward(xs)
        return hs

    def backward(self, dhs):
        dout = self.lstm.backward(dhs)
        dout = self.embed.backward(dout)
        return dout

 

이와 같이 구현할 수 있습니다.

 

 ■ Decoder 구현

Attention 계층을 이용한 Decoder의 구현입니다. 앞 장의 구현과 마찬가지로 Softmax 계층의 앞까지를 Decoder로 구현하겠습니다. 순전파의 forward()와 역전파의 backward() 메서드뿐 아니라 새로운 단어열(혹은 문자열)을 생성하는 generate() 메서드도 추가했습니다.

 

class AttentionDecoder:
    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(2*H, V) / np.sqrt(2*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.attention = TimeAttention()
        self.affine = TimeAffine(affine_W, affine_b)
        layers = [self.embed, self.lstm, self.attention, self.affine]

        self.params, self.grads = [], []
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads

    def forward(self, xs, enc_hs):
        h = enc_hs[:,-1]
        self.lstm.set_state(h)

        out = self.embed.forward(xs)
        dec_hs = self.lstm.forward(out)
        c = self.attention.forward(enc_hs, dec_hs)
        out = np.concatenate((c, dec_hs), axis=2)
        score = self.affine.forward(out)

        return score

    def backward(self, dscore):
        dout = self.affine.backward(dscore)
        N, T, H2 = dout.shape
        H = H2 // 2

        dc, ddec_hs0 = dout[:,:,:H], dout[:,:,H:]
        denc_hs, ddec_hs1 = self.attention.backward(dc)
        ddec_hs = ddec_hs0 + ddec_hs1
        dout = self.lstm.backward(ddec_hs)
        dh = self.lstm.dh
        denc_hs[:, -1] += dh
        self.embed.backward(dout)

        return denc_hs

    def generate(self, enc_hs, start_id, sample_size):
        sampled = []
        sample_id = start_id
        h = enc_hs[:, -1]
        self.lstm.set_state(h)

        for _ in range(sample_size):
            x = np.array([sample_id]).reshape((1, 1))

            out = self.embed.forward(x)
            dec_hs = self.lstm.forward(out)
            c = self.attention.forward(enc_hs, dec_hs)
            out = np.concatenate((c, dec_hs), axis=2)
            score = self.affine.forward(out)

            sample_id = np.argmax(score.flatten())
            sampled.append(sample_id)

        return sampled

 

Time Attention 계층이 새롭게 사용되는 것을 제외하면 앞 장의 Decoder와 크게 다르지 않습니다. forward() 메서드에서 Time Attention 계층의 출력과 LSTM 계층의 출력을 연결해주는 점만 유의하면 됩니다.

 

 ■ seq2seq 구현

마지막으로 AttentionSeq2seq 클래스의 구현을 구현하겠습니다. 앞 장의 Seq2seq 클래스와 거의 같습니다. 다른 점은 Encoder 대신 AttentionEncoder 클래스를, Decoder 대신 AttentionDecoder 클래스를 사용한 것뿐입니다.

 

class AttentionSeq2seq(Seq2seq):
    def __init__(self, vocab_size, wordvec_size, hidden_size):
        args = vocab_size, wordvec_size, hidden_size
        self.encoder = AttentionEncoder(*args)
        self.decoder = AttentionDecoder(*args)
        self.softmax = TimeSoftmaxWithLoss()

        self.params = self.encoder.params + self.decoder.params
        self.grads = self.encoder.grads + self.decoder.grads

 

이상으로 어텐션을 갖춘 seq2seq 구현은 끝났습니다.

 

8.3. 어텐션 평가

날짜 형식을 변경하는 문제로 어텐션을 갖춘 seq2seq의 효과를 확인해보겠습니다. 데이터는 이 책에서 제공해주는 dataset/data.txt를 사용했습니다. 총 50000개의 데이터를 담고 있습니다.

 

데이터의 형식은 위와 같습니다. 이 데이터셋은 입력 문장의 길이를 통일하기 위해 공백 문자로 패딩했고, 입력과 출력의 구분 문자로 '_'을 사용했습니다. 그리고 출력의 문자 수는 일정하기 때문에 출력의 끝을 알리는 구분 문자는 따로 사용하지 않았습니다.

 

 ■ 어텐션을 갖춘 seq2seq의 학습

# coding: utf-8
import sys
sys.path.append('..')
sys.path.append('../ch07')
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 attention_seq2seq import AttentionSeq2seq
from ch07.seq2seq import Seq2seq
from ch07.peeky_seq2seq import PeekySeq2seq


# 데이터 읽기
(x_train, t_train), (x_test, t_test) = sequence.load_data('date.txt')
char_to_id, id_to_char = sequence.get_vocab()

# 입력 문장 반전
x_train, x_test = x_train[:, ::-1], x_test[:, ::-1]

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

model = AttentionSeq2seq(vocab_size, wordvec_size, hidden_size)
# model = Seq2seq(vocab_size, wordvec_size, hidden_size)
# model = PeekySeq2seq(vocab_size, wordvec_size, hidden_size)

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

    acc = float(correct_num) / len(x_test)
    acc_list.append(acc)
    print('정확도 %.3f%%' % (acc * 100))


model.save_params()

# 그래프 그리기
x = np.arange(len(acc_list))
plt.plot(x, acc_list, marker='o')
plt.xlabel('에폭')
plt.ylabel('정확도')
plt.ylim(-0.05, 1.05)
plt.show()

 

모델로 AttentionSeq2seq를 사용했습니다. 그리고 입력 문장을 반전시키는 Reverse 기법도 적용했습니다. 학습은 진행될수록 정확도가 올라가고 2 에폭부터 거의 모든 문제를 풀어냅니다. model의 형태에 따라 정확도와 학습 속도는 달라집니다. 정확도 측면에서 peekySeq2seq와 AttentionSeq2seq는 거의 동등했지만 학습 속도는 Attention이 더 빠릅니다. 그리고 현실의 시계열 데이터는 길고 복잡하므로, Attention 모델이 더 좋은 결과를 보여 줄 수 있습니다.

 

 

'Deep Learning(강의 및 책) > 밑바닥부터 시작하는 딥러닝 2' 카테고리의 다른 글

7. RNN을 사용한 문장 생성  (0) 2022.03.05
6. 게이트가 추가된 RNN  (0) 2022.03.03
5. 순환 신경망(RNN)  (0) 2022.03.02
4. word2vec 속도 개선  (0) 2022.02.26
3. word2vec  (0) 2022.02.23