Doby's Lab

Wordpiece Tokenizer, ['자연어', '를', '토큰', '으로', '만드는', '방법'] 본문

AI/Concepts: NLP

Wordpiece Tokenizer, ['자연어', '를', '토큰', '으로', '만드는', '방법']

도비(Doby) 2024. 2. 21. 23:34

✅ Intro

이번 프로젝트의 중심은 LLM이다 보니 CLIP, 데이터 처리 등 다루어야 할 요소들이 많지만, 가장 근본적으로 공부해야 할 부분은 NLP(자연어 처리)입니다. NLP 관련 모델을 더 깊게 공부하기 앞서 '자연어를 어떻게 모델에 넣지?'라는 질문에 대해서 답을 찾아보았습니다.


✅ Tokenization

단편적으로, 자연어를 모델에 학습시킨다고 생각했을 때, 문장 자체를 넣어버리면 좋겠지만 세상에는 엄청나게 많은 조합들의 단어가 있고, 그 단어들의 조합으로 셀 수도 없는 문장을 만들어낼 수가 있습니다. 각 문장에 대해 숫자를 부여한다면 엄청나게 많은 숫자들로 구성이 되겠지만, 이건 관리 차원에서도 어렵고, 데이터가 숫자 하나로 정리되기에는 너무 간결하여 Representation이 떨어집니다.

 

그래서 Tokenization이라는 개념을 필요로 합니다. Tokenization은 자연어 문장을 특정 단위로 자르는 행위입니다.

'I Love You' -> Tokenization -> 'I', 'Love', 'You'

특정 단위로 Token으로 자르면서 중요한 점은 각 Token이 의미가 있는 표현(Meaningful Representation)이냐, 모델에 가장 적합한 표현이냐, 이 2가지를 중요한 요소로 보고 있습니다. 그래서, 오늘 포스팅에서는 Tokenization은 어떤 방법들이 있는지를 알아보았습니다.

📄 Word-based Tokenizer

가장 먼저 생각할 수 있는 Tokenization의 형태는 단어를 기반으로 한 방법이었습니다. 그중에서도 보편적인 방식은 (1) 공백을 기준으로 분리하는 방법입니다.

이 방법과 더불어 (2) Punctuation(문장 기호)를 기준으로 분리하는 것도 단어 기반 토큰화 방법입니다.

# Word-based Tokenizer

# (1) Split on spaces

tokenized_text1 = "Let's do Tokenization!".split()
print(tokenized_text1)
# output: ["Let's", 'do', 'Tokenization!']

# (2) Split on punctuation

tokenized_text2 = "Let's do Tokenization!".replace('\'', ' \'') # '\'' 를 ' \''로
tokenized_text2 = tokenized_text2.replace('!', ' !') # '!' 를 ' !'로
tokenized_text2 = tokenized_text2.split()
print(tokenized_text2)
# output: ['Let', "'s", 'do', 'Tokenization', '!']

이러한 Word-based Tokenizer를 사용하여 말뭉치(문장)에 존재하는 독립적인 토큰들의 총합으로 구성되는 꽤 큰 규모의 Vocabulary를 얻을 수 있습니다.

 

여기서 Vocabulary라는 개념이 등장하는데 이는 텍스트 데이터셋에서 사용되는 모든 고유한 단어들의 집합을 의미합니다. Vocabulary에는 각 토큰에 대해 고유한 ID가 부여되어 있습니다. 

 

하지만, 언어 체계에 대해 많은 부분을 Tokenization 하면, (1) 엄청난 양의 Token이 발생하여 Vocabulary가 발생합니다.

또한, (2)'dog''dogs'가 유사한 단어들인지 파악하기 어렵습니다. (물론, 임베딩의 과정에서 이러한 관계성을 찾을 수는 있으나 Tokenizer 또한 최대한 의미 있는 Representation을 지향하기 때문에 문제점으로 보입니다.)

마지막으로, (3) Vocabulary에 없는 단어는 'unknown' 토큰으로 알려져 있으며, Tokenizer가 'unknown' 토큰을 많이 생성한다는 것은 합당한 표현을 찾을 수 없다는 의미로 Tokenization 과정에서 정보를 잃어버린다는 것을 의미합니다.

 

즉, Vocabulary를 만들 때는 'unknown' 토큰을 최대한 적게 출력하는 것이 목표가 되어야 합니다.

이러한 문제점을 해결하기 위해 나온 방법이 Character-based Tokenizer입니다.

📄 Character-based Tokenizer

