KoBART를 활용한 카카오톡 대화 요약 서비스_5(ft.추가 개선)

shooting star·2023년 4월 8일
6

들어가며

이번 포스팅은 앞서 개발한 KoBART를 활용한 카카오톡 대화 요약 서비스를 보완한 것이다. 가장 핵심적으로는. ipynb 확장자로 구성된 파일을. py 확장자 python script로 구현한 것이다. 물론 그 외에도 여러 가지 개선을 하였다. 개선사항은 다음과 같다.

기존 서비스개선 서비스진행사항(%)
.ipynb로 구현.py로 현업에 맞게 재구현100%
post-train 모든 토큰 loss 계산masking만 loss 계산100%
post-train meta data 미적용post-train meta data 적용100%(불필요)
잘못된 비율로 적용된 maskingmasking 적용 개선100%(불필요)
잘못된 datacollator 사용직접 datacollator 구현100%

MLM 기법의 경우는 앞의 모델과 평가편 포스팅을 다시 재수정해서 올리기는 하였지만 여기서도 한 번 더 자세히 다루어 보겠다.

1. Python Script

1) post-training

python scirpt로 개선하기 위하여 post_dataset.py와 post_train.py 파일 두개로 구현하였다. 우선 post-dataset.py부터 살펴보도록 하겠다.

post_dataset.py

post_dataset.py 다음과 같이 7가지의 함수로 구현되어 있다.

  • data_load() : 데이터를 불러오기 위해 파일을 읽고 meta data와 [sep]를 적용
  • data_mining() : 각 파일에서 topic과 utterance에서 추출
  • preprocess_sentence() : 데이터를 전처리
  • data_process() : 각 데이터를 preprocess_sentence()에 적용
  • preprocess_data() : input_ids, attention_mask, decode_input_ids, decode_attention_mask, lable을 tokenizer
  • add_padding_data() : padding과 MLM을 적용
  • add_ignored_data() : label의 손실함수 계산이 불필요한 토큰에 -100을 지정

이 함수들은 post_train.py에서 컨트롤 하여 사용할 예정이다.

post_train.py

  • define_argparser() : args 값을 받아서 전달
  • main(config)
    • post_dataset.py의 함수를 활용
    • tokenizer, model를 정의
    • 비식별 정보, 메타데이터, [sep]를 special token에 추가 및 model을 resize
    • model.config에 length_penalty, num_beams, no_repeat_ngram_size 값 추가
    • Seq2SeqTrainingArguments, Seq2SeqTrainer를 활용하여 최종 학습 수행
  • if __name == '__main' : python train.py를 사용하여 post_train.py를 실행

2) fine-tuning

fine-tuning의 Python Script는 post-train과 크게 다르지 않다. 달라진 점을 다음과 같이 정리보았다.

  • special token과 model.config에 새로운 값들이 이미 들어가있으니 지정하지 않아도 된다
  • post-train과 달리 input data와 label데이터가 다름
  • 데이터 파일을 열고 가져오는 방식이 조금 다름
  • 최종 학습된 모델을 통해 테스트 진행(모델과 평가편에서 다룸)

2. Masking

1) masking 코드 수정

우선 가장 핵심이 되는 MLM 기법을 적용한 코드에 대해서 알아보도록 하겠다. 아래의 코드는 masking_rate = 0.15와 inputs의 len을 곱하여 해당 input에 몇 개의 maskig을 해야 할지 지정한다. 그리고 random.sample()을 통하여 masking 해야 할 개수만큼 랜덤으로 지정하여 position을 저장한다. 이를 통해서 position된 부분에는 masking 하고 나머지는 토큰 값을 그대로 입력하는 것이다. 다음은 label에 손실 함수를 계산할 토큰을 지정하기 위한 코드를 소개하도록 하겠다.

 mask_num = int(len(inputs)*config.masking_rate)
 mask_positions = random.sample([x for x in range(len(inputs))], mask_num)

 corrupt_token = []

 for pos in range(len(inputs)):  
     if pos in mask_positions:           
         corrupt_token.append(tokenizer.mask_token_id)               
     else:
         corrupt_token.append(inputs[pos])

2) masking만 손실 함수를 계산하도록 수정

우선 MLM 기법을 적용한 tokenize 및 padding 처리된 input 값을 가져와 pad token 부분은 제거하여 label과 비교하여 값을 입력할 수 있도록 만들어 준다. 그리고 masking 되지 않은 값들의 위치를 가져와 해당 위치에는 -100을 입력한다.

  none_mask = []
  corrupt_token = [x for x in corrupt_token if x != tokenizer.pad_token_id]
  for i in range(len(corrupt_token)):
      if corrupt_token[i] != tokenizer.mask_token_id:
          none_mask.append(i)
  for mask_num in none_mask:
      inputs[mask_num] = config.ignore_index

