자연어 처리에서 크롤링 등으로 얻어낸 corpus data가 필요에 맞게 전처리되지 않은 상태라면, 용도에 맞게 토큰화(tokenization) & 정제(cleaning) & 정규화(normalization)하는 일을 하게 된다. 

이중 토큰화에 대해 알아보자.

 

토큰화(tokenization): 주어진 코퍼스(corpus)에서 토큰(token)이라 불리는 단위로 나뉘는 작업(보통 의미있는 단위로 토큰을 정의한다)

 

1. 단어 토큰화(Word Tokenization)

토큰의 기준을 단어(word)로 하는 경우, 단어 토큰화(word tokenization)라고 한다. 

*단어(word)는 단어 단위 외에도 단어구, 의미를 갖는 문자열로도 간주되기도 한다.

 

input) Time is an illusion. Lunchtime double so!

 

위 입력으로부터 토큰화 작업을 시키면 다음과 같다.

 

output) 'Time', 'is', 'an', 'illusion', 'Lunchtime', 'double', 'so'

 

위 출력을 나타내기 위해, 구두점을 지운 뒤에 띄어쓰기(whitespace)를 기준으로 잘라냈다.(가장 기초)

보통 토큰화 작업은 단순히 구두점이나 특수문자를 전부 제거하는 정제(cleaning) 작업을 수행하는 것만으로 해결되지 않는다. 구두점이나 특수문자를 전부 제거하면 토큰의 의미를 잃어버리는 경우가 발생하기도 한다. 심지어 띄어쓰기 단위로 자르면 사실상 단어 토큰이 구분되는 영어와 달리, 한국어는 띄어쓰기만으로는 단어 토큰을  구분하기 어렵다.

 

2. 토큰화 중 생기는 선택의 순간

토큰화를 하다보면, 예상하지 못한 경우가 있어 토큰화의 기준을 생각해봐야 하는 경우가 발생한다. 물론, 이러한 선택은 해당 데이터를 가지고 어떤 용도로 사용할 것인가에 따라 그 용도에 영향이 없는 기준으로 정하면 된다.

 

ex) Don't be fooled by the dark sounding name, Mr. Jone's Orphanage is as cheery as cheery goes for a pastry shop.

 

" ' " 가 들어간 상황에서 Dont't 와 Jone's는 어떻게 토큰화할 수 있을까?

 

[Dont't, Dont t, Dont, Do n't, Jone's, Jone s, Jone, Jones]

 

원하는 결과가 나오도록 토큰화 도구를 직접 설계할 수 있겠지만, 기존에 공개된 도구들을 사용하였을 때의 결과가 사용자의 목적과 일치한다면 해당 도구를 사용할 수 있을 것이다. NLTK는 영어 코퍼스를 토큰화하기 위한 도구들을 제공한다.

 

word_tokenize를 사용한 예를 살펴보자.

 

from nltk.tokenize import word_tokenize

print(word_tokenize("Don't be fooled by the dark sounding name, Mr. Jone's Orpanage is as cheery as cheery goes for a pastry shop."))

['Do', "n't", 'be', 'fooled', 'by', 'the', 'dark', 'sounding', 'name', ',', 'Mr.', 'Jone', "'s", 'Orpanage', 'is', 'as', 'cheery', 'as', 'cheery', 'goes', 'for', 'a', 'pastry', 'shop', '.']

word_tokenize는 Dont't 를 Do 와 n't로 불리하였으며, 반면 Jone's는 Jone과 's로 분리한 것을 확인할 수 있다.

 

wordPunctTokenizer를 사용한 예를 살펴보자

 

from nltk.tokenize import WordPunctTokenizer
print(WordPunctTokenizer().tokenize("Don't be fooled by the dark sounding name, Mr. Jone's Orpanage is as cheery as cheery goes for a pastry shop."))

['Don', "'", 't', 'be', 'fooled', 'by', 'the', 'dark', 'sounding', 'name', ',', 'Mr', '.', 'Jone', "'", 's', 'Orpanage', 'is', 'as', 'cheery', 'as', 'cheery', 'goes', 'for', 'a', 'pastry', 'shop', '.']

WordPunctTokenizer는 구두점을 별도로 분류하는 특징을 갖고 있기 때문에, word_tokenize와 달리 Don't를 Don과 '와 t로 분리하였으며, 이와 마찬가지로 Jone's를 Jone과 '와 s로 분리한 것을 확인할 수 있다.

 

케라스 또한 토큰화 도구로서 text_to_word_sequence를 지원한다. 

text_to_word_sequence를 사용한 예를 살펴보자

 

from tensorflow.keras.preprocessing.text import text_to_word_sequence
print(text_to_word_sequence("Don't be fooled by the dark sounding name, Mr. Jone's Orphanage is as cheery as cheery goes for a pastry shop."))

["don't", 'be', 'fooled', 'by', 'the', 'dark', 'sounding', 'name', 'mr', "jone's", 'orphanage', 'is', 'as', 'cheery', 'as', 'cheery', 'goes', 'for', 'a', 'pastry', 'shop']

text_to_word_sequence는 기본적으로 모든 알파벳을 소문자로 바꾸면서 마침표나 컴마, 느낌표 등의 구두점을 제거한다. 하지만 어퍼스트로피는 보존하는 것을 볼 수 있다.

 

 

토큰화에서 고려해야할 사항

토큰화작업은 단순하게 코퍼스에서 구두점을 제외하고 공백 기준으로 잘라내는 작업이라 생각하면 안된다. 

이유는 다음과 같다.

1) 구두점이나 특수 문자를 단순 제외해서는 안된다.

코퍼스에 대한 정제 작업을 진행하다보면, 구두점 조차도 하나의 토큰으로 분류하기도 한다. 

이러한 점에 있어 해결법은 단어의 빈도수 확인 및 직접 확인을 통해 사전을 만들어주는 경우, 혹은 오픈소스로 되어있는 단어집을 사용하는 방법이 있다.

2) 줄임말과 단어 내에 띄어쓰기가 있는 경우.

토큰화 작업에서 종종 영어권 언어의 "'"는 압축된 단어를 다시 펼치는 역할을 하기도 한다.

사용 용도에 따라, 하나의 단어 사이에 띄어쓰기가 있는 경우도 하나의 토큰으로 봐야하는 경우가 있을 수 있다. 

 

3) 표준 토큰화 예제

표준으로 쓰이는 토큰화 방법 중 하나인 Peen Treebank Tokenization의 규칙을 알아보자.

Rule 1) 하이푼으로 구성된 단어는 하나로 유지.

Rule 2) dont't 와 같이 아포스토로피가 함께하는 단어는 분리해준다.

 

예를 살펴보자.

