본문 바로가기
AI/기술

GPT (Generative Pre-trained Transformer) 학습시키기

by ai.forme 2021. 2. 12.
반응형

들어가며

오늘은 Generative Pre-trained Transformer (GPT) 의 구조에 대해 자세히 글을 써보려고 한다. 아래의 링크들은 참고하면 좋을만한 사이트들이다. 특히 유튜브 영상은 ETRI 임준호 박사님이 GPT-3에 대해 메타적인 논의를 잘 설명해주시며 많은 인사이트를 주신다. 여기서 메타적인 논의란 "왜 GPT 의 성능이 좋을까?", "GPT에서 Query, Key, Value 의 뜻은 무엇인가?" 와 같은 이야기들이다. 반면, 오늘의 글은 GPT 에 대한 메타적인 논의보다는 그 구조를 코드단에서 자세히 설명해보고자 한다. 필자가 메타적인 얘기를 지양하는 이유는, 아직 본인이 확실한 의견을 가지지 못했으며 실제 GPT의 구조를 자세히 이해하는 것엔 큰 도움이 되지 않는다고 생각하기 때문이다.

 

GPT-1 Paper  GPT-2 Paper  GPT-3 Paper

GPT-3 Projects

GPT-3 설명 글 (영어)

GPT-3 설명 영상 (한글)

 

포스팅에 사용된 대부분 그림의 출처는 여기 이다. 


GPT (Generative Pre-trained Transformer) 란?

GPT 는 그 이름에서 알 수 있듯이, Generative (생성하는) Pre-trained (사전 학습된) Transformer (트랜스포머) 이다. Transformer 란 2017년에 구글에서 제시된 기계 번역을 위한 언어 모델이다. 트랜스포머에 대한 사전 지식이 없다면 여기를 읽고 오는 것을 추천드리는 바이다.

 

 

 

Generative

 

위는 Generative 라는 단어를 직관적으로 설해준다. 즉, 한 단어 (정확히는 토큰) 가 들어오면 다음에 올 적절한 토큰을 생성하는 언어 모델이라는 것이다. 예를들어, "오늘" 이라는 단어가 GPT 모델에 Input으로 들어가면, GPT는 "날씨가"  같은 뒤에 올 적절한 단어를 Output으로 내보내는 것이다. 

 

또한, GPT는 별도의 추가적인 데이터를 사용하여 학습을 하지 않고 기존에 사전 학습된 지식만을 가지고 감정 분석, SQuAD 와 같은 Task에서 뛰어난 성능을 보여주었다. 따라서 Pre-trained , 즉 말뭉치 (Corpus) 만을 가지고 사전 학습된 것이다. GPT 는 다음에 올 토큰을 예측하는 방식으로 학습한다. 즉, 앞서 봤듯이 GPT의 Output은 다음에 올 적절한 토큰이었고, Label 은 원래 와야 할 토큰인 것이다. 이는 뒤에서 좀 더 자세히 다룰 것이다.

 

 

 

GPT Model Architecture

 

GPT의 모델 구조가 Transformer 의 Decoder 구조와 매우 유사하기 때문에 Transformer 라는 수식이 이름에 붙었다. 일전에 친구가 "Transformer라는 Encoder 와 Decoder로 이루어진 것은 직관적으로 이해가 되는데, 어떻게 BERT와 GPT 언어 모델은 Encoder 혹은 Decoder로만 이루어질 수 있는가?" 라는 질문을 하였다. 이에 대한 필자는 Transforemer 와 BERT, GPT의 역할이 다르기 때문이라고 생각한다. Transformer 은 그 목적이 번역을 잘하는 것이었기에 영어를 벡터로 Encode 하고, 다시 벡터를 프랑스어로 Decode 하는 과정이 필요했다. 하지만, BERT 와 GPT 는 그 목적이 언어 모델을 사전 학습 시키는 것이었다. 그렇기 때문에 BERT 와 GPT는 Encoding 과 Decoding 의 과정이 필요하지 않은 것이다. 개인적으로 이러한 오해를 불러일으키기 때문에 GPT의 구조는 Transformer의 Decoder 구조다! 라고 설명하는 것을 좋아하지 않는다. 그저 GPT는 Attention 을 사용한 언어 모델인 것이다. Decoder 라는 오해의 늪에 빠져서는 안 된다.

 

 

 

Few Shot

GPT가 세간의 많은 관심을 받았던 이유는 그 뛰어난 성능도 있지만, Fine Tuning을 하지 않는 것에도 있다. 기존의 NLP에서 특정 문제를 해결하는 방법은 특정 문제에 대한 데이터를 많이 학습시키는 것이었다. 하물며 SOTA를 싹 갈아치운 BERT 도 특정 Task에 대한 다량의 데이터를 넣어 모델을 추가 학습시킨다. 이를 Fine Tuning 이라고 하는데, 언어 모델을 사전 학습시키는 Pre Training 과 대비되는 말이다.

 

NSMC (Naver Sentiment Movie Corpus) 문제인 감정분석을 예시로 들어보겠다.

 

 

 

NSMC 예시

 

