by Yngie

ELMo (Embeddings from Language Models)

|

본 포스트의 내용은 고려대학교 강필성 교수님의 강의김기현의 자연어처리 딥러닝 캠프 , 밑바닥에서 시작하는 딥러닝 2 , 한국어 임베딩 책을 참고하였습니다.

ELMo

elmo

이미지 출처 : 트위터 - Elmo

ELMo(Embeddings from Language Models)는 2018년 6월에 “Deep contextualized word representations” 논문을 통해 발표된 임베딩 모델입니다. “Embeddings from Language Models”라는 말 그대로 언어 모델로부터 만들어진 임베딩이며 더 좋은 단어 표현(Representation)을 위해 만들어졌습니다.

논문에서는 좋은 단어 표현의 조건으로 두 가지를 제시하고 있습니다. 첫 번째는 단어의 구조(Syntax)적 특성과 의미(Semantic)적 특성을 모두 만족시키는 표현이어야 합니다. 두 번째는 언어학적으로 문맥에 맞는 표현을 할 수 있어야 합니다. 아래는 “stick”이라는 표현에 대한 임베딩을 ELMo에게 부탁하는 장면입니다.

elmo_stick

이미지 출처 : http://jalammar.github.io

이전 모델에서는 “stick”이라는 단어를 보면 문맥상 의미는 배제한 채 말뭉치에서 학습한 대로 임베딩했습니다. 하지만 엘모는 단어만 가지고는 답을 주지 않습니다. 엘모는 그 단어가 쓰인 문장이 어떤 것이며 단어가 그 문장에서 어떤 구조나 의미를 가지고 있는지를 알아야 임베딩 벡터를 내놓게 됩니다.

ELMo(biLM)과 GloVe를 비교한 하나의 예시를 더 보겠습니다. 아래 그림에서는 각 임베딩 모델로부터 “play”와 비슷한 단어를 나타낸 결과입니다.

elmo1

이미지 출처 : slideshare.net/shuntaroy

위 그림에서 GloVe로 임베딩하면 “play”라는 단어와 비슷한 단어를 뽑았을 때 “playing, game, games, players …” 등 스포츠와 관련된 단어들이 있는 것을 알 수 있습니다. 하지만 ELMo는 “play”가 문장에서 어떤 의미로 사용되었는 지를 구분합니다. 표에서 2행에 있는 “play”는 GloVe가 판단한 것과 동일한 “(스포츠)경기”를 의미하고 있으며 마지막 행에 있는 “(a Broadway) play”를 의미하고 있습니다. ELMo는 이를 문맥에 맞게 잘 판단하고 있는 것을 볼 수 있습니다.

bi-LSTM

ELMo는 문장을 입력받아 단어 토큰의 임베딩 벡터를 만듭니다. 이런 점에서 단어나 서브워드를 입력받았던 Word2Vec, GloVe, Fasttext 등의 이전 모델과 차이점을 갖습니다. 커다란 말뭉치를 2개 층으로 이루어진 bi-LSTM(bidirectional LSTM, 양방향 LSTM)이 만들어내는 임베딩 벡터를 사용합니다.

이 벡터는 bi-LSTM의 내부 층에 대한 은닉 벡터(Hidden vector)에 가중치를 부여한 뒤에 선형 결합(Linear combination)하여 사용합니다. 이렇게 선형 결합을 통해 나타나는 벡터는 단어가 가진 많은 특성에 대한 정보를 담고 있게 됩니다. 각 은닉 벡터 중에서 위쪽에 위치한 LSTM은 단어의 문맥적인(Context-dependent) 의미를 포착할 수 있고 아래쪽에 위치한 LSTM은 단어의 구조적인(Syntax) 의미를 포착할 수 있습니다.

따라서 단어의 구조적인 표현이 중요해지는 구문 분석(Syntax Analysis)이나 품사 태깅(POS Tagging) 등의 태스크에는 아래쪽 biLSTM층이 만들어 내는 은닉 벡터에 가중치를 더 많이 주게 됩니다. 반대로 의미적인 표현이 중요해지는 NLU(Natural Language Understanding)이나 QA(Question&Answering) 등의 태스크에는 위쪽 biLSTM층이 만들어 내는 은닉 벡터에 더 많은 가중치를 부여하게 되지요.

ELMo는 전체 문장을 본 후에 문장을 구성하는 단어에 임베딩을 해줍니다. 아래는 “Let’s stick to improvisation in this skit”이라는 문장을 입력했을 때 단어가 임베딩되는 과정을 도식화하여 보여주고 있습니다.

elmo2

이미지 출처 : http://jalammar.github.io

ELMo는 기본적으로 언어 모델(Language Models)을 따르고 있기 때문에 타겟 단어 이전까지의 시퀀스로부터 타겟 단어를 예측합니다. 아래는 “Let’s stick to improvisation in this skit”에서 ELMo가 “improvisation”을 예측하는 과정을 보여주는 이미지입니다. (엘모는 귀엽습니다)

elmo3

이미지 출처 : http://jalammar.github.io

ELMo는 단방향으로의 LSTM이 아니라 biLSTM, 즉 양방향으로 진행하는 LSTM을 사용하여 학습합니다. 아래는 ELMo가 입력 문장을 양방향으로 읽어 나간다는 것을 도식화한 이미지입니다. (엘모는 보라색도 귀엽습니다)

elmo4

이미지 출처 : http://jalammar.github.io

이후에는 단어마다 순방향(Forward) LSTM의 은닉 벡터 및 토큰 임베딩 벡터와 역방향(Backward) LSTM의 은닉 벡터 및 토큰 임베딩 벡터를 Concatenate합니다. 그리고 이어붙인 벡터에 각각 가중치 $s_0, s_1, s_2$ 를 곱해줍니다. 마지막으로 세 벡터를 더해준 벡터를 ELMo 임베딩 벡터로 사용합니다. 아래는 이 “stick”이라는 단어가 ELMo 임베딩 되는 과정을 보여주고 있습니다.

elmo5

이미지 출처 : http://jalammar.github.io

위 그림에서 $s_0, s_1, s_2$는 학습을 통해 갱신되는 파라미터로 우리가 수행하고자 하는 태스크에 따라 달라집니다. 위에서도 말했던 것처럼 단어의 문맥적인 의미가 중요한 태스크에서는 상위 레이어에 곱해주는 $s_2$가 커지게 되고, 구조 관계가 중요한 태스크에서는 하위 레이어에 곱해주는 $s_1$이 커지게 됩니다.

아래는 이 과정을 수식 기호를 사용하여 보여주는 그림입니다. 아래 그림에서 $t_k$는 $k$번째 단어 토큰을 나타내며 $\mathbf{x}_k$는 토큰의 임베딩 벡터를 나타냅니다. $\overrightarrow{\mathbf{h}_k^{LM}}, \overleftarrow{\mathbf{h}_k^{LM}}$는 각각 $k$번째 토큰에 대한 순방향 및 역방향 LM의 은닉 벡터를 나타내며 둘을 Concatenate하여 $[\overrightarrow{\mathbf{h}_k^{LM}}; \overleftarrow{\mathbf{h}_k^{LM}}] = \mathbf{h}_k^{LM}$ 이 됩니다. $\gamma$는 세 벡터를 가중합하여 나온 최종 벡터의 요소를 얼마나 증폭 혹은 감소시킬 것인지에 대한 Scale factor이며 태스크에 따라서 달라지게 됩니다.

elmo6

이미지 출처 : zhangruochi.com

biLM

수식을 통해 ELMo를 구성하고 있는 양방향 언어 모델(Bidirectional Language Model, biLM)에 대해 알아보겠습니다. 기호는 위에서 사용했던 것을 그대로 사용합니다. 지금까지 알아보았던 순방향으로 진행되는 언어 모델에서 특정한 $N$개의 단어로 구성된 문장을 만드는 확률을 아래와 같이 나타낼 수 있었습니다.

[P(t_1,t_2, \cdots, t_N) = \prod_{k=1}^N P(t_k t_1, t_2, \cdots, t_{k-1})]

ELMo에서 입력 토큰 벡터에 해당하는 $\mathbf{x}_k^{\text{LM}}$는 단어 단위의 임베딩 모델에서 Pretrained 된 임베딩 벡터를 그대로 가져와서 사용합니다. 하지만 LSTM이 토큰 임베딩을 학습하여 나타나는 은닉 상태 벡터인 $\overrightarrow{\mathbf{h}_k^{LM}}$은 문맥을 고려할 수 있게 됩니다. 역방향에섣 마찬가지 입니다. 역방향 모델을 수식으로 나타내면 다음과 같습니다.

[P(t_1,t_2, \cdots, t_N) = \prod_{k=1}^N P(t_k t_{k+1}, t_{k+2}, \cdots, t_{N})]

