3章 クラスタリング:関連のある文書を見つける

この章でやりたいことは,ある新しい文書に対して類似する文書はどれかを素早く見つけ出すこと.
そのために,教師なし学習であるクラスタリングを行う.
SciKitライブラリにある手法を使えば実現出来る.

3.1 文書の関連性を推測する

3.1.1 やってはいけないこと

テキストデータの類似度はレーベンシュタイン距離(編集距離)で求めることが出来る.
例えば,「machine」と「mchiene」という単語の距離は2.
また,文章内の単語を最小単位として,文章動詞の編集距離を計算することも出来る.
「How to format my hard disk」と「Hard disk format problems」の距離は5.
(how, to, format, myを削除して,format, problemsを追加する)
しかし,この方法は効率が悪い.

3.1.2 どうやるべきか

単語の出現回数を数えるというbag-of-wordsを特徴量に使う.
これにより文書をベクトル化出来るので,距離が計算出来るしクラスタリングも出来る.

3.2 前処理:共通する単語の出現回数を類似度として計測する

3.2.1 テキストデータをbag-of-wordに変換する

単語の出現回数を数えてベクトル表記する. ScikitのCountVectorizerを使う.


In [1]:
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(min_df=1)

上記min_dfパラメタの値より出現回数の小さい単語は無視される.
他にどんなパラメタがあるかは下記の通り.


In [2]:
print(vectorizer)


CountVectorizer(analyzer=u'word', binary=False, decode_error=u'strict',
        dtype=<type 'numpy.int64'>, encoding=u'utf-8', input=u'content',
        lowercase=True, max_df=1.0, max_features=None, min_df=1,
        ngram_range=(1, 1), preprocessor=None, stop_words=None,
        strip_accents=None, token_pattern=u'(?u)\\b\\w\\w+\\b',
        tokenizer=None, vocabulary=None)

試しにベクトル化してみる.


In [3]:
content = ["How to format my hard disk", " Hard disk format problems "]
X = vectorizer.fit_transform(content)
vectorizer.get_feature_names()


Out[3]:
[u'disk', u'format', u'hard', u'how', u'my', u'problems', u'to']

In [4]:
print(X.toarray().transpose())


[[1 1]
 [1 1]
 [1 1]
 [1 0]
 [1 0]
 [0 1]
 [1 0]]

3.2.2 単語を数える


In [5]:
import os
TOY_DIR = "/Users/Atsushi/Desktop/BuildingMachineLearningSystemsWithPython/ch03/data/toy"
posts = [open(os.path.join(TOY_DIR, f)).read() for f in os.listdir(TOY_DIR)]
posts


Out[5]:
['This is a toy post about machine learning. Actually, it contains not much interesting stuff.',
 'Imaging databases provide storage capabilities.',
 'Most imaging databases save images permanently.\n',
 'Imaging databases store data.',
 'Imaging databases store data. Imaging databases store data. Imaging databases store data.']

In [6]:
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(min_df=1)

X_train = vectorizer.fit_transform(posts)
num_samples, num_features = X_train.shape
print("#samples: %d, #features: %d" % (num_samples, num_features))


#samples: 5, #features: 25

In [7]:
print(vectorizer.get_feature_names())


[u'about', u'actually', u'capabilities', u'contains', u'data', u'databases', u'images', u'imaging', u'interesting', u'is', u'it', u'learning', u'machine', u'most', u'much', u'not', u'permanently', u'post', u'provide', u'save', u'storage', u'store', u'stuff', u'this', u'toy']

新しい文書のベクトル化は以下のようにする.
大抵が疎なベクトルなので,出現した単語のみの情報だけをデータに格納する.


In [32]:
new_post = "imaging databases"
new_post_vec = vectorizer.transform([new_post])
print(new_post_vec)


  (0, 4)	1
  (0, 5)	1

toarray()メソッドを用いることで,特徴ベクトルの全ての要素を表示することができる.


In [9]:
print(new_post_vec.toarray())


[[0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]]

新しい文書と他の既存の文書の類似度(ユークリッド距離)を計算する.


