现在的位置: 首页 > 综合 > 正文

SVD在推荐系统中的应用与实现(c++)

2019年08月13日 ⁄ 综合 ⁄ 共 4672字 ⁄ 字号 评论关闭

主要参考论文《A Guide to Singular Value Decomp osition for Collab orative Filtering》

其实一开始是比较疑惑的,因为一开始没有查看论文,只是网上搜了一下svd的概念和用法,搜到的很多都是如下的公式:其中假设C是m*n的话,那么可以得到三个分解后的矩阵,分别为m*r,r*r,r*n,这样的话就可以大大降低存储代价,但是这里特别需要注意的是:这个概念一开始是用于信息检索方面的,它的C矩阵式完整的,故他们可以直接把这个矩阵应用svd分解,但是在推荐系统中,用户和物品的那个评分矩阵是不完整的,是个稀疏矩阵,故不能直接分解成上述样子,也有的文章说把空缺的补成平均值等方法,但效果都不咋滴。。。在看了上述论文之后,才明白,在协同过滤中应用svd,其实是个最优化问题,我们假设用户和物品之间没有直接关系,但是定义了一个维度,称为feature,feature是用来刻画特征的,比如描述这个电影是喜剧还是悲剧,是动作片还是爱情片,而用户和feature之间是有关系的,比如某个用户必选看爱情片,另外一个用户喜欢看动作片,物品和feature之间也是有关系的,比如某个电影是喜剧,某个电影是悲剧,那么通过和feature之间的联系,我们可以把一个评分矩阵rating
= m*n(m代表用户数,n代表物品数)分解成两个矩阵的相乘:user_feature*T(item_feature), T()表示转置,其中user_feature是m*k的(k是feature维度,可以随便定),item_feature是n*k的,那么我们要做的就是求出这两者矩阵中的值,使得两矩阵相乘的结果和原来的评分矩阵越接近越好。

这里所谓的越接近越好,指的是期望越小越好,期望的式子如下:

其中n表示用户数目,m表示物品数目,I[i][j]是用来表示用户i有没有对物品j评过分,因为我们只需要评过分的那些越接近越好,没评过的就不需要考虑,Vij表示训练数据中给出的评分,也就是实际评分,p(Ui,Mj)表示我们对用户i对物品j的评分的预测,结果根据两向量点乘得到,两面的两项主要是为了防止过拟合,之所以都加了系数1/2是为了等会求导方便。

这里我们的目标是使得期望E越小越好,其实就是个期望最小的问题,故我们可以用随机梯度下降来实现。随机梯度下降说到底就是个求导问题,处于某个点的时候,在这个点上进行求导,然后往梯度最大的反方向走,就能快速走到局部最小值。故我们对上述式子求导后得:

所以其实这个算法的流程就是如下过程:

实现起来还是比较方便快捷的,这里rmse是用来评测效果的,后面会再讲。

上述算法其实被称为批处理式学习算法,之所以叫批处理是因为它的期望是计算整个矩阵的期望(so big batch),其实还存在增量式学习算法,批处理和增量式的区别就在于前者计算期望是计算整个矩阵的,后者只计算矩阵中的一行或者一个点的期望,其中计算一行的期望被称为不完全增量式学习,计算一个点的期望被称为完全增量式学习。

不完全增量式学习期望如下(针对矩阵中的一行的期望,也就是针对一个用户i的期望):

那么求导后的式子如下:

算法的大致思想如下:

完全增量式学习算法是对每一个评分进行期望计算,期望如下:

求导后如下:

所以整个算法流程是这样的:

上述都是svd的变种,只不过实现方式不一样,根据论文所说,其中第三种完全增量式学习算法效果最好,收敛速度非常快。

当然还有更优的变种,考虑了每个用户,每个物品的bias,这里所谓的bias就是每个人的偏差,比如一个电影a,b两人都认为不错,但是a评分方便比较保守,不错给3分,b评分比较宽松,不错给4分,故一下的评分方式考虑到了每个用户,每个物品的bias,要比上述算法更加精准。原来评分的话是直接计算user_feature*T(item_feature), T()表示转置,但现在要考虑各种bias,如下:

其中a表示所有评分的平均数,ai表示用户i的bias,Bj表示物品j的偏差,相乘的矩阵还是和上面一样的意思。

故这时候的期望式子和求导的式子如下(这里只写了bias的求导,矩阵求导还是和上面一样):

当然了,光说不练假把式,我们选择了最后一种算法,及考虑bias的算法来实现了一把,数据源是来自movielens的100k的数据,其中包含了1000个用户对2000件物品的评分(当然,我这里是直接开的数组,要是数据量再大的话,就不这么实现了,主要是为了验证一把梯度下降的效果),用其中的base数据集来训练模型,用test数据集来测试数据,效果评测用一下式子来衡量:

说白了就是误差平方和。。。同时我们也记录下每一次迭代后训练数据集的rmse. 

代码如下:

