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

word2vec 속도개선

by 대소기 2022. 2. 10.

* 앞서 살펴본 word2vec 포스팅에서는 corpus에 포함된 단어 수가 많아지면 계산량이 커져 시간이 오래걸린다는 단점이 존재했다. 이를 개선하기 위해 두 가지를 도입한다.

1) Embedding layer 추가

2) Negative Sampling 손실함수 추가

* word2vec 속도를 개선하고 대용량 데이터인 PTB를 가지고 훈련해보자.

Word2vec 개선 1)

* 우리가 저번 포스팅에서 다룬 CBOW는 corpus의 크기가 작을 때는 문제가 없지만, corpus의 크기가 커질수록 문제가 생긴다.

* 어휘 100만개, hidden layer의 뉴런이 100만개인 CBOW 모델을 생각해보자.

* 이렇게 단어의 수가 많을 경우 2가지 계산에서 병목현상이 발생한다.

1) 입력층의 one-hot 표현과 가중치 행렬 $W_in$ 의 곱 계산

2) hidden layer와 가중치행렬 $W_out$의 곱 및 Softmax layer의 계산

* 먼저 1) 문제를 확인해보자. 단어가 100만개정도 되면 one-hot vector의 크기도 100만개가 되어야 한다. 이렇게 단어의 개수가 증가하면 계산 자원을 상당하게 사용하게 된다. 이는 Embedding 계층을 통해 해결할 수 있다.

* 다음으로 2) 문제를 확인해보자. hidden layer와 가중치 행렬 $W_out$의 곱만 해도 계산량이 상당하다. 이는 Negative Sampling이라는 loss function의 도입을 통해 해결할 수 있다.

Embedding 계층

* Embedding layer는 가중치 행렬 W로부터 단어 ID에 해당하는 행 vector를 추출하는 layer이다.

* 이는 바로 전 포스팅에서 다뤘던 내용으로, 벡터와 가중치 행렬을 곱하는 것은 사실 단어 ID에 해당하는 행 벡터를 추출하는 것과 같다는 것을 활용한 것이다. 이를 통해 불필요한 행렬 계산을 줄이는 것이 가능하다.

Embedding layer 구현

class Embedding:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.idx = None # 추출하는 행의 index(단어 id)

    def forward(self, idx):
        W, = self.params
        self.idx = idx
        out = W[idx]
        return out

    def backward(self, dout):
        dW, = self.grads
        dW[...] = 0 
        dW[self.idx] = dout # 실은 나쁜 예
        return None

* 역전파 부분을 좀 살펴보자면, 순전파 때 가중치 행렬의 특정 행을 뽑은 것 뿐이므로 역전파시 전달된 기울기를 그대로 흘려주기만 하면 된다. 사실 몇 번째 행인지를 나타내는 index와 전달된 기울기만 저장해 놓으면 이 정보들을 이용해 특정 행을 갱신할 수 있지만, 우리가 구현해 둔 optimizer 에 맞추기 위해 가중치 행렬과 똑같은 크기의 행렬을 생성한 후 갱신할 index를 제외한 모든 행을 0으로 만드는 작업을 하였다.

* 그런데 위 코드의 backward phase 구현에는 조금 문제가 있다. 만약 위와 같이 idx에 동일한 원소가 있을 때 값 덮어쓰기가 발생한다는 점이다. 이를 개선하기 위해 dW[self.idx] 에 할당하는 것이 아니라 더해줘야 한다. 이를 반영하면 아래 코드와 같다.

  def backward(self, dout):
        dW, = self.grads
        dW[...] = 0 

        #for i, word_id in enumerate(self.idx):
            #dw[word_id] += dout[i]
        # 혹은
        np.add.at(dW, self.idx. dout)

        return None

* for 문 보다는 np.add.at() 메소드 사용이 더 빠르기 때문에 위와 같이 구현하였다.

