写点什么

一篇长文教你学会推荐系统的矩阵分解

2017 年 10 月 16 日

十年前,Netflix 发起了 Netflix 奖公开竞赛,目标在于设计最新的算法来预测电影评级。竞赛历时 3 年,很多研究团队开发了各种不同的预测算法,其中矩阵分解技术因效果突出从众多算法中脱颖而出。

该篇文章有两个目的:

  • 介绍如何用矩阵分解来建模电影评级。我将用具体的例子来阐述 PCA 和 SVD 的工作原理。你可能已经读过一些解释,譬如用户和项目被表示为潜在因子向量空间里的向量,评级被定义为两个向量之间的点积。如果你认为这些没有道理,希望你在读完这篇文章后能明白其含义。
  • 派生和实现一种基于矩阵分解预测评级的算法。该算法很简单,仅需要 10 行 Python 代码。我将应用该算法并在实际数据集上评估它的性能。

我试图让本文的数学内容通俗易懂,但不会过于简化事情,并避免枯燥的语句。我希望该文对机器学习初学者有所帮助,对有经验的人而言有所见解。

这篇文章分为四部分。第一部分清楚地定义了将阐述的问题,提出了一些对 PCA 的见解。在第二部分,我们将回顾 SVD,看看它是如何对评级建模。第三部分展示了如何将 SVD 知识应用于评级预测中,并派生出一个基于矩阵分解的预测算法。在最后一部分,我们将调用 Surprise 库,用 Python 代码实现一个矩阵分解算法。

第一部分:PCA 浅谈

问题描述

在这里,我们将谈论的问题是评级预测问题。我们的数据是评级历史数据,即用户对项目的评级,值区间是 [1,5]。我们可以把数据放在一个稀疏矩阵\(R\) 中:

\(\begin{align*} R = \begin{pmatrix} 1 & \color{#e74c3c}{?} & 2 & \color{#e74c3c}{?} & \color{#e74c3c}{?}\\ \color{#e74c3c}{?} & \color{#e74c3c}{?} & \color{#e74c3c}{?} & \color{#e74c3c}{?} & 4\\ 2 & \color{#e74c3c}{?} & 4 & 5 & \color{#e74c3c}{?}\\ \color{#e74c3c}{?} & \color{#e74c3c}{?} & 3 & \color{#e74c3c}{?} & \color{#e74c3c}{?}\\ \color{#e74c3c}{?} & 1 & \color{#e74c3c}{?} & 3 & \color{#e74c3c}{?}\\ 5 & \color{#e74c3c}{?} & \color{#e74c3c}{?} & \color{#e74c3c}{?} & 2\\ \end{pmatrix} \begin{matrix} \text{Alice}\\ \text{Bob}\\ \text{Charlie}\\ \text{Daniel}\\ \text{Eric}\\ \text{Frank}\\ \end{matrix} \end{align*}\)

矩阵的每一行对应一个给定用户,每一列对应一个给定项目。譬如,在上面的矩阵中,Alice 对第一个项目的评级是 1,Charlie 对第三个项目的评级是 4。在我们的问题中,我们将认为项目是电影,在后面会交替使用“项目”和“电影”这两个术语。

矩阵\(R\) 是稀疏的(99% 以上的内容是缺失的),我们的目标是预测那些缺失的内容,即问号处的值。

有很多不同的技术可用于评级预测,在这里我们将分解矩阵\(R\)。该矩阵分解与 SVD(Singular Value Decomposition,奇异值分解)是根本相关的。SVD 是线性代数的亮点之一。它的结果很美丽。如果有人告诉你数学很差劲,你可以向他们展示看看 SVD 能做什么。

这篇文章的目的在于解释如何能将 SVD 用于预测评级。但在我们进入第二部分的 SVD 之前,我们需要回顾一下什么是 PCA。PCA 只稍稍逊色于 SVD,但也仍然很酷。

PCA 简介

让我们暂时忘记推荐问题。我们将用到 Olivetti 数据集,它是 40 个人的脸部灰度图像集,一共有 400 个图像。第一组 10 个人的图像如下:

每一个图像的大小是 64 x 64 像素。我们将扁平化每个图像,于是可以得到 400 个向量,每个向量里有 64 x 64 = 4096 个成员。我们可以将数据集表示为一个 400 x 4096 矩阵:

\(\begin{align*} % <![CDATA[ \newcommand{\horzbar}{\Rule{2.5ex}{0.5pt}{0.1pt}} \newcommand{\vertbar}{\Rule{0.5pt}{1pt}{2.5ex}} X= \begin{pmatrix} \horzbar & \text{Face 1} & \horzbar\\ \horzbar & \text{Face 2} & \horzbar\\ & \vdots &\\ \horzbar & \text{Face 400} & \horzbar\\ \end{pmatrix} %]]> \end{align*}\)

PCA(Principal Component Analysis,主成分分析)算法可以将 400 个这样的人展现为下面的图:

看起来很恐怖吧?

我们将这些人叫做主成分,他们所代表的脸部图像叫做特征脸部。可以用特征脸部做很多有趣的事情,如脸部识别优化你的tinder 匹配。它们之所以被叫做特征脸部,是因为它们实际是\(X\) 的协方差矩阵的特征向量(但这里我们不会给出细节,感兴趣的读者可以参看文末的参考文献)。

这里,我们获取了400 个主成分,因为原始的矩阵\(X\) 有400 行(更确切的说,是因为\(X\) 有400 个评级)。你可能已经猜到,每一个主成分实际上是一个向量,与原始的脸部有着相同的维度,即有64 x 64 = 4096 个像素。

就我们而言,我们会把这些图像叫做“令人毛骨悚然的图像”。现在,关于它们的一个惊人的事情是,它们可以恢复所有的原始面孔。看看下面这些动画GIF(大约10 秒长):

让我来解释下怎么回事。400 个原始脸部中的每一个(即矩阵的400 个原始行中的每一行)可以表示为这些令人毛骨悚然的图像的(线性)组合。也就是说,我们可以将第一个原始脸部的像素值表示为第一个令人毛骨悚然的图像的一小部分,加上第二个令人毛骨悚然的图像的一小部分,再加上第三个令人毛骨悚然的图像的一小部分,等等,直到最后一个令人毛骨图像的家伙。这一方法对其他原始脸部也适用:它们都能被表示为每一个令人毛骨悚然的图像的一小部分。我们可以将其表示为数学的方式如下:

\(\begin{align*} \text{Face 1}~=~&\alpha_1 \cdot \color{#048BA8}{\text{Creepy guy #1}}\\ +~ &\alpha_2 \cdot \color{#048BA8}{\text{Creepy guy #2}}\\ +~ &\cdots\\ +~ &\alpha_{400} \cdot \color{#048BA8}{\text{Creepy guy #400}} \end{align*}\)

\(\begin{align*} \text{Face 2}~=~&\beta_1 \cdot \color{#048BA8}{\text{Creepy guy #1}}\\ +~ &\beta_2 \cdot \color{#048BA8}{\text{Creepy guy #2}}\\ +~ &\cdots\\ +~ &\beta_{400} \cdot \color{#048BA8}{\text{Creepy guy #400}} \end{align*}\)

上面的动态图正是对这些数学公式的翻译:gif 的第一帧是第一个令人毛骨悚然的图像的贡献,第二帧是第二个令人毛骨悚然的图像的贡献,等等,直到最后一个。

如果你想尝试重现这些动态图,我写了一个Python notebook

注意:用那些令人毛骨悚然的图像来表示原始脸部,这一点并没有什么特别的。我们可以随机选取 400 个独立的向量(每个向量是 64 x 64 = 4096 像素),仍然能够重现这些脸部。降低维度是让这些令人毛骨悚然的图像在 PCA 中变得特别的原因,我们将在第三部分讨论这些。

潜在因子

实际上,我们对那些令人毛骨悚然的图像有些过于严厉了。它们并不恐怖,它们很典型。PCA 的目标就在于揭示典型的向量,每一个令人毛骨悚然的或典型的家伙代表着隐藏在数据下的一个特定的方面。理想情况下,第一个令人毛骨悚然的图像将代表譬如一个典型的年长一些的人,第二个令人毛骨悚然的图像将代表一个典型的滑头滑脑的人,而其他令人毛骨悚然的图像可以代表一些如喜欢微笑的人、表情悲伤的人、有着大鼻子的人等等概念。有了这些概念,我们可以将脸部定义为或多或少年长、或多或少滑头滑脑、或多或少微笑,等等。实际上,PCA 所揭示的概念并没有那么清晰,并没有一个清楚的语义能让我们像在这里一样把任何令人毛骨悚然的图像或典型图像相关联起来。但是,有一个重要的事实是,每一个令人毛骨悚然的或典型的图像捕获了数据的某一个特征。我们把这些特征叫做潜在因子(潜在,是因为它们一直在那里,我们只需要用 PCA 来揭示它们)。每个主成分(令人毛骨悚然的或典型的图像)捕获一个特定的潜在因素。

现在,这是很好玩的,但是我们对用矩阵分解进行推荐感兴趣,对吧?那么我们的矩阵分解在哪里,它与推荐有什么关系? PCA 实际上是即插即用的方法,它适用于任何矩阵。如果矩阵包含图像,它将显示一些典型的图像,可以构建所有初始图像。如果矩阵包含土豆,PCA 将显示一些可以恢复所有原始土豆的典型土豆。如果你的矩阵包含评分,那么接下来我们来看看如何做。

PCA 用于(密集的)评级矩阵

除非另有说明,我们的评级矩阵\(R\) 是完全密集的,即没有缺失的内容。所有评分都是已知的。这当然不是实际推荐问题中的场景,但请与我一样如此假设。

用于用户上的 PCA

我们的评级矩阵如下所示,行代表用户,列代表电影:

\(\begin{align*} % <![CDATA[ R = \begin{pmatrix} \horzbar & \text{Alice} & \horzbar\\ \horzbar & \text{Bob} & \horzbar\\ & \vdots &\\ \horzbar & \text{Zoe} & \horzbar\\ \end{pmatrix} %]]> \end{align*}\)

看起来是不是很熟悉? 我们没有用像素来表示每一行的脸部,相反,现在我们用评级来表示用户。就像之前 PCA 给出一些典型的家伙,现在它将给我们一些典型的用户,或者一些典型的评价者。

在理想情况下,与典型用户相关的概念将具有明确的语义含义:我们将获得典型的动作电影迷、典型的浪漫电影迷、典型的喜剧迷,等等。实际上,典型用户背后的语义并没有被明确定义,但为了简单起见,我们假设它们如此(这不会改变任何东西,只是为了直觉或解释)。

于是,我们的每个初始用户(Alice,Bob 等)可以表示为典型用户的组合。例如,Alice 可以被定义为动作电影迷的一小部分、喜剧电影迷的一小部分、浪漫电影迷的一大部分,等等。至于 Bob,他可以更加热衷于动作电影:

\(\begin{align*} \text{Alice} &= 10\% \color{#048BA8}{\text{ 动作电影迷}} + 10\% \color{#048BA8}{\text{ 喜剧电影迷}} + 50\% \color{#048BA8}{\text{ 浪漫电影迷}} +\cdots\\ \text{Bob} &= 50\% \color{#048BA8}{\text{ 动作电影迷}} + 30\% \color{#048BA8}{\text{ 喜剧电影迷}} + 10\% \color{#048BA8}{\text{ 浪漫电影迷}} +\cdots\\ \text{Zoe} &= \cdots \end{align*}\)

该表示方法对所有用户都适用。(实际上,系数不一定是百分比,但表示成百分比更方便我们思考)。

用于电影上的 PCA

如果我们对评级矩阵进行转置会发生什么?我们没有把用户当成行,而是把电影当作行,由评级定义如下:

\(\begin{align*} R^T = \begin{pmatrix} \horzbar & \text{Titanic} & \horzbar\\ \horzbar & \text{Toy Story} & \horzbar\\ & \vdots &\\ \horzbar & \text{Fargo} & \horzbar\\ \end{pmatrix} \end{align*}\)

在这种情况下,PCA 不会显示典型的人脸或典型的用户,而是显示典型的电影。再次,我们将把典型的电影背后的语义意义相结合,这些典型的电影可以构造我们所有的原始电影。对所有其他电影也是如此。

第二部分: SVD 背后的模型

我们来回顾一下第一部分的内容:

  • 矩阵\(R\) 上的 PCA 将给出典型的用户。这些典型的用户当然被表示为向量(长度与用户相同,就像令人毛骨悚然的图像与原始脸部长度相同)。因为它们是向量,我们可以将它们放在我们称为\(U\) 的矩阵的列中。
  • 矩阵\(R^T\) 上的 PCA 将给出典型的电影。这些典型的电影也是向量(长度与电影相同),我们可以将它们放在我们称为\(M\) 的矩阵的列中。

矩阵分解

SVD 能为我们做什么呢?简而言之,SVD 是\(R\) 和\(R^T\) 上的 PCA。

SVD 能同时给出矩阵\(U\) 和\(M\)。你能同时拿到典型的用户和典型的电影。通过将\(R\) 分解为三部分,SVD 给出\(U\) 和\(M\)。下面是矩阵分解表达式:

\(\begin{align*} R=MΣU^T \end{align*}\)

SVD 算法以矩阵\(R\) 为输入,输出\(M\)、\(Σ\) 和\(U\),使得:

  • \(R\) 等于\(MΣU^T\)。
  • \(M\) 的列能重造\(R\) 的所有列(我们已经知道了这一点)。
  • \(U\) 的列能重造\(R\) 的所有行(我们已经知道了这一点)。
  • \(M\) 的列是正交的,\(U\) 的列也是正交的。主成分都是正交的。这实际上是 PCA(和 SVD)的一个及其重要的特征,但对于推荐而言我们并不关心(之后会讲到这点)。
  • \(Σ\) 是一个对角矩阵(之后会讲到这点)。

我们可以基本总结上述所有这些点为:\(M\) 的列是跨越\(R\) 的列空间的正交基,\(U\) 的列是跨越\(R\) 的行空间的正交基。

SVD 背后的模型

当我们计算和使用评级矩阵\(R\) 的 SVD 时,实际上,我们是在对评级进行特别的、有意义的建模。我们将在这里描述该建模。

简单起见,我们将忘记矩阵\(Σ\):它是一个对角矩阵,所以它仅仅是充当\(M\) 或\(U^T\) 的标量。因此,我们将假装我们已经将其合并到其中的一个矩阵了。我们的矩阵分解是:

\(\begin{align*} R=MΣU^T \end{align*}\)

现在,用这种分解,让我们来看看用户\(u\) 对项目\(i\) 的评级,我们将其表示为\(r_{ui}\):

\(\begin{align*} % <![CDATA[ \newcommand{\horzbar}{\Rule{2.5ex}{0.5pt}{0.1pt}} \newcommand{\vertbar}{\Rule{0.5pt}{1pt}{2.5ex}} \begin{pmatrix} &&&&\\ &&r_{ui}&&\\ &&&&\\ \end{pmatrix} = \begin{pmatrix} &&&&\\ &\horzbar&p_u& \horzbar&\\ &&&&\\ \end{pmatrix} \begin{pmatrix} &&\vertbar&&\\ &&q_i&&\\ &&\vertbar&&\\ \end{pmatrix}\\ %]]> \end{align*}\)

由于定义矩阵乘法的定义方式,\(r_{ui}\) 的值是两个向量的点积的结果:向量\(p_u\),它是\(M\) 的一行,特定于用户\(u\);向量\(q_i\),它是\(U^T\) 的列,特定于项目\(i\):

\(\begin{align*} r_{ui}=p_u⋅q_i, \end{align*}\)

其中⋅表示点积。是否还记得我们是如何定义用户和项目的?

\(\begin{align*} % <![CDATA[ \begin{array}{ll} \text{Alice} & = 10\% \color{#048BA8}{\text{ 动作电影迷}} &+ 10\% \color{#048BA8}{\text{ 喜剧电影迷}} &+ 50\% \color{#048BA8}{\text{ 浪漫电影迷}} &+\cdots\\ \text{Bob} &= 50\% \color{#048BA8}{\text{ 动作电影迷}}& + 30\% \color{#048BA8}{\text{ 喜剧电影迷}} &+ 10\% \color{#048BA8}{\text{ 浪漫电影迷}} &+\cdots\\ \text{Titanic} &= 20\% \color{#048BA8}{\text{ 动作片}}& + 00\% \color{#048BA8}{\text{ 喜剧片}} &+ 70\% \color{#048BA8}{\text{ 浪漫片}} &+\cdots\\ \text{Toy Story} &= 30\% \color{#048BA8}{\text{ 动作片 }} &+ 60\% \color{#048BA8}{\text{ 喜剧片}}&+ 00\% \color{#048BA8}{\text{ 浪漫片}} &+\cdots\\ \end{array} %]]> \end{align*}\)

那么,向量\(p_u\) 和\(q_i\) 的值恰好对应于我们分配给每个潜在因子的系数:

\(\begin{align*} p_\text{Alice} &= (10\%,~~ 10\%,~~ 50\%,~~ \cdots)\\ p_\text{Bob} &= (50\%,~~ 30\%,~~ 10\%,~~ \cdots)\\ q_\text{Titanic} &= (20\%,~~ 00\%,~~ 70\%,~~ \cdots )\\ q_\text{Toy Story} &= (30\%,~~ 60\%, 00\%, \cdots ) \end{align*}\)

向量\(p_u\) 表示用户\(u\) 对每个潜在因素的亲和度。类似地,向量\(q_i\) 表示项目\(i\) 对潜在因素的亲和度。Alice 被表示为 10%,10%,50%,…),这意味着她对动作片和喜剧片只有一点兴趣,但她似乎喜欢浪漫片。对于 Bob,他似乎更喜欢动作电影超过其他任何事情。我们还可以看到,Titanic 主要是一部浪漫电影,绝对没有半点喜剧的元素。

所以,当我们使用\(R\) 的 SVD 时,我们将用\(u\) 户对项目\(i\) 的评级建模如下:

\(\begin{align*} r_{ui}= p_u \cdot q_i = \sum_{f \in \text{latent factors}} \text{affinity of } u \text{ for } f \times \text{affinity of } i \text{ for }f \end{align*}\)

换句话说,如果\(u\) 具有\(i\) 所认可的因子,那么评级\(r_{ui}\) 将会很高。相反,如果\(i\) 不是\(u\) 喜欢的项目(即系数不匹配),那么评级\(r_{ui}\) 将会很低。在我们的例子中,Alice 对 Titanic 的评价将会很高,而 Bob 的评分要低得多,因为他对浪漫电影的热情不高。然而,他对“玩具总动员”的评级将高于 Alice。

我们现在有足够的知识将 SVD 应用于推荐任务。在此之前,我们假定矩阵\(R\) 是密集的。在实际情况下,它显然是稀疏的(因为我们的目标正是使它更密集)。我们将在下一部分中谈到如何做到这一点。

第三部分:用于推荐的 SVD

现在既然我们已经很好地了解了什么是 SVD 以及如何对评级建模,我们就可以触及问题的核心了:将 SVD 应用于推荐。或者,将 SVD 用于预测缺失的评级。我们先回到稀疏矩阵\(R\):

\(\begin{align*} % <![CDATA[ R= \begin{pmatrix} 1 & \color{#e74c3c}{?} & 2 & \color{#e74c3c}{?} & \color{#e74c3c}{?}\\ \color{#e74c3c}{?} & \color{#e74c3c}{?} & \color{#e74c3c}{?} & \color{#e74c3c}{?} & 4\\ 2 & \color{#e74c3c}{?} & 4 & 5 & \color{#e74c3c}{?}\\ \color{#e74c3c}{?} & \color{#e74c3c}{?} & 3 & \color{#e74c3c}{?} & \color{#e74c3c}{?}\\ \color{#e74c3c}{?} & 1 & \color{#e74c3c}{?} & 3 & \color{#e74c3c}{?}\\ 5 & \color{#e74c3c}{?} & \color{#e74c3c}{?} & \color{#e74c3c}{?} & 2\\ \end{pmatrix} %]]> \end{align*}\)

我们的目标是预测矩阵中问号的值。

遇到问题

然而,\(R\) 的 SVD 并没有定义。它不存在,所以根本不可能计算它。但是不用担心,我们接下来讲讲怎么办。

如果\(R\) 是密集的,我们可以很容易计算\(M\) 和\(U\):\(M\) 的行是\(RR^T\) 的特征向量,\(U\) 的列是\(R^TR\) 的特征向量。相关联的特征值组成对角矩阵\(Σ\)。有一些很有效的算法可以计算这些。

但是,\(R\) 是稀疏的,矩阵\(RR^T\) 和\(R^TR\) 并不存在,所以它们的特征向量也不存在,而且我们不能把\(R\) 分解为\(MΣU^T\) 的乘积。但是,有一些办法。曾被用过一段时间的第一个选择是,对\(R\) 的缺失内容进行填充,如,行(或列)的平均值。一旦得到密集矩阵,我们就可以用传统算法来计算其 SVD。这种方法可行,但结果往往有很高的偏见。我们宁愿用另外一种方法,基于最小化问题。

替代方法

计算\(RR^T\) 和\(R^TR\) 的特征向量并不是计算密集矩阵\(R\) 的 SVD 的唯一方法。实际上,我们可以找到矩阵\(M\) 和\(U\),如果我们能找到所有满足如下条件的向量\(p_u\) 和\(q_i\)(\(p_u\) 组成\(M\) 的行,\(q_i\) 组成\(U^T\) 的列):

  • 对所有的\(u\) 和\(i\),\(r_{ui}=p_u⋅q_i\)
  • 所有的向量\(p_u\) 是相互正交的,所有的向量\(q_i\) 也如此。

对所有的用户和项目,找出这种向量\(p_u\) 和\(q_i\) 可通过解决下面的优化问题(同时遵循正交约束)来完成:

\(\begin{align*} min_{p_u, q_i\\p_u \perp p_v\\ q_i \perp q_j}\sum_{r_{ui} \in R} (r_{ui} - p_u \cdot q_i)^2 \end{align*}\)

它可被理解为,找到向量\(p_u\) 和\(q_i\) 使得总和最小。也就是说,我们试图尽可能将\(r_{ui}\) 的值与它们的实际值\(p_u⋅q_i\) 相匹配。

我在这里滥用符号,将\(R\) 既称为矩阵又称为一组评级。一旦我们知道使得这个总和最小(这里的最小值为零)的向量\(p_u\) 和\(q_i\) 的值,我们可以重构\(M\) 和\(U\),从而得到 SVD。

那么当\(R\) 稀疏时,即当矩阵中某些评级缺失时,我们该怎么办? Simon Funk 的答案是我们应该不要废弃。我们仍然解决同样的优化问题:

\(\begin{align*} \min_{p_u, q_i}\sum_{r_{ui} \in R} (r_{ui} - p_u \cdot q_i)^2. \end{align*}\)

唯一的区别是,这次,某些评级是缺失的,即\(R\) 不完整。请注意,我们并没有将缺少的项目视为零:我们纯粹是忽略它们。此外,我们将会忘记正交性约束,因为即使它们对于解释有用,通常,限制向量也不能帮助我们获得更准确的预测。

Simon Funk 因他的这一解决方案曾名列 Netflix 奖的前 3 名。他的算法被其他团队大量使用、研究和改进。

算法

这个优化问题不是凸的。也就是说,很难找到使得该总和最小的向量\(p_u\) 和\(q_i\) 的值(并且最优解甚至可能不是唯一的)。然而,有大量技术可以找到近似解。我们将在这里使用 SGD(Stochastic Gradient Descent,随机梯度下降)方法。

梯度下降是一种非常经典的技术,用于查找函数的最小值(有时是局部的)。如果你听说过用于训练神经网络的反向传播,那么 backprop 只是一种计算梯度的技术,它后来被用于梯度下降。SGD 是梯度下降的十亿种变体之一。我们不会详细介绍 SGD 如何工作(在网上可以找到很多好的资源),但一般情况如下。

当带参数\(θ\) 的函数\(f\) 被表示如下时:

\(\begin{align*} f(\theta) = \sum_k f_k(\theta), \end{align*}\)

SGD 过程通过下列步骤来最小化\(f\) (即,找到\(θ\) 使得\(f(θ)\) 尽可能小):

  1. 随机初始化\(θ\)。
  2. 对于给定的次数,重复下面的步骤:
  • 对所有\(k\),重复下面的步骤:
    • 计算\(\frac{∂f_{k}}{∂θ}\)。
    • 更新\(θ\):\(θ←θ+α⋅\frac{∂f_{k}}{∂θ}\),其中\(α\) 是学习速率(一个很小的值)。

在我们的情况下,参数\(θ\) 对应于所有的向量\(p_u\) 和\(q_i\)(我们将其表示为(\(p_∗\),\(q_*\))),而我们想最小化的函数\(f\) 表示为

\(\begin{align*} f(p_*, q_*) = \sum_{r_{ui} \in R} (r_{ui} - p_u \cdot q_i)^2 =\sum_{r_{ui} \in R} f_{ui}(p_u, q_i), \end{align*}\)

其中\(f_{ui}\) 被定义为\(f_{ui}(p_u, q_i) = (r_{ui} - p_u \cdot q_i)^2\)。

因此,为了应用 SGD,对任意\(p_u\) 和\(q_i\),我们需要找到导数\(f_{ui}\) 的值。

  • 对于给定向量\(p_u\), \(f_{ui}\) 的导数由下式给出:

\(\begin{align*} \frac{\partial f_{ui}}{\partial p_u} = \frac{\partial}{\partial p_u} (r_{ui} - p_u \cdot q_i)^2 = - 2 q_i (r_{ui} - p_u \cdot q_i) \end{align*}\)

  • 对称地,对于给定向量\(q_i\),\(f_{ui}\) 的导数由下式给出:

\(\begin{align*} \frac{\partial f_{ui}}{\partial q_i} = \frac{\partial}{\partial q_i} (r_{ui} - p_u \cdot q_i)^2 = - 2 p_u (r_{ui} - p_u \cdot q_i) \end{align*}\)

那么,SGD 过程就变为:

  1. 随机初始化所有的向量\(p_u\) 和\(q_i\)。
  2. 对给定的次数(如,迭代数),重复下面的步骤:
  • 对所有已知的评级\(r_{ui}\),重复下面的步骤:
    • 计算\(\frac{∂f_{ui}}{∂p_u}\) 和\(\frac{∂{f_{ui}}}{∂q_i}\)
    • 更新\(p_u\) 和\(q_i\):\(p_u←p_u+α⋅q_i(r_{ui}−p_u ⋅q_i)\),\(q_i←q_i+α⋅p_u(r_{ui}−p_u⋅q_i)\) 。这里我们避免了乘以常数 2,而是把它合并到了学习速率\(α\) 中。

注意在这个算法中,\(p_u\)(和\(q_i\))中的不同因子都是同时更新的。Funk 的原始算法有些不同:他实际上训练了第一个因子,然后是第二个因子,然后是第三个,等等,这使得他的算法更加符合 SVD 风格。关于这一点,可以在 Aggarwal 的关于推荐系统的教科书中找到很好的讨论。

一旦计算了所有的\(p_u\) 和\(q_i\) 向量,我们可以用下面的公式来预测评级:

\(\begin{align*} \hat{r}_{ui}=p_u⋅q_i. \end{align*}\)

\(\hat{r}_{ui}\) 是一个估计值,而不是实际值。

降维

在进入 Python 算法实现部分之前,我们还要决定一个事情:向量\(p_u\) 和\(q_i\) 的大小应是多少?我们很确认的是,所有向量\(p_u\) 和\(q_i\) 的长度要相同,否则我们不能进行点积运算。

为了回答这个问题,我们回顾一下第一部分里的 PCA 和令人毛骨悚然的图像。那些令人毛骨悚然的图像能重构所有的原始脸部图像。

\(\begin{align*} \text{Face 1}~=~&\alpha_1 \cdot \color{#048BA8}{\text{Creepy guy #1}}\\ +~ &\alpha_2 \cdot \color{#048BA8}{\text{Creepy guy #2}}\\ +~ &\cdots\\ +~ &\alpha_{400} \cdot \color{#048BA8}{\text{Creepy guy #400}} \end{align*}\)

\(\begin{align*} \text{Face 2}~=~&\beta_1 \cdot \color{#048BA8}{\text{Creepy guy #1}}\\ +~ &\beta_2 \cdot \color{#048BA8}{\text{Creepy guy #2}}\\ +~ &\cdots\\ +~ &\beta_{400} \cdot \color{#048BA8}{\text{Creepy guy #400}} \end{align*}\)

但是,实际上,我们不需要用到所有的特征图像来很好地近似每一个原始脸部。我实际上撒谎了:第一部分里你所看到的动态图只用到了前 200 个令人毛骨悚然的图像(而不是 400 个)。但你看不出区别,是吧?为了进一步说明这一点,这里是第一个原始脸部的重建,用到了 1 到 400 个令人毛骨悚然的图像,每次增加 40 个令人毛骨悚然的图像进入重建。

最后一张图片是最完美的重建,你可以看到,仅仅用 80 个特征图像(第三个图片)就足够识别原始的脸部。

你可能想知道为什么第一张照片看起来不像第一个令人毛骨悚然的图像,为什么令人毛骨悚然的图像比原始图片有更高的对比度:这是因为 PCA 首先减去所有图像的平均值。你看到的第一张图像实际上对应于第一个令人毛骨悚然的图像加上平均脸的贡献。如果这对你没有意义,不用担心。我们在做推荐时并不在乎这些。

所以这里给出的信息是:你不需要所有的令人毛骨悚然的或典型的图像来很好地近似初始矩阵。SVD 和推荐问题同样如此:你不需要所有典型的电影或所有典型的用户来获得良好的近似值

这意味着,当我们把用户和项目表示为下面的形式时(SVD 能给出这些):

\(\begin{align*} % <![CDATA[ \begin{array}{ll} \text{Alice} & = 10\% \color{#048BA8}{\text{ 动作电影迷}} &+ 10\% \color{#048BA8}{\text{ 喜剧电影迷}} &+ 50\% \color{#048BA8}{\text{ 浪漫电影迷}} &+\cdots\\ \text{Bob} &= 50\% \color{#048BA8}{\text{ 动作电影迷}}& + 30\% \color{#048BA8}{\text{ 喜剧电影迷}} &+ 10\% \color{#048BA8}{\text{ 浪漫电影迷}} &+\cdots\\ \text{Titanic} &= 20\% \color{#048BA8}{\text{ 动作片}}& + 00\% \color{#048BA8}{\text{ 喜剧片}} &+ 70\% \color{#048BA8}{\text{ 浪漫片}} &+\cdots\\ \text{Toy Story} &= 30\% \color{#048BA8}{\text{ 动作片 }} &+ 60\% \color{#048BA8}{\text{ 喜剧片}}&+ 00\% \color{#048BA8}{\text{ 浪漫片}} &+\cdots\\ \end{array} %]]> \end{align*}\)

我们可以用一小部分典型的电影或用户得到一个良好的解决方案。这里,我们将把\(p_u\) 和\(q_i\) 的大小限制在 10。也就是说,我们将只考虑 10 个潜在因子。

你有权对此持怀疑态度,但实际上我们对这个近似值有很好的保证。关于 SVD(和 PCA)的一个奇妙的结果是,当我们仅使用 k 个因子时,我们获得原始矩阵的最佳低阶近似(了解少数因子)。尽管细节很有趣,答案是有点技术性的,超出了本文的范围(虽然很有趣),所以我推荐斯坦福大学课程笔记(事实 4.2)。(注意:在课程笔记的第 5 节中,作者提出了一种从 SVD 中恢复缺失条目的方法,这种启发式技术是我们上面提到的技术,但它不是最有效的方法。推荐中最有效的方法是优化已知的评级,正如我们用 SGD 所做的那样)。

第四部分:算法实现

在前一部分,我们已经描述了如何用随机梯度下降方法寻找 SVD 问题的近似解。其步骤如下:

  1. 随机初始化所有的向量\(p_u\) 和\(q_i\),每个向量长度为 10。
  2. 对给定的次数(如,迭代数),重复下面的步骤:
  • 对所有已知的评级\(r_{ui}\),重复下面的步骤:
    • 计算\(\frac{∂f_{ui}}{∂p_u}\) 和\(\frac{∂{f_{ui}}}{∂q_i}\)
    • 更新\(p_u\) 和\(q_i\):\(p_u←p_u+α⋅q_i(r_{ui}−p_u ⋅q_i)\), \(q_i←q_i+α⋅p_u(r_{ui}−p_u⋅q_i)\)。这里我们避免了乘以常数 2,而是把它合并到了学习速率\(α\) 中。

下面是该算法的 Python 实现。

复制代码
def SGD(data):
'''Learn the vectors p_u and q_i with SGD.
data is a dataset containing all ratings + some useful info (e.g. number
of items/users).
'''
n_factors = 10 # number of factors
alpha = .01 # learning rate
n_epochs = 10 # number of iteration of the SGD procedure
# Randomly initialize the user and item factors.
p = np.random.normal(0, .1, (data.n_users, n_factors))
q = np.random.normal(0, .1, (data.n_items, n_factors))
# Optimization procedure
for _ in range(n_epochs):
for u, i, r_ui in data.all_ratings():
err = r_ui - np.dot(p[u], q[i])
# Update vectors p_u and q_i
p[u] += alpha * err * q[i]
q[i] += alpha * err * p[u]

一共只有 10 行代码,非常简洁。

一旦我们运行 SGD 过程,就可以估计所有的向量\(p_u\) 和\(q_i\)的点积来预测所有的评级:

复制代码
def estimate(u, i):
'''Estimate rating of user u for item i.'''
return np.dot(p[u], q[i])

就这样,我们的任务完成了。想要自己尝试吗? 我做了一个小的 ipython notebook ,可以在一个实际的数据集上运行该算法。我们将用到 surprise 库,它是一个很好的库,用于快速实施评级预测算法(但我可能会带有偏见,因为我是 surprise 的主要开发人员)。要使用它,你只需要先使用 pip 来安装它:

pip install scikit-surprise我们的算法性能如何?

正如你在 notebook 上可以看到的,我们得到约 0.98 的 RMSE 值。RMSE 表示均方根误差,计算公式如下:

\(\begin{align*} \text{RMSE} = \sqrt{\sum_{u,i} (\hat{r}_{ui} - r_{ui})^2}. \end{align*}\)

你可以将其视为某种平均错误,其中大的错误被严重处罚(因为它们是平方的)。RMSE 为 0 意味着所有的预测都是完美的。RMSE 为 0.5 意味着,平均而言,每个预测值偏离实际值 0.5。

那么 0.98 是一个好的 RMSE?其实真的不是那么糟糕。如该 notebook 所示,某邻域算法只能达到 1 的 RMSE。一个更复杂的MF 算法(与我们的算法相同,除了评级是无偏的和我们使用了正则化)可以达到约0.96 的RMSE。这个算法在文献中被称为“SVD”,但现在你知道它不能是一个真正的SVD,因为有缺失的评级。它只是(大部分)灵感来自SVD。

总结

我希望你现在可以了解到PCA 和SVD 之美,以及如何调整SVD 用于解决推荐问题。如果你想体验一下 surprise 库,文档中有很多很酷的东西。

如果你发现本文有用(或没用),请在我的博客的评论中告诉我。文中的任何错误都是我自己的,非常感谢任何形式的批评。

感谢 Pierre Poulain 对本文的宝贵意见。

参考文献

本文中,我试图避免理论考虑,而提供可视化的具体例子来说明如何使用 PCA 和 SVD。但理论方面不容忽视,它其实很有趣。如果你想进一步深入了解到目前为止涵盖的各种主题,可能需要阅读一些资源:

  • Jeremy Kun PCA SVD 上的帖子很棒。这篇文章的第一部分受到这两个帖子的启发。
  • 我个人认为, Aggarwal 关于推荐系统的教科书是最好的推荐系统资源。你可以找到关于各种矩阵分解变体的细节,其中涵盖了大量其他科目。
  • 如果你想更多地了解“SVD”算法及其可能的扩展,请从 BellKor 团队查看文章。他们获得了 100 万美元的 Netflix 奖金。
  • PCA 可以用于很多有趣的东西,而不仅仅是乱七八糟的脸部。Jonathon Shlens 的教程提供了对 PCA 作为对角化过程的深刻见解,并提供了与 SVD 的链接。此外,斯坦福大学课程笔记涵盖了我们提出的一些主题(低等级近似,等),更加以理论为导向。
  • 对于线性代数,唯一值得阅读的书是 Gilbert Strang 的“ LA 简介”。他的麻省理工学院课程的技术含金量也很高。
  • 当然, Surprise 是一个很好的推荐系统库(但是我也可能带有偏见)。

查看英文原文: Understanding matrix factorization for recommendation


感谢蔡芳芳对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2017 年 10 月 16 日 17:458532
用户头像

发布了 21 篇内容, 共 73766 次阅读, 收获喜欢 2 次。

关注

评论

发布
暂无评论
发现更多内容

「架构师训练营 4 期」 第一周 - 001002

凯迪

架构师训练营—大作业(一)

Geek_shu1988

交报告 | 2020年读完的50本书

浪亦有道

意想不到,这个神奇的bug让我加班到深夜

码农的荒岛求生

bug修复

2021你好 | 一名五道口程序员的年终总结

herongwei

程序员 职场 自媒体 年终总结 新年

「架构师训练营 4 期」 第一周 - 1001

凯迪

时间戳——区块链不可篡改特性的重中之重

CECBC区块链专委会

区块链

探讨典型互联网系统使用的技术方案

andy

架构师大作业

_

大作业 架构师训练营第 1 期

架构师训练营- 食堂就餐卡系统设计

Geek_d7f0e4

IPFS矿机软件系统开发|IPFS矿机APP开发

开發I852946OIIO

系统开发

微信气质

池建强

微信

想法

BerryMew

【计算机内功修炼】一:看完这篇还不懂线程与线程池你来打我

码农的荒岛求生

高并发 线程池 进程 高性能 线程’

现成花火交易所系统软件APP开发案例

开發I852946OIIO

系统开发

架构入门感悟之十一

莫问

架构师训练营第十一周作业

李日盛

架构师训练营—大作业(二)

Geek_shu1988

架构师训练营—第十三周作业

Geek_shu1988

架构师训练营—第十三周学习总结

Geek_shu1988

从考研失败到最具成长力员工,这个2020就像过山车一样

Java鱼仔

程序员 面试 程序人生 考研

第一周架构方法-练习-食堂就餐卡系统设计

潘涛

架构师训练营 4 期

上地七街

潇潇雨歇

区块链游戏开发注意事项

CECBC区块链专委会

区块链 区块链游戏

在 Emit 代码中如何await一个异步方法

八苦-瞿昙

架构师训练营第十一周笔记

李日盛

笔记

2020年Python文章盘点,我选出了个人TOP10

Python猫

Python 学习 编程 技术

RocketMQ避坑指南:你部署的RocketMQ集群真的是高可用?

公众号『中间件兴趣圈』

架构 RocketMQ 故障分析 消息队列

【计算机内功修炼】二:读取文件时,程序经历了什么

码农的荒岛求生

后端 文件 操作系统 进程 线程’

数字版权资源价值日益凸显

CECBC区块链专委会

版权保护

一次线上cpu过高问题

kcnf

微服务架构下如何保证事务的一致性

微服务架构下如何保证事务的一致性

一篇长文教你学会推荐系统的矩阵分解-InfoQ