NIRVANA

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

AI

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

녜잉 2024. 3. 18. 01:01

word2vec 개선①

어휘가 100만개, 은닉층의 뉴런이 100개인 CBOW 모델의 경우

  • 입력층과 출력층에는 각100만 개의 뉴런이 존재함 → 수 많은 뉴런 때문에 중간 계산에 많은 시간이 소요됨
  • 계산 병목이 발생하는 두 부분
    • 입력층의 원핫 표현과 가중치 행렬 W_in의 곱 계산
    • 은닉층과 가중치 행렬 W_out의 곱 및 softmax 계층의 계산 

 

① 입력층의 원핫 표현과 관련된 문제

- 단어를 원핫 표현으로 다루기 때문에 어휘 수가 많아지면 원핫 표현의 벡터 크기도 커지게 됨 

- 원핫 벡터와 가중치 행렬 W_in을 곱해야 하는데, 이것 만으로도 계산 자원을 상당히 사용하게 됨 → 해당 문제는 4.1절에서 Embedding 계층을 도입하는 것으로 해결 

 

② 은닉층 이후의 계산 문제

- 은닉층과 가중치 행렬 W_out의 곱만 해도 계산량이 상당하고,softmax 계층에서도 다루는 어휘가 많아짐에 따라 계산량이 증가함 → 네거티브 샘플링이라는 새로운 손실함수를 도입하여 해결 

 

Embedding 계층 

  • 맥락의 원핫표현과 MatMul 계층의 가중치를 곱하는 것이 결과적으로 수행하는 일은 단지 행렬의 특정 행을 추출하는 것 → 굳이 할 필요가 없음 
  • 가중치 매개변수로부터 단어 ID에 해당하는 형을 추출하는 계층을 생성 → Embedding 계층 

 

Embedding 계층 구현 

Embedding 계층의 forward() 메서드 구현하기 

- 가중치의 특정 행을 추출 

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 계층의 backward() 메서드 구현하기 

- 출력층에서 전해진 기울기를 다음 층으로 그대로 흘려주되, 앞 층으로붜 전해진 기울기를 가중치 기울기 dW의 특정행(idx 번째 행)에 설정 

def backward(self, dout):
  dW = self.grads
  dW[...] = 0
  
  for i, word_id in enumerate(self.idx):
  	dW[word_id] += dout[i]
    
    
   return None

 

word2vec 개선 ②

  • 네거티브 샘플링을 이용하면 어휘가 아무리 많아져도 계산량을 낮은 수준에서 일정하게 억제 가능 

 

은닉층 이후 계산의 문제점 

① 은닉층의 뉴런과 가중치 행렬의 곱

  • 큰 행렬의 곱을 계산하려면 시간이 오래 걸리고, 역전파 역시 같은 계산을 수행하므로 행렬 곱을 가볍게 만들어야 함 

 

② Softmax 계층의 계산 

  • 어휘가 많아지면 softmax의 계산량이 증가함 

 

다중 분류에서 이진 분류로 

  • 네거티브 샘플링의 핵심 아이디어는 이진 분류에 기반 → 더 정확히는 '다중 분류를 이진 분류로 근사하는 것'이 네거티프 샘플링을 이해하는 중요 포인트가 됨 
    • 즉, 맥락이 you와 goodbye일 때, 타깃 단어는 say입니까? 라는 질문에 답하는 신경망을 만들어야 함 
      • Say에 해당하는 열을 추출하고, 추출된 벡터와 은닉층 뉴런과의 내적을 계산 

 

시그모이드 함수와 교차 엔트로피 오차 

  • 이진분류 문제를 신경망으로 풀려면 점수에 시그모이드 함수를 적용, 확률로 변환하고 손시을 구할 때는 손실 함수로 교차 엔트로피 오차를 사용 
    • 정답 레이블이 1이라면, y가 1(100%)에 가까워질 수록 오차가 줄어들게 됨
    • 반대로 y가 1에서 멀어지면 오차가 커짐 → 오차가 크면 크게 학습하고, 오차가 작으면 작게 학습

 

다중 분류에서 이진분류로(구현) 

  • 은닉층 뉴런 h와 출력 측의 가중치 W_out에서 단어 "say"에 해당하는 단어 벡터와의 내적을 계산하고, 출력을 sigmoid with loss 계층에 입력하여 최종 손실을 얻음 
  • 이를 위해 Embedding dot 계층을 도입 
    • Embedding 계층과 dot연산(내적)의 처리를 합친 계층을 의미 

