Funk-SVD实现的矩阵分解

一、算法简介

1.1 基本特性

        所谓的矩阵分解其实就是将一个矩阵分解为两个或者多个低维矩阵的,这两个低维矩阵能够代表原矩阵特性并且预测原矩阵中未知的特性——在推荐系统矩阵中的描述就是:通过评估低维矩阵乘积来拟合评分矩阵。

        沿用之前图,面对一个有m个用户与n个项目的稀疏的矩阵R_{m\times n},第i行表示第i个用户对于每个项目的评分,图中的问号部分表示这部分没有具体的评分;第j列表示某个项目不同用户给予的评分状况。

 现在假设将此矩阵分解为两个或者多个矩阵的乘积,这里假设有两个低维矩阵P_{m\times k}Q_{k\times n},存在R_{m\times n}=P_{m\times k}Q_{k\times n}(这里为了方便,我们将矩阵R中的那些未评分项目默认写为0)

这里采用的技术是 奇异值分解(Singular Value Decomposition,SVD)这个算法在降维、数据压缩、推荐系统中都有广泛的应用。但是!今天使用的SVD是一种基于机器学习的“ 伪 ”SVD,因为SVD本身是要求将原矩阵分解为可取的两个矩阵,但是这里的SVD我们是做近似分解(所以上图我用的约等于),然后通过拟合让分解得到的矩阵的部分元素不断接近原矩阵的有效元素,最终使得原矩阵的无效元素(0元素)也能被拟合预测出来。这种SVD最早被Simon Funk在博客中发布,因此又叫做Funk-SVD。

       具体的思路是先随机构建矩阵P与Q,然后通过矩阵P×Q取得的每个数据同主要矩阵R的有效值进行差值计算,并通过得到的差值逐步调整P与Q的每个向量,如此反复操作直到这样的差值达到足够小的程度,或者说执行调整的次数满足某个上限后(比如MAE或者RSME在某个可接受的范围)

        可以发现,在P矩阵中可以取出m个k维向量,在Q矩阵中可以取出n个k维向量。这俩矩阵所具有的这(m+n)个向量可以唯一表示R矩阵中的某个用户或者项目情况,而m×n相互交叉可以得到对于某个用户-项目的评分的估计。于是不妨将那m个向量命名为用户向量,其余n个为项目向量。然后下面继续为这个定义建立数学模型:

        取出稀疏矩阵中的实值定义为一个三元组数组,正如我们数据集的txt文本所示这样:

0,0,5
0,1,3
0,2,4
0,3,3
0,4,3
0,5,5
0,6,4
0,7,1
0,8,5
0,9,3
0,10,2
0,11,5
0,12,5
0,13,5
...

 分别表示:{用户,项目,评分},这里的评分是确定的实值,不能为0(因为我们定义0为无实际评分),有集合S={(u,v)|r_{uv}≠∅},这里u表示用户下标,v为项目下标,需要注意,集合S拥有的组合数目是要小于 用户数 * 项目数,集合S中的组合(u,v)也并非全部u取值与v取值的枚举。

通过矩阵分解将用户向量与项目向量定义为:


        因此不难得出对于用户u与物品v的预测评分\hat{r}_{uv}=\vec{p}_{u}\vec{q}_{v}。这里要注意一个细节,\vec{p}_{u}是面向矩阵P的向量,\vec{q}_{v}是面向Q的向量,而这俩矩阵本身是基于随机值生成的,因此得到的某个预测评分\hat{r}_{uv}有可能在原R矩阵中\hat{r}_{uv}=0,这个细节正是奇异值分解具有预测能力的原理。所以有等式R_{m\times n}\approx P_{m\times k}\times Q_{k\times n}=\hat{R}_{m\times n},而算法正是通过\hat{R}_{m\times n}预测R_{m\times n},预测准确度由r_{uv}=0|\hat{r}_{uv}-r_{uv}|的大小保证。

        那么这些向量是否具有一些实际含义呢,一种可以用来解释的含义就是:向量kk维可以表示这个向量的语义对于kk个隐含因子的重视程度。例如\vec{p}_{u}是面向用户的向量,假设k=3有\vec{p}_{u}=(u_{1},u_{2},u_{3}),这里的u1可以是用户觉得的电影情节是否精彩对于她来说的重要性,u2可以是演员演技水平对于他来说的重要性......而对于\vec{q}_{v},我们假设是电影,那么这里就可以是电影本身的精彩程度、电影演员水平......而得到\hat{r}_{uv}正式用户自己认为的重要性同电影本身这个属性的占比作乘积,然后各属性加权的结果。本身各维度在逻辑上是可以自由扩展的隐因子,不必解释也性,这里用语义去解释能更加方便理解吧。

        另一个问题,特征空间k的大小应该设置为多大合适呢,这个并没有非常确定的设置,其本身也没有非常好的解释性,常常称之为" 隐因子向量 ",只用知道其存在即可,是用户向量\vec{p}_{u}与项目向量\vec{q}_{v}的基本维度。

