본문 바로가기

DINOPROJECT

[에어] 7가지 감정의 한국어 대화, 'KOBERT'로 다중 분류 모델 만들기 (파이썬/Colab)

에어 프로젝트
#2 7가지 감정의 한국어 대화, 'KOBERT'로 다중 분류 모델 만들기

 

 

사람에게는 다양한 감정이 있고, 얼굴 표정과 말로 그 사람의 감정을 추측할 수 있다. 또한 상대방의 얼굴 표정을 볼 수 없는 메세지 또는 SNS 채팅상에서도 문맥과 문장에 들어간 단어를 통해 어느정도 상대방의 감정을 예측할 수 있다. 그렇다면 컴퓨터와 같은 기계는 텍스트만 보고도 감정을 예측할 수 있을까?

 

 

 요즘 심리 상담 챗봇 등 다양한 인공지능 대화 챗봇을 많이 접할 수 있는데, 사용해보면 인공지능이 텍스트 문장만 보고도 사람의 감정을 추측해 그에 따른 적절한 답변을 내놓는 것을 볼 수 있다. 이 인공지능은 어떻게 텍스트만 보고도 그 사람이 어떠한 감정인지 예측할 수 있는 것일까?

 

지난 에어 프로젝트에서는 'BERT' 모델을 이용하여 한국어로 이루어진 대화 문장이 일상 대화인지 연애상담 관련 대화인지를 구분하는 인공지능 모델을 만들어보았다. 이번 에어 프로젝트에서는 한국어 버전의 BERT모델인 'KoBERT' 모델을 이용하여, 한국어로 이루어진 짧은 대화 텍스트가 어떠한 감정(공포/놀람/분노/슬픔/중립/행복/혐오)인지 예측하는 다중분류 모델을 만들어 보고자 한다.

 

 

 

|| KoBERT란?

 KoBERT는 SKTBrain에서 공개한 기계번역 모델로, BERT모델을 기반으로 하는 모델이다. KoBERT 개발자의 개발 리뷰기를 보면 BERT를 기반으로 하는 대화엔진 개발을 위해 KoBERT 학습을 시작하게 되었다고 한다. 이처럼 KoBERT는 BERT 모델에서 한국어 데이터를 추가로 학습시킨 모델로, 한국어 위키에서 5백만개의 문장과 54백만개의 단어를 학습시킨 모델이다. 따라서 한국어 버전의 BERT라고도 할 수 있는데, 그렇다면 그 베이스가 되는 BERT는 어떤 모델일까?

 

BERT(Bidirectional Encoder Representations from Transformers)는 2018년 구글에서 발표된 기계번역 모델로, 그 성능을 인정받은 모델이다. 이러한 BERT에 대해선 지난 프로젝트에서 살펴보았는데, 가장 큰 특징만 언급하자면 해당 모델이 방대한 양의 데이터(약 33억개 단어)로 먼저 학습(pretrain)되어 있다는 것과, 자신의 사용 목적에 따라 파인튜닝(finetuning)이 가능하다는 점이다. BERT에 대해 더 궁금하거나 BERT를 이용한 실습 코드가 궁금하다면 아래 프로젝트 글을 참조하길 바란다.

 

 

 

|| KoBERT를 이용하여 다중분류 모델 만들기

이제, 한국어 대화 데이터셋을 여러 감정으로 분류하는 모델을 만드려고 하는데,  KoBERT 깃허브에 있는 이중분류 예시 코드(Naver Sentiment Analysis Fine-Tuning with pytorch)를 바탕으로 코드를 작성하도록 하겠다.(데이터를 변경하고, 다중분류를 위한 class 수만 변경하면 되기 때문에 코드가 크게 변하진 않는다.)

 

