[논문 리뷰] Transformer, Attention all you need

img

안녕하십니까 다제입니다. 오늘은 Transformer에 대해서 한번 공부를 해보려고 합니다. 

해당 포스팅은 
Attention is all you need 논문, 딥러닝을 이용한 자연어처리 입문, 유튜버 나동빈, 유튜버 허민석, Tensorflow Transformer Tutorial, Andrew Ng Coursera강의를 믹스하여 제작하였음을 알려드립니다. 

관련 링크들은 하단 reference를 참고하여 주시기 바랍니다. 또한, 개념과 코드를 동시에 설명하는 방식으로 포스팅을 진행하여 포스팅이 길어졌다는 점도 양해부탁드립니다. 

img
출처 : 나동빈 유튜버 깃허브

1. 개요(등장배경)

 -. 기존의 seq2seq 모델은 encoder-decoder 구조로 구성되어 있었습니다. 여기서 encoder는 입력 시퀀스를 하나의 벡터 표현으로 압축하고, decoder는 이 벡터 표현을 통해서 출력 시퀀스를 만들어냈습니다.

 -. 하지만 이러한 구조는 인코더가 입력 시퀀스를 하나의 벡터로 압축하는 과정에서 입력 시퀀스의 정보가 일부 손실된다는 단점이 있었고, 이를 보정하기 위해 어텐션이 사용되었습니다. 

 -. 그런데 어텐션을 RNN의 보정을 위한 용도가 아니라 아예 어텐션으로 인코더와 디코더를 만들어보면 어떨까요?라는 생각을 Google 개발자분들이 하게 되면서 모델을 만들게 되는데, 그게 바로 2017년 발표된 Transformer입니다. 

2. Transfomer란 ? 

 -. seq2seq의 구조인 인코더-디코더를 따르면서도, 다수의 어텐션(Attention)만으로 구현한 모델

 -. 특히, 자연어처리 Task 중 Machine translation을 위해 제안된 모델

3. Transformer의 특징 

 -. Transformer 모델은 PTM(Pre-Trained-Models)로서 사용되었을 때 여러 Downstream Task에서 SOTA를 보이고 있음(특히, 자연어처리 분야에서 Transformer 모델의 PTM으로서 역할이 강력함)

 -. 이러한 이유는 이미지나 음성 대비, 자연어 텍스트는 데이터의 축적 속도가 매우 빠라 방대한 양으로 존재하며 대용량의 텍스트를 매번 처음부터 학습을 시키는 것은 리소스가 많이 들어가기 때문에 PTM과 Fine-Tuning을 사용하여 높은 성능을 내는 연구가 활발하게 이루어지고 있음 

 -. 출처 : Downstream Task 관련 블로그

  * 사전학습된 모델을 Upstream Task라고 하고, 이를 이용하여 우리가 풀고 싶은 문제를 풀기 때문에 이를 Downstream Task라고 지칭합니다. 

 -. SOTA는 State-of-the-art의 약자로 현제 최고 수준의 결과를 의미한다. 따라서 케글에서 모델 구축을 위해서는 사전학습된 신경망들을 많이 사용하는데, SOTA는 사전학습된 신경망들 중 현재 최고 수준의 신경망이라는 뜻입니다.

4. Transformer 작동순서 

 -. 먼저, 디테일한 설명보다는 큰 흐름을 알기 위해서 Transformer가 어떤 순서로 작동을 하는지 알아보고 그 후 작동원리와 코드에 대해서 하나씩 뜯어보도록 하겠습니다.

 1) 입력데이터를 전처리 합니다. 

 2) 인코더에 데이터 입력 합니다. 

 3) 디코더에 데이터를 입력 합니다. 

 4) 인코더 데이터에 padding mask(연산량을 줄이기 위한 방법)를 씌웁니다. 

 5) 디코더 데이터에 look ahead mask(예측할 단어를 미리 보지 못하게 하는 방법)와 padding mask를 씌웁니다. 

 6) 인코더에 padding mask가 씌워진 데이터와 기존의 입력데이터를 넣습니다. 

 7) 디코더는 디코더 입력데이터, 인코더에서 계산된 결과값, look ahead mask, padding mask 값을 입력을 받습니다. 

 8) 디코더에서 계산된 값을 가지고 다음 단어를 예측합니다. 

4. 모델구조

img
출처 : Tensorflow Transformer Tutorial / 일부 커스터마이징

(1) Encoder와 Decoder는 L개의 동일한 Block이 Stack된 형태를 지님 

(2) Encoder Block 구성 

 -. Multi-head self-attention 

 -. position-wise feed-forward network 

 -. Residual connection 

 -. Layer Normalization 

(3) Decoder Block 구성 

 -. 기본적인 Encoder Block 구성과 동일 

 -. Cross-attention모듈을 Multi-head self attention과 position-wise FFN 사이에 추가 

 -. 디코딩 시에 미래 시점의 단어 정보를 사용하는 것을 방지하기 위해 masked self-attention을 사용 

5. 작동원리

(1) 하이퍼파라미터 

 -. 먼저 논문에서 나오는 하이퍼파라미터 표현들에 대해 설명드리겠습니다. 

$d_{model}$ = 512
트랜스포머의 인코더와 디코더에서의 정해진 입력과 출력의 크기를 의미합니다. 임베딩 벡터의 차원 또한 dmodel이며, 각 인코더와 디코더가 다음 층의 인코더와 디코더로 값을 보낼 때에도 이 차원을 유지합니다. 논문에서는 512입니다.