NSMC 는 한국어 영화 리뷰 데이터셋으로 Naver Movie 댓글을 수집한 데이터셋이다. 평점 1-4는 부정(0)으로, 9-10은 긍정(1)으로 라벨링 되어 Train 15만 문장, Test 5만 문장으로 구성되어있다. 자 이제 우리의 언어 모델이 해야 할 일은 새로운 영화 리뷰가 긍정인지 부정인지 판단하는 것이다. 위의 예시에서 "아이디어가 아주 좋다 재밌다" 라는 리뷰가 긍정인지 부정인지 가려내야 되는 것이다. 기존의 언어 모델이 위와 같은 문제를 풀던 방식은 긍정, 부정 라벨 (1, 0) 이 붙은 다량의 데이터를 추가적으로 학습하는 것이었다. NSMC 의 경우 해당 태스크를 풀기 위해 15만 개의 영화 리뷰들을 추가적으로 학습한다. 그러면 이제 모델이 잘 학습된 경우, "아이디어가 아주 좋다 재밌다"라는 리뷰가 들어오면 이를 긍정, 즉 1이라고 모델이 예측할 수 있는 것이다. 이를 Fine Tuning 이라고 한다. 이 방법은 성능 향상에 크게 도움되지만, 매 문제마다 라벨링 된 데이터가 지나치게 많이 필요하며 모델이 범용적이지 못하다는 단점이 있다.

 

반면 GPT 는 다량의 데이터를 추가적으로 학습하는 Fine Tuning의 과정을 거치지 않는다. 대신, 모델에게 참고할만한 예제를 주는 방식으로 문제를 해결한다. 아래의 예시를 참고해보자.

 

 

Few Shot Input

GPT-3에게 위와 같이 예시를 주는 것이다. Few Shots 방법에서 모델은 예시 문제를 보게 되지만, 가중치 업데이트는 일어나지 않는다. 즉, Few Shot 이란 N 개의 예제를 주고 추론하려는 example의 결과를 완성하도록 하는 접근법이다. 이는 모델이 추가적으로 학습하는 과정이 없기 때문에 추가 데이터가 필요 없으며 범용적이다. 즉, 다른 여러 문제들을 동시에 풀 수 있다. 위와 같이 Input을 GPT-3에게 주면은 아래와 같은 결과를 얻을 수 있다. 

 

 

Few Shot Output

 

"아이디어가 아주 좋다 재밌다"에 대한 답으로 1을 내놓았다. 이는 GPT-3가 위의 예시들을 보고 긍정 리뷰는 1, 부정 리뷰는 0으로 라벨링 하는 문제구나라는 것을 인지하고, 결과를 내놓은 것이다. 물론 그저 패턴을 보고 따라한 것일 수도 있다. Output의 길이를 조절할 수 있는데, 길게 했더니 다른 예제들을 GPT-3가 직접 붙였으며 맨 아래 문장은 잘못 예측한 것을 알 수 있다. GPT-3 의 데이터에는 아주 소량의 한국어만 포함되었기 때문에 사실 한국어를 생성하는 것만으로도 대단해 보인다.

 

Few Shot 외에도 One Shot, Zero Shot 이 있다. One Shot 은 Few Shot과 동일하지만 오직 하나의 예시만을 주는 경우이다. Zero Shot은 문제에 대한 예시는 주지 않고, 문제를 설명하는 자연어 문구만을 준다. 이는 문제를 설명하기만 하면 되기 때문에 굉장히 편리하지만 동시에 굉장히 어려운 과제이다. 아마 몇몇 과제는 사람조차도 지시사항만으로 푸는 데에 어려움을 느낄 것이다. 각각 아래의 예시들을 참고하자.

 

 

 

One Shot Input

 

One Shot Output

 

 

 

Zero Shot Input

 

Zero Shot Output

 

Zero Shot의 경우 양심적으로 문제의 설명 문구는 영어로 주었다. 사실 본 실험을 하면서 살짝 소름 돋았는데, 문제의 모호함을 GPT-3 가 읽은 동시에 올바른 결과를 내놓았다. 0을 부정으로, 1을 긍정으로 분석하라는 설명문에서 꼭 답을 0과 1로 내놓으라는 문구는 없었다. 그래서 1에 가까울수록 긍정, 0에 가까울수록 부정인 실수로 결과를 내놓았다. 동시에 그 결과가 아주 올바르다. 한국어를 제대로 학습하지도 않았는데도 멋진 성능을 보인다. GPT-3, 세간의 관심을 받을만하다.

 

 

 

크기

GPT-3 Models

 

GPT-3 는 모델의 크기로도 꽤 유명한데, 세간에 알려진 GPT-3 는 GPT-3의 175B 버전이다. 이는 즉 모델 Weight의 변수가 175 Billion, 1750억 개 임을 나타낸다. 인간의 뇌에 뉴런이 1000억 개 정도라고 알고 있는데, 굉장히 큰 수치임을 알 수 있다. 

2024.3.12 띠리링구님 제보로 수정)
인간의 뇌는 '뉴런'이 1000억개인것입니다. 인공신경망의 Weight 파라미터는 '뉴런'에 해당하는 것이 아닙니다. 히든 레이어의 각 퍼셉트론들이 '뉴런'에 대항하는 것이지요. weight는 비교하자면 실제 뇌에서는 뉴런과 뉴런의 연결인 시냅스에 해당하는 것이고 인간의 뇌에는 뉴런들이 수조개의 시냅스를 구성한다고 알려져있습니다. 1750억개로는 택도 없지요.

 

 

Size of Language Models

 

언어 모델들의 크기를 비교해 놓은 그래프인데, 맨 위에 우뚝 솟은 것이 GPT-3 이다. 다른 모델들은 거의 보이지도 않을 정도로 GPT-3 에 비하면 그 크기가 작은 것을 알 수 있다.

 

 

 


GPT 뜯어보기

