본문 바로가기
DeepLearning/CS231n

CS231n 2024 Assignment 3: Q1: RNN

by Dev_PSS 2026. 7. 17.

내 풀이 github LINK: https://github.com/qkrtmdtj04/CS231n-Assignment

 

GitHub - qkrtmdtj04/CS231n-Assignment

Contribute to qkrtmdtj04/CS231n-Assignment development by creating an account on GitHub.

github.com

 

RNN의 필요성

Assignment 2까지는 이미지 데이터를 다루며 CNN 기반 모델을 학습했다. 하지만 현실 세계의 데이터는 이미지뿐만 아니라 순차적인 구조(Sequence) 를 가지는 경우가 훨씬 많다. 대표적으로 자연어(NLP), 음성, 시계열 데이터 등이 있다.
이러한 데이터의 핵심 특징은 “이전 정보가 현재에 영향을 준다” 는 점이다. 예를 들어 문장을 생각해보면, 단어 하나하나가 독립적인 것이 아니라 앞선 단어들과의 관계 속에서 의미가 결정된다.
기존의 Fully Connected나 CNN 구조는 이런 순차적인 의존성을 직접적으로 반영하기 어렵다. 이 문제를 해결하기 위해 등장한 것이 바로 Recurrent Neural Network (RNN) 이다.

RNN의 핵심 아이디어는 다음과 같다:

  • 이전 시점의 hidden state를 현재 입력과 함께 사용한다.
  • 동일한 가중치를 시간 축(time step)에 따라 반복적으로 사용한다.
  • sequence 전체를 순차적으로 처리한다.

수식으로 보면 한 스텝에서의 연산은 다음과 같이 표현된다:

CS231N 2O17: L10

  • x_t: 현재 입력
  • h_{t-1}: 이전 hidden state
  • h_t: 현재 hidden state

즉, 현재 입력 + 과거 정보 = 현재 상태 라고 이해하면 된다.
하지만 RNN은 구조적으로 기울기 소실(Vanishing Gradient) 문제가 발생하기 쉽고, 긴 시퀀스를 학습하기 어렵다는 단점도 존재한다. (이 문제는 이후 LSTM, GRU에서 해결된다)
 


Q1 RNN 풀이

Q1-1. Single RNN Step Forward  

  • 가장 먼저, 한 시점(time step)에서의 forward 연산을 구현한다.
  • RNN은 현재 입력 xtx_txt와 이전 hidden state ht−1h_{t-1}ht−1를 함께 받아서 새로운 hidden state를 만들어내는 구조다. 즉, 지금 들어온 정보만 보는 것이 아니라, 앞에서 쌓아온 문맥까지 함께 반영한다는 점이 핵심이다.
def rnn_step_forward(x, prev_h, Wx, Wh, b):

    next_h, cache = None, None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    
    next_h = np.tanh(x.dot(Wx) + prev_h.dot(Wh)+ b)
    cache = (x, prev_h, Wx, Wh, b,next_h)
    
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return next_h, cache
  • 이 코드에서는 입력 x와 이전 hidden state prev_h에 각각 가중치 행렬 Wx, Wh를 곱한 뒤 더하고, bias b를 추가한 다음 tanh를 적용한다.
  • 결국 식으로 보면 ht=tanh⁡(xtWx+ht−1Wh+b) 형태의 가장 기본적인 vanilla RNN cell을 구현한 것이다.
  • 가장 중요한 부분은 cache에 forward 때 사용한 값들을 저장해두는 것이다.
    뒤에서 backward pass를 계산할 때 이 값들이 필요하므로, 단순히 결과만 반환하는 것이 아니라 gradient 계산을 위한 정보까지 함께 보관해야 한다.

Q1-2. Single RNN Step Backward

이제 한 시점에서의 backward pass를 구현한다. forward에서 계산한 hidden state를 기준으로, loss가 다음 hidden state에 대해 전달한 gradient를 다시 입력과 가중치 방향으로 분해하는 과정이다. RNN은 tanh를 사용하므로, 역전파할 때는 tanh의 미분값인 1−tanh⁡2(x)1 - \tanh^2(x)1−tanh2(x)를 곱해주면 된다.

