Python入門トップページ


目次

  1. 協調フィルタリングによる推薦システム
  2. 顧客間の類似度に基づいた手法
  3. 個別データを順にマージしてみる
  4. 個別データを一気にマージしてみる
  5. 行列分解
  6. サンプルコードのまとめ
  7. Surprise による顧客間類似度に基づいた手法
  8. Surprise による行列分解
  9. ユーザごとの推薦アイテムリストを作成する
  10. 推薦アルゴリズムの性能を評価する
  11. 適合率と再現率
  12. クロスバリデーション
  13. 学習済みモデルの保存と読み込み

協調フィルタリングによる推薦システムを作ってみよう

適合率と再現率

前ページの評価指標に加えて,推薦システムのアルゴリズムを比較して評価したり,モデルのパラメータを決定したりするために利用できる評価指標として適合率 (Precision) と再現率 (Recall) があります.

適合率の定義は次の通りです.これは,ユーザごとにレイティングの予測値が高い順に \(K\) 個のアイテムを推薦したときに,それらが推薦すべきアイテムであった割合を意味します.

\begin{equation} \text{Precision@}K = \frac{|\{\text{Recommended items that are relevant}\}|}{|\{\text{Recommended items}\}|} \end{equation}

再現率の定義は次の通りです.これは,ユーザごとに推薦すべきアイテムをすべて取り出し,その中からどの程度の割合で推薦リストに含まれるか,ということを意味しています.

\begin{equation} \text{Recall@}K = \frac{|\{\text{Recommended items that are relevant}\}|}{|\{\text{Relevant items}\}|} \end{equation}

まずは,適合率と再現率を求めるプログラムの全体像を示します.


import pandas as pd
import numpy as np
from surprise import Dataset, Reader, SVD
from surprise.model_selection import train_test_split
from collections import defaultdict

np.set_printoptions(precision=3)    # 小数点以下の表示桁数を設定
np.set_printoptions(suppress=True)  # 指数表示を行わないように

def precision_recall_at_k(predictions, k=10, threshold=3.5):
    """
    ユーザごとの適合率と再現率を求めて返す
    """

    # ユーザごとに {uid:[(予測評価値, 真の評価値),...], ...} のリストを作成する
    user_est_true = defaultdict(list)
    for uid, _, true_r, est, _ in predictions:
        user_est_true[uid].append((est, true_r))

    precisions = dict()
    recalls = dict()
    for uid, user_ratings in user_est_true.items():
        # レイティングの予測値が高い順にソートする
        user_ratings.sort(key=lambda x: x[0], reverse=True)

        # 真の評価値が適合する(thresholdより高い)アイテムの数
        n_rel = sum((true_r >= threshold) for (_, true_r) in user_ratings)

        # 上位 k 個で予測評価値が適合する(thresholdより高い,つまり推薦される)アイテムの数
        n_rec_k = sum((est >= threshold) for (est, _) in user_ratings[:k])

        # 上位 k 個のうち推薦され,かつ適合もするアイテムの数
        n_rel_and_rec_k = sum(
            ((true_r >= threshold) and (est >= threshold))
            for (est, true_r) in user_ratings[:k]
        )

        # 適合率を計算する(ただし, 推薦アイテム数が0の場合は 適合率=0)
        precisions[uid] = n_rel_and_rec_k / n_rec_k if n_rec_k != 0 else 0

        # 再現率を計算する(ただし,適合アイテムが0の場合は 再現率=0)
        recalls[uid] = n_rel_and_rec_k / n_rel if n_rel != 0 else 0

        # 以下4行を有効にすると precisions と recalls の計算処理を可視化できます
        # print("--")
        # print(f"threshold={threshold}, k={k}, uid={uid}, \n{user_ratings} \nn_rel={n_rel}, n_rec_k={n_rec_k}, n_rel_and_rec_k={n_rel_and_rec_k}")
        # print(f"precision[{uid}] = {n_rel_and_rec_k}/{n_rec_k} = {n_rel_and_rec_k / n_rec_k if n_rec_k != 0 else 0:6.3f}")
        # print(f"recall[{uid}]    = {n_rel_and_rec_k}/{n_rel} = {n_rel_and_rec_k / n_rel if n_rel != 0 else 0:6.3f}")

    return precisions, recalls


# データの読み込み
url_ratings = "https://github.com/rinsaka/sample-data-sets/blob/master/collaborative_filtering_ratings2.csv?raw=true"
df_ratings = pd.read_csv(url_ratings)

