본문 바로가기

딥러닝/딥러닝을 이용한 자연어처리 입문

[딥러닝 NLP] 13. Subword Tokenizer(BPE, SentencePiece, Huggingface)

기계가 모르는 단어(OOV, 신조어 등)에 대해 어떻게 대처해야할까?

Subword segmentation는 하나의 단어를 여러 서브워드로 분리해서 단어를 인코딩 및 임베딩하는 전처리 작업으로, 이러한 작업을 수행하는 토크나이저를 Subword Tokenizer라고 한다.

    ex) birthplace = birth + place

이번 장에서는 Subword Tokenizer의 주요 알고리즘인 BPE와 그 변형, 실무에서 주로 사용하는 Tokenizer들을 학습한다.

# 13-01 BPE(Byte Pair Encoding)

BPE(Byte Pair Encoding): 1994년에 제안된 데이터 압축 알고리즘으로, 연속적으로 많이 등장한 바이트 쌍을 하나의 바이트로 치환

ex) aaabdaaabac 에서 Z=aa, Y=ab로 치환하여 ZYdZYac 라고 표현

 

1. NLP에서의 BPE

논문 링크: https://arxiv.org/pdf/1508.07909.pdf

문자 단위에서 점차 단어를 만들어내는 Bottom-up 접근

중요한 것은, BPE 알고리즘을 몇 회 반복할지 사용자가 정한다는 점

 

Ex) 어떤 딕셔너리가 다음과 같은 단어들로 구성되어 있다고 하자.

low, lower, newest, widest

 

먼저 딕셔너리의 모든 단어들을 문자 단위로 분리한다. (초기)

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

 

가장 빈도수가 높은 쌍을 하나로 묶는다. (1회)

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

 

만약 10회 반복한다고 설정하면 다음과 같은 결과가 된다.

l, o, w, e, r, n, s, t, i, d, es, est, lo, low, ne, new, newest, wi, wid, widest

 

구현코드는 아래와 같다.

import re, collections
from IPython.display import display, Markdown, Latex

# BPE 횟수 설정
num_merges = 10

# BPE의 입력으로 사용하는 단어 집합. 단어마다 끝에 </w>을 붙여야 함
dictionary = {'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
         }

# BPE 코드
def get_stats(dictionary):
    # 유니그램의 pair들의 빈도수를 카운트
    pairs = collections.defaultdict(int)
    for word, freq in dictionary.items():
        symbols = word.split()
        for i in range(len(symbols)-1):
            pairs[symbols[i],symbols[i+1]] += freq
    print('현재 pair들의 빈도수 :', dict(pairs))
    return pairs

