mini GPT 과제 랩 구현 시리즈 1편
이번 구현의 첫 관문은 토크나이저였다. 과제에서는 외부 tokenizer를 금지했기 때문에, 한국어 리뷰 문자열을 직접 UTF-8 byte-level BPE 방식으로 token ID 목록으로 바꿔야 했다.
이 글에서는 실제 구현된 `src/bpe.py`를 기준으로 초기 사전 구성, BPE 학습, 저장/로드, encode/decode 복원을 코드 블록 단위로 본다.
테스트 통과 근거
노트북 실행 결과 기준 `tests/test_bpe.py`는 6개 테스트가 통과했다.
tests/test_bpe.py
- special token ID 고정
- 초기 vocabulary 구성
- save/load 복원
- encode/decode 원문 복원
- get_pad_id/get_bos_id/get_eos_id 확인
- train 이후 vocabulary 증가 확인
결과: 6 passed
1. ID 배치를 먼저 고정한다
토크나이저에서 가장 먼저 정해야 하는 것은 약속이다. 어떤 ID가 padding이고, 어떤 ID가 문장 시작과 끝인지 고정되어 있어야 Dataset과 fine-tuning에서도 같은 기준을 쓸 수 있다.
PAD_TOKEN = "<pad>"
UNK_TOKEN = "<unk>"
BOS_TOKEN = "<bos>"
EOS_TOKEN = "<eos>"
SPECIAL_TOKENS = [PAD_TOKEN, UNK_TOKEN, BOS_TOKEN, EOS_TOKEN]
SPECIAL_IDS = {token: idx for idx, token in enumerate(SPECIAL_TOKENS)}
BYTE_OFFSET = len(SPECIAL_TOKENS)
NUM_BYTES = 256
| ID 범위 | 의미 | 왜 필요한가 |
|---|---|---|
| 0~3 | 특수 토큰 | padding, unknown, 문장 시작/끝 표시 |
| 4~259 | byte 0~255 | 모든 UTF-8 문자열을 최소 단위로 표현 |
| 260 이상 | BPE merge token | 자주 붙는 byte/token 조합을 압축 |
2. 초기 vocabulary는 모든 byte를 포함한다
`_init_special_tokens()`는 BPE 학습을 하는 함수가 아니다. 모든 문자를 최소한 byte 단위로 표현할 수 있도록 기본 사전을 까는 함수다.
def _init_special_tokens(self):
self.id_to_token = {}
self.token_to_id = {}
self.merges = []
for idx, token in enumerate(SPECIAL_TOKENS):
self.id_to_token[idx] = token
self.token_to_id[token] = idx
for byte_value in range(NUM_BYTES):
token_id = BYTE_OFFSET + byte_value
token = bytes([byte_value])
self.id_to_token[token_id] = token
self.token_to_id[token] = token_id
여기서 `bytes([byte_value])`로 저장하는 이유가 중요하다. 나중에 decode할 때 merge token을 원본 byte까지 재귀적으로 펼쳐야 하기 때문이다.
3. train은 가장 자주 나온 pair를 하나씩 합친다
학습은 같은 corpus의 token ID 목록을 계속 압축해 가는 과정이다. 매 반복마다 인접 pair 빈도를 다시 세고, 가장 자주 나온 pair 하나를 새 토큰으로 등록한다.
문자열을 byte 값으로 바꿈
이웃한 token pair 빈도 계산
가장 많이 나온 pair를 새 token ID로 등록
해당 pair를 새 ID 하나로 치환
while len(self.id_to_token) < self.vocab_size and len(ids) >= 2:
pair_counts = {}
for i in range(len(ids) - 1):
pair = (ids[i], ids[i + 1])
pair_counts[pair] = pair_counts.get(pair, 0) + 1
best_pair = max(pair_counts, key=pair_counts.get)
best_count = pair_counts[best_pair]
if best_count < 2:
break
new_id = len(self.id_to_token)
self.merges.append(best_pair)
self.id_to_token[new_id] = best_pair
self.token_to_id[best_pair] = new_id
`best_count < 2`에서 멈추는 선택도 의미가 있다. 한 번만 나온 조합까지 계속 합치면 반복 패턴을 배운다기보다 corpus를 억지로 외우는 쪽에 가까워진다.
4. encode는 학습된 merge 순서를 재현한다
encode는 새로운 token을 만들면 안 된다. 학습 때 저장한 `self.merges`를 같은 순서로 적용해, 새 문장을 기존 vocabulary 안의 ID 목록으로 바꾼다.
ids = [BYTE_OFFSET + b for b in text.encode("utf-8")]
for pair in self.merges:
merge_id = self.token_to_id.get(pair)
if merge_id is None:
continue
ids = merge_current_pair(ids, pair, merge_id)
BPE에서 merge 순서가 바뀌면 결과 ID도 달라질 수 있다. 그래서 `save()`와 `load()`는 vocabulary뿐 아니라 merge rule의 순서도 보존해야 한다.
5. decode는 merge token을 byte까지 펼친 뒤 한 번에 복원한다
가장 위험한 실수는 token ID 하나를 곧바로 문자 하나로 생각하는 것이다. merge token 안에는 다른 token ID 두 개가 들어 있고, 그 안에 또 merge token이 들어 있을 수 있다.
def decode(self, ids, skip_special=True):
byte_values = []
for token_id in ids:
if skip_special and token_id in SPECIAL_IDS.values():
continue
byte_values.extend(self._token_to_bytes(token_id))
return bytes(byte_values).decode("utf-8", errors="replace")
한글은 여러 byte가 모여 한 글자가 된다. 따라서 byte를 중간에 하나씩 문자열로 바꾸면 깨질 수 있고, 원본 byte를 모두 모은 뒤 마지막에 한 번만 UTF-8로 복원해야 한다.
이 구현이 통과한 핵심 계약
- `<pad>`, `<unk>`, `<bos>`, `<eos>` ID가 고정되어 있다.
- 모든 byte 0~255가 기본 vocabulary에 들어간다.
- 학습한 vocabulary를 저장하고 다시 불러와도 encode/decode가 유지된다.
- 한글, 영어, 숫자, 문장부호가 섞인 문장도 decode(encode(text))로 복원된다.
다음 글 예고
다음 구현 글에서는 `GPTDataset`과 `InputEmbedding`을 본다. BPE가 만든 token ID 목록을 “다음 토큰 예측” 학습 샘플로 자르고, 모델 입력 벡터로 바꾸는 단계다.
한 줄 정리: 이번 BPE 구현의 핵심은 한글을 byte 단위에서 안전하게 시작하고, 학습된 merge 규칙을 저장해 encode와 decode가 같은 약속을 공유하게 만드는 것이다.