Deep Learning/from scratch II

[밑바닥부터 시작하는 딥러닝 2] - 5. 순환 신경망(RNN) I

해파리냉채무침 2024. 2. 16. 19:42

feed forward (피드포워드 신경망): 흐름이 단방향인 신경망, 입력 신호가 중간층, 그 다음 층 계속 그 다음층으로 한 방향으로만 전달 되는 것을 의미함. 

피드포워드는 시계열 데이터의 성질을 충분히 학습하기 어려움, 그래서 순환신경망의(Recurrent Neural Network) 등장

확률과 언어 모델

word2vec을 확률 관점에서 바라볼 때

W1 W2 ... Wt-1 Wt Wt+1 .... WT-1 WT

 

CBOW모델은 맥락 Wt-1 Wt+1로 부터 Wt를 추측하는 일을 수행한다. 수식으로 나타내면 다음과 같다.

P(Wt | Wt-1, Wt+1)

이 사후 확률은 Wt-1과 Wt+1이 주어졌을 때 Wt가 일어날 확률을 뜻한다. 윈도우 크기(양쪽 맥락 수)가 1일 때이다.

아래는 맥락을 왼쪽 윈도우만으로 한정했을 때이다. 

 

W1 W2 ... Wt-2  Wt-1 Wt Wt+1 .... WT-1 WT

P(Wt| Wt-2, Wt-1)

cross entropy error에 의해 유도된 손실함수 식은 다음과 같다. 

 

L = -log P(Wt| Wt-2, Wt-1)

손실 함수(말뭉치 전체의 손실함수의 총합)를 최소화 하는 가중치 매개변수를 찾는 것이다. 이러한 가중치 매개변수가 발견되면 타겟을 더 정확히 추측할 수 있다. 

언어 모델

언어 모델은 단어 나열에 확률을 부여한다. w1.. wm이라는 m개 단어로 된 문장이 있으면, w1.. wm 순서로 출현할 확률이 P( w1.. wm)으로 나타낸다. 여러사건이 동시에 일어날 확률이므로 동시확률이라고 한다. 

사후 확률을 사용해 분해하면 다음과 같다. 

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-5%EC%9E%A5-%EC%88%9C%ED%99%98-%EC%8B%A0%EA%B2%BD%EB%A7%9DRNN

π 파이 기호는 모든 원소를 곱하는 총곱을 의미함. 동시 확률은 사후확률의 총곱으로 나타낸다. 확률의 곱셈정리로도 나타낼 수 있다. 

P(A,B) = P(B|A)P(A)  P(A,B)  =  P(A|B)P(B)  A,B가 모두 일어날 확률 P(A,B)를 위와 같이 두가지로 나타낼 수 있다. P(W1..Wm)을 사후확률로 나타내면 다음과 같다

https://techblog-history-younghunjo1.tistory.com/470#google_vignette

단어 시퀀스를 하나씩 줄여가면서 매번 사후확률로 분해한다. 사후확률은 타겟단어보다 왼쪽에 있는 단어를 맥락으로 했을 떄의 확률이라는 것을 잊지말자

CBOW 모델을 언어 모델로

CBOW 모델을 언어 모델에 적용하려면 맥락의 크기를 특정 값으로 한정하여 근사적으로 나타낸다. 

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-5%EC%9E%A5-%EC%88%9C%ED%99%98-%EC%8B%A0%EA%B2%BD%EB%A7%9DRNN

맥락의 크기는 임의 길이로 설정할 수 있지만, 결국 특정 길이로 고정된다. 왼쪽 10개 단어를 맥락으로 CBOW 모델을 만든다고 하면, 그 맥락보다 더 왼쪽에 있는 단어의 정보는 무시된다. CBOW 모델의 크기는 얼마든지 키울 수 있지만 맥락 안의 단어 순서가 무시된다는 한계가 있다. 

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-5%EC%9E%A5-%EC%88%9C%ED%99%98-%EC%8B%A0%EA%B2%BD%EB%A7%9DRNN

왼쪽 그림은 CBOW 모델의 은닉층에서는 단어 벡터들이 더해지므로 (1/2(h1+h2)) 맥락의 단어 순서는 무시된다. 

(you, say)나 (say, you)나 똑같이 간주된다. 

오른쪽은 단어 벡터를 은닉층에서 연결하는 방식이다. 연결하면 맥락의 크기에 비례해 가중치 매개변수도 늘어난다. 

매개변수가 늘어나는 것은 그닥 좋은 것이 아니다. 이러한 한계를 해결하기 위해 RNN이 고안되었다.

RNN 이란

순환하는 신경망