3. Meta data 추가

다음과 같이 각 문장 맨 앞에 meta data가 되는 topic을 #으로 감싸서 입력하여 준다. 이는 나중에 special token을 지정하여 학습에 유용한 정보를 가져다줄 것이다.

topic_list.append(datum['header']['dialogueInfo']['topic'])
                          .
                          .
                          .
total_list[i].insert(0,"#"+topic_list[i]+"#")

4. Datacollator

Datacollator는 여러 가지 종류가 있다. BART와 같이 Encoder & Decoder로 이루어진 모델은 DataCollatorForSeq2seq를 사용하면 된다. MLM을 적용하는 경우에는 DataCollatorForLanguageModeling를 사용한다. 하지만 seq2seq이기 때문에 DataCollatorForLanguageModeling를 사용하지 못한다. 그리고 이번 프로젝트에서는 DataCollatorForSeq2seq의 역할인 seq2seq모델에서 학습이 가능하도록 배치를 준비할 때 레이블을 한 스텝 오른쪽으로 이동시켜 디코더 입력을 만드는 것을 직접 구현할 것이다.

1) DataCollatorForSeq2seq

허깅페이스에서 제공해 주는 DataCollatorForSeq2seq의 코드이다. 이것과 비교하여 다음 직접 구현한 코드를 살펴보도록 하겠다.

class DataCollatorForSeq2Seq:

    tokenizer: PreTrainedTokenizerBase
    model: Optional[Any] = None
    padding: Union[bool, str, PaddingStrategy] = True
    max_length: Optional[int] = None
    pad_to_multiple_of: Optional[int] = None
    label_pad_token_id: int = -100
    return_tensors: str = "pt"

    def __call__(self, features, return_tensors=None):
        if return_tensors is None:
            return_tensors = self.return_tensors
        labels = [feature["labels"] for feature in features] if "labels" in features[0].keys() else None
        # We have to pad the labels before calling `tokenizer.pad` as this method won't pad them and needs them of the
        # same length to return tensors.
        if labels is not None:
            max_label_length = max(len(l) for l in labels)
            if self.pad_to_multiple_of is not None:
                max_label_length = (
                    (max_label_length + self.pad_to_multiple_of - 1)
                    // self.pad_to_multiple_of
                    * self.pad_to_multiple_of
                )

            padding_side = self.tokenizer.padding_side
            for feature in features:
                remainder = [self.label_pad_token_id] * (max_label_length - len(feature["labels"]))
                if isinstance(feature["labels"], list):
                    feature["labels"] = (
                        feature["labels"] + remainder if padding_side == "right" else remainder + feature["labels"]
                    )
                elif padding_side == "right":
                    feature["labels"] = np.concatenate([feature["labels"], remainder]).astype(np.int64)
                else:
                    feature["labels"] = np.concatenate([remainder, feature["labels"]]).astype(np.int64)

        features = self.tokenizer.pad(
            features,
            padding=self.padding,
            max_length=self.max_length,
            pad_to_multiple_of=self.pad_to_multiple_of,
            return_tensors=return_tensors,
        )

        # prepare decoder_input_ids
        if (
            labels is not None
            and self.model is not None
            and hasattr(self.model, "prepare_decoder_input_ids_from_labels")
        ):
            decoder_input_ids = self.model.prepare_decoder_input_ids_from_labels(labels=features["labels"])
            features["decoder_input_ids"] = decoder_input_ids

        return features

2) 직접 구현한 코드

여기에서 핵심은 label을 구현하는 코드이다. 특히 add_ignored_data() 함수를 유의깊게 보면 될 것 같다. 이를 통해서 DataCollatorForSeq2seq의 형식으로 이번 모델에 맞게 구현할 수 있었다.

def add_ignored_data(inputs, config, corrupt_token, tokenizer):
  none_mask = []
  corrupt_token = [x for x in corrupt_token if x != tokenizer.pad_token_id]
  for i in range(len(corrupt_token)):
      if corrupt_token[i] != tokenizer.mask_token_id:
          none_mask.append(i)
  for mask_num in none_mask:
      inputs[mask_num] = config.ignore_index
  if len(inputs)+1 < config.max_len:
      pad = [config.ignore_index] * (config.max_len - (len(inputs)+1)) # ignore_index즉 -100으로 패딩을 만들 것인데 max_len - lne(inpu)
      inputs = np.concatenate([inputs, [tokenizer.eos_token_id], pad])
  else:
      inputs = inputs + [tokenizer.eos_token_id]
      inputs = inputs[:config.max_len]
  return inputs