GPT를 이제 본격적으로 곱씹어보려고 한다. GPT의 모델 구조와 자연어가 어떻게 GPT에서 학습되는지를 중점으로 보고자 한다. PyTorch 를 기반으로 설명에 코드를 추가할 것인데, 해당 코드는 OpenAI 의 공식 코드가 아니며 필자가 설명을 위해 작성한 코드임을 미리 밝힌다.

 

 

 

Tokenize

너무나 당연스럽게도 자연어는 그 자체로 모델의 Input이 될 수 없으며 Float으로 바꾸는 과정이 필요하다. 물론, 자연어를 Input으로 받는 모델을 만들 수는 있겠지만 결국 내부적으로 Float으로 바꾸는 과정을 거칠 것이다. 그 첫 단계가 Tokenize 이자 Encoding으로, 각 단어에게 구별되는 고유한 Index를 붙여주는 작업이다. 각 사람에게 고유한 이름이 있듯이, (동명이인이 있듯이 다의어도 있고) 각 단어에게도 고유한 이름이 있어야 모델이 이를 구별할 수 있다. 

 

 

 

 

가장 직관적으로 드는 생각은 위와 같이 각 단어에게 철자를 기반으로 숫자를 하나씩 부여하는 것이다. 이렇게 각 단어는 고유한 Index가 생기게 되었다. 고유한 Index가 있는 하나의 단어를 Token 이라고 하며, 위 같은 방법은 한 단어를 기준으로 토큰을 만들었기 때문에 Word Tokenize 라고 한다. 하지만 세상에 존재하는 모든 단어들의 숫자는 무한대로 발산할 것이기 때문에 모든 단어들에 다른 Index를 부여할 수는 없다. 따라서 모델이 처리할 수 있는 (모델이 아는) 단어를 정해주는데, 이를 Vocab이라고 하는 한다. 위의 그림에서 왼쪽 Token들의 리스트가 Vocab이 될 것이다. BERT의 경우 Vocab의 길이가 32200, GPT의 경우 Vocab의 길이가 50257이었다. 그리고 데이터에 Vocab에 존재하지 않는 단어가 나오게 되면, 이를 [UNK] 토큰으로 처리하게 된다. 

 

 

 

Word Tokenizer 예시

 

위는 Work Tokenizer의 예시이다. "내가 나를 알지 못하는 것이 나의 가장 큰 문제이다 娗"를 Tokenize 하였다. "娗" 같은 경우에는 Vocab에 없는 단어이기 때문에 Tokenizer가 [UNK] 토큰으로 처리한 것을 볼 수 있다. 이렇게 Word Tokenizer 을 사용해서 자연어에 단어마다 고유한 Index를 부여하면 끝난 걸까? 위의 예시에서도 단번에 알 수 있듯이, 위와 같은 Word Tokenize 방식은 아래와 같은 문제점들이 남아있다.

 

 

단어의 변형에 대응할 수 없다

한국어에서 용언의 경우 어간과 어미가 존재하는데, ("웃다"에서 "웃-"는 어간이고, "-다"는 어미에 해당한다) 어미는 가변적이다. 즉, "웃다"는 "웃는다", "웃고있다" 등의 비슷하지만 다른 단어들로 변할 수 있다. 하지만 Word Tokenizer는 이렇게 단어가 조금이라도 변하면 모두 다른 토큰으로 처리하기 때문에 단어의 변형에 대응하지 못한다.

 

OOV (Out Of Vocabulary) 에 취약하다

Word Tokenizer는 단어의 변형에 대응하지 못하여 Vocab에 담을 수 있는 단어가 상당히 제한적이다. 따라서 Word Tokenizer는 많은 수의 단어들을 [UNK] 으로 처리할 것이고, 이는 Vocab에 없는 단어 (Out Of Vocabulary)에 취약하다는 것을 나타낸다.

 

비슷한 단어들의 연관성이 사라진다

위의 예시에서 알 수 있듯이 "나를', '나의" 와 같은 단어들은 명사 "나"와 조사가 결합된 형태이지만, 다른 토큰으로 처리되었다. 따라서 Word Tokenizer는 단어의 조합에서 비롯되는 의미의 유사성을 살리지 못한다.

 

 

 

이것의 대안으로 등장한 것이 바로 Subword Tokenize 이며, 현대의 언어 모델 (BERT, GPT) 모두 Subword Tokenize 방식을 사용한다. BERT의 경우 Subword Tokenize 중 Wordpiece 방식을, GPT-3의 경우 Subword Tokenize 중 Sentencepiece 방식을 사용한다. Subword Tokenize의 핵심은 한 단어를 여러 토큰으로 분리한다는 것이다. 

 

Subword Tokenizer

위의 예시에서 알 수 있듯이, 한 단어가 여러 개의 토큰으로 쪼개진 것을 알 수 있다. 

 

나를 > _나 를
나의 > _나 의
못하는 > _못 하는

문제이다 > _문제 이다

 

특히 Subword Tokenizer는 "나" 가 동일한 토큰인 "_나"로 처리되면서 Word Tokenizer의 단어의 변형에 대응하지 못하며, 비슷한 단어들의 연관성이 사라지는 문제를 해결하고 있다. 동시에 한 단어를 분리하여 Tokenize 함으로써 OOV (Out Of Vocabulary) 에 더욱 잘 대응할 수 있게 되는 장점을 가지고 있다. 이러한 이유로 현대 대부분의 언어 모델들은 Tokenize 방식으로 Subword Tokenize 방식을 채택하였다.

 

 

