본문 바로가기

Deep learning from scratch

Word2Vec : CBOW와 Skip-Gram


먼저 해당 섹션의 포스팅들은 사이토 고키의 밑바닥부터 시작하는 딥러닝 2를 참조하여 작성되었음을 밝힙니다. 영상처리 분야와 더불어 기계 번역, 음성 인식 등 실생활에 큰 영향을 주는 기술의 근간에는 자연어 처리 분야가 활용되고 있는 것을 볼 수 있습니다. 더 나아가 멀티 모달리티 분야에서 딥러닝을 적용하여 문제를 해결하기 위해서는 자연어 처리와 시계열 데이터 처리에 대하여 추가로 학습할 필요가 있다고 생각하였기에 밑바닥부터 시작하는 딥러닝 2Github 레포지토리를 참조하여 공부해보고자 합니다.

 

1. CBOW (Continuous Bag of Words)

그림 1. CBOW 모델과 Skip-gram 모델

단어 간 유사도를 파악하기 위하여 단어를 벡터로 표현하는 방법 중 추론 기반의 기법으로 word2vec 방법이 있습니다. Word2vec는 CBOW와 Skip-gram 두 가지 모델을 가집니다. 먼저 CBOW 모델은 그림 1.과 같이 문장의 맥락으로부터 타깃이 되는 단어를 추측하는 용도의 모델입니다. 이와 달리 Skip-gram 모델은 CBOW를 역전 시킨 모델로서 어떠한 타깃으로부터 맥락을 추측하는 모델입니다. 

 

두 모델은 입력층 쪽의 은닉층과 출력단의 은닉층 두 개를 가지는 것이 특징입니다. 이때 학습 데이터의 말뭉치 크기에 따라 은닉층의 가중치 파라미터의 크기가 커지며, 이에 따른 연산 비용 또한 지수적으로 증가합니다. 이를 해결하기 위해서 입력 데이터는 은닉층의 가중치 행렬과의 행렬곱 대신 embedding층을 거칩니다. 이는 입력 데이터가 원 핫 인코딩(one-hot encoding) 된 상태이기 때문입니다. 입력 데이터의 argmax의 인덱스에 해당하는 은닉층 가중치 행렬의 행만을 선택하여 출력으로 내보냅니다. 이러한 방식으로 미니 배치(mini-batch) 상의 벡터들의 합은 출력단의 은닉층으로 보내집니다.

 

출력단의 은닉층에서도 마찬가지로 embedding을 수행한 후 가중치 행렬의 인덱스에 해당하는 행과 내적을 계산합니다. 그리고 이는 활성화 함수를 거친 후 크로스 엔트로피를 통해 loss를 계산합니다. 이 과정에 따라 CBOW 모델에서는 정답 데이터에 대하여 다중 분류를 마치 이진 분류와 유사한 방법으로 수행하게 됩니다. 하지만 단어 간 유사도를 잘 표현하기 위해서는 입력 데이터가 정답인 경우 뿐만 아니라 오답인 데이터가 입력 되었을 경우 또한 결과를 잘 추정해야 합니다. 이에 따라 CBOW 모델에서는 negative sampling을 수행합니다.

 

앞서 설명한 바와 같이 negative sampling을 수행하지 않는다면, 학습 과정에서는 정답인 입력 데이터에 대한 해당 모델의 출력 값은 1에 가깝게 학습될 것입니다. 즉, 오답인 데이터가 입력되었을 때 그 결과가 0에 가까울 것으로 확신할 수 없습니다. Negative sampling은 오답인 입력 데이터의 경우에 대해서도 loss를 계산하여 모델을 업데이트 하기 위해 수행합니다.

이는 해당 모델의 출력이 0에 가깝게 하기 위함이라고 할 수 있습니다. 해당 데이터는 corpus의 단어들의 출현 횟수를 확률 분포를 참조하여 샘플링됩니다.


2. Reference codes

CBOW 모델 이용한 PTB 데이터의 학습을 위해 Github 레포지토리를 참조하였습니다.

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

        # Xavier initialization
        # Embedding과 Negative Sampling을 수행하므로 가중치의 형태는 아래와 같다. 
        W_in = np.random.randn(V, H) * np.sqrt(2.0 / V)
        W_out = np.random.randn(V, H) * np.sqrt(2.0 / V)

        # 계층 생성
        self.in_layers = []
        for i in range(2 * window_size):
            # W_in
            # 입력되는 데이터가 원 핫 인코딩으로 표현되므로 행렬 곱 대신 W_in의 해당 행을 추출한다.
            layer = Embedding(W_in)  # Embedding! 
            self.in_layers.append(layer)

        # W_out
        # Embedding dot을 통해 연산량을 줄였을 때는 정답인 label에 대한 모델의 출력만을 고려한다.
        # 학습 과정에서는 추가적으로 정답인 아닌 label에 대한 모델의 출력 또한 고려해야 한다.
        # Negative Sampling을 수행하고 loss를 계산함으로써 이를 가능하게 한다.
        # 이때 power 매개변수를 통해서 Corpus의 단어 분포에서 빈도가 낮은 단어의 추출 확률을 약간 높인다.
        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
        # W_in으로 window size 만큼 돌아서 평균을 구한다.
        for i, layer in enumerate(self.in_layers):
            h += layer.forward(contexts[:, i])
        h *= 1 / len(self.in_layers)

        # Negative Sampling loss를 계산한다.
        loss = self.ns_loss.forward(h, target)
        return loss

 

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)

        # Positive sampling - forward
        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)

        # Negativer sampling - forward
        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

 

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:
            # Corpus에서 해당 word_id의 출현 빈도를 저장한다.
            counts[word_id] += 1

        vocab_size = len(counts)
        self.vocab_size = vocab_size

        # Corpus에서 해당 word_id의 출현 빈도를 확률로 나타내기 위해 self.word_p을 초기화 한다.
        self.word_p = np.zeros(vocab_size)
        for i in range(vocab_size):
            self.word_p[i] = counts[i]
        
        # Corpus에서 출현 빈도가 낮은 word의 sampling probability를 높인다. 
        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:
            # Mini-batch 처리를 고려하여 self.word_p에 따라 sampling을 수행한다.
            # 이때 부정적 예에 타깃이 포함된다.
            negative_sample = np.random.choice(self.vocab_size, size=(batch_size, self.sample_size),
                                               replace=True, p=self.word_p)

        return negative_sample

이렇게 word2vec의 CBOW모델을 살펴보았습니다. 질문이나 지적 사항은 댓글로 남겨주시면 감사하겠습니다.