개발자로 후회없는 삶 살기

AI hub의 일반상식 데이터를 활용한 Question & Answering 본문

[프로젝트]/[AI]

AI hub의 일반상식 데이터를 활용한 Question & Answering

몽이장쥰 2022. 12. 29. 19:30

서론

이 프로젝트는 BERT의 specific task 중 하나인 Q&A를 한국어 위키백과 데이터로 fine tuning 하고 한국어로 BERT 모델과 질의응답을 하는 것을 목표로 합니다. 

 

-> 전체 코드

https://github.com/SangBeom-Hahn/Question_And_Answering

 

GitHub - SangBeom-Hahn/Question_And_Answering

Contribute to SangBeom-Hahn/Question_And_Answering development by creating an account on GitHub.

github.com

 

본론

- 프로젝트 주제

 

위 : ko_wiki_v1_squad data/ 아래 : 질의응답 결과

위키백과 데이터로 BERT를 fine-tuning하여 질의응답을 수행합니다. 

 

=> 세부목표

① 위키백과 데이터 BERT QA task 적용할 수 있도록 전처리

② 위키백과 데이터로 BERT를 fine-tuning

③ Inference

 

- 프로젝트 시작

1. 위키백과 데이터 전처리

 

① 데이터 형식(참고 1)

Data 데이터 수 세부사항
1 75만 개 key-value 형태로 질문, 응답 제공

 

한국어 위키백과내 주요 문서 15만 개에 포함된 지식을 추출하여 객체(entity), 속성(attribute), 값(value)을 갖는 데이터 75만 개를 구축하였습니다. 위키백과 본문내용과 관련한 질문과 질문에 대응하는 wiki 백과 본문 내의 정답 쌍으로 구성됩니다.

 

[{'paragraphs': [{'context': '2010년 2월 14일 패밀리가 떴다가 종영되고, ㆍㆍㆍ,
    'qas': [{'answers': [{'answer_start': 167, 'text': 'SBS'}],
      'id': '8_C60_wiki_1818-1',
      'question': '패밀리가 떴다는 어디에서 방송했어'}]}]

1) paragraphs : 질의응답을 추출한 문단

2) qas : 문단에서의 질문의 시작 위치, 정답 text

3) id : 문서 번호

4) question : 질문 내용

 

 

② 질의응답 데이터 전처리

1) 띄어쓰기 단위 정보 관리

먼저, 띄어쓰기 단위로 정보를 관리하기 위해 다음과 같이 인코딩 된 형식의 데이터를 사용합니다. 

 

ex) '1839년 파우스트를 읽었다' 라는 문장에서 첫 번째 어절에 해당하는 1839년은 0으로, 두번째 어절인 파우스트는 1, 세번째 어절은 읽었다는 2로 처리하는 방식으로 단어를 구분할 수 있는 벡터를 만들어줍니다.

 

 

2) Tokenize by Vocab 

앞부분이 공백인 단어에는 앞에 '▁' 포함

[0, 2, 5] : 앞부분이 공백인 단어들의 위치

 

한국어 문장은 교착어이기에 읽었다, 읽고, 읽으니와 같은 어미가 달라짐에 따라 여러 단어들의 조합으로 구성된 경우가 많기 때문에, subword segmentaion을 활용하여 단어를 나누어 처리하는 과정을 거칩니다.

 

> bert모델은 일반적으로 segmentaion 방법 중 wordpiece 방식으로 토큰화를 진행하는데, 이는 기존의 빈도수 기반 쌍 병합방법과 달리, 코퍼스의 우도를 가장 높이는 쌍을 생성하여 단어들 간에 연관성을 내포할 수 있습니다.

 

∴ 이러한 wordpiece 알고리즘을 내장한 sentence piece 모듈을 사용하여 형태소 분리를 진행한 결과, 띄어쓰기가 반영된 형태소 분리 결과를 얻을 수 있었습니다.

 

 

3) 정답 영역을 정확히 찾아내기

QA task는 질문과 지문이 주어지고, 지문 영역에서 정답을 찾도록 구성되어있기 때문에 정답 영역을 정확히 찾는 것이 중요합니다.  때문에 띄어쓰기 단위로 쪼개진 context subword로 토큰화하였습니다.

 

