Taeu

[NLP] 밑바닥부터 시작하는 딥러닝2 - Ch3 : word2vec

Chapter 3. Word2Vec


자연어처리의 역사를 word2vec, 즉, 단어 임베딩이 도입되기 전과 후로 나눌 수 있다고 할만큼 중요한 word2vec에 대해서 공부하고자 한다. 아래는 밑바닥부터 시작하는 딥러닝2를 공부하고 정리한 내용이다.

목차

  • 3.1 추론 기반 기법과 신경망
    • 3.1.1 통계 기반 기법의 문제점
    • 3.1.2 추론 기반 기법 개요
    • 3.1.3 신경망에서의 단어 처리
  • 3.2 단순한 word2vec
    • 3.2.1 CBOW 모델의 추론 처리
    • 3.2.2 CBOW 모델의 학습
    • 3.2.3 word2vec의 가중치와 분산 표현
  • 3.3 학습 데이터 준비
    • 3.3.1 맥락과 타깃
    • 3.3.2 원핫 표현으로 변환
  • 3.4 CBOW 모델 구현
    • 3.4.1 학습 코드 구현
  • 3.5 word2vec 보충
    • 3.5.1 CBOW 모델과 확률
    • 3.5.2 skip-gram 모델
    • 3.5.3 통계 기반 vs 추론 기반
  • 3.6 정리
  • 3.7 기타

3.1 추론 기반 기법과 신경망


  • 3.1.1 통계 기반 기법의 문제점
    • SVD를 n x n행렬에 적용하는 시간복잡도는 O(n^3), 영어의 어휘수는 100만개 이상인데 이를 처리하기에는 비현실적인 방법
  • 3.1.2 추론 기반 기법 개요
    • you [ ? ] goodbye and I say hello.
    • [ ? ] 주위의 맥락(you && goodbye)을 사용해 [ ? ]를 추론하는 작업
    • 신경망을 활용, 학습데이터의 일부를 사용해 순차적 학습(minibatch)
    • output은 [ ? ] 에 들어갈 어휘의 확률 분포 값
  • 3.1.3 신경망에서의 단어 처리
    • one-hot, 원핫 표현(또는 원핫 벡터) : 벡터의 원소 중 하나만 1이고, 나머지는 모두 0
    • you -> 단어 ID : 0 -> 원핫 표현 : [ 1, 0, 0, 0, 0, 0, 0]
    • goodbye -> 단어 ID : 2 -> 원핫 표현 : [0, 0, 1, 0, 0, 0, 0]
# 3.1.3 신경망에서의 단어 처리

import numpy as np

c = np.array([[1,0,0,0,0,0,0]])
W = np.random.randn(7,3)
h = np.matmul(c,W)
print(h) # [[-0.06007483  0.25882855 -0.91835303]]
[[-0.06007483  0.25882855 -0.91835303]]
import sys
sys.path.append('D:/ANACONDA/envs/tf-gpu/code/NLP')
from common.layers import MatMul
c = np.array([[1,0,0,0,0,0,0]])
W = np.random.randn(7,3)
layer = MatMul(W)
h = layer.forward(c)
print(h) # [[-0.69121996  0.00491198 -1.23973913]]
[[-0.69121996  0.00491198 -1.23973913]]