In [10]:
import scipy as sp
def dist_raw(v1, v2):
    delta = v1 - v2
    return sp.linalg.norm(delta.toarray())

In [11]:
dist = dist_raw


In [33]:
import sys
best_dist = sys.maxsize
best_i = None

for i in range(0, num_samples):
    post = posts[i]
    if post == new_post:
        continue
    post_vec = X_train.getrow(i)
    d = dist(post_vec, new_post_vec)
    print("=== Post %i with dist=%.2f: %s" % (i, d, post))
    if d < best_dist:
        best_dist = d
        best_i = i
print("Best post is %i with dist=%.2f" % (best_i, best_dist))


=== Post 0 with dist=1.41: This is a toy post about machine learning. Actually, it contains not much interesting stuff.
=== Post 1 with dist=0.86: Imaging databases provide storage capabilities.
=== Post 2 with dist=0.63: Most imaging databases save images permanently.

=== Post 3 with dist=0.77: Imaging databases store data.
=== Post 4 with dist=0.77: Imaging databases store data. Imaging databases store data. Imaging databases store data.
Best post is 2 with dist=0.63

文書3と文書4を見比べる.
文書4は文書3を3回繰り返しただけなので,新しい文書に対しての類似度は,その二つの文書で同じであるべきでしょう.(ほんと?)


In [13]:
print(X_train.getrow(3).toarray())
print(X_train.getrow(4).toarray())


[[0 0 0 0 1 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0]]
[[0 0 0 0 3 3 0 3 0 0 0 0 0 0 0 0 0 0 0 0 0 3 0 0 0]]

単語の出現頻度だけを特徴量として用いるのは単純すぎる.
特徴ベクトルを単位長さにするために,正規化する必要がある.

3.2.3 単語の出現回数ベクトルを正規化する

dist_raw関数を拡張して,単語の出現頻度からなるベクトルではなく,正規化したベクトルを返すようにする.


In [14]:
def dist_norm(v1, v2):
    v1_normalized = v1 / sp.linalg.norm(v1.toarray())
    v2_normalized = v2 / sp.linalg.norm(v2.toarray())
    delta = v1_normalized - v2_normalized
    return sp.linalg.norm(delta.toarray())

In [15]:
dist = dist_norm

上記スクリプト(※)でもう一度類似度を計算する.
今度は文書3と文書4は同じ類似度になっている.

3.2.4 重要度の低い単語を取り除く

文書2に含まれるmostのような単語は分野に関係なく様々な文章で登場する.
そのような単語は,imagesのような特定の分野で登場しやすい単語と比べて情報を持たず,文章の分類に貢献しない.
そのため,ストップワード(stop word)として処理の対象外とすべき.


In [16]:
vectorizer = CountVectorizer(min_df=1, stop_words='english')

stop_words='english'とすることで,318個の単語をストップワードとして登録出来る.
どのような単語がストップワードとして登録されるかを見るには以下のようにする.


In [18]:
print(sorted(vectorizer.get_stop_words())[0:20])


['a', 'about', 'above', 'across', 'after', 'afterwards', 'again', 'against', 'all', 'almost', 'alone', 'along', 'already', 'also', 'although', 'always', 'am', 'among', 'amongst', 'amoungst']

3.2.5 ステミング(stemming)

今は,意味的には同じ単語が語形変化により異なる単語としてカウントされている.(imaging, imagesなど)
これらは同じ単語としてカウントすべき.
Scikitには標準ではステミングを行う機能は含まれていない.
Natural Language Tooklit(NLTK)を用いることで出来る.

NLTKには異なる種類のステマーがあるが,例えば英語の場合,SnowballStemmerを用いる.


In [23]:
import nltk.stem
s= nltk.stem.SnowballStemmer('english')
print(s.stem("graphics"))
print(s.stem("imaging"))
print(s.stem("image"))
print(s.stem("imagination"))
print(s.stem("imagine"))


graphic
imag
imag
imagin
imagin

上記の例でわかるように,ステミングの結果は必ずしも正しい英単語になるとは限らない.
動詞については,次のような結果になる.


