본문 바로가기

AI/자연어처리(NLP)

한국어 자연어처리 1편_서브워드 구축(Subword Tokenizer, Mecab, huggingface VS SentencePiece)

실습 코드(jupyter notebook) : github.com/keep-steady/NLP_for_korean.git

 

자연어처리를 오랫동안 하다 보니 나만의 한글 데이터셋에 Bert, Transformer 등 새로운 모델의 입력을 만들어야 할 일이 많다. 하지만 길고 긴 Pretraining을 수행 전 subword vocab을 만드는 건 정말 중요하다. 아무리 Pretraining을 잘 수행했어도 subword의 품질이 최종 성능에 많은 영향을 미친다. 특히 vocab은 중간에 바꿀 수 없어서 몇 달간 학습한 Pretraining 결과도 쓸모없어지는 수가 있다.

필자 이전 구글의 sentencepiece를 쭉 이용해와서 새롭고 빠른 tokenizer가 나왔다 해도 별 생각이없었다. 얼마나 빠르길래? 이미 만들어놨는데 굳이 다시 구축하고 싶지가 않았다.

 

Subword Tokenization(한국어 서브워드 분절)

Rico Sennrich의 'Neural Machine Translation of Rare Words with Subword Units'에서 Out-Of-Vocabulary 문제를 해결하기 위해 BPE(Byte-Pair Encoding Tokenization)가 고안되었다. 이로 인해 자연어처리의 가장 골치 아픈 OOV 문제를 해결하고 성능 또한 개선되었다. 최근 Transformer, BERT, ELECTRA 등 모든 모델들이 Subword 분절 방식을 사용하고 있다. Subword 분절 방식엔 BPE, SentencePiece, WordPiece 등 작은 변형들이 존재한다.

하지만 그렇다고 BPE가 완벽하게 OOV를 해결하진 못한다. 코퍼스에서 몇번 등장하지 않는 단어들은 굳이 vocab에 포함하지 않는 경우도 있다. ETRI의 KoBERT는 5번 미만 등장한 단어는 포함시키지 않았다. 즉 이 단어들은 OOV를 야기할 수도 있다. 이모티콘, 한자, 일본어, 아랍어 등등이 이에 해당된다. 이들을 어떻게 처리할 것인가가 성능에 큰 영향을 미친다.

SentencePiece VS Huggingface tokenizer

한국어 자연어처리에 BERT, Transformer를 사용하기 위해서는 개인 한글 데이터의 subword 분절을 직접 구축해야 한다.

구글의 sentencepiece, opennmt, huggingface등의 알고리즘 중 어느 것을 쓸 것인가 항상 고민이다. 본 글에서는 네이버의 NSMC 코퍼스를 이용하여 한국어 subword(BPE)를 구축한다. 구글의 SentencePiece와 Huggingface를 비교해보자

 

1. 데이터셋 준비

NSMC 데이터를 준비한다. nan 값이 있는 행은 제거하고 문장 list, 라벨 list를 구축한다. 라벨은 '0', '1'로 구성되어 있고 '0'이 부정, '1'이 긍정이다. NSMC 코퍼스는 용량 13MB, 총 15만 문장으로 문장당 평균 7.6 단어, 113만 단어로 구성된다.

 

데이터 예시)

  • - 문장1 : '아 더빙.. 진짜 짜증나네요 목소리' - 부정
  • - 문장2 : '흠...포스터보고 초딩영화줄....오버연기조차 가볍지 않구나' - 긍정
  • - 문장3 : '너무재밓었다그래서보는것을추천한다' - 부정
%%time
# NSMC 데이터 로드
import pandas as pd
f_train = pd.read_csv('data/nsmc.txt', sep='\t')
train_pair = [(row[1], row[2]) for _, row in f_train.iterrows() if type(row[1]) == str]  # nan 제거

#  문장 및 라벨 데이터 추출
train_data  = [pair[0] for pair in train_pair]
train_label = [pair[1] for pair in train_pair]
print('data loading done!')
print('문장: %s' %(train_data[:3]))
print('라벨: %s' %(train_label[:3]))

# subword 학습을 위해 문장만 따로 저장
with open('data/train_tokenizer.txt', 'w', encoding='utf-8') as f:
    for line in train_data:
        f.write(line+'\n')

# subword 학습을 위해 문장만 따로 저장
with open('data/train_tokenizer.txt', 'r', encoding='utf-8') as f:
    test_tokenizer = f.read().split('\n')
print(test_tokenizer[:3])