3.2 단순한 word2vec


  • 3.2.1 CBOW 모델의 추론 처리

    • CBOW(continuous bag-of-words) : 맥락으로부터 타깃을 추측하는 신경망

      그림 [3-1]

    • 그림 [3-2] : 가중치의 각 행이 해당 단어의 분산 표현이라고 볼 수 있다. 아래 그림과 결국 해당 단어와 곱해지는 원소들은 W_in에 동일한 행의 원소들이다.

      p3_2

      p3_2_1

    • window size, 맥락의 갯수의 평균을 해준다. (예제의 경우 window size = 1, 맥락의 단어는 2개이므로 은닉층 뉴런은 1/2 * (h1 + h2)

  • 3.2.2 CBOW 모델의 학습

    • 그림 [3-3]

      p3_3

  • 3.2.3 word2vec의 가중치와 분산 표현

# 3.2.1 모델
# 샘플 맥락 데이터
c0 = np.array([[1,0,0,0,0,0,0]])
c1 = np.array([[0,0,1,0,0,0,0]])
# 가중치 초기화
W_in = np.random.randn(7,3)
W_out = np.random.randn(3,7)
#계층 생성
in_layer0 = MatMul(W_in)
in_layer1 = MatMul(W_in)
out_layer = MatMul(W_out)

# forward
h0 = in_layer0.forward(c0)
h1 = in_layer1.forward(c1)
h = 0.5 * (h0 + h1)
s = out_layer.forward(h)

print(s) # [[ 0.59477841  0.1855219   1.22491115  0.2156382  -1.15706682  0.34522547 -0.77261128]]
[[ 0.59477841  0.1855219   1.22491115  0.2156382  -1.15706682  0.34522547
  -0.77261128]]

3.3 학습 데이터 준비


  • 3.3.1 맥락과 타깃
    • [1] 우선 말뭉치 텍스트를 단어 ID로 변환 : preprocess( )
    • 말뭉치에서 맥락과 타깃 만들기 : create_contexts_target( )
      • 양끝 단어를 제외하고 모두 타깃으로 만들고 : target 리스트
      • 타깃을 기준으로 window size 만큼 양 옆을 검사해서 맥락 단어에 추가 : contexts 리스트
      • 각각을 np.array 반환
  • 3.3.2 원핫 표현으로 변환
    • 맥락과 타깃을 원핫 벡터로 변환
    • dimension 1 증가, 원소(차원)는 단어 수만큼 증가
# 3.3.1 맥락과 타깃

from common.util import preprocess

text = "You say goodbye and I say hello." # 말뭉치
corpus, word_to_id, id_to_word = preprocess(text)
print(corpus) # [0 1 2 3 4 1 5 6]
print(id_to_word) # {0: 'you', 1: 'say', 2: 'goodbye', 3: 'and', 4: 'i', 5: 'hello', 6: '.'}
def create_contexts_target(corpus,window_size=1):
    target = corpus[window_size:-window_size]
    contexts = []
    
    for idx in range(window_size,len(corpus)-window_size):
        cs =[]
        for t in range(-window_size, window_size +1):
            if t == 0:
                continue
            cs.append(corpus[idx+t])
        contexts.append(cs)
    return np.array(contexts), np.array(target)
contexts , target = create_contexts_target(corpus)
print(contexts)
'''
[[0 2]
 [1 3]
 [2 4]
 [3 1]
 [4 5]
 [1 6]]
'''
print(target) # [1 2 3 4 1 5]
# 3.3.2 원핫 표현으로 변환

from common.util import convert_one_hot

vocab_size = len(word_to_id)
target = convert_one_hot(target,vocab_size)
contexts = convert_one_hot(contexts,vocab_size)
target.shape #(6, 7)
contexts.shape #(6, 2, 7)

3.4 CBOW 모델 구현


  • 3.4.1 학습 코드 구현
    • (warning) Trainer 클래스 내부에서 매개변수를 갱신할 때 매개변수의 중복을 없애는 간단한 작업을 수행. remove_duplicate(params,grads)
    • 편의상 Trainer class의 if(self.current_epoch % 100 == 0 ) : 부분을 추가해 100번의 epoch마다 Loss를 찍게 수정했다.
from common.layers import SoftmaxWithLoss

class SimpleCBOW:
    def __init__(self,vocab_size,hidden_size):
        V, H = vocab_size, hidden_size
        
        # 가중치 초기화
        W_in = 0.01*np.random.randn(V,H).astype('f')
        W_out = 0.01*np.random.randn(H,V).astype('f')
        
        # 계층 생성
        # 굳이 2개 만들 필요까지는 없는듯 -> Trainer 내부 처리
        self.in_layer0 = MatMul(W_in) #
        self.in_layer1 = MatMul(W_in) #
        self.out_layer = MatMul(W_out)
        self.loss_layer = SoftmaxWithLoss()
        
        # 모든 가중치와 기울기를 리스트에 모은다.
        layers = [self.in_layer0, self.in_layer1, self.out_layer]
        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):
        h0 = self.in_layer0.forward(contexts[:,0]) # 
        h1 = self.in_layer1.forward(contexts[:,1]) #
        h = (h0 + h1) * 0.5 #
        score = self.out_layer.forward(h) #
        loss = self.loss_layer.forward(score,target)
        return loss
    
    def backward(self, dout=1):
        ds = self.loss_layer.backward(dout)
        da = self.out_layer.backward(ds)
        da *= 0.5 #
        self.in_layer1.backward(da) #
        self.in_layer0.backward(da) #
        return None

        
