본문 바로가기

Data Science/밑바닥부터시작하는딥러닝(2)

[밑시딥] 단어의 분산을 표현하는 또 다른 기법, word2vec

반응형

🔊 해당 포스팅은 밑바닥부터 시작하는 딥러닝 2권의 교재 내용을 기반으로 자연어처리 딥러닝 신경망을 Tensorflow, Pytorch와 같은 딥러닝 프레임워크를 사용하지 않고 순수한 Numpy로 구현하면서 자연어 처리의 기초를 탄탄히 하고자 하는 목적 하에 게시되는 포스팅입니다. 내용은 주로 필자가 중요하다고 생각되는 내용 위주로 작성되었음을 알려드립니다.

 

밑바닥부터 시작하는 딥러닝(2)


이번 포스팅에서는 단어의 분산을 표현하는 또 다른 방법으로 신경망을 사용하는 추론 기법인 word2vec에 대해 배워본다. word2vec를 구현하기 위한 신경망 모델의 종류 중 하나로 CBOW 모델에 대해 알아보고 이를 넘파이로 구현하는 방법도 알아보자.

1. 추론 기법의 등장

단어의 분산을 표현하는 또 다른 방법으로 추론 기법이 등장했다. 그런데 추론 기법은 왜 등장했을까? 저번 포스팅에서 주로 알아본 통계 기반 기법에는 여전히 한계점이 존재했다. 통계 기반 기법을 다시 회고해보면, 말뭉치를 통해 단어들의 동시발생 행렬을 만들고 이를 기반으로 PPMI(양의 상호정보량)을 계산한 후, SVD와 같은 차원축소를 수행해주어 단어의 분산을 표현했다. 정확히 말하면 단어의 벡터를 얻을 수있었다. 하지만 이러한 통계 기법은 말뭉치가 매우 클 때, SVD로 차원축소를 한다고 하더라도, SVD 수행 이전에 단어의 동시발생 행렬을 만드는 데 매우 오래걸린다. 설사 어떻게 해서 SVD로 차원축소를 한다고 해도, 시간복잡도 $O(N^3)$이 걸리는 SVD는 슈퍼컴퓨터를 동원해도 처리할 수 없는 수준의 엄청난 연산량이 필요하다.

 

반면 추론 기법은 '추론'하는 과정에서 신경망 모델을 활용해 학습시킨 후, 단어의 분산 표현을 얻을 수 있다. 여기서 통계 기반 기법과 추론 기반 기법의 큰 차이점을 하나 짚고 넘어가자. 통계 기반 기법은 위에서 언급한 것처럼 한 번만 동시발생 행렬을 계산 하고 SVD도 1번만 수행해 단어의 분산 표현을 얻을 수 있다. 즉, 말뭉치라는 데이터 전체를 1번만 활용하면 된다.

 

반면에, 신경망을 활용하는 추론 기법은 말뭉치 데이터 전체 중 일부 가져와서 신경망을 학습시키고, 이후 또 데이터 전체 중 일부를 가져와서 신경망을 학습시키고 하는 반복적인 과정을 거친다. 결국, 신경망 학습 방법에 비유를 들자면, 통계 기반 기법은 전체 데이터를 한 번에 다 넣는 배치 학습법, 추론 기반 기법미니 배치 학습법이라고 할 수 있다.

2. 추론 기법은 어떻게 추론하는가?

그렇다면 추론 기법은 어떻게, 또 무엇을 추론할까? 추론 기법은 기본적으로 '분포 가설'이라는 것을 가정한다. 이는 통계 기반 기법도 가정하고 있는 사항이었는데, 분포 가설이라함은 '특정 단어의 의미는 주변의 단어 즉, 맥락에 의해 결정된다.'라는 가설이다. 추론 기법도 이 가설을 사전에 가정하기 때문에 추론 기법은 "주변 단어를 활용해 특정 공간에 들어갈 단어를 추측한다"는 방식으로 추론한다. 추론 기법을 도식화하면 아래와 같다.(참고로 통계, 추론 기반 기법 모두 분포 가설을 가정하기 때문에, 결국 '단어의 동시발생 가능성'을 얼마나 잘 모델링하는가가 단어의 분산을 표현하는 데 매우 중요하다고 한다)

 

you, goodbye를 활용해 물음표 단어를 예측

 

