推荐系统
目前推荐系统被应用于各个领域,例如淘宝的商品推荐、b站的视频推荐、网易云音乐的每日推荐等等,这些都是基于用于往日在平台的行为模式给用户推荐他们可能喜欢的商品、视频、音乐。
下面我们将以电影推荐系统举例,一步一步通过Python实现一个简单的电影推荐系统。
由于数据量的原因,我们可能无法做到精度较高的推荐系统,但是做一个差不多能实现推荐功能的电影推荐系统是完全没有问题的。
导入模块
import io
import os
import sys
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties
from sklearn.impute import SimpleImputer
from sklearn.metrics.pairwise import cosine_similarity
%matplotlib inline
font = FontProperties(fname='/Library/Fonts/Heiti.ttc')
收集数据
早期讲构造机器学习系统的时候说到,我们第一步往往是需要收集数据。
由于本次要做的推荐系统是和电影有关的,而为了给用户推荐他喜欢的电影,一般会收集每个用户对自己看过的电影的评分。这里我们假设用户看完电影一定会给电影评分,并且评分范围为 { 1 , 2 , 3 , 4 , 5 } \{1,2,3,4,5\} {1,2,3,4,5},即评分最低为1分,最高为5分。如果用户没有评分则意味着用户没有看过该电影。
如果是大型的推荐系统,往往需要通过很多途径获得各种数据,如爬虫、平台合作,并且可能还会考虑用户本身的各种信息,如身高、体重、年龄、兴趣爱好……和电影的各种信息,如篇名、导演、演员阵容、上映时间……,通常这种大型系统针对的数据维度往往都是上万,上十万的。由于数据限制,因此这里只假设用户喜欢看的电影和他对电影的评分有关。
由于这是一个简单的推荐系统版本,所以我们假设我们已经获取了用户对自己看过的电影的评分,即数据在movie.xlsx
表格中。
# 收集数据
# 没有表格文件时自定义数据
csv_data = '''
《肖申克的救赎》,《控方证人》,《这个杀手不太冷》,《霸王别姬》,《美丽人生》,《阿甘正传》,《辛德勒的名单》,姓名
,4.0,,4.0,,5.0,,'刘一'
4.0,,5.0,3.0,5.0,,,'陈二'
3.0,4.0,,3.0,2.0,3.0,3.0,'张三'
2.0,3.0,,3.0,,,,'李四'
3.0,4.0,,5.0,3.0,3.0,,'王五'
,,4.0,,4.0,2.0,,'赵六'
3.0,,1.0,5.0,3.0,3.0,2.0,'孙七'
2.0,,2.0,,1.0,,,'周八'
1.0,2.0,,,,2.0,,'吴九'
,5.0,,4.0,,3.0,3.0,'郑十'
'''
if not os.path.exists('datasets/movie.xlsx'):
# 将文件读入内存
csv_data = io.StringIO(csv_data)
df = pd.read_csv(csv_data, header=0)
df.index = df['姓名'].tolist()
else:
# 从表格中获取数据
df = pd.read_excel('datasets/movie.xlsx', header=0)
df.index = df['姓名'].tolist()
df
《肖申克的救赎》 | 《控方证人》 | 《这个杀手不太冷》 | 《霸王别姬》 | 《美丽人生》 | 《阿甘正传》 | 《辛德勒的名单》 | 姓名 | |
---|---|---|---|---|---|---|---|---|
刘一 | NaN | 4.0 | NaN | 4.0 | NaN | 5.0 | NaN | 刘一 |
陈二 | 4.0 | NaN | 5.0 | 3.0 | 5.0 | NaN | NaN | 陈二 |
张三 | 3.0 | 4.0 | NaN | 3.0 | 2.0 | 3.0 | 3.0 | 张三 |
李四 | 2.0 | 3.0 | NaN | 3.0 | 3.0 | 2.0 | NaN | 李四 |
王五 | 3.0 | 4.0 | NaN | 5.0 | 3.0 | 3.0 | NaN | 王五 |
赵六 | NaN | NaN | 4.0 | NaN | 4.0 | 2.0 | NaN | 赵六 |
孙七 | 3.0 | NaN | 1.0 | 5.0 | 3.0 | 3.0 | 2.0 | 孙七 |
周八 | 2.0 | NaN | 2.0 | NaN | 1.0 | NaN | 2.0 | 周八 |
吴九 | 1.0 | 2.0 | 2.0 | NaN | 1.0 | 1.0 | NaN | 吴九 |
郑十 | NaN | 5.0 | NaN | 4.0 | NaN | 3.0 | 3.0 | 郑十 |
数据预处理
无评分电影处理
从上述表格中可以看到很多用户有些电影是没有看过的,这相当于数据里的缺失值,**《数据预处理》**那一篇详细讲到了缺失值的处理,这里不再赘述,在此处可能使用中位数、平均数、众数恐怕都不行。
这种情况的缺失值为用户没有看过此电影,而我们以后推荐电影也定是推荐用户没有看过的电影,如果你把他没有看过的电影就这样轻易只依据大家对电影的平均评分,你就给他推荐电影,这可能并不合理。因此我在这里统一用 0 0 0替换所有的没有打分的电影,即 0 0 0表示用户没有看过这部电影,然后使用其他算法给用户推荐电影。
# 数据缺失值处理
def imputer_data(np_arr):
# 中位数strategy=median,平均数strategy=mean,众数strategy=most_frequent
imputer = SimpleImputer(missing_values=np.nan,
strategy='constant', fill_value=0)
# 找到用户没有评分的电影
imr = imputer.fit(np_arr.iloc[:, :-1])
# 将用户没有评分的电影评分用0填充
imputed_df = imr.transform(np_arr.iloc[:, :-1])
return imputed_df
imputed_df = imputer_data(df)
imputed_df
array([[0., 4., 0., 4., 0., 5., 0.],
[4., 0., 5., 3., 5., 0., 0.],
[3., 4., 0., 3., 2., 3., 3.],
[2., 3., 0., 3., 3., 2., 0.],
[3., 4., 0., 5., 3., 3., 0.],
[0., 0., 4., 0., 4., 2., 0.],
[3., 0., 1., 5., 3., 3., 2.],
[2., 0., 2., 0., 1., 0., 2.],
[1., 2., 2., 0., 1., 1., 0.],
[0., 5., 0., 4., 0., 3., 3.]])
获取了数据并对数据进行了预处理,接下来就是从数据中得到我们想要的信息,然而我们想要获取什么信息呢?
一般我们想要获取两个用户,或两个电影之间的相似度,以下两点将是我们要从数据中获得的信息。
- 获取用户之间的相似度,然后基于其他用户算出没看过电影的用户可能会给电影的评分。
- 获取相似度最高的电影,然后基于用户看过的电影算出用户可能会给电影的评分。
通过以上两种方式,设定一个评分阈值,如果用户可能会给该电影的评分大于该阈值,则可以放心的给用户推荐电影,反之则不推荐给用户。
协同过滤算法-基于用户的推荐
如果我们想要获取用户user_1和用户user_2之间的相似度时,可以考虑使用余弦相似度来评估两者之间的相似度。余弦相似度的取值范围为 ( − 1 , 1 ) (-1,1) (−1,1),余弦相似度越大,则用户user_1与用户user_2之间的相似度越大,反之则两者之间的相似度越小。
余弦相似度
余弦相似度:
w
u
v
=
∣
N
(
u
)
⋂
N
(
v
)
∣
∣
N
(
u
)
∣
∣
N
(
v
)
∣
w_{uv}=\frac{|N(u)\bigcap{N(v)}|}{\sqrt{|N(u)||{N(v)}|}}
wuv=∣N(u)∣∣N(v)∣∣N(u)⋂N(v)∣
# 数据标准化前用户之间的相似度
user_1 = imputed_df[[0]]
user_2 = imputed_df[[1]]
user_8 = imputed_df[[7]]
user_9 = imputed_df[[8]]
print('电影名:{}'.format(df.columns[:-1].values))
print('刘一:{}'.format(user_1))
print('陈二:{}'.format(user_2))
print('周八:{}'.format(user_8))
print('吴九:{}'.format(user_9))
print('刘一和陈二的余弦相似度:{}'.format(cosine_similarity(user_1, user_2)))
print('陈二和周八的余弦相似度:{}'.format(cosine_similarity(user_2, user_8)))
print('周八和吴九的余弦相似度:{}'.format(cosine_similarity(user_8, user_9)))
电影名:['《肖申克的救赎》' '《控方证人》' '《这个杀手不太冷》' '《霸王别姬》' '《美丽人生》' '《阿甘正传》' '《辛德勒的名单》']
刘一:[[0. 4. 0. 4. 0. 5. 0.]]
陈二:[[4. 0. 5. 3. 5. 0. 0.]]
周八:[[2. 0. 2. 0. 1. 0. 2.]]
吴九:[[1. 2. 2. 0. 1. 1. 0.]]
刘一和陈二的余弦相似度:[[0.18353259]]
陈二和周八的余弦相似度:[[0.73658951]]
周八和吴九的余弦相似度:[[0.58536941]]
从上面的输出的结果可以看到
- 刘一和陈二只对电影《霸王别姬》都共同评分过,其他的都没有共同评分过,两个人之间有很低的相似度是很正常的。
- 陈二和周八虽然对很多电影都共同评分过,但是他们的评分中陈二的评分都是偏高的,而周八都是偏低的,但是两者之间却有着较高的相似度,这明显是不合理的。
- 周八和吴九对很多电影都共同评分过,并且两个人的评分都较偏低,但是应该远没有达到 0.70 0.70 0.70的相似度,一般 0.70 0.70 0.70的相似度是极高的。
对于上述情况是因为评分为 0 0 0在余弦相似度看来是有意义的,也就是较之评分为 1 1 1而言评分为 0 0 0更差。即余弦相似度认为两个人对某一部电影评分都是 0 0 0的话,那两个人给电影评分都很低,两者自然会有很高的相似性,这也是为什么刘一和陈二的相似度很低,而陈二和周八相似度及周八和吴九的相似度都很高的原因。
数据标准化处理
上一节讲到了余弦相似度可能会把电影评分为 0 0 0较于评分 1 1 1认为用户可能讨厌某部电影,即把 0 0 0变得有意义。然而这部电影评分为 0 0 0即用户没有看过此电影,它并无实际意义,即在余弦相似度中的 0 0 0是应该是中性,也就说不能通过用户对电影的评分为 0 0 0就因此判断用户喜不喜欢这部电影,因此我们需要对数据做标准化处理。
首先考虑的可能是热编码处理,它能够让数据都变成中性,但是热编码之后,用户对其他电影的评分可能也变得没有意义。而其他的标准化处理,如归一化是为了做统一尺度处理。因此我们可以自定义一个标准化的方式。
该自定义的标准化方式是重新生成所有用户的评分,使得用户对所有电影的平均评分为
0
0
0,这样在用户把用户没有打分的电影设为
0
0
0的时候这个
0
0
0就成了一个中性值,即
0
0
0在余弦相似度看来是中性的。而使得用户的平均评分为
0
0
0的方法也很简单,可以对某个用户的非零评分的每一个评分值使用如下公式
s
s
t
d
(
i
)
=
x
(
i
)
−
μ
s_{std}^{(i)} = x^{(i)}-\mu
sstd(i)=x(i)−μ
其中
x
(
i
)
x^{(i)}
x(i)表示用户对某部电影的评分,
μ
\mu
μ表示用户对所有电影评分的平均评分。
# 标准化评分
def nonzero_mean(np_arr):
"""计算矩阵内每一行不为0元素的平均数"""
# 找到数组内评分不为0的即非0元素
exist = (np_arr != 0)
# 行非0元素总和
arr_sum = np_arr.sum(axis=1)
# 行非0元素总个数
arr_num = exist.sum(axis=1)
return arr_sum/arr_num
def standard_data(np_arr):
standardized_df = np_arr.copy()
# 非0元素行下标
nonzero_rows = np.nonzero(np_arr)[0]
# 非0元素列下标
nonzero_columns = np.nonzero(np_arr)[1]
# 非0元素行平均值
nonzero_rows_mean = nonzero_mean(np_arr)
# 遍历并修改所有非0元素
for ind in range(len(nonzero_rows)):
# 第ind个元素的行标和列标确定一个元素
i = nonzero_rows[ind]
j = nonzero_columns[ind]
standardized_df[i, j] = round(
np_arr[i, j]-nonzero_rows_mean[i], 2)
return standardized_df
standardized_df = standard_data(imputed_df)
# 数据标准化后用户之间的相似度
user_1 = standardized_df[[0]]
user_2 = standardized_df[[1]]
user_8 = standardized_df[[7]]
user_9 = standardized_df[[8]]
print('电影名:{}'.format(df.columns[:-1].values))
print('刘一:{}'.format(user_1))
print('陈二:{}'.format(user_2))
print('周八:{}'.format(user_8))
print('吴九:{}'.format(user_9))
print('刘一和陈二的余弦相似度:{}'.format(cosine_similarity(user_1, user_2)))
print('陈二和周八的余弦相似度:{}'.format(cosine_similarity(user_2, user_8)))
print('周八和吴九的余弦相似度:{}'.format(cosine_similarity(user_8, user_9)))
电影名:['《肖申克的救赎》' '《控方证人》' '《这个杀手不太冷》' '《霸王别姬》' '《美丽人生》' '《阿甘正传》' '《辛德勒的名单》']
刘一:[[ 0. -0.33 0. -0.33 0. 0.67 0. ]]
陈二:[[-0.25 0. 0.75 -1.25 0.75 0. 0. ]]
周八:[[ 0.25 0. 0.25 0. -0.75 0. 0.25]]
吴九:[[-0.4 0.6 0.6 0. -0.4 -0.4 0. ]]
刘一和陈二的余弦相似度:[[0.30464382]]
陈二和周八的余弦相似度:[[-0.3046359]]
周八和吴九的余弦相似度:[[0.36893239]]
从上面输出的结果可以看到刘一和陈二的相似度提高了,并且陈二和周八的相似度明显降低了,这是符合我们心理预期的,由于 0 0 0变成了中性,周八和吴九的相似度也有着大幅的降低。
预测
上述过程其实我们已经自定义了一个模型,只是这个模型可能并没有用到我们之前学习的传统机器学习算法,他用到的是另一种算法,即一个推荐算法——《协同过滤算法》。
现在我们可以测试我们的模型,当然由于没有真实数据,只能预测某个新用户对某一部他没看过的电影给多少评分。现在假设我们从某个不知名网站弄来了王二麻子和烂谷子对电影的评分,我们可以做一个模型基于上述十个人对电影的评分预测他们会给他们没看过的电影评多少分。
该模型主要是获取某个人如王二麻子观看了所有的电影的评分,然后计算王二麻子与其他所有人之间的余弦相似度,之后通过王二麻子与其他用户的相似度
∗
*
∗相似度对应用户对王二麻子未看电影的评分
/
/
/王二麻子与其他用户的总相似度。假设王二麻子与张三的相似度为
0.2
0.2
0.2,与李四的相似度为
0.6
0.6
0.6,王二麻子没看的电影为《霸王别姬》,而张三对《霸王别姬》的评分为
5
5
5,李四对《霸王别姬》的评分为
2
2
2,则王二麻子对《霸王别姬》的加权评分为
0.2
∗
5
+
0.6
∗
2
0.2
+
0.6
=
2.75
{\frac{0.2*5+0.6*2}{0.2+0.6}}=2.75
0.2+0.60.2∗5+0.6∗2=2.75
# 预测
# 对新数据处理成np.array数组
new_user = '''
1,2,,2,2,2,,王二麻子
2,1,2,,5,1,4,烂谷子
1,2,2,2,3,,4,大芝麻
'''
def predict(new_user, similarity_tool='cosine_similarity'):
rating_list = []
movie_list = []
# 对于输入数据为或str和numpy数组做不同的处理
if isinstance(new_user, str):
new_df = pd.read_csv(io.StringIO(new_user), header=None)
else:
new_df = pd.DataFrame(new_user)
new_df.columns = ['《肖申克的救赎》', '《控方证人》', '《这个杀手不太冷》',
'《霸王别姬》', '《美丽人生》', '《阿甘正传》', '《辛德勒的名单》', '姓名']
# 填充数据并对数据进行标准化
imputed_new_df = imputer_data(new_df)
standardized_new_user = standard_data(imputed_new_df)
# 通过余弦相似度计算预测用户与已有样本之间的相似度
user_similarity_list = []
for ind in range(len(standardized_df)):
user = standardized_df[[ind]]
mod = sys.modules['__main__']
file = getattr(mod, similarity_tool, None)
user_similarity_list.append(cosine_similarity(
user, standardized_new_user)[0])
# 将余弦相似度列表构造成numpy数组方便计算
users_similarity_arr = np.array(user_similarity_list).reshape(
standardized_df.shape[0], standardized_new_user.shape[0])
# 遍历所有用户对她没有评分的电影计算预测评分值
for ind in range(len(new_df)):
empty_rating_ind = []
# 获取名字信息
name = new_df['姓名'][ind]
nonzero_list = np.nonzero(imputed_new_df[[ind], :])[1]
user_similarity_arr = users_similarity_arr[:, [ind]].reshape(1, -1)
# 找到预测用户没有评分电影的索引
for j in range(standardized_new_user.shape[1]):
if j not in nonzero_list:
empty_rating_ind.append(j)
# 遍历预测用户没有评分的电影计算预测评分值
for rating_ind in empty_rating_ind:
# 计算用户的加权评分总和
user_rating_list = imputed_df[:, rating_ind].reshape(1, -1)
rating_arr = user_similarity_arr*user_rating_list
rating_sum = rating_arr.sum(axis=1)
# 计算用户的总相似度
user_similarity_sum = user_similarity_arr.sum(axis=1)
# 当用户总相似度为0时打印提示消息
if rating_sum == 0:
print('亲,{}不怎么看电影我实在无能为力!'.format(name))
else:
rating = rating_sum/user_similarity_sum
print('*{}*可能会给电影{}评分{}'.format(name,
df.columns[:-1].values[rating_ind], round(rating[0], 2)))
# 统计评分
rating_list.append(round(rating[0], 2))
empty_rating_ind = []
return rating_list
rating_list = predict(new_user)
*王二麻子*可能会给电影《这个杀手不太冷》评分-0.21
*王二麻子*可能会给电影《辛德勒的名单》评分-0.31
*烂谷子*可能会给电影《霸王别姬》评分2.79
*大芝麻*可能会给电影《阿甘正传》评分3.13
从上面的预测的数据看到,其实预测效果还很不错。
上面讲到了其实还有一种方法,即基于电影(项目)的推荐,即获取相似度较高的电影。流程为计算所有被你评分的电影与你未评分的电影之间的余弦相似度,然后通过与基于用户推荐相同的方法就可以实现电影选择你。实现方法即对上述给出的表格转置然后修改代码中的参数即可,此处不多赘述。
测试
l = np.random.randint(0, 6, size=(50, 8))
l[:, [-1]] = 0
rating_list = predict(l)
*0*可能会给电影《这个杀手不太冷》评分0.09
*0*可能会给电影《肖申克的救赎》评分2.59
*0*可能会给电影《控方证人》评分1.9
*0*可能会给电影《肖申克的救赎》评分4.62
*0*可能会给电影《控方证人》评分19.66
*0*可能会给电影《肖申克的救赎》评分3.34
*0*可能会给电影《美丽人生》评分1.26
*0*可能会给电影《阿甘正传》评分9.15
*0*可能会给电影《控方证人》评分0.27
*0*可能会给电影《控方证人》评分2.42
*0*可能会给电影《这个杀手不太冷》评分0.36
*0*可能会给电影《美丽人生》评分3.18
*0*可能会给电影《控方证人》评分0.46
*0*可能会给电影《这个杀手不太冷》评分1.67
*0*可能会给电影《控方证人》评分3.79
*0*可能会给电影《美丽人生》评分2.18
*0*可能会给电影《霸王别姬》评分1.67
*0*可能会给电影《美丽人生》评分2.36
*0*可能会给电影《阿甘正传》评分-1.8
*0*可能会给电影《这个杀手不太冷》评分1.89
*0*可能会给电影《霸王别姬》评分2.5
*0*可能会给电影《辛德勒的名单》评分0.31
*0*可能会给电影《霸王别姬》评分0.87
*0*可能会给电影《美丽人生》评分2.98
*0*可能会给电影《肖申克的救赎》评分1.95
*0*可能会给电影《美丽人生》评分1.37
*0*可能会给电影《肖申克的救赎》评分-0.26
亲,0不怎么看电影我实在无能为力!
亲,0不怎么看电影我实在无能为力!
亲,0不怎么看电影我实在无能为力!
亲,0不怎么看电影我实在无能为力!
*0*可能会给电影《肖申克的救赎》评分2.36
*0*可能会给电影《霸王别姬》评分-6.45
*0*可能会给电影《控方证人》评分1.23
*0*可能会给电影《这个杀手不太冷》评分0.41
*0*可能会给电影《霸王别姬》评分-3.71
*0*可能会给电影《美丽人生》评分1.7
*0*可能会给电影《这个杀手不太冷》评分0.8
*0*可能会给电影《美丽人生》评分1.82
*0*可能会给电影《肖申克的救赎》评分2.37
*0*可能会给电影《这个杀手不太冷》评分-0.43
*0*可能会给电影《霸王别姬》评分1.25
*0*可能会给电影《肖申克的救赎》评分1.73
*0*可能会给电影《这个杀手不太冷》评分1.02
*0*可能会给电影《阿甘正传》评分2.18
*0*可能会给电影《辛德勒的名单》评分-0.66
*0*可能会给电影《美丽人生》评分3.2
*0*可能会给电影《这个杀手不太冷》评分-0.83
*0*可能会给电影《肖申克的救赎》评分2.59
*0*可能会给电影《美丽人生》评分2.14
*0*可能会给电影《辛德勒的名单》评分2.2
*0*可能会给电影《美丽人生》评分2.9
*0*可能会给电影《美丽人生》评分1.86
*0*可能会给电影《阿甘正传》评分0.82
*0*可能会给电影《美丽人生》评分7.91
*0*可能会给电影《控方证人》评分10.1
*0*可能会给电影《这个杀手不太冷》评分-4.76
*0*可能会给电影《这个杀手不太冷》评分9.9
*0*可能会给电影《美丽人生》评分2.69
*0*可能会给电影《辛德勒的名单》评分2.41
*0*可能会给电影《肖申克的救赎》评分2.07
*0*可能会给电影《辛德勒的名单》评分2.55
*0*可能会给电影《肖申克的救赎》评分1.78
*0*可能会给电影《霸王别姬》评分1.12
*0*可能会给电影《肖申克的救赎》评分1.67
*0*可能会给电影《美丽人生》评分1.59
*0*可能会给电影《肖申克的救赎》评分1.7
*0*可能会给电影《这个杀手不太冷》评分3.54
*0*可能会给电影《美丽人生》评分2.0
测试结果可以看出模型其实还行,因为评分过高和无评分的现象很少,即符合正态分布。