분석하고싶은코코

여러 모델을 활용한 비속어 문장 탐지 본문

머신러닝&딥러닝/NLP

여러 모델을 활용한 비속어 문장 탐지

코코로코코 2023. 12. 18. 20:45
반응형

이번 포스팅에서는 여러 모델을 활용한 비속어(hate_speach)를 탐지하는 것에 대한 포스팅을 해보겠습니다.

!이번 포스팅은 비속어(욕설) 텍스트에 대한 탐지를 다룬 프로젝트로  결과 예시에 욕설이 포함되어있습니다.

 

오늘 사용할 데이터는 다음 2곳에서 정의한 부정어 텍스트 데이터 셋을 활용하였습니다. 커뮤니티, 특히 게임 유저들이 커뮤니티에서 사용하는 텍스트를 수집하여 부정 텍스트에 대한 라벨링을 진행하여 게임 유저가 사용한 텍스트를 기준으로 진행하려고 했으나 데이터 수집은 진행되고 있고 직접 라벨링하는데까지 시간이 걸려 우선 공유되어있는 부정어(욕설) 데이터셋을 사용하여 이를 탐지하는 것을 목표로 해보았습니다.

 

이번 포스팅에서 사용해볼 모델은 1D CNN 모델과 LSTM모델을 활용한 비속어 문장 탐지를 진행해보려고 합니다. 이에 대한 설명은 아래 모델링 부분에서 좀 더 자세히 이야기해보겠습니다. 아무래도 이번에 사용하는 데이터셋이 약 1만개정도로 그렇게 큰 데이터셋이 아니기에 매우 정교한 탐지에는 어려움이 있다는 점 참고해주시면 좋겠습니다.


데이터셋

https://github.com/kocohub/korean-hate-speech

 

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

 

https://github.com/2runo/Curse-detection-data

 

GitHub - 2runo/Curse-detection-data: 문장의 욕설 여부를 분류한 한글 데이터셋입니다.

문장의 욕설 여부를 분류한 한글 데이터셋입니다. Contribute to 2runo/Curse-detection-data development by creating an account on GitHub.

github.com

 

import pandas as pd
import numpy as np
from soynlp.normalizer import repeat_normalize

import re

    
# 데이터 가져오기
text_datas = list()
text_labels = list()
with open('./data/비속어/dataset.txt', 'r') as f:
    for line in f.readlines():
        try:
            content, label = line.split('|')
            text_datas.append(content)
            text_labels.append(re.sub('\n','', label))
        except:
            print('Split Error Sentence : ', line)

df = pd.DataFrame({
    'content' : text_datas,
    'label' : text_labels
})

new_df = pd.read_csv('./data/비속어/train.tsv', sep='\t')
new_df = new_df.rename(columns={'comments' : 'content'})
concat_df = pd.concat([df,new_df[['content','label']]])
concat_df.reset_index(inplace=True, drop=True)

 

 

데이터 확인

concat_df.head()

 

긍정(0), 부정(1) 데이터 비율 단어비율

 

단어비율

단어 비율은 밑에서 진행할 토크나이저로 확인하는 과정으로 진행하였습니다. 띄어쓰기를 기준으로 WordPiece토크나이징이 진행된 결과에서는 희귀 단어(2회 이하)가 3개 밖에 존재하지 않습니다. 이를 통해 확인할 수 있는 부분은 어느정도 비슷한 단어들로 데이터셋이 구성되어 있음을 알 수 있고 희귀 단어가 많지 않아서 훈련과정에 있어서 방해요소가 적다는 것을 확인할 수 있습니다.

threshold = 3
total_cnt = tokenizer.get_vocab_size()
rare_cnt = 0 
total_freq = 0 
rare_freq = 0 

for key, value in tokenizer.get_vocab().items():
    total_freq = total_freq + value

    if(value < threshold):
        rare_cnt = rare_cnt + 1
        rare_freq = rare_freq + value

