자연어처리(NLP) 17일차 (Spam Detection)

정민수
12 min readJun 20, 2019

--

2019.06.20

출처 : https://wikidocs.net/book/2155

핵심키워드

  • RNN
  • Early Stopping

스팸 메일 분류하기(Spam Detection)

이번 챕터에서는 캐글에서 제공하는 정상 메일과 스팸 메일 데이터를 가지고, 데이터에 대한 전처리를 진행하고 바닐라 RNN(Vanilla RNN)을 이용한 스팸 메일 분류기를 구현해보도록 한다.

1. 스팸 메일 데이터에 대한 이해

데이터 다운로드 링크 : https://www.kaggle.com/uciml/sms-spam-collection-dataset

import pandas as pd
import numpy as np
data = pd.read_csv('spam.csv',encoding='latin1')
data.head()

데이터 중에서 5개의 행만 출력했다. 이 데이터에는 총 5개의 열이 있는데, 여기서 Unnamed라는 이름의 3개의 열은 텍스트 분류 시, 불필요한 열이다. v1열은 해당 메일이 스팸인지 아닌지를 나타내느 레이블에 해당된다. ham은 정상 메일이고, spam은 스팸 메일을 의미한다. v2열은 메일의 본문을 담고 있다.

레이블과 메일 내용이 담긴 v1열과 v2열만 필요하므로, Unnamed: 2, Unnamed: 3, Unnamed: 4 열은 삭제한다. 또한, v1 열에 있는 ham과 spam 레이블을 각각 숫자 0과 1로 바꿔준다. 다시 data에서 5개의 행만 출력해보자.

del data['Unnamed: 2']
del data['Unnamed: 3']
del data['Unnamed: 4']
data['v1'] = data['v1'].replace(['ham', 'spam'],[0,1])
data.head()

불필요한 열이 제거되고, v1열의 값이 숫자로 변환된 것을 확인할 수 있다. 해당 data의 정보를 확인해보자.

data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 5572 entries, 0 to 5571
Data columns (total 2 columns):
v1 5572 non-null int64
v2 5572 non-null object
dtypes: int64(1), object(1)
memory usage: 87.1+ KB

v1열은 정수형 벡터, v2열은 문자열 데이터를 갖고 있다. 데이터 중에 혹시 Null 데이터가 있는지는 Pandas의 isnull().values.any()로도 확인 가능하다.

data.isnull().values.any()
False

False는 별도의 Null값이 없음을 의미한다. 초기 데이터에 ‘Unnamed: 2, 3, 4’ 열에는 NaN 값이 있었는데 만약 그러한 값이 없다면 isnull().values.any()는 True를 리턴한다. 이제 스팸 메일 유무가 기재되어 있는 레이블 값의 분포를 보도록 하자.

import matplotlib.pyplot as plt
% matplotlib inline
data['v1'].value_counts().plot(kind='bar')
plt.show()

데이터의 대부분이 0에 편중되어있는데, 이는 전체 데이터의 대부분이 정상 메일임을 의미한다. 이제 X와 y를 분리해보도록 한다. 정확히는 v2열을 X, v1열을 y로 저장하기만 하면 된다.

X_data = data['v2']
y_data = data['v1']
print(len(X_data))
print(len(y_data))
5572
5572

이제 메일의 본문은 X_data에 각 메일의 본문에 대한 1과 0의 값을 가진 레이블은 y_data에 저장하였다. 데이터의 개수는 둘 다 5,572개다. 이제 토큰화와 정수 인코딩 과정을 수행해보도록 하자.

from keras.preprocessing.text import Tokenizer
tokenizer = Tokenizer()
tokenizer.fit_on_texts(X_data)
# 5572개의 행을 가진 x의 각 행에 토큰화를 수행
sequences = tokenizer.texts_to_sequences(X_data)
# 단어를 숫자값, 인덱스로 변환하여 저장
print(sequences[:5])
[[50, 469, 4410, 841, 751, 657, 64, 8, 1324, 89, 121, 349, 1325, 147, 2987, 1326, 67, 58, 4411, 144], [46, 336, 1495, 470, 6, 1929], [47, 486, 8, 19, 4, 796, 899, 2, 178, 1930, 1199, 658, 1931, 2320, 267, 2321, 71, 1930, 2, 1932, 2, 337, 486, 554, 955, 73, 388, 179, 659, 389, 2988], [6, 245, 152, 23, 379, 2989, 6, 140, 154, 57, 152], [1018, 1, 98, 107, 69, 487, 2, 956, 69, 1933, 218, 111, 471]]

sequences에는 X_data의 단어들이 각 단어에 맵핑되는 정수로 인코딩되어 저장되었다. 5572개의 행이 있으나, 5개의 행만 출력했다.

각 행에는 단어가 아니라 단어에 대한 인덱스가 부여된 것을 확인할 수 있다.

word_index = tokenizer.word_index
print(word_index)
{'i': 1, 'to': 2, 'you': 3, 'a': 4, 'the': 5, 'u': 6, 'and': 7, 'in': 8, 'is': 9, 'me': 10, ...
8915, '087187272008': 8916, 'now1': 8917, 'pity': 8918, 'suggestions': 8919, 'bitching': 8920}

무수히 많은 단어가 등장했기 때문에 출력 결과는 중간에 생략했다.

print(len(word_index))
8920

X_data에는 총 8,920개의 단어가 있음을 확인할 수 있다.

