🔊 해당 포스팅은 밑바닥부터 시작하는 딥러닝 2권의 교재 내용을 기반으로 자연어처리 딥러닝 신경망을 Tensorflow, Pytorch와 같은 딥러닝 프레임워크를 사용하지 않고 순수한 Numpy로 구현하면서 자연어 처리의 기초를 탄탄히 하고자 하는 목적 하에 게시되는 포스팅입니다. 내용은 주로 필자가 중요하다고 생각되는 내용 위주로 작성되었음을 알려드립니다.
저번 포스팅에서는 2개의 RNN을 이어 붙힌 seq2seq 모델에 대해 배웠다. seq2seq 모델은 각각 RNN으로 이루어져있는 Encoder, Decoder로 구성되어 있다는 것과 각 역할에 대해서도 알아보았다. 그리고 이 seq2seq 모델의 성능을 개선하는 2가지의 '작은' 기법으로서 입력 시퀀스(출발어)를 반전시켜 Encoder에 넣는 기법, 기존의 Decoder에서 Encoder가 전달해주는 은닉 상태($h$)를 Decoder 내의 모든 RNN, Affine 계층도 직접적으로 활용할 수 있도록 해주는 '엿보는' 디코더인 Peeky Decoder로 변환해주는 기법을 활용했다. 그리고 이 2가지 개선 기법은 seq2seq 모델의 학습 속도 향상 뿐만 아니라 정확도의 향상이라는 놀라운 효과도 보여주었다.
하지만 이러한 '작은' 개선 기법들을 적용한 seq2seq 모델은 실제 다양한 도메인의 데이터에 따라 성능이 왔다갔다 하기도 하고 하이퍼파라미터에 매우 민감하다는 단점이 존재한다. 그래서 이번에는 seq2seq 모델을 '크게' 개선하는 기법으로서 어텐션(Attention) 기법이 등장한다. 이제 이 책의 마지막 챕터인 어텐션 기법이 무엇인지 살펴보도록 하자! 그리고 마지막엔 이 어텐션 기법이 적용된 seq2seq 모델을 넘파이로 구현한 소스코드를 링크로 첨부해놓았으니 궁금한 분들은 참조하면 좋을 듯 하다.
1. 어텐션 메커니즘
어텐션 메커니즘은 결론부터 말하면 seq2seq 모델이 마치 인간처럼 필요한 정보에만 '주목(Attention)' 하도록 해준다. 그리고 이 메커니즘이 기존의 seq2seq 모델이 갖고 있던 문제를 해결해준다. 그렇다면 기존의 seq2seq 모델이 갖고 있던 문제점이 무엇일까? 문제점이 무엇인지 이해하는 과정이 수반되면 분명 이를 해결하는 어텐션을 이해하는 데 수월할 것이다.
1-1. 기존 seq2seq 모델의 문제점
여기서 기존 seq2seq 모델이라고 한다면 문두에서 언급한 저번 포스팅에서 배운 2가지 작은 개선 기법을 적용하기 이전의 seq2seq 모델을 의미한다고 가정한다.
(2가지 작은 개선 기법을 적용한 seq2seq에 어텐션을 적용할 수도 있겠지만 2가지 작은 개선 기법이 지금 배우고 있는 큰 개선 기법인 어텐션과는 독립적인 기법이기에 기존 seq2seq 모델을 2가지 작은 개선 기법을 적용하기 이전인 가장 기본적인 형태의 seq2seq 모델로 정의하도록 한다)
기존 seq2seq 모델의 문제점은 Encoder에서 발생한다. 저번 포스팅에서도 알아보았듯이 동일한 Encoder에는 서로 다른 길이의 시퀀스가 입력되어도 Encoder를 통해 나오는 최종 은닉 상태 $h$는 항상 모두 동일한 길이의 벡터여만 했다. 아래그림처럼 말이다.
위와 같은 Encoder는 입력 문장의 길이가 몇이든 관계없이 항상 같은 길이의 벡터 $h$로 변환한다. 이렇게 하면 엄청나게 긴 길이의 문장을 입력시켜도 Encoder는 정해진 길이의 벡터 $h$로 어떻게든 압축해서 변환해야 한다. 이런 변환 과정은 만약 엄청나게 긴 길이의 문장이 입력되었을 때 나온 $h$에는 긴 입력 문장에 대한 정보가 잘 담길 리가 없을 것이다.
위 그림을 예시로 들어보자. 보라색 밑줄 칸의 '이름은 아직 없어' 라는 문장을 입력으로 넣어주었을 때, 노드가 4개로 이루어진 벡터 $h$가 나왔다. 이 때는 딱히 문제가 될 것이 없다. $h$에는 '이름은 아직 없어' 라는 문장에 대한 정보가 잘 담겨 있을 것이다. 문제는 초록색 밑줄 칸의 '어디서 태어났는지 도무지 모르겠네' 문장을 입력으로 넣을 경우이다. '이름은 아직 없어' 라는 문장을 입력으로 넣었을 때와 동일한 Encoder 모델을 사용하기 때문에 '어디서 태어났는지 도무지 모르겠네' 문장을 넣은 후에 나오는 벡터 $h$의 길이도 동일할 수 밖에 없다. 이렇게 되면 상대적으로 길이가 긴 '어디서 태어났는지 도무지 모르겠네' 문장을 입력시킨 후 나오는 벡터 $h$에는 해당 문장에 대한 정보를 최대한 많이 담을 수가 없을 것이다. 마치 작은 청바니 주머니에 큰 물건을 넣으려고 하면 그 물건의 일부분이 튀어나오는 것처럼 말이다.
그러면 이러한 Encoder의 문제점을 개선시키는 방법에 대해 알아보자.
1-2. 입력 문장의 길이에 비례하게 정보를 인코딩하도록 Encoder 개선하기!
[1-1. 목차]에서 언급한 Encoder에서 발생한 문제점을 해결하기 위해서는 Encoder에 들어가는 입력 시퀀스 길이에 따라 Encoder가 출력하는 길이를 다르게 해주면 된다. 다시 말해, Encoder 내의 RNN 계층들이 내뱉는 은닉 상태($h$)들을 모두 이용하면 된다. 이 말을 이해하기 위해서 아래의 2가지 비교 그림을 살펴보자.
그림의 왼쪽은 기존 Encoder 구조로, 입력 시퀀스 길이에 상관없이 고정된 길이의 은닉 상태 $hs$를 내뱉는 모습이다. 이를 개선한 방법이 오른쪽 그림이다. 보면 알겠지만 Encoder 내의 모든 LSTM 계층들이 내뱉는 은닉 상태들을 모두 이용해 $hs$를 구성한다. 이러한 개선된 구조를 적용하면 입력 시퀀스 길이에 따라, 해당 예시에서는 단어를 단위로 [나, 는, 고양이, 로소, 이다] 총 5개의 단어가 들어가고 5개의 은닉 상태가 구해진 것을 볼 수 있다. 이러한 개선 방법을 통해서 하나의 고정된 길이 벡터만을 출력해야 하는 기존 Encoder 구조의 제약에서 벗어날 수 있게 된다.
그렇다면 위 새로운 Encoder 구조가 내뱉은 은닉 상태들의 모음인 $hs$에는 어떤 정보가 담겨있을까? 해당 은닉 상태 벡터를 출력하기 위해 입력으로 들어간 단어에 대한 직접적인(주요) 정보가 있을 것이라고 예상할 수 있다. 아래처럼 말이다
참고로 위처럼 왼쪽에서 오른쪽 방향으로만 고려하지 않고 뒤의 입력까지 전체적으로 고려하고 싶다면 시퀀스를 양방향으로 처리하는 양방향 LSTM(Bidirectional LSTM) 계층도 고려할 수 있다.(양방향 LSTM 계층을 고려한 seq2seq는 다음 포스팅에서 알아본다)
지금까지 어텐션 메커니즘을 구현하기 위해 기존 Encoder에서 개선하는 부분에 대해 살펴보았다. 이렇게 개선함으로써 입력 문장의 길이에 비례한 정보를 Encoder가 잘 인코딩하여 Decoder에 전달할 수 있을 것이다. 그러면 이렇게 변경된 Encoder 구조가 내뱉은 은닉 상태($hs$)를 Decoder는 어떻게 받아들여야할까? 이제 Decoder를 개선해보자. Decoder를 개선하는 부분에 대해서는 (책에서도 그랬듯이) 3가지 파트로 나누어서 설명하기로 하자.
1-3. 어떤 것에 '주목!' 하도록 선택 작업 하기, Decoder 개선(1)
기존 Decoder는 기존 Encoder가 내뱉는 최종 은닉 상태만을 가져가 출력 시퀀스(도착어)를 생성하는 방식으로 동작했다. 아래 그림은 [1-2. 목차]에서 배운 개선된 Encoder를 사용하되 Decoder는 기존 구조를 사용했을 때의 구조를 나타낸다.
위 그림에서는 빨간색 네모칸인 $hs$의 마지막 5번째 행에 있는 벡터가 Encoder의 마지막 LSTM 계층의 은닉 상태이다. 그래서 기존 Decoder는 빨간색 네모칸의 은닉 상태만을 활용해 출력 시퀀스를 생성했었다. 그러면 이 기존 Decoder 구조에서 어떻게 개선할까?
구체적인 개선 기법에 대해 알아보기 전에 한 가지 생각해볼 점이 있다. 보통 사람이 번역을 할때, 예를 들어, "나는 고양이 로소 이다" 를 영어 문장으로 번역한다고 가정해보자. 번역 결과는 "I am a cat" 일텐데, 사람이 번역할 때, 본능적으로 '나' 라는 단어는 'I' 와 대응되고 '는' 이라는 단어는 'am' 이라는 단어와 대응될 것이고.. '고양이' 이라는 단어는 'cat' 단어와 대응된다고 판단할 것이다. 즉, 사람은 A라는 단어를 번역할 때, 그 A 단어와 대응되는 단어들(B,C,D,E, ... 여러 단어들이 존재할 것임)을 계속 찾아보면서 이 중에 B라는 단어가 A와 가장 관계가 깊고 대응된다고 판단한 후, [A 단어 ➡︎ B 단어]로 번역할 것이다. 이 말은 곧 A 단어를 번역하기 위해서 B 단어에 특히 '주목(Attention)'하여 A 단어를 B 단어로 변환한 것이다!
따라서, 위와 같이 사람이 번역할 때 거치는 사고 과정처럼 입력과 출력의 각 단어들 간에 서로 어떤 단어들끼리 연관되어 있는가라는 '대응 관계'를 seq2seq 모델에 학습시키자는 것이 바로 어텐션 메커니즘의 핵심이 된다. 이렇게 단어들 간의 대응 관계를 Phrase alignment(얼라인먼트)라고 부른다. 어텐션이 등장하기 전까지는 이 얼라인먼트를 사람이 수작업으로 해왔다. '고양이' = 'cat' , '강아지' = 'dog' 처럼 말이다. 그런데 어텐션은 이러한 얼라인먼트 작업을 신경망 모델이 알아서 학습해 자동화시키는 셈이다.
자, 이제 어텐션 메커니즘에서 '주목' 이라는 키워드가 무엇을 의미하는지도 알아보았다. 그러면 개선된 Decoder 구조의 전체적인 그림을 살펴보면서 앞으로 설명할 Decoder 개선기법(1)이 그림에서 어떤 부분을 나타내는지 보자.
개선된 Decoder도 기존 Decoder의 특징을 계승하기는 한다. 즉, 위 그림의 노란색 화살표 처럼 Encoder가 내뱉은 은닉 상태 중 마지막 은닉 상태를 입력으로 활용한다. 이번 Decoder의 개선 기법 3가지는 파란색 화살표가 가리키는 '어떤 계산'이라는 계층 안에서 모두 수행된다. 그렇다면 '어떤 계산' 계층 하나만 확대해보자.
'어떤 계산' 계층 내부를 보면 ①선택 작업 계층, ②가중치($a$) 계산 계층, ③결합 계층 총 3가지로 구성되어 있는 것을 볼 수 있다. 이 3가지가 바로 Decoder를 개선하는 3가지 기법이 된다. 이번 [1-3. 목차] 에서 소개할 것은 ①선택 작업 계층이다.
이 ①선택 작업 계층은 2가지를 입력으로 받는다. Encoder가 내뱉은 은닉 상태 벡터를 모두 결합한 $hs$와 아직은 어떻게 구한 것인지 모르지만 어쨌건 ②가중치($a$) 계산 계층에서 흘러나온 $a$이다. 입력받은 2가지를 ①선택 작업 계층을 통해 계산을 한 후 나온 결과값($c$)를 위의 Affine 계층에 전달한다.
여기서 ①선택 작업 계층이 하고 싶은 일은 위에서 언급한 Phrase alignment(얼라인먼트)이다. 즉, Decoder에서 각 시각마다 입력된 단어와 대응 관계에 있는 단어의 정보를 담고 있는 벡터를 $hs$ 중에서 골라낸다는 것이다.
위 문단에서 그림 이름이 [개선된 Decoder의 전체적인 구조] 인 것을 예시로 하여 이해해보자. Decoder에서 'I' 라는 단어가 입력되었다. 그러면 ①선택 작업 계층에서는 다음과 같은 일이 벌어진다. ①선택 작업 계층은 $hs$를 입력으로 받는데, 여기서 $hs$는 Encoder에 입력된 시퀀스 즉, [나, 는, 고양이, 로소, 이다] 라는 5개의 단어 각각의 정보를 담고 있는 5개의 은닉 상태를 의미한다. 다시 말해, 5개의 은닉 상태가 각각 5개의 입력 단어를 의미한다고 보면 된다.(엄밀히 말하면 5개 입력 단어 각각에 대한 주요 정보를 5개의 은닉 상태가 각각 갖고 있는 것) 그러면 ①선택 작업 계층은 Decoder에 입력된 'I' 라는 단어와 가장 관련이 깊은 은닉 상태를 $hs$ 중에서 1개를 선택한다는 것이다. 결국 ①선택 작업 계층에서 '선택 작업'이라는 키워드가 들어간 이유가 이 때문이다.
그런데 한 가지 문제가 있다. 이렇게 한 가지만 고른다는 '선택' 한다는 작업은 미분할 수 없다는 점이다. 미분이 갑자기 언급된 이유는 뭘까? 우리는 개선된 Decoder를 통해 얼라인먼트 작업 즉, '선택 작업'을 학습시켜야 한다. 학습시키려면 오차역전파를 이용해야 한다. 그런데 오차역전파를 이용하려면 미분가능한 연산으로 신경망을 구축해야 하기 때문이다.
그래서 '선택 작업'을 미분가능한 연산으로 변환하기 위해서는 '1개만 선택' 하는 것이 아닌 '모든 것을 선택' 하는 관점으로 바꾸면 된다. 단, 모든 것을 선택하긴 하는데, 각 은닉 상태마다 가중치를 부여하여 선택해야 한다. 따라서 ①선택 작업 계층은 바로 이 '가중치'라는 것을 활용해 각 은닉 상태마다 가중치를 부여하는 것이다. '선택 작업'을 간단하게 도식화하면 아래와 같다. 아래 그림의 $hs$는 Encoder가 내뱉은 은닉 상태의 모음, $a$는 가중치를 의미한다.
위 그림에서 각 은닉 상태의 가중치인 $a$를 보면 확률 값처럼 0.0 ~ 1.0 사이의 스칼라 값임을 알 수 있다. 그리고 $a$의 값 총 합은 항상 1이다. 아직 가중치인 $a$가 어떻게 구해졌는지는 잘 모르지만(②가중치($a$) 계산 계층에서 배울 예정) 어쨌거나 구한 가중치 $a$를 아래처럼 각 은닉 상태에 곱해준 후 동일한 위치의 인덱스 값끼리 더해준다. $hs$와 $a$를 Weightd sum 하는 과정을 그림으로 나타내면 아래와 같다.
위와 같이 $hs$와 $a$를 Weightd sum 하게 되서 나온 $c$ 라는 벡터를 '맥락 벡터'라고 부른다. 그러면 $c$에는 어떤 단어에 대한 정보가 많이 들어있을까? 위 그림에서는 '나' 라는 단어에 대한 가중치가 0.8로 가장 높기 때문에 아마 $c$ 에는 '나' 라는 단어에 대한 정보가 많이 포함되어 있을 것이다.
그러면 ①선택 작업 계층을 계산 그래프로 나타낸 그림을 보자. 이 그림을 보면 넘파이로 구현할 때 매우 수월하게 할 수 있다.
위 그림에서 헷갈리지 말아야 할 점은 바로 $T$ 이다. 가중치 $a$의 형상을 보고 "아니 Decoder에서 나온 가중치인데 어떻게 (Encoder에 들어가는) 입력 시퀀스 길이인 $T$가 있지?" 할 수 있을 것이다. 이렇게 되는 이유는 ②가중치($a$) 계산 계층에서 알아볼 예정이니 지금은 신경쓰지말자.
그리고 계산 그래프를 마저 설명하면, 예전 포스팅에서 배웠던 Repeat, Sum 연산 노드로 구성되어 있다. 각 연산 종류에 따른 순전파, 역전파를 적용하여 넘파이로 구현한 ①선택 작업 계층 코드는 아래와 같다.
# (1) 선택 작업 계층
class WeightedSum:
""" Encoder의 모든 은닉 상태 hs 와 LSTM 계층에서 흘러나온 가중치 a 간의 Weighted Sum
"""
def __init__(self):
self.params, self.grads = [], []
self.cache = None
def forward(self, hs, a):
""" Weighted Sum 순전파 수행
Args:
hs: Encoder의 모든 은닉 상태 hs
a: RNN 계층에서 출력한 은닉상태(현재 shape: (batch_size, 은닉상태 차원 수)
"""
N, T, H = hs.shape # (bacth_size, 입력시퀀스 길이, 은닉상태 차원 수)
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):
""" Weighted Sum 역전파 수행
Args:
dc: 순전파 시, Affine 계층으로 전달한 맥락 벡터 c의 기울기 값
"""
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
1-4. 각 은닉 상태의 가중치($a$)를 구하자!, Decoder 개선(2)
이번엔 ②가중치($a$) 계산 계층을 구현해보자. 우선 ②가중치($a$) 계산 계층은 Encoder의 은닉 상태 모음인 $hs$와 LSTM 계층의 출력값인 $h_{LSTM}$ 2개를 입력으로 받는다. 입력을 받는 데이터의 형상을 좀 더 구체화해서 ②가중치($a$) 계산 계층을 표시하면 아래와 같다.
위 그림의 파선으로 되어있는 $hs$와 $h_{LSTM}$의 벡터 형상을 살펴보자. 저 두 개의 입력 데이터로 우리는 $a$라는 가중치를 만들어야 한다. 위 그림에서 현재 입력 데이터가 <eos> 이다. 즉, 문장이 시작된다는 뜻이다. 그리고 <eos>를 LSTM 계층에 넣은 후에 $h_{LSTM}$ 이라는 은닉 상태가 나왔다. 이 때, 우리가 해야할 일은 이 $h_{LSTM}$이 $hs$에서의 각 은닉 상태와 얼마나 비슷한가(관련이 있는가)를 하나의 수치로 표현하는 가중치($a$)를 만들어야 한다. 그러면 이 '비슷한가'의 척도를 나타내기 위한 방법은 무엇일까?
여러가지 방법이 있을 테지만, 여기서는 벡터의 내적을 이용하는 것이다. 우리가 예전 포스팅에서 코사인 유사도를 배웠던 적이 있다. 코사인 유사도는 두 벡터의 크기는 신경쓰지 않고 두 벡터가 가리키는 방향 간의 각도를 구함으로써 두 벡터의 유사도를 계산하는 방법이었다. 그런데 각도를 구할 때, 두 벡터를 내적해서 구했다. 따라서 여기서도 $hs$와 $h_{LSTM}$ 이라는 두 벡터간에 동일하게 적용하는 것이다. 바로 내적으로 말이다!
따라서 $hs$와 $h_{LSTM}$ 두 벡터 간의 내적으로 구해서 가중치 $a$를 구하는 방법은 아래와 같다.
그러면 ②가중치($a$) 계산 계층에 대한 계산 그래프를 살펴보자.
이 계산 그래프를 보면 아까 [1-3. 목차]의 ①선택 작업 계층 계산 그래프를 살펴보았을 때, 왜 가중치 $a$의 형상이 입력 시퀀스 길이인 $T$로 구성되어 있는지를 알 수 있다. 즉, 가중치 개수는 $hs$ 안에 있는 은닉 상태 벡터의 개수와 동일해야 한다. 그런데 $hs$는 입력 시퀀스의 은닉 상태이다. 따라서 가중치 개수는 입력 시퀀스 길이와 동일해야 하므로 ②가중치($a$) 계산 계층에서 $T$개 만큼 복제해서 ①선택 작업 계층으로 넘겨준다.
위 계산 그래프도 마찬가지로 Repeat, Sum 연산 노드로 구성되어 있다. Softmax 연산은 밑시딥 1권에서 이미 배웠다. 따라서 배운 내용을 그대로 적용해 넘파이로 구현하면 아래와 같다.
# (2) 가중치(a) 계산 계층
class AttentionWeight:
def __init__(self):
self.params, self.grads = [], []
self.softmax = softmax()
self.cache = None
def forward(self, hs, h):
""" 가중치 계산 계층에서의 순전파 수행
Args:
hs: Encoder의 모든 은닉 상태 hs
h: RNN 계층에서 출력한 은닉상태(현재 shape: (batch_size, 은닉상태 차원 수)
"""
N, T, H = hs.shape
hr = h.reshape(N, 1, H).repeat(T, axis=1) # RNN 계층에서 나온 은닉상태 repeat
# 내적 수행하여 Encoder의 각 은닉 상태와 RNN 계층에서 나온 은닉상태 간의 유사도 각각 계산
t = hs * hr
s = np.sum(t, axis=2)
a = self.softmax.forward(s)
self.cache = (hs, hr)
return a
def backward(self, da):
""" 가중치 계산 계층에서의 역전파 수행
Args:
da: (1) 선택 작업 계층으로 보낸 가중치(a)의 기울기
"""
hs, hr = self.cache
N, T, H = hs.shape
ds = self.softmax.backward(da)
dt = ds.reshape(N, T, 1).repeat(H, axis=2) # sum의 역전파
dhr = dt * hs
dhs = dt * hr
dh = np.sum(dhr, axis=1)
return dhs, dh
1-5. 두 가지 Decoder 개선 기법을 결합하고 기존 Decoder의 성격을 계승! , Decoder 개선(3)
마지막 Decoder의 세번째 개선 기법은 간단하다. [1-3, 1-4 목차]에서 배운 ①선택 작업 계층, ②가중치($a$) 계산 계층을 결합해 하나의 클래스로 구현하면 된다. 그리고 기존 Decoder의 성격을 계승하면 된다. 여기서 기존 Decoder의 성격이란, 매 LSTM 계층마다 출력되는 은닉 상태($h_{LSTM}$)를 Affine 계층에 입력시켜주는 것이다. 우선은 [1-3. 목차]에서 살펴본 '개선된 Decoder의 전체적인 구조' 라는 이름의 그림 자료를 다시 살펴보자.
위 그림 상으로는 LSTM 계층에서 흘러나오는 $h_{LSTM}$이 '어떤 계산' 계층으로만 들어간다고 나와있다. 하지만 여기서 $h_{LSTM}$을 직접적으로 Affine 계층으로 입력하는 구조를 추가해준다. 추가해준 그림은 아래와 같다.(추가된 선은 빨간색으로 표기)
이렇게 되면 Affine 계층은 두 개의 데이터를 입력으로 받는다. '어떤 계산' 계층(Attention 계층)을 거쳐 나온 맥락 벡터 $c$ 와 $h_{LSTM}$ 두 개이다. Affine 계층이 두 개를 입력받는 과정은 저번 포스팅의 Peeky Decoder에서도 살펴보았다. 여기서도 입력받는 과정은 동일하다. 즉, 입력받는 두 개를 연결(concatenate)해서 하나의 입력으로 만들어 준 후, Affine 계층에 입력시키는 것이다. 그리고 두 입력을 연결(concatenate)해주었기 때문에 하나로 만들어진 벡터의 형상은 늘어났을 것이다.
이것이 바로 기존 Decoder의 성격을 계승한다는 것이다. 그리고 ①선택 작업 계층, ②가중치($a$) 계산 계층을 결합하면 Decoder의 마지막 개선 기법은 끝이다. ①선택 작업 계층, ②가중치($a$) 계산 계층을 결합하는 계산 그래프는 아래와 같다.
위 그림에서 빨간색 화살표로 역전파 과정을 나타내보았다. 빨간색 글씨의 기울기 변수는 아래 ③결합계층의 넘파이 소스코드 역전파(backward 함수) 부분을 이해할 때 도움이 될 것이다. ③결합계층을 넘파이로 구현한 소스코드는 아래와 같다. ①선택 작업 계층, ②가중치($a$) 계산 계층은 이미 위에서 구현한 클래스를 사용했다.
# (3) 결합계층
class Attention:
""" (1) 은닉상태, 가중치 간 Weighted sum 계층, (2) 가중치 계산 계층을 결합하는 클래스
"""
def __init__(self):
self.params, self.grads = [], []
self.attention_weight_layer = AttentionWeight() # (2) 가중치 계산 계층
self.weight_sum_layer = WeightedSum() # (1) 은닉상태, 가중치 간 Weigted sum 계층
self.attention_weight = None
def forward(self, hs, h):
""" (3) 결합 계층의 순전파
Args:
hs: Encoder의 모든 은닉 상태 hs
h: RNN 계층에서 출력한 은닉상태(현재 shape: (batch_size, 은닉상태 차원 수)
"""
# (2) 가중치 계산
a = self.attention_weight_layer.forward(hs, h)
# (1) Weighted sum 계층
out = self.weight_sum_layer.forward(hs, a)
self.attention_weight = a
return out
def backward(self, dout):
"""(3) 결합 계층의 역전파
Args:
dout: Affine 계층으로부터 흘러들어오고 있는 국소적인 미분값
"""
# 순전파 시, hs가 분기(repeat)되어 (1),(2) 계층으로 흘러들어갔으므로 역전파 시 sum!
dhs0, da = self.weight_sum_layer.backward(dout)
dhs1, dh = self.attention_weight_layer.backward(da)
dhs = dhs0 + dhs1
return dhs, dh
참고로 이번 목차에서는 '기존 Decoder의 성격을 계승' 하는 부분을 소스코드로 구현하는 부분은 나오지 않는다. 왜냐하면 나중에 Attention 메커니즘을 활용해 개선한 seq2seq 모델 전체 구조를 구현할 때 추가해주어야 하기 때문이다.
위 내용을 기반으로 하여 ①선택 작업 계층, ②가중치($a$) 계산 계층, ③결합계층을 넘파이로 구현한 코드는 여기, 이를 활용해서 어텐션 메커니즘을 적용한 seq2seq 모델을 만드는 코드는 여기, 그리고 어텐션 메커니즘을 적용한 seq2seq 모델을 예시 데이터로 학습시키고 결과를 확인하는 코드는 여기를 참조하도록 하자.
이렇게 해서 기존 seq2seq 모델(Encoder - Decoder 모델)을 '크게' 개선하는 기법으로 어텐션(Attention) 메커니즘을 적용한 seq2seq 모델에 대해 알아보았다. 구체적으로는 Encoder에서 한 부분을 개선하고, Decoder 에서는 3가지 부분을 개선함으로써 기계가 정말 인간처럼 필요한 곳에만 '주목'하는 메커니즘을 만들 수 있었다.
이제 다음에 게시할 포스팅이 밑시딥 2권의 마지막 내용일 듯 하다. 마지막 포스팅에서는 이번에 배운 어텐션 메커니즘을 적용한 seq2seq 모델을 좀 더 응용한 방법들에 대해서 알아보도록 하자.
'Data Science > 밑바닥부터시작하는딥러닝(2)' 카테고리의 다른 글
[밑시딥] RNN의 역할도 어텐션으로 대체한 seq2seq 모델, Transformer (4) | 2022.01.17 |
---|---|
[밑시딥] 어텐션을 적용한 seq2seq을 한층 더 응용해보자 (0) | 2022.01.02 |
[밑시딥] RNN을 사용한 문장 생성, 그리고 RNN을 이어 붙인 seq2seq(Encoder-Decoder) (0) | 2021.12.26 |
[밑시딥] 게이트를 추가한 GRU와 RNN LM 성능 개선 방법 (0) | 2021.12.21 |
[밑시딥] 게이트가 추가된 RNN, LSTM(Long-Short Term Memory) (2) | 2021.12.16 |