Loading [MathJax]/jax/output/CommonHTML/jax.js
본문 바로가기

자연어처리

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

1. Seq2Seq를 이용한 번역기 구현

seq2seq 모델을 사용하여 기계 번역기를 구현한다. 예제로 사용한 데이터는 영어-프랑스어 텍스트 파일이다. 해당 파일은 영어와 프랑스어가 대응되는 병렬 말뭉치(parallel corpus)이다.

- 병렬 말뭉치는 두 개 이상의 언어가 상호 대응되는 형태로 연결된 말뭉치이다.

■ 목표는 이 병렬 말뭉치를 사용하여 입력 시퀀스인 영어를 입력하면 출력 시퀀스인 프랑스어를 출력하는 seq2seq 모델을 구현하는 것이다. 이때, 입력 시퀀스와 출력 시퀀스의 길이는 서로 다를 수 있다.


1.1 전처리

■ 이 텍스트 데이터는 Run!    Prenez vos jambes à vos cous !와 같이 \t(tab)을 기준으로 영어와 프랑스어로 연결되어 있으며, 악센트와 구두점이 존재한다.

■ 또한, Run! Cours !나 Run. Cours !처럼 단어와 구두점 사이에 공백이 존재한다. 프랑스어에서는 콤마(.), 콜론(:), 세미콜론(;), 느낌표(!), 물음표(?) 등과 같은 구두점 앞뒤에 공백을 넣기 때문이다.  

■ 예시에 사용할 텍스트 데이터는 위와 같이 프랑스어는 구두점 앞에 공백 처리가 되어있는 샘플과 되어 있지 않은 샘플도 존재한다.

영어와 프랑스어를 모두 포함하는 데이터셋을 일관되게 처리하기 위해 ① 악센트는 제거하고 ② 구두점 앞에는 공백을 넣도록 전처리를 수행한다.

1.1.1 악센트 제거

■ 먼저, 악센트를 제거하기 위해 Python의 unicodedata를 사용하여 발음 구별 기호(악센트)를 일반 알파벳으로 변환하는 함수를 다음과 같이 정의한다.

import unicodedata 

def unicode_to_ascii(s): 
    return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')

- unicodedata.normalize('NFD', s)는 s를 발음구별기호(악센트)와 알파벳으로 분리해주는 메서드이다. 

- if unicodedata.category(c) != 'Mn'는 c가 발음기호인지 확인하는 조건문이다. 'Mn'은 Mark, Nonspacing으로 \u0394 \u03a5 같은 발음구별기호이다.

- 먼저, unicodedata.normalize('NFD', s)가 실행되어 입력 문자열을 NFD 형식으로 정규화한 다음, for 루프가 정규화된 문자열의 각 문자 c에 대해 조건물을 통해 c가 발음기호인지 확인한다. 확인 결과, 발음기호가 아니라면 문자 c를 리스트에 넣는다.

