Attention

이번에는 어텐션의 동작 방식과 원리를 알아보고 파이토치 예제 코드를 살펴보겠습니다.

어텐션 이해하기

어텐션attention은 쿼리query와 비슷한 값을 가진 키ley를 찾아서 그 값value을 얻는 과정입니다. 여기서는 흔히 json이나 프로그래밍에서 널리 사용하는 key-value 방식과 비교하면서 어텐션을 설명하겠습니다.

key-value 함수

어텐션을 본격적으로 소개하기 전에 먼저 우리에게 익숙한 자료형을 살펴보겠습니다. 키-밸류key-value 또는 파이썬에서 딕셔너리dictionary라고 부르는 자료형입니다. 파이썬에서는 다음과 같이 사용할 수 있습니다.

>>> dic = {'computer': 9, 'dog': 2, 'cat': 3}

이와 같이 key와 value에 해당하는 값들을 넣고 key를 통해 value 값에 접근할 수 있습니다. 다시말하면, 쿼리가 주어졌을 때 key 값에 따라 value 값에 접근할 수 있습니다. 앞의 작업을 함수로 나타내면 다음과 같이 표현할 수 있을 겁니다(물론 실제 파이썬 딕셔너리의 동작은 매우 다릅니다).

def key_value_func(query):
    weights = []

    for key in dic.keys():
        weights += [is_same(key, query)]

    weight_sum = sum(weights)
    for i, w in enumerate(weights):
        weights[i] = weights[i] / weight_sum

    answer = 0

    for weight, value in zip(weights, dic.values()):
        answer += weight * value

    return answer

def is_same(key, query):
    if key == query:
        return 1.
    else:
        return .0

코드를 살펴보면, 순차적으로 dic 변수 내부의 key 값들과 query 값을 비교하여 key가 같을 경우 weights 변수에 1.0을 더하고, 다를 경우에는 0을 더합니다. 그리고 weights를 weights의 총합으로 나누어 그 합이 1이 되도록 만들어줍니다. 다시 dic 내부의 value 값들과 weights의 값에 대해 곱하여 더해줍니다. 즉, weight가 1.0인 경우에만 value 값을 answer 에 더합니다.

연속적인 key-value 함수

더 발전시켜서 is_same 함수 대신 다른 함수를 써보면 어떻게 될까요? key와 query 사이의 유사도를 반환하는 how_similar 라는 가상 함수가 있다고 가정해봅시다.

>>> query = 'puppy'
>>> how_similar('computer', query)
0.1
>>> how_similar('dog', query)
0.9
>>> how_similar('cat', query)
0.7

해당 함수에 puppy라는 단어를 테스트했더니 앞에서와 같은 값들을 반환했다고 합시다. 그럼 다음과 같이 실행될 겁니다.

>>> query = 'puppy'
>>> key_value_func(query)
2.823 # = .1 / (.9 + .7 + .1) * 9 + .9 / (.9 + .7 + .1) * 2 + .7 / (.9 + .7 + .1) * 3

2.823라는 값이 나왔습니다. 강아지와 고양이, 그리고 컴퓨터의 유사도의 비율에 따른 dic의 값의 비율을 지녔다라고 볼 수 있습니다. issame 함수를 쓸 때는 두 값이 같은지 if 문을 통해 검사하고 값을 할당했으므로, 0과 1로만 이루어진 불연속적인 값이었습니다. 하지만 how similar 함수를 통해 0에서 1사이의 연속적인 값을 weights에 할당하여 key_value_func 함수를 수행합니다. 이제 우리는 key_value_func을 딥러닝에 사용할 수 있습니다.

연속적인 key-value 벡터 함수

만약, dic의 value에는 100차원의 벡터로 들어 있었다면 어떻게 될까요? 추가로 쿼리와 key 값 모두 벡터를 다룬다면 어떻게 될까요? 즉, 단어 임베딩 벡터라면? 그리고 how_similar 함수는 이 벡터들 간의 코사인 유사도를 반환해주는 함수라면? 마지막으로 dic의 key 값과 value 값이 서로 같다면 어떻게 될까요?

이를 위해 다시 가상의 함수를 만들어보겠습니다. word2vec 함수는 단어를 입력으로 받아 그 단어에 해당하는 미리 정해진 단어 임베딩 벡터를 반환해준다고 가정합니다. 그럼 조금 전의 how_similar 함수는 두 벡터 간의 코사인 유사도 값을 반환할 겁니다.

def key_value_func(query):
    weights = []

    for key in dic.keys():
        # cosine similarity 값을 채워 넣는다.
        weights += [how_similar(key, query)]

    # 모든 weight들을 구한 후에 softmax를 계산한다.
    weights = softmax(weights)
    answer = 0

    for weight, value in zip(weights, dic.values()):
        answer += weight * value

    return answer

이번에 key_value_func는 그 값을 받아 weights에 저장한 후, 모든 weights의 값이 채워지면 softmax 함수를 취할 겁니다. 여기서 softmax는 weights의 합의 크기를 1로 고정시키는 정규화 역할을 합니다. 따라서 유사도의 총합에서 차지하는 비율만큼 weight의 값이 채워질 겁니다.

