Basic Deep Learning/Dive into Deep Learning 리뷰

[D2L] 9. Modern RNN

needmorecaffeine 2022. 8. 8. 11:46

 RNN 계열에서 자주 쓰이는, 더 fancy한 모델에 대해 다뤄보겠다. 

 

1. GRU(Gated Recurrent Unit)

 long products of matrices는 gradient vanishing, exploding 문제를 가진다. 이런 gradient anomaly가 현실에서 어떤 문제를 갖는지 그리고 이를 어떻게 해결해야 할지 살펴보자.

 

 1) 초기의 값이 이후 미래 값의 예측에 있어 매우 중요한 경우 gradient anomlay는 큰 문제가 된다. 따라서 중요한 초기 정보를 저장할 수 있는 메커니즘인 memory cell 기능이 필수적이다. memory cell 기능이 없을 시 이후 연속해서 등장하는 모든 값에 악영향을 끼친다.

 2) 몇몇 시점의 데이터가 학습에 적절하지 않을 수 있다. 이 때 이런 데이터는 skipping하는 메커니즘이 필요하다.

 3) sequence 흐름이 바뀔 수 있다. 책에서 따온 텍스트 데이터에서 책의 챕터가 바뀌는 경우가 그 예시이다.

 

 위 문제에 대해 잘 다룰 수 있는 모델로 등장한 것이 LSTM과 GRU이고 GRU에 대해 먼저 살펴보자.

 GRU가 Vanilla RNN과 비교했을 때 가지는 차이점은 hidden state의 latter support gating이 있다는 것이다. 이것은 위의 필요한 메커니즘에서 언급한 hidden state가 update 또는 reset이 필요한 시점에 그 기능을 하는 메커니즘이 구축되어 있다는 것을 의미한다. 즉, 초기의 데이터가 중요할 경우 hidden state를 update하지 않고 관련성이 없는 일시적인 데이터가 있을 시 skip하며 필요할 때 마지막 state를 reset하는 기능이 있다는 것이다.

 

 먼저 reset gate와 update gate에 대해 살펴보자.

이 게이트들은 (0,1) 엔트리를 가지는 벡터들로 convex combination 수행이 가능하다. reset gate는 이전 state를 얼머나 기억할지를 컨트롤하고 update gate는 new gate가 old gate를 얼마나 카피할지를 컨트롤한다. 두 게이트의 output은 두개의 fully connected layer와 sigmoid activation으로 계산된다. 도식하면 다음과 같다.

내부적인 상세 연산은 아래문단과 같다.

 

 한가지 더 살펴볼 state가 있는데 바로 candiadate hidden state이다. reset gate R와 regular latent state updating mechanism을 통합한 것으로 time step t에서 다음과 같이 연산된다.

 여기서는 tahn activation을 통해 candidate hidden state가 (-1,1) 사이의 값을 갖게 한다. 이것을 candidate라고 명명하는 이유는 이 hidden state는 update gate action을 위해 여전히 필요한 state이기 때문이다. 위 식을 보면 이전 state Ht-1 의 영향력이 Rt와 Ht-1과의 element wise multiplication으로 인해 감소될 수 있다는 것을 확인할 수 있다. Rt의 entries가 1과 가까울 수록 vanilla RNN과 같이 update gate가 거의 구현되지 않고 반대로 0과 가까울수록 Xt를 input으로 한 MLP 연산의 결과와 같아진다. 따라서 0과 가까울수록 이전의 hidden state가 reset되게 되는 것이다. 도식하면 다음과 같다.

 이제는 update gate Zt의 효과가 어떻게 작용하는지 살펴보자.

이전에 말했듯, update gate는 new hidden state Ht가 old state Ht-1을 얼마나 자기고 갈지를 결정하고 이는 candiate hidden state ~Ht를 얼마나 사용할지에 따라 결정된다. Zt는 이를 위해 사용될 수 있는데 단순히 Ht-1과 ~Ht와의 elementwise convex combination으로 연산할 수 있다. GRU의 final update equation을 수식으로 나타내면 다음과 같다.

 수식에서 알 수 있듯이 Zt가 1에 가까우면 old state를 유지하는 것이고 새로 들어온 input Xt는 무시된다. 즉 dependecy chain에서 time step t의 시점을 skip하는 것이다. 반대로 Zt가 0에 가까우면 new state Ht는 ~Ht와 유사해진다.

 

 이런 기능으로 서론에서 얘기한 RNN에서의 vanishing grdient문제를 해결할 수 있고 time step이 길어져도 dependencies를 잘 컨트롤할 수 있게 되었다. update gate가 모든 time step마다 1과 가까운 값을 가진다면 old hidden state가 time step의 길이에 상관없이 끝까지 잘 유지된다. 이전까지 flow들을 정리한 도식은 다음과 같다.

 

 도식에서 알 수 있듯이, Reset gate는 short term dependencies를, Update gate는 long term dependencies를 capture한다.

이제는 GRU를 직접 구현해보자.

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

# Initialize model parameter

