(참고) 해당 게시글은 홍정모 선생님의 "홍정모 연구소" 강의 노트 - 01_pretraining.ipynb 을 바탕으로 소개합니다.
HongLabLLM/01_pretraining.ipynb at main · HongLabInc/HongLabLLM
Contribute to HongLabInc/HongLabLLM development by creating an account on GitHub.
github.com
요즈음 GPT와 제미나이 그리고 Grok 등등 정말 수많은 AI를 사용하고 있다. 텍스트의 경우 수많은 글들을 학습하여 대화처럼 묻고 답하거나 문장을 그럴듯하게 완성 시켜 글자를 생성하는 것을 LLM[Large Language Model]이라고 한다. 이 글에서는 이러한 LLM을 직접 학습시키고 돌려볼 수 있게 소개한다. 학습으로 사용하는 텍스트에 따라 답변의 문맥이 달라지기 때문에 굉장히 중요하며 이번에는 해리포터의 소설을 학습데이터로 사용하여 해리포터의 유명한 대사인 "도비는 자유에요"를 생성해보면서 학습 데이터의 중요성을 직접 알아보도록 하자.

준비 - 해리포터 텍스트 다운로드
Harry Potter Books
All 7 harry potter books in txt file format
www.kaggle.com
이번 LLM 학습에서는 해리포터의 02 Harry Potter and the Chamber of Secrets 만을 사용하여 진행할 것이다. 따라서 Kaggle이라는 유명한 AI 모델 대회 사이트로 들어가서 다운을 받아보자. 링크를 누르고 다음과 같이 다운로드 버튼을 눌러준다.

다운을 받기 위해서는 Kaggle 계정이 필요한데 구글 계정으로 간단하게 가입할 수 있다.

코드 익스텐션 - 주피터 노트북
코드를 작성하고 실행하기 위해서 주피터 노트북 (juypter notebook)을 사용해서 진행할 것이다. 파이썬과 주피터 노트북을 설치하고 나서 Kaggle에서 받은 해리포터 파일은 업로드 해주면 된다.

해리포터 파일을 클릭해보면 다음과 같이 잘 정리되어 텍스트의 형태로 있는 것을 볼 수 있다.

