tfidf, lsi, ldaを使ったツイッターユーザーの類似度計算メモ。
tfidf, lsi, ldaを使ったツイッターユーザーの類似度計算
ツイッターの@ts_3156のフォロー情報を使って、ツイッターユーザーの類似度計算を行いました。
結論だけ先に書いておくと、プロフィール情報だけを使って類似度計算を行なっても、全然いい結果にはならないです(^^) その理由あれこれは下の方に書いてあります。
「じゃあ、正確なツイッターユーザーの類似度計算はどうやればいいの?」についても下の方に書いておきました(^^)
今回書いたプログラムは、ツイッタープロフィールだけでなく文章集合の類似度計算全般に使えるので、よかったら各自で何かしら使ってみてください(^^)
サンプルコードの動作環境
python2.7(2系なら何でもOKかも)
もしない場合は、「yum install python27」でインストールできます。
gensim0.86(numpy, scipy)
「Python GensimでLDAを使うための前準備・パッケージのインストール」このページの通りにやれば簡単にインストールできます。
実行方法
下記のpythonプログラムをファイル(twitter.pyとか)に保存して、「$ python twitter.py profiles.txt」とコマンドを打てば実行できます。
profiles.txtは、各行に各ユーザーのプロフィールが入っていればOKです。「一行ごとに各個の情報が入っている」という構造になっていれば、プロフィール以外にも何でも使えます。
tfidf, lsi, ldaを使ったツイッターユーザーの類似度計算のサンプルコード
今回は、一行ごとにツイッターユーザーのプロフィールが書かれたテキストファイルを入力データとして使いました。
同じような構造のデータなら何にでも適用できます。例えば、一行ごとにwikipediaの各記事の文章が書かれたテキストファイルとかとか。
コード中にコメントが書かれているので、細かい意味はコメントを参照してください。意味が分からないところがあったら、@ts_3156にお気軽にご質問ください(^^)
#coding:utf-8 import time import codecs import sys import MeCab def extractKeyword(text): u"""textを形態素解析して、名詞のみのリストを返す""" tagger = MeCab.Tagger() encoded_text = text.encode('utf-8') node = tagger.parseToNode(encoded_text).next keywords = [] while node: if node.feature.split(",")[0] == "名詞": keywords.append(node.surface) node = node.next return keywords def splitDocument(documents): u"""文章集合を受け取り、名詞のみ空白区切りの文章にして返す""" splitted_documents = [] for d in documents: keywords = extractKeyword(d) splitted_documents.append(' '.join(keywords)) return splitted_documents def removeStoplist(documents, stoplist): u"""ストップワードを取り除く""" stoplist_removed_documents = [] for document in documents: words = [] for word in document.lower().split(): if word not in stoplist: words.append(word) stoplist_removed_documents.append(words) return stoplist_removed_documents def getTokensOnce(all_tokens): u"""一回しか出現しない単語を返す""" tokens_once = set([]) for word in set(all_tokens): if all_tokens.count(word) == 1: tokens_once.add(word) return tokens_once def removeTokensOnce(documents, tokens_once): u"""一回しか出現しない単語を取り除く""" token_removed_documents = [] for document in documents: words = [] for word in document: if word not in tokens_once: words.append(word) if len(words) != 0 and ' '.join(words) != '': token_removed_documents.append(words) return token_removed_documents if __name__ == "__main__": argvs = sys.argv argc = len(argvs) file_name = '' if len(argvs) == 1: file_name = argvs[0] elif len(argvs) == 2: file_name = argvs[1] print 'data file: ' + file_name print '実行時引数で違うファイルを指定できます: $ python this_file.py input_file.txt' time.sleep(5) raw_documents = [] for line in codecs.open(file_name, 'r', 'utf-8'): raw_documents.append(line.rstrip()) print 'raw_documents: ' + str(len(raw_documents)) for d in raw_documents: print d.encode('utf-8') print '' # 空白区切りの文字列を入れるリスト splitted_documents = splitDocument(raw_documents) print 'splitted_documents: ' + str(len(splitted_documents)) for d in splitted_documents: print d print '' stoplist = set('/ // @ # ( ) : ! , . 1 2 3 4 5 6 7 8 9 follow'.split()) # unicode文字列でなくてよい print 'stoplist: ' + str(len(stoplist)) for s in stoplist: print s, print '\\n' # ストップワードを除いた二重リスト stoplist_removed_documents = removeStoplist(splitted_documents, stoplist) print 'stoplist_removed_documents: ' + str(len(stoplist_removed_documents)) for t in stoplist_removed_documents: for w in t: print w, print '' print '' # 全ての単語を重複ありで含むリスト all_tokens = sum(stoplist_removed_documents, []) print 'all_tokens: ' + str(len(all_tokens)) for t in all_tokens: print t, print '\\n' # 一回しかでてこない単語のみを持つセット # 今回はこの部分を無効化しています。 tokens_once = set([]) #getTokensOnce(all_tokens) print 'tokens_once: ' + str(len(tokens_once)) for t in tokens_once: print t, print '\\n' # 一回しかでてこない単語を除いた最終的なテキスト preprocessed_documents = removeTokensOnce(stoplist_removed_documents, tokens_once) print 'preprocessed_documents: ' + str(len(preprocessed_documents)) for d in preprocessed_documents: for w in d: print w, print '' print '' """ preprocessed_documents_str = '' for d in preprocessed_documents: preprocessed_documents_str += (' '.join(d)) + '\\n' f = open(file_name + '_preprocessed.txt', 'w') f.write(preprocessed_documents_str) f.close() """ # # ここからはgensimを使ったコードになります # from gensim import corpora, models, similarities dictionary = corpora.Dictionary(preprocessed_documents) #dictionary.save_as_text(file_name + '.dict') #print dictionary.token2id dummy_profile = extractKeyword(u"ピアノにすごく興味があります。ピアノが上手な人がうらやましいです。今度キーボード買おうと思います(^^)") print 'dummy_profile:\\n' + ' '.join(dummy_profile) + '\\n' # 上記のprintはこんな結果になります # ピアノ 興味 ピアノ 上手 人 今度 キーボード (^^) profile_bow_vec = dictionary.doc2bow(dummy_profile) print 'profile_bow_vec:' print profile_bow_vec print '' # 上記のprintは、例えば私の環境だとこんな結果になります # [(0, 1), (299, 2), (484, 1)] # id=0は「人」、id=299は「ピアノ」、id=484は「興味」です。 # 「キーボード」が消えています。辞書(dictionary)にないからです。 # 実際問題として、「人 ピアノ 興味」だけじゃその人のこと何にも分からないですね。 # つまり、全く意味のないプロフィール分析になってしまっています\(^o^)/ # 各モデルのベクトル空間変換モデルを作成しています # lsiモデルのみtfidfコーパスが必要なため一部作成順序が変わっています。 bow_corpus = [dictionary.doc2bow(d) for d in preprocessed_documents] tfidf_model = models.TfidfModel(bow_corpus) tfidf_corpus = tfidf_model[bow_corpus] lsi_model = models.LsiModel(tfidf_corpus, id2word=dictionary, num_topics=100) lda_model = models.LdaModel(bow_corpus, id2word=dictionary, num_topics=100) # dummy_profileのベクトルを各モデルに合わせて変換しています profile_tfidf_vec = tfidf_model[profile_bow_vec] profile_lsi_vec = lsi_model[profile_tfidf_vec] profile_lda_vec = lda_model[profile_bow_vec] # 類似度を求めるためのindexもついでに一気に作成しています tfidf_index = similarities.MatrixSimilarity(tfidf_model[bow_corpus]) lsi_index = similarities.MatrixSimilarity(lsi_model[tfidf_corpus]) lda_index = similarities.MatrixSimilarity(lda_model[bow_corpus]) # 類似度を求めるための各similarityを作成しています tfidf_sims = tfidf_index[profile_tfidf_vec] lsi_sims = lsi_index[profile_lsi_vec] lda_sims = lda_index[profile_lda_vec] # ここからは、作ったデータを確認するprintが続きます print 'profile_tfidf_vec:' print profile_tfidf_vec print '' # これだけ結果がでかくなる。lsi_modelを作ったときのnum_tipicsの影響 print 'profile_lsi_vec:' print profile_lsi_vec print '' print 'profile_lda_vec:' print profile_lda_vec print '' print 'lsi_topics:' for topic in lsi_model.show_topics(num_topics=10, num_words=10): print topic print '' print 'lda_topics:' for topic in lda_model.show_topics(topics=10, topn=10): print topic print '' print 'tfidf_sims:' print sorted(enumerate(tfidf_sims), key=lambda item: -item[1])[:3] for elem in sorted(enumerate(tfidf_sims), key=lambda item: -item[1])[:3]: print u'類似度=' + str(elem[1]) + ': ' + raw_documents[elem[0]] print '' print 'lsi_sims:' print sorted(enumerate(lsi_sims), key=lambda item: -item[1])[:3] for elem in sorted(enumerate(lsi_sims), key=lambda item: -item[1])[:3]: print u'類似度=' + str(elem[1]) + ': ' + raw_documents[elem[0]] print '' print 'lda_sims:' print sorted(enumerate(lda_sims), key=lambda item: -item[1])[:3] for elem in sorted(enumerate(lda_sims), key=lambda item: -item[1])[:3]: print u'類似度=' + str(elem[1]) + ': ' + raw_documents[elem[0]] print ''
サンプルコードの計算結果
使ったプロフィールはこんな感じです。
# 使った生のプロフィール ピアノにすごく興味があります。ピアノが上手な人がうらやましいです。今度キーボード買おうと思います(^^) # 上記のプロフィールは下記のような単語集合として扱われます ピアノ 興味 ピアノ 上手 人 今度 キーボード (^^)
サンプルコードを実行すると、下記のような出力が表示されます。tfidf, lsi, ldaの各モデルを使って、与えられたプロフィールと類似度が高いプロフィールをそれぞれ上位3件ずつ表示しています。
一見うまくいっているみたいに見えますが、うまくいっている結果を貼っただけです…(^^) 他のテキストで試してみると分かるんですが、うまくいかない方がはるかに多いです。
tfidf_sims: [(25, 0.33484662), (0, 0.18499933), (191, 0.1027666)] 類似度=0.334847: エレクトーン、ピアノ、アコーディオンを弾いてます。ゲーム音楽、アイリッシュミュージック、ビバップ〜モダンジャズ、ハリネズミ。 類似度=0.184999: あの人です. 類似度=0.102767: 絵が好きな人 lsi_sims: [(0, 0.71691507), (213, 0.60491246), (45, 0.58843875)] 類似度=0.716915: あの人です. 類似度=0.604912: Founder of Treasure Data, Inc. Founder of the MessagePack project. github: 類似度=0.588439: 中の人は幼女じゃありません。 lda_sims: [(4, 0.81514251), (25, 0.81514251), (114, 0.81514251)] 類似度=0.815143: love♡☞Dance部/fashion/東進HS西葛西校/もののふ/大倉士門/藤田富/外川礼子 女の子とアイドルが大好きです☆ 彡 followまってます\(^o^)/♡ 類似度=0.815143: エレクトーン、ピアノ、アコーディオンを弾いてます。ゲーム音楽、アイリッシュミュージック、ビバップ〜モダンジャズ、ハリネズミ。 類似度=0.815143: ユルメン目ヒゲオヤジ科。ArduinoとMakeと電子工作と組み込みとiPhoneとMacとカメラと猫とクライミングとだいたいその辺にいます。Make (Almost) Anything.
よりよい結果を出すには
結果をよくする方法はたくさんあります。列挙しておきます。
各プロフィールをもっと長く(良質に)する
各ユーザーのプロフィールをもっと良質なものにすれば、lsiやlda等の大域的なモデルはもっと良い結果になると思います。
ツイッターにこだわるなら、各ユーザーの情報を単純なプロフィールではなく、各ユーザーのツイートを数千~数万ぐらい集めてさらに特徴的な語を集めた情報をプロフィールとして使えば、もっと良い結果だったかもです。
ツイッター以外なら、wikipediaの各記事を一つの単位として、ある記事とある記事の類似度を計算する、とかだともっと良い結果になったかもです。
mecabの辞書をもっと充実させる
ツイッタープロフィールは口語的、かつ、新語が多いので、形態素解析がうまくいっていませんでした。ここは大いに改善の余地があります。
前処理を充実させる
ストップワードの除去とか、特徴のなさすぎる単語をもっと取り除くとか。
lsi, ldaのトピック数を最適化する
今回は100トピックにしましたが、本来は2~300ほどでよい結果がでるっぽいです。
今回の方法は穴だらけなので、他にも色々あると思います…(^^) 何かいい案が浮かんだら@ts_3156に教えてください(^^)
もっと簡単で正確なツイッター類似度計算
正確には類似度計算ではないのですが、あるユーザーを表す特徴語を効率的に見付ける方法を二つ紹介します。
ツイッターのリスト構造を使う
「あるユーザーが入っているリストには、そのユーザーと同じようなユーザーがたくさん入っている」という仮定に基づき、リスト構造を分析することであるユーザーの特徴語を見付ける方法があります。
私の作っている「えごったー」にクラスタ分析として実装されています。えごったー以外に同じような仕組みを見たことがないんですが、かなり精度よく特徴語を見付けられるので、よかったら試してみてください(^^)
とにかく人力でNG語を除去してそれっぽい単語だけを残す
「クラスタ判定」論より証拠なのでとりあえず使ってみて下さい。けっこう正確な情報がでて驚くと思います。このサイトはどうやらJSのみで動いているようなのですが、そのJSが圧巻です。
とにかく手当たり次第にNG語を除去して、残った単語をクラスタ語として抽出しています。lsiやldaみたいな難しいモデルを使わなくても、工夫次第ではよい結果がだせるという素晴らしい例だと思います!
参考リンク
LSIやLDAを手軽に試せるGensimを使った自然言語処理入門