def rnn_step_backward(dnext_h, cache):

    dx, dprev_h, dWx, dWh, db = None, None, None, None, None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    x, prev_h, Wx, Wh, b, next_h = cache

    dtahn = dnext_h * (1 - next_h * next_h)
    #print(dtahn.shape)
    db = np.sum(dtahn,axis=0)
    dx = dtahn.dot(Wx.T) 
    dWx = x.T.dot(dtahn) 
    dprev_h = dtahn.dot(Wh.T) 
    dWh = prev_h.T.dot(dtahn) 


    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return dx, dprev_h, dWx, dWh, db
  • 이 코드의 흐름은 생각보다 단순하다. 먼저 dnext_h에 tanh의 local derivative를 곱해서 tanh를 통과한 gradient dtahn을 만든다. 그 다음 이 gradient를 이용해 입력 x, 이전 hidden state prev_h, 가중치 Wx, Wh, bias b에 대한 gradient를 각각 계산한다.usmanr149.github+1
  • 특히 db는 각 샘플에 대해 더해진 값을 모두 합쳐야 하므로 axis=0 기준으로 sum을 취한다. 또한 dx, dprev_h, dWx, dWh는 모두 행렬곱의 형태로 자연스럽게 구할 수 있어서, forward 때의 연산을 거꾸로 따라간다고 생각하면 이해가 쉽다.

한 줄로 정리하면, 이 함수는 한 timestep에서 발생한 gradient를 입력과 파라미터 쪽으로 정확히 분배해주는 RNN의 기본 역전파 단계다.


Q1-3. Vanilla RNN: Forward

이제 단일 timestep이 아니라 전체 sequence에 대한 forward pass를 구현한다. RNN은 각 시점의 입력을 하나씩 순서대로 받아들이면서 hidden state를 계속 갱신하기 때문에, for loop를 사용해 시간축 방향으로 차례대로 계산하면 된다.

def rnn_forward(x, h0, Wx, Wh, b):

    h, cache = None, None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    N,T,D = x.shape
    cache = {'D':D}
    H = Wh.shape[0]
    h = np.zeros((N ,T, H))
    h0x, cache['rnn_t0'] = rnn_step_forward(x[:,0,:], h0, Wx, Wh, b)

    h[:,0,:] = h0x

    for t in range(T-1):
        h[:,t+1,:], cache[f'rnn_t{t+1}'] = rnn_step_forward(x[:,t+1,:], h[:,t,:], Wx, Wh, b)

    #print(cache.keys())
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return h, cache
  •  이 구현의 핵심은 각 timestep의 출력 hidden state를 h에 저장하면서, 동시에 backward pass를 위해 cache도 함께 보관하는 것이다.
  • 첫 번째 시점은 h0를 초기 hidden state로 사용하고, 그 이후부터는 직전 timestep의 hidden state를 다음 timestep의 입력처럼 넘겨준다. 코드를 보면 rnn_step_forward를 여러 번 호출하는 구조인데, 이는 사실상 동일한 RNN cell을 시간축 방향으로 펼쳐놓은 것과 같다. 즉, weight는 공유되지만 입력만 시간에 따라 달라지는 형태이므로, sequence modeling에서 RNN이 자연스럽게 동작할 수 있다.
  • 정리하면, 이 함수는 하나의 문장이나 시계열 전체를 받아서 각 timestep마다 hidden state를 쌓아가는 RNN의 기본 forward 과정이다.

Q1-4. Vanilla RNN: Backward

이제 전체 sequence에 대한 backward pass를 구현한다. RNN은 시간축을 따라 hidden state가 연결되어 있으므로, 역전파도 마지막 timestep부터 처음 timestep 방향으로 진행해야 한다. 즉, Backprop Through Time(BPTT)을 직접 구현하는 단계다.

