본문 바로가기

자연어처리

시퀀스투시퀀스(Sequence‑to‑Sequence, seq2seq) (3)

1. Seq2Seq 개선 

■ Seq2Seq 모델의 학습 진행 속도와 정확도를 개선할 수 있는 방법으로 입력 데이터를 반전(reverse)하는 것과
Peeky Decoder를 사용하는 방법이 있다. 


1.1 Encoder에 들어가는 입력 시퀀스 반전(reverse)

■ 첫 번째 방법은 Encoder에 입력되는 입력 시퀀스 데이터의 순서를 반전시켜 Encoder 모델어 입력하는 것이다. 이 기법은 단순하지만 성능을 개선시킬 수 있다.

■ 입력 시퀀스 반전 기법이란, 예를 들어 '나', '는', '고양이', '로소', '이다.'라는 순서를 가지는 시퀀싀의 순서를 반전시켜  '이다', '로소', '고양이', '는', '나'와 같이 역순으로 Encoder 모델에 입력하는 것이다.

■ 단순히 입력 시퀀스의 순서를 바꾸는 것만으로 Seq2Seq 모델의 성능을 개선시킬 수 있다.

■ 이 기법이 효과적인 이유는 Encoder 입력 초반부의 정보가 Decoder 초반부에 도달하기까지 거쳐야 하는 경로의 길이가 짧아지기 때문이다. 즉, 역전파 과정에서의 기울기 전파 경로가 짧아지기 때문이다. 

■ 예를 들어, 일반적인 (순서) 방법으로 입력 시퀀스로 '나', '는', '고양이', '로소', '이다.'를 Encoder에 입력했을 때, Decoder에서는 'I', 'am', 'a', 'cat'라는 출력 시퀀스를 생성하는 다음과 같은 Seq2Seq 모델을 생각해보자.  

■ 일반적인 순서로 입력 시퀀스를 배치했을 때, Decoder의 첫 번째 출력 시퀀스 'I'와 이에 대응되는 입력 시퀀스 '나' 사이의 경로를 생각해보자. 

■ Decoder에서 'I'를 생성할 때의 오차를 Encoder의 '나'에 전달하려면 Encoder의 '는', '고양이', '로소', '이다' 총 4개의 단어(또는 time step)를 거쳐야 한다. 즉, 각 시점의 순환 신경망 셀을 거쳐 역전파를 수행해야 한다.

■ 이 예에서는 문장이 단순하여 역전파 과정에서 순환 신경망 계층을 몇 단계 거치지 않지만, 많은 time step을 거치게 된다면 역전파 과정에서 더 많은 기울기 전파가 발생하므로 기울기가 약해지거나 소실될 수 있다.

반면, 다음과 같이 입력 시퀀스의 순서를 반전시켜 배치한다면, Encoder의 마지막 입력이 '나'이며 Decoder의 첫 번째 출력이 'I'가 된다. 즉, '나'와 'I'간의 기울기 전파 경로가 더 짧아지므로 역전파 시 기울기를 최대한 보존하면서 전달할 수 있다. 

■ 이렇게 입력 데이터 반전은 긴 문장에서도 학습 안정성을 높여줄 수 있는 핵심적인 트릭이다. 단, 입력 시퀀스를 반전시킨다고 해서 모든 단어 쌍의 거리가 줄어드는 것은 아니다. 즉, 평균 거리는 입력 시퀀스를 반전시키기 전/후가 동일하다.

■ 입력 시퀀스가 토큰의 정수 인덱스로 구성되어 있다고 하자. '나', '는', '고양이', '로소', '이다'라는 토큰에 대응되는 정수 인덱스가 각각 0, 1, 2, 3, 4, 5라고 할때, '나는 고양이로소이다'라는 문장은 [0, 1, 2, 3, 4, 5]라고 할 수 있다. 입력 시퀀스를 반전시키면 [5, 4, 3, 2, 1]이 되는 것이다.

■ 예를 들어 시퀀스투시퀀스(Sequence‑to‑Sequence, seq2seq) (2) 에서는 입력 시퀀스가 영어 단어였다. 그러므로 이 예에서 입력 시퀀스 반전은 단순히 영어 시퀀스의 순서만 뒤집으면 된다.

■ 일반적인 순서를 Encoder에 배치할 때는 다음과 같은 함수를 사용하였다. 해당 함수는 입력된 데이터(tokenized_data)를 순서대로 읽으면서 단어 집합에 해당 단어(토큰)가 있다면, 그 단어(토큰)에 해당하는 정수 인덱스를, 없다면 <unk> 토큰의 정수 인덱스로 처리하는 함수이다. 최종적으로 각 문장의 토큰들은 원래 순서 그대로 유지한 채 토큰의 정수 인덱스로 반환된다.

def texts_to_sequences(tokenized_data, word_to_index):
    encoded_data = []
    for sent in tokenized_data:
        index_sequences = [] # 단어 집합(word_to_index)에서 단어(word)의 인덱스를 찾아 index_sequences에 저장
        for word in sent:
            try:
                index_sequences.append(word_to_index[word]) # 단어 집합에 단어(word)가 있으면 해당 단어의 인덱스를 index_sequences에 저장
            except:
                index_sequences.append(word_to_index['<UNK>']) # 없으면 OOV이므로 <unk> 토큰으로 처리
        encoded_data.append(index_sequences)
    return encoded_data  
    
