본문 바로가기
데이터사이언스/자연어처리 입문

[자연어처리 입문 11장] LSTM과 GRU: 게이트로 기억을 관리하기

by 카이스토 2026. 8. 29.

CH 11 자연어처리 입문 · Part 3 순서를 읽는 모델

LSTM과 GRU: 게이트로 기억을 관리하기

10장에서 RNN의 병은 진단이 끝났다 — 같은 행렬을 반복해서 곱하니 기울기가 지수적으로 사라진다. 20시점에서 10−4배였다. 이 장의 처방은 놀랍도록 단순하다. 곱셈 사슬 옆에 덧셈 고속도로를 하나 놓는다. 그리고 그 고속도로에 무엇을 태울지, 무엇을 내릴지를 학습으로 정하게 한다. 그것이 게이트다.

  • 01 직관
  • 02 수식 읽는 법
  • 03 손으로 풀기
  • 04 코드
  • 05 시각화

약어 및 기호 정의

LSTM
Long Short-Term Memory, 장단기기억 — 게이트와 셀 상태를 가진 순환신경망 (Hochreiter & Schmidhuber, 1997)
GRU
Gated Recurrent Unit, 게이트순환유닛 — LSTM을 게이트 2개로 줄인 경량판 (Cho et al., 2014)
셀 상태
cell state Ct — 장기 기억을 담는 벡터. 은닉상태와 별도로 흐른다
은닉상태
hidden state ht — 이번 시점에 밖으로 내보내는 값. 셀 상태를 출력게이트로 걸러 만든다
게이트
gate — 0~1 값을 원소별로 곱해 정보를 얼마나 통과시킬지 정하는 장치. σ(시그모이드)로 만든다
망각게이트 f
forget gate — 직전 셀 상태를 얼마나 남길지. f=1이면 완전 보존, f=0이면 삭제
입력게이트 i
input gate — 새 후보를 얼마나 써넣을지
출력게이트 o
output gate — 셀 상태를 얼마나 내보낼지
후보 g
candidate — 이번에 새로 쓸 내용. tanh로 만든다(부호가 있어야 하므로)
갱신게이트 z
update gate (GRU) — f와 i를 하나로 합친 것. ht = (1−z)⊙ñ + z⊙ht−1
재설정게이트 r
reset gate (GRU) — 후보를 만들 때 과거를 얼마나 볼지
⊙
아다마르 곱(Hadamard product) — 원소별 곱셈. 행렬곱이 아니다
망각편향
forget bias — bf를 1~2로 초기화하는 관례. 학습 초기에 f를 1 쪽으로 밀어 준다
래치 과제
latch task — "맨 앞 신호를 끝까지 기억하라"는 인공 과제. 장기 의존성만 따로 잰다
STEP 01

직관: 기억은 덮어쓰는 것이 아니라 편집하는 것이다

RNN이 기억을 다루는 방식을 다시 보자.

ht = tanh(xtWx + ht−1Wh + b) 직전 기억 전체를 매번 통째로 갈아엎는다

매 시점 h 전체가 Wh를 거쳐 tanh에 들어간다. 일부만 남기고 일부만 고칠 방법이 없다. 사람이 책을 읽는 방식과 비교해 보자.

사람은 이렇게 읽는다

추리소설을 읽는다고 하자. 3장에서 "범인의 왼손에 흉터가 있다"를 읽으면 그 사실은 마지막 장까지 그대로 들고 간다. 반면 "지금 등장인물이 어느 방에 있는가"는 장면이 바뀔 때마다 지운다.

즉 우리는 기억을 선택적으로 다룬다 — 어떤 것은 보존하고, 어떤 것은 버리고, 어떤 것은 덧쓴다. RNN에게는 이 선택지가 없다.

1-2. 두 개의 길을 만든다

LSTM의 해법은 기억을 담는 통로를 하나 더 만드는 것이다.

RNN의 한 줄기 vs LSTM의 두 줄기
RNN LSTM
흐르는 벡터 h 하나 C(장기) + h(출력) 두 개
갱신 방식 통째로 tanh 통과 Ct = f⊙Ct−1 + i⊙g
기억 유지 경로 Wh 곱셈 + tanh f 하나만 곱함 — 활성함수 없음
무엇을 남길지 정할 수 없음 학습된 게이트가 원소별로 정함
파라미터 1× 4× (게이트 3 + 후보 1)

핵심 한 줄

Ct = f⊙Ct−1 + i⊙g에서 Ct−1 앞에 붙은 것은 행렬이 아니라 숫자 하나(원소별로 하나씩)다. 그리고 그 뒤에 활성함수가 없다.

역전파에서 이 길을 거슬러 오를 때 곱해지는 것은 f뿐이다. f가 1이면 기울기가 그대로 통과한다. 100시점이든 1000시점이든.

STEP 02

수식 읽는 법: 네 줄과 두 줄

2-1. LSTM — 게이트 세 개, 후보 하나

