딥러닝

텍스트 전처리와 RNN을 활용한 스팸 메일 분류

jeongpil 2021. 8. 24. 10:47

안녕하세요! 오늘은 순환 신경망인 RNN을 활용하여 스팸 메일을 분류해보겠습니다.

 

RNN 포스팅에서 알아봤듯이 스팸 메일 분류는 다 대 일 모델입니다.

 

단어 시퀀스를 입력받아 정상 메일인지 아닌지를 출력하는 것입니다.

 

데이터셋은 캐글의 "SMS Spam Collection Dataset"을 활용했습니다.

https://www.kaggle.com/uciml/sms-spam-collection-dataset

 

 

순서

1. 데이터 파악

2. 데이터 전처리

3. RNN 모델 학습/예측/평가

 

1. 데이터 파악

 

#필요한 모듈 및 라이브러리 설치
import numpy as np
import pandas as pd
%matplotlib inline
import matplotlib.pyplot as plt
import urllib.request
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences

 

 

우선 필요한 모듈과 라이브러리를 설치해줍니다.

 

이제 다운로드한 데이터셋을 불러온 뒤 데이터가 어떻게 구성되어 있는지 살펴보겠습니다.

 

data = pd.read_csv('spam.csv',encoding='latin1')
display(data.info())

 

 

데이터의 칼럼은 v1, v2, Unnamed: 2, Unnamed: 3, Unnamed: 4로 총 5개의 열로 이루어져 있습니다.

 

총 데이터의 수는 5572개 입니다.

 

각 칼럼에 어떠한 값이 들어가 있는지 데이터를 보겠습니다.

 

display(data.head(5))

 

 

데이터를 보면 v1 칼럼이 스팸 메일인지 아닌지를 말해주는 타겟 변수이고 v2 칼럼에는 메일의 내용이 들어가 있습니다. 

 

Unnamed: 2 ~ Unnamed: 4는 대부분이 NaN 값으로 되어있는 것을 확인할 수 있습니다.

 

 

2. 데이터 전처리

 

이제 데이터를 전처리를 통해 RNN 모델에 사용할 수 있도록 해보겠습니다.

 

우선, Unnamed:2 ~ Unnamed: 4 칼럼은 스팸 메일 분류에 사용할 필요가 없는 칼럼이라고 판단되므로 삭제하도록 하겠습니다.

 

data=data[['v1','v2']]
data

 

 

현재 타겟 변수가 spam(스팸 메일) or ham(정상 메일)로 되어 있기 때문에 이를 스팸 메일이면 1, 정상 메일이면 0으로 바꿔주겠습니다.

 

data['v1']=data['v1'].replace(['ham','spam'],[0,1])
data

 

 

메일의 특성상 중복된 메일이 많이 존재할 수 있기 때문에 중복된 데이터가 있는지 확인하고 중복된 데이터는 삭제하도록 하겠습니다.

 

n_unique_data=data['v2'].nunique()
display("중복된 데이터의 개수: {}".format(len(data)-n_unique_data))
data.drop_duplicates(subset='v2',inplace=True)

 

 

메일 내용이 중복된 데이터의 개수는 403개가 있었고 이를 삭제했습니다. 

 

 

스팸 메일도 이전에 분류 실습으로 했던 신용카드 사기 검출과 같이

 

타겟 변수가 1인 데이터의 비율이 0인 데이터에 비해 많이 적을 것으로 예상되니 타겟 변수의 분포를 살펴보겠습니다.

 

display(data['v1'].value_counts())
display(data['v1'].value_counts().plot(kind='bar'))

 

 

스팸 메일의 비율이 약 13%이고, 정상 메일의 비율이 87%입니다.

 

스팸 메일 데이터셋도 불균형한 데이터셋임을 확인했습니다.

 

(이 부분에서 Stratified 방식으로 데이터를 분리하고 가야하나 생각했지만 고민하다보니 좋지 않을 것이라 판단해서 사용하지 않았습니다.)

 

 

이제 X_data에 메일의 내용을 y_data에 타겟 값을 가진 레이블을 저장하겠습니다.

 

X_data=data['v2']
y_data=data['v1']

 

 

이제 케라스의 토크나이저를 통해 토큰화와 정수 인코딩을 하고 마지막 3개의 메일이 어떻게 정수로 인코딩 되었는지 살펴보겠습니다.

 