역방향 언어 모델에서는 타겟 토큰 $t_k$보다 뒤에 위치하는 토큰이 조건으로 주어지며 이로부터 $t_k$를 예측하게 됩니다. 역방향 LSTM에 의해 생성된 은닉 상태 벡터 $\overleftarrow{\mathbf{h}_k^{LM}}$역시 문맥을 고려할 수 있습니다. 이제 이 두 방향을 결합, 즉 두 식을 곱한 후에 $t_k$가 위치할 최대 우도(Maximum Likelihood)를 구하면 되겠습니다.

[\prod_{k=1}^N P(t_k t_1, t_2, \cdots, t_{k-1}) \times \prod_{k=1}^N P(t_k t_{k+1}, t_{k+2}, \cdots, t_{N})]

위 식에 로그를 취해주면 다음과 같은 식이 나오게 됩니다. 아래 식에서 $\Theta_X, \Theta_s$는 토큰 벡터 층과 소프트맥스 층에서 사용되는 파라미터를 가리키는 것으로 두 방향 모두에 같은 파라미터가 적용됩니다. ${\Theta}_{LSTM}$은 각 방향 LSTM층에서 사용되는 파라미터로 방향마다 다른 파라미터가 학습됩니다.

[\begin{aligned} \sum^N_{k=1} \bigg( &\log P(t_k|t_1, t_2, \cdots, t_{k-1}; \Theta_x, \overrightarrow{\Theta}_{LSTM} , \Theta_s)\

  • &\log P(t_k|t_{k+1}, t_{k+2}, \cdots, t_N; \Theta_x, \overleftarrow{\Theta}_{LSTM} , \Theta_s) \bigg) \end{aligned}]

$L$개의 층을 가진 양방향 언어 모델을 통해서 생성된 $L$개의 은닉 벡터 $\overrightarrow{\mathbf{h}_k^{LM}}, \overleftarrow{\mathbf{h}_k^{LM}}$를 이어붙이고(Concatenate) 최초의 임베딩 토큰 벡터 $\mathbf{x}_k^{\text{LM}}$를 자기 자신과 이어 붙인 벡터의 집합을 $R_k$라고 하겠습니다.

[R_k = {\mathbf{x}k^{LM}, \overrightarrow{\mathbf{h}{k,j}^{LM}}, \overleftarrow{\mathbf{h}_{k,j}^{LM}} j = 1, \cdots, L} = {\mathbf{h}_{k,j}^{LM} j=0, \cdots, L}]

이어붙인 $L+1$개의 토큰을 선형 결합한 뒤에 최종 ELMo벡터가 만들어지게 됩니다. 선형 결합시에 사용되는 가중치 $s$는 ELMo를 사용하고자 하는 태스크에 따라 달라지게 됩니다. $\gamma$는 벡터 요소의 크기를 조정하는 Scale factor로 역시 태스크에 따라 변하게 됩니다.

[\text{ELMo}k^\text{task} = E(R_k;\Theta^\text{task}) = \gamma^\text{task} \sum^L{j=0} s_j^\text{task} h_{k,j}^{LM}]

Evaluation

아래는 다양한 태스크에서 ELMo가 보여준 성능을 기록한 것입니다. 몇몇 태스크에 대하여 당시의 SOTA 모델보다 좋은 성능을 보였음을 알 수 있습니다.

elmo7

이미지 출처 : Deep contextualized word representations

아래는 “임베딩 벡터와 은닉 벡터에 가중치를 어떻게 부여할 것인가?”에 대한 연구결과를 이미지로 나타낸 것입니다. 논문에서 적용했던 것과 같이 $L+1$개의 벡터에 모두 다른 가중치를 적용할 때의 성능이 가장 좋은 것을 볼 수 있습니다. 이는 두 번째인 가중치를 적용하지 않을 때보다 더 좋은 성능을 보여주고 있습니다. 벡터의 선형 결합을 사용하지 않고 하나의 벡터만 사용한다면 $L$번째 층, 즉 최상단 은닉층이 생성한 벡터를 사용하는 것이 그냥 단어 임베딩 벡터를 사용하는 것보다 좋다는 연구 결과가 있습니다.

elmo8

이미지 출처 : Text Analytics 강의자료

아래 그림은 “ELMo 임베딩 벡터를 어떤 단계에 Concatenate하는 것이 좋은가?”에 대한 연구결과입니다. 입출력 단계에 모두 ELMo 임베딩을 적용하는 것이 가장 좋은 것을 볼 수 있습니다. 입, 출력 벡터 중 하나에만 적용하는 경우는 모두 적용한 경우보다는 떨어지지만, 아무것도 사용하지 않은 모델보다는 좋은 성능을 보여주는 것을 알 수 있습니다.

elmo9

이미지 출처 : Text Analytics 강의자료

ELMo를 시작으로 대량의 말뭉치로부터 생성된 품질 좋은 임베딩 벡터를 만드는 모델이 많이 사용되었습니다. ELMo는 이후에 등장하는 트랜스포머 기반의 BERT나 GPT보다 많이 사용되지는 않습니다. 하지만 좋은 품질의 임베딩 벡터를 바탕으로 적절한 Fine-tuning후에 여러 태스크에 적용하는 전이 학습(Transfer learning)의 시초격인 모델로서의 의의가 있다고 할 수 있겠습니다.

Comment  Read more

트리 (Tree)

|

본 게시물은 문일철 교수님의 데이터 구조 및 분석을 참조하여 작성하였습니다.

Tree

이번 게시물에서 배울 자료구조는 트리(Tree) 입니다. 트리 구조 역시 배열(Array), 연결된 리스트(Linked List)와 동일하게 데이터를 저장하고 데이터간의 Operation을 표현하기 위한 추상화된 데이터 타입 중 하나입니다. 연결된 리스트를 변형한 것으로 연결된 리스트에서는 다른 노드를 가리키는 레퍼런스(Reference)가 하나뿐이었지만, 트리 구조에서는 여러 개의 레퍼런스를 가질 수 있습니다. 한 노드에서 시작해 여러 노드로 뻗어 나가는 모습이 나무를 거꾸로 뒤집은 모양과 유사하다고 하여 트리라는 이름이 붙었습니다.

Tree

이미지 출처 : 위키피디아 - 트리(자료구조)

트리 구조는 연결된 리스트의 일부를 변형시킨 것으로 연결된 리스트에서 가능했던 삽입(Insert), 삭제(Delete), 탐색(Search)이 모두 가능합니다. 또한 트리 구조에는 트래버스(Traverse)라는 새로운 작업이 있습니다.

트리 구조에서 사용되는 용어

트리 구조는 연결된 리스트보다는 훨씬 복잡합니다. 그렇기 때문에 트리의 복잡한 구조를 말로 나타내기 위한 다양한 용어들이 존재합니다. 트리 구조에서 사용되는 용어에 대해 아래 그림을 보며 알아봅시다.

tree_terms

이미지 출처 : tutorialspoint.com

가장 먼저 트리 최상단에 위치한 노드를 가리키는 용어는 루트(Root) 입니다. 그리고 직접 연결되어 있는 노드 중 상단에 위치한 노드를 부모(Parent) 노드라고 하고 아래에 위치한 노드를 자식(Child) 노드라고 합니다. 동일한 부모 노드로부터 나온 자식 노드는 서로 형제/자매(Siblings) 노드라고 합니다. 그리고 자식 노드를 갖지 않는 노드는 리프(Leaf) 노드라고 하며 다른 말로는 터미널(Terminal) 노드라고도 합니다. 리프 노드가 아닌 노드는 모두 인터널(Internal) 노드라고 합니다.

tree_terms2

이미지 출처 : science.jrank.org

특정 노드에서부터 트리의 위쪽으로 거슬러 올라가면서 만나는 모든 노드를 조상(Ancestor) 노드라고 하며, 반대로 아래에 있는 모든 노드는 후손(Descendant) 노드라고 합니다.

tree_terms3

이미지 출처 : btechsmartclass.com

특정 노드로부터 다른 노드로 갈 때 거치는 과정을 경로(Path) 라고 합니다. 경로 길이(Length) 는 출발 노드로부터 목적 노드까지 가는데 거치는 노드의 개수입니다. 위 이미지에서 제시한 두 예시의 경로 길이는 각각 $3, 2$ 가 됩니다. 그리고 깊이(Depth) 는 루트로부터 목적 노드까지의 경로 길이를 나타내는 용어입니다.

tree_term4

높이(Height) 는 트리에 존재하는 최대 경로 길이를 나타냅니다. 아래는 $I, J, K$ 노드에서 출발하여 루트 노드로 가는 경로의 길이가 최대이며 이 값이 3이므로 이 트리의 높이는 3이라고 할 수 있습니다.

