scikit-learnでスパムメッセージを分類する(CountVectorizer + BernoulliNB)

scikit-learnを使ってナイーブベイズでスパムメッセージを分類してみます。

データセットのロード

今回はUCIで提供されているSMS Spam Collection Data Setを使います。 データセット全体で5572サンプル(内スパムは747)からなり、各サンプルはSMSのメッセージ本文とそれがスパムかどうかの2項目のみを持ちます。

UCI Machine Learning Repository: SMS Spam Collection Data Set SMS Spam Collection Dataset | Kaggle

CSVファイル(spam.csv)でダウンロードし、本文をX、スパムかどうか(目的変数)をyにそれぞれ抽出します。

import pandas as pd

original_df = pd.read_csv('spam.csv', encoding='latin-1')
# ゴミカラムの除去
original_df.drop(['Unnamed: 2', 'Unnamed: 3', 'Unnamed: 4'], axis=1, inplace=True)

original_df['v1'].value_counts()
# ham     4825
# spam     747

X = pd.DataFrame(original_df['v2'])
y = original_df['v1'].apply(lambda s: 1 if s == 'spam' else 0) # 目的変数(スパムなら1)

Bag-of-Words

Xにはまだ生の文章が入っているので、ここから特徴量を抽出する必要があります。

Bag-of-Wordsは学習サンプルに現れた語の出現頻度(どの語がいくつ現れたか)をベクトル化した特徴量です。 学習サンプルに現れた全ての語の種類数が、特徴量の数となるため、各サンプルは極めて疎なベクトルとなります。

Bag-of-Wordsを抽出する方法として、scikit-learnではCountVectorizerが提供されています。

学習データでfitした後、 vocabulary_ にアクセスして語の種類数をカウントすると2284となっており、例えば'buy'や'space'が出現していることがわかります(後ろの数字は辞書順のソート番号になっており、'buy'であれば414番目になります)。 このCountVectorizerを使って、transformで文章をベクトル化すると、2284の特徴量を持ったnumpy行列に変換されます。

from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import CountVectorizer

# 学習データ(557サンプル)とテストデータ(5015サンプル)の分離
X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.1)

# CoutVectorizer
vectorizer = CountVectorizer()
vectorizer.fit(X_train['v2'])

print('Vocabulary size: {}'.format(len(vectorizer.vocabulary_)))
print('Vocabulary content: {}'.format(vectorizer.vocabulary_))
# Vocabulary size: 2284
# Vocabulary content: {'buy': 414, 'space': 1832, 'invaders': 1048, 'chance': 463, 'win': 2199, 'orig': 1445, 'arcade': 276, ...

# 文章を特徴ベクトル化
X_train_bow = vectorizer.transform(X_train['v2'])
X_test_bow = vectorizer.transform(X_test['v2'])

print('X_train_bow:\n{}'.format(repr(X_train_bow)))
print('X_test_bow:\n{}'.format(repr(X_test_bow)))
# X_train_bow:
# <557x2284 sparse matrix of type '<class 'numpy.int64'>'
#  with 7471 stored elements in Compressed Sparse Row format>
# X_test_bow:
# <5015x2284 sparse matrix of type '<class 'numpy.int64'>'
#  with 53413 stored elements in Compressed Sparse Row format>

ナイーブベイズによる分類

得られた特徴ベクトルからナイーブベイズで学習・テストさせてみます。

scikit-learnではいくつかナイーブベイズの実装が有りますが、今回は分類タスクに使われるBernoulliNBで学習・テストを行います。

結果としては、学習データでは精度97.8%と高い値をマークしましたが、テストデータでは92.0%となっているため、過学習の傾向が見られます。

from sklearn.naive_bayes import BernoulliNB

model = BernoulliNB()
model.fit(X_train_bow, y_train)

print('Train accuracy: {:.3f}'.format(model.score(X_train_bow, y_train)))
print('Test accuracy: {:.3f}'.format(model.score(X_test_bow, y_test)))
# Train accuracy: 0.978
# Test accuracy: 0.920

CountVectorizerのチューニング

min_df

CountVectorizerの vocabulary_ を見ていると意味のない語が多いことがわかります。 例えば、 080008394021250 といった数字や、maybundrstnd といったおそらくスペルミスなど、特定の文章にしか出てこない固有の語が含まれており、こうした語は未知のデータでも現れない可能性が高いため、分類には役に立たず、過学習を進めてしまいます。

CountVectorizerには min_df というオプションが用意されています。 min_dfは整数で設定し、学習データ全体で現れたサンプル数がmin_df未満の語については特徴ベクトルからは除外する、というものです。

ここでは3を設定しており、3サンプル以上現れた語のみが特徴量となります。 結果として、語数は2284から504まで減り、テストデータの精度も4.7%改善しました(92.0%→96.7%)。

