最近の砂場活動その14: GoogleAnalytis For Firebaseのデータを使ってImplicit-feedbackな推薦システムを構築する

  • ここ半年ほどデータ分析やりまくっているのはいいんだけど、機械学習全然やってない
    • 仕事に不満があるわけでは全然ないけど、人間は欲張りなのであれこれやりたい
  • FirebaseのBigQuery Exportである程度データが溜まりつつある

ということで、タイトルの通り、趣味プロジェクトであるML-Newsに推薦システムを導入してみました、という内容です。特に目新しいことは書いてなくて、主に自分用のログ目的要素が強いです。

これまでのML-Newsの関連エントリの出し方とその問題点

これまでの関連エントリの出し方

ML-Newsは名前の通り機械学習に関するニュースサイトで、元々のエントリには機械学習っぽい単語が入っていることが多いです。作者の僕も機械学習の知識はあるため、以下の形で元のエントリに関連するエントリを出していました。

  • 機械学習に関連する単語を「根性で」列挙
  • Aho–Corasick algorithmを使って、タイトル中に含まれる機械学習に関連する単語を抽出
  • 抽出された単語を元にDBから文字列検索

大分ナイーブですね。まあでも、関連エントリを出すための初手としては悪くもないでしょう。

問題点

これまで説明した方法は以下の問題点がありました。

  • 機械学習に関連する単語を列挙し続けるのはしんどい...
    • 古典的なものは大分列挙したつもりだが、深層学習関連の手法は新しい単語や手法名がどんどん出てくる
    • 機械学習関係の研究を仕事でやっているわけではないので、正直キャッチアップしきれるわけがない
    • とはいえ、新しい単語を追加しないと単語のカバレッジが足りないこと起因で関連エントリが出せなかったり、納得性の低いエントリが関連エントリに出てしまう
  • 単語しか見ていないので、ユーザーの行動履歴の情報を関連エントリを出すロジックに含めることができない

Implicit-feedbackな推薦システムの導入

これらの問題点を解決するために、Implicit-feedbackな推薦システムを導入することにしました。この推薦は協調フィルタリングのアプローチの一種ですが、ユーザーによるアイテムのratingというexplicitなフィードバックを元に推薦するものではなく

  • ユーザーがアイテムを閲覧した
  • ユーザーがアイテムをお気に入りに追加した

といったimplicitな行動を元に推薦するアプローチです。有名な論文はCollaborative Filtering for Implicit Feedback Datasetsでしょうか。

データの取得

ML-Newsでもユーザーの閲覧履歴はFirebaseに溜めていっており、BigQuery Exportを使うことで簡単に学習データを得られます*1。Pythonだとこういう感じ。(事例id, ユーザーid, 閲覧回数)のタプルが手に入ります。

学習データを得るためのスクリプト

from google.cloud import bigquery
import pandas as pd

def get_firebase_training_data(client):
    query = """
    WITH user_example_pairs AS (
      SELECT
        user_id,
        CAST(REGEXP_EXTRACT(page_location, r"^https://www.machine-learning.news/example/([0-9]+)") AS INT64) AS example_id,
      FROM (
        SELECT
          event_date,
          event_name,
          (SELECT value.string_value FROM UNNEST(event_params) AS x WHERE x.key = "page_location") AS page_location,    
          (SELECT value.string_value FROM UNNEST(event_params) AS x WHERE x.key = "page_referrer") AS page_referrer,
          user_pseudo_id AS user_id,
        FROM
          `ml-news.my_firebase_dataset.events_2020*` )
      WHERE
        STARTS_WITH(page_location, "https://www.machine-learning.news/example/")
        AND event_name = "page_view"
    )

    SELECT
      example_id,
      user_id,
      COUNT(*) AS count
    FROM
      user_example_pairs
    INNER JOIN
      `ml-news`.source__db.example
    ON
      example_id = example.id
    WHERE
      ((example.label = 1) OR (example.score > 0.0) AND (example.label != -1))
    GROUP BY
      example_id, user_id
    """

    query_job = client.query(query)
    return query_job.to_dataframe().dropna()


bq_client = bigquery.Client()
data = get_training_data(bq_client)