def rnn_backward(dh, cache):


    dx, dh0, dWx, dWh, db = None, None, None, None, None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
    N,T,H = dh.shape 
    dh = dh.copy()
    dx = np.zeros((N ,T, cache[f'D']))
    dh0 = np.zeros((N ,H))
    dWx = np.zeros((cache[f'D'] ,H))
    dWh = np.zeros((H ,H))
    db = np.zeros((H))
    for t in range(T-1,-1,-1):
        temp_dx, temp_dh0, temp_dWx, temp_dWh, temp_db = rnn_step_backward(dh[:,t,:], cache[f'rnn_t{t}'])
        
        dx[:,t,:] += temp_dx
        dWx += temp_dWx
        dWh += temp_dWh
        db += temp_db
        if t == 0:
            dh0 = temp_dh0
        else:
            dh[:,t-1,:] += temp_dh0

    
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return dx, dh0, dWx, dWh, db
  • 이 코드의 핵심은 각 timestep에서 나온 gradient를 단순히 따로 계산하는 것이 아니라, 이전 timestep으로 전달되는 gradient까지 누적한다는 점이다. 그래서 dh[:, t-1, :] += temp_dh0처럼 현재 step의 이전 hidden state에 대한 gradient를 바로 앞 timestep의 upstream gradient에 더해준다.
  • 또한 dWx, dWh, db는 모든 timestep에서 공통으로 사용된 파라미터에 대한 gradient이므로, 각 timestep의 gradient를 모두 더해줘야 한다. 반면 dx는 timestep별 입력에 대한 gradient이므로 각 위치에 맞게 저장하면 된다.
  • 정리하면, 이 함수는 sequence 전체를 거꾸로 훑으면서 hidden state의 연결 구조를 따라 gradient를 전달하는 RNN의 전체 backward 과정이다.

Q1-5. Word Embedding: Forward

RNN에 들어가기 전에, 먼저 단어를 벡터로 바꿔주는 word embedding을 구현한다. 텍스트 데이터는 원래 정수 인덱스로 표현되기 때문에, 각 단어를 연속적인 dense vector로 매핑해야 RNN이 의미를 학습할 수 있다.

def word_embedding_forward(x, W):

    out, cache = None, None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    N,T = x.shape
    V, D = W.shape
    out = np.zeros((N ,T, D))
    for t in range(T):
        out[:,t,:] = W[x[:,t],:]
    cache = (x,W.shape)

    #out =  W[x] one line 
    

    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return out, cache
  • 이 코드는 각 timestep의 단어 인덱스를 받아서, 그에 해당하는 embedding vector를 꺼내오는 방식이다.
  • 사실 한 줄로 W[x]처럼 표현할 수도 있지만, 여기서는 timestep별로 직접 꺼내는 구조를 통해 동작 방식을 더 분명하게 볼 수 있다. 결국 이 단계의 역할은 정수로 표현된 단어 시퀀스를 RNN이 다룰 수 있는 연속형 벡터 시퀀스로 바꿔주는 것이다.

Q1-6. Word Embedding: Backward

forward에서 사용한 embedding matrix는 backward에서는 조금 다르게 처리해야 한다. 단어 인덱스는 미분할 수 있는 값이 아니므로, gradient는 입력 x가 아니라 embedding matrix W에 대해서만 계산된다.

def word_embedding_backward(dout, cache):

    dW = None

    # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    x, Wshape = cache
    N,T,D = dout.shape
    dW = np.zeros(Wshape)

    np.add.at(dW, x.reshape(-1), dout.reshape(-1, D))
    
    # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

    return dW
  • 여기서 핵심은 np.add.at이다. 같은 단어가 sequence 안에서 여러 번 등장할 수 있기 때문에, 단순 대입이 아니라 해당 인덱스 위치에 gradient를 누적해야 한다.
  • 즉, word embedding backward는 “어떤 단어가 얼마나 자주, 어떤 문맥에서 사용되었는가”에 따라 embedding matrix의 각 행에 gradient를 모아주는 과정이다.

Q1-7. RNN for Image Captioning

이제 앞에서 만든 구성요소들을 모두 합쳐서 image captioning 모델을 완성한다. 이미지 feature를 초기 hidden state로 바꾸고, 이후 단어를 하나씩 생성하면서 문장을 만드는 구조다.

import numpy as np
from ..rnn_layers import *

