RNN(Recurrent Neural Network) & LSTM (Long short term Memory)

본 게시물은 '딥러닝을 이용한 자연어 처리 입문' 도서를 참고하여 정리하였습니다.

 

https://wikidocs.net/book/2155

 

딥 러닝을 이용한 자연어 처리 입문

많은 분들의 피드백으로 수년간 보완된 입문자를 위한 딥 러닝 자연어 처리 교재 E-book입니다. 오프라인 출판물 기준으로 코드 포함 **약 1,000 페이지 이상의 분량*…

wikidocs.net

 


 

딥러닝 기초를 다시 복습하면서, 꼭 알아야 할 개념들을 정확히 짚고 공부해보고자 자연어 처리 task에서 주로 사용되는 RNN과 LSTM을 복습하여 정리하였습니다.

 

[RNN]

RNN 이란?

 

RNN은 Recurrent Neural Network 라고 불리며, 말 그대로 주기적으로 되풀이되거나 반복되는 것을 이용한 Neural Network를 말합니다.

 

신경망 분야에서는 시간적인 의존성을 갖는 모델을 말하는데, 이렇게 이름이 붙게 된 이유는, 현재의 출력이 이전 단계의 출력에 영향을 받는 구조를 띄기 때문입니다. 이런 시계열적인 특징을 갖는 이유 때문에 RNN은 주로 NLP task에서 많이 사용됩니다. 

 

출처 : 딥러닝을 이용한 자연어 처리 입문

 

RNN 구조를 검색해보면 다음과 같은 그림을 많이 보았을 것입니다.

$x_t$ 는 입력층, $y_t$는 출력층을 의미하며, 초록색 박스는 은닉층 (Hidden State)를 의미합니다.

 

그림에서 볼 수 있듯이, RNN의 가장 큰 특징은 은닉층에서 한 타임 전의 결과 ($h_(t-1)$)를 다시 자신의 입력으로 사용하는 재귀적 형태의 모양을 띄고 있습니다. 따라서 왼쪽 그림처럼 반복되는 것 처럼 표현할 수도 있고, 오른쪽 그림처럼 시간 $t$에 따른 결과를 풀어서 이전의 결과가 다음 시점으로 전달되는 모양처럼 나타내기도 합니다.

 

이렇게 은닉층에서 활성화 함수(Activation Function)을 통해 결과를 내보내는 역할을 하는 노드를 Cell 이라고 하는데, 일종의 메모리 역할을 수행하므로, 메모리셀 혹은 RNN셀 이라고도 합니다.

 

 

또한 RNN은 입력과 출력의 길이를 다르게 설계할 수 있기 때문에 다양한 용도로 사용할 수 있습니다.

 

  • one-to-many
    • 이미지 캡셔닝 (ex.하나의 이미지에 대해서 사진의 제목을 출력하는 경우)
  • many-to-one
    • 감성 분류, 스팸 메일 분류
  • many-to-many
    • 번역기, 태깅 작업 (개체명 인식, 품사 태깅 등)

 

RNN을 사용하고자 하는 task에 따라 위의 종류들 처럼 다양하게 분류할 수 있는데, 예를 들어 NLP task 에서의 기계 번역과 같은 사례는 첫 번째 언어로 이루어진 문장을 바탕으로 두 번째 언어로 이루어진 문장을 출력으로 내보내기 때문에 Many-to-many task라고 할 수 있습니다.

 

또한 감정분석 task 의 경우, 예를 들어 영화 리뷰를 통해 사용자의 리뷰가 긍정적인지 부정적인지 판단하는 task의 경우 리뷰를 바탕으로 Positive 인지, Negative인지만을 판별해 출력으로 보내기 때문에 Many-to-one task 라고 할 수 있습니다.

 

RNN 구조의 수식화

 

RNN 구조를 수식으로 분석 해 봅시다.

출처 : 딥러닝을 이용한 자연어 처리 입문

 

다음과 같은 RNN 구조가 있습니다.

 

