딥러닝

밑바닥부터 시작하는 딥러닝 - seq2seq(1)

Leesemo 2021. 11. 4. 20:58

이번 글에서는 시계열 데이터를 다른 시계열 데이터로 변환하는 모델을 생각해볼 것이다. 이를 위한 기법으로, 2개의 RNN을 이용하는 seq2seq라는 기법을 살펴볼 것이다.

 

seq2seq의 원리

seq2seq를 Encoder-Decoder 모델이라고도 한다. Encoder는 입력 데이터를 인코딩하고, Decoder는 인코딩된 데이터를 디코딩(복호화)한다.

더보기

인코딩(부호화)이란 정보를 어떤 규칙에 따라 변환하는 것이다. 문자 코드를 예로 들면, 'A'라는 문자를 '1000001'이라는 이진수로 변환하는 식이다. 한편, 디코딩(복호화)이란 인코딩된 정보를 원래의 정보로 되돌리는 것이다. 문자코드를 예로 들면, '1000001'이라는 비트 패턴을 'A'라는 문자로 변화하는 일이 바로 디코딩이다.

그럼 seq2seq의 구조를 구체적인 예를 들어 설명해보자. 우리말을 영어로 번역하는 예를 생각해보자.

'나는 고양이로소이다'라는 문장을 'I am a cat'으로 번역하면 아래 그림에서 보듯이 Encoder와 Decoder가 시계열 데이터를 변환한다.

위의 그림처럼 먼저 Encoder가 '나는 고양이로소이다'라는 출발어 문장을 인코딩한다. 이어서 그 인코딩한 정보를 Decoder에 전달하고, Decoder가 도착어 문장을 생성한다. 이때 Encoder가 인코딩한 정보에는 번역에 필요한 정보가 조밀하게 응축되어 있다. Decoder는 조밀하게 응축된 이 정보를 바탕으로 도착어 문장을 생성하는 것이다.

seq2seq는 Encoder와 Decoder가 협력하여 시계열 데이터를 다른 시계열 데이터로 변환하는 것이다. 그리고 Encoder와 Decoder로는 RNN을 사용할 수 있다. 

Encoder의 처리 계층은 아래 그림과 같다.

Encoder는 RNN을 이용해 시계열 데이터를 h라는 은닉 상태 벡터로 변환한다. 위의 그림에서는 RNN으로써 LSTM을 이용했지만, 단순한 RNN이나 GRU 등도 이용할 수 있다. 그리고 여기에서는 우리말 문장을 단어 단위로 쪼개 입력한다고 가정한다.

그런데 Encoder가 출력하는 벡터 h는 LSTM 계층의 마지막 은닉 상태이다. 이 마지막 은닉 상태 h에 입력 문장(출발어)을 번역하는 데 필요한 정보가 인코딩된다. 여기서 중요한 것은 LSTM의 은닉 상태 h는 고정 길이 벡터라는 사실이다.

그래서 인코딩한다라함은 결국 임의 길이의 문장을 고정 길이 벡터로 변환하는 작업이 된다.

위의 그림에서 보듯이 Encoder는 문장을 고정 길이 벡터로 변환한다.

위의 그림과 같이 Decoder는 앞에서 배운 신경망과 같은 구성이다. LSTM 계층이 벡터 h를 입력받는다는 점만 다르다.

위의 그림은 Decoder와 Encoder를 연결한 계층 구성이다.

seq2seq는 LSTM 두 개로 구성된다. 이때 LSTM 계층의 은닉 상태가 Encoder와 Decoder를 이어주는 '가교'가 된다. 순전파 때는 Encoder에서 인코딩된 정보가 LSTM 계층의 은닉 상태를 통해 Decoder에 전해진다. 그리고 seq2seq의 역전파 때는 이 '가교'를 통해 기울기가 Decoder로부터 Encoder로 전해진다.

 


시계열 데이터 변환용 장난감 문제

지금부터 시계열 변환 문제의 예로 '더하기'를 다룰 것이다. '57+5'와 같은 문자열을 seq2seq에 건네면 '62'라는 정답을 내놓도록 학습시킬 것이다.

 

가변 길이 시계열 데이터

여기서 주의할 점은 덧셈 문장("57+5"나 "628+521" 등)이나 그 대답("62"나 "1149" 등)의 문자 수가 문제마다 다르다는 것이다.

덧셈 문제에서는 샘플마다 데이터의 시간 방향 크기가 다르다. 즉 "가변 길이 시계열 데이터"를 다룬다는 것이다.

따라서 신경망 학습 시 "미니배치 처리"를 하려면 어떠한 것을 추가시켜야 한다.

 

가변 길이 시계열 데이터를 미니배치로 학습하기 위한 가장 단순한 방법은 패딩을 사용하는 것이다.

