Fessで集めた記事に対してPythonで実装したtf-idfを適用して特徴的な単語を取得する
事前作業
まずpythonにelasticsearchのクライアントをインストールします。
pip install elasticsearch
それからFessでWebサイトをクロールさせて記事を収集させます。
$ curl -XGET http://localhost:9201/_cat/indices/fess.20180701\*\?v health status index uuid pri rep docs.count docs.deleted store.size pri.store.size green open fess.20180701 uclmK-llQcafFyuecoaaaA 5 0 116 21 14.7mb 14.7mb
現在116件の記事が集まっているので、これを使って記事の中から特徴的な単語を集めたいと思います。
tf-idfとは
tf-idfは、文書中に含まれる単語の重要度を評価する手法の1つであり、主に情報検索やトピック分析などの分野で用いられている。
tf-idfは、tf(Term Frequency、単語の出現頻度)とidf(Inverse Document Frequency、逆文書頻度)の二つの指標に基づいて計算される。
トピック分析では他にLatent Dirichlet Allocation(LDA)が有名ですが、tf-idfは分かりやすくて実装しやすいのでまずはこちらで検証したいと思います。
tf-idfはtfとidfにそれぞれ分けることができ、tf-idfはその結果を掛け合わせたものになります。tfはある文章内に現れるある単語の出現頻度を表していて以下の式で表すことができます。
分母の部分はある文章dに現れる全ての単語の出現回数の合計となっていて、分子の部分はある文章d内に現れる単語tの出現回数となっておりこれで文章d内の単語tの出現頻度を求めています。
それからidfは以下の式で表すことができ分子は全ての文書数、分母はある全ての文書中にある単語が現れる文書数になります。これよりidfは逆文書頻度(文書に稀に現れるほど大きな値)を求めています。
tf-idfはこの結果を掛け合わせることで他の文章に現れない単語が沢山出るほどその文章のトピックだとしています。
pythonでの実装について
pythonでの実装は以下のようになりました。
from elasticsearch import Elasticsearch import functools as f from janome.tokenizer import Tokenizer from janome.tokenizer import Tokenizer from janome.analyzer import Analyzer from janome.charfilter import * from janome.tokenfilter import * import math es = Elasticsearch("http://127.0.0.1:9201") def scroll(index, doc_type, query_body, page_size=100, scroll='2m'): page = es.search(index=index, doc_type=doc_type, scroll=scroll, size=page_size, body=query_body) sid = page['_scroll_id'] scroll_size = page['hits']['total'] total_pages = math.ceil(scroll_size/page_size) page_counter = 0 # Start scrolling while (scroll_size > 0): # Get the number of results that we returned in the last scroll scroll_size = len(page['hits']['hits']) if scroll_size>0: yield total_pages, page_counter, scroll_size, page # get next page page = es.scroll(scroll_id = sid, scroll = '2m') page_counter += 1 # Update the scroll ID sid = page['_scroll_id'] def wordToDicGen(tokenizer, char_filters, token_filters, stop_words): def wordToDic(text): dic = dict() for token in Analyzer(char_filters, tokenizer, token_filters).analyze(text): if token.base_form in stop_words: continue if token.base_form in dic: dic[token.base_form] = dic[token.base_form] + 1 else: dic[token.base_form] = 1 return dic return wordToDic class TfIdf(): doc_num=0 word_counter = dict() def addDocument(self, wordCountDic): self.doc_num += 1 for key in wordCountDic: if key in self.word_counter: self.word_counter[key] += 1 else: self.word_counter[key] = 1 def answer(self, wordCountDic): ans = dict() document_words_sum = f.reduce(lambda x,y:x+y, list(wordCountDic.values())) for word in wordCountDic: tf = self.tf(document_words_sum, wordCountDic[word]) idf = self.idf(word) ans[word] = tf*idf return ans def tf(self, document_words_sum, word_num): return word_num / document_words_sum def idf(self, word): return math.log(float(self.doc_num / self.word_counter[word]), math.e) + 1 char_filters = [UnicodeNormalizeCharFilter()] tokenizer = Tokenizer() token_filters = [CompoundNounFilter(), POSStopFilter(['記号','助詞', '助動詞', '助動詞']), LowerCaseFilter()] stop_words=set(["する", "れる", "こと", "いる", "行う", "できる", "場合", "の", "ない", "みる", "使う", "より", "なる"]) wordToDic = wordToDicGen(tokenizer, char_filters, token_filters, stop_words) tfidf = TfIdf() index = 'fess.20180701' doc_type = 'doc' query = { "query": { "match_all": {} }} page_size =30 currrent_volume=0 for total_pages, page_counter, page_items, page_data in scroll(index, doc_type, query, page_size=page_size): for data in page_data['hits']['hits']: tfidf.addDocument(wordToDic(data['_source']['content'])) currrent_volume += page_items page_size =1 currrent_volume=0 for total_pages, page_counter, page_items, page_data in scroll(index, doc_type, query, page_size=page_size): for data in page_data['hits']['hits']: print(data['_source']['url']) for k, v in sorted(tfidf.answer(wordToDic(data['_source']['content'])).items(), key=lambda x: -x[1])[:10]: print(k, v) currrent_volume += page_items if currrent_volume >= 10: break
それぞれの処理について、 elasticsearchからのデータの読み込みは以下の関数を使っています。
def scroll(index, doc_type, query_body, page_size=100, scroll='2m'): page = es.search(index=index, doc_type=doc_type, scroll=scroll, size=page_size, body=query_body) sid = page['_scroll_id'] scroll_size = page['hits']['total'] total_pages = math.ceil(scroll_size/page_size) page_counter = 0 # Start scrolling while (scroll_size > 0): # Get the number of results that we returned in the last scroll scroll_size = len(page['hits']['hits']) if scroll_size>0: yield total_pages, page_counter, scroll_size, page # get next page page = es.scroll(scroll_id = sid, scroll = '2m') page_counter += 1 # Update the scroll ID sid = page['_scroll_id']
それから以下の関数でjanomeを使って形態素解析した結果に対して単語をkeyと単語の数を値とした辞書を作成しています。
def wordToDicGen(tokenizer, char_filters, token_filters, stop_words): def wordToDic(text): dic = dict() for token in Analyzer(char_filters, tokenizer, token_filters).analyze(text): if token.base_form in stop_words: continue if token.base_form in dic: dic[token.base_form] = dic[token.base_form] + 1 else: dic[token.base_form] = 1 return dic return wordToDic
tf-idfは以下のクラスを使っています。idfの計算に使うドキュメント数と単語が現れるドキュメント数の辞書をメンバ変数としていて、最初にaddDocumentで全てのドキュメントを読み込ませた後answerを実行することで単語をkey、単語のtf-idfをvalueとした辞書を返しています。
class TfIdf(): doc_num=0 word_counter = dict() def addDocument(self, wordCountDic): self.doc_num += 1 for key in wordCountDic: if key in self.word_counter: self.word_counter[key] += 1 else: self.word_counter[key] = 1 def answer(self, wordCountDic): ans = dict() document_words_sum = f.reduce(lambda x,y:x+y, list(wordCountDic.values())) for word in wordCountDic: tf = self.tf(document_words_sum, wordCountDic[word]) idf = self.idf(word) ans[word] = tf*idf return ans def tf(self, document_words_sum, word_num): return word_num / document_words_sum def idf(self, word): return math.log(float(self.doc_num / self.word_counter[word]), math.e) + 1
これよりFessで集めた記事に対してtf-idfでトピックの単語を求めたら以下のようになりました。まあまあそれらしいのは出ていそうです。
https://gigazine.net/news/20180706-china-ap1000-epr/ 原子力産業 0.023317488109853554 遅れる 0.01886515867249546 ap1000 0.018473965793423773 新型原子炉 0.017488116082390166 原子力発電所 0.017488116082390166 wh 0.017488116082390166 東芝 0.017488116082390166 アメリカ 0.015243532167490574 中国 0.014023731901107834 最 0.013274455410293236 https://gigazine.net/news/20180706-virus-desides-mining-or-ransomware/ ファイル 0.03633063623601737 カスペルスキー 0.03265374682807244 マイニングマルウェア 0.026122997462457952 コンピューター 0.021134973450343948 侵入 0.019592248096843467 身代金 0.019592248096843467 暗号化 0.017287991110333946 ランサムウェア 0.016922932055221898 マルウェア 0.015427253535004832 攻撃 0.015087760100809063 https://gigazine.net/news/20180706-london-police-facial-recognition/ afs 0.03595993869441478 運用 0.024244676575199242 犯罪容疑者 0.023973292462943184 イギリス 0.019090283152333857 98% 0.01797996934720739 テスト 0.01797996934720739 ロンドン警視庁 0.01797996934720739 警察 0.016303568082312883 顔認識機能 0.01581388440795756 監視カメラ 0.014546805945119546 https://gigazine.net/news/20180706-mit-cheetah-progress/ ボストン・ダイナミクス 0.045936847833184545 ...... 0.025717530837217364 障害物 0.02525171162947315 cheetah 0.024033452280351385 足 0.018582746117517982 飛び越える 0.017226317937444207 走行 0.017226317937444207 ジャンプ 0.016543522070547958 3世代 0.01515102697768389 抜群 0.01515102697768389 https://gigazine.net/news/20180706-customers-cancel-subscriptions-online-law/ サービス 0.026082951604456672 オンライン上 0.023532066221293925 提供 0.0195622137033425 明確 0.019038764427150325 カリフォルニア州 0.017649049665970442 cancel 0.017649049665970442 自動更新型 0.017649049665970442 提示 0.017649049665970442 had 0.017649049665970442 to 0.016061938498019724 https://gigazine.net/news/20180705-mystery-of-kentucky-meat-shower/ ハゲワシ 0.03448705409254564 肉 0.029324503159288345 gohde氏 0.022991369395030426 motherboard 0.01724352704627282 瓶 0.01724352704627282 降る 0.01583149139611712 謎 0.015215504663540665 仮説 0.015166162868770486 雨事件 0.013214519403227284 ? 0.011876498424664378 https://gigazine.net/news/20180705-mario-macaroni-and-cheese/ マカロニ&チーズ 0.06726319468020031 マリオ 0.05691501088324641 恐竜 0.03767518086505781 スーパーマリオワールド 0.03104455139086168 2dアニメーション 0.03104455139086168 ヨッシー 0.025870459492384732 舌 0.025870459492384732 クッパ 0.025870459492384732 少年 0.023564545845250764 パッケージ 0.02093065603614323 https://gigazine.net/news/20180701-gigazine-manga/ 描く 0.024681107815303693 こちら 0.021314858009726056 募集要項 0.01687809246714378 小物 0.016689166616697216 イラスト 0.01656713578847448 gigazineマンガ大賞 0.014635680832971138 gigazineシークレットクラブ 0.014600558020194318 最後 0.01387370921092746 1話 0.01387370921092746 事 0.013502473973715024 https://gigazine.net/news/20180705-kfc-red-hot-crispy-twister/ レッドホットクリスピー 0.040414592434020194 レッドホットツイスター 0.03730577763140326 ケンタッキー 0.0335512088288156 野菜 0.02249811226206441 食べる 0.02121156478528745 衣 0.018741336127382296 マヨソース 0.018741336127382296 骨 0.01865288881570163 サクサク 0.018300659361172145 辛口チキン 0.01554407401308469 https://gigazine.net/news/20180705-virtue-labeling/ ラベル付け 0.10242459318533002 プラシーボ効果 0.035625945455766965 ラベル 0.03525076400535431 研究 0.032018474274988856 寄付 0.027042079442640712 美徳ラベリング 0.02437724672237786 人 0.020651131333006677 もの 0.018290081388115286 効果 0.01818100961931576 ミラー氏 0.017812972727883482