Deep Learning/from scratch II

[밑바닥부터 시작하는 딥러닝 2] - 8장 어텐션 I

해파리냉채무침 2024. 2. 22. 19:46

어텐션의 구조

어텐션 메커니즘은 seq2seq를 한 층 더 강력하게 해준다.

seq2seq의 문제점

seq2seq에서는 encoder가 시계열 데이터를 인코딩한다. 이때의 출력은 고정 길이 벡터인데, 고정길이는 입력 문장의 길이가 아무리 길어도 항상 같은 길이의 벡터로 변환 한다는 특징이 있다. 

https://velog.io/@jw5150/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-8%EC%9E%A5

이러한 seq2seq의 문제점은 필요한 정보가 벡터에 다 담기지 못하게 된다

Encoder 개선

여태까지 encoder에서 LSTM 계층 마지막 은닉 상태만을 decoder에 전달했다.  encoder 출력 길이는 입력문장의 길이에 따라 바꿔주는 것이 개선 포인트이다. 

각 단어의 은닉 상태 벡터를 모두 이용하면 입력된 단어와 같은 수의 벡터를 얻을 수 있다. 5개가 입력되었고, encoder는 5개의 벡터를 출력한다. 시각별 LSTM 계층의 은닉상태에서는 직전에 입력된 단어에 대한 정보가 많이 포함되어있다.

encoder가 출력하는 hs 행렬은 각 단어에 해당하는 벡터들의 집합이다.

https://velog.io/@jw5150/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-8%EC%9E%A5

많은 딥러닝 프레임워크에서는 RNN 계층(LSTM, GRU) 를 초기화 할때, '모든 시각의 은닉상태 벡터 반환', '마지막 은닉상태 벡터만 반환' 둘 중 하나만 선택할 수 있다. keras로 할때 RNN 계층의 초기화 인수로 return_sequences= True 면 모든 시각의 은닉 상태 벡터를 반환한다.

Decoder 개선

encoder의 LSTM 계층의 마지막 은닉 상태를 Decoder의 LSTM 계층의 첫 은닉상태로 설정하였다.

이 hs를 전부 활용하여 decoder를 개선한다. 

도착어 단어와  출발어 단어의 정보를 골라낸다. 즉슨 필요한 정보에만 주목하여 그 정보로부터 시계열 변환을 수행하는 것이다. 이 구조를 어텐션이라고 한다. 

https://velog.io/@jw5150/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-8%EC%9E%A5

Encoder로 부터 받는 hs의 마지막 은닉상태 벡터는 LSTM에 들어가고, 1) 나머지는 '어떤 계산' 층에 들어간다. 2) 시각별 LSTM 계층의 은닉상태도 '어떤 계산'층에 입력된다. 그리고 여기서 필요한 정보만 골라 위쪽의 affine 계층으로 출력된다. 

각 시각에서 '어떤 계산'으로 decoder에 입력된 단어와 대응관계인 단어의 벡터를 hs에서 골라낸다.여기서 문제가 있다면 여러 대상으로부터 몇개를 선택하는 작업은 미분할 수 없다는 점이다. 미분가능한 연산을 이용하지 않으면 오차역전파법을 사용할 수 없다. 

 

'선택한다' 라는 작업을 미분 가능한 연산으로 대체하기 위해 가중치를 별도로 계산하도록 한다.

 

각 단어의 중요도를 나타내는 가중치(기호a)를 이용한다. a는 확률분포처럼 각 원소가 0.0~1.0 사이의 스칼라(단일 원소) 이며, 모든 총합은 1이다. 가중치 a와 각 단어의 벡터 hs로 부터 가중합을 구한다.

가중합을 계산한 벡터 C를 맥락 벡터라고 한다. '나'에 대한 가중치가 0.8인데, 맥락 벡터 C에는 '나' 벡터 성분이 많이 포함되어 있다는 뜻이다. 

import numpy as np
T,H = 5,4 #시계열 길이 5, 은닉 상태 벡터 원소수 4
hs = np.random.randn(T,H)
a = np.array([0.8, 0.1, 0.03, 0.05, 0.02]) #hs 단어 가중치 

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

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

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

ar = a.reshape(5,1).repeat(4,axis=1) 를 설명하면,

0.8 0.1 0.03 0.05 0.02

a = (5,) 형상을 reshape을 거쳐

0.8
0.1
 
 
 

(5,1)인 ar 형상으로 만든다음 , 이배열의 한 축을 네번 반복하여 형상이 (5,4)인 배열을 생성한다.

0.8 0.8 0.8 0.8
0.1      
0.03      
0.05      
0.02      

 

만약 X의 형상이 (X,Y,Z) 일때, x.repeat(3,axis=1)을 실행하면 인덱스가 1인축이 복사되어 형상이 (x,3Y,Z) 인 다차원 배열이 만들어진다. 

repeat() 대신 넘파이의 브로드캐스트를 사용해도 된다. ar = a.reshape(5,1)까지만 하고 곧바로 hs*ar을 계산해도 된다.

