문자 단위 Bigram 언어 모델 만들기
가장 작은 언어 모델에서 시작하기
드디어 첫 번째 언어 모델을 만들 차례입니다. 지금부터 만드는 모델은 아주 작습니다. 너무 작아서 처음 보면 "이걸 모델이라고 불러도 될까?" 싶을 정도입니다. 하지만 이 작은 모델 안에는 앞으로 만들 Mini GPT의 핵심 질문이 이미 들어 있습니다.
지금까지의 입력을 보고, 다음 토큰을 예측한다.
이번 장에서는 토큰을 문자 하나로 두겠습니다. 따라서 문제는 이렇게 바뀝니다.
지금까지의 글자를 보고, 다음 글자를 예측한다.그중에서도 가장 단순한 형태인 Bigram 언어 모델을 만들겠습니다. Bigram은 두 개의 연속된 항목을 뜻합니다. 문자 단위 Bigram 모델은 현재 문자 하나와 다음 문자 하나의 관계만 봅니다.
예를 들어 학습 데이터에 다음 문장이 있다고 합시다.
언어 모델은 다음 글자를 예측한다.문자 단위로 보면 모델은 이런 관계를 배웁니다.
'언' 다음에는 '어'가 자주 온다.
'어' 다음에는 ' '가 자주 온다.
'모' 다음에는 '델'이 자주 온다.
'다' 다음에는 '.' 또는 '음' 같은 문자가 올 수 있다.Bigram 모델은 여기서 더 멀리 가지 않습니다. 언어 모델은 전체를 보지 않습니다. 모델은 다음 같은 긴 문맥도 보지 않습니다. 오직 현재 문자 하나만 봅니다.
현재 문자 -> 다음 문자이 단순함이 Bigram 모델의 약점이자 장점입니다. 약점은 긴 문맥을 이해하지 못한다는 것입니다. 장점은 언어 모델링의 뼈대를 아주 선명하게 볼 수 있다는 것입니다.