def get_params(vocab_size, num_hiddens, device):
  num_inputs = num_outputs = vocab_size

  def normal(shape):
        return torch.randn(size=shape, device=device)*0.01

  def three():
    return (normal((num_inputs, num_hiddens)),
            normal((num_hiddens, num_hiddens)),
            torch.zeros(num_hiddens, device=device))
    
  W_xz, W_hz, b_z = three()  # Update gate parameters
  W_xr, W_hr, b_r = three()  # Reset gate parameters
  W_xh, W_hh, b_h = three()  # Candidate hidden state parameters
  # Output layer parameters
  W_hq = normal((num_hiddens, num_outputs))
  b_q = torch.zeros(num_outputs, device=device)
  # Attach gradients
  params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q]
  for param in params:
      param.requires_grad_(True)
  return params
# Hidden state initialization function : returns a tensor with a shape(batch size, number of hidden units) whose values are all 0

def init_gru_state(batch_size, num_hiddens, device):
  return (torch.zeros((batch_size, num_hiddens), device = device), )
# Define Model

def gru(inputs, state, params):
    W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state
    outputs = []
    for X in inputs:
        Z = torch.sigmoid((X @ W_xz) + (H @ W_hz) + b_z)
        R = torch.sigmoid((X @ W_xr) + (H @ W_hr) + b_r)
        H_tilda = torch.tanh((X @ W_xh) + ((R * H) @ W_hh) + b_h)
        H = Z * H + (1 - Z) * H_tilda
        Y = H @ W_hq + b_q
        outputs.append(Y)
    return torch.cat(outputs, dim=0), (H,)
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_params,
                            init_gru_state, gru)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

 간단하게 API를 통해 구현하면 다음과 같다.

num_inputs = vocab_size
gru_layer = nn.GRU(num_inputs, num_hiddens)
model = d2l.RNNModel(gru_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

 

API를 통해 간단히 구현된 결과만을 보면 다음과 같다.


2. LSTM(Long Short-Term Memory)

이전 장기간의 정보를 보존하고 동시에 짧은 기간의 input은 skip하는 latent variable model은 오랜 기간 고민되어져 왔다. 이런 모델 중에 하나로 LSTM이 고안되었고 GRU와 많은 공통점을 가지고 있지만 더 복잡한 구조를 가지며 GRU보다 좋은 성능을 보였다.

LSTM은 컴퓨터의 logic gate에서 고안되었고 hidden state와 같은 모양을 가지고 추가적인 정보를 기록하기 위한 memory cell을 구현한다. memory cell을 컨트롤하기 위해서는 여러개의 gate가 필요하다.

 1) output gate :  read out the entries from the cell

 2) input gate : decide when to read data into cell 

 3) forget gate : reset the content of the cell

이렇게 세가지 종류의 gate를 가지며 GRU와는 달리 deciding when to remember and when to ignore inputs in the hidden state의 기능을 추가적으로 가지는 것이 LSTM이다.

 

그 구조에 대해 더 자세히 살펴보자. GRU와 마찬가지로 LSTM에 들어오는 data는 현재 step에서의 input과 이전 step에서의 hidden state 두가지를 가진다. 이것은 세개의 fc layer로 처리되며 input과 forget를 계산하는데 sigmoid activation이 사용된다.

 

 위에서 각 gate에 대해 연산과정을 살펴보았고 이제는 memory cell이 어떻게 구현되었는지 살펴보자.

 먼저 candidate memory cell은 위 세개의 gate의 연산과 유사하지만 tanh function을 쓴다는 점에서 차이점을 가진다.

 GRU에서 governing input, forgetting 기능을 가진 것 처럼 LSTM도 두개의 기능을 가지고 있다. 

 1) Input gate (It) : govern how much we take new into Ct

 2) Forget gate (Ft) : address how much of the old memory cell content Ct-1 we retain

이전과 마찬가지로 pointwise multiplication이 사용된다.

위 식에서 알 수 있듯이 forget gate = 1, input gate = 0이면 이전 memory cell Ct-1이 현재 step에 보존된다. 이런 구조를 위에서 언급했듯 vanishing gradient 문제를 완화하고 long range dependnecy를 캡쳐할 수 있다. 전체적인 구조는 다음과 같다.

 

 이제 hidden state가 어떻게 계산되는지 살펴보자. hidden state를 계산할 때 output gate도 반영이되는데 별도의 크게 다른 연산이 있는 것이 아닌 memory cell에 tanh activiation이 적용된다.

output gate가 1에 가까울 때 모든 memory information을 predictor에게 전달하고 0에 가까우면 memory information은 memory cell내에서만 유지하고 더 이상 처리하지 않는다. 아래 사진이 전체적인 LSTM의 flow이다.

 코드로 구현해보자.

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

