Python入門トップページ


目次

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

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

推薦アルゴリズムの性能を評価する

ここでは推薦アルゴリズムの性能を評価するためのいくつかの指標について理解し,簡単なデータについてそれらの指標を求めてみます.

プログラムの全体像

まずは,プログラムの全体像を最初に示します.ハイライトした箇所が前のページのプログラムから追加した部分です.


import pandas as pd
import numpy as np
from surprise import Dataset, Reader, SVD
from surprise.model_selection import train_test_split
from surprise.accuracy import fcp, mae, mse, rmse
from collections import defaultdict

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

def get_top_n(predictions, n=10):
    """
    ユーザごとにレイティングの予測値が高いN個の推薦リストを作成する
    """
    # ユーザごとの予測値リストを作成
    top_n = defaultdict(list)
    for uid, iid, true_r, est, _ in predictions:
        top_n[uid].append((iid, est))

    # ソートして上位 n個のリストを作成する
    for uid, user_ratings in top_n.items():
        user_ratings.sort(key=lambda x: x[1], reverse=True)
        top_n[uid] = user_ratings[:n]

    return top_n

# データの読み込み
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)

# 19件をテストデータにする
trainset, testset = train_test_split(data, test_size=0.21, shuffle=False)  # シャッフルなし
# trainset, testset = train_test_split(data, test_size=0.21)               # シャッフルあり

# 学習データの数をカウントする
n_train = sum(len(d) for d in trainset.ur.values())

# データ数などを確認する
print(f"number of trainset customers  ={trainset.n_users:4d}")
print(f"number of trainset items      ={trainset.n_items:4d}")
print(f"number of elements in matrix  ={trainset.n_users * trainset.n_items:4d}")
print(f"number of null ratings        ={trainset.n_users * trainset.n_items - df_ratings['rating'].shape[0]:4d}")
print(f"number of ratings in data     ={df_ratings['rating'].shape[0]:4d}")
print(f"number of ratings in trainset ={n_train:4d}")
print(f"number of ratings in testset  ={len(testset):4d}")

# 学習データを行列形式に変換し,元の順番に並べ替えた行列も作成する
trainset_matrix = np.zeros((trainset.n_users, trainset.n_items))
train_raw_matrix = np.zeros((trainset.n_users, trainset.n_items))  # 元のデータ
trainset_matrix[:,:] = np.nan
train_raw_matrix[:,:] = np.nan
for u, vals in trainset.ur.items():
  for i, rating in vals:
    trainset_matrix[u, i] = rating
    train_raw_matrix[trainset.to_raw_uid(u), trainset.to_raw_iid(i)] = rating

# 因子数
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

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

print("----- trainset -------")
print(train_raw_matrix)
print("----- testset -----")
print(test_raw_matrix)

print("----- s -------")
print(predict_matrix)

print("----- train_predict -------")
train_predict_matrix = np.copy(train_raw_matrix)
train_predict_matrix = np.where(~np.isnan(train_predict_matrix), predict_matrix, np.nan)
print(train_predict_matrix)

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

# 学習データに対する予測
train_predictions = [mf.predict(trainset._inner2raw_id_users[uid], trainset._inner2raw_id_items[iid], r_ui) for (uid, iid, r_ui) in trainset.all_ratings()]
# テストデータに対しての予測を実行
test_predictions = mf.test(testset)

top_n = get_top_n(test_predictions, n=5)  # ここで抽出するアイテム数 n を設定する
print("---- ユーザごとの推薦アイテム ----")
for uid, user_ratings in top_n.items():
    print(f"ユーザ {uid} への推薦アイテム: {[iid for (iid, _) in user_ratings]}")

print("----- FCP (Fraction of Concordant Pairs) -----")
train_fcp = fcp(train_predictions, verbose=False)
print(f"train_fcp = {train_fcp:8.5f}")
test_fcp = fcp(test_predictions, verbose=False)
print(f"test_fcp  = {test_fcp:8.5f}")

