본문 바로가기

Code States AI

[PYTHON/파이썬] KoBERT를 사용한 온라인 뉴스 악성 댓글 데이터 이진 분류

KoBERT 모델을 사용해 뉴스 댓글을 분류하는 작은 프로젝트를 진행해보았다.

*프로젝트는 코랩 환경에서 진행했다

 

1. 주제 선정 이유🙋‍♂️

프로젝트의 주제는 KoBERT를 사용한 온라인 뉴스 악성 댓글 데이터 이진 분류이다. 주제 선정 이유는 다음과 같다.

  • 유튜브, 개인방송, SNS, 게임 채팅 등 온라인 공간 확장
  • 특정 집단뿐만 아니라 일반인을 대상으로 한 악성 댓글 증가
  • 피해자의 경우 외상 후 스트레스 장애 및 대인관계 기피, 공포로의 발전 가능성 존재

큰 사회적 문제인 악성 댓글 문제를 기업 차원에서 어떻게 대응할 수 있을지에 대해 고민한 결과 딥러닝을 사용해 이를 분류해보기로 했다. 

 

2. 데이터 선정🙋‍♂️

한국어로 된 데이터셋을 조사하였고, 최종적으로 Korean HateSpeech Dataset을 선택했다. 이는 편견, 혐오표현, 모욕에 대한 한국어 온라인 뉴스 댓글 데이터셋으로 특징은 다음과 같다.
 
  • 총 9,381개의 댓글(훈련 7,896개/검증 417개/테스트 974개)
  • 태깅 과정에 대한 가이드라인
  • Deepest 학술그룹 세미나 발표자료
    • 온라인 포털 연예 뉴스기사의 경우 두터운 독자층, 확실한 타깃, 특정 집단에 치우치지 않는 갈등 존재
    • 데이터 수집 기간 Jan.2018 - Feb. 2020

나는 데이터셋 중에서도 Gender-related bias를 가진 데이터를 사용했다. 이 데이터는 성별에 따른 역할이나 능력에 대한 편견, 성별과 나이에 대한 편견, 그 외 특정 성별, 성적 지향성, 성 정체성, 성 관련 사상을 가진 집단에 대한 편견이 있는 경우 TRUE로 라벨링 된 데이터이다. 데이터는 train/dev/test set으로 나누어져 있었고, 학습에는 train set과 dev set을 사용했다. 

 

3. 데이터 살펴보기 및 전처리🙋‍♂️

3-1) 데이터 살펴보기

- 데이터 형태 및 중복값 유무 확인

# 데이터 가져오기
dataset_train = pd.read_csv('/content/drive/MyDrive/project4/gender_bias_data/train.gender_bias.binary.csv')
dataset_dev = pd.read_csv('/content/drive/MyDrive/project4/gender_bias_data/dev.gender_bias.binary.csv')

# 개수 확인
print('train dataset 갯수:', len(dataset_train))	# 7896개
print('dev dataset 갯수:', len(dataset_dev))		# 471개

# 형태 확인
dataset_train.loc[dataset_train['label'] == False].head(20)
dataset_train.loc[dataset_train['label'] == True].head(20)

# 중복값 확인
assert len(dataset_train) == int(dataset_train['comments'].duplicated().value_counts()) 
assert len(dataset_dev) == int(dataset_dev['comments'].duplicated().value_counts())
# 결과) 중복값 없음

dataset_train.head()시 출력

- 타겟 비율 확인

plt.subplot(1,2,1)  
ax = dataset_train['label'].value_counts(normalize=True).plot.bar()
plt.title('Train')
plt.xlabel('lable')

plt.subplot(1,2,2)
ax = dataset_dev['label'].value_counts(normalize=True).plot.bar()
plt.title('Dev')
plt.xlabel('lable')
plt.show()

결괏값

- 길이 분포 확인

comments_len = [len(s.split()) for s in dataset_train['comments']]

print('댓글의 최소 길이 : {}'.format(np.min(comments_len)))
print('댓글의 최대 길이 : {}'.format(np.max(comments_len)))
print('댓글의 평균 길이 : {}'.format(np.mean(comments_len)))