## Initializing Model Param
def get_lstm_params(vocab_size, num_hiddens, device):
    num_inputs = num_outputs = vocab_size
    def normal(shape):
        return torch.randn(size=shape, device=device)*0.01
    def three():
        return (normal((num_inputs, num_hiddens)),
                normal((num_hiddens, num_hiddens)),
                torch.zeros(num_hiddens, device=device))
    W_xi, W_hi, b_i = three()  # Input gate parameters
    W_xf, W_hf, b_f = three()  # Forget gate parameters
    W_xo, W_ho, b_o = three()  # Output gate parameters
    W_xc, W_hc, b_c = three()  # Candidate memory cell parameters
    # Output layer parameters
    W_hq = normal((num_hiddens, num_outputs))
    b_q = torch.zeros(num_outputs, device=device)
    # Attach gradients
    params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
              b_c, W_hq, b_q]
    for param in params:
        param.requires_grad_(True)
    return params
# state initialization : hidden state need to return an additional memory cell with value 0 and shape of (batch size, # of hidden units)

def init_lstm_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device),
            torch.zeros((batch_size, num_hiddens), device=device))
    
# Defining model

def lstm(inputs, state, params):
    [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
     W_hq, b_q] = params
    (H, C) = state
    outputs = []
    for X in inputs:
        I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
        F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
        O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
        C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c)
        C = F * C + I * C_tilda
        H = O * torch.tanh(C)
        Y = (H @ W_hq) + b_q
        outputs.append(Y)
    return torch.cat(outputs, dim=0), (H, C)
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params,
                            init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

 

 API로 구현하면 다음과 같고 그 결과이다.

## API

num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

 LSTM에 대해 정리해보자.

LSTM = prototypical latent variable autoregressive model with nontrivial state control

autoregressive model의 의미는 다음과 같으며 사진의 출처인 아래 링크에서 자세히 설명하고 있다.

https://otexts.com/fppkr/AR.html

LSTM과 같은 sequence model들을 훈련하는 것은 long range dependency 때문에 꽤 costly하다. 이런 문제를 다루고 개선한 transformer에 대해서는 이후에 다루도록 하겠다.

 


3. Deep RNN

이전까지는 single unidirectional hidden layer를 가진 RNN에 대해서만 다뤘다. single layer만 가질 땐 flexibility to model different types of interaction이 부족하다는 문제를 가지는데 linear model에서는 더 많은 layer를 쌓음으로써 이 문제를 해결했다. 하지만 RNN 계열의 모델은 단순히 층을 더 쌓는 것 외에 어떻게 그리고 어디에서 nonlinearity를 더해야할지도 정해야 한다. 우선 layer를 여러개 쌓은 RNN 모델을 도식화하면 다음과 같다.

 deep architecture of L hidden layer의 functional dependencies는 다음과 같이 표현된다.

output layer의 계산은 L번째 hidden layer의 hidden state로만 계산된다.

MLP와 마찬가지로 hidden layer L의 갯수와 hidden unit h의 갯수는 하이퍼파라미터이고 조정 가능하다.

 

코드로 구현해보자.

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
device = d2l.try_gpu()
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
num_epochs, lr = 500, 2
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

Deep RNN에서는 특히 model initialization에 신경써야 하는데 모델 convergence를 위해 learning rate과 clipping 등을 신경써야 한다.

 


4. Bidirectional RNN

이전까지 sequence learning은 이전까지 살핀 데이터를 기반으로 다음 output을 예측할 수 있는 model을 만드는 것이었다. 하지만 아래 문장과 같이 반드시 그 sequence의 끝에서 다음 output을 예측하는 task만이 있는 것은 아니다.

세번째 문장을 보면 빈 칸에 very를 예측하기 위해서는 마지막의 half a pig라는 정보가 매우 중요하다. longer range context도 very 예측을 위해 똑같이 중요한 것이다. 이를 위해 probabilistic graphical model에 대해 살펴보아야 한다.

 

1) Dynamic programming in hidden markov model

 probabilistic graphical model을 이용해 latent variable을 디자인하면 다음과 같다. time step t에서 latent variable ht가 있고 ht는 P(xt | ht)를 통해 emission xt를 관리한다.

 따라서 sequence T개의 관측치에서 다음과 같은 결합확률분포가 등장한다.

이 때 만약 xj가 latent variable이 없어 이를 제외한 모든 xi를 파악했고 P(xj | x-j)를 계산하는 것이 목표라면 h1, h2, ... hT의 모든 조합의 값들을 각각 계산하여 더해야한다. 하지만 hi는 각각 k개의 고유값들이 잇다고 하면 k^T개의 값들에 대해 다 더해야 하는데 이것은 불가능하다. 이 때 필요한 방법이 dynamic programming이다.

즉 위와 같이 forward recursion의 형식으로 연산을 하는 것이다.

동시에 forward recursion과 같이 아래와 같은 방식, backward recursion으로도 더할 수 있다.

위 backward recursion의 initialization은 1로 시작되고 다음과 같이 learnable function g로도 표현된다.

 

위와 같은 두가지 방식으로 T개의 latent variable을 다 더하면 그 복잡도는 kT이다. 두 방식 이전에 단순히 더했을 때 그 복잡도가 k^T였던 것을 생각하면 훨씬 간단하게 연산할 수 있는 것이다. 이것이 probabilistic inference with graphical model의 장점이다. 

