본문 바로가기

DINOPROJECT

[에어] 일상적인 한국어 대화 유형(일상/연애) 'BERT'로 분류 모델 만들기 (파이썬/colab)

에어 프로젝트
#1 일상적인 한국어 대화 유형(일상/연애) 'BERT'로 분류 모델 만들기

 

아래와 같은 메세지가 왔다고 가정하자.

 

 

하나는 일상 대화가 담긴 문자이고, 하나는 연애와 관련된 문자이다. 어느 문자가 연애 관련 문자일까?

우리는 오른쪽 메세지가 '썸남'과 '고백'이라는 단어를 통해 연애와 관련된 문자인지 알 수 있다. 하지만 만약 인공지능에게 판별을 하라고 하면 바로 오른쪽 메세지라고 대답할 수 있을까? 맞췄다면 어떻게 맞춘 것일까?

 

핸드폰에 기본적으로 내장되어 있는 챗봇을 한 번쯤은 사용해봤을 것이다. 이런 챗봇은 하나의 인공지능 프로그램으로, 자연어 처리 및 기계번역을 하는 알고리즘이 들어있다. 현재 매우 많은 기계번역 모델이 많이 연구되고 있는데, 그중 BERT라는 모델에 대해 알아보고, BERT를 이용하여 한국어로 이루어진 대화 문장이 일상 대화인지 연애 관련 대화인지를 구분하는 인공지능 모델을 만들어 보고자 한다.

 

 

 

|| BERT의 구조 및 특징

 

 BERT는 Bidirectional Encoder Representations from Transformers의 약자로 말 그대로 Transformers 라는 기계번역 모델의 Encoder 구조를 갖는 기계번역 모델이다. 즉, Transformers 라는 모델의 일부분을 사용하고 성능을 업그레이드한 모델이 BERT인 것이다. 2018년 10월 구글에서 발표한 BERT는 NLP 분야에서 매우 훌륭한 성능을 보여주었고, 현재 굉장히 촉망받는 기계번역 모델이라고 한다. 

 

 BERT 논문에서 첫 페이지 Abstract을 보면, 개발자들이 강조하는 BERT의 특징과 구조를 살펴볼 수 있다. 

굵은 글씨체로 표시한 부분이 BERT의 핵심적인 부분이다.

Abstract
We introduce a new language representation model called BERT, which stands for Bidirectional Encoder Representations from Transformers. Unlike recent language representation models (Peters et al., 2018a; Radford et al., 2018), BERT is designed to pretrain deep bidirectional representations from unlabeled text by jointly conditioning on both left and right context in all layers. As a result, the pre-trained BERT model can be finetuned with just one additional output layer to create state-of-the-art models for a wide range of tasks, such as question answering and language inference, without substantial taskspecific architecture modifications. BERT is conceptually simple and empirically powerful. It obtains new state-of-the-art results on eleven natural language processing tasks, including pushing the GLUE score to 80.5% (7.7% point absolute improvement), MultiNLI accuracy to 86.7% (4.6% absolute improvement), SQuAD v1.1 question answering Test F1 to 93.2 (1.5 point absolute improvement) and SQuAD v2.0 Test F1 to 83.1 (5.1 point absolute improvement).

 

 위 Abstract에서도 알 수 있듯이 BERT는 Transformer 라는 모델의 인코더 구조를 여러 층으로 쌓은 형태의 구조를 가지고 있다. 아래 이미지는 Transformer 모델의 구조로, 왼쪽에는 인코더 역할을 하는 구조와 오른쪽에는 디코더 역할을 하는 구조를 갖고 있다. 따라서 BERT는 Transformer의 인코더 구조를 이용하기 때문에 왼쪽 부분의 구조만을 이용한다. 

