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

60 阅读1小时+

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

你是如何找到这本书的?是在网站上看到广告吗?也许是朋友或导师建议的;或者你在社交媒体上看到了提到它的帖子。也许你是在书店的书架上发现它的——一家你信任的地图应用带你去的书店?不管你是怎么找到它的,你几乎肯定是通过推荐系统来到这本书的。

实现和设计能够为用户提供建议的系统是任何业务中应用机器学习(ML)最流行和最重要的应用之一。无论您想帮助用户找到与他们口味相配的最佳服装、从在线商店购买的最吸引人的物品、丰富和娱乐他们的视频,最大程度地吸引来自他们网络的内容,还是那一天他们需要了解的新闻要点,推荐系统都提供了解决方案。

现代推荐系统的设计与其服务的领域一样多样化。这些系统包括用于实现和执行产品目标的计算机软件架构,以及排名的算法组件。用于排名推荐的方法可以来自传统的统计学习算法、线性代数的灵感、几何考虑,当然还有基于梯度的方法。正如算法方法多样化一样,推荐的建模和评估考虑也是如此:个性化排名、搜索推荐、序列建模以及所有这些的评分,现在对于与推荐系统工作的 ML 工程师来说都是必须知道的。

注意

从业者经常使用缩写 RecSys 来描述推荐系统领域。因此,在本书中,我们在提到该领域时使用 RecSys,而在提到我们构建的推荐系统时使用 recommendation system。

如果你是一个 ML 从业者,你可能已经意识到推荐系统,并且可能了解一两种最简单的建模方法,并能够对相关的数据结构和模型架构进行明智的讨论;然而,推荐系统经常超出数据科学和 ML 核心课程的范围。许多在行业中有多年经验的高级数据科学家对实际构建推荐系统知之甚少,并且在谈论这个话题时可能感到害怕。尽管借鉴了与其他 ML 问题类似的基础和技能,但 RecSys 拥有一个快速发展的充满活力的社区,这使得把建立推荐系统交给已经投入时间或愿意紧跟最新信息的其他数据科学家变得很容易。

本书存在的原因是打破这些感知障碍。在实际水平上理解推荐系统不仅对需要向用户提供内容的业务案例有用,而且 RecSys 的基本思想通常弥合了非常多其他类型的机器学习之间的鸿沟。例如,文章推荐系统可能利用自然语言处理(NLP)来找到文章的表达,利用序列建模来促进更长时间的参与,以及利用上下文组件允许用户查询来引导结果。如果您从纯学术兴趣的角度接近这一领域,无论您对数学的哪些方面感兴趣, sooner or later,都会出现与 RecSys 有关的链接或应用!

最后,如果与其他领域的联系、几乎所有数学的应用或明显的商业效用都不足以引起您对推荐系统的兴趣,那么令人惊叹的尖端技术可能会:RecSys 始终处于并超越机器学习的前沿。显而易见的收入影响的一个好处是,公司和从业者需要始终推动可能性的边界以及如何实现它。最先进的深度学习架构和最佳的代码基础设施被应用于这一领域。当你考虑到在 FAANG 的五个字母中,四个字母的核心——这个缩写代表着 Meta(以前称为 Facebook)、Apple、Amazon、Netflix 和 Google——至少有一个或多个推荐系统时,这并不奇怪。¹

作为一名从业者,您需要理解如何执行以下操作:

  • 将您的数据和业务问题视为 RecSys 问题

  • 识别关键数据以开始构建 RecSys

  • 确定适合您的 RecSys 问题的模型,以及您应该如何评估它们。

  • 实现、训练、测试和部署上述模型

  • 跟踪指标以确保您的系统按计划运行

  • 在学习有关用户、产品和业务案例的更多信息后逐步改进您的系统

本书阐明了完成这些步骤所需的核心概念和示例,无论是哪个行业或规模。我们将指导您完成建立推荐系统的数学、思想和实现细节——无论是您的第一个还是第五十个。我们将向您展示如何使用 Python 和 JAX 构建这些系统。

如果您还不熟悉,JAX 是来自 Google 的 Python 框架,旨在将自动微分和函数式编程范式作为一流对象。此外,它使用了一种特别适合来自各种背景的机器学习从业者的 NumPy API 风格。

我们将展示捕捉必要概念的代码示例和体系结构模型,并提供扩展这些系统到生产应用程序的方式。

本书使用的惯例

本书使用以下印刷惯例:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

固定宽度

用于程序列表,以及段落内引用程序元素如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

固定宽度粗体

显示用户应按字面输入的命令或其他文本。

固定宽度斜体

显示应由用户提供值或由上下文确定值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素指示警告或注意事项。

使用代码示例

包含的代码片段引用的笔记本将在中等大小和大多数情况下免费资源上运行。为了便于进行实验和探索,我们通过 Google Colab 笔记本提供代码。

可下载补充材料(代码示例、练习等)位于ESRecsys on GitHub

如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般而言,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了大量代码,否则无需联系我们以获得许可。例如,编写使用本书多个代码块的程序不需要许可。销售或分发 O’Reilly 图书中的示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感谢,但通常不要求归属。归属通常包括标题、作者、出版商和 ISBN。例如:“Python 和 JAX 中的建议系统 作者 Bryan Bischof 和 Hector Yee,2024 年版权 Bryan Bischof 和 Resonant Intelligence LLC,978-1-492-09799-0。”

如果您觉得您使用的代码示例超出了公平使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media一直致力于提供技术和业务培训、知识和见解,以帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的广泛的文本和视频集合。更多信息,请访问http://oreilly.com

如何联系我们

请将关于本书的评论和问题寄给出版商:

我们为这本书创建了一个网页,在那里列出勘误、示例和任何额外信息。您可以访问此页面:https://oreil.ly/build_rec_sys_python_jax

获取有关我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://youtube.com/oreillymedia

致谢

Hector 想要感谢他的丈夫,Donald,在写作过程中给予他的爱与支持,以及他的姐姐 Serena 经常寄来的零食。他还想将这本书献给已故的亲人。特别感谢 Google 的审阅者 Ed Chi、Courtney Hohne、Sally Goldman、Richa Nigam、Mingliang Jiang 和 Anselm Levskaya。感谢 Bryan Hughes 审阅维基百科代码。

Bryan 要感谢他在 Stitch Fix 的同事们,在那里他学习到了本书中许多关键的思想——特别是 Ian Horn 在迁移学习方面的耐心指导,Molly Davies 博士在实验和效果估计方面的指导,Mark Weiss 在理解可用性与推荐之间关系方面的深入合作,Reza Sohrabi 博士对变换器的介绍,Xi Chen 博士在推荐系统中使用图神经网络的鼓励,以及 Leland McInnes 博士在维度减少和近似最近邻方面的细致建议。Bryan 还从与 Natalia Gardiol 博士、Daniel Fleischman 博士、Andrew Ho 博士、Jason Liu、Dan Marthaler 博士、Chris Moody 博士、Oz Raza、Anna Schneider 博士、Ujjwal Sarin、Agnieszka Szefer 博士、Daniel Tasse 博士、Diyang Tang、Zach Winston 等人的交流中受益匪浅。除了他不可思议的 Stitch Fix 同事们,他特别感谢 Eric Bunch 博士、Lee Goerl 博士、Will Chernoff 博士、Leo Rosenberg 和 Janu Verma 多年来的合作。Brian Amadio 博士作为一位出色的同事最初建议他写这本书。Even Oldridge 博士鼓励他实际去尝试。Eugene Yan 和 Karl Higley——虽然他们都未曾见面,但对他有极大的启发。他要感谢对他职业生涯产生重要影响的 Zhongzhu Lin 博士和 Alexander Rosenberg 博士。Cianna Salvatora 协助进行早期文献综述,Valentina Besprozvannykh 在阅读早期草稿笔记和提供指导方面提供了极大帮助。

两位作者感谢 Tobias Zwingmann、Ted Dunning、Vicki Boykis、Eric Schles、Shaked Zychlinski 和 Will Kurt,在书稿上给予了大量细致的技术反馈——没有这些反馈,这本书将会难以理解。Rebecca Novack 力劝我们加入这个项目。Jill Leonard 从书稿中删除了近 100 处错误的利用一词,并在书稿文本上提供了大量耐心的合作。

¹ 有人可能会争论说苹果公司的核心推荐系统也是其公司的核心。虽然苹果应用商店确实是公司的重要战略产品,但我们仍然保守地给出四分之五的评估,并表示推荐系统不是苹果的主要盈利能力。

第一部分:热身

我们如何将所有数据放在正确的位置以训练推荐系统,并进行实时推断?

所以,你决定投身于推荐系统的世界!你是希望根据用户在广阔选择范围内的古怪偏好建议恰到好处的东西吗?如果是这样,那么你给自己设定了相当大的挑战!表面上,这些系统可能看起来很简单:如果用户 A 和用户 B 有相似的品味,那么 A 喜欢的东西,B 也可能喜欢。但是,就像所有看似简单的事物一样,等待着被探索的深度。

我们如何捕捉用户历史的本质并将其输入模型?我们将这个模型放在哪里,以便随时为推荐服务?我们如何确保它不会建议违反界限或违反业务规则的东西?协同过滤是我们的起点,一个指引明灯。但是,在它之外有一个整个宇宙,使这些系统运转,我们将一起探索它。

第一章:介绍

推荐系统是我们今天互联网发展的核心,并且是新兴科技公司的重要功能。除了打开网络广度给每个人的搜索排名外,每年还有更多应用推荐系统的新颖和令人兴奋的电影、所有朋友都在看的新视频,或者是公司支付高价展示给你的最相关广告。TikTok 的令人上瘾的 For You 页面,Spotify 的 Discover Weekly 播放列表,Pinterest 的板块建议以及 Apple 的 App Store 都是推荐系统技术的热门应用。如今,序列变压器模型、多模态表示和图神经网络是机器学习研发中最光明的领域之一,都被应用在推荐系统中。

任何技术的普遍性往往引发如何运作、为什么变得如此普遍以及我们是否能参与其中等问题。对于推荐系统来说,如何是相当复杂的。我们需要理解口味的几何形状,以及用户的少量互动如何在那个抽象空间中为我们提供一个GPS 信号。你将看到如何快速收集一组优秀的候选者,并将它们精细化为一组协调的推荐。最后,您将学习如何评估您的推荐器,构建服务推理的端点,并记录其行为。

我们将提出核心问题的各种变体,供推荐系统解决,但最终,激励问题的框架如下:

给定可能推荐的事物集合,根据特定目标选择适合当前上下文和用户的有序少数。

推荐系统的关键组成部分

随着复杂性和精密度的增加,让我们牢记系统的组成部分。我们将使用字符串图表来跟踪我们的组件,但在文献中,这些图表以多种方式呈现。

我们将确定并建立推荐系统的三个核心组件:收集者、排名器和服务器。

收集者

收集者的角色是了解可能推荐的事物集合及其必要的特征或属性。请注意,这个集合通常是基于上下文或状态的子集。

排名器

排名器的角色是接受收集者提供的集合,并根据上下文和用户的模型对其元素进行排序。

服务器

服务员的角色是接收排名器提供的有序子集,确保满足必要的数据模式,包括基本的业务逻辑,并返回请求的推荐数量。

例如,以餐馆服务员为例的款待场景:

当您坐下来看菜单时,不确定应该点什么。您问服务员:“你认为我应该点什么作为甜点?”

侍者检查他们的笔记,并说:“柠檬派已经卖完了,但人们真的很喜欢我们的香蕉奶油派。如果你喜欢石榴,我们会从头开始制作石榴冰淇淋;而且甜甜圈冰淇淋是不会错的——这是我们最受欢迎的甜点。”

在这个简短的交流中,侍者首先充当收集者:识别菜单上的甜点,适应当前的库存情况,并通过检查它们的笔记准备讨论甜点的特性。

接下来,侍者充当排名者;他们提到在受欢迎程度方面得分较高的项目(香蕉奶油派和甜甜圈冰淇淋),以及基于顾客特征的情境高匹配项目(如果他们喜欢石榴)。

最后,侍者口头提供建议,包括他们算法的解释特性和多个选择。

虽然这似乎有点卡通 ish,但请记住,将推荐系统的讨论落实到现实世界的应用中。在 RecSys 中工作的一个优点是灵感总是在附近。

最简单的可能的推荐者

我们已经建立了推荐者的组件,但要真正使其实用,我们需要看到它在实践中的运行情况。虽然这本书的大部分内容都专注于实际的推荐系统,但首先我们将从一个玩具开始,并从那里构建。

平凡推荐者

最简单的推荐者实际上并不是很有趣,但仍然可以在框架中演示。它被称为 平凡推荐者TR),因为它几乎没有逻辑:

def get_trivial_recs() -> Optional[List[str]]:
   item_id = random.randint(0, MAX_ITEM_INDEX)

   if get_availability(item_id):
       return [item_id]
   return None

请注意,这个推荐者可能返回一个特定的 item_idNone。还请注意,这个推荐者不接受任何参数,并且 MAX_ITEM_INDEX 是引用了一个超出范围的变量。忽略软件原则,让我们思考这三个组件:

收集者

生成了一个随机的 item_id。TR 通过检查 item_id 的可用性进行收集。我们可以争论说,获得 item_id 也是收集者的责任的一部分。有条件地,可推荐的事物的收集要么是 [item_id],要么是 None请回想 None 是集合论意义上的一个集合)。

排名者

TR(Trivial Recommender)在与无操作相比较;即,在集合中对 1 或 0 个对象进行排名时,对该集合的恒等函数是排名,所以我们只是不做任何事情,继续进行下一步。

服务器

TR 通过其 return 语句提供建议。在这个例子中指定的唯一模式是 ⁠Optional​[List[str]] 类型的返回类型。

这个推荐者,虽然不太有趣或有用,但提供了一个我们将在进一步开发中添加的框架。

最受欢迎的项目推荐者

最受欢迎的项目推荐者(MPIR)是包含任何效用的最简单的推荐者。你可能不想围绕它构建应用程序,但它在与其他组件一起使用时很有用,除了提供进一步开发的基础之外。

MPIR 正如它所说的那样工作;它返回最受欢迎的项目:

def get_item_popularities() -> Optional[Dict[str, int]]:
    ...
        # Dict of pairs: (item-identifier, count times item chosen)
        return item_choice_counts
    return None

def get_most_popular_recs(max_num_recs: int) -> Optional[List[str]]:
    items_popularity_dict = get_item_popularities()
    if items_popularity_dict:
        sorted_items = sorted(
            items_popularity_dict.items(),
            key=lambda item: item[1]),
            reverse=True,
        )
        return [i[0] for i in sorted_items][:max_num_recs]
    return None

在这里,我们假设get_item_popularities知道所有可用项目及其被选择的次数。

这个推荐系统试图返回可用的k个最受欢迎的项目。虽然简单,但这是一个有用的推荐系统,是构建推荐系统时的一个很好的起点。此外,我们将看到这个例子一次又一次地返回,因为其他推荐器使用这个核心并逐步改进内部组件。

让我们再次看看我们系统的三个组成部分:

收集器

MPIR 首先调用get_item_popularities——通过数据库或内存访问——知道哪些项目可用以及它们被选择的次数。为方便起见,我们假设项目以字典形式返回,键由标识项目的字符串给出,值表示该项目被选择的次数。我们在这里暗示假设不出现在此列表中的项目不可用。

排名器

在这里,我们看到我们的第一个简单的排名器:通过对值进行排序来排名。因为收集器组织了我们的数据,使得字典的值是计数,所以我们使用 Python 内置的排序函数sorted。请注意,我们使用key指示我们希望按元组的第二个元素排序——在这种情况下,相当于按值排序——并发送reverse标志来使我们的排序降序。

服务器

最后,我们需要满足我们的 API 模式,这再次通过返回类型提示提供:Optional[List[str]]。这表示返回类型应为可空列表,其中包含我们推荐的项目标识字符串,因此我们使用列表推导来获取元组的第一个元素。但等等!我们的函数有一个max_num_recs字段——它可能在做什么?当然,这暗示我们的 API 模式希望响应中不超过max_num_recs个结果。我们通过切片操作来处理这个问题,但请注意,我们的返回结果在 0 和max_num_recs之间。

考虑到你手头的 MPIR 所提供的可能性;在每个一级类别中推荐客户最喜欢的项目可能会成为电子商务推荐的一个简单但有用的第一步。当天最受欢迎的视频可能会成为你视频网站主页的良好体验。

对 JAX 的简要介绍

由于这本书标题中含有JAX,我们将在这里提供对 JAX 的简要介绍。其官方文档可以在JAX 网站上找到。

JAX 是一个用 Python 编写数学代码的框架,它是即时编译的。即时编译允许相同的代码在 CPU、GPU 和 TPU 上运行。这使得编写利用向量处理器并行处理能力的高性能代码变得容易。

此外,JAX 的设计哲学之一是支持张量和梯度作为核心概念,使其成为利用梯度为基础的学习在张量形状数据上的理想工具。玩转 JAX 的最简单方式可能是通过Google Colab,这是一个托管在网络上的 Python 笔记本。

基本类型、初始化和不可变性

让我们从学习 JAX 类型开始。我们将在 JAX 中构建一个小的三维向量,并指出 JAX 和 NumPy 之间的一些区别:

import jax.numpy as jnp
import numpy as np

x = jnp.array([1.0, 2.0, 3.0], dtype=jnp.float32)

print(x)
[1. 2. 3.]

print(x.shape)
(3,)

print(x[0])
1.0

x[0] = 4.0
TypeError: '<class 'jaxlib.xla_extension.ArrayImpl'>'
object does not support item assignment. JAX arrays are immutable.

JAX 的接口与 NumPy 的接口大部分相似。我们按惯例导入 JAX 的 NumPy 版本作为jnp,以区分它和 NumPy(np),这样我们就知道要使用哪个数学函数的版本。这是因为有时我们可能希望在像 GPU 或 TPU 这样的向量处理器上运行代码,这时我们可以使用 JAX,或者我们可能更喜欢在 CPU 上使用 NumPy 运行一些代码。

首先要注意的是 JAX 数组具有类型。典型的浮点类型是float32,它使用 32 位来表示浮点数。还有其他类型,如float64,具有更高的精度,以及float16,这是一种半精度类型,通常仅在某些 GPU 上运行。

另一个要注意的地方是 JAX 张量具有形状。通常这是一个元组,因此(3,)表示沿第一个轴的三维向量。矩阵有两个轴,而张量有三个或更多个轴。

现在我们来看看 JAX 与 NumPy 不同的地方。非常重要的是要注意“JAX—The Sharp Bits”来理解这些差异。JAX 的哲学是关于速度和纯度。通过使函数纯粹(没有副作用)并使数据不可变,JAX 能够向其所使用的加速线性代数(XLA)库提供一些保证。JAX 保证这些应用于数据的函数可以并行运行,并且具有确定性结果而没有副作用,因此 XLA 能够编译这些函数并使它们比仅在 NumPy 上运行时更快地运行。

