4.1 word2vec 개선
4.2 word2vec 개선_2
4.3 개선판 word2vec 학습
4.4 word2vec 남은 주제
4.5 정리
CBOW모델은 말뭉치에 포함된 어휘 수가 많아지면 계산량도 커진다.
word2vec의 속도개선을 위해 3장의 단순한 word2vec에 Embedding라는 새로운 계층 도입 / 네거티브 샘플링이라는 새로운 손실 함수 도입.
4.1 word2vec 개선
앞 장의 CBOW모델은 단어 2개를 맥락으로 사용해 이를 바탕으로 하나의 단어(타깃)를 추측한다.
이때 입력 측 가중치(Win)와의 행렬 곱으로 은닉층이 계산되고 다시 출력 측 가중치(Wout)와의 행렬 곱으로 각 단어의 점수를 구한다.
이 점수에 소프르맥스 함수를 적용해 각 단어의 출현 확률을 얻고 이 확률을 정답 레이블과 비교하여 손실을 구한다.(교차 엔트로피 오차 적용)
그러나 거대한 말뭉치를 다루게 되면 문제가 발생한다.
어휘 100만개, 은닉층의 뉴런이 100개인 CBOW모델을 생각해보자.
수많은 뉴런으로 인해 중간 계산에 많은 시간이 소요된다.
- 입력층의 원핫 표현과 가중치 행렬 Win의 곱 계산
- 은닉층과 가중치 행렬 Wout의 곱 및 Softmax 계층의 계산
이 두가지 계산이 병목이 된다.
첫 번째는 단어를 원핫 표현으로 다루기 때문에 어휘 수가 많아지면 원핫 표현의 벡터 크기도 커진다. 이 원핫 벡터와 가중치 행렬 Win을 곱해야 하는데 이는 계산 자원을 상당히 사용하게 된다. 이 문제는 Embedding 계층을 도입하는 것으로 해결된다.
두 번째는 은닉층과 가중치 행렬 Wout의 곱만 해도 계산량이 상당하다. 더불어 소프트맥스 계층에서도 다루는 어휘가 많아짐에 따라 계산량이 증가한다. 이 문제는 네거티브 샘플링이라는 새로운 손실 함수를 도입하는 것으로 해결한다.
1) Embedding 계층
은닉층 뉴런이 100개일 경우 MatMul 계층의 행렬곱
100만개의 어휘를 담은 말뭉치가 있다면, 단어의 원핫 표현도 100만 차원이 된다.
그러나 결과적으로 수행하는 일은 단지 행렬의 특정 행을 추출하는 것 뿐이기 때문에 원핫 표현으로의 변환과 MatMul 계층의 행렬 곱 계산은 필요가 없다.
그럼 가중치 매개변수로부터 '단어 ID에 해당하는 행(벡터)'을 추출하는 계층을 만들어보자. 그 계층을 Embedding계층이라고 부른다. 즉 Embedding 계층에 단어 임베딩(분산 표현)을 저장하는 것이다.
2) Embedding 계층 구현
행렬에서 특정 행을 추출하기는 쉽다. 예컨대 가중치 W가 2차원 넘파이 배열일 때, 이 가중치로부터 특정 행을 추출하기 위해서는 W[2]나 W[5]처럼 원하는 행을 명시하면 끝이다.
import numpy as np
W = np.arange(21).reshape(7,3)
W
array([[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11],
[12, 13, 14],
[15, 16, 17],
[18, 19, 20]])
>>> W[2]
array([6, 7, 8])
>>> W[5]
array([15, 16, 17])
또한 가중치 W로부터 여러 행을 한꺼번에 추출하는 것도 가능하다.
원하는 행 번호들을 다음 코드처럼 배열에 명시하면 된다.
인덱스 4개를 한 번에 추출해보자.
>>> idx = np.array([1, 0, 3, 0])
>>> W[idx]
array([[ 3 4 5],
[ 0 1 2],
[ 9 10 11],
[ 0 1 2]])
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
역전파를 구현한다.
def backward(self, dout):
dW, = self.grads
dW[...] = 0
dW[self.idx] = dout #나쁜 예
return None
- 다음과 같이 가중치 기울기 dW를 꺼낸 다음,
- dW[...] = 0에서는 dW 변수(가중치 행렬의 기울기)의 모든 요소를 0으로 설정. 이는 역전파 기울기를 초기화하는 단계이다.
- dW[self.idx] = dout에서는 self.idx 위치에 대한 역전파 기울기 dout를 dW의 해당 위치에 할당. 이로써 해당 위치의 기울기 값이 업데이트되게 된다.
이 backward() 구현에는 문제가 있다. => idx의 원소가 중복될 때 발생
dW의 0번째 행에 2개의 값이 할당된다. 먼저 쓰여진 값을 덮어쓴다.
이 중복 문제를 해결하려면 할당이 아닌 더하기를 해야한다.
dh의 각 행의 값을 dW의 해당 행에 더해준다.
역전파 올바르게 구현
def backward(self, dout) :
dW, = self.grads
dW[...] = 0
for i, word_id in enumerate(self, idx):
dW[word_id] += dout[i]
# np.add.at(dW, self.idx, dout) 로 대신 사용 가능
return None
for문을 사용해 인덱스에 기울기를 더한다.
이것으로 idx에 중복 인덱스가 있더라도 올바르게 처리됨.
4.2 word2vec 개선_2
남은 병목은 은닉층 이후의 처리(행렬 곱과 sofstmax 계층의 계산)
은닉층 이후에서 계산이 오래 걸리는 곳은 두 부분이다.
- 은닉층의 뉴런과 가중치 행렬(Wout)의 곱
: 은닉층의 벡터 크기가 100이고, 가중치 행렬의 크기가 100*100만이다. = 너무 많은 계산
- Softmax 계층의 계산
: 이도 같은 이유임. 어휘 수를 100만개로 가정했으므로 분모의 값을 얻으려면 exp 계산을 100만번 수행해야 함.
1) 다중 분류에서 이진 분류로
네거티브 샘플링 기법의 핵심은 '다중 분류'를 '이진 분류'로 근사.
: 맥락으로 you와 goodbye를 주면 정답인 say의 확률이 높아지도록 신경망을 학습시켰다. 즉 이 신경망은 "맥락이 you와 goodbye일 때 타깃 단어는 무엇입니까?" 라는 질문에 답을 줄 수 있다. 이진 분류 신경망은 "맥락이 you와 goodbye일 때, 타깃 단어는 say입니까?"라는 질문에 답하는 신경망임. => 출력층에는 뉴런을 하나만 준비하면 된다. 출력층의 이 뉴런이 say의 점수를 출력하는 것이다.
say에 해당하는 열벡터와 은닉층 뉴런의 내적을 계산한다. (dot노드가 내적을 계산)
출력 측 가중치 Wout에서는 각 단어ID의 단어 벡터가 각각의 열로 저장되어 있다.
이 예에서는 say에 해당하는 단어 벡터를 추출한다. 그리고 그 벡터와 은닉층 뉴런과의 내적을 구한다. = 최종 점수
(시그모이드 함수를 이용해 그 점수를 확률로 변환한다.)
2) 시그모이드 함수와 교차 엔트로피 오차
이진 분류 문제를 신경망으로 풀려면 점수에 시그모이드 함수를 적용해 확률로 변환하고, 손실을 구할 때는 손실 함수로 교차 엔트로피 오차를 사용한다.
다중 분류 : 출력층-소프트맥스 함수, 손실함수-교차 엔트로피 오차
이진 분류 : 출력층-시그모이드 함수, 손실함수-교차 엔트로피 오차
y : 시그모이드 함수의 출력
t : 정답 레이블 (0 혹은 1)
t가 1이면 -logy가 출력되고 반대로 t가 0이면 -log(1-y)가 출력
역전파의 y - t 값에 주목
y : 신경망이 출력한 확률
t : 정답 레이블
정답 레이블이 1이라면, y가 1에 가까워질수록 오차가 줄어듦.
그 오차가 앞 계층으로 흘러가므로 오차가 크면 크게 학습하고 오차가 작으면 작게 학습한다.
3) 다중 분류에서 이진 분류로 (구현)
(단어ID = you:0 say:1 goodbye:2)
입력층에서는 각각에 대응하는 단어ID의 분산 표현(단어 벡터)을 추출하기 위해 Embedding 계층을 사용했다. (이전에는 Embedding자리에 MatMul 계층을 사용했었다.)
은닉층 뉴런 h와, 출력 측의 가중치 Wout에서 단어 say에 해당하는 단어 벡터와의 내적을 계산한다.
그 출력을 Sigmoid with Loss 계층에 입력해 최종 손실을 얻는다.
(정답 레이블로 1을 제공 = 현재 문제의 답이 yes임을 의미)
Embedding Dot 계층 도입(Embedding 계층과 내적 계산을 한번에 수행)
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.cache = (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
idx가 [0, 3, 1]인 것은 3개의 데이터를 미니배치로 한 번에 처리하는 예임을 뜻함.
target_W는 W의 0번, 3번, 1번째 행을 추출한 결과이다.
target_W * h는 각 원소의 곱을 계산
이 결과를 행마다 전부 더해 최종 결과 out을 얻는다.
4) 네거티브 샘플링
지금까지는 긍정적인 예에 대해서만 학습했다. 즉 부정적인 예(오답)을 입력하면 어떤 결과가 나올지 확실하지 않다.
틀린 단어의 확률은 낮은 값이어야 하는데 이런 결과를 만들어주는 가중치가 필요하다.
모든 부정적 예를 대상으로 이진 분류를 학습시킬 경우 = 어휘 수가 감당할 수 없을만큼 늘어남.
이를 위해 근사적인 해법으로 부정적인 예를 몇 개 선택한다. 즉, 적은 수의 부정적 예를 샘플링해 사용한다. = 네거티브 샘플링
네거티브 샘플링 기법은 긍정적 예를 타깃으로 한 경우의 손실을 구한다. 이와 동시에 부정적 예를 몇 개 샘플링(선별)하여 그 부정적 예에 대해서도 손실을 구한다. 그리고 각각의(긍정, 부정) 데이터의 손실을 더한 값을 최종 손실로 한다.
부정적 예에 대해서는 정답 레이블로 0을 입력, 그런 다음 각 데이터의 손실을 모두 더해 최종 손실을 출력한다.
5) 네거티브 샘플링의 샘플링 기법
무작위로 샘플링하는 것이 아닌 말뭉치의 통계 데이터를 기초로 샘플링한다.
말뭉치에서 자주 등장하는 단어를 많이 추출하고, 드물게 등장하는 단어를 적게 추출한다.
샘플링 예시 코드(np.random.choice()메서드 이용)
import numpy as np
# words에서 하나만 무작위로 샘플링
words = ['you', 'say', 'goodbye', 'I', 'hello', '.']
np.random.choice(words)
>>> 'goodbye'
# 5개만 무작위로 샘플링 (중복 있음)
np.random.choice(words, size=5)
>>> array(['hello', 'goodbye', 'you', 'goodbye', 'hello'], dtype='<U7')
# 5개만 무작위로 샘플링 (중복 없음)
np.random.choice(words, size=5, replace=False)
>>> array(['goodbye', 'say', 'hello', 'you', 'I'], dtype='<U7')
# 확률 분포에 따라 샘플링
p = [0.5, 0.1, 0.05, 0.2, 0.05, 0.1]
np.random.choice(words, p=p)
>>> 'you'
확률분포에서 한 가지를 더 수정해준다. => 기존 기본 확률분포에 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으로 제공한다.)
6) 네거티브 샘플링 구현
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 : 말뭉치(단어 ID의 리스트)
- power : 확률분포에 제곱할 값
- sample_size : 부정적 예의 샘플링 횟수
인스턴스 변수인 loss_layers와 embed_dot_layers에는 원하는 계층을 리스트로 보관한다.
이때 두 리스트에는 sample_size+1개의 계층을 생성하는데 이는 긍정적 예를 다루는 계층이 하나 더 필요하기 때문이다.
순전파 구현
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
- self.sampler를 이용해 부정적 예를 샘플링하여 negative_sample에 저장
- 긍정적 예와 부정적 예 각각의 데이터에 대해서 순전파를 수행해 그 손실들을 더한다. (Embedding Dot 계층의 forward 점수를 구하고 이 점수와 레이블을 Sigmoid with Loss 계층으로 흘려 손실을 구하는 것)
- 긍정적 예의 정답 레이블은 1이고 부정적 예의 정답 레이블은 0이다
역전파 구현
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()를 호출하기만 하면 된다.
4.3 개선판 word2vec 학습
PTB 데이터셋을 사용해 학습해보자
1) CBOW 모델 구현
기존의 simpleCBOW 클래스에 Embedding 계층과 Negative Sampling Loss 계층을 적용한다.
또한, 맥락의 윈도우 크기를 조정할 수 있도록 한다.
import sys
sys.path.append('..')
from common.np import * # import numpy as np
from common.layers import Embedding
from ch04.negative_sampling_layer import NegativeSamplingLoss
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) # Embedding 계층 사용
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
초기화 인수
- vocab_size : 어휘 수
- hidden_size : 은닉층의 뉴런 수
- corpus : 단어 ID 목록
- window_size : 맥락의 크기(타깃 단어 좌우로 맥락을 몇 개로 삼을지 정함.)
가중치 초기화가 끝나면 계층을 생성한다.
계층 생성
- Embedding 계층을 2*window_size개 작성하여 in layers에 배열로 보관
- Negative Sampling Loss 계층 생성
계층을 생성한 후 이 신경망에서 사용하는 모든 매개변수와 기울기를 인스턴스 변수인 params와 grads에 모은다.
또한 후에 단어의 분산 표현에 접근할 수 있도록 인스턴스 변수인 word_vecs에 가중치 W_in을 할당한다.
# 순전파 구현
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
context는 2차원 배열이고 target는 1차원 배열이다. 이 데이터가 forward에 입력된다.
2) CBOW 모델 학습 코드
import sys
sys.path.append('..')
import numpy as np
from common import config
# GPU에서 실행하려면 아래 주석을 해제하세요(CuPy 필요).
# ===============================================
# config.GPU = True
# ===============================================
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
from cbow import CBOW
from skip_gram import SkipGram
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')
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)
# model = SkipGram(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' # or 'skipgram_params.pkl'
with open(pkl_file, 'wb') as f:
pickle.dump(params, f, -1)
하이퍼 파라미터
- window_size : 2~10개가 좋은 결과를 출력
- hidden_size : 50~500개가 좋은 결과를 출력
- batch_size
- max_epoch
3) CBOW 모델 평가
import sys
sys.path.append('..')
from common.util import most_similar, analogy
import pickle
pkl_file = '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']
# 가장 비슷한(most similar) 단어 뽑기
querys = ['you', 'year', 'car', 'toyota']
for query in querys:
most_similar(query, word_to_id, id_to_word, word_vecs, top=5)
word2vec으로 얻은 단어의 분산 표현은 비슷한 단어를 가까이 모을 뿐 아니라 더 복잡한 패턴을 파악하는 것으로 알려져 있다.
word2vec의 단어의 단어의 분산 표현을 사용하면 유추 문제를 벡터의 덧셈과 뺄셈으로 풀 수 있다.
단어 ‘man’의 분산 표현을 ‘vec(“man”)’이라고 표현해보자. 우리가 얻고 싶은 관계를 수식으로 나타내면 ‘vec(“woman”) - vec(“man”) = vec(?) - vec(“king”)’이 된다. 이 로직을 analogy()함수로 구현해보자. analogy()함수는 “a:b=c:?”에서 ?로 추정되는 상위 5개 단어를 리턴한다.
4.4 word2vec 남은 주제
단어의 분산 표현은 비슷한 단어를 찾는 용도로 이용할 수도 있지만, 전이 학습에 아주 중요한 역할을 한다.
(전이 학습 : 한 분야에서 배운 지식을 다른 분야에도 적용하는 기법)
자연어 처리 관점에서 살펴보면,
위키백과나 구글 뉴스의 텍스트 데이터로 학습을 마친 뒤,
그 분산 표현을 텍스트 분류, 문서 클러스터링, 품사 태그 달기 등의 작업에 이용할 수 있다.
또한, 단어의 분산 표현은 단어나 문장을 bag-of-words 혹은 순환 신경망(RNN)을 사용해 고정 길이 벡터로 변환해 줄 수 있다.
단어나 문장을 고정 길이 벡터로 변환하면, 이를 일반적인 머신러닝 기법에 적용시켜 머신러닝 시스템의 틀에서 원하는 답을 출력해 낼 수 있다.
메일 자동 분류 시스템
- 메일(데이터) 수집
- 긍정, 중립, 부정 3가지의 감정을 나타내는 레이블을 붙임.
- word2vec을 이용해 메일을 벡터로 변환
- 벡터화된 메일과 감정 레이블을 분류 시스템(SVM 혹은 신경망)에 입력시켜 학습 수행
단어 벡터 평가 방법
: 단어 분산 표현을 만드는 시스템과 분류하는 시스템의 학습은 따로 수행할 수도 있지만,
그럴 경우, 각 시스템을 따로 학습시킨 다음 평가해야 하고 이에 더해 최적의 하이퍼 파라미터를 찾기 위한 튜닝도 필요 해서 오랜 시간이 걸린다.
그래서 단어의 분산 표현의 우수성을 실제 어플리케이션과 분리해 평가한다.
여기에서 평가의 척도가 되는 것은 '유사성'이나 '유추 문제'로 볼 수 있다
4.5 정리
- Embedding 계층은 단어의 분산 표현을 담고 있으며, 순전파 시 지정한 단어 ID의 벡터를 추출한다.
- word2vec은 어휘 수의 증가에 비례하여 계산량도 증가하므로, 근사치로 계산하는 빠른 기법을 사용하면 좋다.
- 네거티브 샘플링은 부정적 예를 몇 개 샘플링하는 기법으로 이를 이용하면 다중 분류를 이진 분류처럼 취급할 수 있다.
- word2vec으로 얻은 단어의 분산 표현에는 단어의 의미가 녹아들어 있으며, 비슷한 맥락에서 사용되는 단어는 단어 벡터 공간에서 가까이 위치한다.
- word2vec의 단어 분산 표현을 이용하면 유추 문제를 벡터의 덧셈과 뻴셈으로 풀 수 있게 된다.
- word2vec은 전이 학습 측면에서 특히 중요하며, 그 단어의 분산 표현은 다양한 자연어 처리 작업에 이용할 수 있다.