덜 매운맛으로 새 시작을 알려볼까 한다. 오늘은 tokenizer를 학습할 때 쓸 수 있는 지식을 풀어보고자 한다. 

 

Large Language Model(LLM)또는 Language Model(LM) 모델을 사용하기 위해선 자연어를 임베딩해야 한다. 모델은 자연어가 아닌 fp32 또는 fp16 등등의 숫자로 학습하기 때문이고, 또한 인코딩은 압축의 의미가 있기 때문이다. LM의 변천사가 펼쳐지면서 자연어를 인코딩하는 방법도 조금씩 다르게 채택하고는 했다. 

오늘은 Byte Pair Encoding(BPE), BBPE(Byte-level Byte Pair Encoding), Wordpiece 인코딩 방법론들에 대해서 알아보자. 

 


 

Byte Pair Encoding (BPE)

'글자' 단위로 토큰을 쪼개서 인코딩을 하는 방법론이다. 

 

'hi my name is' 라는 문자가 있다고 가정해 보자. byte pair encoding에선 처음에 글자 단위로 우선 쪼갠다. 

> h, i, m, y, n,a,m,e ,i ,s

 

이렇게만 하고 인코딩을 끝내면 정말 간단할거다. 인코딩을 사전으로 비유했을 때 영어로 따지면 딱 26개만 생성되지 않겠는가. 자연어 처리를 위해서 알파벳 단위로 나열하는 것은 어떤 문제가 있을까?

 

1. 일단 인코딩 후 길이가 매우 길어질 것이다. 

만약 단어 단위로 인코딩을 한다면 위 문장은 단 4개의 숫자로 표현될 것이다(편의상 공백(' ')은 빼고). 'hi', 'my', 'name', 'is'. 

반면 알파벳 단위로 인코딩을 하면 h, i, m, y, n,a,m,e ,i ,s 10개가 된다. 

 

2. 알파벳 단위는 의미가 있을까? 

있을 수 있다. 하지만 더한 의미를 주진 못할 것이다. 'hi' 와 'h', 'i'를 모델에 준다면 직관적으로 'hi'가 더 학습할 거리가 있지 않을까. 'h'를 기준으로 학습한다면 어떻게 될까? 'h'가 가질 수 있는 의미는 hi도 있고 hell도 있다. 반면 'hi'는? hi의 중의적 의미밖에 없을 것이다. 

  

 

따라서 BPE를 비롯한 토크나이징 방식에선 최소단위로 쪼갠 토큰을 이용해 이후 단어를 subword로 합친다. 합치는 방식은 단순하다. '자주 등장하는 단어'들을 병합하는 형태다. 

 

예를 들어 내가 tokenizing에 사용한 문서에서 'hi'가 자주 나온다면, 'h'와 'i'를 결합하여 'hi'를 하나의 토큰으로 저장한다. 그렇게 지정한 vocab_size에 도달할 때까지, '자주' 등장한 글자를 병합한다. 

 

huggingface tokenizer을 통해 BPE를 학습시켜 보자. 먼저 학습에 필요한 말뭉치를 하나 가져온다. 실습용이니 다 가져올 필요는 없으므로 조금 잘라서 가져온다. 

# 학습용 데이터셋으로 말뭉치를 하나 가져온 후, 저장해놓자. 
from datasets import load_dataset 

dataset = load_dataset('lcw99/wikipedia-korean-20221001', split='train[:1%]')
input_docs = dataset['text']
sample_file_path = 'sample_for_tokenizer.txt'
with open(sample_file_path, 'w') as f:
  for doc in input_docs: 
    f.write(doc+ '\n')

 

 

이후 BPE tokenizer를 학습한다. 가져온 말뭉치에서 min_frequency를 충족할 정도로 자주 등장한 글자는 병합되어 저장될 것이다. vocab_size는 토크나이저에서 vocab 사전에 총 몇 개의 토큰을 넣을 것인지의 최소치이다. 

from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers import Tokenizer
from transformers import PreTrainedTokenizerFast

trainer = BpeTrainer(vocab_size = 128, #내 토크나이저 vocab 사전에 총 몇개의 단어(토큰)을 넣을 것인지. 
                min_frequency=3, # 해당 pair가 몇번 이상 등장했을때만 하나의 토큰으로 병합하자. 
                special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"] # 학습에 필요한 special tokens 정의. 

                     )
# whitespace 등의 pre norm은 생략. 

tokenizer = Tokenizer(BPE(unk_token="[UNK]",  #모르겠는 토큰(단어)가 들어왔을 땐 [UNK] 토큰을 준다는 뜻. 
                    ))
                    
tokenizer.train([sample_file_path],
                trainer,
                )

 

