4.1 순환 신경망 코드 리뷰

yunju·6일 전

코드 리뷰

목록 보기
1/5

4.1 순환 신경망 — 코드 리뷰 노트

노트북: 07-01. recurrent_neural_network.ipynb (셀 25개, 코드 셀 19개)
예상 시간: 약 15분


발표 전 체크

  • ⚠️ 노트북에 버그가 하나 있다. 깊은 RNN 파트(셀 15~18)는 2층 RNN을 만들기만 하고 cell(inputs)를 다시 실행하지 않는다. 그래서 바로 앞 1층 RNN의 결과가 그대로 찍혀 _status가 (1, 1, 8)로 나온다. 정답은 (2, 1, 8)이다. Part 3에서 같이 찾아보는 걸로 쓰면 좋다.
  • 파이토치 파트의 입력은 torch.Tensor(1, 10, 5)로 만든다. 이건 값이 초기화되지 않은 텐서다. shape만 보는 노트북이라 결과는 맞지만, 값은 의미가 없다.
  • 넘파이 파트에는 시드가 없다. 실행할 때마다 숫자는 달라지지만, "두 번째 시점부터 거의 1"이라는 패턴은 늘 같다.

흐름 한눈에

파트셀한 줄 메시지시간
1. 넘파이로 직접 구현1~5수식 한 줄 + for문 = RNN6분
2. nn.RNN7~13그 for문을 한 줄로. 반환값 두 개4분
3. 깊은 RNN15~18num_layers=2 + 노트북 버그2분
4. 양방향 RNN20~24bidirectional=True → 16, 43분

읽는 법: 🗣 = 그대로 말해도 되는 문장, ❓ = 나올 만한 질문과 답


Part 1. 넘파이로 직접 구현 (셀 1~5)

셀 1 — 크기 정하고 입력·초기 은닉 상태 만들기

import numpy as np

timesteps = 10
input_size = 4
hidden_size = 8

inputs = np.random.random((timesteps, input_size))
hidden_state_t = np.zeros((hidden_size,))
  • timesteps = 10 → 시점 수. 문장으로 치면 단어 10개.
  • input_size = 4 → 단어 하나를 표현하는 벡터의 차원 dd. 4.4에서 배울 임베딩 크기가 이 자리에 들어간다.
  • hidden_size = 8 → 은닉 상태 크기 DhD_h. 기억을 담는 벡터의 길이다.
  • inputs → (10, 4). 한 행이 단어 하나다. 진짜 단어 대신 0~1 사이 난수로 채웠다.
  • hidden_state_t → (8,) 0 벡터. 수식의 h0h_0이다. 아직 아무것도 읽지 않았으니 기억이 비어 있다.

🗣 "문장 하나를 흉내 낸 거예요. 단어 10개, 단어마다 4차원 벡터, 기억은 8칸짜리입니다. 처음 기억은 0으로 시작해요."

셀 2 — 초기 은닉 상태 확인

print(hidden_state_t)   # [0. 0. 0. 0. 0. 0. 0. 0.]
  • 0이 8개. h0h_0이 0 벡터라는 것만 확인하고 넘어간다.

셀 3~4 — 가중치 만들기

Wx = np.random.random((hidden_size, input_size))   # (8, 4)
Wh = np.random.random((hidden_size, hidden_size))  # (8, 8)
b  = np.random.random((hidden_size,))              # (8,)
  • Wx (8, 4) → 수식의 WxW_x. 4차원 단어를 8차원 기억 공간으로 옮긴다.
  • Wh (8, 8) → 수식의 WhW_h. 8차원 기억을 8차원 기억으로 섞는다.
  • b (8,) → 편향.
  • shape 순서가 (출력, 입력) 인 이유: 뒤에서 np.dot(Wx, x_t)로 왼쪽에서 곱하기 때문이다. (8, 4) @ (4,) → (8,).

🗣 "가중치는 딱 세 개예요. 이 세 개가 10개 시점 전부에서 그대로 쓰입니다. 이게 RNN의 가중치 공유예요."

❓ Wx를 (4, 8)로 만들면? np.dot(Wx, x_t)에서 (4, 8) @ (4,)가 되어 shape 에러가 난다. 순서를 바꾸려면 np.dot(x_t, Wx)로 써야 한다. 파이토치가 내부에서 x @ W.T로 계산하는 것도 같은 이유다.

