After) Encode(나는 점심을 먹는다) -> 고정된 크기의 컨텍스트 벡터 -> Decode(컨텍스트 벡터) = I eat lunch!
Seq2seq의 구조
단순 RNN은 긴 입력에 대한 정보를 학습시키기 어렵기에 Seq2seq에서는 LSTM을 사용한 Encoder-Decoder 구조를 채택하였다.
Encoder에 Input Sequence x를 넣으면 고정된 크기의 Representation Vector(context vector) v에 모든 정보를 담아 Decoder에게 전달해주는 구조이다. Decoder는 전달받은 v를 기반으로 Output Sequence y를 생성한다.
하지만 고정된 크기의 컨텍스트 벡터를 사용하는 것은 정보의 손실을 야기한다. 특히 문장이 길어지면 더욱 손실이 커진다. 그래서 모든 단어를 같은 비중으로 압축하지 말고, 번역하는 데에 중요한 단어만 큰 비중을 줘 성능을 높이는 Dzmitry Bahdanau가 Attention을 제안하였다.
매 스텝의 Hidden State 값을 사용하여 만들어진 Attention을 히트맵으로 출력하면 의미적으로 유사한 단어들끼리 연결되는 것을 볼 수 있다.
[Bahdanau Attention 논문에 포함된 Attention Map] https://arxiv.org/abs/1409.0473
가로의 European Economic Area 부분(영어)과 세로의 europeenne economiquezone 부분(불어)과 의미적으로 유사한 것끼리 연결된 것을 확인할 수 있다.
Bahdanau Attention의 문제는, T 스텝에서 Decoder의 Hidden State를 구하기 위해 T-1 스텝의 Hidden State를 사용해야 한다는 것이다. 이는 재귀적으로 동작하는 RNN에 역행하는 연산으로 효율적이지 못하였다. 그래서 이것을 개선하고자 한 것이 Luong의 Attention 기법이다.
그러다 2017년, Attention Is All You Need라는 제목의 논문이 등장한다.
2. Attention Is All You Need!
We propose a new simple network architecture, the Transformer, based solely on attention mechanisms, dispensing with recurrence and convolutions entirely.
(우리는 RNN과 CNN을 완전히 배제하고 Attention 메커니즘에만 기반을 둔 새롭고 단순한 구조, Transformer를 제안합니다.) - 논문 서론 中 -
RNN은 특히 언어를 모델링하기 위해 고안된 것이었는데 배제한다고 하였다. RNN은 계속 발전하였지만 기울기 소실(Vanishing Gradient)은 완벽히 해결되지 않았다. 문장 데이터의 순차적인 특성을 유지하기 위해 RNN을 반드시 사용해야 한다고 생각을 하였다. 그러나 순차적으로 계산한다는 특성 때문에 병렬 처리가 불가능하다는 큰 문제점이 있다.
문장의 연속성을 배제할 경우, '빨간 사과 노란 바나나'와 '노란 사과 빨간 바나나'를 같은 문장으로 간주한다. Positive Encoding은 그런 불상사를 막기 위한 문장에 연속성을 부여하는 새로운 방법이다. 쉽게 말하면 입력이 들어온 순서대로 단어에 표기하는 것과 같다. 예를 들면 [빨간 +1] [사과+2] [노란+3] [바나나+4]와 같이 부여한다. 그러나 단어 Embedding에 선형적으로 증가하는 값을 더해주면 후에 데이터의 분포가 엉망이 될 것이다. 그러면, Position을 문제없이 나타낼 수 있는 방법엔 어떤 것들이 있을까?
1) Positional Encoding의 두 가지 방법과 한계 - 각 단어에 0 ~ 1 사이의 값을 더한다. 0을 첫번째 단어로, 1을 마지막 단어로 한다. ▶ (한계) 문장의 길이에 따라 더해지는 값이 가변적이다. 따라서 단어 간의 거리(Delta)가 일정하지 않다.
- 각 단어에 선형적으로 증가하는 정수를 더한다. ▶ 단어 간의 거리(Delta)가 일정해지는 것은 좋지만 범위가 무제한이기 때문에 값이 매우 커질 수 있고 모델이 일반화하기 어려워진다.
2) Positional Encoding이 만족해야 할 4가지 조건 - 각 Time-step마다 고유의 Encoding 값을 출력해야 한다. - 서로 다른 Time-step이라도 같은 거리라면 차이가 일정해야 한다. - 순서를 나타내는 값이 특정 범위 내에서 일반화가 가능해야 한다. - 같은 위치라면 언제든 같은 값을 출력해야 한다.
Positional Encoding 수식은 다음과 같다.
pos는 단어가 위치한 Time-step을 의미하며 i는 Encoding 차원의 Index, d_model은 모델의 Embedding 차원 수이다. 이를 Sinusoid(사인파) Embedding이라고 칭한다. 실제 구현을 확인해보자.
import numpy as np
def positional_encoding(pos, d_model):
def cal_angle(position, i):
return position / np.power(10000, int(i) / d_model)
def get_posi_angle_vec(position):
return [cal_angle(position, i) for i in range(d_model)]
sinusoid_table = np.array([get_posi_angle_vec(pos_i) for pos_i in range(pos)])
sinusoid_table[:, 0::2] = np.sin(sinusoid_table[:, 0::2])
sinusoid_table[:, 1::2] = np.cos(sinusoid_table[:, 1::2])
return sinusoid_table
pos = 7
d_model = 4
i = 0
print("Positional Encoding 값:\n", positional_encoding(pos, d_model))
print("")
print("if pos == 0, i == 0: ", np.sin(0 / np.power(10000, 2 * i / d_model)))
print("if pos == 1, i == 0: ", np.sin(1 / np.power(10000, 2 * i / d_model)))
print("if pos == 2, i == 0: ", np.sin(2 / np.power(10000, 2 * i / d_model)))
print("if pos == 3, i == 0: ", np.sin(3 / np.power(10000, 2 * i / d_model)))
print("")
print("if pos == 0, i == 1: ", np.cos(0 / np.power(10000, 2 * i + 1 / d_model)))
print("if pos == 1, i == 1: ", np.cos(1 / np.power(10000, 2 * i + 1 / d_model)))
print("if pos == 2, i == 1: ", np.cos(2 / np.power(10000, 2 * i + 1 / d_model)))
print("if pos == 3, i == 1: ", np.cos(3 / np.power(10000, 2 * i + 1 / d_model)))
if pos == 0, i == 0: 0.0 if pos == 1, i == 0: 0.8414709848078965 if pos == 2, i == 0: 0.9092974268256817 if pos == 3, i == 0: 0.1411200080598672
if pos == 0, i == 1: 1.0 if pos == 1, i == 1: 0.9950041652780258 if pos == 2, i == 1: 0.9800665778412416 if pos == 3, i == 1: 0.955336489125606
Position 값이 각 Time-step 별로 고유하다는 것을 시각화를 통해 확인해 볼 수 있다.
import matplotlib.pyplot as plt
plt.figure(figsize=(7, 7))
plt.imshow(positional_encoding(100, 300), cmap='Blues')
plt.show()
세로축이 Time-step에 해당하고 가로축이 Word Embedding에 더해질 Position값이다. 각 스텝마다 고유한 값을 가지는 것을 알 수 있다.
저자들이 Positional Embedding 기법도 제안하였다. 수식적으로 계산한 Position 값이 아니라 Position에 대한 정보를 담은 Embedding 레이어를 선언하여 위치에 대한 정보를 학습할 수 있게 한 것이다.
위와 같은 구조에서 Positive Embedding이 훈련 중 값이 변한다고 하더라도 그것은 모든 문장에 대해 동일하게 적용되기에 문제가 되지 않는다.
Positive Embedding과 Sinusoid Embedding 두 방법 모두 거의 동일한 결과를 보였으며, 저자들은 길이가 길어져도 부담이 없는 Sinusoid Embedding을 채택하였다. (Positional Embedding은 문장의 길이만큼 Embedding Table의 크기가 커지기 떄문)
4. Multi-Head Attention
트랜스포머의 핵심인 Multi-Head Attention에 대해 알아보자. Positional Embedding이 된 문장으로부터 Attention을 추출하는 부분이다.
보라색으로 표시된 Masked Multi-Head Attention은 Multi-Head Attention과 동일하지만 인과 관계 마스킹(Causality Masking)이라는 과정이 하나 더 추가된다.
Multi-Head Attention 모듈은 Linear 레이어와 Scaled Dot-Product Attention 레이어로 이루어진다.
4.1 Scaled Dot-Product Attention
해당 Attention의 input은 3가지이다.
d_k : dimension을 가지는 queries와 keys
d_v : dimension을 가지는 values
우선 하나의 query에 대해 모든 key들과 dot product를 한 뒤 각 값을 √dk로 나눠준다. 그리고 sotfmax 함수를 씌운 후 마지막으로 value를 곱하면 Attention 연산이 끝난다.
실제로 계산할 때는 query, key, value를 vector 하나하나 계산하는 것이 아니라 여러 개를 matrix로 만들어 계산한다.
오른쪽 그림은 전통적인 Attention 개념이다. Seq2seq 인코더-디코더 구조에서 Attention이란, 디코더의 포지션i에서 바라본 인코더의 context vector c_i를 해석하기 위해 인코더의 각 포지션 j에 부여한 가중치이다. 이 가중치는 디코더의 state s_i와 인코더의 state h_j 사이의 유사도를 통해 계산되었다.
디코더의 state를 Q(query)라고 부르고 인코더의 state를 K(key)라고 추상화한 것이다. Q와 K의 유사도를 dot product로 계산하여
를 Attention 가중치로 삼고, 이것으로 V(value)를 재해석해준 것이다.
다른 점은, 인코더 쪽에서 h_j 하나만 존재하던 것이 K와 V 2가지로 분화되었다는 점이다.
1) Query와 Key를 Dot-Product한 후 Softmax를 취하는 것의 의미 하나의 Query와 모든 Key들 사이의 연관성(유사도)을 계산한 후 그 값을 확률 값으로 만든다. 이는 Query가 어떤 Key와 높은 확률로 연관이 있는 지 계산한다.
2) 트랜스포머의 Attention은 위의 식으로 Attention 값을 나눠준다는 것에서 'Scaled' Dot-Product Attention이라고 불린다. 이 Scale 과정은 어떤 의미를 가지는가? Embedding 차원 수가 깊어지면 깊어질수록 Dot-Product의 값은 커지게 되어 Softmax를 거치고 나면 미분 값이 작아지는 현상이 나타난다. 그 경우를 대비해 Scale 작업이 필요하다.
Scaled Dot-Product Attention은 Additive(합 연산 기반) Attention과 Dot-Product(=Multiplicative, 곱 연산 기반) Attention 중 후자를 사용한 Attention이다. 차원 수가 깊어짐에 따라 Softmax 값이 작아지는 것을 방지하기 위해 Scale 과정을 포함하였다.
모두 같은 범위인 [-3, 3]에서 랜덤 Tensor를 만들어 실제 Attention을 하듯 Dot-Product를 하고 Softmax를 취했다.
위 히트맵에서 어두운 부분으로는 미분 값(Gradient)이 흐르기 어렵기 때문에 모델이 넓은 특성을 반영할 수 없게 된다. 즉, 히트맵이 선명할수록 모델의 시야가 편협해진다.
위의 시각화를 통해 Embedding의 깊이가 깊을수록 모델의 시야가 편협해지는 문제가 생기고 Scale은 그 문제를 해결할 수 있음을 확인할 수 있다. 그리고 깊이에 무관하게 일정한 결과를 만들어내므로 어떤 경우에도 적용할 수 있는 훌륭한 Attention 기법이 탄생했음을 알 수 있다.
4.2 인과 관계 마스킹(Causality Masking)
Seq2seq 모델을 훈련할 때 Decoder는 컨텍스트 벡터로 압축된 입력 문장과 <start> 토큰만을 단서로 첫 번째 단어를 생성한다. 그 다음 스텝도 같은 단서에 추가로 Decoder 본인이 생성한 첫 번째 단어를 포함하여 두 번째 단어를 생성한다. 이 같은 특성을 자기 회귀(Autoregressive)라 하며, 자기 자신을 입력으로 하여 자기 자신을 예측하는 것이다.
하지만 트랜스포머는 모든 단어를 병렬적으로 처리하기에 자기 회귀적인 특성을 잃어버린다. 이는 곧 문장을 생성할 수 없다는 의미이다.
그래서 저자들이 자기 회귀적인 특성을 살리기 위해 '인과 관계 마스킹'을 추가하였다. 인과 관계 마스킹은 목표하는 문장의 일부를 가려 인위적으로 연속성을 학습하게 하는 방법이다.
위와 같은 과정을 거치면 모든 Time-Step에 대한 입력을 한 번에 처리하면서 자기 회귀적인 특성도 유지하게 된다. 테스트 시 소스 문장을 Encoder에 전달하고 타겟 문장은 <start>만 넣더라도 모델이 <start> 토큰만 보고 문장을 생성한 적(이미지에서 마지막 step)이 있기 때문에 첫 번째 단어를 생성해낼 수 있고, 생성된 단어는 다시 입력으로 전달되어 자기 회귀적으로 문장을 생성하게 된다.
그래서 인과 관계 마스크는 대각항을 포함하지 않는 삼각 행렬의 모양을 갖는다. 입력 문장만을 보고 첫 번째 단어를 생성하는 것은 타겟 문장을 모두 가리는 것이 타당하니 대각항을 포함하는게 맞지만, 그럴 경우 Attention 값을 구하는 과정에서 마지막(또는 첫 번째)행이 0개 요소에 대해 Softmax를 취하게 되므로 오류를 야기한다. 따라서 <start> 토큰을 활용해 마스크가 대각항을 포함하지 않는 형태가 되게끔 만든 것이다.
마스킹은 마스킹 할 영역을 -∞로 채우고 그 외 영역을 0으로 채운 배열을 Dot-Product된 값에 더해주는 방식으로 진행된다. 후에 진행될 Softmax는 큰 값에 높은 확률을 할당하는 함수이므로 -∞로 가득 찬 마스킹 영역에는 무조건 0의 확률을 할당하게 된다.
4.3 Multi-Head Attention
바나나라는 단어가 512차원의 Embedding을 가진다고 가정해보자. 그 중 64차원은 노란색에 대한 정보를 표현하고 다른 64차원은 달콤한 맛에 대한 정보를 표현할 것이다. 같은 맥락으로 바나나의 형태, 가격, 유통기한까지 모두 표현될 수 있다. 저자들은 '이 모든 정보들을 섞어서 처리하지 말고, 여러 개의 Head로 나누어 처리하면 Embedding의 다양한 정보를 캐치할 수 있지 않을까?'라는 아이디어를 제시한다.
Multi-Head Attention에서 Head는 주로 8개를 사용한다. Embedding된 10개 단어의 문장이 [10, 512]의 형태를 가진다면, Multi-Head Attention은 이를 [10, 8, 64]로 분할하여 연산한다. 각 64차원의 Embedding을 독립적으로 Attention한 후, 이를 이어붙여 다시금 [10, 512]의 형태로 되돌리며 연산은 끝난다.
Head로 쪼갠 Embedding들끼리 유사한 특성을 가진다는 보장이 없기 때문에 앞단에 Linear 레이어를 추가해준다. Linear 레이어는 데이터를 특정 분포로 매핑시키는 역할을 해주기 때문에, 설령 단어들의 분포가 제각각이더라도 Linear 레이어는 Multi-Head Attention이 잘 동작할 수 있는 적합한 공간으로 Embedding을 매핑한다.
비슷한 이유로 각각의 Head가 Attention한 값이 균일한 분포를 가질 거란 보장이 없다. 따라서 모든 Attention 값을 합쳐준 후, 최종적으로 Linear 레이어를 거치며 비로소 Multi-Head Attention이 마무리가 된다.
5. Position-wise Feed-Forward Networks
W는 Linear 레이어를 의미하고 max(0, x) 함수는 활성 함수 ReLU이다.
예를 들면 10단어로 이루어진 Attention된 문장 [10, 512]를 [10, 2048] 공간으로 매핑, 활성함수를 적용한 후 다시 [10, 512] 공간으로 되돌리는 것이다.
1) FFN 앞에 Position-Wise라는 수식이 붙는 이유 FFN 연산이 개별 단어(Position)마다 적용되기 때문이다.
2) 트랜스포머는 여러 개의 Encoder와 Decoder를 쌓아 완성하는데, 각 레이어의 Position-wise Feed-Forward Networks는 Parameter를 공유하지 않는다. 레이어가 달라지면 다른 Parameter를 사용한다.
Encoder와 Decoder의 각 layer는 fully connected feed-forward network를 포함하고 있다.
https://pozalabs.github.io/transformer/
position마다, 즉 개별 단어마다 적용되기 때문에 position-wise이다. Network는 두 번의 Linear Transformation과 Activation Function ReLU로 이루어져 있다.
x에 Linear transformation을 적용한 뒤, ReLU(max(0, z))를 거쳐 다시 한번 Linear transformation을 적용한다. 이 때 각각의 position마다 같은 parameter W, b를 사용하지만, layer가 달라지면 다른 parameter를 사용한다.
Kernel size가 1이고 Channel이 layer인 convolution을 두 번 수행한 것으로도 위 과정을 이해할 수 있다.
한 단어를 Embedding 차원만큼의 채널을 갖는 이미지라고 취급하면 이해가 될 것이다. Convolution 레이어의 Weight는 [입력 차원 수 x 출력 차원 수 x 커널의 크기]이므로 커널의 크기가 1이라면 Linear 레이어와 동일한 크기의 Weight를 갖게 된다.
6. Additional Techniques
6.1 Layer Normalization
Layer Normalization은 데이터를 Feature 차원에서 정규화를 하는 방법이다. 또 다시 10 단어의 Embedding된 문장을 예로 [10, 512]에서 512차원 Feature를 정규화하여 분포를 일정하게 맞춰주는 것이다.
- 정규화(Normalization) : 데이터 Feature의 Scale을 직접적으로 저정하는 방법이다. (Feature Scaling이라고 불린다.) - Batch Normalization은 정규화를 Batch 차원에서 진행하는 것이고, Layer Normalization은 정규화를 Feature 차원에서 진행하는 것이다.
6.2 Residual Connection
Skip Connection이라고 부르는 Residual Connection이 처음 제안된 것은 2015년 ResNet이라는 모델과 함꼐였다.
1) 네트워크가 깊어질수록 Optimize(Train)하는 것이 어렵기 때문에, 얕은 네트워크가 더 좋은 성능을 보인다. 2) Residual Block은 y = f(x) + x 수식으로 표현할 수 있다.
6.3 Learning Rate Schedular
트랜스포머를 훈련하는 데에는 Adam Optimizer를 사용했는데, 특이한 점은 Learning Rate를 수식에 따라 변화시키며 사용했다는 것이다.
위의 수식에 따르게 되면 warmup_steps까지는 lrate가 선형적으로 증가하고, 이후에는 step_num에 비례해 점차 감소하는 모양새를 보이게 된다.
import matplotlib.pyplot as plt
import numpy as np
d_model = 512
warmup_steps = 4000
lrates = []
for step_num in range(1, 50000):
lrate = (np.power(d_model, -0.5)) * np.min(
[np.power(step_num, -0.5), step_num * np.power(warmup_steps, -1.5)])
lrates.append(lrate)
plt.figure(figsize=(6, 3))
plt.plot(lrates)
plt.show()
이와 같은 Learning Rate를 사용하면 초반 학습이 잘 되지 않은 상태에서의 학습 효율이 늘어나고, 어느 정도 학습이 된 후반에는 디테일한 튜닝을 통해 Global Minimum을 찾아가는 효과가 나게 된다. 학습의 초반과 후반은 warmup_steps 값에 따라 결정이 된다.
6.4 Weight Sharing
Weight Sharing은 모델의 일부 레이어가 동일한 사이즈의 Weight를 가질 때 종종 등장하는 테크닉이다. 하나의 Weight를 두 개 이상의 레이어가 동시에 사용하도록 하는 것인데, 대표적으로 언어 모델의 Embedding 레이어와 최종 Linear 레이어가 동일한 사이즈의 Weight를 가진다.
이것이 비효율적으로 보이지만, ResNet이 증명한 것처럼 많은 Weight가 곧 성능으로 이어지지 않고, 오히려 Optimization에서 불리한 경향을 보인다는 것을 생각하면 이해가 될 것이다.
실제로 Weight Sharing은 튜닝해야 할 파라미터 수가 감소하기 때문에 학습에 더 유리하며 자체적으로 Regularization되는 효과도 있다. (유연성이 제한되어 과적합을 피하기 용이해지기 때문)
트랜스포머에서는 Decoder의 Embedding 레이어와 출력층 Linear 레이어의 Weight를 공유하는 방식을 사용하였다. 소스 Embedding과 타겟 Embedding도 논문상에서는 공유했지만 이는 언어의 유사성에 따라 선택적으로 사용한다. 만약 소스와 타겟 Embedding 층까지 공유한다면 3개의 레이어가 동일한 Weight를 사용하게 된다.
또한 출력층 Linear 레이어와 Embedding 레이어의 Feature 분포가 다르므로 Embedding된 값에 d_model의 제곱근 값을 곱해준다. 이는 분포를 맞춰줌과 동시에 Positional Encoding이 Embedding 값에 큰 영향을 미치는 것을 방지해준다.
GPT와 BERT의 차이점 - GPT는 단방향 Attention을 사용하지만 BERT는 양방향 Attention을 사용한다. - GPT는 트랜스포머에서 디코더(decoder)만 사용하고 BERT는 인코더(encoder)만 사용한다. - GPT는 문장을 생성할 수 있지만 BERT는 문장의 의미를 추출하는 데 강점을 가지고 있다.
트랜스포머는 max_sequence_length를 512에서 1024로만 사용하여 초기에는 주로 몇 개 문장 단위의 번역 작업에 활용되었다.
문단의 문맥을 파악하는 수준까지의 보다 긴 입력 길이를 처리할 수 있도록, 기존의 트랜스포머에 Recurrence라는 개념을 추가한 Transformer-XL이 제안되기도 했다.