본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 4. 13. · 0 Views
Day 5 Baseline 모델 만들기
복잡한 모델에 앞서 가장 단순한 Baseline 모델을 직접 만들어봅니다. 아무런 기교 없이 순수하게 다음 토큰을 예측하는 모델을 구현하면서, 언어모델의 가장 기본 구조를 이해합니다.
목차
- Baseline 모델이란 무엇인가
- Embedding 테이블의 비밀
- Forward 패스와 손실 계산
- Generate 메서드와 텍스트 생성
- 전체 파이프라인 실행하기
- Bigram 모델의 한계와 극복 방향
- 모델 평가와 기준점 설정
- 학습 전 체크리스트와 다음 단계
1. Baseline 모델이란 무엇인가
김개발 씨는 어제 학습용 샘플을 만들고 나서, 드디어 모델을 만들 차례라는 사실을 깨달았습니다. 하지만 막상 코드를 작성하려니 어디서부터 시작해야 할지 막막했습니다.
"Transformer 같은 걸 바로 만들어야 하나요?"
Baseline 모델은 가장 단순한 형태의 언어모델입니다. 이전 토큰 하나만 보고 다음 토큰을 예측하는 Bigram 모델로, 복잡한 구조 없이 언어모델의 핵심인 next token prediction만 구현합니다.
마치 한 글자만 보고 다음 글자를 추측하는 것과 같습니다.
다음 코드를 살펴봅시다.
import torch
import torch.nn as nn
import torch.nn.functional as F
class BigramLanguageModel(nn.Module):
# 가장 단순한 언어모델: 이전 토큰 → 다음 토큰
def __init__(self, vocab_size):
super().__init__()
# 각 토큰이 다음 토큰의 점수를 가진 lookup 테이블
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)
def forward(self, idx, targets=None):
# idx: (B, T) 형태의 토큰 인덱스
logits = self.token_embedding_table(idx) # (B, T, C)
if targets is None:
loss = None
else:
B, T, C = logits.shape
logits = logits.view(B * T, C)
targets = targets.view(B * T)
loss = F.cross_entropy(logits, targets)
return logits, loss
def generate(self, idx, max_new_tokens):
# 가장 단순한 생성: 항상 마지막 토큰만 사용
for _ in range(max_new_tokens):
logits, _ = self(idx)
logits = logits[:, -1, :] # 마지막 시점의 로짓
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, idx_next), dim=1)
return idx
김개발 씨는 어제 학습용 샘플 데이터를 만들고 나서, 드디어 진짜 모델을 만들어야 한다는 사실을 깨달았습니다. 하지만 막상 코드 에디터를 켜고 빈 화면을 바라보니 어디서부터 시작해야 할지 전혀 감이 오지 않았습니다.
"Transformer Attention을 바로 구현해야 하나요? 아니면 뭔가 단계가 있나요?" 이때 박시니어 씨가 커피를 들고 다가왔습니다.
"처음부터 복잡한 걸 만들면 안 돼요. 가장 단순한 형태의 모델부터 만들어서 기준점을 잡아야 합니다.
그게 바로 Baseline 모델이에요." Baseline 모델이란 무엇일까요? 쉽게 비유하자면, 운동선수가 본격적인 훈련을 시작하기 전에 기록을 측정하는 것과 같습니다.
"지금 내가 어디에 있는지"를 알아야, 이후에 모델을 개선했을 때 "얼마나 발전했는지"를 비교할 수 있습니다. Baseline은 그 기준점이 됩니다.
우리가 만들 Baseline은 BigramLanguageModel이라고 부릅니다. Bigram은 "두 글자(bi-gram)"라는 뜻으로, 이전 토큰 딱 하나만 보고 다음 토큰을 예측하는 가장 단순한 방식입니다.
마치 운전 초보가 바로 앞의 차 한 대만 보고 운전하는 것과 비슷합니다. 멀리 내다보지 못하지만, 그래도 기본 원리는 같습니다.
코드를 살펴보겠습니다. 가장 핵심적인 부분은 nn.Embedding입니다.
nn.Embedding(vocab_size, vocab_size)는 vocab_size x vocab_size 크기의 테이블을 만듭니다. 이 테이블의 i번째 행은 "토큰 i 다음에 어떤 토큰이 올지"에 대한 점수(logit)를 담고 있습니다.
즉, 하나의 Embedding 테이블이 모델 전체의 역할을 대신합니다. forward 메서드를 보면, 입력 idx를 받아 Embedding 테이블에서 조회합니다.
그 결과인 logits의 형태는 (Batch, Time, Channel)이 됩니다. targets가 주어지면 cross_entropy 손실을 계산하고, 없으면 손실 없이 로짓만 반환합니다.
이 구조는 앞으로 만들 모든 모델의 기본 뼈대가 됩니다. generate 메서드는 텍스트를 생성합니다.
매 반복에서 현재 시퀀스의 마지막 토큰만 사용합니다. 왜냐하면 Bigram 모델은 이전 토큰 하나만 보기 때문입니다.
마지막 로짓에 softmax를 적용해 확률 분포를 만들고, multinomial으로 샘플링합니다. 그리고 생성된 토큰을 시퀀스 끝에 이어 붙입니다.
여기서 중요한 점이 있습니다. 아직 학습하지 않은 모델로 generate를 실행하면 어떻게 될까요?
완전한 무작위 텍스트가 나옵니다. Embedding 테이블의 값이 무작위로 초기화되어 있기 때문입니다.
마치 영어를 전혀 모르는 사람이 영어 책을 펴고 아무 글자나 적는 것과 같습니다. 하지만 이게 정상입니다.
지금은 "모델의 뼈대"를 만든 단계입니다. 다음 단계에서 이 모델을 학습시키면, 점차 의미 있는 텍스트를 생성하게 됩니다.
학습이 진행될수록 Embedding 테이블의 값이 조정되어, "h 다음에는 e가 올 확률이 높다"는 것을 학습하게 됩니다. 박시니어 씨가 말했습니다.
"이 모델은 텍스트 생성 품질은 형편없을 거예요. 하지만 중요한 건 전체 파이프라인이 정상적으로 동작하는지 확인하는 거예요.
데이터가 들어가고, 손실이 계산되고, 텍스트가 생성되는 이 전체 흐름이 맞아야 다음 단계로 나아갈 수 있어요."
실전 팁
💡 - Baseline 모델의 목적은 성능이 아니라 파이프라인 검증입니다. 정상 동작하는지 먼저 확인하세요.
- 아직 학습하지 않았으니 generate 결과가 무작위여도 당황하지 마세요. 다음 단계에서 학습시킵니다.
- 이 카드뉴스는 "LLM 바닥부터 만들기: 30일 완성 코스" 코스의 5/30편입니다
2. Embedding 테이블의 비밀
김개발 씨는 BigramLanguageModel의 코드를 보면서 한 가지 의문이 생겼습니다. "Embedding 하나로 어떻게 모델 전체가 되는 거죠?" 박시니어 씨는 칠판에 간단한 표를 그리며 설명을 시작했습니다.
Embedding 테이블은 각 토큰을 고차원 벡터로 변환하는 lookup 테이블입니다. Bigram 모델에서는 이 테이블의 각 행이 곧 다음 토큰에 대한 점수표 역할을 합니다.
마치 퀴즈 정답표처럼, 각 토큰이 다음에 올 토큰의 확률을 직접 가지고 있습니다.
다음 코드를 살펴봅시다.
import torch
import torch.nn as nn
vocab_size = 10 # 알파벳 10개라고 가정
embed = nn.Embedding(vocab_size, vocab_size)
# Embedding 테이블의 크기 확인
print(embed.weight.shape) # torch.Size([10, 10])
# 토큰 3이 주어졌을 때 다음 토큰의 점수
token_3 = torch.tensor([3])
logits = embed(token_3)
print(logits.shape) # torch.Size([1, 10])
# logits[0][7] = 토큰 3 다음에 토큰 7이 올 점수
# 학습 전: 무작위 점수
print(logits) # 랜덤한 숫자들
# 학습 후: 의미 있는 패턴이 형성됨
# 예: 토큰 h(7) → e(4)의 점수가 높아짐
김개발 씨는 코드의 한 줄이 눈에 걸렸습니다. nn.Embedding(vocab_size, vocab_size).
왜 입력 차원과 출력 차원이 같을까요? 보통 Embedding은 고차원으로 변환하는데 말입니다.
"선배님, Embedding 차원이 왜 둘 다 vocab_size인가요?" 박시니어 씨가 미소를 지었습니다. "좋은 질문이에요.
여기가 바로 Bigram 모델의 핵심이거든요." Embedding 테이블을 이해하려면 우선 그 기본 원리를 알아야 합니다. 쉽게 비유하자면, Embedding 테이블은 마치 도서관의 책 카탈로그와 같습니다.
책 번호를 입력하면 그 책의 정보(위치, 저자, 분류)가 나옵니다. 비슷하게, 토큰 번호를 입력하면 그 토큰에 해당하는 벡터가 나옵니다.
일반적으로 Embedding은 저차원(예: 토큰 ID)을 고차원(예: 256차원)으로 변환합니다. 하지만 Bigram 모델에서는 vocab_size x vocab_size 형태를 사용합니다.
왜냐하면 이 테이블이 두 가지 역할을 동시에 수행하기 때문입니다. 첫째, 토큰을 벡터로 변환하는 Embedding 역할을 합니다.
둘째, 그 벡터가 곧 다음 토큰에 대한 점수(logit)가 됩니다. vocab_size가 65라면, 65개 토큰 각각이 65차원 벡터로 표현되고, 그 65개의 값은 다음에 올 65개 토큰 각각의 점수가 됩니다.
코드를 보면 이를 명확히 확인할 수 있습니다. embed.weight.shape이 (10, 10)인 것을 알 수 있습니다.
토큰 3을 입력하면 10차원 벡터가 나오고, 이 벡터의 i번째 값이 "토큰 3 다음에 토큰 i가 올 점수"가 됩니다. 학습 전에는 이 테이블의 값이 무작위입니다.
마치 빈칸 퀴즈의 답을 아무렇게나 적어놓은 것과 같습니다. 하지만 학습이 진행되면서 이 값들이 조정됩니다.
훈련 데이터에서 "h 다음에 e가 자주 등장한다"는 패턴을 발견하면, 토큰 h의 행에서 토큰 e에 해당하는 열의 값이 점점 커집니다. 파라미터 개수를 계산해보면 놀랍게도 단순합니다.
vocab_size가 65일 때 65 x 65 = 4,225개의 파라미터뿐입니다. 현대 LLM이 수십억 개의 파라미터를 가진다는 것을 생각하면, 정말 작은 모델입니다.
하지만 이 작은 모델이 언어모델의 모든 핵심 구조를 담고 있습니다. "그럼 나중에 Attention을 추가하면 Embedding 차원이 달라지나요?" 김개발 씨가 물었습니다.
"맞아요. Attention을 추가하면 Embedding 차원과 모델 내부 차원을 분리하게 돼요.
하지만 지금은 가장 단순한 형태부터 이해하는 게 중요해요." 박시니어 씨는 마지막으로 덧붙였습니다. "Embedding 테이블은 우리 모델의 유일한 학습 가능한 파라미터예요.
즉, 학습이 진행된다는 것은 이 테이블의 숫자들이 데이터에 맞게 조정된다는 뜻이에요. 모델 학습의 본질을 이 한 테이블에서 확인할 수 있어요."
실전 팁
💡 - nn.Embedding의 weight는 학습 가능한 파라미터입니다. 학습하면서 값이 자동으로 조정됩니다.
- Bigram 모델의 파라미터는 vocab_size의 제곱입니다. vocab_size가 커질수록 파라미터가 급격히 증가합니다.
- 이 카드뉴스는 "LLM 바닥부터 만들기: 30일 완성 코스" 코스의 5/30편입니다
3. Forward 패스와 손실 계산
모델 구조를 만들었으니 이제 입력을 넣고 출력을 받아야 합니다. 김개발 씨는 forward 메서드에 targets가 있을 때와 없을 때의 동작이 다른 점을 발견했습니다.
"왜 targets를 선택적으로 받나요?"
Forward 패스는 모델에 입력을 주어 출력과 손실을 계산하는 과정입니다. 학습 시에는 입력과 정답을 함께 받아 손실을 계산하고, 생성 시에는 입력만 받아 로짓만 반환합니다.
마치 시험에서는 정답지와 비교하지만, 실전에서는 답만 적는 것과 같습니다.
다음 코드를 살펴봅시다.
import torch
import torch.nn as nn
import torch.nn.functional as F
# 모델 생성 (어제 만든 토크나이저 기반)
vocab_size = 65 # Shakespeare 문자 집합
model = BigramLanguageModel(vocab_size)
# 학습용 샘플 (어제 만든 데이터)
x = torch.randint(0, vocab_size, (4, 8)) # (B=4, T=8)
y = torch.randint(0, vocab_size, (4, 8)) # 정답
# 학습 모드: 손실 계산
logits, loss = model(x, targets=y)
print(f"Logits: {logits.shape}") # (4, 8, 65)
print(f"Loss: {loss.item():.4f}") # 약 4.17 (초기 무작위)
# 생성 모드: 손실 없이 로짓만
logits_only, _ = model(x, targets=None)
print(f"Logits only: {logits_only.shape}") # (4, 8, 65)
# 초기 손실값 확인: -ln(1/vocab_size) ≈ 4.17
import math
expected = -math.log(1.0 / vocab_size)
print(f"Expected initial loss: {expected:.4f}")
김개발 씨는 forward 메서드의 시그니처를 보고 고개를 갸웃했습니다. def forward(self, idx, targets=None).
targets에 None이 기본값으로 설정되어 있었습니다. "이건 왜 optional인 건가요?" 박시니어 씨가 설명했습니다.
"모델은 두 가지 상황에서 사용되거든요. 하나는 학습이고, 다른 하나는 생성이에요.
학습할 때는 정답이 필요하지만, 텍스트를 생성할 때는 정답이 없잖아요?" 이해가 쉬운 비유가 있습니다. 학생이 모의고사를 푸는 상황을 생각해보세요.
문제를 풀 때는 정답지가 있어야 채점을 할 수 있습니다. 이것이 학습 모드입니다.
하지만 실제 시험장에서는 정답지가 없습니다. 문제만 보고 답을 적어야 합니다.
이것이 생성 모드입니다. 코드를 실행해보면, 초기 손실값이 약 4.17로 나옵니다.
이 숫자에는 의미가 있습니다. vocab_size가 65이므로, 무작위로 예측할 때 각 토큰이 정답일 확률은 1/65입니다.
이 확률의 음의 로그를 취하면 -ln(1/65) ≈ 4.17이 됩니다. 즉, 손실이 4.17이라는 것은 "모델이 완전한 무작위로 예측하고 있다"는 뜻입니다.
이것은 학습 전 모델의 정상적인 상태입니다. 마치 운동을 시작하기 전 체력 측정에서 평균적인 수치가 나오는 것과 같습니다.
학습이 진행되면 이 손실값은 점점 줄어들어야 합니다. 손실이 4.17에서 3.0으로 떨어지면, 무작위보다는 조금 더 나은 예측을 하기 시작한 것입니다.
2.0 이하로 내려가면 의미 있는 패턴을 학습하기 시작한 것입니다. forward 메서드의 내부를 보면, logits의 shape이 (B, T, C)에서 (B*T, C)로 변환됩니다.
이것은 PyTorch의 cross_entropy 함수가 (N, C) 형태를 기대하기 때문입니다. B*T는 "전체 예측 횟수"이고, C는 "각 예측에서 선택할 수 있는 토큰 수"입니다.
"shape 변환이 헷갈리는데요." 김개발 씨가 말했습니다. "생각보다 단순해요.
(4, 8, 65)를 (32, 65)로 펴는 것뿐이에요. 배치 4개에 시퀀스 길이 8이니까, 전체 32개의 예측을 한 번에 계산하는 거죠." 박시니어 씨가 대답했습니다.
마지막으로 주의할 점이 있습니다. targets도 같은 방식으로 (B*T)로 펴야 합니다.
targets.view(B * T)가 바로 그 역할을 합니다. logits은 2차원(BT, C)이 되고, targets는 1차원(BT)이 되어서 cross_entropy에 전달됩니다.
실전 팁
💡 - 초기 손실이 -ln(1/vocab_size)와 비슷하면 모델이 정상적으로 초기화된 것입니다. 크게 다르면 버그를 의심하세요.
- view()는 텐서의 형태를 바꾸는 함수로, 데이터 복사 없이 메모리를 공유합니다. reshape()보다 효율적입니다.
- 이 카드뉴스는 "LLM 바닥부터 만들기: 30일 완성 코스" 코스의 5/30편입니다
4. Generate 메서드와 텍스트 생성
모델이 학습을 마쳤다고 가정해봅시다. 그럼 이제 새로운 토큰을 생성해야 합니다.
김개발 씨는 generate 메서드의 핵심 로직인 "마지막 토큰만 사용한다"는 부분이 이상하게 느껴졌습니다. "왜 모든 토큰을 다 안 쓰고 마지막 것만 쓰나요?"
Generate 메서드는 모델이 텍스트를 생성하는 핵심 함수입니다. 현재 시퀀스를 모델에 넣고, 마지막 위치의 로짓으로 다음 토큰을 샘플링한 뒤 시퀀스에 이어 붙이는 과정을 반복합니다.
마치 한 글자씩 타자를 쳐서 문장을 완성하는 것과 같습니다.
다음 코드를 살펴봅시다.
# generate 메서드 핵심 로직 분해
context = torch.zeros((1, 1), dtype=torch.long) # 시작 토큰 (0)
# context: [[0]] → 텐서 모양 (1, 1)
for i in range(20): # 20개 토큰 생성
# 1. 현재 시퀀스로 예측
logits, _ = model(context)
# logits: (1, T, vocab_size)
# 2. 마지막 시점의 로짓만 추출
last_logits = logits[:, -1, :]
# last_logits: (1, vocab_size)
# 3. 확률 분포로 변환
probs = F.softmax(last_logits, dim=-1)
# 4. 샘플링 (무작위 선택, 확률에 따라)
next_token = torch.multinomial(probs, num_samples=1)
# next_token: (1, 1)
# 5. 시퀀스에 이어 붙이기
context = torch.cat([context, next_token], dim=1)
# context: (1, T+1)
print(f"Generated: {context.shape}") # (1, 21)
김개발 씨가 generate 메서드를 다시 살펴보며 질문했습니다. "선배님, logits[:, -1, :]에서 왜 하필 -1을 쓰나요?
모든 토큰의 정보를 활용하면 더 좋지 않나요?" 박시니어 씨가 고개를 끄덕였습니다. "맞아요.
더 많은 문맥을 활용하면 더 좋은 예측을 할 수 있어요. 하지만 지금은 Bigram 모델이잖아요?
Bigram은 정의상 이전 토큰 하나만 봐요. 그래서 어차피 이전 토큰들의 정보는 쓸 수가 없어요." 이해하기 쉬운 비유가 있습니다.
빈칸 뚫린 문장을 완성하는 상황을 생각해보세요. "오늘 날씨가 ___ 좋네요"에서 빈칸을 채울 때, "오늘 날씨가" 전체를 읽는 것이 자연스럽습니다.
하지만 Bigram 모델은 "가"라는 마지막 글자만 보고 빈칸을 추측합니다. 그래서 성능이 제한적이지만, 구조는 훨씬 단순합니다.
코드를 단계별로 분해해보겠습니다. 첫 번째로, context = torch.zeros((1, 1))로 시작 토큰(인덱스 0)을 만듭니다.
이것은 빈 화면에 펜을 대는 것과 같습니다. 아무것도 없는 상태에서 시작하기 위한 시작점입니다.
두 번째로, 모델에 context를 넣어 logits를 얻습니다. 이때 logits의 형태는 (1, T, vocab_size)입니다.
T는 현재 시퀀스의 길이입니다. 시퀀스가 길어질수록 T도 커집니다.
세 번째로, logits[:, -1, :]로 마지막 시점의 로짓만 추출합니다. 여기서 -1은 파이썬의 음수 인덱싱으로, "마지막"을 의미합니다.
모든 배치(:)에 대해, 마지막 시점(-1)의, 모든 클래스(:) 로짓을 가져옵니다. 네 번째로, softmax로 로짓을 확률로 변환합니다.
softmax는 로짓 값을 0에서 1 사이의 확률로 변환하고, 전체 합이 1이 되도록 만듭니다. 마치 시험 점수를 백분위로 환산하는 것과 같습니다.
다섯 번째로, multinomial으로 샘플링합니다. 이것은 확률에 비례하여 무작위로 하나를 선택하는 함수입니다.
항상 가장 확률이 높은 것을 선택하는 것이 아니라, 확률이 높은 토큰이 더 자주 선택되도록 합니다. 이것이 모델이 매번 다른 텍스트를 생성하는 이유입니다.
여섯 번째로, torch.cat으로 기존 시퀀스에 새 토큰을 이어 붙입니다. 이 과정이 max_new_tokens 횟수만큼 반복되면서 텍스트가 점점 길어집니다.
마치 한 글자씩 타자를 쳐서 문장을 완성하는 것과 같습니다. "그럼 나중에 GPT 같은 모델에서는 context 전체를 사용하나요?" 김개발 씨가 물었습니다.
"정확해요. Attention 메커니즘을 추가하면 이전 모든 토큰의 정보를 활용할 수 있어요.
하지만 그때도 여전히 마지막 시점의 로짓만 사용해서 다음 토큰을 예측해요. 구조는 같고, 입력의 품질만 달라지는 거죠." 박시니어 씨는 마지막으로 이렇게 정리했습니다.
"generate 메서드는 지금 우리가 만든 Bigram에서도, 나중에 GPT에서도 동일한 구조를 사용해요. 차이점은 '어떤 정보를 바탕으로 예측하느냐'뿐이에요.
지금은 한 글자만 보고, 나중에는 전체 문맥을 보고 예측하게 될 거예요."
실전 팁
💡 - argmax 대신 multinomial을 사용하면 매번 다른 텍스트가 생성됩니다. 항상 같은 결과만 원하면 argmax를 사용하세요.
- 생성 시 시퀀스가 길어지면 메모리 사용량도 증가합니다. 나중에는 KV 캐시로 이 문제를 해결합니다.
- 이 카드뉴스는 "LLM 바닥부터 만들기: 30일 완성 코스" 코스의 5/30편입니다
5. 전체 파이프라인 실행하기
지금까지 모델 구조, Forward 패스, Generate 메서드를 각각 만들었습니다. 김개발 씨는 이제 이 모든 것을 하나로 연결해서 실제로 실행해보고 싶었습니다.
"이걸 다 합치면 어떻게 되나요?"
전체 파이프라인은 데이터 준비부터 모델 생성, 손실 계산, 텍스트 생성까지의 전체 과정을 의미합니다. 각 단계를 순서대로 연결하여, 데이터가 들어가고 텍스트가 나오는 전체 흐름을 확인합니다.
마치 공장의 조립 라인이 처음 가동되는 것과 같습니다.
다음 코드를 살펴봅시다.
import torch
import torch.nn as nn
import torch.nn.functional as F
# 1. 데이터 로드 (어제 만든 토크나이저 사용)
with open('input.txt', 'r') as f:
text = f.read()
chars = sorted(list(set(text)))
vocab_size = len(chars)
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for i, c in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: ''.join([itos[i] for i in l])
# 2. 학습 데이터 준비
data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]
# 3. 배치 생성
def get_batch(split):
data = train_data if split == 'train' else val_data
ix = torch.randint(len(data) - 8, (4,))
x = torch.stack([data[i:i+8] for i in ix])
y = torch.stack([data[i+1:i+9] for i in ix])
return x, y
# 4. 모델 생성 및 테스트
model = BigramLanguageModel(vocab_size)
xb, yb = get_batch('train')
logits, loss = model(xb, targets=yb)
print(f"Initial loss: {loss.item():.4f}")
# 5. 텍스트 생성 (학습 전)
context = torch.zeros((1, 1), dtype=torch.long)
generated = model.generate(context, max_new_tokens=100)
print(decode(generated[0].tolist()))
김개발 씨는 지금까지 만든 모든 코드를 하나의 파일에 모았습니다. 토크나이저, 데이터 준비, 모델 클래스, 배치 함수, 그리고 실행 코드까지.
"이제 실행해볼까요?" 터미널에 python train.py를 입력하고 Enter를 눌렀습니다. 파이프라인이 순서대로 실행됩니다.
먼저 텍스트 파일을 읽어 문자 집합을 만듭니다. Shakespeare 텍스트라면 약 65개의 고유 문자가 추출됩니다.
이것은 Day 3에서 만든 토크나이저와 동일한 방식입니다. 이미 익숙한 코드입니다.
다음으로 전체 텍스트를 정수 시퀀스로 인코딩합니다. 그리고 90%를 학습용, 10%를 검증용으로 분리합니다.
이것은 Day 4에서 만든 학습 샘플과 같은 원리입니다. train_data는 모델이 학습에 사용할 데이터이고, val_data는 과적합을 확인하기 위한 데이터입니다.
get_batch 함수는 배치 크기 4, 시퀀스 길이 8로 무작위 배치를 생성합니다. 여기서 x와 y는 한 칸씩 어긋나게 만들어집니다.
이것은 Day 1에서 배운 "입력과 정답을 한 칸 밀어 만드는 이유"와 정확히 일치합니다. x의 첫 토큰이 입력이면, x의 두 번째 토큰이 정답이 되는 구조입니다.
모델을 생성하고 첫 번째 배치를 넣어보면, 손실이 약 4.17로 나옵니다. 이것은 앞서 설명한 대로 완전 무작위 예측의 손실값입니다.
모든 것이 정상적으로 동작하고 있다는 증거입니다. 마지막으로 generate를 호출하면 어떤 결과가 나올까요?
학습 전이므로 완전한 무작위 텍스트가 나옵니다. "Sf kLz-xJq" 같은 알 수 없는 문자열이 출력될 것입니다.
하지만 중요한 것은 에러가 나지 않고 실행된다는 점입니다. 박시니어 씨가 이렇게 정리했습니다.
"지금 우리가 확인한 것은 전체 파이프라인이 정상 동작한다는 거예요. 데이터가 들어가고, 모델이 예측하고, 손실이 계산되고, 텍스트가 생성되요.
이 흐름이 깨지지 않아야 다음 단계로 나아갈 수 있어요." "에러가 안 나면 성공인 건가요?" 김개발 씨가 물었습니다. "에러가 안 나는 건 기본이고, 초기 손실이 예상값과 일치하는지도 확인해야 해요.
그리고 generate가 실제로 텍스트를 출력하는지도요. 이 세 가지가 모두 맞아야 파이프라인이 정상인 거예요." 이 전체 파이프라인은 앞으로 30일 동안 계속 사용할 기본 구조입니다.
모델이 복잡해져도, 이 기본 흐름은 변하지 않습니다. 데이터를 넣고, 예측하고, 손실을 계산하고, 텍스트를 생성하는 이 네 단계는 모든 언어모델의 공통된 뼈대입니다.
실전 팁
💡 - 파이프라인 실행 후 반드시 세 가지를 확인하세요: 에러 없음, 초기 손실이 예상값과 일치, generate가 텍스트 출력.
- 학습 전 generate 결과가 무작위여도 정상입니다. 이것이 학습의 시작점입니다.
- 이 카드뉴스는 "LLM 바닥부터 만들기: 30일 완성 코스" 코스의 5/30편입니다
6. Bigram 모델의 한계와 극복 방향
김개발 씨는 generate로 텍스트를 생성해보고 실망했습니다. 완전한 알 수 없는 문자열이 나왔기 때문입니다.
"이걸로는 아무것도 할 수 없는데요?" 박시니어 씨는 웃으며 말했습니다. "당연하죠.
아직 학습도 안 했고, 구조도 가장 단순한 형태니까요."
Bigram 모델의 한계는 이전 토큰 하나만 참조하기 때문에 긴 문맥을 이해할 수 없다는 점입니다. 하지만 이 한계를 인식하는 것이 다음 단계로 나아가는 출발점입니다.
마치 자전거의 한계를 알아야 오토바이의 필요성을 이해하는 것과 같습니다.
다음 코드를 살펴봅시다.
# Bigram vs 더 나은 모델의 비교
# Bigram: 이전 토큰 1개만 참조
# 입력: "I" → 예측: "am" (또는 "like", "have", ...)
# 입력: "am" → 예측: "a" (또는 "going", "not", ...)
# 문맥 무시: "I"가 문장 시작인지 중간인지 모름
# 더 나은 모델: 이전 토큰 여러 개 참조
# 입력: "I am a" → 예측: "student" (문맥 파악)
# 입력: "I am not" → 예측: "sure" (문맥 파악)
# 문맥 활용: 앞의 전체 문맥을 고려
# Bigram의 핵심 문제 코드
# logits = logits[:, -1, :] # 항상 마지막 하나만!
# 이 한 줄이 Bigram의 본질적인 한계
# 극복을 위한 단계별 로드맵
steps = [
"1. Baseline: Bigram (현재 - 토큰 1개 참조)",
"2. Positional Encoding: 위치 정보 추가",
"3. Self-Attention: 모든 이전 토큰 참조",
"4. Multi-Head Attention: 다양한 관점에서 참조",
"5. Transformer Block: Attention + FFN 결합",
]
for step in steps:
print(step)
김개발 씨는 generate 결과를 보고 입을 다물지 못했습니다. 화면에 출력된 것은 "Sf kLz-xJq!pWmNr" 같은 완전한 무작위 문자열이었습니다.
"이걸로 셰익스피어를 쓸 수 있다고요?" 박시니어 씨가 웃으며 설명했습니다. "지금 두 가지 문제가 있어요.
첫째, 아직 학습을 안 했어요. 둘째, 모델 구조가 가장 단순한 형태예요.
이 두 가지를 차근차근 해결해 나갈 거예요." Bigram 모델의 가장 큰 한계는 문맥을 볼 수 없다는 점입니다. 예를 들어 "the cat sat on the" 다음에 올 단어를 예측할 때, 사람은 "mat"이나 "floor"를 예상합니다.
왜냐하면 "the cat sat on the"라는 전체 문맥을 이해하기 때문입니다. 하지만 Bigram 모델은 "the"라는 마지막 단어만 봅니다.
"the" 다음에는 명사가 올 확률이 높다는 것 정도는 알 수 있지만, 고양이가 앉아 있다는 상황까지는 파악할 수 없습니다. 이것을 비유하자면, 영화를 한 프레임씩만 보는 것과 같습니다.
이전 프레임은 전혀 보지 못하고, 지금 프레임만 보고 다음 프레임을 예측해야 합니다. 추격 신면인지 로맨스 신면인지 알 수 없으니, 다음 장면을 제대로 예측할 수 없습니다.
하지만 이 한계는 단점이 아니라 다음 발전의 방향을 알려주는 나침반이 됩니다. Bigram이 안 되는 이유를 정확히 알면, 무엇을 추가해야 하는지도 명확해집니다.
로드맵은 다음과 같습니다. 첫 번째 단계는 Positional Encoding입니다.
현재 모델은 토큰의 위치 정보를 모릅니다. "I love you"와 "you love I"가 같은 입력으로 처리됩니다.
위치 정보를 추가하면 토큰의 순서를 구별할 수 있습니다. 두 번째 단계는 Self-Attention입니다.
이것은 모든 이전 토큰의 정보를 종합하여 예측에 활용하는 메커니즘입니다. 마치 독자가 문장을 읽을 때 앞부분의 내용을 기억하면서 뒷부분을 이해하는 것과 같습니다.
Attention이 추가되면 모델이 비로소 문맥을 이해할 수 있습니다. 세 번째 단계는 Multi-Head Attention과 Transformer Block입니다.
여러 관점에서 문맥을 분석하고, 이를 비선형 변환과 결합하여 더 풍부한 표현을 만듭니다. 이것이 바로 GPT의 핵심 아키텍처입니다.
김개발 씨는 로드맵을 보며 눈을 반짝였습니다. "그러면 지금 만든 Bigram은 이 모든 것의 기초가 되는 건가요?" "정확해요.
우리가 지금 만든 이 간단한 구조에 하나씩 기능을 추가하면, 최종적으로 GPT에 도달해요. 기초가 탄탄해야 나중에 복잡한 구조도 이해할 수 있어요." 박시니어 씨는 마지막으로 강조했습니다.
"지금 당장 완벽한 결과를 기대하지 마세요. 중요한 건 '왜 이 모델은 부족한가'를 이해하는 거예요.
그 이해가 다음 단계에서 왜 Attention이 필요한지를 설명해줄 거예요."
실전 팁
💡 - Bigram의 한계를 먼저 체감해야 Attention의 필요성을 이해할 수 있습니다. 건너뛰지 마세요.
- 모델 개선은 한 번에 하나씩 추가하는 것이 좋습니다. 여러 기능을 동시에 추가하면 문제가 생겼을 때 원인을 찾기 어렵습니다.
- 이 카드뉴스는 "LLM 바닥부터 만들기: 30일 완성 코스" 코스의 5/30편입니다
7. 모델 평가와 기준점 설정
파이프라인이 정상 동작하는 것을 확인한 김개발 씨에게, 박시니어 씨가 다음 과제를 주었습니다. "이제 학습 전 모델의 성능을 측정해보세요.
나중에 개선한 모델과 비교하려면 지금의 점수를 기록해둬야 합니다."
모델 평가는 현재 모델의 성능을 수치로 측정하는 과정입니다. 학습 전의 손실값과 생성 품질을 기록해두면, 이후 모델을 개선했을 때 얼마나 발전했는지 객관적으로 비교할 수 있습니다.
마치 다이어트 전 체중을 재두는 것과 같습니다.
다음 코드를 살펴봅시다.
import torch
@torch.no_grad() # 평가 시에는 기울기 계산 불필요
def estimate_loss(model, eval_iters=100):
results = {}
for split in ['train', 'val']:
losses = torch.zeros(eval_iters)
for k in range(eval_iters):
x, y = get_batch(split)
logits, loss = model(x, targets=y)
losses[k] = loss.item()
results[split] = losses.mean().item()
return results
# 학습 전 기준점 측정
baseline = estimate_loss(model)
print(f"Train loss: {baseline['train']:.4f}")
print(f"Val loss: {baseline['val']:.4f}")
# 예상 결과 (vocab_size=65):
# Train loss: ~4.17
# Val loss: ~4.17
# 둘 다 비슷하면 과적합이 아님 (정상)
# 학습 후 비교용 저장
import json
with open('baseline_metrics.json', 'w') as f:
json.dump(baseline, f)
박시니어 씨가 새로운 함수 estimate_loss를 화면에 띄워주었습니다. "@torch.no_grad()라는 데코레이터가 눈에 띄었습니다.
"이건 왜 필요한 거예요?" 김개발 씨가 물었습니다. "@torch.no_grad()는 PyTorch에게 '기울기(gradient)를 계산하지 마'라고 알려주는 거예요.
학습이 아니라 평가만 할 때 기울기는 불필요하거든요. 이걸 쓰면 메모리도 절약되고 속도도 빨라져요." 이해하기 쉬운 비유가 있습니다.
학생이 모의고사를 다시 푸는 상황을 생각해보세요. 채점을 하려면 정답지가 필요합니다.
하지만 단순히 점수를 확인하는 용도라면, 매번 풀이 과정을 적필할 필요가 없습니다. 답만 체크하면 되니까 더 빠르게 여러 번 풀어볼 수 있습니다.
estimate_loss 함수는 train과 val 데이터에 대해 각각 100번 배치를 뽑아 손실을 측정합니다. 100번 측정한 손실의 평균을 구하면, 단일 배치의 손실보다 훨씬 안정적인 추정값을 얻을 수 있습니다.
마치 혈압을 한 번만 재는 것보다 하루 세 번 재서 평균을 내는 것이 더 정확한 것과 같습니다. 학습 전 모델의 손실은 train과 val에서 거의 같아야 합니다.
둘 다 약 4.17이 나올 것입니다. 이것은 모델이 아직 아무것도 학습하지 않았기 때문에, train 데이터와 val 데이터에 대한 예측 능력이 같다는 뜻입니다.
이것이 정상입니다. 만약 val 손실이 train 손실보다 현저히 높다면 무엇을 의미할까요?
그것은 **과적합(overfitting)**을 의미합니다. 모델이 train 데이터에만 맞춰져서, 새로운 데이터에 대해서는 오히려 예측을 못 하는 상태입니다.
하지만 학습 전에는 이런 현상이 발생하지 않습니다. 박시니어 씨가 말했습니다.
"이 baseline_metrics.json 파일이 우리의 출발점이에요. 나중에 Attention을 추가하고, 더 깊은 레이어를 쌓고, 학습을 오래 한 뒤에 다시 손실을 측정해보세요.
그때 이 기준점과 비교하면, 우리가 얼마나 발전했는지 한눈에 알 수 있어요." "기준점을 안 잡으면 어떻게 되나요?" 김개발 씨가 물었습니다. "모델이 나아지고 있는지 퇴보하고 있는지 알 수 없어요.
손실이 4.17에서 3.5로 내려갔을 때 '많이 나아졌다'고 말하려면, 시작점이 4.17이었다는 것을 기록해둬야 하잖아요." 실무에서도 이 원칙은 동일합니다. 새로운 아키텍처를 제안할 때 반드시 baseline과 비교합니다.
"우리 모델이 성능이 좋다"는 주장은 "baseline보다 성능이 좋다"는 주장이어야 설득력이 있습니다. 기준점 없는 발전은 증명할 수 없습니다.
이 기준점은 다음 카드뉴스에서 학습 루프를 구현할 때도 사용됩니다. 학습이 진행되면서 손실이 어떻게 변하는지, train과 val의 간격이 벌어지지 않는지를 이 baseline과 비교하면서 모니터링할 것입니다.
실전 팁
💡 - @torch.no_grad()는 평가 시 반드시 사용하세요. 메모리를 절약하고 평가 속도를 높여줍니다.
- eval_iters를 너무 작게 하면 측정값이 불안정하고, 너무 크게 하면 시간이 오래 걸립니다. 100~500 정도가 적당합니다.
- 이 카드뉴스는 "LLM 바닥부터 만들기: 30일 완성 코스" 코스의 5/30편입니다
8. 학습 전 체크리스트와 다음 단계
드디어 Baseline 모델의 구현이 끝났습니다. 김개발 씨는 커밋을 하기 전에 박시니어 씨와 함께 체크리스트를 확인했습니다.
"모델이 제대로 만들어졌는지 확인하는 과정이 중요해요. 지금 넘어가면 나중에 더 큰 문제가 돼요."
학습 전 체크리스트는 모델 학습을 시작하기 전에 반드시 확인해야 할 항목들입니다. 파이프라인 동작, 초기 손실값, 파라미터 수, 생성 동작 등을 점검하여 문제를 조기에 발견합니다.
마치 자동차 출발 전 점검과 같습니다.
다음 코드를 살펴봅시다.
# 학습 전 체크리스트 (반드시 모두 통과해야 함)
# 1. 파이프라인 동작 확인
model = BigramLanguageModel(vocab_size)
xb, yb = get_batch('train')
logits, loss = model(xb, targets=yb)
assert logits.shape == (4, 8, vocab_size), "Logits shape mismatch"
assert loss is not None, "Loss is None"
print("[PASS] Forward pass works")
# 2. 초기 손실값 확인
import math
expected_loss = -math.log(1.0 / vocab_size)
assert abs(loss.item() - expected_loss) < 0.5, "Initial loss unexpected"
print(f"[PASS] Initial loss: {loss.item():.2f} (expected ~{expected_loss:.2f})")
# 3. 파라미터 수 확인
total_params = sum(p.numel() for p in model.parameters())
print(f"[INFO] Total parameters: {total_params:,}")
# Bigram: vocab_size^2 = 65^2 = 4,225
# 4. Generate 동작 확인
context = torch.zeros((1, 1), dtype=torch.long)
generated = model.generate(context, max_new_tokens=50)
assert generated.shape == (1, 51), "Generate shape mismatch"
print(f"[PASS] Generate works: {generated.shape}")
# 5. 평가 함수 동작 확인
baseline = estimate_loss(model)
assert 'train' in baseline and 'val' in baseline
print(f"[PASS] Eval works: train={baseline['train']:.2f}, val={baseline['val']:.2f}")
# 모두 통과하면 학습 준비 완료!
print("\nAll checks passed. Ready for training!")
김개발 씨는 체크리스트 코드를 실행하며 하나씩 확인했습니다. 첫 번째 항목인 Forward 패스 통과.
두 번째 항목인 초기 손실값 검증. 세 번째 항목인 파라미터 수 확인.
네 번째 항목인 Generate 동작. 다섯 번째 항목인 평가 함수.
이 체크리스트는 왜 필요할까요? 쉽게 비유하자면, 자동차 여행을 떠나기 전에 타이어 공기압, 엔진 오일, 브레이크를 점검하는 것과 같습니다.
출발하기 전에 문제를 발견하면 고치면 되지만, 고속도로 한가운데서 엔진이 멈추면 큰일입니다. 코딩도 마찬가지입니다.
학습을 시작하기 전에 버그를 찾으면 고치기 쉽지만, 학습이 진행된 후에 구조적 문제를 발견하면 처음부터 다시 해야 할 수 있습니다. 각 항목이 확인하는 것을 구체적으로 살펴보겠습니다.
첫 번째 항목은 logits의 shape이 예상대로 (B, T, C)인지 확인합니다. shape이 틀리면 모델 구조에 문제가 있는 것입니다.
두 번째 항목은 초기 손실이 예상값과 비슷한지 확인합니다. 크게 다르면 데이터나 모델에 버그가 있을 수 있습니다.
세 번째 항목은 파라미터 수를 확인합니다. Bigram 모델의 파라미터는 vocab_size의 제곱입니다.
vocab_size가 65이면 4,225개입니다. 이 숫자가 맞지 않으면 Embedding 설정에 문제가 있는 것입니다.
네 번째 항목은 generate가 정상적으로 작동하는지 확인합니다. 마지막 항목은 평가 함수가 train과 val 모두에서 동작하는지 확인합니다.
"실무에서 이렇게까지 체크하나요?" 김개발 씨가 물었습니다. "당연하죠.
특히 딥러닝에서는 더 중요해요. 학습은 시간이 오래 걸리거든요.
1시간을 학습시키고 나서 버그를 발견하면, 수정하고 다시 1시간을 학습해야 해요. 체크리스트 5분이 1시간을 아껴주는 셈이에요." 모든 항목이 통과되면, 드디어 학습 준비가 완료됩니다.
지금까지 우리가 한 것을 정리하면 다음과 같습니다. Day 1에서 언어모델의 목표를 이해하고, Day 3에서 토크나이저를 만들고, Day 4에서 학습 데이터를 준비하고, 오늘 Day 5에서 Baseline 모델을 구현했습니다.
다음 단계는 무엇일까요? 바로 학습 루프입니다.
모델에 데이터를 계속 넣어주면서 파라미터를 업데이트하는 과정입니다. 옵티마이저를 설정하고, 에포크를 반복하면서 손실이 줄어드는 것을 확인할 것입니다.
이것이 바로 다음 카드뉴스의 주제입니다. 박시니어 씨가 마지막으로 말했습니다.
"지금까지 5일 동안 재료를 준비했어요. 내일부터가 진짜 요리 시작이에요.
학습 루프를 돌리면 이 무작위 텍스트가 조금씩 의미 있는 텍스트로 변해갈 거예요. 그 순간을 직접 경험해보세요."
실전 팁
💡 - 학습 전 체크리스트를 습관화하세요. 5분의 투자가 수시간의 디버깅을 아껴줍니다.
- 각 체크리스트 항목이 "왜 필요한지"를 이해하면, 새로운 모델을 만들 때 스스로 체크리스트를 작성할 수 있습니다.
- 다음 카드뉴스에서는 학습 루프를 구현하여 모델을 실제로 학습시켜봅니다. 지금까지 만든 모든 것이 그때 빛을 발합니다.
- 이 카드뉴스는 "LLM 바닥부터 만들기: 30일 완성 코스" 코스의 5/30편입니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
프레임워크 선택 LangGraph vs CrewAI vs AutoGen 완벽 가이드
AI 에이전트 개발을 위한 세 가지 핵심 프레임워크를 비교 분석합니다. 각 프레임워크의 특징, 장단점, 실무 선택 기준을 초급 개발자도 이해할 수 있도록 설명합니다.
Day 6 학습 루프 이해하기
LLM이 실제로 어떻게 학습하는지 학습 루프의 핵심 원리를 단계별로 살펴봅니다. Forward Pass, Loss 계산, Backward Pass, 파라미터 업데이트까지 한 사이클의 전 과정을 이해합니다.
Day 4 학습용 샘플 데이터 만들기
LLM을 학습시키기 위한 샘플 데이터를 직접 만들어봅니다. 작은 텍스트 말뭉치를 준비하고, 토크나이저로 변환한 뒤 PyTorch 텐서로 만드는 전체 과정을 단계별로 배웁니다.
Day 2 PyTorch 기본기 정리
LLM을 직접 만들기 위해 꼭 알아야 할 PyTorch의 핵심 개념을 정리합니다. 텐서, 자동 미분, 옵티마이저까지 모델 학습의 기초를 다집니다.
LLM 핵심 원리 함수 호출 환각 임베딩 완벽 가이드
LLM의 세 가지 핵심 개념인 함수 호출(Function Calling), 환각(Hallucination), 임베딩(Embedding)을 중급 개발자 관점에서 실무 중심으로 설명합니다. 에이전트 AI 엔지니어가 반드시 알아야 할 원리와 실전 팁을 담았습니다.