reader = Reader(rating_scale=(1, 5)) # レイティングのスケールを設定する
# 列名を指定してデータフレームからデータセットを読み込む
data = Dataset.load_from_df(df_ratings[["customer", "item", "rating"]], reader)

# 学習データとテストデータに分割
trainset, testset = train_test_split(data, test_size=0.21, shuffle=False)
# trainset, testset = train_test_split(data, test_size=0.21)

# 因子数
n_factors = 5
# エポック数
n_epochs = 100000
# バイアスを利用するか
biased = False
# biased = True
# 学習率
lr_all = 0.005
# 正則化パラメータ
reg_all = 0.02
# モデルの設定
mf = SVD(n_factors=n_factors,
         n_epochs=n_epochs,
         biased=biased,
         lr_all=lr_all,
         reg_all=reg_all
         )

# モデルの当てはめ(学習)を行う
mf.fit(trainset)

# 予測評価値のマトリックス
predict_matrix = np.zeros((trainset.n_users, trainset.n_items))
for uid in range(trainset.n_users):
    for iid in range(trainset.n_items):
        pred = mf.predict(uid, iid, verbose=False)
        predict_matrix[uid, iid] = pred.est

# テストデータのマトリックス
raw_test_matrix = np.zeros((trainset.n_users, trainset.n_items))
raw_test_matrix[:] = np.nan
for r, c, v in testset:
    raw_test_matrix[r, c] = v

print("----------------")
print(f"number of trainset = {sum(len(r) for r in trainset.ur.values())}")
print(f"number of testset  = {len(testset)}")
print("----- test -----")
print(raw_test_matrix)

test_predict_matrix = np.copy(raw_test_matrix)
test_predict_matrix = np.where(~np.isnan(test_predict_matrix), predict_matrix, np.nan)
print("----- test_predict -------")
print(test_predict_matrix)

# テストデータに対する検証
predictions = mf.test(testset)

# 適合率と再現率の計算
precisions, recalls = precision_recall_at_k(predictions, k=3, threshold=3)

print('---')
print(f"precisions : [", end="")
for prec in precisions.values():
    print(f"{prec:6.3f}", end="")
print("]")
print(f"recalls    : [", end="")
for rec in recalls.values():
    print(f"{rec:6.3f}", end="")
print("]")
print('---')

print(f"(全ユーザの平均適合率)Precision = {sum(prec for prec in precisions.values()) / len(precisions):6.3f}")
print(f"(全ユーザの平均再現率)Recalls   = {sum(rec for rec in recalls.values()) / len(recalls):6.3f}")
----------------
number of trainset = 70
number of testset  = 19
----- test -----
[[nan nan nan nan nan  2.  4.  2.  3.  5.]
 [nan nan nan nan nan  1.  3.  4.  3.  2.]
 [nan nan nan nan nan  1.  3.  4.  3.  2.]
 [nan nan nan nan nan nan  2.  2.  2.  1.]
 [nan nan nan nan nan nan nan nan nan nan]
 [nan nan nan nan nan nan nan nan nan nan]
 [nan nan nan nan nan nan nan nan nan nan]
 [nan nan nan nan nan nan nan nan nan nan]
 [nan nan nan nan nan nan nan nan nan nan]
 [nan nan nan nan nan nan nan nan nan nan]]
----- test_predict -------
[[  nan   nan   nan   nan   nan 2.556 2.369 1.595 2.642 5.   ]
 [  nan   nan   nan   nan   nan 1.    3.113 4.036 2.813 2.374]
 [  nan   nan   nan   nan   nan 1.    2.955 3.77  3.48  2.029]
 [  nan   nan   nan   nan   nan   nan 2.028 1.412 2.273 1.642]
 [  nan   nan   nan   nan   nan   nan   nan   nan   nan   nan]
 [  nan   nan   nan   nan   nan   nan   nan   nan   nan   nan]
 [  nan   nan   nan   nan   nan   nan   nan   nan   nan   nan]
 [  nan   nan   nan   nan   nan   nan   nan   nan   nan   nan]
 [  nan   nan   nan   nan   nan   nan   nan   nan   nan   nan]
 [  nan   nan   nan   nan   nan   nan   nan   nan   nan   nan]]
---
precisions : [ 1.000 1.000 1.000 0.000]
recalls    : [ 0.333 0.667 0.667 0.000]
---
(全ユーザの平均適合率)Precision =  0.750
(全ユーザの平均再現率)Recalls   =  0.417

