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 — "맨 앞 신호를 끝까지 기억하라"는 인공 과제. 장기 의존성만 따로 잰다
직관: 기억은 덮어쓰는 것이 아니라 편집하는 것이다
RNN이 기억을 다루는 방식을 다시 보자.
매 시점 h 전체가 Wh를 거쳐 tanh에 들어간다. 일부만 남기고 일부만 고칠 방법이 없다. 사람이 책을 읽는 방식과 비교해 보자.
사람은 이렇게 읽는다
추리소설을 읽는다고 하자. 3장에서 "범인의 왼손에 흉터가 있다"를 읽으면 그 사실은 마지막 장까지 그대로 들고 간다. 반면 "지금 등장인물이 어느 방에 있는가"는 장면이 바뀔 때마다 지운다.
즉 우리는 기억을 선택적으로 다룬다 — 어떤 것은 보존하고, 어떤 것은 버리고, 어떤 것은 덧쓴다. RNN에게는 이 선택지가 없다.
1-2. 두 개의 길을 만든다
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시점이든.
수식 읽는 법: 네 줄과 두 줄
2-1. LSTM — 게이트 세 개, 후보 하나
2-2. GRU — 게이트 두 개로 줄이면
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로 초기화하는 관례는 바로 이 함정을 피하는 것이다.
손으로 풀기: 게이트 한 스텝, 그리고 극단값
손풀이 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절에서 직접 잰다.
코드: RNN·GRU·LSTM을 numpy로 나란히 학습한다
세 구조를 같은 코드, 같은 데이터, 같은 하이퍼파라미터로 돌린다. 프레임워크를 쓰지 않는 이유는 게이트의 역전파를 직접 유도해야 그 구조가 보이기 때문이다. 세 구현 모두 수치미분으로 검증했다(오차 10−10 수준).
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))
입력 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까지 간다. 덧셈 고속도로의 이득은 "기울기가 흐른다"만이 아니라 "신호를 키울 공간이 있다"이기도 하다.
실험이 예상을 뒤집으면 예상 쪽을 고쳐 쓰는 것이 맞다. 이 시리즈의 모든 수치는 실제 실행 결과이고, 서술은 거기에 맞춘다.
시각화: 고속도로, 게이트, 기울기, 역할 분화, 비용





이것만 기억하자
- LSTM의 핵심은 Ct = f⊙Ct−1 + i⊙g 한 줄이다. 곱셈 사슬 옆에 놓은 덧셈 고속도로 — 이 길에는 가중치도 활성함수도 없다.
- 그래서 기울기가 거슬러 갈 때 f만 곱해진다. 실측 T=40에서 LSTM은 5.3×10−2, RNN은 4.6×10−3, 기본 GRU는 1.6×10−6였다.
- 게이트는 초기화가 절반이다. GRU의 z 편향을 0에서 1로 바꾼 한 줄이 래치 과제 T=20을 0.532에서 1.000으로 바꿨다.
- 셀 상태에는 활성함수가 없어 상한도 없다. RNN의 h는 분리도가 tanh 천장인 2.0에서 멈췄지만 LSTM의 C는 3.889까지 벌렸다.
흔한 오해
- "LSTM은 RNN보다 항상 낫다" — 영화리뷰에서 0.7750 vs 0.7758로 졌다. 파라미터 4배, 시간 3.5배를 쓰고서. 12토큰짜리 문장에는 기억 장치가 필요 없다.
- "GRU는 LSTM의 열화판이다" — 같은 실험에서 GRU가 0.7864로 셋 중 1등이었다. 다만 장기 의존성에서는 초기화 문제로 가장 먼저 무너진다. 둘은 상황에 따라 갈린다.
- "정확도가 같으면 같은 모델이다" — 래치 T=40에서 RNN과 LSTM은 둘 다 1.0000이지만 기울기는 12배 차이다. 과제가 조금만 어려워지면 갈라진다.
- "장기 기억이 필요하면 f가 1로 간다" — 이 글을 쓰며 내가 틀린 부분이다. 40스텝 래치를 완벽히 푼 LSTM의 f는 평균 0.7425였다. 모델은 기억을 얼려 두는 대신 매 시점 다시 써넣으며 키웠다.
'데이터사이언스 > 자연어처리 입문' 카테고리의 다른 글
| [자연어처리 입문 13장] 어텐션: 매 단어마다 원문을 다시 본다 (0) | 2026.08.29 |
|---|---|
| [자연어처리 입문 12장] seq2seq: 번역기는 어떻게 만들어졌나 (0) | 2026.08.29 |
| [자연어처리 입문 10장] RNN: 순서를 기억하는 신경망 (0) | 2026.08.28 |
| [자연어처리 입문 9장] 언어모델의 기초: 다음 단어 맞히기 (0) | 2026.08.28 |
| [자연어처리 입문 8장] 임베딩의 성질과 한계: 유추, 반의어, 편향 (0) | 2026.08.28 |
댓글