밑바닥부터 시작하는 딥러닝 - word2vec 속도 개선(3)

네거티브 샘플링

긍정적인 예인 'say'만을 대상으로 이진 분류를 해왔다. 만약 여기서 '좋은 가중치'가 준비되어 있다면 Sigmoid 계층의 출력은 1에 가까울 것이다. 그러나 부정적 예에 대해서는 어떠한 정보도 없다. 즉 해야 할 것은 긍정적인 예에 대해서는 Sigmoid 계층의 출력을 1에 가깝게 만들고, 부정적인 예('say' 이외의 단어)에 대해서는 Sigmoid 계층의 출력을 0에 가깝게 만드는 것이다.

위의 그림처럼 맥락이 'you'와 'goodbye'일 때, 타깃이 'hello'일 확률은 낮은 값이어야 바람직하다. 위의 그림에서 타깃이 'hello'일 확률은 0.021이다. 이런 결과를 만들어주는 가중치가 필요하다. 그러나 모든 부정적 예를 대상으로 하여 이진 분류를 학습시키는 것은 불가능하다. 모든 부정적 예를 대상으로 하는 방법은 어휘 수가 늘어나면 감당할 수 없기 때문이다.

그래서 근사적인 해법으로, 부정적 예를 몇 개 선택한다. 즉, 적은 수의 부정적 예를 샘플링해 사용한다. 이것이 바로 '네거티브 샘플링' 기법이 의미하는 것이다.

네거티브 샘플링 기법은 긍정적 예를 타깃으로 한 경우의 손실을 구한다. 그와 동시에 부정적 예를 몇 개 샘플링하여, 그 부정적 예에 대해서도 마찬가지로 손실을 구한다. 그리고 각각의 데이터의 손실을 더한 값을 최종 손실로 한다.

위의 그림은 CBOW 모델의 은닉층 이후만 주목하면 네거티브 샘플링의 계산 그래프이다. 여기서 주의할 부분은 긍정적 예와 부정적 예를 다루는 방식이다. 긍정적 예에 대해서는 지금까지처럼 Sigmoid with Loss 계층에 정답 레이블로 '1'을 입력한다. 한편, 부정적 예에 대해서는 정답 레이블로 '0'을 입력한다. 그런 다음 각 데이터의 손실을 모두 더해 최종 손실을 출력한다.


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

하나 더 설명한 부분은 부정적 예를 어떻게 샘플링하느냐 하는 것인데, 말뭉치의 통계 데이터를 기초로 샘플링하는 방법이다. 말뭉치에서 자주 등장하는 단어를 많이 추출하고 드물게 등장하는 단어를 적게 추출하는 것이다. 말뭉치에서의 단어 빈도를 기준으로 샘플링하려면, 먼저 말뭉치에서 각 단어의 출현 횟수를 구해 '확률분포'로 나타낸 뒤 그 확률분포대로 단어를 샘플링하면 된다.

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

그러면 확률분포에 따라 샘플링하는 예를 코드로 구현해보자.

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

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

# 5개만 무작위로 샘플링(중복 있음)
np.random.choice(words, size = 5)

# 5개만 무작위로 샘플링(중복 없음)
np.random.choice(words, size = 5, replace = False)

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

np.random.choice() 메서드는 무작위 샘플링 용도로 이용할 수 있다. 이때 인수로 size를 지정하면 샘플링을 size만큼 수행한다. 또한 인수에 replace=False를 지정하면 샘플링 시 중복을 없애준다. 인수 p에 확률분포를 담은 리스트를 지정하면 그 확률분포대로 샘플링한다. 이제 이 함수를 사용해 부정적 예를 샘플링하기만 하면 된다.

 

하지만 word2vec의 네거티브 샘플링에서는 앞의 확률분포에서 한 가지를 수정하라고 권고하고 있다. 바로 아래 식처럼 기본 확률분포에 0.75를 제곱하는 것이다.

여기서 P(wi)는 i번째 단어의 확률을 뜻한다.

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


네거티브 샘플링 구현

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

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

UnigramSampler 클래스를 생성하여 인스턴스 변수인 sampler로 저장한다. 또한 부정적 예의 샘플링 횟수는 인스턴스 변수인 sample_size에 저장한다.

인스턴스 변수인 loss_layers와 embed_dot_layers에는 원하는 계층을 리스트로 보관한다. 이때 이 두 리스트에는 sample_size + 1개의 계층을 생성하는데, 부정적 예를 다루는 계층이 sample_size개만큼이고, 여기에 더해 긍정적 예를 다루는 계층이 하나 더 필요하기 때문이다.

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

forward(h, target) 메서드가 받는 인수는 은닉층 뉴런 h와 긍정적 예의 타깃을 뜻하는 target이다. 이 메서드에서는 우선 self.sampler를 이용해 부정적 예를 샘플링하여 negative_sample에 저장한다. 그런 다음 긍정적 예와 부정적 예 각각의 데이터에 대해서 순전파를 수행해 그 손실들을 더한다.

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()를 호출하기만 하면 된다. 은닉층의 뉴런은 순전파 시에 여러 개로 복사되었다.


word2vec 학습

CBOW 모델 구현

CBOW 모델을 구현할 것이다. 앞에서 배운 SimpleCBOW 클래스를 개선할 것이다.

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)
            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

초기화 메서드는 4개의 인수를 받는다. window_size가 2이면 타깃 단어의 좌우 2개씩, 총 4개 단어가 맥락이 된다. 가중치 초기화가 끝나면, 이어서 계층을 생성한다. 그 후 신경망에서 사용하는 모든 매개변수와 기울기를 인스턴스 변수인 params와 grads에 모은다.

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

순전파와 역전파는 적절한 순서로 호출한다. 

 


CBOW 모델 학습

# 하이퍼파라미터 설정
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 = CBOW(vocab_size, hidden_size, window_size, corpus)
optimizer = Adam()
trainer = Trainer(model, optimizer)

# 학습 시작
trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()

word_vecs = model.word_vecs
if config.GPU:
    word_vecs = to_cpu(word_vecs)
params = {}
params['word_vecs'] = word_vecs.astype(np.float16)
params['word_to_id'] = word_to_id
params['id_to_word'] = id_to_word
pkl_file = 'cbow_params.pkl'
with open(pkl_file, 'wb') as f:
    pickle.dump(params, f, -1)

학습이 끝나면 가중치를 꺼내 나중에 이용할 수 있도록 파일에 보관한다. 파일로 저장할 때는 '피클' 기능을 이용하면 된다.

 


CBOW 모델 평가

from common.util import most_similar
import pickle

pkl_file = 'ch04/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']
    
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
    most_similar(query, word_to_id, id_to_word, word_vecs, top = 5)

결과를 보면 'you'를 입력했을 때 비슷한 단어로 인칭대명사 등이 나왔다. 'year'에 대해서는 'month', 'week' 같은 기간을 뜻하는 같은 성격의 단어들이 나왔다.

 

word2vec으로 얻은 단어의 분산 표현은 비슷한 단어를 가까이 모을 뿐 아니라, 더 복잡한 패턴을 파악하는 것으로 알려져 있다. 단어의 분산 표현을 사용하면 유추 문제를 벡터의 덧셈과 뺄셈으로도 풀 수 있다.

 

TAGS.

Comments