프로젝트 회고

문장 간 유사도 측정(STS) 대회 회고

sangwonYoon 2023. 4. 23. 19:48

대회 개요

STS (Semantic Textual Similarity) task는 두 개의 문장이 주어졌을 때, 이 두 문장 사이의 의미적 유사도를 측정하는 자연어 처리(NLP) task이다.

STS task는 NLP 분야에서 중요한 문제 중 하나로, 텍스트 매칭, 정보 검색, 자동 요약, 기계 번역 등의 응용 프로그램에서 유용하게 활용된다. STS task는 주로 평가를 위해 사용되며, 주어진 두 문장 사이의 유사도를 0부터 5까지의 점수로 평가하는 것이 일반적이다. 이때, 0은 전혀 유사하지 않음을 나타내고, 5는 매우 유사함을 나타낸다.

학습 데이터셋 9324개, 검증 데이터셋 550개를 활용해 1100개의 평가 데이터에 대한 예측을 진행한다. 데이터 셋에는 두 개의 문장과 ID, 유사도 점수가 담겨있으며 평가 방식은 정답과 예측 값에 대한 피어슨 상관 계수를 이용하여 평가한다.

 

협업 방식

GitHub

  • GitHub repository를 통해 코드의 버전 관리를 진행하였다.
  • GitHub Issues를 통해 실험 진행 상황을 공유하였다.

 

Notion

  • Notion의 공유 페이지를 통해 실험 내용을 브레인 스토밍하고, 역할을 분담하였다.

 

Hyper Parameter Tuning

Base Pretrained Model

앙상블에 사용될 모델을 찾기 위해 팀원들간에 모델을 분담하여 맡아 실험을 진행하기로 하였다.

Hugging Face 플랫폼에 배포된 사전 학습된 모델을 사용했으며, 모델 선정 기준은 한글 데이터 셋으로 학습된 모델이면서 text classification task에 해당 되는 모델을 위주로 탐색하였다.

본 대회가 STS task임에도 STS task에 해당하는 모델을 사용하지 않은 이유는, Hugging Face에서 STS task에 해당하는 모델들은 각 문장을 임베딩하여 문장 간의 거리를 구하는 방식이고, 본 대회는 두 문장 간의 유사도를 회귀 방식으로 구하는 문제이기 때문에 Hugging Face에서 말하는 STS task보다는 text classification task와 더 유사하다고 판단했기 때문이다.

내가 맡은 모델은 아래와 같다.

  • monologg/koelectra-base-finetuned-nsmc
  • 대회에서 제공한 데이터 셋이 nsmc(Naver sentiment movie corpus) 데이터를 포함하고 있기 때문에, nsmc 데이터로 fine tuning한 모델이 성능이 좋을 것이라고 판단하였다.
  • snunlp/KR-ELECTRA-discriminator
  • ELECTRA를 기반으로 한 모델로, 위키피디아 문서, 뉴스 기사와 댓글, 제품 리뷰 등을 포함한 34GB의 한글 텍스트를 통해 학습 시킨 모델이다.
  • 많은 양의 한글 텍스트 데이터로 학습이 된 모델이고, 평가 데이터 셋에 들어 있는 문장과 유사한 형태인 뉴스 댓글, 제품 리뷰와 같은 데이터로 학습되어 적합한 모델이라고 판단하였다.

 

WandB

wandb의 sweep을 통해 하이퍼 파라미터를 튜닝했다.

  • monologg/koelectra-base-finetuned-nsmc

모든 실험에서 사용된 학습 데이터와 검증 데이터는 대회에서 제공된 원본 데이터를 사용하였다.

나머지 하이퍼 파라미터들은 고정적으로 유지한 채, batch size, learning rate, weight decay를 조작 변인으로 실험하였다.

  • batch size : [4, 8, 16]
  • learning rate : 1e-5 ~ 5e-5 사이의 임의의 값
  • weight decay : [0.2, 0.3, 0.4, 0.5]

 

  • 실험 결과

batch size : 4, learning rate : 0.0000165, weight decay : 0.3으로 튜닝했을 때 피어슨 상관 계수가 0.9063으로 가장 높았으나, 다른 모델에 비해 성능이 좋지 않아 최종 앙상블에서는 사용되지 않았다.

 

  • snunlp/KR-ELECTRA-discriminator