二、损失函数与梯度下降(Gradient descent)

2.1 损失函数

 确定了平方损失函数之后,沿着损失的方向利用梯度下降方法进行求解。

 简单来说,其实梯度下降法就是让变量沿着目标函数的负梯度方向进行下降直到局部最低点,因为我们当前这个问题本身就是求取损失函数的最小值,因此只要对损失函数求偏导然而确定一个两个向量(所代表P与Q向量)梯度下降的迭代式{\vec{p}}'_{u}{\vec{q}}'_{v}即可.

 

就如同上图,从x0得到逆梯度方向(正向)的变量位移移动x1,但在x1的到逆梯度方向(负向)的位移移动到x2吗,如此反复直到一个稳定的局部最小。当然本文的案例中抽象出来是二维的,而并非此处一维的,此图于此只为说明此方法罢了。

         首先对于平方损失函数分别求向量\vec{p}_{u}\vec{q}_{v}方向的偏导,从而得到损失函数的负梯度:

  然后依据负梯度方向更新P与Q的每个向量,同时自定义一个极小的参数α>0,这样便得到了基础的调节函数:

 

 这里的α其实有步长之功效,即是学习速率,也是梯度向量,需要调参确定。不同的步长对于地图下降的方式都会存在影响,当步长过小其下降速度会过慢:

                                               当α过小

合适时,很快就能抵达谷底:

  当这个值为1的时候,会在两侧同等移动,陷入僵局:

  当这个值大于1时,反而会想希望到达的最小值的反方向走

 2.3 加入正则项的损失函数

 还有一种损失函数的评判标准,就是引入正则项||\vec{p}_{u}||^{2}+||\vec{q}_{v}||^{2}来进一步丰富损失函数的含义。之前的平方误差可以权衡模型的欠拟合程度,保证系统对于训练集的基本学习程度。而正则项就如同方差,能权衡模型的稳定性,如此一来:一旦损失函数ff高也预示模型不稳定、对非训练集数据可能表现笨拙、过拟合。一般来说,在Rn×m过于稀疏时会出现这种过拟合情况。

        至于使用二范数限制而不是一范数限制似乎也是有一定说法(二范数:向量全维度的平方和,然后开根号;一范数:向量全维度绝对值和),一范数会使得很多因子为0,削减模型大小,而二范数值会令因子接近0而不是为0。

        最终有损失函数:

 

  其中映入了控制参数λ/2,继续,模仿之前的梯度下降的方式,对于f2求偏导可得损失函数的负梯度:

然后引入α,构造原向量按照负梯度方向进行更新的调整函数:

 

  如此便得到携带正则式的求解迭代式,这个迭代过程引入了方差的限制,控制了最终的P与Q矩阵预测的结果不会过拟合。

2.4 算法思路总结

1.确定k值之后,针对用户-项目矩阵R_{m\times n}生成矩阵P_{m\times k}Q_{k\times n},其中生成内容为随机数。
2.计算矩阵R_{m\times n}中的实际取值r_{uv}P_{m\times k}Q_{k\times n}中得到的预测值\hat{r}_{uv}的差值
3.通过第二步取得的差值,利用公式4、5或者公式进行9、10去调整P矩阵的n个向量\vec{p}_{u}与Q矩阵的m个向量\vec{q}_{v},得到全新的矩阵P与Q
4.重复2、3两步,直到最终MAE或者RSME收敛于一个预定义可靠的范围内。或者单纯循环若干次即可

完整代码:

package machinelearning.recommendersystem;

import java.io.*;
import java.util.Random;

/*
 * Matrix factorization for recommender systems.
 * 
 * @author Rui Chen 1369097405@qq.com.
 */

public class MatrixFactorization {
	/**
	 * Used to generate random numbers.
	 */
	Random rand = new Random();

	/**
	 * Number of users.
	 */
	int numUsers;

	/**
	 * Number of items.
	 */
	int numItems;

	/**
	 * Number of ratings.
	 */
	int numRatings;

	/**
	 * Training data.
	 */
	Triple[] dataset;

	/**
	 * A parameter for controlling learning regular.
	 */
	double alpha;

