혜온의 이것저것

[Chapter 4 word2vec 속도 개선] 1 word2vec 개선 #2 본문

Deep Learning/밑바닥부터 시작하는 딥러닝2

[Chapter 4 word2vec 속도 개선] 1 word2vec 개선 #2

혜온 :) 2022. 7. 18. 17:01

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

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

이때 word2vec이 수행하는 작업은 그림과 같다.

입력층과 출력층에는 뉴런이 가 100만개씩 존재한다.

앞 절에서는 Embedding 계층을 도입하여 입력층 계산에서의 낭비를 줄였다.

남은 문제는 은닉층 이후의 처리이다. 은닉층 이후에서 계산이 오래 걸리는 곳은 다음 두 부분이다.

- 은닉층의 뉴런과 가중치 행렬의 곱

첫번째는 거대한 행렬을 곱하는 문제이다.

은닉층의 벡터 크기가 100이고, 가중치 행렬의 크기가 100X100만이다.

이렇게 큰 행렬의 곱을 계산하려면 시간이 오래 걸리고 메모리도 많이 필요하다.

또한 역전파 때도 같은 계산을 수행하기 때문에 이 행렬 곱을 가볍게 만들어야 한다.

- Softmax 계층의 계산

어휘가 많아지면 Softmax의 계산량도 증가한다.

k번째 원소를 타깃으로 했을 때 Softmax 계산식이다.

이 식에서는 어휘 수를 100만개로 가정했으므로 분모의 값을 얻으려면 exp계산을 100만번 수행해야 한다.

이 계산도 어휘 수에 비례해 증가하므로 Softmax를 대신할 가벼운 계산이 필요하다.

 

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

지금까지 우리는 다중 분류 문제를 다뤄왔다. 100만개의 단어 중에서 옳은 단어 하나를 선택하는 문제였다.

그렇다면 이러한 문제를 이진 분류 문제로 다룰 수 없을까?

 

지금까지 맥락이 주어졌을 때 정답이 되는 단어를 높은 확률로 추측하도록 만드는 일을 했다.

you와 goodbye가 주어지면 say의 확률이 높아지도록 신경망을 학습시키고, 학습이 잘 이뤄지면 그 신경망은 올바른 추측을 수행하게 된다.

즉, 이 신경망은 맥락이 you와 goodbye일 때, 타깃 단어는 무엇인가? 라는 질문에 올바른 답을 내어줄 수 있다.

 

이진 분류 방식으로 해결하기 위해서는 Yes/No로 답할 수 있는 질문을 생각해내야 한다.

맥락이 you와 goodbye일때, 타깃 단어는 say입니까? 라는 질문에 답하는 신경망을 생각해내야한다.

이렇게 하면 출력층에는 뉴런을 하나만 준비하면 된다. 

출력층의 이 뉴런이 say의 점수를 출력하는 것이다.

그림에서 보듯 출력층의 뉴런은 하나뿐이다.

따라서 은닉층과 출력 측의 가중치 행렬의 내적은 say에 해당하는 열만을 추출하고, 그 추출된 벡터와 은닉층 뉴련과의 내적을 계산하면 끝이다

출력 측의 가중치 W_out에서는 각 단어ID의 단어 벡터가 각각의 열로 저장되어 있다.

이 예에서는 say에 해당하는 단어 벡터를 추출한다. 그리고 그 벡터와 은닉층 뉴런과의 내적을 구한다.

이렇게 구한 값이 최종 점수이다.

 

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

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

이 둘은 이진 분류 신경망에서 가장 흔하게 사용하는 조합이다.

 

시그모이드함수는 S자 곡선 형태이며, 입력 값은 0에서 1 사이의 실수로 변환된다. 여기서 핵심은 시그모이드 함수의 출력을 확률로 해석할 수 있다는 것이다.

시그모이드 함수를 적용해 확률 y를 얻으면, 이 확률 y로부터 손실을 구한다.

시그모이드 함수에서 손실 함수는 다중 분류때처럼 교차 엔트로피 오차이다.

여기서 y는 시그모이드 함수의 출력이고, t는 정답 레이블이다.

이 정답 레이블의 값은 0 혹은 1이다. t가 1이면 정답이 Yes 이고, 0이면 No이다.