모든 실험에서 사용된 학습 데이터와 검증 데이터는 대회에서 제공한 원본 데이터에 down sampling + swap sentence + copied sentence를 통해 라벨 분포를 균등하게 맞춘 데이터를 사용하였다.

나머지 하이퍼 파라미터들은 고정적으로 유지한 채, batch size, learning rate, weight decay를 조작 변인으로 실험하였다.

  • batch size : [4, 8, 16]
  • learning rate : 1e-5 ~ 5e-5 사이의 임의의 값
  • weight decay : [0.4, 0.5]

 

  • 실험 결과

batch size : 16, learning rate : 0.00001433, weight decay : 0.5로 튜닝했을 때 피어슨 상관 계수가 0.9337로 가장 높았다.

모델 성능이 좋다고 판단되어 해당 하이퍼 파라미터 셋으로 최종 앙상블에서 사용되었다.

 

EDA

문장 길이 분석

학습 데이터 셋의 sentence 1과 sentence 2를 합쳐 토큰화한 길이의 분포이다.

토크나이저는 monologg/koelectra-base-finetuned-nsmc 토크나이저를 사용했다.

최대 길이가 169이므로, 토크나이징 시 max_length의 값을 기존 512에서 256으로 줄여 padding 토큰의 양을 줄일 수 있었다. 그로 인해 연산량이 2배 가량 빨라졌다.

 

문장 길이 차이 분석

학습 데이터 셋의 sentence 1과 sentence 2의 길이 차이 분포이다.

0을 평균으로 하는 정규 분포의 형태를 띄는 것을 확인할 수 있다.

이 분석을 토대로 이후 Random Noise 실험을 진행했다.

 

라벨 분포 분석

학습 데이터셋의 라벨 분포이다.

0점부터 0.5점 사이의 데이터가 지나치게 많고, 4.5점부터 5점 사이의 데이터가 적어 데이터의 불균형이 존재한다. 라벨이 불균형한 데이터로 모델을 학습하게 되면 자주 등장하는 라벨에 대해 편향되게 예측하는 등 모델 학습에 부정적인 영향을 미칠 수 있다.

따라서 이를 해결하기 위해 우리 팀에서는 데이터를 전처리 및 증강하여 라벨의 분포를 균등하게 맞추어 모델의 성능을 높이려는 다양한 실험들을 진행하였다.

 

Data Preprocessing & Data Augmentation

내가 진행한 데이터 전처리 및 증강 실험들은 아래와 같다.

 

문장 전처리

def preprocess_text(self, text):
        # create Korean tokenizer using soynlp library
        tokenizer = RegexTokenizer()

        # 2회 이상 반복된 문자를 정규화
        text = repeat_normalize(text, num_repeats=2)
        # 불용어 제거
        text = ' '.join([token for token in text.split() if not token in stopwords])
        # 대문자를 소문자로 변경
        text = text.lower()
        # "<PERSON>"을 "사람"으로 변경
        text = re.sub('<PERSON>', '사람', text)
        # 한글 문자, 영어 문자, 공백 문자를 제외한 모든 문자 제거
        text = re.sub('[^가-힣a-z\\s]', '', text)
        # 텍스트를 토큰으로 분리  예) "안녕하세요" -> "안녕", "하", "세요"
        tokens = tokenizer.tokenize(text)
        # 어간 추출
        tokens = [self.stemmer.morphs(token)[0] for token in text.split()]
        # join tokens back into sentence
        text = ' '.join(tokens)
        return text

데이터 셋의 sentence 1과 sentence 2에 대해 전처리를 진행하여 모델의 입력으로 넣어주었으나, 모델의 예측 성능은 하락하였다.

위와 같은 결과의 이유는 모델이 사전 학습한 데이터의 경우 위와 같이 전처리된 문장 형태가 아닌 자연스러운 전체 문장의 형태이기 때문에 새로운 형태의 문장에 대한 예측 성능이 떨어진다고 판단하여 해당 기법은 사용하지 않았다.

 

라벨 스케일링

def preprocessing(self, data):
        data = data.drop(columns=self.delete_columns)
        try:
            if self.state == "train":
                targets = data[self.target_columns].apply(lambda x: min(5, round(max(0, x + 0.3),2)) if x >= 2.5 else min(5, round(max(0, x - 0.3),2))).values.tolist()
            else:
                targets = data[self.target_columns].values.tolist()
        except:
            targets = []
        inputs = self.tokenizing(data)
        return inputs, targets

