行動履歴をもとに協調フィルタリングとWord2Vecでレコメンドしてモデルの精度を評価する

行動履歴をもとに協調フィルタリングとWord2Vecでレコメンドしてモデルの精度を評価する

レコメンド関連の勉強のために使えるデータを探していたところ、Kaggleで丁度良いデータセットがあったので、今回はユーザの行動履歴をもとにアイテムをレコメンドする方法とそのレコメンドモデルを評価する方法を説明します。
使用するアルゴリズムは古典的なアイテムベース協調フィルタリングと、割と新しい手法であるWord2Vec(アイテムを分散表現するのでitem2vecとも呼ばれます)です。
Word2Vecを使ったレコメンドの実装は、リクルートの以下の発表を参考にしました。

ここで、ユーザの行動履歴とは、ECサイトで例えるとユーザが商品を閲覧/購買した履歴のことを意味します。
また、レコメンドモデルの評価にはオンライン評価とオフライン評価の2つがありますが、今回はオフラインでの評価を実装しました。
※2019/12/3にオフライン評価指標の実装と可視化に関して記事を書いています。

データセットの説明

Kaggle Job Recommendation Challengeというコンペのデータセットを使用します。
このデータセットは、CareerBuilder.comという求人検索エンジンをもつ企業が提供してくれたものになります。
注意:現在こちらのデータセットは非公開になっているようです。

提供されているデータセットにはいろいろなバリエーションがありますが、今回はユーザの求人への応募履歴(apps.tsv)のみにフォーカスします。
apps.tsvの以下の3カラムだけを使用します。

カラム名説明
UserIDユーザID
ApplicationDate求人への応募日時
JobID求人ID

Kaggleのコンペページからデータをダウンロードし、apps.tsvを読み込み、今回使うカラムの情報を確認します。
INPUT:

import pandas as pd
import datetime
import matplotlib.pyplot as plt
import seaborn as sns

# tsvファイルの読み込み
apps = pd.read_table('data/apps.tsv')
# ApplicationDateをtimestampにパース
apps.loc[:, 'ApplicationDate'] = pd.to_datetime(apps.ApplicationDate)

print(f'ユニークユーザ数:{len(set(apps.UserID))}')
print(f'ユニークJobID数:{len(set(apps.JobID))}')
print(f'応募日from:{apps.ApplicationDate.min()}')
print(f'応募日to:{apps.ApplicationDate.max()}')

OUTPUT:

ユニークユーザ数:321235
ユニークJobID数:365668
応募日from:2012-04-01 00:00:21.270000
応募日to:2012-06-26 23:59:55.593000

30万強のユーザや求人があることや、2012年4月〜6月における求人への応募データということがわかります。

ただ、各ユーザが求人への応募を2ヶ月に渡ってするのか?という疑問があるので、1ユーザあたりの応募期間の分布を確認してみます。

# 1ユーザあたりの応募期間の分布
action_span = []
for i, v in apps.groupby('UserID', as_index=False):
    action_span.append((v['ApplicationDate'].max() - v['ApplicationDate'].min()).days)

plt.figure(figsize=(20,10))
sns.distplot(action_span, bins=30)
plt.title('distribution of action time by user')

OUTPUT:

予想通り、大半のユーザは2週間以上に渡って応募行動をしていないようです。
おそらく数件求人に応募したらアクションが終了になるのではないかと思います。

次に1ユーザが同じ求人に応募していないか(重複)がないかを確認します。

# 1ユーザあたり1応募のみの確認
(apps.groupby(['UserID', 'JobID'], as_index=False).count()['ApplicationDate']>=2).sum()

結果は0なので、1ユーザが複数回同じ求人に応募していないことがわかりました。

前処理

後で構築するレコメンドモデルに学習データとして入力できるようにデータを整形・加工します。

まず、Word2Vecではユーザごとに時系列でJobIDがソートされている必要があるので、以下を実行します。

# 応募日ごとに昇順ソート
apps.sort_values(['UserID', 'ApplicationDate'], inplace=True)

次に学習データとテストデータに分割します。
今回は応募日時(ApplicationDate)に応じて学習データとテストデータに分割します。

  • 学習データ:2012/4/1~2012/6/21
  • テストデータ:2012/6/21~2012/6/28
# 学習データとテストデータの分割
apps_train = apps[apps['ApplicationDate']=datetime.date(2012,6,21))
].copy()

ここで、Word2Vecに入力するデータは、ユーザ単位でJobIDがlistになっている必要があります。
また、Word2Vecの学習データはJobIDのみで構成されていてUserIDの情報がないので、推論(most_similar)時に各レコメンド結果をUserIDと紐づけるためにも辞書を作成しておく必要があります。

# UserIDごとに応募日時の昇順で求人IDをリスト化
apps_train_w2v = []
apps_train_w2v_dict = {}
for i, v in apps_train.groupby(['UserID']):
    # 学習データ
    apps_train_w2v.append(list(v.JobID))
    # ユーザIDをKeyに持つ辞書
    apps_train_w2v_dict[i] = list(v.JobID)

