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

65 阅读1小时+

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:服务模型和架构

当我们考虑推荐系统如何利用可用数据进行学习并最终提供推荐时,描述这些部件如何配合是至关重要的。数据流和共同可用的数据组合用于学习的组合被称为架构。更正式地说,架构是系统或服务网络的连接和互动;对于数据应用程序,架构还包括每个子系统的可用特征和目标函数。定义架构通常涉及识别组件或个别服务,定义这些组件之间的关系和依赖关系,并指定它们将通过的协议或接口。

在本章中,我们将详细阐述一些最流行和最重要的推荐系统架构。

建议结构的架构

我们已经多次提到收集器、排名器和服务器的概念,并且我们看到它们可以通过在线和离线两种模式来看待。此外,我们还看到了第六章中的许多组件如何满足这些功能的核心要求。

设计这样的大型系统需要考虑几个架构因素。在本节中,我们将演示如何根据您正在构建的推荐系统类型来调整这些概念。我们将比较大部分标准的物品对用户推荐系统,基于查询的推荐系统,基于上下文的推荐和基于序列的推荐。

物品对用户的推荐

我们将从描述迄今为止在本书中建立的系统的架构开始。正如在第四章中提出的那样,我们离线构建了收集器以摄取和处理我们的推荐。我们利用表示来编码物品、用户或用户-物品对之间的关系。

在线收集器接受请求,通常以用户 ID 的形式,并在表示空间中找到物品邻域以传递给排名器。适当时会过滤这些物品,并发送进行评分。

离线排名器通过训练历史数据来学习得分和排名所需的相关特征。然后,它使用这个模型,并且在某些情况下还使用物品特征进行推断。

在推荐系统的情况下,此推断计算与潜在推荐集中每个物品相关联的分数。我们通常按此分数排序,关于这一点,您将在第 III 部分中了解更多。最后,我们基于某些业务逻辑(在第十四章中描述)整合最后一轮排序。这最后一步是服务的一部分,我们在此强加要求,如测试标准或推荐多样性要求。

图 7-1 是检索、排序和提供结构的极好概述,尽管它描绘了四个阶段并使用了稍微不同的术语。在本书中,我们将此处显示的过滤阶段与检索结合在一起。

基于查询的推荐

要开始我们的过程,我们想提出一个查询。最明显的查询示例是文本查询,如基于文本的搜索引擎;但是,查询可能更加普遍!例如,您可能希望允许按图像搜索或按标签搜索的选项。请注意,一个重要类型的基于查询的推荐器使用隐式查询:用户通过 UI 选择或行为提供搜索查询。虽然这些系统在整体结构上与项目到用户系统非常相似,但让我们发现如何修改它们以适应我们的用例。

四阶段推荐系统

图 7-1。一个四阶段推荐系统(根据 Karl Higley 和 Even Oldridge 的图片调整)

我们希望将有关查询的更多上下文整合到请求的第一步中。请注意,我们不想丢弃此系统的用户-项目匹配组件。即使用户正在执行搜索,根据他们的口味个性化推荐也是有用的。相反,我们也需要利用查询;稍后我们将讨论各种技术策略,但现在简单总结一下也是生成查询嵌入的一种方法。请注意,查询类似于项目或用户,但有足够的不同。

一些策略可能包括查询与项目之间的相似性,或者查询与项目的共现。无论哪种方式,我们现在都有了一个查询表示和用户表示,我们希望利用两者进行推荐。一种简单的方法是使用查询表示进行检索,但在评分过程中,通过查询-项目和用户-项目的得分,通过多目标损失将它们结合起来。另一种方法是使用用户进行检索,然后使用查询进行过滤。

不同的嵌入

不幸的是,尽管我们很希望相同的嵌入空间(用于最近邻查找)对我们的查询和我们的文档(项目等)都能很好地工作,但情况通常并非如此。最简单的例子是类似于提问并希望找到相关维基百科文章的情况。这个问题通常被称为查询与文档“不在分布”中。

维基百科文章采用陈述性信息文章风格,而问题通常简短随意。如果您使用一个专注于捕捉语义意义的嵌入模型,您可能会天真地期望查询位于与文章显著不同的子空间中。这意味着您的距离计算将受到影响。这通常是一个巨大问题,因为您通过相对距离检索,并且您可以希望共享的子空间足以提供良好的检索。然而,这种情况下的表现不佳很难预测。

最佳实践是仔细检查常见查询和目标结果上的嵌入。这些问题在像特定时间采取的一系列行动这样的隐式查询上可能特别糟糕。在这种情况下,我们预计查询与文档大相径庭。

基于上下文的推荐

上下文(Context)与查询相似,但倾向于更明显地基于特征,并且通常与项目/用户分布不太相似。上下文通常用于表示系统外的特征,可能对系统产生影响——例如时间、天气或位置等辅助信息。基于上下文的推荐与基于查询的推荐类似,上下文是系统在推荐过程中需要考虑的额外信号,但往往查询应该主导推荐的信号,而上下文则不应该。

让我们举一个简单的订餐的例子。一个食品配送推荐系统的查询可能看起来像墨西哥食品;这是用户寻找墨西哥卷饼或奎萨迪亚的极为重要的信号,决定了推荐的外观。一个食品配送推荐系统的上下文可能看起来像快到午餐时间了。这个信号很有用,但可能不如用户个性化的重要。在这种权重上设定硬性规则可能很困难,通常我们不这样做,而是通过实验学习参数。

上下文特征与体系结构类似于查询的方式,通过学习的加权作为目标函数的一部分。您的模型将学习上下文特征与项目之间的关系,然后将该亲和力添加到其余流程中。同样,您可以在检索早期使用它,在排名后期使用它,甚至在服务步骤中使用它。

基于序列的推荐

基于序列的推荐 是在基于上下文的推荐基础上构建的,但具有特定类型的上下文。序列推荐基于这样一个思想:用户最近接触的项目应显著影响推荐。一个常见的例子是音乐流媒体服务,因为最近播放的几首歌可以显著地影响用户可能想要听的下一首歌曲。为了确保这种自回归或序列预测的特征对推荐有影响,我们可以将序列中的每个项目视为推荐的加权上下文。

通常,项目-项目的表示相似性会加权以提供一系列推荐,并使用各种策略来组合这些推荐。在这种情况下,我们通常期望用户在推荐中非常重要,但序列也非常重要。一个简单的模型是将项目序列视为标记序列,并形成该序列的单一嵌入,就像在 NLP 应用中一样。这种嵌入可以作为上下文基础推荐架构中的上下文使用。

天真的序列嵌入

对于一序列每一次嵌入的组合学,其基数会爆炸式增长;每个连续位置的潜在项数非常大,并且序列中的每一项都会将这些可能性相乘。例如,考虑五个单词的序列,每个项目的可能性接近英语词汇量的大小,因此总数将达到五次方。我们在第十七章中提供了处理此类情况的简单策略。

为什么要关心额外的特征?

有时候退一步思考一个新技术是否真的值得关注是很有用的。到目前为止,在本节中,我们介绍了四种思考推荐问题的新范式。这种详细程度可能令人惊讶,甚至可能显得不必要。

像上下文和查询导向的推荐这样的事物之所以变得相关,是因为要处理之前提到的一些关于稀疏性和冷启动的问题。稀疏性使得那些本不算冷的事物因为学习者对其低频率的暴露而看起来冷,但真正的冷启动也因新项目在大多数应用中高频添加到目录中而存在。我们将详细讨论冷启动,但现在可以说,对于暖启动的一种策略是利用即使在这种情况下也可用的其他特征。

在显式基于特征的 ML 应用中,我们很少会如此严重地遇到冷启动问题,因为在推断时,我们相信用于预测的模型参数与可用的特征是良好对齐的。通过这种方式,包含特征的推荐系统可以从一个潜在较弱的学习者引导启动,通过始终可用的特征保证更可靠的性能。

前面架构反映的第二个类比是增强学习。增强学习模型通过观察到弱学习者的集合可以达到更好的性能。在这里,我们要求一些额外的特征来帮助这些网络与弱学习者合奏,以提高它们的性能。

编码器架构和冷启动

各种类型的推荐问题的前期问题框架指出了四种模型架构,每种都适合我们的收集器、排序器和服务器的一般框架。在了解了这一点之后,让我们稍微详细地讨论一下模型架构如何与服务架构交织在一起。特别是,我们还需要讨论特征编码器。

编码器增强系统的关键机会是对于没有太多数据的用户、项目或上下文,我们仍然可以即时形成嵌入。回想一下,我们的嵌入使我们的系统的其余部分成为可能,但冷启动推荐是一个巨大的挑战。

Xinyang Yi 等人在“大语料库项目推荐的采样偏差校正神经建模”中介绍的双塔架构——或双编码器网络——在图 7-2 中显示了显式的模型架构,旨在在构建推荐系统的评分模型时,优先考虑用户和项目的特征。我们将看到更多关于矩阵分解(MF)的讨论,这是一种从用户-项目矩阵派生出的潜在协同过滤(CF)的一种线性代数算法。在前一节中,我们解释了为什么额外的特征很重要。将这些附加特征添加到 MF 范式中是可能的,并且已经被证明是成功的——例如,应用于隐式反馈的 CF分解机器SVDFeature等应用。然而,在这个模型中,我们将采取更直接的方法。

两个塔的架构

图 7-2. 负责两个嵌入的两个塔

在这种架构中,我们将左侧塔视为负责项目的部分,将右侧塔视为负责用户和(在适当时)上下文的部分。这两种塔式架构受自然语言处理文献的启发,特别是 Paul Neculoiu 等人的“使用连体递归网络学习文本相似性”

让我们详细说明这种模型架构如何应用于 YouTube 视频推荐。要了解此架构首次引入的完整概述,请参见“YouTube 推荐的深度神经网络”由保罗·科温顿等人撰写。训练标签将通过点击来给出,但还带有额外的回归特征 r i ∈ 0 , 1 ,其中最小值对应于点击但是没有明显的观看时间,而范围的最大值对应于完全观看。

正如我们提到的,这种模型架构明确包括来自用户和项目的特征。视频特征包括分类和连续特征,如VideoIdChannelIdVideoTopic等。嵌入层用于许多分类特征以转换为密集表示。用户特征包括通过词袋和标准用户特征的观看历史。

此模型结构结合了许多您之前见过的想法,但对我们的系统架构有相关的收获。首先是顺序训练的概念。每个时间批次的样本应按顺序训练,以确保模型漂移显示给模型;我们将在“预测验证”中讨论预测数据集。接下来,我们为这些模型的产品化提出了一个重要的想法:编码器。

在这些模型中,我们将特征编码器作为两个塔的早期层,并且当我们转移到推断时,我们仍然需要这些编码器。在执行在线推荐时,我们将获得UserIdVideoId,并且首先需要收集它们的特征。如“特征存储”所述,特征存储将有助于获取这些原始特征,但我们还需要将特征编码为推断所需的密集表示。对于已知实体,可以将此信息存储在特征存储中,但对于未知实体,我们需要在推断时进行特征嵌入。

编码层用作将一组特征映射到稠密表示的简单模型。在神经网络中将编码层作为第一步时,常见的策略是取前k层并将其重用为编码器模型。更具体地说,如果ℒ i , 0 ≤ i ≤ k是负责特征编码的层,则称E m b ( V ^ ) = ℒ k ( ℒ k-1 ( ... ℒ 0 ( V ^ ) ) )为将特征向量V ^映射到其稠密表示的函数。

在我们以前的系统架构中,我们将这个编码器作为快速层的一部分,收到来自特征存储的特征后。还要注意的是,我们仍然希望利用向量搜索;这些特征嵌入层在向量搜索和最近邻搜索的上游使用。

编码器作为服务

编码器和检索是多阶段推荐流水线的关键部分。我们曾简要谈到过涉及的潜在空间(更多细节请参见“潜在空间”),我们也提到了一个编码器。简而言之,编码器是将用户、项目、查询等转换为您将执行最近邻搜索的潜在空间的模型。这些模型可以通过多种过程进行训练,其中许多将在后面讨论,但重要的是讨论一下它们在训练后的存储位置。

编码器通常是简单的 API 端点,接受要嵌入的内容并返回一个向量(一组浮点数)。编码器通常在批处理层工作,以对将要检索的所有文档/项目进行编码,但它们也必须同时连接到实时层,以在查询到来时对其进行编码。一个常见的模式是设置一个批处理端点和一个单一查询端点,以促进两种模式的优化。这些端点应该快速并且高度可用。

如果您处理文本数据,一个很好的起点是使用基于 BERT 或 GPT 的嵌入。此时最简单的方法是从 OpenAI 提供的托管服务获取。

部署

像许多 ML 应用程序一样,推荐系统的最终输出本身是一个持续运行并公开 API 以与之交互的小程序;批量推荐通常是一个强大的起点,提前执行所有必要的推荐。在本章中,我们已经看到了嵌入在后端系统中的部件,但现在我们将讨论更靠近用户的组件。

在我们相对通用的架构中,服务器负责在处理所有前面工作后交付推荐结果,并应遵循预设的模式。但是部署看起来是什么样子呢?

模型作为 API

让我们讨论两种系统架构,这些架构可能适合在生产中为您的模型提供服务:微服务和单体。

在 Web 应用程序中,从许多角度和特殊用例涵盖了这种二分法。作为 ML 工程师、数据科学家,以及潜在的数据平台工程师,深入挖掘这个领域并不是必要的,但了解基础知识是至关重要的:

微服务架构

管道的每个组件应该是自己的小程序,具有清晰的 API 和输出模式。组合这些 API 调用可以实现灵活和可预测的管道。

单体架构

一个应用程序应包含所有模型预测所需的逻辑和组件。保持应用程序自包含意味着需要保持一致的接口较少,当管道中的某个位置饥饿时,需要搜索的兔子洞也较少。

无论您选择什么策略,您都需要做出几个决定:

所需应用的规模有多大?

如果您的应用在推理时需要快速访问大型数据集,您需要仔细考虑内存需求。

您的应用程序需要什么访问权限?

我们之前讨论过使用布隆过滤器和特征存储等技术。这些资源可能与您的应用程序紧密耦合(通过在应用程序中内存构建它们)或可能只是一个 API 调用。确保您的部署考虑了这些关系。

您的模型应部署到单节点还是集群?

对于某些模型类型,即使在推理阶段,我们也希望利用分布式计算。这将需要额外的配置以允许快速并行化。

您需要多少复制?

水平扩展允许您同时运行多个相同服务的副本,以减少对任何特定实例的需求。这对确保可用性和性能至关重要。随着我们的水平扩展,每个服务可以独立运行,有多种策略用于协调这些服务和 API 请求。每个副本通常是其自身的容器化应用程序,这些 API 如 CoreOS 和 Kubernetes 用于管理它们。请求本身也必须通过诸如 nginx 之类的方式平衡到不同的副本上。

哪些相关的 API 是公开的?

堆栈中的每个应用程序应该有一组明确公开的模式,并明确关于可能调用 API 的其他应用程序类型的通信。

模型服务的启动

那么,您可以用什么将您的模型放入应用程序中呢?有许多应用开发框架是有用的;在 Python 中最流行的一些包括 Flask、FastAPI 和 Django。每种都有不同的优点,但我们在这里将讨论 FastAPI。

FastAPI 是一个针对 API 应用程序的目标框架,使其特别适合用于提供 ML 模型。它称自己为异步服务器网关接口(ASGI)框架,其特定性带来了大量的简便性。

让我们以一个简单的例子来将一个适合的 torch 模型转化为一个使用 FastAPI 框架的服务。首先,让我们利用一个工件存储库来下载我们的适合模型。这里我们使用的是 Weights & Biases 的工件存储库:

import wandb, torch
run = wandb.init(project=Prod_model, job_type="inference")

model_dir = run.use_artifact(
		'bryan-wandb/recsys-torch/model:latest',
		type='model'
).download()

model = torch.load(model_dir)
model.eval(user_id)

这看起来就像您的笔记本工作流程一样,所以让我们看看如何将其与 FastAPI 集成,这是多么容易:

from fastapi import FastAPI # FastAPI code

import wandb, torch

app = FastAPI() # FastAPI code

run = wandb.init(project=Prod_model, job_type="inference")

model_dir = run.use_artifact(
	'bryan-wandb/recsys-torch/model:latest',
	type='model'
).download()

model = torch.load(model_dir)

@app.get("/recommendations/{user_id}") # FastAPI code
def make_recs_for_user(user_id: int): # FastAPI code
		endpoint_name = 'make_recs_for_user_v0'
		logger.info(
			"{'type': 'recommendation_request',"
			f"'arguments': {'user_id': {user_id}},"
			f"'response': {None}},",
			f"'endpoint_name': {endpoint_name}"
		)
		recommendation = model.eval(user_id)
		logger.log(
			"{'type': 'model_inference',"
			f"'arguments': {'user_id': {user_id}},"
			f"'response': {recommendation}},"
			f"'endpoint_name': {endpoint_name}"
		)
    return { # FastAPI code
			"user_id": user_id,
			"endpoint_name": endpoint_name,
			"recommendation": recommendation
		}

我希望您能分享我的热情,我们现在已经用额外的五行代码将一个模型作为一个服务。虽然这个场景包括了简单的日志示例,但我们将在本章后面更详细地讨论日志记录,以帮助您提高应用程序的可观察性。

工作流编排

部署系统所需的另一个组件是工作流编排。模型服务负责接收请求并提供结果,但是为了使此服务有用,许多系统组件需要就位。这些工作流程具有多个组件,因此我们将按顺序讨论它们:容器化、调度和 CI/CD。

容器化

我们已经讨论了如何组合一个简单的服务,可以返回结果,并建议使用 FastAPI;然而,环境的问题现在是相关的。在执行 Python 代码时,保持环境的一致性,如果不是相同的话,这是很重要的。FastAPI 是设计接口的库;Docker 是管理代码运行环境的软件。常常听到 Docker 被描述为容器或容器化工具:这是因为您将一堆应用程序或可执行的代码组件加载到一个共享环境中。

在这一点上,我们有一些微妙的事情需要注意。环境的含义包括 Python 环境中的软件包依赖项以及更大的环境,包括操作系统或 GPU 驱动程序。环境通常从预定的镜像中初始化,该镜像安装了您需要访问的基本内容,在许多情况下在服务之间变化较小,以促进一致性和标准化。最后,容器通常配备了一系列基础设施代码,这些代码在部署到任何地方时都是必需的。

在实践中,您可以通过requirements文件来指定 Python 环境的详细信息,该文件包含了一系列 Python 包。请注意,一些库依赖关系在 Python 之外,并且需要额外的配置机制。操作系统和驱动程序通常作为基础镜像的一部分构建;您可以在 DockerHub 或类似的地方找到它们。最后,基础设施即代码是一种范式,您可以编写代码来编排在部署到的基础设施中运行您的容器所需的步骤。Dockerfile 和 Docker Compose 专门用于 Docker 容器与基础设施的接口,但您可以进一步将这些概念推广到包括基础设施的其他细节。这种基础设施即代码开始封装云中资源的供应、设置用于网络通信的开放端口、通过安全角色进行访问控制等。编写此代码的常见方式是使用 Terraform。本书不深入讨论基础设施规范,但基础设施即代码正在成为 ML 从业者更重要的工具。许多公司正开始尝试简化培训和部署系统的这些方面,包括 Weights & Biases 或 Modal。

调度

有两种调度作业的范式:cron 和触发器。稍后我们将更详细地讨论持续训练循环和主动学习过程,但在这些过程之上是您的 ML 工作流程。ML 工作流是为了为推理准备您的模型而必需的一组有序步骤。我们介绍了我们的收集器、排名器和服务器的概念,它们被组织成推荐系统的一系列阶段,但这些是系统拓扑结构中最粗略的三个元素。

在机器学习系统中,我们经常假设工作流程的上游阶段对应于数据转换,如第六章中所讨论的那样。无论这个阶段发生在哪里,这些转换的输出将导致我们的向量存储,可能还包括额外的特征存储。这些步骤之间的交接以及工作流程中的下一步骤是作业调度器的结果。正如之前提到的,像 Dagster 和 Airflow 这样的工具可以运行依赖资产的作业序列。这些工具需要编排过渡并确保它们及时完成。

Cron 是指工作流应该开始的时间表,例如每小时的整点或一天四次。触发器 是指当发生另一个事件时启动作业运行的情况,例如如果一个端点接收请求,或者一组数据有了新版本,或者超过响应限制。这些旨在捕捉下一个作业阶段与触发器之间的更多临时关系。这两种范式都非常重要。

CI/CD

您的工作流执行系统是您的机器学习系统的支柱,通常是数据收集过程、训练过程和部署过程之间的桥梁。现代工作流执行系统还包括自动验证和跟踪,以便您可以审核通往生产的步骤。

持续集成 (CI) 是从软件工程中借用的术语,用于对新代码强制执行一系列检查,以加速开发过程。在传统的软件工程中,这包括自动化单元测试和集成测试,通常是在将代码检入版本控制之后运行。对于机器学习系统,CI 可能意味着对模型运行测试脚本,检查数据转换的输入输出类型,或者运行验证集通过模型,并将性能与先前模型进行基准测试。

持续部署 (CD) 也是一个在软件工程中流行的术语,用来指代将新封装的代码自动推送到现有系统的过程。在软件工程中,当代码通过相关检查后部署可以加快开发速度,降低系统过时的风险。在机器学习中,CD 可能涉及诸如将新模型自动部署在服务端点的背后以进行影子测试(我们将在“Shadowing”中讨论),以验证其在实时流量下的预期表现。这也可能意味着在 A/B 测试或多臂老虎机处理的非常小分配后部署模型,以开始测量目标结果的影响。CD 通常需要根据推送前必须满足的要求进行有效的触发。通常可以听到 CD 使用模型注册表,您可以在其中存储和索引模型的各种变体。

警报和监控

警报和监控大部分灵感来自于软件工程的 DevOps 世界。以下是一些指导我们思考的高层原则:

  • 明确定义的模式和先验

  • 可观察性

模式和先验

在设计软件系统时,您几乎总是对组件如何相互配合有期望。就像在编写代码时预期函数的输入和输出一样,在软件系统中,您预期每个接口的输入和输出。这不仅适用于微服务架构;即使在单体架构中,系统的组件也需要协同工作,并且通常在其定义的责任之间存在边界。

让我们通过一个例子来具体化这一点。您已经建立了一个用户-项目的潜在空间,一个用于用户特征的特征存储,一个用于客户避免的布隆过滤器(客户明确告诉您他们不想要的东西),以及一个实验索引,定义了应该用于评分的两个模型中的哪一个。首先让我们检查潜在空间;当提供一个user_id时,我们需要查找其表示,并且我们已经有一些假设:

  • 提供的user_id将是正确类型。

  • user_id将在我们的空间中有一个表示。

  • 返回的表示将是正确的类型和形状。

  • 表示向量的组件值将在适当的域中。(潜在空间中表示的支持可能会每天变化。)

从这里开始,我们需要查找k近邻,这涉及更多的假设:

  • 在我们的潜在空间中有≥ k个向量。

  • 这些向量遵循潜在空间的预期分布行为。

