DL/NLP

[NLP] RNN 메커니즘 Numpy 직접 구현 실습

moonzoo 2025. 5. 9. 16:17

https://mz-moonzoo.tistory.com/80

[DL] RNN - Recurrent Neural Networks 이론 정리

0. RNN (Recurrent Neural Network) RNN(Recurrent Neural Network)은 시간적으로 순차적인 데이터를 처리할 수 있도록 설계된 인공신경망으로, 과거 정보를 현재의 계산에 활용할 수 있는 순환 구조를 가진 모델

mz-moonzoo.tistory.com

 
 
안녕하세요! 이전에 RNN 이론에 대해 정리했던 글에 이어, 드디어 실습 코드를 공유하게 되었습니다.
사실 많이 사용되는 정형화된 예시 데이터보다는 좀 더 흥미로운 데이터셋을 찾아 적용해보고 싶은 마음에 시간이 조금 걸렸네요.
 
RNN 구현에 대한 글은 두 편으로 나누어 작성할 예정입니다. 첫 번째 글인 이번 편에서는 RNN의 핵심 메커니즘을 NumPy로 직접 구현하여 순환 가중치가 업데이트되는 과정을 눈으로 확인해보는 시간을 갖겠습니다. 그리고 다음 편에서는 TensorFlow/Keras 라이브러리를 사용하여 실제 기업 정보 데이터셋의 기업명을 통해 업종을 분류하는 모델을 개발하는 과정을 다룰 예정입니다.
 
그럼 이번 글에서는 RNN 메커니즘을 구현해보도록 하겠습니다.


1. RNN 구성 요소

TensorFlow나 PyTorch 같은 딥러닝 라이브러리 덕분에 우리는 복잡한 모델도 비교적 손쉽게 구현하고 학습시킬 수 있습니다. 하지만 핵심 원리를 직접 들여다보는 것이 모델에 대한 더 깊은 이해를 가져다주기도 합니다.
 
이번 포스팅에서는 바로 그 RNN의 핵심 순환 가중치(Whh)가 어떻게 정보를 기억하고 학습 과정에서 업데이트되는지를 NumPy만을 사용하여 단계별로 구현해보겠습니다.
 
우선 목표는 다음과 같은 기능을 가진 아주 간단한 SimpleRNN 모델을 만드는 것입니다.

  1. 입력 시퀀스를 받아 순차적으로 처리 (순전파)
  2. 예측값과 실제값의 차이(손실) 계산
  3. 시간을 거슬러 올라가며 각 가중치의 그래디언트 계산 (BPTT: Backpropagation Through Time)
  4. 계산된 그래디언트를 사용하여 가중치 업데이트

SimpleRNN의 핵심 구성 요소
우리가 만들 SimpleRNN은 다음과 같은 주요 구성 요소를 가집니다.

  • 입력층 ($x_t$) : 각 타임스텝(time step)에서 시퀀스의 한 요소를 받습니다.
  • 은닉층 ($W_{xh}x_t + W_{hh}h_{t-1}$) : 현재 입력 xt와 이전 타임스텝의 은닉 상태 ht1을 입력으로 받아, 가중치와 편향을 이용한 연산(Wxh_xt+Whh_ht1+bh) 후 활성화 함수를 통과하여 현재 타임스텝의 은닉 상태 ht를 계산합니다. 이 ht가 다음 타임스텝으로 전달되는 '기억'의 역할을 합니다
  • 출력층 ($h_t$) : 현재 타임스텝의 은닉 상태 ht를 입력으로 받아, 가중치와 편향을 이용한 연산(ht_Why+by) 후 (활성화 함수를 통과하여) 최종 예측값 yt를 출력합니다.
  • 가중치 행렬:
    • : 입력에서 은닉층으로의 가중치
    • : 이전 은닉 상태에서 현재 은닉 상태로의 가중치 (이것이 순환 가중치)
    • : 은닉층에서 출력층으로의 가중치
  • 편향(Bias): (은닉층 편향), (출력층 편향)

자세한 설명은 RNN 이론을 작성한 글에서 확인해주세요!
 


2. Numpy를 통한 SimpleRNN 구현 단계

 
그럼 이제 단계별로 구현을 진행해보겠습니다.
 

1단계: 필요한 함수 정의하기

먼저, RNN 구현에 필요한 몇 가지 기본적인 함수들을 정의하겠습니다.

  • 활성화 함수: RNN의 은닉층에서는 주로 tanh 함수가 사용됩니다.
  • 손실 함수: 모델의 예측이 실제값과 얼마나 다른지 측정하는 함수입니다. 여기서는 구현의 단순성을 위해 평균 제곱 오차(Mean Squared Error, MSE)를 사용하겠습니다. MSE는 예측값과 실제값 사이의 차이를 제곱하여 평균낸 값으로, 모델의 오차를 측정합니다. 수식에 0.5를 곱하는 것은 미분 계산 시 상수 2와 상쇄되어 수식을 간결하게 만드는 관례적인 방법이며, 손실 함수의 최소값을 찾는 데는 영향을 주지 않습니다.
import numpy as np

# --- 활성화 함수 및 그 도함수 ---
def tanh(x):
    """tanh 활성화 함수"""
    return np.tanh(x)

def dtanh(tanh_x):
    """tanh 함수의 도함수. 입력은 이미 tanh(x)가 적용된 값입니다."""
    # tanh'(x) = 1 - tanh^2(x)
    return 1 - tanh_x**2

