Deep Learning/from scratch II

[밑바닥부터 시작하는 딥러닝 2] - 6장 게이트가 추가된 RNN I

해파리냉채무침 2024. 2. 20. 00:18

RNN의 문제점

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

RNN은 시계열 데이터의 장기 의존 관계를 학습하기 어렵다. 두가지 주 원인이 기울기 소실, 기울기 폭발이 있다. 

언어 모델은 주어진 단어들을 기반으로 다음 단어를 예측한다. 

 

Tom was watching TV in his room. Mary came into the room. Mary said hi to ____

 

빈칸에 들어갈 단어는 Tom이다. Tom이 방에서 tv를 보고 있는 것과, mary가 방으로 들어간 정보가 기억돼야 한다. 

https://velog.io/@dscwinterstudy/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-6%EC%9E%A5

정답이 Tom인것을 도출하기 위한 학습은 BPTT로 수행한다. 역전파 수행시 RNN 계층이 과거 방향으로 의미있는 기울기를 전달한다. 기울기는 의미있는 정보가 들어있고, 이것을 과거로 전달함으로써 장기 의존 관계를 학습한다. 

역전파 학습시 기울기가 중간에 소실되면(아무런 정보도 남지 않으면) 가중치 매개변수는 더이상 갱신하지 않는다. (기울기소실)

혹은 순전파 학습 시 기울기가 무한대로 커질 수 있다.(기울기 폭발)

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

시간 방향 기울기에 주목하면 역전파로 전해지는 기울기는 tanh, +, MatMul 연산을  순서대로 통과한다. 

y= tanh(x)일 때의 미분은 ∂y/ ∂x = 1-y² 이다. tanh 값과 tanh의 미분값을 그래프로 그리면 다음과 같다.

https://velog.io/@dscwinterstudy/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-6%EC%9E%A5

미분값은 1.0이하이고, x가 0으로 멀어질수록 작아진다. 이는 역전파에서는 기울기가 tanh 노드를 지날 때 마다 기울기 값은 계속 작아진다. tanh 함수를 T번 통과하면 기울기도 T번 반복해서 작아진다

RNN 계층 활성화 함수로 tanh함수를 쓰는데, ReLU로 바꾸면 기울기 소실을 줄일 수 있다. 그 이유는 ReLU 입력 x가 0이상이면, 역전파 시 상류의 기울기를 그대로 하류에 보내서 기울기가 작아지지 않기 때문이다.

https://velog.io/@dscwinterstudy/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-6%EC%9E%A5

역전파 기울기로 dh가 흘러오고, matmul 노드를 지날때마다 dhWhT라는 행렬곱으로 기울기를 계산한다. 행렬 곱셈에서는 매번 똑같은 가중치(Wh)가 사용된다. 

import numpy as np
import matplotlib.pyplot as plt


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

dh = np.ones((N, H)) #초기화, 모든 원소가 1인 행렬을 반환
np.random.seed(3)


Wh = np.random.randn(H, H)


norm_list = []
for t in range(T): #T만큼 dh 갱신
    dh = np.dot(dh, Wh.T)
    norm = np.sqrt(np.sum(dh**2)) / N #L2 norm penalty 
    norm_list.append(norm) #각 단계마다 dh 크기를 norm-list에 포함

print(norm_list)

# 시각화
plt.plot(np.arange(len(norm_list)), norm_list)
plt.show()

https://jsleetech.tistory.com/58

기울기 폭발 : 기울기의 크기는 시간에 비례하여 지수적으로 증가한다

Wh 초기값을 다음과 같이 변경한다.

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

https://juwonking.tistory.com/538

기울기가 지수적으로 감소하고, 가중치 매개변수가 더이상 갱신되지 않아 장기 의존 관계를 학습할 수 없게 된다. 

행렬 Wh를 T번 반복해서 곱하기 때문이다. 

Wh가 행렬일때, 행렬의 특이값(데이터가 얼마나 퍼져있는지 나타냄)이 척도가 된다.특이값중 최대값이 1보타 크면 지수적으로 증가하고, 1보다 작으면 지수적으로 감소할 가능성이 높다고 예측한다. (반드시 폭발하는건 아님)

기울기 폭발 대책

기울기 폭발의 대책으로 기울기 클리핑 기법을 쓴다.

g^: 모든 배개변수의 기울기를 결합한것(ex. 가중치 W1, W2 매개변수를 사용하는 모델이 있다면 기울기 dW1 dW2를 결합)|| g^||  : 기울기의 L2 norm threshold 값을 초과하면 기울기를 수정한다.

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


def clip_grads(grads, max_norm):
    total_norm = 0
    for grad in grads:
        total_norm += np.sum(grad ** 2) #l2 norm
    total_norm = np.sqrt(total_norm)

    rate = max_norm / (total_norm + 1e-6)
    if rate < 1:#매개변수 기울기합(total_norm)이 threshold(max_norm)보다 클때 
        for grad in grads:
            grad *= rate #기울기 수정

기울기 소실과 LSTM

이전에 LSTM 모델 이용해서 블로그 글을 쓴적이 있고 자세하게 공부한적이 있긴하다.

https://coldjellyfish0227.tistory.com/25

 

Keras와 RNN로 하는 주류 판매량 시계열 예측

1. 모듈 임포트 및 데이터셋 불러오기 import warnings warnings.filterwarnings('ignore') import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns import warnings import os %matplotlib inline warnings.filterwar