여기서 $x_t$ 는 input vector를 나타내고 $y_t$ 는 output, $h_t$는 hidden state를 나타냅니다. 그리고 각 $W$들은 그에 대한 가중치 (weights)를 나타내죠.

 

Vanila RNN을 수식으로 표현하면 아래와 같습니다.

 

  • Hidden State Layer : $h_t = tanh(W_x x_t + W_h h_(t-1) + b)$
  • Output Layer : $y_t = f(W_y h_t +b)$

이전 상태를 기억하기 위해 입력과 hidden state를 함께 참고한다는 점에서

$p(y_t | x_t, h_(t-1))$ 이렇게 표현할 수도 있죠. 

 

 

먼저, 수식을 하나하나 뜯어 봅시다.

처음 $x_t$에 (아마 단어 sequence?) 벡터가 input으로 입력되게 됩니다. 이를 바탕으로 가중치 $w_x$와 input의 행렬곱으로 Hidden state를 정의하게 됩니다. 물론 $t-1$ 상태의 hidden state도 같은 방식으로 구해졌겠죠.

 

수식에서 알 수 있듯이 Hidden State Layer에는 하이퍼볼릭 탄젠트 함수를 Activation Function 으로 사용합니다.

Output Layer에서는 활성화 함수를 $f$로 표현했는데, 이 때 활성화함수는 비선형 활성화 함수 중 한 가지가 사용됩니다.

 

Q. Activation Function으로 tanh를 사용하는 이유

 

그렇다면 여기서 Hidden State Layer에서 활성화 함수로서 하이퍼볼릭 탄젠트 함수를 사용하는 이유는 뭘까요?

 

대표적인 이유들을 말하자면 총 4가지의 이유가 존재합니다.

 

  • Gradient Vanishing 문제의 해결
    • 긴 시퀀스의 의존성을 학습하는 동안 기울기가 소실되는 문제가 발생할 수 있음.
    • tanh를 사용하면 입력값이 큰 경우에도 기울기 소실이 감소하게되어 장기 의존성을 더 잘 학습할 수 있음.
  • Zero-Centered Output
    • tanh 함수는 출력의 중심이 0이 되기 때문에 다음 층으로 전달되는 값에 양수와 음수 모두를 포함함.
    • 가중치 업데이트시에 더 다양한 방향의 정보를 전달할 수 있도록 도와줌
  • 비선형 특성
  • Gradient Descent 에서의 안정성
    • tanh 함수의 미분은 최대치가 1이 되기 때문에 Gradient Descent 등의 최적화 알고리즘에서 안정성을 제공함

 

대표적인 활성화 함수를 생각한다면 Sigmoid 함수가 있습니다.

하지만 Sigmoid 함수의 겨우 Gradient Vanishing Problem에 대하여 굉장히 취약하다는 단점이 있습니다. Back Propagation 관점에서 말이죠. 

 

RNN 모델에서 Hidden state는 each time step을 기준으로 업데이트 됩니다. 이러한 Neural Network에서는 Back Propagation 을 통해서 오차 측정값을 역전파 해나가며 행렬과 편향의 가중치(weights)를 조정함으로서 오차를 줄여나가는 방법을 사용하기 때문에 네트워크가 깊어질수록 Gradient가 exploding 되거나 vanishing 되는 경향이 존재하는데요,

Sigmoid function

다음과 같이 sigmoid function을 보았을 때, 시그모이드 함수는 함수 값이 0에서 1사이에서 발생되게 됩니다. sigmoid 함수의 미분 값을 구해보면 미분값은 (0~0.25) 사이에 생성되게 됩니다. 따라서 신경망이 깊어질수록 앞서 말한 Gradient Vanishing Problem이 발생하게 되는 것이죠.

 

그렇다면 tanh 함수를 확인해 볼까요?

tanh function

시그모이드 함수와 모양은 같지만 값이 -1 ~ 1 사이에 존재하며, 미분값을 구하게 되면 미분값은 0 ~ 1 사이에 생성되게 됩니다. 따라서 sigmoid 함수에 비해 Gradient vanishing 문제를 해결할 수 있는 것이죠.

 

