Embedding · Language Model · Next Token Prediction: 이전 토큰으로 다음 토큰의 확률을 학습한다
언어 모델(language model)은 지금까지 나온 토큰을 보고 다음 토큰의 확률을 예측한다. 오늘은 문자를 숫자 벡터로 바꾸는 임베딩부터 시프트된 정답, 교차 엔트로피 손실, 퍼플렉서티까지 하나의 학습 흐름으로 연결한다. 작은 LSTM 문자 모델을 직접 학습하며 퍼플렉서티가 내려가는 과정도 확인한다.
By 송민성
오늘의 목표
언어 모델이 문장을 통째로 외우는 것이 아니라, 이전 토큰들을 바탕으로 다음 토큰의 확률 분포를 학습한다는 사실을 이해한다. 임베딩 행렬부터 언어 모델 손실과 퍼플렉서티 계산까지 직접 구현한다.
개념과 직관
1. 토큰과 문자 단위 언어 모델
토큰(token, 모델이 처리하는 텍스트의 최소 단위)은 문자, 단어, 단어 조각 등이 될 수 있다. 오늘은 원리를 쉽게 확인하기 위해 문자 하나를 토큰 하나로 사용한다.
예를 들어 "hello"를 문자 단위로 나누면 다음과 같다.
h → e → l → l → o모델이 받는 문제는 매 위치마다 같다.
h를 봤을 때 다음 문자는?
he를 봤을 때 다음 문자는?
hel을 봤을 때 다음 문자는?
hell을 봤을 때 다음 문자는?2. 임베딩 행렬
컴퓨터는 문자 "h"를 그대로 계산하지 못한다. 먼저 각 문자에 정수 번호를 붙인다.
e: 0
h: 1
l: 2
o: 3하지만 번호 자체에는 의미가 없다. o의 번호가 e보다 3 크다고 해서 의미도 3만큼 크다는 뜻은 아니다.
임베딩(embedding, 토큰 번호를 학습 가능한 숫자 벡터로 바꾸는 것)은 각 토큰에 여러 숫자로 구성된 표현을 배정한다.
임베딩 행렬
차원 1 차원 2 차원 3
e 0.2 -0.7 0.1
h -0.4 0.3 0.8
l 0.9 0.1 -0.2
o 0.5 0.6 0.4h의 번호가 1이라면 임베딩 행렬의 1번 행을 꺼낸다.
h → 1 → [-0.4, 0.3, 0.8]처음에는 이 숫자들이 무작위다. 학습 과정에서 다음 토큰을 잘 예측하도록 함께 수정된다.
도서관 비유로 보면 토큰 번호는 책의 등록 번호이고 임베딩은 책의 내용과 성격을 압축한 좌표다. 등록 번호끼리는 계산할 의미가 없지만, 좌표는 모델이 학습에 사용할 수 있다.
3. 언어 모델과 자기회귀 분해
언어 모델(language model, 토큰 시퀀스에 확률을 부여하는 모델)은 문장이 나타날 가능성을 여러 번의 다음 토큰 예측으로 나눈다.
P(문장)
= P(첫 토큰)
× P(둘째 토큰 | 첫 토큰)
× P(셋째 토큰 | 첫째, 둘째 토큰)
× ...이를 자기회귀 분해(autoregressive factorization, 앞에서 생성한 토큰들을 조건으로 다음 토큰 확률을 차례로 계산하는 방식)라고 한다.
문장 "hello"의 확률은 다음처럼 표현된다.
P(hello)
= P(h)
× P(e | h)
× P(l | he)
× P(l | hel)
× P(o | hell)곱셈 기호를 간단히 쓰면 다음과 같다.
P(문장) = Π P(다음 토큰 | 이전 토큰들)Π는 여러 확률을 모두 곱한다는 뜻이다.
4. 입력과 정답을 한 칸씩 밀기
다음 토큰 예측용 데이터는 원본 텍스트 하나에서 만들 수 있다.
원본: hello
입력: h e l l
정답: e l l o입력과 정답의 길이는 같다. 정답을 입력보다 한 칸 앞쪽 문자로 구성했기 때문에 시프트된 라벨(shifted labels, 입력의 각 위치보다 한 토큰 뒤에 있는 정답)이라고 부른다.
입력 위치 0: h → 정답 e
입력 위치 1: e → 정답 l
입력 위치 2: l → 정답 l
입력 위치 3: l → 정답 o5. 티처 포싱
티처 포싱(teacher forcing, 학습 중 모델이 예측한 토큰 대신 실제 이전 토큰을 다음 입력으로 사용하는 방식)을 적용하면 모델이 앞에서 틀렸더라도 다음 위치에서는 올바른 문맥을 받는다.
학습:
입력 h → e 예측
실제 e를 다음 입력으로 제공
입력 e → l 예측
실제 l을 다음 입력으로 제공반면 텍스트를 생성할 때는 정답이 없다.
생성:
입력 h → 모델이 e 선택
선택한 e를 다음 입력으로 제공
입력 e → 모델이 l 선택학습 때는 정답 문맥을 보고, 생성 때는 자기 예측을 다시 입력으로 쓴다는 차이가 있다.
6. 교차 엔트로피가 언어 모델 손실인 이유
모델은 다음 토큰 하나만 출력하지 않는다. 가능한 모든 토큰의 확률 분포를 출력한다.
입력: h
e: 0.70
h: 0.05
l: 0.20
o: 0.05정답이 e라면 정답 확률 0.70이 높을수록 좋은 예측이다.
교차 엔트로피(cross entropy, 정답에 배정한 확률이 낮을수록 큰 벌점을 주는 손실)는 한 위치에서 다음처럼 계산된다.
손실 = -log(정답 토큰의 예측 확률)예시:
정답 확률 0.90 → 손실 약 0.11
정답 확률 0.50 → 손실 약 0.69
정답 확률 0.10 → 손실 약 2.30언어 모델은 모든 위치의 교차 엔트로피 평균을 줄인다. 이는 정답 시퀀스의 확률을 높이는 것과 같은 목표다.
7. 퍼플렉서티
퍼플렉서티(perplexity, 모델이 다음 토큰 후보 사이에서 얼마나 혼란스러운지를 나타내는 지표)는 평균 교차 엔트로피로 계산한다.
퍼플렉서티 = exp(평균 교차 엔트로피)평균 손실이 2라면:
퍼플렉서티 = exp(2) ≈ 7.39직관적으로 모델이 매 위치에서 약 7.39개의 후보 사이에서 고민하는 수준이라고 해석할 수 있다. 완벽하게 다음 토큰을 맞혀 평균 손실이 0에 가까워지면 퍼플렉서티는 1에 가까워진다.
퍼플렉서티는 낮을수록 좋지만, 서로 다른 토큰 분할 방식이나 데이터셋에서 나온 수치를 그대로 비교하면 안 된다.
코드로 직접 해보기
1. 라이브러리 없이 임베딩 조회 원리 확인하기
임베딩은 토큰 번호에 해당하는 행을 꺼내는 연산이다.
characters = ["e", "h", "l", "o"]
char_to_id = {char: index for index, char in enumerate(characters)}
embedding_table = [
[0.2, -0.7, 0.1], # e
[-0.4, 0.3, 0.8], # h
[0.9, 0.1, -0.2], # l
[0.5, 0.6, 0.4], # o
]
token_id = char_to_id["h"]
embedding = embedding_table[token_id]
print(token_id)
print(embedding)실행 결과:
1
[-0.4, 0.3, 0.8]실제 학습에서는 embedding_table의 숫자도 모델 파라미터로 취급해 손실이 줄어드는 방향으로 수정한다.
2. 문자 사전과 시프트된 데이터 만들기
text = "hello hello hello"
characters = sorted(set(text))
char_to_id = {char: index for index, char in enumerate(characters)}
id_to_char = {index: char for char, index in char_to_id.items()}
encoded = [char_to_id[char] for char in text]
input_ids = encoded[:-1]
target_ids = encoded[1:]
print("문자 사전:", char_to_id)
print("입력:", input_ids[:8])
print("정답:", target_ids[:8])입력의 첫 문자가 h라면 정답의 첫 문자는 그다음 문자 e다.
3. 작은 LSTM 언어 모델 학습하기
LSTM(Long Short-Term Memory, 앞에서 읽은 정보를 내부 상태에 담아 다음 입력 처리에 활용하는 순환 신경망)을 사용한다. 내부 계산식을 모두 다루기보다 오늘은 입력과 출력의 흐름에 집중한다.
import math
import matplotlib.pyplot as plt
import torch
from torch import nn
torch.manual_seed(0)
text = ("hello world " * 100).strip()
characters = sorted(set(text))
char_to_id = {char: index for index, char in enumerate(characters)}
id_to_char = {index: char for char, index in char_to_id.items()}
encoded = torch.tensor(
[char_to_id[char] for char in text],
dtype=torch.long,
)
inputs = encoded[:-1].unsqueeze(0)
targets = encoded[1:].unsqueeze(0)
vocab_size = len(characters)
embedding_size = 16
hidden_size = 32
class CharacterLanguageModel(nn.Module):
def __init__(self):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_size)
self.lstm = nn.LSTM(
input_size=embedding_size,
hidden_size=hidden_size,
batch_first=True,
)
self.output = nn.Linear(hidden_size, vocab_size)
def forward(self, token_ids):
embedded = self.embedding(token_ids)
hidden_states, _ = self.lstm(embedded)
logits = self.output(hidden_states)
return logits
model = CharacterLanguageModel()
optimizer = torch.optim.Adam(model.parameters(), lr=0.02)
loss_function = nn.CrossEntropyLoss()
perplexities = []
for epoch in range(1, 101):
model.train()
optimizer.zero_grad()
logits = model(inputs)
loss = loss_function(
logits.reshape(-1, vocab_size),
targets.reshape(-1),
)
loss.backward()
optimizer.step()
perplexity = math.exp(loss.item())
perplexities.append(perplexity)
if epoch == 1 or epoch % 10 == 0:
print(
f"에포크 {epoch:3d} | "
f"손실 {loss.item():.4f} | "
f"퍼플렉서티 {perplexity:.2f}"
)
plt.plot(range(1, 101), perplexities)
plt.xlabel("에포크")
plt.ylabel("퍼플렉서티")
plt.title("학습 중 퍼플렉서티 변화")
plt.grid()
plt.show()텐서의 모양은 다음 순서로 변한다.
토큰 번호: [배치, 문장 길이]
임베딩: [배치, 문장 길이, 임베딩 크기]
LSTM 출력: [배치, 문장 길이, 은닉 크기]
로짓: [배치, 문장 길이, 문자 종류 수]로짓(logits, 확률로 바꾸기 전 모델의 원시 점수)은 각 위치에서 모든 문자 후보에 하나씩 나온다. CrossEntropyLoss가 내부에서 이 점수들을 확률 계산에 알맞게 처리하므로 코드에서 소프트맥스를 먼저 적용하지 않는다.
reshape(-1, vocab_size)는 배치와 문장 위치를 한 줄로 펴서 모든 위치를 독립된 다음 문자 문제처럼 손실 함수에 전달한다.
오늘의 실험
같은 코드에서 에포크별 퍼플렉서티를 기록하고 그래프로 그린다.
- 먼저 100에포크 동안 기본 설정으로 학습한다.
- 표에 1, 10, 20, 50, 100에포크의 손실과 퍼플렉서티를 적는다.
hidden_size를 8, 32로 바꿔 각각 다시 학습한다.- 텍스트 반복 횟수를 10회, 100회로 바꿔 차이를 본다.
- 각 실험은
torch.manual_seed(0)을 유지해 시작 조건을 맞춘다.
기록 형식:
| 반복 횟수 | 은닉 크기 | 에포크 | 손실 | 퍼플렉서티 |
|---|---|---|---|---|
| 100 | 32 | 1 | 실행 결과 기록 | 실행 결과 기록 |
| 100 | 32 | 10 | 실행 결과 기록 | 실행 결과 기록 |
| 100 | 32 | 100 | 실행 결과 기록 | 실행 결과 기록 |
| 100 | 8 | 100 | 실행 결과 기록 | 실행 결과 기록 |
| 10 | 32 | 100 | 실행 결과 기록 | 실행 결과 기록 |
관찰할 내용:
- 학습 초반 퍼플렉서티가 빠르게 내려가는가?
- 어느 시점부터 감소 폭이 작아지는가?
- 작은
hidden_size가 반복 패턴을 충분히 학습하는가? - 데이터가 짧을 때 훈련 퍼플렉서티만 낮아지는가?
현재 코드는 학습에 사용한 텍스트로 퍼플렉서티를 다시 계산한다. 이 수치는 학습 진행 확인에는 쓸 수 있지만 새로운 문장에 대한 성능을 뜻하지 않는다. 이후에는 학습 데이터와 평가 데이터를 분리해야 한다.
왜 이걸 배우나 (LLM / Post-training 연결)
대규모 언어 모델도 규모와 내부 구조는 다르지만, 이전 토큰으로 다음 토큰의 확률을 예측하고 교차 엔트로피를 줄이는 기본 틀을 사용한다. LLM Research Engineer는 후속 학습(post-training, 사전 학습된 모델을 목적에 맞게 추가 학습하는 과정)에서도 시프트된 라벨, 토큰별 손실, 마스킹, 퍼플렉서티를 점검하며 데이터와 학습 코드가 의도대로 작동하는지 확인한다.
초보자가 막히는 지점
입력과 정답을 똑같이 만드는 실수
다음 토큰 예측에서는 정답이 한 칸 앞서야 한다.
inputs = encoded[:-1]
targets = encoded[1:]CrossEntropyLoss 전에 소프트맥스를 적용하는 실수
nn.CrossEntropyLoss에는 확률이 아닌 로짓을 전달한다.
loss = loss_function(logits.reshape(-1, vocab_size), targets.reshape(-1))정답 자료형 오류
정답 토큰 번호는 정수형인 torch.long이어야 한다.
encoded = torch.tensor(numbers, dtype=torch.long)출력과 정답 모양이 맞지 않는 오류
로짓의 마지막 차원은 문자 종류 수여야 한다.
로짓: [전체 위치 수, 문자 종류 수]
정답: [전체 위치 수]퍼플렉서티가 내려가면 좋은 문장을 만든다고 오해하기
짧고 반복적인 훈련 데이터에서는 모델이 패턴을 외워 퍼플렉서티를 낮출 수 있다. 새로운 텍스트에서도 잘 작동하는지 판단하려면 별도 평가 데이터가 필요하다.
알고리즘 (병행 1시간)
LeetCode Medium 두 문제를 푼다.
- 스택:
739. Daily Temperatures - 큐:
622. Design Circular Queue
문제당 30분을 사용한다. 각 문제에서 다음 세 항목을 기록한다.
- 접근: 어떤 값을 스택이나 큐에 저장했고, 왜 그 자료구조가 맞는가
- 시간복잡도: 입력 크기를
n이라 할 때 연산 횟수가 어떻게 늘어나는가 - 틀린 이유: 경계 조건, 빈 자료구조 처리, 인덱스 계산 중 무엇을 놓쳤는가