RNN과 LSTM은 상태를 전달하면서 문장 안의 앞뒤 정보를 연결한다. Seq2Seq는 입력 문장을 상태로 요약하고 Attention은 출력 시점에 필요한 입력 위치를 참고한다.

위 음성은 Seq2Seq·Attention 개념 복습용 생성 음성이다. 본문의 작은 숫자와 실행 코드는 계산을 확인하려고 따로 만들었다.

## 1. 다음 단어를 예상하려면 앞부분이 필요하다

Language Model(언어 모델)은 앞서 나온 토큰을 보고 다음 토큰의 확률을 계산한다. `오늘 점심에`까지 읽었을 때 뒤에 나올 말을 예상하는 식이다. 문장 전체의 확률도 각 위치에서 다음 토큰이 나올 조건부 확률을 이어 곱해 표현할 수 있다.

횟수를 세는 방식에서는 `오늘 점심에`가 학습 자료에 몇 번 나왔고, 그 뒤에 어떤 단어가 붙었는지 찾는다. 참고하는 앞부분이 길어질수록 똑같은 조합을 찾기 어려워진다. 자료에 없던 조합의 확률을 어떻게 다룰지도 문제가 된다.

신경망에서는 임베딩을 이용해 비슷한 표현끼리 정보를 공유할 수 있다. 다만 고정된 개수의 앞 단어를 넣는 Feed-forward Neural Network(순방향 신경망)는 입력 창 밖의 내용을 직접 참고하기 어렵다. 앞에서 읽은 정보를 다음 계산에 계속 넘기는 구조가 RNN으로 이어진다.

## 2. RNN은 이전 상태를 다음 계산에 넘긴다

Recurrent Neural Network(순환 신경망)는 현재 입력 `x_t`와 이전 Hidden State(은닉 상태) `h_(t-1)`로 현재 상태 `h_t`를 만든다. 간단히 쓰면 아래와 같다.

```text
h_t = tanh(W_x · x_t + W_h · h_(t-1) + b)
```

`나는 → 책을 → 읽는다`를 읽는다면, 첫 계산의 상태가 두 번째 계산으로 넘어가고 그 결과가 세 번째 계산으로 넘어간다. 위치마다 같은 가중치를 사용하지만 입력과 이전 상태가 달라서 결과도 달라진다.

학습할 때는 펼쳐진 시간축을 따라 기울기를 전달한다. 이를 Backpropagation Through Time(시간에 따른 역전파)이라고 한다. 멀리 떨어진 앞부분까지 여러 연산을 거치면서 기울기가 작아지거나 커질 수 있다. 긴 문장에서 앞의 정보를 잘 유지하기 어려운 이유 중 하나다.

Gradient Clipping(기울기 크기 제한)은 너무 큰 기울기를 줄이는 데 사용한다. 기울기가 작아지는 문제까지 한꺼번에 해결해주는 장치는 아니다. 어떤 정보를 유지할지 조절하는 구조도 필요하다.

## 3. LSTM의 h와 c를 구분해서 보기

Long Short-Term Memory(장단기 메모리)는 은닉 상태 `h`와 Cell State(셀 상태) `c`를 함께 전달한다. `c`에는 유지할 정보를 담고, `h`는 현재 단계의 출력과 다음 계산에 사용한다. 둘 다 숫자 벡터이며 역할과 계산 경로가 다르다.

| 장치 | 하는 일 |
|---|---|
| Forget Gate(망각 게이트) | 이전 셀 상태를 얼마나 유지할지 정한다 |
| Input Gate(입력 게이트) | 새 후보 정보를 얼마나 반영할지 정한다 |
| Output Gate(출력 게이트) | 갱신된 셀 상태에서 은닉 상태로 내보낼 양을 정한다 |

게이트의 `sigmoid`는 0과 1 사이 값을 만든다. 이 값은 벡터의 각 성분을 얼마나 통과시킬지 정하는 비율이다. `tanh`로 만든 후보 정보와 곱하고 더해 상태를 갱신한다. LSTM이 모든 과거를 빠짐없이 기억한다는 뜻은 아니다. 필요한 정보를 오래 유지할 수 있는 학습 경로를 추가한 것이다.

Gated Recurrent Unit(게이트 순환 유닛)은 별도의 셀 상태 없이 은닉 상태를 갱신한다. Update Gate(갱신 게이트)와 Reset Gate(리셋 게이트)를 사용하며 LSTM보다 구조가 간단하다. 어느 쪽이 더 적합한지는 데이터와 모델 크기, 학습 조건을 맞춰 확인해야 한다.

## 4. PyTorch에서는 출력과 마지막 상태가 따로 나온다

배치 크기를 `B`, 문장 길이를 `S`, 임베딩 크기를 `E`, 은닉 크기를 `H`라고 두자. `batch_first=True`인 단방향 LSTM 한 층에서는 아래 형태가 나온다.

```text
입력 임베딩       (B, S, E)
모든 위치의 출력  (B, S, H)
마지막 h          (1, B, H)
마지막 c          (1, B, H)
```

