Notice
Recent Posts
Recent Comments
Link
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
Archives
Today
Total
관리 메뉴

THE JINYA

KoBERT로 7가지 감정 분류 모델 구현 본문

프로젝트

KoBERT로 7가지 감정 분류 모델 구현

지냐냥 2023. 5. 26. 13:49

0. 들어가며

우리 프로젝트의 목표는 실감나는 동화 서비스를 구현 하는 것..

기존 동화 오디오북의 딱딱한 TTS에서, 문맥과 상황에 따라 적합한 TTS가 나오는 것을 목표로 하고 있다.

생동감을 더하려면 인물의 상황에 맞는... 그니까 기쁜 장면에서는 밝은 목소리가 나오고 슬픈 장면에서는 어두운 목소리가 나와야한다.

이를 구현하기 위해서는, 감성 분석(Sentiment Analysis)를 통해 이 장면이 어떤 감정인지 파악을 해야한다.

 

찾아 본 결과, 클로바에서 감성 분류를 지원해주는데, 좋고 나쁨 (+-)으로 분류가 가능했다.

하지만 동화는 아무래도 다양한 감정이 드러나는 경우가 많으므로 두가지 분류는 적합하지 않아보여서 다시 서치를 했다.

 

 

그러다 발견한 것이 KoBert이다.

구글에서 개발한 언어모델인 BERT는 기존의 언어모델과 달리 양방향으로 문맥을 파악하고, 전체 문장을 입력으로 사용하여 단어의 의미와 문맥을 파악할 수 있다고 한다. 하지만 한국어에서는 성능적인 한계가 존재했으며, 이를 개선시켜 SKTBrain에서 개발한 것이 KoBERT인 것이당!! KoBERT는 위키피디아나 뉴스 등에서 수집한 수백만개의 한국어 문장으로 이루어진 대규모말뭉치(corpus)를 학습했다. 또한 기존의 긍정, 부정 두개로 제한되었던 감성분류에서, 감정을 다중분류 할 수 있어 프로젝트에 사용하기 아주아주 적합해보였다!!

 

서론이 길었으니,., 본격적으로 구현에 들어가보겠당.

 

 

 

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

 

 

1. Colab 환경 설정

KoBERT 기반 다중 감정 분류 모델을 구현하기 위해서는 Colab을 사용하는게 좋다.

로컬에서 CPU로 모델을 돌리면 학습시키는 데 1년이 걸려도 결과가 나올지는 미지수라고 하기 때문이다^^..

그러니 Colab 런타임 설정 GPU로 바꿔주고 시작하자.

 

 

KoBERT에서 요구하는 세팅은 다음과 같다. 

업데이트가 현재 진행형으로 되고 있기 때문에 자주 확인 해 보고 팔로업을 해야할 것 같다.

나 또한 예전 KoBERT 구현 글 보고 했더니 실행 오류가 계속 났기 때문에..;; (꽤나 힘들었당ㅜㅜ)

https://github.com/SKTBrain/KoBERT/blob/master/requirements.txt

 

이제 진짜 본격적으로 세팅에 들어가보겠다.

먼저 아래 코드를 입력해줘서 KoBERT 모델 실행에 필요한 프로그램을 깔아보자!

!pip install mxnet
!pip install gluonnlp==0.8.0
!pip install tqdm pandas
!pip install sentencepiece
!pip install transformers
!pip install torch

 

약 일주일전의 따근따끈한 이슈... 

!pip install gluonnlp==0.8.0으로 해결이 된 듯 하다!

 

제발 도와주세요 개발환경 문제 [BUG] · Issue #106 · SKTBrain/KoBERT

🐛 Bug No module named 'kobert' No module named 'glounnlp' colab The code is not running after the update. 코랩 업데이트 이후 코드 실행이 안되고 있습니다. 이전에는 문제 없이 정상작동을 했던 코드입니다. To Reproduce

github.com

 

 