encoder_input = texts_to_sequences(en_input, src_vocab)
encoder_input
```#결과#```
[[27, 2],
 [27, 2],
 ...,
 [27, 114, 2],
 [27, 88, 2],
 ...,
 [210, 6, 2],
 ...]
````````````

■ 입력 시퀀스를 반전시키는 방법은 단순히 [27, 2], [27, 114, 2], .. 등으로 매핑된 정수 인덱스들을 단순히 거꾸로 뒤집어 [2, 27], [2, 114, 27], .... 등으로 만드는 것이다. 이를 위해 texts_to_sequences 함수에 다음과 같이 reverse 기능을 추가하여 사용하면 된다.

def texts_to_reverse_sequences(tokenized_data, word_to_index):
    encoded_data = []
    for sent in tokenized_data:
        index_sequences = [] # 단어 집합(word_to_index)에서 단어(word)의 인덱스를 찾아 index_sequences에 저장
        for word in sent:
            try:
                index_sequences.append(word_to_index[word]) # 단어 집합에 단어(word)가 있으면 해당 단어의 인덱스를 index_sequences에 저장
            except:
                index_sequences.append(word_to_index['<UNK>']) # 없으면 OOV이므로 <unk> 토큰으로 처리
        encoded_data.append(index_sequences)
   
    reverse_encoded_data = []
    for i in encoded_data:
        i.reverse()
        reverse_encoded_data.append(i)
    return reverse_encoded_data
    
encoder_reverse_input = texts_to_reverse_sequences(en_input, src_vocab)
encoder_reverse_input
```#결과#```
[[2, 27],
 [2, 27],
 ...,
 ...,
 [2, 6, 210],
 ...]
````````````

■ 이 함수는 처음에 texts_to_sequences와 동일하게 동작하여 각 문장을 인덱스 리스트로 변환한다. 그리고 변환된 각 문장의 인덱스 리스트를 역순(reverse)으로 변경하여 반환한다. 즉, 이 함수는 각 문장의 시퀀스 순서를 뒤집은 결과를 반환한다.

■ 예를 들어, 20011번째 문장에 대하여 토큰의 정수 인덱스를 다음과 같이 다시 토큰으로 변환하면, 일반적인 문법의 순서가 뒤집어져 주어 'he'가 문장의 마지막에 있으며, Encoder의 마지막 시점에 입력될 것임을 알 수 있다.

index_to_src ={value: key for key, value in src_vocab.items()}
index_to_tar ={value: key for key, value in tar_vocab.items()}

## 인코더의 입력인 영어 문장에 해당하는 정수 시퀀스를 영어 단어로 변환하는 함수
def seq2scr(input_seq):
    sentence = ''
    for int_seq in input_seq:
        if int_seq != 0 : sentence += index_to_src[int_seq] + ' ' # input seq 중 값이 0인 것 제외 # <pad> 토큰 제외
    return sentence
    
## 디코더의 입력인 프랑스어 문장에 해당하는 정수 시퀀스를 프랑스어 단어로 변환하는 함수
def seq2tar(input_seq):
    sentence = ''
    for int_seq in input_seq:
        # input seq 중 값이 0인 것 제외 # <pad> 토큰 제외 그리고 <sos> 토큰과 <eos> 토큰도 제외해야 함
        if int_seq != 0 and int_seq != 3 and int_seq != 4 : sentence += index_to_tar[int_seq] + ' ' 
    return sentence
seq2scr(encoder_input[20011])
```#결과#```
'he blew the deal . '
````````````

seq2scr(encoder_reverse_input[20011])
```#결과#```
'. deal the blew he '
````````````

■ 이 문장이 입력되면 Seq2Seq 모델이 출력(생성)해야 할 문장은 다음과 같다.

decoder_input = texts_to_sequences(fra_input, tar_vocab)
decoder_target = texts_to_sequences(fra_output,tar_vocab)
seq2tar(decoder_input[20011])
```#결과#```
'il a saborde le contrat . '
````````````

■ 위와 같이 Encoder의 입력으로 들어갈 데이터에 대해서만 texts_to_reverse_sequences( ) 함수를 적용하고 디코더의 입력과 타깃에 대해서는 texts_to_sequences( ) 함수를 적용한 다음, 동일하게 패딩 처리 및 데이터셋, 데이터로더를 생성하고 학습을 수행하면 된다. 


1.2 Peeky Decoder

■ 1.1의 Encoder-Decoder 전체 그림을 보면, 일반적인 Encoder-Decoder 모델 구조에서는 Encoder가 입력 시퀀스를 처리하여 생성한 context vector는 Decoder의 첫 번째 시점의 순환 신경망 셀에만 전달된다. 

그리고 Decoder의 첫 번째 셀은 첫 번째 시점의 입력을 받아 첫 번째 출력 시퀀스가 생성한다. 