tree_term_degree

특정 노드가 가지고 잇는 자식 노드의 개수를 차수(Degree) 라고 하며 트리에 존재하는 모든 노드의 개수를 트리의 크기(Size) 라고 합니다. 위 이미지에 있는 트리는 총 11개의 노드를 가지고 있으므로 사이즈를 11이라 할 수 있습니다.

트리의 특징

트리에 존재하는 레퍼런스(엣지)의 개수는 노드 개수보다 하나가 적습니다. 루트를 제외한 모든 노드가 레퍼런스를 받기 때문에 레퍼런스 개수에 1을 더해주면 엣지의 개수가 됩니다.

특정 조건에서의 최대 노드 개수도 구할 수 있습니다. 최대 차수가 $d$ 인 트리에 대해서 깊이 $i$ 에서의 최대 노드 개수는 $d^i$ 입니다. 비슷하게 생각해보면 차수가 $d$ 이고 높이가 $h$ 인 트리에서 최대 리프 노드의 개수 역시 $d^h$ 로 나타낼 수 있습니다. 예를 들어, 차수가 $4$ 이고 높이가 $3$ 인 트리는 최대 $4^3 = 64$ 개의 리프 노드를 가질 수 있습니다.

등비수열 공식을 활용하면 최대 차수가 $d$ 이고 높이가 $h$ 인 트리의 최대 사이즈를 구할 수 있습니다. 각 깊이마다 최대 노드 갯수를 더해줍니다. 수식으로 나타내면 다음과 같습니다.

[1+d+d^2+ \cdots + d^h = \frac{d^{h+1} - 1}{d - 1}]

예를 들어, 차수가 $4$ 이고 높이 $3$ 인 트리는 최대 $\frac{4^{3+1} - 1}{4 - 1} = \frac{255}{3} = 85$ 개의 노드를 가질 수 있습니다.

특수한 트리들

특수하게 생긴 트리에 대해서는 특별한 이름을 붙여 부르기도 합니다. 아래 그림은 특수한 트리 구조를 이미지로 나타낸 것입니다. 이후 설명은 다음 노드를 가리키는 레퍼런스의 개수가 2인 이진 트리(Binary tree)를 기준으로 하겠습니다.

special_tree

이미지 출처 : towardsdatascience.com

각 트리에 대해서 하나씩 알아봅시다. 먼저 Full Tree 는 모든 노드가 0 혹은 2개의 자식 노드를 가지는 트리입니다. 이진 트리의 경우 Full Tree의 리프 노드의 개수는 인터널 노드의 개수보다 하나 많습니다.

Complete Tree 는 왼쪽부터 차례대로 노드가 채워진 트리입니다. 이해를 돕기위해 이미지를 가져와 보겠습니다.

complete_tree

이미지 출처 : towardsdatascience.com

위 그림에서 왼쪽은 Complete Tree이고 오른쪽은 Complete Tree가 아닌 것들입니다. 오른쪽 맨 위에 있는 트리는 왜 Complete Tree가 아닐까요? 깊이가 2인 노드 중에서 가장 오른쪽 노드를 건너뛰고 깊이 3인 노드가 채워졌기 때문입니다. 그 왼쪽 아래에 있는 트리 역시 깊이 2인 노드 중에서 2번째 노드를 건너 뛴 채 3, 4번째 노드가 채워졌기 때문에 Complete Tree라고 할 수 없습니다. 오른쪽 아래에 있는 트리는 깊이가 3인 부분까지는 잘 채워졌지만 깊이 4인 노드를 채우면서 가장 왼쪽을 건너 뛰고 다음 노드를 채웠기 때문에 Complete Tree라고 할 수 없습니다.

다음은 Degenerate Tree 입니다. 이 트리는 리프 노드를 제외한 모든 노드가 1개의 자식 노드만을 가지고 있습니다. 이렇다보니 Degenerate Tree의 높이는 트리의 사이즈보다 하나가 작게 됩니다.

다음 Perfect Tree 는 모든 노드가 2개의 자식 노드를 가지며 모든 리프 노드가 동일한 깊이를 가지는 트리입니다. 높이가 $h$ 인 Perfect Tree는 높이 $h$ 인 트리의 최대 노드 개수이므로 위에서 등장했던 공식을 적용하여 Perfect Tree의 노드 개수를 $\frac{d^{h+1} - 1}{d - 1}$ 로 구할 수 있습니다.

마지막 Balanced Tree 입니다. 이 트리는 트리의 모든 노드에 대하여 왼쪽과 오른쪽의 하위 트리의 높이가 최대 1만큼씩만 다른 트리입니다. 이 또한 이해를 돕기 위해 이미지를 대동하겠습니다.

balaced_tree

이미지 출처 : towardsdatascience.com

이 이미지의 왼쪽은 Balanced Tree를 만족하는 트리들이고 오른쪽은 Balanced Tree를 만족하지 않는 것들입니다. 오른쪽 맨 위에 있는 트리는 왜 Balanced Tree가 아닐까요? 깊이가 1인 두 노드 중에서 왼쪽 노드를 봅시다. 이 노드의 왼쪽 서브트리의 높이는 2입니다. 하지만 오른쪽에는 아예 노드가 존재하지 않기 때문에 오른쪽 서브트리의 높이는 0이 되어 두 트리의 높이 차이가 1이상이 됩니다. 그 아래 두 트리는 루트 왼쪽과 오른쪽 서브 트리의 높이 차이가 각각 3, 2 이기 때문에 역시 Balanced Tree가 아니게 됩니다.

To Binary Search Tree

연결된 리스트는 배열의 단점이었던 삽입과 삭제의 시간 복잡도를 해결했지만 탐색에 대해서는 여전히 최대 N번의 Operation을 필요로 했습니다. 트리는 연결된 리스트가 개선하지 못했던 탐색의 문제를 개선하기 위해 만들어진 자료구조 입니다. 다음에 등장하는 이진 탐색 트리(Binary Search Tree, BST) 는 탐색에 최적화된 트리 구조로, 데이터 탐색에 필요한 Operation의 개수를 훨씬 줄일 수 있습니다.

Comment  Read more

트랜스포머 (Transformer)

|

본 포스트의 내용은 고려대학교 강필성 교수님의 강의김기현의 자연어처리 딥러닝 캠프 , 밑바닥에서 시작하는 딥러닝 2 , 한국어 임베딩 책을 참고하였습니다.

Transformer

2013년 Word2Vec이 발표된 후 GloVe, Fasttext 등이 등장하면서 단어 수준의 임베딩에 대한 방법론이 어느 정도 마무리 되었습니다. 그 뒤로는 문장 수준의 임베딩을 위한 방법론들이 등장하게 됩니다. 대표적인 것이 2017년 6월에 “Attention is All You Need”(Ashish Vaswani et al.) 논문에서 발표한 트랜스포머(Transformer)입니다. 트랜스포머는 Seq2Seq에 등장했던 Attention 메커니즘을 극대화하기 위한 방법입니다.

transformer_1

이미지 출처 : jalammar.github.io

트랜스포머도 Seq2Seq모델과 유사한 인코더(Encoder)-디코더(Decoder) 구조를 가지고 있습니다. 아래는 트랜스포머의 전체적인 구조를 나타내는 이미지입니다. 이전까지의 Seq2Seq를 도식화한 이미지와 매우 유사하지만 Encoder’s’, Decoder’s’라는 점이 다르지요. 각각의 Encoders와 Decoders 내에는 복수의 Encoder, Decoder 블록이 들어있습니다.

transformer_2

이미지 출처 : jalammar.github.io

Structure

트랜스포머는 인코더와 디코더 여러 개를 중첩한 구조를 갖고 있다는 점에서 이전 모델과의 차이점을 갖습니다. 각각의 인코더와 디코더를 블록(Block)이라고 일컫습니다. 논문에서는 6개씩의 인코더 블록과 디코더 블록을 쌓은 구조를 사용하였습니다. 아래 이미지는 논문에서 발표한 트랜스포머의 구조를 도식화하여 나타낸 것입니다.

transformer_3

이미지 출처 : jalammar.github.io

Encoder

먼저 인코더의 내부 구조부터 살펴보겠습니다. 각 인코더 블록의 구조는 모두 동일합니다. 인코더 블록 내부에는 2개의 하위 레이어(Sub layer)가 있습니다. 데이터 흐름 순서대로 볼 때 첫 번째로 만나는 층이 Self-Attention 층이며, 이 층을 지나서 만나는 FFNN(Feed forward neural network) 층입니다. Self-Attention 층에서는 한 토큰이 다른 토큰과 얼마나 관련이 있는 지에 관한 가중치를 계산합니다.

