Python-与-Jax-现代推荐系统构建指南-三-

134 阅读1小时+

Python 与 Jax 现代推荐系统构建指南(三)

原文:annas-archive.org/md5/da17d05291861831978609329c481581

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:个性化推荐度量

在探索了 MF 和神经网络在个性化上下文中的强大方法后,我们现在装备有强大的工具来打造复杂的推荐系统。然而,在列表中的推荐顺序可能对用户参与度和满意度产生深远影响。

到目前为止,我们的旅程主要集中在预测用户可能喜欢的内容,使用潜在因素或深度学习架构。然而,我们如何呈现这些预测结果,或者更正式地说,我们如何排列这些推荐,这些都具有至关重要的意义。因此,这一章将把我们的视线从预测问题转向排名在推荐系统中的复杂景观。

本章专注于理解关键的排名度量标准,包括平均精度(mAP)、平均倒数排名(MRR)和归一化折损累积增益(NDCG)。这些度量标准各自采用独特的方法来量化我们排名的质量,满足用户交互的不同方面。

我们将深入研究这些度量标准的复杂性,揭示它们的计算细节,讨论它们的解释,涵盖它们的优势和劣势,并指出它们在各种个性化场景中的具体相关性。

这种探索是推荐系统评估过程的一个重要组成部分。它不仅为我们提供了一个强大的框架来衡量系统的性能,还提供了理解不同算法在在线设置中可能表现的重要见解。这将为未来讨论算法偏见、推荐多样性以及推荐系统的多利益相关者方法奠定基础。

实质上,本章所获得的知识将对微调我们的推荐系统至关重要,确保我们不仅预测得好,而且推荐方式真正与个体用户的偏好和行为产生共鸣。

环境

在我们深入定义关键度量标准之前,我们将花一些时间讨论我们可以进行的评估类型。正如您很快会看到的那样,推荐系统的评估通常通过推荐对用户的相关性来特征化。这与搜索度量类似,但我们加入了额外因素来考虑最相关项在列表中的位置

对于推荐系统评估的极其全面的视角,最近的项目RecList构建了一个基于检查表的有用框架,用于组织度量和评估。

通常你会听到在几种设置下评估推荐系统:

  • 在线/离线

  • 用户/物品

  • A/B

每种设置提供略有不同类型的评估,并告诉您不同的信息。让我们快速分解这些差异,以建立有关术语的一些假设。

在线和离线

当我们提到在线与离线推荐器时,我们指的是何时运行评估。在离线评估中,您从测试/评估数据集开始,该数据集位于生产系统之外,并计算一组指标。这通常是设置最简单的推荐器,但对现有数据的期望最高。使用历史数据,您构建了一组相关响应,然后可以在模拟推断期间使用。这种方法与其他种类的传统机器学习最相似,尽管对误差的计算略有不同。

当我们训练大型模型时,这些数据集类似于离线数据集。我们之前看到了前序数据,这在推荐系统中比许多其他机器学习应用程序更为相关。有时您会听到人们说“所有推荐系统都是序列推荐系统”,因为历史曝光对推荐问题的重要性。

在线评估发生在推断期间,通常是在生产环境中。棘手的部分在于,你基本上永远不知道反事实的结果。您可以计算在线排名的特定指标:协变量的频率和分布,点击率/成功率,或平台上的停留时间,但最终这些指标与离线指标有所不同。

从历史评估数据中自举

从零开始构建推荐系统的人最常问的一个问题是“你从哪里获取初始训练数据?”这是一个难题。最终,您必须聪明地构建一个有用的数据集。考虑我们在维基百科推荐系统中的共现数据;我们并不需要任何用户交互来获取一组用于构建推荐系统的数据。从项目到项目的自举是最流行的策略,但您也可以使用其他技巧。进入用户-项目推荐器的最简单方法是简单地询问用户问题。如果您要求跨一组项目特征的偏好信息,您可以构建开始融入此信息的简单模型。

用户与项目指标

因为推荐系统是个性化机器,很容易认为我们总是希望为用户提供推荐并衡量其性能。然而,存在微妙之处。我们要确保每个单独的项目都有公平的机会,并且有时审视方程的另一面可以帮助评估这一点。换句话说,推荐的项目是否频繁推荐到足够有机会找到自己的定位?我们应明确计算我们的指标跨用户 项目轴。

项目方面度量的另一个方面是基于集合的推荐器。在上下文中推荐的其他项目可以显著影响推荐的性能。因此,在我们的大规模评估中,我们应该谨慎地测量成对项目指标。

A/B 测试

对于评估新推荐模型的性能,使用随机对照试验是很好的。对于推荐来说,这非常棘手。在本章的最后,您将看到一些细微之处,但现在,让我们考虑一下在闭环范式中如何思考 A/B 测试的一个快速提醒。

A/B 测试最终试图估计将一个模型替换为另一个模型的效果大小;效果大小估计是衡量干预对目标指标的因果影响的过程。首先,我们需要部署两个推荐模型。我们也希望用户在每个推荐系统中有合理的随机分配。但是,随机分配单位是什么?很容易迅速假设它是用户,但是推荐系统发生了什么变化?推荐系统发生了什么变化,使得它与分布的某些属性相关联——例如,您是否构建了一个新的推荐系统,该系统对于季节性电视特别节目的友好程度较低,就在我们进入十一月的第二周?

还有一种考虑推荐系统这种测试的方式是长期复利效应。关于几年来一系列积极的 A/B 测试结果的常见反驳是“你是否测试了第一个推荐系统和最后一个推荐系统之间的差异?”这是因为人口会变化,用户和项目都会变化。当您也改变推荐系统时,您经常会发现自己处于双盲情况下,在这种情况下,您从未看到过这个用户或项目群体与任何其他推荐系统。如果每个 A/B 测试的效果大小在整个行业中都是相加的,那么世界 GDP 可能会增加两到三倍。

抵制这种抗议的方法是通过长期保留,这是一组随机选择的用户(不断增加),他们不会随时间升级到新模型。通过在此集合上测量目标指标与生产中最前沿的模型相比,您始终能够了解您工作的长期影响。长期保留的缺点是什么?它

现在让我们最终谈谈指标吧!

召回率和精确度

让我们首先考虑四个推荐问题以及每个问题对你想要的结果的不同影响。

首先,让我们考虑进入书店并寻找一本知名作者的书。我们会说这是推荐问题:

  • 提 提供了大量的推荐

  • 提供了一些可能的相关结果

此外,如果书店的选择很好,我们期望所有相关结果都包含在推荐中,因为一旦作者变得受欢迎,书店通常会携带大多数或所有作者的作品。然而,许多推荐——书店里的书——对于这个搜索来说根本不相关。

其次,让我们考虑在大都会地区使用地图应用程序附近找加油站。我们预期附近有很多加油站,但你可能只考虑前几个—或者甚至只有一个,你看到的第一个。因此,这个问题的推荐器有以下特点:

  • 许多相关结果。

  • 很少有用的建议。

在第一个情景中,相关结果可能完全包含在建议中;而在第二个情景中,建议可能完全包含在相关结果中。

现在让我们看看更常见的情景。

对于我们的第三个示例,请考虑您在流媒体视频平台上搜索今晚想要观看的浪漫电影。流媒体平台往往会显示很多建议—一页又一页来自这个或那个主题的建议。但是在这个夜晚,在这个平台上,只有几部电影或电视节目可能真正符合您的要求。因此,我们的推荐器做以下事情:

  • 提供了许多建议。

  • 仅提供了一些实际相关的建议。

但是,重要的是,并非所有相关结果都会出现在建议中!正如我们所知,不同的平台具有不同的媒体,所以一些相关结果无论我们查看多少都不会出现在建议中。

第四,最后,您是一位高端咖啡爱好者,品味高雅,正前往当地的烘焙商店品尝第三浪、单品种咖啡。作为经验丰富的咖啡鉴赏家,您喜欢来自世界各地的高质量咖啡,大多数都很享受但并非所有产地。在任何给定的一天,您的本地咖啡馆只有几种单品种手冲咖啡。尽管您有着世界性的口味,但有一些受欢迎的产地您并不喜欢。这个小小的推荐啤酒吧可以描述如下:

  • 提供了一些建议。

  • 提供了许多可能相关的建议。

在任何给定的一天,只有一些建议可能与您相关。

所以这些是我们匹配的四个情景。对于后两种情况,建议和相关性之间的交集可能很小或很大—甚至可能为空!主要思想是较小样本的完整大小并不总是在使用中。

现在我们已经通过了一些示例,让我们看看它们与推荐器的核心指标之间的关系:精度和召回率 @ k(图 11-1)。关注示例 3 和 4,我们可以看到只有一些建议与相关选项相交。而只有一些相关选项与建议相交。它经常被忽视,但事实上这两个比率定义了我们的指标—让我们去吧!

检索问题中的集合

图 11-1. 召回和精度集合

@ k

在本章和推荐系统度量讨论的大部分内容中,我们会说“@ k”。这意味着“在k”,实际上应该是“在k中”或“在k之外”。这些只是建议集的大小。我们经常将客户体验锚定在我们可以展示给用户多少建议而不会影响体验。我们还需要知道相关项集合的基数,我们称之为*@ r*。请注意,虽然可能感觉永远无法知道这个数字,但我们假设这是指通过我们的训练或测试数据知道的“已知相关”选项。

Top-k 精度

精度是相关建议集大小与建议集合k大小的比率。

P r e c i s i o n @ k = num relevant (k)

注意,相关项的大小在公式中并不出现。没关系;交集的大小仍然取决于相关项集合的大小。

看看我们的例子,技术上例子 2 有最高的精度,但由于相关结果的数量,它有点误导人。这是精度不是评估推荐系统最常见的度量标准的一个原因。

k处的召回率

召回率是相关建议集大小与相关项集合r大小的比率。

但等等!如果比率是相关建议与相关项的比率,那么k在哪里?在这里k仍然很重要,因为建议集的大小限制了可能的交集大小。请记住,这些比率是在始终依赖于k的交集上操作的。这意味着你经常考虑rk的最大值。

在第 3 个场景中,我们希望一些符合我们心愿的电影会出现在正确的流媒体平台上。这些电影的数量除以所有媒体的总数就是召回率。如果所有相关的电影都在这个平台上,你可能会称之为全面召回

第 4 场景的咖啡馆体验表明,召回率有时是一个避免的反向概率;因为你喜欢这么多的咖啡,我们可能更容易谈论你不喜欢的事物。在这种情况下,提供的避免数量将对召回率产生很大影响:

R e c a l l @ k = (k-Avoid@k) num relevant

这是召回率的核心数学定义,通常是我们考虑的第一个衡量标准,因为它纯粹地估计了你的检索性能如何。

R-精度

如果我们在建议上也有排名,我们可以在top-r建议中考虑相关建议到r的比率。这在r非常小的情况下(例如示例 1 和 3)改进了这个度量标准。

mAP、MMR、NDCG

深入研究了精度@k和召回率@k的可靠领域后,我们对我们推荐系统的质量获得了宝贵的见解。然而,尽管这些指标至关重要,有时却无法完全捕捉这些系统的一个重要方面:推荐顺序

在推荐系统中,我们提供建议的顺序具有重要的权重,需要评估以确保其有效性。

这就是为什么我们现在将超越 precision@k 和 recall@k,探索一些关键的排名敏感性指标——即平均精度(mAP)、平均倒数排名(MRR)和归一化折现累计增益(NDCG)。

mAP 指标强调每个相关文档及其位置的重要性,而 MRR 则集中于第一个相关项的排名。NDCG 更重视高排名的相关文档。通过了解这些指标,您将拥有更强大的工具来评估和优化推荐系统。

因此,让我们继续探索,平衡精确性与易懂性。到本节末,您将能够自信而且有见地地处理这些重要的评估方法。

mAP

在推荐系统中,这一重要的指标尤其擅长考虑相关项的排名。例如,在五个项目的列表中,如果相关项目分别位于位置 2、3 和 5,那么 mAP 将通过计算 precision@2、precision@3 和 precision@5 并对这些值取平均来计算。mAP 的强大之处在于其对相关项顺序的敏感性,当这些项排名较高时,提供更高的分数。

考虑一个具有两个推荐算法 A 和 B 的示例:

  • 对于算法 A,我们计算 mAP 如下:

    (precision@2 + precision@3 + precision@5)/ 3 = (1/2 + 2/3 + 3/5)/ 3 = 0.6

  • 对于完美排列项目的算法 B,我们计算 mAP 如下:

    mAP = (precision@1 + precision@2 + precision@3)/ 3 = (1/1 + 2/2 + 3/3)/ 3 = 1

mAP 在查询集合 Q 上的广义公式如下所示:

m A P = 1 |Q| ∑ q=1 |Q| 1 m q ∑ k=1 n P ( k ) * r e l ( k )

这里,| Q | 是查询总数,m q 是特定查询 q 的相关文档数,P ( k ) 表示截止到第 k 个位置的精确度,r e l ( k ) 是一个指示函数,如果第 k 个位置的项目是相关的,则等于 1,否则为 0。

MRR

另一个在推荐系统中使用的有效度量标准是 MRR。与考虑所有相关项目的 MAP 不同,MRR 主要关注推荐列表中第一个相关项目的位置。它被计算为第一个相关项目出现的排名的倒数。

因此,如果列表中的第一个项目是相关的,MRR 可以达到其最大值 1。如果第一个相关项目在列表中的位置更靠后,那么 MRR 将小于 1。例如,如果第一个相关项目位于排名 2 的位置,那么 MRR 将为 1/2。

让我们看看这在我们先前使用的推荐算法 A 和 B 的背景下的情况:

  • 对于算法 A,第一个相关项目位于排名 2,因此 MRR 等于 1/2 = 0.5。

  • 对于完美排名项目的算法 B,第一个相关项目位于排名 1,因此 MRR 等于 1/1 = 1。

将这个推广到多个查询,MRR 的一般公式如下:

M R R = 1 |Q| ∑ i=1 |Q| 1 rank i

这里,|Q| 表示查询的总数,r a n k i 是列表中第 i 个查询的第一个相关项目的位置。这个度量标准提供了有价值的见解,用于评估推荐算法在列表顶部提供相关推荐的效果。

NDCG

为了进一步完善我们对排名指标的理解,让我们深入了解 NDCG。和 mAP 和 MRR 一样,NDCG 也承认相关项目的排名顺序,但引入了一个变化。它随着我们在列表中移动到更低的排名,递减项目的相关性,这意味着在列表中出现较早的项目比排名较低的项目更有价值。

NDCG 从累积增益(CG)的概念开始,它简单地是列表中前 k 个项目的相关性得分之和。折现累积增益(DCG)更进一步,根据项目的位置对每个项目的相关性进行折现。因此,NDCG 是由理想折现累积增益(IDCG)标准化的 DCG 值,如果所有相关项目出现在列表的最顶部,我们将获得的 DCG 值。

假设我们的列表中有五个项目,特定用户的相关项目位于位置 2 和 3,那么 IDCG@k 将会是 (1/log(1 + 1) + 1/log(2 + 1)) = 1.5 + 0.63 = 2.13。

让我们将这放到我们先前使用的算法 A 和 B 的背景中。

对于算法 A

  • DCG@5 = 1/log(2 + 1) + 1/log(3 + 1) + 1/log(5 + 1) = 0.63 + 0.5 + 0.39 = 1.52

  • NDCG@5 = DCG@5 / IDCG@5 = 1.52 / 2.13 = 0.71

对于算法 B

  • DCG@5 = 1/log(1 + 1) + 1/log(2 + 1) + 1/log(3 + 1) = 1 + 0.63 + 0.5 = 2.13

  • NDCG@5 = DCG@5 / IDCG@5 = 2.13 / 2.13 = 1

NDCG 的一般公式可以表示为

  • N D C G @ k = DCG@k IDCG@k

其中

  • D C G @ k = ∑ i=1 k rel i log 2 (i+1)

  • I D C G @ k = ∑ i=1 |ℛ| 1 log 2 (i+1)

并且 ℛ 是相关文档的集合。

这个度量标准为我们提供了一个归一化分数,用于衡量我们的推荐算法在排名相关项目方面的表现,随着列表的向下移动而递减。

mAP 与 NDCG 的比较?

mAP 和 NDCG 都是综合评估排名质量的全面指标,通过包含所有相关项目及其相应的排名提供了全面的视角。然而,这些指标的可解释性和使用案例可以根据推荐背景的具体情况和相关性的性质而变化。

虽然 MRR 不考虑所有相关项目,但它确实提供了对算法性能的可解释洞察,突出显示第一个相关项目的平均排名。当最高推荐具有显著价值时,这尤为有用。

另一方面,mAP 是一个丰富的评估指标,有效地表示了精确率-召回率曲线下的面积。它的平均特性提供了一个直观的解释,涉及在不同排名截断下精确率和召回率之间的权衡。

NDCG 引入了对每个项目相关性的强健考虑,并对排名顺序敏感,使用对数折现因子来量化随着我们在列表中向下移动项目的递减重要性。这使得它能够处理项目具有不同程度相关性的情况,超越了 mAP 和 MRR 中常用的二元相关性。然而,NDCG 的这种多功能性也可能由于对数折现的复杂性而限制其可解释性。

此外,尽管 NDCG 在项目具有不同重要性权重的使用案例中表现良好,但在实际应用中获取准确的地面真实相关性评分可能构成重大挑战。这对于 NDCG 的现实世界有效性施加了限制。

总体而言,这些指标构成了推荐算法离线评估方法的基础。随着我们在探索中的进展,我们将涵盖在线评估,讨论评估和减轻算法偏差的策略,了解确保推荐多样性的重要性,并优化推荐系统以满足生态系统中各方的需求。

相关系数

虽然像 Pearson 或 Spearman 这样的相关系数可以用来评估两个排名之间的相似性(例如,预测排名与地面真实排名之间),但它们并不像 mAP、MRR 或 NDCG 那样提供完全相同的信息。

相关系数通常用于衡量两个连续变量之间的线性关联程度,在排名的背景下,它们可以指示两个有序列表之间的整体相似性。然而,它们并未直接考虑诸如个别项目的相关性、相关项目的位置或项目间不同程度的相关性等方面,这些对于 mAP、MRR 和 NDCG 非常重要。

例如,假设用户过去与五个项目互动过。推荐系统可能预测用户会再次与这些项目互动,但会按重要性相反的顺序排名。即使系统正确识别了感兴趣的项目,但由于排名颠倒,根据 mAP、MRR 或 NDCG 测量会导致性能较差,但由于线性关系,会获得较高的负相关系数。

因此,虽然相关系数可以提供对排名性能的高层次理解,但它们不足以取代像 mAP、MRR 和 NDCG 这样提供更详细信息的度量。

要在排名背景下利用相关系数,关键是将其与其他考虑推荐问题特定细微差别的度量配对,例如个别项目的相关性及其在排名中的位置。

RMSE 来自亲和力

均方根误差(RMSE)和 mAP、MRR 和 NDCG 等排名指标,在评估输出亲和力分数的推荐系统时,提供了根本不同的视角。

RMSE 是量化预测误差的常用指标。它计算预测的亲和力分数与真实值之间平方差的平均值的平方根。较低的 RMSE 表示更好的预测精度。但是,RMSE 将问题视为标准回归任务,并忽视了推荐系统中的固有排名结构。

相反,mAP、MRR 和 NDCG 明确设计用于评估排名的质量,在推荐系统中至关重要。本质上,虽然 RMSE 衡量预测亲和力分数与实际值的接近程度,但 mAP、MRR 和 NDCG 通过考虑相关项的位置来评估排名质量。因此,如果您关注的是排名项目而不是预测精确的亲和力分数,则通常应选择这些排名指标更为合适。

积分形式:AUC 和 cAUC

在推荐系统中,我们为每个用户生成一个项目的排名列表。正如您所见,这些排名基于亲和力,即用户对每个项目的偏好或优先级的概率。在这个框架下,已经开发了几个度量来评估这些排名列表的质量。其中一个度量是 AUC-ROC,它与 mAP、MRR 和 NDCG 相辅相成。让我们更仔细地了解这些度量。

推荐概率到 AUC-ROC

在二元分类设置中,接收器工作特征曲线下的面积(AUC-ROC)衡量推荐模型区分正(相关)和负(不相关)实例的能力。它通过在各种阈值设置下绘制真正例率(TPR)与假正例率(FPR)曲线,然后计算此曲线下的面积来计算。

在推荐的背景下,你可以将这些“阈值”视为将向用户推荐的前几个项目的数量。AUC-ROC 指标变成了评估模型在排列相关项目和不相关项目时的表现,而与实际排名位置无关。换句话说,AUC-ROC 有效地量化了模型随机选择的相关项目被排在比随机选择的不相关项目更高的概率。然而,这并不考虑项目在列表中的实际位置或顺序,只考虑了正例与负例的相对排名。通过校准项目的亲和力可以被解释为模型对项目相关性的置信度,并且在考虑历史数据时,即使是未校准的亲和力分数也可能成为发现有用内容所需的推荐数量的很好建议。

这些亲和力分数的一个严肃实现可能是只向用户展示超过特定分数的项目,否则告诉他们稍后再来或使用探索方法来改进数据。例如,如果你销售卫生产品,并且正在考虑在结账时询问客户添加一些 Aesop 肥皂,你可能希望评估 Aesop ROC,并且仅当观察到的亲和力超过学习阈值时才进行此建议。您还将在之后看到这些概念在“库存健康”中的应用。