虽然这些看起来像是相对直接的单元测试应用,但是规范化这些假设是很重要的。考虑这两种服务中的最后一个假设:你如何知道表示向量的适当域?作为训练过程的一部分,您需要计算这一点,然后存储以便在推理管道期间访问。

在第二种情况下,在高维空间中找到最近的邻居时,会出现分布均匀性的困难,但这可能导致推荐性能特别差。在实践中,我们观察到在潜在空间中k个最近邻的行为呈现出尖锐的特性,这在确保推荐多样性时会带来后续的挑战。这些分布可以被估计为先验,并且像 KL 散度这样的简单检查可以在线上使用;我们可以估计嵌入的平均行为以及局部几何之间的差异。

在这两种情况下,收集和记录这些信息的输出可以为系统的运行情况提供丰富的历史记录。如果模型在生产中的性能低下,这可以缩短调试循环。

回到user_id在我们空间中缺乏表示的可能性:这正是冷启动问题!在这种情况下,我们需要切换到不同的预测管道:也许是基于用户特征的,探索-利用的,甚至是硬编码的推荐。在这种设置下,我们需要理解当模式条件不满足时的下一步操作,然后优雅地前进。

集成测试

让我们考虑在像这样的系统中可能出现的一个更高级别的挑战。有些人称这些问题为纠缠

通过实验,您已经了解到为了给用户提供良好的推荐,应在项目空间中找到k = 20个 ANNs。您向表征空间发出调用,获取您的 20 个项目,并将它们传递给过滤步骤。然而,这个用户非常挑剔;他们以前在账户上设置了许多关于允许的推荐种类的限制:不要鞋子,不要裙子,不要牛仔裤,不要帽子,不要手提包——这对一个挣扎的推荐系统来说是个难题。

如果你简单地将 20 个相邻项传递到布隆中,你可能什么也得不到!您可以通过两种方式解决这个挑战:

  • 允许从过滤步骤到检索的回调(见“谓词下推”)

  • 建立用户分布并在检索期间存储该分布

在第一种方法中,您可以让您的过滤步骤调用检索步骤,使用更大的k,直到布隆之后满足要求。当然,这会导致显著的减速,因为它需要多次通过和越来越多的冗余查询!虽然这种方法很简单,但它需要防御性地构建,并提前了解可能出现的问题。

在第二种方法中,在训练过程中,您可以从用户空间中抽样以建立适合不同避开数量的k的估算值。然后,向收集器提供用户总避开数量的查找可以帮助防范这种行为。

过度检索

有时信息检索中的人们会过度检索以缓解搜索请求中冲突要求的问题,这可能会发生在用户进行搜索并同时应用多个过滤器时。这在推荐系统中同样适用。

如果您只检索与您预计向用户展示的潜在推荐数量相等的项目,下游规则或低个性化评分有时会导致严重问题。这就是为什么常常检索比您预期展示给用户的项目数量更多的原因。

可观察性

软件工程中的许多工具可以帮助理解软件堆栈中发生的事情。由于我们正在构建的系统变得非常分布,接口成为关键的监控点,但路径也变得复杂。

跨度和跟踪

这一领域常见的术语是跨度跟踪,它们指的是调用堆栈的两个维度,在图 7-3 中有所说明。在我们之前的示例中给定一组连接的服务,一个个体推理请求将按顺序通过其中一些或全部这些服务。服务请求的序列是跟踪。每个服务的潜在并行时间延迟是跨度

一个请求的追踪跨度图示

图 7-3. 追踪的跨度

跨度的图形表示通常展示了一个服务响应时间如何包括其他调用的各种延迟。

可观察性使您能够同时查看追踪、跨度和日志,以适当地诊断系统行为。在我们的例子中,利用从过滤步骤的回调获取更多邻居的回调时,我们可能会看到一个缓慢的响应,并想知道发生了什么事情。通过查看跨度和追踪,我们能够看到第一次调用收集器如预期般进行,然后过滤步骤调用了收集器,然后又调用了收集器,依此类推,这为过滤步骤建立了一个巨大的跨度。将此视图与日志结合起来将帮助我们快速诊断可能发生的情况。

超时

在前面的例子中,我们有一个可能导致非常糟糕用户体验的漫长过程。在大多数情况下,我们对事情变得如何的严格限制;这些被称为超时

通常情况下,我们对推断响应等待的时间有一个上限,因此实现超时可以使我们的系统符合这些限制。在这些情况下,拥有一个备用方案非常重要。在推荐系统的环境中,备用方案通常包括如 MPIR 这样的准备,以使其附加的延迟最小化。

在生产环境中的评估

如果前一节是关于理解模型在生产中接收到的内容,那么这一节可以概括为生产中模型输出的内容。从高层次来看,生产中的评估可以被认为是将所有您的模型验证技术扩展到推断时间。特别是,您正在查看模型实际正在执行的操作

一方面,我们已经有了工具来进行这种评估。您可以使用与训练相同的方法来评估性能,但现在是在实时流中观察到的真实数据上。然而,这个过程并不像我们最初想象的那么明显。让我们讨论一些挑战。

缓慢的反馈

推荐系统基本上是试图导致物品选择,并在许多情况下是购买。但是,如果我们退后一步,更全面地思考将推荐系统整合到业务中的目的,那就是推动收入。如果您是一个电子商店,物品选择和收入可能似乎很容易关联:购买导致收入,因此良好的物品推荐导致收入。但是,退货呢?甚至更难的问题:这种收入是否是增量的?推荐系统面临的一个挑战是,很难在任何用于衡量模型性能的指标与业务导向的关键绩效指标之间画出因果关系。

我们称之为慢反馈,因为有时从推荐到有意义的度量再回到推荐系统可能需要几周甚至更长时间。当您希望运行实验以理解是否应该推出新模型时,这尤其具有挑战性。测试的长度可能需要更长时间才能获得有意义的结果。

通常,团队会对数据科学家认为是 KPI 良好估计器的代理指标达成一致。该代理指标实时测量。这种方法有各种挑战,但通常足够并促使进行更多测试。良好相关的代理通常是获取方向性信息的好起点,指示进一步迭代的方向。

模型指标

那么,在生产中跟踪模型的关键指标是什么?考虑到我们在推断时间内正在研究推荐系统,我们应该努力理解以下内容:

  • 分类特征的推荐分布

  • 亲和分数的分布

  • 候选人数

  • 其他排名分数的分布

正如我们之前讨论过的,在训练过程中,我们应该广泛计算潜在空间中相似性分数的范围。无论我们是在看高级估计还是精细估计,我们可以使用这些分布来获得警示信号,表明可能存在异常。简单地将我们模型在推断期间或一组推断请求中的输出与这些预先计算的分布进行比较,可以极大地帮助。

比较分布可能是一个长话题,但一个标准方法是计算观察分布与训练预期分布之间的KL 散度。通过计算这些之间的 KL 散度,我们可以理解模型在某一天的预测有多意外

我们真正希望的是理解模型预测在一个转化类型上的接收者操作特征曲线(ROC)。然而,这涉及到另一个整合以回溯到日志记录。由于我们的模型 API 仅生成建议,我们仍然需要从 Web 应用程序中与日志记录相结合以理解结果!为了关联结果,我们必须将模型预测与日志输出进行关联,以获取评估标签,这可以通过日志解析技术(如 Grafana、ELK 或 Prometheus)完成。我们将在第八章中看到更多关于这个的内容。

接收者操作特征曲线

如果我们假设相关性分数正在估计项目是否与用户相关,这形成了一个二元分类问题。利用这些(归一化)分数,我们可以构建一个 ROC 曲线,以估计在查询分布中,当相关性分数开始准确预测一个相关项目时的必要检索深度。因此,这条曲线可以用来估计参数,如必要的检索深度或甚至有问题的查询。

持续的训练和部署

或许我们会觉得故事已经结束了,因为我们已经跟踪了模型并在生产中进行了监控,但我们很少满足于设定即忘的模型开发。机器学习产品的一个重要特征是,模型经常需要更新才能有用。之前,我们讨论了模型指标,有时在生产中的性能可能与我们基于训练模型性能的预期有所不同。这可能会被模型漂移进一步加剧。

模型漂移

模型漂移是同一模型随时间可能表现出不同预测行为的概念,仅仅因为数据生成过程发生变化。一个简单的例子是时间序列预测模型。当你建立一个时间序列预测模型时,特别重要的性质是自回归:函数的值与函数先前的值协变。我们不会详细讨论时间序列预测,但可以说:你做出良好预测的最佳希望是使用最新的数据!如果你想预测股票价格,你应该始终使用最近的价格作为预测的一部分。

这个简单的例子展示了模型可能如何漂移,而预测模型与推荐模型在考虑许多推荐问题的季节性现实时并没有太大不同。两周前表现良好的模型需要重新训练,使用最新数据才能继续表现良好的预期。

一个漂移的模型的批评是“这是一个过拟合模型的明显表现”,但实际上,这些模型需要一定量的过参数化才能有用。在推荐系统的背景下,我们已经看到,像马修效应这样的怪现象对推荐模型的预期性能有灾难性影响。如果我们不考虑推荐系统中的新项目等因素,我们注定会失败。模型可能因多种原因而漂移,通常归结为生成过程中的外生因素,这些因素可能未被模型捕捉到。

处理和预测模型陈旧的一种方法是在训练期间模拟这些情景。如果你怀疑模型主要因为分布随时间变化而变得陈旧,你可以采用顺序交叉验证——在连续的时间段进行训练,然后在随后的时间段进行测试,但带有指定的时间延迟。例如,如果你认为你的模型在两周后的表现会下降,因为它是基于过时观察结果训练的,那么在训练过程中,你可以故意构建你的评估,在测量性能之前加入两周的延迟。这被称为两阶段预测比较,通过比较性能,你可以估计在生产中要注意的漂移量。

有丰富的统计方法可以用来控制这些差异。我们不深入讨论变分建模以及为你的预测的可变性和可靠性开展的工作,而是讨论连续训练和部署,并用一把锤子打开这个花生。

部署拓扑

让我们考虑几种用于部署模型的结构,这不仅可以使你的模型保持良好的调整,还能够容纳迭代、实验和优化。

集成

Ensembles 是一种模型结构类型,其中构建了多个模型,并且这些模型的预测以各种方式汇总在一起。尽管这种集成的概念通常打包到推理模型中,但你可以将这个想法推广到你的部署拓扑中。

让我们举个例子,继续我们之前关于预测先验讨论的内容。如果我们有一组在任务上表现相当的模型,我们可以按其偏离我们设定的预测先验分布进行加权,以集成它们。这样,不仅仅在你的模型输出范围上有一个简单的是/否过滤器,而且可以更平滑地将潜在问题预测过渡为更符合预期的结果。

将集成视为部署拓扑而不仅仅是模型架构的另一个好处是,你可以在观察特征空间的特定子域中进行改进时,通过热插拔集成的组件。例如,生命周期价值(LTV)模型由三个组件组成:一个用于预测新客户,另一个用于激活客户,第三个用于超级用户。你可能会发现通过投票机制进行汇总在平均上表现最佳,因此你决定实施装袋方法。这样做效果很好,但后来你发现了一个更好的新客户模型。通过使用集成的部署拓扑,你可以将新客户的新模型换入,并开始在生产环境中比较集成的性能。这带我们进入下一个策略,模型比较。

集成建模

集成建模 在各种 ML 中都很流行,建立在一个简单的观念上,即专家意见的混合比单一估计器更有效。实际上,假设你有 M 分类器,错误率为 ϵ;那么对于一个 N 类分类问题,你的错误将是 P ( y ≥ k ) = ∑ k n n k ϵ k (1-ϵ) n-k,令人兴奋的是,这对于所有小于 0.5 的值都比 ϵ 小!

阴影

部署两个模型,即使是同一个任务,也可以提供极大的信息量。当一个模型是“实时”的,而另一个在暗中也接收所有请求并进行推理和记录结果时,我们称之为阴影,当然。通过将流量引导到另一个模型,您可以在使您的模型实时之前获得有关模型行为的最佳期望。这在希望确保预测范围与期望一致时特别有用。

在软件工程和 DevOps 中,有一个关于软件的暂存的概念。关于“暂存应该看到多少真实基础设施”的问题争议不小,但是模型的阴影是暂存的机制。你基本上可以为整个基础设施建立一个并行管道来连接阴影模型,或者你可以将它们都置于火线上,并将请求发送到两者,但仅使用一个响应。阴影对于实施实验至关重要。

实验

作为优秀的数据科学家,我们知道如果没有一个合适的实验框架,宣传一个功能或者模型的性能是有风险的。通过阴影,可以通过一个控制器层处理传入请求,并协调部署的模型来返回响应。一个简单的 A/B 实验框架可能会在每个请求时进行随机化,而像多臂老虎机这样的东西则需要控制器层具有奖励函数的概念。

实验是一个深入的话题,我们没有足够的知识或空间来充分展示,但知道实验可以如何适应更大的部署流程是有用的。

评估飞轮

到现在为止,一个生产 ML 模型显然不是一个静态对象。任何类型的生产 ML 系统都会受到与传统软件堆栈一样多的部署问题的影响,除了面临数据集变化和新用户/物品的额外挑战。在本节中,我们将密切关注引入的反馈循环,并理解各组件如何配合以持续改进我们的系统——即使数据科学家或 ML 工程师几乎没有输入。

每日热启动

正如我们现在已经多次讨论过的那样,我们需要将我们模型的连续输出与重新训练联系起来。这里最简单的例子是每日热启动,本质上要求我们利用系统中每天看到的新数据。

正如可能已经显而易见的那样,一些显示出巨大成功的推荐模型非常庞大。重新训练其中一些可能是一项巨大的任务,而仅仅每天重新运行所有内容通常是不可行的。那么,可以做些什么呢?

让我们将这个对话基于我们一直在草拟的用户-用户 CF 示例进行具体化;第一步是通过我们的相似性定义构建一个嵌入。让我们回想一下:

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

在这里,我们记得两个用户之间的相似性取决于共享的评分以及每个用户的平均评分。

在某一天,假设 X ˜ = x ˜ ∣ x was rated since yesterday by a user 。那么我们需要更新我们的用户相似性,但理想情况下,我们会保持其他一切不变。为了更新用户的数据,我们看到所有被两个用户 x ˜ 评价的情况,A 和 B r ¯ A 和 r ¯ B ,需要进行更改,但在许多情况下,我们可能可以跳过这些更新,其中这些用户的评级数量较多。总之,这意味着对于每个 x ˜ ,我们应该查找之前评价过 x 的用户,并更新他们之间以及新评分者之间的用户相似性。

这有点临时抱佛脚,但对于许多方法,您可以利用这些技巧来减少完全的重新训练。这将通过一个快速层避免完整的批量重新训练。还有其他方法,例如构建一个能够近似低信号项目推荐的单独模型。这可以通过特征模型实现,并且可以显著减少这些快速重新训练的复杂性。

Lambda 架构与编排

在这些策略的极端端点上是 Lambda 架构;正如在第六章中讨论的那样,Lambda 架构旨在对系统中的新数据具有更频繁的流水线。速度 层负责处理小批量进行数据转换,并在模型拟合时与核心模型结合。作为提醒,流水线的许多其他方面也应在这些快速层次中更新,如最近邻图、特征存储和过滤器。

流水线的不同组件可能需要不同的投资来保持更新,因此它们的时间表是一个重要的考虑因素。您可能已经开始注意到,保持所有这些方面的同步可能有些挑战。如果您的模型训练、模型更新、特征存储更新、重新部署以及新项目/用户可能在潜在不同的时间表上进行,则可能需要大量的协调工作。这就是编排工具变得相关的地方。存在多种方法,但在这里一些有用的技术包括 GoCD、MetaFlow 和 KubeFlow;后者更多地面向 Kubernetes 基础设施。另一个可以处理批处理和流处理管道的管道编排工具是 Apache Beam。

通常情况下,对于 ML 部署管道,我们需要一个可靠的核心管道以及随着更多数据涌入的能力来保持系统更新。编排系统通常定义系统的拓扑结构、相关的基础设施配置以及需要运行的代码工件的映射——更不用说所有这些作业需要运行的 CRON 时间表了。代码即基础设施是一个流行的范式,捕捉这些目标作为口头禅,以便甚至这些配置本身也是可重现和可自动化的。

在所有这些编排考虑中,与容器化和如何部署这些步骤有很大的重叠。不幸的是,大部分这些讨论超出了本书的范围,但简单概述是,像 Docker 这样的容器化部署对 ML 服务非常有帮助,并且使用各种容器管理系统(如 Kubernetes)来管理这些部署也很流行。

日志记录

日志记录已经多次提及。在本章的前面,您看到了日志记录对确保我们的系统表现如预期的重要性。让我们讨论一些日志记录的最佳实践以及它们如何融入我们的计划中。

在我们之前讨论过轨迹和跨度时,我们能够对响应请求的服务调用堆栈的整体进行快照。将这些服务连接起来以看到更大的画面非常有用,特别是在日志记录方面,这给了我们一个关于如何定位我们的思维的提示。回到我们喜爱的推荐系统架构,我们有以下内容:

  • 收集器接收请求并查找与用户相关的嵌入

  • 对该向量上的项目计算 ANN

  • 通过布隆过滤器应用筛选以消除潜在的不良推荐

  • 通过特征存储增强候选项和用户的特征

  • 通过排名模型对候选人进行评分并估算潜在置信度

  • 业务逻辑或实验的排序和应用

每个元素都有可能应用日志记录,但现在让我们考虑如何将它们链接在一起。来自微服务的相关 ID 概念;关联 ID只是一个通过调用堆栈传递以确保稍后链接一切的标识符。显而易见的是,每个服务都将负责自己的日志记录,但这些服务在聚合时几乎总是更有用。

这些天,Kafka 经常被用作日志流处理器,用来监听管道中所有服务的日志,并管理它们的处理和存储。Kafka 依赖于基于消息的架构;每个服务都是生产者,而 Kafka 则帮助管理这些消息到消费者通道。在日志管理方面,Kafka 集群接收所有相关格式的日志,希望能够附加相关的关联 ID,并将它们发送到 ELK 堆栈。ELK 堆栈 — Elasticsearch、Logstash、Kibana — 包括 Logstash 组件来处理传入的日志流并应用结构化处理,Elasticsearch 用于构建日志存储的搜索索引,Kibana 添加了 UI 和高级仪表板以进行日志记录。

这些技术堆栈专注于确保您能够从日志中访问和观测。其他技术专注于其他方面,但您应该记录什么呢?

收集器日志

再次,我们希望在以下情况下记录:

  • 收集器接收请求并查找与用户相关的嵌入

  • 计算该向量上的 ANN 项目

收集器接收到请求,最简单的示例包括user_idrequesting_timestamp和可能需要的增强关键字元素(kwargs)。一个correlation_id应该从请求者传递或在此步骤生成。应该发送带有这些基本键的日志,以及接收到请求的时间戳。调用嵌入式存储,并且收集器应记录此请求。然后,当接收到请求时,嵌入式存储应记录此请求,以及嵌入式存储的响应。最后,当收集器返回响应时,应记录响应。这可能感觉像是大量冗余信息,但在故障排除时,API 调用中包含的显式参数变得极其有用。

现在收集器已经具备执行向量搜索所需的向量,因此它将调用 ANN 服务。记录此调用,以及选择邻居数<math alttext="k"><mi>k</mi></math>的相关逻辑,以及 ANN 收到的 API 请求,用于计算 ANN 的相关状态和 ANN 的响应。在收集器中,记录该响应以及用于下游服务要求的任何潜在数据增强是下一步。

到目前为止,至少已经发出了六个日志——这进一步强调了需要一种方式将它们全部链接在一起。实际上,您的服务中通常还有其他相关步骤应该记录(例如,检查返回邻居中距离分布是否适合下游排名)。

注意,如果嵌入查找失败,显然记录该失败非常重要,以及记录后续请求到冷启动推荐流程的情况。冷启动流程会产生额外的日志。

过滤和评分

现在我们需要监视以下步骤:

  1. 通过布隆过滤器应用筛选,以消除潜在的不良推荐

  2. 通过特征存储对候选项和用户进行特征增强

  3. 通过排名模型对候选人进行评分,以及可能的置信度估计

我们应该记录进入过滤服务的传入请求,以及我们希望应用的所有过滤器的收集。此外,当我们为每个项目搜索布隆过滤器并根据布隆过滤器将其筛选进入或退出时,我们应该建立一些结构化的日志记录,记录哪些项目被哪些过滤器捕获,然后将所有这些记录为后续检查的 blob。响应和请求应作为特征增强的一部分进行记录——我们应该记录特征存储的请求和响应。

也要记录增强特征,这些特征最终附加到项目实体上。这可能与特征存储本身重复,但是在稍后回顾时理解推荐流水线中添加了哪些特征是至关重要的。

在评分时,应记录整个候选集及其评分所需的特征和输出分数。记录整个数据集非常有用,因为后续训练可以利用这些数据更好地理解真实排名集。最后,将带有排名候选人及其所有特征的响应传递到下一步。

排序

我们还有最后一步要走,但这是一个至关重要的步骤:业务逻辑或实验的排序和应用。这一步可能是最重要的日志记录步骤,因为这一步骤中的逻辑可以变得非常复杂和特定。

如果您在此步骤中通过多个交叉业务需求实施过滤器,同时还与实验集成,您可能会发现自己严重努力解析由于响应时间而导致的合理预期如何变成混乱的情况。像记录传入候选项,以及它们被淘汰的原因和应用的业务规则顺序这样的技术,将使重构行为变得更加可操作。

另外,实验路由可能会由另一个服务处理,但是在这一步骤中看到的实验 ID 以及实验分配方式的使用责任属于服务器。在我们完成最终推荐或决定再次进行时,推荐状态的最后一次日志将确保应用程序日志可以通过响应进行验证。

主动学习

到目前为止,我们已经讨论过使用更新的数据以更频繁的时间表进行训练,并且我们已经讨论过如何在模型尚未看到足够数据的情况下提供良好的推荐。推荐和评级的反馈循环的另一个机会是主动学习。

我们无法深入讨论这个大而活跃的研究领域,但我们将讨论与推荐系统相关的核心思想。主动学习 通过建议学习者不仅仅是被动地收集标记(可能是隐式的)观察结果,还试图从中挖掘关系和偏好,从而改变了学习范式。主动学习确定哪些数据和观察结果在提高模型性能方面最有用,然后寻找这些标签。在推荐系统的背景下,我们知道马太效应是我们面临的最大挑战之一,因为对用户可能有很好匹配的许多项目,缺乏足够或适当的评级,无法在推荐过程中排在前列。

如果我们采用一个简单的策略:每个新项目进入商店时,都推荐给前 100 位顾客作为第二选择。这将导致两个结果:

  • 我们会迅速为我们的新项目建立数据,以帮助冷启动它。

  • 我们可能会降低我们推荐系统的性能。

在许多情况下,第二个结果是值得忍受以实现第一个结果的,但是何时呢?这样做是否是解决这个问题的正确方法?主动学习为解决这些问题提供了一种系统的方法。

