
썸네일 출처: 사진: Unsplash의Riccardo Chiarini
이전 포스팅에서는 1세대와 2세대 텍스트 전처리 방법에 대해 알아보았다. 이번에는 3세대인 예측 기반 텍스트 전처리 모델 두 가지를 살펴보려고 한다. 본격적으로 소개하기 앞서 1세대와 2세대에 대한 간략한 요약과 3세대로 이어지는 흐름에 대해 정리할 것이다.
1세대는 아주 정직하고 단순한 방법으로 단어를 벡터화하였다. 이런 식이다.
I am a boy, you are a girl.
| 단어 | ID | 벡터 |
|---|---|---|
| I | 0 | [1,0,0,0,0,0,0,0,0] |
| am | 1 | [0,1,0,0,0,0,0,0,0] |
| a | 2 | [0,0,1,0,0,0,0,0,0] |
| boy | 3 | [0,0,0,1,0,0,0,0,0] |
| , | 4 | [0,0,0,0,1,0,0,0,0] |
| you | 5 | [0,0,0,0,0,1,0,0,0] |
| are | 6 | [0,0,0,0,0,0,1,0,0] |
| girl | 7 | [0,0,0,0,0,0,0,1,0] |
| . | 8 | [0,0,0,0,0,0,0,0,1] |
아래 문장을 행렬로 바꿔보면 다음과 같다.
I am a boy, you are a girl.
[[1,0,0,0,0,0,0,0,0],
[0,1,0,0,0,0,0,0,0],
[0,0,1,0,0,0,0,0,0],
[0,0,0,1,0,0,0,0,0],
[0,0,0,0,1,0,0,0,0],
[0,0,0,0,0,1,0,0,0],
[0,0,0,0,0,0,1,0,0],
[0,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0],
[0,0,0,0,0,0,0,0,1]]
이 방식은 낭비되는 공간이 너무 많고, 심지어 벡터가 그렇게 많은 정보를 담고 있지도 않다. 이를 보완하는 것이 2세대 통계 기반(count-based) 기법이다.
2세대는 1세대와 달리 벡터가 의미를 담고 있다. 하나의 단어의 바로 앞, 뒤 이웃 단어를 통해 문맥 정보를 벡터에 담을 수 있다.
I am a boy, you are a girl.
| I | am | a | boy | , | you | are | girl | . | |
|---|---|---|---|---|---|---|---|---|---|
| I | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| am | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
| a | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 1 | 0 |
| boy | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 |
| , | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 |
| you | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 |
| are | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 |
| girl | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 |
| . | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
이렇게 만들어진 벡터는 코사인 유사도 계산을 할 수 있다. 즉, 어떤 단어들이 서로 더 유사한 지를 객관적으로 판단할 수 있게된 것이다! 또, PPMI행렬로 변환하고 다시 SVD로 차원을 축소시킴으로써 '희소행렬'에서 '밀집행렬'로 변환할 수 있다.
하지만 동시발생행렬도 만능은 아니다. 크기(: 단어 수)의 행렬을 차원축소하는 데 드는 시간 복잡도는 O()에 달하며, 통상 O()만 되어도 쓰레기 취급을 받는다. 참조
단어 수가 100만 개가 넘어가면 어떻게 벡터로 변환을 할 수 있을까? 답은 3세대 추론 기반(prediction-based) 기법이다.
주변 단어(문맥)가 주어졌을 때 '?'에 들어갈 단어를 추측한다. (CBOW)
you ❓ goodbye and I say hello.
중앙의 단어(타깃)로부터 주변의 여러 단어(문맥)을 추측한다.(Skip-gram)
❓ say ❓ and I say hello.
위와 같이 추론 문제를 풀고 학습하는 것이 '추론 기반 기법'이 다루는 문제이다. 모델은 추론 문제를 반복해서 풀며 단어의 확률분포를 학습한다.