자, 위와 같이 추론하는 방법은 알았다. 그런데 추론을 할 때, 어떤 근거를 가지고 추론을 해야 할 것이 아닌가? 그냥 무턱대고 추론을 하면 안되지 않겠는가? 이 때, 필요한 것이 바로 신경망 모델이다. 즉, 신경망 모델을 활용해 말뭉치 데이터를 학습 데이터로서 학습하면서 단어의 출현 패턴을 파악한다. 그리고 테스트 데이터로서 위와 같은 빈칸이 들어가있는 데이터가 들어오면 미리 학습한 패턴에 기반해 물음표 칸에 들어갈 단어를 예측하는 것이다. 추론 기법의 전반적인 과정을 간략하게 도식화하면 아래와 같다.

 

단어의 분산을 표현하는 추론 기반 기법의 과정

3. 신경망 모델에서 단어 처리

이제 추론 기법에서 신경망 모델을 사용하는 이유도 알아보았다. 그런데 단어는 문자열인 텍스트로 이루어져있는데, 어떻게 이를 숫자로 표현해서 신경망의 입력으로 넣어줄까? 이 때는 단어를 원-핫 벡터로 표현해준다.

 

예를 들어서, "you say goodbye and I say hello." 라는 말뭉치가 존재한다고 가정하자. 여기서 'you'라는 단어를 어떻게 원-핫 벡터로 표현해줄까? 우선은 주어진 말뭉치에서 unique한 단어들의 개수를 세어준다. 예시로 든 말뭉치로 구해보자면, ['you', 'say', 'goodbye', 'and', 'I', 'hello', '.'] 이렇게 총 7가지가 된다.(마지막 마침표까지 포함한다) 이렇게 구한 7이라는 숫자를 길이로 하며 모두 0을 원소로 하는 배열을 정의한다. 그리고 'you'에 해당하는 인덱스의 원소값만 1로 바꾸어 준다. 따라서 'you'를 원-핫 벡터로 표현하게 되면 [1, 0, 0, 0, 0, 0, 0] 이 된다.

 

위 과정을 넘파이로 간단하게 구현하게 되면 아래와 같다. 소스코드로 이해하는 것이 더 확실할 수 있다. 아래 코드에서는 한 층의 신경망을 만들었으며 출력층 노드 개수는 3개로 하였고 편향은 여기서 생략했다.

 

import numpy as np

C = np.array([[1,0,0,0,0,0,0]])  # shape (1,7)
W = np.random.randn(7, 3)        # shape (7,3)
h = np.matmul(C, W)              # shape=> (1,7) x (7,3) = (1,3)
print('h shape:', h.shape)
print(h)

4. 신경망 모델 종류 - CBOW 모델

이제 추론 기법에서 사용하는 신경망 모델 종류 중 하나로서 CBOW(Continuous Bag Of Words) 모델에 대해 알아보자. 참고로 CBOW 모델 이외에 Skip-gram 모델도 존재하는데, 이는 하단에서 간략하게 다룬다.

 

CBOW 모델은 위에서 '추론 기법이 어떻게 추론하는지'를 설명할 때와 동일하게 주변의 단어들(맥락)을 활용해서 중앙의 단어를 예측하는 모델이다. 즉, 주변의 단어들이 인풋으로 들어가고 중앙의 단어가 출력으로 나오게 된다. 우선 간단한 CBOW 신경망 모델의 그림부터 살펴보면서 하나하나씩 이해해보자.

 

입력 데이터가 2개일 때의 간단한 CBOW 모델 구조

 

위 그림을 보면 입력이 'you', 'goodbye' 라는 단어 2개가 원-핫 인코딩된 형태로 들어가고 있는 것을 볼 수 있다. 이는 맥락으로 고려할 단어를 2개로 정했기 때문이다. 입력이 2개이기 때문에 은닉층으로 가는 가중치 값을 2번 계산해야 한다. 하지만 각 입력에 대해 사용하는 가중치는 서로 다른 값을 갖고 있는 것이 아닌 동일한 값을 갖고 있는 똑같은 가중치 $W_{in}$을 사용한다. 

 

이렇게 입력과 $W_{in}$ 행렬 곱을 수행해서 은닉층으로 전개된다. 그런데 이때, 은닉층의 값은 구체적으로 말하면 두 번 계산된다. 왜냐하면 방금도 언급했다시피 입력 데이터 2개에 대해 $W_{in}$이라는 파라미터를 2번 계산했기 때문이다. 따라서 2번 계산한 은닉층에 대한 최종값은 각각 계산된 은닉층 값을 입력 데이터 개수(고려하는 맥락 개수)로 나누어 주어 평균을 취해준다. 

 

