8.1 어텐션의 구조
8장에서는 현재 seq2seq가 안고있는 문제를 살펴보고, 어텐션의 구조를 설명하며 구현해 볼 예정이다.
8.1.1 seq2seq의 문제점
- seq2seq에서는 Encoder가 시계열 데이터를 인코딩해 Decoder로 그 정보를 전달한다. 이때 Endoer의 출력은 '고정 길이의 벡터' 이다. 고정 길이 벡터는 문장이 아무리 길어도 동일한 길이의 벡터로 변환된다는 뜻이다. 이 경우 필요한 정보가 벡터에 다 담기지 못할 가능성도 있다.

8.1.2 Encoder 개선
- Encoder 출력의 길이를 입력 문장의 길이에 따라 바꿔주는게 좋다.

- 즉, 위 그림처럼 각 시각(각 단어)의 은닉 상태를 모두 이용하면 입력된 단어와 같은 수의 벡터를 얻을 수 있어 Encoder가 '하나의 고정 길이 벡터'라는 제약으로부터 해방된다.
- 그렇다면 시각별 LSTM 계층의 은닉 상태에는 직전에 입력된 단어에 대한 정보가 많이 포함되어있을 것이다. ex) 고양이 단어를 입력했을 때의 LSTM 계층의 출력(은닉 상태)은 직전에 입력한 '고양이'라는 단어의 영향을 가장 크게 받는다는 것이다. 즉, '고양이'의 '성분'이 많이 들어간 은닉 상태 벡터를 얻게 되며 Encoder가 출력하는 $\mathbf{hs}$ 행렬은 각 단어에 해당하는 벡터들의 집합이라 볼 수 있다.

- Encoder는 이제 입력 문장의 길이에 비례한 정보를 인코딩 할 수 있게 된다.
8.1.3 Decoder 개선 I.
- Encoder LSTM 계층의 은닉 상태 벡터인 $\mathbf{hs}$가 Decoder에 전달되어 시계열 변환이 이루어진다.
- 앞 장의 seq2seq에서는 Encoder LSTM 계층의 마지막 은닉상태를 Decoder LSTM 계층의 첫 은닉상태로 이용했다. 즉, $\mathbf{hs}$에서 마지막 줄만 빼내어 Decoder에 전달한 것과 같았다. 이를 $\mathbf{hs}$ 전체를 활용할 수 있도록 Decoder를 개선해보자.
- 입력과 출력의 여러 단어들 중 어떤 단어끼리 서로 관련되어있는가의 대응 관계를 seq2seq에게 학습시킬 수 있는 방안을 찾아보자. (ex-고양이 = cat과 같이 단어의 대응 관계를 나타내는 alignment를 seq2seq에 자동으로 도입해보자.)
- 즉, 필요한 정보에만 주목해 그 정보로부터 시계열 변환을 수행하는 것이 목표이며 이 구조를 어텐션이라 한다.

- [그림 8-6]에 추가된 '어떤 계산'은 Encoder로부터 $\mathbf{hs}$와 시각별 LSTM 계층의 은닉상태 2가지를 입력으로 받는다. 여기에서 필요한 정보만 골라 위쪽의 Affine 계층으로 출력한다.
- 신경망에서 최종적으로 목표로 하는 일은 단어들의 alignment(대응관계) 추출이다. 즉, 각 시각에서 Decoder에 입력된 단어와 대응 관계인 단어들의 벡터를 $\mathbf{hs}$에서 골라내겠다는 뜻이다. (ex. Decoder가 "I"를 출력할 때 $\mathbf{hs}$에서 "나"에 대응하는 벡터를 선택함.) 하지만 여러 대상으로부터 몇 개를 선택하는 작업은 미분할 수 었다. 따라서 일반적인 오차역전파법을 사용할 수 없다는 것이다.
- 이를 해결하기 위해 '모든 것을 선택한다'는 아이디어 아래 아래처럼 각 단어의 중요도(기여도)를 나타내는 '가중치'를 별도로 계산하도록 한다.