您可以看到修改x中的一个元素会导致错误。JAX 更喜欢替换数组x而不是修改它。修改数组元素的一种方法是在 NumPy 中进行,而不是在 JAX 中进行,并在随后的代码需要在不可变数据上快速运行时将 NumPy 数组转换为 JAX——例如,使用jnp.array(np_array)

索引和切片

另一个重要的学习技能是索引和切片数组:

x = jnp.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=jnp.int32)

# Print the whole matrix.
print(x)
[[1 2 3]
 [4 5 6]
 [7 8 9]]

# Print the first row.
print(x[0])
[1 2 3]

# Print the last row.
print(x[-1])
[7 8 9]

# Print the second column.
print(x[:, 1])
[2 5 8]

# Print every other element
print(x[::2, ::2])
[[1 3]
 [7 9]]

NumPy 引入了索引和切片操作,允许我们访问数组的不同部分。一般来说,符号遵循start:end:stride约定。第一个元素指示从哪里开始,第二个指示结束的位置(但不包括该位置),而步长表示跳过的元素数量。该语法类似于 Python range 函数的语法。

切片允许我们优雅地访问张量的视图。切片和索引是重要的技能,特别是当我们开始批处理操作张量时,这通常是为了充分利用加速硬件。

广播

广播是 NumPy 和 JAX 的另一个要注意的特性。当应用于两个不同大小的张量的二元操作(如加法或乘法)时,具有大小为 1 的轴的张量会被提升到与较大张量相匹配的秩。例如,如果形状为 (3,3) 的张量乘以形状为 (3,1) 的张量,则在操作之前会复制第二个张量的行,使其看起来像形状为 (3,3) 的张量:

x = jnp.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=jnp.int32)

# Scalar broadcasting.
y = 2 * x
print(y)
[[ 2  4  6]
 [ 8 10 12]
 [14 16 18]]

# Vector broadcasting. Axes with shape 1 are duplicated.
vec = jnp.reshape(jnp.array([0.5, 1.0, 2.0]), [3, 1])
y = vec * x
print(y)
[[ 0.5  1.   1.5]
 [ 4.   5.   6. ]
 [14.  16.  18. ]]

vec = jnp.reshape(vec, [1, 3])
y = vec * x
print(y)
[[ 0.5  2.   6. ]
 [ 2.   5.  12. ]
 [ 3.5  8.  18. ]]

第一种情况是最简单的,即标量乘法。标量在整个矩阵中进行乘法。在第二种情况中,我们有一个形状为 (3,1) 的向量乘以矩阵。第一行乘以 0.5,第二行乘以 1.0,第三行乘以 2.0。然而,如果向量已经重塑为 (1,3),则列将分别乘以向量的连续条目。

随机数

伴随 JAX 的纯函数哲学而来的是其特殊的随机数处理方式。因为纯函数不会造成副作用,一个随机数生成器不能修改随机数种子,不像其他随机数生成器。相反,JAX 处理的是随机数密钥,其状态被显式地更新:

import jax.random as random

key = random.PRNGKey(0)
x = random.uniform(key, shape=[3, 3])
print(x)
[[0.35490513 0.60419905 0.4275843 ]
 [0.23061597 0.6735498  0.43953657]
 [0.25099766 0.27730572 0.7678207 ]]

key, subkey = random.split(key)
x = random.uniform(key, shape=[3, 3])
print(x)
[[0.0045197  0.5135027  0.8613342 ]
 [0.06939673 0.93825936 0.85599923]
 [0.706004   0.50679076 0.6072922 ]]

y = random.uniform(subkey, shape=[3, 3])
print(y)
[[0.34896135 0.48210478 0.02053976]
 [0.53161216 0.48158717 0.78698325]
 [0.07476437 0.04522789 0.3543167 ]]

首先,JAX 要求你从种子创建一个随机数 key。然后将这个密钥传递给类似 uniform 的随机数生成函数,以创建范围在 0 到 1 之间的随机数。

要创建更多的随机数,然而,JAX 要求你将密钥分为两部分:一个新密钥用于生成其他密钥,一个子密钥用于生成新的随机数。这使得 JAX 即使在许多并行操作调用随机数生成器时,也能确定性地和可靠地复现随机数。我们只需将一个密钥分成需要的许多并行操作,所得的随机数现在既是随机分布的,又是可重现的。这在你希望可靠地复现实验时是一种良好的特性。

即时编译

当我们开始使用 JIT 编译时,JAX 在执行速度上开始与 NumPy 有所不同。JIT 编译——即时将代码转换为即时编译——允许相同的代码在 CPU、GPU 或 TPU 上运行:

import jax

x = random.uniform(key, shape=[2048, 2048]) - 0.5

def my_function(x):
  x = x @ x
  return jnp.maximum(0.0, x)

%timeit my_function(x).block_until_ready()
302 ms ± 9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

my_function_jitted = jax.jit(my_function)

%timeit my_function_jitted(x).block_until_ready()
294 ms ± 5.45 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

JIT 编译的代码在 CPU 上速度提升不多,但在 GPU 或 TPU 后端上速度会显著提升。当函数第一次调用时,编译也会带来一些开销,这可能会使第一次调用的时间偏离。能够 JIT 编译的函数有一些限制,比如主要在内部调用 JAX 操作,并对循环操作有限制。长度可变的循环会触发频繁的重新编译。“Just-in-Time Compilation with JAX” 文档详细介绍了许多 JIT 编译函数的细微差别。

摘要

虽然我们还没有进行太多的数学工作,但我们已经到了可以开始提供推荐和实现这些组件更深层逻辑的阶段。我们很快将开始做一些看起来像是机器学习的事情。

到目前为止,我们已经定义了推荐问题的概念,并设置了我们推荐系统的核心架构——收集器、排名器和服务器,并展示了几个简单的推荐器来说明这些部件如何组合在一起。

接下来,我们将解释推荐系统试图利用的核心关系:用户-物品矩阵。这个矩阵使我们能够构建个性化模型,从而进行排名。

第二章:用户-物品评分及问题的构建

如果你被要求为当地咖啡馆的奶酪拼盘进行策划,你可能会从你的最爱开始。你也可能会花一点时间询问你朋友的最爱。在为咖啡馆订购大量这些奶酪之前,你可能会想进行一次小实验 —— 也许请一群朋友品尝你的选择,并告诉你他们的偏好。

除了收到朋友们的反馈意见外,你还将了解到有关朋友和奶酪的信息。你将会了解到朋友们喜欢哪种类型的奶酪,哪些朋友口味相似。你还可以了解到哪些奶酪最受欢迎,以及哪些奶酪被同一些人喜欢。

这些数据将开始为你的第一个奶酪推荐系统提供线索。在本章中,我们将讨论如何将这个想法转化为推荐系统的正确组成部分。通过这个例子,我们将讨论推荐系统的一个基本概念:如何预测用户对他们从未见过的东西的喜好。

用户-物品矩阵

非常常见的是听到从事推荐系统工作的人谈论矩阵,特别是用户-物品矩阵。虽然线性代数在数学上很深奥,但它在推荐系统中的应用很简单直观。

在我们讨论矩阵形式之前,让我们先列出一些用户集合和物品集合之间的二元关系。为了举例,想象一组五个朋友(神秘地被命名为A, B, C, D, E)和一个盲目品尝奶酪的场景,包括四种奶酪(gouda, chèvre, emmentaler, brie)。朋友们被要求对这些奶酪评分,从 1 到 4:

  1. A开始说:“好吧,我真的很喜欢gouda,所以给它 5 分;chèvreemmentaler也很好吃,给 4 分;而brie太难吃了,只给 1 分。”

  2. B回答说:“什么?!brie是我的最爱!4.5!chèvreemmentaler很好,3;而gouda只是还行,2。”

  3. C分别给出 3、2、3 和 4 的评分。

  4. D给出 4、4、5 的评分,但我们在D试之前已经没brie了。

  5. E开始感觉不舒服,并只尝试了gouda,给了它 3 分。

你可能首先注意到这种解说性写作有点乏味并且难以解析。让我们在一个方便的表格中总结这些结果(表 2-1):

表 2-1. 奶酪及评分

奶酪品鉴师GoudaChèvreEmmentalerBrie
A5441
B2334.5
C3234
D445-
E3---

你的第一个直觉可能是将这些内容写成更适合计算机的形式。你可以创建一系列的列表:

A : [ 5 , 4 , 4 , 1 ] B : [ 2 , 3 , 3 , 4 . 5 ] C : [ 3 , 2 , 3 , 4 ] D : [ 4 , 4 , 5 , - ] E : [ 3 , - , - , - ]

在某些情况下这可能有效,但你可能希望更清楚地表明每个列表中的位置意义。你可以简单地使用热图来可视化这些数据(图 2-1):

import seaborn as sns

_ = np.nan
scores = np.array([[5,4,4,1],
    [2,3,3,4.5],
    [3,2,3,4],
    [4,4,5,_],
    [3,_,_,_]])
sns.heatmap(
    scores,
    annot=True,
    fmt=".1f",
    xticklabels=['Gouda', 'Chevre', 'Emmentaler', 'Brie',],
    yticklabels=['A','B','C','D','E',]
)

评分热图

图 2-1. 奶酪评分矩阵

当我们观察到拥有大量用户或物品,并且稀疏度越来越高的数据集时,我们需要使用更适合表示必要数据的数据结构。存在各种所谓的稠密表示,但现在我们将使用最简单的形式:user_iditem_idrating的元组。在实践中,这种结构通常是一个由 ID 提供索引的字典。

稠密和稀疏表示

这些数据的两种类型结构是稠密和稀疏表示。宽泛地说,稀疏表示是这样一种表示,即每个非平凡观察都存在一个数据。稠密表示总是包含每个可能性的数据,即使是在平凡(null或零)情况下也是如此。

让我们看看这些数据作为字典的样子:

'indices': [
  (0,0),(0,1),(0,2),(0,3),
  (1,0),(1,1),(1,2),(1,3),
  (2,0),(2,1),(2,2),(2,3),
  (3,0),(3,1),(3,2),
  (4,0)
]
'values': [
  5,4,4,1,
  2,3,3,4.5,
  3,2,3,4,
  4,4,5,
  3
]

出现了几个自然的问题:

  1. 从迄今的观察来看,最受欢迎的奶酪是什么?看起来emmentaler可能是最喜欢的,但E没有尝试过emmentaler

  2. D会喜欢brie吗?看起来这是一种有争议的奶酪。

  3. 如果你只被要求买两种奶酪,你应该买哪两种来最好地满足所有人的需求?

这个例子及其相关问题故意简单,但其要点是很明确的,即这种矩阵表示至少对捕捉这些评分是方便的。

也许并不明显的是,除了这种数据可视化的便利性之外,这种表示法在数学上的实用性也是存在的。问题 2 暗示了一个固有的推荐系统问题:“预测用户未看到的物品会喜欢多少。”这个问题也可以被认出是线性代数课程中的问题:“如何从我们知道的元素中填充矩阵的未知元素?”这被称为矩阵完成

在创造捕捉用户需求的用户体验和数学建模数据及需求之间的反复过程中,推荐系统的核心问题浮出水面。

用户-用户与物品-物品协同过滤

在我们深入线性代数之前,让我们考虑一下纯数据科学的视角,称为协同过滤CF),这个术语最初是由 David Goldberg 等人在他们 1992 年的论文“Using Collaborative Filtering to Weave an Information Tapestry”中使用的。

CF 的基本思想是,具有相似品味的人可以帮助其他人了解他们喜欢的东西,而无需自己去尝试。协同术语最初是指类似品味的用户之间的协同,过滤最初是指过滤掉人们不喜欢的选择。

你可以用两种方式来思考这种协同过滤策略:

  • 两个具有相似品味的用户将继续拥有相似的品味。

  • 两个具有相似用户粉丝的物品将继续受到其他类似粉丝的用户的欢迎。

这些听起来可能相同,但在数学解释上它们看起来不同。在高层次上,区别在于你的推荐系统应该优先考虑哪种相似性:用户相似性还是物品相似性。

如果你优先考虑 用户相似性,那么为了为用户 A 提供推荐,你会找到一个类似的用户 B,然后从 B 喜欢的内容列表中选择一个 A 还没看过的推荐。

如果你优先考虑 物品相似性,那么为了为用户 A 提供推荐,你会找到一个 A 喜欢的物品 奶酪,然后找到一个与 奶酪 相似的 A 还没看过的物品 埃门塔勒,并向 A 推荐它。

稍后我们将深入探讨相似性,但让我们快速将这些想法与前面的讨论联系起来。相似的用户 是用户-物品矩阵中作为向量相似的行;相似的物品 是作为向量相似的用户-物品矩阵中的列。

向量相似性

点积相似性 在 第十章 中有更精确的定义。暂时来说,相似性是通过将向量归一化然后计算它们的余弦相似度来计算的。给定任何类型的实体,你已经将它们关联到向量(数字列表)中,向量相似性 比较的是哪些实体在特征上最相似,这些特征由这些数字列表(称为 潜在空间)捕获。

Netflix 挑战

2006 年,Netflix 启动了一个名为 Netflix 奖 的在线竞赛。这个竞赛挑战团队在由公司发布的开放数据集上改进 Netflix 的 CF 算法的性能。虽然像 Kaggle 或者会议网站上的这类竞赛如今已经很普遍,但在当时对于对推荐系统感兴趣的人来说,这是非常令人兴奋和新颖的。

这个竞赛包括多个中间回合,颁发进步奖以及 2009 年颁发的最终 Netflix 奖。提供的数据集包括 2,817,131 个三元组,包括 (用户,电影,评分日期)。其中一半还包括评分本身。请注意,就像我们前面的例子一样,用户-物品信息几乎足以指定问题。在这个特定的数据集中,日期是已提供的信息。稍后,我们将探讨时间可能是如何成为因素的,特别是对于顺序推荐系统。

这个竞赛的赌注非常高。击败内部性能的要求是将均方根误差(RMSE)提高 10%;稍后我们将讨论这个损失函数。奖金总额超过了 110 万美元。最终的获胜者是 BellKor's Pragmatic Chaos(其实也赢得了之前两个进步奖)的测试 RMSE 为 0.8567。最终,仅仅是 20 分钟的提前提交时间使 BellKor 领先于竞争对手 The Ensemble。

要详细了解获胜作品,请查看“Netflix 大奖的 BigChaos 解决方案”由 Andreas Töscher 和 Michael Jahrer 以及相同作者的“Netflix Prize 2008 的 BigChaos 解决方案”。与此同时,让我们回顾一下这场比赛中的几个重要教训:

首先,我们看到我们讨论过的用户-项目矩阵在这些解决方案中出现为关键的数学数据结构。模型选择和训练很重要,但参数调整在几种算法中提供了巨大的改进。我们将在后续章节中回到参数调整。作者指出,几种模型创新来自于反思业务用例和人类行为,并尝试在模型架构中捕捉这些模式。接下来,线性代数方法导致了第一个相对性能良好的解决方案,并在此基础上构建了获胜模型。最后,为了获得 Netflix 最初要求赢得比赛的性能,花费了很长时间,而业务环境却发生了变化,解决方案不再有用

最后一点可能是机器学习开发者需要了解的重要的关于推荐系统的事情;请看以下提示。

从简单开始

快速构建一个可用的工作模型,并在模型仍然符合业务需求时进行迭代。

软评分

在我们品尝奶酪的例子中,每种奶酪都会收到一个数字评分或者客人未尝试过。这些是硬评分:无论奶酪是布里还是露琪,评分都是明确的,其缺失表明用户与项目之间缺乏互动。在某些情况下,我们需要处理表明用户确实与项目进行了互动但未提供评分的数据。

一个常见的例子是电影应用程序;用户可能使用应用程序观看了电影,但未提供星级评分。这表明项目(在这种情况下是电影)已被观察到,但我们的算法没有学习的评分。然而,我们仍然可以使用这些隐含数据来执行以下操作:

  • 从未来的推荐中排除这个项目

  • 将这些数据作为我们学习者中的一个单独项使用

  • 分配一个默认的评分值,以指示“有趣但不足以评级”

结果表明,隐含评分对训练有效的推荐系统至关重要,不仅因为用户经常不提供硬评分,而且因为隐含评分提供了不同水平的信号。稍后,当我们希望训练多级模型以预测点击和购买可能性时,这两个级别将非常重要。

总结一下:

  • 当用户直接对项目的反馈提出请求时,会产生硬评分。

  • 当用户的行为在未直接回应提示的情况下隐含地传达对项目的反馈时,会产生软评分。

数据收集和用户记录

我们已经确定,我们既从显式评级学习,也从隐式评级学习,那么我们如何以及从哪里获取这些数据?为了深入探讨这一点,我们需要开始关注应用程序代码。在许多企业中,数据科学家和机器学习工程师与软件工程师是分开的,但是处理推荐系统需要两个功能之间的协调。

记录什么内容

最简单和最明显的数据收集是用户评级。如果用户可以选择提供评级,甚至是赞成或反对的选项,那么就需要构建该组件并存储这些数据。这些评级不仅需要存储,以便建立推荐,还要防止用户评级某物品后不久再次访问页面时评级消失,从而导致不好的用户体验。

同样地,了解几个其他关键互动也可以改进和扩展您的推荐系统:页面加载、页面浏览、点击和加入购物袋。

对于这些类型的数据,让我们使用一个稍微复杂的例子:电子商务网站 Bookshop.org。这个站点有多个推荐系统的应用,我们将逐步回到几乎所有这些应用。现在,让我们专注于一些互动(图 2-2)。

图书店首页

图 2-2. Bookshop.org 首页

页面加载

当您首次加载 Bookshop.org 时,页面上会显示项目。本周畅销书都是可点击的书籍列表的图片。尽管用户在加载此初始页面时没有选择,但记录这个初始页面加载的内容实际上非常重要。

这些选项代表用户看过的所有书籍的总体。如果用户看过某个选项,他们就有点击它的机会,这将最终成为一个重要的隐式信号。

倾向评分

考虑用户看过的所有项目的总体与倾向评分匹配密切相关。在数学上,倾向评分是观察单元被分配到处理组与对照组的概率。

将这种设置与简单的 50-50 A/B 测试进行比较:每个单元有 50% 的机会暴露于您的处理。在特征分层的 A/B 测试中,您有意根据某些特征或特征集(在这种情况下通常称为协变量)更改暴露的概率。这些暴露概率就是倾向评分。

为什么在这里提到 A/B 测试?稍后,我们将对用户偏好的软评级进行挖掘,但我们必须考虑到缺乏软评级可能并不意味着隐含的不良评级。回想起奶酪:品鉴员 D 没有机会评价 brie,因此没有理由认为 Dbrie 有偏好或厌恶。这是因为 D 没有接触到 brie

现在回想起 Bookshop.org:首页没有显示《银河系漫游指南》,所以用户无法点击它并隐式表达对该书的兴趣。用户可以使用搜索选项,但这是一种不同类型的信号——我们稍后会讨论,实际上是一种更强的信号。

在理解像“用户是否看过某物”这样的隐式评级时,我们需要正确考虑他们暴露给的所有选择,并使用该人群规模的倒数来衡量点击的重要性。因此,理解所有页面加载都很重要。