또한 학습 데이터는 AIHUB에서 무료로 오픈한 '한국어 감정 정보가 포함된 단발성 대화 데이터셋'으로 사용하려고 한다. 해당 데이터는 SNS 글 및 온라인 댓글에 대한 웹 크롤링을 실시한 약 38,600개의 문장으로, 각 문장에 대해 7개 감정(기쁨, 슬픔, 놀람, 분노, 공포, 혐오, 중립)에 대해 레이블링이 수행되어 있다. 아래 이미지는 데이터의 일부분을 캡쳐한 것이다.

 

 

그 외 환경

 

  • Python >= 3.6
  • PyTorch >= 1.70
  • Transformers = 3.0.2
  • Colab
  • batch size = 64
  • epochs = 10

 

1. Colab 환경 설정

먼저 아래 코드를 실행하여 필요한 라이브러리 및 KoBERT모델을 불러오고, Colab 환경을 설정한다.

 

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

 

#깃허브에서 KoBERT 파일 로드
!pip install git+https://git@github.com/SKTBrain/KoBERT.git@master

 

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

 

#kobert
from kobert.utils import get_tokenizer
from kobert.pytorch_kobert import get_pytorch_kobert_model

#transformers
from transformers import AdamW
from transformers.optimization import get_cosine_schedule_with_warmup

 

#GPU 사용
device = torch.device("cuda:0")

 

#BERT 모델, Vocabulary 불러오기
bertmodel, vocab = get_pytorch_kobert_model()

 

위 코드의 경우 약간의 시간이 소요된다.

 

 

 

2. 데이터셋  전처리

환경설정이 끝났다면 학습할 한국어 대화 데이터셋을 구글 드라이브에 다운받은 뒤, 구글 드라이브를 연동하여 데이터를 불러와 전처리를 하고자 한다. 

 

#구글드라이브 연동
from google.colab import drive
drive.mount('/content/drive')

 

위 코드를 실행한 뒤, 링크가 나오면 해당 링크에 들어가서 알파벳과 숫자로 되어 있는 코드를 복사한 뒤 입력창에 붙여넣기 해주면 연동이 된다.

 

그리고나서, 아래 코드를 입력해 해당 데이터가 있는 폴더와 파일명을 입력하여 'chatbot_data'라는 이름의 데이터프레임으로 데이터를 불러온다.

 

import pandas as pd
chatbot_data = pd.read_excel('/content/drive/MyDrive/챗봇/한국어_단발성_대화_데이터셋.xlsx')

 

len(chatbot_data)
>> 38594

 

한편, 위와 같이 데이터의 개수를 확인해보면 38594개의 데이터가 있음을 확인할 수 있다. 

 

그렇다면 그 중 데이터 10개를 랜덤으로 출력해보았다.

 

chatbot_data.sample(n=10)

 

랜덤으로 출력된 데이터를 보면 첫 번째 컬럼 'Sentence'에는 한국어 대화 텍스트 데이터가 있고, 두 번째 컬럼 'Emotion'에 어떠한 감정을 나타내는지 레이블링 되어 있음을 확인할 수 있다.

 

이제 이 데이터프레임에서 Sentence, Emotion 컬럼에 있는 데이터만 추출하고, Emotion에 있는 7가지의 감정 class를 숫자로 변경하는 전처리를 수행하도록 하겠다.

 

chatbot_data.loc[(chatbot_data['Emotion'] == "공포"), 'Emotion'] = 0  #공포 => 0
chatbot_data.loc[(chatbot_data['Emotion'] == "놀람"), 'Emotion'] = 1  #놀람 => 1
chatbot_data.loc[(chatbot_data['Emotion'] == "분노"), 'Emotion'] = 2  #분노 => 2
chatbot_data.loc[(chatbot_data['Emotion'] == "슬픔"), 'Emotion'] = 3  #슬픔 => 3
chatbot_data.loc[(chatbot_data['Emotion'] == "중립"), 'Emotion'] = 4  #중립 => 4
chatbot_data.loc[(chatbot_data['Emotion'] == "행복"), 'Emotion'] = 5  #행복 => 5
chatbot_data.loc[(chatbot_data['Emotion'] == "혐오"), 'Emotion'] = 6  #혐오 => 6

 

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

    data_list.append(data)

 