■ Decoder의 첫 번째 시점 이후의 순환 신경망 셀들은 context vector를 직접 받지 않고, 이전 시점의 셀을 통해 간접적으로만 context vector의 정보를 전달받는다고 볼 수 있다.   

■ 두 번째 방법은 Encoder로부터 생성된 context vector를 Decoder의 모든 시점의 순환 신경망 셀에 직접적으로 전달되도록 Deocder의 구조를 개선하는 방법이다.  

■ 이러한 구조적 개선을 통해 Decoder의 각 시점의 순환 신경망 계층들은 Encoder로부터 전달된 context vector를 참조 혹은 엿본다고 하여 Peeky Decoder라고 부른다. Peeky Decoder의 구조는 다음과 같다. 

Peeky Decoder 구조 및 컨텍스트 벡터와 Decoder 은닉 상태 연결 구조 [출처] Understanding the Seq2Seq Model — What You Should Know Before Understanding Transformers by Hyunjong Lee Medium

■ 일반적인 Seq2Seq 모델에서는 Encoder가 생성한 context vector가 Decoder의 초기 상태로만 전달되는 반면, 
Peeky Decoder는 이 인코딩된 정보를 더 광범위하게 활용한다. 

■ 위의 Peeky Decoder 그림은 Encoder가 생성한 인코딩 정보인 context vector를 Decoder의 모든 시간 단계의 Affine 계층에 직접 전달하는 구조를 보이고 있다.  

■ 이러한 구조를 통해 Encoder가 압축한 정보를 여러 계층들이 직접적으로 활용할 수 있게 된다. 

■ 더 나아가, Peeky Decoder는 Affine 계층뿐만 아니라 다음과 같이 순환 신경망 계층에도 Encoder의 context vector를 전달할 수 있다.

[출처] https://techblog-history-younghunjo1.tistory.com/489

■ 그리고 이미지의 오른쪽 부분에서 볼 수 있듯이, Affine 계층이나 순환 신경망 계층에 context vector를 전달하여 계층의 입력으로 사용하기 위해서는 두 개의 데이터를 연결(concatenate)하여 하나의 입력을 만들어야 한다. 

■ 이렇게 데이터를 연결하면 해당 부분의 차원이 늘어나게 되지만, Encoder의 중요한 정보가 Decoder의 여러 계층에 직접 전달됨으로써 모델이 더 올바른 결정을 내릴 것이란 기대를 할 수 있다. 

■ 정리하면, Peeky Decoder는 인코딩된 정보를 Decoder의 다른 계층에도 전해주는 기법이라고 할 수 있다. 이러한 Peeky Decoder를 이용하는 Seq2Seq를 Peeky Seq2Seq라고 부른다. 

■ 위의 그림과 같이 Peeky Decoder를 이용하여 Affine 계층과 LSTM 계층 모두에 Encoder의 context vector를 직접 전달하기 위해서는 다음과 같은 3가지 부분을 고려하면 된다.

- (1) Decoder의 입력 벡터와 Encoder의 은닉 상태를 연결

- (2) Decoder의 LSTM 계층에서는 (1)에서 전달되는 것과 Encoder의 은닉 상태를 입력으로 받아서 Affine 계층으로 출력

- (3) Decoder의 Affine 계층에서는 LSTM 계층에서 전달되는 것과  Encoder의 은닉 상태를 입력으로 받아서 점수(score) 계산

■ 예를 들어, Encoder의 LSTM 계층과 입력 x와 입베딩 계층의 크기가 다음과 같다고 하자.

input_size, hidden_size, embedded_dim = 5, 3, 7

lstm_cell = nn.LSTM(7, hidden_size, batch_first=True)
embedding_layer = nn.Embedding(num_embeddings=input_size+1, embedding_dim=embedded_dim)
x = torch.randint(0, input_size+1, (2, 5))  

embedded = embedding_layer(x)
x = x.float()

x.shape  # [batch_size, sequence_length]
```#결과#```
torch.Size([2, 5])
````````````

embedded.shape  # [batch_size, sequence_length, embedde_dim]
```#결과#```
torch.Size([2, 5, 7])
````````````

■ 그리고 Encoder의 LSTM 계층에 embedded를 넣어서 반환되는 encoder_hidden가 Decoder에 전달해야 하는 은닉 상태라고 하자. 

_, (encoder_hidden, encoder_cell) = lstm_cell(embedded) # 인코더의 lstm 계층이라고 치자.
encoder_hidden.shape # [1, batch_size, hidden_dim]
```#결과#```
torch.Size([1, 2, 3])
````````````

■ Peeky Decoder는 위와 같은 은닉 상태 encoder_hidden을 다른 계층에도 전달하기 위해서 Encoder가 전달한 은닉 상태인 encoder_hidden을 seq_len만큼 복제해야 한다. 

# [1, batch_size, hidden_dim]
encoder_hidden.shape, encoder_hidden
```#결과#```
torch.Size([1, 2, 3]),
 tensor([[[-0.0539, -0.6849,  0.2635],
          [ 0.0775, -0.5767, -0.0309]]], grad_fn=<StackBackward0>))
````````````