# --- 손실 함수 (Mean Squared Error - MSE) ---
def mse_loss(y_true, y_pred):
    D = y_true.shape[1] # 출력 벡터의 차원 수
    if D == 0:
        return 0
    squared_errors_sum = np.sum((y_pred - y_true)**2)
    mse = squared_errors_sum / D
    return mse

 
tanh함수는 하이퍼볼릭 탄젠트(Hyperbolic Tangent) 함수를 의미합니다. 이 함수는 입력값을 받아 -1과 1 사이의 값으로 변환해주는 S자 형태의 곡선을 가집니다.
 
dtanh 함수는 tanh 함수의 도함수(derivative), 즉 미분값을 계산하는 함수입니다. 도함수는 어떤 함수의 특정 지점에서의 순간적인 변화율을 의미합니다.
 
RNN의 학습(역전파) 과정에서 dtanh가 필수적인 이유:
RNN을 포함한 모든 신경망은 역전파(Backpropagation) 알고리즘을 통해 학습합니다. 역전파는 모델의 예측값과 실제값 사이의 오차(손실)를 계산한 뒤, 이 오차를 줄이는 방향으로 각 가중치를 얼마나 조절해야 할지 계산하는 과정입니다. 이때 미분의 연쇄 법칙(Chain Rule)이 사용됩니다. "tanh 함수를 통과할 때의 변화율은 이만큼이었으니, 그래디언트를 이만큼 조절해서 앞으로 전달해야 해!" 라고 알려주는 역할을 하는 것이죠.
 
그래서 저희는 역전파를 직접 구현해야 하므로, dtanh도 함께 구현합니다.
 
 
 

2단계: SimpleRNNNumpy 클래스 구현하기

이제 본격적으로 SimpleRNN 모델을 클래스로 구현해보겠습니다.

class SimpleRNNNumpy:
    def __init__(self, vocab_size, hidden_size, output_size, learning_rate=0.01):
        self.vocab_size = vocab_size  # 입력 벡터의 차원 (예: 원-핫 인코딩된 단어 수)
        self.hidden_size = hidden_size # 은닉 상태 벡터의 차원
        self.output_size = output_size # 출력 벡터의 차원
        self.learning_rate = learning_rate

        # 가중치 초기화: 작은 랜덤 값으로 시작합니다.
        self.Wxh = np.random.randn(vocab_size, hidden_size) * 0.01
        self.Whh = np.random.randn(hidden_size, hidden_size) * 0.01 # 순환 가중치!
        self.Why = np.random.randn(hidden_size, output_size) * 0.01
        
        # 편향 초기화: 0으로 시작합니다.
        self.bh = np.zeros((1, hidden_size))
        self.by = np.zeros((1, output_size))

        # 역전파 시 중간 계산값을 저장하기 위한 딕셔너리
        self.cache = {}

 

  • vocab_size: 어휘 크기 또는 입력 특성(feature)의 차원 수를 의미합니다.
    • 예를 들어, 원-핫 인코딩된 단어를 입력으로 사용한다면, 전체 단어 집합의 크기가 됩니다. 우리 예제에서는 심볼의 종류 수(예: 0, 1, 2 세 종류면 vocab_size=3)를 나타냅니다.
  • hidden_size: RNN의 은닉 상태(hidden state) 벡터의 크기(차원)를 의미합니다.
    • 은닉 상태는 RNN이 시퀀스의 정보를 기억하고 처리하는 핵심적인 부분입니다. hidden_size는 이 "기억 용량" 또는 "정보 처리 능력"의 크기를 결정하는 중요한 하이퍼파라미터입니다. 값이 클수록 더 복잡한 패턴을 기억할 수 있지만, 계산량이 늘어나고 과적합의 위험도 커질 수 있습니다.
  • output_size: 모델의 최종 출력 벡터의 크기(차원)를 의미합니다.
    • 예를 들어, 다음 심볼을 예측하는 문제에서 어휘 크기가 3이라면, 출력도 3차원 벡터(각 심볼에 대한 확률분포 등)가 될 수 있으므로 output_size=3이 됩니다. 분류 문제라면 클래스의 개수가 될 수 있습니다.

 

  • self.Wxh (Input-to-Hidden Weights):
    • 의미: 입력 에서 은닉 상태 로 전달되는 신호를 조절하는 가중치 행렬입니다.
    • np.random.randn(vocab_size, hidden_size): 평균이 0이고 표준편차가 1인 표준 정규 분포를 따르는 난수로 채워진 행렬을 생성합니다. 이 행렬의 크기(shape)는 (vocab_size, hidden_size)가 됩니다. 입력 벡터(크기 vocab_size)와 곱해져 은닉 상태 벡터(크기 hidden_size)에 영향을 줍니다.
    • * 0.01: 생성된 난수 값에 0.01을 곱하여 매우 작은 값으로 만듭니다. 이는 학습 초기에 가중치가 너무 커서 활성화 함수(예: tanh)가 포화 영역에 들어가 학습이 잘 안 되는 것을 방지하고, 학습 과정을 안정적으로 시작하도록 돕습니다.
  • self.Whh (Hidden-to-Hidden Weights):
    • 의미: RNN의 핵심! 순환 가중치라고 불리며, 이전 타임스텝의 은닉 상태 에서 현재 타임스텝의 은닉 상태 로 전달되는 신호를 조절하는 가중치 행렬입니다. 이 가중치를 통해 RNN은 과거의 정보를 "기억"하고 현재 계산에 반영합니다.
    • np.random.randn(hidden_size, hidden_size): 크기가 (hidden_size, hidden_size)인 정방 행렬입니다. 이전 은닉 상태 벡터(크기 hidden_size)와 곱해져 현재 은닉 상태 벡터(크기 hidden_size)에 영향을 줍니다.
    • * 0.01: Wxh와 마찬가지로 작은 값으로 초기화합니다.
  • self.Why (Hidden-to-Output Weights):
    • 의미: 현재 타임스텝의 은닉 상태 에서 최종 출력 로 전달되는 신호를 조절하는 가중치 행렬입니다.
    • np.random.randn(hidden_size, output_size): 크기가 (hidden_size, output_size)인 행렬입니다. 은닉 상태 벡터(크기 hidden_size)와 곱해져 출력 벡터(크기 output_size)를 만듭니다.
    • * 0.01: 역시 작은 값으로 초기화합니다.
  • self.bh (Hidden bias):
    • 의미: 은닉층 계산 시 더해지는 편향 벡터입니다. 
    • np.zeros((1, hidden_size)): 크기가 (1, hidden_size)인 0으로 채워진 벡터로 초기화합니다. 편향은 보통 0이나 작은 상수로 초기화하는 경우가 많습니다.
  • self.by (Output bias):
    • 의미: 출력층 계산 시 더해지는 편향 벡터입니다.
    • np.zeros((1, output_size)): 크기가 (1, output_size)인 0으로 채워진 벡터로 초기화합니다.