따라서 t가 1이면 -logy가 출력되고, 반대로 t가 0이면 -log(1-y)가 출력된다.

 

여기에서 y는 신경망이 출력한 확률이고, t는 정답 레이블이다. 그리고 y-t는 정확히 그 두 값의 차이인 것이다.

정답 레이블이 1이라면, y가 1에 가까워질수록 오차가 줄어든다는 뜻이다.

반대로 y가 1로부터 멀어지면 오차가 커진다. 그리고 그 오차가 앞 계층으로 흘러가므로, 오차가 크면 크게 학습하고, 오차가 작으면 작게 학습하게 된다.

 

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

지금까지의 이야기를 구현 관점에서 정리해보자.

우리는 지금까지 다중 분류 문제를 다뤘다. 다중 분류에서는 출력층에 어휘 수만큼의 뉴런을 준비하고 이 뉴럼들이 출력한 값을 Softmax계층에 통과시켰다.

이때 이용되는 신경망을 계층과 연산 중심으로 그리면 다음 그림과 같다.

입력층에서는 각각에 대응하는 단어ID의 분산 표현을 추출하기 위해 Embedding계층을 사용했다.

위 그림의 신경망을 이진 분류 신경망으로 변환해보자.

여기서는 은닉층 뉴런 h와 출력 측의 가중치 W_out에서 단어 say에 해당하는 단어 벡터와의 내적을 계산한다.

그리고 그 출력을 sigmoid with loss 계층에 입력해 최종 손실을 얻는다.

 

위 그림의 후반부를 더 단순하게 만들어 보기 위해 Embedding Dot 계층을 도입한다.

이 계층은 Embedding 계층과 dot연산의 처리를 합친 계층이다.

이 계층을 사용하면 그림의 후반부를 그림처럼 그릴 수 있다.

은닉층 뉴런 h는 Embedding Dot 계층을 거쳐 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

EmbeddingDot 클래스에는 총 3개의 인스턴스 변수(embed, params, prads, cache)가 있다.

 이 책의 규현 규책대로 params에는 매개변수를 저장하고, grads에는 기울기를 저장한다.

embed는 Embedding 계층을, cache에는 순전파 시의 계산 결과를 잠시 유지하기 위한 변수로 사용한다.

 

순전파를 담당하는 forward(h,idx) 매서드는 인수로 은닉층 뉴런(h)과 단어ID의 넘파이 배열(idx)을 받는다.

여기서 idx는 단어 ID의 배열인데, 배열로 받는 이유는 데이터를 한꺼번에 처리하는 미니배치 처리를 가정했기 때문이다.

 

이 코드의 forward() 매서드는 우선 Embedding 계층의 forward(idx)를 호출한 다음 내적을 계산한다.

내적 계산은 np.sum(self.target_W*h,axis=1)이라는 단 한 줄로 이뤄진다.

이 구현을 이해하려면 구체적인 값을 보는 편이 빠르다.

적당한 W와 h, 그리고 idx를 준비한다. 여기서 idx가 [0,3,1]인데, 이는 3개의 데이터를 미니배치로 한 번에 처리하는 예임을 뜻한다.

idx가 [0,3,1]이므로 target_W는 W의 0번, 3번, 1번째의 행을 추출한 결과이다.

그리고 target_W*h는 각 원소의 곱을 계산한다.

그리고 이 결과를 행마다 전부 더해 최종 결과 out을 얻는다.

 

이상이 Embedding Dot계층의 순전파이다.

역전파느 순전파의 반대 순서로 기울기를 전달해 구현한다.

 

4.2.5 네거티브 샘플링

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

우리는 지금까지 say만을 대상으로 이진 분류를 해왔다. 만약 여기서 좋은 가중치가 준비되어 있다면 sigmoid 계층의 출력은 1에 가까울 것이다.

현재의 신경망에서는 긍정적 예인 say에 대해서만 학습하게 된다.

그러나 부정적인 예에 대해서는 어떠한 지식도 획득하지 못했다.

여기서 우리가 하고 싶은 일은 긍정적인 예에 대해서는 Sigmoid 계층의 출력을 1에 가깝게 만들고, 부정적인 예에 대해서는 Sigmoid 계층의 출력을 0에 가깝게 만드는 것이다.

