노트북:
07-02. lstm_and_gru.ipynb(셀 9개, 코드 셀 7개)
예상 시간: 약 12분 (노트북 3분 + 추가 셀 9분) · 짝꿍 글:4.2_LSTM과_GRU.md
nn.LSTM(input_dim, hidden_size, ...)이고 input_dim = 5인데, 저장된 출력은 LSTM(8, 8, batch_first=True)다. 작성자가 예전에 input_dim = 8로 돌린 출력이 그대로 남은 것으로 보인다. 다시 실행하면 LSTM(5, 8, batch_first=True)가 나온다. 4.1 노트북의 버그와 같은 교훈이라 짧게 짚고 가면 좋다.nn.RNN(...) 코드다. 비교용으로 일부러 둔 것이다.| 파트 | 셀 | 한 줄 메시지 | 시간 |
|---|---|---|---|
| 1. 노트북 | 0~8 | 만드는 법은 RNN과 똑같다. 이름만 바뀐다 | 3분 |
| 2. 추가 A | — | LSTM은 반환값이 (h_n, c_n) 튜플 | 2분 |
| 3. 추가 B | — | 파라미터 4배, 3배 | 2분 |
| 4. 추가 C | — | 수식 6줄 = nn.LSTM | 3분 |
| 5. 추가 D | — | 기울기 실험 (시간 남으면) | 2분 |
읽는 법: 🗣 = 그대로 말해도 되는 문장, ❓ = 나올 만한 질문과 답
import torch
import torch.nn as nn
torch.__version__ # '2.4.0+cu121' (노트북에 저장된 값)
input_dim = 5 # 입력의 크기
hidden_size = 8 # 은닉 상태의 크기
nn.RNN(input_dim, hidden_size, batch_first=True) # → RNN(5, 8, batch_first=True)
nn.LSTM(input_dim, hidden_size, batch_first=True) # → LSTM(8, 8, batch_first=True) ← 저장된 출력이 이상하다
(입력 크기, 은닉 크기, 옵션) 순서다.LSTM(8, 8, ...)은 지금 코드로 나올 수 없는 값이다. 다시 실행하면 LSTM(5, 8, ...)이 나온다.🗣 "RNN을 LSTM으로 바꾸는 건 이름 한 단어만 바꾸면 돼요. 4.5에서도 모델 코드에서 nn.LSTM 한 줄이 이 자리에 들어갑니다."
🗣 "셀 5 출력을 보면 LSTM(8, 8)이라고 찍혀 있는데, 코드는 input_dim이 5예요. 예전에 돌린 출력이 남아 있는 거고, 다시 돌리면 5가 나옵니다. 4.1에서도 봤지만, 저장된 출력은 '언젠가 돌린 결과'일 뿐이라 그대로 믿으면 안 돼요."
nn.RNN(input_dim, hidden_size, batch_first=True) # → RNN(5, 8, batch_first=True)
nn.GRU(input_dim, hidden_size, batch_first=True) # → GRU(5, 8, batch_first=True)
5, 8)이다.❓ 만드는 법이 다 같으면 뭐가 달라? 안에 든 가중치 수와 계산 순서가 다르다. 그리고 LSTM만 반환값 모양이 다르다. 추가 셀 A, B에서 바로 확인한다.
x = torch.randn(1, 10, 5) # (배치, 시점, 입력 차원)
rnn = nn.RNN(5, 8, batch_first=True)
lstm = nn.LSTM(5, 8, batch_first=True)
gru = nn.GRU(5, 8, batch_first=True)
out_r, h_r = rnn(x)
out_l, (h_l, c_l) = lstm(x) # ← LSTM만 튜플로 받는다
out_g, h_g = gru(x)
print(out_r.shape, h_r.shape) # [1, 10, 8] [1, 1, 8]
print(out_l.shape, h_l.shape, c_l.shape) # [1, 10, 8] [1, 1, 8] [1, 1, 8]
print(out_g.shape, h_g.shape) # [1, 10, 8] [1, 1, 8]
outputs 모양은 셋 다 (1, 10, 8)로 같다.(h_n, c_n) 튜플이다. c_n이 마지막 시점의 셀 상태다.outputs에는 만 담긴다. 셀 상태는 셀 안에서만 흐르고, 마지막 값만 c_n으로 꺼내 준다.일부러 틀려 보기:
outputs, _status = lstm(x) # 4.1처럼 받으면
print(_status.shape) # AttributeError: 'tuple' object has no attribute 'shape'
🗣 "4.1 습관대로 outputs, _status로 받으면 _status가 튜플이라 shape에서 에러가 나요. LSTM은 꼭 괄호로 풀어서 받습니다. 4.5 모델 코드도 lstm_out, (hidden, cell) = self.lstm(embedded)로 되어 있어요."
❓ 4.5에서는 뭘 써? hidden.squeeze(0). (1, 배치, 128)에서 층 차원 1을 없애 (배치, 128)로 만들고 fc층에 넣는다. 다 대 일이라 마지막 은닉 상태 하나만 쓴다.
for m in [rnn, lstm, gru]:
print(type(m).__name__, sum(p.numel() for p in m.parameters()))
for name, p in m.named_parameters():
print(' ', name, tuple(p.shape))
RNN 120
weight_ih_l0 (8, 5)
weight_hh_l0 (8, 8)
...
LSTM 480
weight_ih_l0 (32, 5)
weight_hh_l0 (32, 8)
...
GRU 360
weight_ih_l0 (24, 5)
weight_hh_l0 (24, 8)
...
🗣 "파라미터가 정확히 4배, 3배인 건 게이트마다 4.1의 RNN 셀이 하나씩 들어 있다고 보면 돼요. 게이트 하나가 RNN 셀 하나랑 같은 모양의 식이거든요."
❓ LSTM의 첫 8행만 꺼내면 뭐야? lstm.weight_ih_l0[:8]이 입력 게이트 의 (8, 5)다. [8:16]은 삭제 게이트 다.
nn.LSTM 재현하기W_ih, W_hh = lstm.weight_ih_l0, lstm.weight_hh_l0
b_ih, b_hh = lstm.bias_ih_l0, lstm.bias_hh_l0
h = torch.zeros(1, 8) # h_0
c = torch.zeros(1, 8) # C_0
outs = []
for t in range(10):
z = x[:, t] @ W_ih.T + b_ih + h @ W_hh.T + b_hh # (1, 32) 네 게이트를 한 번에
i, f, g, o = z.chunk(4, dim=1) # 8칸씩 넷으로 (순서 i, f, g, o)
i, f, g, o = torch.sigmoid(i), torch.sigmoid(f), torch.tanh(g), torch.sigmoid(o)
c = f * c + i * g # 셀 상태
h = o * torch.tanh(c) # 은닉 상태
outs.append(h)
print(torch.allclose(torch.stack(outs, 1), out_l, atol=1e-6)) # True
print(torch.allclose(c, c_l[0], atol=1e-6)) # True
한 줄씩:
z = ... → 4.1의 RNN 셀 계산과 똑같은 모양이다. 다만 결과가 8칸이 아니라 32칸이다(게이트 넷 몫).z.chunk(4, dim=1) → 32칸을 8칸씩 넷으로 자른다. 순서가 i, f, g, o인 걸 꼭 짚자.c = f * c + i * g → 셀 상태 갱신. 이번 편의 핵심 줄. *는 원소별 곱이다.h = o * torch.tanh(c) → 셀 상태를 다듬어서 필요한 만큼 꺼낸다.🗣 "4.1에서 RNN은 for문 안에 한 줄이었죠. LSTM은 이 여섯 줄이고, 그중 진짜 새로운 건 c = f c + i g 한 줄이에요. 곱셈 한 번, 덧셈 한 번으로만 기억이 바뀌니까 기울기가 멀리까지 갈 수 있습니다."
❓ GRU도 이렇게 재현돼? 된다. 다만 파이토치 GRU는 리셋 게이트를 곱하는 위치가 위키독스 수식과 다르다. 위키독스는 이고, 파이토치는 다. 위키독스 수식 그대로 짜면 nn.GRU와 값이 다르게 나온다(직접 확인함). 학습 성능 차이는 거의 없다.
def grad_by_distance(model, T=60):
x = torch.randn(1, T, 5, requires_grad=True)
out = model(x)[0]
out[:, -1].sum().backward() # 마지막 출력에서 역전파
return x.grad[0].norm(dim=1).flip(0) # [0칸 앞, 1칸 앞, ...]
torch.manual_seed(0)
rnn, lstm = nn.RNN(5, 8, batch_first=True), nn.LSTM(5, 8, batch_first=True)
lstm_open = nn.LSTM(5, 8, batch_first=True)
with torch.no_grad():
lstm_open.bias_ih_l0[8:16] = 3.0 # 삭제 게이트 f의 편향만 3으로 → f ≈ 0.95
for name, m in [('RNN', rnn), ('LSTM', lstm), ('LSTM f≈0.95', lstm_open)]:
g = grad_by_distance(m)
print(name, [f'{g[k]:.0e}' for k in (0, 10, 30, 59)])
결과 경향(시드 20개 평균, 글의 그래프와 같음):
| 0칸 | 10칸 | 30칸 | 59칸 | |
|---|---|---|---|---|
| RNN | 1e+00 | 1e-03 | 2e-07 | 2e-11 |
| LSTM (초기값) | 3e-01 | 1e-03 | 6e-07 | 9e-11 |
| LSTM (f ≈ 0.95) | 2e-01 | 6e-02 | 3e-02 | 2e-02 |
bias_ih_l0[8:16] → 32칸 중 두 번째 8칸, 즉 삭제 게이트 의 편향이다(순서 i, f, g, o).🗣 "초기값 그대로의 LSTM은 RNN이랑 똑같이 기울기가 사라져요. 학습 전에는 f가 0.5 근처라서 매 시점 기억이 반씩 줄거든요. f를 0.95로 열어 주면 60칸 앞까지 기울기가 살아 있습니다."
🗣 "그러니까 LSTM이 기울기 소실을 공짜로 없애 주는 게 아니에요. 기울기가 지나갈 길을 만들어 두고, 그 길을 열지 말지를 학습하게 한 겁니다."
❓ 그럼 4.5에서도 편향을 바꿔야 해? 안 바꾼다. 학습하면서 필요한 칸의 가 알아서 올라간다. 처음부터 1 정도로 잡아 두는 경우도 있지만 필수는 아니다.
🗣 "LSTM과 GRU는 바깥에서 쓰는 법이 RNN과 거의 같아요. 다른 점은 셋입니다. 첫째, LSTM은 반환값이 outputs, (h_n, c_n) 튜플이에요. 둘째, 파라미터가 4배, 3배예요. 셋째, 안에서 게이트가 기억을 얼마나 지우고 넣을지 조절합니다. 4.5 모델 코드에서 nn.LSTM 한 줄과 (hidden, cell)로 받는 부분이 오늘 본 그대로 나옵니다."
치트시트 (입력 (1, 10, 5), hidden_size=8)
| 받는 법 | outputs | 마지막 상태 | 파라미터 | 가중치 순서 | |
|---|---|---|---|---|---|
nn.RNN | out, h_n | (1, 10, 8) | h_n (1, 1, 8) | 120 | — |
nn.LSTM | out, (h_n, c_n) | (1, 10, 8) | h_n, c_n 각 (1, 1, 8) | 480 | i, f, g, o |
nn.GRU | out, h_n | (1, 10, 8) | h_n (1, 1, 8) | 360 | r, z, g |