2-1. 순전파 (Forward Pass) 구현

순전파는 입력 시퀀스를 시간 순서대로 하나씩 처리하며 각 타임스텝의 은닉 상태와 출력을 계산하는 과정입니다.

  • 현재 타임스텝 t의 은닉 상태 ht는 현재 입력 xt와 이전 타임스텝의 은닉 상태 ht−1을 사용하여 다음과 같이 계산됩니다: 

 

  • 현재 타임스텝 t의 출력 yt는 현재 은닉 상태 ht를 사용하여 계산됩니다 (여기서는 간단한 선형 출력): yt=htWhy+by
def forward(self, inputs_sequence):
        """
        순전파를 수행합니다.
        inputs_sequence: 타임스텝별 입력 벡터의 리스트 (예: [[1,0,0], [0,1,0], ...])
        """
        T = len(inputs_sequence)  # 시퀀스 길이
        
        # 중간값 저장을 위해 캐시 초기화
        self.cache['x'] = {}          # 각 타임스텝의 입력 x_t
        self.cache['h_linear'] = {}   # 은닉층 활성화 전 값 (Wx*x + Wh*h_prev + b)
        self.cache['h'] = {0: np.zeros((1, self.hidden_size))} # 초기 은닉 상태 h_0 (0번 인덱스에 저장)
        self.cache['y_pred'] = {}     # 각 타임스텝의 예측값 y_t_pred

        outputs_sequence_pred = [] # 최종 예측값들을 저장할 리스트

        for t in range(T):
            # 현재 타임스텝의 입력 xt와 이전 타임스텝의 은닉 상태 ht_prev 가져오기
            xt = np.array(inputs_sequence[t]).reshape(1, -1) 
            ht_prev = self.cache['h'][t] 

            self.cache['x'][t] = xt

            # 은닉 상태 계산
            ht_linear = np.dot(xt, self.Wxh) + np.dot(ht_prev, self.Whh) + self.bh
            ht = tanh(ht_linear)
            
            self.cache['h_linear'][t] = ht_linear
            self.cache['h'][t+1] = ht # 다음 계산을 위해 현재 은닉 상태 저장 (t+1 인덱스 사용)

            # 출력 계산 (선형 출력)
            yt_pred = np.dot(ht, self.Why) + self.by
            self.cache['y_pred'][t] = yt_pred
            outputs_sequence_pred.append(yt_pred)
            
        return outputs_sequence_pred


 
 
순전파 과정에서 self.cache에 각 타임스텝의 입력(xt), 은닉 상태 계산 전 선형 값(h_t_linear), 활성화된 은닉 상태(ht), 그리고 예측값(y_t_pred)을 저장합니다. 이 값들은 나중에 역전파 과정에서 그래디언트를 계산하는 데 사용됩니다.
 
 

  • xt = np.array(inputs_sequence[t]).reshape(1, -1):
    • inputs_sequence[t]: 현재 타임스텝 에 해당하는 입력 벡터를 가져옵니다. (예: t=0일 때, inputs_sequence[0]는 [1,0,0])
    • np.array(...): 이를 NumPy 배열로 변환합니다.
    • .reshape(1, -1): 이 배열을 (1, vocab_size) 형태의 2차원 행렬로 만듭니다. 1은 배치 크기가 1임을 의미하고 (한 번에 하나의 샘플씩 처리), -1은 NumPy가 vocab_size에 맞게 열의 수를 자동으로 결정하도록 합니다. 이렇게 형태를 바꾸는 이유는 뒤따르는 가중치 행렬과의 곱셈 연산을 위해서입니다.
  • ht_prev = self.cache['h'][t]: 이전 타임스텝의 은닉 상태 를 cache에서 가져옵니다.
    • 첫 번째 타임스텝()에서는 self.cache['h'][0]에 저장된 초기 은닉 상태 (영벡터)가 사용됩니다.
    • 두 번째 타임스텝()에서는 이전 반복에서 계산되어 self.cache['h'][1]에 저장된 이 ht_prev가 됩니다.
  • self.cache['x'][t] = xt: 현재 처리 중인 입력 를 나중(역전파)에 사용하기 위해 cache에 저장합니다.

 

  • ht_linear = np.dot(xt, self.Wxh) + np.dot(ht_prev, self.Whh) + self.bh: (은닉 상태 계산 핵심!)
    • np.dot(xt, self.Wxh): 현재 입력 와 입력-은닉 가중치 를 곱합니다. 이는 현재 입력이 은닉 상태에 미치는 영향을 계산합니다. ()
    • np.dot(ht_prev, self.Whh): 이전 은닉 상태 과 은닉-은닉(순환) 가중치 를 곱합니다. 이것이 RNN이 과거의 정보를 현재로 가져오는 핵심 부분입니다. ()
    • + self.bh: 계산된 값에 은닉층 편향 를 더합니다.
    • 이 모든 것을 합한 ht_linear는 활성화 함수를 통과하기 전의 선형 계산 결과입니다.
  • ht = tanh(ht_linear): ht_linear 값에 tanh 활성화 함수를 적용하여 최종적인 현재 은닉 상태 를 계산합니다. tanh 함수는 값을 -1과 1 사이로 압축하고 비선형성을 추가하여 모델의 표현력을 높입니다.

