썸네일 출처: 사진: 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
가 나오고,da
dp0.5
를 곱해서 입력 측MatMul
층으로 보낸다.None
을 리턴하는 이유는 순전파와 역전파를 실행하는 것 만으로도 이미grads
리스트의 기울기가 갱신되기 때문이다.
다음으로 위에서 사용된 SoftmaxWithLoss
층에 대한 분석이다.
common.layers.SoftmaxWithLoss
class 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_error
def 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.py
preprocess
# 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_target
def 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
값을 얻고, 리턴한다.