transformer_4

이미지 출처 : jalammar.github.io

다음으로 디코더를 보겠습니다. 디코더는 총 3개의 하위 레이어를 가지고 있습니다. 데이터가 흐르는 순서대로 볼 때 첫 번째로 만나는 층은 (masked) Self-Attention 층입니다. 두 번째로 만나는 층은 Encoder-Decoder Attention 층입니다. 인코더 블록에는 없었던 부분입니다. 인코더-디코더 Attention 층에서는 인코더가 처리한 정보를 받아서 Attention 메커니즘을 수행합니다. 마지막은 인코더와 동일하게 FFNN 층으로 구성되어 있습니다. 아래 그림은 인코더 블록을 구성하고 있는 2개의 하위 레이어와 디코더 블록을 구성하고 있는 3개의 하위 레이어를 도식화하여 나타낸 것입니다.

transformer_5

이미지 출처 : jalammar.github.io

Input Embedding

트랜스포머의 전체적인 구조는 아래 이미지와 같습니다. 아래에서는 이미지를 단순화하기 위해 인코더와 디코더 블록 하나씩만을 나타내었으나, 실제로는 $N$(논문에서는 6)개의 블록 스택을 쌓아 구성합니다.

transformer_6

이미지 출처 : mc.ai

먼저 입력 임베딩(Input embedding)에 대하여 알아보겠습니다. 입력 임베딩은 입력 시퀀스의 아이템을 첫 번째 인코더 블록에 입력할 수 있는 벡터로 만드는 작업입니다. 일반적으로는 Word2Vec, GloVe, Fasttext 등에 의해 사전 학습된 임베딩 벡터를 사용합니다. 트랜스포머의 인코더와 디코더는 512차원의 벡터를 다룹니다. 인풋 임베딩에 들어가기 위해서는 512차원의 임베딩 벡터가 필요합니다. 얼마만큼의 아이템을 하나의 시퀀스로 처리할 것인지에 대해서는 하이퍼파라미터를 통해서 조정할 수 있습니다. 일반적으로는 가장 긴 문장의 길이를 사용하고 짧은 문장에 대해서는 패딩(<pad>)을 처리해줍니다.

Positional Encoding

다음으로 필요한 작업은 포지셔널 인코딩(Positional encoding)입니다. Self-Attention 에서는 RNN처럼 순차적으로 토큰을 다루지 않습니다. 그렇기 때문에 단어의 순서 정보를 완전히 무시되지요. 하지만 포지셔널 인코딩을 사용하면 완벽히 복구할 수는 없더라도 단어의 상대적인 위치를 어느 정도 보완해줄 수 있습니다. 그렇다면 포지셔널 인코딩은 어떤 조건을 만족해야 할까요?

일단, 각 위치마다 유일한 값을 출력해야 합니다. 두 단어의 위치가 같을 수는 없겠지요. 그리고 길이가 다른 문장의 단어 위치를 나타낼 때에도 단어의 상대적인 거리가 같으면 같은 차이를 보여야 합니다. 아래의 예시를 보며 이야기 해보도록 하겠습니다.

“어머님 나 는 별 하나 에 아름다운 말 한마디 씩 불러 봅니다”

“소학교 때 책상 을 같이 했 던 아이 들 의 이름 과 패 경 옥 이런 이국 소녀 들 의 이름 과 벌써 애기 어머니 된 계집애 들 의 이름 과 가난 한 이웃 사람 들 의 이름 과 비둘기 강아지 토끼 노새 노루 프란시스 쟘 라이너 마리아 릴케 이런 시인 의 이름 을 불러 봅니다”

첫 번째 문장에서 “나”“아름다운”은 사이에 4개의 형태소를 두고 있습니다. 마찬가지로 두 번째 문장에서도 “책상”“아이”는 4개의 형태소를 사이에 두고 떨어져 있네요. 이런 경우에 상대적인 단어의 거리가 같다고 할 수 있습니다. 그렇다면 “1, 2, 3, 4 …” 처럼 단어 순서대로 고유한 값을 부여하면 안될까요? 이런 방식을 사용하여 각 문장의 포지셔널 인코딩을 출력하면 아래와 같습니다.

1: ‘어머님’, 2: ‘나’, 3: ‘는’, 4: ‘별’, 5: ‘하나’, 6: ‘에’, 7: ‘아름다운’, 8: ‘말’, 9: ‘한마디’, 10: ‘씩’, 11: ‘불러’, 12: ‘봅니다’

1: ‘소학교’, 2: ‘때’, 3: ‘책상’, 4: ‘을’, 5: ‘같이’, 6: ‘했’, 7: ‘던’, 8: ‘아이’, 9: ‘들’, 10: ‘의’, 11: ‘이름’, 12: ‘과’, 13: ‘패’, 14: ‘경’, 15: ‘옥’, 16: ‘이런’, 17: ‘이국’, 18: ‘소녀’, 19: ‘들’, 20: ‘의’, 21: ‘이름’, 22: ‘과’, 23: ‘벌써’, 24: ‘애기’, 25: ‘어머니’, 26: ‘된’, 27: ‘계집애’, 28: ‘들’, 29: ‘의’, 30: ‘이름’, 31: ‘과’, 32: ‘가난’, 33: ‘한’, 34: ‘이웃’, 35: ‘사람’, 36: ‘들’, 37: ‘의’, 38: ‘이름’, 39: ‘과’, 40: ‘비둘기’, 41: ‘강아지’, 42: ‘토끼’, 43: ‘노새’, 44: ‘노루’, 45: ‘프란시스’, 46: ‘쟘’, 47: ‘라이너’, 48: ‘마리아’, 49: ‘릴케’, 50: ‘이런’, 51: ‘시인’, 52: ‘의’, 53: ‘이름’, 54: ‘을’, 55: ‘불러’, 56: ‘봅니다’

위와 같이 인코딩 값을 부여하게 되면 긴 문장에서 맨 뒤에 위치한 토큰의 값이 매우 커집니다. 포지셔널 인코딩 값이 매우 커지게 되면 원래의 인풋 임베딩 값에 영향을 주게 되지요. 그렇다면 범위를 정해놓고 등분하여 나타내면 되지 않을까요? $[0,1]$ 범위를 단어 개수만큼 등분하여 포지셔널 인코딩 값을 나타내어 보겠습니다.

0: ‘어머님’, 0.091: ‘나’, 0.182: ‘는’, 0.273: ‘별’, 0.364: ‘하나’, 0.455: ‘에’, 0.545: ‘아름다운’, 0.636: ‘말’, 0.727: ‘한마디’, 0.818: ‘씩’, 0.909: ‘불러’, 1: ‘봅니다’

0: ‘소학교’, 0.018: ‘때’, 0.036: ‘책상’, 0.055: ‘을’, 0.073: ‘같이’, 0.091: ‘했’, 0.109: ‘던’, 0.127: ‘아이’, 0.145: ‘들’, 0.164: ‘의’, 0.182: ‘이름’, 0.2: ‘과’, 0.218: ‘패’, 0.236: ‘경’, 0.255: ‘옥’, 0.273: ‘이런’, 0.291: ‘이국’, 0.309: ‘소녀’, 0.327: ‘들’, 0.345: ‘의’, 0.364: ‘이름’, 0.382: ‘과’, 0.4: ‘벌써’, 0.418: ‘애기’, 0.436: ‘어머니’, 0.455: ‘된’, 0.473: ‘계집애’, 0.491: ‘들’, 0.509: ‘의’, 0.527: ‘이름’, 0.545: ‘과’, 0.564: ‘가난’, 0.582: ‘한’, 0.6: ‘이웃’, 0.618: ‘사람’, 0.636: ‘들’, 0.655: ‘의’, 0.673: ‘이름’, 0.691: ‘과’, 0.709: ‘비둘기’, 0.727: ‘강아지’, 0.745: ‘토끼’, 0.764: ‘노새’, 0.782: ‘노루’, 0.8: ‘프란시스’, 0.818: ‘쟘’, 0.836: ‘라이너’, 0.855: ‘마리아’, 0.873: ‘릴케’, 0.891: ‘이런’, 0.909: ‘시인’, 0.927: ‘의’, 0.945: ‘이름’, 0.964: ‘을’, 0.982: ‘불러’, 1: ‘봅니다’

이렇게 되면 문장 길이, 즉 문장을 구성하는 단어 개수에 따라 상대적인 거리 관계가 깨지게 됩니다. 첫 번째 문장에서 “어머님”“나”, 두 번째 문장에서 “소학교”“때”는 모두 바로 옆 단어이지만 첫 번째 문장에서의 차이는 $0.091$이고 두 번째 문장에서는 $0.018$으로 5배나 차이나지요. 그리하여 논문에서는 이런 조건을 모두 만족하는 sinusoid를 사용했습니다. sinusoid 포지셔널 인코딩 함수의 수식은 다음과 같습니다.

