본문 바로가기
AI 관련/논문 구현

[논문 구현] Attention Is All You Need

by 세계 최고의 AI Engineer_naknak 2023. 7. 26.

출처 : https://cpm0722.github.io/pytorch-implementation/transformer

 

[NLP 논문 구현] pytorch로 구현하는 Transformer (Attention is All You Need)

Paper Link

cpm0722.github.io

저도 http://nlp.seas.harvard.edu/2018/04/03/attention.html 하버드 NLP를 보고 따라하고 싶었지만 그렇게하면 시간이 진짜 너무 오래 걸릴거 같아서 이미 고생해주신 cpm님을 따르기로 했습니다...ㅎ

 

ps. 와 근데 진짜 양이 쉽지 않네요 ㅎㅎ...

Transformer

 

Transformer에서 input sentence는 전처리되서 tokenziation, word2idx, embedding을 거친 이후 input 되고 총 6개의 encoding층을 거친 이후 context  벡터가 된다. 이때 shape은 input sentence 일때랑 같습니다!

Encoder 6 layers

Encoder는 왜 여러 개의 block을 겹쳐 쌓는 것일까? 각 Encoder Block의 역할은 무엇일까? 결론부터 말하자면, 각 Encoder Block은 input으로 들어오는 vector에 대해 더 높은 차원(넓은 관점)에서의 context를 담는다. 높은 차원에서의 context라는 것은 더 추상적인 정보라는 의미이다. Encoder Block은 내부적으로 어떠한 Mechanism을 사용해 context를 담아내는데, Encoder Block이 겹겹이 쌓이다 보니 처음에는 원본 문장에 대한 낮은 수준의 context였겠지만 이후 context에 대한 context, context의 context에 대한 context … 와 같은 식으로 점차 높은 차원의 context가 저장되게 된다.

라고 하시네요!

 

Attention이라는 것은 넓은 범위의 전체 data에서 특정한 부분에 집중한다는 의미

The animal didn’t cross the street, because it was too tired.

위 문장에서 ‘it’은 무엇을 지칭하는 것일까? 사람이라면 직관적으로 ‘animal’과 연결지을 수 있지만, 컴퓨터는 ‘it’이 ‘animal’을 가리키는지, ‘street’를 가리키는지 알지 못한다. Attention은 이러한 문제를 해결하기 위해 두 token 사이의 연관 정도를 계산해내는 방법론이다. 위의 경우에는 같은 문장 내의 두 token 사이의 Attention을 계산하는 것이므로, Self-Attention이라고 부른다. 반면, 서로 다른 두 문장에 각각 존재하는 두 token 사이의 Attention을 계산하는 것을 Cross-Attention이라고 부른다.

 

Wow... 설명이 정말 주옥 같으시네요..!

RNN에 비해 Self-Attention은 병렬 처리가 가능하며

Self-Attention은 문장에 token이 n개 있다고 가정할 경우, n×n번 연산을 수행해 모든 token들 사이의 관계를 직접 구해낸다. 중간의 다른 token들을 거치지 않고 바로 direct한 관계를 구하는 것이기 때문에 Recurrent Network에 비해 더 명확하게 관계를 잡아낼 수 있다.

 

Attention 계산에는 Query, Key, Value라는 3가지 vector가 사용된다. 각 vector의 역할을 정리하면 다음과 같다.

  1. Query: 현재 시점의 token을 의미
  2. Key: attention을 구하고자 하는 대상 token을 의미
  3. Value: attention을 구하고자 하는 대상 token을 의미 (Key와 동일한 token)

위 문장을 예시로 들면 Query에 it이 들어간다면  Key, Value는 서로 완전히 같은 token을 가리키는데, 문장의 시작부터 끝까지 모든 token들 중 하나가 될 겁니다. Key와 Value가 ‘The’를 가리킬 경우 ‘it’ ‘The’ 사이의 attention을 구하는 것이고, Key와 Value가 마지막 ‘tired’를 가리킬 경우 ‘it’ ‘tired’ 사이의 attention을 구하는 것이 된다. 즉, Key와 Value는 문장의 처음부터 끝까지 탐색한다고 이해하면 된다 라고 하십니다.

 

그렇다면 이 Q, K, V가 어떤 값을 가지고 있을까?

input으로 들어오는 token embedding vector를 fully connected layer에 넣어 세 vector를 생성해낸다. 세 vector를 생성해내는 FC layer는 모두 다르기 때문에, 결국 self-attention에서는 Query, Key, Value를 구하기 위해 3개의 서로 다른 FC layer가 존재한다. 각각 별개의 FC layer로 구해진 Query, Key, Value가 구체적인 값은 다를지언정 같은 shape를 갖는 vector가 된다는 것을 알 수 있다. 정리하자면, Query, Key, Value의 shape는 모두 동일하다. 앞으로 이 세 vector의 dimension을 dk로 명명한다 랍니다. 그리고 여기서 k는 key 를 의미하는데 딱히 커다란 의미가 없다고 하니 넘어가도 될 거 같습니다.

 

Attention Score on Query

이때 필자는 Attention이 Query에 대한 Attention이라고 꼭 짚고 넘어가야한다고 하시네요