그렇다면 Gradient vanishing 문제를 해결할 수 있는 대표적인 활성화 함수인 ReLU function과 같은 다른 Activation function을 사용하지 않은 이유는 무엇일까요?

 

대표적인 이유는 RNN의 기본적인 특성에서 찾을 수 있습니다.

바로 RNN은 재귀적 모델이라는 점이기 때문인데요, 바로 직전의 hidden state 값을 다음 state의 입력값으로 재사용하는 재귀적인 형태를 띄는 모델의 특성상 미분값이 x가 되는 ReLU function을 activation function으로 이용하게 되면 그 출력 값이 어마어마하게 커진다는 문제가 발생할 수 있습니다.

 

 

Python을 이용하여 RNN 구현하기

그럼 바로 python을 이용하여 수식을 토대로 RNN을 구현해 봅시다.

 

먼저 관련 모듈을 import 해 줍니다.

import numpy as np

timesteps = 10
input_dim = 4
hidden_units = 8

 

time step은 10, input 차원은 4, hidden unit은 총 8개로 설정 해 주었습니다.

 

이에 해당하는 입력 텐서를 만들고, 초기 hidden state를 모두 0으로 초기화 해 줍니다.

# 입력에 해당하는 2D tensor

inputs = np.random.random((timesteps, input_dim))

# 초기 은닉상태는 0 (벡터)로 초기화

hidden_state_t = np.zeros((hidden_units,))

print("초기 은닉 상태 : ", hidden_state_t)

 

그리고 각 가중치들을 만들어 줍니다.

(tensor 사이즈에 맞게 맞춰 만들어줍니다.)

# (8, 4) 크기의 2D 텐서 생성. 입력에 대한 가중치
Wx = np.random.random((hidden_units, input_dim))

# (8, 8) 크기의 2D 텐서 생성. 은닉에 대한 가중치
Wh = np.random.random((hidden_units, hidden_units))

# (8, ) 크기의 1D 텐서 생성, 이 값은 편향
b = np.random.random((hidden_units, ))

 

이를 모두 출력해보면, 아래와 같습니다.

가중치 shape

 

이를 통해 Hidden state를 확인 해 봅시다.

total_hidden_states = []

for input_t in inputs :

  # Wx * Xt + Wh * Ht-1 + b (bias)
  output_t = np.tanh(np.dot(Wx, input_t) + np.dot(Wh, hidden_state_t) + b)

  # 각 시점 t별 메모리 셀의 출력의 크기는 (timestep t, output_dim)
  # 각 시점의 은닉 상태의 값을 계속해서 누적
  total_hidden_states.append(list(output_t))
  hidden_state_t =  output_t

# 출력 시 값을 깔끕하게 해 주는 용도
total_hidden_states = np.stack(total_hidden_states, axis = 0)

# (timesteps, output_dim)
print('모든 시점의 은닉 상태 : ')
print(total_hidden_states)

모든 시점의 hidden state

 

이렇게 깔끔하게 출력되는 것을 확인할 수 있습니다.

(본 코드는 '딥러닝을 이용한 자연어처리 입문' 도서를 참고하였습니다.)

 

 

Pytorch 를 이용하여 Vanila RNN 구현하기

 이번에는 pytorch를 이용하여 vanila RNN을 구현해보도록 합시다.

import torch
import torch.nn as nn

class RNN(nn.Module):
    def __init__(self, input_dim, output_dim, hid_dim):
        super(RNN, self).__init__()

        self.input_dim = input_dim
        self.output_dim = output_dim
        self.hid_dim = hid_dim

        self.u = nn.Linear(self.input_dim, self.hid_dim, bias=False)
        self.w = nn.Linear(self.hid_dim, self.hid_dim, bias=False)
        self.v = nn.Linear(self.hid_dim, self.output_dim, bias=False)

    def forward(self, x):
        h_t = torch.zeros(x.size(0), self.hid_dim, dtype=torch.float)  # 초기 은닉 상태를 0으로 초기화

        for input_t in x.split(1, dim=1):
            input_t = input_t.squeeze(1)
            h_t = torch.tanh(self.u(input_t) + self.w(h_t))
            y_t = self.v(h_t)

        return y_t

