밑바닥부터 시작하는 딥러닝2 - 8장

DSC W/S·2020년 2월 15일
0

seq2seq 란, 2개의 RNN 을 연결하여 하나의 시계열 데이터를 다른 시계열 데이터로 변환하는 것이다.

이번 장에서는, seq2seq 와 RNN 의 가능성을 높여주는, 어탠션 이라는 기술에 대해 학습해보자.

8-1. 어탠션의 구조

어탠션 매커니즘은 seq2seq 를 더 강력하게 해준다.

seq2seq가 인간처럼 필요한 정보에만 주목할 수 있게 해주고, 기존의 seq2seq 가 갖고있던 문제점도 해결가능하다.

seq2seq의 문제점은 무엇일까?

seq2seq 에서 encoder 가 시계열데이터를 인코딩하고, 그 인코딩된 정보를 decoder로 전달하는데, 이때 encoder 의 출력은 '고정 길이의 벡터'였다. 이 '고정 길이'에 문제점이 잠재해 있는 것이다. 아무리 입력 문장의 길이가 길다 하더라도 같은 길이의 벡터로 변환한다는 뜻이다. 이는 결국, 필요한 정보가 벡터에 다 담기지 못하는 문제가 생긴다. 그래서 우선 encoder를 개선하고 이어서 decoder 도 개선해야 한다.

Encoder 개선

우선, encoder를 개선해보자.

개선 포인트는, encoder 출력의 길이를 입력 문장의 길이에 따라 바꿔주는 것이다.
그러기 위해서, 시각별(=단어별) LSTM 계층의 은닉 상태 벡터를 모두 이용하는 것이다.
예를들어, 5개 단어가 입력되었으면, encoder는 5개의 벡터를 출력한다.

그러면 각 시각별 LSTM 계층의 은닉 상태에는 어떤 정보가 담겨있을까? 직전에 입력된 단어에 대한 정보가 많이 포함되어있을 것이다. 따라서, encoder가 출력하는 hs 행렬은 각 단어에 해당하는 벡터들의 집합일 것이다.

Decoder 개선

이어서 decoder를 개선해보자.

encoder 가 각 단어에 대응하는 LSTM 계층의 은닉 상태 벡터를 hs로 모아 출력하면, 이 hs는 decoder에 전달되어 시계열 변환이 이루어진다.

개선포인트는, decoder가 encoder 의 LSTM 계층의 마지막 은닉상태만을 이용하는 것이 아닌, hs를 전부 활용할 수 있도록 만드는 것이다.
그 전에 인간이 문장을 번역할 때 머릿속에서 어떤 일이 일어날까 생각해보자.우리는 '어떤 단어'에 주목하여 그 단어의 변환을 수시로 하게 된다.
이를 재현해야 한다. 다시 말하면, 입력과 출력의 여러 단어 중 어떤 단어끼리 서로 관련되어 있는가를 학습시켜야 한다. 즉, 필요한 정보에만 주목하여 그 정보로부터 시계열 변환을 수행해야 한다. 이 구조를 어탠션 이라고 부른다.

개선된 decoder 계층을 살펴보자.

'어떤 계산'을 수행하는 계층이 추가되었다.

'어떤 계산'의 입력으로 encoder로부터 받는 hs 와 시각별 LSTM 계층의 은닉상태를 받는다. 여기에서 필요한 정보만을 골라 Affine 계층으로 출력한다.

그런데 각 시각에서 decoder에 입력된 단어와 대응관계인 단어의 벡터를 hs에서 어떻게 골라내는데, 이런 '선택'작업(여러 대상으로부터 몇 개를 선택하는 작업)은 미분할 수 없다. 이런 선택 작업을 미분 가능한 연산으로 대체할 수는 없을까? 아주 간단하다. '하나를 선택'하는 것이 아닌, '모든 것을 선택'하는 것이다. 그리고 각 단어의 중요도를 나타내는 가중치를 별도로 계산하도록 한다. 각 단어의 중요도를 나타내는 가중치 a와 각 단어의 벡터 hs로부터 가중합을 구하여 우리가 원하는 벡터를 얻는다. 이를 멕락벡터 라고 한다.

이렇게 하면 가중치가 큰 단어의 성분을 많이 포함하고 있으면 그 단어를 선택한다고 해석할 수 있다.

여기서 가중치 a 가 있으면 가중합을 이용해 멕락벡터를 얻을 수 있다. 그런데 이 a는 어떻게 구해야 할까? 이 가중치는 decoder 의 LSTM 계층의 은닉상태를 h라 할때 hs의 각 단어 벡터와 얼마나 비슷한가를 수치로 나타낸 것이다. 이는 내적을 이용하여 두 벡터의 유사도를 표현하는 척도로 사용한다. 이 내적해서 나온 결과가 s 이다. s는 정규화하기 전의 값이며, 점수 라고도 한다. 정규화할때는 소프트맥스 함수를 적용한다.

