4.2 LSTM과 GRU 코드 리뷰

yunju·6일 전

코드 리뷰

목록 보기
2/5

4.2 LSTM과 GRU — 코드 리뷰 노트

노트북: 07-02. lstm_and_gru.ipynb (셀 9개, 코드 셀 7개)
예상 시간: 약 12분 (노트북 3분 + 추가 셀 9분) · 짝꿍 글: 4.2_LSTM과_GRU.md


발표 전 체크

  • 노트북이 아주 짧다. 층을 만들어서 출력(repr)만 보는 셀뿐이고, 입력을 넣어 실행하는 셀이 없다. 그래서 이 노트에 세션에서 같이 돌려 볼 추가 셀 A~E를 붙였다. 미리 코랩에 붙여 두자.
  • ⚠️ 셀 5의 출력이 코드와 안 맞는다. 코드는 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 노트북의 버그와 같은 교훈이라 짧게 짚고 가면 좋다.
  • 셀 4와 셀 7은 똑같은 nn.RNN(...) 코드다. 비교용으로 일부러 둔 것이다.

흐름 한눈에

파트셀한 줄 메시지시간
1. 노트북0~8만드는 법은 RNN과 똑같다. 이름만 바뀐다3분
2. 추가 A—LSTM은 반환값이 (h_n, c_n) 튜플2분
3. 추가 B—파라미터 4배, 3배2분
4. 추가 C—수식 6줄 = nn.LSTM3분
5. 추가 D—기울기 실험 (시간 남으면)2분

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


Part 1. 노트북 (셀 0~8)

셀 0~1 — 임포트와 버전

import torch
import torch.nn as nn

torch.__version__        # '2.4.0+cu121' (노트북에 저장된 값)
  • 버전 출력은 작성자 환경 값이다. 코랩에서 돌리면 다른 버전이 나와도 상관없다.

셀 2 — 크기 정하기

input_dim = 5     # 입력의 크기
hidden_size = 8   # 은닉 상태의 크기
  • 4.1의 파이토치 파트와 같은 숫자다(입력 5, 은닉 8). 4.1의 결과와 바로 비교할 수 있다.

셀 4~5 — RNN과 LSTM 나란히 만들기

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) ← 저장된 출력이 이상하다
  • 두 줄이 클래스 이름만 다르고 인자는 완전히 같다. 바깥에서 쓰는 법은 RNN과 같고, 셀 안쪽만 다르다는 게 이 셀의 메시지다.
  • 변수에 담지 않았으니 층을 만들기만 하고 버린다. 주피터가 마지막 줄의 값을 보여주니까 층의 요약(repr) 이 출력된다. 출력은 (입력 크기, 은닉 크기, 옵션) 순서다.
  • 셀 5의 저장된 출력 LSTM(8, 8, ...)은 지금 코드로 나올 수 없는 값이다. 다시 실행하면 LSTM(5, 8, ...)이 나온다.

🗣 "RNN을 LSTM으로 바꾸는 건 이름 한 단어만 바꾸면 돼요. 4.5에서도 모델 코드에서 nn.LSTM 한 줄이 이 자리에 들어갑니다."

🗣 "셀 5 출력을 보면 LSTM(8, 8)이라고 찍혀 있는데, 코드는 input_dim이 5예요. 예전에 돌린 출력이 남아 있는 거고, 다시 돌리면 5가 나옵니다. 4.1에서도 봤지만, 저장된 출력은 '언젠가 돌린 결과'일 뿐이라 그대로 믿으면 안 돼요."

셀 7~8 — RNN과 GRU 나란히 만들기

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)
  • 이번에도 이름만 다르다. GRU는 출력이 정상(5, 8)이다.

❓ 만드는 법이 다 같으면 뭐가 달라? 안에 든 가중치 수와 계산 순서가 다르다. 그리고 LSTM만 반환값 모양이 다르다. 추가 셀 A, B에서 바로 확인한다.


Part 2. 추가 셀 A — 실행해서 반환값 보기

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)로 같다.
  • LSTM은 두 번째 반환값이 (h_n, c_n) 튜플이다. c_n이 마지막 시점의 셀 상태다.
  • outputs에는 hth_t만 담긴다. 셀 상태는 셀 안에서만 흐르고, 마지막 값만 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층에 넣는다. 다 대 일이라 마지막 은닉 상태 하나만 쓴다.