tokenizer = Tokenizer()
tokenizer.fit_on_texts(X_data) # X_data의 각 행에 토큰화를 수행
sequences = tokenizer.texts_to_sequences(X_data) # 단어를 숫자값, 인덱스로 변환하여 저장
print(sequences[-3:-1])

 

 

메일의 각 단어가 정수로 인코딩 된 것을 확인할 수 있습니다.

 

그렇다면 이 숫자가 어떤 단어를 뜻하는 것인지 알아보겠습니다.

 

 

word_to_index = tokenizer.word_index
print(word_to_index)

 

 

1은 "i"를 뜻하고 2는 "to", 이렇게 메일의 모든 단어가 정수로 인코딩 되었습니다.

 

단어의 사용 빈도가 높을수록 낮은 값으로 인코딩 됩니다.

 

즉, "i"가 메일에서 가장 많이 쓰였다는 것을 알 수 있습니다.

 

print(len(word_to_index))

 

 

이렇게 정수 인코딩 된 단어의 개수는 총 8920개 입니다.

 

메일에 매우 다양한 단어들이 들어가 있습니다. 

 

 

이렇게 많은 단어들 중 분명히 거의 안 쓰이는 단어들이 존재할 것입니다.

 

사실 이런 단어들은 스팸 메일인지 판단하는데 크게 기여하지 않기에

 

1번만 등장하는 단어들의 수와 등장 빈도를 알아보고 1번 등장하는 단어는 단어 집합의 크기를 제한하여 제외하도록 하겠습니다.

 

threshold = 2
total_cnt = len(word_to_index) # 단어의 수
rare_cnt = 0 # 등장 빈도수가 threshold보다 작은 단어의 개수를 카운트
total_freq = 0 # 훈련 데이터의 전체 단어 빈도수 총 합
rare_freq = 0 # 등장 빈도수가 threshold보다 작은 단어의 등장 빈도수의 총 합

# 단어와 빈도수의 쌍(pair)을 key와 value로 받는다.
for key, value in tokenizer.word_counts.items():
    total_freq = total_freq + value

    # 단어의 등장 빈도수가 threshold보다 작으면
    if(value < threshold):
        rare_cnt = rare_cnt + 1
        rare_freq = rare_freq + value

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

 

 

등장 빈도가 1번 이하인 단어가 4908개나 됩니다!

 

비율도 무려 55%에 달하기 때문에 제외하는 것이 좋다고 판단됩니다.

 

단어 집합의 크기를 제한하여 등장 빈도가 낮은 단어를 제외할 수 있습니다.

 

tokenizer = Tokenizer(num_words = total_cnt - rare_cnt + 1) #1번 이하로 등장하는 단어는 제외
tokenizer.fit_on_texts(X_data)
sequences = tokenizer.texts_to_sequences(X_data) # 단어를 숫자값, 인덱스로 변환하여 저장

 

num_words를 제한해주고 단어를 정수로 인코딩 된 마지막 3개의 메일을 확인해보겠습니다.

 

마지막 3개를 확인한 이유가 이것 때문입니다.

 

앞 쪽에는 높은 숫자로 인코딩된 단어가 잘 안 보여서 등장 빈도가 낮은 단어가 제외됐는지 확인이 어려웠습니다.

 

print(sequences[-3:-1])

 

 

8918, 8919 등 높은 숫자들은 제외된 것을 확인할 수 있습니다. 

 

 

이제 메일의 최대 길이와 평균 길이를 알아보겠습니다.

 

