본문 바로가기
Deep Learning/밑바닥부터 시작하는 딥러닝

Word2Vec

by 대소기 2022. 2. 3.

 

 

추론 기반 기법과 신경망

통계 기반 기법의 문제점

* 우리가 저번 포스팅에서 다룬 통계 기반 기법의 마지막 부분인 차원축소 또한 문제점이 존재한다. 차원 축소를 위해서는 PPMI를 만들어야 하고, PPMI 행렬의 크기는 corpus에 있는 단어의 개수에 비례한다. 만약 corpus에 있는 단어 개수가 100만개라면 100만 x 100만 크기의 행렬에 SVD를 적용해야 하는데 이는 매우 많은 연산량을 요구한다.

* 이를 해결하기 위해 추론 기반 기법을 사용할 것이다. 추론 기반 기법은 데이터 전체에 대해 SVD를 한 번에 시행하는 통계 기반 기법과는 달리 미니배치 학습을 통해 작은 데이터를 여러번에 걸쳐 학습한다. 이 때 여러 GPU, 여러 머신을 사용한 병렬 계산을 통해 속도를 높이는 것이 가능하기 때문에 통계 기반 기법을 사용할 때보다 빠른 학습이 가능하다.

추론 기반 기법 개요

* 추론 기반 기법은 말 그대로 주변 단어들의 context를 활용해서 주어진 위치에 어떤 단어가 올지 신경망을 통해 추론 하는 기법을 뜻한다.

* 모델은 입력된 context 를 통해 주어진 위치에 각 단어들이 등장할 확률을 출력한다.

신경망에서의 단어 처리

* 신경망을 이용해 모델을 구성하기 전에, 모델의 입력이 될 각 단어들을 전처리해줘야 한다. 신경망은 숫자를 입력받아 연산을 해야하기 때문에 예를 들어 you, say와 같은 단어들은 그대로 입력할 수 없고, 고정길이 벡터로 변환되어야 한다. 우리는 각 단어들을 one-hot vector로 변환할 것이다.

* 그리고 one hot vector의 크기만큼의 입력 뉴런에 의해 각 단어들은 신경망에 주입된다.

* 이를 python code로 나타내면 아래와 같다.

import numpy as np

c = np.array([[1, 0, 0, 0, 0, 0, 0]])
W = np.random.randn(7, 3)
h = np.matmul(c, W)
print(h)

# [[ 0.94847055  0.34287876 -0.72465143]]

* 그런데 이 과정을 자세히 살펴보면 조금 비효율적이라는 걸 알 수 있다. 아래 그림을 보자.

* one-hot vector와 가중치를 곱하는 것은 가중치 행렬 W에서 one-hot vector의 1과 곱해지는 부분만 추출하는 것과 다를 바가 없다. 다시 말해 위의 예에서 W의 0번째 행만 추출하는 것과 다를 바가 없다는 것이다. 그런데도 행렬 곱셈을 통해 굳이 많은 양의 연산을 시행하는 것은 비효율적으로 느껴진다. 이에 대한 내용은 바로 다음 포스팅에서 다루겠다.

 

 

 

 

단순한 word2vec - CBOW(Continuous Bag-of-Words)

CBOW 모델의 추론 처리

 

* CBOW 모델은 context로부터 target(우리가 원하는 위치의 단어)을 추측한다. context들은 one-hot vector로 변환되어 신경망에 입력된다. 여러개의 context들은 모델에 입력되어 하나의 은닉층과 연결되는데, 이 때 은닉층의 값은 입력 벡터들의 가중합 $h_1, h_2, h_3, ...h_n$ 들의 평균인 ${1 \over n}(h_1 + h_2)$ 이 된다. 위 그림의 경우 입력 context 가 2개이기 때문에 ${1 \over 2} (h_1 + h_2)$가 된다.

* 현재 출력층에는 softmax함수가 적용되어있지 않기 때문에 출력되는 값은 score라고 할 수 있다. softmax를 적용하게 되면 이 score들을 0~1의 확률 값으로 표현할 수 있게 된다.

 

 

* 입력층에서 은닉층으로 변환할 때의 가중치행렬 W를 분산표현(distributed representation) 이라고 한다. 가중치행렬의 각 행이 결국 단어의 의미를 나타낸다고 할 수 있는데, 이는 위에서 언급했듯이 우리가 입력으로 one-hot vector를 사용하기 때문이다.

* 은닉층 뉴런의 개수는 입력층 뉴런의 개수보다 작게 구성해야 한다. 그래야 sparse vector를 인코딩해서 dense vector로 변환할 수 있기 때문이다.

 

 