vectorizer = CountVectorizer(min_df=3)
vectorizer.fit(X_train['v2'])

print('Vocabulary size: {}'.format(len(vectorizer.vocabulary_)))
# Vocabulary size: 504

X_train_bow = vectorizer.transform(X_train['v2'])
X_test_bow = vectorizer.transform(X_test['v2'])

model.fit(X_train_bow, y_train)

print('Train accuracy: {:.3f}'.format(model.score(X_train_bow, y_train)))
print('Test accuracy: {:.3f}'.format(model.score(X_test_bow, y_test)))
# Train accuracy: 0.982
# Test accuracy: 0.967

ストップワード

また stop_words というオプションもあり、ストップワードを指定することができます。 ストップワードは、文法的に特別な意味を持っている語など、分類にかかわらず多くの文章で出現する語(例えば"and"、"you"、"has"など)のことで、これらも分類の役に立たないため、特徴量から除去するのが一般的です。 stop_wordsには除去したい文字列リストの他、'engilish'を渡すと付属のストップワードを除去できます。

学習データに適用してみますと、語数は2284から2110まで減らせました。 get_stop_words()ストップワードの一覧が取得できます(“becomes"や"go"などが含まれています)。

ただし、ストップワードを除去した特徴量では、学習データ・テストデータともに精度は落ちてしまいました。 おそらく非スパムメッセージに現れやすい語(例えば"I"や"my")がストップワードとして除去されてしまったためと思われます。

vectorizer = CountVectorizer(stop_words='english')
vectorizer.fit(X_train['v2'])

print('Vocabulary size: {}'.format(len(vectorizer.vocabulary_)))
print('Stop words: {}'.format(vectorizer.get_stop_words()))
# Vocabulary size: 2110
# Stop words: frozenset({'becomes', 'go', 'former', 'describe', 'eight', 'himself', ...

X_train_bow = vectorizer.transform(X_train['v2'])
X_test_bow = vectorizer.transform(X_test['v2'])

model.fit(X_train_bow, y_train)

print('Train accuracy: {:.3f}'.format(model.score(X_train_bow, y_train)))
print('Test accuracy: {:.3f}'.format(model.score(X_test_bow, y_test)))
# Train accuracy: 0.955
# Test accuracy: 0.892

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

Visual Studio CodeでFlaskをデバッグする環境を作る on Mac

仕事でFlaskを使ったアプリケーションを作る機会があり、Visual Studio Code(VSCode)で環境を整えましたので、その備忘録です。

前提

VS Code、Python、Flaskはインストールしておいてください。

$ python --version
Python 3.6.0 :: Anaconda custom (x86_64)
$ pip list
・・・
Flask (0.12)
・・・

拡張機能のインストールと設定

VS CodeにPython拡張機能をインストールします。

https://marketplace.visualstudio.com/items?itemName=donjayamanne.python

プロジェクトフォルダとプログラムの作成

プロジェクト用の空フォルダを作成し、Pythonファイルをapp.pyという名前で作成します。 /でアクセスすると"Hello World!“とだけ表示されるFlaskアプリケーションです。

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World!"

if __name__ == '__main__':
    app.debug = True
    app.run(host='localhost', port=5000)

デバッグ構成ファイルの編集

VSCodeのメニューから[デバッグ]>[構成を開く]でlaunch.jsonを開いて、Flaskの設定を編集します。 configurations内の"name": "Flask"を編集します。 無ければ以下のように追加してください。

ここではプロジェクトフォルダ直下のapp.pyを実行するので、programenvのFLASK_APP環境変数で、${workspaceRoot}/app.pyを指定します。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Flask",
            "type": "python",
            "request": "launch",
            "stopOnEntry": false,
            "pythonPath": "${config:python.pythonPath}",
            "program": "${workspaceRoot}/app.py",
            "cwd": "${workspaceRoot}",
            "env": {
                "FLASK_APP": "${workspaceRoot}/app.py"
            },
            "args": [
                "run",
                "--no-debugger",
                "--no-reload"
            ],
            "envFile": "${workspaceRoot}/.env",
            "debugOptions": [
                "WaitOnAbnormalExit",
                "WaitOnNormalExit",
                "RedirectOutput"
            ]
        }
    ]
}

デバッグ実行

先ほど作成したapp.pyデバッグ実行します。

VSCodeデバッグウィンドウに切り替えて、構成に"Flask"を選択します。

あとはF5でデバッグ実行されるようになります。

ブレークポイントを張ることもでき、デバッグウィンドウで変数の値を覗いたりもできます。 また、Visual Studioのイミディエイトウィンドウように、デバッグコンソールでコードを実行することもできます。

f:id:ohke:20170831085815p:plain

scikit-learnのロジスティック回帰で特定のクラスに分類されやすくする

クラスに偏りがあるデータセットを使って、分類確率の閾値を変えることで、一方のクラスに分類されやすくします。