Part 3. 추가 셀 B — 파라미터 세어 보기

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)
   ...
  • RNN의 가중치 행 수는 8, LSTM은 32 = 8 × 4, GRU는 24 = 8 × 3이다.
  • LSTM은 f,i,g,of, i, g, o 네 개의 가중치를 한 행렬에 세로로 쌓아 둔다. 순서는 i, f, g, o다. 행렬곱 한 번으로 넷을 같이 계산하고, 나중에 8칸씩 자른다.
  • GRU는 r, z, g 세 벌이다.

🗣 "파라미터가 정확히 4배, 3배인 건 게이트마다 4.1의 RNN 셀이 하나씩 들어 있다고 보면 돼요. 게이트 하나가 RNN 셀 하나랑 같은 모양의 식이거든요."

❓ LSTM의 첫 8행만 꺼내면 뭐야? lstm.weight_ih_l0[:8]이 입력 게이트 ii의 WxiW_{xi} (8, 5)다. [8:16]은 삭제 게이트 ff다.


Part 4. 추가 셀 C — 수식 6줄로 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

한 줄씩:

  1. z = ... → 4.1의 RNN 셀 계산과 똑같은 모양이다. 다만 결과가 8칸이 아니라 32칸이다(게이트 넷 몫).
  2. z.chunk(4, dim=1) → 32칸을 8칸씩 넷으로 자른다. 순서가 i, f, g, o인 걸 꼭 짚자.
  3. 활성화 → 게이트 셋(i, f, o)은 시그모이드(0~1 밸브), 후보 g는 tanh(-1~1 내용).
  4. c = f * c + i * g → 셀 상태 갱신. 이번 편의 핵심 줄. *는 원소별 곱이다.
  5. h = o * torch.tanh(c) → 셀 상태를 다듬어서 필요한 만큼 꺼낸다.

🗣 "4.1에서 RNN은 for문 안에 한 줄이었죠. LSTM은 이 여섯 줄이고, 그중 진짜 새로운 건 c = f c + i g 한 줄이에요. 곱셈 한 번, 덧셈 한 번으로만 기억이 바뀌니까 기울기가 멀리까지 갈 수 있습니다."

❓ GRU도 이렇게 재현돼? 된다. 다만 파이토치 GRU는 리셋 게이트를 곱하는 위치가 위키독스 수식과 다르다. 위키독스는 Whg(r⊙h)W_{hg}(r \odot h)이고, 파이토치는 r⊙(Whgh+b)r \odot (W_{hg}h + b)다. 위키독스 수식 그대로 짜면 nn.GRU와 값이 다르게 나온다(직접 확인함). 학습 성능 차이는 거의 없다.


Part 5. 추가 셀 D — 기울기가 얼마나 멀리 가나 (시간 남으면)

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칸
RNN1e+001e-032e-072e-11
LSTM (초기값)3e-011e-036e-079e-11
LSTM (f ≈ 0.95)2e-016e-023e-022e-02
  • bias_ih_l0[8:16] → 32칸 중 두 번째 8칸, 즉 삭제 게이트 ff 의 편향이다(순서 i, f, g, o).
  • 시드 하나로 돌리면 숫자는 꽤 달라진다(예: 시드 1에서 RNN 59칸은 3e-21). 하지만 "RNN과 초기 LSTM은 계속 작아지고, f ≈ 0.95인 LSTM만 버틴다"는 경향은 같다.

🗣 "초기값 그대로의 LSTM은 RNN이랑 똑같이 기울기가 사라져요. 학습 전에는 f가 0.5 근처라서 매 시점 기억이 반씩 줄거든요. f를 0.95로 열어 주면 60칸 앞까지 기울기가 살아 있습니다."

🗣 "그러니까 LSTM이 기울기 소실을 공짜로 없애 주는 게 아니에요. 기울기가 지나갈 길을 만들어 두고, 그 길을 열지 말지를 학습하게 한 겁니다."

❓ 그럼 4.5에서도 편향을 바꿔야 해? 안 바꾼다. 학습하면서 필요한 칸의 ff가 알아서 올라간다. 처음부터 1 정도로 잡아 두는 경우도 있지만 필수는 아니다.


마무리 (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.RNNout, h_n(1, 10, 8)h_n (1, 1, 8)120—
nn.LSTMout, (h_n, c_n)(1, 10, 8)h_n, c_n 각 (1, 1, 8)480i, f, g, o
nn.GRUout, h_n(1, 10, 8)h_n (1, 1, 8)360r, z, g

0개의 댓글