tfidf, lsi, ldaを使ったツイッターユーザーの類似度計算

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みたいな難しいモデルを使わなくても、工夫次第ではよい結果がだせるという素晴らしい例だと思います!



参考リンク


Corpora and Vector Spaces

Topics and Transformations

LSIやLDAを手軽に試せるGensimを使った自然言語処理入門


著者プロフィール
Webサイトをいくつか作っています。
著者プロフィール