프로세스
BBPE를 Sentencepiece 라이브러리를 활용해 학습해보겠습니다. 간단해요!
학습 코드는 다음과 같습니다.
import sentencepiece as spm
from pathlib import Path
prefix = 'sp-nsmc-test'
vocab_size = 31900 - 7
# vocab_size 는 10_000 ~ 52_000 사이이며 모델에 따라 다르다고 합니다.
# subwords token 31_900개
# special token 7개 제외
# additional_special_tokens (T5용 <extra_id_XX> 토큰) 100개
# -> 총 32_000개
spm.SentencePieceTrainer.train(
f' --input={corpus} --model_prefix={prefix}' +
f' --vocab_size={vocab_size + 7}' + # 최종 데이터셋 갯수
' --model_type=bpe' + # 어떤 토크나이저를 사요할 것인지 설정 : unigram, bpe, char, word
' --max_sentence_length=999_999' +
' --pad_id=0 --pad_piece=<pad>' +
' --unk_id=1 --unk_piece=<unk>' +
' --bos_id=2 --bos_piece=<s>' +
' --eos_id=3 --eos_piece=</s>' +
' --user_defined_symbols=<sep>,<cls>,<mask>' +
' --byte_fallback=True'
)
학습 완료 시, .model 과 .vocab 파일을 얻을 수 있습니다.
학습된 모델을 활용하여 토크나이징하면 다음과 같은 결과를 얻을 수 있어요.