그리고 KoBERT를 받아와준다.

!pip install 'git+https://github.com/SKTBrain/KoBERT.git#egg=kobert_tokenizer&subdirectory=kobert_hf'

 

 

많은 블로그들에서 (좀 예전 글이긴 하지만..) 아래 코드를  입력해야한다고 하길래 했더니 자꾸 onnxruntime 에러가 났다.

!pip install git+https://git@github.com/SKTBrain/KoBERT.git@master

 

 

그래서 onnxruntime를 깔았는데 1.15.0 버전으로 깔렸다. 요구하는 건 1.8.0버전인데... 

onnxruntime은 pip install onnxruntime == 1.8.0 이런 명령어로 버전 설정이 따로 안된다고 한다.. 결국 여전히 에러가 떴다.

!pip install onnx
!pip install onnxruntime

 

근데 KoBERT가 huggingface로 이전해서 그런진 몰라도 저 부분에서 오류가 났는데도 어찌저찌 잘 되더랑

지금은 안 쳐도 되는 명령어 인 것 같긴한데 혹시몰라서 기록해둔다.

만약 모델 학습 과정에서 오류가 난다면.. 밑 블로그 글을 참고해보는 것도 좋을 것 같다.

https://ezyoon.tistory.com/73

 

감정 분석 AI (kobert / onnxruntime 이슈)

목표 SKT의 kobert 모델을 사용해서 사용자의 감정을 7가지로 분류한다 감정 레이블 : 기쁨, 슬픔, 분노, 역겨움, 공포, 놀람, 중립 kobert? GitHub - SKTBrain/KoBERT: Korean BERT pre-trained cased (KoBERT) Korean BERT pre

ezyoon.tistory.com

 

 

아래는 hugginface로 이전한 KoBERT 모델을 불러오는 코드이다.

from kobert_tokenizer import KoBERTTokenizer
from transformers import BertModel

from transformers import AdamW
from transformers.optimization import get_cosine_schedule_with_warmup

 

이제 사전 학습 된 BERT를 사용할 때 필요한 라이브러리를 불러와보자><

import torch
from torch import nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import gluonnlp as nlp
import numpy as np
from tqdm import tqdm, tqdm_notebook
import pandas as pd

 

 

그리고... 1년동안 학습하고 싶지 않으니까 GPU를 사용해보자~~

device = torch.device("cuda:0")

 

세팅 끝 ~~~^^

 

 

2. 데이터셋 불러오기 및 라벨링

그럼 KoBERT를 학습시킬 데이터셋을 불러와봅시당.

이번에는 AI Hub에서 배포한 한국어 단발성 대화 데이터셋을 사용할 겁니당(업데이트 후 현재는 지원하지 않음 ㅜㅜ)

 

 

먼저 코랩에 로컬에 있는 파일을 업로드 해준 후,

업로드 완료.

emotion_data = pd.read_excel('/content/한국어_단발성_대화_데이터셋.xlsx')

나는 emotion_data로 받아왔당. 뭐 이부분 이름은 원하는대로 하면 됨.

 

 

 

그럼 랜덤으로 10개의 데이터를 출력해서 잘 불러왔는지, 라벨링이 되어있는지 확인해보자.

emotion_data.sample(n=10)

왜이렇게 정치적인 문장들만 뽑힌거지..랜덤입니다

출력된 데이터를 보면 sentence에는 한국어 대화 텍스트 데이터, emotion에는 감정 레이블링이 되어있다.

 

 

데이터 프레임에서 sentence, emotion 있는 데이터만 추출하고, emotion 7가지 감정 클래스를 0-6 숫자에 라벨링 한 후, data_list에 담아주자!