여기서 모델은 신경망(Neural Network) 모델이다. 모델이 맥락(context)을 학습하여 맥락 상 ❓에 들어올 확률을 단어 별로 출력한다. 처음엔 가중치에 아무 의미없는 랜덤한 숫자들을 넣었기 때문에 정답과 거리가 먼 확률 분포를 나타낸다. 정답과의 차이인 loss값을 줄이는 방향으로 점차 학습을 진행하다보면 우리가 의도한 대로 정답('say') 단어가 가장 높은 확률을 가진 확률 분포가 완성된다. 이렇게 학습시킨 뒤 나온 가중치를 가지고 이제는 정답을 가르쳐주지 않는 test를 진행한다. 전체적인 과정을 살펴보았으니 세부적인 실행 코드와 함께 작동 원리를 파헤쳐보자.

V)
W_in)의 shape은 V x H이다.(V는 단어 개수에 해당하는 7,H는 은닉층의 뉴런의 수인 3)
W_in)의 행렬곱을 한 결과(h0,h1)가 2개 나온다.(입력층이 2개이기 때문)h = 0,5*(h0+h1))W_in의 shape이 입력층의 뉴런 수와 은닉층의 뉴런 수에 따라 결정된다. H)
W_out)을 행렬곱하면 출력층의 score가 계산된다.

