ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 트랜스포머(Transformer)
    Research/NLP_reference 2024. 2. 15. 21:01

    ※ 출처: 딥 러닝을 이용한 자연어 처리 입문


    RNN을 이용한 인코더-디코더

    하나의 RNN을 인코더, 또 다른 하나의 RNN을 디코더라는 모듈로 명명하고 두 개의 RNN을 연결해서 사용하는 인코더-디코더 구조는 주로 입력 문장과 출력 문장의 길이가 다를 경우에 사용하는데, 대표적 분야가 번역기나 텍스트 요약입니다. 영어 문장을 한국어 문장으로 번역한다고 하였을 때, 입력 문장인 영어 문장과 번역된 결과인 한국어 문장의 길이는 똑같을 필요가 없습니다. 텍스트 요약의 경우에는 출력 문장이 요약된 문장이므로 입력 문장보다는 당연히 길이가 짧을 것입니다. 


    시퀀스-투-시퀀스 (Sequence-to-Sequence, seq2seq)

    시퀀스-투-시퀀스 (Sequence-to-Sequence, seq2seq)는 입력된 시퀀스로부터 다른 도메인의 시퀀스를 출력하는 다양한 분야에서 사용되는 모델입니다. 예를 들어 챗봇(Chatbot)과 기계 번역(Machine Translation)이 대표적인 예인데, 입력 시퀀스와 출력 시퀀스를 각각 질문과 대답으로 구성하면 챗봇으로 만들 수 있고, 입력 시퀀스와 출력 시퀀스를 각각 입력 문장과 번역 문장으로 만들면 번역기로 만들 수 있습니다. 그 외에도 내용 요약(Text Summarization), STT(Speech to Text) 등에서 쓰일 수 있습니다.

     

    seq2seq는 번역기에서 대표적으로 사용되는 모델입니다. RNN을 어떻게 조립했느냐에 따라서 seq2seq 구조가 만들어집니다.

     

    위의 그림은 seq2seq 모델로 만들어진 번역기가 'I am a student'라는 영어 문장을 입력받아서, 'je suis étudiant'라는 프랑스 문장을 출력하는 모습을 보여줍니다. 그렇다면, seq2seq 모델 내부의 모습은 어떻게 구성되었을까요?

     

    seq2seq는 크게 인코더와 디코더라는 두 개의 모듈로 구성됩니다. 인코더는 입력 문장의 모든 단어들을 순차적으로 입력받은 뒤에 마지막에 이 모든 단어 정보들을 압축해서 하나의 벡터로 만드는데, 이를 컨텍스트 벡터(context vector)라고 합니다. 입력 문장의 정보가 하나의 컨텍스트 벡터로 모두 압축되면 인코더는 컨텍스트 벡터를 디코더로 전송합니다. 디코더는 컨텍스트 벡터를 받아서 번역된 단어를 한 개씩 순차적으로 출력합니다.

     

    위 그림에서는 컨텍스트 벡터를 4의 사이즈로 표현하였지만, 실제 현업에서 사용되는 seq2seq 모델에서는 보통 수백 이상의 차원을 갖고 있습니다. 인코더와 디코더의 내부를 좀 더 확대해보겠습니다.

     

    인코더 아키텍처와 디코더 아키텍처의 내부는 사실 두 개의 RNN 아키텍처입니다. 입력 문장을 받는 RNN 셀을 인코더라고 하고, 출력 문장을 출력하는 RNN 셀을 디코더라고 합니다. 물론, 성능 문제로 인해 실제로는 바닐라 RNN이 아니라 LSTM 또는 GRU 들로 구성됩니다. 

     

    우선 인코더를 자세히보면, 입력 문장은 단어 토큰화를 통해서 단어 단위로 쪼개지고 단어 토큰 각각은 RNN 셀의 각 시점의 입력이 됩니다. 인코더 RNN 셀은 모든 단어를 입력받은 뒤에 인코더 RNN 셀의 마지막 시점의 은닉 상태를 디코더 RNN 셀로 넘겨주는데 이를 컨텍스트 벡터라고 합니다. 컨텍스트 벡터는 디코더 RNN 셀의 첫번째 은닉 상태에 사용됩니다.

     

    디코더는 기본적으로 RNNLM(RNN Language Model)입니다. 디코더는 초기 입력으로 문장의 시작을 의미하는 심볼 <sos>가 들어갑니다. 디코더는 <sos>가 입력되면, 다음에 등장할 확률이 높은 단어를 예측합니다. 첫 번째 시점(time step)의 디코더 RNN 셀은 다음에 등장할 단어로 je를 예측하였습니다. 첫 번째 시점의 디코더 RNN 셀은 예측된 단어 je를 다음 시점의 RNN 셀의 입력으로 입력합니다. 그리고 두 번째 시점의 디코더 RNN 셀은 입력된 단어 je로부터 다시 다음에 올 단어인 suis를 예측하고, 그 예측한 단어를 다음 시점의 RNN 셀의 입력으로 넣는 행위를 반복합니다. 이 행위는 문장의 끝을 의미하는 심볼인 <eos>가 다음 단어로 예측될 때까지 반복됩니다. 지금 설명하는 것은 테스트 과정 동안의 이야기입니다.

     

    seq2seq는 훈련 과정과 테스트 과정(또는 실제 번역기를 사람이 쓸 때)의 작동 방식이 조금 다릅니다. 훈련 과정에서는 디코더에게 인코더가 보낸 컨텍스트 벡터와 실제 정답인 상황인 <sos> je suis étudiant를 입력 받았을 때, je suis étudiant <eos>가 나와야 된다고 정답을 알려주면서 훈련합니다. (교사 강요 teacher forcing). 

     

    반면 테스트 과정에서는 앞서 설명한 과정과 같이 디코더는 오직 컨텍스트 벡터와 <sos>만을 입력으로 받은 후에 다음에 올 단어를 예측하고, 그 단어를 다음 시점의 RNN 셀의 입력으로 넣는 행위를 반복합니다. 

     

    이번에는 입, 출력에 쓰이는 단어 토큰들이 있는 부분을 좀 더 확대해보겠습니다.

     

    기계는 텍스트보다 숫자를 잘 처리합니다. 자연어 처리에서 텍스트를 벡터로 바꾸는 방법으로 워드 임베딩이 사용됩니다. 즉, seq2seq에서 사용되는 모든 단어들은 임베딩 벡터로 변환 후 입력으로 사용됩니다. 위 그림은 모든 단어에 대해서 임베딩 과정을 거치는 단계인 임베딩 층(embedding layer)의 모습을 보여줍니다.

     

    예를 들어, I, am, a, student라는 단어들에 대한 임베딩 벡터는 위와 같은 모습을 가집니다. 여기서는 사이즈를 4로 하였지만, 보통 실제 임베딩 벡터는 수백 개의 차원을 가질 수 있습니다. 

     

    RNN 셀에 대해서 확대해보겠습니다. 하나의 RNN 셀은 각 시점(time step)마다 두 개의 입력을 받습니다.

     

    현재 시점(time step)을 t라고 할 때, RNN 셀은 t-1에서의 은닉 상태t에서의 입력 벡터를 입력으로 받고, t에서의 은닉 상태를 만듭니다. 이때 t에서의 은닉 상태는 바로 위에 또 다른 은닉층이나 출력층이 존재할 경우에는 위의 층으로 보내거나, 필요없으면 값을 무시할 수 있습니다. 그리고 RNN 셀은 다음 시점에 해당하는 t+1의 RNN 셀의 입력으로 현재 t에서의 은닉 상태를 보냅니다.

     

    이런 구조에서 현재 시점 t에서의 은닉 상태과거 시점의 동일한 RNN 셀에서의 모든 은닉 상태의 값들의 영향을 누적해서 받아온 값이라고 할 수 있습니다. 그렇기 때문에 컨텍스트 벡터는 인코더에서의 마지막 RNN 셀의 은닉 상태값이며, 이는 입력 문장의 모든 단어 토큰들의 정보를 요약해서 담고있다고 할 수 있습니다.

     

    디코더는 인코더의 마지막 RNN 셀의 은닉 상태인 컨텍스트 벡터첫 번째 은닉 상태의 값으로 사용합니다. 디코더의 첫 번째 RNN 셀은 이 첫 번째 은닉 상태 값과, 현재 t에서의 입력값인 <sos>로부터, 다음에 등장할 단어를 예측합니다. 그리고 이 예측된 단어는 다음 시점인 t+1 RNN에 입력값이 되고, 이 t+1에서의 RNN 또한 이 입력값과 t에서의 은닉 상태로부터 t+1에서의 출력 벡터, 즉, 또 다시 다음에 등장할 단어를 예측합니다.

     

    디코더가 다음에 등장할 단어를 예측하는 부분을 확대해보겠습니다.

     

    출력 단어로 나올 수 있는 단어들은 다양한 단어들이 있습니다. seq2seq 모델은 선택될 수 있는 모든 단어들로부터 하나의 단어를 골라서 예측해야 합니다. 이를 예측하기 위해서 쓸 수 있는 함수가 바로 소프트맥스 함수입니다. 디코더에서 각 시점(time step)의 RNN 셀에서 출력 벡터가 나오면, 해당 벡터는 소프트맥스 함수를 통해 출력 시퀀스의 각 단어별 확률값을 반환하고, 디코더는 출력 단어를 결정합니다. 

     

    지금까지 가장 기본적인 seq2seq에 대해서 배워보았습니다. 사실 seq2seq는 어떻게 구현하느냐에 따라서 충분히 더 복잡해질 수 있습니다. 컨텍스트 벡터를 디코더의 초기 은닉 상태로만 사용할 수도 있고, 거기서 더 나아가 컨텍스트 벡터를 디코더가 단어를 예측하는 매 시점마다 하나의 입력으로 사용할 수도 있으며 거기서 더 나아가면 어텐션 메커니즘이라는 방법을 통해 지금 알고있는 컨텍스트 벡터보다 더욱 문맥을 반영할 수 있는 컨텍스트 벡터를 구하여 매 시점마다 하나의 입력으로 사용할 수도 있습니다. 


    어텐션 메커니즘 (Attention Mechanism)

    앞서 배운 seq2seq 모델은 인코더에서 입력 시퀀스를 컨텍스트 벡터라는 하나의 고정된 크기의 벡터 표현으로 압축하고, 디코더는 이 컨텍스트 벡터를 통해서 출력 시퀀스를 만들어냈습니다.

     

    하지만 이러한 RNN에 기반한 seq2seq 모델에는 크게 두 가지 문제가 있습니다.

    1. 하나의 고정된 크기의 벡터에 모든 정보를 압축하므로 정보 손실이 발생합니다.
    2. RNN의 고질적인 문제인 기울기 소실(vanishing gradient) 문제가 존재합니다.

    결국 이는 기계 번역 분야에서 입력 문장이 길면 번역 품질이 떨어지는 현상으로 나타났습니다. 이를 위한 대안으로 입력 시퀀스가 길어지면 출력 시퀀스의 정확도가 떨어지는 것을 보정해주기 위한 등장한 기법인 어텐션(attention)을 소개합니다.  

    1. 어텐션(Attention)의 아이디어

    어텐션의 기본 아이디어는 디코더에서 출력 단어를 예측하는 매 시점(time step)마다, 인코더에서의 전체 입력 문장을 다시 한 번 참고한다는 점입니다. 단, 전체 입력 문장을 전부 다 동일한 비율로 참고하는 것이 아니라, 해당 시점에서 예측해야 할 단어와 연관이 있는 입력 단어 부분을 좀 더 집중(attention)해서 봅니다.

    2. 어텐션 함수(Attention Function)

    어텐션 메커니즘을 언급하기 전에 컴퓨터 공학의 많은 분야에서 사용되는 Key-Value로 구성되는 자료형에 대해 언급하겠습니다. 가령, 파이썬에도 Key-Value로 구성되는 자료형인 딕셔너리(Dict) 자료형이 존재합니다. 딕셔너리 자료형은 키(Key)와 값(Value)이라는 두 개의 쌍으로 구성되는데, 키를 통해서 맵핑된 값을 찾을 수 있다는 특징을 갖습니다.

     

    # 파이썬의 딕셔너리 자료형을 선언
    # 키(Key) : 값(value)의 형식으로 키와 값의 쌍(Pair)을 선언한다.
    dict = {"2017" : "Transformer", "2018" : "BERT"}

     

    위의 자료형에서 2017은 키에 해당되며, Transformer는 2017의 키와 맵핑되는 값에 해당합니다. 마찬가지로 2018은 키에 해당되며, BERT는 2018이라는 키와 맵핑되는 값에 해당합니다.

     

    Key-Value 자료형에 대한 이해를 가지고 어텐션 함수를 설명하겠습니다.

     

    어텐션을 함수로 표현하면 다음과 같습니다.

    Attention(Q, K, V) = Attention Value

     

    어텐션 함수는 주어진 '쿼리(Query)'에 대해서 모든 '키(Key)'와의 유사도를 각각 구합니다. 그리고 이 유사도를 키와 맵핑되는 각 '값(Value)'에 반영합니다. 그리고 유사도가 반영된 '값(Value)'을 모두 더해서 반환합니다. 이를 어텐션 값(Attention Value)이라고 하겠습니다. 

     

    지금부터 배우는 seq2seq + 어텐션 모델에서 Q, K, V에 해당하는 각 Query, Keys, Values는 다음과 같습니다.

     

    Q = Query : t 시점의 디코더 셀에서의 은닉 상태
    K = Keys : 모든 시점의 인코더 셀의 은닉 상태들
    V = Values : 모든 시점의 인코더 셀의 은닉 상태들

    3. 닷-프로덕트 어텐션(Dot-Product Attention)

    어텐션은 다양한 종류가 있는데 닷-프로덕트 어텐션(Dot-Product Attention)을 통해 어텐션을 이해해봅시다. seq2seq에서 사용되는 어텐션 중에서 닷-프로덕트 어텐션과 다른 어텐션의 차이는 주로 중간 수식의 차이로 메커니즘 자체는 거의 유사합니다.

     

    위 그림은 디코더의 세 번째 LSTM 셀에서 출력 단어를 예측할 때, 어텐션 메커니즘을 사용하는 모습을 보여줍니다. 디코더의 첫 번째, 두 번째 LSTM 셀은 이미 어텐션 메커니즘을 통해 je와 suis를 예측하는 과정을 거쳤다고 가정합니다. 디코더의 세 번째 LSTM 셀은 출력 단어를 예측하기 위해서 인코더의 모든 입력 단어들의 정보를 다시 한번 참고하고자 합니다. 여기서 주목할 것은 인코더의 소프트맥스 함수입니다.

     

    소프트맥스 함수를 통해 나온 결과값은 I, am, a, student 단어 각각이 출력 단어를 예측할 때 얼마나 도움이 되는지의 정도를 수치화한 값입니다. 위 그림에서는 빨간 직사각형의 크기로 소프트맥스 함수의 결과값의 크기를 표현했습니다. 직사각형의 크기가 클수록 도움이 되는 정도의 크기가 큽니다. 각 입력 단어가 디코더의 예측에 도움이 되는 정도를 수치화하여 측정하면 이를 하나의 정보에 담아서 디코더로 전송합니다. 위의 그림에서 초록색 삼각형이 이에 해당합니다. 결과적으로, 디코더는 출력 단어를 더 정확하게 예측할 확률이 높아집니다.

    1) 어텐션 스코어(Attention Score)를 구한다.

     

    인코더의 시점을 각각 1, 2, ... N이라고 하였을 때 인코더의 은닉 상태(hidden state)를 각각 h_1, h_2, ... h_N라고 합시다. 디코더의 현재 시점 t에서의 디코더의 은닉 상태(hidden state)를 s_t라고 합시다. 또한 여기서는 인코더의 은닉 상태와 디코더의 은닉 상태의 차원이 같다고 가정합니다. 

     

    어텐션 스코어(Attention score)에 대해서 배우기 전에, 디코더의 현재 시점 t에서 필요한 입력값을 상기해보겠습니다. 시점 t에서 출력 단어를 예측하기 위해서 디코더의 셀은 두 개의 입력값을 필요로 하는데, 이전 시점인 t-1의 은닉 상태이전 시점 t-1에 나온 출력 단어입니다.

     

    어텐션 메커니즘에서는 출력 단어 예측에 또 다른 값을 필요로 하는데 바로 어텐션 값(Attention Value)이라는 새로운 값입니다. t번째 단어를 예측하기 위한 어텐션 값을 a_t이라고 정의하겠습니다.

     

    어텐션 값이 현재 시점 t에서의 출력 예측에 구체적으로 어떻게 반영되는지는 뒤에서 설명하겠습니다. 지금부터 배우는 모든 과정은 a_t를 구하기 위한 여정입니다. 그리고 그 여정의 첫 걸음은 바로 어텐션 스코어를 구하는 일입니다. 어텐션 스코어(Attention score)란, 현재 디코더의 시점 t에서 단어를 예측하기 위해, 인코더의 모든 은닉 상태 각각이 디코더의 현 시점의 은닉 상태 s_t와 얼마나 유사한지를 판단하는 값입니다.

     

    닷 프로덕트 어텐션에서는 이 스코어 값을 구하기 위해 s_t를 전치(transpose)하고 각 은닉 상태와 내적(dot product)을 수행합니다. 즉, 모든 어텐션 스코어 값은 스칼라입니다. 예를 들어 s_t와 인코더의 i번째 은닉 상태의 어텐션 스코어의 계산 방법은 아래와 같습니다.

    2) 소프트맥스(softmax) 함수를 통해 어텐션 분포(Attention Distribution)를 구한다.

     

    e^t에 소프트맥스 함수를 적용하여, 모든 값을 합하면 1이 되는 확률 분포를 얻어냅니다. 이를 어텐션 분포(Attention Distribution)라고 하며, 각 값은 어텐션 가중치(Attention Weight)라고 합니다. 예를 들어 소프트맥스 함수를 적용하여 얻은 출력값인 I, am, a, student의 어텐션 가중치를 각각 0.1, 0.4, 0.1, 0.4라고 합시다. 이들의 합은 1입니다. 위 그림은 각 인코더의 은닉 상태에서의 어텐션 가중치의 크기를 직사각형의 크기를 통해 시각화하였습니다. 즉, 어텐션 가중치가 클수록 직사각형이 큽니다.

    3) 각 인코더의 어텐션 가중치와 은닉 상태를 가중합하여 어텐션 값(Attention Value)을 구한다.

     

    이제 지금까지 준비해온 정보들을 하나로 합치는 단계입니다. 어텐션의 최종 결과값을 얻기 위해서 각 인코더의 은닉 상태와 어텐션 가중치값들을 곱하고, 최종적으로 모두 더합니다. 요약하면 가중합(Weighted Sum)을 진행합니다. 아래는 어텐션의 최종 결과. 즉, 어텐션 함수의 출력값인 어텐션 값(Attention Value) a_t에 대한 식을 보여줍니다.

     

    이러한 어텐션 값 a_t은 종종 인코더의 문맥을 포함하고 있다고하여, 컨텍스트 벡터(context vector)라고도 불립니다. 앞서 배운 기본적 seq2seq에서는 인코더의 마지막 은닉 상태를 컨텍스트 벡터라고 부르는 것과 대조됩니다.

    4) 어텐션 값과 디코더의 t 시점의 은닉 상태를 연결한다.(Concatenate)

     

    어텐션 함수의 최종값인 어텐션 값 a_t를 구했습니다. 어텐션 메커니즘은 a_t를 s_t와 결합(concatenate)하여 하나의 벡터로 만드는 작업을 수행합니다. 이를 v_t라고 정의하겠습니다. 그리고 이 v_t를 y^hat 예측 연산의 입력으로 사용함으로써 인코더로부터 얻은 정보를 활용하여 y^hat를 좀 더 잘 예측할 수 있습니다. 이것이 어텐션 메커니즘의 핵심입니다.

    5) 출력층 연산의 입력이 되는 s_t~를 계산합니다.

     

    논문에서는 v_t를 바로 출력층으로 보내기 전에 신경망 연산을 한 번 더 추가하였습니다. 가중치 행렬과 곱한 후에 하이퍼볼릭탄젠트 함수를 지나도록 하여 출력층 연산을 위한 새로운 벡터인 s_t~를 얻습니다. 어텐션 메커니즘을 사용하지 않는 seq2seq에서는 출력층의 입력이 t시점의 은닉 상태인 s_t였던 반면, 어텐션 메커니즘에서는 출력층의 입력이 s_t~가 되는 셈입니다.

    6) s_t~를 출력층의 입력으로 사용합니다.

    4. 다양한 종류의 어텐션(Attention)

    앞서 seq2seq + 어텐션(attention) 모델에 쓰일 수 있는 다양한 어텐션 종류가 있지만, 닷-프로덕트 어텐션과 다른 어텐션들의 차이는 중간 수식의 차이라고 언급한 바 있습니다. 중간 수식은 어텐션 스코어 함수를 말합니다. 위에서 배운 어텐션이 닷-프로덕트 어텐션인 이유는 어텐션 스코어를 구하는 방법이 내적이었기 때문입니다.

     

    어텐션 스코어를 구하는 방법은 여러가지가 있으며, 현재 제시된 어텐션 스코어 함수는 다음과 같습니다.

     

    위에서 s_t는 Query, h_i는 Keys, Wa와 Wb는 학습 가능한 가중치 행렬입니다.

     

    이름이 dot이라고 붙여진 스코어 함수가 닷 프로덕트 어텐션입니다. 이 어텐션은 제안한 사람의 이름을 따서 루옹(Luong) 어텐션이라고도 합니다. 

     

    concat이라는 이름의 어텐션은 바다나우(Bahdanau) 어텐션 이라고도 부르며 뒤에서 설명합니다.

     

    지금까지 seq2seq에서 성능을 향상시켜주기 위한 기법인 어텐션에 대해서 알아봤습니다. 어텐션은 처음에는 RNN 기반의 seq2seq의 성능을 보정하기 위한 목적으로 소개되었지만, 현재에 이르러서는 어텐션 스스로가 기존의 seq2seq를 대체하는 방법이 되어가고 있습니다. 이에 대해서는 트랜스포머(Transformer) 챕터에서 더 자세히 배워보겠습니다.


    바다나우 어텐션(Bahdanau Attention)

    이번에는 닷 프로덕트 어텐션보다 조금 더 복잡하게 설계된 바다나우 어텐션 메커니즘을 이해해봅시다.

    1. 바다나우 어텐션 함수(Bahdanau Attention Function)

    어텐션 메커니즘을 함수 Attention()으로 정의하였을 때, 바다나우 어텐션 함수의 입, 출력은 다음과 같이 정의할 수 있습니다.

     

    Attention(Q, K, V) = Attention Value

    t = 어텐션 메커니즘이 수행되는 디코더 셀의 현재 시점을 의미.
    
    Q = Query : t-1 시점의 디코더 셀에서의 은닉 상태
    K = Keys : 모든 시점의 인코더 셀의 은닉 상태들
    V = Values : 모든 시점의 인코더 셀의 은닉 상태들
    

     

    여기서는 어텐션 함수의 Query디코더 셀의 t 시점의 은닉 상태가 아니라 t-1 시점의 은닉 상태임을 주목합시다.

    2. 바다나우 어텐션(Bahdanau Attention)

    바다나우 어텐션의 연산 순서를 이해해봅시다.

    1) 어텐션 스코어(Attention Score)를 구한다.

     

    인코더의 시점을 각각 1, 2, ... N이라고 하였을 때 인코더의 은닉 상태를 각각 h_1, h_2, ... h_N라고 합시다. 디코더의 현재 시점 t에서의 디코더의 은닉 상태를 s_t라고 합시다. 또한 인코더의 은닉 상태와 디코더의 은닉 상태의 차원이 같다고 가정합니다. 위의 그림의 경우에는 인코더의 은닉 상태와 디코더의 은닉 상태가 동일하게 차원이 4입니다.

     

    앞서 루옹 어텐션에서는 Query로 디코더의 t 시점의 은닉 상태를 사용한 것과 달리 이번에는 t-1 시점의 은닉 상태 s_t-1를 사용합니다. 바다나우 어텐션의 어텐션 스코어 함수. 즉, s_t-1과 인코더의 i번째 은닉 상태의 어텐션 스코어 계산 방법은 아래와 같습니다.

     

    이들을 더한 후, 하이퍼볼릭탄젠트 함수를 지나도록 합니다.

     

    지금까지 진행된 연산의 수식은 다음과 같습니다.

    2) 소프트맥스(softmax) 함수를 통해 어텐션 분포(Attention Distribution)를 구한다.

     

    e^t에 소프트맥스 함수를 적용하여, 모든 값을 합하면 1이 되는 확률 분포를 얻어냅니다. 이를 어텐션 분포(Attention Distribution)라고 하며, 각 값은 어텐션 가중치(Attention Weight)라고 합니다.

    3) 각 인코더의 어텐션 가중치와 은닉 상태를 가중합하여 어텐션 값(Attention Value)을 구한다.

     

    지금까지 준비해온 정보들을 하나로 합치는 단계입니다. 어텐션의 최종 결과값을 얻기 위해서 각 인코더의 은닉 상태와 어텐션 가중치값들을 곱하고, 최종적으로 모두 더합니다. 요약하면 가중합(Weighted Sum)을 합니다. 이 벡터는 인코더의 문맥을 포함하고 있다고하여, 컨텍스트 벡터(context vector)라고 부릅니다.

    4) 컨텍스트 벡터로부터 s_t를 구합니다.

    기존의 LSTM이 s_t를 구할 때를 아래 그림을 통해 상기해봅시다. 기존의 LSTM이전 시점의 셀로부터 전달받은 은닉 상태 s_t-1현재 시점의 입력 x_t를 가지고 연산하였습니다. 아래의 LSTM은 seq2seq의 디코더이며 현재 시점의 입력 x_t는 임베딩된 단어 벡터입니다.

     

    그렇다면 어텐션 메커니즘에서는 어떨까요? 아래의 그림은 바다나우 어텐션 메커니즘에서는 컨텍스트 벡터와 현재 시점의 입력인 단어의 임베딩 벡터를 연결(concatenate)하고, 현재 시점의 새로운 입력으로 사용하는 모습을 보여줍니다. 그리고 이전 시점의 셀로부터 전달받은 은닉 상태 s_t-1현재 시점의 새로운 입력으로부터 s_t를 구합니다

     

    기존의 LSTM이 임베딩된 단어 벡터를 입력으로 하는 것에서, 컨텍스트 벡터와 임베딩된 단어 벡터를 연결(concatenate)하여 입력으로 사용하는 것이 달라졌습니다.

     

    이후에는 어텐션 메커니즘을 사용하지 않는 경우와 동일합니다. s_t는 출력층으로 전달되어 현재 시점의 예측값을 구하게 됩니다. 


    트랜스포머(Transformer)

    트랜스포머(Transformer)는 2017년 구글이 발표한 논문인 "Attention is all you need"에서 나온 모델로 기존의 seq2seq의 구조인 인코더-디코더를 따르면서도, 논문의 이름처럼 어텐션(Attention)만으로 구현한 모델입니다. 이 모델은 RNN을 사용하지 않고, 인코더-디코더 구조를 설계하였음에도 번역 성능에서도 RNN보다 우수한 성능을 보여주었습니다. 

    1. 기존의 seq2seq 모델의 한계

    트랜스포머에 대해서 배우기 전에 기존의 seq2seq를 상기해봅시다. 기존의 seq2seq 모델은 인코더-디코더 구조로 구성되어 있었습니다. 여기서 인코더입력 시퀀스를 하나의 벡터 표현으로 압축하고, 디코더는 이 벡터 표현을 통해서 출력 시퀀스를 만들어냈습니다. 하지만 이러한 구조는 인코더가 입력 시퀀스를 하나의 벡터로 압축하는 과정에서 입력 시퀀스의 정보가 일부 손실된다는 단점이 있었고, 이를 보정하기 위해 어텐션이 사용되었습니다. 그런데 어텐션을 RNN의 보정을 위한 용도로서 사용하는 것이 아니라 어텐션만으로 인코더와 디코더를 만들어보면 어떨까요?

    2. 트랜스포머(Transformer)의 주요 하이퍼파라미터

    시작에 앞서 트랜스포머의 하이퍼파라미터를 정의합니다. 아래에서 정의하는 수치는 트랜스포머를 제안한 논문에서 사용한 수치로 하이퍼파라미터는 사용자가 모델 설계 시 임의로 변경할 수 있는 값들입니다.

    d_model = 512

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

    num_layers = 6

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

    num_heads = 8

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

    d_ff = 2048

    트랜스포머 내부에는 피드 포워드 신경망이 존재하며 해당 신경망의 은닉층의 크기를 의미합니다. 피드 포워드 신경망의 입력층과 출력층의 크기는 d_model입니다.

    3. 트랜스포머(Transformer)

     

    트랜스포머는 RNN을 사용하지 않지만 기존의 seq2seq처럼 인코더에서 입력 시퀀스를 입력받고, 디코더에서 출력 시퀀스를 출력하는 인코더-디코더 구조를 유지하고 있습니다. 이전 seq2seq 구조에서는 인코더와 디코더에서 각각 하나의 RNN이 t개의 시점을 가지는 구조였다면, 이번에는 인코더와 디코더라는 단위가 N개로 구성되는 구조입니다. 트랜스포머를 제안한 논문에서는 인코더와 디코더의 갯수를 각각 6개 사용하였습니다.

     

    위 그림은 인코더와 디코더가 6개씩 존재하는 트랜스포머의 구조를 보여줍니다. 여기서는 인코더와 디코더가 여러 개 쌓였다는 의미를 encoders, decoders라고 표현하겠습니다.

     

    위 그림은 인코더로부터 정보를 전달받아 디코더가 출력 결과를 만들어내는 트랜스포머 구조를 보여줍니다. 디코더는 마치 기존의 seq2seq 구조처럼 시작 심볼 <sos>를 입력으로 받아 종료 심볼 <eos>가 나올 때까지 연산을 진행합니다. 이는 RNN은 사용하지 않지만 여전히 인코더-디코더의 구조는 유지함을 보여줍니다.

     

    인코더와 디코더의 구조를 이해하기 전에 트랜스포머의 입력에 대해 이해해보겠습니다. 트랜스포머의 인코더와 디코더는 단순히 각 단어의 임베딩 벡터들을 입력받는 것이 아니라 임베딩 벡터에서 조정된 값을 입력받는데 이에 대해서 알아보기 위해 입력 부분을 확대해보겠습니다.

    4. 포지셔널 인코딩(Positional Encoding)

    트랜스포머의 내부를 이해하기 전에 우선 트랜스포머입력에 대해서 알아보겠습니다. RNN이 자연어 처리에서 유용했던 이유는 단어의 위치에 따라 단어를 순차적으로 입력 받아서 처리하는 RNN의 특성으로 인해 각 단어의 위치 정보(position information)를 가질 수 있다는 점에 있습니다. 

     

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

     

    위 그림은 입력으로 사용되는 임베딩 벡터들이 트랜스포머의 입력으로 사용되기 전에 포지셔널 인코딩 값이 더해지는 것을 보여줍니다. 임베딩 벡터가 인코더의 입력으로 사용되기 전 포지셔널 인코딩 값이 더해지는 과정을 시각화하면 아래와 같습니다.

     

    포지셔널 인코딩 값들은 어떤 값이기에 위치 정보를 반영해줄 수 있는 것일까요? 트랜스포머는 위치 정보를 가진 값을 만들기 위해 아래의 두 개의 함수를 사용합니다.

     

    사인 함수와 코사인 함수의 그래프를 상기해보면 요동치는 값의 형태를 생각해볼 수 있는데, 트랜스포머는 사인 함수와 코사인 함수의 값을 임베딩 벡터에 더해줌으로써 단어의 순서 정보를 더하여 줍니다. 그런데 위의 두 함수에는 pos, i, d_model 등의 생소한 변수들이 있습니다. 위의 함수를 이해하기 위해서는 위에서 본 임베딩 벡터와 포지셔널 인코딩의 덧셈은 사실 임베딩 벡터가 모여 만들어진 문장 행렬포지셔널 인코딩 행렬덧셈 연산을 통해 이루어진다는 점을 이해해야 합니다.

     

    pos는 입력 문장에서의 임베딩 벡터의 위치를 나타내며, i 벡터 내의 차원의 인덱스를 의미합니다. 위의 식에 따르면 임베딩 벡터 내의 각 차원의 인덱스가 짝수인 경우에는 사인 함수의 값을 사용하고 홀수인 경우에는 코사인 함수의 값을 사용합니다. 위의 수식에서 (pos, 2i)일 때는 사인 함수를 사용하고, (pos, 2i+1)일 때는 코사인 함수를 사용함을 주목합시다.

     

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

     

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

    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], :]

     

    50 X 128의 크기를 가지는 포지셔널 인코딩 행렬을 시각화하여 어떤 형태를 가지는지 확인해봅시다. 이는 입력 문장의 단어가 50개이면서, 각 단어가 128차원의 임베딩 벡터를 가질 때 사용할 수 있는 행렬입니다.

    # 문장의 길이 50, 벡터의 차원 128
    
    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()

    5. 어텐션(Attention)

    트랜스포머에서 사용되는 세 가지의 어텐션에 대해서 정리해봅시다.

     

    첫 번째 그림인 셀프 어텐션인코더에서 이루어지지만, 두 번째 그림인 셀프 어텐션과 세 번째 그림인 인코더-디코더 어텐션디코더에서 이루어집니다. 셀프 어텐션Query, Key, Value가 동일한 경우를 말합니다. 반면, 세 번째 그림 인코더-디코더 어텐션에서는 Query디코더의 벡터인 반면에 Key와 Value인코더의 벡터이므로 셀프 어텐션이라고 부르지 않습니다.

    • 주의할 점은 여기서 Query, Key 등이 같다는 것은 벡터의 값이 같다는 것이 아니라 벡터의 출처가 같다는 의미입니다.

    정리하면 다음과 같습니다.

     

    인코더의 셀프 어텐션 : Query = Key = Value

    디코더의 마스크드 셀프 어텐션 : Query = Key = Value

    디코더의 인코더-디코더 어텐션 : Query : 디코더 벡터 / Key = Value : 인코더 벡터

     

    위 그림은 트랜스포머의 아키텍처에서 세 가지 어텐션이 각각 어디에서 이루어지는지를 보여줍니다. 세 개의 어텐션에 추가적으로 '멀티 헤드'라는 이름이 붙어있습니다. 이는 트랜스포머가 어텐션을 병렬적으로 수행하는 방법을 의미합니다.

    6. 인코더(Encoder)

    인코더의 구조에 대해서 알아보겠습니다.

     

    트랜스포머는 하이퍼파라미터인 num_layers 개인코더 층을 쌓습니다. 논문에서는 총 6개의 인코더 층을 사용하였습니다. 인코더를 하나의 층이라는 개념으로 생각한다면, 하나의 인코더 층은 크게 총 2개의 서브층(sublayer)으로 나뉘어집니다. 셀프 어텐션피드 포워드 신경망입니다. 위의 그림에서는 멀티 헤드 셀프 어텐션과 포지션 와이즈 피드 포워드 신경망이라고 적혀있지만, 멀티 헤드 어텐션셀프 어텐션을 병렬적으로 사용하였다는 의미고, 포지션 와이즈 피드 포워드 신경망은 우리가 알고있는 일반적인 피드 포워드 신경망입니다. 우선 셀프 어텐션에 대해서 알아봅시다. 

    7. 인코더의 셀프 어텐션

    트랜스포머에서는 셀프 어텐션이라는 어텐션 기법이 등장하는데, 앞서 배웠던 어텐션과 무엇이 다른지 이해해보겠습니다.

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

    어텐션 함수는 주어진 '쿼리(Query)'에 대해 모든 '키(Key)'와의 유사도를 각각 구합니다. 그리고 이 유사도를 가중치로 하여 키와 맵핑되어있는 각 '값(Value)'에 반영합니다. 그리고 유사도가 반영된 '값(Value)'을 가중합하여 반환합니다. 

     

    여기까지는 앞서 배운 어텐션의 개념입니다. 그런데 어텐션 중에서는 셀프 어텐션(self-attention)이라는 것이 있습니다. 어텐션을 자기 자신에게 수행한다는 의미입니다. 앞서 배운 seq2seq에서 어텐션을 사용할 경우의 Q, K, V의 정의를 다시 생각해봅시다. 

     

    Q = Query : t 시점의 디코더 셀에서의 은닉 상태

    K = Keys : 모든 시점의 인코더 셀의 은닉 상태들

    V = Values : 모든 시점의 인코더 셀의 은닉 상태들

     

    t 시점은 계속 변화하면서 반복적으로 쿼리를 수행하므로 결국 전체 시점에 대해 일반화를 할 수 있습니다.

     

    Q = Querys : 모든 시점의 디코더 셀에서의 은닉 상태들

    K = Keys : 모든 시점의 인코더 셀의 은닉 상태들

    V = Values : 모든 시점의 인코더 셀의 은닉 상태들

     

    이처럼 기존에는 디코더 셀의 은닉 상태가 Q이고 인코더 셀의 은닉 상태가 K라는 점에서 Q와 K가 서로 다른 값을 가지고 있었습니다. 그런데 셀프 어텐션에서는 Q, K, V가 전부 동일합니다. 트랜스포머의 셀프 어텐션에서의 Q, K, V는 아래와 같습니다.

     

    Q : 입력 문장의 모든 단어 벡터들

    K : 입력 문장의 모든 단어 벡터들

    V : 입력 문장의 모든 단어 벡터들

     

    셀프 어텐션을 통해 얻을 수 있는 대표적 효과에 대해 이해해봅시다.

     

    위의 그림은 트랜스포머에 대한 구글 AI 블로그 포스트에서 가져왔습니다. 위의 문장을 번역하면 '그 동물은 길을 건너지 않았다. 왜냐하면 그것은 너무 피곤하였기 때문이다.'라는 의미가 됩니다. 그런데 여기서 그것(it)에 해당하는 것은 길(street)일까요? 동물(animal)일까요? 우리는 피곤한 주체가 동물이라는 것을 쉽게 알 수 있지만 기계는 그렇지 않습니다. 하지만 셀프 어텐션입력 문장 내의 단어들까리 유사도를 구함으로써 그것(it)이 동물(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로 하였습니다. 

     

    예를 들어 여기서 사용하는 예문 중 student라는 단어 벡터를 Q, K, V 벡터로 변환하는 과정을 보겠습니다.

     

    기존의 벡터로부터 더 작은 벡터는 가중치 행렬을 곱함으로써 완성됩니다. 각 가중치 행렬d_model X (d_model / num_heads) 의 크기를 가집니다. 이 가중치 행렬은 훈련 과정에서 학습됩니다. 즉, 논문과 같이 d_model=512이고 num_heads=8라면, 각 벡터에 3개의 서로 다른 가중치 행렬을 곱하여 64의 크기를 가지는 Q, K, V 벡터를 얻어냅니다. 

     

    모든 단어 벡터에 위와 같은 과정을 거치면 I, am, a, student는 각각의 Q, K, V 벡터를 얻습니다.

    3) 스케일드 닷-프로덕트 어텐션(Scaled dot-product Attention)

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

     

    그런데 앞서 어텐션 함수의 종류는 다양하다고 언급한 바 있습니다. 트랜스포머에서는 내적만을 사용하는 어텐션 함수 score(q, k) = q·k 가 아니라 여기에 특정값으로 나눠준 어텐션 함수인 score(q, k) = q·k / root(n) 를 사용합니다. 이러한 함수를 사용하는 어텐션을 '닷-프로덕트 어텐션(dot-product attention)'에서 값을 스케일링하는 것을 추가하였다고 하여 '스케일드 닷-프로덕트 어텐션(Scaled dot-product Attention)'이라고 합니다.

     

    우선 단어 I에 대한 Q벡터를 기준으로 설명하겠습니다. 지금부터 설명하는 과정은 am에 대한 Q벡터, a에 대한 Q벡터, student에 대한 Q벡터에도 동일한 과정을 거칩니다. 위 그림은 단어 I에 대한 Q벡터가 모든 K벡터에 대해서 어텐션 스코어를 구하는 것을 보여줍니다. 위의 128과 32는 임의로 가정한 수치입니다.

     

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

     

    이제 어텐션 스코어에 소프트맥스 함수를 사용하여 어텐션 분포(Attention Distribution)을 구하고, 각 V벡터와 가중합하여 어텐션 값(Attention Value)을 구합니다. 이를 단어 I에 대한 어텐션 값 또는 단어 I에 대한 컨텍스트 벡터(context vector)라고도 할 수 있습니다. am에 대한 Q벡터, a에 대한 Q벡터, student에 대한 Q벡터에도 모두 동일한 과정 반복하여 각각에 대한 어텐션 값을 구합니다. 그런데 굳이 이렇게 각 Q벡터마다 따로 연산할 필요가 있을까요?

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

    사실 각 단어에 대한 Q, K, V 벡터를 구하고 스케일드 닷-프로덕트 어텐션을 수행하는 위의 과정들은 벡터 연산이 아니라 행렬 연산을 사용하면 일괄 계산이 가능합니다. 우선, 각 단어 벡터마다 일일히 가중치 행렬을 곱하는 것이 아니라 문장 행렬에 가중치 행렬을 곱하여 Q행렬, K행렬, V행렬을 구합니다.

     

    행렬 연산을 통해 어텐션 스코어는 어떻게 구할 수 있을까요? 여기서 Q행렬전치한 K행렬과 곱해봅시다. 이렇게 하면 각 단어의 Q벡터와 K벡터의 내적이 각 행렬의 원소가 되는 행렬이 결과로 나옵니다.

     

    위의 그림의 결과 행렬 값에 전체적으로 root(d_k)를 나누어주면 이는 각 행과 열이 어텐션 스코어 값을 가지는 행렬이 됩니다. 예를 들어 I 행과 student 열의 값은 I의 Q벡터와 student의 K벡터의 어텐션 스코어 값입니다. 위 행렬을 어텐션 스코어 행렬이라고 합시다. 남은 것은 어텐션 분포를 구하고, 이를 사용하여 모든 단어에 대한 어텐션 값을 구하는 일입니다. 이는 어텐션 스코어 행렬에 소프트맥스 함수를 사용하고, V행렬을 곱하는 것으로 해결됩니다. 이렇게 하면 각 단어의 어텐션 값을 모두 가지는 어텐션 값 행렬이 결과로 나옵니다.

     

    위 그림은 행렬 연산을 통해 모든 값이 일괄 계산되는 과정을 보여줍니다. 해당 식은 실제 트랜스포머 논문에 기재된 아래의 수식과 정확하게 일치하는 식입니다.

    5) 스케일드 닷-프로덕트 어텐션 구현하기

    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

     

    Q 행렬과 전치한 K행렬을 곱하고, 소프트맥스 함수를 사용하여 어텐션 분포 행렬을 얻은 뒤에 V행렬과 곱합니다. 

     

    scaled_dot_product_attention 함수가 정상 작동하는지 테스트를 해보겠습니다. 우선 temp_q, temp_k, temp_v라는 임의의 Query, Key, Value 행렬을 만들고, 이를 scaled_dot_product_attention 함수에 입력으로 넣어 반환값을 출력해보겠습니다.

    # 임의의 Query, Key, Value인 Q, K, V 행렬 생성
    np.set_printoptions(suppress=True)
    temp_k = tf.constant([[10,0,0],
                          [0,10,0],
                          [0,0,10],
                          [0,0,10]], dtype=tf.float32)  # (4, 3)
    
    temp_v = tf.constant([[   1,0],
                          [  10,0],
                          [ 100,5],
                          [1000,6]], dtype=tf.float32)  # (4, 2)
    temp_q = tf.constant([[0, 10, 0]], dtype=tf.float32)  # (1, 3)

     

    여기서 주목할 점은 Query에 해당하는 temp_q의 값 [0, 10, 0]은 Key에 해당하는 temp_k의 두 번째 값 [0, 10, 0]과 일치한다는 점입니다. 그렇다면 어텐션 분포와 어텐션 값은 어떤 값이 나올까요?

    # 함수 실행
    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)

     

    Query는 4개의 Key 값 중 두 번째 값과 일치하므로 어텐션 분포는 [0, 1, 0, 0]의 값을 가지며 결과적으로 Value의 두 번째 값인 [10, 0]이 출력되는 것을 확인할 수 있습니다. 이번에는 Query의 값만 다른 값으로 바꾸고 함수를 실행해봅시다. 이번에 사용할 Query 값 [0, 0, 10]은 Key의 세 번째 값과, 네 번째 값 두 개의 값 모두와 일치하는 값 입니다.

    temp_q = tf.constant([[0, 0, 10]], 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.  0.  0.5 0.5]], shape=(1, 4), dtype=float32)
    tf.Tensor([[550.    5.5]], shape=(1, 2), dtype=float32)

     

    Query의 값은 Key의 세 번째 값과 네 번째 값 두 개의 값과 모두 유사하다는 의미에서 어텐션 분포는 [0, 0, 0.5, 0.5]의 값을 가집니다. 결과적으로 나오는 값 [550, 5.5]는 Value의 세번째 값 [100, 5]에 0.5를 곱한 값과 네번째 값 [1000, 6]에 0.5를 곱한 값의 원소별 합입니다. 이번에는 하나가 아닌 3개의 Query의 값을 함수의 입력으로 사용해보겠습니다.

    temp_q = tf.constant([[0, 0, 10], [0, 10, 0], [10, 10, 0]], dtype=tf.float32)  # (3, 3)
    temp_out, temp_attn = scaled_dot_product_attention(temp_q, temp_k, temp_v, None)
    print(temp_attn) # 어텐션 분포(어텐션 가중치의 나열)
    print(temp_out) # 어텐션 값
    
    tf.Tensor(
    [[0.  0.  0.5 0.5]
     [0.  1.  0.  0. ]
     [0.5 0.5 0.  0. ]], shape=(3, 4), dtype=float32)
    tf.Tensor(
    [[550.    5.5]
     [ 10.    0. ]
     [  5.5   0. ]], shape=(3, 2), dtype=float32)

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

    d_model의 차원을 가진 단어 벡터num_heads로 나눈 차원을 가지는 Q, K, V벡터로 바꾸고 어텐션을 수행하였습니다. 논문 기준으로 512 차원의 각 단어 벡터8로 나누어 64차원Q, K, V 벡터로 바꾸어서 어텐션을 수행한 셈인데, num_heads의 의미왜 차원을 축소시킨 벡터로 어텐션을 수행하는지 보겠습니다.

     

    트랜스포머 연구진은 한 번의 어텐션을 하는 것보다 여러 번의 어텐션을 병렬로 사용하는 것이 효과적이라고 판단하였습니다. 그래서 d_model의 차원을 num_heads개로 나누어 d_model / num_heads의 차원을 가지는 Q, K, V에 대해서 num_heads개의 병렬 어텐션을 수행합니다. 논문에서는 하이퍼파라미터인 num_heads의 값을 8로 지정하였고, 8개의 병렬 어텐션이 이루어집니다. 즉, 어텐션이 8개로 병렬로 이루어지는데, 이때 각 어텐션 값 행렬어텐션 헤드라고 부릅니다. 이때 가중치 행렬 W_Q, W_K, W_V의 값은 8개의 어텐션 헤드마다 전부 다릅니다.

     

    병렬 어텐션으로 얻을 수 있는 효과는 무엇일까요? 그리스로마신화에는 머리가 여러 개인 괴물 히드라나 케로베로스가 나옵니다. 이 괴물들의 특징은 머리가 여러 개이기 때문에 여러 시점에서 상대방을 볼 수 있다는 겁니다. 이렇게 되면 시각에서 놓치는 게 별로 없을테니까 이런 괴물들에게 기습을 하는 것이 굉장히 힘이 들겁니다. 멀티 헤드 어텐션도 똑같습니다. 어텐션을 병렬로 수행하여 다른 시각으로 정보들을 수집하겠다는 겁니다.

     

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

     

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

     

    어텐션 헤드를 모두 연결한 행렬은 또 다른 가중치 행렬 Wo을 곱하는데, 이렇게 나온 결과 행렬이 멀티-헤드 어텐션의 최종 결과물입니다. 위의 그림은 어텐션 헤드를 모두 연결한 행렬이 가중치 행렬 Wo와 곱해지는 과정을 보여줍니다. 이때 결과물인 멀티-헤드 어텐션 행렬은 인코더의 입력이었던 문장 행렬의 (seq_len, d_model) 크기와 동일합니다.

     

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

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

    멀티 헤드 어텐션에서는 크게 두 종류의 가중치 행렬이 나왔습니다. Q, K, V 행렬을 만들기 위한 가중치 행렬인 WQ, WK, WV 행렬과, 어텐션 헤드들을 연결(concatenate) 후에 곱하는 WO 행렬입니다. 가중치 행렬을 곱하는 것을 구현 상에서는 입력을 전결합층. 즉, 밀집층(Dense layer)을 지나게 합니다. Keras 코드 상으로 Dense()에 해당합니다.

     

    Dense(units)

     

    멀티 헤드 어텐션의 구현은 다섯 파트로 구성됩니다.

    1. WQ, WK, WV에 해당하는 d_model 크기의 밀집층(Dense layer)을 지나게 한다.
    2. 지정된 헤드 수(num_heads)만큼 나눈다(split).
    3. 스케일드 닷 프로덕트 어텐션.
    4. 나눠졌던 헤드들을 연결(concatenate)한다.
    5. WO에 해당하는 밀집층을 지나게 한다.
    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)
    
        # WO에 해당하는 밀집층 정의
        self.dense = tf.keras.layers.Dense(units=d_model)
    
      # num_heads 개수만큼 q, k, v를 split하는 함수
      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 : (batch_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)
        # (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)
        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) 패딩 마스크(Padding Mask)

    앞서 구현한 스케일드 닷 프로덕트 어텐션 함수 내부를 보면 mask라는 값을 인자로 받아서, 이 mask값에다가 -1e9라는 아주 작은 음수값한 후 어텐션 스코어 행렬에 더해줍니다. 이 연산의 정체는 무엇일까요?

     

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

    ... 중략 ...

    logits += (mask * -1e9) # 어텐션 스코어 행렬인 logits에 mask*-1e9 값을 더해주고 있다.

    ... 중략 ...

     

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

     

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

     

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

     

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

     

    패딩 마스크를 구현하는 방법은 입력된 정수 시퀀스에서 패딩 토큰의 인덱스인지, 아닌지를 판별하는 함수를 구현하는 것입니다. 아래의 함수는 정수 시퀀스에서 0인 경우에는 1로 변환하고, 그렇지 않은 경우에는 0으로 변환하는 함수입니다.

    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)

     

    위 벡터를 통해서 1의 값을 가진 위치의 열을 어텐션 스코어 행렬에서 마스킹하는 용도로 사용할 수 있습니다. 위 벡터를 스케일드 닷 프로덕트 어텐션의 인자로 전달하면, 스케일드 닷 프로덕트 어텐션에서는 위 벡터에다가 매우 작은 음수값인 -1e9를 곱하고, 이를 행렬에 더해주어 해당 열을 전부 마스킹합니다.

     

    두 번째 서브층인 포지션-와이즈 피드 포워드 신경망에 대해서 알아보겠습니다.

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

    포지션 와이즈 FFNN인코더디코더에서 공통적으로 가지는 서브층입니다. 포지션-와이즈 FFNN는 완전 연결 FFNN(Fully-connected FFNN)이라고 해석할 수 있습니다. 인공 신경망은 결국 벡터와 행렬 연산으로 표현될 수 있습니다. 아래는 포지션 와이즈 FFNN의 수식을 보여줍니다.

     

    위의 그림에서 좌측은 인코더의 입력을 벡터 단위로 봤을 때, 각 벡터들이 멀티 헤드 어텐션 층이라는 인코더 내 첫 번째 서브 층을 지나 FFNN을 통과하는 것을 보여줍니다. 이는 두 번째 서브층인 Position-wise FFNN을 의미합니다. 물론, 실제로는 그림의 우측과 같이 행렬로 연산되는데, 두 번째 서브층을 지난 인코더의 최종 출력은 여전히 인코더의 입력의 크기였던 (seq_len, d_model)의 크기가 보존되고 있습니다. 하나의 인코더 층을 지난 이 행렬은 다음 인코더 층으로 전달되고, 다음 층에서도 동일한 인코더 연산이 반복됩니다.

     

    이를 구현하면 다음과 같습니다.

     

    # 다음의 코드는 인코더와 디코더 내부에서 사용할 예정입니다.

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

    outputs = tf.keras.layers.Dense(units=d_model)(outputs)

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

     

    인코더의 두 개의 서브층에 대해서 이해하였다면 인코더에 대한 설명은 거의 끝났습니다! 트랜스포머에서는 이러한 두 개의 서브층을 가진 인코더에 추가적으로 사용하는 기법이 있는데, 바로 Add & Norm입니다. 더 정확히는 잔차 연결(residual connection)층 정규화(layer normalization)를 의미합니다. 

     

    위의 그림은 앞서 Position-wise FFNN를 설명할 때 사용한 그림에서 화살표와 Add & Norm(잔차 연결과 정규화 과정)을 추가한 그림입니다. 추가된 화살표들은 서브층 이전의 입력에서 시작되어 서브층의 출력 부분을 향하고 있는 것에 주목합시다. 추가된 화살표가 어떤 의미를 갖고 있는지는 잔차 연결과 층 정규화를 배우면 이해할 수 있습니다.

    1) 잔차 연결(Residual connection)

    잔차 연결(residual connection)의 의미를 이해하기 위해서 어떤 함수 H(x)에 대한 이야기를 해보겠습니다.

     

    H(x) = x + F(x)

     

    위 그림은 입력 x와 x에 대한 어떤 함수 F(x)의 값을 더한 함수 H(x)의 구조를 보여줍니다. 어떤 함수 F(x)가 트랜스포머에서는 서브층에 해당됩니다. 다시 말해 잔차 연결은 서브층의 입력과 출력을 더하는 것을 말합니다. 앞서 언급했듯이 트랜스포머에서 서브층의 입력과 출력은 동일한 차원을 갖고 있으므로, 서브층의 입력과 서브층의 출력은 덧셈 연산을 할 수 있습니다. 이것이 바로 위의 인코더 그림에서 각 화살표가 서브층의 입력에서 출력으로 향하도록 그려졌던 이유입니다. 잔차 연결은 컴퓨터 비전 분야에서 주로 사용되는 모델의 학습을 돕는 기법입니다.

     

    이를 식으로 표현하면 x + Sublayer(x) 입니다.

     

    가령, 서브층이 멀티 헤드 어텐션이었다면 잔차 연결 연산은 다음과 같습니다.

     

    H(x) = x + Multi-head Attention(x)

     

    위 그림은 멀티 헤드 어텐션의 입력과 멀티 헤드 어텐션의 결과가 더해지는 과정을 보여줍니다.

     

    관련 논문: https://arxiv.org/pdf/1512.03385.pdf

    2) 층 정규화(Layer Normalization)

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

     

    LN = LayerNorm(x + Sublayer(x))

     

    층 정규화를 하는 과정을 이해해봅시다. 층 정규화는 텐서의 마지막 차원에 대해서 평균과 분산을 구하고, 이를 가지고 어떤 수식을 통해 값을 정규화하여 학습을 돕습니다. 여기서 텐서의 마지막 차원이란 것은 트랜스포머에서는 d_model 차원을 의미합니다. 아래 그림은 d_model  차원의 방향을 화살표로 표현하였습니다.

     

    층 정규화를 위해서 우선, 화살표 방향으로 각각 평균 mu와 분산 sigma^2을 구합니다. 각 화살표 방향의 벡터를 x_i라고 명명해봅시다.

     

    층 정규화를 수행한 후에는 벡터 x_i는 ln_i라는 벡터로 정규화가 됩니다.

     

    관련 논문: https://arxiv.org/pdf/1607.06450.pdf

    10. 인코더 구현하기

    인코더를 구현한 코드는 다음과 같습니다. 인코더의 입력으로 들어가는 문장에는 패딩이 있을 수 있으므로, 어텐션 시 패딩 토큰을 제외하도록 패딩 마스크를 사용합니다. 이는 MultiHeadAttention 함수의 mask의 인자값으로 padding_mask가 사용되는 이유입니다. 인코더는 총 두 개의 서브층으로 이루어지는데, 멀티 헤드 어텐션피드 포워드 신경망입니다. 각 서브층 이후에는 드롭 아웃, 잔차 연결층 정규화가 수행됩니다.

    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)

     

    위 코드는 하나의 인코더 블록. 즉, 하나의 인코더 층을 구현하는 코드입니다. 트랜스포머는 num_layers 개 인코더 층을 사용하므로 이를 여러 번 쌓는 코드를 별도 구현할 필요가 있습니다.

    11. 인코더 쌓기

    지금까지 인코더 층의 내부 아키텍처에 대해서 이해해보았습니다. 이러한 인코더 층을 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)

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

     

    지금까지 인코더에 대해서 정리해보았습니다. 이렇게 구현된 인코더는 총 num_layers만큼의 층 연산을 순차적으로 한 후에 마지막 층의 인코더의 출력을 디코더에게 전달합니다. 인코더 연산이 끝났으면 디코더 연산이 시작되어 디코더 또한 num_layers만큼의 연산을 하는데, 이때마다 인코더가 보낸 출력을 각 디코더 층 연산에 사용합니다. 디코더에 대해서 이해해봅시다.

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

     

    위 그림과 같이 디코더도 인코더와 동일하게 임베딩 층과 포지셔널 인코딩을 거친 후의 문장 행렬이 입력됩니다. 트랜스포머 또한 seq2seq와 마찬가지로 교사 강요(Teacher Forcing)을 사용하여 훈련되므로 학습 과정에서 디코더는 번역할 문장에 해당되는 <sos> je suis étudiant의 문장 행렬을 한 번에 입력받습니다. 그리고 디코더는 이 문장 행렬로부터 각 시점의 단어를 예측하도록 훈련됩니다.

     

    여기서 문제가 있습니다. seq2seq의 디코더에 사용되는 RNN 계열의 신경망은 입력 단어를 매 시점마다 순차적으로 입력받으므로 다음 단어 예측에 현재 시점을 포함한 이전 시점에 입력된 단어들만 참고할 수 있습니다. 반면, 트랜스포머문장 행렬로 입력을 한 번에 받으므로 현재 시점의 단어를 예측하고자 할 때, 입력 문장 행렬로부터 미래 시점의 단어까지도 참고할 수 있는 현상이 발생합니다. 가령, suis를 예측해야 하는 시점이라고 해봅시다. RNN 계열의 seq2seq의 디코더라면 현재까지 디코더에 입력된 단어는 <sos>와 je뿐일 것입니다. 반면, 트랜스포머는 이미 문장 행렬로 <sos> je suis étudiant를 입력받았습니다.

     

    이를 위해 트랜스포머의 디코더에서는 현재 시점의 예측에서 현재 시점보다 미래에 있는 단어들을 참고하지 못하도록 룩-어헤드 마스크(look-ahead mask)를 도입했습니다. 직역하면 '미리보기에 대한 마스크'입니다.

     

    룩-어헤드 마스크(look-ahead mask)디코더첫 번째 서브층에서 이루어집니다. 디코더의 첫 번째 서브층인 멀티 헤드 셀프 어텐션 층은 인코더의 첫 번째 서브층인 멀티 헤드 셀프 어텐션 층과 동일한 연산을 수행합니다. 오직 다른 점은 어텐션 스코어 행렬에서 마스킹을 적용한다는 점만 다릅니다. 우선 다음과 같이 셀프 어텐션을 통해 어텐션 스코어 행렬을 얻습니다.

     

    이제 자기 자신보다 미래에 있는 단어들은 참고하지 못하도록 다음과 같이 마스킹합니다.

     

    마스킹된 후의 어텐션 스코어 행렬의 각 행을 보면 자기 자신과 그 이전 단어들만을 참고할 수 있음을 볼 수 있습니다. 그 외에는 근본적으로 셀프 어텐션이라는 점과, 멀티 헤드 어텐션을 수행한다는 점에서 인코더의 첫번째 서브층과 같습니다.

     

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

     

    # 스케일드 닷 프로덕트 어텐션 함수를 다시 복습해봅시다.

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

    ... 중략 ...

    logits += (mask * -1e9) # 어텐션 스코어 행렬인 logits에 mask*-1e9 값을 더해주고 있다.

    ... 중략 ...

     

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

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

    이때 룩-어헤드 마스크를 한다고해서 패딩 마스크가 불필요한 것이 아니므로 룩-어헤드 마스크는 패딩 마스크를 포함하도록 구현합니다. 룩-어헤드 마스크를 구현하는 방법은 패딩 마스크 때와 마찬가지로 마스킹을 하고자 하는 위치에는 1을, 마스킹을 하지 않는 위치에는 0을 리턴하도록 합니다.

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

     

    임의의 정수 시퀀스 입력을 넣어서 결과를 봅시다. 패딩 마스크를 테스트하기 위해 세번째 위치에 정수 0을 넣었습니다.

    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)

     

    룩-어헤드 마스크이므로 삼각형 모양의 마스킹이 형성되며 패딩 마스크가 포함된 세 번째 열도 마스킹됩니다.

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

    디코더의 두 번째 서브층에 대하여 이해해봅시다. 디코더의 두 번째 서브층은 멀티 헤드 어텐션을 수행한다는 점에서는 이전의 어텐션들(인코더와 디코더의 첫번째 서브층)과는 공통점이 있으나 이번에는 셀프 어텐션이 아닙니다.

     

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

     

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

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

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

     

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

     

    두 개의 화살표는 각각 Key와 Value를 의미하며 이는 인코더의 마지막 층에서 온 행렬로부터 얻습니다. 반면 Query디코더의 첫번째 서브층의 결과 행렬로부터 얻는다는 점이 다릅니다. Query가 디코더 행렬, Key가 인코더 행렬일 때, 어텐션 스코어 행렬을 구하는 과정은 다음과 같습니다.

     

    그 외에 멀티 헤드 어텐션을 수행하는 과정은 다른 어텐션들과 같습니다.

    15. 디코더 구현하기

    디코더는 총 세 개의 서브층으로 구성됩니다. 첫 번째와 두 번째 서브층 모두 멀티 헤드 어텐션이지만, 첫 번째 서브층은 mask의 인자값으로 look_ahead_mask가 들어가는 반면, 두 번째 서브층은 mask의 인자값으로 padding_mask가 들어갑니다. 이는 첫 번째 서브층은 마스크드 셀프 어텐션을 수행하기 때문입니다. 세 개의 서브층 모두 서브층 연산 후에는 드롭 아웃, 잔차 연결, 층 정규화가 수행되는 것을 확인할 수 있습니다.

    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)

     

    인코더와 마찬가지로 디코도도 num_layers개만큼 쌓는 코드가 필요합니다.

    16. 디코더 쌓기

    포지셔널 인코딩 후 디코더 층을 num_layers 개만큼 쌓는 코드입니다.

    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)

    17. 트랜스포머 구현하기

    지금까지 구현한 인코더와 디코더 함수를 조합하여 트랜스포머를 조립할 차례입니다. 인코더의 출력은 디코더에서 인코더-디코더 어텐션에서 사용되기 위해 디코더로 전달해줍니다. 그리고 디코더의 끝단에는 다중 클래스 분류 문제를 풀 수 있도록, vocab_size 만큼의 뉴런을 가지는 출력층을 추가해줍니다.

    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)

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

    트랜스포머의 하이퍼파라미터를 임의로 정하고 모델을 만들어봅시다. 현재 훈련 데이터가 존재하는 것은 아니지만, 단어 집합의 크기는 임의로 9,000으로 정합니다. 단어 집합의 크기로부터 룩업 테이블을 수행할 임베딩 테이블과 포지셔널 인코딩 행렬의 행의 크기를 결정할 수 있습니다.

     

    인코더와 디코더의 층의 갯수 num_layers는 4개, 인코더와 디코더의 포지션 와이즈 피드 포워드 신경망의 은닉층 d_ff은 128, 인코더와 디코더의 입, 출력의 차원 d_model은 128, 멀티-헤드 어텐션에서 병렬적으로 사용할 헤드의 수 num_heads는 4로 정했습니다. 128/4의 값인 32가 d_v의 값이 되겠습니다.

    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='small_transformer.png', show_shapes=True)

     

    19. 손실 함수 정의하기

    다중 클래스 분류 문제를 풀 예정이므로 크로스 엔트로피 함수를 손실 함수로 정의합니다.

    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_true, 0), tf.float32)
      loss = tf.multiply(loss, mask)
    
      return tf.reduce_mean(loss)

    20. 학습률

    학습률 스케줄러(Learning rate Scheduler)는 미리 학습 일정을 정해두고 그 일정에 따라 학습률이 조정되는 방법입니다. 트랜스포머의 경우 사용자가 정한 단계까지는 학습률을 증가시켰다가 단계에 이르면 학습률을 점차적으로 떨어트리는 방식을 사용합니다. 좀 더 구체적으로 봅시다. step_num(단계)이란 옵티마이저가 매개변수를 업데이트 하는 한 번의 진행 횟수를 의미합니다. 트랜스포머에서는 warmup_steps이라는 변수를 정하고 step_num이 warmup_steps보다 작을 경우는 학습률을 선형적으로 증가 시키고, step_num이 warmup_steps에 도달하게 되면 학습률을 step_num의 역제곱근에 따라서 감소시킵니다. 이를 식으로 표현하면 아래와 같습니다. warmup_steps의 값으로는 4,000을 사용하였습니다.

    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):
        step = tf.cast(step, tf.float32)
        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")

     

    여기서 구현한 트랜스포머 모델을 바탕으로 한국어 챗봇을 만들어보겠습니다.

Designed by Tistory.