Pythonでレコメンドシステムを作る(ユーザベース協調フィルタリング)

Python協調フィルタリングを実装して、お寿司を推薦するシステムを作ってみます。

データセット

今回は寿司ネタの嗜好評価を集めたSUSHI Preference Data Setsを使います。

5000人が寿司ネタ100種類に対して5段階で評価(欠測値有り)したデータセットで、以下で公開されています。

www.kamishima.net

まずはデータをロードします。
上記サイトのAll Data Setからsushi3-2016.zipをダウンロード・展開して、sushi3b.5000.10.scoreファイルをカレントディレクトリにコピーしておきます。

全体では5000行100列(5000人×寿司ネタ100種類)の構成となっており、各要素には評価値0〜4(値が大きいほど、好き)と、欠測値-1がセットされています。

import numpy as np

scores = np.loadtxt('sushi3b.5000.10.score', delimiter=' ')

print('scores.shape: {}'.format(scores.shape))
# scores.shape: (5000, 100)

print('scores[0]: \n{}'.format(scores[0]))
# scores[0]: 
# [-1.  0. -1.  4.  2. -1. -1. -1. -1. -1. -1. -1.  1. -1. -1. -1. -1. -1.
#  -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1.
#  -1. -1. -1. -1. -1. -1. -1. -1.  1. -1. -1. -1. -1. -1. -1. -1. -1. -1.
#  -1. -1. -1. -1.  4. -1.  2. -1. -1. -1. -1. -1. -1.  0. -1. -1. -1. -1.
#  -1. -1.  0. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1.  2. -1. -1.
#  -1. -1. -1. -1. -1. -1. -1. -1. -1. -1.]

ユーザベースの協調フィルタリング

あるユーザの評価が高くなりそうなアイテムを推薦するために、協調フィルタリングと呼ばれる手法が使われます。 これは、他のユーザのアイテムAの評価(既知)から、おすすめしたいユーザのアイテムAの評価(未知)を推定する方法です。 アイテムそのものの内容やユーザの属性などを必要としないため、多種多様な商品を販売するECサイトなどで使われています。

協調フィルタリングには、ユーザベースとアイテムベースの2つの代表的な方法がありますが、今回はユーザベースの方法を実装してみます。
ユーザベースの協調フィルタリングでは、対象の(おすすめしたい)ユーザと、評価が類似するユーザを探して、そのユーザの評価値から計算します。

相関係数

ユーザベースの協調フィルタリングで、ユーザ同士の評価が類似している度合い(類似度)を計算するために、ピアソンの積率相関係数が使われます。

ユーザaとユーザbの相関係数は以下の式で求まります。 r_{a,p}はユーザaのアイテムpに対する評価値で、 \overline{r_a}はユーザaの評価値の平均です。
相関係数は-1〜+1の値となり、-1が負の相関、+1が正の相関があることを表します。

f:id:ohke:20170917131724p:plain:w500

相関係数を求める実装を示します。

全ユーザのスコアをscores、予測したいユーザのインデックスをtarget_user_indexとして受け取り、ユーザのインデックスと類似度のタプルのリストを、類似度の高い順で返します。
類似度の計算にはnumpy.corrcoefを使っています。
また、共通のアイテムを3つ以上評価しているユーザのみを算出対象としています。 そうしないと、共通で評価しているアイテムが1つしかないのに、その1つの評価値が一致しただけで、類似度は1.0となってしまうためです。

0番目のユーザとその他のユーザで類似度を求めたところ、類似度が1.0となっているユーザが何人か発見できました。
186番目のユーザのスコアを見てみますと、4番目(イカ)、60番目(かんぴょう巻)、87番目(トビウオ)の値が完全に一致していることがわかります。

def get_correlation_coefficents(scores, target_user_index):
    similarities = []
    target = scores[target_user_index]
    
    for i, score in enumerate(scores):
        # 共通の評価が少ない場合は除外
        indices = np.where(((target + 1) * (score + 1)) != 0)[0]
        if len(indices) < 3 or i == target_user_index:
            continue
        
        similarity = np.corrcoef(target[indices], score[indices])[0, 1]
        if np.isnan(similarity):
            continue
    
        similarities.append((i, similarity))
    
    return sorted(similarities, key=lambda s: s[1], reverse=True)