print("----- MAE (Mean Absolute Error) -----")
train_mae = mae(train_predictions, verbose=False)
print(f"train_mae = {train_mae:8.5f}")
test_mae = mae(test_predictions, verbose=False)
print(f"test_mae  = {test_mae:8.5f}")

print("----- MSE (Mean Squared Error) -----")
train_mse = mse(train_predictions, verbose=False)
print(f"train_mse = {train_mse:8.5f}")
test_mse = mse(test_predictions, verbose=False)
print(f"test_mse  = {test_mse:8.5f}")

print("----- RMSE (Root Mean Squared Error) -----")
train_rmse = rmse(train_predictions, verbose=False)
print(f"train_rmse = {train_rmse:8.5f}")
test_rmse = rmse(test_predictions, verbose=False)
print(f"test_rmse  = {test_rmse:8.5f}")
number of trainset customers  =  10
number of trainset items      =  10
number of elements in matrix  = 100
number of null ratings        =  11
number of ratings in data     =  89
number of ratings in trainset =  70
number of ratings in testset  =  19
----- trainset -------
[[ 5.  4.  3. nan nan nan nan nan nan nan]
 [nan  2.  4.  2.  3. nan nan nan nan nan]
 [ 2.  3.  4. nan nan nan nan nan nan nan]
 [ 2.  3.  2. nan  2.  2. nan nan nan nan]
 [ 4.  3.  3.  4. nan  4.  4.  3.  2.  4.]
 [ 2.  5. nan  2.  3.  3.  3.  2.  4.  1.]
 [ 3.  3.  2. nan  1.  4.  1.  2.  2.  4.]
 [ 4.  4.  5. nan  5.  2.  5.  5.  4.  4.]
 [ 5.  5.  4.  2.  1.  1.  2.  2.  4.  5.]
 [ 3. nan  2.  2.  2.  3.  2.  1.  2.  3.]]
----- testset -----
[[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]]
----- s -------
[[4.937 3.993 3.01  2.736 1.836 2.556 2.369 1.595 2.642 5.   ]
 [2.155 2.037 3.891 2.033 2.976 1.    3.113 4.036 2.813 2.374]
 [2.026 3.    3.919 1.806 2.796 1.    2.955 3.77  3.48  2.029]
 [2.001 2.959 2.004 1.643 1.977 1.994 2.028 1.412 2.273 1.642]
 [3.934 3.015 2.981 3.876 4.201 3.947 3.956 2.987 2.046 4.02 ]
 [2.01  4.895 2.9   1.973 3.001 2.956 2.98  1.991 3.941 1.084]
 [2.999 2.985 2.009 1.874 1.048 3.901 1.049 1.929 1.984 3.945]
 [3.993 3.985 5.    3.812 4.921 2.042 4.946 4.908 3.955 3.95 ]
 [4.909 4.915 3.967 1.958 1.077 1.062 2.007 2.002 3.962 4.958]
 [3.023 3.182 1.901 2.132 1.877 2.932 1.982 1.137 2.005 2.896]]
----- train_predict -------
[[4.937 3.993 3.01    nan   nan   nan   nan   nan   nan   nan]
 [  nan 2.037 3.891 2.033 2.976   nan   nan   nan   nan   nan]
 [2.026 3.    3.919   nan   nan   nan   nan   nan   nan   nan]
 [2.001 2.959 2.004   nan 1.977 1.994   nan   nan   nan   nan]
 [3.934 3.015 2.981 3.876   nan 3.947 3.956 2.987 2.046 4.02 ]
 [2.01  4.895   nan 1.973 3.001 2.956 2.98  1.991 3.941 1.084]
 [2.999 2.985 2.009   nan 1.048 3.901 1.049 1.929 1.984 3.945]
 [3.993 3.985 5.      nan 4.921 2.042 4.946 4.908 3.955 3.95 ]
 [4.909 4.915 3.967 1.958 1.077 1.062 2.007 2.002 3.962 4.958]
 [3.023   nan 1.901 2.132 1.877 2.932 1.982 1.137 2.005 2.896]]