页面浏览和悬停

网站已经变得更加复杂,现在用户必须应对各种交互。图 2-3 展示了如果用户在本周畅销书籍的轮播中点击右箭头,然后将鼠标移动到《家庭烹饪》选项上会发生什么。

Bookshop 畅销书籍

图 2-3. Bookshop.org 畅销书籍

用户揭示了一个新选项,并通过将鼠标悬停在其上,使其变大并产生视觉效果。这些都是向用户传达更多信息的方法,并提醒用户这些选项是可点击的。对于推荐系统来说,这些点击可以被用作更多的隐式反馈。

首先,用户点击了轮播滚动条——因此他们在轮播中看到的部分内容足够有趣,以便进一步挖掘。其次,他们将鼠标悬停在《家庭烹饪》上,这可能会导致点击,也可能只是想看看在悬停时是否提供了额外信息。许多网站使用悬停交互来提供弹出式详细信息。尽管 Bookshop.org 没有实现类似的功能,但互联网用户已经习惯了所有实现此行为的网站,因此这个信号仍然具有意义。第三,用户现在在轮播滚动中发现了一个新的潜在项目——我们应该将其添加到我们的页面加载中,但评分较高,因为它需要交互才能揭示。

所有这些信息和更多信息都可以编码到网站的日志中。丰富和详细的日志记录是改善推荐系统的最重要方法之一。比你实际需要的更多的日志数据几乎总是比相反情况更好。

点击

如果您认为悬停意味着兴趣,那么等您考虑到点击时会怎样!在大多数情况下,点击是产品兴趣的强有力指标。对于电子商务来说,点击经常作为推荐团队核心关键绩效指标(KPI)的一部分计算。

这是出于两个原因:

  • 点击几乎总是购买所必需的,因此对于大多数业务交易来说,它是一个上游过滤器。

  • 点击需要明确的用户操作,因此它是意图的良好衡量指标。

当然,噪音总是存在的,但点击是客户兴趣的主要指标。许多生产推荐系统是基于点击数据训练的,而不是评级数据,因为点击行为和购买行为之间存在强烈的相关性,且数据量更大。

点击流数据

有时在推荐系统中,您会听到人们谈论点击流数据。这一重要的点击数据视图还考虑了用户在单个会话中点击的顺序。现代推荐系统致力于利用用户点击顺序,称之为顺序推荐,并通过这一额外的维度显示了显著的改进。我们将在第七章中讨论基于序列的推荐。

添加到购物袋

我们终于到了;用户已将物品添加到他们的购物袋、购物车或队列中。这是极其强烈的兴趣指示器,通常与购买行为高度相关。甚至有理由认为添加到购物袋比购买/订单/观看更好作为信号。添加到购物袋基本上是软评级的终点,通常在此之后您希望开始收集评级和评价。

印象

我们可能还希望记录那些未被点击的项目的印象。这为推荐系统提供了用户对不感兴趣项目的负反馈。例如,如果用户被提供了goudachèvreemmentaler这些奶酪,但用户只尝试了chèvre,也许用户不喜欢gouda。另一方面,他们可能还没来得及尝试emmentaler,所以这些印象可能只带来噪声信号。

收集和仪器化

Web 应用程序通常通过事件来仪表化我们讨论过的所有交互。如果您还不知道什么是事件,可以向您工程组织的同事询问,但我们也会为您提供简略信息。与日志记录类似,事件是应用程序在执行某段代码时发送的特殊格式消息。

正如点击的例子中,应用程序需要调用以获取下一个要向用户显示的内容,此时通常也会“触发事件”,指示有关用户的信息,他们点击了什么,会话 ID 以供以后参考,时间以及其他各种有用的细节。此事件可以在下游以任何方式处理,但有一种越来越普遍的路径分歧模式如下:

  • 一个日志数据库,如与服务绑定的 mySQL 应用程序数据库

  • 一个实时处理事件流

后者将很有趣:事件流通常通过诸如 Apache Kafka 之类的技术与侦听器连接。这种基础设施可能会迅速变得复杂(请咨询您的本地数据工程师或 MLOps 人员),但发生的简单模型是所有特定类型的日志都发送到您认为可以利用这些事件的几个目的地。

在推荐系统的情况下,事件流可以连接到一系列转换以处理下游的学习任务数据。如果您希望构建一个使用这些日志的推荐系统,这将非常有用。其他重要用途包括实时度量日志,用于了解网站上的实时情况。

漏斗

我们刚刚通过了我们的第一个漏斗示例,任何一个好的数据科学家都无法避免考虑它们。无论喜欢与否,漏斗分析对于评估你的网站以及扩展你的推荐系统至关重要。

点击流

漏斗是用户从一个状态到另一个状态必须执行的步骤集合;之所以称为漏斗,是因为在每个离散步骤,用户可能会停止继续进行,或者掉队,从而在每个步骤中减少人口数量。

在我们对事件和用户日志的讨论中,每个步骤都与前面的某个子集相关。这意味着该过程是一个漏斗,如图 2-4 所示。了解每个步骤的流失率揭示了你的网站和推荐的重要特征。

一个入门漏斗

图 2-4. 一个入门漏斗

可以在图 2-4 中考虑三种重要的漏斗分析:

  1. 页面浏览到加入购物袋用户流

  2. 页面浏览到每个推荐的加入购物袋

  3. 从加入购物袋到完成购买

第一个漏斗只是在高层次上识别用户在流程中每个步骤中的占比。这是你的网站优化的高级衡量标准,产品提供的一般吸引力,以及用户引导的质量。

第二个更为精细的漏斗考虑了推荐系统本身。正如在倾向性评分方面之前提到的那样,用户只有在看到某个物品时才能进入特定物品的推荐漏斗。这个概念与漏斗的使用相交汇,因为你希望从高层次理解某些推荐与漏斗流失的关系,但是同时,在使用推荐系统时,你对推荐的信心应该与漏斗指标很好地相关。我们将在第三部分详细讨论这个问题,但现在你应该记住考虑不同类别的推荐用户对和他们的漏斗可能与平均水平有何不同。

最后,我们可以考虑从加入购物车到完成购买。这实际上不是推荐系统问题的一部分,但作为试图改进产品的数据科学家或机器学习工程师,你应该时刻关注这一点。*无论你的推荐有多好,这一漏斗可能会摧毁你的所有努力。*在解决推荐问题之前,你几乎总是应该调查漏斗在从加入购物车到完成购买过程中的表现。如果流程中有任何复杂或困难之处,修复这些问题几乎肯定比改进推荐系统更有价值。调查流失情况,进行用户研究以理解可能的混淆因素,并与产品和工程团队合作,确保每个人在开始构建电子商务推荐系统之前都对这一流程有共识。

业务见解和用户喜好

在之前来自 Bookshop.org 的例子中,本周热销排行榜是页面上的主要轮播内容。回顾我们之前的get_most_popular_recs工作;轮播的动力仅仅是推荐系统应用于特定收藏者的结果——一个只看最近一周的收藏者。

这个轮播示例展示了推荐系统在提供业务见解的同时,也推动了推荐功能。增长团队的常见任务是理解每周的趋势和关键绩效指标,通常是每周活跃用户和新注册用户这样的指标。对于许多数字化公司来说,增长团队还对理解用户参与的主要驱动因素感兴趣。

让我们举个例子:截至目前,Netflix 的节目《鱿鱼游戏》成为了该公司有史以来最受欢迎的系列,刷新了大量记录。《鱿鱼游戏》在第一个月就吸引了 1.11 亿观众。显而易见,《鱿鱼游戏》需要在本周热门节目或最热门标题的轮播中亮相,但是像这样的爆款还应该在哪些地方受到重视呢?

公司几乎总是会首先寻求的第一个重要洞见是归因:如果数字在一周内上升,是什么导致了这一点?推动额外增长的重要或特别因素是什么?我们如何从这些信号中学习以在未来做得更好?在Squid Game这样的外语节目引起英语观众的巨大兴趣的情况下,高管可能会倾向于增加对来自韩国的节目或具有高戏剧性的字幕节目的投资。另一面同样重要:当增长指标滞后时,高管几乎总是会问为什么。能够指出什么是最受欢迎的,以及它可能如何偏离预期,会有很大帮助。

另一个重要的洞见可以反馈到推荐中;在像Squid Game这样令人兴奋的首播期间,当你看到所有指标都在上升时很容易被兴奋所影响,但这是否会对指标产生负面影响呢?如果你的节目和Squid Game在同一周或两周内首播,你可能对这一成功不那么热衷。总体而言,这种成功通常会带动增量增长,这对业务非常有利,整体而言,指标可能会全部看起来向好的方向发展。然而,其他项目可能由于核心用户群体之间的零和游戏而推出不成功。这可能会对长期指标产生负面影响,甚至可能使后续的推荐效果不佳。

后来,你将会了解到推荐的多样性;关于为何关心推荐多样化有很多理由,但我们在这里观察其中一个:多样化可以增加匹配用户与物品的整体能力。当你保持广泛的用户基础高度参与时,你增加了未来增长的机会。

最后,在突显热门命中之外,了解平台或服务上真正热门的另一个好处是广告。当一个现象开始时,通过引导和推广成功可以带来巨大优势。有时这会导致网络效应,在这些天病毒式内容和简易传播,这可以对平台的增长产生多重影响。

总结

这构成了制定推荐问题和为解决问题做好准备的最基本方面。

用户-物品矩阵为我们提供了一个工具,以数字评分的最简单情况总结用户和物品之间的关系,并且将在后续更复杂的模型中进行泛化。我们看到了第一个向量相似性的概念,它将扩展到深层次的相关性概念。接下来,我们了解了用户通过显式和隐式行动提供的信号类型。最后,我们学会了如何捕捉这些行动来训练模型。

现在我们完成了问题框架,接下来为您进行一些数学复习。别担心,您可以将尺子和圆规收起来,无需证明任何事情或计算任何积分。然而,您会看到一些重要的数学概念,这些概念将帮助您清晰地思考推荐系统的期望,并确保您提出正确的问题。

第三章:数学考虑

本书大部分内容侧重于实施和让推荐系统正常运行所需的实际考虑。在本章中,你将找到本书最抽象和理论性的概念。本章的目的是涵盖支撑该领域的一些基本思想。理解这些思想很重要,因为它们导致推荐系统中的病态行为,并激励许多架构决策。

我们将从讨论你经常在推荐系统中看到的数据形状开始,以及为什么这种形状可能需要仔细思考。接下来,我们将谈论推动大多数现代推荐系统的基本数学思想——相似性。我们将简要涵盖一种不同的思考推荐者作用的方式,适合那些更倾向于统计的人。最后,我们将使用与自然语言处理类比的方法来制定流行的方法。

推荐系统中的 Zipf 定律和马太效应

在许多机器学习应用中,早期就提到了一个警告:大语料库中唯一项的观察分布由Zipf 定律建模,即出现频率呈指数级下降。在推荐系统中,马太效应体现在热门项目的点击率或热门用户的反馈率上。例如,热门项目的点击量远远大于平均水平,而更积极参与的用户比平均水平给出更多评分。

马太效应

马太效应或流行偏见表明,最受欢迎的项目将继续吸引最多的注意力,并扩大与其他项目之间的差距。

MovieLens 数据集为例,这是一个用于基准推荐系统的极为流行的数据集。Jenny Sheng观察到图 3-1 中展示的电影评分行为:

Movierank Zipfian

图 3-1. 电影排名评分的 Zipf 分布

乍一看,评分的急剧下降显而易见,但这是否是问题呢?让我们假设我们的推荐系统将建立为基于用户的协同过滤(CF)模型——正如在第二章中所暗示的。那么这些分布会如何影响推荐系统呢?

我们将考虑这一现象的分布影响。让概率质量函数由简单的 Zipf 定律描述:

f ( k , M ) = 1/k ∑ n=1 M (1/n)

对于语料库中的M个标记(在我们的例子中是电影的数量),k是按出现次数排序的标记的排名。

让我们考虑用户A和B,分别具有N A = | ℐ A |和N B = | ℐ B |的评级。注意,V i,即第i个最流行的视频,在用户X的ℐ X中出现的概率由以下公式给出:

P ( i ) = f(i,M) ∑ j=1 M f(j,M) = 1/i ∑ j=1 M 1/j

因此,一个项目出现在两个用户评级中的联合概率如下所示:

P ( i 2 ) = 1/i ∑ j=1 M 1/j 2

换句话说,两个用户共享其评级集中项目的概率随其受欢迎排名的平方而减小。

当我们考虑到我们尚未明确的基于用户的 CF 定义基于用户评级集中的相似性时,这变得很重要。这种相似性是两个用户共同评级项目的数量除以任一用户评级的总项目数

以此定义,例如,我们可以计算在用户A和B之间共享项目的相似性分数:

∑ i=1 M P(i 2 ) ∥ℐ A ∪ℐ B ∥

然后,两个用户的平均相似性分数通过前述方程的重复应用被概括如下:

∑ t=1 min(N A ,N B ) ∏ i k =i k-1 +1 t-1 ∑ i=1 M P(i k 2 ) ∥ℐ A ∪ℐ B ∥ t

通过前述观察的重复应用。

这些组合公式不仅指示了我们算法中 Zipfian 的相关性,而且我们还看到这对得分输出的几乎直接影响。考虑 Hao Wang 等人在《量化分析马太效应和推荐系统稀疏问题》中的实验,针对Last.fm 数据集中用户的平均相似性分数,作者发现这种马太效应在相似性矩阵中持续存在(参见图 3-2)。

Last.fm Matthew Effect

图 3-2。Last.fm 数据集上的马太效应

观察“热门”单元格与其他单元格之间的根本差异。明亮的单元格在大多数暗色单元格中为数不多,表明某些极其热门的项目在更普通频率接近零的项目中难以组合。虽然这些结果可能看起来令人担忧,但后来我们将考虑能够减轻马修效应的多样性感知损失函数。一个更简单的方法是使用下游采样方法,我们将作为探索利用算法的一部分进行讨论。最后,马修效应只是这种 Zipf 分布的两大主要影响之一,让我们把注意力转向第二个方面。

稀疏性

现在我们必须认识到稀疏性的存在。随着评分越来越偏向最受欢迎的项目,较不受欢迎的项目将因数据和推荐的匮乏而受到影响,这被称为数据稀疏性。这与线性代数中的定义相联系:向量中大多数为零或未被填充的元素。当您再次考虑我们的用户-物品矩阵时,不受欢迎的项目构成了具有少量条目的列;这些是稀疏向量。同样,在规模上,我们看到马修效应将更多的总评分推向某些列,并且矩阵在传统数学意义上变得稀疏。因此,稀疏性是推荐系统面临的一个极其知名的挑战。

正如之前所述,让我们从这些稀疏评分对我们的协同过滤算法的影响考虑一下。再次观察到,对于用户X,i th 最受欢迎的项目出现在ℐ X的概率由以下给出:

P ( i ) = f(i,M) ∑ j=1 M f(j,M) = 1/i ∑ j=1 M 1/j

然后

( M - 1 ) * P ( i )

是预期的点击i th 最受欢迎项目的其他用户数量,因此总结所有i,得到与X共享评分的其他用户总数:

∑ i=1 M ( M - 1 ) * P ( i )

再次,当我们回到整体趋势时,我们观察到这种稀疏性潜入到我们的协同过滤算法的实际计算中,考虑不同排名的用户的趋势,并看到他们的排名在其他用户的排名中协作(图 3-3)。

lastfm 用户相似性

图 3-3. Last.fm 数据集的用户相似性计数

我们看到这是一个始终要注意的重要结果:稀疏性将重点放在最受欢迎的用户身上,并有可能使您的推荐系统变得近视。

基于物品的协同过滤

虽然方程式不同,但在本节中,它们同样适用于基于物品的协同过滤。物品之间的相似性表现出与它们的分数中的 Zipf 分布的相同继承,并且在 CF 过程中咨询的物品按排名下降。

协同过滤的用户相似性

在数学中,经常听到讨论距离。甚至回溯到毕达哥拉斯定理,我们被教导将点之间的关系看作是距离或不相似性。事实上,这一基本思想被数学确立为度量的一部分:

d ( a , c ) ≤ d ( a , b ) + d ( b , c )

在机器学习中,我们通常更关注相似性的概念——这是一个极为相关的主题。在许多情况下,我们可以计算相似性或不相似性,因为它们是互补的;当 d : X × X → [ 0 , 1 ] ⊂ ℝ 是一个dissimilarity function,那么我们通常定义如下:

S i m ( a , b ) : = 1 - d ( a , b )

这可能看起来像是一个过分精确的陈述,但实际上你会看到,有多种选择可以用来构建相似性的框架。此外,有时我们甚至制定相似性度量,其中关联的距离度量并不在对象集合上建立度量。这些所谓的伪空间仍然可能非常重要,我们将展示它们在第十章中的应用场景。

在文献中,你会发现论文通常以介绍新的相似性度量开始,然后在该新度量上训练一个你之前见过的模型。正如你将看到的,你选择如何关联对象(用户、物品、特征等)可以对你的算法学到什么有很大影响。

现在,让我们专注于一些具体的相似性度量方法。考虑一个经典的机器学习问题,即聚类:我们有一个空间(通常是 ℝ n ),我们的数据在其中表示,并且被要求将数据分成子集,并为这些集合分配名称。这些集合经常旨在捕捉某种意义,或者至少对于总结集合元素的特征是有用的。

当你进行聚类时,你经常考虑在该空间中彼此接近的点。此外,如果给定一个新的观测值,并要求将其分配给一个集合作为推理任务,你通常计算新观测值的最近邻。这可以是k最近邻或者仅仅是最接近的集群中心的最近邻;无论哪种方式,你的任务是利用相似性的概念来关联——从而分类。在协同过滤中,这个相同的概念被用来将你希望推荐的用户与你已有数据的用户联系起来。

最近邻

最近邻 是一个总称,它源于一个简单的几何概念,即在给定某个空间(由特征向量定义的点)和该空间中的一个点的情况下,可以找到距离它最近的其他点。这在所有的机器学习中都有应用,包括分类、排名/推荐和聚类。“近似最近邻” 提供了更多细节。

如何为我们的用户在 CF 中定义相似性呢?他们显然不处于同一空间,所以我们通常的工具似乎不够用。

皮尔逊相关系数

我们最初的 CF 公式表明,口味相似的用户合作推荐物品给彼此。让两个用户 A 和 B 有一组共同评分的项目 —— 简单地说就是每个人评分的项目集合,写成 ℛ A,B ,以及用户 A 对项目 x 的评分写成 r A,x 。那么以下是从 A 对其所有与 B 共同评分的项目的平均评分偏差之和:

∑ x∈ℛ A,B ( r A,x - r ¯ A )

如果我们将这些评分视为随机变量,并考虑 B 的类似物,即联合分布变量之间的相关性(总体协方差)就是我们的 皮尔逊相关系数

USim A,B = ∑ x∈ℛ A,B (r A,x -r ¯ A )(r B,x -r ¯ B ) ∑ x∈ℛ A,B (r A,x -r ¯ A ) 2 ∑ x∈ℛ A,B (r B,x -r ¯ B ) 2