* 지금까지는 뉴런 관점에서 CBOW를 살펴보았으니, 지금부터는 계층 관점에서 CBOW를 살펴보도록 하자. MatMul 계층에서는 입력 vector와 가중치행렬이 곱해진다. 이후 각 vector들은 더해지고, 0.5가 곱해져 평균 값을 score로 도출하게 된다. 이 과정에서 편향은 생략되었다.

* 이를 python code로 구현해보자.

 

import numpy as np

class MatMul:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.x = None

    def forward(self, x):
        W, = self.params
        out = np.dot(x, W)
        self.x = x
        return out

    def backward(self, dout):
        W, = self.params
        dx = np.dot(dout, W.T)
        dW = np.dot(self.x.T, dout)
        self.grads[0][...] = dW
        return dx


# 샘플 맥락 데이터
c0 = np.array([[1, 0, 0, 0, 0, 0, 0]])
c1 = np.array([[0, 0, 1, 0, 0, 0, 0]])

# 가중치 초기화
W_in = np.random.randn(7, 3)
W_out = np.random.randn(3, 7)

# 계층 생성
in_layer0 = MatMul(W_in)
in_layer1 = MatMul(W_in)
out_layer = MatMul(W_out)

# 순전파
h0 = in_layer0.forward(c0)
h1 = in_layer1.forward(c1)
h = 0.5 * (h0 + h1)
s = out_layer.forward(h)
print(s)

# [[-2.00395419 -0.40524207 -0.91484442 -0.15397758  0.35569209  0.58132756 -0.61055763]]

 

 

 

CBOW 모델의 학습

* 지금 까지는 출력층의 출력으로 score를 그대로 사용했지만, 지금부터는 softmax 함수를 사용해 0~1사이의 확률값으로 정규화할 것이다.

* CBOW 모델은 말뭉치로부터 학습을 시행한다. 때문에 말뭉치가 달라지면 단어의 분산 표현도 달라지게 된다. 예를 들어 스포츠 기사만을 사용하는 경우와 음악 관련 기사만을 사용하는 경우 얻게 되는 분산표현은 차이를 보일 것이다.

 

 

* softmax 뿐만 아니라 loss로 crossentropy를 반복 훈련을 통해 loss를 줄이는 신경망 모델을 구현해보려고 한다.

 

 

word2vec의 가중치와 분산 표현

* word2vec모형의 가중치는 2개이다. 입력층과 은닉층을 연결하는 $W_in$, 은닉층과 출력층을 연결하는 $W_out$이다. $W_in$에는 우리가 살펴봤듯이 분산표현이 행 방향으로 저장되어있다. $W_out$에는 반대로 열 방향으로 분산 표현이 저장된다. 

* 그렇다면 최종적으로 어떤 가중치를 단어의 분산표현으로 사용할 것인지에 대해 결정해야 한다. word2vec 특히 skip-gram 모델에서는 $W_in$ 만을 단어의 분산표현으로 이용하는 경우가 많기 때문에 우리도 동일하게 $W_in$을 사용한다. (참고로 GloVe에서는 두 가중치를 더했을 때 더 좋은 결과를 얻었다)

 

 

 

 

학습 데이터 준비

 

 

맥락과 타깃

* word2vec 학습에 쓰일 데이터를 준비해보자. You say goodbye and I say hello라는 한 문장짜리 말뭉치를 이용할 것이다.

* 말뭉치로부터 contexts, target을 생성하였다. 단, 양 끝에 있는 'you', ' . '는 제외한다.

 

 

def preprocess(text):
    text = text.lower()
    text = text.replace('.', ' .')
    words = text.split(' ')

    word_to_id = {}
    id_to_word = {}
    for word in words:
        if word not in word_to_id:
            new_id = len(word_to_id)
            word_to_id[word] = new_id
            id_to_word[new_id] = word

    corpus = np.array([word_to_id[w] for w in words])

    return corpus, word_to_id, id_to_word

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

print(id_to_word)

# [0 1 2 3 4 1 5 6]
# {0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}

* 단어별 ID를 생성하였다.

 

 

* corpus를 입력받으면 contexts와 target을 생성하는 함수를 생성해보자.

 

def create_contexts_target(corpus, window_size=1):
    '''맥락과 타깃 생성
    :param corpus: 말뭉치(단어 ID 목록)
    :param window_size: 윈도우 크기(윈도우 크기가 1이면 타깃 단어 좌우 한 단어씩이 맥락에 포함)
    :return:
    '''
    target = corpus[window_size:-window_size]
    contexts = []

    for idx in range(window_size, len(corpus)-window_size):
        cs = []
        for t in range(-window_size, window_size + 1):
            if t == 0:
                continue
            cs.append(corpus[idx + t])
        contexts.append(cs)

    return np.array(contexts), np.array(target)

 

 