진행개요
코드 진행은 다음과 같이 나눌 수 있다 :
1.데이터 전처리와 토큰화
2.데이터 로더 정의
3.모델 구현
4.훈련
5.결과 확인
사전에 설치해야하는 프레임워크와 라이브러리는 다음과 같이 설치해야한다.
pip install torch, tiktoken, matplotlib
데이터 전처리와 토큰화
인터넷에 있는 글이나 텍스트를 가져오면 텍스트에 문제가 있거나 맞춤법이 틀린 텍스트를 올바르게 수정해야한다. 그렇지만 이번 해리포터 텍스트는 처리가 되어있기 때문에 간단하고 넘어갈 수 있다.
import os, torch
import re
def clean_text(filename):
with open(filename, 'r', encoding='utf-8') as file:
book_text = file.read()
cleaned_text = re.sub(r'\n+', ' ', book_text) # 줄바꿈을 빈칸으로 변경
cleaned_text = re.sub(r'\s+', ' ', cleaned_text) # 여러 빈칸을 하나의 빈칸으로
print("cleaned_" + filename, len(cleaned_text), "characters") # 글자 수 출력
with open("cleaned_" + filename, 'w', encoding='utf-8') as file:
file.write(cleaned_text)
filenames_list = ["02 Harry Potter and the Chamber of Secrets.txt"]
for filename in filenames_list:
clean_text(filename)
cleaned_02 Harry Potter and the Chamber of Secrets.txt 488771 characters
이 코드를 적용하면 텍스트에 줄바꿈을 띄어쓰기로 변경할 수 있고 약 48만개의 글자가 있다는 것을 확인할 수 있다.
다음으로는 토큰화를 진행해야하는데 토큰화란 AI의 모델은 텍스트를 읽을 때 글자 그대로 읽을 수 없고 숫자로만 읽을 수가 있기 때문에 글자를 숫자로 변경을 하는 작업이라고 할 수 있다. 또한 한국어에서 단어를 "어간+어미"처럼 나눌 수 있는 것처럼 나누어 AI가 읽는 단위를 토큰이라고 한다.
tiktoken은 chatGPT을 운영하는 OpenAI에서 텍스트를 토큰화시킬 수 있고 간편하게 사용할 수 있다는 장점이 있다.
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2") #gpt2가 사용하는 방식의 토큰화적용
text = "Harry Potter was a wizard."
tokens = tokenizer.encode(text)
print("글자수:", len(text), "토큰수", len(tokens))
print(tokens)
print(tokenizer.decode(tokens))
for t in tokens:
print(f"{t}\t -> {tokenizer.decode([t])}")
글자수: 26 토큰수 6
[18308, 14179, 373, 257, 18731, 13]
Harry Potter was a wizard.
18308 -> Harry
14179 -> Potter
373 -> was
257 -> a
18731 -> wizard
13 -> .
문장을 6개의 토큰으로 나누어서 각각 숫자로 표현되는 모습을 볼 수 있다. [마침표도 끝났다는 의미로 포함되어 있는 모습]
데이터 로더 정의
다음으로는 모델 학습을 하기 위해서 데이터들로 이루어진 데이터셋을 구성해야한다. 파이토치의 데이터셋을 이용하면 간단하게 구현가능하다.
import torch
from torch.utils.data import Dataset, DataLoader
class MyDataset(Dataset):
def __init__(self, txt, max_length, stride):
self.input_ids = []
self.target_ids = []
# token_ids = tokenizer.encode("<|endoftext|>" + txt, allowed_special={"<|endoftext|>"})
token_ids = tokenizer.encode(txt)
print("Number of tokens in txt:", len(token_ids))
for i in range(0, len(token_ids) - max_length, stride):
input_chunk = token_ids[i:i + max_length]
target_chunk = token_ids[i + 1: i + max_length + 1]
self.input_ids.append(torch.tensor(input_chunk))
self.target_ids.append(torch.tensor(target_chunk))
def __len__(self):
return len(self.input_ids)
def __getitem__(self, idx):
return self.input_ids[idx], self.target_ids[idx]
# with open("cleaned_한글문서.txt", 'r', encoding='utf-8-sig') as file: # 선택: -sig를 붙여서 BOM 제거
with open("cleaned_02 Harry Potter and the Chamber of Secrets.txt", 'r', encoding='utf-8-sig') as file: # 선택: -sig를 붙여서 BOM 제거
txt = file.read()
dataset = MyDataset(txt, max_length = 32, stride = 4)
train_loader = DataLoader(dataset, batch_size=128, shuffle=True, drop_last=True)
# 주의: 여기서는 코드를 단순화하기 위해 test, valid는 생략하고 train_loader만 만들었습니다.
# 관련된 ML 이론이 궁금하신 분들은 train vs test vs validation 등으로 검색해보세요.
학습의 예시는 다음과 같이 확인할 수 있다. 아래 코드는 실행할 때마다 무작위로 문장이 나오는 것을 볼 수 있다.
dataiter = iter(train_loader)
x, y = next(dataiter)
print(tokenizer.decode(x[0].tolist()))
print()
print(tokenizer.decode(y[0].tolist()))
예를 들어 다음과 같이 결과값이 나왔을 때 위의 문장은 학습 때 주어지는 입력이고 아랫 문장은 학습 때 맞추어야하는 target이다.
than Snape, but she was still extremely strict.(중략) Harry had
Snape, but she was still extremely strict. (중략) Harry had seen
아래의 target이 한 토큰씩 밀렸이다는 것을 확인할 수 있다. 문장을 생성하는 AI는 한 단어씩 생성하며 이번 결과값들은 다음 예측의 입력값으로 쓰인다. 따라서 윗 문장의 입력값을 통해 아랫 문장의 다음 단어를 예측하도록 [seen] 모델이 학습한다는 것을 알 수 있다.
모델 구현
LLM은 GPT의 형태를 사용하여 구현할 것이고 다음과 같이 구성되어 있다:
토큰 임배딩
포지션 임배딩
드롭아웃
트랜스포머
Layer 정규화
완전연결층
VOCAB_SIZE = tokenizer.n_vocab # 50257 Tiktoken
#VOCAB_SIZE = len(tokenizer) # AutoTokenizer
CONTEXT_LENGTH = 128 # Shortened context length (orig: 1024)
EMB_DIM = 768 # Embedding dimension
NUM_HEADS = 12 # Number of attention heads
NUM_LAYERS = 12 # Number of layers
DROP_RATE = 0.1 # Dropout rate
QKV_BIAS = False # Query-key-value bias
import torch.nn as nn
class MultiHeadAttention(nn.Module):
def __init__(self, d_in, d_out):
super().__init__()
assert d_out % NUM_HEADS == 0, "d_out must be divisible by n_heads"
self.d_out = d_out
self.head_dim = d_out // NUM_HEADS
self.W_query = nn.Linear(d_in, d_out, bias=QKV_BIAS)
self.W_key = nn.Linear(d_in, d_out, bias=QKV_BIAS)
self.W_value = nn.Linear(d_in, d_out, bias=QKV_BIAS)
self.out_proj = nn.Linear(d_out, d_out)
self.dropout = nn.Dropout(DROP_RATE)
self.register_buffer('mask', torch.triu(torch.ones(CONTEXT_LENGTH, CONTEXT_LENGTH), diagonal=1))
def forward(self, x):
b, num_tokens, d_in = x.shape
keys = self.W_key(x) # (b, num_tokens, d_out)
queries = self.W_query(x)
values = self.W_value(x)
keys = keys.view(b, num_tokens, NUM_HEADS, self.head_dim)
values = values.view(b, num_tokens, NUM_HEADS, self.head_dim)
queries = queries.view(b, num_tokens, NUM_HEADS, self.head_dim)
keys = keys.transpose(1, 2)
queries = queries.transpose(1, 2)
values = values.transpose(1, 2)
attn_scores = queries @ keys.transpose(2, 3)
mask_bool = self.mask.bool()[:num_tokens, :num_tokens]
attn_scores.masked_fill_(mask_bool, -torch.inf)
attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1)
attn_weights = self.dropout(attn_weights)
context_vec = (attn_weights @ values).transpose(1, 2)
context_vec = context_vec.reshape(b, num_tokens, self.d_out)
context_vec = self.out_proj(context_vec)
return context_vec
class LayerNorm(nn.Module):
def __init__(self, emb_dim):
super().__init__()
self.eps = 1e-5
self.scale = nn.Parameter(torch.ones(emb_dim))
self.shift = nn.Parameter(torch.zeros(emb_dim))
def forward(self, x):
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, keepdim=True, unbiased=False)
norm_x = (x - mean) / torch.sqrt(var + self.eps)
return self.scale * norm_x + self.shift
class GELU(nn.Module):
def __init__(self):
super().__init__()
def forward(self, x):
return 0.5 * x * (1 + torch.tanh(
torch.sqrt(torch.tensor(2.0 / torch.pi)) *
(x + 0.044715 * torch.pow(x, 3))
))
class FeedForward(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.Sequential(
nn.Linear(EMB_DIM, 4 * EMB_DIM),
GELU(),
nn.Linear(4 * EMB_DIM, EMB_DIM),
)
def forward(self, x):
return self.layers(x)
class TransformerBlock(nn.Module):
def __init__(self):
super().__init__()
self.att = MultiHeadAttention(
d_in=EMB_DIM,
d_out=EMB_DIM)
self.ff = FeedForward()
self.norm1 = LayerNorm(EMB_DIM)
self.norm2 = LayerNorm(EMB_DIM)
self.drop_shortcut = nn.Dropout(DROP_RATE)
def forward(self, x):
shortcut = x
x = self.norm1(x)
x = self.att(x)
x = self.drop_shortcut(x)
x = x + shortcut
shortcut = x
x = self.norm2(x)
x = self.ff(x)
x = self.drop_shortcut(x)
x = x + shortcut
return x
class GPTModel(nn.Module):
def __init__(self):
super().__init__()
self.tok_emb = nn.Embedding(VOCAB_SIZE, EMB_DIM)
self.pos_emb = nn.Embedding(CONTEXT_LENGTH, EMB_DIM)
self.drop_emb = nn.Dropout(DROP_RATE)
self.trf_blocks = nn.Sequential(
*[TransformerBlock() for _ in range(NUM_LAYERS)])
self.final_norm = LayerNorm(EMB_DIM)
self.out_head = nn.Linear(EMB_DIM, VOCAB_SIZE, bias=False)
def forward(self, in_idx):
batch_size, seq_len = in_idx.shape
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
x = tok_embeds + pos_embeds # Shape [batch_size, num_tokens, emb_size]
x = self.drop_emb(x)
x = self.trf_blocks(x)
x = self.final_norm(x)
logits = self.out_head(x)
return logits
모델 훈련
이후 다음과 같이 cuda 설정을 하자. 만약 GPU가 없다면 cuda대신에 cpu로 진행하게 될 것이다.
import torch
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
#device = "cpu"
print(device)
torch.manual_seed(123)
model = GPTModel()
model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1)
다음과 같이 학습을 진행하고 model_[에포크수].pth의 가중치가 저장된 파일의 형태로 저장이 될것이다. 에포크를 늘릴수록 더 많이 학습이 되겠지만 loss가 이미 충분하게 감소한 상태라면 수렴[convergence]가 되어 더 좋아지기보다는 오히려 오버피팅이 되어서 나빠질 수도 있다. 따라서 적절한 에포크를 설정하는 것이 중요한데 이 학습에서는 (10~20)번의 에포크가 가장 효율적이다.
tokens_seen, global_step = 0, -1
losses = []
for epoch in range(15):
model.train() # Set model to training mode
epoch_loss = 0
for input_batch, target_batch in train_loader:
optimizer.zero_grad() # Reset loss gradients from previous batch iteration
input_batch, target_batch = input_batch.to(device), target_batch.to(device)
logits = model(input_batch)
loss = torch.nn.functional.cross_entropy(logits.flatten(0, 1), target_batch.flatten())
epoch_loss += loss.item()
loss.backward() # Calculate loss gradients
optimizer.step() # Update model weights using loss gradients
tokens_seen += input_batch.numel()
global_step += 1
if global_step % 1000 == 0:
print(f"Tokens seen: {tokens_seen}")
# Optional evaluation step
avg_loss = epoch_loss / len(train_loader)
losses.append(avg_loss)
print(f"Epoch: {epoch + 1}, Loss: {avg_loss}")
torch.save(model.state_dict(), "model_" + str(epoch + 1).zfill(3) + ".pth")
Tokens seen: 4096
Epoch: 1, Loss: 4.395917735700532
Epoch: 2, Loss: 2.2284249306663755
Epoch: 3, Loss: 0.8017834572810826
Tokens seen: 4100096
Epoch: 4, Loss: 0.39307656802061036
Epoch: 5, Loss: 0.3053791473700306
Epoch: 6, Loss: 0.27076525974461413
Epoch: 7, Loss: 0.25332761438578133
Tokens seen: 8196096
Epoch: 8, Loss: 0.24346962395146138
Epoch: 9, Loss: 0.23733308829191163
Epoch: 10, Loss: 0.23044743312625435
Epoch: 11, Loss: 0.2257675657591482
Tokens seen: 12292096
Epoch: 12, Loss: 0.2229799481592779
Epoch: 13, Loss: 0.21953854850662036
Epoch: 14, Loss: 0.21544993017602154
Epoch: 15, Loss: 0.21254784437849766
matplotlib을 통해서 에포크에 따른 손실의 변화를 시각화할 수 있다.
import matplotlib.pyplot as plt
plt.plot(losses)
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.title('Training Loss Over Epochs')
plt.show()

