基于矩阵分解的推荐原理
矩阵分解是推荐系统里最常用的方法之一,其泛指一类算法,由最初数学中的SVD分解衍变而来,已有众多个版本,通常而言推荐系统里的矩阵分解算法指得是FunkSVD分解,可参照这里(多说一句,这位老师在NLP与推荐系统很多博客紧跟潮流,都值得一看)。关于矩阵分解算法的发展历史可以参照这里。
众多优秀博客珠玉在前,我就不详谈矩阵分解的前世今身了,下面对矩阵分解的思想做个简单地概况。通常主要用于推荐地数据为评分矩阵(Rating),此矩阵中每一行代表一个用户(User)对所有项目(Item)的评分。通常的应用场景中,用户数目会多于项目数目,这就会导致用户的评分是稀疏的,即用户只对少部分项目进行了评分,大部分项目没有评分。通常,我们把用户评了分的项目用实际分值填充,在一定场景如评分矩阵为点击矩阵时用1填充,而未评分的项目用0填充(事实上,未评分的项目可能喜欢也可能不喜欢,直接为0是存在问题的,感兴趣地可以看这里),以此得到Rating矩阵。
矩阵分解的思想非常好理解, 直观上地理解就是将Rating矩阵分解成两个矩阵的乘法,其中行数和Rating相同的矩阵为用户隐因子矩阵,每一行代表一个用户,每一列代表用户的潜在特征;列数和Rating相同的矩阵为项目隐因子矩阵,每一列代表一个项目,每一行代表项目的潜在特征。这样,假设Rating的维度是M×N的,那么就可以分解为M×K的User矩阵,K×N的Item矩阵,满足矩阵Rating和矩阵[User×Item]中的非零项尽可能相等。
基于这个原理,有文章利用公式推导实现了矩阵分解,可以参照这里。分解后乘出来的近似矩阵中非零元素可以作为模型对用户-项目评分的预测,对每一个用户按分值进行TopK排序即可形成TopK推荐。
矩阵分解与Embedding的关系
自词向量(Word2Vec)推出以来,各种嵌入(Embedding)方法层出不穷,推荐系统也有部分文章借用Embedding思想进行推荐,Embedding是一种思想,可以理解为提特征的手段,万物皆可Embedding,下面我们来引入这种思想到推荐算法里。
在NLP领域里,我们将词转化为K维度的词向量,再用词向量去做更为复杂的NLP任务,如简单的寻找相关词里,就可以直接用词向量进行相似度计算直观得到。而在推荐系统的场景里,我们有用户和项目两个主体,假如能将用户和项目嵌入到同一空间中,再计算相似性,不就直接完成了推荐目的了吗?
在矩阵分解中,我们同样也是将User和Item分开,User的每一行,代表用户的嵌入向量,Item的每一列代表项目的嵌入向量,两者都在K维空间中,而矩阵乘法的本质就是向量的点积,即User的每一行点乘Item的每一列,而点积a·b = |a||b|cosθ,不就是在计算相似度吗?
到现在你就会发现,原来矩阵分解就是Embedding的一种,二者殊途同归。那么利用神经网络的框架来实现矩阵分解也就带来了可能,整体框架如下图所示。
Keras框架介绍
Keras是一种搭建神经网络的框架,它封装得很好,简便易用,对于新手来讲还是十分友好的。它主要包括两种模型,其一为序列式模型,即一步步往后走,一条路走到黑;另一种为函数式模型,适合多输入;我们的输入包括User和Item,因此,使用的是函数式模型。
数据集简介
为了实验方便,这里采用用烂了的豆瓣电影数据集,没有的可以点击这里下载每一行的字段为:userID, movieID, rating, timestamp。
推荐算法代码实现
1. 训练集/测试集划分
首先,本数据集每个用户至少访问了20个电影,算是已预处理过的,现在要进行的就是对训练集和测试集的划分,这里有两种划分方法。
第一种,截取每个用户最后访问的K个电影做测试集,进行TopK推荐,这样做的好处在于能确保每个用户都有属于他的“标准答案”,但同时也令标准答案的个数固定,导致评价指标召回率和准确率相同。
第二种,按时间进行截取,如通常我们将80%的数据作为训练集,20%的数据作为测试集,那么就得找到时间戳5分位数的值,大于这个值的当作测试集,反之,当作训练集。这样做的好处在于理解简单,符合直觉,但某些用户会因时间原因导致没有“标准答案”,使评价指标不够客观。
对于这一方面,我自己还没有定论,说哪一种一定好,为了使评价指标的计算更好区分开来,此处选第二种进行划分。代码如下:
def split_data(rating,topk):
rating.sort_values(by=['user','time'],axis=0,inplace=True)#先按用户、再按时间排序
rating['isTest'] = 0 #增加一列,标记是否为测试数据
rating = rating.reset_index(drop = True)#重新索引
#print(rating)
timestamp = rating['time']
for i in range(1,num_user+1):
rating_ui = rating[rating['user']==i] #用户i的记录
idx = rating_ui[rating_ui['time']>=timestamp.quantile(.8)].index#按时间5分位数进行划分,若改为最后K个,使用[-topk:].index即可
for j in range(0,len(idx)):#选定的数据标记为测试集
rating.iloc[idx[j]]['isTest'] = 1
train = rating[rating['isTest']==0]
test = rating[rating['isTest']==1]
return train,test
2. Keras实现矩阵分解模型
本文最初的实现参照了这篇文章,但文中方法有人提出未加正则,且分解出来是负数。针对这两个问题,进行改进从而得到如下代码:
def Recmand_model(num_user,num_item,k):
input_uer = Input(shape=[None,],dtype="int32")
model_uer = Embedding(num_user+1,k,input_length = 1,
embeddings_regularizer=regularizers.l2(0.001), #正则,下同
embeddings_constraint=non_neg() #非负,下同
)(input_uer)
model_uer = Dense(k, activation="relu",use_bias=True)(model_uer) #激活函数
model_uer = Dropout(0.1)(model_uer) #Dropout 随机删去一些节点,防止过拟合
model_uer = Reshape((k,))(model_uer)
input_item = Input(shape=[None,],dtype="int32")
model_item = Embedding(num_item+1,k,input_length = 1,
embeddings_regularizer=regularizers.l2(0.001),
embeddings_constraint=non_neg()
)(input_item)
model_item = Dense(k, activation="relu",use_bias=True)(model_item)
model_item = Dropout(0.1)(model_item)
model_item = Reshape((k,))(model_item)
out = Dot(1)([model_uer,model_item]) #点积运算
model = Model(inputs=[input_uer,input_item], outputs=out)
model.compile(loss= 'mse', optimizer='Adam')
model.summary()
return model
关于非负,指的是分解后的两个矩阵每个值都要非负,实现起来比较简单,Embedding层刚好有进行约束的参数,但思想上还存在一定模糊,电影评分是处于[1-5]的,预测评分为什么一定要非负?负数是否可以代表该用户不喜欢该项目?由于推荐结果实际上只与分值的大小排序有关,非负还是否一定更好?
关于正则,最初的想法是在损失函数那里改,但发现若自己定义loss,参数只有y_pred而没有两个潜在向量,后来仔细一想,在Embedding层的正则,不就刚好是对两种嵌入向量的正则嘛!于是,利用Embedding层的正则加进去。
除此以外,还尝试了神经网络的一些trick,加了一层激活函数,增强模型的非线性性,并使用了偏置和dropout防止过拟合。
3. 模型的训练
模型的构建有三个参数,用户数、项目数和嵌入向量的维度,而模型的输入为训练集的用户记录数据、项目记录数据和真实评分。batch_size是批处理参数,epochs是模型的迭代次数,h5为HDF5文件格式。
def train(train_data):
model = Recmand_model(num_user,num_item,100)
train_user = train_data['user'].values
train_item = train_data['item'].values
train_x = [train_user,train_item]
train_y = train_data['score'].values
model.fit(train_x,train_y,batch_size = 100,epochs =10)
model.save("model.h5")
算法测试与评价
1. 评价指标
本文计算了推荐系统常用的性能评价指标:RMSE、MAE、PRE、REC、MAP、NDCG、MRR。大部分评价指标都是分类或信息检索领域演化而来的。RMSE与MAE是对预测分值的评价指标,如若只是对最终的推荐结果进行评价则无需此项。剩下的评价中MAP与NDCG比较难理解,且大多数的解释都是用信息检索那一套解释的,MAP可以看这里,NDCG可以看这里,其他的可以看这里。
通常而言,比较重要的指标是PRE、REC、MAP,其中MAP既要求命中,又要求顺序与原顺序尽量相等,所以往往较低。
2. 评价指标的实现
评价指标往往通过计算单个用户的值再用所有用户求平均,因此单独写了个求每个用户各项指标值的函数如下:
def cal_indicators(rankedlist, testlist,test_score):
hits = 0
sum_precs = 0
AP_u = 0
PRE_u = 0
REC_u = 0
NDCG_u = 0
MRR_u = 0
ranked_score = []
for n in range(len(rankedlist)):
if rankedlist[n] in testlist:
hits += 1
sum_precs += hits / (n + 1.0)
ranked_score.append(test_score[testlist.index(rankedlist[n])])
if MRR_u == 0:
MRR_u = float(1/(testlist.index(rankedlist[n])+1)) #测试集用的是时间序而非评分序
else:
ranked_score.append(0)
if hits > 0:
AP_u = sum_precs/len(testlist)
PRE_u = float(hits/len(rankedlist))
REC_u = float(hits/len(testlist))
DCG_u = cal_DCG(ranked_score)
IDCG_u = cal_DCG(sorted(test_score)[0:len(rankedlist)])
NDCG_u = DCG_u/IDCG_u
return AP_u,PRE_u,REC_u,NDCG_u,MRR_u
为了计算NDCG方便,将DCG的计算公式也单独做成函数
def cal_DCG(rec_list):
s = 0
for i in range(0,len(rec_list)):
s = s + (math.pow(2,rec_list[i])-1)/math.log2((i+1)+1)
return s
3. 模型的读取与测试
参数中的all_user,all_item分别为用户集合和项目集合。在整个测试中,要注意,尽管TopK推荐结果是依据模型的打分,但真正在推荐时,要剔除用户曾经访问过的项目,推荐往往是挖掘用户更深层次的需求而不是一味地推荐给他访问过的项目,这是推荐和预测的区别。
def test(train_data,test_data,all_user,all_item,topk):
model = load_model('model.h5')
RMSE = 0
MAE = 0
PRE = 0
REC = 0
MAP = 0
NDCG = 0
MRR = 0
for i in range(0,len(all_user)):
visited_item = list(train_data[train_data['user']==all_user[i]]['item'])
# print(visited_item)
testlist = list(test_data[test_data['user']==all_user[i]]['item'])
rat_k = list(test_data[test_data['user']==all_user[i]]['score']) #项目的评分
p_rating = [] #总的预测评分
rankedlist = [] #项目推荐列表
for j in range(0,len(all_item)): #让每个用户给所有项目打分
p_rating.append(float(model.predict([[all_user[i]],[all_item[j]]])))
MAE = MAE + sum([abs(rat_k[s]-p_rating[s]) for s in range(len(testlist))])
RMSE = RMSE + sum([(rat_k[s]-p_rating[s])*(rat_k[s]-p_rating[s]) for s in range(len(testlist))])
k = 0
while k < topk:#取前topK个
idx = p_rating.index(max(p_rating))
if all_item[idx] in visited_item: #排除掉访问过的
p_rating[idx] = 0
continue
rankedlist.append(all_item[idx])
p_rating[idx] = 0
k = k + 1
print("对用户",all_user[i])
print("Topk推荐:",rankedlist)
print("实际访问:",testlist)
AP_u,PRE_u,REC_u,NDCG_u,MRR_u = cal_indicators(rankedlist, testlist,rat_k)
PRE = PRE + PRE_u
REC = REC + REC_u
MAP = MAP + AP_u
NDCG = NDCG + NDCG_u
MRR = MRR + MRR_u
print('--------')
print('评价指标如下:')
RMSE = math.sqrt(RMSE/float(len(test_data)))
MAE = MAE/float(len(test_data))
PRE = PRE/len(all_user)
REC = REC/len(all_user)
MAP = MAP/len(all_user)
NDCG = NDCG/len(all_user)
MRR = MRR/len(all_user)
print('RMSE:',RMSE)
print('MAE:',MAE)
print('PRE@',topk,':',PRE)
print('REC@',topk,':',REC)
print('MAP@',topk,':',MAP)
print('NDCG@',topk,':',NDCG)
print('MRR@',topk,':',MRR)
4.主程序
此次实验做Top10推荐,且与这篇文章效果进行对比。
if __name__ == '__main__':
rating = pd.read_csv('movie.txt',header = None,sep = '\t',names = ['user','item','score','time'])
all_user = np.unique(rating['user'])
num_user = len(all_user)
all_item = np.unique(rating['item'])
num_item = len(np.unique(rating['item']))
num_record = len(rating)
topk = 10
# print("用户数为:",num_user,"项目数为:",num_item,"记录数为:",num_record)
# filling_rate = num_record/(num_user*num_item)
# print("填充率为:",filling_rate)
train_data,test_data = split_data(rating,topk) #分割训练集、测试集
train(train_data)
test(train_data,test_data,all_user,all_item,topk)
实验结果分析
原文方法实验结果:
本文方法实验结果:
从实验结果上来看,加入正则、激活函数、非负约束、偏置、dropout等等之后,还是有一定提高的,用神经网络的架构实现矩阵分解,一方面成熟的框架能提高运行速度,另一方面,更多的Embedding和深度学习技巧可以运用进来,给传统的矩阵分解创造更多可能!
完整代码可看我的GitHub,点击这里。
后期问题
在后期做更丰富的实验的时候出现了一些问题(但源程序运行时是没产生的),会陆续在这里更新、总结一下,为日后警醒自己,就不直接去更改了。
DataFrame 赋值问题
在split_data()的第11行,为了给某行某列具体的项赋值时,使用了这行代码:
rating.iloc[idx[j]]['isTest'] = 1
在做扩展实验时,出现了一个Warnning,而且这个Warnning导致赋值不成功:
A value is trying to be set on a copy of a slice from a DataFrame
查了下解决方法,使用 DafaFrameming.loc[行名, 列名] = 值 的方式去赋值, 而不是使用DataFrame[][]的形式去赋值。改为如下语句即可:
rating.loc[idx[j],'isTest'] = 1
对此的解释可以参照这里。
参数传递问题
还是以split_data()为例,在第7行代码中用到了变量num_user,然而此变量是在main函数里定义的,在函数里也没有作为参数传递过来,又不是全局变量。本以为自己写错了,内存没清干净才没报错,结果重启编译器,一输出还能输出出来,值也没问题,就有点迷惑python变量的命名空间了,因为没有产生问题,就没有改,但还是希望自己能理解。
效率问题
矩阵分解其实效率不高,此文中所用数据集较小所以没出啥毛病,但在较大(唯一用户数量或唯一项目数量)数据集上,要么显示资源耗尽,要么就训练时长无法接受。此时还可以调的参数是batch_size和嵌入维度k,同样影响有限,这是方法本身的局限。
性能分析
从推荐列表结果来看,矩阵分解给大部分用户推荐的项目“几乎相同”,矩阵分解的推荐可能更着重于用户群体整体性偏好 (如微博热搜、头条等),个性化方面不足。
实验中推荐对象问题
这里同训练集、测试集的划分中指出的问题类似,如果采用按时间(或记录条数)划分数据集,那么必然会有一些用户在测试集中未访问任何项目,此时相当于没有正确答案。如果将其计入评价指标(即也算一次推荐),则出来的评价结果实际上会偏低。因此,实验中推荐的对象应为测试集中出现过的用户,确保每个用户至少有一个标准答案。
反映在代码中即将test()函数中的all_user进行如下赋值:
all_user = np.unique(test_data['user'])
有效性验证
在更复杂的数据集实践中,发现此方法实现的MF效果并不好,症状表现在给大多数用户推荐相同的项目。为了验证这篇文章提出的基于神经网络的矩阵分解方法(简称NMF),和传统梯度下降实现的Basic矩阵分解方法(简称BMF),特加一个小实验进行验证。
验证过程不专门使用数据集,而是随便用一个小型矩阵R,验证指标设为e,其代表R中每一个非零项,与MF后矩阵nR对应项的差值平方的累加和。
R是随便设的,这里展示的R如下图所示:
先来看下NMF的有效性验证,下图为维度d设为5,迭代次数设为1000次的运行结果:
可以看到nR中原始矩阵的非零值很接近真实值了,说明至少矩阵分解的效果是达到了。
再看看NMF和BMF对比实验的结果,先上迭代次数为500,嵌入维度设为5,比较100次的结果:
运行 100 次 NMF获胜次数: 2 BMF获胜次数: 98
NMF最小e: 50.98786165386436 NMF最大e: 52.702692984687744
BMF最小e: 7.8951470117335205 BMF最大e: 65.59012435806488
再上迭代次数为1000时,嵌入维度设为5,比较100次的结果:
运行 100 次 NMF获胜次数: 65 BMF获胜次数: 35
NMF最小e: 6.627555767762395 NMF最大e: 25.706345176698093
BMF最小e: 3.503323177984534 BMF最大e: 29.463017445662206
从实验结果看,NMF相比于BMF在迭代次数达到一定值后还是有优势的,当然,这个e值只能简单地反馈出一定信息,只能保证更接近原始值,空缺值是否更接近真实值还得专门从已有的数据集按评价指标进行分析。做完这个测试后,觉得问题可能还是出现在迭代次数上,将继续改进自己的复杂实验。
附测试代码如下:
# -*- coding: utf-8 -*-
"""
Created on Fri Oct 18 15:08:00 2019
@author: YLC
"""
import os
import numpy as np
import pandas as pd
import time
import math
from keras import Model
import keras.backend as K
from keras.layers import Embedding,Reshape,Input,Dot,Dense,Dropout,concatenate
from keras.models import load_model
from keras.utils import to_categorical
from keras import regularizers
from keras.constraints import non_neg
def Recmand_model(num_user,num_item,d):
K.clear_session()
input_uer = Input(shape=[None,],dtype="int32")
model_uer = Embedding(num_user+1,d,input_length = 1,
embeddings_regularizer=regularizers.l2(0.001), #正则,下同
embeddings_constraint=non_neg() #非负,下同
)(input_uer)
# model_uer = Dense(d, activation="relu",use_bias=True)(model_uer) #激活函数
# model_uer = Dropout(0.1)(model_uer) #Dropout 随机删去一些节点,防止过拟合
model_uer = Reshape((d,))(model_uer)
input_item = Input(shape=[None,],dtype="int32")
model_item = Embedding(num_item+1,d,input_length = 1,
embeddings_regularizer=regularizers.l2(0.001),
embeddings_constraint=non_neg()
)(input_item)
# model_item = Dense(d, activation="relu",use_bias=True)(model_item)
# model_item = Dropout(0.1)(model_item)
model_item = Reshape((d,))(model_item)
out = Dot(1)([model_uer,model_item]) #点积运算
model = Model(inputs=[input_uer,input_item], outputs=out)
model.compile(loss= 'mse', optimizer='Adam')
model.summary()
return model
def train(num_user,num_item,train_data,d,step):
model = Recmand_model(num_user,num_item,d)
train_user = train_data[:,0]
train_item = train_data[:,1]
train_x = [train_user,train_item]
train_y = train_data[:,2]
model.fit(train_x,train_y,batch_size = 4,epochs = step)
model.save("./MFmodel.h5")
def test(num_user,num_item,R):
model = load_model('./MFmodel.h5')
nR = np.zeros([num_user,num_item])
for i in range(num_user):
for j in range(num_item):
nR[i][j] = model.predict([[i],[j]])
return nR
def cal_e(R,nR):
e = 0
for i in range(len(R)):
for j in range(len(R[0])):
if(R[i][j]!=0):
e = e + math.pow(R[i][j]-nR[i][j],2)
return e
def RtransT(R):
user = [u for u in range(len(R))]
item = [i for i in range(len(R[0]))]
Table = []
for i in user:
for j in item:
if R[i][j]!= 0:
Table.append([i,j,R[i][j]])
Table = np.array(Table)
return Table
def matrix_factorization(R,P,Q,K,steps=500,alpha=0.0002,beta=0.02):
Q=Q.T
for step in range(steps):
for i in range(len(R)):
for j in range(len(R[i])):
if R[i][j]>0:
eij=R[i][j]-np.dot(P[i,:],Q[:,j])
for k in range(K):
P[i][k]=P[i][k]+alpha*(2* eij * Q[k][j]- beta *P[i][k])
Q[k][j]=Q[k][j]+alpha*(2* eij * P[i][k]- beta *Q[k][j])
eR=np.dot(P,Q)
e=0
for i in range(len(R)):
for j in range(len(R[i])):
if R[i][j]>0:
e=e+pow(R[i][j]-np.dot(P[i,:],Q[:,j]),2)
for k in range(K):
e=e+(beta/2)*(pow(P[i][k],2)+pow(Q[k][j],2))
if e<0.001:
break
return P,Q.T,e
def NMF(R,d,step):
T = RtransT(R)
M=len(R)
N=len(R[0])
train(M,N,T,d,step)
nR = test(M,N,R)
e = cal_e(R,nR)
print(nR)
print(e)
return e
def BMF(R,K,step):
M=len(R)
N=len(R[0])
P=np.random.uniform(low=0,high=5,size=[M,K])
Q=np.random.uniform(low=0,high=5,size=[N,K])
#P=np.random.rand(M,K)
#Q=np.random.rand(N,K)
nP,nQ,_= matrix_factorization(R,P,Q,K,steps=step)
nR=np.dot(nP,nQ.T)
print(nR)
e = cal_e(R,nR)
print(e)
return e
def eval_NB(R,test_cnt,emd_cnt,step):
Ne = []
Be = []
Nwin = 0
Bwin = 0
for i in range(test_cnt):
ne = NMF(R,emd_cnt,step)
be = BMF(R,emd_cnt,step)
Ne.append(ne)
Be.append(be)
if(ne<be):
Nwin = Nwin + 1
else:
Bwin = Bwin + 1
print("运行",test_cnt,'次',"NMF获胜次数:",Nwin,"BMF获胜次数:",Bwin)
print("NMF最小e:",min(Ne),"NMF最大e:",max(Ne))
print("BMF最小e:",min(Be),"BMF最大e:",max(Be))
with open('./result_.txt','w') as f:
f.write("运行"+str(test_cnt)+'次'+"NMF获胜次数:"+str(Nwin)+"BMF获胜次数:"+str(Bwin)+'\n')
f.write("NMF最小e:"+str(min(Ne))+"NMF最大e:"+str(max(Ne)))
f.write("BMF最小e:"+str(min(Be))+"BMF最大e:"+str(max(Be)))
f.close()
if __name__ == '__main__':
R=[
[5,3,0,1,2,4,5,2,1,2,5,0],
[4,0,0,1,1,2,0,0,0,0,3,1],
[1,1,0,5,2,4,5,1,2,3,0,0],
[1,0,0,4,5,1,2,0,0,0,0,2],
[0,1,5,4,2,3,0,0,0,2,1,2]
]
R=np.array(R)
dimension = 5
step = 1000
# NMF(R,dimension,step)
# BMF(R,dimension,step)
eval_NB(R,100,dimension,step)
过拟合
经过后续反复尝试,发现在复杂数据集上对每个用户推荐相同项目的原因是过拟合。因为做的是隐式反馈,用户访问过就是1,未访问过就是0,用NMF进行训练时,loss确实很小,但观测预测的矩阵nR发现,矩阵所有位置都是0.999+,接近1,而loss是算原先不为0的地方,因此,loss是小了,但模型根本没用。
通过实验发现神经网络虽然与部分方法理论相同,但实际中会遇到各式各样的问题,不能盲目迷信,理智地掌握训练时地“trick”,或许才能更近一步吧。
求助
问题描述:在使用传统矩阵分解方法进行推荐时发现,迭代次数越多(loss越小),准确率越低,并且一直呈现单调变化,准确率最高是在迭代1次的时候,PRE、REC能达到0.5几,但loss却很大。LOSS确实在下降说明梯度下降的方向没有错,而结果表明越接近原始非零值,效果越不好,这是为什么?问题可能出现在哪里?