"Attention is All You Need" https://arxiv.org/pdf/1706.03762.pdf

 

 간략하게 Transformer의 인코더 부분을 설명하자면, Transformer는 인코더와 디코더로 이루어져 있고, n개의 인코딩 층을 갖고 있다. 그리고 하나의 인코더 층에는 서브층이 크게 2개로 나누어져 있는데, 각각 셀프 어텐션, 피드 포워드 신경망 구조로 이루어져 있다. 여기서 중요한 게 바로 셀프 어텐션인데, 셀프 어텐션이란 문장 내의 단어들의 유사도를 구하는 것을 말한다. 따라서 한 문장이 이러한 구조를 가지는 BERT 인코더를 거치면 입력된 문장은 문맥의 정보를 모두 가진 문장으로 출력된다. 즉, 겉으로는 입력된 문장이 그대로 출력된 것으로 보이지만 실제론 문맥의 정보를 갖고 있는 문장으로 출력된다.

 

 한편, Abstract에 강조되어 있듯이, 기계번역에 성능이 좋은 BERT의 가장 큰 특징은 방대한 양의 데이터(약 33억개 단어/레이블X)로 pretrain 되어 있다는 것이다. 이는 많은 양의 데이터로 먼저 학습이 이루어졌다는 것을 의미하는데, 많은 양의 데이터가 먼저 학습되어 있기 때문에 BERT를 사용하는 누구든지 33억개의 단어를 학습시킬 시간을 단축하는 것이다. 따라서 자신의 목적 또는 용도에 따라 일부분의 데이터만 추가로 학습시켜주면 되는데, 이러한 것을 바로 파인튜닝(finetuning)이라고 한다. BERT의 큰 장점인 파인튜닝은 파라미터를 재조정하고 output layer를 추가함으로써 원하는 목적에 맞게 사용할 수 있다.

 

 

 

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

 이처럼 BERT 모델이 pretrained 되어 있고, 파인튜닝이 가능하다는 점을 이용하여, 한국어로 이루어진 대화 문장이 일상 대화인지, 연애 관련 대화인지 이중분류 하는 모델을 만들어 보도록 하겠다.

 

 

코드는 bert_naver_movie_colab_ipynb를 바탕으로 작성되었고(데이터 전처리 부분을 제외하고는 동일), 학습 데이터는 Chatbot_data를 이용하였다. 해당 데이터의 경우 한글 질의에 대한 한글 답변 데이터와 대화 유형이 무엇인지 약 12,000개의 데이터셋으로 이루어져 있다. 아래 이미지는 해당 데이터의 일부분을 나타낸 것이다.

Chatbot Data _ label[0 : 일상 / 1 : 연애(긍정) / 2 : 연애(부정)]

코드는 Colab에서 작성하였고 GPU를 사용하였다. 이제 BERT의 특징 및 핵심적인 코드를 살펴보며 이중분류 모델을 만들어 보도록 하겠다.

 

1. Colab 환경 설정

먼저, 런타임 유형을 GPU로 변경한 뒤, 필요한 라이브러리를 설치 및 import 한다.

 

!pip install transformers

 

import tensorflow as tf
import torch

from transformers import BertTokenizer
from transformers import BertForSequenceClassification, AdamW, BertConfig
from transformers import get_linear_schedule_with_warmup
from torch.utils.data import TensorDataset, DataLoader, RandomSampler, SequentialSampler
from keras.preprocessing.sequence import pad_sequences
from sklearn.model_selection import train_test_split

import pandas as pd
import numpy as np
import random
import time
import datetime

 

# GPU 확인하기
n_devices = torch.cuda.device_count()
print(n_devices)

for i in range(n_devices):
    print(torch.cuda.get_device_name(i))

 

GPU가 맞게 설정되었다면 'Tesla T4' 라고 출력될 것이다.

 

 

 

2. 데이터셋 불러오기

(1) 한글 대화 데이터 불러오기

 

 pretrain 되어 있는 BERT에 추가로 학습하고자 할 데이터를 불러와 전처리를 하고자 한다.

학습 데이터로 사용되는 Chatbot_data는 11,876개의 한글 대화 문답으로 되어 있는 인공데이터로, 일상 대화, 이별과 관련된 대화, 긍정적인 사랑에 대한 대화가 각각 0, 1, 2 로 라벨링 되어 있다.

깃허브에서 ChatbotData.csv 를 다운로드 한 뒤, 구글 드라이브를 연동해 데이터를 데이터프레임의 형태로 불러오도록 하겠다.

 

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

 

import csv
import pandas as pd
chatbot_data = pd.read_csv('/content/drive/MyDrive/챗봇/ChatbotData.csv',encoding="utf-8")

 

read_csv(  )에는 각자의 경로+파일명을 입력하면 된다.

 

chatbot_data.shape

 

 

(11823,3)

 

데이터프레임의 형식으로 불러온 데이터는 11823개의 행과 3개의 컬럼으로 이루어진 것을 확인할 수 있다.

 

 

그렇다면 데이터가 어떻게 이루어져 있는지 확인해 보자.

 

chatbot_data.sample(n=5)

 