이런 결과를 만들어주는 가중치가 필요하다

 

그러면 모든 부정적 예를 대상으로 하여 이진 분류를 학습시켜보면 어떨까? 

모든 부정적 예를 대상으로 하는 방법은 어휘 수가 늘어나면 감당할 수 없기 때문이다.

그래서 근사적인 해법으로, 부정적 예를 몇 개 선택한다. 즉, 적은 수의 부정적 예를 샘플링해 사용한다.

이것이 바로 네거티브 샘플링 기법이 의미하는 바이다.

 

네거티브 샘플링 기법은 긍정적 예를 타깃으로 한 경우의 손실을 구한다.

그와 동시에 부정적 예를 몇 개 샘플링하여, 그 부정적 예에 대해서도 마찬가지로 손실을 구한다.

그리고 각각의 데이터의 손실을 더한 겂을 최종 손실로 한다.

 

긍정적 예의 타깃은 say, 부정적 예의 타깃을 2개 hello와 I를 샘플링했다고 가정하자.

CBOW 모델의 은닉층 이후만 주목하면 네거티브 샘플링의 계산 그래프는 그림처럼 그릴 수 있다.

주의할 부분은 긍정적 예와 부정적 예를 다루는 방식이다.

긍정적 예에 대해서는 지금까지처럼 Sigmoid with Loss 계층에 정답 레이블로 1을 입력한다.

한편, 부정적 예에 대해서는 Sigmoid with Loss 계층에 정답 레이블로 0을 입력한다.

그런 다음 각 데이터의 손실을 모두 더해 최종 손실을 출력한다.

 

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

부정적인 예를 샘플링 하는 방법 중 단순히 무작위로 샘플링 하는 것보다 좋은 방법이 있다.

바로 말뭉치의 통계 데이터를 기초로 샘플링하는 방법이다.

구체적으로 말하면, 말뭉치에서 자주 등장하는 단어를 많이 추출하고 드물게 등장하는 단어를 적게 추출하는 것이다.

말뭉치에서의 단어 빈도를 기준으로 샘플링하려면, 먼저 말뭉치에서 각 단어의 추련 횟수를 구해 확률분포로 나타낸다.

그런 다음 그 확률분포대로 단어를 샘플링하면 된다.

말뭉치에서의 단어별 출현 횟수를 바탕으로 확률분포를 구한 다음, 그 확률분포에 따라서 샘플링을 수행하기만 하면 된다.

확률분포대로 샘플링하므로 말뭉치에서 자주 등장하는 단어는 선택될 가능성이 높다. 같은 이유로 희소한 단어는 선택되기가 어렵다.

 

import numpy as np

# 0에서 9까지의 숫자 중 하나를 무작위로 샘플링
np.random.choice(10)
# 7
np.random.choice(10)
# 2

# words에서 하나만 무작위로 샘플링
words=['you','say','goodbye','I','hello','.']
np.random.choice(words)
# 'goodbye'

# 5개만 무작위로 샘플링(중복 있음)
np.random.choice(words,size=5)
# array(['goodbye','.','hello','goodbye','say'],dtype='<U7')

# 5개만 무작위로 샘플링 (중복 없음)
np.random.choice(words,size=5,replace=False)
# array(['hello','.','goodbye','I','you'],dtype='<U7')

