컴퓨터/AI

Build a Large Language Model

수제녹차 2025. 5. 29. 23:45
728x90
반응형

* 유튜브, 홍정모 - LLM 바닥부터 만들기 (대형언어모델) 1시간 핵심 정리 수업을 듣고 정리했다.

https://youtu.be/osv2csoHVAo?si=lBAQqiVy4o8SQ6xu

 

 

* Build a Large Language Model

 

딥시크 이후 LLM을 바닥부터 만드는 기술에 대한 진입장벽이 낮아지고 있다.

회사별로 필요한 LLM을 바닥부터 만들어 사용하게 될 가능성이 높아지고 있다.

앞으로 컴공에서 LLM을 직접 만들어보는 수업을 많이 하게 될 것이라고 한다.

 

https://github.com/HongLabInc/HongLabLLM/blob/main/01_pretraining.ipynb

 

HongLabLLM/01_pretraining.ipynb at main · HongLabInc/HongLabLLM

Contribute to HongLabInc/HongLabLLM development by creating an account on GitHub.

github.com

 

 

https://github.com/rasbt/LLMs-from-scratch

 

GitHub - rasbt/LLMs-from-scratch: Implement a ChatGPT-like LLM in PyTorch from scratch, step by step

Implement a ChatGPT-like LLM in PyTorch from scratch, step by step - rasbt/LLMs-from-scratch

github.com

 

 

https://wikidocs.net/book/15693

 

Build a Large Language Model (From Scratch)_번역

원문 출처 - https://www.amazon.com/Build-Large-Language-Model-Scratch/dp/1633437167 - https://www…

wikidocs.net

 

* 내 컴퓨터에서 PyTorch를 돌릴 수 있을까?

 

내 개인 노트북은 2019년형 13인치 MacBook Pro 모델이다

터미널에서 아래 명령어로 어떤 GPU를 가지고 있는지 확인해보았다.

system_profiler SPDisplaysDataType

결과: Intel Iris Plus Graphics 655

 

PyTorch의 GPU 가속은 NVIDIA CUDA 기술을 기반으로 하는데
내껀 NVIDIA GPU가 아니고, CUDA를 지원하지 않기 때문에 PyTorch에서 GPU 연산이 불가능하다고 한다.

 

가능한 선택지는 다음과 같다

옵션 설명
CPU로 PyTorch 사용 가능. 단, 속도는 느림
MPS (Apple Silicon용) M1/M2 칩이면 PyTorch가 MPS(GPU 가속)를 지원함
외장 NVIDIA GPU 사용 데스크탑 또는 eGPU 구성 필요
Google Colab 사용 무료로 NVIDIA GPU 제공 (학습/테스트에 적합)

 

 

 

* 토큰화

 

훈련에 사용하는 문자열을 뉴럴네트워크에 그냥 넘기는 건 아니고 숫자로 넘겨준다.

이때 사용하는게 토크나이저(tokenizer)

import tiktoken # pip install tiktoken