데이터를 랜덤으로 5개의 행을 출력해본 결과, 위와 같이 'Q'라는 컬럼에 있는 한글 질의에 대해 'A' 컬럼에 답변 데이터가 있고, 'label' 컬럼에 해당 질의에 대해 일상 대화인지, 연애 관련 대화 중 이별(부정)에 관한 대화인지, 사랑(긍정)에 관한 대화인지 라벨링 되어 있음을 확인할 수 있다.

 

현재 3개의 클래스로 라벨링 되어 있는데, 이번 프로젝트에서는 일상대화인지 연애 관련 대화인지 이중분류를 할 것이기 때문에 '1'과 '2'로 라벨링 되어 있는 데이터를 모두 '1'로 바꿔주어야 한다.

 

chatbot_data.loc[(chatbot_data['label'] == 2), 'label'] = 1  #라벨 1과 2는 모두 1로 통일

 

랜덤으로 행을 출력해보면 '2'라는 라벨을 갖고 있는 행이 없음을 알 수 있다. (이 과정은 생략)

 

 

 

(2) Train set / Test set으로 나누기

 

데이터를 Train set과 Test set으로 나누기 전, 데이터를 확인해보면 알겠지만 0~5290 까지의 행이 모두 클래스가 '0'인 행이고, 5291행부터 마지막 행까지 모두 클래스가 '1'이다. 특정 비율로 데이터셋을 나눌 때, 클래스가 '0'인 데이터와 '1'인 데이터가 골고루 들어가기 위해선 데이터를 랜덤으로 섞어주고 나눠야 한다. 따라서 다음과 같이 코드를 입력해준다.

 

chatbot_data_shuffled = chatbot_data.sample(frac=1).reset_index(drop=True)

 

데이터를 섞었다면 train data와 test data의 비율을 설정해주어야 하는데, 11823개의 데이터에서 9000개는 train data로, 나머지 2823개의 데이터는 test data로 간단하게 나눠주도록 하겠다. 추후에 train data는 또 한번 train data와 validation data로 나눌 것이다.

 

#train data & test data 로드 
train = chatbot_data_shuffled[:9000]
test = chatbot_data_shuffled[9000:]

print(train.shape)
print(test.shape)

 

(9000, 3)
(2823, 3)

 

 

잘 나누어졌는지 head() 함수를 통해 살펴보았다.

 

display(train.head())
display(test.head())

 

데이터를 보면 골고루 잘 나눠졌음을 확인할 수 있다. 

 

 

 

3. Train set 전처리

여기서부터 지금까지 전처리한 dataset이 BERT 모델의 입력데이터가 되기 위한 특수한 전처리가 필요하다.

 

(1) [CLS] 과 [SEP]

BERT 분류 모델의 경우 각 문장의 앞마다 [CLS]를 붙여 인식시키고, 문장의 종료는 [SEP]를 붙여 인식시킨다. [CLS]을 인식함으로써 문장의 처음이라 알 수 있게 하고, [SEP]을 인식함으로써 문장의 끝을 알 수 있게 하기 위함이다.

BERT input representation.

실제 BERT 논문을 보면 input data의 형식에서 문장 앞에는 [CLS], 문장 끝에는 [SEP]가 붙여져 있음을 확인할 수 있다. 따라서 다음과 같이 각 문장마다 [CLS]와 [SEP]를 붙이는 작업을 수행해준다.

 

# CLS, SEP 붙이기 (문장의 시작, 끝)
sentences = ["[CLS] " + str(s) + " [SEP]" for s in train.Q]

 

 

5개의 데이터만 출력해서 잘 붙여졌는지 확인해보자.

 

sentences[:5]

 

['[CLS] 파도가 엄청 세 [SEP]',
 '[CLS] 코딩 좀 배울까 [SEP]',
 '[CLS] 오늘부로 짝녀를 보내기로 했어요. [SEP]',
 '[CLS] 진짜 이해가 안가 근데 잊을려고 [SEP]',
 '[CLS] 청소는 넘나리 귀찮 [SEP]']

 

한편, '0'과 '1'의 라벨이 들어있는 컬럼을 'labels'이라는 array에 따로 저장한다.

 

labels = train['label'].values
labels

 

array([0, 0, 1, ..., 0, 1, 0])

 

 

 

(2) 서브워드 토크나이저 : WordPiece

한편, BERT에서 중요한 것이 있는데, 바로 서브워드 토크나이저로 WordPiece를 사용한다는 점이다. 서브워드 토크나이저를 통해 단어를 분리해 단어 집합을 만들게 되는데, 이 단어 집합을 기반으로 토큰화를 수행하게 된다. WordPiece를 사용함으로써 기존의 방식과 조금 다른 점이 있다면, 단어를 쪼갰을 때 해당 단어가 단어 집합에 없으면 단어를 한번 더 쪼개려고 시도한다. 아래 코드를 보자.

 

