본문 바로가기

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

4장 : word2vec 속도 개선

앞 장의 단순한 word2vec에 두 가지 개선 추가

1) Embedding 계층 도입

2) 네거티브 샘플링이라는 새로운 손실 함수 도입

4.1 word2vec 개선 ①

거대한 말뭉치를 다루게 되면 두 계산의 병목 발생 -> 계산에 많은 시간 소요

1) 입력층의 원핫 표현과 가중치 행렬 W_in의 곱 계산 -> Embedding 계층으로 해결

2) 은닉층과 가중치 행렬 W_out의 곱 및 softmax 계층의 계산 -> 네거티브 샘플링 손실 함수로 해결

4.1.1 Embedding 계층

-> 결과적으로 행렬의 특정 행을 추출하는 것

=> 단어ID에 해당하는 행을 추출하는 계층 (Embedding 계층) 

  • Embedding 계층에 단어 임베딩(분산 표현) 저장
  • 단어의 밀집벡터 표현  : 단어 임베딩, 분산 표현 

 

4.1.2 Embedding 계층 구현

  • 순전파는 가중치W의 특정행 뉴런만을 다음층으로 보냄
  • 역전파에서는 출력측 행으로부터 전해진 기울기를 다음 층(입력측 층)으로 그대로 흘려주면 됨
  • 앞 층으로부터 전해진 기울기를 가중치 기울기 dW의 특정 행에 설정
  • idx의 원소가 중복될 때를 대비하여 dh의 각 행의 값을 dW의 해당 행에 더해줌
class Embedding:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.idx = None

    def forward(self, idx):  #idx : 추출하는 행의 인덱스(단어ID) 배열
        W, = self.params
        self.idx = idx
        out = W[idx]
        return out

    def backward(self, dout):
        dW, = self.grads
        dW[...]=0  #dW의 원소 0으로 덮기 

        np.add.at(dW, self.idx, dout)  #dout를 dW의 idx 행에 더함
        return None

-> 입력 측 Matmul 계층을 Embedding 계층으로 전환 가능

=> 메모리 사용량 줄이고 계산 생략 가능


4.2 word2vec 개선 ②

4.2.1 은닉층 이후 계산의 문제점

1) 은닉층의 뉴런과 가중치 행렬(W_out)의 곱

2) Softmax 계층의 계산

 

4.2.2 다중 분류에서 이진 분류로

이진 분류 : 예/ 아니오로 답하는 문제

지금까지는 다중 분류 (여러 단어 중에서 옳은 단어 하나를 선택) -> 이진 분류 (한 단어가 옳냐/아니냐)

h : 맥락 단어의 벡터

W_out에서 say에 해당하는 단어 벡터 추출 -> 그 벡터와 h(은닉층 뉴런)과의 내적 => 최종 점수

 

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

  • 이진 분류 문제
    • 점수에 시그모이드 함수 적용해 확률로 변환
    • 손실 함수로 교차 엔트로피 오차 
  • 다중 분류 문제
    • 소프트맥스 함수
    • 교차 엔트로피 오차 

  • 역전파의 y-t값
    • y : 신경망이 출력한 확률
    • t : 정답 레이블
    • y-t : 두 값의 차이 (정답 레이블이 1이면 y가 1에 가까워질수록 오차 줄어듦)

-> 오차 크기대로 앞 계층으로 흘러감 => 오차가 크면 크게 학습, 작으면 작게 학습 

 

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

은닉층 뉴런 h -> Embedding Dot 계층 -> Sigmoid 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):  #미니배치 처리-> idx 배열로 받음
        target_W = self.embed.forward(idx)  #target의 벡터
        out = np.sum(target_W*h, axis=1)  #내적후 행을 축으로 모두 더함-> 각 idx에 대한 out 

        self.cache = (h, target_W)
        return out

    def backward(self, dout):
        h, target_W = self.cashe
        dout = dout.rechape(dout.shape[0],1)

        dtarget_W = dout*h
        self.embed.backward(dtarget_W)
        dh = dout*target_W
        return dh

 

