LLM 엔지니어링3장 · Attention 과 Transformer, GPT 직접 구현17 / 23← 이전목차다음 →MetaContents

3장. Attention 과 Transformer, GPT 직접 구현

실습 3 — 미니 GPT 만들기

실습 1의 글자 토크나이저 위에, 블록 2개짜리 작은 GPT 를 만듭니다. 짜임은 "Transformer 블록" 절의 그림 그대로입니다. 아직 학습하지 않은 모델로 글을 써 보게 해서, 학습 전에는 아무 글자나 늘어놓는다는 것을 확인합니다.

1단계. 실습 1과 같은 준비

Python
import torch
from torch import nn
from torch.nn import functional as F

# 실습 1과 똑같이 글을 읽고 토크나이저를 만든다
with open("cs_dialogs.txt", encoding="utf-8") as f:
    text = f.read()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: "".join(itos[i] for i in ids)

# 모델의 크기를 정하는 설정값
V = len(chars)      # 어휘 크기
BLOCK = 64          # 한 번에 보는 글자 수
EMB = 64            # 임베딩 좌표 수
HEADS = 4           # 헤드 수
LAYERS = 2          # 블록 수
print(V, BLOCK, EMB, HEADS, LAYERS)
출력
124 64 64 4 2

이 단계의 함수는 실습 1의 1~2단계 표와 같습니다. 다른 점만 적습니다.

코드쉬운 뜻
from torch.nn import functional as F층이 아니라 함수 꼴로 쓰는 계산(softmax, 손실 등)을 모은 모듈을 F 로 불러온다. 3단계에서 쓴다.
encode = lambda s: ...실습 1의 def encode 와 같은 일을 한 줄로 적은 것이다.
HEADS = 4멀티헤드 어텐션의 헤드 수다. 좌표 64개를 헤드 4개가 16개씩 나눠 본다.
LAYERS = 2Transformer 블록을 몇 개 쌓을지 정한다.

2단계. 블록 만들기

Python
class Block(nn.Module):
    """Transformer 블록 하나: LayerNorm → 마스크 셀프 어텐션 → 더하기 → LayerNorm → FFN → 더하기"""

    def __init__(self):
        super().__init__()
        self.ln1 = nn.LayerNorm(EMB)
        self.attn = nn.MultiheadAttention(EMB, HEADS, batch_first=True)
        self.ln2 = nn.LayerNorm(EMB)
        # 피드포워드: 좌표를 4배로 넓혔다가 다시 줄인다(2장의 신경망과 같은 모양)
        self.ff = nn.Sequential(nn.Linear(EMB, 4 * EMB), nn.ReLU(), nn.Linear(4 * EMB, EMB))

    def forward(self, x):
        T = x.shape[1]                                    # 지금 넣은 글자 수
        # True 인 칸(자기보다 뒤)을 가리는 마스크
        mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
        h = self.ln1(x)
        x = x + self.attn(h, h, h, attn_mask=mask, need_weights=False)[0]   # 잔차 연결 1
        x = x + self.ff(self.ln2(x))                                         # 잔차 연결 2
        return x

print("블록 정의 완료")
출력
블록 정의 완료

이 단계의 함수입니다.

코드쉬운 뜻
class Block(nn.Module)nn.Module 을 이어받아 나만의 층을 만든다. 안에 넣은 층의 가중치를 PyTorch 가 알아서 모아 관리한다.
super().__init__()nn.Module 의 준비 작업을 먼저 실행한다. 이 줄이 없으면 self.ln1 = ... 처럼 층을 넣을 때 오류가 난다.
nn.LayerNorm(EMB)글자 하나의 좌표 64개를 평균 0, 퍼짐 1 근처로 맞춘다. 블록을 여럿 쌓아도 값이 너무 커지거나 작아지지 않게 한다.
nn.MultiheadAttention(EMB, HEADS, batch_first=True)Q, K, V 만들기, 점수, softmax, 섞기를 헤드 4개로 한꺼번에 하는 층이다. batch_first=True 는 입력을 (배치, 글자 수, 좌표 수) 순서로 받는다는 뜻이다.
nn.Sequential(nn.Linear, nn.ReLU, nn.Linear)2장에서 본 층 묶음이다. 여기서는 피드포워드(FFN)로 쓴다.
def forward(self, x)입력이 층을 지나는 순서를 적는다. block(x) 처럼 부르면 이 함수가 실행된다.
torch.triu(..., diagonal=1)대각선 바로 위부터 오른쪽 위만 남긴다. 자기보다 뒤에 있는 글자 칸만 True 가 된다.
dtype=torch.bool값을 참·거짓으로 만든다. MultiheadAttention 은 참·거짓 마스크에서 True 인 칸을 가린다.
self.attn(h, h, h, attn_mask=mask, need_weights=False)[0]질문, 열쇠, 내용에 모두 같은 h 를 넣어 셀프 어텐션을 한다. need_weights=False 는 비율 표를 돌려주지 않게 한다. 돌려주는 두 값 가운데 첫째(결과)만 쓴다.
x = x + ...층의 결과에 들어온 x 를 그대로 더한다(잔차 연결).