- $\mathbf{a}$는 확률분포처럼 각 원소가 0~1.0 사이의 scalar이며 총합은 1이 된다. 이 $\mathbf{a}$와 $\mathbf{hs}$의 가중합을 '$\mathbf{c}$ = 맥락 벡터(context vector)'라 한다. 그림에서는 현재 '나'에 해당하는 가중치가 0.8이기 때문에 맥락 벡터에는 '나' 벡터의 성분이 많이 포함되어있음을 의미한다. 즉, '나' 벡터를 선택하는 작업을 이 가중합으로 대체하고 있다.
import numpy as np
T, H = 5, 4
hs = np.random.randn(T, H)
a = np.array([0.8, 0.1, 0.03, 0.05, 0.02])
ar = a.reshape(5, 1).repeat(4, axis=1)
print(ar.shape) # shape = (5, 4)
t = hs * ar # shape = (5, 4)
c = np.sum(t, axis=0) # shape = (4, )
- 시계열의 길이 = T, 은닉 상태 벡터의 원소 수 H = 4 가중합을 구하는 과정이 위와 같다.

- a.reshape.repeat() 외에 넘파이의 브로드캐스트를 사용해도 동일한 결과를 얻을 수 있다. (ar = a.reshape(5, 1) 이후 hs * ar) 이 작업은 Repeat 노드이기 대문에 역전파 시 Repeat 노드의 역전파를 수행해야 한다.
import numpy as np
N, T, H = 10, 5, 4
hs = np.random.randn(N, T, H)
a = np.random.randn(N, T)
ar = a.reshape(N, T, 1).repeat(H, axis=2)
t = hs * ar # shape = (10, 5, 4)
c = np.sum(t, axis=1) # shape = (10, 4)
- 미니배치 처리도 간단하게 위처럼 할 수 있다. 이제 가중합 계산을 '계산 그래프'로 그려보자.

- Repeat 노드를 이용해 $\mathbf{a}$를 복제하고 'x' 노드로 원소별 곱을 구한 뒤 sum 노드로 합을 구한다.
- 'Repeat' 역전파는 sum이 되고 'Sum' 역전파는 'Repeat'이 된다는 사실을 기억하자.
class WeightSum:
def __init__(self):
self.params, self.grads = [], []
self.cache = None
def forward(self, hs, a):
N, T, H = hs.shape
ar = a.reshape(N, T, 1).repeat(H, axis=2)
t = hs * ar
c = np.sum(t, axis=1)
self.cache = (hs, ar)
return c
def backward(self, dc):
hs, ar = self.cache
N, T, H = hs.shape
dt = dc.reshape(N, 1, H).repeat(T, axis=1) # sum의 역전파
dar = dt * hs
dhs = dt * ar
da = np.sum(dar, axis=2) # repeat의 역전파
return dhs, da
- 다음이 맥락 벡터를 구하는 Weight Sum 계층의 구현 전부이다. 학습하는 매개변수는 없으므로 self.params = []로 설정된다.
8.1.4 Decoder 개선 II.
- 가중치를 나타내는 $\mathbf{a}$를 학습하는 방법을 알아보자.
- Decoder의 첫번째(시각) LSTM 계층이 은닉상태 벡터를 출력할 때 까지의 처리를 살펴보자.

- [그림 8-12]에서 Deocder의 LSTM 계층의 은닉 상태 벡터를 $\mathbf{h}$라 했다. 이 $\mathbf{h}$가 $\mathbf{hs}$의 각 단어 벡터와 얼마나 '비슷한가'를 수치로 나타내기 위해 벡터의 '내적'을 이용한다.
- 내적의 직관적인 의미는 '두 벡터가 얼마나 같은 방향을 향하고 있는가'이다.

- 내적 결과 $\mathbf{s}$는 정규화 전의 값이며 score(점수)라고 한다. 일반적으로 softmax 함수를 적용해 정규화해 각 원소의 합이 1이고 각 원소는 0.0~1.0 사이의 scalar가 된다. 이렇게 각 단어 가중치 $\mathbf{a}$를 구할 수 있다.
N, T, H = 10, 5, 4
hs = np.random.randn(N, T, H)
h = np.random.randn(N, H)
hr = h.reshape(N, 1, H).repeat(T, axis=1)
t = hs * hr # shape = (10, 5, 4)
s = np.sum(t, axis=2) # shape = (10, 5)
softmax = Softmax()
a = softmax.forward(s) # shape = (10, 5)