num_word_list = [len(sentence.split()) for sentence in test_tokenizer]
print('\n코퍼스 평균/총 단어 갯수 : %.1f / %d' % (sum(num_word_list)/len(num_word_list), sum(num_word_list)))

 

2. 구글의 SentencePiece 학습

구글의 bert를 처음 사용할 때 이 알고리즘을 사용했었다. 사용법이 간편하고 쉬워서 쓰기 좋다. 특별히 속도가 중요하지 않아서 고려하지 않았었다.

 

2.1. 설치

pip install sentencepiece==0.1.83

2.2. BPE 학습

아래 코드는 내 데이터의 BPE를 학습하는 코드이다.

  • input_file : 내 한국어 데이터 경로를 지정한다.
  • vocab_size : BPE의 단어수를 얼마로 할 것인가 이다. 너무 적으면 한 글자 단위로 쪼개지는 경향이 있고, 너무 많으면 쓸데없는 단어들이 만들어진다. 주로 3,2000이 가장 좋다고 알려져 있다.
  • model_name : 저장할 이름이다. 학습하고 나면 <model_name>.model, <model_name>.vocab 2개의 파일이 만들어진다.
  • model_type : bpe, unigram 등이 있는데 두가지를 모두 사용해 보고 성능이 좋은 거로 고르도록 하자. 
  • character_coverage : 모든 단어를 커버할것인가, 너무 희귀한 단어는 뺄 것인가 이다. 학습 코퍼스가 대용량이라면 보통 0.9995로 사용하면 된다. 그런데 코퍼스가 작다면 1.0으로 지정하자. 그럼 [UNK]가 없다.
  • user_defined_symbols : BPE로 생성된 단어 외 알고리즘에서 사용할 특수문자들을 지정한다.

BERT 기반 요약, 번역모델 개발 시 BOS, EOS, SEP 등 추가 토큰들이 필요하다. 그러므로 user_defined_symbols을 이용해 Dummy token(UNK, unused, BOS, EOS 등등)을 추가해준다. 필자는 HTTP나 network traffic에 BPE를 적용해야 할 경우가 있다. 이때 user_defined_symbols 인자에 'http, GET, POST, User-Agent 등 HTTP 데이터에서 자주 나오는 Token들을 지정하기도 했다.

 

필자는 jupyter notebook에서 학습하므로 아래와 같이 코드를 작성했다. cmd에 입력으로 줄 argument가 들어간다.

vocab_size에 비례하여 학습시간이 결정된다. 필자는 32,000으로 지정하여 노트북에서 대략 43sec가 소요됐다.

%%time
import sentencepiece as spm

# spm_train --input=data/train_tokenizer.txt  --model_prefix=sentencepiece/sp --vocab_size=32000 character_coverage=1.0 --model_type="unigram"

input_file = 'data/train_tokenizer.txt'
vocab_size = 32000

sp_model_root='sentencepiece'
if not os.path.isdir(sp_model_root):
    os.mkdir(sp_model_root)
sp_model_name = 'tokenizer_%d' % (vocab_size)
sp_model_path = os.path.join(sp_model_root, sp_model_name)
model_type = 'unigram'  # 학습할 모델 선택, unigram이 더 성능이 좋음'bpe'
character_coverage  = 1.0  # 전체를 cover 하기 위해, default=0.9995
user_defined_symbols = '[PAD],[UNK],[CLS],[SEP],[MASK],[BOS],[EOS],[UNK0],[UNK1],[UNK2],[UNK3],[UNK4],[UNK5],[UNK6],[UNK7],[UNK8],[UNK9],[unused0],[unused1],[unused2],[unused3],[unused4],[unused5],[unused6],[unused7],[unused8],[unused9],[unused10],[unused11],[unused12],[unused13],[unused14],[unused15],[unused16],[unused17],[unused18],[unused19],[unused20],[unused21],[unused22],[unused23],[unused24],[unused25],[unused26],[unused27],[unused28],[unused29],[unused30],[unused31],[unused32],[unused33],[unused34],[unused35],[unused36],[unused37],[unused38],[unused39],[unused40],[unused41],[unused42],[unused43],[unused44],[unused45],[unused46],[unused47],[unused48],[unused49],[unused50],[unused51],[unused52],[unused53],[unused54],[unused55],[unused56],[unused57],[unused58],[unused59],[unused60],[unused61],[unused62],[unused63],[unused64],[unused65],[unused66],[unused67],[unused68],[unused69],[unused70],[unused71],[unused72],[unused73],[unused74],[unused75],[unused76],[unused77],[unused78],[unused79],[unused80],[unused81],[unused82],[unused83],[unused84],[unused85],[unused86],[unused87],[unused88],[unused89],[unused90],[unused91],[unused92],[unused93],[unused94],[unused95],[unused96],[unused97],[unused98],[unused99]'