식 (1) ft = σ(xtWf + ht−1Uf + bf) 망각게이트 — "직전 기억을 얼마나 남길까". σ라서 값이 항상 (0,1)
식 (2) it = σ(xtWi + ht−1Ui + bi)  ·  gt = tanh(xtWc + ht−1Uc + bc) 얼마나 쓸까(i) 와 무엇을 쓸까(g) 를 따로 배운다
식 (3) Ct = ft ⊙ Ct−1 + it ⊙ gt 이 장 전체가 이 한 줄이다 — 남길 것 + 새로 쓸 것
식 (4) ot = σ(xtWo + ht−1Uo + bo)  ·  ht = ot ⊙ tanh(Ct) 기억은 다 갖고 있되 지금 필요한 부분만 꺼낸다 — 이것이 C와 h를 나눈 이유
1
게이트는 왜 σ이고 후보는 왜 tanh인가
게이트는 "몇 %를 통과시킬까"이므로 (0,1)이어야 한다. 후보는 "어느 방향으로 얼마나"이므로 음수가 필요하다 — (−1,1).
2
게이트 네 개가 모두 xt와 ht−1을 본다
그래서 파라미터가 RNN의 4배다. 구현에서는 네 개를 한 행렬로 합쳐 한 번에 곱하고 나눈다(4절 코드가 그렇게 되어 있다).
3
식 (3)의 + 가 전부다
∂Ct/∂Ct−1 = ft 이다. 행렬도 활성함수도 끼어 있지 않다. RNN에서는 이 자리에 WhT·tanh′가 있었다.
4
그래서 기울기는 ∏ft 로 흐른다
f는 학습되는 값이므로 모델이 스스로 1 쪽으로 밀 수 있다. RNN의 ‖Wh‖=1은 우연히 맞춰야 하는 값이었지만, f는 필요하면 배우는 값이다.

2-2. GRU — 게이트 두 개로 줄이면

식 (5) zt = σ(·)  ·  rt = σ(·)  ·  ñt = tanh(xtWn + (rt⊙ht−1)Un) 재설정게이트 r은 후보를 만들 때만 쓴다 — "과거를 잠깐 무시하고 새로 쓸까"
식 (6) ht = (1 − zt) ⊙ ñt + zt ⊙ ht−1 f와 i를 하나로 묶었다 — 남기는 비율과 쓰는 비율의 합이 항상 1

GRU는 셀 상태 C를 따로 두지 않는다. h 하나가 두 역할을 겸한다. 파라미터가 3배로 줄어드는 대신, "다 기억하되 일부만 내보내기"라는 LSTM의 분리는 포기한다.

여기에 함정이 하나 숨어 있다

식 (6)에서 z는 LSTM의 f에 해당한다. 그런데 z의 편향을 0으로 초기화하면 z ≈ 0.5다. 즉 매 시점 기억이 절반으로 준다.

0.540 = 9.1×10−13. 4절에서 이것이 실제로 GRU를 무너뜨리는 장면을 본다. LSTM이 bf를 1로 초기화하는 관례는 바로 이 함정을 피하는 것이다.

STEP 03

손으로 풀기: 게이트 한 스텝, 그리고 극단값

손풀이 A — LSTM 한 스텝

은닉 2차원, 입력 2차원. 파라미터는 이렇다.

Wf = [[0.6,−0.2],[0.1,0.5]], bf = (1, −1)
Wi = [[0.3,0.4],[−0.2,0.1]], bi = (0, 0.5)
Wo = [[0.2,0.7],[0.5,−0.3]], bo = (0.1, 0.1)
Wc = [[0.5,0.1],[0.2,0.6]], bc = (0, 0)

입력 x = (1, 0.5), 직전 h = (0.2, −0.1), 직전 C = (0.8, −0.4). (간결함을 위해 ht−1 항은 이 예에서 생략한다 — 4절 코드도 동일하다.)

망각게이트 xWf = (1×0.6 + 0.5×0.1,  1×(−0.2) + 0.5×0.5) = (0.65, 0.05)
+ bf = (1.65, −0.95) → f = σ(1.65, −0.95) = (0.838891, 0.278885)

입력게이트 i = σ(0.2, 0.95) = (0.549834, 0.721115)

출력게이트 o = σ(0.55, 0.65) = (0.634136, 0.657010)

후보 g = tanh(0.6, 0.4) = (0.537050, 0.379949)

손풀이 B — 셀 상태를 조립한다

f ⊙ Ct−1 = (0.838891×0.8,  0.278885×(−0.4)) = (0.671113, −0.111554)

i ⊙ g = (0.549834×0.537050,  0.721115×0.379949) = (0.295288, 0.273987)

Ct = (0.671113, −0.111554) + (0.295288, 0.273987) = (0.966401, 0.162433)

ht = o ⊙ tanh(Ct) = (0.634136, 0.657010) ⊙ (0.746859, 0.161024) = (0.473774, 0.105791)

두 차원을 비교해 보자. 1번 차원은 f = 0.84로 과거를 대부분 지켰고, 2번 차원은 f = 0.28로 과거를 거의 버렸다(−0.4 → −0.11). bf = (1, −1)이라는 편향 차이가 그대로 나타난 것이다.