본 대회의 평가 지표인 피어슨 상관 계수의 특성상 높은 값을 낮게 예측하거나 그 반대의 경우, 점수가 낮게 나온다는 점에 착안하여 라벨 값이 2.5점 이상인 데이터는 0.3점을 추가로 더하고, 라벨 값이 2.5점 미만인 데이터는 0.3점을 빼는 방식으로 높은 값은 높게 예측하고 낮은 값은 낮게 예측할 수 있도록 라벨 값을 스케일링했다.

그러나 모델 예측 성능은 하락하였다. 그 이유는 피어슨 상관 계수에 대한 잘못된 이해로 인한 것이었는데, 정답과 예측 값 사이의 증감율이 일치하여야 높은 점수를 받을 수 있지만 위와 같은 방식으로 라벨을 스케일링하게 되면 학습 시점부터 원본 데이터의 라벨 값과 수정된 데이터의 라벨 값의 증감율이 불일치하므로 성능이 떨어질 수 밖에 없었던 것이다. 따라서 해당 기법 또한 사용하지 않았다.

 

어순 도치 증강

# 어순 도치

import random
import pandas as pd
from tqdm import tqdm

train_df = pd.read_csv("./train.csv")

def swap_sentence(sentence):
    sentence = sentence[0]
    if len(sentence.split()) == 1:
        return sentence
    target = random.randrange(len(sentence.split())-1)
    temp = sentence.split()
    temp[target], temp[target+1] = temp[target+1], temp[target]
    return " ".join(temp)

temp = pd.DataFrame()

for idx, item in tqdm(train_df.iterrows(), desc = "augment", total=len(train_df)):
    new_row = item.to_frame().transpose()
    new_row["sentence_1"] = swap_sentence(new_row["sentence_1"].values)
    new_row["sentence_2"] = swap_sentence(new_row["sentence_2"].values)
    temp = pd.concat([temp, new_row])

train_df = pd.concat([train_df, temp])
train_df.to_csv("./augment.csv", index = False)

학습 데이터의 라벨 분포를 균등 분포로 맞춰주기 위해 시도했던 방법이다.

상대적으로 데이터의 수가 부족한 라벨 값이 1 이상 5 이하인 데이터의 문장에서 이웃한 단어의 위치를 서로 바꾸어 새로운 데이터를 만들어주는 방식이다.

그러나 모델의 예측 성능은 하락하였고 그 이유는 어순 도치가 문장의 의미를 훼손시켜 학습 과정에서 부정적인 영향을 미쳤다고 판단하여 해당 기법은 사용하지 않았다.

 

단순 복제 증강

# 단순 복제로 학습 데이터 라벨 분포 맞추기

import random
import pandas as pd
from tqdm import tqdm
from math import floor

train_df = pd.read_csv("./train.csv")

l_0 = len(train_df[train_df['label'] < 1])
l_1 = len(train_df[(train_df['label'] >= 1) & (train_df['label'] < 2)])
l_2 = len(train_df[(train_df['label'] >= 2) & (train_df['label'] < 3)])
l_3 = len(train_df[(train_df['label'] >= 3) & (train_df['label'] < 4)])
l_4 = len(train_df[(train_df['label'] >= 4)])

l_list = [l_0, l_1, l_2, l_3, l_4]

temp = pd.DataFrame()

for idx, item in tqdm(train_df.iterrows(), desc = "augment", total=len(train_df)):
    label = floor(item["label"])
    if label < 1:
        continue
    if label == 5:
        label -= 1
    if l_list[label] > 3711:
        continue
    print(label)
    new_row_1 = item.to_frame().transpose()
    temp = pd.concat([temp, new_row_1])

    new_row_2 = item.to_frame().transpose()
    temp = pd.concat([temp, new_row_2])

    l_list[label] += 2

train_df = pd.concat([train_df, temp])
train_df.to_csv("./augment.csv", index = False)

어순 도치 증강이 문장의 의미를 훼손한다는 문제점을 보완하기 위해 사용한 방법이다.

새로운 데이터를 추가하지 않고도 단순히 같은 데이터를 추가하여 라벨의 분포를 균등하게 맞추기만 해도 데이터 불균형이 해소되므로, 모델의 예측 성능이 향상될 것이라는 가설을 기반으로 진행되었다.

라벨 값을 1 단위로 나누어, 1점 이상의 단위 데이터의 양이 0점 미만의 데이터의 양과 같아지도록 데이터를 단순 복제하여 증강하였다.

