10주차(2) - Bidirectional Attention Flow for Machine Comprehension (BiDAF)

2019. 3. 31. 16:29풀잎스쿨 NLP Bootcamp

논문링크: https://arxiv.org/abs/1611.01603

 

Bidirectional Attention Flow for Machine Comprehension

Machine comprehension (MC), answering a query about a given context paragraph, requires modeling complex interactions between the context and the query. Recently, attention mechanisms have been successfully extended to MC. Typically these methods use atten

arxiv.org

참고자료: (최태균님 발표자료) 

BiDAF.pdf
4.93MB

추가참고자료: https://github.com/YBIGTA/DeepNLP-Study/wiki/Bidirectional-Attention-Flow-for-Machine-Comprehension(2016)

 

YBIGTA/DeepNLP-Study

딥러닝 자연어처리 스터디의 논문 구현 코드 및 스터디 자료 모음 공간입니다. Contribute to YBIGTA/DeepNLP-Study development by creating an account on GitHub.

github.com

 

Abstract

이 논문이 쓰여지는 당시에는 Attention 구조가 다양하게 쓰이기 시작했고, 그중에서도 MC(Machine Comprehension, MRC라고도 불림)에 대해 적용하고자 하는 시도가 많았다.

 

때문에 원래 Attention이 어떤 방식으로 쓰였는가를 알 필요가 있다. 기존의 Attention은 크게 3가지의 용도로 구분할 수 있다. 그 3가지란 아래와 같다.

  1. 가장 연관성 있는 정보를 추출하기 위해 문맥(Context 혹은 Context Document)을 Fixed sized vector로 요약/압축한 후, Attention을 계산하게 된다.
  2. Text domain에 한하여 종종 temporarily dynamic하다. 무슨 뜻이냐 하면, 현재의 Attention은 이전 time step의 Attention vector에 기반하여 만들어진 함수와 같다는 뜻이다.
  3. Query를 보고 주목해야 할 Context의 부분을 찾는 방향이다. (Query to Context) 즉, Uni-directional 하다.

이와 같은 방법은 물론 좋은 성능을 내지만, 앞뒤의 관계를 모두 고려하지 않음으로서 생기는 정보 손실에 대한 가능성을 지울수 없다. 또한 Context를 특정 단위의 Token으로만 한정하고 있기 때문에 정보를 일부 놓칠 수 있다는 가능성이 존재한다.

 

때문에 이 논문에서는 다양한 크기의 유닛(granularity, Token의 단위라고 이해해도 좋다)을 통해 Context를 Memory 구조가 없이 다양한 방법으로 분석하게 되며, Query to Context만이 아닌 Context to Query도 같이 고려하는 구조를 통해 MC/MRC 문제를 해결하고자 한다.

Contribution

Architecture

BiDAF의 기본 구조

BiDAF는 크게 6가지 부분으로 나눠볼 수 있다.

  • Character Embedding Layer: CharCNN을 사용하여 각 단어를 vector space에 mapping한다.
  • Word Embedding Layer: pre-trained word embedding 모델을 사용하여 각 단어를 vector space에 mapping한다.
  • Contextual Embedding Layer: Target word의 주변 단어들을 통해 embedding을 정제한다. 처음 3개의 Layer에 대해서는 Query와 Context에 모두 적용된다.
  • Attention Flow Layer: Context에 대해 Query-aware feature vector를 만들기 위해 Query와 Context를 쌍으로 묶어 Attention을 학습하게 된다.
  • Modeling Layer: RNN을 통해 Context를 탐색한다.
  • Output Layer: Query에 대해 답을 생성한다.

각 부분에 대해 PyTorch로 구현된 코드와 함께 세부 내용을 알아보도록 한다.

(Tistory 코드 에디터의 특성상 Formatting이 제대로 안되는 점은 양해 부탁드립니다.)

 

Character Embedding Layer

class CharacterCNNLayer(Module):
    def __init__(self, input_dim, output_dim, kernel_size):
        super(CharacterCNNLayer, self).__init__()
        self.convolution_layer = Conv1d(input_dim, output_dim, kernel_size)

    def forward(self, inputs):
        inputs = torch.transpose(inputs, 1, 2)
        conv_outputs = F.relu(self.convolution_layer(inputs))
        maxpool_output = conv_outputs.max(dim=-1)[0]

        return maxpool_output

