본문 바로가기
NLP

[NLP] BPETokenizer 이해하기

by Tiabet 2024. 2. 3.

최근에 캐글에서 열린 LLM 대회의 최적 솔루션은 아주 인상적이었다.

 

https://www.kaggle.com/code/datafan07/train-your-own-tokenizer

 

Train your own Tokenizer

Explore and run machine learning code with Kaggle Notebooks | Using data from multiple data sources

www.kaggle.com

대회 목적 자체는 문장을 보고 기계가 만든 것인지 인간이 만든 것인지 구분하는 것이었다. 일반적인 솔루션은 BERT 류의 모델을 학습시켜서 분류시키는 것이겠으나, 여기에서 제시된 솔루션은 그런 방식이 아니다. 토크나이저를 로드해오는 게 아니고 데이터셋에 맞게 직접 만들어서, TFIDF 변환을 한뒤 머신러닝으로 푸는 것이다. 

 

실제로 매우 좋은 성적을 거두었을 뿐더러, 코멘트를 읽어보니 BPE 토크나이저 방법이 많은 트랜스포머 기반 모델들의 성능을 능가한다는 의견 또한 존재했다. 이 대회에선 내가 직접 해본 결과 단순히 BERT 류의 트랜스포머 모델들을 사용했을 때는 80퍼센트 정도의 정확성을 보였으나 BPE 토크나이저를 사용했을 때는 95퍼센트 이상의 정확도를 보여줬다. (Public Score 기준)

 

그래서 이번 포스팅에선 BPE 토크나이저에 대해 정리해보고자 한다. 

참고한 자료는 아래의 위키독스이다.

https://wikidocs.net/22592

 

13-01 바이트 페어 인코딩(Byte Pair Encoding, BPE)

기계에게 아무리 많은 단어를 학습시켜도 세상의 모든 단어를 알려줄 수는 없는 노릇입니다. 만약 기계가 모르는 단어가 등장하면 그 단어를 단어 집합에 없는 단어란 의미에서 해당 토…

wikidocs.net

 

Subword Tokenizer

문장을 토큰화(Tokenizing)하는 방식엔 여러 방식이 있다. '영수는 사과를 먹는다' 를 토큰화한다고 보면, '영수', '는', '사과', '를', '먹', '는다' 처럼 토큰화할 수 있을 것이다. 혹은 '영수는', '사과를', '먹는다'로도 분해가 가능할 것이다. 이런 식으로 토큰화하는 것을 단어 토큰화라고 한다. 혹은 문장 전체를 토큰화할 수도 있다. '영수는 사과를 먹었다. 영희는 바나나를 먹고 있다.' 처럼 문장이 여러개 합쳐져 있을 때, '영수는 사과를 먹었다.'와 '영희는 바나나를 먹고 있다'로 분리하게 되면 문장 토큰화이다.

 

BPE 토크나이저는 서브워드 토크나이저의 한 종류다. 단어 토큰화에는 한 가지 문제가 있다. 기계에게 아무리 많은 단어를 학습시킨다 한들 사전에도 없는 단어들이 우후죽순 생겨나는 판에 기계가 모든 단어를 외울 수는 없으며, 설령 알려준다고 해도 그 용량을 감당할 수 없을 것이다. 기계는 모르는 단어를 만나면 성능에 급격한 저하가 온다. 어찌 보면 당연하다. 모르는 영어 단어가 많이 들어있는 문장을 주고 번역하라고 하면 사람도 못 하는 것과 같은 원리이다. (사전에 없는 단어를 Out Of Vocabulary, OOV 라고 한다.)

 

이런 문제를 해결하기 위해 서브워드 토크나이저를 사용하게 됐다. 서브워드 토크나이저는 하나의 단어를 더욱 작은 의미를 갖는 단어로 분해하겠다는 것이다. 예를 들어 많이 사용하는 '꿀잼'이라는 신조어를 생각해보자. 꿀잼은 '꿀'과 '잼'이 합쳐져서 만들어진 단어다. 그러면 이 꿀잼이란 단어를 '꿀'과 '재미'로 각각 토큰화하는 것이다. 혹은 '꿀잼' 하나로 토큰화하면 '핵꿀잼', '개꿀잼' 등 여러 유사어에도 ''과 '꿀잼'으로 각각 토큰화하는 식으로 대처가 가능하다. 기계는 '꿀잼'이란 단어 하나만 알아도 서브워드 토큰화를 하면 여러 유사어, 신조어에 대응이 가능한 것이다.

 

BPE Tokenizer

BPE(Byte Pair Encoding)는 30년 전쯤 개발된 데이터 압축 알고리즘이라고 한다.

aaabdaaabac