plt.subplot(1,2,1)
plt.boxplot(comments_len)
plt.title('Comments')

plt.subplot(1,2,2)
plt.title('Comments')
plt.hist(comments_len, bins=40)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()

결괏값

- 형태소분석기 Mecab과 word cloud를 활용한 시각화

import konlpy
from konlpy.tag import Mecab

# 데이터 합치기
dataset_concat = pd.concat([dataset_train, dataset_dev], names=['comments', 'label'])

# 데이터에서 명사만 추출
def getNouns(s):
    if type(s) == str:
        mecab = Mecab()
        nouns_list = mecab.nouns(s) 
        nouns = ' '.join(nouns_list)
        return nouns
    else:
        return str(s)
dataset_concat['nouns'] = dataset_concat['comments'].apply(getNouns)

# TRUE와 FALSE 데이터로 분리
true_data = dataset_concat[dataset_concat['label'] == True]
false_data = dataset_concat[dataset_concat['label'] == False]

true_data.head()시 출력

 

from wordcloud import WordCloud
from PIL import Image

bubble_mask = np.array(Image.open('/content/drive/MyDrive/project4/speech-bubble.PNG'))

wordcloud = WordCloud(font_path='NanumBarunGothic', 
                      mask=bubble_mask,
                      background_color='white')

# true label에 대한 word cloud 생성
wc_true = wordcloud.generate(' '.join(true_data['nouns']))    

plt.figure(figsize=(10, 10))
plt.imshow(wc_true, cmap=plt.cm.gray, interpolation='bilinear')
plt.axis("off")

plt.show()

시각화 결과 성별에 관한 데이터이다 보니 '여자', '남자'라는 단어가 가장 크게 나타나고, 사이사이 차별·혐오와 관련된 단어들을 찾을 수 있었다. 

 

👇더 자세한 word cloud 사용법은 아래에서 확인

 

[PYTHON/파이썬] 워드 클라우드(Word Cloud)로 한글 데이터 시각화하기

자연어 처리 글에서 꼭 한 번씩 보이는 워드 클라우드(Word Cloud)⛅ * 워드 클라우드 또는 태그 클라우드란 데이터의 중요도나 인기도를 고려하여 2차원으로 시각화한 것을 말한다(출처 위키백과)

donghae0230.tistory.com

 

3-2) 데이터 전처리

전처리는 간단하게 정규표현식을 사용해 특수문자를 제거하고, 라벨 값을 0과 1로 변경해주었다.

# 댓글 내 특수문자 제거
import re

def textCleaning(s):
    res = re.sub(r'[^ A-Za-z0-9가-힣]','',s)    
    return res

dataset_train['comments'] = dataset_train['comments'].apply(textCleaning)
dataset_dev['comments'] = dataset_dev['comments'].apply(textCleaning)
# label 값 0또는 1로 변경
dataset_dev["label"] = dataset_dev["label"].astype(int)
dataset_train["label"] = dataset_train["label"].astype(int)

dataset_train.head()시 출력

4. 딥러닝 방식 적용🙋‍♂️

모델은 BERT(bidirectional encoder representations from transformers) 모델을 사용했다. BERT는 2018년 10월 구글에서 발표한 모델로, 약 33억개의 단어가 사전 학습된 언어모델이다. 트랜스포머의 인코더만을 사용해 문맥을 양방향으로 인식하며 사용 목적에 따라 fine-tuning이 가능하다는 특징이 있다.
 
나는 한국어 데이터셋을 사용했기 때문에 최종적으로 KoBERT를 선택했다. KoBERT는 SK T-Brain에서 구축한 기계번역 모델로 구글 BERT의 한국어 성능 개선을 위해 개발된 모델이다. 위키피디아 등에서 수집한 수백만개의 한국어 말뭉치가 학습되어 있다는 특징이 있다. 
 
코드는 KoBERT 깃허브에서 공개한 아래 *파인 튜닝 예제를 참고했다.

 

* 사전 학습 모델을 기반으로 목적에 따라 학습된 weight나 bias를 조정하는 과정을 파인 튜닝(fine tuning)이라고 한다

* 사전 학습 모델로 파인튜닝을 하는 과정을 전이 학습(transfer learning)이라고 한다. 

 

