レコメンドシステム（コンテンツベースフィルタリング）を実装してみた

# はじめに
レコメンドシステムのうち、アイテムの特徴のみでレコメンドを行う<b>コンテンツベースフィルタリング</b>を実装してみようと思います。（レコメンドシステムの種類に関しては<a href="https://qiita.com/k-oto/items/7790941656fba0068ed8">こちらの記事</a>をご覧ください。）

# コンテンツベースフィルタリング
コンテンツベースフィルタリングとは、<b>アイテムの特徴</b>を元に推薦を行う手法です。ユーザーの閲覧/購入履歴のアイテムと<b>類似性の高いアイテム</b>を計算し、提示します。
<br>
実際に以下のような流れで実装します。

1. アイテムの特徴ベクトルを抽出
2. 類似度の計算
3. 類似度の高いアイテムをレコメンド

# アイテムのベクトル化
類似度を計算するために、まずはアイテムの特徴（単語や文章）を<b>特徴ベクトル</b>に変換します。ベクトル化の方法はOne-Hot EncodingやTF-IDFなどいくつかありますが、今回はアイテムの特徴が単語のデータを使用するのでOne-Hot表現にします。

# 類似度の計算
アイテムのベクトル化が行えたら次は類似度の計算です。類似度の計算もいくつか方法がありますが、今回はよく用いられる<b>コサイン類似度</b>で計算します。

$$
 \cos({x}, {y}) = \frac{{x} \cdot {y}}{|{x}| |{y}|}
$$

例えばアイテム$x,y$の類似度を計算する場合、$x$と$y$の特徴ベクトルを以下とすると、
$$x=(0,1,1,0,0,1,0,1)$$$$y=(0,1,0,0,0,1,0,0)$$コサイン類似度はこのように計算できます。
$$x \cdot y=1+1=2$$$$|{x}|=\sqrt{1+1+1+1}=2$$$$|{y}|=\sqrt{1+1+1}=\sqrt{3}$$$$cos(x,y)=\frac{2}{|{2}| |{\sqrt{3}}|}=0.57735$$



# 実装
それでは実際にkaggleのコンペの<a href="https://www.kaggle.com/CooperUnion/anime-recommendations-database">データ</a>を使用して実装してみます。まずはデータを確認します。（今回はgenreカラムでアイテムの特徴を計算するため、typeやratingカラムは見やすいように削除します。カラムを消さずに実行しても問題ないです。）

```python:コード
import pandas as pd
import numpy as np

#データの読み込み
anime_data = pd.read_csv("anime.csv")

#データの長さを確認
print("データ数:", len(anime_data.anime_id))

#使わないカラムを削除
anime_data = anime_data.drop(columns = ['type', 'episodes', 'rating', 'members'])

#データの中身を確認
anime_data.head()
```
データは以下の通りです。

```python:実行結果
データ数: 12294

   anime_id      name                                  genre
0	32281	Kimi no Na wa.	                   Drama, Romance, School, Supernatural
1	5114	Fullmetal Alchemist: Brotherhood   Action, Adventure, Drama, Fantasy, Magic, Mili...
2	28977	Gintama	                           Action, Comedy, Historical, Parody, Samurai, S...
3	9253	Steins;Gate	                       SciFi, Thriller
4	9969	Gintama039;     	               Action, Comedy, Historical, Parody, Samurai, S...
```

次にアイテムのベクトル化を行います。今回はジャンルのデータが単語で入っているので、One-Hot Encodingで特徴ベクトルにします。anime_dataのgenreカラムにはジャンル名がカンマ区切りで入っているので、以下のコードでジャンル名のカラムを作成します。genre_colにgenreを追加していきますが、この時set()を使っているので重複する要素は取り除かれます。

```python:コード
genres = anime_data['genre'].map(lambda x: x.split(',')).to_list()
genre_col = list()
for i in genres:
    genre_col.extend(i)
genre_col = list(set(genre_col))

#ジャンル名のカラムの長さを確認
print(len(genre_col)
```

```python:実行結果
#ジャンル名のカラムの長さ
83
```

作成したジャンル名のカラムを使い、ジャンル要素のOne-Hot表現にします。row_listで各行をリスト化し、rowsに追加していきます。最後にDataFrameを作成し、genre_dfに格納します。