`batch_first=True`는 입력과 출력의 배치 축 위치를 바꾼다. `h`와 `c`의 첫 축은 여전히 `층 수 × 방향 수`다. 배치 크기와 층 수를 둘 다 1로 놓으면 차이가 잘 안 보이므로, 아래 예제에서는 배치 크기를 2로 잡았다. [PyTorch LSTM 문서](https://docs.pytorch.org/docs/stable/generated/torch.nn.LSTM.html)

패딩이 없는 단방향 한 층에서는 `outputs[:, -1]`과 `hidden[-1]`이 같다. 길이가 다른 문장에 패딩을 붙이거나 양방향 모델을 쓰면, 무엇을 마지막 상태로 꺼내는지 다시 확인해야 한다. 특히 PAD까지 순환 계산한 마지막 상태는 실제 문장의 마지막 위치와 다를 수 있다.

## 5. Seq2Seq는 문장을 읽는 쪽과 만드는 쪽을 나눈다

Sequence-to-Sequence(시퀀스 간 변환)는 길이가 다른 입력과 출력을 연결하는 구조다. 번역이라면 Encoder(인코더)가 원문을 읽고 Decoder(디코더)가 번역문을 만든다. 요약에서도 긴 본문을 읽고 짧은 문장을 만드는 데 같은 틀을 사용할 수 있다.

기본적인 RNN Encoder–Decoder에서는 인코더의 마지막 상태를 디코더의 초기 상태로 넘긴다. LSTM끼리 연결하면 `h`와 `c`를 함께 넘기는 구성이 가능하다. 이때 양쪽 상태의 층 수와 크기가 맞아야 한다. 구조가 다르면 연결을 위한 변환이 필요하다.

디코더는 시작 토큰을 받은 뒤 첫 출력 토큰을 예측하고, 다음 토큰을 예측하는 계산을 이어간다. 인코더에서 가져온 정보가 초기 상태로 고정되어 전달되더라도 디코더의 은닉 상태는 매 단계 갱신된다.

문장이 길어질수록 원문 전체를 마지막 상태 하나에 담는 부담이 커진다. 앞부분의 세부 정보나 이름, 수량을 뒤에서 다시 찾기가 어려워질 수 있다. 이 병목을 줄이기 위해 원문의 여러 위치를 다시 참고하도록 만든 장치가 Attention(어텐션)이다.

## 6. Attention은 참고할 비중을 구하고 가중합한다

Attention을 적용하면 인코더의 모든 위치에서 나온 상태를 보관한다. 디코더는 출력 시점마다 각 위치에 점수를 매기고, Softmax(소프트맥스)로 점수를 비중으로 바꾼다. 그 비중으로 인코더 상태를 더한 것이 해당 시점의 Context Vector(문맥 벡터)다.

```text
현재 출력에 필요한 상태와 각 입력 위치를 비교
→ 위치별 점수
→ 입력 위치 방향으로 softmax
→ 비중 × 각 위치의 정보
→ 합해서 이번 출력에 쓸 context 생성
```

입력의 세 위치에 다음 벡터가 있다고 해보자.

```text
첫 위치 [2, 0]
둘째 위치 [0, 4]
셋째 위치 [6, 2]
```

비중이 `[0.2, 0.3, 0.5]`이면 문맥 벡터는 `[3.4, 2.2]`다. 첫 성분은 `0.2×2 + 0.3×0 + 0.5×6`, 둘째 성분은 `0.2×0 + 0.3×4 + 0.5×2`로 계산한다. 다음 출력에서 비중이 `[0.6, 0.3, 0.1]`로 바뀌면 `[1.8, 1.4]`가 된다.

원문의 상태들은 같아도 어느 위치를 얼마나 참고하느냐에 따라 문맥 벡터가 달라진다. LSTM의 셀 상태 `c`와 Attention의 문맥 벡터를 둘 다 `c`로 적는 자료가 있어 헷갈릴 수 있다. 계산할 때는 `cell`과 `context`로 구분해 두는 편이 읽기 쉽다.

## 7. Bahdanau와 Luong은 점수를 만드는 방법부터 구분한다

Bahdanau Attention은 디코더 상태와 인코더 상태를 각각 변환하고 더한 뒤, `tanh`와 학습 가능한 벡터를 거쳐 점수를 만든다. Additive Attention(덧셈 어텐션)이라고 부른다. 서로 다른 크기의 상태도 공통 점수 계산 공간으로 옮겨 비교할 수 있다. [Bahdanau 등의 논문](https://arxiv.org/abs/1409.0473)

```text
score = vᵀ · tanh(W_s · decoder_state + W_h · encoder_state)
```

점수 계산에 쓰려고 변환한 벡터와, 가중합으로 모으는 원래 인코더 정보를 구분해야 한다. 변환은 어느 위치를 참고할지 정하는 데 쓰이고, 그 결과의 비중으로 필요한 정보를 모은다.

Luong Attention은 `dot`, `general`, `concat` 등의 점수 함수를 비교한다. `dot`은 두 상태의 내적, `general`은 학습 가능한 행렬을 사이에 둔 내적이다. 모든 입력을 참고하는 Global Attention(전역 어텐션)과 일부 범위를 참고하는 Local Attention(지역 어텐션)은 참고 범위에 관한 구분이다. 점수 함수와 같은 항목으로 섞지 않으면 정리가 쉬워진다. [Luong 등의 논문](https://aclanthology.org/D15-1166/)

실제 구현에서는 디코더의 어느 시점 상태로 점수를 구하는지, 문맥 벡터를 GRU 입력에 넣는지 출력과 합치는지도 봐야 한다. 다음 번역기 글에서는 이전 은닉 상태로 Attention을 계산하고, 현재 GRU 출력과 문맥 벡터를 합쳐 단어 점수를 만드는 흐름을 사용한다.

## 8. 작은 숫자와 형태를 코드로 확인하기

아래 코드는 LSTM의 반환 형태와 Attention의 가중합을 따로 확인한다. 두 계산은 크기를 작게 잡은 독립 예제다. Attention 점수는 학습시키지 않고 원하는 비중의 로그값으로 정해 두었다. 실제 번역 모델에서는 점수를 만드는 가중치도 학습한다.

```python
"""LSTM의 상태 형태와 Attention 가중합을 확인하는 자체 예제."""
import torch
from torch import nn


def main():
    torch.manual_seed(42)
    ids = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.long)
    embedding = nn.Embedding(12, 4)
    lstm = nn.LSTM(4, 6, batch_first=True)
    vectors = embedding(ids)
    outputs, (hidden, cell) = lstm(vectors)
    print("embedding:", tuple(vectors.shape))
    print("outputs:", tuple(outputs.shape))
    print("hidden / cell:", tuple(hidden.shape), tuple(cell.shape))
    print("last output equals hidden:", torch.allclose(outputs[:, -1], hidden[-1]))

    # 학습된 점수 대신 원하는 비중의 log를 넣어 가중합만 확인한다.
    scores = torch.log(torch.tensor([[0.2, 0.3, 0.5], [0.6, 0.3, 0.1]]))
    values = torch.tensor([[2.0, 0.0], [0.0, 4.0], [6.0, 2.0]])
    weights = scores.softmax(dim=-1)
    contexts = weights @ values
    print("weight sums:", [round(x, 4) for x in weights.sum(-1).tolist()])
    print("contexts:", [[round(x, 4) for x in row] for row in contexts.tolist()])


if __name__ == "__main__":
    main()
```

실행 출력이다.

```text
embedding: (2, 3, 4)
outputs: (2, 3, 6)
hidden / cell: (1, 2, 6) (1, 2, 6)
last output equals hidden: True
weight sums: [1.0, 1.0]
contexts: [[3.4, 2.2], [1.8, 1.4]]
```

`softmax(dim=-1)`의 마지막 축은 여기서 세 입력 위치다. 그 축의 비중 합이 1이고, 비중과 `values`를 행렬곱한 결과가 위에서 손으로 구한 문맥 벡터와 같다.

## 9. 패딩과 학습·생성의 차이까지 같이 기억하기

문장 길이를 맞추려고 넣은 PAD는 실제 단어와 구분해야 한다. 정답의 PAD를 Loss(손실)에서 제외하는 것과, 원문의 PAD 위치를 Attention에서 가리는 것은 각각 다른 계산이다. Attention에서는 PAD 점수를 Softmax 전에 매우 작은 값으로 만들어 비중이 0이 되게 한다. 모든 위치가 PAD인 빈 문장은 입력 단계에서 걸러야 한다.

학습 중에는 정답의 이전 토큰을 다음 입력으로 넣는 Teacher Forcing(교사 강요)을 사용할 수 있다. 실제 생성에서는 모델이 직전에 예측한 토큰을 넣는다. 학습 손실이 줄어도 스스로 문장을 이어 쓰면 오류가 쌓일 수 있으므로 두 조건을 나눠서 확인해야 한다.

Attention Map(어텐션 분포 그림)은 출력 토큰이 어떤 입력 위치에 비중을 주었는지 살펴보는 데 도움이 된다. 큰 비중 하나만 보고 모델의 판단 이유나 번역의 정확성을 확정할 수는 없다. 이름과 숫자, 부정 표현이 결과에 남아 있는지도 함께 읽는다.

`이전 정보를 상태로 전달 → 입력 문장을 상태로 요약 → 출력 시점마다 필요한 입력 위치를 다시 참고`하는 순서로 계산했다. RNN과 LSTM 위에 Attention을 붙여도 순환 계산은 남아 있다.

## 실행 환경과 참고

개인 Seq2Seq·Attention 복습 노트와 LSTM 상태 정리를 바탕으로 다시 썼다. 코드와 숫자는 이 글을 위해 만든 예제다.

- 실행 환경: Python 3.12.9, NumPy 2.5.2, PyTorch 2.13.0 (CPU), scikit-learn 1.9.0.
- 실행 명령: `python seq2seq_example.py`.
- 확인 범위: LSTM 출력·상태 형태, 마지막 상태 일치, 비중의 합과 두 문맥 벡터 계산.
- 번역 모델 전체 학습과 번역 품질 평가는 이번 코드에 포함하지 않았다.