print('단어 집합(vocabulary)의 크기 :',total_cnt)
print('등장 빈도가 %s번 이하인 희귀 단어의 수: %s'%(threshold - 1, rare_cnt))
print("단어 집합에서 희귀 단어의 비율:", (rare_cnt / total_cnt)*100)
print("전체 등장 빈도에서 희귀 단어 등장 빈도 비율:", (rare_freq / total_freq)*100)

# 출력값
단어 집합(vocabulary)의 크기 : 11180
등장 빈도가 2번 이하인 희귀 단어의 수: 3
단어 집합에서 희귀 단어의 비율: 0.026833631484794274
전체 등장 빈도에서 희귀 단어 등장 빈도 비율: 4.800721260362157e-06

 

전처리 & 토크나이저

토크나이저를 만들기 위한 클랜징 작업은 최소로 진행하였습니다. 한글을 제외한 텍스트를 모두 전처리하려고 생각했지만 생각해보면 게임 혹은 커뮤니티에서는 비속어들은 이미 어느정도 필터링이 된 상태에서 전달이 되는 경우도 있고 혹은 그렇지 않더라도 사용자가 필터링에 대한 것을 우회하고자 '시1발', '병1신', '병싄'과 같이 원래의 단어를 번형하여 사용하는 경우가 있습니다. 그래서 전처리에서는 반복문자에 대한 처리와 영어의 대소문자 통일의 전처리만을 진행하였습니다.

 

토크나이저는 허킹페이스의 Tokenizer인 BertWordPieceTokenizer를 사용하여 토크나이저를 학습시켰습니다. KcELECTREA와 같이 사용자들이 구어체 형태로 작성한 텍스트로 학습시킨 훈련된 토크나이저가 있긴하지만 해당 토크나이저를 활용하면 이번 프로젝트에서는 크게 차이가 없을 것 같지만 데이터에 더 적합한 토크나이저를 사용하기 위해 훈련 데이터 셋으로 학습된 토크나이저를 만들어 사용하기로 하였습니다. 아마 단어 사이즈가 3만보다 크지 않을테지만 가능한 많은 단어를 활용하고자 vocab_size는 3만, 최소 단어는 6000, 최소 반복 단어는 5개로 설정하여 토크나이저를 훈련 시켰습니다. 

 

토크나이저 훈련을 위한 준비

def preprocessing(x):
    x = repeat_normalize(x, num_repeats=2)
    x = x.strip()
    return x


df['content'] = df['content'].apply(preprocessing)

# 토크나이저 훈련을 위한 텍스트 파일 생성
with open('./data/비속어/tokenizer_words.txt', 'w', encoding='utf8') as f:
    f.write('\n'.join(concat_df['content']))

 

토크나이저 훈련

from tokenizers import BertWordPieceTokenizer

tokenizer = BertWordPieceTokenizer(lowercase=False, strip_accents=False)

data_file = './data/비속어/tokenizer_words.txt'
vocab_size = 30000
limit_alphabet = 6000
min_frequency = 5

tokenizer.train(files=data_file,
                vocab_size=vocab_size,
                limit_alphabet=limit_alphabet,
                min_frequency=min_frequency)
                

# 토크나이저 저장
# tokenizer.save_model('./')

 

이제 훈련된 토크나이저를 통해 인코딩을 진행합니다. 이후 입력에 대한 크기를 맞추기 위한 패딩 작업을 진행할텐데 전체 임의로 40이라는 크기를 넣어 확인해보니 약92%의 데이터가 범위 안에 들어있는 것을 확인하여 최대 패딩 길이를 40으로 설정하였습니다.

vocab_size = tokenizer.get_vocab_size()
vocab_size

def encode_content(x, tokenizer = tokenizer):
    return tokenizer.encode(x).ids


X = concat_df['content']
Y = concat_df['label'].astype('int').values

X = X.apply(encode_content)

def below_threshold_len(max_len, nested_list):
  count = 0
  for sentence in nested_list:
    if(len(sentence) <= max_len):
        count = count + 1
  print('길이가 %s 이하인 샘플의 비율: %s'%(max_len, (count / len(nested_list))*100))

max_len = 40
below_threshold_len(max_len, X)