backward와 forward recursion을 같이 표현하면 다음과 같다.

 다시 본론으로 돌아오면 markov model은 미래의 데이터가 언제 이용가능한지 알 수 있다는 점에서 이점을 가진다. 두 가지 경우, knowing future observation, not knowing future observation을 interpolation과 extrapolation으로 구분한다.

 

2) Bidirectional Model

 위의 hidden markov model과 같이 look ahead ability를 가지기 위해서는 이전까지 봤던 RNN 모델 구조를 수정할 필요가 있다.

이전 RNN 모델에서 첫 token에서부터 forward mode로 시작했다면 Bidirectional RNN은 마지막 token에서 다른 token으로 뒤에서부터 앞으로 시작하는 방향도 추가된다. 이를 위해 정보를 backward 방향으로 전달할 hidden layer를 추가한다. 구조는 다음과 같다.

이전 markov model 내용이 통계적인 의미를 살펴본 것이라면 위 모델에서 적용되는 방식은 classical statistical model의 functional dependencies를 사용하고 이것들은 일반적인 형태로 parameterize하는 것이다.

 

forward와 backward를 각각 연산했다면 Ht를 얻기위애 두 hidden state를 concatenate해야 한다.

output layer의 연산은 다음과 같다.

 조금 더 생각을 해보자.

위 내용은 예측하려는 시점의 이전과 그 이후의 데이터를 기반으로 예측하는 내용이다. 하지만 이 구현으로 next token을 예측해야 할 경우 training 동안에서는 present 예측을 위해 both ends of sequence, 즉 past, future 데이터가 있지만 test 동안에는 past data만 있기에 accuracy가 매우 낮을 것이다. 또한 위 bidirectional RNN의 forward propagation이 forward, backward recursion을 포함하고 있고 backward propagation은  forward propagation에 의존하기에 전체 연산 속도가 매우 느리고 또한 very long dependency chain을 가지게 된다. 따라서 bidirectional model은 filling missing word 또는 annotating token 과 같은 한정된 task에서만 적용된다.

 

 위 문단에서 언급한 future token을 예측하는 bidriectional model이 가진 문제점을 실험해보자.

import torch
from torch import nn
from d2l import torch as d2l
# Load data
batch_size, num_steps, device = 32, 35, d2l.try_gpu()
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
# Define the bidirectional LSTM model by setting `bidirectional=True`
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers, bidirectional=True)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
# Train the model
num_epochs, lr = 500, 1
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

bidirectional을 true로 설정하여 수행하면 된다.

perplexity는 어느 정도 수긍할 수 있지만 만든 문장을 보면 말이 안된다는 것을 알 수 있다.

 


5. Machine translation and dataset

 Machine translation = automatic translation of a sequence from one language to another.

1900년대부터 본격적인 컴퓨터의 발달과 함께 많은 연구가 있었던 분야이다. 이 분야의 초창기에서는 statisitical한 접근이 이루어졌다면 현재는 neural translation에 대한 연구가 이뤄지고 잇다. 당연한 얘기지만 translation의 경우 그 데이터셋이 두개의 언어로 쌍이 이루어져 학습되어야 한다. 여기서 두개의 언어는 번역이 이뤄지는 언어와 번역의 결과물로 원하는 언어 두가지 이다. 따라서 이전까지 데이터셋을 처리했던 방식과는 다르게 처리되어야 하는데 그 과정은 아래와 같다.

 예제로 사용할 데이터셋은 English - French dataset으로 bilingual sentence pairs from the Tatoeba Project로 구성되어 있다. 각각의 text sequence는 하나의 문장이 될 수도 있고 여러개의 문장으로 이뤄진 하나의 단락일 수 있으면 source language = English / target language = French이다.

 

데이터셋을 불러와 보자.

d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip',
                           '94646ad1522d915e7b0f9296181140edcf86a4f5')

def read_data_nmt():
    """Load the English-French dataset."""
    data_dir = d2l.download_extract('fra-eng')
    with open(os.path.join(data_dir, 'fra.txt'), 'r') as f:
        return f.read()
raw_text = read_data_nmt()
print(raw_text[:75])

불러온 데이터셋의 예시로 영어와 불어가 쌍으로 이루어져있다.

전처리는 다음과 같은 작업을 해준다.

# Preprocess
def preprocess_nmt(text):
  def no_space(char, prev_char):
    return char in set(',.!?') and prev_char != ' '
  # replace non-breaking space with space / convert uppercase to lowercase
    # non-breaking space : 줄바꿈없는 공백
  text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
    # \u202f : non-breaking space (common)
    # \xa0 : non-breaking space in Latin1
  # inset space btw words and punctuation marks
  out = [' ' + char if i > 0 and no_space(char, text[i-1]) else char for i, char in enumerate(text)]
  return ''.join(out)

text = preprocess_nmt(raw_text)
print(text[:80])

이제 word-level tokenization을 수행해 보자. 

