노트북:
07-01. recurrent_neural_network.ipynb(셀 25개, 코드 셀 19개)
예상 시간: 약 15분
cell(inputs)를 다시 실행하지 않는다. 그래서 바로 앞 1층 RNN의 결과가 그대로 찍혀 _status가 (1, 1, 8)로 나온다. 정답은 (2, 1, 8)이다. Part 3에서 같이 찾아보는 걸로 쓰면 좋다.torch.Tensor(1, 10, 5)로 만든다. 이건 값이 초기화되지 않은 텐서다. shape만 보는 노트북이라 결과는 맞지만, 값은 의미가 없다.| 파트 | 셀 | 한 줄 메시지 | 시간 |
|---|---|---|---|
| 1. 넘파이로 직접 구현 | 1~5 | 수식 한 줄 + for문 = RNN | 6분 |
2. nn.RNN | 7~13 | 그 for문을 한 줄로. 반환값 두 개 | 4분 |
| 3. 깊은 RNN | 15~18 | num_layers=2 + 노트북 버그 | 2분 |
| 4. 양방향 RNN | 20~24 | bidirectional=True → 16, 4 | 3분 |
읽는 법: 🗣 = 그대로 말해도 되는 문장, ❓ = 나올 만한 질문과 답
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 → 단어 하나를 표현하는 벡터의 차원 . 4.4에서 배울 임베딩 크기가 이 자리에 들어간다.hidden_size = 8 → 은닉 상태 크기 . 기억을 담는 벡터의 길이다.inputs → (10, 4). 한 행이 단어 하나다. 진짜 단어 대신 0~1 사이 난수로 채웠다.hidden_state_t → (8,) 0 벡터. 수식의 이다. 아직 아무것도 읽지 않았으니 기억이 비어 있다.🗣 "문장 하나를 흉내 낸 거예요. 단어 10개, 단어마다 4차원 벡터, 기억은 8칸짜리입니다. 처음 기억은 0으로 시작해요."
print(hidden_state_t) # [0. 0. 0. 0. 0. 0. 0. 0.]
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) → 수식의 . 4차원 단어를 8차원 기억 공간으로 옮긴다.Wh (8, 8) → 수식의 . 8차원 기억을 8차원 기억으로 섞는다.b (8,) → 편향.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로 계산하는 것도 같은 이유다.
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)
한 줄씩:
total_hidden_states = [] → 시점마다 나오는 은닉 상태를 모을 리스트.for input_t in inputs: → 2차원 배열을 for로 돌면 행 단위로 나온다. input_t는 (4,), 즉 다. 단어를 하나씩 읽는 부분이다.output_t = np.tanh(...) → 수식 그대로다.np.dot(Wx, input_t) → , (8,)np.dot(Wh, hidden_state_t) → , (8,)(8,)이라 더할 수 있다..append(list(output_t)) → 이번 시점의 를 저장한다. list()는 꼭 필요하진 않다. 바로 아래 np.shape로 크기를 찍어 보려고 리스트로 바꾼 것이다.print(np.shape(...)) → (1, 8), (2, 8), …, (10, 8). 시점마다 한 줄씩 쌓이는 게 보인다.hidden_state_t = output_t → ★ 이 한 줄이 "순환"이다. 방금 만든 가 다음 반복에서 자리로 들어간다.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 ...]
...]
np.random.random은 0~1 사이 양수만 만든다. 가중치·입력·편향이 전부 양수라서, tanh 안의 값이 계속 커지고 tanh가 포화된다.🗣 "이 for문 안에서 Wx, Wh, b는 한 번도 안 바뀌어요. 바뀌는 건 hidden_state_t 하나뿐이고, 마지막 줄에서 방금 계산한 값을 다시 넣어 주는 게 '순환'입니다."
🗣 "결과가 거의 다 1인 건 버그가 아니라 초기화 때문이에요. 3.6에서 본 tanh 포화랑 똑같은 상황이고, 포화되면 기울기가 0에 가까워서 학습이 안 됩니다."
❓ output_t랑 hidden_state_t는 뭐가 달라? 값은 같다. RNN에서는 은닉 상태가 곧 그 시점의 출력이다. 이름이 두 개인 건 "이번 시점의 출력"과 "다음 시점으로 넘길 기억"이라는 두 역할을 보여주려는 것이다. 수식의 (출력층)는 이 노트북에 없다.
❓ 학습은 어디서 해? 이 노트북은 순전파(forward)만 한다. 가중치는 무작위 값으로 고정되어 있다. 학습은 4.5에서 한다.
❓ list() 없이 그냥 append(output_t) 해도 돼? 된다. np.stack은 배열 리스트도 받는다. np.shape도 똑같이 (t, 8)을 준다.
nn.RNN (셀 7~13)import torch
import torch.nn as nn
input_size = 5
hidden_size = 8
input_size가 5다. 숫자가 바뀌었으니 헷갈리지 않게 짚고 간다.# (batch_size, time_steps, input_size)
inputs = torch.Tensor(1, 10, 5)
(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]를 만든다.
cell = nn.RNN(input_size, hidden_size, batch_first=True)
Wx, Wh, b를 안에 만들어 둔다. 이름은 weight_ih_l0(=Wx), weight_hh_l0(=Wh)이다. 편향은 bias_ih_l0, bias_hh_l0 두 개로 나뉘어 있다.batch_first=True → 입력을 (배치, 시점, 차원) 순서로 받겠다는 뜻이다. 기본값은 (시점, 배치, 차원)이다.outputs, _status = cell(inputs)
nn.RNN이 안에서 한다.np.zeros로 만든 것과 같다. 넣고 싶으면 cell(inputs, h0)로 넘긴다.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
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층의 입력 시퀀스가 된다.print(outputs.shape) # torch.Size([1, 10, 8])
print(_status.shape) # torch.Size([1, 1, 8]) ← 주석은 (층의 개수, ...)인데 1?
cell을 새로 만들었지만, outputs, _status = cell(inputs)를 실행하지 않았다.outputs, _status는 Part 2의 1층 RNN 결과다.고친 코드:
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) — 층 수만큼 |
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).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 역방향 마지막 = 맨 앞
🗣 "정리하면, 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 = (층 × 방향 수, 배치, 은닉)