#はじめに
アニメを見ることが好きで、毎日アニメを見ていると、少し違ったジャンルのアニメも見たくなります。しかしアニメ配信サイトでレコメンドされるアニメは同じ系統の物が多く時々大きく好きなジャンルからは外れず、すこし違ったものも紹介してほしいと思うことがあります。かなりわがままですが、ふと、まだ技術や経験も未熟な自分が本気でレコメンドシステムを作ると、完璧なジャンル分けができないため、いい感じのものができるのではないかと考えました。
欲しいものは少しだけ的外れなレコメンンドシステムですが、レコメンドの精度を上げることも勉強になるので、自分のできる最大限の力で作ってみます。その結果自分の力不足で的外れなレコメンンドシステムができてもいいなと思っています。そうなれば、自分の勉強にもなり、欲しいものも手に入るので一石二鳥です!
#協調フィルタリング
協調フィルタリングとは、レコメンドを行う際のアルゴリズムの一つで、ユーザーの行動履歴や商品に対する評価値から似たような性質の物を探し、推薦するもので、評価値があればドメインなどの知識がなくても推薦を行うことが可能な方法である。しかし、ユーザー数がある程度多いシステムでなければ精度の良い結果を得ることが難しい。また新規のユーザーが来た場合には、そのユーザーの情報がないため、レコメンドがうまくいかない(コールドスタート問題)ということが発生する。協調フィルタリングには、アイテムベースの物と、ユーザーベースの物がある。
##アイテムベース
あるユーザー5人が、5つの商品を5段階で評価している時、レコメンドを行いたいユーザーが商品Aのみを高く評価している時、5人の他のユーザーがつけた各商品の評価値から商品Aに類似した商品を推測し、対象者にレコメンドするといったものです。
商品A | 商品B | 商品C | 商品D | 商品E | |
---|---|---|---|---|---|
対象ユーザー | 5 | - | - | - | - |
ユーザーA | 4 | 2 | - | 1 | - |
ユーザーB | 3 | 1 | 5 | - | - |
ユーザーC | - | 2 | 4 | 2 | 3 |
ユーザーD | 4 | - | 4 | 1 | 1 |
ユーザーE | 3 | 2 | - | 1 | - |
##ユーザーベース
アイテムベースではアイテム同士の類似性を評価しましたが、ユーザーベースではユーザー同士の類似性を評価します。同じく、ユーザーが商品Aのみを高く評価している時、同様に商品Aを評価しているユーザーを探し、対象者がまだ購入していない商品をレコメンドします。
##cos類似度
ユーザーやアイテムの類似度を以下のような式で計算する
\cos(x,y)\ = \frac{x・y}{|x||y|}
#実装
今回作成するアニメのレコメンドシステムは、kaggleで公開されているデータセットを使用しています。
##データの確認
#ライブラリのインポート
import pandas as pd
import numpy as np
import scipy as sp
from sklearn.metrics.pairwise import cosine_similarity
import operator
#データの読み込み
anime_data = pd.read_csv("./anime.csv")
rating_data = pd.read_csv("./rating.csv")
anime_data
#データの情報と欠損値の確認
anime_data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 12294 entries, 0 to 12293
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 anime_id 12294 non-null int64
1 name 12294 non-null object
2 genre 12232 non-null object
3 type 12269 non-null object
4 episodes 12294 non-null object
5 rating 12064 non-null float64
6 members 12294 non-null int64
dtypes: float64(1), int64(2), object(4)
memory usage: 672.5+ KB
#各評価値のおおよその個数を確認
rating_data["rating"].hist(bins=11, figsize=(5, 5), color="b")
#回答していないことを表す-1をNaNに変換
rating_data.replace(-1, np.nan, inplace=True)
rating_data.head()
#トップ20の人気アニメの確認
anime_data.sort_values("members", ascending=False)[:20]
#基本統計量の確認
print(anime_data.describe())
print(rating_data.describe())
anime_id rating members
count 12294.000000 12064.000000 1.229400e+04
mean 14058.221653 6.473902 1.807134e+04
std 11455.294701 1.026746 5.482068e+04
min 1.000000 1.670000 5.000000e+00
25% 3484.250000 5.880000 2.250000e+02
50% 10260.500000 6.570000 1.550000e+03
75% 24794.500000 7.180000 9.437000e+03
max 34527.000000 10.000000 1.013917e+06
user_id anime_id rating
count 7.813737e+06 7.813737e+06 6.337241e+06
mean 3.672796e+04 8.909072e+03 7.808497e+00
std 2.099795e+04 8.883950e+03 1.572496e+00
min 1.000000e+00 1.000000e+00 1.000000e+00
25% 1.897400e+04 1.240000e+03 7.000000e+00
50% 3.679100e+04 6.213000e+03 8.000000e+00
75% 5.475700e+04 1.409300e+04 9.000000e+00
max 7.351600e+04 3.451900e+04 1.000000e+01
##データクレンジング
#レコメンド精度向上のために極端にmembersが少ないアニメを削除
new_anime_data = anime_data[anime_data["members"]>5000]
#エピソード数がUnknownの物を0に変換(のちに数値に直した場合に欠損値にならないようにするため)
new_anime_data["episodes"] = new_anime_data["episodes"].replace({"Unknown": "0"})
#new_anime_dataの欠損値の確認
print(new_anime_data.isnull().sum())
anime_id 0
name 0
genre 3
type 5
episodes 0
rating 74
members 0
dtype: int64
データの量に対して、欠損値の数が少ないため今回は欠損値を含む行を削除します。
データが多い場合には補完を行っていきます。
欠損値の削除
new_anime_data = new_anime_data.dropna()
new_anime_data.isnull().sum()
# raitingの値が0以上の物み残す
rating_data = rating_data[rating_data["rating"] >= 0]
print(rating_data.describe())
#データフレームを結内部結合
data = pd.merge(rating_data, new_anime_data, on = "anime_id", how="inner")
data.rename(columns={'rating_x': 'rating'}, inplace=True)
data.rename(columns={'rating_y': 'rating_mean'}, inplace=True)
#ユーザーID、アニメ名、ユーザー評価でピボットテーブルの作成
data2 = data[['user_id', 'name', 'rating']]
piv_rating = pd.pivot_table(data2, index=['user_id'], columns=['name'], values='rating')
##協調フィルタリング
#標準化するためにそれぞれの評価値から平均を引く
#評価が一つのみ、または同じ評価をしたユーザーを全て削除
#値の正規化
piv_norm = piv_rating.apply(lambda x: (x-np.mean(x))/(np.max(x)-np.min(x)), axis=1)
#評価しなかった人を表す0のみを含むすべての列を削除
piv_norm.fillna(0, inplace=True)
piv_norm = piv_norm.T
piv_norm = piv_norm.loc[:, (piv_norm != 0).any(axis=0)]
#疎行列の作成
piv_sparse = sp.sparse.csr_matrix(piv_norm.values)
#cos類似度の行列を作成
item_similarity = cosine_similarity(piv_sparse)
user_similarity = cosine_similarity(piv_sparse.T)
#行列をデータフレームにする
item_sim_df = pd.DataFrame(item_similarity, index = piv_norm.index, columns = piv_norm.index)
user_sim_df = pd.DataFrame(user_similarity, index = piv_norm.columns, columns = piv_norm.columns)
作成した行列を使ってユーザーベースのフィルタリングと、アイテムベースのフィルタリングを行う。
#cos類似度の最も高いtop10のアニメを返す関数
def top_anime(anime_name):
count = 1
print('Similar show to {} include:\n'.format(anime_name))
for item in item_sim_df.sort_values(by = anime_name, ascending = False).index[1:11]:
print('No. {}: {}'.format(count, item))
count += 1
#類似性の最も高いユーザー5人を返す関数
def top_users(user):
if user not in piv_norm.columns:
return('No data available on user {}'.format(user))
print('Most Similar Users:\n')
sim_values = user_sim_df.sort_values(by=user, ascending=False).loc[:,user].tolist()[1:11]
sim_users = user_sim_df.sort_values(by=user, ascending=False).index[1:11]
zipped = zip(sim_users, sim_values,)
for user, sim in zipped:
print('User #{0}, Similarity value: {1:.2f}'.format(user, sim))
#類似しているユーザーごとに最も評価の高いアニメを含むリストを作成し、アニメ名とリストに表示される頻度を返す関数
def similar_user_recs(user):
if user not in piv_norm.columns:
return('No data available on user {}'.format(user))
sim_users = user_sim_df.sort_values(by=user, ascending=False).index[1:11]
best = []
most_common = {}
for i in sim_users:
max_score = piv_norm.loc[:, i].max()
best.append(piv_norm[piv_norm.loc[:, i]==max_score].index.tolist())
for i in range(len(best)):
for j in best[i]:
if j in most_common:
most_common[j] += 1
else:
most_common[j] = 1
sorted_list = sorted(most_common.items(), key=operator.itemgetter(1), reverse=True)
return sorted_list[:5]
#類似しているユーザーの重み平均を計算して入力ユーザーの潜在的な評価を決定し表示する関数
def predicted_rating(anime_name, user):
sim_users = user_sim_df.sort_values(by=user, ascending=False).index[1:1000]
user_values = user_sim_df.sort_values(by=user, ascending=False).loc[:,user].tolist()[1:1000]
rating_list = []
weight_list = []
for j, i in enumerate(sim_users):
rating = piv_rating.loc[i, anime_name]
similarity = user_values[j]
if np.isnan(rating):
continue
elif not np.isnan(rating):
rating_list.append(rating*similarity)
weight_list.append(similarity)
return sum(rating_list)/sum(weight_list)
##関数が動いているかを確認
Hunter x Hunter (2011)に最も類似しているアニメ10個
#top_anime関数の確認
top_anime("Hunter x Hunter (2011)")
Similar show to Hunter x Hunter (2011) include:
No. 1: Fullmetal Alchemist: Brotherhood
No. 2: One Punch Man
No. 3: Steins;Gate
No. 4: Magi: The Kingdom of Magic
No. 5: Kiseijuu: Sei no Kakuritsu
No. 6: Haikyuu!! Second Season
No. 7: Code Geass: Hangyaku no Lelouch R2
No. 8: Shokugeki no Souma
No. 9: Shingeki no Kyojin
No. 10: Fate/Zero 2nd Season
user_idが52の人と最も類似している人
#top_users関数の確認
top_users(52)
Most Similar Users:
User #4621, Similarity value: 0.29
User #8501, Similarity value: 0.29
User #822, Similarity value: 0.27
User #218, Similarity value: 0.27
User #8837, Similarity value: 0.26
User #569, Similarity value: 0.26
User #100, Similarity value: 0.25
User #1930, Similarity value: 0.24
User #3236, Similarity value: 0.23
User #2433, Similarity value: 0.23
類似しているユーザーごとに最も評価の高いアニメを含むリストを作成
#similar_user_recs関数の確認
similar_user_recs(52)
[('Code Geass: Hangyaku no Lelouch R2', 3),
('Dragon Ball', 2),
('Dragon Ball GT', 2),
('Code Geass: Hangyaku no Lelouch', 2),
('Fullmetal Alchemist: Brotherhood', 2)]
似しているユーザーの重み平均を計算して入力ユーザーの潜在的な評価を決定
#predicted_rating関数の確認
predicted_rating("Hunter x Hunter (2011)", 52)
9.236825461918766
##クラスタリング
前述したように、協調フィルタリングではコールドスタート問題が発生する。そこで、新規のユーザーに、あらかじめクラスタリングにより、カテゴリ分けされたアニメを表示し、見たことのあるアニメや、興味のあるアニメを選んでもらうことでその新規のユーザーがどのようなジャンルのアニメが好きであるかを特定できる。そのため、新規のユーザーであってもレコメンドを行うことができ、コールドスタート問題を解決することができる。
下記の手順でデータをクラスタリングするために、object型のカテゴリ変数をダミー変数に変換する。
#ジャンルでone-hot-encodingを作成
one_hot_encoding = new_anime_data["genre"].str.get_dummies(sep=", ")
#new_anime_dataとone_hot_encodingを結合
clustering_data = pd.concat([new_anime_data, one_hot_encoding], axis=1)
#episodesをobjectからfloat型に変換
clustering_data["episodes"] = pd.to_numeric(clustering_data["episodes"], errors="coerce")
#typeをラベルエンコーディングでダミー変数化
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
encoded = le.fit_transform(clustering_data["type"].values)
decoded = le.inverse_transform(encoded)
clustering_data["type_number"] = encoded
今回、アニメのジャンルとTVやMovieといったタイプの二つのカテゴリ変数でエンコーディングを行った。しかし、アニメのタイプを削除した状態でクラスタリングを行ったほうが、アニメのジャンル分けがうまくいった。
#必要のないカラムを削除
new_clustering_data = clustering_data.drop(columns=["anime_id", "name", "genre", "type", "episodes", "rating", "members", "type_number"])
#欠損値の有無を確認
new_clustering_data.isnull().sum()
##エルボー法
今回のクラスタリングにはKMeans法と呼ばれる最も代表的なクラスタリング手法の一つを使用する。このKMeans法は非階層クラスタリングという、決められたクラスタ数にしたがって、近い属性のデータをグループ化するというものである。ここでのクラスタ数は任意で値を設定するのだが、このクラスタ数の決定方法にエルボー法というものがある。これはSSEと呼ばれるクラスタリングの成功度合いを表す数値がクラスターの数によってどのように変化したかを見る方法で、理想的なグラフの形は下のようになる。
グラフの傾きが急に変化する点(上の図のクラスター数が4の位置)が最適なクラスター数であると言われている。このように肘のように曲がっていることからエルボー法と呼ばれている。しかし、実際のデータでこのようにきれいな形になることは少ない。
では実際にやってみる。
#クラスタリング
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
#エルボー法
distortions = []
for i in range(1, 11):
km = KMeans(n_clusters=i,
init="k-means++", # k-means++法によりクラスタ中心を選択
n_init=10,
max_iter=300,
)
km.fit(new_clustering_data)
distortions.append(km.inertia_)
# グラフのプロット
plt.plot(range(1, 11), distortions, marker="o")
plt.xticks(np.arange(1, 11, 1))
plt.xlabel("Number of clusters")
plt.ylabel("Distortion")
plt.show()
このようになめらかな変化になることが多い。
今回は傾きの変化の位置が分かりにくいため、多くのクラスタ数に分けるために8個のクラスタ数に分けようと思う。
#K-Means
n_clusters = 8
km = KMeans(n_clusters=n_clusters)
km.fit_predict(new_clustering_data)
cluster_labels = km.predict(new_clustering_data)
new_anime_data["cluster"] = cluster_labels
クラスタリングの結果、主に以下のようなジャンルの特徴を持ったクラスタに分かれました。
・Action, Drama(コードギアス, カウボーイビバップ, 天元突破グレンラガン 等)
・Slice of Life(おおかみこどもの雨と雪, 宇宙兄弟, GTO 等)
・Action, Adventure, Fantasy(ハンター×ハンター, もののけ姫, ワンピース 等)
・Sports, comedy, Historical, Parody(銀魂, 黒子のバスケ, ハイキュー 等)
・Drama, Romance, School(君の名は, 四月は君の嘘, バクマン 等)
・Mystery, Horror, Psychological(獣の奏者エリン, Re:ZERO, キングダム 等)
・Comedy, Romance(涼宮ハルヒの憂鬱, のだめカンタービレ, 神のみぞ知るセカイ 等)
・Adventure, Mystery(千と千尋の神隠し, ワンパンマン, デスノート 等)
#結果と反省点
今回、類似しているアニメを推測した時に、私がハンター×ハンターが好きだったこともあり、このアニメを指定しました。結果はいい感じに予想とは違う結果になったと思います。私はOnePieceなどのアニメをレコメンドされると思っていましたが、実際は進撃の巨人や寄生獣などの少しグシリアスなアニメや、食戟のソーマのような料理アニメも類似アニメとして出ていました。私の望んだ少し的外れなシステムができましたが、もっと勉強が必要だとも思いました。
今後はこれを用いて自分用のアプリなどを作成できればと思っています。
また、クラスタリングの際に、エピソード数やタイプを考慮するとエピソード数、タイプに分類の比重が大きく傾き、ほとんどジャンルでの分類ができなかった理由について考えてみました。今回、エピソード数は数値型、タイプはlabel-encodingを行い、ジャンルはOne-Hot-encodingを行いました。これによってエピソード数,タイプ,ジャンルの3つや、タイプ,ジャンルの2つでクラスタリングを行った場合、各データの数値の大きい順に重要度だとみなされるデータがエピソード数>タイプ>ジャンルとなっているように感じました。そのため、エピソード数やタイプについても0と1であらわされるOne-Hot-encodingを使うことでもっと精度の良いクラスタリングができるのではないかと思いました。