본문 바로가기

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

4장 word2vec 속도 개선

word2vec 개선 (1)

은닉층의 뉴런이 100개인 CBOW 모델을 생각해보자

다음의 두 계산이 병목이 된다.

1. 입력층의 원핫 표현과 가중치 행렬 $W_{in}$의 곱 계산

뉴런의 크기가 100만개라면 원-핫 벡터는 상당한 메모리를 차지하게된다. 또 이 원핫 벡터와 가중치 행렬의 곱 계산에도 상당한 자원을 소모하게 된다. 이는 Embedding 계층을 도입하여 해결한다.

 

2. 은닉층의 가중치 행렬 $W_{out}$의 곱 및 Softmax 계층의 계산

마찬가지로 다루는 어휘가 많아짐에 따라 계산량이 증가한다. 이는 네거티브 샘플링이라는 새로운 손실 함수를 도입해 해결한다.

 

Embedding 계층

사실 이 계산은 단어 ID에 해당하는 행벡터를 추출하는 것뿐이다. 따라서 원핫 표현으로의 변환과 MatMul 계층의 행렬 곱은 필요가 없어지게 된다.

 

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

Embedding 계층의 forward 메서드는 매우 쉽다. 가중치 행렬 W로 부터 인덱스에 해당하는 행벡터를 추출하기만 하면된다.

 

forward시 가중치의 특정 행 벡터를 다음 층으로 흘려보내기만 했기 때문에, 역전파시에도 앞 층의 gradient를 그대로 흘려주면 된다.

다만 앞 층으로부터 전해진 기울기 $dh$를 가중치 기울기 $dW$의 인덱스 행에 설정한다.

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

np.add.at(A, idx, B)는 B를 A의 idx번째 행에 더해준다. idx가 array일 경우에도 가능하여 for 문을 사용하는 것보다 속도가 더 빠르다.

 

word2vec 개선 (2)

다중 분류에서 이진 분류로

you와 goodbye가 주어졌을 때 여러 어휘 중에서 타겟이 무엇인지를 추측하는 문제에서 타겟이 say인지 아닌지를 추측하는 문제로 바꾸는 아이디어이다. 

CBOW 모델의 구조는 다음과 같이 바뀔 수 있다.

 

 

은닉층과 say에 해당하는 열벡터의 내적을 그림으로 표현하면 다음과 같다.

 

그런 다음 계산된 score에 시그모이드 함수를 이용해 확률로 변환하고 크로스 엔트로피 손실함수를 이용해 학습한다.

 

Embedding과 이진 분류가 적용된 신경망은 다음과 같다.

 

위 그림의 곱셈 노드 이후를 더 간소하게 표현하면 다음과 같다.

 

Embedding Dot 계층은 Embedding 계층과 내적을 합친 계층이다.

class EmbeddingDot:
    def __init__(self, W):
        self.embed = Embedding(W)
        self.params = self.embed.params
        self.grads = self.embed.grads
        self.cache = None # forward 시의 계산 결과를 잠시 유지하기 위한 변수
    
    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

 

네거티브 샘플링

지금까지는 정답 레이블(say)에 대해서만 생각했다. 좋은 신경망을 만들기 위해서는 오답 레이블에 대해서도 sigmoid 출력이 0이 나오도록 학습시켜야 한다. 그렇지만 모든 오답 레이블에 대해 학습하는 것은 어휘 수가 많은 경우 불가능 하기 때문에 오답 중 몇 개를 샘플링하여 사용한다.

 

정답 레이블에 대한 손실과, 샘플링된 오답 레이블에 대한 손실들을 모두 더해 최종 손실로 사용한다. 오답 레이블 2개를 샘플링 한 경우 은닉층 이후 계산 그래프는 다음과 같다.

오답 레이블을 샘플링 하는 방법은 말뭉치에서 단어의 출현 횟수를 구해 이를 확률 분포로 나타낸 뒤 이 확률분포에 기반하면 샘플링을 한다.

words = ['a', 'b', 'c', 'd']
p = [0.1, 0.2, 0.3, 0.4]

np.random.choice(words, p=p)

네거티브 샘플링에서는 확률 분포에 0.75를 제곱하여 사용하라고 한다. 이는 낮은 확률 값을 조금 높이는 효과가 있다.

 

샘플링 담당하는 클래스 UnigramSampler 클래스의 구현은 다음과 같다.

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

np.random.choice() 메서드를 이용해 size에 지정된 수 만큼 샘플링을 한다. replace=False로 지정하면 중복이 없다.

 

이를 가지고 CBOW 모델 구현에 사용할 NegativeSamplingLoss 계층을 구현해보자

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 sample
        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 sample
        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시 positive와 negative sample들에 대하여 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시에도 순전파에서 모두 더해준 것 처럼 sample마다 계산된 $dh$를 모두 더해서 리턴한다.