Character-based Tokenizer는 말 그대로 word 단위가 아닌 character 단위로 나누는 것을 의미합니다.

# Character-based Tokenizer

tokenized_text = " ".join("Let\'s do Tokenization!") # 각 character 사이에 공백 추가
tokenized_text = tokenized_text.split()
print(tokenized_text)
# output : ['L', 'e', 't', "'", 's', 'd', 'o', 'T', 'o', 'k', 'e', 'n', 'i', 'z', 'a', 't', 'i', 'o', 'n', '!']

Character-based Tokenizer를 사용하면 (1) Vocabulary의 사이즈가 굉장히 작아지고, (2) 'unknown' 토큰의 수도 Word-based에 비해 상대적으로 많이 줄어들 것입니다.

 

하지만, 탁월한 장점을 지닌 것에 비해 치명적인 단점 또한 존재합니다. (1) 직관적으로 봤을 때, 각 토큰이 가지는 의미 파악이 어려워집니다. 그리고, (2) 가장 작은 단위로 Tokenization을 하기 때문에 모델이 처리해야 하는 토큰의 양이 매우 많아집니다.

 

Word-based, Character-based에 따라 장단점이 존재하며 이러한 2가지 방법을 최대한 활용하기 위해 이를 결합한 3번째 기법인 Subword Tokenizer를 사용할 수 있습니다.

📄 Subword Tokenizer

Subword Tokenizer는 Word-based에 따라 공백으로 각 word를 분리합니다. 조건에 따라 아래의 그림처럼 Tokenization을 수행하게 됩니다.

Subword Tokenizer

Word는 'Frequently used words인가 Rare words인가'로 나뉩니다. 이에 대한 기준점은 Vocabulary 내의 존재 여부가 될 수도 있고, 발생하는 빈도 수가 될 수 있습니다. 즉, 사용자가 지정하고자 하는 방식으로 기준점에 따라 분기를 합니다.

 

Frequency used words는 그 자체로 의미를 가질 가능성이 높기 때문에 더 이상의 분할은 하지 않습니다.

Rare words의 경우는 어떤 의미인지 명확하지 않기 때문에 Meaningful subword가 되게끔 분할을 합니다.

 

이러한 Subword Tokenizer의 원리로 기존 2가지 방법의 문제점을 해결하면서 더 MeaningfulTokenization을 가능하게 합니다.


✅ Wordpiece Tokenizer

Subword Tokenizer와 관련된 더 디테일한 기법들이 있으며, 이번 프로젝트에 사용되는 모델 중 하나인 BERT의 기반이 되는 Tokenizer인 Wordpiece Tokenizer에 대해서 알아보았습니다.

이에 대한 작동 원리를 알아보기 위해서 Hugging Face의 오픈소스를 열어 코드를 클론 하며 분석해 보았습니다.

class WordpieceTokenizer(object):
    def __init__(self, vocab, unk_token='[UNK]', max_input_chars_per_word=100):
        self.vocab = vocab # Vocabulrary
        self.unk_token = unk_token # OOV
        self.max_input_chars_per_word = max_input_chars_per_word

    def whitespace_tokenize(self, text):
        # 선행, 후행 문자를 지우는 함수, 해당 문자는 arg로 넘김
        text = text.strip()
        if not text:
            return []
        # seperator 기준으로 분할, separtor가 연속적으로 2개이상 있다면, 이것도 다 날림
        tokens = text.split()
        return tokens

    def tokenize(self, text):
        output_tokens = []

        for token in self.whitespace_tokenize(text): # 선후행 공백 모두 제거 및 word-based tokenization
            # 하나의 Token에 대해서 Character로 분할
            chars = list(token)

            if len(chars) > self.max_input_chars_per_word:
                # OOV의 경우, 길이가 100이 넘을 때
                output_tokens.append(self.unk_token)
                continue

            is_bad = False
            start = 0
            sub_tokens = []
            while start < len(chars):
                
                end = len(chars)
                cur_substr = None
                while start < end:
                    # string join "seperator".join(list)
                    # result: li[0]sepli[1]sep...li[end_idx]
                    substr = "".join(chars[start:end])
                    if start > 0:
                        substr = '##' + substr
                    if substr in self.vocab:
                        cur_substr = substr
                        break
                    end -= 1
                if cur_substr is None:
                    # 이 경우는 token 그 자체, 모든 조합을 탐색했을 때도 없는 경우
                    is_bad = True
                    break
                sub_tokens.append(cur_substr)
                start = end

            if is_bad:
                output_tokens.append(self.unk_token)
            else:
                output_tokens.extend(sub_tokens)
        return output_tokens