2-2. 역전파 (Backward Pass - BPTT) 구현

역전파는 시퀀스의 마지막 타임스텝부터 첫 번째 타임스텝까지 시간을 거슬러 올라가며 각 가중치에 대한 손실의 그래디언트(기울기)를 계산하는 과정입니다. 이것을 BPTT(Backpropagation Through Time)라고 부릅니다.
 

def backward(self, inputs_sequence, targets_sequence, outputs_sequence_pred):
        """
        역전파 (BPTT - Backpropagation Through Time)를 수행합니다.
        """
        T = len(inputs_sequence)
        
        # 그래디언트 변수들을 가중치와 동일한 크기로 초기화 (0으로)
        dWxh, dWhh, dWhy = np.zeros_like(self.Wxh), np.zeros_like(self.Whh), np.zeros_like(self.Why)
        dbh, dby = np.zeros_like(self.bh), np.zeros_like(self.by)
        
        # 다음 타임스텝(t+1)으로부터 현재 타임스텝(t)의 은닉 상태로 전달될 그래디언트
        # 초기에는 0으로 시작 (가장 마지막 타임스텝에서는 미래가 없음)
        dh_next = np.zeros((1, self.hidden_size)) 
        
        total_loss_for_sequence = 0 # 시퀀스 전체의 누적 손실 (정보용)

        # 시퀀스의 마지막부터 처음까지 역방향으로 순회
        for t in reversed(range(T)):
            xt = self.cache['x'][t]
            ht = self.cache['h'][t+1]       # 순전파 시 t+1 인덱스에 저장된 h_t
            ht_prev = self.cache['h'][t]    # 이전 은닉 상태 h_{t-1}

            yt_pred = self.cache['y_pred'][t]
            yt_true = np.array(targets_sequence[t]).reshape(1, -1)

            # 현재 타임스텝의 손실 (정보용)
            loss_t = mse_loss(yt_true, yt_pred)
            total_loss_for_sequence += loss_t
            
            # --- 1. 출력층의 그래디언트 계산 ---
            # 손실 함수(MSE)를 예측값 yt_pred로 미분한 값: (yt_pred - yt_true)
            # (mse_loss 정의에서 0.5를 곱했으므로, 최종 미분은 (yt_pred - yt_true) / N)
            # 현재 N=1 (샘플 하나) 이므로, (yt_pred - yt_true) / yt_true.shape[0]
            dy_pred = (yt_pred - yt_true) / yt_true.shape[0] # dL/dy_pred

            # Why와 by에 대한 그래디언트 누적
            # dL/dWhy = dL/dy_pred * dy_pred/dWhy = dy_pred * ht
            dWhy += np.dot(ht.T, dy_pred)
            # dL/dby = dL/dy_pred * dy_pred/dby = dy_pred * 1
            dby += dy_pred
            
            # --- 2. 은닉층의 그래디언트 계산 (BPTT의 핵심) ---
            # 현재 은닉 상태 ht에 대한 그래디언트 (dL/dht)
            # 이 그래디언트는 두 곳으로부터 옵니다:
            #   a) 현재 타임스텝의 출력층으로부터: dy_pred @ Why.T
            #   b) 다음 타임스텝 t+1의 은닉 상태로부터: dh_next (이전 반복에서 계산됨)
            dh = np.dot(dy_pred, self.Why.T) + dh_next
            
            # tanh 활성화 함수의 그래디언트 적용
            # dL/d(ht_linear) = dL/dht * dht/d(ht_linear)
            # dht/d(ht_linear)는 tanh의 도함수인 dtanh(ht) 즉, (1 - ht^2)
            dh_raw = dh * dtanh(ht) # dtanh는 이미 tanh가 적용된 ht를 인자로 받음

            # bh (은닉층 편향)에 대한 그래디언트 누적
            # dL/dbh = dL/d(ht_linear) * d(ht_linear)/dbh = dh_raw * 1
            dbh += dh_raw
            
            # Wxh (입력->은닉 가중치)에 대한 그래디언트 누적
            # dL/dWxh = dL/d(ht_linear) * d(ht_linear)/dWxh = dh_raw * xt
            dWxh += np.dot(xt.T, dh_raw)
            
            # Whh (순환 가중치)에 대한 그래디언트 누적! 이것이 핵심입니다.
            # dL/dWhh = dL/d(ht_linear) * d(ht_linear)/dWhh = dh_raw * ht_prev
            dWhh += np.dot(ht_prev.T, dh_raw)
            
            # 다음 반복(t-1)을 위해, 현재의 dh_raw가 이전 은닉 상태 ht_prev에 미치는 영향(dh_next_for_prev_step)을 계산합니다.
            # 이것이 바로 그래디언트가 시간을 거슬러 전파되는 부분입니다.
            # dL/dh_{t-1} = dL/d(ht_linear) * d(ht_linear)/dh_{t-1} = dh_raw @ Whh.T
            dh_next = np.dot(dh_raw, self.Whh.T)
            
        # (선택 사항) 그래디언트 클리핑: 그래디언트 폭주(exploding gradients)를 막기 위해 사용합니다.
        # 여기서는 매우 간단한 형태로 구현하거나, 값의 범위를 보고 생략할 수 있습니다.
        clip_value = 5.0 
        for dparam in [dWxh, dWhh, dWhy, dbh, dby]:
            np.clip(dparam, -clip_value, clip_value, out=dparam) # dparam 자체를 변경
            
        return total_loss_for_sequence, dWxh, dWhh, dWhy, dbh, dby

 
 
