일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |
- CBOW
- classification
- 고전소설
- 밑바닥부터 시작하는 딥러닝
- tree purning
- boosting for regression
- Python
- 딥러닝
- 밑바닥부터 시작하는 딥러닝2
- model selection
- dynamic programming
- word2vec
- BFS
- SQLD
- Linear Regression
- do it! 알고리즘 코딩테스트: c++편
- RNN
- jini impurity
- Baekjoon
- C++
- boosting for classification
- underfiting
- Language model
- deep learning
- numpy
- overfiting
- Backtracking
- Machine Learning
- marchine learning
- DFS
- Today
- Total
newhaneul
[밑바닥부터 시작하는 딥러닝2: Chapter 7] RNN을 사용한 문장 생성 본문
[밑바닥부터 시작하는 딥러닝2: Chapter 7] RNN을 사용한 문장 생성
뉴하늘 2025. 2. 7. 16:14
본 포스팅은 밑바닥부터 시작하는 딥러닝2을 토대로 공부한 내용을 정리하기 위한 포스팅입니다.
해당 도서에 나오는 Source Code 및 자료는 GitHub를 참조하여 진행하였습니다.
https://github.com/WegraLee/deep-learning-from-scratch-2
7.0 학습 목표
이번 장에서는 ‘문장 생성’을 수행하는 언어 모델인 seq2seq이라는 새로운 구조의 신경망을 다룬다. seq2seq란 한 시계열 데이터를 다른 시계열 데이터로 변환하는 걸 말한다. 이 seq2seq은 기계 번역, 챗봇, 메일의 자동 답신 등 다양하게 응용될 수 있다.
7.2 seq2seq
지금부터 시계열 데이터를 다른 시계열 데이터로 변환하는 모델을 배워보도록 한다. 2개의 RNN을 이용하는 seq2seq(sequence to sequence)이다.
seq2seq를 Encoder-Decoder 모델이라고도 한다. Encoder는 입력 데이터를 인코딩(부호화)하고, Decoder는 인코딩된 데이터를 디코딩(복호화)한다.

[그림 7-5]처럼 Encoder가 “나는 고양이로소이다”라는 출발어 문장을 인코딩하고, 이어서 그 인코딩한 정보를 Decoder에 전달하면, Decoder가 도착어 문장을 생성하는 구조이다. 이때 Encoder가 인코딩한 정보에는 번역에 필요한 정보가 조밀하게 응축되어 있고, Decoder는 조밀하게 응축된 이 정보를 바탕으로 도착어 문장을 생성한다. Encoder의 계층에 대해 자세히 알아보도록 하자.

[그림 7-6]처럼 Encoder는 RNN을 이용해 시계열 데이터를 h라는 은닉 상태 벡터로 변환한다. 이때 h는 LSTM 계층의 마지막 은닉 상태이다. 이 마지막 은닉 상태 h에 입력 문장(출발어)을 번역하는 데 필요한 정보가 인코딩된다. 여기서 중요한 점은 LSTM의 은닉 상태 h는 고정 길이 벡터라는 사실이다. 그래서 인코딩은 결국 임의 길이의 문장을 고정 길이 벡터로 변환하는 작업이 된다(그림 7-7).

Decoder는 앞 절에서 다룬 문장 생성 모델을 그대로 이용할 수 있다.

[그림 7-8]과 같이, Decoder는 LSTM 계층이 벡터 h를 입력받는다는 점을 제외하고는 앞 절의 신경망과 완전히 같은 구성이다. 참고로, 앞 절의 언어 모델에서는 LSTM 계층이 영벡터를 받았다.

