해당 포스팅은 '밑바닥부터 시작하는 딥러닝2'를 공부하고 정리, 요약한 글입니다. 모든 내용은 해당 도서를 기준으로 합니다.
요즘에는 앞 장의 단순한 RNN 대신 LSTM이나 GRU라는 계층이 주로 쓰인다. RNN이라고 하면 앞 장의 RNN이 아니라 LSTM을 가리키는 경우도 흔하다.
LSTM이나 GRU에는 게이트라는 구조가 더해져 있는데 이 게이트 덕분에 시계열 데이터의 장기 의존 관계를 학습할 수 있다.
◼️ 6.1 RNN의 문제점
RNN은 시계열 데이터의 장기 의존 관계를 학습하기 어렵다. BPTT에서 기울기 소실 혹은 기울기 폭발이 일어나기 때문이다.
◾ 6.1.1 RNN 복습
RNN 계층은 시계열 데이터인 xt를 입력하면 ht를 출력한다. 이 ht는 RNN 계층의 은닉 상태라고 하여 과거 정보를 저장한다.
RNN의 특징은 바로 이전 시각의 은닉 상태를 이용한다는 점이다. 이때 RNN 계층이 수행하는 처리를 계산 그래프로 나타내면 아래처럼 된다.
RNN 계층의 순전파에서 수행하는 계산은 행렬의 곱과 합, 그리고 활성화 함수인 tanh 함수에 의한 변환으로 구성된다.
◾ 6.1.2 기울기 소실 또는 기울기 폭발
언어 모델은 주어진 단어들을 기초로 다음에 출현할 단어를 예측하는 일을 한다.
RNN 계층이 과거 방샹으로 의미 있는 기울기를 전달함으로써 시간 방향의 의존 관계를 학습할 수 있다.
◾ 6.1.3 기울기 소실과 기울기 폭발의 원인
+의 역전파는 상류에서 전해지는 기울기를 그대로 하류로 흘려보내기 때문에 기울기는 변하지 않는다.
역전파에서의 기울기가 tanh 노드를 지날 때마다 값은 계속 작아지게 된다.
RNN 계층의 역전파 시 기울기는 아래처럼 MatMul 연산에 의해서만 변화하게 된다.
# chap06/rnn_gradient_graph.py
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
font_path = 'C:/Windows/Fonts/malgun.ttf'
font_name = fm.FontProperties(fname=font_path).get_name()
plt.rc('font', family=font_name)
N = 2
H = 3
T = 20 # 시계열 데이터의 길이(= timestep)
dh = np.ones((N, H))
np.random.seed(3)
# Wh = np.random.randn(H, H)
Wh = np.random.randn(H, H) * 0.5
norm_list = []
for t in range(T):
dh = np.matmul(dh, Wh.T)
norm = np.sqrt(np.sum(dh**2)) / N
norm_list.append(norm)
u, s, vh = np.linalg.svd(Wh)
print(s)
위 그림에서 보듯 기울기의 크기는 시간에 비례해 지수적으로 증가함을 알 수 있다. 이것이 기울기 폭발이다.
이는 결국 오버플로를 일으며 Nan 같은 값을 발생시킨다.
위 그림은 기울기가 지수적으로 감소하며 이는 기울기 소실이다.
기울기가 매우 빠르게 작아지고 기울기가 일정 수준 이하로 작아지면 가중치 매개변수가 더 이상 갱신되지 않으므로 장기 의존 관계를 학습할 수 없게 된다.
◾ 6.1.4 기울기 폭발 대책
깅루기 폭발의 대책으로 전통적인 기법 중 기울기 클리핑이 있다.
신경망에서 사용되는 모든 매개변수에 대한 기울기를 하나로 처리한다고 가정하고 threshold를 문턱값으로 설정한다. 이 때 기울기의 L2 노름이 문턱값을 초과하면 두 번째 줄의 수식과 같이 기울기를 수정한다. 이것이 기울기 클리핑이다.
# chap06/clip_grads.py
import numpy as np
dW1 = np.random.rand(3, 3) * 10
dW2 = np.random.rand(3, 3) * 10
grads = [dW1, dW2]
max_norm = 5.0 # threshold
def clip_grads(grds, max_norm):
total_norm = 0
for grad in grads:
total_norm += np.sum(grad ** 2)
total_norm = np.sqrt(total_norm)
rate = max_norm / (total_norm + 1e-6)
if rate < 1:
for grad in grads:
grad *= rate
◼️ 6.2 기울기 소실과 LSTM
RNN 학습에서는 기울기 소실도 큰 문제이다. 이 문제를 해결하려면 RNN 계층의 아키텍처를 근본부터 뜯어고쳐야 한다. 여기서 등장하는 것이 게이트가 추가된 RNN이다.
◾ 6.2.1 LSTM의 인터페이스
위 그림에서 보듯 LSTM 계층의 인터페이스에는 c라는 경로가 있다는 차이가 있다. 이 c를 기억 셀이라 하며 LSTM 전용의 기억 메커니즘이다.
기억 셀의 특징은 데이터를 자기 자신으로만 주고받는다는 것이다.
LSTM 계층 내에서만 완결되고 다른 계층으로는 출력하지 않는다. 반면, LSTM의 은닉 상태 h는 RNN 계층과 마찬가지로 다른 계층으로 출력된다.
◾ 6.2.2 LSTM 계층 조립하기
LSTM에는 기억 셀 ct에는 시각 t에서의 LSTM의 기억이 저장돼 있는데 과거로부터 시각 t까지에 필요한 모든 정보가 저장돼 있다고 가정한다.
그리고 필요한 정보를 모두 간직한 이 기억을 바탕으로 외부 계층에 은닉 상태 ht를 출력한다. 이때 출력하는 ht는 아래 그림과 같이 기억 셀의 값을 tanh 함수로 변환한 값이다.
위 그림처럼 현재의 기억 셀 ct는 3개의 입력(ct-1, ht-1, x)으로부터 어떤 계산을 수행하여 구할 수 있다.
LSTM에서 사용하는 게이트는 열기/닫기뿐 아니라 어느 정도 열지를 조절할 수 있다. 다음 단계로 흘려보낼 물의 양을 제어하는 것이다.
게이트의 열림 상태는 0.0~1.0 사이의 실수로 나타낸다.
◾ 6.2.3 output 게이트
이 게이트는 다음 은닉 상태 ht의 출력을 담당하는 게이트이므로 output 게이트라고 한다.
output 게이트의 열림 상태는 아래과 같이 식으로 쓸 수 있다.
계산 그래프로는 다음처럼 그릴 수 있다.
위와 같은 output 게이트에서 수행하는 식은 다음과 같다. (아다마르 곱)
◾ 6.2.4 forget 게이트
다음에 해야 할 일은 기억 셀에 무엇을 잊을까를 명확히 지시하는 것이다
ct-1의 기억 중에서 불필요한 기억을 잊게 해주는 게이트를 forget 게이트라 부른다.
forget 게이트는 다음 식의 계산을 수행한다.
◾ 6.2.5 새로운 기억 셀
forget 게이트를 거치면서 이전 시각의 기억 셀로부터 잊어야 할 기억이 삭제되었다.
새로 기억해야 할 정보를 기억 셀에 추가해야 하며 아래 그림처럼 tanh 노드를 추가한다.
이 tanh 노드는 게이트가 아니며 새로운 정보를 기억 셀에 추가하는 것이 목적이다. 활성화 함수로는 시그모이드 함수가 아닌 tanh 함수가 사용된다.
이 tanh 노드에서 수행하는 계산은 다음과 같다.
◾ 6.2.6 input 게이트
위 6-17 그림의 g에 input 게이트를 추가하면 계산 그래프는 아래와 같이 된다.
input 게이트는 g의 각 원소가 새로 추가되는 정보로써의 가치가 얼마나 큰지를 판단한다. 적절히 취사선택하는 것이 이 게이트의 역할이다.
input 게이트에 의해 가중된 정보가 새로 추가되는 셈이다.
이때 수행하는 계산은 다음과 같다.
◾ 6.2.7 LSTM의 기울기 흐름
위 그림에서는 기억 셀에만 집중하여 그 역전파의 흐름을 그렸다.
기억 셀의 역전파에서는 +와 x 노드만 지나게 된다.
+노드는 상류에서 전해지는 기울기를 그대로 흘릴 뿐이며 기울기 변화(감소)는 일어나지 않는다.
남는 것은 x노드인데 이 노드는 행렬 곱이 아닌 원소별 곱을 계산한다.
이번 LSTM의 역전파에서는 행렬 곱이 아닌 원소별 곱이 이뤄지고 매 시각 다른 게이트 값을 이용해 원소별 곱을 계산한다. 이처럼 매번 새로운 게이트 값을 이용하므로 곱셈의 효과가 누적되지 않아 기울기 소실이 일어나지 않는 것이다.
위 6-19 그림의 x노드의 계산은 forget 게이트가 제어한다. forget 게이트가 잊어야 한다고 판단한 기억 셀의 원소에 대해서는 그 기울기가 작아지는 것이다.
forget 게이트가 잊어서는 안된다고 판단한 원소에 대해서는 그 기울기가 약화되지 않은 채로 과거 방향으로 전해진다.
◼️ 6.3 LSTM 구현
LSTM에서 수행하는 계산을 정리한 수식들을 나열하면 다음과 같다.
위 그림에서 보듯 4개의 가중치를 하나로 모을 수 있고 그렇게 하면 원래 개별적으로 총 4번을 수행하던 아핀 변환을 단 1회의 계산으로 끝마칠 수 있다.
Wx, Wh, b 각각에 4개분의 가중치가 포함되어 있다고 가정하고 이때의 LSTM을 계산 그래프로 그려보면 아래처럼 된다.
처음 4개분의 아핀 변환을 한꺼번에 수행하고 slice 노드를 통해 그 4개의 결과를 꺼낸다.
import sys
sys.path.append('..')
from common.np import *
from common.layers import *
from common.functions import sigmoid
class LSTM:
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, c_prev):
Wx, Wh, b = self.params
N, H = h_prev.shape
A = np.dot(x, Wx) + np.dot(h_prev, Wh) + b
f = A[:, :H]
g = A[:, H:2*H]
i = A[:, 2*H:3*H]
o = A[:, 3*H:]
f = sigmoid(f)
g = np.tanh(g)
i = sigmoid(i)
o = sigmoid(o)
c_next = f * c_prev + g * i # Ct
h_next = o * np.tanh(c_next)
self.cache = (x, h_prev, c_prev, i, f, g, o, c_next)
return h_next, c_next
def backward(self, dh_next, dc_next):
Wx, Wh, b = self.params
x, h_prev, c_prev, i, f, g, o, c_next = self.cache
tanh_c_next = np.tanh(c_next)
ds = dc_next + (dh_next * o) * (1 - tanh_c_next ** 2)
dc_prev = ds * f
di = ds * g
df = ds * c_prev
do = dh_next * tanh_c_next
dg = ds * i
di *= i * (1 - i)
df *= f * (1 - f)
do *= o * (1 - o)
dg *= (1 - g ** 2)
dA = np.hstack((df, dg, di, do))
dWh = np.dot(h_prev.T, dA)
dWx = np.dot(x.T, dA)
db = dA.sum(axis=0)
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
dx = np.dot(dA, Wx.T)
dh_prev = np.dot(dA, Wh.T)
return dx, dh_prev, dc_prev
slice 노드의 역전파에서는 4개의 행렬을 연결하며 4개의 기울기 df, dg, di, do를 연결 해서 dA를 만들었음을 위 그림을 통해 알 수 있다.
◾ 6.3.1 Time LSTM 구현
Time LSTM은 T개분의 시계열 데이터를 한꺼번에 처리하는 계층이다.
RNN에서는 학습할 때 Truncated BPTT를 수행했지만 이는 역전파의 연결은 적당한 길이로 끊지만 순저파의 흐름은 그대로 유지한다.
으닉 상태와 기억 셀을 인스턴스 변수로 유지하도록 하고 다음번에 forward()가 불렀을 때 이전 시각의 은닉 상태에서부터 시작할 수 있게 된다.
class TimeLSTM:
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.layers = None
self.h, self.c = None, None
self.dh = None
self.stateful = stateful
def forward(self, xs):
Wx, Wh, b = self.params
N, T, D = xs.shape
H = Wh.shape[0]
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')
if not self.stateful or self.c is None:
self.c = np.zeros((N, H), dtype='f')
for t in range(T):
layer = LSTM(*self.params)
self.h, self.c = layer.forward(xs[:, t, :], self.h, self.c)
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 = Wx.shape[0]
dxs = np.empty((N, T, D), dtype='f')
dh, dc = 0, 0
grads = [0, 0, 0]
for t in reversed(range(T)):
layer = self.layers[t]
dx, dh, dc = layer.backward(dhs[:, t, :] + dh, dc)
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
def set_state(self, h, c=None):
self.h, self.c = h, c
def reset_state(self):
self.h, self.c = None, None
◼️ 6.4 LSTM을 사용한 언어 모델
# chap06/rnnlm.py
import sys
sys.path.append('..')
from common.time_layers import *
from common.base_model import BaseModel
class Rnnlm(BaseModel):
def __init__(self, vocab_size=10000, wordvec_size=100, hidden_size=100):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
embed_W = (rn(V, D) / 100).astype('f')
lstm_Wx = (rn(D, 4 * H) / np.sqrt(D)).astype('f')
lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
lstm_b = np.zeros(4 * 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),
TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True),
TimeAffine(affine_W, affine_b)
]
self.loss_layer = TimeSoftmaxWithLoss()
self.lstm_layer = self.layers[1]
self.params, self.grads = [], []
for layer in self.layers:
self.params += layer.params
self.grads += layer.grads
def predict(self, xs):
for layer in self.layers:
xs = layer.forward(xs)
return xs
def forward(self, xs, ts):
score = self.predict(xs)
loss = self.loss_layer.forward(score, 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.lstm_layer.reset_state()
# chap06/train_rnnlm.py
import sys
sys.path.append('..')
from common.optimizer import SGD
from common.trainer import RnnlmTrainer
from common.util import eval_perplexity
from dataset import ptb
from rnnlm import Rnnlm
batch_size = 20
wordvec_size = 100
hidden_size = 100
time_size = 35
lr = 20.0
max_epoch = 4
max_grad = 0.25
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_test, _, _ = ptb.load_data('test')
vocab_size = len(word_to_id)
xs = corpus[:-1]
ts = corpus[1:]
model = Rnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)
trainer.fit(xs, ts, max_epoch, batch_size, time_size, max_grad,
eval_interval=20)
trainer.plot(ylim=(0, 500))
model.reset_state()
ppl_test = eval_perplexity(model, corpus_test)
print('테스트 퍼플렉서티: ', ppl_test)
# 매개변수 저장
model.save_params()
그림에서 첫번째 퍼블렉서티의 값이 10000.84인데 이는 다음에 나올 수 있는 후보 단어 수를 10000개 정도로 좁혔다는 뜻이다. 이번 데이터셋의 어휘 수가 10000개이므로 아직 아무것도 학습하지 않은 상태이며 예측을 대충 수행하게 된다.
◼️ 6.5 RNNLM 추가 개선
◾ 6.5.1 LSTM 계층 다층화
RNNLM으로 정확한 모델을 만들고자 한다면 많은 경우 LSTM 계층을 깊게 쌓아 효과를 볼 수 있다.
LSTM을 2층으로 쌓아 RNNLM을 만들면 위 그림처럼 된다.
◾ 6.5.2 드롭아웃에 의한 과적합 억제
층을 깊게 쌓음으로써 표현력이 풍부한 모델을 만들 수 잇다. 이런 모델은 종종 과적합을 일으킨다.
RNN은 일반적인 피드포워드 신경망보다 쉽게 과적합을 일으키기도 한다. RNN의 과적합 대책은 중요하고 현재도 활발하게 연구되는 주제이다.
과적합을 억제하는 전통적인 방법이 있다. 모델의 복잡도에 페널티를 주는 정규화도 효과적이며 드롭아웃처럼 훈련 시 계층 내의 뉴런 몇 개를 무작위로 무시하고 학습하는 방법도 있다.
드롭아웃은 무작위로 뉴런을 선택하여 선택한 뉴런을 무시한다. 그 앞 계층으로부터 신호 전달을 막는다는 뜻이다.
위 그림처럼 RNN을 사용한 모델에서는드롭아웃 계층을 LSTM 계층의 시계열 방향으로 삽입하는 것은 좋은 방법이 아니다.
RNN에서 시계열 방향으로 드롭아웃을 넣어버리면 시간이 흐름에 따라 정보가 사라질 수 있다. 흐르는 시간에 비례해 드롭아웃에 의한 노이즈가 축적된다.
이렇게 구성하면 시간 방향으로 아무리 진행해도 정보를 잃지 않는다. 드롭아웃이 시간축과는 독립적으로 깊이 방향에만 영향을 주는 것이다.
최근 연구에서는 RNN의 시간 방향 정규화를 목표로 하는 방법이 다양하게 제안되고 있다.
변형 드롭아웃은 깊이 방향은 물론 시간 방향에도 이용할 수 있어서 언어 모델의 정확도를 한층 더 향상시킬 수 있다.
같은 계층의 드롭아웃끼리 마스크를 공유함으로써 마스크가 고정된다.
◾ 6.5.3 가중치 공유
언어 모델을 개선하는 아주 간단한 트릭 중 가중치 공유가 있다.
Embedding 계층의 가중치와 Affine 계층의 가중치를 연결하는 기법이 가중치 공유이다. 두 계층이 가중치를 공유함으로써 학습하는 매개변수 수가 크게 줄어드는 동시에 정확도도 향상되는 일석이조의 기술이다.
◾ 6.5.4 개선된 RNNLM 구현
개선점은 다음과 같다.
- LSTM 계층의 다층화
- 드롭아웃 사용
- 가중치 공유
◼️ 6.6 정리
- 단순한 RNN의 학습에서는 기울기 소실과 기울기 폭발이 문제가 된다.
- 기울기 폭발에는 기울기 클리핑, 기울기 소실에는 게이트가 추가된 RNN이 효과적이다.
- LSTM에는 input 게이트, forget 게이트, ouput 게이트 등 3개의 게이트가 있다.
- 게이트에는 전용 가중치가 있으며 시그모이드 함수를 사용하여 0.0 ~ 1.0 사이의 실수를 출력한다.
- 언어 모델 개선에는 LSTM 계층 다층화, 드롭아웃, 가중치 공유 등의 기법이 효과적이다.
- RNN의 정규화는 중요한 주제이며 드롭아웃 기반의 다양한 기법이 제안되고 있다.