encoder_hidden = encoder_hidden.squeeze() # 또는 shape이 [1, batch_size, hidden_dim]이므로 encoder_hidden[0]
encoder_hidden.shape #[batch_size, hidden_dim]
```#결과#```
torch.Size([2, 3])
````````````

■ 여기서 seq_len은 두 번째 차원이므로 다음과 같이 두 번째 차원을 만든 다음, repeat 함수를 통해 두 번째 차원의 값을 복제한다. 두 번째 차원을 seq_len의 수만큼 복제한 것을 encoder_hidden_repeated라고 하자.

# [batch_size, seq_len, hidden_dim]
encoder_hidden.unsqueeze(1).repeat(1, 5, 1).shape, encoder_hidden.unsqueeze(1).repeat(1, 5, 1)
```#결과#```
(torch.Size([2, 5, 3]),
 tensor([[[-0.0539, -0.6849,  0.2635],
          [-0.0539, -0.6849,  0.2635],
          [-0.0539, -0.6849,  0.2635],
          [-0.0539, -0.6849,  0.2635],
          [-0.0539, -0.6849,  0.2635]],
 
         [[ 0.0775, -0.5767, -0.0309],
          [ 0.0775, -0.5767, -0.0309],
          [ 0.0775, -0.5767, -0.0309],
          [ 0.0775, -0.5767, -0.0309],
          [ 0.0775, -0.5767, -0.0309]]], grad_fn=<RepeatBackward0>))
````````````

encoder_hidden_repeated = encoder_hidden.unsqueeze(1).repeat(1, 5, 1)

■ encoder_hidden에 unsqueeze(1)을 적용하여 1번 인덱스 위치에 차원을 하나 추가한다. encoder_hidden의 shape은 [2, 1, 3]이 된다. 

■ repeat 함수는 각 차원별로 텐서를 볓 번 반복할지를 정한다. [2, 1, 3].repeat(1, 5, 1)은 첫 번째 차원과 세 번째 차원은 1번 반복. 즉, 첫 번째와 세 번째 차원은 그대로 유지하며 두 번째 차원은 5번 반복한다.

encoder_hidden_repeated.shape # (batch_size, seq_len, hidden_dim)
```#결과#```
torch.Size([2, 5, 3])
````````````

■ 여기서 두 번째 차원이 Decoder의 seq_len이 되므로 encoder_hidden을 5 time step에 맞춰 복제한 것으로 볼 수 있다. 이렇게 repeat 함수를 이용해서 Decoder 내 여러 계층에 전달할 수 있다.

■ Decoder의 입력 시퀀스가 Decoder의 임베딩 계층을 통과한 결과의 shape은 다음과 같으며, encoder_hidden_repeated와 embedded를 연결하여 다음과 같이 Decoder의 순환 신경망 계층의 입력으로 만들어서 Encoder로부터 전달받은 context vector와 함께 순환 신경망 계층에 통과시키면 된다. 

embedded.shape # (batch_size, seq_len, embedded)
```#결과#```
torch.Size([2, 5, 7])
````````````

## (batch_size, seq_len, hidden_dim + embedded_dim)
torch.cat((embedded, encoder_hidden_repeated), 2).shape
```#결과#```
torch.Size([2, 5, 10])
````````````

decoder_lstm_input = torch.cat((embedded, encoder_hidden_repeated), dim=2)

decoder_lstm_output, (decoder_hidden, decoder_cell) = lstm(decoder_lstm_input, (encoder_hidden, encoder_cell))

■ 이때, encoder_hidden_repeated 텐서와 embedded 텐서를 연결한 결과의 형상을 보면, 마지막 차원은 embedding_dim + hidden_dim인 것을 확인할 수 있다.

Peeky Decoder의 초기화는 앞 절의 Decoder와 거의 같다. 다른 점은 마지막 차원이 다른 두 텐서를 연결하기 때문에 마지막 차원의 수가 이렇게 더해진다는 것이다.


1.3 일반적인 Seq2Seq 모델과 성능 비교

■ 일반적인 Seq2Seq 모델과 입력 시퀀스에 reverse를 적용한 Seq2Seq 모델 그리고 Peeky Decoder를 Decoder로 사용하는 Peeky Seq2Seq의 성능을 비교하고자 한다.

1.3.1 일반적인 Seq2Seq 모델

먼저, 일반적인 Seq2Seq의 모델에 대한 Encoder, Decoder, Seq2Seq 클래스는 다음과 같이 구현할 수 있다. 아래 코드는 파이토치를 사용한 기본적인 Seq2Seq 모델 구현 예시이다. 

class Encoder(nn.Module):
    def __init__(self, src_vocab_size, embedding_dim, hidden_dim):
        super().__init__()
        self.embedding = nn.Embedding(src_vocab_size, embedding_dim, padding_idx=0) # 임베딩 계층
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True) # LSTM 계층

    def forward(self, x):
        embedded = self.embedding(x) # 입력값을 embedding_dim 차원으로
        _, (hidden, cell) = self.lstm(embedded) 
        return hidden, cell

