データ分析がしたい

企業でデータ分析などやっています。主にRやPythonによるデータマイニング・機械学習関連の話題やその他備忘録について書いてます。

はてなブックマーク記事のレコメンドシステムを作成 PythonによるはてなAPIの活用とRによるモデルベースレコメンド

私は情報収集にはてなブックマークを多用しており、暇な時は結構な割合ではてなブックマークで記事を探してます。しかし、はてなブックマークは最新の記事を探すのは便利ですが、過去の記事を探すにはいまいち使えません。個人的には多少過去の記事でも自分が興味を持っている分野に関しては、レコメンドして欲しいと感じてます。

ありがたいことにはてなはAPIを公開しており、はてなブックマークの情報を比較的簡単に取得できます。そこでこのAPIを利用して自分に合った記事を見つけるようなレコメンド機能をRとPythonで作成してみたいと思います。


利用するデータは、はてなAPIを使って収集します。具体的には、はてなブックマークフィードを利用して自分のブックマークしているURLを取得し、そのURLをブックマークしているユーザをエントリー情報取得APIを用いて抽出し、そのユーザのブックマークしているURLを収集します。このuser⇒ブックマークURL⇒user⇒ブックマークURLという手順を繰り返せば大量にURLを収集することができますが、今回の目的は自分に合いそうな記事を見つけることなので、探索範囲は自分と同じ記事をブックマークしているユーザに限ります。

なるべく省エネでいきたいので、Pythonを用いて記事を収集し、Rを用いてデータ加工と記事のスコアリングを行います。システム構築という面ではPythonのみで作った方が良いかもしれないですが、データ加工、モデル構築に関してはRの方が個人的に慣れているので。


まず何をするにしてもデータが必要ということで、Pythonを用いてURLを収集します。データ収集に利用したのは以下のコードです。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys,re,time
import csv
import urllib2
import json
import feedparser

reload(sys)
sys.setdefaultencoding("utf-8")

def main():
    # Web情報取得の準備
    opener = urllib2.build_opener()
    user = "Overlap" # はてなuser_idを指定

    # はてなブックマークのfeed情報の取得
    url_list = []
    id = 0
    for i in range(0,200,20):
        feed_url = "http://b.hatena.ne.jp/" + user + "/rss?of=" + str(i) # はてなAPIに渡すクエリの作成
        try:
            response = opener.open(feed_url) # urlオープン
        except:
            continue
        content = response.read() # feed情報の取得
        feed = feedparser.parse(content) # feedパーサを用いてfeedを解析
        # entriesがない場合break
        if feed["entries"] == []:
            break
        # urlリストの作成
        for e in feed["entries"]:
            try:
                url_list.append([id,e["link"],user,e["hatena_bookmarkcount"],re.sub("[,\"]","",e["title"])]) # url_listの作成(titleのカンマとダブルクォーテーションを置換)
                id += 1
            except:
                pass
        time.sleep(0.05) # アクセス速度の制御

    # 対象urlをブックマークしているユーザの抽出
    user_list = []
    for i, url in enumerate(url_list):
        response = opener.open("http://b.hatena.ne.jp/entry/jsonlite/" + url[1]) # はてなAPIによるブックマーク情報の取得
        content = response.read()
        tmp = json.loads(content) # jsonの解析
        # userリストの作成
        for b in tmp["bookmarks"]:
            user_list.append([url[0],b["user"]])
        time.sleep(0.05) # アクセス速度の制御

    # 自分と同じurlをブックマークしている数を集計
    count_user = {}
    for i, (id,uname) in enumerate(user_list):
        if count_user.has_key(uname):
            count_user[uname] += 1
        else:
            count_user[uname] = 1

    # ブックマーク数上位のユーザのブックマークurl情報を取得
    for uname, count in sorted(count_user.items(), key=lambda x:x[1],reverse=True):
        print uname, count
        if uname == user: continue # 自分のidは除く
        # 直近200件のブックマークurlを取得
        for i in range(0,200,20):
            try:
                feed_url = "http://b.hatena.ne.jp/" + uname + "/rss?of=" + str(i) # feed取得用クエリ
            except:
                continue
            response = opener.open(feed_url) # feed情報の取得
            content = response.read()
            feed = feedparser.parse(content) # feed情報の解析
            if feed["entries"] == []:
                break
            for e in feed["entries"]:
                if [e["link"],uname] in [ [tmp[1],tmp[2]] for tmp in url_list]: continue # 過去に取得した情報は除く
                try:
                    url_list.append([id,e["link"],uname,e["hatena_bookmarkcount"],re.sub("[,\"]","",e["title"])])
                    id += 1
                except:
                    pass
            time.sleep(0.05) # アクセス速度の制御
        if count < 10: break # 同じブックマーク数が10より少ない場合break

    print len(url_list)
    # ファイルの出力
    ofname = "url_list.csv"
    fout = open(ofname,"w")
    writer = csv.writer(fout,delimiter=",")
    writer.writerow(["id","url","user","count","title"])
    for t in url_list:
        writer.writerow(t)
    fout.close()

