컴퓨터가 인간의 언어를 처리하는 분야인 자연어 처리(NLP:Natural Language Processing)에서 컴퓨터가 풀고자 하는 문제의 용도에 맞게 텍스트를 사전에 처리하는 작업을 텍스트 전처리(Text Preprocessing)라고 한다.
텍스트 전처리에는 토근화(Tokenizing), 정제(Cleaning), 정규화(Normalizing), 어간추출(Stemming), 표제어추출(Lemmatization), 불용어(Stopwords), 정수인코딩(Integer Encoding), 패딩(Padding), One-Hot Encoding 등 아주 다양한 것들이 있는데 이중 텍스트 전처리의 가장 기본인 토큰화(Tokenizing)에 대해 알아본다.
주어진 코퍼스(corpus)에서 토큰(token)이라 불리는 단위로 나누는 작업을 토큰화(tokenization)라고 한다. 토큰의 단위가 상황에 따라 다르지만, 보통 의미있는 단위로 토큰을 정의한다.
한글을 토큰화할 때 단순히 띄어쓰기 단위로 토큰화를 하면 한글의 특성상 제대로 토큰 분리가 잘 안된다. 그래서 형태소(morpheme) 단위로 토큰화를 수행하는데 형태소는 의미를 가지는 가장 작은 말의 단위를 말한다.
형태소 단위 토큰화 다음으로 많이 쓰이는 방식이 word piece 혹은 subword segmentation(서브워드 분리) 방식인데 이 방식은 하나의 단어는 더 작은 단위의 의미있는 여러 서브워드들로 구성되는 경우가 많기 때문에 하나의 단어를 더 작은 여러 서브워드로 분리하는 방식을 말한다.
즉 한글 토큰화는 형태소 분석 방식과 subword tokenizer 방식의 두방식으로 크게 나뉘어진다.
영어는 New York과 같은 합성어나 he's 와 같이 줄임말에 대한 예외처리만 한다면, 띄어쓰기(whitespace)를 기준으로 하는 띄어쓰기 토큰화를 수행해도 단어 토큰화가 잘 작동한다. 거의 대부분의 경우에서 단어 단위로 띄어쓰기가 이루어지기 때문에 띄어쓰기 토큰화와 단어 토큰화가 거의 같기 때문이다.
그러나 한국어는 영어와는 달리 띄어쓰기만으로는 토큰화를 하기에 부족하다. 한국어의 경우에는 띄어쓰기 단위가 되는 단위를 '어절(語節)'이라고 하는데 어절 토큰화는 한국어 NLP에서 지양되고 있다. 왜냐하면 어절 토큰화와 단어 토큰화는 같지 않기 때문이다. 그 근본적인 이유는 한국어가 영어와는 다른 형태를 가지는 언어인 교착어(膠着語)라는 점에서 기인합니다. 교착어란 조사, 어미 등을 붙여서 말을 만드는 언어를 말한다.
예를 들어보자. 영어와는 달리 한국어에는 조사(助詞)라는 것이 존재한다. 예를 들어 한국어에 그(he/him)라는 주어나 목적어가 들어간 문장이 있다고 하자. 이 경우, 그라는 단어 하나에도 '그가', '그에게', '그를', '그와', '그는'과 같이 다양한 조사가 '그'라는 글자 뒤에 띄어쓰기 없이 바로 붙게다. 자연어 처리를 하다보면 같은 단어임에도 서로 다른 조사가 붙어서 다른 단어로 인식이 되면 자연어 처리가 힘들고 번거로워지는 경우가 많다. 대부분의 한국어 NLP에서 조사는 분리해줄 필요가 있다.
띄어쓰기 단위가 영어처럼 독립적인 단어라면 띄어쓰기 단위로 토큰화를 하면 되겠지만 한국어는 어절이 독립적인 단어로 구성되는 것이 아니라 조사 등의 무언가가 붙어있는 경우가 많아서 이를 전부 분리해줘야 한다는 의미이다.
한국어 토큰화에서는 형태소(morpheme) 란 개념을 반드시 이해해야 한다. 형태소(morpheme)란 뜻을 가진 가장 작은 말의 단위를 말한다. 이 형태소에는 두 가지 형태소가 있는데 자립 형태소와 의존 형태소이다.
예를 들어 다음과 같은 문장이 있다고 하자.
이 문장을 띄어쓰기 단위 토큰화를 수행한다면 다음과 같은 결과를 얻는다.
하지만 이를 형태소 단위로 분해하면 다음과 같다.
'에디'라는 사람 이름과 '책'이라는 명사를 얻어낼 수 있다. 이를 통해 유추할 수 있는 것은 한국어에서 영어에서의 단어 토큰화와 유사한 형태를 얻으려면 어절 토큰화가 아니라 형태소 토큰화를 수행해야한다는 것이다.
한글에서 형태소 분석이 필요한 또다른 이유는 한국어는 영어에 비해 띄어쓰기가 잘 지켜지지 않는다는 점이다.
한국어는 영어권 언어와 비교하여 띄어쓰기가 어렵고 잘 지켜지지 않는 경향이 있다. 그 이유는 여러 견해가 있으나, 가장 기본적인 견해는 한국어의 경우 띄어쓰기가 지켜지지 않아도 글을 쉽게 이해할 수 있는 언어라는 점이다. 띄어쓰기가 없던 한국어에 띄어쓰기가 보편화된 것도 근대(1933년, 한글맞춤법통일안)의 일이다. 띄어쓰기를 전혀 하지 않은 한국어와 영어 두 가지 경우를 살펴보자.
영어의 경우에는 띄어쓰기를 하지 않으면 손쉽게 알아보기 어려운 문장들이 생긴다. 이는 한국어(모아쓰기 방식)와 영어(풀어쓰기 방식)라는 언어적 특성의 차이에 기인한다. 결론적으로 한국어는 수많은 코퍼스(corpus)에서 띄어쓰기가 무시되는 경우가 많아 자연어 처리가 어려워졌다는 것이다.
단어는 표기는 같지만 품사에 따라서 단어의 의미가 달라지기도 한다. 예를 들어서 영어 단어 'fly'는 동사로는 '날다'라는 의미를 갖지만, 명사로는 '파리'라는 의미를 갖고있다. 한국어도 마찬가지이다. '못'이라는 단어는 명사로서는 망치를 사용해서 목재 따위를 고정하는 물건을 의미한다. 하지만 부사로서의 '못'은 '먹는다', '달린다'와 같은 동작 동사를 할 수 없다는 의미로 쓰인다. 결국 단어의 의미를 제대로 파악하기 위해서는 해당 단어가 어떤 품사로 쓰였는지 보는 것이 주요 지표가 될 수가 있다. 그에 따라 단어 토큰화 과정에서 각 단어가 어떤 품사로 쓰였는지를 구분해놓기도 하는데, 이 작업을 품사 태깅(part-of-speech tagging)이라고 한다.
서브워드 분리(Subword segmenation) 작업은 하나의 단어는 더 작은 단위의 의미있는 여러 서브워드들(Ex) birthplace = birth + place)의 조합으로 구성된 경우가 많기 때문에, 하나의 단어를 여러 서브워드로 분리해서 단어를 인코딩 및 임베딩하겠다는 의도를 가진 전처리 작업이다. 이를 통해 OOV(Out-Of-Vocabulary)나 희귀 단어, 신조어와 같은 문제를 완화시킬 수 있다. 실제로 언어의 특성에 따라 영어권 언어나 한국어는 서브워드 분리를 시도했을 때 어느정도 의미있는 단위로 나누는 것이 가능하다.
Subword Tokenizer 알고리즘 중 대표적인 것이 Byte Pair Encoding(BPE)이고 BPE를 포함한 다양한 Subword Tokenizer 알고리즘을 포함한 패키지 도구가 SentencePiece이다. SentencePiece는 구글에서 개발하였다.
또한 이 방식은 주어진 방대한 텍스트를 학습해서 적용하기 때문에 특정 언어에 대한 지식이 필요가 없고 모든 언어에 공통으로 적용할 수 있다는 특징이 있다.
BPE는 1994년 제안된 정보 압축 알고리즘으로, 데이터에서 가장 많이 등장한 문자열을 병합해서 데이터를 압축하는 기법이다. 예를 들어 다음과 같은 데이터가 있다고 가정해 보자.
BPE는 데이터에 등장한 글자(a, b, c, d)를 초기 사전으로 구성하며, 연속된 두 글자를 한 글자로 병합한다. 이 문자열에선 aa가 가장 많이 나타났으므로 이를 Z로 병합(치환)하면 위의 문자열을 다음과 같이 압축할 수 있다.
이 문자열은 한번 더 압축 가능하다. 살펴보니 ab가 가장 많이 나타났으므로 이를 Y로 병합(치환)한다.(물론 ab 대신 Za를 병합할 수도 있는데, 하지만 둘의 빈도수가 2로 같으므로 알파벳 순으로 앞선 ab를 먼저 병합한다.)
ZY 역시 X로 병합할 수 있다. 이미 병합된 문자열 역시 한 번 더 병합할 수 있다는 얘기이다.
BPE 수행 이전에는 원래 데이터를 표현하기 위한 사전 크기가 4개(a, b, c, d)였었다. 그런데 수행 이후엔 그 크기가 7개(a, b, c, d, Z, Y, X)로 늘었다. 반면 데이터의 길이는 11에서 5로 줄었다. 이처럼 BPE는 사전 크기를 지나치게 늘리지 않으면서도 각 데이터 길이를 효율적으로 압축할 수 있도록 한다.
BPE 기반 토큰화 기법은 분석 대상 언어에 대한 지식이 필요 없다. 말뭉치(corpus)에서 자주 나타나는 문자열(서브워드)을 토큰으로 분석하기 때문이다. 실제로 자연어 처리에서 BPE가 처음 쓰인 것은 기계 번역 분야이다. BPE를 활용한 토크나이즈 절차는 다음과 같다.
1) 어휘 집합 구축
BPE 어휘 집합 구축 절차를 구체적으로 살펴보겠다. 어휘 집합을 만드려면 우선 말뭉치를 준비해야 한다. 말뭉치의 모든 문장을 공백으로 나눠준다. 이를 프리토크나이즈(pre-tokenize)라고 하는데 본격적인 토큰화에 앞서 미리 분석했다는 의미에서 이런 이름이 붙었다. 물론 공백 말고 다른 기준으로 프리토크나이즈를 수행할 수도 있다.
우리가 가진 말뭉치에 프리토크나이즈를 실시하고 그 빈도를 모두 세어서 표1을 얻었다고 가정해 본다.
(표1)
토큰 | 빈도 |
---|---|
hug | 10 |
pug | 5 |
pun | 12 |
bun | 4 |
hugs | 5 |
BPE를 문자(character) 단위로 수행할 경우 초기의 어휘 집합은 다음과 같다.
이 7개 문자로도 표1의 모든 토큰을 표현할 수 있다. 하지만 우리는 어휘 집합 크기가 약간 증가하더라도 토큰 시퀀스 길이를 줄이려는(정보를 압축하려는) 목적을 가지고 있으므로 BPE를 수행해 줄 계획이다. 초기 어휘 집합을 바탕으로 표1을 다시 쓰면 표2와 같다.
(표2)
토큰 | 빈도 |
---|---|
h,u,g | 10 |
p,u,g | 5 |
p,u,n | 12 |
b,u,n | 4 |
h,u,g,s | 5 |
표3은 표2의 토큰을 두 개(바이그램; bigram)씩 묶어 쭉 나열한 것입니다. 표2와 표3의 본질은 동일하다.
(표3)
토큰 | 빈도 |
---|---|
h,u | 10 |
u,g | 10 |
p,u | 5 |
u,g | 5 |
p,u | 12 |
u,n | 12 |
b,u | 4 |
u,n | 4 |
h,u | 5 |
u,g | 5 |
g,s | 5 |
이제 할 일은 다음 그림처럼 바이그램 쌍이 같은 것끼리 그 빈도를 합쳐주는 것이다. 표4와 같습니다.
(표4)
토큰 | 빈도 |
---|---|
b,u | 4 |
g,s | 5 |
h,u | 15 |
p,u | 17 |
u,g | 20 |
u,n | 16 |
가장 많이 등장한 바이그램 쌍은 u, g로 총 20회이다. 따라서 u와 g를 합친 ug를 어휘 집합에 추가한다. 다음과 같다.
표5는 표2를 새로운 어휘 집합에 맞게 다시 쓴 결과이다. u와 g를 병합했으므로 표2의 각 빈도는 그대로인 채 ①h, u, g가 h, ug로 ②p, u, g가 p, ug로 ③h, u, g, s가 h, ug, s로 바뀌었음을 확인할 수 있다.
(표5)
토큰 | 빈도 |
---|---|
h,ug | 10 |
p,ug | 5 |
p,u,n | 12 |
b,u,n | 4 |
h,ug,s | 5 |
표6은 표5를 바이그램 쌍 빈도로 나타낸 결과이다. 계산 과정은 표2->표3에서 표3->표4를 얻었던 것과 같다.
(표6)
토큰 | 빈도 |
---|---|
b,u | 4 |
h,ug | 15 |
p,u | 12 |
p,ug | 5 |
u,n | 16 |
ug,s | 5 |
이번에 가장 많이 등장한 바이그램 쌍은 u, n으로 총 16회이다. 따라서 u와 n을 합친 un을 어휘 집합에 추가한다.
표7은 표5를 새로운 어휘 집합에 맞게 다시 쓴 결과이고, 표8은 표7을 바탕으로 바이그램 쌍 빈도를 나타낸 결과이다.
(표7)
토큰 | 빈도 |
---|---|
h,ug | 10 |
p,ug | 5 |
p,un | 12 |
b,un | 4 |
h,ug,s | 5 |
(표8)
토큰 | 빈도 |
---|---|
b,un | 4 |
h,ug | 15 |
p,ug | 5 |
p,un | 12 |
ug,s | 5 |
이번에 가장 많이 등장한 바이그램 쌍은 h, ug로 총 15회이다. 따라서 h와 ug를 합친 hug를 어휘 집합에 추가한다.
BPE 어휘 집합 구축은 어휘 집합이 사용자가 정한 크기가 될 때까지 반복해서 수행한다. 만일 어휘 집합 크기를 10개로 정해놓았다면, 어휘가 10개가 되었으므로 여기에서 BPE 어휘 집합 구축 절차를 마친다. 어휘 집합은 vocab.jason형태로 저장된다.(tool마다 조금씩 다를 수 있다.)
한편 지금까지 가장 많이 등장한 바이그램 쌍을 병합(merge)하는 방식으로 BPE 어휘 집합을 구축해왔는데. 표9는 그 병합 이력을 한 눈에 보기 좋게 모아놓은 것이다. 왼쪽부터 차례로 표4, 표6, 표8에 각각 대응합니다. 표9를 활용해 merge.txt라는 자료를 만든다. 이는 BPE 토큰화 과정에서 서브워드 병합 우선 순위를 정하는 데 쓰인다.
(표9)
처음 병합한 대상은 u, g, 두번째는 u, n, 마지막은 h, ug였음을 확인할 수 있다. 이 내용 그대로 merges.txt 형태로 저장한다.
(MERGES.TXT)
2) BPE 토큰화
어휘 집합(vocab.json)과 병합 우선순위(merge.txt)가 있으면 토큰화를 수행할 수 있다. 예컨대 pug bug mug라는 문장을 토큰화한다고 가정해 보자. 그러면 일단 이 문장에 프리토크나이즈를 수행해 공백 단위로 분리한다.
이렇게 분리된 토큰들 각각에 대해 BPE 토큰화를 수행한다. 가장 먼저 토큰화를 수행할 대상은 pug이다. 우리는 BPE 기본 단위로 문자를 상정하고 있으니, pug를 문자 단위로 분리한다.
이후 merges.txt를 참고해 병합 우선 순위를 부여한다.
둘 중에 u와 g의 우선순위가 높으므로 이들을 먼저 합쳐 준다.
merges.txt를 한번 더 참고해 병합 우선 순위를 부여한다.
더 이상 병합 대상이 존재하지 않으므로 병합을 그만둔다. 그 다음으로는 p, ug가 각각 어휘 집합(vocab.json)에 있는지를 검사한다. 둘 모두 있으므로 pug의 토큰화 최종 결과는 p, ug이다.
이번엔 bug 차례이다. bug를 문자 단위로 분리한다.
이후 merges.txt를 참고해 병합 우선 순위를 부여한다.
둘 중에 u와 g의 우선순위가 높으므로 이들을 먼저 합쳐 준다.
merges.txt를 한번 더 참고해 병합 우선 순위를 부여한다.
더 이상 병합 대상이 존재하지 않으므로 병합을 그만둔다. 그 다음으로는 b, ug가 각각 어휘 집합(vocab.json)에 있는지를 검사한다. 둘 모두 있으므로 bug의 토큰화 최종 결과는 b, ug이다.
마지막으로 토큰화를 수행할 대상은 mug이다. merges.txt를 참고해 병합 우선 순위를 따져보면 ug를 먼저 합치게 된다. 따라서 병합 결과는 m, ug이 될것인데, 이 가운데 m은 어휘 집합에 없으므로 mug의 최종 토큰화 결과는 , ug가 됩니다. 여기서 는 미등록 토큰을 의미한다.(unk=unknown)
결론적으로 pug bug mug라는 문장의 BPE 토큰화 결과는 다음과 같다.
일반적으로 알파벳 등 개별 문자들은 BPE 어휘 집합을 구축할 때 초기 사전에 들어가므로 m의 사례처럼 미등록 토큰이 발생하는 경우는 많지 않다. BPE가 어휘 집합 크기를 합리적으로 유지하면서도 어휘를 구축할 때 보지 못했던 단어(신조어 등)에 대해서 유의미한 분절을 수행할 수 있는 배경이다.
내부 단어 분리를 위한 유용한 패키지로 구글의 센텐스피스(Sentencepiece)가 있다. 구글은 BPE 알고리즘과 Unigram Language Model Tokenizer를 구현한 센텐스피스를 깃허브에 공개하였다.
내부 단어 분리 알고리즘을 사용하기 위해서, 데이터에 단어 토큰화를 먼저 진행한 상태여야 한다면 이 단어 분리 알고리즘을 모든 언어에 사용하는 것은 쉽지 않다. 영어와 달리 한국어와 같은 언어는 단어 토큰화부터가 쉽지 않기 때문이다. 그런데, 이런 사전 토큰화 작업(pretokenization)없이 전처리를 하지 않은 데이터(raw data)에 바로 단어 분리 토크나이저를 사용할 수 있다면, 이 토크나이저는 그 어떤 언어에도 적용할 수 있는 토크나이저가 될 것이다. 센텐스피스는 이 이점을 살려서 구현되었다. 센텐스피스는 사전 토큰화 작업없이 단어 분리 토큰화를 수행하므로 특정한 언어에 종속되지 않는다.
한국어 자연어 처리를 위해서는 KoNLPy(코엔엘파이)라는 파이썬 패키지를 사용할 수 있다. KoNLPy를 통해서 사용할 수 있는 형태소 분석기로 Okt(Open Korea Text), 메캅(Mecab), 코모란(Komoran), 한나눔(Hannanum), 꼬꼬마(Kkma)가 있다.
주로 Okt를 많이 쓰고 Mecab의 경우는 처리 속도가 상대적으로 빨라서 Mecab도 많이 사용된다.
from konlpy.tag import Okt
okt = Okt()
text = '열심히 코딩한 당신, 연휴에는 여행을 가봐요'
morphs = okt.morphs(text)
ps = okt.pos(text)
noun = okt.nouns(text)
print(f'OKT 형태소 분석 : {morphs}')
print(f'OKT 품사 태깅 : {ps}')
print(f'OKT 명사 추출 : {noun}')
형태소 분석 : ['열심히', '코딩', '한', '당신', ',', '연휴', '에는', '여행', '을', '가봐요']
품사 태깅 : [('열심히', 'Adverb'), ('코딩', 'Noun'), ('한', 'Josa'), ('당신', 'Noun'), (',', 'Punctuation'), ('연휴', 'Noun'), ('에는', 'Josa'), ('여행', 'Noun'), ('을', 'Josa'), ('가봐요', 'Verb')]
명사 추출 : ['코딩', '당신', '연휴', '여행']
Okt의 morphs, pos, nouns의 기능에 대한 설명은 다음과 같다.
1) morphs : 형태소 추출
2) pos : 품사 태깅(Part-of-speech tagging)
3) nouns : 명사 추출
koNLPy의 형태소 분석기들은 공통적으로 이 메소드들을 제공하고 있다. 위 예제에서 형태소 추출과 품사 태깅 메소드의 결과를 보면 조사를 기본적으로 분리하고 있음을 확인할 수 있다. 한국어 NLP에서 전처리에 형태소 분석기를 사용하는 것은 굉장히 유용하다.
내부 단어 분리를 위한 유용한 패키지로 구글의 센텐스피스(Sentencepiece)가 있다. 구글은 BPE 알고리즘과 Unigram Language Model Tokenizer를 구현한 센텐스피스를 깃허브에 공개하였다.
naver_df = pd.concat([train_df.copy(), test_df.copy()], axis=0)
print(naver_df.shape)
print(naver_df.isnull().sum().sum())
naver_df = naver_df.dropna()
print(naver_df.isnull().sum().sum())
print(naver_df.shape)
(200000, 3)
8
0
(199992, 3)
네이버 영화 리뷰 데이터를 다운로드 받아서 dataframe으로 저장한다. (train용, test용을 각각 따로 받아서 이둘을 하나로 합친다.)
다음 리뷰가 없는 null data를 모두 제거한다. null data 8개를 모두 제거하니 전체 200,000개의 review 중 199,992개가 남았다.
import sentencepiece as spm
## df로부터 텍스트만 따로 추출해 별도의 text file로 만든다. sentencepiece에서 필요하다.
### 'w' option은 write mode인데 기존 파일이 있으면 그냥 덮어쓴다.
with open('./naver_reviews.txt', 'w', encoding='utf8') as f:
f.write('\n'.join(naver_df['document']))
## 학습시간이 오래 걸린다.
spm.SentencePieceTrainer.Train('--input=naver_reviews.txt --model_prefix=naver --vocab_size=20000 --model_type=bpe --max_sentence_length=9999')
sp = spm.SentencePieceProcessor()
sp.load('naver.model')
sentencepiece는 따로 설치해야 한다.(pip install sentencepiece)
199,992개의 샘플을 naver_review.txt 파일에 저장한 후에 센텐스피스를 통해 단어 집합을 생성한 것이다. 단어 집합으로 naver.model과 naver.vocab 2개의 파일이 생성되는데 naver.model이 실제로 sentencepiece에서 사용되어지는 단어 집합 파일이고 naver.vocab는 생성된 단어 집합을 텍스트 파일 형태로 저장한 것이다.
# 단어 집합의 크기를 확인한다.
sp.GetPieceSize()
20000
## idToPiece : 정수로부터 맵핑되는 서브 워드로 변환합니다.
sp.IdToPiece(7)
_아
## PieceToId : 서브워드로부터 맵핑되는 정수로 변환합니다.
sp.PieceToId('영화')
4
## DecodeIds : 정수 시퀀스로부터 문장으로 변환합니다.
sp.DecodeIds([330, 414, 18331, 18322, 18276, 0, 212, 18408, 260, 5667, 41, 3144, 18356, 18309, 499, 18496, 18406, 4, 18277, 325, 8, 17647, 1321, 1728, 18277])
'막 걸음마 ⁇ 3세부터 초등학교 1학년생인 8살용영화.ᄏᄏᄏ...별반개도 아까움.'
## DecodePieces : 서브워드 시퀀스로부터 문장으로 변환합니다.
sp.DecodePieces(['▁진짜', '▁최고의', '▁영화입니다', '▁ᄏᄏ'])
'진짜 최고의 영화입니다 ᄏᄏ'
## encode : 문장으로부터 인자값에 따라서 정수 시퀀스 또는 서브워드 시퀀스로 변환 가능합니다.
print(sp.encode('뭐 이딴 것도 영화냐.', out_type=str))
print(sp.encode('뭐 이딴 것도 영화냐.', out_type=int))
['▁뭐', '▁이딴', '▁것도', '▁영화냐', '.']
[132, 967, 1298, 2591, 18277]