카테고리 없음

mini GPT 과제 1편 - src/bpe.py byte-level BPE 구현 리뷰

cedis 2026. 6. 1. 11:31

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 하나를 새 토큰으로 등록한다.

corpus.encode("utf-8")
문자열을 byte 값으로 바꿈
pair_counts
이웃한 token pair 빈도 계산
best_pair
가장 많이 나온 pair를 새 token ID로 등록
ids 갱신
해당 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가 같은 약속을 공유하게 만드는 것이다.