[Hands-On] BPE(Byte Pair Encoding)를 활용한 토크나이저 구현

Hugman Sangkeun Jung
17 min readJul 20, 2024

--

(You can find the English version of the post at this link.)

BPE Tokenization (Image by the author using ChatGPT)

이전 글에서 우리는 자연어처리에서의 토크나이제이션에 대한 개념을 살펴보았고, 구체적으로 BPE, Byte-level BPE, WordPiece 및 Unigram 토크나이제이션의 개념에 대해 살펴보았습니다. 이 글은 주요 토크나이제이션을 직접 구현해보는 구현시리즈의 첫번째 글에 해당합니다.

(이 튜토리얼은 일부 핵심 기능들은 BPE tokenization 을 잘 설명해 놓은 Huggingface 글에서 일부 참조했음을 밝힙니다.)

이번 글에서는 BPE(Byte Pair Encoding) 토크나이제이션의 기본 개념과 구현 방법에 대해 알아보겠습니다. BPE는 텍스트를 더 작은 단위로 나누어 드문 단어를 효과적으로 처리하는 방법입니다.

목표

  1. BPE 토크나이제이션의 기본 개념 이해
  2. 텍스트 데이터 토크나이제이션 구현 및 작은 어휘 구축
  3. BPE 토크나이제이션의 학습 알고리즘과 단어 분할 방법 소개
  4. 예제 텍스트 데이터에 대한 BPE 토크나이제이션 수행

BPE 토크나이제이션이란?

BPE는 원래 데이터 압축을 위해 개발되었지만, 자연어 처리 작업, 특히 기계 번역과 언어 모델링에 적용되었습니다. BPE의 핵심 아이디어는 코퍼스에서 가장 빈번한 문자(또는 서브워드) 쌍을 반복적으로 병합하여 새로운 토큰을 만드는 것입니다.

BPE 프로세스는 다음과 같이 요약할 수 있습니다:

  1. 개별 문자(Character)로 구성된 어휘로 시작
  2. 코퍼스에서 문자 쌍의 빈도 계산
  3. 가장 빈번한 쌍을 병합하여 새 토큰 생성
  4. 새 토큰을 어휘에 추가
  5. 원하는 어휘 크기에 도달하거나 더 이상 병합이 불가능할 때까지 2–4단계 반복

이 접근 방식을 통해 모델은 희귀 단어와 형태적 변형을 더 일반적인 서브워드 단위로 분해하여 효과적으로 처리할 수 있습니다. 보다 자세한 개념 설명은 이 글을 참고하세요.

환경 준비하기

먼저 필요한 라이브러리를 설치하고 가져옵니다:

from transformers import AutoTokenizer
from collections import defaultdict
tokenizer = AutoTokenizer.from_pretrained("gpt2")

transformers 라이브러리에서 AutoTokenizer를 사용하여 GPT-2 토크나이저를 로드합니다. 이 tokenizer를 직접 쓰려는것이 아니고, character나 word 레벨의 기본 tokenizer를 활용하려고 준비하는 것입니다.

샘플 문장 설정 및 단어 빈도 계산

BPE 학습을 위한 샘플 문장을 설정하고 각 단어의 빈도를 계산합니다:

sample = "low low low low low lower lower newest newest newest newest newest newest widest widest widest"
# Calculate word frequency
word_freqs = defaultdict(int)
words_with_offsets = tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(sample)
new_words = [word for word, offset in words_with_offsets]
for word in new_words:
word_freqs[word] += 1

for word, freq in word_freqs.items():
print(f"{word:>12}: {freq}") # Align numbers to the right
         low: 1
Ġlow: 4
Ġlower: 2
Ġnewest: 6
Ġwidest: 3

여기서 ‘Ġ’ 기호는 GPT-2 토크나이저가 사용하는 특수 문자로, 원래 텍스트에서 공백 다음에 오는 단어의 시작을 나타냅니다. 이는 모델이 단어 경계와 띄어쓰기에 대한 정보를 보존하는 데 중요합니다. 다른 토크나이제이션 방법에서는 다른 특수기호를 활용하기도 합니다 .

초기 어휘 생성

코퍼스에 사용된 모든 문자를 포함하는 기본 어휘를 생성합니다.

alphabet = []
for word in word_freqs.keys():
for letter in word:
if letter not in alphabet:
alphabet.append(letter)
alphabet.sort()

print("Base vocabulary:", alphabet)
Base vocabulary: ['d', 'e', 'i', 'l', 'n', 'o', 'r', 's', 't', 'w', 'Ġ']

특수 토큰 추가

vocab = ["<|endoftext|>"] + alphabet. Copy()