from common.trainer import Trainer
from common.optimizer import Adam
#from simple_cbow import SimpleCBOW
import time
import matplotlib.pyplot as plt
class Trainer:
    def __init__(self, model, optimizer):
        self.model = model
        self.optimizer = optimizer
        self.loss_list = []
        self.eval_interval = None
        self.current_epoch = 0

    def fit(self, x, t, max_epoch=10, batch_size=32, max_grad=None, eval_interval=20):
        data_size = len(x)
        max_iters = data_size // batch_size
        self.eval_interval = eval_interval
        model, optimizer = self.model, self.optimizer
        total_loss = 0
        loss_count = 0

        start_time = time.time()
        for epoch in range(max_epoch):
            # 뒤섞기
            idx = np.random.permutation(np.arange(data_size))
            x = x[idx]
            t = t[idx]

            for iters in range(max_iters):
                batch_x = x[iters*batch_size:(iters+1)*batch_size]
                batch_t = t[iters*batch_size:(iters+1)*batch_size]

                # 기울기 구해 매개변수 갱신
                loss = model.forward(batch_x, batch_t)
                model.backward()
                params, grads = remove_duplicate(model.params, model.grads)  # 공유된 가중치를 하나로 모음
                if max_grad is not None:
                    clip_grads(grads, max_grad)
                optimizer.update(params, grads)
                total_loss += loss
                loss_count += 1

                # 평가
                if (eval_interval is not None) and (iters % eval_interval) == 0:
                    avg_loss = total_loss / loss_count
                    elapsed_time = time.time() - start_time
                    if(self.current_epoch % 100 == 0 ) :
                        print('| 에폭 %d |  반복 %d / %d | 시간 %d[s] | 손실 %.2f'% (self.current_epoch + 1, iters + 1, max_iters, elapsed_time, avg_loss))
                    self.loss_list.append(float(avg_loss))
                    total_loss, loss_count = 0, 0

            self.current_epoch += 1

    def plot(self, ylim=None):
        x = np.arange(len(self.loss_list))
        if ylim is not None:
            plt.ylim(*ylim)
        plt.plot(x, self.loss_list, label='train')
        plt.xlabel('반복 (x' + str(self.eval_interval) + ')')
        plt.ylabel('손실')
        plt.show()
        
def remove_duplicate(params, grads):
    '''
    매개변수 배열 중 중복되는 가중치를 하나로 모아
    그 가중치에 대응하는 기울기를 더한다.
    '''
    params, grads = params[:], grads[:]  # copy list

    while True:
        find_flg = False
        L = len(params)

        for i in range(0, L - 1):
            for j in range(i + 1, L):
                # 가중치 공유 시
                if params[i] is params[j]:
                    grads[i] += grads[j]  # 경사를 더함
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)
                # 가중치를 전치행렬로 공유하는 경우(weight tying)
                elif params[i].ndim == 2 and params[j].ndim == 2 and \
                     params[i].T.shape == params[j].shape and np.all(params[i].T == params[j]):
                    grads[i] += grads[j].T
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)

                if find_flg: break
            if find_flg: break

        if not find_flg: break

    return params, grads
window_size = 1
hidden_size = 5
batch_size = 3
max_epoch = 1000

model = SimpleCBOW(vocab_size,hidden_size)
optimizer = Adam()
trainer = Trainer(model,optimizer)

trainer.fit(contexts,target,max_epoch,batch_size)
trainer.plot()
| 에폭 1 |  반복 1 / 2 | 시간 0[s] | 손실 1.95
| 에폭 101 |  반복 1 / 2 | 시간 0[s] | 손실 1.72
| 에폭 201 |  반복 1 / 2 | 시간 0[s] | 손실 1.35
| 에폭 301 |  반복 1 / 2 | 시간 0[s] | 손실 0.94
| 에폭 401 |  반복 1 / 2 | 시간 0[s] | 손실 1.04
| 에폭 501 |  반복 1 / 2 | 시간 0[s] | 손실 0.76
| 에폭 601 |  반복 1 / 2 | 시간 0[s] | 손실 0.79
| 에폭 701 |  반복 1 / 2 | 시간 0[s] | 손실 0.74
| 에폭 801 |  반복 1 / 2 | 시간 0[s] | 손실 0.68
| 에폭 901 |  반복 1 / 2 | 시간 0[s] | 손실 0.60

output_19_1

  • 아래와 같이 단어의 분산표현이 word_vecs 에 잘 저장된 것을 볼 수 있다.
word_vecs = model.word_vecs
for word_id, word in id_to_word.items():
    print(word, word_vecs[word_id])