* 입력을 one-hot vector로 바꿔주는 함수는 아래와 같다.

 

def convert_one_hot(corpus, vocab_size):
    '''원핫 표현으로 변환
    :param corpus: 단어 ID 목록(1차원 또는 2차원 넘파이 배열)
    :param vocab_size: 어휘 수
    :return: 원핫 표현(2차원 또는 3차원 넘파이 배열)
    '''
    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):
            for idx_1, word_id in enumerate(word_ids):
                one_hot[idx_0, idx_1, word_id] = 1

    return one_hot

 

* 전처리를 위한 함수가 모두 준비되었으니 이제 전처리를 해 보자.

 

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=1)

vocab_size = len(word_to_id)
target = convert_one_hot(target, vocab_size)
contexts = convert_one_hot(contexts, vocab_size)

 

 

 

 

 

 

CBOW 모델 구현

class SoftmaxWithLoss:
    def __init__(self):
        self.params, self.grads = [], []
        self.y = None  # softmax의 출력
        self.t = None  # 정답 레이블

    def forward(self, x, t):
        self.t = t
        self.y = softmax(x)

        # 정답 레이블이 원핫 벡터일 경우 정답의 인덱스로 변환
        if self.t.size == self.y.size:
            self.t = self.t.argmax(axis=1)

        loss = cross_entropy_error(self.y, self.t)
        return loss

    def backward(self, dout=1):
        batch_size = self.t.shape[0]

        dx = self.y.copy()
        dx[np.arange(batch_size), self.t] -= 1
        dx *= dout
        dx = dx / batch_size

        return dx

class SimpleCBOW:
    def __init__(self, vocab_size, hidden_size):
        V, H = vocab_size, hidden_size

        # 가중치 초기화
        W_in = 0.01 * np.random.randn(V, H).astype('f')
        W_out = 0.01 * np.random.randn(H, V).astype('f')

        # 계층 생성
        self.in_layer0 = MatMul(W_in)
        self.in_layer1 = MatMul(W_in)
        self.out_layer = MatMul(W_out)
        self.loss_layer = SoftmaxWithLoss()

        # 모든 가중치와 기울기를 리스트에 모은다.
        layers = [self.in_layer0, self.in_layer1, self.out_layer]
        self.params, self.grads = [], []
        for layer in layers:
            self.params += layer.params
            self.grads += layer.grads

        # 인스턴스 변수에 단어의 분산 표현을 저장한다.
        self.word_vecs = W_in

    def forward(self, contexts, target):
        h0 = self.in_layer0.forward(contexts[:, 0])
        h1 = self.in_layer1.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):
        ds = self.loss_layer.backward(dout)
        da = self.out_layer.backward(ds)
        da *= 0.5
        self.in_layer1.backward(da)
        self.in_layer0.backward(da)
        return None

* SimpleCBOW class를 생성하고 __init__ , forward, backward 메소드를 구현하였다. 이 코드에서는 여러 계층에서 같은 가중치를 공유하고 있기 때문에 params의 리스트에 같은 가중치가 여러 개 존재하게 된다. 이 때문에 Adam, Momentum 등의 옵티마이저의 처리가 본래의 동작과 달라지게 된다. 이를 해결하기 위해서는 아래 함수를 사용해야 한다. 다만 참고만 하고, 우리 구현해서는 사용하지 않을 것이다.

 

* 역전파는 위와 같은 과정으로 진행된다.

def remove_duplicate(params, grads):
    '''
    매개변수 배열 중 중복되는 가중치를 하나로 모아
    그 가중치에 대응하는 기울기를 더한다.
    '''
    params, grads = params[:], grads[:]  # copy list

    while True:
        find_flg = False
        L = len(params)

        for i in range(0, L - 1):
            for j in range(i + 1, L):
                # 가중치 공유 시
                if params[i] is params[j]:
                    grads[i] += grads[j]  # 경사를 더함
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)
                # 가중치를 전치행렬로 공유하는 경우(weight tying)
                elif params[i].ndim == 2 and params[j].ndim == 2 and \
                     params[i].T.shape == params[j].shape and np.all(params[i].T == params[j]):
                    grads[i] += grads[j].T
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)

                if find_flg: break
            if find_flg: break

        if not find_flg: break

    return params, grads

 

 

 

학습 코드 구현

# ch03/train.py
import sys
sys.path.append('..')  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
from common.trainer import Trainer
from common.optimizer import Adam
from simple_cbow import SimpleCBOW
from common.util import preprocess, create_contexts_target, convert_one_hot