与其他指标的比较

让我们将这些与其他指标放在一起来看:

mAP

这个指标扩展了在排名列表中特定截止点处的精度的思想,以提供模型性能的总体度量。它通过计算每个相关项目被发现的排名处的精度值的平均值来实现这一点。与 AUC-ROC 不同,mAP 更加强调排名较高的项目,并且对排名顶部的变化更为敏感。

MRR

与 AUC-ROC 和 mAP 不同,MRR 只关注列表中第一个相关项目的排名。它衡量模型能够多快地找到相关项目。如果模型始终将相关项目置于列表顶部,那么它的 MRR 将更高。

NDCG

该指标评估了排名的质量,不仅考虑了推荐的顺序,还考虑了项目的分级相关性(而前面的指标没有考虑)。NDCG 降低了列表下面的项目的权重,奖励出现在列表顶部附近的相关项目。

AUC-ROC 提供了一个有价值的综合度量,用于衡量模型区分相关和不相关项目的能力;mAP、MRR 和 NDCG 提供了对模型排名质量更细致的评估,考虑了位置偏差和不同相关程度等因素。

请注意,我们有时会计算每个客户的 AUC 然后取平均值。这就是客户 AUC(cAUC),它经常可以为用户的体验提供一个良好的期望。

BPR

贝叶斯个性化排名(Bayesian personalized ranking,BPR)提出了在推荐系统中进行项目排名的贝叶斯方法,有效地提供了一个概率框架来模拟个性化排名过程。与将项目推荐问题转化为二元分类问题(相关或不相关)不同,BPR 专注于成对偏好:给定两个项目,用户更喜欢哪个?这种方法更符合推荐系统中常见的隐式反馈的性质。

BPR 模型使用成对损失函数,考虑了特定用户对正向项目和负向项目的相对顺序。它旨在最大化观察到的排名正确的后验概率。该模型通常使用随机梯度下降或其变体进行优化。需要注意的是,BPR(与我们讨论过的其他指标,包括 AUC-ROC、mAP、MRR 和 NDCG 不同)是一个模型训练目标,而不是一个评估指标。因此,虽然前述的指标评估模型训练后的性能,但 BPR 提供了一种机制,以直接优化排名任务的方式来引导模型学习过程。关于这些主题的更深入讨论详见“BPR: Bayesian Personalized Ranking from Implicit Feedback” by Steffen Rendle et al.

概要

现在,你已经知道如何评估你训练的推荐系统的性能,或许你会想知道如何实际训练它们。你可能已经注意到,我们介绍的许多指标并不适合作为损失函数;它们涉及关于项目集和项目列表的大量同时观察。不幸的是,这会使推荐者从中学习的信号高度组合。此外,我们提出的指标确实有两个方面需要考虑:与召回相关联的二元指标和排名加权。

在下一章中,你将学习一些优秀的训练目标损失函数。我们相信这些的重要性不会被忽视。

第十二章:排名的训练

典型的 ML 任务通常预测单一结果,例如分类任务中属于正类的概率,或者回归任务中的期望值。而排名则提供物品集合的相对排序。这种任务是搜索结果或推荐中常见的,其中呈现的物品顺序很重要。在这些问题中,物品的分数通常不会直接显示给用户,而是通过物品的序数排名—可能是隐式地—呈现:列表顶部的物品编号低于下一个物品。

本章介绍了 ML 算法在训练过程中可以使用的各种损失函数。这些分数应该估计列表排序,使得与训练数据集中观察到的相关性排序相比,结果更接近的集合。在这里,我们将重点介绍概念和计算,这些内容将在下一章中投入使用。

推荐系统中的排名在哪里适用?

在我们深入讨论排名的损失函数细节之前,我们应该谈谈排名在整个推荐系统中的位置。典型的大规模推荐系统有一个检索阶段,在此阶段使用廉价函数将大量候选项收集到候选集中。通常,此检索阶段仅基于物品。例如,候选集可能包括用户最近消费或喜欢的物品相关的物品。或者如果新鲜度很重要,比如对于新闻数据,该集合可能包括最新的热门和相关物品。物品被收集到候选集后,我们对其物品应用排名。

另外,由于候选集通常比整个物品语料库小得多,我们可以使用更昂贵的模型和辅助特征来帮助排名。这些特征可以是用户特征或上下文特征。用户特征可以帮助确定物品对用户的有用性,例如最近消费物品的平均嵌入。上下文特征可以指示当前会话的详细信息,例如一天中的时间或用户最近键入的查询,这些特征区别于其他会话,并帮助确定相关物品。最后,我们有物品本身的表示,可以是从内容特征到学习嵌入的任何东西。

然后将用户、上下文和物品特征连接成一个特征向量,我们将用它来表示物品;然后一次评分所有候选项并对其排序。然后可能对排序集合应用额外的过滤业务逻辑,例如删除近似重复项或使排序集合中显示的物品类型更多样化。

在以下示例中,我们假设所有项目都可以由用户、上下文和项目特征的连接特征向量表示,并且模型可以简化为具有权重向量W的线性模型,该向量与项目向量点乘以获取用于排序项目的分数。这些模型可以推广为深度神经网络,但最终层的输出仍然是用于排序项目的标量。

现在我们已经为排序设定了上下文,让我们考虑通过向量表示的一组项目的排名方式。

学习排序

学习排序(LTR)是一种根据它们的相关性或重要性对排序列表进行评分的模型类型。这种技术是如何从检索的潜在原始输出到基于它们的相关性的排序列表的方法。

LTR 问题有三种主要类型:

点对点

模型将单独的文档视为孤立的并分配给它们一个分数或等级。任务变成了一个回归或分类问题。

逐对

模型在损失函数中同时考虑文档对。目标是尽量减少错误排序的对数。

列表

模型在损失函数中考虑整个文档列表。目标是找到整个列表的最佳排序。

训练 LTR 模型

LTR 模型的训练数据通常包括项目列表,每个项目都有一组特征和一个标签(或地面真实值)。这些特征可能包括有关项目本身的信息,而标签通常表示其相关性或重要性。例如,在我们的推荐系统中,我们有项目特征,在训练数据集中,标签将显示项目是否与用户相关。此外,LTR 模型有时会使用查询或用户特征。

训练过程是通过使用这些特征和标签来学习排名函数。然后将这些排名函数应用于检索到的项目。

让我们看一些这些模型是如何训练的例子。

用于排序的分类

将排名问题建模为多标签任务的一种方法是一种方法。训练集中出现的与用户关联的每个项目都是正面例子,而那些在外部的则是负面的。这实际上是在项目集合的规模上的多标签方法。网络可能具有每个项目特征作为输入节点的架构,然后还有一些用户特征。输出节点将与您希望标记的项目对应。

使用线性模型,如果X是项目向量,Y是输出,我们学习W,其中sigmoid(WX)=1如果X是正面集中的项目;否则sigmoid(WX)=0。这对应于在 Optax 中的二元交叉熵损失

不幸的是,在这种设置中没有考虑项目的相对排序,因此由于每个项目都有一个 sigmoid 激活函数的损失函数,它不会很好地优化排名度量标准。实际上,这种排名仅仅是一个下游的相关性模型,只有帮助过滤在先前步骤中检索到的选项。

这种方法的另一个问题是,我们已经将训练集之外的所有内容标记为负面,但是用户可能从未看过一个新项目,这个新项目可能与查询相关,因此将这个新项目标记为负面是不正确的,因为它只是未观察到。

您可能已经意识到,排名需要考虑列表中的相对位置。让我们接着考虑这个问题。

用于排名的回归

排列一组项目最朴素的方法是简单地回归到类似 NDCG 或我们其他的个性化度量的排名。

在实践中,这通过将项目集条件化为一个查询来实现。例如,我们可以将问题表述为回归到 NDCG,将查询作为排名的上下文。此外,我们可以将查询作为嵌入上下文向量提供给一个前馈网络,该网络与集合中项目的特征串联并回归到 NDCG 值。

查询作为上下文是必需的,因为一组项目的排序可能依赖于查询。例如,键入搜索栏中的查询**flowers**。然后,我们期望一组最能代表花卉的项目出现在前面的结果中。这表明查询是评分函数的重要考虑因素。

对于线性模型,如果 X 是项目向量,Y 是输出,则我们学习 W ,其中 W X ( i ) = N D C G ( i ) 而 N D C G ( i ) 是项目 i 的 NDCG。在 Optax 中,可以使用 L2 loss 进行回归学习。

最终,这种方法旨在尝试学习导致个性化指标中得分更高的项目的潜在特征。不幸的是,这也未明确考虑项目的相对排序。这是一个相当严重的限制,我们稍后将考虑。

另一个考虑因素是:对于在前 k 个训练项目之外未排名的项目,我们该怎么办?我们给它们分配的排名基本上是随机的,因为我们不知道要给它们分配什么数字。因此,这种方法需要改进,我们将在下一节中探讨。

排名的分类和回归

假设我们有一个网页,比如一个在线书店,用户必须浏览并点击项目才能购买它们。对于这样的漏斗,我们可以将排名分为两部分。第一个模型可以预测在展示的一组项目中点击项目的概率。第二个模型可以在点击后进行条件化,并且可以是一个回归模型,估计项目的购买价格。

然后,一个完整的排名模型可以是两个模型的乘积。第一个模型计算在一组竞争项目中点击项目的概率。第二个模型计算被点击后购买的预期值。请注意,第一个和第二个模型可能具有不同的特征,这取决于用户所处的漏斗阶段。第一个模型可以访问竞争项目的特征,而第二个模型可能考虑到可能改变项目价值的运费和应用的折扣。因此,在这种情况下,利用不同的模型对漏斗的每个阶段进行建模是有利的,以便利用每个阶段存在的最多信息。

WARP

介绍一种随机生成排名损失的可能方式在“WSABIE: Scaling Up to Large Vocabulary Image Annotation”由 Jason Weston 等人提出。该损失被称为weighted approximate rank pairwise(WARP)。在这个方案中,损失函数被分解为看起来像是一对一的损失。更确切地说,如果一个排名更高的项目没有一个分数大于较低排名项目的边界(任意选取为 1),我们对这对项目应用hinge loss。这看起来像下面这样:

m a x ( 0 , 1 - s c o r e ( p o s ) + s c o r e ( n e g ) )

使用线性模型时,如果X p o s是正向项目向量,而X n e g是负向项目向量,则我们学习W,其中W X p o s - W X n e g > 1。这种情况下的损失是hinge loss,其中预测输出为W X p o s - W X n e g,目标为 1。

然而,为了弥补一个未观察到的项目可能不是真负向的事实,而只是未观察到的东西,我们计算了从负向集合中抽样找到违反所选对排序的次数。也就是说,我们计算了需要查找的次数,找到这样的情况:

s c o r e ( n e g ) > s c o r e ( p o s ) - 1

然后,我们构建一个随着我们从未看过的项目(减去正向项目)中抽样找到违反负向的次数而单调递减的函数,并查找此次数的权重,并将损失乘以它。如果很难找到违反负向的情况,梯度应该较低,因为要么我们已经接近一个好的解决方案,要么该项目以前从未被用户显示为查询结果。

注意,当 CPU 是主要的计算形式来训练机器学习模型时,WARP 损失被开发出来。因此,使用了一个排名的近似值来获得负项的排名。近似排名被定义为在我们找到一个负项其分数比正项大的任意常数边界 1.0 之前,从项目宇宙中(减去正例)抽样的次数。

要构建成对损失的 WARP 权重,我们需要一个函数,将负项的近似排名转换为 WARP 权重。计算这个相对简单的代码片段如下:

import numpy as np

def get_warp_weights(n: int) -> np.ndarray:
  """Returns N weights to convert a rank to a loss weight."""

  # The alphas are defined as values that are monotonically decreasing.
  # We take the reciprocal of the natural numbers for the alphas.
  rank = np.arange(1.0, n + 1, 1)
  alpha = 1.0 / rank
  weights = alpha

  # This is the L in the paper, defined as the sum of all previous alphas.
  for i in range(1, n):
    weights[i] = weights[i] + weights[i -1]

  # Divide by the rank.
  weights = weights / rank
  return weights

print(get_warp_weights(5))
[1.         0.75       0.61111111 0.52083333 0.45666667]

如您所见,如果我们立即找到一个负样本,那么 WARP 权重为 1.0,但是如果很难找到违反间距的负样本,那么 WARP 权重将很小。

此损失函数大致优化了 precision@k,因此是改进检索集中排名估计的良好步骤。更好的是,通过采样,WARP 在计算上是高效的,因此更节省内存。

k 阶统计量

有没有办法改进 WARP 损失和直接成对铰链损失?事实证明,有整个一系列方法。在“使用 k 阶统计量损失学习排序推荐”,Jason Weston 等人(包括本书的其中一位合著者)展示了如何通过探索铰链损失和 WARP 损失之间的变体来完成这一点。本文的作者在各种语料库上进行了实验,并展示了在优化单个成对与选择像 WARP 这样的更难的负样本之间的权衡如何影响包括平均排名和 precision 和 recall 在k上的度量。

关键的一般化是,在梯度步骤期间不是考虑单个正项目,而是使用所有正项目。

再次回顾,随机选择一个正样本和一个负样本对优化 ROC 或 AUC。这对排名不是很好,因为它不会优化列表的顶部。另一方面,WARP 损失会优化单个正项目的排名列表的顶部,但不会指定如何选择正项目。

可以使用几种备选策略来对列表顶部进行排序,包括优化均值最大排名,该策略试图将正项目分组,使得得分最低的正项目尽可能靠近列表顶部。为了允许这种排序,我们提供了一个概率分布函数,用于解释我们如何选择正样本。如果概率偏向于正项目列表的顶部,我们会得到类似于 WARP 损失的损失。如果概率是均匀的,我们会得到 AUC 损失。如果概率偏向于正项目列表的末尾,那么我们将优化最坏情况,就像均值最大排名一样。NumPy 函数np.random.choice提供了一种从分布中进行采样的机制P 。

我们还有一个优化考虑: K ,用于构建正样本集的正样本数量。如果 K = 1 ,我们只从正样本集中随机选择一个正样本;否则,我们按分数对样本进行排序,并使用概率分布 P 从大小为 K 的正列表中采样。这种优化在 CPU 时代是有意义的,因为计算成本昂贵,但在 GPU 和 TPU 时代可能不再那么合理,接下来我们会在下面的警告中讨论这一点。

随机损失和 GPU

关于上述随机损失需要注意的一点。它们是为早期 CPU 时代开发的,那时对样本进行抽样并且在发现负样本时退出是廉价且简单的。而在现代 GPU 时代,做出类似这样的分支决策更加困难,因为 GPU 核心上的所有线程必须并行运行相同的代码,但在不同的数据上。这通常意味着分支的两侧都会在一个批次中执行,因此这些早期退出的计算节省效果较少。因此,像 WARP 和k阶统计损失这样的近似随机损失的分支代码看起来不那么高效。

我们该怎么办?我们将在第十三章中展示如何在代码中近似这些损失。长话短说,由于像 GPU 这样的向量处理器通常通过并行均匀地处理大量数据来工作,我们必须找到一种适合 GPU 的方式来计算这些损失。在下一章中,我们通过生成大批负样本并且要么将它们全部评分低于负样本,要么寻找最明显违反负样本,或者两者混合作为损失函数的一部分来近似负采样。

BM25

尽管这本书的大部分内容都是针对向用户推荐物品,但搜索排名是一个紧密相关的研究领域。在信息检索或文档搜索排名空间中,最佳匹配 25(BM25)是一个必不可少的工具。

BM25 是信息检索系统中用于根据其与给定查询的相关性对文档进行排名的算法。这种相关性是通过考虑诸如 TF-IDF 等因素来确定的。它是一个基于词袋模型的检索函数,根据每个文档中出现的查询词来对一组文档进行排名。它还是概率相关性框架的一部分,并且源自概率检索模型。

BM25 排名函数根据查询为每个文档计算一个分数。得分最高的文档被认为与查询最相关。

这是 BM25 公式的简化版本:

score ( D , Q ) = ∑ i=1 n IDF ( q i ) f(q i ,D)(k1+1) f(q i ,D)+k1(1-b+b|D| avgdl)

这个公式的要素如下:

  • D 代表一个文档。

  • Q 是由词组成的查询,包括单词 q 1 , q 2 , . . . , q n 。

  • f ( q i , D ) 是查询项 q i 在文档 D 中的频率。

  • | D | 是文档 D 的长度(单词数)。

  • a v g d l 是集合中的平均文档长度。

  • k 1 和 b 是超参数。 k 1 是正调节参数,用于校准文档词频的缩放。 b 是通过文档长度决定缩放的参数: b = 1 对应完全按文档长度缩放词项权重,而 b = 0 则表示不进行长度归一化。

  • I D F ( q i ) 是查询项 q i 的逆文档频率,用于衡量单词在所有文档中提供的信息量(无论其在文档中是常见还是罕见)。BM25 应用了一种变体的 IDF,可以计算如下:

    IDF ( q i ) = log N-n(q i )+0.5 n(q i )+0.5

    这里,N 是集合中的文档总数,n ( q i ) 是包含查询项 q i 的文档数。

简单来说,BM25 结合了词项频率(术语在文档中出现的频率)和逆文档频率(术语提供的唯一信息量大小)来计算相关性分数。它还引入了文档长度归一化的概念,惩罚过长的文档,并防止它们在简短文档面前占据主导地位,这是简单 TF-IDF 模型中常见的问题。自由参数 k 1 和 b 允许根据文档集的特定特性进行调整。

在实践中,BM25 为大多数信息检索任务提供了强大的基线,包括即时关键字搜索和文档相似性。BM25 被许多开源搜索引擎(如 Lucene 和 Elasticsearch)使用,并成为通常所称的全文搜索的事实标准。

那么我们如何将 BM25 集成到本书中讨论的问题中呢?BM25 的输出是根据给定查询排名的文档列表,然后 LTR 发挥作用。您可以将 BM25 分数作为 LTR 模型中的一个特征,以及您认为可能影响文档与查询相关性的其他特征。

将 BM25 与 LTR 结合进行排名的一般步骤如下:

  1. 检索候选文档列表。给定一个查询,使用 BM25 来检索候选文档列表。

  2. 为每个文档计算特征。计算 BM25 分数作为其中一个特征,以及其他潜在的特征。这可能包括各种文档特定特征、查询-文档匹配特征、用户交互特征等。

  3. 训练/评估 LTR 模型。使用这些特征向量及其对应的标签(相关性评判)来训练您的 LTR 模型。或者,如果您已经有了训练好的模型,可以使用它来评估和排名检索到的文档。

  4. 排名。LTR 模型为每个文档生成一个分数。根据这些分数对文档进行排名。

通过使用 BM25 进行检索和 LTR 进行排名的组合,您可以首先从可能非常庞大的文档集合中缩小潜在的候选文档范围(其中 BM25 表现突出),然后利用一个可以考虑更复杂特征和交互的模型来微调这些候选文档的排名(其中 LTR 表现突出)。

值得一提的是,BM25 分数可以为文本文档检索提供强大的基线,取决于问题的复杂性和您拥有的训练数据量,LTR 可能会或者不会提供显著的改进。

多模态检索

让我们重新审视一下这种检索方法,因为我们可以找到一些强大的优势。回想一下第八章:我们构建了一个共现模型,展示了在其他文章中共同引用的文章如何共享含义和相互相关性。但是,您如何将搜索集成到这个过程中呢?

你可能会想,“哦,我可以搜索文章的名称。” 但这并没有充分利用我们的共现模型;它未充分利用我们发现的联合含义。一个经典的方法可能是在文章标题或文章上使用类似 BM25 的东西。更现代的方法可能会对查询和文章标题进行向量嵌入(使用类似 BERT 或其他变换器模型的东西)。然而,这两者都没有真正捕捉到我们寻找的两面。

考虑改用以下方法:

  1. 通过 BM25 使用初始查询进行搜索,以获取初始的“锚点”集。

  2. 通过您的潜在模型以每个锚点作为查询进行搜索。

  3. 训练一个 LTR 模型来聚合和排名搜索的并集。

现在我们正在使用真正的多模式检索,利用多个潜在空间!这种方法的一个额外亮点是查询通常在基于编码器的潜在空间中与文档的分布不同。这意味着当你输入**谁是莫桑比克的领导人?**时,这个问题看起来与文章标题(莫桑比克)或 2023 年夏季相关的句子(“萨莫拉·马歇尔总统领导下的新政府建立了一个基于马克思主义原则的一党制国家。”)相当不同。

当嵌入根本不是文本时,这种方法变得更加强大:考虑输入文本搜索服装项目,并希望看到与之搭配的整套服装。

摘要

将事物放入正确顺序是推荐系统中的一个重要方面。到目前为止,您知道排序并不是全部内容,但它是管道中的一个关键步骤。我们已经收集了我们的物品并将它们放在正确的顺序中,剩下的就是把它们发送给用户。

我们从最基本的概念开始,学习排名,并将其与一些传统方法进行比较。然后我们通过 WARP 和 WSABIE 进行了大幅升级。这使我们最终使用了k-order 统计量,这涉及到更谨慎的概率抽样。最后,我们以 BM25 作为文本设置中强大的基线结束。

