본문 바로가기

자연어처리

Subword Tokenizer - (1) Byte Pair Encoding(BPE) Tokenization

토큰화를 할 때, 어떤 토큰이 어떤 정수 인덱스(또는 아이디)로 연결됐는지 기록해 둔 단어 집합(또는 사전, vocabulary)을 만들어야 한다.

■ 토큰의 단위를 큰 단위(ex) 단어)로 할수록 텍스트의 의미가 잘 유지된다는 장점이 있지만, 사전의 크기가 커진다는 단점이 있다. 또한, 이전에 본 적이 없는 새로운 단어는 단어 집합에 없기 때문에 OOV 문제가 발생한다.

- 예를 들어 단어 단위로 토큰화룰 하는 경우, 텍스트에 등장하는 단어의 수만큼 토큰 아이디가 필요하기 때문에 단어 집합의 크기가 커진다.

■ 반대로 작은 단위(ex) 문자)로 토큰화하는 경우 사전의 크기가 작고 OOV 문제를 줄일 수 있지만, 의미가 유지되지 않는다는 단점이 있다.

- 예를 들어 단어 단위로 토큰화한 결과가 'manual', 'latex'라면, 단어 단위보다 더 작은 문자 단위로 토큰화했을 때, 토큰은 'm', 'a', 'n', 'u', 'x', .... 이렇게 되기 때문에 의미가 유지되지 않는다.

■ 이렇게 작은 단위와 큰 단위 모두 각각의 장단점이 뚜렷하다. 그래서 최근에는 텍스트 데이터에 등장하는 빈도에 따라 토큰화 단위를 결정하는 서브워드(subword) 토큰화 방식을 사용한다. 

■ 서브워드 토큰화 방식은 자주 나오는 단어는 단어 단위 그대로 유지할 수 있고, 가끔 나오는 단어는 문자 단위와 단어 단위 사이로 유지할 수 있다. 즉, 사전의 크기를 효율적으로 만들 수 있다.

- 예를 들어, 텍스트 데이터에 ' pytorch '라는 단어의 등장 빈도수가 높다면 단어 집합에는 'pytorch'가 등록될 것이다.

- 그리고 'tensorflow'라는 단어가 가끔 나오는 단어라면 단어 집합(사전)에는 'tensor', 'flow'나 'ten', 'so', 'r', 'flow' 등으로 등록될 것이다. 

 

1. Byte Pair Encoding(BPE)

■ 아무리 많은 단어를 단어 집합(사전)에 저장한 다음, 단어 집합을 이용해도 세상의 모든 단어(신조어나 good이 아닌 gooood, ㅋㅋ, ㅋㅋㅋ, ㅋㅋㅋㅋ같은 단어)를 학습할 수는 없다. 

- 왜냐면,  good과 gooood, ㅋㅋ과 ㅋㅋㅋ 그리고 ㅋㅋㅋㅋ도 의미가 동일하지만 서로 다르게 표기되는 단어이다. 단어 집합에서 이 단어들은 모두 서로 다른 단어로 취급된다. 

- 예를 들어 단어 집합에 'ㅋㅋ'이 있지만 새로운 문장에서 'ㅋㅋㅋㅋ'을 만날 경우, 'ㅋㅋㅋㅋ'은 <unk> 토큰으로 처리된다.

■ 보통, 단어 집합에 존재하지 않는 단어(토큰)가 등장하면, 단어 집합에 없는 단어란 의미에서 unknown token. <unk> 토큰으로 표현한다.

이와 같이 단어(토큰)을 저장해뒀던 단어 집합에 모르는(또는 처음 본) 토큰이 나오는 현상을 Out-of-Vocabulary(OOV)라고 한다.

■ 이렇게 새로 나오는 단어마다 <unk> 토큰으로 처리하는 것보다 애초에 단어 집합을 풍부하게 만들면 OOV 문제가 해결될 것이다. 이는 다양한 Token을 많이 모을 수 있는 말뭉치를 사용하여 해결하거나 서브워드 토크나이저(subword tokenizer)를 이용해 서브워드 분리(subword segmentation) 작업을 통해 극복할 수 있다.