RNN(recurrent neural network)는 순환 신경망을 의미한다. 순환하기 위해서는 닫힌 경로가 필요하다. 끊임없이 순환 할 수 있고 과거의 정보를 기억하는 동시에 최신 데이터로 갱신될 수 있게 된다. 

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-5%EC%9E%A5-gnk6bhirc3

xt(단어 벡터, 단어의 분산표현) 를 입력받는데 t는 시간을 의미한다. 단어벡터들이 순서대로 하나씩 RNN 계층에 입력된다.  빨간 동그라미를 보면 출력이 2개로 분기하고 있음을 알 수 있다. 분기된 출력 중 하나가 자기 자신에 입력(순환) 된다.

순환구조 펼치기

RNN 계층의 순환 구조를 펼침으로써 feed forward 구조와 같은 구조를 보이게 된다(한 방향으로만 흐른다)

각 시각의 RNN 계층은 그 계층으로의 입력과 1개 전의 RNN 계층으로부터 출력을 받음. 

계산 수식은 다음과 같다.

ht = tanh(ht-1 Wh + xt Wx + b)

 

Wx:  x를 출력 h로 변환하기 위한 가중치

Wh :  1개의 RNN 출력을 다음 시각의 출력으로 변환하기 위한 가중치

b: bias

ht-1, xt : 행벡터 

출력되는 값 ht: 다른 계층을 향해 위쪽으로 출력되는 동시에 , 다음 시각의 RNN (자기 자신)을 향해 오른쪽으로도 출력된다. 상태를 기억해 1스텝  진행될 때 마다 갱신된다. ht는 ht-1(이전 출력)에 기초에 계산된다. 

BPTT

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-5%EC%9E%A5-gnk6bhirc3

여기에서의 오차역전파법은 시간 방향으로 펼친 신경망의 오차역전파법이란 뜻으로 BPTT(backpropagation through time)이라고 한다. 긴 시계열의 데이터를 처리할때, 컴퓨팅 자원이 증가하고 역전파 시 기울기가 불안정해진다. 그러므로 RNN 계층의 중간 데이터를 메모리에 유지해야 한다. 

 

Truncated BPTT

길이가 1000개인 시계열 데이터를 다뤄서 RNN 계층을 펼치면 가로로 1000개가 늘어선 신경망이 된다.  너무 길면 계산량, 메모리 문제와 더불어 기울기가 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-5%EC%9E%A5-gnk6bhirc3

truncated BPTT는 순전파의 연결은 그대로 유지하고, 역전파의 연결을 적당한 길이로 잘라서 잘라낸 신경망 단위로 학습을 수행하여 위와 같은 문제를 해결한다. 

역전파의 연결을 자르면(순전파의 연결은 자르지 않음), 미래의 데이터와는 독립적으로 오차역전파를 완결시킨다. 미니배치 학습을 수행할 떄 무작위로 데이터를 선택했지만, Truncated BPTT를 수행할 떄는 데이터를 순서대로 입력해야한다.

처음 블록의 순전파와 역전파를 수행하고, x9와 x19를 입력해 오차 역전파법을 수행한다.

두번째 블록에서는 앞블록의 마지막 은닉 상태는 h9를 가져와서 순전파를 연결한다. 

세번째 블록에서는 두번째 블록의 은닉 상태인 h19를 가져와서 순전파를 연결한다. 

 

Truncated BPTT의 미니배치 학습

Truncated BPTT의 미니배치를 학습시키기 위해서는 데이터를 주는 시작 위치를 각 미니배치의 시작위치로 옮겨줘야 한다. 

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-5%EC%9E%A5-gnk6bhirc3

만약 길이가 1000인 시계열 데이터를 시각의 길이를 10개 단위로 잘라 처리할 때, 첫번째 미니배치는 처음부터 순서대로 데이터를 제공하고 두번쨰 미니배치 떄는 500번째 데이터를 시작위치로 500만큼 옮겨준다

첫번째 미니 배치 원소는 x0.. x9, 두번째 미니 배치 원소는 x500,.. x509가 된다. 이 미니배치 데이터를 RNN 입력데이터로 사용해 학습을 수행한다. 이후 넘길 데이터는 10~19, 510~519번째 데이터가 된다.

Truncated BPTT에서 결론은, 데이터 순서대로 제공, 미니배치별 데이터를 제공하는 시작위치 옮기기 이 두가지이다.

RNN 구현

(x0,x1...xt-1)을 묶은 xs를 입력하면 (h0,h1,..ht-1)을 묶은 hs를 출력하는 단일 계층으로 볼 수 있다. RNN 계층에서 T개 단계분의 작업을 한꺼번에 처리하는 계층을 Time RNN 계층이라고 한다. 

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-5%EC%9E%A5-gnk6bhirc3

