Python入門トップページ


目次

  1. 協調フィルタリングによる推薦システム
  2. 顧客間の類似度に基づいた手法
  3. 個別データを順にマージしてみる
  4. 個別データを一気にマージしてみる
  5. 行列分解
  6. サンプルコードのまとめ

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

顧客間の類似度に基づいた手法

目次に戻る

データの読み込み

ここでは,集計済みの行列形式のレイティングデータを読み込んで顧客間の類似度に基づいた協調フィルタリングを実装してみます.まず,Pandas と NumPy ライブラリをインポートします.

import pandas as pd
import numpy as np

次に,GitHub のリポジトリから行列形式のレイティングデータをデータフレームに読み込みます.

url = "https://github.com/rinsaka/sample-data-sets/blob/master/collaborative_filtering_matrix.csv?raw=true"
# url = 'collaborative_filtering_matrix.csv'  # ローカルにダウンロードしてから開く場合

df = pd.read_csv(url)
print(df)
   id   name    A    B    C    D    E
0   0    Eto  5.0  4.0  3.0  NaN  NaN
1   1   Sato  NaN  2.0  4.0  2.0  3.0
2   2   Kato  2.0  3.0  4.0  NaN  NaN
3   3   Muto  2.0  3.0  2.0  NaN  2.0
4   4   Kito  4.0  3.0  3.0  4.0  NaN
5   5   Goto  2.0  5.0  NaN  2.0  3.0
6   6   Bito  3.0  3.0  2.0  NaN  1.0
7   7  Saito  4.0  4.0  5.0  NaN  5.0
8   8  Naito  5.0  5.0  4.0  2.0  1.0
9   9   Koto  3.0  NaN  2.0  2.0  2.0

目次に戻る

目的と方法

目的は顧客 Kato( と Eto )に対して商品「D」と商品「E」のどちらを優先的におすすめすべきかを決めることです.顧客の好みが異なれば自ずとおすすめする商品も異なるはずです.

ここでは,(古典的ですが)顧客の類似度に基づいた協調フィルタリングを考えます.この手法ではおおむね次のようなステップで顧客に対するおすすめ商品を決定します.

  1. 顧客 Kato に好みが似た顧客 \(n\) 人を抽出して,それを近傍顧客とする
  2. 近傍顧客のレイティングをもとに,商品「D」と商品「E」に対する顧客 Kato のスコアを求める
  3. スコアが高い商品を顧客 Kato に優先的におすすめする

目次に戻る

パラメータの設定

まずは,「誰」の「どの商品」について推定したいかを指定するパラメータを設定しておきます.以降では,顧客 Kato の商品「D」のスコアを求めてみます.なお,次のコードのいずれかの行を有効にするだけで,別のスコアを得ることができるようになるはずです.

## customer_id は 推定したい顧客(またはユーザ)
## item_id は 推定したい商品(またはアイテム)

# customer_id = 0; item_id = 3;
# customer_id= 0; item_id = 4;
customer_id= 2; item_id = 3;
# customer_id= 2; item_id = 4;

近傍顧客として利用する顧客の人数 \(n\) もここで設定しておきます(実際上は適切な \(n\) がどの程度であるかについて検討する必要があります).

n = 3

目次に戻る

データフレームをNumPy配列に格納する

すでに読み込んでいる Pandas のデータフレームからレイティングに関する列だけを取得して NumPy 配列に格納し,レイティング行列 y を生成します.

y = df.iloc[:, 2:].values
print(y)
[[ 5.  4.  3. nan nan]
 [nan  2.  4.  2.  3.]
 [ 2.  3.  4. nan nan]
 [ 2.  3.  2. nan  2.]
 [ 4.  3.  3.  4. nan]
 [ 2.  5. nan  2.  3.]
 [ 3.  3.  2. nan  1.]
 [ 4.  4.  5. nan  5.]
 [ 5.  5.  4.  2.  1.]
 [ 3. nan  2.  2.  2.]]

もちろん,次のような書き方でも動作しますが,列名の指定は面倒かもしれません.

y = df.loc[:, ['A', 'B', 'C', 'D', 'E']].values

目次に戻る

類似度の計算

