본문 바로가기

자연어처리

어텐션(Attention) (3)

1. Seq2Seq + Attention을 이용한 번역기 구현

시퀀스투시퀀스(Sequence‑to‑Sequence, seq2seq) (2) 에서 일반적인 Seq2Seq 구조를 이용한 번역기를 구현하였다. 이번 예시에서는 여기에 어텐션 메커니즘을 추가하여 번역기를 구현한다. 데이터 전처리 및 데이터 로드 과정은 동일하다.


1.1 Seq2Seq + Attention의 Encoder

■ Encoder 구조는 일반적인 Seq2Seq의 Encoder 구조와 동일하다. 입력 시퀀스가 들어오면, 입력 시퀀스는 임베딩 계층과 LSTM 계층을 통과하여 은닉 상태를 반환한다.

class Encoder(nn.Module):
    def __init__(self, src_vocab_size, embedding_dim, hidden_units):
        super(Encoder, self).__init__()
        self.embedding = nn.Embedding(src_vocab_size, embedding_dim, padding_idx=0)
        self.lstm = nn.LSTM(embedding_dim, hidden_units, batch_first=True)

    def forward(self, x): 
        x = self.embedding(x) # x.shape: (batch_size, source_seq_len, embedding_dim)
        outputs, (hidden, cell) = self.lstm(x) # hidden.shape: (1, batch_size, hidden_units) # cell.shape: (1, batch_size, hidden_units)
        return outputs, hidden, cell

■ 여기서 모든 타임 스텝의 은닉 상태를 담은 outputs이 Attention 계산에 사용된다. 

마지막 타임 스텝의 hidden과 셀 상태 cell은 Decoder의 초기 상태로 전달된다. 


1.2 Seq2Seq + Attention의 Decoder

어텐션 메커니즘은 Decoder에서 어텐션 값(컨텍스트 벡터)을 계산하기 위해 Query와 Key로 어텐션 스코어를 계산하는 것으로 시작된다.

class Decoder(nn.Module):
    def __init__(self, tar_vocab_size, embedding_dim, hidden_units):
        super(Decoder, self).__init__()
        self.embedding = nn.Embedding(tar_vocab_size, embedding_dim, padding_idx=0)
        self.lstm = nn.LSTM(embedding_dim + hidden_units, hidden_units, batch_first=True)
        self.fc = nn.Linear(hidden_units, tar_vocab_size)
        self.softmax = nn.Softmax(dim=1) # 어텐션 가중치를 계산하기 위한 소프트맥스 함수

    def forward(self, x, encoder_outputs, hidden, cell): 
        x = self.embedding(x) # x.shape: (batch_size, target_seq_len, embedding_dim)

        ## Dot-Product Attention
        attention_scores = torch.bmm(encoder_outputs, hidden.transpose(0, 1).transpose(1, 2)) # attention_scores.shape: (batch_size, source_seq_len, 1)
        attention_weights = self.softmax(attention_scores) # attention_weights.shape: (batch_size, source_seq_len, 1)
        context_vector = torch.bmm(attention_weights.transpose(1, 2), encoder_outputs) # context_vector.shape: (batch_size, 1, hidden_units)

        ## Repeat context_vector 
        seq_len = x.shape[1]
        context_vector_repeated = context_vector.repeat(1, seq_len, 1) # context_vector.shape: (batch_size, target_seq_len, hidden_units)

        ## Concat context_vector and embedded input
        x = torch.cat((x, context_vector_repeated), dim=2) # x.shape: (batch_size, target_seq_len, embedding_dim + hidden_units)

        outputs, (hidden, cell) = self.lstm(x, (hidden, cell)) 
        # outputs.shape: (batch_size, target_seq_len, hidden_units)
        # hidden.shape: (batch_size, 1, hidden_units), cell.shape: (batch_size, 1, hidden_units)

        output = self.fc(outputs) # output.shape: (batch_size, target_seq_len, tar_vocab_size)

        return output, hidden, cell

1.2.1 어텐션 스코어 계산

■ 이 예에서는 닷-프로덕트 어텐션을 적용하기 위해 Query인 디코더의 현재 시점 은닉 상태(hidden)와 Key인 인코더의 모든 시점의 은닉 상태(encoder_outputs)와 내적을 계산한다. 

- 내적에 대한 코드는 torch.bmm(encoder_outputs, hidden.transpose(0, 1).transpose(1, 2))이다.

■ 이때, 처리 대상이 배치 단위의 데이터이므로, 배치 행렬 곱(Batch Matrix Multiplication, BMM)을 수행하는 파이토치 함수 torch.bmm()을 사용한다. 

