Word Piece Model (a.k.a sentencepiece)

토크나이징 (tokenizing) 은 문장을 토큰으로 나누는 과정입니다. 텍스트 데이터를 학습한 모델의 크기는 단어의 개수에 영향을 받습니다. 특히 Google neural machine translator (GNMT) 와 같은 Recurrent Neural Network (RNN) 기반 알고리즘들은 단어 개수에 비례하여 계산비용이 증가합니다. 그렇기 때문에 RNN 에 이용되는 vocabulary, word embedding 벡터의 종류가 제한됩니다. 하지만 vocabulary 의 개수가 제한되면 임베딩 벡터로 표현하지 못하는 단어가 생깁니다. 이 역시 미등록단어 문제입니다. RNN 기반 모델에서 이를 해결하기 위하여 Word Piece Model (WPM) 이 제안되었습니다. 단어를 한정적인 유닛 (finite subword units) 으로 표현합니다. WPM 은 언어에 상관없이 모두 적용할 수 있기 때문에 적용할 언어마다 해당 언어의 특징을 반영한 토크나이저를 만들지 않아도 됩니다. 그러나 모든 데이터 분석에 적합한 것은 아닙니다. WPM 이 유용할 수 있는 상황과 그렇지 않은 상황에 대해서 알아봅니다.

Word piece, units of words

Word Piece Model 은 제한적인 vocabulary units, 정확히는 단어를 표현할 수 있는 subwords units 으로 모든 단어를 표현합니다. 수백만개의 단어를 포함하는 데이터를 표현하기 위해서 bag of words model 는 단어 개수 만큼의 차원을 지닌 벡터 공간을 이용합니다. RNN 처럼 word embedding vectors 를 이용하는 모델은 단어 개수 만큼의 embedding vector 를 학습합니다. 단어의 개수가 많을수록 차원이 크거나 모델이 무거워집니다. 이를 해결하기 위해서는 제한된 (finite) 개수의 단어를 이용해야 합니다. 그러나 자주 이용되지 않는 수 많은 (long-tail) 단어들을 무시하면 미등록단어 문제가 발생합니다.

언어는 글자 (characters)를 subword units 으로 이용합니다. 영어는 알파벳을 유닛으로 이용합니다. 대부분의 영어 단어는 몇 개의 글자가 모여 하나의 단어를 구성합니다. 즉, 하나의 유닛이 어떤 개념을 지칭하기는 어렵습니다. 유닛이 모호성을 지닙니다. 중국어는 한자를 units 으로 이용합니다. 중국어도 여러 글자가 모여 하나의 단어를 이루기도 하지만, 한 글자로 구성된 단어도 많습니다. 영어보다는 유닛의 모호성이 줄어듭니다. 동음이의어의 문제가 남아있지만, 가장 모호성이 적은 방법은 모든 단어를 단어의 유닛으로 이용하는 것입니다.

그러나 토크나이징 방법에 따라 모호성이 적은 최소한의 유닛을 만들 수도 있습니다. 아래의 세 문장을 다음의 요소들로 나눈다면 이 유닛들은 의미를 보존하면서도 재활용이 될 수 있습니다.

공연은 끝났어 -> ['공연-' + '-은' + '끝-' + '-났어']
공연을 끝냈어 -> ['공연-' + '-을' + '끝-' + '-냈어']
개막을 해냈어 -> ['개막-' + '-을' + '해-' + '-냈어']

이 유닛들은 ‘개막공연’ 이라는 복합명사도 분해하는데 이용될 수 있습니다. ‘개막공연’을 독립된 유닛으로 만들 필요가 없습니다.

개막공연을 끝냈어 -> ['개막-' + '공연-' + '-을' + '끝-' + '-냈어']

그런데 문제는 위처럼 토크나이징을 하려면 해당 언어의 언어학적 지식과 학습데이터가 필요합니다. 그러나 언어가 다르고, 도메인이 다르면 이를 준비하는 것은 어렵습니다.

Word Piece Model (sentencepiece) tokenizer