import pandas as pd
from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained("bert-base-multilingual-cased", do_lower_case=False)
result = tokenizer.tokenize('안녕하세요!')
print(result)

 

['안', '##녕', '##하', '##세', '##요', '!']

 

예시로 '안녕하세요!'라는 데이터를 입력했다. 현재 단어 집합이 없는 상태이기 때문에 '안녕하세요'라는 단어는 한번 더 쪼개어 진다. 그리고 '##'이 붙음으로써  '##녕', '##하' 등과 같은 단어는 특정 단어의 서브워드라는 것을 나타내준다. 이를 통해 해당 단어의 의미를 잃지 않고 다시 임베딩 값으로 복원할 수 있게 된다.

 

다시 코드로 돌아와서, train set 데이터에 대해서 WordPiece 방식을 이용하여 토크나이징을 하도록 하겠다.

 

tokenizer = BertTokenizer.from_pretrained('bert-base-multilingual-cased', do_lower_case=False)
tokenized_texts = [tokenizer.tokenize(s) for s in sentences]

 

 

토크나이징이 잘 되었는지 확인해보자.

 

print(sentences[0])  #토크나이징 전
print(tokenized_texts[0]) #토크나이징 후

 

[CLS] 파도가 엄청 세 [SEP]
['[CLS]', '파', '##도가', '엄', '##청', '세', '[SEP]']

 

실행 결과, 잘 수행되었음을 확인할 수 있다.

 

이제 각 문장의 토큰들을 숫자 인덱스로 변환하도록 하겠다. 최대 시퀀스의 길이는 128로 설정하였다.

 

MAX_LEN = 128 #최대 시퀀스 길이 설정
input_ids = [tokenizer.convert_tokens_to_ids(x) for x in tokenized_texts]

 

그리고나서 제로 패딩을 수행한다.

 

input_ids = pad_sequences(input_ids, maxlen=MAX_LEN, dtype="long", truncating="post", padding="post")

 

지금까지 코드를 잘 실행했다면 inpud_ids[0]에 대해서 다음과 같이 출력될 것이다.

 

array([  101,  9901, 68516,  9553, 40311,  9435,   102,     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,     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])

 

 

 

(3) Attention Mask (어텐션 마스크)

한편, BERT 모델의 경우 추가로 시퀀스 입력 데이터가 필요한데, 바로 어텐션 마스크라는 시퀀스이다. 맨 처음에 어텐션에 대해서 언급을 했었는데, 어텐션 연산을 할 때 0의 값을 가지는 패딩 토큰에 대해선 연산을 수행하지 않도록 단어와 패딩 토큰을 구분할 수 있게 알려주어야 하는데, 어텐션 마스크가 이러한 역할을 한다. 따라서 패딩된 값이면 0, 패딩되지 않은 단어라면 1의 값을 가지도록 한다. 아래 예시를 보면 단어 부분은 '1', 0으로 패딩된 부분은 '0'으로 값을 저장한다.

 

예) 패딩된 데이터 = [ 101, 9901, 68516, 9553, 40311, 9435, 102, 0, 0]

     어텐션 마스크 = [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0]

 

그렇다면 train set에 대한 어텐션 마스크 데이터를 만들어 보도록 하겠다.

 

attention_masks = []

for seq in input_ids:
    seq_mask = [float(i>0) for i in seq]
    attention_masks.append(seq_mask)

 

어텐션 마스크가 잘 생성되었는지 확인해 보자.

 

print(attention_masks[0])

 

[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.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.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.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.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]

 

 

(4) train set을 훈련셋과 검증셋으로 분리하기

train set을 훈련셋과 검증셋으로 분리하고자 한다.

 

train_inputs, validation_inputs, train_labels, validation_labels = train_test_split(input_ids,
                                                                                    labels, 
                                                                                    random_state=2000, 
                                                                                    test_size=0.1)

 

마찬가지로 어텐션 마스크도 훈련셋과 검증셋으로 분리한다.

 

train_masks, validation_masks, _, _ = train_test_split(attention_masks, 
                                                       input_ids,
                                                       random_state=2000, 
                                                       test_size=0.1)

 

분리를 했다면 학습시키기 전 각각의 데이터를 파이토치의 텐서로 변환시키는 작업을 수행해야 한다.

 