RNN 계층 구현

RNN의 순전파는 다음과 같다. 

ht = tanh(ht-1 Wh + xt Wx + b)

여기서 데이터를 미니배치로 모아 처리한다. 

https://techblog-history-younghunjo1.tistory.com/470

N : 미니배치 크기

D: 입력 벡터 차원 수

H : 은닉 상태 벡터 차원 수 

 

코드로 구현하면 다음과 같다

class RNN:
    def __init__(self, Wx, Wh, b): 
        self.params = [Wx, Wh, b] #가중치 두개와 편향 1개를 인수로 받음
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)] 
        # 기울기 초기화 한후 grads에 저장
        self.cache = None #역전파 계산 시 사용하는 중간 데이터를 담을 cache를 None으로 초기화 

    def forward(self, x, h_prev):
        Wx, Wh, b = self.params
        t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b #위의 식
        h_next = np.tanh(t)

        self.cache = (x, h_prev, h_next)# h_prev는 ht-1 h_next는 ht
        return h_next

RNN의 순전파와 역전파는 다음과 같은 계산 그래프로 나타낸다. 

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-5%EC%9E%A5-gnk6bhirc3

backward() 코드는 다음과 같다.

    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.dot(h_prev.T, dt)
        dh_prev = np.dot(dt, Wh.T)
        dWx = np.dot(x.T, dt)
        dx = np.dot(dt, Wx.T)

        self.grads[0][...] = dWx
        self.grads[1][...] = dWh
        self.grads[2][...] = db

        return dx, dh_prev

Time RNN 계층 구현

Time RNN 계층은 RNN 계층 T개를 연결한 신경망이다. 여기서 RNN 계층의 은닉 상태 h를 인스턴스 변수로 유지한다. h를 인계받는 용도로 이용한다. 

class TimeRNN:
    def __init__(self, Wx, Wh, b, stateful=False):#stateful 은닉 상태를 인계받을지 (T/F)
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.layers = None #다수의 RNN 계층을 리스트로 저장하는 용도

        self.h, self.dh = None, None #h는 forward했을때 마지막 RNN 계층 은닉상태 저장
        # dh는 backward를 불렀을 떄 하나 앞 블록 은닉 상태 기울기 저장
        self.stateful = stateful 
        
    def set_state(self, h): #계층 은닉상태 설정
        self.h = h

    def reset_state(self):#은닉상태 초기화
        self.h = None

첫번째 init 설정때 stateful = True면 time RNN 계층이 은닉 상태 유지(순전파를 끊지 않고 전파)

stateful = False면 은닉상태를 영행렬(모든 행렬 요소가 0)로 초기화 -> 상태가없음, 무상태

    def forward(self, xs): #xs는 T개 분량의 시계열 데이터를 하나로 모은것
        Wx, Wh, b = self.params
        N, T, D = xs.shape #미니배치N, 입력벡터의 차원수 D
        D, H = Wx.shape

        self.layers = []
        hs = np.empty((N, T, H), dtype='f')

        if not self.stateful or self.h is None: #stateful이 false(영행렬 초기화)거나  처음 호출시
            self.h = np.zeros((N, H), dtype='f') #영행렬 초기화

        for t in range(T):
            layer = RNN(*self.params)#RNN 계층 생성하여 인스턴스 변수 layer에 추가 
            self.h = layer.forward(xs[:, t, :], self.h)#시각 t의 은닉상태 h를 계산, 이를 hs에 해당 인덱스의 값으로 설정
            hs[:, t, :] = self.h
            self.layers.append(layer)

역전파 구현은 다음과 같다.  

상류(출력쪽 )에서 전해지는 기울기를 dhs 라고 하고, 하류에 보내는 기울기를 dxs 라고 한다. 이전 시각의 은닉 상태 기울기는 dh에 저장한다. 

t번째 계층에서는 위로부터의 기울기 dh와 미래 계층 기울기 dhnext 가 전해진다. 순전파시에는 출력이 2개로 분기되었는데, 역전파에서는 기울기가 합산돼서 전해진다 (dht+ dhnext)

https://taepseon.tistory.com/24

    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 #각 시각의 기울기 dx를 dxs의 해당 인덱스에 저장

            for i, grad in enumerate(layer.grads):
                grads[i] += grad

        for i, grad in enumerate(grads):
            self.grads[i][...] = grad #rnn 가중치 기울기 합산 
        self.dh = dh

        return dxs

 

time RNN계층의 최종 가중치 기울기는 각 RNN 계층의 가중치 기울기를 모두 더한것이 된다.