아래 함수는 단어 또는 문장기호로 된 첫 num_example에 대해 tokenizing을 수행하고 source와 target 두가지 리스트를 반환한다. source[i]는 English의 i번째 text sequence의 list of token이고 target[i]는 French의 i번째 text sequence의 list of token이다.

def tokenize_nmt(text, num_examples = None):
  source, target = [] , []
  for i, line in enumerate(text.split('\n')):
    if num_examples and i > num_examples:
      break
    parts = line.split('\t')
    if len(parts) == 2:
      source.append(parts[0].split(" "))
      target.append(parts[1].split( " "))
    return source, target

source, target = tokenize_nmt(text)
source[:6], target[:6]

text sequence별 token의 갯수를 보자. 가장 많은 text sequence를 가진 것도 20개 미만의 token을 가진다.

def show_list_len_pair_hist(legend, xlabel, ylabel, xlist, ylist):
    """Plot the histogram for list length pairs."""
    d2l.set_figsize()
    _, _, patches = d2l.plt.hist(
        [[len(l) for l in xlist], [len(l) for l in ylist]])
    d2l.plt.xlabel(xlabel)
    d2l.plt.ylabel(ylabel)
    for patch in patches[1].patches:
        patch.set_hatch('/')
    d2l.plt.legend(legend)
show_list_len_pair_hist(['source', 'target'], '# tokens per sequence',
                        'count', source, target);

 두 개의 언어 쌍으로 구성되어 있기 때문에 두개의 vocabulary를 따로 구성해야 한다. word-level tokenization은 character-level tokenization 보다 그 사이즈가 더 커지기 때문에 2번보다 적게 등장한 token은 <unk> token으로 처리한다. 이 외에도 mini batch내에서 같은 길이 유지를 위해 <pad>로 padding을 진행하고 시작과 끝을 표시하기 위해 <bos>와 <eos>로 마킹한다. 이런 식으로 마킹하는 것이 자연어 처리에서는 일반적으로 수행되는 task이다.

src_vocab = d2l.Vocab(source, min_freq=2,
                      reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab)

총 10012개의 vocab으로 구성된다. 

 

각각의 segment들은 고정된 길이를 가지고 num_step(number of token)으로 그 길이가 정해진다. translation의 경우에서도 마찬가지로 그 길이를 맞춰주기 위해  하나의 minibatch에 대해 truncation과 padding을 수행한다.text sequence가 num_step 보다 적은 경우 <pad> token을 더해줘 그 길이를 맞춰준다. 만약 text sequence가 num_step 보다 큰 경우 num_step만큼만 token을 가지고 나머지는 버리는 truncate를 진행한다. 이렇게 minibatch 하나 당 같은 길이로 text sequence를 가지고 같은 모양으로 load 될 수 있다.

# truncate & pad
def truncate_pad(line, num_steps, padding_token):
  if len(line) > num_steps:
    return line[:num_steps] # truncate
  return line + [padding_token] * (num_steps - len(line)) # pad

truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])

이제 이렇게 작업한 text sequence를 training을 위해 minibatch 단위로 변환해보자. 이전에 추가했던 <eos>를 통해 output seqence가 끝났다는 것을 알 수 있다. 또한 아래 코드에서는 padding token을 제외한 text sequence의 길이를 기록할 것이다.

# Transform text sequence of machine translation into minibatches

def build_array_nmt(lines, vocab, num_steps):
  lines = [vocab[l] for l in lines]
  lines = [l+[vocab['<eos>']] for l in lines]
  array = torch.tensor([truncate_pad(l, num_steps, vocab['<pad>']) for l in lines])
  valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1) # padding을 제외한 길이
  return array, valid_len

이제 학습을 위한 작업은 다 끝났고 최종적으로 학습을 위한 전처리를 진행하는 라인은 다음과 같다.

# Return the iterator and the vocabularies of the translation dataset.

def load_data_nmt(batch_size, num_steps, num_examples=600):
    text = preprocess_nmt(read_data_nmt())
    source, target = tokenize_nmt(text, num_examples)
    src_vocab = d2l.Vocab(source, min_freq=2,
                          reserved_tokens=['<pad>', '<bos>', '<eos>'])
    tgt_vocab = d2l.Vocab(target, min_freq=2,
                          reserved_tokens=['<pad>', '<bos>', '<eos>'])
    src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
    tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)
    data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)
    data_iter = d2l.load_array(data_arrays, batch_size)
    return data_iter, src_vocab, tgt_vocab
train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
    print('X:', X.type(torch.int32))
    print('valid lengths for X:', X_valid_len)
    print('Y:', Y.type(torch.int32))
    print('valid lengths for Y:', Y_valid_len)
    break


6. Encoder-Decoder Architecture

 이전 machine translation의 경우와 같이 input과 ouput 모두 variable length sequence이다. 이런 종류의 input과 output을 다루기 위해 두개의 요소로 이루어진 구조를 디자인할 수 있는데 encoder와 decoder이다.

 1) encoder : takes a variable-length sequence as the input and transforms it into a state with a fixed shape

 2) decoder : maps the encoded state of a fixed shape to a variable-length sequence