이후 은닉층에서 다시 출력층으로 가기 위해 $W_{out}$이라는 가중치를 또 곱해주어 이번엔 입력 데이터 shape과 동일한 출력값을 내뱉는다. 이 때, 출력값을 입력 데이터와 동일한 shape으로 해주는 이유는 CBOW 모델이 무엇을 예측하는 것인지로부터 찾을 수 있다. 우리는 CBOW 모델로 주변 맥락 단어를 이용해 가운데에 올 단어를 예측하는 것이 목적이었다. 따라서 위 예시로 들면, 'you' 와 'goodbye' 라는 2개의 맥락 단어를 고려해서 출력층에서 가장 높은 Score를 갖는 것에 해당하는 단어가 바로 가운데에 올 단어라고 예측하는 것이다.

 

그렇다면 여기서 잠시 CBOW 모델 자체를 활용하는 이유는 무엇이었는지 상기시켜보자. 바로 단어의 분산 표현 즉, 적절한 단어의 벡터를 얻기 위함이었다. 그러면 위 CBOW 모델의 도식화 그림에서 단어의 벡터들은 어디있을까? 바로 $W_{in}$ 과 $W_{out}$에 있다! 즉, $W_{in}$ 에서 행 벡터에 해당하는 값이 바로 특정 단어에 대한 분산 표현인 단어 벡터를 의미한다.

 

CBOW 모델의 입력층 파라미터는 단어의 벡터가 된다

 

이렇게 신경망 모델의 파라미터가 단어의 분산 표현에 해당하기 때문에 CBOW 신경망 모델이 단어 출현 패턴을 학습하면서 파라미터가 갱신됨으로써 이는 결국 단어의 분산 표현도 단어 추측을 잘하는 방향으로 갱신되는 것이다. 은닉층의 뉴런 개수는 위에서 처럼 입력 데이터의 노드 개수보다 작게하는 것이 중요하다. 왜냐하면 입력 데이터는 원-핫 형태의 Sparse한 벡터 일 것이다. 이 Sparse한 정보를 압축하기 위해 Dense한 벡터로 변환하기 위해서는 Sparse한 벡터의 크기 즉, 노드 개수보다 작은 노드 개수로 은닉층 노드를 설정해야 한다. 이러한 과정은 '인코딩(Encoding)'에 해당한다. 

 

CBOW 모델을 도식화해서 그려본 간단한 신경망을 넘파이로 구현하는 소스코드는 아래와 같다. 아래의 MatMul 클래스는 여기를 참고하자.

 

import numpy as np
from common.layers import Matmul

c0 = np.array([[1, 0, 0, 0, 0, 0, 0]])  # 'you'
c1 = np.array([[0, 1, 0, 0, 0, 0, 0]])  # 'say'

# 파라미터 초기화
W_in = np.random.randn(7, 3)
W_out = np.random.randn(3, 7)

# 계층 생성
in_layer1 = Matmul(W_in)
in_layer2 = Matmul(W_in)
out_layer = Matmul(W_out)

# Forward
h1 = in_layer1.forward(c0)
h2 = in_layer2.forward(c1)
h = (h1 + h2) / 2
out = out_layer.forward(h)

print('out shape:', out.shape)
print(out)

 

그런데, 위에서 살펴본 CBOW 신경망에 약간 아쉬운 점이 있다. 바로 출력층의 Score를 0과 1 사이의 확률 값으로 변환시키기 위해 Softmax 활성함수를 추가해야 한다.

 

활성화 함수까지 적용한 간단한 CBOW 신경망 모델

 

그런데 아까 위에서 CBOW 모델에서 우리가 원하는 단어의 분산을 갖고 있는 것이 $W_{in}$과 $W_{out}$이라고 했다. 즉, 단어의 분산 표현이 두 가지로 나왔다는 것인데 우리는 둘 중 어떤 것을 선택해야 할까?(이 때, $W_{out}$로부터 단어 분산 표현을 가져가려면 $W_{in}$ 때와는 달리 열 벡터로 가져가야 한다)

 