자 이제 위에서 말한 두 방법, 가중치를 계산하는 계층가중합을 계산하는 계층을 합쳐 어탠션 계층을 정리해보자.

앞서 말한 어탠션 계층을 Attention Weight, 가중합 계산 계층을 Weight Sum 계층 두개로 나누어서 구현한 것이다.
Attention Weight 계층은 encoder가 출력하는 각 단어의 벡터 hs 에 주목해 해당 단어의 가중치 a 를 구하는 것이고, Weight Sum 계층이 ahs 의 가중합을 구해 최종적으로 멕락벡터 c를 출력한다. 이 모든 계산을 수행하는 계층을 Attention 계층이라고 한다.

어탠션 계층을 정리한 그림은 다음과 같다.

그리고, LSTM 계층의 은닉 상태 벡터에 어탠션 계층의 멕락벡터까지를 Affine 계층에 입력시킨다.

마지막으로, 시계열 방향으로 펼쳐진 다수의 Attention 계층을 Time Attention 계층으로 모아 구현한다. 그냥 다수의 Attention 계층을 모으면 된다.

8-2. 어탠션을 갖춘 seq2seq 구현

어탠션을 갖춘 seq2seq을 구현해보자.

  • Encoder 구현

    forward() 메소드는 LSTM 계층의 모든 은닉 상태 벡터를 반환한다.
    인수로 받고있는 Encoder 객체는 앞 장에 LSTM계층의 마지막 은닉 상태 벡터를 갖는데, 여기서 이를 상속받는다.
import sys
sys.path.append('..')
from common.time_layers import *
from ch07.seq2seq import Encoder, Seq2seq
from ch08.attention_layer import TimeAttention


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
  • Decoder 구현

    forward() 메서드에서 Time attention 계층의 출력과 LSTM 계층의 출력을 연결한다. 연결시에는 np.concatenate() 사용한다.