class Decoder(nn.Module):
    def __init__(self, tar_vocab_size, embedding_dim, hidden_dim):
        super().__init__()
        self.embedding = nn.Embedding(tar_vocab_size, embedding_dim, padding_idx = 0) # 임베딩 계층
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first = True) # LSTM 계층
        self.fc = nn.Linear(hidden_dim, tar_vocab_size) # Affine 계층 # 
    
    def forward(self, x, hidden, cell):
        embedded = self.embedding(x)
        output, (hidden, cell) = self.lstm(embedded, (hidden, cell))
        output = self.fc(output)
        return output, hidden, cell

class seq2seq(nn.Module):
    def __init__(self, encoder, decoder):
        super().__init__()
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, src, trg):
        hidden, cell = self.encoder(src)
        output, _, _ = self.decoder(trg, hidden, cell) 
        return output
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

encoder = Encoder(src_vocab_size, embedding_dim, hidden_dim)
decoder = Decoder(tar_vocab_size, embedding_dim, hidden_dim)

baseline_model = seq2seq(encoder, decoder).to(device)

■ 먼저, Encoder는 입력 시퀀스(이 예에서는 영어 문장)를 처리하여 고정된 크기의 벡터, 즉 컨텍스트 벡터(context vector)로 압축하는 역할을 한다.

■ Encoder에서 src_vocab_size는 입력 시퀀스인 영어 단어 집합(vocabulary)의 크기를 의미한다.

Encoder의 임베딩 계층에서는 입력 언어인 영어의 단어 집합을 위한 임베딩 계층이다. 단어 집합 내에서 0번 인덱스에 해당하는 토큰이 패딩 토큰이므로 임베딩 계층에 padding_idx = 0으로 지정한다.

■ Encoder에서는 입력 x가 들어오면 임베딩 계층을 통해 고정 길이의 임베딩 벡터로 변환된다. 이때 변환된 임베딩 차원 수는 embedding_dim이다. 

■ 그다음, LSTM 계층이 임베딩된 시퀀스를 받아 은닉 상태와 셀 상태를 생성한다. LSTM 계층을 사용할 경우 Encoder가 Decoder에 전달할 context vector는 Encoder가 생성한 은닉 상태와 셀 상태이다.  

■ 순환 신경망 계층으로 LSTM 계층을 사용한다면 이 두 개의 값이 입력 시퀀스 전체의 정보를 압축한 context vector 역할을 한다. Encoder 클래스에서는 위와 같이 Encoder의 forward( )에서 계산된 context vector를 반환해야 한다. 

■ Decoder에서 tar_vocab_size는 출력(타깃) 시퀀스인 프랑스어 단어 집합의 크기를 의미한다. 

■ Decoder의 임베딩 계층은 타깃 언어인 프랑스어 단어 집합을 위한 임베딩 계층이다. 

■ Decoder의 forward( ) 메서드에서는 초기 Decoder의 Encoder에서 받은 은닉 및 셀 상태와 Decoder의 입력 시퀀스 x로 출력 시퀀스를 생성하고,

■ 이후에는 첫 번째 출력 결과를 기반으로 Decoder의 은닉 상태와 셀 상태를 업데이트해가면서 이후 시점들에 대한 출력 시퀀스를 생성한다.

이렇게 일반적인 Decoder의 순환 신경망 계층은 Encoder의 context vector를 간접적으로 전달받아 사용한다. 

■ 시퀀스를 입력으로 넣어서 시퀀스를 생생하는 번역 문제이므로 모든 시점의 출력 시퀀스(output)을 반환해야 한다.

■ Decoder의 순환 신경망 계층에서 반환된 output은 완전 연결 계층(Affine 계층 또는 Dense 계층이라고도 함)을 통해 타겟 단어 집합의 크기의 차원으로 변환된다.

■ Seq2Seq 클래스는 전체 모델의 구조를 담당하며, Encoder와 Decoder를 결합하는 방법은 생성자에서 외부로부터 미리 생성된 encoder와 decoder 인스턴스를 받은 다음, Seq2Seq 클래스의 인스턴스가 호출될 때 Seq2Seq 클래스이 forward( ) 메서드를 통해서 결합된다.

■ 여기서 입력 시퀀스인 영어를 입력으로 받은 Encoder로부터 context vector를 반환받고, 이 context vector와 Decoder의 입력인 프랑스어를 입력으로 넣어서 시퀀스를 생성한다.  

■ 여기서 Decoder의 예측 점수인 output만 반환한다. 이 output은 손실(loss)을 계산하는 데 사용된다.

1.3.2 반전(reverse)시킨 입력 데이터를 사용하는 Seq2Seq 모델

■ 이 방법은 1.1에서 본 것처럼 입력 시퀀스를 뒤집어서 데이터셋과 데이터로더를 만들고 1.3.1의 Encoder, Decoder, Seq2Seq 클래스를 그대로 사용하면 된다.

# 입력 시퀀스만 순서 반전
encoder_reverse_input = texts_to_reverse_sequences(en_input, src_vocab) 
decoder_input = texts_to_sequences(fra_input, tar_vocab)
decoder_target = texts_to_sequences(fra_output,tar_vocab)

import numpy as np