‘<|endoftext|>’ 토큰은 GPT-2 토크나이저에서 텍스트 시퀀스의 끝을 나타내거나, 시퀀스 패딩, 텍스트 생성 중단 신호 등 다양한 목적으로 사용됩니다. 본 실습에서는 굳이 추가하지 않아도 괜찮습니다.

단어 분할

각 단어를 개별 문자로 분할합니다. 이는 BPE 학습의 시작점입니다:

splits = {word: [c for c in word] for word in word_freqs.keys()}
print("Initial word splits:")
for word, split in splits.items():
print(f"{word:>12}: {split}")
Initial word splits:
low: ['l', 'o', 'w']
Ġlow: ['Ġ', 'l', 'o', 'w']
Ġlower: ['Ġ', 'l', 'o', 'w', 'e', 'r']
Ġnewest: ['Ġ', 'n', 'e', 'w', 'e', 's', 't']
Ġwidest: ['Ġ', 'w', 'i', 'd', 'e', 's', 't']

쌍 빈도 계산 함수

단어 내 연속된 문자 쌍의 빈도를 계산하는 함수를 정의합니다:

def compute_pair_freqs(splits):
pair_freqs = defaultdict(int)
for word, freq in word_freqs.items():
split = splits[word]
if len(split) == 1:
continue
for i in range(len(split) - 1):
pair = (split[i], split[i + 1])
pair_freqs[pair] += freq
return pair_freqs

위 함수를 이용해서 각 단어내에서의 문자들의 조합이 얼마나 많이 나타나는지 계산 가능합니다.

BPE 학습

이를 이용하여 이제 본격적으로 BPE를 학습 시킬 수 있습니다.

BPE 학습 알고리즘

  1. 가장 빈번한 문자 쌍 찾기
  2. 이 쌍을 새 토큰으로 병합
  3. 새 토큰을 어휘에 추가
  4. 원하는 어휘 크기에 도달할 때까지 반복
vocab_size = 50
merges = {}

while len(vocab) < vocab_size:
pair_freqs = compute_pair_freqs(splits)
if not pair_freqs:
break

best_pair = max(pair_freqs, key=pair_freqs.get)
print("Best Pair : ", best_pair)
new_token = ''.join(best_pair)
vocab.append(new_token)
merges[best_pair] = new_token

# Update split words
for word in word_freqs:
split = splits[word]
i = 0
while i < len(split) - 1:
if (split[i], split[i+1]) == best_pair:
split = split[:i] + [new_token] + split[i+2:]
else:
i += 1
splits[word] = split

print("Final vocabulary:", vocab)
print("Merge rules:", merges)
Best Pair :  ('e', 's')
Best Pair : ('es', 't')
Best Pair : ('l', 'o')
Best Pair : ('lo', 'w')
Best Pair : ('Ġ', 'low')
Best Pair : ('Ġ', 'n')
Best Pair : ('Ġn', 'e')
Best Pair : ('Ġne', 'w')
Best Pair : ('Ġnew', 'est')
Best Pair : ('Ġ', 'w')
Best Pair : ('Ġw', 'i')
Best Pair : ('Ġwi', 'd')
Best Pair : ('Ġwid', 'est')
Best Pair : ('Ġlow', 'e')
Best Pair : ('Ġlowe', 'r')
print("Final vocabulary:", vocab)
print("Merge rules:", merges)
Final vocabulary: ['<|endoftext|>', 'd', 'e', 'i', 'l', 'n', 'o', 'r', 's', 't', 'w', 'Ġ', 'es', 'est', 'lo', 'low', 'Ġlow', 'Ġn', 'Ġne', 'Ġnew', 'Ġnewest', 'Ġw', 'Ġwi', 'Ġwid', 'Ġwidest', 'Ġlowe', 'Ġlower']
Merge rules: {('e', 's'): 'es', ('es', 't'): 'est', ('l', 'o'): 'lo', ('lo', 'w'): 'low', ('Ġ', 'low'): 'Ġlow', ('Ġ', 'n'): 'Ġn', ('Ġn', 'e'): 'Ġne', ('Ġne', 'w'): 'Ġnew', ('Ġnew', 'est'): 'Ġnewest', ('Ġ', 'w'): 'Ġw', ('Ġw', 'i'): 'Ġwi', ('Ġwi', 'd'): 'Ġwid', ('Ġwid', 'est'): 'Ġwidest', ('Ġlow', 'e'): 'Ġlowe', ('Ġlowe', 'r'): 'Ġlower'}
print("Number of vocabulary", len(vocab))
print("Final vocabulary:", vocab)
print("Merge rules:")
for (a, b), merge in merges.items():
print(f"('{a}', '{b}') -> '{merge}'")
Number of vocabulary 27
Final vocabulary: ['<|endoftext|>', 'd', 'e', 'i', 'l', 'n', 'o', 'r', 's', 't', 'w', 'Ġ', 'es', 'est', 'lo', 'low', 'Ġlow', 'Ġn', 'Ġne', 'Ġnew', 'Ġnewest', 'Ġw', 'Ġwi', 'Ġwid', 'Ġwidest', 'Ġlowe', 'Ġlower']
Merge rules:
('e', 's') -> 'es'
('es', 't') -> 'est'
('l', 'o') -> 'lo'
('lo', 'w') -> 'low'
('Ġ', 'low') -> 'Ġlow'
('Ġ', 'n') -> 'Ġn'
('Ġn', 'e') -> 'Ġne'
('Ġne', 'w') -> 'Ġnew'
('Ġnew', 'est') -> 'Ġnewest'
('Ġ', 'w') -> 'Ġw'
('Ġw', 'i') -> 'Ġwi'
('Ġwi', 'd') -> 'Ġwid'
('Ġwid', 'est') -> 'Ġwidest'
('Ġlow', 'e') -> 'Ġlowe'
('Ġlowe', 'r') -> 'Ġlower'

