본문 바로가기

Deep Learning(강의 및 책)/밑바닥부터 시작하는 딥러닝 2

4. word2vec 속도 개선

728x90

밑바닥부터 시작하는 딥러닝 2를 바탕으로 제작하였습니다.

 

이 전에 CBOW(Contiuous Bag of Words) 모델은 처리 효율이 떨어져 말뭉치에 포함된 어휘 수가 많아지면 계산량도 커졌습니다. 그래서 이러한 문제를 해결하기 위해 Embedding이라는 새로운 계층을 만들고 네거티브 샘플링이라는 새로운 손실함수를 도입해보겠습니다.

 

4.1. word2vec 개선

 

 

이 그림은 전에 구현한 CBOW 모델을 구현한 모습입니다. CBOW 모델은 단어 2개를 맥락으로 사용해, 이를 바탕으로 하나의 단어(타깃)를 추측합니다. 이때 입력 측 가중치(W)와의 행렬 곱으로 은닉층이 계산되고, 다시 출력 측 가중치(W)와의 행렬 곱으로 각 단어의 점수를 구합니다. 그리고 이 점수에 softmax 함수를 적용해 각 단어의 출현 확률을 얻고, 이 확률을 정답 레이블과 비교하여 손실을 구합니다.

이와 같은 모델은 작은 말뭉치를 다룰 때는 크게 문제 될 게 없습니다. 위 예시에서 어휘 수는 모두 7개인데, 이 정도는 문제없이 처리할 수 있었습니다. 하지만 거대한 말뭉치를 다루게 되면 몇 가지 문제가 발생합니다

예를 들어 어휘 100만 개, 은닉층의 뉴런이 100개인 CBOW 모델을 생각해보겠습니다.

 

 

이와 같이 모델이 그려집니다. 입력층과 출력층에는 각 100만 개의 뉴런이 존재합니다. 이 수많은 뉴런 때문에 중간 계산에 많은 시간이 소요됩니다. 정확히는 두 계산이 병목 되는데

  • 입력층의 원핫 표현과 가중치 행렬 W의 곱 계산
  • 은닉층과 가중치 행렬 W의 곱 및 softmax 계층의 계산

이 두 계산이 병목 됩니다. 첫 번째는 입력층의 원핫 표현과 관련한 문제입니다. 단어를 원핫 표현으로 다루기 때문에 어휘 수가 많아지면 원핫 표현의 벡터 크기도 커집니다. 어휘가 100만 개라면 그 원핫 표현 하나만 해도 원소 수가 100만 개인 벡터가 됩니다. 이렇게 되면 상당한 메모리를 차지하게 됩니다. 게다가 이 원핫 벡터와 가중치 행렬 W를 곱해야 하는데, 이것만으로 계산 자원을 상당히 사용하게 됩니다. 이러한 문제를 Embedding 계층을 도입해 해결하겠습니다.

두 번째 문제는 은닉층 이후의 계산입니다. 우선 은닉층과 가중치 행렬 W의 곱만 해도 계산량이 상당합니다. 그리고 softmax 계층에서도 다루는 어휘가 많아짐에 따라 계산량이 증가하는 문제가 있습니다. 이러한 문제는 네거티브 샘플링이라는 새로운 손실 함수를 도입해 해결하겠습니다.

먼저 embedding 계층에 대해서 알아보겠습니다.

 

 ■ Embedding 계층

word2vec를 구현할 때 단어를 원핫 표현으로 변경한 후 MatMul 계층에 입력하고, MatMul 계층에서 가중치 행렬을 곱했습니다. 사실 결과적으로 수행하는 일은 단지 행렬의 특정 행을 추출하는 것뿐입니다. 따라서 원핫 표현으로의 변환과 MatMul 계층의 행렬 곱 계산은 사실 필요가 없습니다. 이제 가중치 매개변수로부터 '단어 ID에 해당하는 행'을 추출하는 계층을 만들겠습니다. 이를 Embedding 계층이라 부르겠습니다.

그럼 이제 Embedding 계층을 구현해보겠습니다.

 

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

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

 

이와 같이 코드를 작성할 수 있습니다. idx에는 추출하는 행의 인덱스(단어 ID)를 배열로 저장합니다. out에 해당 배열을 저장해 return 해줍니다.

이번에는 backward부분에 대해서 생각해보겠습니다. Embedding 계층의 순전파는 가중치 W의 특정 행을 추출할 뿐입니다. 단순히 가중치의 특정 행 뉴런만을 다음 층으로 흘려보낸 것입니다. 따라서 역전파에서는 앞 층으로부터 전해진 기울기를 다음 층으로 그대로 흘려주면 됩니다. 다만, 앞 층으로부터 전해진 기울기를 가중치 기울기 dW의 특정 행에 설정해야 합니다.

 

 