Word2vec 개선 - 2) Negative Sampling

* 앞서 살펴봤던 병목현상 원인 2가지를 다시 확인해보자.

1) 입력층의 one-hot 표현과 가중치 행렬 $W_in$ 의 곱 계산

2) hidden layer와 가중치행렬 $W_out$의 곱 및 Softmax layer의 계산

* 이 중 1번은 Embedding layer를 도입함으로서 해결했으니 2번을 해결해보자. 2번을 해결하기 위해서는 softmax layer 대신 negative sampling을 사용해야 한다.

 

 

 

은닉층 이후 계산의 문제점

* CBOW를 사용할 때 corpus에 들어있는 단어가 100만개인 경우를 다시 생각해보자.

* 단어가 100만개이기 때문에 출력층의 뉴런도 100만개 존재해야 한다. 그리고 이 100만개의 출력 값은 softmax layer로 입력되는데, softmax layer 내에서는 다음과 같은 계산이 이뤄진다.

 

$y_k = {exp(s_k) \over \sum\limits_{i=1}^{1000000} exp(s_i)}$

 

* 위 계산 식을 보면 분모를 위해 exponential 계산을 100만번 해야한다는 것을 알 수 있다. 더 가벼운 계산이 필요하다.

 

 

 

 

다중 분류에서 이진 분류로

 

* negative sampling 방법은 다중 분류를 이진 분류 문제로 근사하는 방법이다. 이전까지는 softmax 함수에 의하여 각 단어가 target일 확률을 출력하였다면, negative sampling은 하나의 단어에 집중해서 해당 단어가 target인지에 대해 True, False 값을 출력하게 된다.

 

* 예를 들어 target이 'say'인지 아닌지에 대해 알고 싶다면, say에 해당하는 $W_out$의 열 벡터와 say의 ID에 해당하는 뉴런의 가중치 벡터를 내적하면 된다. 더 자세히 그림으로 표현하면 아래와 같다.

 

 

 

 

 

Sigmoid 함수와 Crossentropy 오차

 

* 이진 분류 문제를 위해서는 sigmoid function과 crossentropy를 사용해야 한다.

 

* sigmoid함수는 0~1 사이의 값으로 출력을 제한하기 때문에 출력 값을 확률 값으로 해석할 수 있다.

* 이진분류 문제에서 sigmoid함수에 적용되는 crossentropy는 다음과 같다.

 

$L = - (t log y + (1-t) log (1-y))$

 

* 왼쪽은 이진분류 문제에서의 역전파 과정이고, 오른쪽은 다중분류 문제에서의 역전파 과정이다.

* 왼쪽 이진분류 문제에서 sigmoid에서 전달되는 미분 값이 ${\partial L \over \partial x} = y-t$ 라는 것을 알 수 있다. 때문에 오차가 커

지면 크게 학습하고 오차가 작으면 작게 학습하게 된다.

 

* 이진 분류를 하는 CBOW모델을 계산 그래프로 표현하였다. 은닉층 뉴런 h와 가중치 $W_out$에서 단어 say에 해당하는 단어 벡터는 내적으로 계산된다.

 

 

* 내적 계산을 embedding layer에 합쳐서 표현한 계산 그래프이다. 이를 코드로 구현하면 다음과 같다.

 

class EmbeddingDot:
    def __init__(self, W):
        self.embed = Embedding(W)
        self.params = self.embed.params
        self.grads = self.embed.grads
        self.cache = None #순전파 계산을 캐시하기 위한 변수
        
    def forward(self, h, idx): # 미니배치를 위해 idx는 numpy 배열
        target_W = self.embed.forward(idx)
        out = np.sum(target_W * h, axis=1)
        
        self.cache = (h, target_W)
        return out
    
    def backward(self, dout):
        h, target_W = self.cache
        
        dout = dout.reshape(dout.shape[0], 1)
        
        dtarget_W = dout * h
        self.embed.backward(dtarget_W)
        dh = dout * target_W
        return dh

 