train_inputs = torch.tensor(train_inputs)
train_labels = torch.tensor(train_labels)
train_masks = torch.tensor(train_masks)
validation_inputs = torch.tensor(validation_inputs)
validation_labels = torch.tensor(validation_labels)
validation_masks = torch.tensor(validation_masks)

 

마지막으로 배치사이즈를 설정하고, 입력데이터, 어텐션 마스크, 라벨을 하나의 데이터로 묶어 train_dataloader, validation_dataloader라는 입력 데이터를 생성한다.

 

batch_size = 32

train_data = TensorDataset(train_inputs, train_masks, train_labels)
train_sampler = RandomSampler(train_data)
train_dataloader = DataLoader(train_data, sampler=train_sampler, batch_size=batch_size)

validation_data = TensorDataset(validation_inputs, validation_masks, validation_labels)
validation_sampler = SequentialSampler(validation_data)
validation_dataloader = DataLoader(validation_data, sampler=validation_sampler, batch_size=batch_size)

 

 

 

4. Test set 전처리

앞서 Train set을 전처리 한 것과 같은 방식으로 Test set에도 같은 과정을 수행한다.

 

# [CLS] + 문장 + [SEP]
sentences = ["[CLS] " + str(sentence) + " [SEP]" for sentence in sentences]

# 라벨 데이터
labels = test['label'].values

# Word 토크나이저 토큰화
tokenizer = BertTokenizer.from_pretrained('bert-base-multilingual-cased', do_lower_case=False)
tokenized_texts = [tokenizer.tokenize(sent) for sent in sentences]

# 시퀀스 설정 및 정수 인덱스 변환 & 패딩
MAX_LEN = 128
input_ids = [tokenizer.convert_tokens_to_ids(x) for x in tokenized_texts]
input_ids = pad_sequences(input_ids, maxlen=MAX_LEN, dtype="long", truncating="post", padding="post")

# 어텐션 마스크
attention_masks = []
for seq in input_ids:
    seq_mask = [float(i>0) for i in seq]
    attention_masks.append(seq_mask)
    
# 파이토치 텐서로 변환
test_inputs = torch.tensor(input_ids)
test_labels = torch.tensor(labels)
test_masks = torch.tensor(attention_masks)

# 배치 사이즈 설정 및 데이터 설정
batch_size = 32
test_data = TensorDataset(test_inputs, test_masks, test_labels)
test_sampler = RandomSampler(test_data)
test_dataloader = DataLoader(test_data, sampler=test_sampler, batch_size=batch_size)

 

 

 

5. 모델 생성

(여기서부터는 참조한 코드bert_naver_movie_colab_ipynb와 동일하다.)

 

모델을 생성하기 앞서, GPU를 사용하기 위해 디바이스 설정을 해주어야 한다.

 

if torch.cuda.is_available():    
    device = torch.device("cuda")
    print('There are %d GPU(s) available.' % torch.cuda.device_count())
    print('We will use the GPU:', torch.cuda.get_device_name(0))
else:
    device = torch.device("cpu")
    print('No GPU available, using the CPU instead.')

 

위 코드를 실행하고 'There are 1 GPU(s) available.' 라는 문구가 출력되면 사용이 가능하다는 뜻이다.

 

이제 pretrain된 BERT 모델을 불러오도록 하겠다.

 

model = BertForSequenceClassification.from_pretrained("bert-base-multilingual-cased", num_labels=2)
model.cuda()

 

그리고나서, 하이퍼 파라미터를 설정해주어야 한다. 파라미터 값은 참조한 코드에 있는 값 그대로 설정했다.

에폭 수의 경우 간단하게 4로 설정했다.

 

# 옵티마이저
optimizer = AdamW(model.parameters(),
                  lr = 2e-5, # 학습률(learning rate)
                  eps = 1e-8 
                )

# 에폭수
epochs = 4

# 총 훈련 스텝 : 배치반복 횟수 * 에폭
total_steps = len(train_dataloader) * epochs

# 스케줄러 생성
scheduler = get_linear_schedule_with_warmup(optimizer, 
                                            num_warmup_steps = 0,
                                            num_training_steps = total_steps)

 

 

 

6. 모델 학습

모델 학습을 위해 여러 함수를 설정해준다.

 

# 정확도 계산 함수
def flat_accuracy(preds, labels):
    
    pred_flat = np.argmax(preds, axis=1).flatten()
    labels_flat = labels.flatten()

    return np.sum(pred_flat == labels_flat) / len(labels_flat)
    
    