$num  layers$ = 6
트랜스포머에서 하나의 인코더와 디코더를 층으로 생각하였을 때, 트랜스포머 모델에서 인코더와 디코더가 총 몇 층으로 구성되었는지를 의미합니다. 논문에서는 인코더와 디코더를 각각 총 6개 쌓았습니다.

img
출처 : Tensorflow Transformer Tutorial / 일부 커스터마이징

$num  heads$ = 8
트랜스포머에서는 어텐션을 사용할 때, 1번 하는 것 보다 여러 개로 분할해서 병렬로 어텐션을 수행하고 결과값을 다시 하나로 합치는 방식을 택했습니다. 이렇게 병렬로 처리하였을 때, 장점은 연산속도를 높일 수 있다는 점과 attention이 문장을 다각도로 계산함으로써 더 정확하게 번역되도록 돕습니다. 

img
출처 : Tensorflow Transformer Tutorial / 일부 커스터마이징

$dff$ = 2048
트랜스포머 내부에는 피드 포워드 신경망이 존재합니다. 이때 은닉층의 크기를 의미합니다.

피드 포워드 신경망의 입력층과 출력층의 크기는 $d_{model}$입니다.

(2) Seq2Seq와 Transformer의 차이점 

img
seq2seq 모델 / 출처 : slideshareTransformer 모델 / 출처 : slideshare

 -. Seq2Seq와 Transformer 차이점 : seq2seq 구조에서는 인코더와 디코더에서 각각 하나의 RNN이 t개의 시점(time-step)을 가지는 구조였다면 Transformer에서는 N개로 구성되는 구조입니다. 트랜스포머를 제안한 논문에서는 인코더와 디코더의 개수를 각각 6개를 사용하였습니다.

 -. Seq2Seq와 Transformer 공통점 : 기존의 seq2seq처럼 인코더에서 입력 시퀀스를 입력받고, 디코더에서 출력 시퀀스를 출력하는 인코더-디코더 구조를 유지하고 있습니다.

(3) Positional Encoding 

좋아좋아!

일단 RNN cell을 제거하고 다수의 Encoder를 통해 속도가 빨라졌다는 것에 대해서는 확실히 이해가 되실 것입니다. 

그런데, 여기서 잠깐! 

RNN cell을 제거하면 어떻게 데이터의 순서를 기억할 수 있는 것일까요?

저도 처음에 공부할 때는 아! 제거해도 되는구나 하다가 응? 이러면서 다시 찾아보게 되었습니다. 

일단, 원리는 간단합니다. 

embedding 된 단어데이터에 positional Encoding을 통해 구한 값을 더한 후 Encoder에 넣어주면 됩니다. 

 -. 주의해야할 점 

  * word Embedding 값에 1 또는 정수의 행렬 형태의 postitional encoding value를 넣게 되면, word Embedding의 의미론적으로 데이터가 왜곡되게 됩니다.

  * 이에, 의미론적으로 영향을 주지 않는 작은 값이면서 일정한 범위(-1 to 1)를 가지는 주기성함수(Sine and Cosine)를 사용하여 positional encoding을 하게 됩니다. (또한, Sine and Cosine 사용하게 되면 훈련에 사용했던 문장보다 긴 문장이 들어와도 오류를 내지 않고 실행되며, Sine and Cosine 외 다른 주기성 함수를 사용해도 무방합니다. 논문에서는 다른 주기성 함수를 사용하였지만, 큰 차이가 없다고 이야기하고 있습니다.)

 -. 수식 및 코드 

img
Positional Encodingpos, i에 대한 이해를 돕는 이미지 / 출처 : slide_share

  * pos는 입력 문장에서의 임베딩 벡터의 위치를 나타내며, i는 임베딩 벡터 내의 차원의 인덱스를 의미합니다.

  * 위의 식에 따르면 임베딩 벡터 내의 각 차원의 인덱스가 짝수인 경우에는 사인 함수의 값을 사용하고 홀수인 경우에는 코사인 함수의 값을 사용합니다. 이유를 생각해보면 간단합니다. sin일때는 $x$가 0이면 1이고, $x$가 1이면 $y$가 0이 되면 값이 중간에 비게 됩니다. 이러한 다른 점을 보안하기 위해 sin과 cos을 함께 사용합니다. 

  * 또한 위의 식에서 $d_{model}$은 트랜스포머의 모든 층의 출력 차원을 의미하는 트랜스포머의 하이퍼파라미터입니다. 앞으로 보게 될 트랜스포머의 각종 구조에서 $d_{model}$의 값이 계속해서 등장하는 이유입니다. 임베딩 벡터 또한 $d_{model}$의 차원을 가지는데 위의 그림에서는 마치 4로 표현되었지만 실제 논문에서는 512의 값을 가집니다.

  * 위와 같은 포지셔널 인코딩 방법을 사용하면 순서 정보가 보존되는데, 예를 들어 각 임베딩 벡터에 포지셔널 인코딩값을 더하면 같은 단어라고 하더라도 문장 내의 위치에 따라서 트랜스포머의 입력으로 들어가는 임베딩 벡터의 값이 달라집니다. 결국 트랜스포머의 입력은 순서 정보가 고려된 임베딩 벡터라고 보면 되겠습니다. 이를 코드로 구현하면 아래와 같습니다.

