いっきのblog

技術とか色々

TF-IDFとコサイン類似度を使って似ている文章を見つける

今回は、以前実装したTF-IDFの処理をベースに、自分のブログに一番近いWikipediaの文章は何かをコサイン類似度を使って出してみる。

kzkohashi.hatenablog.com

コサイン類似度とは?

\large{\cos(\pmb x, \pmb y) = \frac {\pmb x \cdot \pmb y}{||\pmb x|| \cdot ||\pmb y||}}

高校の数学でやったようなやってないようなうる覚えな感じだったので、他の方のサイトを参考にすると

コサイン類似度は2本のベクトルがどれくらい同じ向きを向いているのかを表す指標

mathtrain.jp

となり、文章を全単語で表現されたベクトル空間で表すことで計算できる。
単純にある文章にその単語が含まれているかを0 or 1で表現すると以下になる。

単語1 単語2 単語3 単語4 .....
文章1 1 0 1 1 ...
文章2 0 0 0 1 ...
文章3 0 0 1 0 ...
cos(文章1, 文章2) = 1 * 0 + 0 * 0 + ...
cos(文章1, 文章3) = ....

のように、文章1と似ている文章を探す場合は、文章1と全文章のコサイン類似度を計算し、一番高い(Max:1)あたいのものを選び出せば良い。

ただ、これだと出現頻度や希少性が表現されていない空間になってしまい、表現の幅が狭くなってしまう。
そこで、TF-IDFを用いて特徴量を抽出すると以下のような空間になる。

単語1 単語2 単語3 単語4 .....
文章1 2.33 0 0.23 0.85 ...
文章2 0 0 0 3.55 ...
文章3 0 0 3.23 0 ...

前処理

以前行ったように、Wikipediaのデータを準備する。
ほぼメモのような感じなのでペタペタ貼ってく。

kzkohashi.hatenablog.com

文章単位に分割し、さらに単語に分割

def wakati_to_list(wakati_words):
    # 改行コードを消す処理
    wakati_words = wakati_words.replace('\n','')
    wakati_words = wakati_words.replace('\r','')
    
    # すでに分かち書きされているため半角スペースで
    words = wakati_words.split(' ')
    
    return words
f = open('../data/wiki_wakati_neo_head.txt')
# 文章単位に変換
wiki_texts = f.read().split('</ doc >')
f.close()

words = wakati_to_list(wiki_texts[0])
print(words[:200])
['<', 'doc', 'id', '="', '3216890', '"', 'url', '="', 'https', '://', 'ja', '.', 'wikipedia', '.', 'org', '/', 'wiki', '?', 'curid', '=', '3216890', '"', 'title', '="', '2015年アムトラック脱線事故', '">', '2015年アムトラック脱線事故', '2015年アムトラック脱線事故', '(', '2015', 'ねん', 'アムトラック', 'だっ', 'せんじ', 'こ', ')', 'は', '、', '現地時間', 'で', '2015年', '5月12日', '夜', 'に', 'アメリカ合衆国', 'ペンシルベニア州', 'フィラデルフィア', 'の', '付近', 'で', '、', '全米鉄道旅客公社', '(', 'アムトラック', ')', 'が', '運行', 'する', 'ワシントンD.C.', 'から', 'ニューヨーク', 'に', '向かう', '列車', 'が', '脱線', 'し', 'た', '鉄道事故', 'で', 'ある', '事故', '当時', '、', 'この', '列車', 'に', 'は', 'およそ', '240人', 'が', '乗っ', 'て', 'い', 'た', '。', '2015年', '5月12日', 'の', '午後9時', '10分', 'ごろ', '、', 'アムトラック', '北東回廊', 'を', '運行', 'する', '北', '行き', 'の', '「', 'ノースイースト・リージョナル', '」', '188', '(', 'ユニオン駅', '(', 'ワシントンD.C.', ')', '発', 'ペンシルベニア', '駅', '(', 'ニューヨーク', ')', '行き', ')', 'は', 'フィラデルフィア', 'の', '30', '丁目', '駅', 'を', '発車', 'し', 'た', '。', '列車', 'は', '7', '両', 'の', '客車', 'を', '1年前', '製造', 'の', 'ACS', '-', '64', '型', '電気機関車', '(', 'No.', '601', ')', 'が', 'けん引', 'する', 'もの', 'で', 'あっ', 'た', '。', '約', '11分', '後', '、', '列車', 'は', 'から', '南東', 'に', 'ある', '複々線', 'の', '本線', 'を', '走行', 'し', 'て', 'おり', '、', 'ポート', '・', 'リッチモンド', '地区', 'に', 'ある', 'フランクフォード', '・', 'アベニュー', 'と', 'ウィートシーフ・レーン', 'の', '交差点', 'の', '近く', '、', 'に', 'ある', '4度', '(', '半径', '約', '440m', ')', 'の']