이제 모든 준비가 끝났습니다. 우리는 가장 많이 나타나는 subword 의 조합을 얻어냈고, 그 조합으로 새로운 어휘셋도 만들었습니다.

토크나이제이션 함수

실질적으로 문장을 혹은 문서를 Tokenization 하는 함수를 만들어 보죠.

def tokenize(text):
pre_tokenize_result = tokenizer._tokenizer.pre_tokenizer.pre_tokenize_str(text)
pre_tokenized_text = [word for word, offset in pre_tokenize_result]
splits = [[l for l in word] for word in pre_tokenized_text]
for pair, merge in merges.items():
for idx, split in enumerate(splits):
i = 0
while i < len(split) - 1:
if split[i] == pair[0] and split[i + 1] == pair[1]:
split = split[:i] + [merge] + split[i + 2 :]
else:
i += 1
splits[idx] = split
return sum(splits, [])

원리는 간단합니다.

다음과 같이 설명할 수 있습니다:

  1. 전처리 (Pre-tokenization):
    - tokenizer._tokenizer.pre_tokenizer.pre_tokenize_str(text)를 사용하여 입력 텍스트를 초기 분할합니다.
    - 이 단계에서는 주로 공백을 기준으로 단어를 나누고, 문장 부호 등을 분리합니다.
  2. 초기 분할:
    - pre_tokenized_text에서 각 단어를 개별 문자로 분할합니다.
    - 예: “low” -> [‘l’, ‘o’, ‘w’]
  3. 병합 규칙 적용:
    - merges 딕셔너리에 저장된 BPE 병합 규칙을 순차적으로 적용합니다.
    - 각 분할된 단어(split)에 대해:
    a. 인접한 두 토큰이 병합 규칙과 일치하는지 확인합니다.
    b. 일치하면 해당 토큰 쌍을 병합된 새 토큰으로 교체합니다.
    c. 일치하지 않으면 다음 토큰 쌍으로 이동합니다.
    - 이 과정을 모든 병합 규칙에 대해 반복합니다.
  4. 결과 합치기:
    - 모든 분할된 단어(splits)를 하나의 리스트로 합칩니다.

이 과정을 통해 입력 텍스트는 학습된 BPE 규칙에 따라 서브워드 단위로 토큰화됩니다. 이 방법의 장점은 자주 등장하는 부분 문자열을 하나의 토큰으로 처리할 수 있어, 효율적인 어휘 사용과 미등록 단어 처리가 가능하다는 것입니다.

예를 들어, “lowest”라는 단어가 입력되면:

  1. 초기에 [‘l’, ‘o’, ‘w’, ‘e’, ‘s’, ‘t’]로 분할됩니다.
  2. 병합 규칙 적용 시 ‘low’와 ‘est’가 각각 하나의 토큰으로 병합될 수 있습니다.
  3. 최종적으로 [‘low’, ‘est’]와 같이 토큰화될 수 있습니다.

이런 방식으로 BPE는 단어의 의미있는 부분(접두사, 어근, 접미사 등)을 효과적으로 포착할 수 있습니다.

Decode 함수

주어진 문장/문서를 tokenization 하는 함수가 있다면 반대로, Tokenization 된 list of tokens 을 다시 원래의 문장/문서로 바꿔주는 함수도 필요합니다.

# Define the decode function
def decode(tokens):
text = ''
for token in tokens:
if token.startswith('Ġ'):
text += ' ' + token[1:] # Remove 'Ġ' and add a space
else:
text += token
return text.strip() # Remove leading/trailing whitespace