def pad_sequences(sents):
    max_len = max([len(sent) for sent in sents]) # 입력으로 들어온 리스트의 원소 중 가장 길이가 긴 것을 최대 길이로 지정
    pad_result = np.zeros((len(sents), max_len), dtype = int) # 행은 정수 시퀀스의 총 개수, 열은 최대 길이, 정수 인덱스이므로 타입은 int
    for idx, sent in enumerate(sents):
        if len(sent) != 0: pad_result[idx, : len(sent)] = np.array(sent) # 패딩 처리 # np.array(sent)로 채워지지 않는 원소는 0
    return pad_result

# 패딩 처리
encoder_input = pad_sequences(encoder_reverse_input)
decoder_input = pad_sequences(decoder_input)
decoder_target = pad_sequences(decoder_target)

...,
...,

reverse_encoder = Encoder(src_vocab_size,embedding_dim, hidden_dim)
decoder = Decoder(tar_vocab_size,embedding_dim, hidden_dim)
reverse_model = seq2seq(reverse_encoder, decoder).to(device)

1.3.3 Peeky Decoder를 사용하는 Seq2Seq 모델

■ Peeky Decoder의 핵심 중 하나는 Encoder로부터 전달받은 context vector를 Decoder 내의 여러 계층에 전달한다는 것이다. 하나의 결과를 다수의 계층에 전달하기 위해서 생각할 수 있는 방법 중 하나는 하나를 다수의 개수로 복제하는 것이다.

■ 정확하게는 하나의 시점을 여러 개의 시점으로 복제해야 한다. Encoder에서 생성된 마지막 시점의 hidden state의 크기는 [1, batch_size, hidden_dim]이다. 여기에 시퀀스의 길이. 즉, time steps에 해당되는 새로운 축(차원)을 확장해주면 된다.

■ 이러한 복제를 하기위해서는 1.2에서 본 파이토치의 repeat와 expand 함수를 이용하면 된다. 예를 들어 다음과 같은 1차원 텐서 x가 있다고 하자.

x = torch.tensor([1, 2, 3, 4])
x.shape, x
```#결과#```
(torch.Size([4]), tensor([1, 2, 3, 4]))
````````````

- 원본 x의 shape은 (4, )라고 볼 수 있다.

■ 다음과 같이 repeat(3, 2)를 적용하면, 파이토치는 (4, )인 x를 (1, 4)로 취급(=torch.tensor([[1, 2, 3, 4]]))한 뒤, 첫 번째 차원(dim=0)을 3번 반복하고, 두 번째 차원(dim=1)을 2번 반복한다. 그래서 shape이 (1*3, 4*2) = (3, 8)이 된다.

- 1D 텐서의 경우 [n]이 아닌 [1, n]으로 간주한다.

x.repeat(3, 2).shape, x.repeat(3, 2)
```#결과#```
(torch.Size([3, 8]),
 tensor([[1, 2, 3, 4, 1, 2, 3, 4],
         [1, 2, 3, 4, 1, 2, 3, 4],
         [1, 2, 3, 4, 1, 2, 3, 4]]))
````````````

■ 이번에는 세 번째 차원(dim=2)을 늘려 repeat를 적용할 경우, 파이토치는 텐서의 차원 수에 맞추기 위해서 1차원을 늘려 (1, 4)에서 (1, 1, 4)로 취급한다.

첫 번째 차원에는 3번, 두 번째 차원에는 2번, 세 번째 차원에는 1번 반복하므로 shape은 (1*3, 1*2 , 4*1) = (3, 2, 4)가 된다.

x.repeat(3, 2, 1).shape, x.repeat(3, 2, 1)
```#결과#```
(torch.Size([3, 2, 4]),
 tensor([[[1, 2, 3, 4],
          [1, 2, 3, 4]],
 
         [[1, 2, 3, 4],
          [1, 2, 3, 4]],
 
         [[1, 2, 3, 4],
          [1, 2, 3, 4]]]))
````````````

■ repeat는 위와 같이 텐서의 차원의 데이터를 반복한다. expand도 특정 텐서를 반복하여 생성하지만, 차원의 수가 1인 차원에만 적용할 수 있다. 예를 들어, 다음과 같은 원본 텐서 x가 있다고 했을 때

x = torch.tensor([[1], [2], [3]])
x.shape, x
```#결과#```
(torch.Size([3, 1]),
 tensor([[1],
         [2],
         [3]]))
````````````

■ expand는 차원의 수가 1인 차원에만 적용할 수 있다고 하였다. 그러므로 이 예에서 반복은 다음과 같이 차원의 수가 1인 두 번째 차원만 가능하다. 

x.expand(3, 4).shape, x.expand(3, 4)
```#결과#```
(torch.Size([3, 4]),
 tensor([[1, 1, 1, 1],
         [2, 2, 2, 2],
         [3, 3, 3, 3]]))
````````````

x.expand(-1, 2).shape, x.expand(-1, 2)
```#결과#```
(torch.Size([3, 2]),
 tensor([[1, 1],
         [2, 2],
         [3, 3]]))
````````````

x.expand(3, -1).shape, x.expand(3, -1)
```#결과#```
(torch.Size([3, 1]),
 tensor([[1],
         [2],
         [3]]))
