본문 바로가기

책/밑바닥부터 시작하는 딥러닝 2

8장 어텐션 (1)

seq2seq의 문제점은 Encoder의 출력이 '고정 길이의 벡터'라는 것이다. 기계번역을 예로 들면 입력 문장이 길어지게 되면 필요한 정보를 충분히 인코딩할 수 없을 것이다. 따라서 이를 해결하기위해 Encoder와 Decoder를 개선해보자.

 

Encoder 개선

지금까지는 LSTM 계층의 마지막 은닉 상태만 Decoder에 전달했다. 그럼 모든 시각의 은닉 상태를 전달하게되면 Encoder는 고정 길이 벡터라는 제약으로부터 해방될 것이다. 그림으로는 다음과 같다.

 

 

(케라스에서는 return_sequences=True로 설정하면 모든 시각의 은닉 상태 벡터를 반환한다.)

 

$hs$의 각 단어에 대한 정보를 갖고 있는 벡터들의 집합이라고 볼 수 있다. Encoder의 개선은 이것이 전부다.

 

Decoder 개선 (1)

앞 장에서는 Decoder 계층이 $hs$의 마지막 행만 빼내어 Decoder에 전달하였다.

 

 

"I am a cat"을 우리가 머릿속으로 번역할 때 "I"를 보고 "나", "cat"을 보고 "고양이"라고 생각하면서 번역할 것이다. 즉 단어 사이의 대응 관계를 생각하는 것이다. seq2seq에게 $hs$를 전부 활용하면서 이 대응 관계를 학습시키도록 구조를 개선하는 것이 목적이다. 이를 위해 "어떤 계산"이 추가된다.

 

 

우리는 어떤 계산을 통해 decoder가 $hs$로 부터 대응하는 벡터를 선택하여 쓰도록 작업할 것이다. 그런데 '선택한다'라는 작업은 미분이 불가능하다. 이를 미분 가능한 연산으로 대체하기 위해서 '가중합'을 사용해보자.

 

 

그림처럼 우리는 가중치 $a$와 $hs$를 가중합하여 필요한 정보를 담고있는 '맥락 벡터'를 구할 것이다.

 

위의 그림에서 '나'에 대응하는 가중치가 0.8이므로 맥락 벡터 $c$에는 '나' 벡터의 성분이 많이 포함되어 있는 것이다.

이 과정을 코드로 구현해보자.

import numpy as np

# Encoder가 출력하는 hs와 각 단어의 가중치 a를 적당하게 작성하고, 그 가중합을 구하는 구현

T, H = 5, 4 # time size, hidden size
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) # (5, 4)

t = hs * ar # 아다마르 곱

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

 

가중합의 계산 그래프는 다음과 같다.

 

 

이를 클래스로 구현해보자.

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) # (N, T, H)
        dar = dt * hs
        dhs = dt * ar
        da = np.sum(dar, axis=2) # (N, T)

        return dhs, da

 

Decoder 개선 (2)

가중치 a를 구하는 방법을 알아보자. 우선 Decoder의 첫 번째 시각의 LSTM 계층의 은닉 상태 벡터를 처리하는 과정을 알아봐야 한다.

 

벡터 hs와 h가 얼마나 비슷한가를 알아보기 위해서는 벡터를 내적하는 방법이 있다.

 

이렇게 얻은 score를 softmax를 통해 정규화하여 가중치를 구한다.

 

가중치를 구하는 계산 그래프는 다음과 같다.

 

이를 클래스로 구현해보자.

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

 

Decoder 개선 (3)

이제 이 두 계층을 하나로 결합해보자.

 

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

Attention 계층이 포함된 Decoder의 구조는 다음과 같다.

 

Attention 계층의 출력(맥락 벡터)와 LSTM의 은닉 상태를 concat하여 Affine 계층의 입력으로 사용한다.

 

어텐션을 갖춘 seq2seq 구현

Encoder 구현

7장에서 구현했던 Encoder 클래스를 상속 받아 구현한다. 차이점은 forward시 모든 시각의 은닉 상태를 모두 리턴한다.

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 구현

어텐션을 이용한 Decoder의 계층 구성은 다음과 같다.

 

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
  • 초기화에서 self.attention이 추가되었다.
  • 이를 통해 forward에서 맥락 벡터 c를 계산하고 c와 decoder LSTM 계층의 모든시각의 은닉 상태 벡터를 concat하여 affine의 입력으로 사용한다.
    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

이를 조합하여 어텐션을 이용한 seq2seq 클래스를 구현한다.

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

 

어텐션 평가

다음과 같은 날짜 형식 변환 문제를 통해 평가하고자 한다.

 

 

학습 코드는 생략하고 앞 장의 모델과 비교한 결과를 확인해보자.

 

peeky보다 더 빨리 100% 정확도에 도달했다. 학습 데이터가 간단하여 peeky와 동등한 정확도를 얻지만 복잡한 문제를 다루게 된다면 어텐션이 유리할 것이다.

 

어텐션 시각화

어텐션의 장점은 attention_weights을 통해 모델이 어느 원소에 주의를 기울이는지 눈으로 살펴볼 수 있다.

 

seq2seq가 최초로 1을 출력할 때 입력 문장의 '1983'에서 1에 주목하고 있음을 알 수 있다. 다른 부분들도 마찬가지로 적절하게 대응하고 있음을 알 수 있다.