在这里记住一些细节非常重要:

  • 这是描述用户评分的联合分布变量的相似性。

  • 我们通过所有共同评分的项目来计算这一点,因此用户相似性是通过项目评分来定义的。

  • 这是一个取值范围在 [-1,1] 的成对相似度测量 ∈ ℝ。

相关性和相似性

在 第三部分 中,您将了解更多关于处理排名数据的 相关性相似性 的其他定义,特别是适合处理隐式排名的定义。

通过相似性进行评分

现在我们介绍了用户相似性,让我们来使用它吧!对于用户 A 和项目 x ,我们可以通过相似用户的评分来估计评分:

A f f A,i = r ¯ A + ∑ U∈𝒩(A) USim A,U *(r U,i -r ¯ A ) ∑ U∈𝒩(A) USim A,U

这是用户 A 对物品 x 的评分预测,它考虑了 A 的邻居的相似加权平均评分。换句话说:A 的评分可能是与 A 评分相似的人的平均评分,调整为 A 通常的评分慷慨程度。我们称这个估计为 用户-物品亲和分数

但等等!什么是 𝒩 ( A )?这是通过我们前一节的 USim 定义来确定 A 的邻域。这里的想法是,我们正在聚合那些通过先前的 USim 度量确定为与目标用户相似的本地用户的评分。多少个邻居?如何选择这些邻居?这将是后续章节的主题;现在,假设它们是 k -最近邻居,并假设使用一些超参数调整来确定一个好的 k 值。

探索-利用作为推荐系统

到目前为止,我们提出了两个稍微存在紧张关系的想法:

  • MPIR,一个简单易懂的推荐系统

  • 推荐系统中的马太效应及其在评分分布中的失控行为

现在你可能意识到 MPIR 将增强马太效应,并且马太效应将在极限情况下推动 MPIR 成为微不足道的推荐系统。这是在没有随机化的情况下最大化损失函数的经典困难:它很快会定型为一种模态状态。

这个问题——以及许多类似的问题——鼓励对算法进行一些修改,以防止这种失败模式并继续向算法和用户展示其他选择。探索-利用方案 或所谓的 多臂老虎机 的基本策略,不仅是采用最大化结果的推荐,还采用了一系列替代 变体,并随机确定其作为响应使用的方式。

退一步说:给定一组变体推荐或,A ,每个推荐的结果是y t ,我们有一个先验奖励函数R ( y t ) 。老虎机(在这篇文献中称为代理)希望最大化R ( y t ) ,但不知道结果Y a∈A 的分布。因此,代理假设一些先验分布Y a∈A ,然后收集数据来更新这些分布;在足够的观察之后,代理可以估计每个分布的期望值,μ a∈A = 𝔼 ( ℛ ( Y a ) ) 。

如果代理能够自信地估计这些奖励值,推荐问题就会得到解决:在推断时,代理将简单地估计用户的所有变体的奖励值,并选择优化奖励的。当然,这在整体上是荒谬的,但基本思想仍然有用:对将获得最大预期奖励的事先假设,并定期探索替代方案,以继续更新分布并改进估计器。

即使没有明确使用多臂老虎机,这一见解仍然是理解推荐系统目标的强大和有用的框架。利用先前估计良好推荐的想法并探索其他选项以获取信号是一个经常出现的核心思想。让我们看看这种方法的一个实际应用。

ϵ -贪心

您应该多久探索一次,而不是使用您的奖励优化手臂?第一个最佳算法是ϵ -贪婪算法:对于ϵ ∈ ( 0 , 1 ),每次请求时,代理有ϵ的概率选择一个随机手臂和1 - ϵ的概率选择当前最高估计奖励的手臂。

让我们接受 MPIR,并稍作修改以包括一些探索:

from jax import random
key = random.PRNGKey(0)

def get_item_popularities() -> Optional[Dict[str, int]]:
    ...
        # Dict of pairs: (item-identifier, count item chosen)
        return item_choice_counts
    return None

def get_most_popular_recs_ep_greedy(
    max_num_recs: int,
    epsilon: float
) -> Optional[List[str]]:
    assert epsilon<1.0
    assert epsilon>0

    items_popularity_dict = get_item_popularities()
    if items_popularity_dict:
        sorted_items = sorted(
            items_popularity_dict.items(),
            key=lambda item: item[1]),
            reverse=True,
        )
        top_items = [i[0] for i in sorted_items]
        recommendations = []
        for i in range(max_num_recs): # we wish to return max_num_recs
            if random.uniform(key)>epsilon: # if greater than epsilon, exploit
                recommendations.append(top_items.pop(0))
            else: # otherwise, explore
                explore_choice = random.randint(1,len(top_items))
                recommendations.append(top_items.pop(explore_choice))
        return recommendations

    return None

我们的 MPIR 唯一的修改是,现在对于我们的max_num_recs中的每个潜在推荐,有两种情况。如果随机概率小于我们的ϵ,我们像以前一样选择最热门的;否则,我们选择一个随机推荐。

最大化奖励

我们将奖励最大化解释为选择最受欢迎的物品。这是一个重要的假设,随着我们进入更复杂的推荐系统,这将是我们修改以获得不同算法和方案的关键假设。

现在让我们再次总结我们的推荐组件:

收集器

这里的收集器不需要改变;我们仍然希望首先获得物品的流行度。

排序器

排序器也不改变!我们首先按热门程度对可能的推荐进行排名。

服务器

如果收集器和排序器保持不变,显然服务器必须适应这个新的推荐系统。这种情况下,我们不再仅仅获取max_num_recs中的前几项,而是在每一步中利用我们的ϵ来确定是否下一个添加到列表中的推荐应该是从排序器中的下一个还是一个随机选择。否则,我们遵循相同的 API 模式并返回相同的数据形状。

ϵ应该是什么?

在前面的讨论中,ϵ是整个调用过程中的一个固定数值,但它应该是多少呢?这实际上是一个研究的重要领域,一般的智慧是从较大的ϵ开始(以鼓励更多的探索),然后随着时间的推移逐渐减少。确定减少速度、起始值等问题需要认真思考和研究。此外,这个值可以与您的预测循环联系起来,成为训练过程的一部分。参见 Joseph Rocca 的《探索-利用的权衡:直觉和策略》以深入了解。

其他——通常更好的采样技术用于优化。重要性采样可以利用我们稍后构建的排名函数来整合探索-开发和我们的数据所教导的内容。

NLP-RecSys 关系

让我们利用一些来自机器学习的不同领域直觉,自然语言处理。自然语言处理中的一个基本模型是word2vec:一种基于序列的语言理解模型,它使用在句子中一起出现的单词。

对于skipgram-word2vec,该模型接受句子并尝试通过它们与句子中其他单词的共现关系来学习单词的隐含含义。每对共现单词构成一个样本,该样本进行了独热编码并发送到一个大小为词汇大小的神经元层,其中包括一个瓶颈层和一个用于单词出现概率的词汇大小的输出层。

通过这个网络,我们将我们的表示大小减小到瓶颈维度,从而找到比原始语料库大小的独热嵌入更小的维度表示。这样做的想法是现在可以通过在这个新的表示空间中的向量相似性来计算单词的相似性。

这与推荐系统有何关系呢?好吧,因为如果我们取用户-物品交互的有序序列(例如,用户评价的电影序列),我们可以利用与 word2vec 相同的思想来寻找项目相似性而不是单词相似性。在这个类比中,用户历史就是句子

以前,使用我们的 CF 相似度,我们决定相似的用户可以帮助确定对用户的一个好的推荐是什么。在这个模型中,我们正在找到项目-项目的相似性,所以我们假设用户将喜欢与之前喜欢的项目相似的项目。

项目作为单词

您可能已经注意到自然语言模型将单词视为序列,事实上,我们的用户历史也是一个序列!现在,牢记这一知识。稍后,这将引导我们采用基于序列的方法进行推荐系统。

向量搜索

我们已经建立了我们项目的向量表示集合,并声称在这个空间中的相似性(通常称为潜在空间表示空间环境空间)意味着用户对喜欢性的相似性。

要将此相似性转换为推荐,考虑一个用户A,其中包含以前喜欢的项目的集合ℛ A,并考虑𝒜 = { v x | x ∈ ℛ A }这些项目在这个潜在空间中关联的向量集合。我们正在寻找一个新的项目y,我们认为这对A 是好的。

古老的诅咒

这些潜在空间往往是高维的,欧几里得距离在其中表现糟糕而著名。随着区域变得稀疏,距离函数的性能下降;局部距离有意义,但全局距离不可信。相反,余弦距离表现更好,但这是一个深入探讨的主题。此外,实践中并非最小化距离,而是最大化相似性。

利用相似性生成推荐的一种简单方法是找到最接近upper A喜欢的项目平均值的项目:

argmax y USim ( v y , a v g ( 𝒜 ) ) ∣ y ∈ Items

这里,d(left-parenthesis minus comma minus right-parenthesis)是潜在空间中的距离函数(通常是余弦距离)。

argmax基本上平等对待upper A所有的评分,并建议接近这些评分的内容。实际上,这个过程经常充满挑战。首先,您可以按照评分加权各项:

argmax y USim ( v y , ∑ v x ∈𝒜 r x |ℛ 𝒜 | ) ∣ y ∈ Items

这可以潜在地改善推荐中用户反馈的代表性。或者,您可能会发现用户对各种类型和主题的电影都评分。在这种情况下,简单取平均将导致更糟糕的结果,因此也许您只想简单地找到与用户喜欢的某部电影相似的推荐,根据其评分加权:

argmax y USim (v y ,v x ) r x ∣ y ∈ Items , v x ∈ 𝒜

最后,您甚至可能希望对用户喜欢的不同物品多次执行此过程,以获取k个推荐:

min - k argmax y USim (v y ,v x ) r x ∣ y ∈ Items ∣ v x ∈ 𝒜

现在我们有k个推荐;每个推荐都与用户喜欢的某物品相似,并按其喜欢程度加权。这种方法仅利用了由它们的共现形成的物品的隐式几何。

对于推荐来说,潜在空间及其所具有的几何能力将贯穿本书的主线。我们将经常通过这些几何来制定我们的损失函数,并利用几何直觉来探讨下一步技术扩展的方向。

最近邻搜索

一个合理的问题是:“我怎么才能得到这些向量以使距离最小?”在所有前述方案中,我们都在计算许多距离,然后找到最小值。总体而言,最近邻问题是一个极其重要且深入研究的问题。

虽然精确查找最近邻有时可能会很慢,但在近似最近邻(ANN)搜索方面已经取得了很大进展。这些算法不仅返回接近实际最近邻的结果,而且计算复杂度更低。通常情况下,当您看到我们(或其他出版物)在某些距离上计算argmin(最小化函数的参数)时,实际上很可能在实践中使用的是 ANN。

总结

前一章节中的推荐系统讨论了数据分布原理,如 Zipf 定律和马太效应。这些原理导致了挑战,例如用户相似性评分的偏斜和数据稀疏性。在机器学习领域中,虽然传统数学侧重于距离,但重点在于相似性的概念。不同的相似度度量可以极大地改变算法的学习结果,聚类是其中的主要应用之一。

在推荐领域中,物品通常用高维潜在空间来表示。在这些空间中的相似性暗示了用户的偏好。方法包括推荐接近用户平均喜欢物品的物品,这可以通过加权用户评分来改进。然而,个体偏好需要多样化的推荐。潜在空间继续发挥影响,推动推荐技术的发展。

有效定位这些向量需要进行最近邻搜索。尽管精确方法资源密集,但近似最近邻提供了快速且精确的解决方案,为当前章节讨论的推荐系统奠定了基础。

第四章:推荐系统的系统设计

现在您已经对推荐系统的工作原理有了基础理解,让我们更仔细地看一下所需的要素,并设计一个能够在工业规模下提供推荐的系统。在我们的背景下,“工业规模”主要指的是“合理规模”(由 Ciro Greco、Andrea Polonioli 和 Jacopo Tagliabue 在“ML and MLOps at a Reasonable Scale”中引入的术语)——适用于有数十到数百名工程师致力于产品开发的公司的生产应用,而不是千人以上。

理论上,推荐系统是一组数学公式,可以利用用户与物品的历史交互数据返回用户-物品对亲和性的概率估计。实际上,推荐系统是 5、10 或者可能是 20 个软件系统,实时通信并且使用有限信息,受限物品可用性和永远的样本外行为,所有这些都是为了确保用户看到某些东西

本章受到Eugene Yan 的“System Design for Recommendations and Search”Even Oldridge 与 Karl Byleen-Higley 的“Recommender Systems, Not Just Recommender Models”的深刻影响。

在线与离线

机器学习系统由您预先完成的工作和即时完成的工作组成。在线与离线之间的这种分工是关于执行各种类型任务所需信息的实际考虑。要观察和学习大规模模式,系统需要访问大量数据;这是离线组件。然而,执行推理只需要训练过的模型和相关输入数据。这就是为什么许多机器学习系统架构是这样构建的原因。您经常会遇到描述在线-离线范式两侧的术语批处理实时(见图 4-1)。

在线 vs 离线

图 4-1. 实时对比批处理

批处理不需要用户输入,通常需要更长的完成时间,并且能够同时拥有所有必要的数据。批处理通常包括训练基于历史数据的模型、通过额外的特征集合增强数据集或转换计算密集型数据等任务。在批处理中更频繁见到的另一个特征是,它们使用涉及的完整相关数据集,而不仅仅是按时间切片或其他方式切分的数据实例。

实时 过程 是在请求时执行的;换句话说,在推断过程中进行评估。例如,在页面加载时提供推荐、在用户完成上一集后更新下一集、在某个推荐标记为不感兴趣后重新排名推荐等。由于需要快速响应,实时过程通常受到资源限制的影响;但与该领域的许多事物一样,随着世界计算资源的扩展,我们改变了资源限制的定义。

让我们回到第一章中介绍的组件——收集器、排名器和服务器,并考虑它们在离线和在线系统中的作用。

收集器

收集器的角色是了解可能推荐的项目集合及这些项目的必要特征或属性。

离线收集器

离线收集器 具有对最大数据集的访问权限,并对其负责。理解所有用户-项目交互、用户相似性、项目相似性、用户和项目的特征存储以及最近邻居查找索引都在离线收集器的监管范围内。离线收集器需要能够非常快速地访问相关数据,有时还需要大批量操作。为此,离线收集器通常实现亚线性搜索函数或专门调整的索引结构。它们还可能利用分布式计算进行这些转换。

需要记住的是,离线收集器不仅需要访问和了解这些数据集,还要负责编写必要的下游数据集,以供实时使用。

在线收集器

在线收集器 使用离线收集器索引和准备的信息,实时提供对数据部分的访问,这对推断过程至关重要。这包括技术如寻找最近邻居、从特征存储中增补观察、了解完整的库存目录等。在线收集器还需要处理最近的用户行为;当我们在第十七章中看到顺序推荐器时,这将变得尤为重要。

在线收集器可能还承担另一个角色,即对请求进行编码。在搜索推荐系统的上下文中,我们希望将查询编码成搜索空间,通过嵌入模型。对于上下文推荐系统,我们也需要将上下文编码到潜在空间,同样使用嵌入模型。

嵌入模型