셀 5 — 메모리 셀 동작 ★ 이번 노트북의 핵심

total_hidden_states = []

for input_t in inputs:
  output_t = np.tanh(np.dot(Wx,input_t) + np.dot(Wh,hidden_state_t) + b)
  total_hidden_states.append(list(output_t))
  print(np.shape(total_hidden_states))
  hidden_state_t = output_t

total_hidden_states = np.stack(total_hidden_states, axis = 0)
print(total_hidden_states)

한 줄씩:

  1. total_hidden_states = [] → 시점마다 나오는 은닉 상태를 모을 리스트.
  2. for input_t in inputs: → 2차원 배열을 for로 돌면 행 단위로 나온다. input_t는 (4,), 즉 xtx_t다. 단어를 하나씩 읽는 부분이다.
  3. output_t = np.tanh(...) → 수식 ht=tanh⁡(Wxxt+Whht−1+b)h_t = \tanh(W_x x_t + W_h h_{t-1} + b) 그대로다.
    • np.dot(Wx, input_t) → WxxtW_x x_t, (8,)
    • np.dot(Wh, hidden_state_t) → Whht−1W_h h_{t-1}, (8,)
    • 둘 다 (8,)이라 더할 수 있다.
  4. .append(list(output_t)) → 이번 시점의 hth_t를 저장한다. list()는 꼭 필요하진 않다. 바로 아래 np.shape로 크기를 찍어 보려고 리스트로 바꾼 것이다.
  5. print(np.shape(...)) → (1, 8), (2, 8), …, (10, 8). 시점마다 한 줄씩 쌓이는 게 보인다.
  6. hidden_state_t = output_t → ★ 이 한 줄이 "순환"이다. 방금 만든 hth_t가 다음 반복에서 ht−1h_{t-1} 자리로 들어간다.
  7. np.stack(..., axis=0) → 리스트 10개를 (10, 8) 배열 하나로 묶는다.

출력 결과 읽기:

[[0.908 0.914 0.964 ...]     ← t=1
 [0.99996 0.99993 ...]       ← t=2부터
 [0.99999 0.99998 ...]
 ...]
  • 첫 줄만 0.6~0.96 정도이고, 두 번째 시점부터는 거의 다 1이다.
  • 이유: np.random.random은 0~1 사이 양수만 만든다. 가중치·입력·편향이 전부 양수라서, tanh 안의 값이 계속 커지고 tanh가 포화된다.
  • t=1에는 h0=0h_0 = 0이라 Whh0W_h h_0 항이 0이었다. t=2부터는 그 항까지 더해져서 값이 확 커진다.

🗣 "이 for문 안에서 Wx, Wh, b는 한 번도 안 바뀌어요. 바뀌는 건 hidden_state_t 하나뿐이고, 마지막 줄에서 방금 계산한 값을 다시 넣어 주는 게 '순환'입니다."

🗣 "결과가 거의 다 1인 건 버그가 아니라 초기화 때문이에요. 3.6에서 본 tanh 포화랑 똑같은 상황이고, 포화되면 기울기가 0에 가까워서 학습이 안 됩니다."

❓ output_t랑 hidden_state_t는 뭐가 달라? 값은 같다. RNN에서는 은닉 상태가 곧 그 시점의 출력이다. 이름이 두 개인 건 "이번 시점의 출력"과 "다음 시점으로 넘길 기억"이라는 두 역할을 보여주려는 것이다. 수식의 yty_t(출력층)는 이 노트북에 없다.

❓ 학습은 어디서 해? 이 노트북은 순전파(forward)만 한다. 가중치는 무작위 값으로 고정되어 있다. 학습은 4.5에서 한다.

❓ list() 없이 그냥 append(output_t) 해도 돼? 된다. np.stack은 배열 리스트도 받는다. np.shape도 똑같이 (t, 8)을 준다.


Part 2. 파이토치 nn.RNN (셀 7~13)

셀 7~8 — 크기 정하기

import torch
import torch.nn as nn

input_size = 5
hidden_size = 8
  • 넘파이 파트와 달리 input_size가 5다. 숫자가 바뀌었으니 헷갈리지 않게 짚고 간다.