■ 서브워드 분리는 예를 들어 birthplace = birth + place처럼, '하나의 단어는 더 작은 단위의 의미있는 여러 서브워드들의 조합으로 구성되는 경우가 많으므로, 하나의 단어를 여러 서브워드로 분리해서 단어를 인코딩 및 임베딩하겠다'는 아이디어를 가진 전처리 작업이다.

■ 이러한 서브워드 분리를 수행하는 대표적인 알고리즘으로 BPE가 있다.


1.1 기존 tokenization 방법 - character based tokenization, n-gram tokenization

1.1.1 character based tokenization

■ 가장 기본적인 토큰화 방법은 띄어쓰기, 글자 단위, n-gram 등이 있다.

■ 글자(character)를 토큰으로 사용한다면, 단어 집합(사전)의 크기가 줄어든다. 

- 한국어에서는 자음과 모음으로 나타낼 수 있는 모든 조합만 생각하면 되고, 영어에서는 소문자 기준으로 26개의 알파벳만 생각하면 되기 때문이다.

## 영어 유니코드
print([chr(k) for k in range(65, 91)]) # 영어 대문자
print([chr(k) for k in range(97, 123)]) # 영어 소문자
```#결과#```
['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']
````````````
## 한국어 유니코드
l = len([chr(k) for k in range(int('0xAC00', 16), int('0xD7A3', 16)+1)])
print('완성형 한글 개수:', l)
print([chr(k) for k in range(int('0xAC00', 16), int('0xD7A3', 16)+1)][:5]) # 예시
```#결과#```
완성형 한글 개수: 11172
['가', '각', '갂', '갃', '간']
````````````

# 한글 자모(字母) 부분 - 자음, 모음, 받침
print([chr(k) for k in range(int('0x3131', 16), int('0x3163', 16)+1)])
```#결과#```
['ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ', 'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']
````````````

- 특수 문자나 대문자를 고려한다 해도 띄어쓰기를 통한 토큰화보다는 단어 집합의 크기가 더 줄어들 수밖에 없다.

또한, 글자를 토큰으로 사용하면 OOV 현상을 사실상 없앨 수 있다. 

- 최소 단위가 ㄱ, ㄴ, ㄷ, ... ㅣ, ㅏ 같은 글자이므로, 자음과 모음으로 표현되는 한국어는 모든 글자를 표현할 수 있기 때문이다.

■ 말뭉치에서 신조어나 오타(글자 변형)으로 새로운 단어(토큰)이 많이 나타날 수 있다. 하지만, 글자를 이용하면 어떤 단어나 문장도 최소 단위인 '글자'로 표현할 수 있기 때문에 신조어나 글자 변형으로 발생하는 OOV를 방지할 수 있다.

■ 예를 들어, 위의 영어 및 한글 유니코드와 특수문자의 유니코드 이용해서 단어 집합을 만든다면, 

index_to_char = {}
index_to_char[0] = '<PAD>'
index_to_char[1] = '<UNK>'

len(index_to_char)
```#결과#```
2
````````````
srt_idx = len(index_to_char)

for x in range(32, 127): # 영어 대소문자, 특수 문자 
    index_to_char.update({srt_idx: chr(x)})
    srt_idx += 1

for x in range(int('0xAC00', 16), int('0xD7A3', 16)+1): # 완성형 한글
    index_to_char.update({srt_idx: chr(x)})
    srt_idx += 1

for x in range(int('0x3131', 16), int('0x3163', 16)+1): # 한글 자모
    index_to_char.update({srt_idx: chr(x)})
    srt_idx += 1
    
len(index_to_char)
```#결과#```
11320
````````````

char_to_index = {}
for key, value in index_to_char.items():
    char_to_index[value] = key
# 예시
print([char_to_index.get(c) for c in '안녕하세요'])
print([char_to_index.get(c) for c in 'ㅋㅋ! good TMI'])
print([char_to_index.get(c)for c in 'ㅋㅋㅋㅋ, gooood ㅇㅋ ㄳㄳ'])
```#결과#```
[6569, 1462, 10681, 5529, 6901]
[11295, 11295, 3, 2, 73, 81, 81, 70, 2, 54, 47, 43]
[11295, 11295, 11295, 11295, 14, 2, 73, 81, 81, 81, 81, 70, 2, 11291, 11295, 2, 11271, 11271]
````````````