tokenizer = tiktoken.get_encoding("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	 -> .

다시 decode하면 원본 문자열이 나온다.

아스키코드는 글자 하나하나를 컴퓨터가 이해할 수 있는 숫자(0~127)로 약속한 표준 인코딩 체계다.

토크나이저로 인코딩한 숫자는 문자 단위가 아니라 단어 단위다.

 

* 데이터 로더

 

뉴럴 네트워크에 훈련시킬 때 전체 데이터를 한번에 넣을 수는 없다.

훈련 데이터를 뉴럴 네트워크에 분할해서 넣어준다.

ex. pytorch

훈련하는 전략

 

import torch
from torch.utils.data import Dataset, DataLoader

class MyDataset(Dataset): #사용자 정의 Dataset 클래스. 보통 torch.utils.data.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("# 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()

# txt라는 텍스트 데이터를 받아서,
# 일정한 길이(max_length=32)로 슬라이딩 윈도우 방식(stride=4)으로 잘라 데이터셋을 구성하는 코드
# 텍스트를 길이 32짜리로 4칸씩 겹쳐 자른 시퀀스 데이터셋 생성
dataset = MyDataset(txt, max_length = 32, stride = 4)

# 미니 배치를 만들어주는 pytorch utility
# 위 데이터셋을 128개씩 묶어 섞은 후 모델 학습용으로 사용
train_loader = DataLoader(dataset, batch_size=128, shuffle=True, drop_last=True)

# 주의: 여기서는 코드를 단순화하기 위해 test, valid는 생략하고 train_loader만 만들었습니다.
#      관련된 ML 이론이 궁금하신 분들은 train vs test vs validation 등으로 검색해보세요.

코드 중간에 보면 아래와 같은 부분이 있다

input_chunk = token_ids[i:i + max_length]
target_chunk = token_ids[i + 1: i + max_length + 1]

+1: 다음에 올 단어를 맞추도록 훈련시킨다는 것이다.

Harry Potter was a wizard. 라는 문장이 있으면
"Harry Potter"을 주면 "Potter was"를 답변하게 하는 것이다.

 

(참고)

에폭: 전체 학습 데이터를 한 번다 사용해서 모델이 학습을 완료한 횟수

dataiter = iter(train_loader) # 이터레이터로 변환

x, y = next(dataiter) # 입력 시퀀스, 정답 시퀀스

print(tokenizer.decode(x[0].tolist()))
print(tokenizer.decode(y[0].tolist()))

 

정답 시퀀스는 입력 시퀀스보다 한 토큰 단위로 밀려 있다.

 

 

* GPT 스타일의 트랜스포머 모델을 정의할 때 사용하는 주요 하이퍼파라미터(모델 구조를 정의하는 상수)

# 모델을 정의할 때 사용하는 상수들

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

 

 

🔹 VOCAB_SIZE = tokenizer.n_vocab

  • 모델이 처리할 수 있는 전체 토큰 개수 (어휘 사전 크기)
  • Tiktoken을 쓸 경우 n_vocab 사용 (50257개)
    • n_vocab: *토크나이저(tokenizer)**가 가진 전체 단어(토큰) 사전의 크기
    • 모델이 이해할 수 있는 토큰 개수
  • AutoTokenizer를 쓰면 len(tokenizer)로도 가능
  • 이 값은 임베딩 레이어의 입력 크기출력층의 클래스 개수로 사용된다.
    • 임베딩 레이어의 입력 크기: 모델은 단어를 숫자로 받는다. 숫자를 고차원 벡터로 바꿔주는 게임베딩 레이어
    • nn.Embedding(num_embeddings=VOCAB_SIZE, embedding_dim=EMB_DIM)
      • 50257개의 토큰 각각을 768차원 벡터로 변환
    • 출력층 클래스
      • nn.Linear(in_features=EMB_DIM, out_features=VOCAB_SIZE)
      • 768차원 벡터를 다시 50257개의 토큰 중 하나로 분류

🔹 CONTEXT_LENGTH = 128

  • 한 번에 처리할 수 있는 입력 시퀀스의 최대 길이
  • 예: 한 문장을 토큰으로 쪼갠 뒤, 최대 128개까지만 사용
  • 원래 GPT는 1024이지만 메모리/속도 줄이려고 128로 축소한 것

🔹 EMB_DIM = 768

  • 토큰 임베딩의 차원 수, 즉 각 토큰이 768차원의 벡터로 표현됨
  • GPT-2 Small 모델도 768을 사용

🔹 NUM_HEADS = 12

  • 멀티헤드 어텐션에서 사용하는 헤드의 개수
  • 모델이 입력을 여러 관점(12개)으로 나눠서 주의(attention)를 줌

🔹 NUM_LAYERS = 12

  • 트랜스포머 블록의 개수
  • 각 레이어는 self-attention + feed-forward block으로 구성
  • 더 많을수록 모델이 더 깊고 복잡함 (GPT-2 Small도 12층)

🔹 DROP_RATE = 0.1

  • 드롭아웃 확률
  • 학습 중 일부 뉴런을 무작위로 꺼서 과적합을 방지
  • 일반적으로 0.1~0.3 사이 사용

🔹 QKV_BIAS = False

  • Query, Key, Value 벡터에 bias 항을 사용할지 여부
  • True면 각 Q/K/V projection에 bias가 추가됨
  • 최근 논문에서는 bias=False로 성능과 효율을 맞추는 경우도 많음 (예: GPT-2)

 

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

* LayerNorm

가중치 값들이 너무 들쑥날쑥하지 않게 적당한 범위 내에 몰리도록 만든다.

 

* Multi-Head Attention

단어끼리 얼마나 관계가 깊은가 행렬로 저장한다.

 

* Masked Multi-Head Attention

 

다음에 나올 단어들을 가려서 예측하게 한다.

🔹 FeedForward (피드포워드 층)

입력 벡터를 변형시키는 단순한 신경망 층이에요.

  • 트랜스포머에서는 각 토큰마다 독립적으로 적용돼요.
  • "한 단어(벡터)에 대해 조용히 혼자 생각하는 시간"에 비유할 수 있다
  • 구조는 보통 이렇게 생겼어요:
 
[입력] → 선형변환 (Linear) → 비선형함수 (예: GELU) → 선형변환 → [출력]
  • 역할: 어텐션 후 나온 정보를 더 복잡하게 처리해줌

🔹 GELU (Gaussian Error Linear Unit)

비선형 함수 중 하나
ReLU보다 부드럽고 자연스러운 곡선 형태.

  • 수식은 복잡하지만, 핵심은:
    • 작은 값은 천천히 보내고,
    • 큰 값은 거의 그대로 통과시킴
  • ReLU는 0보다 크면 통과, 아니면 0인데,
    GELU는 부드럽게 0~1 사이로 조절해요.
  • GPT와 BERT 등 많은 모델이 ReLU 대신 GELU를 사용함.
  • "신경망이 판단할 때, 정보를 통과시킬지 말지 부드럽게 결정하는 문지기"

 

 

# 1. GPU 사용 가능한지 확인하고 디바이스 설정
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 2. 랜덤 시드 고정 (결과 재현성 확보)
torch.manual_seed(123)

# 3. 모델 생성 및 장치로 이동
model = GPTModel()
model.to(device)

# 4. 옵티마이저 설정 (AdamW + 정규화)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1)

🔸 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

  • **CUDA(GPU)**가 사용 가능한 경우 "cuda"를,
    아니면 "cpu"를 선택해서 device로 설정합니다.
  • 이 설정 덕분에 코드를 GPU가 있든 없든 자동으로 맞춰 실행할 수 있어요.

예:

  • GPU 있으면 device = cuda
  • GPU 없으면 device = cpu

🔸 torch.manual_seed(123)

  • 랜덤 시드를 고정하는 코드예요.
  • 이걸 하면:
    • 매번 실행해도 같은 초기값 → 결과 재현성 확보
    • 학습 성능 테스트할 때 비교 가능

🔸 model = GPTModel()

  • GPTModel이라는 클래스로 모델 객체 생성
  • 보통 nn.Module을 상속받은 커스텀 모델 클래스

🔸 model.to(device)

  • 위에서 설정한 device에 모델을 올리는 명령
    • GPU가 있으면 GPU로,
    • 없으면 CPU로

👉 텐서 연산을 하기 위해 모델과 데이터가 같은 장치에 있어야 함

🔸 optimizer = torch.optim.AdamW(model.parameters(), lr=0.0004, weight_decay=0.1)

  • 학습을 위한 최적화 알고리즘(옵티마이저) 설정

 

항목 설명
AdamW Adam 옵티마이저 + L2 정규화 방식 (Weight Decay)
model.parameters() 모델의 학습 가능한 모든 파라미터
lr=0.0004 학습률 (learning rate)
weight_decay=0.1 L2 정규화 정도 (과적합 방지용)
 
tokens_seen, global_step = 0, -1

losses = []

for epoch in range(100):
    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")

 

 

 

idx = tokenizer.encode("Dobby is") # 토큰 id의 list
idx = torch.tensor(idx).unsqueeze(0).to(device)

# 모델에 입력하여 예측
with torch.no_grad(): # 추론이므로 그래디언트 계산 생략 (속도↑, 메모리↓)
	# 입력 시퀀스를 넣어 다음 단어 예측, logits는 [1, seq_len, vocab_size] 모양의 텐서
	# 각 위치마다 모든 단어(vocab) 확률 점수를 갖고 있음
    logits = model(idx)

# [:, -1, :] → 마지막 토큰 위치에서 다음 단어를 예측하는 로짓만 추출
# : 배치 전체를 가져와라 (여기선 1개지만 일반화)
# -1 시퀀스 길이 중 마지막 위치 (여기선 is 뒤)
# : 모든 단어(vocab)의 로짓 값 가져오기
# 크기: [1, vocab_size]
logits = logits[:, -1, :]

# 확률(로짓)이 가장 높은 10개 단어의 값(top_logits)과 위치(top_indices)를 반환
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)