만약 '우승'이라는 단어가 정답인 경우 '우승하였으며'에서 정확하게 우승만 추출할 수 있도록 하는 과정이 요구됩니다.

 

-> 우승만 추출하기 위한 메서드 선언

이를 위해 context에 포함된 answer의 글자 단위 시작 인덱스 answer_start 와 종료 인덱스 answer_end 위치를 찾고어절(word) 단위로 변환하는 작업을 진행합니다. 

 

 

 

다음 문장에서는 260 ~ 271 인덱스를 어절 단위로 변환한 결과 49 ~ 51번째 어절에 정답이 포함되어 있음을 알 수 있습니다. 이 같은 과정을 통해 정답 영역을 정확히 찾아낼 수 있습니다.

 

 

4) 데이터셋 분리 

다음으로, 위키백과 내 주요15만 개에 포함된 지식을 약 6 8천여개의 질문-답쌍으로 구성한 json파일의 train, test split을 진행하였습니다. 임의로 60,000 건의 데이터를 train 데이터셋으로, 나머지 약 8000건의 데이터를 test 데이터로 분리하였습니다.

 

 

분리한 데이터셋을 앞에서 설명드린 바와 같이 subword로 토큰화하는 과정을 거쳐 전처리를 완료하였습니다. 이로써 qa모델의 최종 목표인 context answer의 위치를 어절단위로 찾아주는 작업을 가능하게 만들었습니다.

 

위와 같이 train, test 전처리json 파일로 저장

 

왼쪽 :  Question과 Context가 포함된 입력데이터/ 오른쪽 :  Question을 0으로, Context를 1로 구분해 준 Segment 데이터

결과적으로 전처리를 완료한 데이터를 메모리에 로드 후 확인했을 때 Question Context가 포함된 입력데이터는 다음과 같이 벡터처리가 완료되었고, 0 1 question context를 구분해주는 정보도 포함되어 있음을 확인할 수 있습니다.

 

> 또한 answer의 시작점과 끝점 labeling도 완료

 

2. 질의응답 task BERT 모델링

① BERT의 특징

 

pre_trained bert 모델을 파인 튜닝해서 이번 프로젝트를 진행하였습니다. BERT는 트랜스포머를 기반으로 한 모델로 기존과는 달리 self-attention라는 개념을 도입하였다는 것이 특징입니다. , 인코더-디코더 중 encoder 구조만을 활용한 모델입니다.

 

② QA task에 맞게 fine-tuning

1) Input presentation

# 생성한 데이터셋 파일을 메모리에 로딩하는 함수
def load_data(args, filename):
    inputs, segments, labels_start, labels_end = [], [], [], []

    n_discard = 0
    with open(filename, "r") as f:
        for i, line in enumerate(tqdm(f, desc=f"Loading ...")):

          data = json.loads(line)
          token_start = data.get("token_start")
          token_end = data.get("token_end")
          question = data["question"][:args.max_query_length]
          context = data["context"]
          answer_tokens = " ".join(context[token_start:token_end + 1])
          context_len = args.max_seq_length - len(question) - 3

          if token_end >= context_len:
              # 최대 길이내에 token이 들어가지 않은 경우 처리하지 않음
              n_discard += 1
              continue
          context = context[:context_len]
          assert len(question) + len(context) <= args.max_seq_length - 3

          tokens = ['[CLS]'] + question + ['[SEP]'] + context + ['[SEP]']
          ids = [vocab.piece_to_id(token) for token in tokens]
          ids += [0] * (args.max_seq_length - len(ids))
          inputs.append(ids)
          segs = [0] * (len(question) + 2) + [1] * (len(context) + 1)
          segs += [0] * (args.max_seq_length - len(segs))
          segments.append(segs)
          token_start += (len(question) + 2)
          labels_start.append(token_start)
          token_end += (len(question) + 2)
          labels_end.append(token_end)
    print(f'n_discard: {n_discard}')

    return (np.array(inputs), np.array(segments)), (np.array(labels_start), np.array(labels_end))