# 데이터 추출 및 라벨링
emotion_data.loc[(emotion_data['Emotion'] == "공포"), 'Emotion'] = 0 # 공포 => 0
emotion_data.loc[(emotion_data['Emotion'] == "놀람"), 'Emotion'] = 1 # 놀람 => 1
emotion_data.loc[(emotion_data['Emotion'] == "분노"), 'Emotion'] = 2 # 분노 => 2
emotion_data.loc[(emotion_data['Emotion'] == "슬픔"), 'Emotion'] = 3 # 슬픔 => 3
emotion_data.loc[(emotion_data['Emotion'] == "중립"), 'Emotion'] = 4 # 중립 => 4
emotion_data.loc[(emotion_data['Emotion'] == "행복"), 'Emotion'] = 5 # 행복 => 5
emotion_data.loc[(emotion_data['Emotion'] == "혐오"), 'Emotion'] = 6 # 혐오 => 6

# data_list 담기
data_list = []
for q, label in zip(emotion_data['Sentence'], emotion_data['Emotion']):
    data = []
    data.append(q)
    data.append(str(label))

    data_list.append(data)

data_list.append(data)

 

몇 개 정도 출력해서 잘 처리되었는지 확인해보자!

print(data_list[0])
print(data_list[2000])
print(data_list[15000])
print(data_list[23000])
print(data_list[27000])
print(data_list[29000])
print(data_list[-5])

여전히 정치적이다....

 

['sentence', 'class'] 형태로 이루어져있고 데이터 라벨링도 잘 되어있는 모습을 볼 수 있다. 

 

 

3. 데이터 학습 및 테스트

모델을 학습할 train data 평가할 test data 나눠야해서, 사이킷런의 train_test_split 라이브러리를 불러와 데이터셋을 4:1 비율로 나누었다.

from sklearn.model_selection import train_test_split
dataset_train, dataset_test = train_test_split(data_list, test_size=0.25, random_state=0)

 

학습시킬 데이터는 28945개, 평가할 데이터는 9649개임을 확인할 수 있다.

print(len(dataset_train))
print(len(dataset_test))

 

 

4. KoBERT 입력 데이터로 만들기

Train data와 Test data를 KoBERT 모델에 들어갈 입력 데이터로 만들어주자.

#BERT 모델에 들어갈 dataset 만들어주는 클래스
class BERTDataset(Dataset):
	def __init__(self, dataset, sent_idx, label_idx, bert_tokenizer,vocab, max_len,
pad, pair):
		transform = nlp.data.BERTSentenceTransform(
			bert_tokenizer, max_seq_length=max_len,vocab=vocab, pad=pad, pair=pair)
		self.sentences = [transform([i[sent_idx]]) for i in dataset]
		self.labels = [np.int32(i[label_idx]) for i in dataset]

	def __getitem__(self, i):
		return (self.sentences[i] + (self.labels[i], ))

	def __len__(self):
		return (len(self.labels))

 

BERT모델 토큰화 및 학습에 사용할 parameter의 경우, batch_size를 64로, epochs를 5로 설정했다.

epochs가 커질수록 정확성이 높아지지만 학습 시간은 더 소모된다.

max_len = 64
batch_size = 64
warmup_ratio = 0.1
num_epochs = 5
max_grad_norm = 1
log_interval = 200
learning_rate = 5e-5

 

 

 

자 이제부터 날 매우 힘들게 한 부분...

토큰화에서 자꾸 오류가 발생했다. 

sentence를 불러오는 부분에서 그런 부분이 없다고 한다던가.. vocab이 어딨냐고 한다던가...

울며 1시간 동안 잡고있었다..

 

 

알고보니 코랩에서 파이썬 버전을 업데이트하면서 생기는 오류라고 한다.

서치 해보니 다행히도 2주 전에 어떤 분께서 손 봐주신 글을 발견할 수 있었다.

 

 

No module named 'kobert' 에러 해결

한 1-2주 전까지만 해도 되던 코드가 에러가 났다.. 코랩에서 파이썬 버전을 업데이트하면서 생기는 오류라...

blog.naver.com

 

 

