Deep Learning

한국어 토큰의 단위는 뭐가 좋을까?

둔진 2020. 4. 28. 14:36

  한국어 자연어 처리를 하다 보면 토큰의 단위를 뭐로 할지 고민이 될 때가 있습니다. 토큰을 어떻게 잡느냐에 따라 데이터의 크기도 달라지고, 아마도(?) 최종 시스템의 성능도 달라질 거고요. 그래서 이 아마도를 한번 확인해보기로 했습니다. 토큰에 따라 성능이 달라질까요?

  결론은 네 그렇습니다. 두 줄 요약.

  • 어절을 통으로 쓰는 것보다는 형태소나 Subword와 같은 작은 단위가 좋다.
  • 글자를 쓰느냐, 자소를 쓰느냐는 (특히 형태소 단위 토큰에서) 크게 유의미하지 않다.

 

  이번 글의 또다른 목적은 간단한 한국어 자연어 처리기를 나름 최신의 기술들을 사용해서 처음부터 끝까지 만드는 법을 보여드리는 겁니다. 그래서 전체 데이터와 코드를 포함했습니다.

시작하기 전에

  • 이 글은 읽으시는 분께서 Tensorflow, Machine Learning, Python, Linux 등을 어느 정도 아신다고 가정하고 있습니다. 세세한 기술 설명은 다른 글에서 하겠습니다. (왠지 많이 볼 것 같은 문장?)
  • 이 글은 모델 최적화, 파라미터 최적화에 대한 글이 아닙니다. 말 그대로 한국어 토큰은 뭐가 좋을지 가볍게(?) 이야기하는 글입니다.
  • 전체 예시 코드 : https://github.com/ceongjee-in/korean-tokenizer-experiment