이를 이용한 CBOW 모델 클래스 구현은 다음과 같다.

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

초기화에서 계층을 생성할 때 window_size의 2배로 해주는 이유는 타겟 단어로 부터 양 옆의 맥락을 사용하기 때문이다.

 

CBOW 모델 평가

앞에서 구현한 CBOW 모델을 ptb 데이터셋에 학습하였다. 2장에서 구현한 most_similar() 메서드를 이용해, 단 어 몇개에 대해 거리가 가장 가까운 단어들을 뽑아보자.

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)

결과는 다음과 같다.

[query] you
 we: 0.6103515625
 someone: 0.59130859375
 i: 0.55419921875
 something: 0.48974609375
 anyone: 0.47314453125

[query] year
 month: 0.71875
 week: 0.65234375
 spring: 0.62744140625
 summer: 0.6259765625
 decade: 0.603515625

[query] car
 luxury: 0.497314453125
 arabia: 0.47802734375
 auto: 0.47119140625
 disk-drive: 0.450927734375
 travel: 0.4091796875

[query] toyota
 ford: 0.55078125
 instrumentation: 0.509765625
 mazda: 0.49365234375
 bethlehem: 0.47509765625
 nissan: 0.474853515625

이번에는 유추 문제를 풀어보자. word2vec으로 얻은 단어의 분산 표현은 비슷한 단어를 가까이 모을 뿐 아니라, 더 복잡한 패턴도 파악할 수 있다. 대표적인 예가 다음과 같은 문제이다.

벡터 공간에서 vec_?을 구하기 위해서는 vec_king + vec_woman - vec_man을 계산하면 된다. 이 로직을 구현한 메서드 analogy()는 다음과 같다.

def analogy(a, b, c, word_to_id, id_to_word, word_matrix, top=5, answer=None):
    for word in (a, b, c):
        if word not in word_to_id:
            print('%s(을)를 찾을 수 없습니다.' % word)
            return

    print('\n[analogy] ' + a + ':' + b + ' = ' + c + ':?')
    a_vec, b_vec, c_vec = word_matrix[word_to_id[a]], word_matrix[word_to_id[b]], word_matrix[word_to_id[c]]
    query_vec = b_vec - a_vec + c_vec
    query_vec = normalize(query_vec)

    similarity = np.dot(word_matrix, query_vec)

    if answer is not None:
        print("==>" + answer + ":" + str(np.dot(word_matrix[word_to_id[answer]], query_vec)))

    count = 0
    for i in (-1 * similarity).argsort():
        if np.isnan(similarity[i]):
            continue
        if id_to_word[i] in (a, b, c):
            continue
        print(' {0}: {1}'.format(id_to_word[i], similarity[i]))

        count += 1
        if count >= top:
            return

query_vec을 계산한 뒤 이를 normalize해주는 부분이 있는데, normalize는 element 값을 정규화하고 word_matrix와 내적 계산이 가능하도록 reshape해준다. 예를 들어 word_matrix의 shape이 (100, 3)이라고 하면 100개의 단어가 있고 이들을 3차원 벡터로 표현한 것이다. query_vec은 당연히 (1, 3)일테고 이를 (3, 1)로 reshape 해주어야 word_matrix와 내적하여 (100, 1)의 벡터를 얻을 수 있다. 이것이 similarity이고 각 행에는 query_vec과의 유사도 점수가 담겨 있다.

 

유추를 해보면 다음과 같다.

# 유추(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)

[analogy] king:man = queen:?
 woman: 5.16015625
 veto: 4.9296875
 ounce: 4.69140625
 earthquake: 4.6328125
 successor: 4.609375

[analogy] take:took = go:?
 went: 4.55078125
 points: 4.25
 began: 4.09375
 comes: 3.98046875
 oct.: 3.90625

[analogy] car:cars = child:?
 children: 5.21875
 average: 4.7265625
 yield: 4.20703125
 cattle: 4.1875
 priced: 4.1796875

[analogy] good:better = bad:?
 more: 6.6484375
 less: 6.0625
 rather: 5.21875
 slower: 4.734375
 greater: 4.671875

두세 번째 문제에서 과거형, 단복수에 대한 패턴을 파악하고 있음 알고 있지만 마지막 문제에서는 worse라고 대답하지는 않았다. 그렇지만 more, less 등의 답변을 하는 것으로 보아 비교급이라는 특징은 단어의 분산 표현에 인코딩되어 있음을 알 수 있다.

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

6장 게이트가 추가된 RNN (1)  (0) 2023.10.27
5장 RNN (2)  (0) 2023.09.27
5장 RNN (1)  (0) 2023.09.26
3장 word2vec  (0) 2023.09.25
2장 자연어와 단어의 분산 표현  (0) 2023.09.24