	/**
	 * A parameter for controlling the learning speed.
	 */
	double lambda;

	/**
	 * The low rank of the small matrices.
	 */
	int rank;

	/**
	 * The user matrix U.
	 */
	double[][] userSubspace;

	/**
	 * The item matrix V.
	 */
	double[][] itemSubspace;

	/**
	 * The lower bound of the rating value.
	 */
	double ratingLowerBound;

	/**
	 * The upper bound of the rating value.
	 */
	double ratingUpperBound;

	/**
	 ************************ 
	 * The first constructor.
	 * 
	 * @param paraFilename
	 *            The data filename.
	 * @param paraNumUsers
	 *            The number of users.
	 * @param paraNumItems
	 *            The number of items.
	 * @param paraNumRatings
	 *            The number of ratings.
	 ************************ 
	 */
	public MatrixFactorization(String paraFilename, int paraNumUsers, int paraNumItems,
			int paraNumRatings, double paraRatingLowerBound, double paraRatingUpperBound) {
		numUsers = paraNumUsers;
		numItems = paraNumItems;
		numRatings = paraNumRatings;
		ratingLowerBound = paraRatingLowerBound;
		ratingUpperBound = paraRatingUpperBound;

		try {
			readData(paraFilename, paraNumUsers, paraNumItems, paraNumRatings);
			// adjustUsingMeanRating();
		} catch (Exception ee) {
			System.out.println("File " + paraFilename + " cannot be read! " + ee);
			System.exit(0);
		} // Of try
	}// Of the first constructor

	/**
	 ************************ 
	 * Set parameters.
	 * 
	 * @param paraRank
	 *            The given rank.
	 * @throws IOException
	 ************************ 
	 */
	public void setParameters(int paraRank, double paraAlpha, double paraLambda) {
		rank = paraRank;
		alpha = paraAlpha;
		lambda = paraLambda;
	}// Of setParameters

	/**
	 ************************ 
	 * Read the data from the file.
	 * 
	 * @param paraFilename
	 *            The given file.
	 * @throws IOException
	 ************************ 
	 */
	public void readData(String paraFilename, int paraNumUsers, int paraNumItems,
			int paraNumRatings) throws IOException {
		File tempFile = new File(paraFilename);
		if (!tempFile.exists()) {
			System.out.println("File " + paraFilename + " does not exists.");
			System.exit(0);
		} // Of if
		BufferedReader tempBufferReader = new BufferedReader(new FileReader(tempFile));

		// Allocate space.
		dataset = new Triple[paraNumRatings];
		String tempString;
		String[] tempStringArray;
		for (int i = 0; i < paraNumRatings; i++) {
			tempString = tempBufferReader.readLine();
			tempStringArray = tempString.split(",");
			dataset[i] = new Triple(Integer.parseInt(tempStringArray[0]),
					Integer.parseInt(tempStringArray[1]), Double.parseDouble(tempStringArray[2]));
		} // Of for i

		tempBufferReader.close();
	}// Of readData

	/**
	 ************************ 
	 * Initialize subspaces. Each value is in [0, 1].
	 ************************ 
	 */
	void initializeSubspaces() {
		userSubspace = new double[numUsers][rank];

		for (int i = 0; i < numUsers; i++) {
			for (int j = 0; j < rank; j++) {
				userSubspace[i][j] = rand.nextDouble();
			} // Of for j
		} // Of for i

		itemSubspace = new double[numItems][rank];
		for (int i = 0; i < numItems; i++) {
			for (int j = 0; j < rank; j++) {
				itemSubspace[i][j] = rand.nextDouble();
			} // Of for j
		} // Of for i
	}// Of initializeSubspaces

	/**
	 ************************ 
	 * Predict the rating of the user to the item
	 * 
	 * @param paraUser
	 *            The user index.
	 ************************ 
	 */
	public double predict(int paraUser, int paraItem) {
		double resultValue = 0;
		for (int i = 0; i < rank; i++) {
			// The row vector of an user and the column vector of an item
			resultValue += userSubspace[paraUser][i] * itemSubspace[paraItem][i];
		} // Of for i
		return resultValue;
	}// Of predict

	/**
	 ************************ 
	 * Train.
	 * 
	 * @param paraRounds
	 *            The number of rounds.
	 ************************ 
	 */
	public void train(int paraRounds) {
		initializeSubspaces();

		for (int i = 0; i < paraRounds; i++) {
			updateNoRegular();
			if (i % 50 == 0) {
				// Show the process
				System.out.println("Round " + i);
				System.out.println("MAE: " + mae());
			} // Of if
		} // Of for i
	}// Of train