# 출력값
# 전체 샘플 중 길이가 40 이하인 샘플의 비율: 92.12...

# 패딩
X = pad_sequences(X, maxlen=max_len)

 

모델링

이제 서두에서 언급했던 1D CNN, LSTM의 모델링을 진행하고 학습시키는 코드를 작성해보겠습니다. 임베딩 크기는 100, 히든레이어 크기는 128로 설정하여 LSTM층을 쌓고 최종에서는 부정인지 긍정인지에 대한 판단을 하기 때문에 1개의 값을 받고 활성화 함수를 sigmoid를 지정하였습니다.

 

LSTM

from tensorflow.keras.layers import Embedding, Dense, LSTM
from tensorflow.keras.models import Sequential
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint

embedding_dim = 100
hidden_units = 128

model = Sequential()
model.add(Embedding(vocab_size, embedding_dim))
model.add(LSTM(hidden_units))
model.add(Dense(1, activation='sigmoid'))

es = EarlyStopping(monitor='val_loss', mode='min', verbose=1, patience=4)
mc = ModelCheckpoint('best_model.h5', monitor='val_acc', mode='max', verbose=1, save_best_only=True)

model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
history = model.fit(X, Y, epochs=15, callbacks=[es, mc], batch_size=64, validation_split=0.2)

 

Multi-1D CNN

이번 모델에서 1D CNN은 여러개의 커널을 사용한 모델을 설계했습니다. 단순하게 하나의 1D CNN층을 설계해도 문제 될 것은 없습니다. 그런데 멀티로 설계한 이유는 N-gram에 대한 개념을 생각해보시면 됩니다. 기본적으로 1D CNN은 경우 N-gram의 방식과 괴장히 유사한 방식으로 진행이 됩니다. 그런데 이번에 진행한 멀티 커널을 사용하게 되면 3,4,5개의 커널로 3-gram, 4-gram, 5-gram에 대한 정보가 하나로 합쳐지게 됩니다. 그런데 이렇게 설계되면 하나의 층에서 3개의 1D CNN 층이 계산됩니다. 그래서 모델이 무거워질수밖에 없습니다. 그래서 모델링할때 CNN모델을 통해서 얻을 수 있는 정보가 무엇인지에 대해서 고민해보고 설계할 필요가 있습니다.

from tensorflow.keras.models import Sequential, Model
from tensorflow.keras.layers import Embedding, Dropout, Conv1D, GlobalMaxPooling1D, Dense, Input, Flatten, Concatenate
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint

embedding_dim = 128
dropout_ratio = (0.5, 0.8)
num_filters = 128
hidden_units = 128

model_input = Input(shape = (max_len,))
z = Embedding(vocab_size, embedding_dim, input_length = max_len, name="embedding")(model_input)
z = Dropout(dropout_ratio[0])(z)

conv_blocks = []

for sz in [3, 4, 5]:
    conv = Conv1D(filters = num_filters,
                         kernel_size = sz,
                         padding = "valid",
                         activation = "relu",
                         strides = 1)(z)
    conv = GlobalMaxPooling1D()(conv)
    conv_blocks.append(conv)

z = Concatenate()(conv_blocks) if len(conv_blocks) > 1 else conv_blocks[0]
z = Dropout(dropout_ratio[1])(z)
z = Dense(hidden_units, activation="relu")(z)
model_output = Dense(1, activation="sigmoid")(z)

model = Model(model_input, model_output)
model.compile(loss="binary_crossentropy", optimizer="adam", metrics=["acc"])

es = EarlyStopping(monitor='val_loss', mode='min', verbose=1, patience=4)
mc = ModelCheckpoint('CNN_model.h5', monitor='val_acc', mode='max', verbose=1, save_best_only=True)

model.fit(X, Y, batch_size=64, epochs=10, validation_split=0.2, verbose=1, callbacks=[es, mc])

 

결과

모델 불러오기 & 결과 출력 함수

cnn_model = load_model('CNN_model.h5')
lstm_model = load_model('best_model.h5')