- 이처럼 Repeat 노드, 원소별 곱 x 노드, Sum 노드, Softmax 계층으로 구성된다.
class AttentionWeight:
def __init__(self):
self.params, self.grads = [], []
self.cache = None
self.softmax = Softmax()
def forward(self, hs, h):
N, T, H = hs.shape
hr = h.reshape(N, 1, H).repeat(T, axis=1)
t = hs * hr
s = np.sum(t, axis=2)
a = self.softmax.forward(s)
self.cache = (hs, hr)
return a
def backward(self, da):
hs, hr = self.cache
N, T, H = hs.shape
ds = self.softmax.backward(da)
dt = ds.reshape(N, T, 1).repeat(H, axis=2)
dhs = dt * hr
dhr = dt * hs
dh = np.sum(dhr, axis=1)
return dhs, dh
8.1.5 Decoder 개선 III.
- 지금까지 (1) Attention Weight 계층, (2) Weight Sum 계층을 각각 구현했다. 이제 이 계층을 하나로 결합해보자.

- (1) Attention Weight 계층은 Encoder가 출력하는 각 단어의 벡터 $\mathbf{hs}$에 주목하여 해당 단어의 가중치 $]mathbf{a}$를 구한다. (2) 이어서 Weight Sum 계층이 $\mathbf{a}$와 $\mathbf{hs}$의 가중합을 구하고 최종 결과를 맥락 벡터 $\mathbf{c}$로 출력한다. 이 일련의 계산을 수행하는 계층을 Attention 계층이라 부르자.
- 요약하면, Encoder가 건네주는 정보 $\mathbf{hs}$에서 중요한 원소에 주목하여, 그것을 바탕으로 맥락 벡터를 구해 위쪽 (Affine) 계층으로 전파한다.
class Attention:
def __init__(self):
self.params, self.grads = [], []
self.attention_weight_layer = AttentionWeight()
self.weight_sum_layer = WeightSum()
self.attention_weight = None
def forward(self, hs, h):
a = self.attention_weight_layer.forward(hs, h)
out = self.weight_sum_layer.forward(hs, a)
self.attention_weight = a
return out
def backward(self, dout):
dhs0, da = self.weight_sum_layer.backward(dout)
dhs1, dh = self.attention_weight_layer.backward(da)
dhs = dhs0 + dhs1
return dhs, dh
- 2개의 Weight Sum 계층과 Attention Weight 계층에 의한 순전파와 역전파를 수행하는 Attention 계층 코드이다. 이 계층을 LSTM과 Affine 계층 사이에 삽입하면 된다.

- 각 시각의 Attention 계층에는 Encoder의 출력인 $\mathbf{hs}$가 입력되며 LSTM 계층의 은닉 상태를 Affine 계층에 입력한다. 이러면 Decoder에 어텐션 정보를 '추가'할 수 있게 된다.

- 오른쪽 Attention이 추가된 Decoder는기존 LSTM 계층의 은닉 상태 벡터에 더해, Attention 계층이 구한 맥락 벡터 정보를 '추가'한 것으로 생각할 수 있다.

- 시계열 방향으로 펼쳐진 다수의 Attention 계층을 Time Attention 계층으로 모아 구현하면 [8-20]처럼 표현된다.
class TimeAttention:
def __init__(self):
self.params, self.grads = [], []
self.layers = None
self.attention_weights = None
def forward(self, hs_enc, hs_dec):
N, T, H = hs_dec.shape
out = np.empty_like(hs_dec)
self.layers = []
self.attention_weights = []
for t in range(T):
layer = Attention()
out[:, t, :] = layer.forward(hs_enc, hs_dec[:, t, :])
self.layers.append(layer)
self.attention_weights.append(layer.attention_weight)
return out
def backward(self, dout):
N, T, H = dout.shape
dhs_enc = 0
dhs_dec = np.empty_like(dout)
for t in range(T):
layer = self.layers[t]
dhs, dh = layer.backward(dout[:, t, :])
dhs_enc += dhs
dhs_dec[:, t, :] = dh
return dhs_enc, dhs_dec
- Attention 계층을 필요한 수만큼 만들고, 각각 순전파와 역전파를 수행하고 각 Attention 계층의 각 단어의 가중치를 attention_weights 리스트에서 보관한다.
8.2 어텐션을 갖춘 seq2seq 구현
8.2.1 Encoder 구현
class AttentionEncoder(Encoder):
def forward(self, xs):
xs = self.embed.forward(xs)
hs = self.lstm.forward(xs) #
return hs
def backward(self, dhs):
dout = self.lstm.backward(dhs)
dout = self.embed.backward(dout)
return dout
- Encoder 클래스의 forward() 메서드에서 LSTM 계층의 마지막 은닉 상태 벡터만 반환하는 것이 아니라 모든 은닉 상태를 반환하도록 변경되었다.
8.2.2 Decoder 구현