主动学习方案的另一个更具体的优势是,可以扩展观察数据的分布。除了仅冷启动项目外,我们还可以使用主动学习来扩展用户的兴趣。这通常被描述为一种减少不确定性的技术,因为它可以用来提高更广泛类别的项目推荐的信心。这里有一个简单的例子:一个用户只购买科幻书籍,因此有一天你向他们展示了一些非常受欢迎的西部片,以查看该用户是否可能偶尔接受西部片的推荐。更多详情请参阅“推荐系统评估的倾向性加权”。

一个主动学习系统作为从试图增强的模型继承的损失函数——通常与某种形式的不确定性相关联——并试图最小化该损失。给定一个训练在一组观察和标签 x i , y i 上的模型 ℳ ,与损失 ℒ ,一个主动学习者寻求找到一个新的观察, x ¯ ,如果获得一个标签 y ¯ ,则通过模型的训练,包括这对新的观察,损失将减少。特别地,目标是近似由于每个可能的新观察而导致的损失的边际减少,并找到最大化损失函数减少的观察。

Argmax x ¯ ℒ ℳ x i ,y i - ℒ ℳ x i ,y i ∪x ¯

一个主动学习系统的结构大致遵循以下步骤:

  1. 估计由于获得一组观察而导致的损失边际减少。

  2. 选择具有最大影响的观察。

  3. 询问用户;即提供推荐以获取标签。

  4. 更新模型。

很显然,这种范式要求比我们先前的快速重新训练方案更快速的训练循环。主动学习可以被仪器化为与我们其他设置相同的基础设施,或者它可以有其自己的机制集成到流水线中。

优化类型

主动学习系统中由主动学习者执行的优化过程有两种方法:个性化和非个性化。由于推荐系统(RecSys)专注于个性化,我们很自然地希望通过整合我们已经了解的关于用户的详细信息,进一步推动我们的主动学习的效用。

我们可以将这两种方法视为全局损失最小化和局部损失最小化。非个性化的主动学习往往是关于在整个系统中最小化损失,而不仅仅是一个用户的损失。在实践中,优化方法是微妙的,有时使用复杂的算法和训练过程。

让我们讨论一些优化非个性化主动学习的因素:

用户评分方差

考虑哪些项目在用户评分中具有最大的方差,以尝试获取更多关于我们在观察中发现最复杂的项目的数据。

考虑特定项目在序数特征上评分的离散度。这对于了解一个项目的评分集是否是均匀随机分布很有用。

贪婪扩展

测量似乎在当前模型中表现最差的项目;这试图通过收集更多关于最难推荐的项目的数据来提高我们的整体表现。

代表性或典型案例

挑选出极具代表性的大量项目中的项目;我们可以将其视为“如果我们对这个有好的标签,那么对所有类似项目也有好的标签。”

流行度

选择用户最有可能有过经验的项目,以最大化他们提供意见或评分的可能性。

协同覆盖

尝试放大数据集中频繁出现的对的评分;这直接针对 CF 结构,以最大化观察的效用。

在个性化方面:

二元预测

为了最大化用户能够提供所请求评分的机会,选择用户更有可能体验过的项目。这可以通过二元评分矩阵上的 MF 实现。

基于影响力

评估项目评分对其他项目评分预测的影响,并选择具有最大影响力的项目。这试图直接衡量新项目评分对系统的影响。

评分优化

显然,可以使用最佳评分或类内最佳评分执行主动学习查询,但这恰好是推荐系统中服务良好推荐的标准策略。

用户分割

在可能的情况下,利用用户分割和用户内特征簇,预测用户是否会对项目有意见和偏好,基于用户相似性结构。

一般来说,存在一种软性权衡,即对于全局最大化模型改进有用的主动学习和对于最大化用户能够和愿意对特定项目评分有用的主动学习。让我们看一个同时使用两者的特定示例。

应用:用户注册

在构建推荐系统时,一个常见的障碍是引导新用户加入。根据定义,新用户将从零开始,没有任何评分,可能不会从一开始就期待到优秀的推荐。

对于所有新用户,我们可以从 MPIR 开始——简单地向他们展示某些东西,然后边学边用。但是否有更好的方法呢?

你可能经历过的一种方法是用户引导流程:许多网站采用的一组简单问题,快速获取用户的基本信息,以帮助早期推荐。如果讨论我们的图书推荐器,这可能是询问用户喜欢哪些类型的书籍,或者在咖啡推荐器的情况下,询问用户早上如何冲泡咖啡。显然,这些问题正在建立基于知识的推荐系统,并不直接与我们之前的流水线相结合,但仍然可以在早期推荐中提供一些帮助。

如果我们反过来看所有以前的数据,并问:“特别是哪些书对确定用户口味最有用?”,这将是一种主动学习方法。当用户回答每个问题时,我们甚至可以有一个可能性的决策树,其中答案决定了下一个最有用的问题是什么。

摘要

现在我们有信心能够提供我们的推荐,更好的是,我们已经为我们的系统装备了收集反馈的工具。我们已经展示了在部署之前如何获得信心,以及如何尝试新的模型或解决方案。集成和级联使您可以将测试与迭代相结合,而数据飞轮为改进您的产品提供了强大的机制。

你可能想知道如何将所有这些新知识付诸实践,下一章将讨论这个问题。让我们了解数据处理和简单计数如何可以导致一个有效的——而且有用的!——推荐系统。

第八章:将所有内容整合在一起:数据处理和计数推荐

现在我们已经讨论了推荐系统的大致轮廓,本章将对其进行具体实现,以便我们可以讨论技术选择和实现在现实生活中的工作方式的细节。

本章涵盖以下主题:

  • 使用协议缓冲区进行数据表示

  • 数据处理框架

  • 一个 PySpark 示例程序

  • GloVE 嵌入模型

  • JAX、Flax 和 Optax 中的额外基础技术

我们将逐步展示如何从下载的维基百科数据集转化为一个推荐系统,该系统可以根据与维基百科文章中词的共现来推荐单词。我们选择自然语言示例是因为单词易于理解,并且它们的关系很容易被抓住,因为我们可以看到相关单词在句子中彼此靠近。此外,维基百科语料库可以轻松下载并由任何有互联网连接的人浏览。这种共现的想法可以推广到任何共同出现的集合,比如在同一会话中观看视频或在同一购物袋中购买奶酪。

本章将演示一个基于项目-项目和特征-项目的具体实现。在这种情况下,项目是文章中的单词,而特征是单词计数的相似性——例如 MinHash 或一种用于单词的局部敏感哈希。第十六章将更详细地讨论局部敏感哈希,但现在我们将这些简单的哈希函数视为内容的编码函数,使具有相似属性的内容映射到相似的共域。这个一般的想法可以作为在缺乏日志数据的新语料库上的热启动机制使用,如果我们有用户-项目特征(如喜欢),这些特征可以作为特征-项目推荐系统的特征使用。共现的原则是相同的,但通过使用维基百科作为示例,您可以下载数据并使用提供的工具进行操作。

技术栈

技术栈(technology stacktech stack)是一组常一起使用的技术。每个技术栈的组件通常可以被其他类似技术替代。我们将列举每个组件的几个替代方案,但不会详细讨论它们的利弊,因为可能有很多情况影响部署组件的选择。例如,您的公司可能已经使用了特定的组件,所以出于熟悉度和支持的考虑,您可能希望继续使用它。

本章涵盖了构建具体实现收集器所需数据处理技术的一些技术选择。

示例代码可以在GitHub上找到。您可能想要将代码克隆到本地目录。

数据表示

我们需要做的第一个技术选择将决定我们如何表示数据。以下是一些选择:

在这个实现中,我们主要使用协议缓冲区,因为它易于指定模式,然后序列化和反序列化。

对于文件格式,我们使用序列化的协议缓冲区,将其编码为单行每个记录,然后使用 Bzip 进行压缩。这仅仅是为了方便,以便我们可以轻松地解析文件,而不需要依赖太多库。例如,您的公司可能会将数据存储在可通过 SQL 访问的数据仓库中。

协议缓冲区通常比原始数据更易于解析和处理。在我们的实现中,我们将解析维基百科 XML 为协议缓冲区,以便更轻松地处理,使用xml2proto.py。您可以从代码中看到,XML 解析是一件复杂的事情,而协议缓冲区解析则简单得多,只需调用ParseFromString方法,然后所有数据随后都以便捷的 Python 对象形式可用。

截至 2022 年 6 月,维基百科转储文件约为 20 GB 大小,转换为协议缓冲区格式大约需要 10 分钟。请按照 GitHub 存储库中 README 中描述的步骤运行程序的最新步骤。

proto目录中,查看一些定义的协议消息。例如,这是我们可能存储维基百科页面文本的方式:

// Generic text document.
message TextDocument {
  // Primary entity, in wikipedia it is the title.
  string primary = 1;
  // Secondary entity, in wikipedia it is other titles.
  repeated string secondary = 2;
  // Raw body tokens.
  repeated string tokens = 3;
  // URL. Only visible documents have urls, some e.g. redirect shouldn't.
  string url = 4;
}

支持的类型和模式定义可以在协议缓冲区文档页面上找到。此模式通过使用协议缓冲区编译器转换为代码。该编译器的工作是将模式转换为您可以在不同语言中调用的代码,而在我们的情况下是 Python。协议缓冲区编译器的安装取决于平台,安装说明可以在协议缓冲区文档中找到。

每次更改模式时,您都需要使用协议缓冲区编译器获取新版本的协议缓冲区代码。可以通过使用像 Bazel 这样的构建系统轻松自动化此步骤,但这超出了本书的范围。为了本书的目的,我们将简单地生成协议缓冲区代码一次,并将其检入存储库以保持简单。

按照 GitHub 的 README 上的说明,下载维基百科数据集的副本,然后运行xml2proto.py将数据转换为协议缓冲区格式。可选择使用codex.py查看协议缓冲区格式的样子。在使用 Windows 子系统来运行 Windows 工作站的 Windows 工作站上,这些步骤花费了 10 分钟。使用的 XML 解析器并不很好地并行化,因此这一步是基本上串行的。接下来,我们将讨论如何将工作并行分配,无论是在本地的多个核心之间还是在集群上。

大数据框架

我们选择的下一个技术将在多台机器上规模化处理数据。这里列出了一些选项:

在这个实现中,我们在 Python 中使用 Apache Spark,或者称为 PySpark。仓库中的 README 显示了如何使用pip install在本地安装 PySpark 的副本。

PySpark 中实现的第一步是标记化和 URL 标准化。代码在tokenize_wiki_pyspark.py,但我们不会在这里详细介绍,因为很多处理只是分布式自然语言解析和将数据写入协议缓冲区格式。我们将详细讨论第二步,即制作一个字典以及有关单词计数的一些统计信息。但是,我们将运行代码,以查看 Spark 使用体验如何。Spark 程序使用spark-submit程序运行,如下所示:

bin/spark-submit
--master=local[4]
--conf="spark.files.ignoreCorruptFiles=true"
tokenize_wiki_pyspark.py
--input_file=data/enwiki-latest-parsed --output_file=data/enwiki-latest-tokenized

运行 Spark 提交脚本允许您在本地机器上执行控制程序,例如,在命令行中我们使用了tokenize_wiki_pyspark.py,请注意,local[4]表示使用最多四个核心。相同的命令可以用于将作业提交到 YARN 集群,以在数百台机器上运行,但是出于尝试 PySpark 的目的,一个足够好的工作站应该能够在几分钟内处理所有数据。

此标记化程序将从特定于源的格式(在本例中,是维基百科协议缓冲区)转换为用于自然语言处理的更通用的文本文档。一般来说,最好使用一个所有数据源都可以转换成的通用格式,因为这样可以简化下游的数据处理。可以将数据转换为每个语料库的标准格式,这个格式被流水线中所有后续程序统一处理。

提交作业后,您可以在本地机器上导航至 Spark UI(在图 8-1 中显示)。您应该会看到作业并行执行,使用您机器上的所有核心。您可能想要尝试使用local[4]参数;使用local[*]将使用您机器上的所有空闲核心。如果您可以访问集群,还可以指向适当的集群 URL。

Spark UI 显示计算阶段

图 8-1. Spark UI

集群框架

编写 Spark 程序的好处在于它可以从单机多核扩展到拥有数千核的多机集群。完整的集群类型列表可以在Spark“提交应用程序”文档中找到。

Spark 可以运行在以下几种集群类型上:

根据您的公司或机构设置的集群类型不同,大多数情况下提交作业只是指向正确 URL 的问题。许多公司,如 Databricks 和 Google,还提供了完全托管的 Spark 解决方案,可以让您轻松设置一个 Spark 集群。

PySpark 示例

统计单词实际上是信息检索中的一个强大工具,因为我们可以使用方便的技巧,比如词频、逆文档频率(TF-IDF),它简单地是文档中单词出现次数除以单词出现在文档中的次数。表示如下:

t f i d f word ( i ) = log 10 numberoftimesword i hasoccurredincorpus numberofdocumentsincorpuscontainingword i

例如,因为单词 the 经常出现,我们可能认为它很重要。但通过除以文档频率,the 就变得不那么特殊,重要性降低了。这个技巧在简单的自然语言处理中非常方便,可以得到比随机加权更好的单词重要性。

因此,我们的下一步是运行 make_dictionary.py。如其名称所示,这个程序简单地统计单词和文档,并创建一个字典,记录单词出现的次数。

我们有一些概念要介绍,以便您正确理解 Spark 如何以分布式方式处理数据。大多数 Spark 程序的入口点是 SparkContext。这个 Python 对象是在控制器上创建的。控制器 是启动实际处理数据的工作节点的中央程序。这些工作节点可以作为进程在单台机器上本地运行,也可以作为独立的工作节点在云上的许多机器上运行。

SparkContext 可以用于创建弹性分布式数据集,或称 RDD。这些是对数据流的引用,可以在控制器上进行操作,并且可以将 RDD 上的处理分配到所有工作节点上。SparkContext 允许您加载存储在分布式文件系统(如 Hadoop 分布式文件系统(HDFS)或云存储桶)中的数据文件。通过调用 SparkContexttextFile 方法,我们得到了一个 RDD 的句柄。然后可以对 RDD 应用或映射一个无状态函数,通过重复应用函数到 RDD 的内容,将其从一个 RDD 转换为另一个 RDD。

例如,这个程序片段加载一个文本文件,并通过运行一个匿名的 lambda 函数将所有行转换为小写:

def lower_rdd(input_file: str,
              output_file: str):
  """Takes a text file and converts it to lowercase.."""
  sc = SparkContext()
  input_rdd = sc.textFile(input_file)
  input_rdd.map(lambda line: line.lower()).saveAsTextFile(output_file)

在单机实现中,我们简单地加载每篇维基百科文章,在 RAM 中保持运行中的字典,并对每个标记进行计数,然后在字典中将标记计数加 1。标记是文档的原子元素,被分成片段。在常规英语中,它可能是一个词,但维基百科文档还有其他实体,如文档引用本身,需要单独跟踪,因此我们称其为标记化的分割和原子元素为标记。单机实现将花费一些时间浏览维基百科的成千上万篇文章,这就是为什么我们使用 Spark 等分布式处理框架的原因。在 Spark 范式中,计算被分成映射,其中一个函数在每个文档上并行无状态地应用。Spark 还具有减少函数,其中单独映射的输出被连接在一起。

例如,假设我们有一个单词计数列表,并想要对出现在不同文档中的单词的值进行求和。减少器的输入将类似于这样:

  • (苹果, 10)

  • (橙子, 20)

  • (苹果, 7)

然后我们调用 Spark 函数reduceByKey(lambda a, b: a+ b),它将所有具有相同键的值加在一起,并返回以下内容:

  • (橙子, 20)

  • (苹果, 17)

如果你查看make_dictionary.py中的代码,映射阶段是我们将文档作为输入,然后将其分解为(标记,1)元组的地方。在减少阶段,映射输出由键连接在一起,这里的键是标记本身,而减少函数只是简单地对所有标记的计数求和。

注意,减少函数假设缩减是可结合的——也就是说,( a + b + c ) = ( a + b ) + c = a + ( b + c )。这使得 Spark 框架能够在内存中对映射阶段的标记字典的某些部分进行求和(在某些框架中,这称为合并步骤,其中在映射器机器上运行减少的一部分结果)然后在减少阶段的多次传递中对它们进行求和。

作为一种优化,我们使用了 Spark 函数mapPartitions。Map 对每一行运行一次提供的函数(我们已将整个维基百科文档编码为协议缓冲区,并将其 uuencode 为单个文本行),而mapPartitions在整个分区上运行它,通常是许多文档,通常为 64 MB。这种优化使我们能够在整个分区上构建一个小的 Python 字典,因此我们有更少的令牌计数对要减少。这节省了网络带宽,因此 mapper 需要发送给 reducer 的数据较少,并且总体上对于这些数据处理管道来说是一个不错的提示,以减少网络带宽(通常是数据处理中最耗时的部分之一,与计算相比)。

接下来,我们展示了一个完整的 Spark 程序,它读取了上一节代码块中所示的TextDocument的协议缓冲区格式的文档,然后计算整个语料库中单词或标记出现的频率。GitHub 仓库中的文件是make_dictionary.py。以下代码与仓库文件略有不同,因为为了可读性将其分成了三个片段,并且将主程序和子程序的顺序进行了交换以便更清晰地展示。在这里,我们首先呈现依赖项和标志,然后是主体部分,然后是主体调用的函数,以便更清楚地了解函数的目的。

首先,让我们看一下依赖项。主要的依赖项是代表维基百科文章的文本文档的协议缓冲区,如前所述。这是我们期望的输入。对于输出,我们有TokenDictionary协议缓冲区,它主要统计文章中单词的出现次数。我们将使用单词的共现来形成文章的相似性图,然后将其用作基于热启动的推荐系统的基础。我们还依赖于 PySpark,这是我们用来处理数据的数据处理框架,以及一个处理程序选项的标志库。absl 标志库非常方便,可以解析和解释命令行标志的目的,并且可以轻松地检索标志的集合值。以下是依赖项和标志:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#

"""
 This reads a doc.pb.b64.bz2 file and generates a dictionary.
"""
import base64
import bz2
import nlp_pb2 as nlp_pb
import re
from absl import app
from absl import flags
from pyspark import SparkContext
from token_dictionary import TokenDictionary

FLAGS = flags.FLAGS
flags.DEFINE_string("input_file", None, "Input doc.pb.b64.bz2 file.")
flags.DEFINE_string("title_output", None,
                    "The title dictionary output file.")
flags.DEFINE_string("token_output", None,
                    "The token dictionary output file.")
flags.DEFINE_integer("min_token_frequency", 20,
                     "Minimum token frequency")
flags.DEFINE_integer("max_token_dictionary_size", 500000,
                     "Maximum size of the token dictionary.")
flags.DEFINE_integer("max_title_dictionary_size", 500000,
                     "Maximum size of the title dictionary.")
flags.DEFINE_integer("min_title_frequency", 5,
                     "Titles must occur this often.")

# Required flag.
flags.mark_flag_as_required("input_file")
flags.mark_flag_as_required("token_output")
flags.mark_flag_as_required("title_output")

接下来是程序的主体部分,在这里调用所有的子程序。我们首先创建SparkContext,这是进入 Spark 数据处理系统的入口点,然后调用其textFile方法来读取压缩的维基百科文章。请阅读存储库上的 README 文件以了解它是如何生成的。接着,我们解析文本文档,并将 RDD 发送到两个处理管道,一个用于创建文章正文的字典,另一个用于创建标题的字典。我们可以选择为两者创建一个统一的字典,但将它们分开允许我们使用标记字典创建基于内容的推荐器,并使用标题字典创建文章对文章的推荐器,因为标题是维基百科文章的标识符。这是主体部分:

def main(argv):
  """Main function."""
  del argv  # Unused.
  sc = SparkContext()
  input_rdd = sc.textFile(FLAGS.input_file)
  text_doc = parse_document(input_rdd)
  make_token_dictionary(
    text_doc,
    FLAGS.token_output,
    FLAGS.min_token_frequency,
    FLAGS.max_token_dictionary_size
  )
  make_title_dictionary(
    text_doc,
    FLAGS.title_output,
    FLAGS.min_title_frequency,
    FLAGS.max_title_dictionary_size
  )

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

最后,我们有由主函数调用的子程序,所有这些都分解为更小的子程序,用于计算文章正文和标题中的标记数量:

def update_dict_term(term, dictionary):
    """Updates a dictionary with a term."""
    if term in dictionary:
        x = dictionary[term]
    else:
        x = nlp_pb.TokenStat()
        x.token = term
        dictionary[term] = x
    x.frequency += 1

def update_dict_doc(term, dictionary):
    """Updates a dictionary with the doc frequency."""
    dictionary[term].doc_frequency += 1

def count_titles(doc, title_dict):
    """Counts the titles."""
    # Handle the titles.
    all_titles = [doc.primary]
    all_titles.extend(doc.secondary)
    for title in all_titles:
        update_dict_term(title, title_dict)
    title_set = set(all_titles)
    for title in title_set:
        update_dict_doc(title, title_dict)

def count_tokens(doc, token_dict):
    """Counts the tokens."""
    # Handle the tokens.
    for term in doc.tokens:
        update_dict_term(term, token_dict)
    term_set = set(doc.tokens)
    for term in term_set:
        update_dict_doc(term, token_dict)

def parse_document(rdd):
    """Parses documents."""
    def parser(x):
        result = nlp_pb.TextDocument()
        try:
            result.ParseFromString(x)
        except google.protobuf.message.DecodeError:
            result = None
        return result
    output = rdd.map(base64.b64decode)\
        .map(parser)\
        .filter(lambda x: x is not None)
    return output

def process_partition_for_tokens(doc_iterator):
    """Processes a document partition for tokens."""
    token_dict = {}
    for doc in doc_iterator:
        count_tokens(doc, token_dict)
    for token_stat in token_dict.values():
        yield (token_stat.token, token_stat)

def tokenstat_reducer(x, y):
    """Combines two token stats together."""
    x.frequency += y.frequency
    x.doc_frequency += y.doc_frequency
    return x

def make_token_dictionary(
    text_doc,
    token_output,
    min_term_frequency,
    max_token_dictionary_size
):
    """Makes the token dictionary."""
    tokens = text_doc.mapPartitions(process_partition_for_tokens)
        .reduceByKey(tokenstat_reducer).values()
    filtered_tokens = tokens.filter(
        lambda x: x.frequency >= min_term_frequency)
    all_tokens = filtered_tokens.collect()
    sorted_token_dict = sorted(
        all_tokens, key=lambda x: x.frequency, reverse=True)
    count = min(max_token_dictionary_size, len(sorted_token_dict))
    for i in range(count):
        sorted_token_dict[i].index = i
    TokenDictionary.save(sorted_token_dict[:count], token_output)

def process_partition_for_titles(doc_iterator):
    """Processes a document partition for titles."""
    title_dict = {}
    for doc in doc_iterator:
        count_titles(doc, title_dict)
    for token_stat in title_dict.values():
        yield (token_stat.token, token_stat)