def add_padding_data(inputs, config, tokenizer, is_mlm=False):

    if is_mlm:
        mask_num = int(len(inputs)*config.masking_rate)
        mask_positions = random.sample([x for x in range(len(inputs))], mask_num)

        corrupt_token = []

        for pos in range(len(inputs)):  
            if pos in mask_positions:           
                corrupt_token.append(tokenizer.mask_token_id)               
            else:
                corrupt_token.append(inputs[pos])

        if len(corrupt_token) < config.max_len:
            pad = [tokenizer.pad_token_id] * (config.max_len - len(corrupt_token))
            inputs = np.concatenate([corrupt_token, pad])
        else:
            inputs = corrupt_token[:config.max_len]
    else:
        if len(inputs) < config.max_len:
            pad = [tokenizer.pad_token_id] * (config.max_len - len(inputs))
            inputs = np.concatenate([inputs, pad])
        else:
            inputs = inputs[:config.max_len]

    return inputs


def preprocess_data(data_to_process, tokenizer, config):
    label_id= []
    label_ids = []
    dec_input_ids = []
    input_ids = []    

    for i in range(len(data_to_process['Text'])):
        input_ids.append(add_padding_data(tokenizer.encode(data_to_process['Text'][i], add_special_tokens=False), config, tokenizer, is_mlm=True))
        label_id.append(tokenizer.encode(data_to_process['Text'][i]))  
        dec_input_id = tokenizer('<s>')['input_ids']
        dec_input_id += label_id[i]
        dec_input_ids.append(add_padding_data(dec_input_id, config, tokenizer))
        label_ids.append(add_ignored_data(label_id[i], config, input_ids[i], tokenizer))

    return {'input_ids': torch.tensor(input_ids),
            'attention_mask' : torch.tensor((np.array(input_ids) != tokenizer.pad_token_id).astype(int)),
            'decoder_input_ids': torch.tensor(dec_input_ids),
            'decoder_attention_mask': torch.tensor((np.array(dec_input_ids) != tokenizer.pad_token_id).astype(int)),
            'labels': torch.tensor(label_ids)}

마치며

마지막으로 수정된 코드를 검증하기 위해서 학습을 돌렸는데 성능이 이전보다 더 떨어졌다. 그래서 여러 가설을 세우고 테스트를 해보았다.

  • masking된 loss만 계산하기에 epoch 늘리기 vs 1 epoch 학습
  • 모든 토큰 loss 계산 vs masking 토큰만 loss 계산
  • Transformers 데이터 콜레이터 함수 vs 직접 구현한 데이터 콜레이터
  • 무작위로 생성된 값의 0.15 미만 값 masking vs 길이당 0.15의 masking
  • post-train시 meta data 사용 vs post-trian시 meta data 미사용

이 결과 길이당 0.15비율로 masking 한 것보다 무작위로 생성된 tensor값의 0.15미만 값이 성능이 더 높았다. 실수로 한 코드였지만 성능은 더 좋았던 것이다. masking된 토큰을 확인해본 결과 이유는 더 많은 token이 masking되기 때문으로 확인된다. 그래서 0.15~0.3%로 masking 하면 될 것이다. 이 이상의 실험은 colab 컴퓨팅이 부족하여 더 진행하지 못했다.

그리고 사실 1월에 post-train시 meta data 사용시 성능 변화에 대해서 실험했었지만 기록을 제대로 안 해서 잊고 meta data를 적용했다. meta data를 적용하면 성능이 떨어지는 이유는 MLM기법으로 구어체 대화문에 대한 모델의 적응을 하는 중인데 메타 데이터가 들어가면 masking된 '-거다' 등의 token을 예측하는데에 대한 유용하지 않은 값을 줄 수 있기 때문이다.

이로써 추가로 개선한 사항들을 마무리하고 KoBART를 활용한 카카오톡 대화 요약 서비스를 마무리하고자 한다. 물론 시간이 지나 더 실력이 쌓였을 때 지금의 코드를 보고 개선해야 할 점이 보인다면 다시 개선해서 포스팅할 예정이다.

github로 이동 : KoBART를 활용한 카카오톡 대화 요약 서비스

0개의 댓글