4.2.5 네거티브 샘플링

  • 긍정적 예와 부정적 예를 모두 대상으로 문제를 생각해야 함
  • 부정적 예를 몇 개 선택 -> 네거티브 샘플링
  • 긍정적인 예를 타깃으로 한 경우의 손실 +부정적 예를 타깃으로 한 경우의 손실 => 최종 손실
  • 긍정적 예의 정답 레이블은 1
  • 부정적 예의 정답 레이블은 0

 

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

  • 말뭉치의 통계 데이터를 기초로 샘츨링
  • 말뭉치에서 자주 등장하는 단어를 많이 추출, 드물게 등장하는 단어는 적게 추출
  • 각 단어의 출현 횟수를 구해 확률분포로 나타내고 분포대로 단어 샘플링

확률분포에 따라 샘플링 -> np.random.choice() 매서드 사용

  • size : 샘플링을 size만큼
  • replace = False : 샘플링 시 중복 없애줌
  • p에 확률분포를 담은 리스트 지정 : 확률분포대로 샘플링
##무작위 샘플링
words = ['you', 'say', 'goodbye', 'I', 'hello', '.']
p = [0.5, 0.1, 0.05, 0.2, 0.05, 0.1]
np.random.choice(words, p=p)

 

  • 기본 확률분포에 0.75제곱
    -> 출현 확률이 낮은 단어를 버리지 않기 위해서
    => 원래 확률이 낮은 단어의 확률을 살짝 높일 수 있음

##0.75제곱
p = [0.7, 0.29, 0.01]
new_p = np.power(p, 0.75)
new_p /=np.sum(new_p)
print(new_p)

-> [0.64196878 0.33150408 0.02652714]

=> 0.01이 0.0265로 커짐 

 

  • 네거티브 샘플링 담당 클래스 - UnigramSampler 
    • 초기화 시 3개의 인수
      • corpus : 단어 ID 목록
      • power : 확률분포에 제곱할 값 (기본값 0.75)
      • sampplel_size : 샘플링을 수행하는 횟수 
    • get_negative_sample(target) 메서드 제공
      • target 인수로 지정한 단어를 긍정적 예로 해석, 그 외의 단어 ID 샘플링
    • 샘플링 무작위로 이뤄짐
sampler = UnigramSampler(corpus, power, sample_size)
target = np.array([1,3,0])
negative_sampler = sampler.get_negative_sample(target)
print(negative_sample)

-> [[0 3]  #1에 대한 부정적 예

      [1 2]  #3에 대한 부정적 예

      [2 3]  #0에 대한 부정적 예

 

4.2.7 네거티브 샘플링 구현

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)]
        #부정적 예를 다르닌 계층이 sample_size만큼 + 긍정적 예 다루는 계층 +1
        #0번째 계층이 긍정적 예 계층
        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):  #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)  #긍정적 예 정답레이블:1
        loss = self.loss_layers[0].forward(score, correct_label)

        #부정적 예 순전파
        negative_label = np.zeros(batch_size, dtype=np.int32)  #부정적 예 정답레이블:0
        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 모델 구현 

##CBOW 모델 구현
from common.layers import Embedding

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')
        #간단CBOW와 달리 Embedding계층을 사용하기 때문에 입력 측, 출력 측 가중치가 같은 형상 (V,H)

        #계층 생성
        self.in_layers = []
        for i in range(2*window_size):
            layer = Embedding(W_in)  #Embedding 계층 사용
            self.in_layers.append(layer)  #문맥 수*2 만큼 임베딩 계층 추가
        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):  
    #simpleCBOW와 달리 단어 ID로 contexts와 target을 받음 (simple-원핫벡터)
        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

 

4.3.2 CBOW 모델 학습 코드

##CBOW 모델 학습 코드
from common import config
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
from common.util import create_contexts_target, to_gpu, to_cpu
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 = 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)
  • 사용하는 말뭉치에 따라 다르지만, 윈도우 크기는 2~10개, 은닉층의 뉴런 수는 50~500개 정도면 좋은 결과 얻음
  • 학습한 가중치는 파일에 저장 (피클 기능 이용)
  • 학습된 가중치 데이터는 학습 환경에 따라 다름
    (학습 과정에서 무작위 값이 쓰이기 때문-가중치 초깃값, 미니배치 선택, 네거티브 샘플링)

학습 결과

loss 그래프

4.3.3 CBOW 모델 평가

##CBOW 모델 평가
import pickle

pkl_file = '/content/drive/MyDrive/Colab Notebooks/밑바닥딥러닝2/cbow_params_book.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)

-> 

좌 : 책 학습 결과 / 우 : 내가 도출한 결과 

  • word2vec으로 얻은 단어의 분산 표현 - 복잡한 패턴 파악 가능
    -> 대표적인 예 : 유추 문제
    (king - man +woman = queen)