input_argument = '--input=%s --model_prefix=%s --vocab_size=%s --user_defined_symbols=%s --model_type=%s --character_coverage=%s'
cmd = input_argument%(input_file, sp_model_path, vocab_size,user_defined_symbols, model_type, character_coverage)

spm.SentencePieceTrainer.Train(cmd)
print('train done')

 

2.3. 학습한 BPE 모델을 load 하여 subword tokenize 수행

학습시 지정하였던 model_name을 sp.load의 입력으로 하여 tokenizer를 불러온다.

sentencepiece_tokenizer 함수에 원하는 문장('나는 오늘 아침밥을 먹었다.')을 입력으로 주자. out_type=str로 할 시, out_type=int로 할 시 idx로 출력된다. 추후 BERT, Transformer를 사용할 때는 idx를 입력으로 줘야 한다.  

## check
import sentencepiece as spm
sp = spm.SentencePieceProcessor()
sp.Load('{}.model'.format(sp_model_path))

tokens = sp.encode_as_pieces('나는 오늘 아침밥을 먹었다.')
ids = sp.encode_as_ids('나는 오늘 아침밥을 먹었다.')

print(ids)
print(tokens)

tokens = sp.decode_pieces(tokens)
ids = sp.decode_ids(ids)

print(ids)
print(tokens)

'나는 오늘 아침밥을 먹었다.' => ['▁나는', '▁오늘', '▁아침', '밥', '을', '▁먹', '었다', '.']

'나는 오늘 아침밥을 먹었다.' => [4284, 552, 4269, 30456, 29636, 2570, 371, 29631]

['▁나는', '▁오늘', '▁아침', '밥', '을', '▁먹', '었다', '.'] => '나는 오늘 아침밥을 먹었다.'

[4284, 552, 4269, 30456, 29636, 2570, 371, 29631] => '나는 오늘 아침밥을 먹었다.'

 

3. Huggingface tokenizer

Huggingface는 BERT, BART, ELECTRA 등등의 최신 자연어처리 알고리즘들을 TF, Torch로 구현한 transformers repository로 유명하다. 그리고 최근 tokenizers repository를 공개했다. 

요즘 주로 많이 사용되는 tokenizer 들을 성능과 다양성에 초점을 맞춰 Rust 언어를 사용하여 구현하였다.

Huggingface tokenizer는 아래 4가지 Tokenizer를 제공한다. 일반 BPE, Byte level BPE, SentencePiece, WordPiece이다. 

  • CharBPETokenizer: The original BPE
  • ByteLevelBPETokenizer: The byte level version of the BPE
  • SentencePieceBPETokenizer: A BPE implementation compatible with the one used by SentencePiece
  • BertWordPieceTokenizer: The famous Bert tokenizer, using WordPiece

큰 장점은 Rust로 구현되어 1GB corpus에 대해 cpu로 20초만에 학습할 수 있다. 물론 tokenization도 빠르다. 그럼 이제 얼마나 빠르고, 좋고, 편한지 WordPiece를 직접 구현해 보자.

 

WordPiece

사실 BERT, ELECTRA는 Wordpiece로 wordpiece를 사용했다. 하지만 구글은 구현체를 공개되지 않고 아래와 같이 3가지 다른 방식을 제시한다. 그래서 대부분의 한국어 NLP 개발자들은 SentencePiece를 사용한다. SentencePiece와 WordPiece, BPE는 약간의 차이가 있지만 비슷한 알고리즘이다.

그럼 이제 한국어 corpus에 WordPiece tokenizer를 구축해 보자.

 

3.1. 설치(210319 기준 최신버전)

pip install transformers==4.4.1
pip install tokenizers==0.10.1

 

3.2. Mecab 기반 형태소 단위 분절

 

한국어는 교착어이므로 명사에 조사가 붙어있는 경우가 많고 복합명사도 많다. 그래서 의미단어로 교착어를 분리시키면 좋은 tokenizer를 얻고 성능도 좋아진다. 예를들면 '어릴때' -> '어릴, 때' 로 분리시켜 줄 필요가 있다.

'어릴때보고 지금다시봐도 재밌어요ㅋㅋ'

   -> ['어릴', '때', '보', '고', '지금', '다시', '봐도', '재밌', '어요', 'ㅋㅋ']

아래 코드는 mecab 형태소 분석기를 이용해 분절한 후 data/after_mecab.txt에 저장하는 코드이다.

