자연어처리를 위한 BPE & SentencePiece

이전 포스트 자연어처리를 위한 Tokenizer & Vocabulary도 함께 참고하세요.


1. BPE (Byte Pair Encoding)

BPE(Byte Pair Encoding)은 압축 알고리즘을 이용하여 띄어쓰기 단위의 단어를 subword 단위로 분할하는 알고리즘입니다. 관련 논문은 Neural Machine Translation of Rare Words with Subword Units을 참고하면 됩니다.

기본적은 동작원리는 subword pair 중 가장 빈도수가 많은 subword pair를 하나의 subword로 합치는 방식입니다.

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
    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

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}
num_merges = 10

for i in range(num_merges):
    pairs = get_stats(vocab)
    best = max(pairs, key=pairs.get)
    vocab = merge_v

위 코드는 Neural Machine Translation of Rare Words with Subword Units 논문에 있는 코드입니다.

bpe-01.png

BPE를 위해서 띄어쓰기 단위로 문장을 분류했더니 위 그림과 같이 'low' 5번, 'lower' 2번, 'newest' 6번, 'widest' 3번이 나왔다고 가정하겠습니다.
각 단어들을 글자 단위로 분할하고 시작을 의미하는 특수 문자 '▁'(unicode \u2581)를 처음에 추가해 줬습니다.
논문에서는 마지막을 의미하는 ' </w>'를 사용한 것과 차이점이 있지만 결과에는 큰 차이가 없습니다.

이제 글자의 중복을 제거하면 다음 11개의 글자로 문장 표현이 가능합니다.

▁, l, o, w, e, r, n, s, t, i, d

첫 번째 bi-gram 단위로 발생 빈도수를 세어봅니다.

bpe-02.png

위 그림과 같이 (e, s)와 (s, t)가 9개로 빈도수가 가장 많습니다. 그중에서 첫번째 (e, s)를 하나의 하나의 byte로 변환 합니다.

bpe-03.png

이제 글자의 중복을 제거하면 다음 12개의 글자로 문장 표현이 가능합니다. 마지막에 es가 추가되었습니다.

▁, l, o, w, e, r, n, s, t, i, d, es

두 번째 bi-gram 단위로 발생 빈도수를 세어봅니다.

bpe-04.png

위 그림과 같이 (es, t)가 9개로 빈도수가 가장 많습니다. (es, t)를 하나의 하나의 byte로 변환 합니다.

bpe-05.png

이제 글자의 중복을 제거하면 다음 13개의 글자로 문장 표현이 가능합니다. 마지막에 est가 추가되었습니다.

▁, l, o, w, e, r, n, s, t, i, d, es, est

세 번째 bi-gram 단위로 발생 빈도수를 세어봅니다.

bpe-06.png

위 그림과 같이 (▁, l), (l, o), (o, w)가 7개로 빈도수가 가장 많습니다. 그중에서 첫번째 (▁, l)를 하나의 하나의 byte로 변환 합니다.

bpe-07.png

이제 글자의 중복을 제거하면 다음 14개의 글자로 문장 표현이 가능합니다. 마지막에 ▁l가 추가되었습니다.

▁, l, o, w, e, r, n, s, t, i, d, es, est, ▁l

위와 같은 과정을 중복을 제거한 글자 수가 원하는 숫자가 될 때까지 반복합니다.


BPE Tokenizer의 장점은 다음과 같습니다.

  • 말뭉치가 있다면 비교적 간단하게 만들 수 있습니다.
  • Subword를 이용하면 적은 수의 vocabulary를 가지고 OOV(Out of Vocabulary)를 최소화할 수 있습니다.