학습 데이터를 이용하지 않으면서도 위의 결과를 이끌 수 있는 heuristics 이 있습니다. ‘공연-‘, ‘개막-‘, ‘-냈어’, ‘-났어’은 유닛이기 때문에 유닛이 아닌 subwords 보다 자주 등장할 가능성이 높습니다. 만약 ‘공연-‘의 빈도수와 ‘개막공연-‘의 빈도수가 같다면 ‘공연-‘이나 ‘개막-‘은 유닛으로 이용하지 않아도 됩니다. 어자피 세 유닛 모두 ‘개막공연’을 나타내기 위한 부분들이니까요. 유닛이 자주 등장한다는 사실은 아마도 많은 언어의 공통적인 특징일 것입니다. 이를 이용한다면 language independent, universial tokenizer 를 만들 수도 있을 것 같습니다.

Word Piece Model (WPM) 은 이 개념을 이용하는 토크나이저입니다. 원 논문 (Sennrich et al., 2015) 에 적힌 예시입니다. Words 는 매우 다양합니다. makers, over 는 모두 자주 이용되기 때문에 그 자체를 units 으로 이용합니다. Jet 은 자주 등장하지 않는 단어이기 때문에 subword units 인 ‘J’ 와 ‘et’ 으로 나눕니다.

그 전에 모든 단어의 시작에는 underbar, ‘_’ 를 붙입니다. 그렇다면 ‘Jet’ 은 ‘_Jet’ 이 되어 ‘_J et’ 으로 나뉘어집니다. makers 는 ‘_makers’ 가 됩니다.

  • Word : Jet makers feud over seat width with big orders at stake
  • Wordpieces: _J et _makers _fe ud _over _seat _width _with _big _orders _at _stake

Underbar 는 문장 생성, 혹은 subwords 부터의 문장 복원을 위한 특수기호 입니다. Underbar 없이 subwords 를 띄어두면, 본래 띄어쓰기와 구분이 되지 않기 때문입니다. 문장을 복원하는 코드는 간단합니다. 띄어쓰기 기준으로 나눠진 tokens 을 concatenation 한 뒤, _로 다시 나눠 tokenize 하거나 _ 를 빈 칸으로 치환하여 문장으로 복원합니다.

def recover(tokens):
    sent = ''.join(tokens)
    sent = sent.replace('_', ' ')
    return sent

Byte-pair Encoding (BPE)

Neural Machine Translation of Rare Words with Subword Units (Sennrich et al., 2015) 에는 word pieces (subword units) 을 학습할 수 있는 간단한 코드가 적혀 있습니다.

아래의 vocab 은 low, lower, newest, widest 의 맨 뒤에 특수기호 ‘/w’ 를 넣은 뒤, 한글자 단위로 모두 띄어 초기화를 한 상태입니다. Character 는 기본 subword units 입니다. for loop 에서 빈도수가 가장 많은 bigram 을 찾습니다. 이 bigram 을 하나의 unit 으로 merge 합니다. 이 과정을 num_merges 만큼 반복합니다. vocab 의 value 는 빈도수 입니다. ‘low’ 가 5 번, ‘lower’ 가 2 번 등장했습니다.

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_vocab(best, vocab)
    print(best)

print(vocab)

위 코드를 실행시킬 때 print(best) 에 의하여 출력된 결과입니다. 처음 ‘es’ 는 9 번 등장하였기 때문에 (‘e’, ‘s’) 가 ‘es’ 로 합쳐집니다. ‘lo’ 는 7 번 등장했습니다만 Python dict 의 key order 순서에 의해 ‘es’ 가 우선적으로 merge 됩니다. 그리고 (‘es’, ‘t’) 가 병합되어 ‘est’가 됩니다. 같은 빈도수를 지닌다면 계속하여 병합이 됩니다. 하지만 ‘west’: 6 번, ‘dest’: 3 번 이기 때문에 ‘est’ 이후에는 (‘l’, ‘o’), (‘lo’, ‘w’) 가 병합됩니다.

('e', 's')
('es', 't')
('est', '</w>')
('l', 'o')
('lo', 'w')
('n', 'e')
('ne', 'w')
('new', 'est</w>')
('low', '</w>')
('w', 'i')

10 번의 병합을 거친 vocab 은 다음처럼 변합니다.

vocab = {
         'low</w>': 5, 
         'low e r </w>': 2, 
         'newest</w>': 6, 
         'wi d est</w>': 3
        }