if __name__ == "__main__":
    main()

あまり使い回す予定はないのでmainの下にべた書きしてるし、エラー処理とか適当です。はてなフィードはfeedparserを用いてパースしています。feedparserに関しては以下のページが分かりやすいかと思います。
 http://python.g.hatena.ne.jp/muscovyduck/20081221/p1
また、はてなブックマークエントリ情報取得APIはJSON形式で取得されるので、jsonライブラリを利用して解析してます。json形式の読み込みに関しては以下のページが参考になると思います。
 http://tmlife.net/programming/python/python-json-module.html
あとAPIを高速で叩くのはサーバ負荷的に良くないと思うので、0.05秒程度の間を置くようにしてます。

上記のコードでは、自分と同じブックマーク数が10以上のユーザのブックマークURLを200件まで取得しています。この辺のパラメータは精度やデータ量との兼ね合いで変更した方がいいかもしれません。
URL情報を取得できたので、次はRを用いて上記情報からモデルを作成しレコメンドURLを抽出します。


Rでは、データをurl×userの行列に整形し、自分がブックマークしたurlを元にスコアリングを行います。データの整形にはreshape2を用いて、pivotテーブルを作成することにします。スコアリングとしては、単純な協調フィルタリングでは疎なデータの場合レコメンドできる記事が限られるので、モデルベースのフィルタリングを行うことにします。
アルゴリズムとしてはランダムフォレストを利用します。選択の理由としては、パラメータチューニングなしでもそこそこの精度が出る点と変数の貢献度(今回の場合は似ているユーザ)がわかるためです。ちなみにWindowsの場合、文字コードエラーが起こる場合がありますが、その場合はurl_list.csvをエディタなどでshift-jisに変換すれば良いでしょう。

以下が利用したコードです。

library(reshape2)
library(randomForest)

user <- "Overlap" # 自分のはてなid

# データの読み込み
url_list <- read.csv("url_list.csv",head=T,sep=",")
tmp <- unique(url_list[,c(2,5)])                        # urlとtitleを保存
tmp[,2] <- substr(tmp[,2],1,50)                         # タイトルの文字数を50字までに

# ピボットテーブルによるデータ整形
dat <- dcast(melt(url_list,id.vars=c("url","user"),measure.vars="id"),url~user,length)
url <- dat[,1]                                          # urlの保存
dat <- dat[,-1]                                         # urlの除外
target <- dat[,colnames(dat) == user]                   # user行の保存
dat <- dat[,colnames(dat) != user]                      # user行の除外
users <- colnames(dat)                                  # user名の保存
colnames(dat) <- paste(rep("user",ncol(dat)),1:ncol(dat),sep="") # 列名の変更
dat <- data.frame(dat,target)                           # targetの追加

