기본기 다지기/CNN부터 Attention까지 구현

[밑시딥] LSTM 구현하기

syveany 2024. 9. 23. 21:11

LSTM 구현하기

참고문헌: 책 『밑바닥부터 시작하는 딥러닝2』 Chapter6. 게이트가 추가된 RNN

~ 목차 ~
0. 서론- 왜 LSTM을 구현하고 있는가

  1. LSTM 개념
    1.1 RNN의 문제점
    1.2 기울기 폭발 대책- 기울기 클리핑
    1.3 기울기 소실 대책- LSTM
    1.3.1 LSTM 설명
    1.3.2 LSTM 구조
    1.3.3 LSTM은 어떻게 기울기 소실을 방지하는가
  2. LSTM 구현
  3. Time LSTM 구현(계층 전체)
  4. 소감

0. 서론- 왜 LSTM을 구현하고 있는가

  • 이것 역시 연휴맞이 기본기 다지기의 일환이다. 기본기라고 하기엔 너무 기본적인 이론인가 싶지만.. 쌓아가야지 뭐
  • 이번에도 국룰 책 『밑바닥부터 시작하는 딥러닝2』의 Chapter6. 게이트가 추가된 RNN을 참고했다.

1. LSTM 개념

1.1 RNN의 문제점

  • 기울기 소실이나 폭발때문에 장기 의존 관계를 학습하기가 어려움
    • RNN의 역전파에서 기울기는 tanh, +, matmul 연산을 통과하게 되는데,
      • tanh : tanh 값의 미분값은 0에서 1사이의 값으로, 노드를 통과하면 통과할수록 기울기는 작아짐
      • +: 영향을 미치지 않음
      • matmul: 초기값에 따라서 기울기가 시간에 비례해서 폭발하거나 소실
    • 가중치 Wh스칼라일 때, Wh > 1이면 지수적으로 증가하고 Wh < 1이면 지수적으로 감소함
    • 하지만 가중치 Wh행렬일 때, 행렬의 '특잇값' 즉 데이터가 얼마나 퍼져 있는지가 기울기 변화의 척도가 됨
      • 주의할 점: 특잇값의 최댓값이 1보다 크면 지수적으로 증가하고, 1보다 작으면 지수적으로 감소할 확률이 높음. 반드시 그런 건 아님(그런가보다하고 일단 이유는 패스)

1.2 기울기 폭발 대책- 기울기 클리핑

  • g=g||g||×threshold
  • 문턱값 threshold를 설정한 뒤, 기울기의 L2 norm(=||g||)이 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):
  # L2 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)

  # rate가 1보다 작으면 기울기 클리핑 실행, 아니면 변경x
  if rate < 1:
    for grad in grads:
      grad *= rate

1.3 기울기 소실 대책- LSTM

1.3.1 LSTM 설명

  • 기울기 폭발은 기울기 클리핑 기법을 사용하면 해결되지만, 기울기 소실은 전체적인 모델 구조를 뜯어 고쳐야 함
  • 이렇게 등장한 모델이 LSTM(Long Short-Term Memory, 단기기억을 긴 시간동안 지속할 수 있다는 뜻)임
  • RNN과 비교해서 봤을 때 LSTM에는 c라는 기억셀이 추가됨

image.png

1.3.2 LSTM 구조

  • o(output)게이트
    • 기억의 몇%만 흘려보낼지를 조정
    • o=σ(xtWx(o)+ht1Wh(o)+b(o)
    • ht=otanh(ct)
  • f(forget)게이트
    • 무엇을 잊을지를 결정
    • f=σ(xtWx(f)+ht1Wh(f)+b(f))
    • ct=fct1
  • g(새로운 기억셀)
    • 새로 기억해야 할 정보 추가
    • g=tanh(xtWx(g)+ht1Wh(g)+b(g))
  • i(input)게이트
    • g의 정보를 적절히 취사선택
    • i=σ(xtWx(i)+ht1Wh(i)+b(i))
  • 최대한 이해해보려고 노력은 했다..

+참고) tanh vs sigmoid

  • tanh의 출력값은 -1.0 ~ 1.0 / sigmoid의 출력값은 0.0 ~ 1.0임
  • tanh는 그 안에 인코딩된 정보의 강약을 표시 / sigmoid는 데이터를 얼마만큼 통과시킬지 정하는 비율
    -> 따라서 sigmoid는 게이트에서, tanh는 정보를 지니는 데이터에서 주로 사용됨

image.png

1.3.3 LSTM은 어떻게 기울기 소실을 방지하는가

  • 기억셀 c의 역전파에 주목해보면 LSTM이 기울기 소실을 어떻게 방지하는지 알 수 있음
  • 아래 그림을 보면 역전파 과정에서 c는 +와 x노드를 지나게 됨
    • +: 기울기 변화에 영향x
    • x: 이 노드는 행렬곱이 아니라 원소별곱(아마다르곱)임. RNN에서는 매번 같은 가중치 행렬을 사용해서 행렬곱을 하기 때문에 기울기 소실/폭발이 일어나지만, LSTM에서는 매번 새로운 게이트값을 이용해서 원소별곱을 계산하기 때문에 곱셈의 효과가 누적되지 않아서 기울기 소실이 일어나지 않는(어려운) 것임 (..더 파고들진 않겠음. 다음 기회에..)
      • x노드의 계산은 forget게이트가 제어함. forget게이트가 잊어야 한다고 판단한 기억셀의 원소의 기울기는 작아지고, 잊으면 안 된다고 판단한 기억셀의 원소의 기울기는 그대로 전해지는 원리임

image.png

2. LSTM 구현

  • 구현하기 전에 f, g, i, o 모두에 있는 아핀 변환(xWx+hWh+b)을 하나의 아핀변환으로 합쳐줄거임. 이렇게 하면
    • 장점1. 계산속도가 빨라짐 ( 행렬 라이브러리는 일반적으로 큰 행렬을 한꺼번에 계산할 때 속도가 빠르기 때문)
    • 장점2. 소스코드가 간결해짐 ( 가중치를 하나로 모아서 관리하기 때문)

image.png

class LSTM:
  def __init__(self, Wx, Wh, b):
    self.params = [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

    # 4개의 아핀변환을 1개의 아핀변환으로 바꿔줌
    A = np.matmul(x, Wx) + np.matmul(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)
    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.

3. Time LSTM 구현(계층 전체)

  • 이제 계층 전체를 한번에 구현해볼거임
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):
      # 매 시간단계에서 새로운 LSTM 레이어 생성
      layer = LSTM(*self.params)
      # 현재 시간단계의 은닉상태와 셀상태 계산
      self.h, self.c = layer.forward(xs[:,t,:], self.h, self.c) # xs[:,t,:]: t시점의 입력데이터
      # 은닉상태 저장
      hs[:,t,:] = self.h

      # 레이어를 리스트에 저장(나중에 역전파시 사용됨)
      self.layers.append(layer)

    return hs

  def backward(self, dhss):
    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

    # grads에 저장된 기울기를 self.grads에 복사
    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

4. 소감

  • 처음에는 이게 무슨소리야 했던 LSTM의 f, g, i, o 구조들이 반복해서 읽으면 읽을수록 조금씩 가닥이 잡히는 게 느껴진다.
  • 이렇게 밑바닥 기초부터 하나씩 잡아나가니 안정감이 느껴져서 좋다.
  • 라이브러리만 무작정 사용해서 경험을 하는 게 아니라 시간 날 때마다 원리부터 차근차근 공부해 가야겠다.
  • 파이팅
    ...
  • 24.10.05.: 그 사이에 발전했다는 걸 느낀다. 뿌듯하다.