````````````

- 이렇게 -1을 차원에 지정하면 해당 차원의 수는 그대로 유지한다는 의미이다.

■ [3, 1]의 크기를 갖는 x를 차원의 수가 1인 두 번째 차원(dim=1)에 대해서만 4번 반복하는 것을 볼 수 있다. 차원의 수가 1이 아닌 첫 번째 차원의 수가 동일하지 않으면 다음과 같은 오류가 발생한다.

x.expand(2, 4).shape
```#결과#```
RuntimeError                              Traceback (most recent call last)
Cell In[399], line 1
----> 1 x.expand(2, 4).shape

RuntimeError: The expanded size of the tensor (2) must match the existing size (3) at non-singleton dimension 0.  Target sizes: [2, 4].  Tensor sizes: [3, 1]
````````````

■ 이 예에서는 Decoder 내 각 시점의 LSTM 계층과 Affine 계층에 모두 전달하고자 한다. 이를 구현하기 위해, 시점별 각 계층의 입력 벡터와 Encoder로부터 받은 context vector를 연결(concatenate)하여 해당 계층의 입력으로 사용한다.

■ 이렇게 함으로써 Decoder 내에서 원본 입력 문맥 전체의 정보(Encoder로부터 전달받은 context vector)를 참고하여 출력 시퀀스를 생성할 수 있게 된다. 

■ Encoder의 context vector를 Decoder의 모든 LSTM 계층과 Affine 계층에 직접 전달하는 구조를 갖는 Peeky Decoder 클래스는 다음과 같다.

class PeekyDecoder(nn.Module):
    def __init__(self, tar_vocab_size, embedding_dim, hidden_dim):
        super().__init__()
        self.embedding = nn.Embedding(tar_vocab_size, embedding_dim, padding_idx = 0)
        
        self.lstm = nn.LSTM(embedding_dim+hidden_dim, hidden_dim, batch_first = True)
        self.fc = nn.Linear(2*hidden_dim, tar_vocab_size) 

    def forward(self, x, hidden, cell):
        # 현재 x.shape: [batch_size, sequence_length]
        embedded = self.embedding(x)
        # 현재 embedded.shape: [batch_size, sequence_length, embedding_dim]
        h_context = hidden.squeeze() # 또는 shape이 [1, batch_size, hidden_dim]이므로 encoder_hidden[0]
        # 현재 h_context.shape: [batch_size, hidden_dim]
        
        # 인코더가 전달한 은닉 상태를 다른 계층에도 전달하기 위해 seq_len = time_steps만큼 복제
        # 먼저, h_context에 시계열 길이(seq_len) = time steps 차원을 두 번째 차원으로 만들어주고, 시계열 길이에 맞게 복제
        # 이는, 다른 계층에도 전달하기 위함
        batch_size, seq_len, _ = embedded.shape
        h_repeated = h_context.unsqueeze(1).repeat(1, seq_len, 1) # [batch_size, seq_len, hidden_dim]

        # 디코더의 lstm 계층의 입력으로 들어오는 머시기 -> 말 다듬기
        lstm_input = torch.cat((h_repeated, embedded), dim=2) # [batch_size, seq_len, embedding_dim + hidden_dim]
        # 근데 왜 이렇게 차원을 합친걸까

        # 디코더의 lstm 계층 통과 -> 말 다듬기
        lstm_output, (hidden, cell) = self.lstm(lstm_input, (hidden, cell))
        # 현재 lstm_output.shape: [batch_size, seq_len, hidden_dim]

        # 이 lstm_output과 인코더가 전달한 은닉 상태를 결합해서 fc layer의 입력으로 -> 말ㄷ ㅏ듬기
        fc_input = torch.cat((h_repeated, lstm_output), dim=2) # [batch_size, seq_len, hidden_dim + hidden_dim]

        # 디코더의 fc 계층 |통과
        output = self.fc(fc_input)
        # 현재 output.shape: [batch_size, sequence_length, tar_vocab_size]
        return output, hidden, cell

- Encoder, Decoder의 순환 신경망 계층이 단층이라고 가정하였다.

■ 먼저, 생성자 함수 부분을 보면, LSTM 계층과 완전 연결 계층의 입력 차원이 달라진 것을 볼 수 있다.

LSTM 계층의 입력 차원이 embedding_dim + hidden_dim인 것을 확인할 수 있다. 이는 각 time step에서 LSTM 계층이 입력으로 (1) 현재 타깃 단어의 임베딩 벡터와 (2) Encoder의 context vector를 연결(concatenate)하여 사용하기 위함이다.

■ 그리고 완전 연결 계층의 입력 차원이 2*hidden_dim인데, 이는 각 time step에서 FC 계층의 입력으로 (1) 해당 time step의 LSTM 출력(hidden state)과 (2) Encoder의 context vector(hidden state)를 연결하여 사용하기 때문이다.

- 이 예에서 Encoder와 Decoder의 LSTM 게층의 hidden_dim을 동일하게 설정하였기 때문에 2*dim으로 처리하였다.

■ 그다음 순전파 함수인 forward( ) 메서드를 보면, forward의 인수로 들어온 hidden 텐서에서 첫 번째 차원을 제거한다.

