내 에서 을 직접 합니다. tokenizer.py 로 가장 자주 붙는 글자 쌍을 합치고, 인코딩 → 디코딩이 원래 글로 돌아오는지 확인해 보세요.
4
BPE 토크나이저
는 글 조각마다 번호를 붙입니다. 이 문자 기반 는 한 글자씩 시작해 붙어 있는 두 조각의 횟수를 세고, 가장 자주 나온 쌍을 합칩니다. 합친 순서가 중요하므로 글을 번호로 바꿀 때도 같은 순서로 규칙을 적용합니다.
이 작은 교육용 구현은 을 합치지 않고 모르는 글자에 0번을 씁니다. 모든 를 다루는 상용 토크나이저와는 다릅니다. tokenizer.json에 와 을 함께 저장합니다. 특정 사전으로 한 에 다른 사전을 끼우면 번호의 뜻이 달라집니다.
왜 토큰화를 하나 — 컴퓨터는 숫자로만 계산한다
모델은 덧셈과 곱셈만 할 줄 압니다. "cat" 이라는 글자로는 계산할 수 없으므로 글을 정수 번호의 목록으로 바꿔야 합니다. 이 일을 토크나이저가 합니다.
: 글 → 번호 목록. 기본 자료로 배운 토크나이저에서 "cat" → [9, 7, 23].
decode: 번호 목록 → 글. [9, 7, 23] → "cat".
번호가 필요한 곳은 세 군데입니다.
: 번호로 표의 한 줄을 꺼냅니다(5단계).
: 어휘의 번호마다 점수를 냅니다. 번호의 개수가 곧 고를 수 있는 다음 의 개수입니다.
: 정답 번호의 을 봅니다(6단계).
번호 자체에는 뜻이 없습니다. 9번과 10번이 비슷한 글자라는 보장은 없습니다. 뜻은 학습을 거쳐 그 번호의 임베딩 벡터에 생깁니다.
어떤 단위로 자를까 — 글자·단어·서브워드
토큰의 단위를 정하는 방법은 세 가지입니다.
글자 단위: 어휘가 작고(영어는 수십 개) 모르는 글자가 거의 없습니다. 대신 글이 길어져 모델이 한 번에 볼 수 있는 내용이 줄고, 글자에서 낱말 뜻을 모으는 일을 모델이 모두 해야 합니다.
단어 단위: 글이 짧아지지만 어휘가 수십만 개로 커지고, 사전에 없는 낱말(새 이름·오타·)을 다룰 수 없습니다.
서브워드 단위(BPE 등): 자주 나오는 조각은 한 토큰으로, 드문 낱말은 여러 조각으로 나눕니다. 어휘 크기와 글 길이의 균형이 맞아 오늘날 대부분의 이 씁니다.
내 자료로 토크나이저를 학습하는 까닭도 여기 있습니다. 범용 토크나이저는 주로 영어 글로 배워서 다른 언어나 특수한 분야의 글을 잘게 쪼갭니다. 예를 들어 " 150k" 는 구인 글로 배운 토크나이저로는 3토큰, 범용 토크나이저로는 6토큰이 됩니다. 토큰이 적을수록 같은 길이에 더 많은 내용이 들어가고 학습도 빨라집니다.
BPE 알고리즘 — 가장 자주 붙는 쌍을 합친다
BPE(Byte Pair Encoding)는 글자에서 시작해 가장 자주 붙어 나오는 두 조각을 새 토큰 하나로 합치기를 되풀이합니다.
처음 어휘는 자료에 나온 글자 하나하나입니다.
자료 전체에서 붙어 있는 두 토큰의 쌍을 모두 셉니다.
가장 많이 나온 쌍을 새 토큰으로 어휘에 더하고, 자료의 그 쌍을 모두 새 토큰으로 바꿉니다.
정한 횟수(settings.json 의 merges)만큼, 또는 2번 이상 나오는 쌍이 없을 때까지 되풀이합니다.
아래 실험은 "low lower lowest" 로 네 번 병합합니다. 16토큰이던 글이 병합할 때마다 짧아져 6토큰이 됩니다. l+o → lo, lo+w → low 처럼 앞에서 합친 토큰이 다음 병합의 재료가 되어 점점 긴 조각이 생기는 것을 보세요. 세 번째 병합 " "+"low" 처럼 공백도 조각에 붙습니다. 은 공백으로 미리 나누지 않기 때문입니다. 실제 는 보통 먼저 공백·바이트 단위로 나눈 뒤(사전 분리, pre-tokenizer) 그 안에서만 병합합니다.
직접 확인하기 lab/bpe_trace.py
from tokenizer import BPE, merge
text = 'low lower lowest'
tok = BPE.train(text, 4)
ids = [tok.chars[c] for c in text]
show = lambda ids: '|'.join(tok.vocab[i] for i in ids)
print('start:', show(ids), len(ids))
for (a, b), new_id in tok.merges:
ids = merge(ids, (a, b), new_id)
print(repr(tok.vocab[a]), '+', repr(tok.vocab[b]), '->', show(ids), len(ids))
~/xslm 에 lab 폴더를 만들고 같은 이름의 파일에 직접 입력한 뒤 실행하세요. -m lab.이름 은 (~/xslm)를 에 넣으므로 앞 단계에서 만든 tokenizer.py·engine.py·model.py 를 그대로 불러옵니다. python3 lab/이름.py 로 실행하면 lab 폴더 안에서만 찾아 가 납니다.
cd ~/xslm
mkdir -p lab
micro lab/bpe_trace.py
python3 -m lab.bpe_trace
merges: 병합 규칙의 목록입니다. 규칙 하나는 [[앞 번호, 뒤 번호], 새 번호] 입니다.
번호는 이렇게 매깁니다.
0번은 언제나 � 입니다. 어휘에 없는 글자를 대신합니다.
1번부터는 에 나온 글자를 정렬한 순서입니다. 기본 자료는 줄바꿈·공백·마침표·쌍점·대문자 A·Q·소문자 21개, 모두 27글자가 1~27번입니다.
그다음은 병합한 순서대로 붙습니다. 기본 자료의 첫 병합은 ":"+" " → ": " (28번), 둘째는 "Q"+": " → "Q: " (29번), 셋째는 "A"+": " → "A: " (30번)입니다.
그래서 어휘 수 = 1 + 글자 종류 + 병합 수 이고, 기본 자료는 1 + 27 + 12 = 40입니다. 정렬해서 번호를 매기므로 같은 자료로 다시 학습하면 언제나 같은 번호가 나옵니다.
인코딩 — 규칙을 배운 순서대로 다시 적용한다
새 글을 번호로 바꿀 때는 먼저 글자마다 번호를 찾고(모르는 글자는 0), 저장한 병합 규칙을 배운 순서대로 하나씩 적용합니다.
순서가 중요한 까닭은 앞 규칙이 만든 토큰을 뒤 규칙이 재료로 쓰기 때문입니다. "Q: " 를 만드는 규칙(Q + ": ")은 ": " 가 먼저 만들어져 있어야 맞아떨어집니다. 순서를 바꾸면 학습 때와 다른 토큰이 나옵니다.
merge 는 목록을 왼쪽부터 훑으며 겹치지 않게 합칩니다. 두 칸이 쌍과 맞으면 새 번호를 넣고 두 칸을 건너뛰고, 아니면 한 칸을 그대로 옮깁니다. 그래서 "aaa" 에서 a+a 를 합치면 [aa, a] 가 되고 [a, aa] 는 되지 않습니다.
decode 는 번호마다 vocab 의 조각을 꺼내 이어 붙일 뿐입니다. 조각이 모두 원래 글자를 이어 붙인 것이므로 encode 한 것을 decode 하면 원래 글로 정확히 돌아옵니다(). 모르는 글자만 �로 바뀌어 돌아오지 않습니다.
경계에서 생기는 함정도 있습니다. 학습 자료에서 "A:" 뒤에는 늘 공백이 와서 "A: " 가 한 토큰으로 합쳐졌습니다. 그래서 를 공백 없이 "A:" 로 끝내면 "A" 와 ":" 두 토큰이 되는데, 이 둘은 학습 자료에 한 번도 따로 나온 적이 없습니다. 생성 프롬프트는 학습 형식과 공백까지 똑같이 "A: " 로 끝냅니다.
줄바꿈은 합치지 않는다 — 생성이 멈추는 자리
tokenizer.py 의 train 은 쌍을 셀 때 두 조각을 이은 글에 줄바꿈이 들어 있으면 세지 않습니다. 그래서 줄바꿈은 언제나 혼자 한 토큰(1번)으로 남습니다.
까닭은 generate.py 가 줄바꿈 토큰을 뽑으면 생성을 멈추기 때문입니다. 학습 자료에서 답은 줄바꿈으로 끝나므로, 모델은 답을 다 쓰면 줄바꿈을 내도록 배웁니다. 만약 ".\n" 처럼 마침표와 줄바꿈이 한 토큰으로 합쳐지면 줄바꿈이 다른 토큰 안에 숨어 멈출 자리를 찾지 못합니다.
실제 언어 모델은 이 일을 끝 표시 (EOS)으로 합니다. XSLM 은 줄바꿈을 EOS 처럼 씁니다.
모르는 글자 — 0번 � 와 바이트 단위 BPE
XSLM 의 토크나이저는 학습 자료에 나온 글자만 압니다. 기본 자료에는 대문자 C 도, 한글도, 이모지도 없으므로 아래 실험에서 Cat 의 C, 안녕, 😀 는 모두 0번이 됩니다. 0번은 "모르는 글자"라는 뜻만 남고 원래 글자는 사라지므로 decode 하면 �가 나옵니다.
이후의 많은 토크나이저는 글자 대신 바이트에서 시작합니다(바이트 단위 BPE).
UTF-8 은 글자 하나를 1~4바이트로 적습니다. 실험 결과처럼 a 는 1바이트, 안 은 3바이트, 😀 는 4바이트입니다.
바이트는 256가지뿐이므로 처음 어휘 256개로 세상의 모든 글을 적을 수 있고 모르는 글자가 생기지 않습니다.
대신 처음 보는 글자는 여러 바이트 토큰으로 길게 쪼개집니다.
XSLM 은 읽기 쉽게 글자 단위로 만들었습니다. 쓸 글자를 학습 자료에 넣어 두는 것이 해결법입니다.
직접 확인하기 lab/unknown.py
import json
from pathlib import Path
from tokenizer import BPE
tok = BPE(**json.loads(Path('data/tokenizer.json').read_text()))
for text in ['cat', 'Cat', '안녕', '😀']:
print(text, tok.encode(text), list(text.encode('utf-8')))
cd ~/xslm
mkdir -p lab
micro lab/unknown.py
python3 -m lab.unknown
실제 토크나이저는 글에 나오지 않는 약속용 토큰을 어휘 앞쪽에 둡니다. 흔히 0~3번에 이 넷을 둡니다.
[PAD]: 길이가 다른 글을 한 묶음(batch)으로 계산할 때 짧은 글 뒤를 채웁니다.
[UNK]: 어휘에 없는 조각입니다. XSLM 의 0번 � 와 같습니다.
[BOS]: 글의 시작을 알립니다.
[EOS]: 글의 끝을 알립니다. 생성은 이 토큰이 나오면 멈춥니다.
자료 형식의 표시([QUESTION]·[ANSWER] 등)도 쪼개지지 않게 특수 토큰으로 등록할 수 있습니다. 대화 모델은 사용자와 도우미의 차례를 나누는 특수 토큰을 씁니다.
XSLM 은 이 가운데 [UNK] 만 0번으로 둡니다. 한 번에 한 구간만 학습하므로 채움이 필요 없고, 줄바꿈이 끝 표시를 맡고, Q:·A: 는 BPE 가 스스로 한 토큰("Q: "·"A: ")으로 합칩니다.
어휘 크기 정하기 — 병합 수가 바꾸는 것
어휘 크기(vocab_size)는 토큰 종류의 수이고, 번호는 0부터 vocab_size − 1 까지입니다. 병합을 늘리면 두 가지가 반대로 움직입니다.
글이 짧아집니다. 아래 실험에서 병합이 0번이면 학습 자료가 309토큰, 12번이면 218토큰, 48번이면 138토큰입니다. 문맥 12칸 안에 더 많은 글이 들어갑니다.
모델이 커집니다. 임베딩 표(token)와 출력층(head)이 어휘 수만큼 줄을 가지므로, 어휘가 40에서 76이 되면 두 표의 숫자가 각각 320개에서 608개로 늘어납니다.
너무 크면 드문 토큰이 많아져 토큰마다 학습할 예가 부족해집니다. 보통 큰 자료는 어휘 16,000~32,000, 아주 작은 자료(10KB 이하)는 500~1,000 을 씁니다. 어림값은 "자료의 글자 수 ÷ 100~500" 입니다.
병합은 정한 횟수보다 일찍 멈출 수 있습니다. 2번 이상 나오는 쌍이 없으면 합쳐도 이득이 없기 때문입니다. 자료가 작을 때 흔한 일이며 정상입니다.
토크나이저를 다시 학습하면 번호의 뜻이 바뀌므로 모델도 처음부터 다시 학습합니다. 토크나이저와 모델의 어휘 수가 다르면 임베딩 표의 줄 수가 맞지 않아 체크포인트를 읽을 수 없습니다.
직접 확인하기 lab/token_count.py
from pathlib import Path
from tokenizer import BPE
text = Path('data/train.txt').read_text(encoding='utf-8')
for count in [0, 6, 12, 24, 48]:
tok = BPE.train(text, count)
print('merges', count, 'vocab', len(tok.vocab), 'tokens', len(tok.encode(text)))
cd ~/xslm
mkdir -p lab
micro lab/token_count.py
python3 -m lab.token_count
merge는 겹치지 않게 한 쌍을 합칩니다. train은 순서 있는 규칙을 배우고, encode는 그 규칙을 다시 적용합니다. decode는 조각을 이어 붙입니다. 마지막 는 원문 복원을 검사합니다.
import json
from collections import Counter
from pathlib import Path
def merge(ids, pair, new_id):
result, i = [], 0
while i < len(ids):
if tuple(ids[i:i + 2]) == tuple(pair):
result.append(new_id)
i += 2
else:
result.append(ids[i])
i += 1
return result
class BPE:
def __init__(self, vocab, merges):
self.vocab, self.merges = vocab, merges
self.chars = {c: i for i, c in enumerate(vocab) if len(c) == 1}
@classmethod
def train(cls, text, count):
vocab = ['\ufffd'] + sorted(set(text) - {'\ufffd'})
ids = [vocab.index(c) for c in text]
merges = []
for _ in range(count):
pairs = Counter((a, b) for a, b in zip(ids, ids[1:])
if '\n' not in vocab[a] + vocab[b])
if not pairs:
break
pair, frequency = pairs.most_common(1)[0]
if frequency < 2:
break
new_id = len(vocab)
vocab.append(vocab[pair[0]] + vocab[pair[1]])
merges.append([list(pair), new_id])
ids = merge(ids, pair, new_id)
return cls(vocab, merges)
def encode(self, text):
ids = [self.chars.get(c, 0) for c in text]
for pair, new_id in self.merges:
ids = merge(ids, pair, new_id)
return ids
def decode(self, ids):
return ''.join(self.vocab[i] for i in ids)
def state(self):
return {'vocab': self.vocab, 'merges': self.merges}
if __name__ == '__main__':
settings = json.loads(Path('settings.json').read_text())
text = Path('data/train.txt').read_text(encoding='utf-8')
tokenizer = BPE.train(text, settings['merges'])
Path('data/tokenizer.json').write_text(
json.dumps(tokenizer.state(), ensure_ascii=False), encoding='utf-8')
assert tokenizer.decode(tokenizer.encode(text)) == text
print('vocab:', len(tokenizer.vocab), 'merges:', len(tokenizer.merges))
print('roundtrip: OK', tokenizer.encode('Q: hi\nA: '))
코드 한 줄씩 뜯어보기
tokenizer.py merge — 겹치지 않게 한 쌍 합치기
def merge(ids, pair, new_id): result, i = [], 0 while i < len(ids): if tuple(ids[i:i + 2]) == tuple(pair): result.append(new_id) i += 2 else: result.append(ids[i]) i += 1 return result
ids 목록을 i 로 왼쪽부터 훑습니다. ids[i:i + 2] 는 지금 칸과 다음 칸 두 개(끝이면 하나)입니다. 그것이 pair 와 같으면 new_id 하나를 결과에 넣고 i 를 2 늘려 두 칸을 건너뜁니다. 아니면 지금 칸을 그대로 넣고 1 늘립니다. tuple 로 바꿔 비교하는 까닭은 JSON 에서 읽은 규칙이 이기 때문입니다(리스트와 튜플은 == 로 같지 않습니다). 목록을 한 번 지나므로 길이에 비례하는 시간이 듭니다.
tokenizer.py 저장한 상태로 토크나이저 만들기
class BPE: def __init__(self, vocab, merges): self.vocab, self.merges = vocab, merges self.chars = {c: i for i, c in enumerate(vocab) if len(c) == 1}
BPE 는 vocab 과 merges 두 가지만 가집니다. 그래서 tokenizer.json 에서 읽은 사전을 키워드 인자로 풀어 BPE(…) 에 그대로 넘겨 다시 만들 수 있고, train.py·generate.py 가 이렇게 씁니다. chars 는 한 글자짜리 토큰만 모은 "글자 → 번호" 조회표입니다. encode 의 첫 단계에서 글자마다 번호를 빨리 찾는 데 씁니다.
tokenizer.py train — 병합 규칙 배우기
@classmethod def train(cls, text, count): vocab = ['\ufffd'] + sorted(set(text) - {'\ufffd'}) ids = [vocab.index(c) for c in text] merges = [] for _ in range(count): pairs = Counter((a, b) for a, b in zip(ids, ids[1:]) if '\n' not in vocab[a] + vocab[b]) if not pairs: break pair, frequency = pairs.most_common(1)[0] if frequency < 2: break new_id = len(vocab) vocab.append(vocab[pair[0]] + vocab[pair[1]]) merges.append([list(pair), new_id]) ids = merge(ids, pair, new_id) return cls(vocab, merges)
@classmethod 는 객체 없이 BPE.train(…) 으로 부르는, 객체를 만들어 돌려주는 함수입니다.
첫 어휘는 \ufffd(�)를 0번에 두고, 글에 나온 글자를 sorted 로 정렬해 붙입니다. 정렬하므로 번호가 늘 같습니다.
ids 는 글자마다 번호를 찾은 목록입니다(vocab.index).
마다 Counter 로 붙어 있는 쌍 (a, b) 를 셉니다. zip(ids, ids[1:]) 은 이웃한 두 칸씩 짝을 짓습니다. 두 조각을 이은 글에 줄바꿈이 있으면 세지 않습니다.
most_common(1) 이 가장 많은 쌍과 횟수를 줍니다. 횟수가 같으면 먼저 센 쌍, 즉 글에서 먼저 나온 쌍이 뽑힙니다.
쌍이 없거나 가장 많은 쌍도 1번뿐이면 멈춥니다.
새 번호는 (vocab), 즉 어휘의 다음 번호입니다. 두 조각을 이은 글을 어휘에 더하고, 규칙 [[a, b], 새 번호] 를 기록하고, ids 를 합친 목록으로 바꿔 다음 반복을 준비합니다.
tokenizer.py encode — 글을 번호로
def encode(self, text): ids = [self.chars.get(c, 0) for c in text] for pair, new_id in self.merges: ids = merge(ids, pair, new_id) return ids
chars.get(c, 0) 은 글자의 번호를 찾고, 없으면 0(�)을 줍니다. 그다음 merges 를 배운 순서대로 하나씩 merge 합니다. 규칙 수만큼 목록을 다시 훑으므로 규칙이 많고 글이 길면 느려집니다. 실제 라이브러리는 규칙의 우선순위 표를 써서 더 빠르게 합칩니다.
tokenizer.py decode·state — 번호를 글로, 상태 저장
def decode(self, ids): return ''.join(self.vocab[i] for i in ids) def state(self): return {'vocab': self.vocab, 'merges': self.merges}
decode 는 번호마다 vocab 의 조각을 꺼내 ''.join 으로 이어 붙입니다. state 는 json 으로 저장할 수 있는 사전을 돌려줍니다. 규칙을 튜플 대신 리스트로 기록하므로 JSON 으로 저장하고 다시 읽어도 모양이 같습니다.
settings.json 의 merges 와 /train.txt 로 학습하고, ensure_ascii=False 로 tokenizer.json 에 저장합니다. 이 옵션이 없으면 한글이 \uc548 같은 이스케이프로 저장됩니다. assert 는 학습 자료 전체를 encode → decode 해서 원래 글과 똑같은지 검사합니다. 마지막 줄은 생성 프롬프트 "Q: hi" 다음 줄 "A: " 가 어떤 번호가 되는지 보여 줍니다. 기본 자료면 [29, 13, 14, 1, 30], 즉 "Q: "·h·i·줄바꿈·"A: " 입니다.
여기까지 이해했나요?
roundtrip: OK는 전체 학습 자료를 번호로 바꿨다가 원문 그대로 되돌렸다는 뜻입니다. settings.json의 병합 횟수를 바꾸어 토큰 수를 비교하세요. 처음 보는 글자는 새로 배우지 않고 �로 바뀝니다.
막혔을 때 확인하기
되풀이되는 쌍이 없으면 병합이 없어도 정상입니다. 글이나 병합 규칙을 바꾸었다면 토크나이저와 모델 를 새로 학습해야 합니다. 는 먼저 내보내 보관하세요.
자주 묻는 질문
토크나이저 라이브러리를 쓰지 않고 왜 직접 만드나요?
실제 개발에서는 tokenizers 나 tiktoken 같은 라이브러리를 씁니다. 등으로 만들어 큰 자료도 금방 처리합니다. XSLM 은 병합 규칙이 어떻게 생기고 어떤 순서로 적용되는지 직접 보려고 60줄로 만듭니다. 라이브러리가 저장하는 tokenizer.json 에도 같은 vocab·merges 가 들어 있습니다.
roundtrip 은 OK 인데 내 질문이 � 로 바뀌어요.
roundtrip 은 학습 자료로만 검사합니다. 질문에 학습 자료에 없는 글자(대문자·한글·기호)가 있으면 0번이 됩니다. 그 글자가 들어간 줄을 raw.txt 에 더하고 prepare → tokenizer → 새 체크포인트로 학습을 다시 하세요.
토큰 하나는 낱말 하나인가요?
아닙니다. 토큰은 BPE 가 자주 본 조각이라 "is " 처럼 낱말과 공백이 붙거나 " ca" 처럼 낱말의 일부일 수 있습니다. 실제 모델도 영어 낱말 하나가 평균 한 개 남짓의 토큰이고, 다른 언어는 더 잘게 쪼개지기도 합니다. 그래서 요금과 문맥 길이를 낱말이 아니라 토큰으로 셉니다.