LibRec学习笔记(一):BiasedMF算法

最近推荐系统导论课程需要使用LibRec库实现一些推荐算法,其实LibRec库已经封装了很多算法,并不需要再去实现,只需要调用命令行修改配置就可以运行,但为了更好地理解算法,我阅读了LibRec库的一些算法的源码,以下是BiaseMF算法在LibRec库中的实现,本文主要从三部分展开讲解,分别是预测公式、损失函数公式和更新公式。

代码展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// SVDPlusPlusRecommender类继承BiasedMFRecommender父类
package net.librec.recommender.cf.rating;

import java.util.Iterator;
import net.librec.annotation.ModelData;
import net.librec.common.LibrecException;
import net.librec.math.structure.MatrixEntry;
import net.librec.math.structure.VectorBasedDenseVector;
import net.librec.recommender.MatrixFactorizationRecommender;

@ModelData({"isRating", "biasedMF", "userFactors", "itemFactors", "userBiases", "itemBiases"})
public class BiasedMFRecommender extends MatrixFactorizationRecommender {
protected double regBias;
protected VectorBasedDenseVector userBiases;
protected VectorBasedDenseVector itemBiases;

public BiasedMFRecommender() {
}

protected void setup() throws LibrecException {
super.setup();
this.regBias = this.conf.getDouble("rec.bias.regularization", 0.01D);
// 用户偏差向量
this.userBiases = new VectorBasedDenseVector(this.numUsers);
// 物品偏差向量
this.itemBiases = new VectorBasedDenseVector(this.numItems);
// 用户偏差初始化
this.userBiases.init((double)this.initMean, (double)this.initStd);
// 物品偏差初始化
this.itemBiases.init((double)this.initMean, (double)this.initStd);
}

protected void trainModel() throws LibrecException {
for(int iter = 1; iter <= this.numIterations; ++iter) {
// 损失
this.loss = 0.0D;

Iterator var2 = this.trainMatrix.iterator();

while(var2.hasNext()) {
MatrixEntry matrixEntry = (MatrixEntry)var2.next();
int userIdx = matrixEntry.row();
int itemIdx = matrixEntry.column();
// 实际评分
double realRating = matrixEntry.get();
// 预测评分
double predictRating = this.predict(userIdx, itemIdx);
// 实际评分与预测评分的误差
double error = realRating - predictRating;
this.loss += error * error;

// 根据当前用户ID得到当前用户偏差向量
double userBiasValue = this.userBiases.get(userIdx);
// 用户偏置项的更新公式
this.userBiases.plus(userIdx, (double)this.learnRate * (error - this.regBias * userBiasValue));

this.loss += this.regBias * userBiasValue * userBiasValue;
// 根据当前物品ID得到当前物品偏差向量
double itemBiasValue = this.itemBiases.get(itemIdx);
// 物品偏置项的更新公式
this.itemBiases.plus(itemIdx, (double)this.learnRate * (error - this.regBias * itemBiasValue));

                this.loss += this.regBias * itemBiasValue * itemBiasValue;

for(int factorIdx = 0; factorIdx < this.numFactors; ++factorIdx) {
double userFactorValue = this.userFactors.get(userIdx, factorIdx);
double itemFactorValue = this.itemFactors.get(itemIdx, factorIdx);
this.userFactors.plus(userIdx, factorIdx, (double)this.learnRate * (error * itemFactorValue - (double)this.regUser * userFactorValue));
this.itemFactors.plus(itemIdx, factorIdx, (double)this.learnRate * (error * userFactorValue - (double)this.regItem * itemFactorValue));
this.loss += (double)this.regUser * userFactorValue * userFactorValue + (double)this.regItem * itemFactorValue * itemFactorValue;
}
}

this.loss *= 0.5D;
if (this.isConverged(iter) && this.earlyStop) {
break;
}

this.updateLRate(iter);
}

}

protected double predict(int userIdx, int itemIdx) throws LibrecException {
return this.userFactors.row(userIdx).dot(this.itemFactors.row(itemIdx)) + this.userBiases.get(userIdx) + this.itemBiases.get(itemIdx) + this.globalMean;
}
}

预测公式

\(\hat{r_{u i}}=\mu+b_{i}+b_{u}+p_{u} q_{i}^{T}\)

其中,\(\mu\)表示全局均值,在代码中使用this.globalMean表示,\(b_{i}\)表示物品偏差项,在代码中使用this.itemBiases.get(itemIdx)表示,需要注意的是,物品偏置项需要根据物品ID进行索引,\(b_{u}\)表示用户偏差项,在代码中使用this.userBiases.get(userIdx)表示,同样地,用户偏置项需要根据用户ID来进行索引,\(p_{u}\)表示对用户评分矩阵进行矩阵分解后得到的用户向量,\(q_{i}\)表示对用户评分矩阵进行分解后得到的物品向量。

  • PS:这里说是用户向量和物品向量的原因是都是针对某一用户和物品进行计算。

预测公式代码如下:

1
2
3
protected double predict(int userIdx, int itemIdx) throws LibrecException {
    return this.userFactors.row(userIdx).dot(this.itemFactors.row(itemIdx)) + this.userBiases.get(userIdx) + this.itemBiases.get(itemIdx) + this.globalMean;
}

传入userIdxitemIdx的原因是需要明确特定用户和特定物品,才能进行预测评分,userFactorsitemFactors是对用户评分矩阵进行矩阵分解后得到的用户矩阵和物品矩阵,需要明确索引才能使用。

损失函数公式