셀 9 — 입력 만들기

# (batch_size, time_steps, input_size)
inputs = torch.Tensor(1, 10, 5)
  • shape (1, 10, 5) = (배치 1개, 단어 10개, 단어당 5차원).
  • 넘파이에서는 (10, 4)로 문장 하나였다. 여기서는 맨 앞에 배치 차원이 붙었다. 2.3의 DataLoader에서 배치를 앞에 두던 것과 같다.
  • ⚠️ torch.Tensor(1, 10, 5)는 크기만 잡고 값은 채우지 않는다. 메모리에 남아 있던 아무 값이 들어간다. 값까지 쓸 거면 torch.randn(1, 10, 5)를 쓰자.

❓ torch.Tensor랑 torch.tensor 차이? 대문자 torch.Tensor(1, 10, 5)는 숫자를 크기로 해석해서 (1, 10, 5)짜리 빈 텐서를 만든다. 소문자 torch.tensor([1, 10, 5])는 값으로 해석해서 원소 3개짜리 텐서 [1, 10, 5]를 만든다.

셀 10 — RNN 층 만들기

cell = nn.RNN(input_size, hidden_size, batch_first=True)
  • 이 한 줄이 넘파이 셀 3의 Wx, Wh, b를 안에 만들어 둔다. 이름은 weight_ih_l0(=Wx), weight_hh_l0(=Wh)이다. 편향은 bias_ih_l0, bias_hh_l0 두 개로 나뉘어 있다.
  • batch_first=True → 입력을 (배치, 시점, 차원) 순서로 받겠다는 뜻이다. 기본값은 (시점, 배치, 차원)이다.

셀 11 — 실행

outputs, _status = cell(inputs)
  • 넘파이 셀 5의 for문 전체가 이 한 줄이다. 시점을 도는 반복은 nn.RNN이 안에서 한다.
  • h0h_0을 따로 안 넣으면 0 벡터로 시작한다. 넘파이에서 np.zeros로 만든 것과 같다. 넣고 싶으면 cell(inputs, h0)로 넘긴다.
  • 반환값이 두 개다.

셀 12~13 — 반환값 shape

print(outputs.shape)   # torch.Size([1, 10, 8])
print(_status.shape)   # torch.Size([1, 1, 8])
shape뜻넘파이에서는
outputs(1, 10, 8)모든 시점의 은닉 상태total_hidden_states (10, 8)
_status(1, 1, 8)마지막 시점의 은닉 상태반복이 끝난 뒤의 hidden_state_t
  • ⚠️ _status의 맨 앞 1은 배치가 아니라 층 수다. batch_first는 _status에 적용되지 않는다. _status는 항상 (층 수 × 방향 수, 배치, 은닉 크기)다.

🗣 "outputs는 매 시점 기억을 다 모은 거고, _status는 마지막 기억 하나예요. 감성 분류처럼 문장 전체로 판단할 때는 _status 쪽을 씁니다."

🗣 (같이 돌려 보기) "정말 같은 값인지 확인해 볼게요."

inputs = torch.randn(1, 10, 5)
outputs, _status = cell(inputs)
print(torch.equal(outputs[:, -1], _status[0]))   # True

Part 3. 깊은 RNN (셀 15~18)

셀 15~16 — 2층 RNN 만들기

inputs = torch.Tensor(1, 10, 5)
cell = nn.RNN(input_size = 5, hidden_size = 8, num_layers = 2, batch_first=True)
  • num_layers = 2 → RNN을 두 층 쌓는다. 1층의 모든 시점 출력이 2층의 입력 시퀀스가 된다.
  • 2층의 입력 크기는 5가 아니라 8이다(1층의 은닉 크기). 파이토치가 알아서 맞춰 준다.

셀 17~18 — ⚠️ 여기서 버그

print(outputs.shape)   # torch.Size([1, 10, 8])
print(_status.shape)   # torch.Size([1, 1, 8])   ← 주석은 (층의 개수, ...)인데 1?
  • 셀 16에서 cell을 새로 만들었지만, outputs, _status = cell(inputs)를 실행하지 않았다.
  • 그래서 지금 찍히는 outputs, _status는 Part 2의 1층 RNN 결과다.
  • 셀 18의 주석은 "(층의 개수, 배치, 은닉)"이라고 해 놓고 결과는 1이 나온다. 이 모순이 단서다.

