💭 Ideation/NLP

KoBERT로 다중 감정 분류 성능 개선을 위한 FineTuning 도전, 1탄

Ju_pyter 2023. 11. 24. 13:15

 

 

사용자 일기에 대한 다중 감정 분류를 진행하기 위해

KoBERT 파인튜닝 과정을 기록하겠습니다!✍️✍️

 

KoBERT모델을 아래 공식 링크를 참고하여 불러오면 됩니다.

https://github.com/SKTBrain/KoBERT

 

GitHub - SKTBrain/KoBERT: Korean BERT pre-trained cased (KoBERT)

Korean BERT pre-trained cased (KoBERT). Contribute to SKTBrain/KoBERT development by creating an account on GitHub.

github.com

 

데이터셋은 

AI Hub에서 제공한 감성 대화 말뭉치 데이터를 사용했습니다!

https://aihub.or.kr/aihubdata/data/view.do?currMenu=115&topMenu=100&aihubDataSe=data&dataSetSn=86

 

AI-Hub

샘플 데이터 ? ※샘플데이터는 데이터의 이해를 돕기 위해 별도로 가공하여 제공하는 정보로써 원본 데이터와 차이가 있을 수 있으며, 데이터에 따라서 민감한 정보는 일부 마스킹(*) 처리가 되

aihub.or.kr

 

이제부터.. 

KoBERT 튜닝을 향한 저의 삽질이 담긴 과정을 적어보도록 하겠습니다,,,🥹🥹 

 

그냥 공식 링크에 나와있는 코드에서 파라미터만 약간 수정하고

실행시킨 결과

 

train acc는 높은데 , test acc가 낮은 것을 확인할 수 있었는데요.

이를  "과적합"이라고 생각하고 

과적합 해결을 위해

dropout비율을 0.3에서 0.5로 증가 

정규화를 위해 laynorm 레이어를 모델에 커스텀을 진행하였습니다.

 

여기서, 정규화하면 보통 배치 정규화를 생각할 텐데, 

저는 LayerNorm Regularization을 사용했습니다.

 

그 이유는 아래 2가지때문인데요!

순환 신경망(RNN) 구조에 적합:

KoBERT를 사용하여 한국어 텍스트의 감정 분류를 수행할 때,

대부분의 경우에는 순환 신경망보다는 Transformer와 같은 어텐션 메커니즘을 사용.

Layer Normalization은 이러한 Transformer 구조에 더 적합하게 설계되어있음

 

미니배치 크기에 덜 민감:

LayerNorm은 미니배치 크기에 상대적으로 덜 민감하며,

KoBERT와 같이 사전 훈련된 언어 모델을 사용할 때 미니배치의 크기에 따른 영향을 줄일 수 있음

 

이러한 2가지 이유로 배치 정규화보다는 LayerNorm Regularization을 사용하였고,

이를 반영하여 모델 구조는 아래와 같이 커스텀을 했습니다.

#입력 데이터의 패딩 부분을 제외하고, 실제 입력에 대한 어텐션 마스크를 생성하는 함수

class BERTClassifier(nn.Module):
    def __init__(self,
                 bert,
                 hidden_size = 768,
                 num_classes=6,
                 dr_rate=None,
                 params=None):
        super(BERTClassifier, self).__init__()
        self.bert = bert
        self.dr_rate = dr_rate

        self.classifier =  nn.Sequential(
            nn.Dropout(p=0.2),
            nn.Linear(in_features=hidden_size, out_features=512),
            nn.Linear(in_features=512, out_features=num_classes)
        ) #nn.Linear(hidden_size , num_classes)

        #정규화 레이어 추가 (Layernormalization)
        self.layer_norm = nn.LayerNorm(768)

        #드롭아웃
        self.dropout = nn.Dropout(p=dr_rate)


    def gen_attention_mask(self, token_ids, valid_length): #token_ids는 입력 문장을 토큰화한 결과
        attention_mask = torch.zeros_like(token_ids)
        for i, v in enumerate(valid_length):
            attention_mask[i][:v] = 1
        return attention_mask.float()

    def forward(self, token_ids, valid_length, segment_ids):
        attention_mask = self.gen_attention_mask(token_ids, valid_length) #gen_attention_mask 메서드를 사용하여 어텐션 마스크를 생성

        _, pooler = self.bert(input_ids = token_ids, token_type_ids = segment_ids.long(), attention_mask = attention_mask.float().to(token_ids.device)) #BERT 모델에 입력을 전달하여 출력을 계산

        pooled_output = self.dropout(pooler)
        normalized_output = self.layer_norm(pooled_output)  # Layer Normalization 적용
        out=self.classifier(normalized_output)

        return out

 

 

하지만..

모델 구조를 커스텀했음에도 불구하고,

test acc가 처음보다는 좋아졌지만 여전히 낮은 수치를 보였는데요..

 