在我们征服服务之前,让我们把这些要素放在一起。在下一章中,我们将加大音量,并创建一些播放列表。这将是最密集的一章,所以去拿杯饮料伸展一下。我们有些工作要做。

第十三章:汇总一切:实验和排名

在最近的几章中,我们涵盖了排名的许多方面,包括各种损失函数以及衡量排名系统性能的指标。在本章中,我们将展示一个排名损失和排名指标的示例,使用了Spotify 百万播放列表数据集

本章鼓励更多的实验和比之前更加开放的方式,其目标是引入概念和基础设施。另一方面,本章旨在鼓励您直接参与损失函数和编写指标的工作。

实验技巧

在我们开始挖掘数据和建模之前,让我们探讨一些实践方法,这些方法在进行大量实验和快速迭代时会使您的生活更加轻松。这些是一些通用的指导原则,使我们的实验更快。因此,我们能够快速迭代向解决方案,帮助我们达到我们的目标。

实验代码与工程代码不同,实验代码是为了探索思路而写的,并非为了稳健性。目标是在不牺牲代码质量太多的情况下达到最大速度。因此,你应该考虑一段代码是否需要彻底测试,或者这并不必要,因为该代码只是为了测试一个假设,然后将被丢弃。考虑到这一点,以下是一些建议。请记住,这些建议是作者多年发展出来的观点,不是硬性规则,只是一些有趣的观点,可能会有人不同意。

保持简单

在研究代码的整体结构方面,最好保持尽可能简单。在探索生命周期的早期阶段,尽量不要在继承和可重用性方面思考太多。在项目开始阶段,我们通常还不知道它需要什么,因此首选应该是保持代码易于阅读和简单调试。这意味着在早期阶段,不必过多关注代码重用,因为在项目的早期阶段,模型结构、数据摄入以及系统各部分的交互将会发生许多代码更改。当不确定性问题解决后,然后可以将代码重写为更稳健的形式,但是过早地重构实际上会降低速度。

一般的经验法则是,可以复制代码三次,第四次将其重构为库,因为你会看到足够多的用例来证明代码的复用是合理的。如果重构得太早,可能没有看到足够多的代码片段的用例来覆盖它可能需要处理的可能用例。

调试打印语句

如果你阅读了多篇机器学习研究论文,你可能期望你的数据在项目开始时是相当干净和有序的。然而,现实世界的数据可能会很杂乱,存在缺失字段和意外值。有很多打印函数可以让你打印并视觉检查数据的样本,还有助于制定输入数据的管道和转换,以供模型使用。此外,打印模型的样本输出对于确保输出符合预期也是有用的。

包括记录日志的最重要的地方是系统组件之间的输入和输出模式;这些帮助你理解现实可能与期望偏差的地方。稍后,你可以编写单元测试来确保重构模型不会出错,但单元测试可以等到模型架构稳定时再做。一个良好的经验法则是当你想重构代码、重用代码或优化代码以保持功能性时,或者当代码稳定并且你希望确保它不会破坏构建时,添加单元测试。在运行训练代码时不可避免地遇到非数字 (NaN) 错误时,添加打印语句也是一个很好的用例。

在 JAX 中,你可以通过以下方式启用 NaN 调试:

from jax import config
config.update("jax_debug_nans", True)

@jax.jit
def f(x):
  jax.debug.print("Debugging {x}", x=x)

调试 NaN 配置设置将在发现任何 NaN 时重新运行编译的函数,并且调试打印函数将打印张量的值,即使在 JIT 内部也是如此。常规打印在 JIT 内部不起作用,因为它不是可编译的命令,在跟踪期间会被跳过,因此你必须使用调试打印函数,它在 JIT 内部是有效的。

推迟优化

在研究代码中,有很多诱惑让你早早进行优化——特别是关注模型或系统的实现,以确保它们在计算上是高效的或者代码是优雅的。然而,研究代码是为了更高的实验速度而写的,而不是执行速度。

我们建议不要过早优化,除非它影响了研究的速度。其中一个原因是系统可能还不完整,因此如果系统的另一部分更慢且是真正的瓶颈,优化其中一部分可能没有意义。另一个原因是你正在优化的部分可能不会成为最终模型的一部分,因此如果代码被重构掉了,所有的优化工作可能都会白费。

最后,优化实际上可能会妨碍在架构或功能方面修改或注入较新设计选择的能力。优化代码往往会有一些选择,这些选择适合当前数据流结构,但可能不适合进一步的更改。例如,在本章的代码中,一种可能的优化选择是批量处理相同大小的播放列表,以便代码可以批量运行。然而,在此实验阶段,这种优化可能过早并且会分散注意力,因为它可能使度量代码变得更加复杂。我们的温和建议是,推迟优化,直到大部分实验完成并且架构、损失函数和度量标准已经选择并确定。

跟踪变更

在研究代码中,可能会涉及太多变量,以至于您不能一次更改它们以查看其效果。这个问题在需要大量运行来确定哪些更改导致哪些效果的大型数据集中尤为明显。因此,一般来说,固定一些参数并逐步改变代码仍然是个好主意,这样您可以跟踪导致最大改进的变更。参数必须被跟踪,但代码变更也同样重要。

通过像我们在第五章讨论的 Weights & Biases 这样的服务,可以跟踪变更的一种方法。记录导致变更的确切代码和参数是一个好主意,这样实验可以被重现和分析。特别是在经常变化且有时未经检查的研究代码中,您必须在某处保存生成运行的代码副本,而 MLOps 工具允许您跟踪代码和超参数。

使用特征工程

与学术论文不同,大多数应用研究更关心良好的结果而不是理论上美丽的结果。我们并不被纯粹主义观点所束缚,即模型必须自己学习数据的一切。相反,我们是实用主义者,关心良好的结果。

我们不应放弃诸如特征工程之类的实践,特别是当数据稀少或时间紧迫时,并且需要快速得到合理的结果。使用特征工程意味着,如果您知道手工制作的特征与像排名这样的结果正相关或负相关,那么请务必将这些工程特征添加到数据中。在推荐系统中的一个例子是,如果一个项目的属性与用户的播放列表中的艺术家或专辑匹配,则返回布尔值 True;否则,返回 False。这种额外的特征简单地帮助模型更快地收敛,如果手工制作的特征表现不佳,模型仍然可以使用其他潜在特征(如嵌入)来进行补偿。

定期消除手工设计的特征通常是一个好的实践。为了做到这一点,偶尔保留一个没有某些特征的实验,以查看这些特征是否随着时间的推移已经过时,或者它们是否仍然有益于业务指标。

消融

在机器学习应用中,消融是指当特定特征被移除时,模型性能的变化情况。在计算机视觉应用中,消融通常是指阻塞图像或视野的一部分,以查看它对模型识别或分割数据的影响。在其他类型的机器学习中,它可以意味着有策略地移除某些特征。

消融的一个要注意的地方是要用什么来替换该特征。简单地将该特征清零可能会显著地扭曲模型的输出。这被称为零消融,可以强制模型将该特征视为超出分布,从而产生较不可信的结果。相反,一些人主张进行均值消融,或者取该特征的平均值或最常见值。这使模型能够看到更多预期的值,并降低这些风险。

然而,这未能考虑到我们一直在研究的模型的最重要的方面——潜在的高阶交互作用。其中一位作者调查了一种更深入的消融方法,称为因果擦除,在这种方法中,您将消融值固定为从其他特征值产生的后验分布中采样的值,即,一个与模型在那时将看到的其余值“合理”的值。

理解指标与业务指标

有时,作为机器学习从业者,我们过分关注我们的模型可以达到的最佳指标。然而,我们应该控制一下这种热情,因为最佳的机器学习指标可能并不完全代表手头的业务利益。此外,包含业务逻辑的其他系统可能会位于我们的模型之上并修改输出。因此,最好不要过分关注机器学习指标,而是进行包含业务指标的适当 A/B 测试,因为这是评估机器学习结果的主要指标。

最好的情况是找到一个与相关业务指标很好地对齐或预测的损失函数。不幸的是,这通常并不容易找到,特别是当业务指标很微妙或有竞争优先级时。

进行快速迭代

不要害怕查看运行时间较短的结果。在开始时,当您正在弄清楚模型架构与数据之间的交互作用时,没有必要对数据进行完整的遍历。进行一些快速运行,并进行一些轻微的调整,以查看它们在较短的时间步长内如何改变指标是可以的。在 Spotify 百万播放列表数据集中,我们在进行较长的运行之前通过使用 100,000 个播放列表来调整了模型架构。有时更改可能会如此显著,以至于效果可以立即看到,甚至在第一次测试集评估时也是如此。

现在,我们已经掌握了实验研究编码的基础知识,让我们转到数据和代码,稍微玩弄一下建模音乐推荐。

Spotify 百万播放列表数据集

此节的代码可以在此书的 GitHub 存储库找到。数据的文档可以在Spotify 百万播放列表数据集挑战找到。

首先,我们应该查看数据:

less data/spotify_million_playlist_dataset/data/mpd.slice.0-999.json

这应该产生以下输出:

{
    "info": {
        "generated_on": "2017-12-03 08:41:42.057563",
        "slice": "0-999",
        "version": "v1"
    },
    "playlists": 
        {
            "name": "Throwbacks",
            "collaborative": "false",
            "pid": 0,
            "modified_at": 1493424000,
            "num_tracks": 52,
            "num_albums": 47,
            "num_followers": 1,
            "tracks": [
                {
                    "pos": 0,
                    "artist_name": "Missy Elliott",
                    "track_uri": "spotify:track:0UaMYEvWZi0ZqiDOoHU3YI",
                    "artist_uri": "spotify:artist:2wIVse2owClT7go1WT98tk",
                    "track_name": "Lose Control (feat. Ciara & Fat Man Scoop)",
                    "album_uri": "spotify:album:6vV5UrXcfyQD1wu4Qo2I9K",
                    "duration_ms": 226863,
                    "album_name": "The Cookbook"
                },
     }
 }

在遇到新数据集时,总是重要的是看一下它,并计划使用哪些特征来生成数据的推荐。Spotify 百万播放列表数据集挑战的一个可能目标是从播放列表中的前五个曲目预测接下来的曲目。

在这种情况下,对任务可能有用的几个特征。我们有曲目、艺术家和专辑的统一资源标识符(URI),它们分别是曲目、艺术家和专辑的唯一标识符。我们还有艺术家和专辑的名称以及播放列表的名称。数据集还包括诸如曲目时长和播放列表关注者数量等数值特征。直觉上,播放列表的关注者数量不应影响播放列表中曲目的排序,因此在使用这些可能不具信息量的特征之前,您可能希望寻找更好的特征。查看特征的整体统计信息,您还可以获得很多见解:

less data/spotify_million_playlist_dataset/stats.txt
number of playlists 1000000
number of tracks 66346428
number of unique tracks 2262292
number of unique albums 734684
number of unique artists 295860
number of unique titles 92944
number of playlists with descriptions 18760
number of unique normalized titles 17381
avg playlist length 66.346428

top playlist titles
  10000 country
  10000 chill
   8493 rap
   8481 workout
   8146 oldies
   8015 christmas
   6848 rock
   6157 party
   5883 throwback
   5063 jams
   5052 worship
   4907 summer
   4677 feels
   4612 new
   4186 disney
   4124 lit
   4030 throwbacks

首先要注意的是曲目数比播放列表数多。这意味着很多曲目可能有非常少的训练数据。因此,track_uri可能不是一个很好泛化的特征。另一方面,album_uriartist_uri会泛化,因为它们会在不同的播放列表中多次出现。为了代码清晰起见,我们将主要使用album_uriartist_uri作为代表曲目的特征。

在之前的“综合应用”章节中,我们展示了可以替代的基于内容的特征或文本令牌化特征的使用,但是直接嵌入特征最清晰地用于排名演示。在实际应用中,嵌入特征和基于内容的特征可以连接在一起形成更好推荐排名的特征。在本章中,我们将把一个曲目表示为元组(track_id, album_id, artist_id),其中 ID 是表示 URI 的整数。我们将在下一节建立从 URI 到整数 ID 的字典。

构建 URI 字典

类似于[第八章,我们将首先构建所有 URI 的字典。这个字典允许我们将文本 URI 表示为整数,以便在 JAX 端更快地处理,因为我们可以轻松地从整数查找嵌入,而不是任意的 URI 字符串。

这是 make_dictionary.py 的代码:

import glob
import json
import os
from typing import Any, Dict, Tuple

from absl import app
from absl import flags
from absl import logging
import numpy as np
import tensorflow as tf

FLAGS = flags.FLAGS
_PLAYLISTS = flags.DEFINE_string("playlists", None, "Playlist json glob.")
_OUTPUT_PATH = flags.DEFINE_string("output", "data", "Output path.")

# Required flag.
flags.mark_flag_as_required("playlists")

def update_dict(dict: Dict[Any, int], item: Any):
    """Adds an item to a dictionary."""
    if item not in dict:
        index = len(dict)
        dict[item] = index

def dump_dict(dict: Dict[str, str], name: str):
  """Dumps a dictionary as json."""
  fname = os.path.join(_OUTPUT_PATH.value, name)
  with open(fname, "w") as f:
    json.dump(dict, f)

def main(argv):
    """Main function."""
    del argv  # Unused.

    tf.config.set_visible_devices([], 'GPU')
    tf.compat.v1.enable_eager_execution()
    playlist_files = glob.glob(_PLAYLISTS.value)
    track_uri_dict = {}
    artist_uri_dict = {}
    album_uri_dict = {}

    for playlist_file in playlist_files:
        print("Processing ", playlist_file)
        with open(playlist_file, "r") as file:
            data = json.load(file)
            playlists = data["playlists"]
            for playlist in playlists:
                tracks = playlist["tracks"]
                for track in tracks:
                  update_dict(track_uri_dict, track["track_uri"])
                  update_dict(artist_uri_dict, track["artist_uri"])
                  update_dict(album_uri_dict, track["album_uri"])

    dump_dict(track_uri_dict, "track_uri_dict.json")
    dump_dict(artist_uri_dict, "artist_uri_dict.json")
    dump_dict(album_uri_dict, "album_uri_dict.json")

if __name__ == "__main__":
    app.run(main)

每当遇到新的 URI 时,我们只需递增计数器并将该唯一标识符分配给 URI。我们对曲目、艺术家和专辑执行此操作,并将其保存为 JSON 文件。

尽管我们可以使用像 PySpark 这样的数据处理框架,但重要的是要注意数据大小。如果数据量小,例如百万个播放列表,仅在单台机器上执行会更快。在何时使用大数据处理框架时我们应该明智,并且对于小数据集,有时仅在一台机器上运行代码可能更快,而不是编写在集群上运行的代码。

构建训练数据

现在我们有了字典,我们可以使用它们将原始 JSON 播放列表日志转换为更可用的形式进行 ML 训练。此代码在 make_training.py 中实现:

import glob
import json
import os
from typing import Any, Dict, Tuple

from absl import app
from absl import flags
from absl import logging
import numpy as np
import tensorflow as tf

import input_pipeline

FLAGS = flags.FLAGS
_PLAYLISTS = flags.DEFINE_string("playlists", None, "Playlist json glob.")
_DICTIONARY_PATH = flags.DEFINE_string("dictionaries", "data/dictionaries",
                   "Dictionary path.")
_OUTPUT_PATH = flags.DEFINE_string("output", "data/training", "Output path.")
_TOP_K = flags.DEFINE_integer("topk", 5, "Top K tracks to use as context.")
_MIN_NEXT = flags.DEFINE_integer("min_next", 10, "Min number of tracks.")

# Required flag.
flags.mark_flag_as_required("playlists")

def main(argv):
    """Main function."""
    del argv  # Unused.

    tf.config.set_visible_devices([], 'GPU')
    tf.compat.v1.enable_eager_execution()
    playlist_files = glob.glob(_PLAYLISTS.value)

    track_uri_dict = input_pipeline.load_dict(
      _DICTIONARY_PATH.value, "track_uri_dict.json")

    print("%d tracks loaded" % len(track_uri_dict))
    artist_uri_dict = input_pipeline.load_dict(
      _DICTIONARY_PATH.value, "artist_uri_dict.json")
    print("%d artists loaded" % len(artist_uri_dict))
    album_uri_dict = input_pipeline.load_dict(
      _DICTIONARY_PATH.value, "album_uri_dict.json")
    print("%d albums loaded" % len(album_uri_dict))
    topk = _TOP_K.value
    min_next = _MIN_NEXT.value
    print("Filtering out playlists with less than %d tracks" % min_next)

    raw_tracks = {}

    for pidx, playlist_file in enumerate(playlist_files):
        print("Processing ", playlist_file)
        with open(playlist_file, "r") as file:
            data = json.load(file)
            playlists = data["playlists"]
            tfrecord_name = os.path.join(
              _OUTPUT_PATH.value, "%05d.tfrecord" % pidx)
            with tf.io.TFRecordWriter(tfrecord_name) as file_writer:
              for playlist in playlists:
                  if playlist["num_tracks"] < min_next:
                      continue
                  tracks = playlist["tracks"]
                  # The first topk tracks are all for the context.
                  track_context = []
                  artist_context = []
                  album_context = []
                  # The rest are for predicting.
                  next_track = []
                  next_artist = []
                  next_album = []
                  for tidx, track in enumerate(tracks):
                      track_uri_idx = track_uri_dict[track["track_uri"]]
                      artist_uri_idx = artist_uri_dict[track["artist_uri"]]
                      album_uri_idx = album_uri_dict[track["album_uri"]]
                      if track_uri_idx not in raw_tracks:
                          raw_tracks[track_uri_idx] = track
                      if tidx < topk:
                          track_context.append(track_uri_idx)
                          artist_context.append(artist_uri_idx)
                          album_context.append(album_uri_idx)
                      else:
                          next_track.append(track_uri_idx)
                          next_artist.append(artist_uri_idx)
                          next_album.append(album_uri_idx)
                  assert(len(next_track) > 0)
                  assert(len(next_artist) > 0)
                  assert(len(next_album) > 0)
                  record = tf.train.Example(
                    features=tf.train.Features(feature={
                      "track_context": tf.train.Feature(
                      int64_list=tf.train.Int64List(value=track_context)),
                      "album_context": tf.train.Feature(
                      int64_list=tf.train.Int64List(value=album_context)),
                      "artist_context": tf.train.Feature(
                      int64_list=tf.train.Int64List(value=artist_context)),
                      "next_track": tf.train.Feature(
                      int64_list=tf.train.Int64List(value=next_track)),
                      "next_album": tf.train.Feature(
                      int64_list=tf.train.Int64List(value=next_album)),
                      "next_artist": tf.train.Feature(
                      int64_list=tf.train.Int64List(value=next_artist)),
                    }))
                  record_bytes = record.SerializeToString()
                  file_writer.write(record_bytes)

    filename = os.path.join(_OUTPUT_PATH.value, "all_tracks.json")
    with open(filename, "w") as f:
        json.dump(raw_tracks, f)

if __name__ == "__main__":
    app.run(main)

此代码读取原始播放列表 JSON 文件,将 URIs 从文本标识符转换为字典中的索引,并过滤出小于最小大小的播放列表。此外,我们对播放列表进行分区,使前五个元素分组为上下文或我们推荐项目的用户,而接下来的元素则是我们希望为给定用户预测的项目。我们称前五个元素为 上下文,因为它们代表一个播放列表,并且因为如果用户有多个播放列表,播放列表与用户之间不会有一对一的映射。然后,我们将每个播放列表写为 TensorFlow 记录文件中的 TensorFlow 示例,以供 TensorFlow 数据输入管道使用。记录将始终包含上下文的五个曲目、专辑和艺术家,以及至少五个更多的下一个曲目,用于学习预测下一个曲目的推断任务。

注意

我们在这里使用 TensorFlow 对象,因为它们与 JAX 兼容,并且引入了一些非常方便的数据格式。

我们还存储具有所有特征的唯一曲目行,这主要是为了调试和显示,如果我们需要将 track_uri 转换为人类可读格式。此曲目数据存储在 all_tracks.json 中。

读取输入

然后通过 input_pipeline.py 读取输入:

import glob
import json
import os
from typing import Sequence, Tuple, Set

import tensorflow as tf
import jax.numpy as jnp

_schema = {
   "track_context": tf.io.FixedLenFeature([5], dtype=tf.int64),
   "album_context": tf.io.FixedLenFeature([5], dtype=tf.int64),
   "artist_context": tf.io.FixedLenFeature([5], dtype=tf.int64),
   "next_track": tf.io.VarLenFeature(dtype=tf.int64),
   "next_album": tf.io.VarLenFeature(dtype=tf.int64),
   "next_artist": tf.io.VarLenFeature(dtype=tf.int64),
}

def _decode_fn(record_bytes):
  result = tf.io.parse_single_example(record_bytes, _schema)
  for key in _schema.keys():
    if key.startswith("next"):
      result[key] = tf.sparse.to_dense(result[key])
  return result

def create_dataset(
    pattern: str):
    """Creates a spotify dataset.

 Args:
 pattern: glob pattern of tfrecords.
 """
    filenames = glob.glob(pattern)
    ds = tf.data.TFRecordDataset(filenames)
    ds = ds.map(_decode_fn)
    return ds

我们使用 TensorFlow 数据的功能来读取和解码 TensorFlow 记录和示例。为此,我们需要提供一个模式或字典,告诉解码器期望哪些特征的名称和类型。由于我们为上下文选择了五个曲目,我们应该期望每个 track_contextalbum_contextartist_context 有五个。然而,由于播放列表本身的长度是可变的,我们告诉解码器期望 next_tracknext_albumnext_artist 特征的可变长度整数。

input_pipeline.py 的第二部分是用于可重用的输入代码,用于加载字典并跟踪元数据:

def load_dict(dictionary_path: str, name: str):
    """Loads a dictionary."""
    filename = os.path.join(dictionary_path, name)
    with open(filename, "r") as f:
        return json.load(f)

def load_all_tracks(all_tracks_file: str,
                    track_uri_dict, album_uri_dict, artist_uri_dict):
  """Loads all tracks.

 """
  with open(all_tracks_file, "r") as f:
    all_tracks_json = json.load(f)
  all_tracks_dict = {
    int(k): v for k, v in all_tracks_json.items()
  }
  all_tracks_features = {
    k: (track_uri_dict[v["track_uri"]],
        album_uri_dict[v["album_uri"]],
        artist_uri_dict[v["artist_uri"]])
    for k,v in all_tracks_dict.items()
  }
  return all_tracks_dict, all_tracks_features

def make_all_tracks_numpy(all_tracks_features):
  """Makes the entire corpus available for scoring."""
  all_tracks = []
  all_albums = []
  all_artists = []
  items = sorted(all_tracks_features.items())
  for row in items:
    k, v = row
    all_tracks.append(v[0])
    all_albums.append(v[1])
    all_artists.append(v[2])
  all_tracks = jnp.array(all_tracks, dtype=jnp.int32)
  all_albums = jnp.array(all_albums, dtype=jnp.int32)
  all_artists = jnp.array(all_artists, dtype=jnp.int32)
  return all_tracks, all_albums, all_artists

我们还提供一个实用函数,将all_tracks.json文件转换为最终推荐中用于评分的所有轨道语料库。毕竟,目标是排名整个语料库,给定前五个上下文轨道,并查看它们与给定的下一个轨道数据匹配的情况。

对问题进行建模

接下来,让我们考虑如何对问题进行建模。我们有五个上下文轨道,每个轨道都有一个关联的艺术家和专辑。我们知道轨道数量比播放列表多,所以暂时我们将忽略track_id,仅使用album_idartist_id作为特征。一种策略是对专辑和艺术家使用一位有效编码(one-hot encoding),这种方法效果不错,但一位有效编码往往导致模型具有较高的精度但泛化能力较差。

表示标识符的另一种方法是将它们嵌入——即制作一个查找表,将标识符嵌入到一个比标识符基数低的固定大小的嵌入中。这种嵌入可以被看作是标识符全秩矩阵的低秩逼近。我们在早期章节中介绍了低秩嵌入,并且在这里使用这个概念作为表示专辑和艺术家特征的方法。

查看models.py,其中包含SpotifyModel的代码的另一种表示方法:

from functools import partial
from typing import Any, Callable, Sequence, Tuple

from flax import linen as nn
import jax.numpy as jnp

class SpotifyModel(nn.Module):
    """Spotify model that takes a context and predicts the next tracks."""
    feature_size : int

    def setup(self):
        # There are too many tracks and albums so limit by hashing.
        self.max_albums = 100000
        self.album_embed = nn.Embed(self.max_albums, self.feature_size)
        self.artist_embed = nn.Embed(295861, self.feature_size)

    def get_embeddings(self, album, artist):
        """
 Given track, album, artist indices return the embeddings.
 Args:
 album: ints of shape nx1
 artist: ints of shape nx1
 Returns:
 Embeddings representing the track.
 """
        album_modded = jnp.mod(album, self.max_albums)
        album_embed = self.album_embed(album_modded)
        artist_embed = self.artist_embed(artist)
        result = jnp.concatenate([album_embed, artist_embed], axis=-1)
        return result

在设置代码中,请注意我们有两个嵌入,分别用于专辑和艺术家。我们有很多专辑,因此我们展示了一种减少专辑嵌入内存占用的方法:取一个比嵌入数量更小的数的模,以便多个专辑可以共享一个嵌入。如果内存更多,可以去掉模运算,但这里演示了一种在具有非常大基数特征的情况下获取一些好处的技术。

艺术家可能是最信息丰富的特征,数据包含的独特艺术家较少,因此我们在artist_id和嵌入之间有一对一映射。当我们将(album_id, artist_id)元组转换为一个嵌入时,我们对每个 ID 进行单独查找,然后连接嵌入并返回一个完整的嵌入来表示一个轨道。如果有更多播放列表数据可用,您可能还希望嵌入track_id。但是,鉴于我们的独特轨道比播放列表多,直到有更多的播放列表数据和track_id更频繁地出现作为观察结果之前,track_id特征将不会很好地泛化。一个经验法则是,特征至少应出现 100 次才有用;否则,该特征的梯度将不会经常更新,它可能与随机数一样,因为它被初始化为这样。

call部分,我们完成了计算上下文与其他轨道亲和性的重要工作:

def __call__(self,
                 track_context, album_context, artist_context,
                 next_track, next_album, next_artist,
                 neg_track, neg_album, neg_artist):
        """Returns the affinity score to the context.
 Args:
 track_context: ints of shape n
 album_context: ints of shape n
 artist_context: ints of shape n
 next_track: int of shape m
 next_album: int of shape m
 next_artist: int of shape m
 neg_track: int of shape o
 neg_album: int of shape o
 neg_artist: int of shape o
 Returns:
 pos_affinity: affinity of context to the next track of shape m.
 neg_affinity: affinity of context to the neg tracks of shape o.
 """
        context_embed = self.get_embeddings(album_context, artist_context)
        next_embed = self.get_embeddings(next_album, next_artist)
        neg_embed = self.get_embeddings(neg_album, neg_artist)

        # The affinity of the context to the other track is simply the dot
        # product of each context embedding with the other track's embedding.
        # We also add a small boost if the album or artist match.
        pos_affinity = jnp.max(jnp.dot(next_embed, context_embed.T), axis=-1)
        pos_affinity = pos_affinity + 0.1 * jnp.isin(next_album, album_context)
        pos_affinity = pos_affinity + 0.1 * jnp.isin(next_artist, artist_context)

        neg_affinity = jnp.max(jnp.dot(neg_embed, context_embed.T), axis=-1)
        neg_affinity = neg_affinity + 0.1 * jnp.isin(neg_album, album_context)
        neg_affinity = neg_affinity + 0.1 * jnp.isin(neg_artist, artist_context)

        all_embeddings = jnp.concatenate(
        [context_embed, next_embed, neg_embed], axis=-2)
        all_embeddings_l2 = jnp.sqrt(
        jnp.sum(jnp.square(all_embeddings), axis=-1))

        context_self_affinity = jnp.dot(jnp.flip(
        context_embed, axis=-2), context_embed.T)
        next_self_affinity = jnp.dot(jnp.flip(
        next_embed, axis=-2), next_embed.T)
        neg_self_affinity = jnp.dot(jnp.flip(neg_embed, axis=-2), neg_embed.T)

        return (pos_affinity, neg_affinity,
                context_self_affinity, next_self_affinity, neg_self_affinity,
                all_embeddings_l2)

让我们深入研究这个模型代码的核心,因为这是第一部分相当直接:我们通过查找专辑和艺术家嵌入并将它们串联为每个曲目的单一向量来将索引转换为嵌入。在这个位置,您可以通过串联添加其他密集特征,或者像我们所做的那样将稀疏特征转换为嵌入。

下一部分计算上下文与下一首曲目的关联性。请注意,上下文由前五首曲目组成,而下一首曲目是待计算的播放列表的其余部分。在这里,我们有几种选择来表示上下文并计算关联性。

对于上下文的关联性,我们选择了最简单形式的关联性,即点积。另一个考虑因素是我们如何处理上下文,因为它由五个曲目组成。一种可能的方法是对所有上下文嵌入求平均值,并使用平均值作为上下文的表示。另一种方法是找到与下一首曲目的上下文中最大关联的曲目作为最接近的曲目。

有关各种选项的详细信息可以在“关联加权嵌入”中找到,作者是 Jason Weston 等人。我们发现,如果用户兴趣广泛,找到最大关联性不会像使用平均嵌入那样使上下文嵌入朝着与下一首曲目相同的方向更新。在播放列表的情况下,平均上下文嵌入向量应该同样有效,因为播放列表往往是单一主题的。

请注意,我们还计算负曲目的关联性。这是因为我们希望下一首曲目与上下文的关联性比负曲目更强。除了上下文与下一首曲目之间的关联性外,我们还计算向量的 L2 范数作为一种正则化模型的方法,以防止其在训练数据上过拟合。我们还颠倒嵌入向量并计算我们称之为自关联的量,或者上下文、下一首曲目和负嵌入向量与自身的关联性,简单地通过颠倒向量列表并进行点积来完成。这并不完全计算集合与自身的所有关联性;这留给你作为练习,因为它能建立使用 JAX 的直觉和技能。

结果会作为一个元组返回给调用者。

捕捉损失函数

现在,让我们看看train_spotify.py。我们将跳过样板代码,只看评估和训练步骤:

def eval_step(state, y, all_tracks, all_albums, all_artists):
    result = state.apply_fn(
            state.params,
            y["track_context"], y["album_context"], y["artist_context"],
            y["next_track"], y["next_album"], y["next_artist"],
            all_tracks, all_albums, all_artists)
    all_affinity = result[1]
    top_k_scores, top_k_indices = jax.lax.top_k(all_affinity, 500)
    top_tracks = all_tracks[top_k_indices]
    top_artists = all_artists[top_k_indices]
    top_tracks_count = jnp.sum(jnp.isin(
      top_tracks, y["next_track"])).astype(jnp.float32)
    top_artists_count = jnp.sum(jnp.isin(
      top_artists, y["next_artist"])).astype(jnp.float32)

    top_tracks_recall = top_tracks_count / y["next_track"].shape[0]
    top_artists_recall = top_artists_count / y["next_artist"].shape[0]

    metrics = jnp.stack([top_tracks_recall, top_artists_recall])

    return metrics

第一段代码是评估步骤。为了计算整个语料库的亲和力,我们对模型传入每个可能曲目的专辑和艺术家索引,然后使用jax.lax.top_k对它们进行排序。前两行是推荐上下文中下一个曲目的评分代码。LAX 是一个实用的工具库,配备了 JAX,其中包含了在像 GPU 和 TPU 这样的向量处理器上工作时非 NumPy API 的函数。在 Spotify 百万播放列表数据集挑战中,一个度量指标是艺术家和曲目级别的 recall@k。对于曲目,isin函数返回下一个曲目和语料库中得分最高的前 500 个曲目的交集的正确度量,除以下一个曲目集的大小。这是因为语料库中的曲目是唯一的。然而,JAX 的isin不支持使元素唯一化,因此对于艺术家召回度量,我们可能多次计算在召回集中的艺术家。为了计算效率,我们使用多次计数而不是唯一计数,以便在 GPU 上快速计算评估,以避免阻塞训练流水线。在最终评估中,我们可能会希望将数据集移动到 CPU 上,以获得更准确的度量。

我们再次使用 Weights & Biases 来跟踪所有指标,如图 13-1 所示。您可以看到它们在多个实验中的表现如何比较:

Spotify 百万播放列表数据集评估指标

图 13-1. Weights & Biases 实验追踪

接下来,我们将看一下损失函数,这是本章末尾的练习中可以进行实验的另一个精彩部分:

def train_step(state, x, regularization):
    def loss_fn(params):
        result = state.apply_fn(
            params,
            x["track_context"], x["album_context"], x["artist_context"],
            x["next_track"], x["next_album"], x["next_artist"],
            x["neg_track"], x["neg_album"], x["neg_artist"])
      pos_affinity = result[0]
      neg_affinity = result[1]
      context_self_affinity = result[2]
      next_self_affinity = result[3]
      neg_self_affinity = result[4]
      all_embeddings_l2 = result[5]

      mean_neg_affinity = jnp.mean(neg_affinity)
      mean_pos_affinity = jnp.mean(pos_affinity)
      mean_triplet_loss = nn.relu(1.0 + mean_neg_affinity - mean_pos_affinity)

      max_neg_affinity = jnp.max(neg_affinity)
      min_pos_affinity = jnp.min(pos_affinity)
      extremal_triplet_loss = nn.relu(
                              1.0 + max_neg_affinity - min_pos_affinity
                                )

      context_self_affinity_loss = jnp.mean(nn.relu(0.5 - context_self_affinity))
      next_self_affinity_loss = jnp.mean(nn.relu(
                                0.5 - next_self_affinity)
                                )
      neg_self_affinity_loss = jnp.mean(nn.relu(neg_self_affinity))

      reg_loss = jnp.sum(nn.relu(all_embeddings_l2 - regularization))
      loss = (extremal_triplet_loss + mean_triplet_loss + reg_loss +
              context_self_affinity_loss + next_self_affinity_loss +
              neg_self_affinity_loss)
      return loss

    grad_fn = jax.value_and_grad(loss_fn)
    loss, grads = grad_fn(state.params)
    new_state = state.apply_gradients(grads=grads)
    return new_state, loss

这里有几个损失函数,一些与主要任务直接相关,其他一些帮助正则化和泛化。

我们最初使用的是mean_triplet_loss,这只是一个简单的损失函数,指出了正亲和力或者上下文曲目对下一个曲目的亲和力应该比负亲和力或者上下文曲目对负曲目的亲和力多一个。我们将讨论如何进行实验以获得其他辅助损失函数。

在图 13-2 中所示的实验追踪在改进模型过程中非常重要,如可复现性所示。我们尽可能使用来自 JAX 的随机数生成器使训练过程确定性,通过使用相同的起始随机数生成器种子来使其可复现。

Spotify 百万播放列表数据集实验 - 评估曲目召回

图 13-2. 曲目召回实验

我们从mean_triplet_lossreg_loss开始,即正则化损失作为一个良好的基准。这两个损失函数简单地确保了上下文到下一个曲目的平均正关联比上下文到负曲目的负关联多一个,并且嵌入的 L2 范数不超过正则化阈值。这些对应于做得最差的指标。注意,我们并没有对整个数据集运行实验。这是因为为了快速迭代,最好先仅运行少量步骤,然后偶尔与使用整个数据集的长时间运行交错比较快。

我们接下来添加的损失函数是max_neg_affinitymin_pos_affinity。这个损失函数在一定程度上受到了 Mark A. Stevens 的“高效协调下降或使用支配损失进行排序”以及 Jason Weston 等人的“学习使用k阶统计损失进行推荐排序”的启发。然而,我们并没有使用整个负集,而只是一个子样本。为什么呢?因为负集存在噪音。仅仅因为用户没有将特定曲目添加到播放列表中,并不意味着该曲目与播放列表无关。也许用户还没有听过这首曲目,所以噪音是由于缺乏接触。我们也不像k阶统计损失论文中讨论的那样进行采样步骤,因为采样对 CPU 友好但对 GPU 不友好。因此,我们结合了这两篇论文的思想,取最大的负关联性并将其设为最小正关联性减一。在我们的实验中,这种在极端轨迹上的损失的添加为我们带来了性能的进一步提升。

最后,我们添加了自关联损失。这些确保了来自上下文和下一个曲目集的曲目之间的关联至少为 0.5,并且负曲目的关联最多为 0。这些是点积关联,相对于使正关联比负关联多一个的相对关联,它们更加绝对。从长远来看,它们并没有帮助太多,但它们确实帮助模型在开始阶段更快地收敛。我们保留它们是因为它们在最后训练步骤的评估指标上仍然提供了一些改进。这结束了本章“将所有内容放在一起”的解释部分。现在来到有趣的部分,练习!

练习

我们提供了大量的练习,因为玩弄数据和代码有助于建立关于不同损失函数和建模用户方法的直觉。此外,考虑如何编写代码可以提高您使用 JAX 的熟练程度。因此,我们列出了一些有趣且有助于理解本书提供内容的有用练习清单。

总结本章,以下是一些有趣的练习可以进行尝试。完成它们应该会让您对损失函数和 JAX 的工作方式有很多直觉,以及对实验过程的感觉。

这里有一些简单的练习可以开始尝试:

  • 尝试不同的优化器(例如 ADAM、RMSPROP)。

  • 尝试改变特征的大小。

  • 添加持续时间作为特征(在归一化时要小心!)。

  • 如果在推断中使用余弦距离,而在训练中使用点积,会发生什么?

  • 添加一个新的指标,比如 NDCG。

  • 在损失中玩弄正负亲和力的分布。

  • 使用最低的下一个跟踪和最高的负跟踪的铰链损失。

继续尝试这些更难的练习:

  • 尝试使用跟踪名称作为特征,并查看它们是否有助于泛化。

  • 如果使用两层网络来计算亲和力会发生什么?

  • 如果使用 LSTM 计算亲和力会发生什么?

  • 将跟踪嵌入替换为相关性。

  • 计算集合中的所有自身亲和力。

摘要

用特征替换嵌入是什么意思?在我们对正负亲和力的示例中,我们使用点积来计算两个实体之间的亲和力,例如两个跟踪,x 和 y 。与其将特征作为潜在的嵌入表示,一个替代方法是手动构建代表两个实体之间亲和力的特征,x 和 y 。正如第九章所介绍的,这可以是对数计数、Dice 相关系数或互信息。

可以制作某种类型的计数特征,然后将其存储在数据库中。在训练和推断时,为每个实体 x 和 y 查找数据库,然后使用亲和分数代替或与正在学习的点积一起使用。这些特征往往更精确,但召回率较低于嵌入表示。低秩的嵌入表示能够更好地泛化并提高召回率。具有计数特征是与嵌入特征协同作用的,因为我们可以通过使用精确的计数特征同时使用精确的计数特征来提高精度,同时通过使用嵌入等低秩特征来提高召回率。

考虑使用 JAX 的 vmap 函数计算集合中跟踪与其他跟踪的所有 n 2 亲和性。vmap 可用于转换代码,例如计算一个跟踪与所有其他跟踪的亲和性,并使其运行以与所有其他跟踪相比。

我们希望您在玩弄数据和代码后享受其中,并且在尝试这些练习后,您在 JAX 中编写推荐系统的能力得到了显著提高!

第四部分:服务

哎呀,你不能这样推荐!有时候最好的推荐并不一定是正确的。

其中一位作者 Bryan 对亚马逊推荐团队有一个很大的问题:“你认为我需要多少个吸尘器?”仅仅因为 Bryan 买了一台时髦的戴森来清理他的狗,并不意味着他很快就会再买一台,但他的亚马逊主页似乎非常坚决地推荐它。事实上,你总是需要包括业务逻辑 —— 或者你希望在推荐系统流程中包括的基本人类逻辑,以防止愚蠢的情况发生。无论你面对的是情境不合适的推荐、业务不可行的推荐,还是简单地需要让推荐集合显得不那么专一,最后一步的排序都可以关键地改善推荐结果。

但是慢下来!不要认为订购步骤只是全部转换情况和手动覆盖推荐系统。你的排名和服务之间需要存在协同作用。Bryan 还有一个关于他为服装建立的特定基于查询的推荐系统的故事:他想在推荐中实施一个超级简单的多样性过滤器 —— 检查推荐的服装是否属于不同的商品类别。他让他的评分模型的输出按商品类别堆栈排名推荐,这样他可以从每个类别中挑选几个来服务。不可思议的是,投入生产的第一周,他推荐了 10 个推荐中的 3 个、4 个,甚至 5 个背包。用户可能很用心,但这似乎是错误的,并且需要进行一些质量保证。他的错误在哪里?背包可以是多达三种商品类别的成员,因此它们可以潜入多个多样性类别!

在这本书的这一部分,我们将讨论理论与生产推荐相遇的棘手问题。我们将像这个例子中那样谈论多样化的推荐,但我们也将讨论其他重要的业务优先事项,这些事项对推荐管道的服务部分起到了作用。

第十四章:业务逻辑

到现在为止,你可能会想,“是的,我们的算法排名和推荐已经到位了!通过潜在理解为每个用户进行个性化是我们经营业务的方式。”不幸的是,业务很少会这么简单。

让我们来看一个非常直接的例子,一个食谱推荐系统。考虑一个简单讨厌西柚的用户(本书的一位作者确实如此),但是可能喜欢与西柚搭配得很好的一系列其他配料:芦笋、鳄梨、香蕉、黄油、腰果、香槟、鸡肉、椰子、蟹肉、鱼、姜、榛子、蜂蜜、柠檬、酸橙、甜瓜、薄荷、橄榄油、洋葱、橙子、山核桃、菠萝、覆盆子、朗姆酒、鲑鱼、海藻、虾、八角茴香、草莓、龙蒿、番茄、香草、葡萄酒和酸奶。这些配料是与西柚受欢迎的搭配,而用户几乎都喜欢这些配料。

推荐系统应该如何处理这种情况?这似乎是协同过滤(CF)、潜在特征或混合推荐可以捕捉到的内容。然而,如果用户喜欢所有这些共享的口味,基于项目的 CF 模型可能无法很好地捕捉到这一点。同样,如果用户真正讨厌西柚,潜在特征可能不足以真正避免它。

在这种情况下,简单的方法是一个伟大的选择:硬避免。在本章中,我们将讨论业务逻辑与推荐系统输出交汇时的一些复杂性。

与试图将异常作为模型在做出推荐时使用的潜在特征的一部分学习相比,通过确定性逻辑将这些业务规则作为外部步骤集成更一致和简单。例如:模型可以移除所有西柚鸡尾酒,而不是试图学习将它们排名较低。

硬排名

当你开始考虑类似于我们的西柚场景的情况时,你可以想出许多这些现象的例子。硬排名通常指的是两种特殊排名规则之一:

  • 明确地在排名之前从列表中移除一些项目。

  • 使用分类特征按类别对结果进行排名。(请注意,这甚至可以针对多个特征进行操作,以实现层次化的硬排名。)

你有没有观察到以下任何一种现象?

  • 用户购买了一个沙发。尽管他们未来五年不需要沙发,系统继续向这个用户推荐沙发。

  • 用户为一个对园艺感兴趣的朋友购买了生日礼物。然后电子商务网站继续推荐园艺工具,尽管用户对此不感兴趣。

  • 父母想给孩子买个玩具。但是当父母去他们通常购买玩具的网站时,网站推荐了几款给比孩子小几岁的孩子的玩具—自孩子那个年龄起,父母就没从这个网站购物过。

  • 一名跑步者经历了严重的膝盖疼痛,决定不能再进行长跑。他们转而选择了对关节冲击较小的骑行。然而,他们当地的社交聚会推荐仍然全是跑步相关的。

所有这些情况可以通过确定性逻辑相对容易处理。对于这些情况,我们更倾向于通过机器学习来学习这些规则。我们应该假设对于这些类型的场景,我们将得到关于这些偏好的低信号:负面的隐式反馈通常相关性较低,而且许多列出的情况都是由您希望系统彻底学习的细节所代表的。此外,在之前的一些示例中,如果未能尊重用户的偏好,可能会影响或损害与用户的关系。

这些偏好的名称为避免项,有时也称为约束、覆盖或硬规则。您应该将它们视为系统的显式期望:“不要显示带有葡萄柚的食谱”,“不要再显示沙发”,“我不喜欢园艺”,“我的孩子现在已经超过 10 岁了”,以及“不要显示越野跑”。

学习到的避免项

并非所有的业务规则都是从显式用户反馈中导出的明显避免项,有些是来自于与特定项目无直接关联的显式反馈。在考虑服务推荐时,包含广泛的避免项是非常重要的。

为了简单起见,假设您正在构建一个时尚推荐系统。更为微妙的避免示例包括以下几种情况:

已拥有的物品

这些是用户真正只需要购买一次的物品,例如通过您平台购买过或者已告知您已拥有的服装。创建一个虚拟衣橱可能是一种让用户告知您他们拥有什么的方式,以帮助避免这些情况。

不喜欢的特征

这些是用户可以表示不感兴趣的物品特征。在入职问卷期间,您可以询问用户是否喜欢波点或者是否有喜欢的颜色调色板。这些都是可以用来避免的明确表达的反馈。

忽略的类别

这是一个用户不感兴趣的物品类别或组。这可能是隐式学习的,但是超出了主要推荐模型。也许用户从未点击过您电子商务网站上的连衣裙类别,因为他们不喜欢穿它们。

低质量物品

随着时间的推移,您会了解到某些物品对大多数用户来说质量较低。您可以通过高退货率或买家低评分来检测这一点。这些物品最终应从库存中移除,但与此同时,重要的是将它们作为避免项包含在除了最强匹配信号之外的所有情况中。

这些额外的避免行为可以在服务阶段轻松实现,甚至可以包括简单的模型。训练线性模型来捕捉其中一些规则,然后在服务阶段应用它们可能是提高排名的一种有用且可靠的机制。请注意,小型模型执行推理非常快,因此通常将它们包含在管道中通常不会产生太大的负面影响。对于更大规模的行为趋势或高阶因素,我们期望我们的核心推荐模型能够学习到这些想法。

手动调整权重

在避免的另一极端是手动调整排名。这种技术在搜索排名的早期时期很受欢迎,当时人类会使用分析和观察来确定他们认为排名中最重要的特征,然后制定多目标排名器。例如,花店可能在五月初排名较高,因为许多用户在寻找母亲节礼物。由于可能有许多变量要跟踪,这些方法不太容易扩展,并且在现代推荐排名中已经大大减少了重视。

然而,手动调整排名在某种程度上可以作为避免的一种极其有用的方式。尽管技术上它不是一种避免,但我们有时仍然会这样称呼它。实践中的一个例子是知道新用户喜欢从价格较低的物品开始,因为他们在学习您的运输是否可靠时。一个有用的技术是在第一次订单之前将价格较低的物品提升排名。

虽然考虑构建手动调整排名可能会让人感到不舒服,但重要的是不要排除这种技术。它有一个位置,通常是一个很好的起点。这种技术的一个有趣的人机交互应用是专家手动调整排名。回到我们的时尚推荐器,一个时尚专家可能知道今年夏天流行的颜色是紫红色,特别是在年轻一代中。如果专家将这些紫红色物品为适合的用户提升排名,可能会积极影响用户满意度。

库存健康

硬排名的一个独特而又有争议的方面是库存健康。众所周知,很难定义库存健康,它估计了现有库存对满足用户需求的好坏程度。

让我们快速看一下定义库存健康的一种方法,通过亲和分数和预测。我们可以通过利用需求预测来做到这一点,这是一种非常强大和流行的优化业务的方式:在接下来的N个时间段内,每个类别的预期销售量是多少?建立这些预测模型超出了本书的范围,但这些核心思想在 Rob Hyndman 和 George Athanasopoulos 的著名书籍《Forecasting: Principles and Practice》(Otexts)中得到了很好的捕捉。就我们讨论的目的而言,假设您能大致估计下个月按尺寸和使用类型出售的袜子数量,这可以成为您应该备有各种类型袜子数量的非常有启发性的估计。

然而,事情并不止于此;库存可能是有限的,在实践中,库存通常是销售实物商品企业的主要限制因素。在这种情况下,我们不得不转向市场需求的另一面。如果我们的需求超过了我们的供应能力,最终会让没有得到他们想要物品的用户感到失望。

让我们以销售百吉饼为例;您已经计算了罂粟籽、洋葱、阿斯亚戈芝士和鸡蛋的平均需求。在任何一天,许多顾客会来买心仪的百吉饼,但您是否有足够的百吉饼?您不销售的每一个百吉饼都是浪费;人们喜欢新鲜的百吉饼。这意味着您为每个人推荐的百吉饼都取决于良好的库存。有些用户不那么挑剔;他们可以选择两种或三种选项中的任意一种,同样可以感到满足。在这种情况下,最好为他们提供另一种百吉饼选项,并为挑剔的人节省最低库存。这是一种被称为优化的模型细化,涵盖了大量技术。我们不会深入讨论优化技术,但数学优化或运营研究的书籍会提供方向。Mykel J. Kochenderfer 和 Tim A. Wheeler 的《Algorithms for Optimization》(MIT Press)是一个很好的起点。

库存健康与硬性排名密切相关,因为将库存积极管理作为推荐的一部分是一种非常重要且强大的工具。最终,库存优化将降低您推荐的整体性能,但通过将其纳入业务规则的一部分,可以提高业务和推荐系统的整体健康。这就是为什么有时称为全局优化

这些方法引发激烈讨论的原因在于,并非每个人都认同为了改善“整体利益”,就应该降低某些用户的推荐质量。市场健康和平均满意度是需要考虑的有用指标,但确保它们与整体推荐系统的北极星指标一致。

实施避免

处理规避的最简单方法是通过下游过滤。为此,你需要在推荐从排名器传递给用户之前应用用户的规避规则。实施这种方法看起来像这样:

import pandas as pd

def filter_dataframe(df: pd.DataFrame, filter_dict: dict):
    """
 Filter a dataframe to exclude rows where columns have certain values.

 Args:
 df (pd.DataFrame): Input dataframe.
 filter_dict (dict): Dictionary where keys are column names
 and values are the values to exclude.

 Returns:
 pd.DataFrame: Filtered dataframe.
 """
    for col, val in filter_dict.items():
        df = df.loc[df[col] != val]
    return df

filter_dict = {'column1': 'value1', 'column2': 'value2', 'column3': 'value3'}

df = df.pipe(filter_dataframe, filter_dict)

诚然,这是一个微不足道但也相对天真的规避尝试。首先,纯粹在 pandas 中工作会限制你的推荐系统的可扩展性,所以让我们将其转换为 JAX:

import jax
import jax.numpy as jnp

def filter_jax_array(arr: jnp.array, col_indices: list, values: list):
    """
 Filter a jax array to exclude rows where certain columns have certain values.

 Args:
 arr (jnp.array): Input array.
 col_indices (list): List of column indices to filter on.
 values (list): List of corresponding values to exclude.

 Returns:
 jnp.array: Filtered array.
 """
    assert len(col_indices) == len(values),

    masks = [arr[:, col] != val for col, val in zip(col_indices, values)]
    total_mask = jnp.logical_and(*masks)

    return arr[total_mask]

但还有更深层次的问题。你可能会面临的下一个问题是这些避免的集合存储在哪里。一个显而易见的地方就是像 NoSQL 数据库这样的地方,键入用户,然后你可以将所有的避免作为一个简单的查找获取。这是一个自然的特征存储的用法,就像你在“特征存储”中看到的。有些避免可能在实时应用,而其他一些则在用户入职时学习。特征存储是一个很好的容纳避免的地方。

我们天真的过滤器的下一个潜在问题是它不自然地延伸到协变避免,或者更复杂的避免情景。有些避免实际上取决于上下文——一个在劳动节后不穿白色的用户、周五不吃肉的用户,或者咖啡加工方法与某些冲泡器不搭配的情况。所有这些都需要有条件的逻辑。你可能认为你强大而有效的推荐系统模型肯定可以学会这些细节,但这只有时候是真的。事实是,这些考虑的许多种类都比你的推荐系统应该学习的大规模概念信号要低,因此很难始终学会。此外,这些规则通常是你应该要求的,而不是保持乐观的。因此,你通常应该明确指定这些限制。

这个规范通常可以通过明确的确定性算法来实现这些要求。对于咖啡问题,其中一位作者手工建立了一个决策树桩来处理几种咖啡烘焙特征和冲泡器的不良组合——厌氧浓缩咖啡?呸!

我们的另外两个例子(不在劳动节后穿白色和周五不吃肉)稍微有些微妙。采用显式的算法方法可能会有些棘手。我们怎么知道用户在一年中的某个时期不吃周五的肉呢?

对于这些用例,基于模型的规避可以强制执行这些要求。

基于模型的规避

在我们努力包含更复杂的规则并可能学习它们的过程中,我们可能会听起来像是回到了检索领域。不幸的是,即使是像宽深模型这样有很多参数同时进行用户建模和物品建模的模型,学习这种高级关系也可能会很棘手。

尽管本书的大部分内容都集中在处理相当大和深入的问题上,但推荐系统的这一部分非常适合简单模型。对于基于特征的二元预测(应该推荐这个),我们当然有很多不错的选择。最佳方法显然会严重依赖于在捕捉您希望捕捉的避免时所涉及的特征数量。记住,在本节中考虑的许多避免起初都是假设或假说:我们认为一些用户可能在劳动节后不穿白色,然后试图找到能很好地模拟这一结果的特征。通过这种方式,使用极其简单的回归模型更容易找到与所讨论的结果相关的协变特征。

这个谜题的另一个相关部分是潜在表示。对于我们的周五素食主义者,我们可能正试图推断出一个我们知道有这一规则的特定角色。这个角色是一个我们希望从其他属性中映射出来的潜在特征。在这种建模中要小心(总的来说,角色可能有些微妙,并且值得深思熟虑的决策),但它确实非常有帮助。也许看起来你大型推荐模型的用户建模部分应该学会这些——它们可以!一个有用的技巧是从该模型中提取出已学到的角色,并将它们回归到假设的避免中,以获得更多信号。然而,另一个模型并不总是学习这些角色,因为我们的检索相关性损失函数(以及下游的排名)试图从潜在的角色特征中分析出个别用户的相关性——这些特征可能仅在上下文特征中预测这些避免。

总而言之,实施避免的方法既非常简单又非常困难。在构建生产推荐系统时,当您开始提供服务时,旅程并没有结束;许多模型都会影响到过程的最后一步。

摘要

有时,您需要依赖更经典的方法来确保您向下游发送的推荐满足您企业的基本规则。从用户那里学到的明确或微妙的教训可以转化为简单的策略,继续让他们感到愉悦。

然而,这并非我们服务挑战的终点。另一种下游考虑是与我们在这里所做的过滤类型相关,但源自用户偏好和人类行为。确保推荐不重复、机械和冗余是下一章推荐多样性的主题。我们还将讨论在确定确切的服务内容时如何同时平衡多个优先事项。

第十五章:偏见在推荐系统中的体现

在本书中,我们花了很多时间剖析如何改进我们的推荐,使它们更个性化、更相关于单个用户。在这个过程中,你已经了解到用户和用户人物之间的潜在关系编码了关于共享偏好的重要信息。不幸的是,所有这些都有一个严重的缺点:偏见。

对于我们讨论的目的,我们将谈论推荐系统中两种最重要的偏见:

  • 过于冗余或自相似的推荐集

  • AI 系统学到的刻板印象

首先,我们将深入探讨推荐输出中多样性的关键要素。尽管推荐系统为用户提供相关的选择至关重要,但确保各种推荐也是至关重要的。多样性不仅防止了过度专业化,还促进了新颖和意外的发现,丰富了整体用户体验。

相关性与多样性之间的平衡是微妙而棘手的。这种平衡挑战着算法不仅仅是简单地重复用户的过去行为,而且鼓励探索新的领域,希望提供更全面积极的内容体验。

这种偏见主要是一个技术挑战;我们如何满足多样化推荐和高度相关推荐的多重目标?

我们将把推荐系统中的内在和外在偏见视为潜在的但常常不经意的重要后果,这是由基础算法和它们所学习的数据引起的。数据收集或算法设计中的系统性偏见可能导致有偏见的输出,从而引发道德和公平性问题。此外,它们可能会形成闭环或过滤泡沫,限制用户接触更广泛范围的内容,无意中加强现有的信念。

在本章结束时,我们将讨论这些风险,并提供更多学习资源。我们不是 AI 公平性和偏见方面的专家,但所有机器学习从业者都应该了解并认真考虑这些话题。我们的目标是提供一个介绍和指引。

推荐多样化

我们对抗偏见的第一个投资是明确地在我们的推荐输出中针对更多的多样性。我们将简要介绍您可能追求的许多目标中的两个:列表内多样性和意外推荐。

列表内多样性试图确保在单个推荐列表中存在各种类型的项目。这个想法是尽量减少推荐项目之间的相似性,以减少过度专业化并鼓励探索。在一组推荐中的高列表内多样性增加了用户接触到许多他们可能喜欢的项目的机会;然而,对于任何特定的兴趣,推荐将会更浅,降低了召回率。

意外推荐对用户来说既惊喜又有趣。这些通常是用户可能独立发现或系统中普遍不太受欢迎的物品。通过注入非显而易见或意想不到的选择,即使这些选择与用户的亲和力得分相对较低,也可以在推荐过程中引入意外性,以提高整体的意外性。在理想情况下,这些意外选择相对于其流行度的其他物品具有较高的亲和力,因此它们是“外部选择中的精品”。

提高多样性

现在我们有了多样性的度量标准,我们可以明确地尝试去改善它们。重要的是,通过将多样性指标作为我们目标之一,我们可能会在诸如召回率或 NDCG 等方面牺牲性能。把这看作是一个帕累托问题或者在追求多样性时强加一个排名度量性能的下限可能是有用的。

注意

帕累托问题中,你经常需要权衡两个优先级。在许多机器学习领域,以及更普遍的应用数学中,某些结果存在自然的紧张关系。在推荐系统中,推荐多样性是帕累托问题的一个重要例子,但这并不是唯一的情况。在第十四章中,你简要了解了全局优化,这是权衡的一个极端案例。

改进多样性度量的一个简单方法是重新排序:这是一个后处理步骤,其中最初检索到的推荐列表被重新排序以增强多样性。各种重新排序算法不仅考虑相关性分数,还考虑推荐列表中物品之间的不相似性。重新排序是一种可以操作任何外部损失函数的策略,因此将其用于多样性是一个直接的方法。

另一种策略是打破我们在“推荐系统评估的倾向性加权”部分讨论的推荐反馈的封闭循环。就像多臂老虎机问题一样,探索-利用的权衡可以在选择利用模型知道用户喜欢的内容和探索不太确定但可能获得更高回报的选项之间进行选择。通过偶尔选择探索并推荐不太明显的选择,可以在推荐系统中使用这种权衡来确保多样性。为了实现这样的系统,我们可以使用亲和力作为奖励估计值,使用倾向性作为利用度量。

而不是使用这些后验策略,一个替代方法是将多样性作为学习过程中的一个目标或在损失函数中包含一个多样性正则化项。包括成对相似性的多目标损失可以帮助模型学习多样化的推荐集合。您之前看到过,各种正则化可以指导训练过程以最小化某些行为。一个可以显式使用的正则化项是推荐间的相似性;推荐中每个嵌入向量的点积可以近似表示这种自相似性。让 ℛ = ( R 1 , R 2 , ... , R k ) 成为推荐的嵌入列表,然后将 ℛ 视为一个列矩阵——其中每行都是一个推荐。计算 ℛ 的格拉姆矩阵将产生所有点积相似性计算,因此我们可以通过适当的超参数权重来通过这个项进行正则化。请注意,这与我们先前的格拉姆矩阵正则化不同,因为这种情况下我们只考虑个别查询的推荐。

最后,我们可以利用多个领域的排名来提高推荐的多样性。通过整合各种排名措施,推荐系统可以建议用户“模式”之外的项目,从而扩展推荐范围。围绕多模态推荐存在着活跃的学科,Pinterest 的 PinnerSage 论文 就是一个特别引人注目的实现。在许多关于多模态推荐的作品中,检索步骤返回的推荐列表中有太多接近用户查询向量的推荐。这强制了检索列表中的自相似性。多模态强制使用多个查询向量来处理每个请求,从而实现内置的多样性。

让我们从另一个角度看待项目的自相似性,并考虑如何利用项目之间的成对关系来实现这一目标。

应用投资组合优化

投资组合优化,这是从金融中借鉴的概念,可以是增强推荐系统多样性的有效方法。这里的目标是创建一个平衡关键参数(相关性和多样性)的“投资组合”推荐项目列表。

在其核心,投资组合优化关乎风险(在我们的案例中是相关性)和回报(多样性)。以下是将此优化应用于推荐系统的基本方法:

  1. 制定一个项目表示,以便空间中的距离是相似性的良好度量。这与我们先前讨论过的构建良好潜在空间的理念一致。

  2. 计算项目之间的成对距离。您可以通过使用丰富您的潜在空间的任何距离度量来完成此操作。重要的是要计算检索到的所有项目之间的这些成对距离,并准备考虑回报。请注意,如何聚合这些距离分布可能是微妙的。

  3. 评估检索集的亲和力。请注意,校准后的亲和力分数表现更好,因为它们提供了对回报的更现实的估计。

  4. 解决优化问题。解决问题将为每个项目产生一个权重,平衡关联性和多样性之间的权衡。具有更高权重的项目在关联性和多样性方面更有价值,应优先考虑放在推荐列表中。从数学上讲,问题看起来是这样的:

    M a x i m i z e ( w T r - λ w T C w )

    这里,w是表示权重的向量(即推荐列表中每个项目的比例),r是关联性分数向量,C是协方差矩阵(捕获多样性),λ是平衡关联性和多样性的参数。约束条件是权重的总和等于 1。

    记住,超参数λ在关联性和多样性之间进行权衡。这使其成为该过程的关键部分,并可能根据系统及其用户的具体需求进行实验或调整。这可以通过诸如 Weights & Biases 等许多包的超参数优化来直接进行。

多目标函数

多样性的另一个相关方法是基于多目标损失进行排名。与排名阶段纯粹的个性化亲和力不同,引入第二(或更多!)排名项可以显著提高多样性。

这里最简单的方法类似于您在第 14 章学到的:硬排名。可能适用于多样性的业务规则是将每个项目类别限制为仅一个项目。这是多目标排名的最简单情况,因为按照分类列排序并选择每组中的最大值将实现相对于该协变量的显式多样性。让我们转向更微妙的内容。

“为基于查询的推荐拼接空间”中,本书的一位作者与共同作者 Ian Horn 合作实现了一个多目标推荐系统,该系统在解决图像检索问题时平衡了个性化和关联性。

目标是为用户上传的图像中与衣物相似的服装提供个性化推荐。这意味着存在两个潜在空间:

  • 个性化服装到用户的潜在空间

  • 服装图像的潜在空间

要解决这个问题,我们首先需要做出一个决定:在相关性方面,什么更重要?个性化还是图像相似性?因为产品围绕着照片上传体验,我们选择了图像相似性。然而,我们还需要考虑另一个事实:每个上传的图像包含多件服装。正如在计算机视觉中流行的那样,我们首先将模型分割成几个单独的项目,然后将每个项目视为其自身的查询(我们称之为锚点项目)。这意味着我们的图像相似性检索是多模态的,因为我们使用了几个不同的查询向量进行搜索。在我们收集完所有这些数据后,我们需要进行最终排名——一个关于图像相似性和个性化的多目标排名。我们优化的损失函数如下所示:

s i = α × ( 1 - d i ) + ( 1 - α ) × a i

α 是一个超参数,表示权重,d i 是图像距离,而 a i 是个性化的参数。我们通过实验来学习 α。最后一步是施加一些严格的排名规则,以确保每个推荐来自每个锚点。

所以让我们总结一下:

  1. 我们使用两个潜在空间的距离来提供排名。

  2. 我们通过图像分割进行了多模态检索。

  3. 我们只使用了其中一个排名来检索。

  4. 我们的最终排名是多目标的,利用了所有我们的潜在空间和业务逻辑。

这使得我们的推荐在一定程度上是多样化的,因为它们在与不同项目对应的查询的几个领域中实现了相关性。

谓词下推

在服务期间,您可能会很高兴并且舒适地应用这些指标——毕竟,这是本书的这一部分的标题——但在我们离开这个话题之前,我们应该讨论一个可能会带来严重后果的边缘情况。当您从第十四章中强制施加严格规则以及本章早些时候讨论的多样性期望,并进行一些多目标排名时,有时您会得出……没有推荐。

假设你开始通过检索 k 个项目,但在足够多的满足业务规则的多样化组合之后,实际上没有任何剩余项目了。你可能会说:“我只是多检索一些项目;让我们增加 k!”但这会带来一些严重问题:它可能会显著增加延迟,降低匹配质量,并扰乱你的排名模型,该模型更适合较低基数集。

一个常见的经验,特别是在多样性方面,是检索的不同模式具有极大不同的匹配分数。举一个我们时尚推荐器世界的例子:所有牛仔裤可能比我们拥有的任何衬衫都更匹配,但如果您正在寻找多样化的服装类别进行推荐,无论 k 有多大,您可能会错过衬衫。

这个问题的一个解决方案是谓词下推。这种优化技术用于数据库,特别是在数据检索的上下文中。谓词下推的主要思想是尽早在数据检索过程中进行数据过滤,以减少后续查询执行计划中需要处理的数据量。

对于传统数据库,例如“将我的查询的where子句应用于数据库以减少 I/O。”,谓词下推可以通过显式地首先拉取相关列以检查where子句,然后获取通过的行 ID,再执行其余查询,来实现此目的。

这如何帮助我们的案例呢?简单来说,如果你的向量存储还具有向量的特征,你可以将特征比较作为检索的一部分。让我们举一个过于简单的例子:假设你的项目有一个名为color的分类特征,为了获得良好的多样化推荐,你希望在你的五个推荐中有至少三种颜色的好组合。为了实现这一点,你可以在你的存储中的每种颜色上进行 top-k搜索(缺点是你的检索增加了C倍,其中C是存在的颜色数量),然后在这些集合的并集上进行排名和多样性评估。这有很高的可能性能够在最终推荐中符合你的多样性规则。这是很棒的!我们期望检索的延迟相对较低,因此如果我们知道在哪里查找,这种额外的检索负担并不糟糕。

如果你的向量存储为所需的过滤器设置得当,这种优化技术可以应用于相当复杂的谓词。

公平性

一般而言,机器学习中的公平性是一个非常微妙的主题,短小的摘要往往难以服务。以下主题至关重要,我们建议您考虑这里包含的强大参考资料:

推动

公平性不需要仅仅是“所有结果的等概率”,它可以在特定协变量的背景下公平。通过推荐者进行推动,即推荐项目以强调某些行为或购买模式,可以增加公平性。考虑 Spotify 的 Karlijn Dinnissen 和 Christine Bauer 关于使用推动来改善音乐推荐中性别表示的工作。

滤泡效应

滤泡效应是极端协同过滤的一个不利方面:一组用户开始喜欢类似的推荐,系统学习到他们应该接收类似的推荐,这种反馈循环会持续下去。要深入了解这一概念及缓解策略,可以参考 Zhaolin Gao 等人的《“缓解过滤泡效应同时保持相关性”》。

高风险

并非所有的 AI 应用在风险上都是相等的。一些领域在 AI 系统的保护不力时尤其有害。有关最高风险情况和缓解措施的一般概述,请参阅 Patrick Hall 等人(O’Reilly)的Machine Learning for High-Risk Applications

可信度

可解释性模型是 AI 风险应用的一种流行的缓解策略。虽然可解释性并不能解决问题,但它经常提供了一条向识别和解决问题的路径。关于这一点的深入探讨,Yada Pruksachatkun 等人(O’Reilly)的Practicing Trustworthy Machine Learning提供了工具和技术。

推荐中的公平性

由于推荐系统显然容易受到人工智能公平性问题的影响,关于这个主题已经有很多文章写作。每个主要社交媒体巨头都设有从事 AI 安全工作的团队。其中一个特别亮点是由 Rumman Chowdhury 领导的 Twitter 负责 AI 团队。您可以阅读 Alfred Ng 的文章"Can Auditing Eliminate Bias from Algorithms?"了解团队的工作。

总结

虽然这些技术提供了增强多样性的途径,但重要的是要记住在多样性和相关性之间取得平衡。使用的确切方法或方法组合可能会因具体用例、可用数据、用户群体的复杂性以及收集反馈的类型而有所不同。在实施推荐系统时,请考虑哪些方面在解决多样性问题中最为关键。

第十六章:加速结构

那么什么是加速结构?在计算机科学术语中,当您尝试逐个对语料库中的每个项目进行排名时,如果有N个项目,通常需要的时间与N成正比。这称为大 O 符号。因此,如果您有一个用户向量,并且有一个包含N个项目的语料库,那么通常需要O(N)时间来为用户评分语料库中的所有项目。如果N很小且可以容纳在 GPU 内存中,通常N < 100 万个项目左右,这通常是可处理的。但是,如果我们有一个非常大的语料库,例如十亿个项目,如果我们还必须为十亿个用户进行推荐,那么在大 O 符号中,为每个十亿个用户评分十亿个项目将需要O(10¹⁸)的点积运算。

在本章中,我们将尝试将O(N * M)的时间减少到与物品数N和用户数M的数量成比例的子线性时间。我们将讨论包括以下策略:

  • 划分

  • 局部敏感哈希

  • k-d 树

  • 分层 k 均值

  • 更便宜的检索方法

我们还将涵盖与每种策略相关的权衡及其可能用途。在所有以下示例中,我们假设用户和项目由相同大小的嵌入向量表示,并且用户和项目之间的关系是简单的点积、余弦距离或欧几里得距离。如果我们要使用像两塔模型这样的神经网络来为用户和项目评分,那么可能唯一可用于加速的方法可能是划分或某种更便宜的预过滤方法。

划分

划分可能是最简单的策略,用于分而治之。假设您有k台机器、N个项目和M个用户。使用划分策略,您可以将运行时间减少到O(N * M / k)。您可以通过为每个项目分配一个唯一标识符来做到这一点,因此您有(unique_id, item_vector)的元组。然后,通过简单地取machine_id = unique_id % K,我们可以将语料库的子集分配给不同的机器。

当用户需要推荐时,我们可以预先计算或按需计算排名靠前的推荐结果,通过将工作负载分布到k台机器上,从而使计算速度提高k倍,除了在服务器上收集并联合排序顶部结果时的开销。请注意,如果您想要例如前 100 名的最高得分项目,您仍然需要从每个分片获取前 100 个结果,将它们合并在一起,然后联合排序所有结果,这样才能获得与全文本评分方法相同的结果。

划分在可以与任何其他加速方法结合,并且不依赖于具有任何特定形式(如单个向量)的表示方式方面非常有用。

局部敏感哈希

局部敏感哈希(LSH)是一种将向量转换为基于标记的表示的有趣技术。这是强大的,因为如果 CPU 容易获得,我们可以使用它们通过使用更便宜的整数算术操作(如 XOR 和位计数)来计算向量之间的相似性,而不是浮点运算。整数操作在 CPU 上比浮点运算快得多,因此我们可以比使用向量操作更快地计算项目之间的相似性。

另一个好处是,一旦项目表示为一系列标记,常规搜索引擎数据库可以通过使用标记匹配来存储和检索这些项目。另一方面,常规哈希倾向于在输入发生轻微变化时产生截然不同的哈希码。这并不是对哈希函数的批评;它们只是针对不同类型数据的不同用途。

让我们来看一下将向量转换为哈希值的几种方法。LSH 与常规哈希不同之处在于,对向量的小扰动应导致与原始向量的哈希位相同。这是一个重要的特性,因为它允许我们通过使用快速方法(如哈希映射)来查找向量的邻域。一种简单的哈希方法称为比较推理的力量,或者全胜哈希。在这种哈希方案中,向量首先通过已知的、可重现的置换进行排列。我们可以通过简单地使用接受种子并可靠地复现相同洗牌序列的随机数生成器来生成这种已知置换。重要的是,这种置换在不同版本的 Python 中是稳定的,因为我们希望在生成哈希时以及检索时都能够复现哈希操作。由于我们使用的是 JAX 的随机库,而 JAX 对于置换的可重现性非常注意,因此我们直接使用 JAX 中的置换函数。之后的哈希码计算只是对置换向量的相邻维度进行比较,如示例 16-1 所示。

示例 16-1. 全胜
def compute_wta_hash(x):
  """Example code to compute some Winner take all hash vectors
 Args:
 x: a vector
 Result:
 hash: a hash code
 """
  key = jax.random.PRNGKey(1337)
  permuted = jax.random.permutation(key, x)

  hash1 = permuted[0] > permuted[1]
  hash2 = permuted[1] > permuted[2]

  return (hash1, hash2)

x1 = jnp.array([1, 2, 3])
x2 = jnp.array([1, 2.5, 3])
x3 = jnp.array([3, 2, 1])
x1_hash = compute_wta_hash(x1)
x2_hash = compute_wta_hash(x2)
x3_hash = compute_wta_hash(x3)
print(x1_hash)
print(x2_hash)
print(x3_hash)

(Array(False, dtype=bool), Array(True, dtype=bool))
(Array(False, dtype=bool), Array(True, dtype=bool))
(Array(True, dtype=bool), Array(False, dtype=bool))

正如你所见,向量x2x1略有不同,结果是相同的哈希码01,而x3不同,结果是哈希码10。然后使用哈希码的海明距离计算两个向量之间的距离,如示例 16-2 所示。距离简单地是两个哈希码的异或,即每当位不同时结果为 1,随后进行位计数。

示例 16-2. 海明函数
x = 16
y = 15
hamming_xy = int.bit_count(x ^ y)
print(hamming_xy)
5

如此处所示,使用汉明距离可以加快距离计算速度,但主要的加速来自于在哈希映射中使用哈希码。例如,我们可以将哈希码分成 8 位块,并将语料库存储在由每个 8 位块键入的分片中,这将导致 256 倍的加速,因为我们只需查找具有与查询向量相同键的哈希映射。

这在召回方面有一个缺点,因为所有 8 位必须匹配才能检索与查询向量匹配的项。在使用哈希和汉明距离计算时存在一个折衷。哈希码位数越大,搜索速度越快,因为语料库被分割成越来越小的块。然而,缺点是越来越多的位必须匹配,因此在原始空间中的相邻向量中,所有哈希码位可能不匹配,因此可能不会被检索。

解决方法是使用多个具有不同随机数生成器的哈希码,并使用不同的随机种子重复此过程几次。这个额外步骤留作你的练习。

另一种常见的计算哈希位的方法使用约翰逊-林登斯特劳斯引理,这是说,当两个向量与同一个随机高斯矩阵相乘时,它们倾向于在相似的位置结束。然而,L2 距离被保留,这意味着当使用欧氏距离来训练嵌入时,这种哈希函数效果更好。在这种方案中,只有哈希码计算不同;汉明距离处理完全相同。

LSH 的加速与哈希码的精确匹配位数成正比。假设哈希映射中仅使用了哈希码的 8 位,那么加速比就是 2⁸,即比原始速度快 256 倍。速度的折衷是需要将哈希映射存储在内存中。

k-d Trees

计算机科学中加速计算的一种常见策略是分而治之。在这种方案中,数据被递归地分成两半,只搜索与搜索查询相关的半部分。与语料库中项数的线性O(n)相比,分而治之算法能在O(log2(n))的时间内查询语料库,如果n很大,这将显著加快速度。

一种用于向量空间的这种二叉树称为k-d 树。通常,构建k-d 树时,我们计算集合中所有点的边界框,找到边界框的最长边,并沿着该边的中间在分割维度上进行分割,然后将集合分成两半。如果使用中位数,则集合在该分割维度上被更多或更少地分成两半;我们说更多或更少,因为在该分割维度上可能存在平局。递归过程在叶子节点中剩余少量项时停止。有许多k-d 树的实现,例如SciPy 的k-d 树

尽管加速效果显著,但这种方法在向量的特征维度较低时更有效。同其他方法类似,k-d 树在欧氏距离作为嵌入的度量时效果最佳。如果使用点积作为相似度度量,可能会造成检索损失,因为k-d 树更适合于欧几里得空间的划分。

示例 16-3 提供了分割一批点的示例代码,沿着最大维度进行分割。

示例 16-3. 通过k-d 树进行分区
import jax
import jax.numpy as jnp

def kdtree_partition(x: jnp.ndarray):
  """Finds the split plane and value for a batch of vectors x."""
  # First, find the bounding box.
  bbox_min = jnp.min(x, axis=0)
  bbox_max = jnp.max(x, axis=0)
  # Return the largest split dimension and value.
  diff = bbox_max - bbox_min
  split_dim = jnp.argmax(diff)
  split_value = 0.5 * (bbox_min[split_dim] + bbox_max[split_dim])
  return split_dim, split_value

key = jax.random.PRNGKey(42)
x = jax.random.normal(key, [256, 3]) * jnp.array([1, 3, 2])
split_dim, split_value = kdtree_partition(x)
print("Split dimension %d at value %f" % (split_dim, split_value))

# Partition the points into two groups, the left subtree
# has all the elements left of the splitting plane.
left = jnp.where(x[:, split_dim] < split_value)
right = jnp.where(x[:, split_dim] >= split_value)

Split dimension 1 at value -0.352623

如代码所示,k-d 树分割代码可以简单地沿着最长维度的中间进行分割。其他可能性包括沿着最长维度的中位数分割或者使用表面积启发式

k-d 树通过重复沿着一个空间维度(通常沿着数据分布最广的主轴)进行数据分割来构建;参见 图 16-1。

KD-树构建

图 16-1. k-d 树构建的初始边界框

分割通常会再次递归地沿着最长轴进行细分,直到分割中的点数少于所选的小数目;参见 图 16-2。

k-d 树的查找时间为O(log2(n)),其中n为语料库中的项数。树本身需要一些额外的内存开销来存储,主要由叶子节点数量决定,因此最好在叶子节点中具有最小数量的项,以防止分割过细。

KD-树递归步骤

图 16-2. k-d 树递归分割

从根节点开始,重复检查查询点(例如,我们正在寻找最近邻居的项目)是否在根节点的左侧或右侧子节点中,如图 16-3 所示。例如,使用go_left = x[split_dim] < value_split[dim]。在二叉树约定中,左子节点包含所有在分割维度上值小于分割值的点。因此,如果查询点在分割维度上的值小于分割值,则向左移动,否则向右移动。递归地沿着树向下降,直到达到叶节点;然后详尽计算所有叶节点中项目到查询点的距离。

KD 树查询

图 16-3. k-d 树查询

k-d 树存在一个潜在的缺点。如果一个项目接近分割平面,该项目将被认为在树的另一侧。因此,该项目不会被考虑为最近邻居候选。在某些k-d 树的实现中,称为溢出树,如果查询点足够接近平面的决策边界,则会访问分割平面的两侧。这种改变稍微增加了运行时,以换取更高的召回率。

分层k-均值

另一种可以扩展到更高特征维度的分而治之策略是k-均值聚类。在这种方案中,语料库被聚类成k个聚类,然后递归地聚类成k个更多的聚类,直到每个聚类小于一个定义的限制。

k-均值的实现可以在scikit-learn 的网页找到。

要构建聚类,首先从现有点随机创建聚类中心(见图 16-4)。

K 均值初始化

图 16-4. k 均值初始化

接下来,我们将所有点分配到它们最接近的聚类中。然后对于每个聚类,我们将所有分配点的平均值作为新的聚类中心。我们重复此过程直到完成,这可以是固定步骤的数量。图 16-5(见图 16-5)说明了这个过程。然后的输出是k个点的聚类中心。可以再次为每个聚类中心重复此过程,再次分割成k个更多的聚类。

K 均值聚类

图 16-5. k 均值聚类

再次,速度提升是O(log(n))的项目数量,但k-均值比k-d 树更适合于聚类高维数据点。

对于k-均值聚类的查询非常直接。您可以找到最接近查询点的最接近聚类,然后对所有子聚类重复该过程,直到找到叶节点为止;然后对叶节点中的所有项目与查询点进行评分。

一种替代k-means 的方法是执行 SVD,并使用前k个特征向量作为聚类标准。使用 SVD 有趣之处在于存在像power iteration这样的封闭形式和近似方法来计算特征向量。使用点积来计算亲和力可能更适合使用点积训练的向量作为亲和度度量。

要了解更多相关信息,您可以参考 Jason Weston 等人撰写的“标签分区用于次线性排名”。该论文比较了 LSH、SVD 和分层k-means 的性能提升及检索损失,以暴力方法为基准。

基于图的 ANN

在 ANN 中的一个新兴趋势是使用基于图的方法。最近,分层可导航小世界是一种特别流行的方法。这种图算法在多层结构中编码接近性,然后依赖于通用的“从一个节点到另一个节点的连接步数通常惊人地少”的最大值。在基于图的 ANN 方法中,通常找到一个邻居,然后遍历与该邻居连接的边以快速找到其他节点。

更便宜的检索方法

如果您的语料库能够进行逐项便宜的检索方法,加快搜索的一种方式是使用便宜的检索方法获取一小部分项目,然后使用更昂贵的基于向量的方法对子集进行排名。其中一种便宜的检索方法是制作一个项目与另一个项目的顶部共现的帖子列表。然后,当生成用于排名的候选集时,将用户首选项目中的所有顶部共现项目收集在一起,并与 ML 模型一起对它们进行评分。通过这种方式,我们不必对整个语料库使用 ML 模型进行评分,而只需对一个小子集进行评分。

摘要

在本章中,我们展示了一些加快语料库中项目检索和评分的方法,给定一个查询向量,同时不会太多地损失召回率,同时仍保持精度。没有一种 ANN 方法是完美的,因为加速结构依赖于数据的分布,而这在数据集之间是不同的。我们希望本章能为您提供一个探索使检索更快且与语料库中项目数量亚线性相关的各种方法的起点。

第五部分:推荐的未来

我渴望更多。公司在产品方面做了什么?

我们已经走了这么远,但还有很多更多的内容!推荐系统发展迅速,了解一下明年会议上你将看到的概念是很值得的。这些想法在某种程度上已经被证明是有效的 —— 它们没有一个是纯科幻,但它们还没有完全定型。

在我们深入探讨这几个非常现代的想法之前,值得注意的是这本书中我们没有涵盖的所有主题。我们最严重的遗漏可能是强化学习技术和与共形方法相关的思想。这两者都是推荐系统的重要方面,但它们都需要显著不同的背景和处理方式,因此不适合纳入这里的结构中。此外,它们在 JAX 生态系统中也没有很好地引入,因此它们更难以支持。

当你的规模足够大时,前几章不再能为你提供帮助,接下来的章节将向你展示如何升级。在撰写本文时,所有这些方法都已在市值超过 100 亿美元的财富 500 强公司中投入生产。学习这些概念,然后去打造下一个 TikTok。

第十七章:顺序推荐

在我们迄今的旅程中,您已经了解了出现在推荐问题中的各种显式或潜在的特征。一种已经隐含出现的特征是以前的推荐和交互历史。您可能想在这里提出异议:“到目前为止,我们所做的所有工作都考虑了以前的推荐和交互!我们甚至学习了关于预测训练数据的内容。”

这是真的,但它未能考虑到关于导致推理请求的推荐序列之间更明确的关系。让我们通过一个例子来区分这两者。您的视频流网站知道您以前看过达伦·阿伦诺夫斯基的所有电影,因此当The Whale发布时,该网站很可能会推荐它。但这种类型的推荐与您在观看Succession第 10 集之后可能收到的推荐不同。您可能已经在长时间内观看了阿伦诺夫斯基的电影——Pi是多年前,Black Swan则是今年早些时候。但您本周每晚都在观看Succession的一集,并且您整个最近的历史记录都是关于洛根·罗伊。后一种情况是一个顺序推荐问题:根据最近的有序交互列表预测您下一个可能喜欢的内容。

就建模目标而言,我们所见过的推荐系统使用了潜在的推荐和历史交互之间的成对关系。顺序推荐的目标是基于过去的顺序交互来预测用户的下一个动作,这些交互可能更高阶——即三个或更多项之间的交互组合。大多数顺序推荐模型涉及顺序数据挖掘技术,如马尔可夫链、递归神经网络(RNN)和自注意力。这些模型通常考虑短期用户行为,对随时间稳定的全局用户偏好甚至无视。

顺序推荐的最初工作集中于建模连续项目之间的过渡。这些方法使用了马尔可夫链和基于转换的方法。随着深度学习方法在建模顺序数据方面表现出越来越多的潜力——例如它们在自然语言处理中的最大成功——人们开始尝试使用神经网络架构来建模用户交互历史的顺序动态。在这方面的早期成功包括使用 RNN 来模拟用户的顺序交互的 GRU4Rec。最近,变压器架构展示了优越的顺序数据建模性能。变压器架构适合有效的并行化,并且在建模长序列方面非常有效。

马尔可夫链

尽管在历史推荐中寻找关系,我们所考虑的模型通常未能捕捉用户行为的顺序模式,因此忽略了用户互动的时间顺序。为了解决这一缺点,开发了顺序推荐系统,其中包括使用马尔可夫链等技术来建模项目之间的时间依赖关系。

马尔可夫链是一种随机模型,基于无记忆性原则运作。它模拟了从一个状态转移到另一个状态的概率——在给定当前状态的情况下——而不考虑先前事件的顺序。马尔可夫链通过将每个状态视为一个项,将转换概率视为用户在当前项之后与某个项互动的可能性,来建模用户的顺序行为。

第一阶马尔可夫链,即未来状态仅依赖于当前状态的模型,在早期的顺序推荐系统中是一种常见策略。尽管简单,第一阶马尔可夫链有效地捕捉到了短期的、项到项的转换模式,从而提高了推荐质量,超过了非序列方法。

举例来说,我们前面的Succession示例。如果仅使用一阶马尔可夫链,一个非常好的启发式方法是“如果是系列的话,下一集是什么;否则,退回到协同过滤(CF)模型。” 你可以看到,对于大部分的观看时间,这个简单的一阶链会告诉用户简单地观看系列的下一集。这并不是特别启发式,但是是一个好迹象。当你进一步抽象时,你开始得到更强大的方法。

在实际应用中,第一阶段的假设并不总是成立,因为用户行为通常受到较长历史互动的影响。为了克服这一限制,更高阶的马尔可夫链向前看更远:下一个状态由一组先前状态决定,提供了更丰富的用户行为模型。然而,选择适当的阶数至关重要,因为阶数过高可能导致过拟合和转移矩阵的稀疏性。

二阶马尔可夫链

考虑一个关于使用天气的二阶马尔可夫链模型的示例。假设我们有三个状态:晴天( S )、多云( C )和雨天( R )。

在二阶马尔可夫链中,今天的天气( t )将取决于昨天( t - 1 )和前天( t - 2 )的天气。转移概率可以表示为 P ( S t | S t-1 , S t-2 ) 。

马尔可夫链可以由转移矩阵来定义,该矩阵提供了从一个状态转移到另一个状态的概率。但是,因为我们处理的是二阶马尔可夫链,我们将有一个转移张量。为简单起见,让我们假设我们有以下转移概率:

P ( S | S , S ) = 0 . 7 , P ( C | S , S ) = 0 . 2 , P ( R | S , S ) = 0 . 1 , P ( S | S , C ) = 0 . 3 , P ( C | S , C ) = 0 . 4 , P ( R | S , C ) = 0 . 3 , ...

您可以在一个三维立方体中可视化这些概率。前两个维度表示今天和昨天的状态,第三个维度表示明天的可能状态。

如果过去两天的天气是晴朗的,并且我们想预测明天的天气,我们将查看从 ( S , S ) 开始的转移概率,分别是 P ( S | S , S ) = 0 . 7 、P ( C | S , S ) = 0 . 2 和 P ( R | S , S ) = 0 . 1 。因此,根据我们的模型,有 70% 的可能是晴天,20% 的可能是多云,10% 的可能是雨天。

转移矩阵(或张量)中的概率通常是根据数据估计的。如果您有几年天气的历史记录,您可以计算每个转移发生的次数,并除以总转移次数来估计概率。

这只是一个二阶马尔可夫链的基本演示。在实际应用中,状态可能更多,转移矩阵可能更大,但原则仍然相同。

其他马尔可夫模型

更高级的马尔可夫方法是马尔可夫决策过程MDP),通过引入动作和奖励扩展了马尔可夫链。在推荐系统的背景下,每个动作可以代表一个推荐,奖励可以是用户对推荐的响应。通过整合用户反馈,MDP 可以学习更个性化的推荐策略。

MDPs 被定义为一个四元组 ( S , A , P , R ) ,其中 S 是状态集合, A 是动作集合, P 是状态转移概率矩阵, R 是奖励函数。

让我们以电影推荐系统的简化 MDP 为例:

状态( S )

这些可以代表用户过去观看的电影类型。为简单起见,假设我们有三种状态:喜剧( C )、剧情片( D )和动作片( A )。

动作( A )

这些可以代表可以推荐的电影。例如,我们假设有五个动作(电影):电影 1、2、3、4 和 5。

过渡概率( P )

这表示在给定特定动作的情况下,从一个状态过渡到另一个状态的可能性。例如,如果用户刚看过一部剧情片( D ),我们推荐电影 3(这是一部动作片),过渡概率 P ( A | D , M o v i e 3 ) 可能为 0.6,表示用户再次观看动作片的概率为 60%。

奖励( R )

这是用户在采取行动(推荐)后的反馈。为简单起见,假设用户点击推荐的电影会得到 +1 的奖励,没有点击则为 0 的奖励。

在这种情况下,推荐系统的目标是学习一个策略 π : S → A,以最大化预期累积奖励。策略指导代理(推荐系统)在每个状态下应该采取的行动。

这个策略可以通过强化学习算法(如 Q-learning 或策略迭代)来学习,这些算法本质上是学习在状态中采取行动的价值(例如,在用户观看某种类型电影后推荐电影),考虑即时奖励和潜在的未来奖励。

在现实世界的推荐系统场景中,主要挑战在于状态和动作空间都非常大,而过渡动态和奖励函数可能复杂且难以准确估计。但是,这个简单示例中展示的原则依然适用。

尽管基于马尔可夫链的推荐系统表现出有希望的性能,仍然存在一些挑战。马尔可夫链的无记忆假设在存在长期依赖关系的某些情景中可能不成立。此外,大多数马尔可夫链模型将用户-项目交互视为二进制事件(交互或无交互),这简化了用户可能与项目进行的各种交互,例如浏览、点击和购买。

接下来,我们将介绍神经网络。我们将看到你可能熟悉的一些体系结构如何与学习顺序推荐任务相关。

RNN 和 CNN 体系结构

循环神经网络(RNNs)是一种设计用于识别数据序列中模式的神经网络体系结构,例如文本、语音或时间序列数据。这些网络在循环中,即序列中一个步骤的输出被反馈到网络作为下一个步骤处理时的输入。这赋予了 RNNs 一种记忆形式,对于像语言建模这样的任务非常有帮助,其中每个词依赖于前面的词。

在每个时间步骤,RNN 接收一个输入(例如句子中的一个单词)并生成一个输出(例如下一个单词的预测)。它还更新内部状态,这是其在序列中“看到”的内容的表示。这个内部状态在处理下一个输入时被传回网络。因此,网络可以利用先前步骤的信息来影响当前步骤的预测。这就是 RNN 有效处理序列数据的原因。

GRU4Rec 使用循环神经网络来建模基于会话的推荐,在推荐问题的神经网络体系结构中是最早的应用之一。会话指的是用户交互的单一连续期间,例如在页面上花费的时间,而没有用户导航离开或关闭计算机。

这里我们将看到顺序推荐系统的一个显著优势:大多数传统推荐方法依赖于显式的用户 ID 来构建用户兴趣模型。然而,基于会话的推荐系统操作匿名用户会话,这些会话通常非常短,以允许进行个人资料建模。此外,在不同会话中用户动机可能会有很大的变化。针对这种推荐情况的无关用户推荐的解决方案是基于项目的模型,其中计算了在单个会话中共现的项目的项目-项目相似性矩阵。这个预先计算的相似性矩阵在运行时用于推荐最相似的上一个点击的项目。这种方法显然有明显的局限性,比如仅依赖于最后点击的项目。为此,GRU4Rec 使用会话中的所有项目,并将会话建模为项目序列。推荐要添加的项目的任务转化为预测序列中的下一个项目。

不同于语言的小固定大小词汇表,推荐系统需要处理随着添加更多项目而逐渐增长的大量项目。为了处理这个问题,考虑了成对排名损失(例如,BPR)。GRU4Rec 进一步在 GRU4Rec+ 中进行了扩展,该模型利用了专门设计用于顶部 k 推荐增益的新损失函数。这些损失函数融合了深度学习和 LTR,以解决神经推荐设置中的问题。

用于推荐的神经网络的另一种方法采用了 CNN 用于顺序推荐。我们不会在这里介绍 CNN 的基础知识,但您可以参考 Brandon Rohrer 的 “How Do Convolutional Neural Networks Work?” 来了解基本内容。

让我们讨论一种表现出了很大成功的方法,CosRec,如 Figure 17-1 所示。这种方法(及其他方法)以类似于本书大部分内容中使用的 MF 结构开始:一个用户-项目矩阵。我们假设有两个潜在因子矩阵,E ℐ 和 E 𝒰,但让我们首先关注项目矩阵。

项目矩阵中的每个向量都是单个项目的嵌入向量,但我们希望编码序列:取长度为L的序列,并收集这些嵌入向量。现在我们有一个L × D矩阵,每个序列中的项目都有一行。将相邻行作为对,并为三维张量中的每个向量连接它们;这有效地捕获了序列作为一系列成对转换。这个三维张量可以通过矢量化的二维 CNN 传递,生成一个向量(长度为L ),这个向量与原始用户向量连接并通过完全连接的层传递。最后,二元交叉熵是我们的损失函数,试图预测最佳推荐。

来自 Yan 等人 2019 年的 CosRec CNN 架构

图 17-1。CosRec CNN

注意力架构

一个与神经网络常见相关的术语,现在可能让你有所耳闻的是注意力。这是因为变压器,特别是出现在大型语言模型(LLM)中的那种,如广义预训练变压器,已成为人工智能用户的中心关注点。

我们将在这里提供一个极为简要且不太技术性的自我关注和变压器介绍。要了解更详细的变压器指南,请参阅 Brandon Rohrer 撰写的优秀概述“从零开始的变压器”

首先,让我们阐明关于变压器模型的一个关键区别性假设:嵌入是有位置的。我们希望不仅为每个项目学习一个嵌入,而且为每个项目-位置对学习一个嵌入。因此,当一篇文章是会话中的第一个和最后一个时,这两个实例被视为两个独立的项目

另一个重要的概念是堆叠。在构建变压器时,我们经常将架构想象成一层一层堆叠的层蛋糕。关键组件包括嵌入、自我关注层、跳跃添加和前馈层。最复杂的操作发生在自我关注中,因此让我们首先专注于这一点。我们刚刚讨论了位置嵌入,它们被发送为这些嵌入向量的序列;请记住,变压器是一个序列到序列模型!跳跃添加意味着我们将嵌入向前环绕自我关注层(和上面的前馈层),并将其添加到注意力层的位置输出上。前馈层是一个不起眼的多层感知器,留在位置列中,并使用 ReLU 或 GeLU 激活函数。

ReLU 与 GeLU

ReLU(修正线性单元)是一个激活函数,定义为f ( x ) = max ( 0 , x )。GeLU(高斯误差线性单元)是另一个激活函数,近似为f ( x ) = 0 . 5 x 1 + tanh 2 π x + 0 . 044715 x 3,受到高斯累积分布函数的启发。GeLU 的直觉是,它倾向于让小值的x通过,同时平滑饱和极端值,可能使深度模型的梯度流更好。这两个函数都在神经网络中引入非线性,GeLU 在某些情况下通常展示出比 ReLU 更好的学习动态。

这里有关于自注意力的一些快速提示:

  • 自注意力背后的思想是序列中的每个元素都以某种方式影响着其他所有元素。

  • 自注意力层每个头部学习四个权重矩阵。

  • 头部与序列长度一一对应。

  • 我们经常将权重矩阵称为Q , K , O , V。Q和K都与位置嵌入相乘,但O和V在与嵌入进行点乘之前首先被交叉为一个嵌入维度大小的方阵。Q E ˙和K E ˙相乘创建了同名的注意矩阵,我们对其进行逐行 softmax 操作以获得注意力向量。

  • 一些规范化存在,但我们会忽略它们,因为对于理解来说并不重要。

当我们想准确而简要地谈论注意力时,通常会说:“它采用一系列位置嵌入并将它们全部混合在一起,以学习它们之间的关系。”

自注意力顺序推荐

SASRec是我们将考虑的第一个 Transformer 模型。这种自回归顺序模型(类似于因果语言模型)从过去的用户互动中预测下一个用户互动。受 Transformer 模型在顺序挖掘任务中成功的启发,基于自注意力的架构用于顺序推荐。

当我们说 SASRec 模型是以自回归方式训练时,我们指的是自注意力允许仅关注序列中较早的位置;不允许向未来看。在我们之前提到的混合术语中,可以将其视为仅向前推进影响。有些人称之为“因果”,因为它尊重时间的因果箭头。该模型还允许可学习的位置编码,这意味着更新会传递到嵌入层。该模型使用两个 Transformer 块。

BERT4Rec

受 BERT 模型在自然语言处理中的启发,BERT4Rec通过训练双向掩码顺序(语言)模型改进了 SASRec。

虽然 BERT 在预训练词嵌入时使用掩码语言模型,但 BERT4Rec 使用此架构训练端到端的推荐系统。它试图预测用户互动序列中的掩码项目。与原始 BERT 模型类似,自注意力是双向的:它可以查看行动序列中的过去和未来互动。为了防止未来信息的泄漏并模拟真实的设置,在推断过程中只掩盖序列中的最后一个项目。使用项目掩码,BERT4Rec 优于 SASRec。然而,BERT4Rec 模型的缺点是计算密集型,需要更长的训练时间。

最近采样

最近,顺序推荐和在这些任务中采用 Transformer 架构引起了很大的兴趣。像 BERT4Rec 和 SASRec 这样的深度神经网络模型显示出比传统方法更好的性能。然而,这些模型存在训练速度慢的问题。最近发表的一篇论文——哈哈,明白了吧——解决了如何在实现最先进性能的同时提高训练效率的问题。详细信息请参见 Aleksandr Petrov 和 Craig Macdonald 的“使用最近采样进行顺序推荐的有效和高效训练”

我们刚刚描述的用于顺序模型的两种训练范式是自回归和掩码。自回归试图预测用户交互序列中的下一项,而掩码则试图预测交互序列中的掩码项。自回归方法在训练过程中不使用序列开头作为标签,因此丢失了宝贵的信息。另一方面,掩码方法与顺序推荐的最终目标关联较弱。

Petrov 和 Macdonald 的论文提出了基于最近性的正例采样策略,用于构建训练数据。该采样设计旨在使最近的交互具有更高的采样机会。然而,由于采样机制的概率性质,即使是最老的交互也有非零的被选中机会。采用指数函数作为采样例程,该函数在基于掩码的采样(每个交互具有相等的被采样概率)和自回归采样(从序列末尾采样项目)之间进行插值。在顺序推荐任务中表现出了卓越的性能,同时需要远低于常规训练的时间。将这种方法与其他采样带来显著改进的例子进行比较!

合并静态和顺序

Pinterest 最近发布了由 Jiajing Xu 等撰写的《在 Pinterest 重新思考个性化排名:一种端到端方法》,描述了其个性化推荐系统,该系统利用原始用户行为。推荐任务被分解为建模用户长期和短期意图。

通过训练一个端到端嵌入模型 PinnerFormer 来理解用户的长期兴趣,该模型从用户在平台上的历史行为中学习。这些行为随后被转换为用户嵌入,设计用于根据预期的长期未来用户活动进行优化。

该过程利用调整后的 Transformer 模型处理用户的顺序行为,旨在预测他们的长期未来活动。每位用户的活动被编制成一个序列,包括他们在特定时间窗口内的行为,如一年。基于图神经网络(GNN)的 PinnerSage 嵌入,结合相关的元数据(例如行为类型、时间戳等),用于为序列中的每个行为添加特征。

与传统的顺序建模任务和顺序推荐系统不同,PinnerFormer 旨在预测延迟未来的用户活动,而不是立即后续的动作。通过训练模型在生成嵌入后的 14 天窗口内预见用户的积极未来互动来实现这一目标。相比之下,传统的顺序模型只会预期下一个动作。

这种替代方法允许嵌入生成以离线批处理模式进行,从而显著减少了基础设施需求。与大多数传统的实时顺序建模系统相比,后者运行成本高昂,需要大量计算和基础设施支持,这些嵌入可以批量生成(例如,每天一次),而不是每次用户执行动作时都生成。

本方法论引入了密集全动作损失,以便批量训练模型。这里的目标不是预测下一个即时动作,而是预测用户在接下来的 k 天内将执行的所有动作。其目的是预测用户在间隔如 T + 3 、T + 8 和 T + 12 的所有积极互动,从而促使系统学习长期意图。虽然传统上会使用最后一个动作的嵌入来进行预测,但密集全动作损失会在动作序列中随机选择位置,并使用相应的嵌入来预测每个位置的所有动作。

基于线下和线上实验结果,使用密集全动作损失来训练长期用户行为显著地弥合了批处理生成和用户嵌入实时生成之间的差距。此外,为了适应用户的短期兴趣,变压器模型实时检索每个用户的最新动作,将其与长期用户嵌入一起处理。

摘要

Transformers 和顺序推荐系统确实处于现代推荐系统的前沿。如今,大多数推荐系统的研究集中在顺序数据集领域,最热门的推荐系统使用越来越长的序列进行预测。有两个重要的项目值得关注:

Transformers4Rec

该开源项目由 NVIDIA Merlin 团队设计的可扩展变压器模型。更多详细信息,请参阅 Gabriel de Souza Pereira Moreira 等人的 “Transformers4Rec:在自然语言处理和顺序/会话式推荐之间架起的桥梁”

Monolith

也被称为 TikTok For You 页面推荐系统,这是当前最受欢迎和令人兴奋的推荐系统之一。它是一种基本的序贯推荐系统,具有一些优雅的混合方法。“Monolith: 实时推荐系统与无碰撞嵌入表” 由刘卓然等人撰写,涵盖了架构上的考虑。

在本书结束之前,我们的最后一步是考虑一些推荐方法。这些方法并不完全建立在我们已经做过的基础上,但会利用我们已经做过的一些工作,并引入一些新的想法。让我们冲刺到终点吧!

第十八章:推荐系统的下一步是什么?

我们正处于推荐系统的过渡时期。然而,这对于这个领域来说是非常正常的,因为它和技术行业的许多部分都是如此紧密相关的。一个与业务目标紧密对齐并具有强大业务价值能力的领域的现实是,该领域往往在不断寻找一切进步的机会。

在本章中,我们将简要介绍推荐系统未来的一些现代观点。一个重要的考虑点是,推荐系统作为一门科学同时深度优先和广度优先地传播。查看该领域最尖端的研究意味着你正在看到几十年来一直在研究的深度优化领域,或者看起来现在纯粹是幻想的领域。

我们在这最后一章选择了三个重点领域。第一个你在本文中已经看到了一些:多模态推荐。随着用户转向平台进行更多活动,这个领域变得越来越重要。请记住,多模态推荐发生在用户同时由几个潜在向量表示的时候。

下一个是基于图的推荐系统。我们已经讨论了共现模型,这是图推荐系统中最简单的模型。它们深入得多!GNN(图神经网络)正变成一种非常强大的机制,用于编码实体之间的关系并利用这些表示,使其对推荐变得有用。

最后,我们将把注意力转向大型语言模型和生成 AI。在撰写本书期间,LLM(大型语言模型)已经从少数 ML 专家理解的东西变成了 HBO 喜剧广播中提到的内容。虽然人们正忙于寻找 LLM 在推荐系统中的相关应用,但业界已经有信心以某些方式应用这些工具。然而,令人兴奋的是,推荐系统应用于 LLM 应用程序的应用。

让我们看看接下来会发生什么!

多模态推荐

多模态推荐 允许承认 用户拥有多种特质:一个用户偏好的单一表示可能无法完全捕捉整个故事。例如,在一个大型综合电子商务网站上购物的人,可能同时是以下所有内容之一:

  • 一个经常需要为他们的狗购买物品的狗主人

  • 一个父母,一直在更新宝宝成长所需的衣柜。

  • 一个业余赛车手,购买了驾驶赛车所需的零件以在赛道上驾驶他们的车。

  • 一个乐高投资者,把数百个未拆封的星球大战套装放在壁橱里。

你在本书中学到的方法应该能够很好地为所有这些用户提供推荐。然而,你可能会注意到这个列表中有一些冲突的地方:

  • 如果你的孩子还很小,为什么会买乐高积木套装呢?而且,你的狗不会咬它们吗?

  • 如果你的车库里摆满了乐高积木,你把所有这些汽车零件放在哪里?

  • 在两座位的 Mazdaspeed MX-5 Miata 中你把你的狗放在哪里?

你可能会想到其他一些情况,其中你购买的一些方面与其他方面不太匹配。这导致了多模态性问题:在你的兴趣的潜在空间中,几个地方聚合成模式或中点,但不只有一个。

让我们回顾一下之前的几何讨论:如果你正在使用用户向量的最近邻居,那么哪一个中点将承担最重要的角色?

我们解决这个问题的方式是通过多模态性,或者为单个用户提供几个关联的向量。尽管一个简单的扩展以考虑用户的所有模式的方法是简单地增加模型在项目方面的维度(以创建更多区域,其中不同类型的项目可以不相交地嵌入),但在规模方面,这提出了严重的训练和内存问题。

这个领域的第一项重要工作之一是由本书的一位作者与合著者共同完成的,并引入了一个扩展 MF 来处理这个问题;参见 Jason Weston 等人的 “通过嵌入多个用户兴趣进行非线性潜在因子分解”。目标是同时构建多个潜在因子,正如我们在其他矩阵因子分解方法中所做的那样,希望每个因子都能代表用户的一个兴趣。

这是通过构造一个张量来实现的,该张量的第三个张量维度表示不同兴趣的每个潜在因子,而不是编码用户项目因子分解矩阵。因子分解推广到张量情况,并使用你之前看到的 WSABIE 损失来进行训练。

在这项工作的基础上,几年后,Pinterest 发布了 PinnerSage,正如我们在 第十五章 中提到的那样。这修改了 Weston 等人的论文中的一些假设,不假设每个用户的表示数量已知。此外,这种方法使用基于图的特征表示,我们将在下一节中详细讨论。最后,这种方法使用的最后一个重要修改是聚类:它试图通过在项目空间中进行聚类来构建模式。

基本的 PinnerSage 方法是:

  1. 固定项目嵌入(他们称之为 pins)。

  2. 簇用户交互(无监督和基数未指定)。

  3. 构建簇表示作为簇嵌入的中点。

  4. 使用中点锚定的近邻搜索进行检索。

PinnerSage 仍然被认为是大规模多模式推荐系统的最新技术。一些系统采取了另一种方法,允许用户通过选择他们正在寻找的主题来更直接地修改他们的“模式”,而另一些系统则希望从一系列交互中学习它。

接下来,我们将看看如何明确指定项目或用户之间的高阶关系。

基于图的推荐系统

图神经网络(GNNs)是一类利用数据的结构信息来构建数据更深层次表示的神经网络。在处理关系型或网络数据时,它们被证明尤为有效。

在我们继续之前,需要澄清一个概念:在这里我们将使用“图”的意义是指节点的集合。这些是纯数学概念,但通常我们可以认为节点是感兴趣的对象,边是它们之间的关系。这些数学对象有助于提炼出构建所需表示的核心内容。虽然这些对象可能看起来非常简单,但我们可以通过多种方式添加适量的复杂性来捕捉更多细微差别。

在最简单的设置中,图上的每个节点表示一个项目或用户,每条边表示诸如用户与项目之间的关系。然而,用户到用户和项目到项目的网络也是极为强大的扩展。我们的共现模型是简单的图网络;然而,我们并没有从中学习表示,而是直接将其作为我们的模型。

让我们考虑一些例子,添加更多结构到图中以编码想法:

方向性

可以添加到边的顶点上的排序,以指示一个节点对另一个节点的严格关系;例如,一个用户阅读一本书,但反过来不行。

边的装饰

可以添加描述符如边标签以传达关系特征;例如,两个用户共享账户凭证,并且一个用户被识别为儿童

多重边

这些可以允许关系具有更高的多重性,或者允许相同的两个实体具有多个关系。在一个服装项为节点的图中,每条边可以是另一种能够使其他两个服装项搭配得当的服装项。

超边

更进一步提高抽象级别可能会添加这些边,这些边同时连接多个节点。对于视频场景,您可能会检测到各种类别的对象,并且您的图表可能会有这些类别的节点,但不仅要理解哪些对象类别成对出现,还要识别出哪些更高阶的组合可以用超边表示。

让我们探讨一下图神经网络(GNNs)的基础知识以及它们的表示稍有不同之处。

神经消息传递

在 GNNs 中,我们感兴趣的对象被分配为图中的节点。通常,GNNs 的主要目标是通过它们之间的关系来构建节点和边的强大表示。

GNNs 与传统神经网络的根本区别在于,在训练期间,我们明确地使用在节点表示之间“沿着边缘”传递数据的操作符。这被称为消息传递。让我们通过一个例子来介绍基本思想。

让节点代表用户,它们的特征是人物细节,如人口统计信息、入职调查问题等。让边表示社交网络图:它们是朋友吗?让我们给边增加装饰,例如在平台上交换的私信数量。如果我们是希望在平台上引入广告购物的社交媒体公司,我们可能会从这些人物特征开始,但理想情况下,我们希望使用一些关于这种交流网络的信息。从理论上讲,经常沟通和分享内容的人可能有相似的兴趣。有点透露,我们引入了一个称为消息函数的概念,允许从一个节点发送到另一个节点的特征。数学上写为如下形式,对于节点 i 处的特征 h i (k) 和节点 j 处的特征 h j (k):

m ij (k) = ℳ ( h i (k) , h j (k) , e ij )

边的特征为 e ij,ℳ 是某个可微函数。注意,上标 (k) 按照反向传播符号标准表示层。以下是两个简单的例子:

  • m ij (k) = h i (k) 意味着“从相邻节点获取特征”

  • m ij (k) = h i (k) c ij 意味着“由 ij 之间的边的数量取平均”

许多强大的消息传递方案使用从 ML 其他领域借鉴的学习方法,比如在节点特征上添加注意力机制,但本书不深入探讨这个理论。

接下来我们将介绍的是聚合函数,它以消息集合作为输入并将它们聚合起来。最常见的聚合函数做以下几种事情:

  • 连接所有消息

  • 总和所有消息

  • 平均所有消息

  • 取消息的最大值

最后,我们将聚合的输出作为我们的更新函数的一部分,该函数接受节点特征和聚合的消息函数,然后应用额外的转换。如果你一直在想,“这个模型到底是在哪里学习东西?”答案就在更新函数中。更新函数通常与一个权重矩阵相关联,因此当你训练这个神经网络时,你正在学习更新函数中的权重。最简单的更新函数会将权重矩阵乘以你的聚合的向量化输出,然后对每个向量应用激活函数。

这种消息传递、聚合和更新的链条是 GNN 的核心,涵盖了广泛的功能。它们对于包括推荐在内的各种 ML 任务都非常有用。让我们看看推荐系统的一些直接应用。

应用

让我们重新审视一些 GNN 在推荐系统领域可能触及的高层思想。

建模用户-项目交互

在我们之前提到的其他方法中,比如矩阵分解,考虑了用户和项目之间的交互,但没有利用用户或项目之间复杂的网络。相反,GNN 能够捕捉用户-项目交互图中的复杂连接,然后利用该图的结构来做出更精确的推荐。

回想一下我们的消息传递,它使我们能够将一些节点(在本例中是用户和项目)的信息“传播”给它们的邻居。这个类比可以是,随着用户与具有特定特征的项目进行越来越多的交互,一些特征会被赋予用户。这听起来可能与潜在特征相似,因为它确实如此!这些最终有助于网络从传递特征的消息中建立起潜在表示。这甚至可能比其他潜在嵌入方法更强大,因为你明确定义了结构关系以及它们如何传递这些特征。

特征学习

GNN 可以通过聚合来自邻居的特征信息,在图中学习节点(用户或项目)更具表达力的特征表示,利用节点之间的连接。这些学到的特征可以提供关于用户偏好或项目特性的丰富信息,极大地增强推荐系统的性能。

之前,我们谈到用户的表示可以从他们互动的物品中学习,但物品也可以互相学习。类似于物品-物品的协同过滤(CF)允许物品从共享用户中获得潜在特征,GNN 允许我们添加潜在的许多其他直接的物品之间的关系。

冷启动问题

回想一下我们的冷启动问题:为新用户或物品提供推荐是困难的,因为缺乏历史互动。通过使用节点的特征和图的结构,GNN 可以学习新用户或物品的嵌入,可能缓解冷启动问题。

在我们的用户图形表示中,有些边不仅需要存在于具有大量先前推荐的用户之间。可以使用其他用户行为来引导一些早期边。结构性边缘,如“分享物理位置”或“由同一用户邀请”或“类似地回答入职问题”,足以快速引导几个用户-用户边缘,从而为他们预热推荐。

上下文感知推荐

GNN 可以将上下文信息整合到推荐过程中。例如,在基于会话的推荐中,GNN 可以将用户在会话中互动的物品序列建模为一个图,其中每个物品是一个节点,顺序形成边缘。然后,GNN 可以学习物品之间的动态复杂转换,以进行上下文感知推荐。

这些高层次的想法应该指向推荐问题中图编码的机会,但接下来让我们看看两个具体的应用:随机游走和元路径。

随机游走

GNN 中的随机游走使得方法能够利用用户-物品交互图来学习有效的节点(即用户或物品)嵌入。然后利用这些嵌入来进行推荐。在图的背景下,随机游走是一个迭代过程,从特定节点开始,然后通过随机选择随机移动到另一个连接节点。

一种流行的基于随机游走的网络嵌入算法是 DeepWalk,已经在各种任务中被改编和扩展,包括推荐系统。

下面是随机游走 GNN 方法在推荐上下文中的工作原理:

  1. 随机游走生成:从交互图上的每个节点开始进行随机游走。从每个节点开始,向其他连接的节点进行一系列随机步骤。这将产生一组路径或“游走”,代表不同节点之间的关系。

  2. 节点嵌入:通过随机游走生成的节点序列类似于文本语料库中的句子,每个节点类似于一个词。然后使用 Word2vec 或类似的语言建模技术来学习节点的嵌入(向量表示),使得在相似上下文(在相同的游走中)中出现的节点具有相似的嵌入。

  3. 推荐:一旦学习了节点嵌入,您可以使用它们来做推荐。对于给定的用户,您可以根据距离度量推荐在嵌入空间中与该用户“接近”的项目。这可以使用我们之前开发的所有推荐技术,从潜在空间表示中得到。

这种方法具有一些良好的特性:

  • 它可以捕捉图中的高阶连接。每个随机游走可以探索与起始节点直接不连接的图的一部分。

  • 它可以帮助解决推荐系统中的稀疏问题,因为它利用图的结构来学习表示,这样就需要较少的交互数据。

  • 它自然地尝试处理冷启动问题。对于新用户或与少数交互的项目,它们的嵌入可以从连接的节点中学习得到。

然而,这种方法也面临一些挑战。在大型图上,随机游走可能计算成本高昂,并且可能难以选择适当的超参数,例如随机游走的长度。此外,这种方法在动态图上可能效果不佳,因为它并未固有地考虑时间信息变化。

这种方法隐含地假设节点是异质的,因此通过连接它们来共嵌入是自然的。虽然这不是一个明确的要求,DeepWalk 构建的序列嵌入类型往往假定了这一点。让我们打破这个规则,以适应学习下一个体系结构示例中异质类型之间的学习,元路径。

元路径和异质性

Metapath 旨在改进可解释的推荐并将知识图谱的思想与 GNN 集成。

元路径 是在异质网络(或图)中连接不同类型节点的路径。异质网络包含多种类型的节点和边,代表多种对象和交互类型。除了简单的用户和项目外,节点类型还可以是“项目的购物车”或“查看会话”或“用于购买的渠道”。

元路径可以用于处理异质信息网络(HINs)中的 GNNs。这些网络提供了对真实世界更全面的表示。当用于 GNN 时,元路径提供了一种方案,用于在网络中聚合和传播信息。它定义了在汇集节点邻域时应考虑的路径类型。

例如,在推荐系统中,您可能有一个包含用户、电影和流派作为节点类型,以及“观看”和“属于”作为边类型的异质网络。“User - watches → Movie - belongs to → Genre - belongs to → Movie - watches → User”可以定义为一个元路径。这个元路径表示通过他们观看的电影和这些电影的流派来连接两个用户的方式。

一种流行的方法是利用元路径的异构图神经网络(Hetero-GNN)及其变体。这些模型利用元路径概念捕获 HIN 中丰富的语义信息,增强节点表示的学习能力。

基于元路径的模型在各种应用中显示出有希望的结果,因为它们允许你明确地将更抽象的关系编码到我们提到的消息传递机制中。

如果高阶建模是你的菜,那就准备好学习我们将在本书中涵盖的最后一个概念。这个主题是目前的技术水平,并充满高级抽象。语言模型支持的代理处于机器学习建模的绝对前沿。

LLM 应用

所有关于 LLM 的赞美之词都已经用完了。因此,我们只能说:LLM 非常强大,并且具有令人惊讶的多种应用。

LLM 是通用模型,允许用户通过自然语言与它们交互。从根本上说,这些模型是生成型的(它们写文本)和自回归的(它们写的内容由前面的内容决定)。因为 LLM 可以进行对话,它们被定位为通用的人工代理。然后很自然地会问:“一个代理可以为我推荐东西吗?”让我们首先看看如何使用 LLM 来进行推荐。

LLM 推荐系统

自然语言是一个很棒的界面,可以用来寻求推荐。如果你想让同事推荐午餐,也许你会出现在他们的桌前一言不发,希望他们记得你的偏好,识别出当前时间的语境,回忆起根据每周几天的餐馆开放情况,并记得昨天你吃了熏牛肉三明治的事实。

更有效地,你可以简单地问一句:“午餐有什么建议吗?”

像你聪明的同事一样,如果你只是请求模型提供推荐,它们可能会更有效。这种方法还增加了定义更精确所需推荐种类的能力。LLM 的一个流行应用是询问它们使用一组食材的菜谱。在我们构建的推荐系统的背景下思考这一点,构建这种推荐系统存在一些障碍。它可能需要一些用户建模,但它非常依赖于指定的项目。这意味着每个指定项目的信号非常低。

另一方面,LLM 在这个任务的自回归性质方面非常有效:给定一些食材,下一个最有可能包含在食谱中的是什么。通过生成几个类似的项,排名模型可以增强这一点,以提供一个真实的推荐系统。

LLM 训练

类似这种因其流行而广受欢迎的大型生成语言模型,通常是通过三个阶段进行训练:

  1. 预训练以完成

  2. 监督对话的精细调整

  3. 从人类反馈中进行强化学习

有时,后两个步骤合并为所谓的 Instruct。对于这个主题的深度探讨,请参阅 Long Ouyang 等人的原始 InstructGPT 论文 “Training Language Models to Follow Instructions with Human Feedback”

让我们回顾一下文本完成任务相当于训练模型,在看到 k 个前文之后预测正确单词的能力。这可能会让你想起 GloVe 在 第八章 的讨论,或者我们关于顺序推荐系统的讨论。

接下来是对话微调阶段;这一步骤是教会模型,“下一个单词或短语”有时应该是回复,而不是原始语句的延续。

在这个阶段,用于训练的数据以 演示数据 的形式存在,即语句和响应的配对。例如包括以下内容:

  • 一个请求,然后是对该请求的回应

  • 一种陈述,然后是该陈述的翻译

  • 一段长文本,然后是该文本的摘要

对于推荐系统,可以想象第一个与我们希望模型展示的任务高度相关。

最后,我们进入从人类反馈中进行强化学习(RLHF)阶段;这里的目标是学习一个奖励函数,稍后可以用来进一步优化我们的 LLM。然而,奖励模型 本身 需要训练。有趣的是,像您这样的推荐系统爱好者,AI 工程师通过排名数据集来完成这一过程。

大量的元组——类似于我们见过的演示数据——提供了语句和响应,尽管不仅仅是一个响应,还有多个响应。它们被排名(通过人工标注器),然后对于每对优秀-次优响应 ( x , s u p , i n f ),我们评估损失:

  • r sup = Θ ( x , s u p ) 是优秀响应的奖励模型评分。

  • r inf = Θ ( x , i n f ) 是次优响应的奖励模型评分。

最终损失计算如下:- l o g ( σ ( s u p - i n f ) )。

然后使用这个奖励函数对模型进行微调。

图 18-1 概括了 OpenAI 通过图表的这种方法。

指导模型微调的方法论

图 18-1. 指导模型微调的方法论

从这个简要概述中,你可以看到这些 LLM 是经过训练以响应请求的——这对于推荐器来说非常合适。让我们看看如何增强这种训练。

用于推荐的指导微调

在先前关于指导对中的讨论中,我们看到训练的最终目标是学习两个响应之间的排名比较。这种训练应该感觉非常熟悉。在 Keqin Bao 等人的《“TALLRec: An Effective and Efficient Tuning Framework to Align Large Language Model with Recommendation”》中,作者使用类似的设置来教给模型用户偏好。

如论文所述,根据其评分,历史交互项目被收集到两组中:用户喜欢和用户不喜欢。他们将这些信息收集到自然语言提示中,以格式化最终的“Rec Input”。

  1. 用户偏好:[ i t e m 1 , . . . , i t e m n ]

  2. 用户偏好:[ i t e m 1 , . . . , i t e m n ]

  3. 用户是否喜欢用户偏好 [ i t e m n+1 ]?

这些遵循与先前提到的 InstructGPT 相同的训练模式。与未经训练的 LLM 相比,作者在推荐问题上取得了显著改善的性能;然而,这些应该被视为基线,因为这不是它们的目标任务。

LLM 排名器

到目前为止,在本章中,我们一直将 LLM 视为整体的推荐器,但实际上,LLM 可以被简单地用作排名器。这样做的最简单方法是仅仅使用用户的相关特征和一组项目提示 LLM,并要求它推荐最佳选项。

虽然天真,但是这种方法的变体在非常普通的情况下取得了一些令人惊讶的结果:“用户今晚想看一部恐怖电影,但不确定如果他不喜欢血腥场面的话哪部会是最好的:电影 1,电影 2,等等。”但我们可以做得更好。

最终,就像 LTR 方法一样,我们可以考虑点对、对对和列表。如果我们希望使用 LLM 进行点对排序,我们应该限制我们的提示和响应设置,这些模型可能会有用。举个例子,为科学论文的推荐系统;用户可能希望写出他们正在研究的内容,并且 LLM 可以帮助建议相关的论文。虽然是传统的搜索问题,但这是我们现代工具可以提供很多效用的一种设置:LLM 擅长总结和语义匹配,这意味着可以从大量语料库中找到语义相似的结果,然后代理可以将这些结果的输出综合成一篇连贯的响应。这里最大的挑战是幻觉,或者建议可能不存在的论文。

你可以类比成对和列表:将参考数据浓缩为这些 LLM 独特能力可以使用的形状,从而做出重要的帮助。

谈到搜索和检索,有一种方式很重要,那就是推荐可以帮助 LLM 应用的一种方式:检索增强。

AI 推荐

我们已经看到 LLM 可以用来生成推荐,但推荐系统如何改进 LLM 应用呢?LLM 代理在其功能上非常普遍,但在许多任务上缺乏具体性。如果你问一个代理,“我今年读的书有哪些是由非西方作者写的?”那么代理就没有成功的机会。从根本上讲,这是因为通用预训练模型不知道你今年读过哪些书。

要解决这个问题,你需要利用检索增强,即从现有数据存储中为模型提供相关信息。数据存储可以是 SQL 数据库、查找表或向量数据库,但最重要的组成部分是从你的请求中,你能够找到相关信息,然后将其提供给代理。

我们在这里做出的一个假设是,你的请求可以被你的检索系统解释。在前面的例子中,你希望系统自动理解“我今年读的书中哪些”这样的短语,作为类似于以下内容的信息检索任务:

SELECT * FROM read_books
WHERE CAST(finished_date, YEAR) = CAST(today(), YEAR)

这里我们刚刚创建了一个 SQL 数据库,但你可以想象一个满足这个请求的模式。从请求转换到这个 SQL 现在是你需要建模的另一个任务——也许这是另一个代理请求的工作。

在其他情境中,您希望全面的推荐系统帮助检索:如果您希望用户今晚询问代理要看电影,同时继续使用您对每位用户口味的深入理解,您可以首先根据用户的偏好过滤潜在的电影,然后只发送您的推荐模型认为非常适合他们的电影。然后,代理可以从已确定为优秀的电影子集中为文本请求提供服务。

LLMs 与推荐系统的交汇将在未来一段时间内主导推荐系统的讨论。在将推荐系统的知识引入这个新行业中,有很多低 hanging 的果实。正如 Eugene Yan 最近所说:

我认为关键的挑战和解决方案是在正确的时间为它们[LLMs]提供正确的信息。拥有一个良好组织的文档存储可以帮助。通过使用关键词和语义搜索的混合,我们可以准确地检索 LLMs 所需的上下文。

摘要

推荐系统的未来一片光明,但技术将继续变得更加复杂。在过去五年中的主要变化之一是对基于 GPU 训练和能够利用这些 GPU 的架构的令人难以置信的转变。这是为什么这本书更青睐 JAX 而不是 TensorFlow 或 Torch 的主要动机。

本章的方法包括更大的模型、更多的互联和潜在的难以在大多数组织中承载的推理规模。最终,推荐问题总是通过以下方式解决:

  • 谨慎的问题框架

  • 用户和物品的深入相关表达

  • 考虑周全的损失函数,编码任务的微妙之处

  • 极佳的数据收集