#include <iostream>
#include <string>
#include <fstream>
#include <math.h>
using namespace std;
const int USERMAX = 1000;
const int ITEMMAX = 2000;
const int FEATURE = 50;
const int ITERMAX = 20;
double rating[USERMAX][ITEMMAX];
int I[USERMAX][ITEMMAX];//indicate if the item is rated
double UserF[USERMAX][FEATURE];
double ItemF[ITEMMAX][FEATURE];
double BIASU[USERMAX];
double BIASI[ITEMMAX];
double lamda = 0.15;
double gamma = 0.04;
double mean;

double predict(int i, int j)
{
	double rate = mean + BIASU[i] + BIASI[j];
	for (int f = 0; f < FEATURE; f++)
		rate += UserF[i][f] * ItemF[j][f];
	
	if (rate < 1)
		rate = 1;
	else if (rate>5)
		rate = 5;
	return rate;
}

double calRMSE()
{
	int cnt = 0;
	double total = 0;
	for (int i = 0; i < USERMAX; i++)
	{
		for (int j = 0; j < ITEMMAX; j++)
		{
			double rate = predict(i, j);
			total += I[i][j] * (rating[i][j] - rate)*(rating[i][j] - rate);
			cnt += I[i][j];
		}
	}
	double rmse = pow(total / cnt, 0.5);
	return rmse;
}
double calMean()
{
	double total = 0;
	int cnt = 0;
	for (int i = 0; i < USERMAX; i++)
		for (int j = 0; j < ITEMMAX; j++)
		{
			total += I[i][j] * rating[i][j];
			cnt += I[i][j];
		}
	return total / cnt;
}
void initBias()
{
	memset(BIASU, 0, sizeof(BIASU));
	memset(BIASI, 0, sizeof(BIASI));
	mean = calMean();
	for (int i = 0; i < USERMAX; i++)
	{
		double total = 0;
		int cnt = 0;
		for (int j = 0; j < ITEMMAX; j++)
		{
			if (I[i][j])
			{
				total += rating[i][j] - mean;
				cnt++;
			}
		}
		if (cnt > 0)
			BIASU[i] = total / (cnt);
		else
			BIASU[i] = 0;
	}
	for (int j = 0; j < ITEMMAX; j++)
	{
		double total = 0;
		int cnt = 0;
		for (int i = 0; i < USERMAX; i++)
		{
			if (I[i][j])
			{
				total += rating[i][j] - mean;
				cnt++;
			}
		}
		if (cnt > 0)
			BIASI[j] = total / (cnt);
		else
			BIASI[j] = 0;
	}
}
void train()
{
	//read rating matrix
	memset(rating, 0, sizeof(rating));
	memset(I, 0, sizeof(I));
	ifstream in("ua.base");
	if (!in)
	{
		cout << "file not exist" << endl;
		exit(1);
	}
	int userId, itemId, rate;
	string timeStamp;
	while (in >> userId >> itemId >> rate >> timeStamp)
	{
		rating[userId][itemId] = rate;
		I[userId][itemId] = 1;
	}
	initBias();

	//train matrix decomposation
	for (int i = 0; i < USERMAX; i++)
		for (int f = 0; f < FEATURE; f++)
			UserF[i][f] = (rand() % 10)/10.0 ;
	for (int j = 0; j < ITEMMAX; j++)
		for (int f = 0; f < FEATURE; f++)
			ItemF[j][f] = (rand() % 10)/10.0 ;

	int iterCnt = 0;
	while (iterCnt < ITERMAX)
	{
		for (int i = 0; i < USERMAX; i++)
		{

			for (int j = 0; j < ITEMMAX; j++)
			{
				if (I[i][j])
				{

					double predictRate = predict(i, j);
					double eui = rating[i][j] - predictRate;
					BIASU[i] += gamma*(eui - lamda*BIASU[i]);
					BIASI[j] += gamma*(eui - lamda*BIASI[j]);
					for (int f = 0; f < FEATURE; f++)
					{
						UserF[i][f] += gamma*(eui*ItemF[j][f] - lamda*UserF[i][f]);
						ItemF[j][f] += gamma*(eui*UserF[i][f] - lamda*ItemF[j][f]);
					}				
				}

			}

		}
		double rmse = calRMSE();
		cout << "Loop " << iterCnt << " : rmse is " << rmse << endl;
		iterCnt++;
	}

}

void test()
{
	ifstream in("ua.test");
	if (!in)
	{
		cout << "file not exist" << endl;
		exit(1);
	}
	int userId, itemId, rate;
	string timeStamp;
	double total = 0;
	double cnt = 0;
	while (in >> userId >> itemId >> rate >> timeStamp)
	{
		double r = predict(userId, itemId);
		total += (r - rate)*(r - rate);
		cnt += 1;
	}
	cout << "test rmse is " << pow(total / cnt, 0.5) << endl;
}
int main()
{
	train();
	test();
	return 0;
}

效果图如下:

可以看到,rmse能非常快的收敛,训练数据中的rmse能很快收敛到0.8左右,然后拿测试集的数据去测试,rmse为0.949,也是蛮不错的预测结果了,当然这里可以调各种参数来获得更优的实验结果。。。就是所谓的黑科技?。。。:)

抱歉!评论已关闭.