# 시간 표시 함수
def format_time(elapsed):

    # 반올림
    elapsed_rounded = int(round((elapsed)))
    
    # hh:mm:ss으로 형태 변경
    return str(datetime.timedelta(seconds=elapsed_rounded))

 

그리고나서 아래 코드를 실행하여 모델을 학습시킨다.

 

#랜덤시드 고정
seed_val = 42
random.seed(seed_val)
np.random.seed(seed_val)
torch.manual_seed(seed_val)
torch.cuda.manual_seed_all(seed_val)

#그래디언트 초기화
model.zero_grad()

# 학습
for epoch_i in range(0, epochs):
    
    # ========================================
    #               Training
    # ========================================
    
    print("")
    print('======== Epoch {:} / {:} ========'.format(epoch_i + 1, epochs))
    print('Training...')

    # 시작 시간 설정
    t0 = time.time()

    # 로스 초기화
    total_loss = 0

    # 훈련모드로 변경
    model.train()
        
    # 데이터로더에서 배치만큼 반복하여 가져옴
    for step, batch in enumerate(train_dataloader):
        # 경과 정보 표시
        if step % 500 == 0 and not step == 0:
            elapsed = format_time(time.time() - t0)
            print('  Batch {:>5,}  of  {:>5,}.    Elapsed: {:}.'.format(step, len(train_dataloader), elapsed))

        # 배치를 GPU에 넣음
        batch = tuple(t.to(device) for t in batch)
        
        # 배치에서 데이터 추출
        b_input_ids, b_input_mask, b_labels = batch

        # Forward 수행                
        outputs = model(b_input_ids, 
                        token_type_ids=None, 
                        attention_mask=b_input_mask, 
                        labels=b_labels)
        
        # 로스 구함
        loss = outputs[0]

        # 총 로스 계산
        total_loss += loss.item()

        # Backward 수행으로 그래디언트 계산
        loss.backward()

        # 그래디언트 클리핑
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)

        # 그래디언트를 통해 가중치 파라미터 업데이트
        optimizer.step()

        # 스케줄러로 학습률 감소
        scheduler.step()

        # 그래디언트 초기화
        model.zero_grad()

    # 평균 로스 계산
    avg_train_loss = total_loss / len(train_dataloader)            

    print("")
    print("  Average training loss: {0:.2f}".format(avg_train_loss))
    print("  Training epcoh took: {:}".format(format_time(time.time() - t0)))
        
    # ========================================
    #               Validation
    # ========================================

    print("")
    print("Running Validation...")

    #시작 시간 설정
    t0 = time.time()

    # 평가모드로 변경
    model.eval()

    # 변수 초기화
    eval_loss, eval_accuracy = 0, 0
    nb_eval_steps, nb_eval_examples = 0, 0

    # 데이터로더에서 배치만큼 반복하여 가져옴
    for batch in validation_dataloader:
        # 배치를 GPU에 넣음
        batch = tuple(t.to(device) for t in batch)
        
        # 배치에서 데이터 추출
        b_input_ids, b_input_mask, b_labels = batch
        
        # 그래디언트 계산 안함
        with torch.no_grad():     
            # Forward 수행
            outputs = model(b_input_ids, 
                            token_type_ids=None, 
                            attention_mask=b_input_mask)
        
        # 로스 구함
        logits = outputs[0]

        # CPU로 데이터 이동
        logits = logits.detach().cpu().numpy()
        label_ids = b_labels.to('cpu').numpy()
        
        # 출력 로짓과 라벨을 비교하여 정확도 계산
        tmp_eval_accuracy = flat_accuracy(logits, label_ids)
        eval_accuracy += tmp_eval_accuracy
        nb_eval_steps += 1

    print("  Accuracy: {0:.2f}".format(eval_accuracy/nb_eval_steps))
    print("  Validation took: {:}".format(format_time(time.time() - t0)))

print("")
print("Training complete!")

 

약 12,000개의 데이터로만 학습을 시켰음에도 약 15분 가량의 시간이 걸렸다.

 

출력된 모습은 다음과 같다.

 

======== Epoch 1 / 4 ========
Training...

  Average training loss: 0.44
  Training epcoh took: 0:02:48

Running Validation...
  Accuracy: 0.85
  Validation took: 0:00:07

======== Epoch 2 / 4 ========
Training...

  Average training loss: 0.31
  Training epcoh took: 0:03:04

Running Validation...
  Accuracy: 0.87
  Validation took: 0:00:07