class MatMul:
def __init__(self, W):
self.params = [W]
self.grads = [np.zeros_like(W)]
self.x = None
def forward(self, x):
W, = self.params
out = np.dot(x, W)
self.x = x
return out
def backward(self, dout):
W, = self.params
dx = np.dot(dout, W.T)
dW = np.dot(self.x.T, dout)
self.grads[0][...] = dW
return dx
- 파라미터
W는 Weight matrix를 의미한다.mm = MatMul()클래스를 호출하는 순간self.params,self.grads,self.x가 초기화된다.mm.forward()메소드는 순전파 계산을 수행한다.
-x를 파라미터로 받고 있다.
-x는W와 행렬곱을 수행할 입력층 또는 은닉층 벡터이다.
-x와W의 행렬곱 결과out을 리턴한다.mm.backward()메소드는 역전파 계산을 수행한다.
- 입력 파라미터dout는 이전 레이어로부터 전파된 미분값을 의미한다.
-W, = self.params에서self.params는 레이어 내의 파라미터값을 저장한 리스트 또는 튜플이다. 여기서는 튜플 언패킹을 통해W에 첫 번째 파라미터(가중치 행렬)을 저장한다.
-dx = np.dot(dout, W.T)에서dout과W의 전치행렬을 내적하는 것은dout을W에 대해 미분한 것과 같다.
-dW = np.dot(self.x.T, dout)에서x의 전치행렬과dout을 내적하는 것은dout을x에 대해 미분한 것과 같다.
-self.grads[0][...] = dW는 입력층의 가중치에 대한 그래디언트를self.grads리스트의 첫 번째 요소에 저장한다.self.grads[0]은 가중치에 대한 그래디언트를 나타내며,[...]를 사용하여 해당 리스트 내용을 dW로 덮어쓴다.(shap유지, 내용만 덮어씀)
-return dx입력값에 대한 미분값 dx를 반환한다. 이 미분값은 이전 레이어로 전파될 것이며, 전체 신경망의 역전파 과정 중에 사용된다.
MatMul층을 활용해 초기 가중치 값으로 정답을 예측하는 과정이다.# coding: utf-8
import sys
sys.path.append('..')
import numpy as np
from common.layers import MatMul
# 샘플 맥락 데이터
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)
# 순전파
h0 = in_layer0.forward(c0)
h1 = in_layer1.forward(c1)
h = 0.5 * (h0 + h1)
s = out_layer.forward(h)
print(s)
c0,c1은 주변 단어의 원핫벡터이다.W_in은VxH사이즈의 랜덤한 값으로 채워진 초기 입력층 가중치 값이다.V는 전체 단어 수,H는 은닉층의 뉴런 수(벡터 차원 수)이다.W_out은HxV사이즈의 랜덤한 값으로 채워진 초기 입력층 가중치 값이다.in_layer0은W_in을 곱하는 합성곱층을 의미한다.in_layer1은in_layer0과 같다. 두번째 맥락(context)데이터에 적용할 합성곱층이다.out_layer은W_out을 곱하는 합성곱층을 의미한다.h0은 은닉 벡터를 구하기 위한 중간단계로,c0데이터에in_layer0층을forward()메소드로 통과시킨 값이다.h1도 은닉 벡터를 구하기 위한 중간단계로,c1데이터에in_layer1층을forward()메소드로 통과시킨 값이다.h는 은닉벡터이며, 앞서 구한h0과h1의 평균값이다.s는 앞서 구한 은닉벡터h에out_layer를forward()메소드로 통과시킨 값으로, 중심 단어 예측 결과 중 score의 형태이다. (확률 구하기 위한 전 단계)
import sys
sys.path.append('..')
import numpy as np
from common.layers import MatMul, 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')
# 계층 생성
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.layers import MatMul, SoftmaxWithLoss:
MatMul모듈은 앞에서 설명한 합성곱층이고,SoftmaxWithLoss는 앞서 얻은 스코어를 확률로 만들고 loss를 구하는 단계이다. SoftmaxWithLoss 참조class SimpleCBOW:: 클래스 명은
SimpleCBOW로, 후에 완전한 CBOW를 구현할 것이기 때문에 간략화된 버전이라고 볼 수있다.def __init__(self, vocab_size, hidden_size): V, H = vocab_size, hidden_size: 클래스가 호출되는 동시에 실행되는 초기화 메소드
__init__()의 입력 파라미터는 단어의 수인vocab_size와 은닉층의 뉴런 수(벡터 차원 수)인hidden_size이다.model = SimpleCBOW(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'): 입력 가중치 행렬(
W_in)과 출력 가중치 행렬(W_out)을V와H의 shape에 따라 랜덤한 값을 넣어 초기화한다. 이 때 데이터 타입은 32bit 부동소수점 실수이다. 64bit가 아닌 32bit를 사용하는 이유는 공간 효율성 때문이다.np.randmo.randn()은 평균이 0이고 표준편차가 1인 표준정규분포에 따라 난수를 생성하는 메소드이고,0.01을 곱한 것은 최대 자릿수가 소수점 아래 둘째자리에 오도록 설정한 것이다.# 계층 생성 self.in_layer0 = MatMul(W_in) self.in_layer1 = MatMul(W_in) self.out_layer = MatMul(W_out) self.loss_layer = SoftmaxWithLoss(): 입력 측의
MatMul레이어를 2개, 출력 층의MatMul레이어를 1개, 그리고SoftmaxWithLoss레이어를 1개 생성한다. 여기서 입력 측의 맥락(context)을 처리하는MatMul레이어의 개수는 윈도우 크기의 2배이다. 또, 입력측MatMul레이어 두 개는 모두 같은 가중치를 이용하도록 초기화한다.# 모든 가중치와 기울기를 리스트에 모은다. 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:
MatMul층을 거치며 저장된 매개변수와 기울기를 인스턴스 변수인params와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: 신경망의 순전파인
forward()메소드를 구현한다. 이 메소드는 파라미터로 맥락(contexts)과 타깃(target)을 받아 손실(loss)을 반환한다.h0에서 사용하는forward()메소드는MatMul층에서 정의된 순전파 메소드이다. 여기서 인수contexts는 [그림 3-18]의 경우shape이(6,2,7)이다.6은 맥락이 6쌍 있다는 뜻이고,2는 window의 크기가 1이므로 두 배인 2개라는 뜻이고,7은 원핫벡터로 변환할 때 말뭉치 내의 총 단어 수가 7개라는 뜻이다 .
따라서contexts[:,0]이 의미하는 바는 타깃(중앙 단어)의 왼쪽에 있는 맥락(주변 단어)의 원핫 벡터만 추출한 6개의 미니배치이다. shape은(6,1,7)이다.
contexts[:,1]이 의미하는 바는 타깃(중앙 단어)의 오른쪽에 있는 맥락(주변 단어)의 원핫 벡터만 추출한 6개의 미니배치이다. shape은(6,1,7)이다.
이렇게 한번에 배치 처리로h0과h1을 구한 뒤 평균을 내면 은닉 벡터인h를 빠르게 구할 수 있다. 은닉벡터h를out_layer에 통과 시켜score를 구하고,score를loss_layer에 통과시켜score를 확률로 바꾼 뒤(softmax)target과의 차이를cross_entropy_error로 계산하여 최종적으로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: 마지막으로 역전파인
backward()를 구성한다. [그림 3-10]은 역전파의 계산 그래프이다.
신경망의 역전파는 기울기를 순전파와 반대 방향으로 전파한다.1에서 시작해SoftmaxWithLoss층을 통과하여ds가 출력된다. 그 다음ds를 출력층(MatMul)에 입력하여da가 나오고,dadp0.5를 곱해서 입력 측MatMul층으로 보낸다.None을 리턴하는 이유는 순전파와 역전파를 실행하는 것 만으로도 이미grads리스트의 기울기가 갱신되기 때문이다.
다음으로 위에서 사용된 SoftmaxWithLoss층에 대한 분석이다.
common.layers.SoftmaxWithLossclass SoftmaxWithLoss:
def __init__(self):
self.params, self.grads = [], []
self.y = None # softmax의 출력
self.t = None # 정답 레이블
def forward(self, x, t):
self.t = t
self.y = softmax(x)
# 정답 레이블이 원핫 벡터일 경우 정답의 인덱스로 변환
if self.t.size == self.y.size:
self.t = self.t.argmax(axis=1)
loss = cross_entropy_error(self.y, self.t)
return loss
def backward(self, dout=1):
batch_size = self.t.shape[0]
dx = self.y.copy()
dx[np.arange(batch_size), self.t] -= 1
dx *= dout
dx = dx / batch_size
return dx
class SoftmaxWithLoss::
softmax와cross_entropy_error를 합쳐 한번에loss를 구하는 층이다.def __init__(self): self.params, self.grads = [], [] self.y = None # softmax의 출력 self.t = None # 정답 레이블:
SoftmaxWithLoss클래스를 호출하면 동시에 실행되는__init__()메소드이다. 입력 파라미터는 없고, 신경망의 파라미터들과 기울기를 저장하는self.params,self.grads를 빈 리스트로 초기화하며softmax의 출력을 저장하는self.y와 정답 레이블을 저장하는self.t도None으로 초기화된다.def forward(self, x, t): self.t = t self.y = softmax(x) # 정답 레이블이 원핫 벡터일 경우 정답의 인덱스로 변환 if self.t.size == self.y.size: self.t = self.t.argmax(axis=1) loss = cross_entropy_error(self.y, self.t) return loss: 순전파를 진행하는
forward()메소드이다.softmax결과값을 저장하는x, 타깃값을 저장하는t를 입력 파라미터로 가진다.t가 원핫 벡터라면 0~6 사이의 label로 변환해준다.argmax()함수는 python 내장 함수로, 벡터에서 가장 큰 값을 가진 요소의 인덱스를 반환한다.(axis = 1)은 열방향으로 탐색하는 것을 의미한다. 그 다음softmax()결과 값이 저장된y와 정답 label인t를 입력하여cross_entropy_error()를 구해준다. loss값은loss에 저장해준 뒤 리턴한다.def backward(self, dout=1): batch_size = self.t.shape[0] dx = self.y.copy() dx[np.arange(batch_size), self.t] -= 1 dx *= dout dx = dx / batch_size return dx: 역전파 함수의 입력 파라미터는
dout값으로, 출력층에서 전달된 그래디언트(기울기)를 의미한다. 기본값은 1이다.batch_size는 한번에 처리할 입력값의 양이다.self.t.shape[0]은 [그림 3-18]에서의 타깃의shape인(6,7)에서6에 해당한다.dx는 score를 확률로 바꾼y를 얕게 복사하여(copy()) 저장했다. 그 이유는 그래디언트를 계산하는 과정에서 중간 결과를 저장하고 사용하기 위해서이다.
🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟🧟

softmax
def softmax(x):
if x.ndim == 2:
x = x - x.max(axis=1, keepdims=True)
x = np.exp(x)
x /= x.sum(axis=1, keepdims=True)
elif x.ndim == 1:
x = x - np.max(x)
x = np.exp(x) / np.sum(np.exp(x))
return x
softmax 함수 식을 구현한 코드이다.
1.if x.ndim == 2:: 입력x의 차원이 2차원인 경우에는 다음 단계를 수행한다. 이 경우는 주로 배치(batch)로 처리되는 다중 클래스 분류 문제에 적용된다.
x = x - x.max(axis=1, keepdims=True): 각 행(row)의 최댓값을 해당 행의 모든 원소에서 빼주어 값의 크기를 조절한다. 이렇게 표준화함으로써 너무 큰 값으로 인한 수치적인 불안정성을 방지할 수 있다.x = np.exp(x): 모든 원소에 지수 함수를 적용하여 양수로 만든다. 이는 확률 분포를 만들기 위한 과정 중 하나이다.x /= x.sum(axis=1, keepdims=True): 각 행의 원소를 해당 행의 모든 원소의 합으로 나누어 확률 분포를 만든다. 이렇게 함으로써 각 클래스에 대한 확률 값을 얻을 수 있다.
elif x.ndim == 1:: 입력x의 차원이 1차원인 경우에는 다음 단계를 수행한다. 이 경우는 주로 단일 데이터 포인트에 대한 다중 클래스 분류 문제에 적용된다.
x = x - np.max(x): 입력 배열의 최댓값을 해당 배열의 모든 원소에서 빼주어 값의 크기를 조절한다.x = np.exp(x) / np.sum(np.exp(x)): 각 원소에 지수 함수를 적용하여 양수로 만든 후, 모든 원소의 합으로 나누어 확률 분포를 만든다. 이 과정은 입력 데이터가 단일 데이터 포인트인 경우에 적용된다.return x: 계산된 softmax 확률 분포를 반환한다. 반환된 값은 입력x의 각 클래스에 대한 확률 값으로 해석될 수 있다.
이렇게 구현된 softmax 함수는 다중 클래스 분류 문제에서 모델의 출력을 확률 분포로 변환하여 예측을 수행하는 데 사용된다.
cross_entropy_errordef cross_entropy_error(y, t):
if y.ndim == 1:
t = t.reshape(1, t.size)
y = y.reshape(1, y.size)
# 정답 데이터가 원핫 벡터일 경우 정답 레이블 인덱스로 변환
if t.size == y.size:
t = t.argmax(axis=1)
batch_size = y.shape[0]
return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size
cross entropy error 함수 식을 구현한 코드이다.
1.if y.ndim == 1:: 출력y의 차원이 1차원인 경우를 처리한다. 이 경우는 주로 단일 데이터 포인트에 대한 처리를 위한 것이다.
t = t.reshape(1, t.size): 정답 레이블t를 2차원 배열로 변환한다. 이렇게 함으로써 단일 데이터 포인트에 대한 처리를 일반화할 수 있습니다.y = y.reshape(1, y.size): 출력y도 2차원 배열로 변환한다.
if t.size == y.size:: 정답 데이터t가 원핫 벡터(one-hot vector)인 경우를 처리한다. 원핫 벡터는 하나의 정답 레이블만 1로 표시하고 나머지는 0으로 표시하는 방식으로 표현된다. 이 경우, 원핫 벡터에서 정답 레이블의 인덱스로 변환된다.
t = t.argmax(axis=1):argmax함수를 사용하여 가장 큰 값의 인덱스를 찾아서 정답 레이블을 해당 인덱스로 변환한다.batch_size = y.shape[0]: 배치 크기(batch size)를 계산한다.y의 첫 번째 차원의 크기로 배치 크기를 구한다. 이 값은 한 번에 처리되는 데이터 포인트의 수를 나타낸다.-np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size: 교차 엔트로피 오차를 계산한다.
np.arange(batch_size): 0부터batch_size-1까지의 정수 배열을 생성한다. 이 배열은 배치 내의 데이터 포인트를 나타낸다.y[np.arange(batch_size), t]:y배열에서 정답 레이블t에 해당하는 값을 선택한다. 이는 각 데이터 포인트에서의 신경망 출력의 확률값을 나타낸다.np.log(y[np.arange(batch_size), t] + 1e-7): 선택된 확률값에 로그를 취한다. 이는 교차 엔트로피 오차의 핵심 부분이다.-np.sum(...): 로그를 취한 값들을 모두 더한 후, 부호를 반대로 바꾸어 오차를 계산한다./ batch_size: 배치 크기로 나누어 각 데이터 포인트에 대한 평균 오차를 계산한다.
# coding: utf-8
import sys
sys.path.append('..') # 부모 디렉터리의 파일을 가져올 수 있도록 설정
from common.trainer import Trainer
from common.optimizer import Adam
from simple_cbow import SimpleCBOW
from common.util import preprocess, create_contexts_target, convert_one_hot
window_size = 1
hidden_size = 5
batch_size = 3
max_epoch = 1000
text = 'You say goodbye and I say hello.'
corpus, word_to_id, id_to_word = preprocess(text)
vocab_size = len(word_to_id)
contexts, target = create_contexts_target(corpus, window_size)
target = convert_one_hot(target, vocab_size)
contexts = convert_one_hot(contexts, vocab_size)
model = SimpleCBOW(vocab_size, hidden_size)
optimizer = Adam()
trainer = Trainer(model, optimizer)
trainer.fit(contexts, target, max_epoch, batch_size)
trainer.plot()
word_vecs = model.word_vecs
for word_id, word in id_to_word.items():
print(word, word_vecs[word_id])
학습 데이터를 전처리(
preprocess(text))해 신경망에 입력한 다음, 기울기를 구하고(SimpleCBOW()) 가중치 매개변수를 순서대로 갱신(Trainer(),.fit())한다.