naver_review_classifications_pytorch_kobert.ipynb

Run, share, and edit Python notebooks

colab.research.google.com

!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.notebook import tqdm

from kobert import get_tokenizer
from kobert import get_pytorch_kobert_model

from transformers import AdamW
from transformers.optimization import get_cosine_schedule_with_warmup
# GPU 설정
if torch.cuda.is_available():
    device = torch.device("cuda:0")
    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')
# KoBERT 및 Vocabulary 불러오기
bertmodel, vocab = get_pytorch_kobert_model(cachedir=".cache")
!wget -O .cache/ratings_train.txt http://skt-lsl-nlp-model.s3.amazonaws.com/KoBERT/datasets/nsmc/ratings_train.txt
!wget -O .cache/ratings_test.txt http://skt-lsl-nlp-model.s3.amazonaws.com/KoBERT/datasets/nsmc/ratings_test.txt
# 토크나이저
tokenizer = get_tokenizer()
tok = nlp.data.BERTSPTokenizer(tokenizer, vocab, lower=False)

 

koBERT에서는 *서브워드 토크 나이저(Subword Tokenizer)로 SentencePiece를 사용한다. 예시는 아래와 같다.

 

train_list[0][0]
# 출력) '현재 호텔주인 심정 아18 난 마른하늘에 날벼락맞고 호텔망하게생겼는데 누군 계속 추모받네'
from gluonnlp.data import SentencepieceTokenizer

sp  = SentencepieceTokenizer(tok_path)
print(sp(train_list[0][0]))
# 출력) ['▁현재', '▁호텔', '주', '인', '▁심', '정', '▁아', '18', '▁난', '▁마', '른', '하늘', '에', '▁날', '벼', '락', '맞', '고', '▁호텔', '망', '하게', '생', '겼', '는데', '▁누', '군', '▁계속', '▁추', '모', '받', '네']

* 서브워드 분리 작업은 단어를 더 작은 단위인 서브 워드로 분리해 인코딩 및 임베딩 하는 전처리 작업이다. 그리고 이 작업을 하는 토크 나이저를 서브 워드 토크 나이저라고 한다.

 

KoBERT 입력 데이터로 만드는 함수는 다음과 같다. 토큰화, 정수 인코딩, 패딩 등이 이루어지는 함수이다. 

 

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

 

데이터를 ['comments', 'label'] 형태로 변경해준 후 KoBERT 입력 데이터로 만들어주었다.

 

def makeDatalist(df):
    data_list = []
    for comment, label in zip(df['comments'], df['label']):
        temp = []
        temp.append(comment)
        temp.append(label)
        data_list.append(temp)
    return data_list

train_list = makeDatalist(dataset_train)
dev_list = makeDatalist(dataset_dev)

data_train = BERTDataset(train_list, 0, 1, tok, max_len, True, False)
data_dev = BERTDataset(dev_list, 0, 1, tok, max_len, True, False)
# PyTorch Dataloader를 사용해 배치 데이터 생성
train_dataloader = torch.utils.data.DataLoader(data_train, batch_size=batch_size, num_workers=5)
dev_dataloader = torch.utils.data.DataLoader(data_dev, batch_size=batch_size, num_workers=5)

 

모델은 다음과 같다. 다중 분류 시 num_classes를 조절해주면 된다.

 

class BERTClassifier(nn.Module):
    def __init__(self,
                 bert,
                 hidden_size = 768,
                 num_classes=2,
                 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)
        
odel = BERTClassifier(bertmodel, dr_rate=0.5).to(device)

 

다음은 optimizer와 learning rate scheduler에 대한 코드이다. optimizers는 Adam보다 일반화 성능이 높다고 평가되는 AdamW를 사용했다. scheduler는 get_cosine_schedule_with_warmup()를 사용했는데, 이는 0과 (optimizer에서 설정한) lr사이에서 코사인 함수를 이용하여 학습률을 스케줄링하는 방법이다.  

transformers.get_cosine_schedule_with_warmup

 

# 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)

 

 

다음은 accuracy를 구하기 위한 함수이다.

 

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

 