input) "Starting a home-based restaurant may be an ideal. it doesn't have a food chain or restaurant of their own."

 

from nltk.tokenize import TreebankWordTokenizer
tokenizer=TreebankWordTokenizer()
text="Starting a home-based restaurant may be an ideal. it doesn't have a food chain or restaurant of their own"
print(tokenizer.tokenize(text))

['Starting', 'a', 'home-based', 'restaurant', 'may', 'be', 'an', 'ideal.', 'it', 'does', "n't", 'have', 'a', 'food', 'chain', 'or', 'restaurant', 'of', 'their', 'own']

결과는, 각 Rule1)과 Rule2)에 따라 home-based는 하나의 토큰으로 취급하고 있으며, doesn't의 경우 does와 n't로 분리되었음을 볼 수 있다.

 

4. 문장 토큰화(Sentence Tokenization)

대부분 갖고있는 코퍼스가 정제되지 않은 상태라면, 코퍼스는 문장 단위로 구분되어있지 않을 가능성이 높다.

이를 사용하고자 하는 용도에 맞게 하기 위해서는 문장 토큰화가 필요할 수 있다.

 

어떤 기준으로 문장을 나누어야할까? 코퍼스가 어떤 국적의 언어인지, 또는 해당 코퍼스 내에서 특수문자들이 어떻게 사용되고 있는지에 따라서 직접 규칙을 정의해야 한다. 이는 또한 100%의 정확성을 얻는 것은 아니다. 

NLTK에서는 영어 문장의 토큰화를 수행하는 sent_tokenize를 지원한다.

 

코드로 살펴보자.

 

from nltk.tokenize import sent_tokenize
text="His barber kept his word. But keeping such a huge secret to himself was driving him crazy. Finally, the barber went up a mountain and almost to the edge of a cliff. He dug a hole in the midst of some reeds. He looked about, to make sure no one was near."
print(sent_tokenize(text))

['His barber kept his word.', 'But keeping such a huge secret to himself was driving him crazy.', 'Finally, the barber went up a mountain and almost to the edge of a cliff.', 'He dug a hole in the midst of some reeds.', 'He looked about, to make sure no one was near.']

위 코드는 text에 저장된 여러 개의 문장들로부터 문장을 구분하는 코드이다. 

이번에는 마침표가 여러번 등장하는 경우를 살펴보자.

 

from nltk.tokenize import sent_tokenize
text="I am actively looking for Ph.D. students. and you are a Ph.D student."
print(sent_tokenize(text))

['I am actively looking for Ph.D. students.', 'and you are a Ph.D student.']

 

NLTK는 단순히 마침표를 구분자로 하여 문장을 구분하지 않았기 때문에, ph.D.를 문장 내의 단어로 인식하여 성공적으로 인식하는 것을 볼 수 있다.

 

한국어 문장 토큰화 도구 KSS(Korean Sentence Splitter)

import kss

text="대부분 갖고있는 코퍼스가 정제되지 않은 상태라면, 코퍼스는 문장 단위로 구분되어있지 않을 가능성이 높다.이를 사용하고자 하는 용도에 맞게 하기 위해서는 문장 토큰화가 필요할 수 있다."
print(kss.split_sentences(text))

['대부분 갖고있는 코퍼스가 정제되지 않은 상태라면, 코퍼스는 문장 단위로 구분되어있지 않을 가능성이 높다.', '이를 사용하고자 하는 용도에 맞게 하기 위해서는 문장 토큰화가 필요할 수 있다.']

 

5. 토큰화가 어려운 한국어

한국어는 띄어쓰기가 되어있는 단어를 '어절'이라 한다. 어절 토큰화는 NLP에서 지양되고 있다. 어절 토큰화와 단어 토큰화가 같지 않기 때문인데, 그 근본적인 이유는 한국어가 영어와는 다르게 다른 형태를 가지는 언어인 교착어라는 점에서 기인한다.

 

교착어: 언어의 형태적 유형의 하나. 실질적인 의미를 가진 단어 또는 어간에 문법적인 기능을 가진 요소가 차례로 결합함으로써 문장 속에서의 문법적인 역할이나 관계의 차이를 나타내는 언어로, 한국어ㆍ터키어ㆍ일본어ㆍ핀란드어 따위가 여기에 속한다.

 

자연어 처리를 하다보면 같은 단어여도 서로 다른 조사가 붙어 다른 단어로 인식이 되는 경우가 많다. 대부분 한국어 NLP에서 조사는 분리해줄 필요가 있다.

 

한국어 토큰화에서는 형태소(morpheme)란 개념을 반드시 알아야 한다.

 

형태소: 단어를 분석한 단위로, 뜻을 가진 가장 작은 말의 단위

 

또한 한국어는 띄어쓰기가 영어보다 잘 지켜지지 않는다.

대부분의 한국어 경우 띄어쓰기가 틀렸거나, 잘 지켜지지 않은 코퍼스가 많다. 한국어는 영어권 언어와 달리 띄어쓰기가 어렵고 잘 지켜지지 않는 경향이 있다.

하지만, 실제로 잘 이루어지지 않더라도 이해가 가능한것이 또 한국어이다.

 

[한국어]

지금이렇게띄어쓰기를안한다해도이해못하는사람없겠죠?

 

[영어]

Itisdifficulttounderstandenglishbecauseitdidn'tsplitsentence

 

이 차이는 한국어(모아쓰기 방식)와 영어(풀어쓰기 방식)라는 언어적 특성의 차이 때문이다.

 

'Deep Learning > NLP' 카테고리의 다른 글

트랜스포머(Transformer)(2)  (0) 2021.03.04
트랜스포머(Transformer)(1)  (0) 2021.03.04
Transfer Learning(전이 학습)  (0) 2021.03.02

 

2021/03/04 - [Deep Learning/Deep Leaning inside] - 트랜스포머(Transformer)(1)

 

8)패딩 마스크(Padding Mask)

앞서 포스팅한 Scaled dot-product attention함수 내부를 보면 mask라는 값을 인자로 받아서, 이 mask값에다가 -1e9라는 아주 작은 음수값을 곱한 후 어텐션 스코어 행렬에 더해준다. 

이는 입력 문장에 <PAD>토큰이 있을 경우 어텐션에서 사실상 제외하기 위한 연산이다. 

그림을 통해 이해해보자.

 

사실 단어 <PAD>의 경우에는 실질적인 의미를 가진 단어가 아니다. 그렇기에 트랜스포머에서는 Key의 경우에 <PAD> 토큰이 존재한다면 이에 대해 유사도를 구하지 않도록 마스킹을 해주기로 했다.

*masking이란 어텐션에서 제외하기 위해 값을 가린다는 의미이다.

