シンプルなコンテンツベースのレコメンデーション・エンジンをPythonで実装する

ECサイト向けのレコメンデーション・エンジンを構築すると仮定しましょう。

構築する方法としては、コンテンツベースか協調フィルタリングを使用する2つの進め方があります。それぞれのメリットとデメリットを見てみましょう。そして、コンテンツベースエンジンを簡単に実装する方法について探りましょう(Herokuにデプロイ可能です)。

コンテンツベースを使用するとどのようになるのか先に知りたい方は、ほぼ同じレコメンデーション・エンジンがGroveの商品(紹介)ページで使用されていますので、見てみてください。

コンテンツベースのレコメンデーション・システムはどのように機能するのか

商品説明や商品名、価格などの実際のアイテムプロパティなどが使用されるため、コンテンツベースシステムで構築されていると周りには思われているのではないでしょうか。これまで一度もレコメンデーション・システムの使用を検討したことがなかったとしても、銃を突きつけられ、30秒以内にどのようなものか説明するよう強要されたとしたらコンテンツベースのシステムを形容するでしょう。「えーっと、多分、似たような商品説明の同じ製造元のたくさんの商品を表示します」のように。

商品自体の実際の属性を使用して同じような商品をお勧めしているのです。これは自然なことで、実世界でもこのような方法で買い物をします。例えば、オーブントースター売り場に行き商品を見てみると、メーカーや価格、または30分以内でターキーが焼ける機能などによって陳列されていることでしょう。

コンテンツベースは万全ではない

ユーザにとって、多くのECサイト上でオーブントースター項目を閲覧するのは、すでに簡単なこととなっています。私たちが本当に必要としているのは、販売の向上につながるレコメンデーテョンシステムなのです(レコメンドしなければ購入されずに終わってしまいます)。顧客が『ハリー・ポッターと秘密の部屋』のページを閲覧している場合、レコメンダが『ハリー・ポッターとアズカバンの囚人』を表示して、その顧客がこれを購入したとしても、これに対して出版社のランダムハウスのデータサイエンティストは大喜びしてはいけません。それは、顧客がすでにハリー・ポッターシリーズの存在を知っていた可能性があり、レコメンダで表示しなかったとしても購入した可能性を否めないからです。この場合、レコメンドが販売の向上につながったとは言えません

レコメンダの協調フィルタリングはどのように機能するのか

そこで、別の方法が必要になってきます。協調フィルタリングあるいはCFで検索してみてください。協調フィルタリングの裏にある大きな概念は直感的です。ある人が購入する可能性が非常に高い商品は、似たような多くの人がすでに購入している商品とも言えます。そのような意味では先ほどのハリー・ポッターシリーズにも当てはまりますが、この協調フィルタリングでは、より深く、より遠くの商品群からレコメンドすることができ、スペルミスにも対応できます(例えば、”ハリー・プッター”のようにタイプミスをしてもお勧めは出てきます)。さらに、コンテンツベースシステムのみの場合よりも、遥かに販売の向上を招くことが多いのが実情です。

協調フィルタリングの裏にある大きな概念は直感的であるものの、きちんとした説明を何度もしなければならないという面を持っています。協調フィルタリングのみの場合、お勧めする商品に関する情報を一切持っていません。システムにとって単なる商品番号とユーザIDの格子状の誰が何を購入したかを表すデータでしかないのです。「コンテンツベースのシステムと組み合わさると、協調フィルタリングのアルゴリズムは計測可能なパフォーマンス改善が見られない」という意味では、直感に反しています。しかし、お勧めする商品のことを何か知っていれば、少しは役に立つのではないでしょうか。

そんなことはありません。

多くの場合、顧客が何を購入したのかをまとめた簡単なマトリクスを使えば基本的には”信号”を100パーセント取得することができます。

では、一体なぜコンテンツベースを構築手段として使用するのでしょうか。

コンテンツベースの方法が有効な場合