[\begin{aligned} PE_{(\text{pos}, 2i)} &= \sin(\text{pos}/10000^{2i/d_{\text{model}}})
PE_{(\text{pos}, 2i+1)} &= \cos(\text{pos}/10000^{2i/d_{\text{model}}}) \end{aligned}]

아래의 그림은 위 함수를 사용하여 사용하여 포지셔널 인코딩한 단어 사이의 거리를 시각화한 것입니다. 왼쪽 그래프는 10개의 단어로 이루어진 문장의 모든 단어 사이의 거리를, 오른쪽 그래프는 50개의 단어로 이루어진 문장에서 앞부분 10개의 단어 사이의 거리를 시각화했습니다. 차원은 논문에서 사용한 $d_\text{model} = 512$ 를 사용하였습니다.

pos_encode

두 그래프로부터 문장의 길이가 달라지더라도 단어 사이의 거리는 보존됩니다. 문장의 단어가 몇 개이든 항상 바로 옆 단어는 3.714만큼, 그 옆 단어는 6.967만큼 차이가 나게 됩니다. 게다가 문장의 길이가 길어져도 포지셔널 인코딩 값이 한없이 커지지 않습니다. 멀리 떨어질수록 증가폭이 점점 떨어지기 때문에 “W1”“W2”사이의 거리는 3이상이지만, “W1”“W10”사이의 거리는 12.37로 9배의 차이가 나지 않는 것을 볼 수 있습니다. 증가폭이 점점 줄기 때문에 “W1”“W20”사이의 거리를 구해보아도 13.98밖에 되지 않습니다.

이렇게 구한 포지셔널 인코딩 벡터는 단어의 임베딩 벡터에 더해진 후에 인코더 블록으로 들어가게 됩니다.

Encoder Block

인코더 블록의 각 하위 레이어에서 어떤 일이 일어나는 지에 대해서 알아보겠습니다. 아래는 첫 번째 인코더 블록에 들어가는 벡터 $x_1, x_2$가 어떤 과정을 거치는 지를 나타낸 이미지입니다.

이미지 출처 : jalammar.github.io

위에서 말했듯이 인코더 블록에서 Self-Attention 하위 레이어를 먼저 만나게 됩니다. 이를 거쳐 나온 출력 벡터 $z_1, z_2$는 각각 FFNN층을 거쳐 인코더 블록을 빠져나오게 됩니다. 위 그림에서도 볼 수 있는 것처럼 Self-Attention층에서는 각각의 입력 벡터가 서로 영향을 끼치는(Dependency) 관계에 있습니다. 하지만 FFNN에서는 따로 들어가기 때문에 서로 영향을 끼치지 않지요. 그렇기 때문에 FFNN에서는 병렬화가 가능하고 덕분에 더 빠르게 학습할 수 있습니다. 인코더 블록가 출력하는 벡터 $r_1, r_2$는 그 다음 인코더 블록의 인풋으로 다시 사용됩니다.

Self-Attention

다음의 예시 문장을 통해서 Self-Attention 레이어에서 어떤 일이 일어나는지 알아보겠습니다.

[\text{“The animal didn’t cross the street because it was too tired”}]

사람은 위 문장에서의 “it” 이 어떤 단어를 지칭하는지 쉽게 알 수 있습니다. 하지만 컴퓨터가 이를 맞추기는 쉽지 않습니다. 이런 지시대명사 외에도 문장에 있는 단어는 시퀀스 내에 있는 다른 단어와 관계를 맺고 있습니다. 이 관계를 표현하는 것이 바로 Self-Attention 레이어의 목적입니다. 아래는 5번째 인코더 블록에서 “it” 이 어떤 단어와 가장 깊은 관계를 맺고 있는 지를 나타내는 그림입니다.

transformer_8

이미지 출처 : jalammar.github.io

위 그림에서 “it”“The animal”을 정확히 가리키고 있는 것을 볼 수 있습니다. Self-Attention층은 어떤 과정을 통해 이를 알아낼 수 있는 것일까요? Self-Attention층 내부에서 일어나는 일에 대해서 알아보겠습니다.

먼저 인풋 벡터가 들어오면 Self-Attention층에서는 세 가지 벡터를 인풋 벡터의 수만큼 준비합니다. 이 세 가지 벡터는 각각 쿼리(Query, Q), 키(Key, k), 밸류(Value, V)라고 부릅니다. 가장 먼저 쿼리는 현재 처리하고자 하는 토큰을 나타내는 벡터입니다. 키는 일종의 레이블(label)로, 시퀀스 내에 있는 모든 토큰에 대한 아이덴티티(Identity)를 나타냅니다. 마지막으로 밸류는 키와 연결된 실제 토큰을 나타내는 벡터입니다. 아래는 쿼리, 키, 밸류의 특징을 잘 살린 그림입니다.

transformer_9

이미지 출처 : jalammar.github.io

위 그림에서 Query #9 벡터는 10번째 단어인 “it”에 연관된 벡터입니다. 컴퓨터는 모든 단어의 키를 살펴보며 쿼리와 가장 잘 맞는 것을 찾습니다. 그리고 해당하는 키에 걸려있는 실제 밸류를 가져가게 되지요. 그렇다면 각각의 쿼리, 키, 밸류 벡터는 어떻게 만들어지는 것일까요?

Self-Attention 층 내부에는 세 벡터를 만들기 위한 행렬 $W^Q, W^K, W^V$ 가 초기화된 값으로 미리 준비되어 있습니다. 입력되는 토큰 벡터는 각 행렬과의 곱을 통해서 그에 맞는 쿼리, 키, 밸류 벡터를 만들게 됩니다. 아래 그림은 미리 준비된 행렬로부터 각 벡터가 만들어짐을 보여주는 이미지입니다.

transformer_10

이미지 출처 : jalammar.github.io

위 그림에서 $\mathbf{x}_1, \mathbf{x}_2$는 토큰 벡터입니다. 수식으로 나타내면 아래와 같이 되지요.

[\begin{aligned} \mathbf{x}_1 \cdot W^Q = \mathbf{q}_1 \qquad& \mathbf{x}_1 \cdot W^K = \mathbf{k}_1 & \mathbf{x}_1 \cdot W^V = \mathbf{v}_1
\mathbf{x}_2 \cdot W^Q = \mathbf{q}_2 \qquad& \mathbf{x}_2 \cdot W^K = \mathbf{k}_2 & \mathbf{x}_2 \cdot W^V = \mathbf{v}_2 \end{aligned}]

$W$행렬에 해당하는 $W^Q, W^K, W^V$의 각 요소 값은 학습 과정에서 갱신되는 파라미터입니다. 이후에 등장할 Multi-head Attention을 사용한다면 쿼리, 키, 밸류 벡터의 차원은 인풋 토큰 벡터보다 작아집니다. 논문에서는 8개의 Head를 사용하기 때문에 512차원의 인풋 벡터를 사용하고 쿼리, 키, 밸류 벡터는 64차원으로 설정하였습니다. 자세한 내용은 아래 Multi-head Attention에서 알아보겠습니다.

쿼리와 잘 맞는 키를 찾는 과정은 말 그대로 어텐션(Attention)입니다. 벡터의 곱연산(Dot product)을 기반으로 Softmax해주어 연관 비중을 구하게 되지요.

transformer_10

이미지 출처 : jalammar.github.io

논문에서는 학습시 그래디언트를 안정적으로 만들어주기 위해서 내적 값을 벡터 차원 수의 제곱근인 $\sqrt{d_k}$로 나누어주었습니다. 최종적으로는 소프트맥스를 통해 구해준 비중과 밸류 벡터를 곱해준 값을 모두 더하여 Self-Attention 층의 최종 출력 벡터로 계산하게 됩니다. 아래는 Self-Attention 층의 과정을 그림으로 나타낸 것입니다.

transformer_11

이미지 출처 : jalammar.github.io

위 그림에서 “Thinking”은 자기 자신과 88%만큼의 관계를, “Machine”과는 12%만큼의 관계를 맺고 있는 것을 볼 수 있습니다. 각 값을 밸류 벡터인 $\mathbf{v}_1, \mathbf{v}_2$와 각각 곱하여 더하면 최종 출력 벡터인 $\mathbf{z}_1$이 나오게 됩니다.

실제 계산은 행렬을 사용하여 한꺼번에 수행합니다. 아래는 토큰 벡터로 이루어진 행렬 $X$ 로부터 쿼리, 키, 밸류에 해당하는 행렬 $Q, K, V$ 를 만드는 과정입니다.

