slim3でProjectionQueryを使えるようにしてみた
AppEngine SDK1.6.6からAppEngineでProjectionQuery https://developers.google.com/appengine/docs/java/datastore/queries#Query_Projection が使えるようになったので試してみました。
自分の場合、AppEngineのDatastoreまわりは完全にslim3に依存していることもあり、せっかくなのでいつもお世話になっているslim3のコードの勉強も兼ねて、slim3でProjectionQueryが使えるように仕込んでみることにしました。
ProjectionQueryとは何か
ProjectionQueryをひとことで言うと、keysOnlyのコスト(通常の1/7の金銭コスト)でEntityのプロパティを取得できるQueryです。
AppEngineのデータストアではリレーショナルDBのようにテーブルの特定のパラメータだけ取得することができず、Entityをまるごと取得する必要があります。そのため特定のプロパティだけ集計したい場合でもEntity取得と同じコストが掛かってしまいます。それをProjectionQueryを使うとリレーショナルDBと同じように特定のプロパティだけ低コストで取得できるようになります。
ProjectionQueryの制限
といってもProjectionQueryには制限があります。おもな制限は以下。
- indexを張っているプロパティのみ取得可能
- equal filterで指定しているプロパティは取得できない(影響無し?)
- Set,Listなどのコレクションは取得できない(これは惜しい…)
indexを張っているプロパティのみ取得可能というのは、AppEngineのIndexの仕組みによるものです。そもそもAppEngineで使われているKeyValue型のデータストアは単体ではkeyを指定してValueオブジェクトを取得するもので、Queryでデータを取得するにはDatastoreとは別にindexを持つ必要があります。AppEngineではQueryを実行時に、まずindexにscanを掛けてValueオブジェクトを取得用のkeyを取得し、次にkeyによりDatastoreに対してオブジェクトを取得するという、2段階のステップを踏みます。取得したいプロパティがindexに含まれている場合は、プロパティをindexから取得し2段階目を経由せずそのまま返すのがProjectionQueryの仕組みではないかと思われます。
Slim3での使い方
今回はSlim3に手を入れて以下のような通常のクエリと同じような形でProjectionQueryを実行できるようにしてみました。
(ビルドしたjarファイルもあるので、手軽に確認してみたい人はお試しください)
BookMeta e = BookMeta.get(); List<Book> books = Datastore.query(e) .filter(e.userId.equal("xxxx")) .select(e.price, e.boughtMonth) .asProjectionList(); for(Book book : books){ System.out.println(book.getPrice()); // 520: 値段が入っている System.out.println(book.getRating()); // null: 指定していない値はnull }
この例はユーザxxxxの蔵書情報を取得しています。3行目では通常のQueryと同様にユーザIDを指定しています。4行目のselect句では取得したいプロパティを指定し、5行目のasProjectionListでProjectionQueryとして実行し、Projectionの結果を受け取っています。
ProjectionQueryの結果は通常のQueryの結果とは異なり、Query指定時にselectで指定したプロパティの値しか含まれていません。(そのためProjectionQueryで取得した結果をputしないよう注意が必要です)
ProjectionQueryはどういう場面で使えるか
このProjectionQueryはデータを集計するシーンで効果を発揮するように思います。
例えば蔵書管理アプリの場合、あるユーザの月間・年間の購入費をカウントするには通常のQueryだとBookオブジェクトを取ってこないといけませんでしたが、ProjectionQueryだと購入額プロパティをindex対象とし、Projection取得することで、keysOnlyのコストで購入費をまとめて手軽に取得できるようになります。購入費以外に購入月などもプロパティとして取得すると、月ごとの集計なども同時に行えます。
逆にProjectionQueryでは取得対象となるプロパティをindex化する必要があるため、読み込みに比べて頻繁に書き換えがあるようなプロパティには向かないです。
slim3の書き換え内容
slim3のソースで手を入れたのは、以下3ファイル、追加2ファイルです。
※試作レベルなので動作に責任は持てません。
間違いなどあればご指摘頂けるとうれしいです。
AbstractQuery.java
// 以下プロパティ、メソッドを追加 protected List<PropertyProjection> projections = new ArrayList<PropertyProjection>(); protected PreparedQuery prepareProjectionQuery() { applyFilter(); applyProjection(); return txSet ? ds.prepare(tx, query) : ds.prepare(query); } public List<Entity> asProjectionEntityList() { PreparedQuery pq = prepareProjectionQuery(); return pq.asList(fetchOptions); } protected void applyProjection() { for (PropertyProjection projection : projections) { query.addProjection(projection); } }
ModelQuery.java
// 以下メソッドを追加 public ModelQuery<M> select(CoreAttributeMeta... attribute) throws NullPointerException { List<ProjectionCriterion> criteriaList = new ArrayList<ProjectionCriterion>(); for(CoreAttributeMeta a : attribute){ criteriaList.add(new DefaultProjectionCriterion(a)); } ProjectionCriterion[] criteria = new ProjectionCriterion[criteriaList.size()]; for(int i=0; i<criteriaList.size(); i++){ criteria[i] = criteriaList.get(i); } projections.addAll(DatastoreUtil.toProjections(modelMeta, criteria)); return this; } public List<M> asProjectionList() { applyPolyModelFilter(); List<Entity> entityList = asProjectionEntityList(); List<M> ret = new ArrayList<M>(entityList.size()); for (Entity e : entityList) { ModelMeta<M> mm = DatastoreUtil.getModelMeta(modelMeta, e); M model = mm.entityToModel(e); mm.postGet(model); ret.add(model); } ret = DatastoreUtil.filterInMemory(ret, inMemoryFilterCriteria); return DatastoreUtil.sortInMemory(ret, inMemorySortCriteria); }
DatastoreUtil.java
// 以下メソッドを追加 protected static List<PropertyProjection> toProjections(ModelMeta<?> modelMeta, ProjectionCriterion... criteria) { List<PropertyProjection> list = new ArrayList<PropertyProjection>(criteria.length); for (ProjectionCriterion c : criteria) { if (c == null || c.getProjection() == null) { throw new NullPointerException( "The element of the criteria parameter must not be null."); } list.add(c.getProjection()); } return list; }
ProjectionCriterion.java
public interface ProjectionCriterion { public PropertyProjection getProjection(); }
DefaultProjectionCriterion.java
public class DefaultProjectionCriterion extends AbstractCriterion implements ProjectionCriterion { protected PropertyProjection projection; public DefaultProjectionCriterion(CoreAttributeMeta<?, ?> attributeMeta) throws NullPointerException { super(attributeMeta); projection = new PropertyProjection( attributeMeta.getName(), attributeMeta.attributeClass); } @Override public PropertyProjection getProjection(){ return this.projection; } }
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でもごく短時間で解析することができました。
レコメンド内容
肝心のレコメンドの内容ですが、登録数が多いシリーズだと何かしらの関係性がありそうですが、マイナーなシリーズだとそのシリーズを登録したユーザの別の登録シリーズがランダムに並んでいるだけのようにも見えました(それはそれで関連性はありそうですが)。
たとえば今のところ一番登録数が多い銀の匙の場合、同じ著者によるシリーズが上位にレコメンドされていたりと何かしらの関連性はありそうです。ただレコメンドされるのは登録数が多いシリーズである傾向があり、全体登録数ランキングに近い結果となっているようです。
結局、それなりにデータ数が多くないと面白いレコメンド結果はなかなか見られないかもしれません…。