본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research10 min read

Tiny GPT 데이터 준비: 텍스트를 (batch, block_size) 토큰 배치로 바꾸는 전 과정

Tiny GPT를 학습시키려면 먼저 글자 덩어리를 숫자(토큰)로 바꾸고, 일정한 길이로 잘라 배치로 묶어야 합니다. 오늘은 작은 코퍼스를 고르고, BPE 토크나이저를 직접 학습하고, train/val 토큰 파일을 만들어 (batch, block_size) 데이터로더까지 완성합니다. 문서 이어붙이기(concat), packing, loss mask가 왜 필요

By 송민성

오늘의 목표

Tiny GPT 학습에 쓸 작은 텍스트 코퍼스를 고르고, 이를 BPE 토크나이저로 숫자열로 바꿔 train.bin, val.bin 두 파일로 저장합니다. 그리고 파일에서 (batch, block_size) 모양의 입력과 정답 쌍을 뽑아주는 데이터로더(dataloader, 데이터를 모델에 먹일 단위로 잘라 공급하는 코드)를 직접 만듭니다. 내일부터 모델을 짜고 학습시킬 때 이 파일을 그대로 씁니다.

개념과 직관

1. 코퍼스(corpus) 선택

코퍼스는 학습에 쓰는 텍스트 모음입니다. Tiny GPT는 파라미터가 적어서 복잡한 글을 배우기 어렵습니다. 그래서 작고 단순한 데이터가 좋습니다.

  • TinyStories: 어린이가 이해할 만한 단순한 단어로 쓴 짧은 영어 이야기 모음입니다. 작은 모델도 그럴듯한 문장을 만들기 쉬워서 Tiny GPT 실습에 자주 쓰입니다.
  • 한국어 위키 일부: 한국어로 해보고 싶을 때 씁니다. 전체가 아니라 일부(예: 수십 MB)만 씁니다. 위키는 문체가 딱딱하고 주제가 다양해서 TinyStories보다 어렵습니다.

처음에는 TinyStories 하나로 끝까지 돌려보고, 시간이 남으면 한국어로 바꿔보길 권합니다. 데이터셋 이름과 접근 경로는 Hugging Face Hub에서 직접 검색해 확인하세요. 라이선스도 한 번 읽어봅니다.

2. 토크나이저와 BPE

모델은 글자를 모르고 숫자만 압니다. 토크나이저(tokenizer, 텍스트를 토큰 단위로 쪼개 정수 번호로 바꾸는 도구)가 이 변환을 맡습니다.

글자 단위로 쪼개면 어휘가 작지만 시퀀스가 너무 길어집니다. 단어 단위로 쪼개면 처음 보는 단어를 처리하지 못합니다. BPE(Byte Pair Encoding, 자주 붙어 나오는 글자 쌍을 하나의 토큰으로 반복해서 합치는 방식)는 그 중간입니다.

직관: 레고 블록을 떠올리세요. 처음엔 낱개 블록(글자)만 있습니다. 텍스트를 훑어보며 "t" 다음에 "h"가 가장 자주 붙어 있으면 "th"를 새 블록으로 만듭니다. 이걸 정한 횟수만큼 반복하면 "the", "ing", "tion" 같은 자주 쓰는 조각이 블록이 됩니다. 흔한 단어는 블록 1개, 드문 단어는 여러 블록 조합으로 표현됩니다.

BPE 학습 한 단계를 텍스트로 쓰면:

text
1. 코퍼스를 글자(또는 바이트) 단위로 쪼갠다
2. 인접한 두 토큰 쌍의 등장 횟수를 센다
3. 가장 많이 나온 쌍을 하나의 새 토큰으로 합친다
4. 어휘 크기(vocab size)가 목표에 이를 때까지 2~3 반복

Tiny GPT에서는 어휘 크기를 수천 정도로 작게 잡습니다. 어휘가 커지면 모델의 임베딩(embedding, 토큰 번호를 숫자 벡터로 바꾸는 표) 크기가 커져서 작은 모델에는 부담입니다.

3. 문서 concat + 청킹(chunking)

