seq2seq의 기본 구조와 작동 원리
Sequence-to-Sequence(seq2seq) 모델은 한 시퀀스를 다른 시퀀스로 변환하는 신경망 모델입니다.
예를 들어, "안녕하세요"라는 한국어 문장을 "Hello"라는 영어 문장으로 번역하는 것처럼, 입력 시퀀스를 받아서 다른 형태의 출력 시퀀스를 생성합니다.
인코더(Encoder)-디코더(Decoder) 아키텍처
seq2seq 모델은 크게 두 부분으로 구성됩니다.
인코더 (Encoder)
인코더는 입력 시퀀스를 처리하여 컨텍스트 벡터(Context Vector)를
* 시퀀스: 시간적 순서 또는 일정한 순서에 따라 배열된 데이터의 집합을 의미함. 이는 데이터가 순차적으로 의존관계를 가지며, 앞뒤의 데이터가 서로 연결되어 있는 경우를 말함. 예를 들어, 문장, 음성, 음악, 비디오 등이 모두 시퀀스 데이터의 예임.
인코더는 다음과 같이 동작합니다.
1️⃣ 시퀀스 데이터의 처리
문장 "I love cats"를 예로 들면
1. 첫 번째 입력: "I"
- 입력
단어 “l”의 임베딩 벡터 .
초기 hidden state : 보통 [0, 0, 0] 또는 무작위 값으로 초기화.
- 결과
hidden state1: [0.2, -0.1, 0.1]
2. 두 번째 입력: “love”
- 입력
단어 “love”의 임베딩 벡터.
이전 hidden state : [0.2, -0.1, 0.1].
- 결과
hidden state2: [0.5, -0.2, 0.3].
3. 세 번째 입력: “cat”
- 입력
단어 “cat”의 임베딩 벡터.
이전 hidden state : [0.5, -0.2, 0.3].
- 결과
hidden state3: [0.7, -0.3, 0.4].
이와 같이, 입력 단어들은 순차적으로 RNN을 통과하면서 hidden state를 업데이트하며, 입력 시퀀스의 마지막 hidden state가 컨텍스트 벡터로 사용됩니다.
2️⃣ 컨텍스트 벡터 생성
컨텍스트 벡터(context vector) 생성
- 마지막 hidden state : [0.7, -0.3, 0.4].
- 이 벡터는 입력 시퀀스의 정보를 압축한 컨텍스트 벡터로 사용됩니다.
- 디코더는 이 컨텍스트 벡터를 바탕으로 출력 시퀀스를 생성합니다.
* 컨텍스트 벡터(마지막 hidden state)는 입력 시퀀스 전체를 요약한 벡터입니다. 이 벡터는 입력 시퀀스에 대한 모든 중요한 정보를 담고 있으며, 디코더가 이를 기반으로 출력 시퀀스를 생성합니다.
컨텍스트벡터는 입력 문장의 길이와 관계없이 항상 같은 크기의 벡터로 변환합니다.
예를 들어
- "I love cats" (3 단어) 후→ [0.8, -0.5, 0.3] (3차원 벡터)
- "I really love cute cats" (5 단어) → [0.7, -0.6, 0.4] (같은 3차원 벡터)
이렇게 다른 길이의 문장도 같은 크기의 벡터로 표현됩니다.
이런 과정을 통해 인코더는 가변 길이의 입력 문장을 고정된 크기의 의미 있는 벡터로 변환할 수 있습니다.
디코더 (Decoder)
디코더는 인코더에서 받은 컨텍스트 벡터를 바탕으로 출력 시퀀스를 생성합니다. 디코더도 RNN 기반 구조를 가지며, 다음과 같이 동작합니다.
1️⃣ 컨텍스트 벡터로 초기화
인코더에서 생성된 컨텍스트 벡터를 첫 hidden state로 사용하여 출력을 생성합니다.
- 초기 hidden state: 컨텍스트 벡터 [0.7, -0.3, 0.4]
- 초기 입력: 시작 토큰 <sos>
2️⃣ 출력 시퀀스 생성
입력 토큰(<sos>)과 hidden state를 사용하여 다음 단어를 예측합니다.
예를 들어, “I love cats”라는 문장의 출력을 생성하는 과정을 단계적으로 살펴보겠습니다.
1. 첫 번째 예측
- 입력: <sos> 토큰 + 초기 hidden state [0.7, -0.3, 0.4]
- 출력: "I" + 업데이트된 hidden state, cell state
2. 두 번째 예측
- 입력: "I" + 첫 번째 예측에서 계산된 hidden state, cell state
- 출력: "love" + 업데이트된 hidden state, cell state
3. 세 번째 예측
- 입력: "love" + 두 번째 예측에서 계산된 hidden state, cell state
- 출력: "cats"
4. 종료
- 입력: “cats” (세 번째 출력 단어) + 세 번째 예측에서 계산된 hidden state, cell state
- 출력 단어: <eos> (종료 토큰)
디코더는 종료 토큰을 예측한 후 작업을 멈춥니다.
디코더 과정에서 Teacher Forcing 기법이 사용될 수 있습니다. (학습 시 정답 토큰을 입력으로 사용하는 방법).
Teacher Forcing 기법
학습 과정에서만 사용되는 특별한 방법입니다.
일반적인 방식과 Teacher Forcing의 차이
Teacher Forcing 사용 시
입력: <START> → 출력: "나는" (정답 사용)
입력: "나는" → 출력: "고양이를" (정답 사용)
입력: "고양이를" → 출력: "좋아해" (정답 사용)
Teacher Forcing 미사용 시
입력: <START> → 출력: "나는" (예측값 사용)
입력: "나는" → 출력: "강아지를" (잘못된 예측)
입력: "고양이를" → 출력: "좋아해" (오류 전파)
학습 시: 모델이"강아지를"이라고 잘못 예측했더라도,
다음 입력으로 정답인 "고양이를"을 사용하여 학습하는 방법입니다.
이렇게 하면 한 번의 오류가 후속 예측에 영향을 주지 않아 학습이 더 안정적입니다.
하지만 추론(Inference)할 때는 정답을 알 수 없으므로, 모델이 생성한 출력을 그대로 다음 입력으로 사용합니다.
🔵 hidden state는 어떻게 업데이트되나요?
RNN, LSTM, GRU와 같은 순환 구조를 사용하며, 이들은 다음과 같은 방식으로 hidden state를 업데이트합니다.
1. 입력: 현재 단어("나는")와 이전 hidden state
2. 계산: RNN/LSTM/GRU가 입력 데이터를 처리하여 새로운 hidden state를 계산
3. 출력: 다음 단어를 예측하고, 새로운 hidden state를 반환
🔵 왜 "순환 신경망을 사용" 할까?
RNN은 이전 시점의 정보를 현재 시점의 처리에 활용할 수 있습니다. 예를 들어 "cats"를 처리할 때, 앞에 나온 "I"와 "love"의 정보도 함께 고려됩니다. 이를 통해 문장의 순서와 문맥 정보를 보존할 수 있습니다.
🔵 단순 벡터 변환 vs. 컨텍스트 벡터
단순히 벡터로 변환하는 임베딩 벡터와 컨텍스트 벡터는 다른 용어인데요. 어떤 차이가 있는지 보시죠.
단순 벡터 변환
- 입력 데이터(예: 단어)를 임베딩(embedding)하여 고정된 차원의 벡터로 표현합니다.
- 예: "I" → [0.2, 0.8], "love" → [0.5, 0.1], "cats" → [0.3, 0.6].
Seq2Seq의 컨텍스트 벡터
- 단순히 단어를 벡터로 변환하는 것을 넘어, 입력 시퀀스의 순서와 의미를 학습한 결과입니다.
- Encoder는 RNN, LSTM, GRU 같은 순환 구조를 통해 시퀀스를 순차적으로 처리하며, 이전 단어들과의 관계를 반영한 hidden state를 생성합니다.
- 마지막 hidden state(컨텍스트 벡터)는 단어 간의 연관성과 문맥적 의미를 포함합니다.
- 예: "I love cats" → [0.7, -0.4, 0.3]
(이 값은 "I", "love", "cats"의 관계와 순서를 반영한 결과)
seq2seq 모델의 주요 특징
1️⃣ 가변 길이의 입력을 받아서 가변 길이의 출력을 생성할 수 있습니다.
입력 문장의 길이가 고정되어 있지 않아도 된다는 의미인데요.
예를 들어, 번역 작업에서
* "I am happy" (3 단어)
* "I am very very happy today" (6 단어)
이렇게 길이가 다른 두 문장 모두를 입력으로 처리할 수 있습니다.
전통적인 신경망은 보통 고정된 크기의 입력만 받을 수 있었지만, seq2seq는 이런 제약이 없습니다.
2️⃣ 문맥을 고려한 시퀀스 생성이 가능
이는 출력을 생성할 때 전체 입력 문장의 의미를 고려한다는 의미입니다.
예를 들어, "bank"라는 단어를 번역할 때
* "I went to the bank to withdraw money" → "은행"으로 번역
* "I sat by the river bank" → "강둑"으로 번역
이처럼 같은 단어라도 문장 전체의 문맥을 보고 적절한 의미를 선택할 수 있습니다. 이는 인코더가 입력 문장 전체의 정보를 압축해서 저장하고, 디코더가 이 정보를 바탕으로 출력을 생성하기 때문에 가능합니다.
예시를 하나 더 들어보겠습니다
문장: "The movie was not bad at all"
이 문장을 한국어로 번역할 때, seq2seq는 "not bad"라는 표현이 실제로는 긍정적인 의미라는 것을 문맥상에서 파악하여 "영화가 꽤 좋았다"와 같이 적절하게 번역할 수 있습니다. 단순히 단어 단위로 번역하면 "영화는 나쁘지 않았다"가 되어 뉘앙스가 달라질 수 있습니다.
3️⃣ 입력과 출력의 길이가 서로 다를 수 있음
🔴 Seq2Seq 모델의 한계점
1. 긴 시퀀스 처리의 한계
- 입력 시퀀스가 길어질수록 정보 손실이 발생
- 초기에 입력된 정보가 마지막 컨텍스트 벡터에 충분히 반영되지 못하는 문제 (장기 의존성 문제)
- 예시: "Last month, I went to Paris..."로 시작하는 긴 문장에서 마지막 부분을 생성할 때 "Last month"라는 시간 정보가 잘 반영되지 않을 수 있음
2. 고정 크기 컨텍스트 벡터의 병목 현상
- 입력 문장의 길이와 관계없이 항상 같은 크기의 컨텍스트 벡터를 사용
- 긴 문장의 경우 모든 정보를 제한된 크기의 벡터에 압축하다 보니 정보 손실이 불가피
- 이는 나중에 어텐션(Attention) 메커니즘의 도입 계기가 됨
3. 출력 생성의 일관성 문제
- 한 번 잘못된 예측을 하면 그 오류가 이후 생성에 계속 영향을 미침
- 예시: "I am" → "나는" → "사과를" → "먹었다"
만약 "나는" 대신 "그는"이라고 잘못 예측하면, 이후 문장의 일관성이 무너질 수 있음
4. 연산 비용과 속도
- 시퀀스를 순차적으로 처리해야 하므로 병렬 처리가 어려움
- 특히 긴 시퀀스의 경우 처리 시간이 크게 증가
- GPU를 활용한 병렬 처리의 이점을 충분히 활용하기 어려움
5. 단방향 문맥 이해의 한계
- 기본적인 seq2seq는 입력을 순차적으로만 처리
- 문장의 전체적인 문맥을 양방향으로 이해하는 데 한계가 있음
- 이는 나중에 양방향 RNN (Bidirectional RNN)의 도입 계기가 됨
🔵 이러한 한계점들을 극복하기 위한 후속 발전
- 어텐션 메커니즘 도입
- Transformer 아키텍처 개발
- 양방향 인코더 사용
- 빔 서치(Beam Search) 같은 디코딩 전략 도입
이러한 한계점들은 seq2seq 모델이 발전하는 과정에서 새로운 아키텍처와 기법들이 등장하는 계기가 되었으며, 현대의 많은 자연어 처리 모델들은 이러한 문제들을 해결하기 위한 다양한 개선 방법들을 포함하고 있습니다.
class Encoder(nn.Module):
def __init__(self, src_vocab_size, embedding_dim, hidden_units):
super(Encoder, self).__init__()
self.embedding = nn.Embedding(src_vocab_size, embedding_dim, padding_idx=0)
self.lstm = nn.LSTM(embedding_dim, hidden_units, batch_first=True)
def forward(self, x): # x.shape == (batch_size, seq_len)
# 1.단어 임베딩
x = self.embedding(x) # 임베딩층 통과 후 x.shape = (batch_size, seq_len, embedding_dim)
# 2.LSTM 처리
_, (hidden, cell) = self.lstm(x) # hidden.shape == (1, batch_size, hidden_units), cell.shape == (1, batch_size, hidden_units)
return hidden, cell # 인코더의 출력은 hidden state, cell state
class Decoder(nn.Module):
def __init__(self, tar_vocab_size, embedding_dim, hidden_units):
super(Decoder, self).__init__()
self.embedding = nn.Embedding(tar_vocab_size, embedding_dim, padding_idx=0)
self.lstm = nn.LSTM(embedding_dim, hidden_units, batch_first=True)
self.fc = nn.Linear(hidden_units, tar_vocab_size)
def forward(self, x, hidden, cell): # x.shape == (batch_size, seq_len)
# 1. 단어 임베딩
x = self.embedding(x) # x.shape == (batch_size, seq_len, embedding_dim)
# 2.LSTM 처리
# 디코더의 LSTM으로 인코더의 hidden state, cell state를 전달.
# output.shape == (batch_size, seq_len, hidden_units)
# hidden.shape == (1, batch_size, hidden_units)
# cell.shape == (1, batch_size, hidden_units)
output, (hidden, cell) = self.lstm(x, (hidden, cell))
# 3.출력 예측
output = self.fc(output) # output.shape: (batch_size, seq_len, tar_vocab_size)
# 디코더의 출력은 예측값, hidden state, cell state
return output, hidden, cell
class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder):
super(Seq2Seq, self).__init__()
self.encoder = encoder
self.decoder = decoder
def forward(self, src, trg):
hidden, cell = self.encoder(src) # 인코더 입력 (정수 인코딩된 소스 시퀀스, 예: 영어 문장).
# 훈련 중에는 디코더의 출력 중 오직 output만 사용한다.
output, _, _ = self.decoder(trg, hidden, cell) # 디코더 입력 (정수 인코딩된 타겟 시퀀스, 예: 프랑스어 문장).
return output
encoder = Encoder(src_vocab_size, embedding_dim, hidden_units)
decoder = Decoder(tar_vocab_size, embedding_dim, hidden_units)
model = Seq2Seq(encoder, decoder)