이제 학습한 모델을 불러보자.
# 파일로 저장했던 네트워크의 가중치들 읽어들이기
model.load_state_dict(torch.load("model_015.pth", map_location=device, weights_only=True))
model.eval() # dropout을 사용하지 않음
결과 확인
이제 처음에 하기로한 Dobby is free를 생성해보자 우선 생성형 모델은 한 단어 씩 예측을 하는 것이기 때문에 Dobby is를 입력값으로 넣고 다음과 같이 가장 그럴듯한 10개의 확률과 토큰 그리고 디코드를 통한 실제 단어의 형태로 받을 수 있다.
idx = tokenizer.encode("Dobby is") # 토큰 id의 list
idx = torch.tensor(idx).unsqueeze(0).to(device)
with torch.no_grad():
logits = model(idx)
logits = logits[:, -1, :]
# 가장 확률이 높은 단어 10개 출력
top_logits, top_indices = torch.topk(logits, 10)
for p, i in zip(top_logits.squeeze(0).tolist(), top_indices.squeeze(0).tolist()):
print(f"{p:.2f}\t {i}\t {tokenizer.decode([i])}")
# 가장 확률이 높은 단어 출력
idx_next = torch.argmax(logits, dim=-1, keepdim=True)
flat = idx_next.squeeze(0) # 배치 차원 제거 torch.Size([1])
out = tokenizer.decode(flat.tolist()) # 텐서를 리스트로 바꿔서 디코드
print(out)
11.15 635 also
10.47 1464 always
10.39 1479 free
10.36 257 a
9.70 1276 must
9.50 973 used
9.09 2406 coming
8.71 4762 believed
8.50 550 had
8.41 329 for
다음과 같이 3번째의 확률로 free가 나왔다. Dobby is라는 표현은 여러 번 등장할 수 있는 형태이며 1등과 2등이 주로 is 뒤에 나오는 부사들이므로 모델이 해리포터의 텍스트로 학습을하고 "Dobby is free"라는 문장 역시 제대로 학습을 하였다는 것으로 해석할 수 있다.
데이터에서 어떠한 문장들이 있어서 다음과 같이 결과가 나왔는지 확인해보자. 우선 "Dobby"라는 단어를 검색하였을 때 총 153번이 이 책에서 등장한다.