코퍼스는 문서(이야기 하나, 위키 항목 하나) 여러 개로 되어 있고 길이가 제각각입니다. 그런데 모델은 항상 같은 길이의 입력을 받는 편이 효율적입니다.

그래서 모든 문서를 토큰으로 바꾼 뒤 문서 사이에 특수 토큰 <eos>(end of sequence, 문서의 끝을 알리는 표시)를 끼워 하나의 긴 토큰 줄로 이어붙입니다(concat). 그다음 일정 길이 block_size로 잘라냅니다(chunking). 긴 두루마리 휴지에 일정 간격으로 가위질하는 그림입니다.

4. Packing

문서마다 따로 길이를 맞추려고 빈칸(padding, 모자란 길이를 의미 없는 토큰으로 채우는 것)을 넣으면 계산이 낭비됩니다. 위처럼 문서를 이어붙여 블록을 꽉 채우는 방식을 packing이라 부릅니다. 짧은 문서 여러 개가 한 블록에 들어가고, 긴 문서는 여러 블록에 걸칩니다.

단점도 있습니다. 한 블록 안에 서로 관련 없는 문서가 섞입니다. <eos>가 경계를 알려주긴 하지만, 모델이 앞 문서를 참고하게 되는 것을 완전히 막지는 않습니다. Tiny GPT 사전학습 수준에서는 이 단점을 감수하는 경우가 많습니다. 정확히 막으려면 별도의 어텐션 마스크 처리가 필요한데, 이는 모델을 만들 때 다시 다룹니다.

5. 입력 x와 정답 y: 한 칸 밀기

GPT는 "다음 토큰 맞히기"를 학습합니다. 그래서 블록에서 입력과 정답을 한 칸 어긋나게 만듭니다.

text
토큰 줄:  [10, 52, 7, 91, 33, 4, ...]
x(입력):  [10, 52, 7, 91]
y(정답):  [52, 7, 91, 33]

위치마다 "x의 이 자리까지 보고 y의 이 자리를 맞혀라"가 됩니다. 길이 block_size짜리 블록 하나에서 학습 예제가 block_size개 나오는 셈입니다.

6. Loss mask

손실(loss, 모델의 예측이 얼마나 틀렸는지 나타내는 숫자)을 계산할 때 특정 위치를 빼는 것을 loss mask라 합니다. 예를 들어 padding 자리는 맞히든 말든 의미가 없으니 손실에서 제외합니다.

개념만 최소 수식으로 쓰면:

text
loss = (각 위치 손실 × mask) 의 합 / mask의 합
mask = 1이면 반영, 0이면 무시

오늘 만드는 packing 방식은 빈칸이 없으므로 mask가 전부 1이라 사실상 쓰지 않습니다. 하지만 나중에 "질문 부분은 빼고 답변 부분만 학습"하는 상황에서 이 개념이 그대로 필요하니 지금 알아두세요.

7. train/val 분할

데이터 일부(예: 마지막 5~10%)를 따로 떼어 val(validation, 학습에 쓰지 않고 성능 확인용으로만 쓰는 데이터)로 둡니다. 모델이 문제를 외웠는지 진짜 일반화했는지 구분하려면 학습에 안 쓴 데이터로 손실을 재야 합니다.

코드로 직접 해보기

0단계: 준비

bash
pip install tokenizers datasets numpy torch

1단계: 원리 확인, 손으로 만드는 BPE 병합 한 번

라이브러리를 쓰기 전에 "가장 흔한 쌍 찾기"가 어떻게 동작하는지 봅니다.

python
from collections import Counter text = "low lower lowest low low" tokens = list(text.replace(" ", "_")) # 공백을 _로 표시, 글자 단위로 쪼갬 def most_common_pair(tokens): pairs = Counter(zip(tokens, tokens[1:])) return pairs.most_common(1)[0] def merge(tokens, pair): out, i = [], 0 while i < len(tokens): if i < len(tokens) - 1 and (tokens[i], tokens[i+1]) == pair: out.append(tokens[i] + tokens[i+1]) i += 2 else: out.append(tokens[i]) i += 1 return out for step in range(3): pair, count = most_common_pair(tokens) tokens = merge(tokens, pair) print(step, pair, count, tokens)