py파일의 BERTDataset 부분에서, def __init__에서 input에 vocab를 받는 부분 추가, self._vocab = vocab 을 추가하고,def __call__에서 vocab = self._vocab로 바꾼 코드라고 한다. 

아무튼 이 코드를 입력하자.

class BERTSentenceTransform:
    def __init__(self, tokenizer, max_seq_length, vocab, pad=True, pair=True):
        self._tokenizer = tokenizer
        self._max_seq_length = max_seq_length
        self._pad = pad
        self._pair = pair
        self._vocab = vocab

    def __call__(self, line):
        # convert to unicode
        text_a = line[0]
        if self._pair:
            assert len(line) == 2
            text_b = line[1]

        tokens_a = self._tokenizer.tokenize(text_a)
        tokens_b = None

        if self._pair:
            tokens_b = self._tokenizer(text_b)

        if tokens_b:
            self._truncate_seq_pair(tokens_a, tokens_b, self._max_seq_length - 3)
        else:
            if len(tokens_a) > self._max_seq_length - 2:
                tokens_a = tokens_a[0:(self._max_seq_length - 2)]

        vocab = self._vocab
        tokens = []
        tokens.append(vocab.cls_token)
        tokens.extend(tokens_a)
        tokens.append(vocab.sep_token)
        segment_ids = [0] * len(tokens)

        if tokens_b:
            tokens.extend(tokens_b)
            tokens.append(vocab.sep_token)
            segment_ids.extend([1] * (len(tokens) - len(segment_ids)))

        input_ids = self._tokenizer.convert_tokens_to_ids(tokens)

        valid_length = len(input_ids)

        if self._pad:
            # Zero-pad up to the sequence length.
            padding_length = self._max_seq_length - valid_length
            # use padding tokens for the rest
            input_ids.extend([vocab[vocab.padding_token]] * padding_length)
            segment_ids.extend([0] * padding_length)

        return np.array(input_ids, dtype='int32'), np.array(valid_length, dtype='int32'), np.array(segment_ids, dtype='int32')
class BERTDataset(Dataset):
    def __init__(self, dataset, sent_idx, label_idx, bert_tokenizer, vocab, max_len, pad, pair):
        transform = BERTSentenceTransform(bert_tokenizer, max_seq_length=max_len, vocab=vocab, pad=pad, pair=pair)
        # transform = nlp.data.BERTSentenceTransform(tokenizer, max_seq_length=max_len, pad=pad, pair=pair)
        self.sentences = [transform([i[sent_idx]]) for i in dataset]
        self.labels = [np.int32(i[label_idx]) for i in dataset]

    def __getitem__(self, i):
        return (self.sentences[i] + (self.labels[i], ))

    def __len__(self):
        return len(self.labels)

 

이제 토큰화와 패딩을 해주자.

tokenizer = KoBERTTokenizer.from_pretrained('skt/kobert-base-v1')
bertmodel = BertModel.from_pretrained('skt/kobert-base-v1', return_dict=False)
vocab = nlp.vocab.BERTVocab.from_sentencepiece(tokenizer.vocab_file, padding_token='[PAD]')
data_train = BERTDataset(dataset_train, 0, 1, tokenizer, vocab, max_len, True, False)
data_test = BERTDataset(dataset_test, 0, 1, tokenizer, vocab, max_len, True, False)

 

그럼 데이터는 아래와 같은 형식이 된다><

 

good

 

 

torch 형식의 데이터셋을 만들어주면 데이터 전처리 과정은 끝~!

train_dataloader = torch.utils.data.DataLoader(data_train, batch_size=batch_size, num_workers=5)
test_dataloader = torch.utils.data.DataLoader(data_test, batch_size=batch_size, num_workers=5)

 

 

 

5. KoBERT 모델 구현

학습시킬 KoBERT 모델을 구현 할 차례이다.

아래 코드에서 다중분류할 클래스 수 만큼 num_classes 변수를 수정해야하는데, 이번에는 7가지 클래스를 분류하므로 7로 입력해주면 된다.