%%time

# load korean corpus for tokenizer training
with open('data/train_tokenizer.txt', 'r', encoding='utf-8') as f:
    data = f.read().split('\n')
print(data[:3])

# mecab for window는 아래 코드 사용
from konlpy.tag import Mecab  # install mecab for window: https://hong-yp-ml-records.tistory.com/91
mecab_tokenizer = Mecab(dicpath=r"C:\mecab\mecab-ko-dic").morphs
print('mecab check :', mecab_tokenizer('어릴때보고 지금다시봐도 재밌어요ㅋㅋ'))

for_generation = False # or normal

if for_generation:
    # 1: '어릴때' -> '어릴, ##때' for generation model
    total_morph=[]
    for sentence in data:
        # 문장단위 mecab 적용
        morph_sentence= []
        count = 0
        for token_mecab in mecab_tokenizer(sentence):
            token_mecab_save = token_mecab
            if count > 0:
                token_mecab_save = "##" + token_mecab_save  # 앞에 ##를 부친다
                morph_sentence.append(token_mecab_save)
            else:
                morph_sentence.append(token_mecab_save)
                count += 1
        # 문장단위 저장
        total_morph.append(morph_sentence)

else:
    # 2: '어릴때' -> '어릴, 때'   for normal case
    total_morph=[]
    for sentence in data:
        # 문장단위 mecab 적용
        morph_sentence= mecab_tokenizer(sentence)
        # 문장단위 저장
        total_morph.append(morph_sentence)
                        
print(total_morph[:3])
print(len(total_morph))

# mecab 적용한 데이터 저장
# ex) 1 line: '어릴 때 보 고 지금 다시 봐도 재밌 어요 ㅋㅋ'
with open('data/after_mecab.txt', 'w', encoding='utf-8') as f:
    for line in total_morph:
        f.write(' '.join(line)+'\n')

 

3.3. Special tokens 선언

 

special_tokens : 언어모델의 범용성을 위해 Dummy token, 여러 개의 [unused]와 [UNK]를 꼭 설정해야 한다. 언어모델에 번역, 요약, 개체명 인식 모델을 fine-tuning시 Dummy token이 필요한 경우가 많다. 또 도메인 특화된 task를 수행할 땐 도메인 토큰을 따로 선언하는게 필수이다. 이를 고려하지 않으면 편법을 사용해야 한다. ETRI의 KorBERT는 Dummy token이 없어 빈도수가 작은 token을 Dummy로 대체해서 쓰기도 한다. 꼭 충분한 unused와 UNK를 설정하자. 그리고 BOS(문장 시작), EOS(문장 끝) 등등도 추가하자. 본 프로젝트에선 10개의 UNK와 200개의 unsed token을 선언하였다.

 

## 1) define special tokens
user_defined_symbols = ['[BOS]','[EOS]','[UNK0]','[UNK1]','[UNK2]','[UNK3]','[UNK4]','[UNK5]','[UNK6]','[UNK7]','[UNK8]','[UNK9]']
unused_token_num = 200
unused_list = ['[unused{}]'.format(n) for n in range(unused_token_num)]
user_defined_symbols = user_defined_symbols + unused_list

print(user_defined_symbols)

 

3.4. Tokenizer 모델 선정

 

Huggingface의 Tokenizer는 4가지 분절 학습 모델을 제공한다.

1) CharBPETokenizer : The original BPE

2) ByteLevelBPETokenizer : The byte-level version of the BPE

3) BertWordPieceTokenizer : The famous Bert tokenizer, using WordPiece
4) SentencePieceBPETokenizer : A BPE implementation compatible with the one used by SentencePiece

GPT와 Roberta는 ByteLevelBPETokenizer를 이용하고 BERT는 BertWordPieceTokenizer를이용한다.

하지만 BERT의 저자는 WordPiece의 구현체를 공개하지 않아 SentencePiece를 사용하길 추천하기도 한다. SentencePieceBPETokenizer는 구글의 SentencePiece 구현체 이다.

 

tokenizers로 부터 4가지 tokenizer 모델(BertWordPieceTokenizer, SentencePieceBPETokenizer, CharBPETokenizer, ByteLevelBPETokenizer)을 불러온 후 기호에 맞게 선택해서 사용하면 된다. 본 글은 Bert의 학습 분절인 BertWordPieceTokenizer 기준으로 설명하겠다.

BertWordPieceTokenizer로 학습시킬 땐 lower_case=False 시 strip_accent=False로 지정해야만 한다!! 이거하나때매 이틀을 날렸다ㅠㅠ 나중에 huggingface transformer에서 tokenzizer를 load할 때도 strip_accent=False를 꼭 지정해야한다!!

