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を使った自然言語処理入門