아래 코드를 쭉 실행시켜 주자.

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

        self.classifier = nn.Linear(hidden_size, num_classes)
        if dr_rate:
            self.dropout = nn.Dropout(p=dr_rate)

    def gen_attention_mask(self, token_ids, valid_length):
        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)

        _, pooler = self.bert(
            input_ids=token_ids,
            token_type_ids=segment_ids.long(),
            attention_mask=attention_mask.float().to(token_ids.device),
            return_dict=False
        )

        if self.dr_rate:
            out = self.dropout(pooler)
        return self.classifier(out)
# BERT 모델 불러오기
model = BERTClassifier(bertmodel, dr_rate=0.5).to(device)

# optimizer와 schedule 설정
no_decay = ['bias', 'LayerNorm.weight']
optimizer_grouped_parameters = [
    {'params': [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], 'weight_decay': 0.01},
    {'params': [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], 'weight_decay': 0.0}
]

optimizer = AdamW(optimizer_grouped_parameters, lr=learning_rate)
loss_fn = nn.CrossEntropyLoss()  # 다중분류를 위한 대표적인 loss func

t_total = len(train_dataloader) * num_epochs
warmup_step = int(t_total * warmup_ratio)

scheduler = get_cosine_schedule_with_warmup(optimizer, num_warmup_steps=warmup_step, num_training_steps=t_total)

# 정확도 측정을 위한 함수 정의
def calc_accuracy(X, Y):
    max_vals, max_indices = torch.max(X, 1)
    train_acc = (max_indices == Y).sum().data.cpu().numpy() / max_indices.size()[0]
    return train_acc

 

6. KoBERT 모델 학습 시키기

학습 데이터셋과 학습 모델 준비가 끝났다. 이제 거의 다 왔다!

아래 코드를 실행하면 KoBERT 모델을 학습시킬 수 있다.

train_history = []
test_history = []
loss_history = []

for e in range(num_epochs):
    train_acc = 0.0
    test_acc = 0.0
    model.train()

    for batch_id, (token_ids, valid_length, segment_ids, label) in enumerate(tqdm_notebook(train_dataloader)):
        optimizer.zero_grad()
        token_ids = token_ids.long().to(device)
        segment_ids = segment_ids.long().to(device)
        valid_length = valid_length
        label = label.long().to(device)
        out = model(token_ids, valid_length, segment_ids)

        # print(label.shape,out.shape)
        loss = loss_fn(out, label)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
        optimizer.step()
        scheduler.step()  # Update learning rate schedule
        train_acc += calc_accuracy(out, label)

        if batch_id % log_interval == 0:
            print("epoch {} batch id {} loss {} train acc {}".format(e + 1, batch_id + 1, loss.data.cpu().numpy(), train_acc / (batch_id + 1)))
        train_history.append(train_acc / (batch_id + 1))
        loss_history.append(loss.data.cpu().numpy())

    print("epoch {} train acc {}".format(e + 1, train_acc / (batch_id + 1)))
    # train_history.append(train_acc / (batch_id+1))

    model.eval()
    for batch_id, (token_ids, valid_length, segment_ids, label) in enumerate(tqdm_notebook(test_dataloader)):
        token_ids = token_ids.long().to(device)
        segment_ids = segment_ids.long().to(device)
        valid_length = valid_length
        label = label.long().to(device)
        out = model(token_ids, valid_length, segment_ids)
        test_acc += calc_accuracy(out, label)
        print("epoch {} test acc {}".format(e + 1, test_acc / (batch_id + 1)))

    test_history.append(test_acc / (batch_id + 1))

 

 

앞서 데이터 전처리 부분에서 파라미터 지정할 때 epoch를 얼마로 설정했냐에 따라 학습하는데 걸리는 시간이 크게 달라진다고 언급했었다.

5로 지정했을때엔 30분정도 걸렸고, 10으로 지정했을때엔 2시간이 걸렸다는 글을 봤다.