transformer_12

이미지 출처 : jalammar.github.io

아래는 생성된 $Q, K, V$ 로부터 Self-Attention의 아웃풋 행렬인 $Z$ 를 만들어 내기 위한 연산 과정입니다.

transformer_13

이미지 출처 : jalammar.github.io

전체 과정을 다른 그림으로 나타내면 아래와 같이 나타낼 수 있습니다. Self-Attention층 내에서 어떤 과정이 진행되는지를 그림을 따라가면서 알아보도록 하겠습니다.

transformer_15

transformer_15

transformer_15

transformer_15

이미지 출처 : jalammar.github.io

Multi-Head Attention

다음으로는 멀티헤드 어텐션(Multi-Head Attention)를 알아보겠습니다. Multi-Head Attention은 단어 간의 관계를 여러 번 계산하기 위해서 사용합니다. 동전을 던져 앞면이 나오는 확률을 구한다면 2번 던질 때보다 20번 던질 때 평균에 가까워지겠지요. 논문에서 사용한 Head의 개수는 $8(=512/64)$ 입니다. 총 8번의 Self-Attention을 실행하여 8개의 아웃풋 $Z_0, Z_1, \cdots , Z_7 $ 을 만들어냅니다. 아래는 이 과정을 그림으로 나타낸 것입니다.

transformer_16

이미지 출처 : jalammar.github.io

이렇게 나온 각각의 아웃풋 행렬 $Z_n (n=1,\cdots,7)$은 이어붙여(Concatenate)집니다. 또 다른 파라미터 행렬인 $W^o$ 와의 내적을 통해 Multi-Head Attention의 최종 결과인 행렬 $Z$를 만들어냅니다. 여기서 행렬 $W^o$의 요소 역시 학습을 통해 갱신됩니다. 최종적으로 생성된 행렬 $Z$는 토큰 벡터로 이루어진 행렬 $X$와 동일한 크기(Shape)가 됩니다. 이렇게 행렬 $Z$는 이제 FFNN으로 넘어가게 됩니다. 아래는 이 과정을 그림으로 나타낸 것입니다.

transformer_17

이미지 출처 : jalammar.github.io

Residual Block & Layer Normalization

Self-Attention층에서 출력된 행렬(벡터)은 FFNN층으로 가기 전에 Residual block과 Layer normalization 과정을 거치게 됩니다. Residual Block이란 Self-Attention층을 통과한 출력 벡터에 원래의 입력 벡터를 더해주는 과정입니다. 이렇게 더해주면 역전파(Backpropagation)에서 그래디언트를 항상 1이상으로 유지하기 때문에 정보를 더 잘 보존합니다. Computer vision분야의 ResNet에서도 Residual block을 사용하여 성능을 끌어올린 사례가 있습니다.

res_block

이미지 출처 : towardsdatascience.com

Residual Block을 지나면 Layer Normalization을 거쳐 드디어 FFNN에 들어갑니다. 아래 그림은 Residual block(Add)과 Layer normalization 과정이 인코더 블록의 Self-Attention과 FFNN 사이에서 수행됨을 잘 보여주고 있습니다.

transformer_18

이미지 출처 : jalammar.github.io

FFNN(Feed forward neural network)

다음으로 벡터는 FFNN(Feed forward neural network)로 들어갑니다. 동일한 인코더 블록 내에 있는 FFNN은 서로 동일한 가중치를 공유합니다. FFNN에서 일어나는 계산은 아래와 같은 수식에 근거하여 계산됩니다.

[FFNN(x) = \max(0, xW_1 + b_1) W_2 +b_2]

아래 그림은 FFNN 입력층(Input layer), 은닉층(Hidden layer), 출력층(Output layer)에서의 벡터를 그림으로 나타낸 것입니다. 512차원의 입력 벡터가 활성화 함수(Activation function)인 ReLU, 즉 $\max(0,\text{input})$을 지나 2048차원의 벡터가 된 뒤에 다시 512차원의 벡터로 출력됩니다.

ffnn_1

이미지 출처 : Text analytics 강의자료

Masked Self Attention

인코더 블록의 내부 구조를 모두 알아보았으니 이제는 디코더 블록의 내부 구조를 살펴볼 차례입니다. 디코더의 첫 번째 하위 레이어(Sub layer)는 인코더와 동일한 Self-Attention입니다. 하지만 디코더의 Self-Attention에는 특별한 조건이 하나 더 붙습니다. 디코더는 시퀀스를 출력하는 역할을 하기 때문에 등장하지 않은 단어에 대한 계산을 해서는 안됩니다.

예를 들어 “나는 학생이다.(I am a student)”라는 문장을 번역하는데 디코더가 “I am” 뒤에 올 단어를 예측할 차례라고 합시다. 3번째 단어를 예측하는 데 뒤에 등장하는“student”를 보고 결정해서는 안된다는 이야기입니다. 이 때문에 디코더에서 Self-Attention을 계산할 때에는 예측하련느 토큰 이전에 있는 토큰에 대한 정보만 남기고 마스킹(Masking)을 해줍니다. 이런 이유 때문에 디코더의 Self-Attention은 특별히 Masked Self-Attention이라고 부릅니다.

“나는 학생이다.(I am a student)”라는 문장을 번역하는데 “I am” 뒤에 올 단어를 예측할 차례라면 “a”, “student”에 대한 정보는 얻을 수 없도록 가려버리는 것이지요. 아래는 인코더에서 수행하는 Self-Attention과 디코더에서 수행하는 Masked Self-Attention의 차이를 그림으로 나타낸 것입니다.

transformer_19

이미지 출처 : jalammar.github.io

그렇다면 어떻게 정보를 마스킹 해줄 수 있을까요? 아래는 위 그림에 있는 두 과정의 계산 차이를 나타낸 그림입니다. 아래 그림을 보며 설명을 이어가겠습니다.

masked_attn

이미지 출처 : Text analytics 강의자료

위 그림을 보면 디코더에서 “Thinking”이라는 단어를 예측할 때에는 그 뒤에 있는 “Machine”이라는 단어의 쿼리-키 내적값 $q \cdot k$의 값을 $-\infty$로 만들어주는 것을 볼 수 있습니다. 이렇게 되면 마스킹된 부분은 소프트맥스 함수에 넣어주어도 그 값이 0이 되고, 이 때문에 밸류 벡터가 아무런 영향을 미치지 못하게 됩니다. 아래는 4개 입력 벡터에 대해 Masked Self-Attention이 작동하는 과정을 도식화한 그림입니다.

transformer_20

이미지 출처 : jalammar.github.io

“robot must obey orders”라는 4단어로 이루어진 문장에 대해서 Masked Self-Attention을 수행하는 되는 전체 과정은 아래와 같이 진행됩니다.

transformer_21

transformer_21

transformer_22

transformer_23

이미지 출처 : jalammar.github.io

Encoder-Decoder Attention

Masked Self-Attention층의 출력 벡터는 인코더 블록에서와 동일하게 Residual block과 Layer normalization을 거친 뒤에 인코더-디코더 Attention(Encoder-Decoder Attention)과정을 거치게 됩니다. 이 층에서는 인코더의 마지막 블록에서 출력된 키, 밸류 행렬으로 Self-Attention 메커니즘을 한 번 더 수행합니다.

transformer_24

이미지 출처 : jalammar.github.io

Linear & Softmax Layer

모든 인코더와 디코더 블록을 거친 벡터는 최상단 층인 Linear 층과 소프트맥스 층을 차례대로 거칩니다. Linear 층은 단순한 완전 연결(Fully connected) 신경망이며 가장 마지막 디코더 블록의 출력 벡터를 로짓 벡터(Logit vector)로 변환해 줍니다. 그리고 소프트맥스 층에서는 이 로짓 벡터를 각 토큰이 위치할 확률로 바꾸어 줍니다. 이 과정을 그림으로 나타내면 아래와 같습니다.

transformer_25

이미지 출처 : jalammar.github.io

Complexity & Performance

Complexity

트랜스포머의 구조는 위에서 모두 살펴보았습니다. 그렇다면 이러한 트랜스포머 모델의 복잡도(Complexity)는 어떠한지 다른 신경망 구조와 비교하여 보도록 하겠습니다.

Layer Type Complexity per Layer Sequential Operations Maximum Path Length
Self Attention $O(n^2 \cdot d)$ $O(1)$ $O(1)$
Recurrent $O(n \cdot d^2)$ $O(n)$ $O(n)$
Convolutional $O(k \cdot n \cdot d)$ $O(1)$ $O(\log_k (n))$
Self-Attention (restricted) $O(r \cdot n \cdot d)$ $O(1)$ $O(n / r)$