----- 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]]
---- ユーザごとの推薦アイテム ----
ユーザ 0 への推薦アイテム: [9, 8, 5, 6, 7]
ユーザ 1 への推薦アイテム: [7, 6, 8, 9, 5]
ユーザ 2 への推薦アイテム: [7, 8, 6, 9, 5]
ユーザ 3 への推薦アイテム: [8, 6, 9, 7]
----- FCP (Fraction of Concordant Pairs) -----
train_fcp =  1.00000
test_fcp  =  0.81818
----- MAE (Mean Absolute Error) -----
train_mae =  0.04510
test_mae  =  0.31444
----- MSE (Mean Squared Error) -----
train_mse =  0.00339
test_mse  =  0.24051
----- RMSE (Root Mean Squared Error) -----
train_rmse =  0.05819
test_rmse  =  0.49042

FCPMAEMSERMSEのそれぞれについて順番に意味を確認しましょう.

目次に戻る

FCP (Fraction of Concordant Pairs)

FCP (Fraction of Concordant Pairs) は推薦アルゴリズムの評価指標の一つで,ユーザの好みをどれだけ正確に予測できているかを測定します.具体的には,ユーザの好みと予測結果の好みの順序がどの程度一致しているかを評価します.Surprise ライブラリでは surprise.accuracy.fcp() を利用することで簡単に求めることができます.


train_fcp = fcp(train_predictions, verbose=False)
print(f"train_fcp = {train_fcp:8.5f}")
test_fcp = fcp(test_predictions, verbose=False)
print(f"test_fcp  = {test_fcp:8.5f}")
----- FCP (Fraction of Concordant Pairs) -----
train_fcp =  1.00000
test_fcp  =  0.81818

FCP の定義は次の通りです.

\begin{equation} \text{FCP} = \frac{n_c}{n_c + n_d} \end{equation}

ここで,\(n_c\) は感覚的には予測値の大小関係と実際の大小関係が正しかったデータ数(正解数)で,\(n_d\) は大小関係に誤りがあった数(不正解数)です.詳細は Koren and Sill による論文の式(7),式(8) を参照してください.また,ソースコードはこちらを参照して下さい.

テストデータについての FCP が,test_fcp = 0.81818 となることを実際に確かめます.まず,テストデータの行列(つまり正解データ)について,値が存在する右上の箇所のみを表示し,予測値についても同じ箇所を表示します.


print("----- testset -----")
print(test_raw_matrix[:4,5:])
print("----- test_predict -------")
print(test_predict_matrix[:4,5:])
----- testset -----
[[ 2.  4.  2.  3.  5.]
 [ 1.  3.  4.  3.  2.]
 [ 1.  3.  4.  3.  2.]
 [nan  2.  2.  2.  1.]]
----- test_predict -------
[[2.556 2.369 1.595 2.642 5.   ]
 [1.    3.113 4.036 2.813 2.374]
 [1.    2.955 3.77  3.48  2.029]
 [  nan 2.028 1.412 2.273 1.642]]

さらに,それぞれの先頭行のみ(つまりユーザ0のみ)を表示します.


print(test_raw_matrix[:1,5:])
print(test_predict_matrix[:1,5:])
[[2.    4.    2.    3.    5.   ]]
[[2.556 2.369 1.595 2.642 5.   ]]

この値についての大小比較を行います.まず,ユーザ0 に対して \(n_c^0 = 0\)\(n_d^0 = 0\) で初期化した後,最初のアイテムである先頭データの予測値 2.556 について着目します.予測値がこの 2.556 よりも小さいアイテムを探します.

[[2.    4.    2.    3.    5.   ]]
[[2.556 2.369 1.595 2.642 5.   ]]

すると,2.3691.595 の2個のアイテムが見つかりました.この2つのアイテムに対する正解データについて,基準となるアイテムの正解データである2より小さな値はないので \(n_c^0 = 0\) のままで,大きな値(4)が1つあるので,\(n_d^0 = 1\) になります.これはつまり,2つのアイテムに関して正解データの大小関係と予測値の大小関係が逆転している(予測に失敗している)ことを意味しています.

