Deep Learning

[밑바닥부터시작하는딥러닝2] Chapter 4. word2vec 속도 개선

씨주 2024. 4. 19. 14:02

CBOW모델은 말뭉치에 포함된 어휘 수가 많아지면 계산량이 커져 계산 시간이 오래 걸린다.

이를 위해 Embedding이라는 새로운 계층을 도입하고 네거티브 샘플링 이라는 새로운 손실함수를 도입한다.

 

4.1. word2vec 개선 (1)

CBOW모델은 단어 2개를 맥락으로 사용해 이를 바탕으로 하나의 단어(타깃)을 추측한다.

이때 입력측 가중치(Win)와의 행렬곱으로 은닉층이 계산되고 다시 출력측 가중치(Wout)와의 행렬곱으로 각 단어의 점수를 구한다.

이 점수에 소프트맥스 함수를 적용해 각 단어의 출현 확률을 얻고 이 확률을 정답 레이블과 비교하여(교차 엔트로피 오차를 적용하여) 손실을 구한다.

그림 4-1.

 

이런 CBOW모델은 거대한 말뭉치를 다루게 되면 문제가 생긴다.

그림 4-2와 같이 어휘가 100만개, 은닉층의 뉴런이 100개인 CBOW 모델을 생각해보자.

입출력층에는 100만개의 뉴런이 존재하고 이 수많은 뉴런때문에 중간 계산에 많은 시간이 소요된다.(두 계산이 병목이 된다.)

- 입력층의 원핫 표현과 가중치 행렬(Win)의 곱 계산 -> Embedding 계층 도입으로 해결

- 은닉층과 가중치 행렬(Wout)의 곱 및 softmax 계층의 계산 -> 네거티브 샘플링이라는 손실함수를 도입해 해결

그림 4-2.

 

✔️ Embedding 계층

word2vec 구현에서는 단어를 원핫표현으로 바꿔 가중치 행렬을 곱한다.

하지만 이는 단지 행렬의 특정행을 추출하는 것이다. 즉, 원핫표현으로의 변환과 행렬곱이 필요가 없다.

이를 해결하기 위해 가중치 매개변수로부터 '단어 ID에 해당하는 행(벡터)'을 추출하는 계층인 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
    
    def backward(self, dout):
        dW, = self.grads
        dW[...] = 0
        
        # dW[self.idx] = dout 나쁜 예
        for i, word_id in enumerate(self.idx):
        	dW[word_id] += dout[i]
        #np.add.at(dW, self.idx, dout)
        return None

 

여기서 idx의 원소가 중복될 때 문제가 발생한다.

idx가 [0, 2, 0, 4]일 때, 0번째 행에 2개의 값이 할당된다. 즉 먼저 쓰여진 값을 덮어쓴다.

따라서 '할당'이 아닌 '더하기'를 해야 한다.

그림 4-5.

 

4.2. word2vec 개선(2)

은닉층 이전은 Embedding 계층을 도입해 계산낭비를 줄였다.

은닉층 이후의 계산낭비가 일어나는 곳은 아래 두 부분이다.

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

- softmax 계층의 계산

 

✔️ 다중 분류에서 이진 분류로

이를 해결하는 네거티브 샘플링은 다중분류(multi-class classification)을 이진분류로 근사하는 것이다.

100만개의 단어 중에서 옳은 단어 하나를 선택하는 다중분류를 이진분류문제로 다룰 수는 없을까?

- 다중분류 : 맥락이 'you'와 'goodbye' 일 때 타깃 단어는 무엇입니까?

- 이진분류 : 맥락이 'you'와 'goodbye'일 때 타깃단어는 'say'입니까?

왼쪽 : 그림 4-6. 다중분류 / 오른쪽 : 그림 4-7. 이진분류

 

다중분류 : '소프트맥스 함수'를 적용해 손실함수로 '교차 엔트로피 오차'를 사용한다.

이진분류 : '시그모이드 함수'를 적용해 손실함수로 '교차 엔트로피 오차'를 사용한다.