계산의 흐름

그러면 위 인용된 문장에서 it과 animal의 attention을 구하는 과정을 예를 들어 shape 등 설명해보도록 하겠습니다.

 

만약 dk 가 3이라면 위와 같이 그려질 겁니다. 이때 Q, K, V는 각각의 서로 다른 fully layer를 거쳤기 때문에 다 다른 값을 가지고 있죠. Query와 Key의 내적을 구하면 Attention score가 계산됩니다. 이 score는 특정 Query(주의를 기울일 대상 단어)가 다른 단어(Key)와 얼마나 관련이 있는지, 즉 얼마나 집중해야 하는지를 나타냅니다.

그 다음, 이 score들을 softmax 함수를 사용하여 정규화합니다. 이렇게 하면 모든 score들의 합이 1이 되므로, 각 score는 확률처럼 해석할 수 있습니다. 이 확률 값은 Query가 각 Key에 얼마나 "집중"해야 하는지를 나타내며, 이를 사용하여 각 Key와 연관된 Value의 가중합을 계산합니다.

따라서, Attention score는 Query가 Key에 얼마나 집중해야 하는지를 나타내며, 이를 통해 각 단어가 문장에서 다른 단어에 얼마나 의존하는지를 모델링할 수 있습니다

 

네 그렇습니다. 내적을 하면 attention score이 나오고 이를 softmax로 Q가 k에 얼마나 집중해야 하는 지를 나타낼 수 있게 합니다.

 

이렇게 1x1 shape이 만들어집니다. 이제 shape이 다른 attention score를 구해볼까요?

 

 Q의 token과 문장 내 모든 token들 사이의 Attention Score를 각각 계산한 뒤 concatenate한 것과 동일하다. 이를 행렬곱 1회로 수행한 것이다. 그래서 모든 token이 Q 가 되고 행렬곱을 수행한다고 하면 연산에 필요한 복잡도는 O(n*n*d)가 될겁니다! n => token화된 단어의 수, d => embedding size를 의미하죠.

그리고 이 값을 softmax에 넣어서 Value와 행렬곱을 해줍니다.

이건 Value에 Q의 token과 해당 token이 얼마나 Attention을 갖는지(얼마나 연관성이 짙은지)에 대한 비율(확률값)을 반영하겠다는 의미를 뜻합니다. 그래서 나온 shape 1xdk 인 Query's Attention이 나오게 됩니다. 따라서 이 Attention은 Query가 문장 내에서 모든 token들 중 어떤 Token에 집중을 할지를 나타내는 값이라고 할 수 있게 되는 거죠.

이때 중요한 것은 Shape입니다.

이렇게 구해진 최종 result는 기존의 Q, K, V와 같은 dimension(dk)를 갖는 vector 1개임을 주목하자. 즉, input으로 Q vector 1개를 받았는데, 연산의 최종 output이 input과 같은 shape를 갖는 것이다.

input이 Q, K, V 긴하지만 개념 상으로는 Q에 대한 Attention을 의미하는 것이므로 semantic(의미론적) 측면에서 input은 Q라고 볼 수 있다 고 하십니다.

 

이제 이를 더 확장해서 it 이라는 단어뿐 아니라 다른 단어 또한 Query로 만들면 아래와 같은 구조가 그려집니다.

 

참, 똑똑하시네요 정말...

그래서 위와 같이 Self-Attention에 들어가더라도 똑같은 input shape를 가지게 될 수 있다는 것이고 이를 통해 Multi-head Attention이 가능하게 됩니다. 아래는 Q, K, V가 구해지는 Fully Connected Layer에 대한 것이니 참고하면 좋겠습니다.

 

이제 고려할 부분은 

계산의 흐름

Mask 부분이다. input을 mini-batch size로 받아와야 하는데 이때 문장의 길이가 다를 경우를 처리해줘야 한다. 그래서 최대 문장으로 (seq_len x seq_len) shape이 만들어질 텐데 이때 문장의 길이가 짧은 부분에 0이 들어가게 된다. 그런데 이에 Attention이 부여되는 건 상식적으로 안되는 일이기 때문에 SoftMax를 들어가기 전에 Mask를 씌워서 0인 부분을 반영되지 않도록 해줍니다. 이 부분은 코드를 통해 살펴보면 좋을 거 같아요.

위 흐름대로 코드가 작성된 걸 볼 수 있어요. 이때 mask를 생성하는 코드는 아래에서 살펴보도록 할게요.

def calculate_attention(query, key, value, mask):

    # query, key, value: (n_batch, seq_len, d_k)
    # mask: (n_batch, seq_len, seq_len) - batch size로 input을 넣기 때문에 shape이 이렇게 된다
    
    d_k = key.shape[-1]
    attention_score = torch.matmul(query, key.transpose(-2, -1)) # Q x K^T, (n_batch, seq_len, seq_len)
    attention_score = attention_score / math.sqrt(d_k)
    # mask를 씌워준다
    if mask is not None:
        attention_score = attention_score.masked_fill(mask==0, -1e9)
    attention_prob = F.softmax(attention_score, dim=-1) # (n_batch, seq_len, seq_len)
    out = torch.matmul(attention_prob, value) # (n_batch, seq_len, d_k)
    return out

 