EmbeddingDot 계층의 순전파 구현 

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.cahce = (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 계층의 출력을 1에 가깝게 만들고, 부정적 예("say"외의 단어)에 대해서는 Sigmoid 계층의 출력을 0에 가깝게 만들어야 함 
  • 위의 목적을 달성하기 위해 적은 수의 부정적 예를 샘플링하여 사용 → '네거티브 샘플링' 기법 

네거티브 샘플링 기법

  • 긍정적 예를 타깃으로 한 경우의 손실을 구하는 동시에 부정적 예를 몇 개 샘플링하여 부정적 예에 대해서도 마찬가지로 손실을 구함
  • 각각의 데이터의 손실을 더한 값을 최종 손실로 결정 

 

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

  • 말뭉치의 통계 데이터를 기초로 샘플링을 진행하는 방식으로 진행
    • 말뭉치에서 자주 등장하는 단어를 많이 추출하고, 드물게 등장하는 단어를 적게 추출하는 것
      • 말뭉치에서 각 단어의 출현 횟수를 구해 '확률 분포'로 나타내고, 확률 분포 대로 단어를 샘플링 
    • 말뭉치에 자주 등장하는 단어는 선택될 가능성이 높고, 희소한 단어는 선택될 확률이 적음 
  • numpy의 np.random.choice() 메서드를 사용하여 무작위 샘플링 수행이 가능 
  • 네거티브 샘플링에서는 기본 확률분포에 0.75를 제곱하게 되어 있음
    • 확률이 낮은 단어의 확률을 살짝 올림으로써 출현 확률이 낮은 단어를 버리지 않을 수 있음 
    • 단, 0.75라는 수치에 이론적 의미가 있는 것은 아니므로 다른 값으로 설정해도 됨 
corpus = np.array([0, 1, 2, 3, 4,1, 2, 3])
power = 0.75
sample_size = 2

sampler = UnigramSampler(corpus, power, sample_size)
target = np.array([1, 3, 0])
negative_sample = sampler.get_negative_sample(target)
print(negative_sample)

 

 

네거티브 샘플링 구현 

 

1. 초기화메서드 

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

 

2. 순전파 구현

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(socre, 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

 

3. 역전파

def backward(self, dout=1):
  dh = 0

  for l0, l1 in zip(self.loss_layers, self.embed_dot_layers):
    dscore = 10.backward(dout)
    dh += l1.backward(dscore)


  return dh

 

 

개선판 word2vec 학습 

  • 앞 장의 단순한 SimpleCBOW 클래스에 Embedding 계층과 Negative Sampling Loss 계층을 적용 

CBOW 모델 학습 코드 

from cbow import CBOW
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')
vovab_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, winodw_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 모델 평가 

import sys 
sys.path.append('..')
from common.util import most_similar
import pickle

pkl_file = 'cbow_params.pkl'

with open(pkl_file, 'rb') as f:
  params = pickle.load(f)

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

 

  • word2vec으로 얻은 단어의 분산 표현은 비슷한 단어를 가까이 모을 뿐 아니라, 더 복잡한 패턴을 파악하는 것으로 알려져 있음
    • king-man + woman = queen" 으로 알려진 유추 문제가 대표적
  • word2vec의 단어 분산 표현을 사용하면 유추 문제를 벡터의 덧셈과 뺄셈으로 풀수 있음 
    • "vec(king) + vec('woman') - vec('man') = vec(?)이라는 벡터에 가장 가까운 단어 벡터를 구하는 일이 됨 

 

word2vec 남은 주제

word2vec을 사용한 애플리케이션의 예 

  • word2vec으로 얻은 단어의 분산 표현은 비슷한 단어를 찾는 용도로 이용할 수 있음
  • 자연어 문제를 풀 때, 대부분 큰 말뭉치로 학습을 끝낸 후 그 분산 표현을 각자의 작업에 이용함 
    • 텍스트 분류, 문서 클러스터링, 품사 태그달기, 감정 분석 등 자연어 처리 작업이라면 가장 먼저 단어를 벡터로 변환하는 작업을 해야 하는데, 이때 학습을 미리 끝낸 단어의 분산 표현 이용 가능 
  • 단어를 고정 길이 벡터로 변환해주는 장점도 존재
    • 문장을 고정 길이 벡터로 변환하는 방법 역시 활발하게연구 되어지는 중 
    • 단어나 문장을 고정 길이 벡터로 변환한다면, 일반적인 머신러닝 기법(신경망 or SVM)등을 적용할 수 있음 

ex) 메일을 자동 분류하는 시스템 만들기 

1. 메일 수집

2. 수집된 메일 ㄹ이블링(긍정, 부정, 중립)

3. 학습된 word2vec 모델을 사용하여 메일을 벡터로 변환 

4. 감정 분석을 수행하는 분류 시스템에 벡터화 된 메일과 감정 레이블을 입력하여 학습 수행 

 

단어 벡터 평가 방법 

  • 단어의 분산 표현의 우수성은 실제 애플리케이션과 분리하여 평가하는 것이 일반적
  • 단어의 유사성, 유추 문제를 활용한 평가가 대중적인 평가 척도 
    • 단어의 유사성 평가: 사람이 작성한 단어 유사도를 검증 세트를 사용하여 평가하는 것
    • 유추 문제를 활용한 평가: "King: queen = man: ?"과 같은 유추 문제를 추출하고, 정답률로 단어의 분산 표현의 우수성을 측정 
      • 유추 문제를 사용하면 '단어의 의미나 문법적인 문제를 제대로 이해하고 있는지를' 어느정도 측정 가능
  • 단어의 분산 표현의 우수함이 애플리케이션에 얼마나 기여하는지는 애플리케이션의 종류, 말뭉치의 내용 등 다루는 문제 상황에 따라 달라지게 됨