%%time
## 2) train
import os
from tokenizers import BertWordPieceTokenizer, SentencePieceBPETokenizer, CharBPETokenizer, ByteLevelBPETokenizer

# 4가지중 tokenizer 선택
how_to_tokenize = BertWordPieceTokenizer  # The famous Bert tokenizer, using WordPiece
# how_to_tokenize = SentencePieceBPETokenizer  # A BPE implementation compatible with the one used by SentencePiece
# how_to_tokenize = CharBPETokenizer  # The original BPE
# how_to_tokenize = ByteLevelBPETokenizer  # The byte level version of the BPE

# Initialize a tokenizer
if str(how_to_tokenize) == str(BertWordPieceTokenizer):
    print('BertWordPieceTokenizer')
    ## 주의!! 한국어는 strip_accents를 False로 해줘야 한다
    # 만약 True일 시 나는 -> 'ㄴ','ㅏ','ㄴ','ㅡ','ㄴ' 로 쪼개져서 처리된다
    # 학습시 False했으므로 load할 때도 False를 꼭 확인해야 한다
    tokenizer = BertWordPieceTokenizer(strip_accents=False,  # Must be False if cased model
                                       lowercase=False)
elif str(how_to_tokenize) == str(SentencePieceBPETokenizer):
    print('SentencePieceBPETokenizer')
    tokenizer = SentencePieceBPETokenizer()

elif str(how_to_tokenize) == str(CharBPETokenizer):
    print('CharBPETokenizer')
    tokenizer = CharBPETokenizer()
    
elif str(how_to_tokenize) == str(ByteLevelBPETokenizer):
    print('ByteLevelBPETokenizer')
    tokenizer = ByteLevelBPETokenizer()
       
else:
    assert('select right tokenizer')

 

3.5. Tokenizer 학습

 

Huggingface의 Tokenizer trainer는 학습시 다음과 같은 인자를 갖는다

  • min_frequency : merge를 수행할 최소 빈도수, 5로 설정 시 5회 이상 등장한 pair만 수행한다
  • vocab_size: 만들고자 하는 vocab의 size, 보통 '32000' 정도가 좋다고 알려져 있다
  • show_progress : 학습 진행과정 show
  • special_tokens : Tokenizer에 추가하고 싶은 special token 지정
  • limit_alphabet : merge 수행 전 initial tokens이 유지되는 숫자 제한
  • initial_alphabet : 꼭 포함됐으면 하는 initial alphabet, 이곳에 설정한 token은 학습되지 않고 그대로 포함되도록 설정된다.

SentencePiece에서 coverage와 유사하게 [UNK]를 줄이기 위해 모든 글자를 커버하도록 limit_alphabet를 큰 값으로 지정한다.

 

corpus_file   = ['data/after_mecab.txt']  # data path
vocab_size    = 32000
limit_alphabet= 6000
output_path   = 'hugging_%d'%(vocab_size)
min_frequency = 5

# Then train it!
tokenizer.train(files=corpus_file,
               vocab_size=vocab_size,
               min_frequency=min_frequency,  # 단어의 최소 발생 빈도, 5
               limit_alphabet=limit_alphabet,  # ByteLevelBPETokenizer 학습시엔 주석처리 필요
               show_progress=True)
print('train complete')

sentence = '나는 오늘 아침밥을 먹었다.'
output = tokenizer.encode(sentence)
print(sentence)
print('=>idx   : %s'%output.ids)
print('=>tokens: %s'%output.tokens)
print('=>offset: %s'%output.offsets)
print('=>decode: %s\n'%tokenizer.decode(output.ids))

sentence = 'I want to go my hometown'
output = tokenizer.encode(sentence)
print(sentence)
print('=>idx   : %s'%output.ids)
print('=>tokens: %s'%output.tokens)
print('=>offset: %s'%output.offsets)
print('=>decode: %s\n'%tokenizer.decode(output.ids))

# save tokenizer
hf_model_path='tokenizer_model'
if not os.path.isdir(hf_model_path):
    os.mkdir(hf_model_path)
tokenizer.save_model(hf_model_path)  # vocab.txt 파일 한개가 만들어진다

20만 문장(19MB)을 학습하는데 노트북에서 5sec밖에 걸리지 않았다. 위 코드는 학습 후 모델 결과를 체크하고 저장하는 코드이다. 원하는 저장 폴더에 'vocab.txt'가 저장되고 학습된 단어들이 담겨있다.

 

3.6. WordPiece 분절 결과 비교

 