顧客 \(i\) と好みが近い顧客を \(n\) 人見つけるために,顧客 \(i\) と \(\ell\) の類似度 \( sim(i, \ell)\) を定式化します.これは顧客 \(i\) と \(\ell\) のレイティングの相関係数です. \begin{eqnarray} sim(i,\ell) &=& \frac{\sum_{j \in \mathcal{J}_{i\ell}}\left(y_{ij} - \overline{y}_{i.} \right)\left( y_{\ell j} - \overline{y}_{\ell.} \right)} { \sqrt{\sum_{j \in \mathcal{J}_{i\ell}} \left( y_{ij} - \overline{y}_{i.} \right)^2 } \sqrt{\sum_{j \in \mathcal{J}_{i\ell}} \left( y_{\ell j} - \overline{y}_{\ell.} \right)^2 } } \end{eqnarray} ここで,\(y_{ij}\) は 顧客 \(i\) の商品 \(j\) に対するレイティング(評価値)を意味し,\(\overline{y}_{i.}\) は 顧客 \(i\) の平均レイティングです.また,\(\mathcal{J}_{i\ell}\) は,顧客 \(i\)と \(\ell\) の両方によって評価された商品の集合を意味します.

上の \( sim(i, \ell)\) を求めるコードを記述していきます.まず,上で定義した customer_iditem_id を定式化での記号に合わせて内部的には i, と j として利用することにします.

i = customer_id
j = item_id

次に,顧客ごとの平均レイティング \(\overline{y}_{i.}\) をベクトル(1次元配列)形式で計算します.これは,後ほど定義する sim(i, l) 関数の中でその都度計算しても良いですが,予め計算しておけば計算が一度で済むので効率的です(ただし,顧客 \(i\)と \(\ell\) の両方で評価された商品だけで平均を計算した方が良いという議論もあります.つまり,全ての商品での平均値を使って類似度(相関係数)を計算するとこれが1.0を超えてしまうようなこともあります.).また,行列 y には NaN 値が含まれるため,np.mean() 関数でなく,np.nanmean() 関数を使っていることに注意してください.例えば,先頭行のレイティングは [ 5. 4. 3. nan nan] であるので,NaN を除いた平均は 4 になります.

y_mean = np.nanmean(y, axis=1)
print(y_mean)
[4.   2.75 3.   2.25 3.5  3.   2.25 4.5  3.4  2.25]

つぎに,顧客 \(i\) と顧客 \(\ell\) の両方によって評価された評価の集合を返す関数 get_Y_il() を定義します.

def get_Y_il(y, i, l):
    # まずは,yi と yl だけを取り出して2行の行列を生成
    yil = np.concatenate((y[i], y[l])).reshape(2,y.shape[1])
#     print(yil)
    # NaN が含まれる列のインデックスを初期化(あとでNaNが含まれている列は削除したい)
    nan_idx = []
    for i in range(yil.shape[1]):
        if np.isnan(yil[0][i]) or np.isnan(yil[1][i]):
            nan_idx.append(i)  # どちらかの顧客が未評価 (NaN) なら削除する列のリストに追加する
    # NaN が含まれる列を削除する
    return np.delete(yil, nan_idx, 1)

例として,顧客 2 と顧客 1 の両方によって評価された評価の集合を表示してみます.行列から計算に必要な部分だけが抽出されていることがわかります.

y_il = get_Y_il(y, 2, 1)
print(y)
print('---')
print(y_il)
[[ 5.  4.  3. nan nan]
 [nan  2.  4.  2.  3.]
 [ 2.  3.  4. nan nan]
 [ 2.  3.  2. nan  2.]
 [ 4.  3.  3.  4. nan]
 [ 2.  5. nan  2.  3.]
 [ 3.  3.  2. nan  1.]
 [ 4.  4.  5. nan  5.]
 [ 5.  5.  4.  2.  1.]
 [ 3. nan  2.  2.  2.]]
---
[[3. 4.]
 [2. 4.]]

顧客 \(i\) と 顧客 \(\ell\) の類似度を返す関数 sim_i_l() を定義します.

def sim_i_l(y, y_mean, i, l):
    y_il = get_Y_il(y, i, l)
#     print(y_il)
    term1 = np.sum((y_il[0] - y_mean[i]) * (y_il[1] - y_mean[l]))
    term2 = np.sqrt(np.sum((y_il[0] - y_mean[i])**2))
    term3 = np.sqrt(np.sum((y_il[1] - y_mean[l])**2))