predict_probaでクラスの分類確率を見る

前回の投稿と同様に、kaggleで提供されているCredit Card Fraud Detectionデータセットをダウンロードして使います。

今回はロジスティック回帰で学習・テストさせてみます。

精度は99.92%ですが、クラスの偏りが極めて大きいため、混合行列を見ると陰性(不正利用ではない、クラス0)と判定されやすくなっています(偽陽性サンプル数は20なのに対して、偽陰性サンプル数は99)。

import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix

# CSVファイルをDataFrameへロード
original_df = pd.read_csv('creditcard.csv')

# 説明変数Xと目的変数yの抽出
X = original_df.loc[:, 'V1':'Amount']
y = original_df.loc[:, 'Class':]

# 学習データとテストデータの分離
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5, random_state=0)

# ロジスティック回帰で学習
lrc = LogisticRegression(random_state=0)
lrc.fit(X_train, y_train)

# スコアと混合行列
print('Train score: {:.4f}'.format(lrc.score(X_train, y_train)))
print('Test score: {:.4f}'.format(lrc.score(X_test, y_test)))
print('Confusion matrix:\n{}'.format(confusion_matrix(y_test, lrc.predict(X_test))))
# Train score: 0.9992
# Test score: 0.9992
# Confusion matrix:
# [[142141     20]
#  [    99    144]]

sklearn.linear_model.LogisticRegressionでは、各クラスに属する確率がサンプル毎に取得できるpredict_probaメソッドが提供されています。

例えば以下の場合、1つ目のサンプルがクラス0(不正利用でない)に分類される確率が0.999398754、クラス1(不正利用である)に分類される確率が0.00061246232となります。

lrc.predict_proba(X_test)
# array([[  9.99398754e-01,   6.01246232e-04],
#        [  9.99651994e-01,   3.48005500e-04],
#        [  9.99654291e-01,   3.45709049e-04],
#        ..., 
#        [  9.99226384e-01,   7.73615876e-04],
#        [  9.99736105e-01,   2.63895411e-04],
#        [  9.99347665e-01,   6.52334777e-04]])

2クラス分類の場合、サンプルの分類確率が0.5を以上のクラスで分類されます(クラス0の確率が0.6、クラス1の確率が0.4のサンプルは、クラス0と予測されます)。

この閾値を変えることで、一方のクラスをより分類されやすくなるように操作できます。 例えば下記のように閾値を0.3にすると、0.3より大きければクラス1に分類されるようになり、先程の結果よりも偽陰性が減少(99→82)していることがわかります(一方で偽陽性は20から36まで増えています)。

# 閾値を0.3に設定
y_pred = (lrc.predict_proba(X_test)[:, 1] > 0.3).astype(int)

print('Confusion matrix:\n{}'.format(confusion_matrix(y_test,  y_pred)))
# Confusion matrix:
# [[142125     36]
# [    82    161]]

適合率-再現率曲線を描く

では、この閾値をどうやって決めれば良いでしょうか。

この閾値を少しずつ変化させ、ある閾値の時の適合率(precision: 真陽性数/(真陽性数+偽陽性数))と再現率(recall: 真陽性数/(真陽性+偽陰性数))を計算するツールとして、sklearn.metrics.precision_recall_curveが提供されています。

x軸を適合率、y軸を再現率として、閾値による変化をプロットしてみます(適合率-再現率曲線)。

import numpy as np
from sklearn.metrics import precision_recall_curve

# ある閾値の時の適合率、再現率の値を取得
precision, recall, threshold = precision_recall_curve(y_test, lrc.predict_proba(X_test)[:, 1])

# 0から1まで0.05刻みで○をプロット
for i in range(21):
    close_point = np.argmin(np.abs(threshold - (i * 0.05)))
    plt.plot(precision[close_point], recall[close_point], 'o')

# 適合率-再現率曲線
plt.plot(precision, recall)
plt.xlabel('Precision')
plt.ylabel('Recall')

plt.show()

プロットされた図を見ると、左上(閾値0.00の時は再現率100%)から右下(閾値1.00の時は適合率100%)に向かっており、閾値が高くなると、再現率が下がって適合率が上がる、というトレード・オフの関係が見えます。 適合率・再現率のバランスが良さそうな点は0.05(左上から2つ目の○)〜0.10(左上から3つ目の点)にありそうです。

f:id:ohke:20170819094452p:plain

閾値を0.1に変えてみますと、偽陰性数は61まで減少しました。 (こういった閾値の調査は、他のハイパーパラメータ同様、本来は学習・テストとは異なるサンプルを使うべきです。)

y_pred = (lrc.predict_proba(X_test)[:, 1] > 0.1).astype(int)
print('Confusion matrix:\n{}'.format(confusion_matrix(y_test,  y_pred)))
# Confusion matrix:
# [[142111     50]
#  [    61    182]]