이 코드에서 가장 중요한 부분은 tokenize 메서드입니다.

 

그중에서도 아래의 코드 부분이 Wordpiece의 핵심입니다

# 하나의 Token에 대해서 Character로 분할
chars = list(token)

if len(chars) > self.max_input_chars_per_word:
    # OOV의 경우, 길이가 100이 넘을 때
    output_tokens.append(self.unk_token)
    continue

is_bad = False
start = 0
sub_tokens = []
while start < len(chars):
    
    end = len(chars)
    cur_substr = None
    while start < end:
        # string join "seperator".join(list)
        # result: li[0]sepli[1]sep...li[end_idx]
        substr = "".join(chars[start:end])
        if start > 0:
            substr = '##' + substr
        if substr in self.vocab:
            cur_substr = substr
            break
        end -= 1
    if cur_substr is None:
        # 이 경우는 token 그 자체, 모든 조합을 탐색했을 때도 없는 경우
        is_bad = True
        break
    sub_tokens.append(cur_substr)
    start = end

if is_bad:
    output_tokens.append(self.unk_token)
else:
    output_tokens.extend(sub_tokens)

위 코드는 하나의 토큰에 대해서 Vocabulary 내에 있는지 확인합니다. 이 과정은 frequently used words인지 rare words인지를 파악하는 것과 같습니다.

 

[⭐중요]

frequently used words라 판단이 되면, 그 자체로 토큰으로 넘기는 작업을 수행합니다.

하지만, rare words라 판단이 되면, 맨 끝 Character부터 index를 하나씩 줄여서 만들어진 Substring에 대해 Vocabulary에 속하는지 확인합니다.

끝까지 갔음에도 Vocabulary에 없다면 unknown 토큰으로 처리합니다. 반대로 있는 경우라면 해당 Substring을 토큰으로 처리한 다음에 남은 substring에 대해서 똑같은 과정을 반복합니다.

이러한 과정이 글로 표현하기에는 다소 이해하기 어려운 부분이 있기 때문에 'bedroom'이라는 word와 'bed', 'room'이 Vocabulary에 있을 때 상황을 가정하여 반복문의 step에 대해 시각화를 해보면 아래와 같습니다.

Wordpiece Tokenizer

그림을 통해 이해가 되셨으리라 생각합니다 :)

 

또한, WordpieceTokenizer class를 선언해서 한 번 핸들링해 봅시다. 이번엔 'entertainment'라는 워드에 대해서 tokenization 할 때, Vocabulary로 'enter', 'tain', 'ment'를 가지고 있다고 가정해 봅시다.

다만, 코드는 접두어를 제외한 나머지 토큰에 대해서는 앞에 무언가가 있었다는 의미에서 '##'이 붙게 됩니다.

 

이러한 점들을 고려하여 시각화한 그림처럼 작동하는지 알아보기 위해 반복 step마다 고려하고 있는 문자열을 출력해 봅시다.

vocab = ['enter', '##enter', 'tain', '##tain', 'ment', '##ment']
tokenizer = WordpieceTokenizer(vocab)

ret = tokenizer.tokenize('entertainment')
print(ret)

'''
OUTPUT
entertainment
entertainmen
entertainme
entertainm
entertain
entertai
enterta
entert
enter
##tainment
##tainmen
##tainme
##tainm
##tain
##ment
['enter', '##tain', '##ment']
'''

시각화한 figure와 같이 잘 작동하는 것을 볼 수 있습니다. 또한, tokenization 된 리스트에도 token이 잘 담겨있네요!

 

그래서 오픈소스 코드 분석을 통해서 WordpieceTokenizer가 어떻게 작동하는지 알게 되었습니다 😁😁


✅ Outro

포스팅의 내용을 정리하면, NLP에서 Tokenization이라는 개념을 배웠고, 크게 3가지 방식으로 나뉘는 것을 알게 되었습니다.

또한, 그중에서도 Subword Tokenizer가 나머지 두 방식에 대한 장단점을 보완한 방식이며, Subword Tokenizer 관련 방법 중에서도 BERT의 Tokenizer 기반이 되는 Wordpiece Tokenizer에 대해 공부했습니다.

Wordpiece Tokenizer에 대해 공부하기 위해 HuggingFace의 오픈소스 코드 분석을 통해 작동 원리를 알게 되었습니다!


✅ Reference

[1] WikiDocs Tokenizer

[2] HuggingFace Github Opensource