# 모델 생성
input_dim = 10
output_dim = 5
hid_dim = 20

model = RNN(input_dim, output_dim, hid_dim)

# 입력 데이터 생성 (예시로 크기 3인 텐서)
input_data = torch.randn(3, input_dim)

# 모델에 입력 전달하여 결과 얻기
output = model(input_data)

# 결과 출력
print("Input Data:")
print(input_data)
print("\nOutput of RNN:")
print(output)

 

RNN Class를 단계별로 뜯어봅시다.

수식에서 보았던 것 처럼 3개의 인자를 받아 초기화합니다.

input_dim, output_dim, hid_dim

 

그리고 이를 바탕으로 세개의 선형 레이어 (self.u, self.w, self.v)를 정의하고 각각의 가중치 행렬을 생성해냅니다.

  • self.u = 입력을 은닉 상태로 변환하는 가중치 행렬
  • self.w  = 이전의 은닉 상태를 현재의 은닉 상태로 변환하는 가중치 행렬
  • self.v = 은닉 상태에서 출력을 생성하는 가중치 행렬

 

forward 메서드에서는 모델의 순전파 연산을 정의합니다.

초기 은닉 상태를 0으로 초기화하고, 각 시간 단계마다 입력과 이전의 hidden state를 이용하여 새로운 hidden state를 계산하고, 이를 통해 출력을 생성해냅니다.

 

위의 코드들은 아주 간단한 RNN 모델을 구현한 것이기 때문에 이를 응용하여 재구현해 볼 수도 있습니다.

 

 

[LSTM]

 

왜 LSTM? 

위에서 설명한 기존의 Vanila RNN은 Gradient가 깊이 차원 뿐 아니라 시간차원에서도 계산되기 때문에 장기 의존성을 학습하기 어렵게 만든다는 큰 단점이 존재했습니다. 기존의 RNN은 비교적 짧은 sequence에 대해서는 효과를 보였지만 시점이 길어질수록 앞의 정보가 뒤로 충분히 전달되지 못하는 현상이 발생했는데요, 따라서 시점이 충분히 긴 상황에서는 원래 input의 전체 정보에 대한 영향력이 거의 의미가 없는 수준에 이르렀습니다.

 

이를 The Problem of Long-Term Dependencies , 즉 장기 의존성 문제라고 합니다.

 

따라서 이를 해결하기 위해 LSTM, Long Short-Term Memory가 등장하였습니다.

 

LSTM은 은닉층의 메모리셀에 입력게이트, 망각게이트, 출력게이트를 추가하여 불필요한 기억을 지우고, 기억해야 할 것들을 정해 필요한 것만 기억해나가는 방식으로 메모리를 유지하는 방식입니다.

 

LSTM의 구조 및 수식

 

LSTM도 RNN의 일종입니다.

따라서 다음과 같이 RNN 과 같은 Hidden state가 존재합니다.

출처 : 딥러닝을 이용한 자연어처리 입문

 

하지만 구조를 보면 Hidden state를 계산하는 식이 기존의 vanila RNN에 비해 보다 복잡해졌으며, 새로운 값이 추가 되었습니다. 이를 Cell state라고 합니다.

이 Cell state에서는 이전의 vanila RNN에서의 hidden state처럼 이전 시점의 셀 상태가 다음 시점의 셀 상태를 구하기 위한 입력으로서 사용됩니다.

 

 

LSTM에서는 각 게이트 위주로 수식을 정리해보고자 합니다.

위 구조에서 볼 수 있듯, LSTM은 기본적으로 tanh 함수를 사용하여 새로운 셀 상태를 업데이트하며, sigmoid 함수를 이용하여 게이트를 조절합니다. (구체적으로, sigmoid 함수를 지나게 되면 0과 1사이의 값이 출력되게 되는데 이 값들을 가지고 게이트의 on/off를 조절합니다.)

 