책에서는 위 고민에 대한 선택지를 3가지 제시한다. $W_{in}$만 활용하기, $W_{out}$만 활용하기, 둘 다 활용하기. 대중적으로는 $W_{in}$ 만 활용해서 단어의 분산 표현을 얻는다고 한다. 특히, CBOW와는 다른 신경망 모델인 Skip-gram 모델에서는 특히 $W_{in}$만 활용한다고 한다. 하지만 word2vec와 비슷한 방법인 GloVe 에서는 두 가중치를 더했을 때 가장 좋은 결과를 얻었다고 한다.(GloVe에 대해 궁금하다면 Weekly NLP 글을 참고하자. Glove는 말뭉치 전체의 통계 정보를 신경망의 손실 함수에 도입한 후 학습하는 방법이라고 한다. 즉, 통계 기반 기법 + 추론 기반 기법의 융합인 셈!)

5. CBOW 모델 Numpy로 구현하기

CBOW 신경망 모델의 구조도 알아보았으니 이제 직접 넘파이를 활용해 모델을 학습시켜보자. 가장 먼저 해야할 일은 주어진 말뭉치 데이터를 CBOW 모델의 입력 형태에 맞게 변환해주어야 한다. 이번 포스팅에서는 말뭉치를 매우 간단한 문장 하나인 "you say goodbye and I say hello."로만 간주하자. 그리고 CBOW 모델은 맥락 데이터를 입력으로 한다고 했다. 그러므로 맥락을 여기서는 2개(좌,우 하나씩)를 고려한다고 가정하자. 그렇게 한다고 했을 때, 주어진 말뭉치에 대해 맥락(입력), 타겟(출력) 데이터를 도식화하면 아래와 같다.

 

말뭉치로부터 맥락, 타깃 데이터를 만들어보자

 

위 그림에서 노란색 표시의 단어는 '타깃' 단어를 의미한다. 즉 CBOW 모델에서 출력되는 단어이다. 그리고 밑줄 친 단어들이 바로 양옆의 맥락 단어를 의미한다. 하지만 위 단어는 여전히 문자열 형태이다. 위 형태를 수치형으로 바꾸어주기 위해서 이전 포스팅에서 실습해본 preprocess 함수를 사용한다. preprocess 함수는 말뭉치를 단어 단위로 쪼갠 후 단어 고유 ID를 붙힌 리스트와 단어를 key로 하고 단어의 ID를 value로 하는 딕셔너리, 그리고 이 key, value를 뒤바꾼 딕셔너리를 반환해주었다. 혹시 까먹었다면 이전 포스팅을 참고하거나 소스코드를 참고하자.

 

위 preprocess 함수를 사용한 후, 반환되는 객체들을 활용해서 위 맥락, 타깃 데이터를 숫자형으로 아래처럼 바꾸어줄 수 있다.

 

텍스트에서 수치형태로 바꾸어준 맥락, 타깃 데이터

 

위 수치형은 일종의 레이블 인코딩 형태로 되어 있음을 의미한다. 즉, 0이라는 것은 "you say goodbye and I say hello." 라는 말뭉치에서 추출한 단어들의 고유 ID 중에서 0번 ID를 갖는 단어를 의미한다. 소스코드로 구현하면 아래와 같다.

 

import numpy as np
from common.util import preprocess


def create_contexts_target(corpus: list, window_size=1):
    """ 말뭉치 리스트를 활용해 맥락, 타겟 numpy array 반환
    
    Args:
        corpus: preprocess 메서드로 반환된 단어ID가 담긴 말뭉치 Python list 객체
        window_size: 맥락을 몇 개 고려할 것인지 (ex. window_size=1 이면 좌,우 총 2개의 맥락을 고려)

    """
    target = corpus[window_size:-window_size]  # window_size=1 이면 양끝 단어를 제외한 나머지를 target으로 설정
    contexts = []
    
    # idx: target 값이 있는 corpus의 index를 의미
    for idx in range(window_size, len(corpus)-window_size):
        cs = []
        for t in range(-window_size, window_size+1):
            if t == 0: # target 자신은 맥락에 담지 않음
                continue
            cs.append(corpus[idx + t])
        contexts.append(cs)
    
    return np.array(contexts), np.array(target)


text = 'You say goodbye and I say Hello.'
corpus, word_to_id, id_to_word = preprocess(text)
cs, tgt = create_contexts_target(corpus, window_size=1)
print('맥락:\n', cs)
print()
print('타깃:\n', tgt)

 