실행하면 ('l','o') 같은 쌍이 먼저 합쳐지고, 이어서 lo+w 순으로 커지는 것을 볼 수 있습니다. 이게 BPE의 전부이고, 실제 라이브러리는 이를 빠르게 구현한 것입니다.

2단계: 데이터 불러오기

python
from datasets import load_dataset ds = load_dataset("roneneldan/TinyStories", split="train[:20000]") # 일부만 사용 texts = [row["text"] for row in ds] print(len(texts), texts[0][:200])

처음에는 2만 개 정도로 시작하세요. 데이터셋 이름이나 컬럼명이 다르면 Hub 페이지에서 확인해 고칩니다. 한국어 위키를 쓸 경우에도 같은 방식으로 texts 리스트만 만들면 이후 코드는 그대로입니다.

3단계: BPE 토크나이저 학습

python
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders tok = Tokenizer(models.BPE(unk_token=None)) tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False) tok.decoder = decoders.ByteLevel() trainer = trainers.BpeTrainer( vocab_size=4096, special_tokens=["<eos>"], initial_alphabet=pre_tokenizers.ByteLevel.alphabet(), ) tok.train_from_iterator(texts, trainer=trainer) tok.save("tok.json") ids = tok.encode("Once upon a time, there was a little cat.").ids print(ids) print(tok.decode(ids))

바이트 단위(ByteLevel)로 시작하면 어떤 문자(한글 포함)도 처음 보는 글자 때문에 깨지지 않습니다. decode한 결과가 원문과 같은지 꼭 확인하세요.

4단계: 이어붙이고 train/val 파일로 저장

python
import numpy as np eos_id = tok.token_to_id("<eos>") all_ids = [] for t in texts: all_ids.extend(tok.encode(t).ids) all_ids.append(eos_id) # 문서 경계 표시 arr = np.array(all_ids, dtype=np.uint16) # 어휘 65536 미만이면 uint16으로 충분 n = int(len(arr) * 0.95) arr[:n].tofile("train.bin") arr[n:].tofile("val.bin") print("전체 토큰:", len(arr), "train:", n, "val:", len(arr) - n)

uint16은 0~65535 정수를 2바이트로 저장하는 형식입니다. 어휘 크기가 4096이므로 충분하고, 파일 크기를 절반으로 줄입니다. 어휘를 6만 5천 이상으로 키우면 uint32를 써야 합니다.

문서 단위로 나눠서 val을 만드는 편이 더 엄밀하지만, 오늘은 단순하게 토큰 줄의 끝 5%를 씁니다.

5단계: (batch, block_size) 데이터로더

python
import torch block_size = 128 batch_size = 32 def get_batch(split): data = np.memmap(f"{split}.bin", dtype=np.uint16, mode="r") starts = torch.randint(0, len(data) - block_size - 1, (batch_size,)) x = torch.stack([torch.from_numpy(data[i : i + block_size].astype(np.int64)) for i in starts]) y = torch.stack([torch.from_numpy(data[i + 1 : i + 1 + block_size].astype(np.int64)) for i in starts]) return x, y x, y = get_batch("train") print(x.shape, y.shape) # torch.Size([32, 128]) 두 개 print((x[0, 1:] == y[0, :-1]).all()) # True여야 한다: y는 x를 한 칸 민 것 print(tok.decode(x[0].tolist())[:200])

np.memmap은 큰 파일을 통째로 메모리에 올리지 않고 필요한 부분만 읽는 방식입니다. 여기서는 매번 무작위 위치에서 block_size만큼 잘라냅니다(랜덤 샘플링). 정해진 간격으로 겹치지 않게 자르는 방식도 있지만, 학습 초기에는 이 방식이 가장 간단합니다.

6단계: loss mask 맛보기