レコメンドモデル構築

協調フィルタリング

Appleが開発した機械学習ライブラリのTuri Createを使ってアイテムベース協調フィルタリングによるレコメンドモデルを構築します。
user_idとitem_idを指定するだけで、サクッとアイテムベース協調フィルタリングによるレコメンドモデルを構築できます。

import turicreate as tc
# pandas.DataFrameをTuri Create独自のSFrameに変換
train = tc.SFrame(apps_train)
test = tc.SFrame(apps_test)
# 協調フィルタリングでモデル構築
model_cf = tc.recommender.create(train, user_id='UserID', item_id='JobID')

Word2Vec

gensim.Word2Vecを使ってモデルを構築します。
なお、今回はハイパーパラメータのmin_count以外はデフォルトにしています。
min_countを1にしている理由は、most_similarで合成ベクトルを作る際に、モデル空間に存在するかどうかを考慮しない(簡略化する)ためです。

from gensim.models import word2vec
# ハイパーパラメータはデフォルト
model_w2v = word2vec.Word2Vec(apps_train_w2v, min_count=1)

推論

今回は、学習データとテストデータに共通して存在しているユーザを対象にレコメンドし、その結果を評価します。
まずは、学習データとテストデータに共通して存在しているユーザを抽出します。

# テストデータと学習データに共通するユーザIDの集合
train_test_UserID_set = set(apps_test.UserID).intersection(set(apps_train.UserID))
print(f'学習データとテストデータに共通するユーザ数:{len(train_test_UserID_set)}')
# レコメンド対象のユーザをlist化
recommend_user = list(train_test_UserID_set)

OUTPUT:

学習データとテストデータに共通するユーザ数:13485

この13,485人を対象にレコメンドします。

協調フィルタリング

recommendメソッドでお手軽にレコメンドを実行できます。
rank(評価点)を引数に入れない場合、item_base_collaborative filtering(アイテムベース協調フィルタリング)を実行します。

# 協調フィルタリングによるレコメンド
rec = model_cf.recommend(users=recommend_user, k=30, verbose=False)
rec = pd.DataFrame(rec)

Word2Vec

アイテムベース協調フィルタリングの場合とは異なり、Word2Vecのmost_similar結果には学習時に使用したアイテム(つまり過去に応募したJobID)が含まれてしまう場合があるため、やや工夫が必要です。
応募済みのJobIDをレコメンドしないために、各ユーザに対して以下の工程を行います。

  1. ユーザが応募したJobIDベクトルを全て合成し、類似度上位100件を取得
  2. 類似度上位100件の中で過去に応募したJobIDがあれば削除

ここで、アイテムベース協調フィルタリングと同じ30件ではなく100件を抽出している理由は、応募したJobIDを削除した後でもJobIDが30件以上必ず残したいためであり、なんとなく100件に指定しているだけです。

jo_list = []
user_list = []
# レコメンド対象のユーザごとに処理
for user_id in recommend_user:
    # 学習データにおけるユーザの全ての行動履歴を合成しベクトル生成。
    # most_similarityはそのベクトルと類似した100件のJobIDを類似度とともにtupleで返却する。
    sim_list = model.wv.most_similar(positive=apps_train_w2v_dict[user_id], topn=100)

    jo_list_by_user = []
    # most_similarされたJobIDに応募済のものがある場合、レコメンド結果から除外
    for sim in sim_list:
        if sim[0] not in apps_train_w2v_dict[user_id]:
            jo_list_by_user.append(sim[0])

    jo_list.extend(jo_list_by_user)
    user_list.extend([user_id]*len(jo_list_by_user))

# 推論結果
rec_w2v = pd.concat(
    [
        pd.DataFrame(user_list, columns=['UserID']),
        pd.DataFrame(jo_list, columns=['JobID'])
    ],
    axis=1,
)

レコメンド結果のオフライン評価

協調フィルタリング、Word2Vecを使ったレコメンド結果を評価していきます。
レコメンドの評価にはオンライン評価、オフライン評価の2通りの評価方法が存在しますが、今回はオフライン評価を行います。
オンライン評価、オフライン評価を説明すると以下になります。

分類説明
オンライン評価レコメンド結果を実際にユーザに提示して、ユーザのコンバージョン率などを評価します。例)A/Bテスト
オフライン評価過去のある時点における実績をもとにレコメンドし、ユーザの実際の行動をどれくらい予測できるかなどを評価します。例)Precision@K, Recall@K

オンライン評価、オフライン評価を詳しく知りたい方はブレインパッドさんのブログが大変参考になります。

さて、まずはオフライン評価で代表的なPrecision@KとRecall@Kを測定するメソッドを定義します。

指標説明
Precision@Kレコメンドしたアイテムのうち、どれくらいユーザの行動を予測できていたかを測る指標
Recall@Kユーザの実際の行動のうち、どれくらいレコメンド結果と一致するかを測る指標
import random