def make_title_dictionary(
    text_doc,
    title_output,
    min_title_frequency,
    max_title_dictionary_size
):
    """Makes the title dictionary."""
    titles = text_doc
      .mapPartitions(process_partition_for_titles)
      .reduceByKey(tokenstat_reducer).values()
    filtered_titles = titles.filter(
      lambda x: x.frequency >= min_title_frequency)
    all_titles = filtered_titles.collect()
    sorted_title_dict = sorted(
      all_titles, key=lambda x: x.frequency, reverse=True)
    count = min(max_title_dictionary_size, len(sorted_title_dict))
    for i in range(count):
        sorted_title_dict[i].index = i
    TokenDictionary.save(sorted_title_dict[:count], title_output)

如您所见,Spark 使得将程序从单台计算机扩展到运行在多台计算机集群上变得非常容易!从主函数开始,我们创建SparkContext,将输入文件作为文本文件读取,解析它,然后制作标记和标题字典。RDD 作为处理函数的参数传递,并可以多次使用,并馈送到各种映射函数(如标记和标题字典方法)中。

制作字典方法中的重要工作由处理分区函数完成,这些函数是一次应用于整个分区的映射函数。分区是输入的大块,通常约为 64 MB 大小,并且作为一个块一次处理,这样我们通过执行映射端组合可以节省网络带宽。这是一种技术,重复应用于映射分区以及通过键(在本例中为标记)进行连接后对计数求和的减少器。我们这样做的原因是为了节省网络带宽,通常是数据处理流水线中磁盘访问后最慢的部分。

您可以通过使用实用程序codex.py查看make_dictionary阶段的输出,它会转储程序中注册的不同类型的协议缓冲区。由于我们所有的数据都被序列化为压缩和编码的文本文件,唯一的区别是使用哪种协议缓冲区模式来解码序列化数据,因此我们可以使用同一个程序来打印出用于调试的数据的前几个元素。尽管将数据存储为 JSON、XML 或 CSV 文件可能更简单,但拥有模式将使您免受未来烦恼,因为协议缓冲区是可扩展的并支持可选字段。它们还是有类型的,这可以使您免受在 JSON 中出现意外错误的影响,例如不知道一个值是字符串、浮点数还是整数,或者在某些文件中将字段作为字符串,而在其他文件中将字段作为整数。拥有显式类型化的模式可以使我们避免许多这些错误。

管道中的下一步是make_cooccurrence.py。顾名思义,这个程序只是计算每个标记与另一个标记共现的次数。这本质上是表示图的一种稀疏方式。在nlp.proto中,稀疏共现矩阵的每一行如下所示:

// Co-occurrence matrix row.
message CooccurrenceRow {
    uint64 index = 1;
    repeated uint64 other_index = 2;
    repeated float count = 3;
}

共现矩阵中,每行i在列j处有一个条目,表示标记j与标记i共现的次数。这是一种方便的方法,可以将标记ij之间的相似性关联起来,因为如果它们经常共同出现,它们必须比不共同出现的标记更相关。在协议缓冲区格式中,这些被存储为两个并行数组other_indexcount。我们使用索引是因为它们比存储原始单词要小,特别是使用协议缓冲区的变化编码(即由标记索引的行和列的矩阵,以及元素是索引的共现)。在这种编码中,小整数需要的位数比大整数少;由于我们按频率反向排序了字典,因此最常出现的标记具有最小的索引。

在这个阶段,如果您想基于频繁项相似性共现构建一个非常简单的推荐系统,您可以查找标记i的行并按计数顺序返回标记j 。这个简单的推荐系统将是对前几章中描述的流行物品推荐系统的一个很好的变体。

客户还购买了

这种共现概念将在第九章进一步发展,但让我们花一点时间来思考 MPIR 和共现概念。当我们查看物品的共现矩阵时,我们可以对行求和或列求和,以确定每个物品被看到(或购买)的次数。这就是我们在第二章中构建 MPIR 的方式。如果我们查看对应于用户已看到物品的特定行的 MPIR,那么这就是条件 MPIR——即给定用户已看到物品i 的最受欢迎的物品。

但是,在这里我们可以选择对共现矩阵进行嵌入或低秩表示。矩阵的嵌入表示很方便,因为它允许我们将每个项表示为向量。通过奇异值分解或 SVD(参见“潜在空间”)来分解矩阵的一种方法,但我们在这里不会这样做。相反,我们将学习用于自然语言处理的 GloVE 嵌入。

GloVE 嵌入的目标函数是学习两个向量,使得它们的点积与两个向量之间共现的对数计数成比例。这种损失函数有效的原因是点积将成比例于共现的对数计数;因此,经常一起出现的单词的点积将大于不经常一起出现的单词。为了计算嵌入,我们需要有共现矩阵可用,幸运的是,管道中的上一步已经为我们生成了这样一个矩阵供我们处理。

GloVE 模型定义

对于本节,请参考train_coccurence.py中的代码。

假设我们有来自令牌字典的令牌 ij。我们知道它们相互共现了 N 次。我们希望以某种方式生成一个嵌入空间,使得向量x ( i ) * x ( j )与 log(N) 成比例。对于 log 计数和确切的方程式,是由 Jeffrey Pennington 等人在“GloVe: 全局词向量表示”中推导出来的。我们只展示推导出的结果:

y predicted = x ( i ) x ˙ ( j ) + bias ( i ) + bias ( j )

这里,x是嵌入查找。在代码中,我们使用 64 维向量,它们不会太小以至于容量不足以表示嵌入空间,但也不会太大,以至于当我们对整个字典进行嵌入时会占用太多内存。偏置项用于吸收与许多其他项共现的非常流行的项的大计数,例如 theaand

我们要最小化的损失是预测值与实际值之间的平方差:

y target = 1 + log 10 ( N ) weight = min 1,N/100 0.75 loss = weight * (y predicted -y target ) 2

损失函数中的加权项是为了防止非常流行的共现项的主导以及减少较少见的共现项的权重。

JAX 和 Flax 中的 GloVE 模型规范

让我们来看看基于 JAX 和 Flax 的 GloVE 模型的实现。这在 GitHub 仓库的文件wikipedia/models.py中:

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

class Glove(nn.Module):
    """A simple embedding model based on gloVe.
 https://nlp.stanford.edu/projects/glove/
 """
    num_embeddings: int = 1024
    features: int = 64

    def setup(self):
        self._token_embedding = nn.Embed(self.num_embeddings,
                                         self.features)
        self._bias = nn.Embed(
            self.num_embeddings, 1, embedding_init=flax.linen.initializers.zeros)

    def __call__(self, inputs):
        """Calculates the approximate log count between tokens 1 and 2.
 Args:
 A batch of (token1, token2) integers representing co-occurence.
 Returns:
 Approximate log count between x and y.
 """
        token1, token2 = inputs
        embed1 = self._token_embedding(token1)
        bias1 = self._bias(token1)
        embed2 = self._token_embedding(token2)
        bias2 = self._bias(token2)
        dot_vmap = jax.vmap(jnp.dot, in_axes=[0, 0], out_axes=0)
        dot = dot_vmap(embed1, embed2)
        output = dot + bias1 + bias2
        return output

    def score_all(self, token):
        """Finds the score of token vs all tokens.
 Args:
 max_count: The maximum count of tokens to return.
 token: Integer index of token to find neighbors of.
 Returns:
 Scores of nearest tokens.
 """
        embed1 = self._token_embedding(token)
        all_tokens = jnp.arange(0, self.num_embeddings, 1, dtype=jnp.int32)
        all_embeds = self._token_embedding(all_tokens)
        dot_vmap = jax.vmap(jnp.dot, in_axes=[None, 0], out_axes=0)
        scores = dot_vmap(embed1, all_embeds)
        return scores

Flax 使用起来相当简单;所有网络都继承自 Flax 的亚麻神经网络库,并且都是模块。Flax 模块也是 Python 的数据类,因此模块的任何超参数都在模块开头定义为变量。对于这个简单的模型,我们只有两个超参数:我们想要的嵌入数量,对应于字典中的令牌数量,以及嵌入向量的维度。接下来,在模块的设置中,我们实际上创建了我们想要的层,这只是每个令牌的偏置项和嵌入。

定义的下一部分是当我们使用此模块时调用的默认方法。在这种情况下,我们想传入一对标记,ij;将它们转换为嵌入,x ( i ) , x ( j );然后计算预测的 log(count(y[predicted])。

在这段代码的这一部分,我们遇到了 JAX 和 NumPy 之间的第一个区别——即向量化映射或vmapvmap接受一个函数,并在张量的轴上以相同的方式应用它;这使得编码更容易,因为您只需考虑原始函数如何在较低秩张量(如向量)上操作。在这个例子中,由于我们正在传入一批标记对并对它们进行嵌入,我们实际上有一批向量,因此我们希望在批次维度上运行点积。我们传入 JAX 的 dot 函数,它接受向量,沿批次维度(即轴 0)运行,并告诉vmap将输出作为另一个批次维度(轴 0)返回。这使我们可以简单高效地编写处理低维张量的代码,并通过vmap处理额外的轴获得可以操作高维张量的函数。从概念上讲,这就好像我们在第一维上循环并返回一个点积数组。然而,通过将此过程转换为函数,我们允许 JAX 将此循环推入可即时编译的代码中,在 GPU 上快速运行。

最后,我们还声明助手函数score_all,它接受一个标记并针对所有其他标记进行评分。再次,我们使用vmap来与特定标记的点积,但是运行它与所有其他标记的嵌入。这里的区别在于,因为x ( i )已经是一个向量,我们不需要对其进行vmap。因此,在in_axes中,我们提供[None, 0],这意味着不要在第一个参数的轴上进行vmap,而是在第二个参数的轴 0 上进行vmap,即所有标记的所有嵌入的批处理。然后我们返回结果,这是x ( i )与所有其他嵌入的点积,但没有偏置项。我们在评分中不使用偏置项,因为它被用来吸收非常常见标记的流行度,如果仅使用其点积部分进行评分,我们的评分函数会更有趣。

使用 Optax 训练 GloVE 模型

接下来,让我们看一下wikipedia/train_coocurrence.py。让我们特别看一下调用模型以深入了解一些 JAX 特定细节的部分:

@jax.jit
def apply_model(state, inputs, target):
    """Computes the gradients and loss for a single batch."""

    # Define glove loss.
    def glove_loss(params):
        """The GloVe weighted loss."""
        predicted = state.apply_fn({'params': params}, inputs)
        ones = jnp.ones_like(target)
        weight = jnp.minimum(ones, target / 100.0)
        weight = jnp.power(weight, 0.75)
        log_target = jnp.log10(1.0 + target)
        loss = jnp.mean(jnp.square(log_target - predicted) * weight)
        return loss

    grad_fn = jax.value_and_grad(glove_loss)
    loss, grads = grad_fn(state.params)

    return grads, loss

第一个要注意的是函数装饰器,@jax.jit。这告诉 JAX 函数中的所有内容都可以进行即时编译(JIT)。要使函数可以进行即时编译,有一些要求——主要是函数必须是纯函数,这是计算机科学术语,表示如果你用相同的参数调用函数,你会期望得到相同的结果。该函数不应该有任何副作用,也不应该依赖于缓存状态,比如私有计数器或具有隐式状态的随机数生成器。作为参数传入的张量可能也应该具有固定的形状,因为每个新的形状都会触发新的 JIT 编译。你可以用static_argnums来给编译器一些参数是常量的提示,但这些参数不应该经常变化,否则将花费大量时间为每个常量编译程序。

纯函数哲学的一个结果是模型结构和模型参数是分开的。这样,模型函数是纯函数,参数传递给模型函数,允许模型函数进行即时编译。这就是为什么我们将模型的apply_fn应用于参数,而不是简单地将参数作为模型的一部分的原因。

然后可以编译这个apply_model函数来实现我们之前描述的 GloVE 损失。JAX 提供的另一个新功能是自动计算函数的梯度。JAX 函数value_and_grad计算损失相对于参数的梯度。由于梯度总是指向损失增加的方向,我们可以使用梯度下降去减少损失。Optax 库提供了几种优化器可供选择,包括 SGD(带动量的随机梯度下降)和 ADAM。

当你运行训练程序时,它将循环遍历共现矩阵,并尝试通过使用 GloVE 损失函数生成其简洁形式。大约一个小时后,你应该能够看到得分最高的术语。

例如,“民主”的最近邻包括:民主:1.064498,自由主义:1.024733,改革:1.000746,事务:0.961664,社会主义:0.952792,组织:0.935910,政治:0.919937,政策:0.917884,政策:0.907138,以及--日期:0.889342。

正如你所见,查询令牌本身通常是评分最高的邻居,但这并不一定是真的,因为一个非常受欢迎的令牌实际上可能比查询令牌本身得分更高。

总结

阅读完本章后,你应该对组装推荐系统的基本要素有了很好的概述。你已经学习了如何搭建基本的 Python 开发环境;管理包;使用标志指定输入和输出;以各种方式编码数据,包括使用协议缓冲区;并且用 PySpark 处理数据的分布式框架。你还学会了如何将几十 GB 的数据压缩成几 MB 的模型,这个模型能够在给定查询项的情况下进行泛化并快速评分。

请花些时间去尝试这些代码,并阅读各种引用包的文档,以对基础知识有个清晰的理解。这些基础示例具有广泛的应用,熟练掌握它们将使你的生产环境更加准确。

第三部分:排名

对于给定的推荐,什么是合适的候选人?这些候选人中哪一个是最好的?那么前十名呢?

有时,最好的推荐系统仅仅是物品的可用性,但在大多数情况下,您希望捕捉关于用户偏好的微妙信号,以提供在可能的数百万选项中出色的推荐。个性化是游戏的名字;虽然我们之前专注于与外部含义相关的物品-物品相似性,但我们需要开始尝试推断用户的口味和欲望。

我们最好最终将这变成一个机器学习任务。除了讨论特征和架构之外,我们还需要定义目标函数。乍一看,推荐的目标是简单的二元“他们喜欢吗?” ——所以也许我们只是预测伯努利试验的结果。然而,正如我们在介绍中讨论的那样,有多种方法可以获得他们喜欢程度的信号。此外,在大多数情况下,推荐系统给了一个好处:你有多次机会。通常你可以推荐几个选项,所以我们非常关注他们最喜欢哪些东西的预测。在本书的这一部分,我们将把你学到的一切变成数字。我们还将讨论用于训练和评估模型的显式损失函数。

第九章:基于特征和基于计数的推荐

考虑这个简化的问题:给定一群新用户,预测哪些用户会喜欢我们的新型超级超级有趣新品项,简称为马芬。您可以开始询问哪些老用户喜欢马芬;这些用户是否有任何共同点?如果有,您可以构建一个模型,从这些相关的用户特征预测马芬的亲和力。

或者,您可以问:“人们与马芬一起购买的其他物品是什么?”如果您发现其他人经常还购买果酱(just-awesome-merch),那么马芬对那些已经拥有果酱的人来说可能是一个不错的建议。这将使用马芬和果酱的共现作为预测器。同样,如果您的朋友有与您相似的口味——您都喜欢司康、果酱、饼干和茶——但您的朋友尚未品尝过马芬,如果您喜欢马芬,那么对于您的朋友来说,这可能是一个不错的选择。这是利用您和朋友之间的物品共现。

这些物品关系特征将在本章形成我们的第一种排名方法;所以拿起一份美味的小吃,让我们深入了解一下。

双线性因子模型(度量学习)

根据关于在马前奔跑和在车后行走的通俗语,让我们从可以视为天真的机器学习方法开始我们的排名系统之旅。通过这些方法,我们将开始感受构建推荐系统中的难点所在,以及为什么一些即将进行的努力是必要的。

让我们重新从推荐问题的基本假设开始:估计用户i对物品x的评分,表示为r i,x 。请注意符号与之前稍有不同的原因很快就会明白。 在通常的机器学习范式中,我们可能会声称通过物品和用户的特性来估计这个分数,并且通常这些特性会被描述为特征,因此𝐢 和 𝐱 可以是用户和物品向量,分别由这些特征组成。

现在,我们考虑用户i及其之前互动过的物品集合ℛ i,并考虑ℐ = { 𝐱 | x ∈ ℛ i },这些物品在特征空间中相关联的向量集合。然后,我们可以将这些向量映射到一个表示中,以生成一个用户i的基于内容的特征向量。图 9-1 展示了一个映射示例。

将用户阅读的书籍映射到单个特征向量

图 9-1. 内容到特征向量

这种极其简单的方法可以将一组物品特征和用户-物品交互转化为用户的特征。在接下来的内容中,将会探讨越来越丰富的方法。通过深入思考映射、特征以及交互的需求,为本书的许多关键洞察铺平道路。

让我们将前述映射𝐢 : = F ℐ,视为一个简单的聚合,如维度平均。然后认识到该映射将提供与物品相同维度的向量。现在我们有了一个与物品相同“空间”的用户向量,我们可以像我们在第三章中讨论潜在空间那样提出相似性问题。

我们需要回到数学框架中来建立如何使用这些向量。最终,我们现在处于一个潜在空间中,其中包括用户和物品,但我们怎么能够做任何事情呢?也许你已经记得如何比较向量相似性了。让我们定义相似性为cosine-similarity

s i m ( 𝐢 , 𝐱 ) = 𝐢·𝐱 𝐢*𝐱

如果我们使用向量归一化预先构造我们的相似性,这就是简单的内积—这是推荐系统的一个重要的第一步。为方便起见,让我们假设我们工作的空间是经过归一化的,因此所有相似度测量都是在单位球上进行的:

r i,x ∼ s i m ( 𝐢 , 𝐱 ) = ∑ k 𝐢 k * 𝐱 k

现在这近似了我们的评分。但等等,亲爱的读者,学习参数在哪里?让我们继续,通过一个对角矩阵A,将其变为加权求和:

r i,x ∼ s i m A ( 𝐢 , 𝐱 ) = ∑ k a k 𝐢 k 𝐱 k

这个轻微的泛化已经将我们置于统计学习的世界中。你可能已经看到A如何用来学习这个空间中哪些维度对于逼近评分最重要,但在我们确立之前,让我们再次泛化:

r i,x ∼ s i m A ( 𝐢 , 𝐱 ) = ∑ k,l a kl 𝐢 k 𝐱 l

这给我们带来了更多的参数!现在我们看到 s i m A ( 𝐢 , 𝐱 ) = 𝐢 A 𝐱 ,我们只差一步就到达了线性回归的熟悉领域。目前,我们的模型处于bilinear regression的形式,因此让我们利用一点线性代数知识。为了阐述清楚,让我们设定 𝐢 ∈ ℝ n , 𝐱 ∈ ℝ m ,以及 A ∈ ℝ n×m ,然后我们有如下结果:

𝐯𝐞𝐜𝐭 𝐢 𝐱 T nm

我们可以简化为以下形式:

s i m A ( 𝐢 , 𝐱 ) = 𝐢 A 𝐱 = 𝐯𝐞𝐜𝐭 𝐢 𝐱 T 𝐯𝐞𝐜𝐭 A

如果我们为右侧的符号构造记号,你会发现你的好朋友线性回归正等待着你:

𝐯 ix : = 𝐯𝐞𝐜𝐭 𝐢 * 𝐱 T , β : = 𝐯𝐞𝐜𝐭 A

因此:

r i,x ∼ s i m A ( 𝐢 , 𝐱 ) = 𝐯 ix β

通过这些计算,我们看到,无论我们想计算二进制评分、序数评分还是概率估计,我们线性模型工具箱中的工具都可以派上用场。我们可以利用正则化和优化器,以及线性模型领域中其他任何有趣的东西。

如果这些方程让你感到沮丧或痛苦,让我试着给你提供一个几何心理模型。每个项目和用户都处在一个高维空间中,最终我们试图弄清楚哪些项目彼此最接近。人们经常误解这些几何结构,认为向量的顶点彼此靠近;但事实并非如此。这些空间是极高维的,这导致这种类比与实际情况相去甚远。相反,问问“向量的某些分量是否相似地大”,这是一个更简单但更准确的几何视角:在这极高维的空间中,存在一些子空间,向量指向相同方向。

这构成了我们接下来要探索的基础,但在大规模推荐问题中存在严重限制。然而,基于特征的学习仍然在冷启动阶段有其用武之地。

注意,除了前面介绍的为用户构建基于内容的特征的方法外,我们还可以通过查询用户或通过其他数据收集隐式获取明显的用户特征;这些特征的示例包括位置、年龄范围和身高。

基于特征的启动

正如您在第七章中看到的,除了我们提出的一些协同过滤(CF)和 MF 方法之外,还有多种方法可以在特征旁边使用它们。特别是,您看到了通过两塔架构构建的编码器如何在冷启动场景下用于快速基于特征的推荐。让我们深入研究这一点,认真考虑一下新用户或项目的特征。

在第九章中,我们将我们的双线性因子模型构建为简单的回归,并且事实上,看到所有标准的 ML 建模方法都适用。然而,我们将用户嵌入视为从项目交互中学到的特征:也就是说,基于内容的特征向量。如果我们的目标是构建一个不需要用户评分历史记录的推荐算法,显然这种构建方法不够。

我们可能首先要问的是在纯用户特征设置中前面的因子回归方法是否可行——暂且不管依赖于相互嵌入的内积的担忧,只把一切都看作是纯矩阵。虽然这是一个可以产生一些结果的合理想法,但我们可能很快就会识别出这个模型的粗糙性:每个用户都需要回答一些查询 q k,使得 𝐢 ∈ ℝ k 。因为这些用户向量的维度随着我们愿意和能够问用户的问题数量呈线性增长,我们正在将问题的难度传递给我们的用户体验。

因为我们打算将 CF 通过 MF 作为我们的核心模型,所以我们真的希望找到一种方法,可以从基于特征的模型平滑地过渡到这个 MF,确保我们利用用户/项目评分的出现。在“评估飞轮”中,我们讨论了使用推理结果及其随后的实时结果来更新模型,但在建模范式中如何考虑这一点呢?

在通过 MF 获得的潜在因子模型中,我们有以下内容:

𝐮 i 𝐯 x

在这里,𝐮 i 具有零均值的高斯先验;这就是为什么新用户在没有交互数据之前不会产生有用的评分。因此,我们说 用户矩阵 具有 零集中先验。我们包含特征在我们的 MF 中的第一个策略是简单地构建一个更好的先验分布。

更具数学性:我们学习一个回归模型 G ( 𝐢 ) ∼ 𝐮 i 用于初始化我们学到的因子矩阵,这意味着我们学习以下内容:

s ( i , x ) ∼ 𝐰 ix γ + α i + β x + 𝐮 i 𝐯 x

在这里,我们的𝐰 ix γ现在是从用户和项目特征的标准双线性特征回归,偏差项被学习以估计流行度或等级膨胀,我们熟悉的 MF 术语是𝐮 i 𝐯 x 。

请注意,这种方法提供了一种将特征包含到 MF 模型中的一般策略。我们如何拟合因子-特征模型完全取决于我们,以及我们希望采用的优化方法。

还要注意,与基于回归的方法不同,先验可以通过在纯粹基于特征的嵌入空间中的k最近邻来建立。这种建模策略在 Nor Aniza Abdullah 等人的“为冷启动用户推荐获取辅助信息:一项调查”中得到了详细探讨。与第五章中的基于项目内容的项目-项目推荐器进行比较,其中查询是一个项目,并且在项目空间中的相似性是上一个项目和下一个项目之间的联系。

我们已经确定了一种通过特征构建我们的模型的策略和一系列方法。我们甚至看到我们的 MF 将为新用户服务,只能通过基于特征的模型来拯救它。那么为什么不坚持使用特征呢?为什么要引入因子?

分割模型和混合模型

类似于我们之前讨论的通过特征进行热启动的概念是与之密切相关的基于人口统计的系统。请注意,在这种情况下,“人口统计”不一定指的是个人可识别信息,可以指的是在注册过程中收集到的用户数据。关于书籍推荐的简单例子可能包括用户喜爱的流派、自我识别的价格偏好、书籍长度偏好和喜欢的作者。基于聚类的回归的标准方法可以帮助将一小组用户特征转换为对新用户的推荐。对于这些粗略的用户特征,构建像朴素贝叶斯这样的简单基于特征的模型可能特别有效。

更一般地,鉴于用户特征向量,我们可以制定一个相似度度量,然后使用用户分段进行新用户推荐。这应该与基于特征的推荐器相似,但我们不要求使用用户特征,而是对用户在段中的包含进行建模,然后从段到不同项目构建我们的因子模型。

想象这种方法的一种方式是将建模问题看作是为 C,一个用户群体,估计以下内容:

r C,x : = Avg ( r 𝐢,x ∣ 𝐢 ∈ C )

然后,我们估计 P ( 𝐣 ∈ C ) ,即用户 𝐣 是属于 C 的概率。我们可以轻松想象,我们反而希望使用与每个聚类相关联的概率来构建一个装袋模型,并且每个聚类都对加权平均评分做出贡献。

虽然这些想法可能看起来不像是我们之前构建的有趣扩展,但在实践中,它们对于新用户的快速、可解释的推荐非常有用。

还要注意,这种构建方式并不特定于用户;我们可以考虑将聚类作为物品的层次结构来进行类似的过程。同时使用这些建模方法可以提供简单的用户段到物品组的模型,并且同时利用多种建模方法可以提供重要且灵活的模型。

基于标签的推荐器

项目推荐模型中基于项目的推荐器的一个特殊案例是基于标签的推荐器。当你有一些人工标签并且需要快速将其转换为可工作的推荐器时,这是一个相当常见的首选推荐器。

让我们通过一个玩具例子来详细讨论:你有一个个人数字衣柜,你已经记录了每件衣物的许多特征。你希望你的时尚推荐器给出建议,告诉你在你选择了一件当天穿的衣服后,还可以穿什么。你醒来看到外面下雨了,所以你开始选择一件舒适的羊毛衫。你训练的模型发现羊毛衫具有外套舒适这两个标签,它知道这些标签与裤子温暖很好地相关,因此今天可能会建议你穿厚重的牛仔裤。

标签推荐器的优势在于推荐解释性强且容易理解。缺点是性能直接取决于标记物品所投入的工作量。

让我们讨论一个稍微更复杂的基于标签的推荐器的示例,这是作者之一与 Ashraf Shaik 和 Eric Bunch 合作建立的,用于推荐博客文章。

目标是通过利用由营销团队维护的高质量标签对博客进行分类主题来启动博文推荐器。该系统的一个特殊方面是其富有层次的标签化。特别是,每个标签类型都有多个值,共有 11 种标签类型,每种最多有 10 个值。博客对每个标签类型都有值,有时在一个标签类型的博客中有多个标签。这听起来可能有点复杂,但可以说每篇博文可能包含 47 个标签之一,并且这些标签进一步分组为类型。

其中一个首要任务是利用这些标签构建一个简单的推荐系统,我们确实做了,但这样做意味着在提供了如此高质量的标签数据时会错过一个重要的额外机会:评估我们的嵌入。

首先,我们需要了解如何构建用户嵌入。我们的计划是对用户已经看过的博客嵌入进行平均,这是一种简单的 CF 方法,当您拥有明确的项嵌入时。因此,我们希望为这些博客训练最好的嵌入模型。我们开始考虑像 BERT 这样的模型,但我们不确定高度技术性的内容是否会被我们的嵌入模型有意义地捕获。这使我们意识到,我们可以将标签用作嵌入的分类器数据集。如果我们可以通过训练一个简单的多层感知器(MLP)为每个标签类型执行多标签多分类,其中输入特征是嵌入的维度,那么我们的嵌入空间将很好地捕获内容。

一些嵌入模型的维度不同,有些非常大,因此我们首先使用了尺寸缩减(UMAP)到一个标准尺寸,然后再训练 MLP。我们使用 F1 分数 确定哪个嵌入模型导致了最佳的标签分类模型,并使用视觉检查确保分组符合我们的期望。这项工作做得相当好,并显示了一些嵌入比其他嵌入好得多。

混合化

在上一节中,您看到了如何通过从较简单的模型中获取先验并学习如何过渡来将我们的 MF 与简单模型混合。存在更粗糙的方法来进行此过程的混合

模型的加权组合

这种方法非常强大,权重可以在标准的贝叶斯框架中学习。

多层建模

这种方法可以包括学习一个模型来选择应该使用哪个推荐模型,然后在每个区域学习模型。例如,当用户的历史评分少于 10 个时,我们可以在用户特征上使用基于树的模型,然后在此之后使用 MF。存在各种多层次的方法,包括切换级联,它们分别大致对应于投票和增强。

特征增强

这允许将多个特征向量串联起来,并学习一个更大的模型。根据定义,如果我们希望将来自 CF 的因子向量与特征向量组合,我们将期望有实质性的零度。尽管存在这种零度,但学习使得可以将不同类型的特征相对简单地组合到模型中,并在用户活动的所有区域进行操作。

我们可以以多种有用的方式组合这些模型。然而,我们的立场是,与其使用在不同范式中表现良好的几种模型的更复杂组合,我们将尝试坚持使用相对简单的模型-服务架构,方法如下:

  • 通过基于 MF 的 CF 来训练我们能够得到的最佳模型

  • 使用基于用户和物品特征的模型来解决冷启动问题

看看为什么我们认为基于特征的建模可能不是最佳策略,即使我们通过神经网络和潜在因子模型来实现它。

Bilinear Models 的局限性

我们从描述bilinear modeling方法开始这一章节,但请立即注意——这些都是线性关系。你可能会立即想到:“我的用户和物品的特征之间真的存在线性关系吗?”

对这个问题的答案可能取决于特征的数量,或者也可能不取决于它。无论哪种情况,持怀疑态度是恰当的,在实践中答案是压倒性地否定的。你可能会想,“那么,作为线性近似,MF 也不能成功”,但事实并非如此明确。实际上,MF 表明线性关系是在潜在因子之间,而不是实际特征之间。这种微妙的差别产生了天壤之别的效果。

在我们转向更简单的想法之前,需要强调的一点是,带有非线性激活函数的神经网络可以用来构建基于特征的方法。这个领域取得了一些成功,但最终一个令人惊讶且重要的结果是,神经网络 CF 并没有超越矩阵分解。这并不意味着没有利用 MLP 的基于特征模型的有效方法,但它确实减少了我们对 MF“过于线性”的担忧。那么,为什么不使用更多基于特征的方法呢?

面向内容、人口统计学和其他基于特征的方法的第一个最明显的挑战是获取这些特征。让我们考虑以下双重问题:

用户的特征

如果我们想要收集用户的特征,我们需要向他们提出一系列查询或隐含地推断这些特征。通过外部信号推断这些特征是嘈杂且有限的,但每次向用户提问都增加了用户流失的可能性。当我们考虑用户引导漏斗时,我们知道每个额外的提示或问题都增加了用户未能完成引导的机会。这种效应迅速累积,如果用户不能通过引导,推荐系统将变得不太有用。

物品的特征

在另一方面,为物品创建特征是一项非常手动的任务。尽管许多企业需要执行此任务以服务其他目的,但在许多情况下,这仍然会产生显著成本。如果要使特征发挥作用,它们需要是高质量的,这会增加更多的成本。但更重要的是,如果物品数量极其庞大,成本可能会迅速超出承受范围。对于大规模推荐问题,手动添加特征根本是不可行的。这就是自动特征工程模型可以发挥作用的地方。

这些基于特征的模型中另一个重要问题是可分离性可区分性。如果特征不能很好地区分物品或用户,这些模型就没有用。随着基数的增加,这会导致复合问题。

最后,在许多推荐问题中,我们假设品味或偏好是非常个人化的。我们基本上认为我们对一本书的兴趣与页数和出版日期的关系要比它与我们个人经历的连接更少(我们深表歉意,如果有人是基于页数和出版日期购买了这本书)。虽然 CF 在概念上很简单,但通过共享体验网络更好地传达了这些联系。

计数推荐

在这里,我们将使用最简单的特征类型,即简单计数。计算频率和成对频率将提供一组简单但有用的初始模型。

返回到最受欢迎的物品推荐器

我们之前的超简单方案,实现 MPIR,为我们提供了一个方便的玩具模型,但部署 MPIR 的实际考虑是什么?事实证明,MPIR 为开始基于贝叶斯逼近方法的奖励最大化提供了一个优秀的框架。请注意,在本节中,我们甚至没有考虑个性化推荐;这里的一切都是跨整个用户群体的奖励最大化。我们遵循了 Deepak K. Agarwal 和 Bee-Chung Chen(剑桥大学出版社)在推荐系统的统计方法中的处理。

为了简单起见,让我们将点击率CTR)作为我们要优化的简单度量标准。我们的公式如下:我们有 ℐ = i 可推荐的项目,并且最初仅有一个时间段来进行推荐,我们对分配计划或比例集 x i , ∑ i∈ℐ x i = 1 , 感兴趣。这可以看作是一个非常简单的多臂赌博机问题,其奖励由以下给出:

R ( 𝐱 , 𝐜 ) = ∑ i∈ℐ c i ( N x i )

这里,c i 表示每个项目的点击率先验分布。显而易见,通过将所有推荐分配给具有最大 p i 的项目来最大化奖励,即选择在点击率上最受欢迎的项目。

这个设置明显表明,如果我们对先验有强烈的信心,这个问题似乎很简单。因此,让我们转向一个我们在信心上存在不匹配的案例。

让我们考虑 两个时间段,N 0 和 N 1 ,表示用户访问的次数。请注意,在这个模型中,我们将 0 视为过去,1 视为未来。让我们假设我们只提供 两个物品,并且有点神秘地,对于一个物品,我们对其在每个时间段内的 CTR 有 100% 的信心:分别用 q 0 和 q 1 表示这些比率。相反,我们只有第二个物品的先验:分别用 p 0 ∼ 𝒫 ( θ 0 ) 和 p 1 ∼ 𝒫 ( θ 1 ) 表示这些比率,我们把 θ i 视为状态向量。我们再次用 x i,t 表示分配,这里第二个索引现在指的是时间段。然后我们可以简单地计算预期点击数如下:

𝔼 N 0 x 0 p 0 - q 0 + N 1 x 1 p 1 - q 1 + q 0 N 0 + q 1 N 1

这是通过假设 p 1 关于 x 0 和 p 0 的分布进行优化的。通过假设 p 0 为 gamma 分布,p 1 为正态分布,我们可以将其视为最大化点击的凸优化问题。详见《推荐系统的统计方法》以获取统计学的全面处理。

这个玩具示例在两个维度上扩展,以建模更大的项目集和更多时间窗口,并且为我们提供了相对直观的关于在这一步优化中每个项目和时间步骤的先验关系的理解。

让我们将这个推荐系统放在环境中:我们从项目流行度开始,并推广到一个基于贝叶斯推荐系统,该系统根据用户反馈进行学习。你可以考虑像这样的推荐系统用于非常趋势导向的推荐环境,比如新闻;热门故事通常很重要,但是这可能会迅速改变,我们希望从用户行为中学习。

相关挖掘

