Elasticsearch と Mahout を組み合わせ、「関連する商品」や、「関連するユーザ」を表示する機能をつくりました。
アルゴリズムはMahoutをそのまま使い、ロジックをElasticsearch プラグインにすることで、サーバ&データストアがElasticsearchでまかなえます。
以下にプラグインを公開しました。
https://github.com/hadashiA/elasticsearch-flavor
これを実際に運用中のサービスがあるんですが、商品数がせいぜい数十万件程度だったので Hadoop等は使わず、リアルタイムで結果を計算して返す作りになっています。
おそらく1,000万件くらいになってくるとやり方を考え直さないといけなくなりそうだけど、そこまで大規模にならなければなかなか便利なものができた。
Mahoutとは
Apache Mahout は、色々な機械学習のアルゴリズムが実装されているJavaのライブラリです。
収録されてるアルゴリズムをつかうと、強調フィルタリングによるレコメンドが簡単なインターフェイスでできるようになっています。
強調フィルタリングというのは、レコメンドのアルゴリズムの一種で、ユーザがアイテムを評価したデータを元に関係を調べる方法のことを言います。大きくわけるとユーザベースとアイテムベースの2種類がありますが、Mahout はどちらも利用できます。ユーザベースもアイテムベース、どちらも特定のユーザを入力にとってアイテム一覧を返す点ではまったく同じです。前者は、似てるユーザが評価したアイテムを候補にし、後者は、今までに評価したアイテムと似てるアイテムを候補にします。
それから、このアルゴリズムの結果を利用するとで「似てる○○」を一覧で出す機能なんかもMahoutでできます。
分散処理しなくて良いのか?
強調フィルタリングは、素朴に考えると、最大でユーザ数 x アイテム数 の件数のデータをなめることになるため (実際には関係ありそうなユーザ/アイテムに絞り込んでる)、大規模になってくるとHadoopやSparkで分散コンプーティングするという運用になるとおもいます。
ただ、Mahoutの独自実装の、ユーザ-アイテム の 1:N 関係を表現するのに特化したクラスを使うと、ユーザとアイテムの関係データ1件につき、高々20バイトくらいしか使わない(らしい)ため、100万件くらいまでなら、20 x 1,000,000 = 19M くらいで、分散せずとも普通に間に合います。
一応、Mahoutには、DataModel
というインターフェイスがあって、レコメンドをする際の入力データの実装を隠蔽しており、自前で DataModel
を実装してあげると好きなデータストアが使える作りになっている。
ただ、この DataModel
というクラスは、必要なデータを必要になるたびにがんがん取りに行く作りになっていたので、好きなデータストアDataModel
を作っても実用的ではなさそうだった。 サンプルを見るに、 DataModel
というインターフェイスは、あらかじめメモリに読み込んだデータにアクセスするためのインターフェイスという使われかたが多い。
Javaで書いたものが動くサーバとしてのElasticsearch
ElasticsearchはJavaで好きにプラグインが書けるので、プラグインのコードからMahoutをライブラリとして読み込んで実行することが簡単にできます。
レコメンドに必要なデータを Elasticsearchに保存しておけば、REST API・データストア・Mahout実行する人 の3つの役割をElasticsearchにやらせることができて便利だ。
Elasticsearch を使うと、データの投げ込みも簡単だし、Herokuとかでも動く。
elasticsearch-flavor
今回作ったプラグインは、ユーザIDとかをHTTPで投げると、関係ありそうなデータを読み込み、レコメンドを計算して返すようになっている。
バッチ処理等は不要。
インストールは、
$ plugin --url 'https://github.com/f-kubotar/elasticsearch-flavor/releases/download/v0.0.2/elasticsearch-flavor-0.0.3.zip' --install flavor
使うときは、Preference(ユーザがアイテムを評価したデータ)のインデックスを作ります。
curl -XPOST localhost:9200/my_index -d '{
"mappings" : {
"preference" : {
"preference": {
"properties": {
"user_id": {
"type": "long"
},
"item_id": {
"type": "long"
},
"value": {
"type": "float"
}
}
}
}
}
}'
インデックスを作ったら、適当にデータを入れます。
valueは、そいつがそのアイテムをどれくらい好きかをflaotで表現した値です。user_id、item_id は、それぞれユーザ/アイテムの一意なIDです。Mahoutの実装上の都合で、整数である必要があります。
選択するアルゴリズムによりますが、LogLikelihoodSimilarity
などの一部のアルゴリズムは、valueを全く参照しないので、全部1とか入れておいても動かすことができます。
ちなみに、再生回数をvalueにするときとか、valueがどんどん増えていく場合には、upsertつかうと便利だ。
curl -XPOST 'localhost:9200/my_index/preference/1:101/_update' -d '{
"script" : "ctx._source.value += value",
"params" : {
"user_id" : 1,
"item_id": 101,
"value": 1
}
}'
上記の例では、既に 1:101
というidのpreferenceが存在する場合は、valueをインクリメントします。
データができたら、準備完了です。
たとえば、似てるアイテムを取得したいときは、以下のようなGETをすると、即結果が返ってきます。
$ curl 'localhost:9200/my_index/preference/_flavor/similar_items/5803?size=3&similarity=EuclideanDistanceSimilarity'
HTTP/1.1 200 OK
Content-Length: 126
Content-Type: application/json; charset=UTF-8
{
"hits": {
"hits": [
{
"item_id": 40891,
"value": 1.0
},
{
"item_id": 48541,
"value": 1.0
},
{
"item_id": 151,
"value": 1.0
}
],
"total": 3
},
"took": 4
}