위 설명에서도 언급했지만 각 Word를 Character 단위의 Input을 통해 Embedding하는 단계이다. 여기서는 각 단어에 ID Vector를 부여하고, 이를 Input으로 받는 형식을 가진다. (d크기의 차원을 가짐)

 

Word Embedding Layer

class WordEmbedding(Module):
    def __init__(self, vocab_size, embedding_dim):
        super(WordEmbedding, self).__init__()
        self.embedding = Embedding(vocab_size, embedding_dim)

    def forward(self, inputs):
        outputs = self.embedding(inputs)
        return outputs
class HighwayNetwork(Module):
    def __init__(self, input_dim):
        super(HighwayNetwork, self).__init__()
        self.input_dim = input_dim
        self.linear = Linear(input_dim, input_dim * 2)

    def forward(self, inputs):
        linear_outputs = self.linear(inputs)
    
        nonlinear_outputs = linear_outputs[:, (0 * self.input_dim): (1 * self.input_dim)]
        gate_outputs = linear_outputs[:, (1 * self.input_dim): (2 * self.input_dim)]
    
        nonlinear_outputs = F.relu(nonlinear_outputs)
        gate = F.sigmoid(gate_outputs)
    
        outputs = gate * inputs + (1 - gate) * nonlinear_outputs
    
        return outputs

Pre-trained Word Embedding 모델을 사용해서 Embedding하는 과정이다. 이 논문에서는 Pre-trained model로 GloVe를 사용하고 있다. (Character Embedding Layer에서와 같이 d 크기의 차원을 가진다)

 

Contextual Embedding Layer

class ContextualEmbeddingLayer(Module):
    def __init__(self, input_dim, output_dim, dropout_rate):
        super(ContextualEmbeddingLayer, self).__init__()
        self.rnn = GRU(input_dim, output_dim, dropout=dropout_rate, batch_first=True, bidirectional=True)
                       
    def forward(self, inputs):
        batch_size = inputs.size()[0]
        feature_size = inputs.size()[-1]
    
        init_hidden_state = torch.zeros(1*2, batch_size, feature_size)
        outputs = self.rnn(inputs, init_hidden_state)
    
        return outputs

Bi-LSTM을 통해 주변 문맥을 파악하는 과정이다. 사실 이 부분에서 혼동하기 쉬운것이 'Bi-directional LSTM'이라서 BiDAF인 것으로 이해할 수 있는데, 이 때문에 BiDAF라는 이름이 붙은 것은 아니다.

 

여기서는 Character Embedding Layer에서의 Output과 Word Embedding Layer에서의 Output을 받아서 Concatenate한 후, 이를 그대로 Highway Network의 Input으로 사용한다. Bi-LSTM의 특성상 d차원의 Output이 Forward에서 한번, Backward에서 한번 나오므로 2d차원의 Output을 가지게 된다. (위 코드에서는 계산량 감소를 위해 GRU를 사용하고 있다)

 

다만 여기서 주의할 것은, Concatenate될 때, 방향이 Column-wise하다는 것이다. 즉, m번째 단어는 m번째 Column을 가지게 된다.

 

Attention Flow Layer

class AttentionFlowLayer(Module):
    def __init__(self, inputs):
        super(AttentionFlowLayer, self).__init__()
        self.tri_linear = Linear(inputs, 1)
        
    def forward(self, passage_inputs, query_inputs):
        tiled_passage_inputs = passage_inputs.unsqueeze(2).expand(passage_inputs.size()[0], passage_inputs.size()[1], query_inputs.size()[1], passage_inputs.size()[2])
        tiled_query_inputs = query_inputs.unsqueeze(1).expand(query_inputs.size()[0], passage_inputs.size()[1], query_inputs.size()[1], query_inputs.size()[2])
                                                              
        trilinear_inputs = torch.cat([tiled_passage_inputs, tiled_query_inputs, tiled_passage_inputs * tiled_query_inputs], dim=-1)
        attention_matrix = self.tri_linear(trilinear_inputs)
        attention_matrix = attention_matrix.squeeze(-1)
        
        softmax_attention_matrix = F.softmax(attention_matrix, dim=-1)
        context2query = torch.bmm(softmax_attention_matrix, query_inputs)
        
        max_pooled_attn_matrix = attention_matrix.max(dim=-1)[0]
        softmax_max_attn_score = F.softmax(max_pooled_attn_matrix, dim=-1).unsqueeze(1)
        query2context = torch.bmm(softmax_max_attn_score, passage_inputs).expand_as(passage_inputs)
                             
        return context2query, query2context