[[2.    4.    2.    3.    5.   ]]
[[2.556 2.369 1.595 2.642 5.   ]]

次は,2番目のアイテムについて着目します.予測値が 2.369 より小さいアイテムを探すと 1.595 のアイテムがあり,そのアイテムの正解データ (2) は基準となる正解データ (4) よりも小さい(つまり,順序が正しい)ことから \(n_c^0\) に 1 加えて \(n_c^0 = 1\) となります.

[[2.    4.    2.    3.    5.   ]]
[[2.556 2.369 1.595 2.642 5.   ]]

3番目のアイテムについては予測値がそれよりも小さいものがないのでスキップします.

[[2.    4.    2.    3.    5.   ]]
[[2.556 2.369 1.595 2.642 5.   ]]

次は4番目のアイテムを基準として,その基準となる予測値 2.642 よりも予測値が小さいアイテムが3つあることを確認します.基準となるアイテムの正解データ (3) よりも正解データが小さいアイテム(つまり順序が正しいもの)は2つあるので,\(n_c^0\) に 2 を加えて \(n_c^0 = 3\) となります.反対に基準となるアイテムの正解データ (3) よりも正解データが大きいアイテム(つまり順序が反対であるもの)は1つあるので,\(n_d^0\) に 1 を加えて \(n_d^0 = 2\) となります.

[[2.    4.    2.    3.    5.   ]]
[[2.556 2.369 1.595 2.642 5.   ]]

最後のアイテムを基準アイテムとします.基準となる予測値 (5) よりも他の4つのアイテムの予測値が小さく,かつ正解データも小さい(つまり順序が正しい)ことから \(n_c^0\) に 4 を加えて \(n_c^0 = 7\) となりました.

[[2.    4.    2.    3.    5.   ]]
[[2.556 2.369 1.595 2.642 5.   ]]

したがって,ユーザ0に対して,\(n_c^0 = 7\)\(n_d^0 = 2\) となりました.同じ様に残りの3名のユーザについても計算すると,次のような結果になります.

n_c^u = [7, 9, 9, 2]
n_d^u = [2, 0, 0, 1]

それぞれの合計を求めると次の結果になりました.

\begin{equation} n_c = \sum_{u}n_c^u = 7 + 9 + 9 + 2 = 27 \end{equation} \begin{equation} n_d = \sum_{u}n_d^u = 2 + 0 + 0 + 1 = 3 \end{equation}

したがって,FCP は次の通り 0.9 となるはずです.

\begin{equation} \text{FCP} = \frac{n_c}{n_c + n_d} = \frac{27}{27 + 3} = 0.9 \end{equation}

Koren and Sill による論文の式(7),式(8) のとおり計算すると上で求めた結果 FCP = 0.9 になるはずです.しかしながら,surprise のソースコード を確認すると次のように ncnd を合計値ではなく,平均値として計算しています.

Source code for surprise.accuracy
nc = np.mean(list(nc_u.values())) if nc_u else 0
nd = np.mean(list(nd_u.values())) if nd_u else 0

この結果,値が0であったアイテムが平均の計算で分母から除外されてしまっています.

\begin{equation} n_c = \frac{7 + 9 + 9 + 2}{4} = 6.75 \end{equation} \begin{equation} n_d = \frac{2 + 1}{2} = 1.5 \end{equation}

したがって,surprise の fcp でを計算すると 0.81818 となります.

\begin{equation} \text{FCP} = \frac{n_c}{n_c + n_d} = \frac{6.75}{6.75 + 1.5} = 0.81818 \end{equation}

Surprise では意図してそのような書き方をしたのか,バグであるのかは確認できていません.情報があれば教えて下さい.surprise のソースコードを次のように変更すると,期待通り 0.9 が求められます.


nc = np.sum(list(nc_u.values())) if nc_u else 0
nd = np.sum(list(nd_u.values())) if nd_u else 0

いずれにせよ,\(n_c\) は正解データと予測における評価値の大小関係が正しく求められた回数であり,\(n_d\) は大小関係で誤った推定を行なった回数を意味します.このことから直前の式を見ても分かる通り,FCPは0から1の間の値をとり,1に近いほどアイテムの好みについてその大小関係を正しく予測できていることを意味します.