======== Epoch 3 / 4 ========
Training...

  Average training loss: 0.22
  Training epcoh took: 0:03:05

Running Validation...
  Accuracy: 0.88
  Validation took: 0:00:07

======== Epoch 4 / 4 ========
Training...

  Average training loss: 0.17
  Training epcoh took: 0:03:06

Running Validation...
  Accuracy: 0.88
  Validation took: 0:00:07

Training complete!

 

마지막 4번째 에폭에서 출력된 Validation set의 정확도를 보면 0.88이 나온걸로 봐서 정확도가 꽤 높은 것을 확인할 수 있다. 하이퍼 파라미터를 재조정하고 에폭 수를 증가시키면 정확도가 조금 더 높게 나올 수 있을거라 예상된다.

 

 

 

7. 테스트셋 평가

지금까지 pretrain된 BERT 모델에 추가로 9000개의 한글 대화 데이터를 추가로 학습시켰다. 이제 테스트 셋 데이터로 정확도를 측정해보도록 하겠다.

 

#시작 시간 설정
t0 = time.time()

# 평가모드로 변경
model.eval()

# 변수 초기화
eval_loss, eval_accuracy = 0, 0
nb_eval_steps, nb_eval_examples = 0, 0

# 데이터로더에서 배치만큼 반복하여 가져옴
for step, batch in enumerate(test_dataloader):
    # 경과 정보 표시
    if step % 100 == 0 and not step == 0:
        elapsed = format_time(time.time() - t0)
        print('  Batch {:>5,}  of  {:>5,}.    Elapsed: {:}.'.format(step, len(test_dataloader), elapsed))

    # 배치를 GPU에 넣음
    batch = tuple(t.to(device) for t in batch)
    
    # 배치에서 데이터 추출
    b_input_ids, b_input_mask, b_labels = batch
    
    # 그래디언트 계산 안함
    with torch.no_grad():     
        # Forward 수행
        outputs = model(b_input_ids, 
                        token_type_ids=None, 
                        attention_mask=b_input_mask)
    
    # 로스 구함
    logits = outputs[0]

    # CPU로 데이터 이동
    logits = logits.detach().cpu().numpy()
    label_ids = b_labels.to('cpu').numpy()
    
    # 출력 로짓과 라벨을 비교하여 정확도 계산
    tmp_eval_accuracy = flat_accuracy(logits, label_ids)
    eval_accuracy += tmp_eval_accuracy
    nb_eval_steps += 1

print("")
print("Accuracy: {0:.2f}".format(eval_accuracy/nb_eval_steps))
print("Test took: {:}".format(format_time(time.time() - t0)))

 

Accuracy: 0.87
Test took: 0:00:19

 

실행 결과, validation set에 대한 정확도와 비슷하게 0.87의 정확도를 가진 것을 확인할 수 있다.

 

 

 

8. 새로운 문장 테스트

이미 test set으로 정확도를 측정해보았지만, 새로운 문장을 입력하여 분류를 잘 하는지 살펴보도록 하겠다.

 

# 입력 데이터 변환
def convert_input_data(sentences):

    # BERT의 토크나이저로 문장을 토큰으로 분리
    tokenized_texts = [tokenizer.tokenize(sent) for sent in sentences]

    # 입력 토큰의 최대 시퀀스 길이
    MAX_LEN = 128

    # 토큰을 숫자 인덱스로 변환
    input_ids = [tokenizer.convert_tokens_to_ids(x) for x in tokenized_texts]
    
    # 문장을 MAX_LEN 길이에 맞게 자르고, 모자란 부분을 패딩 0으로 채움
    input_ids = pad_sequences(input_ids, maxlen=MAX_LEN, dtype="long", truncating="post", padding="post")

    # 어텐션 마스크 초기화
    attention_masks = []

    # 어텐션 마스크를 패딩이 아니면 1, 패딩이면 0으로 설정
    # 패딩 부분은 BERT 모델에서 어텐션을 수행하지 않아 속도 향상
    for seq in input_ids:
        seq_mask = [float(i>0) for i in seq]
        attention_masks.append(seq_mask)

    # 데이터를 파이토치의 텐서로 변환
    inputs = torch.tensor(input_ids)
    masks = torch.tensor(attention_masks)

    return inputs, masks

 