python
import torch.nn.functional as F logits = torch.randn(2, 4, 10) # (batch, 위치, 어휘) 가짜 예측 targets = torch.randint(0, 10, (2, 4)) mask = torch.tensor([[1, 1, 1, 0], [1, 1, 0, 0]], dtype=torch.float) per_tok = F.cross_entropy(logits.view(-1, 10), targets.view(-1), reduction="none").view(2, 4) loss = (per_tok * mask).sum() / mask.sum() print(loss)

reduction="none"은 평균 내지 않고 위치별 손실을 그대로 돌려주라는 옵션입니다. 거기에 mask를 곱하고 mask 합으로 나눠야 "반영한 위치들의 평균"이 됩니다.

오늘의 실험

오늘은 별도 실험이 없습니다. 다만 코드를 돌린 뒤 아래 수치를 노트에 간단히 적어두면 내일 이후 학습 결과를 해석할 때 도움이 됩니다.

  • 사용한 코퍼스, 문서 수, 전체 토큰 수, train/val 토큰 수
  • vocab_size
  • 샘플 문장 하나의 토큰 수 대비 글자 수 (압축률의 감각)
  • x, y shape, 그리고 y가 x를 한 칸 민 것임을 확인한 결과

왜 이걸 배우나 (LLM / Post-training 연결)

LLM의 성능과 비용은 모델 구조만큼이나 데이터와 토크나이저에서 갈립니다. 토큰이 비효율적이면 같은 문장이 더 길어져 학습·추론 비용이 늘고, 데이터 경계나 분할이 잘못되면 평가 수치를 믿을 수 없게 됩니다. 또 오늘 맛본 loss mask는 이후 지시 따르기 데이터로 미세조정(fine-tuning)할 때 "답변 부분에만 손실을 계산"하는 핵심 장치로 다시 나오므로, Post-training 업무의 기초 체력이 됩니다.

초보자가 막히는 지점

  • decode가 원문과 다르다: 바이트 레벨 설정(pre_tokenizer와 decoder)을 둘 다 넣었는지 확인하세요. 한쪽만 빠지면 공백이나 특수문자가 어긋납니다.
  • `.bin` 파일을 읽을 때 dtype을 틀린다: 저장할 때 uint16이면 읽을 때도 uint16이어야 합니다. 다르면 숫자가 엉뚱하게 나옵니다.
  • x와 y 인덱스 off-by-one: y는 i+1부터 시작합니다. 위 코드의 True 검증을 꼭 돌려보세요.
  • 데이터가 너무 작아 `randint` 범위 에러: 토큰 수가 block_size보다 작으면 샘플링할 수 없습니다. 데이터를 늘리거나 block_size를 줄이세요.
  • val이 train과 사실상 같은 내용: 중복 문서가 많은 데이터셋은 val 손실이 실제보다 좋게 나옵니다. 의심스러우면 문서 단위로 분할하세요.
  • 어휘 크기 욕심: 큰 어휘가 항상 좋은 건 아닙니다. 작은 모델과 작은 데이터에서는 드문 토큰이 충분히 학습되지 못합니다.
  • 메모리 부족: 처음부터 전체 데이터를 쓰지 말고 일부로 파이프라인이 끝까지 도는지 먼저 확인하세요.

알고리즘 (병행 1시간)

LeetCode Medium 트리 문제 2문제를 풉니다. 문제는 직접 골라도 되고, 트리 순회(DFS/BFS)나 재귀로 풀 수 있는 Medium을 우선 고르세요. 문제 이름을 외우기보다 풀이 과정을 기록하는 데 집중합니다.

각 문제마다 다음을 기록하세요.

  • 접근: 어떤 순회(전위/중위/후위/레벨 순서)를 썼고 왜 그것을 골랐는지, 재귀의 종료 조건은 무엇인지
  • 시간복잡도 / 공간복잡도: 노드 수 N, 트리 높이 H로 표현 (재귀 깊이가 공간에 미치는 영향 포함)
  • 틀린 이유: 첫 제출이나 막혔던 부분이 있다면 원인 (예: 빈 트리 처리 누락, 반환값 설계 실수, 전역 변수 초기화 문제)

1시간 안에 풀리지 않으면 30분쯤에서 풀이 힌트를 보고, 이해한 뒤 아무것도 보지 않고 다시 구현해 보세요.