def get_angles(pos, i, d_model):
  angle_rates = 1 / np.power(10000, (2 * (i//2)) / np.float32(d_model))
  return pos * angle_rates

# tf.newaxis는 별도의 포스팅을 해두었습니다. 그부분을 참고하시면 됩니다. 
# shape 맞지 않는 행렬을 연산할 때 사용합니다. 
def positional_encoding(position, d_model):
  angle_rads = get_angles(np.arange(position)[:, np.newaxis], # 1차원(리스트) -> 2차원 
                          np.arange(d_model)[np.newaxis, :], # 1차원(리스트) -> 2차원 
                          d_model)

  # apply sin to even indices in the array; 2i
  angle_rads[:, 0::2] = np.sin(angle_rads[:, 0::2])

  # apply cos to odd indices in the array; 2i+1
  angle_rads[:, 1::2] = np.cos(angle_rads[:, 1::2])

  pos_encoding = angle_rads[np.newaxis, ...] # embedding 값과 더해주어야하기 때문에 
                                             # 다시 리스트형태(1차원)로 전환하기 

  return tf.cast(pos_encoding, dtype=tf.float32)

위 코드를 활용하여 입력데이터에 순서를 부여할 수 있게 되었습니다. 

그럼 embedding된 단어 데이터에 개별적으로 더해지고 그 다음 encoder로 들어가게 됩니다. 

Encoder로 들어가게 되면, 가장 먼저 만나는 Attention이 Encoder Self-Attention입니다. 

(4) Encoder 

일단, 여러 가지 개념이 한번에 들어오고 있으니, 정리하는 의미에서 Attention에 대한 개념을 복습하고 넘어가겠습니다.

어텐션 모듈이란 전체 입력 문장을 모두 동일한 비율로 참고하는 것이 아니라 해당 시점에서 예측해야할 단어와 연관이 있는 입력 단어 부분을 집중해서 보자는 아이디어이며, Query, key, value를 기반을 동작하는 모듈입니다. 

‘쿼리(hidden state 값 – Query / 질문 / 번역할 문장)’에 대해서 모든 ‘키(hidden state와 영향을 주고 받는 값 – Key / 답 / 번역된 문장)’와의 유사도를 각각 구합니다. 그리고 구해낸 이 유사도를 가중치로 하여 키와 맵핑되어있는 각각의 ‘값(Value)’에 반영해줍니다. 그리고 유사도가 반영된 ‘값(Value)’을 모두 가중합하여 리턴합니다.

일반적으로 앞에서 배운 어텐션함수는 Decoder를 실행할 때 번역할 문장을 Decoder가 받게 되면, 인코더에서 계산하였던 ‘키(Key / 답 / 번역된 문장)’와 키와 맵핑되어있는 각각의 ‘값(Value)’를 가져와서 연산이 이루어지게 됩니다. 

그런데, self이기 때문에 Encoder에서 Encoder로 값이 참조되며 계산이 되는 것을 말합니다. 

아니? 그럼 왜 이렇게 self-Attention을 하는걸까? 하는 생각이 들었습니다. 

유명한 예를 들어드리겠습니다. 

The animal didn’t cross the street because it was too tired. 라는 문장에서 because 뒤에 있는 it은 무엇을 가르킬까요?

네! 바로 animal을 의미합니다. 그런데, 우리의 모델은 과연 이것을 어떻게 알 수 있을까요? 

그렇습니다. self-Attention을 통해서 알 수 있습니다! 진짜 이러한 방법을 어떻게 생각해냈는지 감탄 밖에 안나옵니다. 

이를 이미지로 표현하면 아래와 같습니다.

img
출처 : slide_share

자! self-Attention이 이루어지는 장소가 Encoder의 첫번째 Cell인 MultiHeadAttention입니다. 

Attention이 여러 개 있기에 MultiHeadAttention이라고 한다는 것은 위에서 설명 드렸죠?

좋다 좋아! 이해가 되어가고 있죠? 이제 Attention score를 구하기 위해 Scaled dot-product Attention을 해야하는데요

Scaled dot-product Attention을 조금 짚고 넘아가겠습니다. 

img
출처 : yukyunglee 깃허브 

Scaled : softmax가 역전파 학습 시, gradient vanishing problem을 완화하기 위해서 스케일링을 해줍니다. 

dot-product : Q과 $K^{T}$의 내적으로 구합니다. 

       N : query의 sequence len

$D_{K}$ : query가 Projection된 dimension

       N : key의 sequence len 

$D_{K}$ : key가 Projection된 dimension

여기서 Q, K, V를 어떻게 구할 수 있는 것일까요?

Q, K, V의 길이는 위에서 설명드린 $num heads$에 의해서 결정되는데요, 트랜스포머는 $d_{model}$을 $num heads$로 나누어서 Q,K,V벡터의 차원을 결정하게 됩니다. $num heads$로 나누는 이유는 연산을 병렬로 처리하여 속도를 높이고, 연산된 후 출력값이 입력값과 같은 shape를 가질 수 있도록 하기 위해서 입니다.

오호! 그럼 Q, K, V벡터의 shape는 논문 기준으로 (512, 64)가 되겠군요!

img
출처 : slide_share / 약간의 커스터마이징

Q, K, V벡터를 어떻게 구하는지도 알아보았습니다.

기존 Attention에서는 이를 활용하여 Attention score를 구했습니다. 

앞서 Attention score를 구할 때 보지 못했던 $\sqrt{d_{k}}$를 볼 수 있는데요. 이것은 softmax에서 값이 너무 커지게 되면 발산하기 때문에 scale 해주었다고 생각하시면 됩니다. 그래서 이름도 scaled dot-product Attention입니다. 

1) scaled dot-product Attention

img
출처 : slide_share

위 이미지를 코드로 한번 구현해보겠습니다. 

여기서 $\sqrt{d_{k}}$가 의미하는 것은 key.shape의 -1을 의미합니다. 

def scaled_dot_product_attention(query, key, value, mask):
  # 우리는 배치단위로 나누어서 학습을 시킬 것이기 때문에 batch_size가 맨앞에 들어오게 됩니다. 
  # 일반적으로 맨 앞쪽에 적어준다고 하는군요 
  # query 크기 : (batch_size, num_heads, query의 문장 길이, d_model/num_heads)
  # key 크기 : (batch_size, num_heads, key의 문장 길이, d_model/num_heads)
  # value 크기 : (batch_size, num_heads, value의 문장 길이, d_model/num_heads)
  # padding_mask : (batch_size, 1, 1, key의 문장 길이)

  # Q와 K의 곱. 어텐션 스코어 행렬.
  # tf에서는 transpose를 이렇게 우하한 방법으로 하더군요. 
  # transpose_a는 query를 transpose_b는 key를 가르킵니다. 
  matmul_qk = tf.matmul(query, key, transpose_b=True)

  # 스케일링을 위해 dk의 루트값으로 나눠준다.
  # 여기서 d는 d_model/num_heads를 의미합니다. 
  depth = tf.cast(tf.shape(key)[-1], tf.float32) 
  logits = matmul_qk / tf.math.sqrt(depth)

  # 아직 설명드리지 않은 부분입니다. 일단, 잠시 스킵하겠습니다. 
  # 마스킹. 어텐션 스코어 행렬의 마스킹 할 위치에 매우 작은 음수값을 넣는다.
  # 매우 작은 값이므로 소프트맥스 함수를 지나면 행렬의 해당 위치의 값은 0이 된다.
  if mask is not None:
    logits += (mask * -1e9)

  # 소프트맥스 함수는 마지막 차원인 key의 문장 길이 방향으로 수행된다.
  # attention weight : (batch_size, num_heads, query의 문장 길이, key의 문장 길이)
  attention_weights = tf.nn.softmax(logits, axis=-1)

  # output : (batch_size, num_heads, query의 문장 길이, d_model/num_heads)
  output = tf.matmul(attention_weights, value)

  return output, attention_weights

Q, K, V는 실제 코드에서는 Dense 층으로 설정하고 이를 split함수를 이용하여 각각의 Attention에 들어갈 수 있도록 합니다. MultiHeadAttention에서 일어나는 5가지 일들을 확인해보도록 하겠습니다. 

 2) MultiHeadAttention

  -. $W_{Q}$, $W_{K}$, $W_{V}$에 해당하는 d_model 크기의 밀집층(Dense layer)을 지나가게 합니다.

  -. $W_{Q}$, $W_{K}$, $W_{V}$를 지정된 헤드 수(num_heads)만큼 나누어 병렬로 연산을 진행합니다. 

  -. Attention score를 구하기 위해서 scaled dot-product Attention을 실행합니다. 

  -. 나눠졌던 헤드들을 연결(concatenatetion)합니다. 

  -. $W_{O}$에 해당하는 Dense층을 지나게 합니다.

class MultiHeadAttention(tf.keras.layers.Layer):

  def __init__(self, d_model, num_heads, name="multi_head_attention"):
    super(MultiHeadAttention, self).__init__(name=name)
    self.num_heads = num_heads
    self.d_model = d_model

    assert d_model % self.num_heads == 0

    # d_model을 num_heads로 나눈 값.
    # 논문 기준 : 64
    self.depth = d_model // self.num_heads

    # WO에 해당하는 밀집층 정의
    self.dense = tf.keras.layers.Dense(units=d_model)

  # num_heads 개수만큼 q, k, v를 split하는 함수
  # 여기서 perm을 통해 순서를 변경하는 이유는 scaled dot-product Attention 연산시 
  # scaled_dot_product_attention func이 데이터를 입력받는 순서가  batch_szie, num_heads, query문장길이, key문장길이 순서 이기 때문입니다. 
  def split_heads(self, inputs, batch_size):
    inputs = tf.reshape(
        inputs, shape=(batch_size, -1, self.num_heads, self.depth))
    return tf.transpose(inputs, perm=[0, 2, 1, 3])

  def call(self, inputs):
    query, key, value, mask = inputs['query'], inputs['key'], inputs[
        'value'], inputs['mask']
    batch_size = tf.shape(query)[0]

    # WQ, WK, WV에 해당하는 밀집층 정의
    # 1. WQ, WK, WV에 해당하는 밀집층 지나기
    # q : (batch_size, query의 문장 길이, d_model)
    # k : (batch_size, key의 문장 길이, d_model)
    # v : (batch_size, value의 문장 길이, d_model)
    # 참고) 인코더(k, v)-디코더(q) 어텐션에서는 query 길이와 key, value의 길이는 다를 수 있다.
    query = tf.keras.layers.Dense(units=d_model)(query)
    key = tf.keras.layers.Dense(units=d_model)(key)
    value = tf.keras.layers.Dense(units=d_model)(value)
    
    # 2. 헤드 나누기
    # q : (batch_size, num_heads, query의 문장 길이, d_model/num_heads)
    # k : (batch_size, num_heads, key의 문장 길이, d_model/num_heads)
    # v : (batch_size, num_heads, value의 문장 길이, d_model/num_heads)
    query = self.split_heads(query, batch_size)
    key = self.split_heads(key, batch_size)
    value = self.split_heads(value, batch_size)

    # 3. 스케일드 닷 프로덕트 어텐션. 앞서 구현한 함수 사용.
    # (batch_size, num_heads, query의 문장 길이, d_model/num_heads)
    scaled_attention, _ = scaled_dot_product_attention(query, key, value, mask)
    # (batch_size, query의 문장 길이, num_heads, d_model/num_heads)
    scaled_attention = tf.transpose(scaled_attention, perm=[0, 2, 1, 3])

    # 4. 헤드 연결(concatenate)하기 : (batch_size, query의 문장 길이, d_model)
    # (batch_size, query의 문장 길이, num_heads, d_model/num_heads) -> (batch_size, query의 문장 길이, d_model) 만드는 과정 
    # 원래 d_model을 num_heads로 나누어서 d_model/num_heads를 구했으니 역으로 합치는 과정입니다. 
    concat_attention = tf.reshape(scaled_attention,
                                  (batch_size, -1, self.d_model))

    # 5. WO에 해당하는 밀집층 지나기
    # concat을 하고 나면 나오는 shape가 (seq_len, d_model)인데, 우리가 필요한 shape는 (d_model, d_model)이기에 Wo를 곱해준다. 
    # (batch_size, query의 문장 길이, d_model)
    outputs = self.dense(concat_attention)

    return outputs

3) Padding Mask 

