밑바닥부터 시작하는 딥러닝 - 어텐션(1)

앞에서 RNN을 사용해 문장을 생성해봤다. 그리고 2개의 RNN을 연결하여 하나의 시계열 데이터를 다른 시계열 데이터로 변환도 했었다. 이를 seq2seq라고 하며, 덧셈 같은 간단한 문제를 푸는 데 성공했다. 마지막으로, seq2seq에 몇 가지 개선을 적용한 결과, 간단한 덧셈이라면 거의 완벽하게 풀 수 있었다.

이번 글부터 seq2seq의 새로운 기법인 어텐션이라는 것에 대해 알아볼 것이다.

 


어텐션의 구조

지금까지 배운 seq2seq를 한층 더 강력하게 하는 어텐션 메커니즘이라는 기법에 대해 살펴보자. 어텐션이라는 메커니즘 덕분에 seq2seq는 필요한 정보에만 "주목"할 수 있게 된다.

 

seq2seq의 문제점

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

현재의 Encoder는 아무리 긴 문장이라도 고정 길이의 벡터로 변환한다. 하지만 이렇게 하면 필요한 정보가 벡터에 다 담기지 못하게 된다.

 


Encoder 개선

지금까지 LSTM 계층의 마지막 은닉 상태만을 Decoder에 전달했다. 그러나 Encoder 출력의 길이는 입력 문장의 길이에 따라 바꿔주는 게 좋다. 이 점이 Encoder의 개선 포인트이다.

위의 그림처럼 시각별 LSTM 계층의 은닉 상태 벡터를 모두 이용하는 것이다.

각 시각의 은닉 상태 벡터를 모두 이용하면 입력된 단어와 같은 수의 벡터를 얻을 수 있다. 위의 예에서는 5개의 단어가 입력되었고, 이때 Encoder는 5개의 벡터를 출력한다. 이것으로 Encoder는 '하나의 고정 길이 벡터'라는 제약으로부터 해방된다.

 

위의 그림에서 주목할 것은 LSTM 계층의 은닉 상태의 "내용"이다. 시각별 LSTM 계층의 은닉 상태에는 어떠한 정보가 담겨 있을까? 각 시각의 은닉 상태에는 직전에 입력된 단어에 대한 정보가 많이 포함되어 있다는 사실이다.

Encoder가 출력하는 hs 행렬은 각 단어에 해당하는 벡터들의 집합이라고 볼 수 있다.

더보기

Encoder는 왼쪽에서 오른쪽으로 처리하므로, 방금 전의 "고양이" 벡터에는 정확히 총 3개 단어("나", "는", "고양이")의 정보가 담겨 있다. 그런데 전체의 균형을 생각하여 "고양이" 단어의 "주변" 정보를 균형 있게 담아야 할 때도 있을 텐데, 그런 경우엔 시계열 데이터를 양방향으로 처리하는 양방향 RNN이 효과적이다.

이상이 Encoder의 개선이다.


Decoder 개선①

Encoder는 각 단어에 대응하는 LSTM 계층의 은닉 상태 벡터를 hs로 모아 출력한다. 그리고 이 hs가 Decoder에 전달되어 시계열 변환이 이뤄진다.

앞에서 본 가장 단순한 seq2seq에서는 Encoder의 마지막 은닉 상태 벡터만을 Decoder에 넘겼다.

Decoder는 Encoder의 LSTM 계층의 마지막 은닉 상태만을 이용한다. hs에서 마지막 줄만 빼내어 Decoder에 전달한 것이다.

 

그렇다면 이 hs 전부를 활용할 수 있도록 Decoder를 개선해 볼 것이다.

목표는 "도착어 단어"와 대응 관계에 있는 "출발어 단어"의 정보를 골라내는 것과 그 정보를 이용하여 번역을 수행하는 것이다. 이 구조를 어텐션이라 부른다.

 