🌈모델 학습 및 평가

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 tqdm(enumerate(train_dataloader), total=len(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)   # gradient clipping
        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 tqdm(enumerate(dev_dataloader), total=len(dev_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)))

결과

epoch 5로 학습했을 때, 코랩 GPU 환경에서 30분내로 학습이 완료되었다. 학습결과 train set에서는 정확도가 0.98로 높게 나왔고, test set에서는(데이터에서는 dev set) 0.93이 나왔다.

 

test set의 경우 라벨링이 되어 있지 않았기 때문에, Korean HateSpeech Dataset 제작 측에서 오픈한 캐글 대회 Korean Gender Bias Detection에서 평가했다. 

 

 

대회의 평가지표는 *F1 Score였고, BASELINE은 BERT모델을 사용했을 때의 점수인 0.68138이었다.

* F1 Score는 정밀도와 재현율의 조화평균, 즉 어느 한쪽으로 치우치지 않을 때 높은 값을 가지는 평가지표이다.

* 재현율(Recall)은 데이터가 실제로 TRUE일 때 모델이 TRUE라고 예측한 경우, 정밀도(Precision)는 모델이 TRUE라고 예측했을 때 데이터가 실제 TRUE인 경우를 말한다(둘은 서로 trade-off 관계이다).

 

에포크를 변경하며 제출해보았는데 최종적으로 5일 때 가장 높은 점수인 0.70833을 얻을 수 있었다.

 

5. 결과 및 느낀점🙋‍♂️

Korean HateSpeech Dataset 데이터와 koBERT 모델을 사용해서 댓글 내 성 편견 유무를 분류하는 모델을 만들어 보았다. 이틀간 정신없이 진행했는데, 다행히 좋은 데이터와 모델을 찾아내 기간 내 완성할 수 있었다.

 

프로젝트를 하며 느낀점은 크게 세 가지로 나눌 수 있다.

 

✔ 악성 댓글 문제와 근본적인 해결책

첫째는 악성 댓글 문제에 관한 고민이다. 이 프로젝트는 악성 댓글 삭제, 댓글 작성자 제재 및 신고에 활용되기를 기대하며 기획했다. 하지만 여러 기사와 자료를 보면서 댓글 삭제나 처벌과 같은 방법만이 해결책인지에 대한 의문이 들었고, 장기적인 관점에서는 딥러닝과 같은 기술적 방법뿐만 아니라 제도적 방법 등 다른 노력도 필요하겠다고 느꼈다. 

 

  데이터와 인공지능, 그리고 사회 문제

두번째는 사회적 문제를 해결하는 것에 대한 관심이다. Korean HateSpeech Dataset 데이터 셋 깃허브와 태깅 가이드라인, 발표 자료 등을 읽으며 하나의 데이터셋을 만드는 과정이 얼마나 복잡한 일인지 알 수 있었다. 하지만 그 과정 속에서도 '혐오'와 '편견'이라는 주제에 대해서 깊게 고민하고, 문제를 해결하려는 사람들이 멋있게 느껴졌고 나 또한 인공지능으로 그러한 일을 하고 싶다 생각했다.

 

  추가 학습에 대한 필요성 

마지막은 역시 추가 학습에 대한 필요성이다. 모델의 일반화 성능을 높이기 위한 방법, 성 편견 댓글뿐만 아니라 다양한 편견 및 혐오를 분류하는 다중 분류 모델 구축에 대한 것도 추가적으로 알아보고 싶다.

 

비록 모델 관련해서는 예제를 거의 따라 했지만, 주제 선정이나 데이터 수집, 코드 분석을 하면서 지난주보다는 조금 더 성장하지 않았나 생각한다. 실력이 한참 부족하지만 계속 부딪혀봐야지.

 

 


프로젝트 정리 글은 여기서 끝끝🖐

 

Korean HateSpeech Dataset

 

GitHub - kocohub/korean-hate-speech: Korean HateSpeech Dataset

Korean HateSpeech Dataset. Contribute to kocohub/korean-hate-speech development by creating an account on GitHub.

github.com

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

Kaggle 대회

 

Korean Gender Bias Detection | Kaggle

 

www.kaggle.com