지금까지 살펴본 신경망은 피드포워드라는 유형의 신경망이다. 피드포워드란 흐름이 단방향인 신경망을 말한다. 이 구조는 단점이 있는데, 바로 시계열 데이터를 잘 다루지 못하는 것이다. 그래서 순환 신경망(RNN)이 등장하게 된다. 이번 장에서는 피드포워드 신경망의 문제점을 지적하고, RNN이 그 문제를 해결할 수 있음을 살펴본다. 또한 RNN을 구현해본다.
확률과 언어 모델
word2vec을 확률 관점에서 바라보다.
word2vec의 CBOW 모델부터 복습해보자. 여기서는 $w_{1}$, $w_{2}$,…, $w_{T}$라는 단어열로 표현되는 말뭉치를 생각해보자. 그리고 t번째 단어를 타깃으로, 그 전후 단어(t-1번째와 t+1번째)를 맥락으로 취급해보자.
$w_{t-1}$과 $w_{t+1}$이 주어졌을 때 타깃이 $w_{t}$가 될 확률을 수식으로 나타내보면 아래와 같다.
이번에는 맥락을 왼쪽 윈도우만으로 한정해보자.
왼쪽 두 단어만을 맥락으로 생각하면 확률은 아래와 같다.
왼쪽 두 단어를 맥락으로 할 경우 교차 엔트로피 오차는 아래와 같다.
CBOW 모델의 학습으로 수행하는 일은 위 식의 손실 함수를 최소화하는 가중치 매개변수를 찾는 것이다. 그러면 맥락으로부터 타깃을 정확하게 추측할 수 있다. 이렇게 얻은 값은 언어 모델에 적용할 수 있게 된다.
언어 모델
언어 모델은 단어 나열에 확률을 부여한다. 특정한 단어의 시퀀스에 대해서, 그 시퀀스가 일어날 가능성이 어느 정도인지를 확률로 평가하는 것이다. 예를들어 “you say goodbye”라는 단어 시퀀스에는 높은 확률을 출력하고, “you say good die”에는 낮은 확률을 출력하는 것이 일종의 언어 모델이다.
이 언어모델은 다양하게 응용할 수 있다. 기계 번역과 음성 인식이 대표적인 예이다. 음성 인식 시스템의 경우, 사람의 음성으로부터 몇 개의 문장을 후보로 생성할 것이다. 그런 다음 언어 모델을 사용하여 후보 문장이 문장으로써 자연스러운지를 기준으로 순서를 매길 수 있다.
또한 언어 모델은 새로운 문장을 생성하는 용도로도 이용할 수 있다. 언어 모델은 단어 순서의 자연스러움을 확률적으로 평가할 수 있으므로, 그 확률분포에 따라 다음으로 적합한 단어를 샘플링 할 수 있기 때문이다.
언어모델을 수식으로 설명해보자. $w_{1}$, $w_{2}$,…, $w_{m}$이라는 m개 단어로 된 문장을 생각해보자. 이 때 단어가 $w_{1}$, $w_{2}$,…, $w_{m}$라는 순서로 출현할 확률을 P($w_{1}$, $w_{2}$,…, $w_{m}$)으로 나타낸다. 이 확률은 여러 사건이 동시에 일어날 확률이므로 동시 확률이라고 한다.
이 동시 확률은 사후 확률을 사용하여 다음과 같이 분해하여 쓸 수 있다.
여기에서 주목할 것은 이 사후 확률은 타깃 단어보다 왼쪽에 있는 모든 단어를 맥락으로 했을 때의 확률이라는 것이다. 즉, 사후 확률을 구하면 동시확률을 구할 수 있기 때문에 우리의 목표는 사후 확률을 구하는 것이다.
CBOW 모델을 언어 모델로?
CBOW 모델을 언어 모델에 적용하려면 어떻게 하면 좋을까? 이는 맥락의 크기를 특정 값으로 한정하여 근사적으로 나타낼 수 있다. 여기에서는 맥락을 왼쪽 2개의 단어로 한정한다. 그러면 CBOW 모델에 따라 근사적으로 나타낼 수 있다.
이 맥락의 크기는 특정 길이로 고정된다. 예를 들어 왼쪽 10개의 단어를 맥락으로 CBOW 모델을 만든다고 하면, 그 맥락보다 더 왼쪽에 있는 단어의 정보는 무시된다. 이것이 문제가 될 때가 있는데, 아래와 같은 경우이다. 이 문맥을 고려하면 답은 “Tom”이다. 정답을 구하려면 “?”로부터 18번째나 앞에 나오는 “Tom”을 기억해야한다. 만약 CBOW 모델의 맥락이 10개 까지 였다면 이 문제에 답할 수 없다.
CBOW 모델의 맥락 크기를 키우더라도 다른 문제가 발생할 수 있다. CBOW 모델에서는 맥락 안의 단어 순서가 무시된다는 한계가 있다. 예를들어 맥락으로 2개의 단어를 다루는 경우, CBOW 모델에서는 이 2개의 단어 벡터의 ‘합’이 은닉층에 온다. 은닉층에서는 단어 벡터들이 더해지므로 (you, say)와 (say, you)라는 맥락을 똑같이 취급한다.
이상적으로는 맥락의 단어 순서도 고려한 모델이 바람직할 것이다. 이를 위해 위 그림의 오른쪽처럼 맥락의 단어 벡터를 은닉층에서 연결하는 방식을 생각할 수 있다. 그러나 연결하는 방식을 취하면 맥락의 크기에 비례해 가중치 매개변수도 늘어나게 된다. 즉, 맥락의 크기를 키우면 키울수록 계산량이 매우 커지게 된다.
이 문제를 해결하기 위해 등장하는 것이 순환 신경망(RNN)이다. RNN은 맥락이 아무리 길더라도 그 맥락의 정보를 기억하는 매거니즘을 갖추고 있다. 그래서 RNN을 사용하면 아무리 긴 시계열 데이터라도 대응할 수 있다.
RNN이란
순환하는 신경망
RNN의 특징은 순환하는 경로가 있다는 것이다. 데이터가 순환되기 때문에 과거의 정보를 기억하는 동시에 최신 데이터로 갱신될 수 있는 것이다. RNN 계층은 아래처럼 그릴 수 있다. 시계열 데이터 ($x_{0}$, $x_{1}$,…, $x_{t}$,…)가 RNN 계층에 입력되고, 그 입력에 대응하여 ($h_{0}$, $h_{1}$,…, $h_{t}$,…)가 출력된다.
순환 구조 펼치기
RNN 계층의 순환 구조를 펼침으로써 오른쪽으로 성장하는 긴 신경망으로 만들 수 있다. 그림에서 볼 수 있듯이 각 시각의 RNN 계층은 그 계층으로의 입력과 1개 전의 RNN 계층으로부터의 출력을 받는다. 그리고 이 두 정보를 바탕으로 현 시각의 출력을 계산한다. 이 때 수행하는 계산의 수식은 아래와 같다. RNN에는 가중치가 2개($W_{x}$ , $W_{h}$)와 편향이 1개(b)있다.
BPTT
RNN에서의 오차역전파법을 BPTT라고 한다. 이 BPTT를 이용하면 RNN을 학습할 수 있을 듯 보인다. 하지만 그 전에 해결해야 할 문제가 하나 있다. 그것은 바로 긴 시계열 데이터를 학습할 때의 문제이다. 시계열 데이터의 시간 크기가 커지는 것에 비례하여 BPTT가 소비하는 컴퓨터 자원도 증가한다. 또한 시간 크기가 커지면 역전파 시의 기울기가 불안정해지는 것도 문제다.
Truncated BPTT
큰 시계열 데이터를 취급할 때는 흔히 신경망 연결을 적당한 길이로 끊는다. 그리고 이 잘라낸 작은 신경망에서 오차역전파법을 수행한다. 이것이 바로 Truncated BPTT라는 기법이다.
Truncated BPTT에서는 신경망의 연결을 끊지만, 제대로 구현하려면 역전파의 연결만 끊고, 순전파의 연결은 유지해야한다. 즉, 순전파의 흐름은 끊어지지 않고 전파된다. 한편, 역전파의 연결은 적당한 길이로 잘라내, 그 잘라낸 신경망 단위로 학습을 수행한다.
예시로, 길이가 1000인 시계열 데이터를 생각해보자. 길이가 100인 시계열 데이터의 RNN 계층을 펼치면 계층이 가로로 1000개나 늘어선 신경망이 된다. 이렇게 신경망이 길어지면 계산량과 메모리 사용량 등이 문제가 된다. 또한, 계층이 길어짐에 따라 신경망을 하나 통과할 때마다 기울기 값이 조금씩 작아져서, 이전 시각 t까지 역전파되기 전에 0이 되어 소멸할 수도 있다. 이런 이유로 역전파에서는 연결을 적당한 길이로 끊는것이다. 아래 그림에서는 10개 단위로 끊어주었다.
Truncated BPTT의 미니배치 학습
미니배치가 있는 경우에는 학습이 어떻게 수행될까. 데이터를 주는 시작 위치를 각 미니배치의 시작 위치로 옮겨줘야한다. 예시로 길이가 1000개인 시계열 데이터에 대해 미니배치의 수를 두 개로 구성해 학습하는 경우를 보자. 이 경우 RNN 계층의 입력 데이터로, 첫 번째 미니배치 때는 처음부터 순서대로 데이터를 제공한다. 그리고 두 번째 미니배치 때는 500번째의 데이터를 시작 위치로 정하고, 그 위치부터 다시 순서대로 데이터를 제공하는 것이다. 그림처럼 첫 번째 미니배치 원소는 $x_{0}$, $x_{1}$,…, $x_{9}$가 되고, 두 번째 미니배치 원소는 $x_{500}$, $x_{501}$,…, $x_{509}$가 된다. 이 미니배치 데이터를 RNN의 입력 데이터로 사용해 학습을 수행한다. 이후로는 순서대로 진행되므로 다음에 넘길 데이터는 각각 시계열 데이터의 10 ~ 19번째 데이터와 510 ~ 510번째의 데이터가 되는 것이다.
RNN 구현
우리가 구현할 신경망은 아래와 같다. 입력은 길이가 T인 시계열 데이터이다. 그리고 각 시각의 은닉 상태를 T개 출력한다.
모듈화를 생각해, 위의 신경망을 하나의 계층으로 구현한다. ($x_{0}$, $x_{1}$,…, $x_{T-1}$)을 묶은 xs를 입력하면 ($h_{0}$, $h_{1}$,…, $h_{T-1}$)을 묶은 hs를 출력하는 단일 계층으로 볼 수 있다. 이 대 Time RNN 계층 내에서 한 단계의 작업을 수행하는 계층을 ‘RNN 계층’이라 하고, T개 단계분의 작업을 한꺼번에 처리하는 계층을 ‘Time RNN 계층’이라 한다.
RNN 계층 구현
RNN 계층을 먼저 구현해보자. RNN의 순전파 식은 아래와 같다.
여기에서 데이터를 미니배치로 모아 한번에 처리한다. 각 요소들의 형상들을 생각하면 아래와 같다.
RNN 계층의 계산 그래프는 아래와 같다. 이를 바탕으로 RNN 클래스를 구현해보자.
class RNN:
def __init__(self, Wx, Wh, b):
self.params = [Wx, Wh, b]
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.cache = None
def forward(self, x, h_prev):
Wx, Wh, b = self.params
t = np.matmul(h_prev, Wh) + np.matmul(x, Wx) + b
h_next = np.tanh(t)
self.cache = (x, h_prev, h_next)
return h_next
def backward(self, dh_next):
Wx, Wh, b = self.params
x, h_prev, h_next = self.cache
dt = dh_next * (1 - h_next ** 2)
db = np.sum(dt, axis=0)
dWh = np.matmul(h_prev.T, dt)
dh_prev = np.matmul(dt, Wh.T)
dWx = np.matmul(x.T, dt)
dx = np.matmul(dt, Wx.T)
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
return dx, dh_prev
Time RNN 계층 구현
Time RNN 계층은 T개의 RNN 계층으로 구성된다. 여기서는 RNN 계층의 은닉 상태 h를 인스턴스 변수로 유지한다. 이 변수를 아래 그림처럼 은닉 상태를 인계받는 용도로 이용한다. 그리고 은닉 상태를 인계받을지에 대한 유무를 stateful이라는 인수로 조정한다. stateful이 True인 경우에는 아무리 긴 시계열 데이터라도 Time RNN계층의 순전파를 끊지 않고 전파한다는 의미다. 한편 stateful이 False일 때는 은닉 상태를 영행렬로 초기화한다.
Time RNN 계층의 역전파의 계산 그래프는 아래와 같다. 출력층에서 전해지는 기울기를 dhs로 쓰고, 입력층으로 내보내는 기울기를 dxs로 쓴다.
더 구체적으로 들어가서 t번째 RNN 계층의 역전파에 주목하면 아래와 같다. t 번째 RNN 계층에서는 위로부터의 기울기 $dh_{t}$와 한 시각 뒤의 계층으로 부터의 기울기 $dh_{next}$가 전해진다. 여기서 주의점은 RNN 계층의 순전파에서는 출력이 2개로 분기된다는 것이다. 순전파시 분기했을 경우, 그 역전파에서는 각 기울기가 합산되어 전해진다. 따라서 역전파 시 RNN 계층에는 합산된 기울기 ($dh_{t}$ + $dh_{next}$)가 입력된다.
class TimeRNN:
def __init__(self, Wx, Wh, b, stateful=False):
self.params = [Wx, Wh, b]
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.h, self.dh = None, None
self.stateful = stateful
def set_state(self, h):
self.h = h
def reset_state(self):
self.h = None
def forward(self, xs):
Wx, Wh, b = self.params
N, T, D = xs.shape
D, H = Wx.shape
self.layers = []
hs = np.empty((N, T, H), dtype='f')
if not self.stateful or self.h is None:
self.h = np.zeros((N, H), dtype='f')
for t in range(T):
layer = RNN(*self.params)
self.h = layer.forward(xs[:, t, :], self.h)
hs[:, t, :] = self.h
self.layers.append(layer)
return hs
def backward(self, dhs):
Wx, Wh, b = self.params
N, T, H = dhs.shape
D, H = Wx.shape
dxs = np.empty((N, T, D), dtype='f')
dh = 0
grads = [0, 0, 0]
for t in reversed(range(T)):
layer = self.layers[t]
dx, dh = layer.backward(dhs[:, t, :] + dh)
dxs[:, t, :] = dx
for i, grad in enumerate(layer.grads):
grads[i] += grad
for i, grad in enumerate(grads):
self.grads[i][...] = grad
self.dh = dh
return dxs
시계열 데이터 처리 계층 구현
위에서 구현한 RNN계층을 이용해서, 언어모델을 만들어보자. RNN을 사용한 언어 모델은 영어로 RNN Language Model이므로 앞으로 RNNLM으로 칭한다.
RNNLM의 전체 그림
아래 그림은 가장 단순한 RNNLM의 신경망을 그려본 것이다. 왼쪽은 RNNKM 계층 구성이고, 오른쪽에는 이를 시간축으로 펼친 신경망이다.
첫 번째 층은 Embedding 계층이다. 이 계층은 단어 ID를 단어의 분산 표현으로 변환한다. 그리고 그 분산 표현이 RNN 계층으로 입력된다. RNN 계층은 은닉 상태를 다음 층으로 출력함과 동시에, 다음 시각의 RNN 계층으로 출력한다. 그리고 RNN 계층이 위로 출력한 은닉 상태는 Affine 계층을 거쳐 Softmax 계층으로 전해진다.
Time 계층 구현
지금까지 시계열 데이터를 한꺼번에 처리하는 계층을 Time RNN이라는 이름의 계층으로 구현했다. 이번 절에서도 시계열 데이터를 한꺼번에 처리하는 계층을 Time Embedding, Time Affine 형태의 이름으로 구현한다. Time Affine 계층과 Time Embedding 계층은 단순히 T개의 계층을 준비하고, 각 계층이 각 시각의 데이터를 처리한다.
from common.layers import Embedding
class TimeAffine:
def __init__(self, W, b):
self.params = [W, b]
self.grads = [np.zeros_like(W), np.zeros_like(b)]
self.x = None
def forward(self, x):
N, T, D = x.shape
W, b = self.params
rx = x.reshape(N*T, -1)
out = np.dot(rx, W) + b
self.x = x
return out.reshape(N, T, -1)
def backward(self, dout):
x = self.x
N, T, D = x.shape
W, b = self.params
dout = dout.reshape(N*T, -1)
rx = x.reshape(N*T, -1)
db = np.sum(dout, axis=0)
dW = np.dot(rx.T, dout)
dx = np.dot(dout, W.T)
dx = dx.reshape(*x.shape)
self.grads[0][...] = dW
self.grads[1][...] = db
return dx
class TimeEmbedding:
def __init__(self, W):
self.params = [W]
self.grads = [np.zeros_like(W)]
self.layers = None
self.W = W
def forward(self, xs):
N, T = xs.shape
V, D = self.W.shape
out = np.empty((N, T, D), dtype='f')
self.layers = []
for t in range(T):
layer = Embedding(self.W)
out[:, t, :] = layer.forward(xs[:, t])
self.layers.append(layer)
return out
def backward(self, dout):
N, T, D = dout.shape
grad = 0
for t in range(T):
layer = self.layers[t]
layer.backward(dout[:, t, :])
grad += layer.grads[0]
self.grads[0][...] = grad
return None
시계열 버전의 Softmax를 살펴보자. Softmax 계층을 구현할 때는 손실 오차를 구하는 Cross Entropy Error 계층도 함께 구현한다.
위 그림에서 $x_{0}$, $x_{1}$ 등의 데이터는 아래층에서부터 전해지는 점수(확률로 정규화되기 전의 값)를 나타낸다. 또한 $t_{0}$, $t_{1}$ 등의 데이터는 정답 레이블을 나타낸다. 그림에서 보듯, T개의 Softmax with Loss 계층 각각이 손실을 산출한다. 그리고 그 손실들을 합산해 평균한 값이 최종 손실이 된다. 이때 수행하는 계산의 수식은 아래와 같다.
from common.layers import softmax
class TimeSoftmaxWithLoss:
def __init__(self):
self.params, self.grads = [], []
self.cache = None
self.ignore_label = -1
def forward(self, xs, ts):
N, T, V = xs.shape
if ts.ndim == 3: # 정답 레이블이 원핫 벡터인 경우
ts = ts.argmax(axis=2)
mask = (ts != self.ignore_label)
# 배치용과 시계열용을 정리
xs = xs.reshape(N * T, V)
ts = ts.reshape(N * T)
mask = mask.reshape(N * T)
ys = softmax(xs)
ls = np.log(ys[np.arange(N * T), ts])
ls *= mask
loss = -np.sum(ls)
loss /= mask.sum()
self.cache = (ts, ys, mask, (N, T, V))
return loss
def backward(self, dout=1):
ts, ys, mask, (N, T, V) = self.cache
dx = ys
dx[np.arange(N * T), ts] -= 1
dx *= dout
dx /= mask.sum()
dx *= mask[:, np.newaxis]
dx = dx.reshape((N, T, V))
return dx
RNNLM 학습과 평가
RNNLM 구현
RNNLM에서 사용하는 신경망을 SimpleRnnlm이라는 이름의 클래스로 구현한다. 계층 구성은 아래와 같다.
class SimpleRnnlm:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
# 가중치 초기화, Xavier 초깃값 사용
embed_W = (rn(V, D) / 100).astype('f')
rnn_Wx = (rn(D, H) / np.sqrt(D)).astype('f')
rnn_Wh = (rn(H, H) / np.sqrt(H)).astype('f')
rnn_b = np.zeros(H).astype('f')
affine_W = (rn(H, V) / np.sqrt(H)).astype('f')
affine_b = np.zeros(V).astype('f')
# 계층 생성
self.layers = [
TimeEmbedding(embed_W),
TimeRNN(rnn_Wx, rnn_Wh, rnn_b, stateful=True),
TimeAffine(affine_W, affine_b)
]
self.loss_layer = TimeSoftmaxWithLoss()
self.rnn_layer = self.layers[1]
# 모든 가중치와 기울기를 리스트에 모은다.
self.params, self.grads = [], []
for layer in self.layers:
self.params += layer.params
self.grads += layer.grads
def forward(self, xs, ts):
for layer in self.layers:
xs = layer.forward(xs)
loss = self.loss_layer.forward(xs, ts)
return loss
def backward(self, dout=1):
dout = self.loss_layer.backward(dout)
for layer in reversed(self.layers):
dout = layer.backward(dout)
return dout
def reset_state(self):
self.rnn_layer.reset_state()
언어 모델의 평가
언어 모델의 평가는 어떻게 해야할까. 언어 모델은 주어진 과거 단어로부터 다음에 출현할 단어의 확률분포를 출력한다. 이때 언어 모델의 예측 성능을 평가하는 척도로 퍼플렉서티를 자주 이용한다. 퍼플렉서티는 간단히 말하면 확률의 역수이다. 예를 들어 “you say goodbye and I say hello .”말뭉치를 생각해보자. ‘모델 1’의 언어 모델에 ‘you’라는 단어를 주니 왼쪽과 같은 확률분포를 출력했다고 해보자. 그리고 정답이 “say”라면, 그 확률은 0.8이다. 이 때의 퍼플렉서티는 이 확률의 역수, 즉 1/0.8 = 1.25로 계산할 수 있다. 한편 ‘모델 2’는 정답인 “say”의 확률이 0.2라고 예측했다. 이때의 퍼플렉서티는 1/0.2 = 5이다. 즉, 퍼플렉서티는 작을수록 예측이 잘 된 것이다.
그렇다면 1.25나 5.0이라는 값은 직관적으로는 어떻게 해석할 수 있을까. 이 값은 분기수로 해석할 수 있다. 분기수란 다음에 취할 수 있는 선택사항의 수이다. 좋은 모델이 예측한 분기수가 1.25라는 것은 다음에 출현할 수 있는 단어의 후보를 1개 정도로 좁혔다는 뜻이 된다. 반면 나쁜 모델에서는 후보가 아직 5개나 된다는 의미이다.
입력데이터가 여러개일때는 어떻게 될까. 이럴때는 다음 공식에 따라 계산한다. N은 데이터의 총개수이다. $t_{nk}$는 n개째 데이터의 k번재 값을 의미한다. 그리고 $y_{nk}$는 확률분포를 나타낸다. L은 신경망의 손실을 뜻하고, 이 L을 사용해 $e^L$을 계산한 값이 곧 퍼플렉서티이다.
RNNLM의 학습 코드
PTB 데이터셋을 이용해 RNNLM학습을 수행해보자. PTB 데이터의 첫 1000개 단어만 이용한다.
import matplotlib.pyplot as plt
import numpy as np
from common.optimizer import SGD
from dataset import ptb
# 하이퍼파라미터 설정
batch_size = 10
wordvec_size = 100
hidden_size = 100 # RNN의 은닉 상태 벡터의 원소 수
time_size = 5 # truncated BPTT가 한 번에 펼치는 시간 크기
lr = 0.1
max_epoch = 100
# 학습 데이터 읽기(전체 중 1000개만)
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_size = 1000
corpus = corpus[:corpus_size]
vocab_size = int(max(corpus) + 1)
xs = corpus[:-1] # 입력
ts = corpus[1:] # 출력(정답 레이블)
data_size = len(xs)
print(f'말뭉치 크기: {corpus_size}, 어휘 수: {vocab_size}')
# 학습 시 사용하는 변수
max_iters = data_size // (batch_size * time_size)
time_idx = 0
total_loss = 0
loss_count = 0
ppl_list = []
# 모델 생성
model = SimpleRnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
# 각 미니배치에서 샘플을 읽기 시작 위치를 계산
jump = (corpus_size - 1) // batch_size
offsets = [i * jump for i in range(batch_size)]
for epoch in range(max_epoch):
for iter in range(max_iters):
# 미니배치 획득
batch_x = np.empty((batch_size, time_size), dtype='i')
batch_t = np.empty((batch_size, time_size), dtype='i')
for t in range(time_size):
for i, offset in enumerate(offsets):
batch_x[i, t] = xs[(offset + time_idx) % data_size]
batch_t[i, t] = ts[(offset + time_idx) % data_size]
time_idx += 1
# 기울기를 구하여 매개변수 갱신
loss = model.forward(batch_x, batch_t)
model.backward()
optimizer.update(model.params, model.grads)
total_loss += loss
loss_count += 1
# 에폭마다 퍼플렉서티 평가
ppl = np.exp(total_loss / loss_count)
print(f'| 에폭 {epoch + 1} | 퍼플렉서티 {ppl:.2f}')
ppl_list.append(float(ppl))
total_loss, loss_count = 0, 0
#---------------------출력---------------------#
# 말뭉치 크기: 1000, 어휘 수: 418
# | 에폭 1 | 퍼플렉서티 402.99
# | 에폭 2 | 퍼플렉서티 292.55
# | 에폭 3 | 퍼플렉서티 234.53
# ...
# | 에폭 99 | 퍼플렉서티 5.97
# | 에폭 100 | 퍼플렉서티 5.85
x = np.arange(len(ppl_list))
plt.plot(x, ppl_list, label='train')
plt.xlabel('epochs')
plt.ylabel('perplexity')
plt.show()
학습을 수행할 때 기존의 데이터와 거의 비슷하지만 두 가지가 다르다. 바로 ‘데이터 제공 방법’과 ‘퍼플렉서티 계산’부분이다.
우선 데이터 제공 방법에 대해 알아보자. 여기서 Truncated BPTT 방식으로 학습을 수행한다. 따라서 데이터는 순차적으로 주고 각각의 미니배치에서 데이터를 읽는 시작 위치를 조정해야한다. 각 미니배치가 데이터를 읽기 시작하는 위치를 계산해 offsets에 저장한다. 이어서 데이터를 순차적으로 읽어준다.
퍼플렉서티를 계산하여 학습이 잘되고 있는지 평가한다. 에폭마다 손실의 평균을 구하고, 그 값을 사용해 퍼플렉서티를 구한다. 위의 그림을 보면 학습이 진행됨에 따라 퍼플렉서티가 줄어드는 것을 볼 수 있다. 이번에는 크기가 작은 말뭉치로 실험했다. 이 모델을 크기가 큰 말뭉치에 사용하면 좋은 결과를 얻을 수 없다. 다음장에서 현재 RNNLM의 문제를 개선한다.