BPE Tokenizer의 단점은 다음과 같습니다.

  • Subword의 분할이 의미 기준이 아닐 수 있습니다. 다음과 같은 예를 들 수 있습니다.
    • ‘수원에’라는 문장을 분할할 때 [‘▁수원’, ‘에’]가 아닌 [‘▁수’, ‘원에’]로 분할되기도 합니다.
    • ‘대한민국을’, ‘대한민국은’, ‘대한민국으로’ 등의 빈도수가 단어들은 [‘대한민국’, ‘을’, ‘은’, ‘으로’] 형태로 분류되길 원하지만 [‘대한민국을’, ‘대한민국은’, ‘대한민국으로’] 그대로 분류되기도 합니다.


2. Sentencepiece

Sentencepiece는 google에서 제공하는 Tokenizer tool입니다. 자세한 내용을 다음을 참고하시면 됩니다.

Tokeinze 방식으로는 char, word, BPE, unigram 등 다양한 방식을 제공합니다. BPE는 발생 빈도수를 이용해서 분할 하였다면 unigram(Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates)은 확률기반으로 subword의 발생확률을 최대화하는 방식으로 분할합니다.

Sentencepiece의 기본 tokenize 방식은 unigram 입니다.

Sentencepiece의 자세한 활용법은 sentencepiece_python_module_example.ipynb을 참고하면 됩니다.


2.1 Sentencepiece 학습
def train_sentencepiece(corpus, prefix, vocab_size=32000):
    """
    sentencepiece를 이용해 vocab 학습
    :param corpus: 학습할 말뭉치
    :param prefix: 저장할 vocab 이름
    :param vocab_size: vocab 개수
    """
    spm.SentencePieceTrainer.train(
        f"--input={corpus} --model_prefix={prefix} --vocab_size={vocab_size + 7}" +  # 7은 특수문자 개수
        " --model_type=unigram" +
        " --max_sentence_length=999999" +  # 문장 최대 길이
        " --pad_id=0 --pad_piece=[PAD]" +  # pad token 및 id 지정
        " --unk_id=1 --unk_piece=[UNK]" +  # unknown token 및 id 지정
        " --bos_id=2 --bos_piece=[BOS]" +  # begin of sequence token 및 id 지정
        " --eos_id=3 --eos_piece=[EOS]" +  # end of sequence token 및 id 지정
        " --user_defined_symbols=[SEP],[CLS],[MASK]" +  # 기타 추가 토큰 SEP: 4, CLS: 5, MASK: 6
        " --input_sentence_size=100000" +  # 말뭉치에서 셈플링해서 학습
        " --shuffle_input_sentence=true")  # 셈플링한 말뭉치 shuffle

위 함수의 Sentencepiece이용해 학습하는 함수 예제 입니다. 설명은 다음과 같습니다.

  • 함수의 입력으로는 corpus(말뭉치), prefix(생성할 파일 이름), vocab_size(vocabulary 개수) 등을 받습니다.
  • -–input: 망뭉치 파일 경로를 입력합니다.
  • -–model_prefix: 생성할 파일 이름을 입력합니다. model_prefix.model, model_prefix.vocab 두 개의 파일이 생성됩니다.
  • --vocab_size: 생성할 vocabulary의 개수를 입력합니다. 특수문자 개수 7을 추가해서 지정했습니다.
  • --model_type: Vocabulary를 학습할 방법을 입력합니다. 기본값인 'unigram'을 입력합니다.
  • --max_sentence_length: 한 문장의 최대길이를 의미합니다.
  • --pad_id: 짧은 문장의 길이를 맞춰줄 pad token의 일련번호 의미합니다.
  • --pad_piece: pad token의 문자열을 의미합니다.
  • --unk_id: Vocabulary에 없는 token을 처리할 unk token의 일련번호 의미합니다.
  • --unk_piece: unk token의 문자열을 의미합니다.
  • --bos_id: 문장의 시작을 의미하는 bos token의 일련번호 의미합니다.
  • --bos_piece: bos token의 문자열을 의미합니다.
  • --eos_id: 짧은 문장의 길이를 맞춰줄 pad token의 일련번호 의미합니다.
  • --eos_piece: pad token의 문자열을 의미합니다.
  • --user_defined_symbols: 추가적으로 필요한 token을 정의합니다. 일련번호는 특수 토큰 이후부터 순서대로 부여됩니다.
  • --input_sentence_size: 한국어위키의 말뭉치의 경우는 데이터가 커서 vocabulary를 학습하는데 시간이 많이 걸립니다. 시간 단축을 위해 sampling을 해서 학습하도록 sampling 개수를 정의합니다. 전체 말뭉치를 학습하기를 원하면 'input_sentence_size', 'shuffle_input_sentence'를 제거하면 됩니다.
  • --shuffle_input_sentence: 말뭉치의 순서를 섞을지 여부를 결정합니다.