결론적으로 원본 데이터로 학습한 모델보다 해당 데이터로 학습한 모델의 예측 성능이 향상되었으나, 과적합 등의 문제로 해당 기법은 사용되지 않았다.

 

Under Sampling + Swap Sentence + Copied Sentence

데이터 셋의 문장을 변형하지 않되 반복되지 않는 데이터를 추가하여 증강하기 위해 아래와 같은 방법들을 사용하여 라벨의 분포를 균등하게 맞춰주었다.

  • Under Sampling
## under sampling

import pandas as pd

df = pd.read_csv('train.csv')
df_0 = df[df['label']==0][1000:2000].copy()

df_new = df[df['label']!=0].copy()
df_new = pd.concat([df_new, df_0])
df_new

학습 데이터의 1/3 가량이 라벨 값이 0점인 데이터이므로, 나머지 구간의 데이터를 문장 변형 없이 증강하는 방식으로는 개수를 맞춰주기 어렵다고 판단하였다. 따라서 0점 데이터 중 1000개만 추출하여 학습에 사용하고 나머지 사용되지 않는 데이터의 일부는 copied sentence에서 활용했다.

문장 길이 분포의 변화가 모델의 학습에 미치는 영향을 줄이기 위해 전체 0점 데이터 문장 길이 분포와 추출한 데이터의 문장 길이 분포가 유사하도록 추출하였다.

 

  • Swap Sentence
## swap sentence

df_switched = df_new.copy()
df_switched["sentence_1"] = df["sentence_2"]
df_switched["sentence_2"] = df["sentence_1"]
df_switched = df_switched[df_switched['label'] != 0]
df_switched

STS task에 사용되는 BERT 계열의 모델의 경우, 데이터 셋 내의 sentence_1과 sentence_2의 순서를 바꾸게 되면 유의미한 차이를 만들 수 있을 것이라고 가설을 세웠다. 이 가설에 대한 근거로는 문장의 위치가 바뀌면서 segment embedding과 positional embedding의 값이 달라져, 새로운 데이터를 학습하는 효과를 낼 수 있을 것이라고 생각했다.

따라서 1점 이상의 데이터에 해당 기법을 적용하여 데이터의 양을 2배로 늘려주었다.

 

  • Copied Sentence
## copied sentence

copied_df = df[df['label']==0][250:750].copy()
copied_df['sentence_1'] = copied_df['sentence_2']
copied_df['label'] = 5.0
copied_df

논문에서 영감을 받아 응용한 기법으로, 다른 점수대의 데이터보다 라벨 값이 5점인 데이터의 양이 많이 부족하여 5점짜리 데이터를 증강하기 위해 고안해 낸 방법이다.

이 기법을 사용하면 원본 학습 데이터의 문장을 그대로 사용함과 동시에 문장의 의미가 거의 일치하는 5점 데이터의 패턴을 모델이 학습하기 쉬워질 것이라고 가설을 세웠다.

under sampling 기법에서 버려진 데이터의 일부를 가져와 같은 문장을 sentence 1과 sentence 2에 넣고 라벨 값을 5점으로 주어서 새로운 데이터 500개를 생성하였다.

 

위 세가지 방법을 통해 증강한 데이터의 개수는 총 15910개로, 원본 학습 데이터의 개수인 9324개보다 약 1.5배 증가하였다.

해당 데이터로 모델을 학습시킨 결과 우리 팀에서 사용했던 모든 모델에서 예측 성능이 향상되어, 유의미한 증강 기법임을 확인할 수 있었다.

 

Random Noise

def check_punctuation(str):
    bool = False
    for c in ".?!;~,ㅠㅎ":
        bool = bool or str.endswith(c)

    return bool
import random

# 문장 내 임의의 공백 제거
def remove_random_space(sentence):
    random.seed(42)
    
    words = sentence.split()
    if len(words) == 1:
        return False
    
    idx = random.randint(0, len(words)-2)

    words.insert(idx, words.pop(idx) + words.pop(idx))
    return " ".join(words)
## copied translation

copied_df = df[df['label']==0][250:750].copy()
copied_df['sentence_1'] = copied_df['sentence_2']
copied_df['label'] = 5.0
copied_df.reset_index(inplace=True)

# 5점 문장의 경우 길이에 대한 편향이 일어나지 않게 공백 제거 및 불용어 추가