def merge_dictionary(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

bpe_codes = {}
bpe_codes_reverse = {}

for i in range(num_merges):
    display(Markdown("### Iteration {}".format(i + 1)))
    pairs = get_stats(dictionary)
    best = max(pairs, key=pairs.get)
    dictionary = merge_dictionary(best, dictionary)

    bpe_codes[best] = i
    bpe_codes_reverse[best[0] + best[1]] = best

    print("new merge: {}".format(best))
    print("dictionary: {}".format(dictionary))

 

출력 결과는 너무 길어 생략한다. 글자들의 통합 과정을 볼 수 있다. 아래 코드로 한눈에 기록을 볼 수 있다.

print(bpe_codes)
# 결과
{('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}

 

만약 서브워드 단어집합에 'lo'가 있고, 새로운 단어 'loki'가 들어오면 ('lo', 'k', 'i') 로 분리한다.

만약 서브워드 단어집합에 'low'와 'est'가 있고, 새로운 단어 'lowest'가 들어오면 ('low', 'est')로 분리한다.

만약 'highing'에 대해 그 어떤 서브워드도 존재하지 않으면 'h' 'i' 'g' 'h' 'i' 'n' 'g' 으로 분리된다.

 

2. WordPiece Tokenizer

BERT의 훈련에 사용되기도 한 알고리즘이다.

논문: https://static.googleusercontent.com/media/research.google.com/ko//pubs/archive/37842.pdf

 

-BPE가 빈도수에 기반해 병합한 것과는 달리, 코퍼스의 우도(Likelihood)를 가장 높이는 쌍을 병합한다.

-모든 단어의 맨 앞에 _를 붙이고, 분리한 subword들은 띄어쓰기로 구분한다.

-수행 전으로 되돌리려면 띄어쓰기를 제거하고, _를 띄어쓰기로 바꾼다.

Ex)

수행 전: Jet makers feud over seat width with big orders at stake
수행 후: _J et _makers _fe ud _over _seat _width _with _big _orders _at _stake

 

3. Unigram Language Model Tokenizer

논문: https://arxiv.org/pdf/1804.10959.pdf

-각각의 subword들에 대해 손실을 계산하여 최악의 영향을 주는 10~20% 토큰을 제거하는 과정을 반복

-손실이란? 해당 subword가 코퍼스에서 제거될 경우, 코퍼스의 우도가 감소하는 정도

 

# 13-02 SentencePiece

1. SentencePiece란?

단어 토큰화가 되지 않은 raw data에 곧바로 사용할 수 있어, 그 어떤 언어에도 적용할 수 있다.

구글이 공개한 내부 단어 분리 패키지로,  BPE와 Unigram LM Tokenizer 알고리즘이 깃허브에 구현되어 있다.

깃허브 링크: https://github.com/google/sentencepiece

pip install sentencepiece

 

2. IMDB 리뷰 토큰화하기

import sentencepiece as spm
import pandas as pd
import urllib.request
import csv

urllib.request.urlretrieve("https://raw.githubusercontent.com/LawrenceDuan/IMDb-Review-Analysis/master/IMDb_Reviews.csv", filename="IMDb_Reviews.csv")
train_df = pd.read_csv('IMDb_Reviews.csv')

with open('imdb_review.txt', 'w', encoding='utf8') as f:
    f.write('\n'.join(train_df['review']))

# SentencePiece로 단어집합과 각 단어에 고유한 정수 부여, 실행이 완료되면 imdb.model, imdb.vocab 파일 두개가 생성됨
spm.SentencePieceTrainer.Train('--input=imdb_review.txt --model_prefix=imdb --vocab_size=5000 --model_type=bpe --max_sentence_length=9999')

# imdb.model, imdb.vocab 중에서 vocab 파일로부터 학습된 subword들 확인 가능
vocab_list = pd.read_csv('imdb.vocab', sep='\t', header=None, quoting=csv.QUOTE_NONE)
vocab_list.sample(10)

# imdb.model, imdb.vocab 중에서 model 파일을 로드해 인코딩, 디코딩 작업 수행
sp = spm.SentencePieceProcessor()
vocab_file = "imdb.model"
sp.load(vocab_file)

# 샘플에 대해 인코딩, 디코딩 해보기
# encode_as_pieces : 문장을 입력하면 subword 시퀀스로 변환
# encode_as_ids : 문장을 입력하면 정수 시퀀스로 변환
lines = [
  "I didn't at all think of it this way.",
  "I have waited a long time for someone to film"
]
for line in lines:
  print(line)
  print(sp.encode_as_pieces(line))
  print(sp.encode_as_ids(line))
  print()

sp.GetPieceSize() # 단어 집합의 크기
sp.IdToPiece(430) # 정수 -> subword 변환
sp.PieceToId('▁character') # subword -> 정수 변환
sp.DecodeIds([41, 141, 1364, 1120, 4, 666, 285, 92, 1078, 33, 91]) # subword 시퀀스 -> 문장
print(sp.encode('I have waited a long time for someone to film', out_type=str)) # 문장 -> 정수 시퀀스
print(sp.encode('I have waited a long time for someone to film', out_type=int)) # 문장 -> subword 시퀀스

 

3. 네이버 영화리뷰 토큰화하기

import pandas as pd
import sentencepiece as spm
import urllib.request
import csv

urllib.request.urlretrieve("https://raw.githubusercontent.com/e9t/nsmc/master/ratings.txt", filename="ratings.txt")
naver_df = pd.read_table('ratings.txt')
naver_df = naver_df.dropna(how = 'any') # Null 값이 존재하는 행 제거

# 샘플을 파일로 저장
with open('naver_review.txt', 'w', encoding='utf8') as f:
    f.write('\n'.join(naver_df['document']))

# SentencePiece로 단어집합 생성
spm.SentencePieceTrainer.Train('--input=naver_review.txt --model_prefix=naver --vocab_size=5000 --model_type=bpe --max_sentence_length=9999')

# 학습된 subwords 확인
vocab_list = pd.read_csv('naver.vocab', sep='\t', header=None, quoting=csv.QUOTE_NONE)
vocab_list[:10]

# 모델 로드
sp = spm.SentencePieceProcessor()
vocab_file = "naver.model"
sp.load(vocab_file)

# 샘플에 대해 인코딩, 디코딩
lines = [
  "뭐 이딴 것도 영화냐.",
  "진짜 최고의 영화입니다 ㅋㅋ",
]
for line in lines:
  print(line)
  print(sp.encode_as_pieces(line))
  print(sp.encode_as_ids(line))
  print()

 

# 13-03 SubwordTextEncoder

텐서플로우를 통해 사용할 수 있는 Subword Tokenizer로, Wordpiece Model 알고리즘을 채택함

import tensorflow_datasets as tfds

 

 

Ex) IMDB 리뷰 토큰화하기

# 토큰화
tokenizer = tfds.features.text.SubwordTextEncoder.build_from_corpus(
    train_df['review'], target_vocab_size=2**13)

# subwords
print(tokenizer.subwords[:100])

# 정수 인코딩
tokenized_string = tokenizer.encode(sample_string)
print(tokenized_string)

# 다시 디코딩
original_string = tokenizer.decode(tokenized_string)
print(original_string)

# 단어집합 크기
print(tokenizer.vocab_size)

 

# 13-04 Huggingface Tokenizer

NLP 스타트업 허깅페이스가 개발한 패키지로, 그 중에서도 WordPiece Tokenizer를 실습해본다.

pip install tokenizers

 

1. BERTWordPieceTokenizer

tokenizer = BertWordPieceTokenizer(lowercase=False, trip_accents=False)
  • lowercase : 대소문자 구분 여부. True일 경우 구분하지 않음.
  • strip_accents : True일 경우 악센트 제거.
vocab_size = 30000
limit_alphabet = 6000
min_frequency = 5

tokenizer.train(files=data_file,
                vocab_size=vocab_size,
                limit_alphabet=limit_alphabet,
                min_frequency=min_frequency)

 

  • files : 학습할 데이터
  • vocab_size : 단어 집합의 크기
  • limit_alphabet : 병합 전의 초기 토큰의 허용 개수
  • min_frequency : 최소 해당 횟수만큼 등장한 쌍(pair)의 경우에만 병합 대상이 된다
# vocab 저장
tokenizer.save_model('./')

# vocab 로드
df = pd.read_fwf('vocab.txt', header=None)

# 인코딩
encoded = tokenizer.encode('아 배고픈데 짜장면먹고싶다')
print('토큰화 결과 :',encoded.tokens)
print('정수 인코딩 :',encoded.ids)

# 디코딩
print('디코딩 :',tokenizer.decode(encoded.ids))

 

2. 그 외 Tokenizer

  • CharBPETokenizer : 오리지널 BPE
  • ByteLevelBPETokenizer : BPE의 바이트 레벨 버전
  • SentencePieceBPETokenizer : SentencePiece와 호환되는 BPE 구현체
from tokenizers import ByteLevelBPETokenizer, CharBPETokenizer, SentencePieceBPETokenizer

tokenizer = SentencePieceBPETokenizer()
tokenizer.train('naver_review.txt', vocab_size=10000, min_frequency=5)

encoded = tokenizer.encode("이 영화는 정말 재미있습니다.")
print(encoded.tokens)

# 결과
['▁이', '▁영화는', '▁정말', '▁재미있', '습니다.']