BPTT 과정에서 가장 중요한 부분은 dWhh와 dh_next가 계산되는 방식입니다. dWhh는 이전 은닉 상태 ht_prev를 사용하여 계산되는데, 이는 순환 가중치가 과거의 정보로부터 어떻게 학습하는지를 보여줍니다. dh_next는 현재 타임스텝의 그래디언트가 이전 타임스텝으로 어떻게 흘러가는지를 나타내며, RNN이 시간적 의존성을 학습하는 근간이 됩니다.
 
 
1. 초기 설정

# 그래디언트 변수들을 가중치와 동일한 크기로 초기화 (0으로)
dWxh, dWhh, dWhy = np.zeros_like(self.Wxh), np.zeros_like(self.Whh), np.zeros_like(self.Why)
dbh, dby = np.zeros_like(self.bh), np.zeros_like(self.by)

# 다음 타임스텝(t+1)으로부터 현재 타임스텝(t)의 은닉 상태로 전달될 그래디언트
# 초기에는 0으로 시작 (가장 마지막 타임스텝에서는 미래가 없음)
dh_next = np.zeros((1, self.hidden_size))

 

  • dWxh, dWhh, dWhy, dbh, dby: 모델의 각 가중치 행렬(Wxh,Whh,Why)과 편향 벡터(bh,by)에 대한 그래디언트를 저장할 변수들입니다.
  • np.zeros_like(...): 해당 가중치/편향과 동일한 크기(shape)를 가지면서 모든 요소가 0인 배열을 생성하여 초기화합니다. 역전파 과정에서 각 타임스텝별로 계산된 그래디언트가 이 변수들에 누적됩니다.
  • dh_next: 이 변수는 BPTT의 핵심적인 부분 중 하나입니다. RNN에서는 현재 타임스텝 t의 은닉 상태 ht가 다음 타임스텝 t+1의 은닉 상태 ht+1 계산에 영향을 미칩니다. 따라서 역전파 시에는, ht+1에 대한 손실의 그래디언트가 ht로 흘러 들어오게 됩니다. dh_next는 바로 이 "미래(t+1)로부터 현재(t)로 전달되는 ht에 대한 그래디언트"를 저장하는 변수입니다.
  • 시간의 역순으로 계산하므로, 가장 마지막 타임스텝(T−1)에서는 그 이후의 시간이 없으므로, dh_next는 0으로 시작합니다.

 
2. 타임스텝별 반복(시간의 역순)
역전파는 순전파와 반대로, 시퀀스의 가장 마지막 타임스텝부터 시작하여 첫 번째 타임스텝으로 거슬러 올라가며 그래디언트를 계산하고 전파합니다.

# 시퀀스의 마지막부터 처음까지 역방향으로 순회
for t in reversed(range(T)):

 
 
3. 현재 타임스텝(t)에 필요한 값 가져오기
순전파 시 self.cache에 저장해두었던 값들을 현재 타임스텝 t에 맞게 가져옵니다.

xt = self.cache['x'][t]             # 현재 입력 x_t
ht = self.cache['h'][t+1]           # 현재 은닉 상태 h_t (forward에서 t+1 인덱스에 저장)
ht_prev = self.cache['h'][t]        # 이전 은닉 상태 h_{t-1} (forward에서 t 인덱스에 저장)

yt_pred = self.cache['y_pred'][t]   # 현재 예측값 y_t_pred
yt_true = np.array(targets_sequence[t]).reshape(1, -1) # 현재 실제 정답값 y_t_true

 
4. 출력층의 그래디언트 계산

# --- 1. 출력층의 그래디언트 계산 ---
# 손실 함수(MSE)를 예측값 yt_pred로 미분한 값
# 이전 답변에서 mse_loss를 0.5 * np.sum((y_pred - y_true)**2) / y_true.shape[1] 로 수정했으므로,
# 그 도함수는 (y_pred - y_true) / y_true.shape[1] 이 됩니다.
dy_pred = (yt_pred - yt_true) / yt_true.shape[1] # dL/dy_pred