1. 어절? 형태소? Subword?

  가장 직관적인 방법은 띄어쓰기로 구분하는 어절 단위입니다. 예를 들어, "아름다운 별 지구에 오신 것을 환영합니다."에서는 [아름다운, 별, 지구에, 오신, 것을, 환영합니다]가 토큰들이 됩니다. 영어 같은 언어에서는 간편하게 써볼 수 있는 방법입니다.

  하지만 한국어에 적용해보기에는 왠지 찜찜합니다. "지구에", "지구를", "지구가"를 모두 다른 토큰으로 취급해야할까요? 그렇다고 할 수도 있지만 엄마 뱃속에서부터 한국어를 듣고 자란 우리로서는 뭔가 찜찜합니다. 그래서 형태소 단위 토큰이 나옵니다. 언젠가 학교에서 배운 기억이 납니다. 형태소... 형태소... 말은 어렵지만 결국은 어절보다는 작지만 그래도 의미를 가진 단위입니다. 명사, 조사, 어미 이런 것들이죠. 이렇게 형태소 단위 토큰을 쓰면 "지구에"는 [지구, 에]로 더 쪼개집니다. "지구를"은 [지구, 를]이 될 거고요. 뭔가 좀 더 언어학자가 된 것 같고 그럴듯해 보입니다. 자연어 처리에서 형태소가 가지는 특징에 대해서는 다름에 기회가 되면 더 다뤄보겠습니다.

  영어는 그냥 띄어쓰기 단위로 토큰으로 해도 될 것 같지만 만족을 모르는 우리 인간은 영어에도 비슷한 방법을 적용합니다. subword tokenize를 하는데요. 말 그대로 단어를 그 보다 작은 단위로 쪼갭니다. 얼핏 보기에는 형태소처럼 보이지만 그렇지는 않습니다. 물론 형태소로 쪼개는 것도 하나의 subword이지만, 대부분의 방법은 의미는 고려치않고 통계적으로 단어를 쪼갭니다. 가장 대표적인 방법이 Byte Pair Encoding (https://en.wikipedia.org/wiki/Byte_pair_encoding) 이구요. 요즘 자연어 처리에서 많이 쓰는 sentencepiece (https://github.com/google/sentencepiece) 가 subword tokenizer를 훈련시키는 모듈입니다.

이 피스는 아니구요..

2. 글자? 자소?

  그런데 가만 생각해보니 초등학교(국민학교 아니고?) 때 우리 글자의 기본 단위는 글자가 아니라 자음, 모음이라고 배웠던 기억이 납니다. 세종대왕께서 고기를 엄청 드셔가시면서 나라말싸미하시면서 만드셨죠. 예를 들어 "집에 갔다"라고 할 때 왠지 "갔다"는 "가+ㅆ+다" 같은 느낌이 듭니다. 왜냐면 "가니?", "가구나", "간다"가 다 비슷한 의미인데 "가+나머지"인 것 같거든요. (사실 이건 형태소와 관련이 있지만 자세한 이야기는 다음에).

  그래서 곰곰이 생각해보다 토큰을 만들 때 글자가 아닌 자소(자음, 모음을 합쳐서 이렇게 부릅니다)로 해봐야겠다는 생각이 듭니다. 예를 들어, "지구에"는 "ㅈㅣㄱㅜㅇㅔ"가 되겠죠. 삼촌, 이모들이 옛날에 보셨다던 옛날 소설에서 본 글자들 같기도 하죠? (귀여니체 능력자 찾습니다.)

3. 궁금하면 500원이 아니고 확인해봅시다!

  궁금하면 확인을 해봐야죠. 실험 계획을 세웁니다.

실험 목표 : 토큰에 따라 자연어처리 시스템의 성능이 달라질까?

  토큰은 아래의 조합들로 해봅니다.

  어절 형태소 Subword
글자 O O O
자소 O O X

  O 표시된 5가지 경우를 테스트해봅니다. 왜 Subword + 자소는 안 했냐고요? 이건 나중에 연습문제로...

 

  최종 태스크로는 네이버 영화 리뷰 분석(https://github.com/e9t/nsmc)을 골랐습니다. 이유는 Sentiment Classification이라서 그렇게 어렵지도 않고, 쉽지도 않고요. Binary Classification이라서 좀 더 간단하기도 하고요. (한마디로 좀 편해서 골랐다는 이야기).

  그리고 토큰을 그대로 넣기보다는 word vector로 넣기로 합니다. word vector는 fastText(https://fasttext.cc/)를 골랐습니다. fastText라서인지 빠르거든요.

  별도 글로 네이버 영화 태스크와 fastText에 대해서는 다뤄보도록 하겠습니다. 이 모든 것을 다루기에는 이번 글이 너무 길어지니까요.

  실험을 구현하는데 있어서 아래 주의사항을 미리 정했습니다. 그렇지 않으면 괜히 최적화의 늪에 빠져서 허우적거리니까요.

  • 어디까지나 한국어 토큰의 영향을 보는 것이기 때문에 네트워크 아키텍처에는 너무 공을 들이지 않는다.
  • 그래도 아키텍쳐가 실험의 결과를 좌지우지하지 않을 정도의 구색은 갖춘다.
  • 마찬가지로 hyper parameter 튜닝에 너무 공을 들이지 않는다.

  이런 관점에서 기본값이 주어진 hyper parameter는 모두 기본값을 사용했습니다. (절대 귀찮아서가 아닙니다)

 

4. 코퍼스 정제

  네이버 영화 코퍼스(https://github.com/e9t/nsmc)의 훈련 데이터(https://github.com/e9t/nsmc/blob/master/ratings_train.txt)와 테스트 데이터(https://github.com/e9t/nsmc/blob/master/ratings_test.txt)를 받아서 corpus 디렉터리에 저장합니다.

 

  멋지고 예쁜 옷 입기 전에 샤워도 하고 양치도 해야 하듯이 먼저 코퍼스 정제를 간단히 해줍니다. 네이버 코퍼스는 중간에 빈 문장이 있는 경우가 있습니다. 첫 번째 줄은 실제 데이터가 아닌 헤더고요. 빈 문장과 첫 번째 헤더를 제외하고 저장합니다.

import sys

for i, line in enumerate(sys.stdin):
    if i == 0:
        continue
        
    line = line.strip()
    id, sentence, label = line.strip().split('\t')
    if sentence:
        print(line)

https://github.com/ceongjee-in/korean-tokenizer-experiment/blob/master/clean_corpus.py 

 

표준 입력으로 데이터를 받아서 표준 출력으로 내보내기 때문에 아래와 같이 실행하면 됩니다.

$ python clean_corpus.py < corpus/ratings_train.txt > corpus/train.txt
$ python clean_corpus.py < corpus/ratings_test.txt > corpus/test.txt

  이러면 corpus 디렉터리 아래에 각각 train.txt와 test.txt로 훈련 데이터와 테스트 데이터가 저장이 됩니다.

5. Tokenizer - 어절 단위

  먼저 어절 단위 Tokenizer를 만들어봅니다. 말이 어절 단위이지 그냥 띄어쓰기 단위로 쪼개면 됩니다.

def tokenize_by_eojeol_char(s):
    return s.split(' ')  

  첫번째 Tokenizer가 이렇게 간단히 만들어졌습니다.

  하지만 어절을 자소 단위로 표현하는 Tokenizer도 필요합니다. 먼저 한글을 자소로 쪼개 봅시다. Unicode 값을 이용해서 이리저리 변환하는 식인데 솔직히 자세한 로직은 모르셔도 됩니다. 이것도 기회가 되면 다음에 자세한 소개를...

NO_JONGSUNG = 'ᴕ'

CHOSUNGS = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']
JOONGSUNGS = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']
JONGSUNGS = [NO_JONGSUNG,  'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']

N_CHOSUNGS = 19
N_JOONGSUNGS = 21
N_JONGSUNGS = 28

FIRST_HANGUL = 0xAC00 #'가'
LAST_HANGUL = 0xD7A3 #'힣'

def to_jaso(s):        
    result = []
    for c in s:
        if ord(c) < FIRST_HANGUL or ord(c) > LAST_HANGUL: # if a character is a hangul
            result.append(c)
        else:            
            code = ord(c) - FIRST_HANGUL
            jongsung_index = code % N_JONGSUNGS
            code //= N_JONGSUNGS
            joongsung_index = code % N_JOONGSUNGS
            code //= N_JOONGSUNGS
            chosung_index = code

            result.append(CHOSUNGS[chosung_index])
            result.append(JOONGSUNGS[joongsung_index])
            result.append(JONGSUNGS[jongsung_index])
    
    return ''.join(result)  

  한가지 특이한 점은 NO_JONGSUNG입니다. 한국은 자음, 모음으로 이루어진 것이 맞지만, 저는 의미를 분석하는 데는 초성, 중성, 종성이 중요하다고 봅니다. 같은  ㄴ이지만 초성에 쓰일 때와 종성에 쓰일 때 다른 의미인 경우들이 있거든요. 그리고 종성이 없는 경우도 의미를 가진다고 생각합니다. 이 개념을 살리기 위해서 종성이 없는 경우는 특별한 기호로 표시를 하기로 했습니다. 예를 들어 "간다고"는 "ㄱㅏㄴㄷㅏ

ᴕㄱㅗᴕ"로 변환됩니다. ᴕ는 특별한 의미가 없습니다. 그냥 한국어에서 안 쓰는 기호를 아무거나 하나 선택했습니다.

  그럼 이제 어절+자소 단위 Tokenizer입니다.

def tokenize_by_eojeol_jaso(s):
    return [to_jaso(token) for token in tokenize_by_eojeol_char(s)]

6. Tokenizer - 형태소 단위

  올 것이 왔습니다. 형태소 분석은 정말 심오하고도 유서 깊은 자연어 처리의 한 분야입니다. 수많은 분들이 이 주제를 가지고 박사학위를 받으셨을 겁니다. (전 박사가 아니라서 잘 모릅니다). 하지만 우리는 형태소 분석기를 만드는 것이 아니기 때문에 그냥 만들어진 것을 쓰기로 합니다. 세상이 참 좋아져서 다행입니다.

  konlpy(http://konlpy.org/ko/latest/)를 이용하면 아주 편리하게 고품질의 형태소 분석기를 사용할 수 있습니다. konlpy에는 여러 가지 형태소 분석기가 포함되어있는데 이번에는 Mecab을 쓰기로 했습니다. Mecab은 처리속도가 매우 빠른데 품질도 괜찮은 편입니다. 

  먼저 http://konlpy.org/ko/latest/ 의 가이드에 따라 konlpy를 설치해줍니다.

$ pip install konlpy

  Mecab을 쓰기 위해서는 추가 설치가 필요합니다. (http://konlpy.org/ko/latest/)

$ sudo apt-get install curl git
$ bash <(curl -s https://raw.githubusercontent.com/konlpy/konlpy/master/scripts/mecab.sh)

  먼저 글자 단위 Tokenizer를 만들어줍니다. 앞에 어절 단위처럼 이 녀석의 결과를 나중에 자소 단위로 바꿀 거거든요.

from konlpy.tag import Mecab

tagger = Mecab()

def tokenize_by_morpheme_char(s):
    return tagger.morphs(s)

  어려운 일은 Mecab이 다 해주니 마음이 참 편합니다. 물론 몸도 편하고요.

  이제 이 함수를 이용해서 자소 단위 Tokenizer도 만들어줍니다.

def tokenize_by_morpheme_jaso(s):
    return [to_jaso(token) for token in tokenize_by_morpheme_char(s)]

7. Tokenizer - Subword 단위

  Subword는 sentencepiece (https://github.com/google/sentencepiece)를 쓰기로 했습니다. sentencepiece는 훈련을 시켜야 해서 Mecab을 쓰는 것보다는 좀 복잡합니다. 물론 한국어 sentencepiece 모델을 만들어서 공개해둔 분들이 계시겠지만 이번에는 직접 훈련을 시키기로 했습니다.

  sentencepiece를 훈련시키기 위해서 거창한 데이터는 필요 없습니다. 그냥 일반 텍스트가 많이 있으면 됩니다. 일반 텍스트가 많은 곳이 어딜까요? 당연히 위키피디아입니다. https://dumps.wikimedia.org/kowiki/latest/에서 kowiki-latest-pages-articles.xml.bz2를 다운로드 합니다. Wikipedia 데이터는 특정 포맷으로 되어있어서 우리에게 필요한 텍스트만 추출을 해야 합니다. 텍스트 추출을 위해서는 WikiExtractor(https://github.com/attardi/wikiextractor)를 사용합니다. 추출을 하고 나면 각 파일들이 여러 개의 디텍토리에 나누어져서 저장됩니다.

  그리고 네이버 데이터 분석에 조금이라도 도움이 되고자 네이버 코퍼스의 훈련 데이터 문장들도 더해줍니다.

  모든 문장을 합친 파일은 corpus 디렉터리를 만들고 그 안에 merged.txt로 저장해둡니다.

 

  sentencepiece를 설치하시고(https://github.com/google/sentencepiece#installation) 아래 명령으로 한국어 모델을 훈련시킵니다.

$ spm_train --input corpus/merged.txt --model_prefix=spm.char --vocab_size=10000 --character_coverage=0.9995

 훈련이 끝난 모델(spm.char.model)을 model 디렉터리로 옮겨줍니다.

$ mkdir model
$ mv spm.char.model model/

  그리고 이 완성된 모델을 사용할 tokenizer 함수를 만들어 줍니다.

import sentencepiece as spm

sp_char = spm.SentencePieceProcessor()
sp_char.Load("model/spm.char.model")

def tokenize_by_subword_char(s):    
    return sp_char.EncodeAsPieces(s)

8. fastText 모델 훈련 - 훈련 데이터 준비

  토큰의 단위가 달라지면 fastText의 입력이 달라지는 셈이기 때문에 각각의 Tokenizer에 대응하는 fastText 모델을 만들어줍니다.

  fastText 훈련도 sentencepiece처럼 특별한 데이터가 필요 없고, 일반 텍스트만 많이 있으면 됩니다. 그래서 merged.txt의 데이터를 그대로 사용하겠습니다.

  먼저 merged.txt를 각각의 tokenizer를 이용해서 토큰으로 나눠줍니다. 앞에 tokenizer 코드들에 아래 내용을 더해서 하나의 스크립트로 만듭니다. 전체 소스는 https://github.com/ceongjee-in/korean-tokenizer-experiment/blob/master/tokenizer.py에 있습니다.

import tqdm

def get_tokenizer(method, unit):
    return getattr(sys.modules[__name__], 'tokenize_by_{}_{}'.format(method, unit))

if __name__ == '__main__':
    import argparse
    import sys
    from tqdm import tqdm

    parser = argparse.ArgumentParser()
    parser.add_argument('-method', choices=['eojeol', 'subword', 'morpheme'])
    parser.add_argument('-unit', choices=['jaso', 'char'], required=True)
    parser.add_argument('input_path')
    parser.add_argument('output_path')    
    args = parser.parse_args()    
    
    tokenizer = get_tokenizer(args.method, args.unit)

    lines = open(args.input_path).read().splitlines()

    with open(args.output_path, 'w') as out:
        for line in tqdm(lines, unit=' line'):
            tokens = tokenizer(line)
            out.write(' '.join(tokens) + '\n')

* tqdm은 progress bar를 손쉽게 출력시키주는 모듈입니다 (https://jins-sw.tistory.com/4)

* get_tokenizer와 같은 구현은 비추천합니다. 이번에는 약식으로 실험 조합을 위해서 썼지만, 코드 리팩토링을 하면 깨지는 코드이기 때문에 실전에서는 쓰지 마시길.

 

  그리고 아래와 같이 실행해서 훈련 데이터를 각각 만듭니다.

$ python3 tokenizer.py -method eojeol -unit char corpus/merged.txt corpus/eojeol.char.txt
$ python3 tokenizer.py -method eojeol -unit jaso corpus/merged.txt corpus/eojeol.jaso.txt
$ python3 tokenizer.py -method morpheme -unit char corpus/merged.txt corpus/morpheme.char.txt
$ python3 tokenizer.py -method morpheme -unit jaso corpus/merged.txt corpus/morpheme.jaso.txt
$ python3 tokenizer.py -method subword -unit char corpus/merged.txt corpus/subword.char.txt

  사실 merged.txt, eojeol.char.txt, subword.char.txt는 같습니다. 어차피 공백으로 나눴다가 다시 공백으로 합친 거니까요.

  중복이기는 하지만 데이터 관리 차원에서는 이렇게 두는 것도 나쁘지 않습니다.

  순서대로 토큰으로 나눠진 예시 문장입니다.

역사가이자 저자인 제임스 트러슬로우 애덤스는 ‘아메리칸 드림’이란 문구를 그가 1931에 출간한 《미국의 서사시》라는 책에서 언급했었다.
ㅇㅕㄱㅅㅏᴕㄱㅏᴕㅇㅣᴕㅈㅏᴕ ㅈㅓᴕㅈㅏᴕㅇㅣㄴ ㅈㅔᴕㅇㅣㅁㅅㅡᴕ ㅌㅡᴕㄹㅓᴕㅅㅡㄹㄹㅗᴕㅇㅜᴕ ㅇㅐᴕㄷㅓㅁㅅㅡᴕㄴㅡㄴ ‘ㅇㅏᴕㅁㅔᴕㄹㅣᴕㅋㅏㄴ ㄷㅡᴕㄹㅣㅁ’ㅇㅣᴕㄹㅏㄴ ㅁㅜㄴㄱㅜᴕㄹㅡㄹ ㄱㅡᴕㄱㅏᴕ 1931ㅇㅔᴕ ㅊㅜㄹㄱㅏㄴㅎㅏㄴ 《ㅁㅣᴕㄱㅜㄱㅇㅢᴕ ㅅㅓᴕㅅㅏᴕㅅㅣᴕ》ㄹㅏᴕㄴㅡㄴ ㅊㅐㄱㅇㅔᴕㅅㅓᴕ ㅇㅓㄴㄱㅡㅂㅎㅐㅆㅇㅓㅆㄷㅏᴕ.
역사 가 이자 저자 인 제임스 트 러 슬로우 애덤스 는 ‘ 아메리칸 드림 ’ 이 란 문구 를 그 가 1931 에 출간 한 《 미국 의 서사시 》 라는 책 에서 언급 했었 다 .
ㅇㅕㄱㅅㅏᴕ ㄱㅏᴕ ㅇㅣᴕㅈㅏᴕ ㅈㅓᴕㅈㅏᴕ ㅇㅣㄴ ㅈㅔᴕㅇㅣㅁㅅㅡᴕ ㅌㅡᴕ ㄹㅓᴕ ㅅㅡㄹㄹㅗᴕㅇㅜᴕ ㅇㅐᴕㄷㅓㅁㅅㅡᴕ ㄴㅡㄴ ‘ ㅇㅏᴕㅁㅔᴕㄹㅣᴕㅋㅏㄴ ㄷㅡᴕㄹㅣㅁ ’ ㅇㅣᴕ ㄹㅏㄴ ㅁㅜㄴㄱㅜᴕ ㄹㅡㄹ ㄱㅡᴕ ㄱㅏᴕ 1931 ㅇㅔᴕ ㅊㅜㄹㄱㅏㄴ ㅎㅏㄴ 《 ㅁㅣᴕㄱㅜㄱ ㅇㅢᴕ ㅅㅓᴕㅅㅏᴕㅅㅣᴕ 》 ㄹㅏᴕㄴㅡㄴ ㅊㅐㄱ ㅇㅔᴕㅅㅓᴕ ㅇㅓㄴㄱㅡㅂ ㅎㅐㅆㅇㅓㅆ ㄷㅏᴕ .
▁역사 가 이자 ▁저 자인 ▁제임스 ▁트 러 슬 로 우 ▁애 덤 스는 ▁‘ 아메리칸 ▁드 림 ’ 이란 ▁문 구를 ▁그가 ▁1931 에 ▁출간 한 ▁《 미 국의 ▁서 사 시 》 라는 ▁책 에서 ▁언급 했었다 .

9. fastText 모델 훈련 - 훈련시키자!

fastText를 설치(https://fasttext.cc/docs/en/support.html)합니다. command line tool과 python module을 둘 다 설치합니다.

 

아래 명령으로 fastText 모델을 훈련합니다. (처음에 이야기한 대로 파라미터는 기본값을 그대로 사용했습니다.)

$ ../fastText/fasttext skipgram -input corpus/eojeol.char.txt -output model/ft.eojeol.char
$ ../fastText/fasttext skipgram -input corpus/eojeol.jaso.txt -output model/ft.eojeol.jaso
$ ../fastText/fasttext skipgram -input corpus/morpheme.char.txt -output model/ft.morpheme.char
$ ../fastText/fasttext skipgram -input corpus/morpheme.jaso.txt -output model/ft.morpheme.jaso
$ ../fastText/fasttext skipgram -input corpus/subword.char.txt -output model/ft.subword.char

  저는 상위 디텍토리에 fastText 소스가 있는데, 이 부분은 각자 설치하신 경로에 맞춰서 변경하시면 됩니다.

  fastText 훈련을 시키다 보면 훈련 데이터에서 읽은 전체 토큰 수와 중복을 제외한 토큰 수가 표시되는데 어절과 형태소의 특징을 볼 수 있습니다.

  총 토큰 수 중복 제외 토큰 수
어절 단위 (eojeol.char.txt) 64M 0.80M
형태소 단위 (morpheme.char.txt) 139M 0.25M

  형태소로 쪼갤 경우 절대적인 토큰의 수는 많아지지만 (더 잘게 쪼개므로), 중복이 많이 발생하고 ("지구가", "지구는" → [지구, 가], [지구, 는]), 중복을 제외하고 나면 실제 토큰은 훨씬 적어집니다. 다르게 표현하면 word vector 뒤의 모델이 학습해야 할 양이 훨씬 적어진다는 의미구요.

10. Deep Learning 모델 훈련 - 코퍼스 읽기

  아주 아주 큰 데이터를 가지고 훈련을 시킬 때는 전처리에 드는 시간을 아끼기 위해서 전처리는 미리 하고, 전처리가 끝난 결과를 읽어서 바로 훈련에 들어가곤 합니다. 하지만 이번에는 훈련 스크립에서 전처리까지 넣어서 해보기로 했습니다. 목표는 아래와 같은 명령으로 훈련을 마칠 수 있게 하는 겁니다.

$ python train.py --method eojeol --unit jaso corpus/train.txt corpus/test.txt

  앞으로 사용할 모듈들을 import 하고 word embedding 값(fastText 기본값)도 선언합니다.

import tensorflow as tf
import numpy as np
import sentencepiece as spm
import fasttext
import tokenizer
import numpy as np
from tqdm import tqdm
import math

EMBEDDING_DIM = 100 #default value of fattext word vector

  다음으로 train.txt와 test.txt를 읽을 함수를 만듭니다.

def parse_corpus_line(line):
    _, sentence, label = line.strip().split('\t')
    label = int(label)
    return sentence, label

def read_corpus(path):
    sentences = []
    labels = []

    for line in open(path):
        sentence, label = parse_corpus_line(line)
        sentences.append(sentence)
        labels.append(label)

    return np.array(sentences), np.array(labels)

  read_corpus를 호출하면 해당 파일의 모든 데이터가 sentence와 label으로 분리돼서 메모리에 올라갑니다.

 

  이렇게 읽은 문장을 Neural Net 모델에 넣으려면 준비가 좀 필요합니다. 모델이 이해할 수 있는 형태로 변경해줘야 하는데, 이 경우에는 fastText의 word vector입니다. 즉, 각 문장들을 word vector 형태로 변환을 해주어야 합니다.

  1. 문장을 토큰으로 쪼갠다
  2. 쪼개진 토큰을 가장 긴 문장에 맞춰 패딩한다
  3. 패딩이 마친 토큰들을 word vector로 변환하다

  여기까지 하면 Neural Net 모델에 넣을 준비가 끝납니다. 그런데 여기에서 좀 문제가 생깁니다.

  네이버 코퍼스가 훈련, 테스트 합쳐서 대략 200,000 문장입니다. 이 중 가장 긴 문장의 약 115 토큰 쯤 되는군요. 한 토큰은 100개의 실수 값으로 word vector로 변환되고요. 실수 값은 4바이트라고 치죠. 전체를 한방에 word vector로 변환한다면 최소로 200,000 * 115 * 100 * 4 = 9,200,000,000 = 9.2 GB가 필요합니다. 물론 여기에다가 이것저것 들어가는 값을 더하면 훨씬 더 커지겠죠.

  아무리 생각해도 한방에 전체를 word vector로 변환해서 메모리에 가지고 있는 것은 아닌 것 같습니다. 최악은 한참 훈련이 돌다가 Out of memory로 죽을 수도 있습니다.

  그래서 메모리에는 word vector 전 단계까지만 가지고 있기로 합니다.

def pad(data, max_len=0):    
    if max_len == 0:
        max_len = max(len(tokens) for tokens in data)

    result = []
    for tokens in tqdm(data, desc='Padding'):
        if len(tokens) >= max_len: #Truncate if tokens are longer than max_len
            result.append(tokens[:max_len])
        else:
            n_to_pad = max_len - len(tokens) 
            result.append(tokens + [''] * n_to_pad)

    return max_len, result

def preprocess(sentences, tokenize_method, unit):
    tokenize_func = tokenizer.get_tokenizer(tokenize_method, unit)    
    tokenized_sentences = [tokenize_func(sentence) for sentence in tqdm(sentences, desc='Tokenizing')]    
    max_tokens, padded_sentences = pad(tokenized_sentences)
    
    return padded_sentences

* 이 코드에서 truncate 하는 부분은 사실 필요가 없습니다. 이번에는 안 쓰거든요. 하지만 언젠가를 위해서 킵!

 

  최초 작전에서 1, 2 단계만 하는 거죠. 그럼 3단계는 언제 하나요? 3단계는 필요할 때 그 때 그 때 합니다. 즉, 훈련이나 평가 과정에서 필요할 때만 동적으로만 만들어서 쓰고 버립니다. 그럼 메모리가 많이 절약되겠죠?

11. 동적으로 Data 제공을 위한 Dataset을 만들자

  Tensorflow는 기본적으로 batch 단위로 훈련과 평가를 합니다. 이 때 데이터를 제공할 수 있는 방법이 여러 가지 있습니다. iterable, TFRecord, generator 등이 있죠. 이번에는 tf.keras.utils.Sequence (https://www.tensorflow.org/api_docs/python/tf/keras/utils/Sequence) 를 이용해보겠습니다. tf.keras.utils.Sequence에 대해서는 별도의 글의 소개하기로 하고요. 이를 이용한 코드를 보여드리겠습니다.

class Dataset(tf.keras.utils.Sequence):
    fasttext_model_cache = {}
    
    def __init__(self, x_set, y_set, batch_size, tokenize_method, unit):
        self.x_set = x_set
        self.y_set = y_set
        self.batch_size = batch_size

        fasttext_model_path = 'model/ft.{}.{}.bin'.format(tokenize_method, unit)        
        if fasttext_model_path not in Dataset.fasttext_model_cache:
            Dataset.fasttext_model_cache[fasttext_model_path] = fasttext.load_model(fasttext_model_path)        
        self.fasttext_model = Dataset.fasttext_model_cache[fasttext_model_path]

    def __len__(self):
        return math.ceil(len(self.x_set) / self.batch_size)

    def __getitem__(self, idx):
        padded_sentences = self.x_set[idx * self.batch_size:(idx + 1) * self.batch_size]        
        word_vectors = [self.get_word_vectors(padded_sentence) for padded_sentence in padded_sentences]        
        
        batch_y = self.y_set[idx * self.batch_size:(idx + 1) * self.batch_size]

        return np.array(word_vectors), np.array(batch_y)
    
    def get_word_vectors(self, words):
        result = []
        for word in words:
            if not word: # zeros for padding
                result.append(np.zeros((EMBEDDING_DIM,)))
            else:
                result.append(self.fasttext_model.get_word_vector(word))

        return np.array(result)

  간단히 중요한 부분만 이야기해보면,

  • Tensorflow가 Data(batch)가 필요할 때 이 Dataset에 [] 연산자를 이용해서 데이터를 가져갑니다. __getitem__이 호출됩니다.
  • __getitem__은 가지고 있는 문장들 중에 필요한 만큼만 뽑아서 word vector로 변환하고 이 값을 돌려줍니다.

  앞에서 짰던 그때 그 때 작전입니다.

 

  작은 디테일이 있는데, fastText 모델을 중복해서 로딩하지 않으려고 캐시를 썼는데 별로 중요하지 않습니다. 앞에서 패딩으로 쓴 빈 문자열('')을 만나면 0으로 채워주고요.

12. Tensorflow Model 만들기

  이제 모델링에 사용할 Neural Net을 짜 보겠습니다. 처음에 말했듯이 이 부분은 핵심이 아니기 때문에 딱 필요한 만큼만 시간을 들이겠습니다. 앞에도 말했지만 최적화의 늪에 빠져 친지 형제도 못 알아보는 지경에 이를 수 있으니까요.

def build_model():
    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(128, return_sequences=True), 
                                            input_shape=(None, EMBEDDING_DIM)))
    model.add(tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(128)))
    model.add(tf.keras.layers.Dense(64))
    model.add(tf.keras.layers.Dense(1, activation='sigmoid'))  

  간단합니다. Masking도 안 하고, L2/Dropout도 없고, Normalization도 안 하고. 그래도 나름 있을 건 있습니다. 무려 2단짜리 Bidirectional LSTM에 word vector까지 들어갔으니까요. (풀옵은 아니어도 깡통은 아니에요.)

 

13. 드디어 훈련과 평가를 해봅시다

  먼 길을 왔습니다. 드디어 대망의 훈련입니다. 훈련을 시작하기에 앞서서 아래 준비물들이 잘 갖춰졌는지 확인해주세요.

  • corpus 디렉터리에 train.txt와 test.txt → 없으면 4번으로
  • model 디렉토리에 sentencepiece model → 없으면 7번으로
  • model 디렉토리에 fastText model들 → 없으면 8번으로

  예전 이모, 삼촌들이 보던 "A를 선택하시면 27쪽으로 가세요"와 같은 책들이 생각나는군요. (잘 있니? 윌리야.)

 

  지금까지 코드들에 실행을 위한 부분을 더해줍니다.

if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser()               
    parser.add_argument('train')
    parser.add_argument('test')
    parser.add_argument('--method', choices=['eojeol', 'subword', 'morpheme'], required=True)
    parser.add_argument('--unit', choices=['jaso', 'char'], required=True)
    parser.add_argument('--batch-size', type=int, default=128)            
    parser.add_argument('--test-batch-size', type=int)
    parser.add_argument('--epochs', type=int, default=10)    
    args = parser.parse_args()
    
    train_sentences, train_labels = read_corpus(args.train)    
    train_padded_sentences = preprocess(train_sentences, args.method, args.unit)
    train_dataset = Dataset(train_padded_sentences, train_labels, args.batch_size, args.method, args.unit)     

    model = build_model()    
    
    model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])    
    model.fit(train_dataset, epochs=args.epochs)
    model.save('model/classfier.{}.{}.model'.format(args.method, args.unit))

    test_sentences, test_labels = read_corpus(args.test)
    test_padded_sentences = preprocess(test_sentences, args.method, args.unit)       
    test_batch_size = args.test_batch_size if args.test_batch_size else args.batch_size    
    test_dataset = Dataset(test_padded_sentences, test_labels, test_batch_size, args.method, args.unit)    
    
    test_loss, test_accuracy = model.evaluate(test_dataset)    
    print('test_loss', test_loss)
    print('test_accuracy', test_accuracy)

https://github.com/ceongjee-in/korean-tokenizer-experiment/blob/master/train.py

   초반의 작전대로 아래 명령으로 각각 성능을 측정해봅니다.

$ python train.py --method eojeol --unit char --batch-size 128 --epoch 10 corpus/train.txt corpus/test.txt
$ python train.py --method eojeol --unit jaso --batch-size 128 --epoch 10 corpus/train.txt corpus/test.txt
$ python train.py --method morpheme --unit char --batch-size 128 --epoch 10 corpus/train.txt corpus/test.txt
$ python train.py --method morpheme --unit jaso --batch-size 128 --epoch 10 corpus/train.txt corpus/test.txt
$ python train.py --method subword --unit char --batch-size 128 --epoch 10 corpus/train.txt corpus/test.txt

* 혹시 메모리 부족으로 훈련이 죽으면 --batch size를 64, 32, 16 등으로 줄여서 해보세요.

 

  아름다운 Tensorflow 로그들과 함께 훈련이 진행되고 성능이 나옵니다. 훈련에 걸리는 시간은 각각 다르니 여기까지 온 스스로를 격려하며 머리를 좀 식히시킬.

  아직 Tensorflow 설치를 안 하셨다면 이 글도 참고해보세요. Docker를 이용해서 편리하게 GPU Tensorflow를 사용하자

14. 실험 결과

쪼개는 방법 단위 성능
어절 글자 82.02%
어절 자소 83.29%
형태소 글자 87.07%
형태소 자소 86.90%
Subword 글자 85.34%

결론을 요약해보자면,

  • 어절을 통으로 쓰는 것보다는 형태소나 Subword와 같은 작은 단위가 좋다.
  • 글자를 쓰느냐, 자소를 쓰느냐는 (특히 형태소 단위 토큰에서) 크게 유의미하지 않다.

데이터에는 보이지 않지만 몇 가지 추측을 해보자면,

  • Subword가 형태소처럼 의미를 고려하지는 않지만 어절보다는 모델의 복잡도를 낮추는데 기여한다. sentencepiece 모델을 전혀 최적화하지 않았다는 점을 고려하면 성능 개선의 여지가 더 있다
  • 글자를 쓰느냐 자소를 쓰느냐는 형태소 단위일 때보다 어절 단위일 때 큰 차이를 보인다. 최초 소개 부분에서 자소를 쓰는 이유로, 자소 자체가 일련의 의미를 지니기 때문이라고 했다. 형태소 단위에서는 이미 의미 단위로 토큰이 나눠졌기 때문에 자소 단위가 주는 이익이 덜한 반면, 어절 단위에서는 자소가 형태소와 유사한 의미를 토큰에 부여하는 것일 수도 있다.

15. 마무리

  Machine Learning 기술이 발전하면서 언어학에 대한 중요도가 갈수록 줄어들고 있는 것이 사실입니다. 데이터로 커버하면 된다는 생각이 많아서이고요.

  Deep Learning을 포함한 모든 Machine Learning이 그렇듯이 학습 기술의 발전 추세가 있습니다. 알고리즘이 약하고, 데이터가 부족할 때는 많은 전문 지식이 필요합니다. 자연어 처리에서는 언어학 지식이 그렇고, 음성인식에서는 음성신호처리 지식이 그렇고요. 그러다가 알고리즘이 발전하고 데이터가 많아지면, 데이터와 알고리즘의 표현력이 전문 지식을 대신하게 됩니다. 하지만 가끔은 수십 문장의 추가 데이터를 그 분야의 전문지식이 대신 해 줄 수도 있습니다.

  자연어 처리를 하는 사람으로서 언어학에 대해서 기본적인 지식을 쌓는 것이 해가 되지는 않습니다. "돌려보니 잘 나오던데요" 보다는 "형태소를 사용해 의미를 유지한 체 데이터와 모델의 복잡도를 최소화했습니다."가 더 그럴듯해 보이기도 하고요. 농담입니다.

   장문의 글을 읽어 주셔서 감사합니다.