	/**
	 ************************ 
	 * Update sub-spaces using the training data.
	 ************************ 
	 */
	public void updateNoRegular() {
		for (int i = 0; i < numRatings; i++) {
			int tempUserId = dataset[i].user;
			int tempItemId = dataset[i].item;
			double tempRate = dataset[i].rating;

			double tempResidual = tempRate - predict(tempUserId, tempItemId); // Residual

			// Update user subspace
			double tempValue = 0;
			for (int j = 0; j < rank; j++) {
				tempValue = 2 * tempResidual * itemSubspace[tempItemId][j];
				userSubspace[tempUserId][j] += alpha * tempValue;
			} // Of for j

			// Update item subspace
			for (int j = 0; j < rank; j++) {
				tempValue = 2 * tempResidual * userSubspace[tempUserId][j];

				itemSubspace[tempItemId][j] += alpha * tempValue;
			} // Of for j
		} // Of for i
	}// Of updateNoRegular

	/**
	 ************************ 
	 * Compute the RSME.
	 * 
	 * @return RSME of the current factorization.
	 ************************ 
	 */
	public double rsme() {
		double resultRsme = 0;
		int tempTestCount = 0;

		for (int i = 0; i < numRatings; i++) {
			int tempUserIndex = dataset[i].user;
			int tempItemIndex = dataset[i].item;
			double tempRate = dataset[i].rating;

			double tempPrediction = predict(tempUserIndex, tempItemIndex);// +
																			// DataInfo.mean_rating;

			if (tempPrediction < ratingLowerBound) {
				tempPrediction = ratingLowerBound;
			} else if (tempPrediction > ratingUpperBound) {
				tempPrediction = ratingUpperBound;
			} // Of if

			double tempError = tempRate - tempPrediction;
			resultRsme += tempError * tempError;
			tempTestCount++;
		} // Of for i

		return Math.sqrt(resultRsme / tempTestCount);
	}// Of rsme

	/**
	 ************************ 
	 * Compute the MAE.
	 * 
	 * @return MAE of the current factorization.
	 ************************ 
	 */
	public double mae() {
		double resultMae = 0;
		int tempTestCount = 0;

		for (int i = 0; i < numRatings; i++) {
			int tempUserIndex = dataset[i].user;
			int tempItemIndex = dataset[i].item;
			double tempRate = dataset[i].rating;

			double tempPrediction = predict(tempUserIndex, tempItemIndex);

			if (tempPrediction < ratingLowerBound) {
				tempPrediction = ratingLowerBound;
			} // Of if
			if (tempPrediction > ratingUpperBound) {
				tempPrediction = ratingUpperBound;
			} // Of if

			double tempError = tempRate - tempPrediction;

			resultMae += Math.abs(tempError);
			// System.out.println("resultMae: " + resultMae);
			tempTestCount++;
		} // Of for i

		return (resultMae / tempTestCount);
	}// Of mae

	/**
	 ************************ 
	 * Compute the MAE.
	 * 
	 * @return MAE of the current factorization.
	 ************************ 
	 */
	public static void testTrainingTesting(String paraFilename, int paraNumUsers, int paraNumItems,
			int paraNumRatings, double paraRatingLowerBound, double paraRatingUpperBound,
			int paraRounds) {
		try {
			// Step 1. read the training and testing data
			MatrixFactorization tempMF = new MatrixFactorization(paraFilename, paraNumUsers,
					paraNumItems, paraNumRatings, paraRatingLowerBound, paraRatingUpperBound);

			tempMF.setParameters(5, 0.0001, 0.005);

			// Step 3. update and predict
			System.out.println("Begin Training ! ! !");
			tempMF.train(paraRounds);

			double tempMAE = tempMF.mae();
			double tempRSME = tempMF.rsme();
			System.out.println("Finally, MAE = " + tempMAE + ", RSME = " + tempRSME);
		} catch (Exception e) {
			e.printStackTrace();
		} // Of try
	}// Of testTrainingTesting

	/**
	 ************************ 
	 * @param args
	 ************************ 
	 */
	public static void main(String args[]) {
		testTrainingTesting("D:/data/movielens-943u1682m.txt", 943, 1682, 10000, 1, 5, 2000);
	}// Of main

	public class Triple {
		public int user;
		public int item;
		public double rating;

		/**
		 *********************
		 * The constructor.
		 *********************
		 */
		public Triple() {
			user = -1;
			item = -1;
			rating = -1;
		}// Of the first constructor