target_user_index = 0 # 0番目のユーザ
similarities = get_correlation_coefficents(scores, target_user_index)

print('Similarities: {}'.format(similarities))
# Similarities: [(186, 1.0), (269, 1.0), (381, 1.0), ...

print('scores[186]:\n{}'.format(scores[186]))
# scores[186]:
# [-1. -1. -1.  4. -1. -1. -1.  2. -1.  3. -1. -1. -1. -1. -1. -1.  4. -1.
#  -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1.  4. -1.
#  -1. -1.  4. -1. -1. -1. -1. -1. -1. -1. -1.  1. -1. -1. -1. -1. -1. -1.
#  -1. -1. -1. -1. -1. -1.  2. -1. -1. -1.  2. -1. -1. -1. -1. -1. -1. -1.
#  -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1.  2. -1. -1.
#  -1. -1. -1. -1. -1. -1. -1. -1. -1. -1.]

予測

次に相関係数から、ユーザaのアイテムpに対する評価値を予測する式を示します。
Nはユーザ数ですが、全てのユーザと比較するのではなく、類似度の高いユーザからN人、あるいは、類似度が閾値以上のユーザN人というように絞り込みます。

実装では、全スコアscoresと先程の計算で得られた類似度similarities、予測したいユーザのインデックスtarget_user_indexとアイテムのインデックスtarget_item_indexをそれぞれ渡し、スコアを予測します。
類似度が0より大きく、かつ、類似度の高い上位5人のユーザの評価値のみを使って、計算します。

0番目のユーザの0番目のアイテム(エビ)の評価を予測すると、2.761となりました。

def predict(scores, similarities, target_user_index, target_item_index):
    target = scores[target_user_index]
    
    avg_target = np.mean(target[np.where(target >= 0)])
    
    numerator = 0.0
    denominator = 0.0
    k = 0
    
    for similarity in similarities:
        # 類似度の上位5人の評価値を使う
        if k > 5 or similarity[1] <= 0.0:
            break
            
        score = scores[similarity[0]]
        if score[target_item_index] >= 0:
            denominator += similarity[1]
            numerator += similarity[1] * (score[target_item_index] - np.mean(score[np.where(score >= 0)]))
            k += 1
            
    return avg_target + (numerator / denominator) if denominator > 0 else -1


target_item_index = 0 # 3番目のアイテム(エビ)

print('Predict score: {:.3f}'.format(predict(scores, similarities, target_user_index, target_item_index)))
# Predict score: 2.761

ランキング

最後に予測される評価値が高い順にランキングを生成します。 実利用では、このランキングの順番でユーザにおすすめすることになります。

0番目のユーザには、92番目(キャビア)、61番目(ネギトロ巻き)、0番目(エビ)のアイテムを薦めると良さそうということがわかります。
この結果に対する考察は難しいところですが、ユーザ0は納豆巻(58番目のアイテム)の評価が高い(4)ので、ネギトロ巻きもなんとなく大丈夫そうです。
一方で、いくら(6番目)やとびっこ(42番目)などの魚卵系の寿司ネタに対する評価がない中、キャビアを薦めるのはギャンブルにも思えます。

def rank_items(scores, similarities, target_user_index):
    rankings = []
    target = scores[target_user_index]
    # 寿司ネタ100種類の全てで評価値を予測
    for i in range(100):
        # 既に評価済みの場合はスキップ
        if target[i] >= 0:
            continue

        rankings.append((i, predict(scores, similarities, target_user_index, i)))
        
    return sorted(rankings, key=lambda r: r[1], reverse=True)


target_user_index = 0 # 0番目のユーザ

rank = rank_items(scores, similarities, target_user_index)
print('Ranking: {}'.format(rank))
# Ranking: [(92, 2.8500000000000001), (61, 2.7935924227841857), (0, 2.7609467700985615), ...

情報推薦システム入門 -理論と実践-

情報推薦システム入門 -理論と実践-