我们已经看到如何利用物品特征与推荐之间的相关性,但我们不应忽视物品本身之间的相关性。回想一下我们在[第二章中关于奶酪的早期讨论(图 2-1);我们说我们的协同过滤提供了一种找到相互喜欢的奶酪品味以推荐新奶酪的方法。这建立在评分的概念上,但我们可以从评分中抽象出来,只需看用户选择物品的相关性。你可以想象对于一个电子商务书店来说,用户选择阅读一本书可能对推荐其他书籍有用,即使用户选择不给第一本书评分。我们在第八章中也看到了这种现象,当我们使用维基百科条目中的词元共现时。

我们引入了共现矩阵作为计数的多维数组,其中两个物品 i 和 j 的共现。让我们花点时间更深入地讨论共现。

共现是依赖于上下文的;对于我们的维基百科文章,我们考虑了文章中词元的共现。在电子商务的情况下,共现可以是同一用户购买的两个物品。对于广告,共现可以是用户点击的两个物品,等等。从数学上讲,给定用户和物品,我们为每个用户构建一个关联向量,即一个二元向量,其中每个与之交互的物品是一个独热编码的特征。这些向量堆叠成一个向量,生成一个# ( u s e r s ) × # ( i t e m s ) 矩阵,其中每行是一个用户,每列是一个物品,当用户-物品对有交互时元素等于 1。

为了数学上的精确性,用户-物品关联结构是用户交互集合的集合,y u u∈U ,具有物品 x i i∈I 的集合,其中 U 索引用户,I 索引物品。

相关的用户-物品关联矩阵,𝒰 ,是二进制矩阵,其行由集合索引,列由节点索引,元素如下:

e y u ,x i = 1 x i ∈ y u 0 otherwise

x a 的 共现 和 x b 的 共现 是集合 y u ∣ x a ∈ y u 和 x b ∈ y u 的顺序。我们也可以将其写为一个可以通过简单公式计算的矩阵;令 C ℐ 为共现矩阵——即行和列的索引由 x i i∈I 的元素组成,这些元素是索引的共现。然后我们使用以下公式:

C ℐ = ℐ T * ℐ

正如在 “顾客也买了” 中提到的,我们可以通过考虑共现矩阵的行或列来构建 MPIR 的新变体。条件 MPIR 是返回与用户上次交互的项目 x i 对应行中元素最大值的推荐系统。

在实践中,我们通常将对应于 x i 的行视为 基向量,即一个在 x i 位置有一个非零元素的向量 q x i :

q x i ,j = 1 j = x i 0 otherwise = 0 ⋮ 1 ⋮ 0

然后我们可以考虑前述点 然后我们可以考虑前面点积的最大值——或者甚至是 softmax:

C ℐ = ℐ T · ℐ * q x i

这得出了 x i 和其他每个项目之间的共现计数向量。在这里,我们经常称 q x i 为 查询,以表明它是我们共现推荐模型的输入。

你如何存储这些数据?

我们可以以许多方式思考共现数据。主要原因是因为我们预计用于推荐系统的共现非常稀疏。这意味着矩阵乘法的前述方法——大约是O ( n 3 )——计算非零条目会相对较慢。由于这个原因以及对存储大量充满零的矩阵的担忧,计算机科学家们认真对待了表示稀疏矩阵的问题。

马克斯·格罗斯曼声称有 101 种方法,但实际上只有几种。JAX 支持BCOO,即批处理坐标格式,其本质上是非零元素的坐标列表,以及这些元素的内容。

在我们的二进制交互案例中,那些是 1,而在共现矩阵中,那些是计数。这些矩阵的结构可以写成如下形式:

{
  'indices': indices,
  'values': values,
  'shape': [user_dim, items_dim]
}

通过共现计算的点对点互信息

文章的早期推荐系统使用点对点互信息或 PMI,这与共现密切相关。在自然语言处理的上下文中,PMI 试图表达共现比随机事件更频繁的程度。根据我们之前看到的内容,你可以把这看作是一种归一化的共现模型。计算语言学家经常使用 PMI 作为词汇相似性或词义的估计器,遵循分布假设:

你可以通过它交往的人了解一个词。

约翰·R·弗斯,英国语言学家

在推荐排序的上下文中,具有非常高 PMI 的物品被认为具有高度有意义的共现。因此,可以将其用作互补物品的估计器:一旦您与其中一个互动,您应该与另一个互动。

PMI 通过以下方式计算两个项目的值,x i , x j 。

p(x i ,x j ) p(x i )p(x j ) = C x i ,x j #( total interactions ) #(x i )*#(x j )

PMI 计算允许我们将所有关于共现的工作修改为更规范的计算,因此更有意义。这个过程与我们在“GloVE 模型定义”中学到的 GloVE 模型相关。负 PMI 值使我们能够理解两个事物不经常一起见到的时候。

这些 PMI 计算可以用于推荐购物车中的另一件物品,当添加了一件物品并发现与之具有非常高 PMI 的物品时。可以通过查看用户已经互动过的物品集合,并找到与其中多个物品具有高 PMI 的物品,作为检索方法使用。

让我们看看如何将共现转化为其他相似性度量。

PMI 是一种距离测量吗?

此时需要考虑的一个重要问题是:“两个对象之间的 PMI 是否是一种距离度量?我是否可以直接将相似性定义为两个项目之间的 PMI,从而产生一个便于考虑距离的几何结构?” 答案是否定的。回想一下距离函数的公理之一是三角不等式;一个有用的练习是思考为什么 PMI 不满足三角不等式。

但一切并未失去。在下一节中,我们将向您展示如何从共现结构中制定一些重要的相似度测量。此外,在下一章中,我们将讨论 Wasserstein 距离,它允许您将共现计数直接转换为距离度量。关键的区别在于同时考虑所有其他项目的共现计数作为一个分布。

来自共现的相似性

之前,我们讨论了相似性度量及其如何来自皮尔逊相关性。当我们有明确评级时,皮尔逊相关性是相似性的一个特例,所以让我们看看在没有明确评级时的情况。

考虑与用户关联的发生集合,y u u∈U ,我们定义三个距离度量:

Jaccard 相似度,J a c ( - )

两个用户共享项目的比例与这些用户互动的总项目数之比。

Sørensen-Dice 相似性,D S C ( - )

两个用户共享项目比例的两倍,除以每个用户互动的总项目数之和。

余弦相似度,C o s i m ( - )

两个用户共享项目的比例与每个用户互动的总项目数的乘积之比。

这些都是非常相关的度量指标,具有略有不同的优势。以下是一些需要考虑的要点:

  • Jaccard 相似度是一个真实的距离度量,具有几何上的一些良好特性;其他两者都不是。

  • 所有三者都在区间 0 , 1 内,但您经常会看到余弦相似度通过包括负评级来扩展到 - 1 , 1。

  • Cosine 可以通过将所有交互的极性扩展为 ± 1 ,来适应“赞/踩”的情况。

  • Cosine 可以适应“多次交互”,如果允许向量是非二进制的,并计算用户与物品交互的次数。

  • Jaccard 和 Dice 之间有简单的关系式 S = 2 J / ( 1 + J ) ,你可以轻松地从一个计算出另一个。

请注意,我们已经定义了所有这些用户之间的相似度量。我们将在下一节中展示如何将这些定义扩展到物品,并将其转化为推荐。

基于相似度的推荐

在上述每个距离度量中,我们定义了一个相似度量,但我们还没有讨论相似度量如何转化为推荐。正如我们在 “最近邻居” 中讨论的那样,我们在检索步骤中利用相似度量;我们希望找到一个空间,在该空间中彼此“接近”的物品是良好的推荐。在排名的背景下,我们的相似度量可以直接用来按照推荐的相关性顺序排序。在下一章中,我们将更多地讨论相关性度量。

在前面的部分,我们看了三种相似度分数,但我们需要扩展这些度量的相关集合的概念。让我们以 Jaccard 相似度为原型考虑一下。

给定一个用户 y u 和一个未见的物品 x i ,让我们问:“这个用户和物品之间的 Jaccard 相似度是多少?” 让我们记住,Jaccard 相似度是两个集合之间的相似度,在定义中,这些集合都是 用户交互的发生集。以下是使用这种方法进行推荐的三种方式:

用户-用户

使用我们前面的定义,找到具有最大 Jaccard 相似度的 k 个用户。计算这些用户中与 x i 互动的百分比。您可能还希望按照物品 x i 的流行度对此进行归一化。

物品-物品

计算每个项目与之交互的用户集合,并根据这些项目-用户发生集的 Jaccard 相似性计算与x i最相似的k个项目。计算这些项目中属于y u的交互集的百分比。您可能还希望通过y u的总交互或相似项目的流行度对其进行归一化。

用户-项目

计算用户y u的已互动项目集合,以及任何用户互动集的x i共现的项目集。计算这两个集合之间的 Jaccard 相似性。

在设计排名系统时,我们经常指定查询,这指的是您要查找的最近邻居。然后我们指定如何使用这些邻居来生成推荐。可能成为推荐的项目是候选项,但正如您在前面的例子中看到的,邻居可能不是候选项本身。另一个复杂之处在于,您通常需要同时计算许多候选项分数,这需要我们将在第十六章中看到的优化计算。

概要

在本章中,我们已经开始深入探讨相似性的概念——建立在我们从检索中的直觉上,即用户的偏好可能通过他们已经展示的互动来捕捉。

我们从基于用户特征的简单模型开始,并建立线性模型将其与我们的目标结果相关联。然后,我们将这些简单模型与特征建模的其他方面和混合系统结合起来。

接下来,我们开始讨论计数,特别是计算项目、用户或购物篮的共现次数。通过查看频繁共现,我们可以构建捕捉“如果你喜欢a,你可能会喜欢b”的模型。这些模型简单易懂,但我们可以使用这些基本的相关结构构建相似度度量,从而建立 ANN 检索能够产生良好推荐候选项的潜在空间。

您可能已经注意到有关所有项目的特征化和构建我们的共现矩阵的一点是特征的数量 astronomically large ——每个项目一个维度!这是我们将在下一章中探讨的研究领域:如何减少您的潜在空间的维度。

第十章:低秩方法

在前一章中,我们对处理这么多特征的挑战感到惋惜。通过让每个项目成为其自己的特征,我们能够表达大量关于用户偏好和项目相关性的信息,但在维度灾难方面我们陷入了麻烦。结合非常稀疏特征的现实,您面临危险。在本章中,我们将转向更小的特征空间。通过将用户和项目表示为低维向量,我们可以更有效地捕捉它们之间的复杂关系。这使我们能够为用户生成更个性化和相关的推荐,同时减少推荐过程的计算复杂性。

我们将探讨使用低维嵌入的方法,并讨论此方法的优势以及部分实现细节。我们还将查看在 JAX 中使用现代基于梯度的优化来减少项目或用户表示维度的代码。

潜在空间

您已经熟悉特征空间,通常是数据的分类或矢量值直接表示。这可以是图像的原始红色、绿色和蓝色值,直方图中项目的计数,或者对象的属性如长度、宽度和高度。另一方面,潜在特征不代表任何特定的实值特征,而是随机初始化,然后根据任务进行学习。我们在第八章中讨论的 GloVe 嵌入就是一个学习表示单词对数计数的潜在向量的例子。在这里,我们将介绍生成这些潜在特征或嵌入的更多方式。

专注于您的“优势”

本章非常依赖于线性代数,因此在继续之前,熟悉向量、点积和向量范数是很好的。了解矩阵及其秩也将非常有用。考虑阅读《线性代数及其应用》(Linear Algebra and Its Applications),作者 Gilbert Strang。

潜在空间如此受欢迎的原因之一是它们通常比它们所代表的特征低维。例如,如果用户-物品评分矩阵或交互矩阵(其中矩阵条目为 1,如果用户与物品有交互)是 N × M 维度的,那么将矩阵分解为 N × KK × M 的潜在因子,其中 K 远小于 NM,是对缺失条目的一种近似,因为我们放宽了因子分解。 KNM 小通常被称为信息瓶颈—也就是说,我们正在强制矩阵由一个小得多的矩阵组成。这意味着 ML 模型必须填补缺失的条目,这对于推荐系统可能是有利的。只要用户与足够相似的物品有交互,通过强制系统在自由度方面容量大大减少,然后因子分解可以完全重构矩阵,并且缺失的条目往往会被相似的物品填补。

例如,当我们将一个 4 × 4 的用户-物品矩阵分解为一个 4 × 2 和一个 2 × 4 的向量时,会发生什么。

我们提供的矩阵的行是用户,列是物品。例如,第 0 行是 [1, 0, 0, 1],这意味着用户 0 选择了物品 0 和物品 3。这些可以是评分或购买。现在让我们看一些代码:

import numpy as np

a = np.array([
    [1, 0, 0 ,1],
    [1, 0, 0 ,0],
    [0, 1, 1, 0],
    [0, 1, 0, 0]]
)

u, s, v = np.linalg.svd(a, full_matrices=False)

# Set the last two eigenvalues to 0.
s[2:4] = 0
print(s)
b = np.dot(u * s, v)
print(b)

# These are the eigenvalues with the smallest two set to 0.
s = [1.61803399 1.61803399 0.         0.        ]

# This is the newly reconstructed matrix.
b = [[1.17082039 0.         0.         0.7236068 ]
 [0.7236068  0.         0.         0.4472136 ]
 [0.         1.17082039 0.7236068  0.        ]
 [0.         0.7236068  0.4472136  0.        ]]

请注意,现在第 1 行的用户在第 3 列有一个物品的分数,并且第 3 行的用户对第 2 列的物品有正分数。这种现象通常被称为矩阵补全,对于推荐系统来说是一个很好的特性,因为现在我们可以向用户推荐新的物品。通过强制让 ML 通过比其尝试重建的矩阵大小更小的瓶颈的一般方法被称为低秩近似,因为近似的秩为 2,但原始用户-物品矩阵的秩为 4。

矩阵的秩是什么?

N × M 矩阵可以被视为 N 行向量(对应用户)和 M 列向量(对应物品)。当你考虑 M 维度中 N 向量时,矩阵的秩 是由 N 个向量在 M 维度中定义的多面体的体积。然而,这与我们讨论矩阵秩的方式通常不同。虽然这是最自然和精确的定义,但我们却说它是“表示矩阵向量所需的最小维数。”

我们将在本章后面更详细地介绍 SVD。这只是为了激发你对理解潜在空间如何与推荐系统相关的兴趣。

点积相似度

在第三章中,我们介绍了相似度测量,但现在我们回到点积的概念,因为在潜在空间中它们变得更加重要。毕竟,潜在空间建立在距离即相似性的假设上。

在推荐系统中,点积相似度有意义,因为它提供了在潜在空间中用户与物品之间关系的几何解释(或者可能是物品与物品、用户与用户等)。在推荐系统的背景下,点积可以看作是一个向量在另一个向量上的投影,指示用户偏好与物品特性之间的相似度或对齐程度。

要理解点积的几何意义,考虑两个向量 up,分别表示潜在空间中的用户和产品。这两个向量的点积可以定义如下:

u × p = | | u | | | | p | | c o s ( θ )

这里,||u|| 和 ||p|| 表示用户向量和产品向量的大小,θ 表示它们之间的角度。因此,点积是一个衡量一个向量在另一个向量上投影的度量,其值受到两个向量大小的影响。

余弦相似度是推荐系统中另一个流行的相似度度量,直接源于点积:

c o s i n e s i m i l a r i t y ( u , p ) = (u×p) (||u||||p||)

余弦相似度的范围从 -1 到 1,其中 -1 表示完全不相似的偏好和特性。0 表示没有相似度,1 表示用户偏好与产品特性完全对齐。在推荐系统的背景下,余弦相似度提供了一个归一化的相似度度量,不受用户和产品向量大小的影响。需要注意,选择使用余弦相似度还是 L2 距离取决于您使用的嵌入类型以及优化计算的方式。在实践中,通常最重要的特征是相对值。

在推荐系统中,点积(以及余弦相似度)的几何解释是捕捉用户偏好与产品特性之间的对齐程度。如果用户向量和产品向量之间的角度很小,则用户偏好与产品特性很好地对齐,从而导致更高的相似度分数。相反,如果角度很大,则用户偏好与产品特性不相似,导致较低的相似度分数。通过将用户和物品向量投影到彼此上,点积相似度可以捕捉用户偏好与物品特性之间的对齐程度,从而使推荐系统能够识别最有可能与用户相关和吸引的物品。

传闻中,点积似乎捕捉到了流行度,因为非常长的向量倾向于容易投影到任何不完全垂直或指向远离的物体上。因此,存在一种权衡,即频繁推荐具有较大向量长度的热门物品与具有较小余弦距离角度差的长尾物品之间的权衡。

图 10-1 考虑了两个向量,ab。使用余弦相似度时,向量是单位长度的,所以角度就是相似度的度量。然而,使用点积时,一个非常长的向量像 c 可能会被认为比 b 更相似 a,尽管 ab 之间的角度更小,因为 c 的长度更长。这些长向量往往是与许多其他项目共同出现的非常流行的项目。

余弦 vs 点积相似度

图 10-1. 余弦相似度与点积相似度

共现模型

在我们的维基百科共现示例中,我们确定了两个项目之间的共现结构可以用来生成相似度测量。我们讨论了如何使用 PMI 可以获取共现计数并基于购物车中的项与其他项之间非常高的互信息做出推荐。

正如我们所讨论的,PMI 不是一个距离度量,但基于共现仍具有重要的相似度度量。让我们回到这个话题。

之前提到过,PMI 的定义如下:

p(x i ,x j ) p(x i )p(x j ) = C x i ,x j #totalinteractions #(x i )*#(x j )

现在让我们考虑 离散共现分布,C D x i ,定义为所有其他 x j 的共现的集合:

C D x i = C ℐ x i ,x 1 , ... , C ℐ x i ,x i , ... , C ℐ x i ,x N

在这里,j ∈ 1 ... N ,而 N 是所有项目的总数。这表示 x i 与所有其他项目之间的共现直方图。通过引入这种离散分布,我们可以利用另一个工具:Hellinger 距离。

我们可以用几种方法来衡量分布距离,每种方法都有不同的优势。在我们的讨论中,我们不会深入探讨这些差异,而是坚持使用最简单但最合适的方法。Hellinger 距离的定义如下:

H ( P , Q ) = 1 - ∑ i n p i q i = 1 2 P-Q 2

P = p i 和 Q = q i 是两个概率密度向量。在我们的设定中,P 和 Q 可以是 C D x i 和 C D x j 。

这个过程背后的动机是,我们现在有了基于共现的物品之间的适当距离。我们可以在这种几何上使用任何维度转换或减少。稍后我们将展示可以使用任意距离矩阵并将空间减少到逼近它的低维嵌入的维度减少技术。

测度空间和信息理论呢?

当我们讨论分布时,你可能会想知道,“是否存在一种距离使得分布在潜在空间中是点?”哦,你没有想到?好吧,我们无论如何会解决这个问题。

简短的答案是,我们可以衡量分布之间的差异。最流行的是库尔巴克-莱布勒(KL)散度,通常在贝叶斯意义下描述为预期分布 Q 时看到分布 P 的惊讶程度。然而,KL 不是一个适当的距离度量,因为它是非对称的。

另一个具有良好性质的对称距离度量是海林格距离。海林格距离实际上是二范数的测度论距离。此外,海林格距离自然地推广到离散分布。

如果这仍然没有解决你对抽象的渴望,我们还可以考虑总变分距离,这是在费舍尔精确距离测度空间中的极限,这实际上意味着它具有两个分布距离的所有良好性质,并且没有测量会认为它们更不同。嗯,所有的良好性质除了一个:它不平滑。如果你还希望具有平滑性和可微性,你需要通过偏移来近似它。

如果你需要计算分布之间的距离,可以使用海林格距离。

减少推荐问题的排名

我们已经表明,随着物品和用户数量的增加,我们推荐问题的维度迅速增加。因为我们将每个项目和用户表示为一列或向量,这类似于n 2的规模。推迟这一困难的一种方法是通过降秩;回想我们之前关于通过因子分解进行降秩的讨论。

像许多整数一样,许多矩阵可以被分解成更小的矩阵;对于整数来说,更小意味着更小的值,对于矩阵来说,更小意味着更小的维度。当我们分解一个N × M矩阵时,我们会寻找两个矩阵U N×d和V d×M;请注意,当您将矩阵相乘时,它们必须共享一个维度,并且该维度被消除,留下另外两个维度。在这里,我们将考虑当d ≤ N和d ≤ M时的 MF。通过分解矩阵,我们要求两个矩阵,它们可以等于或近似于原始矩阵:

A i,j ≃ U i , V j

我们寻求一个小的值d来减少潜在维度的数量。正如您可能已经注意到的那样,每个矩阵U N×d和V d×M将对应于原始评分矩阵的行或列。然而,它们以更少的维度表示。这利用了低维潜在空间的概念。直观地说,潜在空间旨在以两组关系表示与完整N × M维关系相同的关系:项目与潜在特征之间的关系,以及用户与潜在特征之间的关系。

这些方法在其他类型的机器学习中也很受欢迎,但对于我们的案例,我们主要将研究对评分或交互矩阵进行因子分解的问题。

考虑 MF 时,您经常必须克服一些挑战:

  • 您希望分解的矩阵是稀疏的,并且通常是非负的和/或二进制的。

  • 每个项目向量中的非零元素数量可能会有很大的变化,正如我们在马太效应中看到的那样。

  • 矩阵分解的复杂度是立方的。

  • SVD 和其他全秩方法在没有填补的情况下无法工作,填补本身也很复杂。

我们将通过一些替代优化方法来解决这些问题。

优化 MF 与 ALS

我们希望执行的基本优化如下:

A i,j ≃ U i , V j

值得注意的是,如果您希望直接优化矩阵条目,则需要同时优化d 2 N M个元素,这些元素对应于这些因子化中的参数数量。然而,通过交替调整一个或另一个矩阵,我们可以轻松实现显著的加速。这称为交替最小二乘,通常简称为ALS,这是解决此问题的一种常见方法。与在每次传递中向两个矩阵的所有项后向传播更新不同,您可以仅更新两个矩阵中的一个,从而大大减少需要进行的计算数量。

ALS 试图在U和V之间来回切换,评估相同的损失函数,但每次仅更新一个矩阵的权重:

U ← U - η U ∇ U 𝒟 A , U V V V - η V V 𝒟 A , U V

在这里,η是学习率,𝒟是我们选择的距离函数。我们稍后会详细介绍这个距离函数。在我们继续之前,让我们考虑这里的一些复杂性:

  • 每个更新规则都需要相对于相关因子矩阵的梯度。

  • 我们一次更新一个完整的因子矩阵,但在因子矩阵的乘积与原始矩阵之间评估损失。

  • 我们有一个神秘的距离函数。

  • 通过我们构建这种优化的方式,我们隐含地假设我们将使用这个过程来使两个近似的矩阵收敛(通常还会对迭代次数施加限制)。

在 JAX 中,这些优化将很容易实现,并且我们将看到等式形式和 JAX 代码看起来有多么相似。

矩阵之间的距离

我们可以以多种方式确定两个矩阵之间的距离。正如我们之前所见,对于向量的不同距离测量从底层空间提供了不同的解释。对于这些计算,我们不会有太多复杂性,但这值得一小段观察。最明显的方法是你已经看到的观察到的均方误差

∑ Ω A i,j -U i ,V j 2 1Ω|

当用户向量只有一个非零条目(或者说最大评分)时,观察到的均方误差的一个有用替代方法是使用交叉熵损失,这样可以提供一个逻辑 MF,从而得到概率估计。有关如何实现此操作的更多详细信息,请参阅 Kyle Chung 的“推荐系统的矩阵分解”教程

在我们观察到的评分中,我们预期(并看到!)有大量缺失值和一些具有过多评分的项目向量。这表明我们应该考虑非均匀加权的矩阵。接下来我们将讨论如何通过正则化来考虑这些和其他变体。

MF 的正则化

加权交替最小二乘(WALS)与 ALS 类似,但试图更优雅地解决这两个数据问题。在 WALS 中,分配给每个观察到的评分的权重与用户或项目的观察到的评分数成反比。因此,对于评分较少的用户或项目的观察到的评分在优化过程中被赋予更大的权重。

我们可以将这些权重作为我们最终损失函数中的正则化参数应用:

∑ Ω A i,j -<U i ,V j > 2 |Ω| + 1 N ∑ | U |

其他正则化方法对于 MF 也很重要,并且很受欢迎。我们将讨论这两种强大的正则化技术:

  • 权重衰减

  • Gramian 正则化

像往常一样,权重衰减 是我们的 l 2 正则化,这种情况下是 Frobenius 范数的水平,即权重矩阵的大小。一个优雅的观点是,这种权重衰减是在最小化奇异值的大小。

类似地,MF 还有另一种看起来非常标准但计算方式完全不同的正则化技术。这是通过 Gramians——本质上是正则化单个矩阵条目的大小,但在优化中有一个优雅的技巧。特别地,矩阵 U 的 Gramian 是乘积 U T U 。敏锐的读者可能会认出这个术语,因为它与我们先前用于计算二进制矩阵的共现的术语相同。联系在于两者都只是试图找到矩阵行和列之间点积的有效表示方式。

这些正则化是 Frobenius 项:

R ( U , V ) = 1 N ∑ i N | U i | 2 2 + 1 M ∑ j M |V j | 2 2

或者,展开来说,方程看起来像这样:

R ( U , V ) = 1 N ∑ i N ∑ k d U i,k 2 + 1 M ∑ j M ∑ l d V j,l 2

这些是 Gramian 项:

G ( U , V ) : = 1 N·M ∑ i N ∑ j M U i ,V j 2 = 1 N·M k,l d U T UV T V k,l .

最后,我们有我们的损失函数:

1 |Ω| ∑ (i,j)∈Ω (A ij -〈U i ,V j 〉) 2 + λ R 1 N ∑ i N ∑ k d U i,k 2 + 1 M ∑ j M ∑ l d V j,l 2 + λ G 1 N·M k,l d U T UV T V k,l

正则化的 MF 实现

到目前为止,我们已经写了很多数学符号,但所有这些符号都使我们能够得出一个非常强大的模型。正则化矩阵因子分解是一个有效的中等规模推荐问题模型。这种模型类型在许多严肃的企业中仍然在使用。MF 实现的一个经典问题是性能,但因为我们使用 JAX,在这里有极好的本地 GPU 支持,我们的实现实际上可以比像PyTorch 示例中找到的更紧凑。

让我们通过 Gramians 的双重正则化模型来预测用户-项目矩阵中的评分会是怎样的模型。

首先,我们将进行简单的设置。这将假设您的评分矩阵已经在 wandb 上了:

import jax
import jax.numpy as jnp
import numpy as np
import pandas as pd
import os, json, wandb, math

from jax import grad, jit
from jax import random
from jax.experimental import sparse

key = random.PRNGKey(0)

wandb.login()
run = wandb.init(
    # Set entity to specify your username or team name
    entity="wandb-un",
    # Set the project where this run will be logged
    project="jax-mf",
    # associate the runs to the right dataset
    config={
      "dataset": "MF-Dataset",
    }
)

# note that we assume the dataset is a ratings table stored in wandb
artifact = run.use_artifact('stored-dataset:latest')
ratings_artifact = artifact.download()
ratings_artifact_blob = json.load(
    open(
        os.path.join(
            ratings_artifact,
            'ratings.table.json'
        )
    )
)

ratings_artifact_blob.keys()
# ['_type', 'column_types', 'columns', 'data', 'ncols', 'nrows']

ratings = pd.DataFrame( # user_id, item_id, rating, unix_timestamp
    data=ratings_artifact_blob['data'],
    columns=ratings_artifact_blob['columns']
)

def start_pipeline(df):
    return df.copy()

def column_as_type(df, column: str, cast_type):
    df[column] = df[column].astype(cast_type)
    return df

def rename_column_value(df, target_column, prior_val, post_val):
    df[target_column] = df[target_column].replace({prior_val: post_val})
    return df

def split_dataframe(df, holdout_fraction=0.1):
    """Splits a DataFrame into training and test sets.
 Args:
 df: a dataframe.
 holdout_fraction: fraction of dataframe rows to use in the test set.
 Returns:
 train: dataframe for training
 test: dataframe for testing
 """
    test = df.sample(frac=holdout_fraction, replace=False)
    train = df[~df.index.isin(test.index)]
    return train, test

all_rat = (ratings
    .pipe(start_pipeline)
    .pipe(column_as_type, column='user_id', cast_type=int)
    .pipe(column_as_type, column='item_id', cast_type=int)
)

def ratings_to_sparse_array(ratings_df, user_dim, item_dim):
    indices = (np.array(ratings_df['user_id']), np.array(ratings_df['item_id']))
    values = jnp.array(ratings_df['rating'])

    return {
        'indices': indices,
        'values': values,
        'shape': [user_dim, item_dim]
    }

def random_normal(pr_key, shape, mu=0, sigma=1, ):
    return (mu + sigma * random.normal(pr_key, shape=shape))

x = random_normal(
    pr_key = random.PRNGKey(1701),
    shape=(10000,),
    mu = 1.0,
    sigma = 3.0,
) # these hyperparameters are pretty meaningless

def sp_mse_loss(A, params):
    U, V = params['users'], params['items']
    rows, columns = A['indices']
    estimator = -(U @ V.T)[(rows, columns)]
    square_err = jax.tree_map(
        lambda x: x**2,
        A['values']+estimator
    )
    return jnp.mean(square_err)

omse_loss = jit(sp_mse_loss)

请注意,我们不得不在这里实现我们自己的损失函数。这是一个相对简单的均方误差(MSE)损失,但它利用了我们矩阵的稀疏性质。你可能会在代码中注意到,我们已经将矩阵转换为稀疏表示,因此我们的损失函数不仅可以利用该表示,还可以编写为利用 JAX 设备数组和映射/编译的形式。

这个损失函数真的正确吗?

如果你对这种像魔术般出现的损失函数感到好奇,我们理解。在写这本书的时候,我们对利用 JAX 的这种损失函数的最佳实现非常不确定。实际上,有很多合理的方法可以进行这种优化。为此,我们编写了一个公共实验来对几种方法进行基准测试在 Colab 上

接下来,我们需要构建模型对象来处理我们训练时的 MF 状态。尽管这段代码基本上大多是模板代码,但它会以相对内存高效的方式为我们设置好,以便将模型馈送到训练循环中。这个模型是在一台 MacBook Pro 上,在不到一天的时间里,对 100 万条记录进行了几千次迭代的训练:

class CFModel(object):
    """Simple class that represents a collaborative filtering model"""
    def __init__(
          self,
          metrics: dict,
          embeddings: dict,
          ground_truth: dict,
          embeddings_parameters: dict,
          prng_key=None
    ):
        """Initializes a CFModel.
 Args:
 """
        self._metrics = metrics
        self._embeddings = embeddings
        self._ground_truth = ground_truth
        self._embeddings_parameters = embeddings_parameters

        if prng_key is None:
            prng_key = random.PRNGKey(0)
        self._prng_key = prng_key

    @property
    def embeddings(self):
        """The embeddings dictionary."""
        return self._embeddings

    @embeddings.setter
    def embeddings(self, value):
        self._embeddings = value

    @property
    def metrics(self):
        """The metrics dictionary."""
        return self._metrics

    @property
    def ground_truth(self):
        """The train/test dictionary."""
        return self._ground_truth

    def reset_embeddings(self):
        """Clear out embeddings state."""

        prng_key1, prng_key2 = random.split(self._prng_key, 2)

        self._embeddings['users'] = random_normal(
            prng_key1,
            [
              self._embeddings_parameters['user_dim'],
              self._embeddings_parameters['embedding_dim']
            ],
            mu=0,
            sigma=self._embeddings_parameters['init_stddev'],
        )
        self._embeddings['items'] = random_normal(
            prng_key2,
            [
              self._embeddings_parameters['item_dim'],
              self._embeddings_parameters['embedding_dim']],
            mu=0,
            sigma=self._embeddings_parameters['init_stddev'],
        )

def model_constructor(
    ratings_df,
    user_dim,
    item_dim,
    embedding_dim=3,
    init_stddev=1.,
    holdout_fraction=0.2,
    prng_key=None,
    train_set=None,
    test_set=None,
):
    if prng_key is None:
      prng_key = random.PRNGKey(0)

    prng_key1, prng_key2 = random.split(prng_key, 2)

    if (train_set is None) and (test_set is None):
        train, test = (ratings_df
            .pipe(start_pipeline)
            .pipe(split_dataframe, holdout_fraction=holdout_fraction)
        )

        A_train = (train
            .pipe(start_pipeline)
            .pipe(ratings_to_sparse_array, user_dim=user_dim, item_dim=item_dim)
        )
        A_test = (test
            .pipe(start_pipeline)
            .pipe(ratings_to_sparse_array, user_dim=user_dim, item_dim=item_dim)
        )
    elif (train_set is None) ^ (test_set is None):
        raise('Must send train and test if sending one')
    else:
        A_train, A_test = train_set, test_set

    U = random_normal(
        prng_key1,
        [user_dim, embedding_dim],
        mu=0,
        sigma=init_stddev,
    )
    V = random_normal(
        prng_key2,
        [item_dim, embedding_dim],
        mu=0,
        sigma=init_stddev,
    )

    train_loss = omse_loss(A_train, {'users': U, 'items': V})
    test_loss = omse_loss(A_test, {'users': U, 'items': V})

    metrics = {
        'train_error': train_loss,
        'test_error': test_loss
    }
    embeddings = {'users': U, 'items': V}
    ground_truth = {
        "A_train": A_train,
        "A_test": A_test
    }
    return CFModel(
        metrics=metrics,
        embeddings=embeddings,
        ground_truth=ground_truth,
        embeddings_parameters={
            'user_dim': user_dim,
            'item_dim': item_dim,
            'embedding_dim': embedding_dim,
            'init_stddev': init_stddev,
        },
        prng_key=prng_key,
    )

mf_model = model_constructor(all_rat, user_count, item_count)

我们还应该设置好,以便在 wandb 上进行良好的记录,这样在训练过程中就很容易理解发生了什么:

def train():
  run_config = { # These will be hyperparameters we will tune via wandb
      'emb_dim': 10, # Latent dimension
      'prior_std': 0.1, # Std dev around 0 for weights initialization
      'alpha': 1.0, # Learning rate
      'steps': 1500, # Number of training steps
  }

  with wandb.init() as run:
    run_config.update(run.config)
    model_object = model_constructor(
        ratings_df=all_rat,
        user_dim=user_count,
        item_dim=item_count,
        embedding_dim=run_config['emb_dim'],
        init_stddev=run_config['prior_std'],
        prng_key=random.PRNGKey(0),
        train_set=mf_model.ground_truth['A_train'],
        test_set=mf_model.ground_truth['A_test']
    )
    model_object.reset_embeddings() # Ensure we are starting from priors
    alpha, steps = run_config['alpha'], run_config['steps']
    print(run_config)
    grad_fn = jax.value_and_grad(omse_loss, 1)
    for i in range(steps):
      # We perform one gradient update
      loss_val, grads = grad_fn(
          model_object.ground_truth['A_train'],
          model_object.embeddings
      )
      model_object.embeddings = jax.tree_multimap(
          lambda p, g: p - alpha * g,
          # Basic update rule; JAX handles broadcasting for us
          model_object.embeddings,
          grads
      )
      if i % 1000 == 0: # Most output in wandb; little bit of logging
        print(f'Loss step {i}: ', loss_val)
        print(f"""Test loss: {
            omse_loss(
                model_object.ground_truth['A_train'],
                model_object.embeddings
            )}""")

      wandb.log({
          "Train omse": loss_val,
          "Test omse": omse_loss(
              model_object.ground_truth['A_test'],
              model_object.embeddings
           )
      })

请注意,此代码正在使用tree_multimap来处理广播我们的更新规则,并且我们正在调用以前的omse_loss调用中的被 jit 的损失。另外,我们正在调用value_and_grad,这样我们就可以在进行时将损失记录到 wandb 中。这是一个常见的技巧,你会看到这样的技巧既可以高效地做到这两点,又可以不使用回调。

您可以完成这个过程,并开始进行扫描:

sweep_config = {
    "name" : "mf-test-sweep",
    "method" : "random",
    "parameters" : {
        "steps" : {
            "min": 1000,
            "max": 3000,
        },
        "alpha" :{
            "min": 0.6,
            "max": 1.75
        },
        "emb_dim" :{
            "min": 3,
            "max": 10
        },
        "prior_std" :{
            "min": .5,
            "max": 2.0
        },
    },
    "metric" : {
        'name': 'Test omse',
        'goal': 'minimize'
    }
}

sweep_id = wandb.sweep(sweep_config, project="jax-mf", entity="wandb-un")

wandb.init()
train()

count = 50
wandb.agent(sweep_id, function=train, count=count)

在这种情况下,超参数优化(HPO)是关于我们的超参数,如嵌入维度和先验(随机矩阵)的。到目前为止,我们已经在我们的评分矩阵上训练了一些 MF 模型。现在让我们添加正则化和交叉验证。

让我们直接将前述数学方程翻译成代码:

def ell_two_regularization_term(params, dimensions):
    U, V = params['users'], params['items']
    N, M = dimensions['users'], dimensions['items']
    user_sq = jnp.multiply(U, U)
    item_sq = jnp.multiply(V, V)
    return (jnp.sum(user_sq)/N + jnp.sum(item_sq)/M)

l2_loss = jit(ell_two_regularization_term)

def gramian_regularization_term(params, dimensions):
    U, V = params['users'], params['items']
    N, M = dimensions['users'], dimensions['items']
    gr_user = U.T @ U
    gr_item = V.T @ V
    gr_square = jnp.multiply(gr_user, gr_item)
    return (jnp.sum(gr_square)/(N*M))

gr_loss = jit(gramian_regularization_term)

def regularized_omse(A, params, dimensions, hyperparams):
  lr, lg = hyperparams['ell_2'], hyperparams['gram']
  losses = {
      'omse': sp_mse_loss(A, params),
      'l2_loss': l2_loss(params, dimensions),
      'gr_loss': gr_loss(params, dimensions),
  }
  losses.update({
      'total_loss': losses['omse'] + lr*losses['l2_loss'] + lg*losses['gr_loss']
  })
  return losses['total_loss'], losses

reg_loss_observed = jit(regularized_omse)

我们不会深入研究学习率调度器,但我们会做一个简单的衰减:

def lr_decay(
    step_num,
    base_learning_rate,
    decay_pct = 0.5,
    period_length = 100.0
):
    return base_learning_rate * math.pow(
        decay_pct,
        math.floor((1+step_num)/period_length)
    )

我们更新的训练函数将整合我们的新正则化 - 这些正则化带有一些超参数 - 以及一些额外的日志设置。这段代码使得在训练过程中记录我们的实验并配置超参数以与正则化一起工作变得容易:

def train_with_reg_loss():
    run_config = { # These will be hyperparameters we will tune via wandb
        'emb_dim': None,
        'prior_std': None,
        'alpha': None, # Learning rate
        'steps': None,
        'ell_2': 1, #l2 regularization penalization weight
        'gram': 1, #gramian regularization penalization weight
        'decay_pct': 0.5,
        'period_length': 100.0
    }

    with wandb.init() as run:
        run_config.update(run.config)
        model_object = model_constructor(
            ratings_df=all_rat,
            user_dim=942,
            item_dim=1681,
            embedding_dim=run_config['emb_dim'],
            init_stddev=run_config['prior_std'],
            prng_key=random.PRNGKey(0),
            train_set=mf_model.ground_truth['A_train'],
            test_set=mf_model.ground_truth['A_test']
        )
        model_object.reset_embeddings() # Ensure we start from priors

        alpha, steps = run_config['alpha'], run_config['steps']
        print(run_config)

        grad_fn = jax.value_and_grad(
            reg_loss_observed,
            1,
            has_aux=True
        ) # Tell JAX to expect an aux dict as output

        for i in range(steps):
            (total_loss_val, loss_dict), grads = grad_fn(
                model_object.ground_truth['A_train'],
                model_object.embeddings,
                dimensions={'users': user_count, 'items': item_count},
                hyperparams={
                    'ell_2': run_config['ell_2'],
                    'gram': run_config['gram']
                } # JAX carries our loss dict along for logging
            )

            model_object.embeddings = jax.tree_multimap(
                lambda p, g: p - lr_decay(
                    i,
                    alpha,
                    run_config['decay_pct'],
                    run_config['period_length']
                ) * g, # update with decay
                model_object.embeddings,
                grads
            )
            if i % 1000 == 0:
                print(f'Loss step {i}:')
                print(loss_dict)
                print(f"""Test loss: {
                    omse_loss(model_object.ground_truth['A_test'],
                    model_object.embeddings)}""")

            loss_dict.update( # wandb takes the entire loss dictionary
                {
                    "Test omse": omse_loss(
                        model_object.ground_truth['A_test'],
                        model_object.embeddings
                    ),
                    "learning_rate": lr_decay(i, alpha),
                }
            )
            wandb.log(loss_dict)

 sweep_config = {
    "name" : "mf-HPO-with-reg",
    "method" : "random",
    "parameters" : {
      "steps": {
        "value": 2000
      },
      "alpha" :{
        "min": 0.6,
        "max": 2.25
      },
      "emb_dim" :{
        "min": 15,
        "max": 80
      },
      "prior_std" :{
        "min": .5,
        "max": 2.0
      },
      "ell_2" :{
        "min": .05,
        "max": 0.5
      },
      "gram" :{
        "min": .1,
        "max": .75
      },
      "decay_pct" :{
        "min": .2,
        "max": .8
      },
      "period_length" :{
        "min": 50,
        "max": 500
      }
    },
    "metric" : {
      'name': 'Test omse',
      'goal': 'minimize'
    }
  }

  sweep_id = wandb.sweep(
      sweep_config,
      project="jax-mf",
      entity="wandb-un"
  )

run_config = { # These will be hyperparameters we will tune via wandb
      'emb_dim': 10, # Latent dimension
      'prior_std': 0.1,
      'alpha': 1.0, # Learning rate
      'steps': 1000, # Number of training steps
      'ell_2': 1, #l2 regularization penalization weight
      'gram': 1, #gramian regularization penalization weight
      'decay_pct': 0.5,
      'period_length': 100.0
  }

train_with_reg_loss()

最后一步是以一种让我们对所见到的模型感到自信的方式来进行。不幸的是,为 MF 问题设置交叉验证可能会很棘手,因此我们需要对我们的数据结构进行一些修改:

def sparse_array_concatenate(sparse_array_iterable):
    return {
        'indices': tuple(
            map(
                jnp.concatenate,
                zip(*(x['indices'] for x in sparse_array_iterable)))
            ),
        'values': jnp.concatenate(
            [x['values'] for x in sparse_array_iterable]
        ),
    }

class jax_df_Kfold(object):
    """Simple class that handles Kfold
 splitting of a matrix as a dataframe and stores as sparse jarrays"""
    def __init__(
        self,
        df: pd.DataFrame,
        user_dim: int,
        item_dim: int,
        k: int = 5,
        prng_key=random.PRNGKey(0)
    ):
        self._df = df
        self._num_folds = k
        self._split_idxes = jnp.array_split(
            random.permutation(
                prng_key,
                df.index.to_numpy(),
                axis=0,
                independent=True
            ),
            self._num_folds
        )

        self._fold_arrays = dict()

        for fold_index in range(self._num_folds):
        # let's create sparse jax arrays for each fold piece
            self._fold_arrays[fold_index] = (
                self._df[
                    self._df.index.isin(self._split_idxes[fold_index])
                ].pipe(start_pipeline)
                .pipe(
                    ratings_to_sparse_array,
                    user_dim=user_dim,
                    item_dim=item_dim
                )
            )

    def get_fold(self, fold_index: int):
        assert(self._num_folds > fold_index)
        test = self._fold_arrays[fold_index]
        train = sparse_array_concatenate(
            [v for k,v in self._fold_arrays.items() if k != fold_index]
        )
        return train, test

每个超参数设置应该为每个 fold 产生损失,因此在wandb.init中,我们为每个 fold 构建一个模型:

for j in num_folds:
  train, test = folder.get_fold(j)
  model_object_dict[j] = model_constructor(
          ratings_df=all_rat,
          user_dim=user_count,
          item_dim=item_count,
          embedding_dim=run_config['emb_dim'],
          init_stddev=run_config['prior_std'],
          prng_key=random.PRNGKey(0),
          train_set=train,
          test_set=test
      )

在每一步,我们不仅想计算训练的梯度并在测试集上评估,还想为所有折叠计算梯度,评估所有测试,并生成相关的错误:

for i in range(steps):
    loss_dict = {"learning_rate": step_decay(i)}
    for j, M in model_object_dict.items():
        (total_loss_val, fold_loss_dict), grads = grad_fn(
          M.ground_truth['A_train'],
          M.embeddings,
          dimensions={'users': 942, 'items': 1681},
          hyperparams={'ell_2': run_config['ell_2'], 'gram': run_config['gram']}
        )

        M.embeddings = jax.tree_multimap(
            lambda p, g: p - step_decay(i) * g,
            M.embeddings,
            grads
        )

日志应为每个折叠的损失,并且聚合损失应为目标指标。这是因为每个折叠都是模型参数的独立优化;但是,我们希望看到跨折叠的聚合行为:

        fold_loss_dict = {f'{k}_fold-{j}': v for k, v in fold_loss_dict.items()}
        fold_loss_dict.update(
                  {
                      f"Test omse_fold-{j}": omse_loss(
                        M.ground_truth['A_test'],
                        M.embeddings
                      ),
                  }
              )

        loss_dict.update(fold_loss_dict)

    loss_dict.update({
      "Test omse_mean": jnp.mean(
        [v for k,v in loss_dict.items() if k.startswith('Test omse_fold-')]
      )
    })
    wandb.log(loss_dict)

我们将所有内容整合成一个大的训练方法:

def train_with_reg_loss_CV():
    run_config = { # These will be hyperparameters we will tune via wandb
        'emb_dim': None, # Latent dimension
        'prior_std': None,
        # Standard deviation around 0 that our weights are initialized to
        'alpha': None, # Learning rate
        'steps': None, # Number of training steps
        'num_folds': None, # Number of CV Folds
        'ell_2': 1, #hyperparameter for l2 regularization penalization weight
        'gram': 1, #hyperparameter for gramian regularization penalization weight
    }

    with wandb.init() as run:
        run_config.update(run.config) # This is how the wandb agent passes params
        model_object_dict = dict()

        for j in range(run_config['num_folds']):
            train, test = folder.get_fold(j)
            model_object_dict[j] = model_constructor(
                ratings_df=all_rat,
                user_dim=942,
                item_dim=1681,
                embedding_dim=run_config['emb_dim'],
                init_stddev=run_config['prior_std'],
                prng_key=random.PRNGKey(0),
                train_set=train,
                test_set=test
            )
            model_object_dict[j].reset_embeddings()
            # Ensure we are starting from priors

        alpha, steps = run_config['alpha'], run_config['steps']
        print(run_config)

        grad_fn = jax.value_and_grad(reg_loss_observed, 1, has_aux=True)
        # Tell JAX to expect an aux dict as output

        for i in range(steps):
            loss_dict = {
              "learning_rate": lr_decay(
                i,
                alpha,
                decay_pct=.75,
                period_length=250
              )
            }
            for j, M in model_object_dict.items():
            # Iterate through folds

                (total_loss_val, fold_loss_dict), grads = grad_fn(
                # compute gradients for one fold
                    M.ground_truth['A_train'],
                    M.embeddings,
                    dimensions={'users': 942, 'items': 1681},
                    hyperparams={
                      'ell_2': run_config['ell_2'],
                      'gram': run_config['gram']
                    }
                )

                M.embeddings = jax.tree_multimap(
                # update weights for one fold
                    lambda p, g: p - lr_decay(
                      i,
                      alpha,
                      decay_pct=.75,
                      period_length=250
                    ) * g,
                    M.embeddings,
                    grads
                )

                fold_loss_dict = {
                  f'{k}_fold-{j}':
                  v for k, v in fold_loss_dict.items()
                }
                fold_loss_dict.update( # loss calculation within fold
                    {
                        f"Test omse_fold-{j}": omse_loss(
                          M.ground_truth['A_test'],
                          M.embeddings
                        ),
                    }
                )

                loss_dict.update(fold_loss_dict)

            loss_dict.update({ # average loss over all folds
                "Test omse_mean": np.mean(
                    [v for k,v in loss_dict.items()
                    if k.startswith('Test omse_fold-')]
                ),
                "test omse_max": np.max(
                    [v for k,v in loss_dict.items()
                    if k.startswith('Test omse_fold-')]
                ),
                "test omse_min": np.min(
                    [v for k,v in loss_dict.items()
                    if k.startswith('Test omse_fold-')]
                )
            })
            wandb.log(loss_dict)

            if i % 1000 == 0:
                print(f'Loss step {i}:')
                print(loss_dict)

这是我们的最终扫描配置:

sweep_config = {
    "name" : "mf-HPO-CV",
    "method" : "random",
    "parameters" : {
      "steps": {
        "value": 2000
      },
      "num_folds": {
        "value": 5
      },
      "alpha" :{
        "min": 2.0,
        "max": 3.0
      },
      "emb_dim" :{
        "min": 15,
        "max": 70
      },
      "prior_std" :{
        "min": .75,
        "max": 1.0
      },
      "ell_2" :{
        "min": .05,
        "max": 0.5
      },
      "gram" :{
        "min": .1,
        "max": .6
      },
    },
    "metric" : {
      'name': 'Test omse_mean',
      'goal': 'minimize'
    }
  }

  sweep_id = wandb.sweep(sweep_config, project="jax-mf", entity="wandb-un")

wandb.agent(sweep_id, function=train_with_reg_loss_CV, count=count)

这似乎是很多设置工作,但我们在这里确实取得了很多成就。我们已经初始化了模型以优化两个矩阵因子,同时保持矩阵元素和 Gramians 较小。

这带我们来到我们可爱的图片。

HPO MF 的输出

让我们快速看看先前的工作产生了什么。首先,图 10-3 显示我们的主要损失函数,观察到的均方误差(OMSE),正在迅速减少。这很好,但我们应该深入研究一下。

训练过程中的损失

图 10-3. 训练过程中的损失

让我们快速查看以确保我们的正则化参数(图 10-4)正在收敛。我们可以看到,如果我们继续更多的 epochs,我们的 L2 正则化可能仍然可以进一步减少。

正则化参数

图 10-4. 正则化参数

我们希望看到我们的交叉验证按折叠展开,并伴随着相应的损失(图 10-5)。这是一个平行坐标图;其线条对应不同运行,对应于不同参数选择,并且其垂直轴是不同的指标。最右侧的热图轴对应我们试图最小化的总体总损失。在这种情况下,我们交替在一个折叠上测试损失和该折叠上的总损失。较低的数字更好,我们希望看到各行在其折叠的损失上保持一致(否则,我们可能有一个偏斜的数据集)。我们看到,超参数的选择可以与折叠行为互动,但在所有低损失情景(在底部),我们看到不同折叠性能之间的高相关性。

训练过程中的损失

图 10-5. 训练过程中的损失

接下来,哪些超参数选择对性能有较强的影响?图 10-6 是另一个平行坐标图,其垂直轴对应不同的超参数。一般来说,我们希望垂直轴上的哪些领域对应最右侧热图上的低损失。我们看到,我们的一些超参数,比如先验分布和有些令人惊讶的ell_2,几乎没有影响。然而,较小的嵌入维度和较小的 Gram 权重确实有影响。较大的 alpha 值似乎也与良好的性能相关。

训练过程中的损失

图 10-6. 超参数的损失

最后,我们看到,随着我们进行贝叶斯超参数搜索,我们的性能确实随时间改善。图 10-7 是一个帕累托图,散点图中的每个点表示一次运行,从左到右是时间轴。垂直轴是总体损失,值越低越好,这意味着通常我们正在朝着更好的性能收敛。沿着散点凸包底部内刻的线是帕累托前沿,或该 x 值下的最佳性能。由于这是一个时间序列帕累托图,它仅跟踪时间内的最佳性能。

也许你会想知道我们是如何在时间内收敛到更好的损失值的。这是因为我们进行了贝叶斯超参数搜索,这意味着我们从独立的高斯分布中选择了我们的超参数,并且基于先前运行的表现更新了每个参数的先验信息。关于这种方法的介绍,请参阅 Robert Mitson 的《贝叶斯超参数优化入门》。在实际环境中,我们会在这个图中看到较少的单调行为,但我们始终希望有所改善。

训练期间的损失

图 10-7. 损失值的帕累托前沿

先验验证

如果我们将前述方法付诸实践,我们需要将训练过的模型存储在模型注册表中,以便在生产中使用。最佳做法是建立一组明确的评估标准,以测试选择的模型。在你的基本机器学习培训中,你可能已经被鼓励考虑验证数据集;这些可以采用多种形式,测试特定子集的实例或特征,甚至分布在已知方式下的协变量中。

推荐系统的一个有用的框架是记住它们是一个基本上是顺序数据集。有了这个想法,让我们再看看我们的评分数据。稍后我们将更多地讨论顺序推荐系统,但在谈论验证时,提到如何适当地处理是有用的。

注意,我们所有的评分都有相关的时间戳。为了构建一个合适的验证集,从我们数据的末尾获取时间戳是一个好主意。

不过,你可能会想,“不同用户何时活跃?”以及“后期时间戳是否可能是对评分的有偏选择?”这些都是重要的问题。为了解决这些问题,我们应该按用户进行留出验证。

要创建这个先验数据集,其中测试集直接跟在训练集后面,按照时间顺序,首先决定验证的期望大小,例如 10%。接下来,按用户分组数据。最后,使用拒绝抽样,确保不使用最近的时间戳作为拒绝条件。

这里是一个使用拒绝抽样在 pandas 中的简单实现。这不是最高效的实现方式,但可以完成任务:

def prequential_validation_set(df, holdout_perc=0.1):
    '''
 We utilize rejection sampling.

 Assign a probability to all observations, if they lie below the
 sample percentage AND they're the most recent still in the set, include.

 Otherwise return them and repeat.
 Each time, take no more than the remaining necessary to fill the count.
 '''
    count = int(len(df)*holdout_perc)
    sample = []
    while count >0:
      df['p'] = np.random.rand(len(df),1) #generate probabilities
      x = list(
          df.loc[~df.index.isin(sample)] # exclude already selected
          .sort_values(['unix_timestamp'], ascending=False)
          .groupby('user_id').head(1) # only allow the first in each group
          .query("p < @holdout_perc").index # grab the indices
      )
      rnd.shuffle(x) # ensure our previous sorting doesn't bias the users subset
      sample += x[:count] # add observations up to the remaining needed
      count -= len(x[:count]) # decrement the remaining needed

    df.drop(columns=['p'], inplace=True)

    test = df.iloc[sample]
    train = df[~df.index.isin(test.index)]
    return train, test

这是一种对固有顺序数据集的有效和重要的验证方案。

WSABIE

让我们再次关注优化和修改。另一种优化是将 MF 问题视为单一优化问题。

文章“WSABIE: Scaling Up to Large Vocabulary Image Annotation”由 Jason Weston 等人提出的方法也包含了仅对物品矩阵进行因式分解的方法。在这种方案中,我们用用户喜欢的物品的加权和替换用户矩阵。我们覆盖了 Web 规模的图像嵌入(WSABIE)和在“WARP”中的 Warp 损失。将用户表示为他们喜欢的物品的平均值是一种节省空间的方法,如果有大量用户则不需要单独的用户矩阵。

潜在空间 HPO

另一种完全不同的为 RecSys 进行 HPO 的方法是通过潜在空间本身!“Dynamic Recommender Systems 中的潜在空间超参数优化”由 Bruno Veloso 等人尝试在每个步骤中修改相对嵌入以优化嵌入模型。

降维

推荐系统经常使用降维技术来减少计算复杂性并增强推荐算法的准确性。在这个背景下,推荐系统的降维主要概念包括 MF 和 SVD。

矩阵分解方法将用户-物品交互矩阵 ( A ∈ ℝ (m×n) ) 分解成两个表示用户 ( U ∈ ℝ (m×r) ) 和物品 ( V ∈ ℝ (n×r) ) 潜在因子的较低维度矩阵。这种技术可以揭示潜在的数据结构,并根据用户的先前交互提供推荐。在数学上,MF 可以表示如下:

A ∼ U × V (T)

SVD是一种线性代数技术,它将矩阵(A)分解为三个矩阵—左奇异向量(U),奇异值(Σ)和右奇异向量(V)。在推荐系统中,SVD 可用于 MF,其中用户-项目交互矩阵被分解为更少数量的潜在因子。SVD 的数学表示如下:

A = U × Σ × V (T)

在实际操作中,人们通常不会使用数学库来找到特征向量,而是可能使用幂迭代法来近似地发现特征向量。这种方法比为正确性和密集向量优化的完整稠密 SVD 解决方案要可扩展得多:

import jax
import jax.numpy as jnp

def power_iteration(a: jnp.ndarray) -> jnp.ndarray:
  """Returns an eigenvector of the matrix a.
 Args:
 a: a n x m matrix
 """
  key = jax.random.PRNGKey(0)
  x = jax.random.normal(key, shape=(a.shape[1], 1))
  for i in range(100):
    x = a @ x
    x = x / jnp.linalg.norm(x)
  return x.T

key = jax.random.PRNGKey(123)
A = jax.random.normal(key, shape=[4, 4])
print(A)
[[ 0.52830553  0.3722206  -1.2219944  -0.10314374]
 [ 1.4722222   0.47889313 -1.2940298   1.0449569 ]
 [ 0.23724185  0.3545859  -0.172465   -1.8011322 ]
 [ 0.4864215   0.08039388 -1.2540827   0.72071517]]
S, _, _ = jnp.linalg.svd(A)
print(S)
[[-0.375782    0.40269807  0.44086716 -0.70870167]
 [-0.753597    0.0482972  -0.65527284  0.01940039]
 [ 0.2040088   0.91405433 -0.15798494  0.31293103]
 [-0.49925917 -0.00250015  0.5927009   0.6320123 ]]
x1 = power_iteration(A)
print(x1.T)
[[-0.35423845]
 [-0.8332922 ]
 [ 0.16189891]
 [-0.39233655]]

注意,幂迭代返回的特征向量接近于S的第一列,但不完全相同。这是因为这种方法是近似的。它依赖于特征向量在乘以矩阵时不改变方向的事实。因此,通过重复乘以矩阵,我们最终迭代到一个特征向量。还要注意,我们解决的是列特征向量,而不是行特征向量。在这个例子中,列是用户,行是项目。重要的是要玩转转置矩阵,因为很多机器学习涉及到重塑和转置矩阵,所以早期熟悉它们是一项重要技能。

特征向量示例

这里有一个很好的练习给你:第二个特征向量是通过在矩阵乘法后减去第一个特征向量来计算的。这告诉算法忽略第一个特征向量上的任何成分,以计算第二个特征向量。作为一个有趣的练习,跳到Colab上尝试计算第二个特征向量。将这种方法扩展到稀疏向量表示是另一个有趣的练习,因为它允许您开始计算稀疏矩阵的特征向量,这通常是推荐系统使用的矩阵形式。

接下来,我们通过创建一个列并将其与所有特征向量点积,找到最接近的特征向量。然后找到用户尚未看到的特征向量中所有最高评分的条目,并将它们作为推荐返回。因此,在上面的例子中,如果特征向量x 1最接近用户列,那么推荐的最佳项目将是项目 3,因为它是特征向量中最大的成分,因此如果用户最接近特征向量x 1,则评分最高。这段代码如下所示:

import jax
import jax.numpy as jnp

def recommend_items(eigenvectors: jnp.ndarray, user:jnp.ndarray) -> jnp.ndarray:
  """Returns an ordered list of recommend items for the user.
 Args:
 eigenvectors: a nxm eigenvector matrix
 user: a user vector of size m.
 """
  score_eigenvectors = jnp.matmul(eigenvectors.T, user)
  which_eigenvector = jnp.argmax(score_eigenvectors)
  closest_eigenvector = eigenvectors.T[which_eigenvector]
  scores, items = jax.lax.top_k(closest_eigenvector, 3)
  return scores, items

S = jnp.array(
[[-0.375782,    0.40269807],
 [-0.753597,    0.0482972],
 [ 0.2040088,   0.91405433],
 [-0.49925917, -0.00250015]])
u = jnp.array([-1, -1, 0, 0]).reshape(4, 1)
scores, items = recommend_items(S, u)
print(scores)
[ 0.2040088  -0.375782   -0.49925917]
print(items)
[2 0 3]

在这个例子中,用户对项目 0 和项目 1 进行了负面评价。最接近的列特征向量因此是列 0。然后我们选择距离用户最近的特征向量,对条目进行排序,并向用户推荐项目 2,这是用户尚未看到的最高评分条目。

两种技术旨在从用户-项目交互矩阵中提取最相关的特征并减少其维度,从而改善性能:

主成分分析(PCA)

这种统计技术将原始高维数据转换为较低维度的表示,同时保留最重要的信息。 PCA 可以应用于用户-项目交互矩阵,以减少维度数量并提高推荐算法的计算效率。

非负矩阵分解(NMF)

此技术将非负用户-项目交互矩阵 ( A ∈ ℝ (m×n)+ ) 分解为两个非负矩阵 ( W ∈ ℝ (m×r)+ 和 H ∈ ℝ (r×n) + ) 。 NMF 可以用于推荐系统中的维度缩减,其中潜在因子是非负且可解释的。 NMF 的数学表示为 A ≃ W × H 。

MF 技术可以通过使用附加信息(如项目内容或用户人口统计数据)进一步扩展,通过使用辅助信息。 辅助信息可以用来增强用户-项目交互矩阵,从而实现更准确和个性化的推荐。

此外,MF 模型可以扩展到处理隐式反馈数据,其中缺少交互数据并不等同于缺乏兴趣。 通过将额外的正则化项并入目标函数,MF 模型可以学习用户-项目交互矩阵的更强健表示,从而为隐式反馈场景提供更好的推荐。

考虑一个利用 MF 模型用户-物品交互矩阵的推荐系统。 如果系统包含许多用户和物品,生成的因子矩阵可能是高维的,且处理起来计算成本高昂。 然而,通过使用奇异值分解(SVD)或主成分分析(PCA)等降维技术,算法可以减少因子矩阵的维度,同时保留关于用户-物品交互的最重要信息。 这使得算法能够为新用户或具有有限交互数据的物品生成更有效和准确的推荐。

等距嵌入

等距嵌入 是一种特定类型的嵌入,当将点映射到低维空间时,保持高维空间中点之间的距离。 术语等距 表示高维空间中的点之间的距离在低维空间中被精确地保留,只有一个缩放因子。

与其他类型的嵌入(如线性或非线性嵌入)相比,这种等距嵌入在许多需要保持距离的应用中更为理想。 例如,在机器学习中,等距嵌入可用于在二维或三维中可视化高维数据,同时保持数据点之间的相对距离。 在自然语言处理中,等距嵌入可用于表示单词或文档之间的语义相似性,同时在嵌入空间中保持它们的相对距离。

生成等距嵌入的一种流行技术是多维标度MDS)。 MDS 通过计算高维空间中数据点之间的成对距离,然后确定一个保持这些距离的低维嵌入来运作。 优化问题通常被制定为约束优化问题,其目标是最小化高维空间中成对距离与低维嵌入中相应距离之间的差异。 在数学上,我们写成:最小化 上标 左括号 X 右括号 Σ 左括号i,j右括号 左括号 d ij - 绝对值绝对值 x i - x j 绝对值绝对值 2。