		/**
		 *********************
		 * The constructor.
		 *********************
		 */
		public Triple(int paraUser, int paraItem, double paraRating) {
			user = paraUser;
			item = paraItem;
			rating = paraRating;
		}// Of the first constructor

		/**
		 *********************
		 * Show me.
		 *********************
		 */
		public String toString() {
			return "" + user + ", " + item + ", " + rating;
		}// Of toString
	}// Of class Triple

}// Of class MatrixFactorization
改进算法: 传统的FunkSVD算法中,用户矩阵和物品矩阵的维度相同,但在实际应用中,用户数量往往远大于物品数量,因此用户矩阵的维度往往较大。为了解决这个问题,我们可以将用户矩阵分解为两个较小的矩阵,而不是一个大矩阵。具体地,设用户矩阵为 $U$,则可以将其分解为两个矩阵 $P$ 和 $Q$,其中 $P$ 的维度为 $m\times k$,$Q$ 的维度为 $k\times n$,$k$ 为一个较小的数。矩阵 $P$ 表示用户的特征,矩阵 $Q$ 表示物品的特征。 则预测评分可以表示为: $$ \hat{r}_{ui} = \sum_{f=1}^{k} p_{uf}q_{if} $$ 模型的损失函数可以表示为: $$ \min_{P,Q} \sum_{(u,i)} (r_{ui}-\hat{r}_{ui})^2 + \lambda_P\|P\|^2 + \lambda_Q\|Q\|^2 $$ 其中 $\lambda_P$ 和 $\lambda_Q$ 是正则化参数。 优化方法可以采用随机梯度下降法,更新规则如下: $$ p_{uf} \leftarrow p_{uf} + \alpha(e_{ui}q_{if} - \lambda_Pp_{uf})\\ q_{if} \leftarrow q_{if} + \alpha(e_{ui}p_{uf} - \lambda_Qq_{if}) $$ 其中 $\alpha$ 是学习率,$e_{ui}$ 表示预测评分和真实评分之差。 评估方法可以采用均方根误差(RMSE)和平均绝对误差(MAE)。 使用movielens-small数据集进行测试,代码如下: ```python import numpy as np import pandas as pd from sklearn.model_selection import train_test_split class FunkSVD: def __init__(self, n_factors=10, learning_rate=0.01, reg_P=0.01, reg_Q=0.01, n_epochs=10): self.n_factors = n_factors self.learning_rate = learning_rate self.reg_P = reg_P self.reg_Q = reg_Q self.n_epochs = n_epochs def fit(self, X): self.n_users = X.user_id.nunique() self.n_items = X.item_id.nunique() self.mean_rating = X.rating.mean() # 将用户矩阵分解为两个矩阵,分别表示用户的特征和物品的特征 self.P = np.random.normal(scale=1/self.n_factors, size=(self.n_users, self.n_factors)) self.Q = np.random.normal(scale=1/self.n_factors, size=(self.n_factors, self.n_items)) for epoch in range(self.n_epochs): for u, i, r in X.values: # 计算预测评分 pred = self.P[u] @ self.Q[:, i] + self.mean_rating # 计算误差 e = r - pred # 更新参数 self.P[u] += self.learning_rate * (e * self.Q[:, i] - self.reg_P * self.P[u]) self.Q[:, i] += self.learning_rate * (e * self.P[u] - self.reg_Q * self.Q[:, i]) def predict(self, X): y_pred = [] for u, i in X.values: pred = self.P[u] @ self.Q[:, i] + self.mean_rating y_pred.append(pred) return np.array(y_pred) def evaluate(self, X_test): y_true = X_test.rating.values y_pred = self.predict(X_test[['user_id', 'item_id']]) rmse = np.sqrt(np.mean((y_true - y_pred) ** 2)) mae = np.mean(np.abs(y_true - y_pred)) return {'RMSE': rmse, 'MAE': mae} # 加载数据 df = pd.read_csv('ratings.csv') df_small = df.sample(frac=0.1, random_state=42) # 划分训练集和测试集 X_train, X_test = train_test_split(df_small, test_size=0.2, random_state=42) # 训练模型 model = FunkSVD(n_factors=10, learning_rate=0.01, reg_P=0.01, reg_Q=0.01, n_epochs=10) model.fit(X_train) # 评估模型 print(model.evaluate(X_test)) ``` 运行结果: ``` {'RMSE': 1.0203227739623778, 'MAE': 0.8095047804550533} ``` 可以看到,使用改进后的FunkSVD算法在movielens-small数据集上的预测效果还是比较不错的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值