■ 위의 예시를 보면 알 수 있듯이, 기존에 정의된 단어, 문장,  줄임말이나 신조어 등 모두 정수 인코딩을 부여할 수 있으므로 OOV를 방지할 수 있다.

또한, len(index_to_char)( 또는 len(char_to_index))을 확인하면, 단어 집합(사전)의 크기도 큰 편이 아닌 것을 확인할 수 있다. 그 이유는 바로 '글자'가 최소 단위이기 때문에 세상에 존재하는 '글자'를 조합해 만든 '단어'의 개수가 더 많을 수 밖에 없다.

즉, 단어 집합에 모든 단어를 저장하는 것보다 모든 글자 수를 저장하는 것이 훨씬 작은 크기의 단어 집합(사전)이 된다.

■ 하지만, 글자 단위 토큰화는 학습이 어렵다는 단점이 있다. 

■ 예를 들어, 'g'나 '안'과 같이 글자 하나는 보통  그 자체로는 어떤 의미를 가지고 있지 않다. '단어 단위'가 되어야 의미를 가지게 된다.

■ 이렇게 글자 단위로 토큰화한다면, 위의 예시처럼 특정 글자의 연속된 나열이 특정 의미를 나타낸다.

이를 모델이 패턴으로 학습해서 글자의 조합에 대한 정보 또는 의미를 파악해야 하는데, '글자 단위'이기 때문에 글자의 조합에 대한 정보/의미를 '단어 단위'보다 학습하기 어렵다.

- 언어는 계층적 구조를 가진다. "글자 \( \rightarrow \) 단어 \( \rightarrow \) 문장 \( \rightarrow \) 문단"

- 위의 예시처럼 '안', '녕'이나 'g', 'o', 'o', 'd'같은 글자의 나열들이 하나의 단어 '안녕', 'good'을 만들고

- 이 단어의 나열들이 '안녕하세요'처럼 하나의 문장이되며,

- 문장의 나열들이 하나의 문단이 되기 때문에

최소 단위인 '글자' 단위로 토큰화한다면, 문장 또는 문단을 학습하는 것은 더 큰 단위의 의미를 파악하는 것이기 때문에 상당한 계산 부담이 발생한다.

- 이 예에서 '안녕하세요'와 같은 문장을 이해하기 위해 모델은 단어 집합(사전)에 저장된 개별 글자들('안', '녕', '하', '세','요')의 조합이 만들어내는 의미를 파악해야 하므로

- 단어 단위 토큰화(또는 띄어쓰기 토큰화)보다 훨씬 더 많은 복잡성이 요구된다.

1.1.2 n-gram tokenization

1.1.1의 내용처럼 토큰이 '글자'라면 OOV 현상을 방지할 수 있다. 하지만 글자 하나에는 보통 의미가  없기 때문에, '단어 = 글자의 특정 연속성'이 의미를 가지는 '단어'를 학습하게 만들어야 하고, 이를 기반으로 문장이나 문단과 같은 더 긴 형태의 글을 이해하도록 만들어야 한다.

■ 1.1.1의 예시에서 특수 문자를 제외하면, '글자' 토큰으로 영어는 26개의 알파벳, 한글은 약 1만 개가 있었다. 이것으로 계층적 구조(글자 \( \rightarrow \) 단어 \( \rightarrow \) 문장 \( \rightarrow \) 문단)를 학습하는 것은 어려운 문제이다. 

■ 그러므로 글자 단위 토큰화는 단어 수준 혹은 짧은 문장에 대한 이해가 필요한 nlp task가 아닌, 긴 문장 또는 문단 수준의 이해나 문장 생성에 대한 nlp task에서 사용하기는 어렵다.

■ 띄어쓰기 토큰화를 사용하지 않는 경우, 글자보다 좀 더 긴 형태의 토큰을 만들어내는 방법 중 n-gram 토큰화가 있다. 

n-gram 토큰화는 n개의 연속된 윈도우를 단위로 하여 토큰화를 수행한다. 

- n = 1이면, 유니그램(unigram) 또는 1-gram이라고 부른다.

- n = 2이면, 바이그램(bigram) 또는 2-gram

- n = 3이면, 트라이그램(trigram) 또는 3-gram

■ 1.1.1의 '글자 단위 토큰화'는 1개의 글자에 대한 '1개의 글자를 토큰 단위로 하는(=윈도우가 1인) unigram(또는 1-gram) 토큰화'라고 볼 수 있다. 

