Understanding Andre Karpathy’s Tokenizer Lecture

Let’s Build GPT Tokenizer 유튜브 강의 후기

Sigrid Jin
25 min readMar 17, 2024

Andrej Karpathy는 2시간 30분 분량의 콘텐츠를 만드는 데 10시간이 넘게 소요된다고 렉스 프리드먼(Lex Fridman)과의 인터뷰에서 밝힌 바 있다. 이번 글에서는 카파시 강의에서 나온 내용 중 인상적인 것들을 위주로 간단하게 메모 형태로 적어보려고 한다. 금요일 밤부터 달려서 토요일 오전에 실습까지 다 따라해봤다.

개인적으로 실습한 주피터 노트북 코드는 여기에 올려두었으니 수강에 관심이 있다면 참고하기 바란다.

유니코드와 UTF-8

BPE 알고리즘은 유니코드와 UTF-8 인코딩을 기반으로 한다. 개인적으로 여기 블로그가 이해하기에 좋다고 생각한다.

  • 유니코드는 서로 다른 문자에 고유한 숫자를 할당하는 테이블로, 새로운 문자를 지속적으로 추가하고 업데이트한다.
  • UTF-8은 이러한 숫자를 바이트로 인코딩하는 방법이다. 유니코드 문자를 메모리에 저장하는 방법이다.
  • UTF-8은 가변 길이 인코딩으로, 문자를 저장하는 데 1, 2, 3 또는 4바이트를 사용할 수 있다.
  • UTF-8은 ASCII와 역호환되므로, 모든 ASCII 텍스트는 유효한 UTF-8 텍스트이기도 하다.
ord("한")  # Gives the UNICODE number of the character
# 54620
chr(54620) # Gives the character corresponding to the UNICODE number
# '한'

# UTF-8 encoding
"안녕하세요 is hello in korean".encode("utf-8")
# b'\xec\x95\x88\xeb\x85\x95\xed\x95\x98\xec\x84\xb8\xec\x9a\x94 is hello in korean'
# This gives the bytes representation of the string, use list on the output to get the list in the form of integers

list("안녕하세요 is hello in korean".encode("utf-8"))
# [236, 149, 136, ... 101, 97, 110]

# UTF-8 decoding
b"\xec\x95\x88\xeb\x85\x95\xed\x95\x98\xec\x84\xb8\xec\x9a\x94 is hello in korean".decode('utf-8')
# '안녕하세요 is hello in korean'
# If you have a list of integers then use the below method

bytes([236, 149, 136, 235, 133, 149, 237, 149, 156, 236, 151, 144, 32, 105, 115, 32, 104, 101, 108, 108, 111, 32, 105, 110, 32, 107, 111, 114, 101, 97, 110]).decode("utf-8")
# '안녕하세요 is hello in korean'

토크나이저

여기 사이트에 들어가면 카파시가 보여준대로 토큰화를 시각화해서 볼 수 있다. GPT-2 tokenizer와 GPT-4 tokenizer를 비교하면 재미있다.

  • 파이썬 들여쓰기에서 각 공백은 별도로 토큰화되어 GPT-2가 코딩 작업에서 성능이 저하된다.
  • 숫자를 토큰화하는 데 정의된 규칙이 없어 매우 무작위로 토큰화된다.
  • ‘EGG’라는 단어는 문장 내 위치와 대소문자에 따라 다르게 토큰화된다.
  • 한국어는 영어보다 훨씬 더 많은 토큰을 차지하는데, 그 이유는 BPE의 학습 데이터에 한국어가 적게 포함되어 있기 때문이다.

BPE

BPE는 텍스트를 하위 단어(subword)로 토큰화하는 토크나이징 전략이다. 원하는 수의 병합 작업에 도달할 때까지 가장 빈번한 연속 기호 쌍을 반복적으로 새로운 기호로 대체한다.