그 다음으로는 "Dobby is"을 검색하였을 때는 5개가 등장하였고 다음과 같은 문장이 존재한다.






is 다음으로 나오는 단어는 a, always, used, still 그리고 free이며 still을 제외한 모든 단어가 결과값으로 출력이 되었다.
이제 다음으로는 한 단어가 아닌 전체 문장을 생성을 해보도록하자. 이전에 언급을 하였지만 AI 모델이 문장을 생성하였을 때 한 단어씩 생성하고 생성한 단어는 다시 입력으로 사용된다고 하였다. 예시로 첫 입력 다음과 같이 주어졌을 때
- Harry Potter is
"a" 다음 단어로 생성하였다고 해보자. 그럼 다음으로는 "a"를 포함한 문장이 입력으로 사용된다.
- Harry Potter is a
"wizard"가 생성하면 다시 입력에 넣어서 반복한다.
- Harry Potter is a wizard
코드는 다음과 같다.
def generate(model, idx, max_new_tokens, context_size, temperature=0.0, top_k=None, eos_id=None):
for _ in range(max_new_tokens):
idx_cond = idx[:, -context_size:]
with torch.no_grad():
logits = model(idx_cond)
logits = logits[:, -1, :]
if top_k is not None:
top_logits, _ = torch.topk(logits, top_k)
min_val = top_logits[:, -1]
logits = torch.where(logits < min_val, torch.tensor(float("-inf")).to(logits.device), logits)
if temperature > 0.0:
logits = logits / temperature
probs = torch.softmax(logits, dim=-1) # (batch_size, context_len)
idx_next = torch.multinomial(probs, num_samples=1) # (batch_size, 1)
else:
idx_next = torch.argmax(logits, dim=-1, keepdim=True) # (batch_size, 1)
if idx_next == eos_id:
break
idx = torch.cat((idx, idx_next), dim=1) # (batch_size, num_tokens+1)
return idx
start_context = input("Start context: ")
# idx = tokenizer.encode(start_context, allowed_special={'<|endoftext|>'})
idx = tokenizer.encode(start_context)
idx = torch.tensor(idx).unsqueeze(0)
context_size = model.pos_emb.weight.shape[0]
for i in range(10):
token_ids = generate(
model=model,
idx=idx.to(device),
max_new_tokens=10,
context_size= context_size,
top_k=10,
temperature=50
)
flat = token_ids.squeeze(0) # remove batch dimension
out = tokenizer.decode(flat.tolist()).replace("\n", " ")
print(i, ":", out)
다음과 같이 결과를 확인할 수 있다.
Start context: Dobby is
0 : Dobby is believed yourself coming back head and Hogwarts Express business straight
1 : Dobby is believed it D sir and sealed all Chamber forever.
2 : Dobby is always having tantageway for hours the International circumstances
3 : Dobby is had known all for years … Then he who�
4 : Dobby is free, until we weren The perfect around our last
5 : Dobby is also in sir� house… Just until he thinks
6 : Dobby is also from coming or date greatest or Charlie here for
7 : Dobby is had to tell them how greedy their Easter lesson is
8 : Dobby is coming it D DUDBL course with pain with
9 : Dobby is always said something important to leave and risk like ver
조절할 수 있는 하이퍼파라미터는
- 새롭게 생성하는 토큰의 최대 갯수 [max_new_tokens=10]
- 몇 개의 문장을 생성하고 확률 순서대로 정렬 [topk=10]
- 문장의 창의성을 조절할 수 있는 [temperature=50]
temperature의 경우 작게 할수록 생성하는 문장이 일관되며 0.01와 같이 매우 낮은 값으로 하였을 때에는 모든 문장이 똑같이 생성이 된다.
결론
이번 게시글에서 GPT 모델을 바닥에서부터 만들어서 LLM을 구현해보았다. Kaggle에서 해리포터 뿐만아니라 셰익스피어나 이상한 나라의 앨리스 등 다양한 문학작품을 정리된 형태의 데이터로 사용해볼 수 있으니 다른 데이터로 시도를 해볼 수 있다. 이렇게 해리포터의 텍스트를 바탕으로 LLM을 학습시켰을 때 생성하는 문장의 문맥이 정해져있으며 이는 데이터의 품질, 양에 따라서 AI가 어떤 방향의 결과값으로 달라지는지 확인할 수 있다.