1) Without Mecab

"나는 오늘 아침밥을 먹었다." =>

 

  • tokens: ['나는', '오늘', '아침', '##밥', '##을', '먹', '##었다', '.']
  • idx : [6227, 6390, 7860, 4476, 3291, 1474, 5870, 18]
  • offset: [(0, 2), (3, 5), (6, 8), (8, 9), (9, 10), (11, 12), (12, 14), (14, 15)]
  • decode: 나는 오늘 아침밥을 먹었다.

2) With Mecab

"나는 오늘 아침밥을 먹었다." =>

  • tokens: ['나', '##는', '오늘', '아침', '##밥', '##을', '먹', '##었다', '.']
  • idx : [875, 3391, 5446, 6142, 3488, 3421, 1474, 17168, 18]
  • offset: [(0, 1), (1, 2), (3, 5), (6, 8), (8, 9), (9, 10), (11, 12), (12, 14), (14, 15)]
  • decode: 나는 오늘 아침밥을 먹었다.

위 결과는 두 문장의 서브워드분절 결과이다. '아침밥'=>'아침'+'##밥' 으로, '먹었다'=>'먹'+'##었다' 으로 쪼개진 것을 확인 할 수 있다. BERT나 ELECTRA에서 바로 쓸 수 있도록 문장 앞에 문장 시작 부호인 '[CLS]', 문장 마지막에 문장 끝을 나타내는 '[SEP]'가 자동으로 추가된다. 실제 학습 시 token이나 offset이 아닌 idx를 입력으로 학습한다. 하지만 tokenize 후 꼭 tokens들을 확인하여 학습이 제대로 됐는제, 분절모델 경로가 맞는지 등을 확인하자.

Mecab을 적용하지 않았을 때는 '나는'이 분절되지 않고 그대로 쓰이는걸 볼 수 있다. 하지만 Mecab을 적용해서 학습하면 '나는'->['나', '##는'] 으로 분절하여 교착어의 단점을 극복할 수 있다.

 

3.7. Transformer에서 사용가능한지 체크

BertTokenizerFast 는 학습한 환경과 똑같이 strip_accents=False 지정해야한다!! 이걸 빼먹어서 이틀을 버렸다...

from transformers import BertTokenizerFast

tokenizer_for_load = BertTokenizerFast.from_pretrained(hf_model_path,
                                                       strip_accents=False,  # Must be False if cased model
                                                       lowercase=False)  # 로드

print('vocab size : %d' % tokenizer_for_load.vocab_size)
# tokenized_input_for_pytorch = tokenizer_for_load("i am very hungry", return_tensors="pt")
tokenized_input_for_pytorch = tokenizer_for_load("나는 오늘 아침밥을 먹었다.", return_tensors="pt")
tokenized_input_for_tensorflow = tokenizer_for_load("나는 오늘 아침밥을 먹었다.", return_tensors="tf")

print("Tokens (str)      : {}".format([tokenizer_for_load.convert_ids_to_tokens(s) for s in tokenized_input_for_pytorch['input_ids'].tolist()[0]]))
print("Tokens (int)      : {}".format(tokenized_input_for_pytorch['input_ids'].tolist()[0]))
print("Tokens (attn_mask): {}\n".format(tokenized_input_for_pytorch['attention_mask'].tolist()[0]))

vocab size : 27302

 

"나는 오늘 아침밥을 먹었다." =>

  • Tokens (str) : ['[CLS]', '나', '##는', '오늘', '아침', '##밥', '##을', '먹', '##었다', '.', '[SEP]']
  • Tokens (int) : [2, 875, 3261, 5446, 6142, 3776, 3509, 1474, 17145, 18, 3]
  • Tokens (attn_mask): [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

vocab_size를 32000으로 지정했지만 학습데이터가 작아서 32000개를 다 채우지 못하고 학습이 끝났다. 이는 korean corpus를 더 늘려서 학습하면 된다. huggingface tokenizers에서 학습한 결과 그대로 huggingface transformers에서 잘 불러오는것을 확인 할 수 있다. token과 그 index, 그리고 attention_mask도 자동으로 생성해준다.

 

# vocab check
tokenizer_for_load.get_vocab()

=> {'동의': 16632, '##쒸': 4634, '##모르는': 14801, '기자': 9292, '시종일관': 11151, ...}

vocab이 잘 load 되었는지 확인해보자. 

 

# special token check
tokenizer_for_load.all_special_tokens # 추가하기 전 기본적인 special token

=> ['[UNK]', '[SEP]', '[PAD]', '[CLS]', '[MASK]']

special_tokens가 잘 load되어있는지 확인해보자. 기본 special_tokens만 확인되고, 내가 선언하여준 [UNK]나 [unused] token들은 빠져있다. huggingface에서 이를 수정해야 할것으로 보인다. special_tokens는 꼭! 꼭! 추가되어야만 한다!!

 

# tokenizer에 special token 추가
special_tokens_dict = {'additional_special_tokens': user_defined_symbols}
tokenizer_for_load.add_special_tokens(special_tokens_dict)

# check tokenizer vocab with special tokens
print('check special tokens : %s'%tokenizer_for_load.all_special_tokens[:20])

=> check special tokens : ['[UNK]', '[SEP]', '[PAD]', '[CLS]', '[MASK]', '[BOS]', '[EOS]', '[UNK0]', '[UNK1]', '[UNK2]', '[UNK3]', '[UNK4]', '[UNK5]', '[UNK6]', '[UNK7]', '[UNK8]', '[UNK9]', '[unused0]', '[unused1]', '[unused2]'], ....]

 