브로드캐스팅 예제는 밑의 블로그를 통해 공부했다

https://velog.io/@jhdai_ly/%EB%84%98%ED%8C%8C%EC%9D%B4Numpy%EB%B8%8C%EB%A1%9C%EB%93%9C%EC%BA%90%EC%8A%A4%ED%8C%85Broadcasting

 

[넘파이(Numpy)]브로드캐스팅(Broadcasting) - 브로드캐스팅 조건, 예제

넘파이에서 서로 다른 모양(shape)의 배열도 일정 조건을 만족하면 연산할 수 있는데, 이 똑똑한 기능을 브로드캐스팅(Broadcasting)이라고 합니다. 브로드캐스팅1\. 원소가 하나인 배열은 어떤 배열

velog.io

만약 x의 형상이 (X,Y,Z) 일때, np.sum(x,axis=1)을 실행하면 출력형상은 (X,Z) 이다. 1번째 축이 사라진다고 생각하면 된다.

위의 코드에서  np.sum(t,axis=0) 을 보면 0번째 축을 사라지게 하여 형상이 (4,)인 행렬이 구해진다.

미니배치 처리용 가중합 코드는 다음과 같다.

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)
#ar = a.reshape(N,T,1) #브로드캐스트

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

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

https://techblog-history-younghunjo1.tistory.com/493

 

repeat 노드에서 a를 복제하고, X 노드에서 원소별 곱을 계산한 다음 sum노드로 합을 구한다.

역전파를 보면 repeat의 역전파는 sum, sum의 역전파는 repeat이다.

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


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(T, axis=1)
        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) #repeat의 역전파

        return dhs, da

decoder 개선 2

각 단어의 중요도를 나타내는 가중치 a를 구하는 방법을 살펴본다.

Decoder의 LSTM 계층의 은닉상태 벡터를 h라고 한다. h가 hs의 각 단어 벡터와 얼마나 비슷한가를 수치로 나타내는데, 이거를 내적을 계산한다.

a= (a1,a2,..an)와 b= (b1,b2,...bn)의 내적은 다음과 같이 계산한다.

 

a·b = a1b1+ a2b2 + .... anbn

두 벡터가 얼마나 같은 방향을 향하고 있는가를 의미한다. 

https://velog.io/@jw5150/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-8%EC%9E%A5

벡터의 내적을 이용해 h와 hs의 각 단어 벡터와의 유사도를 구한다. s는 정규화 하기 전의 값이고, score라고 한다. s를 정규화 하기 위해 softmax 함수를 적용한다. 

softmax 함수를 적용한 값들의 각 원소는 0~1, 합이 1이되도록 한다. 

import sys
sys.path.append('..')
from 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,T)
hr = a.reshape(N,1,H).repeat(T,axis=1)
#hr = a.reshape(N,1,H) #브로드캐스트

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

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

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

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

repeat 노드, 원소별 곱을 뜻하는 X노드, sum, softmax 계층으로 구성된다. 

https://yerimoh.github.io/DL19/

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) #repeat 노드
        dhs = dt * hr
        dhr = dt * hs
        dh = np.sum(dhr, axis=1) #sum노드

        return dhs, dh

Decoder 개선 3

attention weight와 weight sum 계층 두개로 나눠 구현했다. 

attention weight계층은 encoder가 출력하는 각 단어 벡터 hs에 주목하여 가중치 a를 구한다. weight sum 계층이 a와 hs의 가중합을 구하고, 그결과 맥락 벡터 c를 출력한다 

이것을 통틀어 attention 계층이라고 한다. 

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

Attention 계층을 구현한 코드는 다음과 같다. 이 계층을 LSTM 계층과 Affine 계층 사이에 삽입한다

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_weight라는 인스턴스 변수에 저장한다. 

https://velog.io/@jw5150/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-8%EC%9E%A5

각 attention 계층에는 encoder의 출력인 hs가 입력된다. LSTM 계층의 은닉상태 벡터를 affine 계층에 입력된다. 

오른쪽은 앞장의 decoder에 attention 계층이 구한 맥락 벡터 정보를 추가한것이다. affine 계층에는[ LSTM 계층의 은닉상태 + attention 계층 맥락 벡터](두 벡터를 연결한 벡터)가 입력된다. 

시각 T개로 연결된 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) #hs_dec와 같은 shape를 가지는 out 배열 초기화
        self.layers = []
        self.attention_weights = [] #attention 계층의 각 단어의 가중치를 해당 리스트에 보관

        for t in range(T):
            layer = Attention() #어텐션 계층을 T만큼 만들음.
            out[:, t, :] = layer.forward(hs_enc, hs_dec[:,t,:]) #hs_enc와 hs_dec의 해당 시간  t 단계의 은닉 상태 입력 
            self.layers.append(layer)
            self.attention_weights.append(layer.attention_weight) #attention 계층과 가중치 저장

        return out

    def backward(self, dout):
        N, T, H = dout.shape
        dhs_enc = 0 #dout와 같은 shape를 가지는 dhs_enc 배열 초기화
        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