class CaptioningRNN:

    def __init__(
        self,
        word_to_idx,
        input_dim=512,
        wordvec_dim=128,
        hidden_dim=128,
        cell_type="rnn",
        dtype=np.float32,
    ):

        if cell_type not in {"rnn", "lstm"}:
            raise ValueError('Invalid cell_type "%s"' % cell_type)

        self.cell_type = cell_type
        self.dtype = dtype
        self.word_to_idx = word_to_idx
        self.idx_to_word = {i: w for w, i in word_to_idx.items()}
        self.params = {}

        vocab_size = len(word_to_idx)

        self._null = word_to_idx["<NULL>"]
        self._start = word_to_idx.get("<START>", None)
        self._end = word_to_idx.get("<END>", None)

        # Initialize word vectors
        self.params["W_embed"] = np.random.randn(vocab_size, wordvec_dim)
        self.params["W_embed"] /= 100

        # Initialize CNN -> hidden state projection parameters
        self.params["W_proj"] = np.random.randn(input_dim, hidden_dim)
        self.params["W_proj"] /= np.sqrt(input_dim)
        self.params["b_proj"] = np.zeros(hidden_dim)

        # Initialize parameters for the RNN
        dim_mul = {"lstm": 4, "rnn": 1}[cell_type]
        self.params["Wx"] = np.random.randn(wordvec_dim, dim_mul * hidden_dim)
        self.params["Wx"] /= np.sqrt(wordvec_dim)
        self.params["Wh"] = np.random.randn(hidden_dim, dim_mul * hidden_dim)
        self.params["Wh"] /= np.sqrt(hidden_dim)
        self.params["b"] = np.zeros(dim_mul * hidden_dim)

        # Initialize output to vocab weights
        self.params["W_vocab"] = np.random.randn(hidden_dim, vocab_size)
        self.params["W_vocab"] /= np.sqrt(hidden_dim)
        self.params["b_vocab"] = np.zeros(vocab_size)

        # Cast parameters to correct dtype
        for k, v in self.params.items():
            self.params[k] = v.astype(self.dtype)

    def loss(self, features, captions):

        captions_in = captions[:, :-1]
        captions_out = captions[:, 1:]

        # You'll need this
        mask = captions_out != self._null

        # Weight and bias for the affine transform from image features to initial
        # hidden state
        W_proj, b_proj = self.params["W_proj"], self.params["b_proj"]

        # Word embedding matrix
        W_embed = self.params["W_embed"]

        # Input-to-hidden, hidden-to-hidden, and biases for the RNN
        Wx, Wh, b = self.params["Wx"], self.params["Wh"], self.params["b"]

        # Weight and bias for the hidden-to-vocab transformation.
        W_vocab, b_vocab = self.params["W_vocab"], self.params["b_vocab"]

        loss, grads = 0.0, {}

        # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
        cache = {}

        affine_h,cache["affine"] = affine_forward(features,W_proj,b_proj)
        emd_x, cache["emd"] = word_embedding_forward(captions_in,W_embed)
        if self.cell_type == 'rnn':
            rnn_h, cache["rnn"]  = rnn_forward(emd_x,affine_h,Wx, Wh, b)
        elif self.cell_type == 'lstm':
            pass

        temporal_h, cache["temporal"]  = temporal_affine_forward(rnn_h,W_vocab, b_vocab)
        loss,dx = temporal_softmax_loss(temporal_h,captions_out,mask)

        #backward pass


        dx, grads["W_vocab"],grads["b_vocab"]  = temporal_affine_backward(dx,cache["temporal"]) 

        if self.cell_type == 'rnn':
            dx,h0, grads["Wx"],grads["Wh"],grads["b"] = rnn_backward(dx,cache["rnn"])
            #print(dx.shape)
        elif self.cell_type == 'lstm':
            pass
        grads["W_embed"] = word_embedding_backward(dx,cache["emd"])
        _,grads["W_proj"],grads["b_proj"] = affine_backward(h0,cache["affine"])
        #grads["word_embedding"] = word_embedding_backward(grads["rnn"][1], cache["emd"])
        #grads["affine"]= affine_backward(grads["word_embedding"],cache["affine"])

        # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****


        return loss, grads

    def sample(self, features, max_length=30):

        N = features.shape[0]
        captions = self._null * np.ones((N, max_length), dtype=np.int32)

        # Unpack parameters
        W_proj, b_proj = self.params["W_proj"], self.params["b_proj"]
        W_embed = self.params["W_embed"]
        Wx, Wh, b = self.params["Wx"], self.params["Wh"], self.params["b"]
        W_vocab, b_vocab = self.params["W_vocab"], self.params["b_vocab"]

        # *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

        h,cache1 = affine_forward(features,W_proj,b_proj) 
        
        prev=self._start*np.ones((N,),dtype=np.int32) 
        
        for i in range(max_length):
            prev_embed, cache2 = word_embedding_forward(prev[:, None], W_embed)
            prev_embed = prev_embed[:, 0, :]  # (N, 1, W) -> (N, W)
            h, cache3=rnn_step_forward(prev_embed, h, Wx, Wh, b)

            scores,cache4=affine_forward(h,W_vocab ,b_vocab)
            prev=np.argmax(scores,axis=1)
            captions[:,i]=prev

        # *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****

        return captions