# 문장 테스트
def test_sentences(sentences):

    # 평가모드로 변경
    model.eval()

    # 문장을 입력 데이터로 변환
    inputs, masks = convert_input_data(sentences)

    # 데이터를 GPU에 넣음
    b_input_ids = inputs.to(device)
    b_input_mask = masks.to(device)
            
    # 그래디언트 계산 안함
    with torch.no_grad():     
        # Forward 수행
        outputs = model(b_input_ids, 
                        token_type_ids=None, 
                        attention_mask=b_input_mask)

    # 로스 구함
    logits = outputs[0]

    # CPU로 데이터 이동
    logits = logits.detach().cpu().numpy()

    return logits

 

위 코드는 새로운 문장에 대해 전처리를 해주는 코드와 학습한 모델에 테스트를 수행하는 함수이다. 

먼저 데이터셋에 있던 기존 문장을 입력해 보았다.

 

logits = test_sentences(['더 나은 학교생활 하고 싶어'])
print(logits)

if np.argmax(logits) == 1 :
    print("연애 관련 대화")
elif np.argmax(logits) == 0 :
    print("일상 대화")

 

입력한 문장에 대해 logits 값과, logits값을 argmax() 함수로 연산했을 때 '1'이 나오면 '연애 관련 대화', '0'이 나오면 '일상 대화'가 나오도록 코드를 추가로 입력했다.

 

위와 같이  '더 나은 학교생활을 하고 싶어'라고 입력한 결과, 다음과 같이 '일상 대화'로 알맞게 출력되었다.

 

[[ 2.241536  -2.6760721]]
일상 대화

 

 

이번엔 새로운 문장을 입력해 보았다.

 

logits = test_sentences(['저녁 뭘 먹을지 추천해줘'])

print(logits)
if np.argmax(logits) == 1 :
    print("연애 관련 대화")
elif np.argmax(logits) == 0 :
    print("일상 대화")

 

[[ 0.62954295 -0.69384205]]
일상 대화

 

'저녁 뭘 먹을지 추천해줘'라는 문장에 대해 '일상 대화'라고 출력되었다.

 

logits = test_sentences(['여자친구한테 선물 뭘로 줄까?'])

print(logits)
if np.argmax(logits) == 1 :
    print("연애 관련 대화")
elif np.argmax(logits) == 0 :
    print("일상 대화")

 

[[-2.0409577  2.6851814]]
연애 관련 대화

 

생각보다 잘 예측하나 싶었지만 위 문장에서 '여자친구'를 '엄마'로 바꾸었더니 '일상 대화'가 아닌 '연애 관련 대화'라는 값이 출력되었다. 추가로 여러 문장을 입력하고 결과를 확인했는데, 연애와 관련된 단어가 확실히 들어가 있는 문장은 잘 예측한 반면, 애매한 뉘양스의 문장은 정확도가 떨어지는 것 같았다.

 

 

 

|| BERT 학습 결과 및 보완점

 학습 데이터를 불러와 전처리부터 BERT모델을 학습한 후 예측까지, 전 과정을 수행해본 결과, BERT의 알고리즘이 LSTM, CNN 등으로 자연어 학습을 하는 것과는 다른 방식으로 수행된다는 점에서 큰 차이의 성능을 보여주는 것 같다. 또한 이미 방대한 양의 데이터로 pretrain되어 있어 output layer만 바꾸어서 파인튜닝을 해주면 원하는 용도에 맞게 사용할 수 있다는 점이 가장 큰 장점인 것 같다.

 

 또한, 한국어 대화에 대해 이중 분류를 해본 결과, 높으면 높고 낮으면 낮은 약 88%의 정확도가 나왔지만 에폭 수를 상당히 낮게 설정했기 때문에 에폭 수를 증가시키고, 배치 사이즈, learning rate 등 하이퍼 파라미터 값들을 조정한다면 90%가 넘는 정확도가 나올 것으로 예측된다. 또한 학습 데이터로 사용한 한국어 대화 데이터에서도 애매하게 라벨링된 데이터가 있었기 때문에, 정확하게 라벨링이 되어 있고 다양하고 충분한 양의 데이터를 가지고 학습 시킨다면 훨씬 더 높은 정확도가 나오지 않을까 생각한다.

 

 한편, KOBERT라는 것이 있는데, KOBERT란 한국어 버전의 BERT 알고리즘이다. KOBERT에서는 약 8000개 한국어 단어가 있는 단어 집합을 사용하기 때문에, 한국어 데이터에 대해서 높은 정확도를 보일 수 있다. 따라서 다음 프로젝트에서는 KOBERT에 대해 자세히 알아보고, KOBERT와 더 많은 양의 데이터로 다중분류 모델을 만들어 보도록 하겠다.

 

 


|| REFERENCE