어텐션 스코어 행렬에서 행에 해당하는 문장은 Query, 열에 해당하는 문장은 Key이다. 그리고 Key에 <PAD>가 있는 경우에는 해당 열 전체를 masking 해준다.

 

masking하는 방법은 어텐션 스코어 행렬의 마스킹 위치에 매우 작은 음수값을 넣어주는 것이다. 현재 어텐션 스코어 함수는 소프트맥스 함수를 지나지 않은 상태이다. 현재 마스킹 위치에 매우 작은 음수 값이 들어가 있으므로 어텐션 스코어 행렬이 소프트맥스 함수를 지난 후에는 해당 위치의 값은 0에 근사한 값을 갖게 되어 단어간 유사도를 구하는 일에 <PAD> 토큰은 반영되지 않는다는 의미이다.

위 그림은 소프트맥스 함수를 지난 후의 그림이다. 각 행의 어텐션 가중치는 총합 1이 되는데, <PAD>의 경우 0이되어 어떤 값도 갖고 있지 않다.

 

패딩마스크를 구현하는 방법은 입력된 정수 시퀀스에서 패딩 토큰의 인덱스인지, 아닌지를 판별하는 함수를 구현하는 것이다. 

 

def create_padding_mask(x):
    mask=tf.cast(tf.math.equal(x,0),tf.float32)
    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)

위 함수는 정수 시퀀스에서 패딩 토큰의 인덱스인지 아닌지를 판별하는 함수이다. 

0인경우에는 1로 변환하고, 그렇지 않은경우에는 0으로 변환하는 함수이다.

 

결과를 살펴보면 위 벡터를 통해서 1의 값을 가진 위치의 열을 어텐션 스코어 행렬에서 마스킹하는 용도로 사용할 수 있다. 위 벡터를 Scaled dot-product attention 인자로 전달하면, scaled dot-product attention에는 매우 작은 음수값인 -1e9를 곱하고 이를 행렬에 더해주어 해당 열을 전부 마스킹하게 되는 것이다.

 

 

포지션-와이즈 피드 포워드 신경망(Position-wise FFNN)

포지션-와이즈 FFNN는 쉽게 말하면 완전 연결(Fully-connected FFNN)이라고 해석할 수 있다. 다음 수식을 살펴보자.

-x는 앞서 멀티 헤드 어텐션의 결과로 나온(seq_len,d(model))의 크기를 가지는 행렬을 말한다. 가중치 행렬 W1은 (d(model),d(ff))의 크기를 가지고, 가중치 행렬 W2는 (d(ff),d(model))의 크기를 가진다. 

*논문에서는 은닉층의 크기인 d(ff)는 2047의 크기를 가진다.

 

여기서 매개변수 W1,b1,W2,b2는 하나의 인코더 층 내에서는 다른 문장, 다른 단어들마다 정확하게 동일하게 사용된다. 하지만 인코더 층마다 다른 값을 가진다.

 

위 그림에서 좌측은 인코더의 입력을 벡터 단위로 봤을 떄, 각 벡터들이 멀티헤드 어텐션 층이라는 인코더 내 첫번째 서브 층을 지나, FFNN을 통과하는 과정을 보여준다.

위 그림을 코딩으로 구현해보자.

 

outputs=tf.keras.layers.Dense(units=dff,activation='relu')(attention)
outputs=tf.keras.layers.Dense(units=d_model)(outputs)

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

트랜스포머에서는 위 그림처럼 두 개의 서브층을 가진 인코더에 추가적으로 사용하는 기법이 있다. 

바로 Add&Norm이다. 더 정확히는 이를 잔차 연결(residual connection) 과 층 정규화(layer nomalization)이라 부른다.

 

1)잔차 연결(Residual connection)

다음 식을 확인해보자.

위 그림은 입력 x와 x에 대한 어떤 함수 F(x)의 값을 더한 H(x)의 구조를 보여준다. 여기서 F(x)는 트랜스포머에서 서브층에 해당된다. 다시 말해 잔차 연결은 서브층의 입력과 출력을 더하는 것을 말한다.

위에서 언급했듯이 서브층의 입력과 출력은 동일한 차원을 갖고 있으므로, 서브층의 입력과 서브층의 출력은 연산을 할 수 있다.

 

2)층 정규화(Layer Normalization)

잔차 연결을 거친 결과는 이어, 층 정규화 과정을 거친다. 잔차 연결의 입력을 x, 잔차 연결과 층 정규화 두 가지 연산을 모두 수행한 후의 결과 행렬을 LN이라 하였을 때, 잔차 연결 후 층 정규화 연산을 수식으로 표현하면 다음과 같다.

층 정규화란 텐서의 마지막 차원에 대해서 평균과 분산을 구하고, 이를 가지고 어떤 수식을 통해 값을 정규화하여 학습을 돕는다. 

*텐서의 마지막 차원이란 트랜스포머에서는 d(model)차원을 의미한다.

층 정규화를 수행한 후에는 벡터 x(i)는 ln(i) 라는 벡터로 정규화가 된다.

수식을 이해해보자. 과정은 다음과 같다.

1. 평균과 분산을 통한 정규화

2. 감마와 베타 도입

우선,평균과 분사을 통해 벡터 x(i)를 정규화 해준다. x(i)는 벡터인 반면, 평균과 분산은 스칼라이다. 벡터 xi의 각 차원을 k라고 하였을 때, x(i,k)는 다음과 같이 정규화 할 수있다.

여기서 입실론은 분모가 0이되는 것을 방지하는 값이다.

γ(감마)와 β(베타) 벡터를 준비하자. 이들의 초깃값은 각각 1,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,'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)

                   

인코더의 입력으로 들어가는 문장에는 패딩이 있을 수 있으므로, 어텐션 시 패딩 토큰을 제외하도록 패딩 마스크를 사용한다. 이는 MultiHeadAttention 함수의 mask의 인자값으로 padding_mask가 들어가는 이유이다.

인코더는 총 두 개의 서브층으로 이루어지는데, 멀티 헤드 어텐션과 피드 포워드 신경망이다. 각 서브층 이후에는 드롭 아웃, 잔차 연결과 층 정규화가 수행된다.

 

인코더 쌓기

인코더 층의 내부 아키텍처에 대해서 이해해보았다. 이러한 인코더 층을 num_layers개만큼 쌓고, 마지막 인코더 층에서 얻는 (seq_len,d_model)크기의 행렬을 디코더로 보내서 트랜스포머 인코더의 인코딩 연산이 끝나게 된다.

다음 코드는 인코더 층을 num_layers개만큼 쌓는 코드이다.

 

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')
    
    #포지셔널 인코딩+드롭아웃
    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=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)

 

인코더에서 디코더로(From Encoder To Decoder)

