いっきのblog

技術とか色々

TF-IDFを自力実装してみる

前回ライブラリーを使って実装しようとして断念した「TF-IDF」について自力で実装したいと思う。

kzkohashi.hatenablog.com

ちなみに上記の記事は、TF値の出し方が間違ってて理解不足だった・・。 一度実装するとしないでは理解度が変わるという教訓。。いや計算式の理解の問題か・・。(修正済み)

TF値を求める

まずは数式の確認からすると、以下のようになる。

\large{tf_{t,d} = \frac{n_{t,d}}{\sum_k n_{k,d}}}

tf_{t,d}:文書dにおける、単語tのTF値

{n_{t,d}}:単語tの文書dにおける出現回数

{\sum_k n_{k,d}}:文書dにおけるすべての単語の出現回数の和

まずは、前回同様に単語ごとの出現回数({n_{t,d}})をもとめると以下のようになる。

from sklearn.feature_extraction.text import CountVectorizer
import numpy as np
import logging

logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

count_vectorizer = CountVectorizer(min_df=1, max_df=100, input='filename', token_pattern=u'(?u)\\b\\w+\\b')

bag_of_words = count_vectorizer.fit_transform(['../data/blog_wakati_neo.txt'])
sum_words = bag_of_words.sum(axis=0)
words_freq = [(word, sum_words[0, idx]) for word, idx in count_vectorizer.vocabulary_.items()]
words_freq =sorted(words_freq, key = lambda x: x[1], reverse=True)
[('た', 32),
 ('の', 27),
 ('に', 26),
 ('を', 19),
 ('と', 15),
 ('し', 13),
 ('て', 12),
 ('ない', 10),
 ('から', 10),
 ('な', 10),
 ('も', 8),
 ('こと', 8),
 ('サービス', 8),
 ('が', 6),
 ('は', 6),
省略

続いて全ての単語数({\sum_k n_{k,d}})を求めると

# 扱いやすいディクショナリーに変換
dict_words_freq = dict(words_freq)
all_words_count = sum(dict_words_freq.values())

あとは「単語ごとの出現回数 / 全ての単語数」を行えば良いので以下のようになる

# TF値の計算
dict_words_freq = dict(words_freq)
all_words_count = sum(dict_words_freq.values())
for key in dict_words_freq:
    print(key, dict_words_freq[key], dict_words_freq[key]/all_words_count)

無事できた。

た 32 0.05714285714285714
の 27 0.048214285714285716
に 26 0.04642857142857143
を 19 0.033928571428571426
と 15 0.026785714285714284
し 13 0.023214285714285715
て 12 0.02142857142857143
ない 10 0.017857142857142856
から 10 0.017857142857142856
な 10 0.017857142857142856
も 8 0.014285714285714285
こと 8 0.014285714285714285
サービス 8 0.014285714285714285
が 6 0.010714285714285714
は 6 0.010714285714285714
省略

IDF値を求める

まずは数式の確認からすると、以下のようになる。

\large{idf_t = \mbox{log} \frac{N}{df_t}} + 1

idf_t:単語tにおけるIDF値

N:すべての文章数

df_tNにおいて、単語tが含まれる文章数

元となる文章は以前使った、Wikipediaのデータを元にする。
ただし、量が多くて重いため(2400万行)、100万行ほどのデータのみを使うこととする。

kzkohashi.hatenablog.com

まずは、100万行のみのデータにする。

head -n 1000000 wiki_wakati_neo.txt > wiki_wakati_neo_head.txt

このままだと全ての文章が同じファイルにあるので、文章ごとに分割する。
「<\/ doc >」が区切りっぽいのでそれで分割する。

f = open('../data/wiki_wakati_neo_head.txt')
wiki_texts = f.read().split('</ doc >')
print(wiki_texts[0])
f.close()
< doc id =" 3216890 " url =" https :// ja . wikipedia . org / wiki ? curid = 3216890 " title =" 2015年アムトラック脱線事故 "> 
2015年アムトラック脱線事故 

...省略

次の処理で使うので、リストの中の文字をユニークのリストに返してつかう関数を用意。

def unique(list1):
    unique_list = []
     
    for x in list1:
        if x not in unique_list:
            unique_list.append(x)
    return unique_list

IDF値を求める。

from math import log

# 初期化する
# ブログの文章を含めると最低出現数は1となるため
idf = {}
for key in dict_words_freq:
    idf[key] = 1

# wikipediaが文章単位でわかれてるので
# 文章ごとにブログ内の単語が存在するかカウントする
for wiki_text in wiki_texts:
  # 同一文章内で複数のカウントをさせないため
  word_list = unique(wiki_text.split(' '))
  for key in dict_words_freq:
    if key in word_list:
        # 文章内にブログの単語があった場合
        if key in idf:
            idf[key] += 1
        
# idf[key]はただのカウントのため
# 上記の計算式通りに計算し直す
for key in idf:
    idf[key] = log(len(wiki_texts)/idf[key]) + 1
    print(idf[key])
た 1.3175143932942954
の 1.0777938305785737
に 1.2131304792181579
を 1.3313370482515308
と 1.4096924908938504
し 1.4009755108032362
て 1.450652175643083
ない 2.5456519664401
から 1.7110617213423613
な 2.1554299751676784
も 1.8192044116505661
こと 2.1342823075479105
サービス 5.17021502327521
が 1.4339912023116144
は 1.0493246452432414
だ 2.732840227719004
けど 6.566265559340465
自分 4.343764024527529

自分のブログ内の文書をみると、「た」などの助動詞より、「サービス」や「自分」などよく使われてなさそうな値が高いことがわかる。(希少性が高い)

TF-IDF値を求める

まずは、先ほど求めたTF値を格納しておく

tf = {}
for key in dict_words_freq:
    tf[key] = float(dict_words_freq[key]/all_words_count)

あとは掛け算するだけなので

tfidf = {}
for key in dict_words_freq:
    tfidf[key] = tf[key] * idf[key]
た: 0.07528653675967402
サービス: 0.07386021461821728
色々: 0.06481618517768223
けど: 0.05862737106553986
に: 0.056323915106557335
もっと: 0.05489118978308473
の: 0.05196505968860981
なぁ: 0.050354351268196505
エンジニア: 0.0496422258575285
ない: 0.045458070829287496
を: 0.045170364137105505
視点: 0.04516931372977967
すごく: 0.043155772268012066
思う: 0.043005388411332744
振り返り: 0.04292777433362566
セールス: 0.040755639825903356
自分: 0.038783607361852934
やら: 0.03853413038011895
....

あれ、、想定では、助動詞とかはよく出るからもっと低いはずが・・・100万行じゃちょっと少なかったのかもしれない。
名詞だけに絞ると

import MeCab

m = MeCab.Tagger('-d /usr/local/mecab/lib/mecab/dic/mecab-ipadic-neologd/')
feature_vector = {}

for k, v in sorted(tfidf.items(), key=lambda x: -x[1]):
    node= m.parseToNode(k)
    while node:
        meta = node.feature.split(",")
        if meta[0] == ("名詞"):
                print(str(k) + ": " + str(v))
        node = node.next
サービス: 0.07386021461821728
エンジニア: 0.0496422258575285
視点: 0.04516931372977967
セールス: 0.040755639825903356
自分: 0.038783607361852934
チーム作り: 0.037493182828802825
やめ: 0.03584693947657752
コミュニティ: 0.03359602203641041
たい: 0.031145458128418263
プロダクト: 0.03067338816850126
こと: 0.030489747250684436
業種: 0.029452854976636772
作り: 0.029273521205558244
ジム: 0.025032815107084036
顧客: 0.02418713487438621

綺麗になった。
今回調べたブログ内の言葉では、「サービス」がぶっちぎりで重要な言葉として抽出されていることがわかる。
また「エンジニア」や「視点」、「セールス」など自分が考えてそうな言葉が出てくるので納得感はある。

IDF値は「+1」の部分でチューニングができるっぽいので、もう少しやってみたい感はあるが次回の理解した時にする。

終わりに

ライブラリに頼らず(一部頼ったけど)、自前で実装するのは勉強になったし面白かった。
文章内の言葉から重要なキーワードを抽出するTF-IDF値は色々なところで使えそうだし、うちのサービスでも次使う予定なので次は実践しながら深掘りしていきたい。