window_size = 1
hidden_size = 5
batch_size = 3
max_epoch = 1000

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

vocab_size = len(word_to_id)
contexts, target = create_contexts_target(corpus, window_size)
target = convert_one_hot(target, vocab_size)
contexts = convert_one_hot(contexts, vocab_size)

model = SimpleCBOW(vocab_size, hidden_size)
optimizer = Adam()
trainer = Trainer(model, optimizer)

trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()

* 학습을 진행해본 결과 loss가 아래와 같이 점점 감소하고있음을 알 수 있다.

 

 

 

* 가중치 매개변수를 통해 분산 표현을 확인해보자.

word_vecs = model.word_vecs
for word_id, word in id_to_word.items():
    print(word, word_vecs[word_id])

* 이를 통해 우리는 단어를 dense vector로 나타낼 수 있게 되었다.

 

 

 

 

 

word2vec 보충

 

 

CBOW 모델과 확률

* CBOW모델을 확률의 관점에서 표기해보자. 만약 corpus의 t번째 등장 단어가 w_t라면, contexts로 w_{t-1}, w_{t+1}이 주어졌을 때 target이 w_t가 될 확률은 아래와 같다.

 

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

 

* 위 식을 이용하면 손실함수도 간결하게 표현할 수 있다. cross entropy식은 다음과 같다.

 

$L = - \sum\limits_{k} log(y_k)$

 

* 정답 레이블 $t_k$가 one-hot vector 형태이므로 $w_t$에 해당하는 원소만 1이고 나머지는 0이 된다. 이를 감안하면 식은 다음과 같이 쓸 수 있다.

 

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

 

* 위 식을 음의 로그 가능도라고 한다. 위 식을 corpus 전체에 적용하기 위해 확장해보면 아래와 같다.

 

$L = - {1 \over T} \sum\limits_{t=1}^T log(y_k)P(w_t | w_{t-1}, w_{t+1})$

 

 

 

 

skip-gram 모델

* 우리가 지금까지 살펴봤던 모델은 word2vec 모델 중 CBOW 모델이었다. CBOW 모델은 주위 contexts를 통해 target을 추론한다.

* 반대로 skip-gram 모델은 특정 단어로부터 주변 단어를 추론한다.

 

* skip-gram 모델은 위와 같은 구조로 신경망 모델을 구성한다.

* skip-gram모델을 확률 표기로 나타내면 아래와 같다.

 

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

 

* contexts에 해당하는 단어들 사이에 조건부 독립이라는 가정을 도입하면 아래와 같이 쓸 수 있다.

 

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

 

* 위 식을 crossentropy 오차에 적용하여 skip-gram 모델의 손실함수를 유도하면 아래와 같다.

 

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

$= - 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))$

 

 

* 이를 말뭉치 전체로 확장하면 다음과 같다.

 

$= -  {1 \over T} \sum\limits_{t=1}^T (log P(w_{t-1} | w_t) + log P(w_{t+1}  w_t))$

 

* skip-gram모델의 손실함수를 보면 CBOW 모델의 손실함수와 달리 주변 contexts들의 손실의 총합을 최종 손실로 사용하는 것을 알 수 있다. CBOW모델은 target의 손실만을 다룬다.

* 우리는 두 모델을 둘 다 배웠지만, skip-gram 모델을 사용하는 것이 더 좋다. 분산 표현의 정밀도 측면에서 우수하기 때문이다. CBOW는 학습속도 측면에서는 유리하지만(구해야 할 손실이 적기 때문에) 성능 면에서는 좋지 않다.

 

 

 

 

통계 기반 vs 추론 기반

 

* 이렇게 통계 기반 기법과 추론 기반 기법을 살펴봤지만, 성능적인 우열은 가릴 수 없다고 알려져있다. 다만, 추론 기반 기법이 새로운 데이터가 추가될 때 분산 표현 갱신이 더 쉽다는 장점이 있다.

* 또한 통계 기반 기법과 추론 기반 기법은 서로 관련되어 있다고 한다. skip-gram 모델과 negative sampling을 이용한 모델(다음 포스팅에서 다룰 것임)은 모두 말뭉치 전체의 동시발생 행렬에 특수한 행렬 분해를 적용한 것과 같다. 때문에 특정 조건 하에서 두 기법은 연결되어있다고 불 수 있다.

 

'Deep Learning > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글

순환신경망(RNN)  (0) 2022.02.10
word2vec 속도개선  (0) 2022.02.10
딥러닝 등장 이전의 자연어와 단어의 분산 표현  (0) 2022.02.01
합성곱 신경망(CNN)  (0) 2022.01.30
Optimizer  (0) 2022.01.27