위와 같은 문자열이 있다고 하자. 위 문자열에서 가장 많이 반복되는 글자 쌍(Byte Pair) 는 aa 이다. 따라서 aa를 다른 하나의 문자로 치환(Encoding)해보면,

ZabdZabac
Z=aa

이 된다. 여기서 멈추지 않고 한 번 더 BPE 알고리즘을 적용해보면,

ZYdZYac
Y=ab
Z=aa

이 된다. 여기서 멈추지 않는다면

XdXac
X=ZY
Y=ab
Z=aa

로 압축이 가능하다. 이런 BPE 알고리즘을 자연어에도 적용할 수 있다.

# dictionary
# 훈련 데이터에 있는 단어와 등장 빈도수
low : 5, lower : 2, newest : 6, widest : 3

이런 식으로 단어 토큰화가 이루어진 사전이 있다고 가정하자. 위 사전에는 lowest 라는 단어가 없고, 기계는 그대로 단어토큰화를 사용하면 오류를 일으킬 것이다. 하지만 BPE Tokenizer를 사용해보자.

우선 사전에 맞는 단어 집합을 갖고 있어야 한다.

# vocabulary
l, o, w, e, r, n, s, t, i, d

초기 단어 집합은 간단하다. 등장하는 알파벳을 모두 나열하면 된다.

 

그럼 이제 dictionary를 보고 vocabulary 를 업데이트해나가면 된다. 가장 많이 반복되는 Byte Pair는 e,s(9회)이다. es를 하나의 단어로 묶는다.

# dictionary update!
l o w : 5,
l o w e r : 2,
n e w es t : 6,
w i d es t : 3

# vocabulary update!
l, o, w, e, r, n, s, t, i, d, es

이에 맞춰서 단어 사전에 es를 추가해준다. 이 뒤로는 이런 방식을 계속해서 반복한다. 1회 더 반복해보면 가장 많이 등장하는 Byte Pair가 es,t (9회) 이므로 dictionary와 vocabulary를 다음과 같이 업데이트할 수 있다.

# dictionary update!
l o w : 5,
l o w e r : 2,
n e w est : 6,
w i d est : 3

# vocabulary update!
l, o, w, e, r, n, s, t, i, d, es, est

이제 est가 완성되었으므로, 단어 토큰화에선 OOV였던 lowest가 서브워드 토큰화에선 Vocabulary에 있는 단어가 된다.

 

BPETokenizer 구현

import re, collections
from IPython.display import display, Markdown, Latex

num_merges = 10

dictionary = {'l o w </w>' : 5,
         'l o w e r </w>' : 2,
         'n e w e s t </w>':6,
         'w i d e s t </w>':3
         }


def get_stats(dictionary):
    # 유니그램의 pair들의 빈도수를 카운트
    pairs = collections.defaultdict(int)
    for word, freq in dictionary.items():
        symbols = word.split()
        for i in range(len(symbols)-1):
            pairs[symbols[i],symbols[i+1]] += freq
    print('현재 pair들의 빈도수 :', dict(pairs))
    return pairs

def merge_dictionary(pair, v_in):
    v_out = {}
    bigram = re.escape(' '.join(pair))
    p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
    for word in v_in:
        w_out = p.sub(''.join(pair), word)
        v_out[w_out] = v_in[word]
    return v_out

bpe_codes = {}
bpe_codes_reverse = {}

for i in range(num_merges):
    display(Markdown("### Iteration {}".format(i + 1)))
    pairs = get_stats(dictionary)
    best = max(pairs, key=pairs.get)
    dictionary = merge_dictionary(best, dictionary)

    bpe_codes[best] = i
    bpe_codes_reverse[best[0] + best[1]] = best

    print("new merge: {}".format(best))
    print("dictionary: {}".format(dictionary))

위 코드는 책에서 소개된 구현 코드다. get_stats는 Byte들의 Pair 빈도 수를 카운트하여 dictionary에 저장한다. merge_dictionary는 pair를 한 단어로 합치는 역할을 한다. 이 두 함수를 통해 for문에서 딕셔너리를 업데이트해나가는것이다.

 

실제로 사용할 때는 BPE Tokenizer는 허깅페이스의 tokenizer 라이브러리에서 불러올 수 있다.

from tokenizers import Tokenizer, models

# Initialize BPE tokenizer
tokenizer = Tokenizer(models.BPE())

# Load BPE model and add it to the tokenizer
tokenizer.model = models.BPE.from_file("path/to/bpe_model.json")

# Example usage
encoded = tokenizer.encode("Your input text here")
print(encoded.tokens)

 

이렇게 BPE 토크나이저에 대해 정리해봤다.