Ssul's Blog
[Generative-AI]GPT-Transformer 개념잡기 본문
0. 들어가며...
GPT와 ChatGPT는 다른 아이.
- GPT는 우리가 상상할수 없을 정도의 많은 텍스트 데이터를 가지고 학습한 Pretrained-Model. 이 모델은 다음단어를 귀신같이 잘 예측함. I am a ____를 입력하면, boy:45%, girl:44%, .......처럼 모든 단어들의 등장확률을 예측. 그중 높은 %를 가지는 단어를 출력하는 모델
- ChatGPT는 GPT라는 Pretrained모델을 RLHF, PPO 등을 통 파인튜닝해서 만든 챗봇형 모델
- 이번 글에서는 GPT 모델을 만드는 개념을 알아볼 예정 = 다음 단어를 예측하는 모델
1. 데이터 정리
모델이 학습을 하기위해서는 라벨링된 데이터가 많이 필요한데, 텍스트 데이터를 하나하나 다 라벨링하면 비용과 시간이 엄청나게 발생함.
하지만, 다음단어를 예측하는 Task의 경우, 사람이 라벨링하기보다, 조금만 스킬을 발휘하면 자동으로 라벨링이 됨.
i am a boy라는 문장을 기준으로, [:-1], [1:]이렇게 적용하면,
[:-1] = i am a, [1:] = am a boy로 구성될수 있음.
이렇게 함으로써, 따로 사람의 라벨링 작업이 없이 input: "i am a", label:"am a boy"가 생성 가능하다.
위의 방법을 통해서, 무수히 많은 텍스트 데이터를 이렇게 변환하여, 데이터 셋을 구성 가능하게 되었다.
이렇게 학습하는 방법을 self-supervised learning이라고 하며, 이 방법을 통하여,
언어분야와 비전(이미지)분야에 Pretrained-Model이 탄생할수 있게 되었다.
(*이미지의 경우에도 고양이 사진 한장을 가지고, 뒤집고/90도돌리고/270도 돌리고/흑백으로 만들고 등 한장의 고양이 사진으로 수십장의 데이터로 만들수 있게 된다)
코드와 함께 Transformer모델에 입력한 데이터를 다듬어 보자(*코드출처 "실전 파이토치 딥러닝 프로젝트")
1-1. 데이터 preprocessing
# 구두점 앞에 공백을 채워서 별도의 '단어'로 취급합니다.
# 예: Hello, world! How are you? > Hello , world ! How are you ?
def pad_punctuation(s):
# 구두점 & 줄바꿈 문자의 앞뒤에 띄어쓰기 추가
s = re.sub(f"([{string.punctuation}, '\n'])", r" \1 ", s)
# 연속된 공백 1개로 만들기
s = re.sub(" +", " ", s)
return s
text_data = [pad_punctuation(x) for x in filtered_data]
- filtered_data는 텍스트 데이터(문장들)
- 각 문장마다, 구두점 앞에 공백을 채워서 별도의 단어로 취급
- 데이터마다, 상황에 필요한 preprocessing 진행
1-2. 토크나이징 & 단어사전 구성
# 텐서플로 데이터셋으로 변환하기
text_ds = (
tf.data.Dataset.from_tensor_slices(text_data)
.batch(BATCH_SIZE)
.shuffle(1000)
)
# TextVectorization 층 만들기 텍스트를 > 벡터로 (예: "안녕하세요" > [0,1,1,0])
# 소문자 바꾸고, 가장 자주등장하는 단어10,000개에 정수부여(나머지 무시), 시퀀스길이 제한
vectorize_layer = layers.TextVectorization(
standardize="lower",
max_tokens=VOCAB_SIZE,
output_mode="int",
output_sequence_length=MAX_LEN + 1,
)
# 훈련 세트에 이 층을 적용합니다.
vectorize_layer.adapt(text_ds)
# 단어의 토큰 리스트 생성. 자주 등장하는 순서대로 배치
vocab = vectorize_layer.get_vocabulary()
len(vocab) #10,000
- 언어모델 학습시, 단어사전이 필요함. 해당 사전을 기준으로, 문장에 등장하는 각 단어를 숫자로 변환하는 형태
- TextVectorization을 활용해서, 각 단어를 임베딩하는 layer만들기
- max_tokens=VOCAB_SIZE : VOCAB_SIZE를 10,000으로 설정, 데이터의 모든 문장에서 가장많이 등장하는 단어순으로 나열하고, 숫자를 부여
- output_sequence_length=MAX_LEN + 1 : 문장의 최대길이 80으로 설정했음
- vocab = vectorize_layer.get_vocabulary(): 데이터를 기반으로 단어사전 제작
- len(vocab) : 10,000개의 단어로 사전구성
1-3. input-label구성(self-supervised learning)
- 위에서 이야기 했던, self-supervised learning을 위한 데이터 input-label 생성하기
# 레시피 텍스트와 한 단어만큼 이동된 동일한 텍스트로 훈련 세트를 만듭니다.
# input: "i love surfing with my" > label: "love surfing with my dog "
def prepare_inputs(text):
# 차원추가, 텐서플로우에서 사용하려고
text = tf.expand_dims(text, -1)
# 문장 토큰화
tokenized_sentences = vectorize_layer(text)
# 맨 마지막 단어 제거
x = tokenized_sentences[:, :-1]
# 맨 앞단어 제거
y = tokenized_sentences[:, 1:]
return x, y
train_ds = text_ds.map(prepare_inputs)
- :-1 을 통해서, 제일 마지막 단어를 x에는 미포함
- 1: 을 통해서, 제일 앞단어를 y에는 미포함
- inputs=x, labels=y
# train_ds > [(입력,레이블),(입력,레이블),....] 4060개
# train_ds.take(1) > (입력, 레이블) 1개
# example_input_output = (입력, 레이블)
# example_input_output[0]은 입력 32개(1개배치)
# example_input_output[0][1]은 1개(첫번째배치의 첫번째 값)
# example_input_output[1]은 label 32개
example_input_output = train_ds.take(1).get_single_element()
- train_ds.take(1): train_ds한개를 가져오기 > 첫번째 배치(32개) 가져오기
- 한개의 배치는 (입력, 레이블)로 구성되어 있고, 각각 입력 32개, 레이블 32개임
- 입력의 1번째 데이터, 라벨의 1번째 데이터 출력해보기
- 입력은 7부터 시작. 라벨은 10부터 시작. 실제 데이터를 보면 label은 한개씩 앞으로 당겨진 모습 확인가능
(입력: i am a 라벨: am a boy)
2. Positional Encoding
- 위에서 생성된 문장을 바로 모델에 학습하면 좋겠지만, Transformer모델은 기존의 RNN처럼 순서대로 입력하지 않음. 문장에 있는 단어 전체를 임베딩하여 한꺼번에 입력
- 이럴경우, 순서정보를 알수가 없음
(*the boy look at the dog 와 the dog look at the boy 두문장이, 순서 정보가 없다면, 크게 구분을 할수 없음)
- 이는 당연히 효율적인 학습이 이뤄지지 않음
- 위치정보를 단어임베딩에 결합하여, 최종 임베딩으로 사용하자
- 1에서 진행한 단어임베딩에, 동일한 크기의 벡터를 구성하여, 위치정보를 임베딩해서 더해준다.
(*위의 예시는 discrete하지만, 실제 모델에서는 continous한 positional encoding 사용하기도 함)
- 단어를 임베딩, 위치정보를 인코딩 > 두개의 인코딩을 합쳐서 한개의 인코딩으로 > 최종적으로 Transformer에 입력하는 임베딩 완성
# 예: x {"The": 1, "cat": 2, "sat": 3, "on": 4, "the": 5, "mat": 6} > [1,2,3,4,5,6]
class TokenAndPositionEmbedding(layers.Layer):
def __init__(self, max_len, vocab_size, embed_dim):
super(TokenAndPositionEmbedding, self).__init__()
self.max_len = max_len
# 단어들의 차원
self.vocab_size = vocab_size
# 임베딩 차원(잠채차원)
self.embed_dim = embed_dim
# 단어 임베딩 이해하기: vocab_size*embed_dim의 매트릭스 생성
# 고유한 단어(정수)가 입력되면, 해당 단어에 해당하는 행을 가져오면, 그 행벡터가
# 해당 단어가 임베딩된 것
self.token_emb = layers.Embedding(
input_dim=vocab_size, output_dim=embed_dim
)
# positional encoding
self.pos_emb = layers.Embedding(input_dim=max_len, output_dim=embed_dim)
def call(self, x):
# 마지막 차원 > 시퀀스 길이 > 토큰 갯수
maxlen = tf.shape(x)[-1]
positions = tf.range(start=0, limit=maxlen, delta=1)
positions = self.pos_emb(positions)
x = self.token_emb(x)
# 문장(단어들) + 위치들 > 최종 임베딩
return x + positions
def get_config(self):
config = super().get_config()
config.update(
{
"max_len": self.max_len,
"vocab_size": self.vocab_size,
"embed_dim": self.embed_dim,
}
)
return config
- token_emb는 단어가 임베딩
- pos_emb는 위치가 임베딩
- x(token_emb) + positions(pos_emb)를 통해서 위치정보가 포함된 최종 임베딩 완성
3. Attention
- 본격적인 Transformer모델 부문. 모든 단어간 관심(관계)도를 학습하는 구조
- 위치정보를 포함한 최종 임베딩 입력(X1, X2)
- 해당 문장(단어들)을 입력받아서 q1, k1, v1 출력(*Wq, Wk, Wv는 학습가능한 매트릭스로 이 친구들을 잘 학습하는게 목적)
- 이렇게 도출된 q1, k1, v1으로 아래의 값을 계산한다
- q1과 모든 kn에 대해서 q1*kn을 구하여, 각각 sqrt(dk)로 나누고, softmax를 진행
- 해당 값을 다시 v1과 계산하여 최종적으로 z1을 도출(이는 해당 단어와 문장내의 모든 단어와의 상관관계(attention)을 고려한 느낌)
- 동일하게 모든 단어에 대해서 진행해서 z2, z3를 도출
- 각 단어에 따라, X1 > z1, X2 > z2, X3 > z3, ....이렇게 나올수 있음
- 또한 이 연산은 매트릭스 연산이 가능하여, 한꺼번에 계산이 가능
4. MSA(Multi-headed Self-Attention)
- 위의 작업을 한번만 하는 것이 아니라, 8개를 동시에 진행시켜서 학습가능한 Wq, Wk, Wv도 여러개를 학습
- 이 개념이 MSA임
- 이렇게 나온 Z를 concat하여, FFN를 통과하여 X > Z로 도출
class TransformerBlock(layers.Layer):
def __init__(self, num_heads, key_dim, embed_dim, ff_dim, dropout_rate=0.1):
super(TransformerBlock, self).__init__()
# 멀티해드 갯수 X에 대해서 몇개의 Z1,Z2..를 만들것인가?
self.num_heads = num_heads
# key의 임배딩 차원 Wk의 열차원
self.key_dim = key_dim
# Wq, Wv의 열차원
self.embed_dim = embed_dim
# 멀티해드를 통과시킬 신경망 노드갯수: Z1Z2Z3...
self.ff_dim = ff_dim
self.dropout_rate = dropout_rate
self.attn = layers.MultiHeadAttention(
num_heads, key_dim, output_shape=embed_dim
)
self.dropout_1 = layers.Dropout(self.dropout_rate)
self.ln_1 = layers.LayerNormalization(epsilon=1e-6)
# 멀티해드 입력 > ff_dim으로 출력
self.ffn_1 = layers.Dense(self.ff_dim, activation="relu")
# ff_dim입력 > embed_dim으로 출력
self.ffn_2 = layers.Dense(self.embed_dim)
self.dropout_2 = layers.Dropout(self.dropout_rate)
self.ln_2 = layers.LayerNormalization(epsilon=1e-6)
def call(self, inputs):
input_shape = tf.shape(inputs)
batch_size = input_shape[0]
seq_len = input_shape[1]
causal_mask = causal_attention_mask(
batch_size, seq_len, seq_len, tf.bool
)
attention_output, attention_scores = self.attn(
inputs,
inputs,
attention_mask=causal_mask,
return_attention_scores=True,
)
attention_output = self.dropout_1(attention_output)
out1 = self.ln_1(inputs + attention_output)
ffn_1 = self.ffn_1(out1)
ffn_2 = self.ffn_2(ffn_1)
ffn_output = self.dropout_2(ffn_2)
return (self.ln_2(out1 + ffn_output), attention_scores)
def get_config(self):
config = super().get_config()
config.update(
{
"key_dim": self.key_dim,
"embed_dim": self.embed_dim,
"num_heads": self.num_heads,
"ff_dim": self.ff_dim,
"dropout_rate": self.dropout_rate,
}
)
return config
# 문장이 배치단위로 입력
inputs = layers.Input(shape=(None,), dtype=tf.int32)
# 문장들의 사전을 만들고, 숫자로 임베딩, 위치임베딩 추가하여, 최종 입력 데이터
x = TokenAndPositionEmbedding(MAX_LEN, VOCAB_SIZE, EMBEDDING_DIM)(inputs)
# 단어 > X(숫자+위치정보) > TransformerBlock > Z
x, attention_scores = TransformerBlock(
N_HEADS, KEY_DIM, EMBEDDING_DIM, FEED_FORWARD_DIM
)(x)
# Z를 입력하여 단어사전 크기로 출력 > Softmax적용하여, 단어별 등장확률 출력
outputs = layers.Dense(VOCAB_SIZE, activation="softmax")(x)
gpt = models.Model(inputs=inputs, outputs=[outputs, attention_scores])
gpt.compile("adam", loss=[losses.SparseCategoricalCrossentropy(), None])
- inputs는 문장들
- x = TokenAndPositionEmbedding(MAX_LEN, VOCAB_SIZE, EMBEDDING_DIM)(inputs): 1번에서 설명했던 과정으로, 문장을 입력받아서 사전을 생성. 각 문장을 숫자로 임베딩. 여기에 위치정보추가하여, 최종적으로 Transformer에 입력한 임베딩 단어값 도출
- Transformer에 입력되고, 트랜스포머는 위에서 설명한것처럼 X를 입력받아, Z1,Z2,Z3...를 만들고, 그것을 최종적으로 Z로 출력
- 최종적으로 받은 Z를 마지막 Layer에 입력하면, 단어사전만큼의 결과값과, 확률값이 발생
- 이는 우리가 보유하고 있는 단어중, 다음에 어떤 단어를 출력하면 좋을지 확률값을 알려주는 것
5. 다음단어 출력하기
outputs = layers.Dense(VOCAB_SIZE, activation="softmax")(x)
- 최종적으로 발생한 Z를 Linear 신경망을 통과. 해당 신경망은 input은 Z이고, 출력은 단어사전의 갯수(위 코드에서는 10,000)
- Z를 입력하면 10,000의 출력
- 10,000개의 출력을 Softmax > 각 단어별 등장 확률이 출력
- 가장 높은 확률의 값을 출력 = 다음단어 예측
- 생성형의 경우, 가장 높은 확률을 출력하지 않고, 분포로 제공하여, 다양한 결과 출력가능
# TextGenerator 체크포인트 만들기
class TextGenerator(callbacks.Callback):
def __init__(self, index_to_word, top_k=10):
# 상위 k개의 단어중 선택
# vocab = index_to_word (사전) > 숫자: word
self.index_to_word = index_to_word
# word: 숫자
self.word_to_index = {
word: index for index, word in enumerate(index_to_word)
}
# 주어진 확률분포(probs)에서 단어 샘플링
# probs=y[0][-1] 입력문장의 마지막 단어 X입력의 결과 = 다음나올단어들의 확률
def sample_from(self, probs, temperature):
# 온도가 높으면, 확률분포가 평탄화되어 더 다양한 단어가 선택됨
# 온도가 낮으면, 확률분포가 극적으로 변화되어 확률이 높은단어가 자주 선택
probs = probs ** (1 / temperature)
# 조정된 확률을 정규화. 총합 1
# probs = [0.1, 0.2, 0.7], temp=0.5 > [0.15, 0.25, 0.6]
probs = probs / np.sum(probs)
# 선택가능 옵션 3 = len(probs), 선택확률 p => 옵션3선택확률 70%> 60%로
return np.random.choice(len(probs), p=probs), probs
#
def generate(self, start_prompt, max_tokens, temperature):
# 시장문장을 쪼개서, 각 단어를 사전의 숫자로 전환, 없는 단어이면 1
start_tokens = [
self.word_to_index.get(x, 1) for x in start_prompt.split()
]
sample_token = None
info = []
# 텍스트 생성루프
while len(start_tokens) < max_tokens and sample_token != 0:
# 입력 문장(단어들)을 숫자로 임베딩
x = np.array([start_tokens])
# Transformer를 통해서 Z > 사전모든단어들의 %
# y = (배치, 시퀀스 길이, 어휘사전 크기) > y[0][-1]=첫벗째 배치의, 입력문장의 마지막 X입력에 따른 결과 = [.....사전 단어들의 확률]
y, att = self.model.predict(x, verbose=0)
sample_token, probs = self.sample_from(y[0][-1], temperature)
info.append(
{
# 입력 문장
"prompt": start_prompt,
# 입력문장의 마지막 단어 입력시, 다음단어 확률분포
"word_probs": probs,
# att는 [배치, 헤드수, 쿼리 시퀀스 길이, 키 시퀀스 길이]
# -1: 입력시퀀스의 마지막 요소
# 입력 시퀀스의 마지막 토큰이 입력 시퀀스의 다른 토큰들에 얼마나 주목하는지를 나타내는 8x10의 행렬을 반환
"atts": att[0, :, -1, :],
}
)
# 다음단어를 기존 문장에 더해주기
start_tokens.append(sample_token)
# 입력문장 업데이트
start_prompt = start_prompt + " " + self.index_to_word[sample_token]
# 루프 위로가서 max_token까지 또는 출력이 없을때까지 문장 생성
# 완성된 문장 출력
print(f"\n생성된 텍스트:\n{start_prompt}\n")
return info
def on_epoch_end(self, epoch, logs=None):
self.generate("wine review", max_tokens=80, temperature=1.0)
- 위 클래스를 이해하면, 프롬프트 엔지니어링에서 나왔던, 다양성을 위해 top_k를 조정하고, temperature를 조정하는 개념을 이해 할 수 있다
- __init__는 사전을 입력받아서, 문장(단어들)을 입력받아 숫자로 바꾸고, 숫자를 입력받아 단어로 바꾸는 역할을 하는 함수가 있음
- sample_from함수는 다음 등장할 단어들의 확률 벡터와 온도를 입력받아서, 위에 주석처럼 작동하여, 확률에 따라 다음단어를 숫자를 반환하는 기능을 한다
- generate함수는 문장을 입력받아 > 숫자로 임베딩후 > 트랜스포머를 통과하여 > 다음단어 토큰을 주고, info에는 출력되는 단어가 집중하는 이전단어 정보를 저장
- 루프를 max_tokens 또는 sample_token이 0이 되면 출력을 멈춤
- 한마디로 일정한 문장을 입력하면 다음단어를 계속 출력하면서 다음문장을 완성하는 형태.
def print_probs(info, vocab, top_k=5):
for i in info:
highlighted_text = []
for word, att_score in zip(
i["prompt"].split(), np.mean(i["atts"], axis=0)
):
highlighted_text.append(
'<span style="background-color:rgba(135,206,250,'
+ str(att_score / max(np.mean(i["atts"], axis=0)))
+ ');">'
+ word
+ "</span>"
)
highlighted_text = " ".join(highlighted_text)
display(HTML(highlighted_text))
word_probs = i["word_probs"]
p_sorted = np.sort(word_probs)[::-1][:top_k]
i_sorted = np.argsort(word_probs)[::-1][:top_k]
for p, i in zip(p_sorted, i_sorted):
print(f"{vocab[i]}: \t{np.round(100*p,2)}%")
print("--------\n")
info = text_generator.generate(
"wine review : us", max_tokens=80, temperature=1.0
)
info = text_generator.generate(
"wine review : italy", max_tokens=80, temperature=0.5
)
info = text_generator.generate(
"wine review : germany", max_tokens=80, temperature=0.5
)
print_probs(info, vocab)
- 완성된 모델에 문장을 입력하고, 다음단어들을 예측
- 첫번째는 다음단어 출력하고, 그단어가 집중하고 있는 것(germany가 집중하고 있는 것 진하게 표시)
- 두번째는 다음에 등장할 단어 확률 상위5개( ':'가 germany다음에 올 확률 100%)
- 세번째는 가장 높은 : 출력되고, 다음 확률 상위5개
- 여기서 top_k가 등장
- 그리고, 확률분포로 선택하기 때문에 꼭 1위가 선택되지 않은 경우도 발생(GPT처럼)
6. 기타 요소들
6-1. 배치노말
- 배치에 들어있는 데이터를 평균과 분산으로 정규화 시켜서 학습을 안정화 시키기(예: 총 32개의 배치데이터들간의 평균/분산으로 정규화 진행)
- 배치 정규화는 한 배치에 포함된 모든 샘플들에 대해 평균과 분산을 계산하여 정규화를 진행합니다. 이는 각 배치 내에서 데이터의 내부 분포를 일정하게 유지하여, 모델이 더 효과적으로 학습할 수 있도록 돕습니다. 이 방법은 특히 큰 배치 크기에서 잘 작동하며, 깊은 네트워크에서의 학습을 가속화하는 데 유용
(참고: layer normalization
- 한개의 데이터샘플(이미지1장, 문장1개) 내에서 정규화 하는것
- (예)자연어 처리 작업에서 한 '데이터 샘플'은 하나의 문장 또는 텍스트 조각입니다. 여기서 층 정규화는 해당 문장 내의 모든 단어(또는 토큰) 임베딩에 대해 평균과 분산을 계산하고, 각 단어의 임베딩을 정규화합니다.)
6-2. 코잘 마스킹
- gpt를 생각할때, 다음단어를 예측하는 모델, Transformer계산시, 아직 입력되지 않은 단어는 입력되면 안됨. 하지만 학습시 다음단어 데이터를 가지고 있음. 이를 학습시 모르게 하기 위하여 causal masking을 진행함
# n_dest는 목적의 시퀀스 길이, n_src는 소스의 시퀀스 길이
# 0: 마스킹x(사용), 1: 마스킹O(사용못함)
# [0,1,1,1]: I만고려, [0,0,1,1]: I love, [0,0,0,1]: I love you고려..
def causal_attention_mask(batch_size, n_dest, n_src, dtype):
i = tf.range(n_dest)[:, None]
j = tf.range(n_src)
m = i >= j - n_src + n_dest
mask = tf.cast(m, dtype)
mask = tf.reshape(mask, [1, n_dest, n_src])
mult = tf.concat(
[tf.expand_dims(batch_size, -1), tf.constant([1, 1], dtype=tf.int32)], 0
)
return tf.tile(mask, mult)
np.transpose(causal_attention_mask(1, 10, 10, dtype=tf.int32)[0])
- 0은 마스킹이 안되어서 보이는 형태
- 1은 마스킹이 되어서 안보이는 형태
- i like a dog라는 문장이 있어도, 처음에는 i만 보이고, 나머지는 다 마스킹
- 다음 학습시 i like까지 보이는 형태
7. Transformer전체 연결
- 논문상에서는 트랜스포머가 1개가 아니라, 여러개로 구성. 또한 인코더/디코더로 구성
- 위에서 작업한 것은 디코더 부분만 활용한 GPT구조. 아래 이미지상에서 오른쪽만 사용
- 4번의 TransformerBlock에서 add, 정규화 등 포함되어 있음
(위에는 논문에 있는 이미지이며, 다양한 변형 트랜스포머들이 등장하고 있다)
더 많이 기록해야 할 내용이 있지만, 개념잡기의 글이니, 이정도 깊이로 정리 끝
'AI & ML > 개념잡기' 카테고리의 다른 글
Langchain Prompt template 정리 (0) | 2024.05.14 |
---|---|
LLM 모델 사이즈, 경량화/양자화(quantization) (1) | 2024.03.14 |
[Generative-AI] GAN(Generative Adversarial Networks-생성적 적대 신경망)이해 및 구현 (2) | 2024.01.05 |
[Generative-AI] 오토인코더(AE)에서 VAE까지 (1) | 2024.01.04 |
[Generative-AI] 디퓨전(확산) 모델 개념잡기 (0) | 2023.12.27 |