SIGNATEの「スパムメール分類」問題にチャレンジしてみた

# 0. 概要
- <a href="https://signate.jp/">SIGNATE</a>（日本の機械学習コンペサイト）の練習問題である「<a href="https://signate.jp/competitions/104">スパムメール分類</a>」にチャレンジしてみました。
- BoWを作成してナイーブベイズによって予測したら高い精度が出ました。

# 1. 手順
自然言語処理は機械学習の中でも発展が著しい分野ですが、まずは基本となる知識を習得するのが必要だと考えました。
そんなわけで、基本と思われる「BoW(Bag of Words)」の作成を行い、その結果からスパムメールを分類するということをやってみました。

- 環境：Jupyter Notebook
- notebook同階層に「data」フォルダが存在し、その中にSIGNATEからダウンロードしたファイルを保存
## 1.1. データの取り込み
テキストファイルを読み込んで、pandasのDataFrame形式にしていきます。
```python
# ライブラリのインポート
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

import glob
import os

import collections

# 学習データの取り込み
train_files = pd.DataFrame(columns=['mail'])
for file in glob.glob(f'./data/train/train_*.txt'):
    file_name = os.path.split(file)[1] # パスからファイル名のみを取り出す
    lines = open(file,encoding='utf-8').read()
    train_files.loc[file_name,'mail']=lines

# 取り込んだ学習データにラベルを付加(label==1ならスパム、label==0ならスパムではない)
master = pd.read_csv('./data/train_master.tsv',sep='\t',index_col='file_name')
train_files = train_files.merge(master,left_index=True,right_index=True,how='left') 

# 評価用データの取り込み
test_files = pd.DataFrame(columns=['mail'])
for file in glob.glob(f'./data/test/test_*.txt'):
    file_name = os.path.split(file)[1] # パスからファイル名のみを取り出す
    lines = open(file,encoding='utf-8').read()
    test_files.loc[file_name,'mail']=lines
```
## 1.2. EDA(探索的データ分析)
先ほど取り込んだメールは、「1行目：件名(Subject)」「2行目以降：本文」という形式のようでしたので、件名と本文を分けます。
そしてそれらをグラフにして可視化します。
```python
# 学習データを加工
train_files['subject'] = train_files['mail'].apply(lambda x:x.split('\n')[0])
train_files['body'] = train_files['mail'].apply(lambda x:' '.join(x.split('\n')[1:]))
train_files['counter'] =train_files['body'].apply(lambda x:collections.Counter(x.split(' '))) # スペースごとに単語が分かれているものとして単語の出現数を取得
train_files['length']=train_files['body'].apply(lambda x:len(x)) # 文字数を取得
train_files['words'] = train_files['counter'].apply(lambda x:sum(x.values())) # 単語数の合計を取得

# 評価用データを加工
test_files['subject'] = test_files['mail'].apply(lambda x:x.split('\n')[0])
test_files['body'] = test_files['mail'].apply(lambda x:' '.join(x.split('\n')[1:]))
test_files['counter'] =test_files['body'].apply(lambda x:collections.Counter(x.split(' ')))
test_files['length']=test_files['body'].apply(lambda x:len(x))
test_files['words'] = test_files['counter'].apply(lambda x:sum(x.values()))
```
この辺りはもっとスマートな方法があるかもしれませんが…

```python
#学習データのうちスパムメールの割合を円グラフで表示
plt.pie(train_files['label'].value_counts(),labels=['non-spam','spam'],startangle=90,autopct='%.2f%%',explode=[0,0.05]);
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2656619/d629d306-7118-31a0-5374-a9b3b7a3ddcd.png)
おおよそ3割がスパムのようですね。

```python
# 学習データ
# 文字数によるヒストグラムの作成 x軸のスケールを対数で表示することで見やすく
sns.histplot(x=train_files['length']+0.01,hue=train_files['label'],log_scale=True);

# 単語数によるヒストグラムの作成 x軸のスケールを対数で表示することで見やすく
sns.histplot(x=train_files['words']+0.01,hue=train_files['label'],log_scale=True);