여기서 저는 데이터셋의 문제라 판단을 하고, 

데이터 전처리를 하기로 결심했습니다🥹🥹

 

기존의 데이터 라벨은 "기쁨", "슬픔", "분노", "불안", "상처", "당황" 이렇게 총 6가지가 있었는데,

저는 여기서 슬픔이랑 상처는 비슷한 유형의 감정이라고 판단을 하고

두개의 레이블을 하나로 합쳤습니다.

## 상처와 슬픔을 하나의 레이블로 설정

#분노, 불안, 상처, 기쁨, 슬픔, 당황
train_set.loc[(train_set['label'] == '불안'), 'label'] = 0
train_set.loc[(train_set['label'] == '분노'), 'label'] = 1
train_set.loc[(train_set['label'] == '상처'), 'label'] = 2
train_set.loc[(train_set['label'] == '슬픔'), 'label'] = 2
train_set.loc[(train_set['label'] == '당황'), 'label'] = 3
train_set.loc[(train_set['label'] == '기쁨'), 'label'] = 4
train_set['label']=train_set['label'].astype(int)


validation_set.loc[(validation_set['label'] == '불안'), 'label'] = 0
validation_set.loc[(validation_set['label'] == '분노'), 'label'] = 1
validation_set.loc[(validation_set['label'] == '상처'), 'label'] = 2
validation_set.loc[(validation_set['label'] == '슬픔'), 'label'] = 2
validation_set.loc[(validation_set['label'] == '당황'), 'label'] = 3
validation_set.loc[(validation_set['label'] == '기쁨'), 'label'] = 4
validation_set['label']=validation_set['label'].astype(int)

 

이렇게 전처리를 하고 코드를 실행한 결과 더 개선이 되었음을 확인할 수 있었습니다!

 

하지만 아직도 test 0.65정도의 낮은 수치를 보였는데요,,

그래도 아까보다는 0.1정도 개선이 된 걸 보니 뭔가 희망이 보이기 시작하네요..🥹🥹

 

 

여기서 저는 무엇을 더 해야하는지 엄청 고민을 했었습니다..

모델은 완벽한 거 같은데, 도대체 무엇이 문제일까...

:

그래서 내린 결론이

"평가지표를 잘못된 걸로 판단하고 있나?" 였습니다

생각해보니까..

보통 분류 문제에서는 ACC보다는 F1-score로 평가지표를 사용한다는 사실을 까먹고 있었어요..

(오래 전에 공부하면서 알았던 사실인데....왜 이걸 이제야 기억했을까요...?🥹🥹)

 

F1 score는 precision과 recall이 모두 높으면 높고 0.0 ~ 1.0 사이의 값을 가지는데 (e.g. 0.87, %아님 주의),

한 슈퍼마켓에서 buyer와 looker를 분류하는 예시로 설명하자면

99명이 looker(negative)이고 1명이 buyer(positive)인 경우

모두 looker로 분류한 모델을 Accuracy(정확도)로 평가하면

99%의 정확도라는 썩 괜찮은 모델같지만,

F1 score로 평가하게 되면 0이 나오게 됩니다.

 

이러한 이유로

데이터 불균형이 심한 다중 클래스 분류 문제에서는

보통 평가지표로 F1-score를 사용합니다!!

 

:

 

위에서 설명했듯이 클래스 두개를 하나로 합쳐서

데이터 자체에 클래스의 불균형이 발생하게 되었으니까

평가지표를 ACC에서 F1-score로 바꾸겠습니다!

:

이때 주의해야 점!!

f1-score는 average라는 파라미터가 있는데,

default값 그대로 사용한다면 에러가 발생하게 됩니다!

 

다중 클래스에서 f1_score를 사용하기 위해서는 average값을 변경해야 하는데요!

average에는 다양한 값이 존재하는데 

저는 다중 클래스 불균형에 좀 더 적합한 "macro"로 설정하였습니다!

각 값마다 장점이 있으니 공식링크 참고하면 좋을 거 같아요!☺️☺️

공식 링크 : https://scikit-learn.org/stable/modules/generated/sklearn.metrics.f1_score.html

 

 

평가지표를 재설정하고 다시 코드를 돌린 결과

0.6정도로 바로 위에서 나온 Accuracy결과와 비슷한 수치인데요!

위에서 설명했듯이

accuracy 0.6과 f1 score가 0.6인 것은 해석이 다릅니다! 

f1 score에서 0.6은 60%를 의미하는 것이 아니에요!

 

하지만 그렇다고 해도, 좋은 수치는 아니듯해보입니다...

그냥 데이터 수집을 하여서 데이터의 imbalance를 해결해야겠습니다..

:

우선, 해당 모델로 임의의 문장을 넣어서 Test를 진행해봤는데요

결과는 잘 나오네요?!

 

그래도 평가지표 개선을 위해..

2탄으로 돌아오겠습니다! 🖥️🖥️