##유추문제 풀기
from common.util import most_similar, analogy

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

-> 

좌 : 책 학습 결과 / 우 : 내가 도출한 결과

=> 단어의 의미뿐 아니라 문법적인 패턴도 파악

  • bad : ?는 worse라고 대답하지 못했지만 비교급 단어 제시해냄
  • 책) 잘 풀리는 문제만 선별한 것 -> 사실 원하는 결과를 얻지 못하는 문제도 많음
  • 큰 말뭉치로 학습하면 더 견고한 분산 표현, 정답률 향상될 것

4.4 word2vec 남은 주제

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

  • 분산표현이 중요한 이유 : 전이 학습
  • 전이 학습 : 한 분야에서 배운 지식을 다른 분야에도 적용하는 기법
  • 말뭉치로 학습 -> 분산표현 -> 각자의 작업에 이용 
  • 단어의 분산 표현은 단어를 고정 길이 벡터로 변환해준다는 장점
    • 문장도 가능 (bag-of-words :문장의 각 단어를 분산표현으로 변환하고 그 합을 구함, 단어의 순서 고려 x)
    •  자연어를 벡터로 변환할 수 있어서 머신러닝 기법에 적용 가능

  • 자연어를 고정 길이 벡터로 변환할 수 있다면 머신러닝 시스템의 입력으로 사용 가능
  • 분산표현 학습과 머신러닝 시스템의 학습은 서로 다른 데이터셋 사용
  • 예 ) 메일 -> 단어 벡터화 -> 머신러닝 시스템 (감정분석 시스템) -> 감정 분류 
  • 전이 학습의 혜택으로 정확도 높일 수 있음

 

4.4.2 단어 벡터 평가 방법

  • 실제 애플리케이션과는 분리해 평가하는 것이 일반적

1. 단어의 유사성 평가

  • 사람이 작성한 단어 유사도를 검증 세트를 사용해 평가하는 것이 일반적
  • 사람이 부여한 점수와 word2vec에 의한 코사인 유사도 점수를 비교

2. 유추 문제를 활용한 평가

  • 유추 문제를 출제하고, 정답률로 우수성 측정
  • 논문 평가 결과

  • 의미 열 : 단어의 의미를 유추하는 유추 문제의 정답률 (king :queen = actor : actress)
  • 구문 열 : 단어의 형태 유추 문제의 정답률 (bad:worst = good:best)
  • 평가 결과
    • 모델에 따라 정확도 다름(말뭉치에 따라 적합한 모델 선택)
    • 일반적으로 말뭉치가 클수록 결과가 좋음
    • 단어 벡터 차원 수는 적당한 크기가 좋음 (너무 커도 정확도 나빠짐)
  • 유추 문제에 의한 평가가 높다고 애플리케이션에서도 반드시 좋은 결과가 나오리라는 보장은 없음

4.5 정리

  • Embedding 계층은 단어의 분산 표현을 담고 있으며, 순전파 시 지정된 단어 ID의 벡터를 추출한다.
  • word2vec은 어휘 수의 증가에 비례하여 계산량도 증가하므로, 근사치로 계산하는 빠른 기법을 사용하면 좋다.
  • 네거티브 샘플링은 부정적 예를 몇 개 샘플링하는 기법으로, 이를 이용하면 다중 분류를 이진 분류처럼 취급할 수 있다.
  • word2vec으로 얻은 단어의 분산 표현에는 단어의 의미가 녹아들어 있으며, 비슷한 맥락에서 사용되는 단어는 단어 벡터 공간에서 가까이 위치한다.
  • word2vec의 단어의 분산 표현을 이용하면 유추 문제를 벡터의 덧셈과 뺄셈으로 풀 수 있게 된다.
  • word2vec은 전이 학습 측면에서 특히 중요하며, 그 단어의 분산 표현은 다양한 자연어 처리 작업에 이용할 수 있다. 

책 참고 : 밑바닥부터 시작하는 딥러닝 (한빛미디어)

 

 

 

 

 

 

 

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

6장 : 게이트가 추가된 RNN  (0) 2023.09.26
5장 : 순환 신경망(RNN)  (0) 2023.09.26
3장 : word2vec  (0) 2023.09.21
2장 : 자연어와 단어의 분산 표현  (2) 2023.09.21
1장 : 신경망 복습  (0) 2023.09.20