学習 & 推薦

アルゴリズムの実装は今回の興味のスコープ外なので、よく使われているライブラリをそのまま使います。

学習を回して、あるエントリに対する関連エントリを計算するスクリプトはこんな感じ。

学習 & 関連エントリを出すスクリプト

user_idとitem_idはintにしておく必要があるので、LabelEncoderを使ってエンコードし、関連エントリで元のエントリのidが必要になったらデコードしています。

data = get_training_data(bq_client)
data['example_id'] = data['example_id'].astype("category")
data['user_id'] = data['user_id'].astype("category")

# intのidに相互変換が必要なので、LabelEncoderを使う
le = preprocessing.LabelEncoder()
le.fit(data['example_id'])

train = coo_matrix((data['count'].astype(float),
                   (le.transform(data['example_id']),
                    data['user_id'].cat.codes)))

model = implicit.als.AlternatingLeastSquares(factors=64, use_gpu=False)
model.fit(train, show_progress=False)

result = pd.DataFrame({'example_id' : [], 'related_example_id': []})

for target_example_id in set(data['example_id']):
  related = [int(item) for item in le.inverse_transform([k for k, v in model.similar_items(le.transform([target_example_id])[0], N=6)]) if item != target_example_id]
  tmp = pd.DataFrame({'example_id' : [], 'related_example_id': []})
  tmp['related_example_id'] = related
  tmp['example_id'] = int(target_example_id)
  result = result.append(tmp)

result['example_id'] = result['example_id'].astype(int)
result['related_example_id'] = result['related_example_id'].astype(int)

計算させるのはどこでもいいのですが、趣味でもk8sを動かすいいおもちゃが欲しいところだったので、GKE上のバッチで動かしています。n1-standard-1が1台で全然余裕で動く。

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: recommend-related-examples
spec:
  schedule: "0 */1 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: recommend-related-examples
            image: gcr.io/my-ml-news/my-recommend_related_examples:latest
            imagePullPolicy: Always
          restartPolicy: OnFailure

うまく動かすための工夫

学習データを増やす

ML-Newsは細々としたサイトなので、学習データに使えるエントリの閲覧データはあまり多くありません。以下のデータを擬似的に閲覧データと見做すことで、学習データを増やしました。

  • Twiter上でユーザーがあるエントリに対して言及した
  • あるエントリに対してユーザーがブクマした

これは大分効いたようで、推薦結果が主観的にもよくなったなと感じました。

推薦対象のフィルタリング

クロールしてきたエントリ全てに対して推薦させると、流石に計算時間が厳しいです。「このエントリは機械学習に関連するか」を数年前から数万件自分でアノテーションしているので*2、そのデータを使って機械学習に関連しそうなエントリかを分類し、機械学習に関連しそうなエントリのみを対象にしました。データ取得のSQLでいうと、((example.label = 1) OR (example.score > 0.0) AND (example.label != -1))です。これで推薦対象を1 / 100以下に絞ることができるので、クラウド破産せずに済みます。

新規エントリに対する推薦

バッチで推薦を行なっているので、新規のエントリに対しては推薦を出すことができません。こういったエントリに対しては、閲覧回数上位のエントリを推薦するようにしました。よくあるアプローチですね。

推薦結果

うまくいっていそうな事例をいくつか見ていきます。

スクリーンショット 2020-04-10 1.00.45.png (631.4 kB)

スクリーンショット 2020-04-10 1.03.52.png (401.2 kB)

まとめ

DAUがぐっと伸びたりすると面白かったのですが、DAUは正直あまり伸びませんでした。まあ、他に効果大きそうな施策が普通にたくさんありそう...。ただ、サイト内の回遊率は上がっているので、多少はユーザーの興味に合ったエントリを出せているのかなぁと思ったりしています。

微妙な結果なので、これが仕事だと悲しい限りですが、いくら失敗しても痛くないのが趣味プロジェクトのいいところですね。どんどん失敗していこう。

*1:ややこしいデータ転送を考える必要なく、Webコンソールでぽちぽちやればいいだけなので助かる...!

*2:毎日通勤電車でぽちぽちやってる