그런데 개선할 점이 또 하나 있다. 아직 맥락과 타깃 데이터가 하나의 정수형 스칼라 값으로 레이블 인코딩된 형태이다. 이제 이를 원-핫 인코딩 형태로 변환해보자. 원-핫 인코딩 형태로 변환하는 소스코드는 아래와 같다.

 

def convert_one_hot(corpus: np.array, vocab_size):
    """ create_contexts_target 함수를 활용해 얻은 맥락 또는 타겟 데이터를 원-핫 인코딩 형태로 변환
    
    Args:
        corpus: create_contexts_target 함수를 활용해 얻은 맥락 또는 타겟 데이터
        vocab_size: 말뭉치 속 unique한 단어들의 개수
    
    """
    N = corpus.shape[0] # 맥락 또는 타겟 데이터 총 개수
    
    if corpus.ndim == 1:
        one_hot = np.zeros((N, vocab_size), dtype=np.int32)
        for idx, word_id in enumerate(corpus):
            one_hot[idx, word_id] = 1
    
    elif corpus.ndim == 2:
        C = corpus.shape[1] # 한번에 고려하는 맥락 개수
        one_hot = np.zeros((N, C, vocab_size), dtype=np.int32)
        for idx_0, word_ids in enumerate(corpus):   # 맥락 데이터 총 개수를 loop
            for idx_1, word_id in enumerate(word_ids):  # 한번에 고려하는 맥락 개수 loop
                one_hot[idx_0, idx_1, word_id] = 1
    
    return one_hot

 

위 convert_one_hot 이라는 함수까지 활용해서 말뭉치 데이터로부터 맥락, 타깃 데이터를 원-핫 형태로 변환하는 총 코드는 아래와 같다.

 

import numpy as np
from common.util import preprocess

text = 'You say goodbye and I say Hello.'
corpus, word_to_id, id_to_word = preprocess(text)

corpus, target = create_contexts_target(corpus, window_size=1)

vocab_size = len(word_to_id)
one_hot_corpus = convert_one_hot(corpus, vocab_size)
one_hot_target = convert_one_hot(target, vocab_size)

print('One-hot corpus shape:', one_hot_corpus.shape)
print(one_hot_corpus)
print()

print('One-hot target shape:', one_hot_target.shape)
print(one_hot_target)

 

출력 결과는 다음과 같다. 아래에서 맥락, 타깃 데이터의 shape를 찍어보면서 어떤 차원이 어떤 것을 의미하는지 나타내보았다.

 

소스코드 출력 결과

 

0번째 차원의 6은 맥락, 타깃 데이터의 총 개수를 의미한다. 우리가 아까 위에서 살펴본 도식화를 다시 살펴보자. 아래의 그림에서 6개를 의미하는 바는 다음과 같다.

 

6이 의미하는 바는 '총 6개' 를 의미한다

 

그리고 맥락 데이터 shape의 1번째 차원인 2라는 숫자는 맥락을 2개 고려했다는 의미이다. 마지막으로 맥락, 타깃 데이터의 마지막 차원의 원소 개수인 7은 주어진 말뭉치 데이터의 Unique한 단어들 개수 즉, Vocabulary의 size가 된다. 주어진 말뭉치 "you say goodbye and I say hello."에서 Vocabulary size는 총 7개로 [ you, say, goodbye, and, I, hello, . ] 가 되기 때문이다. 이를 원-핫 인코딩 형태로 표현했기 때문에 원소의 수가 7이 된다.

 

이제 데이터를 모델에 맞는 형태로 변경해주었으니 CBOW 모델을 빌드해서 학습시켜보자! 간단한 CBOW 모델을 빌드하는 소스코드는 아래와 같다. 아래의 소스코드에서는 이전에 행렬곱, 소프트맥스와 크로스엔트로피오차 계층을 구현했던 클래스들을 활용했다. 이는 여기를 참고하자.

 

   
import numpy as np
from common.layers import Matmul, SoftmaxWithLoss