손풀이 C — 게이트가 극단값이면

C0 = 1.0에서 출발해 g = 0.5가 계속 들어온다고 하자. 3스텝을 따라간다.

설정 f i C 궤적 뜻
기억 유지 1 0 1.000 → 1.000 → 1.000 → 1.000 완전 보존. 감쇠가 없다
완전 초기화 0 1 1.000 → 0.500 → 0.500 → 0.500 과거를 지우고 새것만
누적 1 1 1.000 → 1.500 → 2.000 → 2.500 계속 더한다(발산 위험)
RNN처럼 감쇠 0.7 0.3 1.000 → 0.850 → 0.745 → 0.671 지수적으로 잊는다

첫 줄이 핵심이다. f=1, i=0이면 C가 아무 변화 없이 흐른다. RNN에서는 tanh(Whh)가 항상 h를 바꿔 버렸다. "아무것도 하지 않기"가 가능해진 것이 LSTM의 진짜 발명이다.

손풀이 D — 기울기가 T스텝 뒤에 얼마나 남는가

RNN은 매 스텝 Wh와 tanh′가 곱해진다. ‖Wh‖=0.9, tanh′≈0.5로 잡으면 스텝당 0.45다. LSTM의 C 경로는 f만 곱해진다.

T RNN (0.9×0.5) LSTM (f=0.9) LSTM (f=1.0)
5 1.8×10−2 0.591 1.000
10 3.4×10−4 0.349 1.000
20 1.2×10−7 0.122 1.000
50 4.6×10−18 5.2×10−3 1.000
100 2.1×10−35 2.7×10−5 1.000

f = 0.9만 되어도 20시점에서 RNN보다 백만 배 크다. 다만 이것은 이론적 상한이다 — 실제 학습된 모델에서 f가 얼마인지는 4절에서 직접 잰다.

STEP 04

코드: RNN·GRU·LSTM을 numpy로 나란히 학습한다

세 구조를 같은 코드, 같은 데이터, 같은 하이퍼파라미터로 돌린다. 프레임워크를 쓰지 않는 이유는 게이트의 역전파를 직접 유도해야 그 구조가 보이기 때문이다. 세 구현 모두 수치미분으로 검증했다(오차 10−10 수준).

ch11_gates.pynumpy LSTM·GRU 순전파/역전파 직접구현
import time, collections, pickle
import numpy as np
from nlpdata import morph_cache
np.set_printoptions(suppress=True, linewidth=150, precision=6)
sig = lambda z: 1 / (1 + np.exp(-z))
# ============ 1. 게이트 손계산 ============
print("LSTM 셀 한 스텝 손계산 — 은닉 2차원, 입력 2차원")
Wf = np.array([[0.6, -0.2], [0.1, 0.5]]); bf = np.array([1.0, -1.0])
Wi = np.array([[0.3, 0.4], [-0.2, 0.1]]); bi = np.array([0.0, 0.5])
Wo = np.array([[0.2, 0.7], [0.5, -0.3]]); bo = np.array([0.1, 0.1])
Wc = np.array([[0.5, 0.1], [0.2, 0.6]]); bc = np.array([0.0, 0.0])
x = np.array([1.0, 0.5]); h_prev = np.array([0.2, -0.1]); C_prev = np.array([0.8, -0.4])
f = sig(x @ Wf + bf); i = sig(x @ Wi + bi)
o = sig(x @ Wo + bo); g = np.tanh(x @ Wc + bc)
C = f * C_prev + i * g
h = o * np.tanh(C)
# ============ 2. 게이트 극단값 3스텝 ============
for name, ff, ii, gg in [("기억 유지 (f=1,i=0)", 1.0, 0.0, 0.5),
                          ("완전 초기화 (f=0,i=1)", 0.0, 1.0, 0.5),
                          ("누적 (f=1,i=1)", 1.0, 1.0, 0.5),
                          ("RNN처럼 감쇠 (f=0.7,i=0.3)", 0.7, 0.3, 0.5)]:
    Cx = 1.0; traj = [Cx]
    for _ in range(3):
        Cx = ff * Cx + ii * gg; traj.append(Cx)
# ============ 5~6. 세 구조를 같은 틀에 넣는다 ============
D, H = 32, 48
def init(kind, seed=0):
    r = np.random.default_rng(seed)
    k = {"rnn": 1, "gru": 3, "gru1": 3, "lstm": 4}[kind]     # 게이트 묶음 개수
    P = {"E": r.normal(0, 0.1, (Vn, D)),
         "Wx": r.normal(0, np.sqrt(1 / D), (D, k * H)),
         "Wh": np.concatenate([np.linalg.qr(r.normal(0, 1, (H, H)))[0] * 0.9
                               for _ in range(k)], 1),   # 직교 초기화
         "b": np.zeros(k * H),
         "Wo": r.normal(0, np.sqrt(1 / H), H), "bo": 0.0}
    if kind == "lstm": P["b"][:H] = 1.0    # 망각게이트 편향 1
    if kind == "gru1": P["b"][:H] = 1.0    # 갱신게이트 편향 1 (비교용)
    return P