토큰화는 텍스트를 토큰이라고 하는 더 작은 부분으로 나누는 과정이다. 이러한 토큰은 단어, 문자 또는 하위 단어일 수 있다.

  1. 규칙 기반 토큰화: 정규 표현식을 사용하여 텍스트를 토큰으로 분할하는 것이 포함된다.
  2. 하위 단어 토큰화: 텍스트를 하위 단어로 분할하는 것이 포함되며, 이는 BPE가 수행하는 작업이다.

카파시는 NLP에서 BPE가 처음 언급된 논문을 설명하면서 NMT(신경망 기계 번역) 맥락에서 BPE를 처음 도입했는데, 당시 대부분의 NMT 모델은 고정된 어휘로 동작했다. 번역은 open vocabulary 문제로, 모델이 어휘에 포함되지 않은 단어를 포함하여 모든 언어의 모든 단어를 번역할 수 있어야 한다는 의미이다. 해당 논문은 사람이 알 수 없는 단어를 더 작은 부분으로 나누고 그러한 부분을 번역함으로써 번역할 수 있다는 아이디어에서 서브워드 분할이 차용되었고 바이트 수준이 아니라 문자 수준에서 BPE를 적용하였다.

LLM의 맥락에서는 바이트를 기호로 사용하기 때문에 바이트를 병합하게 된다. 카파시는 약 30~40분 동안 처음부터 BPE의 파이썬으로 구현했는데 나도 따라했는데 굉장히 재미있었다. 의미 있던 부분은 BPE를 텍스트에 적용할 때 병합 수가 하이퍼 파라미터 라는 거다.

  • 병합 수가 증가할수록 어휘 크기, 즉 임베딩 계층과 소프트맥스 계층의 크기가 증가한다.
  • 병합 수가 감소할수록 어휘 크기가 감소하므로, 동일한 텍스트가 이제 더 많은 수의 토큰으로 토큰화되지만, 어텐션은 비용이 많이 들고 어텐션 비용을 낮게 유지하면서 동일한 양의 정보에 어텐션을 유지해야 한다.

BPE는 그냥 주어진 문자열을 UTF-8로 인코딩한 다음 바이트에 BPE 알고리즘을 적용하는 것 아닌가요?

카파시가 GitHub에 올렸던 minBPE 구현의 BaseTokenizer()가 하는 역할과 정확히 동일하다. GPT-2 논문에서는 이에 대한 문제점을 언급하고 있는데, 그 문제는 다음과 같다.

“우리는 BPE가 dog., dog!, dog?와 같이 일반적인 단어의 많은 변형을 포함하는 것을 관찰했습니다. 이로 인해 제한된 어휘 슬롯과 모델 용량이 최적으로 할당되지 않습니다. 이를 피하기 위해 우리는 BPE가 문자 범주에 걸쳐 바이트 시퀀스를 병합하지 못하도록 합니다.”

최종 어휘 크기는 하이퍼 파라미터 이며, 이는 다음과 같은 이유로 트레이드오프가 필요하다고 말한다. 어휘 크기가 클수록 임베딩 계층도 커지고, LM 모델 헤드에서 예측을 할 때 소프트맥스 계층도 희석된다. 따라서 사실상 동일한 단어의 변형에 어휘 슬롯을 낭비하지 않아야 한다. 그래서 BPE 알고리즘이 문자 범주를 넘어 병합하지 않도록 제한해야 한다.