위 함수를 적당히 수정해서 사용하면 쉽게 tokenizer를 만들 수 있습니다. 위 함수의 실행 결과 다음 두 개 파일이 생성됩니다.

  • <prefix>.model: Sentencepiece가 학습한 내용을 읽어 들이기 위한 파일입니다.
  • <prefix>.vocab: text 형식으로 내용을 확인할 수 있는 파일입니다.


2.2 Sentencepiece 학습된 모델 불러오기
vocab = spm.SentencePieceProcessor()
vocab.load(<path of prefix.model>)

위 코드와 같이 하면 학습된 모델을 불러올 수 있습니다. <path of prefix.model>는 학습과정에서 생성된 <prefix>.model의 경로입니다.


2.3 문자열을 token단위로 분할
vocab.encode_as_pieces("아름다운 대한민국 우리나라 금수강산")

결과 >> ['▁아름다운', '▁대한민국', '▁우리나라', '▁금', '수', '강', '산']

encode_as_pieces 함수를 사용하면 문자열을 token 단위로 분할할 수 있습니다.

vocab.decode_pieces(['▁아름다운', '▁대한민국', '▁우리나라', '▁금', '수', '강', '산'])

결과 >> 아름다운 대한민국 우리나라 금수강산

decode_pieces 함수를 사용하면 token을 문자열로 복원할 수 있습니다.


2.4 문자열을 일련번호 단위로 분할
vocab.encode_as_ids("아름다운 대한민국 우리나라 금수강산")

결과 >> [4869, 243, 6308, 653, 104, 301, 162]

encode_as_ids 함수를 사용하면 문자열을 일련번호 단위로 분할할 수 있습니다. 문장을 딥러닝 모델에 입력할 때는 일련번호 형태로 입력해야 합니다.

vocab.decode_ids([4869, 243, 6308, 653, 104, 301, 162])

결과 >> 아름다운 대한민국 우리나라 금수강산

decode_ids 함수를 사용하면 token을 문자열로 복원할 수 있습니다.


2.5 token을 일련번호로 변환
vocab.piece_to_id(['▁아름다운', '▁대한민국', '▁우리나라', '▁금', '수', '강', '산'])

결과 >> [4869, 243, 6308, 653, 104, 301, 162]

piece_to_id 함수를 사용하면 token을 일련번호로 변환할 수 있습니다.

vocab.id_to_piece([4869, 243, 6308, 653, 104, 301, 162])

결과 >> ['▁아름다운', '▁대한민국', '▁우리나라', '▁금', '수', '강', '산']

id_to_piece 함수를 사용하면 일련번호를 token으로 변환할 수 있습니다.


3. SentencePeice with Morph

Sentencepiece와 형태소분석기를 함께 사용하는 방법 입니다.

  • 형태소분석기를 이용해 문장을 형태소 단위로 분할한 문장으로 변환합니다.
  • 형태소 단위로 분할된 문장을 Sentencepiece를 이용해 분할합니다.

학습할때도 말뭉치를 우선 형태소분석기를 이용해 형태소 단위로 분할한 말뭉치를 가지고 Sentencepiece를 이용해 분할 합니다.

Sentencepiece와 형태소분석기를 함께 사용하는 것이 Sentencepiece만 사용했을 때 보다 성능이 좋다고 알려져 있습니다.