add_special_tokens 함수를 이용해 special_tokens를 추가했다. 왜 위 과정을 거치게 해야하는지, 완성도가 조금 떨어지는듯 하다. 아무튼 이렇게 해서 추가하고 밑의 코드로 저장해준다. transformers에서 저장하면 tokenizers에서 저장할때와 다르게 저장 파일 명과 개수가 다르다. 즉 tokenizers를 transformers에 연동?하기 위해 본 작업이 꼭 필요하다....하... 

 

# save tokenizer model with special tokens
tokenizer_for_load.save_pretrained(hf_model_path+'_special')

=> ('tokenizer_model_special\\tokenizer_config.json',

'tokenizer_model_special\\special_tokens_map.json',

'tokenizer_model_special\\vocab.txt',

'tokenizer_model_special\\added_tokens.json')

 

# check special tokens
from transformers import BertTokenizerFast
tokenizer_check = BertTokenizerFast.from_pretrained(hf_model_path+'_special')

print('check special tokens : %s'%tokenizer_check.all_special_tokens[:20])

print('vocab size : %d' % tokenizer_check.vocab_size)
tokenized_input_for_pytorch = tokenizer_check("나는 오늘 아침밥을 먹었다.", return_tensors="pt")
tokenized_input_for_tensorflow = tokenizer_check("나는 오늘 아침밥을 먹었다.", return_tensors="tf")

print("Tokens (str)      : {}".format([tokenizer_check.convert_ids_to_tokens(s) for s in tokenized_input_for_pytorch['input_ids'].tolist()[0]]))
print("Tokens (int)      : {}".format(tokenized_input_for_pytorch['input_ids'].tolist()[0]))
print("Tokens (attn_mask): {}\n".format(tokenized_input_for_pytorch['attention_mask'].tolist()[0]))

special_tokens를 추가하여 새로 저장한 폴더를, 다시 불러오면 special_tokens도 잘 있고 결과도 잘 되는것을 확인할 수 있다.

 