def evaluate_rec(
    recommend_user: list,
    apps_test: pd.DataFrame,
    rec: pd.DataFrame,
    topN: int
) -> (list, list):
    """'''
    レコメンド結果と実際の行動結果をもとにPresicion@K, Recall@Kを返却するメソッド。

    Parameters
    -------
    recommend_user : list
        レコメンド対象のUserIDのlist。
    apps_test : pd.DataFrame
        実際のユーザの行動履歴。UserID, JobIDをカラムに持つこと。
    rec : pd.DataFrame
        レコメンド結果。UserID, JobIDをカラムに持つこと。
    topN: int
        レコメンド結果の上位何件を評価対象とするか。

    Returns
    -------
    (recall, precision) : tuple
        Recall@KとPrecision@K。
    """

    precision = []
    recall = []

    for user_id in random.sample(recommend_user, 1000):
        result_set = set(apps_test.query(f'UserID == "{user_id}"').JobID)
        result_size = len(apps_test.query(f'UserID == "{user_id}"').JobID)

        rec_set = set(rec.query(f'UserID == "{user_id}"').JobID[:topN])
        try:
            # Recall@K
            recall.append(len(result_set.intersection(rec_set))/len(result_set))
            # Precisionl@K
            precision.append(len(rec_set.intersection(result_set))/len(rec_set))
        except ZeroDivisionError as e:
            print(e)
            print(f"UserID:{user_id} wasn't recommended.")

    return (recall, precision)

では、定義したメソッドを使って、協調フィルタリングとWord2Vecのレコメンドの評価を行います。

# 協調フィルタリングの評価
recall, precision = evaluate_rec(recommend_user, apps_test, rec, topN=30)
# Word2Vecの評価
recall_w2v, precision_w2v = evaluate_rec(recommend_user, apps_test, rec_w2v, topN=30)

plt.figure(figsize=(20,7))
plt.subplot(2,2,1)
sns.distplot(precision)
plt.title(f'Precisionl@{topN} of CF')
plt.subplot(2,2,2)
sns.distplot(recall)
plt.title(f'Recall@{topN} of CF')

plt.subplot(2,2,3)
sns.distplot(precision_w2v)
plt.title(f'Precisionl@{topN} of Word2Vec')
plt.subplot(2,2,4)
sns.distplot(recall_w2v)
plt.title(f'Recall@{topN} of Word2Vec')

オフライン評価で見た場合、Word2Vecよりも協調フィルタリングの方がユーザの実際の行動を補足できていることがわかります。
さすがに差がありすぎてWord2Vecが可哀想なので、カバレッジ(coverage)についても評価してみます。
ちなみに、カバレッジ(coverage)は、レコメンドしたアイテムが極端に偏っていないかを測る指標です。カバレッジが大きいほど満遍なくレコメンドができていて、小さいほどレコメンドされるアイテムに偏りがあることになります。

# 学習で用いられたJobIDの集合
train_job_set = set(apps_train.JobID)
# 協調フィルタリングでレコメンドされたJobIDの集合
rec_job_set = set(rec.JobID)
# Word2VecでレコメンドされたJobIDの集合
rec_job_w2v_set = set(rec_w2v.JobID)
# カバレッジ
coverage = len(train_job_set.intersection(rec_job_set))/len(train_job_set)
coverage_w2v = len(train_job_set.intersection(rec_job_w2v_set))/len(train_job_set)
print(f'coverage of CF : {coverage}')
print(f'coverage of Word2Vec : {coverage_w2v}')

plt.bar(['CF', 'Word2Vec'], [coverage, coverage_w2v])


min_count=1にしているせいか、Word2Vecのカバレッジが90%を超えています。
一方、協調フィルタリングはカバレッジが20%を下回っていて、レコメンドするアイテムにかなり偏りがあることがわかります。

まとめ

今回はオフライン評価指標のPrecision@K(適合率)、Recall@K(再現率)、Coverage(カバレッジ)でレコメンドモデルの精度を比較しましたが、この他にもたくさんの評価指標があります。
オフライン評価指標はユーザに実際にレコメンドした結果に基づいたものではなく、あくまでユーザにレコメンドしたと仮定し、それがユーザの行動をどれくらい予測したものなのかを測定するものです。
たとえオフライン評価で精度が良くても、オンライン評価(CVRのA/Bテストなど)では精度が悪くなる場合もあります。
結局、結果が重要なのだからオンライン評価だけを使えばいいではないかという声が聞こえてきそうですが、レコメンドで使えるモデルは今回紹介したもの以外にもたくさんあり、Word2Vecのハイパーパラメータでさえしっかりチューニングしようとすると限りなくA/Bテストのパターンを作る必要があります。
オンライン評価はオフライン評価に比べて格段に工数や時間がかかるため、ここぞというモデルの比較に使うべきでしょう。
目的に応じてどのオンライン評価指標やオフライン評価指標を使うのか、よ〜く議論が必要です。