앞서 구현한 scaled dot-product Attention 내부를 보면 mask라는 값을 인자로 받아서, 이 mask값에다가 -1e9라는 아주 작은 음수값을 곱한 후 어텐션 스코어 행렬에 더해주고 있습니다. 이 연산의 정체는 무엇일까요?

이는 입력 문장에 <PAD> 토큰이 있을 경우 어텐션에서 사실상 제외하기 위한 연산입니다. 예를 들어 <PAD>가 포함된 입력 문장의 셀프 어텐션의 예제를 봅시다. 이에 대해서 어텐션을 수행하고 어텐션 스코어 행렬을 얻는 과정은 다음과 같습니다.

img
출처 : slide_share

그런데 사실 단어 <PAD>의 경우에는 실질적인 의미를 가진 단어가 아닙니다. 그래서 트랜스포머에서는 Key의 경우에 <PAD> 토큰이 존재한다면 이에 대해서는 유사도를 구하지 않도록 마스킹(Masking)을 해주기로 했습니다. 여기서 마스킹이란 어텐션에서 제외하기 위해 값을 가린다는 의미입니다. 어텐션 스코어 행렬에서 행에 해당하는 문장은 Query이고, 열에 해당하는 문장은 Key입니다. 그리고 Key에 <PAD>가 있는 경우에는 해당 열 전체를 마스킹을 해줍니다.