반복 수가 늘어날 수록
loss값이 점차 감소하는 것을 확인할 수 있다.
다음은 학습이 끝난 후의 가중치 매개변수이다.

이 가중치 매개변수는 단어의 의미가 잘 담겨있고, 공간 효율성이 높게 밀집되어있는 분산 표현이라 볼 수 있다.
util.pypreprocess# coding: utf-8
import sys
sys.path.append('..')
import os
from common.np import *
def preprocess(text):
text = text.lower()
text = text.replace('.', ' .')
words = text.split(' ')
word_to_id = {}
id_to_word = {}
for word in words:
if word not in word_to_id:
new_id = len(word_to_id)
word_to_id[word] = new_id
id_to_word[new_id] = word
corpus = np.array([word_to_id[w] for w in words])
return corpus, word_to_id, id_to_word
preprocess는 입력으로 받은 텍스트를 소문자로 변환하고 문장 내의 마침표를 분리한 후, 각 단어를 고유한 정수로 매핑하고 이를 코퍼스로 반환하는 역할을 한다.
create_contexts_targetdef create_contexts_target(corpus, window_size=1):
'''맥락과 타깃 생성
:param corpus: 말뭉치(단어 ID 목록)
:param window_size: 윈도우 크기(윈도우 크기가 1이면 타깃 단어 좌우 한 단어씩이 맥락에 포함)
:return:
'''
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)
create_context_target()함수는 주어진 코퍼스에서 주어진 윈도우 크기 내에서의 맥락과 그에 해당하는 타깃을 생성한다. 윈도우 크기(window_size)는 맥락의 범위를 결정하며, 타깃은 해당 맥락 내에서 중심에 위치하는 단어이다. 주어진 코퍼스를 기반으로 맥락과 타깃을 생성하고 반환한다.
convert_one_hot
def convert_one_hot(corpus, vocab_size):
'''원핫 표현으로 변환
:param corpus: 단어 ID 목록(1차원 또는 2차원 넘파이 배열)
:param vocab_size: 어휘 수
:return: 원핫 표현(2차원 또는 3차원 넘파이 배열)
'''
N = corpus.shape[0]
if corpus.ndim == 1:
one_hot = np.zeros((N, vocab_size), dtype=np.int32)
for idx, word_id in enumerate(corpus):
one_hot[idx, word_id] = 1
elif corpus.ndim == 2:
C = corpus.shape[1]
one_hot = np.zeros((N, C, vocab_size), dtype=np.int32)
for idx_0, word_ids in enumerate(corpus):
for idx_1, word_id in enumerate(word_ids):
one_hot[idx_0, idx_1, word_id] = 1
return one_hot
convert_one_hot()함수는 주어진 코퍼스(corpus)를 원핫 표현으로 변환한다. 이 때, 코퍼스의 차원에 따라 2차원 또는 3차원 넘파이 배열로 원핫 표현을 생성한다. 원핫 표현은 어휘 크기(vocab_size)에 해당하는 차원을 가지며, 각 단어를 해당 단어의 인덱스에만 1로 표시하고 나머지는 0으로 표시하는 방식으로 구현된다.
trainer.py# coding: utf-8
import sys
sys.path.append('..')
import numpy
import time
import matplotlib.pyplot as plt
from common.np import * # import numpy as np
from common.util import clip_grads
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 = numpy.random.permutation(numpy.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
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
이 코드는 신경망 모델을 훈련시키는
Trainer클래스를 정의한 것이다. 이 클래스는 주어진 모델과 최적화기(optimizer)를 사용하여 데이터를 훈련하고, 훈련 중에 발생하는 손실(loss)을 기록한다.
1.__init__메서드: Trainer 클래스의 생성자로, 모델과 optimizer를 초기화하고 손실(loss)을 저장할 리스트(loss_list) 및 평가 간격(eval_interval)을 초기화한다.
2.fit메서드: 데이터를 훈련하는 메서드로, 다음과 같은 인자를 받는다.
x: 입력 데이터t: 정답 레이블max_epoch: 최대 에폭(epoch) 수batch_size: 미니배치 크기max_grad: 그래디언트 클리핑을 위한 임계값eval_interval: 평가 간격- 메소드는 다음과 같이 동작한다.
- 데이터를 섞는다.
- 미니배치를 만들어 가중치를 업데이트하고 손실을 계산한다.
- 정해진 평가 간격(
eval_interval)마다 손실과 경과 시간을 출력한다.- 손실 값을
loss_list에 추가한다.

CBOW와 다르게 중앙 단어로부터 주변 단어를 예측하는 것이 Skip-gram의 작동 방식이다. 말뭉치의 크기가 더 커지면 Skip-gram이 더 유리하다.
# coding: utf-8
import sys
sys.path.append('..')
import numpy as np
from common.layers import MatMul, SoftmaxWithLoss
class SimpleSkipGram:
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')
# 계층 생성
self.in_layer = MatMul(W_in)
self.out_layer = MatMul(W_out)
self.loss_layer1 = SoftmaxWithLoss()
self.loss_layer2 = SoftmaxWithLoss()
# 모든 가중치와 기울기를 리스트에 모은다.
layers = [self.in_layer, 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_layer.forward(target)
s = self.out_layer.forward(h)
l1 = self.loss_layer1.forward(s, contexts[:, 0])
l2 = self.loss_layer2.forward(s, contexts[:, 1])
loss = l1 + l2
return loss
def backward(self, dout=1):
dl1 = self.loss_layer1.backward(dout)
dl2 = self.loss_layer2.backward(dout)
ds = dl1 + dl2
dh = self.out_layer.backward(ds)
self.in_layer.backward(dh)
return None
class SimpleSkipGram: def __init__(self, vocab_size, hidden_size): V, H = vocab_size, hidden_size:
Skip_gram을 간단하게 구현한 클래스이다. 초기화 메소드에는vocab_size와hidden_size가 입력 인스턴스로 필요하고, 클래스 내에서 각각V,H에 저장된다.# 가중치 초기화 W_in = 0.01 * np.random.randn(V, H).astype('f') W_out = 0.01 * np.random.randn(H, V).astype('f') # 계층 생성 self.in_layer = MatMul(W_in) self.out_layer = MatMul(W_out) self.loss_layer1 = SoftmaxWithLoss() self.loss_layer2 = SoftmaxWithLoss(): 가중치 초기화는
SimpleCBOW와 동일하고, 다른 점은 입력 측 합성곱층이 한 개이고,loss층이 두 개라는 것이다. 그림과는 다르게W_out합성곱층은 한 개이고, 하나의 스코어만을 구한 뒤loss에서 2개로 나누어지는 것이다.# 모든 가중치와 기울기를 리스트에 모은다. layers = [self.in_layer, self.out_layer] self.params, self.grads = [], [] for layer in layers: self.params += layer.params self.grads += layer.grads # 인스턴스 변수에 단어의 분산 표현을 저장한다. self.word_vecs = W_in: 가중치와 기울기가 포함된
in_layer와out_layer에 대해 순회하며params와grads를 따로 리스트에 저장한다. 입력 가중치 행렬(W_in)은 말뭉치 내 단어들의 의 분산 표현과 같다. 원핫 벡터로 변환한 뒤W_in과 행렬곱을 수행하면 'you'는 [1,0,0,0,0,0,0]⋅W_in이므로W_in의 첫 번째 행이 'you'의 벡터 표현이 되고 다른 단어들도 마찬가지로W_in의idx번째 행을 벡터 표현으로 가져가기 때문에W_in자체가 말뭉치(corpus)의 분산 표현 행렬이 되는 것이다.
def forward(self, contexts, target): h = self.in_layer.forward(target) s = self.out_layer.forward(h) l1 = self.loss_layer1.forward(s, contexts[:, 0]) l2 = self.loss_layer2.forward(s, contexts[:, 1]) loss = l1 + l2 return loss: 순전파 메소드에서 입력 파라미터는 주변 단어(contexts)와 중심 단어(target)이다. skip-gram은 중심 단어로부터 주변 단어를 예측하는 것임을 기억하자. 따라서
target을in_layer에 통과시킨 것을h은닉 벡터에 저장하고,h를out_layer로 통과시킨s스코어를 구한다. 그림과 달리 스코어를 구하는 것까지는 하나인데,loss층을 거치며 두 개(window_size* 2)로 나누어진다. 미니 배치 학습으로 이루어지기 때문에contexts행렬에서 첫 번째 맥락에 해당하는 단어를 첫 번째l1층에s와 함께 통과시키고 두 번째 맥락은s와l2층을 통과시킨다. 이렇게 나온 두l1,l2를 더하여loss값을 얻고, 리턴한다.