앞서 인코더는 총 num_layers만큼의 층 연산을 순차적으로 처리한 후, 마지막 층의 인코더의 출력을 디코더에 전달해준다. 인코더 연산이 끝났으므로, 디코더 연산이 시작되어 다시 디코더 또한 num_layers만큼의 연산을 한다. 

이때마다, 인코더가 보낸 출력을 각 디코더 층 연산에 사용한다. 

 

디코더의 첫번째 서브층: 셀프 어텐션과 룩 어헤드 마스크

위 그림과 같이 디코더도 동일하게 임베딩 층과 포지셔널 인코딩을 거친 후의 문장 행렬이 입력된다. 

트랜스포머 또한 seq2seq와 마찬가지로 교사 강요(Teacher Forcing)을 사용하여 훈련되므로 학습 과정에서 디코더는 번역할 문장에 해당되는 <sos> je suis etudiant의 문장 행렬을 한 번에 입력받는다. 그 후, 디코더는 이 문장 행렬로부터 각 시점의 단어를 예측하도록 훈련된다.

 

<문제점>

seq2seq의 디코더에 사용되는 RNN 계열의 신경망은 입력 단어를 매 시점마다 순차적으로 받으므로 다음 단어 예측에 현재 시점 이전에 입력된 단어들만 참고 할 수 있다.

이에 반해, 트랜스포머는 문장 행렬로 입력을 한번에 받으므로 현재 시점의 단오를 예측하고자 할 떄, 입력 문장 행렬로부터 미래시점의 단어까지도 참고할 수 있는 현상이 발생한다.

 

이 문제점을 해결하기 위해 트랜스포머의 디코더에서는 현재 시점의 예측에서 현재 시점보다 미래에 있는 단어들을 참고하지 못하도록 룩어헤드 마스크를 도입했다.

룩어헤드 마스크(look ahead mask)는 디코더의 첫번째 서브층에서 이루어진다.

디코더의 첫번째 서브층인 멀티 헤드 셀프어텐션층은 인코더의 첫번째 서브층인 멀티 헤드 셀프 어텐션 층과 동일한 연산을 한다. 차이점은 마스킹을 적용한다는 점이다. 

위 그림과 같이 셀프 어텐션을 통해 어텐션 스코어 행렬을 얻는다.

이후 자기 자신보다 미래에 있는 단어들은 참고하지 못하도록 마스킹 처리한다.

마스킹 된 후의 어텐션 스코어 행렬의 각 행을 보면 자기 자신과 그 이전 단어들만을 참고할 수 있음을 볼 수있다.

그 외에는 인코더의 첫 번째 서브층과 같다.

룩어헤드 마스크의 구현을 알아보면 패딩마스크와 마찬가지로 앞서 구현한 스케일드 닷 프로덕트 어텐션 함수에 mask라는 인자로 전달된다. 패딩 마스크를 써야하는 경우, 스케일드 닷 프로덕트 어텐션 함수에 패딩 마스크를 전달하고, 룩어헤드 마스킹을 써야 하는 경우에는 스케일드 닷 프로덕트 어텐션 함수에 룩어헤드 마스크를 전달하게 된다.

 

 

트랜스포머에는 총 3가지 어텐션이 존재한다. 모두 멀티 헤드 어텐션을 수행하고, 멀티 헤드 어텐션 함수 내부에서 스케일드 닷 프로덕트 어텐션 함수를 호출하는데 각 어텐션 시 함수에 전달하는 마스킹은 다음과 같다.

-인코더의 셀프 어텐션: 패딩마스크 전달

-디코더의 첫번째 서브층인 마스크드 셀프 어텐션: 룩어헤드 마스크를 전달

-디코더의 두번째 서브층인 인코더-디코더 어텐션: 패딩 마스크를 전달

 

이때, 룩어헤드 마스크를 한다고 해서 패딩 마스크가 불필요한 것이 아니므로 룩어헤드 마스크는 패딩 마스크를 포함할 수 있도록 구현한다. 

 

#디코더의 첫번쨰 서브층(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)

룩어헤드 마스크이므로 삼각형 모양의 마스킹이 형성되면서, 패딩 마스크가 포함되어져 있으므로 세번째 열이 마스킹 되었다.

 

디코더의 두번째 서브층: 인코더 디코더 어텐션

디코더의 두번째 서브층은 멀티 헤드 어텐션을 수행한다는 점에서는 이전의 어텐션들과 같지만, 셀프 어텐션은 아니다.

셀프 어텐션은 Query,Key,Value가 같은 경우를 말하는데, 인코더-디코더 어텐션은 Query가 디코더인 행렬인 반면, Key와 Value는 인코더 행렬이기 때문이다.

디코더의 두번째 서브층을 확대해보면, 위와 같은 그림이다.

두 개의 (빨간)화살표는 각각 Key,Value를 의미하며, 이는 인코더의 마지막 층에서 온 행렬로부터 얻는다.

반면, Query는 디코더의 첫번쨰 서브층의 결과 행렬로부터 얻는다는 점이 다르다.

Query가 디코더 행렬, Key가 인코더 행렬일 때, 어텐션 스코어 행렬을 구하는 과정은 아래 그림과 같다.

 

디코더 구현하기

우선 코딩을 살펴보자.

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,'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,'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)

 

디코더는 총 세개의 서브층으로 구성된다. 첫번째와 두번째 서브층 모두 멀티 헤드 어텐션이지만, 첫번째 서브층은 mask의 인자값으로 look_ahead_mask가 들어간다.

반면, 두번째 서브층은 mask의 인자값으로 padding_mask가 들어가는 것을 확인할 수 있다.

이유는 첫번째 서브층은 마스크드 셀프 어텐션을 수행하기 때문이다. 세개의 서브층 모두 서브층 연산 후에는 드롭 아웃, 잔차 연결, 층 정규화가 수행되는 것을 알 수 있다.

 

디코더 쌓기

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)

 

이 또한 앞선 인코더 쌓기와 동일하다.

트랜스포머 전체 구현하기

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])
    
    #디코더의 출력은 de_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)

 

트랜스포머 하이퍼파라미터 정하기

앞선 과정까지는 전체 모델에 대한 이해와 이 모델을 직접 파이썬으로 구현하는 과정을 알아보았다. 

이제는 학습을 시키기 위해 하이퍼파라미터를 지정해보자.

단어 집합의 크기는 임의로 9000으로 정한다. 단어 집합의 크기로부터 룩업 테이블을 수행할 임베딩 테이블과 포지셔널 인코딩 행렬의 행의 크기를 결정할 수 있다.

 

여기서는 논문에 제시한 것과는 조금 다르게 하이퍼파라미터를 정해본다. 인코더와 디코더의 층의 개수 num_laters는 4개, 인코더와 디코더의 포지션 와이즈 피드 포워드 신경망은 은닉층 d(ff) 128, d(model) 128, 멀티-헤드 어텐션은 병렬적 사용으로 num_heads 4로 지정해본다. 