드디어 Query와 Context를 연결시켜주는 작업이다. 여기서는 두가지 방향으로 진행된다. 하나는 Context의 어느 정보가 Query와 관련이 있는가를 학습하기 위한 과정인 Query2Context, 그리고 다른 하나는 Query의 어느 정보가 Context와 관련이 있는가를 학습하기 위한 과정인 Context2Query이다. 이렇게 Query와 Context에 대한 Attention이 양방향으로 일어나기 때문에 Bidirectional Attention Flow(BiDAF)라는 이름이 붙여졌다.

 

또한 Query와 Context간의 유사도를 파악하기 위해 Similarity Matrix라는 것도 사용한다. Context는 전체 Document가 들어가는 형식이며, t-th Context Word와 j-th Query Word간의 Similarity를 학습하게 된다.

 

여기서는 이전에 사용하는 Attention방법들과는 다르게 Attention을 SIngle vector(혹은 Fixed-sized Vector)로 요약하는 용도로 사용하지 않는다. 때문에 요약을 하면서 생기는 정보 손실에 대한 문제가 여기서는 일어나지 않는다는 점이 특징이다.

 

Modeling Layer

class ModelingLayer(Module):
    def __init__(self, input_dim, output_dim, dropout_rate):
        super(ModelingLayer, self).__init__()
        self.rnn = GRU(input_dim, output_dim, dropout=dropout_rate,
                       batch_first=True, bidirectional=True,
                       num_layers=2)

    def forward(self, inputs):
        batch_size = inputs.size()[0]
        feature_size = inputs.size()[-1]
        
        init_hidden_state = torch.zeros(2*2, batch_size, feature_size)
        outputs, _ = self.rnn(inputs, init_hidden_state )
        
        return outputs

여기까지 오면 이젠 정보를 모두 정리하는 것만 남은 것이다. 여기서는 Query와 Context간의 Interaction을 학습하기 위해 Bi-LSTM을 사용한다. (위 코드에서는 계산량 감소를 위해 GRU를 사용하고 있다)

 

Output Layer

class OutputLayer(Module):
    def __init__(self, rnn_input_dim, rnn_output_dim, linear_input_dim, dropout_rate):
        super(OutputLayer, self).__init__()
        self.rnn = GRU(rnn_input_dim, rnn_output_dim, dropout=dropout_rate,
                       batch_first=True, bidirectional=True)
        self.start_linear = Linear(linear_input_dim, 1)
        self.end_linear = Linear(linear_input_dim, 1)
        
    def forward(self, model_inputs, att_flow_inputs):
        start_inputs = torch.cat([model_inputs, att_flow_inputs], dim=-1)
        start_logits = self.start_linear(start_inputs)
        start_probs = F.softmax(start_logits)
        
        transposed_inputs = torch.transpose(model_inputs, 1, 2)
        start_summerized_vector = torch.mm(transposed_inputs, start_probs)
        start_summerized_vector = start_summerized_vector.expand_as(model_inputs.size())
        
        rnn_inputs = torch.cat([att_flow_inputs, model_inputs, start_summerized_vector, model_inputs * start_summerized_vector], dim=-1)
        batch_size = rnn_inputs.size()[0]
        feature_size = rnn_inputs.size()[-1]
        init_hidden_state = torch.zeros(1*2, batch_size, feature_size)
        
        rnn_outputs, _ = self.rnn(rnn_inputs, init_hidden_state)
        
        end_inputs = torch.cat([rnn_outputs, att_flow_inputs], dim=-1)
        end_logits = self.end_linear(end_inputs)
        end_probs = F.softmax(end_logits)
        
        return start_probs, end_probs