# Why와 by에 대한 그래디언트 누적
# dL/dWhy = dL/dy_pred * dy_pred/dWhy = dy_pred * ht (정확히는 ht.T @ dy_pred)
dWhy += np.dot(ht.T, dy_pred)
# dL/dby = dL/dy_pred * dy_pred/dby = dy_pred * 1
dby += dy_pred

 

  • dy_pred: 손실 함수(앞서 정의한 MSE)를 y_t_pred로 편미분한 값입니다.
  • dWhy += np.dot(ht.T, dy_pred), dby += dy_pred : 이제 dy_pred를 사용하여 출력층 가중치 Why와 편향 by에 대한 그래디언트를 계산합니다. (미분의 연쇄 법칙 적용)

6. 은닉층의 그래디언트 계산 (BPTT의 핵심)
이제 한 단계 더 안으로 들어가, 은닉 상태 ht에 대한 그래디언트(∂L /∂ht)를 계산합니다.

# --- 2. 은닉층의 그래디언트 계산 (BPTT의 핵심) ---
# 현재 은닉 상태 ht에 대한 그래디언트 (dL/dht)
# 이 그래디언트는 두 곳으로부터 옵니다:
#   a) 현재 타임스텝의 출력층으로부터: dy_pred @ Why.T
#   b) 다음 타임스텝 t+1의 은닉 상태로부터: dh_next (이전 반복에서 계산됨)
dh = np.dot(dy_pred, self.Why.T) + dh_next
  • dh: 를 나타냅니다. 이 그래디언트는 두 가지 경로로 전달됩니다:
    1. np.dot(dy_pred, self.Why.T): 현재 타임스텝 의 출력 가 영향을 미쳤으므로, 로부터 오는 그래디언트입니다. 
    2. dh_next: "미래" 타임스텝 의 은닉 상태 계산에 현재 가 영향을 미쳤습니다. 따라서 로부터 로 흘러 들어오는 그래디언트입니다. 이것이 바로 시간을 거슬러 그래디언트가 전파되는 BPTT의 핵심입니다. (루프의 첫 반복, 즉 가장 마지막 타임스텝 에서는 dh_next가 0입니다.)

다음으로, 활성화 함수 tanh를 통과하기 전의 값(htlinear=xtWxh+ht−1Whh+bh)에 대한 그래디언트를 계산합니다.

# tanh 활성화 함수의 그래디언트 적용
# dL/d(ht_linear) = dL/dht * dht/d(ht_linear)
# dht/d(ht_linear)는 tanh의 도함수인 dtanh(ht) 즉, (1 - ht^2)
dh_raw = dh * dtanh(ht) # dtanh는 이미 tanh가 적용된 ht를 인자로 받음
  • dh_raw: ∂L/∂_ht_linear를 나타냅니다. ∂L/∂_ht_linear는 tanh 함수의 도함수인 dtanh(ht) (즉, 1−ht^2) 입니다.

이제 dh_raw를 사용하여 은닉층의 가중치 Wxh,Whh와 편향 bh에 대한 그래디언트를 계산합니다.
 

# bh (은닉층 편향)에 대한 그래디언트 누적
# dL/dbh = dL/d(ht_linear) * d(ht_linear)/dbh = dh_raw * 1
dbh += dh_raw

# Wxh (입력->은닉 가중치)에 대한 그래디언트 누적
# dL/dWxh = dL/d(ht_linear) * d(ht_linear)/dWxh = dh_raw * xt
dWxh += np.dot(xt.T, dh_raw)

# Whh (순환 가중치)에 대한 그래디언트 누적! 이것이 핵심입니다.
# dL/dWhh = dL/d(ht_linear) * d(ht_linear)/dWhh = dh_raw * ht_prev
dWhh += np.dot(ht_prev.T, dh_raw)
  • dWhh += np.dot(ht_prev.T, dh_raw): 가장 중요한 순환 가중치 Whh에 대한 그래디언트 ∂L/∂Whh를 계산하여 누적합니다. 이 계산에는 이전 은닉 상태 ht_prev가 사용되는데, 이는 Whh가 과거의 정보(ht−1)를 현재의 계산(h_t_linear)으로 연결하는 역할을 하기 때문입니다.

마지막으로, 현재 타임스텝 t에서 계산된 그래디언트(dh_raw)를 이전 타임스텝 t−1의 은닉 상태 ht−1로 전파하기 위해 dh_next를 업데이트합니다.

# 다음 반복(t-1)을 위해, 현재의 dh_raw가 이전 은닉 상태 ht_prev에 미치는 영향(dh_next_for_prev_step)을 계산합니다.
# 이것이 바로 그래디언트가 시간을 거슬러 전파되는 부분입니다.
# dL/dh_{t-1} = dL/d(ht_linear) * d(ht_linear)/dh_{t-1} = dh_raw @ Whh.T
dh_next = np.dot(dh_raw, self.Whh.T)
  • dh_next = np.dot(dh_raw, self.Whh.T): ∂L/ ∂_ht−1를 계산하여 다음 루프(타임스텝 t−1)에서 사용될 dh_next로 설정합니다. 이 과정을 통해 손실에 대한 정보가 시퀀스의 시작 부분까지 거슬러 올라가며 전파됩니다.

 
7. 그래디언트 클리핑 (선택 사항)
RNN은 긴 시퀀스에서 그래디언트가 너무 커지는 그래디언트 폭주(exploding gradients) 문제가 발생하기 쉽습니다. 이를 방지하기 위해 그래디언트의 크기를 일정 범위로 제한하는 기법입니다.