■ 이 함수는 두 개 이상의 차원을 지닌 텐서가 주어졌을 때, 뒤의 두 개 차원에 대해 행렬 곱을 수행하는 함수이다.

■ 예를 들어, mat1라는 텐서의 형상이 \( \left( b \times n \times m \right) \), mat2라는 텐서의 형상이 \( \left( b \times m \times p \right) \)라면, torch.bpm(mat1, mat2)의 결과 텐서의 형상은 \( \left( b \times n \times p \right) \)이 된다. 

■ 즉, 첫 번재 차원(dim=0)을 제외한 나머지 차원의 행렬 곱을 수행하기 때문에 \( \left( n \times m \right) \times \left( m \times p \right) \)의 결과로 \( \left( b \times n \times p \right) \)이 되는 것이다.  

■ torch.transpose() 함수는 두 개의 차원을 맞교환할 때 사용하는 함수이다.
예를 들어 (10, 3, 4) 형상을 갖는 mat1에 대해 첫 번째 차원(dim=0)과 두 번째 차원(dim=1)을 교환하는 예는 다음과 같다. 

mat1 = torch.randn(10, 3, 4)
mat2 = torch.randn(10, 4, 5)
res = torch.bmm(mat1, mat2)

print(res.shape)
```#결과#```
torch.Size([10, 3, 5])
````````````

torch.transpose(mat1, 0, 1).shape
```#결과#```
torch.Size([3, 10, 4])
````````````

mat2.transpose(0, 1).shape
```#결과#```
torch.Size([4, 10, 5])
````````````

cf) torch.permute() 함수는 모든 차원들을 맞교환할 수 있다.

- 예를 들어, 첫 번째 차원(dim=0)에는 세 번째 차원(dim=2)으로, 두 번째 차원(dim=1)은 첫 번째 차원(dim=0)으로, 세 번째 차원(dim=2)은 두 번째 차원(dim=1)으로 교환하고 싶다면,

mat1.permute(2, 0, 1).shape
```#결과#```
torch.Size([4, 10, 3])
````````````

디코더의 현재 시점 은닉 상태(hidden)와 Key인 인코더의 모든 시점의 은닉 상태(encoder_outputs)와 내적을 계산한 결과가 어텐션 스코어이다. 

이 예에서는 batch_first = True를 적용했는데, 그 결과로 인코더의 모든 시점의 은닉 상태를 나타내는 encoder_outputs의 shape은 (batch_size, source_seq_len, hidden_units)이며, 디코더의 현재 시점 은닉 상태는 하나의 시점에 대한 은닉 상태이므로 shape은 (1, batch_size, hidden_units)이다.

torch.bmm() 함수로 배치 행렬 곱을 계산하기 위해서는, 먼저 디코더의 은닉 상태의 차원 중 첫 번째 차원(dim=0)과 두 번째 차원(dim=1)을 교환해야 한다.

- hidden.transpose(0, 1).shape: (batch_size, 1, hidden_units)

그다음, 행렬 곱이므로 첫 번째 행렬의 열 개수와 두 번째 행렬의 행 개수가 동일해야 한다.

그러므로 hidden.transpose(0, 1)의 차원 중 두 번째 차원(dim=1)과 세 번째 차원(dim=2)을 교환해야 한다.

- hidden.transpose(0, 1).transpose(1, 2).shape: (batch_size, hidden_units, 1)

그러면 두 텐서의 배치 행렬 곱은 (batch_size, source_seq_len, hidden_units) ( \times ) (batch_size, hidden_units, 1)이므로 결과 텐서인 어텐션 스코어의 형상은 (batch_size, source_seq_len, 1)이 된다. 

1.2.2 어텐션 분포(어텐션 가중치) 계산

■ 그다음, 어텐션 가중치를 얻기 위해 어텐션 스코어의 두 번째 차원(source_seq_len)에 softmax를 적용한다. 

- 이 내용에 대한 코드는 self.softmax = nn.Softmax(dim=1), attention_weights = self.softmax(attention_scores)

1.2.3 어텐션 값(컨텍스트 벡터) 계산

■ 그다음, 컨텍스트 벡터를 계산하기 위해 어텐션 가중치와 Value인 인코더의 모든 시점의 은닉 상태(encoder_outputs)와 가중합을 계산한다. 

■ 이 계산 과정에서도 torch.bmm()을 사용한다. 이때, 어텐션 가중치의 형상은 (batch_size, source_seq_len, 1)이며 encoder_outputs의 shape은 (batch_size, source_seq_len, hidden_units)이다. 