이렇게 된다면 d(v)는 128/4로 32가 되겠다

 

small_transformer=transformer(
vocab_size=9000,
num_layers=4,
dff=512,
d_model=128,
num_heads=4,
dropout=0.3,
name='small_transformer')

tf.keras.utils.plot_model(small_transformer,to_file='C:/data/small_transformer.png',show_shapes=True)

 

손실 함수 정의하기

다중 클래스 분류 issue를 해결할 것이므로, cross_entropy 를 loss_function으로 정의하겠다.

def loss_function(y_true,y_pred):
    y_true=tf.reshape(y_true,shape=(-1,MAX_LENGTH-1))
    
    loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True,reduction='none')(y_true,y_pred)
    mask=tf.cast(tf.not_equal(y_ture,0),tf.float32)
    loss=tf.multiply(loss,mask)
    
    return tf.reduce_mean(loss)

 

학습률

트랜스포머의 경우 learning rate는 고정된 값을 유지하는 것이 아닌, 학습 경과에 따라 변하도록 설계되었다. 

위 공식으로 학습률을 계산하여 사용하며, warmup_steps의 값으로는 4000을 정의하였다.

class CustomSchedule(tf.keras.optimizers.schedules.LearningRateSchedule):
    def __init__(self,d_model,warmup_steps=4000):
        super(CustomSchedule,self).__init__()
        self.d_model=d_model
        self.d_model=tf.cast(self.d_model,tf.float32)
        self.warmup_steps=warmup_steps
        
    def __call__(self,step):
        arg1=tf.math.rsqrt(step)
        arg2=step*(self.warmup_steps**-1.5)
        
        return tf.math.rsqrt(self.d_model)*tf.math.minimum(arg1,arg2)
    
sample_learning_rate=CustomSchedule(d_model=128)
plt.plot(sample_learning_rate(tf.range(200000,dtype=tf.float32)))
plt.ylabel('Learning Rate')
plt.xlabel('Train Step')

Text(0.5, 0, 'Train Step')

이후에는 이 트랜스포머 모델을 활용해 챗봇을 만들어 보겠다.

 

 

[출처]

wikidocs.net/31379

'Deep Learning > NLP' 카테고리의 다른 글

01) 토큰화(Tokenization)  (0) 2021.03.10
트랜스포머(Transformer)(1)  (0) 2021.03.04
Transfer Learning(전이 학습)  (0) 2021.03.02

Transformer는 2017년 구글이 발표한 논문인 'Attention is all you need'에서 나온 모델이다. 

기존의 seq2seq 구조인 encoder-decoder를 따르면서도 논문의 이름처럼 어텐션 기법만으로 구현한 모델이다.

이 모델은 RNN을 사용하지 않고, 단순히 encoder-decoder 구조를 설계하였음에도 RNN보다 성능이 우수하다고 나왔다.

 

원논문은 다음 링크를 타고 확인해보자.

arxiv.org/abs/1706.03762

 

Attention Is All You Need

The dominant sequence transduction models are based on complex recurrent or convolutional neural networks in an encoder-decoder configuration. The best performing models also connect the encoder and decoder through an attention mechanism. We propose a new

arxiv.org

기존의 seq2seq 모델의 한계

트래느포머에 대해 알아보기 전에 기존의 seq2seq모델을 먼저 알아보자. 기존 seq2seq모델은 인코더-디코더 구조로 구성되어 있다. 

인코더가 하는 일: input sequence를 하나의 벡터로 압축

디코더가 하는 일: 이 벡터 표현을 통해 output sequence를 만들어낸다.

 

단점: 인코더가 하나의 input sequence를 하나의 벡터로 압축하는 과정에서 input sequence의 정보가 일부 손실된다.

 

위 단점을 보완하기 위해 어텐션 기법이 사용되었다. 

 

그렇다면 RNN을 보정하기 위한 용도로 사용하지 않고, 직접 어텐션으로 인코더와 디코더를 사용한다면 어떻게 될까

 

Transformer의 Main Hyperparameter

트랜스포머의 하이퍼파라미터를 정의해보자. 각 하이퍼파라미터의 의미에 대해서는 후에 설명하고, 간단하게만 이해해보자. 아래 정의되는 수치값은 트랜스포머를 제안한 논문에 기재된 수치값이다. 그러나 사용자가 모델 설계시 임의로 변경가능한 값이다.

 

d(model) =512

트랜스포머의 인코더와 디코더에서 정해진 입력과 출력의 크기를 의미한다. 임베딩 벡터의 차원 또한 d(model)이며, 각 인코더와 디코더가 다음 층의 인코더와 디코더로 값을 보낼 때에도 이 차원을 유지한다.

 

num_layers=6

트랜스포머에서 하나의 인코더와 디코더를 층으로 생각하였을 때, 트랜스포머 모델에서 인코더와 디코더가 총 몇 층으로 구성되었는지를 의미한다.

 

num_heads=8

트랜스포머에서는 어텐션을 사용할 때, 1번 하는 것 보다 여러 개로 분할해서 병렬로 어텐션을 수행하고 결과값을 다시 하나로 합치는 방식을 선택했다. 이때의 병렬 개수를 의미한다.

d(ff)=2048

트랜스포머 내부에는 feed forward neural network가 존재한다. 이때 hiddenlayer의 크기를 의미한다.

*feed forward neural network의 input layer, output layer의 크기는 d(model)이다. 

 

트랜스포머(Transformer)

기본 구조

트랜스포머는 RNN을 사용하지 않지만 기존의 seq2seq처럼 인코더에서 입력 시퀀스를 입력받고, 디코더에서 출력 시퀀스를 출력하는 인코더-디코더 구조를 유지한다. 

하지만 트랜스포머와 seq2seq의 차이점은 encoder, decoder 단위가 N개 존재 할 수 있다는 것이다.

즉, 이전 seq2seq 구조에서는 encoder,decoder에서 각각 하나의 RNN이 t개의 시점(time_step)을 가지는 구조였다면, 이번에는 encoder,decoder단위가 N개로 구성되는 구조이다. 

논문에서는 encoder,decoder 개수를 6개씩 사용했다.

트랜스포머 6x6 구조

위 그림은 encoder,decoder가 6개씩 존재하는 트랜스포머 구조이다.  

출력결과를 나타내는 트랜스포머구조

위 그림은 encoder로부터 정보를 전달받아 출력 결과를 만들어내는 트랜스포머 구조를 보여준다. decoder는 seq2seq구조처럼 시작 심볼<sos>를 입력으로 받아 종료 심볼<eos>가 나올 때까지 연산을 진행한다. 

