해당 포스팅은 '밑바닥부터 시작하는 딥러닝2'를 공부하고 정리, 요약한 글입니다. 모든 내용은 해당 도서를 기준으로 합니다.
◼️ 4.1 word2vec 개선 1
위 그림과 같이 CBOW 모델은 단어 2개를 맥락으로 사용해 이를 바탕으로 하나의 단어를 추측한다. 이때 입력 측 가중치와의 행렬 곱으로 은닉층이 계산되고 다시 출력 측 가중치와의 행렬 곱으로 각 단어의 점수를 구한다.
이 점수에 소프트맥스 함수를 적용해 각 단어의 출현 확률을 얻고 이 확률을 정답 레이블과 비교하여 손실을 구한다.
위 그림에서 보듯 입력층과 출력층에는 각 100만 개의 뉴런이 존재한다.
이 수많은 뉴런 때문에 중간 계산에 많은 시간이 소요되며 다음 두 계산이 병목이 된다.
- 입력층의 원핫 표현과 가중치 행렬 Win의 곱 계산.
- 은닉층과 가중치 행렬 Wout의 곱 및 softmax 계층의 계산.
단어를 원핫 표현으로 다루기 때문에 어휘 수가 많아지면 원핫 표현의 벡터 크기도 커진다. 이 원핫 벡터와 가중치 행렬 Win을 곱해야 하는데 이는 계산 자원을 상당히 사용하게 된다.
때문에 Embedding 계층을 도입해 해결하고자 한다.
두번째 문제는 은닉층 이후 계산이다. softmax 계층에서도 다루는 어휘가 많아짐에 따라 계산량이 증가하는 문제가 있다.
◾ 4.1.1 Embedding 계층
결과적으로 수행하는 일은 단지 행렬의 특정 행을 추출하는 것 뿐이다. 따라서 원핫 표현으로의 변환과 MatMul 계층의 행렬 곱 계산은 사실 필요가 없다.
가중치 매개변수로부터 단어 ID에 해당하는 행(벡터)을 추출하는 계층을 Embedding 계층이라고 부른다. 여기서 단어 임베딩(분산 표현)을 저장하게 된다.
◾ 4.1.2 Embedding 계층 구현
# Embedding Layer 구현
# commons/layers.py
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
◼️ 4.2 word2vec 개선 2
은닉층 이후의 처리 병목을 해소하기 위해 네거티브 샘플링이라는 기법을 사용한다.
◾ 4.2.1 은닉층 이후 계산의 문제점
은닉층 이후에서 계산이 오래 걸리는 곳은 다음 두 부분이다.
- 은닉층의 뉴런과 가중치 행렬(Wout)의 곱
- softmax 계층의 계산
역전파 때도 같은 계산을 수행하기 때문에 행렬 곱을 갑벼게 만들어야 한다.
어휘가 많아지면 softmax의 계산량도 증가하게 된다.
◾ 4.2.2 다중 분류에서 이진 분류로
네거티브 샘플링 기법의 핵심 아이디어는 이진 분류에 있다. 다중 분류를 이진 분류로 근사하는 것이 네거티브 샘플링을 이해하는데 중요한 포인트이다.
다중 분류 문제를 이진 분류 방식으로 해결하는 것이 목적이며 그렇기 위해서는 yes/no로 답을 할 수 있는 질문을 생각해내야 한다.
이 예에서는 'say'에 해당하는 단어 벡터를 추출한다. 그 벡터와 은닉층 뉴런과의 내적을 구한다. 이렇게 구한 값이 최종 점수인 것이다.
◾ 4.2.3 시그모이드 함수와 교차 엔트로피 오차
이진 분류 문제를 신경망으로 풀려면 점수에 시그모이드 함수를 적용해 확률로 변환하고 손실을 구할 때는 손실 함수로 교차 엔트로피 오차를 사용한다.
시그모이드 함수는 다음과 같이 쓴다.
이 식을 그래프로 그리면 아래 그림의 오른쪽처럼 나온다.
시그모이드 함수를 적용해 확률 y를 얻으면 이 확률 y로부터 손실을 구한다. 시그모이드 함수에 사용되는 손실 함수는 다중 분류 때처럼 교차 엔트로피 오차이다. 이는 다음과 같이 쓸 수 있다.
여기에서 y는 시그모이드 함수의 출력이고 t는 정답 레이블이다. 이 정답 레이블의 값은 0 혹은 1이다.
여기서 y는 신경망이 출력한 확률이고 t는 정답 레이블이다. y-t는 정확히 그 두 값의 차이인 것이다. 정답 레이블이 1이라면 y가 1에 가까워질수록 오차가 줄어든다는 뜻이다. 오차가 크면 크게 학습하고 오차가 작으면 작게 학습하게 된다.
◾ 4.2.3 다중 분류에서 이진 분류로 (구현)
다중 분류에서는 출력층에 어휘 수만큼 뉴런을 준비하고 이 뉴런들이 출력한 값을 softmax 계층에 통과시켰다. 이때 사용되는 신경망을 계층과 연산 중심으로 그리면 아래처럼 된다.
이 신경망을 이진 분류 신경망으로 변환하면 아래와 같다.
은닉층 뉴런 h와 출력 층의 가중치 Wout에서 단어 'say'에 해당하는 단어 벡터와의 내적을 계산한다.
위 모델에서의 후반부를 더 단순하게 만들기 위해 embedding Dot 계층을 도입한다.
은닉층 뉴런 h는 embedding Dot 계층을 거쳐 sigmodi with Loss 계층을 통과한다.
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
순전파를 담당하는 forward(h, idx) 메서드는 인수로 은닉층 뉴런(h)과 단어 ID의 넘파이 배열(idx)를 받는다.
◾ 4.2.5 네거티브 샘플링
주어진 문제를 다중 분류에서 이진 분류로 변환할 수 있다. 이것만으로는 문제가 다 해결되지 않는다.
하고자 하는 것이 긍정적 예에 대해서는 sigmoid 계층의 출력을 1에 가깝게 만들고 부정적 예에 대해서는 sigmoid 계층의 출력을 0에 가깝게 만드는 것이다.
모든 부정적 예를 대상으로 하여 이진 분류를 학습 시키면 모든 부정적 예를 대상으로 하는 방법은 어휘 수가 늘어나면 감당할 수 없게 된다.
근사적인 해법으로 부정적 예를 몇개 선택하여 샘플링해서 사용하는 방법이 있다.
이를 네거티브 샘플링 기법이라고 한다.
네거티브 샘플링 기법은 긍정적 예를 타깃으로 한 경우의 손실을 구한다. 그와 동시에 부정적 예를 몇 개 샘플링하여 그 부정적 예에 대해서도 마찬가지로 손실을 구한다. 그리고 각각의 데이터의 손실을 더한 값을 최종 손실로 한다.
긍정적 예에 대해서는 지금처럼 sigmodi with Loss 계층에 정답 레이블로 1을 입력한다. 부정적 예에 대해서는 sigmoid with Loss 계층에 정답 레이블로 0을 입력한다.
◾ 4.2.6 네거티브 샘플링의 샘플링 기법
부정적 예를 샘플링 하는 방법으로는 말뭉치의 통계 데이터를 기초로 샘플링 하는 방법이 있다. 말뭉치에서 자주 등장하는 단어를 많이 추출하고 드물게 등장하는 단어를 적게 추출하는 것이다.
말뭉치에서의 단어별 출현 횟수를 바탕으로 확률분포를 구한 다음 그 확률분포에 따라 샘플링을 수행하기만 하면 된다.
# 확률분포에 따른 샘플링 예제
import numpy as np
# 0에서 9까지의 숫자 중 하나를 랜덤하게 샘플링
np.random.choice(10)
>> 1
np.random.choice(10)
>> 10
# words에서 하나만 무작위로 샘플링
words = ['you', 'say', 'goodbye', 'i', 'hello', '.']
np.random.choice(words)
>> 'hello'
# 5개만 무작위로 샘플링(중복 없음)
np.random.choice(words, size=5, replace=False)
>> array(['goodbye', 'you', 'hello', 'i', '.'], dtype='<U7')
# 확률분포에 따라 샘플링
p = [0.5, 0.1, 0.05, 0.2, 0.05, 0.1]
np.random.choice(words, p=p)
>> 'you'
네거티브 샘플링은 말뭉치에서 단어의 확률분포를 만들고 다시 0.75를 제곱한 다음 np.random.choice()를 사용해 부정적 예를 샘플링한다.
import sys
sys.path.append('..')
from common.np import * # import numpy as np
from common.layers import Embedding, SigmoidWithLoss
import collections
# Negative Sampling 클래스 구현
# chap04/negative_sampling_layer.py
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
p /= p.sum()
negative_sample[i, :] = np.random.choice(self.vocab_size,
size=self.sample_size,
replace=False, p=p)
else:
negative_sample = np.random.choice(self.vocab_size,
size=(batch_size, self.sample_size),
replace=True, p=self.word_p)
return negative_sample
◾ 4.2.7 네거티브 샘플링 구현
# chap04/negative_sampling_layer.py
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
◼️ 4.3 개선판 word2vec 학습
◾ 4.3.1 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_vecs1 = W_in
self.word_vecs2 = W_out
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) # average
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.3.2 CBOW 모델 학습 코드
# chap04/train.py
import sys
sys.path.append('..')
import numpy as np
from common import config
# CuPy 필요
# ===============================================
config.GPU = True
# ===============================================
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
from cbow import CBOW
from skip_gram import SkipGram
from common.util import create_contexts_target, to_cpu, to_gpu
from dataset import ptb
# 하이퍼파라미터 설정
window_size = 5
hidden_size = 100
batch_size = 100
max_epoch = 10
# 데이터 읽기
corpus, word_to_id, id_to_word = ptb.load_data('train')
vocab_size = len(word_to_id)
contexts, target = create_contexts_target(corpus, window_size)
if config.GPU:
contexts, target = to_gpu(contexts), to_gpu(target)
# 모델 등 생성
model = SkipGram(vocab_size, hidden_size, window_size, corpus)
optimizer = Adam()
trainer = Trainer(model, optimizer)
# 학습 시작
trainer.fit(contexts, target, max_epoch, batch_size, eval_interval=2000)
trainer.plot()
◾ 4.3.3 CBOW 모델 평가
import sys
sys.path.append('..')
import pickle
from common.util import most_similar, analogy
pkl_file = './cbow_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)
# 유추(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)
◼️ 4.5 정리
- embedding 계층은 단어의 분산 표현을 담고 있으며 순전파 시 지정한 단어 ID의 벡터를 추출한다.
- word2vec은 어휘 수의 증가에 비례하여 계산량도 증가하므로 근사치로 계산하는 빠른 기법을 사용하면 좋다.
- 네거티브 샘플링은 부정적 예를 몇 개 샘플링하는 기법으로 이를 이용하면 다중 분류를 이진 분류처럼 취급할 수 있다.
- word2vec으로 얻은 단어의 분산 표현에는 단어의 의미가 녹아들어 있으며 비슷한 맥락에서 사용되는 단어는 단어 벡터 공간에서 가까이 위치한다.
- word2vec의 단어의 분산 표현을 이용하면 유추 문제를 벡터의 덧셈과 뺄셈으로 풀 수 있게 된다.
- word2vec은 전이 학습 측변에서 특히 중요하며 그 단어의 분산 표현은 다양한 자연어 처리 작업에 이용할 수 있다.