먼저 input presentation 부터 설명하겠습니다. 저희는 NSP, Next Sentence Prediction 문제를 해결하기 위해 CLS 질문 SEP 지문 SEP 형태로 입력 모양을 정의하였습니다.

 

['[CLS]', '다', '##테', '기', '##미', '##코', '##가', '최초로', '은', '##퇴', '선', '##언', 
'##을', '한', '##게', '언', '##제', '##지', '[SEP]', '재', '##팬', '오', '##픈', '##에서', 
'4', '##회', '우승', '##하였으며', ',', '통', '##산', '단', '##식', '200', '##승', '이상', 
'##을', '거', '##두', '##었다', '.', '1994년', '생', '##애', '최초로', '세계', '랭', '##킹', 
'10', '##위', '##권', '##에', '진', '##입', '##하였다', '.', '1992년', '##에는', 'w', '##ta', 
'##로부터', "'", '올', '##해', '가장', '많은', '향', '##상을', '보', '##여', '##준', '선수', 
'##상', "'", '(', 'most', 'improved', 'player', 'of', 'the', 'year', ')', '을', '수', '##여', 
'##받', '##았으며', ',', '일본', '남자', '패', '##션', '협', '##회', '(', 'jap', '##an', 'men', 
"'", 's', 'fashion', 'association', ')', '는', '그', '##녀', '##를', "'", '가장', '패', '##셔',
'##너', '##블', '##한', '선수', "'", '(', 'most', 'fashion', '##able', ')', '로', '칭', '##했다',
'.', '생', '##애', '두', '번째', '올림픽', '참', '##가', '직', '##후', '##인', '1996년', '9월',
'24일', '최초로', '은', '##퇴', '##를', '선', '##언', '##하였다', '.', '이후', '12', '##년',
'##만', '##인', '2008년', '4월', '##에', '예', '##상', '##치', '못', '##한', '복', '##귀',
'선', '##언', '##을', '하고', '투', '##어', '##에', '되', '##돌', '##아', '##왔다', '.',
'2008년', '6월', '15일', '도쿄', '아', '##리아', '##케', '인', '##터', '##내', '##셔', '##널',
'여자', '오', '##픈', '##에서', '복', '##귀', '후', '첫', '우승', '##을', '기', '##록',
'##했으며', ',', '2009년', '9월', '27일에', '##는', '한국', '##에서', '열린', '한', '##솔',
'코', '##리아', '오', '##픈', '대회', '##에서', '우승', '##하면서', '복', '##귀', '후', '첫',
'w', '##ta', '투', '##어', '##급', '대회', '우승', '##을', '기', '##록', '##했다', '.', '한',
'##숨', '좀', '작', '##작', '쉬', '##어', '!', '[SEP]']

NSP는 두 문장을 주고 두 번째 문장이 글에서 첫 번째 문장의 바로 다음에 오는지 예측하는 방법을 의미합니다.

 

 

2) Token Embdding

class SharedEmbedding(tf.keras.layers.Layer):
    """
    Weighed Shared Embedding Class
    """
    
    def _linear(self, inputs):  # (bs, n_seq, d_model)
        """
        linear 실행
        :param inputs: 입력
        """
        n_batch = tf.shape(inputs)[0]
        n_seq = tf.shape(inputs)[1]
        inputs = tf.reshape(inputs, [-1, self.d_model])  # (bs * n_seq, d_model)
        outputs = tf.matmul(inputs, self.shared_weights, transpose_b=True)
        outputs = tf.reshape(outputs, [n_batch, n_seq, self.n_vocab])  # (bs, n_seq, n_vocab)
        return outputs

Input 임베딩은 토큰 임베딩과 세그먼트 임베딩, 포지션 임베딩이 결합한 형태입니다. 토큰 임베딩은 앞서 언급한 것과 같이 토크나이저를 통해 입력 문장을 토큰 단위로 쪼개고, 해당 토큰을 vocab에 매칭하여 숫자값인 id로 입력하는 것을 의미합니다.

 

 

3) Positional Embedding