이 클래스는 image captioning 전체 구조를 담는 핵심 모델이다.
이미지 feature를 hidden state로 바꾸는 W_proj, 단어 embedding용 W_embed, RNN 본체의 Wx, Wh, b, 그리고 hidden state를 vocabulary score로 바꾸는 W_vocab까지 모두 포함하고 있다.
특히 loss 함수에서는 다음과 같은 흐름으로 계산이 진행된다.

  • 이미지 feature를 initial hidden state로 변환한다.
  • caption 입력을 embedding으로 바꾼다.
  • RNN 또는 LSTM으로 sequence를 처리한다.
  • hidden state를 vocabulary score로 변환한다.
  • temporal softmax loss로 정답 문장과 비교한다.

이후 backward에서는 temporal affine → RNN → word embedding → image projection 순서로 gradient를 되돌린다.
즉, caption 생성의 역방향 학습도 결국 앞에서 만든 기본 모듈들을 차례대로 연결한 구조다.
샘플링 단계에서는 <START> 토큰에서 시작해 매 timestep마다 이전 단어를 embedding한 뒤 RNN에 넣고, 다음 단어를 예측한다.
이 과정을 반복하면서 caption을 한 단어씩 생성하게 된다.

학습이 끝난 뒤 샘플링 결과를 확인해보면, 모델이 이미지의 전반적인 구성을 꽤 잘 파악하고 있음을 알 수 있다.
예를 들어 이 결과에서는 침대가 있는 침실 장면과 빨간 벽, 조명, 액자 같은 핵심 객체들이 문장에 반영되었다.
다만 중간에 <UNK>가 등장하는 것을 보면, 아직 vocabulary를 완전히 안정적으로 예측하지는 못한 상태다.
이런 결과는 RNN이 이미지 feature를 단순히 한 번 보는 것이 아니라, 생성 과정 전체를 통해 문장을 점진적으로 이어붙인다는 점을 보여준다.
즉, captioning 모델은 이미지의 내용을 요약하는 동시에, 이전에 생성한 단어들을 문맥으로 활용해 다음 단어를 예측한다.

Inline Question 1
In our current image captioning setup, our RNN language model produces a word at every timestep as its output. However, an alternate way to pose the problem is to train the network to operate over characters (e.g. 'a', 'b', etc.) as opposed to words, so that at it every timestep, it receives the previous character as input and tries to predict the next character in the sequence. For example, the network might generate a caption like
'A', ' ', 'c', 'a', 't', ' ', 'o', 'n', ' ', 'a', ' ', 'b', 'e', 'd'
Can you describe one advantage of an image-captioning model that uses a character-level RNN? Can you also describe one disadvantage? HINT: there are several valid answers, but it might be useful to compare the parameter space of word-level and character-level models.
Your Answer:
장점은 문자 단위 모델은 어휘 사전 크기가 훨씬 작아서, 새 단어나 철자 변형처럼 훈련 때 보지 못한 단어도 조합해서 생성할 수 있다는 점이다.
단점은 각 timestep에서 다뤄야 할 sequence가 훨씬 길어지기 때문에 학습이 더 어렵고 느리며, 문장 수준의 의미를 잡아내기도 더 힘들다는 점이다.

핵심정리

이번 RNN 파트는 CNN과 달리 “시간 흐름”이라는 개념이 추가된 것이 핵심이다.

  • CNN: 공간 구조 (H, W)
  • RNN: 시간 구조 (T)

특히 backward 과정에서의 BPTT (Backprop Through Time) 는 처음 보면 헷갈리지만, 결국 “같은 네트워크를 여러 번 펼쳐놓은 것”이라고 생각하면 훨씬 이해가 쉽다.
간단한 예시로 보면:

  • 입력: “I love coding”
  • RNN은 단어를 하나씩 읽으면서 hidden state를 업데이트
  • 마지막 state를 기반으로 문장의 의미를 이해

이 흐름을 직접 구현해보는 것이 이번 과제의 핵심이다.