# 評価用データ
sns.histplot(x=test_files['length']+0.01,log_scale=True);
sns.histplot(x=test_files['words']+0.01,log_scale=True);
```
なお、0.01を加算しているのは文字数や単語数が0の場合にエラーになってしまうのを避けるためのものです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2656619/30c96fb3-e5b4-6e88-4766-373836bcf12d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2656619/0b97bc60-72d4-89ec-e575-b2953c2fcf9f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2656619/b0102b13-9702-054f-27bc-2246b1a053e8.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2656619/53d115b3-48c8-f666-bcba-6a79349d8b4f.png)

このグラフから
- 本文が0文字のメールはスパムっぽい
- 100文字弱のメールはスパムではなさそう
- それ以上の文字数だと、スパムもそうでないメールも似たような分布
といったような傾向が見て取れます。
また、学習データと評価用データの分布に大きな差がないこともグラフの形状からわかります。

## 1.3. BoW(Bag of Wordsの作成)
ここから、BoWを作成していきます。

```python
counter = pd.DataFrame()
for idx in train_files.index:
    buf = pd.Series((train_files.loc[idx,'counter']))
    buf = pd.DataFrame(buf)
    buf['file'] = idx
    buf['label'] = 'train_' + train_files.loc[idx,'label'].astype('str')
    counter = pd.concat([counter,buf],axis=0)
for idx in test_files.index:
    buf = pd.Series((test_files.loc[idx,'counter']))
    buf = pd.DataFrame(buf)
    buf['file'] = idx
    buf['label'] = 'test'
    counter = pd.concat([counter,buf],axis=0)
    
counter = counter.reset_index()
counter = counter.rename(columns={0:'count'},)
```
データの取り込みのときに作っていた「counter」列の情報を1つのDataFrameにまとめます。
以下のようなDataFrameができ上がります。（なお、単語やcount数は実データではありません）
また、label列は後の分類では別のところから持ってくるので必要ではないのですが、このデータで分析を行うために付加しています。
| (id) | index | count | file | label |
|---|---|--:|:--|--:|
| 0  |the|4|train_0000.txt|0|
| 1  |is|3|train_0000.txt|0|
|...|...   |...   |...   |...   |
|nnnn|is|9|train_xxxx.txt|1|
|...|...   |...   |...   |...   |

ここからBoWを作成します。
```python
bow = counter.pivot_table(index='file',columns='index',values='count',aggfunc='sum').fillna(0)
train_bow = bow[bow.index.str.contains('train_')]
test_bow = bow[bow.index.str.contains('test_')]
```
先ほど作成したcounterをpivot_tableによって「行：file、列：単語」という形式に変換しています。
念のため、DataFrameの形状（＝ファイル数と単語数）を確認しておきましょう。
```python
print(train_bow.shape)
print(test_bow.shape)
```
```python
(2586, 49823)
(2586, 49823)
```
5万種類近くの単語があるようですね。実際には記号なども多く含まれているのですが、今回はそのまま推論することにしてみます。

## 1.4. 推論
```python
from sklearn.naive_bayes import MultinomialNB
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from sklearn.metrics import f1_score
from sklearn.metrics import confusion_matrix

# 正解ラベルデータの作成
y_train = train_files['label']
# 学習データを学習用と検証用に分割
X_tr,X_val,y_tr,y_val = train_test_split(train_bow,y_train,test_size=0.25)
nb = MultinomialNB()
nb.fit(X_tr,y_tr)
# 正解率やf1 scoreの表示
print(f'学習用データの正解率：{accuracy_score(y_tr,y_tr_pred):.4f}')
print(f'検証用データの正解率：{accuracy_score(y_val,y_val_pred):.4f}')
print(f'学習用データのf1 score：{f1_score(y_tr,y_tr_pred):.4f}')
print(f'検証用データのf1 score：{f1_score(y_val,y_val_pred):.4f}')
# 混同行列の表示
print(confusion_matrix(y_tr,y_tr_pred))
print(confusion_matrix(y_val,y_val_pred))
```
```python
# 評価用データで推論を実行、保存
y_test_pred = nb.predict(test_bow)
nb_pred = pd.Series(y_test_pred,index=test_bow.index)
nb_pred.to_csv('./data/nb_pred.csv',index=True,header=None)
```

今回の評価指標はf1 scoreでしたが、おおよそ0.96程度となりました。これだけでかなり高い精度が出るようです。

# 2. おわりに
スパムメールの分類に対して、古典的な手法を用いて推論を行いましたが、思いの外高い精度となったようです。BERTなど高度な手法を用いたらうまくいくのかどうか、引き続き勉強したいと思います。