you [-0.9978328  1.0286773 -1.0270418 -0.9585289 -1.7527387]
say [ 1.0626459 -1.0358709  1.0982287  1.1393781  1.3009013]
goodbye [-0.9673642   0.96609885 -0.95703334 -1.0729057   0.6519317 ]
and [ 1.2688187 -1.3759657  0.3838494  0.862708   1.4202764]
i [-0.9818484   0.98128015 -0.9665366  -1.0440549   0.64191777]
hello [-1.0094638   1.0062143  -1.0238568  -0.94902045 -1.7424139 ]
. [ 0.271719   -0.08897968  1.4705642   1.163066    0.6702039 ]
  • 위의 코드는 in_layer0in_layer1을 따로 분리해서 forward하고 backpropagation 한다. 같은 매개변수를 사용하는 것을 감안해 약간 비효율적이라고 생각해서 나는 in_layer1 를 제거하고 관련된 forward, backward 부분을 수정해주었다.
## 다르게 학습

from common.layers import SoftmaxWithLoss

class SimpleCBOW:
    def __init__(self,vocab_size,hidden_size):
        V, H = vocab_size, hidden_size
        
        # 가중치 초기화
        W_in = 0.01*np.random.randn(V,H).astype('f')
        W_out = 0.01*np.random.randn(H,V).astype('f')
        
        # 계층 생성
        # 굳이 2개 만들 필요까지는 없는듯 -> Trainer 내부 처리
        self.in_layer0 = MatMul(W_in) #
        self.out_layer = MatMul(W_out)
        self.loss_layer = SoftmaxWithLoss()
        
        # 모든 가중치와 기울기를 리스트에 모은다.
        layers = [self.in_layer0, self.out_layer]
        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 = self.in_layer0.forward(contexts[:,0]) * 0.5 # window size 만큼
        h += self.in_layer0.forward(contexts[:,1]) * 0.5
        score = self.out_layer.forward(h) 
        loss = self.loss_layer.forward(score,target)
        return loss
    
    def backward(self, dout=1):
        ds = self.loss_layer.backward(dout)
        da = self.out_layer.backward(ds)
        self.in_layer0.backward(da) #
        return None
window_size = 1
hidden_size = 5
batch_size = 3
max_epoch = 1000

model = SimpleCBOW(vocab_size,hidden_size)
optimizer = Adam()
trainer = Trainer(model,optimizer)

trainer.fit(contexts,target,max_epoch,batch_size)
trainer.plot()
| 에폭 1 |  반복 1 / 2 | 시간 0[s] | 손실 1.95
| 에폭 101 |  반복 1 / 2 | 시간 0[s] | 손실 1.79
| 에폭 201 |  반복 1 / 2 | 시간 0[s] | 손실 1.42
| 에폭 301 |  반복 1 / 2 | 시간 0[s] | 손실 1.05
| 에폭 401 |  반복 1 / 2 | 시간 0[s] | 손실 0.85
| 에폭 501 |  반복 1 / 2 | 시간 0[s] | 손실 0.68
| 에폭 601 |  반복 1 / 2 | 시간 0[s] | 손실 0.61
| 에폭 701 |  반복 1 / 2 | 시간 0[s] | 손실 0.44
| 에폭 801 |  반복 1 / 2 | 시간 0[s] | 손실 0.48
| 에폭 901 |  반복 1 / 2 | 시간 0[s] | 손실 0.53

output_25_1

  • 단어의 분산 표현 확인
word_vecs = model.word_vecs
for word_id, word in id_to_word.items():
    print(word, word_vecs[word_id])
you [ 0.01178248  0.01342613 -0.01825819 -0.00531672  0.01964338]
say [ 0.63442814 -1.1782721   1.0292728  -0.9097165   1.0255805 ]
goodbye [ 1.7009413  1.3123868 -1.3366075 -1.3643217  1.1284081]
and [ 0.46462712 -1.1723162   1.0998902  -1.0015242   0.9973545 ]
i [-1.9195325   0.7654874  -0.65731263  1.9749272   0.95143664]
hello [ 2.2331247  1.2272426 -1.2585099 -2.007749   1.0424646]
. [ 1.4546813  1.6860805  1.3951857  1.5522774 -1.4960616]