協調フィルタリングが有効なオプションではない場合もあります。例えば、Google検索ページ結果のリンクから飛んできた顧客に、商品の詳細ページの閲覧をお勧めするとしましょう。この顧客については何も知らないので、購買マトリクスを構築することはできません。しかし、コンテンツベースのシステムであれば、類似の商品をお勧めすることが可能です。このような点においてコンテンツベースのレコメンダは、協調フィルタリングの”何もない状態からのスタート”という問題点を解決します。

またコンテンツベースは、ある特定の商品を買う意図を強く示した場合(商品名に関連する単語によってGoogle検索から見込み客が導かれるように)、自動化されたキュレーションの情報を提供することも可能です。あなたがNike Proハイパークールフィッテドメンズコンプレッションシャツに興味を持っているとしたら、恐らくNike Proハイパークールメンズプリンテッドタイツも気に入ることでしょう。コンテンツベースのエンジンは多くのマニュアルキュレーションがなくても、関連する商品をピックアップすることができるのです(これらの商品は”パンツ”や”シャツ”のカテゴリーでは表示されません)。

TF-IDFで構築しよう

多くのアルゴリズムと同様に、私たちはすぐに使用可能な多くのライブラリを使用し、日常をとても便利で簡単なものにしています。その方法を見ていきますが、最終的に全ての実装はPythonでは10行未満になるということを心に留めておいてください。その内容を確認してコードを見る前に、まずは方法を説明します。

patagoniaのアウトドア衣類と用品のサンプルデータセットを用意しました。データは次に示します。Githubでは、データ全体(最大550キロバイト)を確認することができます。

| id | description                                                                 |
|----|-----------------------------------------------------------------------------|
|  1 | Active classic boxers - There's a reason why our boxers are a cult favori...|
|  2 | Active sport boxer briefs - Skinning up Glory requires enough movement wi...|
|  3 | Active sport briefs - These superbreathable no-fly briefs are the minimal...|
|  4 | Alpine guide pants - Skin in, climb ice, switch to rock, traverse a knife...|
|  5 | Alpine wind jkt - On high ridges, steep ice and anything alpine, this jac...|
|  6 | Ascensionist jkt - Our most technical soft shell for full-on mountain pur...|
|  7 | Atom - A multitasker's cloud nine, the Atom plays the part of courier bag...|
|  8 | Print banded betina btm - Our fullest coverage bottoms, the Betina fits h...|
|  9 | Baby micro d-luxe cardigan - Micro D-Luxe is a heavenly soft fabric with ...|
| 10 | Baby sun bucket hat - This hat goes on when the sun rises above the horiz...|

データはこれです。IDと、商品に関してTitle – Descriptionという形式で書かれたテキスト、これだけです。ここでは、TF-IDF(Term Frequency – Inverse Document Frequency)と呼ばれる簡単な自然言語処理のテクニックを使います。商品説明をパースし、商品ごとの異なるフレーズを特定し、それらのフレーズに基づいた”類似の”商品を見つけます。

TF-IDFは、商品説明の中に複数回現れる(”単語の出現頻度”)1単語、2単語、3単語のフレーズ(自然言語処理用語では、ユニグラム、バイグラム、トライグラム)の全て(今回のケースでは)を確認し、それを同じフレーズが全ての商品説明に登場した回数で割ります。つまり、特定の商品において”より特異な”特定の単語(上記の商品の9番目にある”micro d-luxe”)は高いスコアを獲得し、頻繁に登場するけれど他の商品にも同じく頻繁に登場する単語(同じく9番目にある”soft fabric”)は低いスコアになります。

それぞれの商品についてTF-IDFの単語とスコアを得たら、コサイン類似度と呼ばれる測定方法を使って、どの商品がそれぞれに”最も近いか”を確認します。

ラッキーなことに、多くのアルゴリズムと同様に、一から作る必要はありません。すでに作成されたライブラリがあり、私たちに代わって手間がかかる仕事をしてくれます。この場合、Pythonのscikit LearnにはTF-IDFコサイン類似度の両方の実装があります。私はこれら全てをFlaskのアプリでまとめました。これは、あなたが本番環境で実装するであろう場合と同じように、REST APIを通じてレコメンデーションを提供します(実際には、このコードはGroveの実システムで行っていることと大した違いはありません)。