Sentencepiece Subword Tokenizer

위는 실제로 GPT-3 에서 사용하였던 Sentencepiece 방식을 사용하여 동일한 문장을 Tokenize 해보았다. 대부분의 단어들이 모두 이미 Vocab에 있기 때문에 한 토큰으로 처리된 것을 알 수 있다. 이는 위 문장에 포함되어있는 단어들이 아주 일반적인, 자주 등장하는 단어들이기 때문이다. 반면, "문제이다" 는 "_문제" 와 "이다" 로 두 개의 토큰으로 분리된 것도 볼 수 있다. 또한 의문의 한자어는 Vocab에 없었는지, [UNK] 으로 처리되었다.

 

Subword Tokenize에 대한 더욱 자세한 설명은 여기를 참고하자.

 

 

 

Input

 

GPT 에서 위 사진의 네모 친 Input 이 무엇인지, 어떻게 만들어지는지 알아보자!

 

 

 

1. Input Ids & Label Ids

GPT 의 Pre-training 은 다음에 올 토큰을 예측하는 방식으로 학습되어 별다른 라벨이 필요 없는 Unsupervised Learning 이기 때문에 Input 의 Token Ids 와 Label은 매우 유사하다. GPT 에는 Window Size라는 변수가 있는데, 한 Input의 토큰 개수를 의미한다. 글로는 이해가 쉽지 않아 아래의 사진과 코드를 참고해보자.

 

Input Ids & Label Ids

 

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
import h5py
import random
from torch.utils.data import Dataset
 
 
class GPTDataset(Dataset):
    def __init__(self, dataset_path: str, seq_len: int, window_size: int, rng: random.Random):
        self.dataset = h5py.File(dataset_path, 'r')['token_ids']
        self.seq_len = seq_len
        self.window_size = window_size
        self.rng = rng
        self.size = len(self.dataset)
 
    def __len__(self):
        return self.size
 
    def __getitem__(self, idx):
        start_point = self.rng.randint(0self.size)
        end_point = min(start_point + (window_size + 1), self.size)
        window = self.dataset[start_point: end_point]  # Windowing
 
        input_ids = window[:-1]
        label_ids = window[1:]
        return input_ids, label_ids
 
cs

 

- 18 :     0 ~ 데이터셋 길이 사이의 임의의 시작점을 고른다.

- 19 :    한 Sample의 끝점을 고른다

- 20 :    Window size + 1 개의 연속된 토큰들을 슬라이싱한다.

- 21 :    첫 번째 토큰부터 1024개 = Input Ids

- 22 :    두 번째 토큰부터 1024개 = Label Ids

 

 

이런 Sample들을 Batchsize 만큼 Fectch 해와서 한 Batch를 만든다. 모델에 실제로 Input으로 들어가게 되는 Input Ids 와 Label Ids는 ( batch_size, seq_length )의 Shape을 가진다. 모델에는 Input Ids 가 들어가고 출력된 Logits 과 Label Ids와 비교하게 된다. 여기서 seq_length 는 window size 와 동일하다. 앞으로 seq_length, 즉 한 Input에서 토큰들의 개수라는 변수를 사용할 것이다.

 

 

 

2. Future Mask (Attention Mask)

Input Ids 말고 모델에 Input으로 들어가는 것으로 Mask가 있다. Mask 란 특정 토큰을 Attention 에서 가리기 위해서 사용되는데, 이것이 어떻게 Attention 을 가리는지는 추후에 설명할 것이다. GPT에서는 다음에 올 토큰을 예측할 때 이전의 토큰들과의 Attention만을 사용해서 예측하기 때문에, 뒤에 오는 토큰들과의 Attention을 가리는 Future Mask를 사용한다.

 

Future Mask

 