[그림 7-9]는 seq2seq의 전체 계층 구성이다. 이때 LSTM 계층의 은닉 상태가 Encoder와 Decoder를 이어주는 ‘가교’가 된다. 순전파 때는 Encoder에서 인코딩된 정보가 LSTM 계층의 은닉 상태를 통해 Decoder에 전해진다. 그리고 seq2seq의 역전파 때는 이 ‘가교’를 통해 기울기가 Decoder로부터 Encoder로 전해진다.
7.2.2 시계열 데이터 변환용 장난감 문제
이번에는 머신러닝을 평가하고자 만든 간단한 문제인 ‘장난감 문제’를 해결해보도록 한다. 장난감 문제는 구체적으로는 ”57+5“와 같은 문자열을 seq2seq에 건네면 ”62“라는 정답을 내놓도록 학습시키는 것이다.

seq2seq는 덧셈의 샘플로부터, 거기서 사용되는 문자의 패턴을 학습한다. 그 방법의 첫 걸음은 ‘문자’ 단위로 분할하는 것이다. 지금까지는 word2vec이나 언어 모델 등에서 문장을 ‘단어’ 단위로 분할해왔다. 실제로 이번 문제에서는 단어가 아닌 ‘문자’ 단위로 분할하려 한다. 문자 단위 불한이란, [‘5’, ‘7’, ‘+’, ’5‘]라는 리스트로 처리하는 걸 말한다.
이번 덧셈 문제에서는 샘플마다 데이터의 시간 방향 크기가 다르다. 즉, 가변 길이 시계열 데이터를 다루게 된다. 따라서 신경망 학습 시 ’미니배치 처리‘를 하려면 패딩을 사용하여야 한다.
패딩이란 원래의 데이터에 의미 없는 데이터를 채워 모든 데이터의 길이를 균일하게 맞추는 기법이다. [그림 7-11]은 패딩을 덧셈 문제에 적용해본 모습이다.

이처럼 패딩을 적용해 데이터 크기를 통일시키면 가변 길이 시계열 데이터도 처리할 수 있다. 그러나 원래는 존재하지 않던 패딩용 문자까지 seq2seq가 처리하게 되므로 패딩 전용 처리를 추가해야 한다. 예컨대 Decoder에 입력된 데이터가 패딩이라면 손실의 결과에 반영하지 않도록 한다. 한편 Encoder에 입력된 데이터가 패딩이라면 LSTM 계층이 이전 시각의 입력을 그대로 출력하게 한다. 즉, LSTM 계층은 마치 처음부터 패딩이 존재하지 않았던 것처럼 인코딩할 수 있다.
7.3 seq2seq 구현
seq2seq은 2개의 RNN을 연결한 신경망이다. 먼저 두 RNN을 Encoder 클래스와 Decoder 클래스로 각각 구현한 다음 두 클래스를 연결하는 Seq2seq 클래스를 구현하는 흐름으로 진행한다.
7.3.1 Encoder 클래스
Encoder 클래스는 [그림 7-14]처럼 문자열을 받아 벡터 h로 변환한다. 여기에서는 LSTM 계층을 이용하도록 한다.

[그림 7-14]와 같이 Encoder 클래스는 Embedding 계층과 LSTM 계층으로 구성된다. Embedding 계층에서는 문자를 문자 벡터로 변환한다. 그리고 이 문자 벡터가 LSTM 계층으로 입력된다.
LSTM 계층은 오른쪽(시간 방향)으로는 은닉 상태와 셀을 출력하고 위쪽으로는 은닉 상태만 출력한다. [그림 7-14]에서 보듯 Encoder에서는 마지막 문자를 처리한 후 LSTM 계층의 은닉 상태 h를 출력한다. 그리고 이 은닉 상태 h가 Decoder로 전달된다.
시간 방향을 한꺼번에 처리하는 Time 계층을 이용하면 Encoder는 [그림 7-15]처럼 된다.