class SimpleCBOW:
    """ 간단한 1층 CBOW 신경망 모델
    
    Args:
        vocab_size: 말뭉치 속 unique한 단어 개수
        hidden_size: 1층 은닉층 속 뉴런 개수
    
    """
    def __init__(self, vocab_size, hidden_size):
        V, H = vocab_size, hidden_size
        
        # 파라미터 초기화
        W_in = 0.01 * np.random.randn(V, H).astype('f')  # 'f'로 하면 32비트 부동소수점으로 변환
        W_out = 0.01 * np.random.randn(H, V).astype('f')
        
        # 계층 생성 -> 현재 고려하는 맥락이 2개이기 때문에 W_in 파라미터를 사용하는 MatMul 계층도 2개!
        self.in_layer1 = Matmul(W_in)
        self.in_layer2 = Matmul(W_in)
        self.out_layer = Matmul(W_out)
        self.loss_layer = SoftmaxWithLoss()
        
        # 모든 파라미터 모으기
        self.params, self.grads = [], []
        layers = [self.in_layer1, self.in_layer2, self.out_layer, self.loss_layer]
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads
            
        # 우리가 얻고자 하는 단어의 분산 표현
        self.word_vecs = W_in
        
    
    def forward(self, contexts, target):
        # 맥락 데이터(contexts) 전체 중에서 모든 0번째 맥락, 1번째 맥락 각각 가져오기
        h0 = self.in_layer1.forward(contexts[:, 0, :])  # contexts[:, 0] 으로 해도 무방
        h1 = self.in_layer2.forward(contexts[:, 1, :])
        h = (h0 + h1) * 0.5
        score = self.out_layer.forward(h)
        loss = self.loss_layer.forward(score, target)
        
        return loss
    
    
    def backward(self, dout=1):
        d_score = self.loss_layer.backward(dout)
        d_h = self.out_layer.backward(d_score)
        d_h *= 0.5
        self.in_layer1.backward(d_h)
        self.in_layer2.backward(d_h)
        
        return None

 

위에서 정의한 SimpleCBOW를 학습시켜보자. 학습시킬 때는 이전 챕터에서 모델과 Optimizer를 넣어주면 편리하게 학습할 수 있는 Trainer 클래스를 활용한다. Trainer 클래스는 여기, Optimizer 클래스는 여기를 참고하자. 저자의 코드를 필자가 직접 따라쳐보면서 한줄한줄 이해하면서 주석도 작성해놓았기 때문에 궁금하신 분들은 살펴보자.

 

# 간단한 CBOW 모델 학습 구현하기 -> Trainer 클래스 활용
from common.util import preprocess, create_contexts_target, convert_one_hot
from common.optimizer import Adam
from common.trainer import Trainer
from simple_cbow import SimpleCBOW

# 1. 하이퍼파라미터 설정
window_size = 1   # 맥락 고려 개수(1이면 2개의 맥락을 고려, 2이면 4개의 맥락을 고려)
hidden_size = 5
batch_size = 3
epochs = 1000

# 2. 말뭉치 전처리
text = 'You say goodbye and I say Hello.'
corpus, word_to_id, id_to_word = preprocess(text)
contexts, target = create_contexts_target(corpus, window_size)
                                          
vocab_size = len(word_to_id)
contexts_ohe = convert_one_hot(contexts, vocab_size)
target_ohe = convert_one_hot(target, vocab_size)

# 3. 신경망 모델(Trainer 클래스)
model = SimpleCBOW(vocab_size, hidden_size)
optimizer = Adam()
trainer = Trainer(model, optimizer)

# 4. 학습
trainer.fit(x=contexts_ohe, t=target_ohe, max_epochs=epochs, batch_size=batch_size)
trainer.plot()

 

출력 결과를 보면 모델의 손실함수 값이 점점 감소함에 따라 학습이 잘 되는 것을 볼 수 있다.(물론 매우 간단한 말뭉치지만!) Epoch 수에 따라 손실함수 값 그래프를 표현해보면 아래와 같다.

 


6. CBOW 모델과 확률의 관계

해당 챕터는 책에서 보충 챕터로 다루긴 하지만 배울 내용이 많아 작성해보려 한다. 바로 위에서 배운 CBOW 모델을 통계 기반 기법처럼 '확률'의 관점에서 바라보았을 때 어떻게 수식이 작성될 수 있는지에 대한 내용이다.

 

어떤 특정한 사건 A가 발생할 확률을 $P(A)$라고 나타낼 수 있다. 그렇다면 특정한 사건 A, B가 동시에 발생할 '동시확률'은 $P(A, B)$로 나타낼 수 있다. 또한 '조건부 확률'로 잘 알려져 있는 사후확률인 $P(A|B)$는 B라는 사건이 일어난 후에 A라는 사건이 일어날 확률을 의미한다. 이러한 확률 수식 개념을 가지고 CBOW 모델의 수식을 살펴보자.

 