- 앞 seq2seq과 마찬가지로 softmax 계층의 앞까지를 Decoder로 구현한다. (학습과 추론이 이후가 다르기 때문에)
class AttentionDecoder(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(2*H, V)/np.sqrt(2*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.attention = TimeAttention()
self.affine = TimeAffine(affine_W, affine_b)
layers = [self.embed, self.lstm, self.attention, self.affine]
self.params, self.grads = [], []
for layer in layers:
self.params += layer.params
self.grads += layer.grads
def forward(self, xs, enc_hs):
h = enc_hs[:, -1] # 마지막 hidden state
self.lstm.set_state(h)
out = self.embed.forward(xs)
dec_hs = self.lstm.forward(out)
c = self.attention.forward(enc_hs, dec_hs)
out = np.concetenate((c, dec_hs), axis=2)
score = self.affine.forward(out)
return score
def backward(self, dscore):
dout = self.affine.backward(dscore)
N, T, H2 = dout.shape
H = H2 // 2
dc, ddec_hs0 = dout[:,:,:H], dout[:,:,H:]
denc_hs, ddec_hs1 = self.attention.backward(dc)
ddec_hs = ddec_hs0 + ddec_hs1
dout = self.lstm.backward(ddec_hs)
dh = self.lstm.dh
denc_hs[:, -1] += dh
self.embed.backward(dout)
return denc_hs
def generate(self, enc_hs, start_id, sample_size):
sampled = []
sample_id = start_id
h = enc_hs[:, -1]
self.lstm.set_state(h)
for _ in range(sample_size):
x = np.array([sample_id]).reshape((1, 1))
out = self.embed.forward(x)
dec_hs = self.lstm.forward(out)
c = self.attention.forward(enc_hs, dec_hs)
out = np.concatenate((c, dec_hs), axis=2)
score = self.affine.forward(out)
sample_id = np.argmax(score.flatten())
sampled.append(sample_id)
return sampled
- forward() 메서드에서 Time Attention 계층의 출력과 LSTM 계층의 출력을 연결한다는 점만 주의하자. 두 출력은 np.concatenate() 메서드를 사용했다.
8.2.3 seq2seq 구현
class AttentionSeq2seq(Seq2seq):
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
self.encoder = AttentionEncoder(*args)
self.decoder = AttentionDecoder(*args)
self.softmax = TimeSoftmaxWithLoss()
self.params = self.encoder.params + self.decoder.params
self.grads = self.encoder.grads + self.decoder.grads
- 앞 절의 seq2seq에서 Encoder 대신 AttentionEncoder 클래스를, Decoder 대신 AttentionDecoder 클래스를 사용하면 구현 완료이다.
8.3 어텐션 평가
8.3.1 날짜 형식 변환 문제
- 다양한 날짜 형식을 표준 형식으로 변환해 보자. ex) september 27, 1994 -> 1994-09-27
- 입력 날짜 데이터에는 다양한 변형이 존재하여 변환 규칙이 복잡해지며, 문제의 입력(질문)과 출력(답변) 사이에 알기 쉬운 대응 관계가 존재한다. (년, 월, 일의 대응관계). 따라서 어텐션이 각 원소에 올바르게 주목하고 있는지를 확인할 수 있다.

- github의 학습 데이터 형식은 다음과 같다. 입력과 출력의 구분 문자로는 '_'를 사용했으며 출력 문자 수가 일정하기 때문에 출력의 끝을 알리는 구분 문자는 따로 사용하지 않았다.
8.3.2 어텐션을 갖춘 seq2seq의 학습
def eval_seq2seq(model, question, correct, id_to_char,
verbos=False, is_reverse=False):
correct = correct.flatten()
# 머릿글자
start_id = correct[0]
correct = correct[1:]
guess = model.generate(question, start_id, len(correct))
# 문자열로 변환
question = ''.join([id_to_char[int(c)] for c in question.flatten()])
correct = ''.join([id_to_char[int(c)] for c in correct])
guess = ''.join([id_to_char[int(c)] for c in guess])
if verbos:
if is_reverse:
question = question[::-1]
colors = {'ok': '\033[92m', 'fail': '\033[91m', 'close': '\033[0m'}
print('Q', question)
print('T', correct)
is_windows = os.name == 'nt'
if correct == guess:
mark = colors['ok'] + '☑' + colors['close']
if is_windows:
mark = 'O'
print(mark + ' ' + guess)
else:
mark = colors['fail'] + '☒' + colors['close']
if is_windows:
mark = 'X'
print(mark + ' ' + guess)
print('---')
return 1 if guess == correct else 0
(x_train, t_train), (x_test, t_test) = sequence.load_data('date.txt')
char_to_id, id_to_char = sequence.get_vocab()
# 입력 문장 반전
x_train, x_test = x_train[:, ::-1], x_test[:, ::-1]
# 하이퍼파라미터 설정
vocab_size = len(char_to_id)
wordvec_size = 16
hidden_size = 256
batch_size = 128
max_epoch = 10
max_grad = 5.0
model = AttentionSeq2seq(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, verbos, is_reverse=True)
acc = float(correct_num) / len(x_test)
acc_list.append(acc)
print('val acc %.3f%%' % (acc * 100))
model.save_params()
- 어텐션을 갖춘 seq2seq는 학습을 거듭할수록 점점 정확도가 높아진다.

- 1 에폭부터 빠르게 정답률 높여 2 에폭째에는 거의 모든 문제를 풀어낸다. seq2seq는 전혀 쓸모가 없지만, Peeky는 좋은 결과를 보여주고 있다. 최종 정확도 측면에서는 어텐션과 Peeky가 동등했다. 하지만 길고 복잡한 현실 시계열 데이터에선 학습 속도 및 정확도 측면에서 모두 어텐션이 유리할 것이다.
8.3.3 어텐션 시각화
- 이어서 어텐션을 시각화해보자. Attention 계층은 각 시각의 어텐션 가중치를 인스턴스 변수로 보관하고 있으므로, 어느 원소에 주의를 기울이는지 시각화 할 수 있다.
- Time Attention 계층의 인스턴스 변수 attention_weights에 각 시각의 어텐션 가중치가 저장되어 있기 때문에 입력 문장과 출력 문장의 단어 대응 관계를 2차원 맵으로 그릴 수 있다.
_idx = 0
def visualize(attention_map, row_labels, column_labels):
fig, ax = plt.subplots()
ax.pcolor(attention_map, cmap=plt.cm.Greys_r, vmin=0.0, vmax=1.0)
ax.patch.set_facecolor('black')
ax.set_yticks(np.arange(attention_map.shape[0])+0.5, minor=False)
ax.set_xticks(np.arange(attention_map.shape[1])+0.5, minor=False)
ax.invert_yaxis()
ax.set_xticklabels(row_labels, minor=False)
ax.set_yticklabels(column_labels, minor=False)
global _idx
_idx += 1
plt.show()
np.random.seed(1984)
for _ in range(5):
idx = [np.random.randint(0, len(x_test))]
x = x_test[idx]
t = t_test[idx]
model.forward(x, t)
d = model.decoder.attention.attention_weights
d = np.array(d)
attention_map = d.reshape(d.shape[0], d.shape[2])
# 출력하기 위해 반전
attention_map = attention_map[:,::-1]
x = x[:,::-1]
row_labels = [id_to_char[i] for i in x[0]]
column_labels = [id_to_char[i] for i in t[0]]
column_labels = column_labels[1:]
visualize(attention_map, row_labels, column_labels)

- seq2seq 시계열 변환을 할 때 어텐션 가중치를 시각화하면 다음과 같다. seq2seq가 최초의 "1"을 출력할 때는 입력 문장의 "1" 위치에 표시가 된다. 결과 그래프를 자세히 보면, 세로축(출력)의 "1983"과 "26"이 가로축(입력)의 "1983"과 "26"에 대응되고 있다. 더욱이, 월을 뜻하는 "08"에 입력 문장의 "AUGUST"가 대응하고 있다. 즉, seq2seq는 "August"가 "8월"에 대응된다는 사실을 데이터만 가지고 학습해 낸 것이다.
- 이처럼 어텐션을 이용하면 필요한 정보에 더 주의를 기울일 수 있다. 어텐션은 이처럼 '인간이 이해할 수 있는 구조나 의미'를 모델에 제공한다.
8.4 어텐션에 관한 남은 이야기
8.4.1 양방향 RNN
- 앞 절 까지의 Encoder는 다음처럼 그릴 수 있다.

- LSTM의 각 시각의 은닉 상태는 $\mathbf{hs}$로 모아진다. Encoder가 출력하는 $\mathbf{hs}$의 각 행에는 그 행에 대응하는 단어의 성분이 많이 포함되어있다. 하지만 우리는 글을 왼쪽 -> 오른쪽으로 읽기 때문에 '고양이' 벡터의 경우 '나', '는', '고양이' 까지 총 3 단어의 정보가 인코딩되어 들어간다. 전체적인 균형을 생각하면 '고양이' 단어의 주변 정보를 균형있게 담고자 한다.
- 따라서 양방향 LSTM 기술이 등장했다.

- 양방향 LSTM에서는 지금까지의 LSTM 계층에 더해 역방향으로 처리하는 LSTM 계층도 추가한다. 그리고 각 시각에서는 이 두 LSTM 계층의 은닉 상태를 연결시킨 벡터를 최종 은닉 상태로 처리한다. 이렇게 양방향으로 처리하면 각 단어에 대응하는 은닉 상태 벡터에는 좌/우 양쪽 방향으로의 정보를 집약해 균형잡힌 정보를 인코딩 할 수 있다.
- 구현 : 2개의 LSTM 계층을 사용해 각 계층에 주는 단어의 순서를 조정한다. 하나의 LSTM 계층에는 왼쪽 -> 오른쪽으로 입력 문장을 처리하고, 다른 하나의 LSTM 계층에는 입력 문장의 단어를 반대 순서대로 입력해 오른쪽 -> 왼쪽으로 처리하게 된다. 마지막으로 이 두 LSTM 계층의 출력을 연결하기만 하면 양방향 LSTM 계층이 완성된다.
8.4.2 Attention 계층 사용 방법

- 복습 : LSTM과 Affine 계층 사이에 Attention 계층을 삽입했다. 하지만 반드시 이 위치일 필요는 없다.

- 위 그림과 같이 Attention 계층의 출력(맥락, context 벡터)이 다음 시각의 LSTM 계층에 입력되도록 연결할 수도 있다. 이렇게 구성하면 LSTM 계층이 맥락 벡터의 정보를 이용할 수 있다. 현재 본 책에서 구현한 모델은 Affine 계층이 맥락 벡터를 이용했다.
- Affine 계층의 위치를 달리하는 것이 최종 정확도에 어떤 영향을 줄지는 실제 데이터로 검증해야만 판단 가능하다.
- 구현 관점에서는 LSTM 계층과 Affine 계층 사이에 Attention 계층을 삽입하는 쪽이 더 쉽다. 또한 이 경우 Decoder의 데이터 흐름이 아래에서 위로 가는 한 방향이기 때문에 Attention 계층을 쉽게 모듈화할 수 있다.
8.4.3 seq2seq 심층화와 skip 연결
- attention이 추가된 seq2seq이 더 높은 표현력을 갖게 하기 위해 RNN(or LSTM) 계층을 더 깊게 쌓는 방법을 고려할 수 있다.

- Encoder와 Decoder로 동일한 3층 층수의 LSTM 계층을 다음과 같이 쌓아 구성했다.
- Attention 계층의 사용법은 여러 변형이 있을 수 있는데, 여기에선 Encoder의 LSTM 계층의 은닉 상태를 Attention 계층에 입력하고 Attention 계층의 출력 결과인 맥락 벡터를 Decoder의 여러 계층(LSTM, Affine)으로 전파한다. 이외에도 여러 Attention 계층을 사용하거나, Attention 출력을 다음 시각의 LSTM 계층으로 입력하는 등 여러 변형이 가능하다.
- 층을 깊게 할 때 사용되는 기법 중 Skip 연결(Skip connection)이 있다. 잔차 연결 혹은 숏컷이라고 부르기도 한다.

- Skip 연결은 위처럼 계층을 넘어 '선을 연결'하는 단순한 기법이다. 이때 skip 연결의 접속부에서는 2개의 출력이 '더해'진다. 이 원소별 덧셈을 함으로써 역전파 시 기울기를 '그대로 흘려' 보내게 되기 때문에 skip 연결의 기울기아 아무런 영향을 받지 않고 모든 계층으로 흐르게 된다. 따라서 층이 깊어져도 기울기가 소실 혹은 폭발하지 않고 전파되어, 좋은 학습을 기대할 수 있다.
- RNN 계층의 역전파에서는 시간 방향에서 기울기 소실/폭발이 일어날 수 있다. 기울기 소실에는 LSTM, GRU 등의 '게이트가 달린 RNN'으로 대응할 수 있고, 기울기 폭발에는 gradient clipping으로 대응할 수 있다. 또 RNN의 깊이방향 기울기 소실에는 여기의 skip 연결이 효과적이다.
8.5 어텐션 응용
8.5.1 구글 신경망 기계 번역(GNMT)
- GNMT(Google Neural Machine Translation) : 구글 신경망 기계번역.

- GNMT도 seq2seq과 마찬가지로 Encoder, Decoder, Attention으로 구성되어있다. 번역 정확도를 높이기 위한 개산 방향으로 LSTM 다층화, Encoder 첫번째 계층만 양방향 LSTM 사용, skip 연결 등을 활용했다. 또 학습 시간을 단축시키기 위해 다수의 GPU로 분산 학습을 수행한다.
- GNMT에서는 아키텍처 외에도 낮은 빈도의 단어 처리나 추론 고속화를 위한 양자화 등 다양한 연구가 이뤄지고 있다.
8.5.2 트랜스포머
- RNN : 이전 시각에 계산한 결과를 이용해 순서대로 계산하기 때문에 병렬처리가 불가하다. 이런 배경에서 'Attention is all you need' 논문에서 제안한 트랜스포머 모델이 등장했다.
- Transformer는 Self-Attention, 즉 하나의 시계열 데이터를 대상으로 한 어텐션으로, '하나의 시계열 데이터 내에서' 각 원소가 다른 원소들과 어떻게 관련되는지 살펴본다. Time Attention계층을 예로 Self-Attention 개념을 이해해보면 다음 그림과 같다.

- 지금까지 시계열 데이터에서는 2개의 시계열 데이터 사이의 대응 관계를 구해왔다. Time Attention 계층에서는 그림 8-37의 왼쪽처럼 서로 다른 두 시계열 데이터가 입력된다. 반면, Self Attention은 오른쪽처럼 두 입력선이 모두 하나의 시계열 데이터로부터 나온다. 이러면 하나의 시계열 데이터 내에서의 원소 간 대응관계가 구해진다.

- Transformer는 위 구성처럼 RNN 대신 Encoder와 Decoder 모두에서 Attention을 사용한다. 또한 Feed Forward 계층은 피드포워드 신경망(시간 방향으로 독립적으로 처리하는 신경망)을 나타낸다. 정확하게는 은닉층이 1개이고 활성화 함수로 ReLU를 이용한 완전연결계층 신경망을 이용한다. 또한 그림에서 $\mathbf{Nx}$는 회색 배경으로 둘러쌓인 계층들을 N겹 쌓았음을 의미한다.
- Transformer를 이용하면 계산량을 줄이고 GPU를 이용한 병렬 계산의 혜택도 누릴 수 있다. 그로써 GNMT보다 학습시간을 큰 폭으로 줄이는데 성공했으며, 번역의 품질도 상당히 끌어올릴 수 있었다.
8.5.3 뉴럴 튜링 머신(NTM)
- '외부 메모리를 통한 확장'
- RNN, LSTM은 내부 상태를 활용해 시계열 데이터를 기억할 수 있었다. 하지만 내부 상태는 길이가 고정이라 채워넣을 수 있는 정보의 양이 제한적이다. 따라서 RNN 외부에 메모리를 두고 필요한 정보를 적절히 기록하는 방안을 착안해 냈다.
- Attention을 갖춘 seq2seq에서는 Encoder가 입력 문장을 인코딩하는 것을 필요한 정보를 메모리에 쓴다고 볼 수 있고, 인코딩된 정보를 Attention을 통해 Decoder가 이용하는 것을 필요한 정보를 읽어들인다라고 해석할 수 있다.
- RNN 외부에 정보저장용 메모리 기능을 배치하고 Attention을 이용해 그 메모리로부터 필요한 정보를 읽거나 쓰는 방법이 뉴럴 튜링 머신(NTM)이다. 이는 후에 DNC(Differentiable Neural Computers) 라는 기법으로 개선되었다.
- 또한 NTM은 이런 메모리 조작을 '미분 가능'한 계산으로 구축했다. 따라서 메모리 조작 순서도 데이터로부터 학습할 수 있다.

- 여기에서는 LSTM 계층이 '컨트롤러'가 되어 NTM의 주된 처리를 수행한다. 각 시각에서 LSTM 계층의 은닉 상태를 Write Head 계층이 받아 필요한 정보를 메모리에 쓰고 Read Head 계층이 메모리로부터 중요한 정보를 읽어들여 다음 시각의 LSTM 계층으로 전달한다.
- Read와 Write Head 계층은 Attention을 사용한다. 메모리의 특정 위치에 담긴 데이터를 읽거나 쓰기 위해선 '선택'작업을 해야하지만 이는 미분할 수 없다. 그래서 어텐션을 사용해 모든 번지에 담긴 데이터를 선택하도록 하고, 각 데이터의 기여도를 나타내는 '가중치'를 이용한다. 이렇게 함으로써 '선택'작업을 미분 가능한 계산으로 대체할 수 있다.
- NTM은 컴퓨터의 메모리 조작을 모방하기 위해 2개의 어텐션을 사용한다. (1) 콘텐츠 기반 어텐션 : 지금까지의 attention과 동일. 입력으로 주어진 query vector와 비슷한 벡터를 메모리로부터 찾아내는 용도로 이용됨. (2) 위치 기반 어텐션 : 이전 시각에서 주목한 메모리의 위치(=메모리 각 위치에 대한 가중치)를 기준으로 그 전후로 이동(shift)하는 용도로 사용됨.
- 이렇게 외부 메모리를 자유롭게 사용함으로써 긴 시계열을 기억하는 문제와 정렬문제에서 큰 성능향상을 보여주었다. 또한 외부메모리를 사용함으로써 알고리즘을 학습하는 능력을 얻었다.
8.6 정리
- 번역, 음성 인식 등 한 시계열 데이터를 다른 시계열 데이터로 변환하는 작업에서는 시계열 데이터 사이의 대응 관계가 존재하는 경우가 많다.
- 어텐션은 두 시계열 데이터 사이의 대응 관계를 데이터로부터 학습한다.
- 어텐션에서는 백터의 내적으로 벡터 간 유사도를 구하고, 그 유사도를 이용한 가중합 벡터가 어텐션의 출력이 된다.
- 어텐션에서 사용하는 연산은 미분 가능하기 때문에 오차역전파법으로 학습할 수 있다.
- 어텐션이 산출하는 가중치(확률)를 시각화하면 입출력의 대응 관계를 볼 수 있다.
Appendix ) TimeBiLSTM
- Attention을 갖춘 seq2seq에 양방향 LSTM을 적용한 코드 :
class TimeBiLSTM:
def __init__(self, Wx1, Wh1, b1,
Wx2, Wh2, b2, stateful=False):
self.forward_lstm = TimeLSTM(Wx1, Wh1, b1, stateful)
self.backward_lstm = TimeLSTM(Wx2, Wh2, b2, stateful)
self.params = self.forward_lstm.params + self.backward_lstm.params
self.grads = self.forward_lstm.grads + self.backward_lstm.grads
def forward(self, xs):
o1 = self.forward_lstm.forward(xs)
o2 = self.backward_lstm.forward(xs[:, ::-1])
o2 = o2[:, ::-1]
out = np.concatenate((o1, o2), axis=2)
return out
def backward(self, dhs):
H = dhs.shape[2] // 2
do1 = dhs[:, :, :H]
do2 = dhs[:, :, H:]
dxs1 = self.forward_lstm.backward(do1)
do2 = do2[:, ::-1]
dxs2 = self.backward_lstm.backward(do2)
dxs2 = dxs2[:, ::-1]
dxs = dxs1 + dxs2
return dxs'Deep Learning' 카테고리의 다른 글
| [밑바닥부터 시작하는 딥러닝2] Chapter 7. RNN을 사용한 문장 생성 (1) | 2024.07.29 |
|---|---|
| [밑바닥부터 시작하는 딥러닝2] Chapter 6. 게이트가 추가된 RNN (9) | 2024.07.22 |
| [밑바닥부터 시작하는 딥러닝2] Chapter 5. 순환 신경망(RNN) (0) | 2024.07.15 |
| [밑바닥부터 시작하는 딥러닝2] Chapter 4. Word2Vec 속도 개선 (3) | 2024.07.08 |
| [밑바닥부터 시작하는 딥러닝2] Chapter 3. Word2Vec (1) | 2024.07.02 |