class PositionalEmbedding(tf.keras.layers.Layer):
    """
    Positional Embedding Class
    """
    def __init__(self, config, name="position_embedding"):
        """
        생성자
        :param config: Config 객체
        :param name: layer name
        """
        super().__init__(name=name)
        
        self.embedding = tf.keras.layers.Embedding(config.n_seq, config.d_model, embeddings_initializer=kernel_initializer())

    def call(self, inputs):
        """
        layer 실행
        :param inputs: 입력
        :return embed: positional embedding lookup 결과
        """
        position = tf.cast(tf.math.cumsum(tf.ones_like(inputs), axis=1, exclusive=True), tf.int32)
        embed = self.embedding(position)
        return embed

포지셔널 임베딩을 통해 위치 정보를 알고 있는 입력값을 생성합니다. 포지셔널 임베딩 자체는 기존의 트랜스포머에서 사용되던 포지션 임베딩과 동일합니다. LSTM은 시계열 입력이므로 입력의 순서가 위치 정보를 담고 있지만, BERT는 동시 입력이므로 위치 정보를 지정해 줘야 합니다.

 

※ Segment Embedding

bert는 이 두 가지 임베딩에 각 단어가 어느 문장을 포함되는지를 규정하는 세그먼트 임베딩을 추가해서 임베딩을 표현한다는 점에서 기존 트랜스포머와 차이가 있습니다. 세그먼트 임베딩에 대해 조금 더 설명하자면 예를 들어 한국어 위키백과에서 해당 단어가 Question에 속하는지 Context에 속하는지 구분합니다.

 

+ 계산식

def get_embedding(self, tokens, segments):
        """
        token embedding, position embedding lookup
        :param tokens: 입력 tokens
        :param segments: 입력 segments
        :return embed: embedding 결과
        """
        embed = self.embedding(tokens) + self.position(tokens) + self.segment(segments)
        embed = self.norm(embed)
        return embed

 

-> 임베딩 결과 ★

Bert Masked Language Model(MLM) Next Sentence Prediction(NSP)을 사용하여 Bi-Direction으로 학습하며 토큰 세그먼트 포지션 벡터를 만들 때 참조하는 행렬은 Pre-train 태스크 수행을 잘하는 방향으로 다른 학습 파라미터와 함께 업데이트 되게 됩니다.

 

 

③ BERT 내부 구조 선언

1) EncoderLayer

class EncoderLayer(tf.keras.layers.Layer):
    """
    Encoder Layer Class
    """
    def __init__(self, config, name="encoder_layer"):
        """
        생성자
        :param config: Config 객체
        :param name: layer name
        """
        super().__init__(name=name)

        self.self_attention = MultiHeadAttention(config)
        self.norm1 = tf.keras.layers.LayerNormalization(epsilon=config.layernorm_epsilon)

        self.ffn = PositionWiseFeedForward(config)
        self.norm2 = tf.keras.layers.LayerNormalization(epsilon=config.layernorm_epsilon)

        self.dropout = tf.keras.layers.Dropout(config.dropout)
 
    def call(self, enc_embed, self_mask):
        """
        layer 실행
        :param enc_embed: enc_embed 또는 이전 EncoderLayer의 출력
        :param self_mask: enc_tokens의 pad mask
        :return enc_out: EncoderLayer 실행 결과
        """
        self_attn_val = self.self_attention(enc_embed, enc_embed, enc_embed, self_mask)
        norm1_val = self.norm1(enc_embed + self.dropout(self_attn_val))

        ffn_val = self.ffn(norm1_val)
        enc_out = self.norm2(norm1_val + self.dropout(ffn_val))

        return enc_out

bert N개의 인코더 블록을 지니고 있습니다. 이는 입력 시퀀스 전체의 의미를N번만큼 반복적으로 구축하는 것을 의미하며 당연히 인코더 블록의 수가 많을수록 단어 사이에 보다 복잡한 관계를 더 잘 포착할 수 있습니다.

 

2) Multi-Head Attention

