Spark 机器学习(二)
原文:
zh.annas-archive.org/md5/7A35D303E4132E910DFC5ADB5679B82A译者:飞龙
第三章:设计一个机器学习系统
在本章中,我们将为一个智能的、分布式的机器学习系统设计一个高层架构,该系统以 Spark 作为其核心计算引擎。我们将专注于采用自动化机器学习系统来支持业务的关键领域,对现有的基于 Web 的业务架构进行重新设计。
在我们深入研究我们的场景之前,我们将花一些时间了解机器学习是什么。
然后我们将:
-
介绍一个假设的业务场景
-
提供当前架构的概述
-
探索机器学习系统可以增强或替代某些业务功能的各种方式
-
基于这些想法提供一个新的架构
现代大规模数据环境包括以下要求:
-
它必须与系统的其他组件集成,特别是与数据收集和存储系统、分析和报告以及前端应用程序集成
-
它应该易于扩展,并且独立于其他架构。理想情况下,这应该以水平和垂直可扩展的形式存在
-
它应该允许对所考虑的工作负载类型进行有效的计算,即机器学习和迭代分析应用
-
如果可能的话,它应该支持批处理和实时工作负载
作为一个框架,Spark 符合这些标准。然而,我们必须确保在 Spark 上设计的机器学习系统也符合这些标准。实施一个最终导致我们的系统在这些要求中的一个或多个方面失败的算法是没有意义的。
什么是机器学习?
机器学习是数据挖掘的一个子领域。虽然数据挖掘已经存在了 50 多年,但机器学习是一个子集,其中使用大量机器来分析和从大型数据集中提取知识。
机器学习与计算统计密切相关。它与数学优化有着密切的联系;它为该领域提供了方法、理论和应用领域。机器学习被应用于各种类型的计算任务,其中设计和编程明确算法是不可行的。示例应用包括垃圾邮件过滤、光学字符识别(OCR)、搜索引擎和计算机视觉。机器学习有时与数据挖掘结合使用,后者更注重探索性数据分析,被称为无监督学习。
根据学习系统可用的学习信号的性质,机器学习系统可以分为三类。学习算法从提供的输入中发现结构。它可以有一个目标(隐藏的模式),或者它可以是一种试图找到特征的手段。
-
无监督学习:学习系统没有给出输出的标签。它自己从给定的输入中找到结构
-
监督学习:系统由人类提供输入和期望的输出,目标是学习一个模型将输入映射到输出
-
强化学习:系统与环境互动,在没有人明确告诉它是否接近目标的情况下,执行一个规定的目标
在后面的章节中,我们将把监督学习和无监督学习映射到各个章节。
介绍 MovieStream
为了更好地说明我们架构的设计,我们将介绍一个实际的场景。假设我们刚刚被任命为 MovieStream 的数据科学团队负责人,MovieStream 是一个虚构的互联网业务,向用户提供流媒体电影和电视节目。
MovieStream 系统概述如下图所示:
MovieStream 的当前架构
正如我们在前面的图表中所看到的,目前,MovieStream 的内容编辑团队负责决定在网站的各个部分推广和展示哪些电影和节目。他们还负责为 MovieStream 的大规模营销活动创建内容,其中包括电子邮件和其他直接营销渠道。目前,MovieStream 基本上收集了用户在聚合基础上观看的标题的基本数据,并且可以访问用户在注册服务时收集的一些人口统计数据。此外,他们可以访问其目录中标题的一些基本元数据。
MovieStream 可以以自动化的方式处理许多目前由内容团队处理的功能。
机器学习系统的业务用例
也许我们应该回答的第一个问题是,“为什么要使用机器学习?”
为什么 MovieStream 不简单地继续人为决策?使用机器学习有许多原因(当然也有一些原因不使用),但最重要的原因在这里提到:
-
涉及的数据规模意味着随着 MovieStream 的增长,完全依赖人类参与很快变得不可行。
-
基于模型驱动的方法,如机器学习和统计学,通常可以从数据集的规模和复杂性导致人类无法发现的模式中受益。
-
模型驱动的方法可以避免人为和情感偏见(只要正确的流程得到仔细应用)。
然而,并没有理由为什么模型驱动和人为驱动的流程和决策不能共存。例如,许多机器学习系统依赖于接收标记数据来训练模型。通常,标记这样的数据是昂贵的、耗时的,并需要人类的输入。这种情况的一个很好的例子是将文本数据分类到类别中或为文本分配情感指标。许多现实世界的系统使用某种形式的人为驱动系统来为这样的数据生成标签(或至少部分)以为模型提供训练数据。然后这些模型用于在更大规模的实时系统中进行预测。
在 MovieStream 的背景下,我们不必担心我们的机器学习系统会使内容团队变得多余。事实上,我们将看到我们的目标是减轻耗时的任务负担,让机器学习能够更好地执行,同时提供工具让团队更好地了解用户和内容。例如,这可能帮助他们选择要为目录获取的新内容(这涉及相当大的成本,因此是业务的关键方面)。
个性化
在 MovieStream 业务中,机器学习最重要的潜在应用之一是个性化。一般来说,个性化是指根据各种因素调整用户的体验和呈现给他们的内容,这些因素可能包括用户行为数据以及外部因素。
推荐本质上是个性化的一个子集。推荐通常指向用户呈现一系列我们希望用户感兴趣的项目。推荐可以用于网页(例如,相关产品的推荐),通过电子邮件或其他直接营销渠道,通过移动应用程序等等。
个性化与推荐非常相似,但推荐通常专注于向用户明确呈现产品或内容,而个性化更加通用,通常更加隐含。例如,将个性化应用于 MovieStream 网站的搜索可能允许我们根据关于用户的可用数据,调整给定用户的搜索结果。这可能包括基于推荐的数据(在搜索产品或内容的情况下),但也可能包括各种其他因素,如地理位置和过去的搜索历史。用户可能不会意识到搜索结果是针对其特定配置文件进行调整;这就是为什么个性化往往更加隐含。
定向营销和客户分割
与推荐类似,定向营销使用模型来选择针对用户的目标。虽然通常推荐和个性化专注于一对一的情况,分割方法可能会尝试根据特征和可能的行为数据将用户分配到组中。这种方法可能相当简单,也可能涉及尝试根据特征和可能的行为数据将用户分配到组中的机器学习模型,如聚类。无论哪种方式,结果都是一组分段分配,这可能使我们能够了解每个用户组的广泛特征,了解在组内使他们相似的因素,以及了解使他们与其他组中的其他人不同的因素。
这可以帮助 MovieStream 更好地了解用户行为的驱动因素,也可能允许更广泛的定位方法,其中以组为目标,而不是(或更可能是,除了)个性化的直接一对一定位。
这些方法也可以在我们不一定有标记数据可用的情况下(例如某些用户和内容配置文件数据),但我们仍希望执行比完全一刀切方法更加集中的定位时提供帮助。
预测建模和分析
机器学习可以应用的第三个领域是预测分析。这是一个非常广泛的术语,在某种程度上,它也包括推荐、个性化和定位。在这种情况下,由于推荐和分割有些不同,我们使用术语“预测建模”来指代寻求进行预测的其他模型。一个例子是一个模型,可以在任何关于标题可能受欢迎程度的数据可用之前,预测新标题的潜在观看活动和收入。MovieStream 可以利用过去的活动和收入数据,以及内容属性,创建一个回归模型,可以用来预测全新标题的情况。
另一个例子是,我们可以使用分类模型自动为我们只有部分数据的新标题分配标签、关键词或类别。
机器学习模型的类型
虽然我们有一个例子,但还有许多其他例子,其中一些我们将在相关章节中介绍每个机器学习任务时进行介绍。
然而,我们可以广泛地将前述用例和方法分为两类机器学习:
-
监督学习:这些类型的模型使用标记数据进行学习。推荐引擎、回归和分类是监督学习方法的例子。这些模型中的标签可以是用户-电影评分(用于推荐)、电影标签(在前述分类示例中)、或收入数字(用于回归)。我们将在第四章中介绍监督学习模型,使用 Spark 构建推荐引擎,第六章,使用 Spark 构建分类模型,和第七章,使用 Spark 构建回归模型。
-
无监督学习:当模型不需要标记数据时,我们称之为无监督学习。这些类型的模型试图学习或提取数据中的一些潜在结构,或将数据减少到其最重要的特征。聚类、降维和一些形式的特征提取,如文本处理,都是无监督技术,将在第八章,使用 Spark 构建聚类模型,第九章,使用 Spark 进行降维,和第十章,使用 Spark 进行高级文本处理中进行讨论。
数据驱动机器学习系统的组件
我们机器学习系统的高级组件如下图所示。该图说明了我们获取数据和存储数据的机器学习流程。然后我们将其转换为可用作机器学习模型输入的形式;训练、测试和改进我们的模型;然后将最终模型部署到我们的生产系统。随着新数据的生成,该过程将重复进行。
一个通用的机器学习流程
数据摄入和存储
我们机器学习流程的第一步将是获取我们训练模型所需的数据。与许多其他企业一样,MovieStream 的数据通常由用户活动、其他系统(通常称为机器生成的数据)和外部来源(例如某个用户访问网站时的时间和天气)生成。
这些数据可以通过各种方式进行摄入,例如从浏览器和移动应用事件日志中收集用户活动数据,或访问外部 Web API 来收集地理位置或天气数据。
一旦收集机制就位,通常需要存储数据。这包括原始数据、中间处理产生的数据以及最终模型结果,用于生产环境中。
数据存储可能会很复杂,涉及各种系统,包括 HDFS、Amazon S3 和其他文件系统;诸如 MySQL 或 PostgreSQL 的 SQL 数据库;分布式 NoSQL 数据存储,如 HBase、Cassandra 和 DynamoDB;以及 Solr 或 Elasticsearch 等搜索引擎,用于流数据系统,如 Kafka、Flume 或 Amazon Kinesis。
为了本书的目的,我们将假设相关数据对我们可用,因此我们将专注于以下流程中的处理和建模步骤。
数据清洗和转换
大多数机器学习算法都是基于特征操作的,这些特征通常是输入变量的数值表示,将用于模型。
虽然我们可能希望花费大部分时间探索机器学习模型,但通过前面的摄入步骤从各种系统和来源收集的数据,在大多数情况下都是以原始形式存在的。例如,我们可能记录用户事件,比如用户何时查看电影信息页面、观看电影或提供其他反馈的详细信息。我们还可能收集外部信息,比如用户的位置(例如通过他们的 IP 地址提供)。这些事件日志通常会包含有关事件的文本和数字信息的组合(也可能包括其他形式的数据,如图像或音频)。
为了在我们的模型中使用原始数据,在几乎所有情况下,我们需要进行预处理,这可能包括:
-
过滤数据:假设我们想要从原始数据的子集创建模型,比如只使用最近几个月的活动数据或只使用符合某些条件的事件。
-
处理缺失、不完整或损坏的数据:许多真实世界的数据集在某种程度上是不完整的。这可能包括缺失的数据(例如由于缺少用户输入)或不正确或有缺陷的数据(例如由于数据摄入或存储错误、技术问题或错误、软件或硬件故障)。我们可能需要过滤掉不良数据,或者决定一种方法来填补缺失的数据点(例如使用数据集的平均值来填补缺失点)。
-
处理潜在的异常、错误和离群值:错误或离群值的数据可能会扭曲模型训练的结果,因此我们可能希望过滤这些情况或使用能够处理离群值的技术。
-
合并不同的数据源:例如,我们可能需要将每个用户的事件数据与不同的内部数据源(如用户资料)以及外部数据(如地理位置、天气和经济数据)进行匹配。
-
聚合数据:某些模型可能需要以某种方式聚合的输入数据,比如计算每个用户的不同事件类型的总和。
一旦我们对数据进行了初始预处理,通常需要将数据转换为适合机器学习模型的表示形式。对于许多模型类型,这种表示形式将采用包含数值数据的向量或矩阵结构。数据转换和特征提取过程中常见的挑战包括:
-
将分类数据(如地理位置的国家或电影的类别)编码为数值表示。
-
从文本数据中提取有用的特征。
-
处理图像或音频数据。
-
将数值数据转换为分类数据,以减少变量可以取值的数量。一个例子是将年龄变量转换为区间(比如 25-35,45-55 等)。
-
转换数值特征;例如,对数值变量应用对数变换可以帮助处理取值范围非常大的变量。
-
对数值特征进行归一化和标准化,确保模型的所有不同输入变量具有一致的尺度。许多机器学习模型需要标准化的输入才能正常工作。
-
特征工程,即将现有变量组合或转换为新特征的过程。例如,我们可以创建一个新变量,即某些其他数据的平均值,比如用户观看电影的平均次数。
我们将通过本书中的示例涵盖所有这些技术。
这些数据清洗、探索、聚合和转换步骤可以使用 Spark 的核心 API 函数以及 SparkSQL 引擎来进行,更不用说其他外部的 Scala、Java 或 Python 库。我们可以利用 Spark 的 Hadoop 兼容性从各种存储系统中读取数据并写入数据。
如果涉及流式输入,我们还可以利用 Spark 流处理。
模型训练和测试循环
一旦我们的训练数据适合我们的模型,我们可以进行模型的训练和测试阶段。在这个阶段,我们主要关注模型选择。这可以是选择最适合我们任务的建模方法,或者给定模型的最佳参数设置。事实上,模型选择这个术语通常指的是这两个过程,因为在许多情况下,我们可能希望尝试各种模型,并选择表现最佳的模型(每个模型的最佳参数设置)。在这个阶段,探索不同模型的组合(称为集成方法)也很常见。
这通常是一个相当简单的过程,即在训练数据集上运行我们选择的模型,并在测试数据集上测试其性能(即一组数据,用于评估模型在训练阶段未见过的模型)。这个过程被称为交叉验证。
有时,模型会出现过拟合或者不完全收敛,这取决于数据集的类型和使用的迭代次数。
使用集成方法,如梯度提升树和随机森林,是避免过拟合的机器学习和 Spark 中使用的技术。
然而,由于我们通常处理的数据规模很大,通常有必要在我们完整数据集的一个较小代表样本上进行这个初始的训练-测试循环,或者在可能的情况下使用并行方法进行模型选择。
对于管道的这一部分,Spark 内置的机器学习库 MLlib 非常合适。在本书中,我们将主要关注使用 MLlib 和 Spark 的核心功能,对各种机器学习技术进行模型训练、评估和交叉验证步骤。
模型部署和集成
一旦找到了最佳的训练-测试循环,我们可能仍然面临将模型部署到生产系统的任务,以便用于进行可操作的预测。
通常,这个过程涉及将训练好的模型导出到一个中央数据存储中,生产系统可以从中获取最新版本。因此,实时系统会定期更新模型,以便使用新训练的模型。
模型监控和反馈
在生产中监控机器学习系统的性能非常重要。一旦部署了最佳训练的模型,我们希望了解它在“野外”的表现。它在新的、未见过的数据上表现如我们所期望的吗?它的准确性是否足够?事实上,无论我们在早期阶段进行了多少模型选择和调整,衡量真正性能的唯一方法是观察在生产系统中发生的情况。
除了批处理模式的模型创建外,还有使用 Spark 流处理构建的实时模型。
另外,请记住,模型准确度和预测性能只是现实世界系统的一个方面。通常,我们关注与业务绩效相关的其他指标(例如收入和盈利能力)或用户体验(例如在我们网站上花费的时间以及我们的用户总体活跃度)。在大多数情况下,我们无法轻易将模型预测性能与这些业务指标相匹配。推荐或定位系统的准确性可能很重要,但它只间接与我们关心的真正指标相关,即我们是否正在改善用户体验、活动性和最终收入。
因此,在现实世界的系统中,我们应该监控模型准确度指标以及业务指标。如果可能的话,我们应该能够在生产中尝试不同的模型,以便通过对模型进行更改来优化这些业务指标。这通常是通过实时分割测试来完成的。然而,正确地进行这项工作并不容易,实时测试和实验是昂贵的,因为错误、性能不佳以及使用基准模型(它们提供了我们测试生产模型的对照)可能会对用户体验和收入产生负面影响。
这一阶段的另一个重要方面是模型反馈。这是我们的模型预测通过用户行为反馈到模型的过程。在现实世界的系统中,我们的模型实质上通过影响决策和潜在用户行为来影响自己未来的训练数据。
例如,如果我们部署了一个推荐系统,那么通过推荐,我们可能会影响用户行为,因为我们只允许用户有限的选择。我们希望这个选择对我们的模型是相关的;然而,这种反馈循环反过来又会影响我们模型的训练数据。这又反过来影响现实世界的性能。可能会陷入一个不断变窄的反馈循环;最终,这可能会对模型准确度和我们重要的业务指标产生负面影响。
幸运的是,我们有一些机制可以尝试限制这种反馈循环的潜在负面影响。这些机制包括通过让一小部分来自未接触我们模型的用户的数据提供一些无偏的训练数据,或者在探索和开发的平衡方式上保持原则,以了解更多关于我们的数据,以及利用我们所学到的知识来改善系统的性能。
我们将在第十一章中简要介绍使用 Spark Streaming 进行实时机器学习。
批处理与实时
在前面的章节中,我们概述了常见的批处理方法,即使用所有数据或所有数据的子集定期重新训练模型。由于前面的管道需要一些时间才能完成,因此可能无法使用这种方法立即更新模型以适应新数据的到来。
虽然在本书中我们将主要介绍批处理机器学习方法,但有一类被称为在线学习的机器学习算法;它们在新数据被馈送到模型时立即更新,从而实现实时系统。一个常见的例子是线性模型的在线优化算法,比如随机梯度下降。我们可以通过示例学习这个算法。这些方法的优势在于系统可以非常快速地对新信息做出反应,同时系统可以适应底层行为的变化(即,如果输入数据的特征和分布随时间变化,这在现实世界的情况下几乎总是发生的)。
然而,在生产环境中,在线学习模型也面临着自己独特的挑战。例如,实时摄取和转换数据可能很困难。在纯在线设置中进行适当的模型选择也可能很复杂。在线培训和模型选择和部署阶段的延迟可能对真实实时需求来说太高(例如,在在线广告中,延迟要求以两位数毫秒为单位)。最后,面向批处理的框架可能使处理流式处理的实时过程变得尴尬。
幸运的是,Spark 的实时流处理非常适合实时机器学习工作流。我们将在第十一章中探讨 Spark Streaming 和在线学习,使用 Spark Streaming 进行实时机器学习
由于真实实时机器学习系统固有的复杂性,在实践中,许多系统针对近实时操作。这本质上是一种混合方法,其中模型不一定在新数据到达时立即更新;相反,新数据被收集到一小组训练数据的小批次中。这些小批次可以被馈送到在线学习算法中。在许多情况下,这种方法与定期批处理过程相结合,该过程可能在整个数据集上重新计算模型并执行更复杂的处理和模型选择。这可以确保实时模型不会随着时间的推移而退化。
另一种类似的方法涉及对更复杂的模型进行近似更新,以便在新数据到达时,定期以批处理过程重新计算整个模型。通过这种方式,模型可以从新数据中学习,但由于应用了近似值,随着时间的推移,模型会变得越来越不准确。定期重新计算通过在所有可用数据上重新训练模型来解决这个问题。
Apache Spark 中的数据管道
正如我们所看到的电影镜头用例,运行一系列机器学习算法来处理和学习数据是非常常见的。另一个例子是简单的文本文档处理工作流,其中可以包括几个阶段:
-
将文档的文本拆分成单词
-
将文档的单词转换为数字特征向量
-
从特征向量和标签中学习预测模型
Spark MLlib 将这样的工作流表示为管道;它由顺序的管道阶段(转换器和估计器)组成,这些阶段按特定顺序运行。
管道被指定为一系列阶段。每个阶段都是一个转换器或一个估计器。转换器将一个数据框转换为另一个数据框。另一方面,估计器是一个学习算法。管道阶段按顺序运行,并且输入数据框在通过每个阶段时进行转换。
在转换器阶段,对数据框调用transform()方法。对于估计器阶段,调用fit()方法以生成一个转换器(它成为 PipelineModel 或拟合管道的一部分)。转换器的transform()方法在数据框上执行。
机器学习系统的架构
现在我们已经探讨了我们的机器学习系统在 MovieStream 环境中可能的工作方式,我们可以为我们的系统概述一个可能的架构:
MovieStream 的未来架构
正如我们所看到的,我们的系统包含了前面图表中概述的机器学习管道;该系统还包括:
-
收集关于用户、他们的行为和我们的内容标题的数据
-
将这些数据转换为特征
-
训练我们的模型,包括我们的训练测试和模型选择阶段
-
将训练好的模型部署到我们的实时模型服务系统以及将这些模型用于离线流程
-
通过推荐和定位页面将模型结果反馈到 MovieStream 网站
-
将模型结果反馈到 MovieStream 的个性化营销渠道
-
使用离线模型为 MovieStream 的各个团队提供工具,以更好地了解用户行为、内容目录的特征和业务收入的驱动因素
在下一节中,我们稍微偏离了 Movie Stream,概述了 MLlib-Spark 的机器学习模块。
Spark MLlib
Apache Spark 是一个用于大型数据集处理的开源平台。它非常适合迭代的机器学习任务,因为它利用了 RDD 等内存数据结构。MLlib 是 Spark 的机器学习库。MLlib 提供了各种学习算法的功能-监督和无监督。它包括各种统计和线性代数优化。它与 Apache Spark 一起发布,因此可以避免像其他库那样的安装问题。MLlib 支持 Scala、Java、Python 和 R 等多种高级语言。它还提供了一个高级 API 来构建机器学习管道。
MLlib 与 Spark 的集成有很多好处。Spark 设计用于迭代计算周期;它为大型机器学习算法提供了高效的实现平台,因为这些算法本身就是迭代的。
Spark 数据结构的任何改进都会直接为 MLlib 带来收益。Spark 庞大的社区贡献帮助加快了新算法对 MLlib 的引入。
Spark 还有其他 API,如 Pipeline API GraphX,可以与 MLlib 一起使用;它使得在 MLlib 之上构建有趣的用例更容易。
Spark ML 在 Spark MLlib 上的性能改进
Spark 2.0 使用了 Tungsten 引擎,该引擎利用了现代编译器和 MPP 数据库的思想。它在运行时发出优化的字节码,将查询折叠成一个单一函数。因此,不需要虚拟函数调用。它还使用 CPU 寄存器来存储中间数据。这种技术被称为整体阶段代码生成。
参考:databricks.com/blog/2016/0… 来源:databricks.com/blog/2016/0…
即将出现的表格和图表显示了 Spark 1.6 和 Spark 2.0 之间单函数改进的情况:
比较 Spark 1.6 和 Spark 2.0 之间单行函数性能改进的图表
比较 Spark 1.6 和 Spark 2.0 之间单行函数性能改进的表格。
比较 MLlib 支持的算法
在本节中,我们将看一下 MLlib 版本支持的各种算法。
分类
在 1.6 版本中,支持超过 10 种分类算法,而当 Spark ML 版本 1.0 发布时,只支持 3 种算法。
聚类
在聚类算法方面进行了相当大的投资,从 1.0.0 的 1 种算法支持到 1.6.0 的 6 种实现支持。
回归
传统上,回归并不是主要关注的领域,但最近已经成为焦点,从 1.2.0 版本到 1.3.0 版本新增了 3-4 个新算法。
MLlib 支持的方法和开发者 API
MLlib 提供了学习算法的快速和分布式实现,包括各种线性模型、朴素贝叶斯、支持向量机和决策树集成(也称为随机森林)用于分类和回归问题,交替进行。
最小二乘法(显式和隐式反馈)用于协同过滤。它还支持 k 均值聚类和主成分分析(PCA)用于聚类和降维。
该库提供了一些低级原语和基本实用程序,用于凸优化(spark.apache.org/docs/latest/mllib-optimization.html)、分布式线性代数(支持向量和矩阵)、统计分析(使用 Breeze 和本地函数)、特征提取,并支持各种 I/O 格式,包括对 LIBSVM 格式的本机支持。
它还支持通过 Spark SQL 和 PMML(en.wikipedia.org/wiki/Predictive_Model_Markup_Language)(Guazzelli 等人,2009)进行数据集成。您可以在此链接找到有关 PMML 支持的更多信息:spark.apache.org/docs/1.6.0/mllib-pmml-model-export.html。
算法优化涉及 MLlib 包括许多优化,以支持高效的分布式学习和预测。
用于推荐的 ALS 算法利用了阻塞来减少 JVM 垃圾收集开销,并利用更高级别的线性代数操作。决策树使用了来自 PLANET 项目的想法(参考:dl.acm.org/citation.cfm?id=1687569),例如数据相关的特征离散化以减少通信成本,以及树集成在树内和树间并行学习。
广义线性模型是使用优化算法学习的,这些算法并行计算梯度,使用快速的基于 C++的线性代数库进行工作。
计算。算法受益于高效的通信原语。特别是,树形聚合可以防止驱动程序成为瓶颈。
模型更新部分地组合在一小组执行器上。然后将它们发送到驱动程序。这种实现减少了驱动程序需要处理的负载。测试表明,这些功能将聚合时间缩短了一个数量级,特别是在具有大量分区的数据集上。
(参考:databricks.com/blog/2014/09/22/spark-1-1-mllib-performance-improvements.html)
Pipeline API包括实用的机器学习管道,通常涉及一系列数据预处理、特征提取、模型拟合和验证阶段。
大多数机器学习库不提供对管道构建的各种功能的本机支持。在处理大规模数据集时,将端到端管道连接在一起的过程在网络开销的角度来看既费力又昂贵。
利用 Spark 的生态系统:MLlib 包括一个旨在解决这些问题的包。
spark.ml包通过提供一组统一的高级 API(arxiv.org/pdf/1505.06807.pdf)来简化多阶段学习管道的开发和调优。它包括使用户能够在其专门的算法中替换标准学习方法的 API。
Spark 集成
MLlib 受益于 Spark 生态系统中的组件。Spark 核心提供了一个执行引擎,其中包含超过 80 个用于转换数据(数据清洗和特征化)的操作符。
MLlib 使用了与 Spark 打包在一起的其他高级库,如 Spark SQL。它提供了集成数据功能、SQL 和结构化数据处理,简化了数据清洗和预处理。它支持 DataFrame 抽象,这对于spark.ml包是基本的。
GraphX(www.usenix.org/system/files/conference/osdi14/osdi14-paper-gonzalez.pdf)支持大规模图处理,并具有强大的 API,用于实现可以视为大型稀疏图问题的学习算法,例如 LDA。
Spark Streaming(www.cs.berkeley.edu/~matei/papers/2013/sosp_spark_streaming.pdf)允许处理实时数据流,并支持在线学习算法的开发,就像 Freeman(2015)中所述。我们将在本书的一些后续章节中涵盖流处理。
MLlib 愿景
MLlib 的愿景是提供一个可扩展的机器学习平台,可以处理大规模数据集,并且相对于现有系统(如 Hadoop)具有更快的处理时间。
它还努力为监督和无监督学习分类、回归和聚类等领域尽可能多的算法提供支持。
MLlib 版本比较
在本节中,我们将比较各个版本的 MLlib 和新增的功能。
Spark 1.6 到 2.0
基于 DataFrame 的 API 将成为主要 API。
基于 RDD 的 API 正在进入维护模式。MLlib 指南(spark.apache.org/docs/2.0.0/ml-guide.html)提供了更多细节。
以下是 Spark 2.0 中引入的新功能:
-
ML 持久性:基于 DataFrames 的 API 支持在 Scala、Java、Python 和 R 中保存和加载 ML 模型和管道
-
R 中的 MLlib:SparkR 在此版本中提供了 MLlib 的 API,用于广义线性模型、朴素贝叶斯、k 均值聚类和生存回归
-
Python:2.0 中的 PySpark 支持新的 MLlib 算法,如 LDA、广义线性回归、高斯混合模型等
基于 DataFrames 的 API 新增了 GMM、二分 K 均值聚类、MaxAbsScaler 特征转换器。
总结
在本章中,我们了解了数据驱动的自动化机器学习系统中固有的组件。我们还概述了这样一个系统在现实世界中可能的高层架构。我们还从性能的角度对 MLlib-Spark 的机器学习库与其他机器学习实现进行了概述。最后,我们看了一下从 Spark 1.6 到 Spark 2.0 各个版本的新功能。
在下一章中,我们将讨论如何获取常见机器学习任务的公开可用数据集。我们还将探讨处理、清洗和转换数据的一般概念,以便用于训练机器学习模型。
第四章:使用 Spark 获取、处理和准备数据
机器学习是一个非常广泛的领域,如今,应用可以在包括网络和移动应用、物联网和传感器网络、金融服务、医疗保健以及各种科学领域等领域找到。
因此,机器学习可用的数据范围是巨大的。在本书中,我们将主要关注业务应用。在这种情况下,可用的数据通常包括组织内部的数据(例如金融服务公司的交易数据)以及外部数据源(例如同一金融服务公司的金融资产价格数据)。
例如,您会从第三章中回忆起,设计一个机器学习系统,我们假想的互联网业务 Movie Stream 的主要内部数据来源包括网站上可用电影的数据,服务的用户以及他们的行为。这包括有关电影和其他内容的数据(例如标题,类别,描述,图片,演员和导演),用户信息(例如人口统计学,位置等),以及用户活动数据(例如网页浏览,标题预览和浏览,评分,评论,以及喜欢,分享等社交数据,包括 Facebook 和 Twitter 等社交网络资料)。
在这个例子中,外部数据源可能包括天气和地理位置服务,第三方电影评分和评论网站,比如IMDB和Rotten Tomatoes等。
一般来说,要获取真实世界服务和企业的内部数据是非常困难的,因为这些数据具有商业敏感性(特别是购买活动数据,用户或客户行为以及收入数据),对相关组织具有巨大的潜在价值。这也是为什么这些数据通常是应用机器学习的最有用和有趣的数据--一个能够做出准确预测的好的机器学习模型可能具有很高的价值(比如机器学习竞赛的成功,比如Netflix Prize和Kaggle)。
在本书中,我们将利用公开可用的数据集来说明数据处理和机器学习模型训练的概念。
在本章中,我们将:
-
简要介绍机器学习中通常使用的数据类型。
-
提供获取有趣数据集的例子,这些数据集通常可以在互联网上公开获取。我们将在整本书中使用其中一些数据集来说明我们介绍的模型的使用。
-
了解如何处理、清理、探索和可视化我们的数据。
-
介绍各种技术,将我们的原始数据转换为可以用作机器学习算法输入的特征。
-
学习如何使用外部库以及 Spark 内置功能来规范输入特征。
访问公开可用的数据集
幸运的是,虽然商业敏感数据可能很难获得,但仍然有许多有用的公开数据集可用。其中许多经常被用作特定类型的机器学习问题的基准数据集。常见数据来源的例子包括:
-
UCI 机器学习库:这是一个包含近 300 个各种类型和大小的数据集的集合,用于分类、回归、聚类和推荐系统等任务。列表可在
archive.ics.uci.edu/ml/找到。 -
Amazon AWS 公共数据集:这是一组通常非常庞大的数据集,可以通过 Amazon S3 访问。这些数据集包括人类基因组计划,Common Crawl 网络语料库,维基百科数据和 Google 图书 Ngrams。这些数据集的信息可以在
aws.amazon.com/publicdatasets/找到。 -
Kaggle:这是 Kaggle 举办的机器学习竞赛中使用的数据集的集合。领域包括分类、回归、排名、推荐系统和图像分析。这些数据集可以在
www.kaggle.com/competitions的竞赛部分找到。 -
KDnuggets:这里有一个详细的公共数据集列表,包括之前提到的一些。列表可在
www.kdnuggets.com/datasets/index.html找到。
根据具体领域和机器学习任务的不同,还有许多其他资源可以找到公共数据集。希望你也可能接触到一些有趣的学术或商业数据!
为了说明 Spark 中与数据处理、转换和特征提取相关的一些关键概念,我们将下载一个常用的用于电影推荐的数据集;这个数据集被称为MovieLens数据集。由于它适用于推荐系统以及潜在的其他机器学习任务,它作为一个有用的示例数据集。
MovieLens 100k 数据集
MovieLens 100k 数据集是与一组用户对一组电影的评分相关的 10 万个数据点。它还包含电影元数据和用户配置文件。虽然它是一个小数据集,但你可以快速下载并在其上运行 Spark 代码。这使得它非常适合作为示例。
你可以从files.grouplens.org/datasets/movielens/ml-100k.zip下载数据集。
下载数据后,使用终端解压缩它:
>unzip ml-100k.zip
inflating: ml-100k/allbut.pl
inflating: ml-100k/mku.sh
inflating: ml-100k/README
...
inflating: ml-100k/ub.base
inflating: ml-100k/ub.test
这将创建一个名为ml-100k的目录。进入此目录并检查内容。重要的文件是u.user(用户配置文件)、u.item(电影元数据)和u.data(用户对电影的评分):
>cd ml-100k
README文件包含有关数据集的更多信息,包括每个数据文件中存在的变量。我们可以使用 head 命令来检查各个文件的内容。
例如,我们可以看到u.user文件包含用户 ID、年龄、性别、职业和邮政编码字段,用管道(|)字符分隔:
$ head -5 u.user
1|24|M|technician|85711
2|53|F|other|94043
3|23|M|writer|32067
4|24|M|technician|43537
5|33|F|other|15213
u.item文件包含电影 ID、标题、发布日期和 IMDB 链接字段以及一组与电影类别数据相关的字段。它也是用|字符分隔的:
$head -5 u.item
1|Toy Story (1995)|01-Jan-1995||http://us.imdb.com/M/title-
exact?Toy%20Story%20(1995)|0|0|0|1|1|1|0|0|0|0|0|0|0|0|0|0|0|0|0
2|GoldenEye (1995)|01-Jan-1995||http://us.imdb.com/M/title-
exact?GoldenEye%20(1995)|0|1|1|0|0|0|0|0|0|0|0|0|0|0|0|0|1|0|0
3|Four Rooms (1995)|01-Jan-1995||http://us.imdb.com/M/title-
exact?Four%20Rooms%20(1995)|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|1|0|0
4|Get Shorty (1995)|01-Jan-1995||http://us.imdb.com/M/title-
exact?Get%20Shorty%20(1995)|0|1|0|0|0|1|0|0|1|0|0|0|0|0|0|0|0|0|0
5|Copycat (1995)|01-Jan-1995||http://us.imdb.com/M/title-
exact?Copycat%20(1995)|0|0|0|0|0|0|1|0|1|0|0|0|0|0|0|0|1|0|0
前面列出的数据格式如下:
movie id | movie title | release date | video release date | IMDb
URL | unknown | Action | Adventure | Animation | Children's |
Comedy | Crime | Documentary | Drama | Fantasy | Film-Noir |
Horror | Musical | Mystery | Romance | Sci-Fi | Thriller | War |
Western |
最后 19 个字段是电影的流派,1 表示电影属于该流派,0 表示不属于;电影可以同时属于几种流派。
电影 ID 是u.data数据集中使用的 ID。它包含 943 个用户对 1682 个项目的 100000 个评分。每个用户至少对 20 部电影进行了评分。用户和项目从 1 开始编号。数据是随机排序的。这是一个以制表符分隔的字段列表:
user id | item id | rating | timestamp
时间戳是自 1970 年 1 月 1 日 UTC 以来的 Unix 秒。
让我们看一下u.data文件中的一些数据:
>head -5 u.data
1962423881250949
1863023891717742
223771878887116
244512880606923
1663461886397596
探索和可视化你的数据
本章的源代码可以在PATH/spark-ml/Chapter04找到:
-
Python 代码位于
/MYPATH/spark-ml/Chapter_04/python -
Scala 代码位于
/MYPATH/spark-ml/Chapter_04/scala
Python 示例可用于 1.6.2 和 2.0.0 版本;我们将在本书中专注于 2.0.0 版本:
├── 1.6.2
│ ├── com
│ │ ├── __init__.py
│ │ └── sparksamples
│ │ ├── __init__.py
│ │ ├── movie_data.py
│ │ ├── plot_user_ages.py
│ │ ├── plot_user_occupations.py
│ │ ├── rating_data.py
│ │ ├── user_data.py
│ │ ├── util.py
│ │
│ └── __init__.py
├── 2.0.0
│ └── com
│ ├── __init__.py
│ └── sparksamples
│ ├── __init__.py
│ ├── movie_data.py
│ ├── plot_user_ages.py
│ ├── plot_user_occupations.py
│ ├── rating_data.py
│ ├── spark-warehouse
│ ├── user_data.py
│ ├── util.py
│
Scala 示例的结构如下所示:
├── 1.6.2
│ ├── build.sbt
│ ├── spark-warehouse
│ ├── src
│ │ └── main
│ │ └── scala
│ │ └── org
│ │ └── sparksamples
│ │ ├── CountByRatingChart.scala
│ │ ├── exploredataset
│ │ │ ├── explore_movies.scala
│ │ │ ├── explore_ratings.scala
│ │ │ └── explore_users.scala
│ │ ├── featureext
│ │ │ ├── ConvertWordsToVectors.scala
│ │ │ ├── StandardScalarSample.scala
│ │ │ └── TfIdfSample.scala
│ │ ├── MovieAgesChart.scala
│ │ ├── MovieDataFillingBadValues.scala
│ │ ├── MovieData.scala
│ │ ├── RatingData.scala
│ │ ├── UserAgesChart.scala
│ │ ├── UserData.scala
│ │ ├── UserOccupationChart.scala
│ │ ├── UserRatingsChart.scala
│ │ └── Util.scala
Scala 2.0.0 示例:
├── 2.0.0
│ ├── build.sbt
│ ├── src
│ │ └── main
│ │ └── scala
│ │ └── org
│ │ └── sparksamples
│ │ ├── CountByRatingChart.scala
│ │ ├── df
│ │ ├── exploredataset
│ │ │ ├── explore_movies.scala
│ │ │ ├── explore_ratings.scala
│ │ │ └── explore_users.scala
│ │ ├── featureext
│ │ │ ├── ConvertWordsToVectors.scala
│ │ │ ├── StandardScalarSample.scala
│ │ │ └── TfIdfSample.scala
│ │ ├── MovieAgesChart.scala
│ │ ├── MovieDataFillingBadValues.scala
│ │ ├── MovieData.scala
│ │ ├── RatingData.scala
│ │ ├── UserAgesChart.scala
│ │ ├── UserData.scala
│ │ ├── UserOccupationChart.scala
│ │ ├── UserRatingsChart.scala
│ │ └── Util.scala
转到以下目录并运行以下命令来运行示例:
$ cd /MYPATH/spark-ml/Chapter_04/scala/2.0.0
$ sbt compile
$ sbt run
探索用户数据集
首先,我们将分析 MovieLens 用户的特征。
我们使用custom_schema将|分隔的数据加载到 DataFrame 中。这个 Python 代码在com/sparksamples/Util.py中:
def get_user_data():
custom_schema = StructType([
StructField("no", StringType(), True),
StructField("age", IntegerType(), True),
StructField("gender", StringType(), True),
StructField("occupation", StringType(), True),
StructField("zipCode", StringType(), True)
])
frompyspark.sql import SQLContext
frompyspark.sql.types import *
sql_context = SQLContext(sc)
user_df = sql_context.read
.format('com.databricks.spark.csv')
.options(header='false', delimiter='|')
.load("%s/ml-100k/u.user"% PATH, schema =
custom_schema)
returnuser_df
这个函数是从user_data.py中调用的,如下所示:
user_data = get_user_data()
print(user_data.first)
你应该看到类似于这样的输出:
u'1|24|M|technician|85711'
代码清单:
将数据加载到 DataFrame 中的 Scala 中的类似代码如下。此代码在Util.scala中:
val customSchema = StructType(Array(
StructField("no", IntegerType, true),
StructField("age", StringType, true),
StructField("gender", StringType, true),
StructField("occupation", StringType, true),
StructField("zipCode", StringType, true)));
val spConfig = (new
SparkConf).setMaster("local").setAppName("SparkApp")
val spark = SparkSession
.builder()
.appName("SparkUserData").config(spConfig)
.getOrCreate()
val user_df = spark.read.format("com.databricks.spark.csv")
.option("delimiter", "|").schema(customSchema)
.load("/home/ubuntu/work/ml-resources/spark-ml/data/ml-
100k/u.user")
val first = user_df.first()
println("First Record : " + first)
你应该看到类似于这样的输出:
u'1|24|M|technician|85711'
正如我们所看到的,这是我们用户数据文件的第一行,由"|"字符分隔。
first函数类似于collect,但它只将 RDD 的第一个元素返回给驱动程序。我们还可以使用take(k)来仅将 RDD 的前k个元素收集到驱动程序。
我们将使用之前创建的 DataFrame,并使用groupBy函数,然后是count()和collect()来计算用户数、性别、邮政编码和职业。然后计算用户数、性别、职业和邮政编码的数量。我们可以通过运行以下代码来实现这一点。请注意,我们不需要对数据进行缓存,因为这个大小是不必要的:
num_users = user_data.count()
num_genders =
len(user_data.groupBy("gender").count().collect())
num_occupation =
len(user_data.groupBy("occupation").count().collect())
num_zipcodes =
len(user_data.groupby("zipCode").count().collect())
print("Users: "+ str(num_users))
print("Genders: "+ str(num_genders))
print("Occupation: "+ str(num_occupation))
print("ZipCodes: "+ str(num_zipcodes))
你将看到以下输出:
Users: 943
Genders: 2
Occupations: 21
ZIPCodes: 795
同样,我们可以使用 Scala 实现获取用户数、性别、职业和邮政编码的逻辑。
val num_genders = user_df.groupBy("gender").count().count()
val num_occupations =
user_df.groupBy("occupation").count().count()
val num_zipcodes = user_df.groupBy("zipCode").count().count()
println("num_users : "+ user_df.count())
println("num_genders : "+ num_genders)
println("num_occupations : "+ num_occupations)
println("num_zipcodes: "+ num_zipcodes)
println("Distribution by Occupation")
println(user_df.groupBy("occupation").count().show())
你将看到以下输出:
num_users: 943
num_genders: 2
num_occupations: 21
num_zipcodes: 795
接下来,我们将创建一个直方图来分析用户年龄的分布。
在 Python 中,首先我们将DataFrame获取到变量user_data中。接下来,我们将调用select('age')并将结果收集到 Row 对象的列表中。然后,我们迭代并提取年龄参数并填充user_ages_list。
我们将使用 Python matplotlib 库的hist函数。
user_data = get_user_data()
user_ages = user_data.select('age').collect()
user_ages_list = []
user_ages_len = len(user_ages)
for i in range(0, (user_ages_len - 1)):
user_ages_list.append(user_ages[i].age)
plt.hist(user_ages_list, bins=20, color='lightblue', normed=True)
fig = matplotlib.pyplot.gcf()
fig.set_size_inches(16, 10)
plt.show()
我们将user_ages_list和我们直方图的箱数(在这种情况下为 20)一起传递给hist函数。使用normed=True参数,我们还指定希望直方图被归一化,以便每个桶代表落入该桶的整体数据的百分比。
你将看到包含直方图图表的图像,看起来类似于这里显示的图像。正如我们所看到的,MovieLens 用户的年龄有些偏向年轻的观众。大量用户的年龄在 15 到 35 岁左右。
用户年龄的分布
对于 Scala 直方图图表,我们使用基于 JFreeChart 的库。我们将数据分成 16 个箱子来显示分布。
我们使用github.com/wookietreiber/scala-chart库从 Scala 映射m_sorted创建条形图。
首先,我们使用select("age")函数从userDataFrame中提取ages_array。
然后,我们填充mx Map,这是用于显示的箱子。我们对 mx Map 进行排序以创建ListMap,然后用它来填充DefaultCategorySet ds:
val userDataFrame = Util.getUserFieldDataFrame()
val ages_array = userDataFrame.select("age").collect()
val min = 0
val max = 80
val bins = 16
val step = (80/bins).toInt
var mx = Map(0 ->0)
for (i <- step until (max + step) by step) {
mx += (i -> 0)
}
for( x <- 0 until ages_array.length) {
val age = Integer.parseInt(
ages_array(x)(0).toString)
for(j <- 0 until (max + step) by step) {
if(age >= j && age < (j + step)){
mx = mx + (j -> (mx(j) + 1))
}
}
}
val mx_sorted = ListMap(mx.toSeq.sortBy(_._1):_*)
val ds = new org.jfree.data.category.DefaultCategoryDataset
mx_sorted.foreach{ case (k,v) => ds.addValue(v,"UserAges", k)}
val chart = ChartFactories.BarChart(ds)
chart.show()
Util.sc.stop()
完整的代码可以在UserAgesChart.scala文件中找到,并在此处列出:
按职业计数
我们统计用户各种职业的数量。
实施以下步骤以获取职业 DataFrame 并填充列表,然后使用 Matplotlib 显示。
-
获取
user_data。 -
使用
groupby("occupation")提取职业计数并对其调用count()。 -
从行列表中提取
tuple("occupation","count")的列表。 -
创建
x_axis和y_axis中的值的numpy数组。 -
创建类型为 bar 的图表。
-
显示图表。
完整的代码清单如下:
user_data = get_user_data()
user_occ = user_data.groupby("occupation").count().collect()
user_occ_len = len(user_occ)
user_occ_list = []
for i in range(0, (user_occ_len - 1)):
element = user_occ[i]
count = element. __getattr__('count')
tup = (element.occupation, count)
user_occ_list.append(tup)
x_axis1 = np.array([c[0] for c in user_occ_list])
y_axis1 = np.array([c[1] for c in user_occ_list])
x_axis = x_axis1[np.argsort(y_axis1)]
y_axis = y_axis1[np.argsort(y_axis1)]
pos = np.arange(len(x_axis))
width = 1.0
ax = plt.axes()
ax.set_xticks(pos + (width / 2))
ax.set_xticklabels(x_axis)
plt.bar(pos, y_axis, width, color='lightblue')
plt.xticks(rotation=30)
fig = matplotlib.pyplot.gcf()
fig.set_size_inches(20, 10)
plt.show()
您生成的图像应该看起来像这里的图像。看起来最普遍的职业是学生,其他,教育工作者,管理员,工程师和程序员。
用户职业分布
在 Scala 中,我们按以下步骤进行操作:
-
首先获取
userDataFrame -
我们提取职业列:
userDataFrame.select("occupation")
- 按职业对行进行分组:
val occupation_groups =
userDataFrame.groupBy("occupation").count()
- 按计数对行进行排序:
val occupation_groups_sorted =
occupation_groups.sort("count")
-
从
occupation_groups_collection中填充默认类别集 ds -
显示 Jfree Bar Chart
完整的代码清单如下:
val userDataFrame = Util.getUserFieldDataFrame()
val occupation = userDataFrame.select("occupation")
val occupation_groups =
userDataFrame.groupBy("occupation").count()
val occupation_groups_sorted = occupation_groups.sort("count")
occupation_groups_sorted.show()
val occupation_groups_collection =
occupation_groups_sorted.collect()
val ds = new org.jfree.data.category.DefaultCategoryDataset
val mx = scala.collection.immutable.ListMap()
for( x <- 0 until occupation_groups_collection.length) {
val occ = occupation_groups_collection(x)(0)
val count = Integer.parseInt(
occupation_groups_collection(x)(1).toString)
ds.addValue(count,"UserAges", occ.toString)
}
val chart = ChartFactories.BarChart(ds)
val font = new Font("Dialog", Font.PLAIN,5);
chart.peer.getCategoryPlot.getDomainAxis().
setCategoryLabelPositions(CategoryLabelPositions.UP_90);
chart.peer.getCategoryPlot.getDomainAxis.setLabelFont(font)
chart.show()
Util.sc.stop()
此代码的输出如下所示:
以下图显示了从先前源代码生成的 JFreeChart:
电影数据集
接下来,我们将调查电影目录的一些属性。我们可以检查电影数据文件的一行,就像我们之前对用户数据所做的那样,然后计算电影的数量:
我们将通过使用格式com.databrick.spark.csv进行解析并给出|分隔符来创建电影数据的 DataFrame。然后,我们使用CustomSchema来填充 DataFrame 并返回它:
def getMovieDataDF() : DataFrame = {
val customSchema = StructType(Array(
StructField("id", StringType, true),
StructField("name", StringType, true),
StructField("date", StringType, true),
StructField("url", StringType, true)));
val movieDf = spark.read.format(
"com.databricks.spark.csv")
.option("delimiter", "|").schema(customSchema)
.load(PATH_MOVIES)
return movieDf
}
然后从MovieData Scala 对象调用此方法。
实施以下步骤以过滤日期并将其格式化为Year:
-
创建一个临时视图。
-
使用
SparkSession.Util.spark将函数Util.convertYear注册为 UDF(这是我们的自定义类)。 -
在此
SparkSession上执行 SQL,如下所示。 -
将生成的 DataFrame 按
Year分组并调用count()函数。
逻辑的完整代码清单如下:
def getMovieYearsCountSorted(): scala.Array[(Int,String)] = {
val movie_data_df = Util.getMovieDataDF()
movie_data_df.createOrReplaceTempView("movie_data")
movie_data_df.printSchema()
Util.spark.udf.register("convertYear", Util.convertYear _)
movie_data_df.show(false)
val movie_years = Util.spark.sql(
"select convertYear(date) as year from movie_data")
val movie_years_count = movie_years.groupBy("year").count()
movie_years_count.show(false)
val movie_years_count_rdd = movie_years_count.rdd.map(
row => (Integer.parseInt(row(0).toString), row(1).toString))
val movie_years_count_collect = movie_years_count_rdd.collect()
val movie_years_count_collect_sort =
movie_years_count_collect.sortBy(_._1)
}
def main(args: Array[String]) {
val movie_years = MovieData.getMovieYearsCountSorted()
for( a <- 0 to (movie_years.length -1)){
print(movie_years(a))
}
}
输出将与此处显示的类似:
(1900,1)
(1922,1)
(1926,1)
(1930,1)
(1931,1)
(1932,1)
(1933,2)
(1934,4)
(1935,4)
(1936,2)
(1937,4)
(1938,3)
(1939,7)
(1940,8)
(1941,5)
(1942,2)
(1943,4)
(1944,5)
(1945,4)
(1946,5)
(1947,5)
(1948,3)
(1949,4)
(1950,7)
(1951,5)
(1952,3)
(1953,2)
(1954,7)
(1955,5)
(1956,4)
(1957,8)
(1958,9)
(1959,4)
(1960,5)
(1961,3)
(1962,5)
(1963,6)
(1964,2)
(1965,5)
(1966,2)
(1967,5)
(1968,6)
(1969,4)
(1970,3)
(1971,7)
(1972,3)
(1973,4)
(1974,8)
(1975,6)
(1976,5)
(1977,4)
(1978,4)
(1979,9)
(1980,8)
(1981,12)
(1982,13)
(1983,5)
(1984,8)
(1985,7)
(1986,15)
(1987,13)
(1988,11)
(1989,15)
(1990,24)
(1991,22)
(1992,37)
(1993,126)
(1994,214)
(1995,219)
(1996,355)
(1997,286)
(1998,65)
接下来,我们绘制先前创建的电影收藏年龄的图表。我们在 Scala 中使用 JFreeChart,并从MovieData.getMovieYearsCountSorted()创建的收藏中填充org.jfree.data.category.DefaultCategoryDataset。
object MovieAgesChart {
def main(args: Array[String]) {
val movie_years_count_collect_sort =
MovieData.getMovieYearsCountSorted()
val ds = new
org.jfree.data.category.DefaultCategoryDataset
for(i <- movie_years_count_collect_sort){
ds.addValue(i._2.toDouble,"year", i._1)
}
val chart = ChartFactories.BarChart(ds)
chart.show()
Util.sc.stop()
}
}
请注意,大多数电影来自 1996 年。创建的图表如下所示:
电影年龄分布
探索评分数据集
现在让我们来看一下评分数据:
代码位于RatingData下:
object RatingData {
def main(args: Array[String]) {
val customSchema = StructType(Array(
StructField("user_id", IntegerType, true),
StructField("movie_id", IntegerType, true),
StructField("rating", IntegerType, true),
StructField("timestamp", IntegerType, true)))
val spConfig = (new SparkConf).setMaster("local").
setAppName("SparkApp")
val spark = SparkSession.builder()
.appName("SparkRatingData").config(spConfig)
.getOrCreate()
val rating_df = spark.read.format("com.databricks.spark.csv")
.option("delimiter", "t").schema(customSchema)
.load("../../data/ml-100k/u.data")
rating_df.createOrReplaceTempView("df")
val num_ratings = rating_df.count()
val num_movies = Util.getMovieDataDF().count()
val first = rating_df.first()
println("first:" + first)
println("num_ratings:" + num_ratings)
}
}
上述代码的输出如下所示:
First: 196 242 3 881250949
num_ratings:100000
有 100,000 个评分,与用户和电影数据集不同,这些记录是用制表符("t")分隔的。正如你可能已经猜到的,我们可能想要计算一些基本的摘要统计和评分值的频率直方图。让我们现在来做这个。
数据被分开了。正如你可能已经猜到的,我们可能想要计算一些基本的摘要统计和评分值的频率直方图。让我们现在来做这个:)。正如你可能已经猜到的,我们可能想要计算一些基本的摘要统计和评分值的频率直方图。让我们现在来做这个:
我们将计算最大、最小和平均评分。我们还将计算每个用户和每部电影的评分。我们正在使用 Spark SQL 来提取电影评分的最大、最小和平均值。
val max = Util.spark.sql("select max(rating) from df")
max.show()
val min = Util.spark.sql("select min(rating) from df")
min.show()
val avg = Util.spark.sql("select avg(rating) from df")
avg.show()
上述代码的输出如下所示:
+----------------+
|. max(rating) |
+----------------+
| 5 |
+----------------+
+----------------+
|. min(rating) |
+----------------+
| 1 |
+----------------+
+-----------------+
|. avg(rating) |
+-----------------+
| 3.52986 |
+-----------------+
评分计数条形图
从结果来看,用户对电影的平均评分大约为 3.5,因此我们可能期望评分的分布会偏向稍高的评分。让我们通过使用与职业相似的过程创建一个评分值的条形图来看看这是否成立。
绘制评分与计数的代码如下所示。这在文件CountByRatingChart.scala中可用:
object CountByRatingChart {
def main(args: Array[String]) {
val customSchema = StructType(Array(
StructField("user_id", IntegerType, true),
StructField("movie_id", IntegerType, true),
StructField("rating", IntegerType, true),
StructField("timestamp", IntegerType, true)))
val spConfig = (new SparkConf).setMaster("local").
setAppName("SparkApp")
val spark = SparkSession
.builder()
.appName("SparkRatingData").config(spConfig)
.getOrCreate()
val rating_df = spark.read.format("com.databricks.spark.csv")
.option("delimiter", "t").schema(customSchema)
val rating_df_count = rating_df.groupBy("rating").
count().sort("rating")
rating_df_count.show()
val rating_df_count_collection = rating_df_count.collect()
val ds = new org.jfree.data.category.DefaultCategoryDataset
val mx = scala.collection.immutable.ListMap()
for( x <- 0 until rating_df_count_collection.length) {
val occ = rating_df_count_collection(x)(0)
val count = Integer.parseInt(
rating_df_count_collection(x)(1).toString)
ds.addValue(count,"UserAges", occ.toString)
}
val chart = ChartFactories.BarChart(ds)
val font = new Font("Dialog", Font.PLAIN,5);
chart.peer.getCategoryPlot.getDomainAxis().
setCategoryLabelPositions(CategoryLabelPositions.UP_90);
chart.peer.getCategoryPlot.getDomainAxis.setLabelFont(font)
chart.show()
Util.sc.stop()
}
}
在执行上一个代码后,您将得到以下条形图:
评分数量的分布
我们还可以查看每个用户所做评分的分布。回想一下,我们之前通过使用制表符分割评分来计算了上述代码中使用的rating_data RDD。我们现在将在下面的代码中再次使用rating_data变量。
代码位于UserRatingChart类中。我们将从u.data文件创建一个 DataFrame,该文件是以制表符分隔的,然后按每个用户给出的评分数量进行分组并按升序排序。
object UserRatingsChart {
def main(args: Array[String]) {
}
}
让我们首先尝试显示评分。
val customSchema = StructType(Array(
StructField("user_id", IntegerType, true),
StructField("movie_id", IntegerType, true),
StructField("rating", IntegerType, true),
StructField("timestamp", IntegerType, true)))
val spConfig = (new
SparkConf).setMaster("local").setAppName("SparkApp")
val spark = SparkSession
.builder()
.appName("SparkRatingData").config(spConfig)
.getOrCreate()
val rating_df = spark.read.format("com.databricks.spark.csv")
.option("delimiter", "t").schema(customSchema)
.load("../../data/ml-100k/u.data")
val rating_nos_by_user =
rating_df.groupBy("user_id").count().sort("count")
val ds = new org.jfree.data.category.DefaultCategoryDataset
rating_nos_by_user.show(rating_nos_by_user.collect().length)
上述代码的输出如下所示:
+-------+-----+
|user_id|count|
+-------+-----+
| 636| 20|
| 572| 20|
| 926| 20|
| 824| 20|
| 166| 20|
| 685| 20|
| 812| 20|
| 418| 20|
| 732| 20|
| 364| 20|
....
222| 387|
| 293| 388|
| 92| 388|
| 308| 397|
| 682| 399|
| 94| 400|
| 7| 403|
| 846| 405|
| 429| 414|
| 279| 434|
| 181| 435|
| 393| 448|
| 234| 480|
| 303| 484|
| 537| 490|
| 416| 493|
| 276| 518|
| 450| 540|
| 13| 636|
| 655| 685|
| 405| 737|
+-------+-----+
在以文本方式显示数据后,让我们通过从rating_nos_by_user DataFrame中加载数据到DefaultCategorySet来使用 JFreeChart 显示数据。
val step = (max/bins).toInt
for(i <- step until (max + step) by step) {
mx += (i -> 0);
}
for( x <- 0 until rating_nos_by_user_collect.length) {
val user_id =
Integer.parseInt(rating_nos_by_user_collect(x)(0).toString)
val count =
Integer.parseInt(rating_nos_by_user_collect(x)(1).toString)
ds.addValue(count,"Ratings", user_id)
}
val chart = ChartFactories.BarChart(ds)
chart.peer.getCategoryPlot.getDomainAxis().setVisible(false)
chart.show()
Util.sc.stop()
在前面的图表中,x 轴是用户 ID,y 轴是评分数量,从最低的 20 到最高的 737 不等。
处理和转换您的数据
为了使原始数据可用于机器学习算法,我们首先需要清理数据,并可能以各种方式对其进行转换,然后从转换后的数据中提取有用的特征。转换和特征提取步骤是密切相关的,在某些情况下,某些转换本身就是特征提取的一种情况。
我们已经看到了在电影数据集中清理数据的需要的一个例子。一般来说,现实世界的数据集包含不良数据、缺失数据点和异常值。理想情况下,我们会纠正不良数据;然而,这通常是不可能的,因为许多数据集来自某种不能重复的收集过程(例如在 Web 活动数据和传感器数据中的情况)。缺失值和异常值也很常见,可以以类似于不良数据的方式处理。总的来说,广泛的选择如下:
-
过滤或删除具有不良或缺失值的记录:有时是不可避免的;然而,这意味着丢失不良或缺失记录的好部分。
-
填补坏数据或缺失数据:我们可以尝试根据我们可用的其余数据为坏数据或缺失数据分配一个值。方法可以包括分配零值,分配全局均值或中位数,插值附近或类似的数据点(通常在时间序列数据集中),等等。决定正确的方法通常是一个棘手的任务,取决于数据、情况和个人经验。
-
对异常值应用健壮的技术:异常值的主要问题在于它们可能是正确的值,即使它们是极端的。它们也可能是错误的。很难知道你正在处理哪种情况。异常值也可以被移除或填充,尽管幸运的是,有统计技术(如健壮回归)来处理异常值和极端值。
-
对潜在异常值应用转换:另一种处理异常值或极端值的方法是应用转换,比如对具有潜在异常值或显示潜在值范围较大的特征应用对数或高斯核转换。这些类型的转换可以减弱变量规模的大幅变化对结果的影响,并将非线性关系转换为线性关系。
填补坏数据或缺失数据
让我们看一下电影评论的年份并清理它。
我们已经看到了一个过滤坏数据的例子。在前面的代码之后,以下代码片段将填充方法应用于坏的发布日期记录,将空字符串分配为 1900(稍后将被中位数替换):
Util.spark.udf.register("convertYear", Util.convertYear _)
movie_data_df.show(false)
val movie_years = Util.spark.sql("select convertYear(date) as year from movie_data")
movie_years.createOrReplaceTempView("movie_years")
Util.spark.udf.register("replaceEmptyStr", replaceEmptyStr _)
val years_replaced = Util.spark.sql("select replaceEmptyStr(year)
as r_year from movie_years")
在前面的代码中,我们使用了此处描述的replaceEmtryStr函数:
def replaceEmptyStr(v : Int): Int = {
try {
if(v.equals("") ) {
return 1900
} else {
returnv
}
}catch{
case e: Exception => println(e)
return 1900
}
}
接下来,我们提取不是 1900 年的经过筛选的年份,将Array[Row]替换为Array[int]并计算各种指标:
-
条目的总和
-
条目的总数
-
年份的平均值
-
年份的中位数
-
转换后的总年数
-
1900 的计数
val movie_years_filtered = movie_years.filter(x =>(x == 1900) )
val years_filtered_valid = years_replaced.filter(x => (x !=
1900)).collect()
val years_filtered_valid_int = new
ArrayInt
for( i <- 0 until years_filtered_valid.length -1){
val x = Integer.parseInt(years_filtered_valid(i)(0).toString)
years_filtered_valid_int(i) = x
}
val years_filtered_valid_int_sorted =
years_filtered_valid_int.sorted
val years_replaced_int = new Array[Int]
(years_replaced.collect().length)
val years_replaced_collect = years_replaced.collect()
for( i <- 0 until years_replaced.collect().length -1){
val x = Integer.parseInt(years_replaced_collect(i)(0).toString)
years_replaced_int(i) = x
}
val years_replaced_rdd = Util.sc.parallelize(years_replaced_int)
val num = years_filtered_valid.length
var sum_y = 0
years_replaced_int.foreach(sum_y += _)
println("Total sum of Entries:"+ sum_y)
println("Total No of Entries:"+ num)
val mean = sum_y/num
val median_v = median(years_filtered_valid_int_sorted)
Util.sc.broadcast(mean)
println("Mean value of Year:"+ mean)
println("Median value of Year:"+ median_v)
val years_x = years_replaced_rdd.map(v => replace(v , median_v))
println("Total Years after conversion:"+ years_x.count())
var count = 0
Util.sc.broadcast(count)
val years_with1900 = years_x.map(x => (if(x == 1900) {count +=1}))
println("Count of 1900: "+ count)
前面代码的输出如下;替换为中位数后带有1900的值表明我们的处理是成功的
Total sum of Entries:3344062
Total No of Entries:1682
Mean value of Year:1988
Median value of Year:1995
Total Years after conversion:1682
Count of 1900: 0
Count of 1900: 0
我们在这里计算了发布年份的均值和中位数。从输出中可以看出,中位数发布年份要高得多,因为年份的分布是倾斜的。虽然要准确决定在给定情况下使用哪个填充值并不总是直截了当的,但在这种情况下,由于这种偏斜,使用中位数是可行的。
注意,前面的代码示例严格来说不太可扩展,因为它需要将所有数据收集到驱动程序中。我们可以使用 Spark 的mean函数来计算数值 RDD 的均值,但目前没有中位数函数可用。我们可以通过创建自己的函数或使用sample函数创建的数据集样本来计算中位数来解决这个问题(我们将在接下来的章节中看到更多)。
从数据中提取有用的特征
数据清理完成后,我们准备从数据中提取实际特征,用于训练机器学习模型。
“特征”是我们用来训练模型的变量。每一行数据包含我们想要提取为训练示例的信息。
几乎所有的机器学习模型最终都是基于数字表示形式的向量进行工作;因此,我们需要将原始数据转换为数字。
特征大致分为几类,如下所示:
-
数值特征:这些特征通常是实数或整数,例如我们之前使用的用户年龄。
-
分类特征:这些特征指的是变量在任何给定时间可以取一组可能状态中的一个。我们数据集中的示例可能包括用户的性别或职业,或电影类别。
-
文本特征:这些是从数据中的文本内容派生出来的特征,例如电影标题、描述或评论。
-
其他特征:大多数其他类型的特征最终都以数值形式表示。例如,图像、视频和音频可以表示为一组数值数据。地理位置可以表示为纬度和经度或地理哈希数据。
在这里,我们将涵盖数值、分类和文本特征。
数值特征
任何普通数字和数值特征之间有什么区别?实际上,任何数值数据都可以用作输入变量。然而,在机器学习模型中,您会了解每个特征的权重向量。这些权重在将特征值映射到结果或目标变量(在监督学习模型的情况下)中起着作用。
因此,我们希望使用有意义的特征,即模型可以学习特征值和目标变量之间的关系的特征。例如,年龄可能是一个合理的特征。也许增长年龄和某个结果之间存在直接关系。同样,身高是一个可以直接使用的数值特征的很好的例子。
我们经常会看到,数值特征在其原始形式下不太有用,但可以转化为更有用的表示。位置就是这样一个例子。
使用原始位置(比如纬度和经度)可能并不那么有用,除非我们的数据确实非常密集,因为我们的模型可能无法学习原始位置和结果之间的有用关系。然而,某种聚合或分箱表示的位置(例如城市或国家)与结果之间可能存在关系。
分类特征
分类特征不能以其原始形式用作输入,因为它们不是数字;相反,它们是变量可以取的一组可能值的成员。在前面提到的示例中,用户职业是一个可以取学生、程序员等值的分类变量。
为了将分类变量转换为数值表示,我们可以使用一种常见的方法,称为1-of-k编码。需要使用 1-of-k 编码这样的方法来表示。
需要使用 1-of-k 编码这样的方法来表示名义变量,使其对机器学习任务有意义。有序变量可能以其原始形式使用,但通常以与名义变量相同的方式进行编码。
假设变量可以取 k 个可能的值。如果我们为每个可能的值分配一个从 1 到 k 的索引,那么我们可以使用长度为 k 的二进制向量来表示变量的给定状态;在这里,除了对应于给定变量状态的索引处的条目设置为 1 之外,所有条目都为零。
例如,学生是[0],程序员是[1]
因此,值为:
学生变成[1,0]
程序员变成[0,1]
提取两个职业的二进制编码,然后创建长度为 21 的二进制特征向量:
val ratings_grouped = rating_df.groupBy("rating")
ratings_grouped.count().show()
val ratings_byuser_local = rating_df.groupBy("user_id").count()
val count_ratings_byuser_local = ratings_byuser_local.count()
ratings_byuser_local.show(ratings_byuser_local.collect().length)
val movie_fields_df = Util.getMovieDataDF()
val user_data_df = Util.getUserFieldDataFrame()
val occupation_df = user_data_df.select("occupation").distinct()
occupation_df.sort("occupation").show()
val occupation_df_collect = occupation_df.collect()
var all_occupations_dict_1:Map[String, Int] = Map()
var idx = 0;
// for loop execution with a range
for( idx <- 0 to (occupation_df_collect.length -1)){
all_occupations_dict_1 +=
occupation_df_collect(idx)(0).toString() -> idx
}
println("Encoding of 'doctor : " +
all_occupations_dict_1("doctor"))
println("Encoding of 'programmer' : " +
all_occupations_dict_1("programmer"))
前面println语句的输出如下:
Encoding of 'doctor : 20
Encoding of 'programmer' : 5
var k = all_occupations_dict_1.size
var binary_x = DenseVector.zerosDouble
var k_programmer = all_occupations_dict_1("programmer")
binary_x(k_programmer) = 1
println("Binary feature vector: %s" + binary_x)
println("Length of binary vector: " + k)
前面命令的输出,显示了二进制特征向量和二进制向量的长度:
Binary feature vector: %sDenseVector(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
Length of binary vector: 21
派生特征
正如我们之前提到的,通常有必要从一个或多个可用变量计算派生特征。我们希望派生特征可以提供比仅使用原始形式的变量更多的信息。
例如,我们可以计算每个用户对其评分的所有电影的平均评分。这将是一个特征,可以为我们的模型提供用户特定的截距(事实上,这是推荐模型中常用的方法)。我们已经从原始评分数据中创建了一个新特征,可以帮助我们学习更好的模型。
从原始数据派生特征的示例包括计算平均值、中位数、方差、总和、差异、最大值或最小值和计数。我们已经在创建电影年龄特征时看到了这种情况,该特征是从电影的发行年份和当前年份派生出来的。通常,使用这些转换的背后思想是以某种方式总结数值数据,这可能会使模型更容易学习特征,例如通过分箱特征。这些常见的示例包括年龄、地理位置和时间等变量。
将时间戳转换为分类特征
提取一天中的时间
为了说明如何从数值数据中派生分类特征,我们将使用用户对电影的评分时间。从时间戳中提取日期和时间,然后提取一天中的小时。
我们需要一个函数来提取评分时间戳(以秒为单位)的datetime表示;我们现在将创建这个函数:从时间戳中提取日期和时间,然后提取一天中的小时。这将导致每个评分的一天中的小时的 RDD。
Scala
首先,我们定义一个函数,该函数从日期字符串中提取currentHour:
def getCurrentHour(dateStr: String) : Integer = {
var currentHour = 0
try {
val date = new Date(dateStr.toLong)
return int2Integer(date.getHours)
} catch {
case _ => return currentHour
}
return 1
}
前面代码的输出如下:
Timestamps DataFrame is extracted from rating_df by creating a TempView df and running a select statement.
相关代码清单:
val customSchema = StructType(Array(
StructField("user_id", IntegerType, true),
StructField("movie_id", IntegerType, true),
StructField("rating", IntegerType, true),
StructField("timestamp", IntegerType, true)))
val spConfig = (new
SparkConf).setMaster("local").setAppName("SparkApp")
val spark = SparkSession
.builder()
.appName("SparkRatingData").config(spConfig)
.getOrCreate()
val rating_df = spark.read.format("com.databricks.spark.csv")
.option("delimiter", "t").schema(customSchema)
.load("../../data/ml-100k/u.data")
rating_df.createOrReplaceTempView("df")
Util.spark.udf.register("getCurrentHour", getCurrentHour _)
val timestamps_df =
Util.spark.sql("select getCurrentHour(timestamp) as hour from
df")
timestamps_df.show()
提取一天中的时间
我们已经将原始时间数据转换为表示给出评分的一天中的小时的分类特征。
现在,假设我们决定这是一个太粗糙的表示。也许我们想进一步完善转换。我们可以将每个一天中的小时值分配到表示一天中的时间的定义桶中。
例如,我们可以说早晨是从上午 7 点到上午 11 点,午餐是从上午 11 点到下午 1 点,依此类推。使用这些时间段,我们可以创建一个函数,根据输入的小时来分配一天中的时间。
Scala
在 Scala 中,我们定义一个函数,该函数以 24 小时制的绝对时间作为输入,并返回一天中的时间:早晨、午餐、下午、晚上或夜晚。
def assignTod(hr : Integer) : String = {
if(hr >= 7 && hr < 12){
return"morning"
}else if ( hr >= 12 && hr < 14) {
return"lunch"
} else if ( hr>= 14 && hr < 18) {
return"afternoon"
} else if ( hr>= 18 && hr.<(23)) {
return"evening"
} else if ( hr>= 23 && hr <= 24) {
return"night"
} else if ( hr< 7) {
return"night"
} else {
return"error"
}
}
我们将此函数注册为 UDF,并在 select 调用中对 temp 视图时间戳进行调用。
Util.spark.udf.register("assignTod", assignTod _)
timestamps_df.createOrReplaceTempView("timestamps")
val tod = Util.spark.sql("select assignTod(hour) as tod from
timestamps")
tod.show()
我们现在已经将时间戳变量(可以取数千个值,可能以原始形式对模型没有用)转换为小时(取 24 个值),然后转换为一天中的时间(取五个可能的值)。现在我们有了一个分类特征,我们可以使用之前概述的相同的 1-of-k 编码方法来生成一个二进制特征向量。
文本特征
在某种程度上,文本特征是一种分类和派生特征的形式。让我们以电影描述为例(我们的数据集中没有)。在这里,原始文本不能直接使用,即使作为分类特征,因为每个文本可能的值几乎是无限的。我们的模型几乎不会看到两次相同特征的出现,也无法有效学习。因此,我们希望将原始文本转换为更适合机器学习的形式,因为每个文本可能的值几乎是无限的。
处理文本的方法有很多种,自然语言处理领域致力于处理、表示和建模文本内容。全面的处理超出了本书的范围,但我们将介绍一种简单和标准的文本特征提取方法;这种方法被称为词袋模型表示。
词袋模型方法将文本内容视为文本中的单词和可能的数字的集合(这些通常被称为术语)。词袋模型的过程如下:
-
分词:首先,对文本应用某种形式的分词,将其分割成一组标记(通常是单词、数字等)。一个例子是简单的空格分词,它在每个空格上分割文本,并可能删除不是字母或数字的标点符号和其他字符。
-
停用词去除:接下来,通常会去除非常常见的词,比如"the"、"and"和"but"(这些被称为停用词)。
-
词干提取:下一步可以包括词干提取,指的是将一个词语减少到其基本形式或词干。一个常见的例子是复数变成单数(例如,dogs 变成 dog,等等)。有许多词干提取的方法,文本处理库通常包含各种词干提取算法,例如 OpenNLP、NLTK 等。详细介绍词干提取超出了本书的范围,但欢迎自行探索这些库。
-
向量化:最后一步是将处理后的术语转换为向量表示。最简单的形式可能是二进制向量表示,如果一个术语存在于文本中则赋值为 1,如果不存在则赋值为 0。这与我们之前遇到的分类 1-of-k 编码基本相同。与 1-of-k 编码一样,这需要一个术语字典,将给定的术语映射到索引号。你可能会发现,即使在停用词去除和词干提取之后,可能仍然有数百万个可能的术语。因此,使用稀疏向量表示计算
time.computetime.computetime.compute时间变得至关重要。
在第十章中,使用 Spark 进行高级文本处理,我们将涵盖更复杂的文本处理和特征提取,包括加权术语的方法;这些方法超出了我们之前看到的基本二进制编码。
简单的文本特征提取
为了展示用二进制向量表示提取文本特征的示例,我们可以使用现有的电影标题。
首先,我们将创建一个函数,用于剥离每部电影的发行年份,如果有年份存在的话,只留下电影的标题。
我们将使用正则表达式,在电影标题中搜索括号之间的年份。如果我们找到与这个正则表达式匹配的内容,我们将仅提取标题直到第一个匹配的索引(即标题字符串中开括号的索引)。
Scala
首先,我们创建一个函数,该函数接受输入字符串并使用正则表达式过滤输出。
def processRegex(input:String):String= {
val pattern = "^[^(]*".r
val output = pattern.findFirstIn(input)
return output.get
}
提取只有原始标题的 DataFrame 并创建一个名为titles的临时视图。使用 Spark 注册上面创建的函数,然后在select语句中对 DataFrame 运行它。
val raw_title =
org.sparksamples.Util.getMovieDataDF().select("name"
raw_title.show()
raw_title.createOrReplaceTempView("titles")
Util.spark.udf.register("processRegex", processRegex _)
val processed_titles = Util.spark.sql(
"select processRegex(name) from titles")
processed_titles.show()
val titles_rdd = processed_titles.rdd.map(r => r(0).toString)
titles_rdd.take(5).foreach(println)
前面代码的输出如下:
//Output of raw_title.show()
+--------------------+
| UDF(name)|
+--------------------+
| Toy Story |
| GoldenEye |
| Four Rooms |
| Get Shorty |
| Copycat |
| Shanghai Triad |
| Twelve Monkeys |
| Babe |
| Dead Man Walking |
| Richard III |
| Seven |
|Usual Suspects, The |
| Mighty Aphrodite |
| Postino, Il |
| Mr. Holland's Opus |
| French Twist |
|From Dusk Till Dawn |
| White Balloon, The |
| Antonia's Line |
| Angels and Insects |
+--------------------+
//titles_rdd.take(5).foreach(println)
Toy Story
GoldenEye
Four Rooms
Get Shorty
Copycat
然后,将我们的函数应用于原始标题,并对提取的标题应用标记化方案,将它们转换为术语,我们将使用我们之前介绍的简单的空格标记化:
接下来,我们将titles拆分成单词
val title_terms = titles_rdd.map(x => x.split(""))
title_terms.take(5).foreach(_.foreach(println))
println(title_terms.count())
应用这种简单的标记化得到以下结果:
Toy
Story
GoldenEye
Four
Rooms
Get
Shorty
Copycat
然后,我们转换单词的 rdd 并找到单词的总数-我们得到总单词的集合以及"Dead"和"Rooms"的索引。
val all_terms_dic = new ListBuffer[String]()
val all_terms = title_terms.flatMap(title_terms => title_terms).distinct().collect()
for (term <- all_terms){
all_terms_dic += term
}
println(all_terms_dic.length)
println(all_terms_dic.indexOf("Dead"))
println(all_terms_dic.indexOf("Rooms"))
这将导致以下输出:
Total number of terms: 2645
Index of term 'Dead': 147
Index of term 'Rooms': 1963
我们还可以使用 Spark 的zipWithIndex函数更有效地实现相同的结果。这个函数接受一个值的 RDD,并将它们与索引合并在一起,创建一个新的键值对 RDD,其中键将是术语,值将是术语字典中的索引。我们将使用collectAsMap将键值对 RDD 收集到驱动程序作为 Python dict方法:
Scala
val all_terms_withZip = title_terms.flatMap(title_terms =>
title_terms).distinct().zipWithIndex().collectAsMap()
println(all_terms_withZip.get("Dead"))
println(all_terms_withZip.get("Rooms"))
输出如下:
Index of term 'Dead': 147
Index of term 'Rooms': 1963
标题的稀疏向量
最后一步是创建一个将一组术语转换为稀疏向量表示的函数。为此,我们将创建一个空的稀疏矩阵,其中有一行,列数等于字典中术语的总数。然后,我们将遍历输入术语列表中的每个术语,并检查该术语是否在我们的术语字典中。如果是,我们将在对应于字典映射中的术语的索引处为向量分配一个值1:
提取的术语:
Scala
def create_vector(title_terms:Array[String],
all_terms_dic:ListBuffer[String]): CSCMatrix[Int] = {
var idx = 0
val x = CSCMatrix.zerosInt
title_terms.foreach(i => {
if (all_terms_dic.contains(i)) {
idx = all_terms_dic.indexOf(i)
x.update(0, idx, 1)
}
})
return x
}
val term_vectors = title_terms.map(title_terms =>
create_vector(title_terms, all_terms_dic))
term_vectors.take(5).foreach(println)
然后,我们可以检查我们新的稀疏向量 RDD 的前几条记录:
1 x 2453 CSCMatrix
(0,622) 1
(0,1326) 1
1 x 2453 CSCMatrix
(0,418) 1
1 x 2453 CSCMatrix
(0,729) 1
(0,996) 1
1 x 2453 CSCMatrix
(0,433) 1
(0,1414) 1
1 x 2453 CSCMatrix
(0,1559) 1
我们可以看到,每个电影标题现在都被转换为稀疏向量。我们可以看到,我们提取了两个术语的标题在向量中有两个非零条目,我们只提取了一个术语的标题有一个非零条目,依此类推。
请注意在前面示例代码中使用了 Spark 的broadcast方法来创建一个包含术语字典的广播变量。在实际应用中,这样的术语字典可能非常庞大,因此不建议使用广播变量。
归一化特征
一旦特征被提取为向量的形式,常见的预处理步骤是对数值数据进行归一化。其背后的想法是以一种方式转换每个数值特征,使其缩放到标准大小。我们可以执行不同类型的归一化,如下所示:
-
归一化特征:这通常是应用于数据集中的单个特征的转换,例如,减去均值(使特征居中)或应用标准正态转换(使特征的平均值为零,标准差为 1)。
-
归一化特征向量:这通常是应用于数据集中给定行的所有特征的转换,使得结果特征向量具有归一化长度。也就是说,我们将确保向量中的每个特征都被缩放,使得向量的范数为 1(通常是在 L1 或 L2 范数上)。
我们将以第二种情况为例。我们可以使用numpy的norm函数通过首先计算随机向量的 L2 范数,然后将向量中的每个元素除以这个范数来实现向量归一化:
//val vector = DenseVector.rand(10)
val vector = DenseVector(0.49671415, -0.1382643,
0.64768854,1.52302986, -0.23415337, -0.23413696, 1.57921282,
0.76743473, -0.46947439, 0.54256004)
val norm_fact = norm(vector)
val vec = vector/norm_fact
println(norm_fact)
println(vec)
前面代码的输出如下:
2.5908023998401077
DenseVector(0.19172212826059407, -0.053367366036303286,
0.24999534508690138, 0.5878602938201672, -0.09037870661786127, -
0.09037237267282516, 0.6095458380374597, 0.2962150760889223, -
0.18120810372453483, 0.20941776186153152)
使用 ML 进行特征归一化
Spark 在其机器学习库中提供了一些内置函数用于特征缩放和标准化。这些包括StandardScaler,它应用标准正态转换,以及Normalizer,它应用我们在前面示例代码中展示的相同特征向量归一化。
我们将在接下来的章节中探讨这些方法的使用,但现在,让我们简单比较一下使用 MLlib 的Normalizer和我们自己的结果:
from pyspark.mllib.feature import Normalizer
normalizer = Normalizer()
vector = sc.parallelize([x])
在导入所需的类之后,我们将实例化Normalizer(默认情况下,它将使用 L2 范数,就像我们之前做的那样)。请注意,在 Spark 的大多数情况下,我们需要为Normalizer提供 RDD 作为输入(它包含numpy数组或 MLlib 向量);因此,我们将从我们的向量x创建一个单元素 RDD,以便说明目的。
然后,我们将在 RDD 上使用Normalizer的transform函数。由于 RDD 中只有一个向量,我们将通过调用first将我们的向量返回给驱动程序,最后通过调用toArray函数将向量转换回numpy数组:
normalized_x_mllib =
normalizer.transform(vector).first().toArray()
最后,我们可以打印出与之前相同的细节,比较结果:
print"x:n%s" % x
print"2-Norm of x: %2.4f" % norm_x_2
print"Normalized x MLlib:n%s" % normalized_x_mllib
print"2-Norm of normalized_x_mllib: %2.4f" %
np.linalg.norm(normalized_x_mllib)
您将得到与我们自己的代码完全相同的归一化向量。但是,使用 MLlib 的内置方法肯定比编写我们自己的函数更方便和高效!等效的 Scala 实现如下:
object FeatureNormalizer {
def main(args: Array[String]): Unit = {
val v = Vectors.dense(0.49671415, -0.1382643, 0.64768854,
1.52302986, -0.23415337, -0.23413696, 1.57921282,
0.76743473, -0.46947439, 0.54256004)
val normalizer = new Normalizer(2)
val norm_op = normalizer.transform(v)
println(norm_op)
}
}
前面代码的输出如下:
[0.19172212826059407,-
0.053367366036303286,0.24999534508690138,0.5878602938201672,-
0.09037870661786127,-
0.09037237267282516,0.6095458380374597,0.2962150760889223,-
0.18120810372453483,0.20941776186153152]
使用特征提取包
虽然每次都从这些常见任务中获得。当然,我们可以为此目的创建自己的可重用代码库;但是,幸运的是,我们可以依赖现有的工具和包。由于 Spark 支持 Scala、Java 和 Python 绑定,我们可以使用这些语言中提供的包,这些包提供了处理和提取特征并将其表示为向量的复杂工具。一些用于特征提取的包的示例包括 Python 中的scikit-learn、gensim、scikit-image、matplotlib和NLTK,Java 中的OpenNLP,以及 Scala 中的Breeze和Chalk。实际上,自从 1.0 版本以来,Breeze一直是 Spark MLlib 的一部分,我们将在后面的章节中看到如何使用一些 Breeze 功能进行线性代数。
TFID
tf-idf是术语频率-逆文档频率的简称。它是一个数值统计量,旨在反映一个词对于集合或语料库中的文档的重要性。它在信息检索和文本挖掘中用作加权因子。tf-idf 值与单词在文档中出现的次数成比例增加。它受到语料库中单词频率的影响,有助于调整一些在一般情况下更频繁出现的单词。
tf-idf 被搜索引擎或文本处理引擎用作评分和排名用户查询的文档相关性的工具。
最简单的排名函数是通过对每个查询术语的 tf-idf 求和来计算的;更复杂的排名函数是这个简单模型的变体。
在术语频率tf(t,d)计算中,一种选择是使用文档中术语的原始频率:术语 t 在文档d中出现的次数。如果t的原始频率是f(t,d),那么简单的tf方案是tf(t,d) = ft,d。
Spark 的tf(t.d)实现使用了哈希。通过应用哈希函数,将原始单词映射到索引(术语)。使用映射的索引计算术语频率。
参考:
IDF
逆文档频率(IDF)表示单词提供的信息量:术语在语料库中是常见的还是罕见的。它是包含该单词的文档的总数与包含该术语的文档数量的倒数的对数比例TF-IDF
TF-IDF 是通过将 TF 和 IDF 相乘来计算的。
以下示例计算 Apache Spark README.md文件中每个术语的 TFIDF:
object TfIdfSample{
def main(args: Array[String]) {
// TODO replace with path specific to your machine
val file = Util.SPARK_HOME + "/README.md"
val spConfig = (new
SparkConf).setMaster("local").setAppName("SparkApp")
val sc = new SparkContext(spConfig)
val documents: RDD[Seq[String]] =
sc.textFile(file).map(_.split("").toSeq)
print("Documents Size:" + documents.count)
val hashingTF = new HashingTF()
val tf = hashingTF.transform(documents)
for(tf_ <- tf) {
println(s"$tf_")
}
tf.cache()
val idf = new IDF().fit(tf)
val tfidf = idf.transform(tf)
println("tfidf size : " + tfidf.count)
for(tfidf_ <- tfidf) {
println(s"$tfidf_")
}
}
}
Word2Vector
Word2Vec 工具以文本数据作为输入,并将单词向量作为输出。该工具从训练文本数据中构建词汇表并学习单词的向量表示。生成的单词向量文件可以用作许多自然语言处理和机器学习应用的特征。
调查学习到的表示的最简单方法是找到用户指定单词的最接近单词。
Apache Spark 中的 Word2Vec 实现计算单词的分布式向量表示。与 Google 提供的单机 Word2Vec 实现相比,Apache Spark 的实现是一种更可扩展的方法。
(code.google.com/archive/p/word2vec/)
Word2Vec 可以使用两种学习算法实现:连续词袋和连续跳字。
跳字模型
跳字模型的训练目标是找到对预测文档或句子中周围单词有用的单词表示。给定一系列单词w1, w2, w3, . . , wT,跳字模型最大化以下平均对数概率:
c是训练上下文的大小(可以是中心词wt的函数)。较大的c会导致更多的训练示例,从而提高准确性,但训练时间会增加。基本的跳字式公式使用softmax函数定义了p(wt+j |wt):
v[w],v' 和,w是输入和输出单词的向量表示,W是词汇表中的单词数
在 Spark 中,使用分层软最大值方法来预测单词wi给定单词wj。
以下示例显示了如何使用 Apache Spark 创建单词向量。
object ConvertWordsToVectors{
def main(args: Array[String]) {
val file =
"/home/ubuntu/work/ml-resources/" +
"spark-ml/Chapter_04/data/text8_10000"
val conf = new SparkConf().setMaster("local").
setAppName("Word2Vector")
val sc = new SparkContext(conf)
val input = sc.textFile(file).map(line => line.split("").toSeq)
val word2vec = new Word2Vec()
val model = word2vec.fit(input)
val vectors = model.getVectors
vectors foreach (
(t2) =>println (t2._1 + "-->" + t2._2.mkString(""))
)
}
}
上述代码的输出:
ideas-->0.0036772825 -9.474439E-4 0.0018383651 -6.24215E-4 -
0.0042944895 -5.839545E-4 -0.004661157 -0.0024960344 0.0046632644 -
0.00237432 -5.5691406E-5 -0.0033026629 0.0032463844 -0.0019799764 -
0.0016042799 0.0016129494 -4.099998E-4 0.0031266063 -0.0051537985
0.004354736 -8.4361364E-4 0.0016157745 -0.006367187 0.0037806155 -
4.4071436E-4 8.62155E-4 0.0051918332 0.004437387 -0.0012511226 -
8.7162864E-4 -0.0035564564 -4.2263913E-4 -0.0020519749 -
0.0034343079 0.0035128237 -0.0014698022 -7.263344E-4 -0.0030510207
-1.05513E-4 0.003316195 0.001853326 -0.003090298 -7.3562167E-4 -
0.004879414 -0.007057088 1.1937474E-4 -0.0017973455 0.0034448127
0.005289607 9.6152216E-4 0.002103868 0.0016721261 -9.6310966E-4
0.0041839285 0.0035658625 -0.0038187192 0.005523701 -1.8146896E-4 -
0.006257453 6.5041234E-4 -0.006894542 -0.0013860351 -4.7463065E-4
0.0044280654 -7.142674E-4 -0.005085546 -2.7047616E-4 0.0026938762 -
0.0020157609 0.0051508015 -0.0027767695 0.003554946 -0.0052921847
0.0020432177 -0.002188367 -0.0010223344 -0.0031813548 -0.0032866944
0.0020323955 -0.0015844131 -0.0041034482 0.0044767153 -2.5071128E-4
0.0022343954 0.004051373 -0.0021706335 8.161181E-4 0.0042591896
0.0036099665 -0.0024891358 -0.0043153367 -0.0037649528 -
0.0033249175 -9.5358933E-4 -0.0041675125 0.0029751007 -0.0017840122
-5.3287676E-4 1.983675E-4 -1.9737136E-5
标准缩放器
标准缩放器通过对训练集中的样本使用列摘要统计数据,将数据集的特征标准化为单位方差并去除均值(可选)。
这个过程是一个非常常见的预处理步骤。
标准化可以提高优化过程中的收敛速度。它还可以防止具有较大方差的特征在模型训练过程中产生过大的影响。
StandardScaler类在构造函数中具有以下参数:
新的 StandardScaler(withMean: Boolean, withStd: Boolean)
-
withMean:默认为False。在缩放之前使用均值对数据进行中心化。它将构建一个密集输出,在稀疏输入上不起作用,并将引发异常。 -
withStd:默认为True。将数据缩放到单位标准差。
注释
可用@Since("1.1.0" )
object StandardScalarSample {
def main(args: Array[String]) {
val conf = new SparkConf().setMaster("local").
setAppName("Word2Vector")
val sc = new SparkContext(conf)
val data = MLUtils.loadLibSVMFile( sc,
org.sparksamples.Util.SPARK_HOME +
"/data/mllib/sample_libsvm_data.txt")
val scaler1 = new StandardScaler().fit(data.map(x => x.features)
val scaler2 = new StandardScaler(withMean = true,
withStd = true).fit(data.map(x => x.features))
// scaler3 is an identical model to scaler2, and will produce
//identical transformations
val scaler3 = new StandardScalerModel(scaler2.std, scaler2.mean)
// data1 will be unit variance.
val data1 = data.map(x =>
(x.label, scaler1.transform(x.features)))
println(data1.first())
// Without converting the features into dense vectors,
//transformation with zero mean will raise
// exception on sparse vector.
// data2 will be unit variance and zero mean.
val data2 = data.map(x => (x.label,
scaler2.transform(Vectors.dense(x.features.toArray))))
println(data2.first())
}
}
总结
在本章中,我们看到了如何找到常见的、公开可用的数据集,这些数据集可以用来测试各种机器学习模型。您学会了如何加载、处理和清理数据,以及如何应用常见技术将原始数据转换为特征向量,这些特征向量可以作为我们模型的训练样本。
在下一章中,您将学习推荐系统的基础知识,探索如何创建推荐模型,使用模型进行预测,并评估模型。
第五章:使用 Spark 构建推荐引擎
现在您已经学会了数据处理和特征提取的基础知识,我们将继续详细探讨各个机器学习模型,首先是推荐引擎。
推荐引擎可能是公众所知的最好的机器学习模型之一。即使人们不确切知道推荐引擎是什么,他们很可能通过使用流行网站(如亚马逊、Netflix、YouTube、Twitter、LinkedIn 和 Facebook)来体验过。推荐是所有这些业务的核心部分,在某些情况下,推荐引擎推动了它们相当大比例的收入。
推荐引擎的理念是预测人们可能喜欢什么,并揭示项目之间的关系,以帮助发现过程;在这方面,它们与搜索引擎相似,实际上通常是互补的,后者也在发现中发挥作用。然而,与搜索引擎不同,推荐引擎试图向人们呈现他们并非必然搜索或甚至可能从未听说过的相关内容。
通常,推荐引擎试图建模用户和某种类型项目之间的连接。例如,在我们的电影流场景中,我们可以使用推荐引擎向用户展示他们可能喜欢的电影。如果我们能做到这一点,我们可以通过我们的服务保持用户的参与,这对我们的用户和我们都是有利的。同样,如果我们能够很好地向用户展示与给定电影相关的电影,我们可以帮助他们在我们的网站上发现和导航,从而提高我们用户的体验、参与度和我们内容对他们的相关性。
然而,推荐引擎不仅限于电影、书籍或产品。本章将探讨的技术可以应用于几乎任何用户对项目的关系,以及用户对用户的连接,比如社交网络上的连接,使我们能够做出推荐,比如你可能认识的人或者应该关注谁。
推荐引擎在两种一般情况下最有效,它们并不是互斥的。这里进行了解释:
-
用户可用选项的大量:当有大量可用项目时,用户要找到他们想要的东西变得越来越困难。当用户知道他们在寻找什么时,搜索可以帮助,但通常,合适的项目可能是他们以前不知道的东西。在这种情况下,被推荐相关的用户可能不知道的项目可以帮助他们发现新项目。
-
涉及个人口味的显著程度:当个人口味在选择中起到重要作用时,推荐模型(通常利用众人的智慧方法)可以帮助根据具有相似口味配置的其他人的行为发现项目。
在本章中,我们将涵盖以下主题:
-
介绍各种类型的推荐引擎
-
使用关于用户偏好的数据构建推荐模型
-
使用训练好的模型为特定用户计算推荐,同时为给定项目计算类似项目,即相关项目
-
应用标准评估指标来衡量我们创建的模型在预测能力方面的表现如何
推荐模型的类型
推荐系统得到了广泛研究,有许多不同的方法,但其中两种可能最为普遍:基于内容的过滤和协同过滤。最近,其他方法,如排名模型,也变得越来越受欢迎。在实践中,许多方法是混合的,将许多不同方法的元素纳入模型或模型组合。
基于内容的过滤
基于内容的方法试图使用项目的内容或属性,以及两个内容之间的相似性概念,来生成与给定项目相似的项目。这些属性通常是文本内容,如标题、名称、标签和附加到项目的其他元数据,或者在媒体的情况下,它们可能包括从音频和视频内容中提取的项目的其他特征。
以类似的方式,用户推荐可以基于用户或用户资料的属性生成,然后使用相似度的度量来将其与项目属性进行匹配。例如,用户可以由他们互动过的项目的组合属性来表示。这就成为了他们的用户资料,然后将其与项目属性进行比较,以找到与用户资料匹配的项目。
这些是为每个用户或项目创建描述其性质的资料的几个例子:
-
电影资料包括有关演员、流派、受欢迎程度等的属性。
-
用户资料包括人口统计信息或对特定问题的回答。
-
内容过滤使用资料来关联用户或项目。
-
基于关键词重叠的新项目与用户资料的相似度使用 Dice 系数进行计算。还有其他方法。
协同过滤
协同过滤仅依赖于过去的行为,如先前的评分或交易。其背后的思想是相似性的概念。
基本思想是用户对项目进行评分,隐式或显式地。过去口味相似的用户将来口味也会相似。
在基于用户的方法中,如果两个用户表现出类似的偏好,即以广泛相同方式与相同项目互动的模式,那么我们会假设他们在口味上相似。为了为给定用户生成未知项目的推荐,我们可以使用表现出类似行为的其他用户的已知偏好。我们可以通过选择一组相似的用户并计算基于他们对项目的偏好的某种形式的综合评分来实现这一点。总体逻辑是,如果其他人对一组项目有类似的口味,这些项目很可能是推荐的良好候选项。
我们还可以采用基于项目的方法,计算项目之间的相似度。这通常基于现有的用户-项目偏好或评分。那些倾向于被类似用户评价的项目在这种方法下会被归类为相似的。一旦我们有了这些相似性,我们可以根据用户与其互动的项目来表示用户,并找到与这些已知项目相似的项目,然后推荐给用户。同样,一组与已知项目相似的项目被用来生成一个综合评分,以估计未知项目。
基于用户和基于项目的方法通常被称为最近邻模型,因为估计的分数是基于最相似的用户或项目集合计算的,即它们的邻居。
传统的协同过滤算法将用户表示为项目的 N 维向量,其中 N 是不同项目的数量。向量的分量是正面或负面项目。为了计算最佳项目,该算法通常将向量分量乘以频率的倒数,即评价该项目的用户数量的倒数,使得不太知名的项目更加相关。对于大多数用户来说,这个向量非常稀疏。该算法基于与用户最相似的少数用户生成推荐。它可以使用一种称为余弦相似度的常见方法来衡量两个用户X和Y的相似度,即两个向量之间的夹角的余弦值。
最后,有许多基于模型的方法试图对用户-物品偏好本身进行建模,以便通过将模型应用于未知的用户-物品组合来直接估计新的偏好。
协同过滤的两种主要建模方法如下:
-
邻域方法:
-
以用户为中心的方法集中在计算用户之间的关系
-
以物品为中心的方法根据同一用户对相邻物品的评分来评估用户对物品的偏好
-
使用居中余弦距离进行相似性计算,也称为皮尔逊相关系数
-
潜在因子模型:
-
潜在因子模型(LFM)方法通过表征用户和物品来解释评分,以找到隐藏的潜在特征
-
在电影中,诸如动作或戏剧、演员类型等都是潜在因子
-
在用户中,喜欢电影评分的特征是潜在因子的一个例子
-
类型包括神经网络、潜在狄利克雷分配、矩阵分解
在下一节中,我们将讨论矩阵分解模型。
矩阵分解
由于 Spark 的推荐模型目前只包括矩阵分解的实现,因此我们将把注意力集中在这类模型上。这种关注是有充分理由的;然而,这些类型的模型在协同过滤中一直表现出色,并且在著名的比赛中,如 Netflix 奖,它们一直是最佳模型之一。
矩阵分解假设:
-
每个用户可以用 n 个属性或特征来描述。例如,特征一可能是一个数字,表示每个用户对动作电影的喜欢程度。
-
每个物品可以用一组 n 个属性或特征来描述。与前面的例子相连,电影的特征一可能是一个数字,表示电影与纯动作的接近程度。
-
如果我们将用户的每个特征乘以物品的相应特征并将所有内容相加,这将是用户给出该物品评分的良好近似。
有关 Netflix 奖的最佳算法的更多信息和简要概述,请参阅techblog.netflix.com/2012/04/netflix-recommendations-beyond-5-stars.html。
显式矩阵分解
当我们处理由用户自己提供的用户偏好数据时,我们称之为显式偏好数据。这包括用户对物品的评分、点赞、喜欢等。
我们可以将这些评分组成一个二维矩阵,以用户为行,物品为列。每个条目表示用户对某个物品的评分。由于在大多数情况下,每个用户只与相对较小的一组物品进行了交互,因此该矩阵只有少数非零条目,即非常稀疏。
举个简单的例子,假设我们有一组电影的以下用户评分:
Tom: 星球大战,5
Jane: 泰坦尼克号,4
Bill: 蝙蝠侠,3
Jane: 星球大战,2
Bill: 泰坦尼克号,3
我们将形成以下评分矩阵:
一个简单的电影评分矩阵
矩阵分解(或矩阵完成)试图直接对用户-物品矩阵进行建模,将其表示为较低维度的两个较小矩阵的乘积。因此,这是一种降维技术。如果我们有U个用户和I个物品,那么我们的用户-物品矩阵的维度为 U x I,可能看起来像下图所示的矩阵:
一个稀疏的评分矩阵
如果我们想要找到一个低维(低秩)的用户-物品矩阵的近似值,维度为k,我们将得到两个矩阵:一个是用户大小为 U x k 的矩阵,另一个是物品大小为 I x k 的矩阵;这些被称为因子矩阵。如果我们将这两个因子矩阵相乘,我们将重构原始评分矩阵的近似版本。请注意,原始评分矩阵通常非常稀疏,而每个因子矩阵是密集的,如下图所示:
用户和物品因子矩阵
这些模型通常也被称为潜在特征模型,因为我们试图发现一些隐藏特征(由因子矩阵表示),这些特征解释了用户-物品评分矩阵中固有的行为结构。虽然潜在特征或因子通常不是直接可解释的,但它们可能代表一些东西,比如用户倾向于喜欢某个导演、类型、风格或一组演员的电影。
由于我们直接对用户-物品矩阵进行建模,因此这些模型中的预测相对简单:要计算用户和物品的预测评分,我们将计算用户因子矩阵的相关行(即用户的因子向量)与物品因子矩阵的相关行(即物品的因子向量)之间的向量点积。
这在下图中突出显示的向量中得到了说明:
从用户和物品因子向量计算推荐
要找出两个物品之间的相似性,我们可以使用与最近邻模型中使用的相似性度量相同的度量,只是我们可以直接使用因子向量,通过计算两个物品因子向量之间的相似性,如下图所示:
使用物品因子向量计算相似性
因子化模型的好处在于一旦模型创建完成,推荐的计算相对容易。然而,对于非常庞大的用户和物品集,这可能会成为一个挑战,因为它需要跨可能有数百万用户和物品因子向量的存储和计算。另一个优势,正如前面提到的,是它们往往提供非常好的性能。
Oryx(github.com/OryxProject/oryx)和 Prediction.io(github.com/PredictionIO/PredictionIO)等项目专注于为大规模模型提供模型服务,包括基于矩阵因子分解的推荐系统。
不足之处在于,与最近邻模型相比,因子化模型相对更复杂,而且在模型的训练阶段通常需要更多的计算资源。
隐式矩阵因子分解
到目前为止,我们已经处理了诸如评分之类的显式偏好。然而,我们可能能够收集到的许多偏好数据是隐式反馈,即用户和物品之间的偏好并未直接给出,而是从他们可能与物品的互动中暗示出来。例如,二进制数据,比如用户是否观看了一部电影,是否购买了一个产品,以及计数数据,比如用户观看一部电影的次数。
处理隐式数据有许多不同的方法。MLlib 实现了一种特定的方法,将输入评分矩阵视为两个矩阵:一个是二进制偏好矩阵P,另一个是置信权重矩阵C。
例如,假设我们之前看到的用户-电影评分实际上是每个用户观看该电影的次数。这两个矩阵看起来可能像以下截图中显示的矩阵。在这里,矩阵P告诉我们电影被用户观看了,矩阵C代表置信度加权,以观看次数的形式--通常情况下,用户观看电影的次数越多,他们实际上喜欢它的置信度就越高。
隐式偏好和置信度矩阵的表示
隐式模型仍然创建用户和物品因子矩阵。然而,在这种情况下,模型试图逼近的矩阵不是整体评分矩阵,而是偏好矩阵P。如果我们通过计算用户和物品因子向量的点积来计算推荐,得分将不是对评分的直接估计。它将更多地是对用户对物品的偏好的估计;尽管不严格在 0 到 1 之间,这些得分通常会相当接近 0 到 1 的范围。
简而言之,矩阵分解方法通过从评分模式中推断出的因子向量来表征用户和物品。用户和物品因子之间的高置信度或对应关系会导致推荐。两种主要的数据类型是显式反馈,如评分(由稀疏矩阵表示),和隐式反馈,如购买历史、搜索模式、浏览历史和点击流数据(由密集矩阵表示)。
矩阵分解的基本模型
用户和物品都被映射到维度为f的联合潜在因子空间中,用户-物品交互在该空间中被建模为内积。物品i与向量q相关联,其中q衡量物品具有潜在因子的程度,用户u与向量p相关联,其中p衡量用户对物品的兴趣程度。
q和p之间的点积捕捉了用户u和物品I之间的交互,即用户对物品的兴趣。模型的关键是找到向量q和p。
设计模型,获取用户和物品之间的潜在关系。生成评分矩阵的低维表示。对评分矩阵执行 SVD 以获取Q、S、P。将矩阵S降维到k维以获取q和p。
现在,计算推荐:
优化函数(对观察到的评分)如下图所示;学习潜在因子向量q和p,系统最小化一组评分的正则化平方误差。
使用的学习算法是随机梯度下降(SGD)或交替最小二乘(ALS)。
交替最小二乘
ALS 是解决矩阵分解问题的优化技术;这种技术功能强大,性能良好,并且已被证明相对容易在并行环境中实现。因此,它非常适合像 Spark 这样的平台。在撰写本书时,它是 Spark ML 中唯一实现的推荐模型。
ALS 通过迭代地解决一系列最小二乘回归问题来工作。在每次迭代中,用户或物品因子矩阵中的一个被视为固定,而另一个则使用固定因子和评分数据进行更新。然后,解决的因子矩阵依次被视为固定,而另一个被更新。这个过程持续进行直到模型收敛(或者达到固定次数的迭代):
目标函数不是凸的,因为q和p都是未知的,但是如果我们固定其中一个未知数,优化可以被解决。如前所述,ALS 在固定q和p之间交替。
Spark 的协同过滤文档包含了支持 ALS 算法实现显式和隐式数据的论文的引用。您可以在 spark.apache.org/docs/latest… 上查看文档。
以下代码解释了如何从头开始实现 ALS 算法。
让我们举个例子,展示它是如何实现的,并看一个真实的 3 部电影和 3 个用户的矩阵:
Array2DRowRealMatrix
{{0.5306513708,0.5144338501,0.5183049},
{0.0612665269,0.0595122885,0.0611548878},
{0.3215637836,0.2964382622,0.1439834964}}
电影矩阵的第一次迭代是随机选择的:
ms = {RealVector[3]@3600}
0 = {ArrayRealVector@3605} "{0.489603683; 0.5979051631}"
1 = {ArrayRealVector@3606} "{0.2069873135; 0.4887559609}"
2 = {ArrayRealVector@3607} "{0.5286582698; 0.6787608323}"
用户矩阵的第一次迭代是随机选择的:
us = {RealVector[3]@3602}
0 = {ArrayRealVector@3611} "{0.7964247309; 0.091570682}"
1 = {ArrayRealVector@3612} "{0.4509758768; 0.0684475614}"
2 = {ArrayRealVector@3613} "{0.7812240904; 0.4180722562}"
挑选用户矩阵us的第一行,计算XtX(矩阵)和Xty(向量),如下面的代码所示:
m: {0.489603683; 0.5979051631}
us: [Lorg.apache.commons.math3.linear.RealVector;@75961f16
XtX: Array2DRowRealMatrix{{0.0,0.0},{0.0,0.0}}
Xty: {0; 0}
j:0
u: {0.7964247309; 0.091570682}
u.outerProduct(u):
Array2DRowRealMatrix{{0.634292352,0.0729291558},
{0.0729291558,0.0083851898}}
XtX = XtX.add(u.outerProduct(u)):
Array2DRowRealMatrix{{0.634292352,0.0729291558},
{0.0729291558,0.0083851898}}
R.getEntry(i, j)):0.5306513708051035
u.mapMultiply(R.getEntry(i, j): {0.4226238752; 0.0485921079}
Xty = Xty.add(u.mapMultiply(R.getEntry(i, j))): {0.4226238752;
0.0485921079}
挑选用户矩阵us的第二行,并按照下面的代码向XtX(矩阵)和Xty(向量)添加值:
j:1
u: {0.4509758768; 0.0684475614}
u.outerProduct(u): Array2DRowRealMatrix{{0.2033792414,0.030868199},{0.030868199,0.0046850687}}
XtX = XtX.add(u.outerProduct(u)): Array2DRowRealMatrix{{0.8376715935,0.1037973548},{0.1037973548,0.0130702585}}
R.getEntry(i, j)):0.5144338501354986
u.mapMultiply(R.getEntry(i, j): {0.2319972566; 0.0352117425}
Xty = Xty.add(u.mapMultiply(R.getEntry(i, j))): {0.6546211318; 0.0838038505}
j:2
u: {0.7812240904; 0.4180722562}
u.outerProduct(u):
Array2DRowRealMatrix{{0.6103110794,0.326608118},
{0.326608118,0.1747844114}}
XtX = XtX.add(u.outerProduct(u)):
Array2DRowRealMatrix{{1.4479826729,0.4304054728},
{0.4304054728,0.1878546698}}
R.getEntry(i, j)):0.5183049000396933
u.mapMultiply(R.getEntry(i, j): {0.4049122741; 0.2166888989}
Xty = Xty.add(u.mapMultiply(R.getEntry(i, j))): {1.0595334059;
0.3004927494}
After Regularization XtX:
Array2DRowRealMatrix{{1.4779826729,0.4304054728},
{0.4304054728,0.1878546698}}
After Regularization XtX: Array2DRowRealMatrix{{1.4779826729,0.4304054728},{0.4304054728,0.2178546698}}
计算ms(使用XtX和XtY的 Cholesky 分解的电影矩阵)的第一行的值:
CholeskyDecomposition{0.7422344051; -0.0870718111}
经过我们每一行的步骤后,我们得到了:
ms = {RealVector[3]@5078}
0 = {ArrayRealVector@5125} "{0.7422344051; -0.0870718111}"
1 = {ArrayRealVector@5126} "{0.0856607011; -0.007426896}"
2 = {ArrayRealVector@5127} "{0.4542083563; -0.392747909}"
列出了先前解释的数学实现的源代码:
object AlternatingLeastSquares {
var movies = 0
var users = 0
var features = 0
var ITERATIONS = 0
val LAMBDA = 0.01 // Regularization coefficient
private def vector(n: Int): RealVector =
new ArrayRealVector(Array.fill(n)(math.random))
private def matrix(rows: Int, cols: Int): RealMatrix =
new Array2DRowRealMatrix(Array.fill(rows, cols)(math.random))
def rSpace(): RealMatrix = {
val mh = matrix(movies, features)
val uh = matrix(users, features)
mh.multiply(uh.transpose())
}
def rmse(targetR: RealMatrix, ms: Array[RealVector], us:
Array[RealVector]): Double = {
val r = new Array2DRowRealMatrix(movies, users)
for (i <- 0 until movies; j <- 0 until users) {
r.setEntry(i, j, ms(i).dotProduct(us(j)))
}
val diffs = r.subtract(targetR)
var sumSqs = 0.0
for (i <- 0 until movies; j <- 0 until users) {
val diff = diffs.getEntry(i, j)
sumSqs += diff * diff
}
math.sqrt(sumSqs / (movies.toDouble * users.toDouble))
}
def update(i: Int, m: RealVector, us: Array[RealVector], R:
RealMatrix) : RealVector = {
val U = us.length
val F = us(0).getDimension
var XtX: RealMatrix = new Array2DRowRealMatrix(F, F)
var Xty: RealVector = new ArrayRealVector(F)
// For each user that rated the movie
for (j <- 0 until U) {
val u = us(j)
// Add u * u^t to XtX
XtX = XtX.add(u.outerProduct(u))
// Add u * rating to Xty
Xty = Xty.add(u.mapMultiply(R.getEntry(i, j)))
}
// Add regularization coefs to diagonal terms
for (d <- 0 until F) {
XtX.addToEntry(d, d, LAMBDA * U)
}
// Solve it with Cholesky
new CholeskyDecomposition(XtX).getSolver.solve(Xty)
}
def main(args: Array[String]) {
movies = 100
users = 500
features = 10
ITERATIONS = 5
var slices = 2
val spark =
SparkSession.builder.master("local[2]").
appName("AlternatingLeastS
quares").getOrCreate()
val sc = spark.sparkContext
val r_space = rSpace()
// Initialize m and u randomly
var ms = Array.fill(movies)(vector(features))
var us = Array.fill(users)(vector(features))
// Iteratively update movies then users
val Rc = sc.broadcast(r_space)
var msb = sc.broadcast(ms)
var usb = sc.broadcast(us)
for (iter <- 1 to ITERATIONS) {
println(s"Iteration $iter:")
ms = sc.parallelize(0 until movies, slices)
.map(i => update(i, msb.value(i), usb.value, Rc.value))
.collect()
msb = sc.broadcast(ms) // Re-broadcast ms because it was
updated
us = sc.parallelize(0 until users, slices)
.map(i => update(i, usb.value(i), msb.value,
Rc.value.transpose()))
.collect()
usb = sc.broadcast(us) // Re-broadcast us because it was
updated
println("RMSE = " + rmse(r_space, ms, us))
println()
}
spark.stop()
}
}
从数据中提取正确的特征
在这一部分,我们将使用显式评分数据,没有额外的用户、物品元数据或其他与用户-物品交互相关的信息。因此,我们需要的输入特征只是用户 ID、电影 ID 和分配给每个用户和电影对的评分。
从 MovieLens 100k 数据集中提取特征
在这个例子中,我们将使用在上一章中使用的相同的 MovieLens 数据集。在下面的代码中,将使用放置 MovieLens 100k 数据集的目录作为输入路径。
首先,让我们检查原始评分数据集:
object FeatureExtraction {
def getFeatures(): Dataset[FeatureExtraction.Rating] = {
val spark = SparkSession.builder.master("local[2]").appName("FeatureExtraction").getOrCreate()
import spark.implicits._
val ratings = spark.read.textFile("/data/ml-100k 2/u.data").map(parseRating)
println(ratings.first())
return ratings
}
case class Rating(userId: Int, movieId: Int, rating: Float)
def parseRating(str: String): Rating = {
val fields = str.split("t")
Rating(fields(0).toInt, fields(1).toInt, fields(2).toFloat)
}
您将看到类似于以下代码行的输出:
16/09/07 11:23:38 INFO CodeGenerator: Code generated in 7.029838 ms
16/09/07 11:23:38 INFO Executor: Finished task 0.0 in stage 0.0 (TID
0). 1276 bytes result sent to driver
16/09/07 11:23:38 INFO TaskSetManager: Finished task 0.0 in stage 0.0
(TID 0) in 82 ms on localhost (1/1)
16/09/07 11:23:38 INFO TaskSchedulerImpl: Removed TaskSet 0.0, whose
tasks have all completed, from pool
16/09/07 11:23:38 INFO DAGScheduler: ResultStage 0 (first at
FeatureExtraction.scala:25) finished in 0.106 s
16/09/07 11:23:38 INFO DAGScheduler: Job 0 finished: first at
FeatureExtraction.scala:25, took 0.175165 s
16/09/07 11:23:38 INFO CodeGenerator: Code generated in 6.834794 ms
Rating(196,242,3.0)
请记住,这个数据集(使用案例映射到Rating类)由userID、movieID、rating和timestamp字段组成,由制表符("t")字符分隔。我们不需要训练模型时的评分时间,所以下面的代码片段中我们只是提取了前三个字段:
case class Rating(userId: Int, movieId: Int, rating: Float)
def parseRating(str: String): Rating = {
val fields = str.split("t")
Rating(fields(0).toInt, fields(1).toInt, fields(2).toFloat)
}
我们将首先将每条记录分割为"t"字符,这样我们就得到了一个String[]数组。然后我们将使用案例类来映射并保留数组的前3个元素,分别对应userID、movieID和rating。
训练推荐模型
一旦我们从原始数据中提取了这些简单的特征,我们就可以继续进行模型训练;ML 会为我们处理这些。我们所要做的就是提供正确解析的输入数据集以及我们选择的模型参数。
将数据集分割为训练集和测试集,比例为 80:20,如下面的代码所示:
def createALSModel() {
val ratings = FeatureExtraction.getFeatures();
val Array(training, test) = ratings.randomSplit(Array(0.8, 0.2))
println(training.first())
}
您将看到以下输出:
16/09/07 13:23:28 INFO Executor: Finished task 0.0 in stage 1.0 (TID
1). 1768 bytes result sent to driver
16/09/07 13:23:28 INFO TaskSetManager: Finished task 0.0 in stage 1.0
(TID 1) in 459 ms on localhost (1/1)
16/09/07 13:23:28 INFO TaskSchedulerImpl: Removed TaskSet 1.0, whose
tasks have all completed, from pool
16/09/07 13:23:28 INFO DAGScheduler: ResultStage 1 (first at
FeatureExtraction.scala:34) finished in 0.459 s
16/09/07 13:23:28 INFO DAGScheduler: Job 1 finished: first at
FeatureExtraction.scala:34, took 0.465730 s
Rating(1,1,5.0)
在 MovieLens 100k 数据集上训练模型
我们现在准备训练我们的模型!我们模型所需的其他输入如下:
-
rank:这指的是我们 ALS 模型中的因子数量,也就是我们低秩近似矩阵中的隐藏特征数量。通常来说,因子数量越多越好,但这直接影响内存使用,无论是计算还是存储模型用于服务,特别是对于大量用户或物品。因此,在实际应用中,这通常是一个权衡。它还影响所需的训练数据量。 -
在 10 到 200 的范围内选择一个秩通常是合理的。
-
iterations:这是指要运行的迭代次数。虽然 ALS 中的每次迭代都保证会减少评级矩阵的重构误差,但 ALS 模型在相对较少的迭代后就会收敛到一个相当不错的解决方案。因此,在大多数情况下,我们不需要运行太多次迭代--大约 10 次通常是一个很好的默认值。 -
numBlocks:这是用户和物品将被分区成的块的数量,以并行化计算(默认为 10)。该数字取决于集群节点的数量以及数据的分区方式。 -
regParam:这指定 ALS 中的正则化参数(默认为 1.0)。常数λ称为正则化参数,如果用户和物品矩阵的分量过大(绝对值),它会对其进行惩罚。这对于数值稳定性很重要,几乎总是会使用某种形式的正则化。 -
implicitPrefs:这指定是否使用显式反馈 ALS 变体或者适用于隐式反馈数据的变体;默认为 false,表示使用显式反馈。 -
alpha:这是 ALS 隐式反馈变体适用的参数,它控制对偏好观察的基线置信度(默认为 1.0)。 -
nonnegative:这指定是否使用最小二乘法的非负约束(默认为false)。
我们将使用默认的rank,5个maxIter,以及regParam参数为0.01来说明如何训练我们的模型,如下面的代码所示:
// Build the recommendation model using ALS on the training data
val als = new ALS()
.setMaxIter(5)
.setRegParam(0.01)
.setUserCol("userId")
.setItemCol("movieId")
.setRatingCol("rating")
val model = als.fit(training)
这将返回一个ALSModel对象,其中包含用户和物品因子。它们分别称为userFactors和itemFactors。
例如,model.userFactors。
您将看到以下输出:
16/09/07 13:08:16 INFO MapPartitionsRDD: Removing RDD 16 from
persistence list
16/09/07 13:08:16 INFO BlockManager: Removing RDD 16
16/09/07 13:08:16 INFO Instrumentation: ALS-als_1ca69e2ffef7-
10603412-1: training finished
16/09/07 13:08:16 INFO SparkContext: Invoking stop() from shutdown
hook
[id: int, features: array<float>]
我们可以看到这些因子的形式是Array[float]。
MLlib 的 ALS 实现中使用的操作是惰性转换,因此实际计算只有在我们对用户和物品因子的 DataFrame 调用某种操作时才会执行。在下面的代码中,我们可以使用 Spark 操作(如count)来强制执行计算:
model.userFactors.count()
这将触发计算,并且我们将看到类似以下代码行的大量输出文本:
16/09/07 13:21:54 INFO Executor: Running task 0.0 in stage 53.0 (TID
166)
16/09/07 13:21:54 INFO ShuffleBlockFetcherIterator: Getting 10 non-
empty blocks out of 10 blocks
16/09/07 13:21:54 INFO ShuffleBlockFetcherIterator: Started 0 remote
fetches in 0 ms
16/09/07 13:21:54 INFO Executor: Finished task 0.0 in stage 53.0 (TID
166). 1873 bytes result sent to driver
16/09/07 13:21:54 INFO TaskSetManager: Finished task 0.0 in stage
53.0 (TID 166) in 12 ms on localhost (1/1)
16/09/07 13:21:54 INFO TaskSchedulerImpl: Removed TaskSet 53.0, whose
tasks have all completed, from pool
16/09/07 13:21:54 INFO DAGScheduler: ResultStage 53 (count at
ALSModeling.scala:25) finished in 0.012 s
16/09/07 13:21:54 INFO DAGScheduler: Job 7 finished: count at
ALSModeling.scala:25, took 0.123073 s
16/09/07 13:21:54 INFO CodeGenerator: Code generated in 11.162514 ms
943
如果我们对电影因子调用count,将会使用以下代码完成:
model.itemFactors.count()
这将触发计算,并且我们将得到以下输出:
16/09/07 13:23:32 INFO TaskSetManager: Starting task 0.0 in stage
68.0 (TID 177, localhost, partition 0, ANY, 5276 bytes)
16/09/07 13:23:32 INFO Executor: Running task 0.0 in stage 68.0 (TID
177)
16/09/07 13:23:32 INFO ShuffleBlockFetcherIterator: Getting 10 non-
empty blocks out of 10 blocks
16/09/07 13:23:32 INFO ShuffleBlockFetcherIterator: Started 0 remote
fetches in 0 ms
16/09/07 13:23:32 INFO Executor: Finished task 0.0 in stage 68.0 (TID
177). 1873 bytes result sent to driver
16/09/07 13:23:32 INFO TaskSetManager: Finished task 0.0 in stage
68.0 (TID 177) in 3 ms on localhost (1/1)
16/09/07 13:23:32 INFO TaskSchedulerImpl: Removed TaskSet 68.0, whose
tasks have all completed, from pool
16/09/07 13:23:32 INFO DAGScheduler: ResultStage 68 (count at
ALSModeling.scala:26) finished in 0.003 s
16/09/07 13:23:32 INFO DAGScheduler: Job 8 finished: count at
ALSModeling.scala:26, took 0.072450 s
1651
如预期的那样,我们为每个用户(943个因子)和每部电影(1651个因子)都有一个因子数组。
使用隐式反馈数据训练模型
MLlib 中的标准矩阵分解方法处理显式评分。要处理隐式数据,可以使用trainImplicit方法。它的调用方式类似于标准的train方法。还有一个额外的参数alpha,可以设置(同样,正则化参数lambda应该通过测试和交叉验证方法进行选择)。
alpha参数控制应用的基线置信度权重。较高水平的alpha倾向于使模型更加确信缺失数据意味着用户-物品对的偏好不存在。
从 Spark 版本 2.0 开始,如果评分矩阵是从其他信息推断出来的,即从其他信号中推断出来的,您可以将setImplicitPrefs设置为true以获得更好的结果,如下例所示:
val als = new ALS()
.setMaxIter(5)
.setRegParam(0.01)
.setImplicitPrefs(true)
.setUserCol("userId")
.setItemCol("movieId")
.setRatingCol("rating")
作为练习,尝试将现有的 MovieLens 数据集转换为隐式数据集。一种可能的方法是通过在某个水平上对评分应用阈值,将其转换为二进制反馈(0 和 1)。
另一种方法可能是将评分值转换为置信权重(例如,也许低评分可能意味着零权重,甚至是负权重,这是 MLlib 实现支持的)。
在此数据集上训练模型,并将以下部分的结果与您的隐式模型生成的结果进行比较。
使用推荐模型
现在我们已经训练好模型,准备使用它进行预测。
ALS 模型推荐
从 Spark v2.0 开始,org.apache.spark.ml.recommendation.ALS建模是因子分解算法的阻塞实现,它将“用户”和“产品”因子分组到块中,并通过在每次迭代时仅向每个产品块发送每个用户向量的一份副本,并且仅对需要该用户特征向量的产品块进行通信,从而减少通信。
在这里,我们将从电影数据集中加载评分数据,其中每一行包括用户、电影、评分和时间戳。然后我们将训练一个 ALS 模型,默认情况下该模型适用于显式偏好(implicitPrefs为false)。我们将通过测量评分预测的均方根误差来评估推荐模型,具体如下:
object ALSModeling {
def createALSModel() {
val ratings = FeatureExtraction.getFeatures();
val Array(training, test) = ratings.randomSplit(Array(0.8,
0.2))
println(training.first())
// Build the recommendation model using ALS on the training
data
val als = new ALS()
.setMaxIter(5)
.setRegParam(0.01)
.setUserCol("userId")
.setItemCol("movieId")
.setRatingCol("rating")
val model = als.fit(training)
println(model.userFactors.count())
println(model.itemFactors.count())
val predictions = model.transform(test)
println(predictions.printSchema())
}
以下是前述代码的输出:
16/09/07 17:58:42 INFO SparkContext: Created broadcast 26 from
broadcast at DAGScheduler.scala:1012
16/09/07 17:58:42 INFO DAGScheduler: Submitting 1 missing tasks from
ResultStage 67 (MapPartitionsRDD[138] at count at
ALSModeling.scala:31)
16/09/07 17:58:42 INFO TaskSchedulerImpl: Adding task set 67.0 with 1
tasks
16/09/07 17:58:42 INFO TaskSetManager: Starting task 0.0 in stage
67.0 (TID 176, localhost, partition 0, ANY, 5276 bytes)
16/09/07 17:58:42 INFO Executor: Running task 0.0 in stage 67.0 (TID
176)
16/09/07 17:58:42 INFO ShuffleBlockFetcherIterator: Getting 10 non-
empty blocks out of 10 blocks
16/09/07 17:58:42 INFO ShuffleBlockFetcherIterator: Started 0 remote
fetches in 0 ms
16/09/07 17:58:42 INFO Executor: Finished task 0.0 in stage 67.0 (TID
176). 1960 bytes result sent to driver
16/09/07 17:58:42 INFO TaskSetManager: Finished task 0.0 in stage
67.0 (TID 176) in 3 ms on localhost (1/1)
16/09/07 17:58:42 INFO TaskSchedulerImpl: Removed TaskSet 67.0, whose
tasks have all completed, from pool
16/09/07 17:58:42 INFO DAGScheduler: ResultStage 67 (count at
ALSModeling.scala:31) finished in 0.003 s
16/09/07 17:58:42 INFO DAGScheduler: Job 7 finished: count at
ALSModeling.scala:31, took 0.060748 s
100
root
|-- userId: integer (nullable = true)
|-- movieId: integer (nullable = true)
|-- rating: float (nullable = true)
|-- timestamp: long (nullable = true)
|-- prediction: float (nullable = true)
在我们继续之前,请注意以下关于用户和物品推荐的示例使用了 Spark v1.6 的 MLlib。请按照代码列表获取使用org.apache.spark.mllib.recommendation.ALS创建推荐模型的详细信息。
用户推荐
在这种情况下,我们希望为给定的用户生成推荐的物品。这通常采用top-K列表的形式,即我们的模型预测用户最有可能喜欢的K个物品。这是通过计算每个物品的预测得分并根据这个得分对列表进行排名来实现的。
执行此计算的确切方法取决于所涉及的模型。例如,在基于用户的方法中,使用相似用户对物品的评分来计算对用户的推荐;而在基于物品的方法中,计算基于用户评分的物品与候选物品的相似性。
在矩阵分解中,因为我们直接对评分矩阵进行建模,所以预测得分可以通过用户因子向量和物品因子向量之间的向量点积来计算。
从 MovieLens 100k 数据集生成电影推荐
由于 MLlib 的推荐模型是基于矩阵分解的,我们可以使用模型计算出的因子矩阵来计算用户的预测分数(或评分)。我们将专注于使用 MovieLens 数据的显式评分情况;然而,使用隐式模型时,方法是相同的。
MatrixFactorizationModel类有一个方便的predict方法,可以计算给定用户和项目组合的预测分数,如下面的代码所示:
val predictedRating = model.predict(789, 123)
输出如下:
14/03/30 16:10:10 INFO SparkContext: Starting job: lookup at
MatrixFactorizationModel.scala:45
14/03/30 16:10:10 INFO DAGScheduler: Got job 30 (lookup at
MatrixFactorizationModel.scala:45) with 1 output partitions
(allowLocal=false)
...
14/03/30 16:10:10 INFO SparkContext: Job finished: lookup at
MatrixFactorizationModel.scala:46, took 0.023077 s
predictedRating: Double = 3.128545693368485
正如我们所看到的,这个模型预测用户789对电影123的评分为3.12。
请注意,您可能会看到与本节中显示的结果不同的结果,因为 ALS 模型是随机初始化的。因此,模型的不同运行将导致不同的解决方案。
predict方法也可以接受一个(user, item) ID 的 RDD 作为输入,并为每个生成预测。我们可以使用这个方法同时为许多用户和项目进行预测。
为了为用户生成top-K推荐项目,MatrixFactorizationModel提供了一个方便的方法叫做recommendProducts。这需要两个参数:user和num,其中user是用户 ID,num是要推荐的项目数。
它返回按预测分数排序的前num个项目。在这里,分数是通过用户因子向量和每个项目因子向量之间的点积计算的。
让我们按照以下方式为用户789生成前10个推荐项目:
val userId = 789
val K = 10
val topKRecs = model.recommendProducts(userId, K)
现在,我们已经为用户789的每部电影预测了一组评分。如果我们打印出来,通过编写以下代码行,我们可以检查这个用户的前10个推荐:
println(topKRecs.mkString("n"))
您应该在控制台上看到以下输出:
Rating(789,715,5.931851273771102)
Rating(789,12,5.582301095666215)
Rating(789,959,5.516272981542168)
Rating(789,42,5.458065302395629)
Rating(789,584,5.449949837103569)
Rating(789,750,5.348768847643657)
Rating(789,663,5.30832117499004)
Rating(789,134,5.278933936827717)
Rating(789,156,5.250959077906759)
Rating(789,432,5.169863417126231)
检查推荐
我们可以通过快速查看用户评价过的电影和推荐的电影的标题来对这些推荐进行一次检查。首先,我们需要加载电影数据,这是我们在上一章中探讨的数据集之一。在下面的代码中,我们将收集这些数据作为Map[Int, String]方法,将电影 ID 映射到标题:
val movies = sc.textFile("/PATH/ml-100k/u.item")
val titles = movies.map(line =>
line.split("|").take(2)).map(array => (array(0).toInt,
array(1))).collectAsMap()
titles(123)
上述代码将产生以下输出:
res68: String = Frighteners, The (1996)
对于我们的用户789,我们可以找出他们评价过的电影,取得评分最高的10部电影,然后检查标题。我们将首先使用keyBy Spark 函数从我们的ratings RDD 中创建一个键值对的 RDD,其中键将是用户 ID。然后,我们将使用lookup函数将这个键(即特定的用户 ID)的评分返回给驱动程序,如下所述:
val moviesForUser = ratings.keyBy(_.user).lookup(789)
让我们看看这个用户评价了多少部电影。这将是moviesForUser集合的size:
println(moviesForUser.size)
我们将看到这个用户已经评价了33部电影。
接下来,我们将通过对moviesForUser集合使用Rating对象的rating字段进行排序,取得评分最高的10部电影。然后,我们将从我们的电影标题映射中提取相关产品 ID 附加到Rating类的电影标题,并打印出带有其评分的前10个标题,如下所示:
moviesForUser.sortBy(-_.rating).take(10).map(rating =>
(titles(rating.product), rating.rating)).foreach(println)
您将看到以下输出显示:
(Godfather, The (1972),5.0)
(Trainspotting (1996),5.0)
(Dead Man Walking (1995),5.0)
(Star Wars (1977),5.0)
(Swingers (1996),5.0)
(Leaving Las Vegas (1995),5.0)
(Bound (1996),5.0)
(Fargo (1996),5.0)
(Last Supper, The (1995),5.0)
(Private Parts (1997),4.0)
现在,让我们看看这个用户的前10个推荐,并查看标题,使用与我们之前使用的相同方法(请注意,推荐已经排序):
topKRecs.map(rating => (titles(rating.product),
rating.rating)).foreach(println)
输出如下:
(To Die For (1995),5.931851273771102)
(Usual Suspects, The (1995),5.582301095666215)
(Dazed and Confused (1993),5.516272981542168)
(Clerks (1994),5.458065302395629)
(Secret Garden, The (1993),5.449949837103569)
(Amistad (1997),5.348768847643657)
(Being There (1979),5.30832117499004)
(Citizen Kane (1941),5.278933936827717)
(Reservoir Dogs (1992),5.250959077906759)
(Fantasia (1940),5.169863417126231)
我们留给您决定这些推荐是否有意义。
项目推荐
项目推荐是关于回答以下问题的:对于某个项目,与之最相似的项目是什么?在这里,相似性的精确定义取决于所涉及的模型。在大多数情况下,相似性是通过使用某些相似性度量来比较两个项目的向量表示来计算的。常见的相似性度量包括皮尔逊相关系数和余弦相似度用于实值向量,以及杰卡德相似度用于二进制向量。
为 MovieLens 100k 数据集生成相似的电影
当前的MatrixFactorizationModelAPI 不直接支持项目之间的相似度计算。因此,我们需要创建自己的代码来完成这个任务。
我们将使用余弦相似度度量,并使用 jblas 线性代数库(MLlib 的依赖项)来计算所需的向量点积。这类似于现有的predict和recommendProducts方法的工作方式,只是我们将使用余弦相似度而不仅仅是点积。
我们想要使用我们的相似度度量来比较我们选择的项目的因子向量与其他项目的因子向量。为了执行线性代数计算,我们首先需要从因子向量中创建一个向量对象,这些因子向量的形式是Array[Double]。JBLAS类DoubleMatrix以Array[Double]作为构造函数参数,如下所示:
import org.jblas.DoubleMatrix
使用以下构造函数从数组实例化DoubleMatrix。
jblas类是一个用 Java 编写的线性代数库。它基于 BLAS 和 LAPACK,是矩阵计算的事实行业标准,并使用像ATLAS这样的实现来进行计算例程,使得 jBLAS 非常快速。
它是对 BLAS 和 LAPACK 例程的轻量级封装。BLAS 和 LAPACK 包起源于 Fortran 社区。
让我们看一个例子:
public DoubleMatrix(double[] newData)
使用newData作为数据数组创建一个列向量。对创建的DoubleMatrix的任何更改都将在输入数组newData中进行更改。
让我们创建一个简单的DoubleMatrix:
val aMatrix = new DoubleMatrix(Array(1.0, 2.0, 3.0))
以下是前面代码的输出:
aMatrix: org.jblas.DoubleMatrix = [1.000000; 2.000000; 3.000000]
请注意,使用 jblas,向量表示为一维的DoubleMatrix类,而矩阵表示为二维的DoubleMatrix类。
我们需要一个方法来计算两个向量之间的余弦相似度。余弦相似度是n维空间中两个向量之间角度的度量。首先计算向量之间的点积,然后将结果除以分母,分母是每个向量的范数(或长度)相乘在一起(具体来说,余弦相似度中使用 L2 范数)。
在线性代数中,向量的大小称为
的范数。我们将讨论几种不同类型的范数。在本讨论中,我们将向量 v 定义为一组有序的数字。
一范数:向量的一范数(也称为 L1 范数或均值范数)如下图所示,并定义为其组件的绝对值的总和:
二范数(也称为 L2 范数、均方根范数、最小二乘范数)
向量的范数如下图所示:
此外,它被定义为其组件的绝对值的平方和的平方根:
这样,余弦相似度是一个归一化的点积。余弦相似度测量值介于-1和1之间。值为1意味着完全相似,而值为 0 意味着独立(即没有相似性)。这个度量是有用的,因为它还捕捉到了负相似性,即值为-1意味着向量不仅不相似,而且完全不相似:
让我们在这里创建我们的cosineSimilarity函数:
def cosineSimilarity(vec1: DoubleMatrix, vec2: DoubleMatrix): Double = {
vec1.dot(vec2) / (vec1.norm2() * vec2.norm2())
}
请注意,我们为这个函数定义了一个Double的返回类型。虽然 Scala 具有类型推断功能,我们并不需要这样做。然而,为 Scala 函数记录返回类型通常是有用的。
让我们尝试对项目567的项目因子之一进行操作。我们需要从我们的模型中收集一个项目因子;我们将使用lookup方法来做到这一点,方式与我们之前收集特定用户的评分的方式类似。在下面的代码行中,我们还将使用head函数,因为lookup返回一个值数组,我们只需要第一个值(实际上,只会有一个值,即这个项目的因子向量)。
由于这将是一个构造函数Array[Double],因此我们需要从中创建一个DoubleMatrix对象,并计算与自身的余弦相似度,如下所示:
val itemId = 567
val itemFactor = model.productFeatures.lookup(itemId).head
val itemVector = new DoubleMatrix(itemFactor)
cosineSimilarity(itemVector, itemVector)
相似度度量应该衡量两个向量在某种意义上的接近程度。在下面的示例中,我们可以看到我们的余弦相似度度量告诉我们,这个项目向量与自身相同,这是我们所期望的。
res113: Double = 1.0
现在,我们准备将我们的相似度度量应用于每个项目,如下所示:
val sims = model.productFeatures.map{ case (id, factor) =>
val factorVector = new DoubleMatrix(factor)
val sim = cosineSimilarity(factorVector, itemVector)
(id, sim)
}
接下来,我们可以通过对每个项目的相似度分数进行排序来计算前 10 个最相似的项目:
// recall we defined K = 10 earlier
val sortedSims = sims.top(K)(Ordering.by[(Int, Double), Double] {
case (id, similarity) => similarity })
在上述代码片段中,我们使用了 Spark 的top函数,这是一种在分布式方式中计算top-K结果的高效方法,而不是使用collect将所有数据返回到驱动程序并在本地进行排序(请记住,在推荐模型的情况下,我们可能会处理数百万用户和项目)。
我们需要告诉 Spark 如何对sims RDD 中的(项目 ID,相似度分数)对进行排序。为此,我们将传递一个额外的参数给top,这是一个 ScalaOrdering对象,告诉 Spark 应该按照键值对中的值进行排序(即按照相似度进行排序)。
最后,我们可以打印与给定项目计算出的最高相似度度量的 10 个项目:
println(sortedSims.take(10).mkString("n"))
您将看到以下类似的输出:
(567,1.0000000000000002)
(1471,0.6932331537649621)
(670,0.6898690594544726)
(201,0.6897964975027041)
(343,0.6891221044611473)
(563,0.6864214133620066)
(294,0.6812075443259535)
(413,0.6754663844488256)
(184,0.6702643811753909)
(109,0.6594872765176396)
毫不奇怪,我们可以看到排名最高的相似项是我们的项目。其余的是我们项目集中的其他项目,按照我们的相似度度量进行排名。
检查相似项目
让我们看看我们选择的电影的标题是什么:
println(titles(itemId))
上述代码将打印以下输出:
Wes Craven's New Nightmare (1994)
与用户推荐一样,我们可以对项目之间的相似性计算进行感知检查,并查看最相似电影的标题。这次,我们将取前 11 个,以便排除给定的电影。因此,我们将在列表中取 1 到 11 的数字:
val sortedSims2 = sims.top(K + 1)(Ordering.by[(Int, Double),
Double] { case (id, similarity) => similarity })
sortedSims2.slice(1, 11).map{ case (id, sim) => (titles(id), sim)
}.mkString("n")
您将看到显示电影标题和分数的输出类似于此输出:
(Hideaway (1995),0.6932331537649621)
(Body Snatchers (1993),0.6898690594544726)
(Evil Dead II (1987),0.6897964975027041)
(Alien: Resurrection (1997),0.6891221044611473)
(Stephen King's The Langoliers (1995),0.6864214133620066)
(Liar Liar (1997),0.6812075443259535)
(Tales from the Crypt Presents: Bordello of Blood (1996),0.6754663844488256)
(Army of Darkness (1993),0.6702643811753909)
(Mystery Science Theater 3000: The Movie (1996),0.6594872765176396)
(Scream (1996),0.6538249646863378)
再次注意,由于随机模型初始化,您可能会看到完全不同的结果。
现在,您已经使用余弦相似度计算了相似的项目,请尝试对用户因子向量执行相同操作,以计算给定用户的相似用户。
评估推荐模型的性能
我们如何知道我们训练的模型是否是一个好模型?我们需要能够以某种方式评估其预测性能。评估指标是模型预测能力或准确性的度量。有些是直接衡量模型预测模型目标变量的能力,例如均方误差,而其他指标则关注模型在预测可能不会直接优化的事物方面的表现,但通常更接近我们在现实世界中关心的内容,例如平均精度。
评估指标提供了一种标准化的方式,用于比较具有不同参数设置的相同模型的性能,并比较跨不同模型的性能。使用这些指标,我们可以执行模型选择,从我们希望评估的模型集中选择表现最佳的模型。
在这里,我们将向您展示如何计算推荐系统和协同过滤模型中使用的两个常见评估指标:均方误差(MSE)和K 处的平均精度(MAPK)。
ALS 模型评估
从 Spark v2.0 开始,我们将使用org.apache.spark.ml.evaluation.RegressionEvaluator来解决回归问题。回归评估是衡量拟合模型在留出测试数据上表现如何的度量标准。在这里,我们将使用均方根误差(RMSE),它只是 MSE 度量的平方根:
object ALSModeling {
def createALSModel() {
val ratings = FeatureExtraction.getFeatures();
val Array(training, test) = ratings.randomSplit(Array(0.8, 0.2))
println(training.first())
// Build the recommendation model using ALS on the training data
val als = new ALS()
.setMaxIter(5)
.setRegParam(0.01)
.setUserCol("userId")
.setItemCol("movieId")
.setRatingCol("rating")
val model = als.fit(training)
println(model.userFactors.count())
println(model.itemFactors.count())
val predictions = model.transform(test)
println(predictions.printSchema())
val evaluator = new RegressionEvaluator()
.setMetricName("rmse")
.setLabelCol("rating")
.setPredictionCol("prediction")
val rmse = evaluator.evaluate(predictions)
println(s"Root-mean-square error = $rmse")
}
def main(args: Array[String]) {
createALSModel()
}
}
你将看到如下输出:
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Getting 4 non-
empty blocks out of 200 blocks
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Getting 2 non-
empty blocks out of 200 blocks
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Started 0 remote
fetches in 0 ms
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Started 0 remote
fetches in 0 ms
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Getting 1 non-
empty blocks out of 10 blocks
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Getting 1 non-
empty blocks out of 10 blocks
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Started 0 remote
fetches in 0 ms
16/09/07 17:58:45 INFO ShuffleBlockFetcherIterator: Started 0 remote
fetches in 0 ms
Root-mean-square error = 2.1487554400294777
在我们进一步进行之前,请注意以下评估示例使用 Spark v1.6 中的 MLLib。请按照代码清单获取使用org.apache.spark.mllib.recommendation.ALS创建推荐模型的详细信息。
均方误差
MSE 是用户-物品评分矩阵重建误差的直接度量。它也是某些模型中被最小化的目标函数,特别是包括 ALS 在内的许多矩阵分解技术。因此,在显式评分设置中通常使用它。
它被定义为平方误差之和除以观察次数。而平方误差则是给定用户-物品对的预测评分与实际评分之间的差的平方。
我们将以用户789为例。让我们从之前计算的moviesForUser集合的Ratings中取出该用户的第一个评分:
val actualRating = moviesForUser.take(1)(0)
以下是输出:
actualRating: org.apache.spark.mllib.recommendation.Rating =
Rating(789,1012,4.0)
我们将看到该用户-物品组合的评分为 4。接下来,我们将计算模型的预测评分:
val predictedRating = model.predict(789, actualRating.product)
模型预测评分的输出如下:
...
14/04/13 13:01:15 INFO SparkContext: Job finished: lookup at MatrixFactorizationModel.scala:46, took 0.025404 s
predictedRating: Double = 4.001005374200248
我们将看到预测评分约为 4,非常接近实际评分。最后,我们将计算实际评分和预测评分之间的平方误差:
val squaredError = math.pow(predictedRating - actualRating.rating,
2.0)
上述代码将输出平方误差:
squaredError: Double = 1.010777282523947E-6
因此,为了计算数据集的整体 MSE,我们需要为每个(用户,电影,实际评分,预测评分)条目计算这个平方误差,将它们相加,然后除以评分数量。我们将在以下代码片段中执行此操作。
注意:以下代码改编自 Apache Spark ALS 的编程指南,网址为spark.apache.org/docs/latest/mllib-collaborative-filtering.html。
首先,我们将从ratings RDD 中提取用户和产品 ID,并使用model.predict对每个用户-物品对进行预测。我们将使用用户-物品对作为键,预测评分作为值:
val usersProducts = ratings.map{ case Rating(user, product,
rating) => (user, product)}
val predictions = model.predict(usersProducts).map{
case Rating(user, product, rating) => ((user, product),
rating)
}
接下来,我们将提取实际评分,并将ratings RDD 映射,使用户-物品对成为键,实际评分成为值。现在我们有了两个具有相同键形式的 RDD,我们可以将它们连接在一起,创建一个新的 RDD,其中包含每个用户-物品组合的实际和预测评分:
val ratingsAndPredictions = ratings.map{
case Rating(user, product, rating) => ((user, product), rating)
}.join(predictions)
最后,我们将通过使用reduce求和平方误差,并除以记录数量的count方法来计算 MSE:
val MSE = ratingsAndPredictions.map{
case ((user, product), (actual, predicted)) => math.pow((actual - predicted), 2)
}.reduce(_ + _) / ratingsAndPredictions.count
println("Mean Squared Error = " + MSE)
输出如下:
Mean Squared Error = 0.08231947642632852
通常使用 RMSE,它只是 MSE 度量的平方根。这更具可解释性,因为它与基础数据(即本例中的评分)具有相同的单位。它相当于预测和实际评分之间差异的标准差。我们可以简单地计算如下:
val RMSE = math.sqrt(MSE)
println("Root Mean Squared Error = " + RMSE)
上述代码将打印 RMSE:
Root Mean Squared Error = 0.2869137090247319
为了解释前面的结果,请记住以下定义。降低 RMSE 值意味着预测值与实际值的拟合更好。在解释 RMSE 时,请记住实际数据的最小值和最大值。
K 处的平均精度
在K处的平均精度是数据集中所有实例的K 处的平均精度(APK)指标的平均值。APK 是信息检索常用的度量标准。APK 是对响应查询呈现的top-K文档的平均相关性分数的度量。对于每个查询实例,我们将top-K结果集与实际相关文档集进行比较,也就是查询的真实相关文档集。
在 APK 指标中,结果集的顺序很重要,如果结果文档既相关又相关文档在结果中排名较高,则 APK 得分会更高。因此,这是推荐系统的一个很好的指标;通常,我们会为每个用户计算top-K推荐的项目,并将这些项目呈现给用户。当然,我们更喜欢那些具有最高预测分数的项目的模型,这些项目在推荐列表的顶部呈现时,实际上是用户最相关的项目。APK 和其他基于排名的指标也更适合隐式数据集的评估指标;在这里,MSE 没有太多意义。
为了评估我们的模型,我们可以使用 APK,其中每个用户相当于一个查询,而top-K推荐项目集是文档结果集。相关文档,也就是在这种情况下的真相,是用户交互的项目集。因此,APK 试图衡量我们的模型在预测用户会发现相关并选择与之交互的项目方面有多好。
以下平均精度计算的代码基于github.com/benhamner/Metrics。
更多关于 MAPK 的信息可以在www.kaggle.com/wiki/MeanAveragePrecision找到。
我们的计算 APK 的函数如下所示:
def avgPrecisionK(actual: Seq[Int], predicted: Seq[Int], k: Int):
Double = {
val predK = predicted.take(k)
var score = 0.0
var numHits = 0.0
for ((p, i) <- predK.zipWithIndex) {
if (actual.contains(p)) {
numHits += 1.0
score += numHits / (i.toDouble + 1.0)
}
}
if (actual.isEmpty) {
1.0
} else {
score / scala.math.min(actual.size, k).toDouble
}
}
如您所见,这需要输入一个与用户相关联的“实际”项目 ID 列表和另一个“预测”ID 列表,以便我们的估计对用户是相关的。
我们可以计算我们示例用户789的 APK 指标如下。首先,我们将提取用户的实际电影 ID,如下所示:
val actualMovies = moviesForUser.map(_.product)
输出如下:
actualMovies: Seq[Int] = ArrayBuffer(1012, 127, 475, 93, 1161, 286,
293, 9, 50, 294, 181, 1, 1008, 508, 284, 1017, 137, 111, 742, 248,
249, 1007, 591, 150, 276, 151, 129, 100, 741, 288, 762, 628, 124)
然后,我们将使用先前制作的电影推荐来使用K = 10计算 APK 得分:
val predictedMovies = topKRecs.map(_.product)
这是输出:
predictedMovies: Array[Int] = Array(27, 497, 633, 827, 602, 849, 401,
584, 1035, 1014)
以下代码将产生平均精度:
val apk10 = avgPrecisionK(actualMovies, predictedMovies, 10)
前面的代码将打印以下命令行:
apk10: Double = 0.0
在这种情况下,我们可以看到我们的模型并没有很好地预测这个用户的相关电影,因为 APK 得分为0。
为了计算每个用户的 APK 并对其进行平均以计算整体 MAPK,我们需要为数据集中的每个用户生成推荐列表。虽然这在大规模上可能相当密集,但我们可以使用我们的 Spark 功能来分发计算。然而,一个限制是每个工作节点必须有完整的项目因子矩阵可用,以便它可以计算相关用户向量和所有项目向量之间的点积。当项目数量非常高时,这可能是一个问题,因为项目矩阵必须适合一个机器的内存中。
实际上,没有简单的方法可以解决这个限制。一种可能的方法是仅使用近似技术,如局部敏感哈希(en.wikipedia.org/wiki/Locality-sensitive_hashing),为总项目集的子集计算推荐。
我们现在将看看如何做。首先,我们将收集项目因子并从中形成一个DoubleMatrix对象:
val itemFactors = model.productFeatures.map { case (id, factor) =>
factor }.collect()
val itemMatrix = new DoubleMatrix(itemFactors)
println(itemMatrix.rows, itemMatrix.columns)
前面代码的输出如下:
(1682,50)
这给我们一个具有1682行和50列的矩阵,这是我们从1682部电影中期望的因子维度为50的矩阵。接下来,我们将将项目矩阵作为广播变量分发,以便它在每个工作节点上都可用:
val imBroadcast = sc.broadcast(itemMatrix)
您将看到以下输出:
14/04/13 21:02:01 INFO MemoryStore: ensureFreeSpace(672960) called
with curMem=4006896, maxMem=311387750
14/04/13 21:02:01 INFO MemoryStore: Block broadcast_21 stored as
values to memory (estimated size 657.2 KB, free 292.5 MB)
imBroadcast:
org.apache.spark.broadcast.Broadcast[org.jblas.DoubleMatrix] =
Broadcast(21)
现在我们准备为每个用户计算推荐。我们将通过对每个用户因子应用map函数来执行用户因子向量和电影因子矩阵之间的矩阵乘法来实现这一点。结果是一个向量(长度为1682,即我们拥有的电影数量),其中包含每部电影的预测评分。然后,我们将按预测评分对这些预测进行排序:
val allRecs = model.userFeatures.map{ case (userId, array) =>
val userVector = new DoubleMatrix(array)
val scores = imBroadcast.value.mmul(userVector)
val sortedWithId = scores.data.zipWithIndex.sortBy(-_._1)
val recommendedIds = sortedWithId.map(_._2 + 1).toSeq
(userId, recommendedIds)
}
您将在屏幕上看到以下内容:
allRecs: org.apache.spark.rdd.RDD[(Int, Seq[Int])] = MappedRDD[269]
at map at <console>:29
我们现在有一个 RDD,其中包含每个用户 ID 的电影 ID 列表。这些电影 ID 按照估计的评分顺序排列。
请注意,我们需要将返回的电影 ID 加 1(如前面的代码片段中所示),因为项目因子矩阵是从 0 开始索引的,而我们的电影 ID 从1开始。
我们还需要每个用户的电影 ID 列表,作为actual参数传递给我们的 APK 函数。我们已经准备好了ratings RDD,所以我们可以从中提取用户和电影 ID。
如果我们使用 Spark 的groupBy操作符,我们将得到一个 RDD,其中包含每个用户 ID 的(userid, movieid)对列表(因为用户 ID 是我们执行groupBy操作的键),如下所示:
val userMovies = ratings.map{ case Rating(user, product, rating)
=> (user, product) }.groupBy(_._1)
上述代码的输出如下:
userMovies: org.apache.spark.rdd.RDD[(Int, Seq[(Int, Int)])] =
MapPartitionsRDD[277] at groupBy at <console>:21
最后,我们可以使用 Spark 的join操作符在用户 ID 键上将这两个 RDD 连接在一起。然后,对于每个用户,我们有实际和预测的电影 ID 列表,我们可以将其传递给我们的 APK 函数。类似于我们计算 MSE 的方式,我们将使用reduce操作来对这些 APK 分数进行求和,并除以用户数量,即allRecs RDD 的计数,如下面的代码所示:
val K = 10
val MAPK = allRecs.join(userMovies).map{ case (userId, (predicted, actualWithIds)) =>
val actual = actualWithIds.map(_._2).toSeq
avgPrecisionK(actual, predicted, K)
}.reduce(_ + _) / allRecs.count
println("Mean Average Precision at K = " + MAPK)
上述代码将打印K处的平均精度如下:
Mean Average Precision at K = 0.030486963254725705
我们的模型实现了一个相当低的 MAPK。但是,请注意,推荐任务的典型值通常相对较低,特别是如果项目集非常大的话。
尝试一些lambda和rank(如果您使用 ALS 的隐式版本,则还有alpha)的参数设置,并查看是否可以找到基于 RMSE 和 MAPK 评估指标表现更好的模型。
使用 MLlib 的内置评估函数
虽然我们已经从头开始计算了 MSE、RMSE 和 MAPK,这是一个有用的学习练习,但是 MLlib 提供了方便的函数来在RegressionMetrics和RankingMetrics类中为我们执行这些操作。
RMSE 和 MSE
首先,我们将使用RegressionMetrics计算 MSE 和 RMSE 指标。我们将通过传入表示每个数据点的预测和真实值的键值对 RDD 来实例化RegressionMetrics实例,如下面的代码片段所示。在这里,我们将再次使用我们在之前示例中计算的ratingsAndPredictions RDD:
import org.apache.spark.mllib.evaluation.RegressionMetrics
val predictedAndTrue = ratingsAndPredictions.map { case ((user,
product), (predicted, actual)) => (predicted, actual) }
val regressionMetrics = new RegressionMetrics(predictedAndTrue)
然后,我们可以访问各种指标,包括 MSE 和 RMSE。我们将在这里打印出这些指标:
println("Mean Squared Error = " +
regressionMetrics.meanSquaredError)
println("Root Mean Squared Error = " +
regressionMetrics.rootMeanSquaredError)
在以下命令行中,您将看到 MSE 和 RMSE 的输出与我们之前计算的指标完全相同:
Mean Squared Error = 0.08231947642632852
Root Mean Squared Error = 0.2869137090247319
MAP
正如我们对 MSE 和 RMSE 所做的那样,我们可以使用 MLlib 的RankingMetrics类来计算基于排名的评估指标。类似地,与我们自己的平均精度函数一样,我们需要传入一个键值对的 RDD,其中键是用户的预测项目 ID 数组,而值是实际项目 ID 的数组。
在RankingMetrics中,平均精度在 K 函数的实现与我们的略有不同,因此我们将得到不同的结果。但是,如果我们选择K非常高(比如至少与我们的项目集中的项目数量一样高),则整体平均精度(MAP,不使用 K 阈值)的计算与我们的函数相同。
首先,我们将使用RankingMetrics计算 MAP 如下:
import org.apache.spark.mllib.evaluation.RankingMetrics
val predictedAndTrueForRanking = allRecs.join(userMovies).map{
case (userId, (predicted, actualWithIds)) =>
val actual = actualWithIds.map(_._2)
(predicted.toArray, actual.toArray)
}
val rankingMetrics = new
RankingMetrics(predictedAndTrueForRanking)
println("Mean Average Precision = " +
rankingMetrics.meanAveragePrecision)
您将在屏幕上看到以下输出:
Mean Average Precision = 0.07171412913757183
接下来,我们将使用我们的函数以与之前完全相同的方式计算 MAP,只是将K设置为一个非常高的值,比如2000:
val MAPK2000 = allRecs.join(userMovies).map{ case (userId,
(predicted, actualWithIds)) =>
val actual = actualWithIds.map(_._2).toSeq
avgPrecisionK(actual, predicted, 2000)
}.reduce(_ + _) / allRecs.count
println("Mean Average Precision = " + MAPK2000)
您将看到我们自己函数计算的 MAP 与使用RankingMetrics计算的 MAP 相同:
Mean Average Precision = 0.07171412913757186.
我们将不在本章涵盖交叉验证,因为我们将在接下来的几章中提供详细的处理。但是,请注意,探讨在即将到来的章节中探索的交叉验证技术可以用于使用 MSE、RMSE 和 MAP 等性能指标来评估推荐模型的性能,这些指标我们在本节中已经涵盖。
FP-Growth 算法
我们将应用 FP-Growth 算法来找出经常推荐的电影。
FP-Growth 算法已在 Han 等人的论文中描述,Mining frequent patterns without candidate generation,可在dx.doi.org/10.1145/335191.335372上找到,其中FP代表frequent pattern。对于给定的交易数据集,FP-Growth 的第一步是计算项目频率并识别频繁项目。FP-Growth 算法实现的第二步使用后缀树(FP-tree)结构来编码交易;这是在不显式生成候选集的情况下完成的,通常对于大型数据集来说生成候选集是昂贵的。
FP-Growth 基本示例
让我们从一个非常简单的随机数字数据集开始:
val transactions = Seq(
"r z h k p",
"z y x w v u t s",
"s x o n r",
"x z y m t s q e",
"z",
"x z y r q t p")
.map(_.split(" "))
我们将找出最频繁的项目(在本例中是字符)。首先,我们将按如下方式获取 Spark 上下文:
val sc = new SparkContext("local[2]", "Chapter 5 App")
将我们的数据转换为 RDD:
val rdd = sc.parallelize(transactions, 2).cache()
初始化FPGrowth实例:
val fpg = new FPGrowth()
FP-Growth 可以配置以下参数:
-
minSupport:被识别为频繁项集的最小支持数。例如,如果一个项目在 10 个交易中出现 3 次,则其支持率为 3/10=0.3。 -
numPartitions:要分发工作的分区数。
设置minsupport和 FP-Growth 实例的分区数,并在 RDD 对象上调用 run。分区数应设置为数据集中的分区数--数据将从中加载的工作节点数,如下所示:
val model = fpg.setMinSupport(0.2).setNumPartitions(1).run(rdd)
获取输出的项目集并打印:
model.freqItemsets.collect().foreach {
itemset =>
println(itemset.items.mkString(
"[", ",", "]") + ", " + itemset.freq
)
前面代码的输出如下,您可以看到[Z]出现最多:
[s], 3
[s,x], 3
[s,x,z], 2
[s,z], 2
[r], 3
[r,x], 2
[r,z], 2
[y], 3
[y,s], 2
[y,s,x], 2
[y,s,x,z], 2
[y,s,z], 2
[y,x], 3
[y,x,z], 3
[y,t], 3
[y,t,s], 2
[y,t,s,x], 2
[y,t,s,x,z], 2
[y,t,s,z], 2
[y,t,x], 3
[y,t,x,z], 3
[y,t,z], 3
[y,z], 3
[q], 2
[q,y], 2
[q,y,x], 2
[q,y,x,z], 2
[q,y,t], 2
[q,y,t,x], 2
[q,y,t,x,z], 2
[q,y,t,z], 2
[q,y,z], 2
[q,x], 2
[q,x,z], 2
[q,t], 2
[q,t,x], 2
[q,t,x,z], 2
[q,t,z], 2
[q,z], 2
[x], 4
[x,z], 3
[t], 3
[t,s], 2
[t,s,x], 2
[t,s,x,z], 2
[t,s,z], 2
[t,x], 3
[t,x,z], 3
[t,z], 3
[p], 2
[p,r], 2
[p,r,z], 2
[p,z], 2
[z], 5
应用于 Movie Lens 数据的 FP-Growth
让我们将算法应用于 Movie Lens 数据,以找到我们频繁的电影标题:
- 通过编写以下代码行来实例化
SparkContext:
val sc = Util.sc
val rawData = Util.getUserData()
rawData.first()
- 获取原始评分并通过编写以下代码行打印第一个:
val rawRatings = rawData.map(_.split("t").take(3))
rawRatings.first()
val ratings = rawRatings.map { case Array(user, movie,
rating) =>
Rating(user.toInt, movie.toInt, rating.toDouble) }
val ratingsFirst = ratings.first()
println(ratingsFirst)
- 加载电影数据并获取标题如下:
val movies = Util.getMovieData()
val titles = movies.map(line =>
line.split("|").take(2)).map(array
=> (array(0).toInt, array(1))).collectAsMap()
titles(123)
-
接下来,我们将使用 FP-Growth 算法找出从 501 到 900 号用户中 400 个用户最频繁的电影。
-
首先通过编写以下代码行创建 FP-Growth 模型:
val model = fpg
.setMinSupport(0.1)
.setNumPartitions(1)
.run(rddx)
- 其中
0.1是要考虑的最小截止值,rddx是加载到 400 个用户的原始电影评分的 RDD。一旦我们有了模型,我们可以迭代overitemsetr,itemset并打印结果。
完整的代码清单在此处给出,并且也可以在github.com/ml-resources/spark-ml/blob/branch-ed2/Chapter_05/scala-spark-app/src/main/scala/MovieLensFPGrowthApp.scala找到。
可以通过编写以下代码行来完成:
var eRDD = sc.emptyRDD
var z = Seq[String]()
val l = ListBuffer()
val aj = new ArrayString
var i = 0
for( a <- 501 to 900) {
val moviesForUserX = ratings.keyBy(_.user).
lookup(a)
val moviesForUserX_10 =
moviesForUserX.sortBy(-_.rating).take(10)
val moviesForUserX_10_1 = moviesForUserX_10.map
(r => r.product)
var temp = ""
for( x <- moviesForUserX_10_1){
if(temp.equals(""))
temp = x.toString
else {
temp = temp + " " + x
}
}
aj(i) = temp
i += 1
}
z = aj
val transaction = z.map(_.split(" "))
val rddx = sc.parallelize(transaction, 2).cache()
val fpg = new FPGrowth()
val model = fpg
.setMinSupport(0.1)
.setNumPartitions(1)
.run(rddx)
model.freqItemsets.collect().foreach { itemset =>
println(itemset.items.mkString("[", ",", "]")
+ ", " + itemset.freq)
}
sc.stop()
前面示例的输出如下:
[302], 40
[258], 59
[100], 49
[286], 50
[181], 45
[127], 60
[313], 59
[300], 49
[50], 94
这为用户 ID 501 到 900 提供了具有最大频率的电影。
摘要
在本章中,我们使用 Spark 的 ML 和 MLlib 库来训练协同过滤推荐模型,并学习如何使用该模型来预测给定用户可能偏好的项目。我们还使用我们的模型来找到与给定项目相似或相关的项目。最后,我们探索了评估我们推荐模型的预测能力的常见指标。
在下一章中,您将学习如何使用 Spark 训练模型来对数据进行分类,并使用标准评估机制来衡量模型的性能。