cnt = 0
for idx, item in enumerate(copied_df['sentence_1']):
    if cnt < 125:
        if check_punctuation(item):
            modified_sentence = item + (item[-1]*2)
            if idx == 0:
                print(modified_sentence)
            copied_df.loc[idx, 'sentence_1'] = modified_sentence
            cnt += 1
    elif cnt < 250:
        modified_sentence = remove_random_space(item)
        if modified_sentence != False:
            copied_df.loc[idx, 'sentence_1'] = modified_sentence
            if cnt == 125:
                print(item)
                print(modified_sentence)
            cnt += 1

copied_df

Copied Sentence 기법으로 추가된 데이터의 경우 sentence 1과 sentence 2가 동일하다는 한계점을 극복하기 위해 제안된 방법이다. 실제 평가 데이터는 sentence 1과 sentence 2가 동일하지 않으므로 문장에 noise를 넣어 두 문장의 내용을 다르게 만들어주면 모델의 예측 성능이 향상될 것이라는 가설을 기반으로 진행하였다.

학습 데이터의 문장 길이 차이 분석을 통해 sentence 1과 sentence 2의 길이 차이의 분포가 0을 평균으로 하는 정규 분포의 형태를 띄기 때문에 Copied Sentence로 추가된 데이터도 이러한 분포를 따르게 하기 위해 아래와 같은 방식으로 데이터를 전처리하였다.

  • 125개 데이터 : 문장 마지막에 불용어 문자 추가
  • 125개 데이터 : 문장 내 임의의 공백 제거
  • 250개 데이터 : 전처리하지 않음

Under Sampling + Swap Sentence를 적용한 데이터에서 Copied Sentence에 Random Noise를 적용한 데이터로 실험해본 결과 모델의 예측 성능은 하락하였다.

그 이유는 Random Noise에서 문장 내 임의의 공백을 제거하는 것이 원본 문장의 의미를 훼손했기 때문이라고 생각한다. 특히 한글 문장의 경우 띄어쓰기의 유무에 따라 의미 변화가 크기 때문에 더 부정적인 영향을 미쳤을 것이다.

또한 해당 실험에서 아쉬웠던 부분은 실제로 Random Noise를 주지 않은 데이터 셋으로 학습시킨 모델이 5점 데이터를 제대로 예측하지 못하는지 확인하지 않고 진행했던 부분이었다. 이번 실험의 실패를 교훈삼아 다음 대회에서는 모델의 예측 결과와 실제 값을 비교하여 어느 부분을 잘못 예측하고 있는지를 확인한 뒤 실험의 방향을 결정해야겠다.

 

Ensemble

최종 앙상블에서 사용된 모델은 아래와 같다.

  • kykim/electra-kor-base
  • xlm-roberta-large
  • snunlp/KR-ELECTRA-discriminator

위 모델들이 예측한 값을 Soft Voting하여 최종 예측값으로 사용했다.

각 모델의 평가 데이터에 대한 public score를 성능 지표로 삼아 성능에 따른 Weighted Voting 방식을 시도해보았으나, 3개의 모델의 public score가 거의 동일하여 유의미한 차이는 없었다.

 

최종 결과

Public Score 0.9367점 1위, Private Score 0.9403점 2위로 마무리 지었다.

 

보완할 점

코드 관리 측면

딥러닝에서 처음 경험하는 대회였다 보니 코드 모듈화와 같은 리팩토링에 전혀 신경쓰지 못한 부분이 아쉬웠다. 이로 인해 실험을 진행할 때에도 코드의 어느 부분을 수정해야될지 종종 헷갈렸다. 다음 대회에서는 효율적인 실험 진행과 가독성을 위해 코드 리팩토링을 진행해야겠다.

 

실험 진행 측면

이번 대회에서는 하이퍼 파라미터 튜닝, 전처리, 증강 등의 실험에서 모두 seed를 42만 사용하여 실험했다. 이렇게 실험할 경우 실제로는 성능이 향상됨에도 seed가 잘못 걸려 성능이 향상되는것을 관찰할 수 없을 수도 있다. 따라서 다음 대회에서는 같은 실험에 대해 여러 seed를 사용해 평균낸 값으로 성능이 향상되었는지 확인해 볼 것이다.

 

모델 측면

이번 대회에서는 사전 학습된 모델의 구조를 크게 수정할 기회가 없었다. 비록 성능이 떨어질지 몰라도, 이번 대회에서 시도했던 것보다 더 low level에서 모델의 구조를 원하는 방식으로 변형해봐야겠다.