目次に戻る

MAE (Mean Absolute Error)

MAE(Mean Absolute Error:平均絶対誤差)の定義は次の通りです.

\begin{equation} \text{MAE} = \frac{1}{|\hat{R}|} \sum_{\hat{r}_{ui} \in \hat{R}} \left| r_{ui} - \hat{r}_{ui} \right| \end{equation}

Surprise では 訓練データやテストデータを mae 関数に渡すだけで,MAE を簡単に求めることができます.


train_mae = mae(train_predictions, verbose=False)
print(f"train_mae = {train_mae:8.5f}")
test_mae = mae(test_predictions, verbose=False)
print(f"test_mae  = {test_mae:8.5f}")
train_mae =  0.04510
test_mae  =  0.31444

なお,これを式の定義通りに自力で求めることも可能です.


print(f"train_mae = {np.nansum(np.abs(train_raw_matrix-predict_matrix))/np.count_nonzero(~np.isnan(train_raw_matrix)):8.5f}")
print(f"test_mae  = {np.nansum(np.abs(test_raw_matrix-predict_matrix))/np.count_nonzero(~np.isnan(test_raw_matrix)):8.5f}")
train_mae =  0.04510
test_mae  =  0.31444

目次に戻る

MSE (Mean Squared Error)

MSE (Mean Squared Error:平均二乗誤差) の定義は次の通りです.

\begin{equation} \text{MSE} = \frac{1}{|\hat{R}|} \sum_{\hat{r}_{ui} \in \hat{R}} \left( r_{ui} - \hat{r}_{ui} \right)^2 \end{equation}


train_mse = mse(train_predictions, verbose=False)
print(f"train_mse = {train_mse:8.5f}")
test_mse = mse(test_predictions, verbose=False)
print(f"test_mse  = {test_mse:8.5f}")
train_mse =  0.00339
test_mse  =  0.24051

やはり,定義通りに求めても同じ結果が得られるはずです.


print(f"train_mse = {np.nansum((train_raw_matrix-predict_matrix)**2)/np.count_nonzero(~np.isnan(train_raw_matrix)):8.5f}")
print(f"test_mse  = {np.nansum((test_raw_matrix-predict_matrix)**2)/np.count_nonzero(~np.isnan(test_raw_matrix)):8.5f}")
train_mse =  0.00339
test_mse  =  0.24051

目次に戻る

RMSE (Root Mean Squared Error)

RMSE (Root Mean Squared Error:二乗平均平方根誤差) の定義は次の通りで,MSE の平方根です.

\begin{equation} \text{RMSE} = \sqrt{\frac{1}{|\hat{R}|} \sum_{\hat{r}_{ui} \in \hat{R}} \left( r_{ui} - \hat{r}_{ui} \right)^2} \end{equation}


train_rmse = rmse(train_predictions, verbose=False)
print(f"train_rmse = {train_rmse:8.5f}")
test_rmse = rmse(test_predictions, verbose=False)
print(f"test_rmse  = {test_rmse:8.5f}")
train_rmse =  0.05819
test_rmse  =  0.49042

もちろん定義通りに求めることができるはずです.


print(f"train_rmse = {np.sqrt(np.nansum((train_raw_matrix-predict_matrix)**2)/np.count_nonzero(~np.isnan(train_raw_matrix))):8.5f}")
print(f"test_rmse  = {np.sqrt(np.nansum((test_raw_matrix-predict_matrix)**2)/np.count_nonzero(~np.isnan(test_raw_matrix))):8.5f}")
train_rmse =  0.05819
test_rmse  =  0.49042

さらにすでに求めていた平均二乗誤差から平方根を計算してもよいでしょう.


print(f"train_rmse = {np.sqrt(train_mse):8.5f}")
print(f"test_rmse  = {np.sqrt(test_mse):8.5f}")
train_rmse =  0.05819
test_rmse  =  0.49042

目次に戻る