이와 같이 그려집니다. backward를 포함해 다시 embedding class를 구현해보겠습니다.

 

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

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

    def backward(self, dout):
        dW, = self.grads
        dW[...] = 0
        np.add.at(dW, self.idx, dout)
        return None

 

backward 코드를 보면 가중치 기울기 dW를 꺼낸 다음, dW[...] = 0 문장에서 dW의 원소를 0으로 덮어씁니다(dW 자체를 0으로 설정하는 게 아니라, dW의 형상을 유지한 채 그 원소들을 0으로 덮어쓰는 겁니다). 그리고 앞 층에서 전해진 기울기 dout을 idx번째 행에 할당합니다. np.add.at으로 값을 할당한 이유는 만약 idx가 중복된다면 기울기의 합을 전달해야 하기 때문입니다.

 

4.2. word2vec 개선 2

이번에는 두 번째 개선을 진행하겠습니다. 네거티브 샘플링이라는 기법을 사용해 은닉층 이후의 처리(행렬 곱과 softmax 계층의 계산)를 개선하겠습니다. softmax 대신 네거티브 샘플링을 이용하면 어휘가 아무리 많아져도 계산량을 낮은 수준에서 일정하게 억제할 수 있습니다.

 

 ■ 은닉층 이후 계산의 문제점

어휘가 100만 개, 은닉층 뉴런이 100개일 때의 word2vec을 예로 생각해보겠습니다.

 

 

그림으로 나타내면 위와 같습니다. 입력층과 출력층에는 뉴런이 각 100만 개씩 존재합니다. 은닉층 이후에서 계산이 오래 걸리는 곳은 다음 두 부분입니다.

  • 은닉층의 뉴런과 가중치 행렬(W)의 곱
  • Softmax 계층의 계산

첫 번째는 거대한 행렬을 곱하는 문제입니다. 두 번째 문제인 Softmax도 똑같은 문제가 존재합니다. 즉 어휘가 많아지면 softmax 계산량도 증가합니다.

 

 ■ 다중 분류에서 이진 분류로

이러한 문제를 해결하는 네거티브 샘플링 기법이 무엇인지 알아보겠습니다. 이 기법의 핵심은 '이진 분류'입니다. 더 정확하게 말하면 '다중 분류'를 '이진 분류'로 근사하는 것이 네거티브 샘플링을 이해하는 데 중요한 포인트입니다. 지금까지는 맥락이 주어졌을 때 정답이 되는 단어를 높은 확률로 추측하도록 만드는 일을 했습니다. 예컨대 맥락으로 'you'와 'goodbye'를 주면 정답인 'say'의 확률이 높아지도록 신경망을 학습했습니다.

이러한 방법이 아니라 질문에 대한 대답을 'yes/no'로 할 수 있도록 한다면 이진 분류 방식으로 문제를 해결할 수 있습니다. 예컨대 "맥락이 'you'와 'goodbye'일 때, 타깃 단어는 'say'입니까?"라는 질문에 답하는 신경망을 생각해내면 됩니다. 이렇게 하면 출력층에는 뉴런을 하나만 준비하면 됩니다. 출력층의 이 뉴런이 'say'의 점수를 출력하면 됩니다. 위 그림에서 두 번째 그림과 같은 형태로 만들어주면 됩니다. 은닉층과 출력 측의 가중치 행렬의 내적은 'say'에 해당하는 열만을 출력하고, 그 추출된 벡터와 은닉층 뉴런과의 내적을 계산하면 끝입니다.

 

 ■ 시그모이드 함수와 교차 엔트로피 오차

이진 분류 문제를 신경망으로 풀려면 점수에 시그모이드 함수를 적용해 확률로 변환하고, 손실을 구할 때는 손실 함수로 '교차 엔트로피 오차'를 사용합니다.

교차 엔트로피 오차는

 

 

이와 같은 수식으로 표현할 수 있습니다. 여기에서 y는 시그모이드 함수의 출력이고, t는 정답 레이블입니다. 이 정답 레이블의 값은 0 또는 1입니다. t가 1이면 정답이 'yes'가 되고 0이면 'no'가 됩니다. 따라서 t가 1이면 -logy가 출력되고, 반대로 t가 0이면 -log(1-y)가 출력됩니다.

sigmoid 계층과 cross entropy error 계층의 역전파를 보면 'y - t'가 됩니다. 여기에서 y는 신경망이 출력한 확률이고, t는 정답 레이블입니다. 그리고 'y - t'는 정확히 두 값의 차이입니다. 정답 레이블이 1이라면, y가 1에 가까울수록 오차가 줄어든다는 뜻입니다.

 

 ■ 다중 분류에서 이진 분류로(구현)

다중 분류에서는 출력층에 어휘 수만큼의 뉴런을 준비하고 이 뉴런들이 출력한 값을 softmax 계층에 통과시켰습니다. 이진 분류 방식은 sigmoid with loss 계층에 입력해 최종 손실을 얻습니다.