```python:コード
#One-Hot Encoding
rows = list()
for index, row in enumerate(genres):
    row_list = np.array([0] * len(genre_col))
    index_list = [genre_col.index(item) for item in row]
    row_list[index_list] = 1
    rows.append(list(row_list))
genre_df = pd.DataFrame(rows, columns = genre_col)
one_hot_data = pd.concat([anime_data, genre_df], axis= 1)
```
分かりやすいようにアニメのidや名前を結合したone_hot_dataを出力するとこんな感じです。

|    |anime_id|name               |Kids|Game|Psychological|Fantasy|Space|School|	
|----|----    |----               |----|----|----         |----   |---- |----  |
| 0  |32281   |Kimi no Na wa.     |0   |0   |0            |0	  |0    |0     |	
| 1  |5114    |Fullmetal Alchemist|0   |0   |1            |0      |0    |0     |
| 2  |28977   |Gintama            |0   |0   |0            |0      |0    |0     |
（12294×86）

<br>
この特徴ベクトルを使って、コサイン類似度の計算をします。以下のコードで類似度行列を作成します。

```python:コード
#one-hot表現の部分で配列を作成
item_vectors = np.array(one_hot_data[genre_col])

#行毎のベクトルノルム
norm = np.matrix(np.linalg.norm(item_vectors, axis=1))

#コサイン類似度の式を使って類似度行列を作成
sim_mat = np.array(np.dot(item_vectors, item_vectors.T)/np.dot(norm.T, norm))
```

このsim_matのままだと何行目がどのアニメか分かりづらいので、anime_idとindexの対応表をキーバリュー型で作成します。

```python:コード
itemindex = dict()
for num, item_id in enumerate(one_hot_data.anime_id):
    itemindex[item_id] = num
itemindex
```
```python:実行結果
{32281: 0,
 5114: 1,
 28977: 2,
 9253: 3,
 9969: 4,
 32935: 5,
 11061: 6,
```

実際に類似度行列から類似度の高いアイテムを取り出してみます。ここでは『君の名は（anime_id：32281）』と類似度の高いアイテムを10個表示させます。

```python:コード
#anime_idを指定してindexを検索、row_numに格納
row_num = itemindex[32281]

#類似度行列のrow_numの列でのトップ10を抽出
top10_index = np.argsort(sim_mat[row_num])[::-1][1:11]

top10_index
```

```python:実行結果
array([6394, 5805,  208, 1959,  504, 1494, 2300, 1201, 5127, 1436])
```

top10_indexのindexと対応するanime_idを検索します。

```python:コード
rec_id = list()
for search_index in top10_index:
    for anime_id, index in itemindex.items():
        if index == search_index:
            rec_id.append(anime_id)
rec_id
```

```python:実行結果
[546, 547, 28725, 713, 6351, 20903, 12175, 10067, 1607, 8481]
```

得られたanime_idから類似度の高いアイテムを表示させてみます。 
           
```python:コード
anime_data.query("anime_id == [546, 547, 28725, 713, 6351, 20903, 12175, 10067, 1607, 8481] ")
```


|     |anime_id|name                          |genre|
|---- |----    |----                          |---- |
|208  |28725   |Kokoro ga Sakebitagatterunda. |Drama, Romance, School|
|504  |6351    |Clannad: After Story - Mou Hitotsu no Sekai|	Drama, Romance, School|
|1201 |10067   |Angel Beats!: Another Epilogue|Drama, School, Supernatural
|1436 |8481    |&quot;Bungaku Shoujo&quot; Memoire|	Drama, Romance, School
|1494 |20903   |Harmonie|	Drama, School, Supernatural
|1959 |713     |Air Movie|	Drama, Romance, Supernatural
|2300 |12175   |Koi to Senkyo to Chocolate|	Drama, Romance, School
|5127 |1607    |Venus Versus Virus|	Drama, Romance, Supernatural
|5805 |547     |Wind: A Breath of Heart OVA|	Drama, Romance, School, Supernatural
|6394 |546     |Wind: A Breath of Heart (TV)|	Drama, Romance, School, Supernatural


# 補足
One-Hot Encodingを今回は自力で行いましたが、Category Encodersを使うとより簡単にできます。<a href="https://qiita.com/Hyperion13fleet/items/afa49a84bd5db65ffc31">こちらの記事</a>が分かりやすかったので紹介させていただきます。

#最後に
アイテムのベクトル化と類似度の計算は、他の方法やコードの書き方があると思うのでまた別のも試してみようと思います。今回は<a href="https://yolo-kiyoshi.com/2020/08/26/post-2234/#outline__1_1">こちらの記事</a>を参考にさせていただきました。レコメンド以外にも分かりやすい記事が多かったです。