Q. LSTM에서는 왜 sigmoid 함수를 사용할까요?

앞서, RNN 구조에서 sigmoid가 아닌 tanh 함수를 activation function으로 사용하는 이유에 대해서 알아보았습니다. 

그렇다면 LSTM에서는 왜 sigmoid 함수를 사용할까요?

 

LSTM에서 sigmoid함수는 각 gate의 활성화에 사용됩니다. 구체적으로 input gate에서는 새로운 입력 정보를 얼마나 수용할지, forget gate에서는 이전 상태의 정보를 얼마나 잊어야 할지, 결정하는데 사용하게 됩니다. 아까 확인했듯, sigmoid 함수는 0 ~ 1 사이의 출력 값을 갖게 되었죠? 이를 바탕으로 0에 가까울수록 정보를 얼마나 버릴지 1에 가까울수록 정보를 얼마나 선택할지 하는 역할을 하게 됩니다.

 

반면에, 기존의 tanh 함수도 여전히 사용됩니다. 바로 cell state 의 업데이트에 사용되는데요.

새로운 정보가 셀 상태에 추가됨에 따라 tanh 함수를 통해 정보를 -1과 1사이의 값으로 정규화하면서 네트워크가 더 안정적으로 학습할 수 있도록 도와줍니다.

 

 

 

그럼, 이제 각 gate 별로 구체적으로 수식을 더 뜯어봅시다.

 

 

Input gate

 

출처 : 딥러닝을 이용한 자연어 처리 입문

먼저 input gate 입니다.

input gate에서는 현재 입력에서 어떤 정보를 셀 상태에 추가할지 구하며, 새로운 정보는 tanh 함수를 통해 구합니다.

 

즉, 다음처럼 이전 시점으로부터 $h_(t-1)$ 에 대한 값이 들어오게 되면 이와 input $x_t$를 앞서 계산한 vanila RNN처럼 tanh function을 이용하여 $g_t$ 값을 구하게 됩니다. (가중치와 bias는 생략하였습니다.)

 

다음으로는 input $x_t$와 hidden state $h_(t-1)$을 sigmoid function을 이용하여 계산한 $i_t$를 구합니다.

 

즉, sigmoid를 이용한 $i_t$ 함수를 통해 어떤 정보를 셀 상태에 추가할지 판별합니다.

 

 

forget gate

출처 : 딥러닝을 이용한 자연어 처리 입문

 

 forget gate는 기억을 삭제하기 위한 게이트입니다. 앞서 말했듯, vanila RNN의 경우 장기 의존성에 취약하다는 단점이 있었죠. 따라서 피필요 없는 데이터에 관한 기억은 삭제해 주는 것 입니다. forget gate를 수식으로 나타내면 아래와 같으며, sigmoid 함수를 거친 결과, 0에 가까울수록 정보가 많이 삭제된 것을 의미하고, 1에 가까울수록 정보를 온전히 기억하는 것을 의미합니다.

 

 

 

Cell state

 위에서 구한 두 게이트 값을 바탕으로 셀의 상태를 계산합니다.

 

출처 : 딥러닝을 이용한 자연어 처리 입문

 

입력 게이트에서 구한 $i_t$, $g_t$의 두 개의 값에 대하여 원소별 곱(Entrywise Product)를 구합니다.

이를 forget gate를 통해 구한 $f_t$와 이전 시점의 cell state $C_(t-1)$의 Entrywise Product를 구한 값과 더해서 최종적인 Cell state를 만들어냅니다.

 

 

수식으로 나타내면 위와 같아집니다.

 

앞선 gate들과 연관지어 생각해봅시다. 만약 $f_t$의 계산 결과가 0에 가깝다면 이전 시점의 셀 상태인 $C_(t-1)$은 영향력이 0에 가까워집니다. (Entrywise Product를 진행하기 때문에) 반대로 $i_t$ 의 계산 결과가 0에 가깝다면 현재시점은 버리게 되고 이전 시점만 cell state에 저장하게 되겠죠.

 