CBOW 모델을 다시 상기하면, 주변의 맥락을 이용해 가운데에 있는 단어를 예측한다고 했다. 그렇다면 하나의 시퀀스 데이터인 말뭉치 데이터가 $w_1, w_2, ... , w_{t-1}, w_t, w_{t+1}, ... , w_{n}$ 이렇게 있다고 가정해보자. 이 때 CBOW 모델은 맥락 2개인 $w_{t-1}, w_{t+1}$ 활용해 가운데 단어인 $w_t$를 예측하게 되므로 이를 수식으로 나타내면 아래와 같다.

$$P(w_t | w_{t-1}, w_{t+1})$$

즉, $w_{t-1}$ 과 $w_{t+1}$ 이라는 단어가 발생한 후, $w_t$ 단어가 발생할 확률을 모델링하는 것이다. 

 

그렇다면 위의 수식을 토대로 CBOW 모델의 손실함수를 나타내보자. CBOW 모델도 다중분류 문제를 해결하므로 손실함수 종류로는 크로스 엔트로피오차(CEE)를 사용한다. CEE 함수 수식은 $-\sum_{k} {t_k \log y_k}$ 이며, 이를 CBOW 모델에 대입하게 되면 아래와 같아진다. 참고로 CEE 수식에서 정답을 의미하는 $t_k$는 여기서도 원-핫 인코딩 형태이기 때문에 정답 즉, 1일 때만 손실함수를 계산하므로 아래 수식처럼 간단하게 변형된다.

$$L = -\log P(w_t | w_{t-1}, w_{t+1})$$

식이 간단해졌다. 이를 확률에 $\log$를 취한 뒤 음수를 붙인다고 해서 음의 로그 가능도(Negative Log Likelihood)라고도 한다. 위 수식은 데이터 1개 일때의 손실 함수이다. 이를 전체 데이터 또는 특정 미니 배치 데이터일 때의 수식으로 확장하면 아래와 같다.

$$ L = -\frac {1}{T} \sum_{t=1}^T \log P(w_t | w_{t-1}, w_{t+1})$$

따라서 CBOW 모델은 위의 손실함수 값을 줄여나감으로써 학습하면서 가중치 값을 갱신하게 된다. 그리고 갱신된 가중치 값들은 바로 우리가 필요로 하는 단어의 의미를 적절하게 표현한 단어 벡터(단어의 분산 표현)이다.

7. word2vec의 또 다른 신경망 모델 - Skip-gram

Skip-gram은 word2vec를 구현하기 위해 활용하는 또 다른 신경망 모델로 CBOW 모델의 친구라고 할 수 있다. 단, 성격이 다른 친구이다. CBOW는 주변 맥락을 이용해 가운데 단어를 예측했지만 Skip-gram은 이를 뒤바꾸어 학습한다. 즉, 가운데 단어를 이용해 주변 맥락 단어를 예측하는 모델이다. 그래서 Skip-gram 모델을 도식화하면 아래와 같다.

 

간단한 Skip-gram 신경망 모델 구조

 

그렇다면 위의 Skip-gram 모델도 확률의 수식으로 변환해보면 아래와 같은 수식이 나온다.

$$P(w_{t-1}, w_{t+1} | w_t)$$

CBOW 모델의 확률 수식과 반대이다. 즉 가운데 단어인 $w_t$가 발생했을 때, 주변 맥락 단어들인 $w_{t-1}, w_{t+1}$이 동시에 발생할 확률을 의미한다. 그런데 여기서 Skip-gram 모델은 맥락의 단어들인 $w_{t-1}, w_{t+1}$ 들이 각각 발생할 확률을 독립적인 사건이라고 가정한다. 그래서 위 수식은 아래처럼 변환된다.

$$P(w_{t-1}, w_{t+1} | w_t) = P(w_{t-1} | w_t)P(w_{t+1} | w_t)$$

Skip-gram도 손실함수를 크로스엔트로피 오차를 사용한다. 그래서 위 확률 수식을 기반으로 Skip-gram의 손실함수 수식을 나타내면 아래와 같다.(아래는 데이터 1개일 때의 손실함수이다)

$$L = -\log P(w_{t-1}|w_t)P(w_{t+1}|w_t) = -(\log P(w_{t-1} | w_t) + \log P(w_{t+1} | w_t))$$

위 수식의 변환은 $\log xy = \log x + \log y$가 되는 성질을 활용해 곱셈에서 덧셈으로 변환해주었다. 이렇게 함으로써 수학적 계산 편의성이 증진된다.

 

