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で学ぶ特徴量エンジニアリングと機械学習の基礎