목차
👀, 🤷♀️ , 📜
이 아이콘들을 누르시면 코드, 개념 부가 설명을 보실 수 있습니다:)
word2vec 속도 개선
실제로 어휘 수가 어느 정도를 넘어서면 CBOW 모델은 계산 시간이 너무 오래 걸림
단순한 word2vec에 두 가지 개선
1️⃣ Embedding이라는 새로운 계층을 도입
2️⃣ 네거티브 샘플링이라는 새로운 손실 함수를 도입.
word2vec 개선 2가지 방법
Embedding이라는 새로운 계층을 도입
먼저 CBOW 모델을 구현
CBOW 모델 구동 과정 (단어 2개를 맥락)
1) 입력 측 가중치(\(W_in\))와의 행렬 곱으로 은닉층이 계산
2) 다시 출력 측 가중치(\(W_out\))와의 행렬 곱으로 각 단어의 점수를 구함
3) 이 점수에 소맥 함수를 적용해 각 단어의 출현확률 얻음
4) 이 확률을 정답 레이블과 비교하여(교차 엔트로피 오차를 적용하여) 손실을 구함
WARNING 앞에서는 맥락의 윈도우 크기를 1로 한정(타깃 앞뒤 한 단어씩 만 사용). 이번 장에는 윈도우 크기의 다양성 추가.
⛔ 어휘가 100만 개일 때를 가정한 CBOW 모델
수많은 뉴런 때문에 중간 계산에 많은 시간이 소요.
정확히는 다음의 두 계산이 병목이 됩니다.
1) 입력층의 원핫 표현과 가중치 행렬 W_in 의 곱 계산
2) 은닉층과 가중치 행렬 W_out 의 곱 및 Softmax 계층의 계산
⭕ Solution
1)입력층의 원핫 표현과 관련한 문제: 단어를 원핫 표현으로 다루기 때문에 어휘 수가 많아지면 원핫 표현의 벡터 크기도 커짐.
- 1️⃣Embedding 계층을 도입하는 것으로 해결
2) 은닉층 이후의 계산: 은닉층과 가중치 행렬 W_out 의 곱만 해도 계산 량이 많음, 그리고 Softmax 계층에서도 다루는 어휘가 많아짐에 따라 계산량이 증가하는 문제.
- 2️⃣네거티브 샘플링이라는 새로운 손실 함수를 도입해 해결.
1️⃣ Embedding 계층 도입
Embedding이라는 새로운 계층을 도입
[기존 방법]
1) 원핫 표현으로 바꿈
2) 이를 MatMul 계층에 입력
3) MatMul 계층에서 가중치 행렬을 곱함.
⛔ 이때 은닉층 뉴런이 100개라면, MatMul 계층의 행렬 곱
100만 개의 어휘를 담은 말뭉치-> 단어의 원핫 표현도 100만 -> 이런 거대한 벡터와 가중치 행렬을 곱해야 하는 것
⭕ Solution
위의 문제에서 결과적으로 단지 행렬의 특정 행을 추출: 원핫 표현으로의 변환과 MatMul 계층의 행렬 곱 계산 필요X
그럼, 가중치 매개변수로부터 ‘단어 ID에 해당하는 행(벡터)’을 추출하는 계층을 만들자 (Embedding 계층)
📜 Hierarchical Softmax
즉 위의 메서드를 Hierarchical Softmax라고 부른다.
이는 Skip-gram Word2vec의 계산 복잡성을 해결하기 위한 방법이다.
Skip-gram Word2vec에서 하나의 중심단어의 embedding 시에 100만개의 단어가 Corpus에 있다면, 벡터의 내적을 100만번 진행해야 했다(계산 양이 엄청남). ➡ 하지만 Hierarchical Softmax 방법을 사용하면 이진 분류를 통해 log2(100만) 으로 계산량이 줄어듦
논문에서 Hierarchical Softmax는 아래와 같이 설명된다
위 식을 풀어서 설명하면 아래와 같다.
‘I live in Seoul and like data analysis’ 라는 문장이 있다고 가정해보자.(8개의 단어)
Seoul 이라는 중심단어를, window size = 1 에 해당하는 주변단어들을 사용하여 학습해보자.
➡ in 을 예측하기 위해서는 오른쪽, 오른쪽, 왼쪽으로 가는 것이 정답이다
각 노드에서 방향을 선택할 확률이 위의 식을 따르는 것이다
1) Seoul 이라는 단어의 벡터가 들어오면,
2) 각 노드에 해당하는 weight 들이 곱해지고,
3) 시그모이드 함수에 들어가서 확률로 나오게 된다,
이 식 안의 [[ ]] 는 1 혹은 -1을 반환하는 함수이다
Hierarchical Softmax는 이 트리 구조의 weight들을 학습하게 되며, 이진 분류이기 때문에 log2(단어 수) 만큼의 계산량이 나온다.
더 자세한 설명은 [링크에 걸린 블로그][https://uponthesky.tistory.com/15)를 참고하길 바란다
📜 자연어 처리 분야에서 단어의 밀집벡터 표현
= 단어 임베딩
= 단어의 분산 표현 distributed representation
참고, 통계 기반 기법으로 얻은 단어 벡터
= distributional representation
신경망을 사용한 추론 기반 기법으로 얻은 단어 벡터
= distributed representation
Embedding 계층 구현
1) Embedding 계층 순전파 구현
forward ( )
- 순전파는 가중치 W의 특정 행을 추출할 뿐, 단순히 가중치의 특정 행 뉴런만을 (아무것도 손대지 않고) 다음 층으로 흘려보냄
👀 코드 보기
import numpy as np
W = np.arange(21).reshape(7, 3)
# 만든 결과 확인
print(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])
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
- 인스턴스 변수
params
와grads
를 사용. - 인스턴스 변수
idx
에는 추출하는 행의 인덱스(단어 ID)를 배열로 저장. - 순전파는 가중치 W의 특정 행을 추출할 뿐, 단순히 가중치의 특정 행 뉴런만을 (아무것도 손대지 않고) 다음 층으로 흘려보낸 것
2) Embedding 계층 역전파 구현
backward()
- 역전파에서는 앞 층(출력 측 층)으로부터 전해진 기울기를 다음 층(입력 측 층)으로 그대로 흘려주면 됨.
- 다만, 앞 층으로부터 전해진 기울기를 가중치 기울기 dW의 특정 행(idx번째 행)에 설정
👀 코드 보기
def backward(self, dout):
dW, = self.grads
dW[...] = 0
dW[self.idx] = dout # 실은 나쁜 예
return None
1) 가중치 기울기 dW
를 꺼냄
2) dW[…] = 0 문장에서 dW
의 원소를 0으로 덮어 씀 (dW
자체를 0으로 설정X, dW
의 형상을 유지한 채, 그 원소들을 0으로 덮어쓰는 것).
3) 앞 층에서 전해진 기울기 dout
을 idx번째 행에 할당
⛔ PROBLEM
여기에서는 가중치 W와 크기가 같은 행렬 dW를 만들고, dW의 특정 행에 기울기를 할당.
그러나 최종적으로 하고 싶은 일은 가중치 W를 갱신하는 것이므로 일부러 dW와 같은 (W와 같은 크기의) 행렬을 만들 필요X.
-> 위의 그림에서 dw 행렬의 공백이 너무 크죠?
⭕ Solution
갱신하려는 행 번호(idx )와 그 기울기(dout)를 따로 저장해두면, 이 정보로부터 가중치(W)의 특정 행만 갱신 가능.
다만 여기에서는 이미 구현해둔 갱신용 클래스(Optimizer)와 조합해 사용하는 것을 고려해 지금처럼 구현함
[PROBLEM] idx의 원소가 중복될 때
EX) idx가 [0, 2, 0, 4 ]일 경우
위와 같이 dh의 각 행 값을 idx가 가리키는 장소에 할당
-> dW의 0번째 행에 2개의 값이 할당(먼저 쓰여진 값을 덮어씀)
[SOLVE] ‘할당’이 아닌 ‘더하기’
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
넘파이의 np.add.at (A, idx, B )
는 B를 A의 idx번째 행에 더해 줌.(효율이 더 좋음)
2️⃣ 네거티브 샘플링이란 손실 함수 도입.
네거티브 샘플링이라는 새로운 손실 함수를 도입.
은닉층 이후의 처리(행렬 곱과 Softmax 계층의 계산) 개선: 네거티브 샘플링 (부정적 샘플링)이라는 기법을 사용
⛔ PROBLEM
은닉층 이후 계산의 문제점
어휘가 100만 개일 때를 가정한 word2vec: “you”와 “goodbye”가 맥락이고 “say”가 타깃일 때,
남은 문제는 은닉층 이후의 처리
- 은닉층의 뉴런과 가중치 행렬(\(W_{out}\))의 곱
- Softmax 계층의 계산
1) 거대한 행렬을 곱하는 문제.
은닉층의 벡터 크기가 100, 가중치 행렬의 크기가 100×100만
또한 역전파 때도 같은 계산을 수행하기 때문에 이 행렬 곱을 ‘가볍게’ 만들어야 함
2) Softmax에서도 같은 문제가 발생
k번째 원소(단어)를 타깃으로 했을 때의 Softmax 계산식
- 점수의 각 원소는 \(s_1\) , \(s_2\) , …
- 여기서 어휘 수를 100만 개로 가정-> 분모의 값을 얻으려면 exp 계산을 100만 번 수행 필요.
다중 분류에서 이진 분류로
네거티브 샘플링 기법: 핵심 아이디어는 ‘이진 분류 binary classification’ 에 있음.
더 정확하게 말하면, ‘다중 분류 multi-class classification ’ (혹은 다중 클래스 분류)를 ‘이진 분류’로 근사하는 것
- 다중 분류: 100만 개의 단어 중에서 옳은 단어 하나를 선택하는 문제, 정답이 되는 단어를 높은 확률로 추측
- 이진 분류: “Yes/No”로 답하는 문제.
⭕ Solution
‘다중 분류’ 문제를 ‘이진 분류’ 방식으로 해결!
- 이를 위해서는 “Yes/No”로 답할 수 있는 질문 필요
(ex)
“맥락이 ‘you’ 와 ‘goodbye’일 때, 타깃 단어는 ‘say’입니까?”라는 질문에 답하는 신경망을 생각해내야 함
- 이렇게 하면 출력층에는 뉴런이 하나만 필요.
- 출력층의 이 뉴런이 “say”의 점수를 출력하는 것
그럼 이때 CBOW 모델은,
출력층의 뉴런은 하나
1) 따라서 은닉층과 출력 측의 가중치 행렬의 내적은 “say”에 해당하는 열(단어 벡터)만을 추출
2) 그 추출된 벡터와 은닉층 뉴런과의 내적을 계산하면 끝.
이 계산을 자세히 그린 것.
‘say’에 해당하는 열벡터와 은닉층 뉴런의 내적을 계산(dot이 내적을 계산)
- 출력 측의 가중치 W out 에서는 각 단어 ID의 단어 벡터가 각각의 열로 저장되어 있음.
이 예에서는 “say”에 해당하는 단어 벡터를 추출.
그리고 그 벡터와 은닉층 뉴런과의 내적을 구합니다.
이렇게 구한 값이 최종 점수인 것.
➕ 이전까지의 출력층에서는 모든 단어를 대상으로 계산을 수행.
하지만 여기에서는 “say” 라는 단어 하나에 주목하여 그 점수만을 계산
그리고 시그모이드 함수를 이용해 그 점수를 확률로 변환
시그모이드 함수와 교차 엔트로피 오차
이진 분류 문제를 신경망으로 풀려면:
점수에 시그모이드 함수를 적용해 확률로 변환하고,
손실을 구할 땐 손실 함수로 ‘교차 엔트로피 오차’ 사용 필요.
이진 분류 신경망에서 가장 흔하게 사용하는 조합
- 다중 분류: 출력층에서는 (점수를 확률로 변환할 때) ‘소프트맥스 함수’, 손실 함수로는 ‘교차 엔트로피 오차’를 이용.
- 이진 분류: 출력층에서는 ‘시그모이드 함수’를, 손실 함수로는 ‘교차 엔트로피 오차’를 이용합니다.
🤷♀️ 이진분류 함수 더보기
[시그모이드 함수]
- 그래프는 S자 곡선 형태
- 입력값 X: 0에서 1 사이 실수로 변환
여기서 핵심은, 시그모이드 함수의 출력 y를 ‘확률’로 해석 가능
- 시그모이드 함수를 적용해 확률 y를 얻으면, 이 확률 y로부터 손실을 구함.
- 시그모이드 함수에 사용되는 손실 함수는 다중 분류 때처럼 ‘교차 엔트로피 오차’입니다.
교차 엔트로피 오차는 다음과 같음. \(L = −(tlogy + (1− t)log(1− y))\)
- y: 시그모이드 함수의 출력
- t: 정답 레이블
- 이 정답 레이블의 값은 0 혹은 1
- t가 1이면 정답이 “Yes”이고, 0이면 “No”입니다.
- 따라서 t가 1이면 -logy가 출력
- 반대로 t가 0이면 -log (1 ‒ y )가 출력
[WARNING]
이진 분류와 다중 분류 모두 손실 함수로 ‘교차 엔트로피 오차’ 를 사용.
각각의 수식은 소프트맥스 함수와 시그모이드 함수 서로 다르지만,
결국 의미는 같음.
정확히 말하면, 다중 분류에서 출력층에 뉴런을 2개만 사용할 경우 이진 분류의 시그모이드 함수과 완전히 같아짐.
따라서 Softmax with Loss 계층의 코드를 조금만 손보면 Sigmoid with Loss 계층도 구현 가능.
이어서 Sigmoid 계층과 Cross Entropy Error 계층의 계산 그래프를 살펴보면,
여기서 주목할 점은 역전파의 y - t 값임
- y: 신경망이 출력한 확률
- t: 정답 레이블.
- y - t: 정확히 그 두 값의 차이
예컨대 정답 레이블이 1이라면, y가 1 (100%) 에 가까워질수록 오차가 줄어든다는 뜻.
반대로 y가 1 로부터 멀어지면 오차가 커짐.
➡ 그 오차가 앞 계층으로 흘러가므로, 오차가 크면 ‘크게’ 학습하고, 오차가 작으면 ‘작게’ 학습하게 됩니다.
📜NOTE
‘시그모이드 함수’와 ‘교차 엔트로피 오차’를 조합하여 역전파의 값이 y - t라는 ‘깔끔한 결과’를 도출.
마찬가지로 ‘소프트맥스 함수’와 ‘교차 엔트로피 오차’의 조합, 또는 ‘항등 함수’와 ‘2 제곱 오차’의 조합에서도 역전파 시에는 y - t 값이 전파됨
(구현) 다중 분류에서 이진 분류로
다중 분류에서는 출력층에 어휘 수만큼의 뉴런을 준비하고 이 뉴런들이 출력한 값을 Softmax 계층에 통과시킴.
이때 이용되는 신경망을 ‘계층’과 ‘연산’ 중심으로 그리면,
- 맥락이 “you”와 “goodbye”이고, 정답이 되는 타깃(예측해야 할 단어)이 “say”인 경우의 예
- 단어 ID는 “you”가 0,“say”가 1, “goodbye”가 2라고 가정
그럼 신경망을 이진 분류 신경망으로 변환해보자
- 입력층에서는 각각에 대응하는 단어 ID의 분산 표현을 추출하기 위해 Embedding 계층을 사용.
WARNING
앞 절에서는 Embedding
계층을 구현했습니다.
이 계층은 대상 단어 ID의 분산 표현(단어 벡터)을 추출합니다.
이전에는 Embedding
계층 자리에 MatMul 계층을 사용했었습니다.
여기에서는 은닉층 뉴런 h와, 출력 측의 가중치 W out 에서 단어 “say”에 해당하는 단어 벡터와의 내적을 계산.
그리고 그 출력을 Sigmoid with Loss 계층에 입력해 최종 손실을 얻음.
WARNING
위의 그림에서는 Sigmoid with Loss
계층에 정답 레이블로 “1”을 입력하고 있음
이는 현재 문제의 답이 “Yes”임을 의미.
답이 “No”라면 Sigmoid with Loss
에 정답 레이블로 “0” 을 입력해야 함.
위 그림의 후반부를 더 단순하게 만들어보자.
- 이를 위해
Embedding Dot
계층을 도입한다 - 이 계층은 [그림 4-12 ]의
Embedding
계층 + ‘dot
연산(내적)’
은닉층 뉴런 h는 Embedding Dot 계층을 거쳐 Sigmoid with Loss 계층을 통과.
보다시피 Embedding Dot 계층을 사용하면서 은닉층 이후의 처리가 간단해짐.
Embedding Dot 계층의 구현
👀 코드 보기
EmbeddingDot 클래스에는 총 4개의 인스턴스 변수 존재
(embed, params, grads, cache)
- params: 매개변수를 저장(이 책의 구현 규칙대로)
- grads: 기울기를 저장.
- embed: Embedding 계층
- cache: 순전파 시의 계산 결과를 잠시 유지하기 위한 변수
class EmbeddingDot:
def __init__(self, W):
self.embed = Embedding(W)
self.params = self.embed.params
self.grads = self.embed.grads
self.cache = None
# 순전파를 담당
# 은닉층 뉴런(h)과 단어 ID의 넘파이 배열(idx )을 받음
# 여기에서 idx는 단어 ID의 ‘배열’인데, 배열로 받는 이유는 데이터를 한꺼번에 처리하는 ‘미니배치 처리’를 가정했기 때문입니다
# 우선 Embedding 계층의 forward (idx )를 호출한 다음 내적을 계산
def forward(self, h, idx):
target_W = self.embed.forward(idx)
# 내적 계산은 np.sum 한줄로 이뤄짐(+구체적인건 밑에)
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
🤷♀️ ```np.sum (self.target_W * h, axis=1 )```
- 이 구현을 이해하기 위한 구체적인 값.
- Embedding Dot 계층의 순전파
1) 위의 그림과 같이, 적당한 W와 h, 그리고 idx를 준비.
- 여기에서 idx가 [0, 3, 1 ]인데, 이는 3개의 데이터를 미니배치로 한 번에 처리하는 예임을 뜻함.
- idx가 [0, 3, 1 ]이므로 target_W는 W의 0번, 3번, 1번째의 행을 추출한 결과임.
2) target_W * h : 각 원소의 곱을 계산
3) 이 결과를 행마다(axis=1 ) 전부 더해 최종 결과 out을 얻음
역전파는 순전파의 반대 순서로 기울기를 전달해 구현.
네거티브 샘플링
⛔ PROBLEM
지금까지 배운 것으로 주어진 문제를 ‘다중 분류’에서 ‘이진 분류’로 변환 가능.
지금까지는 긍정적인 예(정답)에 대해서만 학습 ➡ 문제다 해결되지 않음
다시 말해, 부정적인 예(오답)를 입력하면 어떤 결과가 나올지 확실하지 않음
(ex) 맥락이 “you”와 “goodbye”이고, 정답 타깃이 “say”인 경우,
우리는 지금까지 긍정적 예인 “say”만을 대상으로 이진 분류를 해옴.
만약 여기서 ‘좋은 가중치’가 준비되어 있다면: Sigmoid 계층의 출력(확률)은 1에 가까울 것.
이때의 처리를 계산 그래프로 그리면
- 현재의 신경망에서는 긍정적 예인 “say”에 대해서만 학습.
- 부정적 예(“say” 이외의 단어)에 대해서는 어떠한 지식도x.
[목표]
- 긍정적 예(“say”)에 대해서는 Sigmoid 계층의 출력을 1에 가깝게 만들고,
- 부정적 예(“say” 이외의 단어)에 대해서는 Sigmoid 계층의 출력을 0에 가깝게 만드는 것.
➡ 이런 결과를 내주는 가중치 필요
ex)
맥락이 “you”와 “goodbye”일 때, 타깃이 “hello”일 확률(틀린 단어일 경우의 확률) 은 낮은 값이어야 바람직.
[그림 4-16 ]에서 타깃이 “hello”일 확률은 0.021 (2.1%). 그리고 이런 결과를 만들어주는 가중치가 필요합니다.
📜 다중 분류 문제를 이진 분류로 다루려면 ‘정답(긍정적 예)’과 ‘오답(부정적 예)’ 각각에 대해 바르게 (이진) 분류할 수 있어야 함.
따라서 긍정적 예와 부정적 예 모두를 대상으로 문제를 생각해야 합니다.
그럼 모든 부정적 예를 대상으로 이진 분류를 학습시키는 건?: 안돼!
모든 부정적 예를 대상으로 하는 방법은 어휘 수가 늘어나면 감당 불가(어휘 수 증가에 대처하는 것이 이번 장의 목적)
⭕ Solution
네거티브 샘플링
1) 부정적 예를 몇 개(5개라든지, 10개라든지) 선택 (선택하는 방법은 뒤에서 설명)
- 즉, 적은 수의 부정적 예를 샘플링해 사용.
2) 긍정적 예를 타깃으로 한 경우의 손실을 구함. 그와 동시에 부정적 예를 몇 개 샘플링(선별)하여, 그 부정적 예에 대해서도 마찬가지로 손실을 구함.
3) 각각의 데이터(긍정적 예와 샘플링 된 부정적 예)의 손실을 더한 값을 최종 손실로 합니다.
[EX]
부정적 예의 타깃을 2개(“hello”와 “I”) 샘플링 했다고 가정
이제 CBOW 모델의 은닉층 이후만 주목하면 네거티브 샘플링의 계산 그래프는,
WARNING
긍정적 예와 부정적 예를 다루는 방식 주의!
- 긍정적 예(“say”) 에 대해서는 지금까지처럼 Sigmoid with Loss 계층에 정답 레이블로 “1”을 입력.
- 부정적 예(“hello”와 “I”)에 대해서는 (부정적 예이므로) Sigmoid with Loss 계층에 정답 레이 블로 “0”을 입력.
- 그런 다음 각 데이터의 손실을 모두 더해 최종 손실을 출력.
네거티브 샘플링의 샘플링 기법
부정적 예를 고르는 법
다행히 단순이 무작위로 샘플링하는 것보다 좋은 방법이 있음!
- 말뭉치의 통계 데이터를 기초로 샘플링하는 방법: 말뭉치에서 자주 등장하는 단어를 많이 추출하고 드물게 등장하는 단어를 적게 추출하는 것.
말뭉치에서의 단어 빈도를 기준으로 샘플링하려면,
1) 말뭉치에서 각 단어의 출현 횟수를 구해 ‘확률분포’로 나타냄.
2) 그 확률분포에 따라 샘플링을 여러 번 수행
📜 네거티브 샘플링에서는 부정적 예를 가능한 한 많이 다루는 것이 좋습니다만, 계산량 문제 때문에 적은 수(5개나 10개 등)로 한정해야 함
그런데 우연히도 ‘희소한 단어’만 선택되었다면 어떻게 될까?
- 당연히 결과도 나빠짐.
- 실전 문제에서도 희소한 단어는 거의 출현하지 않기 때문.
- 즉, 드문 단어를 잘 처리하는 일은 중요도가 낮음. 그보다는 흔한 단어를 잘 처리하는 편이 좋은 결과로 이어질 것임.
샘플링하는 코드 구현
이 용도에는 넘파이의 np.random.choice ( )
메서드를 사용 가능
이 메서드의 사용법을 보여주는 예를 몇 가지 살펴보자.
👀 코드 보기
import numpy as np
# 0 에서 9 까지의 숫자 중 하나를 무작위로 샘플링
np.random.choice(10)
## 7
np.random.choice(10)
## 2
# words 에서 하나만 무작위로 샘플링
words = ['you', 'say', 'goodbye', 'I', 'hello', '.']
np.random.choice(words)
## 'goodbye'
# 5 개만 무작위로 샘플링 ( 중복 있음 )
# size만큼 수행
np.random.choice(words, size=5)
## array([‘goodbye’,‘.’,‘hello’,‘goodbye’,‘say’],dtype=’U7’)
# 5 개만 무작위로 샘플링 ( 중복 없음 )
np.random.choice(words, size=5, replace=False)
## array(['hello', '.', 'goodbye', 'I', 'you'], dtype='U7')
# 확률분포에 따라 샘플링
# 인수 p에 확률분포를 담은 리스트를 지정하면 그확률분포대로 샘플링
p = [0.5, 0.1, 0.05, 0.2, 0.05, 0.1]
np.random.choice(words, p=p)
## 'you'
[수정 권고]
그런데 word2vec의 네거티브 샘플링에서는 앞의 확률분포에서 한 가지를 수정하라고 권고.
- 밑의 식처럼 기본 확률분포에 0.75를 제곱하는 것.
- \(P(w_i)\)는 i번째 단어의 확률.
- 단순히 원래 확률분포의 각 요소를 ‘0.75 제곱’할 뿐.
- 다만 수정 후에도 확률의 총합은 1이 되어야 하므로, 분모로는 ‘수정후 확률분포의 총합’이 필요합니다.
수정하는 이유?
- 출현 확률이 낮은 단어를 ‘버리지 않기’ 위해서.
- 더 정확하게 말하면, ‘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 (1%)이던 원소가, 수정 후에는 0.0265…(약 2.65%)로 높아짐.
참고) 0.75라는 수치에는 이론적인 의미X 다른 값 설정가능.
지금까지 살펴본 것처럼 네거티브 샘플링
우리는 이 처리를 담당하는 클래스를 UnigramSampler라는 이름으로 제공
1) 말뭉치에서 단어의 확률분포를 만들고
2) 다시 0.75 를 제곱한 다음,
3) np.random.choice ( )를 사용해 부정적 예를 샘플링.
🤷♀️ 유니그램 Unigram 이란
‘하나의 (연속된) 단어’를 뜻함.
같은 요령으로 바이그램 Bigram 은 ‘2개의 연속된 단어’를, 트라이그램 Trigram 은 ‘3개의 연속된 단어’를 뜻함.
그래서 UnigramSampler 클래스의 이름에는 한 단어를 대상으로 확률분포를 만든다는 의미가 녹아 있음.
만약 이를 ‘바이그램’ 버전으로 만든다면 (‘you’, ‘say’ ), (‘you’, ‘goodbye’ ) … 같이 두 단어로 구성된 대상에 대한 확률분포를 만들게 됨.
UnigramSampler 클래스
초기화 시 3개의 인수를 받음.
(1) ID 목록인 corpus,
(2) 확률분포에 ‘제곱’할 값인 power (기본값은 0.75 )
(3) ‘부정적 예 샘플링’을 수행하는 횟수인 sample _size
get_negative_sample (target )
메서드를 제공.
1) target 인수로 지정한 단어를 긍정적 예로 해석하고,
2) 그 외의 단어 ID를 샘플링(즉, 부정적 예를 골라줍니다).
[UnigramSampler 클래스 사용 구현]
👀 코드 보기
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)
# [[0 3]
# [1 2]
# [2 3]]
➡ 긍정적 예로 [1, 3, 0 ]이라는 3개의 데이터를 미니배치로 다룸.
이 각각의 데이터에 대해서 부정적 예를 2개씩 샘플링합니다.
이 예에서는 첫 번째 데이터에 대한 부정적 예는 [0 3 ],
두 번째는 [1 2 ],
3번째는 [2 3 ]이 뽑혔음을 알 수 있음
(실행할 때마다 결과가 달라질 수 있음)
이제 우리는 부정적 예를 샘플링할 수 있게 됨.
네거티브 샘플링 구현
NegativeSamplingLoss
라는 클래스로 구현.
**[초기화 메서드 구현] **
👀 코드 보기
초기화 메서드의 인수
W
: 출력 측 가중치를 나타냄corpus
: 말뭉치(단어 ID의 리스트)를 뜻함power
: 확률분포에 제곱할 값sample_size
: 부정적 예의 샘플링 횟수.
class NegativeSamplingLoss:
def __init__(self, W, corpus, power=0.75, sample _size=5):
# sample_size: 부정적 예의 샘플링 횟수 저장
self.sample_size = sample_size
# sampler: 앞에서 설명한 UnigramSampler 클래스 생성 후 저장
self.sampler = UnigramSampler(corpus, power, sample_size)
# loss_layers와 embed_dot_layers (+)
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
원하는 계층을 리스트로 보관
1) 이때 이 두 리스트에는 sample_size + 1개의 계층을 생성
부정적 예를 다루는 계층이 sample_size개만큼이고, 여기에 더해 긍정적 예를 다루는 계층이 하나 더 필요하기 때문
정확히는 0번째 계층, 즉 loss_layers[0]과 embed_dot_layers[0]이 긍정적 예를 다루는 계층임. 2)그런 다음 이 계층에서 사용하는 매개변수와 기울기를 각각 배열로 저장
[순전파의 구현]
매서드 과정
1) 우선 self.sampler를 이용해 부정적 예를 샘플링하여 negative_sample에 저장합니다.
2) 그런 다음 긍정적 예와 부정적 예 각각의 데이터에 대해서 순전파를 수행해 그 손실들을 더함.
구체적으로
(1) Embedding Dot 계층의 forward 점수를 구하고,
(2) 이어서 이 점수와 레이블을 Sigmoid with Loss
계층으로 흘려 손실을 구함.
여기에서 긍정적 예의 정답 레이블(correct_label
)은 “1” 부정적 예의 정답 레이블 (negative_label
)은“0”임에 주의
👀 코드 보기
forward (h, target)
메서드가 받는 인수
(1) 은닉층 뉴런 h
(2) 긍정적 예의 타깃을 뜻하는 target
def forward(self, h, target):
batch_size = target.shape[0]
negative_sample = self.sampler.get_negative_sample(target)
# 긍정적 예 순전파(+1)
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
[역전파를 구현]
backward ( )
구현
👀 코드 보기
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 ( )를 호출.
은닉층의 뉴런은 순전파 시에 여러 개로 복사되었음.
이는 ‘1.3.4 계산 그래프’ 절에서 설명한 Repeat 노드에 해당.
➡ 따라서 역전파 때는 여러 개의 기울기 값을 더해줌.
개선판 word2vec 학습
CBOW 모델 구현
앞 장의 단순한 SimpleCBOW
클래스를 개선.
개선점은 Embedding
계층과 Negative Sampling Loss
계층 적용.
나아가 맥락의 윈도우 크기를 임의로 조절할 수 있도록 확장.
[초기화 메서드]
👀 코드 보기
초기화 메서드
(1) vocab_size
: 어휘 수
(2) hidden_size
: 은닉층의 뉴런 수
(3) corpus
: 단어 ID 목록
(4) window_size
: 맥락의 크기(주변 단어 중 몇 개나 맥락으로 포함시킬지)를 로 지정.
예컨대 window_size
가 2이면 타깃 단어의 좌우 2개씩, 총 4개 단어가 맥락이 됨.
import sys
sys.path.append('..')
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):
# Embedding 계층 사용
layer = Embedding(W_in)
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
WARNING
SimpleCBOW 클래스(개선 전의 구현)에서는 입력 측의 가중치와 출력 측의 가중치의 형상이 달라서 출력 측의 가중치에서는 단어 벡터가 열 방향으로 배치
한편 CBOW 클래스의 출력 측 가중치는 입력 측 가중치와 같은 형상으로, 단어 벡터가 행 방향에 배치됩니다.
➡ NegativeSamplingLoss 클래스에서 Embedding 계층을 사용하기 때문입니다.
가중치 초기화가 끝나면,
[계층 생성 구현]
1) Embedding
계층을 2 * window_size
개 작성하여 인스턴스 변수인 in_layers
에 배열로 보관
2) Negative Sampling Loss
계층 생성.
3) 계층을 다 생성 후, 이 신경망에서 사용하는 모든 매개변수와 기울기를 인스턴스 변수인 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
각 계층의 순전파 (혹은 역전파 )를 적절한 순서로 호출할 뿐.
앞 장의 SimpleCBOW 클래스를 자연스럽게 확장한 것
➡ 단, forward (contexts, target) 메서드가 인수로 받는 맥락과 타깃이 단어 ID라는 점이 다름
(앞 장에서는 단어 ID를 원핫 벡터로 변환해서 사용했음).
구체적인 예 오른쪽에 보이는 단어 ID의 배열이 contexts와 target의 예
c o n t e x t s 는 2 차원 배열, t a r g e t 은 1 차원 배열
이러한 데이터가 forward (contexts, target)에 입력되는 것.
[CBOW 모델 학습 구현]
여기에서는 단순히 신경망 학습을 수행할 뿐.
하이퍼파라미터 설정
이번 CBOW 모델은
- 윈도우 크기를 5로,
- 은닉층의 뉴런 수를 100개로 설정.
일반적으로 좋은 결과를 얻기 위해,
- 윈도우 크기는 2~10개,
- 은닉층의 뉴런 수(단어의 분산 표현의 차원 수)는 50~500개 정도
👀 코드 보기
import sys
sys.path.append('..')
import numpy as np from common import config
# GPU 에서 실행하려면 아래 주석을 해제해 (쿠파이 필요).
# ===============================================
# config.GPU = True
# ===============================================
import pickle
from common.trainer import Trainer
from common.optimizer import Adam
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')
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)
📜 PTB
그런데 이번에 다루는 PTB는 지금까지의 말뭉치보다 월등히 큼
- 학습 시간이 상당히 오래 걸림(반나절 정도).
- 그래서 GPU를 사용할 수 있는 모드를 준비해둠: GPU로 실행하려면 파일 앞부분에 있는
# config.GPU = True
주석을 해제하면 됨.
GPU로 실행조건
- 엔비디아 GPU를 장착
- 쿠파이 미리 설치 완료
- 학습이 끝나면 가중치를 꺼내(여기에서는 입력 측 가중치만), 나중에 이용할 수 있도록 파일에 보관 (단어와 단어 ID 변환을 위해 사전도 함께 보관합니다).
- 파일로 저장할 때는 파이썬의 ‘피클 pickle’ 기능을 이용: 피클은 파이썬 코드의 객체를 파일로 저장(또는 파일에서 읽기)하는 데 이용할 수 있음.
CBOW 모델 평가
2장에서 구현한 most_similar ( )
메서드를 이용하여, 단어 몇 개에 대해 거리가 가장 가까운 단어들을 뽑자
👀 코드 보기
import sys
sys.path.append('..')
from common.util import most_similar
import pickle
pkl_file = 'cbow_params.pkl'
# with open(파일 경로, 모드) as 파일 객체:
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)
이 코드를 실행하면 다음 결과를 얻을 수 있습니다(이 결과는 각자의 학습 환경에 따라 다를수 있습니다).
👀 결과, 결과 분석 보기
[query] you
we: 0.610597074032
someone: 0.591710150242
i: 0.554366409779
something: 0.490028560162
anyone: 0.473472118378
[query] year
month: 0.718261063099
week: 0.652263045311
spring: 0.62699586153
summer: 0.625829637051
decade: 0.603022158146
[query] car
luxury: 0.497202396393
arabia: 0.478033810854
auto: 0.471043765545
disk-drive: 0.450782179832
travel: 0.40902107954
[query] toyota
ford: 0.550541639328
instrumentation: 0.510020911694
mazda: 0.49361255765
bethlehem: 0.474817842245
nissan: 0.474622786045
결과 분석
- “you”: 비슷한 단어로 인칭대명사 “i (=I )”와 “we” 등이 나옴
- “year”:“month”와 “week” 같은 기간을 뜻하는 같은 성격의 단어.
- “toyota”: “ford”, “mazda”, “nissan” 같은 자동차 메이커.
유추문제 풀기
word2vec으로 얻은 단어의 분산 표현은 비슷한 단어를 가까이 모을 뿐 아니라,
더 복잡한 패턴을 파악 가능
대표적인 예가 “king - man + woman = queen” 으로 유명한 유추 문제(비유 문제)임.
더 정확하게 말하면, word2vec의 단어의 분산 표현을 사용하면 유추 문제를 벡터의 덧셈과 뺄셈으로 풀 수 있다는 뜻.
실제로 유추 문제를 풀려면 [그림 4-20 ]처럼 단어 벡터 공간에서 “man → woman” 벡터와 “king → ?” 벡터가 가능한 한 가까워지는 단어를 찾습니다.
1) 표현 가정
“vec (‘man’ )”: 단어 “man”의 분산 표현 (단어 벡터)
2) 그러면 [그림 4-20 ]에서 얻고 싶은 관계를 수식으로 나타내면 “vec (‘woman’) - vec (‘man’) = vec (?) - vec (‘king’)”
즉, 우리가 풀어야 하는 문제는 “vec (‘king’) + vec (‘woman’) -vec (‘man’) = vec (?)”라는 벡터에 가장 가까운 단어 벡터를 구하는 일.
이 함수를 사용하면 지금과 같은 유추 문제를 analogy('man', 'king', 'woman', word_to_id, id_to_word, word_vecs, top=5 )
라는 한 줄로 처리 가능.
이 함수의 실행 결과 출력
[analogy] man:king = woman:?
word1: 5.003233
word2: 4.400302
word3: 4.22342
word4: 4.003234
word5: 3.934550
이와 같이 첫 번째 줄에 문제 문장이 출력되고, 다음 줄부터는 점수가 높은 순으로 5개의 단어가 출력. -> 각 단어 옆에는 점수가 표시됩니다.
이처럼 word2vec으로 얻은 단어의 분산 표현으로 알 수 있는 것,
(1) 벡터의 덧셈과 뺄셈으로 유추 문제를 풀 수 있음.
(2) 단어의 단순한 의미뿐 아니라 문법적인 패턴 파악 가능
(3) 그밖에도 “good”과 “best” 사이에는 “better”가 존재한다고 하는 관계성 등
만약 큰 말뭉치로 학습하면, 더 정확하고 더 견고한 단어의 분산 표현을 얻을 수 있으므로 유추 문제의 정답률(정확도 accuracy )도 크게 향상될 것임.
word2vec 남은 주제
word2vec을 사용한 애플리케이션의 예
- word2vec으로 얻은 단어의 분산 표현은 비슷한 단어를 찾는 용도로 이용 가능
- 자연어 처리 분야에서 단어의 분산 표현이 중요한 이유는 전이 학습 transfer learning에 있음.
- 전이 학습: 한 분야에서 배운 지식을 다른 분야에도 적용하는 기법
자연어 문제를 풀 때 word2vec의 단어 분산 표현을 처음부터 학습하는 일은 거의 없음.
➡ 그 대신 먼저 큰 말뭉치(위키백과나 구글 뉴스의 텍스트 데이터 등)로 학습을 끝난 후, 그 분산 표현을 각자의 작업에 이용.
[EX] 텍스트 분류, 문서 클러스터링, 품사 태그 달기, 감정 분석 등 자연어 처리 작업이라면 가장 먼저 단어를 벡터로 변환하는 작업을 해야 하는데, 이때 학습을 미리 끝낸 단어의 분산 표현을 이용할 수 있음.
그리고 이 학습된 분산 표현이 방금 언급한 자연어 처리 작업 대부분에 훌륭한 결과를 가져다줌!
단어의 분산 표현은 단어를 고정 길이 벡터로 변환해준다는 장점도 있음.
게다가 문장(단어의 흐름)도 단어의 분산 표현을 사용하여 고정 길이 벡터로 변환 가능.
bag-of-words: 문장을 고정 길이 벡터로 변환하는 방법은 활발하게 연구되고 있는데, 이 중 가장 간단한 방법.
- 문장의 각 단어를 분산 표현으로 변환하고 그 합을 구하는 것.
- 단어의 순서를 고려하지 않는 모델.
또한 5장에서 설명하는 순환 신경망(RNN)을 사용하면 한층 세련된 방법으로 (word2vec의 단어의 분산 표현을 이용하면서) 문장을 고정 길이 벡터로 변환 가능.
단어나 문장을 고정 길이 벡터로 변환할 수 있다는 점은 매우 중요: 자연어를 벡터로 변환할 수 있다면 일반적인 머신러닝 기법 (신경망이나 SVM 등)을 적용할 수 있기 때문.
➡ 자연어로 쓰여진 질문을 고정 길이 벡터로 변환할 수 있다면, 그 벡터를 다른 머신러닝 시스템의 입력으로 사용 가능
➡ 자연어를 벡터로 변환함으로써 일반적인 머신러닝 시스템의 틀에서 원하는 답을 출력하는 것(그리고 학습하는) 가능
📜 NOTE
위의 파이프라인에서는 단어의 분산 표현 학습과 머신러닝 시스템의 학습은 서로 다른 데이터셋을 사용해 개별적으로 수행하는 것이 일반적.
1️⃣ 단어의 분산 표현은 위키백과와 같은 범용 말뭉치를 사용해 미리 학습해둠.
2️⃣ 그리고 현재 직면한 문제에 관련하여 수집한 데이터를 가지고 머신 러닝 시스템(SVM 등)을 학습시킴
다만, 직면한 문제의 학습 데이터가 아주 많다면 단어의 분산 표현과 머신러닝 시스템 학습 모두를 처음부터 수행하는 방안도 고려 가능.
정리
● Embedding 계층은 단어의 분산 표현을 담고 있으며, 순전파 시 지정한 단어 ID의 벡터를 추출한다.
● word2vec은 어휘 수의 증가에 비례하여 계산량도 증가하므로, 근사치로 계산하는 빠른 기법을 사용하면 좋다.
● 네거티브 샘플링은 부정적 예를 몇 개 샘플링하는 기법으로, 이를 이용하면 다중 분류를 이진 분류처럼 취급할 수 있다.
● word2vec으로 얻은 단어의 분산 표현에는 단어의 의미가 녹아들어 있으며, 비슷한 맥락에서 사용되는 단어는 단어 벡터 공간에서 가까이 위치한다.
● word2vec의 단어의 분산 표현을 이용하면 유추 문제를 벡터의 덧셈과 뺄셈으로 풀 수 있게 된다.
● word2vec은 전이 학습 측면에서 특히 중요하며, 그 단어의 분산 표현은 다양한 자연어 처리 작업에 이용할 수 있다.