고친 코드:

cell = nn.RNN(input_size=5, hidden_size=8, num_layers=2, batch_first=True)
outputs, _status = cell(inputs)          # ← 이 줄이 빠져 있었다
print(outputs.shape)   # torch.Size([1, 10, 8])   마지막 층(2층)의 모든 시점
print(_status.shape)   # torch.Size([2, 1, 8])    층마다 마지막 시점 하나씩

🗣 "여기 _status가 (1, 1, 8)로 나오는데, 주석에는 층 개수라고 되어 있죠. 2층인데 왜 1일까요?" → 잠깐 기다렸다가 → "cell을 만들기만 하고 실행을 안 해서, 앞에서 만든 값이 그대로 찍힌 거예요."

🗣 "노트북은 위에서 만든 변수가 계속 살아 있어서 이런 실수가 잘 납니다. 출력을 볼 때는 그 값이 어느 줄에서 만들어졌는지 같이 확인하는 습관이 필요해요."

outputs_status
1층(1, 10, 8)(1, 1, 8)
2층(1, 10, 8) — 그대로(2, 1, 8) — 층 수만큼

Part 4. 양방향 RNN (셀 20~24)

셀 20~22 — 만들고 실행

inputs = torch.Tensor(1, 10, 5)
cell = nn.RNN(input_size = 5, hidden_size = 8, num_layers = 2,
              batch_first=True, bidirectional = True)
outputs, _status = cell(inputs)
  • bidirectional = True → 정방향(앞→뒤) 셀과 역방향(뒤→앞) 셀을 둘 다 둔다.
  • 여기는 cell(inputs)를 제대로 실행한다(셀 22).

셀 23~24 — shape

print(outputs.shape)   # torch.Size([1, 10, 16])
print(_status.shape)   # torch.Size([4, 1, 8])
  • outputs의 16 = 8 × 2. 시점마다 [정방향 8 ; 역방향 8]을 이어 붙였다.
  • _status의 4 = 층 2 × 방향 2. 순서는 [1층 정, 1층 역, 2층 정, 2층 역].
  • _status의 마지막 차원은 16이 아니라 8이다. 정·역방향을 붙이지 않고 따로 담는다.

🗣 "빈칸 채우기처럼 뒤 단어를 봐야 알 수 있는 경우가 있어서, 거꾸로 읽는 셀을 하나 더 둔 거예요. 그래서 출력이 두 배가 됩니다."

❓ 다 대 일 분류에 outputs[:, -1]을 그대로 쓰면? 앞 8개(정방향)는 문장 전체를 읽은 값이다. 하지만 뒤 8개(역방향)는 마지막 단어 하나만 읽은 값이다. 역방향이 다 읽은 값은 outputs[:, 0, 8:]에 있다. 그래서 _status에서 마지막 층의 정방향과 역방향을 꺼내 붙이는 게 안전하다.

cell = nn.RNN(5, 8, batch_first=True, bidirectional=True)
outputs, _status = cell(torch.randn(1, 10, 5))
print(torch.equal(outputs[:, -1, :8], _status[0]))   # True  정방향 마지막 = 맨 끝
print(torch.equal(outputs[:, 0, 8:],  _status[1]))   # True  역방향 마지막 = 맨 앞

마무리 (1분)

🗣 "정리하면, RNN은 for문 안에서 같은 가중치로 h = tanh(Wx·x + Wh·h + b)를 반복하는 거예요. nn.RNN은 그 for문을 대신 돌려 주고, 모든 시점 기억(outputs)과 마지막 기억(_status) 두 개를 돌려줍니다. 층을 쌓으면 _status 앞자리가 층 수만큼, 양방향이면 outputs 마지막 자리와 _status 앞자리가 두 배가 돼요."

shape 치트시트 (입력 (1, 10, 5), hidden_size=8)

설정outputs_status
기본(1, 10, 8)(1, 1, 8)
num_layers=2(1, 10, 8)(2, 1, 8)
num_layers=2, bidirectional=True(1, 10, 16)(4, 1, 8)

규칙: outputs = (배치, 시점, 은닉 × 방향 수), _status = (층 × 방향 수, 배치, 은닉)

0개의 댓글