Pythonでレコメンド・システムをつくる
協調フィルタリングっていうやつですね。
Last.fmユーザのデータセットを使って、
アーティストをレコメンドしてくれるプログラムを作ってみた。
$ recommend.py 274 <あなたが再生したことのあるアーティスト:再生数> Depeche Mode : 7386 浜崎あゆみ : 2046 X JAPAN : 1365 Michael Jackson : 1261 倖田來未 : 1024 hide : 910 GACKT : 746 安室奈美恵 : 689 ZARD : 560 Céline Dion : 552 Emilie Autumn : 487 MALICE MIZER : 483 the GazettE : 462 L'Arc~en~Ciel : 373 KAT-TUN : 316 宇多田ヒカル : 295 Aerosmith : 274 AAA : 264 アンティック-珈琲店- : 247 モーニング娘。 : 241 清春 : 222 Marilyn Manson : 216 Cristina D'Avena : 212 SUGIZO : 210 YOSHIKI : 199 LUNA SEA : 197 Martin L. Gore : 184 DIR EN GREY : 175 雅-MIYAVI- : 169 Tommy february6 : 165 Dave Gahan : 164 YUI : 152 Lucio Battisti : 145 大塚愛 : 145 hitomi : 143 Alphaville : 137 T.M.Revolution : 128 SADS : 126 Wonder Girls : 124 NEWS : 118 Fabrizio De André : 118 Capsule : 109 883 : 105 동방신기 : 104 RENTRER EN SOI : 99 KOKIA : 95 倉木麻衣 : 94 ポルノグラフィティ : 94 いきものがかり : 90 DJ OZMA : 86 <あなたにオススメのアーティスト:推薦度> Luzmelt : 702 Lady Gaga : 591 Eths : 584 BoA : 509 嵐 : 470 BIG BANG : 422 KOTOKO : 370 The Horrors : 344 Perfume : 298 Dark Schneider : 295
こんな感じで、再生履歴に応じて、おすすめの聴いていないアーティストをレコメンドしてくれます。
やってること
データセットから、ユーザとその嗜好(今回は再生数)を取得して、
ユーザ間のユークリッド距離もしくはピアソン相関係数を求めて、類似度を判定。
求められたユーザ間の類似度から、ユーザが再生したことのないアーティストの推薦度を求める。
そしてその推薦度TOP10のアーティストを出力する。
ユークリッド距離による類似度導出
いろいろな距離指標(ユークリッド、マンハッタン、マハラノビス…)があるけども、
一番ポピュラーなやつっすね。三平方の定理を利用したあれです。
n次元の嗜好空間上のユーザ間の距離を調べ、その逆数を類似度とする方法です。
ユーザsによるアーティストiの再生数をとして、
ユーザs, t間の類似度は
で求められる。
ゼロ除算を回避するために分母に1を加算しています。
ここで、共通するアーティストを聴いていないユーザ間では1が出力されることに注意です。
つまり、類似度が1として出力された場合は同一ユーザか類似度が全くない場合と考え、
このあとで行うアーティストの推薦度導出式には含めるべきではないということ。
ピアソン相関係数による類似度導出
データセットを見てみると、全体的に再生数が多い人、少ない人がいるわけです。
この人たちが同じような嗜好だとしても、再生数によるユークリッド距離を調べてみると、
数式の性質上、どうしても距離が大きくなってしまい、類似度が低くなってしまう。
そこで、ユーザ間の嗜好の相関を見よう、いうことで出てくるのがピアソン相関係数。
ユーザsによるアーティストiの再生数を、
ユーザsの再生数の平均を、と表すとき、
ユーザs, t間の類似度は
で求められる。
相関なので、-1〜1の値が求められる。
1は相関があり、0は無相関、-1は真逆の嗜好(つまりユーザsが好きなアーティストをユーザtは嫌う傾向にある)ということを意味するわけだ。
実装時には、ゼロ除算となる場合は0と返すようにしないといけない。
あと、この数式では、共通する再生アーティストが1人の場合でも相関係数が1となるときがある。
なので、共通アーティストがある一定数以上に満たない場合は計算しないなどの処理が必要だと思う。
アーティストの推薦度の導出
ユーザ間の類似度を導出できたら、今度はアーティストの推薦度を求める。
これはいろいろな考え方や方法があると思うが、今回は以下の数式を使って求めた。
ここではあるアーティストiを再生する全ユーザの集合。
この式は、いろいろな人に再生されているような場合、
推薦度が高くなる傾向をある程度抑えることができる。
データセット
今回は、HetRec2011用に用意されたデータを使ってみた。
商用利用でなければ使っていいというライセンス。
HetRec2011 http://grouplens.org/datasets/hetrec-2011/
基本的にタブ区切りになっているので、
csvモジュールをインポートして、delimiterをタブに変えてあげればパースできる。
詳しいフォーマットについてはreadme.txtを覗いてみてください。
なんか再生数データ以外にもいろいろはいってました。
ソースコード
上のことを踏まえてコーディングした。長いので少々見辛い…
# -*- coding: utf-8 -*- import csv import sys class User: """ ユーザIDと再生したアーティストのIDのディクショナリを保持 usrId...<int>生成するユーザのID artists...<dictionary>ユーザが再生したことのあるアーティスト """ def __init__(self, usrId, playedArtistsDic): """ コンストラクタ usrId...<int>生成するユーザのID playedArtistsDic...<dictionary>ユーザが再生したことのあるアーティスト """ self.usrId = usrId self.artists = playedArtistsDic.copy() def getSimilar1(self, target, thld=0): """ あるユーザとの類似度を計算(ユークリッド距離) target...<User>類似度を計算するユーザインスタンス thld...<int>ユーザ同士で共通アーティスト数の閾値 thld未満の共通アーティスト数では類似度は0と考える return...<float>類似度 """ distance = 0.0 #ユークリッド距離 unionArtistN = 0 for myArtist in self.artists: for targetArtist in target.artists: if(myArtist==targetArtist): #アーティストIDが一致すれば距離計算に加味 unionArtistN += 1 myWeight = self.artists.get(myArtist) targetWeight = target.artists.get(targetArtist) distance += (myWeight - targetWeight)**2 print distance distance = 1 + distance**0.5 if(distance==1.0 or unionArtistN<thld): #ユークリッド距離が1の場合は類似度がないので、0をreturn #一定数共通アーティストがいなければ類似度は0 return 0.0 return 1 / distance def getSimilar2(self, target, thld=0): """ ピアソン相関係数によるユーザ間類似度計算 target...<User>類似度を計算するユーザインスタンス thld...<int>ユーザ同士で共通アーティスト数の閾値 thld未満の共通アーティスト数では類似度は0と考える """ #それぞれのユーザの再生数平均を導出 myMean = 0.0 targetMean = 0.0 for i in self.artists.values(): myMean += i for i in target.artists.values(): targetMean += i myMean = myMean / float(len(self.artists)) targetMean = targetMean / float(len(target.artists)) #ピアソン相関係数を導出 coVar = 0.0 mySd = 0.0 targetSd = 0.0 unionArtistN = 0 for myArtist in self.artists: for targetArtist in target.artists: if(myArtist==targetArtist): unionArtistN += 1 myWeight = float(self.artists.get(myArtist)) targetWeight = float(target.artists.get(targetArtist)) mySd+= (myWeight - myMean)**2 targetSd += (targetWeight - targetMean)**2 coVar += (myWeight - myMean) * (targetWeight - targetMean) mySd = mySd**0.5 targetSd = targetSd**0.5 if(mySd*targetSd==0.0 or unionArtistN<thld): #ゼロ除算を避ける/一定数共通アーティストがいなければ類似度は0 return 0.0 return coVar / (mySd * targetSd) def getSimilarUsrs(self, usrsDic, thld=0, pearson=True): """ 各ユーザの類似度を計算したタプルリスト(ソート済み)を返す usrsDic...<dictionary>ユーザデータディクショナリ thld...<int>ユーザ同士の共通アーティスト数の閾値 thld未満の共通アーティスト数では類似度は0と考える mode...<bool>pearson相関係数による類似度関数を使うか return...<taple list>類似順にソートされた類似ユーザリスト """ simDic = {} for targetId in usrsDic: if(targetId!=self.usrId): #自分自身との類似度は計算しない #ターゲットのユーザインスタンスを生成 target = User(targetId, usrsDic.get(targetId)) if(pearson): sim = self.getSimilar2(target, thld) #ターゲットとの類似度 else: sim = self.getSimilar1(target, thld) if(sim > 0.0): #類似度があればディクショナリに追加 simDic[target.usrId] = sim if(len(simDic)==0): #類似するユーザが全く見つからなかった場合 print "Could not find your similar user." quit() return sorted(simDic.items(), key=lambda d:d[1], reverse=True) def getRecommendArtists(self, simUsrs, usrsDic): """ 類似ユーザの再生アーティスト情報から各アーティストの推薦度を返す simUsrs...<taple list>類似ユーザ usrsDic...<dictionary>ユーザデータディクショナリ return...<taple list>推薦度順にソートされた推薦アーティストリスト """ recomArtists = {} totalSim = {} #アーティストごとにユーザの類似度の合計を持つ for usr in simUsrs: simUsr = User(usr[0], usrsDic.get(usr[0])) for artist in simUsr.artists: if(artist not in self.artists): #自分が再生しているアーティストは推薦しない if(artist not in recomArtists): #recomArtistsにartistがいなければ追加する recomArtists[artist] = 0.0 totalSim[artist] = 1.0 simUsrMean = sum([i for i in simUsr.artists.values()]) / len(simUsr.artists) recomPoint = usr[1] * (simUsr.artists.get(artist) - simUsrMean) recomPoint += recomArtists.get(artist) recomArtists[artist] = recomPoint #同様に再生しているユーザの類似度の合計を求める totalSim[artist] = totalSim.get(artist) + abs(usr[1]) for artist in recomArtists: #各アーティストの推薦度を計算する recomArtists[artist] = recomArtists.get(artist) / totalSim.get(artist) return sorted(recomArtists.items(), key=lambda d:d[1], reverse=True) def readUsersData(path): """ TSV型のユーザデータファイルを読み込み、全ユーザデータをディクショナリで返す path...<string>データパス return...<dictionary>ユーザデータディクショナリ """ tsv = csv.reader(file(path, 'r'), delimiter = '\t') firstRow = True secondRow = False dic = {} usrsDic = {} usrId = 0 for row in tsv: if(firstRow): #一行目は読み飛ばし firstRow = False secondRow = True else: if(secondRow): usrId = int(row[0]) secondRow = False nowUsrId = int(row[0]) if(nowUsrId!=usrId): #現在チェックしているIDと前までのIDが一致しているか #一致しなければユーザが変わっているので、ここでusrsDicに追加 usrsDic[usrId] = dic.copy() dic = {} dic[int(row[1])] = int(row[2]) usrId = nowUsrId #最後のユーザの書き込み usrsDic[usrId] = dic.copy() return usrsDic.copy() def readArtistsData(path): """ TSV型のアーティストデータファイルを読み込み、全アーティストデータをディクショナリで返す ディクショナリのkeyはアーティストのID、valueはアーティスト名 path...<string>データパス return...<dictionary>アーティストデータディクショナリ """ tsv = csv.reader(file(path, 'r'), delimiter = '\t') firstRow = True artistsDic = {} for row in tsv: if(firstRow): #一行目は読み飛ばし firstRow = False else: artistsDic[int(row[0])] = row[1] return artistsDic def getArg(): """ コマンドライン引数(ユーザID)を取得 return...<int>ユーザID """ argv = sys.argv argc = len(argv) if (argc != 2): #引数がない場合は終了 print 'Usage: python %s userId' %argv[0] quit() return int(argv[1]) def main(usrId): #ユーザデータの取得 usrsDic = readUsersData("hetrec2011-lastfm-2k/user_artists.dat") #アーティスト名ディクショナリの取得 artistsName = readArtistsData("hetrec2011-lastfm-2k/artists.dat") #レコメンドするユーザインスタンスを生成 if(usrId not in usrsDic): #存在しないユーザIDが指定されているときは終了 print 'usrId: %d does NOT exist.' %usrId quit() usr = User(usrId, usrsDic.get(usrId)) #類似ユーザの取得 simUsrs = usr.getSimilarUsrs(usrsDic, 5) #レコメンドアーティストの取得 recommendArtists = usr.getRecommendArtists(simUsrs, usrsDic) #ユーザが再生したことのあるアーティストを表示 print "<あなたが再生したことのあるアーティスト:再生数>" #weightが高い順にソート artists = sorted(usr.artists.items(), key=lambda d:d[1], reverse=True) for artist in artists: print "%s : %d" %(artistsName.get(artist[0]), artist[1]) #推薦度上位10のアーティストをレコメンドする depth = 10 print "<あなたにオススメのアーティスト:推薦度>" for i in range(depth): artistId = recommendArtists[i][0] print "%s : %d" %(artistsName.get(artistId), recommendArtists[i][1]) #一番似ているユーザが再生しているアーティストを表示 # simUsr = User(simUsrs[0][0], usrsDic.get(simUsrs[0][0])) # print "<あなたに一番似ているユーザ%d(%f)のプレイリスト>" %(simUsr.usrId, simUsrs[0][1]) # artists = sorted(simUsr.artists.items(), key=lambda d:d[1], reverse=True) # for artist in artists: # print "%s : %d" %(artistsName.get(artist[0]), artist[1]) if __name__ == '__main__': usrId = getArg() main(usrId)
readUsersData関数で返されるディクショナリは、
valueがまたディクショナリだったりすので、少しややこしいです。
あと、ディクショナリに順序という概念がないので、
類似度や推薦度が高い順にデータが得られるように、
sorted関数使ってタプルリストで返しているとこもあります。
雑感
やはり類似度指標を求める際に、共通する再生アーティスト数でフィルタしないと、どうしても、1,2アーティストしか被っておらずそいつがあまり関係なさそうなアーティストのレコメンドの傾向を作ることが起きる。
共通する再生アーティスト数をどの程度にすればいいかは難しいところ…。
あと、これはこのデータセットの特徴かもしれないが、
全体的に日本のアーティストを聴くユーザが少なく、どうしても日本人アーティストへの評価数が少ないという点は気になった。
つまり、評価数(聴いている人)が少ないと、そのアーティストを共通して聴く人が少ないため、そのアーティストを再生しているというデータを活かしたレコメンドを提示できないということ。
例えば、アニソンをよく歌う日本人アーティストをよく聴くユーザデータを作ってレコメンドさせてみても、他の聴いていないアニソン系アーティストがなかなかレコメンドされなかったりした。
あとは、全体として聴くジャンルは偏っているんだけども、各ジャンルの有名アーティストをやや再生しているようなユーザの場合、なかなか上手くレコメンドが働いていないように見えた。確かに、共通アーティスト数の閾値を上げれば良くなるんだけども、かなり(7人以上)上げないと良さそうな結果が得られなかった。
これは、灰色の羊なんていう呼ばれ方をする問題で、複数のユーザタイプに属してしまうユーザに対しては、最適なレコメンドができないという。(コンテンツベースフィルタリングによる解決策は示されている)
ざっと感じたことをまとめると、様々なタイプに対応するなら、非常に多くのデータが必要になるんだなっていうこと。
今回は約2K人でしたが、全然足りないっすね。ビッグデータほしい。
(しかし、今回のプログラムは実装上、計算量的にも膨大なデータには対応できない…)
ただ、アルゴリズム自体は簡単ですぐにコードに起こせたので、とてもおもしろいものだと思った。
+α
レコメンドエンジンなんていうと、Gunosyなんかもこういうアルゴリズムなんだろうか…
にしてもAmazonのアイテムレコメンドとか、Twitterでのユーザレコメンドとか、
webのいろいろなところでレコメンドシステムは動いてそうなので、少し勉強できてよかった。
今回取り上げたものは、おそらく協調フィルタリングの中でもかなり単純なものっぽい。
ベイジアンネットワークを使ったものやクラスタリング手法を利用したものもあるみたい。
そういえば、フィルターバブル問題っていう言葉もあったねー。
このあたりの研究はまだまだ進んでいく感じなんでしょうか。
また機会があればもう少し勉強してみたい。
- 作者: Toby Segaran,當山仁健,鴨澤眞夫
- 出版社/メーカー: オライリージャパン
- 発売日: 2008/07/25
- メディア: 大型本
- 購入: 91人 クリック: 2,220回
- この商品を含むブログ (277件) を見る