check special tokens : ['[UNK]', '[SEP]', '[PAD]', '[CLS]', '[MASK]', '[BOS]', '[EOS]', '[UNK0]', '[UNK1]', '[UNK2]', '[UNK3]', '[UNK4]', '[UNK5]', '[UNK6]', '[UNK7]', '[UNK8]', '[UNK9]', '[unused0]', '[unused1]', '[unused2]']
vocab size : 32000
Tokens (str)      : ['[CLS]', '나는', '오늘', '아침', '##밥', '##을', '먹', '##었다', '.', '[SEP]']
Tokens (int)      : [2, 6227, 6390, 7860, 4476, 3291, 1474, 5870, 18, 3]
Tokens (attn_mask): [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

 

# test to tf&pytorch bert model
from transformers import TFBertModel, BertModel

# load a BERT model for TensorFlow and PyTorch
model_tf = TFBertModel.from_pretrained('bert-base-cased')
model_pt = BertModel.from_pretrained('bert-base-cased')

 

그럼 이제 bert모델에 잘 들어가는지 직접 확인을 해보자. transformers에서 제공하는 bert-based-cased 모델을 불러온다. 우리는 한국어를 학습할거지만 지금은 입력이 제대로 들어가는지만 확인해보기 위해 영어모델이지만 그냥 불러와서 에러가 안나는지만 체크하자. tf 버전과 pytorch 버전의 두 모델을 불러온다.

 

## tf vs torch bert output
# transformers generates a ready to use dictionary with all the required parameters for the specific framework.
input_tf = tokenizer_check("나는 오늘 아침밥을 먹었다.", return_tensors="tf")
input_pt = tokenizer_check("나는 오늘 아침밥을 먹었다.", return_tensors="pt")

# Let's compare the outputs
output_tf, output_pt = model_tf(input_tf), model_pt(**input_pt)

print('final layer output shape : %s'%(output_pt['last_hidden_state'].shape,))

# Models outputs 2 values (The value for each tokens, the pooled representation of the input sentence)
# Here we compare the output differences between PyTorch and TensorFlow.

print('\ntorch vs tf 결과차이')
for name in ["last_hidden_state", "pooler_output"]:
    print("   => {} differences: {:.5}".format(name, (output_tf[name].numpy() - output_pt[name].detach().numpy()).sum()))

=> final layer output shape : torch.Size([1, 10, 768])

torch vs tf 결과차이

     => last_hidden_state differences: -7.618e-05

     => pooler_output differences: 4.0008e-06

 

load한 tokenizer를 입력형태로 만든다. return_tensors를 'tf'로 하면 tensorflow 입력형태, 'pt'로 지정하면 pytorch 입력형태로 자동 변환해준다. 매우 편하다. 그럼 이 두 입력을 각각 두 모델에 넣은 후 final layer의 output shape를 확인해 보자. 1x10x768로 배치사이즈x문장길이(token 개수)x임베딩사이즈로 임베딩이 잘 됨을 확인할 수 있다.  tf 모델과 pytorch 모델의 last_hidden_state 출력 차이를 확인해보자. 모든 값을 합친 차이가 e-5 이하인걸 보면 거의 차이가 나지 않음을 확인할 수 있다.

 

4. SentencePiece VS Huggingface tokenizer 비교

4.1. 학습시간 비교

아래 표는 vocab 사이즈 별 학습시간 비교 표 이다. SK의 vocab은 8000이므로 8000부터 2의 배수씩 128000까지 실험하였다. SentencePiece는 15만 문장을 학습시킬 때 vocab_size에 비례하여 학습시간이 늘어났다. 아래 그래프를 보면 더 잘 볼 수 있는데 vocab_size가 제곱으로 늘어날수록 시간도 제곱으로 늘어난다. 즉 vocab을 얼마로 잡느냐가 학습시간이다. 하지만 huggingface는 vocab_size가 증가해도 학습시간이 크게 바뀌지 않았다. NSMC 코퍼스를 12초 내로 학습할 수 있었다. 확실히 학습 속도에서는 huggingface가 SentencePiece를 압도했다.

 

SentencePiece VS huggingface 학습시간 비교표
SentencePiece VS huggingface 학습시간 그래프

4.2. 분절 시간 비교

NSMC 코퍼스로 학습된 각 모델들로 추론시간을 비교해봤다. 진짜 huggingface가 엄청나게 빠른가?? 의아했다. 아래 표는 15만 문장을 100번 분절하여 걸리는 시간의 평균 표이다. 추론 시 속도 차이는 크지 않았다. huggingface는 학습 시에도 vocab_size에 큰 영향을 받지 않았는데, 추론 시에도 비슷한 속도로 관찰된다. SentencePiece는 vocab_size가 크면 약간 속도가 느려졌다. 하지만 광고하는 것처럼 huggingface의 추론 속도가 SentencePiece에 비해 빠르진 않았다. 오히려 vocab이 적을 땐 SentencePiece가 빨랐다. SentencePiece도 C로 짜여있으므로 추론 시 비슷하다는 결론이다.

5. Conclusion

한국어 서브워드 분절을 위한 Sentencepiece와 huggingface tokenizer의 사용법을 정리하고 비교하였다. 학습시간 면에서는 vocab이 큰 경우 huggingface가 월등히 빨랐다. 하지만 일반적으로 vocab 사이즈는 32,000 정도를 사용하므로 4배 정도 차이이다. 추론 시간 비교에선 별 차이가 없었고 오히려 SentencePiece가 미소하게 빨랐다. 사실 vocab 학습이 자주 있는 일이 아니고 실시간으로 이루어질 필요가 없다. 그러므로 속도가 빠르다는 이유로 굳이 huggingface를 사용할 이유는 없다고 결론지었다.

 

다음장에서는 분절 알고리즘들을 달리하며 only huggingface VS only mecab VS mecab+huggingface의 성능을 비교할 예정이다.

 

Reference

https://github.com/huggingface/tokenizers

https://github.com/google/sentencepiece

https://github.com/monologg/KoELECTRA/blob/master/docs/wordpiece_vocab.md

https://github.com/huggingface/transformers