#     print(term1, term2, term3)
    return term1 / term2 / term3

準備ができたので,例として顧客 2 と顧客 1 の類似度を計算してみます.この結果,顧客 2 と顧客 1 の類似度が 0.857 と高く,2人はよく似た好みを持つ顧客であることがわかりました.

sim_i_l(y, y_mean, 2, 1)
0.8574929257125441

この結果が正しいことを数式を用いて確認してみます. \begin{eqnarray} sim(2,1) &=& \frac{\sum_{j \in \mathcal{J}_{2\ell}}\left(y_{2j} - \overline{y}_{2.} \right)\left( y_{1 j} - \overline{y}_{1.} \right)} { \sqrt{\sum_{j \in \mathcal{J}_{2,1}} \left( y_{2j} - \overline{y}_{2.} \right)^2 } \sqrt{\sum_{j \in \mathcal{J}_{2,1}} \left( y_{1j} - \overline{y}_{1.} \right)^2 } } \\ &=& \frac{ \left(3 - 3\right)\left( 2 - 2.75\right) + \left( 4 - 3 \right)\left( 4 - 2.75 \right) } { \sqrt{\left( 3-3 \right)^2 + \left( 4-3 \right)^2} \times \sqrt{\left( 2-2.75 \right)^2 + \left( 4-2.75 \right)^2} } \\ &=& \frac{0 + 1.25} { \sqrt{0^2 + 1^2} \times \sqrt{(-0.75)^2 + 1.25^2} } \\ &=& \frac{1.25}{\sqrt{2.125}} \approx \frac{1.25}{1.45773} \\ &\approx& 0.85749 \end{eqnarray}

すべての顧客について,顧客 \(i\) との類似度を計算します.このとき,顧客 \(i\) と 顧客 \(i\) の類似度は当然 1 になることに注意してください.

sim = np.zeros(y.shape[0]) # 初期化
for k in range(0, sim.shape[0]):
    sim[k] = sim_i_l(y, y_mean, i, k)
print(sim)
[-1.          0.85749293  1.          0.         -0.81649658  0.4472136
 -0.64888568  0.81649658 -0.30206105 -0.89442719]

上で得られた類似度の高い順に本人を除いて \(n\) 人(本人も含めて \(n+1\)人)を抽出すれば近傍顧客のリストが出来上がります.このために類似度をソートして,要素のインデックスを取得します.このとき,np.argsort() は昇順ソートになるために,逆順にする必要があります.このために [::-1] をつけてソート結果を逆順の降順ソートにします.この結果の意味は,「0位はインデックス2,1位はインデックス1,2位はインデックス7,3位がインデックス5・・・」となります.つまり,0 位を除いた,1 位から n = 3 位までが近傍顧客となります.

np.argsort(sim)[::-1]
array([2, 1, 7, 5, 3, 8, 6, 4, 9, 0])

上の np.argsort を重ねると順位が得られることになります.つまり,「インデックス2 の Kato(本人) は 0 位,インデックス1の Satoは 1位(Katoに最も近い),インデックス7の Saito は2位・・・」であることを意味しています.

customer_rank = np.argsort(np.argsort(sim)[::-1])
print(customer_rank)
[9 1 0 4 7 3 6 2 5 8]

(類似度が 1 のはずの) Kato自身も含んだ近傍顧客のインデックスを取得します.つまり,インデックス 1, 2, 5, 7 の Sato, Kato, Goto, Saito が近傍顧客です.

idx = np.arange(len(sim))
near_customer_ids = idx[customer_rank <= n]
print(near_customer_ids)
[1 2 5 7]

スコアを計算するために,類似度の sim ベクトルから近傍顧客の類似度だけを取り出します.

near_sim = sim[near_customer_ids]
print(near_sim)
[0.85749293 1.         0.4472136  0.81649658]

同様に,レイティング行列から近傍顧客のレイティングだけを取り出します.

near_y = y[near_customer_ids]
print(near_y)
[[nan  2.  4.  2.  3.]
 [ 2.  3.  4. nan nan]
 [ 2.  5. nan  2.  3.]
 [ 4.  4.  5. nan  5.]]

評価したい顧客の変数 i が近傍顧客のみの集団での customer_id のインデックスとなるように i を更新します.これは,近傍顧客の類似度ベクトルやレイティング行列のインデックス 1 がもとの顧客 2 の情報であることを意味しています.