encoder-decoder 구조에서 encoder는 variable length input을 state로 변환하고 이 state를 decoder를 통해 output으로 translated sequence token을 반환한다. 

 

 먼저 encoder를 살펴보자. encoder는 input X로 variable length sequence를 갖는다.

# Base encoder interface

class Encoder(nn.Module):
  def __init__(self, **kwargs):
    super(Encoder, self).__init__(**kwargs)

  def forward(self, X, *args):
    raise NotImplementedError

 decoder를 보면 init_state 함수가 포함되어 있는데 이것은 encoder output을 encoded state로 변환한다. 이 때 이전에서 기록했던 input의 padding을 제외한 valid length 정보가 필요하다. token 별로 vaiable length sequence를 만들기 위해 매 time step마다 decoder는 input과 encoded state를 output token에 mapping한다.

# Base decoder interface

class Decoder(nn.Module):
    def __init__(self, **kwargs):
        super(Decoder, self).__init__(**kwargs)
    def init_state(self, enc_outputs, *args):
        raise NotImplementedError
    def forward(self, X, state):
        raise NotImplementedError

encoder와 decoder를 함께 수행하는 class는 클래스는 다음과 같다. forward propagation에서 encoder의 output은 encoded state를 만드는데 사용되고 이 state는 다시 decoder의 input으로 사용된다.

class EncoderDecoder(nn.Module):
  def __init__(self, encoder, decoder, **kwargs):
    super(EncoderDecoder, self).__init__(**kwargs)
    self.encoder = encoder
    self.decoder = decoder

  def forward(self, enc_X, dec_X, *args):
    enc_outputs = self.encoder(enc_X, *args)
    dec_state = self.decoder.init_state(enc_outputs, *args)
    return self.decoder(dec_X, dec_state)

은연 중에 state라는 용어를 사용하였는데 이후 이 구조를 neural network의 state의 개념으로 구현하기 때문이다. 이 구조에 RNN을 어떻게 적용하는지 살펴보자.


7. Sequence to Sequence Learning

이 챕터에서는 RNN 두개 모델을 이용해  encoder-decoder 구조를 design하고 이르 sequence to sequence learning에 적용한다.

RNN encoder는 variable length sequence를 input으로 받고 fixed shape hidden state로 변환한다. 다시말하면 input sequence의 정보가 RNN의 ecoder의 hidden state로 encoded된다. 또 다른 RNN decoder는 token별로 output sequence를 만들기 위해 이전의 token을 보고 다음의 token을 예측할 수 있다. 이 때 input sequence에서 encoded된 정보도 함께 본다.

 <eos> token을 이용해 model은 예측을 멈출 지점을 파악할 수 있고 RNN decoder의 initial time step에서 두가지 특별한 디자인 결정이 있다. 가장 먼저 sequence의 시작을 알리는 <bos> token을 input으로 가진다. 두번째로 RNN encoder의 마지막 hidden state가 decoder의 hidden state를 initiate하기 위해 사용된다. encoder와 decoder 각각에 대해 더 자세히 살펴보자

 

 1) Encoder

다시 encoder의 기능에 대해 말하자면, variable length의 input sequence를 fixed shape context variable c로 변환하고 input sequence 정보를 이 context variable에 encode한다. input text sequence의 t번째 token을 xt라 하고 이런 input sequence가 x1, x2, ... , xT까지 있다고 할 때, time step t에서의 RNN은 input feature vector Xt를 xt로 변환하고 이전 step의 hidden state ht-1을 현재 step의 hidden state ht로 변환한다. 수식으로 나타내면 다음과 같다.

일반적으로 encoder는 모든 time step에서 customized function q로 hidden state를 context variable로 변환한다.

여기서 q(h1, ..., hT) = hT를 고를 때 context variable은 단순히 마지막 step에서의 input sequence의 hidden sate hT이다.

encoder 또한 bidirectional RNN으로 구현 가능한데, 이 경우 hidden state는 이전과 이후의 time step의 sequence에 의존한다.

이제 encoder를 구현해보자. input sequence에서 각각의 token에 feature vector를 얻기 위해 embedding layer를 사용한다. embedding layer의 weight은 행의 갯수 = vocab size(vocab_size), 열의 갯수 = feature vector's dim(embed_size)로 구성된 행렬이다. 즉 input token의 index i가 주어지면 embeding layer의 weight 행렬의 i번째 행을 i의 feature vector로 반환한다.

 

 encoder를 수행하기 위해 multilayer GRU로 구현한 코드이다.

# RNN encoder in seq to seq learning

class Seq2SeqEncoder(d2l.Encoder):
  def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout = 0, **kwargs):
    super(Seq2SeqEncoder, self).__init__(**kwargs)
    # Embedding Layer
    self.embedding = nn.Embedding(vocab_size, embed_size)
    self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout = dropout)

  def forward(self, X, *args):
    # output X shape = (batch_size, num_steps, embed_size)
    X = self.embedding(X)
    # first axis = time steps
    X = X.permute(1, 0, 2) # state not mentioned >> defaults to zero
    output, state = self.rnn(X)
    # output shape = (num_steps, batch_size, num_hiddens)
    # state shape = (num_layers, batch_size, num_hiddens)
    return output, state
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
                         num_layers=2)