class Encoder:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
embed_W = (rn(V, D) / 100).astype('f')
lstm_Wx = (rn(D, 4 * H) / np.sqrt(D)).astype('f')
lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
lstm_b = np.zeros(4 * H).astype('f')
self.embed = TimeEmbedding(embed_W)
self.lstm = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=False)
self.params = self.embed.params + self.lstm.params
self.grads = self.embed.grads + self.lstm.grads
self.hs = None
def forward(self, xs):
xs = self.embed.forward(xs)
hs = self.lstm.forward(xs)
self.hs = hs
return hs[:, -1, :]
def backward(self, dh):
dhs = np.zeros_like(self.hs)
dhs[:, -1, :] = dh
dout = self.lstm.backward(dhs)
dout = self.embed.backward(dout)
return dout
7.3.2 Decoder 클래스
Decoder 클래스는 [그림 7-17]에서 보듯, Encoder 클래스가 출력한 h를 받아 목적으로 하는 다른 문자열을 출력한다.

[그림 7-17]은 Decoder의 학습 시 계층 구성을 보여준다. 여기에서 정답 데이터는 "_62"이지만, 입력 데이터를 ['_', '6', '2', '']로 주고, 이에 대응하는 출력은 [‘6’, ‘2’, ‘’, ‘’]이 되도록 학습시킨다.
RNN으로 문장을 생성할 때, 학습 시와 생성 시의 데이터 부여 방법이 다르다. 학습 시는 정답을 알고 있기 때문에 시계열 방향의 데이터를 한꺼번에 줄 수 있다. 한편, 추론 시에는 최초 시작을 알리는 구분 문자(‘_’) 하나만 준다. 그리고 그 출력으로부터 문자를 하나 샘플링하여, 그 샘플링한 문자를 다음 입력으로 사용하는 과정을 반복하는 것이다.

[그림 7-18]은 Decoder가 문자열을 생성시키는 흐름을 보여준다. 'argmax' 노드는 최댓값을 가진 원소의 인덱스를 선택하는 노드이다. 이번에는 Softmax 계층을 사용하지 않고, Affine 계층이 출력하는 점수가 가장 큰 문자 ID를 선택하도록 한다.
Decoder에서는 학습 시와 생성 시에 Softmax 계층을 다르게 취급한다. 그러니 Decoder 클래스는 Time softmax with Loss 계층의 앞까지만 담당하도록 구현한다.