이제 Multi-head Attention 에 대해서 이야기해보도록 하겠습니다.

지금까지 Self-Attention에 대해서 이야기한 것은 모두 이를 위한 빌드업이었습니다...

본 논문에서는 Attention 계산을 Scaled Dot-Product Attention이라고 명명합니다. 그래서 Transformer에서는 이 계산을 Encoder Layer마다 1회씩 수행하는 것이 아니고 병렬적으로 h회 수행한 뒤, 그 결과를 Concat해서 사용한다. 라고 합니다. 이걸 Multi-head Attention이라고 하며 이렇게 할 때 다양한 Attention을 반영하기 위해서 입니다.

"예시 문장에서 ‘it’의 Attention에는 ‘animal’의 것이 대부분을 차지하게 될 것이다. 하지만 여러 종류의 attention을 반영한다고 했을 때 ‘tired’에 집중한 Attention까지 반영된다면, 최종적인 ‘it’의 Attention에는 ‘animal’이라는 지칭 정보, ‘tired’ 이라는 상태 정보까지 모두 담기게 될 것이다. 이 것이 Multi-Head Attention을 사용하는 이유이다."

 

구체적인 연산 방법을 보면 본 논문에서는 h=8이라고 채택했습니다. Scaled Dot-Product Attention에서는 Q, K, V를 총 3개의 FC를 거처서 구하기 때문에 이를 h회 수행한다고 하면 3 * h개의 FC Layer이 필요하게 됩니다. 각각 연산의 최종 output은 n×dk의 shape인데, 총 h개의 n×dk matrix를 모두 concatenate해서 n×(dkh)의 shape를 갖는 matrix를 만들어낸다. (n은 token의 개수로, 사실상 seq_lenseq_len이다) 그렇습니다. 이제 dk * h 를 dmodel로 명명한다면 dmodel=dkh 라는 아주 중요한 수식이 만들어지게 되는 겁니다. 

dmodel = dk * h

사실 위의 설명은 개념 상의 이해를 돕기 위한 것이고, 실제 연산은 병렬 처리를 위해 더 효율적인 방식으로 수행된다. 기존의 설명에서 Q, K, V를 구하기 위한 FC layer는 dembed dk로 변환했다. 이렇게 구해낸 Q, K, V로 각각의 Attention을 계산해 concatenate하는 방식은 별개의 Attention 연산을 총 회 수행해야 한다는 점에서 매우 비효율적이다. 따라서 실제로는 Q, K, V 자체를 n×dk가 아닌, n×dmodel로 생성해내서 한 번의 Self-Attention 계산으로 n×dmodel의 output을 만들어내게 된다.

때문에 Q, K, V를 생성해내기 위한 dembed×dk의 weight matrix를 갖는 FC layer를 3h개 운용할 필요 없이  dembed×dmodel의 weight matrix를 갖는 FC layer를 33개만 운용하면 된다.

 

*dembed = dmodel 이기도 하다

dmodel은 Transformer 모델에서 사용되는 용어로, input 데이터와 output 데이터의 차원을 나타냅니다. 다른 말로, 이는 embedding 차원의 크기를 의미하며, 각 단어 또는 토큰이 얼마나 많은 정보를 포함할 수 있는지 결정합니다.

 

 최종적으로 생성해된 matrix (n×dmodel)를 FC layer에 넣어 multi-head attention의 input과 같은 shape(n×dembed)의 matrix로 변환하는 과정이 필요하다. 따라서 마지막 FC layer의 input dimension은 dmodel, output dimension은 dembed가 된다. 이는 multi-head attention layer도 하나의 함수라고 생각했을 때, input의 shape와 output의 shape가 동일하게 하기 위함이다.

 

그래서 Multi-Head Attention을 거치면 shape은 input 값과 같고 Attention Score이 나오게 되는 것입니다.

코드를 살펴볼까요?

 