このエンジンには.train()メソッドがあります。入力した商品のファイルを通してTF-IDFを実行し、データセット内の全商品に関して類似する商品を計算し、それらの商品をコサイン類似度によってRedisに保存します。.predictメソッドは、単に商品のIDを取得し、すでに計算された類似品をRedisから返します。なんてシンプルなのでしょう。

エンジンの全体のコードは次のようになります。コードがどのように機能するかを説明しており、Githubで完成したFlaskのアプリを確認することができます。

import pandas as pd
import time
import redis
from flask import current_app
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import linear_kernel


def info(msg):
    current_app.logger.info(msg)


class ContentEngine(object):

    SIMKEY = 'p:smlr:%s'

    def __init__(self):
        self._r = redis.StrictRedis.from_url(current_app.config['REDIS_URL'])

    def train(self, data_source):
        start = time.time()
        ds = pd.read_csv(data_source)
        info("Training data ingested in %s seconds." % (time.time() - start))

        # redisから古い学習データを削除
        self._r.flushdb()

        start = time.time()
        self._train(ds)
        info("Engine trained in %s seconds." % (time.time() - start))

    def _train(self, ds):
        """
        エンジンに学習させる。

        各商品についてのユニグラム、バイグラム、トライグラムのTF-IDF行列を生成。
        'stop_words'パラメータにより、TF-IDFモジュールに
        'the'などの一般的な英単語を無視するよう指示。

        その後、Scikit Learnのlinear_kernel(今回はコサイン類似度と同じ)を用いて、
        全商品間の類似度を計算する。

        各商品の類似商品について繰り返した後、
        類似度の高い100件を保存。100件で止める理由は……
        あなたが本当に表示すべき似た商品はいくつですか?

        類似度とそのスコアはソート済みセットとしてredisに保存され、
        各セットが各商品に相当します。

        :param ds: description, idの2つのフィールドを持つpandasデータセット
        :return: なし
        """

        tf = TfidfVectorizer(analyzer='word',
                             ngram_range=(1, 3),
                             min_df=0,
                             stop_words='english')
        tfidf_matrix = tf.fit_transform(ds['description'])

        cosine_similarities = linear_kernel(tfidf_matrix, tfidf_matrix)

        for idx, row in ds.iterrows():
            similar_indices = cosine_similarities[idx].argsort()[:-100:-1]
            similar_items = [(cosine_similarities[idx][i], ds['id'][i])
                             for i in similar_indices]

            # 一番目の商品はその商品自身なので削除。
            # 'sum' はタプルのリストから単一のタプルへの変換。
            # [(1,2), (3,4)] -> (1,2,3,4)
            flattened = sum(similar_items[1:], ())
            self._r.zadd(self.SIMKEY % row['id'], *flattened)

    def predict(self, item_id, num):
        """
        これ以上ないぐらいシンプル!
        類似する商品と、その'スコア'をredisから取得するだけです。

        :param item_id: string
        :param num: 類似する商品の、返すべき件数
        :return: [["19", 0.2203],["494", 0.1693], ...]のような、リストのリスト。
        各サブリストの1項目めがID、2項目めが類似度スコア。
        類似度スコアによって降順ソートされています。
        """

        return self._r.zrange(self.SIMKEY % item_id,
                              0,
                              num-1,
                              withscores=True,
                              desc=True)

content_engine = ContentEngine()

実行してみてください

ご自分で実際にやってみたいと思われたなら、非常に簡単なのでぜひ試してみてください。readmeの説明に従って行えば、サンプルのpatagoniaのデータを使用して、すぐにローカルで実行することができるでしょう。このエンジンはHerokuにもデプロイ可能です。

協調フィルタリングを活用しましょう。#contentbased