* forward의 계산 과정을 자세히 살펴보면 위 그림과 같다. target_W와 h는 elementwise하게 곱해진다.

 

 

 

Negative Sampling

* 지금까지는 정답에 대해서만 학습했기 때문에 오답을 입력했을 때 어떤 결과가 나올지 확실하지 않다.

* 예를 들어 say가 맞는지 아닌지에 대해 이진분류를 학습한 신경망은 say 이외의 단어의 입력에 대해 어떤 결과가 나올지 확실하지 않은 것이다.

* 때문에 우리는 긍정적 예(예컨데 say)에 대해서는 sigmoid 계층의 출력을 1에 가깝게, 부정적 예의 경우에는 0에 가깝게 만들어줄 필요가 있다. 이를 위해서 부정적 예에 대해서도 학습을 시행해야 한다. 그렇다면 모든 부정적 예, 그러니까 100만개의 단어 중 say를 제외한 모든 단어에 대해서 학습을 하는게 맞을까? 정답은 아니다. 연산량을 줄이고자 하는 우리의 목적이 전혀 달성되지 않는다. 우리는 그 대신 부정적 예를 n개 sampling하여 사용할 것이다. 이를 negative sampling이라고 한다.

 

* negative sampling 기법을 사용할 때 최종 손실은 긍정적 예 하나에 대한 손실 뿐만 아니라 부정적 예에 대한 손실까지 모두 합한 손실을 사용한다.

 

 

 

Negative Sampling의 Sampling 기법

* 부정적 예를 sampling하는 기법에 대해 살펴보자. uniform한 확률로 각 단어를 추출하는 것 보다 좋은 방법은 corpus에서 단어 출현 빈도에 비례하게 sampling하는 것이다. corpus에서 각 단어별 출연 빈도를 확률분포로 나타내고, 확률분포대로 단어를 sampling하는 것이다. 흔하게 등장하는 단어에 대해 훈련을 시키는 것이 더 효율적일 것이다. the, I, you 등 많이 등장하는 단어와 corpus에 한 두번 등장하는 단어는 확률분포의 gap이 너무 크기 때문에 이를 보정해주고자 0.75 제곱을 하는게 좋다.

* 또한 부정적 예를 5~10개정도로 한정해서 sampling하는 것이 좋다.

* sampler에 대한 코드는 아래와 같이 구현할 수 있다.

 

class UnigramSampler:
    def __init__(self, corpus, power, sample_size):
        self.sample_size = sample_size
        self.vocab_size = None
        self.word_p = None

        counts = collections.Counter()
        for word_id in corpus:
            counts[word_id] += 1

        vocab_size = len(counts)
        self.vocab_size = vocab_size

        self.word_p = np.zeros(vocab_size)
        for i in range(vocab_size):
            self.word_p[i] = counts[i]

        self.word_p = np.power(self.word_p, power)
        self.word_p /= np.sum(self.word_p)

    def get_negative_sample(self, target):
        batch_size = target.shape[0]

        if not GPU:
            negative_sample = np.zeros((batch_size, self.sample_size), dtype=np.int32)

            for i in range(batch_size):
                p = self.word_p.copy()
                target_idx = target[i]
                p[target_idx] = 0
                p /= p.sum()
                negative_sample[i, :] = np.random.choice(self.vocab_size, size=self.sample_size, replace=False, p=p)
        else:
            # GPU(cupy)로 계산할 때는 속도를 우선한다.
            # 부정적 예에 타깃이 포함될 수 있다.
            negative_sample = np.random.choice(self.vocab_size, size=(batch_size, self.sample_size),
                                               replace=True, p=self.word_p)

        return negative_sample

 

 

Negative Sampling 구현