이번엔 전체 데이터 또는 특정 배치 사이즈의 데이터일 때의 Skip-gram 손실함수로 확장해보면 아래와 같아진다.

$$L = -\frac{1}{T} \sum_{t=1}^T{(\log P(w_{t-1} | w_t) + \log P(w_{t+1} | w_t)})$$

 

그런데 위 Skip-gram 도식화를 보면 양쪽 맥락 단어를 출력하기 때문에 출력층이 2개로 나오게 된다. 이렇게 될 때 손실함수값이 여러개 되는데 어떻게 할까? 의외로 간단하다. 출력층이 2개이기 때문에 각 출력층 마다 손실함수 값을(CEE) 계산한다. 그리고 총 손실함수 값은 각 출력층의 손실함수 값을 더한 값으로 Skip-gram의 최종 손실함수로 결정한다.

 

Skip-gram을 넘파이 클래스로 구현한 코드는 여기를 참고하자. 참고로 책에서는 Skip-gram을 학습하는 코드는 제공하지 않는다. 하지만 필자가 CBOW 학습 코드와 유사하게 1차적으로 학습하는 코드를 작성해보았다. 그런데 확실치는 않아서 책 원 저자인 사이토 고키님 Github 이슈에 질문을 남겨놓았다. 혹시 이 글을 읽으시는 분도 궁금하시다면 읽고 답변주시면 너무 감사하겠다!

8. 그래서 CBOW ? Skip-gram ?

지금까지 word2vec를 구현하기 위해 활용하는 신경망 모델 종류인 CBOW 모델, Skip-gram 모델에 대해 알아보았다. 그렇다면 둘 중 어떤 것을 써야할까? 책에서는 Skip-gram을 써야 한다고 이야기한다. 왜냐하면 단어 분산 표현의 정밀도 면에서 Skip-gram 모델이 더 좋은 경우가 많기 때문이다. 특히 말뭉치 데이터가 커질수록 저빈도 단어나 단어 유추 성능 면에서 Skip-gram이 더 뛰어난다고 한다.

 

단, 학습 속도 측면에서는 CBOW 모델이 더 빠르다고 한다. 이유는 Skip-gram이 출력층이 여러개 나오기 때문이다. 출력층이 여러개가 나오게 됨에 따라 손실함수 값도 그만큼 여러번 계산해야 하므로 자연스레 계산 시간이 증가한다.

9. 통계 기반과 추론 기반

저번 포스팅부터 하여 많은 내용을 거쳐오면서 단어의 분산을 표현하는 방법으로서 통계 기반 방법과 추론 기반 방법에 대해 알아보았다. 통계 기반 기법은 말뭉치 전체 데이터를 한 번에 사용하여 동시발생 행렬을 구하고 PPMI를 계산한 후 SVD와 같은 차원축소를 통해 단어의 분산을 표현했다. 반면에 이번에 배운 추론 기반 기법은 CBOW 모델과 같은 신경망을 활용해서 전체 데이터 중 일부만 샘플링해 학습하는 것을 반복하여 단어의 분산 표현을 얻을 수 있었다. 

 

두 기법은 새로운 단어 또는 말뭉치 데이터가 등장했을 때, 편의성 측면에서 편의성이 극도로 갈린다. 새로운 단어가 추가된다면 통계 기반 기법은 그 수많은 말뭉치 데이터를 또 한번에 학습해야 한다. 하지만 추론 기반 기법은 기존의 말뭉치를 학습하여 갱신된 가중치를 갖고 있는 신경망에 새롭게 추가된 말뭉치 데이터만 학습시키면 된다. 

 

단어 분산 표현이나 정밀도 측면에서는 통계 기반 기법은 단어의 유사성이 인코딩되어 저장되지만 추론 기반 기법은 단어의 유사성 뿐만 아니라 더 복잡한 단어 사이의 패턴까지도 인코딩된다고 한다. 이러한 이유로 추론 기반 기법을 대부분 찬양한다고 하지만 실제 두 가지 기법의 단어 유사성을 정량적으로 평가한 결과로 큰 차이가 없었다고 한다.


 

이번 챕터에서는 매우 간단한 말뭉치로 word2vec를 구현해보았다. 다음 챕터부터는 큰 말뭉치를 사용해서 진짜 word2vec를 구현해보기로 하자.

 

 

반응형