n_of_train = int(5572*0.8)
n_of_test = int(5572 - n_of_train)
print(n_of_train)
print(n_of_test)
4457
1115

앞서 확인하였는데 전체 데이터는 5,572개이다. 이제 전체 데이터에서 일부는 테스트 데이터로 분리해야한다. 전체 데이터의 80%를 훈련 데이터로, 20%를 테스트 데이터로 사용하고자 한다. 숫자를 계산해보았더니 훈련 데이터는 4,457개, 테스트 데이터는 1,115개를 쓰도록 한다. 아직은 단순히 숫자를 계산만해본 것이고, 실제로 데이터를 나누지는 않았다.

직관적으로 변수를 기억하기위해 X_data에 대해서 정수 인코딩된 결과인 sequences를 X_data로 변경하고, 전체 데이터에서 가장 길이가 긴 데이터와 전체 데이터의 길이 분포를 알아보도록 하자.

X_data = sequences
print(max(len(l) for l in X_data))
189
plt.hist([len(s) for s in X_data], bins=50)
plt.xlabel('length of Data')
plt.ylabel('number of Data')
plt.show()

가장 긴 데이터의 길이는 189이며, 전체 데이터의 길이 분포는 대체적으로 약 50이하의 길이를 가지는 것을 볼 수 있다. 이제 본격적으로 바닐라 RNN을 이용하여 스팸 메일 분류기를 만들어보도록 하자.

2. RNN으로 스팸 메일 분류하기

from keras.layers import SimpleRNN, Embedding, Dense, LSTM
from keras.models import Sequential
from keras.preprocessing.sequence import pad_sequences
vocab_size = len(word_index)+1
# 단어의 수
max_len = 189
# 전체 데이터의 길이는 189로 맞춤
data = pad_sequences(X_data, maxlen=max_len)
print("data shape: ", data.shape)
data shape: (5572, 189)

maxlen에는 가장 긴 데이터의 길이였던 189라는 숫자를 넣었다. 이는 5,572개의 X_data의 길이를 전부 189로 바꾼다. 189의 길이가 되지 않는 데이터는 전부 숫자 0이 패딩된다.

이제 X_data 데이터는 5,572 × 189의 모양을 갖게된다. 헷갈리지 말아야할 것은 X_train와 X_test를 분리하지 않았다는 것이다.

X_test = data[n_of_train:]
y_test = y_data[n_of_train:]
X_train = data[:n_of_train]
y_train = y_data[:n_of_train]

이제 모델을 설계해보자.

model = Sequential()
model.add(Embedding(vocab_size, 32))
# 임베딩 벡터의 차원은 32
model.add(SimpleRNN(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=60,
validation_split=0.2)

Embedding()은 두 개의 인자를 받는다. 단어 집합의 크기, 임베딩 벡터의 차원이다. 그 후 이진 분류(Binary Classification) 문제이므로 마지막 출력층에는 1개의 뉴런과 활성화 함수로 시그모이드 함수를 사용한다. 손실 함수로는 bianry_crossentropy를 사용한다.

validation_split = 0.2를 주어서 훈련 데이터의 20%를 다시 검증 데이터로 나누고, 검증 데이터를 보면서 훈련이 제대로 되고 있는지 확인해보도록 하자. 검증 데이터는 기계가 훈련 데이터에 심하게 과적합 되고 있지는 않은지 확인 하기 위한 용도로 사용된다. 총 4번 학습한다.

Train on 3565 samples, validate on 892 samples
Epoch 1/4
3565/3565 [==============================] - 2s 685us/step - loss: 0.5127 - acc: 0.7714 - val_loss: 0.4507 - val_acc: 0.9585
Epoch 2/4
3565/3565 [==============================] - 2s 594us/step - loss: 0.2024 - acc: 0.9633 - val_loss: 0.1183 - val_acc: 0.9742
Epoch 3/4
3565/3565 [==============================] - 2s 589us/step - loss: 0.0846 - acc: 0.9778 - val_loss: 0.0801 - val_acc: 0.9787
Epoch 4/4
3565/3565 [==============================] - 2s 590us/step - loss: 0.0527 - acc: 0.9857 - val_loss: 0.0736 - val_acc: 0.9753

이제 테스트 데이터에 대해서 정확도를 확인해보자.

print("\n 테스트 정확도 : %.4f" % (model.evaluate(X_test, y_test)[1]))
1115/1115 [==============================] - 0s 242us/step

테스트 정확도 : 0.9812

정확도 98%가 나왔다. 이번 실습에서는 훈련 데이터와 검증 데이터에 대해서 같이 정확도를 확인하면서 훈련하였으므로, 이를 비교하여 그래프로 시각화해본다.

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 left')
plt.show()

본 실습 데이터는 데이터의 양이 적어 과적합이 빠르게 시작되므로, 검증 데이터에 대한 오차가 증가하기 시작하는 시점의 바로 직전인 3~4 에포크 정도가 적당하다. 이 데이터는 에포크 5를 넘어가기 시작하면 검증 데이터의 오차가 증가하는 경향이 있다. 이에 에포크 4에서 학습을 종료하였고 이는 과적합을 막는 방법 중 하나인 조기 종료(Early stopping)에 해당된다. 조기 종료 시점을 정하는 것은 전적으로 모델 설계자의 판단에 달렸다.

--

--

정민수
정민수

No responses yet