모델보다 먼저 데이터가 있다
이제 드디어 모델을 직접 만들 준비가 되었습니다. 하지만 코드를 쓰기 전에 한 가지를 먼저 분명히 해야 합니다. 언어 모델은 빈손으로 똑똑해지지 않습니다. 모델이 배우는 것은 데이터 안에 들어 있는 패턴입니다.
앞에서 우리는 GPT가 "다음 토큰을 예측하는 모델"이라고 여러 번 이야기했습니다. 그렇다면 학습 데이터는 어떤 모양이어야 할까요? 아주 간단합니다. 긴 텍스트가 하나 있으면 됩니다. 모델은 그 텍스트를 앞에서부터 조금씩 잘라 보면서 "이 다음에는 어떤 글자가 나왔지?"를 맞히는 연습을 합니다.
예를 들어 다음과 같은 문장이 있다고 해 보겠습니다.
언어 모델은 다음 글자를 예측한다.문자 단위 언어 모델은 이 문장을 단어가 아니라 글자 하나하나로 봅니다.
언, 어, , 모, 델, 은, , 다, 음, , 글, 자, 를, , 예, 측, 한, 다, .모델에게 언어 모델은까지 보여 주면, 모델은 다음 글자가 공백인지, 다인지, 아니면 전혀 다른 글자인지 예측해야 합니다. 처음에는 당연히 틀립니다. 하지만 같은 방식으로 수천 번, 수만 번 연습하면 자주 등장하는 글자 조합과 문장 흐름을 조금씩 익힙니다.
중요한 점은 이것입니다. 데이터의 형태가 모델의 학습 문제를 결정합니다. 우리가 문자 단위 데이터셋을 만들면 모델은 다음 "문자"를 예측합니다. 단어 단위로 자르면 다음 "단어"를 예측합니다. BPE 같은 토큰화 방식을 쓰면 다음 "토큰"을 예측합니다. 이번 실습에서는 원리를 가장 투명하게 보기 위해 문자 단위로 시작합니다.