名詞のみ抽出

以前名刺以外もすべていれたら痛い目をみたので、名詞のみにする。

# 名詞の抽出する
def extract_noun(words):
    import MeCab
    m = MeCab.Tagger('-d /usr/local/mecab/lib/mecab/dic/mecab-ipadic-neologd/')
    noun_words = []
    
    for word in words:
        node= m.parseToNode(word)
        while node:
            meta = node.feature.split(",")
            if meta[0] == ("名詞"):
                noun_words.append(word)
            node = node.next
            
    return noun_words

# 単語分割と名詞の抽出を一気にやっておくと
f = open('../data/wiki_wakati_neo_head.txt')
# 文章単位に変換
wiki_texts = f.read().split('</ doc >')

# Wikipediaの文章
wiki_text_words = []
for wiki_text in wiki_texts:
    wiki_words = wakati_to_list(wiki_text)
    wiki_words = extract_noun(wiki_words)
    wiki_text_words.append(wiki_words)



f = open('../data/blog_wakati_neo.txt')
# 文章単位に変換
blog_text = f.read()

# Blogの文章たち
blog_words = wakati_to_list(blog_text)
blog_words = extract_noun(blog_words)

TFの計算

関数にしておく。

# tfの計算
def tf(words):
    import collections
    words_counter= collections.Counter(words)
    all_words_count = len(words)
    tf = {}
    for key in words_counter:
        tf[key] = float(words_counter[key]/all_words_count)

    return tf
# ブログのTF
tf_blog = tf(blog_words)

# 文章ごとのTF
tf_wiki_text = []
for wiki_text_word in wiki_text_words:
    tf_wiki_text.append(tf(wiki_text_word))

# まとめておく
tf_all_text = tf_wiki_text[:1000] + [tf_blog]

tf_wiki_text[:1000]1000文章のみにした理由は、一部の実装で速度を早くすることができず、今回は諦めた。

IDFの計算

こちらも関数にしておく。(変数名はあとで直すかも・・)

def idf(text_words):
    from math import log
    from itertools import chain
    
    words_flat= list(chain.from_iterable(text_words))
    # uniqueにする
    words_flat = set(words_flat)

    text_words_unique = [set(text_word) for text_word in text_words]
        
    idf = {}
    for word in words_flat:  
        for text_word_unique in text_words_unique:
            if word in text_word_unique:
                if word in idf:
                    idf[word] += 1
                else:
                    idf[word] = 1
    
    for word in idf:
        idf[word] = log(len(text_words)/idf[word]) + 1
    
    
    return idf
# TFと同様1000文章のみ
all_words = wiki_text_words[:1000] + [blog_words]
all_words_idf = idf(all_words)

単語 x 文章のマトリックス作る

# Wikipedia + Blogを単語に分解
from itertools import chain

words_flat= list(chain.from_iterable(all_words))
# uniqueにする
words_flat = set(words_flat)  

最初の方に説明したマトリックスを作る

matrix = []
for index in range(len(all_words)):
    line = [all_words_idf[word] * tf_all_text[index][word] if word in all_words[index] else 0 for word in words_flat]
    matrix.append(line)

コサイン類似度の計算

import math
def cosine_similarity(v1,v2):
    "compute cosine similarity of v1 to v2: (v1 dot v2)/{||v1||*||v2||)"
    sumxx, sumxy, sumyy = 0, 0, 0
    for i in range(len(v1)):
        x = v1[i]; y = v2[i]
        sumxx += x*x
        sumyy += y*y
        sumxy += x*y
    return sumxy/math.sqrt(sumxx*sumyy)

blog = matrix[-1]
sims = {}
for index in range(len(matrix)-1):
    sim = cosine_similarity(blog, matrix[index])
    sims[index] = sim

matrixの一番最後の行が今回検索したいブログのため計算から省く。

類似度でソートして似ている文章を出力する

先ほどのコサイン類似度をソートして表示すると

