# Part 7.1: 순환 신경망 (RNN)과 LSTM

**⬅️ 이전 시간: [Part 7: 딥러닝 기초와 PyTorch](./part_7_deep_learning.md)**
**➡️ 다음 시간: [Part 7.2: Transformer와 LLM의 핵심 원리](./part_7.2_transformer_and_llm_principles.md)**

---

## 1. 학습 목표 (Learning Objectives)

이번 파트가 끝나면, 여러분은 다음을 할 수 있게 됩니다.

- 순차 데이터(Sequential Data)의 특징을 이해하고 RNN이 왜 필요한지 설명할 수 있습니다.
- RNN의 기본 구조와 순환하는 아이디어, 그리고 은닉 상태(Hidden State)의 역할을 이해합니다.
- 장기 의존성 문제(Vanishing/Exploding Gradient)가 무엇인지 설명할 수 있습니다.
- LSTM의 게이트(Gate) 구조가 어떻게 장기 의존성 문제를 해결하는지 기본 원리를 이해합니다.
- PyTorch를 사용하여 간단한 RNN/LSTM 모델을 구축하고 텍스트 생성 문제를 해결할 수 있습니다.

## 2. 핵심 요약 (Key takeaways)

순서가 중요한 순차 데이터를 처리하기 위해 RNN(순환 신경망)이 등장했습니다. RNN은 출력이 다시 입력으로 들어가는 순환 구조와 과거 정보를 기억하는 은닉 상태를 특징으로 하지만, 정보가 길어지면 과거를 잊는 장기 의존성 문제가 발생합니다. 이 문제를 해결하기 위해 등장한 LSTM은 셀 상태와 3개의 게이트(망각, 입력, 출력)를 통해 정보의 흐름을 정교하게 제어하여 효과적인 장기 기억을 가능하게 합니다. 이를 바탕으로 텍스트 생성, 시계열 예측 등 다양한 문제를 해결할 수 있습니다.

## 3. 도입: 과거를 기억하는 모델, RNN

지금까지 우리가 다룬 MLP나 CNN은 입력 데이터 간의 순서나 시간적인 관계를 고려하지 않았습니다. 사진 속 고양이의 픽셀 위치는 중요하지만, '고양이'라는 단어를 이루는 '고', '양', '이' 글자들은 **순서**가 없다면 의미가 없습니다.

이렇게 **순서가 중요한 데이터**를 **순차 데이터(Sequential Data)**라고 합니다.
- **텍스트**: 단어의 순서가 문장의 의미를 결정합니다. ("나는 너를 좋아해" vs "너는 나를 좋아해")
- **시계열 데이터**: 주가, 날씨 등 시간의 흐름에 따라 기록된 데이터입니다. 어제의 주가가 오늘의 주가에 영향을 미칩니다.
- **음성**: 소리의 파형은 시간 순서대로 이어져 의미를 가집니다.

**순환 신경망(Recurrent Neural Network, RNN)**은 바로 이 순차 데이터를 처리하기 위해 탄생한 특별한 아키텍처입니다. RNN은 모델 내부에 '기억'을 할 수 있는 **순환(Recurrent)** 구조를 가지고 있어, 이전 시간 단계(time step)의 정보를 현재 계산에 활용할 수 있습니다.

---

## 4. RNN의 작동 원리

### 4-1. 순환과 은닉 상태 (Recurrence and Hidden State)

RNN의 핵심 아이디어는 간단합니다. 네트워크의 출력이 다시 자기 자신에게 입력으로 들어가는 것입니다.