def fwd(P, idx, kind):
    n, T = idx.shape
    emb = P["E"][idx]; mask = (idx != 0).astype(float)
    hs = np.zeros((n, T + 1, H)); Cs = np.zeros((n, T + 1, H)); cache = []
    for t in range(T):
        m = mask[:, t:t + 1]
        if kind == "rnn":
            z = emb[:, t] @ P["Wx"] + hs[:, t] @ P["Wh"] + P["b"]
            ht = np.tanh(z); Ct = Cs[:, t]
            cache.append((z, None))
        elif kind == "lstm":
            z = emb[:, t] @ P["Wx"] + hs[:, t] @ P["Wh"] + P["b"]
            f = sig(z[:, :H]); i = sig(z[:, H:2 * H])
            o = sig(z[:, 2 * H:3 * H]); g = np.tanh(z[:, 3 * H:])
            Ct = f * Cs[:, t] + i * g                  # ← 식 (3)
            tC = np.tanh(Ct); ht = o * tC
            cache.append((f, i, o, g, tC))
        else:                                          # gru / gru1
            z = emb[:, t] @ P["Wx"][:, :2 * H] + hs[:, t] @ P["Wh"][:, :2 * H] + P["b"][:2 * H]
            zt = sig(z[:, :H]); rt = sig(z[:, H:])
            n_ = np.tanh(emb[:, t] @ P["Wx"][:, 2 * H:]
                         + (rt * hs[:, t]) @ P["Wh"][:, 2 * H:] + P["b"][2 * H:])
            ht = (1 - zt) * n_ + zt * hs[:, t]; Ct = Cs[:, t]
            cache.append((zt, rt, n_))
        hs[:, t + 1] = m * ht + (1 - m) * hs[:, t]     # 패딩이면 상태 유지
        Cs[:, t + 1] = m * Ct + (1 - m) * Cs[:, t]
    p = sig(hs[:, -1] @ P["Wo"] + P["bo"])
    return dict(emb=emb, hs=hs, Cs=Cs, mask=mask, p=p, cache=cache)
# --- LSTM 역전파 (핵심 부분만) ---
# f, i, o, gg, tC = F["cache"][t]
# do  = dh * tC                                   출력게이트로 가는 기울기
# dCt = (dC + dh * o * (1 - tC**2)) * m           셀로 오는 두 갈래를 합친다
# df  = dCt * F["Cs"][:, t];  di = dCt * gg;  dg = dCt * i
# dz  = concat[df*f*(1-f), di*i*(1-i), do*o*(1-o)*m, dg*(1-gg**2)]
# dh  = dz @ P["Wh"].T + dh * (1 - m)
# dC  = dCt * f + dC * (1 - m)      ← 여기! 행렬도 활성함수도 곱해지지 않는다
# ============ 7. 긴 문맥 과제(래치) ============
# 첫 토큰이 1이면 정답 1, 2이면 정답 0. 나머지 T-1개는 잡음
for T in [5, 10, 20, 40]:
    r = np.random.default_rng(7); n = 6000
    Xl = r.integers(3, 60, (n, T)).astype(int); yl = r.integers(0, 2, n).astype(float)
    Xl[:, 0] = np.where(yl > 0.5, 1, 2)
    for kind in ["rnn", "gru", "gru1", "lstm"]:
        P, hist, el = train(kind, epochs=10, Xtr=Xl[:5000], Ytr=yl[:5000],
                            Xte=Xl[5000:], Yte=yl[5000:], log=False)
# ============ 8. 첫 시점 기울기 크기 실측 ============
# 학습 전 모델에서 dh(와 LSTM은 dC까지)의 노름을 시점마다 기록해
# 마지막 시점 대비 첫 시점의 비율을 잰다 — 10장과 같은 측정
# ============ 9. 래치 과제를 푼 LSTM을 열어 본다 ============
def run(P, idx):
    emb = P["E"][idx]; h = np.zeros((len(idx), H)); C = np.zeros((len(idx), H))
    Fs = []; Cs = []
    for t in range(idx.shape[1]):
        z = emb[:, t] @ P["Wx"] + h @ P["Wh"] + P["b"]
        f = sig(z[:, :H]); i = sig(z[:, H:2 * H])
        o = sig(z[:, 2 * H:3 * H]); g = np.tanh(z[:, 3 * H:])
        C = f * C + i * g; h = o * np.tanh(C)
        Fs.append(f); Cs.append(C.copy())
    return np.array(Fs), np.array(Cs), h
F, C, hT = run(P, Xe)
# 두 정답을 가장 크게 갈라놓는 유닛을 찾는다
sep = np.abs(C[:, ye > 0.5, :].mean(1) - C[:, ye < 0.5, :].mean(1))    # (T, H)
best = sep[-1].argmax()
# 같은 측정을 RNN으로 — h는 tanh 때문에 ±1에 갇힌다
embr = Pr["E"][Xe]; hh = np.zeros((len(Xe), H)); Hs = []
for t in range(T):
    hh = np.tanh(embr[:, t] @ Pr["Wx"] + hh @ Pr["Wh"] + Pr["b"]); Hs.append(hh.copy())