일단 먼저 embedding dot 계층을 구현해보겠습니다.

 

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):
        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

 

코드에 대해서 살펴보겠습니다. 총 4개의 인스턴스 변수가 존재합니다. params에는 매개변수를 저장하고, grads에는 기울기를 저장합니다. 또, embed는 embedding 계층을, cache는 순전파 시의 계산 결과를 잠시 유지하기 위한 변수로 사용됩니다. 순전파를 담당하는 forward 메서드는 인수로 은닉층 뉴런(h)과 단어 ID의 넘파이 배열(idx)을 받습니다. embedding 계층의 forward를 호출한 후 내적을 진행합니다. 내적 계산은 np.sum이라는 단 한 줄로 이루어집니다. 역전파는 순전파의 반대 순서로 기울기를 전달해 구현합니다.

 

 ■ 네거티브 샘플링

지금까지 구현은 정답에 대해서만 학습했습니다. 즉, 오답에 대한 학습이 제대로 이루어지지 않았습니다. 사실 우리는 긍정적인 대답에는 1에 가깝게, 부정적 대답에는 0에 가깝게 만들어야 합니다. 그러면 모든 부정적인 대답을 대상으로 하여 이진 분류를 학습시키면 되는데 이는 어휘 수가 늘어나면 감당할 수 없기 때문에 올바르지 않습니다. 그래서 근사적인 해법으로, 부정적 예를 몇 개 선택합니다. 즉, 적은 수의 부정적 예를 샘플링해 사용합니다. 이것이 바로 '네거티브 샘플링' 기법입니다. 긍정적 예를 타깃으로 한 경우의 손실과 부정적 예를 타깃으로 한 손실을 구해 더한 값을 최종 손실로 합니다.

 

 ■ 네거티브 샘플링의 샘플링 기법

부정적 예를 어떻게 샘플링하는지에 대한 설명입니다. 말뭉치 통계 데이터를 기초로 샘플링을 할 것입니다. 말뭉치에서 자주 등장하는 단어를 많이 추출하고 드물게 등장하는 단어를 적게 추출하는 방법입니다. 말뭉치에서의 단어 빈도를 기준으로 샘플링하려면, 먼저 말뭉치에서 각 단어의 출현 횟수를 구해 '확률 분포'로 나타냅니다. 그다음 그 확률분포대로 단어를 샘플링하면 됩니다.

여기서 한 가지 개선점이 존재합니다. 확률이 낮은 단어가 선택이 거의 되지 않는 것을 개선시켜 좀 더 선택될 수 있도록 하는 것이 좋습니다. 확률분포들의 각 요소에 0.75 제곱을 해주면 됩니다. 이렇게 하면 확률이 낮은 단어의 확률을 살짝 높일 수 있습니다.

 

 ■ 네거티브 샘플링 구현

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

 

초기화 메서드 먼저 살펴보겠습니다. 인수로 출력 측 가중치를 나타내는 W, 말뭉치를 뜻하는 corpus, 확률분포에 제곱할 값인 power, 그리고 부정적 예의 샘플링 횟수인 sample_size가 존재합니다. sampler는 부정적 예를 선택해주는 함수입니다.

이어서 순전파를 보면 긍정적 예와 부정적 예로 나누어집니다. 은닉층 뉴런 h와 긍정적 예의 타깃을 뜻하는 target을 인자로 받습니다. 이 메서드에서는 우선 self.sampler를 이용해 부정적 예를 샘플링하여 negative_sample에 저장합니다. 그 다음 긍정적 예와 부정적 예 각각의 데이터에 대해서 순전파를 수행해 그 손실들을 더합니다. Embedding Dot 계층의 forward 점수를 구하고, 이어서 이 점수와 레이블을 sigmoid with loss 계층으로 흘려 손실을 구합니다.

마지막 역전파를 보면 순전파 때의 역순으로 각 계층의 backward를 호출하기만 하면 됩니다.

 

4.3 개선판 word2vec 학습

Embedding 계층과 Negative Sampling Loss 계층을 적용해 개선해보겠습니다.

 

# 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

 

이 초기화 메서드는 4개의 인수를 받습니다. vocab_size는 어휘 수, hidden_size는 은닉층의 뉴런 수, corpus는 단어 ID 목록입니다. 그리고 맥락의 크기를 window_size로 지정합니다. Embedding 계층을 2*window_size개 작성하여 인스턴스 변수인 in_layers에 배열로 보관합니다. 그런 다음 Negative Sampling Loss 계층을 생성합니다. 계층을 다 생성했으면, 이 신경망에서 사용하는 모든 매개변수와 기울기를 인스턴스 변수인 params와 grads에 모읍니다. 또한 나중에 단어의 분산 표현에 접근할 수 있도록 인스턴스 변수인 word_vecs에 가중치 W_in을 할당합니다. 이렇게 개선된 word2vec을 구현할 수 있습니다.