![RNN Unrolled](https://i.imgur.com/4f2p7gH.png)
*(이미지 출처: Christopher Olah's Blog)*

왼쪽의 압축된 형태를 보면, RNN 계층은 입력(`x_t`)을 받아 출력(`h_t`)을 내보내고, 이 출력(`h_t`)이 다시 다음 계산을 위해 자기 자신에게 입력됩니다. 이 과정을 오른쪽처럼 시간의 흐름에 따라 펼쳐보면(Unrolled), 각 시간 단계(time step)별로 동일한 가중치를 공유하는 네트워크가 연결된 모습과 같습니다.

여기서 가장 중요한 개념은 **은닉 상태(Hidden State)**, 즉 `h_t` 입니다.

- **은닉 상태 (h_t)**: 해당 시간 단계 `t`까지의 '기억' 또는 '문맥'을 요약한 벡터입니다. 이 벡터는 이전 시간 단계의 은닉 상태(`h_t-1`)와 현재 시간 단계의 입력(`x_t`)을 함께 사용하여 계산됩니다.
- **수식**: `h_t = tanh(W_xh * x_t + W_hh * h_t-1 + b)`
    - `W_xh`: 입력 `x_t`에 대한 가중치
    - `W_hh`: 이전 은닉 상태 `h_t-1`에 대한 가중치
    - `b`: 편향(bias)
    - `tanh`: 활성화 함수 (주로 Tanh가 사용됨)

이 과정을 통해 RNN은 "나는", "너를" 이라는 단어를 순서대로 읽으며, "좋아해"라는 단어가 나올 차례라는 것을 예측할 수 있게 됩니다.

### 4-2. 시간 역전파 (BPTT)
RNN의 학습은 시간의 흐름에 따라 펼쳐진 네트워크를 따라 오차를 거꾸로 전파하는 **BPTT(Backpropagation Through Time)** 방식을 사용합니다. 이는 일반적인 역전파와 원리는 같지만, 모든 시간 단계에 걸쳐 역전파가 일어난다는 점이 다릅니다.

---

## 5. 장기 의존성 문제 (The Long-Term Dependency Problem)

RNN의 아이디어는 훌륭하지만, 치명적인 약점이 있습니다. 문장이 길어지면 **아주 먼 과거의 정보가 현재까지 전달되지 못하는 현상**, 즉 **장기 의존성 문제**가 발생합니다.

> "오늘 아침 [프랑스]에서 유창하게 [프랑스어]를 구사하는 사람들을 만났다."

위 문장에서 마지막 단어 '프랑스어'를 예측하려면, 문장 맨 앞의 '프랑스'라는 단어를 기억해야 합니다. 하지만 이 두 단어 사이의 거리가 너무 멀면, BPTT 과정에서 그래디언트(기울기)가 전달되다가 점점 희미해지거나( **기울기 소실, Vanishing Gradient** ), 반대로 너무 커져서 발산하는( **기울기 폭발, Exploding Gradient** ) 문제가 발생합니다.

- **기울기 소실**: 활성화 함수(주로 `tanh`)의 미분값은 최대 1입니다. 역전파 과정에서 1보다 작은 값이 계속 곱해지면, 그래디언트는 0에 수렴하게 됩니다. 결국, 먼 과거의 정보는 가중치 업데이트에 거의 영향을 주지 못합니다. 이것이 RNN이 긴 문장을 잘 기억하지 못하는 주된 이유입니다.

## 6. LSTM: 장기 기억을 위한 해법

**LSTM(Long Short-Term Memory)** 은 이 장기 의존성 문제를 해결하기 위해 고안된 RNN의 개선된 버전입니다. LSTM은 '단기 기억'과 '장기 기억'을 모두 효과적으로 다룰 수 있습니다.

LSTM의 핵심은 **셀 상태(Cell State)** 와 **게이트(Gate)** 라는 정교한 메커니즘입니다.

![LSTM Structure](https://i.imgur.com/0uTjG3E.png)
*(이미지 출처: Christopher Olah's Blog)*

- **셀 상태 (Cell State)**: 컨베이어 벨트처럼 네트워크 전체를 가로지르는 정보의 흐름입니다. LSTM은 이 셀 상태에 정보를 추가하거나 제거하는 능력을 가지고 있습니다.
- **게이트 (Gates)**: 정보가 셀 상태를 통과하도록 제어하는 장치입니다. 게이트는 시그모이드(Sigmoid) 함수를 통해 0(정보를 차단)과 1(정보를 통과) 사이의 값을 출력하여 정보의 양을 조절합니다.

LSTM에는 3가지 주요 게이트가 있습니다.

1.  **Forget Gate (망각 게이트)**: 과거의 정보 중 **무엇을 잊어버릴지** 결정합니다. "새로운 문장이 시작되었으니, 이전 문장의 주어는 잊어버리자."
2.  **Input Gate (입력 게이트)**: 새로운 정보 중 **무엇을 저장할지** 결정합니다. "새로운 주어 '그녀'가 등장했으니, 이 정보를 셀 상태에 추가하자."
3.  **Output Gate (출력 게이트)**: 셀 상태의 정보 중 **무엇을 출력으로 내보낼지** 결정합니다. "현재 시점에서는 주어 정보가 필요하니, '그녀'에 대한 정보를 출력하자."

이러한 게이트 구조 덕분에 LSTM은 중요한 정보는 오래 기억하고, 불필요한 정보는 잊어버릴 수 있어 RNN의 장기 의존성 문제를 효과적으로 해결합니다.

---

## 7. 연습 문제: "hello" 예측하기

간단한 RNN 모델을 만들어, "hell" 이라는 입력을 받았을 때 마지막 글자 "o"를 예측하도록 학습시켜 보겠습니다.

```python
import torch
import torch.nn as nn
import numpy as np

# 1. 데이터 준비
# 입력 데이터: "hell" -> "o" 를 예측하는 것이 목표
# 어휘집(vocabulary) 생성
input_str = 'hello'
vocab = sorted(list(set(input_str)))
char_to_idx = {c: i for i, c in enumerate(vocab)}
idx_to_char = {i: c for i, c in enumerate(vocab)}

# 데이터셋 생성 (hell -> o)
input_data = [char_to_idx[c] for c in 'hell']
target_data = [char_to_idx['o']]

# 원-핫 인코딩
vocab_size = len(vocab)
x_one_hot = [np.eye(vocab_size)[x] for x in input_data]
y_one_hot = [np.eye(vocab_size)[y] for y in target_data]

# PyTorch 텐서로 변환 (sequence_length, batch_size, input_size)
inputs = torch.Tensor(x_one_hot).unsqueeze(1)
targets = torch.Tensor(y_one_hot).squeeze(0).argmax(dim=1)


# 2. RNN 모델 정의
class SimpleRNN(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(SimpleRNN, self).__init__()
        self.hidden_size = hidden_size
        self.rnn = nn.RNN(input_size, hidden_size, batch_first=False) # batch_first=False 가 기본값
        self.fc = nn.Linear(hidden_size, output_size)

    def forward(self, x, hidden):
        # x: (sequence_length, batch_size, input_size)
        # hidden: (num_layers, batch_size, hidden_size)
        out, hidden = self.rnn(x, hidden)
        
        # 마지막 시퀀스의 출력만 사용
        # out: (sequence_length, batch_size, hidden_size)
        out = self.fc(out[-1, :, :]) # (batch_size, hidden_size) -> (batch_size, output_size)
        return out, hidden

    def init_hidden(self, batch_size=1):
        return torch.zeros(1, batch_size, self.hidden_size)

# 3. 모델, 손실함수, 옵티마이저 설정
input_size = vocab_size
hidden_size = 5
output_size = vocab_size
learning_rate = 0.1

model = SimpleRNN(input_size, hidden_size, output_size)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

# 4. 학습 루프
for epoch in range(100):
    hidden = model.init_hidden(batch_size=1)
    
    optimizer.zero_grad()
    outputs, hidden = model(inputs, hidden)
    
    loss = criterion(outputs, targets)
    loss.backward()
    optimizer.step()

    if (epoch + 1) % 10 == 0:
        _, predicted_idx = outputs.max(1)
        predicted_char = idx_to_char[predicted_idx.item()]
        print(f'Epoch [{epoch+1}/100], Loss: {loss.item():.4f}, Predicted: "{predicted_char}" -> Target: "o"')

# 5. 최종 예측
hidden = model.init_hidden(batch_size=1)
outputs, hidden = model(inputs, hidden)
_, predicted_idx = outputs.max(1)
predicted_char = idx_to_char[predicted_idx.item()]

print(f'\nFinal prediction after training: "hell" -> "{predicted_char}"')
```

> [!TIP]
> 위 코드를 직접 실행해보면서 `hidden_size`, `learning_rate`, `epoch` 등의 하이퍼파라미터를 바꿔보세요. `nn.RNN`을 `nn.LSTM`으로 바꾸고 실행해보는 것도 좋습니다. (LSTM은 `forward`에서 `hidden`이 `(h_0, c_0)` 튜플 형태여야 합니다)

---

## 8. 심화 토론 (What Could Go Wrong?)

RNN과 LSTM은 순차 데이터 처리의 혁신이었지만, 실제 적용 과정에서는 여러 가지 한계와 어려움에 부딪힙니다. 다음 시나리오들에 대해 함께 토론해보세요.

1.  **훈련 중 손실(Loss)이 `NaN`으로 폭발하는 현상**
    -   **상황**: 시계열 예측을 위한 RNN 모델을 훈련하던 중, 손실(loss) 값이 갑자기 `NaN` (Not a Number)으로 바뀌면서 훈련이 멈춰버렸습니다.
    -   **토론**:
        -   이 현상의 가장 유력한 원인은 무엇일까요? 본문에서 배운 '기울기 소실' 문제와 '기울기 폭발' 문제 중 어느 쪽에 해당하며, 그 이유는 무엇일까요?
        -   이 문제를 해결하기 위한 가장 대표적인 기법인 **그래디언트 클리핑(Gradient Clipping)**이란 무엇이며, 어떤 원리로 동작하는지 설명해보세요. (PyTorch의 `torch.nn.utils.clip_grad_norm_` 함수를 찾아보는 것을 추천합니다.)

2.  **순차 처리의 본질적인 병목 현상**
    -   **상황**: 아주 긴 문서(예: 책 한 권)를 LSTM으로 번역하는 모델을 학습시키려 합니다. 강력한 GPU를 사용함에도 불구하고, 이전 챕터에서 배운 CNN 모델에 비해 학습 속도가 매우 느리고, GPU 메모리 문제로 배치 사이즈(batch size)를 키우기도 어렵습니다.
    -   **토론**:
        -   RNN/LSTM 계열의 모델들이 CNN에 비해 병렬화가 어려운 근본적인 아키텍처상의 이유는 무엇일까요? `h_t`가 `h_t-1`에 의존해야만 하는 순차적 계산 방식이 왜 성능의 병목이 될까요?
        -   이러한 한계점이 이후에 배울 Transformer와 같은 새로운 아키텍처의 등장을 어떻게 촉진시켰을지 토론해보세요.

3.  **"잊는 것"은 기능이지만, 완벽하지는 않다**
    -   **상황**: LSTM 기반의 챗봇을 만들었습니다. 대화 초반에 사용자가 자신의 이름을 알려주면, 챗봇은 대화 내내 사용자의 이름을 잘 기억하고 사용합니다. 하지만 대화가 길어진 후 "내가 처음에 했던 질문이 뭐였지?"라고 물어보면, 챗봇은 그 내용을 기억하지 못합니다.
    -   **토론**:
        -   LSTM은 장기 의존성 문제를 해결하기 위해 고안되었지만, 여전히 위와 같은 한계를 보입니다. 일반적인 문맥(사용자 이름)은 잘 유지하면서도, 구체적이고 덜 중요한 과거의 정보를 놓치는 이유는 무엇일까요?
        -   LSTM의 게이트들이 0 또는 1의 이산적인 값이 아닌 0과 1 사이의 연속적인 값을 갖는다는 점이, 어떻게 정보의 '완벽한 보존'이 아닌 점진적인 '희미해짐'에 기여할 수 있을지 토론해보세요.

4.  **양방향 RNN: 미래를 보는 것이 항상 정답일까?**
    -   **상황**: 영화 리뷰의 긍정/부정을 판단하는 감성 분석 모델을 만들 때, 단방향(unidirectional) LSTM보다 양방향(Bidirectional) LSTM이 훨씬 좋은 성능을 보였습니다. 한 동료가 "이 모델은 문장의 미래를 보고 예측하는 것이니, 반칙이 아닌가?"라고 질문했습니다.
    -   **토론**:
        -   양방향 LSTM의 개념을 비전공자에게 어떻게 설명할 수 있을까요? 왜 감성 분석이나 기계 번역 같은 Task에서는 문장의 전체 문맥(과거+미래)을 보는 것이 유리할까요?
        -   반대로, 실시간 주가 예측이나 일기예보와 같은 Task에 양방향 모델을 사용하는 것이 왜 부적절하거나 불가능한지 토론해보세요.

## 9. 더 깊이 알아보기 (Further Reading)
- [The Unreasonable Effectiveness of Recurrent Neural Networks](http://karpathy.github.io/2015/05/21/rnn-effectiveness/): Andrej Karpathy의 전설적인 블로그 포스트. RNN의 매력적인 가능성을 보여줍니다.
- [Understanding LSTM Networks](http://colah.github.io/posts/2015-08-Understanding-LSTMs/): Christopher Olah의 블로그 포스트. LSTM의 구조를 그림으로 매우 알기 쉽게 설명한 최고의 자료입니다.

---

## 10. 심화 프로젝트: 주가 예측 (LSTM)

RNN과 LSTM의 가장 대표적인 활용 사례 중 하나는 주가, 날씨, 판매량과 같은 시계열(Time-series) 데이터를 예측하는 것입니다. 이번 미니 프로젝트에서는 LSTM 모델을 사용하여, 과거의 주가 데이터 패턴을 학습하고 미래의 주가를 예측하는 간단한 주가 예측기를 만들어 봅니다.

이 프로젝트의 핵심은 **연속된 시계열 데이터를 딥러닝 모델의 입력 형식(sequence, target)으로 가공**하는 방법을 배우고, 회귀(Regression) 문제에 맞는 손실 함수를 사용하여 모델을 훈련시키는 것입니다.

### 프로젝트 목표

1.  연속된 시계열 데이터를 정규화(Normalization)하고, 지도 학습을 위한 입력 시퀀스와 타겟 값으로 분리합니다.
2.  PyTorch `nn.LSTM` 레이어를 사용하여 시계열 예측 모델을 설계합니다.
3.  회귀 문제에 적합한 손실 함수(`nn.MSELoss`)를 사용하여 모델을 훈련합니다.
4.  예측된 값을 실제 값과 함께 시각화하여 모델의 성능을 직관적으로 평가합니다.

**최종 분석 리포트 (예시):**
```
Epoch [20/200], Loss: 0.0451
Epoch [40/200], Loss: 0.0218
...
Epoch [200/200], Loss: 0.0015
```
(여기에 실제 주가와 예측 주가를 비교하는 Matplotlib 그래프가 추가됩니다)

![Stock Prediction Chart](https://i.imgur.com/r8O0e2b.png)

### 단계별 구현 가이드

**1. 데이터 준비**
- `numpy`를 사용하여 가상의 일일 주가 데이터를 생성합니다.
- `MinMaxScaler`를 사용하여 데이터 값을 0과 1 사이로 정규화합니다. 이는 LSTM의 안정적인 학습을 위해 매우 중요합니다.
- 정규화된 데이터를 `(입력 시퀀스, 타겟 값)` 쌍으로 만듭니다. 예를 들어, `sequence_length=5` 라면, `[0일차~4일차]` 데이터로 `5일차` 데이터를 예측하는 쌍을 만듭니다.
- 데이터를 훈련용과 테스트용으로 분리하고, PyTorch `Tensor`로 변환합니다.

**2. LSTM 모델 정의**
- `nn.Module`을 상속받는 `StockLSTM` 클래스를 만드세요.
- `__init__`: `nn.LSTM` 레이어와, LSTM의 출력을 받아 최종 예측값(1개)을 만드는 `nn.Linear` 레이어를 정의합니다.
- `forward`: 데이터가 LSTM과 Linear 레이어를 순서대로 통과하는 흐름을 정의합니다.

**3. 학습 및 평가**
- 모델, 손실 함수(`nn.MSELoss`), 옵티마이저를 초기화합니다.
- 학습 루프를 통해 모델을 훈련시킵니다.
- **(중요)** 평가 시에는 모델이 예측한 값과 실제 정답 값을 `scaler.inverse_transform`을 사용하여 원래의 주가 스케일로 되돌려 비교하고 시각화합니다.

**전체 코드 구조:**
```python
import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler

# 1. 데이터 준비
# 가상의 일일 주가 데이터 (20일치)
data = np.array([
    100, 102, 105, 103, 108, 110, 112, 115, 118, 120, 
    122, 125, 123, 128, 130, 132, 135, 133, 138, 140
], dtype=np.float32).reshape(-1, 1)

# 데이터 정규화
scaler = MinMaxScaler(feature_range=(0, 1))
data_scaled = scaler.fit_transform(data)

# 입력 시퀀스, 타겟 쌍 생성
def create_sequences(data, seq_length):
    xs, ys = [], []
    for i in range(len(data) - seq_length):
        x = data[i:i+seq_length]
        y = data[i+seq_length]
        xs.append(x)
        ys.append(y)
    return np.array(xs), np.array(ys)

SEQ_LENGTH = 5
X, y = create_sequences(data_scaled, SEQ_LENGTH)

# 훈련/테스트 데이터 분리 (뒤의 20%를 테스트용으로 사용)
train_size = int(len(X) * 0.8)
X_train, X_test = X[:train_size], X[train_size:]
y_train, y_test = y[:train_size], y[train_size:]

# PyTorch 텐서로 변환
X_train_tensor = torch.from_numpy(X_train).float()
y_train_tensor = torch.from_numpy(y_train).float()
X_test_tensor = torch.from_numpy(X_test).float()
y_test_tensor = torch.from_numpy(y_test).float()

# 2. LSTM 모델 정의
class StockLSTM(nn.Module):
    def __init__(self, input_size=1, hidden_size=50, output_size=1):
        super(StockLSTM, self).__init__()
        self.hidden_size = hidden_size
        self.lstm = nn.LSTM(input_size, hidden_size, batch_first=True)
        self.fc = nn.Linear(hidden_size, output_size)

    def forward(self, x):
        h0 = torch.zeros(1, x.size(0), self.hidden_size).to(x.device)
        c0 = torch.zeros(1, x.size(0), self.hidden_size).to(x.device)
        out, _ = self.lstm(x, (h0, c0))
        out = self.fc(out[:, -1, :]) # 마지막 타임스텝의 출력만 사용
        return out

# 3. 학습
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = StockLSTM().to(device)
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

num_epochs = 200
for epoch in range(num_epochs):
    model.train()
    # 전체 데이터를 한번에 학습 (배치로더는 생략)
    outputs = model(X_train_tensor.to(device))
    optimizer.zero_grad()
    loss = criterion(outputs, y_train_tensor.to(device))
    loss.backward()
    optimizer.step()
    if (epoch+1) % 20 == 0:
        print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')

# 4. 평가 및 시각화
model.eval()
with torch.no_grad():
    test_predict = model(X_test_tensor.to(device))

# 원래 스케일로 되돌리기
test_predict_inv = scaler.inverse_transform(test_predict.cpu().numpy())
y_test_inv = scaler.inverse_transform(y_test)

# 시각화
plt.figure(figsize=(10, 6))
plt.plot(y_test_inv, label='Actual Price')
plt.plot(test_predict_inv, label='Predicted Price')
plt.title('Stock Price Prediction')
plt.xlabel('Time (days)')
plt.ylabel('Price')
plt.legend()
plt.grid(True)
plt.show()
```
이 프로젝트는 시계열 데이터 전처리의 핵심적인 과정을 이해하고, 회귀 문제에 딥러닝을 적용하는 실질적인 경험을 제공할 것입니다.

---

**➡️ 다음 시간: [Part 7.2: Transformer와 LLM의 핵심 원리](./part_7.2_transformer_and_llm_principles.md)** 