这里,d ij 表示高维空间中的成对距离,而 x i 和 x j 表示低维嵌入中的点。

生成等距嵌入的另一种方法是使用核方法,如核 PCA 或核 MDS。核方法通过隐式地将数据点映射到更高维的特征空间,在这个空间中,点之间的距离更容易计算。然后在特征空间中计算等距嵌入,并将结果嵌回原始空间。

等距嵌入已被应用于推荐系统中,用于将用户-物品交互矩阵表示为一个低维空间,其中保留了物品之间的距离。通过在嵌入空间中保持物品之间的距离,推荐算法可以更好地捕捉数据的基本结构,并提供更准确和多样化的推荐。

等距嵌入还可以用于整合额外信息到推荐算法中,例如物品内容或用户人口统计数据。通过使用等距嵌入来表示物品和额外信息,算法可以基于用户-物品交互数据以及物品内容或用户人口统计信息捕捉物品之间的相似性,从而提供更准确和多样化的推荐。

此外,等距嵌入还可以用于解决推荐系统中的冷启动问题。通过使用等距嵌入来表示物品,算法可以根据它们在嵌入空间中与现有物品的相似性,即使在没有用户交互的情况下,为新物品提供建议。

总之,等距嵌入是推荐系统中一种宝贵的技术,用于将用户-物品交互矩阵表示为一个低维空间,其中保留了物品之间的距离。等距嵌入可以使用 MF 技术生成,并可以用于整合额外信息,解决冷启动问题,提高推荐的准确性和多样性。