class NegativeSamplingLoss:
    def __init__(self, W, corpus, power=0.75, sample_size=5):
        self.sample_size = sample_size
        self.sampler = UnigramSampler(corpus, power, sample_size)
        self.loss_layers = [SigmoidWithLoss() for _ in range(sample_size + 1)]
        self.embed_dot_layers = [EmbeddingDot(W) for _ in range(sample_size + 1)]

        self.params, self.grads = [], []
        for layer in self.embed_dot_layers:
            self.params += layer.params
            self.grads += layer.grads

    def forward(self, h, target):
        batch_size = target.shape[0]
        negative_sample = self.sampler.get_negative_sample(target)

        # 긍정적 예 순전파
        score = self.embed_dot_layers[0].forward(h, target)
        correct_label = np.ones(batch_size, dtype=np.int32)
        loss = self.loss_layers[0].forward(score, correct_label)

        # 부정적 예 순전파
        negative_label = np.zeros(batch_size, dtype=np.int32)
        for i in range(self.sample_size):
            negative_target = negative_sample[:, i]
            score = self.embed_dot_layers[1 + i].forward(h, negative_target)
            loss += self.loss_layers[1 + i].forward(score, negative_label)

        return loss

    def backward(self, dout=1):
        dh = 0
        for l0, l1 in zip(self.loss_layers, self.embed_dot_layers):
            dscore = l0.backward(dout)
            dh += l1.backward(dscore)

        return dh

 

 

 

개선판 word2vec 학습

 

* 이렇게 embedding layer, negative sampling 기법을 적용한 개선된 word2vec에 대해 학습해보자.

 

 

CBOW 모델 구현

 

# coding: utf-8
import sys
sys.path.append('..')
from common.np import *  # import numpy as np
from common.layers import Embedding
from ch04.negative_sampling_layer import NegativeSamplingLoss


class CBOW:
    def __init__(self, vocab_size, hidden_size, window_size, corpus):
        V, H = vocab_size, hidden_size

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

        # 계층 생성
        self.in_layers = []
        for i in range(2 * window_size):
            layer = Embedding(W_in)  # Embedding 계층 사용
            self.in_layers.append(layer)
        self.ns_loss = NegativeSamplingLoss(W_out, corpus, power=0.75, sample_size=5)

        # 모든 가중치와 기울기를 배열에 모은다.
        layers = self.in_layers + [self.ns_loss]
        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):
        h = 0
        for i, layer in enumerate(self.in_layers):
            h += layer.forward(contexts[:, i])
        h *= 1 / len(self.in_layers)
        loss = self.ns_loss.forward(h, target)
        return loss

    def backward(self, dout=1):
        dout = self.ns_loss.backward(dout)
        dout *= 1 / len(self.in_layers)
        for layer in self.in_layers:
            layer.backward(dout)
        return None

* 이전 포스팅의 CBOW의 입력 측의 가중치와 출력 측의 가중치는 shape이 다르기 때문에 출력 측의 단어 벡터는 열 방향으로 배치되었지만, 이번 포스팅에서 구현하는 CBOW는 Embedding 계층을 사용하기 때문에 출력 측의 단어 벡터도 행 방향으로 배치된다.

* 이제 CBOW를 학습해보자.

 

# coding: utf-8
import sys
sys.path.append('..')
import numpy as np
from common import config
# GPU에서 실행하려면 아래 주석을 해제하세요(CuPy 필요).
# ===============================================
# config.GPU = True
# ===============================================
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
from cbow import CBOW
from skip_gram import SkipGram
from common.util import create_contexts_target, to_cpu, to_gpu
from dataset import ptb


# 하이퍼파라미터 설정
window_size = 5
hidden_size = 100
batch_size = 100
max_epoch = 10

# 데이터 읽기
corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)

contexts, target = create_contexts_target(corpus, window_size)
if config.GPU:
    contexts, target = to_gpu(contexts), to_gpu(target)