Bigram 가정: 지금 문자 하나면 충분하다고 치자
사람이 글을 읽을 때는 앞뒤 문맥을 함께 봅니다. 예를 들어 다음 문장을 봅시다.
나는 작은 언어 모델을 만든다.사람은 나는 작은 언어까지 읽으면 다음에 모델이 나올 가능성이 높다고 추측할 수 있습니다. 그런데 Bigram 모델은 그렇게 하지 못합니다. 만약 현재 문자가 어라면, 모델은 오직 어만 보고 다음 문자를 예측합니다.
입력: 어
출력 후보: 공백, 모, 는, 떤, ...Bigram 모델의 세계에서는 어 앞에 언이 있었는지, 그 앞에 나는 작은이 있었는지는 사라집니다. 그래서 Bigram 모델은 다음과 같은 질문만 답합니다.
이 문자 다음에는 어떤 문자가 자주 나왔는가?이것을 조금 더 수식처럼 쓰면 다음과 같습니다.
P(다음 문자 | 현재 문자)여기서 P는 확률입니다. 즉 "현재 문자가 주어졌을 때 다음 문자가 무엇일 확률"을 뜻합니다. 예를 들어 학습 데이터에서 모 다음에 거의 항상 델이 나왔다면, 모델은 모 다음의 델 확률을 높게 잡습니다.
P('델' | '모') = 높음
P('가' | '모') = 낮음여기까지는 아직 신경망이 필요 없어 보입니다. 실제로 가장 단순한 Bigram 모델은 확률표 하나로 만들 수 있습니다.
확률 표로 만드는 Bigram 모델
먼저 신경망 없이 생각해 봅시다. 문자 집합의 크기가 vocab_size라고 하겠습니다. 말뭉치에 서로 다른 문자가 50개 등장했다면 vocab_size = 50입니다.
Bigram 확률표는 다음과 같은 표입니다.
행: 현재 문자
열: 다음 문자
값: 현재 문자 다음에 다음 문자가 나올 점수 또는 확률예를 들어 문자 집합이 아주 작아서 [' ', '.', '나', '는', '다']만 있다고 해 봅시다. 그러면 표는 이런 모양이 됩니다.
| 현재 문자 | 다음이 공백 | 다음이 . | 다음이 나 | 다음이 는 | 다음이 다 |
|---|---|---|---|---|---|
| 공백 | 0.1 | 0.0 | 0.5 | 0.0 | 0.4 |
| . | 0.0 | 0.0 | 0.8 | 0.0 | 0.2 |
| 나 | 0.0 | 0.0 | 0.0 | 0.9 | 0.1 |
| 는 | 0.7 | 0.1 | 0.0 | 0.0 | 0.2 |
| 다 | 0.0 | 0.8 | 0.1 | 0.0 | 0.1 |
이 표에서 현재 문자가 나라면 나 행을 봅니다. 그 행에서 가장 확률이 높은 문자가 는이면, 모델은 나 다음에 는이 올 가능성이 높다고 판단합니다.
이 방식은 매우 직관적입니다. 실제로 데이터에서 문자 쌍을 세면 됩니다.
'나' 다음에 '는'이 몇 번 나왔는가?
'나' 다음에 '다'가 몇 번 나왔는가?
'모' 다음에 '델'이 몇 번 나왔는가?이렇게 세어서 표를 만들고, 각 행의 합이 1이 되도록 나누면 확률표가 됩니다.
하지만 이번 장에서는 확률표를 직접 세는 방식에서 한 걸음 더 나아가겠습니다. 신경망 기반 Bigram 모델을 만들 것입니다. 그래도 본질은 같습니다. 모델 안에는 여전히 "현재 문자별 다음 문자 점수표"가 들어 있습니다.
신경망 Bigram은 무엇이 다를까
확률 표 기반 Bigram과 신경망 기반 Bigram은 겉보기에는 비슷합니다. 둘 다 현재 문자 하나를 보고 다음 문자를 예측합니다. 차이는 표를 만드는 방식에 있습니다.
확률 표 방식에서는 사람이 직접 횟수를 셉니다.
데이터에서 문자 쌍을 센다.
각 행을 확률로 정규화한다.
그 확률로 다음 문자를 뽑는다.신경망 방식에서는 표의 값을 처음에는 무작위로 시작합니다. 그리고 예측이 틀릴 때마다 손실을 계산하고, 경사하강법으로 표의 값을 조금씩 고칩니다.
처음에는 아무 점수나 가진다.
입력을 넣고 다음 문자 점수를 출력한다.
정답과 비교해 loss를 계산한다.
optimizer가 점수를 조금씩 수정한다.이때 신경망 Bigram 모델의 핵심 부품은 놀랍게도 하나입니다.
nn.Embedding(vocab_size, vocab_size)앞에서 임베딩은 토큰 ID를 벡터로 바꾸는 표라고 배웠습니다. 보통 임베딩은 토큰을 의미 벡터로 바꾸는 데 사용합니다. 그런데 이번에는 조금 특이하게 사용합니다.
입력 문자 ID -> 다음 문자 후보 전체에 대한 점수 벡터만약 vocab_size = 50이라면, 임베딩 테이블은 50 × 50 크기의 표가 됩니다. 현재 문자 ID 하나를 넣으면 길이 50짜리 벡터가 나옵니다. 이 벡터의 각 값은 다음 문자 후보에 대한 점수입니다.
이 점수를 logits라고 부릅니다. logits는 아직 확률이 되기 전의 원시 점수입니다. 어떤 값은 양수일 수 있고, 어떤 값은 음수일 수 있습니다. 합이 1일 필요도 없습니다. 나중에 softmax를 적용하면 확률처럼 해석할 수 있습니다.
현재 문자 ID -> embedding table의 한 행 선택 -> logits이제 코드로 만들어 보겠습니다.
프로젝트 파일 만들기
이전 장에서 src/data.py를 만들었습니다. 이번 장에서는 여기에 있는 CharTokenizer, load_text, train_val_split, get_batch를 그대로 사용합니다.
새 파일을 하나 만듭니다.
first-llm/
├── data/
│ └── tiny_corpus.txt
├── src/
│ ├── data.py
│ └── bigram.py
└── notebooks/src/bigram.py를 만들고 아래 코드부터 작성합니다.
# src/bigram.py
import torch
import torch.nn as nn
import torch.nn.functional as F
from data import CharTokenizer, get_batch, load_text, train_val_split여기서 torch.nn은 신경망 레이어를 만들 때 사용합니다. torch.nn.functional은 손실 함수나 softmax 같은 함수를 사용할 때 자주 씁니다.
BigramLanguageModel 클래스 만들기
이제 모델 클래스를 만들겠습니다.
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size: int):
super().__init__()
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)
def forward(self, idx, targets=None):
logits = self.token_embedding_table(idx)
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짧은 코드지만 중요한 내용이 많습니다. 하나씩 보겠습니다.
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)이 한 줄이 Bigram 모델의 전부에 가깝습니다. 입력 문자의 ID를 넣으면, 그 문자 다음에 올 수 있는 모든 문자에 대한 점수 벡터가 나옵니다.
예를 들어 입력 idx의 모양이 다음과 같다고 합시다.
idx.shape = (B, T)여기서 B는 batch size, T는 block size입니다. 이전 장에서 batch_size = 4, block_size = 8을 사용했다면 idx.shape는 (4, 8)입니다.
이 입력을 임베딩 테이블에 넣으면 출력은 다음 모양이 됩니다.
logits.shape = (B, T, C)여기서 C는 vocab_size입니다. 즉 각 위치마다 다음 문자 후보 전체에 대한 점수가 나옵니다.
입력 4개 묶음 × 각 묶음의 8개 위치 × 다음 문자 후보 수중요한 점은 Bigram 모델이 T개의 문맥을 모두 보는 것이 아니라는 것입니다. 입력 모양은 (B, T)이지만, 각 위치의 예측은 그 위치의 문자 하나만 보고 이루어집니다.
x[b, t] 하나 -> logits[b, t, :] 하나예를 들어 x가 다음과 같다고 합시다.
언어 모델Bigram 모델은 언어 모델 전체를 보고 다음 문자를 예측하지 않습니다. 각 위치를 따로 봅니다.
'언' -> 다음 문자 예측
'어' -> 다음 문자 예측
' ' -> 다음 문자 예측
'모' -> 다음 문자 예측
'델' -> 다음 문자 예측그래서 Bigram 모델은 block_size가 커져도 더 긴 문맥을 활용하지 못합니다. 이 한계는 뒤에서 다시 확인하겠습니다.
cross-entropy loss 계산하기
모델은 다음 문자 후보마다 점수를 냅니다. 하지만 학습하려면 이 점수가 정답과 얼마나 다른지 숫자로 계산해야 합니다. 이때 사용하는 대표적인 손실 함수가 cross-entropy loss입니다.
cross-entropy는 분류 문제에서 자주 쓰는 손실 함수입니다. 쉽게 말하면 이런 일을 합니다.
정답 문자에 높은 점수를 주면 loss가 작아진다.
정답이 아닌 문자에 높은 점수를 주면 loss가 커진다.우리 문제는 매 위치마다 다음 문자 하나를 맞히는 분류 문제입니다. 후보 클래스는 전체 문자 집합입니다. 따라서 문자 집합 크기가 50이라면, 매 위치마다 50개 후보 중 정답 하나를 맞히는 문제를 푸는 셈입니다.
PyTorch의 F.cross_entropy는 입력으로 보통 다음 모양을 기대합니다.
logits.shape = (N, C)
targets.shape = (N,)여기서 N은 예측 문제의 개수이고, C는 클래스 수입니다.
그런데 우리 모델의 출력은 처음에 다음 모양입니다.
logits.shape = (B, T, C)
targets.shape = (B, T)그래서 B와 T를 하나로 합쳐 줍니다.
B, T, C = logits.shape
logits = logits.view(B * T, C)
targets = targets.view(B * T)
loss = F.cross_entropy(logits, targets)예를 들어 B = 4, T = 8이면 한 번에 총 32개의 다음 문자 예측 문제를 푸는 것입니다.
B × T = 4 × 8 = 32이 32개 문제의 평균 손실이 하나의 loss가 됩니다. 학습은 이 loss를 줄이는 방향으로 진행됩니다.
generate 메서드 추가하기
언어 모델은 학습만 하는 것이 아니라 텍스트를 생성할 수 있어야 합니다. 생성 과정은 앞에서 배운 "다음 토큰 예측"을 반복하는 과정입니다.
현재까지의 문자들을 넣는다.
다음 문자 확률을 구한다.
그중 하나를 뽑는다.
뽑은 문자를 뒤에 붙인다.
다시 반복한다.BigramLanguageModel 클래스에 generate 메서드를 추가하겠습니다.
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size: int):
super().__init__()
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)
def forward(self, idx, targets=None):
logits = self.token_embedding_table(idx)
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: int):
for _ in range(max_new_tokens):
logits, loss = 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생성 코드를 천천히 보겠습니다.
logits, loss = self(idx)현재까지의 문자 ID들을 모델에 넣어 다음 문자 점수를 계산합니다. Bigram 모델은 사실 마지막 문자 하나만 있으면 충분합니다. 하지만 인터페이스를 앞으로 만들 GPT와 비슷하게 맞추기 위해 전체 idx를 넣고, 그중 마지막 위치의 예측만 사용합니다.
logits = logits[:, -1, :]이 줄은 마지막 위치의 logits만 꺼냅니다. 모양은 다음과 같이 바뀝니다.
(B, T, C) -> (B, C)이제 각 배치마다 다음 문자 후보 전체에 대한 점수만 남았습니다.
probs = F.softmax(logits, dim=-1)softmax는 logits를 확률 분포로 바꿉니다. 가장 점수가 높은 문자는 높은 확률을 얻고, 점수가 낮은 문자는 낮은 확률을 얻습니다. 모든 후보 확률의 합은 1이 됩니다.
idx_next = torch.multinomial(probs, num_samples=1)torch.multinomial은 확률에 따라 하나를 샘플링합니다. 항상 가장 높은 확률의 문자를 고르는 것이 아니라, 확률적으로 하나를 뽑습니다. 그래서 같은 모델이라도 실행할 때마다 조금 다른 문장이 나올 수 있습니다.
idx = torch.cat((idx, idx_next), dim=1)뽑은 다음 문자를 기존 입력 뒤에 붙입니다. 그리고 이 과정을 max_new_tokens번 반복합니다.
학습 전 모델 출력 보기
이제 모델을 학습하기 전에 한 번 생성해 보겠습니다. 아직 아무것도 배우지 않았으므로 출력은 거의 무작위에 가깝습니다.
src/bigram.py 아래쪽에 다음 코드를 추가합니다.
if __name__ == "__main__":
torch.manual_seed(42)
text = load_text("data/tiny_corpus.txt")
tokenizer = CharTokenizer(text)
data = torch.tensor(tokenizer.encode(text), dtype=torch.long)
train_data, val_data = train_val_split(data)
vocab_size = tokenizer.vocab_size
model = BigramLanguageModel(vocab_size)
start = torch.zeros((1, 1), dtype=torch.long)
generated = model.generate(start, max_new_tokens=200)
print(tokenizer.decode(generated[0].tolist()))실행해 봅니다.
python src/bigram.py출력은 실행 환경에 따라 다르지만, 대체로 이런 느낌일 것입니다.
은패측나큰배좋델
는을처험,델처쁜자...아직 문장이라고 부르기 어렵습니다. 글자들은 말뭉치에 등장한 문자들이지만, 순서가 엉망입니다. 당연합니다. 지금 모델의 임베딩 테이블은 무작위 값으로 초기화되어 있습니다. 즉 현재 문자 다음에 어떤 문자가 올지 아무 근거 없이 찍고 있습니다.
이제 이 모델을 학습시켜 보겠습니다.
학습 루프 만들기
학습 과정은 크게 네 단계입니다.
배치를 가져옵니다.
모델이 logits와 loss를 계산합니다.
loss를 기준으로 역전파를 수행합니다.
optimizer가 모델 파라미터를 업데이트합니다.
코드로는 다음과 같습니다.
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
for step in range(1000):
xb, yb = get_batch(train_data, batch_size=32, block_size=8)
logits, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
if step % 100 == 0:
print(f"step {step}: loss {loss.item():.4f}")여기서 AdamW는 자주 쓰이는 optimizer입니다. optimizer는 loss가 작아지는 방향으로 모델의 파라미터를 조금씩 바꾸는 역할을 합니다.
학습 루프의 핵심은 이 세 줄입니다.
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()먼저 이전 단계에서 남아 있는 gradient를 비웁니다. 그다음 loss.backward()로 각 파라미터가 loss에 얼마나 영향을 미쳤는지 계산합니다. 마지막으로 optimizer.step()이 그 정보를 사용해 파라미터 값을 갱신합니다.
Bigram 모델에서 학습되는 파라미터는 하나뿐입니다.
model.token_embedding_table.weight이 가중치의 모양은 다음과 같습니다.
(vocab_size, vocab_size)즉 모델은 현재 문자별 다음 문자 점수표를 학습하고 있습니다.
검증 손실도 함께 보기
학습 중 loss가 낮아지는 것은 좋습니다. 하지만 학습 데이터에서만 낮아지는지, 검증 데이터에서도 낮아지는지 확인하는 습관이 중요합니다.
간단한 평가 함수를 만들겠습니다.
@torch.no_grad()
def estimate_loss(model, train_data, val_data, batch_size, block_size, eval_iters=20):
out = {}
model.eval()
for split, source in [("train", train_data), ("val", val_data)]:
losses = torch.zeros(eval_iters)
for k in range(eval_iters):
xb, yb = get_batch(source, batch_size, block_size)
logits, loss = model(xb, yb)
losses[k] = loss.item()
out[split] = losses.mean().item()
model.train()
return out@torch.no_grad()는 평가 중에는 gradient를 계산하지 않겠다는 뜻입니다. 평가할 때는 파라미터를 업데이트하지 않으므로 gradient가 필요 없습니다. 이렇게 하면 메모리를 조금 덜 쓰고 계산도 가벼워집니다.
이제 학습 루프에 평가를 넣어 보겠습니다.
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
for step in range(1000):
if step % 100 == 0:
losses = estimate_loss(
model,
train_data,
val_data,
batch_size=32,
block_size=8,
)
print(
f"step {step}: "
f"train loss {losses['train']:.4f}, "
f"val loss {losses['val']:.4f}"
)
xb, yb = get_batch(train_data, batch_size=32, block_size=8)
logits, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()학습을 시작하면 처음에는 loss가 높게 나오다가 조금씩 내려갈 것입니다. 정확한 숫자는 말뭉치와 실행 환경에 따라 달라집니다. 중요한 것은 loss가 "정답 다음 문자에 더 높은 점수를 주는 방향"으로 모델이 바뀌고 있다는 신호라는 점입니다.
전체 코드 정리하기
지금까지 만든 내용을 src/bigram.py 하나로 정리하면 다음과 같습니다.
# src/bigram.py
import torch
import torch.nn as nn
import torch.nn.functional as F
from data import CharTokenizer, get_batch, load_text, train_val_split
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size: int):
super().__init__()
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)
def forward(self, idx, targets=None):
logits = self.token_embedding_table(idx)
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: int):
for _ in range(max_new_tokens):
logits, loss = 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
@torch.no_grad()
def estimate_loss(model, train_data, val_data, batch_size, block_size, eval_iters=20):
out = {}
model.eval()
for split, source in [("train", train_data), ("val", val_data)]:
losses = torch.zeros(eval_iters)
for k in range(eval_iters):
xb, yb = get_batch(source, batch_size, block_size)
logits, loss = model(xb, yb)
losses[k] = loss.item()
out[split] = losses.mean().item()
model.train()
return out
if __name__ == "__main__":
torch.manual_seed(42)
batch_size = 32
block_size = 8
max_iters = 1000
eval_interval = 100
learning_rate = 1e-3
text = load_text("data/tiny_corpus.txt")
tokenizer = CharTokenizer(text)
data = torch.tensor(tokenizer.encode(text), dtype=torch.long)
train_data, val_data = train_val_split(data)
vocab_size = tokenizer.vocab_size
model = BigramLanguageModel(vocab_size)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
print("학습 전 생성 결과")
start = torch.zeros((1, 1), dtype=torch.long)
generated = model.generate(start, max_new_tokens=200)
print(tokenizer.decode(generated[0].tolist()))
print("n학습 시작")
for step in range(max_iters):
if step % eval_interval == 0:
losses = estimate_loss(
model,
train_data,
val_data,
batch_size=batch_size,
block_size=block_size,
)
print(
f"step {step}: "
f"train loss {losses['train']:.4f}, "
f"val loss {losses['val']:.4f}"
)
xb, yb = get_batch(train_data, batch_size, block_size)
logits, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
print("n학습 후 생성 결과")
generated = model.generate(start, max_new_tokens=400)
print(tokenizer.decode(generated[0].tolist()))이제 실행합니다.
python src/bigram.py처음 실행하면 loss가 조금씩 내려가는 모습을 볼 수 있습니다.
학습 시작
step 0: train loss 4.1234, val loss 4.0987
step 100: train loss 3.9120, val loss 3.9501
step 200: train loss 3.7215, val loss 3.8012
...
step 900: train loss 2.8503, val loss 3.1024위 숫자는 예시입니다. 여러분의 결과는 다르게 나올 수 있습니다. 하지만 대체로 학습이 진행되면서 train loss는 낮아지는 방향으로 움직일 것입니다.
학습 전과 학습 후 비교하기
이제 가장 재미있는 부분입니다. 학습 전과 학습 후의 생성 결과를 비교해 봅시다.
학습 전에는 대체로 이런 식의 출력이 나옵니다.
델험처은은,좋예큰
모쁜자나수텍델...문자 하나하나는 말뭉치에 있던 문자입니다. 하지만 순서에는 규칙이 거의 없습니다. 마치 자음과 모음 조각을 바구니에 넣고 아무렇게나 뽑은 것처럼 보입니다.
학습 후에는 조금 달라집니다.
작은 모델은 다.
나는 작은 데이터는 다.
언어 모델은 다음 글자를 다.
좋은 데이터는 작은 모델은 다.여전히 이상합니다. 완전한 문장도 아니고, 의미가 자연스럽지도 않습니다. 하지만 학습 전과 비교하면 분명히 달라졌습니다. 작은, 모델은, 데이터는, 다. 같은 조각이 보이기 시작합니다. 줄바꿈과 공백의 위치도 조금 더 그럴듯해집니다.
이것이 Bigram 모델이 배운 것입니다.
어떤 문자 다음에 어떤 문자가 자주 오는가?예를 들어 말뭉치에 모델은이라는 표현이 자주 나오면, 모델은 이런 흐름을 배울 수 있습니다.
'모' -> '델'
'델' -> '은'
'은' -> ' '하지만 모델은 다음 글자를 예측한다라는 긴 구조를 이해한 것은 아닙니다. 단지 가까운 문자 쌍의 습관을 따라 하고 있을 뿐입니다.
왜 이상한 반복이 생길까
Bigram 모델의 생성 결과를 보다 보면 짧은 표현이 반복되는 경우가 많습니다.
다.
작은 다.
모델은 다.
데이터는 다.이런 현상은 Bigram 모델의 구조를 생각하면 자연스럽습니다. 모델은 현재 문자 하나만 보고 다음 문자를 뽑습니다. 만약 다 다음에 .이 자주 나오고, . 다음에 줄바꿈이 자주 나오고, 줄바꿈 다음에 작이나 언 같은 문자가 자주 나온다면, 모델은 그 짧은 회로를 계속 돌 수 있습니다.
다 -> . -> 줄바꿈 -> 작 -> 은 -> 공백 -> 모 -> 델 -> 은 -> 공백 -> ...문제는 모델이 전체 문장이 어디로 가고 있는지 모른다는 것입니다. 방금 쓴 주어가 무엇인지, 문장이 이미 끝났는지, 앞에서 나는을 말했는지 기억하지 못합니다. 매 순간 바로 직전 문자 하나만 보고 결정합니다.
그래서 Bigram 모델은 국소 패턴에는 강하지만 긴 문맥에는 약합니다. 여기서 말하는 국소 패턴은 가까운 위치에서 반복되는 짧은 규칙입니다.
'모' 다음에는 '델'
'예' 다음에는 '측'
'학' 다음에는 '습'
'.' 다음에는 줄바꿈이런 패턴은 잘 배울 수 있습니다. 반면 다음과 같은 것은 배우기 어렵습니다.
문장의 주제 유지하기
앞에서 나온 단어 다시 참조하기
긴 문장 구조 완성하기
질문에 맞는 답변 구성하기이 한계가 바로 다음 모델로 넘어가는 이유입니다.
block_size가 있는데 왜 한 글자만 볼까
여기서 한 가지 헷갈릴 수 있습니다. 우리는 이전 장에서 block_size = 8로 입력을 만들었습니다. 그러면 모델이 8글자를 보는 것 아닌가요?
입력 텐서 모양만 보면 맞습니다.
x.shape = (batch_size, block_size)하지만 Bigram 모델의 계산을 다시 봅시다.
logits = self.token_embedding_table(idx)이 코드는 각 위치의 문자 ID를 독립적으로 임베딩 테이블에 넣습니다. 위치 t의 예측을 할 때, 위치 0부터 t-1까지의 문자를 섞어 보지 않습니다.
예를 들어 입력이 다음과 같다고 해 봅시다.
언어 모델실제로는 이렇게 처리됩니다.
'언' -> logits
'어' -> logits
' ' -> logits
'모' -> logits
'델' -> logits'델'을 예측할 때 앞의 '언어 모'를 함께 보는 것이 아닙니다. '모'라는 현재 문자 하나만 보고 다음 문자를 예측합니다.
그렇다면 왜 굳이 block_size 길이의 입력을 쓸까요? 이유는 학습 효율 때문입니다. 한 번에 여러 위치의 예측 문제를 계산할 수 있기 때문입니다.
block_size = 8이면 한 문장 조각에서 8개의 다음 문자 문제를 동시에 푼다.앞으로 만들 신경망 언어 모델과 Self-Attention 모델에서는 block_size가 더 중요한 의미를 갖습니다. 그때는 모델이 앞쪽 토큰들을 실제로 참고할 수 있습니다. 하지만 Bigram 모델에서는 아직 그렇지 않습니다.
embedding table을 들여다보기
Bigram 모델에서 token_embedding_table.weight는 사실상 현재 문자별 다음 문자 점수표입니다. 이 표를 눈으로 확인해 볼 수도 있습니다.
학습 후에 다음 코드를 잠깐 추가해 봅시다.
weights = model.token_embedding_table.weight
print(weights.shape)출력은 다음과 비슷합니다.
torch.Size([55, 55])어휘 크기가 55라면 55 × 55 표입니다. 행 하나는 현재 문자 하나에 대응합니다. 열 하나는 다음 문자 후보 하나에 대응합니다.
특정 문자의 다음 문자 후보를 보고 싶다면 이렇게 할 수 있습니다.
ch = "모"
ch_id = tokenizer.stoi[ch]
logits_for_ch = weights[ch_id]
probs = F.softmax(logits_for_ch, dim=-1)
topk = torch.topk(probs, k=5)
for prob, idx in zip(topk.values, topk.indices):
next_ch = tokenizer.itos[idx.item()]
print(repr(ch), "->", repr(next_ch), float(prob))학습이 잘 되었다면 모 다음 후보로 델이 높은 순위에 나타날 수 있습니다. 물론 데이터가 작고 학습이 짧으면 결과가 완벽하지 않을 수 있습니다. 그래도 이 실험은 모델이 무엇을 배웠는지 직관적으로 보여 줍니다.
다른 문자도 확인해 보세요.
for ch in ["언", "모", "다", ".", " "]:
if ch not in tokenizer.stoi:
continue
ch_id = tokenizer.stoi[ch]
logits_for_ch = weights[ch_id]
probs = F.softmax(logits_for_ch, dim=-1)
topk = torch.topk(probs, k=5)
print("n현재 문자:", repr(ch))
for prob, idx in zip(topk.values, topk.indices):
next_ch = tokenizer.itos[idx.item()]
print(" 다음 후보:", repr(next_ch), "확률:", float(prob))이렇게 보면 Bigram 모델은 더 이상 검은 상자가 아닙니다. 각 문자 다음에 무엇이 올 가능성이 높다고 배웠는지 직접 볼 수 있습니다.
확률 표 방식과 신경망 방식 다시 비교하기
이제 처음에 이야기한 두 Bigram 방식을 다시 비교해 봅시다.
확률 표 기반 Bigram은 데이터에서 직접 횟수를 셉니다.
count[현재 문자, 다음 문자] += 1그다음 각 행을 확률로 바꿉니다.
prob[현재 문자, 다음 문자] = count / 해당 행의 전체 count반면 신경망 기반 Bigram은 nn.Embedding(vocab_size, vocab_size)로 점수표를 만들고, cross-entropy loss가 낮아지는 방향으로 학습합니다.
현재 문자 ID -> logits
logits와 정답 다음 문자 비교
loss 계산
gradient로 embedding table 수정Bigram 수준에서는 두 방식이 비슷한 역할을 합니다. 그래서 "굳이 신경망으로 해야 하나?"라는 생각이 들 수 있습니다. 좋은 질문입니다. 이 장만 놓고 보면 확률 표 방식이 더 간단할 수도 있습니다.
하지만 우리는 여기서 더 큰 목적을 보고 있습니다. 앞으로 만들 모델은 단순히 문자 쌍을 세는 방식으로는 해결할 수 없습니다. 여러 글자의 문맥을 보고, 토큰 사이의 관계를 계산하고, Attention을 통해 필요한 정보를 모아야 합니다. 그때도 학습의 기본 모양은 지금과 같습니다.
logits를 만든다.
cross-entropy loss를 계산한다.
optimizer로 파라미터를 업데이트한다.
생성할 때 softmax와 sampling을 사용한다.즉 Bigram 모델은 작지만, 앞으로의 모든 실습을 위한 골격입니다.
작은 모델이 알려 주는 큰 원리
이번 장에서 만든 모델은 똑똑하지 않습니다. 긴 문맥을 기억하지 못하고, 문장의 의미도 이해하지 못합니다. 하지만 중요한 사실 하나를 직접 보여 줍니다.
언어 모델 학습은 다음 토큰 예측 문제를 반복해서 푸는 과정이다.
이 문장은 책 앞부분에서 여러 번 등장했습니다. 하지만 이제는 추상적인 설명이 아닙니다. 우리는 실제 코드로 보았습니다.
x = source[i : i + block_size]
y = source[i + 1 : i + block_size + 1]입력은 현재 위치까지의 문자이고, 정답은 한 칸 뒤의 문자입니다.
logits = self.token_embedding_table(idx)
loss = F.cross_entropy(logits, targets)모델은 다음 문자 후보의 점수를 만들고, 정답과 비교해 loss를 계산합니다.
loss.backward()
optimizer.step()그 loss를 줄이도록 파라미터를 고칩니다.
idx_next = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, idx_next), dim=1)생성할 때는 다음 문자를 하나 뽑아 뒤에 붙이고, 다시 다음 문자를 예측합니다.
이 흐름은 나중에 Mini GPT를 만들 때도 그대로 이어집니다. 달라지는 것은 logits를 만드는 방식입니다. Bigram 모델은 embedding table 하나로 logits를 만듭니다. 더 발전한 모델은 여러 층의 신경망, Self-Attention, Multi-Head Attention, Feed Forward Network를 거쳐 logits를 만듭니다.
하지만 마지막 질문은 같습니다.
다음 토큰은 무엇인가?이번 장에서 만든 것
이번 장에서는 문자 단위 Bigram 언어 모델을 만들었습니다. 현재 문자 하나만 보고 다음 문자를 예측하는 가장 단순한 언어 모델입니다.
우리는 먼저 Bigram 가정을 살펴보았습니다. 이 모델은 긴 문맥을 보지 않고 P(다음 문자 | 현재 문자)만 학습합니다. 그래서 짧은 문자 조합은 배울 수 있지만, 문장의 의미나 긴 흐름은 유지하지 못합니다.
그리고 확률 표 기반 Bigram과 신경망 기반 Bigram의 차이를 비교했습니다. 확률 표 방식은 문자 쌍의 횟수를 직접 세지만, 신경망 방식은 nn.Embedding(vocab_size, vocab_size)라는 학습 가능한 표를 두고 cross-entropy loss로 값을 조정합니다.
마지막으로 학습 전후의 생성 결과를 비교했습니다. 학습 전에는 무작위에 가까운 글자가 나오지만, 학습 후에는 말뭉치에서 자주 보였던 짧은 조각과 반복 패턴이 나타납니다. 이것은 모델이 최소한의 국소 패턴을 배웠다는 신호입니다.
하지만 한계도 분명합니다. Bigram 모델은 바로 앞 문자 하나만 봅니다. block_size가 있어도 긴 문맥을 활용하지 못합니다. 이제 다음 단계에서는 이 한계를 조금 넘어서 보겠습니다. 현재 문자 하나가 아니라, 여러 글자의 정보를 신경망이 함께 사용하도록 만들어 볼 것입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.