def sentiment_predict(new_sentence):
  new_sentence = preprocessing(new_sentence)
  encoded = [tokenizer.encode(new_sentence).ids]
  pad_new = pad_sequences(encoded, maxlen = max_len) # 패딩
  cnn_score = float(cnn_model.predict(pad_new)) # 예측
  lstm_score = float(lstm_model.predict(pad_new))
  if(cnn_score > 0.5):
    print("[CNN]{:.2f}% 확률로 부정 텍스트입니다.\n".format(cnn_score * 100))
  else:
    print("[CNN]{:.2f}% 확률로 긍정 텍스트입니다.\n".format((1 - cnn_score) * 100))

  if(lstm_score > 0.5):
    print("[LSTM]{:.2f}% 확률로 부정 텍스트입니다.\n".format(lstm_score * 100))
  else:
    print("[LSTM]{:.2f}% 확률로 긍정 텍스트입니다.\n".format((1 - lstm_score) * 100))

 

결과

결과를 위한 텍스트로 실제 게임 안에서 유저간 싸움으로 사용될 수 있는 텍스트를 사용했습니다. 유저가 필터링을 피해서 변형된 형태로 사용하지 않을 경우 부정 텍스트로 잘 분류하는 것을 볼 수 있습니다. 그런데 중간에 '시1발'이라는 단어로 필터링을 우회하는 텍스트를 사용할 경우 긍정 텍스트로 오분류하는 상황이 발생합니다.

sentiment_predict('응 느금마')
# 결과값
[CNN]82.80% 확률로 부정 텍스트입니다.
[LSTM]87.48% 확률로 부정 텍스트입니다.

sentiment_predict('아니 시발 장난하나')
# 결과값
[CNN]92.08% 확률로 부정 텍스트입니다.
[LSTM]94.83% 확률로 부정 텍스트입니다.

sentiment_predict('아니 시1발 장난하나')
# 결과값
[CNN]90.12% 확률로 긍정 텍스트입니다.
[LSTM]75.83% 확률로 긍정 텍스트입니다.

sentiment_predict('저 새끼랑 못함 ㅅㄱ')
# 결과값
[CNN]95.29% 확률로 부정 텍스트입니다.
[LSTM]95.64% 확률로 부정 텍스트입니다.

 

 

이렇게 오분류 되는 문제는 사실 쉽게 이해할 수 있는 문제인 스팸 메일 분류하는것과 동일한 작업입니다. 이 문제에 대한 접근 방법은 크게 두 가지가 있습니다. 하나는 전처리를 통해 방지하는 것이고 다른 하나는 모델에서 우회 방법에 대해서도 탐지할 수 있도록 하는 방법입니다.

 

우선 전처리를 통해서 간다면 우회하는 규칙을 찾아 전처리를 통해 해당 부분을 모델에 들어가기전에 처리해주는 방법입니다. 그런데 이 방법 역시 절대 쉽지 않습니다. 그 이유는 '시발'이라는 단어를 '시1발'로만 우회하는 것이 아닌 '싀발', 'ㅆㅣ발'과 같이 매우 다양한 형태로 변환이 가능하기 때문입니다. 이를 위해서 또 접근해야하는 방법은 토크나이징 방법입니다. 이번 프로젝트에서는 BPE기반의 BertWordPieceTokenizer를 사용하여 단어 기반의 토크나이저를 제작하였지만 좀 세분화된 자모단위의 토크나이저를 사용해 전처리 과정을 만들어주는 방법을 선택할 수 있습니다.

 

다음으로 모델에서 자연스럽게 학습할 수 있는 방법인데 이 방법의 답은 정말 간단합니다. 많은 훈련 데이터가 있어서 모델이 자연스럽게 학습하게 하는 방법입니다. 아니면 비속어(욕설)이 들어가는 문장을 판별하기 이전에 새로운 모델을 하나 설계하여 변형된 문장을 만들고 이를 예측하는 방식인 GANs보다는 ELECTRA모델의 학습 방법과 유사한 방법을 통해서 학습량을 증가시키는 방법을 선택해볼 수 있습니다.

 

반응형