精通-Python-预测性分析-全-

精通 Python 预测性分析(全)

原文:zh.annas-archive.org/md5/97332a22a63e3231f33ebdd3488daae8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在《精通 Python 预测分析》中,你将通过逐步过程将原始数据转化为有价值的见解。本书内容丰富,包含大量案例研究和使用流行开源 Python 库的代码示例,展示了分析应用完整开发过程。详细的示例说明了针对常见用例的稳健和可扩展的应用。你将学会快速将这些方法应用到自己的数据中。

本书涵盖内容

第一章, 从数据到决策 – 分析应用的入门,教你描述分析管道的核心组件以及它们之间的交互方式。我们还考察了批处理和流处理之间的差异,以及每种类型的应用适合的用例。我们通过使用两种范例的基本应用示例以及每个步骤所需的设计决策来指导。

第二章, Python 中的探索性数据分析和可视化,考察了构建分析应用所需执行的大量任务。使用 IPython 笔记本,我们将介绍如何将文件中的数据加载到 pandas 的数据框中,重命名数据集中的列,过滤掉不需要的行,转换数据类型,以及创建新列。此外,我们还将合并来自不同来源的数据,并使用聚合和转置执行一些基本的统计分析。

第三章, 在噪声中寻找模式 – 聚类和无监督学习,展示了如何在数据集中识别相似项的组。这是一种探索性分析,我们可能会频繁地将其作为解读新数据集的第一步。我们探讨了计算数据点之间相似性的不同方法,并描述了这些度量可能最适合哪些类型的数据。我们检查了分裂聚类算法,这些算法从单个组开始将数据分割成更小的组件,以及聚合方法,其中每个数据点最初都是其自己的簇。使用多个数据集,我们展示了这些算法表现更好或更差的情况,以及一些优化它们的方法。我们还看到了我们的第一个(小型)数据处理管道,一个使用流数据的 PySpark 聚类应用。

第四章, 用模型连接点 – 回归方法,考察了几个回归模型的拟合,包括将输入变量转换为正确的尺度以及正确处理分类特征。我们拟合并评估了线性回归以及正则化回归模型。我们还考察了基于树的回归模型的使用,以及如何优化拟合这些模型的参数选择。最后,我们将查看使用 PySpark 的随机森林建模示例,这可以应用于更大的数据集。

第五章, 将数据放在合适的位置 – 分类方法和分析,解释了如何使用分类模型以及提高模型性能的一些策略。除了转换分类特征外,我们还探讨了使用 ROC 曲线解释逻辑回归准确性的方法。为了提高模型性能,我们展示了 SVMs 的使用。最后,我们将通过梯度提升决策树在测试集上实现良好的性能。

第六章, 文字和像素 – 处理非结构化数据,考察了复杂、非结构化数据。然后我们介绍了降维技术,如 HashingVectorizer;矩阵分解,如 PCA、CUR 和 NMR;以及概率模型,如 LDA。我们还考察了图像数据,包括归一化和阈值操作,并探讨如何使用降维技术来发现图像之间的共同模式。

第七章, 自下而上学习 – 深度网络和无监督特征,介绍了深度神经网络作为生成复杂数据类型模型的途径,其中特征难以工程化。我们将考察神经网络如何通过反向传播进行训练,以及为什么额外的层使这种优化变得难以处理。

第八章, 与预测服务共享模型,描述了基本预测服务的三个组成部分,并讨论了这种设计如何使我们能够与其他用户或软件系统共享预测模型的结果。

第九章, 报告和测试 – 在分析系统中迭代,教授了在初始设计之后监控预测模型性能的几种策略,并探讨了模型性能或组件随时间变化的一些场景。

您需要这本书什么

您需要安装最新的 Python 版本和 PySpark 版本,以及 Jupyter 笔记本。

这本书面向谁

本书是为商业分析师、BI 分析师、数据科学家或准备从高级分析的概念理解转向使用 Python 设计和构建高级分析解决方案的初级数据分析师而设计的。您应具备基本的 Python 开发经验。

惯例

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名按以下方式显示:“让我们首先使用head()tail()查看数据的开始和结束部分。”

任何命令行输入或输出都按以下方式编写:

rdd_data.coalesce(2).getNumPartitions()

新术语重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“返回到文件选项卡,您会在右上角注意到两个选项。”

注意

警告或重要注意事项以如下框中的方式显示。

提示

技巧和窍门如下所示。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,请简单地通过电子邮件发送至<feedback@packtpub.com>,并在邮件主题中提及书籍标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您已经是 Packt 书籍的骄傲拥有者,我们有许多事情可以帮助您从购买中获得最大收益。

下载示例代码

您可以从您的账户www.packtpub.com下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持选项卡上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从您购买此书的下拉菜单中选择。

  7. 点击代码下载

一旦文件下载完成,请确保您使用最新版本解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从 www.packtpub.com/sites/default/files/downloads/MasteringPredictiveAnalyticswithPython_ColorImages.pdf 下载此文件。

勘误

尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送链接至疑似盗版材料至 <copyright@packtpub.com> 来联系我们。

我们感谢您的帮助,以保护我们的作者和我们为您提供有价值内容的能力。

询问

如果您对本书的任何方面有问题,您可以通过发送邮件至 <questions@packtpub.com> 来联系我们,我们将尽力解决问题。

第一章:从数据到决策 - 开始使用分析应用

从季度财务预测到客户调查,分析帮助企业在做出决策和规划未来方面。虽然使用电子表格程序制作的饼图和趋势线等数据可视化已经使用了数十年,但近年来,业务分析师可用的数据源的数量和多样性以及用于解释这些信息的工具的复杂性都有所增长。

互联网的快速增长,通过电子商务和社交媒体平台,产生了大量数据,这些数据比以往任何时候都更快地用于分析。照片、搜索查询和在线论坛帖子都是无法在传统电子表格程序中轻松检查的非结构化数据示例。有了适当的工具,这些类型的数据可以提供新的见解,与或超越传统数据源。

传统上,如历史客户记录之类的数据以结构化、表格形式出现,存储在电子数据仓库中,并易于导入电子表格程序。即使在表格数据的情况下,记录的数量和可用速度也在许多行业中不断增加。虽然分析师可能通过交互式操作将原始数据转换为历史数据,但强大的分析越来越需要能够与业务接收到的数据量和速度相匹配的自动化处理。

除了数据本身,用于检查数据的方法也变得更加强大和复杂。除了总结历史模式或使用来自少数关键输入变量的趋势线来预测未来事件之外,高级分析强调使用复杂的预测建模(如下所述预测分析的目标)来理解现状并预测短期和长期结果。

生成此类预测的多种方法通常需要以下共同要素:

  • 我们试图预测的结果或目标,例如购买或搜索结果上的点击率CTR)。

  • 一组列,这些列组成特征,也称为预测因子(例如,客户的人口统计信息、销售账户的历史交易或某种广告上的点击行为),描述我们数据集中每个记录的个体属性(例如,一个账户或广告)。

  • 一种找到模型或模型集的程序,这些模型或模型集最好地将这些特征映射到给定数据样本中感兴趣的结果。

  • 一种评估模型在新数据上性能的方法。

虽然预测建模技术可以在强大的分析应用中使用,以发现看似无关输入之间的复杂关系,但它们也给业务分析师带来了新的挑战:

  • 什么方法最适合特定问题?

  • 如何正确评估这些技术在历史数据和新技术上的表现?

  • 调整给定方法性能的首选策略是什么?

  • 如何稳健地扩展这些技术以适应一次性分析和持续洞察?

在本书中,我们将向您展示如何通过开发将数据转化为您和您的业务强大见解的分析解决方案来应对这些挑战。构建这些应用程序涉及的主要任务是:

  • 将原始数据转换为可用于建模的清洁形式。这可能涉及清理异常数据以及将非结构化数据转换为结构化格式。

  • 特征工程,通过将这些清洁输入转换为用于开发预测模型的格式。

  • 在这部分数据的一个子集上校准预测模型并评估其性能。

  • 在评估模型持续性能的同时评分新数据。

  • 自动化转换和建模步骤以进行常规更新。

  • 将模型的输出暴露给其他系统和用户,通常通过 Web 应用程序。

  • 为分析师和业务用户生成报告,提炼数据和模型为常规和稳健的见解。

在整个本卷中,我们将使用用 Python 编程语言编写的开源工具来构建这些类型的应用程序。为什么是 Python?Python 语言在健壮的编译语言(如 Java、C++和 Scala)和纯统计软件包(如 R、SAS 或 MATLAB)之间取得了吸引人的平衡。我们可以通过命令行(或,如我们在后续章节中将使用的,基于浏览器的笔记本环境)与 Python 进行交互式工作,绘制数据,并原型化命令。Python 还提供了广泛的库,使我们能够将这种探索性工作转化为 Web 应用程序(如 Flask、CherryPy 和 Celery,我们将在第八章中看到,通过预测服务共享模型),或将它们扩展到大型数据集(使用 PySpark,我们将在未来的章节中探讨)。因此,我们可以在同一语言中同时分析数据和开发软件应用程序。

在深入这些工具的技术细节之前,让我们从高层次上看看这些应用背后的概念以及它们的结构。在本章中,我们将:

  • 定义分析管道的元素:数据转换、合理性检查、预处理、模型开发、评分、自动化、部署和报告。

  • 解释批处理和流处理之间的差异以及它们在管道每个步骤中的影响。

  • 检查批处理和流处理如何在 Lambda 架构中联合适应数据处理。

  • 探索一个示例流处理管道,以执行社交媒体流量的情感分析。

  • 探索一个批处理管道的示例,以生成定向电子邮件营销活动。

小贴士

预测分析的目标

术语预测分析,以及其他如数据挖掘机器学习,通常用来描述本书中用于构建分析解决方案的技术。然而,重要的是要记住,这些方法可以解决两个不同的目标。推理涉及建立模型以评估参数对结果的影响的重要性,并强调解释和透明度而非预测性能。例如,回归模型的系数(第四章, 通过模型连接点 – 回归方法)可以用来估计特定模型输入(例如,客户年龄或收入)对输出变量(例如,销售额)的影响。为推理开发的模型的预测可能不如其他技术准确,但提供了有价值的概念洞察,可能指导商业决策。相反,预测强调估计结果的准确性,即使模型本身是一个黑盒,其中输入和结果输出之间的联系并不总是清晰的。例如,深度学习(第七章, 从底部学习 – 深度网络和无监督特征)可以从复杂输入集产生最先进的模型和极其准确的预测,但输入参数和预测之间的联系可能难以解释。

设计高级分析解决方案

分析解决方案的基本组成部分是什么?虽然具体设计可能因应用而异,但大多数解决方案都包含以下部分(图 1):

设计高级分析解决方案

图 1:分析管道的参考架构

  • 数据层:这一阶段涉及数据的存储、处理和持久化,以及如何将其提供给下游应用程序,例如我们将在这本书中构建的分析应用程序。如图 1 所示,数据作为粘合剂将我们应用程序的其他部分粘合在一起,所有这些部分都依赖于数据层来存储和更新它们状态的信息。这也反映了我们将更详细讨论的关注点分离,在第八章, 与预测服务共享模型和第九章, 报告和测试 – 在分析系统中迭代中,我们应用程序的其他三个组件可以独立设计,因为它们仅通过数据层进行交互。

  • 建模层:在此阶段,数据已经被转换成可以被我们的 Python 建模代码摄取的形式。可能还需要进一步的特征工程任务,将清洗后的数据转换为模型输入,以及将数据分割成子集并执行迭代优化和调整的轮次。还需要以可以持久化和部署给下游用户的方式准备模型。此阶段还涉及对新接收的数据进行评分,或随着时间的推移对模型健康进行审计。

  • 部署层:建模层中的算法开发和性能组件通常通过 Web 服务暴露给人类用户或其他软件系统,这些消费者通过服务器层通过网络调用与它们交互,以触发新的模型开发轮次并查询先前分析的结果。

  • 报告层:预测、模型参数和洞察都可以通过报告服务进行可视化和自动化。

考虑到这些广泛的组件,让我们更深入地探讨这些各个部分的细节。

数据层:仓库、湖泊和流

任何分析管道的开始都是数据本身,它是预测建模的基础。这种输入可以在可用的更新速率和需要应用以形成最终用于预测模型的特征集的转换量方面有所不同。数据层是这个信息的存储库。

传统上,用于分析的数据可能只是简单地存储在磁盘上的平面文件中,例如电子表格或文档。随着数据的多样性和规模增加,存储和处理这些数据所需的资源和复杂性也增加了。确实,现代数据层的观点涵盖了实时(流)数据和批量数据,这在许多潜在的下层使用中都是如此。这个称为Lambda 架构(Marz, Nathan, 和 James Warren. 大数据:可扩展实时数据系统的原理和最佳实践. Manning Publications Co., 2015。)的联合系统,在以下图中进行了说明:

数据层:仓库、湖泊和流

图 2:数据层作为 Lambda 架构

此数据层的组成部分包括:

  • 数据来源:这些可以是实时流接收到的数据,也可以是定期或不定期的批量更新。

  • 数据湖: 实时数据和批处理数据通常保存在数据湖模型中,其中使用如Hadoop 文件系统HDFS)或亚马逊网络服务AWS)的简单存储服务S3)等分布式文件系统作为批量接收和流接收数据的通用存储介质。这些数据可以按照固定寿命(临时)或永久(持久)的保留策略进行存储。然后,这些数据可以在 MapReduce 或 Spark 等框架中运行的持续批处理转换,如提取、加载和转换ETL)作业中进行处理。ETL 过程可能包括清理数据、将其聚合到感兴趣的指标中,或将其从原始输入重塑为表格形式。这种处理形成了 Lambda 架构的批处理层,其中不期望实时可用性,并且对于呈现数据视图供下游消费的延迟(分钟到几天)是可以接受的。

  • 数据河: 当数据湖在中央位置累积所有类型的原始数据时,数据河形成了一个持续的消息队列,实时数据被发送到流处理任务。这也被称为架构的速度层(Marz, Nathan, and James Warren. Big Data: Principles and best practices of scalable realtime data systems. Manning Publications Co., 2015.),因为它在数据可用时立即操作,并期望实时可用性。

  • 合并视图: 原始数据的实时和批处理视图可以合并到一个共同的持久层,例如结构化表格中的数据仓库,在那里可以使用结构化查询语言SQL)进行查询,并在事务性(例如,实时更新银行余额)或分析性(例如,运行分析或报告)应用程序中使用。此类仓库系统的例子包括传统的关系型系统,如 MySQL 和 PostgreSQL(通常在行和列中以表格模式存储数据),以及 NoSQL 系统,如 MongoDB 或 Redis(在键值系统中更灵活地安排数据,其中值可以采用许多格式,而不仅仅是传统的行和列)。这个合并系统也被称为服务层(Marz, Nathan, and James Warren. Big Data: Principles and best practices of scalable realtime data systems. Manning Publications Co., 2015.),可以直接使用数据库系统进行查询,或者呈现给下游应用程序。

  • 下游应用: 我们的高级分析管道等系统可以直接消费批处理和实时处理层的输出,或者通过仓库系统中的合并视图与这些来源之一或两者进行交互。

在数据层中,流式数据和批处理数据如何被不同地处理?在批处理管道中,接收和处理数据之间的允许延迟允许对源数据进行潜在的复杂转换:元素可能被聚合(例如,计算用户或产品在一定时间内的平均属性),与其他来源连接(例如,在搜索日志上索引额外的网站元数据),以及过滤(例如,许多网络日志系统需要删除会扭曲预测模型结果的其他机器人活动)。源数据可能来自服务器上发布的简单文本文件、关系数据库系统,或不同存储格式的混合(见下文)。

相反,由于必须快速消费传入数据,流式处理通常涉及比批处理作业更简单的输入处理,并使用简单的过滤器或转换。此类应用的来源通常是来自网络服务(如社交媒体或新闻源)、事件(如车辆和手机的地理位置)或客户活动(如搜索或点击)的持续更新的流。

在此阶段,选择批处理和流处理主要取决于数据源,数据源可以是持续更新的事件系列(流式处理)或较大、定期可用的块(批处理)。在某些情况下,数据的性质也会决定后续管道的形式,以及对实时或更高延迟处理的强调。在其他情况下,应用的使用将优先于下游选择。数据层中呈现的标准化视图将在分析管道的下一阶段,即建模层中使用。

建模层

建模层涉及许多相互关联的任务,如下面的图所示(图 3)。由于数据层可以容纳实时和批处理数据,我们可以想象两种主要的建模系统:

  • 流式管道对可用的连续数据源(如即时消息或新闻源)立即进行操作,这可能允许实时模型更新或评分。然而,实时更新模型的能力可能因算法而异(例如,对于使用随机更新的模型,请参阅第五章,将数据放在合适的位置 – 分类方法和分析),并且某些模型只能在离线过程中开发。流式数据的潜在体积也可能意味着它不能以原始形式存储,而只能在丢弃原始记录之前转换为更易于管理的格式。

  • 批量处理。定期更新(通常为每日)的数据源通常使用面向批量的框架进行处理。输入数据不需要在可用时立即使用,更新之间的延迟通常为几小时或几天,通常是可以接受的,这意味着数据处理和模型开发通常不是实时进行的。

注意

在表面上,选择这两类管道似乎涉及实时(流式)或离线(批量)分析之间的权衡。在实践中,这两类管道可以在单个应用程序中混合使用实时和非实时组件。

如果这两种类型的管道对于给定问题都是可行的(例如,如果流是股票价格,一个体积大且格式简单——一组数字——的数据集应该允许它被轻松离线存储并在稍后日期完整处理),那么选择这两个框架可能由技术或业务问题决定。例如,有时预测模型中使用的方法只允许批量更新,这意味着连续处理接收到的流不会增加额外的价值。在其他情况下,由预测模型提供的信息对业务决策的重要性需要实时更新,因此将受益于流处理。

建模层

图 3:建模层概述

图 3 中所示每种类型管道的通用组件的详细信息如下:

模型输入步骤中,源数据被加载,并且可能通过管道转换为预测模型所需的输入。这可能只是公开数据库表中的一部分列,或者将非结构化源(如文本)转换为可能输入到预测模型的形式。如果我们很幸运,我们希望在模型中使用的特征已经以它们在原始数据中存在的形式存在。在这种情况下,模型拟合直接在输入上进行。更常见的是,输入数据仅包含我们可能希望用作模型输入的基本信息,但需要将其处理成可用于预测的形式。

在数值数据的情况下,这可能采取离散化或转换的形式。离散化涉及将一个连续的数字(例如订阅服务的消费者使用年限)划分为区间(例如,拥有<30天或>=30天订阅的用户),这要么通过将连续尺度上的异常值阈值化到一个合理的区间数量来减少数据集中的变化,要么将数值范围转换为具有更多直接商业含义的值集。离散化的另一个例子是将连续值转换为排名,在这种情况下,我们更关心的是与其他人的相对价值,而不是实际数字。同样,随着指数尺度的变化,可能使用自然对数进行转换,以减少大值对建模过程的影响。

除了这些类型的转换之外,数值特征可能以比率、总和、乘积或其他组合的形式结合,从而从几个基本输入中产生潜在的特征组合爆炸。在某些模型中,这些类型的交互需要通过在输入之间生成这样的组合特征来明确表示(例如,我们在第四章中讨论的回归模型,通过模型连接点 – 回归方法)。其他模型具有在数据集中解码这些交互的能力,而无需我们直接创建特征(例如,第五章中的随机森林算法,将数据放在其位置 – 分类方法和分析或第六章中的梯度提升决策树,文字和像素 – 处理非结构化数据)。

在分类数据的情况下,例如国家代码或星期几,我们可能需要将类别转换为数值描述符。这可能是一个数字(如果数据是序数的,例如,值为2的含义是大于值为1的记录),或者是一个具有一个或多个非零条目的向量,指示分类特征所属的类别(例如,一个文档可以表示为一个与英语词汇表长度相同的向量,其中的数字表示特定向量位置所代表的单词在文档中出现的次数)。

最后,我们可能会发现一些情况,我们希望发现由特定输入集表示的隐藏特征。例如,收入、职业和年龄都可能与其居住的邮政编码相关。如果地理变量不是我们的数据集的一部分,我们仍然可以使用降维技术发现这些共同的潜在模式,正如我们将在第六章“文字和像素 - 处理非结构化数据”中讨论的那样。

在这个阶段也可能进行合理性检查,因为当数据异常出现时,如可能降低模型性能的异常值,及时发现这些异常至关重要。在质量检查的第一阶段,评估输入数据以防止异常值或错误数据影响后续阶段模型的质量。这些合理性检查可能采取多种形式:对于分类数据(例如,一个州或国家),只有固定数量的允许值,这使得排除错误输入变得容易。在其他情况下,这种质量检查基于经验分布,例如与平均值的变化,或合理的最小或最大范围。更复杂的情况通常源于业务规则(例如,某个地区产品不可用,或网络会话中特定 IP 地址组合不合理)。

这样的质量检查不仅作为建模过程的保障措施,还可以作为对事件(如网站上的机器人流量)的警告,这些事件可能表明恶意活动。因此,这些审计规则也可能作为管道结束时可视化和报告层的一部分被纳入。

在模型开发后的第二轮质量检查中,我们希望评估模型的参数是否合理,以及测试数据上的性能是否在可接受的部署范围内。前者可能涉及在技术允许的情况下绘制模型的重要参数,这些可视化结果也可以在下一步的报告中使用。同样,第二类检查可能包括查看准确度统计信息,如精确度、召回率或平方误差,或者测试集与用于模型生成数据之间的相似性,以确定报告的性能是否合理。

与第一轮合理性检查一样,这些质量控制措施不仅可以帮助监控模型开发过程的状态,还可能突出实际建模代码本身的变化(特别是如果预期该代码将定期更新)。

在合理性检查过程中,流式处理和批量处理之间本质上没有太大的区别,只是应用程序在发现源数据或建模过程中的异常并将其传递到报告层时的延迟。合理性检查的复杂性可能会指导这一决策:可以在实时进行的简单检查非常适合流处理,而评估预测模型的属性可能需要比算法本身训练更长的时间,因此更适合批量处理。

在模型开发或更新步骤中,一旦输入数据经过任何必要的处理或转换步骤并通过上述描述的质量检查,它就准备好用于开发预测模型了。这个分析流程的阶段可以包含几个步骤,具体形式取决于应用:

  • 数据拆分:在这个阶段,我们通常将数据拆分为不重叠的集合,即训练数据(我们将从中调整算法的参数)和测试数据(用于评估目的)。进行这种拆分的重要原因是为了使模型能够泛化到其初始输入(训练数据)之外的数据,我们可以通过评估其在测试集上的性能来检查这一点。

  • 参数调整:正如我们将在后续章节中更详细地探讨的那样,许多预测模型都有多个超参数——在模型参数可以针对训练集进行优化之前需要设置的变量。例如,聚类应用中的组数(第三章, 在噪声中寻找模式 – 聚类和无监督学习),随机森林中使用的树的数量 第四章, 用模型连接点 – 回归方法),或者神经网络中的学习率和层数(第七章, 自下而上学习 – 深度网络和无监督特征)。这些超参数通常需要通过网格搜索 (第五章, 将数据放在合适的位置 – 分类方法和分析)或其他方法进行校准,以实现预测模型的最佳性能。这种调整只能在模型开发的初始阶段进行,或者作为常规重新训练周期的一部分。在超参数调整之后或与其同时,参数,如回归系数或树模型中的决策分割 第四章, 用模型连接点 – 回归方法,将针对给定的训练数据集进行优化。根据方法的不同,这一步还可能涉及变量选择——从输入数据中剪枝无信息特征的过程。最后,我们可能需要对多个算法执行上述任务,并选择表现最佳的技术。

    批处理和流式处理过程在此阶段可能会因算法的不同而有所区别。例如,在允许通过随机学习进行增量更新的模型中(第五章, 将数据放在合适的位置 – 分类方法和分析),新数据可以以流的形式进行处理,因为每个新的训练示例都可以单独调整模型参数。相反,数据可能以流的形式到达,但会聚合到足够大的规模,此时才会启动批处理过程以重新训练模型。一些模型允许两种类型的训练,选择更多取决于输入数据的预期波动性。例如,社交媒体帖子中的快速趋势信号可能表明在事件可用时立即更新模型,而基于长期事件(如家庭购买模式)的模型可能不要求这种持续的更新。

  • 模型性能:使用在模型开发期间分割出的测试数据或一组全新的观测数据,建模层还负责对新数据进行评分、在模型中突出显示重要特征,并提供关于其持续性能的信息。一旦模型在一系列输入数据上被训练,它就可以应用于新数据,无论是实时计算还是通过离线批量处理来生成预测结果或行为。

    根据初始数据处理的程度,新的记录可能还需要转换以生成模型评估所需的适当特征。这种转换的程度可能决定了评分最好是通过流式或批量框架来完成。

    同样,结果的预测使用可能指导选择流式或批量导向的处理。当这些评分被用作其他响应系统的输入(如重新排序搜索结果或网页上展示的广告)时,来自流式管道的实时更新允许立即使用新的评分,因此可能很有价值。当评分主要用于内部决策(如优先处理销售线索以进行跟进)时,实时更新可能不是必需的,可以使用批量导向的框架。这种延迟差异可能与下游消费者是另一个应用程序(机器到机器交互)还是依赖模型进行洞察的人类用户有关。

  • 模型持久化:一旦我们调整了预测模型的参数,结果可能还需要打包或序列化为一种格式,以便在生产环境中部署。我们将在第八章共享预测服务中的模型中更深入地探讨这一点,但简要来说,这个过程涉及将模型输出转换为下游系统可用的形式,并将其保存回数据层,以便进行灾难恢复以及可能由下游的报表层使用,如下所述。

部署层

我们预测建模的输出可以通过部署层广泛提供给个人用户和其他软件服务,该部署层将上一层的建模、评分和评估功能封装在 Web 应用程序中,如下面的图 4 所示:

部署层

图 4:部署层组件

这个应用层通过网页接收网络调用,这些调用是通过网页浏览器或由其他软件系统生成的程序性请求传输的。正如我们将在第八章中描述的那样,与预测服务共享模型,这些应用程序通常提供一组标准命令来启动操作、获取结果、保存新信息或删除不需要的信息。它们还通常与数据层交互,以存储结果,在长时间运行的任务的情况下,存储建模计算进度的信息。

这些应用程序接收到的网络调用由服务器层代理,该层的作用是在应用程序之间路由流量(通常基于url模式)。正如我们将在第八章中介绍的那样,与预测服务共享模型,这种服务器和应用程序之间的分离使我们能够通过添加更多机器来扩展我们的应用程序,并独立添加更多服务器以平衡传入的请求。

客户端层,它启动服务器接收到的请求,可以是交互式系统,如仪表板,也可以是独立系统,如电子邮件服务器,该服务器使用模型的输出来安排发出的消息。

报告层

分析管道的输出可能由报告层呈现,这涉及许多不同的任务,如下面的图 5 所示:

报告层

图 5:预测服务的报告应用

  • 可视化:这可以允许对源数据和模型数据进行交互式查询,例如参数和特征重要性。它还可以用于可视化模型输出,例如在电子商务网站上提供给用户的推荐集,或分配给特定银行账户的风险评分。由于它通常以交互式模式使用,我们还可以考虑将大型模型输入汇总到汇总数据集中,以在探索会话期间降低延迟。此外,可视化可以是临时的过程(例如我们将在未来章节中检查的交互式笔记本),也可以是一系列固定的图形(例如我们在第九章中构建的仪表板,报告和测试 – 在分析系统中迭代)。

  • 审计/健康检查:报告服务涉及对应用的持续监控。确实,开发健壮的分析管道的一个重要因素是定期评估,以确保模型按预期运行。通过结合许多先前步骤的输出,例如质量控制检查和新数据的评分,报告框架将这些统计数据可视化,并将它们与先前值或黄金标准进行比较。这种类型的报告既可以由分析师用来监控应用,也可以作为一种方式,将建模过程中发现的见解呈现给更大的业务组织。

  • 比较报告:在通过实验过程迭代模型开发时,这可能被用来,正如我们在第九章中讨论的,报告和测试 – 在分析系统中迭代。因为这种分析可能涉及统计测量,可视化可能需要与部署层中的服务结合来计算显著性指标。

    批处理与流处理过程的选择通常会决定此类报告是否可以实时提供,但仅仅因为它们可以立即获得,并不意味着这种频率对用户有价值。例如,即使可以实时收集用户对广告活动的响应率,关于未来广告计划的决策可能受到季度商业计划的限制。相比之下,对特定搜索查询趋势的兴趣也可能使我们能够快速调整推荐算法的结果,因此这种低延迟信号是有价值的。再次强调,需要根据特定的用例进行判断。

    为了结束这个介绍,让我们考察一对假设的应用程序,这些应用程序说明了我们上面描述的许多组件。不必过于担心所有术语的确切含义,这些将在后续章节中进一步阐述。

案例研究:社交媒体流量的情感分析

考虑一个想要通过监控社交媒体网站上的品牌情绪来评估其活动有效性的市场营销部门。因为情绪的变化可能对整个公司产生负面影响,这种分析是在实时进行的。本例的概述如图 6 所示。

案例研究:社交媒体流量的情感分析

图 6:社交媒体情感分析案例研究图示

数据输入和转换

此应用程序的输入数据是社交媒体帖子。这些数据是实时可用的,但需要应用多个步骤才能使其可用于情感评分模型。需要过滤掉常见词语(如the),选择实际涉及公司的消息,以及需要对拼写错误和单词大小写进行归一化。一旦完成清理,进一步的转换可能将消息转换为向量,其中包含模型允许词汇表中每个单词的计数,或者将其散列以填充固定长度的向量。

理性检查

前面转换的输出需要经过理性检查——是否有用户发送了异常大量的消息(这可能表明是机器人垃圾邮件)?输入中是否有意外的词语(这可能是由于字符编码问题造成的)?是否有任何输入消息的长度超过了服务允许的消息大小(这可能表明输入流中的消息分割不正确)?

一旦模型开发完成,理性检查需要一些人工指导。模型预测的情感是否与人类读者的判断相关?模型中对应给定情感的高概率词语是否具有直观意义?

这些以及其他理性检查可以可视化为一个网页或文档摘要,可以被模型开发者用来评估模型健康状况,以及营销团队的其余成员用来理解可能对应积极或消极品牌情感的新主题。

模型开发

在此流程中使用的模型是多项逻辑回归(第五章

图 7:电子邮件定向案例研究图

数据输入和转换

在初始数据摄取步骤中,存储在公司数据仓库(关系数据库系统)中的客户记录被聚合以生成特征,例如每周平均花费金额、客户访问公司网站频率以及多个类别(如家具、电子产品、服装和媒体)中购买的商品数量。这些特征与电子邮件活动中可能推广的商品集的特征相结合,例如价格、品牌以及网站上类似商品的平均评分。这些特征通过每周一次的批量处理构建,在发送电子邮件之前,在周一对客户进行。

精神检查

模型的输入将检查其合理性:客户的平均购买行为或交易量是否远超出预期范围?这可能会表明数据仓库处理中的错误,或者网站上的机器人流量。由于构建模型特征涉及到的转换逻辑复杂,并且可能随着模型的发展而变化,因此其输出也将进行检查。例如,购买数量和平均价格不应低于零,且没有任何商品类别应有零条记录。

在电子邮件消息之前对潜在项目进行评分后,每个客户得分最高的项目将通过与客户的过往交易(以确定其是否合理)进行比较进行合理性检查,或者如果没有历史记录,则与在人口统计上最相似的客户的购买行为进行比较。

模型开发

在本例中,该模型是一个随机森林回归第四章使用模型连接点 – 回归方法,它将历史项目 – 客户对划分为购买(标记为 1)和非购买(标记为 0),并产生一个评分概率,即客户 A 购买项目 X。该模型的一个复杂性在于,尚未购买的项目可能只是尚未被客户看到,因此对负例施加了限制,即必须从网站上已上架一个月或更长时间的项目中抽取。此模型的超参数(每棵树的数量和大小)在每周重新训练期间进行校准,同时校准个别变量对结果预测的影响。

评分

每周使用历史数据重新训练模型后,网站上的新项目将使用此模型为每个客户进行评分,并将前三项发送到电子邮件营销活动中。

可视化和报告

任何类型的合理性检查(无论是输入数据还是模型性能)都可以是模型定期诊断报告的一部分。由于随机森林模型比其他方法更复杂,因此特别重要的是要监控特征重要性和模型准确性的变化,因为问题可能需要更多时间来调试和解决。

由于预测在生产系统中使用,而不是直接提供洞察,因此此类报告主要供开发管道的分析师使用,而不是营销部门的其他成员。

这些促销电子邮件的成功通常会在接下来的一个月内进行监控,关于准确性的更新(例如,有多少电子邮件导致了超出预期水平的购买)可以成为长期报告的基础,该报告可以帮助指导活动的结构(例如,改变信息中的项目数量)以及模型(如果预测似乎在周之间变得明显更差,可能需要更频繁地进行训练)。

小贴士

下载示例代码

你可以从www.packtpub.com的账户下载此书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  • 使用你的电子邮件地址和密码登录或注册我们的网站。

  • 将鼠标指针悬停在顶部的支持标签上。

  • 点击代码下载与勘误表

  • 搜索框中输入书籍名称。

  • 选择你想要下载代码文件的书籍。

  • 从下拉菜单中选择你购买此书籍的来源。

  • 点击代码下载

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

WinRAR / 7-Zip(适用于 Windows)

  • Zipeg / iZip / UnRarX(适用于 Mac)

  • 7-Zip / PeaZip(适用于 Linux)

摘要

完成本章后,你现在应该能够描述分析管道的核心组件以及它们之间的交互方式。我们还探讨了批处理和流处理之间的差异,以及每种类型的应用程序适合的用例。我们还通过使用这两种范例的示例以及每一步所需的设计决策进行了说明。

在以下章节中,我们将发展之前描述的概念,并对案例研究中提出的一些技术术语进行更深入的探讨。在第二章,使用 Python 进行探索性数据分析和可视化中,我们将介绍使用开源 Python 工具进行交互式数据可视化和探索。第三章,在噪声中寻找模式 - 聚类和无监督学习描述了如何使用聚类方法(也称为无监督学习)在数据集中识别相关对象的组。相比之下,第四章,用模型连接点 - 回归方法和第五章,将数据放在合适的位置 - 分类方法和分析探讨了监督学习,无论是用于连续结果,如价格(在第四章[第四章. 用模型连接点 - 回归方法]中使用的回归技术),还是用于分类响应,如用户情绪(在第五章[第五章. 将数据放在合适的位置 - 分类方法和分析]中描述的分类模型)。给定大量特征或复杂数据,如文本或图像,我们可能通过执行降维来受益,如第六章[第六章. 文字和像素 - 处理非结构化数据]中所述,文字和像素 - 处理非结构化数据。或者,我们可以使用更复杂的方法,如第七章[第七章. 从底部学习 - 深度网络和无监督特征]中涵盖的深度神经网络,从底部学习 - 深度网络和无监督特征,这些方法可以捕捉输入变量之间的复杂交互。为了将这些模型用于商业应用,我们将在第八章[第八章. 与预测服务共享模型]中开发一个 Web 框架来部署分析解决方案,与预测服务共享模型,并在第九章[第九章. 报告和测试 - 在分析系统中迭代]中描述系统的持续监控和改进,报告和测试 - 在分析系统中迭代

在整个过程中,我们将强调这些方法是如何工作的,以及在不同问题之间选择不同方法的实用技巧。通过分析代码示例,将展示构建和维护适用于您自己用例的应用程序所需的组件。有了这些预备知识,让我们接下来深入探讨一些使用笔记本进行的数据探索分析:这是一种强大的记录和分享分析的方法。

第二章:Python 中的探索性数据分析与可视化

分析管道不是一步从原始数据中构建的。相反,开发是一个迭代过程,涉及更详细地了解数据,并系统地细化模型和输入以解决问题。这个周期的一个关键部分是交互式数据分析和可视化,这可以为我们的预测建模提供初步的想法,或者为为什么应用程序没有按预期行为提供线索。

电子表格程序是此类探索的一种交互式工具:它们允许用户导入表格信息,旋转和汇总数据,并生成图表。然而,如果数据太大而无法使用此类电子表格应用程序怎么办?如果数据不是表格形式,或者无法有效地以线形或条形图的形式显示呢?在前一种情况下,我们可能只需获得一台更强大的计算机,但后一种情况则更为复杂。简而言之,许多传统的数据可视化工具并不适合复杂的数据类型,如文本或图像。此外,电子表格程序通常假设数据是最终形式,而实际上我们在分析之前通常需要清理原始数据。我们可能还希望计算比简单平均值或总和更复杂的统计数据。最后,使用相同的编程工具来清理和可视化我们的数据,以及生成模型本身并测试其性能,可以使得开发过程更加流畅。

在本章中,我们介绍了交互式 Python(IPython)笔记本应用程序(Pérez, Fernando,和 Brian E. Granger. IPython:一个交互式科学计算系统。《科学计算中的计算》9.3(2007):21-29)。这些笔记本形成了一个数据准备、探索和建模环境,它运行在网页浏览器中。在 IPython 笔记本的输入单元中键入的命令在接收时被翻译并执行:这种交互式编程对于数据探索很有帮助,因为我们可能需要改进我们的努力,并逐步开发更详细的分析。在这些笔记本中记录我们的工作将有助于在调试期间回溯,并作为可以轻松与同事分享的见解的记录。

在本章中,我们将讨论以下主题:

  • 将原始数据读取到 IPython 笔记本中,使用 Pandas 库对其进行清理和处理。

  • 使用 IPython 处理数值、分类、地理空间或时间序列数据,并执行基本统计分析。

  • 基本探索性分析:汇总统计(均值、方差、中位数)、分布(直方图和核密度)、以及自相关(时间序列)。

  • Spark RDDs 和 DataFrames 的分布式数据处理简介。

在 IPython 中探索分类和数值数据

我们将通过将文本文件加载到 DataFrame 中,计算一些汇总统计量,并可视化分布来开始我们的 IPython 探索。为此,我们将使用来自互联网电影数据库(www.imdb.com/)的一组电影评分和元数据来调查哪些因素可能与该网站上电影的评分相关。此类信息可能有助于,例如,开发基于此类用户反馈的推荐系统。

安装 IPython 笔记本

要跟随示例,您应该在计算机上安装 Windows、Linux 或 Mac OSX 操作系统,并能够访问互联网。有几种安装 IPython 的选项:由于每个资源都包括安装指南,我们提供了可用的来源摘要,并将读者指引到相关文档以获取更深入的说明。

  • 对于大多数用户,Anaconda(Continuum Analytics)或 Canopy(Enthought)之类的预捆绑 Python 环境提供了一个包含 IPython 和我们将在这项练习中使用的所有库的即用型发行版:这些产品是自包含的,因此您不需要担心版本冲突或依赖关系管理。

  • 对于更有雄心的用户,您可以选择安装 Python 发行版,然后使用pipeasy_install之类的包管理器安装所需的库。

笔记本界面

让我们按照以下步骤开始:

  1. 一旦您安装了 IPython,打开计算机上的命令提示符(终端)并输入:

    jupyter notebook
    
    

    注意,根据您安装程序的位置,jupyter命令可能需要将启动jupyter的二进制文件放在您的系统路径中。您应该在终端中看到一系列如下命令:

    笔记本界面

    这将启动内核,即计算笔记本中输入的命令结果的 Python 解释器。如果您想停止笔记本,请按Ctrl + C,然后输入yes,内核将关闭。

  2. 当内核启动时,您的默认网络浏览器也应该打开,显示一个主页,如下所示:笔记本界面

  3. 文件标签(见上图)将显示您启动 IPython 进程的目录中的所有文件。点击运行将显示所有正在运行的笔记本列表——开始时没有:笔记本界面

  4. 最后,集群面板会列出外部集群,如果我们决定通过将命令提交到多个机器上处理来并行化我们的计算,那么我们会考虑这一点。现在我们不必担心这个问题,但当我们开始训练预测模型时,它将变得非常有用,这项任务通常可以通过在多台计算机或处理器之间分配工作来加速。

  5. 返回到 文件 选项卡,你会在右上角注意到两个选项。一个是 上传 文件:当我们在本地上运行 IPython 时,它同样可以运行在远程服务器上,分析师可以通过浏览器访问笔记本。在这种情况下,为了与存储在我们自己机器上的文件交互,我们可以使用这个按钮打开提示并选择要上传到服务器的文件,然后我们可以在笔记本中分析它们。新建 选项卡允许你创建新的文件夹、文本文件、在浏览器中运行的 Python 终端或笔记本。

    现在,让我们通过双击 B04881_chapter02_code01.ipynb 来打开本章的示例笔记本。这会打开笔记本:

    笔记本界面 笔记本由一系列单元格组成,这些是我们可以输入 Python 代码、执行它并查看命令结果的地方。每个单元格中的 Python 代码可以通过点击工具栏上的 笔记本界面 按钮来执行,并且可以通过点击 笔记本界面 按钮在当前单元格下方插入一个新的单元格。

  6. 虽然第一个单元格中的导入语句可能看起来与你在命令行或脚本中使用 Python 的经验相似,但 %matplotlib 内联命令实际上不是 Python:它是对笔记本的标记指令,指示 matplotlib 图像要在浏览器中内联显示。我们在笔记本的开始处输入这个命令,以便我们所有的后续绘图都使用这个设置。要运行导入语句,点击 笔记本界面 按钮,或者按 Ctrl + Enter。当命令执行时,单元格上的 ln[1] 可能会短暂地变为 [*]。在这种情况下,将没有输出,因为我们只是导入了库依赖项。现在我们的环境已经准备好了,我们可以开始检查一些数据。

加载数据和检查数据

首先,我们将使用 Pandas 库将 movies.csv 中的数据导入到 DataFrame 对象中(McKinney, Wes. Python for data analysis: Data wrangling with Pandas, NumPy, and IPython. O'Reilly Media, Inc., 2012)。这个 DataFrame 类似于传统的电子表格软件,并允许强大的扩展,如自定义转换和聚合。这些可以与 NumPy 中可用的数值方法结合,以进行更高级的数据统计分析。让我们继续我们的分析:

  1. 如果这是一个新的笔记本,要添加新的单元格,我们会去工具栏,点击 插入 并选择 在下方插入单元格,或者使用 加载数据和检查数据 按钮。然而,在这个例子中,所有单元格已经生成,因此我们在第二个单元格中运行以下命令:

    >>> imdb_ratings = pd.read_csv('movies.csv')
    
    

    我们现在已经使用 Pandas 库创建了 imdb_ratings DataFrame 对象,并可以开始分析数据。

  2. 让我们从使用 head()tail() 查看数据的开始和结束部分开始。请注意,默认情况下,此命令返回前五行数据,但我们可以向命令提供一个整数参数来指定要返回的行数。此外,默认情况下,文件的第一行假定包含列名,在这种情况下是正确的。键入:

    >>> imdb_ratings.head()
    
    

    输出如下:

    加载数据并检查

    我们可以通过键入以下内容来查看数据的最后 15 行:

    >>> imdb_ratings.tail(15)
    
    

    加载数据并检查

  3. 查看单个行可以让我们了解文件包含的数据类型:我们还可以使用 describe() 命令查看每个列中所有行的摘要,该命令返回记录数、平均值和其他聚合统计信息。尝试键入:

    >>> imdb_ratings.describe()
    
    

    这给出了以下输出:

    加载数据并检查

  4. 列名和它们的数据类型可以通过 columnsdtypes 属性访问。键入:

    >>> imdb_ratings.columns
    
    

    给出列名:

    加载数据并检查

    如果我们发出以下命令:

    >>> imdb_ratings.dtypes
    
    
  5. 如我们所见,当我们首次加载文件时,列的数据类型已被自动推断:加载数据并检查

  6. 如果我们想访问单个列的数据,我们可以使用 {DataFrame_name}.{column_name}{DataFrame_name}['column_name'](类似于 Python 字典)。例如,键入:

    >>> imdb_ratings.year.head()
    
    

    >>> imdb_ratings['year'].head()
    
    

    输出如下:

    加载数据并检查

不费吹灰之力,我们就可以使用这些简单的命令来对数据进行一系列诊断性提问。我们使用 describe() 生成的汇总统计信息是否合理(例如,最高评分应该是 10,而最低是 1)?数据是否正确解析到我们预期的列中?

回顾我们使用 head() 命令可视化的前五行数据,这次初步检查也揭示了一些我们可能需要考虑的格式问题。在 budget 列中,有几个条目具有 NaN 值,表示缺失值。如果我们打算尝试根据包括 budget 在内的特征预测电影评分,我们可能需要制定一个规则来填充这些缺失值,或者以正确表示给算法的方式对它们进行编码。

基本操作 - 分组、过滤、映射和转置

现在我们已经了解了 Pandas DataFrame 的基本特征,让我们开始应用一些转换和计算,这些转换和计算超出了我们通过 describe() 获得的简单统计信息。例如,如果我们想计算属于每个发布年份的电影数量,我们可以使用以下命令:

>>> imdb_ratings.value_counts()

输出如下:

基本操作 - 分组、过滤、映射和转置

注意,默认情况下,结果是按每年记录数排序的(在这个数据集中,2002 年上映的电影最多)。如果我们想按发行年份排序怎么办?sort_index() 命令按其索引(属于的年份)对结果进行排序。索引类似于图表的轴,其值表示每个轴刻度处的点。使用以下命令:

>>> imdb_ratings.year.value_counts().sort_index(ascending=False)

给出以下输出:

基本操作 – 分组、过滤、映射和转置

我们还可以使用 DataFrame 来开始对数据进行分析性提问,就像在数据库查询中那样进行逻辑切片和子选择。例如,让我们使用以下命令选择 1999 年之后上映并具有 R 评分的电影子集:

>>> imdb_ratings[(imdb_ratings.year > 1999) & (imdb_ratings.mpaa == 'R')].head()

这给出了以下输出:

基本操作 – 分组、过滤、映射和转置

同样,我们可以根据任何列(s)对数据进行分组,并使用 groupby 命令计算聚合统计信息,将执行计算的数组作为参数传递给 aggregate。让我们使用 NumPy 中的平均值和标准差函数来找到特定年份上映电影的平均评分和变化:

>>> imdb_ratings.groupby('year').rating.aggregate([np.mean,np.std])

这给出了:

基本操作 – 分组、过滤、映射和转置

然而,有时我们想要提出的问题需要我们重塑或转换我们给出的原始数据。这在后面的章节中会经常发生,当我们为预测模型开发特征时。Pandas 提供了许多执行这种转换的工具。例如,虽然根据类型对数据进行聚合也可能很有趣,但我们注意到在这个数据集中,每个类型都由一个单独的列表示,其中 1 或 0 表示一部电影是否属于某个特定类型。对我们来说,有一个单独的列来指示电影属于哪个类型,以便在聚合操作中使用会更有用。我们可以使用带有参数 1 的 idxmax() 命令来创建这样的列,以表示跨列的最大值(0 将表示沿行的最大索引),它返回所选列中值最大的列。键入:

>>>imdb_ratings['genre']=imdb_ratings[['Action','Animation','Comedy','Drama','Documentary','Romance']].idxmax(1)

当我们使用以下内容检查这个新的类型列时,给出以下结果:

>>> imdb_ratings['genre'].head()

基本操作 – 分组、过滤、映射和转置

我们也许还希望用代表特定类型的颜色来绘制数据。为了为每个类型生成一个颜色代码,我们可以使用以下命令的自定义映射函数:

>>> genres_map = {"Action": 'red', "Animation": 'blue', "Comedy": 'yellow', "Drama": 'green', "Documentary": 'orange', "Romance": 'purple'}
>>> imdb_ratings['genre_color'] = imdb_ratings['genre'].apply(lambda x: genres_map[x])

我们可以通过键入以下内容来验证输出:

>>> imdb_ratings['genre_color'].head()

这给出了以下结果:

基本操作 – 分组、过滤、映射和转置

我们还可以转置表格并使用 pivot_table 命令进行统计分析,该命令可以在类似于电子表格的行和列分组上执行聚合计算。例如,为了计算每个年份每个类型的平均评分,我们可以使用以下命令:

>>>pd.pivot_table(imdb_ratings,values='rating',index='year',columns=['genre'],aggfunc=np.mean)

这给出了以下输出:

基本操作 – 分组、过滤、映射和转置

现在我们已经进行了一些探索性计算,让我们来看看这些信息的可视化。

使用 Matplotlib 绘图

IPython 笔记本的一个实用功能是能够将数据与我们的分析内联绘图。例如,如果我们想可视化电影长度的分布,我们可以使用以下命令:

>>> imdb_ratings.length.plot()

使用 Matplotlib 绘图

然而,这并不是一个非常吸引人的图像。为了制作一个更美观的图表,我们可以使用 style.use() 命令更改默认样式。让我们将样式更改为 ggplot,这是在 ggplot 图形库(Wickham, Hadley. ggplot: An Implementation of the Grammar of Graphics. R 包版本 0.4.0 (2006))中使用的。键入以下命令:

>>> matplotlib.style.use('ggplot')
>>> imdb_ratings.length.plot()

给出了一张更吸引人的图形:

使用 Matplotlib 绘图

如您所见,默认图表是折线图。折线图按 DataFrame 中的行号从左到右将每个数据点(电影时长)作为一条线绘制。为了按电影类型绘制密度图,我们可以使用 groupby 命令并带有 type=kde 参数。KDEKernel Density Estimate(Rosenblatt, Murray. Remarks on some nonparametric estimates of a density function. The Annals of Mathematical Statistics 27.3 (1956): 832-837; Parzen, Emanuel. On estimation of a probability density function and mode. The annals of mathematical statistics 33.3 (1962): 1065-1076)的缩写,意味着对于每个点(电影时长),我们使用以下方程估计密度(具有该运行时的人口比例):

使用 Matplotlib 绘图

其中 f(x) 是概率密度的估计,n 是我们数据集中的记录数,h 是带宽参数,K 是核函数。例如,如果 K 是以下高斯核函数:

使用 Matplotlib 绘图

其中 σ 是正态分布的标准差,μ 是正态分布的均值,那么核密度估计(KDE)表示围绕给定点 x 的正态分布“窗口”中所有其他数据点的平均密度。这个窗口的宽度由 h 给出。因此,KDE 通过在给定点绘制连续概率估计而不是绝对计数来允许我们绘制直方图的平滑表示。为此 KDE 图,我们还可以添加轴的注释,并使用以下命令将最大运行时间限制为 2 小时:

>>> plot1 = imdb_ratings.groupby('genre').length.plot(kind='kde',xlim=(0,120),legend='genre')
>>>plot1[0].set_xlabel('Number of Minutes')
>>>plot1[0].set_title('Distribution of Films by Runtime Minutes')

这给出了以下图表:

使用 Matplotlib 绘图

我们可以看到,不出所料,许多动画电影较短,而其他类别平均长度约为 90 分钟。我们还可以使用以下命令绘制类似的密度曲线来检查不同类型之间的评分分布:

>>> plot2 = imdb_ratings.groupby('genre').rating.plot(kind='kde',xlim=(0,10),legend='genre')
>>> plot2[0].set_xlabel('Ratings')
>>> plot2[0].set_title('Distribution of Ratings')

这给出了以下图表:

使用 Matplotlib 绘图

有趣的是,纪录片平均评分最高,而动作电影评分最低。我们也可以使用以下命令使用箱线图来可视化相同的信息:

>>> pd.pivot_table(imdb_ratings,values='rating',index='title',columns=['genre']).\
plot(kind='box',by='genre').\
set_ylabel('Rating')

这给出了以下箱线图:

使用 Matplotlib 绘图

我们也可以使用笔记本开始为数据集自动进行这种类型的绘图。例如,我们经常想查看每个变量的边缘图(其单维分布)与所有其他变量的比较,以便在数据集的列之间找到相关性。我们可以使用内置的scatter_matrix函数来完成此操作:

>>> from pandas.tools.plotting import scatter_matrix
>>> scatter_matrix(imdb_ratings[['year','length','budget','rating','votes']], alpha=0.2, figsize=(6, 6), diagonal='kde')

这将允许我们绘制我们选择的变量的成对分布,从而为我们提供它们之间潜在相关性的概述:

使用 Matplotlib 绘图

这张单张图实际上提供了很多信息。例如,它显示一般来说,预算较高的电影评分较高,而 20 世纪 20 年代制作的电影的平均评分高于之前的电影。使用这种散点矩阵,我们可以寻找可能指导预测模型发展的相关性,例如根据其他电影特征预测评分。我们只需要给这个函数提供 DataFrame 中用于绘图的列子集(因为我们想排除无法以这种方式可视化的非数值数据),我们就可以为任何新的数据集重复这种分析。

如果我们想更详细地可视化这些分布呢?作为一个例子,让我们使用以下命令来根据类型将长度和评分之间的相关性分解:

>>> fig, axes = plt.subplots(nrows=3, ncols=2, figsize=(15,15))
>>> row = 0
>>> col = 0
>>> for index, genre in imdb_ratings.groupby('genre'):
…    if row > 2:
…        row = 0
…       col += 1
…    genre.groupby('genre').\
....plot(ax=axes[row,col],kind='scatter',x='length',y='rating',s=np.sqrt(genre['votes']),c=genre['genre_color'],xlim=(0,120),ylim=(0,10),alpha=0.5,label=index)
…    row += 1

在这个命令中,我们创建一个 3x2 的网格来容纳六个类型的绘图。然后我们按类型迭代数据组,如果我们已经到达第三行,我们就重置并移动到第二列。然后我们绘制数据,使用我们之前生成的genre_color列以及索引(类型组)来标记绘图。我们通过每个点(代表一部电影)收到的投票数来调整每个点的大小。生成的散点图显示了长度和类型之间的关系,点的大小给出了我们对点值的信心程度。

使用 Matplotlib 绘图

现在我们已经使用分类数据和数值数据进行了基本分析,让我们继续一个特殊的数值数据案例——时间序列。

时间序列分析

虽然 imdb 数据包含了电影发行年份,但本质上我们感兴趣的是单个电影和评分,而不是随时间推移可能相互关联的一系列事件。这种后一种类型的数据——时间序列——提出了不同的问题。数据点是否相互关联?如果是,它们在什么时间段内相关?信号有多嘈杂?Pandas DataFrames 有许多内置的时间序列分析工具,我们将在下一节中探讨。

清洗和转换

在我们之前的例子中,我们能够以提供的数据形式使用这些数据。然而,并不总是有保证这种情况会发生。在我们的第二个例子中,我们将查看过去一个世纪美国按年份的石油价格时间序列(Makridakis, Spyros, Steven C. Wheelwright, 和 Rob J. Hyndman. 《预测方法和应用》,John Wiley & Sons. Inc, 纽约(1998)。我们将再次通过将此数据加载到笔记本中,并使用 tail() 通过输入来检查它:

>>> oil_prices = pd.read_csv('oil.csv')
>>> oil_prices.tail()

这给出了以下输出:

清洗和转换

最后一行是意外的,因为它看起来根本不像一个年份。实际上,它是电子表格中的一个页脚注释。由于它实际上不是数据的一部分,我们需要将其从数据集中删除,这可以通过以下命令完成:

>>> oil_prices = oil_prices[~np.isnan(oil_prices[oil_prices.columns[1]])] 

这将从数据集中删除第二列是 NaN(不是正确格式的数字)的行。我们可以通过再次使用 tail 命令来验证我们已经清理了数据集。

我们希望清理数据的第二个方面是格式。如果我们查看列的格式,使用:

>>> oil_prices.dtypes

我们看到年份默认不是解释为 Python 日期类型:

清洗和转换

我们希望 年份 列是 Python 日期类型。Pandas 提供了使用 convert_object() 命令执行此转换的内置功能:

>>> oil_prices = oil_prices.convert_objects(convert_dates='coerce')

同时,我们可以使用 rename 命令将价格列重命名为更简洁的名称:

>>> oil_prices.rename(columns = {oil_prices.columns[1]: 'Oil_Price_1997_Dollars'},inplace=True)

然后,我们可以通过使用 head() 命令来验证输出显示了这些变化:

清洗和转换

现在我们有了可以开始对这个时间序列进行一些诊断的数据格式。

时间序列诊断

我们可以使用上一节中介绍的 matplotlib 命令来绘制这些数据,如下所示:

>>> oil_prices.plot(x='Year',y='Oil_Price_1997_Dollars')

这将生成以下时间序列图:

时间序列诊断

对于这些数据,我们可能会有许多自然的问题。每年石油价格的波动是完全随机的,还是每年之间的测量相互关联?数据中似乎存在一些周期,但很难量化这种相关性的程度。我们可以使用一个视觉工具来帮助诊断这个特征,这个工具是lag_plot,在 Pandas 中使用以下命令可用:

>>> from pandas.tools.plotting import lag_plot
>>> lag_plot(oil_prices.Oil_Price_1997_Dollars)

时间序列诊断

滞后图简单地绘制了每年石油价格(x 轴)与随后一年石油价格(y 轴)的关系。如果没有相关性,我们预计会看到一个圆形云。这里的线性模式表明数据中存在某种结构,这与每年价格上升或下降的事实相符。这种相关性与预期相比有多强?我们可以使用自相关图来回答这个问题,使用以下命令:

>>> from pandas.tools.plotting import autocorrelation_plot
>>> autocorrelation_plot(oil_prices['Oil_Price_1997_Dollars'])

这给出了以下自相关图:

时间序列诊断

在这个图中,不同滞后(年份差异)的点之间的相关性被绘制出来,同时还有 95%置信区间(实线)和 99%置信区间(虚线)线,表示随机数据预期相关性的范围。根据这个可视化,似乎在滞后小于 10 年的情况下存在异常的相关性,这与上述第一幅图中峰值价格期间的近似持续时间相吻合。

将信号和相关性连接起来

最后,让我们看看一个比较石油价格时间序列与其他数据集的例子,即美国给定年份的汽车事故死亡人数(美国年度机动车死亡名单。维基百科。维基媒体基金会。网络。2016 年 5 月 2 日。en.wikipedia.org/wiki/List_of_motor_vehicle_deaths_in_U.S._by_year)。

例如,我们可能会假设,随着石油价格的上涨,平均消费者将驾驶得少,从而导致未来的车祸。同样,我们需要在将数据集时间转换为日期格式之前,先将数字转换为字符串,使用以下命令:

>>> car_crashes=pd.read_csv("car_crashes.csv")
>>> car_crashes.Year=car_crashes.Year.astype(str)
>>> car_crashes=car_crashes.convert_objects(convert_dates='coerce') 

使用head()命令检查前几行确认我们已经成功格式化了数据:

连接信号和相关性

我们可以将这些数据与石油价格统计数据合并,并比较两个趋势随时间的变化。请注意,我们需要通过除以 1000 来重新缩放崩溃数据,以便在以下命令中可以轻松地将其与同一轴上的数据进行查看:

>>> car_crashes['Car_Crash_Fatalities_US']=car_crashes['Car_Crash_Fatalities_US']/1000

然后,我们使用merge()将数据连接起来,通过on变量指定用于匹配每个数据集中行的列,并使用以下命令绘制结果:

>>> oil_prices_car_crashes = pd.merge(oil_prices,car_crashes,on='Year')
>>> oil_prices_car_crashes.plot(x='Year')

结果图如下所示:

连接信号和相关性

这两个信号的相关性如何?我们再次可以使用auto_correlation图来探索这个问题:

>>> autocorrelation_plot(oil_prices_car_crashes[['Car_Crash_Fatalities_US','Oil_Price_1997_Dollars']])

这给出了:

信号连接和相关性

因此,似乎这种相关性超出了 20 年或更短预期波动范围,比仅从油价中出现的关联范围更长。

小贴士

处理大型数据集

本节中给出的示例规模较小。在实际应用中,我们可能需要处理无法装在计算机上的数据集,或者需要进行分析,这些分析计算量如此之大,以至于必须在多台机器上分割运行才能在合理的时间内完成。对于这些用例,可能无法使用我们用 Pandas DataFrames 展示的形式使用 IPython Notebook。对于处理此类规模的数据,有多个替代应用程序可用,包括 PySpark (spark.apache.org/docs/latest/api/python/)、H20 (www.h2o.ai/) 和 XGBoost (github.com/dmlc/xgboost)。我们也可以通过笔记本使用这些工具中的许多,从而实现对极大数据量的交互式操作和建模。

处理地理空间数据

对于我们的最后一个案例研究,让我们探索使用 Pandas 库的扩展 GeoPandas 来分析地理空间数据。您需要在您的 IPython 环境中安装 GeoPandas 才能跟随此示例。如果尚未安装,您可以使用 easy_install 或 pip 安装它。

加载地理空间数据

除了我们的其他依赖项之外,我们将使用以下命令导入 GeoPandas 库:

>>> import GeoPandas as geo.

我们为此示例加载数据集,非洲国家坐标("Africa." Maplibrary.org。网络。2016 年 5 月 2 日。www.mapmakerdata.co.uk.s3-website-eu-west-1.amazonaws.com/library/stacks/Africa/),这些坐标以前包含在一个形状(.shp)文件中,现在将其导入到 GeoDataFrame 中,这是 Pandas DataFrame 的扩展,使用以下方式:

>>> africa_map = geo.GeoDataFrame.from_file('Africa_SHP/Africa.shp')

使用 head() 查看前几行:

加载地理空间数据

我们可以看到,数据由标识符列组成,以及一个表示国家形状的几何对象。GeoDataFrame 还有一个 plot() 函数,我们可以传递一个 column 参数,指定用于生成每个多边形颜色的字段,如下所示:

>>> africa_map.plot(column='CODE')

这给出了以下可视化:

加载地理空间数据

然而,目前这个颜色代码是基于国家名称的,所以对地图的洞察力不大。相反,让我们尝试根据每个国家的人口来着色每个国家,使用每个国家的人口密度信息(Population by Country – Thematic Map – WorldPopulation by Country – Thematic Map-World。网络。2016 年 5 月 2 日,www.indexmundi.com/map/?v=21)。首先,我们使用以下方式读取人口:

>>> africa_populations = pd.read_csv('Africa_populations.tsv',sep='\t')

注意,在这里我们已将sep='\t'参数应用于read_csv(),因为该文件中的列不是像迄今为止的其他示例那样以逗号分隔。现在我们可以使用合并操作将此数据与地理坐标连接起来:

>>> africa_map = pd.merge(africa_map,africa_populations,left_on='COUNTRY',right_on='Country_Name')

与上面提到的石油价格和事故死亡率的例子不同,这里我们希望用来连接数据的列在每个数据集中都有不同的名称,因此我们必须使用left_onright_on参数来指定每个表中所需的列。然后我们可以使用以下方法使用来自人口数据的颜色绘制地图:

>>> africa_map.plot(column='Population',colormap='hot')

这给出了新的地图如下:

加载地理空间数据

现在,我们可以清楚地看到人口最多的国家(埃塞俄比亚、刚果民主共和国和埃及)以白色突出显示。

在云端工作

在前面的例子中,我们假设您正在通过您的网络浏览器在本地计算机上运行 IPython 笔记本。如前所述,应用程序也可以在远程服务器上运行,用户可以通过界面上传文件以远程交互。这种外部服务的一种方便形式是云平台,如Amazon Web ServicesAWS)、Google Compute Cloud 和 Microsoft Azure。除了提供托管平台以运行笔记本等应用程序外,这些服务还提供存储,可以存储比我们个人电脑能存储的更大的数据集。通过在云端运行我们的笔记本,我们可以更轻松地使用共享的数据访问和处理基础设施与这些分布式存储系统进行交互,同时也强制执行所需的安全性和数据治理。最后,通过这些云服务提供的廉价计算资源也可能使我们能够扩展我们在后面章节中描述的计算类型,通过添加额外的服务器来处理笔记本后端输入的命令。

PySpark 简介

到目前为止,我们主要关注可以适应单个机器的数据库集。对于更大的数据集,我们可能需要通过分布式文件系统如 Amazon S3 或 HDFS 来访问它们。为此,我们可以利用开源分布式计算框架 PySpark (spark.apache.org/docs/latest/api/python/)。PySpark 是一个分布式计算框架,它使用弹性分布式数据集RDDs)的抽象来处理对象的并行集合,这使得我们可以像它适合单个机器一样程序化地访问数据集。在后面的章节中,我们将演示如何在 PySpark 中构建预测模型,但在此介绍中,我们关注 PySpark 中的数据处理函数。

创建 SparkContext

任何 Spark 应用程序的第一步是生成 SparkContext。SparkContext 包含任何特定作业的配置(例如内存设置或工作任务的数目),并允许我们通过指定主节点来连接到 Spark 集群。我们使用以下命令启动 SparkContext:

>>> sc = SparkContext('local','job_.{0}'.format(uuid.uuid4()))

第一个参数给出了我们的 Spark 主节点的 URL,即协调 Spark 作业执行并将任务分配给集群中工作机器的机器。所有 Spark 作业都包含两种任务:驱动器(负责发布命令并收集作业进度的信息)和执行器(在 RDD 上执行操作)。这些任务可以创建在同一台机器上(如我们的示例所示),也可以在不同的机器上,这样就可以使用多台计算机的并行计算来分析无法在单台机器内存中容纳的数据集。在这种情况下,我们将本地运行,因此将主节点的参数指定为 localhost,但否则这可以是集群中远程机器的 URL。第二个参数只是我们给应用程序起的名字,我们使用 uuid 库生成的唯一 ID 来指定它。如果此命令成功,你应该在你的终端中看到运行笔记本的位置出现以下堆栈跟踪:

创建 SparkContext

我们可以使用地址 http://localhost:4040 打开 SparkUI,它看起来如下所示:

创建 SparkContext

你可以在右上角看到我们的作业名称,一旦开始运行它们,我们可以使用这个页面来跟踪作业的进度。现在 SparkContext 已经准备好接收命令,并且我们可以在 ui 中看到我们在笔记本中执行的任何操作的进度。如果你想停止 SparkContext,我们可以简单地使用以下命令:

>>> sc.stop()

注意,如果我们本地运行,我们一次只能在一个 localhost 上启动一个 SparkContext,所以如果我们想更改上下文,我们需要停止并重新启动它。一旦我们创建了基本的 SparkContext,我们就可以实例化其他上下文对象,这些对象包含特定类型数据集的参数和功能。对于这个例子,我们将使用 SqlContext,它允许我们对 DataFrame 进行操作并使用 SQL 逻辑查询数据集。我们使用 SparkContext 作为参数来生成 SqlContext:

>>> sqlContext = SQLContext(sc)

创建 RDD

要生成我们的第一个 RDD,让我们再次加载电影数据集,并使用除了索引和行号之外的所有列将其转换为元组列表:

>>> data = pd.read_csv("movies.csv")
>>> rdd_data = sc.parallelize([ list(r)[2:-1] for r in data.itertuples()])

itertuples() 命令将 pandas DataFrame 的每一行返回为一个元组,然后我们通过将其转换为列表并取索引 2 及以上(代表所有列,但不是行的索引,这是 Pandas 自动插入的,以及行号,这是文件中的原始列之一)来切片。为了将此本地集合转换为 RDD,我们调用 sc.parallelize,它将集合转换为 RDD。我们可以使用 getNumPartitions() 函数检查这个分布式集合中有多少个分区:

>>> rdd_data.getNumPartitions()

由于我们刚刚在本地创建了此数据集,它只有一个分区。我们可以使用 repartition()(增加分区数量)和 coalesce()(减少)函数来更改 RDD 中的分区数量,这可以改变对数据每个子集所做的负载。你可以验证以下命令更改了我们示例中的分区数量:

>>> rdd_data.repartition(10).getNumPartitions() 
>>> rdd_data.coalesce(2).getNumPartitions()

如果我们想检查 RDD 中的小部分数据,可以使用 take() 函数。以下命令将返回五行:

rdd_data.take(5)

你可能会注意到,在输入需要将结果打印到笔记本的命令之前,Spark UI 上没有任何活动,例如 getNumPartitions()take()。这是因为 Spark 遵循惰性执行模型,只有在需要用于下游操作时才返回结果,否则等待这样的操作。除了提到的那些之外,其他将强制执行的操作包括写入磁盘和 collect()(下面将描述)。

为了使用 PySpark DataFrames API(类似于 Pandas DataFrames)来加载数据,而不是使用 RDD(它没有我们上面展示的 DataFrame 操作的许多实用函数),我们需要一个 JavaScript 对象表示法JSON)格式的文件。我们可以使用以下命令生成此文件,该命令将每行的元素映射到一个字典,并将其转换为 JSON:

>>> rdd_data.map( lambda x: json.JSONEncoder().encode({ str(k):str(v) for (k,v) in zip(data.columns[2:-1],x)})).\
>>> saveAsTextFile('movies.json')

如果你检查输出目录,你会注意到我们实际上保存了一个名为 movies.json 的目录,其中包含单个文件(与我们的 RDD 中的分区数量一样多)。这是数据在 Hadoop 分布式文件系统HDFS)中存储在目录中的相同方式。

注意,我们刚刚只是触及了 RDD 可以执行的所有操作的一小部分。我们可以执行其他操作,如过滤、按键对 RDD 进行分组、投影每行的子集、在组内排序数据、与其他 RDD 进行连接,以及许多其他操作。所有可用的转换和操作的完整范围在 spark.apache.org/docs/latest/api/python/pyspark.html 中有文档说明。

创建 Spark DataFrame

现在我们有了 JSON 格式的文件,我们可以使用以下方式将其加载为 Spark DataFrame:

>>> df = sqlContext.read.json("movies.json")

如果我们打算对这份数据执行许多操作,我们可以将其缓存(在临时存储中持久化),这样我们就可以在 Spark 自己的内部存储格式上操作数据,该格式针对重复访问进行了优化。我们可以使用以下命令缓存数据集。

>>> df.cache()

SqlContext 还允许我们为数据集声明一个表别名:

>>> df.registerTempTable('movies')

然后,我们可以像查询关系数据库系统中的表一样查询这些数据:

>>> sqlContext.sql(' select * from movies limit 5 ').show()

与 Pandas DataFrame 类似,我们可以通过特定列对它们进行聚合:

>>> df.groupby('year').count().collect()

我们也可以使用与 Pandas 相似的语法来访问单个列:

>>> df.year

如果我们希望将所有数据带到一台机器上,而不是在可能分布在几台计算机上的数据集分区上操作,我们可以调用 collect() 命令。使用此命令时请谨慎:对于大型数据集,它将导致所有数据分区被合并并发送到驱动器,这可能会潜在地超载驱动器的内存。collect() 命令将返回一个行对象数组,我们可以使用 get() 来访问单个元素(列):

>>> df.collect()[0].get(0)

我们感兴趣在数据上执行的所有操作可能都不在 DataFrame API 中可用,因此如果需要,我们可以使用以下命令将 DataFrame 转换为行 RDD:

>>> rdd_data = df.rdd

我们甚至可以使用以下方法将 PySpark DataFrame 转换为 Pandas DataFrame:

>>> df.toPandas()

在后面的章节中,我们将介绍如何在 Spark 中设置应用程序和构建模型,但你现在应该能够执行许多与 Pandas 中相同的基本数据操作。

摘要

我们现在已经检查了许多开始构建分析应用程序所需的任务。使用 IPython 笔记本,我们介绍了如何将文件中的数据加载到 Pandas 的 DataFrame 中,重命名数据集中的列,过滤掉不需要的行,转换列数据类型,以及创建新列。此外,我们还从不同的来源合并了数据,并使用聚合和交叉操作执行了一些基本的统计分析。我们还使用直方图、散点图和密度图以及自相关和日志图来可视化数据,以及使用坐标文件在地图上叠加地理空间数据。此外,我们还使用 PySpark 处理了电影数据集,创建了 RDD 和 PySpark DataFrame,并对这些数据类型执行了一些基本操作。

我们将在未来的部分中构建这些工具,通过操作原始输入来开发用于构建预测分析管道的特征。我们还将利用类似工具来可视化和理解我们开发的预测模型的特征和性能,以及报告它们可能提供的见解。

第三章. 在噪声中寻找模式 – 聚类和无监督学习

关于数据集的一个自然问题是它是否包含组。例如,如果我们检查由随时间变化的股票价格波动组成的金融市场数据,是否存在下跌和上升模式相似的股票组?同样,对于来自电子商务业务的客户交易集,我们可能会问是否存在由相似购买活动模式区分的用户账户组?通过使用本章中描述的方法识别相关项目的组,我们可以将数据视为一组通用模式,而不仅仅是单个点。这些模式可以帮助在预测建模项目的初期进行高级总结,或者作为持续报告我们正在建模的数据形状的一种方式。产生的分组本身可以成为洞察,或者可以作为我们在后续章节中讨论的模型的起点。例如,数据点所属的组可以成为此观察的特征,为其个别值添加额外的信息。此外,我们可以在这些组内潜在地计算统计量(如均值和标准差),这些统计量可能比单个条目作为模型特征更稳健。

与我们在后续章节中将要讨论的方法相比,分组或聚类算法被称为无监督学习,这意味着我们没有用于确定算法最佳参数的响应值,例如销售价格或点击率。相反,我们仅使用特征来识别相似数据点,作为次级分析可能会问我们识别的聚类是否在其响应中共享一个共同的模式(从而表明该聚类在寻找与我们感兴趣的输出相关的组时是有用的)。

找到这些组或聚类的任务在算法之间有一些共同的成分,这些成分有所不同。一个是数据集中项目之间的距离或相似性的概念,这将使我们能够定量比较它们。第二个是我们希望识别的组数;这可以通过领域知识初始指定,或者通过运行具有不同聚类数量的算法来确定。然后,我们可以通过统计方法,如检查算法确定的组内的数值方差,或通过视觉检查,来识别描述数据集的最佳聚类数量。在本章中,我们将深入探讨:

  • 如何对数据进行归一化以用于聚类算法并计算分类数据和数值数据的相似度测量

  • 如何通过检查平方误差函数来使用 k 均值聚类识别最佳聚类数量

  • 如何使用层次聚类来识别不同尺度的聚类

  • 使用亲和传播自动识别数据集中聚类的数量

  • 如何使用谱方法聚类具有非线性关系的点

相似性和距离度量

对任何新的数据集进行聚类的第一步是决定如何比较项目之间的相似性(或差异性)。有时选择是由我们最感兴趣的相似性类型决定的,在其他情况下,它受到数据集特性的限制。在以下章节中,我们将说明几种用于数值、分类、时间序列和基于集合的数据的距离类型——虽然这个列表不是详尽的,但它应该涵盖了你在商业分析中遇到的大多数常见用例。我们还将介绍在运行聚类算法之前可能需要的归一化方法。

数值距离度量

让我们从探索wine.data文件中的数据开始。它包含一组描述不同种类葡萄酒特性的化学测量值,以及分配给葡萄酒的质量水平(I-III)(Forina, M.,等. PARVUS An Extendible Package for Data Exploration. 分类和相关性(1988))。在 iPython 笔记本中打开文件,并使用以下命令查看前几行:

>>> df = pd.read_csv("wine.data",header=None)
>>> df.head()

数值距离度量

注意到在这个数据集中我们没有列描述,这使得数据难以理解,因为我们不知道特征是什么。我们需要从数据集描述文件wine.names中解析列名,该文件除了列名外还包含有关数据集的附加信息。以下代码生成一个正则表达式,该表达式将匹配列名(使用一个模式,其中数字后面跟着一个括号,括号后面跟着列名,正如你在文件中列名列表中看到的那样):

>>> import re
>>> expr = re.compile('.*[0-9]+\)\s?(\w+).*')

然后,我们创建一个数组,其中第一个元素是葡萄酒的类别标签(它是否属于质量类别 I-III)。

>>> header_names = ['Class']

迭代文件中的行,我们提取那些与我们的正则表达式匹配的行:

>>> df_header = open("wine.names")
>>> for l in df_header.readlines():
 if len(expr.findall(l.strip()))!=0:
 header_names.append(expr.findall(l.strip())[0])
>>> df_header.close()

然后,我们将此列表分配给数据框的列属性,该属性包含列名:

>>> df.columns = header_names

现在我们已经附加了列名,我们可以使用df.describe()方法查看数据集的摘要:

数值距离度量

在对数据进行一些清理之后,我们如何根据每行的信息计算基于葡萄酒的相似度测量?一个选项是将每款葡萄酒视为一个由其维度(除了 Class 之外的所有列)指定的十三维空间中的点。由于生成的空间有十三维,我们无法直接使用散点图来可视化数据点以查看它们是否接近,但我们可以使用欧几里得距离公式来计算距离,该公式简单地是两点之间直线段的长度。这个长度的公式可以用于点在二维图或更复杂的空间(如本例所示)中,公式如下:

数值距离度量

在这里,xy 是数据集的行,n 是列数。欧几里得距离公式的一个重要方面是,尺度与其他列差异很大的列可能会主导计算的整体结果。在我们的例子中,描述每款葡萄酒中镁含量的值是描述酒精含量或灰分百分比的特性的 ~100x 倍。

如果我们要计算这些数据点之间的距离,它将主要取决于镁浓度(因为在这个尺度上的微小差异会压倒性地决定距离计算的值),而不是其任何其他属性。虽然这有时可能是可取的(例如,如果数值最大的列是我们最关心的相似性判断的列),但在大多数应用中,我们不倾向于一个特征而忽略另一个,并希望对所有列给予相同的权重。为了获得这些点之间公平的距离比较,我们需要对列进行归一化,使它们落入相同的数值范围(具有相似的极大值和极小值)。我们可以使用 scikit-learn 中的 scale() 函数和以下命令来实现这一点,该命令使用我们之前构建的 header_names 数组来访问所有列,但不是类标签(数组的第一个元素):

>>> from sklearn import preprocessing
>>> df_normalized = pd.DataFrame(preprocessing.scale(df[header_names[1:]]))
>>> df_normalized.columns = header_names[1:]
>>> df_normalized.describe()

数值距离度量

此函数将从每一列的均值中减去每个元素,然后将每个点除以该列的标准差。这种归一化方法将每个列中的数据中心化到均值为 0,方差为 1,对于正态分布的数据,这会导致标准正态分布。此外,请注意,scale() 函数返回一个 numpy array,这就是为什么我们必须在输出上调用 dataframe 来使用 pandas 函数 describe()

现在我们已经对数据进行归一化,我们可以使用以下命令计算行之间的欧几里得距离:

>>> import sklearn.metrics.pairwise as pairwise
>>> distances = pairwise.euclidean_distances(df_normalized)

您可以通过以下命令验证该命令生成一个 178 x 178 的方阵(原始数据集的行数):

>>> distances.shape

我们现在已将 178 行 13 列的数据集转换成一个方阵,给出了这些行之间的距离。换句话说,这个矩阵中的第 i 行第 j 列表示我们数据集中第 i 行和第 j 行之间的欧几里得距离。这个“距离矩阵”是我们将在下一节中用于聚类输入的输入。

如果想了解点相对于彼此的分布情况,可以使用给定的距离度量,我们可以使用多维尺度分析MDS)——(Borg, Ingwer, 和 Patrick JF Groenen. 现代多维尺度分析:理论与应用. Springer Science & Business Media, 2005;Kruskal, Joseph B. 《非度量多维尺度分析:一种数值方法》。Psychometrika 29.2 (1964): 115-129;Kruskal, Joseph B. 《通过优化非度量假设的拟合优度进行多维尺度分析》。Psychometrika 29.1 (1964): 1-27)来创建可视化。MDS 试图找到一组低维坐标(在这里,我们将使用两个维度),以最好地表示数据集原始、更高维度的点之间的距离(在这里,是我们从 13 个维度计算出的成对欧几里得距离)。MDS 根据应变函数找到最优的 2 维坐标:

数值距离度量

D代表我们计算点之间的距离。通过奇异值分解SVD)找到最小化此函数的二维坐标,我们将在第六章“文字与像素 - 处理非结构化数据”中更详细地讨论它。从 MDS 获得坐标后,我们可以使用wine类来着色图中的点,从而绘制结果。请注意,坐标本身没有解释(实际上,由于算法中的数值随机性,它们每次运行算法时都可能改变)。相反,我们感兴趣的是点的相对位置:

>>> from sklearn.manifold import MDS
>>> mds_coords = MDS().fit_transform(distances)
>>> pd.DataFrame(mds_coords).plot(kind='scatter',x=1,y=0,color=df.Class[:],colormap='Reds')

数值距离度量

考虑到我们可以以多种方式计算数据点之间的距离,欧几里得距离在这里是一个好的选择吗?从多维尺度图上直观来看,我们可以看到基于我们用来计算距离的特征,类别之间存在分离,因此从概念上讲,这似乎是一个合理的选项。然而,这个决定也取决于我们试图比较的内容。如果我们对检测具有相似绝对属性的葡萄酒感兴趣,那么这是一个好的度量标准。然而,如果我们对葡萄酒的绝对组成不太感兴趣,而是想知道其变量是否在不同酒精含量的葡萄酒中遵循相似的趋势,那么情况就不同了。在这种情况下,我们不会对值的绝对差异感兴趣,而是对列之间的相关性感兴趣。这种比较在时间序列分析中很常见,我们将在下一部分讨论。

相关性相似度指标和时间序列

对于时间序列数据,我们通常关心的是系列之间的模式是否在时间上表现出相同的变异,而不是它们在价值上的绝对差异。例如,如果我们比较股票,我们可能希望识别出随着时间的推移价格上下波动模式相似的股票组合。与这种增减模式相比,绝对价格不太重要。让我们通过查看道琼斯工业平均指数DJIA)股票价格随时间的变化来举例(Brown, Michael Scott, Michael J. Pelosi, and Henry Dirska. Dynamic-radius species-conserving genetic algorithm for the financial forecasting of Dow Jones index stocks. Machine Learning and Data Mining in Pattern Recognition. Springer Berlin Heidelberg, 2013. 27-41.)。首先,导入数据并检查前几行:

>>> df = pd.read_csv("dow_jones_index/dow_jones_index.data")
>>> df.head()

相关性相似度指标和时间序列

这份数据包含了 30 只股票的每日股价(为期 6 个月)。由于所有数值(价格)都在相同的尺度上,我们不会像处理葡萄酒维度那样对这份数据进行归一化。

我们注意到关于这份数据的两个问题。首先,每周的收盘价(我们将用它来计算相关性)被表示为一个字符串。其次,日期的格式对于绘图来说是不正确的。我们将处理这两个列以解决这个问题,分别将这些列转换为floatdatetime对象,使用以下命令。

为了将收盘价转换为数字,我们应用一个匿名函数,该函数接受所有字符,但不包括美元符号,并将其转换为浮点数。

>>> df.close = df.close.apply( lambda x: float(x[1:]))

为了转换日期,我们在日期列的每一行上使用一个匿名函数,将字符串分割成年、月和日元素,并将它们转换为整数以形成一个用于datetime对象的元组输入:

>>> import datetime
>>> df.date = df.date.apply( lambda x: datetime.\
 datetime(int(x.split('/')[2]),int(x.split('/')[0]),int(x.split('/')[1])))

通过这种转换,我们现在可以创建一个交叉表(正如我们在第二章,使用 Python 进行探索性数据分析和可视化中所述),将每周的收盘价作为列,个别股票作为行,使用以下命令:

>>> df_pivot = df.pivot('stock','date','close').reset_index()
>>> df_pivot.head()

相关相似度指标和时间序列

如我们所见,我们只需要计算行之间的相关性的列,从第 2 列开始,因为前两列是索引和股票代码。现在,让我们通过选择每行的数据框的第二列到末尾列,计算成对的相关性距离指标,并使用 MDS 进行可视化,就像之前一样:

>>> import numpy as np
>>> correlations = np.corrcoef(np.float64(np.array(df_pivot)[:,2:]))
>>> mds_coords = MDS().fit_transform(correlations)
>>> pd.DataFrame(mds_coords).plot(kind='scatter',x=1,y=0)

相关相似度指标和时间序列

需要注意的是,我们在这里计算的皮尔逊相关系数是衡量这些时间序列之间线性相关性的指标。换句话说,它捕捉了相对于另一个价格的趋势的线性增加(或减少),但并不一定能捕捉非线性趋势(如抛物线或 S 形模式)。我们可以通过查看皮尔逊相关系数的公式来看到这一点,该公式如下:

相关相似度指标和时间序列

这里 μ 和 σ 分别代表序列 ab 的平均值和标准差。这个值从 1(高度相关)到 -1(反向相关),0 代表没有相关性(例如球形点云)。你可能认出这个方程的分子是 协方差,它是衡量两个数据集,ab,如何同步变化的度量。你可以通过考虑分子在两个数据集中的对应点都高于或低于它们的平均值时达到最大值来理解这一点。然而,这个是否准确地捕捉了数据中的相似性取决于尺度。在数据在最大值和最小值之间以相同间隔分布,并且连续值之间差异大致相同的情况下,它很好地捕捉了这种模式。然而,考虑一个数据呈指数分布的情况,最小值和最大值之间有数量级差异,连续数据点的差异也很大。在这种情况下,皮尔逊相关系数将仅由序列中的最大值在数值上主导,这可能或可能不代表数据的整体相似性。这种数值敏感性也出现在分母中,它代表两个数据集标准差的乘积。相关性的值在两个数据集的变化大致由它们各自变化的乘积解释时达到最大;没有剩余的变异在数据集中没有被它们各自的标准差解释。通过提取这个集合中前两只股票的数据并绘制它们的成对值,我们看到这个线性假设对于比较数据点似乎是有效的:

>>> df_pivot.iloc[0:2].transpose().iloc[2:].plot(kind='scatter',x=0,y=1)

相关性相似度指标和时间序列

除了验证这些股票具有大致的线性相关性之外,此命令还引入了一些在 pandas 中你可能觉得有用的新函数。第一个是 iloc,它允许你从数据框中选择索引行。第二个是 transpose,它反转行和列。在这里,我们选择了前两行,进行了转置,然后选择了第二个之后的所有行(价格)(因为第一个是索引,第二个是 股票代码 符号)。

尽管在这个例子中我们看到了这种趋势,我们可能会想象价格之间可能存在非线性趋势。在这些情况下,可能更好的是测量价格本身的线性相关性,而不是一个股票的高价格是否与另一个股票的高价格一致。换句话说,按价格对市场日进行排名应该相同,即使价格是非线性相关的。我们也可以使用 SciPy 计算这种排名相关性,也称为 Spearman's Rho,以下公式:

相关性相似度指标和时间序列

注意

注意,这个公式假设排名是不同的(没有平局);在出现平局的情况下,我们可以使用数据集的排名而不是原始值来计算皮尔逊相关系数。

其中 n 是两个集合 ab 中每个集合的数据点数量,d 是每对数据点 aibi 之间排名的差异。因为我们只比较数据的排名,而不是它们的实际值,所以这个度量可以捕捉到两个数据集之间的变化,即使它们的数值在广泛的范围内变化。让我们看看使用斯皮尔曼相关系数指标绘制结果是否会在从 MDS 计算的股票的成对距离中产生任何差异,使用以下命令:

>>> import scipy.stats
>>> correlations2 = scipy.stats.spearmanr(np.float64(np.array(df_pivot)[:,1:]))
>>> mds_coords = MDS().fit_transform(correlations2.correlation)
>>> pd.DataFrame(mds_coords).plot(kind='scatter',x=1,y=0)

相关相似度指标和时间序列

基于坐标轴 xy 的斯皮尔曼相关系数距离似乎比皮尔逊距离更接近,这表明从排名相关的角度来看,时间序列更相似。

尽管它们在关于两个数据集如何数值分布的假设上有所不同,皮尔逊和斯皮尔曼相关系数都要求两个集合长度相同。这通常是一个合理的假设,并且在我们考虑的大多数例子中都是正确的。然而,对于我们要比较长度不等的时间序列的情况,我们可以使用动态时间规整DTW)。从概念上讲,DTW 的想法是通过允许我们在任一数据集中打开间隙,使其与第二个数据集大小相同,来将一个时间序列“扭曲”以与第二个时间序列对齐。算法需要解决的是两个系列中最相似点的位置,以便可以在适当的位置放置间隙。在最简单的实现中,DTW 由以下步骤组成(见以下图表):

相关相似度指标和时间序列

  1. 对于长度为 n 的数据集 a 和长度为 m 的数据集 b,构建一个大小为 nm 的矩阵。

  2. 将这个矩阵的顶部行和最左边的列都设置为无穷大(如图所示)。

  3. 对于集合 a 中的每个点 i 和集合 b 中的每个点 j,使用成本函数比较它们的相似性。将这个成本函数与元素 (i-1, j-1)、(i-1, j) 和 (j-1, i) 中的最小值相加——即从矩阵中向上和向左移动、向左或向上移动)。这些在概念上代表了在其中一个系列中打开间隙的成本,与在两个系列中对齐相同元素的成本(如图中上方中间所示)。

  4. 在步骤 2 结束时,我们将追踪到对齐两个系列的最小成本路径,DTW 距离将由矩阵的底部角落 (n.m) 表示(如图所示)。

此算法的一个负面方面是第 3 步涉及到为序列ab的每个元素计算一个值。对于大型时间序列或大型数据集,这可能计算上难以承受。虽然关于算法改进的全面讨论超出了我们当前示例的范围,但我们建议感兴趣的读者参考 FastDTW(我们将在示例中使用)和 SparseDTW 作为改进的例子,这些改进可以通过更少的计算进行评估(Al-Naymat, Ghazi, Sanjay Chawla, 和 Javid Taheri. Sparsedtw: 一种加快动态时间规整的新方法。第八届澳大利亚和新西兰数据挖掘会议-第 101 卷。澳大利亚计算机协会,2009 年。Salvador, Stan, 和 Philip Chan. 向线性时间和空间中的准确动态时间规整迈进。智能数据分析 11.5(2007 年):561-580.)。

我们可以使用 FastDTW 算法来比较股票数据,并再次使用 MDS 绘制结果。首先,我们将成对比较每一对股票,并将它们的 DTW 距离记录在一个矩阵中:

>>> from fastdtw import fastdtw
>>> dtw_matrix = np.zeros(shape=(df_pivot.shape[0],df_pivot.shape[0]))
…  for i in np.arange(0,df_pivot.shape[0]):
…     for j in np.arange(i+1,df_pivot.shape[0]):
 …      dtw_matrix[i,j] = fastdtw(df_pivot.iloc[i,2:],df_pivot.iloc[j,2:])[0]

注意

此功能位于 fastdtw 库中,您可以使用 pip 或easy_install进行安装。

为了计算效率(因为ij之间的距离等于股票ji之间的距离),我们只计算这个矩阵的上三角。然后我们将转置(例如,下三角)添加到这个结果中,以获得完整的距离矩阵。

>>> dtw_matrix+=dtw_matrix.transpose()

最后,我们可以再次使用 MDS 来绘制结果:

>>> mds_coords = MDS().fit_transform(dtw_matrix)
>>> pd.DataFrame(mds_coords).plot(kind='scatter',x=1,y=0) 

相关相似度指标和时间序列

与 Pearson 相关和秩相关的坐标分布相比,DTW 距离似乎跨越了更广泛的范围,捕捉到股票价格时间序列之间更细微的差异。

现在我们已经研究了数值和时间序列数据,作为一个最后的例子,让我们来考察计算分类数据集的相似度测量。

分类数据的相似度指标

文本代表一类分类数据:例如,我们可能使用一个向量来表示提交给学术会议的一组论文中给定关键词的存在或不存在,正如我们的示例数据集(Moran, Kelly H., Byron C. Wallace, 和 Carla E. Brodley. 通过社区来源的约束进行聚类以发现更好的 AAAI 关键词。第二十八届 AAAI 人工智能会议。2014 年。)所示。如果我们打开数据,我们会看到关键词被表示为一列中的字符串,我们需要将其转换为二进制向量:

>>> df2 = pd.read_csv("Papers.csv",sep=",")
>>> df2.head()

分类数据的相似度指标

虽然第六章,“文字和像素 – 处理非结构化数据”,我们将检查特殊函数来完成从文本到向量的转换,为了说明目的,我们现在将亲自编写代码。我们需要收集所有独特的关键词,并为每个关键词分配一个唯一的索引,为每个关键词生成一个新的列名'keyword_n'

>>> keywords_mapping = {}
>>> keyword_index = 0

>>> for k in df2.keywords:
…    k = k.split('\n')
…    for kw in k:
…        if keywords_mapping.get(kw,None) is None:
…           keywords_mapping[kw]='keyword_'+str(keyword_index)
…           keyword_index+=1

然后,我们使用这个关键字到列名的映射生成一组新的列,在每个行中,如果该文章的关键词中包含该关键字,则在该行设置 1:

>>>for (k,v) in keywords_mapping.items():
…        df2[v] = df2.keywords.map( lambda x: 1 if k in x.split('\n') else 0 ) Image_B04881_03_18.png

这些列将被附加到现有列的右侧,我们可以使用iloc命令选择这些二进制指示符,就像之前一样:

>>> df2.head().iloc[:,6:]

分类数据的相似性度量

在这种情况下,可以计算文章之间的欧几里得距离,但由于每个坐标只能是 0 或 1,它并不能提供我们想要的连续距离分布(由于只有有限种加法和减法 1 和 0 的方式,我们将会得到很多重复值)。

我们可以使用哪些类型的相似性度量来代替?一个是 Jaccard 系数:

分类数据的相似性度量

这是相交项的数量(在我们的例子中,ab都设置为1的位置)除以并集(ab中任一设置为 1 的总位置数)。然而,如果文章的关键词数量差异很大,这个度量可能会存在偏差,因为更大的词集有更大的概率与另一篇文章相似。如果我们担心这种偏差,可以使用余弦相似度,它测量向量之间的角度,并且对每个向量的元素数量敏感:

分类数据的相似性度量

其中:

分类数据的相似性度量

我们还可以使用汉明距离(Hamming, Richard W. Error detecting and error correcting codes. Bell System technical journal 29.2 (1950): 147-160.),它简单地计算两个集合的元素是否相同:

分类数据的相似性度量

显然,如果我们主要寻找匹配和不匹配,这个度量将是最优的。它也像 Jaccard 系数一样,对每个集合中的项目数量敏感,因为简单地增加元素数量会增加距离的上限。与汉明距离相似的是曼哈顿距离,它不需要数据是二进制的:

分类数据的相似性度量

以曼哈顿距离为例,我们可以再次使用 MDS 来绘制以下命令中关键词空间中文档的排列:

>>> distances = pairwise.pairwise_distances(np.float64(np.array(df2)[:,6:]),metric='manhattan')
>>> mds_coords = MDS().fit_transform(distances)
pd.DataFrame(mds_coords).plot(kind='scatter',x=1,y=0)

分类数据的相似性度量

我们看到许多论文组,它们提出通过简单比较常见关键词可以提供一种区分文章的方法。

下面的图表总结了我们讨论的不同距离方法,以及在选择特定问题时选择一种方法而不是另一种方法的决策过程。虽然它并不详尽,但我们希望它能为你自己的聚类应用提供一个起点。

分类数据的相似性度量

小贴士

旁白:归一化分类数据

正如你可能已经注意到的,我们不会像使用scale()函数处理数值数据那样对分类数据进行归一化。原因有两点。首先,对于分类数据,我们通常处理的是范围在[0,1]之间的数据,因此数据集中某一列包含的极端较大值压倒距离度量的问题最小化了。其次,scale()函数的概念是数据列中的数据是有偏的,我们通过减去均值来消除这种偏差。对于分类数据,'均值'的含义不太明确,因为数据只能取 0 或 1 的值,而均值可能在两者之间(例如,0.25)。减去这个值没有意义,因为它会使数据元素不再是二进制指示器。

旁白:混合距离度量

在本章迄今为止考虑的例子中,我们处理的数据可能被描述为数值型、时间序列型或分类型。然而,我们很容易找到这种情况并不成立的例子。例如,我们可能有一个包含随时间变化的股票价值数据集,其中还包含有关股票所属行业和公司规模及收入的分类信息。在这种情况下,选择一个能够充分处理所有这些特征的单一距离度量将变得困难。相反,我们可以为这三组特征(时间序列、分类和数值)中的每一组计算不同的距离度量,并将它们混合(例如,通过取平均值、总和或乘积)。由于这些距离可能覆盖非常不同的数值范围,我们可能需要对这些距离进行归一化,例如使用上面讨论的scale()函数将每个距离度量转换为具有均值 0、标准差 1 的分布,然后再将它们组合起来。

现在我们有了一些比较数据集中项目相似性的方法,让我们开始实现一些聚类流程。

K-means 聚类

K-means 聚类是经典的划分聚类算法。其思想相对简单:标题中的k代表我们希望识别的簇的数量,在运行算法之前我们需要决定这个数量,而means表示算法试图将数据点分配到它们最接近簇平均值的簇中。因此,一个给定的数据点会在 k 个不同的均值中选择,以便被分配到最合适的簇中。该算法最简单版本的基本步骤如下(MacKay, David. 一个推理任务示例:聚类. 信息理论、推理和学习算法(2003 年):284-292):

  1. 选择一个期望的组数 k

    分配 k 个簇中心;这些可以简单地是数据集中的 k 个随机点,这被称为 Forgy 方法(Hamerly, Greg, 和 Charles Elkan. 替代 k-means 算法以找到更好的聚类. 第十一届国际信息与知识管理会议论文集. ACM, 2002 年)。或者,我们可以将一个随机簇分配给每个数据点,并计算分配给同一簇的 k 个中心的平均数据点,这种方法称为随机划分(Hamerly, Greg, 和 Charles Elkan. 替代 k-means 算法以找到更好的聚类. 第十一届国际信息与知识管理会议论文集. ACM, 2002 年)。也可能有更复杂的方法,我们很快就会看到。

  2. 根据某些相似度度量(例如平方欧几里得距离),将任何剩余的数据点分配到最近的簇中。

  3. 通过计算分配给每个 k 个组的点的平均值来重新计算每个组的中心。请注意,在此点,中心可能不再代表单个数据点的位置,而是该组中所有点的加权质心。

  4. 重复 3 和 4,直到没有点改变簇分配或达到最大迭代次数。

    小贴士

    K-means++

    在上述步骤 2 中算法的初始化中,存在两个潜在问题。如果我们简单地选择随机点作为聚类中心,它们可能不会在数据中优化分布(尤其是如果聚类的大小不等)。k 个点可能实际上不会最终落在数据中的 k 个聚类中(例如,如图下顶部中间面板所示,多个随机点可能位于数据集最大的聚类中),这意味着算法可能不会收敛到“正确”的解,或者可能需要很长时间才能做到。同样,随机划分方法倾向于将所有中心放置在数据点最大质量附近(见下图中顶部右侧面板),因为任何随机点集都将被最大的聚类主导。为了改进参数的初始选择,我们可以使用 2007 年提出的 k++初始化(Arthur, David, 和 Sergei Vassilvitskii. "k-means++: The advantages of careful seeding." Proceedings of the eighteenth annual ACM-SIAM symposium on Discrete algorithms. Society for Industrial and Applied Mathematics, 2007.)。在这个算法中,我们随机选择一个初始数据点作为第一个聚类的中心。然后我们计算每个其他数据点到所选数据点的平方距离,并选择下一个聚类中心,其概率与这个距离成比例。随后,我们通过计算给定数据点到先前选定的中心的平方距离来选择剩余的聚类。因此,这种初始化将以更高的概率选择远离任何先前选择点的点,并在空间中更均匀地分布初始中心。这个算法是 scikit-learn 中默认使用的算法。

K-means 聚类

Kmeans++聚类。 (顶部,左侧):具有三个大小不等聚类的示例数据。 (顶部,中间):随机选择聚类中心倾向于最大的基础聚类中的点。 (顶部,右侧):随机划分导致所有三个随机聚类的质心接近图表底部。 (底部面板):Kmeans++导致在数据集中均匀选择三个聚类中心。

让我们思考一下为什么这有效;即使我们以随机分组中心开始,一旦我们将点分配到这些组,中心就会被拉向我们的数据集中观察的平均位置。更新后的中心更接近这个平均值。经过多次迭代后,每个组的中心将由随机选择起始点附近的平均数据点的位置主导。如果起始点选择得不好,它将被拉向这个平均值,而可能被错误分配到这个组的点将逐渐被重新分配。在这个过程中,通常最小化的整体值通常是平方误差之和(当我们使用欧几里得距离度量时),由以下公式给出:

K-means 聚类

其中 D 是欧几里得距离,c 是分配给点的簇的中心。这个值有时也被称为惯性。如果我们稍微思考一下,我们可以看到这会产生这样的效果:该算法最适合由圆形(或更高维度的球体)组成的数据;当点在球形云中均匀地远离簇时,簇的整体 SSE 最小化。相比之下,非均匀形状(如椭圆)往往会具有更高的 SSE 值,算法将通过将数据分成两个簇来优化,即使从视觉上看,它们似乎可以用一个簇很好地表示。这一事实强化了为什么标准化通常是有益的(因为 0 均值,1 标准差标准化试图近似所有维度的正态分布形状,从而形成数据圆或球体),以及在判断簇的质量时,数据可视化在除了数值统计之外的重要作用。

考虑到这一最小化标准对步骤 3 的影响也很重要。SSE 等于簇点与其质心之间的欧几里得距离的平方和。因此,使用平方欧几里得距离作为比较的度量,我们保证了簇分配也在优化最小化标准。我们可以使用其他距离度量,但这样就不能保证这一点。如果我们使用曼哈顿或汉明距离,我们可以将最小化标准改为到簇中心的距离之和,我们称之为 k-中位数,因为优化这个统计量的值是簇的中位数(Jain, Anil K. 和 Richard C. Dubes. 数据聚类算法。Prentice-Hall, Inc.,1988 年)。或者,我们可以使用任意距离度量,例如 k-medoids 算法(见下文)。

显然,这种方法将对我们初始选择的组中心的选择敏感,因此我们通常会多次运行算法,并使用最佳结果。

让我们看看一个例子:在笔记本中输入以下命令以读取样本数据集。

>>> df = pd.read_csv('kmeans.txt',sep='\t')
>>> df.plot(kind='scatter',x='x_coord',y='y_coord')

K-means 聚类

通过视觉检查,这个数据集明显包含多个簇。让我们尝试使用k=5进行聚类。

>>> from sklearn.cluster import KMeans
>>> kmeans_clusters = KMeans(5).fit_predict(X=np.array(df)[:,1:])
>>> df.plot(kind='scatter', x='x_coord', y='y_coord', c=kmeans_clusters)

K-means 聚类

你会注意到我们使用切片操作符 '[]' 来索引从输入数据框创建的 numpy 数组,并选择所有行以及第一个之后的列(第一个包含标签,因此我们不需要它,因为它不是用于聚类的数据的一部分)。我们使用在 scikit-learn 和 PySpark 中许多算法都会熟悉的模式来调用 KMeans 模型:我们用参数(在这里是 5,这是簇的数量)创建模型对象(KMeans),并调用 'fit_predict' 来校准模型参数并将模型应用于输入数据。在这里,应用模型会生成簇中心,而在我们将在 第四章、通过模型连接点 – 回归方法 和 第五章、将数据放在合适的位置 – 分类方法和分析 中讨论的回归或分类模型中,'predict' 将分别对每个数据点产生估计的连续响应或类标签。我们也可以简单地调用 KMeans 的 fit 方法,这将简单地返回一个描述簇中心和拟合模型产生的统计信息的对象,例如我们上面描述的惯性度量。

这个簇的数量适合数据吗?我们可以通过使用几个 k 的值进行聚类并绘制每个的惯性来探索这个问题。在 Python 中,我们可以使用以下命令。

>>> inertias = []
>>> ks = np.arange(1,20)
>>> for k in ks:
 …      inertias.append(KMeans(k).fit(X=np.array(df)[:,1:]).inertia_)
>>> results = pd.DataFrame({"num_clusters": ks, "sum_distance": inertias})

回想一下,惯性被定义为簇中点到其分配的簇中心的平方距离之和,这是我们试图在 k-means 中优化的目标。通过在每个簇编号 k 处可视化这个惯性值,我们可以对最适合数据的簇数量有一个感觉:

>>> results.plot(kind='scatter', x='num_clusters', y='sum_distance')

K-means 聚类

我们注意到在五个簇的标记处有一个 肘部,幸运的是,这是我们最初选择的值。这个肘部表明,在五个簇之后,我们增加更多簇时惯性并没有显著减少,这表明在 k=5 时我们已经捕捉到了数据中的重要组结构。

这个练习也说明了某些问题:正如您从图中可以看到的,我们的某些聚类可能是由看似重叠的段形成的,形成一个十字形状。这是一个单独的聚类还是两个混合的聚类?遗憾的是,如果没有在我们的聚类模型中指定聚类应遵循的形状,结果将完全由距离度量驱动,而不是您可能通过视觉注意到的模式。这强调了可视化结果并使用领域专家检查它们以判断获得的聚类是否有意义的重要性。在没有领域专家的情况下,我们还可以查看获得的聚类是否包含所有用已知分配标记的点——如果一个高比例的聚类富含单一标签,这表明聚类不仅概念质量良好,而且最小化了我们的距离度量。

我们还可以尝试使用一种方法来自动计算数据集的最佳聚类数量。

亲和传播 – 自动选择聚类数量

k-means 算法的一个弱点是我们需要事先定义预期在数据中找到的聚类数量。当我们不确定合适的选项时,我们可能需要运行多次迭代来找到一个合理的值。相比之下,亲和传播算法(Frey, Brendan J. 和 Delbert Dueck. 通过数据点间传递消息进行聚类. science 315.5814 (2007): 972-976)能够从数据集中自动找到聚类数量。该算法以相似性矩阵(S)作为输入(例如,可能是欧几里得距离的逆矩阵——因此,在 S 中,更接近的点具有更大的值),并在初始化一个包含所有零值的责任和可用性矩阵后执行以下步骤。它计算一个数据点 k 作为另一个数据点 i 的聚类中心的责任。这通过两个数据点之间的相似性数值表示。由于所有可用性都从零开始,在第一轮中,我们只需从 i 中减去任何其他点(k')的最高相似性。因此,当点 k 比任何其他点与 i 更相似时,就会产生一个高的责任分数。

亲和传播 – 自动选择聚类数量

其中i是我们试图找到的聚类中心的数据点,k是可能被分配给数据点i的潜在聚类中心,s 是它们的相似性,a 是下面描述的'可用性'。在下一步中,算法计算数据点k作为数据点i的聚类中心时的可用性,这表示 k 作为i的聚类中心是否合适,通过判断它是否也是其他点的中心。被许多其他点选择为具有高责任度的点具有高可用性,如公式所示:

亲和传播 – 自动选择簇数量

其中r是上面给出的责任。如果i=k,则此公式为:

亲和传播 – 自动选择簇数量

这些步骤有时被称为消息传递,因为它们代表了两个数据点之间关于一个数据点作为另一个数据点簇中心的相对概率的信息交换。查看步骤 1 和 2,你可以看到随着算法的进行,许多数据点的责任将降低到负数(因为我们不仅减去了其他数据点的最高相似度,还减去了这些点的可用性得分,只留下少数几个正数来确定簇中心。在算法结束时(一旦责任和可用性不再以可观的数值变化),每个数据点都将指向另一个数据点作为簇中心,这意味着簇的数量会自动从数据中确定。这种方法的优势在于我们不需要事先知道簇的数量,但与其他方法相比,其扩展性并不好,因为在最简单的实现中,我们需要一个 n-by-n 的相似度矩阵作为输入。如果我们将此算法应用于之前的数据集,我们会看到它检测到的簇数量远多于我们的肘图所暗示的,因为运行以下命令给出簇数量为 309。

>>> affinity_p_clusters = sklearn.cluster.AffinityPropagation().fit_predict(X=np.array(df)[:,1:]) 
>>> len(np.unique(affinity_p_clusters))

然而,如果我们查看每个簇中数据点的数量直方图,使用以下命令:

>>> pd.DataFrame(affinity_p_clusters).plot(kind='hist',bins=np.unique(affinity_p_clusters))

我们可以看到只有少数簇很大,而许多点被标识为属于它们自己的组:

亲和传播 – 自动选择簇数量

在 K-Means 失败的地方:聚类同心圆

到目前为止,我们的数据已经很好地使用 k-means 或亲和传播等变体进行了聚类。这个算法可能在哪些情况下表现不佳?让我们通过加载我们的第二个示例数据集并使用以下命令进行绘图来举一个例子:

>>> df = pd.read_csv("kmeans2.txt",sep="\t")
>>> df.plot(x='x_coord',y='y_coord',kind='scatter')

亲和传播 – 自动选择簇数量

仅凭肉眼,你可以清楚地看到有两个组:两个嵌套在一起的圆。然而,如果我们尝试对此数据进行 k-means 聚类,我们会得到一个不满意的结果,正如你从以下命令的运行和结果图中可以看到:

>>> kmeans_clusters = KMeans(2).fit_predict(X=np.array(df)[:,1:])
>>> df.plot(kind='scatter', x='x_coord', y='y_coord', c=kmeans_clusters)

亲和传播 – 自动选择簇数量

在这种情况下,算法无法识别数据中的两个自然聚类——因为数据中心环在许多点上的距离与外环相同,随机分配的聚类中心(更有可能落在外环的某个地方)对于最近的聚类是一个数学上合理的选择。这个例子表明,在某些情况下,我们可能需要改变我们的策略,并使用概念上不同的算法。也许我们的目标——平方误差(惯性)是不正确的,例如。在这种情况下,我们可能会尝试使用 k-medoids。

k-medoids

正如我们之前所描述的,k-means(中位数)算法最适合特定的距离度量,分别是平方欧几里得距离和曼哈顿距离,因为这些距离度量等同于这些算法试图最小化的统计量的最优值(如总平方距离或总距离)。在可能具有其他距离度量(如相关性)的情况下,我们也可以使用 k-medoid 方法(Theodoridis, Sergios, and Konstantinos Koutroumbas. Pattern recognition. (2003).),它包括以下步骤:

  1. 选择k个初始点作为初始聚类中心。

  2. 通过任何距离度量计算每个数据点的最近聚类中心,并将其分配给该聚类。

  3. 对于每个点和每个聚类中心,将聚类中心与点交换,并计算使用此交换在整个聚类成员中到聚类中心的总体距离的减少。如果它没有改进,则撤销。对所有点重复步骤 3。

这显然不是一个穷举搜索(因为我们没有重复步骤 1),但它的优点是,最优性标准不是一个特定的优化函数,而是通过灵活的距离度量来提高聚类的紧凑性。k-medoids 能否改善我们同心圆的聚类?让我们尝试使用以下命令运行并绘制结果:

>>> from pyclust import KMedoids
>>> kmedoids_clusters = KMedoids(2).fit_predict(np.array(df)[:,1:])
>>> df.plot(kind='scatter', x='x_coord', y='y_coord', c=kmedoids_clusters)

注意

注意,k-medoids 不包括在 sci-kit learn 中,所以您需要使用easy_installpip安装 pyclust 库。

k-medoids

与 k-means 相比,改进不大,所以我们可能需要完全改变我们的聚类算法。也许我们不应该在单阶段生成数据点之间的相似性,而应该检查相似性和聚类的层次度量,这正是我们将要检查的层次聚类算法的目标。

层次聚类

与将数据集划分为单个组的算法(如 k-means)不同,聚合层次聚类技术首先将每个数据点视为其自己的聚类,并从底部向上将它们合并成更大的组(Maimon, Oded, 和 Lior Rokach 编著 数据挖掘与知识发现手册。第 2 卷。纽约:Springer,2005)。这一想法的经典应用是在进化中的系统发育树,其中共同的祖先连接了单个生物体。确实,这些方法将数据组织成树状图,称为树状图,以可视化数据如何按顺序合并成更大的组。

聚合算法的基本步骤如下(如图所示):

  1. 从每个点在其自己的聚类开始。

  2. 使用距离度量比较每对数据点。这可以是上述讨论的任何方法。

  3. 使用连接标准合并数据点(在第一阶段)或聚类(在后续阶段),其中连接由如下函数表示:

    • 两组点之间的最大距离(也称为完全连接),或最小距离(也称为单连接)。

    • 两组点之间的平均距离,也称为未加权配对组平均法UPGMA)。每个组中的点也可以加权,以给出加权平均值,或 WUPGMA。

    • 中心点(质量中心)之间的差异,或 UPGMC。

    • 两组点之间的欧几里得距离的平方,或 Ward 的方法(Ward Jr, Joe H. 通过优化目标函数进行层次分组美国统计协会杂志 58.301 (1963): 236-244)。

    • 重复步骤 2-3,直到只剩下一个包含所有数据点的单一聚类。注意,在第一轮之后,聚类的第一阶段成为一个新的点,与其他所有聚类进行比较,并且随着算法的进行,每个阶段的聚类形成会越来越大。在这个过程中,我们将构建一个树状图,因为我们按顺序将前一步骤中的聚类合并在一起。

    聚合聚类

    聚合聚类:从上到下,从数据集(左)通过顺序合并最近的点构建树形结构(右)的示例。

注意,我们也可以反向运行此过程,从一个初始数据集开始,将其拆分为单个点,这也会构建一个树状图。在两种情况下,我们都可以通过选择树的截止深度并将点分配给它们在截止深度以下分配的最大聚类来找到多个分辨率的聚类。这个深度通常使用步骤 3 中给出的连接分数来计算,使我们能够在概念上选择一个合适的组间距离来考虑聚类(随着我们向上移动树,要么相对接近要么相对远离)。

聚类分析中的不足

层次聚类算法与 k-means 算法有许多相同的成分;我们选择一个聚类数(这将决定我们如何切割聚类生成的树——在最极端的情况下,所有点都成为单个聚类的成员)和一个相似性度量。我们还需要为第 3 步选择一个链接度量,它决定了合并树中单个分支的规则。层次聚类能否在 k-means 失败的地方成功?尝试这种方法在圆形数据上似乎不然,如下面的命令结果所示:

>>> from sklearn.cluster import AgglomerativeClustering
>>> agglomerative_clusters = AgglomerativeClustering(2,linkage='ward').fit_predict(X=np.array(df)[:,1:])
>>> df.plot(kind='scatter', x='x_coord', y='y_coord', c=agglomerative_clusters)

聚类失败的地方

为了正确地将内圈和外圈分组,我们可以尝试通过连通性来修改我们对相似性的理解,连通性是一个从图分析中借用的概念,其中一组节点通过边连接,连通性指的是两个节点之间是否共享一条边。在这里,我们通过阈值化可以相互视为相似点的点的数量,而不是测量每对点之间的连续距离度量,从而在点对之间构建一个图。这可能会减少我们在同心圆数据上的困难,因为如果我们设置一个非常小的值(比如说 10 个附近的点),从中心到外环的均匀距离就不再成问题,因为中心点总是彼此比外围点更近。为了构建这种基于连通性的相似性,我们可以取一个距离矩阵,例如我们之前已经计算过的那些,并对其进行阈值化,以某个我们认为点之间相连的相似性值,得到一个由 0 和 1 组成的二进制矩阵。这种表示图中节点之间边存在与否的矩阵也被称为邻接矩阵。我们可以通过检查成对相似度分数的分布或基于先验知识来选择这个值。然后,我们可以将这个矩阵作为参数提供给我们的层次聚类程序,提供在比较数据点时要考虑的点邻域,这为聚类提供了初始结构。我们可以看到,在运行以下命令后,这会对算法的结果产生巨大影响。注意,当我们生成邻接矩阵 L 时,我们可能会得到一个非对称矩阵,因为我们为数据中的每个成员阈值化了最相似的十个点。这可能导致两个点不是彼此最近的,导致在邻接矩阵的上三角或下三角中只表示一个边。为了生成聚类算法的对称输入,我们取矩阵 L 及其转置的平均值,这实际上在两点之间添加了双向边。

>>> from sklearn.cluster import AgglomerativeClustering
>>> from sklearn.neighbors import kneighbors_graph
>>> L = kneighbors_graph(np.array(df)[:,1:], n_neighbors=10, include_self=False)
>>> L = 0.5 * (L + L.T)
>>> agglomerative_clusters = AgglomerativeClustering(n_clusters=2,connectivity=L,linkage='average').fit_predict(X=np.array(df)[:,1:])
>>> df.plot(kind='scatter', x='x_coord', y='y_coord', c=agglomerative_clusters)

现在,正如你所看到的,这个算法可以正确地识别和分离两个聚类:

有趣的是,构建这个邻域图并将其划分为子图(将整个图划分为一组节点和边,这些节点和边主要相互连接,而不是与其他网络元素连接),这与在转换后的距离矩阵上执行 k-means 聚类等价,这种方法被称为谱聚类(Von Luxburg, Ulrike. 谱聚类的教程. 统计与计算 17.4 (2007): 395-416)。这里的转换是将我们之前计算的欧几里得距离 D 转换为核分数——由以下高斯核给出:

聚合聚类失败的地方

在每对点 i 和 j 之间,用带宽 γ 代替我们在构建邻域之前所做的硬阈值。使用从所有点 i 和 j 计算出的成对核矩阵 K,然后我们可以构建一个图的拉普拉斯矩阵,它由以下公式给出:

聚合聚类失败的地方

在这里,I 是单位矩阵(对角线上的元素为 1,其他地方为 0),而 D 是对角矩阵,其元素为:

聚合聚类失败的地方

为每个点 i 提供邻居的数量。本质上,通过计算 L,我们现在将数据集表示为一系列通过边(这个矩阵的元素)连接的节点(点),这些边的值已经被归一化,使得每个节点的所有边的总和为 1。由于高斯核分数是连续的,在这个归一化过程中,将给定点与所有其他点之间的成对距离划分为一个概率分布,其中距离(边)的总和为 1。

你可能还记得,从线性代数中,矩阵 A 的特征向量 v 是这样的向量,如果我们用矩阵乘以特征向量 v,我们会得到与用常数 λ 乘以向量相同的结果:聚合聚类失败的地方。因此,这里的矩阵代表对向量的某种操作。例如,单位矩阵给出特征值 1,因为用单位矩阵乘以 v 得到的是 v 本身。我们也可以有如下矩阵:

聚合聚类失败的地方

它将与之相乘的向量的值加倍,表明矩阵在向量上执行了“拉伸”操作。从这个角度来看,较大的特征值对应于向量更大的拉伸,而特征向量给出了拉伸发生的方向。这很有用,因为它给出了矩阵操作作用的主要轴。在我们的例子中,如果我们取具有最大特征值的两个特征向量(本质上,矩阵表示向量最大变换的方向),我们正在提取矩阵中两个最大的变化轴。当我们讨论第六章中的主成分时,我们将更详细地回到这个概念,文字和像素 - 处理非结构化数据,但简而言之,如果我们在这两个特征向量上运行run-kmeans(这种方法被称为谱聚类,因为聚类的矩阵的特征值被称为矩阵的谱),我们得到的结果与之前使用邻域的聚合聚类方法非常相似,正如我们从以下命令的执行中可以看到:

>>> spectral_clusters = sklearn.cluster.SpectralClustering(2).fit_predict(np.array(df)[:,1:])
>>> df.plot(kind='scatter', x='x_coord', y='y_coord', c=spectral_clusters)

聚类失败的地方

我们可以成功地捕捉到这个非线性分离边界,因为我们已经将点表示在成对距离最大的空间中,这是数据中内圈和外圈之间的差异:

上述示例应该已经给了你许多可以用来解决聚类问题的方法,并且作为一个经验法则指南,以下图表说明了选择它们之间的决策过程:

聚类失败的地方

在我们探索聚类的最后部分,让我们看看一个利用 Spark Streaming 和 k-means 的示例应用,这将允许我们随着接收到的数据逐步更新我们的聚类。

Spark 中的流式聚类

到目前为止,我们主要展示了用于即席探索性分析的示例。在构建分析应用时,我们需要开始将这些内容放入一个更健壮的框架中。作为一个例子,我们将演示使用 PySpark 的流式聚类管道的使用。这个应用有可能扩展到非常大的数据集,我们将以这种方式组合分析的部分,使其在数据格式错误的情况下具有容错性。

由于我们将在接下来的章节中使用与 PySpark 类似的示例,让我们回顾一下此类应用中我们需要的关键组成部分,其中一些我们在第二章中已经看到,Python 中的探索性数据分析和可视化。我们将在本书中创建的大多数 PySpark 作业都由以下步骤组成:

  1. 构建一个 Spark 上下文。该上下文包含有关应用程序名称以及内存和任务数量等参数的信息。

  2. Spark 上下文可以用来构建二级上下文对象,例如我们将在此示例中使用的流上下文。此上下文对象包含特定于特定类型任务的参数,例如流数据集,并继承我们在基本 Spark 上下文中先前初始化的所有信息。

  3. 构建一个数据集,在 Spark 中表示为弹性分布式数据集RDD)。从程序的角度来看,我们可以像操作例如 pandas 数据框一样操作这个 RDD,但实际上在分析过程中它可能被并行化到多台机器上。我们可能在从源文件读取后或从 Hadoop 等并行文件系统中读取数据后并行化数据。理想情况下,我们不希望因为一行数据错误而导致整个作业失败,因此我们希望在错误处理机制中放置一个将提醒我们解析行失败而不会阻塞整个作业的机制。

  4. 我们经常需要将我们的输入数据集转换为 RDD 的子类,称为标记 RDD。标记 RDD 包含一个标签(例如,本章中我们研究过的聚类算法的聚类标签)和一组特征。对于我们的聚类问题,我们将在预测时执行此转换(因为我们通常不知道聚类的时间),但对于我们在第四章、“用模型连接点——回归方法”和第五章、“将数据放在合适的位置——分类方法和分析”中将要查看的回归和分类模型,标签用作拟合模型的一部分。

  5. 我们经常希望有一种方法将我们的建模输出保存下来,以便由下游应用程序使用,无论是在磁盘上还是在数据库中,我们可以稍后通过历史记录查询索引模型。

让我们使用 Python 笔记本查看这些组件。假设我们已经在系统上安装了 Spark,我们将首先导入所需的依赖项:

>>> from pyspark import SparkContext
>>> from pyspark.streaming import StreamingContext

然后,我们可以测试启动SparkContext

>>> sc = SparkContext( 'local', 'streaming-kmeans')

请记住,第一个参数提供了我们的 Spark 主机的 URL,即协调 Spark 作业执行并将任务分配给集群中工作机的机器。在这种情况下,我们将本地运行它,因此将此参数指定为localhost,但否则这可能是我们集群中远程机器的 URL。第二个参数只是我们为应用程序指定的名称。在上下文运行后,我们还可以使用以下方式生成流上下文,该上下文包含有关我们的流应用程序的信息:

>>> ssc = StreamingContext(sc, 10)

第一个参数简单地是作为StreamingContext父类的SparkContext:第二个是我们将检查流数据源新数据的频率(以秒为单位)。如果我们期望数据定期到达,我们可以将其设置得更低,或者如果预期新数据不太频繁地可用,我们可以将其设置得更高。

现在我们有了StreamingContext,我们可以添加数据源。假设现在我们将有两个训练数据源(可能是历史数据)。我们希望作业在给出一条错误数据时不会死亡,因此我们使用一个提供这种灵活性的Parser类:

>>> class Parser():
 def __init__(self,type='train',delimiter=',',num_elements=5, job_uuid=''):
 self.type=type
 self.delimiter=delimiter
 self.num_elements=num_elements
 self.job_uuid=job_uuid

 def parse(self,l):
 try:
 line = l.split(self.delimiter) 
 if self.type=='train':
 category = float(line[0])
 feature_vector = Vectors.dense(line[1:])
 return LabeledPoint(category, feature_vector)
 elif self.type=='test':
 category = -1
 feature_vector = Vectors.dense(line)
 return LabeledPoint(category, feature_vector)
 else:
 # log exceptions
 f = open('/errors_events/{0}.txt'.format(self.job_uuid),'a')
 f.write('Unknown type: {0}'.format(self.type))
 f.close()
 except:
 # log errors
 f = open('/error_events/{0}.txt'.format(self.job_uuid),'a')
 f.write('Error parsing line: {0}'.format)
 f.close() 

我们将错误行记录到以我们的作业 ID 命名的文件中,这样如果需要的话,我们可以稍后定位它们。然后我们可以使用这个解析器来训练和评估模型。为了训练模型,我们将具有三列(标签和要聚类的数据)的文件移动到训练目录中。我们还可以向测试数据目录添加只有两列的文件,仅包含坐标特征:

>>> num_features = 2
num_clusters = 3

training_parser = Parser('train',',',num_features+1,job_uuid)
test_parser = Parser('test',',',num_features,job_uuid)

trainingData = ssc.textFileStream("/training_data").\
 map(lambda x: training_parser.parse(x)).map(lambda x: x.features)
testData = ssc.textFileStream("/test_data").\
 map(lambda x: test_parser.parse(x)).map(lambda x: x.features)
streaming_clustering = StreamingKMeans(k=num_clusters, decayFactor=1.0).\
 setRandomCenters(num_features,0,0)
streaming_clustering.trainOn(trainingData)
streaming_clustering.predictOn(testData).\
 pprint()
ssc.start() 

参数中的衰减因子给出了结合当前聚类中心和旧聚类中心的配方。对于参数 1.0,我们使用新旧数据之间的相等权重,而在另一个极端,即 0 时,我们只使用新数据。如果我们在任何时候停止模型,我们可以使用lastestModel()函数来检查它:

>>>  streaming_clustering.latestModel().clusterCenters

我们也可以使用predict()函数在适当大小的向量上预测:

>> streaming_clustering.latestModel().predict([ … ])

摘要

在本节中,我们学习了如何在数据集中识别相似项的组,这是一种探索性分析,我们可能会在解读新数据集时频繁使用它作为第一步。我们探讨了计算数据点之间相似性的不同方法,并描述了这些指标可能最适合应用的数据类型。我们考察了两种聚类算法:一种是从单个组开始将数据分割成更小组件的划分聚类算法,另一种是每个数据点最初都是其自己的聚类的聚合方法。使用多个数据集,我们展示了这些算法表现好坏的例子,以及一些优化它们的方法。我们还看到了我们的第一个(小型)数据处理管道,这是一个使用流数据的 PySpark 聚类应用。

第四章. 通过模型连接点 – 回归方法

趋势线是许多商业分析中的常见特征。当在主页上更频繁地展示广告时,购买量会增加多少?根据用户年龄,社交媒体上视频的平均评分是多少?如果客户在 6 个月前购买了第一个产品,他们从你的网站上购买第二个产品的可能性有多大?这些问题可以通过绘制一条线来回答,这条线表示随着输入(例如,用户年龄或过去购买量)的变化,我们的响应(例如,购买或评分)的平均变化,并基于历史数据使用它来外推未来数据的响应(在这种情况下,我们只知道输入,但不知道输出)。计算这条线被称为回归,基于假设我们的观察值围绕着两个变量之间真实关系的周围散布,并且平均来说,未来的观察值将回归(接近)输入和输出之间的趋势线。

在实际应用中,几个复杂性使得这种分析变得复杂。首先,我们拟合的关系通常涉及不止一个输入,而不仅仅是单一输入。我们不能再画一个二维线来表示这种多变量关系,因此必须越来越多地依赖更高级的计算方法来计算这个在多维空间中的趋势。其次,我们试图计算的趋势甚至可能不是一条直线——它可能是一条曲线、一个波或更复杂的模式。我们可能也有比我们需要的更多变量,需要决定哪些,如果有的话,与当前问题相关。最后,我们需要确定不仅是最适合我们已有数据的趋势,而且也是对新数据推广得最好的趋势。

在本章中,我们将学习:

  • 如何为回归问题准备数据

  • 如何在给定问题中选择线性或非线性方法

  • 如何进行变量选择和评估过拟合

线性回归

普通最小二乘法OLS)。

我们将从最简单的线性回归模型开始,尝试通过我们拥有的数据点拟合最佳直线。回忆一下,线性回归的公式是:

线性回归

其中 y 是我们试图预测的 n 个响应的向量,X 是长度为 n 的输入变量的向量,β 是斜率响应(响应 y 在 X 的值增加 1 个单位时增加多少)。然而,我们很少只有一个输入;相反,X 将代表一组输入变量,响应 y 是这些输入的线性组合。在这种情况下,称为多元线性回归,X 是 n 行(观测值)和 m 列(特征)的矩阵,β 是斜率或系数的向量集,当乘以特征时给出输出。本质上,它只是包含许多输入的趋势线,但也将允许我们比较不同输入对结果的影响程度。当我们试图使用多元线性回归拟合模型时,我们还假设响应包含一个白噪声误差项 ε,它是一个均值为 0 且对所有数据点具有恒定方差的正态分布。

为了求解此模型中的系数 β,我们可以进行以下计算:

线性回归

β 的值是系数的普通最小二乘估计。结果将是一个系数 β 的向量,用于输入变量。我们对数据做出以下假设:

  • 我们假设输入变量(X)被准确测量(我们给出的值中没有误差)。如果不是这样,并且包含了误差,那么它们代表随机变量,我们需要在我们的响应估计中包含这个误差,以便准确。

  • 响应是输入变量的线性组合——换句话说,我们需要能够在响应中拟合一条直线。正如我们将在本章后面看到的那样,我们可以经常进行变换,将非线性数据转换为线性形式以满足这个假设。

  • 响应 y 的残差(拟合值与实际响应之间的差异)假设在其值域内具有恒定的方差。如果情况并非如此(例如,如果 y 的较小值比较大值具有较小的误差),那么这表明我们没有适当地在我们的模型中包含一个误差来源,因为在我们考虑了预测变量 X 之后,剩下的唯一变化应该是误差项 ε。如前所述,这个误差项 ε 应该具有恒定的方差,这意味着拟合应该具有恒定的残差方差。

  • 假设残差与预测值 X 的值不相关。这一点很重要,因为我们假设我们试图拟合一条通过每个预测值处的响应数据点平均值的线,如果我们假设残差误差在 0 周围随机分布,这将是非常准确的。如果残差与预测值的值相关,那么准确拟合数据的线可能不会通过平均值,而是由数据中的潜在相关性决定。例如,如果我们正在查看时间序列数据,一周中的某一天可能有一个 7 天的模式,这意味着我们的模型应该拟合这个周期性,而不是试图简单地通过所有天的数据点来画一条线。

  • 假设预测变量之间不存在共线性(彼此相关)。如果两个预测变量相同,那么当我们对输入矩阵 X 进行线性组合时,它们会相互抵消。正如我们上面在β的推导中看到的,为了计算系数,我们需要取逆。如果矩阵中的列完全相互抵消,那么这个矩阵(XTX)^-1 是秩亏的,没有逆。回想一下,如果一个矩阵是满秩的,它的列(行)不能由其他列(行)的线性组合来表示。一个秩亏的矩阵没有逆,因为如果我们试图解决由以下线性系统表示的问题:线性回归

    当 A 是我们试图解决的逆矩阵,I 是单位矩阵时,我们将在解的列中遇到完全相互抵消的情况,这意味着任何一组系数都可以解决这个方程,我们无法得到一个唯一解。

为什么 OLS 公式对于β的估计代表系数的最佳估计?原因是这个值最小化了平方误差:

线性回归

尽管这个事实的推导超出了本文的范围,但这个结果被称为高斯-马尔可夫定理,它表明 OLS 估计量是系数β的最佳线性无偏估计量(BLUE)。回想一下,当我们估计这些系数时,我们是在假设我们的计算存在一些误差,并且与真实(未见的)值有所偏差。因此,BLUE 是具有从这些真实值中最小平均误差的系数β的集合。更多细节,我们建议读者参考更全面的文本(Greene, William H. 经济计量分析. Pearson Education India, 2003; Plackett, Ronald L. "一些最小二乘定理." 生物统计学 37.1/2 (1950): 149-157)。

根据问题和数据集,我们可以使用基本线性模型的扩展方法放松上述许多假设。在我们探索这些替代方案之前,让我们从一个实际例子开始。我们将为此练习使用的数据是从网站mashable.com/获取的一系列新闻文章。(Fernandes, Kelwin, Pedro Vinagre, and Paulo Cortez. "A Proactive Intelligent Decision Support System for Predicting the Popularity of Online News." Progress in Artificial Intelligence. Springer International Publishing, 2015. 535-546.)。每篇文章都使用诸如单词数量和发布日期等特征进行了注释——完整的列表出现在与此练习相关的数据文件中。任务是使用这些其他特征预测流行度(数据集中的共享列)。在拟合第一个模型的过程中,我们将检查在这种分析中出现的常见特征准备任务。

数据准备

让我们首先通过输入以下命令来查看数据:

>>> news = pd.read_csv('OnlineNewsPopularity.csv',sep=',')
>>> news.columns

这给出了以下输出:

Index(['url', ' timedelta', ' n_tokens_title', ' n_tokens_content',        ' n_unique_tokens', ' n_non_stop_words', ' n_non_stop_unique_tokens',        ' num_hrefs', ' num_self_hrefs', ' num_imgs', ' num_videos',        ' average_token_length', ' num_keywords', ' data_channel_is_lifestyle',        ' data_channel_is_entertainment', ' data_channel_is_bus',        ' data_channel_is_socmed', ' data_channel_is_tech',        ' data_channel_is_world', ' kw_min_min', ' kw_max_min', ' kw_avg_min',        ' kw_min_max', ' kw_max_max', ' kw_avg_max', ' kw_min_avg',        ' kw_max_avg', ' kw_avg_avg', ' self_reference_min_shares',        ' self_reference_max_shares', ' self_reference_avg_sharess',        ' weekday_is_monday', ' weekday_is_tuesday', ' weekday_is_wednesday',        ' weekday_is_thursday', ' weekday_is_friday', ' weekday_is_saturday',        ' weekday_is_sunday', ' is_weekend', ' LDA_00', ' LDA_01', ' LDA_02',        ' LDA_03', ' LDA_04', ' global_subjectivity',        ' global_sentiment_polarity', ' global_rate_positive_words',        ' global_rate_negative_words', ' rate_positive_words',        ' rate_negative_words', ' avg_positive_polarity',        ' min_positive_polarity', ' max_positive_polarity',        ' avg_negative_polarity', ' min_negative_polarity',        ' max_negative_polarity', ' title_subjectivity',        ' title_sentiment_polarity', ' abs_title_subjectivity',        ' abs_title_sentiment_polarity', ' shares'],       dtype='object')

如果你仔细观察,你会意识到所有列名都有前导空格;你可能在第一次尝试使用名称作为索引提取某一列时就已经发现了这一点。我们数据准备的第一步是使用以下代码从每个列名中去除空格来修复这种格式:

>>> news.columns = [ x.strip() for x in news.columns]

现在我们已经正确格式化了列标题,让我们使用describe()命令检查数据的分布,就像我们在前面的章节中看到的那样:

数据准备

当你从左到右滚动列时,你会注意到每列中值的范围差异很大。有些列的最大值在几百或几千,而其他列则严格在 0 到 1 之间。特别是,我们试图预测的值,即共享值,分布非常广泛,如果我们使用以下命令绘制分布图,就可以看到:

>>> news['shares'].plot(kind='hist',bins=100)

数据准备

为什么这种分布是个问题?回想一下,从概念上讲,当我们通过数据集拟合一条线时,我们是在寻找以下方程的解:

数据准备

其中 y 是响应变量(如份额),β是通过 X 列的 1 单位变化增加/减少响应值的向量斜率。如果我们的响应是对数分布的,那么系数将偏向于适应极端大的点,以最小化给定以下拟合的总误差:

数据准备

为了减少这种影响,我们可以对响应变量进行对数变换,正如以下代码所示,这使得分布看起来更像正态曲线:

>>> news['shares'].map( lambda x: np.log10(x) ).plot(kind='hist',bins=100)

数据准备

这个经验法则同样适用于我们的预测变量 X。如果某些预测变量比其他变量大得多,我们方程的解将主要强调那些范围最大的变量,因为它们将对总体误差贡献最大。在这个例子中,我们可以系统地使用对数转换来缩放所有变量。首先,我们移除所有无信息列,例如 URL,它只是为文章提供网站位置。

>>> news_trimmed_features = news.ix[:,'timedelta':'shares']

注意

注意,在第六章文字与像素 - 处理非结构化数据中,我们将探讨利用文本数据中信息(如 url)的潜在方法,但到目前为止,我们只是简单地丢弃它。

然后,我们确定要转换的变量(这里一个简单的经验法则是,它们的最大值,由 describe()数据框的第 8 行(索引 7)给出,大于 1,表明它们不在 0 到 1 的范围内)并使用以下代码应用对数转换。请注意,我们给每个对数转换的变量加 1,以避免对 0 取对数时出现错误。

>>> log_values = list(news_trimmed_features.columns[news_trimmed_features.describe().reset_index().loc[7][1:]>1])
>>> for l in log_values:
…    news_trimmed_features[l] = np.log10(news_trimmed_features[l]+1)

再次使用 describe()命令确认,现在列具有可比的分布:

数据准备

我们还需要从数据集中移除无穷大或不存在的数据。我们首先使用以下方法将无穷大值转换为占位符“不是一个数字”,或 NaN:

>>> news_trimmed_features = news_trimmed_features.replace([np.inf, -np.inf], np.nan)

然后,我们使用fill函数用列中的前一个值替换NaN占位符(我们也可以指定一个固定值,或使用列中的前一个值)如下:

>>> news_trimmed_features = news_trimmed_features.fillna(method='pad')

现在,我们可以将数据分为响应变量('shares')和特征(从'timedelta''abs_title_sentiment_polarity'的所有列),这些我们将作为后面描述的回归模型的输入使用以下命令:

>>> news_response = news_trimmed_features['shares']
>>> news_trimmed_features = news_trimmed_features.ix[:,'timedelta':'abs_title_sentiment_polarity']

现在,让我们再看看我们没有进行对数转换的变量。如果你现在尝试使用数据集拟合线性模型,你会发现其中许多斜率非常大或非常小。这可以通过查看剩余变量代表的内容来解释。例如,一组我们没有进行对数转换的列编码了一个新闻文章是否在特定一周的某一天发布的0/1值。另一个(标注 LDA)提供了一个0/1指示符,表示文章是否被标记为特定算法定义的主题(我们将在第六章文字与像素 - 处理非结构化数据中更详细地介绍这个算法,称为潜在狄利克雷分配)。在这两种情况下,数据集中的任何行都必须在这些特征的一列中具有值 1(例如,星期几必须取七个潜在值之一)。这为什么会成为问题?

记住,在大多数线性拟合中,我们都有一个斜率和一个截距,这是线在 x-y 平面原点 (0, 0) 的垂直偏移。在具有许多变量的线性模型中,我们通过特征矩阵 X 中的一个全 1 列来表示这个多维截距,这在许多模型拟合库中默认添加。这意味着一组列(例如,一周中的某一天),由于它们是独立的,可以形成一个线性组合,正好等于截距列,这使得无法找到斜率 β 的唯一解。这与我们之前讨论的线性回归的最后一个假设相同,即矩阵 (XTX) 不可逆,因此我们无法获得系数的数值稳定解。这种不稳定性导致如果你在这个数据集上拟合回归模型,你会观察到系数值不合理地大。正因为如此,我们可能需要省略截距列(通常需要在建模库中指定此选项),或者省略这些二元变量的某一列。在这里,我们将执行第二种方法,使用以下代码从每个二元特征集省略一列:

>>> news_trimmed_features = news_trimmed_features.drop('weekday_is_sunday',1)
>>> news_trimmed_features = news_trimmed_features.drop('LDA_00',1)

现在我们已经处理了这些特征工程问题,我们准备将回归模型拟合到我们的数据上。

模型拟合和评估

现在我们准备将回归模型拟合到我们的数据上,明确分析目标是重要的。正如我们在第一章中简要讨论的,“从数据到决策 – 开始使用分析应用”,建模的目标可以是 a) 根据历史数据预测未来的响应,或 b) 推断给定变量对结果的影响的统计意义和效应。

在第一种情况下,我们将选择数据的一个子集来训练我们的模型,然后在一个独立的数据集上评估线性模型的拟合优度,这个数据集没有用于推导模型参数。在这种情况下,我们希望验证模型所表示的趋势是否可以推广到特定数据点之外。虽然线性模型的系数输出是可解释的,但在这个场景中,我们更关心的是我们能否准确预测未来的响应,而不是系数的意义。

在第二种情况下,我们可能根本不使用测试数据集进行验证,而是使用所有数据来拟合线性模型。在这种情况下,我们更感兴趣的是模型的系数以及它们是否具有统计学意义。在这个场景中,我们通常还感兴趣的是比较具有更多或更少系数的模型,以确定预测结果的最重要的参数。

我们将回到这个第二个案例,但就目前而言,让我们继续假设我们正在尝试预测未来的数据。为了获得测试和验证数据,我们使用以下命令将响应和预测数据分割成 60%的训练和 40%的测试分割:

>>> from sklearn import cross_validation
>>> news_features_train, news_features_test, news_shares_train, news_shares_test = \
>>> cross_validation.train_test_split(news_trimmed_features, news_response, test_size=0.4, random_state=0)

我们使用'随机状态'参数来设置随机化的固定结果,这样我们就可以在稍后日期重新运行分析时重现相同的训练/测试分割。有了这些训练和测试集,我们就可以拟合模型,并使用以下代码通过可视化比较预测值和观察值:

>>> from sklearn import linear_model
>>> lmodel = linear_model.LinearRegression().fit(news_features_train, news_shares_train)
>>> plt.scatter(news_shares_train,lmodel.predict(news_features_train),color='black')
>>> plt.xlabel('Observed')
>>> plt.ylabel('Predicted')

这给出了以下图:

模型拟合和评估

同样,我们可以使用以下命令查看模型在测试数据集上的性能:

>>> plt.scatter(news_shares_test,lmodel.predict(news_features_test),color='red')
>>> plt.xlabel('Observed')
>>> plt.ylabel('Predicted')

这给出了以下图:

模型拟合和评估

通过观察变异系数,或'R-squared'值,可以确认视觉上的相似性。这是一个在回归问题中常用的指标,它定义了响应中的多少变化可以由模型中预测变量的变化来解释。它被计算为:

模型拟合和评估

其中CovVar分别是两个变量(观察到的响应 y 和由 yβ给出的预测响应)的协方差方差(分别)。完美分数是1(一条直线),而0表示预测值和观察值之间没有相关性(一个例子是一个球形点云)。使用 scikit learn,我们可以使用线性模型的score()方法获得值,其中特征和响应变量作为参数。运行以下代码来处理我们的数据:

>>> lmodel.score(news_features_train, news_shares_train)
>>> lmodel.score(news_features_test, news_shares_test)

训练数据得到0.129的值,测试集得到0.109的值。因此,我们看到,尽管新闻文章数据中捕获了预测值和观察值之间的一些关系,但我们仍有改进的空间。

除了寻找整体性能外,我们还可能对模型中哪些输入变量最重要感兴趣。我们可以通过绝对值对模型的系数进行排序,使用以下代码来分析排序后的系数位置,并使用这个新索引重新排序列名:

>>> ix = np.argsort(abs(lmodel.coef_))[::-1][:]
>>> news_trimmed_features.columns[ix]

这给出了以下输出:

Index([u'n_unique_tokens', u'n_non_stop_unique_tokens', u'n_non_stop_words',        u'kw_avg_avg', u'global_rate_positive_words',        u'self_reference_avg_sharess', u'global_subjectivity', u'LDA_02',        u'num_keywords', u'self_reference_max_shares', u'n_tokens_content',        u'LDA_03', u'LDA_01', u'data_channel_is_entertainment', u'num_hrefs',        u'num_self_hrefs', u'global_sentiment_polarity', u'kw_max_max',        u'is_weekend', u'rate_positive_words', u'LDA_04',        u'average_token_length', u'min_positive_polarity',        u'data_channel_is_bus', u'data_channel_is_world', u'num_videos',        u'global_rate_negative_words', u'data_channel_is_lifestyle',        u'num_imgs', u'avg_positive_polarity', u'abs_title_subjectivity',        u'data_channel_is_socmed', u'n_tokens_title', u'kw_max_avg',        u'self_reference_min_shares', u'rate_negative_words',        u'title_sentiment_polarity', u'weekday_is_tuesday',        u'min_negative_polarity', u'weekday_is_wednesday',        u'max_positive_polarity', u'title_subjectivity', u'weekday_is_thursday',        u'data_channel_is_tech', u'kw_min_avg', u'kw_min_max', u'kw_avg_max',        u'timedelta', u'kw_avg_min', u'kw_max_min', u'max_negative_polarity',        u'kw_min_min', u'avg_negative_polarity', u'weekday_is_saturday',        u'weekday_is_friday', u'weekday_is_monday',        u'abs_title_sentiment_polarity'],       dtype='object')

你会注意到参数值的方差信息没有提供。换句话说,我们不知道给定系数值的置信区间,也不知道它是否具有统计显著性。实际上,scikit-learn 回归方法不计算统计显著性测量值,对于这种推断分析——之前在第一章和本章讨论的第二种回归分析——“从数据到决策 – 开始使用分析应用”——我们将转向第二个 Python 库,statsmodels (statsmodels.sourceforge.net/)。

回归输出的统计显著性

在安装了statsmodels库之后,我们可以像之前一样执行相同的线性模型分析,使用所有数据而不是训练/测试分割。使用statsmodels,我们可以使用两种不同的方法来拟合线性模型,apiformula.api,我们使用以下命令导入:

>>> import statsmodels
>>> import statsmodels.api as sm
>>> import statsmodels.formula.api as smf

api方法首先类似于 scikit-learn 函数调用,除了在运行以下命令后,我们得到了关于模型统计显著性的更多详细输出:

>>> results = sm.OLS(news_response, news_trimmed_features).fit()
>>> results.summary()

这给出了以下输出:

回归输出的统计显著性

所有这些参数意味着什么?观测数和因变量数量可能很明显,但其他我们之前没有见过。简要来说,它们的名称和解释如下:

  • Df model:这是模型参数中的独立元素数量。我们有 57 列;一旦我们知道其中 56 个的值,最后一个就由最小化剩余误差的需要来确定,所以总共有 56 个自由度。

  • Df residuals:这是模型误差估计中独立信息块的数量。回想一下,我们通过y-X获得误差。在X中,我们只有最多m个独立的列,其中m是预测器的数量。因此,我们的误差估计从数据本身中有n-1个独立元素,从中我们减去另一个由输入决定的m,这给我们留下n-m-1

  • Covariance type:这是模型中使用的协方差类型;在这里,我们只是使用白噪声(均值为0,正态分布的误差),但我们也可以指定一个特定的结构,以适应例如误差与响应幅度相关的情形。

  • Adj. R-squared:如果我们在一个模型中包含更多的变量,我们可以通过简单地增加更多的自由度来拟合数据,从而开始增加 R2。如果我们希望公平地比较具有不同参数数量的模型的 R2,那么我们可以使用以下公式调整 R2 的计算:回归输出的统计显著性

    使用这个公式,对于具有更多参数的模型,我们通过拟合误差的量来惩罚 R2。

  • F 统计量: 这个度量用于通过卡方分布比较(任何回归系数是否在统计上与0不同)。

  • F 统计量的概率: 这是来自 F 统计量的 p 值(假设系数为0且拟合不优于仅截距模型),表明零假设(系数为0且拟合不优于仅截距模型)是真实的。

  • 对数似然: 回想一下,我们假设线性模型中残差的误差是正态分布的。因此,为了确定我们的结果是否符合这个假设,我们可以计算似然函数:回归输出的统计显著性

    其中 σ 是残差的标准差,μ 是残差的均值(根据上述线性回归假设,我们期望这个值非常接近 0)。因为乘积的对数是和,这在数值上更容易处理,所以我们通常取这个值的对数,表示为:

    回归输出的统计显著性

    虽然这个值本身并不很有用,但它可以帮助我们比较两个模型(例如具有不同系数数量的模型)。更好的拟合优度由较大的对数似然或较低的对数负似然表示。

    注意

    在实践中,我们通常最小化负对数似然,而不是最大化对数似然,因为我们可能使用的多数优化算法默认目标是最小化。

  • AIC/BIC: AIC 和 BIC 是 Akaike 信息准则和 Bayes 信息准则的缩写。这两个统计量有助于比较具有不同系数数量的模型,从而给出增加更多特征后模型复杂度增加的好处。AIC 的计算公式如下:回归输出的统计显著性

    其中 m 是模型中的系数数量,L 是似然,如前所述。更好的拟合优度由较低的 AIC 表示。因此,增加参数数量会惩罚模型,同时提高其降低 AIC 的可能性。BIC 类似,但使用以下公式:

    回归输出的统计显著性

其中 n 是模型中的数据点数量。对于 AIC 和 BIC 的更全面比较,请参阅(Burnham, Kenneth P. 和 David R. Anderson. 模型选择和多模型推断:一种实用的信息论方法. Springer Science & Business Media, 2003)。

除了这些,我们还会收到每个系数的统计显著性输出,这是通过对其标准误差的t-检验来判断的:

回归输出的统计显著性

我们还会收到一个最终的统计块:

回归输出的统计显著性

其中大部分内容超出了本卷的范围,但 Durbin-Watson (DW) 统计量将在我们讨论处理时间序列数据时变得重要。DW 统计量由以下公式给出:

回归输出的统计显著性

其中 e 是残差(在这里是线性模型的 y-Xβ)。本质上,这个统计量询问残差是正相关还是负相关。如果其值大于2,这表明存在正相关。介于12之间的值表示几乎没有相关性,其中2表示没有相关性。小于1的值表示连续残差之间的负相关。更多细节请参阅(Chatterjee, Samprit, and Jeffrey S. Simonoff. 回归分析手册. 第 5 卷. 约翰·威利父子出版社,2013 年)。

我们也可以使用formula.api命令来拟合模型,通过从输入数据构造一个表示线性模型公式的字符串。我们使用以下代码生成公式:

>>> model_formula = news_response.name+" ~ "+" + ".join(news_trimmed_features.columns)

您可以将此公式打印到控制台以验证它是否给出了正确的输出:

shares ~ timedelta + n_tokens_title + n_tokens_content + n_unique_tokens + n_non_stop_words + n_non_stop_unique_tokens + num_hrefs + num_self_hrefs + num_imgs + num_videos + average_token_length + num_keywords + data_channel_is_lifestyle + data_channel_is_entertainment + data_channel_is_bus + data_channel_is_socmed + data_channel_is_tech + data_channel_is_world + kw_min_min + kw_max_min + kw_avg_min + kw_min_max + kw_max_max + kw_avg_max + kw_min_avg + kw_max_avg + kw_avg_avg + self_reference_min_shares + self_reference_max_shares + self_reference_avg_sharess + weekday_is_monday + weekday_is_tuesday + weekday_is_wednesday + weekday_is_thursday + weekday_is_friday + weekday_is_saturday + is_weekend + LDA_01 + LDA_02 + LDA_03 + LDA_04 + global_subjectivity + global_sentiment_polarity + global_rate_positive_words + global_rate_negative_words + rate_positive_words + rate_negative_words + avg_positive_polarity + min_positive_polarity + max_positive_polarity + avg_negative_polarity + min_negative_polarity + max_negative_polarity + title_subjectivity + title_sentiment_polarity + abs_title_subjectivity + abs_title_sentiment_polarity

然后,我们可以使用这个公式来拟合包含响应变量和输入变量的完整 pandas 数据框,通过沿着它们的列(轴 1)连接响应变量和特征变量,并调用我们之前导入的公式 API 的ols方法:

>>> news_all_data = pd.concat([news_trimmed_features,news_response],axis=1)
>>> results = smf.ols(formula=model_formula,data=news_all_data).fit()

在这个例子中,假设我们拟合的模型中,以新文章特征为函数的流行度残差是独立的,这似乎是合理的。在其他情况下,我们可能在同一组输入上做出多次观察(例如,当某个客户在数据集中出现多次时),这些数据可能与时间相关(例如,当单个客户的记录在时间上更接近时,它们更有可能相关)。这两种情况都违反了我们对模型残差之间独立性的假设。在接下来的几节中,我们将介绍三种处理这些情况的方法。

广义估计方程

在我们接下来的练习中,我们将使用记录在几个学校中数学课程学生成绩的例子,这些成绩是在三个学期内记录的,用符号(G1-3)表示(Cortez, Paulo, and Alice Maria Gonçalves Silva. "Using data mining to predict secondary school student performance." (2008))。我们可能预计学生所在的学校和他们每个学期的数学成绩之间存在相关性,当我们使用以下命令绘制数据时,我们确实看到了一些证据:

>>> students = pd.read_csv('student-mat.csv',sep=';')
>>> students.boxplot(by='school',column=['G1','G2','G3'])

广义估计方程

我们可以看到,在 2 和 3 学期数学成绩下降与学校之间存在某种相关性。如果我们想估计其他变量对学生成绩的影响,那么我们想要考虑这种相关性。我们如何做到这一点取决于我们的目标。如果我们只想在总体水平上对模型的系数 β 有一个准确的估计,而无法使用我们的模型预测个别学生的响应,那么我们可以使用广义估计方程GEE)(Liang, Kung-Yee, 和 Scott L. Zeger. "使用广义线性模型进行纵向数据分析." Biometrika 73.1 (1986): 13-22)。广义估计方程的激励思想是,我们将学校与成绩之间的这种相关性视为模型中的附加参数(我们通过在数据上执行线性回归并计算残差来估计它)。通过这样做,我们考虑了这种相关性对系数估计的影响,从而获得了更好的估计值。然而,我们通常仍然假设组内的响应是可交换的(换句话说,顺序不重要),这与可能具有时间依赖成分的聚类数据的情况不符。

与线性模型不同,广义估计方程的参数估计是通过目标函数 U(β) 的非线性优化获得的,使用以下公式:

广义估计方程

其中 μ[k] 是组 k(例如,在我们的例子中是学校)的平均响应,V[k] 是方差矩阵,它给出了组 k 成员残差之间的相关性,而 Y[k]- μ[k] 是该组内的残差向量。这通常使用牛顿-拉夫森方程来解决,我们将在第五章(Chapter 5,Putting Data in its Place – Classification Methods and Analysis)中更详细地探讨。从概念上讲,我们可以使用回归的残差来估计方差矩阵 V,并优化上述公式直到收敛。因此,通过优化由 V 给出的分组数据样本之间的相关性结构以及系数 β,我们有效地获得了与 V 无关的估计 β。

要将此方法应用于我们的数据,我们再次可以使用以下命令创建模型字符串:

>>> model_formula = "G3 ~ "+" + ".join(students.columns[1:len(students.columns)-3])

然后,我们可以使用学校作为分组变量来运行广义估计方程(GEE):

>>> results = smf.gee(model_formula,"school",data=students).fit()
>>> results.summary()

然而,在某些情况下,我们可能更希望获得个体而不是总体水平的响应估计,即使在我们之前讨论的组相关性的情况下。在这种情况下,我们可以改用混合效应模型。

混合效应模型

回想一下,在本章中我们拟合的线性模型中,我们假设响应是按以下方式建模的:

混合效应模型

其中 ε 是误差项。然而,当我们有属于同一组的数据点之间的相关性时,我们也可以使用以下形式的模型:

混合效应模型

其中 Zu 分别是组别变量和系数。系数 u 的均值为 0,其方差结构需要指定。例如,它可以在组别之间不相关,或者具有更复杂的协方差关系,其中某些组别之间的相关性比其他组别更强。与 GEE 模型不同,我们并不是试图简单地估计系数的组别水平效应(在考虑了组别成员效应之后),而是控制特定组别归属效应的组内系数。混合效应模型的名字来源于变量 β 是固定效应,其值是确切已知的,而 u 是随机效应,其中 u 的值代表一个组别水平系数的观察值,这是一个随机变量。系数 u 可以是一组组别水平的截距(随机截距模型),或者与组别水平的斜率相结合(随机斜率模型)。组别甚至可以嵌套在彼此之中(分层混合效应模型),例如,如果城镇级别的组别捕捉到一种相关变化,而州级别的组别捕捉到另一种。混合效应模型的多种变体将超出本书的讨论范围,但我们建议感兴趣的读者参考以下参考文献(West, Brady T., Kathleen B. Welch, and Andrzej T. Galecki. 线性混合模型:使用统计软件的实用指南. CRC Press, 2014;Stroup, Walter W. 广义线性混合模型:现代概念、方法和应用. CRC press, 2012)。与 GEE 一样,我们可以通过包含组别变量使用以下命令来拟合此模型:

>>> results = smf.mixedlm(model_formula,groups="school",data=students).fit()
>>> results.summary()

时间序列数据

我们将要考虑的模型假设的最后一类是集群数据在时间上是相关的,例如,如果某个客户基于一周中的某一天有周期性的购买活动。虽然 GEE 和混合效应模型通常处理组间相关性可交换的数据(顺序不重要),但在时间序列数据中,顺序对于数据的解释很重要。如果我们假设可交换性,那么我们可能会错误地估计模型中的误差,因为我们假设最佳拟合线穿过给定组别数据中的中间部分,而不是遵循时间序列中重复测量的相关性结构。

一个特别灵活的时间序列数据模型使用称为卡尔曼滤波的公式。表面上,卡尔曼滤波类似于混合效应模型的方程;考虑一个在特定时间点有一个未观察到的状态,我们希望在模型中推断这个状态(例如,某个股票的价格是上升还是下降),它被噪声(例如股票价格的市场变化)所掩盖。数据点的状态由以下公式给出:

时间序列数据

其中 F 代表状态之间的转移概率矩阵,xt-1 是最后一个时间步的状态,w[t] 是噪声,而 B[t]u[t] 代表回归变量,例如可以包含季节效应。在这种情况下,u 将是一个季节或一天中的时间的二元指示符,而 β 是根据这个指示符我们应该从 x 中添加或减去的数量。状态 x 用于使用以下方法预测观察到的响应:

时间序列数据

其中 xt 是前一个方程中的状态,H 是每个潜在状态的系数集,vt 是噪声。卡尔曼滤波使用时间 t-1 的观察值来更新我们对时间 t 的潜在状态 x 和响应 y 的估计。

之前给出的方程组也被称为更通用的术语“结构时间序列方程”。对于更新方程的推导和有关“结构时间序列模型”的更多细节,我们建议读者参考更高级的参考资料(Simon, Dan. 最优状态估计:卡尔曼,H 无穷,和非线性方法。John Wiley & Sons,2006;Harvey, Andrew C. 预测,结构时间序列模型和卡尔曼滤波。Cambridge University Press,1990)。

statsmodels 包中,卡尔曼滤波用于自回归移动平均ARMA)模型,使用以下命令进行拟合:

>>> statsmodels.tsa.arima_model.ARMA()

广义线性模型

在大多数先前的例子中,我们假设响应变量可能被建模为响应的线性组合。然而,我们可以通过拟合广义线性模型来放宽这个假设。而不是以下公式:

广义线性模型

我们用一个 链接 函数(G)替换,该函数将非线性输出转换为线性响应:

广义线性模型

链接函数的例子包括:

  • Logit:这个 链接 函数将范围在 01 之间的响应映射到线性尺度,使用函数 Xβ=ln(y/1-y),其中 y 通常是在 01 之间的概率。这个 链接 函数用于逻辑回归和多项式回归,在第五章中介绍,将数据放在合适的位置——分类方法和分析

  • Poisson:这个 链接 函数使用关系 Xβ=ln(y) 将计数数据映射到线性尺度,其中 y 是计数数据。

  • 指数:这个链接函数将指数尺度上的数据映射到线性尺度,公式为 Xβ=y-1

虽然这类变换使得将许多非线性问题转化为线性问题成为可能,但它们也使得估计模型参数变得更加困难。确实,用于推导简单线性回归系数的矩阵代数不再适用,而且方程也没有任何封闭解,我们无法通过单一步骤或计算来表示。相反,我们需要像用于广义估计方程(GEE)和混合效应模型那样的迭代更新方程。我们将在第五章中更详细地介绍这类方法,将数据放在合适的位置——分类方法和分析

现在我们已经介绍了一些将模型拟合到违反线性回归假设的数据的多样情况,以便正确解释系数。现在让我们回到尝试通过选择变量子集来提高线性模型的预测性能的任务中,希望移除相关输入并减少过拟合,这种方法被称为正则化

将正则化应用于线性模型

在观察到我们的线性模型性能不佳后,一个相关的问题是,这个模型中的所有特征是否都是必要的,或者我们所估计的系数是否次优。例如,两列可能高度相关,这意味着矩阵 XTX 可能是秩亏的,因此不可逆,导致计算系数时出现数值不稳定性。或者,我们可能已经包含了足够多的输入变量,使得在训练数据上拟合得非常好,但这种拟合可能无法推广到测试数据,因为它精确地捕捉了仅在训练数据中存在的细微模式。变量的高数量使我们能够有很大的灵活性,使预测响应与训练集中的观察响应完全匹配,从而导致过拟合。在这两种情况下,对模型应用正则化可能是有帮助的。使用正则化,我们试图对系数的大小和/或数量施加惩罚,以控制过拟合和多重共线性。对于回归模型,两种最流行的正则化形式是岭回归和 Lasso 回归。

在岭回归中,我们希望将系数的大小限制在合理的水平,这是通过在损失函数方程中对系数的大小应用平方惩罚来实现的:

将正则化应用于线性模型

注意

请注意,尽管使用了相同的符号,但这个 L(β)与之前讨论的似然方程不同。

换句话说,通过将惩罚α应用于系数的平方和,我们不仅约束模型尽可能好地近似y,使用斜率β乘以特征,而且还约束系数β的大小。这种惩罚的效果由加权因子α控制。当 alpha 为0时,模型就是普通的线性回归。具有α > 0的模型会越来越多地惩罚大的β值。我们如何选择α的正确值?scikit-learn库提供了一个有用的交叉验证函数,可以使用以下命令在训练集上找到α的最优值:

>>> lmodel_ridge = linear_model.RidgeCV().fit(news_features_train, news_shares_train)
>>> lmodel_ridge.alpha_

这给出了最优的α值为0.100

然而,当我们使用以下命令评估新的R2值时,这种改变似乎不会影响测试集上的预测准确性:

>>> lmodel_ridge.score(news_features_test, news_shares_test)

事实上,我们得到了与原始线性模型相同的结果,该模型给出了测试集R20.109

另一种正则化方法被称为 Lasso,其中我们最小化以下方程。它与上面的岭回归公式类似,不同之处在于β值的平方惩罚已被绝对值项所取代。

将正则化应用于线性模型

这种绝对值惩罚的实际效果是许多斜率被优化为零。如果我们有很多输入并且希望只选择最重要的输入以尝试得出见解,这可能是有用的。它也可能有助于在两个变量彼此高度相关的情况下,我们选择其中一个变量包含在模型中。像岭回归一样,我们可以使用以下交叉验证命令找到α的最优值:

>>> lmodel_lasso = linear_model.LassoCV(max_iter=10000).fit(news_features_train, news_shares_train)
>>> lmodel_lasso.alpha_

这表明最优的α值为6.25e-5

在这种情况下,将这种类型的惩罚应用于模型似乎没有太多价值,因为最优的α值接近于零。综合上述分析,上述分析表明,修改系数本身并没有帮助我们的模型。

除了拟合优度的改进,我们可能会使用岭回归还是 Lasso,还有什么可以帮助我们做出决定?一个权衡是,虽然 Lasso 可能会生成一个更稀疏的模型(更多系数被设置为0),但结果系数的值难以解释。给定两个高度相关的变量,Lasso 会选择其中一个,而将另一个缩小到0,这意味着通过对基础数据进行一些修改(从而对选择这些变量之一产生偏差)我们可能会选择一个不同的变量进入模型。虽然岭回归不会出现这个问题,但缺乏稀疏性可能会使得解释输出更加困难,因为它不倾向于从模型中移除变量。

弹性网络回归(Zou, Hui, 和 Trevor Hastie. "通过弹性网络进行正则化和变量选择." 《皇家统计学会会刊:系列 B(统计方法)》 67.2 (2005): 301-320)在这两种选择之间提供了平衡。在弹性网络中,我们的惩罚项变成了岭回归和 Lasso 的混合,最优的β值最小化:

对线性模型应用正则化

由于这种修改,弹性网络可以选出相关变量组,同时将许多变量缩小到零。像岭回归和 Lasso 一样,弹性网络有一个 CV 函数来选择两个惩罚项α的最优值,使用以下方法:

>>> from sklearn.linear_model import ElasticNetCV
>>> lmodel_enet = ElasticNetCV().fit(news_features_train, news_shares_train)
>>> lmodel_enet.score(news_features_test, news_shares_test)

然而,这仍然没有显著提高我们模型的性能,因为测试 R2 仍然没有从我们的原始最小二乘回归中移动。可能是因为响应没有被涉及输入组合的线性趋势很好地捕捉。可能存在某些特征之间的交互作用,这些交互作用不是任何单个变量的系数所表示的,并且某些变量可能有非线性响应,例如:

  • 非线性趋势,例如预测变量线性增加时响应的对数增加

  • 非单调(增加或减少)函数,例如抛物线,在预测变量值范围的中间有较低的响应,在最小值和最大值处有较高的值

  • 更复杂的多模态响应,例如三次多项式

尽管我们可以尝试使用上面描述的广义线性模型来捕捉这些模式,但在大型数据集中,我们可能难以找到一个能够有效捕捉所有这些可能性的转换。我们可能还会通过例如将每个输入变量相乘来生成“交互特征”,从而产生 N(N-1)/2 个额外的变量(对于所有输入变量之间的成对乘积)。虽然这种方法,有时被称为“多项式展开”,有时可以捕捉到原始模型中遗漏的非线性关系,但随着特征集的增大,这最终可能变得难以控制。相反,我们可能尝试探索可以高效探索可能变量交互空间的方法。

树方法

在许多数据集中,我们的输入和输出之间的关系可能不是一条直线。例如,考虑一天中的小时数和社交媒体发帖概率之间的关系。如果你绘制这个概率的图表,它可能会在傍晚和午餐时间增加,在夜间、早晨和工作日减少,形成一个正弦波模式。线性模型无法表示这种关系,因为响应的值并不严格随着一天中的小时数增加或减少。那么,我们可以使用哪些模型来捕捉这种关系呢?在特定的时间序列模型中,我们可以使用上述描述的卡尔曼滤波器等方法,使用结构时间序列方程的组成部分来表示社交媒体活动的 24 小时循环模式。在下一节中,我们将探讨更通用的方法,这些方法将适用于时间序列数据以及更通用的非线性关系。

决策树

考虑这样一个案例,当我们为在社交媒体上发帖的概率分配一个值,当小时数大于上午 11 点且小于下午 1 点,大于下午 1 点且小于下午 6 点,以此类推。我们可以将这些看作是一棵树的分支,在每个分支点上都有一个条件(例如小时数小于下午 6 点),并将我们的输入数据分配到树的某个分支。我们继续这种分支,直到达到一系列此类选择的末端,称为树的“叶子”;树的预测响应是最后这个组中训练数据点值的平均值。为了预测新数据点的响应,我们沿着树的分支走到底部。因此,要计算决策树,我们需要以下步骤:

  1. 从特征X和响应y的训练集开始。

  2. 找到X的列以及分割点,这可以优化数据点之间的分割。我们可以优化几个标准,例如决策边界两侧目标响应的方差(参见后续的分割函数)。(Breiman, Leo, 等人. 分类与回归树. CRC 出版社,1984 年)。我们根据这个规则将训练数据点分配到两个新的分支。

  3. 重复步骤 2,直到达到停止规则,或者树的最终分支中只剩下一个值。

  4. 预测响应是最终落在树中特定分支的训练点的平均响应。

如前所述,每次我们在树模型中选择一个分割点时,我们需要一个原则性的方法来确定哪个候选变量比另一个变量更适合将数据分成具有更多相关响应的组。有几个选项。

方差减少衡量在分割数据后形成的两个组在响应变量 y 中的方差是否比整体数据低,并在决策树的分类和回归树(CART)算法中使用。它可以按以下方式计算:

决策树

其中 A 是分割前的所有数据点的集合,L 是落在分割左侧的值的集合,而 R 是落在分割右侧的点的集合。当分割点的两侧的联合方差小于原始数据的方差时,此公式得到优化。

减少方差对于本章所探讨的问题将最为有效,其中输出是一个连续变量。然而,在具有分类结果的分类问题中,例如我们将在第五章中探讨的,数据定位 – 分类方法和分析,方差变得不那么有意义,因为数据只能假设固定数量的值(对于特定类别为 1 或 0)。我们可能还需要优化的另一个统计量是“信息增益”,它在构建决策树的迭代二分器 3(ID3)和 C4.5 算法中使用(Quinlan, J. Ross. C4. 5: programs for machine learning. Elsevier, 2014; Quinlan, J. Ross. "Induction of decision trees." Machine learning 1.1 (1986): 81-106)。信息增益统计量询问在决策分割后,左侧和右侧的数据是否变得更加相似或不同。如果我们认为响应 y 是一个概率,那么信息增益的计算如下:

决策树

其中 α 是分割到分割左侧的数据的分数,f[Ak],f[Lk],和 f[Rk] 是在所有数据点、分割的左侧和右侧中类别 k 的元素分数。这个方程式的三个项被称为熵(Borda, Monica. Fundamentals in information theory and coding. Springer Science & Business Media, 2011)。为什么熵反映了数据的良好分割?为了看到这一点,使用以下方式绘制函数 ylog2y01 的值:

>>> probs = np.arange(0.01,1,0.01)
>>> entropy = [ -1*np.log2(p)*p for p in probs]
>>> plt.plot(probs,entropy)
>>> plt.xlabel('y')
>>>plt.ylabel('Entropy')

看看结果:

决策树

你可以欣赏到,当 y 接近 01 时,熵会下降。这对应于在分类问题中特定类别的非常高的概率或低概率,因此根据信息增益分割数据的树将最大化左右分支趋向或反对给定类别的概率程度。

类似地,CART 算法(Breiman, Leo, et al. 分类和回归树. CRC press, 1984 年。)也使用基尼不纯度来决定分割点,计算如下:

决策树

检查这个公式,你可以看到当一类接近值f = 1时,它将被最大化,而所有其他类都是0

我们如何处理此类模型中的空值和缺失数据?在 scikit-learn 中,当前的决策树实现不兼容缺失值,因此我们需要插入一个占位符值(例如-1),删除缺失记录,或者进行插补(例如,用列均值替换)(详见旁注以获取更多详细信息)。然而,某些实现(如 R 统计编程语言的gbm包)将缺失数据视为一个第三分支,数据将被排序到这个分支中。

在处理分类数据时,也存在类似的多样性。当前的 scikit-learn 实现只期望数值列,这意味着性别或国家等分类特征需要被编码为二进制指示符。然而,其他包,如 R 语言中的实现,通过根据特征值将数据分配到桶中,然后按平均响应对桶进行排序,来处理分类数据,以确定将哪些桶分配给树的左右分支。

提示

旁注处理缺失数据

在处理数据中的缺失值时,我们需要考虑几种可能性。一种是数据是否是随机缺失,或者非随机缺失。在前一种情况下,响应变量与数据缺失之间存在相关性。我们可以分配一个虚拟值(例如-1),从我们的分析中删除包含缺失数据的整个行,或者分配列均值或中位数作为占位符。我们还可以考虑更复杂的方法,例如训练一个回归模型,使用所有其他输入变量作为预测变量,将包含缺失数据的列作为输出响应,并使用该模型的预测来推导出插补值。如果数据是非随机缺失的,那么简单地用占位符编码数据可能是不够的,因为占位符值与响应相关。在这种情况下,我们可能会删除包含缺失数据的行,或者如果这不可能,则采用基于模型的方法。这将更适合推断数据中缺失元素的价值,因为它应该预测与列中其余部分相同的分布。

在实践中,在构建树的过程中,我们通常有一些停止规则,例如形成叶节点所需的最小观察数(否则预测响应可能来自少数几个数据点,这通常会增加预测误差)。

一开始并不清楚树应该分支多少次。如果分支太少(决策点),那么可以应用于细分数据集的规则就很少,模型的最终准确率可能较低。如果在一棵非常深的树中有太多的分支,那么模型可能无法很好地推广到新的数据集。对于我们的例子,让我们尝试将树拟合到不同的深度:

>>> from sklearn.tree import DecisionTreeRegressor
>>> max_depths = [2,4,6,8,32,64,128,256]
>>> dtrees = []
>>> for m in max_depths:
…    dtrees.append(DecisionTreeRegressor(min_samples_leaf=20,max_depth=m).\
…    fit(news_features_train, news_shares_train))

现在,我们可以通过绘制每个模型的 R2 值与树深度之间的关系来评估结果:

>>> r2_values = []
>>> for d in dtrees:
…    r2_values.append(d.score(news_features_test, news_shares_test))
>>> plt.plot(max_depths,r2_values,color='red')
>>> plt.xlabel('maximum depth')
>>> plt.ylabel('r-squared')

查看测试集上的性能,我们可以看到,一旦我们将树做得太深,性能提升就会迅速下降:

决策树

不幸的是,树模型的表现仍然没有比我们的基本线性回归好多少。为了尝试改进这一点,我们可以尝试增加树的数量而不是树的深度。这里的直觉是,一组较浅的树结合起来可能能够捕捉到单个深树难以近似的复杂关系。这种方法,即使用组合的小模型来拟合复杂关系,在下一节讨论的随机森林算法中,以及在梯度提升决策树(第五章, 将数据放在合适的位置 – 分类方法和分析)和从下往上学习(第七章, 从底部学习 – 深度网络和无监督特征)中,以及在某种程度上,在我们将在第七章讨论的深度学习模型中都有应用。

随机森林

虽然捕捉非线性关系的想法看起来合理,但可能很难构建一个能够捕捉输入和输出之间如此复杂关系的单个树。如果我们对许多简单的决策树进行平均呢?这就是随机森林算法的精髓(Ho, Tin Kam. "随机决策森林." 《文档分析与识别,1995 年,第三届国际会议论文集》 第 1 卷. IEEE, 1995 年;Breiman, Leo. "随机森林." 《机器学习》 45.1 (2001): 5-32),在这个算法中,我们构建多个树来尝试探索可能的非线性交互空间。

随机森林是对树模型(Breiman, Leo. "Bagging predictors." Machine learning 24.2 (1996): 123-140.)的 Bootstrap Aggregation(Bagging)概念的进一步创新。在通用的 Bagging 算法中,我们通过从训练数据中采样(有放回地)选取少量数据点,并仅在这部分数据上构建一棵树,来构建大量树。虽然单个树可能相对较弱,但通过平均大量树,我们通常可以实现更好的预测性能。从概念上讲,这是因为我们不是试图通过单个模型(如单条线)来拟合响应,而是使用多个小模型来近似响应,每个小模型拟合输入数据中的单个简单模式。

随机森林通过随机化不仅用于构建每棵树的每个数据点,还包括变量,对 Bagging 的概念进行了进一步的发展。在构建树中的分割时,我们也在每一步只考虑 X 的列的随机子集(例如,大小等于总列数的平方根)。如果我们每个训练轮次都使用所有输入列,我们往往会选择与响应最强烈相关的变量。通过随机选择变量子集,我们还可以发现较弱预测器之间的模式,并更广泛地覆盖可能的特征交互空间。与 Bagging 一样,我们多次遵循随机数据和变量选择的过程,然后将所有树的预测平均在一起以得到总体预测。同样,我们可以探索是否改变一个参数(树的数量)可以提高测试集上的性能:

>>> from sklearn import ensemble
>>> rforests = []
>>> num_trees = [2,4,6,8,32,64,128,256]
>>> for n in num_trees:
 …   rforests.\
 …   append(ensemble.RandomForestRegressor(n_estimators=n,min_samples_leaf=20).\
 …   fit(news_features_train, news_shares_train))

最后,当我们使用以下代码绘制结果时,我们可以开始看到模型准确性的某些提高:

>>> r2_values_rforest = []
>>> for f in rforests:
 …   r2_values_rforest.append(f.score(news_features_test, news_shares_test))
>>> plt.plot(num_trees,r2_values_rforest,color='red')
>>> plot.xlabel('Number of Trees')
>>> plot.ylabel('r-squared')

随机森林

与线性回归模型一样,我们可以得到特征重要性的排名。对于线性回归来说,它只是斜率的幅度,而在随机森林模型中,特征的重要性是以更复杂的方式确定的。直观地说,如果我们对数据集中特定列的行值进行洗牌,如果该列很重要,它应该会降低模型的性能。通过测量这种排列的平均效应,并将其除以这种效应的标准差,我们可以得到一个变量对模型性能影响的幅度和一致性的排名。通过按这种随机化对准确度的影响程度对变量进行排名,我们可以得出特征显著性的度量。我们可以使用以下命令检查重要变量,以选择最大的随机森林的特征重要性值。由于np.argsort命令默认按升序返回列表,我们使用[::-1]切片来反转列表顺序,将大系数值放在前面。

>>> ix = np.argsort(abs(f[5].feature_importances_))[::-1]
>>> news_trimmed_features.columns[ix]

这给出了以下结果:

 Index(['kw_avg_avg', 'self_reference_avg_sharess', 'timedelta', 'LDA_01',        'kw_max_avg', 'n_unique_tokens', 'data_channel_is_tech', 'LDA_02',        'self_reference_min_shares', 'n_tokens_content', 'LDA_03', 'kw_avg_max',        'global_rate_negative_words', 'avg_negative_polarity',        'global_rate_positive_words', 'average_token_length', 'num_hrefs',        'is_weekend', 'global_subjectivity', 'kw_avg_min',        'n_non_stop_unique_tokens', 'kw_min_max', 'global_sentiment_polarity',        'kw_max_min', 'LDA_04', 'kw_min_avg', 'min_positive_polarity',        'num_self_hrefs', 'avg_positive_polarity', 'self_reference_max_shares',        'title_sentiment_polarity', 'max_positive_polarity', 'n_tokens_title',        'abs_title_sentiment_polarity', 'abs_title_subjectivity',        'title_subjectivity', 'min_negative_polarity', 'num_imgs',        'data_channel_is_socmed', 'rate_negative_words', 'num_videos',        'max_negative_polarity', 'rate_positive_words', 'kw_min_min',        'num_keywords', 'data_channel_is_entertainment', 'weekday_is_wednesday',        'data_channel_is_lifestyle', 'weekday_is_friday', 'weekday_is_monday',        'kw_max_max', 'data_channel_is_bus', 'data_channel_is_world',        'n_non_stop_words', 'weekday_is_saturday', 'weekday_is_tuesday',        'weekday_is_thursday'],       dtype='object')

有趣的是,如果你将这个列表与线性回归模型进行比较,顺序相当不同。令人鼓舞的是,这表明随机森林能够结合线性回归无法捕捉到的模式,从而导致了本节中看到的增益。

在这个数据集中也存在一个相对微妙的问题,即所有分类变量都使用二进制标志进行编码。因此,变量重要性是单独应用于每个类别的成员。如果一个类别的成员与响应高度相关,而另一个则不是,这些个别变量的重要性度量将给出一个关于真实变量重要性的不准确图景。一个解决方案是对所有类别中的结果值进行平均,这是一个我们现在不会应用但会作为你未来分析考虑的修正。

在这里,我们提供了一个视觉流程图,说明了我们在本章关于回归分析中讨论的许多权衡。虽然很难为所有场景提供全面的规则,但它可以作为诊断给定问题应应用哪种方法的起点:

随机森林

回归分析流程图

使用 PySpark 进行扩展 - 预测歌曲发布年份

最后,让我们通过另一个使用 PySpark 的例子来结束。在这个数据集(Bertin-Mahieux, Thierry, et al. "The million song dataset." ISMIR 2011: Proceedings of the 12th International Society for Music Information Retrieval Conference, October 24-28, 2011, Miami, Florida. University of Miami, 2011)中,这是一个百万首歌曲数据集的子集,目标是根据歌曲的轨道特征预测歌曲的发行年份。数据以逗号分隔的文本文件的形式提供,我们可以使用 Spark 的textFile()函数将其转换为 RDD。像我们之前的聚类示例一样,我们也定义了一个带有try…catch块的解析函数,这样我们就不至于在一个大型数据集中因为单个错误而失败:

>>> def parse_line(l):
…      try:
…            return l.split(",")
…    except:
…         print("error in processing {0}".format(l))

然后,我们使用此函数将每一行映射到解析格式,该格式将逗号分隔的文本拆分为单个字段,并将这些行转换为 Spark DataFrame:

>>> songs = sc.textFile('/Users/jbabcock/Downloads/YearPredictionMSD.txt').\
map(lambda x : parse_line(x)).\
toDF()

由于我们将结果 RDD 转换为 DataFrame,以便我们可以像在 Python 中访问列表或向量一样访问其元素。接下来,我们想要将其转换为LabeledPoint RDD,就像我们在上一章的 Streaming K-Means 示例中所做的那样:

>>> from pyspark.mllib.regression import LabeledPoint
>>> songs_labeled = songs.map( lambda x: LabeledPoint(x[0],x[1:]) )

作为该数据集文档的一部分,我们假设训练数据(不包括测试集中出现的艺术家的曲目)包含在前 463,715 行中,其余的是测试数据。为了分割它,我们可以使用zipWithIndex函数,该函数为分区中的每个元素分配一个索引,并在分区之间:

>>> songs_train = songs_labeled.zipWithIndex().\
filter( lambda x: x[1] < 463715).\
map( lambda x: x[0] )
>>> songs_test = songs_labeled.zipWithIndex().\
filter( lambda x: x[1] >= 463715).\
map( lambda x: x[0] )

最后,我们可以使用以下命令在此数据上训练一个随机森林模型:

>>> from pyspark.mllib.tree import RandomForest
>>> rf = RandomForest.trainRegressor(songs_train,{},50,"auto","variance",10,32)
>>> prediction = rf.predict(songs_test.map(lambda x: x.features))
>>> predictedObserved = songs_test.map(lambda lp: lp.label).zip(prediction)

为了评估结果的模型准确性,我们可以使用RegressionMetrics模块:

>>> from pyspark.mllib.evaluation import RegressionMetrics
>>> RegressionMetrics(predictedObserved).r2

PySpark 的分布式特性意味着这项分析将在您电脑上的单个示例文件上运行,同时在一个更大的数据集(例如完整的百万首歌曲)上运行,所有这些都将使用相同的代码。如果我们想保存随机森林模型(例如,如果我们想将特定日期的模型存储在数据库中以供将来参考,或者将此模型分发到多台机器上,从序列化格式中加载),我们可以使用toString()函数,该函数可以使用 gzip 进行潜在压缩。

摘要

在本章中,我们探讨了几个回归模型的拟合,包括将输入变量转换为正确的尺度以及正确考虑分类特征。在解释这些模型的系数时,我们考察了线性回归的经典假设得到满足和被违反的情况。在后一种情况下,我们考察了广义线性模型、广义估计方程(GEE)、混合效应模型和时间序列模型作为我们分析的替代选择。在尝试提高回归模型准确性的过程中,我们拟合了简单和正则化的线性模型。我们还考察了基于树的回归模型的使用以及如何优化拟合这些模型时的参数选择。最后,我们考察了在 PySpark 中使用随机森林的例子,这可以应用于更大的数据集。

在下一章中,我们将探讨具有离散分类结果的而非连续响应的数据。在这个过程中,我们将更详细地研究不同模型的似然函数是如何优化的,以及用于分类问题的各种算法。

第五章:将数据放在合适的位置——分类方法和分析

在上一章中,我们探讨了分析结果为连续变量的数据的方法,例如客户账户的购买量或订阅服务取消前预期的天数。然而,商业分析中的许多结果都是离散的——它们可能只取有限数量的值。例如,电影评论可以是 1 到 5 星(但只有整数),客户可以取消或续订订阅,或者在线广告可以被点击或忽略。

用于建模和预测此类数据的方法与我们上一章中讨论的回归模型类似。此外,有时我们可能希望将回归问题转换为分类问题:例如,我们可能更感兴趣的是预测客户在一个月内的消费模式是否超过了一个从商业角度来看有意义的阈值,并在我们的训练数据中将值分配为 0(低于阈值)和 1(高于),根据这个截止点。在某些情况下,这可能会增加我们分类中的噪声:想象一下,如果许多客户的个人支出接近我们为该模型设置的阈值,这将使得学习一个准确模型变得非常困难。在其他情况下,使结果离散将帮助我们聚焦于我们感兴趣回答的问题。想象一下,如果客户支出数据在阈值上下都很好地分离,但在截止点以上存在广泛的数值变化。在这种情况下,回归模型会尝试通过拟合较大数据点的趋势来最小化模型的整体误差,这些数据点不成比例地影响总误差值,而不是实现我们实际的目标,即识别高消费和低消费客户。

除了这些考虑因素之外,一些数据本身就不适合通过回归分析进行有效建模。例如,考虑这样一个场景:我们试图预测在五则广告中,客户最有可能点击哪一则。我们可以用 1 到 5 的数值来编码这些广告,但它们没有自然的顺序,这在回归问题中是没有意义的——2 并不比 1 大,它仅仅是一个标签,表示广告属于五个类别中的哪一个。在这种情况下,将数据集的标签编码为一个长度为5的向量,并在对应广告的列中放置一个1,从算法的角度来看,这将使所有标签都等价。

考虑到这些点,在接下来的练习中,我们将涵盖以下内容:

  • 将数据响应编码为分类结果

  • 使用平衡和倾斜数据构建分类模型

  • 评估分类模型的准确性

  • 评估不同分类方法的优缺点

逻辑回归

我们将开始探索分类算法,从最常用的分类模型之一:逻辑回归。逻辑回归类似于第四章连接点与模型 - 回归方法中讨论的线性回归方法,主要区别在于它不是直接计算输入的线性组合,而是通过一个函数压缩线性模型的输出,使得输出被限制在 [0,1] 范围内。我们将看到,这实际上是一种“我们上一次在第四章连接点与模型 – 回归方法中讨论的广义线性模型”,回想一下,在线性回归中,预测输出由以下公式给出:

逻辑回归

其中 Y 是数据集所有 n 个成员的响应变量,X 是一个 nm 列的矩阵,表示每行数据的 m 个特征,βT 是一个 m 个系数的列向量(回想一下,T 运算符代表向量或矩阵的转置。在这里,我们转置系数,使其维度为 mx1,这样我们就可以与矩阵 X(其维度为 nxm)形成乘积),它给出了对于特定特征的 1 单位变化预期的响应变化。因此,通过计算 Xβ 的点积(将每个系数与其对应特征相乘并按特征求和)可以得到预测的响应。在逻辑回归中,我们开始时使用的是以下公式:

逻辑回归

其中逻辑函数为:

逻辑回归

您可以通过在笔记本会话中使用以下代码来绘制逻辑函数的行为:

>>> %matplotlib inline
… import pandas as pd
… import matplotlib.pyplot as plt
… import numpy as np
… plt.style.use('ggplot')
>>> input = np.arange(-10,10,1)
>>> output = 1/(1+np.exp(-input))
>>> pd.DataFrame({"input":input,"output":output}).plot(x='input',y='output')

逻辑回归

图 1:连续输入的逻辑函数输出

如图 1 所示,逻辑函数通过使用 S 形函数(sigmoid)将线性回归的输出进行转换:随着线性回归值变大,指数项趋向于 0,使得输出为 1。相反,当线性回归值变负时,指数项变得非常大,输出变为 0

在模型不再模拟简单的线性趋势的情况下,我们如何解释这个模型中的系数?由于逻辑变换,系数不再代表预测变量每增加 1 单位时响应的预期增加。为了发展类似的解释,我们从观察开始,即逻辑回归方程表示给定观察值x属于类别1的概率(假设数据中的响应变量分为两个类别——见以下关于类别数大于2的情况的讨论)。我们也可以写出类似的方程来表示给定观察值属于类别0的概率,该概率如下:

逻辑回归

现在,我们可以取这两个概率的自然对数,最终得到:

逻辑回归

换句话说,线性响应的结果现在表示的是类别1和类别0之间概率比的自然对数。这个量也被称为对数几率或 logit 函数,并且等同于逻辑函数的逆。在这个公式中,系数β的 1 单位变化将导致对数几率的 1 单位增加,这为我们提供了解释这个模型中系数的方法。

您可能还记得来自第四章通过模型连接点 – 回归方法的内容,在广义线性模型GLMs)中,连接函数将线性响应转换为非线性范围。在逻辑回归中,logit 函数是连接函数。虽然对各种类型 GLM 的全面讨论超出了本书的范围,但我们建议感兴趣的读者参考更全面的主题处理(Madsen, Henrik, 和 Poul Thyregod. 广义和广义线性模型导论. CRC Press, 2010; Madsen, Henrik, 和 Poul Thyregod. 广义和广义线性模型导论. CRC Press, 2010: Hardin, James William, Joseph M. Hilbe, 和 Joseph Hilbe. 广义线性模型及其扩展. Stata press, 2007.)。

如果你仔细阅读,你可能会意识到我们在上面的讨论中自相矛盾。一方面,我们希望拟合只有允许的结果是01的数据。另一方面,我们的逻辑函数(和对数几率)可以取01之间的值,连续变化。因此,为了正确应用此模型,我们需要在01之间选择一个阈值来分类回归的输出:如果值高于此阈值,我们将其视为类别1,否则为0。最简单的阈值选择是半数,实际上对于正例和反例数量相等的平衡数据集,这是一个合理的选择。然而,在现实世界中我们遇到的许多情况下(例如广告点击或订阅),正例的数量远少于反例。如果我们使用这样的不平衡数据集优化逻辑回归模型,最佳参数将识别出很少的观察结果为正例。因此,使用半数作为截止值将不准确地分类许多反例为类别 1(正例),并导致高误报率。

我们有几个选项来解决数据中类别不平衡的问题。第一个选项是简单地调整逻辑函数的阈值,将其结果视为 1,这可以通过接收器操作特征ROC)曲线进行视觉调整,以下练习中将有更详细的描述。我们也可以重新平衡我们的训练数据,使得一半代表一个合理的值,通过选择相同数量的正例和反例。如果我们担心在许多反例中做出有偏的选择,我们可以多次重复这个过程并平均结果——这个过程被称为 Bagging,在第四章“通过模型连接点——回归方法”中描述得更为详细,这是在随机森林回归模型的背景下。最后,我们可以通过在误差函数中为它们分配一个大于更多反例的权重来简单地惩罚少数正例的误差。关于重新加权的更多细节将在以下内容中呈现。

多类逻辑分类器:多项式回归

虽然到目前为止我们只处理了简单的双类问题示例,但我们可以想象存在多个类别的场景:例如,预测客户在在线商店中选择的一组商品中的哪一个。对于这类问题,我们可以想象将逻辑回归扩展到K个类别,其中K > 2。回想一下,将 e 的对数函数的幂取值给出:

多类逻辑分类器:多项式回归

在两分类问题中,这个值比较的是 Y=1 的概率与其他所有值的比率,唯一的其他值是 0。我们可以想象运行一系列针对 K 个类别的逻辑回归模型,其中 e(Logit(x)) 给出 Y = class k 的概率与其他任何类别的比率。然后我们将得到一系列关于 e(Xβ)K 个表达式,具有不同的回归系数。因为我们希望将结果约束在 0–1 的范围内,我们可以使用以下公式将任何 K 个模型的输出除以所有 K 个模型的总和:

多元逻辑分类器:多项式回归

这个方程也称为 softmax 函数。它在神经网络模型(我们将在第七章 从底部学习 – 深度网络和无监督特征 中介绍)中得到广泛的应用。它有一个很好的特性,即即使对于给定类别 k 的 e(xβ) 的极端值,函数的整体值也不会超过 1。因此,我们可以在数据集中保留异常值,同时限制它们对模型整体准确性的影响(因为否则它们会倾向于主导错误函数的整体值,例如我们在第四章 使用模型连接点 – 回归方法 中使用的平方误差)。

为了使当前演示更加简单,我们将在以下练习中仅考察一个两分类问题。然而,请记住,就像逻辑回归一样,以下讨论的其他方法也可以扩展到处理多个类别。此外,我们将在第七章 从底部学习 – 深度网络和无监督特征 中演示一个完整的多元分类问题,使用神经网络。

现在我们已经介绍了逻辑回归是什么以及它旨在解决的问题,让我们准备一个数据集以供此以及其他分类方法使用。除了处理拟合和解释逻辑回归模型的实际例子外,我们还将以此作为起点来检查其他分类算法。

为分类问题格式化数据集

在这个例子中,我们将使用一个人口普查数据集,其中行代表美国成年公民的特征(Kohavi, Ron. 提高朴素贝叶斯分类器的准确性:决策树混合. KDD. 第 96 卷. 1996 年)。目标是预测个人的年收入是否高于或低于平均年收入 55,000 美元。让我们首先使用以下命令将数据集加载到 pandas 数据框中,并检查前几行:

>>> census = pd.read_csv('census.data',header=None)
>>> census.head()

数据集格式化以用于分类问题

为什么我们使用参数(header = None)来加载数据?与我们在前几章中检查的一些其他数据集不同,人口普查数据的列名包含在一个单独的文件中。这些特征名称将有助于解释结果,因此让我们从数据集描述文件中解析它们:

>>> headers_file = open('census.headers')
>>> headers = []
>>> for line in headers_file:
>>>    if len(line.split(':'))>1: # colon indicates line is a column description
>>>        headers.append(line.split(':')[0]) # the column name precedes the colon
>>> headers = headers[15:] # the filter in the if (…) statement above is not 100 percent accurate, need to remove first 15 elements
>>> headers.append('income') # add label for the response variable in the last column
>>> census.columns = headers # set the column names in the dataframe to be extracted names

现在我们已经将列名附加到数据集中,我们可以看到响应变量,收入,需要重新编码。在输入数据中,它被编码为字符串,但由于 scikit-learn 无法接受字符串作为输入,我们需要使用以下代码将其转换为01标签:

>>> census.income = census.income.map( lambda x: 0 if x==' <=50K' else 1)

在这里,我们使用 lambda 表达式将匿名函数(在程序的其他部分没有定义名称的函数)应用于数据。map(…)调用中的条件表达式将x作为输入并返回01。我们也可以正式定义这样的函数,但对于我们不想重用的表达式,lambda 表达式提供了一种简单的方式来指定这种转换,而不会使我们的代码充斥着许多没有通用用途的函数。

让我们花点时间看看不同收入等级的分布,通过绘制一个以收入为垂直轴值的直方图来观察:

>>> census.plot(kind='hist', y='income')

数据集格式化以用于分类问题

注意,标签为1的观测值大约比标签为0的观测值少 50%。正如我们之前讨论的,这是一个简单地将一半作为评估类概率的阈值会导致模型不准确的情况,我们在评估性能时应该记住这种数据偏差。

除了我们的结果变量外,这个数据集中许多特征也是分类的:在拟合模型之前,我们还需要将它们重新编码。我们可以分两步来做:首先,让我们找出这些列中每列的唯一元素数量,并使用字典将它们映射到整数值。为此,我们检查数据框中的每一列是否为分类类型(dtype等于object),如果是,我们将它的索引添加到我们想要转换的列的列表中:

>>> categorical_features = [e for e,t in enumerate(census.dtypes) if t=='object' ]

现在我们已经得到了我们想要转换的列号,我们需要将每个列从字符串映射到从1k的标签,其中k是类别的数量:

>>> categorical_dicts = []
>>> for c in categorical_features:
>>>    categorical_dicts.append(dict( (i,e) for (e,i) in enumerate(census[headers[c]].unique()) ))

注意,我们首先提取每列的唯一元素,然后使用enumerate函数对这个唯一元素列表进行操作以生成我们需要的标签。通过将这个索引列表转换为字典,其中键是列的唯一元素,值是标签,我们就得到了重新编码这个数据集中分类字符串变量为整数的精确映射。

现在我们可以使用上面生成的映射字典创建数据的第二个副本:

>>> census_categorical = census
>>> for e,c in enumerate(categorical_features):
>>>   census_categorical[headers[c]] = \ census_categorical[headers[c]].\
map(categorical_dicts[e].get)

现在,我们可以使用 scikit-learn 的一热编码器将这些整数值转换成一系列列,其中只有一列被设置为1,表示这一行属于 k 个类别中的哪一个。为了使用一热编码器,我们还需要知道每一列有多少个类别,我们可以通过以下命令通过存储每个映射字典的大小来实现:

>>> n_values = [len(d) for d in categorical_dicts] 

然后我们应用一热编码器:

>>> from sklearn.preprocessing import OneHotEncoder
>>> census_categorical_one_hot = OneHotEncoder(categorical_features=categorical_features, n_values=n_values).fit_transform(census_categorical[headers[:-1]])

从这里我们有了适合我们的逻辑回归的正确格式的数据。正如我们在第四章中的例子一样,通过模型连接点——回归方法,我们需要将我们的数据分成训练集和测试集,指定测试集中数据的比例(0.4)。我们还设置了随机数生成器的种子为 0,这样我们可以在以后通过生成相同的随机数集来重复分析:

>>> from scipy import sparse
>>> from sklearn import cross_validation
>>> census_features_train, census_features_test, census_income_train, census_income_test = \
>>> cross_validation.train_test_split(census_categorical_one_hot, \
>>> census_categorical['income'], test_size=0.4, random_state=0)

现在我们已经准备好了训练数据和测试数据,我们可以将逻辑回归模型拟合到数据集上。我们如何找到这个模型的最优参数(系数)?我们将检查两个选项。

第一种方法,被称为随机梯度下降SGD),计算在给定数据点上的误差函数的变化,并调整参数以考虑这种误差。对于单个数据点,这会导致拟合不良,但如果我们多次在整个训练集上重复这个过程,系数将收敛到所需的值。这个方法名称中的随机一词指的是这种优化是通过在数据集上以随机顺序跟随损失函数相对于给定数据点的梯度(一阶导数)来实现的。像这样随机的方法通常可以很好地扩展到大型数据集,因为它们允许我们只单独或以小批量检查数据,而不是一次利用整个数据集,这使得我们可以并行化学习过程或至少在处理大量数据时不会使用我们机器上的所有内存。

与之相反,scikit-learn 中默认实现的逻辑回归函数的优化方法被称为二阶方法。SGD(随机梯度下降),因为它使用误差函数的一阶导数来调整模型参数值,所以被称为一阶方法。在第一导数变化非常缓慢的情况下,二阶方法可能是有益的,正如我们将在下面看到的那样,以及在误差函数遵循复杂模式的情况下寻找最优值。

让我们更详细地看看这些方法中的每一个。

使用随机梯度下降进行点更新学习

我们如何使用随机更新找到我们的逻辑回归模型的最优参数?回想一下,我们正在尝试优化概率:

使用随机梯度下降进行点更新学习

如果我们想要优化数据集中每个单独点的概率,我们希望最大化方程的值,这个方程被称为似然,因为它根据模型评估给定点属于类别1(或0)的概率;

使用随机梯度下降学习点更新

您可以看到,如果真实标签yi1,并且模型给出高概率1,那么我们就会最大化F(zi)的值(因为第二项的指数是0,使其为1,而乘积中的第一项仅仅是F(zi)的值)。相反,如果yi的真实标签是0,那么我们希望模型最大化(1-F(zi))的值,这是模型下类别0的概率。

因此,每个点将通过其实际类别的概率对似然做出贡献。通常,处理和比处理乘积更容易,因此我们可以对似然方程取对数,并使用以下方式对数据集中的所有元素进行求和:

使用随机梯度下降学习点更新

为了找到参数的最优值,我们只需对这一方程(回归系数)的参数取一阶偏导数,并求解最大化似然方程的β值,通过将导数设置为0并找到β的值,如下所示:

使用随机梯度下降学习点更新

这是我们想要更新系数β的方向,以便将其移动到最优点附近。因此,对于每个数据点,我们可以进行以下形式的更新:

使用随机梯度下降学习点更新

其中α是学习率(我们用它来控制每次步骤中系数可以改变的大小——通常较小的学习率可以防止值的大幅变化并收敛到更好的模型,但会花费更长的时间),t是当前的优化步骤,而t-1是前一步。回想一下,在第四章中,我们讨论了正则化的概念,其中我们可以使用惩罚项λ来控制系数的大小。我们在这里也可以这样做:如果我们的似然中的正则化项由以下给出(惩罚系数的平方和,这是第四章中岭回归的L2范数):

使用随机梯度下降学习点更新

然后,一旦我们取一阶导数,我们就有:

使用随机梯度下降学习点更新

最终的更新方程变为:

使用随机梯度下降学习点更新

我们可以看到,这种正则化惩罚的效果是减少我们在任何给定步骤中修改系数 β 的幅度。

如我们之前提到的,随机更新对于大数据集特别有效,因为我们只需要逐个检查每个数据点。这种方法的一个缺点是我们需要运行足够长时间的优化以确保参数收敛。例如,我们可以监控随着我们对每个数据点求导而系数值的变化,并在值停止变化时停止。根据数据集的不同,这可能很快发生,也可能需要很长时间。第二个缺点是沿着误差函数的第一导数跟踪梯度并不总是导致最快的解决方案。二阶方法使我们能够克服一些这些缺点。

使用二阶方法联合优化所有参数

在逻辑回归的情况下,我们的目标函数是凸函数(见附图),这意味着我们选择的任何优化方法都应该能够收敛到全局最优解。然而,我们可以想象其他场景:例如,似然方程的表面作为输入函数的函数可能在通向其全局最优解的长峡谷中缓慢变化。在这种情况下,我们希望找到移动系数的方向,这是变化率和变化率的变化率之间的最佳权衡,后者由似然函数的二阶导数表示。找到这种权衡使得优化过程能够快速穿越缓慢变化的区域。这种策略由所谓的牛顿方法类表示,它最小化以下形式的方程:

使用二阶方法联合优化所有参数

其中 f(x*) 是我们试图最小化的目标函数,例如逻辑回归误差,x 是那些最小化回归似然值的值(例如模型系数),x* 是优化函数值(例如最优系数 β)的输入,xt 是优化当前步骤中这些参数的值(这里确实有一些符号的滥用:在本书的其余部分,x 是输入行,而在这里我们用 x 来表示模型中的参数值)。牛顿方法的名字来源于物理学的奠基人艾萨克·牛顿,他描述了这种过程的早期版本(Ypma, Tjalling J. 牛顿-拉夫森方法的史前发展. SIAM 评论 37.4 (1995): 531-551)。最小化涉及找到在当前阶段 xt 应该移动参数的方向,以找到 f 的最小值。我们可以使用微积分中的泰勒展开来近似前一个函数的值:

使用二阶方法联合优化所有参数

我们想要找到 Δx 的值,以使函数最大化,因为这是我们希望移动参数的方向,就像在梯度下降中一样。我们可以通过求解函数梯度相对于 Δx 变为 0 的点来获得这个最优方向,从而得到:

使用二阶方法联合优化所有参数

因此,当 f′(x) 变化缓慢(f″(x) 较小)时,我们采取更大的步长来改变参数的值,反之亦然。

对于逻辑回归来说,最常用的二阶方法之一是迭代加权最小二乘法IRLS)。为了展示它是如何工作的,让我们将上面的方程转换为我们的逻辑回归模型。我们已经知道 f'(x),因为这只是我们上面用于随机梯度下降的公式:

使用二阶方法联合优化所有参数

那么,关于似然函数的二阶导数呢?我们也可以求解它

使用二阶方法联合优化所有参数

在这里,我们仍然将这个方程写作单个数据点的解。在二阶方法中,我们通常不会使用随机更新,因此我们需要将公式应用于所有数据点。对于梯度(一阶导数),这给出总和:

使用二阶方法联合优化所有参数

对于二阶导数,我们可以将其表示为一个矩阵。成对二阶导数的矩阵也被称为 Hessian 矩阵。

使用二阶方法联合优化所有参数

其中 I 是单位矩阵(对角线上的元素为 1,其余位置为 0),而 A 包含了对每对点 ij 评估的二阶导数。因此,如果我们使用这些表达式进行牛顿更新,我们就有:

使用二阶方法联合优化所有参数

由于二阶导数出现在分母中,我们使用矩阵逆(由 -1 指数给出)来执行此操作。如果你仔细观察分母,我们有一个由 A 的元素加权的 XT X 的乘积,而在分子中我们有 X(Y-F(X))。这与我们在第四章中看到的普通线性回归方程相似,使用模型连接点 – 回归方法!本质上,这个更新是在每次迭代时通过 A(其值随着我们更新系数而变化)进行加权逐步线性回归,从而赋予该方法其名称。IRLS 的一个缺点是我们需要反复求逆一个将随着参数和数据点数量的增加而变得相当大的 Hessian 矩阵。因此,我们可能会尝试找到方法来近似这个矩阵而不是显式地计算它。用于此目的的一种常用方法是有限记忆 Broyden–Fletcher–Goldfarb–Shanno (L-BFGS) 算法(Liu, Dong C. 和 Jorge Nocedal. 关于大规模优化的有限记忆 BFGS 方法. 数学规划 45.1-3 (1989): 503-528),它使用算法的最后 k 次更新来计算 Hessian 矩阵的近似值,而不是在每个阶段显式求解它。

在 SGD 和牛顿法中,我们由于似然函数的一个称为凸性的性质,对这两种方法最终收敛到正确的(全局最优)参数值有理论上的信心。从数学上讲,一个凸函数 F 满足以下条件:

使用二阶方法联合优化所有参数

从概念上讲,这意味着对于两个点 x1x2,它们之间(等式左侧)的 F 值小于或等于两点之间的直线(等式右侧,给出两点函数值的线性组合)。因此,凸函数将在 x1x2 之间有一个全局最小值。在 Python 笔记本中绘制以下内容可以图形化地看到这一点

>>> input = np.arange(10)-5
>>> parabola = [a*a for a in input]
>>> line = [-1*a for a in input-10]
>>> plt.plot(input,parabola)
>>> plt.plot(input,line,color='blue')

使用二阶方法联合优化所有参数

抛物线是一个凸函数,因为位于 x1x2(蓝色线与抛物线相交的两个点)之间的值始终低于代表 α(F(x1))+(1-α) (F(x2)) 的蓝色线。正如你所见,抛物线在这两点之间也有一个全局最小值。

当我们处理如之前提到的 Hessian 矩阵这样的矩阵时,这个条件通过矩阵的每个元素都满足 ≥ 0 来满足,这是一个称为正半定性的属性,意味着任何向量乘以这个矩阵的任一边(xTHx)都会得到一个值 ≥ 0。这意味着函数有一个全局最小值,并且如果我们的解收敛到一组系数,我们可以保证它们代表模型的最佳参数,而不是局部最小值。

我们之前提到,我们可以在训练过程中通过重新加权单个点来潜在地补偿数据集中类的不平衡分布。在 SGD 或 IRLS 的公式中,我们可以为每个数据点应用一个权重 wi,增加或减少其在似然值和优化算法每次迭代中更新的相对贡献。

现在我们已经描述了如何获得逻辑回归模型的最佳参数,让我们回到我们的例子,并将这些方法应用于我们的数据。

模型拟合

我们可以使用 SGD 或二次方法将逻辑回归模型拟合到我们的数据。让我们使用 SGD 比较结果;我们使用以下命令拟合模型:

>>> log_model_sgd = linear_model.SGDClassifier(alpha=10,loss='log',penalty='l2',n_iter=1000, fit_intercept=False).fit(census_features_train,census_income_train)

其中损失参数 log 指定这是一个我们正在训练的逻辑回归,n_iter 指定我们迭代训练数据以执行 SGD 的次数,alpha 代表正则化项的权重,我们指定我们不想拟合截距以使与其他方法的比较更简单(因为不同优化器的拟合截距方法可能不同)。惩罚参数指定正则化惩罚,我们在第四章中已经看到了,使用模型连接点 – 回归方法,对于岭回归。由于l2是我们可以在二次方法中使用的唯一惩罚,我们在这里也选择l2,以便比较方法。我们可以通过引用模型对象的 coeff_ 属性来检查结果模型系数:

>>> log_model_sgd.coef_

将这些系数与我们使用以下命令获得的二次拟合进行比较:

>>> log_model_newton = linear_model.LogisticRegression(penalty='l2',solver='lbfgs', fit_intercept=False).fit(census_features_train,census_income_train

与 SGD 模型一样,我们移除了截距拟合,以便最直接地比较两种方法产生的系数。我们发现系数并不相同,SGD 模型的输出包含几个较大的系数。因此,在实践中,我们看到即使具有类似模型和凸目标函数,不同的优化方法也可以给出不同的参数结果。然而,我们可以通过系数的双变量散点图看到,结果高度相关:

>>> plt.scatter(log_model_newton.coef_,log_model_sgd.coef_)
>>> plt.xlim(-0.08,0.08)
>>> plt.ylim(-0.08,0.08)
>>> plt.xlabel('Newton Coefficent')
>>> plt.ylabel('SGD Coefficient')

模型拟合

SGD 模型具有更大的系数这一事实给我们一个提示,关于可能造成差异的原因:也许 SGD 对特征之间的尺度差异更敏感?让我们通过使用 第三章 中介绍的 StandardScaler 来评估这个假设,在 K-means 聚类的情况下,在运行 SGD 模型之前对特征进行归一化,使用以下命令:

>>> from sklearn.preprocessing import StandardScaler
>>> census_features_train_sc= StandardScaler().fit_transform(X=census_features_train.todense())

记住,我们需要将特征矩阵转换为密集格式,因为 StandardScaler 不接受稀疏矩阵作为输入。现在,如果我们使用相同的参数重新训练 SGD 并将结果与牛顿法进行比较,我们会发现系数要接近得多:

拟合模型

这个例子应该强调优化器有时与实际算法一样重要,并可能决定我们在数据归一化中应采取的步骤。

评估分类模型

现在我们已经拟合了一个分类模型,我们可以检查测试集上的准确性。进行这种分析的一个常用工具是 接收者操作特征ROC)曲线。为了绘制 ROC 曲线,我们选择分类器的特定截止值(在这里,一个介于 01 之间的值,高于此值我们认为数据点被分类为正,或 1),并询问这个截止值正确分类的 1 的比例(真正率),同时,基于这个阈值,有多少负数被错误地预测为正(假正率)。从数学上讲,这表示选择一个阈值并计算四个值:

TP = true positives = # of class 1 points above the threshold
FP = false positives = # of class 0 points above the threshold
TN = true negatives = # of class 0 points below the threshold
FN = false negatives = # of class 1 points below the threshold

ROC 曲线绘制的 真正率TPR)是 TP/(TP+FN),而 假正率FPR)是 FP/(FP+TN)

如果这两个比率相等,那么这并不比随机选择更好。换句话说,无论我们选择什么截止值,模型对类别 1 的预测都是等可能的,无论该点实际上是正还是负。因此,从左下角到右上角的斜线代表通过随机选择数据点的标签创建的分类器的性能,因为真正率和假正率总是相等的。相反,如果分类器表现出优于随机性能,随着正确分类的点在阈值之上增加,真正率会更快地上升。将 ROC 曲线下方的面积(AUC)积分,其最大值为 1,是报告分类方法准确性的常见方式。为了找到用于分类的最佳阈值,我们找到曲线上真正率和假正率比率最大的点。

在我们的例子中,这一点很重要,因为 1 的出现频率低于 0。正如我们在本章开头检查数据集时提到的,这可能导致在训练分类模型时出现问题。虽然直观的选择是将预测概率高于 0.5 的事件视为 1,但在实践中我们发现,由于这个数据集的不平衡,一个较低的阈值是最佳选择,因为解决方案偏向于零。在高度倾斜的数据中,这种影响可能会更加明显:考虑一个只有 1,000 个点中有 1 个标签为 1 的例子。我们可能有一个非常出色的分类器,它预测每个数据点都是 0:它有 99.9% 的准确率!然而,它对识别稀有事件并不太有用。除了调整 AUC 的阈值之外,我们还有几种方法可以抵消这种偏差。

一种方法是通过构建一个 50% 为 1s 和 50% 为 0s 的训练集来重新平衡模型。然后我们可以评估在未平衡测试数据集上的性能。如果不平衡性非常大,我们的重新平衡训练集可能只包含 0s 的可能变化的一小部分:因此,为了生成代表整个数据集的模型,我们可能需要构建许多这样的数据集,并平均从它们生成的模型的成果。这种方法与我们在第四章中看到的用于构建随机森林模型的 Bagging 方法并不相似,连接点与模型 – 回归方法

其次,我们可以在优化参数的过程中,利用我们对不平衡数据的了解来改变每个数据点的贡献。例如,在 SGD 方程中,我们可以将 1s 上的错误惩罚比 0s 上的错误惩罚高 1,000 倍。这个权重将随后纠正模型中的偏差。

在非常不平衡的数据集中,我们对 AUC 的解释也发生了变化。虽然整体 AUC 为 0.9 可能被认为是好的,但如果在假阳性率为 0.001(包含稀有类别的数据比例)时,TPR 和 FPR 之间的比率不大于 1,这表明我们可能需要搜索排名前列的大量数据来丰富稀有事件。因此,尽管整体准确率看起来很好,但我们最关心的数据范围内的准确率可能并不高。这些场景在实践中并不少见。例如,广告点击通常比非点击少得多,销售咨询的回复也是如此。从视觉上看,一个不适合不平衡数据的分类器会在 ROC 曲线上显示出 TPR 和 FPR 差距在曲线中间最大(~0.5)。相反,对于一个适当调整以适应稀有事件的分类器的 ROC 曲线,大部分区域都包含在曲线的左侧(从 0 的截止点急剧上升,然后向右平缓),这代表着在高阈值下正例的丰富。

注意,假正例率和假负例率只是我们可能计算的准确性指标的两个例子。我们可能还感兴趣知道,在模型分数的给定截止值以上,1)我们有多少个正例被分类(召回率)以及超过此阈值的点的实际正例百分比 2)精确度。这些计算如下:

精确度 = TP/(TP+FP)

召回率 = TP/(TP+FN)

事实上,召回率与真正例率相同。虽然 ROC 曲线允许我们评估模型是否以比假正例更高的比率生成真正例预测,但比较精确度与召回率可以让我们了解给定分数阈值以上的预测的可靠性和完整性。我们可能有非常高的精确度,但只能检测到整体正例中的少数。相反,我们可能以牺牲低精确度为代价获得高召回率,因为我们通过降低分数阈值来调用模型中的正例,从而产生假正例。这些之间的权衡可能因应用而异。例如,如果模型主要是探索性的,例如用于为营销生成潜在销售线索的分类器,那么我们可以接受相当低的精确度,因为即使真正的预测中夹杂着噪声,每个正例的价值也相当高。另一方面,在用于垃圾邮件识别的模型中,我们可能希望偏向于高精确度,因为将有效的商业电子邮件错误地移动到用户的垃圾文件夹中的成本可能高于偶尔通过过滤器的垃圾邮件。最后,我们还可以考虑适用于不平衡数据的性能指标,因为它们代表了多数类和少数类之间的精确度与召回率的权衡。这些包括 F 度量:

评估分类模型

以及马修斯相关系数(马修斯,布莱恩·W. 比较预测和观察到的 T4 噬菌体溶菌酶的二级结构. 生物化学与生物物理学报(BBA)-蛋白质结构 405.2(1975):442-451.):

评估分类模型

返回到我们的例子,我们在如何从模型中计算预测结果上有两种选择:要么是一个类别标签(01),要么是一个特定个体被分类为 1 的概率。对于计算 ROC 曲线,我们希望选择第二种,因为这将允许我们在一系列用作分类阈值的概率范围内评估分类器的准确性:

>>> train_prediction = log_model_newton.predict_proba(census_features_train)
>>> test_prediction = log_model_newton.predict_proba(census_features_test)

使用以下代码绘制训练集和测试集的 ROC 曲线,我们可以直观地看到我们的模型在以下方面给出了低于平均的准确性:

>>>  from sklearn import metrics
>>> fpr_train, tpr_train, thresholds_train = metrics.roc_curve(np.array(census_income_train),\
 np.array(train_prediction[:,1]), pos_label=1)
>>> fpr_test, tpr_test, thresholds_test = metrics.roc_curve(np.array(census_income_test),\
 np.array(test_prediction[:,1]), pos_label=1)
>>> plt.plot(fpr_train, tpr_train)
>>> plt.plot(fpr_test, tpr_test)
>>> plt.xlabel('False Positive Rate')
>>> plt.ylabel('True Positive Rate')

评估分类模型

从数值上讲,我们发现测试集和训练集的 AUC 略好于随机(0.5),因为以下两个命令:

>>> metrics.auc(fpr_train,tpr_train)

and

>>> metrics.auc(fpr_test,tpr_test)

give results of ~ 0.6.

如果可能的话,我们希望提高我们分类的性能——我们如何诊断现有逻辑回归模型的问题并朝着更好的预测努力?

提高分类模型策略

面对这种不尽如人意的表现,我们通常有几个选择:

  • 使用更多数据进行训练

  • 对模型进行正则化以减少过拟合

  • 选择另一个算法

在我们关于表现不佳的逻辑回归模型的例子中,哪个选项最有意义?

让我们考虑第一个选项,即我们仅仅需要更多的数据来提高性能。在某些情况下,我们可能没有足够的数据在我们的训练集中来代表我们在测试集中观察到的模式。如果情况是这样,我们预计随着我们增加用于构建模型的训练集的大小,我们的测试集性能将得到改善。然而,我们并不总是有获取更多数据的便利。在这个例子中,我们实际上没有更多的数据来训练;即使理论上可以收集更多的数据,在实践中可能成本太高,无法证明其合理性,或者我们可能需要在更多数据可用之前做出决定。

那么,过拟合怎么办?换句话说,也许我们的模型精确地调整到了训练集中的模式,但不能推广到测试集。像第一个选项一样,我们将观察到训练集上的性能优于测试集。然而,解决方案并不一定是添加更多数据,而是修剪特征以使模型更具普遍性。在前面的场景中,我们看到训练集和测试集的性能相似,所以这并不像是最可能的解释。

最后,我们可能尝试另一种算法。为了这样做,让我们考虑我们当前模型的局限性。一方面,逻辑回归仅包含单个特征:它没有表示它们之间相互作用的方法。例如,它只能模拟婚姻状态的影响,但不能模拟基于教育和年龄的婚姻状态。这些因素可能组合起来预测收入,但并不一定单独预测。查看系数的值可能会有所帮助,为此,我们需要将原始列标题映射到我们的独热编码中的列名,其中每个分类特征现在由几个列表示。在这个格式中,数值列附加到数据框的末尾,因此我们需要将它们添加到最后到列列表中。以下代码使用我们之前计算的类别到独热位置的映射重新映射列标题:

>>> expanded_headers = []
>>> non_categorical_headers = []
>>> categorical_index = 0
>>> for e,h in enumerate(np.array(census.columns[:-1])):
 …   if e in set(categorical_features):
 …       unsorted_category = np.array([h+key for key in categorical_dicts[categorical_index].keys()]) # appends the category label h to each feature 'key' 
 …       category_indices = np.array(list(categorical_dicts[categorical_index].values())) # gets the mapping from category label h to the position in the one-hot array 
 …       expanded_headers+=list(unsorted_category[np.argsort(category_indices)]) # resort the category values in the same order as they appear in the one-hot encoding
…        categorical_index+=1 # increment to the next categorical feature
…    else:
…        non_categorical_headers+=[h]
… expanded_headers+=non_categorical_headers

我们可以检查单个系数是否合理:记住,排序函数按升序排列项目,因此要找到最大的系数,我们需要按负值排序:

>>> expanded_headers[np.argsort(-1*log_model.coef_[0])]
array(['capital-gain', 'capital-loss', 'hours-per-week', 'age',        'education-num', 'marital-status Married-civ-spouse',        'relationship Husband', 'sex Male', 'occupation Exec-managerial',        'education Bachelors', 'occupation Prof-specialty',        'education Masters', 'relationship Wife', 'education Prof-school',        'workclass Self-emp-inc', 'education Doctorate',        'workclass Local-gov', 'workclass Federal-gov',        'workclass Self-emp-not-inc', 'race White',        'occupation Tech-support', 'occupation Protective-serv',        'workclass State-gov', 'occupation Sales', … 

从逻辑上讲,顺序似乎是有意义的,因为我们预计年龄和教育是收入的重要预测因素。然而,我们发现只有 ~1/3rd 的特征通过以下图示对模型有重大影响:

>>> plt.bar(np.arange(108),np.sort(log_model_newton.coef_[0]))

提高分类模型策略

因此,看起来这个模型只能从特征的一个子集中学习信息。我们可能尝试通过组合标签生成交互特征(例如,一个表示已婚和最高教育水平为硕士学位的二进制标志)通过所有特征相互之间的乘积。以这种方式生成潜在的非线性特征被称为多项式展开,因为我们正在将单个系数项转换为具有平方、立方或更高幂关系的乘积。然而,为了本例的目的,我们将尝试一些替代算法。

使用支持向量机分离非线性边界

在我们之前的逻辑回归示例中,我们隐含地假设训练集中的每个点都可能有助于定义我们试图分离的两个类之间的边界。在实践中,我们可能只需要少量数据点来定义这个边界,额外的信息只是给分类添加噪声。这个概念,即通过仅使用少量关键数据点来提高分类,是支持向量机(SVM)模型的关键特征。

在其基本形式中,支持向量机(SVM)与之前我们所见的线性模型相似,使用以下方程:

使用支持向量机分离非线性边界

其中 b 是截距,β 是系数向量,正如我们在回归模型中所见。我们可以看到一条简单的规则,即点 X 被分类为类别 1 如果 F(x) ≥ 1,如果 F(x) ≤ –1 则被分类为类别 -1。从几何上讲,我们可以理解为这是平面到点 x 的距离,其中 β 是一个垂直(成直角)于平面的向量。如果两个类别理想地分离,那么由 1/使用支持向量机分离非线性边界 表示的两个类别之间的宽度尽可能大;因此,在寻找 β 的最优值时,我们希望最小化 使用支持向量机分离非线性边界 的范数。同时,我们希望最小化分配标签到数据中的错误。因此,我们可以有一个损失函数,它最小化这两个目标之间的权衡:

使用支持向量机分离非线性边界

其中 yx 的正确标签。当 x 被正确分类时,y(xβ+b) ≥ 1,并且我们从 L 的值中整体减去。相反,当我们错误地预测 x 时。

注意,这里的 || 表示欧几里得范数,或:

使用支持向量机分离非线性边界

y(xβ+b) < 1,因此我们向L的值中添加。如果我们想最小化L的值,我们可以通过求这个函数的导数并将其设置为 0 来找到βb的最优值。从β开始:

使用支持向量机分离非线性边界

同样,对于b

使用支持向量机分离非线性边界

将这些值代入损失函数方程,我们得到:

使用支持向量机分离非线性边界

这里有两个重要的事情。首先,只有一些α需要非零。其余的可以设置为0,这意味着只有少数几个点会影响最优模型参数的选择。这些点是支持向量,它们位于两个类别的边界上。请注意,在实践中,我们不会使用上述错误函数版本,而是使用软边界公式,其中我们使用铰链损失

使用支持向量机分离非线性边界

这意味着我们只有在点位于分离超平面的错误一侧时才会对其进行惩罚,并且根据它们的误分类误差的大小进行惩罚。这允许 SVM 在数据无法线性分离的情况下应用,允许算法根据铰链损失惩罚进行错误。有关详细信息,请参阅参考文献(Cortes, Corinna, and Vladimir Vapnik. 支持向量机. Machine learning 20.3 (1995): 273-297; Burges, Christopher JC. 支持向量机在模式识别中的应用教程. Data mining and knowledge discovery 2.2 (1998): 121-167.)。

其次,我们现在看到解只通过单个点的乘积依赖于输入x。实际上,我们可以用任何函数K(xi,xj)替换这个乘积,其中K是一个所谓的核函数,表示xixj之间的相似性。这在尝试捕捉数据点之间的非线性关系时特别有用。例如,考虑二维空间中抛物线上的数据点,其中x2(垂直轴)是x1(水平轴)的平方。通常,我们无法画一条直线来分离抛物线上下方的点。然而,如果我们首先使用函数x1sqrt(x2)映射这些点,我们现在可以线性地分离它们。我们在第三章中看到了这种非线性映射的有效性,在噪声中寻找模式 – 聚类和无监督学习,当我们使用高斯核通过谱 K-Means 聚类分离同心圆之间的非线性边界时。

除了在输入空间中线性不可分的数据点之间创建线性决策边界之外,核函数还允许我们计算没有向量表示的对象之间的相似性,例如图(节点和边)或单词集合。这些对象也不需要具有相同的长度,只要我们能计算出一个相似性即可。这些事实归功于一个称为 Mercer 定理的结果,该定理保证对于所有输入对,>=0 的核函数代表了一个有效的内积 使用支持向量机分离非线性边界,将输入 x 映射到由 φ 表示的线性可分空间中,φ 是一个映射(Hofmann, Thomas, Bernhard Schölkopf, 和 Alexander J. Smola. 机器学习中的核方法. 统计学年刊 (2008): 1171-1220)。这种映射可以是显式的,例如在上面的例子中应用于抛物线输入的平方根函数。然而,我们实际上并不需要这种映射,因为核函数保证了能够表示映射输入之间的相似性。实际上,映射甚至可以在一个我们无法显式表示的无限维空间中执行,正如我们接下来要描述的高斯核函数那样。

既然我们已经了解了 SVM 的一些基本直觉,让我们看看通过将 SVM 应用于数据,它是否可以提高我们分类模型的表现。

将 SVM 应用于人口普查数据

在这个例子中,我们将尝试 scikit-learn 中 SVM 模型的默认核函数,这是一个高斯核函数,你可能认识它是正态分布函数中使用的相同方程。我们之前在 第三章 的谱聚类上下文中使用了高斯核函数,在噪声中寻找模式 – 聚类和无监督学习,作为提醒,公式如下:

将 SVM 应用于人口普查数据

从本质上讲,这个函数将两个数据点之间的差异转换到 1(当它们相等且指数变为 0 时)和 0(当差异非常大且指数趋向于一个非常大的负数时)。参数 γ 代表标准差,或带宽,它控制函数值随着点之间差异的增加而趋向于零的速度。带宽较小的值将使分子成为一个更大的负数,从而将核值缩小到 0

正如我们之前提到的,高斯核函数表示将输入 x 映射到一个无限维空间。如果我们通过一个无限级数展开核函数的值,我们可以看到这一点:

将 SVM 应用于人口普查数据

因此,高斯核函数捕捉了无限维特征空间中的相似性。

我们使用以下命令将 SVM 模型拟合到训练数据:

>>> from sklearn import svm
>>> svm_model = svm.SVC(probability=True,kernel='rbf').fit(census_features_train.toarray(),census_income_train)
>>> train_prediction = svm_model.predict_proba(census_features_train.toarray())
>>> test_prediction = svm_model.predict_proba(census_features_test.toarray())
>>> fpr_train, tpr_train, thresholds_train = metrics.roc_curve(np.array(census_income_train),\
 np.array(train_prediction[:,1]), pos_label=1)
>>> fpr_test, tpr_test, thresholds_test = metrics.roc_curve(np.array(census_income_test),\
 np.array(test_prediction[:,1]), pos_label=1)
>>> plt.plot(fpr_train, tpr_train)
>>> plt.plot(fpr_test, tpr_test)
>>> plt.xlabel('False Positive Rate')
>>> plt.ylabel('True Positive Rate')

然而,在绘制结果 ROC 曲线时,我们发现我们在逻辑回归上并没有取得很大的进步:

将 SVM 拟合到人口普查数据

可能难以看清,但图像左上角的红色线条是训练集上的性能,而蓝色线条是测试集上的性能。因此,我们处于之前描述的那种情况,即模型几乎完美地预测了训练数据,但泛化到测试集上却表现不佳。

在某种意义上,我们能够取得进展是因为我们使用非线性函数来表示数据中的相似性。然而,现在的模型拟合我们的数据太好了。如果我们想更多地实验 SVM 模型,我们可以调整许多参数:我们可以改变核函数,调整高斯核的带宽(或我们选择的核函数的特定超参数),或者调整我们惩罚分类错误的程度。然而,对于我们的下一步算法优化,我们将改变方向,尝试用许多弱模型而不是一个过拟合的模型来引入非线性,这个概念被称为提升法。

提升法 – 通过组合小型模型来提高准确性

在之前的例子中,我们隐含地假设有一个单一的模型可以描述我们数据集中存在的所有模式。如果,相反,一个不同的模型最适合于数据子集表示的模式,并且只有通过组合代表许多这些较小模式的模型,我们才能得到一个准确的图像呢?这就是提升法的直觉——我们从一个弱个体模型开始,确定它正确分类的点,并为这个模型遗漏的点拟合额外的模型。虽然每个额外的模型本身也相对较差,但通过逐步添加这些捕捉数据某个子集的弱模型,我们逐渐达到整体准确的预测。此外,因为组中的每个模型都只拟合数据的一个子集,所以我们不必过于担心过拟合。虽然提升法的基本思想可以应用于许多模型,但让我们看看一个例子,使用我们在第四章中介绍的决策树,用模型连接点 – 回归方法

梯度提升决策树

回想一下,在第四章中,通过模型连接点 – 回归方法,我们通过在具有随机特征的树集合上平均来提高了回归任务的预测能力。梯度提升决策树(Breiman, Leo. Arcing the edge. 技术报告 486,加州大学伯克利分校统计学系,1997;Friedman, Jerome H. 贪婪函数逼近:梯度提升机. 统计学年鉴(2001):1189-1232;Friedman, Jerome H. 随机梯度提升. 计算统计学与数据分析 38.4 (2002): 367-378。)遵循类似的策略,但不是在每一步选择随机特征,而是在每个点上贪婪地优化。通用算法如下:

  1. 从一个常数值开始,例如输入数据中的平均响应值。这是基线模型,F0

  2. 将决策树h拟合到训练数据上,通常限制其深度非常浅,目标是将每个点i伪残差作为目标,由以下公式给出:梯度提升决策树

  3. 从概念上讲,对于给定的损失函数 L(例如我们在第四章中研究的平方误差,或上述 SVM 的 hinge 损失)的伪残差是损失函数相对于当前模型F在点yi的值的导数。虽然标准残差只是预测值和观察值之间的差异,但伪残差表示损失在给定点的变化速度,以及我们需要将模型参数移动的方向以更好地分类这个点。

  4. 步骤 1:将步骤 2 中树的值乘以一个最优步长γ和学习率α梯度提升决策树

  5. 我们可以选择一个对整个树最优的γ值,或者对每个单独的叶节点,我们可以使用如上所述的牛顿优化方法来确定最优值。

  6. 重复步骤 1-3,直到收敛。

目标是通过拟合多个较弱的树,它们在逐步拟合时总体上能做出更好的预测,以补偿模型在每个步骤中剩余的残差。在实践中,我们也在每个阶段只选择训练数据的一个子集来拟合树,这应该进一步减少过拟合的可能性。让我们通过拟合一个具有 200 棵树和最大深度为 5 的模型来检验这个理论:

>>> from sklearn.ensemble import GradientBoostingClassifier
>>> gbm = GradientBoostingClassifier(n_estimators=200, learning_rate=1.0,\
… max_depth=5, random_state=0).fit(census_features_train.toarray(),census_income_train) 
>>> train_prediction = gbm.predict_proba(census_features_train.toarray()) 
>>> test_prediction = gbm.predict_proba(census_features_test.toarray()) 
>>> fpr_train, tpr_train, thresholds_train = metrics.roc_curve(np.array(census_income_train),\
…   np.array(train_prediction[:,1]), pos_label=1)
>>>fpr_test, tpr_test, thresholds_test = metrics.roc_curve(np.array(census_income_test),\
…   np.array(test_prediction[:,1]), pos_label=1) 

现在,当我们绘制结果时,我们看到测试集上的准确率有显著提高:

>>> plt.plot(fpr_train, tpr_train)
>>> plt.plot(fpr_test, tpr_test)
>>> plt.xlabel('False Positive Rate')
>>> plt.ylabel('True Positive Rate')

梯度提升决策树

与随机森林模型类似,我们可以通过在数据点之间随机打乱它们的值来检查特征的重要性,这会影响准确率的损失:

>>> np.array(expanded_headers)[np.argsort(gbm.feature_importances_)]
array('native-country Outlying-US(Guam-USVI-etc)',        'native-country Holand-Netherlands', 'native-country Laos',        'native-country Hungary', 'native-country Honduras',        'workclass Never-worked', 'native-country Nicaragua',        'education Preschool', 'marital-status Married-AF-spouse',        'native-country Portugal', 'occupation Armed-Forces',        'native-country Trinadad&Tobago', 'occupation Priv-house-serv',        'native-country Dominican-Republic', 'native-country Hong',        'native-country Greece', 'native-country El-Salvador',        'workclass Without-pay', 'native-country Columbia',        'native-country Yugoslavia', 'native-country Thailand',        'native-country Scotland', 'native-country Puerto-Rico',        'education 1st-4th', 'education 5th-6th'

注意,这与我们对逻辑回归模型进行的相同评估并不直接可比,因为这里的重要性不是由特征是否预测正面或负面决定的,这在逻辑回归系数的符号中是隐含的。

还要注意,在解释输出系数时存在一个更微妙的问题:我们中的许多特征实际上是共同特征的个别类别,例如原产国或教育水平。我们真正感兴趣的是整体特征的重要性,而不是个别级别。因此,为了更准确地量化特征重要性,我们可以对包含属于共同特征的类别列的平均重要性进行平均。

如果我们想要进一步调整 gbm 模型的性能,我们可以搜索不同数量的树、树的深度、学习率(公式上方的α)和min_samples_leaf(它决定了需要存在于数据中以便从树的底部分裂(或叶子)的最小数据点数),以及其他一些参数。作为一个经验法则,使树更深会增加过拟合的风险,但较浅的树需要更多的模型来实现良好的准确度。同样,较低的学习率也会通过减少单个树对模型得分的贡献来控制过拟合,但可能需要更多的模型来达到所需的预测准确度。这些参数之间的平衡可能既受应用(模型应该有多准确才能对业务问题产生有意义的贡献)的影响,也受性能考虑(例如,如果模型需要在网站上在线运行,那么占用较少内存的较少树可能是有益的,并且值得略微降低准确度)。

比较分类方法

在本章中,我们探讨了使用逻辑回归、支持向量机和梯度提升决策树进行分类。在什么情况下我们应该优先选择一种算法而不是另一种?

对于逻辑回归,数据理想情况下将是线性可分的(毕竟,逻辑回归公式中的指数本质上与支持向量机(SVM)的分离超平面方程相同)。如果我们的目标是推理(在输入测量每增加 1 个单位时产生响应单位增加,正如我们在[第一章中描述的,从数据到决策 – 分析应用入门),那么系数和对数几率值将是有帮助的。在无法同时处理所有数据的情况下,随机梯度下降法也可能是有帮助的,而我们在讨论的第二阶方法可能更容易应用于未归一化的数据。最后,在序列化模型参数和使用这些结果对新数据进行评分的背景下,逻辑回归因其表示为一个数字向量并且易于存储而具有吸引力。

如我们讨论的,支持向量机(SVM)可以适应输入之间的复杂非线性边界。它们也可以用于没有向量表示的数据,或者长度不同的数据,这使得它们相当灵活。然而,它们在拟合以及评分时需要更多的计算资源。

梯度提升决策树可以在输入之间拟合非线性边界,但仅限于某些类型。考虑到决策树在每个决策节点将数据集分为两组。因此,产生的边界代表数据集 m 维空间中的一系列超平面,但每次只沿特定维度分割,并且只沿直线分割。因此,这些平面不一定能够捕捉到支持向量机(SVM)可能具有的非线性,但如果数据可以以这种方式分割,GBM 可能会表现良好。

下面的流程图概述了从我们讨论的分类方法中选择的一般情况。同时,请记住,我们在第四章中讨论的随机森林算法,通过模型连接点 – 回归方法也可以用于分类,而本章中描述的支持向量机(SVM)和梯度提升树(GBM)模型具有可能用于回归的形式。

比较分类方法

案例研究:在 PySpark 中拟合分类器模型

现在我们已经检查了 scikit-learn 库中用于拟合分类器模型的几个算法,让我们看看我们如何在 PySpark 中实现一个类似模型。我们可以使用本章前面提到的相同的普查数据集,并从启动 spark 上下文后使用 textRdd 加载数据开始:

>>> censusRdd = sc.textFile('census.data')

接下来,我们需要将数据分割成单个字段,并去除空白字符

>>> censusRddSplit = censusRdd.map(lambda x: [e.strip() for e in x.split(',')])

现在,就像之前一样,我们需要确定我们的哪些特征是分类的,需要使用独热编码重新编码。我们通过取单一行并询问每个位置的字符串是否代表一个数字(不是一个分类变量)来完成此操作:

>>> categoricalFeatures = [e for e,i in enumerate(censusRddSplit.take(1)[0]) if i.isdigit()==False]
>>> allFeatures = [e for e,i in enumerate(censusRddSplit.take(1)[0])]

现在,就像之前一样,我们需要收集一个字典,表示每个分类标签到独热编码向量位置的字符串到位置的映射:

>>> categoricalMaps = []
>>> for c in categoricalFeatures:
…    catDict = censusRddSplit.map(lambda x: x[c] if len(x) > c else None).\
…    filter(lambda x: x is not None).\
…    distinct().\
…    zipWithIndex().\
…    collectAsMap()
…    censusRddSplit.map(lambda x: x[c]).take(1)
…    categoricalMaps.append(catDict)

接下来,我们计算表示所有特征的独热编码向量的总长度。我们从该值中减去两个,因为最后一个分类特征是收入,它有两个值,我们将其用作数据的标签:

>>> expandedFeatures = 0
>>> for c in categoricalMaps:
…    expandedFeatures += len(c)
expandedFeatures += len(allFeatures)-len(categoricalFeatures)-2

现在,我们使用映射函数将所有数据转换为用于逻辑回归的标记点对象。为此,我们从向量的最后一个元素提取每行的标签,然后使用我们之前计算的独热编码特征集的长度实例化一个空向量。我们使用两个索引:一个用于访问哪个分类变量(以索引正确的字典执行映射),另一个用于记录在特征向量中的位置(因为对于分类变量,我们将跳过给定变量的 k 个空间,其中 k 是该变量的类别数)。

>>> def formatPoint(p):
…      if p[-1] == '<=50K':
…          label = 0
…      else:
 …         label = 1
…      vector = [0.0]*expandedFeatures
…      categoricalIndex = 0
…      categoricalVariable = 0
…      for e,c in enumerate(p[:-1]):
…          if e in categoricalFeatures:
 …             vector[categoricalIndex + categoricalMaps[categoricalVariable][c]]=1
…              categoricalIndex += len(categoricalMaps[categoricalVariable])
…              categoricalVariable +=1
 …         else:
 …             vector[e] = c
…              categoricalIndex += 1
…      return LabeledPoint(label,vector)

我们将此函数应用于所有数据点

>>> censusRddLabeled = censusRddSplit.map(lambda x: formatPoint(x))

现在我们数据格式正确,我们可以运行逻辑回归:

>>> from pyspark.mllib.classification import LogisticRegressionWithLBFGS
>>> censusLogistic = LogisticRegressionWithLBFGS.train(censusRddLabeled )

要访问结果模型的权重,我们可以检查权重参数:

>>> censusLogistic.weights

如果我们想将生成的模型应用于新的数据集,我们可以在新的特征向量上使用censusLogisticpredict()方法。上述步骤与我们用于 scikit-learn 示例的数据处理步骤类似,但最终可以扩展到更大的数据集。

摘要

在本章中,你学习了如何使用分类模型以及一些提高模型性能的策略。除了转换分类特征外,你还研究了使用 ROC 曲线解释逻辑回归准确性的方法。在尝试提高模型性能时,我们展示了 SVMs 的使用,并能够在训练集上提高性能,尽管代价是过拟合。最后,我们通过梯度提升决策树在测试集上实现了良好的性能。结合第四章中的材料,“通过模型连接点 – 回归方法”,你现在应该拥有一套完整的方法,可以应用于连续和分类结果的问题,这些问题可以在主要领域中使用。

第六章:文字与像素 - 处理非结构化数据

到目前为止,我们查看的大部分数据都是由行和列组成的,包含数值或分类值。这类信息既适合传统的电子表格软件,也适合之前练习中使用的交互式 Python 笔记本。然而,数据越来越多地以这种形式(通常称为结构化数据)和更复杂的格式(如图像和自由文本)提供。这些其他数据类型,也称为非结构化数据,比表格信息更难以解析和转换成机器学习算法中可用的特征。

什么使得非结构化数据难以使用?这主要是因为图像和文本具有极高的维度,包含的列或特征数量比我们之前看到的要多得多。例如,这意味着一个文档可能有数千个单词,或一个图像有数千个单独的像素。这些组件可能单独或以复杂组合的形式构成我们算法的特征。然而,为了在预测中使用这些数据类型,我们需要以某种方式将这些极其复杂的数据提炼成通用的特征或趋势,这些特征或趋势可能在模型中有效使用。这通常涉及从这些数据类型中去除噪声并找到更简单的表示。同时,这些数据类型的更大内在复杂性可能比表格数据集包含的信息更多,或者可能揭示在其他任何来源中不可获得的信息。

在本章中,我们将通过以下方式探索非结构化数据:

  • 通过词干提取、停用词去除和其他规范化方法清理原始文本

  • 使用标记化和 n-gram 在文本数据中寻找共同模式

  • 正规化图像数据并去除噪声

  • 通过几种常见的矩阵分解算法将图像分解为低维特征

处理文本数据

在以下示例中,我们将考虑分离手机用户之间发送的短信的问题。其中一些信息是垃圾广告,目标是将这些信息与正常通信(Almeida, Tiago A., José María G. Hidalgo, 和 Akebo Yamakami. 对短信垃圾邮件过滤研究的新贡献:新的收集和结果. 第 11 届 ACM 文档工程研讨会论文集。ACM,2011)区分开来。通过寻找在垃圾广告中通常发现的单词模式,我们可能能够开发出一个智能过滤器,自动从用户的收件箱中移除这些信息。然而,在之前的章节中,我们关注的是为这类问题拟合预测模型,而在这里,我们将重点转向清理数据、去除噪声和提取特征。一旦完成这些任务,简单或低维特征就可以输入到我们已研究的许多算法中。

清理文本数据

让我们先使用以下命令加载数据并检查它。注意,我们需要自己提供此数据的列名:

>>> spam = pd.read_csv('smsspamcollection/SMSSpamCollection',sep='\t',header=None)
>>> spam.columns = ['label','text']
>>> spam.head()

这给出了以下输出:

清洗文本数据

数据集包含两列:第一列包含标签(spamham),分别表示消息是否为广告或普通消息。第二列包含消息的文本。一开始,我们可以看到使用这种原始文本作为算法预测垃圾邮件/非垃圾邮件标签输入时存在的一些问题:

  • 每条消息的文本包含大小写字母的混合,但这种大写形式并不影响单词的意义。

  • 许多单词(如 tohethe 等)很常见,但关于消息的信息相对较少。

其他问题更为微妙:

  • 当我们比较诸如 largerlargest 这样的单词时,关于单词意义的最多信息是由词根 large 承载的——区分这两种形式实际上可能阻止我们捕捉到关于单词 large 在文本中出现的共同信息,因为消息中这个词根的计数将分布在各种变体之间。仅仅查看单个单词并不能告诉我们它们使用的上下文。实际上,考虑单词集合可能更有信息量。

  • 即使对于不属于常见类别的单词,如 andtheto,有时也不清楚一个单词是否出现在文档中,因为它在所有文档中都常见,或者它是否包含关于特定文档的特殊信息。例如,在一组在线电影评论中,像 characterfilm 这样的单词会频繁出现,但它们并不能帮助区分不同的评论,因为它们在所有评论中都常见。由于英语词汇量很大,结果特征集的大小可能非常大。

让我们先从清理文本开始,然后再深入研究其他特征问题。我们可以通过以下函数将文本中的每个单词转换为小写:

>>> def clean_text(input):
…      return "".join([i.lower() for i in input])

我们然后将此函数应用于每条消息,使用我们在之前的示例中看到的映射函数:

>>> spam.text = spam.text.map(lambda x: clean_text(x))

检查结果可以验证所有字母现在确实都是小写的:

清洗文本数据

接下来,我们希望删除常见单词并修剪剩余词汇表,仅保留对预测建模最有用的单词的词干部分。我们使用 自然语言工具包NLTK)库(Bird, Steven. NLTK: the natural language toolkit. Proceedings of the COLING/ACL on Interactive presentation sessions. Association for Computational Linguistics, 2006)来完成这项操作。停用词表是该库关联下载的数据集的一部分;如果您是第一次打开 NLTK,可以使用 nltk.download() 命令打开一个 图形用户界面GUI),在那里您可以使用以下命令选择要复制到本地计算机的内容:

>>> import nltk
>>> nltk.download()
>>> from nltk.corpus import stopwords
>>> stop_words = stopwords.words('english')

然后我们定义一个函数来执行词干提取:

>>> def stem_text(input):
…    return " ".join([nltk.stem.porter.PorterStemmer().stem(t) if t not in \
…       stop_words else for t in nltk.word_tokenize(input)])

最后,我们再次使用 lambda 函数对每个消息执行此操作,并直观地检查结果:

>>> spam.text = spam.text.map(lambda x: stem_text(x))

清洗文本数据

例如,您可以看到从 joking 中提取了词干 joke,以及从 available 中提取了 avail

现在我们已经完成了小写化和词干提取,消息处于相对清洁的状态,我们可以从这个数据中生成用于预测建模的特征。

从文本数据中提取特征

在可能的最简单文本数据特征中,我们使用由 0s1s 组成的二进制向量来简单地记录词汇表中每个单词在每个消息中的存在或不存在。为此,我们可以利用 scikit-learn 库中的 CountVectorizer 函数,使用以下命令:

>>> from sklearn.feature_extraction.text import CountVectorizer
>>> count_vect_sparse = CountVectorizer().fit_transform(spam.text)

默认情况下,结果存储为 稀疏向量,这意味着只有非零元素被保留在内存中。为了计算这个向量的总大小,我们需要将其转换回 密集向量(其中所有元素,包括 0,都存储在内存中):

>>> count_vect_sparse[0].todense().size

通过检查为第一条消息创建的特征向量长度,我们可以看到它为每个消息创建了一个长度为 7,468 的向量,其中 1 和 0 分别表示特定单词在文档列表中的存在或不存在。

我们可以使用以下命令检查这个长度实际上与词汇表(消息中所有唯一单词的并集)相同,该命令提取向量化器的 vocabulary_ 元素,它也给出了 7,468 的值:

>>> len(CountVectorizer().fit(spam.text).vocabulary_)Recall from the earlier that individual words might not informative features if their meaning is dependent upon the context given by other words in a sentence. Thus, if we want to expand our feature set to potentially more powerful features, we could also consider n-grams, sets of n co-occurring words (for example, the phrase \the red house contains the n-grams the red, and red house (2-grams), and the red house (3-gram)). These features are calculated similarly as above, by supplying the argument ngram_range to the CountVectorizer constructor:
>>> count_vect_sparse = CountVectorizer(ngram_range=(1, 3)).fit_transform(spam.text)

我们可以看到,通过再次检查第一行的长度,这个操作将结果特征的大小增加了大约 10 倍:

>>> count_vect_sparse[0].todense().sizeInsert

然而,即使在计算了 n-gram 之后,我们仍未考虑到某些单词或 n-gram 可能跨越所有消息,因此提供的信息很少,难以区分垃圾邮件和非垃圾邮件。为了解决这个问题,我们可能不会简单地记录单词(或 n-gram)的存在或不存在,而是比较文档中单词的频率与所有文档的频率。这个比率,即 词频-逆文档频率tf-idf),以最简单形式计算如下:

从文本数据中提取特征

其中ti 是一个特定的术语(单词或 n-gram),dj 是一个特定的文档,D是文档的数量,Vj 是文档j中的单词集合,vk 是文档j中的一个特定单词。这个公式中的下标1被称为指示函数,如果下标条件为true则返回1,否则返回0。本质上,这个公式比较了一个单词在文档中的频率(计数)与包含这个单词的文档数量。随着包含该单词的文档数量的减少,分母减少,因此整体公式在除以一个远小于1的值时变得更大。这通过分子中单词在文档中的频率来平衡。因此,tf-idf分数将更重视在文档中频率更高的单词,相对于在所有文档中都常见的单词,这些单词可能表明特定消息的特殊特征。

注意,上面的公式仅代表这个表达式的最简单版本。还有一些变体,我们可能会对计数进行对数变换(以抵消来自大型文档的偏差),或者通过文档中任何术语的最大频率来缩放分子(再次,为了抵消较长的文档可能由于拥有更多单词而具有比短文档更高的术语频率的偏差)(Manning, Christopher D., Prabhakar Raghavan, and Hinrich Schütze. 评分、术语加权和向量空间模型. 信息检索导论 100 (2008): 2-4)。我们可以使用以下命令对垃圾邮件数据应用tf-idf

>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> tf_idf = TfidfVectorizer().fit_transform(spam.text)

我们可以通过使用以下方法来观察这种变换的影响:

>>> tf_idf.todense().max(1)

max函数的1参数指示函数是沿着行(而不是列,列可以通过0指定)应用时。当我们的特征仅由二进制值组成时,每行的最大值会是1,但我们现在可以看到它是一个浮点值。

我们将要讨论的最后一个文本特征是关于压缩我们的特征集。简单来说,当我们考虑越来越大的词汇表时,我们会遇到许多非常罕见的单词,以至于几乎从未出现过。然而,从计算的角度来看,即使一个文档中的一个单词的实例也足以增加我们所有文档文本特征中的列数。鉴于这一点,我们可能认为可以通过压缩空间需求来减少列数,从而用更少的列来表示相同的数据集。虽然在某些情况下,两个单词可能映射到同一列,但由于单词频率的长尾分布,这种情况在实践中很少发生,这可以作为一种方便的方法来降低我们文本数据的维度。为了执行这种映射,我们使用一个哈希函数,该函数将单词作为输入并输出一个随机数(列位置),该随机数与该字符串的值相关联。我们最终映射到转换后的数据集中的列数由HashingVectorizern_features参数控制,我们可以使用以下命令将其应用于我们的数据集:

>>> from sklearn.feature_extraction.text import HashingVectorizer
>>> h = HashingVectorizer(n_features=1024).fit_transform(spam.text)

使用降维简化数据集

尽管使用HashingVectorizer可以将数据减少到从更大的特征集到 1,024 列,但我们数据集中仍然存在许多变量。直觉告诉我们,这些特征中的一些,无论是在应用HashingVectorizer之前还是之后,可能存在相关性。例如,一组单词可能在一个垃圾邮件文档中同时出现。如果我们使用 n-gram 并且单词相互相邻,我们可能会注意到这个特征,但如果单词只是出现在消息中但被其他文本分隔,则不会。例如,如果某些常见术语出现在消息的第一句话中,而其他术语则接近结尾。更广泛地说,考虑到我们已看到的大量变量,例如文本数据,我们可能会问是否可以用更紧凑的特征集来表示这些数据。换句话说,是否有潜在的规律可以描述成千上万的变量变化,这些变量可以通过计算代表个体变量之间相关性的更少数量的特征来提取?在某种程度上,我们在第三章中已经看到了几个这个想法的例子,即通过将单个数据点聚合到聚类中来降低数据集的复杂性。在以下例子中,我们有一个类似的目标,但不是聚合单个数据点,而是想要捕捉相关变量的组。

尽管我们可能通过变量选择技术如正则化(我们在第四章中讨论过)部分实现这一目标,即“通过模型连接点 – 回归方法”,但我们并不一定想要删除变量,而是要捕捉它们变化的共同模式。

让我们考察一些常见的降维方法,以及对于给定问题我们如何在这之间进行选择。

主成分分析

最常用的降维方法之一是主成分分析PCA)。从概念上讲,PCA 计算数据变化最大的轴。你可能还记得,在第三章中,“在噪声中寻找模式 – 聚类和无监督学习”,我们计算了数据集的邻接矩阵的特征值以执行谱聚类。在 PCA 中,我们同样想要找到数据集的特征值,但在这里,我们不会使用任何邻接矩阵,而是会使用数据的协方差矩阵,它是列之间的相对变化。数据矩阵X中列xixj的协方差由以下公式给出:

主成分分析

这是均值列值偏移的平均乘积。我们在第三章中计算相关系数时见过这个值,作为皮尔逊系数的分母。让我们用一个简单的例子来说明 PCA 是如何工作的。我们将创建一个数据集,其中六个列是从同一个基本正态分布中得出的,其中一个列的符号被反转,使用以下命令:

>>> syn_1 = np.random.normal(0,1,100)
>>> syn_2 = -1*syn_1
>>> syn_data = [ syn_1, syn_1, syn_1, syn_2, syn_2, syn_2]

注意,我们每一列的均值都是 0,标准差是 1。如果不是这样,我们可以使用我们在第三章中讨论的 scikit-learn 工具 StandardScaler,即 在噪声中寻找模式 – 聚类和无监督学习,当我们对数据进行归一化以用于 k 均值聚类时。如果我们认为变量的尺度差异对我们问题很重要,我们可以简单地将变量中心化到 0,并使用得到的协方差矩阵。否则,尺度差异将倾向于通过数据列中的不同方差值来反映,因此我们的结果 PCA 将不仅反映变量之间的相关性,还会反映它们的大小差异。如果我们不想强调这些差异,并且只对变量之间的相对相关性感兴趣,我们还可以将数据中的每一列除以其标准差,使每一列的方差为 1。我们还可以潜在地运行 PCA,不是在协方差矩阵上,而是在变量之间的皮尔逊相关矩阵上,该矩阵已经自然地缩放到 0 和一个常数范围(从 -1 到 1)的值(Kromrey, Jeffrey D. 和 Lynn Foster-Johnson. 调节多重回归中的均值中心化:无事生非. 教育与心理测量 58.1 (1998): 42-67)。现在,我们可以使用以下命令计算我们数据的协方差矩阵:

>>> syn_cov = np.cov(syn_data)

回想我们在第三章中关于谱聚类的讨论,即 在噪声中寻找模式 – 聚类和无监督学习,如果我们把协方差矩阵看作是对向量的拉伸操作,那么,如果我们找到沿着这些扭曲方向的向量,我们就在某种程度上找到了定义数据变化的轴。如果我们然后比较这些向量的特征值,我们可以确定这些方向中是否有一个或多个反映了数据整体变化的一个更大比例。让我们使用以下命令计算协方差矩阵的特征值和向量:

>>> [eigenvalues, eigenvectors] = np.linalg.eig(syn_cov)

这给出了以下特征值变量:

array([  0.00000000e+00,   4.93682786e+00,   1.23259516e-32,          1.50189461e-16,   0.00000000e+00,  -9.57474477e-34])

你可以看到,除了第二个之外,大多数特征值实际上都是零。这反映了我们构建的数据,尽管有六个列,实际上只来源于一个数据集(一个正态分布)。这些特征向量的另一个重要特性是它们是正交的,这意味着在 n 维空间中它们彼此垂直:如果我们把它们之间的点积取出来,它将是 0,因此它们代表独立的向量,当它们线性组合时,可以用来表示数据集。

如果我们将数据乘以与第二个特征值对应的特征向量,我们将把数据从六维空间投影到一维空间:

>>> plt.hist(np.dot(np.array(syn_data).transpose(),np.array(eigenvectors[:,1])))

注意,我们需要转置数据以获得 100 行和 6 列,因为我们最初将其构建为一个包含 6 列的列表,而 NumPy 将其解释为有 6 行和 100 列。结果直方图如下所示:

主成分分析

换句话说,通过将数据投影到最大方差轴上,我们恢复了这样一个事实,即这六个列数据实际上是从一个单一分布生成的。现在,如果我们使用 PCA 命令,我们会得到类似的结果:

>>> syn_pca = PCA().fit(np.array(syn_data))

当我们提取explained_variance_ratio_时,算法实际上已经取了前面的特征值,按大小排序,然后除以最大的一个,得到:

array([  1.00000000e+000,   6.38413622e-032,   2.02691244e-063,          2.10702767e-094,   3.98369984e-126,   5.71429334e-157])

如果我们将这些数据绘制成条形图,一种称为“特征值分布图”的可视化可以帮助我们确定数据中代表了多少个潜在成分:

>>> scree, ax = plt.subplots()
>>> plt.bar(np.arange(0,6),syn_pca.explained_variance_ratio_)
>>> ax.set_xlabel('Component Number')
>>> ax.set_ylabel('Variance Explained')
>>> plt.show()

这生成了以下图表:

主成分分析

显然,只有第一个成分携带任何方差,由条形的高度表示,其他所有成分都接近 0,因此在图表中不显示。这种视觉分析方法类似于我们在第三章中寻找 k-means 的惯性函数中的肘部,在噪声中寻找模式 – 聚类和无监督学习,作为 k 的函数来确定数据中存在多少个簇。我们还可以提取投影到第一个主成分上的数据,并看到与之前将数据投影到协方差矩阵的特征向量上时相似的图表:

>>> plt.hist(syn_pca.components_[0])

主成分分析

为什么它们不完全相同?虽然从概念上讲,PCA 计算协方差矩阵的特征值,但在实践中,大多数软件包为了数值效率并没有实际实现我们之前展示的计算。相反,它们采用一种称为奇异值分解SVD)的矩阵运算,试图将X的协方差矩阵表示为一系列低维行和列矩阵:

主成分分析

其中,如果X是一个nm列的矩阵,W 可能是一个nk列的矩阵,其中k远小于m。在这里,σ代表一个对角线以外的所有元素都是 0 的矩阵,对角线上的元素是非零的。因此,协方差矩阵表示为两个较小的矩阵的乘积和一个由σ的对角线元素给出的缩放因子。与之前我们计算协方差矩阵的所有特征向量不同,我们可以只要求 k 列或 WT,我们认为它们可能是有意义的,根据我们上面展示的散点图分析。然而,当我们通过这种方法将数据投影到主成分上时,奇异值分解的计算可能会给数据在主成分上的投影带来不同的符号,即使这些成分的相对大小和符号保持不变。因此,当我们查看将数据投影到前 k 个主成分后分配给特定行的分数时,我们应该将它们与其他数据集中的值进行比较,就像我们在第三章中检查多维尺度产生的坐标时一样,在噪声中寻找模式 – 聚类和无监督学习。默认 scikit-learn PCA 实现中使用的 SVD 计算的详细信息见(Tipping, Michael E.,and Christopher M. Bishop. 概率主成分分析. 英国皇家统计学会会刊:系列 B(统计方法)61.3 (1999): 611-622.).

现在我们已经从概念上了解了 PCA 计算的内容,让我们看看它是否可以帮助我们减少文本数据集的维度。让我们对上面的 n-gram 特征集运行 PCA,请求 100 个成分。请注意,因为原始数据集是一个稀疏矩阵,而 PCA 需要一个密集矩阵作为输入,我们需要使用toarray()将其转换。此外,为了保留与 PCA 拟合函数使用的正确维度,我们需要转置结果:

>>>  pca_text = PCA(num_components=10).fit(np.transpose(count_vect_sparse.toarray()))

如果我们将这个数据集的前 10 个主成分解释的总方差绘制成散点图,我们会看到我们可能需要相对较多的变量来捕捉数据的变异,因为解释的方差上升趋势相对平滑:

>>> scree, ax = plt.subplots()
>>> plt.bar(np.arange(0,10),pca_text.explained_variance_ratio_)
>>>ax.set_xlabel('Component Number')
>>>ax.set_ylabel('Variance Explained')
>>> plt.show()

主成分分析

我们也可以通过查看使用k个成分解释的累积方差来可视化这一点,如下面的曲线所示:

>>> scree, ax = plt.subplots()
>>> plt.plot(pca_text.explained_variance_ratio_.cumsum())
>>> ax.set_xlabel('Number of Components')
>>> ax.set_ylabel('Cumulative Variance Explained')
>>> plt.show() 

主成分分析

关于归一化的一点说明:在实践中,对于文档数据,我们可能不希望通过减去均值并除以方差来缩放数据,因为数据大多是二进制的。相反,我们只需对二进制矩阵或我们之前计算过的 tF-idf 分数应用 SVD,这种方法也称为潜在语义索引LSI)(Latent Semantic Indexing)(Berry, Michael W., Susan T. Dumais, and Gavin W. O'Brien. Using linear algebra for intelligent information retrieval. SIAM review 37.4 (1995): 573-595; Laham, T. K. L. D., and Peter Foltz. Learning human-like knowledge by singular value decomposition: A progress report. Advances in Neural Information Processing Systems 10: Proceedings of the 1997 Conference. Vol. 10. MIT Press, 1998.)。

使用 PCA 降低数据集维度的潜在缺点可能有哪些?首先,PCA 生成的成分(协方差矩阵的特征向量)本质上仍然是数学实体:这些轴所表示的变量模式可能实际上并不对应于数据的任何元素,而是它们的线性组合。这种表示并不总是容易理解,尤其是在尝试将此类分析的结果传达给领域专家以生成特定主题见解时尤其困难。其次,PCA 在其特征向量中产生负值,即使对于只有正值的文本数据(在一个文档中,一个术语不能以负值出现,只能是 0、1、计数或频率),这也是由于数据是使用这些因素进行线性组合的。换句话说,当我们通过矩阵乘法将数据投影到其成分上时,正负值可能会相加,从而产生投影的整体正值。再次,可能更希望有能够提供对数据本身结构的洞察力的因素,例如,通过提供一个由一组在特定文档组中倾向于共同出现的单词的二进制指示符组成的因素。这些目标通过两种其他矩阵分解技术得到解决:CUR 分解和非负矩阵分解。

与 PCA 中使用的 SVD 类似,CUR 试图将数据矩阵 X 表示为低维矩阵的乘积。在这里,CUR 分解试图找到矩阵的列和行集合,以最佳方式表示数据集,如下所示:

主成分分析

其中 C 是原始数据集的 c 列矩阵,R 是原始数据集的 r 行集合,U 是缩放因子矩阵。在此重建中使用的 c 列和 r 行是从原始矩阵的列和行中采样的,其概率与杠杆分数成正比,该分数由以下给出:

主成分分析

其中 lvj 是列(行)j 的统计杠杆,kX 的奇异值分解中的分量数量,而 vj 是这些 k 个分量向量的第 j 个元素。因此,如果列(行)对矩阵奇异值总体范数的贡献显著,它们将以高概率被采样,这意味着它们也对从奇异值分解(例如,奇异值分解如何逼近原始矩阵)的重建误差有重大影响(Chatterjee, Samprit, 和 Ali S. Hadi. 线性回归中的敏感性分析. 第 327 卷. 约翰·威利与 Sons, 2009; Bodor, András, 等人. rCUR: 用于 CUR 矩阵分解的 R 包. 生物信息学杂志 13.1 (2012): 1)。

虽然这种分解可能不会像 PCA 中使用的奇异值分解方法那样精确地逼近原始数据集,但结果因子可能更容易解释,因为它们是原始数据集的实际元素。

注意

请注意,虽然我们使用奇异值分解来确定列和行的采样概率,但 CUR 的最终分解并不这样做。

存在许多生成 CUR 分解的算法(Mahoney, Michael W. 和 Petros Drineas. CUR 矩阵分解以提高数据分析. 美国国家科学院院刊 106.3 (2009): 697-702. Boutsidis, Christos 和 David P. Woodruff. 最优 CUR 矩阵分解*. 第 46 届年度 ACM 理论计算研讨会. ACM, 2014)。CUR 分解在 pymf 库中实现,我们可以使用以下命令调用它:

>>> cur = pymf.CUR(count_vect_sparse.toarray().transpose(),crank=100,rrank=100)
>>> cur.factorize() >>> cur.factorize()

crankrrank 参数指示在执行分解过程中应从原始矩阵中选择多少行和列。然后我们可以使用以下命令来打印出在这次重建中选择的列(词汇表中的单词)的索引包含在 cur 对象的 ._cid(列索引)元素中,以检查这些显著单词。首先我们需要收集我们垃圾邮件数据集词汇表中的所有单词列表:

>>> vocab = CountVectorizer().fit(spam.text).vocabulary_
>>> vocab_array = ['']*len(vocab.values())
>>> for k,v in vocab.items():
…      vocab_array[v]=k
>>>vocab_array = np.array(vocab_array)

由于 CountVectorizer 返回的 vocabulary_ 变量是一个字典,它给出了术语在映射到的数组中的位置,我们需要通过放置由这个字典给出的位置上的单词来构建我们的数组。现在我们可以使用以下命令打印出相应的单词:

>>> for i in cur._cid:
… print(vocab_array[i])

与 CUR 类似,非负矩阵分解试图找到一组正成分来表示数据集的结构(Lee, Daniel D., 和 H. Sebastian Seung. 通过非负矩阵分解学习物体的部分. 自然 401.6755 (1999): 788-791; Lee, Daniel D., 和 H. Sebastian Seung. 非负矩阵分解的算法. 神经信息处理系统进展. 2001.; P. Paatero, U. Tapper (1994). Paatero, Pentti, 和 Unto Tapper. 正矩阵分解:一种非负因子模型,具有对数据值误差估计的最优利用. 环境计量学 5.2 (1994): 111-126. Anttila, Pia, 等人. 通过正矩阵分解识别芬兰的大量湿沉降源. 大气环境 29.14 (1995): 1705-1718.). 同样,它试图使用以下方法重建数据:

主成分分析

其中 WH 是低维矩阵,当相乘时可以重建 XWHX 的所有三个矩阵都被限制为没有负值。因此,X 的列是 W 的线性组合,使用 H 作为系数。例如,如果 X 的行是单词,列是文档,那么 X 中的每个文档都表示为 W 中基本文档类型的线性组合,其权重由 H 给定。与 CUR 分解返回的元素一样,非负矩阵分解的 W 成分可能比我们从 PCA 得到的特征向量更容易解释。

计算矩阵 WH 的算法有好几种,其中最简单的一种是通过乘性更新(Lee, Daniel D., 和 H. Sebastian Seung. 非负矩阵分解的算法. 神经信息处理系统进展. 2001)。例如,如果我们想最小化 XWH 之间的欧几里得距离:

主成分分析

我们可以计算这个值相对于 W 的导数:

主成分分析

然后为了更新 W,我们在每一步乘以这个梯度:

主成分分析

对于 H 也是如此:

主成分分析

这些步骤会重复进行,直到 WH 的值收敛。让我们看看当我们使用 NMF 提取成分时,从我们的文本数据中检索到的成分:

>>> from sklearn.decomposition import NMF
>>> nmf_text = NMF(n_components=10).fit(np.transpose(count_vect_sparse.toarray())

然后,我们可以查看 NMF 成分所表示的单词,其中单词在分解后的成分矩阵中有较大的值。

我们可以看到它们似乎捕捉到不同的单词组,但它们是否与区分垃圾邮件和非垃圾邮件相关?我们可以使用 NMF 分解来转换我们的原始数据,这将给出线性组合这些特征的权重(例如,将分解得到的 10 个基文档线性组合以重建消息的权重)使用以下命令:

>>> nmf_text_transform = nmf_text.transform(count_vect_sparse.toarray())

现在,让我们绘制每个这些nmf因素分配给正常和垃圾邮件的平均权重。我们可以通过绘制一个条形图来实现,其中x轴是 10 个nmf因素,而y轴是分配给这个因素的平均权重,对于文档的一个子集:

>>> plt.bar(range(10),nmf_text_transform[spam.label=='spam'].mean(0))

主成分分析

>>> plt.bar(range(10),nmf_text_transform[spam.label=='ham'].mean(0))

主成分分析

令人鼓舞的是,因素 8 和 9 在这两类消息之间似乎具有非常不同的平均权重。实际上,我们可能需要少于 10 个因素来表示数据,因为这两类可能很好地对应于潜在的垃圾邮件与非垃圾邮件消息。

潜在狄利克雷分配

一种将数据分解为可解释特征集的相关方法是潜在狄利克雷分配LDA),这是一种最初为文本和基因数据开发的方法,后来已扩展到其他领域(Blei, David M., Andrew Y. Ng, 和 Michael I. Jordan. 潜在狄利克雷分配. 机器学习研究杂志 3 (2003): 993-1022。Pritchard, Jonathan K., Matthew Stephens, 和 Peter Donnelly. 使用多座位点基因型数据推断种群结构. 遗传学 155.2 (2000): 945-959)。与之前我们探讨的方法不同,那些方法将数据表示为一系列低维矩阵的集合,这些矩阵相乘可以近似原始数据,LDA 使用一个概率模型。这个模型通常使用一个板图来解释,该图说明了变量之间的依赖关系,如下面的图所示:

潜在狄利克雷分配

这个图究竟描述了什么?它被称为生成模型:一组生成文档概率分布的指令。这个想法与你可能熟悉的正态分布“钟形曲线”类似,但在这里,我们不是从分布中抽取实数,而是抽样文档。生成模型可以与我们在前几章中看到的预测方法进行对比,这些方法试图将数据拟合到响应(例如,我们在第四章 Chapters 4、Connecting the Dots with Models – Regression Methods和第五章 Chapter 5、Putting Data in its Place – Classification Methods and Analysis中研究的回归或分类模型)中看到的,而不是简单地根据分布生成数据的样本。平板图表示这个生成模型的组件,我们可以将这个模型视为以下一系列步骤来生成一个文档:初始化一个狄利克雷分布来从一组主题中进行选择。这些主题类似于我们在 NMF 中找到的组件,可以被视为代表一组共同出现的单词的基础文档。狄利克雷分布由以下公式给出:

潜在狄利克雷分配

前面的公式给出了观察给定分布(此处为主题)在 K 个类别中的概率,并且可以用来抽样一个 K 个类别成员的向量(例如,抽样一个随机向量,给出集合中属于特定主题的文档比例)。狄利克雷分布中的 alpha 参数用作 K 类别概率的指数,并增加分配给特定成分(例如,更频繁的主题)的重要性。术语 B 是贝塔函数,它只是一个归一化项。我们在步骤 1 中使用狄利克雷分布来生成文档 i 的每个主题的概率分布。这个分布将是,例如,一系列权重,它们的和为 1,给出文档属于特定主题的相对概率。这是平板图中的参数 θ。M 代表我们数据集中的文档数量。

  1. 对于文档中的每个 N 个单词位置,从分布 θ 中选择一个主题 Z。每个 M 个主题都有一个参数 β 的狄利克雷分布,而不是给出每个单词的概率,给出 ϕ。使用这个分布来选择文档中每个 N 位置的单词。

  2. 对数据集中的每个文档的每个单词位置重复步骤 2–4,以生成一组文档。

在之前的图中,矩形内的数字(MNK)表示圆圈所代表的变量在生成模型中生成的次数。因此,作为最内层的单词 w 被生成N × M次。你还可以注意到,矩形包围了生成相同次数的变量,而箭头则表示在此数据生成过程中变量之间的依赖关系。你现在也可以理解这个模型名称的由来,因为文档在许多主题中潜在分配,正如我们在 NMF 中使用因子来找到可以重建我们观察到的数据的'基础文档'的线性组合。

此配方也可以用来找到一组主题(例如单词概率分布),这些主题适合数据集,假设之前描述的模型用于生成文档。不深入推导的细节,我们随机初始化一个固定的K个主题数量,并按照之前描述的方式运行模型,即始终采样一个文档的主题,给定所有其他文档,以及一个单词,给定文档中所有其他单词的概率。然后我们根据观察到的数据更新模型的参数,并使用更新的概率再次生成数据。经过多次迭代,这个被称为 Gibbs 抽样的过程将从随机初始化的值收敛到一组最佳拟合观察到的文档数据的模型参数。现在让我们使用以下命令将 LDA 模型拟合到垃圾邮件数据集:

>>> lda = LatentDirichletAllocation(n_topics=10).fit(count_vect_sparse)

与 NMF 一样,我们可以使用以下方法检查每个主题的最高概率单词:

>>> for i in range(10):
…    print(vocab_array[np.argsort(lda.components_[i])[1:10]])

同样,我们可以看到这些主题是否代表了垃圾邮件和非垃圾邮件之间的有意义分离。首先,我们使用以下transform命令找到每个文档在 10 个潜在主题中的主题分布:

>>> topic_dist = lda.transform(count_vect_sparse)

这与我们在 NMF 中计算的权重类似。现在我们可以按如下方式绘制每个消息类的平均主题权重:

>>> plt.bar(range(10),topic_dist[spam.label=='ham'].mean(0))

潜在狄利克雷分配

>>> plt.bar(range(10),topic_dist[spam.label=='spam'].mean(0))

潜在狄利克雷分配

再次,令人鼓舞的是,我们发现对于垃圾邮件和非垃圾邮件,主题 5 的平均权重不同,这表明 LDA 模型已成功分离出我们用于分类目的感兴趣的变化轴。

在预测建模中使用降维

我们之前概述的分析主要致力于通过找到一组较小的成分来提取文本集合的较低维表示,这些成分可以捕捉单个文档之间的变化。在某些情况下,这种分析可以作为探索性数据分析工具是有用的,就像我们在第三章中描述的聚类技术一样,在噪声中寻找模式 – 聚类和无监督学习,它使我们能够理解数据集的结构。我们甚至可以将聚类和降维结合起来,这本质上是我们考察的第三章中谱聚类的想法,在噪声中寻找模式 – 聚类和无监督学习,使用 SVD 将邻接矩阵减少到更紧凑的表示,然后对这一减少的空间进行聚类,以产生数据点之间更清晰的分离。

与通过聚类分配的组一样,我们也可以潜在地使用这些降维方法得到的成分作为预测模型中的特征。例如,我们之前提取的 NMF 成分可以用作分类模型的输入,以区分垃圾邮件和非垃圾邮件。我们甚至在此之前已经看到了这种用法,因为我们使用的在线新闻流行度数据集,在第四章中,使用模型连接点 – 回归方法,其列是从 LDA 主题中派生出来的。与我们在第四章中看到的正则化方法一样,降维可以通过提取变量之间的潜在相关性来帮助减少过拟合,因为这些低维变量通常比使用整个特征空间更少噪声。现在我们已经看到降维如何帮助我们找到文本数据中的结构,让我们考察图像中发现的另一类可能的高维数据。

图像

与文本数据一样,图像可能存在噪声和复杂性。此外,与具有单词、段落和句子结构的语言不同,图像没有我们可以用来简化原始数据的预定义规则。因此,图像分析的大部分工作将涉及从输入特征中提取模式,这些模式理想情况下仅基于输入像素对人类分析师来说是可解释的。

清洗图像数据

我们将在图像上执行的一种常见操作是增强对比度或改变它们的颜色范围。例如,让我们从一个来自skimage包的咖啡杯示例图像开始,您可以使用以下命令导入和可视化它:

>>> from skimage import data, io, segmentation
>>> image = data.coffee()
>>> io.imshow(image)
>>> plt.axis('off');

这会产生以下图像:

清洗图像数据

在 Python 中,这个图像被表示为一个三维矩阵,其维度对应于高度、宽度和颜色通道。在许多应用中,颜色并不重要,我们试图确定一组图像中常见的形状或特征,这些图像可以根据灰度级别区分。我们可以轻松地使用以下命令将此图像转换为灰度版本:

>>> grey_image = skimage.color.rgb2gray(image)
>>> io.imshow(grey_image)
>>> plt.axis('off');

清洗图像数据

在图像分析中,一个常见的任务是识别图像中的不同区域或对象。如果像素聚集在一个区域(例如,如果图像中有非常强烈的阴影或强烈的光线),而不是沿着强度谱均匀分布,这可能会使任务变得更加困难。为了识别不同的对象,通常希望这些强度均匀分布,我们可以通过以下命令执行直方图均衡化来实现这一点:

>>> from skimage import exposure
>>> image_equalized = exposure.equalize_hist(grey_image)
>>> io.imshow(image_equalized)
>>> plt.axis('off'); 

清洗图像数据

要查看这种归一化的效果,我们可以使用以下命令绘制变换前后像素强度的直方图:

>>> plt.hist(grey_image.ravel())

这给出了未校正图像的以下像素分布:

清洗图像数据

这里使用的ravel()命令用于将我们开始的二维数组展平成一个可能输入到直方图函数的单个向量。同样,我们可以使用以下命令绘制归一化后像素强度的分布:

>>> plt.hist(image_equalized.ravel(),color='b')

清洗图像数据

通过阈值化图像突出显示对象

对于图像分析来说,另一个常见的任务是识别单张图像中的单个对象。为此,我们需要选择一个阈值将图像二值化为白色和黑色区域,并分离重叠的对象。对于前者,我们可以使用阈值化算法,如 Otsu 阈值化(Otsu, Nobuyuki. 从灰度直方图中选择阈值的方法. 自动化 11.285-296 (1975): 23-27),它使用一个结构元素(例如 n 像素的圆盘)并试图找到一个像素强度,这个强度将最好地将该结构元素内的像素分为两类(例如,黑色和白色)。我们可以想象将一个圆盘在整个图像上滚动并进行这种计算,结果要么是在圆盘内的局部值,要么是一个全局值,它将图像分为前景和背景。然后我们可以通过阈值化高于或低于此值的像素将图像转换为二值掩码。

为了说明,让我们考虑一张硬币的图片,我们想要将硬币从背景中分离出来。我们可以使用以下命令可视化直方图均衡化的硬币图像:

>>> coins_equalized = exposure.equalize_hist(skimage.color.rgb2gray(data.coins()))
>>> io.imshow(coins_equalized)

通过阈值化图像突出显示对象

我们可以看到的一个问题是背景有一个光照梯度,向图像的左上角增加。这种差异不会改变背景和对象(硬币)之间的区别,但由于背景的一部分与硬币具有相同的强度范围,这将使得分离硬币本身变得困难。为了减去背景,我们可以使用闭合函数,它依次侵蚀(移除小于结构元素的白色区域)然后膨胀(如果结构元素内有白色像素,结构元素内的所有元素都翻转成白色)。在实践中,这意味着我们移除小的白色斑点并增强剩余的浅色区域。如果我们然后从图像中减去这部分,就像这里所示,我们就可以减去背景:

>>> from skimage.morphology import opening, disk
>>> d=disk(50)
>>> background = opening(coins_equalized,d)
>>> io.imshow(coins_equalized-background) 

突出显示对象的图像阈值化

现在我们已经移除了背景,我们可以应用之前提到的 Otsu 阈值化算法,使用以下命令找到理想的像素来将图像分割成背景和对象:

>>> from skimage import filter
>>> threshold_global_otsu = filter.threshold_otsu(coins_equalized-background)
>>> global_otsu = (coins_equalized-background) >= threshold_global_otsu
>>> io.imshow(global_otsu)

突出显示对象的图像阈值化

现在图像已经被分割成硬币和非硬币区域。我们可以使用这个分割后的图像来计数硬币的数量,例如,如果我们只想从硬币区域记录像素数据作为使用图像数据进行预测建模特征的一部分,我们可以使用上面获得区域作为掩码来突出显示原始图像中的硬币。

图像分析的降维

一旦我们适当地清理了我们的图像,我们如何将它们转换成更通用的建模特征呢?一种方法是通过使用我们之前用于文档数据的相同降维技术来尝试捕捉一组图像之间的共同变化模式。在文档中,我们用单词表示,而在图像中,我们有像素的模式,但除此之外,相同的算法和分析在很大程度上适用。作为一个例子,让我们考虑一组人脸图像(www.geocities.ws/senthilirtt/Senthil%20Face%20Database%20Version1),我们可以使用以下命令加载和检查:

>>> faces = skimage.io.imread_collection('senthil_database_version1/S1/*.tif')
>>> io.imshow(faces[1])

图像分析的降维

对于这两维图像中的每一个,我们希望将其转换成一个向量,就像我们在讨论归一化时绘制像素频率直方图时做的那样。我们还将构建一个集合,其中从每个像素中减去人脸的平均像素强度,从而得到每个脸相对于数据中的平均脸的偏移,以下命令:

>>> faces_flatten = [f.ravel() for f in faces]
>>> import pylab
>>> faces_flatten_demean = pylab.demean(faces_flatten,axis=1)

我们考虑了两种将面孔分解为更一般特征的可能方法。第一种是使用 PCA 来提取这些数据中的主要变化向量——这些向量碰巧看起来也像面孔。由于它们是由协方差矩阵的特征值形成的,这类特征有时被称为 eigenfaces。以下命令说明了在面部数据集上执行 PCA 的结果:

>>> from sklearn.decomposition import PCA
>>> faces_components = PCA(n_components=3).fit(faces_flatten_demean)
>>> io.imshow(np.reshape(faces_components.components_[1],(188,140)))

图像分析的降维

面部数据中的多少变化可以通过主成分来捕捉?与文档数据相比,我们可以看到,即使只使用三个成分,PCA 也能解释数据集中大约三分之二的变化:

>>> plt.plot(faces_components.explained_variance_ratio_.cumsum())

图像分析的降维

我们也可以像之前描述的那样应用 NMF,以找到一组基面孔。你可以从前面的热图中注意到,我们提取的 eigenfaces 可以具有负值,这突出了我们之前提到的一个解释困难:我们实际上不可能有负像素(因为 ,所以具有负元素的潜在特征很难解释。相比之下,我们使用 NMF 提取的成分将看起来更像是原始数据集的元素,如下所示,使用以下命令:

>>> from sklearn.decomposition import NMF
>>> faces_nmf = NMF(n_components=3).fit(np.transpose(faces_flatten)) 
>>> io.imshow(np.reshape(faces_nmf.components_[0],(188,140))) 

图像分析的降维

与类似于许多图像平均版本的 eigenfaces 不同,从这些数据中提取的 NMF 成分看起来像单个面孔。虽然我们在这里不会进行练习,但我们甚至可以将 LDA 应用于图像数据以找到由像素分布表示的主题,实际上它已经被用于这个目的(Yu, Hua, 和 Jie Yang. 《高维数据直接 LDA 算法——应用于人脸识别》. 模式识别 34.10 (2001): 2067-2070; Thomaz, Carlos E. 等. 《基于最大不确定性的 LDA 方法用于分类和分析 MR 脑图像》. 医学图像计算和计算机辅助干预——MICCAI 2004. Springer Berlin Heidelberg, 2004. 291-300.)。

尽管我们之前讨论的降维技术在理解数据集、聚类或建模的上下文中很有用,但它们在存储数据的压缩版本方面也可能很有用。特别是在我们将在第八章 Chapter 8 中开发的模型服务中,能够存储数据的小版本可以减少系统负载,并提供一种更容易将传入数据处理成预测模型可以理解的形式的方法。例如,我们可以快速提取所需的少量成分,例如,从新的文本数据中,而不必持久化整个记录。

案例研究:在 PySpark 中训练推荐系统

为了结束这一章,让我们看看如何使用降维技术生成一个大规模推荐系统的示例。我们将使用的数据集来自一家在线商店的用户交易记录(Chen, Daqing, Sai Laing Sain, 和 Kun Guo. 数据挖掘在在线零售行业中的应用:基于数据挖掘的 RFM 模型客户细分案例研究. 数据库营销与客户战略管理杂志 19.3 (2012): 197-208)。在这个模型中,我们将输入一个矩阵,其中行表示用户,列代表电子商务网站目录中的项目。用户购买的项目用 1 表示。我们的目标是使用 k 个组件将这个矩阵分解为 1 x k 的 用户因素(行成分)和 k x 1 的 项目因素(列成分)。然后,面对一个新用户及其购买历史,我们可以预测他们未来可能购买的项目,从而在主页上向他们推荐可能的产品。这样做的方法如下:

  1. 将用户的先前购买历史视为 一个 向量 p。我们想象这个向量是未知的 用户因素 成分 u 与我们通过矩阵分解获得的项目因素 i 的乘积:向量 p 的每个元素然后是未知用户因素与给定项目因素的点积。在方程中求解未知用户因素 u案例研究:在 PySpark 中训练推荐系统

    考虑到项目因素 i 和购买历史 p,使用矩阵。使用得到的用户因素 u,与每个项目因素进行点积运算以获得排序结果,从而确定排名靠前的项目列表。

现在我们已经描述了在这个例子中“幕后”发生的事情,我们可以开始使用以下命令解析这些数据。首先,我们创建一个解析函数来读取包含项目 ID 和用户 ID 的第 2 列和第 7 列的数据:

>>> def parse_data(line):
…     try: 
…         line_array = line.split(',')
…      return (line_array[6],line_array[1]) # user-term pairs
…      except:
…         return None

接下来,我们读取文件并将用户和项目 ID(都是字符串)转换为数值索引,通过在向字典添加唯一项目时递增计数器来完成:

>>> f = open('Online Retail.csv',encoding="Windows-1252")
>>> purchases = []
>>> users = {}
>>> items = {}
>>>user_index = 0
>>>item_index = 0
>>>for index, line in enumerate(f):
…    if index > 0: # skip header
…         purchase = parse_data(line)
…         if purchase is not None:
 …            if users.get(purchase[0],None) is not None:
 …                purchase_user = users.get(purchase[0])
 …            else:
 …                users[purchase[0]] = user_index
 …                user_index += 1
 …                purchase_user = users.get(purchase[0])
 …            if items.get(purchase[1],None) is not None:
 …               purchase_item = items.get(purchase[1])
…             else:
 …                items[purchase[1]] = item_index
 …                item_index += 1
 …                purchase_item = items.get(purchase[1])
 …           purchases.append((purchase_user,purchase_item))>>>f.close()

接下来,我们将得到的购买数组转换为 rdd,并将结果条目转换为 Rating 对象——一个 (用户, 项目, 评分) 元组。在这里,我们将仅通过给所有观察到的购买项目分配 1.0 的评分来表示购买的发生,但也可以有一个系统,其中评分表示用户偏好(如电影评分)并遵循数值尺度。

>>> purchasesRdd = sc.parallelize(purchases,5).map(lambda x: Rating(x[0],x[1],1.0))

现在我们可以使用以下命令拟合矩阵分解模型:

>>> from pyspark.mllib.recommendation import ALS, MatrixFactorizationModel, Rating

>>> k = 10
>>> iterations = 10
>>> mfModel = ALS.train(purchasesRdd, k, iterations)

PySpark 中使用的矩阵分解算法是交替最小二乘法ALS),它具有选择行(列)组件数量(k)的参数和一个正则化参数 λ,我们在这里没有指定,但它与我们在第四章“通过模型连接点 – 回归方法”中研究的回归算法中的角色类似,通过模型连接点 – 回归方法,通过限制行(列)向量中的值不会变得过大,从而可能引起过拟合。

我们可以尝试几个 k 和 λ 的值,并测量观测值和预测矩阵(通过将行因子乘以列因子)之间的均方误差,以确定最佳值。

一旦我们获得了一个良好的拟合,我们就可以使用模型对象的 predictpredictAll 方法来获取对新用户的预测,并使用 save 方法将其持久化到磁盘上。

摘要

在本章中,我们检查了复杂、非结构化的数据。我们清理和标记了文本,并检查了将文档特征提取到可以纳入预测模型(如 n-gram 和 tf-idf 分数)的几种方法。我们还检查了降维技术,如 HashingVectorizer,矩阵分解,如 PCA、CUR、NMF,以及概率模型,如 LDA。我们还检查了图像数据,包括归一化和阈值操作,以及我们如何使用降维技术来找到图像之间的共同模式。最后,我们使用矩阵分解算法在 PySpark 中原型化了一个推荐系统。

在下一节中,你还将查看图像数据,但处于不同的背景:尝试使用复杂的深度学习模型从这些数据中捕获复杂特征。

第七章. 从底层学习 – 深度网络和无监督特征

到目前为止,我们研究的是使用一组特征(表格数据集中的列)的预测建模技术,这些特征是为当前问题预先定义的。例如,用户账户、互联网交易、产品或任何对业务场景重要的项目通常使用特定行业的领域知识推导出的属性来描述。更复杂的数据,如文档,仍然可以转换成表示文本中单词的向量,如图像可以通过我们在第六章中看到的矩阵因子来表示,“文字与像素 – 处理非结构化数据”。然而,对于简单和复杂的数据类型,我们可以很容易地想象特征之间的高级交互(例如,某个国家某个年龄段的用户使用特定设备更有可能点击网页,而这三个因素中的任何一个单独都不是预测性的)以及全新的特征(如图像边缘或句子片段),这些特征在没有领域专业知识或大量试错的情况下很难构建。

理想情况下,我们可以自动找到用于预测建模任务的最佳特征,无论我们手头有什么原始输入,而无需测试大量变换和交互。这种能力——从相对原始的输入中自动确定复杂特征——是深度学习方法的一个吸引人的特性,这类算法通常应用于神经网络模型,近年来受到了广泛的欢迎。在本章中,我们将探讨以下主题:

  • 基本神经网络如何拟合数据

  • 深度学习方法如何提高经典神经网络的性能

  • 如何使用深度学习进行图像识别

使用神经网络学习模式

我们将要研究的深度学习算法的核心构建块是神经网络,这是一种模拟大脑内部细胞产生脉冲以传递信号的预测模型。通过结合许多输入(例如,我们可能在表格数据集中拥有的许多列、文档中的单词或图像中的像素)的个别贡献,网络整合信号以预测感兴趣的输出(无论是价格、点击率还是其他响应)。因此,将此类模型拟合到数据涉及确定神经元的最优参数,以执行从输入数据到输出变量的映射。

本章我们将讨论的深度学习模型的一些常见特征是我们能够调整的大量参数以及模型的复杂性。而到目前为止我们所看到的回归模型需要我们确定大约 50 个系数的最优值,在深度学习模型中,我们可能拥有数百或数千个参数。然而,尽管这种复杂性,深度学习模型由相对简单的单元组成,因此我们将从这些构建块开始研究。

一个网络——感知器

我们能想象到的最简单的神经网络由一个线性函数组成,被称为感知器(Rosenblatt, Frank. The perceptron, a perceiving and recognizing automaton Project Para. Cornell Aeronautical Laboratory, 1957):

一个网络——感知器

在这里,w 是列向量 x 中每个输入特征的权重集合,而 b 是截距。你可能认出这与我们在第五章中考察的支持向量机(SVM)的公式非常相似,将数据放在合适的位置 – 分类方法和分析,当使用的核函数是线性时。上述函数和 SVM 根据点是否位于由 w 给定的超平面之上或之下将数据分为两类。如果我们想使用数据集来确定这个感知器的最优参数,我们可以执行以下步骤:

  1. 将所有权重 w 设置为随机值。不同于使用固定的 b 作为偏移量,我们将向由 n x m 矩阵 X 表示的数据集矩阵中添加一列 1s 来表示这个偏移量,并与其他参数一起学习最优值。

  2. 计算模型对于数据集中特定观察值 x 的输出,即 F(xi)

  3. 使用学习率 α 根据以下公式更新权重:一个网络——感知器

  4. 在这里,y[i] 是目标(对于 x[i] 的真实标签 01)。因此,如果 F(x[i]) 太小,我们将增加所有特征上的权重,反之亦然。

  5. 对我们集合中的每个数据点重复步骤 2 和 3,直到达到最大迭代次数或平均误差达到:一个网络——感知器

  6. n 个数据点上的误差低于给定的阈值 ε(例如,1e-6)。

虽然这个模型容易理解,但它对许多问题来说在实践上存在局限性。首先,如果我们的数据不能被超平面分离(它们不是线性可分的),那么我们就永远无法正确分类所有数据点。此外,所有权重都使用相同的规则更新,这意味着我们需要学习所有数据点的特征重要性的共同模式。此外,由于输出只有 1 或 0,感知器仅适用于二元分类问题。然而,尽管存在这些局限性,这个模型展示了更复杂神经网络模型的一些共同特征。上面给出的训练算法根据由学习率调整的分类误差来调整模型权重,这种模式我们将在更复杂的模型中也会看到。我们经常看到像前面那样的阈值(二元)函数,尽管我们也会放宽这个限制,并研究使用其他函数。

我们如何将这个简单的感知器发展成为更强大的模型?作为第一步,我们可以从结合许多这种类型的单个模型的输入开始。

结合感知器——单层神经网络

正如生物大脑由单个神经元细胞组成一样,神经网络模型由一系列函数组成,例如之前讨论过的感知器。我们将把这些网络中的单个函数称为神经元单元。通过结合来自多个函数的输入,并使用一组权重进行混合,我们可以开始拟合更复杂的模式。我们还可以通过使用比感知器的线性决策边界更复杂的其他函数来捕捉非线性模式。一个流行的选择是我们之前在第五章中看到的对数变换,将数据放在合适的位置——分类方法和分析。回想一下,对数变换由以下公式给出:

结合感知器——单层神经网络

在这里,w是向量x的元素上的权重集合,b是一个偏移量或偏差,就像在感知器中一样。这个偏差在感知器模型中起着相同的作用,通过增加或减少模型计算出的分数的固定量,而权重则类似于我们在第四章中看到的回归系数,使用模型连接点 – 回归方法。为了在以下推导中简化符号,我们可以将值wx+b表示为一个单一变量z。正如在第五章中提到的逻辑回归模型,将数据放在合适的位置 – 分类方法和分析,这个函数将输入x映射到范围[0,1],并且可以如图 1 所示(图 1)将一个输入向量x(由图顶部的三个绿色单元组成)通过线(表示权重W)连接到一个单一的蓝色单元(函数F(z))进行视觉表示。

除了通过这种非线性变换增加我们分离数据的灵活性外,让我们也调整我们的目标定义。在感知器模型中,我们有一个单一的输出值01。我们也可以考虑将其表示为一个由两个单元组成的向量(如图 1 中所示,用红色表示),其中一个元素设置为1,另一个设置为0,表示数据点属于两个类别中的哪一个。这看起来可能像是一种不必要的复杂性,但随着我们构建越来越复杂的模型,它将变得非常有用。

经过这些修改,我们的模型现在由图 1 中显示的元素组成。逻辑函数从图顶部的向量表示的x的三个特征中获取输入,使用每个x元素的权重W1通过逻辑函数进行组合,并返回一个输出。然后,这个输出被下游的两个额外的逻辑函数使用,如图中底部的红色单元所示。左侧的函数使用第一个函数的输出给出类别1的概率分数。在右侧,第二个函数使用这个输出给出类别0的概率。同样,从蓝色单元到红色单元的输入由一个向量W2加权。通过调整W2的值,我们可以增加或减少激活其中一个红色节点并将其值设置为1的可能性。通过对这两个值取最大值,我们得到一个二元分类。

结合感知器 – 单层神经网络

图 1:基本单层神经网络的架构

这个模型仍然相对简单,但具有一些将在更复杂场景中出现的特征。首先,我们现在实际上有多个层,因为输入数据有效地形成了这个网络的最顶层(绿色节点)。同样,两个输出函数形成另一个层(红色节点)。因为它位于输入数据和输出响应之间,中间层也被称为网络的 隐藏层。相比之下,最底层被称为 可见层,最顶层是 输出层

目前,这并不是一个非常有趣的模型:虽然它可以使用逻辑函数从输入 x 进行非线性映射,但我们只有一组权重可以调整,这意味着我们只能通过重新加权来有效地从输入数据中提取一组模式或特征。从某种意义上说,它与感知器非常相似,只是决策函数不同。然而,只需进行一些修改,我们就可以轻松地开始创建更复杂的映射,这些映射可以适应输入特征之间的交互。例如,我们可以在隐藏层中添加两个额外的神经元,如图 图 2 所示。有了这些新单元,我们现在有三个可能不同的权重集,用于输入元素(每个代表输入的不同加权),每个在隐藏层中整合时都可能形成不同的信号。作为一个简单的例子,考虑如果向量代表一个图像:右侧和左侧的隐藏神经元可以接收权重 (1,0,0) 和 (0,0,1),拾取图像的边缘,而中间的神经元可以接收权重 (0,1,0),因此只考虑中间像素。输出层中两个类别的输出概率现在都受到三个隐藏神经元的影响。因此,我们现在可以调整向量 W2 中的权重参数,将三个隐藏单元的贡献汇总以决定两个类别的概率

结合感知器 - 单层神经网络

图 2:具有隐藏层中三个单元的更复杂架构

即使有这些修改,图 2 代表的模型仍然相对简单。为了增加模型的灵活性,我们可以在隐藏层中添加更多的单元。我们还可以通过在图的下部输出层添加更多单元来将这种方法扩展到具有两个以上类别的问题。此外,我们迄今为止只考虑了线性和对数逻辑变换,但正如我们将在本章后面看到的,我们有各种各样的函数可以选择。

然而,在我们考虑这个设计的更复杂变化之前,让我们检查确定此模型适当参数的方法。插入中间层意味着我们不能再依赖于我们为感知器模型使用的简单学习训练算法。例如,虽然我们仍然想要调整输入层和隐藏层之间的权重以优化预测和目标之间的误差,但最终的预测现在不是由隐藏层的输出给出,而是由其下面的输出层的输出给出。因此,我们的训练过程需要将输出层的误差纳入调整网络隐藏层的过程中。

使用反向传播进行参数拟合

给定图 2 中所示的三层网络,我们如何确定将我们的输入数据映射到输出的最佳权重集 W 和偏移量集 b?与感知器算法一样,我们最初可以将所有权重设置为随机数(一种常见策略是从均值为 0、标准差为 1 的正态分布中采样)。然后我们遵循网络从上到下的箭头流向,计算每个阶段的每个节点的逻辑变换,直到我们到达两个类别的概率。

为了调整我们随机选择的参数以更好地拟合输出,我们可以计算权重应该移动的方向以减少预测响应和观察响应 y 之间的误差,就像在感知器学习规则中一样。在图 2中,我们可以看到我们需要调整的两组权重:输入层和隐藏层之间的权重(W1)以及隐藏层和输出层之间的权重(W2)。

让我们从更简单的情况开始。如果我们正在调整最底层的权重(在输出层和隐藏层之间),那么我们希望找到随着我们改变权重时误差的变化(预测值和输出真实值之间的差异)。目前,我们将使用平方误差函数来演示:

使用反向传播进行参数拟合

在这里,yi 是标签的实际值,而 F(zi) 代表输出层中的一个红色神经元(在这个例子中,是一个逻辑函数)。我们希望计算当我们调整权重(图 2 中连接隐藏层蓝色神经元和输出层红色神经元的一条线)时,这个误差函数的变化,这个权重由变量 Wij 表示,其中 ij 是通过给定权重连接的红色神经元和蓝色神经元的索引。回想一下,这个权重实际上是变量 z 的一个参数(因为 z=wx+b),代表函数逻辑 F 的输入。由于变量 w 被嵌套在逻辑函数中,我们无法直接计算误差函数关于这个权重的偏导数来确定权重更新 Δw。

注意

我们想计算偏导数,因为误差是所有输入权重的函数,但我们希望独立更新每个权重

为了理解这一点,请回忆一下微积分中的一个例子,如果我们想要找到函数相对于 x 的导数:

参数拟合与反向传播

我们首先需要计算相对于 ez 的导数,然后乘以 z 相对于 x 的导数,其中 z=x²,得到最终值为 参数拟合与反向传播。这个模式更一般地表示为:

参数拟合与反向传播

这被称为链式法则,因为我们把嵌套函数中的导数在一起进行计算。实际上,尽管我们的例子只有单层嵌套,但我们可以将这个方法扩展到任意层数,只需要在上面的公式中插入更多的乘法项。

因此,当我们改变一个给定的权重 wij 时,要计算误差函数 E 的变化,我们首先需要计算误差函数相对于 F(z) 的导数,然后是 F(z) 相对于 z 的导数,最后是 z 相对于权重 wij 的导数。当我们把这三个偏导数相乘并约简分子和分母中的项时,我们得到误差函数相对于权重的导数。这就是之前描述的链式法则,用于计算特定权重 w 之间的误差相对于输出 i 和隐藏神经元 j 的偏导数:

参数拟合与反向传播

现在我们有了确定误差函数相对于权重的导数的公式,让我们确定这三个项中的每一个的值。第一个项的导数很简单,就是预测值和实际响应变量之间的差值:

参数拟合与反向传播

注意

请注意,这里的下标 i 指的是输出神经元的索引,而不是之前使用的数据点 i。

对于第二个项,我们发现逻辑函数的偏导数有一个方便的形式,即函数与 1 减去函数的乘积:

参数拟合与反向传播

最后,对于最后一个项,我们简单地,

参数拟合与反向传播

这里,F(z)j 是隐藏层神经元 j 的输出。为了调整权重 wij,我们希望朝着误差增加的反方向移动,就像我们在第五章中描述的随机梯度下降算法中做的那样,第五章,将数据放在合适的位置 – 分类方法和分析。因此,我们使用以下方程更新权重的值:

参数拟合与反向传播

其中 α 是学习率。对于第一组权重(输入层和隐藏层之间),计算稍微复杂一些。我们再次从一个与之前相似的公式开始:

使用反向传播进行参数拟合

其中 wjk 是隐藏神经元 j 和可见神经元 k 之间的权重。与输出 F(z)i 相比,现在计算的是误差相对于权重的偏导数,相对于隐藏神经元的输出 F(z)j。由于隐藏神经元连接到多个输出神经元,在第一项中,我们不能简单地使用误差相对于神经元输出的导数,因为 F(z)j 从所有这些连接接收错误输入:F(z)j 与误差之间没有直接关系,只有通过输出层的 F(z)i。因此,对于隐藏到可见层的权重,我们需要通过将链式法则应用于每个输出神经元 i 的连接来求和偏导数的第一个项的结果:

使用反向传播进行参数拟合

换句话说,我们沿着所有连接 wjk 到输出层的箭头求和偏导数。对于隐藏到可见层的权重,这意味着两条箭头(从每个输出到隐藏神经元 j)。

由于隐藏神经元的输入现在是可见层中的数据本身,方程中的第三项变为:

使用反向传播进行参数拟合

这只是数据向量 x 的一个元素。将这组值代入第一项和第三项,并使用之前给出的梯度下降更新,我们现在拥有了优化这个网络权重所需的所有成分。为了训练网络,我们重复以下步骤:

  1. 随机初始化权重(再次,使用标准正态分布的样本是一种常见的方法)。

  2. 从输入数据开始,按照 图 2 中的箭头(从上到下)通过网络前进,以计算底层输出。

  3. 使用步骤 2 中计算的结果与实际输出值(例如类别标签)之间的差异,使用前面的方程计算每个权重需要改变的数量。

  4. 重复步骤 1–3,直到权重达到一个稳定的值(这意味着新旧值之间的差异小于某个小的数值截止点,例如 1e-6)。

这个过程被称为反向传播(Bryson, Arthur E., Walter F. Denham, 和 Stewart E. Dreyfus. 带不等式约束的最优编程问题。AIAA 杂志 1.11 (1963): 2544-2550; Rumelhart, David E., Geoffrey E. Hinton, 和 Ronald J. Williams. 通过反向传播错误学习表示 认知建模 5.3 (1988): 1; Bryson, Arthur Earl. 应用最优控制:优化、估计和控制。CRC 出版社,1975;Alpaydin, Ethem. 机器学习导论。麻省理工学院出版社,2014。)因为从视觉上看,预测误差通过网络反向流动到输入的连接权重 w。在形式上,它与我们在本章开头讨论的感知器学习规则非常相似,但它适应了将预测误差与隐藏层和可见层之间的权重相关联的复杂性,这些权重取决于我们展示的示例中的所有输出神经元。

区分性模型与生成性模型

在前面描述的示例以及图 1 和图 2 中展示的例子中,箭头始终只从输入数据指向输出目标。这被称为前馈网络,因为信息的流动始终是单向的(Hinton, Geoffrey, 等人 在语音识别中的声学建模深度神经网络:四个研究小组的共同观点 IEEE 信号处理杂志 29.6 (2012): 82-97)。然而,这并不是一个硬性要求——如果我们有一个箭头在可见层中既向前又向后移动的模型(图 3),那么在某种意义上,我们可以拥有一个生成模型,这与在第六章中讨论的 LDA 算法类似,文字与像素 - 处理非结构化数据

区分性模型与生成性模型

图 3:从图 2 中看到的神经网络的顶层两个级别的受限玻尔兹曼机(RBM)

而不是简单地生成输出层中的预测目标(判别模型),这样的模型可以用来从输入数据的假设分布中抽取样本。换句话说,正如我们能够使用 LDA 中描述的概率模型生成文档一样,我们也可以使用从隐藏层到可见层的权重作为输入到可见神经元的输入,从可见层抽取样本。这种类型的神经网络模型也被称为信念网络,因为它可以用来模拟网络(以输入数据的形式)所表示的“知识”以及执行分类。在每一层中每个神经元之间都有连接的可见层和隐藏层是一种更普遍地被称为限制性玻尔兹曼机(RBM)的模型(Smolensky, Paul. Information processing in dynamical systems: Foundations of harmony theory. No. CU-CS-321-86. COLORADO UNIV AT BOULDER DEPT OF COMPUTER SCIENCE, 1986; Hinton, Geoffrey E., James L. Mcclelland, and David E. Rumelhart. Distributed representations, Parallel distributed processing: explorations in the microstructure of cognition, vol. 1: foundations. (1986).)。

除了通过模拟网络所接触到的可能输入数据点的空间中的样本来帮助我们理解数据的分布之外,RBM(限制性玻尔兹曼机)还可以成为我们构建的具有额外隐藏层的深度网络中的有用构建模块。然而,在添加这些额外层时,我们面临着许多挑战。

梯度消失和解释掉

即使图 2 和图 3 中显示的架构也不是我们能够想象的最复杂的神经网络。额外的隐藏层意味着我们可以添加输入特征之间的额外交互,但对于非常复杂的数据类型(如图像或文档),我们可以很容易地想象出可能需要超过一层混合和重组来捕捉所有感兴趣交互的情况。例如,可以想象一个文档数据集,其中网络捕获的个别单词特征被合并成句子片段特征,这些特征进一步合并成句子、段落和章节模式,从而可能提供 5+级别的交互。每个这样的交互都需要另一层隐藏神经元,因此连接数(以及需要调整的权重)相应增加。同样,一个图像可能被解析成不同分辨率的网格,这些网格合并成嵌套的更小和更大的对象。为了通过在我们的网络中添加额外的隐藏层(图 4)来适应这些更高级的交互,我们最终会创建一个越来越的网络。我们还可以添加额外的 RBM 层,就像我们描述的那样。这种增加的复杂性是否有助于我们学习更准确的模式?我们是否仍然能够使用反向传播算法计算这样一个系统的最优参数?

消失的梯度与解释

图 4:多层神经网络架构

让我们考虑在反向传播中添加额外层时会发生什么。回想一下,当我们推导出误差率变化作为第一层(可见层和第一隐藏层之间)权重函数的表达式时,我们最终得到一个乘积形式的方程,它是输出层和隐藏层之间的权重以及第一层中的权重的乘积:

消失的梯度与解释

让我们考虑当第一个项(输出层的误差总和)小于 1 时会发生什么。由于这个公式是乘积形式,整个表达式的值也会减小,这意味着我们将通过非常小的步骤改变 wjk 的值。现在回想一下,为了计算与可见到隐藏连接 wjk 相关的误差变化,我们需要对所有从输出到这个权重的连接进行求和。在我们的例子中,我们只有两个连接,但在更深层的网络中,我们最终会得到额外的项,例如第一个项,以捕捉隐藏层和输出层之间所有层的误差贡献。当我们乘以更多小于 1 的项时,整个表达式的值会越来越接近 0,这意味着在梯度步骤中权重的值几乎不会更新。相反,如果所有这些项的值都大于 1,它们将迅速增加整个表达式的值,导致权重的值在梯度更新步骤之间剧烈变化。

因此,误差作为隐藏到可见权重函数的变化往往接近 0 或以不稳定的方式增加,导致权重要么变化非常缓慢,要么在幅度上剧烈振荡。因此,训练网络将需要更长的时间,并且更难找到接近可见层的稳定权重值。随着我们添加更多层,这个问题会变得更糟,因为我们不断添加更多的误差项,这使得权重更难收敛到稳定值,因为增加表示梯度的乘积中的项数有更大的可能性缩小或爆炸值。

由于这种行为,仅仅通过添加更多层和使用反向传播来训练深度网络,并不能有效地通过在网络中包含多个隐藏层来生成更复杂的功能。事实上,这个问题也被称为梯度消失,因为随着我们添加层,梯度有更大的可能性缩小到零并消失,这是多层神经网络多年来在实际上不可行的主要原因之一(Schmidhuber, Jürgen. Deep learning in neural networks: An overview. Neural Networks 61 (2015): 85-117.)。从某种意义上说,问题是网络的较外层比深层更快地“吸收”了误差函数的信息,使得学习率(由权重更新表示)极为不均匀。

即使假设我们在反向传播过程中不受时间的限制,并且可以运行算法直到权重最终收敛(即使这个时间量对于实际应用来说不切实际),多层神经网络仍然存在其他困难,例如解释消除。

解释消除效应涉及一个输入单元压倒另一个输入单元效应的倾向。一个经典的例子(Hinton, Geoffrey E., Simon Osindero, 和 Yee-Whye Teh. A fast learning algorithm for deep belief nets. Neural computation 18.7 (2006): 1527-1554)是,如果我们的响应变量是一栋从地面跳起来的房子。这可以通过两个潜在输入的证据来解释,即是否有卡车撞到房子以及附近是否发生了地震(图 5):

梯度消失和解释消除

图 5:解释消除导致深度网络中的权重不平衡

如果发生了地震,那么这种地震是房屋移动原因的证据就非常强烈,以至于卡车碰撞的证据被最小化。简单来说,知道发生了地震就意味着我们不再需要任何额外的证据来解释房屋的移动,因此卡车证据的价值变得可以忽略不计。如果我们正在优化如何权衡这两种证据来源的权重(类似于我们可能如何权衡从隐藏神经元到输出单元的输入),那么卡车碰撞证据的权重可以设置为 0,因为地震证据解释了其他变量。我们可以开启这两个输入,但由于这两个事件同时发生的概率足够低,我们的学习过程不会最优地这样做。实际上,这意味着权重的值与隐藏神经元(由每种证据类型表示)是否开启(设置为 1)相关。因此,很难找到一组参数,不会以牺牲另一个权重为代价而饱和。考虑到梯度消失和解释掉的问题,我们如何在由多层组成的深度神经网络中找到最优参数呢?

预训练信念网络

消失梯度效应和解释消除效应在某种程度上是由以下事实引起的:如果我们从一个随机值集合开始并执行反向传播,那么在大型网络中找到一组最优权重是困难的。与我们在第五章中看到的逻辑回归目标函数不同,将数据放在合适的位置 – 分类方法和分析,深度学习网络中的最优误差不一定总是凸的。因此,通过反向传播进行多次梯度下降并不保证收敛到全局最优值。实际上,我们可以想象误差函数的空间是一个多维景观,其中高度代表误差函数的值,坐标代表不同的权重参数值。反向传播通过沿着这个景观的斜坡上下移动来导航不同的参数值,这些斜坡由每次权重更新所采取的步骤表示。如果这个景观由一个位于权重最优值的单峰顶峰组成,反向传播可能会快速收敛到这个值。然而,更常见的情况是多维空间中可能有许多峡谷山谷(误差函数以不规则的方式随着特定权重集的上升和下降),这使得一阶方法如反向传播难以从局部最小值/最大值中导航出来。例如,误差函数景观中的一个山谷中,误差函数的一阶导数可能会缓慢变化,因为当我们进入或围绕山谷移动时,误差只逐渐增加或减少。通过标准随机初始化权重变量在这个景观中的随机位置开始,我们可能处于一个不太可能导航到最优参数值的位置。因此,一种可能性是在运行反向传播之前将网络中的权重初始化到一个更有利的配置,这样我们就有更大的机会找到最优的权重值。

的确,这是 2006 年发表的研究(Hinton, Geoffrey E., Simon Osindero, and Yee-Whye Teh. A fast learning algorithm for deep belief nets. Neural computation 18.7 (2006): 1527-1554)提出的解决方案的精髓。该研究不是直接将多层神经网络拟合到数据集(在这种情况下,由一组手绘数字的图像表示的数字)的响应变量上,而是在随机初始化权重变量之后,建议通过一个预训练阶段来初始化网络权重,这样可以在运行反向传播之前将它们移动到正确的值附近。本研究中使用的网络包含几个 RBM 层,提出的解决方案是依次优化一个 RBM,具体步骤在图 6中展示:

  1. 首先,使用可见层生成隐藏神经元的一组值,就像在后向传播中一样。

  2. 然而,然后过程被反转,最上面的 RBM 中的隐藏单元值用作起点,网络反向运行以重新创建输入数据(如图 3 所示)。

  3. 层与层之间的最佳权重是通过计算输入数据与从隐藏层反向运行模型生成的数据样本之间的差异来计算的。

  4. 此过程重复多次,直到推断出的权重停止变化。

  5. 然后通过连续的层重复此过程,每一层更深的隐藏层形成新的输入。此外,强制执行一个约束,即可见层和第一隐藏层之间的权重以及第一和第二隐藏层之间的权重是矩阵转置:这被称为绑定权重。这个条件被强制执行在相邻隐藏层之间每对权重之间:预训练信念网络

    图 6:深度信念网络的预训练算法

这种预训练程序的实际效果是网络以与输入数据的一般形状相似的权重初始化。由于此程序是逐层进行的,因此避免了之前讨论的一些梯度消失问题,因为在每一步中只考虑一组权重。由于第 5 步中描述的权重匹配,解释问题的可能性也最小化。回到我们关于房屋移动的例子,地震和卡车权重的相对强度将在深度信念网络的第一层中表示。在预训练的下一阶段,这些权重将通过矩阵转置进行反转,取消高层中的解释效果。这种模式在连续层中重复,系统地消除权重值与连接的隐藏单元被激活的可能性之间的相关性。

一旦完成预训练,就使用与简单网络中描述的类似的后向传播方法,但现在权重更快地收敛到稳定值,因为它们是从一个更优而不是随机的值开始的。

使用 dropout 正则化网络

即使使用之前描述的预训练方法,优化深度网络中的大量参数也可能非常耗时。我们可能还会遇到与具有大量系数的回归模型相同的问题,即大量参数导致网络过度拟合训练数据,并且无法很好地泛化到之前未见过的数据。

在回归的情况下,我们使用了如岭回归、Lasso 和弹性网络等方法来正则化我们的模型,而对于深度网络,我们可以使用称为 Dropout 的方法来减少过拟合(Srivastava, Nitish, 等人 Dropout:一种防止神经网络过拟合的简单方法。机器学习研究杂志 15.1 (2014): 1929-1958)。这个想法相对简单:在调整权重的每个阶段,我们随机从网络中移除一些神经元及其连接,并且只更新剩余的权重。随着我们重复这个过程,我们实际上是在许多可能的网络结构上取平均值。这是因为,在每个阶段有 50%的概率从网络中丢弃任何给定的神经元,我们训练的每个阶段实际上是从 2^n 个可能网络结构中采样。因此,模型被正则化,因为我们只在每个阶段拟合参数的子样本,并且类似于我们在第五章中检查的随机森林,数据定位 – 分类方法和分析,我们在更多随机构建的网络上取平均值。尽管 Dropout 可以减少过拟合,但它可能会使训练过程更长,因为我们需要平均更多的网络以获得准确的预测。

卷积网络和整流单元

尽管预训练过程提供了一种初始化网络权重的途径,但随着我们添加层,整体模型复杂性增加。对于更大的输入数据(例如,大图像),这可能导致每个额外层增加的权重数量增加,因此训练周期可能会更长。因此,对于某些应用,我们可能通过以下方式智能地简化网络结构来加速训练过程:(1)不在每一层的每个神经元之间建立连接;(2)改变神经元使用的函数。

这些修改在一种称为卷积网络(LeCun, Yann, 等人 基于梯度的学习应用于文档识别。IEEE 汇刊 86.11 (1998): 2278-2324;Krizhevsky, Alex, Ilya Sutskever 和 Geoffrey E. Hinton 使用深度卷积神经网络进行 Imagenet 分类。神经信息处理系统进展。2012)的深度网络中很常见。卷积这个名字来源于图像分析,其中像我们在第六章中使用的开运算和膨胀运算这样的卷积算子被应用于图像的重叠区域。实际上,卷积网络通常应用于涉及图像识别的任务。虽然可能的配置数量很大,但一个卷积网络的潜在结构可能如下(见图 7):

  • 可见的输入层,宽度为 w,高度为 h:对于彩色图像,这个输入可以是三维的,每个红色、绿色和蓝色通道都有一个深度层。

  • 卷积层:在这里,单个神经元可以通过所有三个颜色通道(nxnx3)连接到一个正方形区域。这些 nxnx3 单元中的每一个都有一个权重连接到卷积层中的一个神经元。此外,我们可以在卷积层中连接到每个这些nxnx3单元的多个神经元,但每个神经元都有不同的权重集。

  • 整流层:使用本章后面讨论的整流线性单元ReLU),卷积层中的每个神经元输出都被阈值化,以产生另一组相同大小的神经元。

  • 下采样层:这种类型的层在上一层的子区域上平均,以产生一个宽度更小、高度更小的层,同时保持深度不变。

  • 全连接层:在这个层中,下采样层中的每个单元都连接到一个输出向量(例如,一个表示 10 个不同类别标签的 10 单元向量)。

这种架构利用了数据结构(检查图像中的局部模式),并且训练速度更快,因为我们只在每层的神经元之间进行选择性连接,导致需要优化的权重更少。这种结构可以更快地训练的第二个原因是由于整流和池化层中使用的激活函数。池化函数的一个常见选择是所有输入的最大值,也称为整流线性单元ReLU)(Nair, Vinod, and Geoffrey E. Hinton. Rectified linear units improve restricted boltzmann machines. Proceedings of the 27th International Conference on Machine Learning (ICML-10). 2010)。它是这样的:

卷积网络和整流单元

这里,z是给定神经元的输入。与之前描述的对数函数不同,ReLU 不受范围[0,1]的限制,这意味着网络中跟随它的神经元的值可以比对数函数更快地变化。此外,ReLU 的梯度由以下给出:

卷积网络和整流单元

这意味着梯度不太可能消失(除非神经元输入非常低,以至于它总是关闭)或爆炸,因为最大变化是1。在前一种情况下,为了防止 ReLU 永久关闭,函数可以被修改为漏斗状:

卷积网络和整流单元

这里,α是一个小的值,例如0.01,防止神经元被设置为 0。

卷积网络和整流单元

图 7:卷积神经网络架构。为了清晰起见,卷积层的连接表示为高亮区域,而不是所有 wxhxd 神经元,并且只显示了网络中汇聚到池化层神经元的子集。

小贴士

旁注:其他激活函数

除了之前讨论的线性、Sigmoid 和 ReLU 函数外,在构建深度网络时还会使用其他激活函数。其中一个是双曲正切函数,也称为tanh函数,其表达式为:

卷积网络和整流单元

该函数的输出范围在[–1,1]之间,与 Sigmoid 或 ReLU 的输出范围在[0,1]之间不同,一些证据表明,这可以通过允许神经元的平均输出为零来加速网络的训练,从而减少偏差(LeCun, Yann, Ido Kanter, and Sara A. Solla. "Second order properties of error surfaces: Learning time and generalization." Advances in neural information processing systems 3 (1991): 918-924.). 类似地,我们可以想象在谱聚类和 SVMs 的背景下使用高斯函数,如我们在第三章、在噪声中寻找模式 – 聚类和无监督学习和第五章、将数据放在合适的位置 – 分类方法和分析中看到的核函数。在第五章、将数据放在合适的位置 – 分类方法和分析中用于多项式回归的softmax函数也是一个候选者;潜在函数数量的增加增加了深度模型的灵活性,使我们能够根据具体问题调整特定的行为。

使用自动编码器网络压缩数据

尽管本章的大部分讨论涉及使用深度学习进行分类任务,但这些模型也可以用于降维,其方式与我们在第六章中讨论的矩阵分解方法相当,即 文字与像素 – 处理非结构化数据。在这种应用中,也称为 自动编码器 网络 (Hinton, Geoffrey E., 和 Ruslan R. Salakhutdinov. 使用神经网络降低数据维度. 科学 313.5786 (2006): 504-507),目标不是拟合响应(如二进制标签),而是重建数据本身。因此,可见层和输出层总是相同的大小(图 8),而隐藏层通常较小,因此形成数据的低维表示,可用于重建输入。因此,类似于 PCA 或 NMF,自动编码器发现输入的紧凑版本,可以近似原始数据(存在一些误差)。如果隐藏层的大小不小于可见层和输出层,网络可能只是优化隐藏层以与输入相同;这将允许网络完美地重建输入,但会牺牲任何特征提取或降维。

使用自动编码器网络压缩数据

图 8:自动编码器网络架构

优化学习率

在我们上面讨论的例子中,每个阶段的参数学习率始终是一个固定的值 α。直观上,对于某些参数我们可能希望更积极地调整其值,而对于其他参数则希望调整得较少。为此,已经提出了许多优化方法。例如,自适应梯度(Adaptive Gradient,AdaGrad)(Duchi, John, Elad Hazan, 和 Yoram Singer. 自适应子梯度方法用于在线学习和随机优化. 机器学习研究杂志 12.Jul (2011): 2121-2159.) 使用基于给定参数过去梯度历史的每个参数的学习率:

优化学习率

其中 Gt 代表特定参数所有梯度的平方和,gt 是当前步骤的梯度,ε 是平滑参数。因此,每个阶段的学习率是全球值 α,乘以当前梯度表示的历史变化的分数。如果当前梯度与历史更新相比较高,那么我们将改变参数。否则,我们应该改变得较少。随着时间的推移,大多数学习率将趋向于零,加速收敛。

这个想法的自然扩展被用于 AdaDelta(Zeiler, Matthew D. ADADELTA: an adaptive learning rate method. arXiv preprint arXiv:1212.5701 (2012)),在这里,我们不是使用梯度更新历史记录的全集 G,而是在每一步,用当前梯度和历史平均梯度的平均值来替换这个值:

优化学习率

Adagrad 的表达式在分母中使用上述公式代替 Gt。与 Adagrad 一样,这倾向于减少相对于其历史记录变化不大的参数的学习率。

我们将在以下部分检查的 TensorFlow 库也提供了自适应矩估计(ADAM)方法来调整学习率(Kingma, Diederik, and Jimmy Ba. Adam: A method for stochastic optimization. arXiv preprint arXiv:1412.6980 (2014))。在这个方法中,与 AdaDelta 一样,我们保持平方梯度和梯度的平均值。更新规则如下:

优化学习率

在这里,与 AdaDelta 中的加权平均值一样,通过除以衰减参数(1-β)进行归一化。已经提出了许多其他算法,但我们所描述的方法样本应该能给你一个关于如何自适应调整学习率以加速深度网络训练的想法。

提示

旁白:替代网络架构

除了我们讨论过的卷积、前馈和深度信念网络之外,其他网络架构也被调整以解决特定问题。循环神经网络RNNs)在层之间有稀疏的双向连接,允许单元通过这些循环表现出增强行为(见图 9)。由于网络从这个循环中具有记忆,它可以用于处理语音识别等任务的数据(Graves, Alex, et al. "A novel connectionist system for unconstrained handwriting recognition." IEEE transactions on pattern analysis and machine intelligence 31.5 (2009): 855-868),在这些任务中,一系列不确定长度的输入被处理,并且网络可以在每个点根据当前和之前的输入产生一个预测标签。同样,长短期记忆网络LSTM)(Hochreiter, Sepp, and Jürgen Schmidhuber. Long short-term memory. Neural computation 9.8 (1997): 1735-1780)具有循环元素,允许单元记住先前输入的数据。与 RNNs 相比,它们还具有可以清除循环激活单元中值的辅助单元,允许网络在特定时间窗口内保留输入信息(见图 9,循环代表这个遗忘功能,它可能由输入激活)。

优化学习率

图 9:循环神经网络(RNN)和长短期记忆(LSTM)架构。

现在我们已经看到了如何通过多种优化构建、训练和调整深度学习网络,让我们看看一个图像识别的实际例子。

TensorFlow 库和数字识别

对于本章的练习,我们将使用由谷歌开源的TensorFlow库(可在www.tensorflow.org/找到)。安装说明因操作系统而异。此外,对于 Linux 系统,你可以利用计算机上的 CPU 和图形处理单元GPU)来运行深度学习模型。由于训练中的许多步骤(如更新权重值网格所需的乘法)涉及矩阵运算,因此它们可以通过使用 GPU 轻松并行化(从而加速)。然而,TensorFlow库也可以在 CPU 上运行,所以如果你没有访问 Nvidia GPU 卡也不要担心。

MNIST 数据

在这个练习中,我们将检查的是来自混合国家标准与技术研究院MNIST)数据库(LeCun, Yann, Corinna Cortes, and Christopher JC Burges. The MNIST database of handwritten digits. (1998))的一组手绘数字 0 到 9 的图像。类似于用于介绍基本编程技术的 Hello World!程序,或者用于演示分布式计算框架的单词计数示例,MNIST 数据是用于演示神经网络库功能的常见示例。与这些数据相关的预测任务是给图像分配一个标签(0 到 9 的数字),只给出输入像素。

TensorFlow库提供了一个方便的库函数,可以使用以下命令加载数据:

>>> from tensorflow.examples.tutorials.mnist import input_data
>>> mnist = input_data.read_data_sets('MNIST_data', one_hot=True)

注意,除了指定我们希望加载 MNIST 数据外,我们还指明目标变量(图像表示的数字)应以二进制向量编码(例如,数字 3 通过在这个向量的第四个元素中放置一个 1 来表示,因为第一个元素编码数字 0)。一旦我们加载了数据,我们就可以开始检查图像本身。我们可以看到,数据已经方便地被分为训练集和测试集,使用 4:1 的分割,通过检查训练集和测试集的长度使用以下命令:

>>> len(mnist.train.images)
>>> len(mnist.test.images)

这些图像中的每一个都是一个 2828* 像素的图像。在数据中,这些图像被存储为一个长度为 784 的一维向量,但一旦我们使用命令将数组重塑为其原始维度,我们就可以使用上一章中的skimage库来可视化这些图像。

>>> from skimage import io
>> io.imshow(np.reshape(mnist.train.images[0],(28,28))) 

显示集合中的第一张图像:

MNIST 数据

这看起来像数字 7:要检查分配给此图像的标签,我们可以使用以下命令检查 train 对象的 labels 元素:

>>> mnist.train.labels[0]

这给出了

array([ 0., 0., 0., 0., 0., 0., 0., 1., 0., 0.])

标签是一个 10 个元素的向量,从左到右代表数字 0 到 9,与图像标签关联的位置为 1。实际上,分配给此图像的标签是 7。请注意,标签数组与我们所检查的神经网络算法的最终层具有相同的形状,这使得我们能够方便地将标签直接与计算出的预测进行比较。

现在我们已经检查了数据,让我们使用TensorFlow库的其他工具来开发一个可以预测图像标签的神经网络。

构建网络

如您现在可能已经欣赏到的,深度神经网络的架构可以极其复杂。因此,如果我们为网络的每一层定义变量,我们最终会得到一大块代码,每次我们改变网络结构时都需要对其进行修改。因为在实际应用中,我们可能想要尝试许多不同深度的变体、层大小和连接性,所以我们在这个练习中展示了如何使这种结构通用和可重用。关键成分是生成层的函数,由这些生成函数指定的所需层列表,以及一个外部过程,它将生成的层连接起来:

>>> def weight_variable(dimensions,stddev):
 …     return tf.Variable(tf.truncated_normal(dimensions, stddev=stddev))

>>> def bias_variable(dimensions,constant):
…      return tf.Variable(tf.constant(constant, shape=dimensions))

>>> def two_dimensional_convolutional_layer(x, W, strides, padding):
 …     return tf.nn.conv2d(x, W, strides=strides, padding=padding)

>>> def max_pooling(x,strides,ksize,padding):
…     return tf.nn.max_pool(x, ksize=ksize,strides=strides, padding=padding)

>>> def generate_network(weight_variables,\
 bias_variables,\
 relu_layers,\
 pooling_layers,\
 fully_connected_layers,\
 inputs,\
 conv_strides,\
 pool_stries,\
 ksize,\
 output_channels,\
 conv_field_sizes,\
 conv_field_depths,\
 sd_weights\
 ,bias_mean,\
 padding,\
 conv_layers,\
 fc_layers,\
 fc_shape,\
 keep_prob,\
 class_num,\
 dropouts):

 # add convolutional layers
 >>> for k in range(conv_layers):
 …      weight_variables.append(weight_variable([conv_field_sizes[k], conv_field_sizes[k], conv_field_depths[k],output_channels[k]],sd_weights))
 bias_variables.append(bias_variable([output_channels[k]],bias_mean))
 relu_layers.append(tf.nn.relu(two_dimensional_convolutional_layer(inputs[k],weight_variables[k],\
 conv_strides,padding) + bias_variables[k]))
 pooling_layers.append(max_pooling(relu_layers[k],pool_strides,ksize,padding))
 inputs.append(pooling_layers[k])

 # finally, add fully connected layers at end with dropout
 >>> for r in range(fc_layers):
 weight_variables.append(weight_variable(fc_shape,sd_weights))
 bias_variables.append(bias_variable([fc_shape[1]],bias_mean))
 pooling_layers.append(tf.reshape(pooling_layers[-1],[-1,fc_shape[0]]))
 fully_connected_layers.append(tf.nn.relu(tf.matmul(pooling_layers[-1], weight_variables[-1]) + bias_variables[-1]))
 dropouts.append(tf.nn.dropout(fully_connected_layers[-1], keep_prob))

 # output layer
 weight_variables.append(weight_variable([fc_shape[1],class_num],sd_weights))
 bias_variables.append(bias_variable([class_num],bias_mean))
 return tf.nn.softmax(tf.matmul(dropouts[-1],weight_variables[-1])+bias_variables[-1])

因此,这种格式允许我们以易于重新配置和重用的方式模板化网络的构建。

此函数构建了一系列卷积/最大池化层,随后是一或多个全连接层,其输出用于生成预测。最后,我们只需将softmax函数的最终层预测作为输出返回。因此,我们可以通过设置一些参数来配置网络:

 >>> X = tf.placeholder("float", shape=[None, 784])
>>> observed = tf.placeholder("float", shape=[None, 10])
>>> images = tf.reshape(X, [-1,28,28,1])

# shape variables
>>> sd_weights = 0.1
>>> bias_mean = 0.1
>>> padding = 'SAME'
>>> conv_strides = [1,1,1,1]
>>> pool_strides = [1,2,2,1]
>>> ksize = [1,2,2,1]
>>> output_channels = [32,64]
>>> conv_field_sizes = [5,5]
>>> conv_field_depths = [1,32]
>>>fc_shape = [7*7*64,1024]
>>> keep_prob = tf.placeholder("float")
>>> class_num = 10
>>> conv_layers = 2
>>> fc_layers = 1

# layers variables
>>> weight_variables = []
>>> bias_variables = []
>>> relu_layers = []
>>> pooling_layers = []
>>> inputs = [images]
>>> fully_connected_layers = []
>>> dropouts = []

>>> prediction = generate_network(weight_variables,\
 bias_variables,\
 relu_layers,\
 pooling_layers,\
 fully_connected_layers,\
 inputs,\
 conv_strides,\
 pool_strides,\
 ksize,\
 output_channels,\
 conv_field_sizes,\
 conv_field_depths,\
 sd_weights\
 ,bias_mean,\
 padding,\
 conv_layers,\
 fc_layers,\
 fc_shape,\
 keep_prob,\
 class_num,\
 dropouts)

注意,输入(X)、真实标签(观察到的)以及层中丢弃概率(keep_prob)都是占位符,它们不包含实际值,但将在网络训练过程中以及我们向算法提交数据批次时被填充。

现在我们需要做的只是初始化一个会话,并开始使用以下代码提交数据批次:

>>> my_session = tf.InteractiveSession()
>>> squared_error = tf.reduce_sum(tf.pow(tf.reduce_sum(tf.sub(observed,prediction)),[2]))
>>> train_step = tf.train.GradientDescentOptimizer(0.01).minimize(squared_error)
>>> correct_prediction = tf.equal(tf.argmax(prediction,1), tf.argmax(observed,1))
>>> accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float"))
>>> my_session.run(tf.initialize_all_variables())

>>>for i in range(20000):
…    batch = mnist.train.next_batch(50)
 …   if i%1000 == 0:
…      train_accuracy = accuracy.eval(feed_dict={X: batch[0], observed: batch[1], keep_prob: 1.0})
 …     print("step %d, training accuracy %g"%(i, train_accuracy))
…        train_step.run(feed_dict={X: batch[0], observed: batch[1], keep_prob: 0.5})
…        print("test accuracy %g"%accuracy.eval(feed_dict={X: mnist.test.images, observed: mnist.test.labels, keep_prob: 1.0}))

我们可以在算法训练过程中观察其进度,每 1000 次迭代的准确度都会打印到控制台。

摘要

在本章中,我们介绍了深度神经网络作为生成复杂数据类型模型的途径,其中特征难以工程化。我们探讨了神经网络通过反向传播进行训练的方式,以及为什么额外的层使得这种优化变得难以处理。我们讨论了这个问题的一些解决方案,并展示了如何使用TensorFlow库构建一个用于手绘数字的图像分类器。

现在你已经覆盖了广泛的预测模型,我们将转向最后两章,探讨生成分析管道的最后两个任务:将我们训练的模型转化为可重复的、自动化的流程,以及可视化结果以获取持续洞察和监控。

第八章. 使用预测服务共享模型

到目前为止,我们已经探讨了如何使用从标准“表格”数据到文本和图像的各种数据源构建各种模型。然而,这仅仅完成了我们在商业分析中的部分目标:我们可以从数据集中生成预测,但无法轻松与同事或其他公司内部的软件系统共享结果。我们也无法在新的数据可用时轻松复制结果,而无需手动重新运行前几章讨论的分析,或者随着时间的推移将其扩展到更大的数据集。在没有通过我们代码中公开的分析模型参数揭示分析细节的情况下,我们还将难以在公共环境中(如公司的网站)使用我们的模型。

为了克服这些挑战,下一章将描述如何构建“预测服务”,这些是封装并自动化数据转换、模型拟合以及新观测评分等核心组件的 Web 应用程序,这些内容我们在前几节讨论预测算法时已经讨论过。通过将我们的分析打包成 Web 应用程序,我们不仅可以轻松扩展建模系统,还可以更改底层算法的实现,同时使这些更改对消费者(无论是人类还是其他软件系统)不可见,消费者通过向我们的应用程序通过 Web URL 和标准的 REST 应用程序编程接口API)发送请求来与我们的预测模型交互。这还允许通过调用服务来自动化分析初始化和更新,使预测建模任务保持一致并可重复。最后,通过仔细参数化许多步骤,我们可以使用同一服务与可互换的数据源计算框架交互。

从本质上讲,构建预测服务涉及将我们已讨论的几个组件(如数据转换和预测建模)与本章首次讨论的一系列新组件相连接。为此,我们将涵盖以下主题:

  • 如何使用 Cherrypy 和 Flask 框架对基本 Web 应用程序和服务器进行监控

  • 如何使用 RESTful API 自动化通用建模框架

  • 使用 Spark 计算框架扩展我们的系统

  • 将我们的预测模型结果存储在数据库系统中,以便在第九章中讨论的报告应用程序中,报告和测试 – 在分析系统中迭代

预测服务的架构

现在有了明确的目标——通过 Web 应用程序共享和扩展我们的预测建模结果——要实现这一目标需要哪些组件?

第一个是客户端:这可以是网络浏览器,或者简单地是用户在终端中输入curl命令(见旁注)。在两种情况下,客户端都使用超文本传输协议HTTP)发送请求,这是一种标准传输约定,用于在网络中检索或传输信息(Berners-Lee, Tim, Roy Fielding, and Henrik Frystyk. Hypertext transfer protocol--HTTP/1.0. No. RFC 1945. 1996)。HTTP 标准的一个重要特性是客户端和服务器不必“知道”任何关于对方实现的信息(例如,用于编写这些组件的编程语言),因为只要遵循 HTTP 标准,消息在他们之间就会保持一致。

下一个组件是服务器,它从客户端接收 HTTP 请求并将它们转发到应用程序。你可以将其视为客户端请求通往我们实际预测建模应用的网关。在 Python 中,Web 服务器和应用程序都遵循Web 服务器网关接口WSGI),它指定了服务器和应用程序应该如何通信。就像客户端和服务器之间的 HTTP 请求一样,这个标准允许服务器和应用程序只要两者一致实现接口就可以模块化。实际上,服务器和应用程序之间甚至可能存在中间件来进一步修改两者之间的通信:只要这种通信的格式保持一致,接口两边的细节就是灵活的。虽然我们将使用 Cherrypy 库为我们构建服务器,但其他常见的选择是 Apache Tomcat 和 Nginx,它们都是用 Java 编程语言编写的。

在客户端请求被服务器接收并转发之后,应用程序会根据请求执行操作,并返回一个值来指示任务的执行成功或失败。例如,这些请求可以获取特定用户的预测分数,更新训练数据集,或者进行一轮模型训练。

小贴士

旁注:curl 命令

作为测试我们的预测服务的一部分,有一个快速向服务器发送命令并观察我们收到的响应的方法是非常有用的。虽然我们可以通过使用网络浏览器的地址栏进行一些交互式操作,但在我们需要运行多个测试或复制特定命令的情况下,脚本浏览器活动并不容易。大多数 Linux 命令行终端中都可以找到的curl命令对于这个目的非常有用:可以使用curl命令向预测服务发出与在浏览器中给出的相同的请求(就 URL 而言),并且可以使用 shell 脚本自动化这个调用。curl应用程序可以从curl.haxx.se/安装。

Web 应用程序依赖于服务器端代码来执行响应于 Web 服务器请求的命令。在我们的例子中,这个服务器端代码被分为几个组件:第一个是为建模逻辑提供的通用接口,它指定了构建预测模型、使用输入数据集对其进行训练以及评分传入数据的标准方式。第二个是使用第五章中的逻辑回归算法(Putting Data in its Place – Classification Methods and Analysis)实现此框架。此代码依赖于执行 Spark 作业,这些作业可以在本地(在 Web 应用程序所在的同一台机器上)或远程(在单独的集群上)执行。

这个链的最后一部分是数据库系统,它可以持久化预测服务使用的信息。这个数据库可能只是与 Web 服务器在同一台机器上的文件系统,也可能像分布式数据库软件一样复杂。在我们的例子中,我们将使用 Redis(一个简单的键值存储)和 MongoDB(一个 NoSQL 数据库)来存储用于建模的数据、关于我们应用程序的瞬态信息以及模型结果本身。

正如我们之前强调的,这三个组件的一个重要特征是它们在很大程度上是独立的:因为 WGSI 标准定义了 Web 服务器和应用程序之间的通信方式,所以我们可以更改服务器和预测模型实现,只要在 Web 应用程序中使用的命令相同,代码仍然可以工作,因为这些命令是以一致的方式格式化的。

现在我们已经介绍了预测服务的基本组件以及它们如何相互通信,让我们更详细地考察每一个。

客户端和发出请求

当客户端向服务器和下游应用程序发出请求时,我们可能会遇到一个主要的设计问题:我们如何事先知道我们可能会收到什么类型的请求?如果我们每次开发 Web 应用程序时都必须重新实现一组新的标准请求,那么将很难重用代码并编写其他程序可以调用的通用服务,因为它们的请求可能会随着客户端可能与之交互的每个 Web 应用程序而变化。

这就是 HTTP 标准解决的问题,它描述了一种标准语言和格式,用于服务器和客户端之间发送请求,使我们能够依赖于一个通用的命令语法,该语法可以被许多不同的应用程序消费。虽然从理论上讲,我们可以通过将 URL 粘贴到浏览器的地址栏中(例如 GET,下面将描述)向我们的预测服务发出一些这些命令,但这只会覆盖我们想要发出的请求类型的一个子集。我们在 Web 应用程序中通常实现的请求类型包括:

GET 请求

GET请求只检索信息,这些信息将根据响应类型在我们的 Web 浏览器中渲染。我们可能会收到一个实际的html页面,或者只是一段文本。为了指定我们想要接收的信息,GET 请求将在 URL 中包含变量,形式为url?key1=value1&key2=value2。URL 是提供给预测服务的 Web 地址,在我们的例子中将是本地机器,但也可能是任何有效的 IP 地址或 URL。这个 URL 通过一个问号(?)与定义我们信息请求参数的(键,值)对分开。可以指定多个参数:例如,我们可以使用字符串userid=12894&itemid=93819来表示用户和项目数据集的一对参数,每个键值对由与符号(&)分隔。

我们可以直接通过将之前描述的 URL 格式粘贴到浏览器的地址栏中,或者通过在终端中输入以下命令向同一地址发出一个curl命令来直接发出一个GET请求:

> curl <address>

我们还可以使用 Python requests 库(docs.python-requests.org/en/master/),它允许我们不必担心 URL 格式化的细节。使用这个库,相同的 GET 请求可以通过以下方式调用:

>>> r = requests.get(url,params)

在这里,params是我们本应传递到 URL 中的键值对字典。requests 库为我们执行了这种格式化,正如我们可以通过打印生成的 URL 所看到的那样:

>>> print(r.url)

一旦我们发出了请求,我们可以使用以下两个命令中的任何一个来检查结果:

>>> r.json()
>>> r.text

我们还可以检查响应的状态码,以查看是否发生了错误(参见关于标准响应代码的附录):

>>> r.status_code

小贴士

附录:HTTP 状态码

当我们使用本章讨论的方法向 Web 应用程序发出请求时,检查请求成功的一种方法是通过检查响应代码,它给出一个与 Web 应用程序对请求的响应相对应的标准数字。你可能甚至在没有意识到的情况下见过这些代码,例如当网页在你的浏览器中无法显示时返回的 404 错误。需要注意的标准代码如下:

200:成功,我们通常检查这个值以确保我们收到了正确的响应。

404:未找到,表示 Web 应用程序找不到我们请求的资源。

500:服务器错误,如果我们运行的 Web 应用程序代码遇到问题,我们通常会收到这个错误。

对于更全面的列表,请参阅(Nottingham, Mark,和 Roy Fielding. "Additional HTTP Status Codes." (2012);Berners-Lee, Tim,Roy Fielding,和 Henrik Frystyk. Hypertext transfer protocol--HTTP/1.0. No. RFC 1945. 1996)。

POST 请求

与 GET 命令不同,POST 请求不使用 URL 中包含的数据,而是传输与 URL 分离的信息。如果您曾在在线商店输入过信用卡信息,那么这些信息可能就是通过 POST 请求传输的,这是幸运的,因为这样信息就保持隐藏了。然而,由于请求信息不包含在 URL 中,我们不能简单地将其粘贴到 Web 浏览器的地址栏中:我们需要网页上发出 POST 请求的表单,或者我们自己编程发出请求。如果没有实际的表单在网页上,我们可以使用 curl 命令,使用以下语法发出 POST 请求:

> curl –x POST  -d  <data> <url>

我们还可以使用 Python 的 requests 库:

>>> r = requests.post(url,data)

在前面的代码中,data 是一个 Python 字典,包含网络应用程序在满足 POST 请求时可以访问的信息。

HEAD 请求

与 GET 请求类似,HEAD 请求检索信息,但它只检索响应的元数据(例如编码),而不是响应的主体(例如网页或 JSON)。我们可以使用以下方式发出 HEAD 请求:

> curl –i –X HEAD <url>

注意,我们已向此请求添加了 –i 标志;通常,没有此选项,curl 命令不会打印头部信息。使用 Python 的 requests 库,我们将使用以下命令:

>>>  requests.head(url)

PUT 请求

在我们的网络应用程序可以访问数据库系统的情况下,我们发出 PUT 命令以存储新信息。使用 curl,我们使用以下方式发出此请求:

> curl -X PUT -d key1=value1 -d key2=value2 <url>

我们还可以使用 requests 库发出此请求:

>>>  r = requests.put(url,data)

在这里,数据是我们希望放置在应用程序存储系统中的参数的字典。

DELETE 请求

与 PUT 命令相反,DELETE 请求用于从应用程序的存储系统中删除数据。curl 命令如下:

> curl -X DELETE -d key1=value1 -d key2=value2 <url>

而使用 requests 库执行相同请求的方式如下:

>>>  r = requests.delete(url,data)

在这里,数据是我们希望从应用程序存储系统中删除的参数的字典。

尽管还有其他请求类型可用,但在此讨论中我们将不涉及它们;更多详情请参阅(Berners-Lee, Tim, Roy Fielding, and Henrik Frystyk. 超文本传输协议--HTTP/1.0. No. RFC 1945. 1996)。请注意,由于我们可以使用 Python 的 request 库发出这些请求,我们实际上可以在本卷练习中使用的 Python 笔记本中测试我们的 Web 应用程序。

对于我们的目的,客户端将是 Jupyter 笔记本本身或终端的命令行;然而,我们可以想象其他情况,其中客户端实际上是另一个发出这些命令并对响应采取行动的 Web 应用程序。再次强调,由于服务器只需要保证特定的消息格式而不是发送者的详细信息,两种选项可以互换。

现在我们知道了如何向我们的服务发出 HTTP 请求,让我们来看看服务器。

服务器 – 网络流量控制器

要运行我们的预测服务,我们需要与外部系统通信以接收训练模型、评分新数据、评估现有性能或提供模型参数信息的请求。Web 服务器执行这个功能,接受传入的 HTTP 请求,并将它们直接或通过可能使用的任何中间件转发到我们的 Web 应用程序。

尽管我们可以在展示这个示例时选择许多不同的服务器,但我们选择了 CherryPy 库,因为它与 Apache Tomcat 或 Nginx 等其他流行的服务器不同,它是用 Python 编写的(允许我们在笔记本中演示其功能),并且是可扩展的,只需几毫秒就能处理许多请求(www.aminus.org/blogs/index.php/2006/12/23/cherrypy_3_has_fastest_wsgi_server_yet)。服务器连接到特定的端口或端点(这通常以url:port的格式给出),我们将请求指向该端点,然后请求被转发到 Web 应用程序。端口的用途意味着在理论上我们可以在给定的 URL 上有多个服务器,每个服务器监听不同的端点。

如我们之前讨论的,服务器使用 WGSI 规范与应用程序本身进行通信。具体来说,服务器有一个名为可调用(callable)的功能(例如,任何具有__call__方法的对象),每次接收到请求时都会执行这个功能,并将结果传递给应用程序。在本章的示例中,WGSI 已经被 CherryPy 实现,我们将简单地说明它是如何做到这一点的。该接口的完整文档可在www.python.org/dev/peps/pep-0333/找到。从某种意义上说,WGSI 解决了服务器与应用程序之间通信中与 HTTP 相同的问题:它提供了一个两个系统交换信息的通用方式,使我们能够在不改变信息传输基本方式的情况下交换组件或放置中间组件。

在我们可能希望将应用程序扩展到更大负载的情况下,我们可以想象在服务器和应用程序之间有一个中间件,比如负载均衡器。这个中间件会接收可调用输出并将其传递给 Web 应用程序。在负载均衡器的情况下,这可能会将请求重新分配到许多相同的预测服务的单独实例,使我们能够水平扩展服务(见旁注)。然后,这些服务中的每一个都会在将响应发送回客户端之前将其发送回服务器。

提示

旁注:水平和垂直扩展

随着我们的预测服务数据量或计算复杂度的增加,我们有两种主要方式来提高服务的性能。第一种,称为水平扩展,可能涉及添加我们应用程序的更多实例。另外,我们也可能增加我们底层计算层中的资源数量,例如 Spark。相比之下,垂直扩展涉及通过添加更多 RAM、CPU 或磁盘空间来改进现有资源。虽然仅使用软件就可以更容易地实现水平扩展,但针对此类资源约束的正确解决方案将取决于问题领域和组织预算。

应用程序 – 预测服务的引擎

一旦请求从客户端传递到应用程序,我们需要提供执行这些命令并返回响应给服务器以及随后客户端的后续逻辑。为此,我们必须将一个函数附加到我们预期接收的特定端点和请求上。

在本章中,我们将使用 Flask 框架来开发我们的 Web 应用程序(flask.pocoo.org/)。虽然 Flask 也可以支持 HTML 页面的模板生成,但在本章中,我们将仅使用它通过对应于之前讨论的 HTTP 请求的 URL 端点来实现对底层预测算法代码的各种请求。实现这些端点允许通过一个一致的接口,许多其他软件系统可以与我们的应用程序交互——他们只需要指向适当的 Web 地址并处理从我们的服务返回的响应。如果你担心我们不会在我们的应用程序中生成任何实际的“网页”,请不要担心:我们将在第九章,报告和测试 – 在分析系统中迭代中使用相同的 Flask 框架,来开发一个基于我们将在本章通过预测建模服务生成数据的仪表板系统。

在编写我们的预测建模应用程序的逻辑时,重要的是要记住,响应客户端请求而调用的函数本身可以是指定通用、模块化服务的接口。虽然我们可以在网络应用程序的代码中直接实现特定的机器学习算法,但我们选择抽象这种设计,让网络应用程序通过一些参数构建模型、训练和评分的通用调用,而不论应用程序中使用的数据或特定模型。这使我们能够在许多不同的算法上重用网络应用程序代码,同时也提供了随着时间的推移以不同方式实现这些算法的灵活性。这也迫使我们确定算法的一致操作集,因为网络应用程序将通过这个抽象层与它们交互。

最后,我们有算法本身,这是由网络应用程序代码调用的。这个程序需要实现函数,例如使用一组数据训练模型和评分记录,这些函数在网络应用程序中指定。随着时间的推移,这些细节可能会发生重大变化,而无需修改网络应用程序,这使得我们能够灵活地开发新的模型或尝试不同的库。

使用数据库系统持久化信息

我们的预测服务将以多种方式使用数据。当我们启动服务时,我们希望检索标准配置(例如,模型参数),并且我们也可能希望记录应用程序响应的请求记录以供调试目的。在我们评分数据或准备训练模型时,我们理想情况下希望将这些数据存储在某个地方,以防预测服务需要重新启动。最后,正如我们将更详细讨论的,数据库可以让我们跟踪应用程序状态(例如,哪些任务正在进行)。对于所有这些用途,可以应用多种数据库系统。

数据库通常分为两组:关系型和非关系型。关系型数据库可能对你来说很熟悉,因为它们被用于大多数商业数据仓库。数据以表格形式存储,通常包含事实(如购买或搜索事件),这些事实包含列(如用户账户 ID 或项目标识符),这些列可以与维度表(包含有关项目或用户的信息)或关系信息(如定义在线商店内容的物品 ID 层次结构)相关联。在 Web 应用程序中,关系型系统可以在幕后用于检索信息(例如,响应用户信息的 GET 请求),插入新信息或从数据库中删除行。由于关系型系统中的数据存储在表中,它需要遵循一系列常见的列,并且这些系统并不是针对嵌套结构(如 JSON)设计的。如果我们知道将经常查询的列(如项目 ID),我们可以在这些系统中的表上设计索引,以加快检索速度。一些常见的流行(和开源)关系型系统包括 MySQL、PostgreSQL 和 SQLite。

非关系型数据库,也称为'NoSQL',遵循一个非常不同的数据模型。这些系统不是由多列的表组成,而是设计为具有替代布局,例如键值存储,其中一行信息(如客户账户)有一个键(如项目索引)和值字段中的任意数量的信息。例如,值可以是单个项目或嵌套的其他键值序列。这种灵活性意味着 NoSQL 数据库可以在同一表中存储具有不同模式的信息,因为值字段中的字段不需要具体定义。其中一些应用程序允许我们在值中的特定字段上创建索引,就像关系型系统一样。除了键值数据库(如 Redis)和文档存储(如 MongoDB)之外,NoSQL 系统还包括列存储,其中数据主要基于列块而不是行(例如 Cassandra 和 Druid),以及图数据库,如 Neo4j,这些数据库针对由节点和边组成的数据进行了优化(例如我们在第三章中研究的谱聚类上下文),在噪声中寻找模式 - 聚类和无监督学习。在本章的示例中,我们将使用 MongoDB 和 Redis。

除了存储具有灵活模式的数据,如我们可能在 REST API 调用中遇到的嵌套 JSON 字符串外,键值存储还可以通过允许我们持久化任务的状태在 Web 应用程序中发挥另一个功能。对于快速响应的请求,如获取信息的 GET 类,这并不是必需的。然而,预测服务可能经常有长时间运行的任务,这些任务是通过 POST 请求启动的,需要时间来计算响应。即使任务尚未完成,我们也希望立即向启动任务的客户端返回一个响应。否则,客户端将等待服务器完成而停滞,这可能会影响客户端的性能,并且与之前描述的系统组件解耦的哲学非常不符。相反,我们希望立即向客户端返回一个任务标识符,这将允许客户端轮询服务以检查任务的进度,并在结果可用时检索它。我们可以使用键值数据库存储任务的状태,并提供更新方法,允许我们通过编辑任务记录提供中间进度信息,以及 GET 方法,允许客户端检索任务的当前状态。在我们的示例中,我们将使用 Redis 作为后端存储长时间运行应用程序的任务结果,并且作为任务可以通过其通信的消息队列,这个角色被称为“经纪人”。

现在我们已经涵盖了预测服务的基本结构,让我们考察一个具体的例子,这个例子将结合我们在前几节中开发的许多预测建模任务的模式。

案例研究 – 逻辑回归服务

作为之前提到的架构的示例,让我们来看一个实现逻辑回归模型的预测服务示例。该模型既用于训练数据,也用于使用通过 URL 传递的信息(无论是通过网页浏览器还是通过命令行调用 curl)对新数据进行评分,并展示了这些组件是如何协同工作的。我们还将检查如何使用之前相同的 IPython 笔记本交互式测试这些组件,同时允许我们将生成的代码无缝部署到独立的应用程序中。

我们的首要任务是设置用于存储建模中使用的信息的数据库,以及结果和模型参数。

设置数据库

在我们的应用程序的第一步中,我们将设置数据库以存储我们的训练数据和模型,以及为新数据获得的分数。这个练习的示例包括来自营销活动的数据,其目标是说服客户订阅定期存款(Moro, Sérgio, Paulo Cortez, 和 Paulo Rita. "A data-driven approach to predict the success of bank telemarketing."Decision Support Systems 62 (2014): 22-31)。因此,使用这些数据的目的是根据客户的特征变量预测他们是否可能为此服务付费。数据包含在bank-full.csv文件中,我们需要将其加载到 MongoDB 中(www.mongodb.org/)。

在您的系统上安装 MongoDB 后,您可以通过在终端中运行以下命令来测试数据库:

$ mongodb

上述命令应该启动数据库。现在,为了导入我们的训练数据,我们可以在另一个终端窗口中使用以下命令:

$ mongoimport -d datasets -c bank --type csv --file bank-full.csv —headerline

这将使我们能够将数据导入一个名为'datasets'的数据库中,在名为 bank 的集合中。我们可以通过在终端中打开一个 mongo 客户端来测试数据是否已成功加载:

$ mongo

如果我们运行以下命令,我们应该能够在 datasets 数据库下看到我们的数据集列表:

$ use datasets
$ show collections

我们可以通过检查一条记录来验证数据是否被正确解析:

$ db.bank.findOne()

注意

这里的代码灵感来源于github.com/jadianes/spark-movie-lensfgimian.github.io/blog/2012/12/08/setting-up-a-rock-solid-python-development-web-server中的示例。

您可以看到记录看起来像 Python 字典。为了检索具有特定值的元素,我们可以使用带有 key:values 设置为要应用的过滤器的 findOne:

$ db.bank.findOne({},{key:value,..})

现在我们已经加载数据,我们可以通过使用 pymongo 客户端通过 Python 与之交互。我们使用以下方式初始化一个客户端,以访问我们刚刚创建的数据库:

>>> from pymongo import MongoClient
>>> MONGODB_HOST = 'localhost'
>>> MONGODB_PORT = 27017
>>> DBS_NAME = 'datasets'
>>> COLLECTION_NAME = 'bank'
>>> connection = MongoClient(MONGODB_HOST, MONGODB_PORT)
>>> collection = connection[DBS_NAME][COLLECTION_NAME]
>>> customers = collection.find(projection=FIELDS)

注意,mongod命令仍然需要在单独的终端窗口中运行,以便您可以通过 Python 访问数据库。客户对象将包含每个客户的记录。而对于当前示例,我们将主要使用 SparkConnector 通过 MongoDB 进行分析,上述命令将在第九章,报告和测试 – 在分析系统中迭代时有用,当我们分析模型输出时。实际上,MongoDB 数据库允许我们存储模型服务使用的信息,也可以成为我们将要在第九章,报告和测试 – 在分析系统中迭代中构建的报告服务共享信息的来源,通过可视化我们的建模结果。

如前所述,我们还将使用 Redis (redis.io/) 键值存储来记录长时间运行任务的中间状态,以及存储在 Spark 中训练模型的序列化输出。在您的系统上安装 Redis 数据库后,您应该在终端中键入以下命令来启动服务器:

 > redis-server

如果成功,应该会得到以下输出:

设置数据库

redis-py 包中的 Redis Python 接口(类似于我们在前几章中看到的许多库,可以使用pipeasy_install安装)与 MongoDB 相当。如果我们想从我们的 redis 数据库中检索记录,我们可以使用以下命令启动客户端并发出查询或存储数据:

>>> import redis
>>> r = redis.StrictRedis(host='localhost', port=6379, db=1)
>>> r.get(key)
>>> r.set(key,value)

当我们使用 'StrictRedis' 启动新客户端时,我们指定 redis-server 监听的端口(默认为 6379)和数据库标识符。通过发出 get 和 set 命令,我们可以分别检索先前结果或更新数据库中的新信息。与 Python mongo 客户端一样,我们需要在单独的命令行窗口中运行 redis-server 命令,以便我们可以在 Python 中向数据库发出命令。

现在我们已经设置了数据库,让我们看看将管理使用这些数据的请求的服务器。

网络服务器

如前所述,网络服务器接收请求并将它们转发到网络应用程序。对于我们的示例,我们使用 main 函数启动服务器:

>>>if __name__ == "__main__":

 modelparameters = json.loads(open(sys.argv[1]).readline())

 service = modelservice(modelparameters)

 run_server(service)

有三个步骤:我们读取此服务的参数(在这里,只是使用的算法名称),它作为命令行参数传递,创建网络应用程序(使用在构造函数中创建时传递的相同参数文件),然后启动服务器。如您所见,预测服务运行的算法使用字符串参数指定。稍后我们将检查这如何允许我们编写一个通用的预测服务类,而不是为每个可能使用的新算法编写特定的网络应用程序。当我们启动服务器时;它在 localhost 的 5000 端口上注册,如您通过检查run_server函数的主体所见:

>>>  def run_server(app):
 import paste
 from paste.translogger import TransLogger
 app_ = TransLogger(app)
 cherrypy.tree.graft(app_, '/')
 cherrypy.config.update({
 'engine.autoreload.on': True,
 'log.screen': True,
 'server.socket_port': 5000,
 'server.socket_host': '0.0.0.0'
 })
 cherrypy.engine.start()
 cherrypy.engine.block()

在这个函数中发生了一些关键的事情。首先,我们看到中间件的作用,因为来自 paste 库的 TransLogger 类在服务器和应用程序之间传递请求。然后,TransLogger 对象代表一个有效的 WGSI 应用程序,因为它有一个可调用的对象(即应用程序)。我们使用tree.graft命令附加应用程序(即模型服务本身),这样当 CherryPy 模型服务器接收到 HTTP 请求时,就会调用该对象。

当我们启动 cherrypy 服务器时,我们会提供一些配置。enable.autoreload.on参数控制当更改应用程序指向的源文件时,应用程序是否会刷新,在这种情况下是我们的 Flask 应用程序。Log.screen将错误和访问消息的输出定向到 stdout,这在调试时很有用。最后,最后两个设置指定了我们将向应用程序发送请求的 URL 和端点。

一旦我们启动了应用程序,我们也会将其设置为阻塞模式,这意味着它必须完成处理一个请求之后才会考虑另一个请求。如果我们想调整性能,我们可以移除这个配置,这样应用程序就可以在等待第一个请求完成之前接收多个请求。因此,一旦服务器启动,可以通过http://0.0.0.0:5000访问这个服务器的 URL——这是我们向预测服务发送各种命令的地址。要启动服务器,请在命令行中输入以下内容:

> python modelserver.py parameters.json

parameters.json文件可能包含在启动建模应用程序时将使用的modelservice应用程序的参数,但到目前为止,我们实际上在这个文件中放置了空的内容。如果成功,你应该在终端中看到以下输出:

网络服务器

当我们向服务器发出curl命令时,我们将在输出中看到相应的响应。

网络应用程序

现在我们已经启动了服务器,并且可以开始接收来自客户端的命令,让我们看看我们的应用程序将要执行的命令,例如通过 Python 笔记本或 curl 命令发出的 HTTP 请求。当我们向CherryPy服务器发送请求时执行的代码包含在modelservice.py文件中。

当我们启动应用程序时,CherryPy服务器会调用构造函数,该构造函数返回一个使用 Flask 框架指定的 app 对象:

>>> def modelservice(model_parameters):
 …return app
What is the definition of app? If we examine the beginning of the modelservice.py file, we see that app is defined using the Flask library:
>>> app = Flask(__name__)
… app.config.update(CELERY_BROKER_URL='redis://localhost:6379',CELERY_RESULT_BACKEND='redis://localhost:6379')
… celery = Celery(app.import_name, backend=app.config['CELERY_RESULT_BACKEND'],broker=app.config['CELERY_BROKER_URL'])
… celery.conf.update(app.config)

除了创建 Flask 对象 app,我们还生成一个 celery 对象。这个 celery 对象是什么?如前所述,我们不希望我们的客户端在等待长时间运行的任务响应,因为这可能会导致客户端应用程序挂起或超时。因此,我们的应用程序需要是非阻塞的,并且对于长时间运行的任务,立即返回一个 ID,这个 ID 允许我们通过 REST API 访问任务的进度和结果。我们希望在辅助进程中运行长时间运行的任务,并在结果或中间状态可用时报告它们。对于我们的应用程序,我们将使用 Celery 库(www.celeryproject.org/),这是一个异步任务队列系统,非常适合此类应用程序。Celery 由提交作业到队列的客户机和读取此队列、执行工作并将结果返回给客户的工人任务组成。客户端和工人通过消息队列进行通信,例如我们之前提到的 Redis 键值存储,结果也持久化到这个数据库中。CELERY_BROKER_URLCELERY_RESULT_BACKEND 参数分别用于指定工人任务检索计划任务信息的位置,以及我们可以查找当前运行任务状态信息的位置。在我们的示例中,这两个功能都由 Redis 提供,但我们可以用其他系统替换,例如消息队列系统 RabbitMQ(www.rabbitmq.com/)。

为了让我们向 Celery 工人任务发出 HTTP 请求,我们需要确保 redis 已经运行,然后使用以下命令启动 Celery 工人:

> celery worker -A modelservice.celery

这将启动具有对 modelservice.py 中指定命令的访问权限的芹菜工作进程,我们将在下面进行介绍。如果成功,你将在你的终端中看到以下内容。

网络应用程序

当我们稍后向服务发送请求,这些请求被传递给 Celery 工人时,信息(如 Spark 输出)也将在此窗口中打印。

预测服务的流程 - 训练模型

因此,现在我们已经启动了 Celery 进程以及 Flask 应用程序,我们如何定义工人执行的函数以响应我们的 HTTP 请求?我们如何指定我们将发出 curl 命令的 URL?我们将通过展示对训练函数的调用如何启动一系列 Spark 作业以执行交叉验证并存储逻辑回归模型来展示事件流程。

我们首先向 train 函数发出 curl 命令,如下所示:

curl -X POST http://0.0.0.0:5000/train/ -d @job.json --header "Content-Type: application/json"

我们同样可以使用 Python 的 requests 库将 job.json 中的信息传输给模型训练任务。job.json 文件包含了我们在解析数据和训练模型各个阶段可能需要使用的所有参数,正如我们在遍历这个请求流程时将会看到的。当这个命令被 CherryPy 模型服务器接收时,它会被转发到在 modelservice.py 中定义的 Flask 应用。我们如何让 Flask 应用响应这个请求呢?这就像提供一个装饰器,指定一个在接收到这个 URL 的请求时运行的函数一样简单:

>>> @app.route("/train/",methods=["POST"])
… def train():
…    try:
 …       parsed_parameters = request.json
 …   trainTask = train_task.apply_async(args=[parsed_parameters])
 …   return json.dumps( {"job_id": trainTask.id } )
 except:
 …    print(traceback.format_exc())

@app.route 装饰器表示 Flask 对象 app 监听一个作为 route 参数提供的 URL 的 POST 命令。在响应中,它从 POST 请求中提取参数字典,并将其传递给 train_task,该任务将通过 apply_async 函数在 Celery 工作进程上运行。然后我们立即返回与这个任务关联的任务标识符,我们可以使用它来检查状态,或者,正如我们将看到的,识别结果的模型输出。

我们如何指定 Celery 任务 train_task?同样,我们提供一个装饰器,表示这个函数将在工作进程中运行:

>>> @celery.task(bind=True)
… def train_task(self,parameters):
…   try: 
 …       spark_conf = start_conf(parameters)
…        model.set_model(parameters)
…        messagehandler = MessageHandler(self)
 …       model.train(parameters,messagehandler=messagehandler,sc=spark_conf)
 …   except:
…        messagehandler.update('FAILURE',traceback.format_exc())

这里有几个重要的细节。首先,除了使用 @celery.task 装饰函数外,我们还提供了 bind=True 参数。这确保了函数有一个 self 参数。我们为什么需要一个 self 参数呢?在我们的例子中,我们使用函数的引用(self)将 MessageHandler 对象附加到训练任务上,这样我们就可以在任务进行过程中注入状态更新,并检索在发出 POST 请求后返回的任务标识符。MessageHandler 类相对简单,如下所示,定义在代码示例这一章的 messagehandler.py 文件中:

>>> class MessageHandler:
 …
 …def __init__(self,parent):
 …    self.parent = parent
 …   self.task_id = parent.request.id
 …
 … def update(self,state,message):
 …    self.parent.update_state(state=state,meta={"message": message})
 …
 …def get_id(self):
 …return self.task_id

当我们构建 MessageHandler 对象时,我们从 request.id 字段检索与任务关联的 ID。如果我们没有在上面的 bind=True 参数中使用,我们就无法访问这个字段,因为我们没有任务对象的引用(self)来传递给 MessageHandler。这也需要 update 函数,它允许我们使用上面的训练任务引用注入任务进度的状态更新。最后,如果我们需要在应用程序的其他地方访问训练任务标识符,我们可以使用 get_id 来实现。

我们如何访问更新后修改的任务状态?如果你还记得,当我们初始化 Celery 应用时,我们提供了 Redis 数据库作为任务状态信息的存储位置。使用我们 POST 请求返回的标识符,我们可以使用 GET 方法来查找这个任务的状态,我们通过另一个 Flask 应用端点来指定这个状态:

>>> @app.route('/training/status/<task_id>')
… def training_status(task_id):
…    try: 
…        task = train_task.AsyncResult(task_id)
…        message = ""
…        if task.state == 'PENDING':
 …           response = {
 …               'status': task.status,
 …               'message': "waiting for job {0} to start".format(task_id)
 …           }
 …       elif task.state != 'FAILED':
 …           if task.info is not None:
 …               message = task.info.get('message','no message')
 …           response = {
 …               'status': task.status,
 …               'message': message
 …           }
 …       else:
…            if task.info is not None:
 …               message = task.info.get('message','no message')
 …           response = {
 …              'status': task.status,
 …              'message': message 
 …           }
 …       return json.dumps(response)
 …   except:
 …       print(traceback.format_exc())

因此,使用curl命令,我们可以发出一个 GET 请求来获取我们训练任务的状况,要么将其打印到控制台,要么如果我们使这个应用程序更复杂,可以使用它来生成一个工作状态仪表板,用于管道或系统。

既然我们已经有了注入关于任务状态更新的方法,让我们回到train_task定义。除了为这个任务创建MessageHandler之外,我们还生成一个SparkConfiguration并初始化一个模型对象。SparkConfiguration可能看起来与之前章节中的一些示例相似,并且由以下函数返回:

>>> def start_conf(jobparameters):
 … conf = SparkConf().setAppName("prediction-service")
 … conf.set("spark.driver.allowMultipleContexts",True)
 …conf.set("spark.mongodb.input.uri",jobparameters.get('inputCollection',\
 …     "mongodb://127.0.0.1/datasets.bank?readPreference=primaryPreferred"))
 … conf.set("spark.mongodb.output.uri",jobparameters.get('outputCollection',\
 …    "mongodb://127.0.0.1/datasets.bankResults"))
 …return conf

注意

注意,SparkConfiguration的参数由 Spark mongo 连接器使用。这个连接器是一个外部依赖项,需要在运行时下载并添加到我们的 Spark 应用程序的系统路径中,这可以通过向您的系统参数添加以下内容来完成(假设 Linux 命令行环境):

export PYSPARK_SUBMIT_ARGS="--packages org.mongodb.spark:mongo-spark-connector_2.10:1.0.0 pyspark-shell"

在这里,我们设置了应用程序名称,我们将通过该名称在 Spark UI 的 4040 端口上识别训练任务,并允许通过"spark.driver.allowMultipleContexts"使用多个上下文,这样几个 Spark 应用程序可以并行运行。最后,我们提供了mongodb输入和输出位置,Spark 将从中读取训练数据并将评分结果存储在这些位置。请注意,这些默认值可以更改,只需修改job.json文件中的参数即可,这样我们的应用程序可以通过仅更改 POST 请求的参数来在不同的输入上运行并将数据存储到不同的输出位置。

现在我们有了传递给 Spark 作业的配置,让我们看看将接收这些参数的模型对象。我们在modelservice文件的开始处构建它,如下所示:

>>> model = ModelFactory()

如果您检查随代码示例提供的modelfactory.py文件中ModelFactory类的定义,您会看到它为不同机器学习算法的训练和预测函数提供了一个通用接口:

>>> class ModelFactory:

...  def __init__(self):
…    self._model = None

…  def set_model(self,modelparameters):
…    module = importlib.import_module(modelparameters.get('name'))
…    model_class = getattr(module, modelparameters.get('name'))
…    self._model = model_class(modelparameters)

…  def get_model(self,modelparameters,modelkey):
…    module = importlib.import_module(modelparameters.get('name'))
…    model_class = getattr(module, modelparameters.get('name'))
…    self._model = model_class(modelparameters)
…    self._model.get_model(modelkey)

…  def train(self,parameters,messagehandler,sc):
…    self._model.train(parameters,messagehandler,sc)

…  def predict(self,parameters,input_data):
…    return self._model.predict(parameters,input_data)

…  def predict_all(self,parameters,messagehandler,sc):
…    self._model.predict_all(parameters,messagehandler,sc)

正如你所见,在这个类中,我们并没有指定训练或预测任务的特定实现。相反,我们创建了一个具有内部成员(self_model)的对象,我们可以通过 set_model 使用它来设置,通过使用 importlib 动态检索与特定算法相关的代码。"name" 参数也来自 job.json,这意味着我们可以在应用程序中加载不同的算法并运行训练任务,只需更改我们的 POST 请求的参数即可。在这个例子中,我们将模型指定为 LogisticRegressionWrapper,这将导致在调用 train_task 时,此模型(以及同名的类)被加载并插入到 ModelFactoryself_model 中。ModelFactory 还有一个用于加载现有模型的通用方法 get_model,它接受一个任务 ID 作为输入,例如响应我们的训练请求生成的任务 ID,并将 self_model 设置为使用此任务 ID 作为参考检索到的先前训练的模型对象。此外,这个类还有用于预测(为单行数据提供预测响应)或 predict_all(使用 Spark 执行批量评分)的方法。

回顾一下,现在我们看到,在响应我们的 POST 请求时,CherryPy 服务器将 data.json 中的信息传递给我们的 Flask 服务中的 train 函数,该函数在 Celery 工作器上启动一个后台进程。这个工作进程将我们的 Flask 应用程序的通用模型对象设置为逻辑回归,创建一个 Spark 配置来运行训练任务,并返回一个任务 ID,我们可以用它来监控模型训练的进度。在这次 POST 请求的最终步骤中,让我们看看逻辑回归模型是如何实现训练任务的。

LogisticRegressionWrapper.py 文件中,你可以看到训练任务的规格:

>>> def train(self,parameters,messagehandler,spark_conf):
…        try:
…            sc = SparkContext(conf=spark_conf, pyFiles=['modelfactory.py', 'modelservice.py'])
…            sqlContext = SQLContext(sc)
…            iterations = parameters.get('iterations',None)
…            weights = parameters.get('weights',None)
…           intercept = parameters.get('intercept',False)
…            regType = parameters.get('regType',None)
 …           data = sqlContext.\
 …               createDataFrame(\
 …               sqlContext.read.format("com.mongodb.spark.sql.DefaultSource").\
 …               load().\
 …               map(lambda x: DataParser(parameters).parse_line(x)))
 …           lr = LogisticRegression()
 …           pipeline = Pipeline(stages=[lr])
 …           paramGrid = ParamGridBuilder()\
 …               .addGrid(lr.regParam, [0.1]) \
 …               .build()

 …           crossval = CrossValidator(estimator=pipeline,\
 …                 estimatorParamMaps=paramGrid,\
 …                 evaluator=BinaryClassificationEvaluator(),\
 …                 numFolds=2)
 …           messagehandler.update("SUBMITTED","submitting training job")
 …           crossvalModel = crossval.fit(data)
 …           self._model = crossvalModel.bestModel.stages[-1]
 …           self._model.numFeatures = len(data.take(1)[0]['features'])
 …           self._model.numClasses = len(data.select('label').distinct().collect())
 …          r = redis.StrictRedis(host='localhost', port=6379, db=1)
 …          r.set( messagehandler.get_id(), self.serialize(self._model) )
 …          messagehandler.update("COMPLETED","completed training job")
 …          sc.stop()
 …      except:
 …          print(traceback.format_exc())
 ….          messagehandler.update("FAILED",traceback.format_exc())

首先,我们使用传递给此函数的 SparkConfiguration 中定义的参数启动 SparkContext。我们的 job.json 文件中的参数还包括算法参数,我们解析这些参数。然后,我们以分布式方式从 mongodb 读取我们在 SparkConfiguration 中指定的输入数据,将其读取到 Spark DataFrame 中,使用 lambda 函数解析输入。解析逻辑在 dataparser.py 文件中的 DataParser 类的 parse_line 函数中定义:

>>> def parse_line(self,input,train=True):
…        try:
…            if train:
…               if self.schema_dict.get('label').get('values',None) is not None:
 …                   label = self.schema_dict.\
 …                   get('label').\
 …                   get('values').\
 …                   get(input[self.schema_dict.\
 …                   get('label').\
…                    get('key')])
 …               else:
 …                   label = input[self.schema_dict.\
 …                   get('label').\
 …                   get('key')]
 …           features = []
 …           for f in self.schema_dict['features']:
 …               if f.get('values',None) is not None:
 …                   cat_feature = [ 0 ] * len(f['values'].keys())
 …                  if len(f['values'].keys()) > 1: # 1 hot encoding
 …                       cat_feature[f['values'][str(input[f.get('key')])]] = 1
 …                   features += cat_feature # numerical
 …               else:
 …                   features += [ input[f.get('key')] ]

 …           if train:
 …               Record = Row("features", "label")
 …               return Record(Vectors.dense(features),label)
 …           else:
 …               return Vectors.dense(features)

…        except:
…            print(traceback.format_exc())
…            pass

DataParser类接受一个包含数据模式的参数字典作为输入,该模式——再次强调——我们在我们的job.json数据中指定了,这是我们包含在 POST 请求中的。这些信息存储在解析器的self._schema属性中。使用这些信息,parse_line 函数提取标签(响应列)并在必要时将其编码为数值。同样,解析每个记录的特征,并在必要时使用 POST 请求中的信息进行独热编码。如果数据要用于训练(train=True),解析器返回标签和特征向量。否则,它只返回用于评分新记录的特征。在任何情况下,特征都编码为来自 Spark ml 库的密集向量(这对于逻辑回归算法是必需的),并将行作为 Row 对象返回,以与用于训练代码的 Spark DataFrame 兼容。因为我们在job.json数据中指定的字段用作特征,所以我们可以使用相同的数据集的不同列来训练模型,而无需更改底层代码。

一旦数据被解析,我们构建一个 Spark Pipeline 对象来处理模型训练的阶段。在我们的示例中,唯一的步骤就是模型训练本身,但我们可能具有像我们在第六章中检查的 Vectorizers 这样的转换,作为文本数据处理的管道的一部分。然后我们创建一个 ParamGrid 来执行模型正则化参数的网格搜索,并将其传递给 CrossValidator,它将执行 n 折验证以确定最佳模型。一旦我们拟合了这个模型,我们就从 CrossValidator 的结果中检索最佳模型,并确定模型中使用的特征和类的数量。最后,我们通过使用函数序列化该模型后,打开与 Redis 数据库的连接并存储其参数:

>>> def serialize(self,model):
…        try:
…            model_dict = {}
 …           model_dict['weights'] = model.weights.tolist()
…            model_dict['intercept'] = model.intercept
 …           model_dict['numFeatures'] = model.numFeatures
…            model_dict['numClasses'] = model.numClasses
 …           return json.dumps(model_dict)
…        except:
 …           raise Exception("failed serializing model: {0}".format(traceback.format_exc()))

注意,我们使用附加到该任务的 MessageHandler 来检索任务 ID,该 ID 用作在 Redis 中存储序列化模型的键。此外,尽管我们将结果存储在由 Celery 用于队列任务和更新后台任务状态的同一 Redis 实例(监听端口 6379)中,但我们将其保存到 db 1 而不是默认的 0,以分离信息。

通过追踪上述步骤,你现在应该能够看到如何将 POST 请求转换成一系列命令,这些命令解析数据,执行交叉验证网格搜索来训练模型,然后将该模型序列化以供以后使用。你也应该欣赏到每一层的参数化如何使我们能够仅通过修改 POST 请求的内容来修改训练任务的行为,以及应用程序的模块化如何使其易于扩展到其他模型。我们还使用了 Spark,这将允许我们随着时间的推移轻松地将我们的计算扩展到更大的数据集。

既然我们已经说明了我们预测服务中的数据逻辑流程,让我们通过检查预测函数来结束,这些函数的输出将用于第九章,报告和测试 – 在分析系统中迭代

按需和批量预测

现在我们已经在系统中保存了一个训练好的模型,我们如何利用它来评分新数据?我们的 Flask 应用程序为此服务提供了两个端点。在第一个端点,我们发送一个 POST 请求,其中包含一行数据作为 json,以及一个模型 ID,并请求从逻辑回归模型中获取一个评分:

>>> @app.route("/predict/",methods=['POST'])
… def predict():
…    try:
…        parsed_parameters = request.json
 …       model.get_model(parsed_parameters,parsed_parameters.get('modelkey'))
…        score = model.predict(parsed_parameters,parsed_parameters.get('record'))
…        return json.dumps(score)
…    except:
 …       print(traceback.format_exc())

这次,我们不是调用 ModelFactoryset_model 方法,而是使用 get_model 加载一个先前训练好的模型,然后使用它来预测输入记录的标签并返回值。在逻辑回归的情况下,这将是一个 0 或 1 的值。虽然在这个例子中我们没有提供用户界面,但我们可以想象一个简单的表单,用户在其中指定记录的几个特征,并通过 POST 请求提交它们,实时收到预测结果。

在观察 LogisticRegressionWrapperget_model 的实现时,我们发现可以检索并反序列化我们在训练任务中生成的模型,并将其分配给 ModelFactoryself._model 成员:

>>> def get_model(self,modelkey):
…        try:
…            r = redis.StrictRedis(host='localhost', port=6379, db=1)
…            model_dict = json.loads(r.get(modelkey))
 …           self._model = LogisticRegressionModel(weights=Vectors.dense(model_dict['weights']),\
 …               intercept=model_dict['intercept'],\
 …               numFeatures=model_dict['numFeatures'],\
 …               numClasses=model_dict['numClasses']
 …               )
 …       except:
 …          raise Exception("couldn't load model {0}: {1}".format(modelkey,traceback.format_exc()))

随后,当我们评分一个新记录时,我们调用 predict 函数来解析这个记录,并使用反序列化的模型生成一个预测:

>>> def predict(self,parameters,input_data):
…        try:
…            if self._model is not None:
 …               return self._model.predict(DataParser(parameters).parse_line(input_data,train=False))
 …           else:
 …               return "Error, no model is trained to give predictions"
 …       except:
 …           print(traceback.format_exc())

这种功能对于交互式应用程序非常有用,例如,人类用户提交一些感兴趣的记录以获取预测,或者对于实时应用程序,我们可能会接收流输入并提供即时使用的预测。请注意,尽管在这个特定实例中我们没有使用 Spark,但我们仍然有很好的横向扩展机会。一旦我们训练了模型,我们就可以在多个 modelservice 副本中反序列化结果参数,这样我们就可以在收到许多请求时避免超时。然而,在需要大量预测且必要的延迟不是实时的案例中,利用 Spark 来执行数据库中记录的批量评分可能更有效。我们通过在 Flask 应用程序中指定predictall端点的方式,使用 Celery 任务实现这种批量评分功能,类似于train_task

>>> @app.route("/predictall/",methods=["POST"])
… def predictall():
…    try:
…       parsed_parameters = request.json

…        predictTask = predict_task.apply_async(args=[parsed_parameters])
…        return json.dumps( {"job_id": predictTask.id } )
…    except:
…        print(traceback.format_exc())

相关的 Celery 任务如下所示:

>>> @celery.task(bind=True)
… def predict_task(self,parameters):
…    try: 
…        spark_conf = start_conf(parameters)
 …       messagehandler = MessageHandler(self)
…        model.get_model(parameters,parameters.get('modelkey'))
…        print(model._model._model)
 …       model.predict_all(parameters,messagehandler=messagehandler,sc=spark_conf)
…    except:
…        messagehandler.update('FAILURE',traceback.format_exc())

再次,我们创建一个 SparkConfiguration 和一个 MessageHandler,就像 predict 方法一样,我们使用job.json中指定的先前模型 ID 来加载一个之前的训练模型。然后我们调用该模型的predict_all方法来启动一个批量评分流程,该流程将为大量数据生成预测,并将结果存储在 SparkConfiguration 输出位置参数指定的mongodb集合中。对于LogisticRegressionWrapperpredict_all方法如下所示:

>>> def predict_all(self,parameters,messagehandler,spark_conf):
…        try:
…            sc = SparkContext(conf=spark_conf, pyFiles=['modelfactory.py', 'modelservice.py'])
…            sqlContext = SQLContext(sc)
…            Record = Row("score","value")
…           scored_data = sqlContext.\
…                createDataFrame(\
…                sqlContext.read.format("com.mongodb.spark.sql.DefaultSource").\
…                load().\
…                map(lambda x: Record(self._model.predict(DataParser(parameters).parse_line(x,train=False)),x)))
…           messagehandler.update("SUBMITTED","submitting scoring job")
… scored_data.write.format("com.mongodb.spark.sql.DefaultSource").mode("overwrite").save()
…            sc.stop()
…        except:
…         messagehander.update("FAILED",traceback.format_exc())

与训练任务一样,我们使用在 Celery 任务中定义的 SparkConfiguration 启动一个 SparkContext,并使用 Spark 连接器从 mongodb 加载数据。我们不仅解析数据,还使用get_model命令加载的反序列化模型对解析的记录进行评分,并将这两个以及原始记录都传递给一个新的 Row 对象,该对象现在有两个列:评分和输入。然后我们将这些数据保存回 mongodb。

如果你打开 mongo 客户端并检查bankResults集合,你可以验证它现在包含批量评分的输入数据。我们将在第九章 报告和测试 – 在分析系统中迭代中使用这些结果,我们将这些评分暴露在报告应用程序中以可视化我们模型的持续性能并诊断模型性能中的潜在问题。

摘要

在本章中,我们描述了基本预测服务的三个组成部分:客户端、服务器和 Web 应用。我们讨论了这种设计如何使我们能够与其他用户或软件系统共享预测建模的结果,以及如何将我们的建模水平化和模块化以适应各种用例的需求。我们的代码示例说明了如何创建一个具有通用模型和数据解析功能的预测服务,这些功能可以在我们尝试特定业务用例的不同算法时重复使用。通过利用 Celery 工作线程中的后台任务以及在 Spark 上进行分布式训练和评分,我们展示了如何有可能将此应用程序扩展到大型数据集,同时向客户端提供任务状态的中间反馈。我们还展示了如何使用按需预测工具通过 REST API 生成数据流的实时评分。

使用这个预测服务框架,在下一章中,我们将扩展这个应用以提供对我们预测模型性能和健康状况的持续监控和报告。

第九章。报告和测试 – 迭代分析系统

在前面的章节中,我们考虑了许多分析应用组件,从输入数据集到算法选择和调整参数,甚至展示了使用 Web 服务器的一个潜在部署策略。在这个过程中,我们考虑了可扩展性、可解释性和灵活性等参数,以使我们的应用能够应对算法的后续改进和规模需求的变化。然而,这些细节忽略了此应用最重要的元素:希望从模型中获得洞察力的业务伙伴以及组织持续的需求。我们应该收集哪些关于模型性能的指标来证明其影响?我们如何迭代初始模型以优化其在商业应用中的使用?如何将这些结果传达给利益相关者?这些问题在传达为组织构建分析应用的好处时至关重要。

正如我们可以使用越来越大的数据集来构建预测模型一样,自动分析包和“大数据”系统正在使收集大量关于算法行为的信息变得更加容易。因此,挑战不再是我们能否收集算法数据或如何衡量这种性能,而是选择哪些统计数据在商业分析环境中最能体现价值。为了使你具备更好地监控预测应用健康状况、通过迭代里程碑改进它们以及在本章中向他人解释这些技术的技能,我们将:

  • 审查常见的模型诊断和性能指标。

  • 描述如何使用 A/B 测试迭代改进模型。

  • 总结在报告中传达预测模型预测洞察的方法。

使用诊断工具检查模型的健康状况

在前面的章节中,我们主要关注预测建模的初始步骤,从数据准备和特征提取到参数优化。然而,我们的客户或业务不太可能保持不变,因此预测模型通常也需要适应。我们可以使用多种诊断工具来检查模型随时间的变化性能,这些工具作为评估我们算法健康状况的有用基准。

评估模型性能的变化

让我们考虑一个场景,在这个场景中,我们在客户数据上训练一个预测模型,并在之后的每个月每天对一组新的记录进行性能评估。如果这是一个分类模型,例如预测客户在下一个支付周期是否会取消订阅,我们可以使用之前在第五章中看到的曲线下面积AUC)和接收者操作特征ROC)曲线的度量,即将数据放在合适的位置 – 分类方法和分析。或者,在回归模型的情况下,例如预测平均客户消费,我们可以使用值或平均平方误差:

评估模型性能变化

为了量化随时间推移的性能。如果我们观察到这些统计数据中的一个下降,我们如何进一步分析可能的原因?

评估模型性能变化

在上面的图表中,我们展示了这样一个场景,我们通过量化有多少目标用户点击了通过电子邮件发送的广告并访问了我们的公司网站,来衡量在初始训练后的 30 天内一个假设的广告定位算法的 AUC。我们看到 AUC 在第 18 天开始下降,但由于 AUC 是准确性的总体度量,因此不清楚是否所有观察结果都被错误预测,或者只有一部分子群体导致了性能下降。因此,除了测量总体 AUC 之外,我们还可能考虑计算由输入特征定义的数据子集的 AUC。除了提供识别问题新数据(并建议何时需要重新训练模型)的方法之外,此类报告还提供了一种识别我们模型整体业务影响的方法。

评估模型性能变化

以我们的广告定位算法为例,我们可能会查看整体性能,并将其与我们数据集中的某个标签进行比较:用户是否是当前订阅者。性能在订阅者身上通常更高可能并不令人惊讶,因为这些用户已经很可能访问过我们的网站。从未访问过我们网站的未订阅者代表了这种情况下的真正机会。在前面的图表中,我们可以看到,确实,在第 18 天非订阅者的性能下降了。然而,也值得注意,这并不一定能够完全解释性能下降的原因。我们仍然不知道为什么新成员的性能较低。我们可以再次对数据进行子集划分,并寻找相关的变量。例如,如果我们查看多个广告 ID(这些 ID 对应于在电子邮件中向客户展示的不同图片),我们可能会发现性能下降是由于某个特定的广告(请参阅以下图表)。通过跟进我们的业务利益相关者,我们可能会发现这个特定的广告是为季节性产品做的,并且每年只展示 12 个月。因此,这个产品对订阅者来说很熟悉,他们可能之前见过这个产品,但对非会员来说则不熟悉,因此他们没有点击它。我们可能可以通过查看订阅者数据来验证这个假设,看看模型的性能是否也会在服务期限少于 12 个月的订阅者身上下降。

评估模型性能的变化

这种调查可以开始探讨如何优化针对新成员的特定广告,同时也可能指出改进我们模型训练的方法。在这种情况下,我们可能是在一个简单随机样本的数据上训练了算法,而这个样本对当前订阅者是有偏见的,因为我们如果对事件数据进行了简单随机抽样,那么我们关于这些客户的数据就更多了:订阅者更加活跃,因此他们产生的印象(他们可能已经注册了促销电子邮件)更多,点击广告的可能性也更大。为了改进我们的模型,我们可能想要在订阅者和非订阅者之间平衡我们的训练数据,以补偿这种偏差。

在这个简单的例子中,我们能够通过检查模型在数据的一小部分子段上的性能来诊断问题。然而,我们无法保证这种情况总是会发生,手动搜索数百个变量将是不高效的。因此,我们可能考虑使用预测模型来帮助缩小搜索范围。例如,可以考虑使用来自第五章的梯度提升机GBM),将数据放在合适的位置 – 分类方法和分析,输入数据与训练预测模型所用的相同数据,输出数据为误分类(对于分类模型,可以是标签 1 或 0,对于回归模型,可以是平方误差或对数损失等连续误差)。现在我们有一个预测第一个模型中错误的模型。使用 GBM 这样的方法允许我们系统地检查大量潜在变量,并使用由此产生的变量重要性来缩小假设的数量。

当然,任何这些方法的成功都取决于导致性能下降的变量是否是我们训练集的一部分,以及问题是否与基本算法或数据有关。当然,也可以想象其他情况,其中存在一个我们没有使用来构建训练数据集的额外变量,这导致了问题,例如,在特定互联网服务提供商上的糟糕连接阻止用户点击广告到我们的网页,或者系统问题,如电子邮件投递失败。

通过对各个部分进行性能分析,也可以帮助我们确定在做出更改时算法是否按预期运行。例如,如果我们重新加权我们的训练数据以强调非订阅者,我们希望这些客户的 AUC 性能会提高。如果我们只检查整体性能,我们可能会观察到对现有客户的改进,但不是我们实际希望实现的效果。

特征重要性变化

除了检查模型随时间的准确性外,我们还可能想检查不同输入数据的重要性变化。在回归模型中,我们可能通过大小和统计显著性来判断重要系数,而在基于决策树的算法(如随机森林或 GBM)中,我们可以查看变量重要性的度量。即使模型的表现与之前讨论的评估统计量相同,底层变量的变化可能表明数据记录的问题或底层数据中具有商业意义的真实变化。

让我们考虑一个流失率模型,其中我们为用户账户输入一系列特征(如邮编、收入水平、性别以及每周在我们网站上花费的小时数等参与度指标),并尝试预测在每一个账单周期结束时,特定用户是否会取消其订阅。虽然有一个预测流失可能性的分数是有用的,因为我们可以针对这些用户进行额外的促销活动或定向信息,但贡献于这个预测的底层特征可能为我们提供更具体行动的见解。

在这个例子中,我们每周生成一个报告,列出预测模型中最重要的 10 个特征。从历史来看,这个列表一直保持一致,其中顾客的职业和收入是顶级变量。然而,在一周内,我们发现收入不再在这个列表中,取而代之的是邮编。当我们检查流入模型的数据时,我们发现收入变量不再被正确记录;因此,与收入相关的邮编在模型中成为了这个特征的替代品,而我们常规的变量重要性分析帮助我们检测到一个重大的数据问题。

如果收入变量被正确记录了呢?在这种情况下,如果底层特征都在捕捉客户的财务状况,那么邮编比收入更有预测力似乎不太可能。因此,我们可能会检查在过去一周内,是否有特定的邮编其流失率发生了显著变化。经过调查,我们发现竞争对手最近在某些邮编地区推出了价格更低的网站,这让我们既理解了邮编作为预测因素上升的原因(选择更低价格选项的客户更有可能放弃我们的网站),又指出了更大兴趣的市场动态。

这种第二种场景还暗示了另一个我们可能需要监控的变量:数据集中变量之间的相关性。虽然在大数据集中全面考虑每一对变量在计算上困难重重,在实践上又受到限制,但我们可以使用如第六章中描述的主成分分析等降维技术,文字与像素 - 处理非结构化数据,来提供变量之间相关性的高级概述。这把监控这些相关性的任务简化为检查几个重要成分的图表,反过来,这又能使我们注意到数据底层结构的变化。

无监督模型性能的变化

我们之前考察的例子都涉及一个监督模型,其中我们有一个预测的目标,并通过查看 AUC 或类似指标来衡量性能。在第三章中考察的无监督模型,即《在噪声中寻找模式 – 聚类和无监督学习》,我们的结果是聚类成员资格而不是目标。在这种情况下,我们可以查看哪些诊断?

在我们有黄金标准标签的情况下,例如如果我们对电子邮件文档进行聚类,我们有垃圾邮件与非垃圾邮件消息的人类标注标签,我们可以检查消息是否最终进入不同的聚类或混合。在某种程度上,这类似于查看分类精度。然而,对于无监督模型,我们可能经常没有任何已知的标签,聚类纯粹是一个探索性工具。我们仍然可以使用人类标注的示例作为指导,但对于更大的数据集来说,这可能变得不可行。在其他场景中,例如在线媒体的情感,仍然足够主观,以至于人类标签可能不会显著丰富来自如我们在第六章中讨论的 LDA 主题模型等自动化方法的标签。在这种情况下,我们如何判断聚类随时间的变化质量?

在那些组数是动态确定的场景中,例如通过第三章中描述的亲和传播聚类算法,即《在噪声中寻找模式 – 聚类和无监督学习》,我们检查聚类数量是否随时间保持不变。然而,在我们之前考察的大多数情况下,聚类数量是固定的。因此,我们可以设想一种诊断方法,即检查训练周期之间最近聚类中心的距离:例如,对于一个有 20 个聚类的 k-means 模型,将第 1 周中的每个聚类分配给第 2 周中最接近的匹配,并比较 20 个距离的分布。如果聚类保持稳定,那么这些距离的分布也应该如此。变化可能表明 20 已不再是拟合数据的良好数字,或者 20 个聚类的组成随时间显著变化。我们还可以检查 k-means 聚类随时间变化的平方和误差等值,以查看获得的聚类质量是否显著变化。

另一个对特定聚类算法无差别的质量指标是轮廓分析(Rousseeuw, Peter J. "Silhouettes: a graphical aid to the interpretation and validation of cluster analysis." Journal of computational and applied mathematics 20 (1987): 53-65)。对于集合中的每个数据点i,我们询问它与其他簇中点的平均差异(由聚类算法中使用的距离度量判断),给出一个值d(i)。如果点i被适当地分配,那么d(i)接近 0,因为i与其簇中其他点的平均差异低。我们还可以为i计算其他簇的相同平均差异值,第二个最低值(i 的第二好簇分配)由d'(i)给出。然后我们使用公式获得一个介于-1 和 1 之间的轮廓分数:

无监督模型性能的变化

如果一个数据点很好地分配到其簇中,那么它在平均上与其他簇的相似度要低得多。因此,d'(i)(i 的“第二好簇”)大于d(i),并且轮廓分数公式中的比率接近 1。相反,如果点分配到其簇中的效果不佳,那么d'(i)的值可能小于d(i),在轮廓分数公式的分子中给出负值。接近零的值表明该点可以合理地分配到两个簇中。通过查看数据集上轮廓分数的分布,我们可以了解点随时间聚类的效果。

最后,我们可能使用一种自举方法,即多次重新运行聚类算法,并询问两个点有多少次最终落在同一个簇中。这些簇共现的分布(介于 0 和 1 之间)也可以给出关于分配随时间稳定性的感觉。

与聚类模型一样,降维技术也不容易找到一个金标准来衡量模型质量随时间的变化。然而,我们可以取数据集的主成分向量等值,并检查它们的成对差异(例如,使用第三章中描述的余弦分数 Chapter 3,在噪声中寻找模式 – 聚类和无监督学习),以确定它们是否发生了显著变化。在矩阵分解技术的情况下,我们还可以查看原始矩阵与分解元素乘积(例如,非负矩阵分解中的WH矩阵)之间的重建误差(例如,所有矩阵元素的平均平方差)。

通过 A/B 测试迭代模型

在上述示例以及本书的前几章中,我们主要从预测能力方面分析了分析系统。然而,这些指标并不一定最终能衡量出对商业有意义的各种结果,例如收入和用户参与度。在某些情况下,这种不足可以通过将模型的性能统计数据转换为更易于商业应用理解的单位来克服。例如,在我们的先前的流失率模型中,我们可能会将我们对“取消”或“未取消”的预测乘以,以生成通过订阅者取消而损失的预测金额。

在其他情况下,我们基本上无法使用历史数据来衡量业务结果。例如,在尝试优化搜索模型时,我们可以衡量用户是否点击了推荐,以及他们点击后是否购买了任何东西。通过这种回顾性分析,我们只能优化用户在网页上实际看到的推荐顺序。然而,可能的情况是,通过更好的搜索模型,我们会向用户展示一组完全不同的推荐,这将导致更高的点击率和收入。然而,我们无法量化这种假设情景,这意味着我们需要在改进算法时采用替代方法来评估算法。

做这件事的一种方法是通过实验过程,或 A/B 测试,这个名字来源于比较随机分配到治疗 A 和 B 的测试对象(例如,客户)的结果的概念,以确定哪种方法产生最佳结果。在实践中,可能存在许多超过两种治疗方法的情况,实验可以在用户、会话(例如,在网站上的登录和登出之间的时间段)、产品或其他单位上进行随机化。虽然对 A/B 测试的全面讨论超出了本章的范围,但我们建议感兴趣的读者参考更广泛的参考资料(Bailey, Rosemary A. 比较实验设计. 第 25 卷. 剑桥大学出版社,2008 年;Eisenberg, Bryan 和 John Quarto-vonTivadar. 始终在测试:Google 网站优化器的完整指南. 约翰·威利父子出版社,2009 年;Finger, Lutz 和 Soumitra Dutta. 询问、衡量、学习:使用社交媒体分析来理解和影响客户行为. "O'Reilly 媒体公司",2014 年)。

实验分配 - 将客户分配到实验中

你有一个希望改进的算法——如何比较其性能在提高一个指标(如收入、留存、参与度)方面与现有模型(或根本不使用预测模型)相比?在这个比较中,我们想要确保除了两个(或更多)模型本身之外,移除所有可能混淆的因素。这个想法是实验随机化的概念:如果我们随机将客户(例如)分配给接收来自两个不同模型的搜索推荐,客户人口统计学的任何变化,如年龄、收入和订阅期限,应该在两组之间大致相同。因此,当我们比较根据这种随机分配在时间上两组之间模型的性能时,算法性能的差异可以归因于模型本身,因为我们已经通过这种随机化考虑了其他潜在变化来源。

我们如何保证用户被随机分配到实验组?一种可能性是为每个成员分配一个介于 0 和 1 之间的随机数,并根据这个数是否大于 0.5 来划分他们。然而,这种方法可能存在缺点,即由于分配给用户的随机数可能会改变,因此很难复制我们的分析。另一种可能性是,我们通常会有用户 ID,这是分配给特定账户的随机数。假设这个数的格式足够随机,我们可以取这个数的模(除以一个固定的除数,如 2)的余数,并根据模数(例如,如果除数是 2,这将基于账户 ID 是偶数还是奇数来决定是 0 还是 1)将用户分配到两组。因此,用户被随机分配到两组,但我们可以轻松地在未来重新创建这种分配。

我们还可能考虑是否总是想要一个简单的随机分层。在之前讨论的广告定位示例中,我们实际上更关心算法在非订阅者上的性能,而不是在我们的例子中可能构成随机分配大部分的现有用户。因此,根据我们的目标,我们可能需要考虑随机分配分层样本,其中我们对某些账户进行过采样,以补偿数据中固有的偏差。例如,我们可能会强制执行每个国家账户数量大致相等,以抵消对人口较多的地区的地理偏差,或者为以年轻用户为主的服务提供相同数量的青少年和成年用户。

除了根据特定算法随机分配用户以接收某种体验(如搜索推荐或通过电子邮件发送的广告)之外,我们通常还需要一个控制组,以便将其结果进行比较。在某些情况下,控制组可能是没有使用任何预测模型时预期的结果。在其他情况下,我们是在比较旧的预测模型与新的版本。

决定样本大小

现在我们已经知道了我们试图测试的内容,并且有了一种随机分配用户的方法,那么我们应该如何确定分配给实验的人数呢?如果我们有一个对照组和几个实验条件,我们应该将多少用户分配给每个组?如果我们的预测模型依赖于用户交互(例如,评估搜索模型的性能需要用户访问网站)而这种情况可能并不保证在实验人群中的每个成员都会发生,我们需要积累多少活动(例如,搜索)来判断实验的成功?这些问题都涉及到对效应大小和实验功效的估计。

如您可能从统计学中回忆起来,在控制实验中,我们试图确定两个群体(例如,我们实验评估不同广告定位预测算法时用户群体产生的收入)之间的结果差异更有可能是由于随机变化还是算法性能的实际差异。这两个选项也被称为零假设,通常用 H0 表示(即,两组之间没有差异)和用 H1 表示的备择假设。为了确定一个效应(例如,两组之间的收入差异)是否由随机机会解释,我们将这个效应与一个分布(以下我们将讨论的原因)进行比较,并询问如果真实效应是 0,观察这个效应或更大的可能性是什么。这个值——在无效应假设下,观察到的效应大于或等于给定效应的累积概率——被称为 p 值,我们通常将其应用于一个阈值,例如 0.05(在下面的例子中,这由标准正态分布左侧的阴影区域表示)。

决定样本大小

当我们评估这种统计显著性时,我们可能会遇到两种类型的错误,因为任何效应的测量都受到不确定性的影响。我们永远不知道效应的真正值,而是用一些误差来测量这个真实、未知的效果。首先,我们可能会错误地宣布一个结果具有统计学意义,而实际上它并没有。这被称为第一类错误(假阳性)。其次,我们可能会未能宣布一个结果具有统计学意义,而实际上它确实具有(也称为第二类错误,或假阴性)。

我们可以提出问题,如果我们两个群体之间确实存在差异(例如,收入差异),我们需要多少样本才能宣布这种特定效果(例如,收入差异)具有显著性。虽然具体应用可能有所不同,但为了说明,我们将假设两组足够大,并且测量平均值的任何差异(例如收入或点击率)都遵循正态分布,这是由于大数定律。然后,我们可以使用 t 分布来评估这种差异,它近似于大样本的标准正态分布,但不需要我们知道总体均值和方差,只需知道样本的均值和方差。然后,计算所需样本数量只需要使用以下公式(对于方差可能不等的双样本 t 检验,也称为 Welch 的 t 检验):

决定样本大小

在这里,Y 是每个组的平均效果(例如,每位客户的收入),而 S(标准差)由以下方程给出:

决定样本大小

在这里,S[1]S[2] 是两组的样本方差,而 n[1]n[2] 是两组的样本大小。因此,如果我们想能够检测到 10 的差异,例如,以 0.05 的 p 值,我们求解在零假设下 t 统计量导致 5%的假阳性(我们使用 1.64 的正常近似值,这是标准正态分布累积分布函数值为 0.05 的值)。我们可以求解:

决定样本大小

因此,给定实验中各组方差的值,我们可以为两组输入不同的 n 值,并查看它们是否足够满足不等式。为此应用,我们可能通过查看给定样本大小的用户历史收入数据来估计方差。

如果你仔细观察前面方程的右侧,你会看到(假设样本方差合理相似,这在许多大规模实验中并不算不合理,例如在消费者网站上进行的实验),这个值将由 n[1]n[2] 中的较小者决定,因为当我们增加一个样本大小时,包含它的项趋向于 0。因此,我们通常通过将两组的样本量分配得相等来实现最佳功效。这个事实在考虑如何决定我们的控制组和实验组的相对大小时非常重要。以一个例子来说明,我们有三版广告定位算法,以及没有任何算法作为控制,并测量产生的点击率。根据前面的计算,我们需要决定我们的主要问题是什么。如果我们想知道是否有任何算法比没有算法更好,我们应该在控制和任何三个算法变体之间平均分配用户。然而,如果我们想决定哪个算法与控制相比最好,我们希望所有四个单元格中的用户数量相等,这样控制和每个处理都是大约相等的大小。

注意,前面的计算假设我们感兴趣的是两组之间固定的 10 个单位的响应差异。我们也可以简单地询问是否存在任何差异(例如,差异不是零)。这个选择取决于算法所代表的任何提升是否具有价值,或者是否需要固定的改进来实现当前的业务目标。

多重假设检验

我们将要讨论的最后一个话题有些微妙,但很重要,因为当模型具有众多可调参数和算法变体时,我们可能在单个 A/B 实验中执行大量假设测试。虽然我们可能以 0.05 的显著性水平评估每个测试,但如果我们执行 20 次这样的评估,我们找到一些显著结果的可能性为 200.05 = 1(或几乎肯定),即使它实际上是随机噪声。这个问题被称为多重假设检验*,它要求我们可能需要重新调整我们的显著性阈值。这样做最简单的方法是将我们使用的 p 值阈值(例如,0.05)除以执行的测试次数(20),以获得新的显著性阈值。这被称为 Bonferroni 校正(Dunn, Olive Jean. "Estimation of the medians for dependent variables." The Annals of Mathematical Statistics (1959): 192-197; Dunnett, Charles W. "A multiple comparison procedure for comparing several treatments with a control." Journal of the American Statistical Association 50.272 (1955): 1096-1121),虽然正确,但在某些情况下可能过于保守。它假设我们希望类型 I(假阳性)率为零。然而,在探索性分析中,只要我们合理确信大多数显著结果是可以复制的,我们通常可以接受一些非零的假阳性率。在这种情况下,一种全家族错误率FWER)的方法可能更可取。虽然 FWER 的讨论超出了本章的范围,但我们建议感兴趣的读者参考该主题的参考文献(Shaffer, Juliet Popper. "Multiple hypothesis testing." Annual review of psychology 46 (1995): 561; Toothaker, Larry E. Multiple comparison procedures. No. 89. Sage, 1993)。

沟通指南

现在我们已经涵盖了预测模型的调试、监控和迭代测试,我们将以一些关于如何将算法结果传达给更广泛受众的注意事项作为结尾。

将术语翻译成商业价值

在本文中,我们经常讨论评估统计量或系数,其解释可能并不明显,也不明显这些值的数值变化差异。系数较大或较小意味着什么?AUC 在预测客户互动方面意味着什么?在任何这些情况下,将基础值转换为业务指标以向非技术同事解释其重要性都是有用的:例如,线性模型中的系数表示特定输入变量变化 1 个单位时结果(如收入)的单位变化。对于转换变量,将诸如对数几率(来自逻辑回归)之类的值与诸如事件概率加倍之类的值相关联可能是有用的。此外,如前所述,我们可能需要将预测的结果(如取消)转换为财务金额,以使其含义清晰。此类转换不仅有助于传达预测算法的影响,而且有助于在规划中明确优先事项。如果一个算法的开发时间(其成本可能由涉及员工的工资来估算)不能抵消其性能的估计收益,那么这表明从业务角度来看,这不是一个有用的应用。

可视化结果

虽然我们讨论的并非所有算法都适合可视化,但许多算法都有可能通过绘图来清晰展示其元素。例如,可以通过条形图比较回归系数,而树模型可以通过导致特定结果的分支决策点进行视觉表示。此类图形有助于将本质上数学的对象转化为更易于理解的结果,同时也提供了对模型性能的持续洞察,如前所述。

作为构建此类服务的实际示例,本章的案例研究将介绍如何生成一个自定义仪表板,作为我们在第八章中构建的预测服务的扩展,即通过预测服务共享模型

案例研究:构建一个报告服务

在第八章中,我们创建了一个使用 MongoDB 作为后端数据库来存储模型数据和预测的预测服务。我们可以使用这个相同的数据库作为创建报告服务的源。就像我们在第八章中描述的 CherryPy 服务器和建模服务应用程序之间的关注点分离一样,报告服务可以编写而不需要了解数据库中信息的生成方式,这使得在建模代码可能随时间变化的情况下,可以生成灵活的报告基础设施。就像预测服务一样,我们的报告服务有几个关键组件。

  • 将接收报告服务输出请求的服务器。

  • 服务器运行的报告应用程序,它接收来自服务器的请求并将它们路由以显示正确的数据。

  • 从中检索用于制作图表所需信息的数据库。

  • 为最终用户渲染我们感兴趣的图表的图表系统。

让我们逐个分析每个组件的示例,这将说明它们是如何相互配合的。

报告服务器

我们的服务器代码与我们在第八章中使用的CherryPy服务器非常相似,通过预测服务共享模型

注意

这个例子受到了github.com/adilmoujahid/DonorsChoose_Visualization上可用的代码的启发。

唯一的区别是,我们不是启动modelservice应用程序,而是使用服务器来启动reportservice,正如你在main方法中看到的那样:

>>> if __name__ == "__main__":
…      service = reportservice()
…    run_server(service)

我们可以通过在命令行上简单地运行以下命令来测试这个服务器:

python report_server.py

你应该看到服务器开始将信息记录到控制台,就像我们之前观察到的modelserver一样。

报告应用程序

在应用程序代码中,它也是一个像我们在第八章中构建的模型服务一样的 Flask 应用程序,通过预测服务共享模型,我们需要一些之前没有使用过的额外信息。首先是路径变量,用于指定我们构建图表时所需的 JavaScript 和 CSS 文件的位置,这些文件使用以下命令指定:

>>> static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates/assets')

我们还需要指定包含我们图表的 HTML 页面的位置,这些图表将渲染给用户,使用以下参数:

>>> tmpl_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates')

当我们初始化应用程序时,我们将这两个变量作为变量传递给构造函数:

>>> app = Flask(__name__,template_folder=tmpl_dir,static_folder=static_dir)

当服务器调用此应用程序时,我们只需在reportservice函数中返回app即可:

>>> def reportservice():
…    return app

现在,我们只需要指定服务器转发的请求的应用程序响应。首先是简单地渲染一个包含我们图表的页面:

>>> @app.route("/")
…   def index():
…     return render_template("layouts/hero-thirds/index.html")

本例中的模板来自 github.com/keen/dashboards,这是一个开源项目,提供了用于快速生成仪表板的可重用模板。

第二个路由将允许我们检索用于填充图表的数据。这不应该暴露给最终用户(尽管如果您在浏览器中导航到这个端点,您会看到我们集合中所有 JSON 的文本输出):它是由客户端 JavaScript 代码用来检索填充图表所需信息的。首先,我们需要在另一个终端窗口中使用以下命令启动 mongodb 应用程序:

> mongod

接下来,在我们的代码中,我们需要指定用于访问数据的 MongoDB 参数。虽然我们可以将这些参数作为 URL 中的参数传递,但为了简化本例,我们将在 reportservice 代码的顶部直接硬编码它们,以便指向我们在第八章 分享预测服务中的模型 中训练 Spark 逻辑回归模型所使用的银行数据集的批量评分结果:

>>> FIELDS = {'score': True, \
…          'value': True, \
…          '_id': False}
… MONGODB_HOST = 'localhost'
… MONGODB_PORT = 27017
… DBS_NAME = 'datasets'
… COLLECTION_NAME = 'bankResults'

注意,我们同样可以通过更改 MONGODB_HOST 参数,将数据源指向远程服务器,而不是我们机器上运行的本地数据源。回想一下,当我们存储批量评分的结果时,我们保存了包含两个元素的记录,即评分和原始数据行。为了绘制我们的结果,我们需要提取原始数据行,并使用以下代码将其与评分一起展示:

>>> @app.route("/report_dashboard")
…  def run_report():
…    connection = MongoClient(MONGODB_HOST, MONGODB_PORT)
…    collection = connection[DBS_NAME][COLLECTION_NAME]
…    data = collection.find(projection=FIELDS)
…    records = []
…    for record in data:
…        tmp_record = {}
…        tmp_record = record['value']
…        tmp_record['score'] = record['score']
…        records.append(tmp_record)
…    records = json.dumps(records, default=json_util.default)
…    connection.close()

现在我们已经将所有评分记录存储在一个单独的 json 字符串数组中,我们可以使用一点 JavaScript 和 HTML 来绘制它们。

可视化层

我们还需要的是用于填充图表的客户端 JavaScript 代码,以及一些修改 index.html 文件以使用图表代码的修改。让我们依次查看这些内容。

生成图表的代码是一个包含在 report.js 文件中的 JavaScript 函数,您可以在项目目录下的 templates/assets/js 中找到它,对应于第九章 报告和测试 - 在分析系统中迭代。我们从这个函数开始,调用所需的数据,并使用异步函数 d3.queue() 等待其检索:

>>> d3_queue.queue() 
… .defer(d3.json, "/report_dashboard")
… .await(runReport);

注意,这个 URL 与我们在报告应用程序中之前指定的相同端点,用于从 MongoDB 中检索数据。d3_queue 函数调用此端点,并在运行 runReport 函数之前等待数据返回。虽然更深入的讨论超出了本文的范围,但 d3_queued3 库的一个成员 (d3js.org/),这是一个流行的 JavaScript 语言可视化框架。

一旦我们从数据库中检索到数据,我们需要指定如何使用runReport函数来绘制它。首先,我们将声明与函数相关的数据:

>>> function runReport(error, recordsJson) { 
…  var reportData = recordsJson; 
…  var cf = crossfilter(reportData);

虽然直到我们视觉上检查生成的图表时才会明显,但crossfilter库([square.github.io/crossfilter/](http://square.github.io/crossfilter/))允许我们在一个图表中突出显示数据的一个子集,并同时突出显示另一个图表中对应的数据,即使绘制的维度不同。例如,想象一下,我们有一个系统中特定account_ids的年龄直方图,以及一个特定广告活动的点击率与account_id的散点图。Crossfilter函数将允许我们使用我们的光标选择散点图的点的一个子集,同时过滤直方图,只显示与所选点对应的年龄。这种过滤对于深入特定数据子段非常有用。接下来,我们将生成我们在绘图时将使用的维度:

>>>  var ageDim = cf.dimension(function(d) { return d["age"]; });
…  var jobDim = cf.dimension(function(d) { return d["job"]; });
…  var maritalDim = cf.dimension(function(d) { return d["marital"]; });

这些函数中的每一个都接受输入数据并返回请求的数据字段。维度包含列中的所有数据点,并形成我们将用于检查数据子集的超集。使用这些维度,我们构建了唯一的值组,我们可以使用,例如,在绘制直方图时:

>>>  var ageDimGroup = ageDim.group();
…  var jobDimGroup = jobDim.group();
…  var maritalDimGroup = maritalDim.group();

对于我们的一些维度,我们想要添加表示最大值或最小值的值,我们在绘制数值数据的范围时使用这些值:

>>> var minAge = ageDim.bottom(1)[0]["age"];
… var maxAge = ageDim.top(1)[0]["age"];
… var minBalance = balanceDim.bottom(1)[0]["balance"];
… var maxBalance = balanceDim.top(1)[0]["balance"];

最后,我们可以使用dc([dc-js.github.io/dc.js/](https://dc-js.github.io/dc.js/)),一个使用d3和 crossfilter 创建交互式可视化的图表库,来指定我们的图表对象。每个图表构造函数给出的#标签指定了我们在将其插入 HTML 模板时将使用的 ID。我们使用以下代码构建图表:

>>>  var ageChart = dc.barChart("#age-chart"); 
…  var jobChart = dc.rowChart("#job-chart"); 
…  var maritalChart = dc.rowChart("#marital-chart"); 
…

最后,我们指定这些图表的维度和轴:

>>>  ageChart
…  .width(750) 
…  .height(210) 
…  .dimension(ageDim) 
…  .group(ageDimGroup) 
…  .x(d3_scale.scaleLinear()
…  .domain([minAge, maxAge]))
…  .xAxis().ticks(4); 

>>>  jobChart 
…  .width(375) 
…  .height(210) 
…  .dimension(jobDim) 
…  .group(jobDimGroup) 
…  .xAxis().ticks(4);

我们只需要一个渲染调用来显示结果:

>>>  dc.renderAll();

最后,我们需要修改我们的index.html文件以显示我们的图表。如果你在这个文件中打开一个文本编辑器,你会注意到几个地方有<div>标签,例如:

>>>  <div class="chart-stage">
…
…       </div>

这是我们需要使用以下 ID 放置图表的位置,这些 ID 我们在前面的 JavaScript 代码中已经指定了:

>>>  <div id="age-chart">
…         </div>

最后,为了渲染图表,我们需要在 HTML 文档底部的<script>标签中包含我们的javascript代码:

>>> <script type="text/javascript" … src="img/report.js"></script>

现在,你应该能够导航到CherryPy服务器指向的 URL,即localhost:5000,现在应该显示如下图表:

可视化层

在给定年龄范围内的用户子集的其他维度上突出显示的 Crossfilter 图表。

数据来源于我们在第八章中使用的训练模型服务的银行违约示例,与预测服务共享模型。您可以看到,通过选择年龄分布中的数据点子集,我们突出了这些相同用户的职业、银行余额和教育分布。这种可视化对于深入诊断问题点(例如,如果数据点的子集被模型错误分类)非常有用。使用这些基本成分,您现在不仅可以使用第八章中提到的预测服务进行模型训练,还可以通过报告层来可视化其行为。

摘要

在本章中,我们学习了在初始设计之后监控预测模型性能的几种策略,并观察了模型性能或组件随时间变化的多种场景。作为模型优化过程的一部分,我们探讨了 A/B 测试策略,并说明了如何执行基本的随机分配以及估计测量改进所需的样本量。我们还展示了如何利用我们的预测服务基础设施来创建用于监控的仪表板可视化,这可以轻松扩展到其他用例。

posted @ 2025-10-26 09:01  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报