Hs = np.array(Hs)
sepr = np.abs(Hs[:, ye > 0.5, :].mean(1) - Hs[:, ye < 0.5, :].mean(1))
LSTM 셀 한 스텝 손계산 — 은닉 2차원, 입력 2차원
  입력 x = [1.  0.5], 직전 은닉 h = [ 0.2 -0.1], 직전 셀 C = [ 0.8 -0.4]
  망각게이트 f = σ(xWf+bf) = [0.838891 0.278885]   ← C를 얼마나 남길지
  입력게이트 i = σ(xWi+bi) = [0.549834 0.721115]   ← 새 후보를 얼마나 넣을지
  출력게이트 o = σ(xWo+bo) = [0.634136 0.65701 ]   ← C를 얼마나 내보낼지
  후보    g = tanh(xWc+bc) = [0.53705  0.379949]   ← 새로 쓸 내용
  C_t = f⊙C_{t-1} + i⊙g = [0.8389 0.2789] ⊙ [ 0.8 -0.4] + [0.5498 0.7211] ⊙ [0.537  0.3799]
        = [ 0.671113 -0.111554] + [0.295288 0.273987] = [0.966401 0.162433]
  h_t = o⊙tanh(C_t) = [0.473774 0.105791]
  → C는 곱셈 사슬이 아니라 '이전 값 × f + 새 값'의 덧셈이다. 여기가 핵심
게이트가 극단값일 때 셀 상태는 어떻게 되나 (3스텝)
  설정                              f          i         C 궤적 (C0=1.0에서 시작)
  기억 유지 (f=1,i=0)               1.0        0.0 1.000 → 1.000 → 1.000 → 1.000
  완전 초기화 (f=0,i=1)              0.0        1.0 1.000 → 0.500 → 0.500 → 0.500
  누적 (f=1,i=1)                  1.0        1.0 1.000 → 1.500 → 2.000 → 2.500
  RNN처럼 감쇠 (f=0.7,i=0.3)        0.7        0.3 1.000 → 0.850 → 0.745 → 0.671
  → f=1이면 C가 그대로 흐른다. 100스텝을 가도 감쇠가 없다
기울기가 T스텝을 거슬러 갈 때 남는 비율
         T      RNN(‖Wh‖=0.9)        LSTM(f=0.9)        LSTM(f=1.0)
         5          1.845e-02          5.905e-01          1.000e+00
        10          3.405e-04          3.487e-01          1.000e+00
        20          1.159e-07          1.216e-01          1.000e+00
        50          4.577e-18          5.154e-03          1.000e+00
       100          2.095e-35          2.656e-05          1.000e+00
  → RNN은 Wh와 tanh' 두 개가 곱해져 훨씬 빨리 죽는다
     LSTM의 C 경로는 f만 곱해진다. f를 1에 가깝게 배우면 기울기가 산다
     (망각게이트 편향 bf를 1~2로 초기화하는 관례가 여기서 나온다)
파라미터 수 비교 (입력 D, 은닉 H)
           D            H            RNN            GRU           LSTM
          32           48          3,888         11,664         15,552
         100          128         29,312         87,936        117,248
         300          512        416,256      1,248,768      1,665,024
  → GRU는 게이트 2개(3배), LSTM은 4개(4배). 성능 대비 비용을 봐야 한다
실험 데이터: 네이버 영화리뷰
  어휘 6001개, 최대 길이 30, 학습 20000 / 평가 5000
세 모델을 같은 조건에서 학습한다 (임베딩 32, 은닉 48, 6에폭)
     RNN  에폭 6  검증 정확도 0.775800
     GRU  에폭 6  검증 정확도 0.786400
    LSTM  에폭 6  검증 정확도 0.775000
최종 비교
        모델           파라미터         검증 정확도        학습 시간
       RNN        195,969       0.775800        14.6초
       GRU        203,745       0.786400        33.7초
      LSTM        207,633       0.775000        50.4초
