ECサイトで協調フィルタリングを使った簡単なレコメンデーションをつくってみる

## はじめに

業務でECサイトを運営していると、Amazonの「これを買った人はこれも買ってます」的なユーザーごとに商品のレコメンドをやりたいという話がよくでるので、簡単なレコメンドの機能のつくってみることにしました。

## 手順
今回行うのは、レコメンデーションでは一般的な「協調フィルタリング(Collaborative Filtering)」の手法を用います。
有名なCouceraのAndrew Ngによる[Machine Learning](https://www.coursera.org/learn/machine-learning
)での協調フィルタリングの例を使ってかいつまんで説明すると、

① ユーザーは各映画に対して5段階評価を行なっている。ただしユーザーはすべての映画を見ているとは限らない。？はユーザーがまだ評価していない映画。
 
|  | Alice | Bob | Carol | Dave |
|:-----------|:-----------|:-----------|:-----------|:-----------|
| Love at last |5|5|0|0|
| Romance forever |5|?|?|0|
| Cute puppies of love |?|4|0|?|
| Nonstop Car Chases |0|0|5|4|
| Swords vs Karte |0|0|5|?|

② 各ユーザー間の類似度を出す。両ユーザーが評価している映画に絞りそれぞれの評価の差をの合計から類似度を求めます。
例えば、AliceとBobの両方が評価している映画は3つで、すべて同じ評価を出しているので類似度は高くなります。

③ 類似度を基に?の部分を他のユーザーの評価から推測する。例えばBobはAliceとの類似度がとても高いので、BobのRomance foreverへの評価はAliceが評価した5に近いと推測できます。


##前処理
まず対処になるデータはユーザーの購入履歴です。
運営しているECサイトは食材の販売を行なっているので、ユーザーは定期的に商品を注文します。
RDBに注文データとして、誰がいつ何をいくつ買ったというデータがあるのでこれを使います。

| ユーザーid | 商品id | 注文数 | 注文日 |
|:-----------|:-----------|:-----------|:-----------|
| 1       |       1 |     2     | 2018-1-1 |
| 2       |       2 |     1     | 2018-1-1 |
| 3       |       2 |     3     | 2018-1-1 |
| 1       |       1 |     1     | 2018-1-2 |
| 3       |       1 |     10     | 2018-1-2 |
| 1       |       3 |     2     | 2018-1-2 |

今回は直近3ヶ月のデータに絞って、その期間内でユーザーごと商品ごとに何回買ったかを抽出します。
レコメンドではユーザーが商品へ評価した値を使いますが、今回は評価値を持っていないので商品を何回かったか(リピートしたか)を評価値の代わりにします。
また、全体で注文回数が5回以下のユーザーは十分な評価ができないので除きます。
データ数は約6万、ユーザー数は約1,000、商品数は約1,000

|ユーザーid | 商品id | 注文回数 |
|:-----------|:-----------|:-----------|
| 1       |       1 |     2     | 
| 1       |       3 |     1     | 
| 2       |       1 |     1     | 
| 3       |       3 |     1     | 

また抽出したデータを計算用と評価用の2つに分けます。
計算用から出した推測値の答え合わせように評価用とつきあわせるためです。推測値と評価用データセットとの値の誤差で、推測値の妥当性をだします。
データの分割は単純にデータセットを8:2で分けています。

```py
import pandas as pd
from sklearn.model_selection import train_test_split

# csv形式のデータを取り込み
datas = pd.read_csv('dataset.csv') 
#注文数
user_value_counts = datas['ユーザーid'].value_counts()
#注文数が5以下のユーザーを省く
user_value_counts = user_value_counts[user_value_counts > 5].index.tolist()
dataset = datas[datas['ユーザーid'].isin(user_value_counts)]
# 計算用と評価用に分ける
train, test = train_test_split(dataset, test_size=0.2)
train.to_csv("train.csv")
test.to_csv("test.csv")
```

## 類似度計算
類似度の計算手法にはユークリッド距離を使用します。
ユークリッド距離は三平方の定理から距離を出す方法です。

```math
S = \sqrt{(a_1 - b_1)^{2} + (a_2 - b_2)^{2}}
```

類似度の計算の実装はこちらを参考にさせていただきました。
https://qiita.com/hik0107/items/96c483afd6fb2f077985

```py
def get_similairty(user1, user2):

  ## 両者とも注文のある集合を取る
  set_user1 = set(dataset[user1].keys())
  set_user2 = set(dataset[user2].keys())
  set_both = set_user1.intersection(set_user2)

  if len(set_both)==0: #評価した共通のものがない場合は類似度を0とする
    return 0

  list_destance = []

  for item in set_both:
    #差の二乗
    distance = pow(dataset[user1][item]-dataset[user2][item], 2) 
    list_destance.append(distance)
  
  # 足し合わせて平方根をとる
  total_distance = math.sqrt(sum(list_destance))
  return 1/(1 + total_distance) #類似度が高ければ1、低ければ0
```