In [24]:
print(s.stem("buys"))
print(s.stem("buying"))
print(s.stem("bought"))


buy
buy
bought

NLTKのステマーを用いて,ベクトル化を拡張する

文書をCountVectorizerに入力する前に,ステミングを行う必要がある.
ここでは,次のようにbuild_analyzerを上書きすることで対応する.


In [25]:
import nltk.stem
english_stemmer = nltk.stem.SnowballStemmer('english')

class StemmedCountVectorizer(CountVectorizer):
    def build_analyzer(self):
        analyzer = super(StemmedCountVectorizer, self).build_analyzer()
        return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc))

結果を見ると,imagesとimagingが同じ単語としてカウントされている.


In [30]:
vectorizer = StemmedCountVectorizer(min_df=1, stop_words='english')
X_train = vectorizer.fit_transform(posts)
print(vectorizer.get_feature_names())


[u'actual', u'capabl', u'contain', u'data', u'databas', u'imag', u'interest', u'learn', u'machin', u'perman', u'post', u'provid', u'save', u'storag', u'store', u'stuff', u'toy']

再び上記スクリプト(※)で類似度を計算してみる.

3.2.6 TF-IDFを用いる

これまで考えてきた特徴量は,単語の出現頻度を数えるだけの単純なものだった.
しかし,例えばsubjectのような,どの文書にも存在するような単語の影響を考えると今までのやり方は良くない.

そこで,「ある単語に対して,対象の文書中で出現した回数をカウントするのに加えて,その単語が他の文書でどれだけ出現するかをカウントし,その回数で割る」方法で解決する.
それによって,特定の文書だけで現れやすい単語,つまり,他の文書ではあまり現れない単語の特徴量の値は大きくなる.
これを,TF-IDF(term frequency - inverse document frequency)という.


In [34]:
import scipy as sp

def tfidf(t, d, D):
    tf = float(d.count(t)) / sum(d.count(w) for w in set(d))
    idf = sp.log(float(len(D)) / (len([doc for doc in D if t in doc])))
    return tf * idf

簡単な例を以下に示す.


In [35]:
a, abb, abc = ["a"], ["a", "b", "b"], ["a", "b", "c"]
D = [a, abb, abc]

print(tfidf("a", a, D))
print(tfidf("b", abb, D))
print(tfidf("a", abc, D))
print(tfidf("b", abc, D))
print(tfidf("c", abc, D))


0.0
0.270310072072
0.0
0.135155036036
0.366204096223

TF-IDFはTfidfVectorizer(CountVectorizerを継承したクラス)の中に含まれているため,これまで使用してきたステミングの機能を一から実装し直す必要はない.


In [38]:
from sklearn.feature_extraction.text import TfidfVectorizer

class StemmedTfidfVectorizer(TfidfVectorizer):
    def build_analyzer(self):
        analyzer = super(TfidfVectorizer, self).build_analyzer()
        return lambda doc: (english_stemmer.stem(w) for w in analyzer(doc))

vectorizer = StemmedTfidfVectorizer(min_df=10, max_df=0.5, stop_words='english', decode_error='ignore')

charset_errorと書くとエラーになるので注意 → decode_error

3.2.7 ここまでやってきたこと

1.テキストデータをトークン化する.

2.頻出しすぎる単語は,関連する文書を見つけるために役立たないため,取り除く.

3.滅多に使われない単語は,新しい文書でも使われる可能性が低いため,取り除く.

4.残った単語について,その出現回数をカウントする.

5.文書全体の状況を考慮するため,単語の出現回数からTF-IDFを計算する.

ただし,以下のような欠点がある.

●単語の関連性について考慮していない.「Car hits wall」と「Wall hits car」が同じ特徴ベクトルになる.

●否定的な意味を捉えることが出来ない.「I will eat ice cream」と「I will not eat ice cream」は意味的に逆だが,似た特徴ベクトルになる.しかし,2つの単語のペア(バイグラム)や3つ(トリグラム)をカウントすることで解決可能.

●タイプミスに対応出来ない.databaseとdatabasのように明らかに同じ意味だとわかる単語も別々に扱われる.