亲手做一个分词器
한국어 원문으로 표시합니다.
목표
바이트 수준 BPE 를 표준 라이브러리만으로 직접 만든다. UTF-8 바이트에서 출발해 인접 쌍을 세고, 가장 흔한 쌍을 합치는 규칙을 배우고, 그 규칙을 배운 순서대로 적용해 부호화한 뒤 원문으로 정확히 되돌린다. 마지막에 어휘 크기를 바꿔 가며 토큰 수가 줄어드는 곡선을 재고, 같은 내용의 한국어 문단과 영어 문단을 하나의 어휘로 부호화해 토큰 수를 나란히 놓는다.
왜 중요한가
요금도 문맥 한도도 단위가 토큰인데, 토큰은 글자도 낱말도 아니다. 어떤 조각을 하나로 셀지는 어휘를 만들 때 정해지고 어휘는 모델마다 다르다. 그래서 "글자 수에 얼마를 곱하면 된다" 는 어림은 언어가 바뀌는 순간 무너진다. 한글 음절 하나는 UTF-8 로 3바이트라, 어휘가 작으면 한 글자가 토큰 셋이 된다. 이 실습은 실제 모델의 토크나이저를 부르지 않는다. 이 파드에는 transformers·tokenizers·tiktoken 이 없고 numpy 도 시스템 파이썬에는 없다. 대신 같은 알고리즘을 손으로 만들고, 여러분이 만든 어휘로 잰 숫자만 쓴다. 그래서 "어느 모델은 이 문장을 몇 토큰으로 센다" 같은 말은 여기서 하지 않는다. 알고리즘 자체는 한 문장이다 — 가장 자주 붙어 다니는 둘을 하나로 합치는 일을 어휘가 원하는 크기가 될 때까지 되풀이한다. 어려운 것은 세부다. 겹치는 자리를 어떻게 셀지, 동점을 어떻게 깰지, 부호화할 때 규칙을 어떤 순서로 적용할지가 정해져 있지 않으면 같은 글로 돌려도 어휘가 매번 달라진다. 채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러 매번 다른 입력으로 함수를 직접 두드려 보고, 채점기가 따로 계산한 값과 대조한다. 입력은 실행마다 바뀌므로 값을 외워 넣을 수 없다.
단계
- /root/work/tf-token/bpe.py 에 표본 문단
SAMPLE_KO·SAMPLE_EN과to_ids(text)·from_ids(ids)를 만드세요. 글을 UTF-8 바이트 목록으로 열고 다시 글로 되돌립니다. count_pairs(ids)를 더해 이웃한 두 개가 몇 번 붙어 나오는지 세게 하세요. 열쇠는(앞, 뒤)짝이고 겹치는 자리도 그대로 셉니다.merge(ids, pair, new_id)를 더해 그 짝이 나오는 자리를 새 번호 하나로 바꾸게 하세요. 왼쪽부터, 겹치지 않게 갑니다.MIN_PAIR_COUNT = 2와train(text, vocab_size)를 만들어 병합 규칙을 배운 순서대로 모으게 하세요. 새 번호는 256부터 하나씩 올라갑니다.encode(text, merges)를 만들어 배운 순서 그대로 병합을 적용하게 하세요.decode(ids, merges)를 만들어 번호를 끝까지 풀고 원문으로 되돌리게 하세요. 어휘에 없던 글자가 섞여도 원문과 한 글자도 달라지면 안 됩니다.vocab_curve(text, sizes)를 만들어 어휘 크기마다 같은 글이 몇 토큰이 되는지 재게 하세요. 돌려주는 값은(어휘크기, 토큰수)짝의 목록입니다.- 어휘를 두 벌 배워 같은 두 문단을 재고, /root/work/tf-token/token_report.json 과 /root/work/tf-token/token_report.md 에 결과를 기록하세요.
참고
- 실행 계약: 채점기는
/root/work/tf-token/bpe.py를 파이썬 모듈로 불러SAMPLE_KO·SAMPLE_EN·to_ids·from_ids·count_pairs·merge·MIN_PAIR_COUNT·train·encode·decode·vocab_curve를 직접 씁니다. 스크립트로 실행하지 않으므로if __name__ == "__main__"은 없어도 됩니다. - 표본 문단은 같은 내용을 두 언어로 적습니다.
SAMPLE_KO는 한글이 대부분인 300자 이상,SAMPLE_EN은 아스키만 쓰는 300자 이상이어야 합니다. 내용은 자유롭게 정하세요. to_ids(text)는text.encode("utf-8")의 바이트를 정수 목록으로 돌려줍니다. 글자 번호(ord)가 아닙니다.from_ids(ids)는 조각난 바이트가 들어와도 예외를 내지 않아야 합니다.decode의errors인자를 쓰세요.count_pairs([9, 9, 9])는{(9, 9): 2}입니다. 겹치는 자리를 그대로 셉니다.merge([5, 5, 5], (5, 5), 300)은[300, 5]입니다. 앞의 둘을 삼킨 뒤 남은 5 는 짝을 잃습니다. 넘겨받은 목록을 제자리에서 고치지 말고 새 목록을 만드세요.train의 한 바퀴: 인접 쌍을 세고, 가장 많이 나온 쌍을 고르고, 그 쌍을 새 번호로 합칩니다. 동점이면(앞, 뒤)가 사전순으로 작은 쌍을 고릅니다. 고른 쌍이MIN_PAIR_COUNT보다 적게 나오면 멈춥니다. 돌려주는 값은((앞, 뒤), 새번호)짝의 목록이고 순서가 곧 배운 순서입니다.train(text, 256)은 빈 목록입니다. 0 부터 255 는 이미 바이트가 쓰고 있어 새 번호를 만들 자리가 없습니다.encode는merges를 받은 순서대로 한 번씩 적용합니다. 정렬하거나 반복하지 마세요.decode는 새 번호를 두 개로 풀고, 그 둘 중에 또 새 번호가 있으면 다시 풉니다. 남는 것이 없을 때from_ids로 읽습니다.- 8단계 보고서는 어휘를 두 벌 배웁니다. 둘 다 크기는 512 입니다. 하나는
SAMPLE_KO와SAMPLE_EN을 줄바꿈 하나로 이어 붙인 글로 배운 공용 어휘이고, 다른 하나는SAMPLE_EN만으로 배운 영어 전용 어휘입니다. 두 어휘로 같은 두 문단을 각각 부호화해 토큰 수를 견줍니다. vocab_size는 상한일 뿐입니다. 합칠 만한 쌍이 떨어지면MIN_PAIR_COUNT에서 멈추므로 실제 규칙 수는 512 에서 256 을 뺀 값보다 적을 수 있습니다. 그래서shared_rules·en_only_rules에 실제로 배운 규칙 수를 적습니다.- 곡선은 이어 붙인 글에 대해 크기 256·320·384·512 로 잽니다.
- 이 파드에는 인터넷이 없습니다.
pip install은 되지 않고 transformers·tokenizers·tiktoken 도 없습니다. numpy 는/opt/onnx-lab/bin/python안에만 있으므로 시스템 파이썬에서는import numpy가 되지 않습니다. 표준 라이브러리만으로 충분합니다. - 공식 문서: BPE 원논문 · Hugging Face — Byte-Pair Encoding tokenization · Attention Is All You Need
- 흔한 실수: 바이트 대신 글자 번호를 쓰기, 쌍을 두 칸씩 건너뛰며 세기, 합칠 때 한 칸씩만 전진해 겹쳐 삼키기, 동점 규칙을 안 정하기, 부호화할 때 규칙을 정렬하기, 복호화를 한 겹만 풀고 끝내기.
글을 바이트로 연다
/root/work/tf-token/bpe.py 에 표본 문단 SAMPLE_KO(한글 위주 300자 이상)·SAMPLE_EN(아스키만 300자 이상)과 to_ids(text)·from_ids(ids) 를 만드세요. to_ids 는 UTF-8 바이트를 정수 목록으로 돌려주고, from_ids 는 그것을 다시 글로 되돌립니다.
text.encode("utf-8") 이 바이트열을 주고, list() 로 감싸면 0 부터 255 까지의 정수 목록이 됩니다. 되돌릴 때는 bytes(ids) 로 모아 decode 합니다. 글자 중간에서 끊긴 조각이 들어와도 예외를 내면 안 되니 errors 인자를 주세요. 두 표본은 같은 내용을 두 언어로 적어야 뒤에서 견줄 수 있습니다.
이웃한 둘을 센다
count_pairs(ids) 를 더하세요. 이웃한 두 개가 몇 번 붙어 나오는지 세어 {(앞, 뒤): 횟수} 를 돌려줍니다. 겹치는 자리도 그대로 세므로 count_pairs([9, 9, 9]) 는 {(9, 9): 2} 입니다.
zip(ids, ids[1:]) 로 이웃한 짝을 한 번에 훑을 수 있습니다. 두 칸씩 건너뛰며 세면 겹치는 자리를 놓치고, 그러면 무엇을 합쳐야 이득인지 판단이 어긋납니다. 목록이 비었거나 하나뿐이면 빈 딕셔너리입니다.
한 쌍을 하나로 합친다
merge(ids, pair, new_id) 를 더하세요. pair 가 나오는 자리를 새 번호 하나로 바꿉니다. 왼쪽부터 겹치지 않게 가므로 merge([5, 5, 5], (5, 5), 300) 은 [300, 5] 입니다. 넘겨받은 목록은 그대로 두고 새 목록을 돌려주세요.
인덱스를 손으로 옮기는 while 문이 가장 정확합니다. 짝을 찾으면 두 칸 전진하고, 아니면 한 칸만 전진합니다. 한 칸씩만 가면 방금 만든 새 번호를 다시 짝의 앞쪽으로 보게 되어 겹쳐 삼킵니다. 목록을 제자리에서 고치면 부르는 쪽이 들고 있던 값이 조용히 바뀝니다.
병합 규칙을 배운다
MIN_PAIR_COUNT = 2 와 train(text, vocab_size) 를 만드세요. 인접 쌍을 세고, 가장 많이 나온 쌍을 고르고(동점이면 (앞, 뒤) 가 사전순으로 작은 쌍), 그 쌍을 새 번호로 합치는 일을 되풀이합니다. 새 번호는 256부터 하나씩 올라가고, 돌려주는 값은 ((앞, 뒤), 새번호) 짝의 목록입니다.
한 바퀴마다 다시 세야 합니다 — 합치고 나면 이웃 관계가 바뀌기 때문입니다. max(counts.items(), key=...) 의 key 로 동점 규칙까지 한 번에 적을 수 있습니다. 고른 쌍이 MIN_PAIR_COUNT 보다 적게 나오면 멈추세요. 한 번밖에 안 나오는 쌍을 합쳐 봐야 어휘만 늘고 토큰은 하나도 안 줄어듭니다. vocab_size 가 256이면 새 번호를 만들 자리가 없어 빈 목록입니다.
배운 순서대로 부호화한다
encode(text, merges) 를 만드세요. 글을 바이트로 연 뒤 merges 를 받은 순서 그대로 한 번씩 적용합니다. 정렬하거나 되풀이하지 마세요.
뒤에 배운 규칙은 앞에서 만든 번호를 재료로 씁니다. 그래서 순서를 바꾸면 같은 규칙 목록으로도 다른 결과가 나옵니다. 함수는 세 줄이면 끝납니다 — 바이트로 열고, 규칙마다 merge 를 부르고, 남은 목록을 돌려줍니다.
한 글자도 다르지 않게 되돌린다
decode(ids, merges) 를 만드세요. 새 번호를 두 개로 풀고, 그 둘 중에 또 새 번호가 있으면 다시 풉니다. 남는 것이 없으면 바이트만 남고 그것을 글로 읽습니다. 학습에 없던 글자가 섞인 글도 원문과 한 글자도 달라지면 안 됩니다.
{새번호: (앞, 뒤)} 표를 만들어 두면 푸는 일은 그 표를 보는 것뿐입니다. 한 겹만 풀고 끝내면 안 됩니다 — 풀어 낸 값이 또 새 번호일 수 있습니다. 다 풀린 뒤에는 from_ids 가 그대로 쓰입니다. 어휘에 없던 글자도 바이트로는 반드시 적히므로, 이 구조에서는 처음 보는 글자가 와도 실패하지 않습니다.
어휘를 키우면 토큰이 준다
vocab_curve(text, sizes) 를 만드세요. sizes 의 어휘 크기마다 그 글로 처음부터 규칙을 배우고, 같은 글을 부호화해 토큰 수를 잽니다. 돌려주는 값은 (어휘크기, 토큰수) 짝의 목록이고 순서는 sizes 와 같습니다.
크기마다 그 크기의 규칙으로 재야 합니다. 가장 큰 어휘로 한 번 배워 두고 모든 칸을 그 규칙으로 재면 곡선이 평평해집니다. 앞에서 만든 train 과 encode 를 그대로 쓰면 함수는 다섯 줄입니다. 참고로 이 알고리즘은 앞만 보고 고르므로 train(글, 300) 의 결과는 train(글, 400) 결과의 앞부분과 같습니다 — 크기를 키우는 것은 더 오래 도는 것이지 다시 고르는 것이 아닙니다. 줄어드는 폭이 어떻게 달라지는지 눈으로 보세요.
어휘가 누구의 글로 만들어졌는가
어휘를 두 벌 배우세요. 하나는 SAMPLE_KO 와 SAMPLE_EN 을 줄바꿈 하나로 이어 붙인 글로 배운 크기 512 의 공용 어휘, 다른 하나는 SAMPLE_EN 만으로 배운 크기 512 의 영어 전용 어휘입니다. 두 어휘로 같은 두 문단을 각각 부호화한 뒤 /root/work/tf-token/token_report.json 에 vocab_size·ko_chars·ko_bytes·en_chars·en_bytes·shared_rules·en_only_rules·ko_tokens_shared·en_tokens_shared·ko_tokens_en_only·en_tokens_en_only·ko_bytes_per_token·en_bytes_per_token·curve·roundtrip_ok 를, /root/work/tf-token/token_report.md 에 ## 무엇을 쟀나 ## 어휘를 키우면 토큰이 어떻게 줄었나 ## 한국어가 손해를 보는 이유 ## 바이트 수준이라 안 깨지는 것 네 절로 쓰세요.
숫자는 손으로 적지 말고 여러분의 코드를 실제로 돌려 얻은 값으로 채우세요. shared_rules·en_only_rules 는 train() 이 실제로 돌려준 규칙 수입니다 — 크기 512 를 줘도 합칠 쌍이 떨어지면 그전에 멈춥니다. ko_bytes_per_token·en_bytes_per_token 은 공용 어휘 기준으로 바이트 수를 토큰 수로 나눈 값입니다. curve 는 이어 붙인 글에 대해 크기 256·320·384·512 로 잰 [[크기, 토큰수], ...] 입니다. roundtrip_ok 는 두 어휘 모두에서 두 문단이 원문으로 정확히 되돌아오는지입니다 — 영어 전용 어휘로 한국어를 부호화해도 되돌아온다는 것이 바이트 수준의 성질입니다.