X_data = sequences
print('메일의 최대 길이 : %d' % max(len(l) for l in X_data))
print('메일의 평균 길이 : %f' % (sum(map(len, X_data))/len(X_data)))
plt.hist([len(s) for s in X_data], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()

 

 

1번 이하로 등장하는 단어를 제외시켰을 때 메일의 최대 183개의 단어로 이루어져 있고 대부분 50 단어 이하로 이루어져 있는 것을 확인할 수 있습니다.

 

vocab_size = vocab_size = total_cnt - rare_cnt + 1
print('단어 집합의 크기: {}'.format((vocab_size)))

 

1번 이하로 등장하는 단어를 제외한 단어 집합의 크기는 4013입니다.

 

 

이제 데이터셋을 훈련 데이터와 테스트 데이터로 나눠보겠습니다.

 

훈련 데이터와 테스트 데이터의 비율은 8:2로 설정했습니다.

 

n_of_train = int(len(sequences) * 0.8)
n_of_test = int(len(sequences) - n_of_train)
print('훈련 데이터의 개수 :',n_of_train)
print('테스트 데이터의 개수:',n_of_test)

 

 

max_len = 183
# 전체 데이터셋의 길이는 max_len으로 맞춥니다.
data = pad_sequences(X_data, maxlen = max_len)
print("훈련 데이터의 크기(shape): ", data.shape)

 

 

5169개의 데이터의 길이를 모두 가장 긴 데이터인 183으로 늘려줍니다.

 

183보다 길이가 짧은 메일의 경우 남는 부분은 모두 0으로 채워집니다.

 

X_test = data[n_of_train:] 
y_test = np.array(y_data[n_of_train:]) 
X_train = data[:n_of_train] 
y_train = np.array(y_data[:n_of_train])

 

이제 훈련 데이터와 테스트 데이터를 모두 나눴습니다.

 

모델을 설계하고 학습/예측/평가를 해보겠습니다.

 

3. RNN 모델 학습/예측/평가

 

RNN 모델은 기본 RNN 모델인 바닐라 RNN을 사용하도록 하겠습니다.

 

from tensorflow.keras.layers import SimpleRNN, Embedding, Dense
from tensorflow.keras.models import Sequential

model = Sequential()
model.add(Embedding(vocab_size, 32)) # 임베딩 벡터의 차원은 32
model.add(SimpleRNN(32)) # RNN 셀의 hidden_size는 32
model.add(Dense(1, activation='sigmoid'))

model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
history = model.fit(X_train, y_train, epochs=4, batch_size=64, validation_split=0.2)

 

 

스팸 메일 분류는 이진 분류 문제이므로 마지막 출력층에는 1개의 뉴런과 시그모이드 함수를 활성화 함수를 사용합니다. 

 

validation_split=0.2 로 설정하여 훈련 데이터의 20%를 검증 데이터로 나누고 교차 검증을 진행합니다.

 

검증 데이터를 통해 모델이 훈련 데이터에 과적합되고 있지 않은지 확인할 수 있습니다.

 

이제 테스트 데이터에 대해 모델의 정확도를 확인해보겠습니다.

 

print("\n 테스트 정확도: %.4f" % (model.evaluate(X_test, y_test)[1]))

 

 

0.9778의 정확도를 보이는 것을 확인할 수 있습니다.

 

괜찮은 결과입니다!

 

 

검증 데이터와 훈련 데이터의 epoch에 따른 loss를 그래프로 시각화하여 적절한 epoch를 구해보도록 하겠습니다.

 

epochs = range(1, len(history.history['acc']) + 1)
plt.plot(epochs, history.history['loss'])
plt.plot(epochs, history.history['val_loss'])
plt.title('model loss')
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train', 'val'], loc='upper right')
plt.show()

 

 

이 데이터셋은 데이터의 수가 적어 비교적 과적합이 빠르게 일어납니다.

 

가장 적절한 epoch가 몇인지를 살펴보면, 검증 데이터의 loss가 증가하기 시작하는 부분인 3이 제일 적절하다고 판단할 수 있습니다.

 

 

 

 

캐글의 스팸 메일 데이터 셋을 이용하고 RNN을 이용하여 다 대 일 모델을 설계하고 학습/예측/평가를 해보았습니다.

 

이번 실습을 진행하면서 불균형한 데이터셋임을 확인하고 Stratified 방식을 통해 훈련 데이터와 테스트 데이터를 분리하려고 생각했는데

 

만약 이렇게 분류하고 토큰화를 진행할 경우 단어에 대한 index가 같지 않을 수 있어 모델의 성능이 저하될 수 있다고 생각했습니다.

 

하지만 우연인지 모르겠지만, 훈련 데이터와 테스트 데이터의 타겟 변수의 라벨 분포가 비슷하게 나눠진 것을 확인했습니다.

 

y_train.sum()/len(y_train)

 

y_test.sum()/len(y_test)

 

Stratified 방식을 사용하여 데이터를 미리 분리하고 토큰화와 정수 인코딩을 하고 모델을 통해 학습/예측/평가하는 방식이 가능한지 고민해 봐야겠습니다.

 

 

 

 

Reference

 

https://wikidocs.net/22894