class Decoder:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
embed_W = (rn(V, D) / 100).astype('f')
lstm_Wx = (rn(D, 4 * H) / np.sqrt(D)).astype('f')
lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
lstm_b = np.zeros(4 * H).astype('f')
affine_W = (rn(H, V) / np.sqrt(H)).astype('f')
affine_b = np.zeros(V).astype('f')
self.embed = TimeEmbedding(embed_W)
self.lstm = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True)
self.affine = TimeAffine(affine_W, affine_b)
self.params, self.grads = [], []
for layer in (self.embed, self.lstm, self.affine):
self.params += layer.params
self.grads += layer.grads
def forward(self, xs, h):
self.lstm.set_state(h)
out = self.embed.forward(xs)
out = self.lstm.forward(out)
score = self.affine.forward(out)
return score
def backward(self, dscore):
dout = self.affine.backward(dscore)
dout = self.lstm.backward(dout)
dout = self.embed.backward(dout)
dh = self.lstm.dh
return dh
def generate(self, h, start_id, sample_size):
sampled = []
sample_id = start_id
self.lstm.set_state(h)
for _ in range(sample_size):
x = np.array(sample_id).reshape((1, 1))
out = self.embed.forward(x)
out = self.lstm.forward(out)
score = self.affine.forward(out)
sample_id = np.argmax(score.flatten())
sampled.append(int(sample_id))
return sampled
generate() 메서드는 인수를 3개 받는다. 차례로, Encoder로부터 받는 은닉 상태인 h, 최초로 주어지는 문자 ID인 start_id, 생성하는 문자 수인 sample_size이다. 여기에서는 문자를 1개씩 주고, Affine 계층이 출력하는 점수가 가장 큰 문자 ID를 선택하는 작업을 반복한다.
7.3.3 Seq2seq 클래스
Seq2seq 클래스는 Encoder 클래스와 Decoder 클래스를 연결하고, Time Softmax with Loss 계층을 이용해 손실을 계산하는 것이 전부이다.
class Seq2seq(BaseModel):
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
self.encoder = Encoder(V, D, H)
self.decoder = Decoder(V, D, H)
self.softmax = TimeSoftmaxWithLoss()
self.params = self.encoder.params + self.decoder.params
self.grads = self.encoder.grads + self.decoder.grads
def forward(self, xs, ts):
decoder_xs, decoder_ts = ts[:, :-1], ts[:, 1:]
h = self.encoder.forward(xs)
score = self.decoder.forward(decoder_xs, h)
loss = self.softmax.forward(score, decoder_ts)
return loss
def backward(self, dout=1):
dout = self.softmax.backward(dout)
dh = self.decoder.backward(dout)
dout = self.encoder.backward(dh)
return dout
def generate(self, xs, start_id, sample_size):
h = self.encoder.forward(xs)
sampled = self.decoder.generate(h, start_id, sample_size)
return sampled
7.3.4 seq2seq 평가
seq2seq의 학습은 기본적인 신경망의 학습과 같은 흐름으로 이뤄진다.
1. 학습 데이터에서 미니배치를 선택하고,
2. 미니배치로부터 기울기를 계산하고,
3. 기울기를 사용하여 매개변수를 갱신한다.
# coding: utf-8
import sys
sys.path.append('..')
import numpy as np
import matplotlib.pyplot as plt
from dataset import sequence
from common.optimizer import Adam
from common.trainer import Trainer
from common.util import eval_seq2seq
from seq2seq import Seq2seq
from peeky_seq2seq import PeekySeq2seq
# 데이터셋 읽기
(x_train, t_train), (x_test, t_test) = sequence.load_data('addition.txt')
char_to_id, id_to_char = sequence.get_vocab()
# 하이퍼파라미터 설정
vocab_size = len(char_to_id)
wordvec_size = 16
hidden_size = 128
batch_size = 128
max_epoch = 25
max_grad = 5.0
# 모델 / 옵티마이저 / 트레이너 생성
model = Seq2seq(vocab_size, wordvec_size, hidden_size)
optimizer = Adam()
trainer = Trainer(model, optimizer)
acc_list = []
for epoch in range(max_epoch):
trainer.fit(x_train, t_train, max_epoch=1,
batch_size=batch_size, max_grad=max_grad)
correct_num = 0
for i in range(len(x_test)):
question, correct = x_test[[i]], t_test[[i]]
verbose = i < 10
correct_num += eval_seq2seq(model, question, correct,
id_to_char, verbose, is_reverse)
acc = float(correct_num) / len(x_test)
acc_list.append(acc)
print('검증 정확도 %.3f%%' % (acc * 100))
학습 결과를 보면 학습을 거듭할수록 조금씩 정답에 가까워지지만 아직 개선할 여지가 많다는 것을 알 수 있다.

7.4 seq2seq 개선
7.4.1 입력 데이터 반전(Reverse)
첫 번째 개선안은 [그림 7-23]처럼 입력 데이터의 순서를 반전시키는 것이다.

이 개선안을 사용하면 많은 경우 학습 진행이 빨라져서, 결과적으로 최종 정확도도 좋아지게 된다.
# 데이터셋 읽기
(x_train, t_train), (x_test, t_test) = sequence.load_data('addition.txt')
...
x_train, x_test = x_train[:, ::-1], x_test[:, ::-1]
...
이 코드와 같이, 배열의 행을 반전시키려면 x_train[:, ::-1]이라는 표기법을 사용하면 된다. 결과는 [그림 7-24]와 같다.