非线性局部度量嵌入

非线性局部度量嵌入 是另一种方法,用于将用户-物品交互矩阵表示为一个低维空间,其中保留了附近物品之间的局部距离。通过在嵌入空间中保持物品之间的局部距离,推荐算法可以更好地捕捉数据的局部结构,并提供更准确和多样化的推荐。

数学上,设 X = x 1 , x 2 , . . . , x n 表示高维空间中的项目集合,而 Y = y 1 , y 2 , . . . , y n 表示低维空间中的项目集合。非线性局部可度量嵌入的目标是找到一个映射 f : X → Y ,以保持局部距离,即对于任意 x i , x j ∈ X ,我们有:

d Y ( f ( x i ) , f ( x j ) ) ≃ d X ( x i , x j )

在推荐系统中生成非线性局部可度量嵌入的一个流行方法是通过自编码器神经网络。自编码器通过编码器网络将高维用户-项目交互矩阵映射到低维空间,然后通过解码器网络将矩阵重新构建回高维空间。编码器和解码器网络被联合训练,以最小化输入数据和重构数据之间的差异,目的是捕捉嵌入空间中的数据潜在结构:

m i n (θ,φ) ∑ (i=1) n | | x i - g φ ( f θ ( x i ) ) || 2

这里,f θ 表示带有参数 θ , g θ 的编码器网络,θ 表示具有参数 θ 的解码器网络,并且 | | · | | 表示欧几里得范数。

在推荐系统中生成非线性局部可度量嵌入的另一种方法是使用 t-分布随机邻域嵌入(t-SNE)。t-SNE 的工作原理是对高维空间中项目之间的成对相似性进行建模,然后找到一个保持这些相似性的低维嵌入。

在现代,更流行的方法是 UMAP,它试图在局部邻域中保持密度的最小流形。 UMAP 是在复杂和高维潜在空间中寻找低维表示的重要技术;查阅其文档 https://oreil.ly/NLqDg。优化问题通常被制定为一个成本函数 C,用于衡量高维空间中成对相似性与较低维度嵌入中相应相似性之间的差异:

C ( Y ) = ∑ (i,j) p ij * l o g ( p ij q ij )

这里,p ij 表示高维空间中的成对相似性,q ij 表示低维空间中的成对相似性,求和遍历所有项目对( i , j ) 。

非线性局部可度量嵌入还可用于将额外信息整合到推荐算法中,例如项目内容或用户人口统计数据。通过使用非线性局部可度量嵌入来表示项目和额外信息,算法可以基于用户-项目交互数据以及项目内容或用户人口统计数据捕捉项目之间的相似性,从而实现更准确和多样化的推荐。

此外,非线性局部可度量嵌入还可用于解决推荐系统中的冷启动问题。通过使用非线性局部可度量嵌入来表示项目,算法可以基于它们在嵌入空间中与现有项目的相似性为新项目提供建议,即使在缺乏用户交互的情况下也能实现。

总之,非线性局部可度量嵌入是推荐系统中的一种有用技术,用于在低维空间中表示用户-项目交互矩阵,保持附近项目之间的局部距离。非线性局部可度量嵌入可以使用自编码器神经网络或 t-SNE 等技术生成,并可用于整合额外信息,解决冷启动问题,并提高推荐的准确性和多样性。

居中核对齐

在训练神经网络时,预期每一层的潜在空间表示能够表达传入信号之间的相关结构。通常,这些层间表示由一系列从初始层到最终层的状态转换组成。你可能会自然而然地想到:“这些表示如何随网络层次变化而变化?”以及“这些层有多相似?”有趣的是,对于某些架构,这个问题可能揭示出网络行为的深刻见解。

比较层表示的这一过程称为相关分析。对于具有层 1 , ... , N 的 MLP,这些相关性可以通过一个 N × N 的矩阵来表示成对关系。其核心思想是,每一层都包含一系列潜在因素,类似于数据集其他特征的相关分析,这些潜在特征的关系可以简单地通过它们的协方差进行总结。

亲和力和 p-sale

正如你所见,MF 是一种强大的降维技术,可以生成销售概率的估算器(通常缩写为p-sale)。在 MF 中,目标是将用户行为和产品销售矩阵的历史数据分解为两个低维矩阵:一个代表用户偏好,另一个代表产品特征。现在,让我们将这个 MF 模型转换为销售估算器。

让 R ∈ ℝ (M×N) 是历史数据矩阵,其中 M 是用户数,N 是产品数。MF 的目标是找到两个矩阵 U ∈ ℝ (M×d) 和 V ∈ ℝ (N×d) ,其中 d 是潜在空间的维数,使得:

R ≃ U * V T

销售的概率,或等效地说,阅读、观看、吃或点击,可以通过 MF 预测,首先将历史数据矩阵分解为用户和产品矩阵,然后计算一个表示用户购买特定产品可能性的分数。这个分数可以通过用户矩阵中对应行和产品矩阵中的列的点积计算,然后通过逻辑函数将点积转换为概率分数。

从数学上讲,用户u和产品p的销售概率可以表示如下:

P ( u , p ) = sigmoid ( u * p T )

这里,sigmoid 是将用户和产品向量的点积映射到介于 0 和 1 之间的概率分数的逻辑函数:

s i g m o i d ( x ) = 1 / ( 1 + e x p ( - x ) )

p T表示产品向量的转置。用户和产品向量的点积是用户偏好与产品特性之间相似性的度量,逻辑函数将此相似性分数映射到概率分数。

用户和产品矩阵可以通过使用各种 MF 算法(如 SVD、NMF 或 ALS)在历史数据上进行训练。一旦矩阵训练好了,可以将点积和逻辑函数应用于新的用户-产品对,以预测销售的概率。预测的概率可以用来对用户进行产品排名和推荐。

值得注意的是,由于 ALS 的损失函数是凸的(意味着存在唯一的全局最小值),当我们固定用户或物品矩阵时,收敛速度可能会很快。在这种方法中,固定用户矩阵,解决物品矩阵。然后固定物品矩阵,解决用户矩阵。该方法在两种解之间交替,并且因为在这个范围内损失是凸的,所以方法会迅速收敛。

用户矩阵中对应行和产品矩阵中列的点积表示用户和产品之间的亲和分数,或者说用户偏好与产品特性匹配的程度。然而,仅凭这个分数可能不足以预测用户是否会实际购买产品。

在 MF 模型中,应用于点积的逻辑函数将亲和分数转换为概率分数,该分数表示销售的可能性。此转换考虑了除用户偏好和产品特性外的额外因素,例如产品的整体流行度、用户的购买行为以及其他相关的外部因素。通过整合这些额外因素,MF 能够更好地预测销售的概率,而不仅仅是亲和分数。

一个比较库(不过不在 JAX 中)用于线性计算潜在嵌入是 libFM。因子机的公式类似于 GloVe 嵌入,它也模拟两个向量之间的交互,但点积可以用于回归或二元分类任务。该方法还可以扩展到推荐超过用户和项目两种类型的物品。

总之,矩阵因子化(MF)不仅通过将用户偏好和产品特性之外的因素整合,产生销售概率而非仅仅亲和力评分,还通过使用 logistic 函数将亲和力分数转换为概率分数。

推荐系统评估的倾向分配加权

如您所见,推荐系统是根据用户反馈进行评估的,这些反馈来自部署的推荐系统。然而,这些数据受到部署系统的因果影响,形成了一个反馈循环,可能会偏向评估新模型。这种反馈循环可能导致混杂变量,使得很难区分用户偏好和部署系统影响之间的关系。

如果这让您感到惊讶,让我们考虑一下,推荐系统对用户采取的行动和/或这些行动导致的结果产生因果影响,这需要假设像“用户完全忽视推荐”和“系统随机推荐”。倾向分配可以减轻这个问题的一些最坏影响。

推荐系统的性能取决于许多因素,包括用户-项目特征、上下文信息和趋势,这些因素可以影响推荐的质量和用户参与度。然而,影响可能是相互的:用户互动影响推荐系统,反之亦然。因此,评估推荐系统对用户行为和满意度的因果效应是一项具有挑战性的任务,因为它需要控制潜在的混杂因素——这些因素可能影响治疗分配(推荐策略)和感兴趣的结果(用户对推荐的响应)。

因果推断为解决这些挑战提供了一个框架。在推荐系统的背景下,因果推断可以帮助回答以下问题:

  • 推荐策略的选择如何影响用户参与度,如点击率、购买率和满意度评分?

  • 对于给定用户段、项目类别或上下文,什么是最佳推荐策略?

  • 推荐策略对用户保留、忠诚度和生命周期价值的长期影响是什么?

我们将通过介绍因果推断中一个对推荐系统至关重要的方面来结束本章,基于倾向得分的概念。我们将介绍倾向性,以量化向用户展示某些项的调整后可能性。接下来我们将看到这如何与著名的辛普森悖论互动。

倾向性

在许多数据科学问题中,我们不得不处理混杂因素,特别是混杂因素与目标结果之间的相关性。根据设置的不同,混杂因素可能具有各种形式。有趣的是,在推荐系统中,这种混杂因素可能是系统本身!推荐系统的离线评估受用户的项目选择行为和部署的推荐系统产生的混杂因素的影响。

如果这个问题看起来有点循环,它确实有点。这有时被称为闭环反馈。缓解的一种方法是倾向加权,通过考虑每个反馈在相应层次上的估计倾向性来解决这个问题。你可能还记得倾向性指的是用户看到某个项的可能性;通过其倒数加权,我们可以抵消选择偏差。与标准的离线留置评估相比,这种方法试图代表所考虑的推荐模型的实际效用。

利用反事实推断

缓解选择偏差的另一种方法是反事实评估,它使用类似于强化学习中的离线策略评估方法的倾向加权技术来估计推荐模型的实际效用。然而,反事实评估通常依赖于在开环设置中准确记录倾向性,其中一些随机项目会暴露给用户,这对大多数推荐问题来说是不实际的。如果您可以选择向用户提供随机推荐以进行评分,这也可以帮助去偏差。这种方法可能与基于 RL 的推荐系统结合使用,后者使用探索-利用方法,如多臂老虎机或其他结构化随机化。

反向倾向评分IPS)是一种基于倾向性的评估方法,利用重要性抽样来考虑部署的推荐系统收集的反馈不是均匀随机的事实。倾向性评分是一个平衡因子,它调整了基于倾向性评分的观察到的反馈分布。如果开环反馈可以从所有可能的项目中均匀随机抽样,那么 IPS 评估方法在理论上是无偏的。在第三章中,我们讨论了马太效应,或者推荐系统的“富者愈富”现象;IPS 是对抗这种效应的一种方式。注意这里马太效应和辛普森悖论之间的关系,当在不同的分层中,选择效应产生了显著的偏倚时,这种关系是存在的。

倾向性加权基于这样一个思想:部署的推荐系统使一个项目暴露给用户的概率(倾向性评分)会影响到从该用户收集到的反馈。通过根据倾向性评分重新加权反馈,我们可以调整由部署系统引入的偏倚,并获得对新推荐模型更准确的评估。

要应用 IPS,我们需要估计收集到的反馈数据集中每个项目-用户交互的倾向性评分。这可以通过建模部署系统在交互时会暴露项目给用户的概率来实现。一个简单的方法是使用项目的流行度作为其倾向性评分的代理。然而,也可以使用更复杂的方法来基于用户和项目特征以及交互的上下文来建模倾向性评分。

一旦估计出倾向性评分,我们可以通过重要性抽样重新加权反馈。具体来说,每个反馈都被其倾向性评分的倒数加权,以便更可能由部署系统暴露的项目被降权,而更不可能被暴露的项目则被升权。这种重新加权过程近似于从人气均匀分布的推荐中获得的反馈的反事实分布。

最后,我们可以使用重新加权的反馈通过标准的评估指标来评估新推荐模型,正如我们在本章中看到的那样。然后,通过使用重新加权的反馈,将新模型的效果与部署系统的效果进行比较,提供对新模型性能更公平、更准确的评估。

辛普森氏和缓解混杂变量

辛普森悖论是基于一个混杂变量的概念,该混杂变量建立了我们看到(可能误导性的)协变量的分层。当调查两个变量之间的关联性时,但这些变量受到混杂变量的强烈影响时,就会出现这种悖论。

对于推荐系统,这个混淆变量是部署模型的特征和选择趋势。倾向分数被引入为系统从无偏开环暴露场景偏离的度量。此分数允许基于观察到的闭环反馈设计和分析推荐模型的离线评估,模拟开环场景的某些特定特征。

传统的辛普森悖论描述通常建议分层,这是一种通过首先识别底层分层来识别和估计因果效应的众所周知方法。对于推荐系统,这涉及根据部署模型特征的可能值分层观察结果,该特征是混淆变量。

用户独立倾向分数通过使用先验概率来估计,即部署模型推荐项目的先验概率,以及在推荐时用户与项目交互的条件概率进行两步生成过程估计。基于一系列温和假设(但在这里数学上太技术化,不予详细讨论),可以使用每个数据集的最大似然估计用户独立倾向分数。

我们需要定义用户倾向分数 p u,i ,这表示部署模型暴露项目 i ∈ I 给用户 u ∈ U 的倾向或频率。在实践中,我们对用户进行边际化,得到用户独立倾向分数 p *,i 。正如 Longqi Yang 等人在 “不偏离线推荐系统评估的隐式反馈” 中所述,方程如下:

p ,i α n i γ+1 2

在这里,n i * 是项目 i 与之交互的总次数,γ 是影响不同观察流行度的倾向分布的参数。幂律参数 γ 影响项目的倾向分布,并且依赖于研究的数据集;我们使用每个数据集的最大似然估计 γ 参数。

使用这些倾向估计,我们可以在计算反馈效果时应用简单的反权重w i = 1 p i。最后,我们可以将这些权重与倾向匹配结合起来,生成反事实推荐;通过收集大致相等的倾向项目到分层中,我们可以将这些分层用作混杂变量。

双重稳健估计

双重稳健估计(DRE)是一种结合两个模型的方法:一个模型建模接受治疗(通过已部署模型推荐项目)的概率,另一个模型建模感兴趣的结果(用户对项目的反馈)。在 DRE 中使用的权重取决于这两个模型的预测概率。该方法的优点在于,即使其中一个模型被错误规定,它仍然可以提供无偏估计。

具有倾向分数加权和结果模型的双重稳健估计器的结构方程如下:

Θ = ∑w i Y i -f(X i ) ∑w i T i -p i +∑w i p i (1-p i ) 2 f(X i )-f * (X i )

这里,Y i 是结果,X i 是协变量,T i 是治疗,p i 是倾向分数,w i 是权重,f ( X i ) 是结果模型,f * ( X i ) 是估计的结果模型。

对于这些考虑的很好介绍,请查看“给我一个稳健的估计器 - 并且要双重!”

摘要

真是一个旋风!潜在空间是推荐系统中重要的方面之一。它们是我们用来编码用户和项目的表示。最终,潜在空间不仅仅是维度减少,它们关乎理解一种几何,其中距离的测量编码与您的机器学习任务相关的含义。

嵌入和编码器的世界深不可测。我们还没有时间讨论 CLIP 嵌入(图像+文本)或 Poincaré盘(自然层次距离测量)。我们没有深入讨论 UMAP(一种非线性密度感知的降维技术)或 HNSW(一种在潜在空间中检索的方法,很好地尊重局部几何关系)。而是指向了 Vicki Boykis 的(同时发布的)文章关于嵌入,Karel Minařík 的文章和构建嵌入的指南,或者 Cohere 的 Meor Amer 的文本嵌入美丽的视觉指南

我们现在已经有了表示方法,但接下来我们需要优化。我们正在构建个性化推荐系统,因此让我们定义一些度量标准来衡量我们在任务上的表现。