2. WordPiece Tokenizer
■ WordPiece는 Google이 BERT를 사전 학습하기 위해 개발한 토큰화 알고리즘이며, 이것은 BPE의 변형 알고리즘이다. 학습 측면에서 BPE와 매우 유사하지만 실제 토큰화는 다르게 수행된다.
■ BPE가 빈도수에 기반하여 가장 많이 등장한 쌍을 병합했다면, 이 알고리즘은 병합되었을 때, 말뭉치의 우도(likelihood)를 가장 높이는 쌍을 병합한다.
- '말뭉치의 우도를 가장 높이는 쌍'이란 말뭉치의 전체 확률 분포에 더 큰 기여를 하는 하위 단어의 쌍. 즉, 말뭉치에 함께 자주 등장하는 하위 단어 쌍을 의미
- 즉, BEP는 빈도수 기반으로 쌍을 병합하며, WordPiece는 말뭉치에서 자주 나타나는 쌍을 병합한다.
2.1 WordPiece 알고리즘 과정
■ WordPiece 알고리즘의 과정은
- ① 초기. 모든 단어의 글자(=문자(character))로 시작한다.
- ② 가능한 모든 글자(문자) 쌍의 빈도를 계산한다.
- ③ 말뭉치(ex) 훈련 데이터)의 가능도(likelihood)를 최대화하는 글자 쌍을 찾는다.
- ④ 해당 글자 쌍을 병합하여 새로운 단어(토큰) 항목을 만든다.
- ⑤ 모든 병합을 모두 학습할 때까지 이 과정을 반복한다.
2.1.1 텍스트 전처리 및 단어 빈도 계산
■ 먼저, BEP와 마찬가지로 WordPiece는 모델에서 사용하는 특수 토큰과 초기 알파벳을 포함한 작은 단어 집합(사전)에서 시작한다.
■ BERT에서 사용하는 Tokenizer는 글자(character) 단위로 분할할 때, 접미사 ##를 추가하여 하위 단어(subword)를 식별한다. 예를 들어, 'Backward(뒤로)', 'Colorful(다채로운)'이라는 단어는 ['Back', '##ward'], ['Color', '##ful']로 분할된다.
'-ward'는 '어떤 방향으로'라는 의미의 접미사, '-ful'은 '어떤 것이 가득 찬 상태'를 나타내는 의미의 접미사
- 여기서 ## 기호는 '각 문자가 처음 시작 문자가 아니라는 것' 또는 '앞에 띄어쓰기가 아닌 바로 이어지는 토큰'으로 볼 수도 있다.
■ 그리고, 사람 이름이나 회사명 같은 명사에서도 하위 단어를 식별한다. 예를 들어, Hugging이라는 단어를 학습하지 않았어도, subword 'Hu'와 subword '##gging'로 표현하는 것을 볼 수 있다.
■ BPE에서 초기 딕셔너리를 dictionary={'l o w ':5, 'l o w e r ':2,'n e w e s t':6,'w i d e s t':3}으로 나타냈다면,
■ WordPiece에서 초기 딕셔너리의 형태는 dictionary={l ##o ##w :5,l ##o ##w ##e ##r :2,n ##e ##w ##e ##s ##t::6,w ##i ##d ##e ##s ##t:3}이므로, 단어 집합 또는 사전이라 부르는 vocabulary의 초기 형태는 [l, ##0, ##w, ##e, ##r, n, w, ##w, ##s, ##t, ##l, ##d]가 된다.
■ 예를 들어, 다음과 같은 텍스트 데이터가 있다고 했을 때, WordPiece Tokenizer를 직접 구현해 보자.
sample = "low low low low low lower lower newest newest newest newest newest newest widest widest widest"
■ 먼저, 말뭉치가 있으면 이를 단어로 사전 토큰화(pre-tokenization)해야 한다. Hugging Face에서 제공하는 transformers 라이브러리에서 BERT Tokenizer의 사전 토큰화 기능을 제공하고 있다.
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
print(len(tokenizer.vocab))
```#결과#```
28996
````````````
tokenizer.vocab
```#결과#```
{'##ven': 7912,
'##ǐ': 28267,
'recognition': 4453,
'1987': 2164,
'食': 1084,
...,
...,
'Von': 13610,
'coral': 16189,
...}
````````````
또는
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-cased")
print(len(tokenizer.vocab))
```#결과#```
28996
````````````
tokenizer.vocab
```#결과#```
OrderedDict([('[PAD]', 0),
('[unused1]', 1),
('[unused2]', 2),
...,
('口', 997),
('史', 998),
('司', 999),
...])
````````````
- 사전 토큰화에 'bert-base-cased'라는 이름의 이미 학습된 모델을 사용한다. 이때, 해당 모델을 사용하려면 모델 학습을 위해 사용했던 Tokenizer도 일치시켜야 한다. 그래서 위와 같이 미리 학습해둔 Tokenizer(AutoTokenizer, BertTokenizer, ... 등)를 가져와 사용한다. 이렇게 미리 학습해둔 Tokenizer를 Pre-Trained Tokenizer라고 부른다.
- vocab을 통해 해당 Tokenizer의 크기를 확인해볼 수 있다.
참고)
- 불러온 tokenizer로 텍스트를 토큰화하는 방법은 다음과 같이 불러온 tokenizer의 tokenize 함수에 텍스트를 넣으면 된다.
print(tokenizer.tokenize('Backward'))
```#결과#```
['Back', '##ward']
````````````
print(tokenizer.tokenize('Colorful'))
```#결과#```
['Color', '##ful']
````````````
print(tokenizer.tokenize('Jhon'))
```#결과#```
['J', '##hon']
````````````
sample_1 = "This is the Hugging Face course. This chapter is about tokenization."
print(tokenizer.tokenize(sample_1))
```#결과#```
['This', 'is', 'the', 'Hu', '##gging', 'Face', 'course', '.', 'This', 'chapter', 'is',
'about', 'token', '##ization', '.']
````````````
sample_2 = "이것은 Hugging Face 과정입니다. 이 장은 토큰화에 관한 것입니다."
print(tokenizer.tokenize(sample_2))
```#결과#```
['[UNK]', 'Hu', '##gging', 'Face', '[UNK]', '.', '이', '[UNK]', '[UNK]', '[UNK]', '[UNK]', '.']
````````````
- 위의 tokenizer는 영어는 제대로 인식하지만, 한글에 대해서는 '[unk]' 토큰으로 처리하는 것을 볼 수 있다.
- 다양한 언어를 담고 있는 학습한 모델로 다음과 같은 'bert-base-multilingual-uncased'가 있다.
tokenizer2 = BertTokenizer.from_pretrained("bert-base-multilingual-uncased")
print(len(tokenizer2.vocab))
```#결과#```
105879
````````````
- 'bert-base-multilingual-uncased'의 Tokenizer로 같은 문장을 토큰화하면, 위의 bert-base-cased 모델과 달리, 'tokenization'이라는 단어가 다음과 같이 'tok'와 '##ein', '##zation'으로 나뉜다.
- 이렇게 학습한 데이터(tokenizer에 있는 vocab)에 따라 토큰화 결과가 달라지는 것을 확인할 수 있다.
print(tokenizer2.tokenize(sample_1))
```#결과#```
['this', 'is', 'the', 'hu', '##gging', 'face', 'course', '.', 'this', 'chapter', 'is',
'about', 'tok', '##eni', '##zation', '.']
````````````
print(tokenizer2.tokenize(sample_2))
```#결과#```
['이것은', 'hu', '##gging', 'face', '과', '##정이', '##ᆸ니다', '.', '이', 'ᄌ', '##ᅡᆼ',
'##은', 'ᄐ', '##ᅩ', '##크', '##ᆫ', '##화', '##에', '관한', '것이', '##ᆸ니다', '.']
````````````
- multilingual model인 'bert-base-multilingual-uncased'에는 한국어 데이터도 포함돼 있어서, 영어와 한국어가 혼합된 상태에 대해서도 '[unk]'토큰으로 처리되지 않고, 결과가 나온 것을 볼 수 있다.
- 단, 이러한 multilingual model은 다양한 언어에 대한 Tokenizer임에도, vocab의 개수를 확인하면 특수 토큰을 포함하여 105,879개로 크기가 제한적인 단어 집합(vocabulary)임을 알 수 있다.
- 즉, 이러한 vocabulary 크기 제약하에서도 최적의 토큰을 구하기 위해서는 토큰이 짧아질 수밖에 없어 위의tokenizer2.tokenize(sample_2) 결과와 같이 많이 잘리는 듯한 결과를 보여준다.
■ 다시 2.1.1로 돌아와서, 말뭉치 샘플에 있는 각 단어 빈도수를 계산한다.
# 단어 빈도수 계산
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
print(word_freqs)
```#결과#```
defaultdict(<class 'int'>, {'low': 5, 'lower': 2, 'newest': 6, 'widest': 3})
````````````
2.1.2 단어 집합(사전) vocabulary 생성
■ WordPiece 알고리즘은 BPE 알고리즘과 마찬가지로, 다음과 같은 단일 문자(= 글자 단위)로 구성된 단어 집합(사전)에서 시작한다.
- 이 단계에서 BPE와 차이점이 있다면, '##'를 사용하는 것
# 초기 단어 집합(vocabulary) 생성
vocabulary = []
for word in word_freqs.keys(): # word는 word_freqs의 'key'. 즉, word = 'low', 'lower', 'newest', 'widest'
if word[0] not in vocabulary: # word[0]은 word의 첫 글자인 l, l, n, w
vocabulary.append(word[0])
for letter in word[1:]:
if f"##{letter}" not in vocabulary:
vocabulary.append(f"##{letter}")
print("Number of vocabulary:", len(vocabulary))
print("Initial vocabulary:", vocabulary)
```#결과#```
Number of vocabulary: 11
Initial vocabulary: ['l', '##o', '##w', '##e', '##r', 'n', '##s', '##t', 'w', '##i', '##d']
````````````
- 이 vocabulary가 초기 단어 집합(사전)이다.
■ 참고로, BERT의 단어 집합에서 사용하는 특수 토큰 시작 부분에 모델이 사용하는 특수 토큰("[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]")을 추가한다면, 이때의 초기 단어 집합은 다음과 같다.
BERT_vocab = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"] + vocabulary.copy()
BERT_vocab
```#결과#```
['[PAD]',
'[UNK]',
'[CLS]',
'[SEP]',
'[MASK]',
'l',
'##o',
'##w',
'##e',
'##r',
'n',
'##s',
'##t',
'w',
'##i',
'##d']
````````````
■ 다음으로, 단어 집합(vocabulary)에 존재하는 '##이 아닌 문자(글자)'를 이용하여 각 단어를 다음과 같이 subword 토큰 단위로 분할한다.
## index == 0이면, 단어의 '첫 번째 글자'
splits = {
word: [c if i == 0 else f"##{c}" for i, c in enumerate(word)]
for word in word_freqs.keys()
}
splits
```#결과#```
{'low': ['l', '##o', '##w'],
'lower': ['l', '##o', '##w', '##e', '##r'],
'newest': ['n', '##e', '##w', '##e', '##s', '##t'],
'widest': ['w', '##i', '##d', '##e', '##s', '##t']}
````````````
2.1.3 점수(score) 계산 및 병합
■ BPE와 마찬가지로 WordPeice도 병합 규칙을 학습한다. 주요 차이점은 병합할 쌍이 선택되는 방식이다.
■ 단, BPE처럼 가장 빈번하게 출현하는 문자(글자) 쌍을 선택(조합)하는 방법 대신, WordPiece는 다음 공식을 사용하여 각 토큰 쌍에 대한 점수를 계산해서 토큰 쌍을 선택한다.
■ 이 식은 두 토큰 쌍이 묶여서 어휘로 쓰일 때의 점수를 계산하기 위해 사용된다.
■ 분자는 두 토큰 쌍이 함께 등장한 횟수(쌍의 빈도수)이며, 분모는 두 토큰이 각각 개별적으로(독립적으로) 등장한 빈도수의 곱이다.
■ 두 토큰 쌍이 함께 등장한 횟수를 각 부분의 곱(첫 번째 토큰의 빈도수와 두 번째 토큰의 빈도수의 곱)으로 나눔으로써, 두 토큰이 각각 독립적으로 등장하는 것보다 함께 등장할 가능성이 높은지 계산한다.
■ 그리고 계산 결과 토큰 쌍의 점수 중 높은 점수를 가지는 쌍이 단어 집합(vocabulary)에 새로운 어휘로 등록된다.
■ 위의 수식을 통해 계산된 두 토큰 쌍의 점수가 높은 점수라면, 이는 해당 쌍이 독립적으로 나타나는 것보다 함께 나타날 가능성이 더 높다는 것을 의미한다.
- 계산된 점수는 다음과 같이 크게 3가지 경우로 분류할 수 있다.
- (1) 두 토큰이 항상 함께 나타난다면, 분자와 분모가 거의 같은 값을 가지므로 점수는 1에 가까워진다.
- (2) 두 토큰이 '우연히 함께 나타나는' 것이 아니라 '더 자주 함께 나타나는' 경우라면, 점수는 1보다 커진다.
- (3) 두 토큰이 거의 함께 나타나지 않는다면, 점수는 1보다 작아진다.
■ 이러한 점수 계산 방식은 WordPiece 알고리즘의 핵심이다. 단순한 빈도 기반 방식보다 더 정교하게 의미 있는 하위 단어(subword) 단위를 식별할 수 있다. 단순히 빈도가 높은 쌍보다는 상대적으로 함께 자주 나타나는 쌍에 높은 점수를 부여하기 때문이다.
■ 이와 같은 점수를 계산하는 함수를 정의하면 다음과 같다. 이 함수를 통해 각 토큰 쌍의 점수를 계산한다. 그리고 학습의 각 단계에서 이 함수를 사용하여 단어 집합(vocabulary)을 업데이트한다.
def compute_pair_scores(splits):
letter_freqs = defaultdict(int)
pair_freqs = defaultdict(int)
for word, freq in word_freqs.items():
split = splits[word] # word = 'low', 'lower', 'newest', 'widest'
if len(split) == 1: # split = ['l', '##o', '##w'], ['l', '##o', '##w', '##e', '##r'], ['n', '##e', '##w', '##e', '##s', '##t'], ['w', '##i', '##d', '##e', '##s', '##t']
letter_freqs[split[0]] += freq
continue
for i in range(len(split) - 1): # if split = ['l', '##o', '##w'] -> for i in range(2) -> i = 0, 1 <=> 글자 조합 2개: l##o, ##o##w
pair = (split[i], split[i + 1])
letter_freqs[split[i]] += freq # l:5, ##o:5, ##w:5
pair_freqs[pair] += freq # ('l', '##o'): 5, ('##o', '##w'): 5
letter_freqs[split[-1]] += freq
print(f'pair_freqs: {pair_freqs}');print()
print(f'letter_freqs: {letter_freqs}');print()
## 점수 계산
scores = {
pair: freq / (letter_freqs[pair[0]] * letter_freqs[pair[1]])
for pair, freq in pair_freqs.items()
}
return scores
pair_scores = compute_pair_scores(splits)
```#결과#```
pair_freqs: defaultdict(<class 'int'>, {('l', '##o'): 7, ('##o', '##w'): 7, ('##w', '##e'): 8, ('##e', '##r'): 2, ('n', '##e'): 6, ('##e', '##w'): 6, ('##e', '##s'): 9, ('##s', '##t'): 9, ('w', '##i'): 3, ('##i', '##d'): 3, ('##d', '##e'): 3})
letter_freqs: defaultdict(<class 'int'>, {'l': 7, '##o': 7, '##w': 13, '##e': 17, '##r': 2, 'n': 6, '##s': 9, '##t': 9, 'w': 3, '##i': 3, '##d': 3})
````````````
## 각 쌍의 score 점수 내림차순 정렬
best_pair_scores = sorted(pair_scores.items(), key = lambda item:item[1], reverse = True)
# item[1]은 딕셔너리의 value
## 가장 높은 점수를 기록한 토큰 쌍과 그 점수를 확인
best_pair = best_pair_scores[0][0]
best_score = best_pair_scores[0][1]
print(best_pair); print(best_score)
```#결과#```
('w', '##i')
0.3333333333333333
````````````
for key, value in best_pair_scores:
print(key,':', value)
```#결과#```
('w', '##i') : 0.3333333333333333
('##i', '##d') : 0.3333333333333333
('l', '##o') : 0.14285714285714285
('##s', '##t') : 0.1111111111111111
('##o', '##w') : 0.07692307692307693
('##e', '##r') : 0.058823529411764705
('n', '##e') : 0.058823529411764705
('##e', '##s') : 0.058823529411764705
('##d', '##e') : 0.058823529411764705
('##w', '##e') : 0.03619909502262444
('##e', '##w') : 0.027149321266968326
````````````
■ 위의 결과를 확인하면, 토큰 쌍 중에 ('w', '##i')와 ('##i', '##d')의 점수가 가장 높은 것을 확인할 수 있다. 이는 이 쌍들이 다른 쌍들보다 더 자주 말뭉치에 등장한다는 것을 의미한다.
■ 이제, 둘 중 가장 첫 번째인 ('w', '##i') 쌍을 병합하자. 각 토큰의 병합을 새로운 단어로서 단어 집합에 등록한다.
- 이렇게 동일한 점수를 기록하는 경우, 가장 첫 번째에 있는 쌍부터 병합을 수행하면 된다.
- 이 예시의 경우 첫 번째 병합은 ('w', '##i') 쌍이다. 그러므로 새로운 단어로서 'w##i'를 단어 집합(vocabulary)에 추가한다.
vocabulary.append("w##i")
vocabulary
```#결과#```
['l', '##o', '##w', '##e', '##r', 'n', '##s', '##t', 'w', '##i', '##d', 'w##i']
`````````````
■ 다음 코드는 가장 높은 점수를 받은 토큰 쌍을 병합하는 함수이다. 이 함수는 주어진 토큰 쌍 (a, b)을 모든 분할된 단어에서 찾아서 병합한다.
def merge_pair(a, b, splits):
for word in word_freqs:
split = splits[word]
if len(split) == 1:
continue
i = 0
while i < len(split) - 1:
if split[i] == a and split[i + 1] == b:
merge = a + b[2:] if b.startswith("##") else a + b
split = split[:i] + [merge] + split[i + 2 :]
else:
i += 1
splits[word] = split
return splits
splits = merge_pair("w", "##i", splits)
splits
```#결과#```
{'low': ['l', '##o', '##w'],
'lower': ['l', '##o', '##w', '##e', '##r'],
'newest': ['n', '##e', '##w', '##e', '##s', '##t'],
'widest': ['wi', '##d', '##e', '##s', '##t']}
````````````
■ 이렇게 토큰 쌍의 점수를 계산해서 점수가 가장 높은 것을 병합하여 단어 집합(vocabulary)에 추가하는 방법이 바로 WordPiece 기반의 토큰화(Tokenization)이다.
■ 또 다른 예로, 특수 토큰을 제외하고 사전 토큰화(pre-tokenize)를 실행한 후, 코퍼스에 포함된 단어들이 다음과 같다고 하자.
Corpus(word, frequency): ("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
■ 단어들을 분할한 결과가 다음과 같을 때,
("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12),
("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5)
■ 고유한 글자를 모아서 초기 단어 집합(vocabulary)를 구성하면 다음과 같다.
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u"]
■ 이제, 위의 score 식을 통해 계산된 글자(토큰) 쌍의 점수를 기준으로 병합 순서를 결정한다.
- 이 예에서 현재 가장 빈번한 쌍은 20회 등장하는 ("##u" "##g")이다. 단, "##u"는 모든 단어에 포함되어 있어 개별 빈도가 매우 높다.
- \( \text{score}(a, b) = \dfrac{ \text{freq} (a, b)}{ \text{freq} (a) \text{freq} (b) } \)일 때, \( \text{score} (\#\#u, \#\#g) \)는 \( \text{score} (\#\#u, \#\#g) = \dfrac{20}{20 \times 36} = \dfrac{1}{36} \)이다.
- 다른 쌍에서도 동일한 계산 방법으로 점수를 계산하면 ("##g" "##s") 쌍이 1/20으로 가장 높은 점수를 가지고 있는 것을 확인할 수 있다.
- 그러므로 첫 번째 병합은 ("##g" "##s") -> ("##gs")이다.
■ 이렇게 각 토큰 쌍에 대한 점수를 계산한 다음, 가장 높은 점수를 기록한 토큰 쌍이 병합 대상이 된다.
■ 병합할 대상을 정했다면, 단어 집합의 새로운 어휘로 병합된 어휘를 추가하고, 말뭉치의 모든 단어에 해당 병합을 적용한다. 이 예에서 첫 번째 병합은 ("##gs")이므로 단어 집합은 다음과 같이 업데이트된다.
- Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u""] \( \rightarrow \) Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
- Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5) \( \rightarrow \) Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)
- 위의 병합 내용을 적용한 결과를 정리하면 다음과 같다.
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12),
("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)
■ 이제, 다음 병합을 위해 다시 각 토큰 쌍의 점수를 계산한다.
- 현재 상태에서는 모든 토큰 쌍에 "##u"가 들어간다.
-- ("h", "##u"), ("##u", "##g"), ("p", "##u"), ("##u", "##n"), ("b", "##u"), ("##u", "##gs")
- 이때, 모든 토큰 쌍에 대한 점수가 동일하다. 이러한 경우에는 가장 첫 번째에 위치한 토큰 쌍을 병합한다.
- 그러므로 두 번째 병합은 ("h", "##u") \( \rightarrow \) "hu"이다.
■ 마찬가지로 병합 내용을 단어 집합과 말뭉치에 적용하면 된다.
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu"]
Corpus: ("hu" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12),
("b" "##u" "##n", 4), ("hu" "##gs", 5)
■ 세 번째 병합을 위해 다시 말뭉치에 있는 모든 토큰 쌍들의 점수를 계산하면,
- 최고 점수는 ("hu", "##g") 및 ("hu", "##gs")가 1/15로 동일한 값을 가지며 나머지 토큰 쌍들의 점수는 1/21이다.
- 이러한 경우, 첫 번째 쌍인 ("hu", "##g"를 병합한다.
■ 세 번째 병합 내용을 단어 집합과 말뭉치에 적용하면 다음과 같다.
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
Corpus: ("hug", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12),
("b" "##u" "##n", 4), ("hu" "##gs", 5)
■ 이러한 병합 단계는 사용자가 원하는 단어 집합(vocabulary) 크기에 도달할 때까지 반복된다.
■ 위와 같은 학습 결과(병합 결과)를 통해 최종 단어 집합이 완성되었다고 하자.
■ 만약, 최종 단어 집합이 완성된 상태에서 기존 말뭉치에 존재했던 텍스트를 만나거나, 새로운 텍스트를 만난다면, 어떻게 토큰화할까?
■ 예를 들어, 기존 말뭉치에는 존재했던 "hugs"라는 단어를 만났다고 하자.
■ WordPiece는 시작 부분을 기준으로 가장 긴 하위 단어(subword)가 단어 집합(vocabulary)에 있는지 확인한다.
- "hugs"의 경우, 위의 단어 집합을 보면 시작 부분 "h"를 기준으로 가장 긴 하위 단어는 "hug"임을 알 수 있다.
- 그러므로 "hugs"는 "hug"를 기준으로 분할하여 ["hug", "##s"]로 분할된다. 이때, "##s"가 단어 집합에 존재하고 이를 더 이상 분할할 수 없으므로 "hugs"의 토큰화 결과는 ["hug", "##s"]이다.
■ 이번에는 기존 말뭉치에 존재하지 않았던 "bugs"라는 단어를 만났다고 가정하자.
■ 마찬가지로 단어 집합에서 시작 부분 "b"를 가장 긴 하위 단어를 단어 집합 내부에서 찾는다.
- 단어 집합을 확인해 보면, "b"로 시작하는 가장 긴 하위 단어는 "b"밖에 없음을 알 수 있다.
- 그러므로, "bugs"는 "b"를 기준으로 분할한다. 그 결과, ["b", "##ugs"]라는 중간 결과가 도출된다. ["b", "##ugs"]가 중간 결과인 이유는 "##ugs"는 계속 분할할 수 있기 때문이다.
- 즉, 이제 "##ugs"에 대해서 분할을 해야 한다. 단어 집합 내부에 시작 부분 "u"로 시작하는 가장 긴 하위 단어는 "u"이다.
- 그러므로 "##ugs"를 "u"로 분할하여 ["b", "##u, "##gs"]라는 결과를 얻는다.
- 이제 "##gs"에 대해 분할해야 한다. 이때, "##gs"가 vocabulary에 있으므로 ["b", "##u, "##gs"]이 "bugs"의 토큰화 결과가 된다.
■ 추가로 고려해야할 경우는 새로운 단어를 만났을 때, 해당 단어를 구성하는 글자가 아예 단어 집합에 없는 경우이다. 예를 들어, 기존 말뭉치에 존재하지 않았던 "mugs"라는 단어를 토큰화해야 한다고 가정하자.
■ 단어 집합 내부에 시작 부분 "m"을 기준으로 가장 긴 하위 단어가 존재하는지 찾아야 하지만, 단어 집합에서 "m"으로 시작하는 하위 단어는 존재하지 않는다.
■ 이렇게, 단어 집합에서 하위 단어(subword)를 더 이상 찾을 수 없는 단계에 도달하면, 전체 단어를 "unknown"으로 토큰화한다. 즉, 이 예에서 "mugs"같은 경우는 특수 토큰인 "<UNK>"로 토큰화된다.
■ 시작 부분이 단어 집합에 없는 경우뿐만 아니라, 단어 "bum"같이 "b"와 "##u"로 시작할 수 있더라도 "##m"이 단어 집합에 존재하지 않는 경우. 이때의 토큰화는 ["b", "##u", "[UNK]"]가 아니라 ["[UNK]"]로 처리된다.
■ 이렇게 단어 집합에 없는 개별 문자에 대해 "unknwon"으로 분류하는 것이 WordPiece와 BPE와의 또 다른 차이점이다.
2.1.4 WordPiece 학습 루프
■ 이제 원하는 병합을 모두 학습할 때까지 반복하는데 필요한 함수들을 모두 구현하였다. 이 함수들을 이용하여 WordPiece의 학습 루프를 구현하면 다음과 같다.
vocab_size = 70
while len(vocabulary) < vocab_size:
scores = compute_pair_scores(splits)
best_pair, max_score = "", None
for pair, score in scores.items():
if max_score is None or max_score < score:
best_pair = pair
max_score = score
# Break if dictionary update is not possible
if best_pair == "":
break
else:
print(f"Best pair to update: {best_pair}")
splits = merge_pair(*best_pair, splits)
new_token = (
best_pair[0] + best_pair[1][2:]
if best_pair[1].startswith("##")
else best_pair[0] + best_pair[1]
)
vocabulary.append(new_token)
print(f"Current vocabulary: {vocabulary}")
print()
print(f"Final vocabulary: {vocabulary}")
```#결과#```
pair_freqs: defaultdict(<class 'int'>, {('l', '##o'): 7, ('##o', '##w'): 7, ('##w', '##e'): 8, ('##e', '##r'): 2, ('n', '##e'): 6, ('##e', '##w'): 6, ('##e', '##s'): 9, ('##s', '##t'): 9, ('wi', '##d'): 3, ('##d', '##e'): 3})
letter_freqs: defaultdict(<class 'int'>, {'l': 7, '##o': 7, '##w': 13, '##e': 17, '##r': 2, 'n': 6, '##s': 9, '##t': 9, 'wi': 3, '##d': 3})
Best pair to update: ('wi', '##d')
Current vocabulary: ['l', '##o', '##w', '##e', '##r', 'n', '##s', '##t', 'w', '##i', '##d', 'wid']
pair_freqs: defaultdict(<class 'int'>, {('l', '##o'): 7, ('##o', '##w'): 7, ('##w', '##e'): 8, ('##e', '##r'): 2, ('n', '##e'): 6, ('##e', '##w'): 6, ('##e', '##s'): 9, ('##s', '##t'): 9, ('wid', '##e'): 3})
letter_freqs: defaultdict(<class 'int'>, {'l': 7, '##o': 7, '##w': 13, '##e': 17, '##r': 2, 'n': 6, '##s': 9, '##t': 9, 'wid': 3})
Best pair to update: ('l', '##o')
Current vocabulary: ['l', '##o', '##w', '##e', '##r', 'n', '##s', '##t', 'w', '##i', '##d', 'wid', 'lo']
...,
...,
pair_freqs: defaultdict(<class 'int'>, {})
letter_freqs: defaultdict(<class 'int'>, {'low': 5, 'lower': 2, 'newest': 6, 'widest': 3})
Final vocabulary: ['l', '##o', '##w', '##e', '##r', 'n', '##s', '##t', 'w', '##i', '##d', 'wid', 'lo', '##st', 'low', '##er', 'lower', 'ne', 'new', 'newe', 'wide', 'newest', 'widest']
````````````
■ 위와 같은 while 루프는 다음과 같이 작동한다.
- 현재 분할에 대한 모든 인접 토큰 쌍의 점수를 계산한다.
- 가장 높은 점수를 받은 토큰 쌍을 찾은 다음, 해당 쌍을 병합한다.
- 토큰 쌍이 병합된 결과는 새로운 토큰으로서 기존 단어 집합에 추가된다.
- 사용자가 지정한 단어 집합의 크기에 도달하거나, 더 이상 병합할 수 있는 토큰 쌍이 없을 때까지 반복한다.
■ 위의 코드는 사용자가 지정한 단어 집합의 크기 = 70이 될 때까지 WordPiece 토큰화를 반복하는 코드이며, 루프 결과는 다음과 같다.
print(len(vocabulary))
print(vocabulary)
```#결과#```
23
['l', '##o', '##w', '##e', '##r', 'n', '##s', '##t', 'w', '##i', '##d', 'wid', 'lo', '##st', 'low', '##er', 'lower', 'ne', 'new', 'newe', 'wide', 'newest', 'widest']
````````````
- 목표 단어 집합의 크기를 70으로 하였으나, 더 이상 병합할 수 있는 토큰 쌍이 없어 루프는 종료되고, 최종 단어 집합에 등록된 토큰의 개수는 23개임을 확인할 수 있다.
■ 이 결과에 BERT에서 토큰화를 하는 것처럼, 단어 집합에 특수 토큰을 추가하면 다음과 같다.
BERT_vocab = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"] + vocabulary.copy()
```#결과#```
28
['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]', 'l', '##o', '##w', '##e', '##r', 'n', '##s', '##t', 'w', '##i', '##d', 'wid', 'lo', '##st', 'low', '##er', 'lower', 'ne', 'new', 'newe', 'wide', 'newest', 'widest']
````````````
- '[PAD]'는 패딩 토큰으로, 길이를 동일하게 맞추기 위해 사용된다.
- '[UNK]'는 unknown 토큰으로, 단어 집합에 없는 단어(토큰)를 처리하기 위해 사용된다. 즉, 모델이 학습하지 않은 새로운 단어를 만난다면, 이 토큰으로 대체된다.
- '[CLS]'는 classification 토큰으로 문장의 시작을 나타내며, 문장 분류 작업에서 이 토큰의 최종 은닉 상태(hidden state)를 사용한다.
- '[SEP]'는 separator 토큰으로 문장이나 문단의 끝을 나타낸다.
- '[MASK]'는 mask 토큰으로 마스크 언어 모델링((Masked Language Modeling) 학습에서 사용된다.
2.1.5 토크나이징(tokenizing) 함수
■ 학습된 WordPiece 어휘를 통해 새로운 텍스트를 토크나이징하는 함수를 위해 다음과 같이 encode_word라는 함수가 필요하다.
- 토크나이징은 문장을 토큰으로 나누는 과정
def encode_word(word):
tokens = []
while len(word) > 0:
i = len(word)
while i > 0 and word[:i] not in vocabulary:
i -= 1
if i == 0:
return ["[UNK]"]
tokens.append(word[:i])
word = word[i:]
if len(word) > 0:
word = f"##{word}"
return tokens
- encode_word 함수는 단일 단어를 WordPiece 토큰으로 분할하는 함수이다.
- 단어의 가장 긴 부분부터 시작하여 단어 집합 내부에 있는 가장 긴 하위 단어를 찾는다.
- 찾은 하위 단어를 토큰으로 추가하고, 해당 부분을 제거한다.
- 제거하고 남은 부분이 있다면 '##'을 붙여 하위 단어를 식별한다.
- 이 과정을 반복한다. 이때, 단어 집합에 없는 단어는 ‘[UNK]’ 토큰으로 대체된다.
## 현재 학습된 WordPiece 단어 집합
['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]', 'l', '##o', '##w', '##e', '##r', 'n', '##s',
'##t', 'w', '##i', '##d', 'wid', 'lo', '##st', 'low', '##er', 'lower', 'ne', 'new', 'newe',
'wide', 'newest', 'widest']
print(encode_word("lower")) # vocabulary에 존재하는 단어
print(encode_word("lowest")) # vocabulary에 존재하지 않는 단어 - 유형 1
print(encode_word("widely")) # vocabulary에 존재하지 않는 단어 - 유형 2
```#결과#```
['lower']
['low', '##e', '##st']
['[UNK]']
````````````
- 위의 결과를 보면, 'lower'는 학습된 단어 집합에 존재하는 토큰이므로 그대로 ['lower']로 토큰화된 것을 볼 수 있다.
- 반면 'lowest'의 경우, 시작 부분 'l'로 시작하는 하위 단어가 'low'이므로 ['low', '##est']로 분할된 다음, 다시 '##est'에 대해 분할을 수행해서 ['low', '##e', '##st']로 토큰화된 것을 볼 수 있다.
- 마지막 'widely'의 경우, 시작 부분 'w'로 시작하는 하위 단어가 학습된 단어 집합(vocabulary) 내부에 존재하지 않으므로 unk토큰으로 토큰화된 것을 볼 수 있다. ['[UNK]']
■ 다음 함수는 위의 encode_word 함수를 이용하여, 전체 텍스트를 WordPiece 토큰으로 분할하는 토크나이징 함수이다.
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]
encoded_words = [encode_word(word) for word in pre_tokenized_text]
return sum(encoded_words, [])
- 새로운 텍스트를 토큰화하기 위해 pre-tokenizer를 사용하여 사전 토큰화(pre-tokenization)하고, (이 예에서는 BERT의 pre-tokenizer를 사용함) 분할(split)한 다음,
- 각 단어에 토큰화 알고리즘(첫 번째 단어의 시작 부분을 기준으로 학습된 단어 집합 내부에서 가장 긴 하위 단어를 찾아 분할하고 나머지 단어에 대해서도 동일한 프로세스를 계속 반복)인 encode_word 함수를 적용하여 WordPiece 토큰으로 분할한다.
- 마지막으로, 모든 단어의 토큰을 하나의 리스트로 합쳐서 반환한다.
- 이제 이 tokenize 함수를 통해 다음과 같이 어떤 텍스트에 대해서도 학습된 WordPiece 단어 집합을 기준으로 WordPiece 토큰화를 수행할 수 있다.
tokenize("The lowest, newest and widest!")
```#결과#```
['[UNK]', 'low', '##e', '##st', '[UNK]', 'newest', '[UNK]', 'widest', '[UNK]']
````````````
■ 결과를 보면, 'newest'와 'widest'가 통째로 토큰화된 것을 확인할 수 있다. 이렇게 통째로 토큰화된 단어는 해당 단어가 말뭉치에서 자주 등장해 하나의 토큰으로 학습되었음을 의미한다.
■ 그리고 '##e'나 '##st'처럼 접미사를 의미하는 ##가 붙어 별도의 토큰으로 인식된 것은 이 단어들이 말뭉치에서 자주 등장하는 접미사일 가능성이 높다는 것을 의미한다.
■ 참고로 다음과 같은 decode 함수를 정의하여, WordPiece 토큰화로 분활된 토큰을 원래의 텍스트로 다시 합쳐줄 수 있다.
def decode(tokens):
"""
Decodes WordPiece tokens back into text.
"""
text = ""
for token in tokens:
if token.startswith("##"):
text += token[2:]
elif token == "[UNK]":
text += "<unk>"
else:
if text and not text.endswith(" "):
text += " "
text += token
return text.strip()
tokens = tokenize("row lower lowest")
tokens
```#결과#```
['[UNK]', 'lower', 'low', '##e', '##st']
````````````
# Demonstrate the decode function
decoded_text = decode(tokens)
decoded_text
```#결과#```
'<unk> lower lowest'
````````````
참고) https://static.googleusercontent.com/media/research.google.com/ko//pubs/archive/37842.pdf
참고) https://arxiv.org/pdf/1609.08144
'자연어처리' 카테고리의 다른 글
시퀀스투시퀀스(Sequence‑to‑Sequence, seq2seq) (1) (0) | 2025.03.19 |
---|---|
Subword Tokenizer - (3) Unigram Tokenization (0) | 2025.03.17 |
Subword Tokenizer - (1) Byte Pair Encoding(BPE) Tokenization (0) | 2025.03.15 |
엘모(Embeddings from Language Model, ELMo) (0) | 2025.03.08 |
Pre-trained Word Embedding (0) | 2025.03.04 |