# (선택 사항) 그래디언트 클리핑: 그래디언트 폭주(exploding gradients)를 막기 위해 사용합니다.
clip_value = 5.0 
for dparam in [dWxh, dWhh, dWhy, dbh, dby]:
    np.clip(dparam, -clip_value, clip_value, out=dparam) # dparam 자체를 변경
  • np.clip(dparam, -clip_value, clip_value, out=dparam): 각 그래디언트 dparam의 모든 요소 값을 -clip_value와 clip_value 사이로 강제합니다.

2-3. 가중치 업데이트 구현

계산된 그래디언트를 사용하여 모델의 가중치와 편향을 업데이트합니다. 여기서는 가장 기본적인 경사 하강법(Gradient Descent)을 사용합니다. 업데이트 규칙: W_new=W_old−learning_rate × ∂L/ ∂W_old

def update_weights(self, dWxh, dWhh, dWhy, dbh, dby):
        """단순 경사 하강법으로 가중치를 업데이트합니다."""
        self.Wxh -= self.learning_rate * dWxh
        self.Whh -= self.learning_rate * dWhh # 순환 가중치 Whh가 업데이트됩니다!
        self.Why -= self.learning_rate * dWhy
        self.bh  -= self.learning_rate * dbh
        self.by  -= self.learning_rate * dby

 
여기까지가 가중치 업데이트 진행하는 코드이고... 이제 구현한 해당 메커니즘을 통해 더미데이터의 가중치를 업데이트해보겠습니다.
 


이제 더미데이터로 가중치가 업데이트 되는 과정을 눈으로 확인해보시겠습니다.

# --- 4. 더미 데이터 생성 및 학습 루프 ---

# 하이퍼파라미터
vocab_size = 3    # 입력/출력 단어(심볼)의 종류 수
hidden_size = 4   # 은닉 상태의 크기 (자유롭게 설정)
output_size = vocab_size # 다음 심볼을 예측하므로 vocab_size와 동일
learning_rate = 0.1
epochs = 100

# 더미 시퀀스 데이터: 0 -> 1 -> 2 -> 0 (순환)
# 입력: [0, 1, 2], 타겟: [1, 2, 0] (다음 스텝 예측)
# 원-핫 인코딩으로 표현
# x_0 = [1,0,0], x_1 = [0,1,0], x_2 = [0,0,1]
# y_0_true = [0,1,0], y_1_true = [0,0,1], y_2_true = [1,0,0]

inputs_indices = [0, 1, 2]
targets_indices = [1, 2, 0] 

# 원-핫 인코딩된 시퀀스 생성
inputs_sequence_onehot = [np.eye(vocab_size)[i] for i in inputs_indices]
targets_sequence_onehot = [np.eye(vocab_size)[i] for i in targets_indices]

# RNN 모델 인스턴스 생성
rnn = SimpleRNNNumpy(vocab_size, hidden_size, output_size, learning_rate)

print("--- 학습 시작 ---")
print(f"순환 가중치 Whh (초기 상태 일부):\n{rnn.Whh[:2, :2]}\n") # Whh의 일부 값만 출력

# 학습 루프
for epoch in range(epochs):
    # 1. 순전파
    predictions_sequence = rnn.forward(inputs_sequence_onehot)
    
    # 2. 역전파 (BPTT)
    total_loss, dWxh, dWhh, dWhy, dbh, dby = rnn.backward(inputs_sequence_onehot, targets_sequence_onehot, predictions_sequence)
    
    # 3. 가중치 업데이트
    rnn.update_weights(dWxh, dWhh, dWhy, dbh, dby)
    
    # 10 에포크마다 손실 및 Whh의 변화 출력
    if (epoch + 1) % 10 == 0:
        print(f"Epoch {epoch + 1}/{epochs}, Loss: {total_loss:.4f}")
        print(f"  순환 가중치 Whh (일부 업데이트):\n{rnn.Whh[:2, :2]}\n")
--- 학습 시작 ---
순환 가중치 Whh (초기 상태 일부):
[[-0.01062304  0.00473592]
 [-0.00783253 -0.00322062]]

Epoch 10/100, Loss: 0.3328
  순환 가중치 Whh (일부 업데이트):
[[-0.01060539  0.00489137]
 [-0.00781459 -0.00339613]]

Epoch 20/100, Loss: 0.3281
  순환 가중치 Whh (일부 업데이트):
[[-0.01064966  0.0055517 ]
 [-0.00807516 -0.00541251]]

Epoch 30/100, Loss: 0.3003
  순환 가중치 Whh (일부 업데이트):
[[-0.01078553  0.00827648]
 [-0.01003287 -0.0189621 ]]

Epoch 40/100, Loss: 0.1778
  순환 가중치 Whh (일부 업데이트):
[[-0.01116764  0.01790587]
 [-0.02180755 -0.08326324]]

Epoch 50/100, Loss: 0.0207
  순환 가중치 Whh (일부 업데이트):
[[-0.01218653  0.03012927]
 [-0.0501777  -0.18174685]]

Epoch 60/100, Loss: 0.0005
  순환 가중치 Whh (일부 업데이트):
[[-0.0129922   0.03381806]
 [-0.06171097 -0.20498559]]

Epoch 70/100, Loss: 0.0000
  순환 가중치 Whh (일부 업데이트):
[[-0.0131798   0.03438109]
 [-0.06312459 -0.20627326]]

Epoch 80/100, Loss: 0.0000
  순환 가중치 Whh (일부 업데이트):
[[-0.01321145  0.03446086]
 [-0.06326269 -0.20620749]]

Epoch 90/100, Loss: 0.0000
  순환 가중치 Whh (일부 업데이트):