class MultiHeadAttention(tf.keras.layers.Layer):
    """
    Multi Head Attention Class
    """
    def __init__(self, config, name="multi_head_attention"):
        """
        생성자
        :param config: Config 객체
        :param name: layer name
        """
        super().__init__(name=name)

        self.d_model = config.d_model
        self.n_head = config.n_head
        self.d_head = config.d_head

        # Q, K, V input dense layer
        self.W_Q = tf.keras.layers.Dense(config.n_head * config.d_head, kernel_initializer=kernel_initializer(), bias_initializer=bias_initializer())
        self.W_K = tf.keras.layers.Dense(config.n_head * config.d_head, kernel_initializer=kernel_initializer(), bias_initializer=bias_initializer())
        self.W_V = tf.keras.layers.Dense(config.n_head * config.d_head, kernel_initializer=kernel_initializer(), bias_initializer=bias_initializer())
        # Scale Dot Product Attention class
        self.attention = ScaleDotProductAttention(name="self_attention")
        # output dense layer
        self.W_O = tf.keras.layers.Dense(config.d_model, kernel_initializer=kernel_initializer(), bias_initializer=bias_initializer())

Multi-Head Attention은 인코더 블록의 핵심적인 부분으로 서로 다른 가중치 행렬을 이용해 어텐션을 헤드의 개수만큼 계산한 다음 이를 서로 연결 Concatenates한 결과를 갖게 됩니다. 어텐션에 관해서는 다음에 자세히 설명합니다.

 

 

3) ScaleDotProductAttention

class ScaleDotProductAttention(tf.keras.layers.Layer):
    """
    Scale Dot Product Attention Class
    """
    def __init__(self, name="scale_dot_product_attention"):
        """
        생성자
        :param name: layer name
        """
        super().__init__(name=name)

    def call(self, Q, K, V, attn_mask):
        """
        layer 실행
        :param Q: Q value
        :param K: K value
        :param V: V value
        :param attn_mask: 실행 모드
        :return attn_out: attention 실행 결과
        """
        attn_score = tf.matmul(Q, K, transpose_b=True)
        scale = tf.math.sqrt(tf.cast(tf.shape(K)[-1], tf.float32))
        attn_scale = tf.math.divide(attn_score, scale)
        attn_scale -= 1.e9 * attn_mask
        attn_prob = tf.nn.softmax(attn_scale, axis=-1)
        attn_out = tf.matmul(attn_prob, V)
        return attn_out

같은 문장 내에서 모든 단어에 대해 query, key, value를 만들고 첫 번째 단어부터 그 외의 단어와 얼마나 연관이 있는지 첫 번째 단어의 query로 질문을 합니다. 다른 단어들의 key와 첫 번째 단어의 query를 연산하여 연관 정도를 수치로 계산하고 value에 곱합니다. 최종적으로는 같은 문장 내의 value를 모아 반환합니다. 

 

> MultiHeadAttention 부분을 다시 정리하면 쿼리와 키, value를 입력받아 쿼리, , 밸류에 스케일닷프로덕트를 적용하여 다른 입력 차원으로 변환 후 출력으로 나오는 여러 개의를 concat하고 다시 projection을 수행하는 과정을 거치게 되는 것입니다.

 

 

4) Position Wise Feed Forward

class PositionWiseFeedForward(tf.keras.layers.Layer):
    """
    Position Wise Feed Forward Class
    """
    def __init__(self, config, name="feed_forward"):
        """
        생성자
        :param config: Config 객체
        :param name: layer name
        """
        super().__init__(name=name)

        self.W_1 = tf.keras.layers.Dense(config.d_ff, activation=gelu, kernel_initializer=kernel_initializer(), bias_initializer=bias_initializer())
        self.W_2 = tf.keras.layers.Dense(config.d_model, kernel_initializer=kernel_initializer(), bias_initializer=bias_initializer())

    def call(self, inputs):
        """
        layer 실행
        :param inputs: inputs
        :return ff_val: feed forward 실행 결과
        """
        ff_val = self.W_2(self.W_1(inputs))
        return ff_val

Maksed 어텐션 클래스까지 정의한 후 해당 결과를 Position Wise Feed Forward 네트워크로 통과합니다. Feed-forward Network Layer는 두 개의 Linear Transformations로 구성되어 있으며, 그 사이에 relu보다 부드러운 형태의 gelu 활성화 함수를 적용하였습니다. 참고로 gelurelu와는 달리 음수에서도 미분이 가능하기 때문에 약간의 그래디언트를 전달할 수 있으며 때문에 일반적으로 NLP Vision 분야의 태스크에 대해 성능이 더 좋다는 실험결과가 있었다고 저자가 언급하였습니다.

 

