해당 내용은 김기현의 자연어 처리 딥러닝 캠프 파이토치편 및 Pytorch로 시작하는 딥러닝 입문읽으며 발췌 및 정리하였으며, 필요에 따라 추가로 검색하여 내용을 보충했습니다.
이전 글 참고
2021.05.12 - [Study/NLP] - [NLP/자연어처리] 자연어 처리 전처리(3) - 단어집합(Vocabulary), 패딩
자연어 처리 분야에서 가장 기초이자 필수인 단어의 표현에 대해서 오늘은 정리하려고 한다.
컴퓨터는 본래 비정형 데이터 보다 정형 데이터를 더 잘처리한다. 그렇기 때문에 자연어 처리에서는 비정형 텍스트인 문자를 숫자로, 정형 데이터로 만들어주는 기법이 필요하다. 그렇게 변환해주는 많은 기법들이 있는데, 가장 기본적인 표현방법으로 원-핫 인코딩(One-Hot Encoding)이 있다.
0. 단어 사전(Vocabulary)
단어 사전에 대해서는 이전 포스팅에서도 설명했지만, 자연어처리를 하려면 가장 먼저 해야하는 일이 단어 사전을 만드는 일이다. 훈련 데이터에 사용될 텍스트의 모든 단어를 중복을 허용하지 않고 모아놓고, 각 단어에 고유한 숫자(index)를 부여하여 정수 인코딩을 진행하는 것이다. 이 단어 사전을 기반으로 우리는 단어를 표현하는 방법에 대해 알아볼 것이다.
1. 원-핫 인코딩(One-hot Encoding)이란?
원-핫 인코딩은 단어 사전의 크기를 벡터의 차원으로 하고, 표현하고 싶은 단어의 인덱스에 1의 값을, 다른 인덱스에는 0을 부여하는 단어의 벡터 표현방식이다. 이렇게 표현된 벡터를 원-핫 벡터(One-hot Vector)라고 한다.
원-핫 인코딩을 두가지 과정으로 정리한다면 아래와 같다.
- 각 단어에 고유한 인덱스 부여하기(정수 인코딩)
- 표현하고 싶은 단어의 인덱스 위치에 1을 부여하고 다른 단어의 인덱스 위치에 0 부여하기
간단한 예제로는 이렇게 볼 수 있다.
import numpy as np
sentence = "I have a book and I like romance"
token = sentence.split()
print(token)
>>> ['I', 'have', 'a', 'book', 'and', 'I', 'like', 'romance']
# create vocabulary
word_set = list(set(token))
word_dict = {w:i for i, w in enumerate(word_set)}
word_dict
>>> {'I': 0, 'like': 1, 'and': 2, 'have': 3, 'romance': 4, 'a': 5, 'book': 6}
def one_hot_encoding(word, word_dict):
one_hot_vector = np.zeros(len(word_dict)) # 단어사전 길이만크의 0벡터 생성
one_hot_vector[word_dict[word]] = 1 # 해당하는 단어의 index 자리에 1 부여
return one_hot_vector
one_hot_encoding('I', word_dict)
>>> array([1., 0., 0., 0., 0., 0., 0.])
word_list = []
for w in token:
word_list.append(one_hot_encoding(w, word_dict))
print(np.array(word_list)) ## full sentence onehotencoding
>>>
[[1. 0. 0. 0. 0. 0. 0.]
[0. 0. 0. 1. 0. 0. 0.]
[0. 0. 0. 0. 0. 1. 0.]
[0. 0. 0. 0. 0. 0. 1.]
[0. 0. 1. 0. 0. 0. 0.]
[1. 0. 0. 0. 0. 0. 0.]
[0. 1. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 1. 0. 0.]]
1.1 원핫인코딩(One-hot Encoding)의 단점
원핫인코딩의 치명적인 단점은, 단어사전의 수가 클수록 벡터를 저장하기 위해 필요한 공간이 계속 늘어난다는 점이다. 다른 말로는 벡터의 차원이 곧 단어사전의 개수이므로 단어사전의 사이즈가 클수록 벡터의 차원이 커진다는 것이다. 평소 자연어 처리를 할때 기본 1000개의 단어가 넘어가는 상황에서 단어의 수만큼 차원이 늘어난다는 것은 저장 공간의 측면에서 매우 비효율적이라고 할 수 있다.
또한 단어 간의 유사성을 알 수 없다는 단점이 있다. 강아지와 고양이, 바나나와 오렌지라는 단어에 대해서 원핫인코딩을 한다면 [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]으로 원-핫 벡터를 부여받음으로 단어를 독립적으로 여기고 강아지와 고양이가 유사하고, 바나나와 오렌지가 유사하다고 표현할 수 없다.
이런 단점을 보완하기 위해서 나타난 것이 단어의 차원축소 과정인 워드 임베딩 (Word Embedding)과정이다. 워드 임베딩은 단어를 벡터로 표현하는 것을 말하는데 one-hot encoding과 같이 희소한 벡터(Sparse vector)형태의 밀집 벡터(Dense vector)의 형태로 표현시켜주는 방법을 말한다. 임베딩 과정을 통해 나온 벡터를 임베딩 벡터라고도 한다. 워드 임베딩의 방법론으로는 LSA, Word2Vec, FastText, Glove 등이 있다. 지금은 Word2Vec방법론에 대해서 알아보고자 한다.
2. 워드투벡터(Word2Vec)
앞서 말한 원핫 인코딩을 통해서 나온 벡터들은 표현하고자 하는 단어의 인덱스 값만 1이고 나머지 인덱스의 값은 전부 0으로 표현되는 희소 벡터(sparse vecotr)이다. 이런 희소 벡터는 단어간 유사성을 표현할 수 없을 뿐 아니라 단어의 사이즈가 커질수록 고차원이 되는 단점이 있다.
이를 위한 대안으로 단어의 '의미'를 다차원 공간에 벡터화 하는 방법을 찾게 되는데, 단어의 유사도를 벡터화하는 작업을 워드 임베딩(word embedding)이라고 한다. 그렇게 표현된 벡터를 또한 임베딩 벡터(embedding vector)라고 하며, 저차원을 가지므로 밀집 벡터(dense vector)라고도 한다.
이러한 워드 임베딩의 학습 방법으로는 여러가지가 있는데, 요즘은 Word2Vec가 많이 쓰인다. Word2Vec의 방법은 크게 두가지 방식이 있는데, CBOW(Continuous Bag of Words)와 Skip-Gram 이 있다. CBOW는 주변에 있는 단어들을 가지고, 중간에 있는 단어들을 예측하는 방법을 말하며, 반대로 Skip-Gram은 중간에 있는 단어로 주변의 단어들을 예측하는 방법이다.
2.1 CBOW(Continuous Bag of Words)
더 자세하게 알아보기 위해 CBOW에 대해서 알아보자. 예를 들어 코퍼스에 "the fat cat sat on the mat"과 같은 문장이 있다고 하자. {'the','fat','cat','on','the','mat'}을 이용해 'sat'을 예측하는 것이 CBOW의 일이다. 이때 예측할 단어를 중심단어, 예측에 사용되는 단어들을 주변단어라고 한다.
중심 단어를 예측하기 위해 앞뒤로 몇개의 단어를 볼지 정했다면, 그 범위를 윈도우 크기(window size)라고 한다. 윈도우 크기가 n이라고 한다면, 실제 중심 단어를 예측하기 위해 참고하려는 주변 단어의 개수는 2n이 되는 것이다.
윈도우 크기를 정했다면, 윈도우를 움직여가며 주변 단어와 중심 단어 선택을 바꿔가며 학습을 위한 데이터 셋을 만들어가는데, 이 방법을 슬라이딩 윈도우라고 한다. 위 그림의 경우, window size를 2로 슬라이딩 윈도우가 어떤식으로 이루어지며 데이터 셋을 만드는지 보여준다. 또한 Word2Vec에서의 입력은 모두 원-핫 벡터로 이루어져야 하는데, 어떻게 원핫 벡터가 되는지 보여 준다. 위와 같은 그림이라면, 학습 데이터의 개수가 7개가 되는 것이다.
CBOW의 인공신경망의 형태를 보연 다음과 같다. 주변 단어들의 원핫 벡터가 input layer로 들어가게 하고 Output layer에 예측하고자 하는 중간 단어의 원핫 벡터가 나오게 한다.
위 그림에서 알 수 있는 사실은 Word2Vec은 딥러닝 모델이 아니라는 점이다. 보통 입력층과 출력층 사이에 개수가 충분히 쌓인 신경망을 학습할 대 딥러닝이라 하는데, 해당 모델은 입력층과 출력층 사이에 하나의 은닉층만 존재하기 때문에 얕은 신경망(Shallow Neural Network)라고 한다. 또한 Word2Vec은 일반적인 은닉층과는 달리 활성화 함수가 존재하지 않아 일반적인 은닉층과 구별하기 위해 투사층(projection layer)라고 부르기도 한다.
안의 내부 신경망을 좀더 확대해서 알아보면, 중요한 것이 두가지가 있는데, 하나는 투사층의 크기가 임베딩하고 난 벡터의 차원 M이라는 점이다. 그리고 두번째는 입력층과 투사층 사이에 가중치 W는 V(단어 사전의 크기) ⅹ M(임베딩 된 벡터) 행렬이며, 투사층에서 출력 층 사이의 가중치 W'는 MⅹV 행렬이라는 점이다. 여기서 W와 W'는 동일한 행렬을 전치(transpose)한 것이 아니라 서로 다른 행렬이라는 점이다. CBOW는 주변 단어로 중심 단어를 더 정확히 마주기 위해 가중치 W와 W'를 학습해 가는 구조이다.
입력으로 들어오는 주변 단어의 원-핫 벡터 \${x}\$를 가중치 W를 곱하면 W에서는 \${x}\$의 값에 대한 행을 읽어오는 것과 같다(lookup). 학습된 가중치 W가 곱해서 생겨진 결과 벡터들은 투사층에서 만나 이 벡터들의 평균 벡터를 구하게 된다. 만약 윈도우 크기가 2라면, 입력 벡터의 총 개수는 2n이므로 중간 단어를 예측하기 위해서는 총 4개가 입력벡터로 사용된다. 그렇기 때문에 평균을 구할때는 4개의 결과 벡터에 대해서 평균을 구해 투사층 이 M의 크기로 나오는 것이다. 투사층에서 벡터의 평균을 구하는 부분은 CBOW가 Skip-Gram과 다른 차이점이기도 하다.(skip-gram은 입력이 중심단어 하나라서 벡터의 평균을 구하지 않는다.)
이렇게 구해진 평균 벡터는 두번째 가중치 행렬 W'와 곱해져 원핫 벡터들과 차원이 V로 동일한 벡터가 나온다. 이 벡터에 softmax함수를 취해 0과 1사이의 실수로 나타내게 된다.
모델을 Pytorch 예제로 만들면 아래와 같이 만들 수 있다.
class CBOW(nn.Module):
def __init__(self, voc_size, embedding_size):
super(CBOW, self).__init__()
self.W = nn.Embedding(voc_size, embedding_size)
self.WT = nn.Linear(embedding_size, voc_size, bias = False)
def forward(self, X):
# [batch_size, window_size*2]
# One_hot_encoding : [batch_size, window_size*2, voc_size]
p_layer = self.W(X) # projection_layer : [batch_size, window_size*2, embedding_size]
p_layer = p_layer.mean(dim = 1) # mean_Weight = [batch_size, embedding_size]
output = self.WT(p_layer)
return output
2.2 Skip-gram
앞에서 CBOW는 주변 단어를 통해 중심 단어를 예측했다면, Skip-gram은 중심 단어를 통해 주변 단어를 예측하는 것을 말한다.
만일 위 사진처럼 데이터가 구성되어있다면, skipgram의 input 데이터와 output데이터는 ('the', 'fat'),('the','cat'),('the','set')...이런식으로 구성된다. 중심단어 하나에 예측해야하는 값들이 주변단어의 개수만큼 있으므로 데이터의 개수는 중심단어 개수 * window_size*2가 된다.
위 그림처럼 예측을 하면 Matrix W가 embedding Matrix가 된다. CBOW와 같이 원핫된 input vector가 Matrix M과 곱해지면 해당 단어에 대한 index만 추출되기 때문에 해당 단어의 임베딩 벡터를 얻을 수 있다.
해당 모델을 Pytorch로 구현하면 아래와 같다.
class SkipGram(nn.Module):
def __init__(self, voc_size, embedding_size):
super(SkipGram, self).__init__()
self.W = nn.Linear(voc_size, embedding_size, bias = False)
self.W_p = nn.Linear(embedding_size, voc_size, bias = False)
def forward(self, X):
# [batch_size, voc_size]
p_layer = self.W(X) # projection_layer : [batch_size,embedding_size]
output = self.W_p(p_layer) # [batch_size, voc_size]
return output
여러 논문에서 성능 비교를 진행했을 때, 하나의 단어로 여러 주변단어를 예측해서 더 범용적이라 그런지, SkipGram이 CBOW보다 성능이 좋다고 알려져 있다.
한가지 궁금한건, CBOW에서는 이런 Embedding matrix를 볼수 없는가 하는 것이다. 가운데 projection layer가 임베딩 벡터려나...?
3. 네거티브 샘플링(Negative Sampling)
Word2Vec에도 단점이 있는데, 바로 속도다. 결국 Word2Vec도 마지막의 출력층이 단어사전의 크기만큼이기 때문에 소프트맥스 함수를 사용해서 집합 크기의 벡터 내의 모든 값을 바꾸어주어야 한다. 그래서 단어사전이 크기가 점점 커질수록 softmax를 계산하는데 많은 연산량이 필요하게 되어 속도나 메모리 측면에서 비효율적일 수 있다. 그래서 나온 방법이 네거티브 샘플링이다.
네거티브 샘플링은 출력층에서 단어를 비교할 때 모든 단어와 비교하는 것이 아닌 정답이 아닌 일부 단어하고만 비교한다. 이렇게 전체 단어 집합보다 훨씬 작은 단어 집합을 만들어놓고 마지막 단계를 이진 분류 문제로 바꿔버린다. 그래서 Word2Vec에서 주변 단어들은 긍정으로 두고, 랜덤으로 샘플링된 단어들은 부정으로 두어 이진분류 문제를 해결한다.
이는 기존의 다중 클래스 분류 문제를 이진 분류 문제로 바꾸면서 연산량이 훨씬 효율적이다.
4. Word2Vec의 한계
Word2Vec에도 한계가 존재하는데, 바로 동음이의어에 대한 문제다. Hidden layer하나로 언어의 관계를 전부 나타내기에 충분하지 않은 것도 있지만, 동음이의어의 경우에 항상 동일한 벡터 값으로 표현할 수 밖에 없다. 예를들어
- 어부가 *배*를 타고 바다에 나가 낚시를 했다.
- 오늘 *배*가 아파서 병원에 갔다.
- 오늘 먹은 *배*가 달고 맛있다.
위 세개의 문장은 실제로 다른 의미를 갖고 있지만, Word2Vec을 사용하면 해당 3개의 벡터가 다 같은 벡터로 표현된다.
이러한 문제가 있어 추후에 Attention알고리즘이 나왔다고 하는데, 이는 추후에 알아볼 예정이다.
※ 해당 내용에 대한 실습 문제는 github에서 확인할 수 있다.
'Study > NLP' 카테고리의 다른 글
[NLP/자연어처리] 순환신경망 (Recurrent Neural Network, RNN) (0) | 2021.05.20 |
---|---|
[NLP/자연어처리] 단어의 표현(2) - 카운트 기반의 단어 표현 (0) | 2021.05.18 |
[NLP/자연어처리] 자연어처리 전처리(4) - 토치텍스트(TorchText) (0) | 2021.05.13 |
[NLP/자연어처리] 자연어 처리 전처리(3) - 단어집합(Vocabulary), 패딩 (4) | 2021.05.12 |
[NLP/자연어처리 ]자연어 처리 전처리(2) - 분절(토큰화) 라이브러리 소개 (0) | 2021.05.11 |