# 확률분포에 따라 샘플링
p=[0.5,0.1,0.05,0.2,0.05,0.1]
np.random.choice(words,p=[)
# 'you'

이 코드에서 보듯 np.random.choice()는 무작위 샘플링 용도로 이용할 수 있다.

이 때 인수로 size를 지정하면 샘플링을 size만큼 수행한다.

또한 인수에 replace=False를 지정하면 샘플링 시 중복을 없애준다.

그리고 인수 p에 확률분포를 받은 리스트를 지정하면 그 확률분포대로 샘플링한다.

이제 이 함수를 사용해 부정적 예를 샘플링하기만 하면 된다.

 

그런데 word2vec의 네거티브 샘플링에서는 앞의 확률분포에서 한 가지를 수정하라고 권고하고 있다.

바로 이 식처럼 기본 확률분포에 0.75를 제곱하는 것이다.

다만 수정 후에도 확률의 총합은 1이 되어야 하므로, 분모로는 수정후 확률분포의 총합이 필요하다.

출현 확률이 낮은 단어를 버리지 않기 위해서 이처럼 수정을 한다.

더 정확하게 말하면, 0.75제곱을 함으로써, 원래 확률이 낮은 단어의 확률을 살짝 높일  수 있다.

p=[0.7,0.29,0.01]
new_p=np.power(p,0.75)
new_p/=np.sum(new_p)
print(new_p)
# [0.64196878 0.331504408 0.02652714]

이 처럼 낮은 확률의 단어가 조금 더 쉽게 샘플링되도록 하기 위한 구제 조치이다.

0.75라는 수치는 이론적인 의미는 없으니 다른 값으로 설정해도 된다.

 

이 책에서는 이 처리르 담당하는 클래스는 UnigramSampler라는 이름으로 제공한다.

이 클래스는 초기화 시 3개의 인수를 받는다.

단어 ID 목록인 corpus, 확률분포에 제곱할 값인 power, 부정정 예 샘플링을 수행하는 횟수인 sample_size.

또한 이 클래스는 get_negative_sample(target)매서드를 제공한다.

이 매서드는 target 인수로 지정한 단어를 긍정적 예로 해석하고, 그 외의 단어 ID를 샘플링한다.

corpus=np.array([0,1,2,3,4,1,2,3])
power=0.75
sample_size=2

sampler=UnigramSampler(corpus,power,sample_size)
target=np.array([1,3,0])
negative_sample=sampler.get_negative_sample(target)
print(negative_sample)
# [[0 3]
#  [1 2]
#  [2 3]]

여기서는 긍정적 예로 [1,3,0]이라는 3개의 데이터를 미니배치로 다뤘다.

이 각각의 데이터에 대해서 부정적 예를 2개씩 샘플링한다.

이 예에서는 첫번째 데이터에 대한 부정적 예는 [0,3], 두번째는 [1,2], 세번째는 [2 3]이 뽑혔음을 알 수 있다.

 

4.2.7 네거티브 샘플링 구현

class NegativeSampligLoss:
    def __init__(self, W, corpus, power=0.75, sample_size=5):
        self.sample_size=sample_size
        self.smapler=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 += later.grads

초기화 메서드의 인수로는 출력 측 가중치를 나타내는 W, 말뭉치를 뜻하는 corpus, 확률분포에 제곱할 값인 power, 부정적 예의 샘플링 횟수인 sample_size이다.

여기서 앞절에서 설명한 UnigramSampler클래스를 생성하여 인스턴스 변수인 sampler로 저장한다.

 

인스턴수 변수의 loss_layers와 embed_dot_layers에는 원하는 계층을 리스트로 보관한다.

이 때 이 두 리스트에는 sample_size+1개의 계층을 생성하는데, 부정적 예를 다루는 계층이 sample_size개만큼이고, 여기에 더해 긍정적 예를 다루는 계층이 하나 더 필요하기 때문이다.

정확히는 0번째 계층, 즉 loss_layers[0]과 embed_dot_layers[0]이 긍정적 예를 다루는 계층이다.

그런 다음 이 계층에서 사용하는 매개변수와 기울기를 각각 배열로 저장한다.

def forward(self,h,target):
    batch_size=target.shape[0]
    negative_sample=self.smapler.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

forward(h,target) 매서드가 받는 인수는 은닉층 뉴런 h와 긍정적 ㅇ_의 타깃을 뜻하는 target이다.

이 메서드에서는 우선 self.params를 이용해 부정적 예를 샘플링하여 negative_sample에 저장한다.

그런 다음 긍정적 예와 부정적 예 각각의 데이터레 대해 순전파를 수행해 그 손실을 더한다.

구체적으로는 Embedding Dot 계층의 forward 점수를 구하고, 이어서 이 점수와 레이블을 Sigmoid with 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

역전파의 구현은 간단하다.

순전파 때의 역순으로 각 계층의 backward()를 호출하기만 하면 된다.

은닉층의 뉴런의 순전파 시에 여러 개로 복사된다. 따라서 역전파 때는 여러 개의 기울기 값을 더해준다.

 

Comments