실습 2는 torch.tril 로 "볼 칸"을 1로 두었고, 여기서는 torch.triu 로 "가릴 칸"을 True 로 둡니다. 모양은 반대지만 자기보다 뒤를 못 보게 한다는 뜻은 같습니다.

3단계. 미니 GPT 만들기

Python
class MiniGPT(nn.Module):
    """임베딩 → 블록 LAYERS 개 → 다음 글자 점수"""

    def __init__(self):
        super().__init__()
        self.tok_emb = nn.Embedding(V, EMB)       # 글자의 뜻
        self.pos_emb = nn.Embedding(BLOCK, EMB)   # 자리 정보
        self.blocks = nn.Sequential(*[Block() for _ in range(LAYERS)])
        self.ln = nn.LayerNorm(EMB)
        self.head = nn.Linear(EMB, V)             # 좌표 64개 → 어휘 크기만큼의 점수

    def forward(self, idx, targets=None):
        T = idx.shape[1]
        x = self.tok_emb(idx) + self.pos_emb(torch.arange(T))
        logits = self.head(self.ln(self.blocks(x)))     # (배치, 글자 수, 어휘 크기)
        loss = None
        if targets is not None:
            # 분류 손실: 자리마다 "다음 글자"를 맞혔는지. 모양을 (건수, 어휘 크기) 로 펴서 넣는다
            loss = F.cross_entropy(logits.view(-1, V), targets.view(-1))
        return logits, loss

    @torch.no_grad()
    def generate(self, idx, n_new, temperature=1.0, generator=None):
        """글 끝에 글자를 하나씩 n_new 번 붙여 나간다."""
        for _ in range(n_new):
            logits, _ = self(idx[:, -BLOCK:])                  # 마지막 64글자만 넣는다
            probs = F.softmax(logits[:, -1, :] / temperature, dim=-1)   # 마지막 자리의 점수 → 확률
            nxt = torch.multinomial(probs, 1, generator=generator)    # 확률대로 한 글자 뽑기
            idx = torch.cat([idx, nxt], dim=1)                  # 글 끝에 붙인다
        return idx

torch.manual_seed(42)
model = MiniGPT()
print("파라미터 수:", sum(p.numel() for p in model.parameters()))
출력
파라미터 수: 120188

이 단계의 함수입니다.

코드쉬운 뜻
nn.Sequential(*[Block() for _ in range(LAYERS)])블록 2개를 만들어 차례로 잇는다. * 는 리스트를 풀어 하나씩 넘긴다.
nn.Linear(EMB, V)좌표 64개를 어휘 크기(124)만큼의 점수로 바꾼다. 점수가 가장 큰 글자가 모델이 가장 그럴듯하게 보는 다음 글자다.
self.pos_emb(torch.arange(T))넣은 글자 수 T 만큼만 자리 좌표를 꺼낸다. 글이 64글자보다 짧아도 된다.
logits자리마다 어휘 글자 각각에 매긴 점수다. softmax 를 거치기 전의 날 점수를 이렇게 부른다.
logits.view(-1, V), targets.view(-1)(배치, 글자 수, 어휘) 모양을 (건수, 어휘)로, 정답을 한 줄로 편다. 손실 함수가 받는 모양이다.
F.cross_entropy(점수, 정답)분류의 손실이다. 정답 글자에 준 확률이 낮을수록 값이 크다. softmax 를 안에서 해 주므로 날 점수를 넣는다.
@torch.no_grad()함수 위에 붙여 그 함수 안의 계산 전체를 기울기 기록 없이 한다. with torch.no_grad(): 와 같은 뜻이다.
logits[:, -1, :] / temperature마지막 자리의 점수만 꺼내 온도로 나눈다. 온도가 작을수록 큰 점수가 더 도드라진다.
F.softmax(..., dim=-1)점수를 어휘 글자마다의 확률로 바꾼다. torch.softmax 와 같은 계산이다.
torch.multinomial(probs, 1, generator=generator)확률대로 번호 하나를 뽑는다. 확률이 높은 글자가 잘 뽑히지만 낮은 글자도 뽑힐 수 있다. generator 를 주면 뽑기의 씨앗을 따로 정할 수 있다.
torch.cat([idx, nxt], dim=1)두 텐서를 글자 축(dim=1)으로 이어 붙인다. 글이 한 글자 길어진다.
sum(p.numel() for p in model.parameters())모델 안의 모든 가중치 텐서를 돌며 값 개수를 더한다. 파라미터 수다.

4단계. 학습 전에 글 써 보기