긴 문맥 과제: 문장 맨 앞 신호를 끝까지 기억해야 푸는 문제(래치 과제)
  규칙 — 첫 토큰이 1이면 정답 1, 2이면 정답 0. 나머지 T-1개는 무작위 잡음
  세 가지를 잰다 ① 최종 정확도 ② 90%에 도달한 에폭 ③ 첫 시점 기울기 크기
  최종 정확도
        길이 T        RNN        GRU     GRU(b=1)       LSTM
           5     1.0000     1.0000       1.0000     1.0000
          10     1.0000     1.0000       1.0000     1.0000
          20     1.0000     0.5320       1.0000     1.0000
          40     1.0000     0.5060       0.5070     1.0000
  90%에 도달한 에폭 (0 = 10에폭 안에 도달 실패)
        길이 T        RNN        GRU     GRU(b=1)       LSTM
           5          1          1            1          1
          10          1          3            2          1
          20          1          0            6          2
          40          2          0            0          3
  → 기본 GRU는 갱신게이트 편향이 0이라 z≈0.5, 즉 매 스텝 기억이 절반으로 준다.
     0.5^40 = 9.1e-13. 그래서 T=20부터 무너진다.
     편향만 1로 바꾼 GRU(b=1)는 T=20을 되찾지만 T=40은 여전히 못 푼다 —
     초기화는 도움이 되지만 만능이 아니다. LSTM만 네 길이를 모두 푼다.
     한편 RNN이 T=40을 푼 것은 직교 초기화 + 기울기 클리핑 덕이다.
     '풀 수 있다'와 '기울기가 살아 있다'는 다른 말이므로, 다음 절에서 기울기를 직접 잰다.
첫 시점 기울기 크기 — 학습 전 모델에서 직접 잰다 (10장과 같은 측정)
        길이 T            RNN            GRU       GRU(b=1)           LSTM
           5      6.130e-01      2.068e-01      3.867e-01      5.399e-01
          10      3.100e-01      3.659e-02      1.516e-01      3.575e-01
          20      7.625e-02      1.243e-03      2.664e-02      1.825e-01
          40      4.563e-03      1.595e-06      9.102e-04      5.339e-02
  → 마지막 시점 대비 첫 시점의 기울기 비율이다. LSTM은 셀 경로 덕에 40스텝에서도 살아 있다
래치 과제(T=40)를 푼 LSTM을 연다 — 검증 정확도 1.0000
망각게이트 f 는 생각만큼 1에 가깝지 않다
  f 전체 평균 0.7425, 유닛별 최댓값 0.7841  →  최댓값^40 = 5.95e-05
  즉 40스텝 뒤 셀 값의 크기는 10만분의 1 이하로 줄어든다.
  그런데도 정확도는 1.0000이다. 크기가 아니라 무엇이 남은 것인가?
마지막 시점에서 두 정답을 가장 크게 갈라놓는 유닛: 32번
      시점        C (정답1)        C (정답0)             차이     f (해당유닛)
       1      -0.125218       0.188554      -0.313771       0.7392
       2      -0.084399       0.124338      -0.208737       0.7580
       6       0.489610      -0.630731       1.120342       0.7649
      11       1.380154      -1.668139       3.048293       0.7582
      21       1.048671      -2.163836       3.212508       0.7368
      31      -1.277357       0.424282      -1.701639       0.7603
      40      -1.987603       1.901214      -3.888817       0.7522
  → 예상과 다르다. 차이가 줄기는커녕 0.31에서 3.89로 열두 배 커졌다
     f^40이 6e-05인데도 그렇다. 감쇠보다 새로 쓰는 양이 더 컸다는 뜻이다
그렇다면 매 시점 얼마나 쓰고 있는가 — 입력게이트 i⊙g 를 본다
      시점     이 유닛에 새로 쓴 양 i⊙g
       1             0.156886
       2             0.049759
       3             0.082469
       6             0.305149
      11             0.468465
      21             0.384567
      40             0.498616
  → 1시점에 0.1569, 마지막 시점에 0.4986. 오히려 뒤로 갈수록 더 많이 쓴다.
     즉 LSTM은 첫 신호를 '가만히 보관'한 것이 아니라
     매 시점 다시 써넣으며 '계속 키운' 것이다. 셀 상태에 tanh가 없어서 가능하다
RNN에는 이 선택지가 없다 — 같은 측정을 RNN으로
  RNN 검증 정확도 1.0000, 가장 잘 가르는 유닛 20번
      시점         LSTM 분리도          RNN 분리도
       1         0.313771         0.023483
       5         0.667478         1.995765
      10         2.789341         2.000000
      20         3.357079         2.000000
      30         1.055463         2.000000
      40         3.888817         2.000000
  → RNN의 분리도가 10시점 이후 정확히 2.000000에 붙어 있다.
     tanh가 완전히 포화해 두 정답이 각각 +1과 -1에 고정된 것이다(차이 = 2).
     즉 RNN은 '천장'에서 아슬아슬하게 푼다. 여유가 없다.
     LSTM의 C에는 tanh가 없어 상한이 없다 — 그래서 3.889까지 벌릴 수 있다.
     이 여유가 과제가 어려워질 때 차이를 만든다

4-1. 결과를 정직하게 읽자

이 실험은 교과서가 말하는 대로 되지 않은 부분이 있다. 하나씩 보자.