class MultiHeadAttentionLayer(nn.Module):
	
    # 그림을 따라간다고 생각하시면 쉽게 이해할 수 있을 겁니다
    def __init__(self, d_model, h, qkv_fc, out_fc):
        super(MultiHeadAttentionLayer, self).__init__()
        self.d_model = d_model
        self.h = h
        self.q_fc = copy.deepcopy(qkv_fc) # (d_embed, d_model)
        self.k_fc = copy.deepcopy(qkv_fc) # (d_embed, d_model)
        self.v_fc = copy.deepcopy(qkv_fc) # (d_embed, d_model)
        self.out_fc = out_fc              # (d_model, d_embed)
        
    # 가장 중요한 forward 부분이다 꼭 이해해보자
    def forward(self, *args, query, key, value, mask=None):
        # query, key, value: (n_batch, seq_len, d_embed)
        # mask: (n_batch, seq_len, seq_len)
        # return value: (n_batch, h, seq_len, d_k)
        n_batch = query.size(0)

        def transform(x, fc):  # (n_batch, seq_len, d_embed)
            out = fc(x)        # (n_batch, seq_len, d_model)
            out = out.view(n_batch, -1, self.h, self.d_model//self.h) # (n_batch, seq_len, h, d_k)
            out = out.transpose(1, 2) # (n_batch, h, seq_len, d_k)
            return out

        query = transform(query, self.q_fc) # (n_batch, h, seq_len, d_k)
        key = transform(key, self.k_fc)     # (n_batch, h, seq_len, d_k)
        value = transform(value, self.v_fc) # (n_batch, h, seq_len, d_k)

        out = self.calculate_attention(query, key, value, mask) # (n_batch, h, seq_len, d_k)
        out = out.transpose(1, 2) # (n_batch, seq_len, h, d_k)
        out = out.contiguous().view(n_batch, -1, self.d_model) # (n_batch, seq_len, d_model)
        out = self.out_fc(out) # (n_batch, seq_len, d_embed)
        return out

 

자, 여기서 제가 이해가 안갔던 부분이 d_model과 d_embed였습니다. 뭐가 다른거지? 였죠.

shape은 동일해야 합니다. 즉 d_model = d_embed가 된다는 뜻이죠. 그렇다면 out_fc에 들어간 d_model이 d_embed로 변환될 때 어떻게 변환 될까요?

d_model에서 d_embed로의 변환은 선형 변환을 통해 이루어집니다. 선형 변환이란 기본적으로 벡터 공간에서 벡터를 다른 차원의 벡터 공간으로 매핑하는 과정입니다. 이 변환은 특정 벡터에 가중치를 곱하고 편향을 더하는 방식으로 이루어집니다.
따라서, 원래의 d_model 차원의 값이 어떠한 선형 변환(가중치 곱 및 편향 더하기)을 통해 d_embed 차원의 값으로 바뀌게 됩니다. 이 과정에서 원래의 값은 변경될 수 있지만, 이러한 변환은 모델을 통해 학습되므로 입력의 중요한 정보를 유지하면서 더 낮은 차원으로 압축하려고 합니다.

 

이렇게 다릅니다.

또 갑자기 궁금해진 건데 코드에서 어떤 부분이 각기 다른 attention을 만들어주는 걸까요?

    def transform(x, fc):  # (n_batch, seq_len, d_embed)
        out = fc(x)        # (n_batch, seq_len, d_model)
        out = out.view(n_batch, -1, self.h, self.d_model//self.h) # (n_batch, seq_len, h, d_k)
        out = out.transpose(1, 2) # (n_batch, h, seq_len, d_k)
        return out

    query = transform(query, self.q_fc) # (n_batch, h, seq_len, d_k)
    key = transform(key, self.k_fc)     # (n_batch, h, seq_len, d_k)
    value = transform(value, self.v_fc) # (n_batch, h, seq_len, d_k)

    out = self.calculate_attention(query, key, value, mask) # (n_batch, h, seq_len, d_k)

이쪽 부분입니다. query, key, value는 각각 고유한 파라미터를 가진 fc를 가져오는데 (맨처음에는 각각 동일한 파라미터값을 가지지만 학습이 진행되면서 FC layer가 독립적으로 업데이트 되기 때문에 고유한 파라미터를 가졌다고 할 수 있습니다) 이를 transform안에서 d_k로 쪼개줍니다. 그리고 calculate_attention으로 각각 attention score를 구해주게 되는 겁니다.

* view는 pyTorch에서 텐서의 모양을 변경하는 함수

* contiguous()는 텐서를 메모리에 연속적으로 배치하게 한다

 

자, 신기한 개념 들어갑니다.

 

이 내용은 원래 각각의 attention head를 독립적으로 계산하려면 각 head에 대해 따로따로 Q, K, V를 생성해야 하므로, 각 head에 대해 d_embed x d_k 크기의 weight matrix를 갖는 FC layer가 필요하게 됩니다. 그런데 이렇게 하면 attention head의 개수 h에 비례하여 연산 비용이 늘어나므로, 비효율적입니다.

그래서 대신에 Q, K, V를 모두 d_embed x d_model 크기의 weight matrix를 갖는 FC layer를 통과시켜 n x d_model 크기의 텐서를 생성합니다. 이 텐서는 각 head에 대한 Q, K, V를 모두 포함하고 있습니다. 그리고 이 텐서에 대해 한 번에 self-attention 연산을 수행하면, 각 head에 대한 attention 결과를 모두 합친 n x d_model 크기의 output을 얻을 수 있습니다

 

라는 것이죠. 그러니까 원래는 q, k,v가 d_embed 차원일 때 얘를 h로 나눠서 (h, d_k)으로 만들고 얘네들의 특징을 추출하기 위해 FC layer를 거쳐야하는데 나누고 나면은 FC layer가 3*h만큼 필요하기 때문에 나주기 전에 fc에 넣어서 한번에 하고 h로 나눠서 데이터에서 필요한 정보를 추출할 수 있도록 하는 겁니다. 이렇게 하면 3*h개나 필요하던 FC layer를 3개만 사용해서 계산 할 수 있기 때문에 훨씬 효율적이다. 라고 할 수 있게 되는 거죠.

    def transform(x, fc):  # (n_batch, seq_len, d_embed)
        out = fc(x)        # (n_batch, seq_len, d_model)
        out = out.view(n_batch, -1, self.h, self.d_model//self.h) # (n_batch, seq_len, h, d_k)
        out = out.transpose(1, 2) # (n_batch, h, seq_len, d_k)
        return out

기존의 접근 방식이었다면 각각의 attention head에 대해 d_embed x d_k 크기의 weight matrix를 갖는 Fully Connected (FC) layer를 사용하여 Q, K, V를 생성하고 각 head에서 개별적으로 attention을 계산해야 했습니다.

 

하지만 주어진 코드에서는 먼저 각 query, key, value에 대해 d_embed x d_model 크기의 weight matrix를 갖는 FC layer (self.q_fc, self.k_fc, self.v_fc)를 통과시키고, 이 결과를 .view().transpose()를 사용하여 attention head 수(self.h)에 맞게 재구성합니다. 이렇게 해서 각 head에서 독립적으로 attention을 계산하는 것과 같은 결과를 얻을 수 있지만, 한 번의 batch 처리로 모든 head에 대한 계산을 동시에 수행할 수 있습니다

 

 

이제

Pad Mask Code in Pytorch

어떻게 Encoder에 Pad Mask를 설정할지에 대한 코드입니다.

Query 와 Key를 내적한 결과가 attention score으로 서로의 관계를 계산한 뒤 value에 가중치를 줌으로써 Query's Attention 값을 구합니다.

    def make_pad_mask(self, query, key, pad_idx=1):
        # query: (n_batch, query_seq_len)
        # key: (n_batch, key_seq_len)
        query_seq_len, key_seq_len = query.size(1), key.size(1)

        key_mask = key.ne(pad_idx).unsqueeze(1).unsqueeze(2)  # (n_batch, 1, 1, key_seq_len)
        key_mask = key_mask.repeat(1, 1, query_seq_len, 1)    # (n_batch, 1, query_seq_len, key_seq_len)

        query_mask = query.ne(pad_idx).unsqueeze(1).unsqueeze(3)  # (n_batch, 1, query_seq_len, 1)
        query_mask = query_mask.repeat(1, 1, 1, key_seq_len)  # (n_batch, 1, query_seq_len, key_seq_len)

        mask = key_mask & query_mask
        mask.requires_grad = False
        return mask

코드가 진짜 헷갈렸는데 흐름과 결과만 두고 이야기를 해보겠습니다. 먼저 padding 된 값은 1로 저장이 되기 때문에 pad_idx를 1로 두고 .ne(pad_idx)로 패딩인 값은 False를 줍니다. 그리고 unsqueeze는 ()안에 인덱스에 새로운 차원을 추가하겠다는 뜻입니다. key와 query의 mask를 그렇게 만들어주고 repeat를 통해서 차원을 동일하게 만들어줍니다. 왜 1,1,1 이냐? 저도 모릅니다. 몰라요. 모른다고요 ㅋㅋ

암튼 그래서 동일한 차원을 만들고 &연산자를 통해 하나라도 false인 부분 즉, padding이 들어간 부분을 0으로 바꿔서 attention score를 계산할 때 padding 된 값이 계산되지 않도록 해줍니다.

마지막으로, mask.requires_grad = False를 통해 mask의 값이 학습 과정에서 변경되지 않도록 설정합니다.

 

Self-Attention에서는 query와 key 값이 동일하기 때문에 ("src"는 소스 문장을 나타냅니다. 기계 번역 같은 문제에서는 입력 언어 문장을 "src" (source)라고 하고, 출력 언어 문장을 "tgt" (target)라고 합니다.)

def make_src_mask(self, src):
    pad_mask = self.make_pad_mask(src, src)
    return pad_mask

위와 같이 호출해주는데 입력 문장 내에서 padding이 아닌 부분에 대해서만 attention을 계산하도록 마스크를 생성하는 것을 의미 한다고 합니다.

"src"는 일반적으로 토큰화 및 정수 인코딩이 완료된 텐서 형태의 입력 문장이며, 각 배치의 형태는 (n_batch, seq_len), 여기서 "seq_len"은 각 배치에서 문장의 길이를 나타내며, 문장마다 길이가 다를 경우 padding으로 문장의 길이를 맞춰줍니다.

 

Position-wise Feed Forward Layer

Transformer

다음 encoder로 넘기기 위해서 metrix를 처음 input shape을 유지시킨다. 

Feed Forward Layer는 Multi-Head Attention Layer의 output을 input으로 받아 연산을 수행하고, 다음 Encoder Block에게 output을 넘겨준다. 논문에서의 수식을 참고하면 첫번째 FC Layer의 output에 ReLU()를 적용하게 된다.

Feed Forward

2개의 층으로 이뤄져있다. 그리고 활성화 함수로 ReLu를 사용한다.

 

Residual Connection Layer

일단 제가 알기론 이 친구는 기존의 값을 잊지 않기 위해 해주는 친구로 알고 있긴 합니다.Encoder Block의 구조를 다시보면 아래와 같습니다. 이때 Residual Connection은 고려되지 않았죠.

Encoder Block

 y=f(x) y=f(x)+x로 변경하는 것이다. 즉, output을 그대로 사용하지 않고, output에 input을 추가적으로 더한 값을 사용하게 된다. 이로 인해 얻을 수 있는 이점은 Back Propagation 도중 발생할 수 있는 Gradient Vanishing을 방지할 수 있다는 것이다.

네 그렇습니다. 기울기 소실을 방지 할 수 있다고 하네요.

(Gradient Vanishing - 역전파 될 때 그 값이 점점 줄어드는 현상 -> 매우 작아질 경우, 가중치 업데이트가 거의 발생하지 않게 되어 학습이 잘 진행되지 않는 문제가 발생)

그래서 Residual에서는 입력을 네트워크의 깊은 부분에 직접 전달함으로써 그래디언트가 신경망을 통해 자유롭게 흐르도록 해줍니다.

Residual Connection에서 입력을 출력에 더하는 것은 신경망 학습에 문제를 일으키지 않습니다. 실제로 이 방법은 심층 신경망의 학습을 향상시키는데 중요한 역할을 합니다.

자 여기서 알아야 하는 개념이 있습니다. Residual Connection Layer의  "학습해야 하는 타겟"을 입력과 출력의 차이, 즉 '잔차'로 변환함으로써, 심층 네트워크에서 발생할 수 있는 학습의 어려움을 완화해 줍니다

즉 입력과 출력 사이의 차이가 적어지게 하는 게 RC 의 목표입니다. 물론 RC가 신경망의 전체적인 학습 능력을 향상시키기 위한 것이지, 단순히 입력에 가까운 출력을 만들어내는 건 아니라고 합니다.

 

정리해보자면 RC Layer는 깊이가 깊은 신경망에서 Gradient Vanishing을 방지하기 위해 출력값에 입력값을 더해 입력값과 출력값의 차이를 적어지게 하면서 신경망의 전체적인 학습 능력을 향상시키는 Layer 라고 생각하면 좋을 것 같습니다.

 

class ResidualConnectionLayer(nn.Module):

    def __init__(self):
        super(ResidualConnectionLayer, self).__init__()


    def forward(self, x, sub_layer):
        out = x
        out = sub_layer(out)
        out = out + x
        return out
        
        
class EncoderBlock(nn.Module):

    def __init__(self, self_attention, position_ff):
        super(EncoderBlock, self).__init__()
        self.self_attention = self_attention
        self.position_ff = position_ff
        self.residuals = [ResidualConnectionLayer() for _ in range(2)]


    def forward(self, src, src_mask):
        out = src
        out = self.residuals[0](out, lambda out: self.self_attention(query=out, key=out, value=out, mask=src_mask))
        out = self.residuals[1](out, self.position_ff)
        return out

 

residuals 를 2개 만들어줍니다. 그리고 Multi-head Attention과 Position_feed_forward에 적용해줍니다.

 

 

 

Decoder

Decoder는 Encoder와 몇몇의 변경이 있을 뿐 이해하는 데 어려움이 없을거라고 하십니다.

과연...

Decoder

 

Transformer 모델의 목적을 생각해보자. input 을 받아서 output을 낸다. Task가 번역이라면 영어 text를 context로 변환해서 이를 통해 한글 text output을 만들어낸다. 이때 Decoder는 Encoder에서 output 된 context와 some sentence를 가지고 번역된 output sentence를 출력한다. 이때 some sentence의 역할은 뭘까?

 

참고로 encoder의 input shape과  output의 shape은 동일하다

 

자, some sentence를 이해하기 위해 Teaching Forcing을 이해해야한다. 알듯이 Cheating이라고 생각하자. 학습 시 잘못된 값을 예측하더라도 이미 존재하는 label을 통해서 다음 예측이 정상적으로 진행되도록 하는 기법이다.

물론 이 기법을 사용하기 위해선 model 학습 과정에서 Ground Truth를 포함한 Dataset을 가져야 하고 학습과 실제 사용에서 괴리가 발생하긴 하지만 model의 학습 성능을 비약적으로 향상시킬 수 있다는 점에서 많은 Encoder-Decoder Model에서 사용된다고 합니다.

 

그렇다면 Some Sentence가 Teacher Forcing에 해당되는 Ground truth라고 생각할 수 있게 됩니다. 그렇지만 병렬연산이 가능한 Transform에서 RNN 방식처럼 Teacher Forcing을 적용할 순 없다고 합니다. 그래서 이 ground truth 값을 embedding marix로 만들어 input으로 사용하면 되지만 이렇게 되면 self-attention 연산에서 미래의 값까지, 정답을 학습해버리는 cheating이 발생하기 때문에 masking을 적용해야 합니다. i번째 token을 생성해낼 때, i번째 토큰을 생성할 때, i+1번째부터 끝까지의 토큰은 보이지 않도록 처리를 해야 한다

 

해당 코드와 출력 결과입니다. tril을 사용하면 저런 tensor가 그려진다고 하네요.

def make_subsequent_mask(query, key):
    # query: (n_batch, query_seq_len)
    # key: (n_batch, key_seq_len)
    query_seq_len, key_seq_len = query.size(1), key.size(1)
	
    # query_seq_len, key_seq_len 각각 10일때
    tril = np.tril(np.ones((query_seq_len, key_seq_len)), k=0).astype('uint8') # lower triangle without diagonal
    mask = torch.tensor(tril, dtype=torch.bool, requires_grad=False, device=query.device)
    return mask
    
    
[[1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
 [1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
 [1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
 [1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
 [1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
 [1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
 [1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
 [1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]

 

 

Decoder Block

얼마 안남았습니다. 힘을 내요...!

Decoder 도 Encoder와 마찬가지로 여러 개의 Decoder Block으로 이뤄진 구조 입니다. 이때 주목할 점은 Encoder 에서 넘어오는 context가 각 Decoder Block마다 input으로 주어진다는 것입니다.

 

Decoder & Decoder Block

Decoder Block에는 2개의 Multi-Head Attention이 존재하는데 첫번째 Multi-head Attention에는 pad masking + subsequent masking이 적용되기 때문에 Masked-Multi-Head Attention Layer라고 부릅니다. 두번째 Multi-head Attention은 Encoder 의 context를 key, value로 사용한다는 점에서 Cross-Multi-Head Attention이라고 부른다고 합니다.

 

Decoder Block에서 Masked-Multi-Head Attention이 다른 Multi-Head Attention과 다른 점이 있다면 Ground Truth sentence 내부에서의 Attention을 계산한다는 것이다.

- 이 부분은 논문에서 직접적인 언급은 없지만 저자가 구현한 코드를 보면 Teacher forcing 이 적용된 것과 decoder에 ground truth sentence, encdoing context를 입력값으로 준다는 걸 알 수 있습니다.

 

 

Cross-Multi-Head Attention Layer

Decoder 에서 가장 핵심적인 부분입니다.  Masked-Multi-Head Attention에서 넘어온 output을 input으로 받고 추가적으로 Encoder에서 도출된 context도 input으로 받습니다. Masked-Multi-Head Attention에서 넘어온 output을 Query로 사용하고 Encoder context를 Key와 Value로 사용하게 됩니다.

그래서 Decoder에서 우리가 도출하고자 하는 최종 output은 teacher forcing 으로 넘어온 sentence와 최대한 유사한 predicted sentence입니다. 따라서 Decoder Block 내 이전 layer에서 넘어오는 input이 Query가 되고, 이에 상응하는 Encoder에서의 Attention을 찾기 위해 context를 Key, Value로 두게 된다. 번역 task를 생각했을 때 가장 직관적으로 와닿는다. 만약 영한 번역을 수행하고자 한다면, Encoder의 input은 영어 sentence일 것이고, Encoder가 도출해낸 context는 영어에 대한 context일 것이다. Decoder의 input(teacher forcing)과 output은 한글 sentence일 것이다. 따라서 이 경우에는 Query가 한글, Key와 Value는 영어가 되어야 한다.

 

궁금했던 점이 실제 추론 단계에서 Decoder의 처음 값이 들어가면 어떻게 다음 값을 만들어 낼까였는데 아래와 같이 궁금점이 풀렸습니다.

결과적으로, 모델이 첫 번째 토큰을 만들어내는 과정은 다음과 같습니다:
<start> 토큰을 Decoder의 입력으로 제공합니다.Decoder는 Masked Multi-Head Attention을 계산하며, 이 때에는 <start> 토큰만 있기 때문에 Self-Attention은 실질적으로 의미가 없습니다.Decoder는 Cross Multi-Head Attention을 계산하며, 이 때에는 Encoder의 출력인 context 벡터와 <start> 토큰 사이의 Attention을 계산합니다.Decoder는 최종적으로 첫 번째 출력 토큰을 생성합니다. 이 토큰은 다음 시점의 입력으로 사용됩니다.

그러나, 추론 또는 테스트 단계에서는 모델이 각 단계에서 하나의 토큰을 출력하고, 이 토큰을 다음 단계의 입력으로 사용하기 때문에, 이 과정은 순차적으로 이루어집니다.
다시 말해, Transformer 모델의 학습은 병렬적으로 수행될 수 있지만, 실제 추론은 순차적으로 이루어지게 됩니다. 이는 Transformer 모델이 각 시점에서 과거의 모든 토큰을 동시에 참조할 수 있도록 설계되었기 때문에, 모델이 미래의 토큰에 대한 정보를 사용하지 않도록 보장하는 것이 중요하기 때문입니다. 이는 "look-ahead mask"나 "subsequent mask"를 사용하여 이루어집니다.

 

 

Transformer’s Input (Positional Encoding)

제가 알기론 Positional Encoding은 문장에 순서에 대한 정보를 담은 embedding vector를 줌으로써 병렬처리의 이점과 순차적인 데이터 처리를 동시에 가져갈 수 있게 하는 걸로 알고 있습니다.

 

Positional Encoding은 주로 주기적인 함수, 예를 들어 사인 함수와 코사인 함수를 이용하여 계산되며, 각 위치에 대해 유니크한 벡터를 생성합니다. 이 벡터는 각 입력 토큰의 임베딩 벡터에 추가되어, 위치 정보가 포함된 임베딩 벡터를 생성하게 됩니다. 이런 방식으로, Transformer 모델은 입력 시퀀스의 순서와 위치 정보를 활용할 수 있게 됩니다.

따라서, Positional Encoding의 주요 목적은 Transformer 모델이 입력 시퀀스의 순서와 위치 정보를 포착하고 이해할 수 있도록 하는 것

 

그런데 다른 목적이 존재하기도 한다고 합니다.

PositionalEncoding의 목적은 positional 정보(token index number 등)를 정규화시키기 위한 것이다. 단순하게 index number를 positionalEncoding으로 사용하게 될 경우, 만약 training data에서는 최대 문장의 길이가 30이었는데 test data에서 길이 50인 문장이 나오게 된다면 30~49의 index는 model이 학습한 적이 없는 정보가 된다. 이는 제대로 된 성능을 기대하기 어려우므로, positonal 정보를 일정한 범위 안의 실수로 제약해두는 것이다. 여기서 sin함수와 cos함수를 사용하는데, 짝수 index에는 sin함수를, 홀수 index에는 cos함수를 사용하게 된다. 이를 사용할 경우 항상 -1에서 1 사이의 값만이 positional 정보로 사용되게 된다.

 

pad masking과 positional encoding의 차이점은 두 기법은 모두 Transformer 모델의 일부로 사용되지만, 각각 위치 정보를 전달하고, 무의미한 패딩 토큰을 무시하는 데 사용됩니다.

입니다. pad masking은 단지 shape을 맞춰주기 위한 기법이고 positional encoding은 문장의 길이가 다를 때 그 범위에 제약을 줘서 위치에 대한 정보를 전달하는 기법입니다.

 

Positional Encoding 방식은 각 위치에 대해 고유한 값을 생성합니다. 이 고유한 값은 입력 시퀀스의 각 단어나 토큰에 추가되어, 그 토큰이 어느 위치에 있는지를 나타내는 정보를 제공합니다. 이 고유한 값은 사인 함수와 코사인 함수를 이용하여 생성되며, 각각 짝수 위치와 홀수 위치에 적용됩니다.
이렇게 생성된 Positional Encoding 값은 각 위치에서 고유하며, 사인 함수와 코사인 함수의 주기성을 이용하여 서로 다른 위치의 정보를 고유하게 표현할 수 있습니다. 따라서, 이 값은 각 토큰의 임베딩 벡터에 추가되어 입력으로 사용되며, 이를 통해 모델은 각 토큰의 위치 정보를 학습하고 이해할 수 있습니다.
또한, 이 방식은 모델이 시퀀스 내의 상대적 위치 정보를 이해하는 데도 도움이 됩니다. 사인 함수와 코사인 함수의 주기성을 이용하면, 특정 위치 간의 거리나 순서 차이를 계산할 수 있으며, 이는 모델이 문장 구조나 문법 등을 이해하는 데 중요합니다.

네, 이런 방식으로 위치에 대한 정보를 전달합니다.

 

 

 

After Decoder (Generator)

진짜 막바지 입니다.

이제 Decoder의 output을 우리가 원하는 대로 바꿔줘야겠죠.

이때 decoder output shape은 n_batch×seq_len×dembed인데 저희가 원하는 shape은 n_batch×seq_len 라는 거죠.

즉 이제 embedding이 아닌 실제 문장을 원하는 겁니다. 이 친구를 위해서는 새로운 FC layer이 필요하고 대개 Generator라고 부른다고 합니다.

softmax를 사용하고 vocabulary에 대한 확률값으로 변환 시킵니다. 이때 log_softmax()를 사용해서 성능을 향상시킨다고 하네요.

여기서 generator가 dembed 사이즈의 출력 벡터를 len(vocab)으로 변경하는 선형변환이라고 생각하시면 좋을 거 같아요. 그 값을 softmax에 넣어서 vocab을 예측하는 거죠.

class Transformer(nn.Module):

    def __init__(self, src_embed, tgt_embed, encoder, decoder, generator):
        super(Transformer, self).__init__()
        self.src_embed = src_embed
        self.tgt_embed = tgt_embed
        self.encoder = encoder
        self.decoder = decoder
        self.generator = generator

    ...

    def forward(self, src, tgt):
        src_mask = self.make_src_mask(src)
        tgt_mask = self.make_tgt_mask(tgt)
        src_tgt_mask = self.make_src_tgt_mask(src, tgt)
        encoder_out = self.encode(src, src_mask)
        decoder_out = self.decode(tgt, encoder_out, tgt_mask, src_tgt_mask)
        out = self.generator(decoder_out)
        out = F.log_softmax(out, dim=-1)
        return out, decoder_out

 

 

네, 이런 식으로 Transformer를 코드로 어떻게 구현하는지 볼 수 있었습니다.

새로운 난관이 있다면 이렇게 만든 Transformer를 학습시킨 뒤 어떻게 사용 + 앱/웹에 적용할 수 있는지 이게 큰 숙제가 될 거 같아요. 코드를 살펴보면 학습시키고 dataset을 통해서 얼마나 정확한지는 나오지만 하나 하나 데이터를 넣어서 실험하는 건 안보이더라구요. 아니 있을텐데 제가 찾지 못하는 거가 맞겠죠. 아무튼 이렇게 Attention is All you need 논문 구현을 마무리하겠습니다!

 

 

 

 

 

 

 

 

 

ㅁㅁㅁ

댓글