coldjellyfish0227.tistory.com

https://velog.io/@dscwinterstudy/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-6%EC%9E%A5

C:  기억셀(memory cell) 이라고 하며, 데이터를 자기 자신(LSTM 계층 내) 에서만 주고받는 특징이 있음. 외부에서 보이지 않음.

h: 은닉상태 벡터, RNN 계층과 마찬가지로 위쪽으로 출력함.

시각 t에서 기억셀 Ct를  tanh함수로 변환하여 ht를 출력한다.

ht = tanh(Ct)

gate(게이트): 데이터의 흐름을 제어한다. 열림상태는 0~1.0 사이의 실수로 나타내고, 게이트 열림상태를 제어하기 위해 전용 가중치 매개변수를 이용한다. 가중치 매개변수는 학습 데이터로부터 갱신된다. 열림상태를 구할 때는 sigmoid 함수를 사용한다.( sigmoid 출력이 0~1.0 사이이기 때문)

 

LSTM 기전 설명

https://velog.io/@dscwinterstudy/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-6%EC%9E%A5

1. O) output 게이트

output 게이트는 은닉상태 ht 의 출력을 담당한다. 열림상태는 입력 xt와 이전상태 ht-1로부터 구함

o = sigmoid(입력xt *가중치 Wx + 이전상태 은닉 ht-1 * 가중치 Wh + bias)

ht = o ⊙ tanh(ct) ( ⊙은 아다마르 곱이라고 하며, 원소별 곱을 의미함)

2. f) forget 게이트

forget 게이트는 망각 게이트라고 하며 불필요한 정보를 잊게 한다. 

시그마 안에는 forget 게이트 전용의 가중치 매개변수가 있다. 

ct = f ⊙ ct-1 (ct-1은 이전 기억셀)

3. g) 새로운 기억 셀

forget 게이트를 거치면서 이전 시각의 기억 셀로부터 잊어야할 기억들이 삭제되었다. 새로 기억해야할 정보를 기억셀에 추가해야한다.

tanh 노드가 계산한 결과가 이전 시각의 기억 셀 ct-1에 더해진다. tanh 노드는 게이트가 아니고, 새로운 정보를 기억 셀에 추가하는 것이 목적이다. 

4. i) input 게이트

input 게이트는 g의 각 원소가 새로 추가 되는 정보로써의 가치가 얼마나 큰지를 판단한다. 새 정보를 취사선택하는 것이 input 게이트의역할이다. i와 g의 원소별 곱 결과를 기억 셀에 추가한다.   

LSTM의 기울기 흐름

역전파에서 기울기 소실을 없애주는 원리는 다음과 같다.

역전파시, + 와 x 노드만을 지난다. + 노드는 상류에서 전해지는 기울기 그대로 흘리고, x노드는 행렬 곱이 아닌 원소별 곱(아다마르곱) 을 계산한다. 매 시각 다른 게이트 값을 이용해 원소별 곱을 계산해서 기울기 소실이 일어나지 않는다. 

x노드의 계산은 forget 게이트가 제어하고, 잊어야 하는 기억셀의 원소에 대해서는 기울기가 작아진다. 잊으면 안되는 원소에 대해 기울기가 약화되지 않은 채로 과거방향으로 전해진다. 

LSTM 구현

위의 수식들은 아핀변환을 개별적으로 수행하지만 , 하나의 식으로 정리해 계산할 수 있다.

4개의 가중치, 편향들을 하나로 모을 수 있어 단 1회의 계산으로 끝마칠수 있다. 계산 속도가 빨라질 수 있다는 의미다. 

https://velog.io/@syi07030/NLP-%EA%B3%B5%EB%B6%80-%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-6%EC%9E%A5-%EA%B2%8C%EC%9D%B4%ED%8A%B8%EA%B0%80-%EC%B6%94%EA%B0%80%EB%90%9C-RNN

4개의 아핀변환을 한꺼번에 수행하고, slice 노드를 통해 4개의 결과를 꺼낸다. slice는 아핀변화의 결과는 균등하게 네조각으로 나눠서 꺼내주는 노드이다. 

class LSTM:
    def __init__(self, Wx, Wh, b):
        self.params = [Wx, Wh, b] #가중치 매개변수 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): #현시각 입력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) #forget
        g = np.tanh(g) #gate
        i = sigmoid(i) #input
        o = sigmoid(o) #output

        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

 이 코드를 형상화하면 다음과 같다

https://velog.io/@dscwinterstudy/%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-%EB%94%A5%EB%9F%AC%EB%8B%9D2-6%EC%9E%A5

미니배치수를 N,  입력 데이터 차원수를 D, 기억셀과 은닉상태의 차원수는 H

A는 네개분의 아핀변환 결과가 저장된다. 이 결과에서 데이터를 꺼낼때 A[ :, :H] 또는 A[ : , H:2H] 형태로 슬라이스 해서 꺼낸다. 

순전파에서는 행렬을 slice를 거쳐 네조각으로 나눠서 분배했다. 역전파에서는 반대로 4개의 기울기를 결합한다. 

slice 노드의 역전파에서는 4개의 행렬을 연결하고, 4개의 기울기 df, dg, di, do를 연결해서 dA를 만든다. 이를 넘파이로 만들려면 np.hstack()(주어진 배열 가로로 연결)를 쓰면된다. (세로 연결은 np.vstack()) 아래 코드로 연결을 수행한다.

dA= np.hstack((df,dg,di,do))

 

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