본 포스팅은 밑바닥부터 시작하는 딥러닝2을 토대로 공부한 내용을 정리하기 위한 포스팅입니다.
해당 도서에 나오는 Source Code 및 자료는 GitHub를 참조하여 진행하였습니다.
https://github.com/WegraLee/deep-learning-from-scratch-2
6.0 학습 목표
이번 장에서는 RNN에 게이트가 추가된 구조인 LSTM과 GRU에 대해서 공부해본다. 앞 장에서 배운 RNN은 기본적인 구조이기에 장기 의존 관계를 잘 학습할 수 없기 때문에 성능이 좋지 못하다. 따라서 RNN의 문제점에 대해 알아보고, LSTM이 게이트를 사용하여 어떻게 RNN의 문제점을 해결하였는지에 대해 초점을 두고 학습해보도록 한다.
6.1 RNN의 문제점
앞 장에서 구현한 RNNLM은 [그림 6-4]처럼 기울기를 전파한다. 학습은 BPTT로 수행되고, 정답 레이블이 "Tom"이라고 주어진 시점으로부터 과거 방향으로 기울기를 전달하게 된다.
이때 기울기는 학습해야 할 의미가 있는 정보가 들어 있고, 그것을 과거로 전달함으로써 장기 의존 관계를 학습한다. 하지만 만약 이 기울기가 중간에 소실되는 기울기 소실(vanishing gradients) 현상이 일어난다면, 가중치 매개변수는 전혀 갱신되지 않게 되고, 장기 의존 관계를 학습할 수 없게 된다. 반대로 기울기가 커지는 기울기 폭발(exploding gradients) 현상이 일어나도 오버 플로가 발생해 학습이 불가능해진다.
6.1.3 기울기 소실과 기울기 폭발의 원인
[그림 6-5]와 같이 RNN 계층에서의 시간 방향 기울기 전파에 주목해보자. 역전파로 전해지는 기울기는 차례로 ‘tanh', '+', 'MatMul(행렬 곱)’ 연산을 통과한다는 것을 알 수 있다. 이때 ‘+'의 역전파는 상류에서 전해지는 기울기를 그대로 하류로 흘려보낼 뿐이므로 생략 가능하다. 따라서 ’tanh'와 'MatMul'이 기울기를 변화시키는 원인임을 알 수 있다.
우선 y = tanh(x)의 미분은 아래와 같다.
이때 y = tanh(x) 이므로 [식 6.0]을 정리하면 1 - y^2 으로 생각할 수 있고, y = tanh(x)와 그 미분 값을 각각 그래프로 나타내면 [그림 6-6]처럼 된다.
보다시피 미분 값은 1.0이하이고, x가 0에서 멀어질 수록 값이 작아짐을 알 수 있다. 따라서 역전파에서는 기울기가 tanh 노드를 지날 때마다 값은 계속 작아진다는 것을 알 수 있고, tanh 함수를 T번 통과하면 기울기도 T번 반복해서 작아지게 되는 것이다.
이번에는 MatMul(행렬 곱)노드에 대해 알아보자. tanh 노드를 무시하고 이해하면, RNN 계층의 역전파 시 기울기는 [그림 6-7]과 같이 ‘MatMul' 연산에 의해서만 변화하게 된다.
이때 MatMul 노드에서의 역전파는 dhWh T라는 행렬 곱으로 기울기를 계산한다. 그리고 같은 계산을 시계열 데이터의 시간 크기만큼 반복한다. 여기에서 주목할 점은 이 행렬 곱셈에서는 매번 똑같은 가중치인 Wh가 사용된다는 것이다. 즉, [그림 6-8]에서 보듯 기울기의 크기는 시간에 비례해 지수적으로 증가함을 알 수 있다. 이것이 바로 기울기 폭발(exploding gradients)이다. 이러한 기울기 폭발이 일어나면 결국 오버플로를 일으켜 NaN(Not a Number) 같은 값을 발생시키게 된다. 따라서 신경망 학습을 제대로 수행할 수 없게 된다.
Wh의 초깃값을 0.5배 하여 학습을 진행하게 되면 기울기가 지수적으로 감소하게 된다. 이것이 기울기 소실(vanishing gradients)이다. 기울기 소실이 일어나면 기울기가 매우 빠르게 작아지게 되고, 일정 수준 이하로 작아지면 가중치 매개변수가 더 이상 갱신되지 않게 된다. 그렇게 되면 장기 의존 관계를 학습할 수 없게 된다.
6.1.4 기울기 폭발 대책
exploding gradients의 대책으로는 기울기 클리핑(gradients clipping)이라는 기법이 있다. gradients clipping은 매우 단순하며, 그 알고리즘을 의사 코드로 쓰면 다음과 같다.
여기에서는 신경망에서 사용되는 모든 매개변수에 대한 기울기를 하나로 처리한다고 가정하고, 이를 기호 g(cap)으로 표기했다. 그리고 threshold를 문턱값으로 설정한다. 이때 기울기의 L2 norm이 문턱값을 초과하면 두 번째 줄의 수식과 같이 기울기를 수정한다. gradients clipping을 파이썬으로 구현하면 아래와 같다.
import numpy as np
dW1 = np.random.randn(3, 3) * 10
dW2 = np.random.randn(3, 3) * 10
grads = [dW1, dW2]
max_norm = 5.10
def clip_grads(grads, 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 학습에서 vanishing gradients를 해결하려면 RNN 계층의 구조를 근본부터 뜯어고쳐야 한다. 여기서 등장하는 것이 게이트를 추가하는 것이다. 이번 절에서는 LSTM에 집중하여 그 구조를 이해하면서 LSTM이 vanishing gradients을 일으키지 않는다는 사실을 배워보도록 한다.
[그림 6-11]에서 보듯 LSTM 계층의 인터페이스에는 c라는 경로가 있다는 차이가 있다. 이 c를 기억 셀(memory cell)이라 하며, LSTM 전용의 기억 메커니즘 역할을 한다.
memory cell의 특징은 데이터를 자기 자신으로만 주고받는다는 것이다. 즉, LSTM 계층 내에서만 완결되고, 다른 계층으로는 출력하지 않는다. 반면, LSTM의 은닉 상태 h는 RNN 계층과 마찬가지로 다른 계층으로 출력된다.
LSTM의 출력 값인 ht는 memory cell인 ct를 tanh 함수로 변환한 값이다. 즉, ht = tanh(ct)이다. 이는 ct의 각 요소에 tanh 함수를 적용한다는 뜻이다.
추가로 LSTM에서 사용하는 게이트는 시그모이드 함수를 사용하여 그 출력 한계인 0.0~1.0 사이의 실수의 값을 가진다. 게이트는 데이터의 열림 상태(openness)를 제어하는 역할을 한다. 여기서 중요한 것은 ‘게이트를 얼마나 열지’도 데이터로부터 자동으로 학습한다는 점이다.
6.2.3 output gate
output gate는 tanh(ct)의 각 원소에 대해 ‘그것이 다음 시각의 은닉 상태에 얼마나 중요한가’를 조정한다. 수식으로 나타내면 아래와 같다.
그래서 LSTM의 마지막 출력은 tanh(ct)와 output gate의 곱으로 계산된다. 여기서 말하는 곱은 아다마르 곱(Hadamard product)를 말하며, 원소별 곱을 의미한다.
이상이 LSTM의 최종 출력이 된다.
6.2.4 forget gate
forget gate(망각 게이트)는 c_t-1의 기억 중에서 불필요한 기억을 잊게 해주는 게이트이다. forget gate를 LSTM 계층에 추가하면 계산 그래프가 [그림 6-16]처럼 된다.
forget gate는 [식 6.3]의 계산을 수행한다.
6.2.5 새로운 memory cell
foreget gate를 거치면서 이전 시각의 memory cell로부터 잊어야 할 기억이 삭제되었다. 그래서 새로 기억해야 할 정보를 memory cell에 추가하는 노드가 필요하다.
[그림 6-17]에서 보듯 tanh 노드는 ‘gate'가 아니며, 새로운 ’정보‘를 memory cell에 추가하는 것이 목적이다. 따라서 활성화 함수로는 시그모이드 함수가 아닌 tanh 함수가 사용된다. 이 tanh 노드에서 수행하는 계산은 다음과 같다.
이 식의 출력인 g가 이전 시각의 memory cell인 c_t-1에 더해짐으로써 새로운 기억이 생겨난다.
6.2.6 input gate
마지막으로 [그림 6-17]의 g에 gate를 하나 추가한다. 이를 input gate라 하며, input gate를 추가하면 계산 그래프가 [그림 6-19]처럼 변한다.
input gate는 g의 각 원소가 새로 추가되는 정보로써의 가치가 얼마나 큰지를 판단한다. 새 정보를 무비판적으로 수용하는게 아니라, 적절히 취사선택하는 것이 이 gate의 역할이다.
6.2.7 LSTM의 기울기 흐름
[그림 6-19]는 memory cell에만 집중하여, 그 역전파의 흐름을 그린 것이다. 이때 memory cell의 역전파에서는 ‘+'와 ’x‘ 노드만을 지나게 되는데, ’+' 노드는 상류에서 전해지는 기울기를 그대로 흘릴 뿐이므로 기울기 변화가 일어나지 않는다.
남는 것은 ‘x‘ 노드인데, 이 노드는 ’행렬 곱‘이 아닌 ’원소별 곱(Hadamard product)'을 계산한다. 즉, 이번 LSTM의 역전파에서는 ‘행렬 곱’이 아닌 ‘원소별 곱’이 이뤄지고, 매 시각 다른 게이트 값을 이용해 원소별 곱을 계산한다. 이처럼 매번 새로운 게이트 값을 이용하므로 곱셈의 효과가 누적되지 않아 vanishing gradients가 일어나지 않는 것이다.
6.3 LSTM 구현
다음은 LSTM에서 수행하는 계산을 정리하는 수식들이다.
[식 6.6]의 네 수식에서는 아핀 변환(Affine Transformation)을 개별적으로 수행하지만, 이를 하나의 식으로 정리해 계산할 수 있다.
[그림 6-20]에서 보듯 4개의 가중치를 하나로 모을 수 있고, 그렇게 하면 원래 개별적으로 총 4번을 수행하던 Affine Transformation을 단 1회의 계산으로 끝마칠 수 있다. 이때의 LSTM을 계산 그래프로 그려보면 [그림 6-21]처럼 된다.
[그림 6-21]에서 보듯, slice 노드를 통해 4개의 Affine Transformation 결과를 꺼낸다. slice 노드 다음에는 활성화 함수를 거쳐 계산을 진행한다.
class LSTM:
def __init__(self, Wx, Wh, b):
self.parmas = [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.matmul(x, Wx) + np.matmul(h_prev, Wh) + b
# slice
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
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
forward에서는 가장 먼저 Affine Transformation을 한다. 이때 인스턴스 변수 Wx, Wh, b에는 각각 4개분의 매개변수가 저장되어 있으며, 이 변환을 행렬의 형상으로 그려보면 [그림 6-22]처럼 된다.
이 Affine Transformation 결과로부터 데이터를 꺼낼 때는 A[:, :H]나 A[:, H:2*H] 형태로 슬라이스해서 꺼내고, 꺼낸 데이터를 다음 연산 노드에 분배한다.
backward에서 slice 노드는 반대로 4개의 기울기를 결합해야 한다.
[그림 6-23]에서 보듯 slice 노드의 역전파에서는 4개의 행렬을 연결한다. 이를 넘파이로 수행하려면 np.hstack() 메서드를 사용하면 된다. np.hstack()은 인수로 주어진 배열들을 가로로 연결한다.
6.3.1 Time LSTM 구현
Time LSTM은 T개분의 시계열 데이터를 한꺼번에 처리하는 계층이다. 전체 그림은 [그림 6-24]와 같이 T개의 LSTM 계층으로 구성된다.
RNN에서 사용한 Truncated BPTT를 Time LSTM에도 사용한다. 때문에 순전파의 흐름은 그대로 유지하지만, [그림 6-25]처럼 은닉 상태와 memory cell을 인스턴스 변수로 유지하도록 한다. 이렇게 하여 다음번에 forward()가 불렸을 때, 이전 시각의 은닉 상태와 memory cell에서부터 시작할 수 있게 되는 것이다.
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을 사용한 언어 모델
- Time RNN 계층이 Time LSTM 계층으로 바뀐것 말고는 앞 장에서 구현한 언어 모델과 거의 동일하다.
[그림 6-26]의 오른쪽 신경망을 Rnnlm이라는 클래스로 구현하면 아래와 같다.
# coding: utf-8
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()
Rnnlm 클래스에는 Softmax 계층 직전까지를 처리하는 predict() 메서드가 추가되었다. 이 메서드는 다음 장에서 수행하는 문장 생서엥 사용된다.
이 신경망을 사용해 PTB 데이터셋을 학습시키면 아래와 같은 perplexity의 추이를 얻을 수 있다.
perplexity는 훈련이 잘 될수록 값이 줄어드는데, [그림 6-28]을 통해 반복이 늘어남에 따라 값이 줄어듦을 확인할 수 있다. 그러나 perplexity가 100 이상일 때는 그리 좋은 결과는 아니다. 아직 개선할 여지가 많이 있는 결과값이다.
6.5 RNNLM 추가 개선
6.5.1 LSTM 계층 다층화
RNNLM으로 정확한 모델을 만들고자 한다면 많은 경우 LSTM 계층을 깊게 쌓아 효과를 볼 수 있다. 몇 층이나 쌓아야 할 지는 hyperparameter의 문제이므로 처리할 문제의 복잡도나 준비된 학습 데이터의 양에 따라 적절하게 결정해야 한다. 참고로, PTB 데이터셋의 언어 모델에서는 LSTM의 층 수는 2~4 정도일 때 좋은 결과를 얻는다.
6.5.2 드롭아웃에 의한 과대적합 억제
층을 깊게 쌓을수록 표현력이 풍부한 모델을 만들 수 있지만, 이런 모델은 종종 overfiting을 일으키곤 한다. 심지어 RNN은 일반적인 feedforward 신경망보다 쉽게 overfiting을 일으킨다. overfiting이란 훈련 데이터에만 너무 치중해 학습된 상태를 말한다. 즉, 일반화 능력이 결여된 상태이다.
overfiting을 억제하는 전통적인 방법으로는 ‘훈련 데이터의 양 늘리기’와 ‘모델의 복잡도 줄이기’가 가장 대표적이다. 그 외에는 모델의 복잡도에 페널티를 주는 정규화(normalization)도 효과적이다. 예컨대 L2 정규화는 가중치가 너무 커지면 페널티를 부과한다.
또, dropout처럼 훈련 시 계층 내의 뉴런 몇 개를 무작위로 무시하고 학습하는 방법도 일종의 정규화라고 할 수 있다.
[그림 6-30]과 같이 dropout은 무작위로 뉴런을 선택하여 선택한 뉴런을 무시한다. 즉, 앞 계층으로부터의 신호 전달을 막는다.
feedforward 신경망에서는 dropout 계층을 아래와 같이 적용한다.
하지만 RNN을 사용한 모델에서는 dropout 계층을 시간축과는 독립적인 방향에 삽입한다. 만약 시간축 방향으로 dropout을 넣었을 경우, 시간의 흐름에 따라 정보가 사라질 수 있다. 즉, 흐르는 시간에 비례해 dropout에 의한 노이즈가 축적된다. 따라서 시간축과는 독립적인 상하 방향으로 dropout을 삽입하여 정보를 잃지 않도록 한다.
6.5.3 가중치 공유
언어 모델을 개선하는 아주 간단한 트릭 중 가중치 공유(weight tying)가 있다. 가중치 공유의 예시는 아래와 같다.
[그림 6-35]처럼 Embedding 계층의 가중치와 Affine 계층의 가중치를 연결하는 기법이 가중치 공유이다. 두 계층이 가중치를 공유함으로써 학습하는 매개변수 수가 크게 줄어드는 동시에 정확도도 향상되는 것이다. 이때 어휘 수를 V로, LSTM의 은닉 상태의 차원수를 H라고 하면, Embedding 계층의 가중치 형상은 (V, H)이며, Affine 계층의 가중치 형상은 (H,V)가 된다. 따라서 가중치 공유를 적용하려면 Embedding 계층의 가중치를 전치하여 Affine 계층의 가중치로 설정하기만 하면 된다.
지금까지 알아본 개선 방법으로 개선한 RNNLM의 계층 구성은 [그림 6-36]과 같다.
[그림 6-36]에서의 개선점은 다음 세 가지이다.
- LSTM 계층의 다층화(2층)
- Dropout 사용(깊이 방향으로만 적용)
- 가중치 공유(Embedding 계층과 Affine 계층에서 가중치 공유)
'2. Artificial Intelligence > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글
[밑바닥부터 시작하는 딥러닝2: Chapter 5] 순환 신경망(RNN) (0) | 2025.01.09 |
---|---|
[밑바닥부터 시작하는 딥러닝2: Chapter 4] word2vec 속도 개선 (2) | 2025.01.02 |
[밑바닥부터 시작하는 딥러닝2: Chapter 3] word2vec (0) | 2024.12.07 |
[밑바닥부터 시작하는 딥러닝2: Chapter 2] 자연어와 단어의 분산 표현 (4) | 2024.11.28 |
[밑바닥부터 시작하는 딥러닝1: Chapter 8] 딥러닝 (4) | 2024.09.27 |