구현은 byte 가 아니라 char 기준으로 진행했어요.
프로세스
: 문장 내 아래 단어 발생 빈도를 집계합니다.
문장 : 나는 계란밥을 먹는다. 계란밥! 얼마나 맛있던가. 나는 계란밥이 세상에서 제일좋다. 물론 간장밥 러버도 있겠지. 예를 들어 내 동생은 간장밥 하나만 있으면 1주일을 버틸 수 있다. 어떻게 하면 간장밥 그 단일 메뉴가 아침을 해결할 수 있을까. 말이 안된다. 중학생 때 먹은 간장밥, 그게 내 마지막 간장밥이다.
나는 내동생과 서로를 이해하지 못했다. 어느날 부모님이 계란과 간장을 섞어 간장계란밥을 만들었다. 노란색의 밥에 검정 소스가 있는, 이상한 그 메뉴의 이름을 묻자 부모님은 웃으며 내게 말했다. '간장계란밥'
볶음밥이야? 라고 물어봤지만 부모님은 고개를 저었다. 아니 볶음밥이라니? 이건 기름에 볶지 않았어. 볶음밥의 기본은 볶음인걸? 이건 간장과 계란이 합쳐진 비빔밥이야.
위의 문장을 아래처럼 만들고자 합니다.
< dictionary>
: 훈련데이터에 있는 단어와 등장 빈도수
- '계란밥': 3
- '간장밥' : 5
- '간장계란밥' : 2
- '볶음밥' : 3
<vocabulary>
- 계란밥
- 간장밥
- 간장계란밥
- 볶음밥
< dictionary>
- 계 란 밥 : 3
- 간 장 밥 : 5
- 간 장 계 란 밥 : 2
- 볶 음 밥 : 3
< vocab>
- 계, 란, 밥, 간, 장, 볶, 음
: 몇 번 merge 해서 하나의 유니그램으로 통합할까요?
아래는 그 merge 횟수에 따라 통합되는 생김새를 예시로 보여드립니다.
1회 : 빈도수가 5 + 2= 7 인 (간, 장) 의 쌍을 간장 으로 통합
- 계 란 밥 : 3
- 간장 밥: 5
- 간장 계 란 밥 : 2
- 볶 음 밥 : 3
2회 : 빈도수가 2 + 3 = 5로 가장 높은 (계, 란) 을 계란 으로 통합, (간장, 밥) 을 간장밥 으로 통합
- 계란 밥: 3
- 간장밥: 5
- 간장 계란 밥 : 2
- 볶 음 밥 : 3
3회 : 빈도수가 3 + 2 = 5로 가장 높은 (계란, 밥) 을 계란밥으로 통합
- 계란밥 : 3
- 간장밥 : 5
- 간장 계란밥 : 2
- 볶 음 밥 : 3
... 반복
3번 반복하였을 때 결과는 다음과 같아요.
< dictionary>
- 계란밥 : 3
- 간장밥 : 5
- 간장 계란밥 : 2
- 볶 음 밥 : 3
< vocab>
- 계, 란, 밥, 간, 장, 볶, 음, 계란밥, 간장밥, 간장
만약, 계란간장볶음밥이 등장한다면?
먼저, 필요한 함수를 아래와 같이 정의합니다.
import re, collections
# Character pair encoding
def get_word_freq(corpus):
word_freq = collections.defaultdict(int)
for sentence in corpus:
for word in sentence.split():
word_space = ' '.join([w for w in word if len(word)>1])
word_freq[word_space] += 1
return word_freq
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
return pairs
def merge_vocab(pair, v_in):
v_out = {}
bigram = re.escape(' '.join(pair))
p = re.compile(f'(?<!\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
그리고 나서, corpus 를 가져옵니다
# 위의 예시에 따른 결과값을 확인하기 위해 띄어쓰기를 부자연스럽게 적용하였습니다.
corpus = [
'나는 계란밥 을 먹는다',
'계란밥 ! 얼마나 맛있던가.',
'나는 계란밥 이 세상에서 제일좋다.',
'물론 간장밥 러버도 있겠지.',
'예를 들어 내 동생은 간장밥 하나만 있으면 1주일을 버틸 수 있다.',
'어떻게 하면 간장밥 그 단일 메뉴가 아침을 해결할 수 있을까.',
'말이 안된다. 중학생 때 먹은 간장밥 , 그게 내 마지막 간장밥 이다.',
'나는 내동생과 서로를 이해하지 못했다.',
'어느날 부모님이 계란과 간장을 섞어 간장계란밥 을 만들었다.',
'노란색의 밥에 검정 소스가 있는, 이상한 그 메뉴의 이름을 묻자 부모님은 웃으며 내게 말했다.',
'간장계란밥',
'볶음밥 이야? 라고 물어봤지만 부모님은 고개를 저었다.',
'아니 볶음밥 이라니? 이건 기름에 볶지 않았어.',
'볶음밥 의 기본은 볶음인걸? 이건 간장과 계란이 합쳐진 비빔밥이야.'
]
앞서 설명한 BPE 방식대로 단어를 만들게 되면 아래와 같습니다.
bpe_vocab_history = {}
vocab = get_word_freq(corpus)
num_merges = 10
for i in range(num_merges):
pairs = get_stats(vocab)
best = max(pairs, key=pairs.get)
vocab = merge_vocab(best, vocab)
bpe_vocab_history[best] = i

그 다음, BPE merge 방식에 따라 단어를 만듦니다.
class BPE_tokenizer:
def __init__(self, num_merges):
self.num_merges = num_merges
self.vocab = None
self.byte_pairs = None
self.token_vocab = None
self.bpe_vocab_history = {}
# BPE알고리즘 학습
def train(self, corpus):
self.vocab = get_word_freq(corpus)
for i in range(self.num_merges):
self.byte_pairs = get_stats(self.vocab)
best = max(self.byte_pairs, key=self.byte_pairs.get)
self.vocab = merge_vocab(best, self.vocab)
self.bpe_vocab_history[best] = i
self.token_vocab = set() # 완료 시 만든 vocab 을 추가해줌
for word in self.vocab.keys():
self.token_vocab.update(word.split())
def tokenize(self, text):
tokens = text.split()
tokenized_text = []
for token in tokens:
if token in self.token_vocab:
tokenized_text.append(token)
else:
tokenized_text.extend(self.byte_tokenize(token))
return tokenized_text
# 텍스트 토큰화
def byte_tokenize(self, token):
tokens = []
while len(token) > 0:
found = False
for i in range(len(token), 0, -1):
subword = token[:i]
#print(subword)
if subword in self.token_vocab:
tokens.append(subword)
token = token[i:]
found = True
break
if not found :
tokens.append(token[0])
token = token[1:]
return tokens
num_merges 는 목표 vocab_size 와 token 품질에 따라 adaptive 하게 적용할 수 있습니다. 병합이 완료된 모델을 활용해 토크나이징이 얼마나 잘 되었는지는 아래처럼 확인할 수 있어요.