띄어쓰기 기준으로 subword units 를 나누면 다음과 같습니다. 총 9 개의 units 으로 네 단어를 표현합니다.

{'low</w>': 5,
 'low': 2,
 'e': 2,
 'r': 2,
 '</w>': 2,
 'newest</w>': 6,
 'wi': 3,
 'd': 3,
 'est</w>': 3}

Byte pair encoding 은 데이터 압축 방법입니다. 빈도수가 많은 최장길이의 substring 을 하나의 unit 으로 만들면 bit 를 아낄 수 있습니다.

토크나이저 입장에서는 많이 쓰이는 subwords 를 units 으로 이용하면, 자주 이용되는 단어는 그 자체가 unit 이 되며, 자주 등장하지 않는 단어 (rare words)가 subword units 으로 나뉘어집니다. 즉 WPM 은 각 언어의 지식이 없이도 빈번히 등장하는 substring 을 단어로 학습하고, 자주 등장하지 않는 단어들을 최대한 의미보존을 할 수 있는 최소한의 units 으로 표현합니다. 한국어에서 복합명사를 단일명사들로, 어절을 명사와 조사로 나누는 것과 비슷합니다.

다른 논문들을 살펴보면, 번역 엔진들에서는 자주 이용되는 개의 단어는 따로 지정을 하고 그 외의 단어에 대해서 개의 subword units 을 이용하여 토크나이징을 수행한다고 합니다.

논문의 원저자인 Sennrich 도 본인의 github 에 코드를 공개하였습니다. Python script 형식으로 input file 을 토크나이징하여 output file 로 만듭니다.

Codes (논문)

Sennrich et al (2015) 에 공개된 코드에 save, load 와 같은 기능을 추가하여 간단히 word piece model 을 구현하였습니다. 코드는 github에 공개하였습니다.

BytePairEncoder(n_units) 은 n_units 개수 만큼의 subword units 을 학습하는 클래스입니다. corpus 는 list of str (like) 입니다.

from bytepairencoder import BytePairEncoder

n_units = 5000
encoder = BytePairEncoder(n_units)
encoder.train(corpus)

학습이 끝낸 encoder의 tokenize() 를 이용하여 토크나이징을 할 수 있습니다.

tokens = encoder.tokenize(sent)

save 와 load 는 다음과 같습니다. model_path 를 입력합니다.

encoder.save(model_path)

loaded_encoder = BytePairEncoder()
loaded_encoder.load(model_path)

2016-10-20 의 뉴스를 이용하여 5,000 개의 subword units 을 학습한 뒤, 토크나이징을 하였습니다.

sent = '오패산터널 총격전 용의자 검거 서울 연합뉴스 경찰 관계자들이 19일 오후 서울 강북구 오패산 터널 인근에서 사제 총기를 발사해 경찰을 살해한 용의자 성모씨를 검거하고 있다 성씨는 검거 당시 서바이벌 게임에서 쓰는 방탄조끼에 헬멧까지 착용한 상태였다'

encoder.tokenize(sent)

오패산터널의 경우, 잘 등장하지 않은 단어이기 때문에 모든 단어가 글자들로 나뉘어 집니다. ‘용의자’는 빈도수 상위 5000 등 안에 들지 못하였습니다. 그러나 ‘의자’는 5000 등 안에 들었기 때문에 하나의 subword unit 이 되었습니다. ‘연합뉴스’는 뉴스 문서에서는 매우 빈번한 단어이기 때문에 하나의 단어임을 확인할 수 있습니다.

오 패 산 터 널_ 총 격 전_ 용 의자 _ 검 거_ 서울_ 연합뉴스_ 경찰_ 관계 자들이_ 19일_ 오후_ 서울_ 강 북 구_ 오 패 산_ 터 널_ 인근 에서_ 사제 _ 총 기를_ 발사 해_ 경찰 을_ 살 해 한_ 용 의자 _ 성 모 씨를_ 검 거 하고_ 있다_ 성 씨는_ 검 거_ 당시_ 서 바이 벌_ 게임 에서_ 쓰 는_ 방탄 조 끼 에_ 헬 멧 까지_ 착 용한_ 상태 였다_