코드를 실행하면, 각 sentence와 숫자로 라벨링된 값이 data_list에 저장되는데, 몇 개 출력해보겠다.

 

print(data_list[0])
print(data_list[6000])
print(data_list[12000])
print(data_list[18000])
print(data_list[24000])
print(data_list[30000])
print(data_list[-1])
#output
['언니 동생으로 부르는게 맞는 일인가요..??', '0']
['기술적으로도 아직도 해체해서 다시 완벽히 돌려놓는게 어려운데 해체를한다고?', '1']
['당연히 그렇게 해야지 우리나라도 판매를 중단하라', '2']
['그거들은 뒤부터 미치겠어요...', '3']
['최악의 상황중 그나마 나은 방법이네. 기분은 잡치겠지만', '4']
['  요리하는것이 숙제하는것처럼 힘든저에게 용기나게 해주시고 할수 있을것같은 희망을 주셔서감사합니다!!', '5']
['와이프도 그렇고 댓글 다 볼텐데 이휘재 좀 하차 하라고 전해주세요', '6']

 

출력된 데이터들로 보면 ['sentence', 'class'] 의 형태로 이루어진 것을 확인할 수 있고, 각 숫자 class로 잘 라벨링 되었음을 확인할 수 있다.

 

 

 

3. Train data & Test data

데이터셋이 준비가 되었다면 이제 모델을 학습할 Train data와 모델을 평가할 Test data로 나누어야 한다. 따라서 사이킷런에서 제공하는 train_test_split 라이브러리를 설치하여 데이터셋을 4:1 비율로 나누도록 하겠다.

 

#train & test 데이터로 나누기
from sklearn.model_selection import train_test_split
                                                         
dataset_train, dataset_test = train_test_split(data_list, test_size=0.25, random_state=0)

 

print(len(dataset_train))
print(len(dataset_test))
28945
9649

 

위 코드를 통해 train_test_split에 의해 나누어진 train dataset과 test dataset의 개수가 각각 28945, 9649개인 것을 확인할 수 있다.

 

 

 

 

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

전처리 마지막 단계로, train data와 test data가 KoBERT 모델의 입력데이터가 되도록 만들어주어야 한다.

 

# BERT 모델에 들어가기 위한 dataset을 만들어주는 클래스
class BERTDataset(Dataset):
    def __init__(self, dataset, sent_idx, label_idx, bert_tokenizer, max_len,
                 pad, pair):
        transform = nlp.data.BERTSentenceTransform(
            bert_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))

 

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

 

위 코드는 BERT 모델의 토큰화 및 학습에 사용할 파라미터로, 숫자를 조정해도 된다.

이번 프로젝트에서는 batch_size를 64, epochs수를 10으로 설정하였다.

 

#토큰화
tokenizer = get_tokenizer()
tok = nlp.data.BERTSPTokenizer(tokenizer, vocab, lower=False)

 

data_train = BERTDataset(dataset_train, 0, 1, tok, max_len, True, False)
data_test = BERTDataset(dataset_test, 0, 1, tok, max_len, True, False)

 

한편 토큰화 & 패딩을 거치면 데이터는 다음과 같은 형식을 갖게 된다.

 

data_train[0]
# output
(array([   2, 1189,  517, 6188, 7245, 7063,  517,  463, 3486, 7836, 5966,
        1698,  517, 6188, 7245, 7063,  517,  463, 1281, 7870, 1801, 6885,
        7088, 5966, 1698, 5837, 5837,  517, 6188, 7245, 6398, 6037, 7063,
         517,  463,  517,  463,  517,  364,  517,  364,    3,    1,    1,
           1,    1,    1,    1,    1,    1,    1,    1,    1,    1,    1,
           1,    1,    1,    1,    1,    1,    1,    1,    1], dtype=int32),
 array(42, dtype=int32),
 array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       dtype=int32), 5)

 