encoder.eval()
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
output.shape

state.shape

주석에 적어놓았든 output과 state과 모두 원하는 shape으로 반환되는 것을 확인할 수 있다.

 

2) Decoder

 이전에서도 말했듯 encoder의 output의 context variable c는 전체 input sequence x1, x2, ... , xT를 encode한다. 

decoder의 관점에서 생각해보면 encoder의 time step와  구분되는 decoder의 time step T'의 y1, y2, ... , yT'까지의 output sequence가 있을 때 decoder output yt'은 이전 output subsequence y1, y2, ..., yt'-1와 context variable c의 조건부 확률 형태를 가진다.

encoder와 마찬가지로 위 조건부 확률을 model하기 위해서 또 다른 RNN 구조를 사용한다. time step t'에서의 output sequence에서 RNN은 이전 step의 output yt'-1과 context variable c를 input으로 갖고 이것을 변환하며 이전 hidden state st'-1을 st'으로 변환한다. decoder의 hidden layer의 변환을 표현하는 함수 g를 이용해 표현하면 다음과 같다.

decoder의 hidden state를 얻은 후 ouput layer와 softmax를 통해 위 조건부 확률을 계산할 수 있다.

decoder를 실행하기 위해서, 즉 decoder의 hidden state를 intialize하기 위해서는 encoder의 마지막 time step의 hidden state를 사용하기 때문에 RNN encoder와 decoder 같은 수의 layer와 hidden unit을 가져야 한다. 또한 encoder의 input sequence 정보를 가지기 위해 context variable은 모든 time step에서 decoder input과 concatenate된다. 그리고 당연히 output token의 조건부 확률을 예측하기 위해 RNN decoder의 마지막 layer의 hidden state를 변환하기 위해 fc layer가 사용된다.

# RNN decoder

class Seq2SeqDecoder(d2l.Decoder):
  def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout = 0, **kwargs):
    super(Seq2SeqDecoder, self).__init__(**kwargs)
    self.embedding = nn.Embedding(vocab_size, embed_size)
    self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers, dropout = dropout)
    self.dense = nn.Linear(num_hiddens, vocab_size)

  def init_state(self, enc_outputs, *args):
    return enc_outputs[1]

  def forward(self, X, state):
    # output X shape = (num_steps, batch_size, embed_size)
    X = self.embedding(X).permute(1, 0, 2)
    # broadcast context > has the same num_step as X
    context = state[-1].repeat(X.shape[0], 1, 1)
    X_and_context = torch.cat((X, context), 2)
    output, state = self.rnn(X_and_context, state)
    output = self.dense(output).permute(1,0,2)
    # output shape = (batch_size, num_steps, vocab_size)
    # state shape = (num_layers, batch_size, num_hiddens)
    return output, state
decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16,
                         num_layers=2)
decoder.eval()
state = decoder.init_state(encoder(X))
output, state = decoder(X, state)
output.shape, state.shape

 

 지금까지 다룬 encoder와 decoder 구조를 요약하면 다음과 같다.

3) Loss function

 encoder와 decoder가 어떻게 구현되는지 알았다면 decoder가 output token에 대해 예측하는 확률분포에 대한 loss를 구하는 방식에 대해 알아보자. 위에서 살짝 언급했듯 분포를 얻기위해서 softmax를 적용하고 optimization을 위해 cross entropy loss를 계산한다.

 다시 이전 내용을 생각해보면 <eos> token을 통해 우리는 minibatch 하나다 가지는 모양을 통일했었다. 하지만 이런 padding token에 대한 loss 계산은 당연히 제외되어야 한다.

 아래 function은 관련없는 prediction에 0을 곱해 그 값을 0으로 만들어주는, masking irrelevant entries의 기능을 한다.

# mask irrelevant entries in sequences

def sequence_mask(X, valid_len, value = 0):
  maxlen = X.size(1)
  mask = torch.arange((maxlen), dtype = torch.float32, device = X.device)[None, :] < valid_len[:, None]

  X[~mask] = value
  return X

X = torch.tensor([[1,2,3], [4,5,6]])
sequence_mask(X, torch.tensor([1,2]))

 위의 경우 valid length of two sequences excluding padding token = one & two의 경우로 결과값을 보면 첫 한개와 첫 두개의 entries를 제외한 entries들이 0이 된 것을 확인할 수 있다.

 

 위 방식 외에도 이런 entry를 0이 아닌 값으로 대체할 수 있고 모든 entry에 대해 last few axes를 mask 할 수 있다. 아래는 1, 2번째 행을 제외한 entry에 대해 -1로 masking한 예시이다.

 X = torch.ones(2, 3, 4)
sequence_mask(X, torch.tensor([1, 2]), value=-1)