의미적으로 ‘용’ + ‘의자’ = ‘용의자’는 아닙니다. 하지만 composition 을 통하여 ‘용의자’의 의미를 파악하는 것은 더 이상 토크나이저의 몫은 아닙니다. 이 부분은 번역기, 문서 판별기 등의 알고리즘의 몫입니다.

Codes (Google release)

이 챕터는 2019-03-20 에 업데이트 되었습니다

Google 에서도 sentencepiece 라는 이름으로 코드를 공개하였습니다. Github 에는 C++ 로 구현된 코드가 구현되어 있으며, PyPI 는 이 코드를 파이썬에서 subprocess 를 띄어 스크립트로 이용하는 패키지 입니다.

Google 에서도 sentencepiece 라는 이름으로 Word Piece Model package 를 공개하였습니다. 설치는 pip install 로 가능합니다. 실습 환경에서의 버전은 sentencepiece==0.1.8 입니다.

pip install sentencepiece

학습 데이터는 빈 칸이 포함되지 않은 문서 집합입니다. 우리의 실습 데이터에는 정규화 과정에서 한문으로만 이뤄진 기사의 텍스트가 지워졌기 때문에 빈 줄이 포함되어 있습니다. 이 빈 줄을 제거하여 spm_input.txt 파일을 만듭니다.

import sentencepiece as spm

input_file = 'spm_input.txt'

corpus = ['list of string', 'should be not empty']
with open(input_file, 'w', encoding='utf-8') as f:
    for sent in corpus:
        f.write('{}\n'.format(sent))

Sentencepiece 는 C++ 로 구현되어 있으며, subprocess 를 이용하여 파이썬에서 스크립트를 실행시키는 방식입니다. Input 은 반드시 텍스트 파일이어야 합니다. command message 는 input, prefix, vocab_size 를 입력해야 합니다.

IPython notebook 에서는 파이썬 스크립트에서 오류가 날 경우 커널이 그대로 죽습니다. 파이썬 인터프리터에서 직접 작업하면 코드의 오류를 볼 수 있습니다. vocab_size 가 지나치게 작게 설정되면 모델이 학습을 하다 오류를 발생시킵니다. 당황하지 마시고 vocab_size 를 키우면 됩니다. 저는 2016-10-20 뉴스 기사를 이용하여 2000 개의 vocabulary (subwords) 를 학습하였습니다.

templates = '--input={} --model_prefix={} --vocab_size={}'

vocab_size = 2000
prefix = '2016-10-20-news'
cmd = templates.format(input_file, prefix, vocab_size)

spm.SentencePieceTrainer.Train(cmd)

학습이 끝나면 2016-10-20-news 라는 prefix 를 지니는 .model 파일과 .vocab 파일이 만들어집니다. 학습된 모델을 이용하려면 .model 을 로딩합니다.

sp = spm.SentencePieceProcessor()
sp.Load('{}.model'.format(prefix))

문장을 입력하면 subword list 로 출력할 수 있습니다.

sp.EncodeAsPieces('오늘의 연합뉴스 기사입니다')
# ['▁오늘', '의', '▁연합뉴스', '▁기사', '입니다']

혹은 subword idx list 로 출력할 수도 있습니다.

sp.EncodeAsIds('오늘의 연합뉴스 기사입니다')
# [870, 6, 442, 786, 433]

.vocab 에서 학습된 subwords 를 확인할 수도 있습니다. 설정한대로 2000 개의 subwords 가 학습되었습니다.

with open('{}.vocab'.format(prefix), encoding='utf-8') as f:
    vocabs = [doc.strip() for doc in f]

print('num of vocabs = {}'.format(len(vocabs))) # 2000

일부를 실제로 학인해봅니다. Vocabulary 에는 unknown, 문장의 시작, 문장의 끝, 단어의 앞부분 등 네 가지의 special token 이 있으며, 그 외에는 , , , 과 같은 조사들이 뒤따릅니다. _기자, _재배포 와 같은 도메인에서 자주 이용되는 단어들도 포함되어 있습니다. 앞서 가 6 번으로 encode 된 것을 보았습니다. 아래의 예시에서 <unk>, <s>, </s> 가 각각 0, 1, 2 번 subword 입니다. 는 6 번 subword 임도 확인할 수 있습니다.