전체적인 틀은 위의 그림과 같다. 새롭게 "어떤 계산"을 수행하는 계층을 추가할 것이다. 이 "어떤 계산"이 받는 입력은 두 가지로, 하나는 Encoder로부터 받는 hs이고, 다른 하나는 시각별 LSTM 계층의 은닉 상태이다. 그리고 여기에서 필요한 정보만 골라 위쪽의 Affine 계층으로 출력한다. 참고로, 지금까지와 똑같이 Encoder의 마지막 은닉 상태 벡터는 Decoder의 첫 번째 LSTM 계층에 전달한다. 

하지만 위의 신경망으로 하고 싶은 것은 단어들의 얼라이먼트 추출이다. 각 시각에서 Decoder에 입력된 단어와 대응 관계인 단어의 벡터를 hs에서 골라내는 것이다.

 

위의 그림에서 보듯이 각 단어의 중요도를 나타내는 "가중치"를 이용한다. a는 확률분포처럼 각 원소가 0.0 ~ 1.0 사이의 스칼라이며, 모든 원소의 총합은 1이 된다. 그리고 각 단어의 중요도를 나타내는 가중치 a와 각 단어의 벡터 hs로부터 가중합을 구하여, 원하는 벡터를 얻는다.

계산을 그려보면 아래 그림과 같아진다.

단어 벡터의 가중합을 계산한다. 여기에서는 결과를 "맥락 벡터"라 부르고, 기호로는 c로 표기한다.

"나"에 대응하는 가중치가 0.8이다. 이것이 의미하는 것은 맥락 벡터 c에는 "나" 벡터의 성분이 많이 포함되어 있다는 것이다. "나" 벡터를 "선택"하는 작업을 이 가중합으로 대체하고 있다고 할 수 있다. 예를 들어 "나"에 대응하는 가중치가 1이고 그 외에는 0이라면, "나" 벡터를 "선택"한다고 해석할 수 있다.

 

그럼 코드를 통해 살펴보자. Encoder가 출력하는 hs와 각 단어의 가중치 a를 적당하게 작성하고, 그 가중합을 구하는 구현을 볼 수 있다.

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)
print(ar.shape)

t = hs * ar
print(t.shape)

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

시계열의 길이는 T = 5, 은닉 상태 벡터의 원소 수를 H = 4로 하여 가중합을 구하는 과정이다. ar = a.reshape(5, 1).repeat(4, axis = 1) 코드는 a를 아래 그림처럼 ar로 변환한다.

형상이 (5,)인 a를 복사하여, (5, 4)짜리 배열을 만드는 것이다. 원래 형상이 (5,)인 a를 a.reshape(5, 1)을 거쳐 (5, 1) 형상으로 성형한 다음, 이 배열의 한 축을 네 번 반복하여 형상이 (5, 4)인 배열을 생성한다.

더보기

repeat() 메서드는 다차원 배열의 원소를 복사하여 새로운 다차원 배열을 생성한다. x가 넘파이 다차원 배열일 때, x.repeat(rep, axis) 형태로 사용할 수 있다. 인수 rep는 복사를 반복하는 횟수, axis는 반복하는 축(차원)을 지정한다. 예를 들어, x의 형상이 (X, Y, Z)일 때, x.repeat(3, axis = 1)을 실행하면 x에서 인덱스가 1인 축이 복사되어 형상이 (X, 3 * Y, Z)인 다차원 배열이 만들어진다.


계속해서 미니배치 처리용 가중합을 구현한다. 코드로 살펴보자.

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
print(t.shape)

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

여기에서는 Repeat 노드를 사용해 a를 복제한다. 이어서 'X' 노드로 원소별 곱을 계산한 다음 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) # repeat의 역전파
        
        return dhs, da

맥락 벡터를 구하는 Weight Sum 계층의 구현이다. 이 계층은 학습하는 매개변수가 없으므로, self.params = []로 설정한다.

TAGS.

Comments