Self-Attention 메커니즘은 Sequential하게 입력을 받지 않으므로 이 부분에서 RNN보다 시간복잡도 측면에서 유리한 것을 볼 수 있습니다.

Performance

BLEU Score1를 통해서 다른 모델과 비교한 트랜스포머의 성능은 다음과 같습니다. 트랜스포머의 크기를 키운 Big 모델은 BLEU 기준으로 당시 SOTA(State-of-the-art)를 기록하였습니다. 트랜스포머 모델이 이전의 방법을 사용하였을 때보다 더 적은 학습 비용을 필요로 하는 것도 볼 수 있습니다.

trans_perform_1

이미지 출처 : Attention is all you need

게다가 논문에서는 입력 임베딩 벡터의 차원 $d_\text{model}$이나 Self-Attention 내부 쿼리-키-밸류 벡터의 차원인 $d_k$, 인코더(디코더) 블록의 개수인 $N$ 등을 변형시켜 가면서 측정한 성능을 PPL(Perplexity)와 BLEU 기준으로 보여주고 있습니다. 아래는 위와 같은 실험 내용에 대한 이미지입니다.

trans_variation

이미지 출처 : Attention is all you need

  1. 김동화님 블로그 에 잘 정리되어 있습니다. 

Comment  Read more

Seq2Seq (Sequence to Sequence) 와 Attention

|

본 포스트의 내용은 고려대학교 강필성 교수님의 강의Jay alammar의 깃허브 , 그리고 김기현의 자연어처리 딥러닝 캠프 , 밑바닥에서 시작하는 딥러닝 2 , 한국어 임베딩 책을 참고하였습니다.

Seq2Seq(Sequence to Sequence)

Seq2Seq(Sequence to Sequence)란 아이템의 시퀀스를 입력받아 또 다른 시퀀스를 내놓는 모델을 통칭하여 일컫는 말입니다. 만약 시퀀스가 문장이라면 각각의 아이템은 단어를 나타내고, 동영상이라면 프레임 별 이미지를 나타냅니다. 입력하는 아이템의 개수와 출력하는 아이템의 개수가 같을 필요는 없습니다. 아래 그림에서도 3개의 아이템으로 구성된 시퀀스가 입력되었지만 4개의 아이템으로 구성된 시퀀스가 출력되는 것을 볼 수 있습니다.

seq2seq_1

이미지 출처 : jalammar.github.io

자연어처리에서 Seq2Seq이 사용되는 대표적인 태스크(Task)로는 기계 번역(Machine translation)이 있습니다. 아래는 위 이미지를 기계번역 관점에서 구체화한 이미지입니다. 3개의 단어로 이루어진 프랑스어 문장을 4개의 단어로 이루어진 영어 문장으로 번역하고 있습니다.

seq2seq_2

이미지 출처 : jalammar.github.io

Encoder - Decoder

Seq2Seq 모델은 크게 인코더(Encoder)디코더(Decoder)가 연결된 구조로 이루어져 있습니다. 인코더는 입력한 시퀀스의 정보를 어떻게 압축하여 처리할 지를 담당합니다. 디코더는 인코더가 압축하여 넘겨준 정보를 어떤 식으로 반환해낼 지에 대한 역할을 합니다.

조금 더 자세히 설명하면 인코더는 입력 시퀀스에 있는 각각의 아이템을 컴파일(Compile)하여 하나의 벡터, 즉 문맥(Context) 벡터로 표현합니다. 시퀀스를 모두 읽어낸 후에는 생성한 문맥 벡터를 디코더에게 넘겨주게 되지요. 디코더는 이를 받아 새로운 아이템 시퀀스를 출력하게 됩니다. 아래 동영상은 인코더와 디코더가 어떤 역할을 수행하는지를 잘 보여주고 있습니다.

seq2seq_3

이미지 출처 : jalammar.github.io

기계 번역 태스크라면 아래와 같이 적용될 것입니다.

seq2seq_4

이미지 출처 : jalammar.github.io

RNN(Recurrent Neural Network)

RNN(Recurrent Neural Network, 순환 신경망) Seq2Seq에서 고전적인 모델 중 하나입니다. RNN의 은닉층(Hidden layer)에서는 은닉층 위치에 해당하는 아이템에 대해 입력 벡터와 이전 은닉층이 출력한 벡터(Hidden state vector)를 동시에 입력받습니다. 신경망은 출력 벡터뿐만 아니라 갱신된 은닉 상태 벡터를 출력합니다. 이 중 은닉 상태 벡터는 다음 Time-step에 인풋과 함께 은닉층에 입력됩니다. 아래는 RNN이 첫 번째 Time step, 즉 시퀀스 내에 있는 첫 번째 아이템을 입력받을 때 동작하는 방식을 이미지로 나타낸 것입니다.

RNN_1

이미지 출처 : jalammar.github.io

첫 번째 Time step에서는 0번째 은닉 상태 벡터와 첫 번째 아이템의 벡터가 입력됩니다. 두 벡터를 입력받은 은닉층은 이를 계산하여 은닉 상태 벡터를 새롭게 갱신합니다(태스크마다 출력 벡터를 내놓는 시점은 달라집니다). 위에서 사용했던 예시를 통해서 각 Time step마다 은닉 상태 벡터가 갱신되는 모습을 볼 수 있습니다.

seq2seq_5

이미지 출처 : jalammar.github.io

기계 번역 관점에서는 아래와 같이 Time-step 별로 자세히 나타낼 수 있습니다. 인코더에서는 각 아이템이 입력될 때마다 은닉 상태 벡터가 업데이트 됩니다. 인코더는 가장 마지막 아이템이 입력되고 난 후에 생성된 은닉 상태 벡터를 디코더에게 넘겨주게 됩니다. 디코더는 이 벡터를 받아 적절한 아이템을 반환합니다.

seq2seq_6

이미지 출처 : jalammar.github.io

Attention

RNN의 구조적 문제점

RNN의 가장 큰 단점은 장기 의존성(Long-term dependency) 문제입니다. 순차적으로 아이템이 입력되는 RNN 구조의 특성상 디코더로 들어가는 문맥(Context) 벡터는 시퀀스 뒤쪽에 있는 아이템의 영향을 더 많이 받을 수 밖에 없습니다. 특히 시퀀스가 길어지면 시퀀스 앞쪽에 있는 단어의 영향이 거의 사라집니다. 이렇게 과거 정보가 마지막까지 전달되지 못하는 것을 장기 의존성 문제라고 합니다.

장기 의존성 문제를 해결하기 위해 Vanilla RNN의 구조를 변형한 LSTM(Long-term Short Memory, 장단기 기억망)이나 GRU(Gated Recurrent Unit)등이 제시되었습니다. 이런 변형 모델이 문제를 일부 줄여주기는 했지만 순차적으로 구조적 문제는 여전히 남아있지요.

Attention

Attention은 이를 해결하기 위해서 고안된 개념입니다. RNN구조가 보여주고 있는 문제는 문맥(Context)의 정보를 가지고 있는 은닉 상태 벡터(Hidden state vector)가 하나였기 때문에 병목현상이 발생한다는 것이었습니다. 하지만 Attention은 모델이 아이템을 출력할 때마다 입력 시퀀스에서 어떤 아이템을 주목(Attention)해야하는 지를 가중치 벡터로 표시해 줍니다.

attention_connect_image

이미지 출처 : DeepMind.com

Attention에는 두 가지 모델이 있습니다. 모델을 발표한 사람의 이름을 따서 Bahadanau Attention과 Luong Attention 이라고 부릅니다. Bahadanau의 모델은 Attention 가중치(Score)를 학습하는 신경망이 따로 있으며 Luong의 모델은 유사도를 측정하여 Attention 가중치를 만들어냅니다. 두 모델 간의 성능 차이가 거의 없기 때문에 일반적으로는 Luong attention을 사용합니다.

아래는 Attention이 적용된 RNN이 어떻게 작동하는 지를 보여주는 이미지입니다.

seq2seq_7

이미지 출처 : jalammar.github.io

인코더는 더 이상 마지막 은닉 상태 벡터만을 디코더에 입력하지 않습니다. 각 Time step 마다 생성되는 은닉 상태 벡터를 보관합니다. 그리고 인코딩이 끝나면 생성된 모든 은닉 상태 벡터를 디코더에 넘겨줍니다. 생성되는 은닉 상태 벡터를 모두 넘겨주기 때문에 시퀀스 앞쪽의 정보도 디코더에 손실없이 넘겨줄 수 있게 됩니다. 덕분에 장기 의존성 문제를 해결할 수 있지요.

그렇다면 디코더는 각각의 은닉 상태 벡터를 어떻게 활용하는 것일까요? 아래는 4번째 Time step, 즉 입력 시퀀스 아이템에 의해서 생긴 은닉 상태 벡터를 디코더가 받은 후에 일어나는 일을 순차적으로 나타낸 것입니다.