RNN은 사용되지 않지만 여전히 encoder-decoder구조는 유지되고 있음을 보여준다.

 

그렇다면 이제 전체적인 큰 흐름은 확인했으니 내부 구조를 확인해보자.

 

포지셔널 인코딩(Positional Encoding)

내부를 이해하기에 앞서, 트랜스포머 입력을 알아보자. RNN이 자연어 처리에 유용했던 이유는 단어 위치에 따라 순차적으로 입력받아서 처리하는 RNN의 특성으로 인해 각 단어의 위치정보를 가질 수 있었다.

 

이와는 다르게 트랜스포머는 단어 입력을 순차적으로 받는 방식이 아니므로, 단어의 위치 정보를 다른 방식으로 알려줄 필요가 있다. 이 정보를 얻기 위해 각 단어의 임베딩 벡터에 위치 정보들을 더하여 모델의 입력으로 사용하는데, 이를 포지셔널 인코딩(Positional Encoding)이라고 한다.

 

Positional Encoding 과정

위 그림은 입력으로 사용되는 임베딩 벡터들이 트랜스포머의 입력으로 사용되기 전에 포지셔널 인코딩 값이 더해지는 것을 보여준다. 

위 그림이 실제 임베딩 벡터가 입력으로 사용되기 전에 포지셔널 인코딩값이 더해지는 과정을 시각화한 그림이다.

 

포지셔널 인코딩 값들은 어떤 값이기에 위치 정보를 반영해준다는 것일까? 다음 두가지 함수를 보자.

위 sin,cos함수는 위아래로 순환하여 요동친다. 즉, 이 함수를 이용해 임베딩벡터에 더해줌으로써, 단어의 순서 정보를 더하여준다. 

pos는 입력 문장에서의 임베딩 벡터의 위치를 나타내며, i는 임베딩 벡터 내의 차원의 인덱스를 의미한다. 각 차원의 인덱스가 짝수인 경우에는 sin함수의 값을, 홀수인 경우에는 cos함수의 값을 사용한다. 

위 식에서 d(model)은 트랜스포머의 모든 층의 출력 차원을 의미하는 하이퍼파라미터이다. 위에서 단어의 정의를 언급하였듯이 계속 나오게 될것이므로 기억해두자.

 

위와 같은 포지셔널 인코딩 방법을 사용하면 순서 정보가 보존되는데, 예를 들어 각 임베딩 벡터에 포지셔널 인코딩 값을 더하면 같은 단어라 하더라도 문장 내의 위치에 따라서 트랜스포머의 입력으로 들어가는 임베딩 벡터의 값이 달라진다.

코드로 살펴보자.

 