④ BERT 선언

1) BERT의 구조 정리

class BERT(tf.keras.layers.Layer):
    """
    BERT Class
    """
    def __init__(self, config, name="bert"):
        """
        생성자
        :param config: Config 객체
        :param name: layer name
        """
        super().__init__(name=name)

        self.i_pad = config.i_pad
        self.embedding = SharedEmbedding(config)
        self.position = PositionalEmbedding(config)
        self.segment = tf.keras.layers.Embedding(2, config.d_model, embeddings_initializer=kernel_initializer())
        self.norm = tf.keras.layers.LayerNormalization(epsilon=config.layernorm_epsilon)
        
        self.encoder_layers = [EncoderLayer(config, name=f"encoder_layer_{i}") for i in range(config.n_layer)]

        self.dropout = tf.keras.layers.Dropout(config.dropout)

입력 문장을 임베딩 > Positional 임베딩을 거치고 인코더 레이어의 입력으로 들어갑니다.

 

 

2) BERT Structure

class BERT4KorQuAD(tf.keras.Model):
    def __init__(self, config):
        super().__init__(name='BERT4KorQuAD')

        self.bert = BERT(config)
        self.dense = tf.keras.layers.Dense(2)

BERT PRE-TRAIN 이후 FINE-TUNNING을 진행하였습니다. 저희는 맨 앞 [CLS] 토큰을 제외한 BERT의 각 토큰마다 Fully Connected Layer를 붙여 한국어 용으로 미세조정하기 위한 모델 클래스를 생성합니다.

 

config = Config({"d_model": 512, "n_head": 8, "d_head": 64, "dropout": 0.1, "d_ff": 1024, 
"layernorm_epsilon": 0.001, "n_layer": 6, "n_seq": 384, "n_vocab": 32007, "i_pad": 0})
config.n_vocab = len(vocab)
config.i_pad = vocab.pad_id()
config

> BERT-large가 아닌 base 를 사용했지만 base 역시 너무 거대한 모델이기에 우리의 작은 TASK를 다루기에는 무리가 있다고 판단하였습니다. 따라서 레이어의 수는 기존 12개에서 6개로 줄였고, 히든 뉴런의 수는 768개에서 512로 축소합니다.

 

 

3. Inference

1) 질문 : 패밀리가 떴다는 어디에서 방송했어?

질문에 대한 답변을 보게 될 경우 토큰화된 단어 단위의 정답 유추하였다는 것을 확인하였습니다. 특히 제대로 토큰화된 예측을 하였다는 점에 주목하여야 합니다.

 

 

2) 질문 : 다케스탄 공화국에 헌법이 만들어진 게 언제야?

3) 질문 : 오키나와 사회대중당은 언제 창당했지?

질문에 대한 답변을 보게 될 경우 토큰화된 단어 단위의 정답 유추하였다는 것을 확인할 수 있습니다. 하지만 토큰화된 단어에서 subword segmentation 되지 않은 정답을 추측하였습니다.

 

=> Loss & Accuracy

일반 상식 질의응답 데이터에 대한 저희가 학습한 BERT 기반 모델의 최종 성능을 살펴보면 약 92%의 정확도를 보였습니다. 굉장히 높은 정확도를 보였다고 볼 수 있습니다. 하지만 앞서하였듯이 일부의 데이터가 subword segmentation 이 제대로 되지 않은 문제가 있다는 한계가 있습니다.

 

결론

PRETRAIN 과정은 비교적 복잡하였으나 한번 모델 학습 이후 파인 튜닝의 과정은 간단하기에 추후 다른 한국어 데이터셋을 넣었을 때도 우수한 성능이 나오는지 비교한다면 더욱 우수한 모델을 구축할 예정이며 이후 정제되지 않은 형태의 데이터를 넣었을 때 역시 우수한 성능이 나오는지를 확인해볼 예정입니다.

 

 

참고

AI Hub 일반상식 데이터

 

 

Comments