# the main GPT text split patterns, see
# https://github.com/openai/tiktoken/blob/main/tiktoken_ext/openai_public.py
GPT2_SPLIT_PATTERN = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
GPT4_SPLIT_PATTERN = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"""

따라서 훈련하기 전에 먼저 re.findall을 사용하여 훈련 텍스트를 청크(chunk)로 나눈 다음, 이러한 청크 내에서만 쌍 빈도를 계산한다. 이렇게 하면 문자 범주에 걸친 병합을 피할 수 있다. 본질적으로 "dog?"가 ["dog", "?"]로 분할되기 때문에 우리는 "dog?"를 단일 토큰으로 병합하지 않을 것이다. 인코딩할 때도 마찬가지로, 청크로 나누고 인코딩한 다음 토큰을 병합하는 작업을 수행한다.

GPT-2는 BPE에 대한 훈련 코드를 공개하지 않고 추론 코드만 사용 가능하다. 정규 표현식을 사용한 구현으로는 완전하지 않은데, GPT-2 토크나이저에서는 모든 공백을 독립적으로 토큰화하는 것을 관찰할 수 있지만, 정규 표현식을 적용하고 훈련하더라도 정확한 reproduce는 어렵다. 실제로 GPT-2 토크나이저는 공개되지 않은 더 많은 규칙을 적용하고 있음을 알 수 있다.

Trained된 토크나이저를 구성하는 요소

인코딩과 디코딩에 필요한 것은 단 두 가지, 바로 vocabmerges다.

vocab은 인덱스를 바이트 문자열에 매핑하는 딕셔너리이고, merges는 토큰 쌍을 새로운 토큰 ID에 매핑하는 딕셔너리이다.

아래는 GPT-2의 merges와 vocab을 사용하는 코드이다.

!wget https://openaipublic.blob.core.windows.net/gpt-2/models/1558M/vocab.bpe
!wget https://openaipublic.blob.core.windows.net/gpt-2/models/1558M/encoder.json

import os, json

with open('encoder.json', 'r') as f:
encoder = json.load(f) # <--- 우리의 "vocab"과 ~동등

with open('vocab.bpe', 'r', encoding="utf-8") as f:
bpe_data = f.read()
bpe_merges = [tuple(merge_str.split()) for merge_str in bpe_data.split('\n')[1:-1]]
# ^---- "merges"와 ~동등

tiktoken은 OpenAI에서 토큰화를 위해 공식적으로 출시한 라이브러리로, 훈련 코드 자체는 포함하고 있지 않지만 OpenAI의 토크나이저를 사용하여 인코딩과 디코딩을 할 수 있다.

import tiktoken

# GPT-2 (공백을 병합하지 않음)
enc = tiktoken.get_encoding("gpt2")
print(enc.encode(" hello world!!!"))

# GPT-4 (공백을 병합함)
enc = tiktoken.get_encoding("cl100k_base")
print(enc.encode(" hello world!!!"))
len(encode)
# 50257

50,000번의 병합이 이루어지고 기본 어휘가 256바이트였다면, 우리는 50,256개의 토큰을 가질 것이다. 하지만 실제로는 50,257개의 토큰이 있는데, 이는 특수 토큰 <|endoftext|> 때문이다.

스페셜 토큰이 텍스트에 있으면 단일 토큰으로 토큰화되며, 이는 BPE 알고리즘 자체의 일부가 아니라 별도로 처리해야 한다. GPT-2에는 특수 토큰이 하나만 있다. 이와 달리 GPT-4에는 4개의 특수 토큰이 있으며, FIM 토큰은 이 논문을 기반으로 한다. tiktoken은 새로운 토큰 추가도 지원하는데, 자세한 내용은 README.md에 있다.

스페셜 토큰을 추가하는 방법

보통 스페셜 토큰은 훈련 시작 시점에 추가되지만, 일부는 사전 훈련 후에 추가된다. 스페셜 토큰을 추가하는 것은 약간의 모델 수술(model surgery)이 필요한데, 다음 두 단계가 포함된다.

  1. 임베딩 계층에 추가 행을 넣고 무작위 가중치로 초기화한다.
  2. 소프트맥스 계층에도 추가 행을 넣는다

모델이 사전 훈련된 후에는 이러한 스페셜 토큰의 추가 역할이 매우 중요하다. Chat 기반 모델을 만들 때 파인튜닝에서 중요한 역할을 하고, 심지어 ChatGPT 브라우징 기능에도 스페셜 토큰이 들어간다고 한다.

GPT-3.5-turbo의 특수 토큰. <|im_start|>, <|im_end|> 토큰이 단일 토큰으로 토큰화되는 방식을 관찰해본다.
llama 토크나이저의 일부 특수 토큰. <s>, </s>, <unk>가 특수 토큰임을 확인할 수 있다.

새로운 토큰을 추가하는 것과 관련된 전체 설계 공간 (entire design space) 이 있는데, 카파시는 Gist 토큰으로 프롬프트를 압축하는 방법을 배우는 이 논문을 참고하라고 영상에서 언급한다.

모달리티의 세계와 minBPE

최근 모달리티 계열의 아이디어는 모델의 아키텍처를 변경하는 것이 아니라, 모달리티를 토큰으로 토큰화하여 모델에 입력하는 방법을 찾는 것이다. 예를 들어, OpenAI의 Sora에서는 비디오를 패치로 토큰화하여 모델에 입력할 수 있는 방법을 찾았다.

그리고 본인의 사이드 프로젝트인 minBPE에 대해서도 언급하는데 OpenAI의 tiktoken과 유사하지만 트레이닝 코드를 추가하는 것이라 한다.

SentencePiece

SentencePiece는 Google에서 출시한 라이브러리로, 텍스트 토크나이저이며 BPE와 Unigram Language Model(토큰화를 위한 다른 알고리즘)을 모두 지원한다. LLaMA와 Mistral 모두 토큰화에 SentencePiece를 사용한다. SentencePiece와 tiktoken의 주요 차이점은 SentencePiece의 BPE가 바이트 수준이 아닌 “유니코드 코드 포인트” 직접 수행된다는 점이다. 즉, 병합이 코드 포인트 사이에서 일어난다.

본인은 SentencePiece를 좋아하지 않고 HuggingFace Tokenizer 사용을 매우 추천하고 있다. 좋아하지 않는 이유는 레거시 프레임워크라고 생각하기 때문이란다. 옵션 값 설정이 너무 복잡하고 LLM 시대 이전의 잔재가 너무 많다는 것이다. 본인이 직접 minBPE를 만드려는 이유도 SentencePiece에 대한 불호에서 나오지 않았을까. 알아야 하는 매개변수가 너무 많다는 것이다. 예를 들어보자면 다음과 같다.

  • character_coverage 매개변수(예: 0.9995)는 유니코드가 희귀할 경우 어휘에 포함되지 않도록 한다.
  • byte_fallback 매개변수는 character_coverage 매개변수로 인해 어휘에서 제외된 코드 포인트를 처리한다. 코드 포인트가 어휘에 없으면 (추론 중에) 이 매개변수의 값에 따라 바이트 시퀀스(UTF-8에 따라)로 인코딩되거나 <UNK>로 표시된다.
Training💡

import sentencepiece as spm

# write a toy.txt file with some random text
with open("toy.txt", "w", encoding="utf-8") as f:
f.write("SentencePiece is an unsupervised text tokenizer and detokenizer mainly for Neural Network-based text generation systems where the vocabulary size is predetermined prior to the neural model training. SentencePiece implements subword units (e.g., byte-pair-encoding (BPE) [Sennrich et al.]) and unigram language model [Kudo.]) with the extension of direct training from raw sentences. SentencePiece allows us to make a purely end-to-end system that does not depend on language-specific pre/postprocessing.")
Observe that the training data doesn’t include any korean code points ( for example later )


# train a sentencepiece model on it
# the settings here are (best effort) those used for training Llama 2
import os

options = dict(
# input spec
input="toy.txt",
input_format="text",
# output spec
model_prefix="tok400", # output filename prefix
# algorithm spec
# BPE alg
model_type="bpe",
vocab_size=400,
# normalization
normalization_rule_name="identity", # ew, turn off normalization
remove_extra_whitespaces=False,
input_sentence_size=200000000, # max number of training sentences
max_sentence_length=4192, # max number of bytes per sentence
seed_sentencepiece_size=1000000,
shuffle_input_sentence=True,
# rare word treatment
character_coverage=0.99995,
byte_fallback=True,
# merge rules
split_digits=True,
split_by_unicode_script=True,
split_by_whitespace=True,
split_by_number=True,
max_sentencepiece_length=16,
add_dummy_prefix=True,
allow_whitespace_only_pieces=True,
# special tokens

위 트레이닝 코드를 보면 byte_fallback을 True로 설정하여 Trainer를 구성하고 있다. 이제 어휘가 어떻게 구성되고 인코딩이 어떻게 작동하는지 살펴보겠다.

sp = spm.SentencePieceProcessor()
sp.load('tok400.model')
vocab = [[sp.id_to_piece(idx), idx] for idx in range(sp.get_piece_size())]
vocab
  • Group 1 : Special Tokens
['<unk>', 0],
['<s>', 1],
['</s>', 2],
  • Group 2 : byte_fallback이 True로 설정되어 있기 때문에 모든 개별 바이트
['<0x00>', 3],
['<0x01>', 4],
['<0x02>', 5],
['<0x03>', 6],
['<0x04>', 7],

...

['<0xFB>', 254],
['<0xFC>', 255],
['<0xFD>', 256],
['<0xFE>', 257],
['<0xFF>', 258]
  • Group 3: 부모 토큰과 병합된 토큰
['en', 259],
['▁t', 260],
['ce', 261],
['in', 262],
['ra', 263],

['▁m', 271],
['▁u', 272],

['entence', 276],

['▁the', 294],
['Piece', 295],
['▁Sentence', 296],
['▁SentencePiece', 297],
['.]', 298],
['Ne', 299],

['.])', 314],
['age', 315],
['del', 316],

['▁Ne', 323],

['guage', 335],

['▁training', 343],
['.,', 344],
['BP', 345],
['Ku', 346],
['ab', 347],

['lo', 358],
['nr', 359],
['oc', 360]
  • Group 4: 훈련 텍스트에 포함된 개별 코드 포인트. 훈련 텍스트에서 드문 것들은 제외된다.
['e', 361],
['▁', 362],
[',', 395],
['/', 396],
['B', 397],
['E', 398],
['K', 399]

이제 인코딩(추론)해 보겠다. 한국어 문자가 바이트 시퀀스로 인코딩되는 것을 볼 수 있는데, 이는 해당 유니코드 코드 포인트가 어휘에 매핑되어 있지 않기 때문이다.

ids = sp.encode("hello 안녕하세요") 
print(ids)

[362, 378, 361, 372, 358, 362, 239, 152, 139, 238, 136, 152, 240, 152, 155, 239, 135, 187, 239, 157, 151]

print([sp.id_to_piece(idx) for idx in ids])

['▁', 'h', 'e', 'l', 'lo', '▁', '<0xEC>', '<0x95>', '<0x88>', '<0xEB>', '<0x85>', '<0x95>', '<0xED>', '<0x95>', '<0x98>', '<0xEC>', '<0x84>', '<0xB8>', '<0xEC>', '<0x9A>', '<0x94>']

이 때 byte_fallback이 False로 설정되어 있다면, UNK 토큰으로 나온다. id 0UNK를 나타낸다는 점에 유의하자.

ids = sp.encode("hello 안녕하세요")
print(ids)

[362, 378, 361, 372, 358, 362, 0]

Vocab Size에 대하여

BPE에서 어휘 크기는 하이퍼 파라미터이다. 안드레이가 그의 영상에서 언급한 몇 가지 질문과 답변은 다음과 같다.

Q. 왜 어휘 크기는 무한할 수 없을까?

아래 GPT2 모델을 정의한 클래스를 보면 vocab size가 나타나는 곳은 아래 그림에서 볼 수 있듯이 두 부분이 있다.

안드레 카파시의 minGPT repo에 있는 gpt.py

어휘 크기가 증가함에 따라 임베딩 계층과 lm_head의 크기가 커지므로, 이는 많은 파라미터가 있을 것이다. 모델이 이러한 토큰을 더 드물게 보게 될 것이므로, 이러한 매개변수가 충분히 학습되지 않을 수 있다. 예를 들어, 전체 학습 데이터에서 pneumonoultramicroscopicsilicovolcanoconiosis 와 같은 희귀 단어를 상상해 보자. 모델은 이 지점을 한 번만 볼 것이므로, 해당 임베딩은 충분히 학습되지 않을 것이다.

정보를 많이 압축하고 있는데, 이는 동일한 양의 연산으로 더 많은 정보에 어텐션할 수 있기 때문에 sequence length 측면에서는 유리하더라도, 이는 또한 모델이 forward pass에서 많은 양의 정보를 이해해야 한다는 것을 의미하므로 병목 현상이 발생할 수 있다.

The quirks of tokenization

LLM은 왜 단어를 제대로 철자를 못 맞출까? 토크나이제이션 때문이다.

영상에서 카파시는 GPT-4 토크나이저에서 가장 긴 토큰 중 하나인 .DefaultCellStyle을 찾는다. .DefaultCellStyle는 단일 토큰이기 때문에, GPT-4에게 철자를 물어보면 character 별 개별 토큰 단위로 보지 않았기 때문에 이상한 대답을 한다.

LLM이 비영어권 언어에서 성능이 떨어지는 이유는 무엇일까? 토크나이제이션 때문이다.

토크나이저가 다른 언어에 대해 충분히 훈련되지 않아서 더 많은 토큰으로 토큰화된다. 이는 모델 자체의 학습 데이터에서 해당 언어의 희소성 때문일 수도 있지만, 일부는 실제로 토크나이제이션 때문일 수 있다.

LLM이 간단한 산술을 잘 못하는 이유는 무엇일까? 토크나이제이션 때문이다.

덧셈은 문자 수준에서 이루어지지만, 숫자는 무작위 병합에 따라 분할되는데, 자세한 내용은 여기 블로그 글에 잘 나와있다.

GPT-2가 파이썬 코딩에 어려움을 겪은 이유는 무엇일까? 토크나이제이션 때문이다.

파이썬 코드의 space들 하나하나가 별도로 토큰화되기 때문인데, 이는 토크나이제이션 자체의 문제이다. 이는 나중에 GPT-4에서 수정되었다.

LLM이 “<|endoftext|>” 문자열을 보면 갑자기 중단되는 이유는 무엇일까? 토크나이제이션 때문이다.

<|endoftext|>는 특수 토큰이므로 단일 토큰으로 토큰화되는데, 이로 인해 모델에서 일부 문제가 발생할 수 있다.

OpenAI Playground에서 trailing whitespace에 대한 이상한 경고가 나오는 이유는 무엇일까? 토크나이제이션 때문이다.

Chat이 아닌 Pretrain이 아닌 모델에 다음과 같은 프롬프트가 주어졌다고 생각해보자.

Here is a tag line for an ice cream shop:_

“_”는 공백을 나타내는 데 사용되며, 실제로 공백이다. tiktoken 라이브러리에서 이를 토큰화하면 다음과 같다.

[8586, 374, 264, 4877, 1584, 369, 459, 10054, 12932, 8221, 25, 220]

프롬프트를 공백으로 끝내는 것이 왜 문제일까.

일반적으로 학습 데이터에서 모델은 다음과 같은 프롬프트를 보았을 것이다.

_Here is a tag line for an ice cream shop:_Oh_yeah!

이를 토큰화하면 _Oh가 단일 토큰이 되고, 공백이 토큰의 일부가 된다. 따라서 모델이 자체적으로 개별 공백을 보면 분포에서 벗어나므로 경고를 준다. 모델은 [25, 220] 시퀀스를 어떻게 완성해야 할지 모른다. 220은 공백을 나타내는데, 공백은 일반적으로 독자적으로 등장하지는 않기 때문이다. 문장 끝에 공백이 있는 경우를 본 적이 있는가? 오타가 아니라면 없을 것이다. 공백은 항상 옆 단어의 일부가 되어 다른 토큰이 된다.

이러한 경우에 대해 Rust로 작성되어 있는 tiktoken 코드에는 incomplete token에 대한 수없는 if-else 후처리 로직들이 있는데 관련한 문서화는 전혀 이루어지지 않았다.

LLM에게 “SolidGoldMagikarp”에 대해 묻는 순간 모델이 답변을 뻗어버리는 이유는 무엇일까? 토크나이제이션 때문이다.

Reddit의 열혈 유저 중 어떤 잉여가 자신의 블로그에 쓴 내용이다. 토큰의 임베딩을 클러스터링했는데 이상한 클러스터를 발견했다고 한다. 해당 클러스터에 있는 토큰이 프롬프트에 포함되면 모델의 답변이 이상해진다고 한다.

해당 블로그의 주장에 따르면 solidGoldmagicKarp가 Reddit에 하루에 글을 몇십개씩 올렸던 잉여이고, 토크나이저 학습 데이터에 Reddit 데이터가 많이 포함되어 있어서 단일 토큰이 되었다는 것이다. 하지만 모델 자체의 학습 데이터에서는 이 토큰이 한 번도 보이지 않았다. 이는 이 토큰에 해당하는 임베딩이 한 번도 학습되지 않았음을 의미한다. 따라서 모델이 이 토큰을 보면 한 번도 학습되지 않은 무작위 임베딩을 다루게 되므로 모델의 답변이 이상해진다고 한다. 마치 unallocate memory 같은 개념이랄까?

LLM과 함께 JSON보다 YAML을 사용하는 것이 좋은 이유는 무엇일까? 토크나이제이션 때문이다.

JSON은 토큰이 매우 밀집되어 있고 YAML은 토큰 사용이 매우 효율적이다. 토큰 당 비용을 지불하기 때문에 토크나이제이션에서 효율적이어야 한다.

요약하자면?

현재 LLM에서는 Unicode를 직접 토큰화 단위로 사용하는 대신 UTF-8로 인코딩된 raw byte를 사용하고 있는데, 이는 Unicode의 가변성과 방대한 문자 수로 인한 어려움을 피하기 위한 선택이었다. 이러한 raw byte들이 LLM의 원자(atom)라고 볼 수 있다.

하지만 context 길이의 제약으로 인해 이 raw byte들을 그대로 입력으로 사용하기에는 한계가 있다. 이를 해결하기 위해 반복적으로 나타나는 byte pair를 묶어 subword로 만드는 BPE(Byte Pair Encoding) 알고리즘이 사용되는 것이다. 이렇게 만들어진 subword가 바로 토큰이 되는 거다.

적절한 vocab 크기는 경험적으로 결정되는데, 너무 작으면 context 길이가 길어져 품질이 저하되고, 너무 크면 LLM이 단계적으로 생각하는 능력이 떨어져 역시 품질 저하로 이어진다. OpenAI에서는 약 10만 개의 토큰을 사용한다고 한다.

또한 BPE 알고리즘을 그대로 적용하면 “dog.”, “dog!”, “dog?” 같은 표현이 하나의 토큰으로 묶일 수 있어 문장 경계를 이해하는 데 혼란을 줄 수 있다. 이를 방지하기 위해 정규식을 사용하여 특정 문자들을 미리 분리해 두는 기법도 사용된다.

강의에서는 OpenAI의 토크나이저인 tiktoken과 구글의 SentencePiece에 대해서도 언급되었는데, tiktoken은 training과 inference가 명시적으로 구분된 반면 SentencePiece는 두 단계를 동시에 수행할 수 있어 현업에서 많이 사용되고 있다고 합니다. 다만 Andrej Karpathy는 SentencePiece의 복잡성과 부족한 문서화를 단점으로 꼽으며, 이를 개선한 minbpe 프로젝트를 진행 중이라고 한다.

토크나이제이션은 LLM의 성능과 행동에 지대한 영향을 미치는 요소이다. 단순히 전처리 과정으로 치부할 것이 아니라 모델 설계에 있어 신중하게 고려해야 할 부분이라는 걸 이번 강의를 통해 배울 수 있었다. 무엇보다도 월드클래스 연구원의 Andrej Karpathy가 유튜버로 전향한 덕분에 집에서 배긁으면서 토크나이제이션에 대한 깊이 있는 수업을 들을 수 있으니 대학교에 굳이 복학할 이유가 없다라는 생각을 다시 한번 하게 되었다.

Reference

--

--