>>> len(word2vec('computer'))
100
>>> word2vec('dog')
[0.1, 0.3, -0.7, 0.0, ...
>>> word2vec('cat')
[0.15, 0.2, -0.3, 0.8, ...
>>> dic = {word2vec('computer'): word2vec('computer'),
           word2vec('dog'): word2vec('dog'),
           word2vec('cat'): word2vec('cat')
           }
>>>
>>> query = 'puppy'
>>> answer = key_value_func(word2vec(query))

이제 answer의 값에는 어떤 벡터 값이 들어 있을 겁니다. 그 벡터는 puppy 벡터와 dog, computer, cat 벡터들의 코사인 유사도에 따라 값이 정해집니다. 즉, 이 함수는 query와 비슷한 key 값을 찾아서 비슷한 정도에 따라 weight를 정하고, 각 key의 value 값을 weight 값만큼 가져와서 모두 더하는 것입니다. 이것이 바로 어텐션의 원리입니다.

기계번역에서의 어텐션

그럼 번역에서 어텐션은 어떻게 작용할까요? 번역 과정에서는 인코더의 각 time-step별 출력을 키와 밸류로 삼고, 현재 time-step의 디코더 출력을 쿼리로 삼아 어텐션을 계산합니다.

항목

구성

Query

현재 time-step의 디코더의 출력

Keys

각 time-step 별 인코더의 출력

Values

각 time-step 별 인코더의 출력

>>> context_vector = attention(query = decoder_output,
                               keys = encoder_outputs,
                               values = encoder_outputs
                               )

어텐션을 추가한 seq2seq의 수식은 다음과 같은 부분이 추가됩니다.

w=softmax(httgtTWHsrc)c=Hsrcw and c is a context vector.h~ttgt=tanh(linear2×hshs([httgt;c]))y^t=softmax(linearhsVtgt(h~ttgt))where hs is hidden size of RNN, and Vtgt is size of output vocabulary.\begin{gathered} w = \text{softmax}({h_{t}^{\text{tgt}}}^T W \cdot H^{src}) \\ c = H^{src}\cdot w\text{ and }c\text{ is a context vector}. \\ \\ \tilde{h}_{t}^{\text{tgt}}=\tanh(\text{linear}_{2\times hs\rightarrow hs}([h_{t}^{\text{tgt}}; c])) \\ \hat{y}_{t}=\text{softmax}(\text{linear}_{hs\rightarrow|V_{\text{tgt}}|}(\tilde{h}_{t}^{\text{tgt}})) \\ \\ \text{where }hs\text{ is hidden size of RNN, and }|V_{\text{tgt}}|\text{ is size of output vocabulary}. \end{gathered}

원하는 정보를 어텐션을 통해 인코더에서 획득한 후, 해당 정보를 디코더의 출력과 이어붙여 $tanh$ 를 취한 후, softmax 계산을 통해 다음 time-step의 입력이 되는 $\hat{y}_{t}$ 을 구합니다.

선형 변환

이때 각 입력 파라미터를 추상적으로 예상해본다면 다음과 같습니다. 물론 실제 신경망 내부의 값은 이보다 훨씬 복잡하고 해석할 수 없는 값으로 채워져 있을 겁니다.

항목

의미

decoder_output

현재 time-step까지 번역된 타깃 언어의 단어 또는 문장, 의미

encoder_outputs

각 time-step에서의 소스 언어의 단어 또는 문장, 의미

신경망 내부의 각 차원들은 숨겨진 특징latent feature 값이므로 딱 잘라 정의할 수 없습니다. 하지만 분명한 것은 소스 언어와 대상 언어가 애초에 다르다는 점입니다. 따라서 단순히 벡터 내적을 해주기보다는 소스 언어와 대상 언어 사이에 연결고리를 하나 놓아주어야 할 겁니다. 따라서 두 언어가 각각 임베딩된 잠재 공간latent space이 선형 관계에 있다고 추상적으로 가정하고, 내적 연산을 수행하기 전에 선형 변환linear transformation을 해줍니다. 이 선형 변환을 위한 $W$ 값은 신경망 가중치 파라미터로써, 피드포워드 및 역전파를 통해 학습됩니다.

왜 어텐션이 필요한 걸까요? 기존의 seq2seq는 인코더와 디코더라는 두 개의 RNN으로 이루어져 있습니다. 여기서 문장 임베딩 벡터에 해당하는 인코더 결과 벡터의 정보를 디코더의 은닉 상태(LSTM의 경우에는 cell state가 추가)로 전달해야 합니다. 그리고 디코더는 인코더로부터 넘겨받은 은닉 상태로부터 문장을 만들어냅니다. 은닉 상태만으로는 문장의 모든 정보를 완벽하게 전달하기 어렵기 때문입니다. 특히 문장이 길어질수록 이 문제는 더 심각해집니다. 따라서 디코더의 time-step마다 현재 디코더의 은닉 상태에 따라 필요한 인코더의 정보(인코더 마지막 계층의 은닉 상태)에 접근하여 끌어다 쓰겠다는 것입니다.

이 선형 변환을 배우는 것 자체가 어텐션이라고 표현해도 과하지 않습니다. 선형 변환 과정을 통해서 디코더의 현재 상태에 따라 필요한 쿼리를 만들어내고, 인코더의 key 값들과 비교하여 가중합weighted sum을 하는 것이기 때문입니다. 즉, 어텐션을 통해 디코더는 인코더에 쿼리를 날리는 것인데, 쿼리를 잘 날려야 좋은 정답을 얻을 것입니다.

예를 들어 어떤 정보에 관해 알고자 할 때, 구글링 또는 네이버를 검색한다면 검색창에 검색을 잘하기 위한 쿼리를 만들어 검색 버튼을 누를 것입니다. 그 쿼리의 질에 따라 검색 결과의 품질은 천차만별일 것입니다. 그리고 같은 정보를 알고자 할 때도, 쿼리를 만들어내는 능력에 따라 검색 결과를 얻을 수 있느냐 없느냐는 달라질 것입니다. 한마디로 여러분의 부모님이나 아주 어린 자녀들이 여러분보다 구글링에 서툰 것은 당연합니다. 하지만 쿼리를 만들어내는 훈련을 한다면 그들의 검색능력 역시 좋아질 것입니다.

마찬가지로 신경망도 쿼리를 만들어내는 훈련을 하는 것이라고 볼 수 있습니다. 따라서, 현재 디코더의 상태에 따라 필요한 정보가 무엇인지를 스스로 판단하여 선형 변환을 통해 쿼리를 만들어낼 것입니다. 또한, 선형 변환을 위한 가중치 파라미터 자체도 한계가 있으므로, 디코더의 상태 자체가 선형 변환이 되어 쿼리가 좋은 형태가 되도록 RNN이 동작할 것입니다.

어텐션의 적용 결과

어텐션을 사용하지 않은 seq2seq는 전반적으로 성능이 떨어짐을 알 수 있을뿐만 아니라, 특히 문장이 길어질수록 성능이 더욱 하락함을 알 수 있습니다. 하지만 이에 비해서 어텐션을 사용하면 문장이 길어지더라도 성능이 크게 하락하지 않음을 알 수 있습니다.

![어텐션의 사용여부에 따른 문장 길이별 번역 성능

Stanford University CS224n 수업 슬라이드에서 발췌, 인용했습니다.

파이토치 예제 코드

torch.bmm 함수는 배치 행렬 곱batch matrix multiplication(BMM) 을 수행하는 함수로써, 2개 이상의 차원을 지닌 텐서가 주어졌을 때 뒤의 2개 차원에 대해 행렬 곱을 수행하고 앞의 다른 차원은 미니배치로 취급합니다. 따라서 앞의 차원들은 크기가 같아야 하고, 뒤의 2개 차원은 행렬 곱을 수행하기 위한 적절한 크기를 지녀야 합니다.

import torch

# |x| = (batch_size, n, k)
# |y| = (batch_size, k, m)
z = torch.bmm(x, y)
# |z| = (batch_size, n, m)

어텐션 클래스

선형 변환을 위한 가중치 파라미터를 편향bias 이 없는 선형 계층로 대체했음을 확인할 수 있습니다. 추가적인 자세한 설명은 이후 섹션에서 하도록 합니다. 다음 코드는 저자의 깃허브에서 볼 수 있습니다.

깃허브 리포지터리URL: https://github.com/kh-kim/simple-nmt 파일 URL: https://github.com/kh-kim/simple-nmt/blob/master/simple_nmt/seq2seq.py

class Attention(nn.Module):

    def __init__(self, hidden_size):
        super(Attention, self).__init__()

        self.linear = nn.Linear(hidden_size, hidden_size, bias=False)
        self.softmax = nn.Softmax(dim=-1)

    def forward(self, h_src, h_t_tgt, mask=None):
        # |h_src| = (batch_size, length, hidden_size)
        # |h_t_tgt| = (batch_size, 1, hidden_size)
        # |mask| = (batch_size, length)

        query = self.linear(h_t_tgt.squeeze(1)).unsqueeze(-1)
        # |query| = (batch_size, hidden_size, 1)

        weight = torch.bmm(h_src, query).squeeze(-1)
        # |weight| = (batch_size, length)
        if mask is not None:
            # Set each weight as -inf, if the mask value equals to 1.
            # Since the softmax operation makes -inf to 0,
            # masked weights would be set to 0 after softmax operation.
            # Thus, if the sample is shorter than other samples in mini-batch,
            # the weight for empty time-step would be set to 0.
            weight.masked_fill_(mask, -float('inf'))
        weight = self.softmax(weight)

        context_vector = torch.bmm(weight.unsqueeze(1), h_src)
        # |context_vector| = (batch_size, 1, hidden_size)

        return context_vector

Last updated