원리는 매우 간단하죠.

  1. 토큰 순회: 함수는 입력으로 받은 토큰 리스트를 순차적으로 처리합니다.
  2. 특수 문자 처리:
    - ‘Ġ’로 시작하는 토큰을 만나면, 이는 원래 텍스트에서 새로운 단어의 시작을 의미합니다.
    - 이 경우, ‘Ġ’를 제거하고 그 자리에 공백을 추가합니다.
  3. 일반 토큰 처리: ‘Ġ’로 시작하지 않는 토큰은 그대로 텍스트에 추가됩니다.
  4. 공백 정리: 마지막으로 strip() 메소드를 사용하여 결과 텍스트의 앞뒤 불필요한 공백을 제거합니다.

이 함수의 핵심은 ‘Ġ’ 문자의 처리입니다. GPT-2 토크나이저에서 ‘Ġ’는 단어 경계를 나타내는 특수 문자로 사용됩니다. 디코딩 과정에서 이를 적절히 처리함으로써, 원래의 단어 분리와 띄어쓰기를 복원할 수 있습니다.

예를 들어, [‘Ġlow’, ‘est’] 같은 토큰 시퀀스가 입력되면:

  1. ‘Ġlow’를 처리할 때 공백을 추가하고 ‘low’를 텍스트에 추가합니다.
  2. ‘est’는 그대로 추가됩니다.
  3. 결과적으로 “ lowest”가 됩니다.

이 간단한 메커니즘을 통해 토큰화된 텍스트를 원래의 가독성 있는 형태로 복원할 수 있습니다.

Tokenization 예시

실제 문장을 한번 토크나이제이션 해보죠.

test_sentence = "The lowest, newest and widest!"
tokens = tokenize(test_sentence)
print(f"Tokenization result for '{test_sentence}':")
print(tokens)
Tokenization result for 'The lowest, newest and widest!':
['T', 'h', 'e', 'Ġlow', 'est', ',', 'Ġnewest', 'Ġ', 'a', 'n', 'd', 'Ġwidest', '!']
# Demonstrate the decode function
decoded_text = decode(tokens)
print(f"\nDecoded text: '{decoded_text}'")

# Verify if the decoded text matches the original
print(f"Matches original: {decoded_text == test_sentence}")
Decoded text: 'The lowest, newest and widest!'
Matches original: True

또 다른 예제를 해보죠.

test_sentence = "lowest"
tokens = tokenize(test_sentence)
print(f"Tokenization result for '{test_sentence}':")
print(tokens)
Tokenization result for 'lowest':
['low', 'est']
# Demonstrate the decode function
decoded_text = decode(tokens)
print(f"\nDecoded text: '{decoded_text}'")

# Verify if the decoded text matches the original
print(f"Matches original: {decoded_text == test_sentence}")
Decoded text: 'lowest'
Matches original: True

결론

이 실습 튜토리얼에서는 BPE(Byte Pair Encoding) 토크나이제이션의 기본 개념과 구현 방법을 살펴보았습니다. BPE는 텍스트를 단어 수준에서 분할한 후 문자 수준에서 다시 분할하고, 문자 쌍의 빈도에 기반하여 병합하는 토크나이제이션 기법입니다. 원하는 수의 어휘를 생성하거나 더 이상 사전을 업데이트할 수 없을 때 중지됩니다.

BPE의 주요 특징:

  1. 서브워드 분할: BPE는 단어를 의미 있는 서브워드 단위로 나눕니다.
  2. 빈도 기반 병합: 가장 빈번한 문자 쌍을 반복적으로 병합하여 새로운 토큰을 생성합니다.
  3. 특수 토큰 처리: ‘Ġ’와 같은 특수 문자를 사용하여 단어 경계를 표시합니다.
  4. 미등록 단어 처리: 학습하지 않은 단어도 서브워드 단위로 분해하여 처리할 수 있습니다.

BPE의 장점:

  1. 형태학적으로 풍부한 언어에 효과적
  2. 어휘 크기와 표현력 사이의 균형 유지
  3. 미등록 단어를 잘 처리함
  4. 데이터에 특화된 토크나이제이션 가능

BPE는 GPT-2, RoBERTa 등 많은 최신 언어 모델에서 널리 사용됩니다. 이 구현은 BPE의 기본을 이해하는 데 도움이 되지만, 실제 응용에서는 더 정교한 전처리 및 후처리 단계가 포함될 수 있습니다.

다음 Colab 링크에서 직접 코드를 수행해 볼 수 있습니다.

--

--

Hugman Sangkeun Jung

Hugman Sangkeun Jung is a professor at Chungnam National University, with expertise in AI, machine learning, NLP, and medical decision support.