\(\sum_{r_{u i} \in R_{\text {train }}}\left(r_{u i}-\hat{r}_{u i}\right)^{2}+\lambda\left(b_{i}^{2}+b_{u}^{2}+\left\|q_{i}\right\|^{2}+\left\|p_{u}\right\|^{2}\right)\)

将用户对物品的特定评分与真实评分之间的差值的平方作为损失函数的一部分是显而易见的,在代码中体现如下:

1
2
3
4
5
6
7
// 实际评分
double realRating = matrixEntry.get();
// 预测评分
double predictRating = this.predict(userIdx, itemIdx);
// 实际评分与预测评分的误差
double error = realRating - predictRating;
this.loss += error * error;

通过以上展示的predict()函数可以直接根据用户ID与物品ID求出预测评分,但值得琢磨的是,实际评分是如何得到的?

可以看到,MatrixEntry接口是如下定义的,

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface MatrixEntry {
int row();

int column();

double get();

void set(double var1);

int rowPosition();

int columnPosition();
}

SequentialSparseMatrixEntry类中实现了MatrixEntry接口的get()方法,

1
2
3
public double get() {
    return this.tempVector.getAtPosition(this.columnPosition);
}

值得注意的是,在SequentialSparseMatrixEntry类中,columnPosition属性被初始化为-1,这就让我们联想到,标签的值总是被设置在最后一列,我们可以验证这个猜想,在代码中,var2被强制转换为MatrixEntry类型,而var2又是trainMatrix的一个迭代器,不出所料地,trainMatrix是·SequentialAccessSparseMatrix类型,所以程序中可以直接调用get()方法来获取数据集中的实际评分值。

我们重新来回到损失函数的话题上,接下来需要关注正则项的那一部分,即

\(\lambda\left(b_{i}^{2}+b_{u}^{2}+\left|q_{i}\right|^{2}+\left|p_{u}\right|^{2}\right)\)

首先我们关注\(\lambda(b_{i}^{2}+b_{u}^{2})\),这一部分的代码比较好写,根据索引找到特定的用户偏置向量和物品偏置向量就可以写出代码,代码如下所示,

1
2
3
4
double userBiasValue = this.userBiases.get(userIdx);
this.loss += this.regBias * userBiasValue * userBiasValue;
double itemBiasValue = this.itemBiases.get(itemIdx);
this.loss += this.regBias * itemBiasValue * itemBiasValue;

接下来的一部分就可能比较难懂,在对用户评分矩阵进行分解时,我们有一个超参数\(K\),表示的是隐因子的个数,在电影的用户评分矩阵中,隐因子指的可能是喜剧片、动画片等内容,换言之,用户是因为这些隐因子才作出的这些评分,所以矩阵分解的公式可以表示如下,

\(\hat{r_{u i}}=p_{u} q_{i}^{T}=\sum_{k=1}^{K} p_{u k} q_{k i}^{T}=\sum_{k=1}^{K} p_{u k} q_{i k} \approx r_{u i}\)

所以在代码中我们需要增加一层遍历,即从1到\(K\)对隐因子的遍历,对应每一种隐因子ID和用户ID有不同的用户隐因子值,同样的,对于每一种隐因子ID和物品ID有不同的物品隐因子值,对于\(\lambda\left(\left|q_{ik}\right|^{2}+\left|p_{uk}\right|^{2}\right)\)这一部分的代码如下所示,

1
2
3
4
5
for(int factorIdx = 0; factorIdx < this.numFactors; ++factorIdx) {
double userFactorValue = this.userFactors.get(userIdx, factorIdx);
double itemFactorValue = this.itemFactors.get(itemIdx, factorIdx);
this.loss += (double)this.regUser * userFactorValue * userFactorValue + (double)this.regItem * itemFactorValue * itemFactorValue;
}

以上就是损失函数全部的内容。

更新公式

\(\begin{array}{l} b_{u} \leftarrow b_{u}+\gamma\left(e_{u i}-\lambda b_{u}\right) \\ b_{i} \leftarrow b_{i}+\gamma\left(e_{u i}-\lambda b_{i}\right) \\ p_{u} \leftarrow p_{u}+\gamma\left(e_{u i} \cdot q_{i}-\lambda p_{u}\right) \\ q_{i} \leftarrow q_{i}+\gamma\left(e_{u i} \cdot p_{u}-\lambda q_{i}\right) \end{array}\)

有了预测函数和损失函数的铺垫,这一部分就可以直接上代码,函数变量基本是差不多的,

1
2
3
4
5
6
7
8
this.userBiases.plus(userIdx, (double)this.learnRate * (error - this.regBias * userBiasValue));
this.itemBiases.plus(itemIdx, (double)this.learnRate * (error - this.regBias * itemBiasValu));
for(int factorIdx = 0; factorIdx < this.numFactors; ++factorIdx) {
double userFactorValue = this.userFactors.get(userIdx, factorIdx);
double itemFactorValue = this.itemFactors.get(itemIdx, factorIdx);
this.userFactors.plus(userIdx, factorIdx, (double)this.learnRate * (error * itemFactorValue - (double)this.regUser * userFactorValue));
this.itemFactors.plus(itemIdx, factorIdx, (double)this.learnRate * (error * userFactorValue - (double)this.regItem * itemFactorValue));
}

只需要注意一点,plus()函数是需要知道具体索引位置才能进行更新的,所以需要传入索引参数。

1
2
3
4
5
6
7
8
9
10
// DenseVector类中的plus方法
public void plus(int index, double value) {
this.set(index, value + this.get(index));
}

// DenseMatrix类中的plus方法
public void plus(int row, int column, double value) {
double[] var10000 = this.values[row];
var10000[column] += value;
}