for k, v in sorted(sims.items(), key=lambda x: -x[1]):    
    print(k, v)

49 0.10417548423881662
625 0.10121151071826293
184 0.08561012221392927
24 0.08253925952346762
845 0.07463393507872249
546 0.055037919480128226
115 0.05484217388705765
738 0.05439783280734914
395 0.053252522673636296
744 0.053045184024489614
....省略
627 6.935653310818008e-05
125 6.934484006734906e-05
566 6.830350235460813e-05
309 6.70632734759553e-05
778 5.281461561599366e-05

左が記事番号、右が類似度のあたいになっている。
一番高い記事は「49」、一番低いのは「778」なので見てみると

print(all_words[49])
[...'情報デザイン', '専門', '情報', 'コミュニケーション', '形', 'ソフトウエア', 'プロダクト', 'サービス', 'デザイン', '領域', '構築', 'コミュニティ', '社会', 'デザイン', 'マインド', '構築', '試み']

自分のブログの記事のTF-IDFをみてみると

サービス: 0.07386021461821728
エンジニア: 0.0496422258575285
視点: 0.04516931372977967
セールス: 0.040755639825903356
自分: 0.038783607361852934
チーム作り: 0.037493182828802825
やめ: 0.03584693947657752
コミュニティ: 0.03359602203641041
たい: 0.031145458128418263
プロダクト: 0.03067338816850126
こと: 0.030489747250684436

文章自体は似ているかはあれだが、分野はかなり近いと思う。

一番低いのも見てみると

print(all_words[778])
[...'駆潜艇', '七', '号', '駆潜艇', 'だい', 'なな', 'ごうく', 'せんてい', '日本海軍', '駆潜艇', '普遍的', '第四号型駆潜艇', '4番', '艇', '海軍省', '定め', '特務艇', '類別', '等級', '艦艇', '類別', '等級', '第一号型駆潜艇', '7番', '艇', 'マル3計画', '300', 'トン', '型', '駆潜艇', '仮称', '艦', '名', '62', '号', '艦', '計画', '1937年', '10月30日', '鶴見', '製鉄', '造船', '株式会社', '鶴見', '工場', '起工', '1938年', '4月15日', '七', '号', '駆潜艇', '命名', '特務艇', '駆潜艇', '第一号型', '4番', '艇', '定め', '6月10日', '進水', '11月20日', '竣工', '大湊', '防備', '隊', '附属', '1940年', '11月15日', '艦艇', '類別', '等級', '駆潜艇', '新設', '特務艇', '駆潜艇', '艦艇', '駆潜艇', '同日', '七', '号', '駆潜艇', '八', '号', '駆潜艇', '第九', '号', '駆潜艇', '3', '隻', '十', '一', '潜', '隊', '新編', '第二艦隊', '十', '一', '根拠地', '隊', '編入', '1941年', '4月10日', '十', '一', '潜', '隊', '第三艦隊', '一', '根拠地', '隊', '編入', '以後', '1941年', '9月', '断続', '的', '中国大陸', '沿岸', '監視', '哨戒', '10月31日', '十', '一', '潜', '隊', '南', '艦隊', '九', '根拠地', '隊', '編入', '太平洋戦争', '緒戦', 'ボルネオ', 'ミリ', 'クチン', '攻略', '作戦', '従事', 'マレー半島', '上陸', '船団', '護衛', '1942年', '3月', '以後', 'ペナン', '根拠地', 'シンガポール', 'サバン', '方面', '護衛', '従事', '4月10日', '十', '一', '潜', '隊', '一南遣艦隊第九根拠地隊', '編入', '以後', '一南遣艦隊隷下', '根拠地', '隊', '特別', '根拠地', '隊', '間', '異動', '任務', '従事', '1945年', '4月11日', 'カー・ニコバル', '沖', 'イギリス軍機', '空襲', '沈没', '5月25日', '帝国', '駆潜艇', '籍', '除か', '十', '一駆潜隊', '一駆潜隊', '一駆潜隊', '艦艇', '類別', '等級', '別表', '削除']

おぉおお・・戦争の話っぽくて、だいぶかけ離れている。

終わりに

似ている文章を、TF-IDFとコサイン類似度を使って計算した。割と似ているものが出たので、何か他のに使えそうだなーと思えるものだった。ただ、一部計算が重てく全文章できないのが悔しいので近々直したい。。
あと、ほぼ自力実装のためだいぶ理解が進んだ気する。