마지막으로 Output을 Predict하는 과정이다. 여기서 잠깐 데이터셋에 대해 설명하자면, SQuAD 데이터셋에서는 주어진 Query에 대해 어느 문장이 정답인가에 대한 답을 하는 Task를 풀게 된다. 따라서 Context 내 어떤 문장이 정답이 포함된 문장인가를 찾아야 하는데, 이 정답을 표기하기 위해 Start(정답 문장의 시작 단어)와 End(정답 문장의 마지막 단어)를 찾는다.

 

따라서 Output에서는 Modeling Layer에서의 Forward Output과 Backward Output을 Input으로 받아 Start 토큰과 End 토큰을 찾게 된다.

 

Model 전체구조

class BiDAF(Module):
    def __init__(self, char_embedding_dim , word_embedding_dim, char_cnn_dim, hidden_dim, dropout_rate):
        super(BiDAF, self).__init__()
        
        embedding_dim = char_cnn_dim + word_embedding_dim
        
        self.char_embedding = WordEmbedding(char_vocab_size, char_embedding_dim)
        self.word_embedding = WordEmbedding(vocab_size, embedding_dim)
        
        self.char_cnn = CharacterCNNLayer(char_embedding_dim, char_cnn_dim, kernel_size)
        
        self.highway = HighwayNetwork(embedding_dim)
        self.contextual = ContextualEmbeddingLayer(embedding_dim, hidden_dim, dropout_rate)
        
        self.attn_flow = AttentionFlowLayer(hidden_dim * 6)
        self.modeling = ModelingLayer(hidden_dim * 8, hidden_dim, dropout_rate)
        self.output = OutputLayer(hidden_dim * 8, hidden_dim, hidden_dim * 10, dropout_rate)

 

Result & Conclusion

Result

Question Answering Experiment

여기서는 SQuAD 데이터셋에 대해 다른 모델과의 성능 비교를 한다. 사실 Single 모델만으로는 SOTA라고 할 수 없지만, 앙상블(Ensemble)모델로서는 SOTA를 보여주고 있다. (해당 데이터셋은 SQuAD 1.0기준이다.)

 

재밌는것은 SQuAD데이터셋에 대해 Ablation을 통해 각 부분이 얼마나 영향을 끼치고 있는가 보여주고 있다는 점이다. 다른 부분에 비해 Word Embedding과 C2Q(Context to Query)부분이 성능에 많은 영향을 끼치고 있다는 것을 확인할 수 있다.

 

Context Embedding Layer의 성능을 각각 시각화 한 결과이다. Table 2에서는 Word에 비해 Context가 Query에 대해 세부정보를 더 잘 잡아내고 있는 것을 확인할 수 있다.

 

Figure 2에서는 Context Embedding Layer가 다른 의미를 가지는 같은 형태의 단어(may vs May)에 대해 Semantic 의미를 얼마나 잘 Clustering하는가 나타내고 있다. 결과를 보면 알겠지만, Context Embedding Layer를 사용할 경우 날짜로서의 May와 ~할 것 같다 라는 의미의 may가 잘 구분되어 있는 것을 확인할 수 있다. 또한 벤 다이어그램과 Histogram을 통해 모델의 정확도에 대한 성능도 나타내주고 있다.

 

해당 결과에서는 Query에 대해 Context의 어느 부분이 Attention을 해야하는가에 대해 시각화를 했다. 정량적인 결과는 아니니 자세한 설명은 넘어가도록 한다.

 

Cloze Test Experiment

SQuAD 데이터셋 뿐만이 아닌 CNN/DailyMail 데이터셋에 대한 성능도 측정한다. (다른 논문들처럼 당연하겠지만) BiDAF의 성능이 가장 좋다는 것을 보여주고 있다.

 

Conclusion

이 논문에서 제시하는 구조인 Bidirectional Attention Flow 모델(BiDAF)에서는 Context와 Query의 양방향 Attention을 통해 Context나 Query의 요약없이 Query-aware context representation을 찾아내는 모델 구조를 제시한다. 해당 구조는 ELMO나 BERT가 나오기 전까지 가장 활발하게 연구/사용되던 모델이니 만큼 Attention을 어떻게 사용하는가에 대한 좋은 예시로 볼 수 있을 것으로 보인다.

 

또한 여기서는 Self-Attention/Transformer모델이 사용되지 않고 있는데, 여기에 그러한 구조를 넣는다면 어떤 성능을 보여줄지에 대한 궁금증도 있다.