세 가지 관찰과 그 해석
관찰 수치 해석
영화리뷰에서 LSTM이 RNN을 못 이겼다 0.7750 vs 0.7758 리뷰가 평균 12토큰이다. 기억할 것이 없는데 기억 장치를 달았다
GRU가 셋 중 가장 좋았다 0.7864 파라미터 3배로 표현력은 늘고, 게이트 감쇠는 짧은 문장에서 문제가 안 된다
래치 과제에서 RNN이 T=40을 풀었다 정확도 1.0000 직교 초기화 + 기울기 클리핑의 효과다. 무작위 초기화였다면 달랐다
그런데 기울기는 RNN이 12배 작다 4.6×10−3 vs 5.3×10−2 "풀 수 있다"와 "학습이 쉽다"는 다른 말이다. 정확도만 보면 놓친다
기본 GRU는 T=20에서 무너졌다 0.5320 (찍기 수준) z≈0.5 초기화. 0.520 = 9.5×10−7 — 2절에서 예고한 함정
편향만 1로 바꾸니 T=20이 풀렸다 0.5320 → 1.0000 코드 한 줄이다. 다만 T=40은 여전히 못 푼다 — 초기화는 만능이 아니다
학습된 f는 1 근처가 아니었다 평균 0.7425, 최대 0.7841 예상이 틀렸다. 0.78440=6×10−5인데도 정확도는 1.0 — 감쇠를 새로 쓰는 양으로 이겼다
RNN의 분리도가 2.000000에 고정 10시점 이후 정확히 2 tanh 포화. 천장에 닿았다. LSTM의 C는 상한이 없어 3.889까지 간다

정확도는 차이를 감추고, 기울기는 감추지 않는다

래치 과제 T=40에서 RNN과 LSTM은 둘 다 정확도 1.0000이다. 표만 보면 같은 모델이다. 그런데 기울기를 재면

LSTM 5.3×10−2 > RNN 4.6×10−3 > GRU(b=1) 9.1×10−4 > GRU 1.6×10−6

LSTM은 RNN의 12배, 기본 GRU의 33,000배다. 이 여유가 문장이 100, 500 토큰으로 길어질 때 학습 가능 여부를 가른다.

그리고 하나 더 — 내 예상이 틀린 지점

9절은 원래 "장기 기억이 필요한 과제로 학습하면 f가 1로 밀릴 것"을 확인하려는 실험이었다. 결과는 그렇지 않았다.

f 평균은 리뷰 학습 0.7351, 래치 학습 0.7425로 거의 같았다. 0.78440 = 6×10−5인데도 과제는 완벽하게 풀린다.

대신 드러난 것은 다른 그림이다 — 셀 상태에 상한이 없다는 사실. RNN의 h는 tanh 때문에 분리도가 정확히 2.0에서 멈추지만(그림 4 오른쪽), LSTM의 C는 3.889까지 간다. 덧셈 고속도로의 이득은 "기울기가 흐른다"만이 아니라 "신호를 키울 공간이 있다"이기도 하다.

실험이 예상을 뒤집으면 예상 쪽을 고쳐 쓰는 것이 맞다. 이 시리즈의 모든 수치는 실제 실행 결과이고, 서술은 거기에 맞춘다.

STEP 05

시각화: 고속도로, 게이트, 기울기, 역할 분화, 비용