# 모델 등 생성
model = CBOW(vocab_size, hidden_size, window_size, corpus)
# model = SkipGram(vocab_size, hidden_size, window_size, corpus)
optimizer = Adam()
trainer = Trainer(model, optimizer)

# 학습 시작
trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()

# 나중에 사용할 수 있도록 필요한 데이터 저장
word_vecs = model.word_vecs
if config.GPU:
    word_vecs = to_cpu(word_vecs)
params = {}
params['word_vecs'] = word_vecs.astype(np.float16)
params['word_to_id'] = word_to_id
params['id_to_word'] = id_to_word
pkl_file = 'cbow_params.pkl'  # or 'skipgram_params.pkl'
with open(pkl_file, 'wb') as f:
    pickle.dump(params, f, -1)

 

 

CBOW 모델 평가

# coding: utf-8
import sys
sys.path.append('..')
from common.util import most_similar, analogy
import pickle


pkl_file = 'cbow_params.pkl'
# pkl_file = 'skipgram_params.pkl'

with open(pkl_file, 'rb') as f:
    params = pickle.load(f)
    word_vecs = params['word_vecs']
    word_to_id = params['word_to_id']
    id_to_word = params['id_to_word']

# 가장 비슷한(most similar) 단어 뽑기
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
    most_similar(query, word_to_id, id_to_word, word_vecs, top=5)

# 유추(analogy) 작업
print('-'*50)
analogy('king', 'man', 'queen',  word_to_id, id_to_word, word_vecs)
analogy('take', 'took', 'go',  word_to_id, id_to_word, word_vecs)
analogy('car', 'cars', 'child',  word_to_id, id_to_word, word_vecs)
analogy('good', 'better', 'bad',  word_to_id, id_to_word, word_vecs)

* 단어 몇 개에 대해 거리가 가장 가까운 단어들을 추출하면서 모델을 평가해보자.

 

# coding: utf-8
import sys
sys.path.append('..')
from common.util import most_similar, analogy
import pickle


pkl_file = 'cbow_params.pkl'
# pkl_file = 'skipgram_params.pkl'

with open(pkl_file, 'rb') as f:
    params = pickle.load(f)
    word_vecs = params['word_vecs']
    word_to_id = params['word_to_id']
    id_to_word = params['id_to_word']

# 가장 비슷한(most similar) 단어 뽑기
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
    most_similar(query, word_to_id, id_to_word, word_vecs, top=5)

# 유추(analogy) 작업
print('-'*50)
analogy('king', 'man', 'queen',  word_to_id, id_to_word, word_vecs)
analogy('take', 'took', 'go',  word_to_id, id_to_word, word_vecs)
analogy('car', 'cars', 'child',  word_to_id, id_to_word, word_vecs)
analogy('good', 'better', 'bad',  word_to_id, id_to_word, word_vecs)

# [query] you 
# we: 0.610597074032
# someone: 0.591710150242
# i: 0.554366409779
# something: 0.490028560162
# anyone: 0.473472118378

# [query] year 
# month: 0.718261063099
# week: 0.652263045311
# spring: 0.62699586153
# summer: 0.625829637051
# decade: 0.603022158146

# [query] car 
# luxury: 0.497202396393
# arabia: 0.478033810854
# auto: 0.471043765545
# disk-drive: 0.450782179832
# travel: 0.40902107954

# [query] toyota 
# ford: 0.550541639328
# instrumentation: 0.510020911694
# mazda: 0.49361255765
# bethlehem: 0.474817842245
# nissan: 0.474622786045

* 결과를 보면 어느정도 납득이 가능하다고 판단이 된다.

 

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

게이트가 추가된 RNN  (0) 2022.02.10
순환신경망(RNN)  (0) 2022.02.10
Word2Vec  (0) 2022.02.03
딥러닝 등장 이전의 자연어와 단어의 분산 표현  (0) 2022.02.01
합성곱 신경망(CNN)  (0) 2022.01.30