[[-0.01321647  0.03447281]
 [-0.06327644 -0.20617401]]

Epoch 100/100, Loss: 0.0000
  순환 가중치 Whh (일부 업데이트):
[[-0.01321724  0.03447469]
 [-0.06327785 -0.20616675]]

--- 학습 종료 ---
순환 가중치 Whh (최종 상태 일부):
[[-0.01321724  0.03447469]
 [-0.06327785 -0.20616675]]

 
실행 결과 및 분석
위 코드를 실행하면, 학습이 진행됨에 따라 다음과 같은 변화를 관찰할 수 있습니다.

  1. 손실 감소: 에포크가 반복될수록 Loss 값이 점차 줄어드는 것을 볼 수 있습니다. 이는 모델이 주어진 시퀀스 패턴(0->1, 1->2, 2->0)을 학습하고 있음을 의미합니다. Loss가 0인 시점에서는 가중치가 업데이트도 미미한 것을 확인하실 수 있습니다.
  2. 환 가중치 Whh의 변화: 가장 중요한 부분입니다! "순환 가중치 Whh (초기 상태)", "순환 가중치 Whh (업데이트 중)", "순환 가중치 Whh (최종 상태)"의 출력값을 비교해보면, Whh 행렬의 값들이 학습 과정에서 실제로 계속해서 변하는 것을 확인할 수 있습니다. 이 변화는 BPTT를 통해 계산된 그래디언트와 학습률에 따라 결정되며, 모델이 시퀀스 내의 시간적 의존성을 포착하도록 가중치를 조정하는 과정입니다.

 
학습 후 예측 테스트

# 학습 후 예측 테스트
print("\n--- 학습 후 간단 예측 테스트 ---")
# 테스트 시에는 이전 은닉 상태를 수동으로 관리해야 합니다.
current_h_state = np.zeros((1, hidden_size)) # 초기 은닉 상태
for i in range(len(inputs_sequence_onehot)):
    xt_test = np.array(inputs_sequence_onehot[i]).reshape(1, -1)
    ht_linear_test = np.dot(xt_test, rnn.Wxh) + np.dot(current_h_state, rnn.Whh) + rnn.bh
    ht_test = tanh(ht_linear_test) # 현재 은닉 상태
    yt_pred_test = np.dot(ht_test, rnn.Why) + rnn.by # 현재 예측
    
    predicted_next_symbol_index = np.argmax(yt_pred_test) # 가장 확률 높은 다음 심볼 인덱스
    
    print(f"입력 심볼 인덱스: {inputs_indices[i]}, "
          f"모델이 예측한 다음 심볼 인덱스: {predicted_next_symbol_index} (실제 다음 심볼: {targets_indices[i]})")
    
    current_h_state = ht_test # 다음 예측을 위해 현재 은닉 상태를 업데이트

 
간단 예측 테스트 결과: 학습이 잘 되었다면, "학습 후 간단 예측 테스트" 부분에서 모델이 입력 심볼에 대해 다음 심볼을 비교적 정확하게 예측하는 것을 볼 수 있을 것입니다. (물론, 이 예제는 매우 단순하여 완벽한 예측을 보장하진 않지만, 학습의 경향성을 볼 수 있습니다.)

--- 학습 후 간단 예측 테스트 ---
입력 심볼 인덱스: 0, 모델이 예측한 다음 심볼 인덱스: 1 (실제 다음 심볼: 1)
입력 심볼 인덱스: 1, 모델이 예측한 다음 심볼 인덱스: 2 (실제 다음 심볼: 2)
입력 심볼 인덱스: 2, 모델이 예측한 다음 심볼 인덱스: 0 (실제 다음 심볼: 0)

마치며

NumPy로 SimpleRNN을 직접 구현해보는 과정은 다소 번거로울 수 있지만, 이를 통해 중요한 점들을 직접 체험하고 이해할 수 있습니다. (사실 더 상세하게 설명드리고 싶었지만, 블로그에 수식 적는게 익숙하지 않아서..ㅋㅋㅋ)

  • RNN이 어떻게 시간 순서대로 정보를 처리하고, 이전 타임스텝의 정보(은닉 상태)를 현재 타임스텝의 계산에 활용하는지 (순전파).
  • BPTT가 어떻게 전체 시퀀스에 대한 손실을 각 타임스텝으로 전파하고, 시간을 거슬러 각 가중치(특히 순환 가중치 )의 그레디언트를 계산하는지.
  • 순환 가중치 가 어떻게 과거의 정보를 "기억"하고, 학습을 통해 그 "기억" 방식을 조절해나가는지.

Keras와 같은 라이브러리는 이러한 복잡한 내부 과정을 추상화하여 우리에게 편리함을 제공합니다. 하지만 그 내부 동작 원리를 한 번쯤 직접 들여다보는 경험은 모델을 더 깊이 이해하고, 문제 발생 시 디버깅하거나 새로운 아이디어를 모델에 적용하는 데 언젠간... 도움이 될 수 있습니다!

 

 

다음 글에서는 오늘 살펴본 TensorFlow/Keras를 사용하여 실제 기업 데이터를 다루는 좀 더 실용적인 예제를 들고 와보겠습니다!

 

ML-DL_Basic_study/Deeplearning/Numpy_RNN.ipynb at main · moonjoo98/ML-DL_Basic_study

머신러닝, 딥러닝 기초에 대한 실습 코드입니다. Contribute to moonjoo98/ML-DL_Basic_study development by creating an account on GitHub.

github.com