ch11-cell
그림 1. LSTM 셀의 구조. 위쪽 굵은 가로선이 셀 상태 C다 — 왼쪽에서 오른쪽으로 지나가는 동안 만나는 것은 ×(망각게이트를 곱함)와 +(새 후보를 더함)뿐이다. 가중치 행렬도, 활성함수도 없다. RNN에서는 이 자리에 tanh(Wh·)가 있었고 그것이 기울기를 죽였다. 아래쪽 가는 가로선은 은닉상태 h로, 게이트 넷을 만드는 입력이자 셀 상태를 o로 걸러 만든 출력이다. 기억(C)과 출력(h)을 분리한 것이 LSTM의 두 번째 아이디어다 — 다 갖고 있되 지금 필요한 것만 꺼낸다.
ch11-gate
그림 2. 왼쪽 — 손풀이 C를 12스텝으로 늘린 것. f=1, i=0이면 셀 상태가 완벽하게 평평하다. f=0.7은 지수적으로 감쇠하고(RNN이 하던 일), f=i=1은 끝없이 누적된다(발산 위험 — 그래서 i도 게이트다). 이 네 가지를 차원마다, 시점마다 따로 고를 수 있다는 것이 게이트의 뜻이다. 오른쪽(로그축) — 기울기가 T스텝 뒤에 남는 비율. RNN은 Wh와 tanh′ 둘이 곱해져 가장 빨리 죽고, 기본 GRU는 z≈0.5 때문에 거의 같은 속도로 죽는다. LSTM(f=0.9)은 훨씬 완만하고, f=1이면 수평선이다.
ch11-long
그림 3. 왼쪽 — 이론이 아니라 실측이다. 학습 전 네 모델에서 마지막 시점 대비 첫 시점의 기울기 크기를 직접 쟀다. T=40에서 LSTM 5.3×10−2, RNN 4.6×10−3, GRU(b=1) 9.1×10−4, 기본 GRU 1.6×10−6 — LSTM과 GRU 사이가 33,000배다. 오른쪽 — 래치 과제 정확도. T=5, 10에서는 넷 다 완벽하지만 T=20에서 기본 GRU가 찍기 수준(0.532)으로 무너진다. z의 편향을 0에서 1로 바꾼 것만으로 GRU(b=1)이 T=20을 되찾는 것에 주목하자 — 코드 한 줄이다. 다만 T=40은 여전히 못 푼다. RNN이 T=40을 푼 것은 직교 초기화와 기울기 클리핑 덕인데, 기울기는 LSTM의 12분의 1이므로 "풀렸다"와 "학습이 안정적이다"를 혼동하면 안 된다.
ch11-gates
그림 4. 래치 과제(T=40)를 푼 LSTM을 열어 본 것이다. 여기서 나는 예상이 틀렸다. 나는 "LSTM이 f를 1로 밀어 첫 신호를 얼려 둔다"고 예상했는데, 실제로 재 보니 f 평균은 0.7425, 유닛별 최댓값도 0.7841에 불과했다 — 0.784140 = 5.95×10−5다. 얼려 두기는커녕 10만분의 1로 줄어드는 감쇠율이다. 왼쪽 — 그런데도 두 정답을 가장 잘 가르는 32번 유닛의 셀 상태를 보면, 두 곡선의 간격이 0.31에서 3.89로 열두 배 벌어진다. 심지어 27시점 근처에서 부호가 한 번 뒤집힌다. 감쇠보다 매 시점 새로 써넣는 양이 더 컸던 것이다(입력게이트가 쓰는 양: 1시점 0.157 → 40시점 0.499). LSTM은 신호를 보관한 게 아니라 계속 키웠다. 오른쪽 — 왜 그것이 가능한가. RNN의 h는 10시점 이후 분리도가 정확히 2.000000에 붙는다. tanh가 완전히 포화해 두 정답이 +1과 −1에 고정된 것이다 — 천장에 닿았다. LSTM의 C에는 활성함수가 없어 상한이 없고, 그래서 3.89까지 벌릴 수 있다. 이것이 "덧셈 고속도로"의 두 번째 이득이다 — 기울기가 잘 흐를 뿐 아니라, 신호를 담을 여유 공간이 무한하다.
ch11-cost
그림 5. 왼쪽 — 네이버 영화리뷰 6에폭 학습 곡선. 세 모델이 0.775~0.786 안에 모여 있다. 오른쪽 — 그 성능을 얻는 데 든 비용. 파라미터는 3,888 → 11,664 → 15,552로 4배, 학습 시간은 14.6초 → 33.7초 → 50.4초로 3.5배다. 그런데 정확도는 최대 0.011 차이다. 심지어 가장 비싼 LSTM이 가장 싼 RNN보다 낮다(0.7750 vs 0.7758). 이것은 LSTM이 나쁘다는 뜻이 아니라 과제가 장기 기억을 요구하지 않는다는 뜻이다 — 평균 12토큰짜리 리뷰다. 그림 3의 래치 과제처럼 장기 의존성이 실제로 필요한 순간에만 이 4배 비용이 값을 한다. "좋은 모델"이 아니라 "이 과제에 맞는 모델"을 고르는 것이 실무의 일이다.

이것만 기억하자

  1. LSTM의 핵심은 Ct = f⊙Ct−1 + i⊙g 한 줄이다. 곱셈 사슬 옆에 놓은 덧셈 고속도로 — 이 길에는 가중치도 활성함수도 없다.
  2. 그래서 기울기가 거슬러 갈 때 f만 곱해진다. 실측 T=40에서 LSTM은 5.3×10−2, RNN은 4.6×10−3, 기본 GRU는 1.6×10−6였다.
  3. 게이트는 초기화가 절반이다. GRU의 z 편향을 0에서 1로 바꾼 한 줄이 래치 과제 T=20을 0.532에서 1.000으로 바꿨다.
  4. 셀 상태에는 활성함수가 없어 상한도 없다. RNN의 h는 분리도가 tanh 천장인 2.0에서 멈췄지만 LSTM의 C는 3.889까지 벌렸다.

흔한 오해

  1. "LSTM은 RNN보다 항상 낫다" — 영화리뷰에서 0.7750 vs 0.7758로 졌다. 파라미터 4배, 시간 3.5배를 쓰고서. 12토큰짜리 문장에는 기억 장치가 필요 없다.
  2. "GRU는 LSTM의 열화판이다" — 같은 실험에서 GRU가 0.7864로 셋 중 1등이었다. 다만 장기 의존성에서는 초기화 문제로 가장 먼저 무너진다. 둘은 상황에 따라 갈린다.
  3. "정확도가 같으면 같은 모델이다" — 래치 T=40에서 RNN과 LSTM은 둘 다 1.0000이지만 기울기는 12배 차이다. 과제가 조금만 어려워지면 갈라진다.
  4. "장기 기억이 필요하면 f가 1로 간다" — 이 글을 쓰며 내가 틀린 부분이다. 40스텝 래치를 완벽히 푼 LSTM의 f는 평균 0.7425였다. 모델은 기억을 얼려 두는 대신 매 시점 다시 써넣으며 키웠다.

댓글