시그모이드 함수의 출력을 확률로 해석하여 사용한다.

그림 4-9.
그림 4-10.

여기서, y는 신경망이 출현할 확률, t는 정답 레이블

역전파의 y-t는 이 두값의 차이로 정답 레이블 t이 1이라면 y가  1(100%)에 가까울수록 오차가 줄어든다.

따라서 오차가 앞 계층으로 흘러가므로, 오차가 크면 '크게' 학습되고 오차가 작으면 '작게' 학습된다.

왼쪽 : 그림 4-11. 다중분류 / 오른쪽 : 그림 4-12. 이중분류

 

그림 4-12의 이중분류를 간단하게 표현하기 위해 Embedding Dot 계층을 도입한다.

그림 4-13.

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

 

✔️ 네거티브 샘플링

이제 정답에 대해서만 학습(출력을 1에 가깝게 만드는 것)하였으니 오답일 때의 학습(출력을 0에 가깝게 만드는 것)도 해보자.

모든 부정적 예를 대상으로 하면 계산이 늘기 때문에 적은 수의 부정적 예를 샘플링(네거티브 샘플링)해 사용할 것이다.

그림 4-17.

 

이때 샘플링은 무작위가 아닌 말뭉치 통계 데이터를 바탕으로 한다.

자주 등장하는 단어를 많이 추출하고 드물게 등장하는 단어를 적게 추출한다. 

그림 4-18.

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:  # == CPU
            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  # target이 뽑히지 않게 하기 위함
                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
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]  # embed_dot에 해당하는 타겟이라는 의미인 듯
            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

 

4.3. 개선판 word2vec 학습

관련 내용은 https://github.com/ExcelsiorCJH/DLFromScratch2.git 참고

 

4.4. word2vec 남은 주제

단어의 분산표현은 한 분야에서 배운 지식을 다른 분야에도 적용할 수 있는 전이 학습(transfer learning)이 가능하다.

큰 말뭉치(위키백과, 구글 뉴스의 텍스트 데이터 등)로 학습을 끝난 후 텍스트 분류, 문서 클러스터링, 감정 분석 같은 자연어 처리작업에 사용할 수 있다.

 

단어의 분산표현은 단어를 고정 길이 벡터로 변환해준다는 장점도 있다.

문장(단어의 흐름)도 단어의 분산표현을 사용해 고정 길이 벡터로 변환할 수 있다.

이를 통해 머신러닝 기법(신경망이나 SVM 등)을 적용할 수 있다.

그림 4-21.

 

4.5. 정리

말뭉치의 어휘 수 증가에 비례해 계산량이 증가하는 문제를 '모든' 데이터가 아닌 '일부' 데이터를 처리하는 것으로 해결했다.

인간 역시 모든 것을 알 수 없듯이 컴퓨터도 모든 데이터를 처리하는 것은 비현실적이다.

그러니 꼭 필요한 일부에 집중하는 편이 얻는게 많다.

 

- Embedding 계층은 단어의 분산 표현을 담고 있으며, 순전파 시 지정한 단어 ID의 벡터를 추출한다.

- word2vec은 어휘 수의 즈가에 비례해 계산량도 증가하므로, 근사치로 계산하는 빠른 기법을 사용하면 좋다.

- 네거티브 샘플링은 부정적 예를 몇 개 샘플링하는 기법으로, 이를 이용하면 다중 분류를 이진 분류처럼 취급할 수 있다.

- word2vec으로 얻은 단어의 분산 표현에는 단어의 의미가 녹아들어 있으며, 비슷한 맥락에서 사용되는 단어는 단어 벡터 공간에서 가까이 위치한다.

- word2vec의 단어의 분산 표현을 이용하면 유추 문제를 벡터의 덧셈과 뺄셈으로 풀 수 있게 된다.

- word2vec은 전이 학습 측면에서 특히 중요하며, 그 단어의 분산 표현은 다양한 자연어 처리 작업에 이용할 수 있다.

 

 

 

참고 git : https://github.com/ExcelsiorCJH/DLFromScratch2.git