i = np.where(near_customer_ids == i)[0][0]
print(i)
1

目次に戻る

スコアの計算

顧客 \(i\) の商品 \(j\) に対するスコアは次の式から求められます. \begin{eqnarray} s_{ij} &=& \overline{y}_{i.} + \frac { \sum_{\ell \in \mathcal{I}_{j}(i)}w(i, \ell) \left( y_{\ell j} - \overline{y}_{\ell .}\right) } { \sum_{\ell \in \mathcal{I}_{j}(i)} \left| w(i, \ell) \right| } \end{eqnarray} ただし,\(\mathcal{I}_{j}(i)\) は商品 \(j\) を評価した顧客 \(i\) に類似する顧客の集合です.また \(w(i,\ell)\) は顧客 \(i\) が商品 \(j\) を評価するときの顧客 \(\ell\) の評価に対する重みであり,ここでは \(w(i,\ell) = sim(i, \ell)\) とします.さらに,\(\overline{y}_{i.}\) は顧客 \(i\) の平均レイティングです.

実際に Kato さんの商品「D」についてのスコアは次のようになります.このとき,近傍顧客の中で商品「D」をまだ評価していない Saito さんのデータは利用されないことに注意してください. \begin{eqnarray} s_{Kato,3} &=& 3 + \frac{ 0.8575 \times(2-2.75) + 0.4472 \times (2 -3) }{ 0.8575 + 0.4472 } \\ &=& 3 + \frac{-0.643125-0.4472}{1.3047} \\ &=& 3 + \frac{-1.090325}{1.3047} \\ &=& 2.164 \end{eqnarray}

上の式を用いてスコアを求める関数 get_sij() を次のように定義します.

def get_sij(y, w, i, j):
    y_mean = np.nanmean(y, axis=1)
    y_mean_i = y_mean[i]
    # NaNが含まれている行,および自身の行は削除したい
    nan_idx = [i]
    for k in range(len(w)):
        if np.isnan(y[k][j]):
            nan_idx.append(k)
    y = np.delete(y, nan_idx, 0)
    y_mean = np.delete(y_mean, nan_idx)
    w = np.delete(w, nan_idx)
    # j 列目だけを取り出す
    y = y[:,j]
    sij = y_mean_i + np.sum(w * (y - y_mean)) / np.sum(w)
    return sij

近傍顧客のレイティング行列,および類似度を与えて,顧客 i=1customer_id = 2)の商品 j=3(商品「D」) に対するスコアを求めます.数式上で求めたものと同じ結果が得られました.

sij = get_sij(near_y, near_sim, i, j)
print(sij)
2.1643076262306966

結果を表示してみます.

print(df)
print(f"商品 {item_id} ({df.columns[item_id+2]}) に対する顧客 {customer_id} のスコアは {sij:5.3f} です.")
   id   name    A    B    C    D    E
0   0    Eto  5.0  4.0  3.0  NaN  NaN
1   1   Sato  NaN  2.0  4.0  2.0  3.0
2   2   Kato  2.0  3.0  4.0  NaN  NaN
3   3   Muto  2.0  3.0  2.0  NaN  2.0
4   4   Kito  4.0  3.0  3.0  4.0  NaN
5   5   Goto  2.0  5.0  NaN  2.0  3.0
6   6   Bito  3.0  3.0  2.0  NaN  1.0
7   7  Saito  4.0  4.0  5.0  NaN  5.0
8   8  Naito  5.0  5.0  4.0  2.0  1.0
9   9   Koto  3.0  NaN  2.0  2.0  2.0
商品 3 (D) に対する顧客 2 のスコアは 2.164 です.

今の例では,近傍顧客数を n=3 と設定したものの,近傍顧客の中で商品「D」をまだ評価していない顧客がいたため,実際には2名の近傍顧客のデータしか利用しませんでした.大きな顧客数と商品数で構成される現実の評価データでは,巨大な行列の中のごく一部にしか評価値がない(評価値が全体の10%にも満たず,90%以上が欠損値である)ことが少なくありません.このような場合には,商品「D」を評価していない顧客は近傍顧客に含めないようにしたり,そのような顧客はあらかじめ削除してから近傍顧客を求めるなどの工夫が必要になるでしょう.

目次に戻る