け日記

SIerから転職したWebアプリエンジニアが最近のIT技術キャッチアップに四苦八苦するブログ

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

前回に引き続き、今回も↓のデータセットを使って、スパムメッセージの分類を行います。

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

TF-IDF

scikit-learnでは、前回使ったCountVectorizer以外に、TF-IDFで特徴量を抽出するTfidfVectorizerが提供されています。

TF-IDFはBag-of-Wordsで得られたベクトルから、以下の計算式で抽出される特徴量です。 tf(w,d)は学習サンプル(文書)dの中で語wが現れる回数、Nは学習サンプル数(文書数)、N_wは語wが現れる学習サンプル数(文書数)です。

tfidf(w, d) = tf(w, d)×\log(\frac{N+1}{N_w+1})+1

つまり、TF-IDFは「ある文書内での出現頻度が高く、かつ、他の文書での出現頻度が低い」語に高い重要度を与えるように重み付けを行う方法です。 たくさんの文書で使われている語は、文書の特徴を表しにくいために、重み付けを小さくする方法とも言い換えられます。
TF-IDFについてはこちらの方の記事が参考になります。

TF-IDFで文書内の単語の重み付け | takuti.me

学習データを使ってfitさせてみますと、"Are you wet right now?“は、0.36、0.22、0.66、0.51、0.33の重みとなっていることがわかります。 また、よく使われるであろう"you"には小さい値、逆に使われる頻度が低いであろう"wet"には大きい値が割り当てられています。 オプションnormでL2正則化を指定しているので、二乗して総和すると1になることも確認できます。

import pandas as pd
pd.set_option("display.max_colwidth", 1000)
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer

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

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

# 学習データとテストデータを1:9で分割
X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.1, random_state=6)

# TfidfVectorizerをL2正則で初期化・学習
vectorizer = TfidfVectorizer(norm='l2')
vectorizer.fit(X_train['v2'])

# 1つを取り出して変換させてみる
print(X_train.ix[X_train.index[0], 'v2'])
# Are you wet right now?
print(vectorizer.transform(X_train.ix[X_train.index[0]]))
#   (0, 2295)  0.223346777887
#   (0, 2206)  0.665796698325
#   (0, 1664)  0.514782102841
#   (0, 1387)  0.334883227937
#   (0, 267)   0.360116069549
print(vectorizer.vocabulary_)
# {'are': 267, 'you': 2295, 'wet': 2206, 'right': 1664, 'now': 1387, ...

TfidfVectorizerを使って、前回同様BernoulliNBで分類させてみます。

TfidfVectorizerでもmin_dfオプションが用意されており、今回はmin_dfに3を指定しています。
結果を見ると、学習データにおいてはCountVectorizerで得られた精度98.2%より低い97.5%でしたが、テストデータにおいては97.0%をマークし、CountVectorizerの精度よりも0.3%だけ上回りました。

from sklearn.naive_bayes import BernoulliNB

# TfidfVectorizerの生成・学習
vectorizer = TfidfVectorizer(min_df=3, norm='l2')
vectorizer.fit(X_train['v2'])
print('Vocabulary size: {}'.format(len(vectorizer.vocabulary_)))
# Vocabulary size: 501

# ベクトル化して特徴量を抽出
X_train_bow = vectorizer.transform(X_train['v2'])
X_test_bow = vectorizer.transform(X_test['v2'])

# ベイズモデルの生成・学習
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.975
# Test accuracy: 0.970

ステミング

トークン化の処理に、ステミング(stemming: 語幹抽出)を加えてみます。

例えば"likes"と"liked"はスペルとしては異なりますが、いずれも元は"like"で、意味的にも同じため、同じ語として特徴抽出できたほうが分類では望ましいです。 そうした単複や時制による語形の違いを除去するのがステミングで、PorterやSnowball、Lancasterなどのアルゴリズムなどの方法が提案されています。

今回はNeural Language Toolkit(nltk)PorterStemmerを使ってステミングしてみます。

nltkのインストール

まずはnltkをインストールします。
また実行に必要なデータは別途ダウンロードが必要で、 nltk.download() でダウンロードする必要があります。 (全体で数GBのファイルサイズになりますので注意してください。)

$ pip install nltk
$ python
>>> import nltk
>>> nltk.download()

PorterStemmer

ダウンロードができたら、word_tokenizeとPorterStemmerをインポートします。

word_tokenizeは文章となっているstringから語を切り出し、PorterStemmerのstemメソッドでステミングします。
この後、TfidfVectorizerと組み合わせるため、クラス化しておきます。

試しにPorterStemmerのコメントの1文をステミングさせてみますと、'has'は'ha'、'original'は'origin'などに変形していることが確認できます。 (変形の結果、実際に存在しない語になることもあります。)

from nltk import word_tokenize
from nltk.stem.porter import PorterStemmer

class PorterTokenizer(object):
    def __init__(self):
        self.porter = PorterStemmer()
        
    def __call__(self, doc):
        return [self.porter.stem(w) for w in word_tokenize(doc)]

tokenizer = PorterTokenizer()
print(tokenizer('Martin Porter has endorsed several modifications to the Porter algorithm since writing his original paper, '))
# ['martin', 'porter', 'ha', 'endors', 'sever', 'modif', 'to', 'the', 'porter', 'algorithm', 'sinc', 'write', 'hi', 'origin', 'paper', ',']

最後にTfidfVectorizerで特徴ベクトル化して、ナイーブベイズで学習・テストさせてみます。

tokenizerオプションでトークン化処理を関数として渡せますので、先程定義したPorterTokenizerをセットします。 また、ステミングによって、同じ語に集約されることが多くなります(被りやすくなる)ので、min_dfも3から6へ大きくします。

テストデータで97.1%の精度となり、ステミング無しの場合よりも0.1%だけ向上しました。

vectorizer = TfidfVectorizer(min_df=6, norm='l2', tokenizer=PorterTokenizer())

vectorizer.fit(X_train['v2'], y_train)

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

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.975
# Test accuracy: 0.971

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

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

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