# スコア計算
購入していない商品に対して他ユーザーの購入頻度と類似度をかけてスコアを計算します。
類似度が0~1の値なので、類似度が1であれば同じスコアになります。
すべてのユーザーに対してスコア計算を行い、加重平均で平均値をだします。
加重平均：

```math
x = \frac{s_1x_1 + s_2x_2 ... +s_nx_n}{s_1 + s_2 + ..+s_n}
```

``` py
def get_scores(user):

  total_score = {}
  total_sim= {}

    
  # 本人を除いたユーザーのリスト
  list_others = set(dataset.keys())
  list_others.remove(user)

  for other in list_others:
    # 本人が購入していない商品の一覧
    set_other = set(dataset[other])
    set_user = set(dataset[user])
    set_new_items = set_other.difference(set_user)

    # あるユーザと本人の類似度を計算(simは0~1の数字)
    similarity = get_similairty(user, other)

    # (本人がまだ見たことがない)映画のリストでFor分を回す
    for item in set_new_items:
      # 類似度 x レビュー点数
      total_score.setdefault(item,0)
      total_score[item] += dataset[other][item]*similarity 

      # 類似度の積算値
      total_sim.setdefault(item,0)
      total_sim[item] += similarity
  # 商品ごとの平均スコアを返す
  return {item: total/total_sim[item] for item,total in total_score.items()}
```


これを各ユーザー、各商品について計算します。

```py
datas = pd.read_csv('train.csv')
# {user_id: {food_id: count}}の形式に変換する
dataset = datas.groupby('ユーザーid')['商品id', '注文回数'].apply(lambda x:  x.set_index('商品id').to_dict()['注文回数'])

# スコア計算
predict_result = { user_id: get_scores(user_id) for user_id in dataset.keys()}

# csvに出力
result_list = []
for user_id in predict_result.keys():
    for item_id in predict_result[user_id].keys():
        result_list.append([user_id, item_id,  predict_result[user_id][item_id]])
df = pd.DataFrame(result_list,columns=["user_id", "item_id", "predict"])
df.to_csv("predict.csv")
```

## 精度確認
すべてのユーザーのすべての商品に対するスコアが出たので、正しいかを確認します。
計算用と確認用でデータを分割してあるので、計算してだしたスコアと実データの値を比較し、平均の誤差を出します。

```py
test_datas = pd.read_csv('test.csv')
predicts = pd.read_csv('predict.csv')

# 誤差合計
diffs = 0
# テストデータ数
length = len(test_datas.index)

for i, test_data in test_datas.iterrows():
    user_id = test_data['ユーザーid']
    item_id = test_data['商品id']
    actual = test_data['注文回数']
    # 予測のスコアを取得する
    predict_set = predicts[(predicts['user_id'] == user_id ) & (predicts['item_id'] == item_id) ]
    if len(predict_set) == 0: 
        continue
    predict = predict_set.iloc[0]["predict"]
    if math.isnan(predict):
        continue
    # 実値と予測値の絶対値を積算
    diff = np.absolute(actual - predict)
    diffs = diffs + diff
diffs / length
```

結果は、
誤差平均：2.1677379548278464

わりと悪くない精度かなと思います。

## 課題
スコアの高いものをレコメンドしてあげればいいかと思ったけれど、単価の低いものは比較的注文回数が多くなるので、スコアが高くなる傾向にある。
例えば、100円のトマトは毎日買っても、5,000円の牛肉は週に1回しか買わない。
そのため、どの食材をレコメンドするかは単価も考慮に入れる必要がある。

## 番外編
類似度計算に、コサイン類似度を使って計算してみました。
コサイン類似度は、ベクトル同士のなす角を基に類似度を出す方法です。

``` math
sim(A,B) = cos(\theta) = \frac{A・B}{|A||B|}
```

実装はこちらを参考にさせていただきました。
https://qiita.com/haminiku/items/f5008a57a870e0188f63

```py
def get_similairty(user1, user2):

  ## 両者とも評価のある集合を取る
  set_user1 = set(dataset[user1].keys())
  set_user2 = set(dataset[user2].keys())
  set_both = set_user1.intersection(set_user2)

  ab = 0  # A・B

  for item in set_both.keys():
        value1 = set_user1[item]
        value2 = set_user2[item]
        ab += float(value1 * value2)

  # |A| and |B|
  a = math.sqrt(sum([v ** 2 for v in set_user1.values()]))
  b = math.sqrt(sum([v ** 2 for v in set_user2.values()]))
  return float(ab / (a * b))
```
最終的な結果は、
誤差平均：8.4682790857361017
ユークリッド距離の時よりも誤差が大きくなってしまったので失敗です。

今回は学習を行わなかったので、次は学習ありでの計算モデルを試してみようと思います。