class PositionalEncoding(tf.keras.layers.Layer):
    def __init__(self,position,d_model):
        super(PositionalEncoding,self).__init__()
        self.pos_encoding=self.positional_encoding(position,d_model)
        
    def get_angles(self,position,i,d_model):
        angles=1/tf.pow(10000,(2*(i//2))/tf.cast(d_model,tf.float32))
        return position*angles
    
    def positional_encoding(self,position,d_model):
        angle_rads=self.get_angles(position=tf.range(position,dtype=tf.float32)[:,tf.newaxis],i=tf.range(d_model,dtype=tf.float32)[tf.newaxis,:],d_model=d_model)
        #배열의 짝수 인덱스(2i) 사인함수
        sines=tf.math.sin(angle_rads[:,0::2])
        #배열의 홀수 인덱스(2i+1) 코사인함수
        cosines=tf.math.cos(angle_rads[:,1::2])
        
        angle_rads=np.zeros(angle_rads.shape)
        angle_rads[:,0::2]=sines
        angle_rads[:,1::2]=cosines
        pos_encoding=tf.constant(angle_rads)
        pos_encoding=pos_encoding[tf.newaxis,...]
        
        print(pos_encoding.shape)
        return tf.cast(pos_encoding,tf.float32)
    
    def call(self,inputs):
        return inputs+self.pos_encoding[:,:tf.shape(inputs)[1],:]

위 클래스를 이용해 포지셔널인코딩을 정리하였다. 여기서 가장 주의깊게 봐야할 부분은 짝수,홀수일때 어떤 함수를 사용하는가이다.

 

sample_pos_encoding=PositionalEncoding(50,128)
plt.pcolormesh(sample_pos_encoding.pos_encoding.numpy()[0],cmap='RdBu')
plt.xlabel('Depth')
plt.xlim((0,128))
plt.ylabel('Position')
plt.colorbar()
plt.show()

50x128의 크기를 가진 포지셔널 인코딩 행렬을 시각화하여 어떤 형태를 가지는지 확인해보자.

 

(1, 50, 128)

단어가 50개이면서, 각 단어가 128차원의 임베딩 벡터를 가질 때 사용할 수 있는 행렬이다. 좀 더 쉽게 설명하면, '관돌'이라는 단어가 가질 수 있는 고유 숫자(벡터)는 128개라고 이해해도 괜찮다고 본다.

 

어텐션(Attention)

트랜스포머에서 사용되는 3가지 어텐션에 대해 간단하게 정리하자. 

Self-Attention은 인코더에서 이루어지는 반면에 Masked Decoder Self-Attention , Encoder-Decoder Attention은 디코더에서 이루어진다. 셀프 어텐션은 본질적으로 Query,Key,Value가 동일한 경우를 말한다. 반면, 세번째 그림 인코더-디코드 어텐션에서는 Query가 디코더의 벡터인 반면에 Key,Value가 인코더의 벡터이므로 셀프 어텐션이라 불리지 않는다.

*Query,Key 등이 같다는 것은 벡터의 값이 같다는 것이 아닌, 벡터의 출처가 같다는 의미이다.

전체적인 트랜스포머의 구조이다. 여기서 Multi-head라고 추가가 되었는데, 이는 간단하게 병렬수행으로 생각해두자. 뒤에 다시 언급할 것이다.

 

인코더(Encoder)

앞서 말했듯이 트랜스포머는 하이퍼파라미터인 num_layers 개수의 인코더 층을 쌓는다. 총 6개의 인코더 층을 사용하였다. 

인코더를 하나의 층이라는 개념으로 생각해본다면, 그 층은 크게 총 2개의 sublayer로 나뉜다. 이 sublayer는 셀프 어텐션과 피드 포워드 신경망이다. 위 그림에서 FFNN과 Self-Attention이라고 적혔는데, 멀티 해드 어텐션은 셀프 어텐션을 병렬적으로 사용한다는 의미고, Position-wise FFNN은 일반적인 FFNN이다. 

우선 셀프 어텐션에 대해서 먼저 이해해보자.

 

Self-Attention

트랜스포머에서는 셀프 어텐션이라는 어텐션 기법이 등장하는데, 어텐션 기법에 대해서는 추후 다루도록 하겠다.

1) 셀프 어텐션의 의미와 이점

어텐션 함수는 주어진 query에 대해서 모든 key와의 유사도를 각각 구한다. 그리고 구해낸 이 유사도를 가중치로 하여 키와 맵핑되어있는 각각의 value에 반영해준다. 그 후, 유사도가 반영된 value를 모두 가중합하여 return 한다.

셀프 어텐션에 대한 대표적인 효과를 이해해보자.

구글 AI블로그 포스트

위 그림에서 'The animal didn't cross the street because it was too tired'라는 글을 해석해보면 '그 동물은 그것이 너무 힘들어서 길을 건너지 않았다' 이다. 여기서 it 이 의미하는 건 무엇일까? 'street' 혹은 'animal'중 하나일 것이다. 이 셀프 어텐션을 이용하면 결국 animal에 더 연관되어 있다는 즉, 확률이 높다는 것을 찾아내 준다.

 

이제 트랜스포머에서의 셀프 어텐션의 동작 메커니즘을 알아보자.

 

2) Q,K,V 벡터 얻기

셀프 어텐션은 입력 문장의 단어 벡터들을 가지고 수행한다고 하였는데, 사실 어텐션은 인코더의 초기 입력인 d(model)의 차원을 가지는 단어 벡터들을 사용하여 셀프 어텐션을 수행하는 것이 아닌, 각 단어의 벡터들로부터 Q,K,V벡터를 얻는 작업을 거친다. 

이때 Q,K,V벡터들은 초기 입력인 d(model)의 차원을 가지는 단어 벡터들보다 더 작은 차원을 가지는데, 논문에서는 d(model)=512의 차원을 가졌던 각 단어 벡터들을 64의 차원을 갖는 Q,K,V벡터로 변환하였다.

 

여기서, 64라는 값은 트랜스포머의 또 다른 하이퍼파라미터인 num_heads로 인해 결정된다. 트랜스포머는 d(model_을 num_heads로 나누 값을 각 Q,K,V벡터의 차원으로 결정한다. 논문에서는 num_heads를 8로 설정하였다.

Q,K,V벡터 변환 과정

기존 벡터로부터 더 작은 벡터는 가중치 행렬을 곱하므로써 완성된다. 

 

3) Scaled dot-product Attention

Q,K,V 벡터를 얻었다면 기존 어텐션 메커니즘과 동일하다. 각 Q벡터는 모든 K벡터에 대해서 어텐션 스코어를 구하고, 어텐션 분포를 구한 뒤에 이를 사용해, 모든 V벡터를 가중합하여 어텐션 벨류 혹은 컨텍스트 벡터를 구하게 된다. 그리고 이를 모든 Q 벡터에 대해서 반복한다.

 

그러나 어텐션 함수의 종류는 다양하다. 트랜스포머에서는 내적을 사용하는 어텐션 함수

dot-product attention

가 아닌, 특정 값으로 나눠즈는 어텐션함수를 사용한다.

Scaled dot-product Attention

그림을 통해 확인해보자.

 

우선 단어 I에 대한 Q벡터를 살펴보자. 이 과정은 am에 대한 Q벡터, a에 대한 Q벡터, student에 대한 Q벡터에 대해서도 모드 동일한 과정을 거친다. 

위 그림에서 어텐션 스코어는 각각 단어 I가 단어 I,am,a,student와 얼마나 연관되어 있는지를 보여주는 수치이다. 트랜스포머에서는 두 벡터의 내적값을 스케일링하는 값으로 K벡터의 차원을 나타내는 d(k)에 루트를 씌워 사용하였다. 논문에서 d(k)는 d(model)/num_heads라는 식에 따라서 64의 값을 가지므로 루트를 씌운 d(k)는 8의 값을 갖는다.

 

다음은 어텐션 스코어에 softmax함수를 사용하여 어텐션 분포를 구하고, 각 V벡터와 가중합하여 Attention Value를 출력한다. 이를 단어 I에 대한 어텐션 값 또는 단어 I에 대한 context vector 라고도 부른다. 과연 이 간단한 문장에서는 하나하나 따로 연산을 하는게 큰 문제는 없어보이지만, 많은 양의 문장을 사용할 시에는 과연 따로 연산하는게 효율적일까?

 

4)행렬 연산으로 일괄 처리

위 과정을 벡터 연산이 아닌 행렬 연산을 사용하면 일괄 계산이 가능하다. 위 과정을 행렬 연산으로 다시 확인해보자.

위 그림에서 Q 행렬을 K행렬로 전치한 행렬과 곱해준다고 해보자. 이렇게 된다면 각각의 단어의 Q벡터와 K벡터의 내적이 각 행렬의 원소가 되는 행렬이 결과로 나온다.

다시 말해 위 그림의 결과 행렬의 값에 전체적으로 d(k)에 루트를 씌운 값으로 나누어주면 이는 각 행과 열이 어텐션 스코어 값을 가지는 행렬이 된다. I 행과 student 열의 값은 I의 Q벡터와 student의 K벡터의 어텐션 스코어와 동일한 행렬이 된다는 의미이다. 

위 Attention score matrix를 구하였다면 남은 것은 Attention distribution을 구하고, 이를 사용해 모든 단어에 대한 Attention value를 구하면된다. 이를 위해 score matrix에 softmax함ㅅ수를 사용하고, V행렬을 곱하면 된다.

과정은 다음과 같다.

5)Scaled dot-product attention 구하기

코드를 먼저 살펴보자.

def scaled_dot_product_attention(query,key,value,mask):
	# 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의 곱. 어텐션 스코어 행렬.
    matmul_qk=tf.matmul(query,key,transpose_b=True)
    
    # 스케일링
  	# dk의 루트값으로 나눠준다.
    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

 

앞서 설명한것을 다 이해하였다면 위 코드를 해석하는데는 큰문제가 없을것이라본다. 이제 테스를 해보자.

 

np.set_printoptions(suppress=True)
temp_k=tf.constant([[10,0,0],
                   [0,10,0],
                   [0,0,10],
                   [0,0,10]],dtype=tf.float32)
temp_v=tf.constant([[1,0],
                   [10,0],
                   [100,5],
                   [1000,6]],dtype=tf.float32)
temp_q=tf.constant([[0,10,0]],dtype=tf.float32)

temp_out,temp_attn=scaled_dot_product_attention(temp_q,temp_k,temp_v,None)
print(temp_attn)
print(temp_out)


tf.Tensor([[0. 1. 0. 0.]], shape=(1, 4), dtype=float32)
tf.Tensor([[10.  0.]], shape=(1, 2), dtype=float32)

 

6)멀티 헤드 어텐션(Multi-head Attention)