따라서 결론적으로 forget gate는 이전 시점의 입력을 얼마나 반영할지, input gate는 이번 시점의 입력을 얼마나 반영할지를 결정합니다.

 

 

Output gate & Hidden state

 

출처 : 딥러닝을 이용한 자연어 처리 입문

 

Output gate는 현재 시점의 $x_t$ 값과 이전 시점 $t-1$ 의 hidden state가 sigmoid함수를 거쳐 나온 결과값입니다. output gate는 현재 시점 t의 hidden state를 결정하는데 역할을 하게 됩니다. 

앞서 구한 Cell state의 현재시점 $C_t$ 가 tanh 함수를 거치며 -1 ~ 1 사이의 결과를 출력하고 이것이 output gate 의 값과 연산되면서 새로운 hidden state가 되기도 하고 바로 output state로 향하기도 합니다.

 

 

앞서 설명한 Cell state와 Hidden state의 큰 차이점을 두자면, Cell state의 경우는 현재 시점의 정보를 담고 있는 내부상태를 의미한다면, Hidden state는 LSTM 자체의 메모리셀 내에서의 내부상태를 의미합니다. 따라서 장기 의존성을 학습하고 기억하는데 사용됩니다.

 

 

 

Pytorch로 LSTM 구현해보기

 

그럼 마지막으로 Pytorch를 이용해 LSTM을 구현해보도록 하겠습니다.

import torch
import torch.nn as nn

class simple_LSTM(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(CustomLSTM, self).__init__()

        self.input_size = input_size
        self.hidden_size = hidden_size
        self.output_size = output_size

        # LSTMCell 레이어들
        self.lstm_cell = nn.LSTMCell(input_size, hidden_size)
        self.fc = nn.Linear(hidden_size, output_size)

    def forward(self, x):
        batch_size, seq_len, _ = x.size()

        # 초기 은닉 상태와 셀 상태 초기화
        h_t = torch.zeros(batch_size, self.hidden_size, dtype=torch.float)
        c_t = torch.zeros(batch_size, self.hidden_size, dtype=torch.float)

        # 시퀀스 길이만큼 반복
        for t in range(seq_len):
            # 현재 시점의 입력
            x_t = x[:, t, :]

            # LSTMCell에 현재 입력과 이전 은닉 상태, 셀 상태를 전달하여 새로운 은닉 상태와 셀 상태 계산
            h_t, c_t = self.lstm_cell(x_t, (h_t, c_t))

        # 마지막 시퀀스의 은닉 상태를 추출
        output = self.fc(h_t)

        return output

# 모델 생성
input_size = 10
hidden_size = 20
output_size = 5

model = simple_LSTM(input_size, hidden_size, output_size)

# 입력 데이터 생성 (예시로 크기 3인 텐서)
input_data = torch.randn(3, 15, input_size)

# 모델에 입력 전달하여 결과 얻기
output = model(input_data)

# 결과 출력
print("Input Data:")
print(input_data)
print("\nOutput of Simple LSTM:")
print(output)

 

torch에서는 nn.LSTMCell 이라는 메서드를 지원하면서 각 게이트를 포함하는 LSTM을 간단하게 구현할 수 있습니다.

 

https://pytorch.org/docs/stable/generated/torch.nn.LSTMCell.html

 

LSTMCell — PyTorch 2.1 documentation

Shortcuts

pytorch.org

 

LSTM의 output state에서 마지막 sequence의 hidden state를 추출하고 이를 Fully connected layer에 통과시켜 최종 출력을 얻는 형태의 간단한 LSTM 구조입니다.

 

 


 

이상으로 NLP task에서 아주 기본적인 RNN과 LSTM에 대해 알아보았습니다.

 

잘못된 정보의 정정이나 질문 댓글은 언제든지 환영입니다 :)

'딥러닝 기초 > 머신러닝 & 딥러닝' 카테고리의 다른 글

Encoder Decoder Language Models  (0) 2024.04.25
CNN(Convolution Neural Network)  (1) 2024.03.02
[머신러닝] Bias and Variance  (2) 2024.01.18