- 즉, bigram은 2개의 글자를 토큰 단위로, trigram은 3개의 글자를 토큰 단위로 사용한다.

■ 예를 들어, 다음과 같은 문장이 있을 때, 글자 단위를 토큰 단위로 한다는 가정 하에 unigram, bigram, trigram을 구해보면

[sentence[i:i+1] for i in range(len(sentence))] # uni-gram
```#결과#```
['나', '는', ' ', '고', '양', '이', '로', '소', '이', '다']
````````````

[sentence[i:i+2] for i in range(len(sentence))] # bi-gram
```#결과#```
['나는', '는 ', ' 고', '고양', '양이', '이로', '로소', '소이', '이다', '다']
````````````

[sentence[i:i+3] for i in range(len(sentence))] # tri-gram
```#결과#```
['나는 ', '는 고', ' 고양', '고양이', '양이로', '이로소', '로소이', '소이다', '이다', '다']
````````````

- 여기서의 unigram은 1.1.1의 글자 단위 토큰화의 결과라고 볼 수 있다. 

- 위의 결과를 보면 알 수 있듯이, unigram은  '고', '양', '이', '로', ... 처럼  대부분 그 자체로 의미 없는 글자가 토큰이었다면,

- bigram이나 trigram에서는 '이다', '고양이' 등의 의미를 가지는 토큰이 있는 것을 확인할 수 있다.

■ 글자 단위가 아니라 띄어쓰기 단위의 토큰에 대해서도 n-gram을 사용할 수 있다. 특히, 한글과 달리 띄어쓰기 토큰화로 대부분 토큰들이 '단어'로 분리되는 영어의 경우, 예를 들어 'I am dying', 'am dying to'와 같은 '연속적으로 사용되는 영어의 용어'를 토큰화할 수 있다. 

sentence = 'I am dying to play the game' 
s = sentence.split(' ') # 띄어쓰기 단위로 토큰화
s
```#결과#```
['I', 'am', 'dying', 'to', 'play', 'the', 'game']
````````````

- 이 예시처럼, 영어는 대부분 띄어쓰기만으로도 '단어' 단위로 토큰화가 가능하다. 

cf) 반면, 한국어는 '문장 성분의 최소 단위로서 띄어쓰기의 단위가 되는 "어절"'이 있기 때문에 띄어쓰기 토큰화 시, 영어와 달리 '어절 토큰화'가 수행된다.

sentence = '그는 책을 읽는다.' 
s = sentence.split(' ') # 한글 띄어쓰기 단위로 토큰화 = 어절 토큰화
s
```#결과#```
['그는', '책을', '읽는다.']
````````````

■ 예시 문장을 unigram, bigram, trigram tokenization한 결과는 다음과 같다.

[s[i:i+1] for i in range(len(s))] # uni-gram
```#결과#```
[['I'], ['am'], ['dying'], ['to'], ['play'], ['the'], ['game']]
````````````

def generate_ngrams(text, n):
    ngrams = []
    for i in range(len(text) - n + 1):
        ngram = [' '.join(text[i:i+n])]
        ngrams.append(ngram)
    return ngrams

## bi-gram
print(generate_ngrams(s, 2))
```#결과#```
[['I am'], ['am dying'], ['dying to'], ['to play'], ['play the'], ['the game']]
````````````

## tri-gram
print(generate_ngrams(s, 3))
```#결과#```
[['I am dying'], ['am dying to'], ['dying to play'], ['to play the'], ['play the game']]
 ````````````

- 이렇게 bigram과 trigram을 통해 'I am'이나 'am dying to', 'to play', 'the game'같은 영어에서 연속적으로 사용되는 용어(또는 문법)을 찾아낼 수 있다.

■ n-gram 토큰화에서 uni-gram, bi-gram, tri-gram의 토큰들을 함께 사용하면 uni-gram만 사용할 때보다 더 좋은 결과를 얻을 수 있다. 다만, 대부분 의미가 없거나 잘 사용되지 않는 서로 다른 토큰이 많이 생기므로 단어 집합(사전)의 크기가 과하게 커질 수 있다는 단점이 있다.