위 구현한 기능을 토대로 다시 loss function으로 돌아오면 실제 예측 token의 mask들은 1로 설정되어 있고, valid_length가 주어지면 padding token에 해당하는 mask는 0이 된다. 이런 방식으로 loss에서의 irrelevant prediction of padding token이 mask와 곱해지면서 loss에 영향을 주지 않게 된다.

class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
    """The softmax cross-entropy loss with masks."""
    # `pred` shape: (`batch_size`, `num_steps`, `vocab_size`)
    # `label` shape: (`batch_size`, `num_steps`)
    # `valid_len` shape: (`batch_size`,)
    def forward(self, pred, label, valid_len):
        weights = torch.ones_like(label)
        weights = sequence_mask(weights, valid_len)
        self.reduction='none'
        unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
            pred.permute(0, 2, 1), label)
        weighted_loss = (unweighted_loss * weights).mean(dim=1)
        return weighted_loss
loss = MaskedSoftmaxCELoss()
loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),torch.tensor([4, 2, 0]))

 

4) Training

 이제 본격적으로 training에 대한 내용이다. special begining-of-sequence token(<bos>)과 original output sequence excluding the final token을 concatenate한 것이 decoder의 input이 되고 이런 방식을 teacher forcing이라고 한다. 

def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
    """Train a model for sequence to sequence."""
    def xavier_init_weights(m):
        if type(m) == nn.Linear:
            nn.init.xavier_uniform_(m.weight)
        if type(m) == nn.GRU:
            for param in m._flat_weights_names:
                if "weight" in param:
                    nn.init.xavier_uniform_(m._parameters[param])
    net.apply(xavier_init_weights)
    net.to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    loss = MaskedSoftmaxCELoss()
    net.train()
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[10, num_epochs])
    for epoch in range(num_epochs):
        timer = d2l.Timer()
        metric = d2l.Accumulator(2)  # Sum of training loss, no. of tokens
        for batch in data_iter:
            optimizer.zero_grad()
            X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
            bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],
                               device=device).reshape(-1, 1)
            dec_input = torch.cat([bos, Y[:, :-1]], 1)  # Teacher forcing
            Y_hat, _ = net(X, dec_input, X_valid_len)
            l = loss(Y_hat, Y, Y_valid_len)
            l.sum().backward()  # Make the loss scalar for `backward`
            d2l.grad_clipping(net, 1)
            num_tokens = Y_valid_len.sum()
            optimizer.step()
            with torch.no_grad():
                metric.add(l.sum(), num_tokens)
        if (epoch + 1) % 10 == 0:
            animator.add(epoch + 1, (metric[0] / metric[1],))
    print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
          f'tokens/sec on {str(device)}')
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, d2l.try_gpu()
train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)

encoder = Seq2SeqEncoder(
    len(src_vocab), embed_size, num_hiddens, num_layers, dropout)
decoder = Seq2SeqDecoder(
    len(tgt_vocab), embed_size, num_hiddens, num_layers, dropout)
net = d2l.EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

5) Prediction

 훈련된 모델을 바탕으로 prediction을 수행한다. 초기 step에서 <bos> token이 decoder에 들어가고 마지막 <eos>를 예측하게되면 prediction of output sequence는 종료된다.

기존에 사용하던 perplexity가 아닌 translation task에서는 BLEU(Bilingual Evaluation Understudy)가 사용되는데 자세한 설명은 다음 링크에서 확인할 수 있다. https://wikidocs.net/31695

 

3) BLEU Score(Bilingual Evaluation Understudy Score)

앞서 언어 모델(Language Model)의 성능 측정을 위한 평가 방법으로 펄플렉서티(perplexity, PPL)를 소개한 바 있습니다. 기계 번역기에도 PPL을 평가 ...

wikidocs.net

def bleu(pred_seq, label_seq, k):
    """Compute the BLEU."""
    pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
    len_pred, len_label = len(pred_tokens), len(label_tokens)
    score = math.exp(min(0, 1 - len_label / len_pred))
    for n in range(1, k + 1):
        num_matches, label_subs = 0, collections.defaultdict(int)
        for i in range(len_label - n + 1):
            label_subs[' '.join(label_tokens[i: i + n])] += 1
        for i in range(len_pred - n + 1):
            if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
                num_matches += 1
                label_subs[' '.join(pred_tokens[i: i + n])] -= 1
        score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
    return score

이렇게 설정한 metric을 기반으로 실제 예측을 진행한 결과이다.

engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
    translation, attention_weight_seq = predict_seq2seq(
        net, eng, src_vocab, tgt_vocab, num_steps, device)
    print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')

'Basic Deep Learning > Dive into Deep Learning 리뷰' 카테고리의 다른 글

[D2L] 8. RNN  (0) 2022.08.08
[D2L] 7.1 Modern CNN  (0) 2022.07.26
[D2L] 6. CNN  (0) 2022.07.25
[D2L] 11.1 Optimization and Deep Learning  (0) 2022.07.13
[D2L] 4.8 Numerical Stability and Initialization  (0) 2022.07.11