🐳 시각화: logits 텐서 구조

logits.shape == [1, 2, 50257]  

[batch_size(한 번에 처리하는 문장 수),  seq_len(입력된 토큰 수), vocab_size( 다음 단어로 예측 가능한 모든 단어의 수)]

logits = [
  [   # batch 0
    [p00, p01, p02, ..., p0_50256],   ← Dobby (1번째 토큰)
    [p10, p11, p12, ..., p1_50256],   ← is    (2번째 토큰)
  ]
]

last_logits = logits[:, -1, :]  # shape: [1, 50257]

👉 "Dobby is" 다음에 어떤 단어가 올지에 대한 모델의 "점수표"

 

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)

        # temperature는 "출력의 무작위성(창의성)"을 조절하는 하이퍼파라미터
        # temperature > 0: 다양성 추가 (확률 분포로 샘플링)
        # temperature == 0: 가장 확률 높은 토큰 선택 (deterministic)
        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)

 

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 (= 한 번에 처리할 수 있는 최대 토큰 수)를 모델의 위치 임베딩(positional embedding) 크기에서 자동으로 가져오는 코드
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=50,
        context_size= context_size,
        top_k=50,
        temperature=0.5
    )

    flat = token_ids.squeeze(0) # remove batch dimension
    out = tokenizer.decode(flat.tolist()).replace("\n", " ")

    print(i, ":", out)
반응형