img
출처 : slide_share

마스킹을 하는 방법은 어텐션 스코어 행렬의 마스킹 위치에 매우 작은 음수값을 넣어주는 것입니다. 여기서 매우 작은 음수값이라는 것은 -1,000,000,000과 같은 -무한대에 가까운 수라는 의미입니다. 현재 어텐션 스코어 함수는 소프트맥스 함수를 지나지 않은 상태입니다. 앞서 배운 연산 순서라면 어텐션 스코어 함수는 소프트맥스 함수를 지나고, 그 후 Value 행렬과 곱해지게 됩니다. 그런데 현재 마스킹 위치에 매우 작은 음수 값이 들어가 있으므로 어텐션 스코어 행렬이 소프트맥스 함수를 지난 후에는 해당 위치의 값은 0에 굉장히 가까운 값이 되어 단어 간 유사도를 구하는 일에 <PAD> 토큰이 반영되지 않게 됩니다.

img
출처 : slide_share

위 그림은 소프트맥스 함수를 지난 후를 가정하고 있습니다. 소프트맥스 함수를 지나면 각 행의 어텐션 가중치의 총 합은 1이 되는데, 단어 <PAD>의 경우에는 0이 되어 어떤 유의미한 값을 가지고 있지 않습니다.

패딩 마스크를 구현하는 방법은 입력된 정수 시퀀스에서 패딩 토큰의 인덱스인지, 아닌지를 판별하는 함수를 구현하는 것입니다. 아래의 함수는 정수 시퀀스에서 0인 경우에는 1로 변환하고, 그렇지 않은 경우에는 0으로 변환하는 함수입니다. 1의 값을 가진 위치의 열을 어텐션 스코어 행렬에서 마스킹하는 용도로 사용할 수 있습니다. 위 벡터를 스케일드 닷 프로덕트 어텐션의 인자로 전달하면, 스케일드 닷 프로덕트 어텐션에서는 위 벡터에다가 매우 작은 음수값인 -1e9를 곱하고, 이를 행렬에 더해주어 해당 열을 전부 마스킹하게 되는 것입니다.

코드로도 한번 살펴보겠습니다.

def create_padding_mask(x):
  mask = tf.cast(tf.math.equal(x, 0), tf.float32)
  # (batch_size, 1, 1, key의 문장 길이)
  return mask[:, tf.newaxis, tf.newaxis, :]
  
# 예로 하나 들어보겠습니다. 
print(create_padding_mask(tf.constant([[1, 21, 777, 0, 0]])))

# 결과 
tf.Tensor([[[[0. 0. 0. 1. 1.]]]], shape=(1, 1, 1, 5), dtype=float32)

3) Position-wise FFNN