class AttentionDecoder:
    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]
        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.concatenate((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
  • seq2seq 구현

    AttentionEncoder 와 AttentionDecoder을 연결하여 AttentionSeq2seq 클래스를 완성한다.
class AttentionSeq2seq(Seq2seq):
    def __init__(self, vocab_size, wordvec_size, hidden_size):
        args = 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

8-3. 어탠션 평가

이 책에서는 '날짜 형식'을 변경하는 문제로 어텐션을 갖춘 seq2seq의 효과를 확인해 보았다. (데이터 크기가 작고, 어느 쪽인가를 맞추는 인위적인 문제)

번역용 데이터셋 중에서는 WMT 가 유명하여 seq2seq의 성능을 평가하는데 자주 이용되지만 크기가 크니(20GB) 부담된다.

자, 예를 들어 "september 27, 1994' 를 '1994-09-27' 같은 표준형식으로 변환해보자.

왜 하필 이문제냐? 이게 그렇게 보기보다 간단하지 않다. 변환 규칙이 나름 복잡하다. 그리고 질문과 답변 즉, 입력과 출력 사이에 알기 쉬운 대응관계가 있기 때문이다.

학습 데이터셋의 형식은 이러하다.

학습코드는 다음과 같다.

import sys
sys.path.append('..')
sys.path.append('../ch07')
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 attention_seq2seq import AttentionSeq2seq
from ch07.seq2seq import Seq2seq
from ch07.peeky_seq2seq import PeekySeq2seq


# 데이터 읽기
(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)
# model = Seq2seq(vocab_size, wordvec_size, hidden_size)
# model = PeekySeq2seq(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=True)

    acc = float(correct_num) / len(x_test)
    acc_list.append(acc)
    print('정확도 %.3f%%' % (acc * 100))


model.save_params()

# 그래프 그리기
x = np.arange(len(acc_list))
plt.plot(x, acc_list, marker='o')
plt.xlabel('에폭')
plt.ylabel('정확도')
plt.ylim(-0.05, 1.05)
plt.show()

이렇게 학습하면 1에폭부터 빠르게 정답률이 높아져 2에폭째에는 이미 거의 모든 문제를 풀어낸다.

이전 장들에서 다뤘던 단순한 seq2seq, 엿보기를 적용한 seq2seq 모델과 비교했을때 학습 속도 측면에서는 가장 빠르고, 정확도는 엿보기를 적용한 seq2seq과 동등했지만 현실의 시계열 데이터는 복잡하고 길기 때문에 어텐션이 매우 유리하다.

어탠션이 시계열 데이터를 변환할 때 어떤 원소에 주의를 기울이는지 보기 위해 어탠션을 시각화해보자.

학습이 끝난 AttentionSeq2seq로 날짜 변환을 수행할 때의 어텐션 가중치를 시각화한다.

가로축은 입력문장, 세로축은 출력문장. 맵의 각 원소는 밝을수록 값이 크다(1.0).

AUGUST 일때 08에 대응하고 있는 것을 볼 수 있다. seq2seq가 August가 8월에 대응한다는 사실을 데이터만 가지고 학습해낸것이다. 1983, 26도 1983과 26에 대응하고 있는 것을 볼 수 있다.

어탠션 모델은 인간이 이해할 수 있는 구조나 의미를 제공한다는 점에서 굉장한 의의가 있다. 어탠션을 사용해 단어와 단어의 관련성을 볼 수 있었으므로, 이를 보고 모델의 처리가 인간의 논리를 따르는지 판단이 가능하다.

8-4. 어탠션에 관한 남은 이야기

어탠션을 중심으로 더 발전된 기법을 살펴보자!

  • 양뱡향 RNN

    앞에서 단방향 RNN을 보았을 때, LSTM 의 각 시각의 은닉 상태 벡터는 hs로 모아진다. 그리고 Encoder가 출력하는 hs의 각 행에는 그 행에 대응하는 단어의 성분이 많이 포함되어있다. 글을 왼쪽에서 오른쪽으로 읽기때문에, '나는 고양이로소이다' 라는 문장에서 '고양이'에 대응하는 벡터에는 '나', '는'', '고양이' 까지 총 세 단어의 정보가 인코딩되어 들어간다. 하지만, 이렇게 하지말고, '고양이'단어의 '주변'정보를 균형있게 담고싶을때 LSTM을 양뱡향으로 처리하도록 한다. 이것이 양방향 LSTM 이다.
    전에 했던 LSTM 계층에, 역방향으로 처리하는 LSTM 계층도 추가한다. 각 시각에서는 이 두 LSTM 계층의 은닉상태를 '연결'시킨 벡터를 최종 은닉 상태로 처리한다.
    역방향으로 처리하는 LSTM 계층에 반대순서로 단어를 나열하여 넣는다. 즉, 오른쪽에서 왼쪽으로 처리한다. 이 두 계층을 연결하기만 하면 양뱡향 LSTM 계층이 나온다.

  • Attention 계층 사용 방법

    앞에선 Attention 계층을 LSTM 계층과 Affine 계층 사이에 삽입했지만,
    Attention 계층을 이용하는 장소가 정해져 있지는 않다.
    LSTM 계층 전에 넣어, LSTM 계층의 입력으로 사용할 수 있다.
    뭐가 더 정확도가 높냐는, 실제 데이터에 따라 다르다. 직접 해보기전에는 알 수 없다. 구현 관점에서는 전자의 구성이 쉽다.

  • seq2seq 심층화와 skip 연결

    seq2seq을 심층화 할때 쉽게 떠올릴 수 있는건 RNN층을 깊게 쌓는 방법이다. 그러면 표현력이 높은 모델을 만들 수 있다.
    보통은 Encoder와 Decoder 에서는 같은 층수의 LSTM 계층을 이용하는것이 일반적인데, 여러가지 변형을 하면서, Decoder의 LSTM 계층의 은닉 상태를 Attention 계층에 입력하고, Attention 계층의 출력인 맥락벡터를 Decoder의 여러 계층(LSTM 계층과 Affine계층) 으로 전파할 수 있다.
    skip 연결 은 층을 깊에 할때 사용하는 중요한 기법이다. 계층을 넘어(=계층을 건너 뛰어) '선을 연결'하는 단순한 기법이다. 이때 skip 연결의 접속부에서는 2개의 출력이 '더해'진다. 왜냐햐면, 덧셈은 역전파 시 기울기를 그대로 '흘려'보내므로, skip 연결의 기울기가 아무런 영향을 받지 않고 모든 계층으로 흐르기 때문이다. 따라서 층이 깊어져도 기울기가 소실되지 않고 전파되어 잘 학습된다.

8-5. 어탠션의 응용

최근 딥러닝 연구에서 어탠션이 중요한 기술로 다양한 방면에서 등장한다.
최첨단 연구 3가지를 소개한다.

  • 구글 신경망 기계 번역(GNMT)

    기계번역의 역사를 보면 다음과 같이 변화해왔다.
    규칙 기반 번역 -> 용례 기반 번역 -> 통계 기반 번역
    현재는 신경망 기계 번역(NMT) 이 주목받고 있다.
    계층 구성은 다음과 같다.

    우리가 앞서 배웠던 어탠션을 갖춘 seq2seq와 마찬가지로 Encoder, Decoder, Attention으로 구성되어있다. 다만, 여기에 번역 정확도를 높이기 위해 LSTM 계층의 다층화, 양방향 LSTM, skip 연결 등을 추가했다. 그리고 학습 시간을 단축하기 위해 GPU로 분산학습을 수행하고 있다. 이외에도 낮은 빈도의 단어처리나 추론 고속화를 위한 양자화 등의 연구도 이루어지고 있다. 이로써 점점 사람의 정확도에 가까워지고 있다.

  • 트랜스포머

    RNN의 단점 중 하나는 병렬처리다. RNN은 이전 시각에 계산한 결과를 이용하여 순서대로 계산하기 때문에 RNN 의 계산을 시간방향으로 병렬계산하기란 기본적으로 불가능하다. 이는 딥러닝 학습이 GPU를 사용한 병렬계산환경에서 이뤄진다는 점을 생각할때 큰 병목이다. 이것의 해결방안을 제안한 연구중 트랜스포머 기법이 유명하다. 셀프어탠션 기술을 이용해 어탠션을 구성하는 것이 핵심이다. '하나의 시계열 데이터 내에서 각 원소가 다른 원소들과 어떻게 관련되는지'를 살펴보자는 취지다. 앞서 보았던 어텐션에서는 2개의 서로다른 시계열 데이터(hs_enc, hs_dec) 사이의 대응관계를 구했으나, 셀프어텐션은 두 입력선이 하나의 시계열 데이터로부터 나온다.
    트랜스포머는 RNN 대신 어텐션을 사용한다. encoder, decoder 모두 셀프어텐션을 사용한다. 또 피드포워드 신경망(시간 방향으로 독립적으로 처리하는 신경망) 을 넣는다. 은닉층 1개, 활성화 함수로 ReLU 를 이용해 완전연결계층 신경망을 이용한다.

  • 뉴럴 튜링 머신(NMT)

    외부 메모리를 통한 확장을 통해 성능을 끌어올릴 수 있다. RNN 과 LSTM 은 내부 상태를 활용해 시계열 데이터를 기억할 수 있었으나 길이가 고정이어 채워넣을 수 있는 정보량이 제한적이어서, RNN 외부 기적장치를 두고 필요한 정보를 거기에 적절히 기록하는 방안을 찾아낸 것이다. '어텐션'을 통해 Encoder와 Decoder는 '메모리 조작' 같은 작업을 수행한다. Encoder는 필요한 정보를 메모리에 쓰고, Decoder는 그 메모리로부터 필요한 정보를 읽거나 쓴다. 이렇게 해서 컴퓨터의 메모리 조작을 신경망에서도 재현 가능하다.
    이것에 대한 연구 중 유명한 것이 NMT 다.
    큰 흐름에서 중요한것이 '컨트롤러' 모듈인데, 이는 정보를 처리하는 모듈로 신경망(혹은 RNN 을 이용한다. 이 컨트롤러에서 바깥에 있는 메모리에서 정보를 읽고, 신경망에서 계산하고, 출력하는 것이다. 다시 NMT로 생각해보면, NMT 는 외부 메모리를 읽고 쓰면서 시계열 데이터를 처리한다. 그리고 이러한 메모리 조작을 '미분 가능한' 계산으로 구축했다. 따라서 메모리 조작 순서도 데이터로부터 학습이 가능하다. NMT는 컨트롤러를 LSTM 계층이 수행한다. 각 시각에서 LSTM 계층의 은닉 상태를 Write Head 계층이 받아서 필요한 정보를 메모리에 쓴다. 그런다음 Read Head 계층이 메모리로부터 중요한 정보를 읽어 들여 다음 시각의 LSTM 계층으로 전달한다.
    Write Head 와 Read Head 계층이 어떻게 메모리를 조작하냐고? 이것도 어텐션을 사용한다. 2개의 어텐션, '콘텐츠 기반 어텐션'과 '위치 기반 어텐션'을 사용한다.
    콘텐츠 기반 어텐션은 지금까지 본 어텐션과 같고, 입력으로 주어진 질의 벡터와 비슷한 벡터를 메모리에서 찾아낸다. 위치 기반 어텐션은 이전 시각에서 주목한 메모리의 위치를 기준으로 그 전후로 이동(=시프트)하는 용도로 사용된다. 메모리를 읽어 나가는 컴퓨터 특유의 움직임을 재현했다고 볼 수 있다.

profile
DSC Duksung 겨울방학 NLP 스터디

0개의 댓글