# randomForestを利用したモデル構築
dat.rf <- randomForest(factor(target)~.,data=dat)
pred.rf <- predict(dat.rf,dat,type="prob")[,2]          # モデルの適用
rank <- data.frame(url,dat$target,pred.rf)
rank <- merge(rank,tmp,by="url",all.x=T)                # titleの追加
rank <- rank[order(rank$pred.rf,decreasing=T),]
rank <- rank[rank$dat.target==0,c(4,1,3)]               # 非ブックマークのみに限定
rownames(rank) <- 1:nrow(rank)
write.csv(rank,"rank.csv")                  # データの出力

# 貢献度の確認
sim_user <- data.frame(users,varImpPlot(dat.rf))
head(sim_user[order(sim_user$MeanDecreaseGini,decreasing=T),],20)

出力結果(上位10件)

"","title","url","pred.rf"
"1","第19回 ロジスティック回帰の学習:機械学習 はじめよう|gihyo.jp … 技術評論社","http://gihyo.jp/dev/serial/01/machine-learning/0019",0.214
"2","SEも知っておきたいデータサイエンス - 行動予測を活用したCRMシステムの活用法と要件:ITpro","http://itpro.nikkeibp.co.jp/article/COLUMN/20130507/475061/",0.2
"3","「データ分析とソフトウエアの会社になります」?ジェフ・イメルト氏・米ゼネラル・エレクトリック(GE)","http://itpro.nikkeibp.co.jp/article/COLUMN/20130604/482084/",0.182
"4","NTT、「稼げる」研究所へ新組織 人工知能でビッグデータ技術を収益化  :日本経済新聞","http://www.nikkei.com/article/DGXZZO55804220T00C13A6000000/",0.174
"5","RとPythonによるデータ解析入門","http://www.slideshare.net/gepuro/rpython",0.148
"6","機械学習チュートリアル@Jubatus Casual Talks","http://www.slideshare.net/unnonouno/jubatus-casual-talks",0.148
"7","「ゲームとTwitterとFacebookしかしないなんてもったいない」、Gunosy開発チーム根掘","http://gigazine.net/news/20130417-gunosy/",0.144
"8","R統計解析入門: 統計解析 テクニカルデータプレゼンテーション  梶山 喜一郎","http://monge.tec.fukuoka-u.ac.jp/R_analysis/0r_analysis.html#cross_table",0.144
"9","情報学研究データリポジトリ ニコニコ動画コメント等データ","http://www.nii.ac.jp/cscenter/idr/nico/nico.html",0.142
"10","データサイエンティストを目指すに当たって、ぜひ揃えておきたいテキストたちを挙げてみる - 道玄坂で働","http://tjo.hatenablog.com/entry/2013/05/07/191000",0.138

見事にデータマイニング・ビッグデータ系の記事ばっかりですね。私の興味・関心を反映していて、個人的にはかなり満足できる結果になってます。はてなAPIはタグ情報なども取得できるので、タグ情報やタイトルのテキスト解析情報なども用いればより精度向上が見込めると思います。また、見たけどブックマークしなかった記事が多数含まれているんですが、閲覧履歴がないとここに対応するのは難しいですね。趣味レベルの試みですから現状ではこれで十分かと思います。

ちなみに貢献度が高かったユーザは以下の通りでした。

                 users MeanDecreaseGini
user95  TohgorohMatsui         3.635295
user37      irisu22001         3.068073
user115        yokkuns         2.462939
user48           Keiku         1.886593
user61          moa108         1.737433
user5       asa6008885         1.734965
user76          s-feng         1.731526
user116       yoshia_e         1.723635
user102            TYK         1.573865
user34       ilford400         1.537937

貢献度が高いことは必ずしも類似度が高いというわけではないですが、自分と興味が近いユーザ候補がわかるのもこの分析の面白い点ではないでしょうか。


はてなブックマークは自由に使えるデータとして非常に面白いものだと思うので、今後も何か面白いことができないか考えていきたいですね。