■ 두 텐서에서 첫 번째 차원을 빼면 벡터와 행렬의 곱임을 알 수 있다. 이 계산을 하기 위해 어텐션 가중치의 두 번째 차원(dim=1)과 세 번째 차원(dim=2)을 교환해야 한다. 

- attention_weights.transpose(1, 2).shape: (batch_size, 1, source_seq_len)

■ 그러면 두 텐서의 배치 행렬 곱은 (batch_size, 1, source_seq_len) \( \times \) (batch_size, source_seq_len, hidden_units)이며, 결과 텐서인 컨텍스트 벡터(어텐션 값)의 형상은 (batch_size, 1, hidden_units)이 된다.  

- 이 내용에 대한 코드는 context_vector = torch.bmm(attention_weights.transpose(1, 2), encoder_outputs) 

■ 여기까지의 과정을 통해 얻은 컨텍스트 벡터는 현재 타임 스텝에 대한 단일 벡터이다. 즉, 하나의 시점에 대한 컨텍스트 벡터이다. 

■ 디코더의 모든 타임 스텝(seq_len = time steps)에 동일한 컨텍스트 벡터 정보를 제공하기 위해, 단일 시점의 컨텍스트 벡터를 시퀀스 길이(seq_len = time steps)만큼 복제한다.

■ 이렇게 하는 이유는, 마치 사람이 하나의 문장을 읽을 때, 특정 시점의 단어를 이해하기 위해 해당 단어뿐 아니라 해당 단어의 주변 단어에도 주의를 기울이는 것과 같은 개념이다. 

■ 단일 시점의 컨텍스트 벡터의 형상은 (batch_size, 1, hidden_units)이다. 두 번째 차원은 1인데 이는 단일 시점을 의미한다. 

■ 그러므로 두 번째 차원에 대해 seq_len만큼 반복하면 된다. 이 내용에 대한 코드는 seq_len = x.shape[1], context_vector_repeated = context_vector.repeat(1, seq_len, 1)이다. 

■ 이제 이 컨텍스트 벡터(context_vector_repeated)를 연결해야 하는데 이 어텐션 값(컨텍스트 벡터)를 활용하는 방법은 어텐션 메커니즘을 구현 방식에 따라 달라진다. 

■ 이 예에서는 바다나우 어텐션 메커니즘에 따라 컨텍스트 벡터와 입력 벡터를 연결한다. 여기서 입력 벡터는 디코더의 입력인 타겟 시퀀스가 아니라 임베딩 계층을 통과한 임베딩 벡터를 사용한다. 

■ 이는 마치 언어 모델인 디코더에서 현재 입력 단어와 소스(source) 문맥 중 중요한 부분을 함께 고려하여 다음 상태를 결정하는 것이라고 볼 수 있다. 

- 이 내용에 대한 코드는  x = torch.cat((x, context_vector_repeated), dim=2)

■ 이 x를 hidden, cell과 함께 디코더의 LSTM 계층에 통과시켜 얻은 디코더의 은닉 상태를 fc(fully-connected) 레이어에 통과시켜 타겟 어휘 사전 크기(tar_vocab_size)의 벡터로 변환한다. 

■ 이 벡터는 단어별 점수(로짓)를 의미하며, 손실 함수로 CrossEntropy() 함수를 사용할 것이므로 Softmax를 적용하지 않는다.  


1.3 Seq2Seq + Attention의 Seq2Seq

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

    def forward(self, src, trg):
        encoder_outputs, hidden, cell = self.encoder(src)
        output, _, _ = self.decoder(trg, encoder_outputs, hidden, cell)
        return output
        
encoder = Encoder(src_vocab_size, embedding_dim, hidden_units)
decoder = Decoder(tar_vocab_size, embedding_dim, hidden_units)
model = Seq2Seq(encoder, decoder)

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(512, 256, batch_first=True)
    (fc): Linear(in_features=256, out_features=7476, bias=True)
    (softmax): Softmax(dim=1)
  )
)
````````````

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

■ 디코더는 인코더의 마지막 은닉 상태를 초기 은닉 상태로 사용한다.
디코더 클래스에서 디코더의 은닉 상태와 셀 상태를 반환하기는 하지만 훈련 과정에서는 사용되지 않는다.

■ Seq2Seq의 디코더는 매 시점마다 tar_vocab_size 개의 프랑스어 단어 중에서, 예측할(생성할) 단어 하나를 선택하게 된다. 그러므로 다중 클래스 분류 문제로 볼 수 있으며, 손실 함수로 크로스 엔트로피 함수를 사용한다.