7.4.2 엿보기(Peeky)
두 번째 seq2seq 개선안은 peeky 기법이다. seq2seq의 Encoder는 입력 문장을 고정 길이 벡터 h로 변환하는 역할을 한다. 이때 h 안에는 Decoder에게 필요한 정보가 모두 담겨 있다. 즉, 벡터 h는 Decoder에게 유일한 정보이다. 그러나 현재의 seq2seq은 [그림 7-25]와 같이 최초 시각의 LSTM 계층만이 벡터 h를 이용하고 있다. 이 중요한 정보인 h를 더 활용하는 측면에서 개선을 진행하고자 한다.

중요한 정보가 담긴 Encoder의 출력 h를 Decoder의 다른 계층에게도 전해주는 것이다. 개선하게 되면 [그림 7-26]과 같은 구성이 된다. 비교해보면, 기존에는 하나의 LSTM만이 소유하던 h를 여러 계층이 공유함을 알 수 있다.

그런데 [그림 7-26]에서는 LSTM 계층과 Affine 계층에 입력되는 벡터가 2개씩이 되었다. 따라서 두 벡터를 연결시키는 concat 노드를 이용해 [그림 7-27]처럼 그려야 정확한 계산 그래프이다.

class PeekyDecoder:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
embed_W = (rn(V, D) / 100).astype('f')
lstm_Wx = (rn(H + D, 4 * H) / np.sqrt(H + D)).astype('f')
lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
lstm_b = np.zeros(4 * H).astype('f')
affine_W = (rn(H + H, V) / np.sqrt(H + H)).astype('f')
affine_b = np.zeros(V).astype('f')
self.embed = TimeEmbedding(embed_W)
self.lstm = TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True)
self.affine = TimeAffine(affine_W, affine_b)
self.params, self.grads = [], []
for layer in (self.embed, self.lstm, self.affine):
self.params += layer.params
self.grads += layer.grads
self.cache = None
def forward(self, xs, h):
N, T = xs.shape
N, H = h.shape
self.lstm.set_state(h)
out = self.embed.forward(xs)
hs = np.repeat(h, T, axis=0).reshape(N, T, H)
out = np.concatenate((hs, out), axis=2)
out = self.lstm.forward(out)
out = np.concatenate((hs, out), axis=2)
score = self.affine.forward(out)
self.cache = H
return score
def backward(self, dscore):
H = self.cache
dout = self.affine.backward(dscore)
dout, dhs0 = dout[:, :, H:], dout[:, :, :H]
dout = self.lstm.backward(dout)
dembed, dhs1 = dout[:, :, H:], dout[:, :, :H]
self.embed.backward(dembed)
dhs = dhs0 + dhs1
dh = self.lstm.dh + np.sum(dhs, axis=1)
return dh
def generate(self, h, start_id, sample_size):
sampled = []
char_id = start_id
self.lstm.set_state(h)
H = h.shape[1]
peeky_h = h.reshape(1, 1, H)
for _ in range(sample_size):
x = np.array([char_id]).reshape((1, 1))
out = self.embed.forward(x)
out = np.concatenate((peeky_h, out), axis=2)
out = self.lstm.forward(out)
out = np.concatenate((peeky_h, out), axis=2)
score = self.affine.forward(out)
char_id = np.argmax(score.flatten())
sampled.append(char_id)
return sampled

참고로 Peeky를 이용하게 되면 신경망은 가중치 매개변수가 커져서 계산량도 늘어나게 된다.
'2. Artificial Intelligence > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글
[밑바닥부터 시작하는 딥러닝2: Chapter 6] 게이트가 추가된 RNN (0) | 2025.01.20 |
---|---|
[밑바닥부터 시작하는 딥러닝2: Chapter 5] 순환 신경망(RNN) (0) | 2025.01.09 |
[밑바닥부터 시작하는 딥러닝2: Chapter 4] word2vec 속도 개선 (2) | 2025.01.02 |
[밑바닥부터 시작하는 딥러닝2: Chapter 3] word2vec (0) | 2024.12.07 |
[밑바닥부터 시작하는 딥러닝2: Chapter 2] 자연어와 단어의 분산 표현 (4) | 2024.11.28 |