Position-wise FFNN는 인코더와 디코더에서 공통적으로 가지고 있는 서브층이며, 완전 연결 FFNN(Fully-connected FFNN)을 적용하는 부분입니다. position(각 단어)마다 적용되기 때문에 position-wise라는 표현이 붙게 되었습니다. MultiHeadAttention의 결과로 나온 (seq_len, dmodel)의 크기를 가집니다.

img
출처 : slide_share

위의 그림에서 좌측은 인코더의 입력을 벡터 단위로 봤을 때, 각 벡터들이 멀티 헤드 어텐션 층이라는 인코더 내 첫번째 서브 층을 지나 FFNN을 통과하는 것을 보여줍니다. 이는 두번째 서브층인 Position-wise FFNN을 의미합니다. 

이를 구현하면 다음과 같습니다. 그냥 단순히 Dense 층을 의미하는 것이 너무 어렵게 생각하시면 안됩니다. 

  # 포지션 와이즈 피드 포워드 신경망 (두번째 서브층)
  outputs = tf.keras.layers.Dense(units=dff, activation='relu')(attention)
  outputs = tf.keras.layers.Dense(units=d_model)(outputs)

4) 잔차 연결과 층 정규화(Layer Normalization)

 -. 잔차 연결은 하위 층의 출력 텐서를 상위 층의 출력 텐서에 더해서 아래층의 표현이 네트워크 위쪽으로 흘러갈 수 있도록 합니다. 하위 층에서 학습된 정보가 데이터 처리 과정에서 손실되는 것을 방지합니다.

 -. 잔차 연결을 거친 결과는 이어서 층 정규화 과정을 거치게됩니다. 잔차 연결의 입력을 x, 잔차 연결과 층 정규화 두 가지 연산을 모두 수행한 후의 결과 행렬을 LN이라고 하였을 때, 잔차 연결 후 층 정규화 연산은 케라스에서는 LayerNormalization()를 이미 제공하고 있으므로, 이를 가져와 사용합니다.

attention = tf.keras.layers.LayerNormalization(epsilon=1e-6)(inputs + attention)

epsilon은 분모가 0이 되는 것을 방지하기 위한 파라미터이고, 자세한 내용은 관련 논문 또는 위키독스에서 참고하여 주시기 바랍니다. (관련 논문위키독스)

자! 드디어 인코더 하나가 끝이 났습니다. 이 모든 수식을 연결하여 기재해보도록 하겠습니다. 

def encoder_layer(dff, d_model, num_heads, dropout, name="encoder_layer"):
  inputs = tf.keras.Input(shape=(None, d_model), name="inputs")

  # 인코더는 패딩 마스크 사용
  padding_mask = tf.keras.Input(shape=(1, 1, None), name="padding_mask")

  # 멀티-헤드 어텐션 (첫번째 서브층 / 셀프 어텐션)
  attention = MultiHeadAttention(
      d_model, num_heads, name="attention")({
          'query': inputs, 'key': inputs, 'value': inputs, # Q = K = V
          'mask': padding_mask # 패딩 마스크 사용
      })

  # 드롭아웃 + 잔차 연결과 층 정규화
  attention = tf.keras.layers.Dropout(rate=dropout)(attention)
  attention = tf.keras.layers.LayerNormalization(
      epsilon=1e-6)(inputs + attention)

  # 포지션 와이즈 피드 포워드 신경망 (두번째 서브층)
  outputs = tf.keras.layers.Dense(units=dff, activation='relu')(attention)
  outputs = tf.keras.layers.Dense(units=d_model)(outputs)

  # 드롭아웃 + 잔차 연결과 층 정규화
  outputs = tf.keras.layers.Dropout(rate=dropout)(outputs)
  outputs = tf.keras.layers.LayerNormalization(
      epsilon=1e-6)(attention + outputs)

  return tf.keras.Model(
      inputs=[inputs, padding_mask], outputs=outputs, name=name)

그러나, 실제로는 Encoder를 여러 개 겹쳐서 사용하기 때문에 일부 코드를 조금 수정해보도록 하겠습니다. 

def encoder(vocab_size, num_layers, dff, d_model, num_heads, dropout, name="encoder"):

  inputs = tf.keras.Input(shape=(None,), name="inputs")

  # 인코더는 패딩 마스크 사용
  padding_mask = tf.keras.Input(shape=(1, 1, None), name="padding_mask")

  # 포지셔널 인코딩 + 드롭아웃
  # 들어오는 vocab_size와 d_model(model_dims)만큼 Embedding을 시켜줍니다. 
  embeddings = tf.keras.layers.Embedding(vocab_size, d_model)(inputs)
  embeddings *= tf.math.sqrt(tf.cast(d_model, tf.float32))
  embeddings = positional_encoding(vocab_size, d_model)(embeddings)
  outputs = tf.keras.layers.Dropout(rate=dropout)(embeddings)

  # 인코더를 num_layers개 쌓기
  for i in range(num_layers):
    outputs = encoder_layer(dff=dff, d_model=d_model, num_heads=num_heads,
        dropout=dropout, name="encoder_layer_{}".format(i),
    )([outputs, padding_mask])

  return tf.keras.Model(
      inputs=[inputs, padding_mask], outputs=outputs, name=name)

(5) Decoder 

Decoder에서는 딱 두가지만 보면 됩니다. 

look ahead mask와 Encoder의 값을 어떻게 가져와서 사용하는지가 중요한 포인트이고 나머지는 Encoder 부분과 대부분 유사합니다. 

1) look ahead mask