Python
# "고객: " 으로 시작하게 하고 80글자를 이어 쓰게 한다
start = torch.tensor([encode("고객: ")])
out = model.generate(start, 80, generator=torch.Generator().manual_seed(1))
print(decode(out[0].tolist()))
출력
고객: 왔셔께7?드수환개머티4:드취5으7 부에1나부터채텀남객.요취39트고수고쿠그로금티4커백진머일수즈할쿠니쿠문품되을운면고폰팩수할팩쿠께개어주셔세신바습동2라

이 단계의 함수입니다.

코드쉬운 뜻
torch.tensor([encode("고객: ")])번호 목록을 리스트로 한 번 더 감싸 모양이 (1, 글자 수)인 텐서를 만든다. 모델이 배치 축을 기대하기 때문이다.
torch.Generator().manual_seed(1)이 뽑기에만 쓰는 난수 발생기를 씨앗 1로 만든다. 전체 씨앗과 따로 놀아 다시 돌려도 같은 글이 나온다.
model.generate(start, 80, generator=…)3단계에서 만든 함수로 글자를 80번 하나씩 붙여 나간다. 온도는 기본값 1.0이다.
out[0].tolist()배치의 첫째(유일한) 글을 꺼내 번호 리스트로 바꾼다.
decode(...)번호 리스트를 다시 글로 되돌린다.

이 실습이 뜻하는 것

이 실습은 글을 쓰는 모델의 몸통을 조립만 하고, 아직 가르치지는 않습니다. 새로 문을 연 식당의 주방을 꾸민다고 생각해 봅니다. 쓸 재료 목록과 주방의 크기를 정하고(1단계), 조리대 하나를 설계한 뒤(2단계), 조리대 두 개를 이어 주방 전체를 완성합니다(3단계). 그리고 요리를 한 번도 배우지 않은 신입에게 접시를 내 보게 합니다(4단계). 여기서 재료 목록이 어휘, 조리대가 Transformer 블록, 주방 전체가 미니 GPT, 신입의 손에 밴 요령이 파라미터입니다. 실제 주방과 달리 이 모델은 접시 하나를 한 번에 내지 않고, 다음 글자 하나씩을 골라 이어 붙입니다.

단계출력뜻
1. 설정값124 64 64 4 2어휘 124글자, 한 번에 보는 글자 64개, 글자마다 좌표 64개, 헤드 4개, 블록 2개라는 뜻입니다. 헤드 4개가 좌표 64개를 16개씩 나눠 봅니다
2. 블록블록 정의 완료조리대의 설계도만 적었다는 표시입니다. 아직 블록이 실제로 만들어지지는 않았습니다
3. 파라미터 수120188학습으로 고쳐 나갈 숫자가 120,188개입니다. 임베딩 표 둘(12,032개)과 head 층(8,060개)을 빼면 100,096개가 남고, 이것이 블록 둘과 마지막 LayerNorm 의 몫입니다
4. 학습 전 글고객: 왔셔께7?드수환개…"고객: " 뒤로 80글자를 이어 썼지만 낱말도 문장도 되지 않습니다. 가중치가 난수라 124글자를 거의 아무렇게나 고르기 때문입니다

한 줄로 줄이면, 짜임은 다 갖췄지만 아는 것은 하나도 없는 모델을 만든 실습입니다. 실습 4에서 이 모델을 대화 글로 1,500번 학습시키면 같은 "고객: " 에서 바른 문장이 나오기 시작합니다. 파라미터 120,188개는 모델의 크기를 말할 뿐이고, 숫자가 크다고 글을 잘 쓴다는 뜻은 아닙니다. 지금은 그 숫자가 모두 난수입니다.

코드 설명

  • class Block(nn.Module) 처럼 nn.Module 을 이어받아 층을 묶으면, 안에 든 모든 가중치를 model.parameters() 로 한꺼번에 옵티마이저에 넘길 수 있습니다. __init__ 에서 층을 만들고 forward 에서 순서를 정합니다.
  • forward 의 x = x + ... 두 줄이 잔차 연결입니다. 블록에 들어온 x 를 결과에 그대로 더합니다.
  • 파라미터는 120,188개입니다. 실습 1의 임베딩 표 둘(12,032개)과 블록 둘, 마지막 LayerNorm 과 head 층(64 × 124 + 124 = 8,060개)을 더한 수입니다.
  • 학습 전의 글은 "고객: 왔셔께7?드수환개…"처럼 어휘의 글자를 아무렇게나 늘어놓았습니다. 가중치가 난수라 모든 글자의 확률이 거의 같기 때문입니다.
  • generate 의 idx[:, -BLOCK:] 는 글이 64글자보다 길어지면 마지막 64글자만 넣게 합니다. 위치 임베딩 표가 64줄뿐이기 때문입니다.
← 이전 절3장 · 실습 2 — 셀프 어텐션 손으로 계산하기다음 절 →3장 · 실습 4 — 미니 GPT 학습하고 글 이어 쓰기