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-2] : 가중치의 각 행이 해당 단어의 분산 표현이라고 볼 수 있다. 아래 그림과 결국 해당 단어와 곱해지는 원소들은
W_in
에 동일한 행의 원소들이다. -
window size, 맥락의 갯수의 평균을 해준다. (예제의 경우 window size = 1, 맥락의 단어는 2개이므로 은닉층 뉴런은 1/2 * (h1 + h2)
-
-
3.2.2 CBOW 모델의 학습
-
그림 [3-3]
-
-
3.2.3 word2vec의 가중치와 분산 표현
- W_in 과 W_out 은 서로 다른 mat, 많은 연구에서 W_in 만 단어의 분산 표현으로 이용.
- “Tying Word Vectors and Word Classifiers : A Loss Framwork for Language Modeling” 이라는 논문에서는 word2vec 과 skip-gram 모델 대상으로 W_in 의 효과를 실험을 통해 보여준다.
- “GloVe(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 반환
- 양끝 단어를 제외하고 모두 타깃으로 만들고 :
- [1] 우선 말뭉치 텍스트를 단어 ID로 변환 :
- 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를 찍게 수정했다.
- (warning) Trainer 클래스 내부에서 매개변수를 갱신할 때 매개변수의 중복을 없애는 간단한 작업을 수행.
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
- 아래와 같이 단어의 분산표현이
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_layer0
과in_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
- 단어의 분산 표현 확인
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]
</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]
-
target을 입력으로 주고, 맥락을 추론하는 모델
-
수식 [3-5-2] : 위의 CBOW와 크게 다르지 않으므로 설명은 생략.
-
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 관련 모델 코드 이해 ++
댓글