1. 需求
根据一个用户对电影评分的数据集来实现基于用户相似度的协同过滤算法推荐,相似度的算法采用皮尔逊相关系数法
数据样例如下:
用户ID:电影ID:评分:时间
1::1193::5::978300760
1::661::3::978302109
1::914::3::978301968
1::3408::4::978300275
1::2355::5::978824291
1::1197::3::978302268
1::1287::5::978302039
1::2804::5::978300719
1::594::4::978302268
1::919::4::978301368
2. 算法实现
本案例使用的数据分析包为pandas,Numpy和matplotlib
2.1 数据规整
首先将评分数据从ratings.dat中读出到一个DataFrame 里:
>>> import pandas as pd
>>> from pandas import Series,DataFrame
>>> rnames = ['user_id','movie_id','rating','timestamp']
>>> ratings = pd.read_table(r'ratings.dat',sep='::',header=None,names=rnames)
>>> ratings[:3]
user_id movie_id rating timestamp
0 1 1193 5 978300760
1 1 661 3 978302109
2 1 914 3 978301968
[3 rows x 4 columns]
ratings 表中对我们有用的仅是 user_id、movie_id 和 rating 这三列,因此我们将这三列取出,放到一个以 user 为行,movie 为列,rating 为值的表 data 里面。
>>> data = ratings.pivot(index='user_id',columns='movie_id',values='rating') #形成一个透视表
>>> data[:5]
可以看到这个表相当得稀疏,填充率大约只有 5%,接下来要实现推荐的第一步是计算 user 之间的相关系数
2.2 相关度测算
DataFrame对象有一个很亲切的方法:
corr(method='pearson', min_periods=1)
方法,可以对所有列互相计算相关系数。
其中:
- method默认为皮尔逊相关系数,
- min_periods参数,这个参数的作用是设定计算相关系数时的最小样本量,低于此值的一对列将不进行运算。这个值的取舍关系到相关系数计算的准确性,因此有必要先来确定一下这个参数。
2.3 min_periods 参数测定
测定这样一个参数的基本方法:
- 统计在 min_periods 取不同值时,相关系数的标准差大小,越小越好;
但同时又要考虑到,我们的样本空间十分稀疏,min_periods 定得太高会导致出来的结果集太小,所以只能选定一个折中的值。
这里我们测定评分系统标准差的方法为:
- 在 data中挑选一对重叠评分最多的用户,用他们之间的相关系数的标准差去对整体标准差做点估计。
在此前提下对这一对用户在不同样本量下的相关系数进行统计,观察其标准差变化。
首先,要找出重叠评分最多的一对用户。我们新建一个以 user 为行列的方阵 foo,然后挨个填充不同用户间重叠评分的个数:
>>> foo = DataFrame(np.empty((len(data.index),len(data.index)),dtype=int),index=data.index,columns=data.index)
#print(empt.shape): (6040, 6040)
>>> for i in foo.index:
for j in foo.columns:
foo.ix[i,j] = data.ix[i][data.ix[j].notnull()].dropna().count()
这段代码特别费时间,因为最后一行语句要执行 4000*4000 = 1600万遍;
找到的最大值所对应的行列分别为 424 和 4169,这两位用户之间的重叠评分数为 998:
>>> for i in foo.index:
foo.ix[i,i]=0 #先把对角线的值设为 0
>>> ser = Series(np.zeros(len(foo.index)))
>>> for i in foo.index:
ser[i]=foo[i].max() #计算每行中的最大值
>>> ser.idxmax() #返回ser的最大值所在的行号
4169
>>> ser[4169] #取得最大值
998
>>> foo[foo==998][4169].dropna() #取得另一个 user_id
424 4169
Name: user_id, dtype: float64
把 424 和 4169 的评分数据单独拿出来,放到一个名为 test 的表里,另外计算了一下这两个用户之间的相关系数为 0.456,还算不错,另外通过柱状图了解一下他俩的评分分布情况:
>>> data.ix[4169].corr(data.ix[424])
0.45663851303413217
>>> test = data.reindex([424,4169],columns=data.ix[4169][data.ix[424].notnull()].dropna().index)
>>> test
movie_id 2 6 10 11 12 17 ...
424 4 4 4 4 1 5 ...
4169 3 4 4 4 2 5 ...
>>> test.ix[424].value_counts(sort=False).plot(kind='bar')
>>> test.ix[4169].value_counts(sort=False).plot(kind='bar')
对这俩用户的相关系数统计,我们分别随机抽取 20、50、100、200、500 和 998 个样本值,各抽 20 次。并统计结果:
>>> periods_test = DataFrame(np.zeros((20,7)),columns=[10,20,50,100,200,500,998])
>>> for i in periods_test.index:
for j in periods_test.columns:
sample = test.reindex(columns=np.random.permutation(test.columns)[:j])
periods_test.ix[i,j] = sample.iloc[0].corr(sample.iloc[1])
>>> periods_test[:5]
10 20 50 100 200 500 998
0 -0.306719 0.709073 0.504374 0.376921 0.477140 0.426938 0.456639
1 0.386658 0.607569 0.434761 0.471930 0.437222 0.430765 0.456639
2 0.507415 0.585808 0.440619 0.634782 0.490574 0.436799 0.456639
3 0.628112 0.628281 0.452331 0.380073 0.472045 0.444222 0.456639
4 0.792533 0.641503 0.444989 0.499253 0.426420 0.441292 0.456639
[5 rows x 7 columns]
>>> periods_test.describe()
10 20 50 100 200 500 #998略
count 20.000000 20.000000 20.000000 20.000000 20.000000 20.000000
mean 0.346810 0.464726 0.458866 0.450155 0.467559 0.452448
std 0.398553 0.181743 0.103820 0.093663 0.036439 0.029758
min -0.444302 0.087370 0.192391 0.242112 0.412291 0.399875
25% 0.174531 0.320941 0.434744 0.375643 0.439228 0.435290
50% 0.487157 0.525217 0.476653 0.468850 0.472562 0.443772
75% 0.638685 0.616643 0.519827 0.500825 0.487389 0.465787
max 0.850963 0.709073 0.592040 0.634782 0.546001 0.513486
[8 rows x 7 columns]
从 std 这一行来看,理想的 min_periods 参数值应当为 200 左右(标准差和均值、极值最接近)。
2.4 算法检验
为了确认在 min_periods=200 下本推荐算法的靠谱程度,最好还是先做个检验。
具体方法为:在评价数大于 200 的用户中随机抽取 1000 位用户,每人随机提取一个评价另存到一个数组里,并在数据表中删除这个评价。然后基于阉割过的数据表计算被提取出的 1000 个评分的期望值,最后与真实评价数组进行相关性比较,看结果如何。
>>> check_size = 1000
>>> check = {}
>>> check_data = data.copy() #复制一份 data 用于检验,以免篡改原数据
>>> check_data = check_data.ix[check_data.count(axis=1)>200] #滤除评价数小于200的用户
>>> for user in np.random.permutation(check_data.index):
movie = np.random.permutation(check_data.ix[user].dropna().index)[0]
check[(user,movie)] = check_data.ix[user,movie]
check_data.ix[user,movie] = np.nan
check_size -= 1
if not check_size:
break
>>> corr = check_data.T.corr(min_periods=200)
>>> corr_clean = corr.dropna(how='all')
>>> corr_clean = corr_clean.dropna(axis=1,how='all') #删除全空的行和列
>>> check_ser = Series(check) #这里是被提取出来的 1000 个真实评分
>>> check_ser[:5]
(15, 593) 4
(23, 555) 3
(33, 3363) 4
(36, 2355) 5
(53, 3605) 4
dtype: float64
接下来要基于corr_clean 给 check_ser 中的 1000 个用户-影片对计算评分期望。
计算方法为:对与用户相关系数大于 0.1 的其他用户评分进行加权平均,权值为相关系数
>>> result = Series(np.nan,index=check_ser.index)
>>> for user,movie in result.index: #这个循环看着很乱,实际内容就是加权平均而已
prediction = []
if user in corr_clean.index:
corr_set = corr_clean[user][corr_clean[user]>0.1].dropna() #仅限大于 0.1 的用户
else:continue
for other in corr_set.index:
if not np.isnan(data.ix[other,movie]) and other != user:#注意bool(np.nan)==True
prediction.append((data.ix[other,movie],corr_set[other]))
if prediction:
result[(user,movie)] = sum([value*weight for value,weight in prediction])/sum([pair[1] for pair in prediction])
>>> result.dropna(inplace=True)
>>> len(result)#随机抽取的 1000 个用户中也有被 min_periods=200 刷掉的
862
>>> result[:5]
(23, 555) 3.967617
(33, 3363) 4.073205
(36, 2355) 3.903497
(53, 3605) 2.948003
(62, 1488) 2.606582
dtype: float64
>>> result.corr(check_ser.reindex(result.index))
0.436227437429696
>>> (result-check_ser.reindex(result.index)).abs().describe()#推荐期望与实际评价之差的绝对值
count 862.000000
mean 0.785337
std 0.605865
min 0.000000
25% 0.290384
50% 0.686033
75% 1.132256
max 3.629720
dtype: float64
862 的样本量能达到 0.436 的相关系数,应该说结果还不错。如果一开始没有滤掉评价数小于 200 的用户的话,那么首先在计算 corr 时会明显感觉时间变长,其次 result 中的样本量会很小,大约 200+个。但因为样本量变小的缘故,相关系数可以提升到 0.5~0.6 。
另外从期望与实际评价的差的绝对值的统计量上看,数据也比较理想。
2.5 实现推荐
在上面的检验,尤其是平均加权的部分做完后,推荐的实现就没有什么新东西了。
首先在原始未阉割的 data 数据上重做一份 corr 表:
>>> corr = data.T.corr(min_periods=200)
>>> corr_clean = corr.dropna(how='all')
>>> corr_clean = corr_clean.dropna(axis=1,how='all')
我们在 corr_clean 中随机挑选一位用户为他做一个推荐列表:
>>> lucky = np.random.permutation(corr_clean.index)[0]
>>> gift = data.ix[lucky]
>>> gift = gift[gift.isnull()] #现在 gift 是一个全空的序列
最后的任务就是填充这个 gift:
>>> corr_lucky = corr_clean[lucky].drop(lucky)#lucky 与其他用户的相关系数 Series,不包含 lucky 自身
>>> corr_lucky = corr_lucky[corr_lucky>0.1].dropna() #筛选相关系数大于 0.1 的用户
>>> for movie in gift.index: #遍历所有lucky没看过的电影
prediction = []
for other in corr_lucky.index: #遍历所有与lucky 相关系数大于 0.1 的用户
if not np.isnan(data.ix[other,movie]):
prediction.append((data.ix[other,movie],corr_clean[lucky][other]))
if prediction:
gift[movie] = sum([value*weight for value,weight in prediction])/sum([pair[1] for pair in prediction])
>>> gift.dropna().order(ascending=False) #将 gift 的非空元素按降序排列
movie_id
3245 5.000000
2930 5.000000
2830 5.000000
2569 5.000000
1795 5.000000
981 5.000000
696 5.000000
682 5.000000
666 5.000000
572 5.000000
1420 5.000000
3338 4.845331
669 4.660464
214 4.655798
3410 4.624088
...
2833 1
2777 1
2039 1
1773 1
1720 1
1692 1
1538 1
1430 1
1311 1
1164 1
843 1
660 1
634 1
591 1
56 1
Name: 3945, Length: 2991, dtype: float64