<unk>	0
<s>	0
</s>	0
▁	-2.31113
을	-4.05915
이	-4.09143
의	-4.2983
는	-4.38238
에	-4.42689
가	-4.55528
...
▁무단	-6.22084
▁서울	-6.22214
회	-6.24648
면	-6.26359
소	-6.26558
치	-6.26763
계	-6.26962
조	-6.27293
▁3	-6.27301
트	-6.28715
적	-6.30156
▁금지	-6.30178
▁재배포	-6.32423
...

Sentiment classification

네이버 영화 댓글과 평점을 이용하여 영화 평의 긍/부정을 구분하는 감성 분석을 수행하였습니다.

영화평의 감성 분석을 위해서는 데이터 전처리가 필요합니다. 영화 평점은 1 ~ 10 점을 지닙니다. 사람마다 점수의 기준이 다르기 때문에 4 ~ 7 점은 긍/부정을 명확히 판단하기 어려워 제외하였습니다. 또한 1 ~ 3 점을 서로 다른 클래스로 구분하는 것도 큰 의미가 없습니다. 그렇기 때문에 1 ~ 3 점은 negative, 8 ~ 10 점은 positive 로 레이블을 부여하였습니다.

성능의 이해를 위하여 KoNLPy 의 트위터 한국어 분석기와 성능을 비교하였습니다. Unigram 만 이용한 경우와 uni, bigram 을 함께 이용한 성능을 측정하였습니다. 문서 분류에서는 bigram 이 유용하다고 널리 알려져 있습니다. WPM 옆의 숫자는 units 의 개수입니다.

Tokenizer unigram accuracy uni + bigram accuracy
WPM 3000 89.12 % 92.67 %
WPM 5000 89.56 % 92.95 %
WPM 10000 91.69 % 93.47 %
WPM 20000 92.23 % 93.41 %
WPM 30000 92.43 % 93.35 %
WPM 50000 92.65 % 93.32 %
트위터 한국어 분석기 91.91 % 93.39 %

트위터 한국어 분석기는 unigram 만 이용하여도 91.9 % 의 성능을 보입니다. Word Piece Model 의 unigram 의 경우, unit 이 많을수록 성능이 올라갑니다. 20K 의 units 을 이용하면 오히려 트위터 한국어 분석기보다도 좋은 성능을 보입니다. 이는 빈번하게 등장하면서도 긍/부정을 판단하는데 유용한 subwords 가 존재하는데, 트위터 한국어 분석기는 이를 형태소들로 분해하였다는 의미입니다.

Word Piece Model 도 bigram 을 함께 이용하면 성능이 올라갑니다. 감성 분석은 bigram 이 중요한 features 입니다. ‘재미’라는 단어는 긍/부정을 판단하기 어렵습니다. 뒤에 따라오는 단어와 함께 ‘재미 + 없다’ / ‘재미 + 있다’ 처럼 bigram 이 이뤄져야 유의미한 features 가 됩니다. 트위터 한국어 분석기는 ‘재미없다 -> 재미 + 없다’로 나눕니다. 그렇기 때문에 bigram 을 이용하면 성능이 증가합니다. 91.91 % 93.39 % 로 증가하였습니다.

그러나 Word Piece Model 을 이용하여도 성능의 상한선이 존재합니다. 10K 과 bigram 을 이용할 때 최고의 정확도인 93.47 % 을 보이며, 그 이후에는 bigram 을 이용하여도 성능이 내려갑니다. 과적합 (over-fitting) 의 문제라고 예상됩니다.

또한 50K 의 유닛을 이용할 때와 3K 의 유닛과 bigram 을 이용할 때의 성능이 92.65 % 와 92.67 % 로 비슷합니다. 실험을 수행할 때, 빈도수 20 이하의 uni, bigram 은 모두 제거를 하였습니다. 그 결과 3K 의 유닛과 uni + bigram 를 이용할 때의 차원은 56K 입니다. Bigram 으로 선택된 features 의 개수가 50K units 을 이용하는 WPM 의 unigram 과 비슷합니다. 즉, units 의 개수를 늘리면 bigram 으로 만들어져야 할 features 가 unigram 으로 만들어지는 효과를 얻습니다.

References