Future Mask는 각각의 토큰이 다른 토큰들에 대해서 Masking의 여부를 나타내는 2차원 행렬이다. 따라서 행과 열 모두 seq_length 길이를 갖으며, Masking을 할 때는 True, 하지 않는 경우에는 False 값을 가진다. GPT 는 자기 이후의 토큰들에 대해서 Masking 하여 Attention 값을 참조하지 않으므로 위와 같이 대각선을 기준으로 양분되는 구조로 만들어진다. 이 구조는 GPT의 특성이므로 모든 Sample에 대해서 동일하다. 아래의 코드도 참고해보자.

 
1
2
3
4
5
6
7
8
9
10
11
def create_future_mask(x: torch.Tensor, offset: int = 0-> torch.Tensor:
 
    seq_len = x.size(-1)  # seq_length
 
    # Create shifted upper triangular matrix.
    future = torch.ones((seq_len, seq_len), dtype=torch.bool, device=x.device)
    future = future.triu(1)
 
    future_mask = future.view((1,) * (x.ndim - 1+ future.size())
    return future_mask.expand(x.shape + future_mask.shape[-1:])  # (b, s, s)
 
cs

 

 

위 코드를 실행시키면 아래와 같은 결과를 얻을 수 있다. 아래의 예시에서는 batch_size를 2로, seq_length 를 4로 설정하였다.

 

Attention Mask

 

 

 

(Learanble) Embedding

 

GPT 에서 위 사진의 네모 친 Embedding 이 무엇인지, 어떻게 작동하는지 알아보자!

 

 

 

1. Token Embedding

 

 

Tokenize & Encoding 을 통해 자연어를 서로 구별되는 토큰들의 집합으로 변환하였다. 이는 각 토큰이 고유한 값을 가지기 때문에 각 토큰이 위의 사진처럼 One-hot Vector 로 표현된 것과 동일하다. 각 토큰이 벡터 형태로 표현되어 모델의 Input으로 들어갈 수는 있지만, 현재는 토큰 사이의 유사성을 나타낼 수 없다. 그 이유는 One hot vector 는 각 벡터들 사이의 거리가 모두 동일하기 때문이다. 또한, 모든 토큰들을 One hot vector 로 표현하게 되면 Sparse Representation, 즉 고차원에 적은 벡터가 있는 상태가 된다. 이렇게 되면 모델의 수렴성이 낮아진다. 위와 같은 이유들로 토큰들의 차원을 축소하는 과정을 거치는데 이것이 Embedding이다.

 

Embedding

 

위 사진은 9차원이었던 예시를 3차원으로 축소한 Embedding의 예시이다.

 

 

 

 

영화 리뷰로 토큰 간의 유사성을 살펴본 이미지이다. 비슷한 역할이었던 인물들의 토큰이 근접하여 위치하는 것을 볼 수 있다. 해당 이미지에 대한 더욱 자세한 설명은 여기를 참고하자.

 

 

GPT 에서 Embedding은 Learnable Embedding Layer 이다. 즉, Embedding의 과정 또한 모델에서 하나의 Layer이며, 학습의 대상이라는 것이다. 아래의 코드를 보자.

 

1
2
3
4
import torch.nn as nn
 
token_embeddings = nn.Embedding(vocab_size, hidden_size)
 
cs

Pytorch에서 torch.nn.Embedding  모듈로 쉽게 GPT Embedding 을 구현할 수 있다. torch.nn.Embedding 모듈은 단순하게 하나의 표라고 생각하면 된다. 한 토큰의 Id에 대응하는 벡터가 존재하는 것이다. 코드에서 hidden size 란 임베딩 차원을 의미한다. 위의 표에서 0이 들어오면 들어오면 해당하는 [0.3, 1.3, 0.`] 벡터를 내놓고, 6이 들어오면 해당하는 [0.1, -0.2, 5.3] 벡터를 내놓는 식이다. 다만, 표 안의 벡터들은 학습의 대상이기에 모델이 학습하는 동안 그 값이 변하게 된다. 실제로 작동 방식은 아래와 같이 행렬의 곱셈으로 작용할 것이다. 그렇기 때문에 학습이 될 수 있는 것이다. (단순히 룩업테이블이면 Gradient 를 구할 방법이 없다)

 

 

torch.nn.Embedding 에서 0을 Encoding 하면 이뤄지는 계산

 

 

torch.nn.Embedding

 

위의 사진은 실제로 torch.nn.Embedding 을 사용하여 0을 Embedding 해보고, 이것이 nn.torch.Embedding Layer의 Weight 행렬과의 곱셈인지 확인해본 것이다. 위의 상황과 동일하게 Token Id 는 9차원 (스칼라 값이지만 One-hot vector로 바꾸면 9차원임을 알 수 있다), Embedding 차원은 3차원으로 설정하였다예상과 동일하게 0 을 Embedding 한 결과와 0의 One-hot Vector 인 [1, 0, 0, 0, 0, 0, 0 ,0, 0] 와 Embedding Layer Weight의 행렬곱이 동일한 것을 알 수 있다.

 

 

다시 GPT 로 돌아오면, Vocab이 N개면 Token Id가 0~N-1 의 정수가 될 수 있기 때문에 GPT 에서 Token Id의 차원은 Vocab 개수이다. 임베딩 차원, 즉 hidden size 는 GPT 에서도 그 크기에 따라 정하기 나름인데, GPT-3 의 경우 1600을 사용하였다. 따라서 Input Ids (Token Id의 배열) 이 Token Embedding 과정을 거치면 (batch_size, seq_length, hidden_size)의 Shape을 가지게 된다.

 

 

 

2. Position Embedding

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch.nn as nn
    
position_embeddings = nn.Embedding(seq_len, hidden_size)
 
def position_embed(input_ids, position_embeddings):
 
    seq_length = input_ids.size(1)
    position_ids = torch.arange(seq_length, dtype=torch.long, device=input_ids.device)
    position_ids = position_ids.unsqueeze(0).expand_as(input_ids)
 
   position_embedding_vector = position_embeddings(position_ids)
 
    return position_embedding_vector
cs

GPT 의 Input에는 해당 토큰의 위치가 어디인지에 대한 정보가 없다. 하지만 자연어에서 토큰의 위치는 굉장히 중요한 지표이므로, 해당 정보를 추가해주어야 한다. Position Embedding 또한 torch.nn.Embedding 모듈을 사용하기에 Learnable Embedding Layer 이다. 다만 여기서는 Input의 차원이 토큰들의 개수 (seq_length) 이다. 그 이유는 각 토큰들의 위치는 0, 1, ... , seq_len - 1 번째 이기 때문이다. 

 

 

Position Embedding

 

위의 예시는 batch_size 를 2로, seq_len을 4로, hidden_size를 8로 설정한 예시이다. Position Embedding 을 거치면 각 토큰의 위치가 (0 ~ seq_len 의 정수값) hidden_size 차원의 벡터로 바뀌는 것을 알 수 있다. 따라서 Position Embedding 과정을 거치면 Token Embedding의 결과와 동일한 (batch_size, seq_length, hidden_size)의 Shape을 가진 벡터를 얻을 수 있다.

 

 

 

3. Dropout

1
2
3
4
import torch.nn as nn
 
drop = nn.Dropout(0.1)
final_embed_vector = drop(token_embed_vector + position_embed_vector)
cs

이후 Token Embedding Vector와 Position Embedding Vector을 더하여 Dropout을 거치면 GPT 의 Masked Attention Layer 에 들어갈 Input 이 완성된다. 여기서 Dropout 을 적용하게 되면 위의 코드에서는 0.1의 확률로 Input tensor의 각 값이 0으로 바뀌게 된다. 아래의 예시를 보자.

 

 

 

torch.nn.Dropout

10개의 값 중에 하나가 0으로 바뀌었다. 위의 예시처럼 정확히 확률과 개수가 비례하는 것은 아니다. 하나의 값이 0으로 바뀐 대신 나머지 생존한 값들이 1/1-p 배 된 것을 알 수 있다. 

 


 

Final Embedding Vector = Dropout(Token Embedding + Position Embedding)

 


 

 

 

GPT Layer

GPT 에서 한 Layer가 어떻게 구성되고 작동하는지 알아보자!

 

 

 

1. Layer Normalization

 

Layer Normalization

 

1
2
3
4
5
import torch.nn as nn
 
layer_norm = nn.LayerNorm(hidden_size, eps=1e-5)
ln_vector = layer_norm(final_embed_vector)

cs
torch.nn.LayerNorm

 

Layer Normalize 란 한 Sample 에 있는 값들을 정규화하는 것이다. 위의 사진을 보면 서로 다른 Sample 끼리는 영향을 미치지 않는 것을 볼 수 있다. GPT 에서는 여러번의 Layer Normalization 과정을 거치게 된다. GPT 에서 Layer Normalization 은 벡터의 값들을 정규화하여 training시간을 줄이는 것이 목표이다. Layer Normalization 에 대한 더욱 자세한 내용은 여기를 참고하자.

 

 

 

2. Masked Multi-Head Self Attention

 

GPT 에는 Masked Multi-Head Self Attention 이 사용된다. 이는 Masked, 자신 토큰 이후의 Attention 을 가린다는 것과 Attention Head의 2개 이상이라는 것을 내포한다. 아래의 코드와 예시를 보자.

 

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
import math
import torch
import torch.nn as nn
from typing import Optional, Tuple
 
 
class AttentionLayer(nn.Module):
 
    def __init__(self, heads: int, dims: int, dropout: float = 0.1):
        super().__init__()
        self.attn = MultiHeadAttention(heads, dropout)
        self.proj_q = nn.Linear(dims, dims)  # (h, h)
        self.proj_k = nn.Linear(dims, dims)  # (h, h)
        self.proj_v = nn.Linear(dims, dims)  # (h, h)
        self.linear = nn.Linear(dims, dims)  # (h, h)
 
    def forward(self,
                q: torch.Tensor,
                k: torch.Tensor,
                v: torch.Tensor,
                past: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
                mask: Optional[torch.Tensor] = None
                ) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
        
        # Get Query, Key, Value / Shape : (batch_size, seq_length, hidden_size)
        q, k, v = self.proj_q(q), self.proj_k(k), self.proj_v(v)
 
        s = self.attn(q, k, v, mask)
        x = self.linear(s)  # (h, h)
 
        return x, (k, v)
 
cs

 

26번째 줄에서 들어온 Input Tensor로 Query, Key, Value를 만든다. 이때 Query, Key, Value를 만드는 방법은 hidden_size 에서 hidden_size 로의 Linear Layer을 거치는 것이고, 실제로 GPT 가 Pre-training 될 때 학습되는 것은 바로 이 Query, Key, Value 를 만드는 Layer 이다. 들어온 Input Tensor 은 Embedding 과정을 거친 Input Ids 이고 Shape은 (batch_size, seq_length, hidden_size) 이다. 따라서 Query, Key, Value 의 Shape은 모두 동일하게 (batch_size, seq_length, hidden_size) 이다. 즉, 각 Sample의 각 토큰이 모두 Query, Key, Value 값을 가지는 것이다. 

 

Making Query, Key, Value

위와 같이 한 Layer에서 각 토큰들이 Linear Layer을 거쳐서 Query, Key, Value 값을 가지는 것이다. 이후 위 코드의 28번째 줄에서 Multi-Head Attention 을 거치는 것을 볼 수 있다. Multi-Head Attention 은 한 개의 Query, Key, Value를 여러개로 쪼개는 것인데, 이는 One-Head Attention 보다 더욱 다양한 Attention을 가질 수 있다는 장점을 가지고 있다.

 

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
57
58
59
60
61
62
63
64
65
import math
import torch
import torch.nn as nn
from typing import Optional, Tuple
 
 
class MultiHeadAttention(object):
 
    def __init__(self, heads: int, dropout: float = 0.1):
        super().__init__(dropout)
        self.heads = heads
        self.dropout = nn.Dropout(dropout)
 
    def forward(self,
                q: torch.Tensor,
                k: torch.Tensor,
                v: torch.Tensor,
                mask: Optional[torch.Tensor] = None-> torch.Tensor:
 
        # Split the tensors to multi-heads.
        # Shape : (batch_size, seq_length, head_num, one_head_len)
        q = q.view(q.size()[:-1+ (self.heads, q.size(-1// self.heads))
        k = k.view(k.size()[:-1+ (self.heads, k.size(-1// self.heads))
        v = v.view(v.size()[:-1+ (self.heads, v.size(-1// self.heads))
        
        # Shape : (batch_size, head_num, seq_length, one_head_len)
        q = q.transpose(-3-2)
        k = k.transpose(-3-2)
        v = v.transpose(-3-2)
 
        if mask is not None:
            mask = mask.unsqueeze(-3)  # (batch_size, 1, seq_length, seq_length)
            mask = mask.type_as(x)  # (batch_size, head_num, seq_length, seq_length)
 
 
        # Calculate multi-headed attentions and merge them into one.
        k = k.transpose(-2-1)  # (batch_size, head_num, one_head_len, seq_length)
        num_heads_sqrt = math.sqrt(k.size(-1))  # sqrt(head_num)
        
        # Matrix Multiple Query AND Keys / Divide with sqrt(head_num)
        # Shape : (batch_size, head_num, seq_length, seq_length)
        score = torch.matmul(q, k) / num_heads_sqrt
        
        # Add Large Negative where the mask is true
        # Shape : (batch_size, head_num, seq_length, seq_length)
        masked_score = score + mask * score.new_tensor(-1e6)
        
        # Softmax
        # float that large negative number has added becomes to zero
        masked_score  = masked_score.softmax(-1)
        masked_score  = self.dropout(masked_score)
        
        # Matrix Multiple score and value
        # Shape : (batch_size, head_num, seq_length, one_head_len)
        attention_score = torch.matmul(x, v)
        
        # (batch_size, seq_length, head_num, one_head_len)
        attention_score = attention_score.transpose(-3-2).contiguous()
 
        # (batch_size, seq_length, hidden_size)
        original_shape = q.size()[:-3+ (q.size(-2), v.size(-1* self.heads)
        attention_score = attention_score.view(original_shape)
 
        return attention_score
 
cs

위의 코드의 22-24 줄에서 들어온 Query, Key, Value를 여러개로 분할하는 것을 볼 수 있다. hidden_size 차원의 벡터였던 각 토큰의 Query, Key, Value를 각각 one_head_len 차원의 벡터인 head_num 개로 쪼개었다. 한 쌍의 Query, Key, Value를 Head라고 하기에 GPT의 Attention은 한 토큰에 Query, Key, Value 쌍이 여러 개인 Multi-Head Attention 이라고 하는 것이다.

 

Split Query, Key, Value
Multi-Head Self Attention

위와 같은 과정을 각 토큰의 Query, Key, Value 가 거쳐 여러 개의 작은 Query, Key, Value로 분할된다.

 

 

 

이후 각 토큰의 Query는 해당 Sample의 모든 Key들과 Matrix Multiplication을 하고, 이 결과를 Score라고 한다. 각 토큰의 Query Shape이 (batch_size, head_num, seq_length, one_head_len) 이고, Key Shape 이 (batch_size, head_num, one_head_len, seq_length) 이기 때문에 Matrix Multiplication 을 거치면 Score의 Shape은 (batch_size, head_num, seq_length, seq_length) 가 된다. 이를 해석하면, 각 Sample의 (batch_size) 각 Head의 (head_num) 각 토큰의 (seq_length) 해당 Sample의 다른 모든 토큰들에 대한 Attention 값이 된다. 이후 이 값을 Head 개수의 제곱근으로 나누어 주는데, 이는 더 빠른 수렴을 위해 가하는 작업이라고 생각한다.

 

 

 

Attention Mask

이제 Attention에 마스킹을 할 차례다. GPT는 자기 이후의 토큰에 대한 Attention 값을 사용하지 않는다. 따라서 위 Score은 각 토큰이 모든 다른 토큰들에 대한 Attention 값을 가지고 있기 때문에 수정해주어야 한다. 위 사진은 오랜만에 보는 GPT의 Attention Mask이다.  코드의 31-33 줄에서 기존의 Mask를 Head 개수만큼 늘려준 뒤,  True (1) 인 부분에 아주 큰 음수를 곱하는 것을 알 수 있다. 큰 음수를 곱한 후 Attention Mask를 Score에 더한 뒤 Softmax 를 취하게 되면 Mask에서 True 였던 부분이 0이 되게 된다. 이는 앞서 말했던 자기 이후의 토큰들에 대한 Attention 값을 삭제하는 효과를 낸다. 아래의 그림과 예시를 참고해보자.

 

 

 

 

 

자기 이전의 토큰들의 Attention 이 모두 0으로 바뀐 것을 볼 수 있다. 이후 Dropout을 적용한다.

 

 

 

이후 각 토큰의 masked_score 과 value를 Matrix Multiplication을 해준다. 이는 한 토큰을 기준으로 보면, 다른 토큰들과의 Attention Score과 Value를 곱한 것으로 해석할 수 있다. Value는 그 토큰의 Embedding Vector의 일부다. 각 토큰의 Masekd Score 과 다른 토큰들의 Value를 곱한 Shape 은 (batch_size, head_num, seq_length, one_head_len) 되고 이를 Attention Score 이라고 한다

 

 

모든 Head 들의 Attention Score을 다시 합쳐준 뒤, hidden_size 에서 hidden_size 로의 Linear Layer을 거치게 되면 GPT의 Masked Multi-Head Self Attention 이 종료된다. 

 

 

 

3. Add  (Skip Connection) & Layer Normalization

 

 

1번의 Layer Normalization 을 거치기 전에, GPT Layer에 처음 들어온 Vector 를 Masked Multi-Head Self Attention을 거친 Vector와 더한다. 이를 Skip Connection 이라고 한다. 이후, Layer Normalization 과정을 거친다. 이전에 설명했던 과정이므로 간단히 넘어가도록 하겠다.

 

 

 

4. Feed Forward Neural Network

 

3번의 과정을 거쳐 Normalize된 Vector의 차원을 키웠다 줄이는 Feed Forward Neural Network를 거친다. 이는 Embedding Vector 차원의 병목을 줄여주는 효과가 있다고 한다. GPT에서는 hidden_size를 4배 키웠다 줄이는데, 이는 실험적인 결과가 아닌 기존의 Transformer 에서 그랬기 때문이라고 한다.

 

1
2
3
4
5
6
7
8
9
10
11
import torch.nn as nn
 
class PositionWiseFeedForward(nn.Sequential):
 
    def __init__(self, dims: int, rate: int = 4, dropout: float = 0.1):
        super().__init__(
            nn.Linear(dims, dims * rate),  # (b, s, 4 * h)
            gelu(),  # Activation Function
            nn.Dropout(dropout),
            nn.Linear(dims * rate, dims))  # (b, s, h)

cs

코드는 위와 같이 구현 가능하며, GPT 에서는 Activation Function 으로 GeLU 를 사용하는데 이는 아래와 같이 생겼다.

반면, GeLU 를 사용한 것이 아니라, Swish 를 사용했을 때 더 좋은 결과가 있었다는 논문을 찾아서 첨부해본다.

 

 

Swish 는 PyTorch에서 아래와 같이 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch.nn as nn
 
 
class Swish(nn.Module):
 
    def __init__(self):
        super().__init__()
        self.sigmoid = nn.Sigmoid()
 
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = x * self.sigmoid(x)
 
        return x
 
cs

 

 

 

5. Add (Skip Connection)

 

3번과 비슷하게 Masked Multi-Head Self Attention 을 거쳐서 나온 벡터와 FFNN 을 거쳐서 나온 벡터를 더해준다. Layer Normalization은 다음 GPT Layer의 첫 부분에서 하게 된다. GPT-3 의 경우 위와 같은 Layer을 48번 거친다.

 

 

 

Predicting Next Token

 

연속된 GPT Layer 을 거쳐서 나온 Vector를 사용하여 어떻게 다음 토큰을 예측하는지 알아보자!

 

 

우선 마지막 GPT Layer의 Output 벡터는 Skip Connection을 Add하고 아직 Layer Normalization을 거치지 않은 상태이다. 따라서 마지막으로 Layer Normalization 을 거친다. Output 벡터의 Shape은 (batch_size, seq_length, hidden_size) 이다. 이제 이 벡터를 사용하여 다음 토큰을 예측하기 위해서는 모델에 있는 각 토큰이 될 확률을 구해야 된다. 이는 Output Embedding 과 Softmax를 사용한다.

 

 

 

1. Output Embedding

Token Embedding 은 0 ~ vocab_size - 1 의 정수를 hidden_size의 벡터로 바꾸는 과정이었다. Output Embedding 은 hidden_size의 벡터를 vocab_size 의 logit으로 바꾸는 과정이다. 이때, Token Embedding의 Weight 가 사용된다. 즉, Token embedding 과 Output Embedding은 동일한 Weight를 사용하는 Linear Layer이다. 아래의 예시는 batch_size 가 2, seq_length 가 4, vocab_size 가 3, hidden_size 가 6일 때의 예시이다.

 

 

Output Embedding 은 hidden_size 에서 vocab_size로의 Linear Layer 이지만 그 Weight가 Token Embedding 과 동일하게 설정하였다. 둘의 Weight 는 모두 (vocab_size, hidden_size) 의 2차원 행렬이다.

 

 

 

Input_Ids 를 Token Embedding Layer로 Embedding 한 뒤, Output Embedding으로 logit 로 바꾸는 과정이다.

 

 

 

 

실제 Output Embedding 에서 Logit을 계산하는 과정은 위와 같을 것이다. 이를 해석하면, GPT Layer 들을 거쳐서 나온 Vector이 각 토큰에게 "너는 다음에 올 확률이 어느정도 되니?"라고물어보는 것이라고 이해하면 될 것 같다.

 

 

 

2. Softmax

Output Embedding 을 거쳐서 나온 Logit에 Softmax를 취해 다음에 각 토큰이 올 확률을 구하게 된다. 이후 해당 값과 Label Id의 CrossEntropy를 사용하여 loss를 구한다.

 

 

 

정리하며

본 포스팅은 GPT에 대해 자세히 다루어보았다. 상당히 길고 집약적인 포스팅이었지만 스스로도 GPT에 대해 상세히 정리할 수 있었던 좋은 기회였던 것 같다. 이에 대한 내용으로 학교 자연어 처리 수업에서 세미나를 해볼까 한다. 교수님에게 연락을 드려봐야겠다. 그것의 가치를 떠나 나의 배움을 누군가에게 공유하는 것은 나에게도 상대에게도 유익한 일이라고 생각한다. 교수님께서 긍정적으로 검토해주셨으면 좋겠다. 자연어를 공부하다 보니 언젠가는 나도 혁신적인 자연어 모델을 만들어보고 싶다.

 

 

 

 

Reference

1. http://jalammar.github.io/illustrated-gpt2/

 

2. www.groundai.com/project/megatron-lm-training-multi-billion-parameter-language-models-using-gpu-model-parallelism/1#S2.F2

 

3. arxiv.org/pdf/1710.05941.pdf

 

4. github.com/affjljoo3581/GPT2

반응형