패딩이란 원래의 데이터에 의미 없는 데이터를 채워 모든 데이터의 길이를 균일하게 맞추는 기법이다.

이처럼 패딩을 적용해 데이터 크기를 통일시키면 가변 길이 시계열 데이터도 처리할 수 있다. 그러나 원래는 존재하지 않던 패딩용 문자까지 seq2seq가 처리하게 된다. 따라서 패딩을 적용해야 하지만 정확성이 중요하다면 seq2seq에 패딩 전용 처리를 추가해야 한다. Decoder에 입력된 데이터가 패딩이라면 손실의 결과에 반영하지 않도록 한다.

한편 Encoder에 입력된 데이터가 패딩이라면 LSTM 계층이 이전 시각의 입력을 그대로 출력하게 한다. 즉, LSTM 계층은 마치 처음부터 패딩이 존재하지 않았던 것처럼 인코딩할 수 있다.

 

seq2seq 구현

두 RNN을 클래스와 Encoder 클래스와 Decoder 클래스로 각각 구현한다. 그런 다음 두 클래스를 연결하는 seq2seq 클래스를 구현하는 흐름을 진행한다.

 

 

Encoder 클래스는 문자열을 받아 벡터 h로 변환한다. Encoder 클래스는 Embedding 계층과 LSTM 계층으로 구성된다.

Embedding 계층에서는 문자를 문자 벡터로 변환한다. 그리고 이 문자 벡터가 LSTM 계층으로 입력된다.

 

LSTM 계층은 오른쪽으로는 은닉 상태와 셀을 출력하고 위쪽으로는 은닉 상태만 출력한다. 이 구성에서 더 위에는 다은 계층이 없으니 LSTM 계층의 위쪽 출력은 폐기된다. 우의 그림에서 보듯이 Encoder에서는 마지막 문자를 처리한 후 LSTM 계층의 은닉 상태 h를 출력한다. 그리고 이 은닉 상태 h가 Decoder로 전달된다.

 

Encoder 클래스의 코드를 살펴보자.

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
        slef.hs = None

초기화 메서드에서는 인수로 vocab_size, wordvec_size, hidden_size를 받는다. 이 메서드는 가중치 매개변수를 초기화하고, 필요한 계층을 생성한다. 마지막으로, 가중치 매개변수와 기울기를 인스턴스 변수 params와 grads 리스트에 각각 보관한다.

이어서 forward()와 backward() 메서드를 살펴보자.

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

순전파에서는 Time Embedding 계층과 Time LSTM 계층의 forward() 메서드를 호출한다. 그리고 Time LSTM 계층의 마지막 시각의 은닉 상태만을 추출해, 그 값을 Encoder의 forward() 메서드의 출력으로 반환한다.

 

역전파에서는 LSTM 계층의 마지막 은닉 상태에 대한 기울기가 dh 인수로 전해진다. 이 dh는 Decoder가 전해주는 기울기이다. 역전파 구현에서는 원소가 모두 0인 텐서 dhs를 생성하고 dh를 dhs의 해당 위치에 할당한다. 그다음은 Time LSTM 계층과 Time Embedding 계층의 backward() 메서드를 호출한다.

 

다음으로 Decoder 클래스를 살펴보자.

Decoder 클래스는 Encoder 클래스가 출력한 h를 받아 목적으로 하는 다른 문자열을 출력한다.

 Decoder의 학습 시 계층 구성을 보여준다. 여기에서 정답 데이터는 "_62"이다. 입력 데이터를 ['_', '6', '2', '']로 주고 이에 대응하는 출력은 ['6', '2', '', '']이 되도록 학습시킨다.

위 그림과 같이 Decoder 클래스는 Time Embedding, Time LSTM, Time Affine의 3가지 계층으로 구성된다.

그럼 Decoder 클래스를 구현하는 코드를 살펴보자.

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

backward() 메서드는 위쪽의 SoftmaxwithLoss 계층으로부터 기울기 dscore를 받아 Time Affine 계층, Time LSTM 계층, Time Embedding 계층 순서로 전파시킨다. 이때 Time LSTM 계층의 시간 방향으로의 기울기는 TimeLSTM 클래스의 인스턴스 변수 dh에 저장되어 있다. 그래서 이 시간 방향의 기울기 dh를 꺼내서 Decoder 클래스의 backward()의 출력으로 반환한다.

Decoder 클래스는 학습 시와 문장 생성 시의 동작이 다르다. 다음으로 Decoder 클래스에 문장 생성을 담당하는 generate() 메서드를 구현해보자.

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를 선택하는 작업을 반복한다. 이상이 Decoder 클래스의 구현이다.

 

다음 글에서는 seq2seq 클래스 구현과 평가, 그리고 개선에 대해 알아볼 것이다.