- 특히, 한글은 같은 의미를 나타내는 단어라도 어미가 매우 다양하기 때문에, 의미는 동일하지만 서로 다른 토큰들이 많이 생성될 수 있다.

■ n-gram의 장점을 챙기면서 의미가 있는 토큰들만 사용하는 방법이 바로 Byte Pair Encoding(BPE)이다.


1.2 BPE(Byte Pair Encoding)

■ BPE는 데이터 압축(data compression) 분야에서 사용됐던 알고리즘으로, 반복적으로 나오는 데이터의 연속된 패턴을 치환하는 방식을 사용하여 데이터를 저장한다. 

■ 예를 들어 다음과 같은 문자열이 주어졌을 때, BPE를 수행한다면

abbcabcab

- 먼저, 연속적으로 가장 많이 등장한 바이트(byte)의 쌍은 'ab'이다. 'ab'를 'X'로 치환하면, 'abbcabcab'는 다음과 같이 ' XbcXcX '가 된다.

abbcabcab

X = ab
=> XbcXcX

- 위 문자열 중에서 가장 많이 등장한 바이트의 쌍은 'cX'이다. 'cX'를 'Y'로 치환하면, 

abbcabcab

X = ab
=> XbcXcX

XbcXcX

Y = cX
=> XbYY

- 연속적으로 가장 많이 등장한 글자(또는 바이트)를 'X = ab'와 'Y = cX' 순으로 단계적으로 치환하여 문자열 'abbcabcab'에서 'XbYY'이 되었다.

- 'XbYY'에서 더 이상 반복적으로 나오는, 치환할 바이트의 쌍이 없으므로 BPE 최종 결과는 'XbYY'이다.

- BPE 최종 결과, 문자열 'abbcabcab'에 대한 초기 단어 집합(사전)은 "a, b, c, d"로, 초기 단어 집합의 크기는 4였다.

- BPE 수행 후, 단어 집합에  'X'와 'Y'라는 글자가 추가되어 단어 집합의 크기는 6으로 늘어난다. 그러나 데이터의 길이는 9에서 4로 줄었다. 이렇게 BPE는 단어 집합의 크기를 과하게 늘리지 않으면서 데이터의 길이를 압축한다.

■ 이 개념을 토큰화에 적용한 논문이 arXiv:1508.07909v5 [cs.CL] 10 Jun 2016 이며, 다음 코드는 논문 속의 코드를 그대로 가져온 것이다.

import re, collections

def get_stats(vocab):
    pairs = collections.defaultdict(int)
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pairs[symbols[i], symbols[i+1]] += freq
    print(f'현재 pair들의 빈도수: {dict(pairs)}')
    return pairs