어떤 텍스트를 사용할까
데이터를 준비할 때 가장 먼저 생각해야 할 것은 "아무 글이나 가져와도 되는가?"입니다. 기술 실습에서는 이 부분을 쉽게 지나치기 쉽지만, 실제 서비스를 만들 때는 매우 중요합니다. 인터넷에 공개되어 있다고 해서 마음대로 학습 데이터로 써도 된다는 뜻은 아닙니다.
이번 장에서는 다음 세 가지 중 하나를 선택하는 편이 안전합니다.
직접 작성한 짧은 말뭉치
저작권 보호 기간이 끝난 공개 도메인 텍스트
학습과 재사용이 허용된 공개 라이선스 텍스트
처음 실습에서는 직접 만든 짧은 말뭉치가 가장 좋습니다. 양은 적어도 괜찮습니다. 지금 만드는 모델의 목적은 ChatGPT급 모델을 만드는 것이 아니라, 다음 토큰 예측이 실제 코드에서 어떻게 돌아가는지 이해하는 데 있습니다.
다만 텍스트가 너무 짧으면 모델이 배울 패턴이 거의 없습니다. 한두 문장만 넣으면 학습은 되지만, 결과는 거의 장난감 수준에 머뭅니다. 가능하면 100줄 이상, 더 좋게는 몇천 글자 이상의 텍스트를 준비해 봅시다. 그래도 거대한 데이터가 필요한 것은 아닙니다. 우리는 작은 실험실을 만드는 중입니다.
작은 말뭉치 만들기
먼저 프로젝트 루트에 data 폴더를 만들고, 그 안에 tiny_corpus.txt 파일을 만듭니다.
first-llm/
├── data/
│ └── tiny_corpus.txt
├── src/
└── notebooks/data/tiny_corpus.txt에는 다음과 같이 직접 만든 문장을 넣어 보겠습니다. 그대로 사용해도 되고, 여러분의 관심사에 맞게 바꾸어도 됩니다.
나는 작은 언어 모델을 만든다.
언어 모델은 다음 글자를 예측한다.
처음에는 아무것도 모르는 것처럼 보인다.
하지만 같은 문장을 여러 번 읽으면 패턴을 배운다.
작은 모델은 세상을 이해하지 못한다.
작은 모델은 데이터에 나온 글자 흐름을 따라 한다.
그래도 우리는 이 모델을 통해 큰 모델의 원리를 배울 수 있다.
오늘은 텍스트를 숫자로 바꾸고,
숫자를 다시 텍스트로 되돌리는 연습을 한다.
모델은 숫자를 입력받고 숫자를 출력한다.
사람은 그 숫자를 글자로 읽는다.
좋은 데이터는 좋은 실험을 만든다.
나쁜 데이터는 이상한 출력을 만든다.
작은 데이터는 작은 습관을 만든다.
큰 데이터는 더 다양한 패턴을 보여 준다.이 말뭉치는 아주 작습니다. 실제 LLM 학습 데이터와 비교하면 먼지 한 톨보다 작다고 해도 과장이 아닙니다. 하지만 문자 단위 Bigram 모델과 작은 신경망 언어 모델을 실험하기에는 충분합니다. 오히려 데이터가 작기 때문에 모델이 무엇을 외우고, 무엇을 일반화하지 못하는지 관찰하기 좋습니다.
여기서 빈 줄도 데이터에 포함된다는 점을 기억해 주세요. 줄바꿈 문자 n도 하나의 문자입니다. 공백도 하나의 문자입니다. 문자 단위 모델에게는 다와 .뿐 아니라 공백, 줄바꿈까지 모두 예측 대상입니다.
텍스트 파일 읽기
이제 Python으로 텍스트를 읽어 보겠습니다. src/prepare_data.py 파일을 만들고 다음 코드를 작성합니다.
from pathlib import Path
path = Path("data/tiny_corpus.txt")
text = path.read_text(encoding="utf-8")
print("전체 글자 수:", len(text))
print("앞부분 미리 보기:")
print(text[:100])실행해 봅니다.
python src/prepare_data.py여기서 encoding="utf-8"을 지정했습니다. 한국어 텍스트를 다룰 때 인코딩 문제로 글자가 깨지는 경우가 종종 있습니다. UTF-8은 오늘날 가장 널리 쓰이는 문자 인코딩 방식입니다. 쉽게 말해, 글자를 컴퓨터가 저장할 수 있는 바이트 배열로 바꾸는 약속입니다.
출력 결과에서 전체 글자 수가 너무 작게 나온다면 텍스트를 조금 더 추가해도 좋습니다. 지금 단계에서는 정확한 숫자보다 "Python이 텍스트 파일을 문자열로 읽어 왔다"는 사실이 중요합니다.
문자 집합 만들기
모델은 글자를 직접 이해하지 못합니다. 앞 장들에서 보았듯이 모델 안으로 들어가는 것은 숫자입니다. 따라서 먼저 말뭉치에 어떤 문자들이 등장하는지 모아야 합니다.
chars = sorted(list(set(text)))
vocab_size = len(chars)
print("사용된 문자 수:", vocab_size)
print("문자 집합:")
print(chars)set(text)는 텍스트 안에 등장한 중복 없는 문자 집합을 만듭니다. 여기에 sorted를 적용해 항상 같은 순서가 나오도록 정렬합니다. 순서가 매번 바뀌면 같은 글자에 다른 ID가 붙을 수 있으므로 실험이 헷갈립니다.
예를 들어 말뭉치에 다음 문자들이 등장했다고 해 봅시다.
['n', ' ', '.', ',', '가', '고', '나', '다', '델', ...]실제 출력은 이와 다릅니다. 중요한 것은 문자 하나하나가 어휘 사전의 항목이 된다는 점입니다. 여기서 말하는 어휘 사전은 거창한 국어사전이 아닙니다. 모델이 구분할 수 있는 기호 목록입니다.
문자 단위 모델에서는 등장한 문자 수가 곧 vocab_size입니다. 만약 말뭉치에 55종류의 문자가 등장한다면, 모델의 출력 후보도 55개입니다. 모델은 매 시점마다 "다음 문자는 이 55개 중 무엇인가?"를 예측합니다.
문자와 정수 ID 연결하기
이제 각 문자에 정수 ID를 붙여 보겠습니다. 이 과정은 앞에서 배운 토큰 ID 만들기와 같습니다. 다만 이번에는 토큰이 문자 하나입니다.
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
print(stoi)여기서 stoi는 string to integer의 줄임말처럼 생각하면 됩니다. 문자를 정수로 바꾸는 사전입니다. 반대로 itos는 integer to string, 즉 정수를 문자로 되돌리는 사전입니다.
예를 들어 다음과 같은 매핑이 생길 수 있습니다.
{
'n': 0,
' ': 1,
'.': 2,
'가': 3,
'나': 4,
'다': 5
}이 숫자에는 특별한 의미가 없습니다. 다가 5번이라고 해서 나보다 다섯 배 중요하다는 뜻이 아닙니다. 숫자는 그저 주소표입니다. 나중에 신경망은 이 ID를 이용해 임베딩 벡터를 찾아갑니다.
이 구분은 매우 중요합니다. 토큰 ID는 의미를 담은 숫자가 아닙니다. 의미는 ID 자체가 아니라, 학습 과정에서 만들어지는 임베딩과 모델 내부의 가중치에 담깁니다.
encode와 decode 함수 만들기
문자를 숫자로 바꾸는 함수를 encode, 숫자를 다시 문자로 바꾸는 함수를 decode라고 부르겠습니다.
def encode(s: str) -> list[int]:
return [stoi[c] for c in s]
def decode(ids: list[int]) -> str:
return "".join(itos[i] for i in ids)간단히 테스트해 봅시다.
sample = "언어 모델"
encoded = encode(sample)
decoded = decode(encoded)
print("원문:", sample)
print("인코딩:", encoded)
print("디코딩:", decoded)출력은 대략 다음과 같은 형태가 됩니다.
원문: 언어 모델
인코딩: [23, 18, 1, 31, 29]
디코딩: 언어 모델정확한 숫자는 여러분의 말뭉치에 따라 달라집니다. 문자 집합의 정렬 결과가 달라지기 때문입니다. 중요한 것은 decode(encode(sample))을 했을 때 원래 문자열이 되돌아와야 한다는 점입니다.
다만 주의할 점이 있습니다. encode는 말뭉치에 등장한 문자만 처리할 수 있습니다. 만약 tiny_corpus.txt에 한 번도 나오지 않은 문자를 넣으면 KeyError가 발생합니다.
encode("처음 보는 이모지 😊")말뭉치에 😊가 없다면 이 문자는 어휘 사전에 없으므로 숫자로 바꿀 수 없습니다. 실제 LLM의 토크나이저는 이런 문제를 처리하기 위해 더 정교한 방식을 사용합니다. 하지만 이번 실습에서는 단순함이 장점입니다. 어떤 문자가 어휘에 있고, 어떤 문자가 없는지 눈으로 확인할 수 있기 때문입니다.
전체 텍스트를 숫자 텐서로 바꾸기
이제 전체 텍스트를 정수 ID의 나열로 바꿉니다. PyTorch 모델은 Python 리스트보다 텐서를 입력으로 받는 것이 자연스럽습니다.
import torch
data = torch.tensor(encode(text), dtype=torch.long)
print(data.shape)
print(data[:20])dtype=torch.long은 정수 ID를 담기 위한 타입입니다. 나중에 임베딩 레이어나 손실 함수에 넣을 때 토큰 ID는 보통 long 타입이어야 합니다.
여기까지 오면 원래 텍스트는 다음과 같은 긴 숫자 배열이 됩니다.
[12, 7, 1, 34, 28, 1, 20, 31, 15, 2, ...]사람에게는 의미 없어 보이지만, 모델에게는 이 배열이 학습 교재입니다. 모델은 이 숫자 배열을 보며 다음 숫자를 맞히는 연습을 합니다. 그리고 우리가 decode를 사용하면 그 숫자 예측 결과를 다시 글자로 읽을 수 있습니다.
train과 validation으로 나누기
학습 데이터를 전부 모델에게 보여 주고, 다시 같은 데이터로 성능을 확인하면 문제가 생깁니다. 모델이 정말 패턴을 배운 것인지, 그냥 학습 문장을 외운 것인지 구분하기 어렵습니다.
그래서 데이터를 두 부분으로 나눕니다. 하나는 학습용, 다른 하나는 검증용입니다.
n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]
print("train:", train_data.shape)
print("validation:", val_data.shape)여기서는 앞의 90%를 학습용으로, 뒤의 10%를 검증용으로 나누었습니다. 검증 데이터는 학습 중에 가중치를 업데이트하는 데 직접 사용하지 않습니다. 대신 모델이 처음 보는 구간에서도 다음 글자를 잘 예측하는지 확인하는 용도로 사용합니다.
작은 말뭉치에서는 검증 결과가 꽤 흔들릴 수 있습니다. 검증 데이터 자체가 너무 짧기 때문입니다. 그래도 나누는 습관은 중요합니다. 이 습관이 있어야 나중에 과적합을 관찰할 수 있습니다.
과적합은 모델이 학습 데이터에 지나치게 맞춰진 상태를 말합니다. 예를 들어 학습 손실은 계속 낮아지는데 검증 손실은 더 이상 낮아지지 않거나 오히려 올라간다면, 모델이 새로운 텍스트에 일반화하지 못하고 있을 가능성이 큽니다. 작은 모델 실습에서도 이 현상은 충분히 볼 수 있습니다.
block size: 모델이 한 번에 보는 길이
이제 가장 중요한 부분입니다. 우리는 긴 텍스트 전체를 한 번에 모델에 넣지 않습니다. 일정한 길이로 잘라서 넣습니다. 이때 한 번에 모델이 보는 토큰 수를 block_size라고 부르겠습니다.
예를 들어 block_size = 8이라고 해 보겠습니다. 그리고 어떤 데이터 조각이 다음과 같다고 합시다.
언어 모델은 다음문자 단위로 보면 앞에서부터 8개의 문자를 입력 x로 사용할 수 있습니다.
x: 언어 모델은 정답 y는 입력보다 한 칸 뒤로 밀린 시퀀스입니다.
y: 어 모델은 다즉 모델은 다음 예측 문제를 동시에 풀게 됩니다.
입력: 언 정답: 어
입력: 언어 정답:
입력: 언어 정답: 모
입력: 언어 모 정답: 델
입력: 언어 모델 정답: 은실제 텐서에서는 이 모든 위치의 예측을 한 번에 계산합니다. 이 점이 중요합니다. 모델은 하나의 문맥만 보고 하나의 정답만 맞히는 것이 아니라, 길이 block_size 안의 각 위치마다 다음 글자를 맞히도록 학습됩니다.
block_size는 앞에서 배운 컨텍스트 윈도우의 아주 작은 버전이라고 볼 수 있습니다. block_size = 8이면 모델은 최대 8글자 문맥만 볼 수 있습니다. block_size = 128이면 최대 128글자를 볼 수 있습니다. 다만 길이를 늘리면 계산량도 늘어납니다. 처음에는 작게 시작하는 것이 좋습니다.
batch: 여러 조각을 한꺼번에 학습하기
하나의 텍스트 조각만으로 학습하면 느리고 불안정합니다. 그래서 여러 조각을 한 번에 묶어 모델에 넣습니다. 이 묶음을 batch라고 합니다.
예를 들어 batch_size = 4, block_size = 8이면 입력 x의 모양은 다음과 같습니다.
x.shape = (4, 8)이 뜻은 8글자짜리 조각 4개를 한 번에 학습한다는 의미입니다. 정답 y도 같은 모양입니다.
y.shape = (4, 8)각 행은 서로 다른 위치에서 잘라 온 텍스트 조각입니다. 모델은 한 번의 학습 단계에서 총 4 × 8 = 32개의 다음 글자 예측 문제를 풉니다.
이제 배치를 만드는 함수를 작성해 보겠습니다.
torch.manual_seed(42)
batch_size = 4
block_size = 8
def get_batch(split: str):
source = train_data if split == "train" else val_data
ix = torch.randint(len(source) - block_size, (batch_size,))
x = torch.stack([source[i : i + block_size] for i in ix])
y = torch.stack([source[i + 1 : i + block_size + 1] for i in ix])
return x, y조금 천천히 보겠습니다. torch.randint는 무작위 시작 위치를 뽑습니다. 예를 들어 ix가 [10, 53, 120, 7]이라면, 데이터의 10번째 위치부터 8글자, 53번째 위치부터 8글자, 120번째 위치부터 8글자, 7번째 위치부터 8글자를 가져옵니다.
x는 해당 위치부터 block_size만큼 자른 입력입니다. y는 같은 구간을 한 칸 뒤로 밀어 만든 정답입니다.
x = source[i : i + block_size]
y = source[i + 1 : i + block_size + 1]이 한 줄에 언어 모델 학습의 핵심이 들어 있습니다. 입력은 현재까지의 글자이고, 정답은 바로 다음 글자입니다.
배치가 제대로 만들어졌는지 확인하기
데이터 코드는 눈으로 확인하는 습관이 중요합니다. 특히 언어 모델에서는 입력과 정답이 한 칸 어긋나야 합니다. 두 칸 어긋나거나 같은 위치를 정답으로 두면 모델은 엉뚱한 문제를 풀게 됩니다.
다음 코드를 추가해 확인해 봅시다.
x, y = get_batch("train")
print("x shape:", x.shape)
print("y shape:", y.shape)
print()
print("첫 번째 입력:")
print(x[0])
print(decode(x[0].tolist()))
print()
print("첫 번째 정답:")
print(y[0])
print(decode(y[0].tolist()))출력은 실행할 때마다 조금 달라질 수 있습니다. 무작위로 조각을 뽑기 때문입니다. 하지만 입력 문자열과 정답 문자열을 비교했을 때 정답이 입력보다 한 글자 뒤에서 시작해야 합니다.
조금 더 자세히 보고 싶다면 각 위치별 예측 문제를 출력해 볼 수 있습니다.
for b in range(batch_size):
print(f"n--- batch {b} ---")
for t in range(block_size):
context = x[b, : t + 1].tolist()
target = y[b, t].item()
print(f"입력 {decode(context)!r} -> 정답 {decode([target])!r}")예를 들어 다음과 비슷한 결과가 나올 수 있습니다.
입력 '언' -> 정답 '어'
입력 '언어' -> 정답 ' '
입력 '언어 ' -> 정답 '모'
입력 '언어 모' -> 정답 '델'이 출력이 보이면 이제 모델이 풀 문제를 정확히 준비한 것입니다. 다음 장에서 만들 Bigram 모델은 바로 이 x, y를 사용해 다음 문자를 예측합니다.
재사용할 수 있는 데이터 준비 코드
지금까지의 코드를 하나의 파일로 정리해 보겠습니다. 앞으로 여러 모델에서 같은 데이터 준비 과정을 사용할 것이므로 src/data.py로 만들어 두면 편합니다.
# src/data.py
from pathlib import Path
import torch
class CharTokenizer:
def __init__(self, text: str):
self.chars = sorted(list(set(text)))
self.vocab_size = len(self.chars)
self.stoi = {ch: i for i, ch in enumerate(self.chars)}
self.itos = {i: ch for i, ch in enumerate(self.chars)}
def encode(self, s: str) -> list[int]:
return [self.stoi[c] for c in s]
def decode(self, ids: list[int]) -> str:
return "".join(self.itos[i] for i in ids)
def load_text(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
def train_val_split(data: torch.Tensor, ratio: float = 0.9):
n = int(ratio * len(data))
return data[:n], data[n:]
def get_batch(data: torch.Tensor, batch_size: int, block_size: int):
ix = torch.randint(len(data) - block_size, (batch_size,))
x = torch.stack([data[i : i + block_size] for i in ix])
y = torch.stack([data[i + 1 : i + block_size + 1] for i in ix])
return x, y
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)
print("전체 글자 수:", len(text))
print("어휘 크기:", tokenizer.vocab_size)
print("학습 데이터 크기:", train_data.shape)
print("검증 데이터 크기:", val_data.shape)
x, y = get_batch(train_data, batch_size=4, block_size=8)
print("x shape:", x.shape)
print("y shape:", y.shape)
print("입력 예시:", tokenizer.decode(x[0].tolist()))
print("정답 예시:", tokenizer.decode(y[0].tolist()))실행합니다.
python src/data.py이 파일은 앞으로 계속 사용할 기초 도구입니다. CharTokenizer는 문자와 ID를 오가는 일을 맡습니다. load_text는 파일을 읽습니다. train_val_split은 학습 데이터와 검증 데이터를 나눕니다. get_batch는 모델에 넣을 입력과 정답을 만듭니다.
아직 모델은 없습니다. 하지만 사실상 학습 문제는 이미 만들어졌습니다. 이제 모델은 이 문제를 얼마나 잘 푸는지만 결정하면 됩니다.
작은 데이터에서 일부러 보게 될 것들
작은 데이터셋으로 학습하면 결과가 이상할 때가 많습니다. 문장이 중간에 끊기고, 같은 표현을 반복하고, 학습 데이터에 있던 문장을 거의 그대로 따라 하기도 합니다. 이것은 실패가 아닙니다. 오히려 좋은 관찰 대상입니다.
작은 데이터는 모델의 습관을 과장해서 보여 줍니다. 데이터에 언어 모델은이라는 표현이 자주 나오면 모델도 그 표현을 자주 만들려고 합니다. 줄바꿈이 자주 나오면 줄바꿈을 자주 예측합니다. 마침표 뒤에 항상 줄바꿈이 있었다면, 모델은 마침표 다음에 줄바꿈이 온다고 배울 가능성이 큽니다.
이 경험은 나중에 큰 LLM을 사용할 때도 도움이 됩니다. LLM의 출력이 갑자기 특정 말투로 기울거나, 특정 형식을 반복하거나, 문서에 있는 표현을 지나치게 따라 하는 현상은 모두 데이터와 학습 문제의 영향을 받습니다. 규모는 다르지만 원리는 이어져 있습니다.
이번 장에서 준비한 것
이번 장에서는 모델을 만들기 전에 데이터셋을 준비했습니다. 텍스트 파일을 읽고, 등장한 문자 집합을 모으고, 문자와 정수 ID를 연결했습니다. 그리고 encode, decode 함수를 통해 사람이 읽는 텍스트와 모델이 처리하는 숫자 배열을 오갈 수 있게 했습니다.
또한 데이터를 학습용과 검증용으로 나누었습니다. 이 분리는 모델이 단순히 외우는지, 처음 보는 구간에서도 어느 정도 예측하는지 확인하기 위한 첫걸음입니다. 마지막으로 batch_size와 block_size를 이용해 입력 x와 정답 y를 만드는 방법을 배웠습니다.
이제 모델이 풀 문제는 준비되었습니다.
입력 x: 지금까지의 문자들
정답 y: 한 칸 뒤의 다음 문자들다음 장에서는 가장 단순한 언어 모델인 Bigram 모델을 만들어 보겠습니다. Bigram 모델은 바로 앞의 한 글자만 보고 다음 글자를 예측합니다. 너무 단순해 보이지만, 이 모델을 직접 만들고 학습시키면 "다음 토큰 예측"이라는 말이 실제 손실 함수와 생성 코드로 어떻게 바뀌는지 선명하게 보이기 시작합니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.