결과를 보면 vocab_size는 128보다 크다. min_frequency를 만족시킨 단어 수가 128보다 많은 것이다. 토크나이저 결과를 코드를 통해 간단히 확인해 보자. 

# 토크나이저 저장. 
tokenizer_path = 'sample_bpe.json'
tokenizer.save(tokenizer_path)

# 토크나이저 로드 및 encode decode 결과 확인. 
res_tokenizer = PreTrainedTokenizerFast(tokenizer_file=tokenizer_path)
print(res_tokenizer.encode('안녕? 내 이름은', return_tensors='pt'))
## printed result
# tensor([[6762, 5910,   38,    7, 5866,    7, 6942, 6260, 6931]])

print(res_tokenizer.decode(6762)) # '안'

 

참고로 llama2 계열은 BPE를 변형한 sentence piece를 사용했다. 

 

 

Wordpiece

wordpiece encoder도 큰 개념으로는 BPE와 유사하다. 다른 점은 다음과 같다. 

  • BPE: 글자 병합 시 '자주 등장한(min_frequency)' 순으로 병합한다. 
  • wordpiece: 글자 병합 시 max likelihood를 통해 병합한다. (그렇다 보니 BPE보단 느리다) 

 

wordpiece는 단어 pair에 대해 다음과 같은 스코어를 계산한다. 

 

 

 

from tokenizers.models import WordPiece 

tokenizer2 = Tokenizer(WordPiece(unk_token="[UNK]",  #모르겠는 토큰(단어)가 들어왔을 땐 [UNK] 토큰을 준다는 뜻. 
                    ))
tokenizer2.train(['sample_for_tokenizer.txt'],
                )

 

 

참고로 wordpiece 인코딩은 주로 BERT계열에서 많이 쓰였다. 초기 GPT도 wordpiece를 사용한다. 

 

또 참고로, wordlevel 인코딩이랑 이름이 비슷하지만 다른 인코딩 방법론이다. wordlevel은 진짜 단어를 통째로 하나의 토큰으로 삼는다. 

개념상 '안녕? 내 이름은'이라는 문장이 들어왔을 때 (어떻게 학습됐냐에 따라 다르겠지만), 다음과 같은 느낌으로 다르다. 

  • BPE: '안녕', '?', '내', '이름', '은' 
  • wordlevel: '안녕', '내', '이름은'
  • wordpiece: '안녕', '?', '내', '이름', '##은'   

 

 

Byte-level Byte Pair Encoding (BBPE) 

BPE와 wordpiece 개념을 알고 나면 다른 파생된 인코딩 방법론들을 이해하기 더 쉬울 것이다. 

BBPE는 모든 문자를 UTF-8 바이트 시퀀스로 변환한 뒤에 BPE를 태운 방법론이다. UTF-8 byte sequence라 함은 한국어와 이모지 등도 포함이다. 

 

'안녕 내 이름은' 를 예시로, 초기에 글자를 나누어 보면

  • BPE: '안', '녕', '내', '이', ... 
  • BBPE: 236, 149, 136,(이게 utf-8로 '안'이다.) , ... 

가 된다. 따라서 BBPE encoding을 할 땐 어떤 유니코드 normalization을 쓸지도 선택해야 한다. 이모지와 숫자, 문자의 결합 등을 고려할 때 NFKC를 많이 선택한다. 그래서 저장된 tokenizer를 살펴보면 단어가 특수문자로 되어있는 것도 종종 보인다. 

이후 병합은 BPE와 마찬가지로 '빈도' 다. 

 

이를 코드를 통해 보면 다음과 같다. 

from tokenizers.normalizers import NFC, NFKC, StripAccents, Lowercase, Sequence
from tokenizers import ByteLevelBPETokenizer

tokenizer_path = 'sample_bbpe_nfkc.json'

tokenizer3 = ByteLevelBPETokenizer() 
tokenizer3.normalizer = Sequence([NFKC(), Lowercase()]) #유니코드 NFKC 전처리. 소문자화도 추가.

# train은 위와 동일. 
tokenizer3.train( 
    ['sample_for_tokenizer_1.txt'],
    vocab_size=256,
    min_frequency=2,
    special_tokens=["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"]
)

tokenizer3.save(tokenizer_path)

res_tokenizer = PreTrainedTokenizerFast(tokenizer_file=tokenizer_path)

sample_text = '안녕! 내 이름은 [MASK]야. 잘부탁해~'
enc_text = res_tokenizer.encode(sample_text, return_tensors="pt")
#tensor([[21191,     5,   761,  3698,   225,     4,   610,    18,  1785,   378,
#          2446,   374,    98]])

res_tokenizer.decode(enc_text[0][0])# '안녕'

 

 

참고로 최근에 나온 Qwen3도 BBPE를 사용했다. 

 

 


 

728x90

+ Recent posts