def merge_vocab(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
    
num_merges = 10

vocab = {'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} # (1)

bpe = {} # bpe 과정 기록

for i in range(num_merges): # (4)
    print(f'Iteration {i + 1}')
    pairs = get_stats(vocab) # (2)
    best = max(pairs, key=pairs.get) # (2) 
    vocab = merge_vocab(best, vocab) # (3)
	
    bpe[best] = i # bpe 과정 기록
    
    print(f'merge: {best}')
    print(f'dictionary: {vocab}')
    print()

■ 고리즘을 살펴보면,

- (1) 각 단어들의 빈도수가 기록된 단어 집합을 만든다. 이때, 사전의 모든 단어들을 글자(chracter) 단위로 띄어 표현한다.

-- vocab = {'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}이 (1) 단계에 대한 내용이다.

- (2) 각 단어에 대해 연속된 2개의 글자의 빈도수를 세어 가장 많이 나오는 글자 2개의 조합을 찾는다. 

또는 가장 많이 나오는 unigram의 쌍(pair)을 하나의 unigram으로 통합하는 것으로 이해할 수 있다.

-- pairs = get_stats(vocab)와 best = max(pairs, key=pairs.get)가 (2) 단계에 대한 내용이다.

- (3) 두 글자를 합쳐 기존 사전(vocabulary)의 단어를 수정한다.

-- vocab = merge_vocab(best, vocab)가 (3) 단계에 대한 내용이다.

- (4) 사용자가 미리 정해 놓은 횟수만큼 (2) ~ (3) 과정을 반복한다.

-- for i in range(num_merges): 부분이 (4) 단계에 대한 내용이다.

■ 실질적으로 BPE가 적용되는 곳은  merge_vocab이다. best라는 빈도수가 가장 높은 글자 쌍과 기존 단어 집합을 입력으로 넣으면, merge_vocab에서 두 글자를 병합하여 하나의 글자로 치환하는 작업이 수행된다. 그리고 치환(또는 병합)이 되었다면, 해당 글자로 단어 집합의 key를 갱신한다.

```#결과#```
Iteration 1
현재 pair들의 빈도수: {('l', 'o'): 7, ('o', 'w'): 7, ('w', '</w>'): 5, ('w', 'e'): 8, ('e', 'r'): 2, ('r', '</w>'): 2, ('n', 'e'): 6, ('e', 'w'): 6, ('e', 's'): 9, ('s', 't'): 9, ('t', '</w>'): 9, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'e'): 3}
merge: ('e', 's')
dictionary: {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3}

Iteration 2
현재 pair들의 빈도수: {('l', 'o'): 7, ('o', 'w'): 7, ('w', '</w>'): 5, ('w', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('n', 'e'): 6, ('e', 'w'): 6, ('w', 'es'): 6, ('es', 't'): 9, ('t', '</w>'): 9, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'es'): 3}
merge: ('es', 't')
dictionary: {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est </w>': 6, 'w i d est </w>': 3}

Iteration 3
현재 pair들의 빈도수: {('l', 'o'): 7, ('o', 'w'): 7, ('w', '</w>'): 5, ('w', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('n', 'e'): 6, ('e', 'w'): 6, ('w', 'est'): 6, ('est', '</w>'): 9, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'est'): 3}
merge: ('est', '</w>')
dictionary: {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}

Iteration 4
현재 pair들의 빈도수: {('l', 'o'): 7, ('o', 'w'): 7, ('w', '</w>'): 5, ('w', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('n', 'e'): 6, ('e', 'w'): 6, ('w', 'est</w>'): 6, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'est</w>'): 3}
merge: ('l', 'o')
dictionary: {'lo w </w>': 5, 'lo w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}

Iteration 5
현재 pair들의 빈도수: {('lo', 'w'): 7, ('w', '</w>'): 5, ('w', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('n', 'e'): 6, ('e', 'w'): 6, ('w', 'est</w>'): 6, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'est</w>'): 3}
merge: ('lo', 'w')
dictionary: {'low </w>': 5, 'low e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}

Iteration 6
현재 pair들의 빈도수: {('low', '</w>'): 5, ('low', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('n', 'e'): 6, ('e', 'w'): 6, ('w', 'est</w>'): 6, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'est</w>'): 3}
merge: ('n', 'e')
dictionary: {'low </w>': 5, 'low e r </w>': 2, 'ne w est</w>': 6, 'w i d est</w>': 3}

Iteration 7
현재 pair들의 빈도수: {('low', '</w>'): 5, ('low', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('ne', 'w'): 6, ('w', 'est</w>'): 6, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'est</w>'): 3}
merge: ('ne', 'w')
dictionary: {'low </w>': 5, 'low e r </w>': 2, 'new est</w>': 6, 'w i d est</w>': 3}

Iteration 8
현재 pair들의 빈도수: {('low', '</w>'): 5, ('low', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('new', 'est</w>'): 6, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'est</w>'): 3}
merge: ('new', 'est</w>')
dictionary: {'low </w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'w i d est</w>': 3}

Iteration 9
현재 pair들의 빈도수: {('low', '</w>'): 5, ('low', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'est</w>'): 3}
merge: ('low', '</w>')
dictionary: {'low</w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'w i d est</w>': 3}

Iteration 10
현재 pair들의 빈도수: {('low', 'e'): 2, ('e', 'r'): 2, ('r', '</w>'): 2, ('w', 'i'): 3, ('i', 'd'): 3, ('d', 'est</w>'): 3}
merge: ('w', 'i')
dictionary: {'low</w>': 5, 'low e r </w>': 2, 'newest</w>': 6, 'wi d est</w>': 3}
````````````

위와 같은 결과가 나온 이유는 다음과 같다.

{low : 5, lower : 2, newest : 6, widest : 3} 라는 딕셔너리에 BPE를 적용하기 위해 모든 단어들을 글자 단위로 분리한다. 그렇다면, 딕셔너리를 {l o w : 5, l o w e r : 2, n e w e s t : 6, w i d e s t : 3}의 형태가 된다.

이 딕셔너리를 통해 만든 초기 단어 집합(vocab)은 "l, o, w, e, r, n, s, t, i, d"으로 글자 단위로 분리된 상태가 될 것이다.

 이때, BPE 알고리즘을 몇 번 반복할지 사용자가 정할 수 있다. 이 예에서는 10회를 수행한다. 즉, 가장 빈도수가 높은 uni-gram 쌍을 하나의 유니그램으로 병합하는 과정을 총 10회 수행한다.

이 예시에서 1회에 딕셔너리에 BPE를 적용했을 때, 가장 빈도수가 높은 uni-gram 쌍은 (e, s)였다. 그래서 (e, s) 쌍을 es로 병합한 다음, 딕셔너리와 단어 집합을 업데이트하면 

- 딕셔너리는 {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3}로, 

- 단어 집합은 새로운 단어 'es'를 추가하여 "l, o, w, e, r, n, s, t, i, d, es"로 업데이트된다.

■ 2회에서 업데이트된 딕셔너리에 BPE를 적용했을 때, 빈도수가 가장 높은 uni-gram 쌍은 (es, t)였다. 그래서 이 쌍을 est로 병합한 다음, 딕셔러니와 단어 집합을 업데이트하면

- 딕셔너리는 {'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w est </w>': 6, 'w i d est </w>': 3}로,

- 단어 집합은 새로운 단어 'est'를 추가하여 "l, o, w, e, r, n, s, t, i, d, es, est"로 업데이트된다.

■ 이와 같은 방식으로 4회에서 빈도수가 가장 높은 uni-gram 쌍은 (l, o)이므로, 딕셔너리는 {'lo w </w>': 5, 'lo w e r </w>': 2, 'n e w est</w>': 6, 'w i d est</w>': 3}로, 그리고 단어 집합에는 새로운 단어인 'lo'가 추가된다. 

■ '</w>'가 없다고 가정한다면 총 10회 반복했을 때,

- 10회 동안 업데이트된 딕셔너리는 {'low': 5, 'low e r ': 2, 'newest': 6, 'wi d est': 3}이며,

- 10회 동안 업데이트된 단어 집합에는 초기 단어 집합에 'es', 'est', 'lo', 'low', 'ne', 'new', 'newest', 'wi', 'wid', 'widest'의 새로운 10개 단어가 추가된다.

bpe
```#결과#```
{('e', 's'): 0,
 ('es', 't'): 1,
 ('est', '</w>'): 2,
 ('l', 'o'): 3,
 ('lo', 'w'): 4,
 ('n', 'e'): 5,
 ('ne', 'w'): 6,
 ('new', 'est</w>'): 7,
 ('low', '</w>'): 8,
 ('w', 'i'): 9}
````````````

■ 예를 들어 새로운 단어로 'lowest'가 등장했다면, 기존 방법은 단어 집합에 'lowest'는 없는 단어. 즉, OOV 단어이기 때문에 <unk> 토큰으로 처리되었을 것이다.

하지만, BPE 알고리즘을 사용하면, 'lowest'라는 단어가 글자 단위 'l o w e s t'로 분해되어도 'low'와 'est'가 단어 집합에 있기 때문에 'lowest'라는 새로운 단어를 이 두 단어로 인코딩할 수 있다. 
■ 그리고, 이 예시의 경우 문장 1개에 대해 적용했기 때문에 10회 반복을 통해 얻은 단어 집합(사전)이 완벽한 단어 집합이라고 보기는 어렵다. 더 큰 말뭉치 데이터에서 적절한 반복 횟수를 지정하여 단어 집합을 만들면 더 적합한 단어 집합을 만들 수 있다.

https://hyeon-jae.tistory.com/251

■ BPE 외에도 BPE를 참고하여 만들어진 WordPiece Tokenizer나 Unigram (Language Model) Tokenizer와 같은 서브워드 분리 알고리즘이 존재한다.


 

참고) Byte Pair Encoding - ratsgo's NLPBOOK

 

Byte Pair Encoding

pratical tips for Natural Language Processing

ratsgo.github.io

 

이어서 Subword Tokenizer - <1> Byte Pair Encoding(BPE), WordPiece, Unigram (2)