3.5 word2vec 보충


  • 3.5.1 CBOW 모델과 확률

    • 수식으로 쉽게 나타낼 수 있음

    • 수식 [3-5-1]

      m_3_5_1</left>

    • CBOW는 W_t-1 , W_t+1 (맥락)이 주어졌을 때, W_t의 확률을 추론하는 모델이다. 따라서 두번째 줄과 같이 P(~) 확률 식으로 나타낼 수 있다.

    • Loss 부분을 가만히 생각해보면, 1장에서 구했던 Cross entropy Error와 다르지 않다는 것을 직관적으로 이해할 수 있다. 조금 더 설명을 하자면, t는 정답 레이블 vector이고 t_k는 그 중에서 k번째 해당하는 정답 레이블 값. y는 모델에서 나온 확률 vector이고 y_k는 그 중에서 k번째 해당하는 단어의 확률 값을 의미한다. 따라서 두개를 곱하게 되면 결국 정답 레이블의 해당 확률만 남게 되므로 P(w_t | w_t-1, w_t+1)과 똑같아진다.

    • T는 전체 말뭉치에서 샘플 데이터 수 ( = 말뭉치 - windowsize * 2)

  • 3.5.2 skip-gram 모델

    • 그림 [3-5-2]

      p3_5_2

    • target을 입력으로 주고, 맥락을 추론하는 모델

    • 수식 [3-5-2] : 위의 CBOW와 크게 다르지 않으므로 설명은 생략.

      m_3_5_2

    • CBOW 보다는 주로 skip-gram (말뭉치가 커질수록 저빈도 단어나 유추 문제의 성능 면에서 skip-gram 모델이 더 뛰어난 경향이 있음)

    • CBOW 보다 계산비용이 더 큼 : 손실을 맥락 수만큼 구해야하므로

  • 3.5.3 통계 기반 vs 추론 기반

    • 학습 : 말뭉치 전체를 1회 vs 말뭉치 미니배치(일부분)씩 여러번
    • 새로운 단어 : 계산 처음 부터 다시, 동시발생행렬 다시 만드는 등 vs 기존의 매개변수를 활용해 다시 학습 가능
    • 분산표현의 성격 : 주로 단어의 유사성 인코딩 vs 단어의 유사성 + 복잡한 단어 사이의 패턴까지 학습
    • 정밀도 : 유사성은 비슷함 (“Don’t count, predict”이라는 논문에서는 항상 추론 기반이 좋은 성능을, 다른 논문에서는 단어의 유사성 작업의 경우 하이퍼파라미터에 크게 의존, 통계 기반과 추론 기반의 우열을 명확히 가릴 수 없다고 함)
    • 서로 연결되어 있다. 예를 들어 skip-gram + 네거티브 샘플링을 이용한 모델은 말뭉치 전체의 동시발생 행렬에 특수한 행렬 분해를 적용한 것과 같다고 볼 수 있음.

3-6 정리


  • 추론 기반 기법은 추측하는 것이 목적, 단어의 분산 표현 word_vecs을 얻을 수 있다.
  • 2층 신경망 word2vec 의 모델 구현과 학습 방법을 알아보았다.
  • word2vec의 CBOW와 skip-gram의 차이를 알아보았다.
  • word2vec과 같은 추론 기반 모델과 통계기반 모델의 차이점을 알아보았다.

3-7 기타


- word2vec 개선

  • 밑바닥부터 시작하는 딥러닝2 Chapter 4. word2vec 속도 개선 부분에서 다룸.

    • [1] 특정행 연산 : Embedding 계층, [그림 3-2]에서 언급했던 것처럼, 어차피 계산되는 것은 특정 행 연산. 따라서 특정행만 골라서 계산하는 것 + 중복된 행 처리
    • [2] 네거티브 샘플링 : 다중 분류 -> 이진 분류, negative(정답이 아닌) 레이블 몇 개만 뽑아 loss 계산 + 낮은 확률과 높은 확률의 balance 조절을 위한 P^(0.75)

- 한국어 활용

  • 한국어 포스태거
    • KoNLPy
    • cohesion tokenizer : 김현중 서울대 박사과정이 개발. KoNLPy처럼 품사 정보까지 반환하지는 않지만 토크나이징 분석 대상 코퍼스의 출현 빈도를 학습한 결과를 토대로 토큰을 나눠준다.
  • 형태소 분석기

- word2vec: seq2seq -> attention -> BERT 까지

  • https://lovit.github.io/machine%20learning/2019/03/17/attention_in_nlp/#more
  • 이 부분은 lovit 블로그를 꼭 들어가서 읽어보길 추천한다.

- 추가로 해볼 것들

  • 실제 문제 풀어보기

    • [0] 한국어 데이터 구하고
    • [1] 전처리 : tokenizer (여러 tokenizer 비교)
    • [2] 모델 구성 : 관련 task에 따라 다르겠지만, 최신 트렌드를 살펴보고 해당 관련 모델 구성(BERT까지 이해한다면 좋겠지만, 구현한다고 치더라도 학습은 단일 GPU라 힘들지 않을까 생각이듦. 그래도 일단 해보기)
    • [3] 학습
  • 따라서 [0] NLP 관련 task 중 한 가지 주제 선정 후 일단 한국어 데이터를 구하고 [1] 전처리하는 것부터 차근차근 시도해보기 + NLP 관련 모델 코드 이해 ++

Taeu Kim

Taeu Kim

Life is balance

댓글