上のプログラムにおいて,109行目までは10行目からの precision_recall_at_k 関数の定義を除いてこれまでのページで利用してきたコードと同様です.89件の評価値データにある最後の19件をテストデータとしています(実際上はシャッフルした後に20%程度をテストデータにすることが多いです).上の出力結果「--- test ---」にあるとおり評価値行列の右上の領域がテストデータで,「--- test_predict ---」に示されているのがテストデータに対する予測値です.

上のコード,129行目ではテストデータに対する検証を行って結果を predictions に代入しています.その後 predictions の内容を表示すると次の通りになりました.

テストデータに対する検証
predictions = mf.test(testset)
predictions
[Prediction(uid=0, iid=5, r_ui=2.0, est=2.5558876335275227, details={'was_impossible': False}),
 Prediction(uid=1, iid=5, r_ui=1.0, est=1, details={'was_impossible': False}),
 Prediction(uid=2, iid=5, r_ui=1.0, est=1, details={'was_impossible': False}),
 Prediction(uid=0, iid=6, r_ui=4.0, est=2.368841450400599, details={'was_impossible': False}),
 Prediction(uid=1, iid=6, r_ui=3.0, est=3.1133573495398914, details={'was_impossible': False}),
 Prediction(uid=2, iid=6, r_ui=3.0, est=2.9548133180474396, details={'was_impossible': False}),
 Prediction(uid=3, iid=6, r_ui=2.0, est=2.027915114057582, details={'was_impossible': False}),
 Prediction(uid=0, iid=7, r_ui=2.0, est=1.5945790702419338, details={'was_impossible': False}),
 Prediction(uid=1, iid=7, r_ui=4.0, est=4.03574452458458, details={'was_impossible': False}),
 Prediction(uid=2, iid=7, r_ui=4.0, est=3.7703581404910183, details={'was_impossible': False}),
 Prediction(uid=3, iid=7, r_ui=2.0, est=1.412225232686598, details={'was_impossible': False}),
 Prediction(uid=0, iid=8, r_ui=3.0, est=2.641879139074499, details={'was_impossible': False}),
 Prediction(uid=1, iid=8, r_ui=3.0, est=2.8128747593071584, details={'was_impossible': False}),
 Prediction(uid=2, iid=8, r_ui=3.0, est=3.4795940576409268, details={'was_impossible': False}),
 Prediction(uid=3, iid=8, r_ui=2.0, est=2.272892258406061, details={'was_impossible': False}),
 Prediction(uid=0, iid=9, r_ui=5.0, est=5, details={'was_impossible': False}),
 Prediction(uid=1, iid=9, r_ui=2.0, est=2.3738423990439776, details={'was_impossible': False}),
 Prediction(uid=2, iid=9, r_ui=2.0, est=2.0286379415421623, details={'was_impossible': False}),
 Prediction(uid=3, iid=9, r_ui=1.0, est=1.642049696653872, details={'was_impossible': False})]

この predictions を関数 precision_recall_at_k に与えて適合率と再現率を計算します.なお k=3 はユーザごとに評価の予測値が高い3つのアイテムを推薦することを意味しています.また,threshold=3 は評価値が3以上のものを推薦すべきアイテムであるとすることを意味してます.関数 precision_recall_at_kSurprise の FAQ に掲載されているものと同じです.

上のコードの62行目以下4行のコメントを外して有効にすることで,関数内での処理内容を可視化できます.実際に動作させて uid=0 の出力を示してその処理内容を確認します(小数点以下の表示桁数は減らしています).

threshold=3, k=3, uid=0,
[(5, 5.0), (2.64, 3.0), (2.55, 2.0), (2.36, 4.0), (1.59, 2.0)]
n_rel=3, n_rec_k=1, n_rel_and_rec_k=1
precision[0] = 1/1 =  1.000
recall[0]    = 1/3 =  0.333

目次に戻る

適合率の考え方

適合率についてより具体的に処理内容を確認しましょう.まず,threshold=3k=3 であるので,評価の予測値が高い順にソートし,予測値がしきい値 (threshold=3) 以上のアイテムを上位から k=3 個推薦するというルールです.ユーザ0 (uid=0) について予測値の順にソートした結果が次の通りです.なお,(推定値, 実際の評価値) という順番で表示されていることに注意してください.

[(5, 5.0), (2.64, 3.0), (2.55, 2.0), (2.36, 4.0), (1.59, 2.0)]