Decoder에서 가장 큰 이슈는 값이 순차적으로 들어오지 않는다는 점에 있습니다. 

연산 속도를 증가시키기 위해 입력데이터를 한번에 받기 때문에 모델이 예측해야하는 단어들도 보이게 됩니다. 

이를 모델이 보지 못하도록 가려주는 방법이 look ahead mask 방법입니다. 

img
출처 : 유튜브(https://youtu.be/xhY7m8QVKjo)

빨간색 부분이 마스킹 된 부분입니다.

빨간색이 실제 어텐션 연산에서 가리는 역할을 합니다.

이 덕분에 현재 단어를 기준으로 이전 단어들하고만 유사도를 구할 수 있습니다.

행을 Query, 열을 Key로 표현된 행렬임을 감안하고 천천히 행렬을 살펴봅시다.

예를 들어, Query 단어가 ‘나는’이라면 그 행에는 < s >까지의 열만 보입니다.

그 뒤 열은 아예 빨간색으로 칠해져 있어서 유사도를 구할 수 없도록 해놓았습니다.

저 빨간색 부분을 마스킹 함수로 구현해보겠습니다.

# 디코더의 첫번째 서브층(sublayer)에서 미래 토큰을 Mask하는 함수
def create_look_ahead_mask(x):
  seq_len = tf.shape(x)[1]
  look_ahead_mask = 1 - tf.linalg.band_part(tf.ones((seq_len, seq_len)), -1, 0)
  padding_mask = create_padding_mask(x) # 패딩 마스크도 포함
  return tf.maximum(look_ahead_mask, padding_mask)
  
# 예시 
print(create_look_ahead_mask(tf.constant([[1, 2, 0, 4, 5]])))

# 결과 
tf.Tensor(
[[[[0. 1. 1. 1. 1.]
   [0. 0. 1. 1. 1.]
   [0. 0. 1. 1. 1.]
   [0. 0. 1. 0. 1.]
   [0. 0. 1. 0. 0.]]]], shape=(1, 1, 5, 5), dtype=float32)

(tf.linalg.band_part가 익숙하지 않으신 분들은 한번 가지고 놀아보시는 것을 추천드립니다.)

2) Encoder-Decoder Attention 

디코더의 두번째 서브층은 MultiheadAttention을 수행한다는 점에서는 이전의 어텐션들(인코더와 디코더의 첫번째 서브층)과 같지만, 셀프 어텐션은 아닙니다.

셀프 어텐션은 Query, Key, Value가 같은 경우를 말하는데, 인코더-디코더 어텐션은 Query가 디코더인 행렬인 반면, Key와 Value는 인코더 행렬이기 때문입니다. 다시 한 번 각 서브층에서의 Q, K, V의 관계를 정리해봅시다.

  * 인코더의 첫번째 서브층 : Query = Key = Value

  * 디코더의 첫번째 서브층 : Query = Key = Value

  * 디코더의 두번째 서브층 : Query : 디코더 행렬 / Key = Value : 인코더 행렬

디코더의 두번째 서브층을 확대해보면, 다음과 같이 인코더로부터 두 개의 화살표가 그려져 있습니다.

값만 Encoder에서 가져오고 나머지 연산은 모두 Encoder 내부에서 이루어진 연산과 동일합니다. 

img
출처 : tensorflow tutorial

이를 코드로 한번 구현해보겠습니다. 

def decoder_layer(dff, d_model, num_heads, dropout, name="decoder_layer"):
  inputs = tf.keras.Input(shape=(None, d_model), name="inputs")
  enc_outputs = tf.keras.Input(shape=(None, d_model), name="encoder_outputs")

  # 룩어헤드 마스크(첫번째 서브층)
  look_ahead_mask = tf.keras.Input(
      shape=(1, None, None), name="look_ahead_mask")

  # 패딩 마스크(두번째 서브층)
  padding_mask = tf.keras.Input(shape=(1, 1, None), name='padding_mask')

  # 멀티-헤드 어텐션 (첫번째 서브층 / 마스크드 셀프 어텐션)
  attention1 = MultiHeadAttention(
      d_model, num_heads, name="attention_1")(inputs={
          'query': inputs, 'key': inputs, 'value': inputs, # Q = K = V
          'mask': look_ahead_mask # 룩어헤드 마스크
      })

  # 잔차 연결과 층 정규화
  attention1 = tf.keras.layers.LayerNormalization(
      epsilon=1e-6)(attention1 + inputs)

  # 멀티-헤드 어텐션 (두번째 서브층 / 디코더-인코더 어텐션)
  attention2 = MultiHeadAttention(
      d_model, num_heads, name="attention_2")(inputs={
          'query': attention1, 'key': enc_outputs, 'value': enc_outputs, # Q != K = V
          'mask': padding_mask # 패딩 마스크
      })

  # 드롭아웃 + 잔차 연결과 층 정규화
  attention2 = tf.keras.layers.Dropout(rate=dropout)(attention2)
  attention2 = tf.keras.layers.LayerNormalization(
      epsilon=1e-6)(attention2 + attention1)

  # 포지션 와이즈 피드 포워드 신경망 (세번째 서브층)
  outputs = tf.keras.layers.Dense(units=dff, activation='relu')(attention2)
  outputs = tf.keras.layers.Dense(units=d_model)(outputs)

  # 드롭아웃 + 잔차 연결과 층 정규화
  outputs = tf.keras.layers.Dropout(rate=dropout)(outputs)
  outputs = tf.keras.layers.LayerNormalization(
      epsilon=1e-6)(outputs + attention2)

  return tf.keras.Model(
      inputs=[inputs, enc_outputs, look_ahead_mask, padding_mask],
      outputs=outputs,
      name=name)

