AppEngineでMahoutを使ったレコメンド機能を作ってみた
Apache Mahoutは様々な機械学習・データマイニング手法を、Hadoopを利用してスケーラブルに取り扱うことができるライブラリなのですが、ちょっとしたレコメンド機能の開発にも手軽に利用することができます。今回は自分用の備忘録も兼ねてAppEngine/Javaでの利用実例を紹介してみたいと思います。
やったこと
先日リリースした漫画の読書管理Webサービス「コミックライブラリー(コミ蔵)」
で漫画の関連シリーズのレコメンド機能を作成しました。Amazonとかでよくある商品の関連アイテムのレコメンドです。利用するユースケースやデータ量にもよりますが、意外と簡単にAppEngine上のサービスで推薦機能を使えるようになりました。
実行構成
レコメンド機能の実行の流れは下記の通り。
- レコメンド機能を実装したServletをcronで1週間に1回、Backend Instance上で実行。
- 全てのエントリー(全ユーザの登録した漫画シリーズ)のエントリーIDをDataStoreから読み込み、各シリーズごとに、類似シリーズを10本推薦する。
- シリーズごとに類似シリーズのシリーズIDを保有するレコメンドEntityを生成し、全てDataStoreに保持する。
- 通常実行環境(Front Instance)から、関連シリーズを提示したいシリーズのシリーズIDから、3で保持したレコメンドEntityを読み込んで関連シリーズを表示する。
以下、レコメンド機能の実装の流れについて説明します。
Apache Mahoutのダウンロード
Mahoutはレコメンド(協調フィルタリング)以外にも機械学習やクラスタリングなど、かなりたくさんの機能を搭載していますが、AppEngine上では使えないjarファイルも使われているため、今回は利用する機能をレコメンドに絞り、Mavenを使わず必要最低限のjarを手動で配置しました。
まずMahoutのサイトから物件(12/6/26現在ver0.7が最新)をダウンロード。
ダウンロードしたzipファイルを解凍し、直下およびlibフォルダ配下にある以下4つのjarファイルをWEB-INF/lib配下に置く。またmahout-core-0.7をビルドパスに追加する。
mahout-core-0.7.jar mahout-math-0.7.jar guava-r09.jar slf4j-api-1.6.1.jar
レコメンド機能の実装
今回はシリーズにたいする関連シリーズのレコメンド(=Item To Item)を実装しましたが、Item To User や User To Userも簡単に実装できます。
漫画エントリーの読み込み
レコメンド計算には、ユーザが登録した漫画シリーズのIDとそのItemを登録したUserのIDのセット群が必要になります。
今回のシステムではユーザのシリーズ登録エントリーのIDとして、{ユーザID}-{シリーズID}を利用しているため、Keyだけ読み込んでkeyStringからユーザIDとシリーズIDを取得します。
(※コードは説明用に簡略化しています)
EntrySeriesMeta e = EntrySeriesMeta.get(); List keys = Datastore.query(e).asKeyList(); for(Key key : keys){ String keyStr = key.getName(); String[] strs = keyStr.split("-"); String userId = strs[0]; String itemId = strs[1]; if(!idMap.containsKey(userId)){ Set itemIds = new HashSet(); itemIds.add(itemId); idMap.put(userId, itemIds); }else{ idMap.get(userId).add(itemId); } }
Mahout用データモデル変換
Mahoutでレコメンド計算するためには先のユーザIDとシリーズIDの対応を独自のデータ型に変換する必要があります。ちょっと面倒なのは、IDとしてLong型しか利用できない点で、それ以外の型を使っている場合にはLong型に変換する必要があります。
Map itemIdlongStringMap = new HashMap(); FastByIDMap userData = new FastByIDMap(idMap.size(), idMap.size() + 1); for (Iterator>> it = idMap.entrySet().iterator(); it.hasNext();) { Map.Entry> entry = (Map.Entry>)it.next(); String userId = (String)entry.getKey(); Set itemIds = (Set)entry.getValue(); FastIDSet itemIdSet = new FastIDSet(itemIds.size()); for(String itemId : itemIds){ long longItemId = 0L; if(itemId.contains("X")){ // AmazonのIDにはXが含まれる場合がある
// とりあえずXを10に変換(重複する可能性はあるが今回は気にしない)
String convertedItemId = itemId.replaceAll("X", "10");
longItemId = Long.valueOf(convertedItemId); }else{ longItemId = Long.valueOf(itemId); }
// レコメンド後に元ItemIDを引けるよう対応関係を保持
itemIdlongStringMap.put(longItemId, itemId); itemIdSet.add(longItemId); } userData.put(Long.valueOf(userId), itemIdSet); }
レコメンド計算&実行
先に生成したデータからレコメンド計算を行い、シリーズごとに類似シリーズの推薦を行います。
データモデル、類似度の定義、レコメンダを作成しますが、これらの詳しい内容についてはこちらのブログが詳しいです。
// データモデルの作成
// (今回はratingなどの嗜好度を利用しないのでBoolean嗜好モデル)
DataModel model = new GenericBooleanPrefDataModel(userData);
// アイテムベースの類似度定義(嗜好度を用いない谷本係数による類似度)
ItemSimilarity similarity = new TanimotoCoefficientSimilarity(model);
// アイテムベースのレコメンダ作成(Boolean嗜好モデルによるレコメンダ)
ItemBasedRecommender recommender =
new GenericBooleanPrefItemBasedRecommender(model, similarity);
// シリーズごとに関連シリーズを求めてEntityに保持する
List recommends = new ArrayList(); for (Iterator> it = itemIdlongStringMap.entrySet().iterator(); it.hasNext();) { Map.Entry entry = (Map.Entry)it.next(); long longItemId = (long)entry.getKey();
// 関連シリーズを10件推薦する
List recommendations = recommender.mostSimilarItems(longItemId, 10);
List similarItemIds = new ArrayList();
for(RecommendedItem recommendation : recommendations){ String recommendedItemId = itemIdlongStringMap.get(recommendation.getItemID()); similarItemIds.add(recommendedItemId); }
// シリーズ毎に推薦された関連シリーズを保持する
ItemToItemRecommend recommend = new ItemToItemRecommend();
recommend.setSimilarItemIds(similarItemIds);
recommend.setItemId( (String)entry.getValue() );
recommends.add(recommend); }
// 作成したレコメンド結果をDatastoreに保持する
Datastore.put(recommends);
あとは保持したレコメンド結果を必要に応じて読み出して表示します。
今回はシリーズにたいする関連シリーズのレコメンド(=Item To Item)を実装しましたが、Item To User や User To Userも簡単に実装できます。
たとえばItem To Userとするには、
List recommendations = recommender.mostSimilarItems(longItemId, 10);
となっているのを以下のように変更します
List recommendations = recommender.recommend(longUserId, 10);
実行コスト
今回の解析対象エントリは以下の通り(まだ利用者少ない…(涙)
・解析した全シリーズエントリ総数:26,251件 ・有効ユーザ数:1,269人 ・レコメンドシリーズ数:9,677件
解析に要した計算時間
(Backend Instanceは一番安いB1クラス)
keysOnlyでの全エントリkeyの取得時間:2,936ms レコメンド時間:406ms レコメンド結果put時間:13,955ms
レコメンド結果は、この時はBatch Putではなく、100件ごとに入れていたので時間が掛かっていますが、まとめてPutするともっと短くなるはずです。
このくらいのデータ数であれば、B1クラスのInstanceでもごく短時間で解析することができました。
レコメンド内容
肝心のレコメンドの内容ですが、登録数が多いシリーズだと何かしらの関係性がありそうですが、マイナーなシリーズだとそのシリーズを登録したユーザの別の登録シリーズがランダムに並んでいるだけのようにも見えました(それはそれで関連性はありそうですが)。
たとえば今のところ一番登録数が多い銀の匙の場合、同じ著者によるシリーズが上位にレコメンドされていたりと何かしらの関連性はありそうです。ただレコメンドされるのは登録数が多いシリーズである傾向があり、全体登録数ランキングに近い結果となっているようです。
結局、それなりにデータ数が多くないと面白いレコメンド結果はなかなか見られないかもしれません…。