- 단방향 LSTM을 가정했으므로 hidden의 shape은 [1, batch_size, hidden_dim]이다.

- 여기에 squeeze()를 적용하여 hidden의 shape을 [batch_size, hidden_dim]으로 만들어준다.

■ batch_size, seq_len, _ = embedded.shape을 통해 배치 크기와 시퀀스 길이를 가져온다. 이는 [batch_size, hidden_dim] 텐서인 h_context에 seq_len 차원을 확장하기 위해서이다. 

■ h_repeated = h_context.unsqueeze(1).repeat(1, seq_len, 1)은 Encoder의 context vector인 h_context를 Decoder의 각 time step에서 사용하기 위해 복제하는 과정이다. 

- 이 예시에서 LSTM 계층은 batch_first = True이므로, 두 번째 차원을 확장시켜 seq_len 차원으로 사용한다. h_context.unsqueeze(1): [batch_size, hidden_dim] \( \rightarrow \) [batch_size, 1, hidden_dim] 

- 그다음, repeat(1, seq_len, 1)을 통해 두 번째 차원(시퀀스 길이, seq_len = time steps)을 seq_len만큼 복제한다. \( \rightarrow \)  [batch_size, seq_len, hidden_dim]

이렇게 하는 이유는 Decoder의 시퀀스를 처리할 때마다 Encoder의 전체 문맥 정보(context vector)를 참고하기 위함이다.

■ lstm_input = torch.cat((h_repeated, embedded), dim=2)는 복제된 Encoder context(h_repeated)와 현재 time step의 단어 임베딩(embedded)을 마지막 차원을 따라 연결한다. 

- lstm_input.shape: [batch_size, seq_len, hidden_dim + embedding_dim]

- 이는 seq_len의 시퀀스 길이를 갖는 각 배치 데이터에 대하여 각각 현재 입력 정보뿐만 아나라 Encoder의 전반적인 문맥 정보를 함께 고려하도록 하기 위함이다.

■ lstm_output, (hidden, cell) = self.lstm(lstm_input, (hidden, cell))을 통해 준비한 입력을 Decoder의 LSTM 계층에 통과시킨다. 

■ fc_input = torch.cat((h_repeated, lstm_output), dim=2)은 다시 한번, 복제된 Encoder context(h_repeated)와 이번에는 Decoder의 출력(lstm_output)을 마지막 차원에 따라 연결한다. 

- 이는 최종 단어 예측을 위한 FC 계층이 해당 time step의 Decoder LSTM (출력) 정보(=LSTM의 hidden state)뿐만 아니라 Encoder의 전반적인 문맥 정보를 다시 한번 직접 참고하도록 하기 위함이다.

- LSTM 계층들을 통과하며 희석될 수 있는 초기 context 정보를 보강하는 효과를 줄 수 있을 것이라 기대할 수 있다.

■ Peeky Decoder를 사용하는 Peeky Seq2Seq 모델을 만든 다음, 일반적인 Seq2Seq, 입력 시퀀스 반전 Seq2Seq 그리고 Peeky Seq2Seq의 검증 정확도를 비교한 결과는 다음과 같다. 

encoder = Encoder(src_vocab_size,embedding_dim, hidden_dim)
peeky_decoder = PeekyDecoder(tar_vocab_size,embedding_dim, hidden_dim)
peeky_model = seq2seq(encoder, peeky_decoder).to(device)
x = list(range(len(reverse_val_acc)))

plt.plot(x, baseline_val_acc, label='baseline', marker='o')
plt.plot(x, reverse_val_acc, label='reverse', marker='o')
plt.plot(x, peeky_val_acc, label='reverse+peeky', marker='o')


plt.xabel('epochs')
plt.ylabel('accuracy')
plt.legend(loc='best')
plt.grid(True)
plt.show()

■ 결과를 보면, 일반적인 Seq2Seq 모델에 Reverse,  Reverse + Peeky Decoder를 적용할수록 성능이 향상되는 것을 확인할 수 있다. 

Reverse는 단순히 입력 시퀀스 순서를 반전시키는 것이므로 계산에 큰 영향을 미치지 않는다. 반면,  Peeky Decoder를 사용할 경우 계산량이 증가한다. 

■ 계산량이 증가하는 이유는 (이 예에서는) Decoder의 LSTM 계층과 Affine 계층에도 Encoder의 context vector를 전달하기 위해, 두 텐서를 '연결(concatenate)'하게 되어 입력 차원이 기존보다 커지기 때문이다.

- 이 예에서는 두 텐서의 연결로 인해 다음과 깉이 입력 형상이 변경되었다. 

-  self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first = True)

\( \rightarrow \) self.lstm = nn.LSTM(embedding_dim+hidden_dim, hidden_dim, batch_first = True)

- self.fc = nn.Linear(hidden_dim, tar_vocab_size)

\( \rightarrow \) self.fc = nn.Linear(2*hidden_dim, tar_vocab_size)  

그러므로 Peeky Decoder를 사용할 경우,  순전파/역전파 시 필요한 계산량이 증가하는 것은 필연적이다.