어라라 그런데.. train data의 정확도는 0.8에 수렴함에 비해 test data의 정확성이 많이 떨어진다.. epoch를 10으로 설정하면 train data 정확성은 1에 가깝게 얻을 수 있을 것 같긴 한데, test data가 고민이다.

업데이트 후 no moduled kobert 에러 발생하고 나서 정확성이 떨어졌다는 글을 본 기억이 있는데 그것 때문에 그런 것 같기도 하고,

다른 분은 3만여개의 데이터셋을 자신의 프로젝트에 적합하게 직접 재분류하는 과정을 거쳤더니 test data의 정확도가 매우 높게 나왔다고 하니 참고하면 좋을 것 같다.

우리 또한 프로젝트의 성격에 맞게 데이터셋을 조정하는 과정이 필요할 것 같다.

 

 

7. 새로운 문장 테스트

이제 동화 문장들로 다중 감정 분류 모델이 잘 작동하는지 테스트해보자.

def predict(predict_sentence):

    data = [predict_sentence, '0']
    dataset_another = [data]

    another_test = BERTDataset(dataset_another, 0, 1, tok, vocab, max_len, True, False)
    test_dataloader = torch.utils.data.DataLoader(another_test, batch_size=batch_size, num_workers=5)
    
    model.eval()

    for batch_id, (token_ids, valid_length, segment_ids, label) in enumerate(test_dataloader):
        token_ids = token_ids.long().to(device)
        segment_ids = segment_ids.long().to(device)

        valid_length= valid_length
        label = label.long().to(device)

        out = model(token_ids, valid_length, segment_ids)


        test_eval=[]
        for i in out:
            logits=i
            logits = logits.detach().cpu().numpy()

            if np.argmax(logits) == 0:
                test_eval.append("공포가")
            elif np.argmax(logits) == 1:
                test_eval.append("놀람이")
            elif np.argmax(logits) == 2:
                test_eval.append("분노가")
            elif np.argmax(logits) == 3:
                test_eval.append("슬픔이")
            elif np.argmax(logits) == 4:
                test_eval.append("중립이")
            elif np.argmax(logits) == 5:
                test_eval.append("행복이")
            elif np.argmax(logits) == 6:
                test_eval.append("혐오가")

        print(">> 입력하신 내용에서 " + test_eval[0] + " 느껴집니다.")
        
#질문 무한반복하기! 0 입력시 종료
end = 1
while end == 1 :
    sentence = input("하고싶은 말을 입력해주세요 : ")
    if sentence == "0" :
        break
    predict(sentence)
    print("\n")

 

문장을 입력하니 다음과 같은 결과가 나왔다.

매신(매우신기하다는뜻)

 

 

 

 

8. 마치며

일단 test data의 정확도가 낮아서 걱정했는데, 동화 문장으로 테스트 해 보았을때 분류가 잘 되어서 다행이었다. 어찌됐건 긍정/부정 분류는 잘 이루어지는 것으로 보인다.

그러나 정확성을 좀 더 올리고, 우리 프로젝트의 특성에 적합하게 하기 위해서는 데이터셋을 직접 재분류하는 과정을 거쳐야할 것 같다. 

학습시킨 데이터셋의 레이블링을 살펴본 결과, 완벽하게 되어있지 않기도 한데다 공포/놀람/분노/슬픔/중립/행복/혐오로 라벨링된 감정 분류가 사실 동화에는 적합하지 않기 때문이다. 

따라서 우리 팀의 서비스인 동화에 맞는 감정을 분류하기 위해서는 행복/평온/슬픔/분노/공포/놀람 6가지로 단발성 대화 데이터셋의 감정을 재분류하면  높은 정확성을 갖출 있을 것이라 기대된다.


References

https://hoit1302.tistory.com/159

https://www.dinolabs.ai/271

 

https://github.com/SKTBrain/KoBERT

https://blog.naver.com/newyearchive/223097878715