이렇게 만들어진 Decoder를 여러 개로 연결해보겠습니다. 

def decoder(vocab_size, num_layers, dff,
            d_model, num_heads, dropout,
            name='decoder'):
  inputs = tf.keras.Input(shape=(None,), name='inputs')
  enc_outputs = tf.keras.Input(shape=(None, d_model), name='encoder_outputs')

  # 디코더는 룩어헤드 마스크(첫번째 서브층)와 패딩 마스크(두번째 서브층) 둘 다 사용.
  look_ahead_mask = tf.keras.Input(
      shape=(1, None, None), name='look_ahead_mask')
  padding_mask = tf.keras.Input(shape=(1, 1, None), name='padding_mask')

  # 포지셔널 인코딩 + 드롭아웃
  embeddings = tf.keras.layers.Embedding(vocab_size, d_model)(inputs)
  embeddings *= tf.math.sqrt(tf.cast(d_model, tf.float32))
  embeddings = PositionalEncoding(vocab_size, d_model)(embeddings)
  outputs = tf.keras.layers.Dropout(rate=dropout)(embeddings)

  # 디코더를 num_layers개 쌓기
  for i in range(num_layers):
    outputs = decoder_layer(dff=dff, d_model=d_model, num_heads=num_heads,
        dropout=dropout, name='decoder_layer_{}'.format(i),
    )(inputs=[outputs, enc_outputs, look_ahead_mask, padding_mask])

  return tf.keras.Model(
      inputs=[inputs, enc_outputs, look_ahead_mask, padding_mask],
      outputs=outputs,
      name=name)

이 모든 것을 다 혼합하여 Transformer를 정의해보겠습니다. 

def transformer(vocab_size, num_layers, dff,
                d_model, num_heads, dropout,
                name="transformer"):

  # 인코더의 입력
  inputs = tf.keras.Input(shape=(None,), name="inputs")

  # 디코더의 입력
  dec_inputs = tf.keras.Input(shape=(None,), name="dec_inputs")

  # 인코더의 패딩 마스크
  enc_padding_mask = tf.keras.layers.Lambda(
      create_padding_mask, output_shape=(1, 1, None),
      name='enc_padding_mask')(inputs)

  # 디코더의 룩어헤드 마스크(첫번째 서브층)
  look_ahead_mask = tf.keras.layers.Lambda(
      create_look_ahead_mask, output_shape=(1, None, None),
      name='look_ahead_mask')(dec_inputs)

  # 디코더의 패딩 마스크(두번째 서브층)
  dec_padding_mask = tf.keras.layers.Lambda(
      create_padding_mask, output_shape=(1, 1, None),
      name='dec_padding_mask')(inputs)

  # 인코더의 출력은 enc_outputs. 디코더로 전달된다.
  enc_outputs = encoder(vocab_size=vocab_size, num_layers=num_layers, dff=dff,
      d_model=d_model, num_heads=num_heads, dropout=dropout,
  )(inputs=[inputs, enc_padding_mask]) # 인코더의 입력은 입력 문장과 패딩 마스크

  # 디코더의 출력은 dec_outputs. 출력층으로 전달된다.
  dec_outputs = decoder(vocab_size=vocab_size, num_layers=num_layers, dff=dff,
      d_model=d_model, num_heads=num_heads, dropout=dropout,
  )(inputs=[dec_inputs, enc_outputs, look_ahead_mask, dec_padding_mask])

  # 다음 단어 예측을 위한 출력층
  outputs = tf.keras.layers.Dense(units=vocab_size, name="outputs")(dec_outputs)

  return tf.keras.Model(inputs=[inputs, dec_inputs], outputs=outputs, name=name)

이렇게 해서 데이터를 활용해 챗봇을 구현해보면 아래와 같이 아주 신박한 대답을 해준다.

꽤 그럴듯하게 대답하여 매우 놀랐다. 이를 활용하여 사이트나 앱에서 자동으로 응답을 해주는 챗봇을 만들어도 될 것 같다는 생각을 하였다. 

img
직접촬영한 이미지

6. 연구동향 

 -. Transformer 이 후 크게 세가지 관점에서 Transformer 모델을 변형한 모델이 활발히 연구되고 있습니다. 

  (1) 모델의 효율성 측면(Model Efficiency)

    * 한계점 : self-Attention 모듈로 긴 입력 시퀀스를 처리할 때, 연산과 메모리의 비효율성이 존재합니다. 

    * 대안방안 : LightWeight Attention, Divide and conquer methods 방법이 연구중에 있습니다. 

  (2) Model Generalization

    * 한계점 : Transformer는 기본적으로 유연한 구조의 성격을 지니며, 입력데이터의 구조적 bias에 대해서 최소한의 가정만을 하고 있어 적은 데이터셋으로 robust한 모델 학습에 어려움이 있습니다. 

    * 대안방안 : Structural bias나 regularization의 도입, Large-scale unlabeled data에 대한 사전학습이 연구되고 있습니다.

  (3) Model Adapation 

    * 이미지, 음성 등의 다양한 downstream task에 적용하기 위한 변형 연구가 이루어지고 있습니다. 

글이 너무 길어서 챗봇은 별도의 포스팅을 통해서 인사드리겠습니다. 

긴 글 읽어주셔서 너무 감사합니다. 

답글 남기기