收集者工作中一个流行的子组件将涉及一个嵌入步骤;参见机器学习设计模式,作者瓦利亚帕·拉克什马南等(O'Reilly)。离线方面的嵌入步骤涉及训练嵌入模型和构建后续使用的潜在空间。在在线方面,嵌入转换将需要将查询嵌入到正确的空间中。通过这种方式,嵌入模型作为您模型架构的一部分的转换服务。

排序器

排名器的角色是接受收集者提供的集合,并根据上下文和用户的模型对其部分或全部元素进行排序。排名器实际上包括两个组件,即过滤和评分。

过滤 可以被视为适合推荐的项目的粗略包含和排除。这个过程通常的特征是迅速地削减我们绝对不希望展示的大量潜在推荐。一个简单的例子是不推荐用户过去已经选择过的项目。

评分 是对排名更传统的理解:根据选择的目标函数创建潜在推荐的排序。

离线排名器

离线排名器 的目标是促进过滤和评分。它与在线排名器的区别在于如何运行验证以及输出如何用于构建在线排名器可以利用的快速数据结构。此外,离线排名器可以与人机协作机器学习的人工审查流程集成。

一个后面将讨论的重要技术是布隆过滤器。布隆过滤器允许离线排名器批处理工作,因此实时过滤可以更快地进行。这个过程的一个简化版本是利用请求的几个特征来快速选择所有可能候选集的子集。如果可以快速完成这一步骤——在计算复杂度方面,力求少于候选数的二次——那么下游复杂算法可以大幅提升性能。

在离线组件中,排名是训练学习如何对项目进行排名的模型的过程。正如稍后将看到的那样,学习如何根据目标函数对项目进行排名是推荐模型的核心。训练这些模型并准备其输出的方面是排名器的批处理职责的一部分。

在线排名器

在线排名器受到很多赞赏,但实际上是利用了其他组件的辛勤工作。在线排名器首先进行过滤,利用离线构建的过滤基础设施,例如索引查找或布隆过滤器应用。过滤后,候选推荐的数量已经被控制住,因此我们实际上可以来完成最臭名昭著的任务:排名推荐。

在在线排名阶段,通常会访问特征存储来获取候选项并为其添加必要的细节,然后应用评分和排名模型。评分或排名可能会在几个独立的维度上进行,然后汇总为一个最终排名。在多目标范式中,你可能会有几个与排名器返回的候选列表相关联的这些排名。

服务器

服务器的角色是接收排名器提供的有序子集,确保满足必要的数据模式(包括基本业务逻辑),并返回请求的推荐数量。

离线服务器

离线服务器负责系统返回的推荐的硬需求的高级对齐。除了建立和执行架构外,这些规则可能更细化,比如“在推荐这种上装时,永远不要推荐这条裤子”。通常被视为“业务逻辑”,离线服务器负责创建有效的方式,以在返回的推荐中施加顶级优先级。

离线服务器的另一个责任是处理像实验这样的任务。在某些时候,你可能想运行在线实验,以测试你用本书构建的所有惊人推荐系统。离线服务器是你将实现实验决策逻辑并以在线服务器可以实时使用的方式提供其影响的地方。

在线服务器

在线服务器接受已经建立的规则、要求和配置,并将它们最终应用到排名推荐上。一个简单的例子是多样化规则;正如稍后将看到的,推荐多样化对用户体验的质量有显著影响。在线服务器可以从离线服务器读取多样化要求,并将其应用到排名列表中,以返回预期数量的多样化推荐。

总结

记住在线服务器是其他系统获取响应的端点很重要。虽然通常它是消息的来源,但系统中最复杂的组件多数是上游的。务必以一种方式来监测这个系统,使得当响应变慢时,每个系统都足够可观察,从而可以确定性能降级的原因所在。

现在我们已经建立了框架,您了解了核心组件的功能,接下来我们将讨论机器学习系统的方面以及与其相关的技术类型。

在接下来的章节中,我们将动手操作上述的组件,并看看如何实现关键的部分。最后,我们将把所有内容整合到一个仅使用每个项目内容的生产规模推荐系统中。出发吧!

第五章:将所有内容整合起来:基于内容的推荐系统

在本书的这一部分中,我们介绍了推荐系统中最基本的一些组件。在本章中,我们将亲自动手。我们将为来自 Pinterest 的图像设计并实现推荐系统。这一章以及本书的其他“将所有内容整合起来”章节将向您展示如何使用开源工具处理数据集。这类章节的材料是指在 GitHub 上托管的代码,您需要下载并与之互动,以便全面体验内容。

由于这是第一个实践操作章节,这里提供了开发环境的额外设置说明。我们在运行 Windows 子系统 Linux(WSL)Ubuntu 虚拟机的 Windows 上开发了这段代码。该代码应该可以在 Linux 机器上正常运行,对于 macOS 需要更多的技术适配,而对于 Windows,则最好在 WSL2 Ubuntu 虚拟机上运行。您可以查看Windows 的 Microsoft 文档来了解 WSL 的设置。我们选择 Ubuntu 作为映像。如果您有 NVIDIA GPU 并希望使用它,您还需要NVIDIA CUDAcuDNN

我们将使用“完成外观:基于场景的补充产品推荐”中的Shop the Look (STL) 数据集,作者是康旺诚(Wang-Cheng Kang)等人。

在本章中,我们将向您展示如何构建基于内容的推荐系统。请记住,基于内容的推荐系统使用间接的、可推广的项目表示。例如,假设您想推荐一个蛋糕,但不能使用蛋糕的名称。相反,您可以使用蛋糕的描述或其成分作为内容特征。

使用 STL 数据集,我们将尝试将场景(人物在特定环境中的图片)与可能与场景搭配的产品匹配。训练集包含场景与单个产品的配对,我们希望使用内容推荐系统将推荐扩展到整个产品目录,并按某种排序顺序进行排序。基于内容的推荐系统利用间接的内容特征进行推荐,可用于推荐尚未包含在推荐系统中的新产品,或者在用户开始使用之前手动策划数据和建立反馈循环。在 STL 数据集的情况下,我们将重点放在场景和产品的视觉外观上。

我们将通过卷积神经网络(CNN)架构生成内容嵌入,然后通过三元损失训练嵌入,并展示如何创建内容推荐系统。

本章涵盖以下主题:

  • 修订控制软件

  • Python 构建系统

  • 随机项目推荐

  • 获取 STL 数据集的图像

  • CNN 的定义

  • JAX、Flax 和 Optax 中的模型训练

  • 输入流水线

版本控制软件

版本控制软件是一种跟踪代码更改的软件系统。可以将其视为跟踪您编写的代码版本的数据库,同时提供显示每个代码版本之间差异的附加功能,并允许您恢复到先前版本。

有许多种版本控制系统。我们在GitHub上托管本书的代码。

我们使用的版本控制软件名为Git。代码更改以patch批次的形式进行,每个patch都会上传到类似 GitHub 的源代码控制存储库,以便许多人同时克隆并进行工作。

您可以使用此命令克隆书中代码示例存储库:

git clone git@github.com:BBischof/ESRecsys.git

对于这一章,查看目录 ESRecsys/pinterest 以了解如何详细运行代码的说明。这一章主要侧重于描述和指向存储库,以便你能够实际感受这些系统。

Python 构建系统

Python 是提供超出标准 Python 库功能的库。这些包括诸如 TensorFlow 和 JAX 等 ML 包,但也包括更实用的包,例如 absl 标志库或机器学习操作(MLOps)库,如Weights & Biases

这些包通常托管在Python 软件包索引上。

查看文件 requirements.txt

absl-py==1.1.0
tensorflow==2.9.1
typed-ast==1.5.4
typing_extensions==4.2.0
jax==0.3.25
flax==0.5.2
optax==0.1.2
wandb==0.13.4

您可以看到我们选择了一小组 Python 包来安装我们的依赖项。格式为包名、两个等号,然后是包的版本。

还有其他与 Python 一起工作的构建系统,包括以下内容:

对于这一章,我们将使用 pip。

然而,在安装包之前,您可能想要了解一下Python 虚拟环境。Python 虚拟环境是一种跟踪每个项目的 Python 包依赖关系的方法,因此,如果不同项目使用不同版本的相同包,它们不会相互干扰,因为每个项目都有自己的 Python 虚拟环境来运行。

您可以通过在 Unix shell 中键入以下内容来创建和激活 Python 虚拟环境:

python -m venv pinterest_venv
source pinterest_venv/bin/activate

第一个命令创建一个 Python 虚拟环境,第二个命令激活它。每次打开新的 shell 时,您都需要激活一个虚拟环境,以便 Python 知道要在哪个环境中工作。

创建虚拟环境后,您可以使用 pip 将软件包安装到虚拟环境中,新安装的软件包不会影响系统级软件包。

您可以通过在 ESRecsys/pinterest 目录中运行此命令来执行此操作:

pip install -r requirements.txt

这将安装指定的软件包及其可能依赖的任何子软件包到虚拟环境中。

随机项目推荐器

我们首先要看的程序是一个随机项目推荐器(示例 5-1)。

示例 5-1. 设置标志
FLAGS = flags.FLAGS
_INPUT_FILE = flags.DEFINE_string(
  "input_file", None, "Input cat json file.")
_OUTPUT_HTML = flags.DEFINE_string(
  "output_html", None, "The output html file.")
_NUM_ITEMS = flags.DEFINE_integer(
  "num_items", 10, "Number of items to recommend.")

# Required flag.
flags.mark_flag_as_required("input_file")
flags.mark_flag_as_required("output_html")

def read_catalog(catalog: str) -> Dict[str, str]:
    """
 Reads in the product to category catalog.
 """
    with open(catalog, "r") as f:
        data = f.read()
    result = json.loads(data)
    return result

def dump_html(subset, output_html:str) -> None:
    """
 Dumps a subset of items.
 """
    with open(output_html, "w") as f:
        f.write("<HTML>\n")
        f.write("""
 <TABLE><tr>
 <th>Key</th>
 <th>Category</th>
 <th>Image</th>
 </tr>""")
        for item in subset:
            key, category = item
            url = pin_util.key_to_url(key)
            img_url = "<img src=\"%s\">" % url
            out = "<tr><td>%s</td><td>%s</td><td>%s</td></tr>\n" %
            (key, category, img_url)
            f.write(out)
        f.write("</TABLE></HTML>")

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

    catalog = read_catalog(_INPUT_FILE.value)
    catalog = list(catalog.items())
    random.shuffle(catalog)
    dump_html(catalog[:_NUM_ITEMS.value], _OUTPUT_HTML.value)

在这里,我们使用 absl 标志库来传递程序参数,例如包含 STL 场景和产品配对的 JSON 目录文件的路径。

标志可以有不同的类型,如字符串和整数,并且您可以将它们标记为必需的。如果未传递必需的标志到程序中,程序将报错并停止运行。可以通过它们的值方法访问标志。

我们通过使用 JSON Python 库加载和解析 STL 数据集,然后随机洗牌目录并将前几个结果转储到 HTML 中。

您可以通过以下命令来运行随机项目推荐器:

python3 random_item_recommender.py
--input_file=STL-Dataset/fashion-cat.json --output_html=output.html

完成后,您可以使用 Web 浏览器打开output.html文件,查看目录中的一些随机项目。图 5-1 展示了一个示例。

来自 Pinterest 商店“看看”数据集的随机项目

图 5-1. 随机项目推荐器

fashion-catalog.json 文件包含产品描述及其 Pinterest ID,而fashion.json 包含场景与推荐产品的配对信息。

接下来,我们将看看如何通过对场景-产品配对进行训练 ML 模型来为单个场景推荐多个新项目。

当您第一次遇到一个语料库时,创建一个随机项目推荐器通常是一个好主意,这样您可以了解语料库中的项目类型,并有一个比较的基准。

获取 STL 数据集图像

创建基于内容的推荐系统的第一步是获取内容。在这种情况下,STL 数据集的内容主要是图像,还包括一些关于图像的元数据(如产品类型)。本章节我们将仅使用图像内容。

您可以查看fetch_images.py中的代码,了解如何使用 Python 标准库 urllib 获取图像。请注意,在其他网站上进行过多的抓取可能会触发其机器人防御机制,并导致将您的 IP 地址列入黑名单,因此限制抓取速率或找到其他获取数据的方法可能是个明智的选择。

我们已经下载了成千上万个图像文件,并将它们放在一个 Weights & Biases 工件中。由于存档已经包含在此工件中,您无需自行抓取这些图像,但我们提供的代码将允许您这样做。

您可以在Weights & Biases 文档中了解有关工件的更多信息。工件是 MLOps 概念,用于版本控制和打包数据的归档,并跟踪数据的生产者和消费者。

您可以通过运行以下命令来下载图像文件:

wandb artifact get building-recsys/recsys-pinterest/shop_the_look:latest

图像将会位于本地目录artifacts/shop_the_look:v1中。

卷积神经网络定义

现在我们有了图像,下一步是找出如何表示数据。图像有不同的大小,是一种复杂的内容类型进行分析。我们可以使用原始像素作为我们内容的表示,但缺点是像素值的微小变化可能会导致图像之间的距离差异很大。我们不希望这样。相反,我们希望某种方式学习图像中重要的内容,忽略像背景颜色这样的图像部分,这些部分可能不那么重要。

对于这个任务,我们将使用卷积神经网络(CNN)来计算图像的嵌入向量。嵌入向量 是从数据中学习到的图像的一种特征向量,大小固定。我们使用嵌入向量作为我们的表示,因为我们希望我们的数据库小而紧凑,易于在大量图像中进行评分,并与手头的任务相关,即将产品匹配到给定场景图像。

我们使用的神经网络架构是残差网络的一种变体,或称为 ResNet。有关架构的详细信息和 CNN 的参考,请参阅 Kaiming He 等人的“深度残差学习用于图像识别”。简而言之,卷积层重复地在图像上应用一个通常为 3×3 大小的小滤波器。如果步幅为(1,1)(这意味着在 x 方向和 y 方向上以 1 像素步幅应用滤波器),则这会产生与输入相同分辨率的特征图,如果步幅为(2,2),则为四分之一大小。残差跳跃连接只是从前一个输入层到下一个的一种快捷方式,因此实际上,网络的非线性部分学习线性跳跃部分的残差,因此得名残差网络。

另外,我们使用 BatchNorm 层,其详细信息可以在Sergey Ioffe 和 Christian Szegedy 的“批归一化:通过减少内部协变量偏移加速深度网络训练”Prajit Ramachandran,Barret Zoph 和 Quoc V. Le 的“搜索激活函数”中找到。

一旦我们指定了模型,我们还需要为任务进行优化。

在 JAX、Flax 和 Optax 中进行模型训练

在任何 ML 框架中优化我们的模型应该是相当简单的。这里我们展示如何使用JAXFlaxOptax轻松地完成这项任务。JAX 是一个类似于 NumPy 的低级 ML 库,Flax 是一个更高级的神经网络库,提供神经网络模块和嵌入层等功能。Optax 是一个库,用于优化我们将用来最小化损失函数的内容。

如果你熟悉 NumPy,学习 JAX 会很容易。JAX 与 NumPy 共享相同的 API,但通过即时编译具备在矢量处理器(如 GPU 或 TPU)上运行生成的代码的能力。JAX 设备数组和 NumPy 数组可以轻松相互转换,这使得在 GPU 上开发变得简单,但在 CPU 上进行调试也很容易。

除了学习如何表示图像外,我们还需要指定它们之间的关系。

由于嵌入向量具有固定的维度,最简单的相似度分数只是两个向量的点积。参见“共现相似度”获取其他类型的相似度度量方法。因此,给定一个场景的图像,我们计算场景嵌入,并对产品执行相同操作以获取产品嵌入,然后取两者的点积以获取场景 s → 与产品 p -> 的拟合紧密程度分数:

s c o r e ( s → , p → ) = s → * p →

我们使用 CNN 来获取图像的嵌入。

我们为场景和产品使用单独的 CNN,因为它们来自不同类型的图像。场景往往显示我们要匹配产品的背景和人物设置,而产品则往往是鞋子和包的目录图像,背景是空白的,因此我们需要不同的神经网络来确定图像中重要的内容。

一旦我们有了分数,仅仅这些还不够。我们需要确保一个场景和产品的良好匹配,我们称之为正面产品,比负面产品得分更高。正面产品是场景的良好匹配,而负面产品是场景的不太良好匹配。正面产品来自训练数据,而负面产品来自随机采样的目录。能够捕捉正面场景-产品对(A, B)和负面场景-产品对(A, C)之间关系的损失称为三元损失。让我们详细定义一下三元损失

假设我们希望正面场景-产品对的分数比负面场景-产品对多一个。那么我们有以下不等式:

s c o r e ( s c e n e , p o s product ) > s c o r e ( s c e n e , n e g product ) + 1

1 只是我们使用的一个任意常数,称为间隔,以确保正面场景-产品分数大于负面场景-产品分数。

由于梯度下降的过程是最小化一个函数,我们将前述不等式转化为损失函数,通过将所有项移到一边实现:

0 > 1 + s c o r e ( s c e n e , n e g product ) - s c o r e ( s c e n e , p o s product )

只要右侧的数量大于 0,我们希望将其最小化;但如果已经小于 0,则不进行操作。因此,我们将数量编码为修正线性单元,其由函数max(0, *x*)表示。因此,我们可以将我们的损失函数写成如下形式:

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

由于我们通常会最小化损失函数,这确保只要score(scene, neg_product)score(scene, pos_product)多 1,优化过程将尝试减少负对的分数,同时增加正对的分数。

下一个示例按顺序涵盖以下模块,以便它们在数据从读取到训练再到制作推荐的流程中具有意义:

input__pipeline.py

如何读取数据

models.py

如何指定神经网络

train_shop_the_look.py

如何使用 Optax 拟合神经网络

make_embeddings.py

如何制作一个紧凑的场景和产品数据库

make_recommendations.py

如何使用嵌入的紧凑数据库创建每个场景的产品推荐列表

输入流水线

示例 5-2 展示了input_pipeline.py的代码。我们使用 ML 库TensorFlow来进行数据流水线处理。

示例 5-2. TensorFlow 数据流水线
import tensorflow as tf

def normalize_image(img):
  img = tf.cast(img, dtype=tf.float32)
  img = (img / 255.0) - 0.5
  return img

def process_image(x):
  x = tf.io.read_file(x)
  x = tf.io.decode_jpeg(x, channels=3)
  x = tf.image.resize_with_crop_or_pad(x, 512, 512)
  x = normalize_image(x)
  return x

def process_image_with_id(id):
  image = process_image(id)
  return id, image

def process_triplet(x):
  x = (process_image(x[0]), process_image(x[1]), process_image(x[2]))
  return x

def create_dataset(
    triplet: Sequence[Tuple[str, str, str]]):
    """Creates a triplet dataset.
 Args:
 triplet: filenames of scene, positive product, negative product.
 """
    ds = tf.data.Dataset.from_tensor_slices(triplet)
    ds = ds.map(process_triplet)
    return ds

您可以看到 create_dataset 接受三个文件名:场景的文件名,然后是正匹配和负匹配。对于这个示例,负匹配只是从目录中随机选择的。我们在 第十二章 中介绍了选择负面的更复杂的方法。图像文件名通过读取文件,解码图像,裁剪到固定大小,然后重新缩放数据,使其成为围绕 0 居中并具有在 –1 和 1 之间的小值的浮点图像来处理。我们这样做是因为大多数神经网络被初始化时假定它们接收到的数据大致上是正态分布的,因此如果您传入的值太大,它将远远超出预期输入范围的正常值。

示例 5-3 展示了如何使用 Flax 指定我们的 CNN 和 STL 模型。

示例 5-3. 定义 CNN 模型
from flax import linen as nn
import jax.numpy as jnp

class CNN(nn.Module):
    """Simple CNN."""
    filters : Sequence[int]
    output_size : int

    @nn.compact
    def __call__(self, x, train: bool = True):
        for filter in self.filters:
            # Stride 2 downsamples 2x.
            residual = nn.Conv(filter, (3, 3), (2, 2))(x)
            x = nn.Conv(filter, (3, 3), (2, 2))(x)
            x = nn.BatchNorm(
              use_running_average=not train, use_bias=False)(x)
            x = nn.swish(x)
            x = nn.Conv(filter, (1, 1), (1, 1))(x)
            x = nn.BatchNorm(
              use_running_average=not train, use_bias=False)(x)
            x = nn.swish(x)
            x = nn.Conv(filter, (1, 1), (1, 1))(x)
            x = nn.BatchNorm(
              use_running_average=not train, use_bias=False)(x)
            x = x + residual
            # Average pool downsamples 2x.
            x = nn.avg_pool(x, (3, 3), strides=(2, 2), padding="SAME")
        x = jnp.mean(x, axis=(1, 2))
        x = nn.Dense(self.output_size, dtype=jnp.float32)(x)
        return x

class STLModel(nn.Module):
    """Shop the look model that takes in a scene
 and item and computes a score for them.
 """
    output_size : int

    def setup(self):
        default_filter = [16, 32, 64, 128]
        self.scene_cnn = CNN(
          filters=default_filter, output_size=self.output_size)
        self.product_cnn = CNN(
          filters=default_filter, output_size=self.output_size)

    def get_scene_embed(self, scene):
        return self.scene_cnn(scene, False)

    def get_product_embed(self, product):
        return self.product_cnn(product, False)

    def __call__(self, scene, pos_product, neg_product,
                 train: bool = True):
        scene_embed = self.scene_cnn(scene, train)

        pos_product_embed = self.product_cnn(pos_product, train)
        pos_score = scene_embed * pos_product_embed
        pos_score = jnp.sum(pos_score, axis=-1)

        neg_product_embed = self.product_cnn(neg_product, train)
        neg_score = scene_embed * neg_product_embed
        neg_score = jnp.sum(neg_score, axis=-1)

        return pos_score, neg_score, scene_embed,
          pos_product_embed, neg_product_embed

在这里,我们使用了 Flax 的神经网络类 Module。注释 nn.compact 存在是为了对于像这样的简单神经网络架构,我们不必指定一个设置函数,而是可以简单地在 call 函数中指定层。call 函数接受两个参数,一个图像 *x* 和一个布尔值 train,告诉模块我们是否在训练模式下调用它。我们需要布尔值训练的原因是 BatchNorm 层仅在训练期间更新,并在网络完全学习时不会更新。

如果您查看 CNN 规范代码,您可以看到我们如何设置残差网络。我们可以自由地混合神经网络函数,比如 swish,和 JAX 函数,比如 meanswish 函数是神经网络的非线性激活函数,它将输入转换为一种方式,以便对某些激活值给予更高的权重。

另一方面,STL 模型具有更复杂的设置,因此我们必须指定设置代码来创建两个 CNN tower:一个用于场景,另一个用于产品。CNN tower 只是相同架构的副本,但对于不同的图像类型具有不同的权重。如前所述,我们对于每种图像类型都有一个不同的 tower,因为每种代表不同的事物;一个 tower 用于场景(提供我们匹配产品的上下文),另一个 tower 用于产品。因此,我们添加了两种不同的方法,用于将场景和产品图像转换为场景和产品嵌入。

调用也是不同的。它没有注释紧凑,因为我们有一个更复杂的设置。在 STL 模型的调用函数中,我们首先计算场景嵌入,然后计算正产品嵌入和正分数。之后,我们对负分数进行同样的操作。然后,我们返回正分数、负分数和所有三个嵌入向量。我们返回嵌入向量以及分数,因为我们希望确保模型泛化到新的、未见过的数据,例如保留验证集,因此我们希望确保嵌入向量不会过大。限制它们大小的概念称为正则化

现在让我们来看一下 train_shop_the_look.py (示例 5-4)。我们将其分解为单独的函数调用,并逐一讨论它们。

示例 5-4. 生成训练用三元组
def generate_triplets(
    scene_product: Sequence[Tuple[str, str]],
    num_neg: int) -> Sequence[Tuple[str, str, str]]:
    """Generate positive and negative triplets."""
    count = len(scene_product)
    train = []
    test = []
    key = jax.random.PRNGKey(0)
    for i in range(count):
        scene, pos = scene_product[i]
        is_test = i % 10 == 0
        key, subkey = jax.random.split(key)
        neg_indices = jax.random.randint(subkey, [num_neg], 0, count - 1)
        for neg_idx in neg_indices:
            _, neg = scene_product[neg_idx]
            if is_test:
                test.append((scene, pos, neg))
            else:
                train.append((scene, pos, neg))
    return train, test

 def shuffle_array(key, x):
    """Deterministic string shuffle."""
    num = len(x)
    to_swap = jax.random.randint(key, [num], 0, num - 1)
    return [x[t] for t in to_swap]

代码片段读取场景产品的 JSON 数据库,并为输入流水线生成场景、正产品和负产品的三元组。这里值得注意的有趣部分是 JAX 如何处理随机数。JAX 的理念是功能性的,意味着函数是纯净的,没有副作用。随机数生成器带有状态,因此为了使 JAX 的随机数生成器能够工作,你必须将状态传递给随机数生成器。其机制是使用伪随机数生成器密钥 PNRGKey 作为携带状态的对象。我们任意地从数字 0 初始化一个。然而,每当我们希望使用密钥时,我们必须使用 jax.random.split 将其分成两部分,然后使用其中一部分生成下一个随机数,使用子密钥执行随机操作。在本例中,我们使用子密钥从整个产品语料库中选择一个随机负例。在第 12 章中,我们介绍了更复杂的负例抽样方法,但随机选择一个负例是构建三元组损失的最简单方法。

类似于选择负例的方式,我们再次使用 JAX 的随机功能生成要交换的索引列表,以便在训练步骤中对数组进行洗牌。在随机梯度下降中,随机洗牌对于打破训练数据的任何结构都是重要的,以确保梯度是随机的。我们使用 JAX 的随机洗牌机制来提高再现性,以便在相同的初始数据和设置下实验更有可能是相同的。

我们将看一下的下一对函数列在 示例 5-5 中,展示了训练和评估步骤的编写方式。训练步骤采用了模型状态,其中包含模型参数以及梯度信息,这取决于所使用的优化器。此步骤还接受场景批次、正产品和负产品,以构建三元损失。除了优化三元损失外,我们还希望在嵌入超出单位球时最小化其大小。最小化嵌入大小的过程称为正则化,因此我们将其添加到三元损失中以获得最终损失。

示例 5-5. 训练和评估步骤
def train_step(state, scene, pos_product,
               neg_product, regularization, batch_size):
    def loss_fn(params):
        result, new_model_state = state.apply_fn(
            params,
            scene, pos_product, neg_product, True,
            mutable=['batch_stats'])
        triplet_loss = jnp.sum(nn.relu(1.0 + result[1] - result[0]))
        def reg_fn(embed):
            return nn.relu(
              jnp.sqrt(jnp.sum(jnp.square(embed), axis=-1)) - 1.0)
        reg_loss = reg_fn(result[2]) +
                   reg_fn(result[3]) + reg_fn(result[4])
        reg_loss = jnp.sum(reg_loss)
        return (triplet_loss + regularization * reg_loss) / batch_size

    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

def eval_step(state, scene, pos_product, neg_product):
    def loss_fn(params):
        result, new_model_state = state.apply_fn(
            state.params,
            scene, pos_product, neg_product, True,
            mutable=['batch_stats'])
        # Use a fixed margin for the eval.
        triplet_loss = jnp.sum(nn.relu(1.0 + result[1] - result[0]))
        return triplet_loss

Flax 基于 JAX 编写,同样具有功能哲学,因此使用现有状态计算损失函数的梯度,应用后返回一个新的状态变量。这确保函数保持纯粹且状态变量可变。

正是这种功能哲学使得 JAX 能够即时编译或使用即时函数,从而在 CPU、GPU 或 TPU 上运行得更快。

相比之下,评估步骤相对简单。它仅计算三元损失,不考虑正则化损失作为我们的评估指标。我们将在 第 11 章 中介绍更复杂的评估指标。

最后,让我们来看看训练程序的主体部分,如 示例 5-6 所示。我们将学习率、正则化以及输出大小等超参数存储在配置字典中。我们这样做是为了将配置字典传递给 Weights & Biases MLOps 服务进行安全存储,同时也能进行超参数调优。

示例 5-6. 训练模型的主体代码
def main(argv):
    """Main function."""
    del argv  # Unused.
    config = {
        "learning_rate" : _LEARNING_RATE.value,
        "regularization" : _REGULARIZATION.value,
        "output_size" : _OUTPUT_SIZE.value
    }

    run = wandb.init(
        config=config,
        project="recsys-pinterest"
    )

    tf.config.set_visible_devices([], 'GPU')
    tf.compat.v1.enable_eager_execution()
    logging.info("Image dir %s, input file %s",
      _IMAGE_DIRECTORY.value, _INPUT_FILE.value)
    scene_product = pin_util.get_valid_scene_product(
      _IMAGE_DIRECTORY.value, _INPUT_FILE.value)
    logging.info("Found %d valid scene product pairs." % len(scene_product))

    train, test = generate_triplets(scene_product, _NUM_NEG.value)
    num_train = len(train)
    num_test = len(test)
    logging.info("Train triplets %d", num_train)
    logging.info("Test triplets %d", num_test)

     # Random shuffle the train.
    key = jax.random.PRNGKey(0)
    train = shuffle_array(key, train)
    test = shuffle_array(key, test)
    train = np.array(train)
    test = np.array(test)

    train_ds = input_pipeline.create_dataset(train).repeat()
    train_ds = train_ds.batch(_BATCH_SIZE.value).prefetch(
      tf.data.AUTOTUNE)

    test_ds = input_pipeline.create_dataset(test).repeat()
    test_ds = test_ds.batch(_BATCH_SIZE.value)

    stl = models.STLModel(output_size=wandb.config.output_size)
    train_it = train_ds.as_numpy_iterator()
    test_it = test_ds.as_numpy_iterator()
    x = next(train_it)
    key, subkey = jax.random.split(key)
    params = stl.init(subkey, x[0], x[1], x[2])
    tx = optax.adam(learning_rate=wandb.config.learning_rate)
    state = train_state.TrainState.create(
        apply_fn=stl.apply, params=params, tx=tx)
    if _RESTORE_CHECKPOINT.value:
        state = checkpoints.restore_checkpoint(_WORKDIR.value, state)

    train_step_fn = jax.jit(train_step)
    eval_step_fn = jax.jit(eval_step)

    losses = []
    init_step = state.step
    logging.info("Starting at step %d", init_step)
    regularization = wandb.config.regularization
    batch_size = _BATCH_SIZE.value
    eval_steps = int(num_test / batch_size)
    for i in range(init_step, _MAX_STEPS.value + 1):
        batch = next(train_it)
        scene = batch[0]
        pos_product = batch[1]
        neg_product = batch[2]

        state, loss = train_step_fn(
            state, scene, pos_product, neg_product,
            regularization, batch_size)
        losses.append(loss)
        if i % _CHECKPOINT_EVERY_STEPS.value == 0 and i > 0:
            logging.info("Saving checkpoint")
            checkpoints.save_checkpoint(
              _WORKDIR.value, state, state.step, keep=3)
        metrics = {
            "step" : state.step
        }
        if i % _EVAL_EVERY_STEPS.value == 0 and i > 0:
            eval_loss = []
            for j in range(eval_steps):
                ebatch = next(test_it)
                escene = ebatch[0]
                epos_product = ebatch[1]
                eneg_product = ebatch[2]
                loss = eval_step_fn(
                  state, escene, epos_product, eneg_product)
                eval_loss.append(loss)
            eval_loss = jnp.mean(jnp.array(eval_loss)) / batch_size
            metrics.update({"eval_loss" : eval_loss})
        if i % _LOG_EVERY_STEPS.value == 0 and i > 0:
            mean_loss = jnp.mean(jnp.array(losses))
            losses = []
            metrics.update({"train_loss" : mean_loss})
            wandb.log(metrics)
            logging.info(metrics)

    logging.info("Saving as %s", _MODEL_NAME.value)
    data = flax.serialization.to_bytes(state)
    metadata = { "output_size" : wandb.config.output_size }
    artifact = wandb.Artifact(
        name=_MODEL_NAME.value,
        metadata=metadata,
        type="model")
    with artifact.new_file("pinterest_stl.model", "wb") as f:
        f.write(data)
    run.log_artifact(artifact)

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

超参数调优是一个调整服务,通过运行许多不同数值的试验来帮助您找到诸如学习率之类的超参数的最佳值,并寻找最佳值。将配置作为字典允许我们通过运行超参数调优并保存最佳参数来复现最佳参数用于最终模型。

在 图 5-2 中,您可以看到 Weights & Biases 超参数调优的示例。左侧显示了调优中的所有运行;每个运行尝试不同的数值组合,我们在配置字典中指定了这些值。中间显示了随着调优试验次数变化的最终评估损失。右侧显示了影响评估损失的超参数重要性图。在这里,我们可以看到学习率对评估损失有最大影响,其次是正则化量。

brpj 0502

图 5-2. Weights & Biases 超参数调优

在图的右下角,平行坐标图显示了每个参数如何影响评估损失。阅读图表时,请跟随每条线并查看其在最终评估损失上的位置。通过追踪从右下方评估损失的目标值到左侧的线路,可以找到最佳超参数。在这种情况下,选择的最佳数值为学习率为0.0001618,正则化为0.2076,输出大小为 64。

代码的其余部分主要是设置模型并将输入流水线连接到模型。决定何时记录指标和模型序列化在很大程度上是不言自明的。详情可以参阅 Flax 文档。

在保存模型时,请注意使用了两种方法。一种是检查点,另一种是 Flax 序列化。我们之所以两者都有,是因为当训练作业被取消时,需要使用检查点来恢复作业取消的步骤,以便能够恢复训练。最终的序列化在训练完成时使用。

我们还将模型保存为Weights & Biases artifact的副本。这样,Weights & Biases 平台可以跟踪创建模型的超参数、确切的代码和生成模型的确切 Git 哈希,以及模型的衍生线。这条衍生线包括用于生成模型的上游工件(如训练数据)、用于创建模型的作业状态,以及未来可能使用该工件的所有作业的反向链接。在您的组织较大且人员正在寻找有关如何创建模型的信息时,这将非常方便。通过使用工件,他们可以简单地查找代码和训练数据工件的位置以复现模型。

现在我们已经训练了模型,我们希望为场景和产品数据库生成嵌入。与使用模型作为评分函数不同之处在于,您可以独立生成场景和产品嵌入,然后在推断时扩展这些计算。这种扩展将在第八章介绍,但现在我们将显示make_embeddings.py的相关部分,如示例 5-7 所示。

示例 5-7. 寻找前k个推荐
    model = models.STLModel(output_size=_OUTPUT_SIZE.value)
    state = None
    logging.info("Attempting to read model %s", _MODEL_NAME.value)
    with open(_MODEL_NAME.value, "rb") as f:
        data = f.read()
        state = flax.serialization.from_bytes(model, data)
    assert(state != None)

    @jax.jit
    def get_scene_embed(x):
      return model.apply(state["params"], x, method=models.STLModel.get_scene_embed)
    @jax.jit
    def get_product_embed(x):
      return model.apply(
      state["params"],
      x,
      method=models.STLModel.get_product_embed
      )

    ds = tf.data.Dataset
      .from_tensor_slices(unique_scenes)
      .map(input_pipeline.process_image_with_id)
    ds = ds.batch(_BATCH_SIZE.value, drop_remainder=True)
    it = ds.as_numpy_iterator()
    scene_dict = {}
    count = 0
    for id, image in it:
      count = count + 1
      if count % 100 == 0:
        logging.info("Created %d scene embeddings", count * _BATCH_SIZE.value)
      result = get_scene_embed(image)
      for i in range(_BATCH_SIZE.value):
        current_id = id[i].decode("utf-8")
        tmp = np.array(result[i])
        current_result = [float(tmp[j]) for j in range(tmp.shape[0])]
        scene_dict.update({current_id : current_result})
    scene_filename = os.path.join(_OUTDIR.value, "scene_embed.json")
    with open(scene_filename, "w") as scene_file:
      json.dump(scene_dict, scene_file)

正如您所见,我们简单地使用相同的 Flax 序列化库加载模型,然后使用apply函数调用模型的适当方法。然后我们将向量保存在 JSON 文件中,因为我们已经在场景和产品数据库中使用 JSON。

最后,我们将使用make_recommendations.py中的评分代码为样本场景生成产品推荐(示例 5-8)。

Example 5-8. 核心检索定义
def find_top_k(
  scene_embedding,
  product_embeddings,
  k):
  """
 Finds the top K nearest product embeddings to the scene embedding.
 Args:
 scene_embedding: embedding vector for the scene
 product_embedding: embedding vectors for the products.
 k: number of top results to return.
 """

  scores = scene_embedding * product_embeddings
  scores = jnp.sum(scores, axis=-1)
  scores_and_indices = jax.lax.top_k(scores, k)
  return scores_and_indices

top_k_finder = jax.jit(find_top_k, static_argnames=["k"])

最相关的代码片段是评分代码,我们在其中有一个场景嵌入,并希望使用 JAX 来评分所有产品嵌入,而不是单个场景嵌入。在这里,我们使用了 JAX 的一个子库 Lax,提供了直接调用 XLA 的 API,XLA 是 JAX 的底层 ML 编译器,用于访问像top_k这样的加速函数。此外,我们通过使用 JAX 的 JIT 编译函数 find_top_k。您可以将包含 JAX 命令的纯 Python 函数传递给 jax.jit,以便将它们编译到特定的目标架构,例如使用 XLA 的 GPU。请注意我们有一个特殊的参数 static_argnames;这允许我们告知 JAX,k 是固定的,并且不会经常更改,以便 JAX 能够为一个固定值 k 编译一个特定用途的 top_k_finder

图 5-3 展示了一个女性穿红衬衫场景的产品推荐示例。推荐的产品包括红色天鹅绒和深色裤子。

穿红衬衫室内推荐物品

图 5-3. 室内场景推荐物品

图 5-4 展示了另一个场景:一个女性在户外穿着红色外套,搭配的配件是黄色手提包和黄色裤子。

我们预先生成了一些结果,并将其存储为一个工件,您可以通过输入以下命令查看:

wandb artifact get building-recsys/recsys-pinterest/scene_product_results:v0

你可能会注意到黄色包和裤子经常被推荐。黄色包的嵌入向量可能很大,因此会与许多场景匹配。这被称为流行物品问题,是推荐系统中的一个常见问题。我们将在后面的章节中涵盖一些处理多样性和流行度的业务逻辑,但这是一个您可能希望留意的推荐系统问题。

穿红衬衫户外推荐物品

图 5-4. 户外场景推荐物品

总结

至此,我们完成了第一个“全面介绍”章节。我们讲解了如何使用 JAX 和 Flax 来读取现实世界的数据,训练模型,并找出一个外观的顶级推荐物品。如果你还没有尝试过这些代码,请移步到 GitHub 仓库来试试看吧!我们希望通过提供一个实际工作的内容推荐器的示例,让你更好地理解理论如何转化为实践。享受与代码的互动吧!

第二部分:检索

我们如何将所有数据放到正确位置来训练推荐系统?如何构建和部署用于实时推断的系统?

阅读关于推荐系统的研究论文经常会给人一种印象,即它们是通过一堆数学方程构建的,而真正困难的工作是将这些方程与您问题的特征连接起来。更现实地说,构建生产推荐系统的前几个步骤通常属于系统工程。了解您的数据如何进入系统,如何被操作成正确的结构,以及如何在训练流程的每个相关步骤中可用,通常构成了初始推荐系统工作的大部分内容。但即使在这个初始阶段之后,确保所有必要的组件在生产环境中足够快速和稳健,仍然需要对平台基础设施进行另一项重大投资。

通常情况下,您将构建一个负责处理各种类型数据并以方便的格式存储它们的组件。接下来,您将构建一个模型,该模型将获取这些数据并将其编码为潜在空间或其他表示模型中。最后,您需要将输入请求转换为此空间中的查询表示。这些步骤通常采用工作流管理平台中的作业形式或部署为端点的服务。接下来的几章将带您了解构建和部署这些系统所需的相关技术和概念,以及可靠性、可扩展性和效率等重要方面的认识。

您可能会想:“我是一名数据科学家!我不需要了解所有这些!”但您应该知道,推荐系统有一个不方便的双重性:模型架构的变化经常影响系统架构。有兴趣尝试那些花俏的变压器吗?您的部署策略将需要新的设计。也许您聪明的特征嵌入可以解决冷启动问题!这些特征嵌入将需要为您的编码层提供服务,并与您的新 NoSQL 特征存储集成。不要惊慌!本书的这一部分将带您穿越大数据动物园。

第六章:数据处理

在我们在第一章中定义的简单推荐器中,我们使用了get_availability方法;而在 MPIR 中,我们使用了get_item_popularities方法。我们希望这些命名选择能提供足够的上下文来说明它们的功能,但我们并未关注实现细节。现在我们将开始分解一些这种复杂性的细节,并呈现在线和离线收集工具集。

系统水合

将数据导入管道中幽默地称为水合。ML 和数据领域有很多与水相关的命名惯例;《《(数据 ∩ 水) 术语》》由帕迪斯·努尔扎德涵盖了这个主题。

PySpark

Spark 是一个非常通用的计算库,提供 Java、Python、SQL 和 Scala 的 API。PySpark 在许多 ML 流水线中用于数据处理和转换大规模数据集。

让我们回顾一下我们为推荐问题引入的数据结构;回想一下用户-物品矩阵是所有用户、物品及用户对物品评分的线性代数表示。这些三元组在野外并不自然存在。最常见的情况是,你从系统的日志文件开始;例如,Bookshop.org 可能有类似以下内容的东西:

	'page_view_id': 'd15220a8e9a8e488162af3120b4396a9ca1',
	'anonymous_id': 'e455d516-3c08-4b6f-ab12-77f930e2661f',
	'view_tstamp': 2020-10-29 17:44:41+00:00,
	'page_url': 'https://bookshop.org/lists/best-sellers-of-the-week',
	'page_url_host': 'bookshop.org',
	'page_url_path': '/lists/bookshop-org-best-sellers-of-the-week',
	'page_title': 'Best Sellers of the Week',
	'page_url_query': None,
	'authenticated_user_id': 15822493.0,
	'url_report_id': 511629659.0,
	'is_profile_page': False,
	'product_viewed': 'list',

这是一个捏造的日志文件,它可能类似于 Bookshop.org 本周畅销书的后端数据。这些是你从工程部门获取的事件,并且很可能存储在你的列式数据库中。对于这样的数据,使用 SQL 语法将是我们的入口点。

PySpark 提供了一个方便的 SQL API。基于你的基础设施,这个 API 将允许你编写看起来像对大规模数据集的 SQL 查询。

示例架构

这些示例数据库架构仅仅是对 Bookshop.org 可能使用的猜测,但它们是基于作者多年来查看多家公司数百个数据库架构经验的建模。此外,我们试图将这些模式提炼到与我们主题相关的组件。在实际系统中,你会期望有更多的复杂性,但同样的基本部分。每个数据仓库和事件流都有其独特的特点。请咨询你附近的数据工程师。

让我们使用 Spark 查询前面的日志:

user_item_view_counts_qry = """
SELECT
 page_views.authenticated_user_id
 , page_views.page_url_path
 , COUNT(DISTINCT page_views.page_view_id) AS count_views

FROM prod.page_views
JOIN prod.dim_users
 ON page_views.authenticated_user_id = dim_users.authenticated_user_id

WHERE DATE page_views.view_tstamp >= '2017-01-01'
 AND dim_users.country_code = 'US'

GROUP BY
 page_views.authenticated_user_id
 , page_views.page_url_path

ORDER BY 3, page_views.authenticated_user_id
"""

user_item_view_counts_sdf = spark.sql(user_item_view_counts_qry)

这是一个简单的 SQL 查询,假设前面的日志模式,它可以让我们看到每个用户-物品对被查看的次数。在这里纯粹使用 SQL 的便利性意味着我们可以利用我们在列式数据库上的经验快速上手 Spark。

然而,Spark 的主要优势尚未展现出来。在 Spark 会话中执行前述代码时,此查询不会立即运行。它将被准备好以执行,但 Spark 将等待直到您在下游使用此数据,并且需要立即执行时才开始执行。这被称为惰性评估,它允许您在不立即应用每个更改和交互的情况下操作数据对象。有关更多详细信息,请参阅像 Learning Spark 这样更深入的指南,由 Jules Damji 等人(O'Reilly)编写,但 Spark 范式的另一个重要特征值得讨论。

Spark 本质上是一种分布式计算语言。具体来说,这意味着即使在强制执行之后,前述查询仍将数据存储在多台计算机上。Spark 通过程序或笔记本中的驱动程序工作,该驱动程序驱动集群管理器,后者进一步协调工作节点上的执行程序。当我们使用 Spark 查询数据时,数据并非全部返回到我们使用的计算机上的内存中的 DataFrame 中,而是将部分数据发送到执行程序的内存中。当我们对 DataFrame 进行转换时,它将适用于存储在每个执行程序上的 DataFrame 片段。

如果这听起来有点像魔术,那是因为它在几个便利层后面隐藏了许多技术细节。Spark 是一种技术层,允许机器学习工程师编程时像在一台机器上工作一样,并使这些更改在整个机器群集上生效。在查询时理解网络结构并不重要,但如果出了问题,了解一些细节是很重要的;在故障排除时理解错误输出所指的内容至关重要。这一切都在 图 6-1 中总结,这是来自 Spark 文档 的一张图。

Sparkitecture

图 6-1. Spark 3.0 的组件架构

需要注意的是,这一切并非免费;惰性评估和分布式 DataFrame 需要在编写程序时额外思考。尽管 Spark 让许多工作变得更容易,但理解如何在这种范式中编写与体系结构兼容但仍能实现复杂目标的高效代码,可能需要一年的经验积累。

回到推荐系统,特别是离线收集器,我们希望使用 PySpark 构建训练模型所需的数据集。使用 PySpark 可以轻松将日志数据转换为训练模型所需的适当形式。在我们的简单查询中,我们对数据应用了一些过滤器,并按用户和项目分组以获取观看次数。许多其他任务可能自然而然地适合这种范例,例如添加存储在其他数据库中的用户或项目特征,或者进行高级聚合。

在我们的 MPIR 中,我们要求使用 get_item_popularities;我们有点假设了一些事情:

  • 这将返回每个项目被选择的次数。

  • 这种方法将会很快。

如果要实时调用终端节点,则第二点非常重要。那么 Spark 可能如何发挥作用呢?

首先,让我们假设我们有大量数据,足以使我们无法将其全部适应我们的小 MacBook Pro 的内存中。此外,让我们继续使用前面的架构。我们可以编写一个更简单的查询:

item_popularity_qry = """
SELECT
 page_views.page_url_path
 , COUNT(DISTINCT page_views.authenticated_user_id) AS count_viewers

FROM prod.page_views
JOIN prod.dim_users
 ON page_views.authenticated_user_id = dim_users.authenticated_user_id

WHERE DATE page_views.view_tstamp >= '2017-01-01'
 AND dim_users.country_code = 'US'

GROUP BY
 page_views.page_url_path

ORDER BY 2
"""

item_view_counts_sdf = spark.sql(item_popularity_qry)

现在,我们可以将这个聚合的(item, count)对列表写入应用程序数据库以提供get_item_popularities(当调用时不需要我们进行任何解析),或者我们可以获取此列表的前N个子集,并将其存储在内存中,以根据特定排名获取最佳项目。无论哪种方式,我们都已经将解析所有日志数据和进行聚合的任务与实时调用中的get_item_popularities函数调用分离开来。

此示例使用了一个过于简单的数据聚合,可以在诸如 PostgreSQL 等数据库中轻松完成,那么为什么还要费这个劲呢?第一个原因是可伸缩性。Spark 真的是为水平扩展而构建的,这意味着随着我们需要访问的数据增长,我们只需添加更多的工作节点。

第二个原因是 PySpark 不仅仅是 SparkSQL;任何完成复杂 SQL 查询的人都可能同意 SQL 的强大和灵活性是巨大的,但是经常需要在完全 SQL 环境中执行一些你想要的任务需要很多创造力。PySpark 为您提供了 pandas DataFrames、Python 函数和类的所有表现力,以及将 Python 代码应用于 PySpark 数据结构的用户定义函数(UDFs)的简单接口。UDFs 类似于您在 pandas 中使用的 lambda 函数,但它们是为 PySpark DataFrames 构建和优化的。正如您在较小的数据范围内编写 ML 程序时可能遇到的情况一样,有一天您会从仅使用 SQL 切换到使用 pandas API 函数执行数据转换,同样您将欣赏到在 Spark 数据规模上拥有的这种功能。

PySpark 允许您编写看起来非常像 Python 和 pandas 代码的代码,并以分布式方式执行该代码!您不需要编写代码来指定应在哪些工作节点执行操作;PySpark 会为您处理这些工作。这个框架并不完美;一些您期望能够正常工作的事情可能需要一些小心,而且对代码的优化可能需要额外的抽象级别,但总的来说,PySpark 为您提供了一种快速将代码从一个节点移动到一个集群并利用该能力的方法。

为了在 PySpark 中更实用地说明一些内容,让我们回到协同过滤(CF)并计算一些更适合排名的特征。

示例:PySpark 中的用户相似度

用户相似度表允许您将用户映射到与推荐系统相关的其他用户。这提醒了一个假设,即两个相似的用户喜欢相似的事物,因此您可以向这两个用户推荐其中一个尚未看过的项目。构建这个用户相似度表是一个 PySpark 作业的示例,您可能会在离线收集器的职责范围内看到。尽管在许多情况下,评分将继续不断流入,但为了大型离线作业的目的,我们通常考虑每日批处理以更新我们模型的基本表。实际上,在许多情况下,这种每日批处理作业足以提供足够好的特性,以满足大多数 ML 工作的需求。其他重要的范例存在,但这些范例通常将更频繁的更新与这些每日批处理作业结合起来,而不是完全消除它们。

这种每日批处理作业与更小、更频繁的批处理作业的架构称为lambda 架构,我们将在稍后更详细地讨论如何以及为什么这样做。简言之,这两个层次——批处理和速度——通过它们处理数据的处理频率和每次运行的数据量(反向)进行区分。请注意,速度层可能具有与之关联的不同频率,并且可能会有不同的速度层,用于执行不同操作的小时和分钟频率作业。图 6-2 概述了架构。

LambdaArchitecture

图 6-2. Lambda 架构概览

在用户相似度的情况下,让我们着手实现一个计算每日表格的批处理作业。首先,我们需要从昨天之前的架构中获取评分。我们还将包括一些其他模拟这个查询在现实生活中可能看起来如何的过滤器:

user_item_ratings_qry = """
SELECT
 book_ratings.book_id
 book_ratings.user_id
 , book_ratings.rating_value
 , book_ratings.rating_tstamp

FROM prod.book_ratings
JOIN prod.dim_users
 ON book_ratings.user_id = dim_users.user_id
JOIN prod.dim_books
 ON book_ratings.book_id = dim_books.dim_books

WHERE
 DATE book_ratings.rating_tstamp
 BETWEEN (DATE '2017-01-01')
 AND (CAST(current_timestamp() as DATE)
 AND book_ratings.rating_value IS NOT NULL
 AND dim_users.country_code = 'US'
 AND dim_books.book_active
"""

user_item_ratings_sdf = spark.sql(user_item_ratings_qry)

与以前一样,利用 SQL 语法将数据集导入 Spark DataFrame 是第一步,但现在我们在 PySpark 方面有更多的工作。一个常见的模式是通过简单的 SQL 语法和逻辑获取要处理的数据集,然后使用 PySpark API 进行更详细的数据处理。

首先,让我们观察一下,我们对用户-项目评分的唯一性没有任何假设。为了这个表格,让我们决定使用最近的评分对:

from pyspark.sql.window import Window

windows = Window().partitionBy(
	['book_id', 'user_id']
).orderBy(
	col("rating_tstamp").desc()
)

user_item_ratings_sdf.withColumn(
	"current_rating",
	first(
		user_item_ratings_sdf("rating_tstamp")
	).over(windows).as("max_rating_tstamp")
).filter("rating_tstamp = max_rating_tstamp")

现在,我们将使用current_rating作为我们的评分列,用于下游计算。回顾之前我们基于评分定义的用户相似度:

USim A,B = ∑ x∈ℛ A,B (r A,x -r ¯ A )(r B,x -r ¯ B ) ∑ x∈ℛ A,B (r A,x -r ¯ A ) 2 ∑ x∈ℛ A,B (r B,x -r ¯ B ) 2

我们需要的重要值如下:

r (-,-)

用户-项目对应的评分

r ¯ (-)

用户的所有项目的平均评分

行已经是r (-,-)值,所以让我们计算用户平均评分r ¯ (-)和评分偏差:

from pyspark.sql.window import Window
from pyspark.sql import functions as F

user_partition = Window.partitionBy('user_id')

user_item_ratings_sdf = user_item_ratings_sdf.withColumn(
	"user_average_rating",
	F.avg("current_rating").over(user_partition)
)

user_item_ratings_sdf = user_item_ratings_sdf.withColumn(
	"rating_deviation_from_user_mean",
	F.col("current_rating") - F.col("user_average_rating")
)

现在我们的模式应该如下所示(我们对其进行了比默认 Spark 输出稍微优化的格式化):

+-------+-------+------------+-------------+
|book_id|user_id|rating_value|rating_tstamp|
+-------+-------+------------+-------------+
+-------------+-------------------+-------------------------------+
current_rating|user_average_rating|rating_deviation_from_user_mean|
+-------------+-------------------+-------------------------------+

让我们完成创建一个包含我们用户相似度计算的数据集:

user_pair_item_rating_deviations = user_item_ratings_sdf.alias("left_ratings")
.join(user_item_ratings_sdf.alias("right_ratings"),
  (
F.col("left_ratings.book_id") == F.col("right_ratings.book_id") &\
F.col("left_ratings.user_id") != F.col("right_ratings.user_id")
	),
	"inner"
).select(
	F.col("left_ratings.book_id"),
	F.col("left_ratings.user_id").alias("user_id_1"),
	F.col("right_ratings.user_id").alias("user_id_2"),
  F.col("left_ratings.rating_deviation_from_user_mean").alias("dev_1"),
  F.col("right_ratings.rating_deviation_from_user_mean").alias("dev_2")
).withColumn(
	'dev_product',
	F.col("dev_1")*F.col("dev_2")
)

user_similarities_sdf = user_pair_item_rating_deviations.groupBy(
	"user_id_1", "user_id_2"
).agg(
	sum('dev_product').alias("dev_product_sum"),
	sum(F.pow(F.col("dev_1"),2)).alias("sum_of_sqrd_devs_1"),
	sum(F.pow(F.col("dev_2"),2)).alias("sum_of_sqrd_devs_2")
).withColumn(
	"user_similarity",
	(
		F.col("dev_product_sum") / (
			F.sqrt(F.col("sum_of_sqrd_devs_2")) *
			F.sqrt(F.col("sum_of_sqrd_devs_2"))
		)
	)
)

在构建这个数据集时,我们首先进行自连接,避免将相同用户与自身匹配,而是根据匹配的书籍进行连接。在进行此连接时,我们使用之前计算得出的用户平均评分偏差值。同时,我们利用这个机会将它们相乘,作为用户相似度函数中的分子。在最后一步,我们再次使用groupBy,以便对所有匹配的书籍 ID(通过对user_id_1user_id_2进行groupBy)进行求和;我们对每组偏差值的产品和幂进行求和,以便最终进行除法,并生成新的用户相似度列。

尽管这个计算并不特别复杂,但让我们注意一些我们可能会欣赏的事情。首先,我们从记录中完整构建了用户相似度矩阵。现在可以将这个矩阵存储在更快的访问格式中,因此如果我们希望进行实时操作,它已经准备好了。其次,我们在 Spark 中完成了所有这些数据转换,因此可以在大数据集上运行这些操作,并让 Spark 处理并行化到集群上。我们甚至能够编写类似于 pandas 和 SQL 的代码。最后,所有我们的操作都是基于列的,不需要迭代计算。这意味着这段代码将比某些其他方法更好地扩展。这还确保了 Spark 能够很好地并行化我们的代码,并且我们可以期望良好的性能。

我们已经看到了 PySpark 如何用于准备我们的用户相似度矩阵。我们有这样一个定义的亲和力,用于评估一个项目对于用户的适宜性;我们可以将这些分数收集到一个表格中——用户行和项目列——以生成一个矩阵。作为一项练习,你能否拿这个矩阵并生成亲和力矩阵?

Aff A,i = r ¯ A + ∑ U∈𝒩(A) USim A,U *(r U,i -r ¯ A ) ∑ U∈𝒩(A) USim A,U

请随意假设𝒩 ( A )只是关于用户相似性的A的五个最近邻。

数据加载器

数据加载器是源自 PyTorch 的编程范式,但已经在其他梯度优化的 ML 工作流中得到了应用。随着我们开始将基于梯度的学习整合到我们的推荐系统架构中,我们将在 MLOps 工具中面临挑战。第一个与训练数据大小和可用内存有关。数据加载器是一种指定数据如何被批处理并有效地发送到训练循环中的方式;随着数据集变大,这些训练集的谨慎调度可能会对学习产生重大影响。但为什么我们必须考虑数据的批次?那是因为我们将使用一种适用于大量数据的梯度下降的变体。

首先,让我们回顾一下小批量梯度下降的基础知识。在通过梯度下降进行训练期间,我们对训练样本进行前向传播,得出预测结果,然后通过我们的模型计算错误和适当的梯度向后传播以更新参数。批量梯度下降在单次传递中获取所有数据以计算训练集的梯度并将其传回;这意味着您在内存中拥有整个训练数据集。随着数据集的扩大,这从昂贵到不可能;为了避免这种情况,我们可以只计算一次数据集的一部分的损失函数的梯度。这种最简单的范式称为随机梯度下降(SGD),它逐个样本计算这些梯度和参数更新。小批量版本执行我们的批量梯度下降,但是在一系列子集上进行,以形成数据集的分区。在数学表示中,我们根据较小的批次编写更新规则:

θ = θ - η * ∇ θ J θ ; x (i:i+n) ; y (i:i+n)

这种优化有几个目的。首先,在这些步骤期间,它仅需要可能很小的数据子集保存在内存中。其次,它比 SGD 中的纯迭代版本需要的传递要少得多。第三,对这些小批量的梯度操作可以组织为雅可比矩阵,因此我们有可能高度优化的线性代数运算。

雅可比矩阵

数学概念中的雅可比矩阵最简单的意义是一种用于一组具有相关索引的向量导数的组织工具。您可能还记得,对于多个变量的函数,您可以相对于每个变量进行导数计算。对于单个多变量标量函数,雅可比矩阵简单地是该函数的一阶导数的行向量——恰好是梯度的转置。

这是最简单的情况;多变量标量函数的梯度可以写成雅可比矩阵。然而,一旦我们有了(向量)导数的向量,我们可以将其写成矩阵;这里的实用性实际上只在于符号,虽然如此。当您将一系列多变量标量函数收集成函数向量时,相关的梯度向量是导数的向量。这称为 雅可比矩阵,它将梯度推广到矢量值函数。正如您可能已经意识到的那样,神经网络层是希望进行导数推导的矢量值函数的绝佳来源。

如果您确信小批量很有用,现在是讨论 DataLoaders 的时候了——这是一个简单的 PyTorch API,用于从大型数据集中提取小批量。DataLoader 的关键参数包括 batch_sizeshufflenum_workers。批次大小很容易理解:每个批次中包含的样本数量(通常是数据集总大小的整数因子)。通常会对这些批次应用随机顺序操作;这旨在提高模型的稳健性。最后,num_workers 是用于 CPU 批次生成的并行化参数。

DataLoader 的效用最好通过演示来理解:

params = {
         'batch_size': _,
         'shuffle': _,
         'num_workers': _
}

training_generator = torch.utils.data.DataLoader(training_set, params)

validation_generator = torch.utils.data.DataLoader(validation_set, params)

// Loop over epochs
for epoch in range(max_epochs):
    // Training
    for local_batch, local_labels in training_generator:

        // Model computations
        [...]

    // Validation
    with torch.set_grad_enabled(False):
        for local_batch, local_labels in validation_generator:

            // Model computations
            [...]

代码中的第一个重要细节是,它的任何生成器都将从您的总数据集中读取小批量,并可以指示并行加载这些批次。还要注意,模型计算中的任何差分步骤现在将在这些小批量上运行。

容易认为 DataLoaders 只是用于提高代码清洁度的工具(诚然,它确实改进了),但重要的是不要低估批次顺序、并行化和形状控制对模型训练的重要性。最后,您的代码结构现在看起来像是批量梯度下降,但它利用了小批量,进一步暴露了代码实际执行的内容而非所需步骤。

数据库快照

让我们通过远离这些高级技术来结束这一部分,讨论一些重要而经典的事情:对生产数据库进行快照。

极有可能的情况是,建立推荐服务器的工程师们(可能还包括您)正在将其日志和其他应用程序数据写入 SQL 数据库。很可能,这种数据库架构和部署是针对应用程序跨其最常见使用情况进行快速查询进行了优化。正如我们讨论过的那样,这些日志可能处于事件样式架构中,还有其他可能需要聚合和汇总以得出任何意义的表格。例如,当前库存 表可能需要了解每天开始的库存,然后聚合一系列购买事件列表。

总的来说,生产 SQL 数据库通常是针对特定用途设计的堆栈中的关键组件。作为这些数据的下游消费者,您可能希望拥有不同的模式,对这些数据库有大量的访问,并对这些数据执行重要的操作。最常见的范式是数据库快照。快照是由各种 SQL 版本提供的功能,用于高效地创建数据库的克隆。虽然快照可以采用各种形式,但让我们专注于一些简化系统并确保其具备所需数据的方式:

  • 每日表快照可能与as_of字段相关联,或者这个表在这一天的状态

  • 每日表快照可能受时间限制,只能查看今天新增了哪些记录

  • 事件表快照可用于将一组事件馈送到像 Segment 这样的事件流处理器(请注意,您也可以设置像 Kafka 这样的实时事件流)。

  • 每小时汇总的表可用于状态记录或监视。

一般而言,范式通常是操作下游数据处理的快照。我们前面提到的许多数据处理种类,如计算用户相似性,都是可能需要大量数据读取的操作。重要的是不要构建需要在生产数据库上进行大量查询的 ML 应用程序,因为这样做可能会降低应用程序的性能,并导致用户体验变慢。这种降低将会损害推荐系统可能实现的改进。

一旦您对感兴趣的表进行了快照,通常可以找到一系列数据管道,有助于将数据转换为更具体的表格,存放在数据仓库中(您应该在这里完成大部分工作)。像 Dagster、dbt、Apache Airflow、Argo 和 Luigi 这样的工具是流行的数据管道和工作流编排工具,用于提取、转换和加载(ETL)操作。

用于学习和推断的数据结构

本节介绍了三种重要的数据结构,这些结构将使我们的推荐系统能够快速执行复杂操作。每种结构的目标是尽可能少地牺牲精度,同时加速对实时数据的访问。正如您将看到的,这些数据结构构成了实时推断管道的核心,并尽可能精确地近似了批处理管道的运行过程。

这三种数据结构如下:

  • 向量搜索/ANN 索引

  • 布隆过滤器用于候选过滤

  • 特征存储

到目前为止,我们已经讨论了在系统中使数据流动所必需的组件。这些帮助组织数据,使其在学习和推断过程中更易于访问。此外,我们还将找到一些快捷方式来加速检索过程中的推断。向量搜索将允许我们在规模上识别相似的项目。布隆过滤器将允许我们快速评估许多排除结果的标准。特征存储将为我们提供有关用户的推荐推断所需的必要数据。

向量搜索

就理解这些实体之间的关系而言,我们已经讨论了用户相似性和项目相似性,但我们还没有谈论这些过程的任何加速结构

首先让我们讨论一些术语;如果我们将表示具有由距离函数提供的相似度度量的实体的向量集合视为一个潜空间。简单的目标是利用我们的潜空间及其相关的相似度度量(或补充距离度量),以便能够快速检索相似的项目。在我们以前的相似性示例中,我们谈到了用户邻域及其如何被利用来建立用户与未见项目之间的亲和分数。但是你如何找到这个邻域呢?

要理解这一点,请回想一下我们定义了一个元素x的邻域,写成𝒩 ( x ),作为潜空间中与最大相似度的k个元素的集合;或者换句话说,项目相似性样本的j阶统计的集合小于或等于k。这些*k -最近邻居*,通常被称为,将用作被视为与x相似的元素集合。

从 CF 得出的这些向量还产生了一些其他有用的副作用:

  • 一个简单的推荐系统,从用户邻域的喜欢项目中随机抽取未见项目

  • 关于用户特征的预测,从邻域中已知用户的已知特征

  • 通过口味相似性进行用户分割

那么我们如何加速这些过程呢?这个领域的第一个重大改进之一来自倒排索引。利用倒排索引的核心是在查询的标记之间(用于基于文本的搜索)和候选项之间谨慎地构建一个大哈希。

这种方法非常适合像句子或小词典集合这样的可标记化实体。由于能够查找与查询共享一个或多个标记的项目,您甚至可以使用一般的潜在嵌入来按相似性对候选响应进行排名。随着规模化,这种方法值得额外考虑:它会产生速度成本,因为它需要两个步骤,并且因为相似性分布可能与返回比我们需要的更多候选者所需的标记相似性不相关。

构建搜索系统的经典方法基于大型查找表,并具有确定性的感觉。随着我们转向 ANN 查找,我们希望放松一些强大的确定性行为,并引入能够“修剪”这些大索引的数据结构的假设。与仅为元素的可标记化组件构建索引不同,您可以预先计算k-d 树并使用索引作为索引。 k-d 树将以批处理过程预计算最近邻居(这可能很慢),以填充用于快速查找的前k个响应。 k-d 树是一种有效的数据结构,用于编码前述邻域,但在更高维度中读取时已众所周知较慢。然而,使用它们来构建倒排索引可以带来很大的改进。

最近,明确使用向量数据库进行向量搜索变得越来越可能和可行。Elasticsearch 已添加了这一功能;Faiss是一个 Python 库,可以帮助您在系统中实现此功能;Pinecone是一种专门针对此目标的向量数据库系统;而Weaviate是一种本地向量数据库架构,允许您在前述基于标记的倒排索引和向量相似性搜索之上构建层。

近似最近邻

这个元素的k个最近邻是什么?令人惊讶的是,近似最近邻(ANN)与实际最近邻相比可以达到非常高的准确性,并且通过令人眼花缭乱的加速技术更快地到达那里。对于这些问题,您经常对近似解决方案感到满意。

专门处理这些近似值的开源库之一是PyNNDescent,它通过优化的实现和精心设计的数学技巧实现了巧妙的加速。使用 ANN,您可以采用如下讨论的两种策略:

  • 可以显著改善预索引。

  • 在没有预先索引选项的查询中,您仍可以期望良好的性能。

在实践中,这些相似性查找对于使您的应用程序实际工作非常重要。虽然我们大多数时候都在讨论已知项目目录的推荐,但我们不能在其他推荐上下文中做出这种假设。这些上下文包括以下内容:

  • 基于查询的推荐(如搜索)

  • 上下文推荐

  • 冷启动新项目

当我们深入研究相似性和最近邻时,你会越来越多地看到这些参考,每当这些时刻到来时,请想一想:“我知道如何使这个过程变得快速!”

布隆过滤器

布隆过滤器是一种概率数据结构,允许我们非常高效地测试集合的包含性,但有一个缺点:集合的排除是确定的,但集合的包含是概率性的。实际上,这意味着询问“x是否在这个集合中”永远不会产生假阴性,但可能会产生假阳性! 请注意,这种类型-I 错误随着布隆大小的增加而增加。

通过向量搜索,我们已经确定了用户的大量潜在推荐项目。从这些项目中,我们需要立即进行一些排除。最明显的高级过滤类型是删除用户以前没有显示兴趣或已经购买的商品。你可能经历过被反复推荐同一件商品的经历,然后想:“我不想要这个,请别再给我看了。”通过我们介绍的简单协同过滤模型,你现在可能会明白为什么会出现这种情况。

系统已经通过 CF 识别出了一组更有可能被你选择的项目。在没有任何外部影响的情况下,这些计算将继续返回相同的结果,你将永远无法摆脱这些推荐。作为系统设计师,你可以从一个启发式开始:

如果用户看到这个推荐的商品三次都没有点击,我们就不再向他们展示了。

这是一种完全合理的策略,旨在改善你推荐系统中的新鲜度(确保用户看到新项目推荐的想法)。虽然这是改善推荐的简单策略,但你如何在规模上实现它呢?

通过定义以下的集合,可以使用布隆过滤器:“这个用户看到这个推荐的商品三次但从未点击过吗?”布隆过滤器的一个注意事项是它们仅能进行添加操作:一旦某物被加入布隆中,就无法将其移除。当观察这种启发式的二进制状态时,这并不是一个问题。

让我们构建一个用户-商品 ID,用作我们在布隆中的哈希。请记住,布隆过滤器的关键特性是快速确定哈希后的项目是否在布隆中。当我们观察到满足上述标准的用户-商品对时,请将该对作为 ID 并进行哈希。现在,由于可以从用户的项目列表中轻松重建这个哈希对,我们有了一种非常快速的过滤方式。

让我们讨论一下这个话题的一些技术细节。首先,你可能希望进行各种类型的过滤 —— 也许新鲜度是其中之一,另一个可能是用户已经购买的商品,第三个可能是排除已售罄的商品。

在这里,独立实施每个过滤器将是很好的;前两个可以像以前一样遵循我们的用户-商品 ID 哈希,第三个可以仅对商品 ID 进行哈希。

另一个考虑因素是填充布隆过滤器。最佳实践是在离线批处理作业期间从数据库构建这些布隆过滤器。无论您的批处理训练按什么时间表运行,都要从记录存储重新构建布隆过滤器,以确保保持布隆过滤器的准确性。请记住,布隆过滤器不允许删除,因此在前面的示例中,如果某个项目从售罄状态变为补货状态,您的批量刷新操作可以重新捕捉其可用性。在批量重新训练之间,向布隆过滤器添加内容也非常高效,因此您可以在实时过滤需要考虑的更多数据时继续向布隆过滤器添加内容。但务必将这些事务记录到表中!当您想要刷新时,这些日志记录将非常重要。

有趣的一点:布隆过滤器作为推荐系统

布隆过滤器不仅提供了一种有效的方式来根据包含条件消除某些推荐,而且还可以用来执行推荐本身!特别是,Manuel Pozo 等人在 “An Item/User Representation for Recommender Systems Based on Bloom Filters” 中指出,对于具有高维特征集和大量稀疏性的推荐系统(正如我们在 第三章 中讨论的那样),布隆过滤器所做的哈希类型可以帮助克服定义良好相似性函数的关键挑战!

让我们观察到,通过布隆过滤器数据结构,我们可以对集合执行两种自然操作。首先,考虑两个集合 A 和 B,并为它们关联布隆过滤器 ℬℱ A 和 ℬℱ B。那么 A ∩ B 的定义是什么?我们能为这个交集设计一个布隆过滤器吗?当然可以!回想一下,我们的布隆过滤器保证能告诉我们元素不在集合中,但如果元素在集合中,则布隆过滤器只能以一定的概率回应。在这种情况下,我们只需查找根据 ℬℱ A 和 ℬℱ B 声明为“在”的元素。当然,每个集合返回的“在”元素集合都比实际集合更大(即 A ⊂ ℬℱ A ),因此交集也会更大。

A ∩ B ⊂ ℬℱ A ∩ ℬℱ B

请注意,您可以通过有关哈希函数选择的信息计算基数的确切差异。还请注意,方程式是滥用符号的,通过将ℬℱ A称为对应于A的布隆过滤器返回的事物集。

其次,我们还需要构建联合。通过考虑根据ℬℱ A OR in根据ℬℱ B。因此,类似地:

A ∪ B ⊂ ℬℱ A ∪ ℬℱ B

现在,如果我们将项目X和Y作为可能具有许多特征的串联向量,并对这些串联特征进行哈希处理,我们将它们表示为我们的布隆的位向量。我们之前看到两个布隆的交集是有意义的,事实上等价于它们的布隆表示的位AND。这意味着两个项目的特征相似性可以通过它们的布隆哈希的位and相似性来表示:

sim ( X , Y ) = | ℬℱ ( X ) ∩ ℬℱ ( Y ) | = ℬℱ ( X ) * bitwise ℬℱ ( X )

对于静态数据集,这种方法具有真正的优势,包括速度、可伸缩性和性能。限制因素基于各种特征以及改变可能项目集的能力。稍后我们将讨论局部敏感哈希,它进一步迭代查找速度,并降低在高维空间中碰撞风险,一些类似的想法也将重新出现。

特征存储

到目前为止,我们专注于我们可能称为纯协同过滤的推荐系统。我们只在试图做出好的推荐时使用了用户或项目相似性数据。如果您一直在想,“嘿,实际用户和项目的信息呢?”您现在将满足您的好奇心。

除了以前的 CF 方法外,您可能对特征感兴趣的原因有很多。让我们列举一些高级别的关注点:

  • 您可能希望首先向新用户展示特定的一组项目。

  • 您可能希望在推荐中考虑地理边界。

  • 区分儿童和成年人可能对给予的推荐类型很重要。

  • 项目特征可用于确保推荐中的高级别多样性(更多内容请参见第十五章)。

  • 用户特征可以启用各种类型的实验测试。

  • 项目特征可用于将项目分组为上下文推荐的集合(更多内容请参见第十五章)。

除了这些问题,另一种重要的特征通常是必不可少的:实时特征。虽然特征存储的目的是提供对所有必要特征的实时访问,但值得注意的是要区分那些变化不频繁的稳定特征和我们预计会经常变化的实时特征。

一些实时特征存储的重要示例包括动态价格、当前商品可用性、趋势状态、愿望清单状态等。这些特征可能会在一天之中发生变化,我们希望通过其他服务和系统的实时方式对特征存储中的值进行可变更。因此,实时特征存储将需要提供 API 访问以进行特征的变更。这是你可能不想为稳定特征提供的功能。

当我们设计我们的特征存储时,我们可能希望稳定的特征通过数据仓库表格通过 ETL 和转换构建,并且我们可能也希望实时特征以相同的方式构建,但是在更快的时间表上或允许 API 访问进行变更。无论哪种情况,特征存储的关键质量是非常快的读取访问。通常建议为离线模型训练单独构建特征存储,以便在测试中构建以确保支持新模型。

那么架构和实施可能是什么样子呢?参见图 6-3。

特征存储

图 6-3. 特征存储的演示

设计特征存储涉及设计管道,定义并将特征转换到该存储中(通过像 Airflow、Luigi、Argo 等协调的方式),并且通常看起来类似于构建我们收集器使用的数据管道类型。特征存储需要考虑的一个额外复杂因素是速度层。在本章早些时候讨论的 Lambda 架构中,我们提到可以将批量数据处理用于收集器,并为中间更新提供更快的速度层,但是对于特征存储来说,这更为重要。特征存储可能还需要一个流处理层。这一层操作于连续的数据流,并可以对这些数据进行数据转换;然后实时将适当的输出写入在线特征存储。这增加了复杂性,因为流数据的数据转换提出了一组非常不同的挑战,并且通常需要不同的算法策略。在这方面有帮助的一些技术包括 Spark Streaming 和 Kinesis。您还需要配置系统以正确处理数据流,其中最常见的是 Kafka。数据流处理层涉及许多组件和架构考虑因素,这超出了我们的范围;如果您考虑开始使用 Kafka,请查看Kafka:权威指南(Gwen Shapira 等人编著,O’Reilly)。

特征存储还需要一个存储层;这里有许多方法,但在在线特征存储中使用 NoSQL 数据库是常见的。原因是检索更快和数据存储的性质。推荐系统的特征存储往往是非常基于键的(例如,获取此用户的特征获取此项的特征),这很适合键值存储。这里的一些示例技术包括 DynamoDB、Redis 和 Cassandra。离线特征存储的存储层可能只是 SQL 样式数据库,以减少复杂性,但这样做会在离线和在线之间产生差异。这种差异和其他类似的称为训练-服务偏差

特征存储的一个独特但至关重要的方面是注册表。注册表对于特征存储非常有用,因为它协调了现有特征及其定义方式的信息。一个更复杂的注册表实例还包括输入和输出的模式和类型,并且有分布预期。这些是数据管道必须遵守和满足的合同,以避免在特征存储中填充垃圾数据。此外,注册表的定义允许并行的数据科学家和 ML 工程师开发新特征,共享彼此的特征,并通常了解模型可能使用的特征假设。

这些注册表的一个重要优势是它们激励团队和开发人员之间的对齐。特别是,如果你决定关注用户的国家,并在注册表中看到一个名为国家的特征,你更有可能使用它(或者询问负责此特征的开发人员),而不是从头开始创建一个新的。实际上,数据科学家在定义模型时会做出数百个小决策和假设,这减轻了依赖现有资源的负担。

模型注册表

与特征注册表密切相关的概念是模型注册表。这些概念有很多共同点,但我们提醒您以不同的方式思考它们。一个优秀的模型注册表可以对模型的输入和输出有类型合同,并且可以提供与对齐和清晰度相关的许多相同的好处。特征注册表应该真正关注业务逻辑和特征的定义。因为特征工程也可以是模型驱动的,清楚地表达这两者之间的差异可能是具有挑战性的,因此总结起来,我们将专注于它们的作用:模型注册表关注于 ML 模型和相关的元数据,而特征注册表关注于模型将使用的特征。

最后,我们需要讨论如何提供这些特征。通过合适高效的存储层支持,我们需要通过 API 请求提供必要的特征向量。这些特征向量包括模型在提供推荐时需要的用户详细信息,例如用户的位置或内容的年龄限制。API 可以返回指定键的全部特征集,或者更具体的规定。通常,响应以 JSON 序列化形式进行快速数据传输。重要的是,所提供的特征应是最新的特征集,并且此处的延迟预计应小于 100 毫秒,以满足更严肃的工业应用需求。

在离线训练中的一个重要注意事项是,这些特征存储需要适应时间旅行。因为在训练期间,我们的目标是以最具普遍性的方式提供模型所需的数据,因此在训练模型时,关键是不让其访问超出时间范围的特征。这被称为数据泄漏,可能导致训练和生产中性能出现严重偏差。因此,离线训练的特征存储必须具备对历史时间段内特征的知识,以便在训练期间提供时间索引,以获取那时的特征。这些as_of键可以与历史训练数据关联,就像重放用户-项目交互历史一样。

有了这些基础设施,并伴随着这个系统所需的重要监控,您将能够向模型提供离线和在线特征。在第三部分,您将看到利用这些特征的模型架构。

总结

我们不仅讨论了为了滋养您的系统和提供推荐所必需的关键组件,还探讨了使这些组件成为现实所需的一些工程基础构件。配备了数据加载器、嵌入、特征存储和检索机制,我们已准备好开始构建我们的管道和系统拓扑。

在接下来的章节中,我们将把目光集中在 MLOps 上,以及构建和迭代这些系统所需的其他工程工作。对我们来说,认真考虑部署和监控是至关重要的,以便我们的推荐系统能在 IPython 笔记本中运行。

继续向前看,了解到向生产环境迁移的架构考虑因素。