밑바닥부터 시작하는 딥러닝 - 게이트가 추가된 RNN(1)

RNN은 순환 경로를 포함하며 과거의 정보를 기억할 수 있었다. 구조가 단순하여 구현도 쉽게 가능했다.

요즘에는 RNN 대신 LSTM이나 GRU라는 계층이 주로 쓰인다. LSTM이나 GRU에는 '게이트'라는 구조가 더해져 있는데, 이 게이트 덕분에 시계열 데이터의 장기 의존 관계를 학습할 수 있다.

 


RNN의 문제점

1. 기울기 소실 또는 기울기 폭발

언어 모델은 주어진 단어들을 기초로 다음에 출현할 단어를 예측하는 일을 한다.

"?"에 들어가는 단어는 "Tom"이다. RNNLM이 이 문제에 올바르게 답하려면, 현재 맥락에서 "Tom이 방에서 TV를 보고 있음"과 "그 방에 Mary가 들어옴"이란 정보를 기억해둬야 한다. 이런 전보를 RNN 계층의 은닉 상태에 인코딩해 보관해야 한다.

위의 그림과 같이 정답 레이블이 "Tom"임을 학습할 때 중요한 것이 바로 RNN 계층의 존재이다. RNN 계층이 과거 방향으로 "의미 있는 기울기"를 전달함으로써 시간 방향의 의존관계를 학습할 수 있는 것이다. 이때 기울기는 학습해야 할 의미가 있는 정보가 들어 있고, 그것을 과거로 전달함으로써 장기 의존 관계를 학습한다. 하지만 만약 이 기울기가 중간에 사그라들면 가중치 매개변수는 전혀 갱신되지 않게 된다. 즉, 장기 의존 관계를 학습할 수 없게 된다.

 

2. 기울기 소실과 기울기 폭발의 원인

아래 그림으로 기울기 소실이 일어나는 원인을 살펴보자.

위의 그림처럼 길이가 T인 시계열 데이터를 가정하여 T번째 정답 레이블로부터 전해지는 기울기가 어떻게 변하는지 보자. 앞의 문제에 대입하면 T번째 정답 레이블이 'Tom'인 경우에 해당한다. 이때 시간 방향 기울기에 주목하면 역전파로 전해지는 기울기는 차례로 'tanh', '+', 'MatMul(행렬 곱)' 연산을 통과한다는 것을 알 수 있다.

'+'의 역전파는 상류에서 전해지는 기울기를 그대로 하류로 흘려보낼 뿐이다. 그래서 기울기는 변하지 않는다.

위의 그림은 tanh 함수의 그래프이다. 점선이 미분된 값인데, 그 값은 1.0 이하이고, x가 0으로부터 멀어질수록 작아진다.

 

MatMul(행렬 곱)은 상류로부터 dh라는 기울기가 흘러온다고 가정한다. 이때 MatMul 노드에서의 역전파는 dhW(h)T라는 행렬 곱으로 기울기를 계산한다. 그리고 같은 계산을 시계열 데이터의 시간 크기만큼 반복한다. 여기에서 주목할 점은 이 행렬 곱셈에서는 매번 똑같은 가중치 W(h)가 사용된다는 것이다.

코드를 통해 기울기의 크기 변화를 관찰해보자.

import numpy as np
import matplotlib.pyplot as plt

N = 2 # 미니배치 크기
H = 3 # 은닉 상태 벡터의 차원 수
T = 20 # 시계열 데이터의 길이

dh = np.ones((N, H))
np.random.seed(3) # 재현할 수 있도록 난수의 시드 고정
Wh = np.random.randn(H, H)

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)

이 코드에서는 dh를 np.ones()로 초기화한다. 그리고 역전파의 MatMul 노드 수(T)만큼 dh를 갱신하고, 각 단계에서 dh 크기를 norm_list에 추가한다. 또한 여기에서는 미니배치(N개)의 평균 'L2 노름'을 구해 dh 크기로 사용하고 있다.

참고로, L2 노름이란 각각의 원소를 제곱해 모두 더하고 제곱근을 취한 값이다.

 

# 그래프 그리기
plt.plot(np.arange(len(norm_list)), norm_list)
plt.xticks([0, 4, 9, 14, 19], [1, 5, 10, 15, 20])
plt.xlabel('time step')
plt.ylabel('norm')
plt.show()

위의 그래프를 보면 기울기의 크기는 시간에 비례해 지수적으로 증가함을 알 수 있다. 이것이 바로 기울기 폭발이다. 이러한 기울기 폭발이 일어나면 결국 오버플로를 일으켜 NaN같은 값을 발생시킨다. 신경망 학습을 제대로 수행할 수 없게 되는 것이다.

# Wh = np.random.randn(H, H)
Wh = np.random.randn(H, H) * 0.5

Wh의 초깃값을 변경한 후 나온 결과 그래프이다. 이번에는 기울기가 지수적으로 감소한다. 이것이 기울기 소실이다.

기울기 소실이 일어나면 기울기가 매우 빠르게 작아진다. 그리고 기울기가 일정 수준 이하로 작아지면 가중치 매개변수가 더 이상 갱신되지 않으므로, 장기 의존 관계를 학습할 수 없게 된다.

왜 이런 지수적인 변화가 일어났을까?

그것은 바로 행렬 Wh를 T번 반복해서 곱했기 때문이다. 만약 Wh가 스칼라라면 이야기는 단순해진다. Wh가 1보다 크면 지수적으로 증가하고, 1보다 작으면 지수적으로 감소한다.

그렇다면 Wh가 스칼라가 아니라 행렬이라면 어떻게 될까? 이 경우 ,행렬의 '특잇값'이 척도가 된다.

행렬의 특잇값이란, 데이터가 얼마나 퍼져 있는지를 나타낸다. 이 특이값의 값이 1보다 큰지 여부를 보면 기울기 크기가 어떻게 변할지 예측할 수 있다.


기울기 폭발 대책

지금까지 RNN의 문제점(기울기 폭발과 기울기 소실)을 살펴봤다. 그렇다면 이제 해결책을 알아보자.

기울기 폭발의 대책으로는 기울기 클리핑이라는 기법을 사용한다. 

- 신경망에서 사용되는 모든 매개변수에 대한 기울기를 하나로 처리한다고 가정, 이를 기호 g^로 표기

- threshold를 문턱값으로 설정, 이때 기울기의 L2 노름이 문턱값을 초과하면 두 번째 줄의 수식과 같이 기울기를 수정

 

더보기

g^은 신경망에서 사용되는 모든 매개변수의 기울기를 하나로 모은 것이다. 예를 들어 두 개의 가중치 W1과 W2 매개변수를 사용하는 모델이 있다면, 이 두 매개변수에 대한 기울기 dW1과 dW2를 결합한 것이 g^이다.

dW1 = np.random.rand(3, 3) * 10
dW2 = np.random.rand(3, 3) * 10
grads = [dW1, dW2]
max_norm = 5.0

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

기울기 클리핑의 구현이다. 

TAGS.

Comments