attention_1

이미지 출처 : jalammar.github.io

디코더는 먼저 각각의 은닉 상태 벡터$(h_1, h_2, h_3)$를 살펴봅니다. 이 벡터들은 이전 Time-step의 은닉 상태 벡터와 입력 시퀀스의 아이템에 의해서 생성되었기 때문에 그 벡터를 생성할 시점에 입력된 아이템의 정보를 가장 많이 가지고 있습니다. 디코더의 은닉 상태 벡터는 각각의 은닉 상태 벡터와 내적한 값(Attention score)에 소프트맥스를 취해줍니다.

소프트맥스의 함숫값, 즉 위 그림에서 $(0.96, 0.02, 0.02)$ 는 각 은닉 상태 벡터에 가중치가 됩니다. 이 값은 각각의 은닉 상태 벡터와 곱해집니다. 최종적으로 이를 모두 더하여 $0.96 \times h_1 + 0.02 \times h_2 + 0.02 \times h_3$ 라는 문맥 벡터(Sum-up weighted vector)를 생성하게 됩니다. 즉 2번 단계에서 계산되는 내적값이 클수록 디코딩되는 아이템 입장에서 중요한 은닉 상태 벡터, 작을수록 중요도가 떨어지는 벡터로 판단합니다.

이렇게 생성된 컨텍스트 벡터는 디코더의 은닉 상태 벡터와 Concatenation(옆으로 붙임)하여 사용합니다. 그래서 출력 아이템을 생성하기 위해 입력되는 벡터의 사이즈는 두 벡터의 사이즈를 더한 것이 됩니다. 그리고 이를 각 출력 아이템의 FFNN(Feed-forward neural network)에 넣어 출력 아이템의 임베딩 벡터를 도출합니다. 이 과정을 도식화하면 아래와 같이 나타낼 수 있습니다.

attention_2

이미지 출처 : jalammar.github.io

위 그림에서 <END>는 입력 문장의 끝을 나타내는 토큰으로 디코더가 작동하는 시점을 알리는 신호의 역할을 합니다. $h_\text{init}$는 각 은닉 상태 벡터를 내적하여 새로운 문맥 상태 벡터 $C_4$를 생성합니다. 이 벡터는 디코더의 첫 번째 Time step의 은닉층이 생성한 $h_4$와 이어붙여집니다. 이렇게 생성된 벡터 $[C_4;h_4]$ 는 FFNN을 거쳐 가장 적합한 아이템 “I”의 임베딩 벡터를 출력하게 되지요.

다음 Time step에서는 이전 단계의 은닉층이 출력하는 은닉 상태 벡터, 즉 $h_4$가 이전 단계에서 $h_\text{init}$과 같은 역할을 하게 됩니다. 그리고 이전 단계의 <END>와 같은 역할은 이전 단계의 출력 벡터인 “I”의 임베딩 벡터가 맡게 됩니다. 동일한 과정을 거치면 “am”의 임베딩 벡터가 출력됩니다. 이런 과정을 계속 반복하면 “a”, “student”의 임베딩 벡터가 생성되며 디코더는 <END>혹은 <END>벡터를 생성할 때까지 이 과정을 반복한 후에 문장 생성을 종료합니다.

출력 시퀀스의 각 아이템이 생성될 때 인코더의 은닉 상태 벡터에게 받은 Attention score를 시각화한 결과는 다음과 같습니다.

seq2seq_9

이미지 출처 : jalammar.github.io

그림으로부터 “Je”“I”, “suis”“am”, “étudiant”“student” 가 깊은 연관을 가지고 있음을, 번역된 “a”“suis”“étudiant” 에 골고루 연관되어 있음을 알 수 있습니다.

의의

RNN구조든 Attention이든 자연어처리에서 Seq2Seq 구조의 발전은 기계 번역 태스크의 커다란 발전을 가져왔습니다. 게다가 RNN의 구조적 한계점을 개선한 Attention의 등장으로 긴 문장에 대해서도 좋은 성능을 보이는 모델이 개발되었지요. 이후 Self-Attention을 활용한 트랜스포머(Transformer)가 등장하게 되는 배경이 되는 아이디어로도 중요한 역할을 담당하고 있습니다.

Comment  Read more

동적 프로그래밍 (Dynamic Programming)

|

본 게시물은 문일철 교수님의 데이터 구조 및 분석을 참조하여 작성하였습니다.

Dynamic Programming

재귀(Recursion)으로 구현한 함수에는 한 가지 큰 문제가 있었습니다. 탈출문이 등장할 때까지 문제를 분할(Divide)하기 때문에 함수를 너무 많이 호출하게 된다는 것이었습니다. 이 때문에 굉장히 큰 파라미터를 입력할 경우 스택 프레임에 굉장히 많은 아이템이 쌓이게 되어 시간이 오래 걸리고 굉장히 많은 컴퓨팅 자원을 필요로 했습니다.

이런 문제를 해결하고자 등장한 것이 동적 프로그래밍(Dynamic programming)입니다. 동적 프로그래밍은 하위 인스턴스(Sub-Instance)가 반복되는 문제를 해결하기 위해 고안된 알고리즘 설계 기술입니다. 하위 인스턴스는 우리가 구하고자 하는 인스턴스에서 파생되는 인스턴스를 말합니다.

재귀에서 사용했던 예시를 바탕으로 동적 프로그래밍에 대해서 알아보겠습니다. 아래는 재귀로 피보나치를 구현한 후 $fib(7)$을 구할 때 발생하는 함수 호출 그래프입니다.

fib_rec

이미지 출처 : semanticscholar.org

함수 호출 그래프에서 $fib(0), fib(1), fib(2), fib(3)$ 등의 하위 인스턴스가 굉장히 많이 호출되고 있는 것을 볼 수 있습니다. 실제로는 위 이미지에서 초록색으로 색칠된 값만 있어도 나머지 원하는 인스턴스의 값을 구할 수 있는데 말이지요. 동적 프로그래밍은 위와 같이 반복되는 하위 인스턴스를 줄이기 위해 개발되었습니다. 동적 프로그래밍의 기본적인 아이디어는 ‘큰 문제를 풀기 위해서 하위 인스턴스 부터 차근차근 해결해보자’ 입니다.

동적 프로그래밍의 스토리라인은 다음과 같습니다.

우리가 풀어야 할 큰 문제를 그보다 작은 작은 문제외 연결 - 작은 문제를 풀어 구해진 값을 테이블에 저장 - 테이블에 저장된 값중 더 큰 문제를 푸는 데에 필요한 값을 추출하여 사용

Memoization

메모이제이션(Memoization)은 동적 프로그래밍에서 사용되는 기법입니다. 위 스토리라인처럼 작은 문제를 풀어 나온 답을 테이블에 저장하고 한 번 구해진 인스턴스가 다시 구해질 때에는 테이블에 있는 값을 사용합니다.

rec_dy

위 그림에서도 알 수 있듯이 재귀는 위에서 아래로 향하는 Top-down 접근 방식이며, 동적 프로그래밍은 Bottom-up이라고 할 수 있습니다. 동적 프로그래밍에서는 한 번 구해진 값에 대해서는 만들어 놓은 테이블에서 값을 꺼내 사용하기 때문에 각 하위인스턴스마다 한 번씩만 값을 구하게 됩니다. 그래서 위 이미지에서 노란색에 해당하는 인스턴스의 값을 구할 필요가 없어지지요.

아래는 피보나치 수열을 동적 프로그래밍으로 구현한 코드입니다.

def FibonacciDP(n):
    """
    Memoization table 구성하기
    딕셔너리로 빈 테이블을 만든 후,
    필요한 최하위 인스턴스를 넣어준다.
    재귀문에서 탈출 조건문에 해당되는 부분을 바꾸어 쓸 수 있다.
    """
    dicFibonacci = {}
    dicFibonacci[0] = 0
    dicFibonacci[1] = 1
	
    """
    더 큰 문제를 풀기 위해서 연결한다.
    """
    for itr in range(2, n+1):
        dicFibonacci[itr] = dicFibonacci[itr-1] + dicFibonacci[itr-2]
    return dicFibonacci[n]

동적 프로그래밍 방법을 사용하여 구현한 피보나치 알고리즘의 시간 복잡도를 빅-O 표기법(Big-O Notation)으로 나타내면 $O(N)$입니다. 재귀룰 사용하여 구현한 피보나치의 알고리즘의 시간 복잡도가 $O(2^N)$이었던 것을 생각해보면 동적 프로그래밍을 사용하여 구현하는 것이 얼마나 효율적인지를 알 수 있습니다.

Comment  Read more