앞서 본 어텐션은 d(model)의 차원을 가진 단어 벡터를 num_heads로 나눈 차원을 가지는 Q,K,V벡터로 바꾸고 어텐션을 수행하였다. 논문 기준 512 차원의 각 단어 벡터를 8로 나누고 64차원의 Q,K,V벡터로 바꾸어 어텐션을 수행한 것이다. 

이번에는 num_heads의 의미와 왜 d(model)의 차원을 가진 단어 벡터를 가지고 어텐션을 하지 않고 차원을 축소시킨 벡터로 어텐션을 수행했는지 살펴보자.

 

트랜스포머 연구진은 한 번의 어텐션을 하는 것보다 여러번의 어텐션을 병렬구조로 사용하는 것이 더 효과적이라 판단했다. 그래서 d(model)의 차원을 num_heads개로 나누어 d(model)/num_heads의 차원을 가지는 Q,K,V에 대해서 num_heads개의 병렬 어텐션을 수행하였다. 

 

그렇다면 병렬 어텐션으로 얻을 수 있는 효과는 무엇이 있을까? 어텐션을 병렬로 수행하여 다른 시각으로 정보들을 수집할 수 있다.

예를들어 앞서 말한 '그 동물은 길을 건너지 않았다. 왜냐하면 그것은 너무 피곤하였기 때문이다'를 살펴보면 단어인 it이 쿼리였다고 해보자. 즉 it에 대한 Q벡터로부터 다른 단어와의 연관도를 구하였을 때 첫 번째 어텐션 헤드는 'it'과 'animal'의 연관도를 높게 본다면, 두 번째 어텐션은 'it'과 'tired'를 높게 볼 수있다. 즉, 다각도로 볼 수 있다는 장점이 있다는 것이다.

병렬 어텐션을 모두 수행하였다면, 모든 어텐션 헤드를 연결(concatenate)한다. 모두 연결된 어텐션 헤드 행렬의 크기는 (seq_len,d(model))가 된다.

 

어텐션 헤드를 모두 연결한 행렬은 또 다른 가중치 행렬 W(a)를 곱하게 되는데 이렇게 나온 결과 행렬이 멀티-헤드 어텐션의 최종 결과물이다. 

인코더의 첫번째 서브픙인 멀티-헤드 어텐션 단계를 끝마쳤을 때, 인코더의 입력으로 들어왔던 행렬의 크기가 아직 유지되고 있음을 기억해두자. 첫 번째 서브층인 멀티-헤드 어텐션과 두 번째 서브층인 포지션 와이즈 피드 포워드 신경망을 지나면서 인코더의 입력으로 들어올 때의 행렬의 크기는 계속 유지되어야 한다. 트랜스포머는 다수의 인코더를 쌓은 형태인데, 인코더에서의 입력의 크기가 출력에서도 동일한 크기로 계속 유지되어야만 다음 인코더에서도 다시 입력이 될 수 있기 때문이다.

 

7)멀티 헤드 어텐션(Multi-head Attention)구현하기

코드를 살펴보자.

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
        
        #WQ,WK,WV에 해당하는 밀집층 정의
        self.query_dense=tf.keras.layers.Dense(units=d_model)
        self.key_dense=tf.keras.layers.Dense(units=d_model)
        self.value_dense=tf.keras.layers.Dense(units=d_model)
        
        #WQ에 해당하는 밀집층 정의
        self.dense=tf.keras.layers.Dense(units=d_model)
    
    #num_heads의 개수만큼 q,k,v를 나누는 함수
    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]
        
        #1.WQ,WK,WV에 해당하는 밀집층 지나기
        #q:(batch_size,query의 문장 길이,d_model)
        #k:(batck_size,key의 문장 길이,d_model)
        #v:(batch_size,value의 문장 길이,d_model)
        #참고) 인코더(k,v)-디코더(q)어텐션에서는 query길이와 key,value의 길이는 다를 수 있다.
        query=self.query_dense(query)
        key=self.key_dense(key)
        value=self.value_dense(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)
        scaled_attention=tf.transpose(scaled_attention,perm=[0,2,1,3])
        
        # 4. 헤드 연결(concatenate)하기
    	# (batch_size, query의 문장 길이, d_model)
        concat_attention=tf.reshape(scaled_attention,(batch_size,-1,self.d_model))
        
        # 5. WO에 해당하는 밀집층 지나기
    	# (batch_size, query의 문장 길이, d_model)
        outputs=self.dense(concat_attention)
        return outputs

 

8)패딩마스크부터는 다음 포스팅에서 이어가겠다.

 

 

[자료 출처]

wikidocs.net/31379

'Deep Learning > NLP' 카테고리의 다른 글

01) 토큰화(Tokenization)  (0) 2021.03.10
트랜스포머(Transformer)(2)  (0) 2021.03.04
Transfer Learning(전이 학습)  (0) 2021.03.02

"Transfer learning(TL) is a research problem in machine learning(ML) that focuses on storing knowledge gained while solving one problem and applying it to a different but related problem"

[Definition of Wekipedia]

 

일반적으로 CNN 기반의 딥러닝 모델을 제대로 훈련시키려면 많은 수의 데이터가 필요하다. 그러나 이러한 데이터셋을 만드는 것은 비용이나 시간면으로 쉽지 않은일이다.

 

위 문제점을 해결하기 위한 것이 바로  TL(Transfer Learning)이다. TL이란 이미지넷과 같이 아주 큰 데이터셋에 훈련된 모델의 가중치를 가지고 와서 우리가 해결하고자 하는 과제에 맞게 재보정해서 사용하는 것을 의미한다. 

좀 더 쉽게 설명하자면, 흔히 우리가 MNIST예제를 통해 만들어 저장해 놓은 h5 파일이 있을 것이다. 이 파일은 가중치를 저장해 놓은 파일인데 이를 다시 가져와 우리가 가중치를 다시 재보정해 사용하는 것과 동일하다.

 

결과적으로 비교적 적은 수의 데이터를 가지고도 우리가 원하는 과제를 해결할 수 있는 딥러닝 모델을 훈련시킬 수 있게 되는 것이다.

 

전이학습

전이 학습은 특정 환경에서 만들어진 AI 알고리즘을 다른 비슷한 분야에 적용하는 것으로, 현재 많은 연구가 이뤄지는 머신러닝의 한 분야이다. 

간략하게 설명해보면, 체스를 익힌 AI에게 장기를 두게 하는 느낌(?)이다.

 

'Deep Learning > NLP' 카테고리의 다른 글

01) 토큰화(Tokenization)  (0) 2021.03.10
트랜스포머(Transformer)(2)  (0) 2021.03.04
트랜스포머(Transformer)(1)  (0) 2021.03.04

+ Recent posts