-- 예를 들어, s = 'café'라면 s는 for문을 통해 'c', 'a', 'f', 'e', '''로 분리된다. 

-- 'c', 'a', 'f'같은 문자는 모두 'Mn'이 아닌  'Ll'이다.

- 모든 반복이 끝난 후 필터링된 문자들은 ''.join()을 통해 하나의 문자열로 재결합된다.

-- 이 예시에서 café는 unicode_to_ascii() 함수를 통해 cafe로 변환된다.

1.1.2 공백 처리

■ 이제 위에서 정의한 악센트 제거 함수를 우선적으로 적용한 후, 단어와 구두점 사이에 공백을 추가하여 문장을 정제하는 전처리 함수를 다음과 같이 정의한다.

import re

def preprocess_sentence(sent):
    sent = unicode_to_ascii(sent.lower()) # 소문자화 및 악센트 제거
    sent = re.sub(r"([?.!,?])", r" \1", sent) # 단어와 구두점 사이에 공백 만들기 ex) ~ cafe. -> ~ cafe .
    sent = re.sub(r"[^a-zA-Z!.?]+", r" ", sent) # a-z, A-Z, '.', '?', '!', ','들을 제외하고 전부 공백으로 변환
    sent = re.sub(r"\s+", " ", sent) # 여러 개의 공백을 하나의 공백으로 치환
    return sent

- 예를 들어 sent로 "Café! What's up? 123"가 들어오면,
- 첫 번째 줄에서 "cafe! what's up? 123", 두 번째 줄에서 "cafe ! what's up ? 123", 세 번째 줄에서 "cafe ! what s up ? ", 네 번째 줄에서  "cafe ! what s up ? "가 된다. 

1.1.3 입출력 시퀀스 분리

■ 이제 영어-프랑스어가 저장된 텍스트 파일을 불러와서 정의한 전처리 함수 preprocess_sentence( )를 적용하면 된다. 

■ 텍스트 파일을 불러오기 전에, 먼저 해당 파일의 인코딩 방식을 확인해야 한다. 다음과 같이 Python의 chardet 라이브러리를 이용하여 확인할 수 있다.

import chardet

with open("fra.txt", "rb") as f:
    rawdata = f.read()
    result = chardet.detect(rawdata)
    encoding = result['encoding']
    
encoding
```#결과#```
'utf-8'
````````````

■ 이제 영어-프랑스어 텍스트 파일인 fra.txt 파일을 불러와, 탭(\t)으로 분리된 영어와 프랑스어에 전처리 함수를 적용하면 된다. fra.txt는 각 line이 다음과 같이 영어와 프랑스어가 \t로 구분된 형태이다.

Go.	Va !	CC-BY 2.0 (France) Attribution: tatoeba.org #2877272 (CM) & #1158250 (Wittydev)

■ 여기서 필요한 것은 영어와 프랑스어이므로 텍스트 파일을 불러와서 \t를 기준으로 나눈 다음, 첫 번째에 등장하는 영어와 두 번째에 등장하는 프랑스어만 추출해야 한다.

■ 그리고 훈련 과정에서 teacher forcing을 적용하기 위해 디코더의 입력 시퀀스와 출력 시퀀스(target)를 따로 분리하여 저장한다. 

■ 디코더의 입력 시퀀스와 출력 시퀀스를 분리하기 위해  다음과 같이 디코더의 입력 시퀀스 시작 부분에는 <sos> 토큰을, 출력 시퀀스 마지막 부분에는 <eos> 토큰을 추가한다.

def load_preprocessed_data():
    num_samples = 30000 # 총 30,000개의 샘플만 사용
    encoder_input, decoder_input, decoder_target = [], [], []

    with open("fra.txt", "r", encoding = encoding) as lines:
        ## fra.txt에서 \t(tab)을 기준으로 입력 문장(scr_line)과 출력 문장(tar_line)을 분리
        for i, line in enumerate(lines): 
            scr_line, tar_line, _ = line.strip().split('\t')
            
            ## Encoder에 사용할 source인 영어 데이터 전처리 
            scr_line = [w for w in preprocess_sentence(scr_line).split()]
            
            ## Decoder에 사용할 target인 프랑스어 데이터 전처리
            tar_line = preprocess_sentence(tar_line)
            # 훈련 과정에서 teacher forcing을 사용하기 위해 디코더의 입력 시퀀스와 출력 시퀀스를 따로 분리하여 저장
            # 다음과 같은 기준으로 타겟 문장 처리 
            tar_line_in = [w for w in ("<sos> " + tar_line).split()] # 입력 시퀀스에는 시작을 의미하는 토큰인 <sos> 추가 # 디코더의 입력 시퀀스
            tar_line_out = [w for w in (tar_line+ " <eos>").split()] # 출력 시퀀스에는 종료를 의미하는 토큰으로 <eos> 추가 # 디코더의 출력 시퀀스
            
            encoder_input.append(scr_line)
            decoder_input.append(tar_line_in)
            decoder_target.append(tar_line_out)

            if i == num_samples-1: # num_samples개의 샘플만 처리
                break
    return encoder_input, decoder_input, decoder_target

■ 각 데이터의 3개 샘플을 출력한 결과는 다음과 같다.

en_input, fra_input, fra_output = load_preprocessed_data()
print('Encoder Input:', en_input[:3])
print('Decoder Input:', fra_input[:3])
print('Decoder Output(Label):', fra_output[:3])
```#결과#```
Encoder Input: [['go', '.'], ['go', '.'], ['go', '.']]
Decoder Input: [['<sos>', 'va', '!'], ['<sos>', 'marche', '.'], ['<sos>', 'en', 'route', '!']]
Decoder Output(Label): [['va', '!', '<eos>'], ['marche', '.', '<eos>'], ['en', 'route', '!', '<eos>']]
````````````

- 출력 결과를 보면, Decoder의 입력은 <sos> 토큰으로 시작하고, 출력은 <eos> 토큰으로 끝나는 것을 볼 수 있다.

■ teacher forcing은 훈련 과정에서 예측 결과 대신 실제 정답(실제값)을 입력으로 사용하기 때문에, teacher forcing을 너무 과하게 사용하면

'추론(테스트) 과정에서 생성되는 출력 시퀀스'와 '학습 과정에서 teacher forcing을 통해 실제 정답 시퀀스에 가깝게 유지되도록 강제되어 생성된 출력 시퀀스'는 시퀀스 생성 방식에 차이가 있으므로 노출 편향(exposure bias) 문제가 발생한다. 

- 노출 편향 문제란  훈련 시 모델이 실제 데이터 분포에서만 학습되고, 자신이 생성한(예측한) 데이터로는 학습되지 않는 문제를 말한다.

■ 이러한 학습과 추론 단계에서의 차이로 인해 실제 추론 시 성능이 저하될 수 있다. 

■ 이런 문제를 완화하기 위해 보통 랜덤 확률로 teacher forcing 사용을 조절하거나(일정 확률로만 teacher forcing을 사용하거나), 모델이 충분히 학습되지 않은 초기 단계. 즉, 정확성이 부족한 초반에만 사용한다. 


1.2 데이터셋 구축

1.2.1 단어 집합(vocabulary), 정수 인덱스, 패딩(padding) 

■ 번역 작업에서는 서로 다른 언어(이 예에서는 영어와 프랑스어)를 다루게 되므로, 각 언어별로 고유한 단어 집합(vocabulary)을 생성해야 한다. 이를 위해 다음과 같은 규칙을 적용하여 단어 집합을 생성하는 함수를 정의한다.

- 패딩을 나타내는 토큰은 정수 인덱스 0, 사전에 없는 단어(OOV)를 처리하기 위한 토큰은 정수 인덱스 1에 매핑.

- 이후 빈도수가 높은 단어들은 정수 인덱스 2부터 시작하여 빈도수 기준으로 정수 인덱스를 부여

from collections import Counter

def create_vocabulary(sents):
    word_list = []
    for sent in sents: # 문장으로 분해
        for word in sent: # 단어로 분해
            word_list.append(word) # 단어 리스트에 단어 추가
            
    word_counts = Counter(word_list)  # 각 단어별 등장 빈도를 계산
    vocab = sorted(word_counts, key = word_counts.get, reverse = True) # 등장 빈도가 높은 순서로 정렬

    word_to_index = {}
    word_to_index['<PAD>'] = 0
    word_to_index['<UNK>'] = 1

    for index, word in enumerate(vocab):
        word_to_index[word] = index + 2

    return word_to_index

■ 현재 프랑스어는 <sos>로 시작하는 리스트와 <eos>로 끝나는 리스트로 나누어져 있다. <sos>와 <eos> 토큰도 프랑스어 단어 집합에 추가하기 위해 다음과 같이 하나의 프랑스어 단어 집합을 생성한다. 

src_vocab = create_vocabulary(en_input)
tar_vocab = create_vocabulary(fra_input + fra_output)

# 영어 단어 집합의 크기, 프랑스어 단어 집합의 크기
len(src_vocab), len(tar_vocab)
```#결과#```
(4287, 7476)
````````````

- <sos> 토큰과 <eos> 토큰의 인덱스를 확인하면 다음과 같다. 

print(tar_vocab['<sos>'])
print(tar_vocab['<eos>'])
```#결과#```
3
4
````````````

■ 이제,en_input, fra_input, fra_output = load_preprocessed_data()로 불러온 문장들에 대하여 단어 집합을 이용해서 정수 인덱스를 부여한다.

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)
decoder_input = texts_to_sequences(fra_input, tar_vocab)
decoder_target = texts_to_sequences(fra_output,tar_vocab)

len(encoder_input[0]), len(encoder_input[100])
```#결과#```
(2, 3)
````````````

정수 인코딩한 결과 위와 같이 같은 데이터라도 길이가 다른 것을 확인할 수 있다. 그러므로 패딩 처리가 필요하다. 

■ 패딩 처리를 하기 위해 다음과 같이 데이터 내 최대 길이로 패딩하는 함수를 적용하여 정수 인코딩 결과에 적용한다.

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_input)
decoder_input = pad_sequences(decoder_input)
decoder_target = pad_sequences(decoder_target)

print(encoder_input.shape, decoder_input.shape, decoder_target.shape)
```#결과#```
(30000, 7) (30000, 16) (30000, 16)
````````````

encoder_input[:3], decoder_input[:3], decoder_target[:3]
```#결과#```
(array([[27,  2,  0,  0,  0,  0,  0],
        [27,  2,  0,  0,  0,  0,  0],
        [27,  2,  0,  0,  0,  0,  0]]),
 array([[  3,  64,  10,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
           0,   0,   0],
        [  3, 202,   2,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
           0,   0,   0],
        [  3,  26, 454,  10,   0,   0,   0,   0,   0,   0,   0,   0,   0,
           0,   0,   0]]),
 array([[ 64,  10,   4,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
           0,   0,   0],
        [202,   2,   4,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
           0,   0,   0],
        [ 26, 454,  10,   4,   0,   0,   0,   0,   0,   0,   0,   0,   0,
           0,   0,   0]]))
````````````

■ 처음에 영어와 프랑스어를 분리할 때, 서로 대응되는 두 언어를 분리한 것이므로 위의 정수 인코딩 결과도 순서대로 정렬되어 있는 상태이다. 그러므로 영어-프랑스어 정수 시퀀스 리스트들의 순서를 섞은 다음 train, valid, test set을 분리한다.

indices = np.arange(len(encoder_input))
np.random.shuffle(indices)

encoder_input = encoder_input[indices]
decoder_input = decoder_input[indices]
decoder_target = decoder_target[indices]

 

total_length = len(encoder_input)
ratio = int(total_length * 0.2)
ratio
```#결과#```
6000
````````````

split_index_train = total_length - 2 * ratio  # train 세트 끝 인덱스
split_index_valid = total_length - ratio      # validation 세트 끝 인덱스
split_index_train, split_index_valid
```#결과#```
(18000, 24000)
````````````

## 인코더 입력 시퀀스
encoder_input_train = encoder_input[:split_index_train]
encoder_input_valid = encoder_input[split_index_train:split_index_valid]
encoder_input_test = encoder_input[split_index_valid:]

## 디코더 입력 시퀀스
decoder_input_train = decoder_input[:split_index_train]
decoder_input_valid = decoder_input[split_index_train:split_index_valid]
decoder_input_test = decoder_input[split_index_valid:]

## 타깃
decoder_target_train = decoder_target[:split_index_train]
decoder_target_valid = decoder_target[split_index_train:split_index_valid]
decoder_target_test = decoder_target[split_index_valid:]

print(encoder_input_train.shape, encoder_input_valid.shape, encoder_input_test.shape)
print(decoder_input_train.shape, decoder_input_valid.shape, decoder_input_test.shape)
print(decoder_target_train.shape, decoder_target_valid.shape, decoder_target_test.shape)
```#결과#```
(18000, 7) (6000, 7) (6000, 7)
(18000, 16) (6000, 16) (6000, 16)
(18000, 16) (6000, 16) (6000, 16)
````````````

1.2.2 데이터셋 및 데이터로더 생성

■ 먼저, 각각의 넘파이 배열 데이터 셋을 파이토티 첸서로 변환한다. 이때, 토큰의 정수 인덱스를 그대로 유지하기 위해서 다음과 같이 데이터 타입은 torch.long으로 설정한다.

import torch

encoder_input_train_tensor = torch.tensor(encoder_input_train, dtype=torch.long)
decoder_input_train_tensor = torch.tensor(decoder_input_train, dtype=torch.long)
decoder_target_train_tensor = torch.tensor(decoder_target_train, dtype=torch.long)

encoder_input_valid_tensor = torch.tensor(encoder_input_valid, dtype=torch.long)
decoder_input_valid_tensor = torch.tensor(decoder_input_valid, dtype=torch.long)
decoder_target_valid_tensor = torch.tensor(decoder_target_valid, dtype=torch.long)

encoder_input_test_tensor = torch.tensor(encoder_input_test, dtype=torch.long)
decoder_input_test_tensor = torch.tensor(decoder_input_test, dtype=torch.long)
decoder_target_test_tensor = torch.tensor(decoder_target_test, dtype=torch.long)

■ 다음으로, 파이토치의 TensorDataste을 사용하여 정수 텐서들을 각각 train, valid, test set으로 묶는다. TensorDataset은 텐서들을 묶어서 데이터셋으로 만들어주는 역할을 한다.

■ 그리고 파이토치의 DataLoader를 사용하여 학습용, 검증용, 테스트용 데이터로더를 생성한다. 이때,

학습용 데이터로더는 모델이 입력되는 데이터 순서를 암기해서 학습하지 않도록 shuffle = True로 설정한다.

- 이 예에서 배치 크기는 128로 설정하였다. 

# 데이터셋 및 데이터로더 생성
batch_size = 128

train_dataset = TensorDataset(encoder_input_train_tensor, decoder_input_train_tensor, decoder_target_train_tensor)
train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)

valid_dataset = TensorDataset(encoder_input_valid_tensor, decoder_input_valid_tensor, decoder_target_valid_tensor)
valid_dataloader = DataLoader(valid_dataset, batch_size=batch_size, shuffle=False)

test_dataset = TensorDataset(encoder_input_test_tensor, decoder_input_test_tensor, decoder_target_test_tensor)
test_dataloader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

■ DataLoader는 데이터셋을 배치 크기 단위로 나누어 모델에 입력할 수 있도록 해주는 Iterable이며, Dataset을 바탕으로 __get__iterator 메서드를 통해 iterator를 생성한다.


1.3 Encoder

예제에 사용할 임베딩 계층의 차원과 은닉 상태 벡터의 차원은 다음과 같다.

# embedding 계층의 차원(임베딩 벡터의 차원)과 은닉 상태 h의 차원(은닉 상태 벡터의 차원)
embedding_dim, hidden_units = 256, 256

■ 그리고 Encoder와 Decoder의 임베딩 계층 입력 차원으로 단어 집합의 크기를 지정해야 한다.

단, Encoder는 영어 시퀀스를 다루고 Decoder는 프랑스어 시퀀스를 다루므로 Encoder는 영어 단어 집합의 크기를, Decoder는 프랑스어 단어 집합의 크기를 입력 차원으로 설정해야 한다. 

Encoder를 구성할 계층(layer)는 임베딩 계층, LSTM 계층이다. 

torch.nn.Embedding(num_embeddings, embedding_dim, padding_idx=None, ...)
- num_embeddings: 임베딩할 단어들의 개수 = 단어 집합의 크기
- embedding_dim: 임베딩 벡터의 차원
- padding_idx: 패딩을 위한 토큰의 인덱스를 알려주기 위해 사용
torch.nn.LSTM(input_size, hidden_size, num_layers=1, batch_first=False, bidirectional=False, ...)
- input_size: input x에 대한 features의 수
- hidden_size: hidden state의 features의 수
- num_layers: LSTM 계층을 stacking할 수
- batch_first: True로 설정시 batch_size를 첫 번째 차원에 위치 시킴
- batch_first:
-- batch_firts = True -> (batch, seq, feature), batch_first = False -> (seq, batch, feature)
-- batch_firts의 True/False 여부는 hidden_state와 cell_state에 영향을 미치지 않음
- bidirectional는 양방향 LSTM 구현 여부로 기본값은 False

■ Encoder 클래스를 다음과 같이 임베딩 계층과 LSTM 계층으로 구성한다면, Encoder의 입력 시퀀스는 임베딩 계층을 통과하여 고정 크기의 밀집 벡터(dense vector)인 임베딩 벡터(embedding vector)로 변환되고,

Encoder의 LSTM 계층은 이 임베딩 벡터를 입력으로 받아, 시퀀스의 순서 정보(time step)를 고려하여 모든 입력 시퀀스의 정보를 압축한다.

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 계층

■ Encoder 클래스의 순방향 전파(forward pass) 메서드인 forward( )는 다음과 같이 입력 시퀀스를 받아 Encoder LSTM 계층의 마지막 시점(time step)의 은닉 상태(hidden state)와 셀 상태(cell state)를 반환해야 한다. Decoder에 전달해야 되기 때문이다.

    def forward(self, x):
        # 현재 x.shape: [batch_size, sequence_length]
        embedded = self.embedding(x) # 입력값을 embedding_dim 차원으로
        # 현재 embedded.shape: [batch_size, sequence_length, embedding_dim]

        # [batch_size, sequence_length, embedding_dim]를 입력으로 받아 hidden_dim 크기의 hidden state와 cell state 출력
        _, (hidden, cell) = self.lstm(embedded) 
        # 현재 hidden.shape: [num_layers * bidirectional, batch_size, hidden_dim] = [1, batch_size, hidden_dim]
        # 현재 cell.shape: [num_layers * bidirectional, batch_size, hidden_dim] = [1, batch_size, hidden_dim]
        # _은 원래 lstm의 output 자리, encoder_output.shape: [batch_size, sequence_length, hidden_dim * bidirectional] = [batch_size, sequence_length, hidden_dim]
        return hidden, cell

Encoder의 forward( ) 메서드를 보면, 먼저 크기가 [batch_size, seq_len]인 입력 시퀀스 x를 입력으로 받는다. 이때 x는 토큰의 정수 인덱스이다.

- x의 형상 [batch_size, seq_len]에서 두 번째 차원인 sequence_length는 현재 정수 인덱스이기 때문이다. 

이 입력 시퀀스 x는 임베딩 계층을 통과하며, 임베딩 계층에서는 토큰의 정수 인덱스에 룩업 테이블(lookup table)을 통해 밀집 벡터로 변환하는 연산을 수행한다.

예를 들어, 단어 집합의 크기가 패딩 토큰을 포함해 총 100개라고 했을 때, sequence_length = 7인 입력 x = [47, 26, 19, 34, 5, 0, 0]이 있다고 하자.

x = [47, 26, 19, 34, 5, 0, 0]에서 47은 단어 집합에서 47번째 단어, 26은 26번째 단어를 의미하며, 0은 패딩 토큰 <pad>를 나타낸다.

■ 이 입력이 pad_idx = 0으로 설정된 임베딩 계층을 통과하면 룩업 테이블 연산이 수행된다. 이 과정에서 각 정수 인덱스는 임베딩 벡터로 변환된다.

- 예를 들어, 47번 단어는 차원이 embedding_dim인 벡터로, 26번 단어 또한 동일한 차원의 벡터로 변환되며, 나머지 정수들도 같은 방식으로 처리된다. 그리고 입력 x에 포함된 0은 pad_idx = 0으로 인해 임베딩 계층에서 패딩 토큰으로 인식된다.

■ 그러므로 sequence_length를 가지는 입력 x가 임베딩 계층(self.embedding( ))에 들어가면, 반환되는 x는 [sequence_length, embedding_dim]의 크기를 가지게 된다. 

■ 이때, 배치 크기(batch size)가 batch_size라면,  [sequence_length, embedding_dim]의 크기를 가지는 텐서가 batch_size의 개수만큼 있는 것이다. 즉, [batch_size, sequence_length, embedding_dim] 형태의 3차원 텐서로 표현된다.

■ 그다음, Encoder의 LSTM 계층은 [batch_size, sequence_length, embedding_dim] 형태의 3차원 텐서를 입력으로 받는다. [sequence_length, embedding_dim] 크기를 가지는 batch_size 개의 텐서를 입력으로 받는 것이다.

■ LSTM 계층은 [batch_size, sequence_length, embedding_dim] 크기의 텐서를 hidden_dim의 차원의 은닉 상태(hidden state) h를 만들게 된다. 

■ LSTM 계층에서 반환되는 것은 ① outputs ② hidden state ③ cell state이다. 

outputs는 모든 시간 스텝(time step)에 대한 hidden states를 나타내며,

- hidden state와 cell state는 마지막 time step의 hidden state와 cell state이다. 

■ LSTM layer를 이용하여 seq2seq를 만들 경우, Encoder에서 Decoder로 전달해줘야 하는 것은 마지막 시점의 hidden state와 cell state이다. 

■ outputs의 shape은 모든 time step의 hidden state이므로 [batch_size, sequence_length, hidden_dim * num_directions]가 된다. sequence의 개수(sequence_length)만큼 time step이 있기 때문이다. 

그리고 hidden_dim은 hidde_dim * bidirectional. 현재 양방향 LSTM이 아니므로 bidirectional = 1이라고 볼 수 있다.  

■ 그러므로 이 예에서 outputs의 shape은

[batch_size, sequence_length, hidden_dim * bidirectional] = [batch_size, sequence_length, hidden_dim]이 된다.  

■ 마지막 시점의 hidden state와 cell state의 shape은 정확하게는 [num_layers * bidirectional, batch_size, hidden_dim]이다.  

■이 예에서 입력-출력 방향으로 쌓는 LSTM layer의 수는 1개이므로 num_layers = 1. 그리고 단방향 LSTM이므로 bidirectional = 1이다.

그러므로 이 예에서 반환되는 마지막 은닉 상태(hidden state)와 셀 상태(cell state)의 차원은 [1, batch_size, hidden_dim]가 된다.   

■ LSTM layer를 이용한 seq2seq의 Encoder에서 Decoder로 전달해야 할 context vector는 Encoder의 마지막 시점(time step)의  은닉 상태(hidden state)와 셀 상태(cell state)이다.

그러므로 Encoder에서는 마지막 은닉 상태(hidden state)와 셀 상태(cell state)를 반환해야 한다. 


1.4 Decoder

■ Decoder는 Encoder에서 생성된 context vector를 기반으로 출력 시퀀스를 생성하는 역할을 한다.

■ Decoder 또한 임베딩 계층과 LSTM 계층으로 구성되어 있다고 했을 때, Decoder의 LSTM 계층은 Encoder로부터 전달받은 은닉 상태와 셀 상태를 Decoder LSTM 계층의 초기 상태로 사용하여 Decoder의 입력 시퀀스에 대한 출력 시퀀스를 생성한다.

■ 생성된 출력 시퀀스는 완전 연결 계층을 통과시켜 각 시점(time step)의 출력 토큰에 대한 점수(score)를 얻을 수 있다. 

■ Decoder 클래스를 임베딩 계층, LSTM 계층, Affine 계층으로 구현한다면 다음과 같다. forward( ) 메서드는 Decoder의 입력 시퀀스, 은닉 상태 & 셀 상태를 받아 출력 시퀀스, 업데이트된 은닉 상태 & 상태를 반환 한다. 

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, hidde_dim, batch_first = True) # LSTM 계층
        self.fc = nn.Linear(hidden_dim, tar_vocab_size) # Affine 계층 # 
    
    def forward(self, x, hidden, cell):
        # 현재 x.shape: [batch_size, sequence_length]
        embedded = self.embedding(x)
        # 현재 embedded.shape: [batch_size, sequence_length, embedding_dim]
        
        # 초기 디코더의 LSTM 계층이 받는 입력은 1. LSTM으로 구성된 인코더의 결과인 hidden state와 cell state와 2. 디코더의 첫 번째 시점의 입력
        output, (hidden, cell) = self.lstm(embedded, (hidden, cell))
        # 현재 output.shape: [batch_size, sequence_length, hidden_dim * bidirectional] = [batch_size, sequence_length, hidden_dim]
        # 현재 hidden.shape: [1, batch_size, hidden_dim]
        # 현재 cell.shape: [1, batch_size, hidden_dim]
        
        output = self.fc(output)
        # 현재 output.shape: [batch_size, sequence_length, tar_vocab_size]
        return output, hidden, cell

■ 이 예제의 목적은 Encoder에 영어 시퀀스를 입력하면, 언어 모델(language model)인 Decoder가 이를 바탕으로 프랑스어 시퀀스를 생성하는 것이다.  

■ 그러므로 Encoder의 임베딩 계층의 입력 차원은 영어 단어 집합의 크기로 설정하고, Decoder의 임베딩 계층의 입력 차원은 프랑스어 단어 집합의 크기로 설정해야 한다. 

■ 마지막으로 어파인 계층은 입력 차원을 LSTM 계층의 출력 차원인 hidden_dim으로, 출력 차원을 타겟 언어(프랑스어)의 단어 집합 크기인 tar_vocab_size로 설정하여, Decoder LSTM에서 나온 hidden state를 프랑스어 단어 집합의 크기로 변환한 뒤 각 단어의 점수(score)를 계산한다. 

■ 이때, 어파인 계층이 전달받는 것은 모든 시점의 은닉 상태 벡터들이다. 그 이유는 이 예제에서 하고자 하는 기계 번역은 Encoder-Decoder로 many-to-many 형태로 구현하기 위함이다.  

■ 그러므로, Decoder의 LSTM 계층에서 매 시점마다 출력되는 은닉 상태 벡터 전체(모든 은닉 상태 벡터)를 각각의 Affine 계층에 전달하여, 각 시점별로 출력 토큰에 대한 점수(score)를 계산하도록 한다..


1.5 Seq2Seq 구현

■ 1.3, 1.4에서 정의한 Encoder와 Decoder를 다음과 같이 연결하여 seq2seq를 구현하면 된다. 

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

■ seq2seq 모델의 forward 메서드는 입력 시퀀스(src)와 출력 시퀀스(trg)를 인자로 받는다.

■ Encoder에서 입력 시퀀스(src)를 기반으로, Encoder의 마지막 시점의 은닉 상태와 셀 상태를 생성해서 Decoder에 전달하면, 언어 모델인 Decoder는 전달받은 은닉 상태와 셀 상태 그리고 출력 시퀀스(trg)를 입력으로 사용하여 출력 시퀀스를 생성한다.

■ 단, Decoder와 seq2seq 모델을 위와 같이 정의한다면, Encoder의 마지막 시점 은닉 상태와 셀 상태를 Decoder에 한 번에 넣어 전체 시퀀스를 처리하게 된다. 

즉, 위와 같은 구현은 정답을 Decoder에 입력으로 제공하지 않기 때문에 교사 강요가 적용되지 않은 구현이다.


1.6 교사 강요(teacher forcing) 구현

■ 기계 번역의 궁극적인 목표는 새로운 문장을 생성하는 것이다. 

■ 순환 신경망 구조를 사용할 경우, 첫 번째 토큰을 생성한 뒤 이를 다음 토큰의 생성을 위한 입력으로 사용한다. 즉, 현재 출력 시퀀스를 생성하기 위해 이전에 생성된 토큰들을 참고하는 방식으로 동작한다.

■ 그러나 이러한 동작 방식은 초기 학습 단계에서 모델의 파라미터가 랜덤하게 초기화되어 있다면, 생성된 결과는 무의미하거나 특정 패턴에 치우치는 등 엉터리일 가능성이 높다. 

- RNN (2) 에서 문자 단위로 모델을 학습할 때 다음과 같이 학습 초기에는 특정 문자를 반복하거나 의미 없는 문자열을 출력하는 것을 확인할 수 있었다. 이는 모델 가중치가 무작위로 초기화되어 있기 때문이다.

for i in range(2001):
    optimizer.zero_grad()
    output = model(X) # ouput.shape: (627, 10, 24) # 24는 클래스의 개수
    # loss_function((N, C), C) = (batch_size * time steps(=seq_len), # of class), (batch_size * time steps(=seq_len))
    loss = loss_function(output.view(-1, output.size(-1)), y.view(-1)) 
    loss.backward()
    optimizer.step()
    predicted = output.argmax(dim=2) # dim2 <=> time step # time step마다 가장 높은 확률을 가진 정수 인덱스 선택
    # predicted.shape: (627, 10) 
    predicted_str = ''.join([index_to_char[int(token)] for token in predicted[:, 0]])
    if i % 200 == 0: print(i, ':', predicted_str);print()
```#결과#```
0 : naknoooooknnnnnooarnanoanakooanoponnaoonanaoanoonnoooknnnnkkoaoooaoonknonnonnnkononnornnonaoohnaonoooaoookannnkkooanoponnaoonnnooaooonnonanaooookoononnonknononoonnknoaoooknaooaanoonaoknanakonnonnakookoonnnnoooonnaoooaaooaoonnknnnknnaoononoannoonknaanakoooonaoaaanoooonanaoonooooooaoooanaoonanooooooaoooknnnanoonoanaoonnknnnoaoooaoonknoooannoooaoononnonknooakonnknooonooaannnnknoaoonoooknknanaonoaoooroonnoanoaaaoonknknnknnooooaooooanooaknoookkononnoaoooaoooknknoooooonaoknonnnnnaanoaonoooonpoaoooaaorknnoooaoonknnkooraaoaoooaoonanoaookannnaooaanokaoonanooaoonoannoonnaaaoananknnoaooomooaaoonnooakoanpoonooorooknnannknoaokoonknk
200 :                    e   e          e    a                         e                       e    e                        e                a            e  a   e       a          a e    e   e        a      e                 e                       e           a          a                  a  a e      e             a         a     e    e              e  a       e      e    e              e   e    a   e        e      a          e        a e               e    e        e                            e            a        a     e        e                e          e          e a            e          a       e    e a         a   
400 : e    e ran  rea e re ersed  ere i ees  aaa ese e t ran  rea ee t e  att e ae e  eaaen   re e se  e  ae ran  rea  ere i ees i a e t ese  ana  ea a ta tanae rere sea a e resee ea e sere  red  e se a and res a re en  eor t e sea aeatan  sereaeee re e sea in  a  are in  ana  it ea t e t ere  are e  t e ran  rea se ied e ee  a a t e  are e aen  oe ta ta al and real ta e iareaea  aa its erese e  ere t e re re re s earanle  t  t e e  se  are a e eoe e a t e  are e e a re ae ren tare e  e serei a t e  atan  oe tan  a e ri a t e se e e e rerae ear e e sereore se ree oea e ese atan s a s are    aes aaaa  e t e i sereal e  t  ana 
...,

2000 : eng no valkyria unrecorded chronicles japanese lit valkyria of the battlefield commonly referred to as valkyria chronicles iii outside japan is a tactical role playing video game developed by sega and media vision for the playstation portanle released in january in japan it is the third game in the valkyria series emploling the same fusion of tactical and real time gameplay as its predecessors the story runs parallel to the first game and follors the nameless a penal military unit serving the nation of gallia during the second europan jar cho perform secret black operations and are pitted against the imperial unit calam
````````````

 

■ 엉터리 결과를 기반으로 가중치를 업데이트하면, 모델은 잘못된 방향으로 학습되어 점점 더 부정확한 결과를 생성할 수 있다. 이를 방지하기 위해 교사 강요 기법을 사용하는 것이다.
■ 교사 강요는 지도 학습에서 실제 정답 데이터(label data)를 입력으로 사용하는 방법이다. 학습 과정에서 모델이 생성한 토큰을 다음 토큰을 생성하기 위한 입력으로 사용하는 대신, 실제 Ground Truth(label data) 토큰을 입력으로 사용하는 것이다.

교사 강요는 훈련 과정에서 현재 시점의 출력 시퀀스 생성을 위해 이전 시점의 출력(예측) 결과 대신, 실제 정답(실제값)을 입력으로 사용하기 때문에 교사 강요를 지나치게 사용하면, 위에서 언급한 노출 편향 문제가 발생할 수 있다. 

 

 

■ 교사 강요를 적용하기 위해서는 기존의 Decoder 클래스와 seq2seq 클래스의 forward() 메서드를 다음과 같이 수정해야 한다.

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, input, hidden, cell):
        # input.shpae: [batch_size] -> 각 배치의 토큰 인덱스
        input = input.unsqueeze(1) # [batch_size, 1]로 차원 확장
        embedded = self.embedding(input) 
        # 현재 embedded.shape: [batch_size, 1, embedding_dim]
        output, (hidden, cell) = self.lstm(embedded, (hidden, cell))
        # 현재 output.shape: [batch_size, 1, hidden_dim]
        output = self.fc(output.squeeze(1)) # 출력 시퀀스의 점수(score) 계산 
        # 현재 output.shape: [batch_size, tar_vocab_size]
        return output, hidden, cell # 출력 시퀀스, hidden state, cell state 반환

■ 교사 강요를 하기 위해 들어온 input은 단일 시점의 토큰이다. 이때, 배치를 사용한다면 input의 shape은 [batch_size]가 될 것이다.  input 인수는 현재 시점에서 Decoder에 입력으로 들어갈 토큰의 인덱스(정수)이다.

■ 이 Decoder의 임베딩 계층은 [batch_size, seq_len], LSTM 계층은 [batch_size, seq_len, embedding_dim]의 크기를 입력을 기대하므로 seq_len 차원이 필요하다.

- 그래서 unsqueeze(1)을 통해 [batch_size, 1] 크기로 만들어 '길이 1인 시퀀스'로 만들어 준다. 이렇게 해야 LSTM의 입력 차원 [batch_size, seq_len, embedding_dim]과 맞출 수 있다.

- Decoder의 LSTM 계층을 통과한 결과로 반환되는 output의 크기는 [batch_size, 1, hidden_dim]이 된다.

- batch_size가 있는 경우 fc 계층(nn.Linear)에 입력으로 들어가야할 크기는 [batch_size, hidden_dim]이 되어야 하므로 squeeze(1)을 통해 차원의 수가 1인 두 번째 차원을 제거하고 [batch_size, hidden_dim] 형태로 맞춰준 뒤 fc 계층에 넣어 단일 토콘의 점수(score)를 계산한다.

import random

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

    def forward(self, src, trg, device, teacher_forcing_ratio=0.5):
        # src.shape: [batch_size, src_len], trg.shape: [batch_size, trg_len]
        batch_size = src.size(0) # src.size(0) = trg.size(0) = batch_size
        trg_len = trg.size(1) # trg.size(1) = trg_len
        trg_vocab_size = self.decoder.fc.out_features # self.decoder.fc.out_features = trg_vocab_size

        # 디코더의 출력 시퀀스 생성을 저장할 텐서 # GPU로 손실, 정확도 계산하기 위해 device = cuda 할당
        outputs = torch.zeros(batch_size, trg_len, trg_vocab_size).to(self.device)

        # 인코더의 최종 hidden state와 cell state 반환
        hidden, cell = self.encoder(src)

        # 디코더의 첫 입력으로 <sos> 토큰이 되도록 trg에서 <sos> 토큰의 인덱스(정수)를 뽑아 input에 할당
        input = trg[:,0] # 모든 행에서 각 행의 첫 번째 원소 == <sos> 토큰의 인덱스(정수)
        for t in range(1, trg_len): # <eos> 토큰이 나오는 마지막 시점을 제외하고 모든 시점(time step)에 대해
            output, hidden, cell = self.decoder(input, hidden, cell)
            outputs[:, t, :] = output # outputs에 Decoder 클래스의 반환 결과인 output 저장 # 첫 번째 차원(batch_size)을 유지한 채, t번째 time step에 대한 결과 저장

            ## teacher forcing 적용 여부 결정
            teacher_force = random.random() < teacher_forcing_ratio # 랜덤으로 생성된 0과 1사이의 값 < 0.5이면 teacher_force = True
            
            top1 = output.argmax(1) # 예측 결과 중 가장 점수가 높은 토큰 선택
            if teacher_force: input = trg[:, t] # teacher forcing을 적용하는 경우에는 실제 정답 토큰(trg[:, t])을 다음 입력으로 사용
            else: input = top1 # 적용하지 않는 경우에는 예측 토큰(top1)을 다음 입력으로 사용
        return outputs

■ src와 trg는 데이터로더에서 model = seq2seq()가 전달받을 영어 시퀀스와 프랑스어 시퀀스이다. (정확하게는 각 언어의 배치 데이터) 

■ 이 예에서 패딩이 적용된 src의 시퀀스 길이는 7, trg는 16이며, src와 trg의 크기는 [batch_size, seq_len]이다. 모델 인스턴스를 호출할 때, seq2seq 클래스의 forward 메서드에 src와 trg 그리고 교사 기법을 사용할 확률을 입력으로 받는다.

■ Decoder의 출력(예측 결과)을 저장하기 위해 [타깃 시퀀스 길이, 배치 크기, 디코더 출력 차원(프랑스어 단어 집합 크기)]
형태의 텐서를 생성한다. 이 턴서에 각 time step마다의 예측값을 저장한다. 

■ seq2seq 모델은 입력 시퀀스를 Encoder에 통과시켜서 Encoder의 context vector를 Decoder에 전달한다. 

■ 그러면 Decoder는 Encoder로부터 전달 받은 context vector와 Decoder의 입력을 통해 각 시점 t마다 현재의 Decoder의 입력 토큰, 이전 시점의 은닉 상태와 셀 상태를 입력으로 받아 다음 토큰에 대한 예측. 즉, 출력 시퀀스를 생성한다.  

■ 이때, Decoder의 초기 입력으로 시작 토큰 <sos>를 사용한다. input = trg[0, :]

■ 예측된 출력은 초기화해둔 outputs 텐서에 저장된다. 

■ 교사 강요 비율(teacher_forcing_ratio)은 모델 훈련 과정에서 사용된다. 

■ random.random() < teacher_forcing_ratio를 통해 랜덤 확률에 기반하여 티처 포싱을 적용할지 결정한다. 이것은 교사 강요를 과도하게 사용하지 않도록 조절하기 위한 메커니즘이다.

■ 교사 강요를 사용하는 경우 실제 타깃 시퀀스의 다음 토큰(trg[t])을 Decoder의 다음 입력으로 사용하고, 교사 강요를 사용하지 않는 경우 Decoder의 현재 출력에서 가장 높은 점수(score)를 가진 토큰(top1)을 다음 입력으로 사용하면 된다.
이 과정을 통해 학습 과정에서는 모델이 정답 정보를 참고할 수 있도록 만들어준다.

추론(예측) 과정에서는 teacher_forcing_ratio의 값을 0으로 지정하여 교사 강요가 진행되지 않도록 할 것이다.

■ 위와 같은 과정을 모든 time step(=seq_len)만큼 반복하여 저장된 Decoder의 출력들을 반환한다. 

■ 루프 범위가 1부터 trg_len이므로 <eos> 토큰은 Decoder의 출력 시퀀스를 생성(예측)하기 위한 입력으로 사용되지 않는다.

루프에서 발생하는 일을 다시 정리하면,

- (1) 입력과 이전 은닉 상태 및 셀 상태를 Decoder에 전달한다.
- (2) Decoder로부터 예측값과 다음 은닉 상태 및 셀 상태를 받는다. 
- (3) 예측값(output)을 예측 텐서(outputs)의 time step(=seq_len) 차원에 배치한다. 
- (4) 교사 강요를 사용할지 여부를 결정한다. 
-- 교사 강요를 사용하는 경우 Decoder의 다음 입력은 시퀀스의 실제 정답(ground truth)인 다음 토큰이다.
-- 교사 강요를 사용하지 않는 경우 다음 입력은 시퀀스에서 예측된(생성된) 토큰이며, 이는 출력 텐서에서 argmax를 수행하여 얻을 수 있다.
- (5) 모든 예측을 완료하면 예측으로 가득 찬 출력 텐서 outputs을 반환한다.
- 참고로 Decoder의 루프에서 0이 아닌 1부터 시작한다. 이는 outputs 텐서의 0번째 원소가 모두 0으로 유지됨을 의미한다.
- 그러므로 trg와 outputs의 형태는 trg = [<sos>,y1,y2,y3,<eos>], outputs = [0,ˆy1,ˆy2,ˆy3,<eos>] 

■ 추후, 손실(loss)을 계산할 때, 각 텐서의 첫 번째 요소를 제거하면 된다. trg = [y1,y2,y3,<eos>], outputs = [ˆy1,ˆy2,ˆy3,<eos>]

■softmax 함수를 적용하지 않고도, output.argmax(1)(또는 output.argmax(dim=-1))를 사용해 점수(score)가 가장 높은 토큰을 선택할 수 있다.

■ 그 이유는 파이토치의 nn.CrossEntropyLoss( )는 nn.LogSoftmax( )와 Negative Log Likelihood(NLL)인 nn.NLLLoss( )가 결합되어 구현된 함수이기 때문이다. 

■ 그러므로 output.argmax(dim=-1)만으로도 완전 연결 계층에서 계산된 출력 토큰들의 점수(score) 중 가장 높은 점수를 가진 가진 토큰의 인덱스를 반환할 수 있다. 

■ nn.CrossEntropyLoss()에 내부적으로 softmax가 포함되어 있으므로 output.argmax(dim=-1)은 argmax(softmax(output))으로 볼 수 있다. 그러므로 softmax를 계산하는 계층(layer)을 생략하였다.


1.7 학습

■ Encoder와 Decoder 인스턴스를 다음과 같이 seq2seq 인스턴스의 입력으로 넣으면 된다. 이렇게 구현된 seq2seq 모델은 기계 번역이나 챗봇과 같은 시퀀스-투-시퀀스 문제를 해결하는 데 사용될 수 있다.

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

encoder = Encoder(src_vocab_size,embedding_dim, hidden_units)
decoder = Decoder(tar_vocab_size,embedding_dim, hidden_units)
model = seq2seq(encoder, decoder, device).to(device)

## 모델 구조
print(model)
```#결과#```
seq2seq(
  (encoder): Encoder(
    (embedding): Embedding(4287, 256, padding_idx=0)
    (lstm): LSTM(256, 256, batch_first=True)
  )
  (decoder): Decoder(
    (embedding): Embedding(7476, 256, padding_idx=0)
    (lstm): LSTM(256, 256, batch_first=True)
    (fc): Linear(in_features=256, out_features=7476, bias=True)
  )
)
````````````

■ 언어 모델인 Decoder에서 매 시점마다 프랑스어 단어 집합의 단어(토큰) 7476개 중에서 1개를 선택하는, 즉 multi-calss classification 문제로 볼 수 있다. 그러므로 모델 학습을 위해 크로스 엔트로피 함수를 사용하여 손실을 계산한다.

loss_function = nn.CrossEntropyLoss(ignore_index=0)
optimizer = optim.Adam(model.parameters())

 크로스 엔트로피 함수의 ignore_index 파라미터에 패딩 토큰의 정수 인덱스인 '0'을 설정하여 패딩 토큰에 해당하는 인덱스는 손실 계산에서 무시하도록 설정한다.

https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html

 

CrossEntropyLoss — PyTorch 2.6 documentation

Shortcuts

pytorch.org

 옵티마이저는 Adam을 사용한다.

def train(model, dataloader, optimizer, loss_function, device, teacher_forcing_ratio = 0.5):
    model.train()
    total_loss, total_correct, total_count = 0.0, 0.0, 0.0

    for encoder_inputs, decoder_inputs, decoder_targets in dataloader:
        encoder_inputs = encoder_inputs.to(device)
        decoder_inputs = decoder_inputs.to(device)
        decoder_targets = decoder_targets.to(device)

        optimizer.zero_grad()
        
        ## 순전파
        # encoder_inputs == src, decoder_inputs == trg
        outputs = model(encoder_inputs, decoder_inputs, device, teacher_forcing_ratio = 0.5) 

        ## 손실 계산
        # nn.CrossEntropyLoss() 사용. 차원 제거를 위해
        # 예측값에 view를 사용하여 배치 차원과 시점 차원을 하나로 # 여기서 outputs의 마지막 차원은 input_size였던 vocab_size
        # loss 계산을 위해 decoder_targets도 view를 통해 1차원으로 
        loss = loss_function(outputs.view(-1,outputs.size(-1)), decoder_targets.view(-1)) # ((N, C), C)
        loss.backward()
        optimizer.step()

        total_loss += loss.item()
        
        ## 패딩 토큰 제외하고 정확도 계산
        mask = decoder_targets != 0 
        total_correct += ((outputs.argmax(dim=-1) == decoder_targets) * mask).sum().item() # 예측값 == 실제값 중에서 True인 것만 sum()
        total_count += mask.sum().item() # 총 개수(패딩 토큰 제외)

        Loss, Acc = total_loss / len(dataloader), total_correct / total_count
        
    return Loss, Acc

 

 

 

■ 학습을 위해 정의한 함수 def train은 평가할 모델, 데이터로더, 손실 함수, 모델을 실행할 디바이스 그리고 학습 과정에서 교사 강요(teacher forcing)를 위해 teacher_forcing_ratio 값을 입력으로 받는다. 

■ model.train()을 호출하여 모델을 학습 모드로 설정한 다음, train set에 대한 손실과 정확도를 계산한다. 

def evaluate(model, dataloader, loss_function, device, teacher_forcing_ratio = 0.0):
    model.eval()
    total_loss, total_correct, total_count = 0.0, 0.0, 0.0

    with torch.no_grad():
        for encoder_inputs, decoder_inputs, decoder_targets in dataloader:
            encoder_inputs = encoder_inputs.to(device)
            decoder_inputs = decoder_inputs.to(device)
            decoder_targets = decoder_targets.to(device)

            ## 순전파
            outputs = model(encoder_inputs, decoder_inputs, device, teacher_forcing_ratio = 0.0) 

            ## 손실 계산
            loss = loss_function(outputs.view(-1, outputs.size(-1)), decoder_targets.view(-1))
            total_loss += loss.item()
            
            ## 정확도 계산(패딩 토큰 제외)
            mask = decoder_targets != 0
            total_correct += ((outputs.argmax(dim=-1) == decoder_targets) * mask).sum().item()
            total_count += mask.sum().item()

        Loss, Acc = total_loss / len(dataloader), total_correct / total_count
    return Loss, Acc

■ 평가(검증, 테스트)를 위해 정의한 함수 def evaluate은 평가할 모델, 데이터로더, 손실 함수, 모델을 실행할 디바이스 그리고 teacher_forcing_ratio 값을 입력으로 받는데,

이때 교사 강요를 끄기 위해 teacher_forcing_ratio값은 0으로 지정해준다. 검증과 테스트 과정에서는 교사 강요를 적용하지 않고 Decoder에서 출력 시퀀스를 생성하기 위해서이다. 

■ model.eval()을 호출하여 모델을 평가 모드로 설정한 다음, valid/test set에 대한 손실과 정확도를 계산한다.

■ 두 함수 모두 각 배치(batch)에 대해 인코더 입력, 디코더 입력, 디코더 타겟을 디바이스로 이동시킨다. 그리고 순전파를 수행해서 모델의 예측 결과를 얻은 다음, 실제값인 decoder_target과 손실을 계산한다.  

■ 그리고 패딩 토큰을 제외한 실제 의미 있는 토큰들에 대해서만 정확도를 계산한다. 현재 영어와 프랑스어가 각각 다른 길이를 가지고 있기 때문에 패딩 토큰을 적용했을 때도 각각 길이가 달랐기 때문이다.

- encoder_inputs의 길이는 7, decoder_inputs/targets의 길이는 16

outputs.size(-1)은 seq2seq 모델의 결과. 즉, Decoder의 결과인 outputs이며 outputs의 형상은 [batch_size, trg_len, trg_vocab_size]이므로, outputs의 마지막 차원인 tar_vocab_size값 7476이다. 

그러므로, outputs.view(-1, outputs.size(-1))는 outputs.view(batch_size * trg_len, 7476)이므로 손실 계산을 위한 예측값 outputs의 형상은 (2048, 7476)이다.  

즉, outputs의 행은 배치의 모든 문장(각 문장당 16개의 토큰) = 128 * 16 = 2048개의 토큰 예측 결과이며, 열은 각 행에 대해 7476개의 가능한 단어(클래스)들의 점수(score)를 담고 있다. 

그리고 실제값(target)인 decoder_targets의 형상은 [batch_size, trg_len] = [128, 16]이므로 view(-1)을 통해 평탄화되어 (2048, ). 즉 2048개의 1차원 정답 인덱스가 된다. 

즉, 1 에포크(epoch)당 2048개의 정답 인덱스가 담긴 벡터를 사용하여, 예측 결과인 2048개에 대해 7476가지의 가능한 예측 결과에 크로스 엔트로피 함수를 적용하여 손실(loss)을 계산한다. 
■ 정확히 맞춘 것을 계산하기 위해 7476가지 클래스에 대한 점수(score)를 가지는 예측값(outputs) 중 가장 큰 점수를 가지는 토큰과 실제 정답을 비교하여 정확하게 예측한 개수를 계산한다. 

그리고 정확하게 맞춘 개수를 총 토큰 개수로 나누어 정확도를 계산한다. 또한, 총 손실과 데이터로더의 배치 수로 나누어 평균 손실을 계산한다.  

import math

num_epochs = 20
best_val_loss = float('inf')
val_acc = []

for epoch in range(num_epochs):
    train_loss, train_acc = train(model, train_dataloader, optimizer, loss_function, device, teacher_forcing_ratio = 0.5)
    valid_loss, valid_acc = evaluate(model, valid_dataloader, loss_function, device, teacher_forcing_ratio = 0.0)

    if valid_loss < best_val_loss: 
        print(f'valid loss improved from {best_val_loss:.4f} to{valid_loss:.4f}.체크포인트 저장.')
        best_val_loss = valid_loss # valid_loss 업데이트
        torch.save(model.state_dict(), 'best_model_checkpoint.pth') 
    val_acc.append(valid_acc)
    print(f'Epoch:{epoch+1}/{num_epochs} | Train Loss: {train_loss:.4f} | Train PPL: {math.exp(train_loss):.4f} | 
    	Train Acc: {train_acc:.4f} | Valid Loss: {valid_loss:.4f} | Valid PPL: {math.exp(valid_loss):.4f} | Valid Acc: {valid_acc:.4f}')
    ```#결과#```
    valid loss improved from inf to4.9607.체크포인트 저장.
Epoch:1/20 | Train Loss: 5.4593 | Train PPL: 234.9357 | Train Acc: 0.2405 | Valid Loss: 4.9607 | Valid PPL: 142.6917 | Valid Acc: 0.2870
valid loss improved from 4.9607 to4.6474.체크포인트 저장.
Epoch:2/20 | Train Loss: 4.6478 | Train PPL: 104.3566 | Train Acc: 0.3342 | Valid Loss: 4.6474 | Valid PPL: 104.3156 | Valid Acc: 0.3314
...,
Epoch:9/20 | Train Loss: 3.1503 | Train PPL: 23.3436 | Train Acc: 0.4885 | Valid Loss: 3.9523 | Valid PPL: 52.0541 | Valid Acc: 0.4051
valid loss improved from 3.9523 to3.9248.체크포인트 저장.
Epoch:10/20 | Train Loss: 3.0055 | Train PPL: 20.1962 | Train Acc: 0.5072 | Valid Loss: 3.9248 | Valid PPL: 50.6443 | Valid Acc: 0.4108
valid loss improved from 3.9248 to3.8918.체크포인트 저장.
Epoch:11/20 | Train Loss: 2.8741 | Train PPL: 17.7087 | Train Acc: 0.5247 | Valid Loss: 3.8918 | Valid PPL: 49.0009 | Valid Acc: 0.4166
...,
Epoch:19/20 | Train Loss: 2.1928 | Train PPL: 8.9607 | Train Acc: 0.6453 | Valid Loss: 3.8523 | Valid PPL: 47.1031 | Valid Acc: 0.4288
Epoch:20/20 | Train Loss: 2.1372 | Train PPL: 8.4755 | Train Acc: 0.6569 | Valid Loss: 3.8464 | Valid PPL: 46.8249 | Valid Acc: 0.4331
    ````````````
## 모델 로드
model.load_state_dict(torch.load('best_model_checkpoint.pth'))
model.to(device)

test_loss, test_acc = evaluate(model, test_dataloader, optimizer, loss_function, device, teacher_forcing_ratio = 0.0)
print(f'test acc: {test_acc:.4f} | test_loss: {test_loss:.4f}')
```#결과#```
test acc: 0.4247 | test_loss: 3.8509
````````````

 

 

'자연어처리' 카테고리의 다른 글