上の結果の中で,予測値がしきい値 (3) 以上のものは 1 個(ただし,最大 k=3 個)であるので,推薦アイテムの個数は n_rec_k=1 となります(上のコードでは30行目).次に推薦アイテムの中で,真の評価値がしきい値 (3) 以上で適合するアイテムは 1 個あるので,n_rel_and_rec_k=1 となります(上のコードでは33行目).したがって,適合率は39行目のとおりで 1.0 になります.

\begin{eqnarray} \text{Precision@}K &=& \frac{|\{\text{Recommended items that are relevant}\}|}{|\{\text{Recommended items}\}|} \\ &=& \frac{\text{n_rel_and_rec_k}}{\text{n_rec_k}} \\ &=& \frac{1}{1} = 1.0 \end{eqnarray}

同じ処理をすべてのユーザについて行います.その結果は precisions に格納されています.


precisions, recalls = precision_recall_at_k(predictions, k=3, threshold=3)

print(f"precisions : [", end="")
for prec in precisions.values():
    print(f"{prec:6.3f}", end="")
print("]")
precisions : [ 1.000 1.000 1.000 0.000]

すべてのユーザについて適合率の平均を求めたものが \(\text{Precision@}K\) となります.


print(f"(全ユーザの平均適合率)Precision = {sum(prec for prec in precisions.values()) / len(precisions):6.3f}")
(全ユーザの平均適合率)Precision =  0.750

目次に戻る

再現率の考え方

次は再現率についても具体的に処理内容を確認しましょう.まず,threshold=3k=3 であるので,予測値がしきい値 (threshold=3) 以上のアイテムを上位から k=3 個推薦するというルールです.ユーザ0 (uid=0) について予測値の順にソートした結果は次の通りです.

[(5, 5.0), (2.64, 3.0), (2.55, 2.0), (2.36, 4.0), (1.59, 2.0)]

予測値順にソートされていますが,ユーザ0に対するテストデータ全体の中で,真の評価値がしきい値である 3 以上のアイテム(つまり推薦すべきアイテム)は 3 個あるので,n_rel=3 となります(上のコード27行目).一方で,推薦アイテム(k=3であることから最大3個,上の例では最初の1個のみ)の中で,真の評価値がしきい値 (3) 以上のものは1個あるので,n_rel_and_rec_k=1 となります(これは適合率のところにすでに求めています).したがって,ユーザ0についての再現率は上の42行目の通り求められます.

\begin{eqnarray} \text{Recall@}K &=& \frac{|\{\text{Recommended items that are relevant}\}|}{|\{\text{Relevant items}\}|} \\ &=& \frac{\text{n_rel_and_rec_k}}{\text{n_rel}} \\ &=& \frac{1}{3} = 0.333 \end{eqnarray}

同じ手順ですべてのユーザについてユーザごとの再現率が計算されて recalls に格納されています.


# 適合率と再現率の計算
precisions, recalls = precision_recall_at_k(predictions, k=3, threshold=3)

print(f"recalls    : [", end="")
for rec in recalls.values():
    print(f"{rec:6.3f}", end="")
print("]")
recalls    : [ 0.333 0.667 0.667 0.000]

すべてのユーザについての再現率からその平均を求めたものが \(\text{Recall@}K\) となります.


print(f"(全ユーザの平均再現率)Recalls   = {sum(rec for rec in recalls.values()) / len(recalls):6.3f}")
(全ユーザの平均再現率)Recalls   =  0.417

目次に戻る

適合率と再現率のトレードオフ

多くの場合,適合率と再現率にはトレード・オフの関係があります.つまり,適合率を上げようとすると,再現率が下がるということです.より具体的には,適合率を上げるためには,その分母を小さくすればよいので,推薦する数を少なくしたり,しきい値を上げたりします.これにより推薦されたアイテムはより厳選されたものになるので,真の評価値もしきい値よりも高い可能性が大きくなり,適合率が上昇することが期待されます.その一方で,推薦する数を少なくすると,再現率の分子が小さくなるので,再現率が低下することになります.

さらに,ユーザごとに推薦すべきアイテムが多数あるような状況(つまり分母が大きい状況)で再現率を上げるためには,分子も大きくすることで再現率が1に近づきます.このためには推薦リストの個数を大きくして,しきい値も下げると良いことがわかります.しかしながらその一方で,そのような設定にすると適合率が下がってしまうことが予想できます.

\begin{equation} \text{Precision@}K = \frac{|\{\text{Recommended items that are relevant}\}|}{|\{\text{Recommended items}\}|} \end{equation}
\begin{equation} \text{Recall@}K = \frac{|\{\text{Recommended items that are relevant}\}|}{|\{\text{Relevant items}\}|} \end{equation}

目次に戻る