그리고 마지막으로 아래 코드를 실행하여 torch 형식의 dataset을 만들어준다.

 

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가지의 class를 분류하기 때문에 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))
        if self.dr_rate:
            out = self.dropout(pooler)
        return self.classifier(out)

 

그리고나서 아래 코드를 쭉 실행해주면 된다.

 

model = BERTClassifier(bertmodel,  dr_rate=0.5).to(device)

 

# Prepare optimizer and schedule (linear warmup and decay)
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()

 

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

 

train_dataloader

 

 

 

6. KoBERT 모델 학습시키기

 학습 데이터셋과 학습 모델 준비가 다 끝났다면 이제 아래 코드 실행을 통해 KoBERT 모델을 학습시켜준다.

 

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)
        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)))
    print("epoch {} train acc {}".format(e+1, 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)))

 

앞서 batch size를 64, epochs 를 10으로 지정한 뒤 학습한 결과, 약 2시간 이내의 시간이 소요되었다.

그 결과, 마지막 epoch에서의 정확도 값은 다음가 같이 출력되었다.

 

 

출력된 정확도를 보면, 학습 데이터에 대해선 0.96으로 정확도가 높은 반면, 테스트 데이터에 대해선 정확도가 많이 떨어졌다.

 

 

 

 

7. 새로운 문장 테스트

마지막 단계로, 앞에서 훈련시킨 KoBERT 모델에 새로운 문장을 넣어, 감정 분류를 잘 하는지 테스트 해보려고 한다.

새로운 문장 역시 토크화 과정을 시켜준다.

 

#토큰화
tokenizer = get_tokenizer()
tok = nlp.data.BERTSPTokenizer(tokenizer, vocab, lower=False)

 

그리고나서 예측을 해주기 위한 predict 함수를 만든다.

 

def predict(predict_sentence):

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

    another_test = BERTDataset(dataset_another, 0, 1, tok, max_len, True, False)
    test_dataloader = torch.utils.data.DataLoader(another_test, batch_size=batch_size, num_workers=5)
    
    model1.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 = model1(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")

 

이제 위 코드를 실행하고, 대화를 입력하면 된다. 

한번 여러 대화를 입력하였더니 아래와 같은 결과가 출력되었다.

 

 

 

 

|| KoBERT 학습 결과 및 보완점

 앞서 KoBERT 모델을 학습시킬 때, test dataset에 대해서 정확도가 매우 낮아서 과연 예측이 잘 될까 생각했는데, 의외로 감정이 잘 예측되었다. 추가로 더 문장을 테스트 해본 결과, 100% 정확하진 않아도 비슷한 결의 감정으로 예측이 되었다. 예를 들자면 공포의 감정이 느껴지는 텍스트를 행복이 아닌 혐오, 놀람과 같은 감정으로 예측되었다. 이를 통해 어느정도 긍정/부정의 분류는 잘 이루어지는 것 같다.

 

한편, 감정 대화 데이터셋을 보면 사실 레이블링이 완벽하게 되어있지 않음을 볼 수 있는데, 만약 확실한 감정을 나타내는 텍스트와 이 텍스트에 대한 레이블링이 완벽하게 구축된다면, 학습 모델의 정확도를 훨씬 높일 수 있지 않을까 싶다.

 

이렇게 이번 프로젝트에서는 BERT의 한국어 버전이라고 할 수 있는 KoBERT를 가지고 실습을 해보았는데, 확실히 한국어 데이터셋으로 챗봇 등 기계번역 모델을 학습시키고자 한다면, 쉽게 사용할 수 있고, 단어사전이 구축되어 있는 KoBERT 모델이 적합한 것 같다.

 

 

 

|| Reference