R-数据科学实践指南第二版-全-

R 数据科学实践指南第二版(全)

原文:Practical Data Science with R, Second Edition

译者:飞龙

协议:CC BY-NC-SA 4.0

前言部分

前言

《用 R 进行实用数据科学》 第二版是一本关于数据科学的实战指南,重点关注使用 R 语言和统计软件包处理结构化或表格数据的技巧。本书强调机器学习,但在涉及数据科学家在项目中的作用、结果管理以及甚至设计演示文稿等主题上,它所分配的章节数量是独一无二的。除了解释如何编写模型代码外,本书还分享了如何与多元化的团队协作、如何将业务目标转化为指标,以及如何组织工作和报告。如果你想学习如何使用 R 作为数据科学家工作,这本书是必读的。

我们已经认识 Nina Zumel 和 John Mount 多年。我们曾邀请他们与我们一同在奇点大学授课。他们是我们所知的两位顶尖数据科学家。我们经常推荐他们关于交叉验证和影响编码(也称为目标编码)的原创研究。实际上,《用 R 进行实用数据科学》 第二版中的第八章介绍了影响编码的理论,并通过作者的 R 包:vtreat来应用这一理论。

《用 R 进行实用数据科学》 花时间描述了数据科学是什么,以及数据科学家如何解决问题并解释他们的工作。它包括对经典监督学习方法的仔细描述,如线性回归和逻辑回归。我们喜欢这本书的调查风格,并广泛使用了随机森林和xgboost等获奖方法论和软件包的实例。这本书充满了有用的共享经验和实用建议。我们注意到他们甚至包括了我们的技巧,即使用随机森林变量重要性进行初始变量筛选。

总体来说,这是一本优秀的书,我们强烈推荐。

—JEREMY HOWARD

AND RACHEL THOMAS

前言

这是我们自己在学习那些后来被称为数据科学的众多主题和技能时希望拥有的书。这是我们希望分发给我们的客户和同行的书。它的目的是解释统计学、计算机科学和机器学习中与数据科学至关重要的相关部分。

数据科学借鉴了实证科学、统计学、报告、分析、可视化、商业智能、专家系统、机器学习、数据库、数据仓库、数据挖掘和大数据等工具。正是因为我们有这么多工具,我们才需要一个涵盖所有这些工具的学科。数据科学本身与工具和技术区分开来的,是其核心目标是将有效的决策模型部署到生产环境中。

我们的目的是从实用、以实践为导向的角度来介绍数据科学。我们通过专注于真实数据上的完整练习来实现这一目标——总的来说,这本书处理了超过 10 个重要的数据集。我们相信这种方法使我们能够展示我们真正想要教授的内容,并展示在任何实际项目中必要的所有准备工作步骤。

在我们的文本中,我们讨论了有用的统计和机器学习概念,包括具体的代码示例,并探讨了与非专业人士合作和展示的方法。如果你可能觉得这些主题并不新颖,我们希望为你指出一两个你可能最近没有考虑过的其他主题。

致谢

我们还要感谢那些阅读并评论我们早期章节草稿的同事和其他人。特别感谢我们的审稿人:Charles C. Earl,Christopher Kardell,David Meza,Domingo Salazar,Doug Sparling,James Black,John MacKintosh,Owen Morris,Pascal Barbedo,Robert Samohyl 和 Taylor Dolezal。他们的评论、问题和更正大大提高了这本书的质量。我们特别想感谢我们的开发编辑 Dustin Archibald 和第一版工作的 Cynthia Kane,感谢他们的想法和支持。同样的感谢也适用于 Nichole Beard,Benjamin Berg,Rachael Herbert,Katie Tennant,Lori Weidert,Cheryl Weisman 以及所有努力使这本书成为一本优秀书籍的其他编辑。

此外,我们感谢我们的同事 David Steier,加州大学伯克利分校信息科学学院的 Douglas Tygar 教授,威斯康星大学怀特沃特分校生物科学和计算机科学系的 Robert K. Kuzoff 教授,以及所有使用这本书作为教学文本的其他教师和讲师。我们感谢 Jim Porzak,Joseph Rickert 和 Alan Miller 邀请我们参加 R 用户组会议,经常讨论我们在本书中涵盖的主题。我们特别感谢 Jim Porzak 为第一版撰写了序言,并成为我们书籍的热心倡导者。在我们疲惫和泄气,想知道为什么我们给自己设定了这个任务的时候,他的兴趣帮助我们提醒我们,我们所提供的东西以及我们提供的方式是有需求的。没有这种鼓励,完成这本书将会困难得多。此外,我们还想感谢 Jeremy Howard 和 Rachel Thomas 撰写新序言,邀请我们发言,并提供了他们的强力支持。

关于本书

这本书是关于数据科学的:一个使用统计学、机器学习和计算机科学的结果来创建预测模型的研究领域。由于数据科学的广泛性,讨论它并概述我们在本书中采取的方法是很重要的。

什么是数据科学?

统计学家威廉·S·克利夫兰将数据科学定义为比统计学本身更大的跨学科领域。我们定义数据科学为管理将假设和数据转化为可操作预测的过程。典型的预测分析目标包括预测谁将赢得选举、哪些产品会一起畅销、哪些贷款会违约以及哪些广告会被点击。数据科学家负责获取和管理数据、选择建模技术、编写代码以及验证结果。

由于数据科学借鉴了这么多学科,它通常是一个“第二职业”。我们遇到的大多数优秀数据科学家最初都是程序员、统计学家、商业智能分析师或科学家。通过增加一些额外的技术到他们的技能库中,他们成为了优秀的数据科学家。这个观察结果推动了这本书:我们通过具体地处理所有基于真实数据的常见项目步骤,介绍了数据科学家所需的实用技能。有些步骤你可能比我们更了解,有些你可能很快就能掌握,有些你可能需要进一步研究。

数据科学的大部分理论基础来自统计学。但我们所知道的数据科学受到技术和软件工程方法论的强烈影响,并在很大程度上是在计算机科学和信息技术驱动的群体中演化的。我们可以通过列出一些著名的例子来指出数据科学的一些工程特点:

  • 亚马逊的产品推荐系统

  • 谷歌的广告估值系统

  • 领英的联系人推荐系统

  • 推特的流行话题

  • 沃尔玛的消费需求预测系统

这些系统有很多共同点:

  • 所有这些系统都是基于大型数据集构建的。这并不是说它们都在大数据领域。但如果没有使用小型数据集,它们中的任何一个都无法成功。为了管理数据,这些系统需要来自计算机科学的概念:数据库理论、并行编程理论、流数据技术和数据仓库。

  • 大多数这些系统都是在线或实时运行的。而不是生成一个单一的报告或分析,数据科学团队部署一个决策程序或评分程序,直接做出决策或直接向大量最终用户展示结果。生产部署是最后的机会来确保一切正确,因为数据科学家并不总是能够随时解释缺陷。

  • 所有这些系统都允许以一些不可协商的速率犯错误

  • 这些系统都不关注原因。当它们找到有用的相关性并且不被要求正确区分原因和效果时,它们才会成功。

本书教授了构建此类系统所需的原则和工具。我们教授了成功交付此类项目所使用的常见任务、步骤和工具。我们的重点是整个过程——项目管理、与他人合作以及向非专业人士展示结果。

路线图

本书涵盖了以下内容:

  • 管理数据科学过程本身。数据科学家必须能够衡量和跟踪自己的项目。

  • 应用数据科学项目中使用的许多最强大的统计和机器学习技术。将本书视为一系列使用 R 编程语言进行实际数据科学工作的明确练习。

  • 为各种利益相关者(如管理层、用户、部署团队等)准备演示文稿:你必须能够用他们常用的词汇,而不是在某个特定领域坚持的技术定义,具体地解释你的工作。你不能仅仅通过将数据科学项目结果扔过篱笆来逃避责任。

我们按照我们认为能提高理解顺序安排了本书的主题。材料组织如下。

第一部分 描述了数据科学过程的基本目标和技巧,强调协作和数据。第一章 讨论了如何成为一名数据科学家。第二章 讲解了如何将数据加载到 R 中,并展示了如何开始使用 R。

第三章 讲解了在数据中首先应该寻找什么,以及描述和理解数据的重要步骤。数据必须为分析做好准备,并且需要纠正数据问题。第四章 展示了如何纠正第三章中确定的问题。

第五章 讲解了另一个数据准备步骤:基本数据整理。数据科学家并不总是以最适合分析的形式或“形状”获得数据。R 提供了许多工具来操纵和重塑数据,使其适合适当的结构;这些工具在本章中进行了介绍。

第二部分 从描述和准备数据过渡到构建有效的预测模型。第六章 提供了将业务需求映射到技术评估和建模技术的映射。它涵盖了评估模型性能的标准指标和程序,以及一种专门技术 LIME,用于解释模型做出的特定预测。

第七章 介绍了基本的线性模型:线性回归、逻辑回归和正则化线性模型。线性模型是许多分析任务的工作马,特别是在识别关键变量和洞察问题结构方面非常有帮助。对这些模型有扎实的理解对数据科学家来说极其宝贵。

第八章暂时从建模任务转向更高级的数据处理:如何为建模步骤准备混乱的现实世界数据。由于理解这些数据处理方法的工作原理需要一些对线性模型和模型评估指标的了解,因此最好将此主题推迟到第二部分。

第九章涵盖了无监督方法:不使用标记训练数据建模的方法。第十章涵盖了更高级的建模方法,这些方法可以提高预测性能并解决特定的建模问题。涵盖的主题包括基于树的集成、广义加性模型和支持向量机。

第三部分从建模转向过程。我们展示了如何交付结果。第十一章演示了如何管理、记录和部署你的模型。你将在第十二章中学习如何为不同的受众创建有效的演示。

附录中包含了关于 R、统计学以及更多可用工具的附加技术细节。附录 A 展示了如何安装 R、开始工作以及与其他工具(如 SQL)一起使用。附录 B 是关于几个关键统计学概念的复习。

材料根据目标和任务组织,在需要时引入工具。每章中的主题都是在具有相关数据集的代表项目中讨论的。你将在本书的过程中完成多个重大项目。本书中提到的所有数据集都可以在本书的 GitHub 仓库github.com/WinVector/PDSwR2中找到。你可以下载整个仓库作为一个单一的 zip 文件(GitHub 的一项服务),将仓库克隆到你的机器上,或者根据需要复制单个文件。

读者对象

要运行本书中的示例,你需要对 R 和统计学有一定的了解。我们建议你手头有一些好的入门文本。在开始本书之前,你不需要成为 R 的专家,但你需要熟悉它。

要开始使用 R,我们推荐 Jonathan Carroll 的《超越电子表格的 R》(Manning, 20108)或 Robert Kabacoff 的《R 实战》(现在有第二版:www.manning.com/kabacoff2/),以及文本的关联网站Quick-R (www.statmethods.net)。对于统计学,我们推荐 David Freedman、Robert Pisani 和 Roger Purves 合著的《统计学》,第四版(W. W. Norton & Company, 2007)。

通常,我们期望我们的理想读者具备以下条件:

  • 对工作示例的兴趣。 通过解决示例,你将至少学会一种执行项目所有步骤的方法。你必须愿意尝试简单的脚本和编程,以获得这本书的全部价值。对于每个我们解决的示例,你应该尝试不同的变体,并预期会有一些失败(你的变体不起作用)和一些成功(你的变体优于我们的示例分析)。

  • 对 R 统计系统的一些了解,以及愿意用 R 编写简短的脚本和程序。 除了 Kabacoff,我们还列出了附录 C 中的一些好书。我们将在 R 中解决具体问题;你需要运行示例并阅读额外的文档,以了解我们没有展示的命令的变体。

  • 对基本统计概念(如概率、均值、标准差和显著性)的舒适度。 我们将在需要时介绍这些概念,但你可能需要阅读额外的参考资料,因为我们解决示例。我们将定义一些术语,并在适当的时候引用一些主题参考资料和博客。但我们期望你将不得不对某些主题进行一些自己的互联网搜索。

  • 一台计算机(macOS、Linux 或 Windows),用于安装 R 和其他工具,以及互联网访问权限,以便下载工具和数据集。 我们强烈建议你解决示例,检查各种方法的 R help(),并跟进一些额外的参考资料。

这本书没有包含什么?

  • 这本书不是一本 R 手册。 我们使用 R 来具体展示数据科学项目的重要步骤。我们教授足够的 R 知识,让你能够解决示例,但如果你不熟悉 R,你将需要参考附录 A 以及许多现有的优秀 R 书籍和教程。

  • 这本书不是一系列案例研究。 我们强调方法和技巧。示例数据和代码仅用于确保我们提供具体、可用的建议。

  • 这本书不是一本大数据书。 我们认为最有意义的数据科学发生在数据库或可管理的文件规模(通常大于内存,但仍足够小,易于管理)。将测量条件映射到依赖结果的宝贵数据往往生产成本高昂,这往往限制了其规模。对于某些报告生成、数据挖掘和自然语言处理,你将不得不进入大数据领域。

  • 这不是一本理论书。 我们不强调任何一种技术的绝对严格理论。数据科学的目标是灵活,有多个好技术可用,并且愿意深入研究似乎适用于手头问题的技术。即使在我们的文本中,我们也更喜欢 R 代码符号而不是漂亮的排版方程式,因为 R 代码可以直接使用。

  • 这不是一本机器学习修补匠的书。我们强调已经在 R 中实现的方法。对于每种方法,我们都会讲解其工作原理,并展示该方法的优势。我们通常不讨论如何实现它们(即使实现起来很简单),因为已经有优秀的 R 实现。

代码约定和下载

本书以示例驱动。我们在 GitHub 存储库(github.com/WinVector/PDSwR2)中提供了准备好的示例数据,包括 R 代码和指向原始来源的链接。您可以在网上探索这个存储库,或者将其克隆到您的机器上。我们还提供了生成书中所有结果和几乎所有图表的代码,作为 zip 文件(github.com/WinVector/PDSwR2/raw/master/CodeExamples.zip),因为从 zip 文件中复制代码可能比从书中复制和粘贴更容易。有关如何下载、安装以及开始使用所有建议的工具和示例数据的说明,请参阅附录 A 中的 A.1 节。

我们鼓励您在阅读文本时尝试示例 R 代码;即使我们在讨论数据科学的相当抽象方面,我们也会用具体的数据和代码来举例。每一章都包括指向它所引用的具体数据集的链接。

在这本书中,代码使用固定宽度字体如这样来区分它和普通文本。具体的变量和值以类似的方式格式化,而抽象的数学将以斜体字体如这样显示。R 代码编写时不包含任何命令行提示符,例如>(在显示 R 代码时经常看到,但不需要作为新 R 代码输入)。内联结果以 R 的注释字符#为前缀。在许多情况下,原始源代码已经被重新格式化;我们添加了换行并重新调整了缩进以适应书中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续续标记()。此外,当代码在文本中描述时,源代码中的注释通常已从列表中删除。代码注释伴随着许多列表,突出重要概念。

使用本书

《使用 R 进行实用数据科学》最好在至少尝试一些示例的同时阅读。为此,我们建议您安装 R、RStudio 以及书中常用的包。我们将在附录 A 的 A.1 节中分享如何进行这些操作。附录 A。我们还建议您从我们的 GitHub 存储库github.com/WinVector/PDSwR2下载所有示例,这些示例包括代码和数据。

下载本书的支持材料/存储库

您可以通过使用 GitHub 的“下载为 ZIP”功能将存储库的内容下载为 ZIP 文件,如图所示,从 GitHub 网址 github.com/WinVector/PDSwR2

fmxxiii_alt.jpg

点击“下载 ZIP”链接应该会下载软件包的压缩内容(或者您也可以尝试直接链接到 ZIP 文件:github.com/WinVector/PDSwR2/archive/master.zip)。或者,如果您熟悉从命令行使用 Git 版本控制系统,您可以从 Bash shell 中使用以下命令完成此操作(而不是从 R 中):

git clone https://github.com/WinVector/PDSwR2.git

在所有示例中,我们假设您已经克隆了存储库或下载并解压了内容。这将生成一个名为 PDSwR2 的目录。我们讨论的路径将从该目录开始。例如,如果我们提到使用 PDSwR2/UCICar,我们的意思是在您解压 PDSwR2 的任何子目录中工作。您可以通过 setwd() 命令更改 R 的工作目录(请在 R 控制台中输入 help(setwd) 以获取一些详细信息)。或者,如果您使用 RStudio,文件浏览窗格也可以通过窗格的齿轮/更多菜单上的选项设置工作目录。本书的所有代码示例都包含在 PDSwR2/CodeExamples 目录中,因此您不需要输入它们(尽管要运行它们,您必须在适当的数据目录中工作——而不是在您找到代码的目录中)。

本书中的示例是作为明确练习的替代品提供的。我们建议您通过示例进行练习,并尝试不同的变体。例如,在 第 2.3.1 节 中,我们展示了如何将预期收入与教育和性别联系起来,尝试将收入与就业状况或甚至年龄联系起来是有意义的。数据科学需要对于编程、函数、数据、变量和关系的好奇心,您越早发现数据中的惊喜,它们就越容易处理。

书籍论坛

购买《Practical Data Science with R》将包括免费访问由 Manning Publications 运营的私人网络论坛,您可以在论坛上对书籍发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问 forums.manning.com/forums/practical-data-science-with-r-second-edition。您还可以在 forums.manning.com/forums/about 了解更多关于 Manning 论坛和行为准则的信息。

Manning 对读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向他们提出一些挑战性的问题,以免他们的兴趣转移!只要这本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

关于作者

Nina Zumel 曾在独立的非营利性研究机构 SRI International 担任科学家,担任过价格优化公司的首席科学家,并创立了一家合同研究公司。Nina 现在是 Win-Vector LLC 的主要顾问。您可以通过 nzumel@win-vector.com 联系她。

John Mount 曾在生物技术领域担任计算科学家,并担任过股票交易算法设计师,还管理过 Shopping.com 的研究团队。他现在是 Win-Vector LLC 的主要顾问。您可以通过 jmount@win-vector.com 联系他。

关于前言作者

JEREMY HOWARD 是一位企业家、商业策略家、开发者和教育者。Jeremy 是 fast.ai 的创始研究员,fast.ai 是一个致力于使深度学习更易于获取的研究机构。他还是旧金山大学的教师,并在 doc.ai 和 platform.ai 担任首席科学家。

此前,Jeremy 曾是 Enlitic 的创始首席执行官,Enlitic 是第一家将深度学习应用于医疗的公司,连续两年被《麻省理工学院技术评论》评为世界上最具智慧的公司之一。他是数据科学平台 Kaggle 的总裁和首席科学家,在那里他连续两年在国际机器学习竞赛中排名第一。

RACHEL THOMAS 是 USF 应用数据伦理中心的主任,也是 fast.ai 的联合创始人,fast.ai 曾被《经济学人》、《麻省理工学院技术评论》和《福布斯》报道。她被《福布斯》评为 20 位令人难以置信的 AI 女性之一,在杜克大学获得了数学博士学位,并在 Uber 担任过早期工程师。Rachel 是一位受欢迎的作家和主题演讲者。在她的 TEDx 演讲中,她分享了关于 AI 使她感到害怕的原因,以及为什么我们需要来自所有背景的人参与 AI。

关于封面插图

《实用 R 数据科学》封面的图案被标注为“1703 年一位中国女士的习惯。”这幅插图取自托马斯·杰弗里斯的《不同国家古今服饰汇编》(四卷),伦敦出版,时间介于 1757 年至 1772 年之间。扉页声明这些是手工上色的铜版雕刻,并使用阿拉伯树胶进行了增强。托马斯·杰弗里斯(1719–1771)被称为“乔治三世国王的地理学家。”他是当时的英国制图家,也是他那个时代的主要地图供应商。他为政府和其它官方机构雕刻和印刷地图,并制作了广泛的商业地图和地图集,尤其是北美地区的地图。他作为制图家的工作激发了他对所调查和绘制的土地上的地方服饰习俗的兴趣;这些习俗在这四卷集中得到了精彩的展示。

对遥远土地的迷恋和为了娱乐而旅行在 18 世纪是相对较新的现象,像这样的收藏品很受欢迎,它们向游客和沙发旅行者介绍了其他国家的居民。杰弗里斯卷集中的绘图多样性生动地描绘了几个世纪前世界各国独特性和个性的世界。服饰规范已经改变,当时地区和国家之间的多样性如此丰富,现在已经消失。现在,往往很难区分一个大陆的居民与另一个大陆的居民。或许,从乐观的角度来看,我们用文化和视觉多样性换取了更加多样化的个人生活——或者更加多样化、有趣的知识和技术生活。

在难以区分一本计算机书籍与另一本的时候,曼宁通过基于三百年前丰富多样的民族服饰的封面设计,庆祝了计算机行业的创新精神和主动性,这些设计通过杰弗里斯的画作得以重现。

第一部分:数据科学简介

在第一部分中,我们专注于数据科学中最基本的工作:与您的合作伙伴合作,定义您的问题,以及检查您的数据。

第一章涵盖了典型数据科学项目的生命周期。我们探讨了项目团队成员的不同角色和职责,典型项目的不同阶段,以及如何定义目标和设定项目期望。本章作为本书其余部分内容的概述,其组织顺序与我们所呈现的主题顺序相同。

第二章深入探讨了从各种外部格式将数据加载到 R 中并将数据转换为适合分析格式的细节。它还讨论了数据科学家最重要的 R 数据结构:数据框。关于 R 编程语言的更多详细信息请参阅附录 A。

第三章和第四章涵盖了在进入建模阶段之前应该进行的数据探索和处理。在第三章中,我们讨论了一些您可能会遇到的数据典型问题和问题,以及如何使用汇总统计和可视化来检测这些问题。在第四章中,我们讨论了可以帮助您处理数据中问题和问题的数据处理方法。我们还推荐了一些习惯和程序,这些习惯和程序将帮助您在项目的不同阶段更好地管理数据。

第五章涵盖了如何整理或操纵数据以使其适合分析。

在完成第一部分后,您将了解如何定义一个数据科学项目,并且您将知道如何将数据加载到 R 中并为建模和分析做准备。

第一章. 数据科学流程

本章涵盖

  • 定义数据科学

  • 定义数据科学项目角色

  • 理解数据科学项目的阶段

  • 为新的数据科学项目设定期望

数据科学是一个跨学科实践,它借鉴了数据工程、描述性统计、数据挖掘、机器学习和预测分析的方法。与运筹学类似,数据科学侧重于实施数据驱动决策并管理其后果。对于本书,我们将专注于将数据科学应用于商业和科学问题,并使用这些技术。

数据科学家负责从开始到结束引导数据科学项目。数据科学项目的成功不在于对任何一种异国工具的访问,而在于有可衡量的目标、良好的方法论、跨学科互动和可重复的工作流程。

本章将向您展示一个典型的数据科学项目是什么样的:您会遇到的问题类型、应该设定的目标类型、您可能要处理的任务,以及预期的结果类型。

我们将使用一个具体、真实世界的例子来激发本章的讨论。^[[1])

¹

对于本章,我们将使用汉斯·霍夫曼博士(Dr. Hans Hofmann)于 1994 年捐赠给 UCI 机器学习仓库的信用数据集。为了清晰起见,我们对一些列名进行了简化。原始数据集可以在archive.ics.uci.edu/ml/datasets/Statlog+(German+Credit+Data)找到。我们将在第二章中展示如何加载数据并为其分析做准备。请注意,数据收集时的德国货币是德国马克(DM)。


示例

假设您在一家德国银行工作。银行觉得它在坏账上的损失太大,希望减少损失。为了做到这一点,他们希望有一个工具帮助贷款官员更准确地检测风险贷款。


这就是您的数据科学团队发挥作用的地方。

1.1. 数据科学项目中的角色

数据科学不是在真空中进行的。它是一个协作努力,它借鉴了多个角色、技能和工具。在我们谈论过程本身之前,让我们看看在成功项目中必须填补的角色。项目管理长期以来一直是软件工程的核心关注点,因此我们可以从中寻求指导。在定义这里的角色时,我们借鉴了弗雷德里克·布鲁克斯(Fredrick Brooks)在《人月神话:软件工程论文》(Addison-Wesley,1995)中描述的“外科团队”对软件开发的观点。我们还借鉴了敏捷软件开发范例的思想。

1.1.1. 项目角色

让我们看看数据科学项目中的一些常见角色在表 1.1 中。

表 1.1. 数据科学项目角色和职责

角色 职责
项目赞助人 代表商业利益;倡导项目
客户 代表最终用户利益;领域专家
数据科学家 制定和执行分析策略;与赞助人和客户沟通
数据架构师 管理数据和数据存储;有时管理数据收集
操作 管理基础设施;部署最终项目成果

有时这些角色可能会重叠。一些角色——特别是客户、数据架构师和操作——通常由不在数据科学项目团队中的人担任,但他们却是关键的合作者。

项目赞助人

在数据科学项目中,最重要的角色是项目赞助人。 赞助人是想要数据科学结果的人;通常,他们代表商业利益。在贷款申请示例中,赞助人可能是银行消费贷款部门的负责人。赞助人负责决定项目是成功还是失败。如果数据科学家认为自己了解并能代表商业需求,他们可以为自己的项目填补赞助人角色,但这不是最佳安排。理想的赞助人应满足以下条件:如果他们对项目结果满意,那么项目在定义上就是成功的。获得赞助人的批准成为数据科学项目的核心组织目标。


保持赞助人的知情和参与

保持赞助人的知情和参与至关重要。向他们展示计划、进度以及他们能够理解的中间成功或失败。让赞助人蒙在鼓里是保证项目失败的好方法。


为了确保获得赞助人的批准,你必须通过定向访谈从他们那里获得明确的目标。你试图将赞助人表达的目标作为定量陈述来捕捉。一个目标示例可能是“在第一次逾期付款前至少两个月识别出 90%将违约的账户,错误率为不超过 25%。”这是一个精确的目标,它允许你并行检查实现目标是否真的对业务有意义,以及你是否拥有足够质量和数量的数据和工具来实现目标。

客户

虽然赞助人代表的是商业利益,但客户代表的是模型最终用户的利益。有时,赞助人和客户角色可能由同一个人担任。再次强调,如果数据科学家能够权衡商业权衡,他们可以填补客户角色,但这并不是最佳选择。

客户比赞助商更注重实际操作;他们是构建良好模型的技术细节与模型将被部署的日常工作流程之间的接口。他们不一定在数学或统计学方面有专业知识,但熟悉相关的业务流程,并在团队中作为领域专家发挥作用。在贷款申请示例中,客户可能是贷款官员或代表贷款官员利益的人。

与赞助商一样,您应该让客户保持知情并参与其中。理想情况下,您希望定期与他们开会,以确保您的努力与最终用户的需求保持一致。通常,客户属于组织中的不同团队,并且除了您的项目之外还有其他职责。保持会议聚焦,用他们能理解的方式展示结果和进度,并认真对待他们的批评。如果最终用户无法或不愿意使用您的模型,那么从长远来看,项目就不算成功。

数据科学家

数据科学项目中的下一个角色是数据科学家,他们负责采取所有必要的步骤以确保项目成功,包括制定项目策略并让客户保持知情。他们设计项目步骤,选择数据来源,并选择要使用的工具。由于他们选择要尝试的技术,他们必须对统计学和机器学习有深入了解。他们还负责项目规划和跟踪,尽管他们可能需要与项目管理合作伙伴一起完成。

在更技术层面上,数据科学家还会查看数据,执行统计测试和程序,应用机器学习模型,并评估结果——这是数据科学中的科学部分。


领域同理心

通常要求数据科学家成为领域专家是过于苛刻的。然而,在所有情况下,数据科学家必须培养强烈的领域同理心,以帮助定义和解决正确的问题。


数据架构师

数据架构师负责所有数据和其存储。通常这个角色由数据科学团队之外的人担任,例如数据库管理员或架构师。数据架构师通常管理多个不同项目的数据仓库,并且他们可能只能提供快速咨询。

操作

运营角色在获取数据和交付最终结果方面至关重要。担任此角色的人通常在数据科学团队之外有运营责任。例如,如果你正在部署一个影响在线购物网站上产品排序的数据科学结果,那么负责运营该网站的人将对如何部署此类事物有大量意见。这个人可能对响应时间、编程语言或数据大小有约束,你需要在部署时尊重这些约束。在运营角色中的人可能已经支持你的赞助者或客户,因此他们通常很容易找到(尽管他们的时间可能已经非常紧张)。

1.2. 数据科学项目的阶段

理想的数据科学环境是鼓励数据科学家与所有其他利益相关者之间进行反馈和迭代的。这在数据科学项目的生命周期中得到了体现。尽管这本书,就像其他关于数据科学过程讨论一样,将周期分解为不同的阶段,但在现实中,阶段之间的界限是流动的,一个阶段的活动经常会与其他阶段的活动重叠。通常,在向前推进整体过程之前,你会在两个或更多阶段之间来回循环。这可以在图 1.1 中看到。

²

机器学习过程的一个常见模型是跨行业数据挖掘标准流程(CRISP-DM)(en.wikipedia.org/wiki/Cross-industry_standard_process_for_data_mining)。我们在这里讨论的模型与此类似,但强调在过程的任何阶段都可以进行来回迭代。

图 1.1. 数据科学项目生命周期:循环中的循环

即使你完成了一个项目并部署了一个模型,从看到该模型在实际应用中可能出现新的问题和疑问。一个项目的结束可能引出后续的项目。

让我们来看看图 1.1 中展示的不同阶段。

1.2.1. 定义目标

数据科学项目的第一个任务是定义一个可衡量和可量化的目标。在这个阶段,尽可能多地了解你项目背景:

  • 为什么赞助者一开始就想要这个项目?他们缺少什么,需要什么?

  • 他们现在正在做什么来解决问题,为什么这还不够好?

  • 你需要哪些资源:什么类型的数据和多少人员?你将拥有领域专家进行合作,以及计算资源是什么?

  • 项目赞助者计划如何部署你的结果?为了成功部署,必须满足哪些约束条件?

让我们回到我们的贷款申请示例。最终的商业目标是减少银行因不良贷款而遭受的损失。你的项目赞助商设想了一个工具,可以帮助贷款官员更准确地评估贷款申请人,从而减少不良贷款的数量。同时,重要的是贷款官员感到他们在贷款审批上拥有最终决定权。

一旦你和项目赞助商以及其他利益相关者对这些问题的初步答案达成一致,你们就可以开始定义项目的具体目标。目标应该是具体和可衡量的;不是“我们想更好地找到不良贷款”,而是“我们想通过一个预测哪些贷款申请人可能违约的模型,至少将贷款坏账率降低 10%。”

一个具体的目标会导致具体的中止条件和具体的标准。目标越不具体,项目越有可能无限制地进行,因为没有结果会“足够好”。如果你不知道你想要实现什么,你就不知道何时停止尝试——甚至不知道尝试什么。当项目最终结束时——因为时间或资源耗尽——没有人会对结果感到满意。

当然,有时需要更宽松、更具探索性的项目:“数据中是否有与更高违约率相关的东西?”或者“我们应该考虑减少我们发放的贷款类型吗?哪些类型可能会被取消?”在这种情况下,你仍然可以用具体的中止条件来界定项目,例如时间限制。例如,你可能会决定花两周时间,不再花更多时间探索数据,目标是提出候选假设。然后,这些假设可以被转化为全面建模项目的具体问题或目标。

一旦你对项目目标有了很好的了解,你就可以专注于收集数据以满足这些目标。

1.2.2. 数据收集与管理

此步骤包括确定所需数据、探索数据以及调整数据以便进行分析。这一阶段通常是整个过程中耗时最长的步骤。它也是最重要的步骤之一:

  • 我能获得哪些数据?

  • 这能帮助我解决问题吗?

  • 这是否足够?

  • 数据质量是否足够?

想象一下,对于你的贷款申请问题,你已经收集了过去十年中具有代表性的贷款样本。其中一些贷款已经违约;大多数贷款(大约 70%)尚未违约。你已经收集了关于每个贷款申请的各种属性,如表 1.2 中所示。

表 1.2. 贷款数据属性

Status_of_existing_checking_account (at time of application)
Duration_in_month (loan length)
Credit_history
Purpose (car loan, student loan, and so on)
Credit_amount (loan amount)
Savings_Account_or_bonds (balance/amount)
Present_employment_since
Installment_rate_in_percentage_of_disposable_income
Personal_status_and_sex
Cosigners
Present_residence_since
Collateral (汽车、房产等)
Age_in_years
Other_installment_plans (其他贷款/信用额度类型)
Housing (拥有、租赁等)
Number_of_existing_credits_at_this_bank
Job (就业类型)
Number_of_dependents
Telephone (是否有)
Loan_status (因变量)

在您的数据中,Loan_status有两种可能的值:GoodLoanBadLoan。在本讨论中,假设GoodLoan已偿还,而BadLoan违约。


尽量直接测量您所需的信息

尽可能地,尝试使用可以直接测量的信息,而不是从其他测量中推断出的信息。例如,您可能会倾向于使用收入作为变量,认为收入较低意味着偿还贷款更困难。偿还贷款的能力可以通过考虑贷款支付金额与借款人可支配收入的比例来更直接地衡量。这个信息比单纯的收入更有用;您在数据中作为变量Installment_rate_in_percentage_of_disposable_income拥有它。


这是您最初探索和可视化数据的阶段。您还将清理数据:修复数据错误和转换变量,如有必要。在探索和清理数据的过程中,您可能会发现数据不适合您的问题,或者您还需要其他类型的信息。您可能会在数据中发现比您最初计划解决的问题更重要的问题。例如,图 1.2 中的数据似乎与直觉相反。

图 1.2. 按信用历史类别划分的违约贷款比例。每个条形的深色区域代表该类别中违约贷款的比例。

为什么一些看似安全的申请人(那些已向银行偿还所有贷款的人)的违约率会比看似风险更高的申请人(那些过去有过拖欠记录的人)更高?在更仔细地查看数据并与其他利益相关者和领域专家分享令人困惑的发现后,你意识到这个样本本身是有偏见的:你只有实际发放的贷款(因此已经接受)。一个真正的无偏贷款申请样本应该包括已接受的贷款申请和被拒绝的贷款申请。总体而言,由于你的样本只包括已接受的贷款,数据中看起来有风险的贷款比看起来安全的贷款要少。可能的情况是,看起来有风险的贷款在经过更严格的审查过程后被批准,这个过程可能是看似安全的贷款申请可以绕过的。这表明,如果你的模型要在当前申请批准流程之后使用,信用历史就不再是有用的变量。这也表明,即使是看似安全的贷款申请也应该更加仔细地审查。

这样的发现可能会让你和其他利益相关者改变或完善项目目标。在这种情况下,你可能会决定专注于看似安全的贷款申请。在发现数据中的事物时,你可能会在这个阶段和上一个阶段之间,以及在这个阶段和建模阶段之间来回循环。我们将在第三章(chapters 3)和第四章(4)中深入探讨数据探索和管理。

1.2.3. 建模

在建模或分析阶段,你最终会接触到统计学和机器学习。在这里,你试图从数据中提取有用的见解,以实现你的目标。由于许多建模过程对数据分布和关系做出了特定的假设,因此在尝试找到最佳的数据表示方式和建模形式时,建模阶段和数据清洗阶段之间可能会有重叠和反复。

图片

最常见的数据科学建模任务包括这些:

  • Classifying— 判断某物是否属于某一类别或另一类别

  • Scoring— 预测或估计一个数值,例如价格或概率

  • Ranking— 学习根据偏好对项目进行排序

  • Clustering— 将项目分组到最相似的组中

  • Finding relations— 寻找数据中观察到的相关关系或潜在原因

  • Characterizing— 从数据中非常通用的绘图和报告生成

对于这些任务中的每一个,都有几种不同的可能方法。我们将在这本书中介绍这些不同任务的一些最常见方法。

贷款申请问题是一个分类问题:你想要识别那些可能违约的贷款申请人。在这种情况下的一些常见方法是对数回归和基于树的算法(我们将在第七章和第十章中深入探讨这些方法)。你已经与贷款官员和其他将在现场使用你的模型的人进行了交谈,因此你知道他们希望能够理解模型分类背后的推理链,并且他们希望有一个模型对其决策的置信度的指示:这位申请人高度可能违约,还是只有一定可能?为了解决这个问题,你决定决策树是最合适的。我们将在第十章中更广泛地介绍决策树,但现在我们只看看得到的决策树模型。3

³

在本章中,为了清晰起见,我们故意拟合了一个小而浅的树。

假设你发现了图 1.3 中展示的模型。让我们追踪一条通过树形结构的示例路径。假设有一个申请一年期 10,000 德国马克(当时的研究货币)贷款的应用。在树的顶部(图 1.3 中的节点 1),模型检查贷款是否超过 34 个月。答案是“否”,因此模型沿着树的右侧分支下行。这显示为从节点 1 出发的高亮分支。下一个问题(节点 3)是贷款是否超过 11,000 德国马克。同样,答案是“否”,因此模型再次向右分支(由从节点 3 出发的较暗的高亮分支表示)并到达叶子 3。历史上,到达这个叶子的 75%的贷款都是良好的贷款,因此模型建议你批准这笔贷款,因为有很高的可能性会偿还。

图 1.3. 用于寻找不良贷款申请的决策树模型。结果节点显示置信度分数。

另一方面,假设有一个申请一年期 15,000 德国马克贷款的应用。在这种情况下,模型首先在节点 1 处向右分支,然后在节点 3 处向左分支,到达叶子 2。历史上,到达叶子 2 的所有贷款都违约了,因此模型建议你拒绝这笔贷款申请。

我们将在第六章中讨论一般的建模策略,并在第二部分中详细介绍特定的建模算法。

1.2.4. 模型评估和批评

一旦你有了模型,你需要确定它是否满足你的目标:

  • 它是否足够准确以满足你的需求?它是否具有良好的泛化能力?

  • 它的表现是否优于“显而易见的猜测”?是否优于你目前使用的任何估计?

  • 模型的结果(系数、聚类、规则、置信区间、显著性以及诊断)在问题域的背景下是否有意义?

如果你对这些问题的任何一个回答了“不”,那么是时候回到建模步骤——或者决定数据不支持你试图实现的目标。没有人喜欢负面结果,但了解在当前资源下无法满足你的成功标准时,将节省你徒劳的努力。你的精力将更好地用于创造成功。这可能意味着定义更现实的或收集你为实现原始目标所需的额外数据或其他资源。

回到贷款申请示例,首先要检查的是模型发现的规则是否有意义。查看 图 1.3,你不会注意到任何明显奇怪的规则,因此你可以继续评估模型的准确度。分类器准确度的一个很好的总结是 混淆矩阵,它列出了实际分类与预测分类的对比。([1]

通常,我们会将模型与测试集(用于构建模型的数据)进行比较。在这个例子中,为了简单起见,我们将使用训练数据(用于构建模型的数据)来评估模型。此外,请注意我们遵循的绘图约定:预测是 x 轴,对于表格来说,这意味着预测是列名。请注意,混淆矩阵还有其他约定。

在 列表 1.1 中,你将创建一个混淆矩阵,其中行表示实际贷款状态,列表示预测贷款状态。为了提高可读性,代码通过名称而不是索引引用矩阵元素。例如,conf_mat ["GoodLoan", "BadLoan"] 指的是元素 conf_mat[2, 1]:模型预测为不良的实际良好贷款数量。矩阵的对角线条目表示正确预测。

列表 1.1. 计算混淆矩阵

library("rpart")                                                           ❶
 load("loan_model_example.RData")                                          ❷
 conf_mat <-
      table(actual = d$Loan_status, pred = predict(model, type = 'class')) ❸

##           pred
## actual     BadLoan GoodLoan
##   BadLoan       41      259
##   GoodLoan      13      687

(accuracy <- sum(diag(conf_mat)) / sum(conf_mat))                          ❹
 ## [1] 0.728
(precision <- conf_mat["BadLoan", "BadLoan"] / sum(conf_mat[, "BadLoan"])  ❺
 ## [1] 0.7592593
(recall <- conf_mat["BadLoan", "BadLoan"] / sum(conf_mat["BadLoan", ]))    ❻
 ## [1] 0.1366667

(fpr <- conf_mat["GoodLoan","BadLoan"] / sum(conf_mat["GoodLoan", ]))      ❻
 ## [1] 0.01857143

❶ 如何安装运行本书示例所需的所有包,可以在以下链接找到:github.com/WinVector/PDSwR2/blob/master/packages.R

❷ 此文件可以在以下链接找到:github.com/WinVector/PDSwR2/tree/master/Statlog

❸ 创建混淆矩阵

❹ 模型总体准确率:73% 的预测是正确的。

❺ 模型精确度:76% 被预测为不良的申请者确实违约了。

❻ 模型召回率:模型发现了 14% 的违约贷款。

❻ 假阳性率:2% 的良好申请者被错误地识别为不良。

模型正确预测贷款状态的准确率为 73%,优于随机猜测(50%)。在原始数据集中,30%的贷款是坏账,所以一直猜测GoodLoan的准确率将是 70%(尽管并不很有用)。所以你知道模型比随机猜测做得更好,也比明显的猜测做得更好。

总体准确率还不够。你想要知道正在犯什么类型的错误。模型是否漏掉了太多的坏账,或者是否将太多的好账标记为坏账?召回率衡量模型实际上能找到多少坏账。精确率衡量被标记为坏账的贷款中有多少确实是坏账。假阳性率衡量有多少好账被错误地标记为坏账。理想情况下,你希望召回率和精确率都很高,而假阳性率很低。什么构成“足够高”和“足够低”是与其他利益相关者共同做出的决定。通常,正确的平衡需要在召回率和精确率之间进行一些权衡。

还有其他衡量准确率的指标,以及其他衡量模型质量的指标。我们将在第六章讨论模型评估。

1.2.5. 演示和文档

一旦你有一个满足成功标准的模型,你将向你的项目赞助商和其他利益相关者展示你的结果。你还必须为那些在模型部署后负责使用、运行和维护模型的组织成员记录模型。

图片

不同的受众需要不同类型的信息。面向商业的受众希望了解你的发现对商业指标的影响。在贷款示例中,向商业受众展示的最重要的事情是,你的贷款申请模型将如何减少坏账(银行因不良贷款而损失的资金)。假设你的模型识别出了一批坏账,占总损失资金的 22%。那么,你的演示或执行摘要应该强调该模型有可能减少银行损失的这个数额,如图 1.4 所示。

图 1.4。执行演示中的示例幻灯片

图片

你还希望向这个受众提供你最有趣的发现或建议,例如,新车贷款比二手车贷款风险更大,或者大多数损失都与不良汽车贷款和不良设备贷款有关(假设受众不知道这些事实)。模型的技术细节对这个受众来说可能不那么有趣,你应该跳过它们或只在高层次上展示它们。

为模型最终用户(贷款官员)准备的演示应该强调模型将如何帮助他们更好地完成工作:

  • 他们应该如何解释模型?

  • 模型的输出看起来是什么样子?

  • 如果模型提供了决策树中执行了哪些规则的跟踪,他们应该如何阅读这些信息?

  • 如果模型除了分类之外还提供了一个置信度分数,他们应该如何使用这个置信度分数?

  • 他们可能在什么情况下可能会推翻模型?

面向运营人员的演示或文档应该强调你的模型对他们负责的资源的影响。我们将在第三部分讨论不同受众的演示和文档结构。

1.2.6. 模型部署和维护

最后,模型投入运行。在许多组织中,这意味着数据科学家不再对模型的日常运营负有主要责任。但你仍然应该确保模型能够平稳运行,不会做出灾难性的无监督决策。你还想确保模型能够随着环境的变化而更新。在许多情况下,模型最初将部署在一个小型的试点项目中。测试可能会发现你没有预料到的问题,你可能需要相应地调整模型。我们将在第十一章讨论模型部署。

图片

当你部署模型时,你可能会发现贷款官员在特定情况下经常绕过模型,因为它与他们的直觉相矛盾。他们的直觉错了吗?或者你的模型不完整?或者,在一个更积极的场景中,你的模型可能表现得太成功,以至于银行希望你也将其扩展到住房贷款。

在我们深入探讨数据科学生命周期各阶段之前,在接下来的章节中,让我们先看看初始项目设计阶段的一个重要方面:设定预期。

1.3. 设定预期

设定预期是定义项目目标和成功标准的关键部分。你的团队面向业务成员(特别是项目发起人)可能已经对满足业务目标所需的表现有了一定的想法:例如,银行希望至少减少 10%的不良贷款损失。在你深入一个项目之前,你应该确保你拥有的资源足以帮助你实现业务目标。

这是一个项目生命周期阶段灵活性的例子。在探索和清洗阶段,你将更好地了解数据;在你对数据有了感觉之后,你可以判断数据是否足够好,以满足预期的性能阈值。如果不够,那么你将不得不重新审视项目设计和目标设定阶段。

1.3.1. 确定模型性能的下限

在定义验收标准时,了解模型应该达到的预期性能水平是很重要的。

零模型代表你应该努力达到的模型性能的下限。你可以将零模型视为“显而易见的猜测”,即你的模型必须做得更好的。在已经有一个正在运行的模型或解决方案,而你试图改进的情况下,零模型是现有的解决方案。在没有现有模型或解决方案的情况下,零模型是最简单的可能模型:例如,始终猜测GoodLoan,或者在你试图预测数值时始终预测输出的平均值。

在我们的贷款申请示例中,数据集中的 70%的贷款申请最终证明是好的贷款。将所有贷款标记为GoodLoan(实际上,仅使用现有流程来分类贷款)的模型将有 70%的时间是正确的。所以你知道,任何实际模型,当你将其拟合到数据上时,应该比 70%的准确率更好,以便有用——如果准确率是你的唯一指标。由于这是最简单的模型,其错误率被称为基础错误率

你应该比 70%好多少?在统计学中,有一个叫做假设检验显著性检验的程序,用于检验你的模型是否与零模型(在这种情况下,是否新模型基本上只与始终猜测GoodLoan一样准确)等效。你希望你的模型准确率在统计上“显著优于”70%。我们将在第六章中讨论显著性检验。

准确率并不是唯一的(甚至不是最好的)性能指标。正如我们之前看到的,召回率衡量模型识别出的真实坏贷款的比例。在我们的例子中,始终猜测GoodLoan的零模型在识别坏贷款方面的召回率为零,这显然不是你想要的。通常,如果已经有一个现有的模型或流程,你希望了解其精确度、召回率和假阳性率;提高这些指标中的一个几乎总是比仅仅考虑准确率更重要。如果你的项目目的是改进现有流程,那么当前模型至少在这些指标中的一个上必须是不令人满意的。了解现有流程的限制有助于你确定所需性能的有用下限。

摘要

数据科学流程涉及很多来回——在数据科学家和其他项目利益相关者之间,以及在流程的不同阶段之间。在这个过程中,你会遇到惊喜和障碍;这本书将教你克服一些这些障碍的程序。保持所有利益相关者知情和参与很重要;当项目结束时,与项目有关联的任何人都不应该对最终结果感到惊讶。

在接下来的章节中,我们将探讨项目设计之后的阶段:加载数据、探索数据和管理工作数据。第二章介绍了几种将数据加载到 R 中的基本方法,这些方法便于分析。

在本章中,你学习了

  • 一个成功的数据科学项目不仅仅涉及统计学。它还需要各种角色来代表商业和客户利益,以及运营方面的关注。

  • 你应该确保你有一个明确、可验证、可量化的目标。

  • 确保你对所有利益相关者设定了现实的目标。

第二章。从 R 和数据开始

本章涵盖

  • 开始使用 R 和数据

  • 掌握 R 的数据框结构

  • 将数据加载到 R 中

  • 对数据进行重新编码以供后续分析

本章将介绍如何开始使用 R 以及如何从各种来源将数据导入 R。这将为您在本书其余部分的工作做好准备。

图 2.1 是一个表示本书心智模型的图表,经过重新着色以强调本章的目的:开始使用 R 并将数据导入 R。整体图表显示了从第一章中的数据科学流程图与本书标题的象形文字形式相结合。在每一章中,我们将重新着色这个心智模型,以指明我们强调的数据科学流程的各个部分。例如:在本章中,我们将掌握收集和管理数据的初始步骤,并触及实用性、数据和 R(但还不是科学艺术)的问题。

图 2.1。第二章心智模型

许多数据科学项目始于有人将分析师指向一大堆数据,分析师则被留下去理解它。5 您的第一个想法可能是使用临时工具和电子表格来整理它,但您很快就会意识到,您花在调整工具上的时间比实际分析数据的时间要多。幸运的是,有一个更好的方法:使用 R。到本章结束时,您将能够自信地使用 R 提取、转换和加载数据进行分析。

我们假设读者对成为分析师、统计学家或数据科学家感兴趣,因此我们将交替使用这些术语来代表与读者类似的人。

没有数据的 R 就像去剧院看帷幕上下摆动。

改编自 Ben Katchor 的《Julius Knipl,房地产摄影师:故事》

2.1。从 R 开始

R 是开源软件,在 Unix、Linux、Apple 的 macOS 和 Microsoft Windows 上运行良好。本书将专注于如何作为数据科学家进行工作。然而,为了运行示例,读者必须熟悉 R 编程。如果您想获取一些先决知识,我们建议咨询 CRAN(主要 R 包仓库:cran.r-project.org/manuals.html)和其他在线材料。以下是一些适合 R 入门的好书:

  • 《R 编程艺术,第二版》,Robert Kabacoff,Manning,2015

  • 《超越电子表格的 R》,Jonathan Carroll,Manning,2018

  • 《R 编程的艺术》,Norman Matloff,No Starch Press,2011

  • 《R 编程人人用,第二版》,Jared P. Lander,Addison-Wesley,2017

每本书都有不同的教学风格,其中一些包括统计学、机器学习和数据工程的内容。一点研究可能会告诉你哪些书籍适合你。本书将专注于处理大量的数据科学示例,展示在未来的实际应用中遇到的典型问题的解决步骤。

我们认为数据科学是可重复的:在相同的数据上重新运行相同的工作应该给出相似的质量结果(由于数值问题、时间问题、并行性问题以及伪随机数问题,确切的结果可能会有所不同)。实际上,我们应该坚持可重复性。这就是为什么我们在数据科学书中讨论编程的原因。编程是可靠地指定可重用操作序列的方法。考虑到这一点,我们应该始终将数据更新(获取更新、更正或更大的数据)视为好事,因为重新运行分析应该,按照设计,非常容易。一个由多个手动步骤执行的分析永远不会容易重复。

2.1.1. 安装 R、工具和示例

我们建议你遵循附录 A 的 A.1 节中的步骤来安装 R、软件包、工具和本书的示例。


寻找帮助

R 包含一个非常出色的帮助系统。要获取 R 命令的帮助,只需在 R 控制台中运行help()命令。例如,要查看如何更改目录的详细信息,你应该输入help(setwd)。你必须知道函数的名称才能获取帮助,所以我们强烈建议做笔记。对于一些简单的函数,我们不会解释函数,而是留给读者调用help()来找出函数的功能。


2.1.2. R 编程

在本节中,我们将简要介绍一些 R 编程的约定、语义和风格问题。详细信息可以在特定软件包的文档、R 的help()系统中找到,也可以通过尝试我们在此处提供的示例的不同变体来获取。在这里,我们将专注于与其他常见编程语言不同的方面,以及我们在书中强调的约定。这应该有助于你进入 R 编程的思维模式。

有许多常见的 R 编程风格指南。编程风格是尝试使事物更加一致、清晰和易于阅读。本书将遵循我们在教学和代码维护中找到的非常有效的风格变体。显然,我们的风格只是众多风格中的一种,并不是强制性的。好的起始参考包括以下内容:

我们将尽量减少与当前惯例的差异,并指出我们有哪些差异。我们还推荐作者博客中的“R 技巧和窍门”。^([6])

查看 www.win-vector.com/blog/tag/r-tips/

R 是一种丰富而广泛的语言,通常有多种方式来完成同一项任务。这代表了一点点初始的学习曲线,因为直到你熟悉了符号,R 程序的含义可能很难辨别。然而,花时间复习一些基本符号是非常有回报的,因为它会使处理即将到来的大量示例变得更加容易。我们理解 R 的语法对于来到这里学习数据科学方法和实践的读者来说可能并不有趣(我们的确切目标受众!),但这个小小的初始努力可以防止以后出现很多困惑。我们将使用本节来描述一些 R 的符号和含义,重点关注特别有用和令人惊讶的部分。所有以下都是小而基本的内容,但其中许多都是微妙的,值得实验。


倾向于工作代码

倾向于使用程序、脚本或代码,这些代码可以工作但尚未实现你想要的功能。而不是编写一个大的、未经测试的程序或脚本,其中包含了分析的所有期望步骤,编写一个能够正确执行一个步骤的程序,然后迭代地修改脚本以正确执行更多步骤。这种从工作修订版开始的方法通常比尝试调试一个大的、有缺陷的系统到正确性要快得多。


示例和注释字符(#)

在示例中,我们将以自由文本的形式展示 R 命令,结果以井号 # 开头,这是 R 的注释字符。在许多示例中,我们将在命令之后包括结果,前面加上注释字符。R 打印通常包括方括号中的数组单元格索引,并且经常涉及换行。例如,打印整数 1 到 25 的样子如下:

print(seq_len(25))
# [1]  1  2  3  4  5  6  7  8  9 10 11 12
# [13] 13 14 15 16 17 18 19 20 21 22 23 24
# [25] 25

注意数字被换行到三行,每行都以该行报告的第一个单元格的索引开始,位于方括号内。有时我们不会显示结果,这是额外鼓励你完成这些特定示例的一种方式。

打印

R 有一些规则可以打开或关闭隐式或自动打印。一些包,如 ggplot2,使用打印来触发它们预期的操作。输入一个值通常会导致打印该值。在函数或 for 循环中必须小心,因为这些上下文中 R 的自动打印结果是禁用的。打印非常大的对象可能是一个问题,因此你想要避免打印未知大小的对象。通常可以通过添加额外的括号来强制隐式打印,例如在“(x <- 5)”中。

向量和列表

向量(值序列的顺序数组)是 R 的基本数据结构。列表可以在每个槽位中持有不同类型;向量在每个槽位中只能持有相同的基本或原子类型。除了数字索引外,向量和列表都支持名称键。可以通过下面的操作符从列表或向量中检索项目。


向量索引

R 中的向量(R vectors)和列表(R lists)是从 1 开始索引的,而不是像许多其他编程语言那样从 0 开始。


example_vector <- c(10, 20, 30)                ❶
example_list <- list(a = 10, b = 20, c = 30)   ❷

example_vector[1]                              ❸
 ## [1] 10
example_list[1]
## $a
## [1] 10

example_vector[[2]]                            ❹
 ## [1] 20
example_list[[2]]
## [1] 20

example_vector[c(FALSE, TRUE, TRUE)]           ❺
 ## [1] 20 30
example_list[c(FALSE, TRUE, TRUE)]
## $b
## [1] 20
##
## $c
## [1] 30

example_list$b                                 ❻
 ## [1] 20

example_list[["b"]]
## [1] 20

❶ 构建一个示例向量。c() 是 R 的连接操作符——它从较短的向量或列表构建更长的向量或列表,而不需要嵌套。例如,c(1) 只是数字 1,而 c(1, c(2, 3)) 等价于 c(1, 2, 3),这又等价于整数 1 到 3(尽管以浮点格式存储)。

❷ 构建一个示例列表

❸ 展示了使用 [] 的向量和列表的使用。注意,对于列表,[] 返回一个新的短列表,而不是项目本身。

❹ 展示了使用 [[]] 的向量和列表的使用。在常见情况下,[[]] 强制返回单个项目,尽管对于复杂类型的嵌套列表,这个项目本身也可以是一个列表。

❺ 向量和列表可以通过逻辑向量、整数向量以及(如果向量或列表有名称)字符向量进行索引。

❻ 对于命名示例,example_list$b 的语法实际上是 example_list[["b"]] 的简写(对于命名向量也是如此)。

我们不会在每一个例子中都分享这么多笔记,但我们邀请读者通过在使用的每个函数或命令上调用 help() 来像有这些笔记一样工作。此外,我们非常鼓励尝试不同的变体。在 R 中,“错误”只是 R 安全拒绝完成一个不良形成的操作(错误并不表示“崩溃”,结果也没有被破坏)。因此,对错误的恐惧不应限制实验。

x <- 1:5
print(x)                                                                   ❶
# [1] 1 2 3 4 5

x <- cumsumMISSPELLED(x)                                                   ❷
# Error in cumsumMISSPELLED(x) : could not find function "cumsumMISSPELLED"

print(x)                                                                   ❸
# [1] 1 2 3 4 5

x <- cumsum(x)                                                             ❹
print(x)
# [1]  1  3  6 10 15

❶ 定义了一个我们感兴趣的价值并将其存储在变量 x 中

❷ 尝试将新的结果分配给 x,但失败了

❸ 注意到除了提供有用的错误信息外,R 还保留了变量 x 的原始值。

❹ 再次尝试操作,使用 cumsum() 的正确拼写。cumsum(),即累积和,是一个有用的函数,可以快速计算运行总和。

R 中向量的另一个方面是大多数 R 操作都是向量化的。当一个函数或操作符应用于向量时,它被称作向量化,这意味着它独立地对向量的每个元素应用函数。例如,函数 nchar() 用于计算字符串中的字符数。在 R 中,这个函数可以用于单个字符串,也可以用于字符串向量。


列表和向量是 R 的映射结构

列表和向量是 R 的映射结构。它们可以将字符串映射到任意对象。主要的列表操作 []match()%in% 都是向量化的。这意味着当它们应用于值向量时,通过每个条目执行一次查找来返回结果向量。要从列表中提取单个元素,请使用双括号表示法 [[]]


nchar("a string")
# [1] 8

nchar(c("a", "aa", "aaa", "aaaa"))
# [1] 1 2 3 4

逻辑运算

R 的逻辑运算符有两种类型。R 有标准的后缀标量值运算符,它们只期望一个值,并且具有与 C 或 Java 中相同的行为和名称:&&||。R 还有向量化后缀运算符,它们作用于逻辑值的向量:&|。务必始终在if语句等情况下使用标量版本(&&||),在处理逻辑向量时使用向量化版本(&|)。


NULL 和 NANA(不可用)值

在 R 中,NULL只是使用连接运算符c()不带参数形成的空或长度为零向量的同义词。例如,当我们把c()输入到 R 控制台时,我们会看到返回的值是NULL。在 R 中,NULL不是任何形式的无效指针(如在大多数 C/Java 相关语言中)。NULL只是一个长度为零的向量。连接NULL是一个安全且定义良好的操作(实际上是一个“无操作”或“no-op”,什么都不做)。例如,c(c(), 1, NULL)是完全有效的,并返回值1

NA代表“不可用”,这在 R 中相当独特。大多数简单类型都可以取NA的值。例如,向量c("a", NA, "c")是一个包含三个字符字符串的向量,其中我们不知道第二个条目的值。NA的存在非常方便,因为它允许我们在数据中直接标注缺失或不可用的值,这在数据处理中可能至关重要。NA的行为有点像浮点运算中的NaN值,^([7])但我们不限于只使用它与浮点类型一起。此外,NA意味着“不可用”,而不是无效(如NaN表示),因此NA有一些方便的规则,例如逻辑表达式FALSE & NA简化为FALSE

浮点运算的局限性,或者说在计算机中通常如何近似实数,是处理数值数据时常见的混淆和问题来源。为了理解处理数值数据的问题,我们建议数据科学家阅读大卫·戈尔伯格 1991 年的《计算调查》。“关于浮点运算,每位计算机科学家都应该知道的内容”已从该期公开分享(docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html)。

标识符

标识符或符号名称是 R 引用变量和函数的方式。Google R 风格指南坚持使用所谓的“驼峰式命名法”(名称中的单词边界由大写字母表示,如“CamelCase”本身)。高级 R 指南建议使用下划线样式,其中标识符内的名称用下划线分隔(例如,“day_one”而不是“DayOne”)。此外,许多 R 用户使用点来分隔标识符的名称(例如,“day.one”)。特别是,重要的内置 R 类型,如data.frame和包如data.table,使用点表示法约定。

我们建议使用下划线表示法,但发现在与他人合作时经常必须在这两种约定之间切换。如果可能,避免使用点约定,因为这种表示法通常在面向对象的语言和数据库中用于其他目的,因此会无端地混淆他人.^([8])

点表示法可能来自 Lisp 世界(它强烈影响了 R)和避免下划线的倾向可能是在“_”曾是 R 中可用的赋值运算符时遗留下来的(它现在不再用作 R 中的赋值运算符)。

换行

通常建议将 R 源代码行限制在 80 列或更少。只要语句结束是明确的,R 接受多行语句。例如,要将单个语句“1 + 2”拆分为多行,可以这样编写代码:

1 +
  2

不要编写如下代码,因为第一行本身就是一个有效的语句,这会创建歧义:

1
  + 2

规则是这样的:每次在多行语句中读取时,如果语句提前结束,就强制产生语法错误。

分号

R 允许使用分号作为语句结束标记,但不是必须的。大多数风格指南建议不要在 R 代码中使用分号,当然也不应在行尾使用它们。

赋值

R 有许多赋值运算符(见表 2.1);首选的是<-。在 R 中,=也可以用于赋值,但它也用于在函数调用期间通过名称将参数值绑定到参数名(因此使用=存在一些潜在的歧义)。

表 2.1. R 的主要赋值运算符

运算符 用途 示例
<- 将右侧的值赋给左侧的符号。 x <- 5 # 将 5 赋值给符号 x
= 将右侧的值赋给左侧的符号。 x = 5 # 将 5 赋值给符号 x
-> 将左侧赋给右侧,而不是传统的从右到左。 5 -> x # 将 5 赋值给符号 x

赋值的左侧

许多流行的编程语言只允许将值赋给变量名或符号。R 允许在赋值的左侧使用切片表达式,以及数值和逻辑数组索引。这允许使用非常强大的数组切片命令和编码风格。例如,我们可以像以下示例中那样,将向量中的所有缺失值(用“NA”表示)替换为零:

d <- data.frame(x = c(1, NA, 3))    ❶
print(d)
#    x
# 1  1
# 2 NA
# 3  3                              ❷

d$x[is.na(d$x)] <- 0                ❸
print(d)
#   x
# 1 1
# 2 0
# 3 3

❶ “data.frame”是 R 的表格数据类型,也是 R 中最重要的一种数据类型。data.frame 以行和列的形式组织数据。

❷ 当打印 data.frame 时,行号显示在第一列(未命名的列)中,列值显示在其对应的列名下方。

❸ 我们可以将 d 的 x 列的切片或选择放在赋值的左侧,以轻松地将所有 NA 值替换为零。

因子

R 可以处理多种类型的数据:数值、逻辑、整数、字符串(称为 字符 类型)和 因子。因子是 R 的一种类型,它将一组固定的字符串编码为整数。因子可以在存储上节省很多空间,同时看起来像字符串一样行为。然而,因子可能与 as.numeric() 命令(它为因子返回因子代码,但解析文本为字符类型)产生潜在的混淆。因子还编码了整个允许值的集合,这很有用——但可能会使将来自不同来源(看到了不同的值集合)的数据合并变得有些麻烦。为了避免问题,我们建议在分析后期再延迟将字符串转换为因子。这通常通过向 data.frame()read.table() 等函数添加 stringsAsFactors = FALSE 参数来实现。然而,我们确实鼓励在有理由的情况下使用因子,例如想要使用 summary() 或准备生成虚拟指标(参见 关于因子编码的更多内容 后的 2.10 列表,了解更多关于虚拟指标及其与因子的关系)。

命名参数

R 围绕着将函数应用于数据。具有大量参数的函数很快就会变得令人困惑和难以阅读。这就是为什么 R 包含命名参数功能的原因。例如,如果我们想将工作目录设置为 “/tmp”,我们通常会使用 setwd() 命令如下:setwd("/tmp")。然而,help(setwd) 显示 setwd() 的第一个参数名为 dir,因此我们也可以这样写:setwd(dir = "/tmp")。这对于具有大量参数的函数和设置可选函数参数非常有用。注意:命名参数必须通过 = 设置,而不是通过赋值运算符如 <-

如果你有一个有 10 个参数的过程,你可能遗漏了一些。

艾伦·佩利斯,《编程格言》,ACM SIGPLAN 通告 17

包符号

在 R 中,使用包中的函数主要有两种方式。第一种是通过 library() 命令附加包,然后使用函数名。第二种是使用包名,然后使用 :: 来指定函数。例如,stats::sd(1:5) 就是一个这种方法的例子。:: 符号有助于避免歧义,或者在你以后阅读自己的代码时留下关于函数来自哪个包的提示。

值语义

R 在高效模拟“按值复制”语义方面很独特。任何用户拥有两个数据引用时,每个都会独立演变:对其中一个的更改不会影响另一个。这对于兼职程序员来说非常理想,并且在编写代码时消除了大量可能的别名错误。这里我们给出一个快速示例:

d <- data.frame(x = 1, y = 2)     ❶
d2 <- d                           ❷
d$x <- 5                          ❸

print(d)
#   x y
# 1 5 2

print(d2)
#   x y
# 1 1 2

❶ 创建一些示例数据,并通过名称 d 引用它们

❷ 创建对同一数据的另一个引用 d2

❸ 修改 d 所引用的值

注意 d2x 保留了旧值 1。这个特性使得编码非常方便和安全。许多编程语言以这种方式在函数调用中保护引用或指针;然而,R 保护复杂数值,并且在所有情况下都这样做(而不仅仅是函数调用)。当你想要共享回更改时,需要特别注意,例如在所有所需更改完成后调用最终的赋值操作,如 d2 <- d。根据我们的经验,R 的值隔离语义防止了比它引入的复制回传不便更多的问题。

组织中间值

长序列的计算可能会变得难以阅读、调试和维护。为了避免这种情况,我们建议保留变量名 . 来存储中间值。想法是这样的:慢慢工作以快速前进。例如:一个常见的数据科学问题是排序收入记录,然后计算达到给定排序键的总收入比例。在 R 中,可以通过将这个任务分解成小步骤轻松完成:

data <- data.frame(revenue = c(2, 1, 2),                        ❶
                   sort_key = c("b", "c", "a"),
                   stringsAsFactors = FALSE)
print(data)
#   revenue sort_key
# 1       2        b
# 2       1        c
# 3       2        a

. <- data                                                       ❷
. <- .[order(.$sort_key), , drop = FALSE]                       ❸
.$ordered_sum_revenue <- cumsum(.$revenue)
.$fraction_revenue_seen <- .$ordered_sum_revenue/sum(.$revenue)
result <- .                                                     ❹

print(result)
#   revenue sort_key ordered_sum_revenue fraction_revenue_seen
# 3       2        a                   2                   0.4
# 1       2        b                   4                   0.8
# 2       1        c                   5                   1.0

❶ 我们的概念性或示例数据。

❷ 将我们的数据赋值给一个名为 “.” 的临时变量。原始值将保留在 “data” 变量中,这使得在必要时从开始重新计算变得容易。

❸ 使用 order 命令对行进行排序。drop = FALSE 并非绝对必要,但养成包含它的习惯是好的。对于不带 drop = FALSE 参数的单列数据框,[,] 索引操作符会将结果转换为向量,这几乎不是 R 用户真正的意图。drop = FALSE 参数关闭了这种转换,并且包含它是“以防万一”的好主意,并且在数据框只有一列或我们不确定数据框是否有多列(因为数据框来自其他地方)时,这是一个明确的要求。

❹ 将结果从 “.” 赋值给一个更容易记忆的变量名

R 包 dplyr 将点符号替换为所谓的 管道符号(由名为 magrittr 的另一个包提供,类似于 JavaScript 方法,链式调用)。由于 dplyr 非常受欢迎,你可能会看到用这种风格编写的代码,我们有时会使用这种风格来帮助你为这样的代码做好准备。然而,重要的是要记住,dplyr 仅仅是标准 R 代码的一个流行替代品,而不是一个更优越的替代品。

library("dplyr")

result <- data %>%
  arrange(., sort_key) %>%
  mutate(., ordered_sum_revenue = cumsum(revenue)) %>%
  mutate(., fraction_revenue_seen = ordered_sum_revenue/sum(revenue))

这个示例的每一步都已被相应的 dplyr 等效项所取代。arrange()dplyrorder() 的替代,而 mutate()dplyr 对赋值的替代。代码翻译是逐行进行的,唯一的例外是赋值是首先编写的(尽管它发生在所有其他步骤之后)。计算步骤是通过 magrittr 管道符号 %>% 顺序排列的。

magrittr 管道允许你用 x %>% fx %>% f()x %>% f(.) 代替 f(x)。通常,x %>% f 是教授的符号,然而,我们认为 x %>% f(.) 在表示所发生的事情方面最为明确。⁹]

对于我们自己的工作,我们实际上更喜欢使用 wrapr 包中的“点管道” %.>%,它强制执行更多的符号一致性。

dplyr 符号的详细信息可以在以下链接中找到:dplyr.tidyverse.org/articles/dplyr.html。请注意,调试长的 dplyr 管道是困难的,在开发和实验过程中,将 dplyr 管道分解成更小的步骤,并将中间结果存储到临时变量中是有意义的。

中间结果符号的优点是它既容易重启,也容易逐步调试。在这本书中,我们将根据方便使用不同的符号。

data.frame

R 的 data.frame 类旨在以非常好的“分析就绪”格式存储数据。data.frame 是二维数组,其中每一列代表一个变量、测量或事实,每一行代表一个个体或实例。在这种格式中,单个单元格代表单个实例的单个事实或变量的已知信息。data.frame 实现为列向量的命名列表(列表列是可能的,但它们通常是 data.frame 的例外),在 data.frame 中,所有列的长度都相同,这意味着我们可以将所有列的 k 个条目视为一行。

data.frame 列的操作通常既高效又矢量化。添加、查找和删除列都很快速。在 data.frame 上的行操作可能很昂贵,因此对于大型 data.frame 处理,你应该优先选择矢量化列符号。

R 的 data.frame 与数据库表非常相似,因为它具有类似模式的信息:一个明确的列名和列类型列表。大多数分析最好用 data.frame 列的转换来表示。

让 R 为你做工作

大多数常见的统计或数据处理操作已经在“基础 R”(R 本身及其核心包,如 utilsstats)或扩展包中有了很好的实现。如果你不委托给 R,你最终会与 R 作对。例如,来自 Java 的程序员可能会期望必须使用 for 循环来添加两个数据列的每一行值。在 R 中,添加两个数据列被认为是基本的,并且如下实现:

d <- data.frame(col1 = c(1, 2, 3), col2 = c(-1, 0, 1))
d$col3 <- d$col1 + d$col2
print(d)
#   col1 col2 col3
# 1    1   -1    0
# 2    2    0    2
# 3    3    1    4

data.frame 实际上是列的命名列表。我们将在这本书中广泛使用它们。在 R 中,人们倾向于在列上工作,并让 R 的矢量化特性一次性对每一行执行指定的操作。如果你发现自己正在 R 中逐行迭代,你就是在与语言作对。


搜索现成的解决方案

寻找正确的 R 函数可能很繁琐,但这是值得花费时间的(尤其是如果你保持可搜索的笔记)。R 是为数据分析而设计的,因此数据分析中最常见的步骤已经在 R 中得到了很好的实现,尽管可能名称不为人知,并且可能有奇特的默认设置。正如化学家弗兰克·韦斯特海默所说,“在实验室里几个月的时间可以经常节省在图书馆里几个小时。”^([10]) 这是对快速行动慢思考原则的有意讽刺重述:研究现有解决方案需要时间,但通常可以节省大量的直接编码时间。

¹⁰

en.wikiquote.org/wiki/Frank_Westheimer


2.2. 从文件中处理数据

最常见的现成数据格式实际上是一系列称为结构化值的表格格式。你找到的大部分数据都将是这些格式之一(或几乎是这样)。当你能够将这些文件读入 R 中时,你就可以分析来自各种公共和私人数据源的数据。在本节中,我们将通过两个从结构化文件加载数据的例子和一个直接从关系数据库加载数据的例子来展示。目的是快速将数据导入 R,然后我们可以使用 R 进行有趣的分析。

2.2.1. 从文件或 URL 中处理结构化数据

最容易读取的数据格式是带有标题的表格结构数据。如图 2.2 所示,这些数据按行和列排列,标题显示列名。每一列代表一个不同的事实或测量;每一行代表一个实例或数据,关于这个实例或数据,我们知道一组事实。大量的公共数据都是这种格式,因此能够读取它会打开很多机会。

图 2.2. 以表格形式查看的汽车数据

在我们加载上一章中使用的德国信贷数据之前,让我们用一个来自加州大学欧文分校机器学习存储库([archive.ics.uci.edu/ml/](http://archive.ics.uci.edu/ml/))的简单数据集来演示基本的加载命令。UCI 数据文件通常没有标题,因此为了节省步骤(并且使事情简单)我们预先准备了我们的第一个数据示例,来自 UCI 汽车数据集:http://archive.ics.uci.edu/ml/machine-learning-databases/car/。我们的预准备文件包含在本书支持目录 PDSwR2/UCICar 中(请参阅第 FM.5.6 节以获取说明),如下所示:

buying,maint,doors,persons,lug_boot,safety,rating    ❶
vhigh,vhigh,2,2,small,low,unacc                      ❷
vhigh,vhigh,2,2,small,med,unacc
vhigh,vhigh,2,2,small,high,unacc
vhigh,vhigh,2,2,med,low,unacc
...

❶ 标题行包含数据列的名称,在这种情况下由逗号分隔。当分隔符是逗号时,这种格式称为逗号分隔值,或.csv。

❷ 数据行与标题行具有相同的格式,但每一行包含实际的数据值。在这种情况下,第一行代表一组名称/值对:购买=vhigh,维护=vhigh,车门=2,人员=2,等等。


避免在 R 外部手动操作

我们强烈建议您在 R 外部避免手动执行步骤。使用编辑器向文件添加标题行,就像我们在示例中所做的那样,是很诱人的。更好的策略是编写一个 R 脚本来执行任何必要的格式转换。自动化这些步骤可以大大减少在不可避免的数据刷新过程中的痛苦和工作量。收到新的、更好的数据应该总是感觉像好消息,编写自动化和可重复的程序是朝着这个方向迈出的重要一步。

我们在第 2.2.2 节的示例中将展示如何在不手动编辑文件的情况下添加标题,就像在这个示例中所做的那样。


注意,这个演示的结构类似于电子表格,具有易于识别的行和列。每一行(非标题行)代表对不同车型的一次评估。列代表关于每个车型的事实。大多数列是客观测量值(购买成本、维护成本、车门数量等),最后的最终主观列“评级”标记为整体评级(vgoodgoodaccunacc)。这些细节来自原始数据中的文档,对于项目至关重要(因此我们建议保留实验记录本或笔记)。

加载结构良好的数据

将此类数据加载到 R 中是一行代码:我们使用 R 命令 utils::read .table() 并完成操作.^([11]) 为了完成这个练习,我们假设你已经下载并解压了本书 GitHub 存储库 github.com/WinVector/PDSwR2 的内容,并将你的工作目录更改为 PDSwR2/UCICar,正如前言中“使用本书”一节中所述(为此,你将使用 setwd() R 函数,并需要输入你保存 PDSwR2 的完整路径,而不仅仅是我们在示例中显示的文本片段)。一旦 R 处于 PDSwR2/UCICar 目录,读取数据就像以下列表所示。

^(11)

另一个选项是使用 readr 包中的函数。

列表 2.1. 读取 UCI 汽车数据

uciCar <- read.table(          ❶
    'car.data.csv',            ❷
    sep = ',',                 ❸
    header = TRUE,             ❹
    stringsAsFactor = TRUE     ❺
    )

View(uciCar)                   ❻

❶ 从文件或 URL 读取并存储结果为名为 uciCar 的新数据框对象的命令

❷ 获取数据的文件名或 URL

❸ 指定列或字段分隔符为逗号

❹ 告诉 R 预期一个标题行来定义数据列名

❺ 告诉 R 将字符串值转换为因子。这是默认行为,所以我们只是使用这个参数来记录意图。

❻ 使用 R 的内置表格查看器检查数据

列表 2.1 加载数据并将其存储在一个新的 R 数据框对象 uciCar 中,我们在 图 2.2 中展示了它的 View()

read.table() 命令功能强大且灵活;它可以接受许多不同类型的数据分隔符(逗号、制表符、空格、管道和其他),并且有许多选项用于控制引号和数据转义。read.table() 可以从本地文件或远程 URL 读取。如果一个资源名称以 .gz 后缀结尾,read.table() 假设文件已被 gzip 格式压缩,并在读取时自动解压缩。

检查我们的数据

一旦我们将数据加载到 R 中,我们就会想要检查它。以下是一些始终要尝试的命令:

  • class() 告诉你你有什么样的 R 对象。在我们的例子中,class(uciCar) 告诉我们对象 uciCardata.frame 类。类是一个面向对象的概念,它描述了一个对象将如何行为。R 还有一个(不太有用)的 typeof() 命令,它揭示了对象存储的实现方式。

  • dim() 对于数据框,此命令显示数据中有多少行和列。

  • head() 显示数据的前几行(或“头部”)。示例:head(uciCar)

  • help() 提供类的文档。特别是尝试 help(class (uciCar))

  • str() 提供了对象的架构。尝试 str(uciCar)

  • summary() 提供几乎所有 R 对象的摘要。summary(uciCar) 显示了 UCI 汽车数据的分布情况。

  • print() 打印所有数据。注意:对于大型数据集,这可能需要非常长的时间,并且是你想要避免的。

  • View() 以简单的电子表格样式的网格查看器显示数据。


许多 R 函数是通用的

许多 R 函数是通用的,因为它们在许多数据类型上工作方式几乎相同,或者甚至是面向对象的,因为它们根据它们正在处理的对象的运行时类选择正确的行为。我们建议如果你在一个对象的例子中看到某个函数被使用,尝试在其他对象上使用它。可以在许多不同类和类型上使用的常见 R 函数包括 length()print()saveRDS()str()summary()。R 运行时非常健壮,并奖励实验。大多数常见错误都会被捕获,并且不能破坏你的数据或使 R 解释器崩溃。所以,请实验吧!


我们接下来将展示这些步骤的一些结果(R 结果在每个步骤后以“##”为前缀显示)。

列表 2.2. 探索汽车数据

class(uciCar)
## [1] "data.frame"               ❶
summary(uciCar)
##    buying      maint       doors
##  high :432   high :432   2    :432
##  low  :432   low  :432   3    :432
##  med  :432   med  :432   4    :432
##  vhigh:432   vhigh:432   5more:432
##
##  persons     lug_boot    safety
##  2   :576   big  :576   high:576
##  4   :576   med  :576   low :576
##  more:576   small:576   med :576
##
##    rating
##  acc  : 384
##  good :  69
##  unacc:1210
##  vgood:  65

dim(uciCar)
## [1] 1728    7                ❷

❶ 加载的对象 uciCar 是数据框类型。

❷ [1] 仅仅是一个输出序列标记。实际信息如下:uciCar 有 1728 行和 7 列。始终尝试通过至少检查行数是否正好比原始文件中的文本行数少一行来确认您已正确解析。差一的原因是列标题算作一行文本,但不算作数据行。

summary()命令显示了数据集中每个变量的分布。例如,我们知道数据集中的每辆车都被声明为可容纳24更多人,我们还知道数据集中有 576 辆两座车。我们已经从数据中了解了很多,而无需花费大量时间手动构建像在电子表格中那样必须构建的交叉表。

处理其他数据格式

.csv 并不是你将遇到的唯一常见数据文件格式。其他格式包括.tsv(制表符分隔值)、管道分隔(竖线)文件、Microsoft Excel 工作簿、JSON 数据和 XML。R 的内置read.table()命令可以读取大多数分隔值格式。许多更深层的数据格式都有相应的 R 包:

  • CSV/TSV/FWF—reader(readr.tidyverse.org)提供了读取“分隔数据”的工具,如逗号分隔值(CSV)、制表符分隔值(TSV)和固定宽度文件(FWF)。

  • SQL— CRAN.R-project.org/package=DBI

  • XLS/XLSX— readxl.tidyverse.org

  • .RData/.RDS— R 有二进制数据格式(可以避免解析、引号、转义和以文本形式读取和写入数值或浮点数据时的精度损失等复杂问题)。.RData 格式用于保存对象集和对象名称,并通过save()/load()命令使用。.RDS 格式用于保存单个对象(不保存原始对象名称)并通过saveRDS()/readRDS()命令使用。对于临时工作,.RData 更方便(因为它可以保存整个 R 工作空间),但对于可重用工作,.RDS 格式更可取,因为它使得保存和恢复更加明确。为了以.RDS 格式保存多个对象,我们建议使用命名列表

  • JSON— CRAN.R-project.org/package=rjson

  • XML— CRAN.R-project.org/package=XML

  • MongoDB— CRAN.R-project.org/package=mongolite

2.2.2. 使用 R 处理非结构化数据

数据并不总是以现成的格式存在。数据管理员通常只是差一点就能制作出现成的机器可读格式。在第一章中讨论的德国银行信贷数据集就是一个例子。这些数据以无标题的表格形式存储;它使用了一种神秘的编码方式,需要数据集的配套文档来解开。这种情况并不少见,通常是由于其他常用工具的习惯或限制造成的。在我们将数据带入 R 之前重新格式化数据,就像我们在上一个例子中所做的那样,我们现在将展示如何使用 R 来重新格式化数据。这是一个更好的实践,因为我们可以保存并重用准备数据所需的 R 命令。

德国银行信贷数据集的详细信息可以在 mng.bz/mZbu 找到,我们已经在 PDSwR2/Statlog 目录中包含了这个数据集的副本。我们将展示如何使用 R 将这些数据转换成有意义的格式。完成这些步骤后,您可以执行 第一章 中已展示的分析。正如我们可以在我们的文件摘录中看到的那样,数据最初似乎是一块难以理解的代码块:

A11 6 A34 A43 1169 A65 A75 4 A93 A101 4 ...
A12 48 A32 A43 5951 A61 A73 2 A92 A101 2 ...
A14 12 A34 A46 2096 A61 A74 2 A93 A101 3 ...
  ...

在 R 中转换数据

数据通常需要一些转换才能变得有意义。为了解密麻烦的数据,你需要所谓的 模式文档数据字典。在这种情况下,包含的数据集描述说明数据有 20 个输入列,后面跟着一个结果列。在这个例子中,数据文件中没有标题。列定义和神秘的 A-* 代码的含义都在随附的数据文档中。让我们首先将原始数据加载到 R 中。启动 R 或 RStudio 的副本,并输入以下列表中的命令。

列表 2.3. 加载信贷数据集

setwd("PDSwR2/Statlog")                    ❶
 d <- read.table('german.data', sep=' ',
   stringsAsFactors = FALSE, header = FALSE)

❶ 将此路径替换为您保存 PDSwR2 的实际路径。

由于文件中没有列标题,我们的数据框 d 将会有无用的列名形式 V#。我们可以使用 c() 命令将这些列名更改为有意义的名称,如下所示。

列表 2.4. 设置列名

d <- read.table('german.data',
                sep  =  " ",
                stringsAsFactors  =  FALSE, header  =  FALSE)

colnames(d) <- c('Status_of_existing_checking_account', 'Duration_in_month',
                 'Credit_history', 'Purpose', 'Credit_amount', 'Savings_account_bonds',
                 'Present_employment_since',
                 'Installment_rate_in_percentage_of_disposable_income',
                 'Personal_status_and_sex', 'Other_debtors_guarantors',
                 'Present_residence_since', 'Property', 'Age_in_years',
                 'Other_installment_plans', 'Housing',
                 'Number_of_existing_credits_at_this_bank', 'Job',
                 'Number_of_people_being_liable_to_provide_maintenance_for',
                 'Telephone', 'foreign_worker', 'Good_Loan')
str(d)
## 'data.frame':    1000 obs. of  21 variables:
##  $ Status_of_existing_checking_account                     : chr  "A11" "A
     12" "A14" "A11" ...
##  $ Duration_in_month                                       : int  6 48 12
     42 24 36 24 36 12 30 ...
##  $ Credit_history                                          : chr  "A34" "A
     32" "A34" "A32" ...
##  $ Purpose                                                 : chr  "A43" "A
     43" "A46" "A42" ...
##  $ Credit_amount                                           : int  1169 595
     1 2096 7882 4870 9055 2835 6948 3059 5234 ...
##  $ Savings_account_bonds                                   : chr  "A65" "A
     61" "A61" "A61" ...
##  $ Present_employment_since                                : chr  "A75" "A
     73" "A74" "A74" ...
##  $ Installment_rate_in_percentage_of_disposable_income     : int  4 2 2 2
     3 2 3 2 2 4 ...
##  $ Personal_status_and_sex                                 : chr  "A93" "A
     92" "A93" "A93" ...
##  $ Other_debtors_guarantors                                : chr  "A101" "
     A101" "A101" "A103" ...
##  $ Present_residence_since                                 : int  4 2 3 4
     4 4 4 2 4 2 ...
##  $ Property                                                : chr  "A121" "
     A121" "A121" "A122" ...
##  $ Age_in_years                                            : int  67 22 49
     45 53 35 53 35 61 28 ...
##  $ Other_installment_plans                                 : chr  "A143" "
     A143" "A143" "A143" ...
##  $ Housing                                                 : chr  "A152" "
     A152" "A152" "A153" ...
##  $ Number_of_existing_credits_at_this_bank                 : int  2 1 1 1
     2 1 1 1 1 2 ...
##  $ Job                                                     : chr  "A173" "
     A173" "A172" "A173" ...
##  $ Number_of_people_being_liable_to_provide_maintenance_for: int  1 1 2 2
     2 2 1 1 1 1 ...
##  $ Telephone                                               : chr  "A192" "
     A191" "A191" "A191" ...
##  $ foreign_worker                                          : chr  "A201" "
     A201" "A201" "A201" ...
##  $ Good_Loan                                               : int  1 2 1 1
     2 1 1 1 1 2 ...

c() 命令是 R 构造向量的方法.^([12]) 我们直接从数据集文档中复制了列名。通过将我们的名称向量分配给数据框的 colnames(),我们已经将数据框的列名重置为有意义的名称。

¹²

c() 还可以连接列表或向量,而不引入额外的嵌套。


分配给访问器

在 R 中,数据框类有多个数据访问器,如 colnames()names()。我们像在 列表 2.3 中使用 colnames(d) <- c('Status_of_existing_checking_account', ...) 分配新名称时看到的那样,许多这些数据访问器可以被分配。这种将值分配给访问器的功能在 R 中有点不寻常,但是一个非常有用的特性。


数据文档还告诉我们列名,并且还有一个所有神秘A-代码含义的代码字典。例如,它说在第 4 列(现在称为Purpose*,表示贷款的目的)中,代码A40是“新车贷款”,A41是“二手车贷款”,等等。我们可以使用 R 的列表映射功能将值重新映射到更描述性的术语。文件 PDSwR2/Statlog/GCDSteps.Rmd 是一个 R Markdown 文件,它包含了到目前为止的所有步骤,并将A#形式的值重新映射到更清晰的名字。该文件首先将数据集文档的值映射实现为一个 R 命名向量。这使得我们可以将难以辨认的名字(如A11)改为更有意义的描述(如... < 0 DM,这本身可能是“报告零或更少德国马克”的缩写)。^([13]) 此映射定义的前几行如下所示:

¹³

数据收集时的德国货币是德国马克(DM)。

mapping <- c('A11' = '... < 0 DM',
             'A12' = '0 <= ... < 200 DM',
             'A13' = '... >= 200 DM / salary assignments for at least 1 year',
             ...
                )

注意:在构建命名映射时,必须使用参数绑定符号=,而不是任何赋值运算符,如<-

定义了映射列表后,我们可以使用以下 for 循环将每列中类型为character的值从原始的神秘A-*代码转换为直接从数据文档中获取的简短级别描述。当然,对于包含数值数据的列,我们跳过任何此类转换。

列表 2.5. 转换汽车数据

source("mapping.R")                             ❶
for(ci in colnames(d)) {                        ❷
    if(is.character(d[[ci]])) {
      d[[ci]] <- as.factor(mapping[d[[ci]]])    ❸
    }
}

❶ 此文件可在github.com/WinVector/PDSwR2/blob/master/Statlog/mapping.R找到。

❷ 建议使用列名而不是列索引。

❸ 使用[[ [ ]] ]]表示法是利用数据框是列命名的列表这一事实。因此,我们依次处理每一列。请注意,映射查找是向量化的:它一次性应用于列中的所有元素。

正如我们提到的,完整的列准备集在 R Markdown 文件 PDSwR2/Statlog/GCDSteps.Rmd 中。我们鼓励读者检查此文件并尝试所有这些步骤。为了方便,准备好的数据已保存在 PDSwR2/Statlog/creditdata.RDS 中。

检查我们的新数据

我们现在可以轻松地使用命令print(d[1:3,'Purpose'])检查前三笔贷款的目的。我们可以使用summary(d$Purpose)查看贷款目的的分布。这就是为什么我们将值转换为因子,因为summary()对于字符串/字符类型报告不多,尽管我们也可以直接在字符类型上使用table(d$Purpose, useNA = "always")。我们还可以开始研究贷款类型与贷款结果之间的关系,如下面的列表所示。

列表 2.6. Good_LoanPurpose的摘要

setwd("PDSwR2/Statlog")                   ❶
 d <- readRDS("creditdata.RDS")           ❷

table(d$Purpose, d$Good_Loan)

##                       BadLoan GoodLoan
##   business                 34       63
##   car (new)                89      145
##   car (used)               17       86
##   domestic appliances       4        8
##   education                22       28
##   furniture/equipment      58      123
##   others                    5        7
##   radio/television         62      218
##   repairs                   8       14
##   retraining                1        8

❶ 设置工作目录。您需要将 PDSwR2/Statlog 替换为您机器上 Statlog 的实际完整路径。

❷ 读取准备好的 statlog 数据

从输出中,我们可以看到我们已经成功地将数据从文件中加载。然而,正如之前提到的,大量数据存储在其他来源,如 Excel 电子表格(使用readxl包,这些可以像处理文件一样处理)和数据库中(包括 Apache Spark 等大数据系统)。接下来,我们将讨论通过 SQL 查询语言和 DBI 包与关系数据库一起工作。

2.3. 与关系数据库一起工作

在许多生产环境中,你想要的数据存储在关系或 SQL 数据库中,而不是文件中。公共数据通常存储在文件中(因为它们更容易共享),但你的最重要的客户数据通常存储在数据库中。关系数据库可以轻松扩展到数亿条记录,并提供重要的生产功能,如并行处理、一致性、事务、日志和审计。关系数据库旨在支持在线事务处理(OLTP),因此它们很可能是你需要了解的交易实际产生的位置。

通常,你可以将数据导出为结构化文件,然后使用我们之前章节中的方法将数据传输到 R 中。但这种方法通常不是处理数据的正确方式。由于模式信息丢失、转义、引号和字符编码问题,从数据库导出到文件通常不可靠且具有特殊性。与数据库中找到的数据的最佳工作方式是直接将 R 连接到数据库,这正是我们将在本节中演示的内容。

作为演示的一部分,我们首先将展示如何将数据加载到数据库中。关系数据库是进行诸如连接或采样等转换的好地方(尽管sqldfdplyr等包为 R 提供了类似的功能),这将是第五章的主题。我们将从数据库中的数据开始我们的下一个示例。

2.3.1. 一个生产规模的示例

对于我们的生产规模示例,我们将使用 2016 年美国人口普查美国社区调查(ACS)公共使用微观数据样本(PUMS)数据,通常称为“ACS PUMS”。我们在字典PDSwR2/PUMS/download中有关于如何下载和准备该数据样本的文档。我们还有一个准备好的 R 数据文件 PDSwR2/PUMS/PUMSsample.RDS,允许你跳过初始下载和处理步骤。

PUMS 数据非常适合设置相对现实的数据科学场景:汇总数据和构建模型,从其他列预测数据的一列。我们将在本书的后面部分回到这个数据集。

PUMS 是一组非常出色的数据,涉及大约 300 万人和 150 万个家庭。它是少数几个共享的美国人口普查数据集之一,涉及个人和家庭(而不是按地区汇总)。这一点很重要,因为大多数常见的数据科学任务都是设计用来使用详细的个人记录,因此这是最像数据科学家会处理的数据的公共数据。每一行包含关于每个个人或家庭的 200 多个事实(收入、就业、教育、房间数量等)。数据包含家庭交叉引用 ID,因此个人可以与所在的 household 关联。数据集的大小很有趣:压缩后只有几吉字节。因此,它足够小,可以存储在良好的网络上或闪存驱动器上,但比在笔记本电脑上使用 R 进行内存操作(在处理数十万行数据时更为舒适)更方便。


摘要或边缘数据

从面向个人的数据到摘要或边缘数据的转换是一个简单的过程,称为 汇总统计基本分析。反向转换通常是不可能的,或者最多是一个深度的统计问题(超出了基本数据科学的范围)。大多数美国人口普查数据都是以地区汇总的形式共享的,因此通常需要复杂的统计插补方法来生成有用的个人级预测模型。PUMS 数据非常有用,因为它面向个人。


数百万行数据是关系数据库或 SQL 辅助的单机分析的理想大小。我们还没有被迫进入数据库集群或 Apache Spark 集群来完成我们的工作。

管理数据

科学的一个硬性规则是,你必须能够重现你的结果。至少,你应该能够通过你记录的步骤重复你自己的成功工作。一切都必须有如何生产的说明或清晰的来源文档。我们称这为“无外来物品”的学科。例如,当我们说我们正在使用 PUMS 美国社区调查数据时,这个声明并不足够精确,以至于任何人都不清楚我们具体指的是什么数据。我们关于 PUMS 数据的实际笔记本条目(我们将其保存在线上,以便我们可以搜索)将在下一列表中展示。

列表 2.7. PUMS 数据来源文档(PDSwR2/PUMS/download/LoadPUMS.Rmd)

Data downloaded 4/21/2018 from:Reduce Zoom                               ❶

  https://www.census.gov/data/developers/data-sets/acs-1year.2016.html   ❷
   https://www.census.gov/programs-surveys/acs/
technical-documentation/pums.html
  http://www2.census.gov/programs-surveys/acs/tech_docs/pums/data_dict/PUMSDataDict16.txt
  https://www2.census.gov/programs-surveys/acs/data/pums/2016/1-Year/

First in a `bash` shell perform the following steps:

wget https://www2.census.gov/programs-surveys/acs/data/
pums/2016/1-Year/csv_hus.zip                                           ❸
 md5 csv_hus.zip
# MD5 (csv_hus.zip) = c81d4b96a95d573c1b10fc7f230d5f7a                   ❹
 wget https://www2.census.gov/programs-surveys/acs/data/pums/2016/1-Year/csv_pus.zip
md5 csv_pus.zip
# MD5 (csv_pus.zip) = 06142320c3865620b0630d74d74181db
wget http://www2.census.gov/programs-
     surveys/acs/tech_docs/pums/data_dict/PUMSDataDict16.txt
md5 PUMSDataDict16.txt
# MD5 (PUMSDataDict16.txt) = 56b4e8fcc7596cc8b69c9e878f2e699aunzip csv_hus.zip

❶ 当我们下载数据时

❷ 我们找到数据文档的地方。记录这一点很重要,因为许多数据文件不包含指向文档的链接。

❸ 我们采取的精确步骤

❹ 我们下载的文件内容的加密哈希值。这些是非常短的摘要(称为哈希值),不同文件具有相同值的可能性极低。这些摘要可以后来帮助我们确定我们组织中的另一位研究人员是否正在使用相同的数据。


记录笔记

成为数据科学家的一个重要部分是能够捍卫你的结果并重复你的工作。我们强烈建议保留数据的本地副本并保持一个笔记本。注意,在列表 2.7 中,我们不仅展示了我们如何以及何时获取数据,还展示了当时下载的加密哈希值。这对于确保可重复的结果以及诊断是否以及在哪里发生了变化非常重要。我们还强烈建议将所有脚本和代码置于版本控制之下,我们将在第十一章中讨论。你绝对需要能够确切回答上周你展示的结果使用了哪些代码和哪些数据。

特别重要的笔记维护形式是使用 Git 源代码控制,我们将在第十一章中讨论。


从 PUMS 数据开始

至少浏览一下下载的 PUMS 数据文档很重要:PDSwR2/PUMS/ACS2016_PUMS_README.pdf(一个位于下载的 zip 容器中的文件)和 PDSwR2/PUMS/PUMSDataDict16.txt(我们下载的文件之一)。三个突出点:数据以逗号分隔的格式化文件形式分布,值以难以辨认的整数编码(类似于我们早期的Statlog示例),个人被加权以代表不同数量的额外家庭。R Markdown^([14])脚本 PDSwR2/PUMS/download/LoadPUMS.Rmd 读取 CSV 文件(从一个压缩的中间文件中),将值重新编码为更有意义的字符串,并使用与指定家庭抽样权重成比例的概率对数据进行伪随机抽样。成比例的抽样不仅将文件大小减少到大约 10 兆字节(一个易于通过 GitHub 分发的尺寸),而且构建了一个可以以统计正确的方式使用的样本,无需进一步参考人口普查权重。

^((14))

我们将在本书的后面讨论 R Markdown。它是一种重要的格式,可以一起存储 R 代码和文本文档。


抽样

当我们说“伪随机样本”时,我们只是指由 R 的伪随机数生成器构建的样本。R 的随机数生成器被称为“伪随机”,因为它实际上是一系列希望难以预测的选择,其行为与真正的随机不可预测样本非常相似。伪随机样本易于处理,因为它们是可重复的:用相同的种子启动伪随机生成器,你将得到相同的序列选择。在数字计算机广泛可用之前,统计学家通常通过使用预先准备好的表格(如兰德公司 1955 年的书籍《一百万个随机数字及十万个正态偏差》)来实现这种可重复性。目的是使随机样本具有与总体非常相似的属性。你正在处理的特征越常见,这种情况就越有可能发生。

注意:在伪随机实验的可重复性方面必须小心谨慎。许多因素可能会干扰伪随机样本和结果的精确再现。例如,使用不同的操作顺序可能会产生不同的结果(尤其是在并行算法的情况下),而且 R 在从版本 3.5.(本书的准备工作所使用的版本)迁移到 3.6.(R 的下一个版本)时,其伪随机数的细节也发生了变化。与浮点表示法类似,有时我们必须接受等效结果而不是完全相同的结果。


在数百万行数据的规模上,结构化数据最好在数据库中处理,尽管 R 和data.table包在这个规模上也能很好地工作。我们将通过将我们的 PUMS 样本复制到内存数据库中来模拟在数据库中处理数据,如下所示。

列表 2.8. 从关系数据库将数据加载到 R 中

library("DBI")
library("dplyr")                                           ❶
library("rquery")

dlist <- readRDS("PUMSsample.RDS")                         ❷
db <- dbConnect(RSQLite::SQLite(), ":memory:")             ❸
dbWriteTable(db, "dpus", as.data.frame(dlist$ss16pus))     ❹
dbWriteTable(db, "dhus", as.data.frame(dlist$ss16hus)) 
rm(list = "dlist")                                         ❺

dbGetQuery(db, "SELECT * FROM dpus LIMIT 5")               ❻

dpus <- tbl(db, "dpus")                                    ❻
dhus <- tbl(db, "dhus")

print(dpus)                                                ❽
glimpse(dpus)

View(rsummary(db, "dpus"))                                 ❾

❶ 附上我们希望使用命令和函数的一些包。

❷ 将压缩的 RDS 磁盘格式中的数据加载到 R 内存中。注意:您需要将路径 PUMSsample 更改为您保存 PDSwR2/PUMS 内容的路径。

❸ 连接到一个新的 RSQLite 内存数据库。我们将使用 RSQLite 作为我们的示例。在实际应用中,你会连接到一个预存在的数据库,例如 PostgreSQL 或 Spark,以及预存在的表。

❹ 将内存中的结构 dlist 中的数据复制到数据库中

❺ 删除我们的本地数据副本,因为我们正在模拟在数据库中找到数据

❻ 使用 SQL 查询语言快速查看我们数据的前五行

❻ dplyr 构建的引用远程数据库数据的处理程序

❽ 使用 dplyr 检查和操作远程数据

❾ 使用 rquery 包获取远程数据的摘要

在这个列表中,我们故意没有显示命令产生的任何结果,因为我们希望你自己尝试这个示例。


代码示例

本书中的所有代码示例都可在 PDSwR2/CodeExamples 目录中找到。从该目录中取代码可能比重新输入它更容易,也比从书的电子副本中复制粘贴更可靠(避免页面中断、字符编码和格式错误等问题)。


注意,虽然这些数据量不大,但已经超出了使用电子表格方便的范围。使用 dim(dlist$ss16hus)dim(dlist$ss16pus)(在 rm() 步骤之前或重新加载数据之后),我们看到我们的家庭样本有 50,000 行和 149 列,而个人样本有 109,696 行和 203 列。所有列和值代码都在人口普查文档中定义。此类文档至关重要,我们在 PDSwR2/PUMS 中提供了文档链接。

检查和调整 PUMS 数据

将数据加载到 R 中的目的是为了便于建模和分析。数据分析师应该始终“手握数据”,并在加载数据后快速查看数据。作为我们的示例,我们将演示如何快速检查一些 PUMS 的列或字段。

PUMS 数据的每一行代表一个单独的匿名个人或家庭。记录的个人数据包括职业、教育水平、个人收入以及许多其他人口统计变量。我们在列表 2.8 中加载了数据,但在继续之前,让我们讨论一下数据集中及其文档中的一些列:

  • 年龄— 在 AGEP 列中找到的整数

  • 就业类别— 例如:营利性公司、非营利性公司等,在 COW 列中找到

  • 教育水平— 例如:无高中文凭、高中、大学等,在 SCHL 列中找到

  • 总收入— 在 PINCP 列中找到

  • 工人性别— 在 SEX 列中找到

我们将构建一个示例问题,将收入(在字段中以美元表示)与这些变量相关联。这是一个典型的预测建模任务:将一些我们知道其值的变量(年龄、就业等)与一个我们希望知道的变量(在这种情况下,收入)相关联。这个任务是一个监督学习的例子,这意味着我们使用一个数据集,其中可观察的变量(在统计学中称为“独立变量”)和未观察到的结果(或“因变量”)都同时可用。您通常通过购买数据、雇佣标注者或使用您已有时间观察所需结果的老数据来获得此类标记数据。


不要过于骄傲自满而采样

许多数据科学家花费太多时间调整算法以直接处理大数据。通常这是徒劳的努力,因为对于许多模型类型,你会在合理大小的数据样本上得到几乎完全相同的结果。只有在你所建模的内容不适合采样时,你才需要处理“所有你的数据”,例如在描述罕见事件或在社会网络上执行链接计算时。


我们不想在示例问题的非人工方面花费太多时间;我们的目标是说明建模和数据处理的程序。结论非常依赖于数据条件的选择(你使用的数据的子集)和数据编码(你如何将记录映射到信息符号)。这就是为什么经验科学论文有一个强制性的“材料和方法”部分,描述了数据是如何选择和准备的。我们的数据处理是通过限制子集以满足以下所有条件来选择“典型的全职工人”子集:

  • 自称全职员工的工人

  • 每周至少报告 30 小时活动的工人

  • 18 至 65 岁的工人

  • 年收入在 1,000 美元到 250,000 美元之间的工人。

以下列表显示了限制我们所需数据子集的代码。继续使用我们的列表 2.8 中的数据,我们按照列表 2.9 中的说明进行操作。由于我们的数据量很小(只是 PUMS 的一个样本),我们使用 DBI 包将数据带入 R,以便我们可以处理它。

列表 2.9. 从数据库加载数据

dpus <- dbReadTable(db, "dpus")                        ❶

dpus <- dpus[, c("AGEP", "COW", "ESR",  "PERNP",
                 "PINCP","SCHL", "SEX", "WKHP")]       ❷

for(ci in c("AGEP", "PERNP", "PINCP", "WKHP")) {       ❸
   dpus[[ci]] <- as.numeric(dpus[[ci]])
}

dpus$COW <- strtrim(dpus$COW, 50)                      ❹

str(dpus)                                              ❺

❶ 将数据从数据库复制到 R 内存中。这假设我们正在继续上一个示例,所以我们附加的包仍然可用,数据库句柄 db 仍然有效。

❷ 在这个 PUMS 数据副本中,所有列都存储为字符类型,以保留原始数据中的特征,例如前导零。在这里,我们将希望作为数值处理的列转换为数值类型。非数值值,通常是缺失条目,用符号 NA 编码,代表不可用。

❸ 选择我们想要工作的列的子集。限制列不是必需的,但可以提高后续打印的可读性。

❹ PUMS 级别的名称非常长(这也是这些列被分配为整数的原因之一),因此对于这个具有级别名称而不是级别代码的数据集,我们将就业代码缩短到不超过 50 个字符。

❺ 以列方向查看数据的前几行。


注意 NA 值

R 表示空白或缺失数据的是NA。不幸的是,许多 R 命令在没有任何警告的情况下静默地跳过 NA。命令table(dpus$COW, useNA = 'always')将显示 NA,就像summary(dpus$COW)做的那样。


我们现在已经执行了一些标准的数据分析步骤:加载数据,重新处理几个列,并查看数据。这些步骤是使用我们所说的“基础 R”来执行的,这意味着使用来自 R 语言本身和自动附加的基本包(如basestatsutils)的功能和函数。R 非常适合数据处理任务,因为这是大多数用户来到 R 要做的事情。还有一些扩展包,如dplyr,它们有自己的数据处理符号,可以直接对数据库中的数据进行许多步骤,同时还能在内存中处理数据。我们在 R Markdown 示例 PDSwR2/PUMS/PUMS1.Rmd 中展示了如何使用基础 R 执行相同的数据处理步骤,或者在 PDSwR2/PUMS/PUMS1_dplyr.Rmd 中使用dplyr,或者在 PDSwR2/PUMS/PUMS1_rquery.Rmd 中使用高级查询生成包rquery

我们现在可以开始处理我们的假设问题列表 2.10:根据个人已知的其他事实来描述收入。我们将从一些领域特定的步骤开始:我们将重新映射一些级别名称并将级别转换为因子,每个因子都有一个选择的参考级别。因子是从指定集合中取出的字符串(类似于其他语言中的枚举类型)。因子还有一个特殊级别,称为参考级别;惯例是每个级别都被认为是与参考级别的差异。例如,我们将所有低于学士学位的教育水平设置为一个新的级别,称为无高级学位,并将无高级学位作为我们的参考级别。一些 R 建模函数将根据与参考级别无高级学位的差异来评分教育水平,如硕士学位。这将在我们的示例中变得清晰。

列表 2.10. 重新映射值和从数据中选择行

target_emp_levs <- c(                                        ❶
  "Employee of a private for-profit company or busine",
  "Employee of a private not-for-profit, tax-exempt, ",
  "Federal government employee",
  "Local government employee (city, county, etc.)",
  "Self-employed in own incorporated business, profes",
  "Self-employed in own not incorporated business, pr",
  "State government employee")

complete <- complete.cases(dpus)                             ❷

stdworker <- with(dpus,                                      ❸
                   (PINCP>1000) &
                    (ESR=="Civilian employed, at work") &
                    (PINCP<=250000) &
                    (PERNP>1000) & (PERNP<=250000) &
                    (WKHP>=30) &
                    (AGEP>=18) & (AGEP<=65) &
                    (COW %in% target_emp_levs))

dpus <- dpus[complete & stdworker, , drop = FALSE]           ❹

no_advanced_degree <- is.na(dpus$SCHL) |                     ❺
   (!(dpus$SCHL %in% c("Associate's degree",
                      "Bachelor's degree",
                      "Doctorate degree",
                      "Master's degree",
                      "Professional degree beyond a bachelor's degree")))
dpus$SCHL[no_advanced_degree] <- "No Advanced Degree"

dpus$SCHL <- relevel(factor(dpus$SCHL),                      ❻
                      "No Advanced Degree")
dpus$COW <- relevel(factor(dpus$COW),
                    target_emp_levs[[1]])
dpus$ESR <- relevel(factor(dpus$ESR),
                    "Civilian employed, at work")
dpus$SEX <- relevel(factor(dpus$SEX),
                    "Male")

saveRDS(dpus, "dpus_std_employee.RDS")                       ❻

summary(dpus)                                                ❽

❶ 定义一个我们认为是“标准”的就业定义向量

❷ 构建一个新的逻辑向量,指示哪些行在我们所有感兴趣的列中都有有效值。在实际应用中,处理缺失值非常重要,并且不能总是通过跳过不完整的行来处理。当我们讨论数据管理时,我们将回到正确处理缺失值的问题。

❸ 构建一个新的逻辑向量,指示我们认为的典型全职员工。所有这些列名都是我们之前讨论过的。任何分析的结果都将受到这个定义的严重影响,因此,在实际任务中,我们会花很多时间研究这一步骤的选择。这实际上控制了我们研究和研究的内容。请注意,为了使事情简单和统一,我们将这项研究限制在平民中,这在完整的工作中将是不可接受的限制。

❹ 仅限于符合我们典型工人定义的行或示例

❺ 将教育重新编码,将低于学士学位的级别合并到单个级别“无高级学位”

❻ 将我们的字符串值列转换为因子,使用 relevel()函数选择参考级别

❻ 将此数据保存到文件中,以便我们可以在后面的示例中使用。此文件也已在路径 PDSwR2/PUMS/dpus_std_employee.RDS 中可用。

❽ 查看我们的数据。因子的一个优点是 summary()会为它们构建有用的计数。然而,最好是在完成重新映射级别代码后,再将字符串代码作为因子。

关于因子编码的更多内容

R 的因子类型将字符串编码为已知可能字符串集中的整数索引。例如,我们的 SCHL 列在 R 中的表示如下:

levels(dpus$SCHL)                                                          ❶
## [1] "No Advanced Degree"                            "Associate's degree"

## [3] "Bachelor's degree"                              "Doctorate degree"
## [5] "Master's degree"                                "Professional degree
     beyond a bachelor's degree"

head(dpus$SCHL)                                                            ❷
## [1] Associate's degree Associate's degree Associate's degree No Advanced D
     egree Doctorate degree Associate's degree
##   6 Levels: No Advanced Degree Associate's degree Bachelor's degree Doctor
     ate degree ... Professional degree beyond a bachelor's degree

str(dpus$SCHL)                                                             ❸
##  Factor w/ 6 levels "No Advanced Degree",..: 2 2 2 1 4 2 1 5 1 1 ...

❶ 显示 SCHL 的可能级别

❷ 显示前几个级别如何表示为代码

❸ 显示 SCHL 的前几个字符串值

非统计学家常常惊讶地发现,你可以将非数值列(如字符串或因子)用作模型的输入或变量。这可以通过多种方式实现,最常见的一种方法是称为引入指标虚拟变量的方法。在 R 中,这种编码通常是自动的且不可见的。在其他系统(如 Python 的 scikit-learn)中,分析师必须指定一个编码(通过方法名如“one-hot”)。在这本书中,我们将使用这种编码以及来自vtreat包的附加、更复杂的编码。SCHL 列可以明确地转换为基本虚拟变量,正如我们接下来所展示的。这种重新编码策略将在书中隐式和显式地使用,因此我们在这里进行演示:

d <- cbind(                                                                ❶
   data.frame(SCHL = as.character(dpus$SCHL),                              ❷
              stringsAsFactors = FALSE),
   model.matrix(~SCHL, dpus)                                               ❸
 )
d$'(Intercept)' <- NULL                                                    ❹
 str(d)                                                                    ❺

## 'data.frame':    41305 obs. of  6 variables:
##  $ SCHL                                              : chr  "Associate's d
     egree" "Associate's degree" "Associate's degree" "No Advanced Degree" ...

##  $ SCHLAssociate's degree                            : num  1 1 1 0 0 1 0
     0 0 0 ...
##  $ SCHLBachelor's degree                             : num  0 0 0 0 0 0 0
     0 0 0 ...
##  $ SCHLDoctorate degree                              : num  0 0 0 0 1 0 0
     0 0 0 ...
##  $ SCHLMaster's degree                               : num  0 0 0 0 0 0 0
     1 0 0 ...
##  $ SCHLProfessional degree beyond a bachelor's degree: num  0 0 0 0 0 0 0
     0 0 0 ...

❶ cbind 运算符通过列组合两个数据框,或者每行是通过匹配每个数据框中的行来构建的。

❷ 创建一个将 SCHL 列重新编码为字符字符串而不是因子的 data.frame

❸ 使用 SCHL 因子列生成的虚拟变量构建一个矩阵

❹ 从 data.frame 中删除名为“(Intercept)”的列,因为它是我们目前不感兴趣的 model.matrix 的副作用。

❺ 显示包含原始 SCHL 字符串形式及其指标的结构。str()以转置格式(翻转,使行现在是上下,列是左右)显示前几行。

注意,没有高级学位的参考水平没有获得列,而新的指标列有一个 1,这揭示了原始 SCHL 列中的哪个值。没有高级学位的列都是全零虚拟变量,因此我们也可以知道哪些示例具有该值。这种编码可以读作“所有零行是基准或正常情况,其他行与全零情况不同,因为有一个指标被打开(显示我们正在讨论哪种情况)。”注意,这种编码包含了原始字符串形式的所有信息,但所有列现在都是数值的(这是许多机器学习和建模过程所需的一种格式)。这种格式在许多 R 机器学习和建模函数中隐式使用,用户甚至可能没有意识到转换。

使用 PUMS 数据进行工作

到目前为止,我们已经准备好使用数据来练习我们的问题。正如我们所见,summary(dpus) 已经给出了我们数据集中每个变量的分布信息。我们还可以使用一些汇总命令查看变量之间的关系:tapply()table()。例如,要查看按教育水平和性别同时分解的示例计数,我们可以输入命令 table(schooling = dpus$SCHL, sex = dpus$SEX)。要按相同方式获取平均收入,我们可以使用命令 tapply(dpus$PINCP, list(dpus$SCHL, dpus$SEX), FUN = mean)

table(schooling = dpus$SCHL, sex = dpus$SEX)                            ❶

##                                                 sex
## schooling                                         Male Female
##   No Advanced Degree                             13178   9350
##   Associate's degree                              1796   2088
##   Bachelor's degree                               4927   4519
##   Doctorate degree                                 361    269
##   Master's degree                                 1792   2225
##   Professional degree beyond a bachelor's degree   421    379

tapply(                                                                 ❷
    dpus$PINCP,                                                         ❸
    list(dpus$SCHL, dpus$SEX),                                          ❹
    FUN = mean                                                          ❺
    )

##                                                     Male   Female
## No Advanced Degree                              44304.21 33117.37
## Associate's degree                              56971.93 42002.06
## Bachelor's degree                               76111.84 57260.44
## Doctorate degree                               104943.33 89336.99
## Master's degree                                 94663.41 69104.54
## Professional degree beyond a bachelor's degree 111047.26 92071.56

❶ 使用 table 命令来统计每个 SCHL 和 SEX 配对的频率

❷ 使用 tapply 来统计每个 SCHL 和 SEX 配对的频率

❸ 此参数是我们正在 tapply 中聚合或汇总的数据向量。

❹ 此参数列表指定了数据的分组方式,在这种情况下是同时按 SCHL 和 SEX 分组。

❺ 此参数指定了我们如何聚合值;在这种情况下,我们使用 mean 函数计算平均值。

dplyr 语法中的相同计算如下:

library("dplyr")

dpus %>%
  group_by(., SCHL, SEX)  %>%
  summarize(.,
            count = n(),
            mean_income = mean(PINCP)) %>%
  ungroup(.) %>%
  arrange(., SCHL, SEX)

## # A tibble: 12 x 4
##    SCHL                                           SEX    count mean_income
##    <fct>                                          <fct>  <int>       <dbl>
##  1 No Advanced Degree                             Male   13178      44304.
##  2 No Advanced Degree                             Female  9350      33117.
##  3 Associate's degree                             Male    1796      56972.
##  4 Associate's degree                             Female  2088      42002.
##  5 Bachelor's degree                              Male    4927      76112.
##  6 Bachelor's degree                              Female  4519      57260.
##  7 Doctorate degree                               Male     361     104943.
##  8 Doctorate degree                               Female   269      89337.
##  9 Master's degree                                Male    1792      94663.
## 10 Master's degree                                Female  2225      69105.
## 11 Professional degree beyond a bachelor's degree Male     421     111047.
## 12 Professional degree beyond a bachelor's degree Female   379      92072.

dplyr 管道将任务表示为基本数据转换的序列。注意,tapply() 的结果是在所谓的宽格式(数据单元格按行和列键)中,而 dplyr 的输出是在高格式(数据单元格按每行的关键列键)中。

我们甚至可以绘制关系图,如图 列表 2.11 所示。最后,如果我们想同时估计所有其他变量的收入模型,我们可以尝试回归分析,这是第八章的主题。第五章 覆盖了在转换此类格式时的关键主题。

列表 2.11. 绘制数据

WVPlots::ScatterHist(
  dpus, "AGEP", "PINCP",
  "Expected income (PINCP) as function age (AGEP)",
  smoothmethod = "lm",
  point_alpha = 0.025)

这是一个值得庆祝的时刻,因为我们终于实现了数据科学的目标。在 图 2.3 中,我们正在查看数据和数据中的关系。在图中解释汇总信息的技术任务将在第八章第八章 中介绍。

图 2.3. 收入(PINCP)随年龄(AGEP)的变化散点图

图片

在本书中,我们将多次回到人口普查数据,并展示更复杂的建模技术。在所有情况下,我们都在通过这些示例来展示在实际处理数据时遇到的基本挑战,并介绍一些准备好的 R 工具来帮助。作为后续步骤,我们强烈建议运行这些示例,查阅所有这些函数的 help(),并在线搜索官方文档和用户指南。

摘要

在本章中,我们探讨了如何最初提取、转换和加载数据以进行分析的基本方法。对于小型数据集,我们使用 R 和在内存中执行转换。对于大型数据集,我们建议使用 SQL 数据库,甚至使用如 Spark(通过 sparklyr 包加上 SQLdplyrrquery)这样的大数据系统。在任何情况下,我们都将所有转换步骤保存为代码(无论是 SQL 还是 R),以便在数据刷新时可以重用。本章的目的是为下一章中实际有趣的工作做准备:探索、管理、纠正和建模数据。

R 被构建来处理数据,将数据加载到 R 中的目的是检查和处理它。在第三章中,我们将演示如何通过汇总、探索和绘图来描述您的数据。这些是在任何建模努力早期的重要步骤,因为这些步骤可以帮助您了解您希望建模的实际问题和性质。

在本章中,您已经学到了

  • 数据框,其每一行代表一个实例,每一列代表一个变量或测量值,是数据分析中首选的数据结构。

  • 使用 utils::read.table()readr 包将小型、结构化数据集加载到 R 中。

  • DBI 包允许您使用 SQLdplyrrquery 中的任何一种直接与数据库或 Apache Spark 工作。

  • R 被设计成以高级步骤与数据工作,并拥有许多现成的、用于数据转换的命令和函数。通常,如果在 R 中某个任务变得困难,那是因为您不小心尝试用低级编程步骤重新实现高级数据转换。

第三章. 探索数据

本章涵盖

  • 使用汇总统计来探索数据

  • 使用可视化探索数据

  • 在数据探索过程中发现问题和问题

在前两章中,您学习了如何设定数据科学项目的范围和目标,以及如何在 R 中开始处理您的数据。在本章中,您将开始深入数据。如图 3.1 所示的心理模型(图 3.1),本章强调在建模步骤之前探索数据的科学。您的目标是拥有尽可能干净和有用的数据。


示例

假设您的目标是构建一个模型来预测哪些客户没有健康保险。您收集了一个您知道其健康保险状态的客户数据集。您还确定了一些客户属性,您认为这些属性有助于预测保险覆盖率的概率:年龄、就业状态、收入、居住和车辆信息等。


图 3.1. 第三章 心理模型

您已经将所有数据放入了一个名为 customer_data 的单个数据框中,并将其输入到 R 中.^([1]) 现在您已经准备好开始构建模型以识别您感兴趣的客户。

¹

我们可以从github.com/WinVector/PDSwR2/tree/master/Custdata下载这个合成数据集,一旦保存,您可以使用命令customer_data <- readRDS("custdata.RDS")将其加载到 R 中。这个数据集是从您在第二章中看到的普查数据派生出来的。我们在age变量中引入了一些噪声,以反映在现实世界噪声数据集中通常看到的情况。我们还包含了一些可能与我们示例场景不相关的列,但这些列展示了某些重要的数据异常。

很容易在没有仔细查看数据集的情况下直接进入建模步骤,尤其是当您有大量数据时。抵制这种诱惑。没有数据集是完美的:您将缺少一些客户的信息,并且关于其他客户的数据可能是不正确的。一些数据字段将是杂乱无章和不一致的。如果您在开始建模之前不花时间检查数据,您可能会发现自己反复重做工作,因为您发现了需要转换的坏数据字段或变量。在最坏的情况下,您将构建一个返回错误预测的模型——您也不会知道为什么。


在建模之前了解您的数据

通过早期解决数据问题,您可以节省一些不必要的劳动,并避免很多头痛!


您还希望了解您的客户是谁。他们是年轻人、中年人还是老年人?他们的富裕程度如何?他们住在哪里?了解这些问题的答案可以帮助您构建更好的模型,因为您将有一个更具体的概念,了解哪些信息最准确地预测了保险覆盖的概率。

在本章中,我们将演示一些了解数据的方法,并讨论您在探索过程中寻找的一些潜在问题。数据探索结合了描述性统计——均值和中位数、方差和计数——以及可视化,即数据的图表。您只需使用描述性统计就能发现一些问题;其他问题则更容易通过视觉发现。


组织数据以进行分析

在本书的大部分内容中,我们将假设您正在分析的数据在一个单一的数据框中。数据通常不是这样存储的。例如,在数据库中,数据通常以规范化形式存储以减少冗余:关于单个客户的信息分布在许多小的表中。在日志数据中,关于单个客户的信息可以分布在许多日志条目或会话中。这些格式使得添加(或,在数据库的情况下,修改)数据变得容易,但不是分析的最佳选择。您通常可以使用 SQL 将所有需要的数据合并到一个数据库中的单个表中,但在第五章中,我们将讨论您可以在 R 中使用以进一步合并数据的join等命令。


3.1. 使用描述性统计来发现问题

在 R 中,您通常会使用summary()命令来首次查看数据。目标是了解您是否有可以帮助预测健康保险覆盖的客户信息,以及数据是否足够好,足以提供信息。1]

¹

如果您还没有这样做,我们建议您按照附录 A 中的章节 A.1 中的步骤安装 R、包、工具和本书的示例。

列表 3.1. summary()命令

setwd("PDSwR2/Custdata")                                                 ❶
customer_data = readRDS("custdata.RDS")
summary(customer_data)
##     custid              sex        is_employed       income           ❷
##  Length:73262       Female:37837   FALSE: 2351   Min.   :  -6900
##  Class :character   Male  :35425   TRUE :45137   1st Qu.:  10700
##  Mode  :character                  NA's :25774   Median :  26200
##                                                  Mean   :  41764
##                                                  3rd Qu.:  51700
##                                                  Max.   :1257000
##
##             marital_status  health_ins                                ❸
##  Divorced/Separated:10693   Mode :logical
##  Married           :38400   FALSE:7307
##  Never married     :19407   TRUE :65955
##  Widowed           : 4762
##
##
##
##                        housing_type   recent_move      num_vehicles   ❹
##  Homeowner free and clear    :16763   Mode :logical   Min.   :0.000
##  Homeowner with mortgage/loan:31387   FALSE:62418     1st Qu.:1.000
##  Occupied with no rent       : 1138   TRUE :9123      Median :2.000
##  Rented                      :22254   NA's :1721      Mean   :2.066
##  NA's                        : 1720                   3rd Qu.:3.000
##                                                       Max.   :6.000
##                                                       NA's   :1720
##       age               state_of_res     gas_usage                    ❺
##  Min.   :  0.00   California  : 8962   Min.   :  1.00
##  1st Qu.: 34.00   Texas       : 6026   1st Qu.:  3.00
##  Median : 48.00   Florida     : 4979   Median : 10.00
##  Mean   : 49.16   New York    : 4431   Mean   : 41.17
##  3rd Qu.: 62.00   Pennsylvania: 2997   3rd Qu.: 60.00
##  Max.   :120.00   Illinois    : 2925   Max.   :570.00
##                   (Other)     :42942   NA's   :1720

❶ 将此路径更改为您实际解压 PDSwR2 的目录路径

❷ 变量 is_employed 大约有三分之一的缺失值。变量 income 有负值,这些值可能是无效的。

❸ 大约 90%的客户有健康保险。

❹ 变量 housing_type、recent_move、num_vehicles 和 gas_usage 中每个变量都缺失 1720 或 1721 个值。

❺ 变量 age 的平均值看起来合理,但最小值和最大值似乎不太可能。变量 state_of_res 是一个分类变量;summary()报告了每个州(对于前几个州)有多少客户。

在数据框上使用 summary() 命令会报告数据框数值列的各种摘要统计信息,以及任何分类列的计数统计信息(如果分类列已经作为因子读入^([1]))。

¹

在 R 中,分类变量属于 factor 类。它们可以用字符串(character 类)表示,并且一些分析函数会自动将字符串变量转换为因子变量。为了得到一个有用的分类变量总结,它需要是一个因子。

如列表 3.1 所示,数据的总结可以帮助你快速发现潜在问题,如缺失数据或不可能的值。你还可以大致了解分类数据的分布情况。让我们更详细地讨论一下你可以通过总结发现的典型问题。

3.1.1. 数据总结揭示的典型问题

在这个阶段,你正在寻找几个常见问题:

  • 缺失值

  • 无效值和异常值

  • 数据范围过宽或过窄

  • 数据的单位

让我们详细讨论这些问题中的每一个。

缺失值

一些缺失值可能并不是真正的问题,但如果某个特定数据字段大部分未填充,在没有一些修复的情况下不应作为输入使用(我们将在第 4.1.2 节中讨论)。例如,在 R 中,许多建模算法默认会静默地丢弃包含缺失值的行。正如你在下面的列表中看到的,is_employed 变量中的所有缺失值可能导致 R 静默地忽略超过三分之二的数据。

列表 3.2. 变量 is_employed 对建模是否有用?

## is_employed                                            ❶
## FALSE: 2321
## TRUE :44887
## NA's :24333

##                       housing_type   recent_move       ❷
## Homeowner free and clear    :16763   Mode :logical
## Homeowner with mortgage/loan:31387   FALSE:62418
## Occupied with no rent       : 1138   TRUE :9123
## Rented                      :22254   NA's :1721
## NA's                        : 1720
##
##
##   num_vehicles     gas_usage
##  Min.   :0.000   Min.   :  1.00
##  1st Qu.:1.000   1st Qu.:  3.00
##  Median :2.000   Median : 10.00
##  Mean   :2.066   Mean   : 41.17
##  3rd Qu.:3.000   3rd Qu.: 60.00
##  Max.   :6.000   Max.   :570.00
##  NA's   :1720    NA's   :1720

❶ 变量 is_employed 缺失的数据超过三分之二。为什么?就业状态是否未知?公司是否最近才开始收集就业数据?NA 是否表示“不在活跃劳动力中”(例如,学生或全职主妇)?

❷ 变量 housing_type、recent_move、num_vehicles 和 gas_usage 缺失值相对较少——大约占数据的 2%。仅删除缺失值的行可能是安全的,尤其是如果这些缺失值都集中在同一 1720 行中。

如果某个特定数据字段大部分未填充,尝试确定原因是有意义的;有时,一个值缺失本身就有信息量。例如,为什么 is_employed 变量缺失了这么多值?正如我们注意到的,有许多可能的原因,如列表 3.2 所示。

无论缺失数据的原因是什么,您都必须决定最合适的行动。您是否要在模型中包含缺失值的变量,或者不包含?如果您决定包含它,您是删除所有该字段缺失的行,还是将缺失值转换为0或额外的类别?我们将在第四章中讨论处理缺失数据的方法。在这个例子中,您可能决定删除关于房屋或车辆的数据行,因为它们不多。您可能不想丢弃缺失就业信息的数据,因为就业状况可能是健康保险的强预测因素;您可能将 NAs 作为第三个就业类别处理。在模型评分时,您可能会遇到缺失值,因此您应该在模型训练期间处理它们。

无效值和异常值

即使某一列或变量没有缺失值,您仍然想要检查您拥有的值是否有意义。您是否有任何无效值或异常值?无效值的例子包括在应该是非负数值数据字段(如年龄或收入)中的负值或您期望数字的文本。异常值是那些远远超出您期望数据所在范围的数值点。您能否在下一个列表中找到异常值和无效值?

列表 3.3. 无效值和异常值的示例

summary(customer_data$income)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##   -6900   11200   27300   42522   52000 1257000       ❶

summary(customer_data$age)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##    0.00   34.00   48.00   49.17   62.00  120.00       ❷

❶ 收入为负值可能表明数据有误。它们也可能有特殊含义,例如“债务金额”。无论哪种情况,您都应该检查这个问题有多普遍,并决定如何处理。您是否要删除收入为负的数据?您是否要将负值转换为零?

❷ 年龄为零或年龄大于大约 110 的客户是异常值。它们超出了预期客户值的范围。异常值可能是数据输入错误。它们可能是特殊的哨兵值:零可能表示“年龄未知”或“拒绝声明”。而且,您的某些客户可能特别长寿。

通常,无效值仅仅是数据输入错误。然而,在年龄这样的字段中,负数可能是一个哨兵值,表示“未知”。异常值也可能是数据错误或哨兵值。或者,它们可能是有效但异常的数据点——人们偶尔会活过 100 岁。

与缺失值一样,您必须决定最合适的行动:删除数据字段、删除该字段有问题的数据点,或将不良数据转换为有用的值。例如,即使您确信某些异常值是有效数据,您可能仍然希望在不干扰模型拟合过程的情况下,将它们从模型构建中省略。通常,建模的目标是在典型情况下做出良好的预测,而一个高度偏向于正确预测罕见情况的模型可能并不总是最好的模型。

数据范围

你还应该注意数据中的值变化有多大。如果你认为年龄或收入有助于预测健康保险覆盖的概率,那么你应该确保你的客户在年龄和收入上有足够的差异,以便你能看到这些关系。让我们再次看看收入,在下一个列表中。数据范围是宽的还是窄的?

列表 3.4. 查看变量的数据范围

summary(customer_data$income)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##   -6900   10700   26200   41764   51700 1257000       ❶

❶ 收入范围从零到超过一百万美元,这是一个非常宽的范围。

即使忽略负收入,列表 3.4 中的 income 变量范围从零到超过一百万美元。这相当宽(尽管对于收入来说很典型)。像这样跨越几个数量级的范围的数据可能会对某些建模方法造成问题。当我们谈到第四章中的对数变换时,我们将讨论缓解数据范围问题的方法。

数据也可能太窄。假设你的所有客户年龄都在 50 到 55 岁之间。可以肯定的是,这个年龄范围可能不是预测该人群健康保险覆盖概率的好指标,因为它几乎没有任何变化。


数据范围“太窄”是什么意思?

当然,术语“窄”是相对的。如果我们预测的是 5 到 10 岁儿童的阅读能力,那么年龄可能是一个有用的变量。对于包括成人年龄的数据,你可能需要以某种方式转换或分组年龄,因为你不期望在 40 到 50 岁之间阅读能力有显著变化。你应该根据问题域的信息来判断数据范围是否狭窄,但一个粗略的经验法则是与标准差与平均值之比相关。如果这个比率非常小,那么数据变化不大。


当我们谈到图形化检查数据时,我们将在 第 3.2 节 回顾数据范围。

决定数据范围的一个因素是计量单位。以一个非技术性的例子来说,我们用周或月来衡量婴儿和幼儿的年龄,因为在这个时间尺度上,非常年幼的儿童会经历发育变化。假设我们用年来衡量婴儿的年龄。从数字上看,一岁和两岁之间可能没有太大的区别。实际上,差异是巨大的,正如任何父母都可以告诉你的那样!单位也可能因为另一个原因在数据集中引起潜在问题。

单位

列表 3.5 中的收入数据代表的是每小时工资,还是以 $1000 为单位的年工资?实际上,它是以 $1000 为单位的年工资,但如果它代表的是小时工资呢?你可能在建模阶段不会注意到这个错误,但最终有人会将小时工资数据输入模型,并得到错误的预测结果。

列表 3.5. 检查单位;错误可能导致惊人的错误

IncomeK = customer_data$income/1000
summary(IncomeK)                                        ❶
 ##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##   -6.90   10.70   26.20   41.76   51.70 1257.00

❶ 变量 IncomeK 被定义为 IncomeK = customer_data$income/1000。但假设你不知道这一点。仅通过查看摘要,这些值可能合理地解释为“时薪”或“以 1000 美元为单位的年收入。”

时间间隔是以天、小时、分钟还是毫秒来衡量的?速度是以每秒公里、每小时英里还是节来衡量的?货币金额是以美元、千美元还是 1/100 美分(在金融中这是一种习惯做法,计算通常使用定点算术)?这实际上是通过检查数据字典或文档中的数据定义来捕捉的,而不是在总结统计中;按每小时工资数据和每年工资单位为 1000 美元之间的差异在随意一瞥时可能并不明显。但在查看变量的值范围时,这仍然是一件需要记住的事情,因为通常你可以发现测量单位是否意外。汽车速度以节为单位看起来与以每小时英里为单位大不相同。

3.2. 使用图形和可视化发现问题

正如你所见,你只需查看数据摘要就能发现许多问题。对于数据的其他属性,图片比文字更好。

我们不能期望少数数值[总结统计]能够持续传达数据中存在的丰富信息。数值缩减方法并不保留数据中的信息。

威廉·克利夫兰,《数据绘图要素》

图 3.2 展示了客户年龄分布的图表。我们稍后会讨论图表的 y 轴代表什么;现在只需知道,图表的高度对应于人口中该年龄段的客户数量。正如你所见,分布的峰值年龄、数据的范围以及异常值的存在,通过视觉吸收比通过文本确定要容易得多。

图 3.2. 一些信息从图表中更容易阅读,而另一些则从摘要中更容易阅读。

图片

使用图形来检查数据被称为可视化。我们试图遵循威廉·克利夫兰的科学可视化原则。除了特定图表的细节之外,克利夫兰哲学的关键点如下:

  • 一张图表应尽可能多地展示信息,同时给观众带来最低的认知压力。

  • 力求清晰。使数据突出。提高清晰度的具体技巧包括以下这些:

    • 避免过多的叠加元素,例如在同一绘图空间中过多的曲线。

    • 找到合适的宽高比和缩放比例,以正确展示数据的细节。

    • 避免数据在图表的一侧或另一侧过于倾斜。

  • 可视化是一个迭代的过程。它的目的是回答关于数据的问题。

在可视化阶段,你绘制数据,学习你能学到的,然后重新绘制数据以回答从先前图形中产生的问题。不同的图形最适合回答不同的问题。我们将在本节中查看其中的一些。

在本书中,我们将使用 R 绘图包 ggplot2(Leland Wilkinson 的 图形语法 的 R 实现,Springer,1999)以及来自 WVPlots 包的一些预包装 ggplot2 可视化来展示可视化图形。你也可以查看 ggpubrggstatsplot 包以获取更多预包装的 ggplot2 图形。当然,其他 R 可视化包,如基础图形或 lattice 包,也可以生成类似的图形。


关于 ggplot2 的注意事项

本节的主题是如何使用可视化来探索你的数据,而不是如何使用 ggplot2ggplot2 包基于 Leland Wilkinson 的书籍,图形语法。我们选择 ggplot2 是因为它擅长将多个图形元素组合在一起,但其语法可能需要一些时间来适应。以下是在查看我们的代码片段时需要理解的关键点:

  • ggplot2 中的图形只能定义在数据框上。图形中的变量——x 变量、y 变量、定义点颜色或大小的变量——被称为 美学,通过使用 aes 函数声明。

  • ggplot() 函数声明了图形对象。ggplot() 的参数可以包括感兴趣的数据框和美学。ggplot() 函数本身并不产生可视化;可视化是通过 产生的。

  • 层生成图形和图形变换,并使用 + 操作符添加到给定的图形对象中。每个层也可以接受数据框和美学作为参数,以及特定的图形参数。层的例子包括 geom_point(用于散点图)或 geom_line(用于线图)。

在接下来的示例中,这种语法将变得更加清晰。有关更多信息,我们建议参考 Hadley Wickham 的参考网站 ggplot2.tidyverse.org/reference/,该网站提供了在线文档的链接;Winston Chang 网站的图形部分 www.cookbook-r.com/;以及 Winston Chang 的 R 图形烹饪书(O’Reilly,2012)。


在接下来的两个章节中,我们将展示如何使用图片和图形来识别数据特征和问题。在 3.2.2 节 中,我们将查看两个变量的可视化。但让我们先看看单变量的可视化。

3.2.1. 检查单变量分布的视觉

在本节中我们将查看

  • 直方图

  • 密度图

  • 条形图

  • 点图

本节中的可视化可以帮助你回答如下问题:

  • 分布的峰值是多少?

  • 分布中有多少个峰值(单峰与双峰)?

  • 数据有多正常(或对数正态)?我们将在附录 B 中讨论正态和对数正态分布。附录 B。

  • 数据变化有多大?它是否集中在某个区间或某个类别中?

容易直观理解的一件事是数据分布的形状。图 3.3 中的图表在 25 岁到 60 岁之间相对平坦,60 岁之后缓慢下降。然而,即使在范围内,似乎在 20 年代末到 30 年代初和 50 年代初存在峰值。这些数据有多个峰值:它不是单峰的。^([1])

¹

单峰的严格定义是分布有一个唯一的最大值;从这个意义上说,图 3.3 是单峰的。然而,大多数人使用“单峰”一词来表示分布有一个唯一的峰值(局部最大值);客户年龄分布有多个峰值,因此我们将它称为多峰的

图 3.3. 年龄密度图

图片

单峰性是你在数据中想要检查的一个属性。为什么?因为(粗略地说)单峰分布对应于一个受试者群体。对于图 3.4 中的实线曲线,平均客户年龄约为 50 岁,50%的客户年龄在 34 岁到 64 岁之间(第一和第三四分位数,以阴影表示)。因此,可以说“典型”的客户是中年人,并且可能拥有许多中年人的人口统计特征——尽管,当然,你必须用你的实际客户信息来验证这一点。

图 3.4. 单峰分布(实线曲线)通常可以建模为来自单一用户群体的数据。双峰分布(虚线曲线)时,你的数据通常来自两个用户群体。

图片

图 3.4 中的虚线曲线显示了当你有两个峰值或双峰分布时可能发生的情况。(具有超过两个峰值的分布是多峰的。)这组客户群体的平均年龄与实线曲线所代表的客户群体的平均年龄大致相同——但 50 岁的人 hardly 可以算作“典型”客户!这个(诚然是夸张的)例子对应于两个客户群体:一个相对年轻的群体,主要在十几岁到二十年代末,另一个是 70 多岁的老年群体。这两个群体可能具有非常不同的行为模式,如果你想要建模一个客户是否可能有医疗保险,分别对这两个群体进行建模可能不是一个坏主意。

直方图和密度图是两种可视化工具,可以帮助您快速检查数值变量的分布。图 3.1 和 3.3 是密度图。您使用直方图还是密度图在很大程度上取决于个人喜好。我们倾向于更喜欢密度图,但直方图对不那么注重量化的人群来说更容易解释。

直方图

基本直方图将变量分成固定宽度的桶,并返回每个桶中数据点的数量作为高度。例如,假设您想了解您的客户每月天然气供暖账单的支付情况。您可以将天然气账单金额分组为$10 间隔:$0–10,$10–20,$20–30,等等。处于边界的客户进入更高的桶:每月支付约$20 的人进入$20–30 桶。然后,您为每个桶计数该桶中有多少客户。生成的直方图显示在图 3.5 中。

图 3.5. 直方图告诉您数据集中在哪里。它还直观地突出显示异常值和异常。

您可以在ggplot2中使用geom_histogram层在图 3.5 中创建直方图。

列表 3.6. 绘制直方图

library(ggplot2)                              ❶
ggplot(customer_data, aes(x=gas_usage)) +
  geom_histogram(binwidth=10, fill="gray")    ❷

❶ 如果您还没有这样做,请加载 ggplot2 库。

binwidth参数告诉geom_histogram调用如何创建$10 间隔的桶(默认为 datarange/30)。fill参数指定直方图条的颜色(默认:黑色)。

适当的binwidth可以使直方图直观地突出数据集中的集中区域,并指出潜在异常值和异常的存在。例如,在图 3.5 中,您可以看到一些异常客户有比典型情况大得多的天然气账单,因此您可能希望从任何使用天然气账单作为输入的分析中排除这些客户。您还看到支付$0–10/月天然气费用的异常高人群集中。这可能意味着您的客户中大多数没有天然气供暖,但在进一步调查数据字典(表 3.1)时,您发现了这一点。

表 3.1. gas_usage的数据字典条目

定义
NA 未知或不适用
001 包含在租金或公寓费中
002 包含在电费中
003 无费用或未使用天然气
004-999 $4 至$999(四舍五入并上限编码)

换句话说,gas_usage 列中的值是数值和作为数字编码的符号代码的混合。值 001002003哨兵值,将它们视为数值可能会在分析中导致错误的结论。在这种情况下的一种可能的解决方案是将数值 1-3 转换为 NA,并添加额外的布尔变量来指示可能的案例(包括租金/公寓费等)。

直方图的主要缺点是您必须提前决定桶的宽度。如果桶太宽,您可能会丢失关于分布形状的信息。如果桶太窄,直方图可能看起来太嘈杂,难以阅读。一种替代的可视化方法是密度图。

密度图

你可以将密度图视为变量的连续直方图,除了密度图下的面积重新缩放为等于一。密度图上的一个点对应于具有特定值的数值(或数值的百分比,除以 100)的数据(或数据百分比)。这个分数通常非常小。当你查看密度图时,你更感兴趣的是曲线的整体形状,而不是 y 轴上的实际值。你已经看到了年龄的密度图;图 3.6 显示了收入的密度图。

图 3.6. 密度图显示了数据集中的集中位置。

你可以使用 geom_density 层生成 图 3.6,如下面的列表所示。

列表 3.7. 生成密度图

library(scales)                                           ❶

ggplot(customer_data, aes(x=income)) + geom_density() +
  scale_x_continuous(labels=dollar)                       ❷

❶ The scales package brings in the dollar scale notation.

❷ 将 x 轴标签设置为美元

当数据范围非常广且分布的质量严重集中在一边时,就像 图 3.6 中的分布一样,很难看到其形状的细节。例如,很难确定收入分布的峰值的确切值。如果数据是非负的,那么一种突出更多细节的方法是将分布绘制在对数尺度上,如图 图 3.7 所示。这相当于绘制 log10(income) 的密度图。

图 3.7. 在 log10 尺度上的收入密度图突出了在常规密度图中难以看到的收入分布的细节。

ggplot2 中,您可以使用 geom_densityscale_x_log10 层绘制 图 3.7,如下面的列表所示。

列表 3.8. 创建对数刻度的密度图

ggplot(customer_data, aes(x=income)) +
  geom_density() +
  scale_x_log10(breaks = c(10, 100, 1000, 10000, 100000, 1000000),
  labels=dollar) +                                               ❶
   annotation_logticks(sides="bt", color="gray")                   ❷

❶ 将 x 轴设置为 log10 尺度,并手动设置刻度和标签为美元

❷ 在图表的顶部和底部添加对数刻度的刻度线

当你发出前面的命令时,你也会收到一个警告信息:

## Warning in self$trans$transform(x): NaNs produced
## Warning: Transformation introduced infinite values in continuous x-axis
## Warning: Removed 6856 rows containing non-finite values (stat_density).

这表明ggplot2忽略了零值和负值行(因为log(0) = Infinity),并且有 6856 这样的行。在评估图表时请记住这一点。


何时应该使用对数刻度?

当百分比变化或数量级的变化比绝对单位的变化更重要时,你应该使用对数刻度。你也应该使用对数刻度来更好地可视化严重偏斜的数据。

例如,在收入数据中,收入差异为 5,000 美元在收入倾向于数万美元的人口中意味着与收入在数十万或数百万美元的人口中意味着大不相同。换句话说,“显著差异”的定义取决于你所查看的收入的数量级。同样,在图 3.7 中类似的人口中,少数高收入者会导致大多数数据压缩到图表的相对较小区域。出于这两个原因,将收入分布绘制在对数刻度上是一个好主意。


在对数空间中,收入分布看起来像“正常”分布,这将在附录 B 中讨论。它并不完全是一个正态分布(实际上,它似乎至少是两个正态分布混合在一起)。

条形图和点图

条形图 是离散数据的直方图:它记录了分类变量每个值的频率。图 3.8 显示了客户数据集中婚姻状况的分布。如果你认为婚姻状况有助于预测健康保险覆盖的可能性,那么你想要检查你是否拥有足够的不同婚姻状况的客户,以帮助你发现已婚(或未婚)与拥有健康保险之间的关系。

[图 3.8]_ 条形图显示了分类变量的分布 _。

图片

生成图 3.8 的ggplot2命令使用geom_bar

ggplot(customer_data, aes(x=marital_status)) + geom_bar(fill="gray")

这个图表实际上并没有比summary(customer_data$marital.stat)显示的更多信息,但有些人发现图表比文本更容易吸收。当可能的值数量相当大时,如居住状态,条形图最有用。在这种情况下,我们经常发现像图 3.9 中所示的水平图表比垂直图表更易读。

[图 3.9]_A 水平条形图在存在几个具有长名称的分类时更容易阅读 _。

图片

生成图 3.9 的ggplot2命令将在下一列表中展示。

列表 3.9. 生成水平条形图

ggplot(customer_data, aes(x=state_of_res)) +
  geom_bar(fill="gray") +                        ❶
   coord_flip()                                  ❷

❶ 如前所述绘制条形图:居住状态在 x 轴上,计数在 y 轴上

❷ 翻转 x 和 y 轴:state_of_res 现在位于 y 轴

克利夫兰^([1]) 更喜欢使用 点图 而不是条形图来可视化离散计数。这是因为条形图是二维的,所以计数差异看起来像是条形 面积 的差异,而不是仅仅在条形高度上的差异。这可能会产生感知上的误导。由于点图中的点线和线不是二维的,所以在比较两个数量时,观众只考虑高度差异,正如他们应该的那样。

¹

见威廉·S·克利夫兰,数据绘图元素,霍巴特出版社,1994 年。

克利夫兰还建议对条形图或点图中的数据进行排序,以便更有效地从数据中提取洞察。这如图 3.10 所示。图 3.10。现在可以很容易地看到哪些州居住着最多的客户——或者最少的客户。

图 3.10. 使用点图和按计数排序使数据更容易阅读。

排序的可视化需要更多的操作,至少在 ggplot2 中是这样,因为默认情况下,ggplot2 将按字母顺序绘制因子变量的类别。幸运的是,大部分代码已经包含在 WVPlots 包中的 ClevelandDotPlot 函数中。

列表 3.10. 生成排序类别的点图

library(WVPlots)                                    ❶
ClevelandDotPlot(customer_data, "state_of_res",     ❷
     sort = 1, title="Customers by state") +        ❸
 coord_flip()                                       ❹

❶ 加载 WVPlots 库

❷ 绘制客户数据数据框的 state_of_res 列

❸ “sort = 1”按递增顺序(最频繁的放在最后)排序类别。

❹ 如前所述翻转坐标轴

在我们继续讨论两个变量的可视化之前,我们将总结本节中讨论的可视化,见表 3.2。

表 3.2. 单变量可视化

图表类型 用途 例子
直方图或密度图 检查数据范围 检查数量模式 检查分布是否为正态/对数正态 检查异常值和离群值 检查客户年龄的分布以获取典型客户年龄范围 检查客户收入的分布以获取典型收入范围
条形图或点图 比较分类变量值的频率 计算来自不同居住州的客户的数量,以确定哪些州拥有最大的或最小的客户基础

3.2.2. 检查两个变量之间的关系

除了单独检查变量外,你通常会想查看两个变量之间的关系。例如,你可能想回答以下问题:

  • 我的数据中两个输入 年龄收入 之间是否存在关系?

  • 如果有的话,是什么类型的关系,有多强?

  • 输入 婚姻状况 和输出 健康保险 之间是否存在关系?有多强?

你将在建模阶段精确量化这些关系,但现在探索它们可以让你对数据有一个感觉,并帮助你确定哪些变量是模型中最佳候选变量。

本节探讨了以下可视化:

  • 用于比较两个连续变量的折线图和散点图

  • 用于比较大量两个连续变量的平滑曲线和六边形图

  • 用于比较两个离散变量的不同类型的条形图

  • 用于比较连续和离散变量的直方图和密度图的变体

首先,让我们考虑两个连续变量之间的关系。你可能首先想到的图表(尽管它并不总是最好的)是折线图。

折线图

折线图在两个变量之间的关系相对清晰时效果最好:每个x值都有一个唯一的(或几乎唯一的)y值,如图 3.11 所示。你使用geom_line绘制图 3.11。

图 3.11。折线图的示例

列表 3.11。生成折线图

x <- runif(100)                                           ❶
y <- x² + 0.2*x                                          ❷
ggplot(data.frame(x=x,y=y), aes(x=x,y=y)) + geom_line()   ❸

❶ 首先,生成本例的数据。x 变量在 0 和 1 之间均匀随机分布。

❷ y 变量是 x 的二次函数。

❸ 绘制折线图

当数据关系不是那么清晰时,折线图并不那么有用;你将想要使用散点图,正如你将在下一节中看到的。

散点图和平滑曲线

你可能会预期年龄和健康保险之间存在关系,收入和健康保险之间也存在关系。但年龄和收入之间的关系是什么?如果它们完美地相互跟踪,那么你可能不想在健康保险模型中使用这两个变量。适当的汇总统计量是相关性,我们在数据的一个安全子集上计算它。

列表 3.12。检查年龄和收入之间的相关性

customer_data2 <- subset(customer_data,
                   0 < age & age < 100 &
                    0 < income & income < 200000)        ❶

cor(customer_data2$age, customer_data2$income)           ❷
 ## [1] 0.005766697                                      ❸

❶ 只考虑具有合理年龄和收入值的子集数据。

❷ 获取年龄和收入的相关性

❸ 结果相关性为正但几乎为零。

相关性为正,正如你可能预期的,但几乎为零,这意味着年龄和收入之间似乎没有太多关系。可视化比单个数字能提供更多关于正在发生的事情的洞察。让我们先尝试散点图(图 3.12)。由于我们的数据集有超过 64,000 行,这对于可读的散点图来说太大,我们将在绘图之前对数据集进行抽样。你使用geom_point绘制图 3.12,如列表 3.13 所示。

图 3.12。收入与年龄的散点图

列表 3.13。创建年龄和收入的散点图

set.seed(245566)                                                                     ❶
 customer_data_samp <-
      dplyr::sample_frac(customer_data2, size=0.1, replace=FALSE)                    ❷

ggplot(customer_data_samp, aes(x=age, y=income)) +                                   ❸
        geom_point() +
       ggtitle("Income as a function of age")

❶ 通过设置随机种子使随机抽样可重复。

❷ 为了可读性,只绘制数据的 10%样本。我们将在下一节中展示如何绘制所有数据。

❸ 创建散点图

年龄与收入之间的关系并不容易看出。你可以尝试通过在数据上绘制平滑曲线来使这种关系更清晰,如图 3.13 图所示。

图 3.13. 收入与年龄的散点图,带有平滑曲线

平滑曲线使得更容易看出,在这个群体中,收入通常从二十多岁开始随着年龄增长,直到三十多岁中期,之后收入以较慢、几乎平缓的速度增长,直到大约五十多岁中期。五十多岁中期之后,收入通常随着年龄的增长而减少。

ggplot2 中,你可以通过使用 geom_smooth 来对数据进行平滑曲线绘制:

ggplot(customer_data_samp, aes(x=age, y=income)) +
  geom_point() + geom_smooth() +
  ggtitle("Income as a function of age")

对于点数较少的数据集,geom_smooth 函数使用 loess(或 lowess)函数来计算数据的平滑局部线性拟合。对于像这样较大的数据集,geom_smooth 使用样条拟合。

默认情况下,geom_smooth 还会在平滑曲线周围绘制一个“标准误差”带。这个带子在哪里数据点较少就较宽,在哪里数据密集就较窄。它的目的是表示平滑曲线估计的不确定性。对于图 3.13 中的图表,散点图非常密集,以至于平滑带在图表的极端右侧之外是不可见的。由于散点图已经提供了标准误差带所提供的信息,你可以通过 se=FALSE 参数将其关闭,正如我们将在后面的例子中看到的那样。

带有平滑曲线的散点图也用于连续变量与布尔值之间关系的可视化。假设你正在考虑将年龄作为你的医疗保险模型的输入。你可能想绘制医疗保险覆盖范围作为年龄的函数,如图 3.14 图所示。

图 3.14. 拥有医疗保险的客户比例,作为年龄的函数

当一个人有医疗保险时,变量 health_ins 的值为 1(表示 TRUE),否则为 0(表示 FALSE)。数据的散点图将会有所有的 y 值都是 01,这可能看起来没有太多信息量,但数据的平滑曲线估计了 0/1 变量 health_ins 作为年龄的函数的平均值。给定年龄的 health_ins 的平均值简单地是该年龄的人在你数据集中拥有医疗保险的概率。

图 3.14 展示了随着客户年龄的增长,拥有医疗保险的概率也在增加,从 20 岁时的约 80%增加到 75 岁后的近 100%。


为什么保留散点图?

你可能会问,为什么费心绘制这些点?为什么不直接绘制平滑曲线?毕竟,数据只取01这两个值,所以散点图似乎没有太多信息量。

这是个品味问题,但我们喜欢保留散点图,因为它能让我们直观地估计出在x变量的不同范围内有多少数据。例如,如果你的数据在 70-100 岁年龄段的客户只有十几个,那么你就知道那个年龄段健康保险概率的估计可能不太准确。相反,如果你有数百名客户分布在那个年龄段,那么你对估计的信心就会更大。

geom_smooth在平滑曲线周围绘制的标准误差带提供了等效的信息,但我们发现散点图更有帮助。


使用WVPlots中的BinaryYScatterPlot函数绘制图 3.14 是一个简单的方法:

BinaryYScatterPlot(customer_data_samp, "age", "health_ins",
                   title = "Probability of health insurance by age")

默认情况下,BinaryYScatterPlot通过数据拟合逻辑回归曲线。你将在第八章中了解更多关于逻辑回归的内容,但就现在而言,只需知道逻辑回归试图估计布尔结果y为真的概率,作为数据x的函数。

如果你尝试绘制customer_data2数据集中所有点的散点图,散点图将变成难以辨认的涂抹。为了在类似这种情况的高容量情况下绘制所有数据,尝试一个聚合图,如 hexbin 图。

Hexbin 图

hexbin图就像一个二维直方图。数据被分成几个区间,每个区间中的数据点数量用颜色或阴影表示。让我们回到收入与年龄的例子。图 3.15 展示了数据的 hexbin 图。注意平滑曲线是如何追踪数据最密集区域形成的形状的。

图 3.15. 收入与年龄的 hexbin 图,叠加了平滑曲线

图片

要在 R 中制作 hexbin 图,你必须安装hexbin包。我们将在附录 A 中讨论如何安装 R 包。一旦安装了hexbin并加载了库,你可以使用geom_hex层创建图表,或者使用WVPlots中的便利函数HexBinPlot,就像我们在这里做的那样。HexBinPlot预先定义了一个颜色尺度,其中密集的单元格被着色得更深;默认的ggplot2颜色尺度将密集的单元格着色得更浅。

列表 3.14. 制作hexbin

library(WVPlots)                                                            ❶

HexBinPlot(customer_data2, "age", "income", "Income as a function of age") +❷
   geom_smooth(color="black", se=FALSE)                                     ❸

❶ 加载 WVPlots 库

❷ 绘制收入作为年龄函数的 hexbin 图

在黑色中添加平滑线;抑制标准误差带(se=FALSE)

在本节和上一节中,我们查看了一些至少有一个变量是数值的图表。但在我们的健康保险例子中,输出是分类的,许多输入变量也是分类的。接下来,我们将探讨可视化两个分类变量之间关系的方法。

两个分类变量的条形图

让我们考察婚姻状态与健康保险覆盖率概率之间的关系。可视化这种关系的最直接方法是使用堆积条形图,如图 3.16 所示。

图 3.16. 健康保险与婚姻状态:堆积条形图

堆积条形图使得比较每个婚姻类别中的人数总和以及比较每个婚姻类别中未投保人数变得容易。然而,你不能直接比较每个类别中投保人数的数量,因为条形并不都从同一水平开始。所以有些人更喜欢图 3.17 中显示的并列条形图,这使得比较不同类别中投保和未投保人数变得更容易——但不是每个类别中总人数。

图 3.17. 健康保险与婚姻状态:并列条形图

如果你想要比较不同类别中投保和未投保人数,同时保持每个类别中总人数的概念,一个可以尝试的图表是我们所说的阴影图。这个数据集的阴影图创建了两个图表,一个用于投保人口,一个用于未投保人口。这两个图表都叠加在总人口的“阴影图”上。这允许在婚姻状态类别之间和内部进行比较,同时保持类别总数的信息。这如图 3.18 所示。

图 3.18. 健康保险与婚姻状态:阴影图

所有前面图表的主要缺点是,你不能轻易地比较投保与未投保人数的比率,特别是对于像丧偶这样的罕见类别。你可以使用ggplot2所说的填充条形图直接绘制比率的可视化,如图 3.19 所示。

图 3.19. 健康保险与婚姻状态:填充条形图

填充条形图使得明显看出离婚客户比已婚客户更有可能未投保。但你失去了关于丧偶的信息,尽管它高度预测保险覆盖率,但这是一个罕见的类别。

你使用哪种条形图取决于你需要传达的最重要信息是什么。接下来给出生成这些图表的代码。注意ggplot2命令中使用fill美学;这告诉ggplot2根据变量health_ins的值来着色(填充)条形。geom_barposition参数指定条形图样式。

列表 3.15. 指定不同的条形图样式

ggplot(customer_data, aes(x=marital_status, fill=health_ins)) +
                        geom_bar()                                           ❶

ggplot(customer_data, aes(x=marital_status, fill=health_ins)) +
                     geom_bar(position = "dodge")                            ❷

ShadowPlot(customer_data, "marital_status", "health_ins",
                         title = "Health insurance status by marital status")❸
ggplot(customer_data, aes(x=marital_status, fill=health_ins)) +
                     geom_bar(position = "fill")                             ❹

❶ 堆积条形图,默认样式

❷ 并列条形图

❸ 使用来自 WVPlots 包的 ShadowPlot 命令进行阴影图绘制

❹ 填充条形图

在前面的例子中,一个变量是二元的;相同的图表可以应用于每个变量都有几个类别的两个变量,但结果更难阅读。假设你对不同住房类型的婚姻状况分布感兴趣。有些人发现在这种情况下并排条形图最容易阅读,但它并不完美,如你在 图 3.20 中所见。

图 3.20. 按住房类型分布的婚姻状况:并排条形图

如果任何一个变量有大量类别,像 图 3.20 这样的图表就会显得杂乱。更好的选择是将分布分解成不同的图表,每个住房类型一个。在 ggplot2 中,这被称为 分面 图表,并使用 facet_wrap 层。结果如图 图 3.21 所示。

图 3.21. 按住房类型分布的婚姻状况:分面并排条形图

图 3.20 和 3.21 的代码看起来像下面的列表。

列表 3.16. 带与不带分面的条形图绘制

cdata <- subset(customer_data, !is.na(housing_type))        ❶

ggplot(cdata, aes(x=housing_type, fill=marital_status)) +   ❷
   geom_bar(position = "dodge") +
  scale_fill_brewer(palette = "Dark2") +
  coord_flip()                                              ❸

ggplot(cdata, aes(x=marital_status)) +                      ❹
   geom_bar(fill="darkgray") +
  facet_wrap(~housing_type, scale="free_x") +               ❺
   coord_flip()                                             ❻

❶ 限制为已知住房类型的数据

❷ 并排条形图

❸ 使用 coord_flip() 函数旋转图表,以便婚姻状况可读

❹ 分面条形图

❺ 通过 housing.type 对图表进行分面。参数 scales="free_x" 指定每个分面都有一个独立缩放的 x 轴;默认情况下,所有分面在两个轴上都有相同的缩放。参数 "free_y" 将释放 y 轴缩放,而参数 "free" 则释放两个轴。

❻ 使用 coord_flip() 函数旋转图表

比较连续变量和分类变量

假设你想比较你数据中不同婚姻状况人群的年龄分布。你在 3.2.1 节 中看到,你可以使用直方图或密度图来查看连续变量(如 age)的分布。现在你想要多个分布图:每个婚姻状况类别一个。最直接的方法是将这些图叠加在同一张图上。

图 3.22 比较了数据中丧偶(虚线)和从未结婚(实线)人群的年龄分布。你可以快速看到这两个种群分布差异很大:丧偶人群偏老,而从未结婚的人群偏年轻。

图 3.22. 比较丧偶和从未结婚人群的婚姻状况分布

生成 图 3.22 的代码如下。

列表 3.17. 比较不同类别的种群密度

customer_data3 = subset(customer_data2, marital_status %in%
   c("Never married", "Widowed"))                             ❶
ggplot(customer_data3, aes(x=age, color=marital_status,       ❷
   linetype=marital_status)) +
   geom_density() + scale_color_brewer(palette="Dark2")

❶ 限制为丧偶或从未结婚的人的数据

❷ 通过婚姻状况区分图表的颜色和线型

重叠密度图可以提供关于分布形状的良好信息:人口密集和稀疏的地方,人口是否分离或重叠。然而,它们会失去关于每个种群相对大小的信息。这是因为每个个体的密度图都按单位面积缩放。这有利于提高每个个体分布的可读性,但可能会让你误以为所有种群的大小都差不多。实际上,图 3.22 中的重叠密度图也可能让你误以为 55 岁后丧偶人口会超过从未结婚的人口,这实际上是不正确的。

为了保留关于每个种群相对大小的信息,使用直方图。直方图不易重叠,因此你可以使用facet_wrap()命令与geom_histogram()一起使用,就像你在列表 3.16 中看到的柱状图一样。你还可以使用WVPlots中的ShadowHist()函数生成阴影图的直方图版本,如下所示。

列表 3.18. 使用ShadowHist()比较不同类别的种群密度

ShadowHist(customer_data3, "age", "marital_status",
 "Age distribution for never married vs. widowed populations", binwidth=5) ❶

❶ 将直方图的箱宽设置为 5

结果显示在图 3.23 中。现在你可以看到,丧偶人口相当少,并且直到大约 65 岁后才会超过从未结婚的人口——比图 3.22 中的交叉点晚 10 年。

图 3.23. 丧偶和从未结婚人口年龄分布的ShadowHist比较

当比较超过两个类别的分布时,你也应使用分面,因为过多的重叠图表难以阅读。尝试检查所有四个婚姻状况类别的年龄分布;该图表显示在图 3.24 中。

图 3.24. 不同婚姻状况年龄分布的截面图

ggplot(customer_data2, aes(x=age)) +
  geom_density() + facet_wrap(~marital_status)

再次,这些密度图可以提供关于分布形状的良好信息,但会失去关于每个种群相对大小的信息。

两个变量可视化概述

表 3.3 总结了我们所涵盖的两个变量的可视化。

表 3.3. 两个变量的可视化

图表类型 使用 示例
折线图 显示两个连续变量之间的关系。当这种关系是函数性的或几乎如此时最佳。 绘制 y = f(x)
散点图 显示两个连续变量之间的关系。当关系过于松散或类似云朵,难以在折线图上轻易看出时最佳。 绘制收入与工作年限的关系图(收入在 y 轴上)。
平滑曲线 展示两个连续变量之间的“平均”关系或趋势。也可以用来展示连续变量与二进制或布尔变量之间的关系:离散变量的真实值作为连续变量的函数的分数。 估计收入与工作年限之间的“平均”关系。
六边形图 当数据非常密集时,展示两个连续变量之间的关系。 为大量人口绘制收入与工作年限的关系图。
堆积条形图 展示两个分类变量(var1 和 var2)之间的关系。突出显示 var1 的每个值的频率。当 var2 为二进制时效果最佳。 当您想保留每个婚姻类别中人数的信息时,将保险覆盖范围(var2)作为婚姻状况(var1)的函数绘制。
并列条形图 展示两个分类变量(var1 和 var2)之间的关系。适用于比较 var1 的每个值上 var2 的频率。当 var2 为二进制时效果最佳。 当您想直接比较每个婚姻类别中投保和未投保人数时,将保险覆盖范围(var2)作为婚姻状况(var1)的函数绘制。
阴影图 展示两个分类变量(var1 和 var2)之间的关系。显示 var1 的每个值的频率,同时允许比较 var2 的值在 var1 的各个类别内和类别间的比较。 当您想直接比较每个婚姻类别中投保和未投保人数,并且仍然保留每个婚姻类别中总人数的信息时,将保险覆盖范围(var2)作为婚姻状况(var1)的函数绘制。
填充条形图 展示两个分类变量(var1 和 var2)之间的关系。适用于比较 var1 的每个值内 var2 的相对频率。当 var2 为二进制时效果最佳。 当您想比较每个婚姻类别中未投保与投保人数的比率时,将保险覆盖范围(var2)作为婚姻状况(var1)的函数绘制。
分面条形图 展示两个分类变量(var1 和 var2)之间的关系。当 var2 有超过两个值时,最适合比较 var1 的每个值内 var2 的相对频率。 将婚姻状况(var2)的分布作为住房类型(var1)的函数绘制。
叠加密度图 比较连续变量在不同分类变量值上的分布。当分类变量只有两个或三个类别时效果最佳。显示连续变量在各个类别中的分布是否不同或相似。 比较已婚与离婚人口年龄分布。
分面密度图 比较连续变量在不同分类变量值上的分布。适用于有超过三个或更多类别的分类变量。显示连续变量在类别之间是分布不同还是相似。 比较几种婚姻状况(未婚、已婚、离婚、丧偶)的年龄分布。
分面直方图或阴影直方图 在保留关于相对人口规模信息的同时,比较连续变量在不同分类变量值上的分布。 在保留关于相对人口规模信息的同时,比较几种婚姻状况(未婚、已婚、离婚、丧偶)的年龄分布。

你可以使用许多其他变体和可视化来探索数据;前面的一组涵盖了最有用和最基本的一些图表。你应该尝试不同类型的图表,从数据中获得不同的见解。这是一个互动的过程。一个图表可能会提出一些问题,你可以通过重新绘制数据并使用不同的可视化来尝试回答这些问题。

最终,你会探索足够多的数据,以获得对它的感觉,并发现大多数主要问题和问题。在下一章中,我们将讨论一些解决你可能在数据中发现的一些常见问题的方法。

摘要

到目前为止,你已经对你的数据有了感觉。你已经通过摘要和可视化来探索它;你现在对你的数据质量以及变量之间的关系有了感觉。你已经发现了并准备纠正几种数据问题——尽管随着你继续前进,你很可能会遇到更多的问题。

可能你发现的一些事情让你重新评估了你试图回答的问题,或者修改了你的目标。也许你决定你需要更多或不同类型的数据来实现你的目标。这些都是好事。正如我们在上一章提到的,数据科学过程是由层层嵌套的循环组成的。数据探索和数据清洗阶段(我们将在下一章讨论清洗)是过程中耗时较长且最重要的两个阶段。没有好的数据,你无法构建好的模型。你在这里花费的时间是你在其他地方不会浪费的时间。

在下一章中,我们将讨论解决你在数据中发现的问题。

在本章中,你学习了

  • 在深入建模之前,花时间检查和理解你的数据。

  • 摘要命令帮助你发现数据范围、单位、数据类型以及缺失或无效值的问题。

  • 不同的可视化技术有不同的优点和应用。

  • 可视化是一个迭代的过程,有助于解答关于数据的问题。从一次可视化中获得的信息可能会引出更多问题——你可能会尝试用另一次可视化来回答这些问题。如果一次可视化不起作用,就尝试另一次。在这里花费的时间是在建模过程中没有浪费的时间。

第四章. 管理数据

本章涵盖

  • 修复数据质量问题

  • 在建模前转换数据

  • 为建模过程组织你的数据

在 第三章 中,你学习了如何探索你的数据以及如何识别常见的数据问题。在本章中,你将看到如何修复你发现的数据问题。之后,我们将讨论在建模过程中转换和组织数据。本章的大部分例子都使用了你在上一章中使用过的相同客户数据.^([1])

¹

数据可以通过保存文件 custdata.RDS 到 github.com/WinVector/PDSwR2/tree/master/Custdata 并然后在 R 中运行 readRDS("custdata.RDS") 来加载。

如心智模型 (图 4.1) 所示,本章再次强调了在建模步骤之前以统计有效的方式管理数据的重要性。

图 4.1. 第四章 心智模型

4.1. 数据清洗

在本节中,我们将解决你在数据探索/可视化阶段发现的问题,特别是无效和缺失值。数据中的缺失值很常见,你处理它们的方式通常从项目到项目都是一样的。处理无效值通常是 领域特定的:哪些值是无效的,你将如何处理它们,取决于你试图解决的问题。


示例

假设你有一个名为 credit_score* 的数值变量。领域知识将告诉你该变量的有效范围。如果信用评分应该是客户的“经典 FICO 评分”,那么任何在 300–850 范围之外的值都应该被视为无效。其他类型的信用评分将有不同的有效值范围。*


我们首先来看一个领域特定数据清洗的例子。

4.1.1. 领域特定数据清洗

从上一章的数据探索中,我们知道我们的数据存在一些问题:

  • 变量 gas_usage 混合了数值和符号数据:大于 3 的值是月度 gas_bills,但 13 的值是特殊代码。此外,gas_usage 还有一些缺失值。

  • 变量 age 有问题值 0,这可能意味着年龄未知。此外,还有一些客户的年龄超过 100 岁,这也可能是一个错误。然而,对于这个项目,我们将值 0 视为无效,并假设年龄超过 100 岁是有效的。

  • 变量 income 有负值。我们假设在这次讨论中这些值是无效的。

这类问题相当常见。事实上,前面提到的大部分问题都已经在我们的假设客户数据示例所基于的实际人口普查数据中存在。

处理ageincome变量的快速方法是将其无效值转换为NA,就像它们是缺失变量一样。然后您可以使用在 4.1.2 节中讨论的自动缺失值处理方法来处理NA。^([2])

²

如果您还没有这样做,我们建议您按照附录 A 的 A.1 节和附录 A 中的步骤安装 R、包、工具和本书的示例。

列表 4.1. 处理年龄和收入变量

library(dplyr)
customer_data = readRDS("custdata.RDS")                  ❶

customer_data <- customer_data %>%
   mutate(age = na_if(age, 0),                           ❷
           income = ifelse(income < 0, NA, income))      ❸

❶ 加载数据

❷ dplyr 包中的 mutate()函数向数据框中添加列,或修改现有列。来自 dplyr 的函数 na_if()将特定的有问题值(在本例中为 0)转换为 NA。

❸ 将负收入转换为 NA

gas_usage变量需要特殊处理。回想一下第三章,值123不是数值,而是代码:

  • 1表示“燃气账单包含在租金或公寓费中。”

  • 2表示“燃气账单包含在电费中。”

  • 3表示“无费用或未使用燃气。”

处理gas_usage的一种方法是将所有特殊代码(123)转换为NA,并添加三个新的指示变量,每个代码一个。例如,指示变量gas_with_electricity将在原始gas_usage变量值为2时具有值1(或TRUE),否则为0。在下面的列表中,您将创建三个新的指示变量,gas_with_rentgas_with_electricityno_gas_bill

列表 4.2. 处理gas_usage变量

customer_data <- customer_data %>%
  mutate(gas_with_rent = (gas_usage == 1),                   ❶
          gas_with_electricity = (gas_usage == 2),
         no_gas_bill = (gas_usage == 3) ) %>%
  mutate(gas_usage = ifelse(gas_usage < 4, NA, gas_usage))   ❷

❶ 创建三个指示变量

❷ 将gas_usage列中的特殊代码转换为 NA

4.1.2. 处理缺失值

让我们再次看看上一章中客户数据集中一些具有缺失值的变量。一种通过编程查找这些变量的方法是计算客户数据框中每列的缺失值数量,并查找计数大于零的列。下一个列表计算数据集中每列的缺失值数量。

列表 4.3. 计算每个变量的缺失值数量

count_missing = function(df) {                             ❶
   sapply(df, FUN=function(col) sum(is.na(col)) )
}

nacounts <- count_missing(customer_data)
hasNA = which(nacounts > 0)                                ❷
nacounts[hasNA]

##          is_employed               income         housing_type
##                25774                   45                 1720
##          recent_move         num_vehicles                  age
##                 1721                 1720                   77
##            gas_usage        gas_with_rent gas_with_electricity
##                35702                 1720                 1720
##          no_gas_bill
##                 1720

❶ 定义了一个函数,用于计算数据框中每列的 NA 值数量

❷ 将函数应用于 customer_data,识别具有缺失值的列,并打印列和计数

基本上,您可以使用这些变量做两件事:删除包含缺失值的行,或将缺失值转换为有意义的值。对于incomeage这样的变量,相对于数据的大小(customer_data有 73,262 行),缺失值非常少,删除行可能是安全的。对于is_employedgas_usage这样的变量,其中很大一部分值是缺失的,删除行可能是不安全的。

此外,记住,R(以及其他语言)中的许多建模算法会静默地删除具有缺失值的行。所以如果你有宽数据,并且许多列有缺失值,删除具有缺失值的行可能并不安全。这是因为在这种情况下,至少有一个缺失值的行比例可能很高,你可能会丢失大部分数据,如图 4.2(figure 4.2)所示。因此,为了这次讨论,我们将所有缺失值转换为有意义的值。

图 4.2. 即使只有几个缺失值也可能丢失所有数据。

图片描述

分类变量中的缺失数据

当缺失值的变量是分类变量时,一个简单的解决方案是为该变量创建一个新的类别,例如,称为missing_invalid_。这在图 4.3(figure 4.3)中 schematically 展示了housing_type变量。

图 4.3. 为缺失的分类值创建新级别

图片描述

数值或逻辑变量中的缺失值

假设你的收入变量缺失大量数据,如图 4.4(figure 4.4)所示。你认为收入仍然是健康保险覆盖概率的重要预测因子,因此你仍然想使用该变量。你该怎么办?这可以取决于你为什么认为数据缺失。

图 4.4. 具有缺失值的收入数据

图片描述

缺失值的性质

你可能认为数据缺失是因为数据收集失败是随机的,独立于情况和其他值。在这种情况下,你可以用“合理的估计”或插补值来替换缺失值。从统计学的角度来看,一个常用的估计是期望值或平均值,如图 4.5(figure 4.5)所示。

图 4.5. 用平均值替换缺失值

图片描述

假设缺失收入的客户与其他客户的分布方式相同,用平均值替换缺失值在平均意义上将是正确的。这也是一个容易实施的解决方案。

当你记得收入与你的数据中的其他变量相关时,你可以改进这个估计——例如,根据前一章的数据探索,你知道年龄和收入之间存在关系。居住地或婚姻状况与收入之间也可能存在关系。如果你有这些信息,你可以使用它。基于其他输入变量对输入变量的缺失值进行插补的方法也可以应用于分类数据。^([3)]

³

《R in Action,第二版》(Robert Kabacoff,2014,mng.bz/ybS4)包括对 R 中可用的几种值插补方法的广泛讨论。

重要的是要记住,用平均值替换缺失值,以及其他更复杂的缺失值插补方法,假设缺失收入的客户在某种程度上是典型的。可能缺失收入数据的客户与其他客户在系统上有所不同。例如,可能的情况是,缺失收入信息的客户确实没有收入,因为他们是全职学生或全职主妇,或者他们不是活跃的劳动力。如果是这样,那么使用上述方法之一“填补”他们的收入信息是不充分的处理,可能会导致错误的结论。

将缺失值视为信息

你仍然需要将 NA 替换为一个替代值,比如平均值。但是建模算法应该知道这些值可能与其他值不同。对我们来说,一个有效的方法是将 NA 替换为平均值,并添加一个额外的指示变量来跟踪哪些数据点已被更改。这如图 4.6 所示。

图 4.6. 用平均值替换缺失值并添加指示列以跟踪更改的值

图片

income_isBAD 变量让你能够区分数据中的两种值:你即将添加的值和已经存在的值。

你已经在另一个关于系统缺失值的例子中看到了这种方法的变化,那就是 gas_usage 变量。大多数缺失 gas_usage 值的客户并不是随机的:他们要么与其他账单一起支付燃气费,比如电费或租金,要么他们不使用燃气。你通过添加额外的指示变量来识别这些客户:no_gas_billgas_with_rent 等。现在你可以在 gas_usage 中的“缺失”值用替代值填充,比如零,或者 gas_usage 的平均值。

策略是在建模步骤中,将所有变量——incomeincome_isBADgas_usageno_gas_bill 等——都提供给建模算法,它可以确定如何最好地使用这些信息进行预测。如果缺失值确实是随机缺失的,那么你添加的指示变量就是无信息的,模型应该忽略它们。如果缺失值是系统缺失的,那么指示变量为建模算法提供了有用的额外信息。


缺失指示器可能很有用

我们在许多情况下观察到,isBAD 变量有时甚至比原始变量更有信息和有用!


如果你不知道缺失值是随机的还是系统的,我们建议假设差异是系统的,而不是努力根据随机缺失假设对变量进行值填充。正如我们之前所说的,当缺失值实际上指示某些数据点的系统性差异时,将其视为随机缺失可能会得出错误的结论。

4.1.3. 用于自动处理缺失变量的 vtreat 包

由于缺失值是数据中如此常见的问题,因此有一个自动且可重复的处理过程来处理它们是非常有用的。我们建议使用 vtreat 变量处理包。vtreat 处理过程创建一个 处理计划,记录重复数据处理过程所需的所有信息:例如,观察到的平均收入,或像 housing_type 这样的分类变量的所有观察值。然后,你使用这个处理计划在拟合模型之前“准备”或处理你的训练数据,然后再在将新数据输入模型之前再次处理。其想法是处理过的数据是“安全的”,没有缺失或意外的值,并且不应该破坏模型。

你将在后面的章节中看到使用 vtreat 的更复杂示例,但到目前为止,你将只创建一个简单的处理计划来管理 customer_data 中的缺失值。图 4.7 显示了创建和应用此简单处理计划的过程。首先,你必须指定数据中的哪些列是输入变量:除了 health_ins(要预测的结果)和 custid 之外的所有列:

varlist <- setdiff(colnames(customer_data), c("custid", "health_ins"))

图 4.7. 创建和应用简单处理计划

然后,你创建处理计划,并“准备”数据。

列表 4.4. 创建和应用处理计划

library(vtreat)
treatment_plan <-
      design_missingness_treatment(customer_data, varlist = varlist)
training_prepared <- prepare(treatment_plan, customer_data)

数据框 training_prepared 是用于训练模型的处理数据。让我们将其与原始数据进行比较。

列表 4.5. 比较处理数据与原始数据

colnames(customer_data)
##  [1] "custid"               "sex"                  "is_employed"
##  [4] "income"               "marital_status"       "health_ins"
##  [7] "housing_type"         "recent_move"          "num_vehicles"
## [10] "age"                  "state_of_res"         "gas_usage"
## [13] "gas_with_rent"        "gas_with_electricity" "no_gas_bill"
colnames(training_prepared)                                               ❶
##  [1] "custid"                     "sex"
##  [3] "is_employed"                "income"
##  [5] "marital_status"             "health_ins"
##  [7] "housing_type"               "recent_move"
##  [9] "num_vehicles"               "age"
## [11] "state_of_res"               "gas_usage"
## [13] "gas_with_rent"              "gas_with_electricity"
## [15] "no_gas_bill"                "is_employed_isBAD"
## [17] "income_isBAD"               "recent_move_isBAD"
## [19] "num_vehicles_isBAD"         "age_isBAD"
## [21] "gas_usage_isBAD"            "gas_with_rent_isBAD"
## [23] "gas_with_electricity_isBAD" "no_gas_bill_isBAD"

nacounts <- sapply(training_prepared, FUN=function(col) sum(is.na(col)) ) ❷
sum(nacounts)
## [1] 0

❶ 准备好的数据有额外的列,这些列在原始数据中不存在,最重要的是那些带有 _isBAD 标记的列。

❷ 准备好的数据没有缺失值。

现在检查一些你知道有缺失值的列。

列表 4.6. 检查数据处理

htmissing <- which(is.na(customer_data$housing_type))                  ❶

columns_to_look_at <- c("custid", "is_employed", "num_vehicles",
                           "housing_type", "health_ins")

customer_data[htmissing, columns_to_look_at] %>% head()                ❷
##           custid is_employed num_vehicles housing_type health_ins
## 55  000082691_01        TRUE           NA         <NA>      FALSE
## 65  000116191_01        TRUE           NA         <NA>       TRUE
## 162 000269295_01          NA           NA         <NA>      FALSE
## 207 000349708_01          NA           NA         <NA>      FALSE
## 219 000362630_01          NA           NA         <NA>       TRUE
## 294 000443953_01          NA           NA         <NA>       TRUE
columns_to_look_at = c("custid", "is_employed", "is_employed_isBAD",
                       "num_vehicles","num_vehicles_isBAD",
                       "housing_type", "health_ins")

training_prepared[htmissing, columns_to_look_at] %>%  head()           ❸
##           custid is_employed is_employed_isBAD num_vehicles
## 55  000082691_01   1.0000000                 0       2.0655
## 65  000116191_01   1.0000000                 0       2.0655
## 162 000269295_01   0.9504928                 1       2.0655
## 207 000349708_01   0.9504928                 1       2.0655
## 219 000362630_01   0.9504928                 1       2.0655
## 294 000443953_01   0.9504928                 1       2.0655
##     num_vehicles_isBAD housing_type health_ins
## 55                   1    _invalid_      FALSE
## 65                   1    _invalid_       TRUE
## 162                  1    _invalid_      FALSE
## 207                  1    _invalid_      FALSE
## 219                  1    _invalid_       TRUE
## 294                  1    _invalid_       TRUE

customer_data %>%
    summarize(mean_vehicles = mean(num_vehicles, na.rm = TRUE),
    mean_employed = mean(as.numeric(is_employed), na.rm = TRUE))       ❹
##   mean_vehicles mean_employed
## 1        2.0655     0.9504928

❶ 找到 housing_type 缺失的行

❷ 查看原始数据中那些行的几个列

❸ 查看处理数据中的那些行和列(包括 isBADs)

❹ 验证数据集中预期的车辆数量和预期的失业率

你可以看到 vtreat 将分类变量 housing_type 的缺失值替换为 _invalid_,并将数值列 num_vehicles 的缺失值替换为原始数据中的平均值。它还将逻辑变量 is_ employed 转换为数值变量,并用原始数据中的平均值替换缺失值。

除了修复缺失数据外,还有其他方法可以转换数据,以解决你在探索阶段发现的问题。在下一节中,我们将检查一些其他常见的转换。

4.2. 数据转换

数据转换的目的是使数据更容易建模,也更容易理解。机器学习通过学习训练数据中的有意义模式来工作,然后通过利用这些模式在新数据中进行预测。因此,使训练数据中的模式与新数据中的模式更容易匹配的数据转换可以带来好处。


示例

假设你正在考虑将收入作为保险模型的输入。生活成本会因州而异,所以在一个地区可能是高薪,而在另一个地区可能几乎不足以维持生计。因此,可能更有意义的是,通过他们居住地区的典型收入来归一化客户的收入。这是一个相对简单(且常见)的转换例子。


对于这个例子,你有一个名为 median_income.RDS 的文件,其中包含每个州的中位数收入的外部信息。列表 4.7 使用这些信息来归一化收入。代码使用连接操作将 median_income.RDS 中的信息与现有的客户数据进行匹配。我们将在下一章讨论连接表,但就目前而言,你应该理解连接是将数据从另一个数据框复制到具有匹配行的数据框中。

列表 4.7. 按州归一化收入

library(dplyr)
median_income_table <- readRDS("median_income.RDS")                         ❶
head(median_income_table)

##   state_of_res median_income
## 1      Alabama         21100
## 2       Alaska         32050
## 3      Arizona         26000
## 4     Arkansas         22900
## 5   California         25000
## 6     Colorado         32000

training_prepared <-  training_prepared %>%
  left_join(., median_income_table, by="state_of_res") %>%                  ❷
   mutate(income_normalized = income/median_income)

head(training_prepared[, c("income", "median_income", "income_normalized")])❸

##   income median_income income_normalized
## 1  22000         21100         1.0426540
## 2  23200         21100         1.0995261
## 3  21000         21100         0.9952607
## 4  37770         21100         1.7900474
## 5  39000         21100         1.8483412
## 6  11100         21100         0.5260664

summary(training_prepared$income_normalized)

##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##  0.0000  0.4049  1.0000  1.5685  1.9627 46.5556

❶ 如果你已下载 PDSwR2 代码示例目录,那么 median_income.RDS 位于 PDSwR2/Custdata 目录中。我们假设这是你的工作目录。

❷ 将 median_income_table 连接到客户数据中,以便你可以按其所在州的中位数收入归一化每个人的收入

❸ 比较收入和 income_normalized 的值

查看第 4.7 表的结果,你会发现收入高于其所在州中位数收入的客户,其income_normalized值大于1,而收入低于其所在州中位数收入的客户,其income_normalized值小于1。由于不同州的客户得到不同的归一化处理,我们称这种处理为条件性转换。换句话说,这种归一化是基于客户居住地的状态。我们称通过相同值对所有客户进行缩放为非条件性转换。

数据转换的需要也可能取决于你计划使用的建模方法。例如,对于线性回归和逻辑回归,理想情况下,你希望确保输入变量与输出变量之间的关系大致是线性的,并且输出变量的方差是恒定的(输出变量的方差与输入变量无关)。你可能需要转换一些输入变量以更好地满足这些假设。

在本节中,我们将探讨一些有用的数据转换及其应用场景:

  • 标准化

  • 中心化和缩放

  • 对数转换

4.2.1. 标准化

标准化(或缩放)在绝对量不如相对量有意义时很有用。你已经看到了一个将收入相对于另一个有意义的量(中位数收入)进行标准化的例子。在这种情况下,有意义的量是外部的(它来自外部信息),但它也可以是内部的(从数据本身导出)。

例如,你可能对客户的绝对年龄不如对客户相对于“典型”客户的年龄大小感兴趣。让我们以客户的平均年龄作为典型年龄。你可以通过以下列表进行标准化。

列表 4.8. 通过平均年龄进行标准化

summary(training_prepared$age)

##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##   21.00   34.00   48.00   49.22   62.00  120.00

mean_age <- mean(training_prepared$age)
age_normalized <- training_prepared$age/mean_age
summary(age_normalized)

##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##  0.4267  0.6908  0.9753  1.0000  1.2597  2.4382

age_normalized的值远小于1表示客户异常年轻;远大于1表示客户异常年长。但“远小于”或“远大于1”是什么意思?这取决于你的客户倾向于有多大的年龄分布。参见图 4.8 以获取示例。

图 4.8. 35 岁的人算年轻吗?

图片

两个群体中的平均客户年龄都是 50 岁。群体 1的年龄分布相对较广,所以 35 岁的人仍然看起来相当典型(可能有点年轻)。同样的 35 岁的人在群体 2中看起来异常年轻,因为群体 2 的年龄分布较窄。你的客户的典型年龄分布可以通过标准差来总结。这导致了一种表达客户相对年龄的另一种方式。

4.2.2. 中心化和缩放

你可以通过使用标准差作为距离单位来缩放你的数据。一个年龄在平均年龄加减一个标准差范围内的客户被认为与典型年龄相差不大。一个年龄比平均年龄多一个或两个标准差的客户可以被认为是年龄较大或较小。为了使相对年龄更容易理解,你还可以通过平均值来中心化数据,这样“典型年龄”的客户中心化年龄为 0。

列表 4.9. 中心化和缩放年龄

(mean_age <- mean(training_prepared$age))                                  ❶
 ## [1] 49.21647

(sd_age <- sd(training_prepared$age))                                      ❷
 ## [1] 18.0124

print(mean_age + c(-sd_age, sd_age))                                       ❸
 ## [1] 31.20407 67.22886

training_prepared$scaled_age <- (training_prepared$age - mean_age) / sd_age❹

training_prepared %>%
  filter(abs(age - mean_age) < sd_age) %>%
  select(age, scaled_age) %>%
  head()

##   age scaled_age                                                        ❺
## 1  67  0.9872942
## 2  54  0.2655690
## 3  61  0.6541903
## 4  64  0.8207422
## 5  57  0.4321210
## 6  55  0.3210864

training_prepared %>%
  filter(abs(age - mean_age) > sd_age) %>%
  select(age, scaled_age) %>%
  head()

##   age scaled_age                                                        ❻
## 1  24  -1.399951
## 2  82   1.820054
## 3  31  -1.011329
## 4  93   2.430745
## 5  76   1.486950
## 6  26  -1.288916

❶ 计算平均值

❷ 计算标准差

❸ 这个群体的典型年龄范围大约在 31 岁到 67 岁之间。

❹ 以平均值作为起点(或参考点),并通过标准差缩放与平均值的距离

❺ 典型年龄范围内的客户具有小于 1 的缩放年龄值。

❻ 在典型年龄范围之外的客户具有大于 1 的缩放年龄值。

现在,小于 -1 的值表示比典型客户年轻的客户;大于 1 的值表示比典型客户年长的客户。


一个技术细节

标准差作为距离单位的常见解释隐含地假设数据是正态分布的。对于正态分布,大约三分之二的数据(大约 68%)在均值加减一个标准差范围内。大约 95%的数据在均值加减两个标准差范围内。在 figure 4.8(在 figure 4.9 中作为分面图重现)中,一个 35 岁的人在 population 1 中距离均值一个标准差,但在 population 2 中超过一个(实际上,超过两个)标准差。

Figure 4.9. 分面图:35 岁的人年轻吗?

即使数据不是正态分布的,你仍然可以使用这种变换,但标准差作为距离的单位在数据单峰且大致围绕均值对称时最有意义。


当你有多个数值变量时,你可以使用 scale() 函数同时将它们居中和缩放。这有一个优点,即现在所有数值变量都具有相似且更兼容的范围。为了具体说明,比较变量 age(以年为单位)和变量 income(以美元为单位)。两个客户之间 10 岁的年龄差异可能很大,但 10 美元的收入差异却相当小。如果你对这两个变量都进行居中和缩放,那么值 0 对两个缩放变量意味着相同的事情:平均年龄或平均收入。而值 1.5 也意味着相同的事情:一个比平均年龄高 1.5 个标准差的个人,或者比平均收入高 1.5 个标准差的个人。在这两种情况下,值 1.5 可以被认为是从平均值的一个较大差异。

以下列表展示了如何使用 scale() 对数据中的四个数值变量进行居中和缩放。

Listing 4.10. 居中和缩放多个数值变量

dataf <- training_prepared[, c("age", "income", "num_vehicles", "gas_usage")]
summary(dataf)

##       age             income         num_vehicles     gas_usage
##  Min.   : 21.00   Min.   :      0   Min.   :0.000   Min.   :  4.00
##  1st Qu.: 34.00   1st Qu.:  10700   1st Qu.:1.000   1st Qu.: 50.00
##  Median : 48.00   Median :  26300   Median :2.000   Median : 76.01
##  Mean   : 49.22   Mean   :  41792   Mean   :2.066   Mean   : 76.01
##  3rd Qu.: 62.00   3rd Qu.:  51700   3rd Qu.:3.000   3rd Qu.: 76.01
##  Max.   :120.00   Max.   :1257000   Max.   :6.000   Max.   :570.00

dataf_scaled <- scale(dataf, center=TRUE, scale=TRUE)                      ❶

summary(dataf_scaled)
##       age               income         num_vehicles        gas_usage
##  Min.   :-1.56650   Min.   :-0.7193   Min.   :-1.78631   Min.   :-1.4198
##  1st Qu.:-0.84478   1st Qu.:-0.5351   1st Qu.:-0.92148   1st Qu.:-0.5128
##  Median :-0.06753   Median :-0.2666   Median :-0.05665   Median : 0.0000
##  Mean   : 0.00000   Mean   : 0.0000   Mean   : 0.00000   Mean   : 0.0000
##  3rd Qu.: 0.70971   3rd Qu.: 0.1705   3rd Qu.: 0.80819   3rd Qu.: 0.0000
##  Max.   : 3.92971   Max.   :20.9149   Max.   : 3.40268   Max.   : 9.7400

(means <- attr(dataf_scaled, 'scaled:center'))                             ❷
 ##          age       income num_vehicles    gas_usage
##     49.21647  41792.51062      2.06550     76.00745

(sds <- attr(dataf_scaled, 'scaled:scale'))
##          age       income num_vehicles    gas_usage
##    18.012397 58102.481410     1.156294    50.717778

❶ 通过其均值居中数据,并通过其标准差进行缩放

❷ 获取原始数据的均值和标准差,这些值存储为 dataf_scaled 的属性

由于 scale() 变换将所有数值变量放入兼容的单位,因此它是主成分分析和深度学习等一些数据分析和技术推荐的预处理步骤。

保持训练变换

当你使用从数据中导出的参数(如均值、中位数或标准差)在建模之前转换数据时,你通常应该保留这些参数,并在转换将输入到模型的新数据时使用它们。当你使用列表 4.10 中的scale()函数时,你保留了scaled:centerscaled:scale属性的值作为变量meanssds。这样做是为了你可以使用这些值来缩放新数据,正如列表 4.11 中所示。这确保了新的缩放数据与训练数据具有相同的单位。

当使用vtreat包中的design_missingness_treatment()函数清理缺失值时,与你在第 4.1.3 节中所做的一样,同样的原则适用。产生的处理计划(在列表 4.1.3)中称为treatment_plan)保留了训练数据中的信息,以便从新数据中清理缺失值,正如你在列表 4.5 中所看到的。

列表 4.11. 在将新数据输入模型之前对其进行处理

newdata <- customer_data                              ❶

library(vtreat)                                       ❷
newdata_treated <- prepare(treatment_plan, newdata)

new_dataf <- newdata_treated[, c("age", "income",     ❸
"num_vehicles", "gas_usage")]

dataf_scaled <- scale(new_dataf, center=means, scale=sds)

❶ 模拟拥有一个新的客户数据集

❷ 使用原始数据集的处理计划对其进行清理

❸ 使用原始数据集的均值和标准差来缩放年龄、收入、车辆数量和油耗

然而,有些情况下你可能希望使用新的参数。例如,如果模型中的重要信息是某个主体的收入与当前中位数收入的关系,那么在为建模准备新数据时,你将希望使用当前的中位数收入来归一化收入,而不是模型训练时的中位数收入。这里的含义是,收入是中位数三倍的人的特征将不同于收入低于中位数的人的特征,并且这些差异与实际收入金额无关。

4.2.3. 对偏斜和宽分布进行对数转换

通过均值和标准差进行归一化,正如你在第 4.2.2 节中所做的那样,当数据分布大致对称时最有意义。接下来,我们将探讨一种可以使某些分布更加对称的转换方法。

货币金额——如收入、客户价值、账户价值或购买大小——是数据科学应用中最常见的偏斜分布来源之一。事实上,正如我们将在附录 B 中讨论的那样,货币金额通常是对数正态分布:数据的对数是正态分布的。这导致我们想到,通过对货币数据取对数可以恢复数据的对称性和规模,使其看起来“更正常”。我们在图 4.11 中展示了这一点。

对于建模的目的,通常使用哪种对数并不太关键,无论是自然对数、以 10 为底的对数还是以 2 为底的对数。例如,在回归分析中,对数的选择会影响对应于对数变量的系数的大小,但它不会影响模型的架构。我们喜欢使用以 10 为底的对数来表示货币金额,因为十进制对于金钱来说似乎是自然的:$100,$1000,$10,000 等等。变换后的数据易于阅读。


关于图形的一个补充

注意到图 4.10 的底部面板与图 3.7 具有相同的形状。在收入的密度图上使用ggplot层的scale_x_log10与绘制log10(income)的密度图之间的主要区别是轴标签。使用scale_x_log10将对 x 轴进行美元金额的标注,而不是对数。


图 4.10。一个近似对数正态分布及其对数

图片

对于包含跨越几个数量级的值的数值数据,通常进行对数变换也是一个好主意,例如城镇和城市的居民数量,可能从几百到几百万不等。这样做的一个原因是因为建模技术往往难以处理非常宽的数据范围。另一个原因是,这类数据通常来自乘法过程,而不是加法过程,因此对数单位在某种程度上更为自然。

作为一种加法过程的例子,假设你正在研究减肥。如果你体重 150 磅,而你的朋友体重 200 磅,你们两人同样活跃,并且你们都采取了完全相同的限制卡路里饮食,那么你们可能都会减掉大约相同数量的磅数。你减掉的体重并不取决于你最初体重有多少,而只取决于卡路里摄入量。在这种情况下,自然单位是绝对磅数(或千克)的减少。

作为乘法过程的例子,考虑工资增长。如果管理层给部门里的每个人都加薪,那么它可能并不是给每个人都额外加$5,000。相反,每个人都得到 2%的加薪:你最终工资中增加的金额取决于你的初始工资。在这种情况下,自然单位是百分比,而不是绝对美元。其他乘法过程的例子:

  • 在线零售网站的变化会使每个商品的转化率(购买)增加 2%(而不是每晚正好增加两个购买)。

  • 餐厅菜单的任何变化都会使每晚的顾客数量增加 5%(而不是每晚正好增加五个顾客)。

当过程是乘法时,对数变换过程数据可以使建模更容易。

不幸的是,只有当数据是非负的时,取对数才有效,因为零的对数是负无穷大,负数的对数没有定义(R 将负数的对数标记为NaN:不是一个数字)。还有其他转换,如arcsinh,可以在你有零或负值时用来减少数据范围。我们并不总是使用arcsinh,因为我们发现转换后的数据值并不具有意义。在数据偏斜是货币(如账户余额或客户价值)的应用中,我们使用我们称之为带符号对数的方法。带符号对数取变量的绝对值对数并乘以适当的符号。严格位于-11之间的值被映射到零。对数和带符号对数之间的区别在图 4.11 中显示。

图 4.11. 带符号的对数可以将非正值数据可视化在对数尺度上。

图 4.11 的替代图片

这是在 R 中计算带符号的对数底数为 10 的方法:

signedlog10 <- function(x) {
     ifelse(abs(x) <= 1, 0, sign(x)*log10(abs(x)))
}

这种方法将-1 和 1 之间的所有数据映射到零,因此如果小于 1 的数值很重要,这种转换显然是不实用的。但是,对于许多货币变量(如美元),实际上小于一美元的数值与零(或 1)没有太大区别。因此,例如,将账户余额小于或等于 1 美元(相当于每个账户始终有最低余额 1 美元)进行映射可能是可以的。您还可以为“小”选择一个更大的阈值,例如 100 美元。这将把小于 100 美元的小账户映射到相同的值,并消除图 4.10 和 4.11 中的长左尾。在某些情况下,消除这个长尾可能是希望的——一方面,它使数据图表的视觉偏差减少。4]

除了截断之外,还有其他方法可以处理带符号的对数,例如反正弦函数(见mng.bz/ZWQa),但它们也会扭曲接近零的数据,并使几乎任何数据看起来是双峰的,这可能具有欺骗性。

一旦数据得到了适当的清理和转换,你几乎可以开始建模阶段了。在我们到达那里之前,我们还有一步。

4.3. 用于建模和验证的抽样

抽样是选择一个子集来代表总体,在分析和建模过程中的过程。在当前的大数据集时代,有些人认为计算能力和现代算法让我们能够分析整个大型数据集,而不需要抽样。但请记住,“大数据”本身通常是从更大的宇宙中抽取的样本。因此,了解抽样对于处理数据总是必要的。

我们当然可以分析比以前更大的数据集,但抽样仍然是一个有用的工具。当你处于开发或改进建模过程的中途时,在训练整个数据集之前,在小子样本上测试和调试代码会更简单。使用数据子样本进行可视化可能更容易;ggplot 在较小的数据集上运行得更快,过多的数据往往会模糊图表中的模式,正如我们在第三章 中提到的。而且,通常不可能使用你的整个客户群来训练一个模型。

确保你使用的整个数据集能够准确代表你的整体人群是很重要的。例如,你的客户可能来自美国的各个地方。当你收集客户数据时,可能会倾向于使用一个州的所有客户,比如康涅狄格州,来训练模型。但如果你计划使用该模型来预测全国各地的客户,那么随机从所有州选择客户是一个好主意,因为预测德克萨斯州客户健康保险覆盖率的因素可能与预测康涅狄格州健康保险覆盖率的因素不同。这可能并不总是可行的(也许只有康涅狄格州和马萨诸塞州的分支机构目前收集客户健康保险信息),但使用非代表性数据集的缺点应该引起注意。

另一个对数据进行抽样的原因是创建测试和训练分割。

4.3.1. 测试和训练分割

当你构建一个用于预测的模型,比如我们预测健康保险覆盖概率的模型时,你需要数据来构建模型。你还需要数据来测试模型是否能在新数据上做出正确的预测。第一个集合被称为训练集,第二个集合被称为测试集(或保留集)。图 4.12 展示了分割过程(以及可选的校准集分割,详见侧边栏 “训练/校准/测试分割”)。

图 4.12. 将数据分割成训练集和测试集(或训练、校准和测试集)

训练集是提供给模型构建算法(我们将在第二部分中介绍具体算法)的数据,以便算法能够拟合正确的结构以最佳预测结果变量。测试集是输入到最终模型中的数据,以验证模型在新数据上的预测是否准确。我们将在第六章中详细介绍你可以通过使用保留数据检测到的建模问题。现在,我们将为稍后进行的保留实验准备数据。


训练/校准/测试分割

许多作家推荐使用训练/校准/测试分割,其中 校准集 用于设置模型拟合算法需要的参数,而训练集用于拟合模型。这也是一条很好的建议。我们的理念是这样的:尽早将数据分割为训练/测试,直到最终评估前不要查看测试数据,如果您需要校准数据,则从您的训练子集中重新分割。


4.3.2. 创建样本组列

管理随机采样的便捷方法是在数据框中添加一个样本组列。该样本组列包含使用 runif() 函数生成的从零到一的均匀数。您可以通过对样本组列使用适当的阈值从数据框中抽取任意大小的随机样本。

例如,一旦您已经使用样本组列(让我们称它为 gp)标记了数据框的所有行,那么 gp < 0.4 的所有行将大约是四分之一,即 40% 的数据。gp 在 0.55 和 0.70 之间的所有行大约是数据的 15%(0.7 – 0.55 = 0.15)。因此,您可以通过使用 gp 可重复地生成任何大小的数据随机样本。

列表 4.12. 使用随机分组标记分割为测试集和训练集

set.seed(25643)                                      ❶
customer_data$gp <- runif(nrow(customer_data))       ❷
customer_test <- subset(customer_data, gp <= 0.1)    ❸
customer_train <- subset(customer_data, gp > 0.1)    ❹

dim(customer_test)
## [1] 7463   16

dim(customer_train)
## [1] 65799    16

❶ 设置随机种子,以确保此示例可重复

❷ 创建分组列

❸ 在这里,我们生成大约 10% 的数据测试集。

❹ 在这里,我们使用剩余的数据生成一个训练集。

列表 4.12 生成大约 10% 的数据测试集,并将剩余的 90% 数据分配给训练集。

dplyr 包也有名为 sample_n()sample_frac() 的函数,可以从数据框中抽取随机样本(默认为均匀随机样本)。为什么不直接使用这些中的一个来抽取训练集和测试集呢?您可以这样做,但您应该确保通过 set.seed() 命令(就像我们在列表 4.12 中所做的那样)设置随机种子,以确保您每次都会抽取相同的样本组。在调试代码时,可重复采样是必不可少的。在许多情况下,代码会因为您忘记防范的边缘情况而崩溃。这个边缘情况可能会出现在您的随机样本中。如果您每次运行代码时都使用不同的随机输入样本,您将不知道是否会再次触发该错误。这使得跟踪和修复错误变得困难。

您还希望对于软件工程师所说的 回归测试(不要与统计回归混淆)有可重复的输入样本。换句话说,当您对模型或数据处理进行更改时,您想确保您不会破坏已经正常工作的事物。如果模型版本 1 为某个输入集提供了“正确答案”,您想确保模型版本 2 也能这样做。

我们发现,将样本组列与数据一起存储是保证在开发和测试期间可重复采样的一种更可靠的方法。


可重复抽样的技巧不仅适用于 R

如果你的数据存储在数据库或其他外部存储中,而你只想将数据的一个子集拉入 R 进行分析,你可以在数据库中适当表中生成一个样本组列,使用 SQL 命令RAND来抽取一个可重复的随机样本。


4.3.3. 记录分组

一个注意事项是,前面的技巧在感兴趣的每个对象(在这种情况下是每个客户)对应一个唯一行时才有效。但如果你对哪些客户没有健康保险的兴趣较少,而对哪些家庭有未投保成员的兴趣更多呢?如果你在家庭层面而不是客户层面建模问题,那么家庭中的每个成员都应该在同一组(测试或训练)中。换句话说,随机抽样也必须在家庭层面进行。

假设你的客户既由家庭 ID 标记,也由客户 ID 标记。这显示在图 4.13 中。我们想要将家庭分成训练集和测试集。列表 4.13 显示了一种生成适当的样本组列的方法。

图 4.13. 具有客户和家庭的示例数据集

列表 4.13. 确保测试/训练分割不会在家庭内部分割

household_data <- readRDS("hhdata.RDS")                 ❶
hh <- unique(household_data$household_id)               ❷

set.seed(243674)
households <- data.frame(household_id = hh,             ❸
                          gp = runif(length(hh)),
                         stringsAsFactors=FALSE)

household_data <- dplyr::left_join(household_data,      ❹
                             households,
                            by = "household_id")

❶ 如果你已下载了 PDSwR2 代码示例目录,那么家庭数据集位于 PDSwR2/Custdata 目录中。我们假设这是你的工作目录。

❷ 获取唯一的家庭 ID

❸ 为每个家庭生成唯一的抽样组 ID,并将其放入名为 gp 的列中

❹ 将家庭 ID 重新合并到原始数据中

结果样本组列显示在图 4.14。家庭中的每个人都有相同的样本组编号。

图 4.14. 按家庭而非客户进行数据集抽样

现在,我们可以像以前一样生成测试集和训练集。然而,这次阈值 0.1 并不代表数据行的 10%,而是家庭的 10%,这可能会多或少于 10%的数据,具体取决于家庭的大小。

4.3.4. 数据来源

你还可能想要添加一个(或多个)列来记录数据来源:你的数据集何时收集的,也许在建模之前使用了哪个版本的数据清理程序,等等。这种元数据类似于数据的版本控制。当你正在改进模型或比较不同模型或模型的不同版本时,这是一些方便的信息,以确保你是在比较苹果和苹果。

图 4.15 展示了添加到训练数据中的一些可能的元数据示例。在这个例子中,你记录了原始数据源(称为“数据提取 8/2/18”),数据收集的时间以及处理的时间。例如,如果数据上的处理日期早于你最近的数据处理程序版本,那么你知道这个处理过的数据可能是过时的。多亏了元数据,你可以回到原始数据源并再次处理它。

图 4.15. 使用数据记录数据源、收集日期和处理日期

摘要

在某个时候,你将拥有尽可能好的数据质量。你已经解决了缺失数据的问题,并执行了任何需要的转换。你现在可以进入建模阶段。

然而,请记住,数据科学是一个迭代的过程。在建模过程中,你可能会发现你需要进行额外的数据清理或转换。你可能需要回溯得更远,收集不同类型的数据。这就是为什么我们建议在你的数据集中添加样本组和数据来源的列(稍后,在模型和模型输出中),这样你就可以在数据和模型演变过程中跟踪数据管理步骤。

在本章中,你学习了

  • 处理缺失值的不同方法可能更适合某个特定的目的或另一个目的。

  • 你可以使用vtreat包来自动管理缺失值。

  • 如何归一化或缩放数据,以及何时进行归一化/缩放是合适的。

  • 如何进行对数转换数据,以及何时对数转换是合适的。

  • 如何实现一个可重复的采样方案来创建你的数据的测试/训练分割。

第五章。数据工程和数据整理

本章涵盖

  • 习惯于应用数据转换

  • 从重要的数据处理包包括data.tabledplyr开始

  • 学习控制您数据的布局

本章将向您展示如何使用 R 来组织或整理数据,使其适合分析。数据整理是一系列步骤,如果您发现数据不是全部在一个表中,或者不是在分析准备好的排列中。

图 5.1 是本章的心理模型:与数据一起工作。前几章假设数据是现成的,或者我们已经预先准备数据,使其以这种形式呈现。本章将使您能够自己采取这些步骤。数据整理的基本概念是可视化您的数据结构,以使您的任务更容易,然后采取步骤将这种结构添加到您的数据中。为了教学,我们将处理一些示例,每个示例都有一个激励性的任务,然后处理一个解决问题的转换。我们将专注于一组强大且有用的转换,这些转换涵盖了大多数常见情况。

图 5.1。第五章心理模型

我们将展示使用基础 R、data.tabledplyr进行数据处理解决方案。^([1]) 每个都有其优势,这就是为什么我们展示了不止一个解决方案。在整个这本书中,我们故意采用多语言方法进行数据处理:混合基础 R、data.tabledplyr,以方便起见。每个系统都有其优势:

¹

对于数据库任务,我们建议使用dbplyrrquery,我们将在附录 A(../Text/A.xhtml#app01)中简要介绍。

  • 基础 R— 这是在 R 中编写的代码,它直接使用 R 的内置功能操作data.frame。将复杂的转换分解为基础 R 原语可能是一个难题,但我们将在本章中为您提供解决这个难题的工具。

  • data.table data.table是 R 中进行快速和内存高效数据处理的包。它与常规 R 语义的不同之处在于data.table使用引用语义,其中更改直接在共享数据结构(对所有引用同一结构可见)中进行,而不是 R 的更典型的值语义(在一个引用中做出的更改不会对其他引用可见)。data.table符号通过[]索引操作符的变体指定强大的转换,并在help(data .table, package="data.table")vignette("datatable-intro", package= "data.table")中得到很好的解释。

  • dplyr dplyr是一个流行的数据处理包,它强调通过一系列类似 SQL(或 Codd 风格)操作符的数据操作。dplyr通常不如data.table快(或空间效率高),但符号很方便。

在 R 中操作数据的良好起点是以下免费指南:

我们希望提高你编写 R 代码(将意图转化为实现)和阅读 R 代码(从现有代码中推断意图)的能力。为此,本章,就像这本书的大部分内容一样,被设计成一系列的工作示例。我们强烈鼓励你亲自尝试运行这些示例(它们在这里可用:github.com/WinVector/PDSwR2)。养成在编码之前规划(甚至绘制)数据转换的习惯是关键。在陷入编码细节之前,先明确你的意图。相信 R 中大多数常见的数据整理任务都有易于找到的方法,并假设你可以在需要时找到它们。原则是这样的:通过将你的数据转换成简单的“数据矩阵”格式来简化你的分析,其中每一行是一个观察值,每一列是一个测量类型。尽早解决像奇特的列名等问题,这样你就不必编写复杂的代码来绕过它们。

本章按所需的转换类型组织。为了多样化,我们将引入一些小型概念数据集,并花一点时间查看每个数据集。这种任务到示例的组织将快速介绍 R 中的数据转换。我们将涵盖的转换包括以下内容:

  • 选择列的子集

  • 选择行子集

  • 重新排序行

  • 创建新列

  • 处理缺失值

  • 通过行合并两个数据集

  • 通过列合并两个数据集

  • 合并两个数据集

  • 聚合行

  • 通用数据重塑(长格式与宽格式)

这个列表的目的是为你提供足够多的工具来完成大量任务。我们将从问题到解决方案进行工作。我们将展示哪个命令解决了给定问题,并将命令的语法细节留给 R 的帮助系统以及我们在本章中建议的指南和教程。请将本章视为数据整理的罗塞塔石碑:每个概念只解释一次,然后执行三次(通常是在基础 R、data.tabledplyr中)。

我们的第一个应用(选择行和列)将设定应用的一般模式,所以即使你已经自信地使用 R 进行数据选择,也值得阅读。


数据来源

在本章中,我们将使用小型、玩具大小的数据集来简化在转换前后检查数据的过程。我们强烈建议您与我们一同运行所有示例。所有示例要么是 R 自带的数据集,要么可以从本书的 GitHub 站点获取:github.com/WinVector/PDSwR2。此外,所有代码示例都可以在相同位置的CodeExamples中找到。我们建议克隆或下载此材料以帮助您使用本书。


关于 R 的内置示例的更多信息,请尝试使用命令 help(datasets)

5.1. 数据选择

本节涵盖了删除行、删除列、重新排序列、删除缺失数据和重新排序数据行。在大数据时代,你常常需要查看太多的数据,因此将你的数据限制在你需要的内容上可以大大加快你的工作速度。

5.1.1. 选择行和列

在处理数据集时,选择行或列的子集是一个常见任务。

情境

在我们的第一个示例中,我们将使用iris数据集:测量三种鸢尾花物种的萼片长度和宽度以及花瓣长度和宽度。

首先,我们将查看一些数据方面的内容。我们建议始终这样做,并将其作为“关注数据”工作纪律的一部分。例如,图 5.2 显示了我们的示例 iris 的花瓣尺寸:

library("ggplot2")                                                     ❶

summary(iris)                                                          ❷
##   Sepal.Length    Sepal.Width     Petal.Length    Petal.Width
##  Min.   :4.300   Min.   :2.000   Min.   :1.000   Min.   :0.100
##  1st Qu.:5.100   1st Qu.:2.800   1st Qu.:1.600   1st Qu.:0.300
##  Median :5.800   Median :3.000   Median :4.350   Median :1.300
##  Mean   :5.843   Mean   :3.057   Mean   :3.758   Mean   :1.199
##  3rd Qu.:6.400   3rd Qu.:3.300   3rd Qu.:5.100   3rd Qu.:1.800
##  Max.   :7.900   Max.   :4.400   Max.   :6.900   Max.   :2.500
##
##        Species
##  setosa    :50
##  versicolor:50
##  virginica :50

❶ 将 ggplot2 包附加到后续绘图

❷ 查看内置的 iris 数据

图 5.2. 番茄图示例


附加包

早期附加包是一个好习惯。如果一个包无法附加,请尝试使用如install.packages("ggplot2")之类的命令安装它。


head(iris)

##   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
## 1          5.1         3.5          1.4         0.2  setosa
## 2          4.9         3.0          1.4         0.2  setosa
## 3          4.7         3.2          1.3         0.2  setosa
## 4          4.6         3.1          1.5         0.2  setosa
## 5          5.0         3.6          1.4         0.2  setosa
## 6          5.4         3.9          1.7         0.4  setosa

ggplot(iris,
       aes(x = Petal.Length, y = Petal.Width,
           shape = Species, color = Species)) +
  geom_point(size =2 ) +
  ggtitle("Petal dimensions by iris species: all measurements")

iris数据是 R 预安装的,并属于datasets包。我们将故意在本章中使用小型示例,以便更容易查看结果。

场景

假设我们被分配生成一份关于仅包含花瓣长度和花瓣宽度,按 iris 物种分类的报告,对于花瓣长度大于 2 的 iris。为了完成这项任务,我们需要从数据框中选择列(变量)的子集或行的子集(实例)

列和行选择看起来像图 5.3。

图 5.3. 选择列和行


图表

本章中的图表旨在成为转换的助记卡通。我们建议在转换前后查看实际数据以获取数据转换所做的更多详细信息。我们还在解决完问题后再次审查它们,并注意它们如何抽象转换前后的数据排列。将这些图表视为转换的视觉索引。


解决方案 1:基础 R

基础 R 解决方案是通过使用[,]索引运算符来实现的。


drop = FALSE

When working with [,] always add a third argument drop = FALSE to get around the issue that the default behavior when selecting a single column from an R data.frame returns a vector and not a data.frame containing the column. In many cases, we know we have more than one column, and don’t strictly need the command. But it is good to get in the habit of adding this argument to avoid unpleasant surprises.


The solution strategy is this:

  • Get desired columns by name or column index in the second position of [,].

  • Get desired rows by Boolean per-row selection in the first position of [,].

columns_we_want <- c("Petal.Length", "Petal.Width", "Species")
rows_we_want <- iris$Petal.Length > 2

# before
head(iris)

##   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
## 1          5.1         3.5          1.4         0.2  setosa
## 2          4.9         3.0          1.4         0.2  setosa
## 3          4.7         3.2          1.3         0.2  setosa
## 4          4.6         3.1          1.5         0.2  setosa
## 5          5.0         3.6          1.4         0.2  setosa
## 6          5.4         3.9          1.7         0.4  setosa

iris_base <- iris[rows_we_want, columns_we_want, drop = FALSE]

# after
head(iris_base)

##    Petal.Length Petal.Width    Species
## 51          4.7         1.4 versicolor
## 52          4.5         1.5 versicolor
## 53          4.9         1.5 versicolor
## 54          4.0         1.3 versicolor
## 55          4.6         1.5 versicolor
## 56          4.5         1.3 versicolor

Notice column selection is also a good way to reorder columns. An advantage of base R is it tends to be fast and has very stable APIs: code written this year in base R is most likely to work next year (tidyverse packages, unfortunately, tend to have less-stable APIs). The one disadvantage is that a few base R defaults are irritating. For example, we included the drop=FALSE notation to work around the fact that base R would return a vector instead of a data.frame if we tried to select only one column.

Solution 2: data.table

Row and column selection in data.table is performed similarly to base R. data .table uses a very powerful set of index notations. In this case, we use a .. notation to tell data.table that we are using the second index position to specify column names (and not to specify calculations, as we will demonstrate later).

library("data.table")

iris_data.table <- as.data.table(iris)                                 ❶

columns_we_want <- c("Petal.Length", "Petal.Width", "Species")
rows_we_want <- iris_data.table$Petal.Length > 2

iris_data.table <- iris_data.table[rows_we_want , ..columns_we_want]   ❷

head(iris_data.table)

##    Petal.Length Petal.Width    Species
## 1:          4.7         1.4 versicolor
## 2:          4.5         1.5 versicolor
## 3:          4.9         1.5 versicolor
## 4:          4.0         1.3 versicolor
## 5:          4.6         1.5 versicolor
## 6:          4.5         1.3 versicolor

❶ Converts to data.table class to get data.table semantics

❷ The .. notation tells data.table that columns_we_want isn’t itself the name of a column but a variable referring to names of columns.

The advantage of data.table is that it is the fastest and most memory efficient solution for data wrangling in R at a wide range of scales. data.table has a very helpful FAQ, and there is a nice cheat sheet:

如果你在使用data.table的示例之后再来理解这两个概念,它们就会更加清晰。这些示例可以在 R 中使用命令vignette("datatable-intro", package = "data.table")获取。

Taking care when using data.table

data.table works like data.frames for packages that are not data.table-aware. This means you can use data.tables with just about any package, even those that predate data.table. In a data.table-aware situation (using data.table at the command line, or using a package that depends on data.table), data.table implements slightly enhanced semantics. We show a quick example here:

library("data.table")

df <- data.frame(x = 1:2, y = 3:4)                              ❶

df[, x]                                                         ❷
## Error in `[.data.frame`(df, , x) : object 'x' not found

x <- "y"                                                        ❸
dt <- data.table(df)

dt[, x]                                                         ❹
## [1] 1 2

dt[, ..x]                                                       ❺
##    y
## 1: 3
## 2: 4

❶ Example data.frame

❷ Notice that writing df[, x] instead of df[, "x"] is an error (assuming x is not bound to a value in our environment).

❸ Sets up data.table example

❹ 注意这返回的列 x 与 d$x 非常相似。

❺ 这使用 data.table 的“查找”习语来获取由变量 x 引用的列的数据表。

解决方案 3:dplyr

dplyr解决方案是用选择和过滤来编写的:

  • 使用dplyr::select来选择所需的列

  • 使用dplyr::filter来选择所需的行

通常使用magrittr管道操作符%>%来链式调用dplyr步骤,但分配给临时变量同样有效。在这里教学时,我们将使用显式点表示法,其中数据管道被写作iris %>% select(., column)而不是更常见的隐式第一个参数表示法(iris %>% select(column))。显式点表示法在第二章中已有讨论,也是以下 R 技巧的主题:www.win-vector.com/blog/2018/03/r-tip-make-arguments-explicit-in-magrittr-dplyr-pipelines/.^([2])

²

wrapr包中可以找到一个强大的替代管道,称为点箭头管道(写作%.>%),它坚持使用显式点表示法并提供额外的功能。在这本书的大部分内容中,我们将坚持使用magrittr管道,但我们鼓励好奇的读者在自己的工作中检查wrapr管道。

library("dplyr")

iris_dplyr <- iris %>%
  select(.,
         Petal.Length, Petal.Width, Species) %>%
  filter(.,
         Petal.Length > 2)

head(iris_dplyr)

##   Petal.Length Petal.Width    Species
## 1          4.7         1.4 versicolor
## 2          4.5         1.5 versicolor
## 3          4.9         1.5 versicolor
## 4          4.0         1.3 versicolor
## 5          4.6         1.5 versicolor
## 6          4.5         1.3 versicolor

dplyr的优势在于强调数据处理作为一个分解为可见管道的操作序列。

可以从www.rstudio.com/wp-content/uploads/2015/02/data-wrangling-cheatsheet.pdf获取一份关于dplyr的不错的速查表。速查表总是简明扼要的,所以你在尝试了一些示例之后,这份表格将会非常有用。

5.1.2. 删除不完整数据记录

子集数据的一个重要变体是删除含有缺失值的行数据。我们还将讨论一些简单的策略,通过在行之间移动值(使用na.locf())或在列之间移动值(称为合并)来替换缺失值.^([3])

³

实际上,有一个完整的科学致力于填补缺失数据值的值。关于这个主题的一个好资源是CRAN.R-project.org/view=MissingData

在本节中,我们将展示如何快速选择只有没有缺失数据或值的行。这只是一个例子;我们通常建议在现实世界的应用中使用第四章和第八章中提到的处理缺失值的方法。

情况

由于我们前面的例子没有缺失值,我们将转向另一个例子:具有不同特征的动物的睡眠时间 msleep 数据集。在这个数据集中,有几行有缺失值。本例的另一个目标是让你熟悉一些常见的实践数据集。这些是应该尝试新数据整理方法的数据库集。

首先,像往常一样,让我们看看数据:

library("ggplot2")
data(msleep)               ❶

str(msleep)

## Classes 'tbl_df', 'tbl' and 'data.frame':    83 obs. of  11 variables:
##  $ name        : chr  "Cheetah" "Owl monkey" "Mountain beaver" "Greater sh
     ort-tailed shrew" ...
##  $ genus       : chr  "Acinonyx" "Aotus" "Aplodontia" "Blarina" ...
##  $ vore        : chr  "carni" "omni" "herbi" "omni" ...
##  $ order       : chr  "Carnivora" "Primates" "Rodentia" "Soricomorpha" ...
##  $ conservation: chr  "lc" NA "nt" "lc" ...
##  $ sleep_total : num  12.1 17 14.4 14.9 4 14.4 8.7 7 10.1 3 ...
##  $ sleep_rem   : num  NA 1.8 2.4 2.3 0.7 2.2 1.4 NA 2.9 NA ...
##  $ sleep_cycle : num  NA NA NA 0.133 0.667 ...
##  $ awake       : num  11.9 7 9.6 9.1 20 9.6 15.3 17 13.9 21 ...
##  $ brainwt     : num  NA 0.0155 NA 0.00029 0.423 NA NA NA 0.07 0.0982 ...
##  $ bodywt      : num  50 0.48 1.35 0.019 600 ...

summary(msleep)

##      name              genus               vore
##  Length:83          Length:83          Length:83
##  Class :character   Class :character   Class :character
##  Mode  :character   Mode  :character   Mode  :character
##
##
##
##
##     order           conservation        sleep_total      sleep_rem
##  Length:83          Length:83          Min.   : 1.90   Min.   :0.100
##  Class :character   Class :character   1st Qu.: 7.85   1st Qu.:0.900
##  Mode  :character   Mode  :character   Median :10.10   Median :1.500
##                                        Mean   :10.43   Mean   :1.875
##                                        3rd Qu.:13.75   3rd Qu.:2.400
##                                        Max.   :19.90   Max.   :6.600
##                                                        NA's   :22
##   sleep_cycle         awake          brainwt            bodywt
##  Min.   :0.1167   Min.   : 4.10   Min.   :0.00014   Min.   :   0.005
##  1st Qu.:0.1833   1st Qu.:10.25   1st Qu.:0.00290   1st Qu.:   0.174
##  Median :0.3333   Median :13.90   Median :0.01240   Median :   1.670
##  Mean   :0.4396   Mean   :13.57   Mean   :0.28158   Mean   : 166.136
##  3rd Qu.:0.5792   3rd Qu.:16.15   3rd Qu.:0.12550   3rd Qu.:  41.750
##  Max.   :1.5000   Max.   :22.10   Max.   :5.71200   Max.   :6654.000
##  NA's   :51                       NA's   :27

❶ 将 ggplot2 包中的 msleep 复制到我们的工作空间

场景

我们被要求构建一个没有缺失值的 msleep 数据的提取。为了完成这个任务,我们将删除所有包含缺失值的行。转换的卡通图显示在图 5.4 中。

图 5.4. 删除包含缺失值的行

Base R 解决方案

  • complete.cases() 返回一个向量,其中每个数据框的行都有一个条目,如果且仅当该行没有缺失条目时为 TRUE。一旦我们知道我们想要哪些行,就只是选择这些行(我们之前已经看到过)。

  • na.omit() 在一步中完成整个任务。

clean_base_1 <- msleep[complete.cases(msleep), , drop = FALSE]

summary(clean_base_1)

##      name              genus               vore
##  Length:20          Length:20          Length:20
##  Class :character   Class :character   Class :character
##  Mode  :character   Mode  :character   Mode  :character
##
##
##
##     order           conservation        sleep_total       sleep_rem
##  Length:20          Length:20          Min.   : 2.900   Min.   :0.600
##  Class :character   Class :character   1st Qu.: 8.925   1st Qu.:1.300
##  Mode  :character   Mode  :character   Median :11.300   Median :2.350
##                                        Mean   :11.225   Mean   :2.275
##                                        3rd Qu.:13.925   3rd Qu.:3.125
##                                        Max.   :19.700   Max.   :4.900
##   sleep_cycle         awake          brainwt            bodywt
##  Min.   :0.1167   Min.   : 4.30   Min.   :0.00014   Min.   :  0.0050
##  1st Qu.:0.1792   1st Qu.:10.07   1st Qu.:0.00115   1st Qu.:  0.0945
##  Median :0.2500   Median :12.70   Median :0.00590   Median :  0.7490
##  Mean   :0.3458   Mean   :12.78   Mean   :0.07882   Mean   : 72.1177
##  3rd Qu.:0.4167   3rd Qu.:15.07   3rd Qu.:0.03670   3rd Qu.:  6.1250
##  Max.   :1.0000   Max.   :21.10   Max.   :0.65500   Max.   :600.0000

nrow(clean_base_1)

## [1] 20

clean_base_2 = na.omit(msleep)
nrow(clean_base_2)

## [1] 20

data.table 解决方案

complete.cases() 解决方案也适用于 data.table

library("data.table")

msleep_data.table <- as.data.table(msleep)

clean_data.table = msleep_data.table[complete.cases(msleep_data.table), ]

nrow(clean_data.table)

## [1] 20

dplyr 解决方案

dplyr::filter 也可以与 complete.cases() 一起使用。

使用 magrittr 管道符号,. 被认为是管道中的项目。因此,我们可以方便地多次使用 . 来引用我们的数据,例如告诉 dplyr::filter 使用数据作为要过滤的对象,并将其作为要传递给 complete .cases() 的对象。

library("dplyr")

clean_dplyr <- msleep %>%
filter(., complete.cases(.))

nrow(clean_dplyr)

## [1] 20

5.1.3. 排序行

在本节中,我们想要排序或控制我们的数据行顺序。也许数据到达我们这里时未排序,或者排序的目的不是我们的。

场景

我们被要求构建按时间累积的销售总和,但数据到达我们这里时是乱序的:

purchases <- wrapr::build_frame(        ❶
    "day", "hour", "n_purchase" |
   1    , 9     , 5            |
   2    , 9     , 3            |
   2    , 11    , 5            |
   1    , 13    , 1            |
   2    , 13    , 3            |
   1    , 14    , 1            )

❶ 使用 wrapr::build_frame 直接以可读的列顺序输入数据

问题

按照日期和小时重新排序行,并计算运行总和。抽象图显示在图 5.5 中。

图 5.5. 排序行

Base R 解决方案

order_index <- with(purchases, order(day, hour))                          ❶

purchases_ordered <- purchases[order_index, , drop = FALSE]
purchases_ordered$running_total <- cumsum(purchases_ordered$n_purchase)   ❷

purchases_ordered

##   day hour n_purchase running_total
## 1   1    9          5             5
## 4   1   13          1             6
## 6   1   14          1             7
## 2   2    9          3            10
## 3   2   11          5            15
## 5   2   13          3            18

with() 函数执行其第二个参数中的代码,就像第一个参数的列是变量一样。这使得我们可以用 x 代替 purchases_ordered$x

❷ 计算运行总和

data.table 解决方案

library("data.table")

DT_purchases <- as.data.table(purchases)

order_cols <- c("day", "hour")              ❶
setorderv(DT_purchases, order_cols)
DT_purchases[ , running_total := cumsum(n_purchase)]

# print(DT_purchases)

❶ 重新排序数据


:= 和 []

改变数据位置的操作(如 :=)会注释结果以抑制打印。这很重要,因为通常你正在处理大型结构,并且不希望中间数据打印出来。[] 是一个无操作,作为副作用恢复打印。


setorderv()就地重新排序数据,并接受一个排序列名的列表来指定顺序。这比基础 R 解决方案更方便,后者需要多个排序列作为多个参数。wrapr::orderv()试图通过允许用户使用列的列表(列值,而不是列名)来指定排序约束来弥合这一差距。

dplyr 解决方案

dplyr使用单词arrange来排序数据,并使用mutate来添加新列:

library("dplyr")

res <- purchases %>%
  arrange(., day, hour) %>%
  mutate(., running_total = cumsum(n_purchase))

# print(res)

高级排序使用

对于我们的高级示例,假设我们想要按日计算销售总额——也就是说,在每天的开始重置总和。

基础 R 解决方案

这是最简单的基于基础 R 的解决方案,采用拆分和重新组合策略:

order_index <- with(purchases, order(day, hour))                  ❶
purchases_ordered <- purchases[order_index, , drop = FALSE]

data_list <- split(purchases_ordered, purchases_ordered$day)      ❷

data_list <- lapply(                                              ❸
  data_list,
  function(di) {
    di$running_total <- cumsum(di$n_purchase)
    di
  })

purchases_ordered <- do.call(base::rbind, data_list)              ❹
rownames(purchases_ordered) <- NULL                               ❺

purchases_ordered

##   day hour n_purchase running_total
## 1   1    9          5             5
## 2   1   13          1             6
## 3   1   14          1             7
## 4   2    9          3             3
## 5   2   11          5             8
## 6   2   13          3            11

❶ 首先对数据进行排序

❷ 现在将数据拆分成一组组

❸ 对每个组应用 cumsum

❹ 将结果重新组合成一个单独的数据框

❺ R 通常在 rownames()中保留注释。在这种情况下,它存储了我们正在组装的各个部分的原始行号。当打印时,这可能会让用户困惑,因此,像我们在这里所做的那样,删除这些注释是良好的实践。

data.table 解决方案

data.table解决方案特别简洁。我们排序数据,然后告诉data.table使用by参数按组计算新的运行总和。分组是计算的性质,而不是数据的性质,这一想法与 SQL 类似,有助于最小化错误。


:= 与 =

data.table中,:=表示“就地分配”——它用于更改或创建传入的data.table中的列。相反,=用于表示“创建新的data.table”,我们用.()符号包装这些类型的赋值,以避免列名与data.table的参数混淆。


library("data.table")

# new copy for result solution
DT_purchases <- as.data.table(purchases)[order(day, hour),
             .(hour = hour,
               n_purchase = n_purchase,
               running_total = cumsum(n_purchase)),
             by = "day"]                                          ❶
# print(DT_purchases)                                             ❷

# in-place solution
DT_purchases <- as.data.table(purchases)
order_cols <- c("day", "hour")
setorderv(DT_purchases, order_cols)
DT_purchases[ , running_total := cumsum(n_purchase), by = day]
# print(DT_purchases)                                             ❸

# don't reorder the actual data variation!
DT_purchases <- as.data.table(purchases)
DT_purchases[order(day, hour),
             `:=`(hour = hour,
               n_purchase = n_purchase,
               running_total = cumsum(n_purchase)),
             by = "day"]

# print(DT_purchases)                                             ❹

❶ 添加 by 关键字将计算转换为按组计算。

❷ 第一种解决方案:结果是数据的第二个副本。(=)符号。只有用于计算的列(如日期)和明确分配的列在结果中。

❸ 第二种解决方案:在分组计算之前对表进行排序,结果就地计算。

❹ 第三种解决方案:结果与原始表顺序相同,但累积总和是按排序表、计算分组运行总和然后返回到原始顺序的方式计算的。


data.table操作的排序

data.table操作的排序可以通过连续写入就地操作(就像我们在这些示例中所做的那样)或在一个关闭的]之后开始一个新的开放的``来创建新副本的操作(这称为方法链,相当于使用管道操作符)来实现。


dplyr 解决方案

dplyr解决方案之所以有效,是因为mutate()命令(我们将在下一节中讨论)如果数据已分组,则按组工作。我们可以使用group_by()命令使数据分组:

library("dplyr")

res <- purchases %>%
  arrange(., day, hour) %>%
  group_by(., day) %>%
  mutate(., running_total = cumsum(n_purchase)) %>%
  ungroup(.)

# print(res)

ungroup()

dplyr 中,当您完成对每个组操作的执行时,始终要取消分组您的数据。这是因为 dplyr 分组注释的存在可能会导致许多后续步骤计算出不期望和不正确的结果。我们建议即使在 summarize() 步骤之后也要这样做,因为 summarize() 会移除一个关键分组,使得代码读者不清楚数据是否仍然分组。


5.2. 基本数据转换

本节介绍添加和重命名列。

5.2.1. 添加新列

本节涵盖向数据框添加新变量(列)或对现有列应用转换(参见 [图 5.6)。

图 5.6. 添加或修改列

图片

示例数据

对于我们的示例数据,我们将使用 1973 年的空气质量测量数据,其中包含缺失数据和非标准日期格式:

library("datasets")
library("ggplot2")

summary(airquality)

##      Ozone           Solar.R           Wind             Temp
##  Min.   :  1.00   Min.   :  7.0   Min.   : 1.700   Min.   :56.00
##  1st Qu.: 18.00   1st Qu.:115.8   1st Qu.: 7.400   1st Qu.:72.00
##  Median : 31.50   Median :205.0   Median : 9.700   Median :79.00
##  Mean   : 42.13   Mean   :185.9   Mean   : 9.958   Mean   :77.88
##  3rd Qu.: 63.25   3rd Qu.:258.8   3rd Qu.:11.500   3rd Qu.:85.00
##  Max.   :168.00   Max.   :334.0   Max.   :20.700   Max.   :97.00
##  NA's   :37       NA's   :7
##      Month            Day
##  Min.   :5.000   Min.   : 1.0
##  1st Qu.:6.000   1st Qu.: 8.0
##  Median :7.000   Median :16.0
##  Mean   :6.993   Mean   :15.8
##  3rd Qu.:8.000   3rd Qu.:23.0
##  Max.   :9.000   Max.   :31.0
##

场景

我们被要求将这种非标准的日期表示转换为一个新的、更有用的日期列,以便进行查询和绘图。

library("lubridate")
library("ggplot2")

# create a function to make the date string.
datestr = function(day, month, year) {
  paste(day, month, year, sep="-")
}

Base R 解决方案

在基础 R 中,我们通过赋值来创建新列:

airquality_with_date <- airquality                                    ❶

airquality_with_date$date <- with(airquality_with_date,               ❷
                                   dmy(datestr(Day, Month, 1973)))

airquality_with_date <- airquality_with_date[,                        ❸
                                              c("Ozone", "date"),
                                              drop = FALSE]

head(airquality_with_date)                                            ❹

##   Ozone       date
## 1    41 1973-05-01
## 2    36 1973-05-02
## 3    12 1973-05-03
## 4    18 1973-05-04
## 5    NA 1973-05-05
## 6    28 1973-05-06
ggplot(airquality_with_date, aes(x = date, y = Ozone)) +              ❺
  geom_point() +
  geom_line() +
  xlab("Date") +
  ggtitle("New York ozone readings, May 1 - Sept 30, 1973")

❶ 构建数据的副本

a

❷ 使用 with() 添加日期列,无需引用表名即可引用列

❸ 限制到感兴趣的列

❹ 显示结果

❺ 绘制结果

上述代码生成了 图 5.7。

图 5.7. 臭氧图示例

图片

基础 R 已经有这些基本操作符的转换样式(或可管道化)版本了(只是没有管道!)。让我们再次以这种方式处理这个例子:

library("wrapr")                                                   ❶

airquality %.>%                                                    ❷
  transform(., date = dmy(datestr(Day, Month, 1973))) %.>%
  subset(., !is.na(Ozone), select =  c("Ozone", "date")) %.>%
  head(.)
##   Ozone       date
## 1    41 1973-05-01
## 2    36 1973-05-02
## 3    12 1973-05-03
## 4    18 1973-05-04
## 6    28 1973-05-06
## 7    23 1973-05-07

❶ 将 wrapr 包附加到定义 wrapr 点箭头管道:%.>%. 点箭头管道是另一个 R 管道,在 R 项目的期刊中有所描述,请参阅 journal.r-project.org/archive/2018/RJ-2018-042/index.html

❷ 使用 transform() 和 subset() 重复所有步骤,并添加一个额外的步骤,过滤掉没有缺失臭氧值的行

data.table 解决方案

data.table 使用 := 来显示“就地”发生的列更改或创建(当前 data.table 被更改,而不是创建一个新的)。

library("data.table")

DT_airquality <-
  as.data.table(airquality)[                       ❶
     , date := dmy(datestr(Day, Month, 1973)) ][   ❷
       , c("Ozone", "date")]                       ❸

head(DT_airquality)

##    Ozone       date
## 1:    41 1973-05-01
## 2:    36 1973-05-02
## 3:    12 1973-05-03
## 4:    18 1973-05-04
## 5:    NA 1973-05-05
## 6:    28 1973-05-06

❶ 构建数据的 data.table 副本

❷ 添加日期列

❸ 限制到感兴趣的列

注意到开放的 [ 步骤如何大量地像管道一样工作,将一个 data.table 阶段连接到另一个。这是 data.table[] 中放置许多操作的原因之一:在 R 中,[] 自然地按从左到右的顺序链式操作。

dplyr 解决方案

dplyr 用户会记得在 dplyr 中,新列是通过 mutate() 命令生成的:

library("dplyr")

airquality_with_date2 <- airquality %>%
  mutate(., date = dmy(datestr(Day, Month, 1973))) %>%
  select(., Ozone, date)

head(airquality_with_date2)

##   Ozone       date
## 1    41 1973-05-01
## 2    36 1973-05-02
## 3    12 1973-05-03
## 4    18 1973-05-04
## 5    NA 1973-05-05
## 6    28 1973-05-06

情景继续

注意原始臭氧图中的数据有孔,这是由于缺失值造成的。我们将尝试通过将最后已知的臭氧读数传播到有缺失值的日期来修复这个问题。这种“任务完成了……直到我们查看结果”的情况是数据科学的典型情况。所以总是要查看,并寻找问题。

在图 5.8 中展示了从列中较早的缺失值填充。

图 5.8. 填充缺失值

zoo 包提供了一个名为 na.locf() 的函数,该函数旨在解决我们的问题。我们现在将展示如何应用此函数。

基础 R 解决方案

library("zoo")

airquality_corrected <- airquality_with_date
airquality_corrected$OzoneCorrected <-
  na.locf(airquality_corrected$Ozone, na.rm = FALSE)

summary(airquality_corrected)

##      Ozone             date            OzoneCorrected
##  Min.   :  1.00   Min.   :1973-05-01   Min.   :  1.00
##  1st Qu.: 18.00   1st Qu.:1973-06-08   1st Qu.: 16.00
##  Median : 31.50   Median :1973-07-16   Median : 30.00
##  Mean   : 42.13   Mean   :1973-07-16   Mean   : 39.78
##  3rd Qu.: 63.25   3rd Qu.:1973-08-23   3rd Qu.: 52.00
##  Max.   :168.00   Max.   :1973-09-30   Max.   :168.00
##  NA's   :37

ggplot(airquality_corrected, aes(x = date, y = Ozone)) +
  geom_point(aes(y=Ozone)) +
  geom_line(aes(y=OzoneCorrected)) +
  ggtitle("New York ozone readings, May 1 - Sept 30, 1973",
          subtitle = "(corrected)") +
  xlab("Date")

这生成了 图 5.9。

图 5.9. 再次展示臭氧图


使用 na.rm = FALSE

总是使用 na.rm = FALSEna.locf() 一起;否则,它可能会删除数据中的初始 NA 元素。


data.table 解决方案

library("data.table")
library("zoo")

DT_airquality[, OzoneCorrected := na.locf(Ozone, na.rm=FALSE)]

summary(DT_airquality)

##      Ozone             date            OzoneCorrected
##  Min.   :  1.00   Min.   :1973-05-01   Min.   :  1.00
##  1st Qu.: 18.00   1st Qu.:1973-06-08   1st Qu.: 16.00
##  Median : 31.50   Median :1973-07-16   Median : 30.00
##  Mean   : 42.13   Mean   :1973-07-16   Mean   : 39.78
##  3rd Qu.: 63.25   3rd Qu.:1973-08-23   3rd Qu.: 52.00
##  Max.   :168.00   Max.   :1973-09-30   Max.   :168.00
##  NA's   :37

注意 data.tableDT_airquality 中“就地”进行了修正,而不是生成一个新的 data.frame

dplyr 解决方案

library("dplyr")
library("zoo")

airquality_with_date %>%
  mutate(.,
         OzoneCorrected = na.locf(Ozone, na.rm = FALSE)) %>%
  summary(.)

##      Ozone             date            OzoneCorrected
##  Min.   :  1.00   Min.   :1973-05-01   Min.   :  1.00
##  1st Qu.: 18.00   1st Qu.:1973-06-08   1st Qu.: 16.00
##  Median : 31.50   Median :1973-07-16   Median : 30.00
##  Mean   : 42.13   Mean   :1973-07-16   Mean   : 39.78
##  3rd Qu.: 63.25   3rd Qu.:1973-08-23   3rd Qu.: 52.00
##  Max.   :168.00   Max.   :1973-09-30   Max.   :168.00
##  NA's   :37

5.2.2. 其他简单操作

在处理数据时,有许多常用的简单操作可用——特别是通过直接更改列名来重命名列,以及通过分配 NULL 来删除列。我们将简要展示这些操作:

d <- data.frame(x = 1:2, y = 3:4)
print(d)
#>   x y
#> 1 1 3
#> 2 2 4

colnames(d) <- c("BIGX", "BIGY")
print(d)
#>   BIGX BIGY
#> 1    1    3
#> 2    2    4

d$BIGX <- NULL
print(d)
#>   BIGY
#> 1    3
#> 2    4

5.3. 聚合转换

本节涵盖了结合多行或多列的转换。

5.3.1. 将多行合并为汇总行

在这里,我们处理了存在多个观察或测量值的情况,在这种情况下是鸢尾花的物种,我们希望将其聚合为单个观察值。

场景

我们被要求制作一份总结报告,总结鸢尾花花瓣按物种的分类。

问题

按类别汇总测量值,如图 5.10 所示。

图 5.10. 聚合行

示例数据

再次,我们使用来自 iris 数据集的鸢尾花花瓣长度和宽度的测量值,按鸢尾花种类:

library("datasets")
library("ggplot2")

head(iris)

##   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
## 1          5.1         3.5          1.4         0.2  setosa
## 2          4.9         3.0          1.4         0.2  setosa
## 3          4.7         3.2          1.3         0.2  setosa
## 4          4.6         3.1          1.5         0.2  setosa
## 5          5.0         3.6          1.4         0.2  setosa
## 6          5.4         3.9          1.7         0.4  setosa

基础 R 解决方案

iris_summary <- aggregate(
  cbind(Petal.Length, Petal.Width) ~ Species,
  data = iris,
  FUN = mean)

print(iris_summary)

#      Species Petal.Length Petal.Width
# 1     setosa        1.462       0.246
# 2 versicolor        4.260       1.326
# 3  virginica        5.552       2.026

library(ggplot2)
ggplot(mapping = aes(x = Petal.Length, y = Petal.Width,
                     shape = Species, color = Species)) +
  geom_point(data = iris, # raw data
             alpha = 0.5) +
  geom_point(data = iris_summary, # per-group summaries
             size = 5) +
  ggtitle("Average Petal dimensions by iris species\n(with raw data for refer
     ence)")

这生成了 图 5.11,一个新的带有分组平均值的鸢尾花图。

图 5.11. 鸢尾花图

data.table 解决方案

library("data.table")

iris_data.table <- as.data.table(iris)
iris_data.table <- iris_data.table[,
                                   .(Petal.Length = mean(Petal.Length),
                                     Petal.Width = mean(Petal.Width)),
                                   by = .(Species)]

# print(iris_data.table)

dplyr 解决方案

  • dplyr::group_by

  • dplyr::summarize

  • 一个单参数聚合函数,例如 summean

library("dplyr")

iris_summary <- iris %>% group_by(., Species) %>%
  summarize(.,
            Petal.Length = mean(Petal.Length),
            Petal.Width = mean(Petal.Width)) %>%
  ungroup(.)

# print(iris_summary)

窗口函数

data.tabledplyr 都有前面操作的分组版本(类似于关系数据库中称为 窗口函数 的内容)。这允许每行包含每个组的汇总,而无需构建汇总表和连接(计算此类量的常用方法)。例如:

iris_copy <- iris
iris_copy$mean_Petal.Length <-
      ave(iris$Petal.Length, iris$Species, FUN = mean)
iris_copy$mean_Petal.Width <- ave(iris$Petal.Width, iris$Species, FUN = mean)

# head(iris_copy)
# tail(iris_copy)

data.table 中,任务看起来如下:

library("data.table")

iris_data.table <- as.data.table(iris)

iris_data.table[ ,
                 `:=`(mean_Petal.Length = mean(Petal.Length),
                      mean_Petal.Width = mean(Petal.Width)),
                 by = "Species"]

# print(iris_data.table)

请运行前面的代码并打印 iris_data.table,以查看计算出的平均值是按组计算的。

dplyr 有类似的功能:

library("dplyr")

iris_dplyr <- iris %>%
  group_by(., Species) %>%
  mutate(.,
         mean_Petal.Length = mean(Petal.Length),
         mean_Petal.Width = mean(Petal.Width)) %>%
  ungroup(.)

# head(iris_dplyr)

再次强调,在应用每个组的转换时,必须执行 ungroup()。此外,请注意,dplyr 分组操作(特别是通过 filter() 进行行选择)通常比未分组操作慢得多,因此您希望将 group()/ ungroup() 间隔尽可能缩短。并且通常 dplyr 分组操作比 data.table 分组操作慢。

5.4. 多表数据转换

本节涵盖了多个表之间的操作。这包括拆分表、连接表和连接表的任务。

5.4.1. 快速合并两个或多个有序数据框

在这里,我们讨论合并具有相同行数或列数(以及相同顺序)的两个数据框。一种更复杂但更通用的合并数据的方法在 5.4.2 节中演示。

场景

我们被要求从销售数据库中提取有关产品的信息并生成报告。通常,不同的事实(在本例中为价格和销售单位)存储在不同的表中,因此为了生成我们的报告,我们将不得不从多个表中组合数据。

例如,假设我们的示例数据如下:

productTable <- wrapr::build_frame(
   "productID", "price" |
   "p1"       , 9.99    |
   "p2"       , 16.29   |
   "p3"       , 19.99   |
   "p4"       , 5.49    |
   "p5"       , 24.49   )

salesTable <- wrapr::build_frame(
   "productID", "sold_store", "sold_online" |
   "p1"       , 6           , 64            |
   "p2"       , 31          , 1             |
   "p3"       , 30          , 23            |
   "p4"       , 31          , 67            |
   "p5"       , 43          , 51            )

productTable2 <- wrapr::build_frame(
   "productID", "price" |
   "n1"       , 25.49   |
   "n2"       , 33.99   |
   "n3"       , 17.99   )

productTable$productID <- factor(productTable$productID)
productTable2$productID <- factor(productTable2$productID)

问题 1:追加行

当两个表具有完全相同的列结构时,我们可以将它们连接起来以获得更大的表,如图 5.12 所示。

图 5.12. 行合并

基础 R 解决方案

rbind

rbind_base = rbind(productTable,
                   productTable2)

注意到 rbind 在合并不兼容的因子变量时创建一个新的因子变量:

str(rbind_base)

## 'data.frame':    8 obs. of  2 variables:
##  $ productID: Factor w/ 8 levels "p1","p2","p3",..: 1 2 3 4 5 6 7 8
##  $ price    : num  9.99 16.29 19.99 5.49 24.49 ...

data.table 解决方案

library("data.table")

rbindlist(list(productTable,
               productTable2))

##    productID price
## 1:        p1  9.99
## 2:        p2 16.29
## 3:        p3 19.99
## 4:        p4  5.49
## 5:        p5 24.49
## 6:        n1 25.49
## 7:        n2 33.99
## 8:        n3 17.99

data.table 还正确合并因子类型。

dplyr 解决方案

dplyr::bind_rows

library("dplyr")

bind_rows(list(productTable,
               productTable2))

## Warning in bind_rows_(x, .id): Unequal factor levels: coercing to character

## Warning in bind_rows_(x, .id): binding character and factor vector,
## coercing into character vector

## Warning in bind_rows_(x, .id): binding character and factor vector,
## coercing into character vector

##   productID price
## 1        p1  9.99
## 2        p2 16.29
## 3        p3 19.99
## 4        p4  5.49
## 5        p5 24.49
## 6        n1 25.49
## 7        n2 33.99
## 8        n3 17.99

注意到 bind_rows 将不兼容的因子变量强制转换为字符。

问题 2:拆分表

行绑定(row binding)的逆操作是拆分。通过将数据框拆分为一系列数据框,然后对每个数据框进行操作,最后将它们重新绑定在一起,可以简化许多复杂的计算。data.table 中的实现是最好的,它有一些优先级(因为它是第一批之一)。na.rm = FALSE 仅模拟拆分和重组数据(因此通常非常快)。

基础 R 解决方案

# add an extra column telling us which table
# each row comes from
productTable_marked <- productTable
productTable_marked$table <- "productTable"
productTable2_marked <- productTable2
productTable2_marked$table <- "productTable2"

# combine the tables
rbind_base <- rbind(productTable_marked,
                    productTable2_marked)
rbind_base

##   productID price         table
## 1        p1  9.99  productTable
## 2        p2 16.29  productTable
## 3        p3 19.99  productTable
## 4        p4  5.49  productTable
## 5        p5 24.49  productTable
## 6        n1 25.49 productTable2
## 7        n2 33.99 productTable2
## 8        n3 17.99 productTable2

# split them apart
tables <- split(rbind_base, rbind_base$table)
tables

## $productTable
##   productID price        table
## 1        p1  9.99 productTable
## 2        p2 16.29 productTable
## 3        p3 19.99 productTable
## 4        p4  5.49 productTable
## 5        p5 24.49 productTable
##
## $productTable2
##   productID price         table
## 6        n1 25.49 productTable2
## 7        n2 33.99 productTable2
## 8        n3 17.99 productTable2

data.table 解决方案

data.table 将拆分、应用和重组步骤合并为一个单一且非常高效的运算。我们将继续使用 rbind_base 对象来展示其效果。data.table 愿意为每个数据组调用用户函数或执行用户表达式,并为每个组提供特殊变量以进行工作:

  • .BY 一个命名列表,包含分组变量和每个组的值。.BY 是一个标量列表,因为根据定义,分组变量在每个组中不变化。

  • .SD 给定组的行集的 data.table 表示,分组列已被移除。

例如,为了计算每个组的最大价格,我们可以执行以下操作:

library("data.table")

# convert to data.table
dt <- as.data.table(rbind_base)

# arbitrary user defined function
f <- function(.BY, .SD) {
  max(.SD$price)
}

# apply the function to each group
# and collect results
dt[ , max_price := f(.BY, .SD), by = table]

print(dt)

##    productID price         table max_price
## 1:        p1  9.99  productTable     24.49
## 2:        p2 16.29  productTable     24.49
## 3:        p3 19.99  productTable     24.49
## 4:        p4  5.49  productTable     24.49
## 5:        p5 24.49  productTable     24.49
## 6:        n1 25.49 productTable2     33.99
## 7:        n2 33.99 productTable2     33.99
## 8:        n3 17.99 productTable2     33.99

注意,前面的内容是一个强大的通用形式,对于这样一个简单的任务并不需要。通常通过命名列来实现简单的按组聚合值:

library("data.table")

dt <- as.data.table(rbind_base)
grouping_column <- "table"
dt[ , max_price := max(price), by = eval(grouping_column)]

print(dt)

##    productID price         table max_price
## 1:        p1  9.99  productTable     24.49
## 2:        p2 16.29  productTable     24.49
## 3:        p3 19.99  productTable     24.49
## 4:        p4  5.49  productTable     24.49
## 5:        p5 24.49  productTable     24.49
## 6:        n1 25.49 productTable2     33.99
## 7:        n2 33.99 productTable2     33.99
## 8:        n3 17.99 productTable2     33.99

在这个例子中,我们展示了如何通过变量选择的列进行分组。

dplyr 解决方案

dplyr 没有自己的分割实现。dplyr 尝试通过其 group_by() 符号来模拟在子表上工作。例如,要在 dplyr 中计算每个组的最大价格,我们会编写如下代码:

rbind_base %>%
  group_by(., table) %>%
  mutate(., max_price = max(price)) %>%
  ungroup(.)

## # A tibble: 8 x 4
##   productID price table         max_price
##   <fct>     <dbl> <chr>             <dbl>
## 1 p1         9.99 productTable       24.5
## 2 p2        16.3  productTable       24.5
## 3 p3        20.0  productTable       24.5
## 4 p4         5.49 productTable       24.5
## 5 p5        24.5  productTable       24.5
## 6 n1        25.5  productTable2      34.0
## 7 n2        34.0  productTable2      34.0
## 8 n3        18.0  productTable2      34.0

这将不会像为每个数据组调用任意函数那样强大。

问题 3:追加列

将数据框作为列追加到另一个数据框中。数据框必须具有相同数量的行和相同的行顺序(相对于我们认为是行键的内容)。这在图 5.13 中得到了说明。

图 5.13. 合并列

图像 5.13

productTablesalesTable 创建一个产品信息表(价格和销售单位)。这假设产品在这两个表中按相同的顺序排序。如果它们不是,那么对它们进行排序,或者使用连接命令将表合并在一起(请参阅第 5.4.2 节)。

基础 R 解决方案

cbind

cbind(productTable, salesTable[, -1])

##   productID price sold_store sold_online
## 1        p1  9.99          6          64
## 2        p2 16.29         31           1
## 3        p3 19.99         30          23
## 4        p4  5.49         31          67
## 5        p5 24.49         43          51

data.table 解决方案

对于绑定列,data.table 方法要求数据已经为 data.table 类型。

library("data.table")

cbind(as.data.table(productTable),
      as.data.table(salesTable[, -1]))

##    productID price sold_store sold_online
## 1:        p1  9.99          6          64
## 2:        p2 16.29         31           1
## 3:        p3 19.99         30          23
## 4:        p4  5.49         31          67
## 5:        p5 24.49         43          51

dplyr 解决方案

dplyr::bind_cols

library("dplyr")

# list of data frames calling convention
dplyr::bind_cols(list(productTable, salesTable[, -1]))

##   productID price sold_store sold_online
## 1        p1  9.99          6          64
## 2        p2 16.29         31           1
## 3        p3 19.99         30          23
## 4        p4  5.49         31          67
## 5        p5 24.49         43          51

5.4.2. 从多个表中组合数据的主要方法

连接是关系数据库中合并两个表以创建第三个表的过程。连接的结果可能是一个表,它可能对于原始两个表中的每一对行都有一个新行(以及可能来自每个表没有匹配的其他表中的行)。行是通过键值匹配的,从一个表匹配到另一个表。最简单的情况是,每个表都有一组列,这些列可以唯一确定每一行(一个唯一键),这是我们在这里要讨论的情况。

场景

我们的示例数据是销售数据库中产品信息。各种事实(在这种情况下,价格和销售单位)存储在不同的表中。允许存在缺失值。我们的任务是组合这些表以生成报告。

首先,让我们设置一些示例数据:

productTable <- wrapr::build_frame(
   "productID", "price" |
   "p1"       , 9.99    |
   "p3"       , 19.99   |
   "p4"       , 5.49    |
   "p5"       , 24.49   )

salesTable <- wrapr::build_frame(
   "productID", "unitsSold" |
   "p1"       , 10          |
   "p2"       , 43          |
   "p3"       , 55          |
   "p4"       , 8           )

左连接

对于数据科学家来说,最重要的连接可能是左连接。这种连接保留左表中的每一行,并添加来自右表中匹配行的列。如果没有匹配的行,则用 NA 值替换。通常,你设计右表(连接命令的第二个参数)具有唯一的键;否则,行数可能会增加(左表不需要有唯一的键)。

该操作通常用于将来自第二个(或右)表的数据附加到第一个或左表的副本中,如图 5.14 所示。

图 5.14. 左连接

图像 5.14

基础 R 解决方案

merge函数,其中all.x = TRUE

merge(productTable, salesTable, by = "productID", all.x = TRUE)

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p3 19.99        55
## 3        p4  5.49         8
## 4        p5 24.49        NA

data.table 解决方案

library("data.table")

productTable_data.table <- as.data.table(productTable)
salesTable_data.table <- as.data.table(salesTable)

# index notation for join
# idea is rows are produced for each row inside the []
salesTable_data.table[productTable_data.table, on = "productID"]

##    productID unitsSold price
## 1:        p1        10  9.99
## 2:        p3        55 19.99
## 3:        p4         8  5.49
## 4:        p5        NA 24.49

# data.table also overrides merge()
merge(productTable, salesTable, by = "productID", all.x = TRUE)

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p3 19.99        55
## 3        p4  5.49         8
## 4        p5 24.49        NA

Base R 索引解决方案

data.table索引表示法提醒我们,还有另一种非常好的 Base R 方法,即使用一个表向另一个表添加单个列:通过match()[]方法进行向量化查找。

library("data.table")

joined_table <- productTable
joined_table$unitsSold <- salesTable$unitsSold[match(joined_table$productID,
salesTable$productID)]
print(joined_table)

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p3 19.99        55
## 3        p4  5.49         8
## 4        p5 24.49        NA

match()找到了匹配的索引,[]使用这些索引检索数据。请参阅help(match)获取更多详细信息。

dplyr 解决方案

library("dplyr")

left_join(productTable, salesTable, by = "productID")

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p3 19.99        55
## 3        p4  5.49         8
## 4        p5 24.49        NA

右连接

还有一种称为右连接的连接,它只是将左连接的参数反转。由于右连接与左连接非常相似,我们将省略任何右连接的示例。

内连接

在一个内连接中,你将两个表合并成一个单一的表,只保留两个表中都存在的键的行。这将产生两个表的交集,如图 5.15 所示。

图 5.15. 内连接

Base R 解决方案

merge

merge(productTable, salesTable, by = "productID")

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p3 19.99        55
## 3        p4  5.49         8

data.table 解决方案

library("data.table")

productTable_data.table <- as.data.table(productTable)
salesTable_data.table <- as.data.table(salesTable)

merge(productTable, salesTable, by = "productID")

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p3 19.99        55
## 3        p4  5.49         8

dplyr 解决方案

inner_join

library("dplyr")

inner_join(productTable, salesTable, by = "productID")

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p3 19.99        55
## 3        p4  5.49         8

全连接

在一个全连接中,你将两个表合并成一个单一的表,保留所有键值对应的行。注意,在这里两个表具有相同的重要性。我们将在图 5.16 中展示结果。

图 5.16. 全连接

Base R 解决方案

merge函数,其中all=TRUE

# note that merge orders the result by key column by default
# use sort=FALSE to skip the sorting
merge(productTable, salesTable, by = "productID", all=TRUE)

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p2    NA        43
## 3        p3 19.99        55
## 4        p4  5.49         8
## 5        p5 24.49        NA

data.table 解决方案

library("data.table")

productTable_data.table <- as.data.table(productTable)
salesTable_data.table <- as.data.table(salesTable)

merge(productTable_data.table, salesTable_data.table,
      by = "productID", all = TRUE)

##    productID price unitsSold
## 1:        p1  9.99        10
## 2:        p2    NA        43
## 3:        p3 19.99        55
## 4:        p4  5.49         8
## 5:        p5 24.49        NA

dplyr 解决方案

dplyr::full_join

library("dplyr")

full_join(productTable, salesTable, by = "productID")

##   productID price unitsSold
## 1        p1  9.99        10
## 2        p3 19.99        55
## 3        p4  5.49         8
## 4        p5 24.49        NA
## 5        p2    NA        43

一个更复杂的连接问题

我们到目前为止给出的示例没有使用行顺序。有些问题可以通过使用行顺序的方法解决得更加高效,例如data.table强大的滚动连接操作。

场景

你被提供了历史股票交易和报价(买入/卖出)数据。你被要求对股票数据进行以下分析:找出每次交易时的买入价和卖出价。这涉及到使用行顺序来表示时间,并在行之间共享信息。

示例数据

在股票市场中,买入价是某人声明的愿意支付的最高价格,而卖出价是某人声明的愿意以该价格出售股票的最低价格。买入价和卖出价数据称为报价,它们通常是不规则的时序数据(因为新的报价可以在任意时间形成,而不仅仅是定期间隔),如下面的示例所示:

library("data.table")

quotes <- data.table(
  bid = c(5, 5, 7, 8),
  ask = c(6, 6, 8, 10),
  bid_quantity = c(100, 100, 100, 100),
  ask_quantity = c(100, 100, 100, 100),
  when = as.POSIXct(strptime(
    c("2018-10-18 1:03:17",
      "2018-10-18 2:12:23",
      "2018-10-18 2:15:00",
      "2018-10-18 2:17:51"),
    "%Y-%m-%d %H:%M:%S")))

print(quotes)

##    bid ask bid_quantity ask_quantity                when
## 1:   5   6          100          100 2018-10-18 01:03:17
## 2:   5   6          100          100 2018-10-18 02:12:23
## 3:   7   8          100          100 2018-10-18 02:15:00
## 4:   8  10          100          100 2018-10-18 02:17:51

另一个不规则的时序数据是交易。这些是在特定时间以特定价格交换股票数量的事后报告。

trades <- data.table(
  trade_id = c(32525, 32526),
  price = c(5.5, 9),
  quantity = c(100, 200),
  when = as.POSIXct(strptime(
    c("2018-10-18 2:13:42",
      "2018-10-18 2:19:20"),
    "%Y-%m-%d %H:%M:%S")))

print(trades)

##    trade_id price quantity                when
## 1:    32525   5.5      100 2018-10-18 02:13:42
## 2:    32526   9.0      200 2018-10-18 02:19:20

滚动连接

data.table的滚动连接非常适合找到每个交易的最新报价信息。滚动连接是一种在有序列上的连接类型,它在我们查找时间点提供最新的可用数据。

quotes[, quote_time := when]
      trades[ , trade_time := when ]
      quotes[ trades, on = "when", roll = TRUE ][
        , .(quote_time, bid, price, ask, trade_id, trade_time) ]
##             quote_time bid price ask trade_id          trade_time
## 1: 2018-10-18 02:12:23   5   5.5   6    32525 2018-10-18 02:13:42
## 2: 2018-10-18 02:17:51   8   9.0  10    32526 2018-10-18 02:19:20

我们将前面的内容解读为“对于每一笔交易,查找相应的报价。”在连接中,when字段来自交易,这也是为什么我们添加了quote_time字段,以便我们也能看到报价是在何时建立的。data.table的滚动连接非常快,而且在基础 R、SQLdplyr中也不容易高效地模拟。

滚动连接是data.table独有的。在 R 中,有许多任务,如匹配最新记录,可以很容易地表示为在行之间移动索引。然而,在 R 中,在行之间移动索引通常效率不高,因为它不能像列操作那样向量化。滚动连接是直接解决这类问题的方法,并且有一个高效的实现。

5.5. 重新塑造转换

本节涵盖在行和列之间移动数据。这通常被称为旋转,这个名字来自 Pito Salas 的工作,它结合了数据汇总和形状转换。示例将在三个包中完成:data.tablecdata(它只重新塑造数据,不汇总数据)和tidyr。基础 R 确实有这些转换的符号(如stack()unstack()),但包版本是显著更好的工具。

5.5.1. 将数据从宽格式转换为长格式

我们将展示如何将所有测量值都在单行中的数据记录移动到一个新的记录集中,其中数据在多行中。我们称这种移动为从宽格式到窄或长格式。

数据示例

让我们以按月份测量的车辆驾驶员/乘客受伤或死亡的数据为例。数据还包括有关燃料价格以及法律是否要求使用安全带的信息。

本例的相关变量:

  • date 测量年份和月份(数字表示)

  • DriversKilled 被杀害的汽车驾驶员

  • front 前排乘客死亡或严重受伤

  • rear 后排乘客死亡或严重受伤

  • law 安全带法律是否生效(0/1)

library("datasets")
library("xts")

# move the date index into a column
dates <- index(as.xts(time(Seatbelts)))
Seatbelts <- data.frame(Seatbelts)
Seatbelts$date <- dates

# restrict down to 1982 and 1983
Seatbelts <- Seatbelts[ (Seatbelts$date >= as.yearmon("Jan 1982")) &
                          (Seatbelts$date <= as.yearmon("Dec 1983")),
                           , drop = FALSE]
Seatbelts$date <- as.Date(Seatbelts$date)
# mark if the seatbelt law was in effect
Seatbelts$law <- ifelse(Seatbelts$law==1, "new law", "pre-law")
# limit down to the columns we want
Seatbelts <- Seatbelts[, c("date", "DriversKilled", "front", "rear", "law")]

head(Seatbelts)

##           date DriversKilled front rear     law
## 157 1982-01-01           115   595  238 pre-law
## 158 1982-02-01           104   673  285 pre-law
## 159 1982-03-01           131   660  324 pre-law
## 160 1982-04-01           108   676  346 pre-law
## 161 1982-05-01           103   755  410 pre-law
## 162 1982-06-01           115   815  411 pre-law

为了将我们的数据转换成可展示的格式,我们已经在本章前面的部分中描述了转换操作:选择行,选择列,添加新的派生列等等。现在数据中每行对应一个日期(我们将日期视为行键),并包含诸如在三个座位位置(驾驶员、前排、后排)中各有多少人死亡以及新的安全带法律是否生效等信息。

我们想看看新的安全带法律是否能够挽救生命。请注意,我们缺少一个关键信息:一个标准化因子,例如每日期的汽车拥有数量,按日期划分的驾驶人口规模,或每日期的总行驶里程(风险作为比率比作为绝对计数更有意义)。这是一个真正的数据科学是迭代过程的例子:我们将尽我们所能使用现有的数据,但在实际项目中,我们也会回到来源和合作伙伴那里,试图获取关键的缺失数据(或者至少是缺失数据的估计或代理)。

让我们根据法律绘制数据:

# let's give an example of the kind of graph we have in mind,
# using just driver deaths
library("ggplot2")

ggplot(Seatbelts,
       aes(x = date, y = DriversKilled, color = law, shape = law)) +
  geom_point() +
  geom_smooth(se=FALSE) +
  ggtitle("UK car driver deaths by month")

此代码生成了图 5.17。

图 5.17. 乘客死亡图表

从图表来看,引入安全带法与死亡数的正常变化相比,死亡人数的下降是非平凡的。这也表明效果可能迅速逆转。

假设我们的后续问题是进一步将数据分解到座位位置(因为安全带类型根据座位位置有很大差异)。

要使用 ggplot2 制作此类图表,我们需要将数据从每行包含所有事实转换为每个座位位置一行。这是一个从宽或非规范化格式(机器学习任务的自然格式)到高或多行记录格式的转换示例。

问题

使用 ggplot2 根据日期和座位位置绘制死亡人数图表。ggplot2 要求数据以长格式而非宽格式。因此,我们将集中讨论如何执行此转换。我们称这种转换是将数据从面向行的记录移动到行块,如图 5.18 所示。

图 5.18. 宽到高的转换

解决方案 1:data.table::melt.data.table()

我们可以用 data.table::melt.data.table() 解决这个问题。使用 measure.vars 参数指定原始表中要取值的列。使用 variable.name(新键列)和 value.name(新值列)参数指定在转换表中写入信息的列对。

library("data.table")

seatbelts_long2 <-
  melt.data.table(as.data.table(Seatbelts),
                  id.vars = NULL,
                  measure.vars = c("DriversKilled", "front", "rear"),
                  variable.name = "victim_type",
                  value.name = "nvictims")

这些新图表确实显示了一些更多信息:法律对后排乘客几乎没有影响。这可能是因为法律没有涵盖这些座位,或许执行后排座位遵守规定很困难,或者后排安全带可能是安全带(而不是三点式约束)并且效果不佳。最大的好处似乎是对前排乘客,这并不太奇怪,因为他们通常配备高质量的座椅安全带,并且不坐在方向盘前面(这是致命伤害的主要来源)。

解决方案 2:cdata::unpivot_to_blocks()

library("cdata")

seatbelts_long3 <- unpivot_to_blocks(
  Seatbelts,
  nameForNewKeyColumn = "victim_type",
  nameForNewValueColumn = "nvictims",
  columnsToTakeFrom = c("DriversKilled", "front", "rear 
"))

cdata 提供了简单的方法来一次性指定多个列的坐标转换。有关介绍,请参阅www.win-vector.com/blog/2018/10/faceted-graphs-with-cdata-and-ggplot2/

我们鼓励您尝试所有三种解决方案,并确信它们产生等效的结果。我们更喜欢 cdata 解决方案,但它较新,不如 data.tabletidyr 解决方案知名。

解决方案 3:tidyr::gather()

library("tidyr")

seatbelts_long1 <- gather(
  Seatbelts,
  key = victim_type,
  value = nvictims,
  DriversKilled, front, rear)

head(seatbelts_long1)
##         date     law   victim_type nvictims
## 1 1982-01-01 pre-law DriversKilled      115
## 2 1982-02-01 pre-law DriversKilled      104
## 3 1982-03-01 pre-law DriversKilled      131
## 4 1982-04-01 pre-law DriversKilled      108
## 5 1982-05-01 pre-law DriversKilled      103
## 6 1982-06-01 pre-law DriversKilled      115

ggplot(seatbelts_long1,
       aes(x = date, y = nvictims, color = law, shape = law)) +
  geom_point() +
  geom_smooth(se=FALSE) +
  facet_wrap(~victim_type, ncol=1, scale="free_y") +
  ggtitle("UK auto fatalities by month and seating position")

我们现在有了按座位位置分组的乘客死亡数据,如图 5.19 所示。

图 5.19. 分面乘客死亡图表

5.5.2. 从高到宽的数据移动

我们得到了以日志风格提供的数据,其中每个测量的细节都写在单独的一行中。通俗地说,我们称这种形式为“高”或“瘦”数据形式(正式上,它与如 RDF 三元组这样的信息存储理念相关)。将数据转换为宽形式的过程非常类似于微软 Excel 用户所说的“旋转”,除了聚合(总和、平均值、计数)不是从高到宽形式转换的严格部分(我们建议在转换之前先进行聚合)。此外,从高到宽形式的转换当然是之前讨论的从宽到高形式转换的逆过程。

数据

对于我们的例子,我们从 R 的datasets包中取了ChickWeight数据。请尝试与本书一起使用这些命令,并采取额外步骤检查数据(使用View()head()summary()等命令):

library("datasets")
library("data.table")
library("ggplot2")

ChickWeight <- data.frame(ChickWeight) # get rid of attributes
ChickWeight$Diet <- NULL # remove the diet label
# pad names with zeros
padz <- function(x, n=max(nchar(x))) gsub(" ", "0", formatC(x, width=n))
# append "Chick" to the chick ids
ChickWeight$Chick <- paste0("Chick", padz(as.character(ChickWeight$Chick)))

head(ChickWeight)

##   weight Time   Chick
## 1     42    0 Chick01
## 2     51    2 Chick01
## 3     59    4 Chick01
## 4     64    6 Chick01
## 5     76    8 Chick01
## 6     93   10 Chick01

这组数据组织得如此之好,以至于每一行都是关于给定时间给定小鸡的一个单一事实(重量)。这是一个非常容易产生和传输的格式,这也是为什么它在科学环境中很受欢迎。为了从数据中进行有趣的工作或从中学习,我们需要将数据带入更宽的结构。对于我们的问题,我们希望所有关于小鸡的重量事实都在一行中,时间作为新的列名。

在做那之前,让我们利用我们之前的一些课程来查看数据。我们可以聚合数据,从关于个体的信息转换到整体趋势。

# aggregate count and mean weight by time
ChickSummary <- as.data.table(ChickWeight)
ChickSummary <- ChickSummary[,
             .(count = .N,
               weight = mean(weight),
               q1_weight = quantile(weight, probs = 0.25),
               q2_weight = quantile(weight, probs = 0.75)),
             by = Time]
head(ChickSummary)

##    Time count    weight q1_weight q2_weight
## 1:    0    50  41.06000        41        42
## 2:    2    50  49.22000        48        51
## 3:    4    49  59.95918        57        63
## 4:    6    49  74.30612        68        80
## 5:    8    49  91.24490        83       102
## 6:   10    49 107.83673        93       124

ChickSummary中,唯一的关键是Time(由data.tableby参数指定),现在我们可以看到在给定时间存活的小鸡数量以及给定时间存活小鸡重量的分布。

我们可以将这个表格以图形方式呈现。要使用ggplot2来完成此操作,我们需要将汇总数据移动到高形式(因为ggplot2更喜欢与高数据一起工作)。我们使用cdata::unpivot_to_blocks:

library("ggplot2")

ChickSummary <- cdata::unpivot_to_blocks(                                ❶
   ChickSummary,
  nameForNewKeyColumn = "measurement",
  nameForNewValueColumn = "value",
  columnsToTakeFrom = c("count", "weight"))

ChickSummary$q1_weight[ChickSummary$measurement=="count"] <- NA          ❷
 ChickSummary$q2_weight[ChickSummary$measurement=="count"] <- NA
CW <- ChickWeight
CW$measurement <- "weight"

ggplot(ChickSummary, aes(x = Time, y = value, color = measurement)) +    ❸
   geom_line(data = CW, aes(x = Time, y = weight, group = Chick),
            color="LightGray") +
  geom_line(size=2) +
  geom_ribbon(aes(ymin = q1_weight, ymax = q2_weight),
              alpha = 0.3, colour = NA) +
  facet_wrap(~measurement, ncol=1, scales = "free_y") +
  theme(legend.position = "none") +
  ylab(NULL) +
  ggtitle("Chick Weight and Count Measurements by Time",
          subtitle = "25% through 75% quartiles of weight shown shaded around
      mean")

❶ 将数据从宽形式转换为高形式以进行绘图

❷ 确保我们有用于绘图的所需列的准确集合

❸ 绘制图表

这为小鸡提供了按时间和小鸡组织起来的重量,如图图 5.20 所示。

图 5.20. 随时间变化的小鸡数量和重量

在这里,我们将存活小鸡的总数作为时间的函数绘制出来,以及每个个体检查的重量轨迹,以及总结统计(平均重量和 25%至 75%的四分位数)。

问题

我们现在可以回到本节示例任务:将每个小鸡的所有信息放入一行中。

图形上,它看起来如下:一列的(meastype)值用作新的列标题,第二列(meas)提供值。我们称这种将数据从块移动到宽行记录的过程,如图图 5.21 所示。

图 5.21. 从高到宽形式转换

解决方案 1:data.table::dcast.data.table()

要使用 dcast.data.table() 将数据移动到宽格式,我们使用带有 ~ 符号的公式指定结果矩阵的行和列。然后我们通过 value.var 参数说明如何填充这个矩阵的单元格。在我们的例子中,为了得到一个每只小鸡一行、每段时间一列,单元格中是重量的数据框,我们使用以下步骤:

library("data.table")

ChickWeight_wide2 <- dcast.data.table(
  as.data.table(ChickWeight),
  Chick ~ Time,
  value.var = "weight")

这个表是一个矩阵,行由小鸡标识,列是时间。单元格包含给定小鸡和时间的重量(如果小鸡没有存活到给定时间,则为 NA)。请注意,这种格式更容易阅读,可能需要用于报告。

data.tabledcast 的实现还允许更强大的转换,例如同时进行变量铸造和聚合。

解决方案 2:cdata::pivot_to_rowrecs()

cdata::pivot_to_rowrecs() 通过行键、从列中获取新列键的列和从列中获取值的列来描述预期的表:

library("cdata")

ChickWeight_wide3 <- pivot_to_rowrecs(
  ChickWeight,
  columnToTakeKeysFrom = "Time",
  columnToTakeValuesFrom = "weight",
  rowKeyColumns = "Chick")

解决方案 3:tidyr::spread()

library("tidyr")

ChickWeight_wide1 <- spread(ChickWeight,
                            key = Time,
                            value = weight)

head(ChickWeight_wide1)

##     Chick  0  2  4  6  8  10  12  14  16  18  20  21
## 1 Chick01 42 51 59 64 76  93 106 125 149 171 199 205
## 2 Chick02 40 49 58 72 84 103 122 138 162 187 209 215
## 3 Chick03 43 39 55 67 84  99 115 138 163 187 198 202
## 4 Chick04 42 49 56 67 74  87 102 108 136 154 160 157
## 5 Chick05 41 42 48 60 79 106 141 164 197 199 220 223
## 6 Chick06 41 49 59 74 97 124 141 148 155 160 160 157

5.5.3. 数据坐标

数据转换有很多细节。需要保留的重要概念是:数据有坐标,例如表名、列名和行标识符。坐标的确切指定方式是实现细节,需要克服或转换为方便的状态。所有这些都是 Codd 数据库设计第二规则的后果:“关系数据库中的每个原子值(数据项)都保证可以通过表名、主键值和列名的组合来逻辑访问。”^([4]) 我们希望您已经学到的是:坐标(或访问计划)的哪些部分是表名,与行键、列名相比,是一个可更改的实现细节。

查看 en.wikipedia.org/wiki/Edgar_F._Codd


倾向于使用简单的代码

早期构建临时表、添加列和更正列名要比有复杂的分析代码好得多。这遵循了 Raymond 的“表示规则”。

Raymond 的“表示规则”


将知识融入数据,使程序逻辑可以简单且健壮。

《Unix 编程艺术》,Erick S. Raymond,Addison-Wesley,2003

我们建议您尽早转换数据以解决问题(更正列名、更改数据布局),以便使后续步骤更容易。您应该尝试转换为用于预测建模的格式,数据库设计者称之为非规范化形式,统计学家称之为多元数据矩阵模型矩阵:一个常规数组,其中行是个人,列是可能的观测值.^([5])

查看 W. J. Krzanowski 和 F. H. C. Marriott 的 多元分析,第一部分,Edward Arnold,1994。

对此感兴趣的读者可能希望深入了解cdata强大的数据布局图示系统,该系统正在被广泛采用,并在此处讨论:github.com/WinVector/cdata

摘要

在本章中,我们处理了用于分析和展示的基本转换数据示例。

到目前为止,我们已经处理了大量的数据转换。自然要问的问题是:这些转换是否足够?我们能否快速将任何任务分解成一系列这些转换?

答案是“不,也不”。有一些更专业的转换,如“滚动窗口”函数和其他时间序列操作,难以用这些转换来表示,但在 R 和data.table中确实有它们自己的高效实现。然而,对于“是”的答案,有很好的理由认为我们学到的转换集是相当全面的。基本的操作转换几乎涵盖了 Edgar F. Codd 的关系代数:自 1970 年以来一直在推动数据工程的一系列转换。

在本章中,您已经学到了

  • 如何使用强大的数据重塑转换目录

  • 如何应用这些转换来解决数据组织问题

在本书的第二部分中,我们将讨论构建和评估模型的过程,以满足您所设定的目标。

第二部分. 模型方法

在第一部分中,我们讨论了数据科学项目的初始阶段。在你更精确地定义了你想要回答的问题以及你想要解决的问题的范围之后,是时候分析数据并寻找答案了。在第二部分中,我们将使用来自统计学和机器学习的强大建模方法。

第六章 讲述了如何识别适当的建模方法来解决你的特定业务问题。它还讨论了如何评估你或他人发现的模型的质量和有效性。

第七章 讲述了基本的线性模型:线性回归、逻辑回归和正则化线性模型。线性模型是许多分析任务的工作马,特别是在识别关键变量和洞察问题结构方面非常有帮助。对这些模型有扎实的理解对于数据科学家来说极其宝贵。

第八章 暂时脱离建模任务,介绍使用 vtreat 包的高级数据准备。vtreat 为建模步骤准备混乱的真实世界数据。由于理解 vtreat 的工作原理需要一些对线性模型和模型评估指标的了解,因此将此主题推迟到第二部分似乎是最好的选择。

第九章 讲述了无监督方法:聚类和关联规则挖掘。无监督方法不进行明确的输出预测;它们在数据中发现关系和隐藏结构。第十章 简要介绍了更多高级建模算法。我们讨论了袋装决策树、随机森林、梯度提升树、广义加性模型和支持向量机。

我们通过具体的数据科学问题和非平凡的数据集来逐一讲解我们涵盖的每一种方法。在适当的情况下,我们还讨论了针对我们涵盖的方法的特定模型评估和解释程序。

在完成第二部分后,你将熟悉最流行的建模方法,并且会对哪些方法最适合回答不同类型的问题有一个感觉。

第六章:选择和评估模型

本章涵盖

  • 将商业问题映射到机器学习任务

  • 评估模型质量

  • 解释模型预测

在本章中,我们将讨论建模过程(图 6.1)。在深入探讨特定机器学习方法的细节之前,我们先讨论这个过程,因为本章讨论的主题适用于任何类型的模型。首先,让我们讨论选择合适的模型方法。

图 6.1. 心理模型

6.1. 将问题映射到机器学习任务

作为数据科学家,您的任务是将商业问题映射到一个好的机器学习方法。让我们看看一个现实世界的情况。假设您是一家在线零售公司的数据科学家。您的团队可能会被要求解决以下一些商业问题:

  • 根据历史交易预测客户可能会购买的商品

  • 识别欺诈交易

  • 确定各种产品或产品类别的价格弹性(价格上升导致销售下降的比率,反之亦然)

  • 确定当客户搜索商品时展示产品列表的最佳方式

  • 客户细分:将具有相似购买行为的客户分组

  • AdWord 估值:公司在搜索引擎上购买特定 AdWords 应花费的金额

  • 评估营销活动

  • 将新产品组织到产品目录中

您对模型的预期用途将对您应使用的方法有很大影响。如果您想知道输入变量的微小变化如何影响结果,那么您可能想使用回归方法。如果您想知道哪个单一变量驱动了分类的大部分,那么决策树可能是一个不错的选择。此外,每个商业问题都建议尝试一种统计方法。为了讨论的目的,我们将数据科学家通常解决的问题的不同类型分组到以下类别中:

  • 分类— 将标签分配给数据

  • 评分— 将数值分配给数据

  • 分组— 在数据中发现模式和共性

在本节中,我们将描述这些问题类别,并列出针对每个类别的典型方法。

6.1.1. 分类问题

让我们尝试以下示例。


示例

假设您的任务是自动化将新产品分配到您公司的产品类别中,如图 6.2 所示。


图 6.2. 将产品分配到产品类别

这可能比听起来更复杂。来自不同来源的产品可能有它们自己的产品分类,这与你在零售网站上使用的分类不一致,或者它们可能没有任何分类。许多大型在线零售商使用由人类标签员组成的团队手动分类他们的产品。这不仅劳动密集,而且不一致且容易出错。自动化是一个吸引人的选择;它节省劳动力,并且可以提高零售网站的质量。

基于产品属性和/或产品文本描述的产品分类是分类的一个例子:决定如何将(已知的)标签分配给一个对象。分类本身是所谓监督学习的一个例子:为了学习如何分类对象,你需要一个已经分类的对象数据集(称为训练集)。构建训练数据是大多数分类任务的主要开销,尤其是与文本相关的任务。


多类别与二类别分类

产品分类是多类别多项式分类的一个例子。大多数分类问题和大多数分类算法都是针对二类别,或二项式,分类专门化的。有一些技巧可以使用二进制分类器来解决多类别问题(例如,为每个类别构建一个分类器,称为一对余分类器)。但在大多数情况下,找到合适的多类别实现是值得努力的,因为它们往往比多个二进制分类器工作得更好(例如,使用mlogit包而不是基础方法glm()进行逻辑回归)。


本书将涵盖的常见分类方法包括逻辑回归(带有阈值)和决策树集成。

6.1.2. 评分问题

评分可以解释如下。


示例

假设你的任务是帮助评估不同的营销活动如何增加对网站有价值的流量。目标是不仅吸引更多的人到网站上,还要吸引更多购买的人。


在这种情况下,你可能需要考虑许多不同的因素:沟通渠道(网站上的广告、YouTube 视频、印刷媒体、电子邮件等);流量来源(Facebook、Google、电台等);目标受众;一年中的时间,等等。你想要衡量这些因素是否增加了销售额,以及增加了多少。

根据这些因素预测特定营销活动带来的销售额增加是回归评分的一个例子。在这种情况下,回归模型将测量的不同因素映射到一个数值:销售额,或从某个基线开始的销售额增加。

预测事件(如属于某个给定类别)的概率也可以被认为是评分。例如,您可能会将欺诈检测视为分类:这个事件是欺诈还是不是?然而,如果您试图估计事件是欺诈的概率,这可以被认为是评分。这如图 图 6.3 所示。评分也是监督学习的一个实例。

图 6.3. 确定交易欺诈概率的概念示例

6.1.3. 分组:在没有已知目标的情况下工作

前面的方法要求您有一个已知结果的训练数据集。在某些情况下,您可能还没有(或尚未)想要预测的具体结果。相反,您可能正在寻找数据中的模式和关系,这将帮助您更好地了解您的客户或您的业务。

这些情况对应于一类称为 无监督学习 的方法:无监督学习的目标不是基于输入预测输出,而是发现数据中的相似性和关系。一些常见的无监督任务包括以下这些:

  • 聚类—— 将相似对象分组在一起

  • 关联规则—— 发现常见的购买模式,例如,总是一起购买的商品,或者总是一起借出的图书馆书籍

让我们进一步探讨这两种无监督方法。

何时使用基本聚类

一个好的聚类示例如下。


示例

假设您想将客户分成具有相似购买模式的通用人群类别。您可能事先不知道这些群体应该是什么。


这个问题非常适合使用 k-means 聚类。k-means 聚类是一种将数据分组的方法,使得同一组内的成员彼此之间比与其他组成员更相似。

假设您发现(如图 图 6.4 所示),您的客户分为有小孩的群体,他们购买更多家庭导向的产品,以及没有小孩或成年子女的群体,他们购买更多休闲和社会活动相关的产品。一旦您将客户分配到这些群体之一,您就可以对他们的一般行为做出概括性陈述。例如,有小孩的客户群体可能对促销耐用的吸引人玻璃器皿的反应更为积极,而不是对精美水晶酒杯的促销。

图 6.4. 根据购买模式和购买金额对客户进行聚类的概念示例

我们将在 第 9.1 节 中更详细地介绍 k-means 和其他聚类方法。

何时使用关联规则

你可能对直接确定哪些产品倾向于一起购买感兴趣。例如,你可能发现泳装和太阳镜经常一起购买,或者购买某些文化电影(如《Repo Man》)的人通常会同时购买电影原声带。

这是对关联规则(甚至推荐系统)的良好应用。你可以挖掘有用的产品推荐:每当观察到有人将泳装放入购物车时,你还可以推荐防晒霜。这如图 6.5 所示。我们将在第 9.2 节中介绍用于发现关联规则的 Apriori 算法。

图 6.5。在数据中寻找购买模式的概念示例

6.1.4. 问题到方法的映射

总结前面的内容,表 6.1 将一些典型的商业问题映射到相应的机器学习任务。

表 6.1。从问题到方法

示例任务 机器学习术语
识别垃圾邮件 在产品目录中排序产品 识别即将违约的贷款 将客户分配到现有的客户群组 分类—将已知的标签分配给对象。分类是一种监督方法,因此你需要预分类的数据来训练模型。
预测 AdWords 的价值 估计贷款违约的概率 预测营销活动将增加多少流量或销售额 基于过去拍卖的类似产品的最终价格预测拍卖物品的最终价格 回归—预测或预测数值。回归也是一种监督方法,因此你需要已知输出的数据来训练模型。
寻找一起购买的产品 识别在同一会话中经常访问的网页 识别成功的(经常点击的)网页和 AdWords 的组合 关联规则—寻找在数据中倾向于一起出现的对象。关联规则是一种无监督方法;你不需要已知关系的现有数据,而是试图发现数据中的关系。
识别具有相同购买模式的客户群体 识别在相同地区或具有相同客户群组的流行产品群体 识别讨论类似事件的新闻条目 聚类—找到彼此之间比其他组中的对象更相似的物体组。聚类也是一种无监督方法;你不需要预分组的数据,而是试图发现数据中的分组。

预测与预测

在日常用语中,我们倾向于将预测预测这两个词互换使用。技术上,预测是选择一个结果,例如“明天会下雨”,而预测是分配一个概率:“有 80%的可能性明天会下雨。”对于不平衡类别应用(如预测信用违约),这种区别很重要。考虑建模贷款违约的情况,并假设整体违约率为 5%。识别一个违约率为 30%的群体是不准确的预测(你不知道群体中谁会违约,群体中的大多数人不会违约),但可能是一个非常有用的预测(这个群体的违约率是整体率的六倍)。


6.2. 评估模型

在构建模型时,你必须能够估计模型质量,以确保你的模型在现实世界中表现良好。为了尝试估计未来的模型性能,我们通常将数据分为训练数据和测试数据,如图 6.6 所示。[测试数据]是在训练期间未使用的数据,目的是让我们对模型在新数据上的表现有一些经验。

图 6.6. 模型构建和评估示意图

图片

测试集可以帮助你识别的一件事是过度拟合:构建一个记住训练数据的模型,并且对新数据泛化不好。许多建模问题都与过度拟合有关,寻找过度拟合的迹象是诊断模型的好第一步。

6.2.1. 过度拟合

过度拟合的模型在训练数据上看起来很棒,但在新数据上的表现却很差。模型在它训练的数据上的预测误差被称为训练误差。模型在新数据上的预测误差被称为泛化误差。通常,训练误差会小于泛化误差(这并不令人惊讶)。然而,理想情况下,这两个误差率应该很接近。如果泛化误差很大,并且你的模型测试性能不佳,那么你的模型可能已经过度拟合——它记住了训练数据而不是发现可泛化的规则或模式。你希望通过(尽可能)选择更简单的模型来避免过度拟合,因为这些模型实际上往往能更好地泛化。¹ 图 6.7 展示了合理模型和过度拟合模型的典型外观。

¹

防止过度拟合的其他技术包括正则化(偏好模型变量的小效应)和袋装法(平均不同的模型以减少方差)。

图 6.7. 过度拟合的概念性插图

图片

一个过于复杂和过度拟合的模型至少有两个缺点。首先,过度拟合的模型可能比任何有用的模型都要复杂得多。例如,图 6.7 中过度拟合部分的额外波动可能会使相对于x的优化变得毫无必要地困难。此外,正如我们提到的,过度拟合的模型在生产中的准确性通常低于训练期间,这很尴尬。

在保留数据上进行测试

在第 4.3.1 节中,我们介绍了将数据分为测试-训练或测试-训练-校准集的想法,如图 6.8 所示。在这里,我们将更详细地讨论为什么您想要以这种方式分割数据。

图 6.8. 将数据分割为训练和测试(或训练、校准和测试)集


示例

假设您正在构建基于汽车各种特征的模型来预测二手车价格。您拟合了一个线性回归模型和一个随机森林模型,并且您希望比较这两个模型。^([2])

²

这两种建模技术将在本书的后续章节中介绍。


如果您没有分割数据,而是使用所有可用数据来训练和评估每个模型,那么您可能会认为您将选择更好的模型,因为模型评估已经看到了更多的数据。然而,用于构建模型的数据并不是评估模型性能的最佳数据。这是因为在这组数据中存在乐观的测量偏差,因为在这组数据中看到了模型构建过程。模型构建是优化您的性能度量(或者至少与您的性能度量相关的东西),因此您往往会得到训练数据上性能的夸张估计。

此外,数据科学家自然倾向于调整他们的模型以获得最佳性能。这也导致了性能的过度评估。这通常被称为多重比较偏差。而且,由于这种调整可能有时会利用训练数据中的怪癖,它可能导致过度拟合。

对于这种乐观偏差的一个推荐预防措施是将您可用的数据分为测试和训练集。仅在训练数据上执行所有您聪明的工作,并尽可能晚地测量您相对于测试数据的性能(因为您在看到测试或保留性能后所做的所有选择都会引入建模偏差)。我们希望尽可能长时间地保持测试数据保密,这就是我们通常实际上将数据分为训练、校准和测试集的原因(我们将在第 8.2.1 节中演示)。

当你对数据进行分区时,你希望平衡保持足够的数据以拟合良好模型和保留足够的数据以对模型性能进行良好估计之间的权衡。一些常见的分割比例是 70%用于训练,30%用于测试,或者 80%用于训练,20%用于测试。对于大型数据集,有时甚至可以看到 50-50 的分割。

K 折交叉验证

在保留数据上进行测试,虽然有用,但每个示例只使用一次:要么作为模型构建的一部分,要么作为保留的模型评估集的一部分。这并不是统计效率高的,^([3]) 因为测试集通常比我们的整个数据集小得多。这意味着通过如此简单地对数据进行分区,我们在对模型性能的估计中损失了一些精度。在我们的示例场景中,假设你无法收集到大量历史二手车价格数据集。那么你可能觉得你没有足够的数据来分割成足够大的训练集和测试集,以便既能构建良好的模型,又能正确评估它们。在这种情况下,你可能会选择使用更彻底的分区方案,称为k 折交叉验证

³

当一个估计量对于一个给定的数据集大小具有最小方差时,我们称其为统计效率高的估计量。

k 折交叉验证背后的思想是在不同的可用训练数据子集上重复构建模型,然后在构建过程中未看到的数据上评估该模型。这使我们能够在训练和评估模型时使用每个示例(只是永远不会同时在两个角色中使用相同的示例)。这种思想在图 6.9 中对于k = 3 进行了展示。

图 6.9. 3 折交叉验证的数据分区

图片

在图中,数据被分割成三个非重叠的部分,这三个部分被排列成三个测试-训练分割。对于每个分割,在训练集上训练一个模型,然后将其应用于相应的测试集。然后使用我们在本章后面将要讨论的适当评估分数来评估整个预测集。这模拟了在一个与整个数据集大小相同的保留集上训练模型并对其进行评估。对所有数据进行模型性能的估计,给我们提供了一个更精确的估计,即给定类型的模型在新数据上会如何表现。假设这个性能估计是令人满意的,那么你就可以回去使用所有训练数据来训练一个最终模型。

对于大数据,测试-训练分割通常足够好,并且实施起来更快。在数据科学应用中,交叉验证通常用于调整建模参数,这基本上是连续尝试许多模型。交叉验证也用于嵌套模型(使用一个模型作为另一个模型的输入)。这是在转换数据进行分析时可能出现的问题,并在第七章中讨论。

6.2.2. 模型性能度量

在本节中,我们将介绍一些模型性能的定量度量。从评估的角度来看,我们这样分组模型类型:

  • 分类

  • 评分

  • 概率估计

  • 聚类

对于大多数模型评估,我们只想计算一个或两个总结分数,告诉我们模型是否有效。为了决定给定的分数是高还是低,我们通常将我们的模型性能与几个基线模型进行比较。

零模型

零模型是你试图超越的非常简单模型的最佳版本。最典型的零模型是对于所有情况都返回相同答案的模型(一个常量模型)。我们将零模型用作期望性能的下限。例如,在分类问题中,零模型将始终返回最流行的类别,因为这是最容易猜测且最不常出错的选择。对于评分模型,零模型通常是所有结果的平均值,因为这与所有结果的最小平方偏差最小。

这个想法是,如果你没有超越零模型,你就没有提供价值。请注意,要做得和最好的零模型一样好可能很困难,因为尽管零模型很简单,但它有特权知道将要被测验的项目的大致分布。我们总是假设我们比较的零模型是所有可能的零模型中最好的。

单变量模型

我们还建议将任何复杂的模型与您可用的最佳单变量模型进行比较(请参阅第八章了解如何将单变量转换为单变量模型)。如果一个复杂的模型不能超越从您的训练数据中可用的最佳单变量模型,那么它就没有理由。此外,商业分析师有许多构建有效单变量模型(如交叉表)的工具,所以如果您的客户是分析师,他们很可能在寻找高于这个水平的性能。

我们将介绍模型质量的标准化度量,这些度量在模型构建中很有用。在所有情况下,我们建议除了标准模型质量评估之外,您还应该尝试与您的项目赞助商或客户设计自己的以业务为导向的指标。通常这就像为每个结果分配一个名义美元价值,然后看看您的模型在这个标准下的表现。让我们从如何评估分类模型开始,然后继续下去。

6.2.3. 评估分类模型

一个分类模型将示例放入两个或更多类别之一。为了衡量分类器的性能,我们首先介绍一个极其有用的工具,称为混淆矩阵,并展示它是如何被用来计算许多重要评估分数的。我们将讨论的第一个分数是准确率。


示例

假设我们想要将电子邮件分类为垃圾邮件(我们根本不想要的电子邮件)和非垃圾邮件(我们想要的电子邮件)。


一个现成的示例(附带良好描述)是“Spambase 数据集”(mng.bz/e8Rh)。该数据集的每一行都是针对特定电子邮件测量的特征集,以及一个额外的列,说明邮件是否是垃圾邮件(不受欢迎)或非垃圾邮件(受欢迎)。我们将快速构建一个垃圾邮件分类模型,以便我们有结果来评估。我们将在第 7.2 节中讨论逻辑回归,但就目前而言,您可以从本书的 GitHub 网站(github.com/WinVector/PDSwR2/tree/master/Spambase)下载 Spambase/spamD.tsv 文件,然后执行以下列表中显示的步骤。

列表 6.1. 构建和应用逻辑回归垃圾邮件模型

spamD <- read.table('spamD.tsv',header=T,sep='\t')              ❶

spamTrain <- subset(spamD,spamD$rgroup  >= 10)                  ❷
spamTest <- subset(spamD,spamD$rgroup < 10)

spamVars <- setdiff(colnames(spamD), list('rgroup','spam'))     ❸
spamFormula <- as.formula(paste('spam == "spam"',
paste(spamVars, collapse = ' + '),sep = ' ~ '))

spamModel <- glm(spamFormula,family = binomial(link = 'logit'), ❹
                                  data = spamTrain)

spamTrain$pred <- predict(spamModel,newdata = spamTrain,        ❺
                              type = 'response')
spamTest$pred <- predict(spamModel,newdata = spamTest,
                            type = 'response')

❶ 读取数据

❷ 将数据分为训练集和测试集

❸ 创建一个描述模型的公式

❹ 拟合逻辑回归模型

❺ 在训练集和测试集上做出预测

垃圾邮件模型预测给定电子邮件是垃圾邮件的概率。我们简单垃圾邮件分类器的结果样本在下一列表中展示。

列表 6.2. 垃圾邮件分类

sample <- spamTest[c(7,35,224,327), c('spam','pred')]
print(sample)
##          spam         pred       ❶
## 115      spam 0.9903246227
## 361      spam 0.4800498077
## 2300 non-spam 0.0006846551
## 3428 non-spam 0.0001434345

❶ 第一列给出了实际的类别标签(垃圾邮件或非垃圾邮件)。第二列给出了电子邮件是垃圾邮件的预测概率。如果概率大于 0.5,则电子邮件被标记为“垃圾邮件”;否则,它被标记为“非垃圾邮件”。

混淆矩阵

分类器性能最有趣的总结是混淆矩阵。这个矩阵仅仅是一个表格,总结了分类器对实际已知数据类别的预测。

混淆矩阵是一个表格,统计了已知结果(真相)与每种预测类型组合发生的频率。对于我们的电子邮件垃圾邮件示例,混淆矩阵是通过以下列表中的 R 命令计算的。

列表 6.3. 垃圾邮件混淆矩阵

confmat_spam <- table(truth = spamTest$spam,
                         prediction = ifelse(spamTest$pred > 0.5,
                         "spam", "non-spam"))
print(confmat_spam)
##          prediction
## truth   non-spam spam
##   non-spam   264   14
##   spam        22  158

表格的行(标记为真相)对应于数据点的实际标签:它们是否真的是垃圾邮件。表格的列(标记为预测)对应于模型做出的预测。因此,表格的第一个单元格(真相 = 非垃圾邮件预测 = 非垃圾邮件)对应于测试集中不是垃圾邮件的 264 封电子邮件,并且模型(正确地)预测它们不是垃圾邮件。这些正确的负预测被称为真正负例


混淆矩阵惯例

许多工具以及维基百科都会绘制混淆矩阵,实际的真实值控制图中的 x 轴。这可能是由于矩阵和表格中数学惯例,即矩阵和表格的第一个坐标命名行(垂直偏移),而不是列(水平偏移)。我们觉得直接标签,如“pred”和“actual”,比任何惯例都要清晰。此外,请注意,在残差图中,预测总是 x 轴,与这个重要惯例保持视觉一致性是一个好处。因此,在这本书中,我们将预测绘制在 x 轴上(无论其名称如何)。


将属于感兴趣类别的数据称为正实例,而不属于感兴趣类别的数据称为负实例是标准术语。在我们的场景中,垃圾邮件是正实例,非垃圾邮件是负实例。

在一个二乘二的混淆矩阵中,每个单元格都有一个特殊名称,如表 6.2 所示。

表 6.2. 二乘二混淆矩阵

预测=NEGATIVE (预测为非垃圾邮件) 预测=POSITIVE (预测为垃圾邮件)
真实标记=NEGATIVE (非垃圾邮件) 真阴性 (TN) confmat_spam[1,1]=264 假阳性 (FP) confmat_spam[1,2]=14
真实标记=POSITIVE (垃圾邮件) 假阴性 (FN) confmat_spam[2,1]=22 真阳性 (TP) confmat_spam[2,2]=158

使用这个摘要,我们现在可以开始计算垃圾邮件过滤器的各种性能指标。


将分数转换为分类

注意,我们将数值预测分数转换为决策,通过检查分数是否高于或低于 0.5。这意味着如果模型返回的电子邮件是垃圾邮件的概率高于 50%,我们就将其分类为垃圾邮件。对于某些评分模型(如逻辑回归),0.5 的分数可能是一个提供合理准确性的分类器阈值。然而,准确率并不总是最终目标,对于不平衡的训练数据,0.5 的阈值可能不是好的选择。选择除 0.5 以外的阈值可以让数据科学家在精确度召回率(这两个术语我们将在本章后面定义)之间进行权衡。你可以从 0.5 开始,但考虑尝试其他阈值并查看 ROC 曲线(见第 6.2.5 节)。


准确率

准确率回答了这样的问题:“当垃圾邮件过滤器说这封邮件是或不是垃圾邮件时,它正确的概率是多少?”对于一个分类器,准确率定义为正确分类的项目数除以总项目数。这很简单,就是分类器做出的分类中正确比例。这如图 6.10 图所示。

图 6.10. 准确率

至少,你希望分类器是准确的。让我们计算垃圾邮件过滤器的准确率:

(confmat_spam[1,1] + confmat_spam[2,2]) / sum(confmat_spam)
## [1] 0.9213974

对于垃圾邮件过滤器来说,大约 8%的错误率是不可接受的,但对于说明不同类型的模型评估标准来说却是好的。

在我们继续之前,我们想分享一个良好的垃圾邮件过滤器的混淆矩阵。在下一个列表中,我们创建来自 Win-Vector 博客的 Akismet 评论垃圾邮件过滤器的混淆矩阵。^([4)]

www.win-vector.com/blog/

列表 6.4. 手动输入 Akismet 混淆矩阵

confmat_akismet <- as.table(matrix(data=c(288-1,17,1,13882-17),nrow=2,ncol=2))
rownames(confmat_akismet) <- rownames(confmat_spam)
colnames(confmat_akismet) <- colnames(confmat_spam)
print(confmat_akismet)
##       non-spam  spam
## non-spam   287     1
## spam        17 13865

由于 Akismet 过滤器使用链接目的地线索和来自其他网站的判断(除了文本特征),它达到了更可接受的精确度:

(confmat_akismet[1,1] + confmat_akismet[2,2]) / sum(confmat_akismet)
## [1] 0.9987297

更重要的是,Akismet 似乎抑制了更少的良好评论。我们将在下一节中讨论精确度和召回率,以量化这种区别。


对于不平衡的类别,准确度是一个不合适的衡量标准

假设我们有一个罕见事件的情况(比如,分娩期间的严重并发症)。如果我们试图预测的事件是罕见的(比如,大约 1%的人口),那么说罕见事件永远不会发生的零模型是非常(99%)准确的。实际上,零模型比一个有用(但不完美)的模型更准确,后者将 5%的人口识别为“有风险”,并捕捉到 5%中的所有不良事件。这并不是任何形式的悖论。这只是因为准确性不是衡量不平衡分布或不平衡成本事件的好指标。


精确度和召回率

机器学习研究人员使用的另一个评估指标是一对称为精确度和召回率的数字。这些术语来自信息检索领域,其定义如下。

精确度回答了这样的问题:“如果垃圾邮件过滤器说这封邮件是垃圾邮件,那么它真的是垃圾邮件的概率是多少?”精确度定义为真实阳性与预测阳性的比率。这如图 6.11 所示。

图 6.11. 精确度

图片

我们可以按以下方式计算垃圾邮件过滤器的精确度:

confmat_spam[2,2] / (confmat_spam[2,2]+ confmat_spam[1,2])
## [1] 0.9186047

精确度如此接近我们之前报告的准确度数字只是一个巧合。再次强调,精确度是指阳性指示最终被证明是正确的情况的频率。重要的是要记住,精确度是分类器和数据集组合的函数。单独询问分类器的精确度是没有意义的;只有针对给定的数据集询问分类器的精确度才有意义。希望分类器在数据集抽取的整体人口中也会有类似的精确度——一个具有与数据集相同的阳性实例分布的人口。

在我们的电子邮件垃圾邮件示例中,92%的精确度意味着被标记为垃圾邮件的 8%实际上并不是垃圾邮件。这对于丢失可能重要的信息来说是不可接受的比率。另一方面,Akismet 的精确度超过 99.99%,因此它丢弃的非垃圾邮件非常少。

confmat_akismet[2,2] / (confmat_akismet[2,2] + confmat_akismet[1,2])
## [1] 0.9999279

精确度的伴随分数是 召回率。召回率回答了这样的问题:“在电子邮件集中的所有垃圾邮件中,有多少比例被垃圾邮件过滤器检测到了?” 召回率是真实阳性与所有实际阳性的比率,如 图 6.12 所示。

图 6.12. 召回率

让我们比较两个垃圾邮件过滤器的召回率。

confmat_spam[2,2] / (confmat_spam[2,2] + confmat_spam[2,1])
## [1] 0.8777778

confmat_akismet[2,2] / (confmat_akismet[2,2] + confmat_akismet[2,1])
## [1] 0.9987754

对于我们的电子邮件垃圾邮件过滤器,这个值是 88%,这意味着我们收到的垃圾邮件中仍有大约 12% 会进入我们的收件箱。Akismet 的召回率为 99.88%。在这两种情况下,大多数垃圾邮件实际上都被标记了(我们有高召回率),并且精确度比召回率更重要。这对于垃圾邮件过滤器来说是合适的,因为不丢失非垃圾邮件比从我们的收件箱中过滤掉每一件垃圾邮件更重要。

重要的是要记住这一点:精确度是确认度的度量(当分类器指示为正时,实际上它是正确的频率),召回率是实用性的度量(分类器找到了多少实际存在的可找内容)。精确度和召回率通常与商业需求相关,并且是与你的项目赞助商和客户讨论的好指标。

F1


示例

假设你有多个垃圾邮件过滤器可供选择,每个过滤器都有不同的精确度和召回率。你该如何选择使用哪个垃圾邮件过滤器呢?


在这种情况下,有些人更喜欢有一个数字来比较所有不同的选择。这样一个评分就是 F1 分数。F1 分数衡量了精确度和召回率之间的权衡。它被定义为精确度和召回率的调和平均值。这可以通过一个明确的计算来最容易地展示:

precision <- confmat_spam[2,2] / (confmat_spam[2,2]+ confmat_spam[1,2])
recall <- confmat_spam[2,2] / (confmat_spam[2,2] + confmat_spam[2,1])

(F1 <- 2 * precision * recall / (precision + recall) )
## [1] 0.8977273

我们具有 0.93 精确度和 0.88 召回率的垃圾邮件过滤器有一个 0.90 的 F1 分数。当分类器具有完美的精确度和召回率时,F1 为 1.00,而对于具有非常低精确度或召回率(或两者都有)的分类器,F1 会降到 0.00。假设你认为你的垃圾邮件过滤器丢失了太多的真实邮件,并且你想要让它“更挑剔”地标记邮件为垃圾邮件;也就是说,你想要提高它的精确度。通常情况下,提高分类器的精确度也会降低其召回率:在这种情况下,一个更挑剔的垃圾邮件过滤器可能会标记更少的真实垃圾邮件为垃圾邮件,并允许它进入你的收件箱。如果随着精确度的提高,过滤器的召回率变得太低,这将导致 F1 分数降低。这可能意味着你为了更好的精确度而牺牲了太多的召回率。

敏感度和特异性


示例

假设你已经使用你的工作电子邮件作为训练数据,成功训练了一个具有可接受精确度和召回率的垃圾邮件过滤器。现在你想要将同一个垃圾邮件过滤器应用于你主要用于摄影爱好者的个人电子邮件账户。这个过滤器会同样有效吗?


过滤器可能在你个人的电子邮件上工作得很好,因为垃圾邮件的性质(电子邮件的长度、使用的单词、链接的数量等)在这两个电子邮件账户之间可能变化不大。然而,你个人电子邮件账户上收到的垃圾邮件的比例可能与工作电子邮件上的不同。这可能会改变你个人电子邮件上的垃圾邮件过滤器的性能。⁵

垃圾邮件过滤器的性能也可能发生变化,因为非垃圾邮件的性质也会不同:常用的单词可能不同;合法电子邮件中的链接或图像数量可能不同;你与之通信的人的电子邮件域名可能不同。对于这次讨论,我们将假设垃圾邮件的比例是垃圾邮件过滤器性能差异的主要原因。

让我们看看垃圾邮件比例的变化如何改变垃圾邮件过滤器的性能指标。在这里,我们模拟了具有比我们训练过滤器时更多的和更少的电子邮件集。

列表 6.5. 观察垃圾邮件比例变化时过滤器性能的变化

set.seed(234641)

N <- nrow(spamTest)
pull_out_ix <- sample.int(N, 100, replace=FALSE)
removed = spamTest[pull_out_ix,]                                  ❶

get_performance <- function(sTest) {                              ❷
  proportion <- mean(sTest$spam == "spam")
  confmat_spam <- table(truth = sTest$spam,
                        prediction = ifelse(sTest$pred>0.5,
                                            "spam",
                                            "non-spam"))
  precision <- confmat_spam[2,2]/sum(confmat_spam[,2])
  recall <- confmat_spam[2,2]/sum(confmat_spam[2,])
  list(spam_proportion = proportion,
       confmat_spam = confmat_spam,
       precision = precision, recall = recall)
}

sTest <- spamTest[-pull_out_ix,]                                  ❸
get_performance(sTest)

## $spam_proportion
## [1] 0.3994413
##
## $confmat_spam
##           prediction
## truth      non-spam spam
##   non-spam      204   11
##   spam           17  126
##
## $precision
## [1] 0.919708
##
## $recall
## [1] 0.8811189

get_performance(rbind(sTest, subset(removed, spam=="spam")))      ❹

## $spam_proportion
## [1] 0.4556962
##
## $confmat_spam
##           prediction
## truth      non-spam spam
##   non-spam      204   11
##   spam           22  158
##
## $precision
## [1] 0.9349112
##
## $recall
## [1] 0.8777778

get_performance(rbind(sTest, subset(removed, spam=="non-spam"))) ❺

## $spam_proportion
## [1] 0.3396675
##
## $confmat_spam
##           prediction
## truth      non-spam spam
##   non-spam      264   14
##   spam           17  126
##
## $precision
## [1] 0.9
##
## $recall
## [1] 0.8811189

❶ 从测试集中随机抽取 100 封电子邮件

❷ 这是一个方便的函数,用于在测试集上打印出过滤器的混淆矩阵、精确度和召回率。

❸ 检查与训练数据中相同比例的垃圾邮件的测试集上的性能

❹ 仅将额外的垃圾邮件添加回来,因此测试集中垃圾邮件的比例高于训练集

❺ 仅将非垃圾邮件添加回来,因此测试集中垃圾邮件的比例低于训练集

注意,过滤器的召回率在这三种情况下都是相同的:大约 88%。当数据中的垃圾邮件数量多于过滤器训练的数据时,过滤器的精确度更高,这意味着它抛出的非垃圾邮件比例更低。这是好事!然而,当数据中的垃圾邮件数量少于过滤器训练的数据时,精确度较低,这意味着过滤器会抛出更高比例的非垃圾邮件。这是不希望的。

由于存在一些情况,分类器或过滤器可能被用于正类(在本例中为垃圾邮件)的普遍性不同的群体中,因此拥有独立于类别普遍性的性能指标是有用的。这样一对指标是灵敏度特异性。这对指标在医学研究中很常见,因为疾病和其他条件的测试将用于不同的群体,这些群体中特定疾病或条件的普遍性不同。

灵敏度也称为真正阳性率,与召回率完全相同。特异性也称为真正阴性率:它是真正阴性数与所有阴性数的比率。这如图 6.13 所示。

图 6.13. 特异性

敏感性和召回率回答了“垃圾邮件过滤器找到了多少比例的垃圾邮件?”的问题。特异性回答了“垃圾邮件过滤器找到了多少比例的非垃圾邮件?”的问题。

我们可以计算我们的垃圾邮件过滤器的特异性:

confmat_spam[1,1] / (confmat_spam[1,1] + confmat_spam[1,2])
## [1] 0.9496403

特异性减一也称为假阳性率。假阳性率回答了“模型将多少比例的非垃圾邮件分类为垃圾邮件?”的问题。您希望假阳性率低(或特异性高),同时敏感性也要高。我们的垃圾邮件过滤器的特异性约为 0.95,这意味着它将大约 5%的非垃圾邮件标记为垃圾邮件。

敏感性和特异性的一个重要特性是:如果您翻转标签(从试图识别的类别垃圾邮件切换到试图识别的类别非垃圾邮件),您只是交换了敏感性和特异性。此外,一个总是说正面或总是说负面的简单分类器在敏感性和特异性上都会得到零分。因此,无用的分类器在这至少一个指标上得分总是很差。

为什么需要精确率/召回率和敏感度/特异性?从历史上看,这些指标来自不同的领域,但每个都有其优点。敏感度/特异性适用于像医学这样的领域,在这些领域中,了解分类器、测试或过滤器如何独立于人口中不同类别的分布将正例与负例分开是很重要的。但精确率/召回率可以给您一个关于分类器或过滤器在特定人群上如何工作的概念。如果您想知道被识别为垃圾邮件的电子邮件实际上是垃圾邮件的概率,您必须知道垃圾邮件在该人的电子邮件收件箱中有多常见,适当的指标是精确率。

摘要:使用常见的分类性能指标

在与客户和赞助商合作时,您应使用这些标准分数,以查看哪些指标最符合他们的业务需求。对于每个分数,您应询问他们是否需要该分数较高,然后与他们进行快速的思想实验,以确认您已经理解了他们的业务需求。然后您应该能够用这些指标中的一对的最小界限来撰写项目目标。表 6.3 展示了典型的业务需求和每个指标的示例后续问题。

表 6.3. 分类器性能度量业务故事。

指标 典型的业务需求 后续问题
准确率 “我们需要大多数决策都是正确的。” “我们能否容忍 5%的错误率?并且用户是否将垃圾邮件被标记为非垃圾邮件或非垃圾邮件被标记为垃圾邮件视为等效?”
精确度 “我们标记为垃圾邮件的大部分内容确实应该是垃圾邮件。” “这将保证大多数在垃圾邮件文件夹中的内容确实是垃圾邮件,但这并不是衡量用户丢失多少合法邮件的最佳方式。我们可以通过向所有用户发送大量易于识别的垃圾邮件来正确识别,从而在这个目标上作弊。也许我们真正想要的是良好的特异性。”
召回率 “我们希望将用户看到的垃圾邮件数量减少 10 倍(消除 90%的垃圾邮件)。” “如果 10%的垃圾邮件得以通过,用户将看到主要是非垃圾邮件还是主要是垃圾邮件?这将导致良好的用户体验吗?”
敏感性 “我们必须削减大量的垃圾邮件;否则,用户将看不到任何好处。” “如果我们把垃圾邮件削减到现在的 1%,这将是一个好的用户体验吗?”
特异性 “我们必须至少达到三个九的合法电子邮件;用户必须至少看到 99.9%的非垃圾邮件。” “用户能否容忍错过 0.1%的合法邮件,我们应该保留一个用户可以查看的垃圾邮件文件夹吗?”

对于垃圾邮件分类的对话过程,一个结论可能是建议将业务目标写为最大化敏感性,同时保持至少 0.999 的特异性。

6.2.4. 评估评分模型

让我们在一个简单的例子中演示评估。


示例

假设你已经读到,蟋蟀鸣叫的速率与温度成正比,因此你已经收集了一些数据并拟合了一个模型,该模型可以从条纹地面蟋蟀的鸣叫速率(每秒鸣叫次数)预测温度(华氏度)。现在你想要评估这个模型。


你可以使用以下列表拟合一个线性回归模型,并进行预测。我们将在第八章详细讨论线性回归。确保你的工作目录中有 crickets.csv 数据集。⁶]

乔治·W·皮尔斯,《昆虫之歌》,哈佛大学出版社,1948 年。你可以在这里找到数据集:github.com/WinVector/PDSwR2/tree/master/cricketchirps

列表 6.6. 拟合蟋蟀模型并进行预测

crickets <- read.csv("cricketchirps/crickets.csv")

cricket_model <- lm(temperatureF ~ chirp_rate, data=crickets)
crickets$temp_pred <- predict(cricket_model, newdata=crickets)

图 6.14 比较了实际数据(点)与模型的预测(线)。预测的temperatureFtemp_pred之间的差异被称为模型的残差误差。我们将使用残差来计算评分模型的常见性能指标。

图 6.14. 评分残差

根均方误差

最常见的拟合优度度量称为均方根误差(RMSE)。RMSE 是平均平方残差的平方根(也称为均方误差)。RMSE 回答了“预测温度通常偏离多少?”这个问题。我们按照以下列表所示计算 RMSE。

列表 6.7. 计算 RMSE

error_sq <- (crickets$temp_pred - crickets$temperatureF)²
( RMSE <- sqrt(mean(error_sq)) )
## [1] 3.564149

RMSE 与结果单位相同:由于结果(温度)是以华氏度为单位,因此 RMSE 也是以华氏度为单位。在这里,RMSE 告诉你模型的预测通常(即平均)会偏离实际温度大约 3.6 度。假设你认为一个通常能将温度预测在 5 度以内的模型是“好的”,那么恭喜你!你已经拟合了一个符合你目标的模型。

RMSE 是一个好的度量,因为它通常是你在使用的拟合算法明确试图最小化的。在商业环境中,一个好的 RMSE 相关目标可能是“我们希望账户估值的 RMSE 低于每账户 1000 美元。”

mean(error_sq)被称为均方误差。我们将量sum(error_sq)称为均方误差之和,也将其称为模型的方差

R 平方

另一个重要的拟合度度量称为R 平方(或 R2,或确定系数)。我们可以如下推导 R 平方的定义。

对于你收集的数据,温度的最简单基线预测只是数据集中的平均温度。这是一个零模型;它不是一个很好的模型,但你必须至少做得比它好。数据的总方差是零模型的均方误差之和。你希望你的实际模型的均方误差之和远小于数据的方差——也就是说,你希望你的模型均方误差与总方差之比接近零。R 平方定义为这个比率的倒数,因此我们希望 R 平方接近 1。这导致了以下 R 平方的计算。

列表 6.8. 计算 R 平方

error_sq <- (crickets$temp_pred - crickets$temperatureF)²             ❶
numerator <- sum(error_sq)                                             ❷

delta_sq <- (mean(crickets$temperatureF) - crickets$temperatureF)²    ❸
denominator = sum(delta_sq)                                            ❹

(R2 <- 1 - numerator/denominator)                                      ❺
## [1] 0.6974651

❶ 计算平方误差项

❷ 将它们相加得到模型的均方误差,或方差

❸ 从零模型计算平方误差项

❹ 计算数据的总方差

❺ 计算 R 平方

由于 R 平方是由一个比率构成的,该比率比较了你的模型方差与总方差,因此你可以将 R 平方视为衡量你的模型“解释”了多少方差的一个指标。R 平方有时也被称为衡量模型“拟合”数据程度或其“拟合优度”的指标。

最佳可能的 R 平方是 1.0,接近零或负的 R 平方是糟糕的。一些其他模型(如逻辑回归)使用偏差来报告一个类似数量的指标,称为伪 R 平方

在某些情况下,R-squared 等于另一个称为 相关系数 的度量(见mng.bz/ndYf)。一个关于 R-squared 商业目标的良好陈述将是:“我们希望模型至少解释账户价值变化的 70%。”

6.2.5. 评估概率模型

概率模型是既能判断一个项目是否属于给定类别,又能返回该项目属于该类别的估计概率(或置信度)的模型。逻辑回归和决策树建模技术在返回良好的概率估计方面相当著名。这些模型可以根据它们的最终决策进行评估,正如我们在第 6.2.3 节中已经展示的那样,但它们也可以根据它们的估计概率进行评估。

在我们看来,大多数概率模型的度量都非常技术化,并且非常擅长比较同一数据集上不同模型的品质。了解它们很重要,因为数据科学家通常在这些标准之间进行交流。但将这些标准精确地转化为业务需求并不容易。因此,我们建议跟踪它们,但不要在与你的项目赞助商或客户交流时使用它们。

为了激励使用概率模型的不同指标,我们将继续从第 6.2.3 节中的垃圾邮件过滤器示例。


示例

假设在构建你的垃圾邮件过滤器时,你尝试了多种不同的算法和建模方法,并提出了几个模型,所有这些模型都返回了给定电子邮件是垃圾邮件的概率。你想要快速比较这些不同的模型,并确定哪一个将制作出最佳的垃圾邮件过滤器。


为了将概率模型转换为分类器,你需要选择一个阈值:得分高于该阈值的项将被分类为垃圾邮件;否则,它们将被分类为非垃圾邮件。对于概率模型来说,最简单(也可能是最常见的)的阈值是 0.5,但针对特定概率模型的“最佳”分类器可能需要不同的阈值。这个最佳阈值可能因模型而异。本节中的指标直接比较概率模型,而没有将它们转换为分类器。如果你合理地假设最佳的概率模型将产生最佳的分类器,那么你可以使用这些指标来快速选择最合适的概率模型,然后花一些时间调整阈值,以构建满足你需求的最佳分类器。

双密度图

在考虑概率模型时,构建双密度图(如图 6.15 所示)是有用的。

列表 6.9. 制作双密度图

library(WVPlots)
DoubleDensityPlot(spamTest,
                  xvar = "pred",
                  truthVar = "spam",
                  title = "Distribution of scores for spam filter")

图 6.15. 按已知类别拆分的分数分布

图中的 x 轴对应于垃圾邮件过滤器返回的预测分数。图 6.15 展示了我们在评估估计概率模型时试图检查的内容:属于该类别的示例应该主要具有高分数,而不属于该类别的示例应该主要具有低分数。

双密度图在选择分类器阈值时可能很有用,或者分类器从将电子邮件标记为非垃圾邮件切换到垃圾邮件的阈值分数。如我们之前提到的,标准分类器阈值是 0.5,这意味着如果电子邮件是垃圾邮件的概率大于一半,我们就将其标记为垃圾邮件。这是你在第 6.2.3 节中使用的阈值。然而,在某些情况下,你可能选择使用不同的阈值。例如,使用垃圾邮件过滤器的 0.75 阈值将产生一个具有更高精确度(但召回率较低)的分类器,因为分数高于 0.75 的电子邮件中有更高的比例实际上是垃圾邮件。

接收者操作特征曲线和 AUC

接收者操作特征曲线(或ROC 曲线)是双密度图的一个流行替代方案。对于通过在垃圾邮件和非垃圾邮件之间选择不同的分数阈值得到的每个不同的分类器,我们绘制了真正的阳性(TP)率和错误的阳性(FP)率。得到的曲线代表了从该模型派生出的分类器中可用的每个可能的真正阳性率和错误阳性率之间的权衡。图 6.16 显示了我们的垃圾邮件过滤器的 ROC 曲线,如下一列表所示。在列表的最后一行,我们计算了AUC曲线下的面积,这是对模型质量的一种衡量。

图 6.16. 电子邮件垃圾邮件示例的 ROC 曲线

图片

列表 6.10. 绘制接收者操作特征曲线

library(WVPlots)
ROCPlot(spamTest,                                 ❶
        xvar = 'pred',
        truthVar = 'spam',
        truthTarget = 'spam',
        title = 'Spam filter test performance')
library(sigr)
calcAUC(spamTest$pred, spamTest$spam=='spam')     ❷
 ## [1] 0.9660072

❶ 绘制接收者操作特征(ROC)曲线

❷ 显式计算 ROC 曲线下的面积

AUC 背后的推理

在模型光谱的一端是理想的完美模型,该模型将为垃圾邮件返回 1 分,为非垃圾邮件返回 0 分。这个理想模型将形成一个有三个点的 ROC 曲线:

  • (0,0)—对应于由阈值p = 1定义的分类器:没有任何内容被分类为垃圾邮件,因此这个分类器的错误阳性率和真正阳性率都为 0。

  • (1,1)—对应于由阈值p = 0定义的分类器:所有内容都被分类为垃圾邮件,因此这个分类器的错误阳性率和真正阳性率都为 1。

  • (0,1)—对应于由 0 到 1 之间的阈值定义的任何分类器:所有内容都被正确分类,因此这个分类器的错误阳性率为 0,真正阳性率为 1。

理想模型的 ROC 曲线形状如图 6.17 所示。此模型的曲线下面积为 1。返回随机分数的模型将有一个从原点到点(1,0)的对角线 ROC:真正的阳性率与阈值成正比。随机模型的曲线下面积为 0.5。因此,你希望模型具有接近 1 的 AUC,并且大于 0.5。

图 6.17. 完美分类的理想模型的 ROC 曲线

图 6.17_ 替代

当比较多个概率模型时,你通常希望优先考虑具有更高 AUC 的模型。然而,你还需要检查 ROC 曲线的形状,以探索可能的项目目标权衡。曲线上的每个点都显示了使用此模型可实现的真正阳性率和假阳性率之间的权衡。如果你与客户分享 ROC 曲线的信息,他们可能会对两种可接受的权衡有意见。

对数似然

对数似然是衡量模型预测“匹配”真实类别标签好坏的一个指标。它是一个非正数,其中对数似然为 0 表示完美匹配:模型将所有垃圾邮件评分的概率为 1,将所有非垃圾邮件评分的概率为 0。对数似然的绝对值越大,匹配越差。

模型对特定实例预测的对数似然是该模型分配给实例实际类别的概率的对数。如图 6.18 所示,对于一个估计概率为p的垃圾邮件,其对数似然是log(p);对于非垃圾邮件,相同的p分数给出对数似然为log(1 - p)

图 6.18. 垃圾邮件过滤器预测的对数似然

图 6.18

模型对整个数据集预测的对数似然是各个单独对数似然的和:

log_likelihood = sum(y * log(py) + (1-y) * log(1 - py))

这里y是真实的类别标签(非垃圾邮件为 0,垃圾邮件为 1),py是实例属于类别 1(垃圾邮件)的概率。我们使用乘法来选择正确的对数。我们还使用约定0 * log(0) = 0(尽管为了简单起见,这在代码中没有显示)。

图 6.19 展示了对数似然如何奖励匹配并惩罚实际邮件标签与模型分配的分数之间的不匹配。对于正实例(垃圾邮件),模型应该预测一个接近 1 的值,而对于负实例(非垃圾邮件),模型应该预测一个接近 0 的值。当预测与类别标签匹配时,对数似然的贡献是一个小的负数。当它们不匹配时,对数似然的贡献是一个较大的负数。对数似然越接近 0,预测越好。

图 6.19. 对数似然惩罚预测与真实类别标签之间的不匹配。

图片

下一个列表展示了计算垃圾邮件过滤器预测的似然对数的一种方法。

列表 6.11. 计算似然对数

ylogpy <- function(y, py) {              ❶
   logpy = ifelse(py > 0, log(py), 0)
  y*logpy
}

y <- spamTest$spam == 'spam'             ❷

sum(ylogpy(y, spamTest$pred) +           ❸
       ylogpy(1-y, 1-spamTest$pred))
## [1] -134.9478

❶ 一个计算 y * log(py) 的函数,约定 0 * log(0) = 0

❷ 获取测试集的类别标签为 TRUE/FALSE,R 在算术运算中将它们视为 1/0

❸ 计算模型在测试集上的预测的似然对数

似然对数对于比较同一测试数据集上的多个概率模型很有用——因为似然对数是一个非归一化的总和,其大小隐式地依赖于数据集的大小,因此你不能直接比较在不同数据集上计算的似然对数。在比较多个模型时,你通常希望偏好具有较大(即较小幅度)似然对数的模型。

至少,你想要将模型的性能与预测每个示例相同概率的空模型进行比较。最佳可观察的单个估计值是训练集中垃圾邮件的观察率。

列表 6.12. 计算空模型的似然对数

(pNull <- mean(spamTrain$spam == 'spam'))
## [1] 0.3941588

sum(ylogpy(y, pNull) + ylogpy(1-y, 1-pNull))
## [1] -306.8964

该模型将 -134.9478 的似然对数分配给测试集,这比空模型的 -306.8964 要好得多。

偏差

在拟合概率模型时,另一个常见的度量是 偏差。偏差定义为 -2*(logLikelihood-S),其中 S 是称为“饱和模型的似然对数”的技术常数。在大多数情况下,饱和模型是一个完美的模型,对于属于类的项目返回概率 1,对于不属于类的项目返回概率 0(因此 S=0)。偏差越低,模型越好。

我们最关心的是偏差率的比率,例如空偏差与模型偏差之间的比率。这些偏差可以用来计算伪 R 平方(见 mng.bz/j338)。将空偏差视为需要解释的变异量,将模型偏差视为模型未解释的变异量。你希望伪 R 平方接近 1。

在下一个列表中,我们展示了使用 sigr 包快速计算偏差和伪 R 平方的方法。

列表 6.13. 计算偏差和伪 R 平方

library(sigr)

(deviance <- calcDeviance(spamTest$pred, spamTest$spam == 'spam'))
## [1] 253.8598
(nullDeviance <- calcDeviance(pNull, spamTest$spam == 'spam'))
## [1] 613.7929
(pseudoR2 <- 1 - deviance/nullDeviance)
## [1] 0.586408

与似然对数一样,偏差是非归一化的,因此你应该只比较在相同数据集上计算的偏差。在比较多个模型时,你通常会偏好偏差较小的模型。伪 R 平方是归一化的(它是偏差比率的函数),所以原则上你可以比较在不同测试集上计算的伪 R 平方。在比较多个模型时,你通常会偏好伪 R 平方较大的模型。

AIC

偏差的一个重要变体是赤池信息量准则AIC)。这相当于模型中使用的deviance + 2*numberOfParameters。模型中的参数越多,模型越复杂;模型越复杂,越有可能过拟合。因此,AIC 是对模型复杂度进行惩罚的偏差。当比较模型(在同一测试集上)时,你通常会倾向于选择 AIC 较小的模型。AIC 在比较具有不同复杂度度量以及具有不同水平数量的建模变量时很有用。然而,调整模型复杂度通常更可靠地通过使用在第 6.2.1 节中讨论的保留法和交叉验证法来实现。

到目前为止,我们已经评估了模型在总体上的表现:模型在测试数据上返回正确或错误预测的整体比率。在下一节中,我们将探讨一种评估模型在特定示例上的方法,或者解释为什么模型在给定示例上返回特定的预测。

6.3. 本地可解释模型无关解释(LIME)用于解释模型预测

在许多人看来,现代机器学习方法(如深度学习或梯度提升树)的预测性能提升是以降低可解释性为代价的。正如你在第一章中看到的,一个人类领域专家可以审查决策树的 if-then 结构,并将其与自己的决策过程进行比较,以决定决策树是否会做出合理的决策。线性模型也有一个易于解释的结构,正如你将在第八章中看到的。然而,其他方法的结构远更复杂,难以被人类评估。例如,随机森林中的多个单独的树(如图 6.20 所示[../Text/06.xhtml#ch06fig20]),或者神经网络的高度连接拓扑结构。

图 6.20. 一些模型比其他模型更容易手动检查。

如果模型在保留数据上表现良好,这表明模型将在野外表现良好——但这并不是万无一失的。一个潜在的问题是,保留集通常来自与训练数据相同的来源,并且具有与训练数据相同的怪癖和独特性。你如何知道你的模型是否在学习实际感兴趣的概念,或者只是学习数据中的怪癖?或者,换一种说法,模型是否会在来自不同来源的类似数据上工作?


示例

假设你想要训练一个分类器来区分关于基督教的文档和关于无神论的文档。


其中一个模型是使用 20 Newsgroups 数据集的帖子语料库进行训练的,这是一个常用于机器学习文本研究的数据集。该随机森林模型在保留数据上的准确率为 92%。^([7]) 表面上看,这似乎相当不错。

该实验在 Ribeiro, Singh 和 Guestrin 的论文“‘Why Should I Trust You?’ Explaining the Predictions of Any Classifier”中有描述,arxiv.org/pdf/1602.04938v1.pdf

然而,深入研究模型发现,它正在利用数据中的特殊性,使用“那里”、“帖子”或“edu”等词语的分布来决定帖子是关于基督教还是关于无神论。换句话说,该模型正在查看数据中的错误特征。该模型的一个分类示例在图 6.21.^([8])中展示。

来源:homes.cs.washington.edu/~marcotcr/blog/lime/

图 6.21。一个文档及其被模型分类为“无神论者”的最强贡献词语示例

此外,由于语料库中的文档似乎包含了特定发帖者的名字,这个模型也可能潜在地学习到在训练语料库中频繁发帖的“人”是基督徒还是无神论者,这不同于学习“文本”是基督徒还是无神论者,尤其是在尝试将模型应用于来自不同语料库、不同作者的文档时。

另一个现实世界的例子是亚马逊最近尝试自动化简历审查,使用亚马逊在过去 10 年内雇佣的人的简历作为训练数据.^([9]) 如路透社报道,公司发现他们的模型对女性存在歧视。它惩罚包含“女性”等词语的简历,并对毕业于两所特定女子大学的申请者进行降级。研究人员还发现,该算法忽略了指代特定技能的常见术语(例如计算机编程语言的名称),并偏好像“执行”或“捕获”这样的词语,这些词语在男性申请者中被不成比例地使用。

Jeffrey Dastin,“亚马逊取消对女性存在歧视的秘密 AI 招聘工具,”路透社,2018 年 10 月 9 日,www.reuters.com/article/us-amazon-com-jobs-automation-insight/amazon-scraps-secret-ai-recruiting-tool-that-showed-bias-against-women-idUSKCN1MK08G

在这种情况下,问题不在于机器学习算法,而在于训练数据,该数据显然捕捉到了亚马逊招聘实践中的现有偏见——模型随后将这些偏见编码。LIME 等预测解释技术可能发现此类问题。

6.3.1. LIME:自动性检查

为了检测模型是否真正学习到概念,而不是仅仅学习数据中的异常,领域专家通常会手动通过运行一些示例案例来检查模型,并查看答案。通常,你想要尝试一些典型案例和一些极端案例,以看看会发生什么。你可以将 LIME 视为一种自动性检查的形式。

LIME 可以产生一个关于模型对特定数据预测的解释。也就是说,LIME 试图确定哪些特征对该数据产生了最大的影响,从而帮助数据科学家尝试理解黑盒机器学习模型的行为。

为了具体说明,我们将演示 LIME 在两个任务上的应用:鸢尾花物种分类和电影评论分类。

6.3.2. 漫步 LIME:一个小例子

第一个例子是鸢尾花分类。


示例

假设你有一个包含三种鸢尾花花瓣和萼片尺寸的数据集。目标是根据花瓣和萼片的尺寸预测给定的鸢尾花是否为 setosa。


让我们获取数据并将其分为测试集和训练集。

列表 6.14. 加载鸢尾花数据集

iris <- iris

iris$class <- as.numeric(iris$Species == "setosa")   ❶

set.seed(2345)
intrain <- runif(nrow(iris)) < 0.75                  ❷
train <- iris[intrain,]
test <- iris[!intrain,]

head(train)

##   Sepal.Length Sepal.Width Petal.Length Petal.Width Species class
## 1          5.1         3.5          1.4         0.2  setosa     1
## 2          4.9         3.0          1.4         0.2  setosa     1
## 3          4.7         3.2          1.3         0.2  setosa     1
## 4          4.6         3.1          1.5         0.2  setosa     1
## 5          5.0         3.6          1.4         0.2  setosa     1
## 6          5.4         3.9          1.7         0.4  setosa     1

❶ Setosa 是正类。

❷ 使用 75%的数据进行训练,其余作为保留(测试数据)

变量是萼片和花瓣的长度和宽度。你想要预测的结果是class,当鸢尾花是setosa时为 1,否则为 0。你将使用来自xgboost包的梯度提升模型来预测class

你将在第十章中详细了解梯度提升模型;目前,我们已经将拟合过程封装到函数fit_iris_example()中,该函数接受输入矩阵和类别标签向量作为输入,并返回一个预测class的模型。^([10]) fit_iris_example()的源代码位于github.com/WinVector/PDSwR2/tree/master/LIME_iris/lime_iris_example.R;在第十章中,我们将详细解释该函数的工作原理。

¹⁰

xgboost包要求输入是一个数值矩阵,类别标签是一个数值向量。

要开始,将训练数据转换为矩阵并拟合模型。确保lime_iris_example.R位于你的工作目录中。

列表 6.15. 将模型拟合到鸢尾花训练数据

source("lime_iris_example.R")                     ❶

input <- as.matrix(train[, 1:4])                  ❷
model <- fit_iris_example(input, train$class)

❶ 加载便利函数

❷ 模型的输入是训练数据的前四列,转换为一个矩阵。

在您拟合模型后,您可以在测试数据上评估模型。模型的预测是给定留兰香是 setosa 的概率。

列表 6.16. 评估留兰香模型

predictions <- predict(model, newdata=as.matrix(test[,1:4]))   ❶

teframe <- data.frame(isSetosa = ifelse(test$class == 1,       ❷
                                        "setosa",
                                        "not setosa"),
                      pred = ifelse(predictions > 0.5,
                                "setosa",
                                "not setosa"))
with(teframe, table(truth=isSetosa, pred=pred))                ❸

##             pred
## truth        not setosa setosa
##   not setosa         25      0
##   setosa              0     11

❶ 在测试数据上做出预测。预测是留兰香是 setosa 的概率。

❷ 预测和实际结果的数据框

❸ 检查混淆矩阵

注意,测试集中的所有数据点都落在混淆矩阵的对角线上:该模型正确地将所有 setosa 示例标记为 “setosa”,并将所有其他标记为 “not setosa”。该模型在测试集上的预测完美无缺!然而,您可能仍然想知道在用您的模型对留兰香进行分类时,哪些特征是最重要的。让我们从 test 数据集中的一个特定示例开始,并使用 lime 包对其进行解释.^([11])

¹¹

lime 包并不支持所有类型的模型。请参阅 help(model_support) 获取它支持的模型类列表(xgboost 是其中之一),以及如何添加对其他类型模型的支持。有关其他示例,请参阅 LIME 的 README 文件 (cran.r-project.org/web/packages/lime/README.html)。

首先,使用训练集和模型构建一个 解释器:一个您将用于解释模型预测的函数。

列表 6.17. 从模型和训练数据构建 LIME 解释器

library(lime)
explainer <- lime(train[,1:4],                  ❶
                      model = model,
                      bin_continuous = TRUE,    ❷
                      n_bins = 10)              ❸

❶ 从训练数据构建解释器

❷ 在解释时对连续变量进行分箱

❸ 使用 10 个分箱

现在从测试集中选择一个特定示例。

列表 6.18. 留兰香数据示例

(example <- test[5, 1:4, drop=FALSE])                    ❶
##    Sepal.Length Sepal.Width Petal.Length Petal.Width
## 30          4.7         3.2          1.6         0.2

test$class[5]
## [1] 1                                                 ❷

round(predict(model, newdata = as.matrix(example)))
## [1] 1                                                 ❸

❶ 单行数据框

❷ 这个示例是 setosa

❸ 并且模型预测它是 setosa

现在解释 example 上的模型预测。请注意,dplyr 包也有一个名为 explain() 的函数,所以如果您在命名空间中有 dplyr,您可能在调用 limeexplain() 函数时遇到冲突。为了防止这种歧义,请使用命名空间符号指定函数:lime::explain(...).

列表 6.19. 解释留兰香示例

explanation <- lime::explain(example,
                                explainer,
                                n_labels = 1,     ❶
                                n_features = 4)   ❷

❶ 要解释的标签数量;对于二分类,使用 1。

❷ 在拟合解释时使用的特征数量

您可以使用 plot_features() 函数可视化解释,如图 6.22 所示 图 6.22。

plot_features(explanation)

图 6.22. 可视化模型预测的解释

解释器期望模型预测此示例是 setosa(标签 = 1),并且该示例的 Petal.Length 值是支持此预测的强烈证据。

LIME 的工作原理

为了更好地理解 LIME 的解释,并诊断解释是否可信,了解 LIME 的高层次工作原理很有帮助。图 6.23 高层次地概述了 LIME 对分类器的处理过程。该图显示了以下要点:

  • 模型的决策表面。分类器的决策表面是在变量空间中分隔模型将数据点分类为正例(在我们的例子中,作为“setosa”)和将其分类为负例(在我们的例子中,作为“not setosa”)的表面。

  • 图中标记为圆圈加号的我们想要解释的数据点。在图中,该数据点是一个正例。在接下来的解释中,我们将称这个点为“原始示例”或example

  • 算法创建并给模型评估的合成数据点。我们将详细说明合成示例是如何产生的。

  • LIME 对我们要解释的示例附近的决策表面的估计。我们将详细说明 LIME 是如何得出这个估计的。

图 6.23。LIME 工作原理的概念草图

图 6.23 的替代文本

程序如下:

  1. “抖动”原始示例以生成与它相似的合成示例。你可以将每个抖动点视为原始示例,其中每个变量的值略有变化。例如,如果原始示例是

    Sepal.Length Sepal.Width Petal.Length Petal.Width
             5.1         3.5          1.4         0.2
    

    那么一个抖动点可能为

    Sepal.Length Sepal.Width Petal.Length Petal.Width
        5.505938    3.422535       1.3551   0.4259682
    

    为了确保合成示例是合理的,LIME 使用训练集中数据的分布来生成抖动数据。在我们的讨论中,我们将称合成示例的集合为 {s_i}。图 6.23 显示了作为额外正号和负号的合成数据。请注意,抖动是随机的。这意味着在同一个示例上多次运行 explain() 将每次产生不同的结果。如果 LIME 的解释是强有力的,结果应该不会太不同,这样解释在数量上仍然相似。在我们的案例中,Petal.Length很可能总是作为权重最大的变量出现;只是Petal.Length的权重及其与其他变量的关系会有所不同。

  2. 使用模型对所有合成示例进行预测 {y_i}。在图 6.23 中,正号表示模型被分类为正例的合成示例,负号表示模型被分类为负例的合成示例。LIME 将使用 {y_i} 的值来了解模型在原始示例附近的决策表面看起来像什么。在图 6.23 中,决策表面是分隔模型将数据点分类为正例的区域和将其分类为负例的区域的大型曲线结构。

  3. 将 {y_i} 作为 {s_i} 的函数拟合一个 m- 维线性模型。线性模型是 LIME 对原始模型决策表面的估计,如图 6.23 图 中的虚线所示。使用线性模型意味着 LIME 假设模型的决策表面在 example 附近的局部区域是线性的(平坦的)。你可以将 LIME 的估计看作是最准确地分离正合成示例和负合成示例的平坦表面(在图中,它是一条线)。线性模型的 R²(在 图 6.22 中报告为“解释拟合”)表明这一假设得到了多好的满足。如果解释拟合接近 0,那么就没有一个平坦的表面能够很好地将正例和负例分开,LIME 的解释可能不可靠。你通过函数 explain() 中的 n_features 参数指定 m 的值。在我们的例子中,我们使用四个特征(所有特征)来拟合线性模型。当有大量特征(如文本处理中)时,LIME 会尝试选择最佳的 m 个特征来拟合模型。线性模型的系数给出了解释中特征的权重。对于分类,一个大的正权重意味着相应的特征是模型预测的有力证据,而一个大的负权重意味着相应的特征是反对模型预测的有力证据。

将步骤作为一个整体来考虑

这可能看起来有很多步骤,但它们都由 lime 包提供了一个方便的包装器。总的来说,这些步骤是在实现一个简单的反事实问题的解决方案:如果给定的示例有不同的属性,它的评分会有何不同?总结强调了最重要的合理变化。

回到鸢尾花示例

让我们再挑选几个示例,并解释模型对这些示例的预测。

列表 6.20. 更多鸢尾花示例

(example <- test[c(13, 24), 1:4])

##     Sepal.Length Sepal.Width Petal.Length Petal.Width
## 58           4.9         2.4          3.3         1.0
## 110          7.2         3.6          6.1         2.5

test$class[c(13,24)]                                 ❶
## [1] 0 0

round(predict(model, newdata=as.matrix(example)))    ❷
## [1] 0 0

explanation <- explain(example,
                          explainer,
                          n_labels = 1,
                          n_features = 4,
                          kernel_width = 0.5)

plot_features(explanation)

❶ 这两个示例都是负的(不是 setosa)。

❷ 模型预测这两个示例都是负的。

解释器期望模型会预测这两个示例都不是 setosa(标签 = 0)。对于案例 110(example 的第二行和 图 6.24 的右侧图),这又是由于 Petal.Length。案例 58(图 6.24 的左侧图)看起来很奇怪:大部分证据似乎都与预期的分类相矛盾!请注意,案例 58 的解释拟合相当小:它比案例 110 的拟合小一个数量级。这告诉你,你可能不想相信这个解释。

图 6.24. 两个鸢尾花示例的解释

图片

让我们看看这三个例子与鸢尾花数据的其余部分如何比较。图 6.25 显示了数据中花瓣和萼片尺寸的分布,其中三个样本案例被标记出来。

图 6.25. 不同物种花瓣和萼片尺寸的分布

从图 6.25 中可以看出,花瓣长度强烈区分了setosa与其他的鸢尾花物种。就花瓣长度而言,案例 30 显然是setosa,而案例 110 显然不是。案例 58 似乎不是setosa,因为花瓣长度,但如前所述,案例 58 的整个解释相当差,可能是因为案例 58 位于模型决策曲面上的一种类型的拐点上。

现在我们尝试在一个更大的例子上使用 LIME。

6.3.3. LIME 用于文本分类


示例

在这个例子中,你将使用互联网电影数据库(IMDB)中的电影评论进行分类。任务是识别正面评论。


为了方便,我们将原始存档中的数据转换为两个 RDS 文件,IMDBtrain.RDSIMDBtest.RDS,它们可以在github.com/WinVector/PDSwR2/tree/master/IMDB找到。每个 RDS 对象包含两个元素:一个表示 25,000 条评论的字符向量,以及一个表示数值标签的向量,其中 1 表示正面评论,0 表示负面评论.^([13]) 你将再次拟合一个xgboost模型来对评论进行分类。

^{(12)}

原始数据可以在s3.amazonaws.com/text-datasets/aclImdb.zip找到。

^{(13)}

创建 RDS 文件所使用的提取/转换脚本可以在github.com/WinVector/PDSwR2/tree/master/IMDB/getIMDB.R找到。

你可能会想知道 LIME 是如何抖动文本数据的。它是通过随机从文档中删除单词来做到这一点的,然后将生成的新文本转换为模型适当的表示。如果删除一个单词倾向于改变文档的分类,那么这个单词可能对模型很重要。

首先,加载训练集。确保你已经将 RDS 文件下载到你的工作目录中。

列表 6.21. 加载 IMDB 训练数据

library(zeallot)                                 ❶

c(texts, labels) %<-% readRDS("IMDBtrain.RDS")   ❷

❶ 加载 zeallot 库。如果失败,则调用 install.packages(“zeallot”)。

❷ 命令 read(IMDBtrain.RDS)返回一个列表对象。使用 zeallot 赋值箭头%<-%将列表解包成两个元素:texts 是一个包含评论的字符向量,labels 是一个 0/1 的类别标签向量。标签 1 表示正面评论。

你可以检查评论及其相应的标签。这里有一个正面评论:

list(text = texts[1], label = labels[1])
## $text
## train_21317
## train_21317
## "Forget depth of meaning, leave your logic at the door, and have a
## great time with this maniacally funny, totally absurdist, ultra-
## campy live-action \"cartoon\". MYSTERY MEN is a send-up of every
## superhero flick you've ever seen, but its unlikelysuper-wannabes
## are so interesting, varied, and well-cast that they are memorable
## characters in their own right. Dark humor, downright silliness,
## bona fide action, and even a touchingmoment or two, combine to
## make this comic fantasy about lovable losers a true winner. The
## comedic talents of the actors playing the Mystery Men --
## including one Mystery Woman -- are a perfect foil for Wes Studi
## as what can only be described as a bargain-basement Yoda, and
## Geoffrey Rush as one of the most off-the-wall (and bizarrely
## charming) villains ever to walk off the pages of a Dark Horse
## comic book and onto the big screen. Get ready to laugh, cheer,
## and say \"huh?\" more than once.... enjoy!"
##
## $label
## train_21317
##           1

这里有一个负面评论:

list(text = texts[12], label = labels[12])
## $text
## train_385
## train_385
## "Jameson Parker And Marilyn Hassett are the screen's most unbelievable
## couple since John Travolta and Lily Tomlin. Larry Peerce's direction
## wavers uncontrollably between black farce and Roman tragedy. Robert
## Klein certainly think it's the former and his self-centered  performance
## in a minor role underscores the total lack of balance and chemistry
## between the players in the film. Normally, I don't like to let myself
## get so ascerbic, but The Bell Jar is one of my all-time favorite books,
## and to watch what they did with it makes me literally crazy."
##
## $label
## train_385
##         0

为建模表示文档

对于我们的文本模型,特征是单个词,有很多这样的词。为了使用 xgboost 在文本上拟合模型,我们必须构建一个有限的特征集,或称为 词汇表。词汇表中的词是模型唯一考虑的特征。

我们不想使用太常见的词,因为出现在正面评论和负面评论中的常见词不会提供信息。我们也不想使用太罕见的词,因为很少出现在评论中的词并不那么有用。对于这个任务,让我们将“太常见”定义为出现在超过一半的训练文档中的词,将“太罕见”定义为出现在不到 0.1%的文档中的词。

我们将使用 text2vec 包构建一个包含 10,000 个词的词汇表,这些词不太常见也不太罕见。为了简洁,我们将该过程封装在函数 create_pruned_vocabulary() 中,该函数接受文档向量作为输入并返回一个词汇表对象。create_pruned_vocabulary() 函数的源代码位于 github.com/WinVector/PDSwR2/tree/master/IMDB/lime_imdb_example.R

一旦我们有了词汇表,我们必须将文本(再次使用 text2vec)转换为 xgboost 可以使用的数值表示。这种表示称为 文档-词矩阵,其中行表示语料库中的每个文档,每列表示词汇表中的一个词。对于文档-词矩阵 dtmdtm[i, j] 是词汇表词 w[j] 在文档 texts[i] 中出现的次数。参见 图 6.26。请注意,这种表示会丢失文档中词的顺序。

图 6.26. 创建文档-词矩阵

图片

文档-词矩阵将会相当大:25,000 行乘以 10,000 列。幸运的是,词汇表中的大多数词不会出现在给定的文档中,所以每一行将主要是零。这意味着我们可以使用一种特殊表示,称为 稀疏矩阵,该矩阵以空间高效的方式表示大型、主要为零的矩阵。

我们将这个转换封装在函数 make_matrix() 中,该函数接受文本向量和词汇表作为输入,并返回一个稀疏矩阵。与鸢尾花示例一样,我们还把模型拟合封装在函数 fit_imdb_model() 中,该函数接受文档词矩阵和数值文档标签作为输入,并返回一个 xgboost 模型。这些函数的源代码也位于 github.com/WinVector/PDSwR2/tree/master/IMDB/lime_imdb_example.R

6.3.4. 训练文本分类器

在将 lime_imdb_example.R 下载到您的当前工作目录后,您可以从训练数据中创建词汇表和文档-词矩阵,并拟合模型。这可能需要一些时间。

列表 6.22. 转换文本并拟合模型

source("lime_imdb_example.R")

vocab <- create_pruned_vocabulary(texts)      ❶
dtm_train <- make_matrix(texts, vocab)        ❷
model <- fit_imdb_model(dtm_train, labels)    ❸

❶ 从训练数据创建词汇表

❷ 创建训练语料库的文档-词矩阵

❸ 训练模型

现在加载测试语料库并评估模型。

列表 6.23. 评估评论分类器

c(test_txt, test_labels) %<-%  readRDS("IMDBtest.RDS")               ❶
dtm_test <- make_matrix(test_txt, vocab)                             ❷

predicted <- predict(model, newdata=dtm_test)                        ❸

teframe <- data.frame(true_label = test_labels,
                         pred = predicted)                           ❹

(cmat <- with(teframe, table(truth=true_label, pred=pred > 0.5)))    ❺

##      pred
## truth FALSE  TRUE
##     0 10836  1664
##     1  1485 11015

sum(diag(cmat))/sum(cmat)                                            ❻
## [1] 0.87404

library(WVPlots)
DoubleDensityPlot(teframe, "pred", "true_label",
                  "Distribution of test prediction scores")          ❻

❶ 读取测试语料库

❷ 将语料库转换为文档-词矩阵

❸ 在测试语料库上做出预测(概率)

❹ 创建包含真实标签和预测标签的框架

❺ 计算混淆矩阵

❻ 计算准确率

❻ 绘制预测分布

根据其在测试集上的表现,模型在分类评论方面做得很好,但并不完美。测试预测分数的分布(图 6.27)显示,大多数负评(类别 0)得分低,大多数正评(类别 1)得分高。然而,也有一些正评得分接近 0,一些负评得分接近 1。还有一些评论得分接近 0.5,这意味着模型对这些看似模糊的评论完全不确定。你希望改进分类器,在这些看似模糊的评论上做得更好。

图 6.27. 测试预测分数的分布

图 6.27 的替代文本

6.3.5. 解释分类器的预测

尝试解释几个示例评论的预测,以了解模型。首先,从训练数据和模型中构建解释器。对于文本模型,lime()函数需要一个预处理函数,该函数将训练文本和合成示例转换为模型所需的文档-词矩阵。

列表 6.24. 为文本分类器构建解释器

explainer <- lime(texts, model = model,
                  preprocess = function(x) make_matrix(x, vocab))

现在从测试语料库中取一个简短的样本文本。这条评论是积极的,模型预测它是积极的。

列表 6.25. 解释模型对评论的预测

casename <- "test_19552";
sample_case <- test_txt[casename]
pred_prob <- predict(model, make_matrix(sample_case, vocab))
list(text = sample_case,
     label = test_labels[casename],
     prediction = round(pred_prob) )

## $text
## test_19552
## "Great story, great music. A heartwarming love story that's beautiful to
## watch and delightful to listen to. Too bad there is no soundtrack CD."
##
## $label
## test_19552
##          1
##
## $prediction
## [1] 1

现在用五个最有力的证据词来解释模型的分类。影响预测最多的单词在图 6.28 中显示。

图 6.28. 样本评论的预测解释

图 6.28 的替代文本

列表 6.26. 解释模型的预测

explanation <- lime::explain(sample_case,
                       explainer,
                       n_labels = 1,
                       n_features = 5)

plot_features(explanation)

在列表 6.26 中,你使用了plot_features()来可视化解释,就像在iris示例中做的那样,但lime还有一个针对文本的特殊可视化,即plot_text_explanations()

如图 6.29 所示,plot_text_explanations()突出了文本中的关键词,绿色代表支持证据,红色代表矛盾。证据越强,颜色越深。在这里,解释器期望模型会根据单词delightfulgreatbeautiful预测这条评论是积极的,尽管有单词bad

plot_text_explanations(explanation)

图 6.29. 列表 6.26 中预测的文本解释

图 6.29 的替代文本

让我们再看几篇评论,包括一篇模型分类错误的评论。

列表 6.27. 检查两篇更多的评论

casenames <-  c("test_12034", "test_10294")
sample_cases <- test_txt[casenames]
pred_probs <- predict(model, newdata=make_matrix(sample_cases, vocab))
list(texts = sample_cases,
     labels = test_labels[casenames],
     predictions = round(pred_probs))

## $texts
## test_12034
## "I don't know why I even watched this film. I think it was because
## I liked the idea of the scenery and was hoping the film would be
## as good. Very boring and pointless."
##
## test_10294
## "To anyone who likes the TV series: forget the movie. The jokes
## are bad and some topics are much too sensitive to laugh about it.
## <br /><br />We have seen much better acting by R. Dueringer in
## \"Hinterholz 8\"".
##
## $labels
## test_12034 test_10294                          ❶
##          0          0
##
## $predictions                                   ❷
## [1] 0 1

explanation <- lime::explain(sample_cases,
                                    explainer,
                                    n_labels = 1,
                                    n_features = 5)

plot_features(explanation)
plot_text_explanations(explanation)

❶ 这两篇评论都是负面的。

❷ 模型错误地将第二篇评论分类。

如图 6.30 所示,解释器预计模型将主要基于单词pointlessboring将第一篇评论分类为负面。它预计模型将基于单词8sensitiveseen将第二篇评论分类为正面,尽管有单词bad和(有些令人惊讶地)better

图 6.30. 列表 6.27 中的两个样本评论的解释可视化

注意,根据图 6.30,第二次评论的分类概率似乎为 0.51——换句话说,解释器预计模型对其预测将完全没有把握。让我们将其与模型在现实中的预测进行比较:

predict(model, newdata=make_matrix(sample_cases[2], vocab))
## [1] 0.6052929

模型实际上以 0.6 的概率预测标签 1:这不是一个自信的预测,但比解释器估计的要稍微自信一些(尽管仍然是错误的)。差异在于解释器返回的标签和概率来自模型线性近似的预测,而不是来自模型本身。你偶尔甚至可能会看到解释器和模型对同一示例返回不同的标签的情况。这通常发生在解释器拟合不佳时,因此你根本不应该相信那些解释。

作为负责分类评论的数据科学家,你可能会对数字8看似很高的重要性感到好奇。经过反思,你可能记得一些电影评论中包含评分“8 分/10 分”,或者“8/10”。这可能会让你考虑在将评论传递给文本处理器之前提取明显的评分,并将它们作为额外的特殊特征添加到模型中。你可能也不喜欢使用像seenidea这样的词作为特征。

作为一项简单的实验,你可以尝试从词汇表中移除数字 1 到 10,然后重新调整模型。新的模型正确地分类了test_10294,并返回了一个更合理的解释,如图 6.31 所示。

^(14)

这涉及到将数字 1 到 10 作为字符串添加到文件 lime_imdb_example.R 中create_pruned_vocabulary()函数的停用词列表中。我们将重新创建词汇表和文档-词矩阵,以及重新调整评论分类器,作为读者的练习。

图 6.31. test_10294的解释可视化

查看模型对其他被错误分类的评论的解释可以引导你改进特征工程或数据预处理,这可能会提高你的模型。你可能会决定,单词序列(好主意,而不是仅仅主意)是更好的特征。或者你可能会决定,你想要一个文本表示和模型,它查看文档中单词的顺序,而不仅仅是单词频率。无论如何,查看模型对边缘情况的预测解释可以让你对你的模型有更深入的了解,并帮助你决定如何更好地实现你的建模目标。

摘要

你现在对如何选择建模技术有了些稳固的想法。你也知道了如何评估数据科学工作的质量,无论是自己的还是他人的。本书第二部分第二部分的剩余章节将更详细地介绍如何构建、测试和交付有效的预测模型。在下一章,我们将实际开始构建预测模型。

在本章中,你学习了

  • 如何将你想要解决的问题与适当的建模方法相匹配。

  • 如何划分你的数据以进行有效的模型评估。

  • 如何计算用于评估分类模型的各项指标。

  • 如何计算用于评估评分(回归)模型的各项指标。

  • 如何计算用于评估概率模型的各项指标。

  • 如何使用lime包解释模型中的单个预测。

第七章 线性和逻辑回归

本章涵盖

  • 使用线性回归预测数量

  • 使用逻辑回归预测概率或类别

  • 从线性模型中提取关系和建议

  • 解释 R 的lm()调用中的诊断信息

  • 解释 R 的glm()调用中的诊断信息

  • 使用glmnet包通过正则化来解决线性模型可能出现的各种问题。

在上一章中,你学习了如何评估模型。现在我们有了讨论模型是好是坏的能力,我们将继续到建模步骤,如图 7.1 中的心理模型所示。在本章中,我们将介绍如何在 R 中拟合和解释线性模型

图 7.1. 心理模型

当你不仅想要预测一个结果,还想了解输入变量与结果之间的关系时,线性模型特别有用。这种知识可能非常有用,因为这种关系通常可以用作如何获得你想要的结果的建议

我们首先定义线性回归,然后用它来预测客户收入。之后,我们将使用逻辑回归来预测新生儿需要额外医疗注意力的概率。我们还将介绍 R 在拟合线性或逻辑模型时产生的诊断信息。

线性方法可以在出人意料广泛的情境中有效工作。然而,当模型的输入变量相关或共线性时,可能会出现问题。在逻辑回归的情况下,当变量子集在训练数据的一个子集中完美预测分类输出时,也可能出现问题(具有讽刺意味的是)。本章的最后部分将展示如何通过一种称为正则化的技术来解决这些问题。

7.1. 使用线性回归

线性回归是统计学家和数据科学家常用的预测方法。如果你试图预测像利润、成本或销售量这样的数值量,你应该首先尝试线性回归。如果它工作得很好,你就完成了;如果它失败了,产生的详细诊断信息可以给你一个很好的线索,告诉你下一步应该尝试什么方法。

7.1.1. 理解线性回归


示例

假设你想预测一个正在节食和锻炼计划中的人一个月内会减掉多少磅。你将基于该人的其他事实来做出预测,比如他们在这个月平均每天减少多少卡路里摄入量,以及他们每天锻炼多少小时。换句话说,对于每个人 i,你想要根据 daily_cals_down[i] daily_exercise[i]来预测 pounds_lost[i]


线性回归假设结果pounds_lost与每个输入daily_cals_down[i]daily_exercise[i]线性相关。这意味着(例如)daily_cals_down[i]pounds_lost之间的关系看起来像一条(有噪声的)直线,如图 7.2 所示。1

¹

很有希望希望b0J = bC0 + be0或者b.calsJ = b.cals;然而,联合回归并不能保证这一点。

图 7.2。daily_cals_downpounds_lost之间的线性关系

图 7.2

daily_exercisepounds_lost之间的关系也会是直线。假设图 7.2 中显示的线的方程是

pounds_lost = bc0 + b.cals * daily_cals_down

这意味着对于daily_cals_down(每减少一卡路里)的每单位变化,pounds_lost的值会通过b.cals变化,无论daily_cals_down的起始值是多少。为了具体说明,假设pounds_lost = 3 + 2 * daily_cals_down。那么增加daily_cals_down一个单位,pounds_lost也会增加 2,无论你从什么值开始。对于像pounds_lost = 3 + 2 * (daily_cals_down²)这样的例子,这就不成立了。

线性回归进一步假设总减重是变量daily_cals_down[i]daily_exercise[i]线性组合,或者说是由于减少卡路里摄入量和运动导致的减重之和。这给我们带来了以下线性回归模型形式的pounds_lost

pounds_lost[i] = b0 + b.cals * daily_cals_down[i] +
     b.exercise * daily_exercise[i]

线性回归的目标是找到b0b.calsb.exercise的值,使得daily_cals_lost[i]daily_exercise[i]的线性组合(加上一些偏移量b0)对于训练数据中的所有人员ipounds_lost[i]都非常接近。

让我们用更普遍的术语来表述。假设y[i]是你想要预测的数值量(称为因变量响应变量),而x[i,]是与输出y[i]相对应的一行输入(x[i,]自变量解释变量)。线性回归试图找到一个函数f(x),使得

公式 7.1。线性回归模型的表示

公式 7.1

你想要找到数字b[0],...,b[n](称为系数贝塔),使得对于训练数据中的所有(x[i,],y[i])对,f(x[i,])尽可能接近y[i]。R 提供了一个单行命令来找到这些系数:lm()

公式 7.1 中的最后一项e[i]代表所谓的非系统误差,或噪声。非系统误差被定义为具有均值为0(因此它们不代表净向上或净向下的偏差)并且与x[i,]不相关。换句话说,x[i,]不应该包含关于e[i]的信息(反之亦然)。

通过假设噪声是无系统的,线性回归试图拟合所谓的“无偏”预测器。这另一种说法是,预测器在整个训练集上“平均”得到正确答案,或者说它低估的量与高估的量大致相等。特别是,无偏估计往往能得到总数正确。


示例

假设你已拟合一个线性回归模型来预测基于卡路里摄入量减少和锻炼的体重减轻。现在考虑训练数据集中的LowExercise组,该组的人每天锻炼时间在零到一小时之间。这些受试者共同在研究过程中减掉了总共 150 磅。模型预测他们会减掉多少体重?


使用线性回归模型时,如果你将LowExercise组中所有受试者的预测体重减轻值加起来,这个总和将是 150 磅,这意味着模型正确预测了LowExercise组一个人的平均体重减轻,尽管有些人减掉的重量超过了模型的预测,有些人减掉的重量少于模型预测。在商业环境中,正确计算这样的总和是至关重要的,尤其是在汇总货币金额时。

在这些假设(线性关系和无系统噪声)下,线性回归在寻找最佳系数b[i]方面是绝对不懈的。如果有某种有利的特点组合或特征消除,它也会找到。线性回归不做的一件事是将变量重塑为线性。奇怪的是,即使实际关系不是线性的,线性回归通常也能做得很好。


思考线性回归

当使用线性回归时,你会在“加法太简单无法工作”和“如何估计系数?”之间来回摇摆。这是自然的,源于该方法既简单又强大。我们的朋友菲利普·阿普斯总结了这一点:“你必须起得很早才能打败线性回归。”


当线性回归的假设被违反时

作为一个小例子,考虑只用线性函数加上一个常数来拟合整数 1 到 10 的平方。我们要求系数b[0]b[1],使得

x[i]² nearly equals b[0] + b[1] * x[i]

显然,这个问题是不公平的,因为我们知道我们试图预测的不是线性的。然而,在这种情况下,线性回归仍然做得相当不错。它选择了以下拟合:

x[i]² nearly equals -22 + 11 * x[i]

如图 7.3 所示,这是我们在训练区域的一个良好拟合。

图 7.3. y=x²的拟合与实际值

图 7.3 中的例子是线性回归在“实际应用中”的典型用法——我们使用线性模型来预测本身不是线性的东西。请注意,这是一个小错误。特别是,请注意,模型预测与真实y之间的误差不是随机的,而是系统的:模型在特定的x范围内低估,在其他范围内高估。这不是理想的,但通常是我们能做的最好的。请注意,在本例中,预测在拟合的端点附近偏离真实结果更远,这表明这个模型可能不适合在模型在训练数据中观察到的x范围之外使用。


外推法不如内插法安全

通常,您应该只尝试使用模型进行内插法:预测训练数据范围内的新的数据。外推法(预测训练数据观察范围之外的新的数据)对任何模型来说都更具风险。对于线性模型来说,除非您知道您正在建模的系统确实是线性的,否则这尤其危险。


接下来,我们将通过一个例子来展示如何将线性回归应用于更有趣的实时数据。

介绍 PUMS 数据集


示例

假设您想预测任何公众人物的相对百分比的个人收入,给定他们的年龄、教育和其他人口统计变量。除了预测收入外,您还有一个次要目标:确定学士学位对收入的影响,相对于完全没有学位的情况。


对于这个任务,您将使用 2016 年美国人口普查 PUMS 数据集。为了简化,我们已经准备了一个小样本的 PUMS 数据用于本例。数据准备步骤包括以下内容:

  • 将数据限制为 20 至 50 岁之间的全职员工,收入在 1,000 美元至 250,000 美元之间。

  • 将数据划分为训练集dtrain和测试集dtest

我们可以通过将 psub.RDS(您可以从github.com/WinVector/PDSwR2/raw/master/PUMS/psub.RDS下载)加载到工作目录中,并执行以下列表中的步骤来继续这个例子.^([1])

¹

准备数据样本的脚本可以在github.com/WinVector/PDSwR2/blob/master/PUMS/makeSubSample.Rmd找到。

列表 7.1. 加载 PUMS 数据并拟合模型

psub <- readRDS("psub.RDS")

set.seed(3454351)
gp <- runif(nrow(psub))                                               ❶

dtrain <- subset(psub, gp >= 0.5)                                     ❷
 dtest <- subset(psub, gp < 0.5)

model <- lm(log10(PINCP) ~ AGEP + SEX + COW + SCHL, data = dtrain)    ❸
 dtest$predLogPINCP <- predict(model, newdata = dtest)                ❹
 dtrain$predLogPINCP <- predict(model, newdata = dtrain)

❶ 通过随机变量对数据进行分组和划分

❷ 将数据 50-50 分成训练集和测试集

❸ 将线性模型拟合到对数收入

❹ 在测试集和训练集上获取预测的对数收入

PUMS 数据集的每一行代表一个单独的匿名个人或家庭。记录的个人数据包括职业、教育水平、个人收入以及许多其他人口统计变量。

对于这个例子,我们决定预测log10(PINCP),即收入的对数。拟合对数转换后的数据通常会得到相对误差更小的结果,强调对较小收入的较小误差。但这种改进的相对误差是以引入偏差为代价的:平均而言,预测的收入将低于实际训练收入。预测log(income)的无偏替代方案是使用一种称为泊松回归的广义线性模型。我们将在第 7.2 节中讨论广义线性模型(特别是逻辑回归)。泊松回归是无偏的,但通常以更大的相对误差为代价。1]

¹

关于讨论这些问题的系列文章,请参阅www.win-vector.com/blog/2019/07/link-functions-versus-data-transforms/

对于本节的分析,我们将考虑输入变量年龄(AGEP)、性别(SEX)、工人类别(COW)和教育水平(SCHL)。输出变量是个人收入(PINCP)。我们还将设置参考水平,或“默认”性别为M(男性);工人类别的参考水平为私营营利性企业的雇员;教育水平的参考水平为无高中文凭。我们将在本章后面讨论参考水平。


参考水平是基线,而非价值判断

当我们说默认性别为男性,默认教育水平为无高中文凭时,我们并不是暗示你应该期待典型工人是男性,或者典型工人没有高中文凭。变量的参考水平是其他变量值与之比较的基础水平。因此,我们说的是,在分析的这个阶段,我们可能想要比较具有相同特征的男女工人的收入,或者我们可能想要比较拥有高中文凭或学士学位的工人与没有高中文凭(但其他特征相同)的工人的收入。

默认情况下,R 会选择分类变量按字母顺序排列的第一个值作为参考水平。


现在开始构建模型。

7.1.2. 构建线性回归模型

预测或寻找关系(建议)的第一步是构建线性回归模型。在 R 中构建线性回归模型的函数是lm(),由stats包提供。lm()函数最重要的参数是一个公式,其中用~代替等号。公式指定数据框中哪一列是要预测的量,以及要用于预测的列。

统计学家将需要预测的量称为因变量,将用于进行预测的变量/列称为自变量。我们发现将需要预测的量称为y,将用于预测的变量称为x更容易。我们的公式是:log10(PINCP) ~ AGEP + SEX + COW + SCHL,这可以读作“预测以年龄、性别、就业类别和教育为函数的收入的对数 10 为基数。”^([1])整体方法在图 7.4 中得到了演示。

¹

回想一下第 4.2 节中关于对数正态分布的讨论,通常对货币量进行对数变换是有用的。对数变换也与我们的原始任务兼容,即使用相对误差(意味着大误差对小额收入的影响更大)来预测收入。第 7.2 节中的glm()方法可以用来避免对数变换,并以最小化平方误差的方式预测(因此,偏离$50,000 将被视为大额和小额收入相同的误差)。

图 7.4. 使用lm()构建线性模型

图 7.4 的替代文本

图 7.4 中的语句构建了线性回归模型,并将结果存储在名为model的新对象中。这个模型能够进行预测,并从数据中提取重要信息。


R 将训练数据存储在模型中

R 在其模型中保留了一份训练数据的副本,以提供在summary(model)中看到的残差信息。以这种方式保留数据的副本并非绝对必要,并且可能无谓地耗尽你的内存。如果你内存不足(或正在交换),你可以使用rm()命令删除 R 对象,如model。在这种情况下,你可以通过运行rm("model")来删除模型。


7.1.3. 进行预测

一旦你调用了lm()函数来构建模型,你的第一个目标就是预测收入。在 R 中这样做很简单。要预测,你需要将数据传递给predict()方法。图 7.5 展示了如何使用测试数据框dtest和训练数据框dtrain来演示这一点。

图 7.5. 使用线性回归模型进行预测

图 7.5 的替代文本

数据框列dtest$predLogPINCPdtrain$predLogPINCP现在分别存储测试集和训练集的预测结果。我们现在已经产生了并应用了一个线性回归模型。

描述预测质量

在公开分享预测之前,你希望检查预测和模型的质量。我们建议将你试图预测的实际 y(在这种情况下,预测收入)作为你的预测的函数来绘制。在这种情况下,将 log10(PINCP) 作为 predLogPINCP 的函数来绘制。如果预测非常好,那么图上的点将排列在 y=x 线附近,我们称之为完美预测线(这个短语不是标准术语;我们用它来使讨论图形更容易)。在图 7.6 中展示的生成此图的步骤将在下一个列表中显示。

图 7.6. 实际对数收入作为预测对数收入的函数的图

列表 7.2. 将对数收入作为预测对数收入的函数进行绘图

library('ggplot2')
ggplot(data = dtest, aes(x = predLogPINCP, y = log10(PINCP))) +
   geom_point(alpha = 0.2, color = "darkgray") +
   geom_smooth(color = "darkblue") +
   geom_line(aes(x = log10(PINCP),                  ❶
                  y = log10(PINCP)),
             color = "blue", linetype = 2) +
   coord_cartesian(xlim = c(4, 5.25),               ❷
                    ylim = c(3.5, 5.5))

❶ 绘制 x=y 的线

❷ 限制图形范围以提高可读性

统计学家更喜欢图 7.7 中所示的残差图,其中残差误差(在这种情况下,predLogPINCP - log10(PINCP))作为 predLogPINCP 的函数进行绘制。在这种情况下,完美预测线是 y=0 的线。注意,点从这个线散布很广(可能是低质量拟合的迹象)。图 7.7 中的残差图是用下一个列表中的 R 步骤准备的。

图 7.7. 预测误差作为预测函数的图

列表 7.3. 将残差收入作为预测对数收入的函数进行绘图

ggplot(data = dtest, aes(x = predLogPINCP,
                     y = predLogPINCP - log10(PINCP))) +
  geom_point(alpha = 0.2, color = "darkgray") +
  geom_smooth(color = "darkblue") +
  ylab("residual error (prediction - actual)")

为什么预测而不是真实值在 x 轴上?

在 x 轴上绘制预测,并在 y 轴上绘制真实值(如图 7.6 所示)或残差(如图 7.7 所示)的图与在 x 轴上绘制真实值并在 y 轴上绘制预测(或残差)的图所回答的问题不同。统计学家倾向于更喜欢图 7.7 中所示的图形。在 x 轴上有预测的残差图能让你根据模型输出判断模型可能存在低估或高估的情况。

如果在 x 轴上有真实结果并在 y 轴上有残差,那么几乎总是会显示出不希望的残差结构,即使没有建模问题。这种错觉是由于一种称为回归到均值回归到平庸的效果造成的。


当你查看真实值与拟合值或残差图时,你正在寻找一些我们将在下文中讨论的具体内容。

平均而言,预测是否正确?

平滑曲线是否更靠近完美预测的直线?理想情况下,点将非常接近那条线,但如果你输入的变量没有很好地解释输出,你可能会得到一个更宽的点云(如我们在图 7.6 和 7.7 中看到的那样)。但如果平滑曲线沿着完美预测的直线并且“在点云的中间”,那么模型平均预测是正确的:它低估的量大约等于高估的量。

是否存在系统性误差?

如果平滑曲线过多地偏离了完美预测的直线,如图 7.8 所示,这表明在特定范围内存在系统性的低估或高估:误差与预测相关。系统性误差表明系统“线性程度”不够,线性模型不适合,因此你应该尝试本书后面将要讨论的不同建模方法之一。

图 7.8. 模型预测中的系统性误差示例

R-squared 和 RMSE

除了检查图表外,你还应该对预测质量和残差进行定量总结。衡量预测质量的一个标准指标称为R-squared,我们在第 6.2.4 节中讨论过。R-squared 是衡量模型“拟合”数据程度或其“拟合优度”的指标。你可以使用以下列表中的 R 步骤计算预测和实际y之间的 R-squared。

列表 7.4. 计算 R-squared

rsq <- function(y, f) { 1 - sum((y - f)²)/sum((y - mean(y))²) }

rsq(log10(dtrain$PINCP), dtrain$predLogPINCP)     ❶
 ## [1] 0.2976165

rsq(log10(dtest$PINCP), dtest$predLogPINCP)       ❷
 ## [1] 0.2911965

❶ 训练数据上的模型 R-squared

❷ 测试数据上的模型 R-squared

R-squared 可以理解为y变化的多少部分是由模型解释的。你希望 R-squared 相当大(1.0 是你可以达到的最大值)并且测试和训练数据上的 R-squared 相似。测试数据上的 R-squared 显著低于这是过度拟合模型的症状,这种模型在训练中看起来很好,但在生产中却不起作用。在这种情况下,训练和测试数据的 R-squared 大约都是 0.3。我们希望看到高于这个值(比如,0.7–1.0)。所以模型质量低,但不是过度拟合。

对于拟合良好的模型,R-squared 也等于预测值与实际训练值之间的相关性的平方.^([1])

¹

查看www.win-vector.com/blog/2011/11/correlation-and-r-squared/


R-squared 可能过于乐观

通常,训练数据上的 R-squared 对于具有更多输入参数的模型会更高,无论这些额外变量是否真正改善了模型。这就是为什么许多人更喜欢调整后的 R-squared(我们将在本章后面讨论)。

此外,R-squared 与相关系数有关,如果模型正确预测了几个异常值,相关系数可能会被人为地夸大。这是因为增加的数据范围使得整体数据云相对于完美预测线看起来“更紧密”。以下是一个玩具示例。设y <- c(1,2,3,4,5,9,10)pred <- c(0.5,0.5,0.5, 0.5,0.5,9,10)。这对应于一个模型,对于前五个点与真实结果完全不相关,并且完美预测了最后两个点,这两个点与前面的五个点有些距离。你可以自己检查这个明显较差的模型具有约 0.926 的相关系数cor(y, pred),相应的 R-squared 为 0.858。因此,在检查 R-squared 的同时,查看测试数据的真实值与拟合值图是一个很好的想法。


另一个值得考虑的良好度量是均方根误差(RMSE)。

列表 7.5. 计算均方根误差

rmse <- function(y, f) { sqrt(mean( (y-f)² )) }

rmse(log10(dtrain$PINCP), dtrain$predLogPINCP)      ❶
 ## [1] 0.2685855

rmse(log10(dtest$PINCP), dtest$predLogPINCP)        ❷
 ## [1] 0.2675129

❶ 模型在训练数据上的均方根误差(RMSE)

❷ 模型在测试数据上的均方根误差(RMSE)

你可以将 RMSE 视为围绕完美预测线的数据云宽度的度量。我们希望 RMSE 尽可能小,实现这一目标的一种方法就是引入更多有用、解释性的变量。

7.1.4. 寻找关系和提取建议

记住,你的另一个目标,除了预测收入之外,是找到拥有学士学位的价值。我们将展示如何直接从线性回归模型中读取这个价值以及其他数据中的关系。

线性回归模型中的所有信息都存储在一个称为系数的数字块中。系数可以通过coefficients(model)函数获得。我们的收入模型的系数在图 7.9 中显示。

图 7.9. 模型系数

报告的系数

我们原始的建模变量只有AGEPSEXCOW(工作类别)和SCHL(教育/学历);然而,模型报告的系数比这四个还要多。我们将解释所有报告的系数是什么。

在图 7.9 中,有八个以SCHL开头的系数。原始变量SCHL取了这八个字符串值以及一个未显示的值:no high school diploma。每个可能的字符串被称为水平,而SCHL本身被称为分类因子变量。未显示的水平被称为参考水平;其他水平的系数是以参考水平为基准测量的。

例如,在SCHLBachelor's degree中,我们找到了系数 0.36,这可以读作“模型为拥有学士学位相对于没有高中文凭的 10 对数收入提供了 0.36 的额外奖励。”你可以通过以下方式求解拥有学士学位的人与同等学历(相同性别、年龄和工作类别)但没有高中文凭的人之间的收入比率:

log10(income_bachelors) = log10(income_no_hs_degree) + 0.36
log10(income_bachelors) - log10(income_no_hs_degree) = 0.36
         (income_bachelors) / (income_no_hs_degree)  = 10^(0.36)

这意味着,拥有学士学位的人的收入将倾向于是 10⁰.36,或者说比没有高中文凭的同等人员高出 2.29 倍。

SCHLRegular high school diploma 下,我们发现系数为 0.11。这可以理解为“模型认为,相对于拥有高中文凭,拥有学士学位往往会使预测的对数收入增加 0.36-0.11 个单位。”

log10(income_bachelors) - log10(income_no_hs_degree) = 0.36
       log10(income_hs) - log10(income_no_hs_degree) = 0.11

log10(income_bachelors) - log10(income_hs) = 0.36 - 0.11         ❶
          (income_bachelors) / (income_hs)  = 10^(0.36 - 0.11)

❶ 从第二个方程中减去第一个方程

本科毕业生预期收入与高中毕业生(其他变量相等)之间的模型关系是 10^(0.36 - 0.11),或者说大约高 1.8 倍。建议:如果你能找到工作,上大学是值得的(记住,我们只分析了完全就业的情况,所以这是假设你能找到工作)。

SEXCOW 也是离散变量,分别以 Male私人营利性公司员工 [公司] 为参考水平。对应于 SEXCOW 不同水平的系数可以以与教育水平相似的方式解释。AGEP 是一个系数为 0.0116 的连续变量。你可以将其解释为:年龄增加一年,对对数收入的增加贡献了 0.0116 的额外值;换句话说,年龄增加一年相当于收入增加 10⁰.0116,即增加了 1.027 倍——大约是收入的 2.7%增加(其他变量相等)。

系数 (Intercept) 对应于一个始终具有值为 1 的变量,这个变量在调用 lm() 函数时默认添加到线性回归模型中,除非你在公式调用中使用特殊的 0+ 符号。解释截距的一种方式是将其视为“参考主体的预测”——也就是说,这个主体承担了所有分类输入的参考水平值,对于连续变量为零。请注意,这可能不是一个在物理上合理的主体。

在我们的例子中,参考主体将是一个没有高中文凭、年龄为零的男性私人营利性公司员工。如果这样的人存在,模型将预测他们的以 10 为底的对数收入约为 4.0,这对应于 1 万美元的收入。


指标变量

大多数建模方法通过将其转换为 n(或 n-1)个二元变量,或指标变量来处理具有 n 个可能水平的字符串值(分类)变量。R 有命令可以显式控制将字符串值变量转换为表现良好的指标变量:as.factor() 从字符串变量创建分类变量;relevel() 允许用户指定参考水平。

但要注意那些具有非常多的水平变量的情况,例如邮政编码。线性(和逻辑)回归的运行时间大致与系数数量的立方成正比。太多的水平(或太多的变量)将使算法变得缓慢,并需要更多的数据来进行可靠的推断。在第八章第八章中,我们将讨论处理此类高基数变量的方法,例如效果编码或影响编码。


之前对系数的解释假设模型已经提供了良好的系数估计。我们将在下一节中看到如何检查这一点。

7.1.5. 阅读模型摘要和描述系数质量

在第 7.1.3 节中,我们检查了我们的收入预测是否值得信赖。现在我们将展示如何检查模型系数是否可靠。这尤其重要,因为我们一直在讨论展示系数与其他系数的关系作为建议。

我们需要了解的大部分信息已经在模型摘要中,该摘要是通过summary()命令生成的:summary(model)。这产生了图 7.10 所示的输出。

图 7.10. 模型摘要

这个图看起来可能有些吓人,但它包含了很多有用的信息和诊断信息。当你展示结果时,你可能会被问到图 7.10 中的元素,所以我们将演示所有这些字段是如何得出的以及这些字段的意义。

我们首先将summary()分解成几个部分。

原始模型调用

summary()的第一部分是lm()模型的构建方式:

Call:
lm(formula = log10(PINCP) ~ AGEP + SEX + COW + SCHL,
    data = dtrain)

这是一个双重检查你是否使用了正确的数据框、执行了预期的转换以及使用了正确变量的好地方。例如,你可以再次检查你是否使用了数据框dtrain而不是数据框dtest

残差摘要

summary()的下一部分是残差摘要:

Residuals:
    Min      1Q  Median      3Q     Max
-1.5038 -0.1354  0.0187  0.1710  0.9741

记住,残差是预测中的误差:log10(dtrain$PINCP) - predict (model,newdata=dtrain)。在线性回归中,残差是所有内容。你想要了解的关于模型拟合质量的大部分信息都在残差中。你可以计算训练集和测试集的残差的有用摘要,如下所示。

列表 7.6. 残差摘要

( resids_train <- summary(log10(dtrain$PINCP) -
      predict(model, newdata = dtrain)) )
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
## -1.5038 -0.1354  0.0187  0.0000  0.1710  0.9741

( resids_test <- summary(log10(dtest$PINCP) -
      predict(model, newdata = dtest)) )
##      Min.   1st Qu.    Median      Mean   3rd Qu.      Max.
## -1.789150 -0.130733  0.027413  0.006359  0.175847  0.912646

在线性回归中,系数被选择以最小化残差的平方和。这也是为什么这种方法也经常被称为最小二乘法。因此,对于好的模型,你期望残差很小。

在残差摘要中,你得到了 Min.Max.,这是所见到的最小和最大残差。你还得到了残差的四分位数:1st. Qu.,或上界于数据前 25% 的值;Median,或上界于数据前 50% 的值;以及 3rd Qu.,或上界于数据前 75% 的值(Max 是第四四分位数:上界于 100% 的数据的值)。四分位数给你一个关于数据分布的大致概念。

你希望在残差摘要中看到的是中位数接近 0(如我们的示例所示),以及 1st. Qu.3rd Qu. 大约与中位数等距(两者都不太大)。在我们的例子中,训练残差(resids_train)的 1st. Qu.3rd Qu. 都大约离中位数 0.15。测试残差(0.16 和 0.15 离中位数)稍微不对称,但仍在范围内。

1st. Qu.3rd Qu. 的四分位数很有趣,因为正好有一半的训练数据在这个范围内有残差。在我们的例子中,如果你随机抽取一个训练数据,其残差有 50% 的时间会在 –0.1354 到 0.1710 的范围内。所以你实际上期望经常看到这种规模的预测误差。如果这些误差对于你的应用来说太大,那么你没有一个可用的模型。

系数表

summary(model) 的下一部分是系数表,如图 7.11 所示。此表的矩阵形式可以通过 summary(model)$coefficients 获取。

图 7.11. 模型摘要系数列

每个模型系数形成系数摘要表的行。列报告估计系数、估计的不确定性、系数相对于不确定性的大小以及这种比率仅由于偶然性而可能出现的可能性。图 7.11 给出了列的名称和解释。

你开始研究收入以及获得学士学位对收入的影响。但你必须查看所有系数以检查是否存在干扰效应。

例如,SEXF 的系数为 –0.108 表示你的模型学会了为女性对 log10(PINCP) 施加 –0.108 的惩罚。女性收入与男性收入的比率被建模为 10^(-0.108):女性的收入是男性的 78%,在其他模型参数相等的情况下。注意我们说的是“其他模型参数相等”,而不是“其他所有事物相等。”这是因为我们不是在建模工作年限(年龄可能不是一个可靠的代理)或职业/行业类型(这对收入有重大影响)。这个模型,在它被赋予的特征下,无法测试平均而言,一个与男性在相同职位且具有相同工作经验的女性是否会被支付更少。


不显著的系数

注意在图 7.11 中,系数COWSelf employed incorporated是“不显著的”。这意味着对于这个模型设计来说,没有足够的证据来确定系数是否为零。

一些推荐逐步回归来移除这样的变量,或者添加一种有用的归纳偏差,形式为:“如果我们不能判断它是非零的,就强制它为零。”在这种情况下,这并不方便,因为变量只是一个分类变量的水平(因此,独立处理它有点困难)。我们不推荐逐步回归,因为逐步回归会引入多重比较问题,这会偏颇剩余系数的估计。^a 我们建议接受非显著的估计(因为即使将它们替换为零,也是在用一个不确定的估计替换另一个),或者预先过滤变量以供使用,或者使用正则化方法(如glmnet/lasso)。本书中涵盖了所有这些想法。

^a

参见 Robert Tibshirani,“通过 lasso 进行回归收缩和选择。”皇家统计学会杂志,第 58 卷:267–288,1996 年。

一个需要记住的点:在预测(我们的主要目标)方面,拥有少量影响较小的无关系数并不是问题。问题出现在我们拥有影响较小的系数但系数/效应较大,或者有大量无关系数的情况下。



统计学作为纠正不良实验设计的尝试

测试是否存在性别驱动的收入分布差异的绝对最佳实验是将所有可能变量(年龄、教育、行业年数、绩效评估、种族、地区等)都相同但性别不同的个人的收入进行比较。我们不太可能获得这样的数据,所以我们只能满足于一个好的实验设计:一个没有其他特征与性别相关的总体。随机选择可以帮助实验设计,但它并不是万能的。如果没有一个好的实验设计,通常的实用策略是这样的:引入额外的变量来表示可能干扰我们试图研究的效应的效应。因此,研究性别对收入的影响可能包括其他变量,如教育水平和年龄,以尝试解开相互竞争的效应。


p 值和显著性

p 值(也称为显著性)是系数摘要中最重要的一列诊断信息。p 值估计在真实系数实际上为零的情况下(如果变量对结果没有影响)观察到与观察到的系数大小一样大的概率。因此,不要信任任何具有大 p 值的系数的估计。通常,人们会选择一个阈值,并将所有 p 值低于该阈值的系数称为统计显著,这意味着这些系数很可能不是零。一个常见的阈值是p < 0.05;然而,这是一个任意水平。

注意,一旦足够好,较低的 p 值并不总是“更好”。只要两个 p 值都低于你选择的阈值,就没有理由偏好一个 p 值为 1e-23 的系数而不是一个 p 值为 1e-08 的系数;在这个点上,你知道两个系数都可能是良好的估计,你应该偏好那些解释最多变异的系数。此外,注意高 p 值并不总是告诉我们哪些系数是坏的,正如我们在侧边栏中讨论的那样。


共线性也会降低显著性

有时,一个预测变量可能不会显得显著,因为它与另一个预测变量共线性(或相关)。例如,如果你尝试使用年龄和在职年数来预测收入,两个变量可能都不会显得显著。这是因为年龄往往与在职年数相关。如果你移除其中一个变量,另一个变量变得显著,这是一个很好的相关性指标。

如果你看到看起来不合理大的系数(通常是相反的符号),或者系数的异常大的标准误差,这可能表明存在共线性变量。

输入中存在共线性的另一个可能迹象是看到具有意外符号的系数:例如,看到收入与在职年数呈负相关

整体模型即使在输入相关的情况下仍然可以很好地预测收入,但它无法确定哪个变量应该得到预测的功劳。

在第 7.3 节中我们将讨论,使用正则化在共线性情况下可能会有所帮助。正则化偏好较小的系数,当用于新数据时可能风险较低。

如果你希望将系数值用作建议以及做出良好的预测,尽量在输入中避免共线性。


整体模型质量摘要

summary(model)报告的最后部分是整体模型质量统计。在分享任何预测或系数之前检查整体模型质量是个好主意。摘要如下:

Residual standard error: 0.2688 on 11186 degrees of freedom
Multiple R-squared:  0.2976,    Adjusted R-squared:  0.2966
F-statistic: 296.2 on 16 and 11186 DF,  p-value: < 2.2e-16

让我们更详细地解释每个摘要。

自由度

自由度是数据行数减去拟合的系数数,在我们的例子中,是这样的:

(df <-  nrow(dtrain) - nrow(summary(model)$coefficients))
## [1] 11186

自由度是在纠正了你试图解决的系数数量之后,你拥有的训练数据行数。你希望训练集中的数据点数量与你要解决的系数数量相比要大;换句话说,你希望自由度要高。自由度低表明你试图拟合的模型对于你拥有的数据量来说过于复杂,并且你的模型很可能是过拟合的。过拟合是指你在训练数据中发现了在总体中不存在的偶然关系。过拟合是件坏事:当你没有好模型时,你却以为你有。

残差标准误差

残差标准误差 是残差平方和(或平方误差之和)除以自由度的结果。因此,它与我们在前面讨论过的 RMSE(均方根误差)类似,只是调整了数据行数以匹配自由度;在 R 中,这可以这样计算:

(modelResidualError <- sqrt(sum(residuals(model)²) / df))
## [1] 0.2687895

残差标准误差比 RMSE 对模型性能的估计更保守,因为它调整了模型的复杂性(自由度小于训练数据行数,因此残差标准误差大于 RMSE)。再次强调,这试图弥补更复杂的模型有更高的过拟合数据的倾向。


测试数据上的自由度

在测试数据(在训练过程中未使用的数据)上,自由度等于数据行数。这与训练数据的情况不同,正如我们所说的,在训练数据的情况下,自由度等于数据行数减去模型的参数数量。

差异产生的原因在于模型训练“窥视”的是训练数据,而不是测试数据。


多元和调整后的 R-squared

多元 R-squared 只是模型在训练数据上的 R-squared(在 7.1.3 节中讨论)。

调整后的 R-squared 是对输入变量数量进行惩罚的多个 R-squared。这种惩罚的原因是,通常情况下,增加输入变量的数量会提高训练数据上的 R-squared,即使添加的变量实际上并不具有信息性。这另一种说法是,更复杂的模型由于过拟合,往往在训练数据上看起来更好,因此调整后的 R-squared 是对模型拟合优度的一个更保守的估计。

如果你没有测试数据,在评估你的模型时依赖调整后的 R 平方是一个好主意。但更好的是,在保留的测试数据上计算预测值和实际值之间的 R 平方。在 7.1.3 节中,我们展示了测试数据上的 R 平方为 0.29,在这种情况下,它与报告的调整后的 R 平方 0.3 大致相同。然而,我们仍然建议准备训练集和测试集;测试集的估计可能比统计公式更能代表生产模型的表现。

F 统计量和它的 p 值

F 统计量与你在图 7.11 中看到的系数的 t 值类似。正如 t 值用于计算系数的 p 值一样,F 统计量用于计算模型拟合的 p 值。它得名于 F 检验,这是一种检查两个方差(在这种情况下,常数模型的残差方差和线性模型的残差方差)是否显著不同的技术。相应的 p 值是在假设所讨论的两个方差实际上相同的情况下,我们观察到如此大的或更大的 F 统计量的概率估计。因此,你希望 p 值很小(一个常见的阈值:小于 0.05)。

在我们的例子中,F 统计量的 p 值非常小(< 2.2e-16):该模型解释的方差比常数模型更多,而且这种改进不太可能仅仅来自抽样误差。


解释模型的重要性

大多数线性回归的测试,包括系数和模型重要性的测试,都是基于误差项或残差呈正态分布的假设。重要的是要通过图形分析或使用分位数分析来确定回归模型是否合适。


7.1.6. 线性回归要点

线性回归是预测数量时首选的统计建模方法。它简单且具有优势,即模型的系数通常可以充当建议。以下是关于线性回归你应该记住的几个要点:

  • 线性回归假设结果是输入变量的线性组合。自然地,当这个假设几乎成立时,它效果最好,但即使它不成立,它也能出人意料地预测得很好。

  • 如果你想要使用你模型的系数作为建议,你应该只信任那些在统计上显著的系数。

  • 过大的系数幅度、过大的系数估计标准误差以及系数的符号错误可能是相关输入的迹象。

  • 即使在存在相关变量的情况下,线性回归也能很好地预测,但相关变量会降低建议的质量。

  • 线性回归在处理具有大量变量或具有大量级别的分类变量的问题时可能会遇到困难。

  • 线性回归包提供了一些最好的内置诊断工具,但仍然需要在测试数据上重新检查你的模型,这是你最有效的安全检查。

7.2. 使用逻辑回归

逻辑回归是称为 广义线性模型 的一类模型中最重要的(也可能是最常用的)成员。与线性回归不同,逻辑回归可以直接预测限制在 (0, 1) 区间内的值,例如概率。它是预测概率或比率的首选方法,并且像线性回归一样,逻辑回归模型的系数可以被视为 建议。它也是二元分类问题的良好首选。

在本节中,我们将使用一个医疗分类示例(预测新生儿是否需要额外的医疗关注)来详细说明生产和使用逻辑回归模型的各个步骤.^([1])

¹

逻辑回归通常用于分类,但逻辑回归及其近亲 beta 回归 也用于估计 比率。事实上,R 的标准 glm() 调用除了预测分类之外,还可以预测介于 0 和 1 之间的数值。

正如我们在线性回归中所做的那样,在处理主要示例之前,我们将快速概述逻辑回归。

7.2.1. 理解逻辑回归


示例

假设你想根据航班的事实,如起点和终点、天气和航空公司,预测航班是否会延误。对于每架航班 i,你希望根据 origin[i] destination[i] weather[i]* 和* air_carrier[i]* 来预测* flight_delayed[i]


我们希望使用线性回归来预测航班 i 将会延误的概率,但概率严格限制在 0:1 的范围内,而线性回归并不限制其预测值在这个范围内。

一个想法是找到一个概率函数,其值域在 -Infinity:Infinity 之间,将线性模型拟合到预测该数量,然后从模型预测中求解适当的概率。那么让我们看看一个稍微不同的问题:不是预测航班延误的概率,而是考虑航班延误的 几率,或者航班延误的概率与不延误的概率之比。

odds[flight_delayed] = P[flight_delayed == TRUE] / P[flight_delayed == FALSE]

几率函数的值域不是 -Infinity:Infinity;它被限制为非负数。但我们可以取几率的对数——对数几率——以得到一个概率函数,其值域是 -Infinity:Infinity

log_odds[flight_delayed] = log(P[flight_delayed == TRUE] / P[flight_delayed == FALSE])

Let: p = P[flight_delayed == TRUE]; then
log_odds[flight_delayed] = log(p / (1 - p))

注意,如果航班延迟的可能性大于准时,则几率比将大于一;如果航班延迟的可能性小于准时,则几率比将小于一。因此,如果航班延迟的可能性更大,对数几率将是正的;如果航班准时的可能性更大,对数几率将是负的;如果延迟的可能性是 50-50,则对数几率为零。这如图 7.12 所示。

图 7.12. 将延迟航班的几率映射到对数几率

概率p的对数几率也称为logit(p)logit(p)的逆函数是sigmoid函数,如图 7.13 所示。sigmoid 函数将范围从-Infinity:Infinity的值映射到范围0:1——在这种情况下,sigmoid 函数将无界的对数几率比映射到介于 0 和 1 之间的概率值。

logit <- function(p) { log(p/(1-p)) }
s <- function(x) { 1/(1 + exp(-x))}

s(logit(0.7))
# [1] 0.7

logit(s(-2))
# -2

图 7.13. 通过 Sigmoid 函数将延迟航班的对数几率映射到概率

现在我们可以尝试将线性模型拟合到延迟航班的对数几率:

logit(P[flight_delayed[i] == TRUE]) = b0 + b_origin * origin[i] + ...

但我们真正感兴趣的是航班延迟的概率。为了得到这个概率,取等式两边的 sigmoid s()

P[flight_delayed[i] == TRUE] =  s(b0 + b_origin * origin[i] + ...)

这是航班延迟概率的逻辑回归模型。前面的推导可能看起来是临时的,但使用logit函数转换概率是已知具有许多有利特性的。例如,像线性回归一样,它正确地得到总数(正如我们将在 7.2.3 节中看到)。

更一般地,假设y[i]是对象i的类别:TRUEFALSEdelayedon_time。同时,假设x[i,]是一行输入,并将其中一个类别称为“感兴趣类别”或目标类别——即你试图预测的类别(你想要预测某物是否为TRUE或者航班是否属于delayed类别)。然后逻辑回归试图拟合一个函数f(x),使得

方程 7.2. 逻辑回归模型的表达式

如果y[i]x[i,]属于感兴趣类别的概率,那么拟合的任务就是找到a, b[1], ..., b[n],使得f(x[i,])y[i]的最佳可能估计。R 提供了一个单行语句来找到这些系数:glm()。^([1]) 注意,运行glm()不需要提供概率估计的y[i];训练方法只需要y[i]表明给定的训练示例属于哪个类别。

¹

逻辑回归可以用于分类到任意数量的类别(只要类别是互斥的并且覆盖所有可能性:每个x必须属于给定的类别之一)。但glm()只处理两类别的情况,因此我们的讨论将集中在这种情况。

正如我们所展示的,你可以将逻辑回归视为一种线性回归,它寻找你感兴趣概率的对数几率。特别是,逻辑回归假设 logit(y)x 的值上是线性的。与线性回归一样,逻辑回归将找到最佳系数来预测 y,包括当输入相关时找到有利组合和抵消。

现在来看主要示例。


示例

想象一下你在一所医院工作。总体目标是设计一个计划,为分娩室配备新生儿紧急设备。新生儿出生后一分钟和五分钟会使用所谓的阿普加测试进行评估,该测试旨在确定婴儿是否需要立即的紧急护理或额外的医疗关注。阿普加评分低于 7 分(0 到 10 分的评分标准)的婴儿需要额外的关注。

这类有风险的新生儿很少见,所以医院不想为每一次分娩都配备额外的紧急设备。另一方面,有风险的新生儿可能需要快速得到关注,因此主动为合适的分娩提供资源可以挽救生命。你的任务是构建一个模型,提前识别出风险概率较高的情形,以便能够适当地分配资源。


我们将使用 2010 年 CDC 出生公共使用数据文件中的样本数据集 (mng.bz/pnGy)。该数据集记录了在美国 50 个州和哥伦比亚特区的所有出生登记的统计数据,包括关于母亲和父亲以及分娩的事实。样本中有一个名为 sdata 的数据框,包含超过 26,000 次出生。^([2]) 数据被分为训练集和测试集,使用我们添加的随机分组列,这允许进行可重复的实验,并确定分割比例。

²

我们预先准备好的文件在 github.com/WinVector/PDSwR2/tree/master/CDC/NatalRiskData.rData;我们还提供了一个脚本文件 (github.com/WinVector/PDSwR2/blob/master/CDC/PrepNatalRiskData.R),该文件从完整的出生数据集的提取中准备数据框。详细信息请参阅 github.com/WinVector/PDSwR2/blob/master/CDC/README.md

列表 7.7. 加载 CDC 数据

load("NatalRiskData.rData")
train <- sdata[sdata$ORIGRANDGROUP <= 5 , ]
test <- sdata[sdata$ORIGRANDGROUP > 5, ]

表 7.1 列出了您将使用的数据集的列。因为目标是提前预测有风险的新生儿,所以我们将变量限制在那些在分娩前已知或在分娩期间可以确定的变量。例如,关于母亲体重和健康史的资料是有效的输入,但出生后的资料,如婴儿出生体重则不是。我们可以通过推理将分娩室内的并发症,如臀位分娩包括在内,因为模型可以在分娩前(通过协议或清单)及时更新,以便在分娩前分配紧急资源。

表 7.1. 出生数据集中的一些变量

Variable 类型 描述
atRisk 逻辑 如果 5 分钟 Apgar 评分小于 7 则为 TRUE;否则为 FALSE
PWGT 数字 母亲的孕前体重
UPREVIS 数字(整数) 预产期医疗访问次数
CIG_REC 逻辑 如果吸烟则为 TRUE;否则为 FALSE
GESTREC3 分类 两个类别:小于 37 周(早产)和大于等于 37 周
DPLURAL 分类 出生多胎,分为三类:单胎/双胎/三胎及以上
ULD_MECO 逻辑 如果羊水有中度/重度粪便污染则为 TRUE
ULD_PRECIP 逻辑 如果异常短产(小于三小时)则为 TRUE
ULD_BREECH 逻辑 臀位(骨盆首先)出生位置为 TRUE
URF_DIAB 逻辑 如果母亲是糖尿病患者则为 TRUE
URF_CHYPER 逻辑 如果母亲有慢性高血压则为 TRUE
URF_PHYPER 逻辑 如果母亲有与怀孕相关的妊娠高血压则为 TRUE
URF_ECLAM 逻辑 如果母亲经历了子痫:与怀孕相关的癫痫发作则为 TRUE

现在我们已经准备好构建模型。

7.2.2. 构建逻辑回归模型

在 R 中构建逻辑回归模型的函数是 glm(),由 stats 包提供。在我们的情况下,因变量 y 是逻辑(或布尔)的 atRisk;表 7.1 中的所有其他变量是自变量 x。使用这些变量预测 atRisk 的模型公式相当长,手动输入;您可以使用 wrapr 包中的 mk_formula() 函数生成公式,如下所示。

列表 7.8. 构建模型公式

complications <- c("ULD_MECO","ULD_PRECIP","ULD_BREECH")
riskfactors <- c("URF_DIAB", "URF_CHYPER", "URF_PHYPER",
                  "URF_ECLAM")

y <- "atRisk"
x <- c("PWGT",
      "UPREVIS",
      "CIG_REC",
      "GESTREC3",
      "DPLURAL",
      complications,
      riskfactors)
library(wrapr)
fmla <- mk_formula(y, x)

现在我们将构建逻辑回归模型,使用训练数据集。

列表 7.9. 拟合逻辑回归模型

print(fmla)

## atRisk ~ PWGT + UPREVIS + CIG_REC + GESTREC3 + DPLURAL + ULD_MECO +
##     ULD_PRECIP + ULD_BREECH + URF_DIAB + URF_CHYPER + URF_PHYPER +
##     URF_ECLAM
## <environment: base>

model <- glm(fmla, data = train, family = binomial(link = "logit"))

这与调用lm()的线性回归类似,但有一个额外的参数:family = binomial(link = "logit")family函数指定了因变量y的假设分布。在我们的例子中,我们将y建模为二项分布,或者是一个概率依赖于x的硬币。链接函数“链接”输出到线性模型——就好像您通过链接函数传递y,然后将其作为x值的线性函数建模。不同的family函数和链接函数的组合会导致不同类型的广义线性模型(例如,泊松或 probit)。在这本书中,我们只讨论逻辑模型,所以我们只需要使用带有logit链接的二项分布族。^([1])

¹

logit链接是二项分布族的默认链接,因此调用glm(fmla, data = train, family = binomial)是完全可以的。我们明确指定链接是为了讨论的目的。


不要忘记family参数!

没有显式的family参数,glm()默认为标准线性回归(如lm)。

可以使用family参数来选择glm()函数的许多不同行为。例如,选择family = quasipoisson会选择一个“log”链接,将预测的对数视为输入的线性模型。

这将是尝试解决第 7.1 节中的收入预测问题的另一种方法。然而,确定对数变换和线性模型或对数链接和广义线性模型对于给定问题来说哪个更好是一个微妙的问题。对数链接将更好地预测总收入(对于小收入和大收入都达到$50,000 的误差)。对数变换方法将更好地预测相对收入(对于大收入来说,$50,000 的评分误差不如小收入严重)。


如前所述,我们将结果存储在对象model中。

7.2.3. 做出预测

使用逻辑模型进行预测与使用线性模型进行预测类似——使用predict()函数。以下代码将训练集和测试集的预测结果存储为相应数据框中的pred列。

列表 7.10. 应用逻辑回归模型

train$pred <- predict(model, newdata=train, type = "response")
test$pred <- predict(model, newdata=test, type="response")

注意额外的参数type = "response"。这告诉predict()函数返回预测概率y。如果您不指定type = "response",则默认情况下predict()将返回link函数的输出,即logit(y)

逻辑回归的一个优点是它保留了训练数据的边缘概率。这意味着如果你对整个训练集的预测概率得分进行求和,这个数量将等于训练集中正结果(atRisk == TRUE)的数量。对于由模型中包含的变量确定的数据子集也是如此。例如,在训练数据子集中,train$GESTREC == "<37 weeks"(婴儿早产)的情况下,预测概率的总和等于正训练示例的数量(例如,参见mng.bz/j338)。

列表 7.11. 使用逻辑回归保留边缘概率

sum(train$atRisk == TRUE)                               ❶
 ## [1] 273

sum(train$pred)                                         ❷
 ## [1] 273

premature <- subset(train, GESTREC3 == "< 37 weeks")    ❸
sum(premature$atRisk == TRUE)
## [1] 112

sum(premature$pred)                                     ❹
 ## [1] 112

❶ 计算训练集中处于风险中的婴儿数量。

❷ 对训练集中的所有预测概率进行求和。请注意,它增加了处于风险中的婴儿的数量。

❸ 计算训练集中处于风险中的早产婴儿数量

❹ 对训练集中所有早产婴儿的预测概率进行求和。请注意,它增加了处于风险中的早产婴儿的数量。

由于逻辑回归保留了边缘概率,你知道模型在某种程度上与训练数据是一致的。当模型应用于与训练数据分布相似的未来数据时,它应该返回与该数据一致的结果:预期的处于风险中的婴儿的正确概率质量,正确地根据婴儿的特征分布。然而,如果模型应用于具有非常不同分布的未来数据(例如,处于风险中的婴儿的比率大大增加),模型可能无法预测得很好。

描述预测质量

如果你的目标是使用模型将新实例分类为两个类别之一(在这种情况下,处于风险或不在风险中),那么你希望模型对正实例给出高分数,否则给出低分数。正如我们在第 6.2.5 节中讨论的那样,你可以通过绘制正负实例得分的分布来检查这一点。让我们在训练集上这样做(你也应该绘制测试集,以确保性能质量相似)。

列表 7.12. 按已知结果分组绘制预测得分分布

library(WVPlots)
DoubleDensityPlot(train, "pred", "atRisk",
                  title = "Distribution of natality risk scores")

结果显示在图 7.14 中。理想情况下,我们希望得分的分布是分离的,负实例(FALSE)的得分集中在左侧,而正实例的分布集中在右侧。在图 6.15(此处重现为图 7.15)中,我们展示了一个分类器(垃圾邮件过滤器)的例子,它很好地将正负实例分开。使用出生风险模型,两个分布都集中在左侧,这意味着正负实例的得分都较低。这并不奇怪,因为正实例(有风险婴儿的实例)是罕见的(在数据集中占所有出生的约 1.8%)。负实例的得分分布比正实例的得分分布衰减得更快。这意味着模型确实在数据中识别出了子群体,其中高风险新生儿的比率高于平均水平,如图 7.14 所示。

图 7.14。按正例(TRUE)和负例(FALSE)分开的得分分布

图片

图 7.15。重绘第六章图 6.15 中的垃圾邮件过滤器得分分布

图片

为了将模型用作分类器,你必须选择一个阈值;得分高于阈值的将被分类为正,低于阈值的将被分类为负。当你选择一个阈值时,你试图平衡分类器的精确度(预测的正例中有多少是真正的正例)和其召回率(分类器找到多少真正的正例)。

如果正负实例的得分分布很好地分离,如图 7.15 所示,你可以在两个峰值之间的“山谷”中选取一个合适的阈值。在当前情况下,两个分布没有很好地分离,这表明模型不能构建一个同时实现良好召回率和良好精确度的分类器。

然而,你可能能够构建一个分类器,以识别具有高于平均风险出生率的子集:例如,你可能能够找到一个阈值,产生一个精确度为 3.6%的分类器。尽管这个精确度很低,但它代表了一个数据子集,其风险是整体人口的两倍(3.6%比 1.8%),因此为这些情况预先分配资源可能是明智的。我们将分类器精确度与平均正例率之比称为富集率

你设置的阈值越高,分类器的精确度就越高(你将识别出风险出生率远高于平均水平的情境集);但你也可能会错过更多风险情境。在挑选阈值时,你应该使用训练集,因为挑选阈值是分类器构建的一部分。然后你可以使用测试集来评估分类器性能。

为了帮助挑选阈值,你可以使用类似于图 7.16 的图表,该图表显示了富集度和召回率作为阈值的函数。

图 7.16. 训练集的富集度(顶部)和召回率(底部)作为阈值的函数

查看图 7.16,你会发现更高的阈值会导致更精确的分类(精确度与富集度成正比),但代价是错过更多案例;较低的阈值将识别更多案例,但代价是许多更多的误报(精确度降低)。最佳精度/富集度与召回率之间的权衡取决于医院可以分配的资源数量,以及他们可以保留在储备(或重新部署)以应对分类器未涵盖情况的数量。阈值 0.02(在图 7.16 中由虚线标记)可能是一个不错的权衡。结果分类器将识别出人口中风险出生率是总体人口 2.5 倍的一个子集,并且包含所有真正风险情况的一半左右。

你可以使用WVPlots中的PRTPlot()函数生成图 7.16。

列表 7.13. 探索建模权衡

library("WVPlots")
library("ggplot2")
plt <- PRTPlot(train, "pred", "atRisk", TRUE,                            ❶
         plotvars = c("enrichment", "recall"),
        thresholdrange = c(0,0.05),
        title = "Enrichment/recall vs. threshold for natality model")
plt + geom_vline(xintercept = 0.02, color="red", linetype = 2)           ❷

❶ 在 PRTPlot()中调用 pred 为预测列,atRisk 为真实结果列,TRUE 为感兴趣类别

❷ 添加一条线标记阈值=0.02。

一旦你挑选了一个合适的阈值,你可以通过查看混淆矩阵来评估结果分类器,正如我们在第 6.2.3 节中讨论的那样。让我们使用测试集来评估阈值为 0.02 的分类器。

列表 7.14. 评估所选模型

( ctab.test <- table(pred = test$pred > 0.02, atRisk = test$atRisk)  )   ❶

##        atRisk
## pred    FALSE TRUE
##   FALSE  9487   93
##   TRUE   2405  116

( precision <- ctab.test[2,2] / sum(ctab.test[2,]) )
## [1] 0.04601349

( recall <- ctab.test[2,2] / sum(ctab.test[,2]) )
## [1] 0.5550239

( enrichment <- precision / mean(as.numeric(test$atRisk))  )
## [1] 2.664159

❶ 构建混淆矩阵。行包含预测的负例和正例;列包含实际的负例和正例。

结果分类器精确度较低,但识别出包含测试集中 55.5%真正正例的潜在风险案例集,其比率是总体平均水平的 2.66 倍。这与训练集上的结果一致。

除了做出预测外,逻辑回归模型还能帮助你提取有用的信息和建议。我们将在下一节中展示这一点。

7.2.4. 从逻辑模型中寻找关系和提取建议

逻辑回归模型的系数以类似于线性回归模型系数的方式编码输入变量与输出之间的关系。你可以通过调用 coefficients (model) 来获取模型的系数。

列表 7.15。模型系数

coefficients(model)
##              (Intercept)                     PWGT
##              -4.41218940               0.00376166
##                  UPREVIS              CIG_RECTRUE
##              -0.06328943               0.31316930
##       GESTREC3< 37 weeks DPLURALtriplet or higher
##               1.54518311               1.39419294
##              DPLURALtwin             ULD_MECOTRUE
##               0.31231871               0.81842627
##           ULD_PRECIPTRUE           ULD_BREECHTRUE
##               0.19172008               0.74923672
##             URF_DIABTRUE           URF_CHYPERTRUE
##              -0.34646672               0.56002503
##           URF_PHYPERTRUE            URF_ECLAMTRUE
##               0.16159872               0.49806435

统计上显著的负系数对应于与正结果(婴儿处于风险)的概率负相关的变量。统计上显著的正系数与婴儿处于风险的概率正相关。

¹

我们将在下一节中展示如何检查统计显著性。

就像线性回归一样,每个分类变量都会扩展成一组指示变量。如果原始变量有 n 个级别,将会有 n-1 个指示变量;剩余的级别是参考水平。

例如,变量 DPLURAL 有三个级别,分别对应单胎、双胞胎和三胞胎或更高。逻辑回归模型有两个相应的系数:DPLURALtwinDPLURALtriplet or higher。参考水平是单胎。DPLURAL 的两个系数都是正的,表明多胎出生的风险概率比单胎出生高,其他变量保持不变。


逻辑回归也不喜欢变量数量非常多

就像线性回归一样,你应该避免使用具有太多级别的分类变量。


解释系数

与线性回归相比,解释系数值要复杂一些。如果变量 x[,k] 的系数是 b[k],那么对于 x[,k] 每单位的变化,正结果的概率会乘以 exp(b[k]) 的因子。


示例

假设一个具有某些特征的足月婴儿有 1% 的风险概率。那么这个婴儿的风险概率是 p/(1-p),即 0.01/0.99 = 0.0101。那么具有相同特征但早产儿的婴儿的风险概率(以及风险概率)是多少?


对于 GESTREC3< 37 weeks(早产儿)的系数是 1.545183。因此,对于一个早产儿,其处于风险的概率是 exp(1.545183)= 4.68883 倍高于足月出生的婴儿,其他输入变量保持不变。具有与假设足月婴儿相同特征的早产儿的危险概率是 0.0101 * 4.68883 = 0.047

你可以将公式 odds = p / (1 - p) 逆转来求解 p 作为 odds 的函数:

p = odds * (1 - p) = odds - p * odds
p * (1 + odds) = odds
p = odds/(1 + odds)

这个早产儿处于风险的概率是 0.047/1.047,大约是 4.5%——比等效的足月婴儿高得多

同样,UPREVIS(产前医疗访问次数)的系数大约是-0.06。这意味着每次产前访问都会将高风险婴儿的概率降低到exp(-0.06)倍,即大约 0.94。假设一个早产儿的母亲没有进行任何产前访问;在相同情况下,如果母亲进行了三次产前访问,那么婴儿处于风险的概率大约是0.047 * 0.94 * 0.94 * 0.94 = 0.039。这对应于 3.75%的风险概率。

在这种情况下的一般建议可能是特别关注早产(和多胞胎),并鼓励孕妇进行定期的产前访问。

7.2.5。阅读模型摘要和描述系数

正如我们之前提到的,关于系数值的结论只有在系数值具有统计学意义时才应予以信任。我们还想确保模型实际上在解释某些东西。模型摘要中的诊断将帮助我们确定有关模型质量的一些事实。调用,如前所述,是summary(model)

列表 7.16。模型摘要

summary(model)

## Call:
## glm(formula = fmla, family = binomial(link = "logit"), data = train)
##
## Deviance Residuals:
##     Min       1Q   Median       3Q      Max
## -0.9732  -0.1818  -0.1511  -0.1358   3.2641
##
## Coefficients:
##                           Estimate Std. Error z value Pr(>|z|)
## (Intercept)              -4.412189   0.289352 -15.249  < 2e-16 ***
## PWGT                      0.003762   0.001487   2.530 0.011417 *
## UPREVIS                  -0.063289   0.015252  -4.150 3.33e-05 ***
## CIG_RECTRUE               0.313169   0.187230   1.673 0.094398 .
## GESTREC3< 37 weeks        1.545183   0.140795  10.975  < 2e-16 ***
## DPLURALtriplet or higher  1.394193   0.498866   2.795 0.005194 **
## DPLURALtwin               0.312319   0.241088   1.295 0.195163
## ULD_MECOTRUE              0.818426   0.235798   3.471 0.000519 ***
## ULD_PRECIPTRUE            0.191720   0.357680   0.536 0.591951
## ULD_BREECHTRUE            0.749237   0.178129   4.206 2.60e-05 ***
## URF_DIABTRUE             -0.346467   0.287514  -1.205 0.228187
## URF_CHYPERTRUE            0.560025   0.389678   1.437 0.150676
## URF_PHYPERTRUE            0.161599   0.250003   0.646 0.518029
## URF_ECLAMTRUE             0.498064   0.776948   0.641 0.521489
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
##    Null deviance: 2698.7  on 14211  degrees of freedom
## Residual deviance: 2463.0  on 14198  degrees of freedom
## AIC: 2491
##
## Number of Fisher Scoring iterations: 7

再次强调,当你展示结果时,可能会被问到模型摘要中的元素,所以我们将讨论这些字段的意义,以及如何使用它们来解释你的模型。

原始模型调用

摘要的第一行是glm()的调用:

Call:
glm(formula = fmla, family = binomial(link = "logit"), data = train)

这里我们检查是否使用了正确的训练集和正确的公式(尽管在我们的情况下,公式本身在另一个变量中)。我们还可以验证是否使用了正确的家族和连接函数来生成逻辑模型。

偏差残差摘要

偏差残差是线性回归模型残差的类似物:

Deviance Residuals:
    Min       1Q   Median       3Q      Max
-0.9732  -0.1818  -0.1511  -0.1358   3.2641

线性回归模型是通过最小化残差的平方和来找到的;逻辑回归模型是通过最小化残差偏差的总和来找到的,这相当于最大化给定模型的数据的对数似然,我们将在本章后面讨论对数似然。

逻辑模型也可以用来明确地计算比率:给定几个相同数据点(除了结果外都相同)的组,预测每个组中阳性结果的比率。这种数据称为分组数据。在分组数据的情况下,偏差残差可以用作模型拟合的诊断。这就是为什么偏差残差包括在摘要中的原因。我们正在使用未分组数据——训练集中的每个数据点可能是唯一的。在未分组数据的情况下,使用偏差残差进行模型拟合诊断不再有效,所以我们在这里不讨论它们.^([1])

¹

见 Daniel Powers 和 Yu Xie 所著的《分类数据分析的统计方法》,第 2 版,Emerald Group Publishing Ltd.,2008 年。

摘要系数表

逻辑回归的摘要系数表与线性回归的系数表具有相同的格式:

Coefficients:
                          Estimate Std. Error z value Pr(>|z|)
(Intercept)              -4.412189   0.289352 -15.249  < 2e-16 ***
PWGT                      0.003762   0.001487   2.530 0.011417 *
UPREVIS                  -0.063289   0.015252  -4.150 3.33e-05 ***
CIG_RECTRUE               0.313169   0.187230   1.673 0.094398 .
GESTREC3< 37 weeks        1.545183   0.140795  10.975  < 2e-16 ***
DPLURALtriplet or higher  1.394193   0.498866   2.795 0.005194 **
DPLURALtwin               0.312319   0.241088   1.295 0.195163
ULD_MECOTRUE              0.818426   0.235798   3.471 0.000519 ***
ULD_PRECIPTRUE            0.191720   0.357680   0.536 0.591951
ULD_BREECHTRUE            0.749237   0.178129   4.206 2.60e-05 ***
URF_DIABTRUE             -0.346467   0.287514  -1.205 0.228187
URF_CHYPERTRUE            0.560025   0.389678   1.437 0.150676
URF_PHYPERTRUE            0.161599   0.250003   0.646 0.518029
URF_ECLAMTRUE             0.498064   0.776948   0.641 0.521489
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

表格的列表示

  • 一个系数

  • 其估计值

  • 该估计值周围的误差

  • 估计系数值与 0 的符号距离(使用标准误差作为距离的单位)

  • 在假设系数值实际上为零的零假设下,观察到至少与我们观察到的系数值一样大的概率

这个最后的值,称为p 值显著性,告诉我们是否应该相信估计的系数值。常见的做法是假设 p 值小于 0.05 的系数是可靠的,尽管一些研究人员更喜欢更严格的阈值。

对于出生数据,我们可以从系数摘要中看到,早产和三胞胎出生是新生儿需要额外医疗关注的有力预测因素:系数的绝对值不可忽视,p 值表明其显著性。其他影响结果的因素还包括

  • PWGT—母亲的孕前体重(体重较重的母亲表示风险更高——有点令人惊讶)

  • UPREVIS—产前医疗访问次数(访问次数越多,风险越低)

  • ULD_MECOTRUE—羊水中的胎粪污染

  • ULD_BREECHTRUE—出生时的臀位

母亲吸烟与高风险出生之间可能存在正相关,但数据并没有明确表明这一点。其他变量与高风险出生之间没有显示出强烈的关系。


不显著可能意味着共线性输入

与线性回归一样,逻辑回归在处理共线性(或相关)输入时也能很好地进行预测,但相关性可能会掩盖有用的建议。

为了让您自己看到这一点,我们在数据集sdata中留下了关于婴儿出生体重的克数数据。它在测试数据和训练数据中都作为DBWT列存在。尝试将DBWT添加到除了所有其他变量之外的逻辑回归模型中;您会发现婴儿出生体重的系数将是显著的、不可忽视的(对预测有实质性影响),并且与风险呈负相关。DPLURALtriplet or higher的系数将显得不显著,而GESTREC3< 37 weeks的系数的绝对值要小得多。这是因为低出生体重与早产和多胞胎都有关联。在这三个相关变量中,出生体重是结果的最佳单一预测因素:知道婴儿是三胞胎不会增加额外的有用信息,而知道婴儿早产只会增加一点信息。

在建模目标——主动分配紧急资源到更有可能需要的地点——的背景下,出生体重不是一个非常有用的变量,因为我们不知道婴儿出生时的体重。我们事先知道它是否早产,或者是否是多个婴儿中的一个。因此,最好使用GESTREC3DPLURAL作为输入变量,而不是DBWT

其他可能存在共线性输入的迹象是系数符号错误和异常大的系数幅度,伴随着巨大的标准误差。


模型整体质量摘要

摘要的下一部分包含模型质量统计信息:

Null deviance: 2698.7  on 14211  degrees of freedom
Residual deviance: 2463.0  on 14198  degrees of freedom
AIC: 2491

零偏差和残差偏差

偏差是衡量模型拟合数据好坏的度量。它是数据集给定模型的负对数似然的两倍。正如我们之前在第 6.2.5 节中讨论的,对数似然背后的思想是,正实例y应该在模型下有高概率py发生;负实例应该有低概率发生(或者换句话说,(1 - py)应该是大的)。对数似然函数奖励结果y与预测概率py之间的匹配,并惩罚不匹配(负实例的高py,反之亦然)。

如果你认为偏差类似于方差,那么零偏差类似于围绕正例平均率的平均数据方差。残差偏差类似于围绕模型的数据方差。与方差一样,你希望残差偏差相对于零偏差较小。模型摘要报告了训练数据上的偏差和零偏差;你(并且应该)也可以为测试数据计算它们。在下面的列表中,我们计算了训练集和测试集的偏差。

列表 7.17. 计算偏差

loglikelihood <- function(y, py) {                                     ❶
   sum(y * log(py) + (1-y)*log(1 - py))
}

(pnull <- mean(as.numeric(train$atRisk))  )                            ❷
## [1] 0.01920912

(null.dev <- -2  *loglikelihood(as.numeric(train$atRisk), pnull) )     ❸
## [1] 2698.716

model$null.deviance                                                    ❹
## [1] 2698.716

pred <- predict(model, newdata = train, type = "response")             ❺
(resid.dev <- -2 * loglikelihood(as.numeric(train$atRisk), pred) )     ❻
## [1] 2462.992

model$deviance                                                         ❻
## [1] 2462.992

testy <- as.numeric(test$atRisk)                                       ❽
testpred <- predict(model, newdata = test,
                        type = "response")

( pnull.test <- mean(testy) )
## [1] 0.0172713

( null.dev.test <- -2 * loglikelihood(testy, pnull.test) )
## [1] 2110.91

( resid.dev.test <- -2 * loglikelihood(testy, testpred) )
## [1] 1947.094

❶ 计算数据集对数似然的函数。变量 y 是数值形式的结果(正例为 1,负例为 0)。变量 py 是 y 等于 1 的预测概率。

❷ 计算数据集中正例的比率

❸ 计算零偏差

❹ 对于训练数据,零偏差存储在 model$null.deviance 槽中。

❺ 为训练数据预测概率

❻ 计算训练数据的模型偏差

❻ 对于训练数据,模型偏差存储在 model$deviance 槽中。

❽ 计算测试数据的零偏差和残差偏差

伪 R 平方

基于偏差的一个有用的拟合优度度量是伪 R 平方:1 - (dev.model/dev.null)。伪 R 平方是线性回归中 R 平方的类似物。它是衡量模型“解释”了多少偏差的度量。理想情况下,你希望伪 R 平方接近 1。让我们计算测试数据和训练数据的伪 R 平方。

列表 7.18. 计算伪 R 平方

pr2 <- 1 - (resid.dev / null.dev)

print(pr2)
## [1] 0.08734674
pr2.test <- 1 - (resid.dev.test / null.dev.test)
print(pr2.test)
## [1] 0.07760427

该模型仅解释了约 7.7-8.7%的偏差;它不是一个高度预测性的模型(你早已应该从图 7.14 中怀疑这一点)。这告诉我们,我们尚未识别出所有实际预测风险出生的因素。

模型显著性

您还可以使用空模型和残差偏差来检查模型的概率预测是否比仅仅猜测平均阳性率更好,从统计学的角度来看。换句话说,模型偏差的减少是否具有意义,或者只是偶然观察到的?这与计算线性回归中报告的 F 检验统计量和相关 p 值类似。在逻辑回归的情况下,您将运行的测试是卡方检验。为此,您需要知道空模型和实际模型的自由度(这些在总结中报告)。空模型的自由度是数据点数量减去 1:

df.null =  dim(train)[[1]] - 1

您拟合的模型自由度是数据点数量减去模型中的系数数量:

df.model = dim(train)[[1]] - length(model$coefficients)

如果训练集中数据点的数量很大,且df.null - df.model很小,那么偏差差异null.dev - resid.dev与我们观察到的差异一样大的概率大约服从具有df.null - df.model个自由度的卡方分布。

列表 7.19. 计算观察到的拟合的显著性

( df.null <- dim(train)[[1]] - 1  )                               ❶
 ## [1] 14211

( df.model <- dim(train)[[1]] - length(model$coefficients) )      ❷
 ## [1] 14198

( delDev <- null.dev - resid.dev )                                ❸
 ## [1] 235.724
( deldf <- df.null - df.model )
## [1] 13
( p <- pchisq(delDev, deldf, lower.tail = FALSE) )                ❹
 ## [1] 5.84896e-43

❶ 空模型具有(数据点数量 - 1)个自由度。

❷ 拟合模型具有(数据点数量 - 系数数量)个自由度。

❸ 计算偏差差异和自由度差异

❹ 使用卡方分布估计在空模型下观察到偏差差异的概率(p 值)

p 值非常小;我们通过偶然看到如此多的偏差减少的可能性极低。这意味着这个模型在数据中找到信息性模式的可能性是合理的(但遗憾的是不是决定性的)。


拟合优度与显著性

值得注意的是,我们找到的模型是一个显著的模型,但不是一个强大的模型。良好的 p 值告诉我们,该模型是显著的:它在训练数据中预测有风险出生的质量,这种质量不太可能是纯粹的偶然。较差的伪 R 平方意味着该模型没有给我们提供足够的信息来有效地区分低风险和高风险出生。

也可能存在良好的伪 R 平方(在训练数据上),但 p 值较差。这是过拟合的迹象。这就是为什么检查两者都是好主意,或者更好的是,检查模型在训练和测试数据上的伪 R 平方。


AIC

概述部分给出的最后一个指标是 AIC,或赤池信息准则。AIC 是调整了系数数量的对数似然。正如线性回归的 R 平方在变量数量较高时通常较高一样,对数似然也随着变量数量的增加而增加。

列表 7.20. 计算赤池信息准则

aic <- 2 * (length(model$coefficients) -
         loglikelihood(as.numeric(train$atRisk), pred))
aic
## [1] 2490.992

AIC 通常用于决定在模型中使用哪些输入变量以及使用多少。如果你在同一个训练集上使用不同的变量集训练了许多不同的模型,你可以考虑具有最低 AIC 的模型为最佳拟合。

费舍尔评分迭代

模型摘要的最后一行是费舍尔评分迭代的次数:

Number of Fisher Scoring iterations: 7

费舍尔评分法是一种迭代优化方法,类似于牛顿法,glm() 使用它来找到逻辑回归模型的最佳系数。你应该期望它在大约六到八次迭代内收敛。如果迭代次数比这多得多,那么算法可能没有收敛,模型可能无效。

分离和准分离

非收敛的可能原因是分离或准分离:模型中的一个变量或一些模型变量的组合至少对于训练数据的一个子集完美预测了结果。你可能会认为这是一件好事;但讽刺的是,当变量过于强大时,逻辑回归会失败。理想情况下,glm() 在检测到分离或准分离时会发出警告:

Warning message:
glm.fit: fitted probabilities numerically 0 or 1 occurred

不幸的是,有些情况下似乎没有发出警告,但仍有其他警告信号:

  • 费舍尔迭代次数异常高

  • 非常大的系数,通常伴随着非常大的标准误差

  • 剩余偏差大于零偏差

如果你看到这些迹象中的任何一种,那么模型是可疑的。本章的最后一节介绍了一种解决该问题的方法:正则化。

7.2.6. 逻辑回归要点

逻辑回归是二元分类的统计建模方法中的首选。与线性回归一样,逻辑回归模型的系数通常可以充当建议。以下是关于逻辑回归的一些要点:

  • 逻辑回归校准良好:它再现了数据的边缘概率。

  • 伪 R 平方是一个有用的拟合优度启发式方法。

  • 逻辑回归在处理具有大量变量或具有大量级别的分类变量的问题时可能会遇到困难。

  • 即使存在相关变量,逻辑回归也能很好地预测,但相关变量会降低建议的质量。

  • 过大的系数幅度,系数估计的标准误差过大,以及系数的符号错误可能是相关输入的迹象。

  • 过多的费舍尔迭代,或过大的系数与非常大的标准误差可能是你的逻辑回归模型没有收敛,可能无效的迹象。

  • glm() 提供了良好的诊断,但仍然需要在测试数据上重新检查你的模型,这是你最有效的诊断方法。

7.3. 正则化

如前所述,过大的系数绝对值和过大的标准误差可能表明你的模型存在一些问题:线性或逻辑回归中的几乎共线变量,或者在逻辑回归系统中存在分离或准分离。

几乎共线的变量可能导致回归求解器不必要地引入大的系数,这些系数通常几乎相互抵消,并且具有大的标准误差。分离/准分离可能导致逻辑回归无法收敛到预期的解;这是大系数和大标准误差的另一个来源。

过大的系数绝对值不太可靠,当模型应用于新数据时可能会带来风险。每个系数估计都存在一些测量噪声,而大的系数会导致估计中的噪声驱动预测中的大变化(和错误)。直观地说,拟合几乎共线的变量的大的系数必须在训练数据中相互抵消,以表达变量对结果的影响。如果未来的数据中相同的变量没有以完全相同的方式平衡,这种抵消集合就是对训练数据的过度拟合。


示例

假设 age years_in_workforce 高度相关,并且年龄增长一年/在劳动力市场工作一年更长,在训练数据中会使对数收入增加一个单位。如果模型中只有 years_in_workforce ,它将得到大约 1 的系数。如果模型包括 age 又会发生什么?


在某些情况下,如果模型中同时包含ageyears_in_workforce,线性回归可能会给years_in_workforceage分配大的、符号相反的平衡系数;例如,years_in_workforce的系数为 99,而age的系数为-98。这些大的系数将“相互抵消”以达到适当的效果。

由于准分离,即使没有共线变量,逻辑模型也可能产生类似的效果。为了演示这一点,我们将引入本节中将处理的更大场景。

7.3.1. 准分离的一个例子


示例

假设一个汽车评论网站根据几个特征对汽车进行评级,包括可负担性和安全评级。汽车评级可以是“非常好”、“好”、“可接受”或“不可接受”。你的目标是预测汽车是否会失败审查:也就是说,得到一个不可接受的评级。


对于这个示例,你将再次使用 UCI 机器学习仓库中你在第二章中使用过的汽车数据。这个数据集包含 1728 种汽车的信息,以下变量:

  • car_price—(非常高,高,中,低)

  • maint_price—(非常高,高,中,低)

  • doors—(2,3,4,5,更多)

  • persons—(2,4,更多)

  • lug_boot—(小,中,大)

  • safety—(低,中,高)

结果变量是rating(vgood,good,acc,unacc)。

首先,让我们读取数据并将其分割为训练集和测试集。如果您还没有这样做,请从 github.com/WinVector/PDSwR2/blob/master/UCICar/car.data.csv 下载 car.data.csv 文件,并确保该文件位于您的当前工作目录中。

列表 7.21. 准备 cars 数据

cars <- read.table(
  'car.data.csv',
  sep = ',',
  header = TRUE,
  stringsAsFactor = TRUE
)

vars <- setdiff(colnames(cars), "rating")              ❶

cars$fail <- cars$rating == "unacc"
outcome <- "fail"                                      ❷

set.seed(24351)
gp <- runif(nrow(cars))                                ❸

library("zeallot")
c(cars_test, cars_train) %<-% split(cars, gp < 0.7)    ❹

nrow(cars_test)
## [1] 499
nrow(cars_train)
## [1] 1229

❶ 获取输入变量

❷ 你想要预测汽车是否会获得不可接受的评级

❸ 为测试/训练分割创建分组变量(70%用于训练,30%用于测试)

split() 函数返回一个包含两个组的列表,其中 gp < 0.7 == FALSE 的组排在前面。zeallot 包的 %<-% 多重赋值操作将这个值列表解包到名为 cars_testcars_train 的变量中。

解决这个问题的第一个可能方法是尝试简单的逻辑回归。

列表 7.22. 拟合逻辑回归模型

library(wrapr)
(fmla <- mk_formula(outcome, vars) )

## fail ~ car_price + maint_price + doors + persons + lug_boot +
##     safety
## <environment: base>

model_glm <- glm(fmla,
            data = cars_train,
            family = binomial)

您将看到 glm() 返回一个警告:

## Warning: glm.fit: fitted probabilities numerically 0 or 1 occurred

这个警告表明问题是准可分的:一些变量集合可以完美预测数据的一个子集。实际上,这个问题足够简单,您可以很容易地确定低安全评级可以完美预测汽车将无法通过评审(我们将这作为读者的练习)。然而,即使安全评级较高的汽车也可能获得不可接受的评级,因此安全变量只能预测数据的一个子集。

您还可以通过查看模型摘要来看到这个问题。

列表 7.23. 查看模型摘要

summary(model_glm)

##
## Call:
## glm(formula = fmla, family = binomial, data = cars_train)
##
## Deviance Residuals:
##      Min        1Q    Median        3Q       Max
## -2.35684  -0.02593   0.00000   0.00001   3.11185
##
## Coefficients:
##                   Estimate Std. Error z value Pr(>|z|)
## (Intercept)        28.0132  1506.0310   0.019 0.985160
## car_pricelow       -4.6616     0.6520  -7.150 8.67e-13 ***
## car_pricemed       -3.8689     0.5945  -6.508 7.63e-11 ***
## car_pricevhigh      1.9139     0.4318   4.433 9.30e-06 ***
## maint_pricelow     -3.2542     0.5423  -6.001 1.96e-09 ***
## maint_pricemed     -3.2458     0.5503  -5.899 3.66e-09 ***
## maint_pricevhigh    2.8556     0.4865   5.869 4.38e-09 ***
## doors3             -1.4281     0.4638  -3.079 0.002077 **
## doors4             -2.3733     0.4973  -4.773 1.82e-06 ***
## doors5more         -2.2652     0.5090  -4.450 8.58e-06 ***
## persons4          -29.8240  1506.0310  -0.020 0.984201          ❶
## personsmore       -29.4551  1506.0310  -0.020 0.984396
## lug_bootmed         1.5608     0.4529   3.446 0.000568 ***
## lug_bootsmall       4.5238     0.5721   7.908 2.62e-15 ***
## safetylow          29.9415  1569.3789   0.019 0.984778          ❷
## safetymed           2.7884     0.4134   6.745 1.53e-11 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
##     Null deviance: 1484.7  on 1228  degrees of freedom
## Residual deviance:  245.5  on 1213  degrees of freedom
## AIC: 277.5
##
## Number of Fisher Scoring iterations: 21)                         ❸

❶ 变量 persons4personsmore 具有显著的大负幅度和巨大的标准误差。

❷ 变量 safetylow 具有显著的大正幅度和巨大的标准误差。

❸ 算法运行了异常大量的 Fisher 评分迭代。

变量 safetylowpersons4personsmore 都具有异常高的幅度和非常高的标准误差。如前所述,safetylow 总是与不可接受的评级相对应,因此 safetylow 是评审失败的一个强烈指标。然而,更大的汽车(能容纳更多人的汽车)并不总是能通过评审。可能算法观察到更大的汽车往往更安全(获得比 safetylow 更好的安全评级),因此它使用 persons4personsmore 变量来抵消 safetylow 过高的系数。

此外,您还可以看到 Fisher 评分迭代次数异常高;算法没有收敛。

这个问题相当简单,因此模型可能在测试集上预测得相当好;然而,一般来说,当您看到 glm() 没有收敛的证据时,您不应该相信这个模型。

为了与正则化算法进行比较,让我们绘制逻辑回归模型的系数(图 7.17)。

图 7.17. 逻辑回归模型的系数

图像 7.17

列表 7.24。查看逻辑模型的系数

coefs <- coef(model_glm)[-1]                      ❶
coef_frame <- data.frame(coef = names(coefs),
                        value = coefs)

library(ggplot2)
ggplot(coef_frame, aes(x = coef, y = value)) +
  geom_pointrange(aes(ymin = 0, ymax = value)) +
  ggtitle("Coefficients of logistic regression model") +
  coord_flip()

❶ 获取系数(除了截距)

在图中,指向右的系数与未通过审查正相关,而指向左的系数与失败负相关。

你也可以查看模型在测试数据上的表现。

列表 7.25。逻辑模型的测试性能

cars_test$pred_glm <- predict(model_glm,
                             newdata=cars_test,
                             type = "response")                          ❶

library(sigr)                                                            ❷

confmat <- function(dframe, predvar) {                                   ❸
   cmat <- table(truth = ifelse(dframe$fail, "unacceptable", "passed"),
               prediction = ifelse(dframe[[predvar]] > 0.5,
                                   "unacceptable", "passed"))
  accuracy <- sum(diag(cmat)) / sum(cmat)
  deviance <- calcDeviance(dframe[[predvar]], dframe$fail)
  list(confusion_matrix = cmat,
       accuracy = accuracy,
       deviance = deviance)
}

confmat(cars_test, "pred_glm")
## $confusion_matrix
##               prediction
## truth          passed unacceptable
##   passed          150            9
##   unacceptable     17          323
##
## $accuracy
## [1] 0.9478958
##
## $deviance
## [1] 97.14902

❶ 获取模型在测试集上的预测

❷ 添加 sigr 包以进行偏差计算(sigr 包含多个拟合优度摘要和测试)

❸ 打印混淆矩阵、准确率和偏差的便利函数

在这种情况下,模型看起来是好的。然而,你并不能总是信任非收敛模型,或者那些系数过大的模型。

在你看到系数异常大且标准误差极端大的情况下,无论是由于共线性还是准分离,我们建议使用正则化。^([1)] 正则化向公式中添加了一个惩罚,使模型的系数偏向于零。这使得求解器难以将系数驱动到不必要的过大值。

¹

有些人建议使用主成分回归(PCR)来处理共线性变量:PCR 使用现有变量创建相互正交的合成变量,消除共线性。这不会帮助解决准分离问题。我们通常更喜欢正则化。


关于过拟合

建模的目标是在未来的应用数据上做出良好的预测。提高你在训练数据上的测量性能并不总是能达到这一点。这就是我们一直在讨论的过拟合问题。正则化会降低训练数据拟合的质量,以期提高未来模型的表现。


7.3.2。正则化回归的类型

存在多种类型的正则化回归,每种类型都由对模型系数施加的惩罚定义。在这里,我们涵盖了不同的正则化方法。

岭回归

岭回归(或 L2-正则化回归)试图在最小化训练预测误差的同时,也最小化系数的平方幅度之和。^([2)] 让我们看看岭回归正则化线性回归。记住,线性回归试图找到系数 b,使得

²

这被称为“系数向量的 L2 范数”,因此得名。

f(x[i,]) = b[0] + b[1] x[i,1] + ... b[n] x[i,n]

对于所有训练数据,预测值尽可能接近 y[i]。它是通过最小化 (y - f(x))²,即 yf(x) 之间平方误差的和来做到这一点的。岭回归试图找到使 b 最小的值

(y - f(x))² + lambda * (b[1]² + ...  + b[n]²)

其中 lambda >= 0。当 lambda = 0 时,这会退化为常规线性回归;lambda 越大,算法对大系数的惩罚就越严格。正则化逻辑回归的表达式类似。


岭回归对系数的影响

当变量几乎完全线性相关时,岭回归往往会将线性相关的变量平均在一起。你可以将其视为“岭回归共享信用。”

例如,让我们回到使用年龄和工作年限(这两个变量几乎完全线性相关)来拟合对数收入的线性回归的例子。回想一下,在训练数据中,年龄增加一年或工作年限增加一年会使对数收入增加一个单位。

在这种情况下,岭回归可能会将变量ageyears_in_workforce的系数都分配为 0.5,这加起来是适当的效果。


Lasso 回归

Lasso 回归(或 L1 正则化回归)试图在最小化训练预测误差的同时,也最小化系数绝对值的总和。[¹] 对于线性回归,这看起来就像是最小化

¹

或者“系数向量的 L1 范数。”

(y - f(x))² + lambda * ( abs(b[1]) + abs(b[2]) + .... abs(b[n]) )

lasso 回归如何影响系数

当变量几乎完全线性相关时,lasso 回归往往会将其中一个或多个变量驱动到零。因此,在收入场景中,lasso 回归可能会将years_in_workforce的系数分配为 1,而将age的系数分配为 0。[^a] 因此,lasso 回归通常被用作一种变量选择方法。较大的 lambda 值往往会将更多系数驱动到零。

^a

如 Hastie 等人所指出的,在《统计学习的要素》第 2 版(Springer,2009 年),哪些相关变量被置零是有些任意的。


Elastic net

在某些情况下,如准分离性,岭回归的解可能更受欢迎。在其他情况下,例如当你有大量变量,其中许多变量相互关联时,lasso 可能更受欢迎。你可能不确定哪种方法最好,所以一种折衷的方法是将两者结合起来。这被称为弹性网络。使用弹性网络的惩罚是岭回归和 lasso 惩罚的组合:

(1 - alpha) * (b[1]² + ...  + b[n]²) +
    alpha * ( abs(b[1]) + abs(b[2]) + .... abs(b[n]) )

alpha = 0 时,这会降低到岭回归;当alpha = 1 时,它降低到 lasso。在 0 和 1 之间的不同alpha值给出了在共享相关变量之间的信用和仅保留它们的一个子集之间的不同权衡。

7.3.3. 使用 glmnet 的正则化回归

我们所讨论的所有正则化回归类型都由 R 中的glmnet包实现。不幸的是,glmnet包使用了一个不太像 R 的调用接口;特别是,它期望输入数据是一个数值矩阵而不是数据框。因此,我们将使用glmnetUtils包来为函数提供更 R 式的接口。


调用接口

如果所有建模过程都有相同的调用接口,那就最好不过了。lm()glm()包几乎做到了,而glmnetUtils帮助使glmnet更符合 R 的调用接口约定。

然而,要正确使用某种方法,你必须了解一些关于其特定约束和后果的事情。这意味着即使所有建模方法都有相同的调用接口,你仍然必须研究文档来了解如何正确使用它。


让我们比较一下不同的正则化方法在汽车评分预测问题上的应用。

岭回归的解决方案

当减少变量数量不是问题的时候,我们通常首先尝试岭回归,因为它是一种更平滑的正则化,我们觉得它保留了系数的最大可解释性(但请参阅本节后面的警告)。参数 alpha 指定了岭回归和 Lasso 惩罚的混合(0=岭回归,1=Lasso);因此对于岭回归,设置 alpha = 0。参数 lambda 是正则化惩罚。

由于你通常不知道最佳的 lambda 值,原始函数 glmnet:: glmnet() 会尝试几个 lambda 值(默认为 100)并返回对应每个值的模型。glmnet::cv.glmnet() 函数除了执行交叉验证以选择固定 alpha 的最小交叉验证误差的 lambda 值,并将其作为 lambda.min 字段返回外,还返回一个值 lambda.1se,这是使误差在最小值 1 个标准误差范围内的 lambda 的最大值。这如图 7.18 所示。

图 7.18. cv.glmnet() 的示意图

函数 glmnetUtils::cv.glmnet() 允许你以更友好的 R 方式调用交叉验证版本。

当使用正则化回归时,标准化数据(或中心化和缩放数据)(见 4.2.2 节)是一个好主意。幸运的是,cv.glmnet() 默认就会这样做。如果出于某种原因你想关闭这个功能(可能你已经标准化了数据),使用参数 standardize = FALSE。^([1])

¹

关于 glmnetUtils::cv.glmnet() 的帮助/文档,请参阅 help(cv.glmnet, package = "glmnet-Utils")help(cv.glmnet, package = "glmnet")help(glmnet, package = "glmnet")

列表 7.26. 拟合岭回归模型

library(glmnet)
library(glmnetUtils)

(model_ridge <- cv.glmnet(fmla,
                         cars_train,
                         alpha = 0,
                         family = "binomial"))      ❶

## Call:
## cv.glmnet.formula(formula = fmla, data = cars_train, alpha = 0,
##     family = "binomial")
##
## Model fitting options:
##     Sparse model matrix: FALSE
##     Use model.frame: FALSE
##     Number of crossvalidation folds: 10
##     Alpha: 0
##     Deviance-minimizing lambda: 0.02272432  (+1 SE): 0.02493991

❶ 对于逻辑回归风格的模型,使用 family = “binomial”。对于线性回归风格的模型,使用 family = “gaussian”。

打印出 model_ridge 会告诉你对应于最小交叉验证误差(偏差)的 lambda 值,即 model_ridge$lambda.min。它还报告了 model_ridge$lambda.1se 的值。

记住,cv.glmnet() 默认返回 100 个模型;当然,你实际上只需要一个——“最佳”模型。如图 7.18 所示,当你调用 predict()coef() 等函数时,cv.glmnet 对象默认使用对应于 lambda.1se 的模型,因为有些人认为 lambda.1se 比最小值 lambda.min 更不可能过拟合。

以下列表检查lambda.1se模型的系数。如果您想看到对应于lambda.min的模型,将列表中的第一行替换为(coefs <- coef(model_ridge, s = model_ridge$lambda.min))

列表 7.27. 查看岭模型的系数

(coefs <- coef(model_ridge))

## 22 x 1 sparse Matrix of class "dgCMatrix"
##                            1
## (Intercept)       2.01098708
## car_pricehigh     0.34564041
## car_pricelow     -0.76418240
## car_pricemed     -0.62791346
## car_pricevhigh    1.05949870
## maint_pricehigh   0.18896383
## maint_pricelow   -0.72148497
## maint_pricemed   -0.60000546
## maint_pricevhigh  1.14059599
## doors2            0.37594292
## doors3            0.01067978
## doors4           -0.21546650
## doors5more       -0.17649206
## persons2          2.61102897      ❶
## persons4         -1.35476871
## personsmore      -1.26074907
## lug_bootbig      -0.52193562
## lug_bootmed      -0.18681644
## lug_bootsmall     0.68419343
## safetyhigh       -1.70022006
## safetylow         2.54353980
## safetymed        -0.83688361

coef_frame <- data.frame(coef = rownames(coefs)[-1],
                        value = coefs[-1,1])

ggplot(coef_frame, aes(x = coef, y = value)) +
  geom_pointrange(aes(ymin = 0, ymax = value)) +
  ggtitle("Coefficients of ridge model") +
  coord_flip()

❶ 注意,分类变量 persons 的所有级别都存在(没有参考级别)。

注意,cv.glmnet()不使用分类变量的参考级别:例如,coefs向量包括变量persons2persons4personsmore,对应于persons变量的级别 2、4 和“more”。7.3.1 节中的逻辑回归模型使用了变量persons4personsmore,并使用级别值2作为参考级别。在正则化时使用所有变量级别的好处是,系数的大小会正则化到零,而不是到一个(可能是任意的)参考级别。

您可以在图 7.19 中看到,该模型不再具有异常大的幅度。系数的方向表明,低安全评级、小型汽车以及非常高的购买或维护价格都正向预测不可接受的评级。有人可能会怀疑小型汽车与低安全评级相关,因此safetylowpersons2可能共享了这一荣誉。

图 7.19. 岭回归模型的系数


正则化影响可解释性

因为正则化向算法的优化函数添加了一个额外的项,所以您不能像在 7.1.4 节和 7.2.4 节中那样解释系数。例如,没有报告系数的显著性。然而,您至少可以使用系数的符号作为指示,哪些变量在联合模型中与结果呈正相关或负相关。


您还可以评估model_ridge在测试数据上的性能。

列表 7.28. 查看岭模型的测试性能

prediction <- predict(model_ridge,
                     newdata = cars_test,
                     type = "response")

cars_test$pred_ridge <- as.numeric(prediction)    ❶

confmat(cars_test, "pred_ridge")
## $confusion_matrix
##               prediction
## truth          passed unacceptable
##   passed          147           12
##   unacceptable     16          324
##
## $accuracy
## [1] 0.9438878
##
## $deviance
## [1] 191.9248

❶ 预测变量是一个一维矩阵;在将其添加到 cars_test 数据框之前,需要将其转换为向量。

要查看对应于lambda.min的模型的预测,将前面列表中的第一个命令替换为以下命令:

prediction <- predict(model_ridge,
                      newdata = cars_test,
                      type="response",
                      s = model_ridge$lambda.min)

lasso 回归解决方案

您可以使用alpha = 1(默认值)与上一节相同的步骤来拟合 lasso 回归模型。我们将拟合模型作为练习留给读者;以下是结果。

列表 7.29. lasso 模型的系数

## 22 x 1 sparse Matrix of class "dgCMatrix"
##                             1
## (Intercept)      -3.572506339
## car_pricehigh     2.199963497
## car_pricelow     -0.511577936
## car_pricemed     -0.075364079
## car_pricevhigh    3.558630135
## maint_pricehigh   1.854942910
## maint_pricelow   -0.101916375
## maint_pricemed   -0.009065081
## maint_pricevhigh  3.778594043
## doors2            0.919895270
## doors3            .
## doors4           -0.374230464
## doors5more       -0.300181160
## persons2          9.299272641
## persons4         -0.180985786
## personsmore       .
## lug_bootbig      -0.842393694
## lug_bootmed       .
## lug_bootsmall     1.886157531
## safetyhigh       -1.757625171
## safetylow         7.942050790
## safetymed         .

如图 7.20 所示,cv.glmnet() 并没有减少最大系数的幅度,尽管它将一些变量(doors3personsmorelug_boot_medsafety_med)置零,并且选择了与强烈预测不可接受评分相似的变量集。

图 7.20. lasso 回归模型的系数

在测试数据上,lasso 模型的准确度与 ridge 模型相似,但偏差要低得多,这表明在测试数据上模型性能更好。

列表 7.30. lasso 模型的测试性能

### $confusion_matrix
##               prediction
## truth          passed unacceptable
##   passed          150            9
##   unacceptable     17          323
##
## $accuracy
## [1] 0.9478958
##
## $deviance
## [1] 112.7308

弹性网络解决方案:选择 alpha

cv.glmnet() 函数仅优化 lambda;它假设 alpha,指定岭回归和 lasso 惩罚混合的变量是固定的。glmnetUtils 包提供了一个名为 cva.glmnet() 的函数,该函数将对 alphalambda 同时进行交叉验证。

列表 7.31. 对 alphalambda 进行交叉验证

(elastic_net <- cva.glmnet(fmla,
                          cars_train,
                          family = "binomial"))

## Call:
## cva.glmnet.formula(formula = fmla, data = cars_train, family = "binomial")
##
## Model fitting options:
##     Sparse model matrix: FALSE
##     Use model.frame: FALSE
##     Alpha values: 0 0.001 0.008 0.027 0.064 0.125 0.216 0.343 0.512 0.729 1
##     Number of crossvalidation folds for lambda: 10

提取最佳模型的过程稍微复杂一些。与 cv.glmnet 不同,cva.glmnet 不返回 alpha.minalpha.1se。相反,字段 elastic_net$alpha 返回函数尝试的所有 alpha 值(默认情况下为 11 个),而 elastic_net$modlist 返回所有相应的 glmnet::cv.glmnet 模型对象(参见图 7.21)。这些模型对象中的每一个实际上都是 100 个模型,因此对于给定的 alpha,我们将选择 lambda.1se 模型作为“最佳模型”。

图 7.21. 使用 cva.glmnet 选择 alpha 的示意图

下面的列表实现了在图 7.21 中概述的过程,以获取每个“最佳模型”的平均交叉验证误差,并将误差作为 alpha 的函数绘制出来(图 7.22)。您可以使用函数 minlossplot(elastic_net) 创建类似的图表,但下面的列表还返回了最佳测试的 alpha 值。

图 7.22. 交叉验证误差作为 alpha 的函数

列表 7.32. 寻找最小误差 alpha

get_cvm <- function(model) {                                           ❶
   index <- match(model$lambda.1se, model$lambda)
  model$cvm[index]
}

enet_performance <- data.frame(alpha = elastic_net$alpha)              ❷
models <- elastic_net$modlist                                          ❸
enet_performance$cvm <- vapply(models, get_cvm, numeric(1))            ❹

minix <- which.min(enet_performance$cvm)                               ❺
(best_alpha <- elastic_net$alpha[minix])                               ❻
## [1] 0.729
ggplot(enet_performance, aes(x = alpha, y = cvm)) +                    ❻
   geom_point() +
  geom_line() +
  geom_vline(xintercept = best_alpha, color = "red", linetype = 2) +
  ggtitle("CV loss as a function of alpha")

❶ 获取 cv.glmnet lambda.1se 模型平均交叉验证误差的函数

❷ 获取算法尝试的 alpha 值

❸ 获取生成的模型对象

❹ 获取每个最佳模型的误差

❺ 找到最小交叉验证误差

❻ 获取相应的 alpha

❻ 将模型性能作为 alpha 的函数绘制

记住,cv.glmnetcva.glmnet都是随机的,因此结果可能每次运行都会有所不同。glmnetUtils的文档(cran.r-project.org/web/packages/glmnetUtils/vignettes/intro.html)建议多次运行cva.glmnet以减少噪声。如果您想对alpha进行交叉验证,我们建议多次计算enet_performance的等效值,并将cvm列的值平均在一起——每次运行的alpha值将是相同的,尽管相应的lambda.1se值可能不是。在确定了与最佳平均cvm相对应的alpha之后,再使用所选的alpha调用一次cv.glmnet以获得最终模型。

列表 7.33. 拟合和评估弹性网络模型

(model_enet <- cv.glmnet(fmla,
                       cars_train,
                       alpha = best_alpha,
                       family = "binomial"))

## Call:
## cv.glmnet.formula(formula = fmla, data = cars_train, alpha = best_alpha,
##     family = "binomial")
##
## Model fitting options:
##     Sparse model matrix: FALSE
##     Use model.frame: FALSE
##     Number of crossvalidation folds: 10
##     Alpha: 0.729
##     Deviance-minimizing lambda: 0.0002907102  (+1 SE): 0.002975509

prediction <- predict(model_enet,
                      newdata = cars_test,
                      type = "response")

cars_test$pred_enet <- as.numeric(prediction)

confmat(cars_test, "pred_enet")

## $confusion_matrix
##               prediction
## truth          passed unacceptable
##   passed          150            9
##   unacceptable     17          323
##
## $accuracy
## [1] 0.9478958
##
## $deviance
## [1] 117.7701

还值得注意的是,在这种情况下,交叉验证损失在alpha=0之后下降得相当快,因此在实际应用中,几乎任何非零的alpha都会给出相似质量的模型。

摘要

线性和逻辑回归都假设结果是一个输入线性组合的函数。这似乎很限制,但在实践中,即使理论假设并不完全符合,线性和逻辑回归模型也能表现良好。我们将在第十章(chapter 10)中展示如何进一步克服这些限制。

线性和逻辑回归还可以通过量化结果与模型输入之间的关系来提供建议。由于模型完全由其系数表达,它们体积小、易于携带、效率高——这些都是将模型投入生产时的宝贵品质。如果模型的误差与y不相关,则可以信任模型在训练范围之外进行外推预测。外推永远不完全安全,但有时是必要的。

在变量相关或预测问题近似可分的情况下,线性方法可能表现不佳。在这些情况下,正则化方法可以产生更安全应用于新数据的模型,尽管这些模型的系数对于变量与结果之间关系建议并不那么有用。

在本章学习线性模型时,我们假设数据表现良好:数据没有缺失值,分类变量的可能级别数量较少,并且所有可能的级别都包含在训练数据中。在现实世界的数据中,这些假设并不总是成立。在下一章中,你将学习关于如何准备表现不佳的数据以进行建模的高级方法。

在本章中,你已经学到了

  • 如何使用线性回归模型预测数值量

  • 如何使用逻辑回归模型预测概率或进行分类

  • 如何解释lm()glm()模型的诊断结果

  • 如何解释线性模型的系数

  • 如何诊断线性模型可能不“安全”或不够可靠(多重共线性,准分离)

  • 如何使用 glmnet 来拟合正则化线性与逻辑回归模型

第八章. 高级数据准备

本章涵盖

  • 使用 vtreat 包进行高级数据准备

  • 交叉验证数据准备

在我们上一章中,我们基于良好或行为良好的数据构建了大量的模型。在本章中,我们将学习如何准备或处理混乱的现实世界数据以进行建模。我们将使用第四章(第四章)的原则和高级数据准备包:vtreat。我们将重新审视缺失值、分类变量、变量重编码、冗余变量以及变量过多等问题。我们将花一些时间讨论变量选择,这在当前的机器学习方法中也是一个重要的步骤。本章的心智模型总结(图 8.1)强调,本章是关于处理数据和为机器学习建模做准备。我们首先介绍 vtreat 包,然后解决一个详细的现实世界问题,接着更详细地介绍如何使用 vtreat 包。

图 8.1. 心智模型

8.1. vtreat 包的用途

vtreat 是一个 R 包,旨在为监督学习或预测建模准备现实世界的数据。它旨在处理许多常见问题,因此数据科学家无需处理。这为他们留下了更多时间来寻找和解决独特的领域相关问题。vtreat 是对第四章(第四章)中讨论的概念以及许多其他概念的出色实现。第四章(第四章)的一个目标是为您提供一些我们在处理数据时可能遇到的问题的理解,以及处理此类数据时应采取的原则性步骤。vtreat 将这些步骤自动化为高性能的生产级包,并且是一种可以正式引用的方法,您可以将其纳入自己的工作中。由于 vtreat 做了很多事情,我们无法简要解释它对数据所做的一切;有关详细信息,请参阅此处的高级文档:arxiv.org/abs/1611.09477。此外,vtreat 在此处有许多解释性小节和工作示例:CRAN.R-project.org/package=vtreat

在本章中,我们将通过使用 KDD Cup 2009 数据集预测账户取消(称为 客户流失)的例子来探讨 vtreat 的功能。在这个示例场景中,我们将使用 vtreat 准备数据,以便在后续建模步骤中使用。vtreat 帮助解决的问题包括以下几方面:

  • 数值变量中的缺失值

  • 数值变量中的极端或超出范围的值

  • 分类变量中的缺失值

  • 分类数据中的稀有值

  • 分类数据中的新颖值(在测试或应用期间看到,但在训练期间没有看到)

  • 具有非常多个可能值的分类数据

  • 由于变量数量过多导致的过拟合

  • 由于“嵌套模型偏差”导致的过度拟合

vtreat 的基本工作流程(如图 8.2 所示)是使用一些训练数据来创建一个 处理计划,该计划记录了数据的关键特征,例如个体变量与结果之间的关系。然后,这个处理计划被用来 准备 将用于拟合模型的数据,以及准备模型将应用到的数据。这个想法是,这种准备或处理过的数据将是“安全”的,没有缺失或意外的值,并且可能包含新的合成变量,这将提高模型拟合度。从这个意义上说,vtreat 本身看起来很像一个模型。

图 8.2. vtreat 三分分割策略

我们在 第四章 中看到了 vtreat 的简单用法,用于处理缺失值。在本章中,我们将使用 vtreat 的全部编码能力来处理我们的客户流失示例。为了激发兴趣,我们将解决 KDD Cup 2009 问题,然后我们将讨论如何一般性地使用 vtreat

KDD Cup 2009 提供了一个关于客户关系管理的数据集。这个竞赛数据提供了 50,000 个信用卡账户的 230 个事实。从这些特征中,竞赛的一个目标就是预测账户取消(称为 流失)。

使用 vtreat 的基本方法是采用三分数据分割:一组用于学习数据处理,一组用于建模,第三组用于在新数据上评估模型质量。图 8.2 展示了这一概念,一旦我们通过一个示例,它将作为一个很好的记忆点。如图所示,要这样使用 vtreat,我们将数据分成三部分,并使用其中一部分来准备处理计划。然后我们使用处理计划来准备其他两个子集:一个子集用于拟合所需的模型,另一个子集用于评估拟合的模型。这个过程可能看起来很复杂,但从用户的角度来看,它非常简单。

让我们从使用 vtreat 的一个示例场景开始,该场景使用 KDD Cup 2009 账户取消预测问题。

8.2. KDD 和 KDD Cup 2009


示例

我们被赋予预测在特定时间段内哪些信用卡账户将取消的任务。这种取消称为流失。为了构建我们的模型,我们有监督的训练数据可用。对于训练数据中的每个账户,我们有数百个测量的特征,并且我们知道账户后来是否取消。我们希望构建一个模型,能够识别数据中的“有取消风险”的账户,以及未来的应用。


为了模拟这个场景,我们将使用 KDD Cup 2009 竞赛数据集.^([1])

¹

我们在这里分享数据以及使用 R 准备这些数据以进行建模的步骤:github.com/WinVector/PDSwR2/tree/master/KDD2009


数据的不足之处

与许多基于评分的竞赛一样,这次竞赛专注于机器学习,并故意抽象或跳过了一些重要的数据科学问题,例如共同定义目标、请求新的度量、收集数据以及根据业务目标量化分类器的性能。对于这次竞赛的数据,我们没有任何独立(或输入)变量的名称或定义,也没有对依赖(或结果)变量的真正定义。我们有优势,即数据以准备好建模的格式提供(所有输入变量和结果都安排在单行中)。但我们不知道任何变量的含义(因此我们很遗憾不能加入外部数据源),而且我们不能使用任何处理时间和事件重复的仔细方法(如时间序列方法或生存分析)。

^a

我们将使用各种名称或列来构建模型,称为变量独立变量输入变量等,以尝试区分它们与要预测的值(我们将称之为结果依赖变量)。


为了模拟数据科学过程,我们将假设我们可以使用我们给出的任何列来进行预测(即所有这些列在需要预测之前都是已知的)^([2])。我们将假设竞赛指标(AUC,或曲线下面积,如第 6.2.5 节中讨论的)是正确的,并且顶级参赛者的 AUC 是一个很好的上限(告诉我们何时停止调整)^([3])。

²

检查列是否实际上将在预测期间可用(而不是未知输出的某个后续函数)是数据科学项目中的一个关键步骤。

³

AUC 是一个好的初始筛选指标,因为它衡量你的分数的任何单调变换是否是一个好的分数。为了微调,我们将使用 R 平方和伪 R 平方(也在第六章中定义)作为它们更严格,衡量手头的确切值是否是好的分数。

8.2.1. 开始使用 KDD Cup 2009 数据

对于我们的示例,我们将尝试预测 KDD 数据集中的客户流失。KDD 竞赛是根据 AUC(曲线下面积,第 6.2.5 节中讨论的预测质量度量)来评判的,因此我们也将使用 AUC 作为我们的性能度量^([4])。获胜团队在客户流失上的 AUC 为 0.76,因此我们将将其视为可能性能的上限。我们的性能下限是 AUC 为 0.5,因为 AUC 低于 0.5 比随机预测更差。

此外,正如常见的示例问题一样,我们没有项目赞助商来讨论指标,因此我们的评估选择有点随意。

此问题具有大量变量,其中许多是具有大量可能级别的分类变量。正如我们将看到的,此类变量在创建治疗方案的过程中特别容易过拟合。由于这个担忧,我们将数据分为三个集合:训练集、校准集和测试集。在以下示例中,我们将使用训练集来设计治疗方案,并使用校准集来检查治疗方案中的过拟合。测试集保留用于对模型性能进行最终估计。许多研究人员推荐这种三方分割程序。5

通常,我们会使用校准集来设计治疗方案,训练集来训练模型,测试集来评估模型。由于本章的重点是数据处理过程,我们将使用最大的集合(dTrain)来设计治疗方案,并使用其他集合来评估它。

让我们按照以下列表开始工作,其中我们准备数据进行分析和建模。6

请在 PDSwR2 支持材料的 KDD2009 子目录中工作,或者将相关文件复制到您正在工作的位置。PDSwR2 支持材料可在 github.com/WinVector/PDSwR2 获取,有关入门说明请参阅 附录 A。

列表 8.1. 准备 KDD 数据进行分析

d <- read.table('orange_small_train.data.gz',                          ❶
   header = TRUE,
   sep = '\t',
   na.strings = c('NA', ''))                                           ❷

churn <- read.table('orange_small_train_churn.labels.txt',
   header = FALSE, sep = '\t')                                         ❸
d$churn <- churn$V1                                                    ❹
set.seed(729375)                                                       ❺
rgroup <- base::sample(c('train', 'calibrate', 'test'),                ❻
   nrow(d),
   prob = c(0.8, 0.1, 0.1),
   replace = TRUE)
dTrain <- d[rgroup == 'train', , drop = FALSE]
dCal <- d[rgroup == 'calibrate', , drop = FALSE]
dTrainAll <- d[rgroup %in% c('train', 'calibrate'), , drop = FALSE]
dTest <- d[rgroup == 'test', , drop = FALSE]

outcome <- 'churn'
vars <- setdiff(colnames(dTrainAll), outcome)

rm(list=c('d', 'churn', 'rgroup'))                                     ❻

❶ 读取独立变量文件。所有数据均来自 github.com/WinVector/PDSwR2/tree/master/KDD2009

❷ 将 NA 和空字符串都视为缺失数据

❸ 读取已知的流失结果

❹ 添加流失作为新列

❺ 通过设置伪随机数生成器的种子,我们使我们的工作可重复:有人重新执行它将看到完全相同的结果。

❻ 将数据分为训练集、校准集和测试集。显式指定 base::sample() 函数以避免与 dplyr::sample() 函数发生名称冲突,如果已加载 dplyr 包。

❻ 从工作区移除不必要的对象

我们还在 GitHub 仓库中保存了一个 R 工作空间,其中包含本章的大部分数据、函数和结果,您可以使用命令 load('KDD2009.Rdata') 加载它。我们现在准备好构建一些模型。

我们想提醒读者:始终查看您的数据。查看数据是发现惊喜的最快方式。有两个函数特别有助于对数据进行初步查看:str()(以转置形式显示前几行的结构)和 summary()


练习:使用 str() 和 summary()

在继续之前,请运行列表 8.1 中的所有步骤,然后尝试自己运行str(dTrain)summary(dTrain)。我们试图通过不在我们的保留数据上做出建模决策来避免过拟合。



快速进行子样本

经常数据科学家会如此专注于业务问题、数学和数据,以至于他们忘记了需要多少试错。首先在小部分训练数据上工作通常是一个非常好的主意,这样调试代码只需要几秒钟,而不是几分钟。除非你真的需要,否则不要处理大型和缓慢的数据集。


描述结果

在开始建模之前,我们应该查看结果分布。这告诉我们有多少变异,以至于甚至尝试预测。我们可以这样做:

outcome_summary <- table(
   churn = dTrain[, outcome],                   ❶
   useNA = 'ifany')                             ❷

knitr::kable(outcome_summary)

outcome_summary["1"] / sum(outcome_summary)     ❸
#          1
# 0.07347764

❶ 列出客户流失结果的水平

❷ 在列表中包含 NA 值

❸ 估计观察到的客户流失率或流行率

图 8.3 中的表格表明,客户流失有两个值:-1 和 1。值 1(表示发生了客户流失或账户取消)大约有 7% 的时间被看到。因此,我们可以简单地通过预测没有任何账户取消来达到 93% 的准确率,尽管显然这不是一个有用的模型!^([7])

www.win-vector.com/blog/2009/11/i-dont-think-that-means-what-you-think-it-means-statistics-to-english-translation-part-1-accuracy-measures/

图 8.3. KDD2009 客户流失率

8.2.2. 中国瓷器店的牛

让我们故意忽略查看数据、查看列以及描述拟议的解释变量与要预测的数量之间关系的建议。对于这个第一次尝试,我们不是在制定治疗方案,所以我们将使用dTraindCal数据一起来拟合模型(作为dTrainAll集合)。让我们看看如果我们立即尝试为churn == 1构建模型会发生什么,给定解释变量(提示:这不会很漂亮)。

列表 8.2. 尝试在没有准备的情况下进行建模

library("wrapr")                                                 ❶

outcome <- 'churn'
vars <- setdiff(colnames(dTrainAll), outcome)

formula1 <- mk_formula("churn", vars, outcome_target = 1)        ❷
model1 <- glm(formula1, data = dTrainAll, family = binomial)     ❸

# Error in `contrasts ...                                        ❹

❶ 为方便函数(如 mk_formula())附加 wrapr 包

❷ 构建模型公式规范,要求预测 churn == 1 作为我们的解释变量的函数

❸ 要求 glm() 函数构建一个逻辑回归模型

❹ 尝试失败,出现错误。

如我们所见,这次尝试失败了。一些研究将表明,我们试图用作解释变量的某些列没有变化,并且对于每一行或示例都具有完全相同的值。我们可以尝试手动过滤掉这些不良列,但以这种方式修复常见的数据问题是非常繁琐的。例如,列表 8.3 展示了如果我们只使用第一个解释变量Var1来构建模型会发生什么。


解释变量

解释变量是我们试图用作模型输入的列或变量。在这种情况下,变量到达我们这里时没有信息性的名称,因此它们以Var#的形式命名,其中#是一个数字。在一个真实的项目中,这可能是数据管理伙伴未承诺的迹象,并且在尝试建模之前需要解决的问题。


列表 8.3. 尝试仅使用一个变量

model2 <- glm((churn == 1) ~ Var1, data = dTrainAll, family = binomial)
summary(model2)
#
# Call:
# glm(formula = (churn == 1) ~ Var1, family = binomial, data = dTrainAll)
#
# Deviance Residuals:
#     Min       1Q   Median       3Q      Max
# -0.3997  -0.3694  -0.3691  -0.3691   2.3326
#
# Coefficients:
#               Estimate Std. Error z value Pr(>|z|)
# (Intercept) -2.6523837  0.1674387 -15.841   <2e-16 ***
# Var1         0.0002429  0.0035759   0.068    0.946
# ---
# Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#
# (Dispersion parameter for binomial family taken to be 1)
#
#     Null deviance: 302.09  on 620  degrees of freedom
# Residual deviance: 302.08  on 619  degrees of freedom
#   (44407 observations deleted due to missingness)       ❶
# AIC: 306.08
#
# Number of Fisher Scoring iterations: 5

dim(dTrainAll)
# [1] 45028   234

❶ 这意味着建模过程丢弃了这么多的(几乎全部)训练数据。

我们在第 7.2 节中详细介绍了如何阅读模型摘要。这里引人注目的是“由于缺失值删除了 44407 个观测值”这一行。这意味着建模过程丢弃了我们 45028 个训练行中的 44407 行,基于剩余的 621 行数据构建模型。因此,除了不变化的列之外,我们还有包含大量缺失值的列。

数据问题并没有结束。看看另一个变量,这次是名为Var200的变量:

head(dTrainAll$Var200)
# [1] <NA>    <NA>    vynJTq9 <NA>    0v21jmy <NA>
# 15415 Levels: _84etK_ _9bTOWp _A3VKFm _bq4Nkb _ct4nkXBMp ... zzQ9udm

length(unique(dTrainAll$Var200))
# [1] 14391

head()命令显示了Var200的前几个值,告诉我们这个列具有作为因子的字符串值编码。因子是 R 对来自已知集合的字符串的表示。这正是问题的所在。注意列表中提到因子有 15415 个可能的水平。具有这么多不同水平的因子或字符串变量在过拟合方面将是一个大问题,并且对于glm()代码来说也难以处理。此外,length(unique(dTrainAll$Var200))摘要告诉我们Var200在我们的训练样本中只采取了 14391 个不同的值。这告诉我们我们的训练数据样本没有看到这个变量的所有已知值。我们的保留测试集除了在训练期间看到的值之外,还包含训练集之外的新的值。这对于具有大量水平的字符串值或分类变量来说是很常见的,并且导致大多数 R 建模代码在尝试对新数据进行预测时出错。

我们可以继续。我们还没有用尽第 8.1 节中常见错误事项的列表。到目前为止,我们希望读者会同意:一种合理的系统方法来识别、描述和减轻常见的数据质量问题将大有裨益。以领域无关的方式处理常见的数据质量问题,让我们有更多时间处理数据并解决任何特定领域的问题。vtreat包是这项任务的优秀工具。在本章的其余部分,我们将与 KDD Cup 2009 数据略作工作,然后掌握一般使用vtreat的方法。

8.3. 分类的基本数据准备

vtreat通过清理现有列或变量以及引入新的列或变量来准备数据。对于我们的订单取消场景,vtreat将处理缺失值、具有许多级别的分类变量和其他问题。让我们在这里掌握vtreat的过程。

首先,我们将使用我们数据的一部分(dTrain集)来设计我们的变量处理。

列表 8.4. 分类的基本数据准备

library("vtreat")                                                       ❶

(parallel_cluster <- parallel::makeCluster(parallel::detectCores()))    ❷

treatment_plan <- vtreat::designTreatmentsC(                            ❸
  dTrain,
  varlist = vars,
  outcomename = "churn",
  outcometarget = 1,
  verbose = FALSE,
  parallelCluster = parallel_cluster)

❶ 添加 vtreat 包以使用如 designTreatmentsC()等函数

❷ 启动一个并行集群以加快计算速度。如果您不想使用并行集群,只需将 parallel_cluster 设置为 NULL。

❸ 使用 designTreatmentsC()从训练数据中学习处理计划。对于大小和复杂度类似于 KDD2009 的数据集,这可能需要几分钟。

然后,我们将使用处理计划来准备清洗和处理后的数据。prepare()方法构建一个新的数据框,其行顺序与原始数据框相同,并包含处理计划中的列(如果存在,则复制依赖变量列)。这一想法在图 8.4 中得到了说明。在列表 8.5 中,我们将处理计划应用于dTrain数据,以便我们可以比较处理后的数据与原始数据。

图 8.4. vtreat变量准备

列表 8.5. 使用vtreat准备数据

dTrain_treated <- prepare(treatment_plan,
                          dTrain,
                          parallelCluster = parallel_cluster)

head(colnames(dTrain))
## [1] "Var1" "Var2" "Var3" "Var4" "Var5" "Var6"
head(colnames(dTrain_treated))                                       ❶
## [1] "Var1"       "Var1_isBAD" "Var2"       "Var2_isBAD" "Var3"
## [6] "Var3_isBAD"

❶ 比较原始 dTrain 数据的列与其处理后的对应数据

注意,处理后的数据既转换了现有列,也引入了新的列或派生变量。在下一节中,我们将探讨这些新变量是什么以及如何使用它们。

8.3.1. 变量得分框架

我们到目前为止所处理的vtreat过程集中在designTreatmentsC()上,它返回治疗方案。治疗方案是一个 R 对象,有两个目的:通过prepare()语句用于数据准备,并提供对建议变量的简单总结和初步评估。这个简单的总结封装在分数框架中。分数框架列出了prepare()方法将创建的变量,以及一些关于它们的信息。分数框架是我们了解vtreat引入的新变量以简化我们的建模工作的指南。让我们看一下分数框架:

score_frame <-  treatment_plan$scoreFrame
t(subset(score_frame, origName %in% c("Var126", "Var189")))

# varName           "Var126"       "Var126_isBAD" "Var189"       "Var189_isBAD" ❶
# varMoves          "TRUE"         "TRUE"         "TRUE"         "TRUE"         ❷
# rsq               "0.0030859179" "0.0136377093" "0.0118934515" "0.0001004614" ❸
# sig               "7.876602e-16" "2.453679e-64" "2.427376e-56" "1.460688e-01" ❹
# needsSplit        "FALSE"        "FALSE"        "FALSE"        "FALSE"        ❺
# extraModelDegrees "0"            "0"            "0"            "0"            ❻
# origName          "Var126"       "Var126"       "Var189"       "Var189"       ❻
# code              "clean"        "isBAD"        "clean"        "isBAD"        ❽

❶ 派生变量或列的名称

❷ 一个指示符,表明这个变量不总是相同的值(不是一个常数,这对建模是无用的)

❸ 变量的 R-squared 或伪 R-squared;这个变量在线性模型中可以解释的因变量变异的分数

❹ 估计 R-squared 的重要性

❺ 一个指示符,当为 TRUE 时,是对用户的一个警告,表明该变量隐藏了额外的自由度(模型复杂度的度量),需要使用交叉验证技术进行评估

❻ 变量的复杂程度;对于分类变量,这与级别的数量有关。

❻ 变量派生自原始列的名称

❽ 用于构建此变量的转换类型的名称

分数框架是一个data.frame,每行对应一个派生解释变量。每一行显示派生变量将来自哪个原始变量(orig-Name),将使用什么类型的转换来生成派生变量(code),以及一些关于变量的质量摘要。

在我们的例子中,Var126 生成了两个新的或派生变量:Var126(原始 Var126 的清理版本,没有 NA/缺失值),以及 Var116_isBAD(一个指示变量,表示 Var126 原始的哪些行包含缺失或不良值)。

rsq列记录了给定变量的伪 R-squared,这是如果将其作为因变量的单变量模型处理时,变量信息性的一个指标。sig列是对此伪 R-squared 显著性的估计。请注意,var126_isBAD比清理后的原始变量var126更有信息量。这表明我们应该考虑将var126_isBAD包含在我们的模型中,即使我们决定不包含var126的清理版本本身!


信息性缺失值

在生产系统中,缺失值通常非常有信息。缺失值通常表明相关数据经历了某种条件(温度超出范围,测试未运行,或其他情况),并以编码的形式提供了大量上下文。我们见过许多情况,其中变量缺失的信息比变量本身的清理值更有信息量。


让我们看看一个分类变量。原始 Var218 有两个可能的水平:cJvFUYBR

t(subset(score_frame, origName == "Var218"))

# varName           "Var218_catP"  "Var218_catB"  "Var218_lev_x_cJvF" "Var218
                                                                 _lev_x_UYBR"
# varMoves          "TRUE"         "TRUE"         "TRUE"              "TRUE"

# rsq               "0.011014574"  "0.012245152"  "0.005295590"       "0.0019
                                                                       70131"
# sig               "2.602574e-52" "5.924945e-58" "4.902238e-26"
                                                               "1.218959e-10"
# needsSplit        " TRUE"        " TRUE"        "FALSE"             "FALSE"
# extraModelDegrees "2"            "2"            "0"                 "0"
# origName          "Var218"       "Var218"       "Var218"            "Var218
# code              "catP"         "catB"         "lev"               "lev"

原变量 Var218 产生了四个派生变量。特别是,请注意,水平 cJvFUYBR 分别为我们提供了新的派生列或变量。

水平变量(lev)

Var218_lev_x_cJvFVar218_lev_x_UYBR 是指示变量,当原始 Var218 的值分别为 cJvFUYBR 时,它们的值为 1;^([8]) 我们稍后会讨论其他两个变量。回顾 第七章,大多数建模方法通过将其转换为 n(或 n-1)个二元变量,或指示变量(有时称为 独热编码虚拟变量)来处理具有 n 个可能水平的分类变量。R 中的许多建模函数,如 lmglm,会自动进行这种转换;而其他函数,如 xgboost,则不会。vtreat 尝试在可行的情况下显式地独热编码分类变量。这样,数据既可以由 glm 等建模函数使用,也可以由 xgboost 等函数使用。

在一个真实的建模项目中,我们会坚持使用有意义的水平名称和一个 数据字典 来描述各种水平的含义。KDD2009 竞赛数据没有提供此类信息,这是竞赛数据的一个局限性,阻碍了使用变量从外部数据源获取额外信息等强大方法。

默认情况下,vtreat 只为“非稀有”水平创建指示变量:出现频率超过 2% 的水平。正如我们将看到的,Var218 也有一些缺失值,但缺失值只占 1.4% 的时间。如果缺失值更有信息量,那么 vtreat 也会创建一个 Var218_lev_x_NA 指示变量。

影响变量(catB)

独热编码为分类变量的每个非稀有水平创建一个新变量。catB 编码返回一个单一的新变量,其中每个原始分类变量的可能水平都有一个数值。这个值表示给定水平的信息量:具有大绝对值的值对应于更具有信息量的水平。我们称此为该水平对结果的影响;因此,术语“影响变量”。为了理解影响变量,让我们比较原始 Var218Var218_catB

comparison <- data.frame(original218 = dTrain$Var218,
                         impact218 = dTrain_treated$Var218_catB)

head(comparison)
 ##   original218  impact218
## 1        cJvF -0.2180735
## 2        <NA>  1.5155125
## 3        UYBR  0.1221393
## 4        UYBR  0.1221393
## 5        UYBR  0.1221393
## 6        UYBR  0.1221393

对于分类问题,影响编码的值与从 Var218 预测流失的逻辑回归模型的预测相关。为了展示这一点,我们将使用我们在 第 4.1.3 节 中使用的简单缺失值处理方法,将 Var218 中的 NA 值显式地转换为新的水平。我们还将使用我们在 第七章 中看到的 logit 或对数优势函数。

treatment_plan_2 <- design_missingness_treatment(dTrain, varlist = vars)   ❶
dtrain_2 <- prepare(treatment_plan_2, dTrain)                              ❷
head(dtrain_2$Var218)

## [1] "cJvF"      "_invalid_" "UYBR"      "UYBR"      "UYBR"      "UYBR"

model <- glm(churn ==1  ~ Var218,                                          ❸
             data = dtrain_2,
             family = "binomial")
pred <- predict(model,                                                     ❹
                newdata = dtrain_2,
                type = "response")

(prevalence <- mean(dTrain$churn == 1) )                                   ❺
 ## [1] 0.07347764

logit <- function(p) {                                                     ❻
   log ( p / (1-p) )
}

comparison$glm218 <- logit(pred) - logit(prevalence)                       ❻
 head(comparison)

##   original218  impact218     glm218
## 1        cJvF -0.2180735 -0.2180735                                     ❽
## 2        <NA>  1.5155125  1.5155121
## 3        UYBR  0.1221393  0.1221392
## 4        UYBR  0.1221393  0.1221392
## 5        UYBR  0.1221393  0.1221392
## 6        UYBR  0.1221393  0.1221392

❶ 将 NA 转换为安全字符串的简单处理

❷ 创建处理后的数据

❸ 符合一元逻辑回归模型

❹ 在数据上做出预测

❺ 计算流失的全球概率。

❻ 一个计算概率的对数几率或对数优势的函数

❻ 手动计算 catB 值

❽ 注意,vtreat 的影响代码与标准 glm 模型中编码的“delta logit”预测相匹配。这有助于说明 vtreat 是如何实现的。

在我们的 KDD2009 示例中,我们看到catB影响编码正在用一个对应的一元逻辑回归模型的预测来替换一个分类变量。由于技术原因,预测是在“链接空间”或logit空间中,而不是在概率空间中,并且表示为与总是预测全局结果概率的零模型之间的差异。在所有情况下,这种数据准备都涉及一个可能复杂的分类变量(可能表示许多自由度或虚拟变量列),并推导出一个单一的数值列,该列提取了变量的大部分建模效用。

当建模问题是一个回归而不是分类(结果为数值)时,影响编码与一元线性回归的预测相关。我们将在本章后面看到这个示例。

普及变量(catP)

想法是这样的:对于某些变量,知道某个水平出现的频率非常有信息量。例如,对于美国的 ZIP 代码,罕见的 ZIP 代码可能都是来自低人口密度的农村地区。普及变量简单地编码了原始变量在给定水平上占的时间比例,使得这些整个数据集的统计数据以方便的按例格式可用于建模过程。


变量伦理

注意:对于某些应用,某些变量和推断可能既不道德也可能非法使用。例如,由于历史上的“红线”歧视做法,美国禁止在信用批准决策中使用 ZIP 代码和种族。

在实际应用中,对伦理问题的敏感性和熟悉数据和建模法律是至关重要的。


让我们看看另一个给我们带来麻烦的变量发生了什么:Var200。回想一下,这个变量有 15415 个可能的值,其中只有 13324 个出现在训练数据中。

score_frame[score_frame$origName == "Var200", , drop = FALSE]

#           varName varMoves         rsq          sig needsSplit
            extraModelDegrees origName code
# 361   Var200_catP     TRUE 0.005729835 4.902546e-28
                        TRUE             13323   Var200 catP
# 362   Var200_catB     TRUE 0.001476298 2.516703e-08
                        TRUE             13323   Var200 catB
# 428 Var200_lev_NA     TRUE 0.005729838 4.902365e-28
                        FALSE                 0   Var200  lev

注意,vtreat只返回了一个指示变量,表示缺失值。Var200的所有其他可能值都很罕见:它们出现的频率不到 2%。对于像Var200这样的具有非常大量级的变量,在建模时将所有级别编码为指示变量并不实用;将变量表示为单个数值变量,如catB变量,计算上更有效。

在我们的例子中,designTreatmentsC() 方法将原始的 230 个解释变量重新编码为 546 个新的全数值解释变量,这些变量没有缺失值。想法是这些 546 个变量更容易处理,并且有很大的机会代表数据中大部分原始的预测信号。vtreat 可以引入的新变量的完整描述可以在 vtreat 包文档中找到。9]

查看 winvector.github.io/vtreat/articles/vtreatVariableTypes.html

8.3.2. 正确使用治疗计划

治疗计划对象的主要目的是允许 prepare() 在拟合和应用模型之前将新数据转换为安全、干净的形式。让我们看看这是如何完成的。在这里,我们将从 dTrain 集中学习到的治疗计划应用于校准集 dCal,如图 8.5 所示。

图 8.5. 准备保留数据

图片

dCal_treated <- prepare(treatment_plan,
                        dCal,
                        parallelCluster = parallel_cluster)

通常,我们现在可以使用 dCal_treated 来拟合一个关于客户流失的模型。在这种情况下,我们将用它来说明在得分框架中 needsSplit == TRUE 的转换变量上过度拟合的风险。

如我们之前提到的,你可以将 Var200_catB 变量视为一个关于客户流失的单变量逻辑回归模型。当我们调用 designTreatmentsC() 时,这个模型使用了 dTrain 进行拟合;然后当我们调用 prepare() 时,它被应用于 dCal 数据。让我们看看这个模型在训练集和校准集上的 AUC:

library("sigr")

calcAUC(dTrain_treated$Var200_catB, dTrain_treated$churn)

# [1] 0.8279249

calcAUC(dCal_treated$Var200_catB, dCal_treated$churn)

# [1] 0.5505401

注意训练数据中估计的 AUC 为 0.83,这似乎非常好。然而,当我们查看未用于设计变量处理的校准数据时,这个 AUC 并没有得到证实。Var200_catB 相对于 dTrain_ treated 过度拟合。Var200_catB 是一个有用的变量,只是不如它在训练数据上看起来那么好。


不要直接重用相同的数据来拟合治疗计划和模型!

为了避免过度拟合,一般规则是,每当预建模数据预处理步骤使用对结果的知识时,你不应该使用相同的数据进行预建模步骤和建模。

本节中的 AUC 计算表明 Var200_catB 在训练数据上看起来“太好”了。任何使用 dTrain_ treated 来拟合客户流失模型的模型拟合算法都可能基于其明显的值过度使用这个变量。结果模型将无法在新的数据上实现这个值,并且它的预测效果不会像预期的那样好。


正确的程序是在设计数据处理计划后不重复使用 dTrain,而是使用 dCal_treated 进行模型训练(尽管在这种情况下,我们应该使用比最初分配更多的可用数据)。有了足够的数据和正确的数据分割(例如,40% 数据处理设计,50% 模型训练,10% 模型测试/评估),这是一种有效的策略。

在某些情况下,我们可能没有足够的数据来进行良好的三路分割。内置的 vtreat 交叉验证程序允许我们使用相同的训练数据来设计数据处理计划,并正确构建模型。这是我们接下来要掌握的。

8.4. 高级数据准备用于分类

现在我们已经看到了如何为分类准备杂乱的数据,让我们探讨如何以更统计有效的方式进行。也就是说,让我们掌握那些让我们可以安全地重复使用相同数据来设计处理计划和模型训练的技术。

8.4.1. 使用 mkCrossFrameCExperiment()

使用 vtreat 安全地使用相同的数据进行数据处理设计和模型构建是很容易的。我们所做的只是使用 mkCrossFrameCExperiment() 方法而不是 designTreatmentsC() 方法。designTreatmentsC() 方法使用交叉验证技术来生成一个特殊的 交叉帧 用于训练,而不是在训练数据上使用 prepare(),这一点我们在图 8.6 中进行了回顾。

图 8.6. vtreat 三路分割策略再次

图片

交叉帧是一种特殊的代理训练数据,其行为就像它没有被用来构建自己的处理计划一样。这个过程在图 8.7 中显示,我们可以将其与图 8.6 进行对比。

图 8.7. vtreat 交叉帧策略

图片

程序的用户可见部分很小且简单。图 8.7 之所以看起来复杂,是因为 vtreat 提供了一个非常复杂的服务:适当的交叉验证组织,这允许我们安全地重复使用数据来进行处理设计和模型训练。

治疗计划和交叉帧可以按以下方式构建。在这里,我们将最初分配用于训练和校准的所有数据作为一个单独的训练集,dTrainAll。然后我们将评估测试集中的数据。

列表 8.6. 高级数据准备用于分类

library("vtreat")

parallel_cluster <- parallel::makeCluster(parallel::detectCores())

cross_frame_experiment <- vtreat::mkCrossFrameCExperiment(
  dTrainAll,
  varlist = vars,
  outcomename = "churn",
  outcometarget = 1,
  verbose = FALSE,
  parallelCluster = parallel_cluster)

dTrainAll_treated <- cross_frame_experiment$crossFrame       ❶
treatment_plan <- cross_frame_experiment$treatments
score_frame <- treatment_plan$scoreFrame

dTest_treated <- prepare(treatment_plan,                     ❷
                         dTest,
                         parallelCluster = parallel_cluster)

❶ 我们将使用交叉帧来训练逻辑回归模型。

❷ 准备测试集,以便我们可以对其调用模型

列表 8.6 中的步骤故意与列表 8.4 中的步骤非常相似。请注意,dTrainAll_treated 是作为实验的一部分返回的值,而不是我们使用 prepare() 产生的。这种整体数据处理策略实现了图 8.7 中的想法。

让我们重新检查 Var200 在训练集和测试集上的预测质量估计:

library("sigr")

calcAUC(dTrainAll_treated$Var200_catB, dTrainAll_treated$churn)

# [1] 0.5450466

calcAUC(dTest_treated$Var200_catB, dTest_treated$churn)

# [1] 0.5290295

注意,Var200 在训练数据上的估计效用现在与其在测试数据上的未来性能非常接近.^([10]) 这意味着在训练数据上做出的决策在稍后重新测试保留的测试数据或未来的应用数据时,有很大机会是正确的。

¹⁰

记住,我们是从受抽样影响的数据中估计性能的,因此所有质量估计都是嘈杂的,我们不应将观察到的差异视为问题。

8.4.2. 构建模型

现在我们已经处理了我们的变量,让我们再次尝试构建一个模型。

变量选择

构建许多变量模型的关键部分是选择要使用的变量。我们使用的每个变量都代表解释更多结果变化的机会(构建更好模型的机会),但也代表噪声和过拟合的可能来源。为了控制这种影响,我们通常预先选择我们将用于拟合的变量子集。变量选择可以是一个重要的防御性建模步骤,即使是那些“不需要”这种步骤的模型类型。现代数据仓库中通常看到的列数可能会压倒最先进的机器学习算法.^([11])

¹¹

查看 www.win-vector.com/blog/2014/02/bad-bayes-an-example-of-why-you-need-hold-out-testing/

vtreat 提供了两种过滤变量的方法:评分框架中的汇总统计以及一种称为 value_variables_C() 的方法。评分框架中的汇总是每个变量的线性拟合质量,因此它们可能低估了复杂的非线性数值关系。一般来说,你可能想尝试 value_variables_C() 来正确评分非线性关系。对于我们的例子,我们将拟合一个线性模型,因此使用更简单的评分框架方法是合适的.^([12])

¹²

我们在 github.com/WinVector/PDSwR2/blob/master/KDD2009/KDD2009vtreat.md 分享了一个 xgboost 的工作解决方案,它在 AUC(曲线下面积)方面与线性模型具有相似的性能。事情可以改进,但我们似乎已经进入了一个收益递减的区域。

我们将根据显著性过滤变量,但请注意,显著性估计本身非常嘈杂,如果不当进行变量选择,本身也可能成为错误和偏差的来源。13] 我们将使用以下思路:假设某些列实际上是不相关的,并使用最宽松的标准,只允许少量不相关列通过。我们使用最宽松的条件来尽量减少我们可能意外过滤掉的实际有用列或变量的数量。请注意,虽然相关列的显著性值应接近零,但不相关列的显著性应在零到一之间均匀分布(这与显著性的定义非常接近)。因此,一个好的选择过滤器将保留所有显著性不超过k/nrow(score_frame)的变量;我们预计只有大约k个不相关变量会通过这样的过滤器。

¹³

关于这个效果的优秀文章是 Freedman 的“关于筛选回归方程的注释”,《美国统计学家》,第 37 卷,第 152-155 页,1983 年。

变量选择可以按以下方式进行:

k <- 1                                                ❶
 (significance_cutoff <- k / nrow(score_frame))
# [1] 0.001831502
score_frame$selected <- score_frame$sig < significance_cutoff
suppressPackageStartupMessages(library("dplyr"))      ❷

score_frame %>%
  group_by(., code, selected) %>%
  summarize(.,
            count = n()) %>%
  ungroup(.) %>%
  cdata::pivot_to_rowrecs(.,
                          columnToTakeKeysFrom = 'selected',
                          columnToTakeValuesFrom = 'count',
                          rowKeyColumns = 'code',
                          sep = '=')

# # A tibble: 5 x 3
#   code  `selected=FALSE` `selected=TRUE`
#   <chr>            <int>           <int>
# 1 catB                12              21
# 2 catP                 7              26
# 3 clean              158              15
# 4 isBAD               60             111
# 5 lev                 74              62

❶ 使用我们的 k / nrow(score_frame)启发式方法进行过滤显著性,其中 k = 1

❷ 引入 dplyr 包以帮助总结选择

表格显示了对于每种转换后的变量类型,选择了多少个变量或被拒绝。特别是,请注意,几乎所有的clean类型变量(这是清理后的数值变量的代码)都被丢弃,因为它们不可用。这可能表明线性方法可能不足以解决这个问题,我们应该考虑使用非线性模型。在这种情况下,你可以使用value_variables_C()(它返回一个类似于得分框架的结构)来选择变量,并使用第十章中提到的先进的非线性机器学习方法。在本章中,我们专注于变量准备步骤,因此我们只构建一个线性模型,将尝试不同的建模技术作为读者的重要练习。14]

¹⁴

尽管我们在这里提供了一个工作的xgboost解决方案:github.com/WinVector/PDSwR2/blob/master/KDD2009/KDD2009vtreat.md

构建多变量模型

一旦我们的变量准备就绪,构建模型似乎相对直接。在这个例子中,我们将使用逻辑回归(第 7.2 节的主题)。拟合多变量模型的代码将在下一列表中给出。

列表 8.7. 基本变量重新编码和选择

library("wrapr")

newvars <- score_frame$varName[score_frame$selected]              ❶

f <- mk_formula("churn", newvars, outcome_target = 1)             ❷
model <- glm(f, data = dTrainAll_treated, family = binomial)      ❸
# Warning message:
# glm.fit: fitted probabilities numerically 0 or 1 occurred

❶ 构建一个公式,指定建模流失 == 1 是所有变量的函数

❷ 使用 R 的 glm()函数建模公式

❸ 注意这个警告:它暗示我们应该转向正则化方法,如 glmnet。

评估模型

现在我们有了模型,让我们在测试数据上评估它:

library("sigr")

dTest_treated$glm_pred <- predict(model,                                   ❶
                                  newdata = dTest_treated,
                                  type = 'response')
# Warning message:                                                         ❷
# In predict.lm(object, newdata, se.fit, scale = 1, type = ifelse(type ==  :
#   prediction from a rank-deficient fit may be misleading

calcAUC(dTest_treated$glm_pred, dTest_treated$churn == 1)                  ❸
## [1] 0.7232192

permTestAUC(dTest_treated, "glm_pred", "churn", yTarget = 1)               ❹
 ## [1] "AUC test alt. hyp. AUC>AUC(permuted): (AUC=0.7232, s.d.=0.01535, p<1
     e-05)."

var_aucs <- vapply(newvars,                                                ❺
        function(vi) {
        calcAUC(dTrainAll_treated[[vi]], dTrainAll_treated$churn == 1)
       }, numeric(1))
(best_train_aucs <- var_aucs[var_aucs >= max(var_aucs)])
## Var216_catB
##   0.5873512

❶ 将模型预测添加到评估数据作为新列

❷ 再次注意这个警告:它暗示我们应该转向正则化方法,如 glmnet。

❸ 在保留数据上计算模型的 AUC

❹ 使用另一种估计标准差或误差条的方法,第二次计算 AUC

❺ 在这里,我们计算最佳单变量模型 AUC 以进行比较。

模型的 AUC 为 0.72。这不如获胜者的 0.76(在不同的测试数据上),但比最佳输入变量作为单变量模型的 AUC(显示为 0.59)要好得多。请记住,perm-TestAUC()计算表明,对于这个大小的测试集,AUC 估计的标准差为 0.015。这意味着 AUC 的加减 0.015 的差异在统计学上并不显著。

将逻辑回归模型转换为分类器

如我们从模型分数的双密度图(图 8.8)中可以看出,该模型在区分流失账户和非流失账户方面只做了中等的工作。如果我们犯了一个错误,将这个模型用作硬分类器,其中所有预测流失倾向超过 50%的个体都被认为是风险,我们会看到以下糟糕的性能:

图 8.8. glm模型在测试数据上的分数分布

table(prediction = dTest_treated$glm_pred >= 0.5,
      truth = dTest$churn)
#           truth
# prediction   -1    1
#      FALSE 4591  375
#      TRUE     8    1

该模型仅识别出 9 个具有如此高概率的个人,其中只有 1 个流失。记住这是一个不平衡的分类问题;只有 7.6%的测试示例实际上流失。模型可以识别出处于较高流失风险的个体,而不是那些肯定会流失的个体。例如,如果我们要求模型找出预测流失风险是预期流失风险两倍的个体:

table(prediction = dTest_treated$glm_pred>0.15,
      truth = dTest$churn)
#           truth
# prediction   -1    1
#      FALSE 4243  266
#      TRUE   356  110

注意,在这种情况下,使用 0.15 作为我们的评分阈值,模型识别出 466 个潜在风险账户,其中 101 个实际上已经流失。因此,这个子集的流失率为 24%,大约是整体流失率的 3 倍。并且,该模型识别出 376 个流失者中的 110 个,即 29%。从商业角度来看,这个模型正在识别出占人口 10%的子群体,他们负责 29%的流失。这可能很有用。

在第 7.2.3 节中,我们看到了如何将召回率(检测到的流失者占流失者的比例)和富集或提升(在所选集中流失的频率有多高)之间的权衡关系表示为图表。图 8.9 显示了流失模型中召回率和富集作为阈值的函数的图。

图 8.9. glm召回率和富集作为阈值的函数

使用图 8.9 的一种方法是,在选择的 x 轴阈值处画一条垂直线,比如 0.2。然后这条垂直线与每条曲线交叉的高度告诉我们,如果我们把高于阈值的分数分类为阳性,我们会看到的同时丰富度和召回率。在这种情况下,我们的召回率大约为 0.12(意味着我们识别了大约 12%的受风险账户),丰富度大约为 3(意味着我们警告的群体账户取消率是普通群体的 3 倍,这表明这确实是一个高风险群体)。

生成这些图表的代码看起来像这样:

WVPlots::DoubleDensityPlot(dTest_treated, "glm_pred", "churn",
                           "glm prediction on test, double density plot")

WVPlots::PRTPlot(dTest_treated, "glm_pred", "churn",
                 "glm prediction on test, enrichment plot",
                 truthTarget = 1,
                 plotvars = c("enrichment", "recall"),
                 thresholdrange = c(0, 1.0))

现在我们已经使用vtreat解决了一个实质性的分类问题。

8.5. 为回归建模准备数据

为回归建模准备数据与为分类建模准备数据非常相似。我们不是调用designTreatmentsC()mkCrossFrameCExperiment(),而是调用designTreatmentsN()mkCrossFrameNExperiment()


示例

您希望根据汽车的其他事实(如重量和马力)预测每加仑英里数的汽车燃油经济性。


为了模拟这种情况,我们将使用来自 UCI 机器学习仓库的 Auto MPG 数据集。我们可以从auto_mpg/目录中的auto_mpg.RDS文件加载此数据(在下载此存储库后)。

auto_mpg <- readRDS('auto_mpg.RDS')

knitr::kable(head(auto_mpg))          ❶

❶ 快速查看数据。

看过图 8.10 中的数据后,让我们采取“闯入瓷器店的大象”的方法进行建模,直接调用lm()函数,而不检查或处理数据:

图 8.10. auto_mpg数据的前几行

library("wrapr")

vars <- c("cylinders", "displacement",                          ❶
          "horsepower", "weight", "acceleration",
          "model_year", "origin")
f <- mk_formula("mpg", vars)
model <- lm(f, data = auto_mpg)

auto_mpg$prediction <- predict(model, newdata = auto_mpg)       ❷

str(auto_mpg[!complete.cases(auto_mpg), , drop = FALSE])

# 'data.frame':    6 obs. of  10 variables:
#  $ mpg         : num  25 21 40.9 23.6 34.5 23
#  $ cylinders   : num  4 6 4 4 4 4
#  $ displacement: num  98 200 85 140 100 151
#  $ horsepower  : num  NA NA NA NA NA NA                       ❸
#  $ weight      : num  2046 2875 1835 2905 2320 ...
#  $ acceleration: num  19 17 17.3 14.3 15.8 20.5
#  $ model_year  : num  71 74 80 80 81 82
#  $ origin      : Factor w/ 3 levels "1","2","3": 1 1 2 1 2 1
#  $ car_name    : chr  "\"ford pinto\"" "\"ford maverick\"" "\"renault lecar
      deluxe\"" ...
#  $ prediction  : num  NA NA NA NA NA NA                       ❹

❶ 在不处理数据的情况下直接跳入建模。

❷ 添加模型预测作为新列

❸ 注意,这些汽车没有记录马力。

❹ 因此,这些汽车没有获得预测。

由于数据集有缺失值,模型无法为每一行返回预测。现在,我们将再次尝试,使用vtreat先处理数据:

library("vtreat")

cfe <- mkCrossFrameNExperiment(auto_mpg, vars, "mpg",     ❶
                                verbose = FALSE)
treatment_plan <- cfe$treatments
auto_mpg_treated <- cfe$crossFrame
score_frame <- treatment_plan$scoreFrame
new_vars <- score_frame$varName

newf <- mk_formula("mpg", new_vars)
new_model <- lm(newf, data = auto_mpg_treated)

auto_mpg$prediction <- predict(new_model, newdata = auto_mpg_treated)
# Warning in predict.lm(new_model, newdata = auto_mpg_treated): prediction
# from a rank-deficient fit may be misleading
str(auto_mpg[!complete.cases(auto_mpg), , drop = FALSE])
# 'data.frame':    6 obs. of  10 variables:
#  $ mpg         : num  25 21 40.9 23.6 34.5 23
#  $ cylinders   : num  4 6 4 4 4 4
#  $ displacement: num  98 200 85 140 100 151
#  $ horsepower  : num  NA NA NA NA NA NA
#  $ weight      : num  2046 2875 1835 2905 2320 ...
#  $ acceleration: num  19 17 17.3 14.3 15.8 20.5
#  $ model_year  : num  71 74 80 80 81 82
#  $ origin      : Factor w/ 3 levels "1","2","3": 1 1 2 1 2 1
#  $ car_name    : chr  "\"ford pinto\"" "\"ford maverick\"" "\"renault lecar deluxe\"" ...
#  $ prediction  : num  24.6 22.4 34.2 26.1 33.3 ...    ❷

❶ 再次尝试使用 vtreat 数据准备。

❷ 现在我们可以进行预测,即使是对有缺失数据的项。

现在,模型为每一行返回预测,包括那些有缺失数据的行。

8.6. 掌握 vtreat 包

现在我们已经看到了如何使用vtreat包,我们将花一些时间来回顾这个包为我们做了什么。这可以通过玩具大小的例子最容易地看到。

vtreat旨在为监督机器学习或预测建模准备数据。该包旨在帮助将一组输入或解释变量与单个要预测的输出或因变量相关联。

8.6.1. vtreat 阶段

如图 8.11 所示,vtreat在两个阶段工作:设计阶段和应用/准备阶段。在设计阶段,vtreat学习数据的细节。对于每个解释变量,它估计变量与结果之间的关系,因此解释变量和因变量都必须可用。在应用阶段,vtreat引入了从解释变量派生的新变量,但更适合简单的预测建模。转换后的数据都是数值型且没有缺失值.^([15]) R 本身有处理缺失值的方法,包括许多缺失值插补包.^([16]) R 还有一个将任意data.frame转换为数值数据的规范方法:model.matrix(),许多模型都使用它来接受任意数据。vtreat是专门为这些任务设计的工具,旨在为监督机器学习或预测建模任务提供非常好的效果。

¹⁵

记住:缺失值不是数据可能出错的所有情况,也不是vtreat唯一解决的问题。

¹⁶

查看cran.r-project.org/web/views/MissingData.html

图 8.11. vtreat的两个阶段

对于处理设计阶段,调用以下函数之一:

  • designTreatmentsC() 设计一个用于二元分类任务的变量处理计划。二元分类任务是我们想要预测一个示例是否属于给定类别,或者预测一个示例属于给定类别的概率。

  • designTreatmentsN() 设计一个用于回归任务的变量处理计划。回归任务是在给定示例数值结果的情况下预测数值结果。

  • designTreatmentsZ() 设计一个简单的变量处理计划,不查看训练数据的结果。此计划处理缺失值并将字符串重新编码为指示变量(独热编码),但不产生影响变量(这需要了解训练数据的结果)。

  • design_missingness_treatment() 设计一个非常简单的处理方案,仅处理缺失值,但不进行独热编码分类变量。相反,它将NA替换为标记"_invalid_"

  • mkCrossFrameCExperiment() 使用交叉验证技术准备用于分类的数据,这样设计变量处理所使用的数据可以安全地重新用于训练模型。

  • mkCrossFrameNExperiment() 使用交叉验证技术准备用于回归的数据,这样设计变量处理所使用的数据可以安全地重新用于训练模型。

对于应用或数据准备阶段,我们始终调用prepare()方法。

vtreat 包附带大量文档和示例,可以在 winvector.github.io/vtreat/ 找到。然而,除了了解如何操作包之外,数据科学家还必须知道他们使用的包为他们做了什么。因此,我们将在这里讨论 vtreat 实际上做了什么。

我们需要回顾的概念包括以下这些:

  • 缺失值

  • 指示变量

  • 影响编码

  • 治疗计划

  • 变量得分框架

  • 跨框架

这些概念很多,但它们是数据修复和准备的关键。我们将通过具体但微小的例子来保持具体性。可以在此处找到展示这些方法性能的更大例子:arxiv.org/abs/1611.09477

8.6.2. 缺失值

正如我们之前讨论的,R 有一个特殊的代码用于缺失、未知或不可用的值:NA。许多建模过程不接受带有缺失值的数据,因此如果出现缺失值,我们必须采取一些措施。常见的策略包括以下这些:

  • 限制为“完整案例”— 只使用没有缺失值的列的数据行。这对于模型训练可能是个问题,因为完整案例可能分布不均或不具有代表性,实际数据集。此外,这种策略也无法很好地说明如何评分具有缺失值的新数据。有一些关于如何重新加权数据使其更具代表性的理论,但我们不鼓励这些方法。

  • 缺失值插补— 这些是使用非缺失值来推断或插补缺失值(或值的分布)的方法。可以在 cran.r-project.org/web/views/MissingData.html 找到专门针对这些方法的 R 任务视图。

  • 使用可以容忍缺失值的模型— 一些决策树或随机森林的实现可以容忍缺失值。

  • 将缺失性视为可观察信息— 用替代信息替换缺失值。

vtreat 提供了将缺失性视为可观察信息的最后一种方法的实现,因为这很容易做到,并且非常适合监督机器学习或预测建模。这个想法很简单:缺失值被替换为某个替代值(可以是零,也可以是非缺失值的平均值),并添加一个额外的列来指示这种替换已经发生。这个额外的列给任何建模步骤提供了额外的自由度,或者说是将插补值与未插补值分开处理的能力。

以下是一个简单示例,展示了变换的添加:

library("wrapr")                                  ❶

d <- build_frame(
   "x1"    , "x2"         , "x3", "y" |
   1       , "a"          , 6   , 10  |
   NA_real_, "b"          , 7   , 20  |
   3       , NA_character_, 8   , 30  )

knitr::kable(d)

plan1 <- vtreat::design_missingness_treatment(d)
vtreat::prepare(plan1, d) %.>%                    ❷
    knitr::kable(.)

❶ 引入 wrapr 包用于 build_frame 和 wrapr 的“点管道”

❷ 使用 wrapr 的点管道而不是 magrittr 的前向管道。点管道需要显式点参数符号,这在第五章中讨论过。

注意到在图 8.12 中x1列有缺失值,并且在图 8.13 中用已知值的平均值替换了该值。处理后的或准备好的数据(见图 8.13)还有一个新列,x1_isBAD,表示x1被替换的位置。最后,注意对于字符串值列x2NA值被替换为一个特殊的级别代码。

图 8.12. 我们简单的示例数据:原始的

图 8.13. 我们简单的示例数据:处理后的

8.6.3. 指示变量

许多统计和机器学习程序都期望所有变量都是数值型的。一些 R 用户可能没有意识到这一点,因为许多 R 模型实现都在幕后调用model.matrix()将任意数据转换为数值数据。对于现实世界的项目,我们建议使用更可控的显式转换,例如vtreat.^([17])

¹⁷

然而,在这本书中,为了教学目的,我们将尽量减少每个例子中不必要的准备步骤,因为这些步骤不是讨论的主题。

这种转换有多个名称,包括指示变量、虚拟变量和独热编码。其思路是这样的:对于字符串值变量的每个可能值,我们创建一个新的数据列。当字符串值变量具有与列标签匹配的值时,我们将这些新列中的每个设置为1,否则为0。以下示例中可以很容易地看到这一点:

d <- build_frame(
   "x1"    , "x2"         , "x3", "y" |
   1       , "a"          , 6   , 10  |
   NA_real_, "b"          , 7   , 20  |
   3       , NA_character_, 8   , 30  )

print(d)
#   x1   x2 x3  y
# 1  1    a  6 10
# 2 NA    b  7 20                                        ❶
# 3  3 <NA>  8 30
plan2 <- vtreat::designTreatmentsZ(d,
                                   varlist = c("x1", "x2", "x3"),
                                   verbose = FALSE)
vtreat::prepare(plan2, d)
#   x1 x1_isBAD x3 x2_lev_NA x2_lev_x_a x2_lev_x_b
# 1  1        0  6         0          1          0
# 2  2        1  7         0          0          1       ❷
# 3  3        0  8         1          0          0

❶ x2 的第二个值是 b。

❷ 在处理数据的第二行中,x2_lev_x_b = 1。

注意到在第二个准备好的数据行中x2_lev_x_b1。这就是转换后的数据如何保留x2变量在这个行中原本的值为b的信息。

正如我们在第七章中关于lm()glm()的讨论中看到的,在传统统计实践中,实际上并不为字符串值变量的一个可能级别保留一个新列。这个级别被称为参考级别。我们可以识别字符串值变量等于参考级别的行,因为在这样的行中,所有其他级别的列都是零(其他行在级别列中恰好有一个1)。对于一般的监督学习,特别是对于正则化回归等高级技术,我们建议编码所有级别,正如这里所看到的。

8.6.4. 影响编码

影响编码是一个经常在不同名称下被重新发现的好主意(效果编码、影响编码,以及最近更常见的目标编码).^([18])

¹⁸

我们能找到的关于影响编码的最早讨论是 Robert E. Sweeney 和 Edwin F. Ulveling 的“用于简化回归分析中二元变量系数解释的转换。” 《美国统计学家》,第 26 卷(5),30–32,1972 年。我们,即作者,已经产生了研究并在 R 和 Kaggle 用户中推广了该方法,并添加了类似于“堆叠”方法的关键交叉验证方法arxiv.org/abs/1611.09477

当一个字符串类型的变量有数千个可能的值或级别时,为每个可能的级别生成一个新的指标列会导致数据极度膨胀和过拟合(如果模型拟合器在这种情况下甚至可以收敛)。因此,我们使用一个影响代码:将级别代码替换为其作为单一变量模型的效果。这就是我们在 KDD2009 信用账户取消示例中产生catB类型派生变量的原因,以及在回归情况下产生catN风格变量的原因。

让我们看看一个简单的数值预测或回归示例的影响:

d <- build_frame(
   "x1"    , "x2"         , "x3", "y" |
   1       , "a"          , 6   , 10  |
   NA_real_, "b"          , 7   , 20  |
   3       , NA_character_, 8   , 30  )

print(d)
#   x1   x2 x3  y
# 1  1    a  6 10
# 2 NA    b  7 20
# 3  3 <NA>  8 30
plan3 <- vtreat::designTreatmentsN(d,
                                   varlist = c("x1", "x2", "x3"),
                                   outcomename = "y",
                                   codeRestriction = "catN",
                                   verbose = FALSE)
vtreat::prepare(plan3, d)
#   x2_catN  y
# 1     -10 10
# 2       0 20
# 3      10 30

影响编码的变量位于名为x2_catN的新列中。注意,在第一行它为-10,因为y的值是10,这比y的平均值低 10。这种“条件 delta 从均值”的编码就是“影响代码”或“效果代码”名称的由来。

对于分类变量的影响编码类似,只是它们是以对数单位表示的,就像第 8.3.1 节中的逻辑回归一样。在这种情况下,对于如此小的数据,x2_catB的初始值在第一行和第三行将是负无穷大,在第二行是正无穷大(因为x2级别值完美地预测或区分y == 20的情况)。我们看到接近正负10的值是由于一个重要的调整,称为平滑,它说在计算条件概率时,为了更安全的计算,添加一点偏向“无效果”的偏差.^([19]) 下一个示例将展示如何使用vtreat为可能的分类任务准备数据:

¹⁹

关于平滑的参考资料可以在这里找到:en.wikipedia.org/wiki/Additive_smoothing

plan4 <- vtreat::designTreatmentsC(d,
                                   varlist = c("x1", "x2", "x3"),
                                   outcomename = "y",
                                   outcometarget = 20,
                                   codeRestriction = "catB",
                                   verbose = FALSE)
vtreat::prepare(plan4, d)
#     x2_catB  y
# 1 -8.517343 10
# 2  9.903538 20
# 3 -8.517343 30

平滑

平滑是一种防止在小数据上出现一定程度过拟合和胡说八道答案的方法。平滑的想法是试图遵守 Cromwell’s rule,即在任何经验概率推理中不应使用零概率估计。这是因为如果你通过乘法(结合概率估计的最常见方法)组合概率,那么一旦某个项为 0,整个估计将变为 0 无论其他项的值如何。最常见形式的平滑称为 Laplace 平滑,它将 n 次试验中的 k 次成功计为一个成功比率 (k+1)/(n+1),而不是 k/n 的比率(防御 k=0 的情况)。频率派统计学家认为平滑是一种正则化的形式,而贝叶斯派统计学家认为平滑是从先验的角度来看。


8.6.5. 治疗方案

治疗方案指定了在用其拟合模型之前如何处理训练数据,以及在使用模型之前如何处理新数据。它直接由 design*() 方法返回。对于 mkExperiment*() 方法,治疗方案是返回结果中键为 treatments 的项目。以下代码显示了治疗方案的结构:

class(plan4)
# [1] "treatmentplan"

names(plan4)

# [1] "treatments"  "scoreFrame"  "outcomename" "vtreatVersion" "outcomeType"  

# [6] "outcomeTarget" "meanY"         "splitmethod"

变量分数框架

所有治疗方案中都包含的一个重要项目是分数框架。可以从治疗方案中提取出来,如下所示(继续我们之前的例子):

plan4$scoreFrame

#   varName varMoves rsq   sig needsSplit extraModelDegrees origName code
# 1 x2_catB   TRUE   0.0506719  TRUE   2  x2 catB

分数框架是一个每行一个派生解释变量的 data.frame。每一行显示派生变量是从哪个原始变量派生出来的 (orig-Name),用于生成派生变量的转换类型 (code),以及关于变量的某些质量摘要。例如,needsSplit 是一个指示符,当 TRUE 时,表示变量复杂且需要交叉验证评分,这正是 vtreat 生成变量质量估计的方法。

8.6.6. 交叉框架

vtreat 的一个关键创新是 交叉框架。交叉框架是 mkCrossFrame*Experiment() 方法返回的对象列表中的一个项目。这是一个允许安全地使用相同数据既用于变量处理的设计又用于训练模型的创新。如果没有这种交叉验证方法,你必须保留一部分训练数据来构建变量处理计划,以及一个不重叠的训练数据集来拟合处理后的数据。否则,复合系统(数据准备加模型应用)可能会受到严重的嵌套模型偏差的影响:产生一个在训练数据上看起来很好的模型,但后来在测试或应用数据上失败。

无知地重复使用数据的风险

这里有一个例子。假设我们从一个例子数据开始,其中实际上 xy 之间没有关系。在这种情况下,我们知道我们认为它们之间存在的任何关系只是我们程序的一个产物,实际上并不存在。

列表 8.8. 一个无信息数据集

set.seed(2019)                                               ❶

d <- data.frame(                                             ❷
  x_bad = sample(letters, 100, replace = TRUE),
  y = rnorm(100),
  stringsAsFactors = FALSE
)
d$x_good <- ifelse(d$y > rnorm(100), "non-neg", "neg")       ❸

head(d)                                                      ❹
#   x_bad           y  x_good
# 1     u -0.05294738 non-neg
# 2     s -0.23639840     neg
# 3     h -0.33796351 non-neg
# 4     q -0.75548467 non-neg
# 5     b -0.86159347     neg
# 6     b -0.52766549 non-neg

❶ 设置伪随机数生成器种子以使示例可重复

❷ 构建一个示例数据,其中 x_bad 和 y 之间没有关系

❸ x_good 是 y 符号的嘈杂预测,因此它确实有关于 y 的信息。

❹ 查看我们的合成示例数据。其思路是这样的:y 与 x_good 之间存在一种嘈杂的关系,但与 x_bad 无关。在这种情况下,我们知道应该选择哪些变量,因此我们可以判断我们的接受程序是否工作正确。

我们天真地使用训练数据来创建治疗方案,然后在拟合模型之前准备相同的数据。

列表 8.9. 重复使用数据的危险

plan5 <- vtreat::designTreatmentsN(d,                                     ❶
                                   varlist = c("x_bad", "x_good"),
                                   outcomename = "y",
                                   codeRestriction = "catN",
                                   minFraction = 2,
                                   verbose = FALSE)
class(plan5)
# [1] "treatmentplan"

print(plan5)                                                              ❷
#   origName     varName code          rsq          sig extraModelDegrees

# 1    x_bad  x_bad_catN catN 4.906903e-05 9.448548e-01                24
# 2   x_good x_good_catN catN 2.602702e-01 5.895285e-08                 1

training_data1 <- vtreat::prepare(plan5, d)                               ❸

res1 <- vtreat::patch_columns_into_frame(d, training_data1)               ❹
 head(res1)
#   x_bad  x_good x_bad_catN x_good_catN           y
# 1     u non-neg  0.4070979   0.4305195 -0.05294738
# 2     s     neg -0.1133011  -0.5706886 -0.23639840
# 3     h non-neg -0.3202346   0.4305195 -0.33796351
# 4     q non-neg -0.5447443   0.4305195 -0.75548467
# 5     b     neg -0.3890076  -0.5706886 -0.86159347
# 6     b non-neg -0.3890076   0.4305195 -0.52766549

sigr::wrapFTest(res1, "x_good_catN", "y")                                 ❺
# [1] "F Test summary: (R2=0.2717, F(1,98)=36.56, p<1e-05)."

sigr::wrapFTest(res1, "x_bad_catN", "y")                                  ❻
# [1] "F Test summary: (R2=0.2342, F(1,98)=29.97, p<1e-05)."

❶ 使用 x_bad 和 x_good 设计变量处理计划以预测 y

❷ 注意到派生变量 x_good_catN 显示出显著的信号,而 x_bad_catN 则没有。这是由于在 vtreat 质量估计中正确使用了交叉验证。

❸ 在设计治疗方案所用的相同数据上调用 prepare()——这并不总是安全的,正如我们将看到的。

❹ 使用 training_data1 当存在重复列名时将数据帧 d 和 training_data1 合并

❺ 使用统计 F-test 检查 x_good_catN 的预测能力

❻ x_bad_catN 的 F-test 被夸大,并看起来是显著的。这是由于未能使用交叉验证方法。

在这个例子中,注意 sigr F-test 报告了 x_bad_catN 和结果变量 y 之间的 R-squared 为 0.23。这是检查解释变异分数(本身称为 R-squared)是否在统计上不显著(在纯机会下常见)的技术术语。因此,我们希望真正的 R-squared 高(接近 1),真正的 F-test 显著性低(接近零)对于好变量。我们也期望真正的 R-squared 低(接近 0),真正的 F-test 显著性非零(不接近零)对于坏变量。

然而,请注意,好变量和坏变量都得到了良好的评估!这是一个错误,发生是因为我们正在测试的变量 x_good_catNx_bad_catN 都是高基数字符串值变量的影响代码。当我们在这组数据上测试这些变量时,我们会过度拟合,这错误地夸大了我们的变量质量估计。在这种情况下,大量的 表面 质量实际上只是变量复杂性的度量(或过度拟合的能力)。

还要注意,在得分框架中报告的 R-squared 和显著性正确地表明 x_bad_catN 不是一个高质量的变量(R-squared 接近零,显著性不接近零)。这是因为得分框架使用交叉验证来估计变量显著性。这很重要,因为涉及多个变量的建模过程可能会因为 x_bad_catN 的过度拟合夸大的质量分数而选择 x_bad_catN 而不是其他实际有用的变量。

如前几节所述,解决过拟合的方法是将我们训练数据的一部分用于designTreatments*()步骤,而将另一部分不重叠的训练数据用于变量使用或评估(例如sigr::wrapFTest()步骤)。

安全重用数据的交叉框架

另一种实现方式,允许我们同时使用所有训练数据来设计变量处理计划和模型拟合,被称为交叉框架方法。这是vtreatmkCrossFrame*Experiment()方法中内置的特殊交叉验证方法。在这种情况下,我们只需调用mkCrossFrameNExperiment()而不是designTreatmentsN,并从返回的列表对象的crossFrame元素中获取准备好的训练数据(而不是调用prepare())。对于未来的测试或应用数据,我们确实从处理计划(作为返回列表对象的treatments项返回)中调用prepare(),但对于训练我们则不调用prepare()

代码如下。

列表 8.10. 使用mkCrossFrameNExperiment()

cfe <- vtreat::mkCrossFrameNExperiment(d,
                                       varlist = c("x_bad", "x_good"),
                                       outcomename = "y",
                                       codeRestriction = "catN",
                                       minFraction = 2,
                                       verbose = FALSE)
plan6 <- cfe$treatments

training_data2 <- cfe$crossFrame
res2 <- vtreat::patch_columns_into_frame(d, training_data2)

head(res2)
#   x_bad  x_good x_bad_catN x_good_catN           y
# 1     u non-neg  0.2834739   0.4193180 -0.05294738
# 2     s     neg -0.1085887  -0.6212118 -0.23639840
# 3     h non-neg  0.0000000   0.5095586 -0.33796351
# 4     q non-neg -0.5142570   0.5095586 -0.75548467
# 5     b     neg -0.3540889  -0.6212118 -0.86159347
# 6     b non-neg -0.3540889   0.4193180 -0.52766549

sigr::wrapFTest(res2, "x_bad_catN", "y")
# [1] "F Test summary: (R2=-0.1389, F(1,98)=-11.95, p=n.s.)."

sigr::wrapFTest(res2, "x_good_catN", "y")
# [1] "F Test summary: (R2=0.2532, F(1,98)=33.22, p<1e-05)."
plan6$scoreFrame                                              ❶
 #       varName varMoves        rsq          sig needsSplit
# 1  x_bad_catN     TRUE 0.01436145 2.349865e-01       TRUE
# 2 x_good_catN     TRUE 0.26478467 4.332649e-08       TRUE
#   extraModelDegrees origName code
# 1                24    x_bad catN
# 2                 1   x_good catN

❶ 数据和 scoreFrame 统计的 F 测试现在在很大程度上是一致的。

注意现在sigr::wrapFTest()正确地将x_bad_catN视为低值变量。此方案也能正确评分变量,这意味着我们可以区分好坏。我们可以使用交叉框架的training_data2来拟合模型,并从变量处理中获得良好的保护,以防止过拟合。

嵌套模型偏差

将一个模型的输出作为另一个模型的输入导致的过拟合称为嵌套模型偏差。使用vtreat时,这可能是一个影响代码的问题,因为影响代码本身也是模型。对于不查看结果的数据处理,如design_ missingness_treatment()designTreatmentsZ(),可以使用相同的数据来设计处理计划并拟合模型。然而,当数据处理使用结果时,我们建议进行额外的数据拆分或使用第 8.4.1 节中的mkCrossFrame*Experiment()/$crossFrame模式。

vtreat使用交叉验证程序来创建交叉框架。有关详细信息,请参阅winvector.github.io/vtreat/articles/vtreatCrossFrames.html


designTreatments*()mkCrossFrame*Experiment()

对于较大的数据集,使用训练数据的三分法以及designTreatments*()/prepare()模式来设计处理计划、拟合模型和评估更容易。对于看起来太小而无法三分的数据集(特别是具有非常大量变量的数据集),使用mkCrossFrame*Experiment()/prepare()模式可能会得到更好的模型。


摘要

现实世界的数据通常很杂乱。未经检查和处理的原始数据可能会使你的建模或预测步骤失败,或者可能给出不良的结果。“修复”数据并不与拥有更好的数据相竞争。但能够处理你拥有的数据(而不是你希望拥有的数据)是一种优势。

除了许多特定领域或特定问题的领域特定问题之外,你可能会发现,在你的数据中,存在许多应该被预见并系统处理的常见问题。vtreat是一个专门用于为监督机器学习或预测建模任务准备数据的包。它还可以通过其可引用的文档来减少你的项目文档需求^([20])。然而,请记住,工具不是避免查看数据的借口。

²⁰

查看arxiv.org/abs/1611.09477

在本章中,你学习了

  • 如何使用vtreat包的designTreatments*()/prepare()模式,通过将训练数据分为三部分来准备用于模型拟合和模型应用的前处理数据

  • 如何使用vtreat包的mkCrossFrame*Experiment()/prepare()模式,通过将训练数据分为两部分来准备用于模型拟合和模型应用的前处理数据,尤其是在统计效率很重要的情况下

第九章. 无监督方法

本章涵盖

  • 使用 R 的聚类函数探索数据和寻找相似性

  • 选择合适的聚类数量

  • 评估一个聚类

  • 使用 R 的关联规则函数来发现数据中的共现模式

  • 评估一组关联规则

在上一章中,我们介绍了使用vtreat包来准备用于建模的杂乱的真实世界数据。在本章中,我们将探讨发现数据中未知关系的方法。这些方法被称为无监督方法。使用无监督方法时,没有你试图预测的结果;相反,你希望发现数据中的模式,这些模式可能是你之前未曾怀疑的。例如,你可能希望找到具有相似购买模式的客户群体,或者人口流动与社会经济因素之间的相关性。我们仍将这种模式发现视为“建模”,因此算法的结果仍然可以评估,如本章的心理模型所示(图 9.1)。

图 9.1. 心理模型

图片

无监督分析通常不是目的本身;相反,它们是寻找可以用于构建预测模型的关系和模式的方法。事实上,我们鼓励您将无监督方法视为探索性的——帮助您接触数据的程序——而不是神秘且自动给出“正确答案”的黑盒方法。

在本章中,我们将探讨两类无监督方法:

  • 聚类分析寻找具有相似特性的群体。

  • 关联规则挖掘寻找数据中倾向于一起出现的元素或属性。

9.1. 聚类分析

在聚类分析中,目标是把你的数据中的观测值分组到聚类中,使得每个聚类中的数据点与其他聚类中的数据点相比,更相似。例如,一家提供导游服务的公司可能希望根据行为和口味对其客户进行聚类:他们喜欢访问哪些国家;他们是否更喜欢冒险之旅、奢华之旅还是教育之旅;他们参加的活动类型;以及他们喜欢访问的场所类型。此类信息可以帮助公司设计吸引人的旅游套餐,并针对客户群中的适当细分市场进行定位。

聚类分析是一个值得单独成书的话题;在本章中,我们将讨论两种方法。层次聚类找到嵌套的聚类群体。层次聚类的例子可能是标准的植物分类学,它按家族、属、种等分类植物。我们将探讨的第二种方法是k-means,这是一种快速且流行的在定量数据中寻找聚类的方法。


聚类和密度估计

从历史上看,聚类分析与密度估计问题相关:如果你认为你的数据存在于一个高维空间中,那么你想要找到数据最密集的区域。如果这些区域是不同的,或者几乎是不同的,那么你就有了聚类。


9.1.1. 距离

为了进行聚类,你需要相似性和不相似性的概念。不相似性可以被视为距离,这样聚类中的点比其他聚类中的点更接近。这如图 9.2 所示。

图 9.2. 三簇数据的示例

不同的应用领域会有不同的距离和相似度概念。在本节中,我们将介绍其中的一些最常见概念:

  • 欧几里得距离

  • 汉明距离

  • 曼哈顿(城市街区)距离

  • 余弦相似度

欧几里得距离


示例

假设你测量了每天主体在不同活动上花费的分钟数,并且你想根据他们的活动模式对主体进行分组。


由于你的测量是数值和连续的,欧几里得距离 是用于聚类的良好选择。人们通常在想到“距离”时想到的就是欧几里得距离。优化平方欧几里得距离是 k-means 的基础。当然,欧几里得距离只有在所有数据都是实值(定量)时才有意义。如果数据是分类的(特别是二元的),则应使用其他距离。

两个向量 xy 之间的欧几里得距离定义为

edist(x, y) <- sqrt((x[1] - y[1])² + (x[2] - y[2])² + ...)

汉明距离


示例

假设你想要将你的食谱盒分组为相似食谱的组。一种方法是测量它们的成分列表的相似度。


通过这个度量,煎饼、华夫饼和可丽饼非常相似(它们的成分几乎相同,只是比例不同);它们都与玉米面包(使用玉米粉而不是面粉)有所不同;并且它们与土豆泥的差异更大。

对于像食谱成分、性别(男性/女性)或定性大小(//)这样的分类变量,你可以定义当两个点属于同一类别时距离为 0,否则为 1。如果所有变量都是分类的,则可以使用 汉明距离,它计算不匹配的数量:

hdist(x, y) <- sum((x[1] != y[1]) + (x[2] != y[2]) + ...)

在这里,a != b 被定义为当表达式为真时取值为 1,当表达式为假时取值为 0

你还可以将分类变量扩展为指示变量(如我们在 7.1.4 节 中讨论的),每个变量一个。

如果类别是有序的(如//),使得某些类别比其他类别“更接近”,那么您可以将其转换为数值序列。例如,(//)可能映射到(1/2/3)。然后您可以使用欧几里得距离或其他距离来处理定量数据。

曼哈顿(城市街区)距离


示例

假设您经营一家为市中心企业提供服务的快递公司。您想要将客户聚类,以便在每个聚类中放置位于中心位置的取件/投递箱。


曼哈顿距离通过水平单位和垂直单位数来衡量从一个(实值)点到另一个点(没有对角线移动)的距离。这也被称为L1 距离(而平方欧几里得距离是L2 距离)。

在这个例子中,曼哈顿距离更为合适,因为您想要通过人们沿街道行走的距离来衡量距离,而不是通过对角线点对点(欧几里得距离)。例如,在图 9.3 中,客户 A 位于场地北面 2 个街区,西面 2 个街区,而客户 B 位于场地南面 3 个街区,东面 1 个街区。他们通过曼哈顿距离与场地距离相等(4 个街区)。但是,客户 B 通过欧几里得距离更远:3x1 矩形的对角线比 2x2 正方形的对角线长。

图 9.3. 曼哈顿距离与欧几里得距离

两个向量xy之间的曼哈顿距离定义为

mdist(x, y) <- sum(abs(x[1] - y[1]) + abs(x[2] - y[2]) + ...)

余弦相似度


示例

假设您将文档表示为文档-文本矩阵的行,就像我们在第 6.3.3 节中所做的那样,其中行向量中的每个元素 i 给出了单词 i 在文档中出现的次数。然后两个行向量之间的余弦相似度是相应文档相似度的度量。


余弦相似度是文本分析中常用的相似度度量。它衡量两个向量之间的最小角度。在我们的文本示例中,我们假设非负向量,因此两个向量之间的角度theta在 0 到 90 度之间。余弦相似度如图 9.4 所示。

图 9.4. 余弦相似度

两个垂直向量(theta = 90 度)是最不相似的;90 度的余弦值为 0。两个平行向量是最相似的(如果假设它们都基于原点,则相同);0 度的余弦值为 1。

从初等几何中,您可以推导出两个向量之间角度的余弦值由两个向量的归一化点积给出:

dot(x, y) <- sum(x[1] * y[1] + x[2] * y[2] + ...)
cossim(x, y) <- dot(x, y) / (sqrt(dot(x,x) * dot(y, y)))

您可以通过从 1.0 中减去余弦相似度将其转换为伪距离(尽管要得到一个实际度量,您应该使用1 - 2 * acos(cossim(x, y)) / pi)。

不同的距离度量会给出不同的聚类,不同的聚类算法也会如此。应用领域可能会给你一个关于最合适的距离的提示,或者你可以尝试几种距离度量。在本章中,我们将使用(平方)欧几里得距离,因为它是最自然的定量数据距离。

9.1.2. 准备数据

为了展示聚类,我们将使用 1973 年关于欧洲 25 个国家九个不同食物组蛋白质消费的小型数据集.^([1]) 目标是根据各国蛋白质消费的模式对国家进行分组。该数据集被加载到 R 中,作为一个名为protein的数据框,如下所示。

¹

原始数据集来自数据与故事库,之前托管在 CMU。它不再在线上。可以找到带有数据的制表符分隔的文本文件github.com/WinVector/PDSwR2/tree/master/Protein/。数据文件名为 protein.txt;更多信息可以在文件 protein_README.txt 中找到。

列表 9.1. 读取蛋白质数据

protein <- read.table("protein.txt", sep = "\t", header=TRUE)
summary(protein)
##            Country      RedMeat         WhiteMeat           Eggs
##  Albania       : 1   Min.   : 4.400   Min.   : 1.400   Min.   :0.500
##  Austria       : 1   1st Qu.: 7.800   1st Qu.: 4.900   1st Qu.:2.700
##  Belgium       : 1   Median : 9.500   Median : 7.800   Median :2.900
##  Bulgaria      : 1   Mean   : 9.828   Mean   : 7.896   Mean   :2.936
##  Czechoslovakia: 1   3rd Qu.:10.600   3rd Qu.:10.800   3rd Qu.:3.700
##  Denmark       : 1   Max.   :18.000   Max.   :14.000   Max.   :4.700
##  (Other)       :19
##       Milk            Fish           Cereals          Starch
##  Min.   : 4.90   Min.   : 0.200   Min.   :18.60   Min.   :0.600
##  1st Qu.:11.10   1st Qu.: 2.100   1st Qu.:24.30   1st Qu.:3.100
##  Median :17.60   Median : 3.400   Median :28.00   Median :4.700
##  Mean   :17.11   Mean   : 4.284   Mean   :32.25   Mean   :4.276
##  3rd Qu.:23.30   3rd Qu.: 5.800   3rd Qu.:40.10   3rd Qu.:5.700
##  Max.   :33.70   Max.   :14.200   Max.   :56.70   Max.   :6.500
##
##       Nuts           Fr.Veg
##  Min.   :0.700   Min.   :1.400
##  1st Qu.:1.500   1st Qu.:2.900
##  Median :2.400   Median :3.800
##  Mean   :3.072   Mean   :4.136
##  3rd Qu.:4.700   3rd Qu.:4.900
##  Max.   :7.800   Max.   :7.900

单位和缩放

该数据集的文档没有提及测量单位是什么;我们将假设所有列都使用相同的单位进行测量。这很重要:单位(或者更精确地说,单位差异)会影响算法会发现哪些聚类。如果你将你的受试者的生命体征测量为年龄(年)、身高(英尺)和体重(磅),你将得到不同的距离——可能还有不同的聚类——如果你将年龄测量为年、身高为米和体重为千克。

理想情况下,你希望每个坐标的变化单位代表相同程度的不同。在protein数据集中,我们假设所有测量都在相同的单位下进行,所以看起来我们可能没问题。这很可能是一个正确的假设,但不同的食物组提供的蛋白质含量不同。一般来说,基于动物的食物来源每份蛋白质的克数比基于植物的食物来源多,因此有人可能会认为在蛋白质摄入量上增加五克,在蔬菜消费方面比在红肉消费方面有更大的差异。

尝试使每个变量的单位更兼容的一种方法是将所有列转换为单位均值为 0、标准差为 1。这使得标准差成为每个坐标的测量单位。假设你的训练数据具有准确代表总体的大规模分布,那么标准差在每一个坐标上表示大约相同程度的不同。

你可以使用 R 中的scale()函数缩放数值数据。scale()的输出是一个矩阵。为了本章的目的,你可以主要将矩阵视为一个包含所有数值列的数据框(这并不完全正确,但足够接近)。

scale() 函数使用两个属性注释其输出——scaled:center 返回所有列的均值,而 scaled:scale 返回标准差。您将保存这些值,以便稍后“取消缩放”数据。

列表 9.2. 重新缩放数据集

vars_to_use <- colnames(protein)[-1]           ❶
 pmatrix <- scale(protein[, vars_to_use])
pcenter <- attr(pmatrix, "scaled:center")      ❷
 pscale <- attr(pmatrix, "scaled:scale")

rm_scales <- function(scaled_matrix) {         ❸
  attr(scaled_matrix, "scaled:center") <- NULL
  attr(scaled_matrix, "scaled:scale") <- NULL
  scaled_matrix
}

pmatrix <- rm_scales(pmatrix)                  ❹

❶ 使用除第一个(国家)之外的所有列

❷ 存储缩放属性

❸ 用于从缩放矩阵中删除缩放属性的便利函数。

❹ 为安全起见,将缩放属性置为零

图 9.5 展示了缩放对两个变量 Fr.VegRedMeat 的影响。原始(未缩放)变量有不同的范围,反映了通过红肉提供的蛋白质量往往高于通过水果和蔬菜提供的蛋白质量。缩放后的变量现在具有相似的范围,这使得比较每个变量的相对变化更容易。

图 9.5. Fr.VegRedMeat 变量的比较,未缩放(顶部)和缩放(底部)

图片

现在您已经准备好对蛋白质数据进行聚类。我们将从层次聚类开始。

9.1.3. 使用 hclust 进行层次聚类

hclust() 函数接受一个距离矩阵(作为 dist 类的对象)作为输入,该矩阵记录了数据中所有点对之间的距离(使用任何一种方法)。您可以使用 dist() 函数计算距离矩阵。

dist() 将使用(平方)欧几里得距离 (method = "euclidean")、曼哈顿距离 (method = "manhattan") 以及当分类变量扩展为指示符时类似汉明距离的方法来计算距离函数。如果您想使用其他距离度量,您必须计算适当的距离矩阵,并使用 as.dist() 调用将其转换为 dist 对象(有关更多详细信息,请参阅 help(dist))。

hclust() 也使用多种聚类方法之一来生成一棵树,该树记录了嵌套的聚类结构。我们将使用沃德方法,该方法最初将每个数据点作为一个单独的聚类,并通过迭代合并聚类以最小化聚类的总 内部平方和 (WSS)(我们将在本章后面更详细地解释 WSS)。

让我们对蛋白质数据进行聚类。

列表 9.3. 层次聚类

distmat <- dist(pmatrix, method = "euclidean")   ❶
pfit <- hclust(distmat, method = "ward.D")       ❷
plot(pfit, labels = protein$Country)             ❸

❶ 创建距离矩阵

❷ 进行聚类

❸ 绘制树状图

hclust() 返回一个 树状图:表示嵌套聚类的树。蛋白质数据的树状图显示在 图 9.6 中。如果树中的叶子之间存在路径,则它们位于同一聚类中。通过在某个深度切割树,您将断开一些路径,从而创建更多、更小的聚类。

图 9.6. 蛋白质消费聚类国家的树状图

图片

这棵树状图表明五个集群可能是一个合适的数量,如图 9.6 所示。figure 9.6。您可以使用 rect.hclust() 函数在树状图上绘制矩形:

rect.hclust(pfit, k=5)

要从 hclust 对象中提取每个集群的成员,请使用 cutree()

列表 9.4. 提取 hclust() 找到的集群

groups <- cutree(pfit, k = 5)

print_clusters <- function(data, groups, columns) {     ❶
   groupedD <- split(data, groups)
   lapply(groupedD,
         function(df) df[, columns])
}

cols_to_print <- wrapr::qc(Country, RedMeat, Fish, Fr.Veg)
print_clusters(protein, groups, cols_to_print)

## $`1`
##       Country RedMeat Fish Fr.Veg
## 1     Albania    10.1  0.2    1.7
## 4    Bulgaria     7.8  1.2    4.2
## 18    Romania     6.2  1.0    2.8
## 25 Yugoslavia     4.4  0.6    3.2
##
## $`2`
##        Country RedMeat Fish Fr.Veg
## 2      Austria     8.9  2.1    4.3
## 3      Belgium    13.5  4.5    4.0
## 9       France    18.0  5.7    6.5
## 12     Ireland    13.9  2.2    2.9
## 14 Netherlands     9.5  2.5    3.7
## 21 Switzerland    13.1  2.3    4.9
## 22          UK    17.4  4.3    3.3
## 24   W Germany    11.4  3.4    3.8
##
## $`3`
##           Country RedMeat Fish Fr.Veg
## 5  Czechoslovakia     9.7  2.0    4.0
## 7       E Germany     8.4  5.4    3.6
## 11        Hungary     5.3  0.3    4.2
## 16         Poland     6.9  3.0    6.6
## 23           USSR     9.3  3.0    2.9
##
## $`4`
##    Country RedMeat Fish Fr.Veg
## 6  Denmark    10.6  9.9    2.4
## 8  Finland     9.5  5.8    1.4
## 15  Norway     9.4  9.7    2.7
## 20  Sweden     9.9  7.5    2.0
##
## $`5`
##     Country RedMeat Fish Fr.Veg
## 10   Greece    10.2  5.9    6.5
## 13    Italy     9.0  3.4    6.7
## 17 Portugal     6.2 14.2    7.9
## 19    Spain     7.1  7.0    7.2

❶ 打印每个集群中国家的便利函数,以及红肉、鱼类和水果/蔬菜消费的值。在本节中我们将使用此函数。请注意,该函数假定数据在数据框(而非矩阵)中。

这些集群有一定的逻辑性:每个集群中的国家往往位于相同的地理区域。同一地区的国家拥有相似的饮食习惯是有道理的。您还可以看到,

  • 集群 2 由消费红肉量高于平均的国家组成。

  • 集群 4 包含了鱼类消费量高于平均但农产品消费量较低的国家。

  • 集群 5 包含了鱼类和农产品消费量高的国家。

这个数据集只有 25 个点;当数据点非常多时,很难“目测”集群及其成员。在接下来的几节中,我们将探讨一些更全面地检查集群的方法。

使用主成分分析可视化集群

正如我们在 第三章 中提到的,可视化是获取数据整体视图的有效方法,或者在这种情况下,是获取聚类视图。蛋白质数据是九维的,因此很难用散点图进行可视化。

我们可以尝试通过将数据投影到数据的第一个两个 主成分 上来可视化聚类。[2] 如果 N 是描述数据的变量数量,那么主成分描述了 N-空间中大致界定数据的超椭圆体。每个主成分是一个 N-维向量,描述了该超椭圆体的一个轴。图 9.7 展示了当 N = 3 时的这种情况。

¹

我们可以将数据投影到任何两个主成分上,但前两个最有可能显示有用的信息。

图 9.7. 主成分分析背后的思想

如果您按超椭圆体对应轴的长度(最长优先)对主成分进行排序,那么前两个主成分描述了 N-空间中的一个平面,该平面可以捕捉到尽可能多的数据变化,这些变化可以在二维中捕捉到。换句话说,它描述了数据的最佳 2-D 投影。我们将使用 prcomp() 调用来进行主成分分解。

列表 9.5. 将集群投影到前两个主成分上

library(ggplot2)
princ <- prcomp(pmatrix)                                            ❶
nComp <- 2
project <- predict(princ, pmatrix)[, 1:nComp]                       ❷
project_plus <- cbind(as.data.frame(project),                       ❸
                      cluster = as.factor(groups),
                     country = protein$Country)

ggplot(project_plus, aes(x = PC1, y = PC2)) +                       ❹
  geom_point(data = as.data.frame(project), color = "darkgrey") +
  geom_point() +
  geom_text(aes(label = country),
            hjust = 0, vjust = 1) +
  facet_wrap(~ cluster, ncol = 3, labeller = label_both)

❶ 计算数据的主成分

predict() 函数会将数据旋转到由主成分描述的坐标中。旋转数据的前两列是数据在第一个和第二个主成分上的投影。

❸ 创建一个数据框,包含转换后的数据,以及每个点的聚类标签和国家标签。

❹ 绘制它。为了可读性,将每个集群放在单独的面板中。

您可以在 图 9.8 中看到,集群 1(罗马尼亚/南斯拉夫/保加利亚/阿尔巴尼亚)和地中海集群(集群 5)与其他集群分离。其他三个集群在这个投影中混合在一起,尽管它们在其他投影中可能更分离。

图 9.8. 按蛋白质消耗量聚类的国家,投影到前两个主成分

集群的自举评估

在评估集群时,一个重要的问题是给定集群是否“真实”——该集群是否代表数据中的实际结构,或者它是否是聚类算法的产物?正如您将看到的,这对于像 k-means 这样的聚类算法尤为重要,用户必须事先指定集群的数量。我们的经验表明,聚类算法通常会生成几个代表数据中实际结构或关系的集群,然后是一个或两个代表“其他”或“杂项”的集群。所谓的“其他”集群往往由彼此之间没有真正关系的点组成;它们根本不适合其他任何地方。

评估一个集群是否代表真实结构的一种方法是通过观察该集群在数据集的合理变化下是否保持稳定。fpc 包含一个名为 clusterboot() 的函数,该函数使用自举重采样来评估给定集群的稳定性.^([1]) clusterboot() 是一个集成函数,它既执行聚类又评估最终生成的集群。它具有与多个 R 聚类算法的接口,包括 hclustkmeans

¹

对于算法的完整描述,请参阅 Christian Henning 的研究报告,“Cluster-wise assessment of cluster stability”,报告编号 271,伦敦大学学院统计科学系,2006 年 12 月。

clusterboot 算法使用 Jaccard 系数,它是集合之间的相似度度量。集合 A 和 B 之间的 Jaccard 相似度是 A 和 B 交集元素数量与 A 和 B 并集元素数量的比率。这如图 9.9 所示。

图 9.9. Jaccard 相似度

基本的一般策略如下:

  1. 按照常规对数据进行聚类。

  2. 通过对原始数据集进行有放回的重采样(意味着某些数据点可能会出现多次,而其他点则可能一次也不出现)来绘制一个新的数据集(与原始数据集大小相同)。对新数据集进行聚类。

  3. 对于原始聚类中的每个聚类,找到新聚类中最相似的聚类(给出最大贾卡德系数的那个聚类)并记录该值。如果这个最大贾卡德系数小于 0.5,原始聚类被认为是 溶解 的——它没有出现在新的聚类中。一个经常溶解的聚类可能不是一个“真实”的聚类。

  4. 重复步骤 2–3 几次。

原始聚类中每个聚类的 聚类稳定性 是其在所有自助迭代中贾卡德系数的平均值。一般来说,稳定性值小于 0.6 的聚类应被视为不稳定。介于 0.6 和 0.75 之间的值表明聚类正在测量数据中的模式,但关于哪些点应该聚类在一起没有很高的确定性。稳定性值高于约 0.85 的聚类可以被认为是高度稳定的(它们很可能是真实聚类)。

不同的聚类算法即使在产生高度相似的聚类时也可能给出不同的稳定性值,因此 clusterboot() 也测量聚类算法的稳定性。

让我们在蛋白质数据上运行 clusterboot(),使用五聚类的层次聚类。请注意,clusterboot() 是随机的,因此你可能不会得到相同的结果。

列表 9.6. 在蛋白质数据上运行 clusterboot()

library(fpc)                                             ❶
kbest_p <- 5                                             ❷
cboot_hclust <- clusterboot(pmatrix,
                           clustermethod = hclustCBI,    ❸

                           method = "ward.D",
                           k = kbest_p)

summary(cboot_hclust$result)                             ❹

##               Length Class  Mode
## result         7     hclust list
## noise          1     -none- logical
## nc             1     -none- numeric
## clusterlist    5     -none- list
## partition     25     -none- numeric
## clustermethod  1     -none- character
## nccl           1     -none- numeric

groups <- cboot_hclust$result$partition                  ❺
print_clusters(protein, groups, cols_to_print)           ❻
## $`1`
##       Country RedMeat Fish Fr.Veg
## 1     Albania    10.1  0.2    1.7
## 4    Bulgaria     7.8  1.2    4.2
## 18    Romania     6.2  1.0    2.8
## 25 Yugoslavia     4.4  0.6    3.2
##
## $`2`
##        Country RedMeat Fish Fr.Veg
## 2      Austria     8.9  2.1    4.3
## 3      Belgium    13.5  4.5    4.0
## 9       France    18.0  5.7    6.5
## 12     Ireland    13.9  2.2    2.9
## 14 Netherlands     9.5  2.5    3.7
## 21 Switzerland    13.1  2.3    4.9
## 22          UK    17.4  4.3    3.3
## 24   W Germany    11.4  3.4    3.8
##
## $`3`
##           Country RedMeat Fish Fr.Veg
## 5  Czechoslovakia     9.7  2.0    4.0
## 7       E Germany     8.4  5.4    3.6
## 11        Hungary     5.3  0.3    4.2
## 16         Poland     6.9  3.0    6.6
## 23           USSR     9.3  3.0    2.9
##
## $`4`
##    Country RedMeat Fish Fr.Veg
## 6  Denmark    10.6  9.9    2.4
## 8  Finland     9.5  5.8    1.4
## 15  Norway     9.4  9.7    2.7
## 20  Sweden     9.9  7.5    2.0
##
## $`5`
##     Country RedMeat Fish Fr.Veg
## 10   Greece    10.2  5.9    6.5
## 13    Italy     9.0  3.4    6.7
## 17 Portugal     6.2 14.2    7.9
## 19    Spain     7.1  7.0    7.2

cboot_hclust$bootmean                                    ❻
## [1] 0.8090000 0.7939643 0.6247976 0.9366667 0.7815000

cboot_hclust$bootbrd                                     ❽
## [1] 19 14 45  9 30

❶ 加载 fpc 包。你可能需要先安装它。

❷ 设置所需的聚类数量

❸ 使用 hclust (clustermethod = hclustCBI) 和 Ward 方法 (method = "ward.D") 以及 kbest_p 个聚类 (k = kbest_p) 运行 clusterboot()。结果存储在名为 cboot_hclust 的对象中。

❹ 聚类结果存储在 cboot_hclust$result 中。

❺ cboot_hclust\(result\)partition 返回一个聚类标签向量。

❻ 聚类结果与直接调用 hclust() 得到的结果相同。

❻ 聚类稳定性的向量

❽ 每个聚类被溶解的次数。默认情况下,clusterboot() 运行 100 次自助迭代。

clusterboot() 的结果显示,高鱼消费国家(聚类 4)的聚类非常稳定:聚类稳定性高,聚类相对较少地被溶解。聚类 1 和 2 也相当稳定;聚类 5 的稳定性较低(你可以在 图 9.8 中看到聚类 5 的成员与其他国家分离,但也相对彼此分离)。聚类 3 具有我们一直称之为“其他”聚类的特征。

clusterboot() 假设你知道聚类数量,k。我们从树状图中目测了合适的 k,但这种方法在大数据集中并不总是可行。我们能否以更自动化的方式选择一个合理的 k?我们将在下一节中探讨这个问题。

选择聚类数量

有许多启发式方法和经验规则用于选择簇;给定的启发式方法在某些数据集上可能比其他数据集上工作得更好。如果可能的话,最好利用领域知识来帮助设置簇的数量。否则,尝试各种启发式方法,也许还有几个不同的k值。

总内部平方和

一个简单的启发式方法是计算不同k值的总内部平方和(WSS),并寻找曲线中的“肘部”。我们将在本节中介绍 WSS 的定义。

图 9.10 显示了具有四个簇的数据。定义每个簇的质心为簇中所有点的平均值。质心将位于簇的中心,如图所示。单个簇的内部平方和(或WSS_i)是簇中每个点与簇质心的平方距离之和。这在图中的簇 4 中显示出来。

图 9.10. 四个簇的簇 WSS 和总 WSS

图 9.10 的替代图片

总的内部平方和是所有簇的WSS_i的总和。我们将在下面的列表中展示计算过程。

列表 9.7. 计算总内部平方和

sqr_edist <- function(x, y) {                                              ❶
   sum((x - y)²)
}

wss_cluster <- function(clustermat) {                                      ❷
   c0 <- colMeans(clustermat)                                              ❸
   sum(apply(clustermat, 1, FUN = function(row) { sqr_edist(row, c0) }))   ❹
 }

wss_total <- function(dmatrix, labels) {                                   ❺
  wsstot <- 0
  k <- length(unique(labels))
  for(i in 1:k)
    wsstot <- wsstot + wss_cluster(subset(dmatrix, labels == i))           ❻
  wsstot
}

wss_total(pmatrix, groups)                                                 ❻

## [1] 71.94342

❶ 计算两个向量之间平方距离的函数

❷ 计算单个簇的 WSS 的函数,该簇表示为一个矩阵(每行一个点)

❸ 计算簇的质心(所有点的平均值)

❹ 计算簇中每个点与质心的平方差,并求和所有距离

❺ 计算从一组数据点和聚类标签中得到的总 WSS 的函数

❻ 提取每个簇,计算簇的 WSS,并求和所有值

❻ 计算当前蛋白质聚类的总 WSS。

随着簇数量的增加,总的 WSS 会减少,因为每个簇将变得更小、更紧密。希望 WSS 减少的速率会在超过最佳簇数量后的k上放缓。换句话说,WSS 与k的图表应该在最佳k之后变平,因此最佳k将在图表的“肘部”。让我们尝试计算最多 10 个簇的 WSS。

列表 9.8. 绘制k范围的 WSS

get_wss <- function(dmatrix, max_clusters) {               ❶
    wss = numeric(max_clusters)

  wss[1] <- wss_cluster(dmatrix)                           ❷

  d <- dist(dmatrix, method = "euclidean")
  pfit <- hclust(d, method = "ward.D")                     ❸

  for(k in 2:max_clusters) {                               ❹

    labels <- cutree(pfit, k = k)
    wss[k] <- wss_total(dmatrix, labels)
  }

  wss
}

kmax <- 10
cluster_meas <- data.frame(nclusters = 1:kmax,
                          wss = get_wss(pmatrix, kmax))

breaks <- 1:kmax
ggplot(cluster_meas, aes(x=nclusters, y = wss)) +          ❺
   geom_point() + geom_line() +
  scale_x_continuous(breaks = breaks)

❶ 一个函数,用于获取从 1 到最大值的簇的总 WSS

❷ wss[1]只是所有数据的 WSS。

❸ 对数据进行聚类

❹ 对于每个 k,计算簇标签和簇 WSS

❺ 绘制 WSS 随 k 的变化图

图 9.11 显示了 WSS 随k变化的曲线图。不幸的是,在这种情况下,曲线的肘部很难看清,尽管如果你眯起眼睛,你可能会说服自己,在k = 2处有一个肘部,在k = 5k = 6处也有一个。这意味着最佳的聚类可能是 2 个簇,5 个簇,或 6 个簇。

图 9.11. 蛋白质数据的 WSS 随k的变化

Calinski-Harabasz 指数

Calinski-Harabasz 指数是衡量聚类好坏的另一种常用指标。它试图找到所有聚类都紧密且彼此之间距离较远的位置。为了说明(并计算)Calinski-Harabasz 指数(简称 CH 指数),我们首先需要定义一些更多术语。

如图 9.12 所示,一组点的总平方和(TSS)是所有点与数据质心的距离平方的和。在列表 9.8 的get_wss()函数中,值wss[1]是 TSS,它与聚类无关。对于具有总内部平方和的给定聚类,我们还可以定义之间平方和(BSS):

BSS = TSS - WSS

图 9.12. 四个聚类的总平方和

BSS 衡量聚类彼此之间的距离。一个好的聚类具有小的 WSS(所有聚类都紧密围绕其中心)和大的 BSS。我们可以比较随着聚类数量的变化,BSS 和 WSS 如何变化。

列表 9.9. 作为k函数绘制 BSS 和 WSS

total_ss <- function(dmatrix) {                                            ❶
   grandmean <- colMeans(dmatrix)
  sum(apply(dmatrix, 1, FUN = function(row) { sqr_edist(row, grandmean) }))
}

tss <- total_ss(pmatrix)
cluster_meas$bss <- with(cluster_meas, tss - wss)

library(cdata)                                                             ❷
cmlong <- unpivot_to_blocks(cluster_meas,                                  ❸
                            nameForNewKeyColumn = "measure",
                           nameForNewValueColumn = "value",
                           columnsToTakeFrom = c("wss", "bss"))

ggplot(cmlong, aes(x = nclusters, y = value)) +
  geom_point() + geom_line() +
  facet_wrap(~measure, ncol = 1, scale = "free_y") +
  scale_x_continuous(breaks = 1:10)

❶ 计算总平方和:TSS

❷ 加载 cdata 包以重塑数据

❸ 将 cluster_meas 重塑,使 WSS 和 BSS 在同一列

图 9.13 显示,随着k的增加,BSS 增加,而 WSS 减少。我们希望找到一个具有良好 BSS 和 WSS 平衡的聚类。为了找到这样的聚类,我们必须查看与 BSS 和 WSS 相关的几个指标。

图 9.13. BSS 和 WSS 作为k的函数

聚类内方差 W由以下公式给出

W = WSS / (n - k)

这里,n是数据点的数量,k是聚类的数量。你可以将W视为“平均”WSS。

聚类间方差 B由以下公式给出

B = BSS / (k - 1)

同样,你可以将B视为每个聚类对 BSS 的平均贡献。

一个好的聚类应该具有小的平均 WSS 和大的平均 BSS,因此我们可能会尝试最大化BW的比率。这就是 Calinski-Harabasz(CH)指数。让我们计算 CH 指数并绘制出最多 10 个聚类的图像。

列表 9.10. Calinski-Harabasz 指数

cluster_meas$B <- with(cluster_meas,  bss / (nclusters - 1))     ❶

n = nrow(pmatrix)
cluster_meas$W <- with(cluster_meas,  wss / (n - nclusters))     ❷
cluster_meas$ch_crit <- with(cluster_meas, B / W)                ❸

ggplot(cluster_meas, aes(x = nclusters, y = ch_crit)) +
  geom_point() + geom_line() +
  scale_x_continuous(breaks = 1:kmax)

❶ 计算聚类间方差 B

❷ 计算聚类内方差 W

❸ 计算 CH 指数

观察图 9.14,你看到 CH 标准在k = 2时最大化,在k = 5处有另一个局部最大值。k = 2的聚类对应于蛋白质数据树状图的第一次分割,如图 9.15 所示;如果你使用clusterboot()进行聚类,你会看到聚类高度稳定,尽管可能不是非常有信息量。

图 9.14. 作为k函数的 Calinski-Harabasz 指数

图 9.15. 具有两个聚类的蛋白质数据树状图


聚类质量的其他度量

在选择k时,你可以尝试几种其他度量。gap 统计量^([a])是尝试自动在 WSS 曲线上进行“肘部发现”。当数据来自具有近似高斯分布的多个总体(称为mixture of Gaussians)时,它效果最好。当我们讨论kmeans()时,我们还将看到另一个度量,即平均轮廓宽度

^a

参见 Robert Tibshirani,Guenther Walther 和 Trevor Hastie,“通过 gap 统计量估计数据集中聚类的数量”,皇家统计学会 B 卷,2001 年,第 63 卷,第 2 期,第 411-423 页;www.stanford.edu/~hastie/Papers/gap.pdf


9.1.4. k-means 算法

当数据都是数值且距离度量是平方欧几里得距离时(尽管理论上可以用其他距离度量运行它),K-means 是一种流行的聚类算法。它相当随意,并且主要缺点是你必须事先选择k。优点是它易于实现(这是它如此受欢迎的原因之一),并且在大型数据集上可能比层次聚类更快。它最适合看起来像高斯混合的数据,不幸的是,protein数据看起来并不是这样。

kmeans()函数

在 R 中运行 k-means 的函数是kmeans()kmeans()的输出包括聚类标签、聚类的中心(质心)、总平方和、总 WSS、总 BSS 以及每个聚类的 WSS。

k-means 算法在图 9.16 中展示,其中k = 2。这个算法不保证有一个唯一的停止点。k-means 可能相当不稳定,因为最终的聚类取决于初始的聚类中心。多次使用不同的随机开始运行 k-means 是一个好习惯,然后选择具有最低总 WSS 的聚类。kmeans()函数可以自动完成此操作,尽管它默认只使用一个随机开始。

图 9.16. k-means 过程。两个聚类中心由轮廓星和菱形表示。

图片

让我们在protein数据上运行kmeans()(如前所述,缩放到均值为 0 和单位标准差)。我们将使用k = 5,如列表 9.11 所示。请注意,kmeans()是随机化代码,因此你可能不会得到显示的确切结果。

列表 9.11. 使用k = 5运行 k-means

kbest_p <- 5

pclusters <- kmeans(pmatrix, kbest_p, nstart = 100, iter.max = 100)   ❶
summary(pclusters)                                                    ❷
##              Length Class  Mode
## cluster      25     -none- numeric
## centers      45     -none- numeric
## totss         1     -none- numeric
## withinss      5     -none- numeric
## tot.withinss  1     -none- numeric
## betweenss     1     -none- numeric
## size          5     -none- numeric
## iter          1     -none- numeric
## ifault        1     -none- numeric

pclusters$centers                                                     ❸

##        RedMeat  WhiteMeat        Eggs       Milk       Fish    Cereals
## 1 -0.570049402  0.5803879 -0.08589708 -0.4604938 -0.4537795  0.3181839
## 2 -0.508801956 -1.1088009 -0.41248496 -0.8320414  0.9819154  0.1300253
## 3 -0.807569986 -0.8719354 -1.55330561 -1.0783324 -1.0386379  1.7200335
## 4  0.006572897 -0.2290150  0.19147892  1.3458748  1.1582546 -0.8722721
## 5  1.011180399  0.7421332  0.94084150  0.5700581 -0.2671539 -0.6877583
##       Starch       Nuts      Fr.Veg
## 1  0.7857609 -0.2679180  0.06873983
## 2 -0.1842010  1.3108846  1.62924487
## 3 -1.4234267  0.9961313 -0.64360439
## 4  0.1676780 -0.9553392 -1.11480485
## 5  0.2288743 -0.5083895  0.02161979

pclusters$size                                                        ❹
 ## [1] 5 4 4 4 8

groups <- pclusters$cluster                                           ❺

cols_to_print = wrapr::qc(Country, RedMeat, Fish, Fr.Veg)
print_clusters(protein, groups, cols_to_print)                        ❻

## $`1`
##           Country RedMeat Fish Fr.Veg
## 5  Czechoslovakia     9.7  2.0    4.0
## 7       E Germany     8.4  5.4    3.6
## 11        Hungary     5.3  0.3    4.2
## 16         Poland     6.9  3.0    6.6
## 23           USSR     9.3  3.0    2.9
##
## $`2`
##     Country RedMeat Fish Fr.Veg
## 10   Greece    10.2  5.9    6.5
## 13    Italy     9.0  3.4    6.7
## 17 Portugal     6.2 14.2    7.9
## 19    Spain     7.1  7.0    7.2
##
## $`3`
##       Country RedMeat Fish Fr.Veg
## 1     Albania    10.1  0.2    1.7
## 4    Bulgaria     7.8  1.2    4.2
## 18    Romania     6.2  1.0    2.8
## 25 Yugoslavia     4.4  0.6    3.2
##
## $`4`
##    Country RedMeat Fish Fr.Veg
## 6  Denmark    10.6  9.9    2.4
## 8  Finland     9.5  5.8    1.4
## 15  Norway     9.4  9.7    2.7
## 20  Sweden     9.9  7.5    2.0
##
## $`5`
##        Country RedMeat Fish Fr.Veg
## 2      Austria     8.9  2.1    4.3
## 3      Belgium    13.5  4.5    4.0
## 9       France    18.0  5.7    6.5
## 12     Ireland    13.9  2.2    2.9
## 14 Netherlands     9.5  2.5    3.7
## 21 Switzerland    13.1  2.3    4.9
## 22          UK    17.4  4.3    3.3
## 24   W Germany    11.4  3.4    3.8

❶ 使用五个聚类(kbest_p = 5),每次运行 100 次随机开始,每次运行 100 次最大迭代次数的 kmeans()函数

❷ kmeans()返回所有平方和度量。

❸ pclusters\(centers 是一个矩阵,其行是聚类的质心。请注意,pclusters\)centers 是在缩放坐标中,而不是原始蛋白质坐标中。

pclusters$size 返回每个簇中的点数。一般来说(尽管并非总是如此),一个好的聚类将相当平衡:没有极小的簇,也没有极大的簇。

pclusters$cluster是一个簇标签的向量。

❻ 在这种情况下,kmeans()hclust()返回相同的聚类。这并不总是正确的。

选择 k 的kmeansruns()函数

要运行kmeans(),你必须知道kfpc包(与clusterboot()相同的包)有一个名为kmeansruns()的函数,它在一个k的范围内调用kmeans()并估计最佳的k。然后它返回其选择的最佳k值,该值对应的kmeans()输出,以及一个关于k的标准的向量。目前,kmeansruns()有两个标准:Calinski-Harabasz 指数("ch")和平均轮廓宽度("asw")。对于任何标准,最大值表示最佳簇数(有关轮廓聚类的更多信息,请参阅mng.bz/Qe15)。检查整个k的范围的标准值是个好主意,因为你可能会看到算法没有自动选择的k的证据。下面的列表说明了这一点。

列表 9.12。绘制聚类标准

clustering_ch <- kmeansruns(pmatrix, krange = 1:10, criterion = "ch")      ❶

clustering_ch$bestk                                                        ❷
## [1] 2

clustering_asw <- kmeansruns(pmatrix, krange = 1:10, criterion = "asw")    ❸
clustering_asw$bestk
## [1] 3

clustering_asw$crit                                                        ❹
## [1] 0.0000000 0.3271084 0.3351694 0.2617868 0.2639450 0.2734815 0.2471165
## [8] 0.2429985 0.2412922 0.2388293

clustering_ch$crit                                                         ❺
##  [1]  0.000000 14.094814 11.417985 10.418801 10.011797  9.964967  9.861682
##  [8]  9.412089  9.166676  9.075569

cluster_meas$ch_crit                                                       ❻
##  [1]       NaN 12.215107 10.359587  9.690891 10.011797  9.964967  9.506978
##  [8]  9.092065  8.822406  8.695065

summary(clustering_ch)                                                     ❻

##              Length Class  Mode
## cluster      25     -none- numeric
## centers      18     -none- numeric
## totss         1     -none- numeric
## withinss      2     -none- numeric
## tot.withinss  1     -none- numeric
## betweenss     1     -none- numeric
## size          2     -none- numeric
## iter          1     -none- numeric
## ifault        1     -none- numeric
## crit         10     -none- numeric
## bestk         1     -none- numeric

❶ 从 1 到 10 个簇运行kmeansruns(),并使用 ch 标准。默认情况下,kmeansruns()每个运行使用 100 个随机起始点和 100 次最大迭代。

❷ ch 标准选择了两个簇。

❸ 从 1 到 10 个簇运行kmeansruns(),并使用平均轮廓宽度标准。平均轮廓宽度选择了 3 个簇。

❹ 查看 asw 标准值作为 k 的函数

❺ 查看 ch 标准值作为 k 的函数

❻ 将这些与 hclust()聚类的 ch 值进行比较。它们并不完全相同,因为两种算法没有选择相同的簇。

kmeansruns()还返回了k = bestk时的kmeans输出。

图 9.17 的顶部图比较了kmeansruns提供的两种聚类标准的结果。这两种标准都已被缩放到兼容的单位。它们建议两到三个簇是最好的选择。然而,如果你比较图 9.17 底部图中kmeanshclust聚类的(未缩放)CH 标准值,你会发现 CH 标准为kmeans()hclust()聚类产生了不同的曲线,但它确实为k = 5k = 6选择了相同的值(这可能意味着它选择了相同的簇),这可以被视为 5 或 6 是k的最佳选择的证据。

图 9.17。顶部:比较kmeans聚类的(缩放)CH 和平均轮廓宽度指数。底部:比较kmeanshclust聚类的 CH 指数。

clusterboot()重新审视

我们也可以使用 k-means 算法运行clusterboot()

列表 9.13. 使用 k-means 运行 clusterboot()

kbest_p <- 5
cboot <- clusterboot(pmatrix, clustermethod = kmeansCBI,
            runs = 100,iter.max = 100,
            krange = kbest_p, seed = 15555)           ❶

groups <- cboot$result$partition
print_clusters(protein, groups, cols_to_print)
## $`1`
##       Country RedMeat Fish Fr.Veg
## 1     Albania    10.1  0.2    1.7
## 4    Bulgaria     7.8  1.2    4.2
## 18    Romania     6.2  1.0    2.8
## 25 Yugoslavia     4.4  0.6    3.2
##
## $`2`
##    Country RedMeat Fish Fr.Veg
## 6  Denmark    10.6  9.9    2.4
## 8  Finland     9.5  5.8    1.4
## 15  Norway     9.4  9.7    2.7
## 20  Sweden     9.9  7.5    2.0
##
## $`3`
##           Country RedMeat Fish Fr.Veg
## 5  Czechoslovakia     9.7  2.0    4.0
## 7       E Germany     8.4  5.4    3.6
## 11        Hungary     5.3  0.3    4.2
## 16         Poland     6.9  3.0    6.6
## 23           USSR     9.3  3.0    2.9
##
## $`4`
##        Country RedMeat Fish Fr.Veg
## 2      Austria     8.9  2.1    4.3
## 3      Belgium    13.5  4.5    4.0
## 9       France    18.0  5.7    6.5
## 12     Ireland    13.9  2.2    2.9
## 14 Netherlands     9.5  2.5    3.7
## 21 Switzerland    13.1  2.3    4.9
## 22          UK    17.4  4.3    3.3
## 24   W Germany    11.4  3.4    3.8
##
## $`5`
##     Country RedMeat Fish Fr.Veg
## 10   Greece    10.2  5.9    6.5
## 13    Italy     9.0  3.4    6.7
## 17 Portugal     6.2 14.2    7.9
## 19    Spain     7.1  7.0    7.2

cboot$bootmean
## [1] 0.8670000 0.8420714 0.6147024 0.7647341 0.7508333

cboot$bootbrd
## [1] 15 20 49 17 32

❶ 我们已为随机生成器设置种子,以便结果可重复。

注意,由 cboot$bootmean 提供的稳定性数字(以及由 cboot$bootbrd 提供的簇“溶解”次数)在层次聚类和 k-means 中是不同的,尽管发现的簇是相同的。这表明聚类的稳定性部分是聚类算法的函数,而不仅仅是数据的函数。再次强调,两个聚类算法发现相同的簇可能表明 5 是簇的最佳数量。

9.1.5. 将新点分配给簇

聚类通常用作数据探索的一部分,或作为其他监督学习方法的先导。但您可能还想使用您发现的簇来对新数据进行分类。这样做的一种常见方式是将每个簇的重心作为整个簇的代表,然后将新点分配给最近的质心所在的簇。请注意,如果您在聚类之前对原始数据进行缩放,那么在将新点分配给簇之前也应以相同的方式进行缩放。

列表 9.14 展示了一个函数的示例,该函数将新数据点 newpt(表示为向量)分配给聚类 centers,其中 centers 表示为矩阵,每行是一个簇中心。这是 kmeans() 返回的簇中心表示。如果聚类之前使用 scale() 对数据进行缩放,那么 xcenterxscale 分别是 scaled:centerscaled:scale 属性。

列表 9.14. 将点分配给簇的函数

assign_cluster <- function(newpt, centers, xcenter = 0, xscale = 1) {
   xpt <- (newpt - xcenter) / xscale                                       ❶
    dists <- apply(centers, 1, FUN = function(c0) { sqr_edist(c0, xpt) })  ❷
    which.min(dists)                                                       ❸
  }

❶ 对新数据点进行中心化和缩放

❷ 计算新数据点到每个簇中心的距离

❸ 返回最近质心的簇编号

注意,函数 sqr_edist()(欧几里得距离的平方)之前已在 9.1.1 节 中定义。

让我们通过使用合成数据来查看将点分配给簇的示例。首先,我们将生成数据。

列表 9.15. 生成和聚类合成数据

mean1 <- c(1, 1, 1)                                                    ❶
sd1 <- c(1, 2, 1)

mean2 <- c(10, -3, 5)
sd2 <- c(2, 1, 2)

mean3 <- c(-5, -5, -5)
sd3 <- c(1.5, 2, 1)

library(MASS)                                                          ❷
clust1 <- mvrnorm(100, mu = mean1, Sigma = diag(sd1))
clust2 <- mvrnorm(100, mu = mean2, Sigma = diag(sd2))
clust3 <- mvrnorm(100, mu = mean3, Sigma = diag(sd3))
toydata <- rbind(clust3, rbind(clust1, clust2))

tmatrix <- scale(toydata)                                              ❸

tcenter <- attr(tmatrix, "scaled:center")                              ❹
tscale <-attr(tmatrix, "scaled:scale")
tmatrix <- rm_scales(tmatrix)

kbest_t <- 3
tclusters <- kmeans(tmatrix, kbest_t, nstart = 100, iter.max = 100)    ❺

tclusters$size

## [1] 101 100  99

❶ 设置三个 3D 高斯簇的参数

❷ 使用来自 MASS 包的 mvrnorm() 函数生成 3D 对齐的高斯簇

❸ 缩放合成数据

❹ 获取缩放属性,然后从矩阵中删除它们

❺ 将合成数据聚类为三个簇

❻ 生成的簇的大小与真实簇的大小一致。

让我们比较发现的 k-means 簇的中心与真实簇中心。为此,我们需要对 tclusters$centers 进行反缩放。scale() 函数通过减去中心向量然后除以缩放向量来实现。因此,要逆转这个过程,首先对缩放矩阵进行“反缩放”,然后“去中心化”。

列表 9.16. 反缩放中心

unscaled = scale(tclusters$centers, center = FALSE, scale = 1 / tscale)
rm_scales(scale(unscaled, center = -tcenter, scale = FALSE))

##         [,1]      [,2]       [,3]
## 1  9.8234797 -3.005977  4.7662651
## 2 -4.9749654 -4.862436 -5.0577002
## 3  0.8926698  1.185734  0.8336977

将未缩放的中心与 列表 9.15 中的 mean1mean2mean3 进行比较,我们看到

  • 第一个发现的中心对应于 mean2: (10, –3, 5)。

  • 第二个发现的中心对应于 mean3: (–5, –5, –5)。

  • 第三个发现的中心对应于 mean1: (1, 1, 1)。

因此,似乎发现的聚类与真实聚类一致。

现在我们可以演示将新点分配到聚类中。让我们从每个真实聚类生成一个点,并查看它被分配到哪个 k-means 聚类。

列表 9.17. 将点分配到聚类的示例

assign_cluster(mvrnorm(1, mean1, diag(sd1))   ❶
                 tclusters$centers,
                tcenter, tscale)

## 3
## 3

assign_cluster(mvrnorm(1, mean2, diag(sd2))   ❷
                 tclusters$centers,
                tcenter, tscale)

## 1
## 1

assign_cluster(mvrnorm(1, mean3, diag(sd3))   ❸
                 tclusters$centers,
                tcenter, tscale)

## 2
## 2

❶ 这应该被分配到聚类 3。

❷ 这应该被分配到聚类 1。

❸ 这应该被分配到聚类 2。

assign_cluster() 函数已正确地将每个点分配到适当的聚类。

9.1.6. 聚类要点

在这个阶段,你已经学会了如何估计数据集的适当聚类数量,如何使用层次聚类和 k-means 聚类数据集,以及如何评估结果聚类。以下是关于聚类的你应该记住的内容:

  • 聚类的目标是发现或提取数据子集之间的相似性。

  • 在一个好的聚类中,同一聚类中的点应该比其他聚类中的点更相似(更近)。

  • 在聚类时,每个变量所测量的单位很重要。不同的单位会导致不同的距离和潜在的不同的聚类。

  • 理想情况下,你希望每个坐标的单位变化代表相同程度的变化。一种近似方法是,通过使用函数 scale() 将所有列转换为具有 0 均值和 1.0 标准差,例如。

  • 聚类通常用于数据探索或作为监督学习方法的前奏。

  • 与可视化一样,聚类比监督方法更迭代、更交互式,也更少自动化。

  • 不同的聚类算法将给出不同的结果。你应该考虑不同的方法,使用不同的聚类数量。

  • 存在许多启发式方法用于估计最佳聚类数量。再次提醒,你应该考虑不同启发式方法的结果,并探索不同数量的聚类。

有时,与其寻找彼此高度相似的数据点子集,你更想知道哪些类型的数据(或哪些数据属性)倾向于一起出现。在下一节中,我们将探讨解决此问题的一种方法。

9.2. 关联规则

关联规则挖掘用于寻找经常一起出现的对象或属性——例如,在购物过程中经常一起购买的产品,或者在网站搜索引擎会话中倾向于一起出现的查询。此类信息可用于向购物者推荐产品,将经常捆绑的商品一起放置在商店货架上,或重新设计网站以便更容易导航。

9.2.1. 关联规则概述


示例

假设你在图书馆工作。你想知道哪些书籍倾向于一起被借出,以帮助你预测书籍的可用性。


在挖掘关联规则时,“一起”的单位称为交易。根据问题,交易可以是单个购物篮、单个网站用户会话,甚至单个客户。构成交易的实体称为项目集中的项目:购物篮中的产品、网站会话期间访问的页面、客户的操作。有时交易被称为篮子,来源于购物篮的类比。

当图书馆读者借出一套书时,那是一个交易;读者借出的书构成了交易的项目集。表格 9.1 代表交易数据库(你运营的图书馆中奇幻类书籍非常受欢迎)。

表格 9.1. 图书馆交易数据库

交易 ID 借出的书籍
--- ---
1 《霍比特人》,《公主新娘》*
2 《公主新娘》,《最后的独角兽》*
3 《霍比特人》
4 《永远的故事》
5 《最后的独角兽》
6 《霍比特人》,《公主新娘》,《指环王》
7 《霍比特人》,《指环王》,《双塔奇兵》,《王者归来》*
8 《指环王》,《双塔奇兵》,《王者归来》
9 《霍比特人》,《公主新娘》,《最后的独角兽》
10 《最后的独角兽》,《永远的故事》*

关联规则挖掘分为两个步骤:

  1. 寻找出现频率高于最小交易比例的所有项目集(交易的子集)。

  2. 将这些项目集转化为规则。

让我们看看涉及物品《霍比特人》(简称 H)和《公主新娘》(简称 PB)的交易。表格 9.2 的列代表交易;行标记了出现给定项目集的交易。表格 9.2 代表交易数据库(你运营的图书馆中奇幻类书籍非常受欢迎)。

表格 9.2. 寻找《霍比特人》和《公主新娘》

1 2 3 4 5 6 7 8 9 10 总计
H X X X X X 5
PB X X X X 4
{H, PB} X X X 3

查看表格 9.2 中的所有交易,你会发现

  • 《霍比特人》出现在所有交易的 5/10,即 50%。

  • 《公主新娘》出现在所有交易的 4/10,即 40%。

  • 两本书一起被借出发生在 3/10,即所有交易中的 30%。

我们会说项目集{《霍比特人》,《公主新娘》}*的支持度为 30%。

  • 在包含《霍比特人》的五个交易中,有三个(3/5 = 60%)也包含了《公主新娘》

因此,你可以制定一条规则:“借阅《霍比特人》的人也会借阅《公主新娘》。”这条规则应该有 60%的正确率(根据你的数据)。我们会说这条规则的置信度是 60%。

  • 相反,在《公主新娘》被借阅的四次中,有三次出现了《霍比特人》,即 3/4 = 75%的时间。

因此,规则“借阅《公主新娘》的人也会借阅《霍比特人》”有 75%的置信度。

让我们正式定义规则、支持和置信度。

规则

规则“如果 X,则 Y”意味着每次你在交易中看到项目集 X 时,你期望也会看到 Y(给定一定的置信度)。对于本节将要讨论的 Apriori 算法,Y 始终是一个包含一个项目的项目集。

支持度

假设你的交易数据库称为 T,X 是一个项目集。那么support(X)是包含 X 的交易数量除以 T 中交易的总数。

置信度

规则“如果 X,则 Y”的置信度给出了规则为真的频率相对于你看到 X 的频率的分数或百分比。换句话说,如果support(X)是项目集 X 在交易中出现的频率,而support({X, Y})是项目集 X 和 Y 在交易中同时出现的频率,那么规则“如果 X,则 Y”的置信度是support({X, Y})/support(X)

关联规则挖掘的目标是在数据库中找到所有至少有给定最小支持度(比如说 10%)和最小置信度(比如说 60%)的有趣规则。

9.2.2. 示例问题


示例

假设你为一家书店工作,你想根据客户的所有先前购买和书籍兴趣推荐他们可能感兴趣的书籍。你希望使用历史书籍兴趣信息来制定一些推荐规则。


你可以通过两种方式获取客户书籍兴趣的信息:要么他们从你这里购买了一本书,要么他们在你的网站上对一本书进行了评分(即使他们在其他地方购买了这本书)。在这种情况下,一个交易是一个客户,一个项目集是他们通过购买或评分表示兴趣的所有书籍。

你将使用的数据是基于 2004 年从书籍社区 Book-Crossing 收集的数据(参考文献[1]),用于弗莱堡大学信息学院的研究(参考文献[2])。信息被压缩成一个单独的制表符分隔的文本文件,称为 bookdata.tsv。文件中的每一行包含一个用户 ID、一本书的标题(每个书籍都被设计为唯一的 ID)和评分(在这个例子中你实际上不会使用评分):

¹

原始数据存储库可以在mng.bz/2052找到。由于原始文件中的一些工件在读取到 R 时导致错误,我们提供了数据的准备版 RData 对象:github.com/WinVector/PDSwR2/blob/master/Bookdata/bxBooks.RData。本节中我们将使用的数据的准备版本是github.com/WinVector/PDSwR2/blob/master/Bookdata/bookdata.tsv.gz。有关准备数据的更多信息和相关脚本可以在github.com/WinVector/PDSwR2/tree/master/Bookdata找到。

²

研究者的原始论文是“通过主题多样化改进推荐列表”,Cai-Nicolas Ziegler,Sean M. McNee,Joseph A. Konstan,Georg Lausen;第 14 届国际万维网会议(WWW ‘05),2005 年 5 月 10 日至 14 日,日本千叶。可以在mng.bz/7trR在线找到。

|token                 | userid| rating|title                 |
|:---------------------|------:|------:|:---------------------|
|always have popsicles | 172742|      0|Always Have Popsicles |

token列包含小写列字符串;这些标记用于识别具有不同 ISBN(原始书籍 ID)的书籍,这些书籍的标题除了大小写外都相同。title列包含正确大写的标题字符串;这些字符串对每本书都是唯一的,因此在这个例子中,你将使用它们作为书籍 ID。

在这种格式中,事务(客户)信息通过数据扩散,而不是全部在一个行中;这反映了数据在数据库中自然存储的方式,因为客户的活动会随时间扩散。书籍通常有不同的版本或来自不同的出版商。在这个例子中,我们将所有不同的版本压缩成一个单独的项目;因此,小妇人的不同副本或印刷版都将映射到我们数据中的相同项目 ID(即标题“小妇人”)。

原始数据包括大约一百万条来自 278,858 位读者的 271,379 本书的评分。由于我们之前讨论的映射,我们的数据将包含更少的书籍。

现在你可以开始挖掘了。

9.2.3. 使用 arules 包挖掘关联规则

你将使用arules包进行关联规则挖掘。arules包括流行的关联规则算法apriori的实现,以及读取和检查事务数据的功能。^[[1]) 该包使用特殊的数据类型来存储和处理数据;你将在处理示例时探索这些数据类型。

¹

对于比本章中我们能提供的更全面的arules介绍,请参阅 Hahsler, Grin, Hornik 和 Buchta 的“arules 介绍——挖掘关联规则和频繁项集的计算环境”,在线阅读cran.r-project.org/web/packages/arules/vignettes/arules.pdf

读取数据

您可以直接使用read.transactions()函数将数据从bookdata.tsv.gz文件读取到bookbaskets对象中。

列表 9.18. 读取书籍数据

library(arules)                                                        ❶
bookbaskets <- read.transactions("bookdata.tsv.gz",
                                     format = "single",                ❷
                                      header = TRUE,                   ❸
                                      sep = "\t",                      ❹
                                      cols = c("userid", "title"),     ❺
                                      rm.duplicates = TRUE)            ❻

❶ 加载 arules 包

❷ 指定文件和文件格式

❸ 指定输入文件有标题行

❹ 指定列分隔符(制表符)

❺ 分别指定事务 ID 和项目 ID 的列

❻ 告诉函数查找并删除重复条目(例如,同一用户对《霍比特人》的多个条目)

read.transactions()函数以两种格式读取数据:每行对应一个单一物品的格式(如 bookdata.tsv.gz),以及每行对应一个单一事务的格式,可能包含事务 ID,如表 9.1。要读取第一种格式的数据,请使用format = "single"参数;要读取第二种格式的数据,请使用format = "basket"参数。

有时会发生读者购买一本书的一个版本,然后后来在另一个版本下为该书添加评分的情况。由于我们在这个例子中代表书籍的方式,这两个动作将导致重复条目。rm.duplicates = TRUE参数将消除它们。它还会输出一些(不太有用)的关于重复项的诊断信息。

读取数据后,您可以检查生成的对象。

检查数据

事务以一个称为transactions的特殊对象表示。您可以将transactions对象视为一个 0/1 矩阵,其中每一行代表一个事务(在这个例子中,是一个顾客),每一列代表每一个可能的物品(在这个例子中,是一本书)。矩阵条目(i, j)为 1,如果第i个事务包含项目j,或者如果顾客i对书籍j表示了兴趣。您可以使用多个调用来检查事务数据,如下一个列表所示。

列表 9.19. 检查事务数据

class(bookbaskets)                                                    ❶
## [1] "transactions"
## attr(,"package")
## [1] "arules"
bookbaskets                                                           ❷
## transactions in sparse format with
##  92108 transactions (rows) and
##  220447 items (columns)
dim(bookbaskets)                                                      ❸
## [1]  92108 220447
colnames(bookbaskets)[1:5]                                            ❹
## [1] " A Light in the Storm:[...]"
## [2] " Always Have Popsicles"
## [3] " Apple Magic"
## [4] " Ask Lily"
## [5] " Beyond IBM: Leadership Marketing and Finance for the 1990s"
rownames(bookbaskets)[1:5]                                            ❺
## [1] "10"     "1000"   "100001" "100002" "100004"

❶ 该对象是transactions类。

❷ 打印对象会告诉你其维度。

❸ 您也可以使用dim()查看矩阵的维度。

❹ 列标签为书名

❺ 行标签为顾客

您可以使用size()函数检查事务大小(或篮子大小)的分布:

basketSizes <- size(bookbaskets)
summary(basketSizes)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##     1.0     1.0     1.0    11.1     4.0 10250.0

大多数顾客(实际上至少有一半)只对一本书表示了兴趣。但有人对超过 10,000 本书表示了兴趣!您可能想更仔细地查看大小分布,看看发生了什么。

列表 9.20. 检查大小分布

quantile(basketSizes, probs = seq(0, 1, 0.1))                         ❶
##    0%   10%   20%   30%   40%   50%   60%   70%   80%   90%  100%
##     1     1     1     1     1     1     2     3     5    13 10253
library(ggplot2)                                                      ❷
ggplot(data.frame(count = basketSizes)) +
  geom_density(aes(x = count)) +
  scale_x_log10()

❶ 查看购物篮大小分布,以 10% 的增量

❷ 绘制分布图以获得更好的观察

图 9.18 显示了购物篮大小的分布。90% 的客户对少于 15 本书表示了兴趣;大多数剩余的客户对大约 100 本书或更少的书籍表示了兴趣;调用 quantile(basketSizes, probs = c(0.99, 1)) 将显示 99% 的客户对 179 本书或更少的书籍表示了兴趣。尽管如此,仍有少数人对几百本甚至几千本书表示了兴趣。

图 9.18. 购物篮大小的密度图

他们都读了哪些书?函数 itemFrequency() 可以告诉你每本书在交易数据中出现的频率。

列表 9.21. 计算每本书出现的频率

bookCount <- itemFrequency(bookbaskets, "absolute")
summary(bookCount)

##     Min.  1st Qu.   Median     Mean  3rd Qu.     Max.
##    1.000    1.000    1.000    4.638    3.000 2502.000

你还可以找到出现频率最高的 10 本书。

列表 9.22. 找到出现频率最高的 10 本书

orderedBooks <- sort(bookCount, decreasing = TRUE)           ❶
knitr::kable(orderedBooks[1:10])                             ❷

# |                                                |    x|
# |:-----------------------------------------------|----:|
# |Wild Animus                                     | 2502|
# |The Lovely Bones: A Novel                       | 1295|
# |She's Come Undone                               |  934|
# |The Da Vinci Code                               |  905|
# |Harry Potter and the Sorcerer's Stone           |  832|
# |The Nanny Diaries: A Novel                      |  821|
# |A Painted House                                 |  819|
# |Bridget Jones's Diary                           |  772|
# |The Secret Life of Bees                         |  762|
# |Divine Secrets of the Ya-Ya Sisterhood: A Novel |  737|

orderedBooks[1] / nrow(bookbaskets)                          ❸

## Wild Animus
##  0.02716376

❶ 按计数降序排序

❷ 以良好的格式显示前 10 本书籍

❸ 数据集中最受欢迎的书籍出现在少于 3% 的购物篮中。

前面的列表中的最后一个观察结果突出了挖掘高维数据时的问题之一:当你有成千上万的变量或项目时,几乎每个事件都是罕见的。在决定规则挖掘的支持度阈值时,请记住这一点;你的阈值通常需要相当低。

在我们进行规则挖掘之前,让我们进一步精炼数据。正如你之前观察到的,数据中的半数客户只对一本书表示了兴趣。由于你想要找到在人们兴趣列表中一起出现的书籍,你不能直接使用那些尚未对多本书表示兴趣的人。你可以将数据集限制为至少对两本书表示过兴趣的客户:

bookbaskets_use <- bookbaskets[basketSizes > 1]
dim(bookbaskets_use)
## [1]  40822 220447

现在你已经准备好寻找关联规则了。

apriori() 函数

为了挖掘规则,你需要决定一个最小支持度水平和最小阈值水平。对于这个例子,让我们尝试将我们考虑的项目集限制在最小支持度为 0.2%,或 0.002。这对应于至少出现 0.002 * nrow(bookbaskets_use) 次的项目集,大约是 82 笔交易。我们将使用 75% 的置信度阈值。

列表 9.23. 寻找关联规则

rules <- apriori(bookbaskets_use,                                       ❶
                  parameter = list(support = 0.002, confidence = 0.75))

summary(rules)
## set of 191 rules                                                     ❷
##
## rule length distribution (lhs + rhs):sizes                           ❸
##   2   3   4   5
##  11 100  66  14
##
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##   2.000   3.000   3.000   3.435   4.000   5.000
##
## summary of quality measures:                                         ❹
##     support           confidence          lift            count
##  Min.   :0.002009   Min.   :0.7500   Min.   : 40.89   Min.   : 82.0
##  1st Qu.:0.002131   1st Qu.:0.8113   1st Qu.: 86.44   1st Qu.: 87.0
##  Median :0.002278   Median :0.8468   Median :131.36   Median : 93.0
##  Mean   :0.002593   Mean   :0.8569   Mean   :129.68   Mean   :105.8
##  3rd Qu.:0.002695   3rd Qu.:0.9065   3rd Qu.:158.77   3rd Qu.:110.0
##  Max.   :0.005830   Max.   :0.9882   Max.   :321.89   Max.   :238.0
##
## mining info:                                                         ❺
##             data ntransactions support confidence
##  bookbaskets_use         40822   0.002       0.75

❶ 使用最小支持度 0.002 和最小置信度 0.75 调用 apriori()

❷ 找到的规则数量

❸ 规则长度的分布(在这个例子中,大多数规则包含 3 个项目——左侧 2 个,X(lhs),右侧 1 个,Y(rhs))

❹ 规则质量度量总结,包括支持度和置信度

❺ 关于如何调用 apriori() 的相关信息

规则的质量度量包括规则的支持度和置信度、支持度计数(规则应用到的交易数量),以及一个称为 提升度 的量。提升度比较观察到的模式的频率与仅通过偶然看到该模式频率的期望。规则“如果 X,则 Y”的提升度由 support({X, Y}) / (support(X) * support(Y)) 给出。如果提升度接近 1,那么观察到的模式仅通过偶然发生的可能性很大。提升度越大,模式是“真实”的可能性就越大。在这种情况下,所有发现的规则提升度至少为 40,因此它们很可能是客户行为的真实模式。

检查和评估规则

您还可以使用其他指标和兴趣度量来通过 interestMeasure() 函数评估规则。我们将查看其中两个度量:coveragefishersExactTest覆盖率 是规则左侧(X)的支持度;它告诉您规则在数据集中会被应用多少次。费舍尔精确检验 是一个用于检验观察到的模式是真实还是偶然(与提升度测量的相同;费舍尔检验更为正式)的显著性检验。费舍尔精确检验返回 p 值,即您偶然看到观察到的模式的概率;您希望 p 值很小。

列表 9.24. 评分规则

measures <- interestMeasure(rules,                              ❶
                  measure=c("coverage", "fishersExactTest"),    ❷
                  transactions = bookbaskets_use)               ❸
summary(measures)
##     coverage        fishersExactTest
##  Min.   :0.002082   Min.   : 0.000e+00
##  1st Qu.:0.002511   1st Qu.: 0.000e+00
##  Median :0.002719   Median : 0.000e+00
##  Mean   :0.003039   Mean   :5.080e-138
##  3rd Qu.:0.003160   3rd Qu.: 0.000e+00
##  Max.   :0.006982   Max.   :9.702e-136

interestMeasure() 的第一个参数是发现的规则。

❷ 第二个参数是应用兴趣度量的列表。

❸ 最后一个参数是用于评估兴趣度量的数据集。这通常是用于挖掘规则的相同集合,但不必如此。例如,您可以在整个数据集 bookbaskets 上评估规则,以获得反映所有客户的覆盖率估计,而不仅仅是那些对多本书表示兴趣的客户。

发现的规则覆盖率范围在 0.002–0.007 之间,相当于大约 82–286 人的范围。所有费舍尔检验的 p 值都很小,因此这些规则很可能反映了实际的客户行为模式。

您还可以使用 interestMeasure() 函数,通过 supportconfidencelift 等方法进行调用。在我们的例子中,如果您想为整个数据集 bookbaskets 获取支持度、置信度和提升度估计,而不是过滤后的数据集 bookbaskets_use——或者对于数据的一个子集,例如,仅限于美国客户,这将是有用的。

函数 inspect() 会美化打印规则。函数 sort() 允许您根据质量或兴趣度量对规则进行排序,例如置信度。要打印数据集中最自信的五个规则,可以使用以下语句,我们将使用管道符号进行扩展。

列表 9.25. 获取最自信的五个规则

library(magrittr)                    ❶

rules %>%
  sort(., by = "confidence") %>%     ❷

  head(., n = 5) %>%                 ❸

  inspect(.)                         ❹

❶ 使用 magrittr 添加管道符号

❷ 按置信度排序规则

❸ 获取前五个规则

❹ 调用 inspect() 函数以美化打印规则

为了提高可读性,我们将此命令的输出显示在 表 9.3 中。

表 9.3. 数据中发现的最自信的五个规则

左侧 右侧 支持度 自信度 升值 数量
四到得分 高五 七上 两块面包 三到致命 0.002 0.988 165 84
哈利·波特与凤凰社 哈利·波特与阿兹卡班的囚徒 哈利·波特与魔法石 哈利·波特与密室 0.003 0.966 73 117
四到得分 高五 一美元一把 两块面包 三到致命 0.002 0.966 162 85
四到得分 七上 三到致命 两块面包 高五 0.002 0.966 181 84
高五 七上 三到致命 两块面包 四到得分 0.002 0.966 168 84

在 表 9.3 中有两个需要注意的地方。首先,这些规则涉及的是系列书籍:关于赏金猎人斯蒂芬妮·普卢的编号系列小说,以及哈利·波特系列。因此,这些规则本质上表明,如果一个读者读过四本斯蒂芬妮·普卢或三本哈利·波特的书,他们几乎肯定会再买一本。

第二个要注意的是,规则 1、4 和 5 是相同项目集的排列。当规则变长时,这种情况很可能会发生。

限制挖掘的项目

你可以限制规则左侧或右侧出现的项目。假设你特别感兴趣的是那些倾向于与小说 The Lovely Bones 同时出现的书籍。你可以通过限制规则右侧出现的书籍来实现这一点,使用 appearance 参数。

列表 9.26. 带限制条件的规则查找

brules <- apriori(bookbaskets_use,
                parameter = list(support = 0.001,                        ❶
                                  confidence = 0.6),
                appearance = list(rhs = c("The Lovely Bones: A Novel"),  ❷
                                   default = "lhs"))                     ❸
 summary(brules)
## set of 46 rules
##
## rule length distribution (lhs + rhs):sizes
##  3  4
## 44  2
##
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
##   3.000   3.000   3.000   3.043   3.000   4.000
##
## summary of quality measures:
##     support           confidence          lift           count
##  Min.   :0.001004   Min.   :0.6000   Min.   :21.81   Min.   :41.00
##  1st Qu.:0.001029   1st Qu.:0.6118   1st Qu.:22.24   1st Qu.:42.00
##  Median :0.001102   Median :0.6258   Median :22.75   Median :45.00
##  Mean   :0.001132   Mean   :0.6365   Mean   :23.14   Mean   :46.22
##  3rd Qu.:0.001219   3rd Qu.:0.6457   3rd Qu.:23.47   3rd Qu.:49.75
##  Max.   :0.001396   Max.   :0.7455   Max.   :27.10   Max.   :57.00
##
## mining info:
##             data ntransactions support confidence
##  bookbaskets_use         40822   0.001        0.6

❶ 将最小支持度放宽到 0.001,最小置信度放宽到 0.6

❷ 规则的右侧只允许出现“《骨之恋》”。

❸ 默认情况下,所有书籍都可以进入规则的左侧。

支持度、置信度、数量和升值都比我们之前的例子低,但升值仍然远大于一,因此这些规则很可能反映了真实的客户行为模式。

让我们检查按置信度排序的规则。由于它们都将有相同的右侧,你可以使用 lhs() 函数只查看左侧。

列表 9.27. 规则检查

brules %>%
  sort(., by = "confidence") %>%
  lhs(.) %>%                          ❶

  head(., n = 5) %>%
  inspect(.)
##   items
## 1 {Divine Secrets of the Ya-Ya Sisterhood: A Novel,
##    Lucky : A Memoir}
## 2 {Lucky : A Memoir,
##    The Notebook}
## 3 {Lucky : A Memoir,
##    Wild Animus}
## 4 {Midwives: A Novel,
##    Wicked: The Life and Times of the Wicked Witch of the West}
## 5 {Lucky : A Memoir,
##    Summer Sisters}

❶ 获取排序规则的左侧

注意,五个最自信的规则中有四个包括左侧的 Lucky: A Memoir,这可能并不令人惊讶,因为 LuckyThe Lovely Bones 的作者所写。假设你想了解其他作者的作品,这些作品对那些对 The Lovely Bones 感兴趣的人也很有趣;你可以使用 subset() 函数来筛选出不包括 Lucky 的规则。

列表 9.28. 带限制条件的规则检查

brulesSub <- subset(brules, subset = !(lhs %in% "Lucky : A Memoir"))    ❶
 brulesSub %>%
  sort(., by = "confidence") %>%
  lhs(.) %>%
  head(., n = 5) %>%
  inspect(.)

brulesConf <- sort(brulesSub, by="confidence")

inspect(head(lhs(brulesConf), n = 5))
##   items
## 1 {Midwives: A Novel,
##    Wicked: The Life and Times of the Wicked Witch of the West}
## 2 {She's Come Undone,
##    The Secret Life of Bees,
##    Wild Animus}
## 3 {A Walk to Remember,
##    The Nanny Diaries: A Novel}
## 4 {Beloved,
##    The Red Tent}
## 5 {The Da Vinci Code,
##    The Reader}

❶ 限制为不包含 Lucky 在左侧的规则子集

这些例子表明,关联规则挖掘通常是高度交互的。为了得到有趣的规则,你通常必须将支持度和置信度水平设置得相当低;结果,你可以得到许多许多规则。有些规则可能比你想象的更有趣或更令人惊讶;要找到它们,需要按不同的兴趣度量对规则进行排序,或者可能限制自己只关注特定的规则子集。

9.2.4. 关联规则要点

你现在已经通过一个使用关联规则来探索购买数据中常见模式的例子。以下是关于关联规则你应该记住的几点:

  • 关联规则挖掘的目标是在数据中找到关系:倾向于一起出现的项目或属性。

  • 一个好的规则“如果 X,则 Y”应该比偶然观察到的频率更高。你可以使用提升或 Fisher 的精确检验来检查这是否成立。

  • 当大量不同项目可能出现在篮子里(在我们的例子中,成千上万的不同书籍)时,大多数事件将是罕见的(支持度低)。

  • 关联规则挖掘通常是交互式的,因为可能有许多规则需要排序和筛选。

摘要

在本章中,你学习了如何使用 R 中的两种不同的聚类方法来寻找数据中的相似性,以及如何使用关联规则来寻找数据中倾向于一起出现的项目。你还学习了如何评估你发现的集群和规则。

本章中我们介绍的无监督方法实质上更具有探索性。与监督方法不同,没有“真实情况”来评估你的发现。但无监督方法的发现可以成为更专注的实验和建模的起点。

在最后几章中,我们介绍了最基本建模和数据分析技术;它们都是当你开始一个新项目时值得考虑的良好起点。在下一章中,我们将触及一些更高级的方法。

本章你学到了

  • 如何使用层次方法和 k-means 对无标签数据进行聚类

  • 如何估计合适的聚类数量

  • 如何评估现有聚类的聚类稳定性

  • 如何使用 apriori 在事务数据中找到模式(关联规则)

  • 如何评估和筛选发现的关联规则

第十章. 探索高级方法

本章涵盖

  • 基于决策树的模型

  • 广义加性模型

  • 支持向量机

在第七章中,你学习了拟合预测模型的线性方法。这些模型是机器学习的核心方法;它们易于拟合;它们小巧、便携、高效;它们有时会提供有用的建议;并且它们可以在各种情况下表现良好。然而,它们也对世界做出了强烈的假设:即结果与所有输入线性相关,并且所有输入都以加性方式对结果产生影响。在本章中,你将了解一些放宽这些假设的方法。

图 10.1 代表了我们本章将要执行的心理模型:使用 R 掌握构建监督机器学习模型的科学。

图 10.1. 心理模型

图片


示例

假设你想研究死亡率与一个人的健康或体能指标之间的关系,包括 BMI(身体质量指数)。


图 10.2 显示了在四年期间,泰国老年人群体的 BMI 与死亡率风险比之间的关系.^([1]) 它表明,无论是高 BMI 还是低 BMI 都与较高的死亡率相关:BMI 与死亡率之间的关系不是线性的。因此,基于 BMI(部分)预测死亡率的简单线性模型可能表现不佳。

¹

Vapattanawong 等人。“泰国老年人的肥胖与死亡率:四年随访研究”,BMC 公共健康,2010。doi.org/10.1186/1471-2458-10-604

图 10.2. 男女死亡率与身体质量指数的关系

图片

此外,BMI(身体质量指数)与其他因素之间可能存在相互作用,例如一个人的活跃程度。例如,对于非常活跃的人来说,BMI 对死亡率的影响可能远小于对久坐不动的人的影响。一些相互作用,如变量之间的“如果-那么”关系或变量之间的乘法效应,可能并不总是可以用线性模型来表示.^([2])

²

在线性模型中可以建模相互作用,但这必须由数据科学家明确执行。相反,我们将专注于机器学习技术,例如基于树的方法,这些方法可以直接学习至少某些类型的相互作用。

本章中介绍的机器学习技术使用各种方法来解决建模中的非线性、相互作用和其他问题。

10.1. 基于树的方法

你在第一章中看到了一个基本的决策树模型的例子(在图 10.3 中重现)。决策树对分类和回归都很有用,并且由于以下原因,它们是一种吸引人的方法:

  • 它可以接受任何类型的数据,无论是数值型还是分类型,而不需要任何分布假设和预处理。

  • 大多数实现(特别是 R)处理缺失数据;该方法对冗余和非线性数据也具有鲁棒性。

  • 算法易于使用,输出(树)相对容易理解。

  • 它们自然地表达了输入变量之间的一些交互:形式为“如果 x 为真且 y 为真,那么……”

  • 一旦模型拟合完成,评分就很快。

图 10.3。示例决策树(来自第一章)

另一方面,决策树也有一些缺点:

  • 它们有过度拟合的倾向,尤其是在没有剪枝的情况下。

  • 它有很高的训练方差:从同一总体中抽取的样本可以产生具有不同结构和不同预测准确性的树。

  • 简单决策树不如本章我们将讨论的其他基于树的集成方法可靠。([3])

    ³

    参见 Lim、Loh 和 Shih,“三十三种旧分类算法与新分类算法的预测准确性、复杂性和训练时间比较”,机器学习,2000。40,203–229;在线mng.bz/qX06

由于这些原因,我们在这本书中不强调基本决策树的使用。然而,有许多技术可以修复这些弱点,从而产生最先进、有用且性能良好的建模算法。我们将在本节中讨论一些这些技术。

10.1.1。一个基本的决策树

为了激发对基于树的讨论,我们将回到第六章中使用的示例,并构建一个基本的决策树。


示例

假设你想要将电子邮件分类为垃圾邮件(你不想收到的邮件)和非垃圾邮件(你想要的邮件)。


对于这个例子,你将再次使用 Spambase 数据集。该数据集包含大约 4,600 个文档和 57 个特征,这些特征描述了某些关键词和字符的频率。以下是过程:

  • 首先,你将训练一个决策树来估计给定文档是垃圾邮件的概率。

  • 接下来,你将根据几个性能指标评估树的表现,包括准确率、F1 和偏差(所有这些都在第七章中讨论过)。

回想一下第六章和第七章的讨论,我们希望准确性和 F1 值要高,而偏差(与方差类似)要低。

首先,让我们加载数据。就像你在第 6.2 节中所做的那样,从github.com/WinVector/PDSwR2/raw/master/Spambase/spamD.tsv下载一份 spamD.tsv 的副本。然后,编写一些便利函数并训练一个决策树,如下所示。

列表 10.1。准备 Spambase 数据和评估决策树模型

spamD <- read.table('spamD.tsv', header = TRUE, sep = '\t')                ❶
spamD$isSpam <- spamD$spam == 'spam'
spamTrain <- subset(spamD, spamD$rgroup >= 10)
spamTest <- subset(spamD, spamD$rgroup < 10)

spamVars <- setdiff(colnames(spamD), list('rgroup', 'spam', 'isSpam'))
library(wrapr)
spamFormula <- mk_formula("isSpam", spamVars)                              ❷

loglikelihood <- function(y, py) {                                         ❸
   pysmooth <- ifelse(py == 0, 1e-12,
                  ifelse(py == 1, 1 - 1e-12, py))

  sum(y * log(pysmooth) + (1 - y) * log(1 - pysmooth))
}

accuracyMeasures <- function(pred, truth, name = "model") {                ❹
   dev.norm <- -2 * loglikelihood(as.numeric(truth), pred) / length(pred)  ❺
   ctable <- table(truth = truth,
                 pred = (pred > 0.5))                                      ❻
   accuracy <- sum(diag(ctable)) / sum(ctable)
  precision <- ctable[2, 2] / sum(ctable[, 2])
  recall <- ctable[2, 2] / sum(ctable[2, ])
  f1 <- 2 * precision * recall / (precision + recall)
  data.frame(model = name, accuracy = accuracy, f1 = f1, dev.norm)
}

library(rpart)                                                             ❻
treemodel <- rpart(spamFormula, spamTrain, method = "class")

library(rpart.plot)                                                        ❽
rpart.plot(treemodel, type = 5, extra = 6)

predTrain <- predict(treemodel, newdata = spamTrain)[, 2]                  ❾

trainperf_tree <- accuracyMeasures(predTrain,                              ❿)

                 spamTrain$spam == "spam",
                 name = "tree, training")

predTest <- predict(treemodel, newdata = spamTest)[, 2]
testperf_tree <- accuracyMeasures(predTest,
                 spamTest$spam == "spam",

❶ 加载数据并将其分为训练集(90%的数据)和测试集(10%的数据)

❷ 使用所有特征并执行二元分类,其中 TRUE 对应于垃圾邮件文档

❸ 一个用于计算对数似然(用于计算偏差)的函数

❹ 一个用于计算并返回模型上各种度量(归一化偏差、预测准确性和 f1)的函数

❺ 通过数据点的数量归一化偏差,以便我们可以比较训练集和测试集之间的偏差

❻ 将类概率估计器转换为分类器,通过将评分大于 0.5 的文档标记为垃圾邮件

❻ 加载 rpart 库并拟合决策树模型

❽ 用于绘制树

❾ “垃圾邮件”类的概率

❿ 评估决策树模型与训练集和测试集

结果的决策树模型显示在图 10.4 中。两次调用accuracyMeasures()的输出如下:

图 10.4. 用于垃圾邮件过滤的决策树模型

library(pander)                              ❶

panderOptions("plain.ascii", TRUE)           ❷
panderOptions("keep.trailing.zeros", TRUE)
panderOptions("table.style", "simple")
perf_justify <- "lrrr"

perftable <- rbind(trainperf_tree, testperf_tree)
pandoc.table(perftable, justify = perf_justify)

##
##
## model              accuracy       f1   dev.norm
## ---------------- ---------- -------- ----------
## tree, training       0.8996   0.8691     0.6304
## tree, test           0.8712   0.8280     0.7531

❶ 一个用于制作格式优美的 ASCII 表格的包

❷ 在全局设置一些选项,这样我们就不必在每次调用中都设置它们

如预期的那样,准确性和 F1 分数在测试集上都降低了,偏差增加了。

10.1.2. 使用袋装提高预测

通过自助聚合,或称为袋装,可以减轻决策树模型的不足。在袋装中,你从数据中抽取自助样本(带有替换的随机样本)。从每个样本中,你构建一个决策树模型。最终模型是所有单个决策树的平均值。这显示在图 10.5.^([4])

袋装、随机森林和梯度提升树是称为集成学习的一般技术的变体。一个集成模型由几个较小的简单模型(通常是小的决策树)的组合组成。Giovanni Seni 和 John Elder 的《数据挖掘中的集成方法》(Morgan & Claypool,2010)是集成学习一般理论的优秀入门书籍。

图 10.5. 袋装决策树

为了使这一点具体化,假设x是一个输入数据,y_i(x)是第i个树的输出,c(y_1(x), y2(x), ... yn(x))是单个输出的向量,y是最终模型的输出:

  • 对于回归,或用于估计类概率,y(x)是单个树返回的分数的平均值:y(x) = mean(c(y1(x), ... yn(x)))。

  • 对于分类,最终模型将分配给单个树投票最多的类别。

袋装决策树通过降低方差来稳定最终模型,这提高了准确性。一个树袋集成也较少可能过度拟合数据。

尝试对垃圾邮件示例进行一些树模型的袋装处理。

列表 10.2. 袋装决策树

ntrain <- dim(spamTrain)[1]
n <- ntrain                                          ❶

ntree <- 100

samples <- sapply(1:ntree,                           ❷

                 FUN = function(iter)
                   { sample(1:ntrain, size = n, replace = TRUE) })

treelist <-lapply(1:ntree,                           ❸

                  FUN = function(iter) {
                    samp <- samples[, iter];
                    rpart(spamFormula, spamTrain[samp, ], method = "class") }
      )

predict.bag <- function(treelist, newdata) {         ❹

  preds <- sapply(1:length(treelist),
                 FUN = function(iter) {
                   predict(treelist[[iter]], newdata = newdata)[, 2] })
  predsums <- rowSums(preds)
  predsums / length(treelist)
}

pred <- predict.bag(treelist, newdata = spamTrain)
trainperf_bag <- accuracyMeasures(pred,              ❺

                 spamTrain$spam == "spam",
                 name = "bagging, training")

pred <- predict.bag(treelist, newdata = spamTest)
testperf_bag <- accuracyMeasures(pred,
                 spamTest$spam == "spam",
                 name = "bagging, test")

perftable <- rbind(trainperf_bag, testperf_bag)
pandoc.table(perftable, justify = perf_justify)
##
##
## model                 accuracy       f1   dev.norm
## ------------------- ---------- -------- ----------
## bagging, training       0.9167   0.8917     0.5080
## bagging, test           0.9127   0.8824     0.5793

❶ 使用与训练集相同大小的自助样本,有 100 棵树

❷ 通过替换抽样spamTrain的行索引来构建自助样本。矩阵的每一列样本表示构成自助样本的spamTrain中的行索引。

❸ 训练单个决策树并将它们以列表形式返回。注意:此步骤可能需要几分钟。

predict.bag假设底层分类器返回决策概率,而不是决策。predict.bag取所有单个树的预测的平均值

❺ 评估袋装决策树对训练集和测试集的性能

如您所见,与单个决策树相比,袋装提高了准确性和 F1 分数,并减少了训练集和测试集的偏差。与决策树相比,从训练到测试的袋装模型性能下降较少。

您可以通过从袋装到随机森林来进一步提高预测性能。


袋装分类器

袋装减少方差的证明仅适用于回归和估计类概率,不适用于分类器(仅返回类成员资格,不返回类概率)。对不良分类器进行袋装可能会使其变得更糟。因此,如果您能获得类概率估计,您肯定希望在此基础上工作。但可以证明,在 R 中的 CART 树(决策树实现)在轻微假设下,袋装往往会提高分类器的准确性。有关更多详细信息,请参阅 Clifton D. Sutton 的“分类和回归树、袋装和提升”,《统计手册,第 24 卷》(Elsevier,2005)。


10.1.3. 使用随机森林进一步提高预测

在袋装中,使用随机数据集构建树,但每棵树都是通过考虑完全相同的特征集来构建的。这意味着所有单个树很可能使用非常相似的特征集(可能是不同的顺序或不同的分割值)。因此,单个树之间可能会过度相关。如果特征空间中有某些区域一棵树倾向于出错,那么所有树也可能会在那里出错,从而减少了我们的纠正机会。随机森林方法试图通过随机化每棵树允许使用的变量集来解耦树。

该过程在图 10.6 中显示。对于集成中的每棵单个树,随机森林方法执行以下操作:

  1. 从训练数据中抽取一个自助样本

  2. 对于每个样本,生长一棵决策树,并在树的每个节点

    1. p个总特征中随机抽取一个子集mtry变量

    2. mtry变量集中选择最佳变量和最佳分割

    3. 继续生长直到树完全成熟

图 10.6. 增长随机森林

然后将最终的树集合进行袋装,以生成随机森林预测。这相当复杂,但幸运的是,所有这些操作都由单行随机森林调用完成。

默认情况下,R 中的randomForest()函数在每个节点上为回归树抽取mtry = p/3个变量,为分类树抽取m = sqrt(p)个变量。从理论上讲,随机森林对mtry值的敏感性并不高。较小的值会使树生长得更快;但如果你有很多变量可供选择,其中只有一小部分真正有用,那么使用较大的mtry更好,因为使用较大的mtry,你更有可能在树的生长过程中每一步都抽取到一些有用的变量。

从第 10.1 节中的数据继续,尝试使用随机森林构建一个垃圾邮件模型。

列表 10.3. 使用随机森林

library(randomForest)                                        ❶
set.seed(5123512)                                            ❷
fmodel <- randomForest(x = spamTrain[, spamVars],            ❸
         y = spamTrain$spam,
         ntree = 100,                                        ❹
         nodesize = 7,                                       ❺
         importance = TRUE)                                  ❻

pred <- predict(fmodel,
                spamTrain[, spamVars],
                type = 'prob')[, 'spam']

trainperf_rf <-  accuracyMeasures(predict(fmodel,            ❻
   newdata = spamTrain[, spamVars], type = 'prob')[, 'spam'],
   spamTrain$spam == "spam", name = "random forest, train")

testperf_rf <-  accuracyMeasures(predict(fmodel,
   newdata = spamTest[, spamVars], type = 'prob')[, 'spam'],
   spamTest$spam == "spam", name = "random forest, test")

perftable <- rbind(trainperf_rf, testperf_rf)
pandoc.table(perftable, justify = perf_justify)

##
##
## model                    accuracy       f1   dev.norm
## ---------------------- ---------- -------- ----------
## random forest, train       0.9884   0.9852     0.1440
## random forest, test        0.9498   0.9341     0.3011

❶ 加载 randomForest 包

❷ 设置伪随机种子为已知值,以尝试使随机森林运行可重复

❸ 调用 randomForest()函数,以解释变量作为 x,预测类别作为 y 来构建模型

❹ 使用 100 棵树以与我们的袋装示例兼容。默认为 500 棵树。

❺ 指定树的每个节点必须至少有 7 个元素,以与 rpart()在此训练集上使用的默认最小节点大小兼容

❻ 告诉算法将信息保存下来,用于计算变量重要性(我们稍后会看到)

❻ 报告模型质量

你可以总结你所查看的所有三个模型的成果。首先,在训练数据上:

trainf <- rbind(trainperf_tree, trainperf_bag, trainperf_rf)
pandoc.table(trainf, justify = perf_justify)
##
##
## model                    accuracy       f1   dev.norm
## ---------------------- ---------- -------- ----------
## tree, training             0.8996   0.8691     0.6304
## bagging, training          0.9160   0.8906     0.5106
## random forest, train       0.9884   0.9852     0.1440

然后,在测试数据上:

testf <- rbind(testperf_tree, testperf_bag, testperf_rf)
pandoc.table(testf, justify = perf_justify)
##
##
## model                   accuracy       f1   dev.norm
## --------------------- ---------- -------- ----------
## tree, test                0.8712   0.8280     0.7531
## bagging, test             0.9105   0.8791     0.5834
## random forest, test       0.9498   0.9341     0.3011

随机森林模型在训练和测试数据上都比其他两个模型表现得好得多。

你还可以查看性能变化:从训练到测试时准确性和 F1 的下降,以及偏差的增加。

difff <- data.frame(model = c("tree", "bagging", "random forest"),
                  accuracy = trainf$accuracy - testf$accuracy,
                  f1 = trainf$f1 - testf$f1,
                  dev.norm = trainf$dev.norm - testf$dev.norm)

pandoc.table(difff, justify=perf_justify)

##
##
## model             accuracy        f1   dev.norm
## --------------- ---------- --------- ----------
## tree              0.028411   0.04111   -0.12275
## bagging           0.005523   0.01158   -0.07284
## random forest     0.038633   0.05110   -0.15711

当从训练数据到测试数据时,随机森林模型退化程度与单个决策树相当,并且比袋装模型要多得多。这是随机森林模型的一个缺点:过度拟合训练数据的倾向。然而,在这种情况下,随机森林模型仍然是表现最好的。


随机森林可以过拟合!

在随机森林的支持者中,有一个传说:“随机森林不会过拟合。”实际上,它们可以。Hastie 等人在这本《统计学习的要素》中关于随机森林的章节中支持了这个观察结果(Springer,2011 年)。在训练数据上看到几乎完美的预测,而在保留数据上表现不佳,这是随机森林模型的特点。因此,在使用随机森林时,在保留数据上验证模型性能非常重要。


检查变量重要性

randomForest()函数的一个有用特性是其变量重要性计算。由于该算法使用大量自助样本,每个数据点x都有一个相应的袋外样本集:那些不包含点x的样本。这在图 10.7 中对于数据点x1进行了展示。袋外样本可以像N-折交叉验证一样使用,以估计集成中每棵树的准确性。

图 10.7. 数据点x1的袋外样本

图 10.7 的替代文本

为了估计变量v1的“重要性”,随机排列该变量的值。然后,评估每棵树对其袋外样本,并估计每棵树准确性的相应下降。这已在图 10.8 中展示。

图 10.8. 计算变量v1的重要性

图 10.8 的替代文本

如果所有树的平均下降幅度很大,则认为该变量很重要——其值对预测结果有很大影响。如果平均下降幅度很小,则该变量对结果影响不大。算法还测量了在排列变量(如何影响树的质量)上分割时节点纯度的下降。

你可以通过在randomForest()调用中设置importance = TRUE来计算变量重要性(就像你在列表 10.3 中所做的那样),然后调用importance()varImpPlot()函数。

列表 10.4. randomForest变量重要性

varImp <- importance(fmodel)                                       ❶

varImp[1:10, ]                                                     ❷
##                     non-spam      spam MeanDecreaseAccuracy
## word.freq.make      1.656795  3.432962             3.067899
## word.freq.address   2.631231  3.800668             3.632077
## word.freq.all       3.279517  6.235651             6.137927
## word.freq.3d        3.900232  1.286917             3.753238
## word.freq.our       9.966034 10.160010            12.039651
## word.freq.over      4.657285  4.183888             4.894526
## word.freq.remove   19.172764 14.020182            20.229958
## word.freq.internet  7.595305  5.246213             8.036892
## word.freq.order     3.167008  2.505777             3.065529
## word.freq.mail      3.820764  2.786041             4.869502

varImpPlot(fmodel, type = 1)                                       ❸

❶ 在垃圾邮件模型上调用importance()

importance()函数返回一个重要性度量矩阵(值越大表示越重要)。

❸ 以准确性变化衡量变量重要性的图

varImpPlot()调用的结果在图 10.9 中展示。根据图表,确定一封电子邮件是否为垃圾邮件最重要的变量是char.freq.bang,即电子邮件中感叹号出现的次数,这在某种程度上是有直觉意义的。下一个最重要的变量是word.freq.remove,即电子邮件中“remove”一词出现的次数。

图 10.9. 垃圾邮件模型中最重要变量的准确性测量图

图 10.9 的替代文本

了解哪些变量最重要(或者至少,哪些变量对底层决策树的结构贡献最大)可以帮助你进行变量减少。这不仅有助于构建更小、更快的树,还可以在选择用于其他建模算法的变量时发挥作用。在这个垃圾邮件示例中,我们可以将变量数量从 57 减少到 30,而不会影响最终模型的质量。


变量筛选作为初步筛选

数据科学家 Jeremy Howard(Kaggle 和 fast.ai 的知名人士)是使用初始变量重要性筛选的强烈支持者,在数据科学项目的早期阶段消除不感兴趣的变量,并确定与业务伙伴讨论的变量。


列表 10.5. 使用较少变量进行拟合

sorted <- sort(varImp[, "MeanDecreaseAccuracy"],       ❶
                decreasing = TRUE)

selVars <- names(sorted)[1:30]
fsel <- randomForest(x = spamTrain[, selVars],         ❷
                         y = spamTrain$spam,
                        ntree = 100,
                        nodesize = 7,
                        importance = TRUE)

trainperf_rf2 <- accuracyMeasures(predict(fsel,
   newdata = spamTrain[, selVars], type = 'prob')[, 'spam'],
   spamTrain$spam == "spam", name = "RF small, train")

testperf_rf2 <- accuracyMeasures(predict(fsel,
   newdata=spamTest[, selVars], type = 'prob')[, 'spam'],
   spamTest$spam == "spam", name = "RF small, test")

perftable <- rbind(testperf_rf, testperf_rf2)          ❸
pandoc.table(perftable, justify = perf_justify)
##
##
## model                   accuracy       f1   dev.norm
## --------------------- ---------- -------- ----------
## random forest, test       0.9498   0.9341     0.3011
## RF small, test            0.9520   0.9368     0.4000

❶ 按照准确度变化率对变量进行排序

❷ 仅使用 30 个最重要的变量构建随机森林模型

❸ 在测试集上比较两个随机森林模型

较小的模型与使用所有 57 个变量构建的随机森林模型表现相当。


随机森林变量重要性与 LIME

随机森林变量重要性衡量单个变量对模型整体预测性能的重要性。它们告诉你哪些变量通常对模型的预测影响最大,或者模型最依赖哪些变量。

LIME 变量重要性(在第 6.3 节 section 6.3 中讨论)衡量不同变量对模型在特定示例上的预测影响程度。LIME 解释可以帮助你确定模型是否适当地使用了其变量,通过解释特定的决策。


10.1.4. 梯度提升树

梯度提升是另一种集成方法,它通过逐步向现有集成中添加树来提高决策树的表现。与 bagging 和随机森林不同,梯度提升不是简单地平均多个树的预测,而是试图通过增量添加树来提高预测性能。步骤如下:

  1. 使用当前的集成TE对训练数据进行预测。

  2. 测量真实结果与训练数据上的预测之间的残差。

  3. 将新树T_i拟合到残差。将T_i添加到集成TE中。

  4. 继续直到残差消失,或达到另一个停止标准。

该过程在图 10.10 中进行了概述。

图 10.10. 构建梯度提升树模型

梯度提升树也可能过拟合,因为某个时刻残差只是随机噪声。为了减轻过拟合,大多数梯度提升的实现都提供了交叉验证方法,以帮助确定何时停止向集成中添加树。

当我们在第 6.3 节 section 6.3 中讨论 LIME 时,你看到了梯度提升的例子,其中使用xgboost包拟合了梯度提升树模型。在本节中,我们将更详细地介绍你在第 6.3 节 section 6.3 中使用的建模代码。

鸢尾花示例

让我们从一个小例子开始。


示例

假设你有一个包含三种鸢尾花品种花瓣和萼片尺寸的数据集。目标是根据花瓣和萼片的尺寸预测给定的鸢尾花是否为 setosa 品种。


列表 10.6. 加载鸢尾花数据

iris <- iris
iris$class <- as.numeric(iris$Species == "setosa")       ❶

set.seed(2345)
intrain <- runif(nrow(iris)) < 0.75                      ❷
train <- iris[intrain, ]
test <- iris[!intrain, ]
head(train)

##   Sepal.Length Sepal.Width Petal.Length Petal.Width Species class
## 1          5.1         3.5          1.4         0.2  setosa     1
## 2          4.9         3.0          1.4         0.2  setosa     1
## 3          4.7         3.2          1.3         0.2  setosa     1
## 4          4.6         3.1          1.5         0.2  setosa     1
## 5          5.0         3.6          1.4         0.2  setosa     1
## 6          5.4         3.9          1.7         0.4  setosa     1

input <- as.matrix(train[, 1:4])                         ❸

❶ setosa 是正类。

❷ 将数据分为训练集和测试集(75%/25%)

❸ 创建输入矩阵

注意xgboost需要其输入为数值矩阵(没有分类变量),所以在列表 10.6 中,你从训练数据框中获取输入数据并创建一个输入矩阵。

在第 6.3 节中,你使用预提供的便利函数fit_iris_example()拟合了鸢尾花模型;这里我们将详细解释该函数中的代码。第一步是运行交叉验证函数xgb.cv()以确定要使用的树的数量。

列表 10.7. 交叉验证以确定模型大小

library(xgboost)

cv <- xgb.cv(input,                                     ❶

            label = train$class,                        ❷

              params = list(
                objective = "binary:logistic"           ❸
               ),
              nfold = 5,                                ❹
              nrounds = 100,                            ❺
              print_every_n = 10,                       ❻

              metrics = "logloss")                      ❻

evalframe <- as.data.frame(cv$evaluation_log)           ❽

head(evalframe)                                         ❾

##   iter train_logloss_mean train_logloss_std test_logloss_mean
## 1    1          0.4547800      7.758350e-05         0.4550578
## 2    2          0.3175798      9.268527e-05         0.3179284
## 3    3          0.2294212      9.542411e-05         0.2297848
## 4    4          0.1696242      9.452492e-05         0.1699816
## 5    5          0.1277388      9.207258e-05         0.1280816
## 6    6          0.0977648      8.913899e-05         0.0980894
##   test_logloss_std
## 1      0.001638487
## 2      0.002056267
## 3      0.002142687
## 4      0.002107535
## 5      0.002020668
## 6      0.001911152

(NROUNDS <- which.min(evalframe$test_logloss_mean))    ❿)
## [1] 18

library(ggplot2)
ggplot(evalframe, aes(x = iter, y = test_logloss_mean)) +
  geom_line() +
  geom_vline(xintercept = NROUNDS, color = "darkred", linetype = 2) +
  ggtitle("Cross-validated log loss as a function of ensemble size")

❶ 输入矩阵

❷ 类标签也必须是数字(1 代表 setosa,0 代表非 setosa)

❸ 使用“binary:logistic”目标函数进行二分类,使用“reg:linear”进行回归

❹ 使用 5 折交叉验证

❺ 构建包含 100 棵树的集成

❻ 每 10 次迭代打印一条消息(使用 verbose = FALSE 不打印消息)

❻ 使用最小交叉验证对数损失(与偏差相关)来选择最佳树的数量。对于回归,使用指标“rmse”。

❽ 获取性能日志

evalframe记录了训练和交叉验证对数损失作为树数量的函数。

❿ 找到给出最小交叉验证对数损失的树的数量

图 10.11 显示了交叉验证对数损失作为树数量的函数。在这种情况下,xgb.cv()估计 18 棵树给出了最佳模型。一旦你知道要使用的树的数量,你就可以调用xgboost()来拟合适当的模型。

图 10.11. 验证集交叉验证对数损失作为集成大小的函数

列表 10.8. 拟合xgboost模型

model <- xgboost(data = input,
                 label = train$class,
                 params = list(
                    objective = "binary:logistic"
                  ),
                 nrounds = NROUNDS,
                 verbose = FALSE)

test_input <- as.matrix(test[, 1:4])      ❶
pred <- predict(model,  test_input)       ❷

accuracyMeasures(pred, test$class)

##   model accuracy f1   dev.norm
## 1 model        1  1 0.03458392

❶ 为测试数据创建输入矩阵

❷ 进行预测

模型在保留数据上预测完美,因为这是一个简单的问题。现在你已经熟悉了步骤,你可以尝试在更难的问题上使用xgboost:第 6.3.3 节中的电影评论分类问题。

文本分类的梯度提升


示例

对于这个例子,你将使用来自互联网电影数据库(IMDB)的电影评论进行分类。任务是识别正面评论。


正如你在第 6.3.3 节中所做的那样,你将使用训练和测试数据,IMDBtrain.RDSIMDBtest.RDS,可以在github.com/WinVector/PDSwR2/tree/master/IMDB找到。每个RDS对象是一个包含两个元素的列表:一个表示 25,000 条评论的字符向量,以及一个表示标签的数值向量,其中 1 表示正面评论,0 表示负面评论。

首先,加载训练数据:

library(zeallot)
c(texts, labels) %<-% readRDS("IMDBtrain.RDS")

你必须将文本输入数据转换为数值表示。正如第 6.3.3 节中所述,你将训练数据转换为文档-词矩阵,实现为dgCMatrix类的稀疏矩阵。用于执行此转换的便利函数在github.com/WinVector/PDSwR2/tree/master/IMDB/lime_imdb_example.R中。接下来,你将创建语料库中的术语词汇表,然后为训练数据创建文档-词矩阵:

source("lime_imdb_example.R")
vocab <- create_pruned_vocabulary(texts)
dtm_train <- make_matrix(texts, vocab)

配置模型的第一步是确定要使用的树的数量。这可能需要一些时间。

cv <- xgb.cv(dtm_train,
             label = labels,
             params = list(
               objective = "binary:logistic"
               ),
             nfold = 5,
             nrounds = 500,
             early_stopping_rounds = 20,     ❶
             print_every_n = 10,
             metrics = "logloss")

evalframe <- as.data.frame(cv$evaluation_log)
(NROUNDS <- which.min(evalframe$test_logloss_mean))
## [1] 319

❶ 如果 20 轮内性能没有提高,则提前停止。

然后拟合模型并评估它:

model <- xgboost(data = dtm_train, label = labels,
                  params = list(
                    objective = "binary:logistic"
                  ),
                  nrounds = NROUNDS,
                  verbose = FALSE)

pred = predict(model, dtm_train)
trainperf_xgb =  accuracyMeasures(pred, labels, "training")

c(test_texts, test_labels) %<-% readRDS("IMDBtest.RDS")      ❶
dtm_test = make_matrix(test_texts, vocab)

pred = predict(model, dtm_test)
testperf_xgb = accuracyMeasures(pred, test_labels, "test")

perftable <- rbind(trainperf_xgb, testperf_xgb)
pandoc.table(perftable, justify = perf_justify)
##
##
## model        accuracy       f1   dev.norm
## ---------- ---------- -------- ----------
## training       0.9891   0.9891     0.1723
## test           0.8725   0.8735     0.5955

❶ 加载测试数据并将其转换为文档-词矩阵

与随机森林一样,这个梯度提升模型在训练数据上给出了近乎完美的性能,在保留数据上则略逊一筹,但仍然相当不错。尽管交叉验证步骤建议使用 319 棵树,你可能还想检查evalframe(就像你在鸢尾花示例中所做的那样),并尝试不同的树的数量,看看是否可以减少过拟合。


梯度提升模型与随机森林

在我们自己的工作中,我们发现梯度提升模型在大多数我们已经尝试过的问题上往往优于随机森林。然而,偶尔会有梯度提升模型表现不佳,而随机森林模型给出可接受性能的情况。你的经验可能不同。无论如何,保留这两种方法在你的工具箱中是个好主意。


使用 xgboost 处理分类变量

在鸢尾花示例中,所有输入变量都是数值型的;在电影评论示例中,你将非结构化文本输入转换为结构化、数值矩阵表示。在许多情况下,你将拥有具有分类级别的结构化输入数据,如下例所示。


示例

假设你想使用xgboost预测新生儿的出生体重作为几个变量的函数,这些变量既有数值型也有分类型。


本例中的数据来自 2010 年 CDC 出生数据集;它与你在第七章中用于预测风险出生的数据相似。^([[5)]

数据集可以在github.com/WinVector/PDSwR2/blob/master/CDC/NatalBirthData.rData找到。

列表 10.9. 加载出生数据

load("NatalBirthData.rData")
train <- sdata[sdata$ORIGRANDGROUP <= 5, ]                            ❶

test <- sdata[sdata$ORIGRANDGROUP >5 , ]

input_vars <- setdiff(colnames(train), c("DBWT", "ORIGRANDGROUP"))    ❷

str(train[, input_vars])

## 'data.frame':    14386 obs. of  11 variables:
##  $ PWGT      : int  155 140 151 160 135 180 200 135 112 98 ...
##  $ WTGAIN    : int  42 40 1 47 25 20 24 51 36 22 ...
##  $ MAGER     : int  30 32 34 32 24 25 26 26 20 22 ...
##  $ UPREVIS   : int  14 13 15 1 4 10 14 15 14 10 ...
##  $ CIG_REC   : logi  FALSE FALSE FALSE TRUE FALSE FALSE ...
##  $ GESTREC3  : Factor w/ 2 levels ">= 37 weeks",..: 1 1 1 2 1 1 1 1 1 1 ...
##  $ DPLURAL   : Factor w/ 3 levels "single","triplet or higher",..: 1 1 1 1 1 1 1 1 1 1 ...
##  $ URF_DIAB  : logi  FALSE FALSE FALSE FALSE FALSE FALSE ...
##  $ URF_CHYPER: logi  FALSE FALSE FALSE FALSE FALSE FALSE ...
##  $ URF_PHYPER: logi  FALSE FALSE FALSE FALSE FALSE FALSE ...
##  $ URF_ECLAM : logi  FALSE FALSE FALSE FALSE FALSE FALSE ...

❶ 将数据分为训练集和测试集

❷ 使用模型中的所有变量。DBWT(婴儿出生体重)是要预测的值,ORIGRANDGROUP 是分组变量。

如你所见,输入数据包含数值变量、逻辑变量和分类(因子)变量。如果你想使用 xgboost() 来拟合一个使用所有这些变量的梯度提升模型以预测婴儿的出生体重,你必须将输入转换为全数值数据。有几种方法可以实现这一点,包括基础 R 的 model.matrix() 函数。我们推荐使用 vtreat,正如你在第八章中所做的那样。

对于这个场景,你可以使用 vtreat 的三种方法:

  • 将数据分为三个集合:校准/训练/测试。使用 designTreatmentsN() 与校准集一起创建治疗方案;使用 prepare() 准备训练集以拟合 xgboost 模型;然后使用 prepare() 准备测试集以验证模型。当你拥有一个包含复杂变量(具有大量可能级别的分类变量)或大量分类变量的大型训练集时,这是一个不错的选择。如果你希望在拟合模型之前剪枝一些变量(使用显著性剪枝——参见 第 8.4.2 节),这也是一个好的选择。

  • 将数据分为训练/测试集(正如我们在这里所做的那样)。使用 mkCrossFrameNExperiment() 创建治疗方案和交叉帧以训练 xgboost 模型;使用 prepare() 准备测试集以验证模型。当你没有足够的训练数据来分为三组,但你拥有复杂变量或大量分类变量,以及/或者你希望在拟合模型之前剪枝一些变量时,这是一个不错的选择。

  • 将数据分为训练/测试集。使用 designTreatmentsZ() 创建一个处理计划,该计划管理缺失值并将分类变量转换为指示变量。使用 prepare() 准备训练集和测试集以创建纯数值输入。这个解决方案与调用 model.matrix() 类似,但增加了管理缺失值和优雅地处理某些分类级别只在训练或测试中出现,而不在两者中都出现的情况的优势。当你只有少量分类变量,且没有任何变量过于复杂时,这是一个很好的解决方案。

由于在这个场景中只有两个分类变量,而且它们都不太复杂(GESTREC3 有两个值,DPLURAL 有三个值),你可以使用第三种方法。

列表 10.10. 使用 vtreat 准备 xgboost 的数据

library(vtreat)

treatplan <- designTreatmentsZ(train,                                        ❶
                               input_vars,
                               codeRestriction = c("clean", "isBAD", "lev" ),❷
                               verbose = FALSE)

train_treated <- prepare(treatplan, train)                                   ❸
str(train_treated)

## 'data.frame':    14386 obs. of  14 variables:
##  $ PWGT                           : num  155 140 151 160 135 180 200 135 112 98 ...
##  $ WTGAIN                         : num  42 40 1 47 25 20 24 51 36 22 ...
##  $ MAGER                          : num  30 32 34 32 24 25 26 26 20 22 ...
##  $ UPREVIS                        : num  14 13 15 1 4 10 14 15 14 10 ...
##  $ CIG_REC                        : num  0 0 0 1 0 0 0 0 0 0 ...
##  $ URF_DIAB                       : num  0 0 0 0 0 0 0 0 0 0 ...
##  $ URF_CHYPER                     : num  0 0 0 0 0 0 0 0 0 0 ...
##  $ URF_PHYPER                     : num  0 0 0 0 0 0 0 0 0 0 ...
##  $ URF_ECLAM                      : num  0 0 0 0 0 0 0 0 0 0 ...
##  $ GESTREC3_lev_x_37_weeks        : num  0 0 0 1 0 0 0 0 0 0 ...
##  $ GESTREC3_lev_x_37_weeks_1      : num  1 1 1 0 1 1 1 1 1 1 ...
##  $ DPLURAL_lev_x_single           : num  1 1 1 1 1 1 1 1 1 1 ...
##  $ DPLURAL_lev_x_triplet_or_higher: num  0 0 0 0 0 0 0 0 0 0 ...
##  $ DPLURAL_lev_x_twin             : num  0 0 0 0 0 0 0 0 0 0 ...

❶ 创建治疗方案

❷ 创建干净的数值变量(“clean”)、缺失值指示器(“isBad”)、指示变量(“lev”),但不包括 catP(患病率)变量

❸ 准备训练数据

注意train_treated完全是数值的,没有缺失值,并且不包含结果列,因此可以安全地与xgboost一起使用(尽管你必须首先将其转换为矩阵)。为了演示这一点,以下列表直接将 50 棵树的梯度提升模型拟合到准备好的训练数据(没有交叉验证来选择最佳大小),然后将模型应用于准备好的测试数据。这只是为了演示目的;通常你想要先调用xgb.cv()来选择合适的树的数量。

列表 10.11. 对出生体重进行xgboost模型的拟合和应用

birthwt_model <- xgboost(as.matrix(train_treated),
                         train$DBWT,
                         params = list(
                           objective = "reg:linear",
                           base_score = mean(train$DBWT)
                         ),
                         nrounds = 50,
                         verbose = FALSE)
test_treated <- prepare(treatplan, test)
pred <- predict(birthwt_model, as.matrix(test_treated))

练习:尝试使用 xgboost 来解决出生体重问题。

尝试使用xgboost来预测DBWT,即设置数据和运行前面的代码。


Bagging、随机森林和梯度提升是事后可以尝试的改进,以提高决策树模型。在下一节中,你将使用广义加性模型,它使用不同的方法来表示输入和输出之间的非线性关系。

10.1.5. 基于树的模型要点

关于基于树的模型,你应该记住以下几点:

  • 树对于建模输入和输出之间具有非线性关系的数据以及变量之间的潜在交互是有用的。

  • 基于树的集成通常比基本的决策树模型有更好的性能。

  • Bagging 通过减少方差来稳定决策树并提高准确性。

  • 随机森林和梯度提升树都可能倾向于在训练数据上过拟合。确保在保留数据上评估模型,以获得更好的模型性能估计。

10.2. 使用广义加性模型(GAMs)来学习非单调关系

在第七章中,你使用了线性回归来建模和预测定量输出,以及逻辑回归来预测类别概率。线性回归和逻辑回归模型是强大的工具,特别是当你想了解输入变量和输出变量之间的关系时。它们对相关变量(当正则化时)具有鲁棒性,逻辑回归保留了数据的边缘概率。这两个模型的主要缺点是它们假设输入和输出之间的关系是单调的。也就是说,如果更多是好的,那么更多总是更好的。

但如果实际关系是非单调的呢?考虑一下你在本章开头看到的 BMI 例子。对于体重不足的成年人,增加 BMI 可以降低死亡率。但有一个限度:在某个点上,更高的 BMI 会变得有害,随着 BMI 的增加,死亡率也会上升。线性回归和逻辑回归无法捕捉到这种区别。对于我们正在处理的数据,如图 10.12 所示,线性模型会预测死亡率总是随着 BMI 的增加而降低。

图 10.12. BMI 对死亡率的影响:线性模型与 GAM

广义加性模型(GAMs)是在线性或逻辑模型(或任何其他广义线性模型)框架内对非单调响应进行建模的一种方法。在死亡率示例中,GAM 将尝试找到一个好的“U 形”函数 s(BMI),来描述 BMI 与死亡率之间的关系,如图 图 10.12 所示。GAM 然后将拟合一个函数来根据 s(BMI) 预测死亡率。

10.2.1. 理解 GAMs

记住,如果 y[i] 是你想要预测的数值量,而 x[i, ] 是对应于输出 y[i] 的输入行,那么线性回归找到一个函数 f(x),使得

f(x[i, ]) = b0 + b[1] * x[i, 1] + b[2] * x[i, 2] + ... b[n] * x[i, n]

并且 f(x[i, ]) 尽可能接近 y[i]

在其最简单形式中,GAM 模型放宽了线性约束,并找到一组函数 s_i()(以及一个常数项 a0),使得

f(x[i,]) = a0 + s_1(x[i, 1]) + s_2(x[i, 2]) + ... s_n(x[i, n])

我们也希望 f(x[i, ]) 尽可能接近 y[i]。函数 s_i() 是由多项式构建的平滑曲线拟合。这些曲线被称为 样条,它们被设计成尽可能接近数据点而不太“扭曲”(不过度拟合)。一个样条拟合的例子在 图 10.13 中展示。

图 10.13. 通过一系列点拟合的样条

让我们来看一个具体的例子。

10.2.2. 一维回归示例

首先,考虑这个玩具示例。


示例

假设你想要将模型拟合到数据中,其中响应 y 是输入变量 x 的噪声非线性函数(实际上,它是 图 10.13 中显示的函数)。*


如同往常,我们将数据分为训练集和测试集。

列表 10.12. 准备一个人工问题

set.seed(602957)

x <- rnorm(1000)
noise <- rnorm(1000, sd = 1.5)

y <- 3 * sin(2 * x) + cos(0.75 * x) - 1.5 * (x²) + noise

select <- runif(1000)
frame <- data.frame(y = y, x = x)

train <- frame[select > 0.1, ]
test <-frame[select <= 0.1, ]

由于数据来自非线性函数 sin()cos(),从 xy 不应该有一个好的线性拟合。我们将首先构建一个(较差的)线性回归。

列表 10.13. 将线性回归应用于人工示例

lin_model <- lm(y ~ x, data = train)
summary(lin_model)

##
## Call:
## lm(formula = y ~ x, data = train)
##
## Residuals:
##     Min      1Q  Median      3Q     Max
## -17.698  -1.774   0.193   2.499   7.529
##
## Coefficients:
##             Estimate Std. Error t value Pr(>|t|)
## (Intercept)  -0.8330     0.1161  -7.175 1.51e-12 ***
## x             0.7395     0.1197   6.180 9.74e-10 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 3.485 on 899 degrees of freedom
## Multiple R-squared:  0.04075,    Adjusted R-squared:  0.03968
## F-statistic: 38.19 on 1 and 899 DF,  p-value: 9.737e-10

rmse <- function(residuals) {                      ❶
   sqrt(mean(residuals²))
}

train$pred_lin <- predict(lin_model, train)        ❷
resid_lin <- with(train, y - pred_lin)
rmse(resid_lin)
## [1] 3.481091

library(ggplot2)                                   ❸

ggplot(train, aes(x = pred_lin, y = y)) +
  geom_point(alpha = 0.3) +
  geom_abline()

❶ 计算残差向量中均方根误差(RMSE)的便利函数

❷ 计算此模型在训练数据上的 RMSE

❸ 绘制 y 与预测值的对比图

结果模型的预测与真实响应在 图 10.14 中绘制。正如预期的那样,这是一个非常差的拟合,R-squared 约为 0.04。特别是,误差 不是同方差:存在模型系统性地低估和系统性地高估的区域。如果 xy 之间的关系确实是线性的(具有独立的噪声),那么误差将是 同方差:误差将在预测值周围均匀分布(均值为 0)。

图 10.14. 线性模型的预测与实际响应。实线是完美预测的线(预测 == 实际)。

现在尝试找到一个将 x 映射到 y 的非线性模型。我们将使用 mgcv 包中的 gam() 函数。^([6]) 当使用 gam() 函数时,你可以将变量建模为线性或非线性。通过将变量 x 包裹在 s() 符号中,你可以将其建模为非线性。在这个例子中,你不会使用公式 y ~ x 来描述模型,而是使用公式 y ~ s(x)。然后 gam() 将搜索最佳样条 s() 来描述 xy 之间的关系,如 代码列表 10.14 所示。只有被 s() 包围的项才会接受 GAM/样条处理。

有一个名为 gam 的旧包,由 GAM 的发明者 Hastie 和 Tibshirani 编写。gam 包运行良好。但它与 mgcv 包不兼容,而 ggplot 已经加载了 mgcv 包。由于我们使用 ggplot 进行绘图,我们将使用 mgcv 作为我们的示例。

列表 10.14. 将 GAM 应用于人工示例

library(mgcv)                                                       ❶
gam_model <- gam(y ~ s(x), data = train)                            ❷
gam_model$converged                                                 ❸
## [1] TRUE

summary(gam_model)

## Family: gaussian                                                 ❹
## Link function: identity
##
## Formula:
## y ~ s(x)
##
## Parametric coefficients:                                         ❺
##             Estimate Std. Error t value Pr(>|t|)
## (Intercept) -0.83467    0.04852   -17.2   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Approximate significance of smooth terms:                        ❻
##        edf Ref.df     F p-value
## s(x) 8.685  8.972 497.8  <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## R-sq.(adj) =  0.832   Deviance explained = 83.4%                 ❻
## GCV score =  2.144  Scale est. = 2.121     n = 901

train$pred <- predict(gam_model, train)                             ❽
resid_gam <- with(train, y - pred)
rmse(resid_gam)

## [1] 1.448514

ggplot(train, aes(x = pred, y = y)) +                               ❾
  geom_point(alpha = 0.3) +
  geom_abline()

❶ 加载 mgcv 包

❷ 构建模型,指定 x 应被视为非线性变量

❸ 收敛参数告诉你算法是否收敛。只有当此值为 TRUE 时,你才应该相信输出结果。

❹ 设置 family = gaussian 和 link = identity 告诉你模型被处理为与标准线性回归相同的分布假设。

❺ 参数系数是线性项(在这个例子中,只有常数项)。本节总结告诉你哪些线性项与 0 显著不同。

❻ 平滑项是非线性项。本节总结告诉你哪些非线性项与 0 显著不同。它还告诉你构建每个平滑项使用的有效自由度(edf)。edf 接近 1 表示变量与输出有大约线性关系。

❻ R-sq.(adj) 是调整后的 R 平方。 “Deviance explained” 是原始 R 平方(0.834)。

❽ 计算此模型在训练数据上的 RMSE

❾ 绘制 y 与预测值的对比图

结果模型的预测值与真实响应值在 图 10.15 中进行对比。这个拟合效果要好得多:模型解释了超过 80% 的方差(R 平方为 0.83),并且在训练数据上的均方根误差(RMSE)小于线性模型的 RMSE 的一半。注意,图 10.15 中的点在大约均匀地分布在完美预测线的周围。GAM 被拟合为同方差,任何给定的预测值作为高估或低估的可能性相同。

图 10.15. GAM 的预测值与实际响应值。实线是理论上的完美预测线(预测值等于实际值)。


使用 gam() 建模线性关系

默认情况下,gam()将执行标准线性回归。如果你用公式y ~ x调用gam(),你会得到与使用lm()相同的结果。更普遍地,调用gam(y ~ x1 + s(x2), data=...)将变量x1建模为与y有线性关系,并尝试拟合最佳可能的平滑曲线来建模x2y之间的关系。当然,最佳平滑曲线可能是一条直线,所以如果你不确定xy之间的关系是否线性,你可以使用s(x)。如果你看到系数有一个edf(有效自由度——参见模型摘要列表 10.14),那么你可以尝试将变量重新拟合为线性项。


使用样条曲线为 GAM 提供了更丰富的模型空间来选择;这种增加的灵活性带来了更高的过拟合风险。你还应该检查模型在测试数据上的性能。

列表 10.15. 比较线性回归和 GAM 性能

test <- transform(test,                                  ❶
                  pred_lin = predict(lin_model, test),
                  pred_gam = predict(gam_model, test) )

test <- transform(test,                                  ❷
                  resid_lin = y - pred_lin,
                  resid_gam = y - pred_gam)

rmse(test$resid_lin)                                     ❸
## [1] 2.792653

rmse(test$resid_gam)
## [1] 1.401399

library(sigr)                                            ❹
 wrapFTest(test, "pred_lin", "y")$R2
## [1] 0.115395
wrapFTest(test, "pred_gam", "y")$R2
## [1] 0.777239

❶ 从两个模型在测试数据上获取预测值。函数transform()是 dplyr::mutate()的基础 R 版本。

❷ 计算残差

❸ 在测试数据上比较两个模型的均方根误差(RMSE)

❹ 使用 sigr 包在测试数据上比较两个模型的 R 平方

GAM 在训练集和测试集上的表现相似:测试集上的 RMSE 为 1.40,训练集上的 RMSE 为 1.45;测试集上的 R 平方为 0.78,训练集上的 R 平方为 0.83。所以很可能没有过拟合。

10.2.3. 提取非线性关系

一旦你拟合了一个广义可加模型(GAM),你可能会对s()函数的形状感兴趣。在 GAM 上调用plot()将为你提供每个s()曲线的图表,这样你可以可视化非线性。在我们的例子中,plot(gam_model)生成了图 10.16 中的顶部曲线。

图 10.16. 顶部:gam()发现的非线性函数s(PWGT),由plot(gam_model)输出。底部:相同的样条曲线叠加在训练数据上。

曲线的形状与我们看到的图 10.13(作为图 10.16 的下半部分重现)非常相似。实际上,图 10.13 中散点图上叠加的样条曲线与同一曲线相同。

你可以通过使用predict()函数并带有type = "terms"参数来提取用于制作此图的点。这将产生一个矩阵,其中第i列代表s(x[,i])。以下列表演示了如何重现图 10.16 中的下部分图。

列表 10.16. 从 GAM 中提取学习样条

sx <- predict(gam_model, type = "terms")
summary(sx)
##       s(x)
##  Min.   :-17.527035
##  1st Qu.: -2.378636
##  Median :  0.009427
##  Mean   :  0.000000
##  3rd Qu.:  2.869166
##  Max.   :  4.084999

xframe <- cbind(train, sx = sx[,1])

ggplot(xframe, aes(x = x)) +
     geom_point(aes(y = y), alpha = 0.4) +
     geom_line(aes(y = sx))

现在你已经完成了一个简单的例子,你就可以尝试一个包含更多变量的更现实的例子了。

10.2.4. 在实际数据上使用 GAM


示例

假设你想根据多个变量预测一个新生儿的体重(DBWT):

  • 母亲的体重(PWGT

  • 母亲的孕期体重增加(WTGAIN

  • 母亲的年龄(MAGER

  • 产前医疗访问次数(UPREVIS


对于这个例子,你将使用 2010 年 CDC 出生数据集中你用过的数据第 7.2 节(尽管这不是该章节中使用的风险数据)。^([7)请注意,我们选择这个例子是为了突出gam()的机制,而不是为了找到最佳出生体重模型。除了我们选择的四个变量之外,添加其他变量将提高拟合度,但会模糊说明。

数据集可在github.com/WinVector/PDSwR2/blob/master/CDC/NatalBirthData.rData找到。从原始 CDC 提取准备数据集的脚本可在github.com/WinVector/PDSwR2/blob/master/CDC/prepBirthWeightData.R找到。

在下一个列表中,你将拟合一个线性模型和一个 GAM,并进行比较。

列表 10.17. 将线性回归(带 GAM 和不带 GAM)应用于健康数据

library(mgcv)
library(ggplot2)
load("NatalBirthData.rData")
train <- sdata[sdata$ORIGRANDGROUP <= 5, ]
test <- sdata[sdata$ORIGRANDGROUP > 5, ]

form_lin <- as.formula("DBWT ~ PWGT + WTGAIN + MAGER + UPREVIS")
linmodel <- lm(form_lin, data = train)                             ❶

summary(linmodel)

## Call:
## lm(formula = form_lin, data = train)
##
## Residuals:
##      Min       1Q   Median       3Q      Max
## -3155.43  -272.09    45.04   349.81  2870.55
##
## Coefficients:
##              Estimate Std. Error t value Pr(>|t|)
## (Intercept) 2419.7090    31.9291  75.784  < 2e-16 ***
## PWGT           2.1713     0.1241  17.494  < 2e-16 ***
## WTGAIN         7.5773     0.3178  23.840  < 2e-16 ***
## MAGER          5.3213     0.7787   6.834  8.6e-12 ***
## UPREVIS       12.8753     1.1786  10.924  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 562.7 on 14381 degrees of freedom
## Multiple R-squared:  0.06596,    Adjusted R-squared:  0.0657    ❷
## F-statistic: 253.9 on 4 and 14381 DF,  p-value: < 2.2e-16
form_gam <- as.formula("DBWT ~ s(PWGT) + s(WTGAIN) +
                        s(MAGER) + s(UPREVIS)")
gammodel <- gam(form_gam, data = train)                            ❸
gammodel$converged                                                 ❹
## [1] TRUE

summary(gammodel)

##
## Family: gaussian
## Link function: identity
##
## Formula:
## DBWT ~ s(PWGT) + s(WTGAIN) + s(MAGER) + s(UPREVIS)
##
## Parametric coefficients:
##             Estimate Std. Error t value Pr(>|t|)
## (Intercept) 3276.948      4.623   708.8   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Approximate significance of smooth terms:
##              edf Ref.df       F  p-value
## s(PWGT)    5.374  6.443  69.010  < 2e-16 ***
## s(WTGAIN)  4.719  5.743 102.313  < 2e-16 ***
## s(MAGER)   7.742  8.428   7.145 1.37e-09 ***
## s(UPREVIS) 5.491  6.425  48.423  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## R-sq.(adj) =  0.0927   Deviance explained = 9.42%               ❺
## GCV = 3.0804e+05  Scale est. = 3.0752e+05  n = 14386

❶ 使用四个变量构建线性模型

❷ 该模型解释了大约 6.6%的方差;所有系数均与 0 有显著差异。

❸ 使用相同的变量构建 GAM

❹ 验证模型已收敛

❺ 模型解释了略超过 9%的方差;所有变量都有与 0 显著不同的非线性效应。

GAM 提高了拟合度,所有四个变量似乎与出生体重有非线性关系,正如edf值都大于 1 所证明的。你可以使用plot(gammodel)来检查s()函数的形状;相反,让我们将它们与每个变量对母亲体重的直接平滑曲线进行比较。

列表 10.18. 绘制 GAM 结果

terms <- predict(gammodel, type = "terms")               ❶
terms <- cbind(DBWT = train$DBWT, terms)                 ❷

tframe <- as.data.frame(scale(terms, scale = FALSE))     ❸
colnames(tframe) <- gsub('[()]', '', colnames(tframe))   ❹

vars = c("PWGT", "WTGAIN", "MAGER", "UPREVIS")
pframe <- cbind(tframe, train[, vars])                   ❺

ggplot(pframe, aes(PWGT)) +                              ❻
  geom_point(aes(y = sPWGT)) +
  geom_smooth(aes(y = DBWT), se = FALSE)

# [...]                                                  ❻

❶ 获取s()函数的矩阵

❷ 绑定出生体重(DBWT

❸ 将所有列移至零均值(以便比较);转换为数据框

❹ 使列名便于引用(s(PWGT)转换为 sPWGT 等)

❺ 绑定输入变量

❻ 将样条 s(PWGT)与 DBWT(婴儿体重)作为母亲体重(PWGT)的函数的平滑曲线进行比较

❻ 对剩余变量重复操作(为简洁起见省略)

图 10.17 显示了gam()学习的s()样条曲线,这些曲线以虚线表示。这些样条曲线是gam()对每个变量与结果(DBWT)之间(联合)关系的估计。样条的总和(加上偏移量)是模型对DBWT作为输入变量的函数的最佳估计。

图 10.17. 四个输入变量对出生体重的平滑曲线,与gam()发现的样条进行比较。所有曲线都已移至零均值,以便比较形状。

图表还显示了直接与DBWT相关的平滑曲线。每个案例中的平滑曲线在形状上类似于相应的s(),并且对所有变量都是非线性的。形状上的差异是因为样条曲线是联合拟合的(这对建模更有用),而平滑曲线是单独计算的。

与往常一样,您应该使用保留数据来检查过拟合。

列表 10.19. 在保留数据上检查 GAM 模型性能

test <- transform(test,                                 ❶
                  pred_lin = predict(linmodel, test),
                  pred_gam = predict(gammodel, test) )

test <- transform(test,                                 ❷
                  resid_lin = DBWT - pred_lin,
                  resid_gam = DBWT - pred_gam)

rmse(test$resid_lin)                                    ❸
## [1] 566.4719

rmse(test$resid_gam)
## [1] 558.2978

wrapFTest(test, "pred_lin", "DBWT")$R2                  ❹
## [1] 0.06143168

wrapFTest(test, "pred_gam", "DBWT")$R2
## [1] 0.08832297

❶ 获取测试数据上两个模型的预测

❷ 获取残差

❸ 在测试数据上比较了两个模型的 RMSE

❹ 在测试数据上比较了两个模型的 R-squared 值,使用 sigr

在测试集上,线性模型和 GAM 的性能相似,与训练集上相似,因此在这个例子中,没有实质性的过拟合。

10.2.5. 使用 GAM 进行逻辑回归

gam()函数也可以用于逻辑回归。


示例

假设您想预测婴儿何时会出生体重不足(定义为DBWT < 2000),使用与先前场景相同的输入变量。*


执行此操作的逻辑回归调用在以下列表中显示。

列表 10.20. GLM 逻辑回归

form <- as.formula("DBWT < 2000 ~ PWGT + WTGAIN + MAGER + UPREVIS")
logmod <- glm(form, data = train, family = binomial(link = "logit"))

对应的gam()调用还指定了二项式族和logit连接。

列表 10.21. GAM 逻辑回归

form2 <- as.formula("DBWT < 2000 ~ s(PWGT) + s(WTGAIN) +
                                              s(MAGER) + s(UPREVIS)")
glogmod <- gam(form2, data = train, family = binomial(link = "logit"))
glogmod$converged
## [1] TRUE

summary(glogmod)
## Family: binomial
## Link function: logit
##
## Formula:
## DBWT < 2000 ~ s(PWGT) + s(WTGAIN) + s(MAGER) + s(UPREVIS)
##
## Parametric coefficients:
##             Estimate Std. Error z value Pr(>|z|)
## (Intercept) -3.94085    0.06794     -58   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Approximate significance of smooth terms:
##              edf Ref.df  Chi.sq  p-value
## s(PWGT)    1.905  2.420   2.463  0.36412                 ❶
## s(WTGAIN)  3.674  4.543  64.426 1.72e-12 ***
## s(MAGER)   1.003  1.005   8.335  0.00394 **
## s(UPREVIS) 6.802  7.216 217.631  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## R-sq.(adj) =  0.0331   Deviance explained = 9.14%        ❷
## UBRE score = -0.76987  Scale est. = 1         n = 14386

❶ 注意与母亲体重(PGWT)相关的大 p 值。这意味着没有统计证据表明母亲的体重(PWGT)对结果有显著影响。

❷ “偏差解释”是伪 R-squared:1 - (偏差/空偏差)。

与标准逻辑回归调用一样,我们通过调用predict(glogmodel, newdata = train, type = "response")恢复类别概率。同样,这些模型的质量较低,在实践中,我们会寻找更多解释变量来构建更好的筛选模型。

10.2.6. GAM 要点

关于 GAMs,您应该记住以下几点:

  • GAMs 让您能够在线性或逻辑回归框架中表示变量和结果之间的非线性和非单调关系。

  • mgcv包中,您可以使用predict()函数和type = "terms"参数从 GAM 模型中提取发现的关系。

  • 您可以使用与标准线性或逻辑回归相同的指标来评估 GAM:残差、偏差、R-squared 和伪 R-squared。gam()摘要还提供了哪些变量对模型有显著影响的指示。

  • 由于与标准线性或逻辑回归模型相比,GAMs 具有更高的复杂性,因此存在更大的过拟合风险。

GAMs 通过允许变量对结果产生非线性(甚至非单调)影响来扩展线性方法(以及广义线性方法)。另一种方法是通过对现有变量的非线性组合形成新的变量。数据科学家可以通过手动添加交互作用或新的合成变量来完成这项工作,或者可以通过支持向量机(SVMs)机械地完成,如下一节所示。希望有了足够多的这些新变量,你的建模问题就会变得更容易。

在下一节中,我们将探讨两种最流行的方法来添加和管理新变量:核方法支持向量机

10.3. 使用支持向量机解决“不可分”问题

一些分类问题被称为不可分:类 A 的实例位于由类 B 定义的区域内,因此类 A 不能通过一个平坦的边界与类 B 分开。例如,在图 10.18 中,我们看到许多 o 位于由 x 定义的三角形内部(我们也看到数据通过所谓的核函数phi()转换成了一个很好的可分离排列)。左侧的原始排列是线性不可分:没有超平面可以将 x 与 o 分开。因此,线性方法完全分离这两个类是不可能的。我们可以使用第 10.1 节中展示的基于树的模型来拟合分类器,或者我们可以使用一种称为核方法的技术。在本节中,我们将使用 SVMs 和核方法在线性不可分数据上构建良好的分类器。

图 10.18. 核变换的概念图(基于 Cristianini 和 Shawe-Taylor,2000)


有多种方法可以完成事情

在这一点上,我们已经看到了许多高级方法,它们为我们提供了处理复杂问题的多种方式。例如:随机森林、提升和 SVMs 都可以引入变量交互来解决问题。如果总有一种明显最好的方法那会很好。然而,每种方法对于不同的问题都可能占主导地位。因此,没有一种最好的方法。

我们的建议是首先尝试简单的方法,如线性回归和逻辑回归。然后引入并尝试高级方法,如 GAMs(可以处理单变量重塑)、基于树的模型(可以处理多变量交互)和 SVMs(可以处理多变量重塑)来解决建模问题。


10.3.1. 使用 SVM 解决问题

让我们从 R 的kernlab库文档中改编的一个例子开始。学习分离两个螺旋是一个著名的“不可能”问题,线性方法无法解决(尽管可以通过谱聚类、核方法、SVMs、深度学习或深度神经网络来解决)。


示例

图 10.19 显示了两个螺旋,一个在另一个内部。你的任务是构建一个决策过程,将平面切割成两个区域,使得 1 标记的例子在一个区域,2 标记的例子在互补区域。^([8])

参见 K. J. Lang 和 M. J. Witbrock 在 1988 年连接主义模型夏季学校的论文“学会区分两个螺旋”,D. Touretzky, G. Hinton, 和 T. Sejnowski(编),Morgan Kaufmann,1988(第 52-59 页)。


支持向量机在学习和“相邻的例子应该给予相同的分类”形式的概念方面表现出色。为了使用 SVM 技术,用户必须选择一个核(以控制“近”或“远”的定义),并选择一个超参数Cnu的值(以尝试控制模型复杂度)。

螺旋示例

列表 10.22 展示了图 10.19 中显示的两个螺旋的恢复和标记。你将使用标记的数据进行示例任务:给定标记数据,通过监督机器学习恢复 1 与 2 的区域。

图 10.19. 螺旋反例

列表 10.22. 将螺旋数据设置为分类问题

library('kernlab')
data(spirals)                                               ❶
 sc <- specc(spirals, centers = 2)                          ❷
 s <- data.frame(x = spirals[, 1], y = spirals[, 2],        ❸
    class = as.factor(sc))

library('ggplot2')
ggplot(data = s) +                                          ❹
                geom_text(aes(x = x, y = y,
                label = class, color = class)) +
  scale_color_manual(values = c("#d95f02", "#1b9e77")) +
  coord_fixed() +
  theme_bw() +
  theme(legend.position  = 'none') +
  ggtitle("example task: separate the 1s from the 2s")

❶ 加载 kernlab 核和 SVM 包,并要求提供包含的示例螺旋

❷ 使用 kernlab 的谱聚类程序识别示例数据集中的两个不同螺旋

❸ 将螺旋坐标和螺旋标签合并到一个数据框中

❹ 绘制带有类别标签的螺旋

图 10.19 展示了标记的螺旋数据集。两种数据类别(用数字表示)被安排在两个交织的螺旋中。这个数据集对于没有足够丰富概念空间(感知器、浅层神经网络)的方法来说很难,但对于可以引入正确新特征的更复杂的学习者来说很容易。使用正确核的支持向量机是一种引入新组合特征以解决问题的方式。

使用过简单核的支持向量机

支持向量机功能强大,但没有正确的核,它们在处理某些概念(如螺旋示例)时会有困难。列表 10.23 展示了使用恒等或点积(线性)核尝试使用 SVM 学习螺旋概念的失败尝试。线性核不对数据进行变换;它可以适用于某些应用,但在这个情况下,它没有给我们想要的数据分离特性。

列表 10.23. 使用核选择不当的 SVM

set.seed(2335246L)
s$group <- sample.int(100, size = dim(s)[[1]], replace = TRUE)
sTrain <- subset(s, group > 10)
sTest <- subset(s,group <= 10)                                             ❶

library('e1071')
mSVMV <- svm(class ~ x + y, data = sTrain, kernel = 'linear', type =
'nu-classification')                                                  ❷
 sTest$predSVMV <- predict(mSVMV, newdata = sTest, type = 'response')      ❸

shading <- expand.grid(                                                    ❹
  x = seq(-1.5, 1.5, by = 0.01),
  y = seq(-1.5, 1.5, by = 0.01))
shading$predSVMV <- predict(mSVMV, newdata = shading, type = 'response')

ggplot(mapping = aes(x = x, y = y)) +                                      ❺
  geom_tile(data = shading, aes(fill = predSVMV),
            show.legend = FALSE, alpha = 0.5) +
  scale_color_manual(values = c("#d95f02", "#1b9e77")) +
  scale_fill_manual(values = c("white", "#1b9e77")) +
  geom_text(data = sTest, aes(label = predSVMV),
            size = 12) +
  geom_text(data = s, aes(label = class, color = class),
            alpha = 0.7) +
  coord_fixed() +
  theme_bw() +
  theme(legend.position = 'none') +
  ggtitle("linear kernel")

❶ 准备使用 SVM 从坐标中学习螺旋类别标签

❷ 使用 vanilladot 核(不是一个很好的核)构建支持向量模型

❸ 使用模型对保留数据预测类别

❹ 在点网格上调用模型以生成表示学习概念的背景阴影

❺ 在所有数据的灰色副本上绘制预测,以便我们可以看到预测是否与原始标记一致

这次尝试的结果是图 10.20。图中以小字体显示了整个数据集,以大字体显示了测试数据集的 SVM 分类。它还通过阴影指示了学习到的概念。SVM 没有使用标识核产生一个好的模型,因为它被迫选择线性分离器。在下文中,你将使用高斯径向核重复这个过程,并得到更好的结果。

图 10.20. 标识核无法学习螺旋概念

具有良好核的支持向量机

在列表 10.24 中,你将重复 SVM 拟合过程,但这次指定高斯或径向核。图 10.21 再次以黑色(整个数据集以较小的字体显示)绘制了 SVM 测试分类。注意这次算法正确地学习了实际的螺旋概念,如阴影所示。

图 10.21. 径向核成功学习螺旋概念

列表 10.24. 选择良好核的 SVM

mSVMG <- svm(class ~ x + y, data = sTrain, kernel = 'radial', type =
'nu-classification')                                               ❶
sTest$predSVMG <- predict(mSVMG, newdata = sTest, type = 'response')

shading <- expand.grid(
  x = seq(-1.5, 1.5, by = 0.01),
  y = seq(-1.5, 1.5, by = 0.01))
shading$predSVMG <- predict(mSVMG, newdata = shading, type = 'response')

ggplot(mapping = aes(x = x, y = y)) +
  geom_tile(data = shading, aes(fill = predSVMG),
            show.legend = FALSE, alpha = 0.5) +
  scale_color_manual(values = c("#d95f02", "#1b9e77")) +
  scale_fill_manual(values = c("white", "#1b9e77")) +
  geom_text(data = sTest, aes(label = predSVMG),
            size = 12) +
  geom_text(data = s,aes(label = class, color = class),
            alpha = 0.7) +
  coord_fixed() +
  theme_bw() +
  theme(legend.position = 'none') +
  ggtitle("radial/Gaussian kernel")

❶ 这次使用“径向”或高斯核,这是一个很好的几何距离度量


练习:尝试使用 xgboost 解决螺旋问题。

正如我们所说的,一些方法在某些问题上比其他方法更有效。尝试使用 xgboost 包解决螺旋问题。你发现xgboost的结果比 SVM 的结果更好还是更差?(这个示例的工作版本可以在以下位置找到:github.com/WinVector/PDSwR2/tree/master/Spirals。)


10.3.2. 理解支持向量机

支持向量机通常被描绘成一个使分类变得更容易的魔法机器。9 为了消除敬畏并能够自信地使用支持向量方法,我们需要花些时间学习它们的原则和它们是如何工作的。直觉是这样的:具有径向核的支持向量机是非常好的近邻式分类器

支持向量机也可以用于回归,但这里我们不会涉及。

在图 10.22 中,在“真实空间”(左侧),数据通过非线性边界分离。当数据提升到更高维的核空间(右侧)时,提升的点通过超平面分离。让我们称该超平面的法线为w,从原点偏移为b(未显示)。

图 10.22. SVM 的概念性说明

SVM 找到一个线性决策函数(由参数wb确定),对于给定的示例x,机器决定x属于该类,如果

w %*% phi(x) + b >= 0

对于某些wb,否则不在该类中。该模型完全由函数phi()、向量w和标量偏移b确定。其想法是phi()将数据提升或重塑到更合适的空间(其中事物是线性可分的),然后 SVM 在这个新空间中找到分离两个数据类的线性边界(由wb表示)。这个提升空间中的线性边界可以拉回到原始空间中的通用曲线边界。这个原理在图 10.22 中进行了概述。

支持向量训练操作找到wb。SVM 有变体可以在多于两个类别之间做出决策,执行评分/回归,并检测新颖性。但我们将只讨论用于简单分类的 SVM。

作为 SVM 的用户,你不必立即了解训练过程是如何工作的;这是软件为你做的。但你确实需要有一些关于它试图做什么的概念。模型w,b理想地选择,以便

w %*% phi(x) + b >= u

对于所有在类中的训练x

w %*% phi(x) + b <= v

对于所有不在该类中的训练示例。

如果u > v,则数据被称为可分。分离的大小是(u - v) / sqrt(w %*% w),称为间隔。SVM 优化器的目标是最大化间隔。实际上,较大的间隔可以确保对未来数据的良好行为(良好的泛化性能)。在实践中,即使存在核,真实数据也不总是可分的。为了解决这个问题,大多数 SVM 实现都实现了所谓的软间隔优化目标。

软间隔优化器添加额外的误差项,这些误差项用于允许有限比例的训练示例位于决策表面的错误一侧。10] 模型实际上并不擅长处理这些修改后的训练示例,但它将这些示例上的误差与剩余训练示例上的间隔增加进行权衡。对于大多数实现,模型超参数Cnu决定了剩余数据的间隔宽度和为了实现间隔而推来推去的数据量之间的权衡。我们将使用nu超参数。nu的设置在零和一之间;较低的值允许更少的训练错误分类,有利于更复杂的模型(更多的支持向量)。11] 对于我们的示例,我们将只使用函数默认值:0.5

^(10)

在任何核下都不可分的一种常见数据集是至少有两个示例属于不同结果类,并且所有输入或x变量的值都完全相同的数据集。原始的“硬间隔”SVM 无法处理这类数据,因此被认为不实用。

^(11)

关于 SVM 的更多详细信息,我们推荐 Cristianini 和 Shawe-Taylor 的 支持向量机及其他基于核的学习方法导论,剑桥大学出版社,2000 年。

10.3.3. 理解核函数

SVM 选择哪些数据是不重要的(被排除在外)以及哪些数据非常重要(用作支持向量)。但实际上,将问题重塑以使数据可分是由所谓的 核方法核函数 来执行的。

图 10.22 展示了^([12]) 我们希望从好的核函数中得到的结果:我们的数据被推来推去,使其更容易排序或分类。通过使用核变换,我们进入一个这样的状态,即我们试图学习的区别可以通过我们变换后的数据的线性分离器来表示。

¹²

Cristianini 和 Shawe-Taylor,支持向量机及其他基于核的学习方法导论

为了开始理解 SVM,我们需要快速查看 SVM 和核方法用户应该熟悉的常见数学和术语。首先是核函数的概念,它用于实现我们看到的重塑空间的 phi()

核函数的正式定义

在我们的应用中,核是一个具有非常特定定义的函数。令 uv 为任意一对变量。uv 通常是从数据集的两行中选取的输入或独立变量的向量(可能是)。一个将 (u,v) 对映射到数字的函数 k(,) 被称为 核函数,当且仅当存在一个将 (u,v) 映射到向量空间的函数 phi(),使得对于所有 u,v,有 k(u,v) = phi(u) %*% phi(v)。^([13]) 我们将非正式地将表达式 k(u,v) = phi(u) %*% phi(v) 称为核的 Mercer 展开(参考 Mercer 定理;见 mng.bz/xFD2),并将 phi() 视为告诉我们 k(,) 是一个好的核的证明。这可以通过具体的例子更容易理解。在下面的列表中,我们展示了等价的 phi() / k(,) 对。

¹³

%*% 是 R 的点积或内积的表示;有关详细信息,请参阅 help('%*%')。请注意,phi() 可以映射到非常大的(甚至无限的)向量空间。

列表 10.25. 一个人工核函数示例

u <- c(1, 2)
v <- c(3, 4)
k <- function(u, v) {                   ❶
      u[1] * v[1] +
        u[2] * v[2] +
        u[1] * u[1] * v[1] * v[1] +
        u[2] * u[2] * v[2] * v[2] +
        u[1] * u[2] * v[1] * v[2]
  }
phi <- function(x) {                    ❷
      x <- as.numeric(x)
     c(x, x*x, combn(x, 2, FUN = prod))
  }
print(k(u, v))                          ❸
 ## [1] 108
print(phi(u))
## [1] 1 2 1 4 2
print(phi(v))
## [1]  3  4  9 16 12
print(as.numeric(phi(u) %*% phi(v)))    ❹
 ## [1] 108

❶ 定义一个关于两个向量变量(都是二维的)的函数,作为各种项乘积的总和

❷ 定义一个关于单个向量变量的函数,该函数返回一个包含原始条目以及所有条目乘积的向量

❸ k(,) 的示例评估

❹ 确认 phi() 与 k(,) 一致。phi() 是一个证明,表明 k(,) 实际上是一个核函数。

大多数核方法直接使用函数 k(,),并且只使用由匹配的 phi() 保证的 k(,) 的性质来确保方法正确性。k(,) 函数通常比理论上的函数 phi() 更快计算。一个简单的例子是我们所说的文档的 点积相似性。点积文档相似性定义为两个向量的点积,其中每个向量都是通过构建一个由指示器组成的大向量从文档中导出的,每个指示器对应一个可能的特征。例如,如果你考虑的特征是词对,那么对于给定字典中的每一对单词,如果这对单词在文档中作为连续的表述出现,则文档获得特征值 1,如果没有出现,则获得 0。这种方法是 phi(),但在实践中我们从不使用 phi() 程序。相反,当比较两个文档时,一个文档中连续的每一对单词都会生成,如果这对单词在字典中并且也在另一个文档中连续出现,则会添加一些分数。对于中等大小的文档和大型字典,这种直接的 k(,) 实现比 phi() 实现效率高得多。

支持向量

支持向量机得名于向量 w 通常的表示方式:作为训练示例的线性组合——支持向量。回想一下,我们在 第 10.3.3 节 中提到,函数 phi() 原则上可以映射到一个非常大的或甚至是无限的向量空间。这意味着可能无法直接写出 w

支持向量机通过限制 w 为原则上为 phi() 项之和来绕过“无法写出 w”的问题,如下所示:

w = sum(a1 * phi(s1), ... , am * phi(sm))

向量 s1, ..., sm 实际上是 m 个训练示例,被称为支持向量。前面的公式之所以有用,是因为这样的和(通过一些数学运算)等同于我们接下来展示的形式的 k( ,x) 内核项的和:

w %*% phi(x) + b = sum(a1 * k(s1, x),... , am * k(sm, x)) + b

右边是一个我们可以计算的数量。

支持向量训练算法的工作是选择向量 s1, ..., sm,标量 a1, ..., am,以及偏移 b。所有这些都称为“核技巧”。


关于支持向量模型需要记住的内容

支持向量模型由以下这些组成:

  • 一个 phi(),它重塑空间(由用户选择)

  • 一组称为 支持向量 的训练数据示例(由 SVM 算法选择)

  • 一组标量 a1, ..., am,这些标量指定了支持向量定义的分离表面的线性组合(由 SVM 算法选择)

  • 一个与之一比较的标量阈值 b(由 SVM 算法选择)


数据科学家必须了解支持向量的原因在于它们存储在支持向量模型中。例如,如果模型过于复杂,可能会有非常多的支持向量,导致模型变得很大且评估成本高昂。在最坏的情况下,模型中的支持向量数量几乎可以与训练示例的数量相当,使得支持向量模型的评估可能像最近邻评估一样昂贵,并增加了过拟合的风险。用户通过交叉验证选择一个良好的 Cnu 值来选择一个良好的支持向量数量。


练习:在螺旋问题中尝试不同的 nu 值。

nu 是 SVM 的重要超参数。理想情况下,我们应该对 nu 的一个良好值进行交叉验证。而不是进行完整的交叉验证,只需尝试几个 nu 的值来获取景观。(我们在这里有一个解决方案:github.com/WinVector/PDSwR2/tree/master/Spirals)。


10.3.4. 支持向量机和核方法要点

这里是你应该记住的这部分内容:

  • 支持向量机是一种基于核的分类方法,其中复杂的分离表面是通过训练示例的(可能非常大的)子集(称为支持向量)来参数化的。

  • “核技巧”的目标是将数据提升到一个数据可分的空间,或者可以直接使用线性方法的空间。当问题具有适中的变量数量,并且数据科学家怀疑要建模的关系是变量效应的非线性组合时,支持向量机和核方法效果最佳。

摘要

在本章中,我们展示了处理基本建模方法特定问题的某些高级方法:建模方差、建模偏差、非线性问题和变量交互问题。我们希望有时间触及的一个重要附加方法系列是 深度学习,这是神经网络现代改进处理。幸运的是,已经有了一本关于这个主题的好书可以推荐:使用 R 进行深度学习,作者为 François Chollet 和 J. J. Allaire,Manning,2018 年。

你应该理解,你引入高级方法和技术来解决特定的建模问题,并不是因为它们有异国情调的名字或激动人心的历史。我们也认为,在构建自己的定制技术之前,你应该至少尝试找到一种现有的技术来解决你怀疑隐藏在数据中的问题;通常,现有的技术已经包含了大量的调整和智慧。哪种方法最好取决于数据,并且有众多高级方法可以尝试。高级方法可以帮助解决过拟合、变量交互、非加性关系和不平衡分布,但不能解决特征或数据不足的问题。

最后,学习高级技术理论的目标不是能够背诵常见实现的步骤,而是要知道何时应用这些技术以及它们代表了什么权衡。数据科学家需要提供思考和判断,并认识到平台可以提供实现。

在本章中,你学习了

  • 如何通过捆绑决策树来稳定模型并提高预测性能

  • 如何通过使用随机森林或梯度提升进一步改进基于决策树的模型

  • 如何使用随机森林变量重要性来帮助进行变量选择

  • 如何使用广义加性模型在线性回归和逻辑回归的背景下更好地建模输入和输出之间的非线性关系

  • 如何使用高斯核支持向量机来对具有复杂决策表面的分类任务进行建模,特别是最近邻风格的任务。

建模项目的实际目的是为生产部署提供结果,并向您的合作伙伴提供有用的文档和评估。本书的下一部分将介绍交付您结果的最佳实践。

第三部分。在现实世界中工作

在 第二部分 中,我们介绍了如何构建一个解决你想要解决的问题的模型。接下来的步骤是实现你的解决方案并向其他感兴趣的相关方传达你的结果。在 第三部分 中,我们总结了将工作部署到生产环境、记录工作和构建有效演示的重要步骤。

第十一章 讨论了分享或转移你的工作给他人所需的文档,特别是那些将在操作环境中部署你的模型的人。这包括有效的代码注释实践,以及与版本控制软件 Git 的正确版本管理和协作。我们还讨论了使用 knitr 进行可重复研究的方法。第十一章 还涵盖了如何从 R 导出你构建的模型,或将它们作为 HTTP 服务部署。

第十二章 讨论了如何向不同的受众展示你项目的结果。项目赞助商、项目消费者(将在组织中使用或解释你模型结果的人)以及同行数据科学家都将有不同的观点和兴趣。我们还提供了如何根据特定受众的需求和兴趣调整你的演示的例子。

完成 第三部分 后,你将了解如何记录和转移你项目的结果,以及如何有效地向其他感兴趣的相关方传达你的发现。

第十一章. 文档和部署

本章涵盖

  • 生成有效的里程碑文档

  • 使用源控制管理项目历史

  • 部署结果和进行演示

在本章中,我们将探讨记录和部署您工作的技术。我们将处理特定场景,并在您想掌握所讨论技术时提供进一步学习的资源。主题是这样的:既然您能够构建机器学习模型,您就应该探索工具和程序来熟练地保存、分享和重复成功。我们本章的心理模型(图 11.1)强调本章全部关于分享您所构建的模型。让我们使用 表 11.1 来获取更多具体的目标。

图 11.1. 心理模型

表 11.1. 章节目标

目标 描述
生成有效的里程碑文档 项目目标、数据来源、采取的步骤和技术结果(数字和图表)的可读性总结。里程碑文档通常由合作者和同行阅读,因此它可以简洁,并且经常包括实际代码。我们将展示一个用于生成优秀里程碑文档的出色工具:R knitrrmarkdown 包,我们将泛指它们为 R markdown。R markdown 是“可重复研究”运动的产物(参见 Christopher Gandrud 的 Reproducible Research with R and RStudio, 第二版,Chapman and Hall,2015),是生成可靠快照的绝佳方式,不仅展示了项目的状态,还允许他人验证项目是否可行。
管理完整项目历史 如果您无法获取二月份的代码和数据副本,那么对您项目上个月如何工作的精美里程碑或检查点文档几乎没有意义。这就是为什么您需要良好的版本控制纪律来保护代码,以及良好的数据纪律来保存数据。
部署演示 真正的生产部署最好由经验丰富的工程师完成。这些工程师知道他们将要部署的工具和环境。快速启动生产部署的一个好方法是有一个参考应用程序。这允许工程师对您的工作进行实验,测试边缘情况,并构建验收测试。

本章解释了如何分享您的工作——甚至与未来的自己分享。我们将讨论如何使用 R markdown 创建实质性的项目里程碑文档并自动化图形和其他结果的再现。您将了解如何在代码中使用有效的注释,以及如何使用 Git 进行版本管理和协作。我们还将讨论将模型作为 HTTP 服务和应用程序进行部署。

对于一些示例,我们将使用 RStudio,这是一个由 RStudio, Inc.(而不是 R/CRAN 本身)生产的集成开发环境(IDE)。我们展示的所有内容都可以在不使用 RStudio 的情况下完成,但 RStudio 提供了一个基本的编辑器以及一些一键式替代方案来处理一些脚本任务。

11.1. 预测 Buzz


示例

在我们的示例场景中,我们希望使用关于文章前几天的浏览量收集的指标来预测文章的长期受欢迎程度。这对销售广告和预测和管理收入可能很重要。具体来说:我们将使用文章发布前八天进行的测量值来预测文章是否会在长期内保持受欢迎。

本章的任务是保存和分享我们的 Buzz 模型,记录模型,测试模型,并将模型部署到生产环境中。


为了模拟预测长期文章受欢迎程度或 Buzz 的示例场景,我们将使用来自 ama.liglab.fr/datasets/buzz/Buzz 数据集。我们将处理文件 TomsHardware-Relative-Sigma-500.data.txt 中的数据.^([1]) 原始提供的文档(TomsHardware-Relative-Sigma-500.names.txt 和 BuzzDataSetDoc.pdf)告诉我们 Buzz 数据的结构如图 11.2 所示。

¹

本章中提到的所有文件均可在 github.com/WinVector/PDSwR2/tree/master/Buzz 获取。

表 11.2. Buzz 数据描述

属性 描述
每行代表一个技术个人电脑讨论主题的多种不同测量值。
主题 主题包括关于个人电脑的技术问题,如品牌名称、内存、超频等。
测量类型 对于每个主题,测量类型包括发帖数、帖子数、作者数、读者数等数量。每种测量值在八个不同时间进行。
时间 八个相对时间命名为 0 到 7,可能是天数(原始变量文档并不完全清楚,匹配的论文尚未发布)。对于每种测量类型,所有八个相对时间都存储在同一数据行的不同列中。
Buzz 要预测的量称为 Buzz,如果观察日之后的几天内平均每天新增讨论活动的发生频率至少为 500 事件,则定义为真或 1。可能的 Buzz 是七个标记为 NAC 的变量的未来平均值(原始文档对此并不明确)。

在我们最初的 Buzz 文档中,我们列出了我们所知的内容(并且,重要的是,承认了我们不确定的地方)。在指出提供的 Buzz 文档中的问题时,我们并没有任何不尊重的意思。这种文档在项目开始时所能看到的水平大致如此。在实际项目中,你会通过讨论和工作周期来澄清和改进不明确的地方。这是为什么在现实世界项目中能够接触到活跃的项目赞助者和合作伙伴是至关重要的一个原因。

在本章中,我们将使用 Buzz 模型和数据集,并专注于展示用于生成文档、部署和演示的工具和技术。在实际项目中,我们建议你首先生成像 表 11.2 中的笔记。你还会结合会议笔记来记录你的实际项目目标。由于这只是一个演示,我们将强调技术文档:数据来源和初步的简单分析,以证明我们对数据有控制权。我们的示例初始 Buzz 分析可以在以下位置找到:github.com/WinVector/PDSwR2/blob/master/Buzz/buzzm.md。我们建议你在我们介绍下一节中使用的工具和步骤之前先浏览一下。

11.2. 使用 R markdown 生成里程碑文档

你需要准备文档的第一个受众是你自己和你的同伴。你可能几个月后需要回到之前的工作,可能是在紧急情况下,比如重要的错误修复、演示或功能改进。对于自我/同伴文档,你想要专注于事实:所陈述的目标是什么,数据来自哪里,以及尝试了哪些技术。你假设只要使用标准术语或参考,读者就能弄清楚他们需要知道的其他任何内容。你想要强调任何惊喜或异常问题,因为它们正是重新学习时最昂贵的。你不能期望与客户分享这类文档,但你可以稍后用它作为构建更广泛文档和演示的基础。

我们推荐的第一种文档类型是项目里程碑或检查点文档。在项目的主要步骤中,你应该抽出一些时间在一个干净的环境中重复你的工作(证明你知道中间文件中的内容,并且实际上可以重新创建它们)。一个重要且常被忽视的里程碑是项目的开始。在本节中,我们将使用 knitr 和 rmarkdown R 包来记录使用 Buzz 数据开始工作的过程。


文档场景:分享 Buzz 模型的 ROC 曲线

我们的第一个任务是构建一个包含示例模型 ROC 曲线的文档。我们希望能够在更改模型或评估数据时自动重建此文档,因此我们将使用 R markdown 来生成文档。


11.2.1. 什么是 R markdown?

R markdown 是 Markdown 文档规范的变体^([2]), 允许在文档中包含 R 代码和结果。处理代码和文本组合的概念应归功于 R Sweave 软件包^([3]) 和 Knuth 的文献编程的早期思想.^([4]) 实际上,你维护一个包含用户可读文档和程序源代码片段的主文件。R markdown 支持的文档类型包括 Markdown、HTML、LaTeX 和 Word。LaTeX 格式是详细、排版、技术文档的好选择。Markdown 格式是在线文档和维基的好选择。

²

Markdown 本身是一种流行的文档格式化系统,其灵感来源于模仿人们手动注释电子邮件的方式:en.wikipedia.org/wiki/Markdown.

³

查看 leisch.userweb.mwn.de/Sweave/.

查看 www.literateprogramming.com/knuthweb.pdf.

执行文档创建任务的引擎被称为 knitr。knitr 的主要操作称为 knit:knitr 提取并执行所有 R 代码,然后构建一个新的结果文档,该文档将原始文档的内容与格式化后的代码和结果组合在一起。图 11.2 展示了 knitr 如何将文档视为片段(称为 chunks)并将片段转换为可分享的结果。

图 11.2. R markdown 流程示意图

通过几个示例可以最好地展示这个过程。

一个简单的 R markdown 示例

Markdown (daringfireball.net/projects/markdown/) 是一种简单的、适用于网络的格式,在许多维基中都被使用。以下列表显示了一个简单的 Markdown 文档,其中 R markdown 注释块用 ``` 标记。

列表 11.1. R 注释的 Markdown

---                                                                            ❶
title: "Buzz scoring example"
output: github_document
---
```{r, include = FALSE}                                                        ❷

# 使用 knitr 或 rmarkdown 处理文档。

# knitr::knit("Buzz_score_example.Rmd") # creates Buzz_score_example.md

# rmarkdown::render("Buzz_score_example.Rmd",

# rmarkdown::html_document()) # creates Buzz_score_example.html

```                                                                            ❸

Example scoring (making predictions with) the Buzz data set.                   ❹

First attach the `randomForest` package and load the model and test data.
```{r}                                                                         ❺

suppressPackageStartupMessages(library("randomForest"))

lst <- readRDS("thRS500.RDS")

varslist <- lst$varslist

fmodel <- lst$fmodel

buzztest <- lst$buzztest

rm(list = "lst")

Now show the quality of our model on held-out test data. ❻


buzztest$prediction <-

    predict(fmodel, newdata = buzztest, type = "prob")[, 2, drop = TRUE]

WVPlots::ROCPlot(buzztest, "prediction",

                "buzz", 1,

                "ROC 曲线估计模型预测在保留数据上的质量-

    out data")


❶ YAML(另一种标记语言)标题指定一些元数据:标题和默认输出格式

❷ R Markdown 的“起始代码块”注释。`include = FALSE` 指令表示该块在渲染中不显示。

❸ R Markdown 块的结束;起始和结束标记之间的所有内容都被视为 R 代码并执行。

❹ 自由 Markdown 文本

❺ 另一个 R 代码块。在这种情况下,我们正在加载一个已经生成的随机森林模型和测试数据。

❻ 更多自由测试

❻ 另一个 R 代码块

列表 11.1 的内容可在文件 [`github.com/WinVector/PDSwR2/blob/master/Buzz/Buzz_score_example.Rmd`](https://github.com/WinVector/PDSwR2/blob/master/Buzz/Buzz_score_example.Rmd) 中找到。在 R 中,我们会这样处理它:

rmarkdown::render("Buzz_score_example.Rmd", rmarkdown::html_document())


这生成了新的文件 Buzz_score_example.html,这是一个完成的 HTML 格式的报告。将这种能力添加到您的流程中(无论是使用 Sweave 还是 knitr/rmarkdown)都是革命性的。

R Markdown 的目的

R Markdown 的目的是生成可重复的工作。相同的数据和技术应该可以重新运行以获得等效的结果,而不需要要求容易出错的人工干预,例如选择工作表范围或复制粘贴。当您以 R Markdown 格式分发您的作品(如我们在第 11.2.3 节中所做的那样)时,任何人都可以下载您的作品,并且无需很大努力就可以重新运行它以确认他们得到的结果与您相同。这是科学研究的理想标准,但很少达到,因为科学家通常在共享所有代码、数据和实际程序方面存在不足。knitr 收集并自动化所有步骤,因此如果某些内容缺失或实际上没有按声称的方式工作,就会变得明显。knitr 自动化可能看起来只是方便,但它使得表 11.3 中列出的基本工作变得更加容易(因此更有可能真正完成)。

表 11.3\. R Markdown 使维护任务变得更容易

| 任务 | 讨论 |
| --- | --- |
| 保持代码与文档同步 | 只有一份代码(已经在文档中)的情况下,要使其与文档不同步就不那么容易了。 |
| 保持结果与数据同步 | 消除所有手动步骤(如剪切粘贴结果、选择文件名和包含图表)将大大增加您正确重新运行和重新检查工作的可能性。 |
| 将正确的工作转交给他人 | 如果步骤按顺序排列,以便机器可以运行它们,那么重新运行和确认它们就更容易了。此外,有一个容器(主文档)来保存所有工作,这使得管理依赖关系变得更加容易。 |

### 11.2.2\. knitr 技术细节

要在大型项目中使用 knitr,您需要了解更多关于 knitr 代码块的工作方式。特别是,您需要清楚如何标记代码块以及您将需要操作哪些常见的代码块选项。图 11.3 显示了准备 R Markdown 文档的步骤。

图 11.3\. R Markdown 流程

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig03_alt.jpg)

knitr 块声明格式

通常,一个 knitr 代码块以块声明开始(在 Markdown 中为 ```` ``` ````,在 LaTeX 中为 `<<`)。第一个字符串是块的名称(在整个项目中必须是唯一的)。之后,允许有多个以逗号分隔的 `option=value` 块选项赋值。

knitr 块选项

在 表 11.4 中给出了有用的选项赋值示例。

表 11.4\. 一些有用的 knitr 选项

| 选项名称 | 目的 |
| --- | --- |
| cache | 控制是否缓存结果。当 cache = FALSE(默认值)时,代码块总是执行。当 cache = TRUE 时,如果之前运行中有有效的缓存结果可用,则代码块不会执行。缓存块在修订 knitr 文档时是必不可少的,但你应该始终删除缓存目录(位于你使用 knitr 的子目录中),并执行干净的重跑以确保你的计算正在使用你文档中指定的当前数据版本和设置。 |
| echo | 控制源代码是否被复制到文档中。当 echo = TRUE(默认值)时,格式化良好的代码会被添加到文档中。当 echo = FALSE 时,代码不会被回显(当你只想显示结果时很有用)。 |
| eval | 控制代码是否被评估。当 eval = TRUE(默认值)时,代码被执行。当 eval = FALSE 时,它不会被评估(用于显示指令)。 |
| message | 将 message = FALSE 设置为将 R 的 message() 命令直接发送到运行 R 的控制台,而不是发送到文档。这对于向用户发布进度消息很有用,你不需要在最终文档中看到这些消息。 |
| results | 控制对 R 输出的处理方式。通常你不需要设置此选项,输出会与代码混合(带有 ## 注释)。一个有用的选项是 results='hide',它可以抑制输出。 |
| tidy | 控制在打印之前是否重新格式化源代码。我们曾经将 tidy = FALSE,因为 knitr 的一个版本在整理时错误地格式化了 R 注释。 |

大多数这些选项在我们的 Buzz 示例中都有演示,我们将在下一节中处理这个示例。

### 11.2.3\. 使用 knitr 记录 Buzz 数据并生成模型

我们刚刚评估的模型本身是使用 R markdown 脚本生成的:位于 [`github.com/WinVector/PDSwR2/tree/master/Buzz`](https://github.com/WinVector/PDSwR2/tree/master/Buzz) 的 buzzm.Rmd 文件。编译此文件生成了 Markdown 结果 buzzm.md 和驱动我们示例的保存模型文件 thRS500.RDS。本章中提到的所有步骤都在 Buzz 示例目录中完全演示。我们将展示 buzzm.Rmd 的摘录。

* * *

Buzz 数据注释

对于 Buzz 数据,准备说明可以在 buzzm.md 和 buzzm.html 文件中找到。我们建议查看这些文件之一和 表 11.2。Buzz 项目的原始描述文件(TomsHardware-Relative-Sigma-500.names.txt 和 BuzzDataSetDoc.pdf)也可在 [`github.com/WinVector/PDSwR2/tree/master/Buzz`](https://github.com/WinVector/PDSwR2/tree/master/Buzz) 找到。

* * *

确认数据来源

因为 knitr 正在自动化步骤,你可以多走几步来确认你正在分析的数据确实是你认为下载的数据。例如,我们将从确认我们从哪里开始的数据的 SHA 密码哈希与我们所认为下载的内容相匹配开始我们的 Buzz 数据分析。这可以通过以下列表完成(注意:始终查看块的第一行以查找块选项,如 `cache = TRUE`)。

列表 11.2\. 使用 `system()` 命令计算文件哈希


infile <- "TomsHardware-Relative-Sigma-500.data.txt"

paste('checked at', date())

system(paste('shasum', infile), intern = TRUE)             ❶

buzzdata <- read.table(infile, header = FALSE, sep = ",")

...

❶ Runs a system-installed cryptographic hash program (this program is outside of R’s install image)

This code sequence depends on a program named shasum being on your execution path. You have to have a cryptographic hash installed, and you can supply a direct path to the program if necessary. Common locations for a cryptographic hash include /usr/bin/shasum, /sbin/md5, and fciv.exe, depending on your actual system configuration.

This code produces the output shown in figure 11.4. In particular, we’ve documented that the data we loaded has the same cryptographic hash we recorded when we first downloaded the data. Having confidence you’re still working with the exact same data you started with can speed up debugging when things go wrong. Note that we’re using the cryptographic hash only to defend against accident (using the wrong version of a file or seeing a corrupted file) and not to defend against adversaries or external attacks. For documenting data that may be changing under external control, it is critical to use up-to-date cryptographic techniques.

Figure 11.4. knitr documentation of Buzz data load

Figure 11.5 is the same check, rerun in 2019, which gives us some confidence we are in fact dealing with the same data.

Figure 11.5. knitr documentation of Buzz data load 2019: buzzm.md

Recording the performance of the naive analysis

The initial milestone is a good place to try to record the results of a naive “just apply a standard model to whatever variables are present” analysis. For the Buzz data analysis, we’ll use a random forest modeling technique (not shown here, but in our knitr documentation) and apply the model to test data.


Save your data!

Always save a copy of your training data. Remote data (URLs, databases) has a habit of changing or disappearing. To reproduce your work, you must save your inputs.


Listing 11.3. Calculating model performance


``` {r}
rtest <- data.frame(truth = buzztest$buzz,
pred = predict(fmodel, newdata = buzztest, type = "prob")[, 2, drop = TRUE])
print(accuracyMeasures(rtest$pred, rtest$truth))
## [1] "精确度= 0.832402234636871 ; 召回率= 0.84180790960452"

## pred

## 真实值 FALSE TRUE

## 0   584   30

## 1    28  149

## 模型准确度        f1 dev.norm       AUC

## 1 模型 0.9266751 0.8370787  0.42056 0.9702102

Using milestones to save time

Now that we’ve gone to all the trouble to implement, write up, and run the Buzz data preparation steps, we’ll end our knitr analysis by saving the R workspace. We can then start additional analyses (such as introducing better variables for the time-varying data) from the saved workspace. In the following listing, we’ll show how to save a file, and how to again produce a cryptographic hash of the file (so we can confirm work that starts from a file with the same name is in fact starting from the same data).

Listing 11.4\. Saving data

保存变量名、模型和测试数据。

fname <- 'thRS500.RDS'
items <- c("varslist", "fmodel", "buzztest")
saveRDS(object = list(varslist = varslist,
                      fmodel = fmodel,
                      buzztest = buzztest),
        file = fname)
message(paste('saved', fname))  # message to running R console
print(paste('saved', fname))    # print to document
## [1] "saved thRS500.RDS"
paste('finished at', date())
## [1] "finished at Thu Apr 18 09:33:05 2019"
system(paste('shasum', fname), intern = TRUE)  # write down file hash
## [1] "f2b3b80bc6c5a72079b39308a5758a282bcdd5bf  thRS500.RDS"

knitr takeaway

In our knitr example, we worked through the steps we’ve done for every dataset in this book: load data, manage columns/variables, perform an initial analysis, present results, and save a workspace. The key point is that because we took the extra effort to do this work in knitr, we have the following:

*   Nicely formatted documentation (buzzm.md)
*   Shared executable code (buzzm.Rmd)

This makes debugging (which usually involves repeating and investigating earlier work), sharing, and documentation much easier and more reliable.

* * *

**Project organization, further reading**

To learn more about R markdown we recommend Yihui Xie, *Dynamic Documents with R and knitr* (CRC Press, 2013). Some good ideas on how to organize a data project in reproducible fashion can be found in *Reproducible Research with R and RStudio*, Second Edition.

* * *

## 11.3\. Using comments and version control for running documentation

Another essential record of your work is what we call *running documentation*. Running documentation is less formal than milestone/checkpoint documentation and is easily maintained in the form of code comments and version control records. Undocumented, untracked code runs up a great deal of *technical debt* (see [`mng.bz/IaTd`](http://mng.bz/IaTd)) that can cause problems down the road.

* * *

Example

*Suppose you want to work on formatting Buzz modeling results. You need to save this work to return to it later, document what steps you have taken, and share your work with others.*

* * *

In this section, we’ll work through producing effective code comments and using Git for version control record keeping.

### 11.3.1\. Writing effective comments

R’s comment style is simple: everything following a `#` (that isn’t itself quoted) until the end of a line is a comment and ignored by the R interpreter. The following listing is an example of a well-commented block of R code.

Listing 11.5\. Example code comments

' 返回 x 的伪对数,以 10 为底。

'

' 返回 x 的伪对数(以 10 为底),接近

' sign(x)*log10(abs(x)) 对于 abs(x) 较大的 x

' 并且不会在零附近“爆炸”。有用

' 用于转换可能为负的宽范围变量

' (如利润/亏损)。

'

' 参考:\url{http://www.win-vector.com/blog/2012/03/modeling-trick-the-

signed-pseudo-logarithm/

'

' 注意:此转换具有不希望的性质,使得大多数

' 签名分布出现在原点周围的双峰分布,无论

' 真正的分布看起来是什么样子。

' 参数 x 假设为数值,可以是向量。

'

' @param x 数值向量

' @return x 的伪对数,以 10 为底

'

' @examples

'

' pseudoLog10(c(-5, 0, 5))

' # 应该是:[1] -0.7153834 0.0000000 0.7153834

'

' @export

'

pseudoLog10 <- function(x) {

asinh(x / 2) / log(10)

}


When such comments (with the `#'` marks and `@` marks ) is included in an R package, the documentation management engine can read the structured information and use it to produce additional documentation and even online help. For example, when we saved the preceding code in an `R` package at [`github.com/WinVector/PDSwR2/blob/master/PseudoLog10/R/pseudoLog10.R`](https://github.com/WinVector/PDSwR2/blob/master/PseudoLog10/R/pseudoLog10.R), we could use the `roxygen2` R package to generate the online help shown in figure 11.6.

Figure 11.6\. `roxygen@`-generated online help

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig06_alt.jpg)

Good comments include what the function does, what types arguments are expected to be used, limits of domain, why you should care about the function, and where it’s from. Of critical importance are any `NB` (*nota bene* or *note well* ) or `TODO` notes. It’s vastly more important to document any unexpected features or limitations in your code than to try to explain the obvious. Because R variables don’t have types (only objects they’re pointing to have types), you may want to document what types of arguments you’re expecting. It’s critical to state if a function works correctly on lists, data frame rows, vectors, and so on.

For more on packages and documentation, we recommend Hadley Wickham, *R Packages: Organize, Test, Document, and Share Your Code* (O’Reilly, 2015).

### 11.3.2\. Using version control to record history

Version control can both maintain critical snapshots of your work in earlier states and produce running documentation of what was done by whom and when in your project. Figure 11.7 shows a cartoon “version control saves the day” scenario that is in fact common.

Figure 11.7\. Version control saving the day

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig07_alt.jpg)

In this section, we’ll explain the basics of using Git ([`git-scm.com/`](http://git-scm.com/)) as a version control system. To really get familiar with Git, we recommend a good book such as Jon Loeliger and Matthew McCullough’s *Version Control with Git,* Second Edition, (O’Reilly, 2012). Or, better yet, work with people who know Git. In this chapter, we assume you know how to run an interactive shell on your computer (on Linux and OS X you tend to use `bash` as your shell; on Windows you can install Cygwin—[`www.cygwin.com`](http://www.cygwin.com)).

* * *

Working in bright light

Sharing your Git repository means you’re sharing a lot of information about your work habits and also sharing your mistakes. You’re much more exposed than when you just share final work or status reports. Make this a virtue: know you’re working in bright light. One of the most critical features in a good data scientist (perhaps even before analytic skill) is scientific honesty.

* * *

To get most of the benefit from Git, you need to become familiar with a few commands, which we will demonstrate in terms of specific tasks next.

Choosing a project directory structure

Before starting with source control, it’s important to settle on and document a good project directory structure. *Reproducible Research with R and RStudio,* Second Edition, has good advice and instructions on how to do this. A pattern that’s worked well for us is to start a new project with the directory structure described in table 11.5.

Table 11.5\. A possible project directory structure

| Directory | Description |
| --- | --- |
| Data | Where we save original downloaded data. This directory must usually be excluded from version control (using the .gitignore feature) due to file sizes, so you must ensure it’s backed up. We tend to save each data refresh in a separate subdirectory named by date. |
| Scripts | Where we store all code related to analysis of the data. |
| Derived | Where we store intermediate results that are derived from data and scripts. This directory must be excluded from source control. You also should have a master script that can rebuild the contents of this directory in a single command (and test the script from time to time). |
| Results | Similar to derived, but this directory holds smaller, later results (often based on derived) and hand-written content. These include important saved models, graphs, and reports. This directory is under version control, so collaborators can see what was said when. Any report shared with partners should come from this directory. |

Starting a Git project using the command line

When you’ve decided on your directory structure and want to start a version-controlled project, do the following:

1.  Start the project in a new directory. Place any work either in this directory or in subdirectories.
2.  Move your interactive shell into this directory and type `git init`. It’s okay if you’ve already started working and there are already files present.
3.  Exclude any subdirectories you don’t want under source control with .gitignore control files.

You can check if you’ve already performed the init step by typing `git status`. If the init hasn’t been done, you’ll get a message similar to `fatal: Not a git repository (or any of the parent directories): .git`. If the init has been done, you’ll get a status message telling you something like `on branch master` and listing facts about many files.

The init step sets up in your directory a single hidden file tree called .git and prepares you to keep extra copies of every file in your directory (including subdirectories). Keeping all of these extra copies is called *versioning* and what is meant by *version control*. You can now start working on your project: save everything related to your work in this directory or some subdirectory of this directory.

Again, you only need to init a project once. Don’t worry about accidentally running `git init.` a second time; that’s harmless.

Using add/commit pairs to checkpoint work

* * *

Get nervous about uncommitted state

Here’s a good rule of thumb for Git: you should be as nervous about having uncommitted changes as you should be about not having clicked Save. You don’t need to push/pull often, but you do need to make local commits often (even if you later squash them with a Git technique called *rebasing*).

* * *

As often as practical, enter the following two commands into an interactive shell in your project directory:

git add -A ❶

git commit ❷


❶ Stages results to commit (specifies what files should be committed)

❷ Actually performs the commit

Checking in a file is split into two stages: add and commit. This has some advantages (such as allowing you to inspect before committing), but for now just consider the two commands as always going together. The `commit` command should bring up an editor where you enter a comment as to what you’re up to. Until you’re a Git expert, allow yourself easy comments like “update,” “going to lunch,” “just added a paragraph,” or “corrected spelling.” Run the add/commit pair of commands after every minor accomplishment on your project. Run these commands every time you leave your project (to go to lunch, to go home, or to work on another project). Don’t fret if you forget to do this; just run the commands next time you remember.

* * *

**A “wimpy commit” is better than no commit**

We’ve been a little loose in our instructions to commit often and not worry too much about having a long commit message. Two things to keep in mind are that usually you want commits to be meaningful with the code working (so you tend not to commit in the middle of an edit with syntax errors), and good commit notes are to be preferred (just don’t forgo a commit because you don’t feel like writing a good commit note).

* * *

Using git log and git status to view progress

Any time you want to know about your work progress, type either `git status` to see if there are any edits you can put through the add/commit cycle, or `git log` to see the history of your work (from the viewpoint of the add/commit cycles).

The following listing shows the `git status` from our copy of this book’s examples repository ([`github.com/WinVector/PDSwR2`](https://github.com/WinVector/PDSwR2)).

Listing 11.6\. Checking your project status

$ git status

在 master 分支上

你的分支与 'origin/master' 保持同步。

没有要提交的内容,工作树干净


And the next listing shows a `git log` from the same project.

Listing 11.7\. Checking your project history

$ git log

提交 d22572281d40522bc6ab524bbdee497964ff4af0 (HEAD -

> master, origin/master)

作者:John Mount jmount@win-vector.com

日期: 周二 Apr 16 16:24:23 2019 -0700

技术编辑 ch7

The indented lines are the text we entered at the `git commit` step; the dates are tracked automatically.

Using Git through RStudio

The RStudio IDE supplies a graphical user interface to Git that you should try. The add/commit cycle can be performed as follows in RStudio:

*   Start a new project. From the RStudio command menu, select Project > Create Project, and choose New Project. Then select the name of the project and what directory to create the new project directory in; leave the type as (Default), and make sure Create a Git Repository for this Project is checked. When the new project pane looks something like figure 11.8, click Create Project, and you have a new project.

    Figure 11.8\. RStudio new project pane

    ![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig08_alt.jpg)

*   Do some work in your project. Create new files by selecting File > New > R Script. Type some R code (like `1/5`) into the editor pane and then click the save icon to save the file. When saving the file, be sure to choose your project directory or a subdirectory of your project.
*   Commit your changes to version control. Figure 11.9 shows how to do this. Select the Git control pane in the top right of RStudio. This pane shows all changed files as line items. Check the Staged check box for any files you want to stage for this commit. Then click Commit, and you’re done.

    Figure 11.9\. RStudio Git controls

    ![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig09_alt.jpg)

You may not yet deeply understand or like Git, but you’re able to safely check in all of your changes every time you remember to stage and commit. This means all of your work history is there; you can’t clobber your committed work just by deleting your working file. Consider all of your working directory as “scratch work”—only checked-in work is safe from loss.

Your Git history can be seen by pulling down on the Other Commands gear (shown in the Git pane in figure 11.9) and selecting History (don’t confuse this with the nearby History pane, which is command history, not Git history). In an emergency, you can find Git help and find your earlier files. If you’ve been checking in, then your older versions are there; it’s just a matter of getting some help in accessing them. Also, if you’re working with others, you can use the push/pull menu items to publish and receive updates. Here’s all we want to say about version control at this point: *commit often, and if you’re committing often, all problems can be solved with some further research*. Also, be aware that since your primary version control is on your own machine, you need to make sure you have an independent backup of your machine. If your machine fails and your work hasn’t been backed up or shared, then you lose both your work and your version repository.

### 11.3.3\. Using version control to explore your project

Up until now, our model of version control has been this: Git keeps a complete copy of all of our files each time we successfully enter the pair of add/commit lines. We’ll now use these commits. If you add/commit often enough, Git is ready to help you with any of the following tasks:

*   Tracking your work over time
*   Recovering a deleted file
*   Comparing two past versions of a file
*   Finding when you added a specific bit of text
*   Recovering a whole file or a bit of text from the past (undo an edit)
*   Sharing files with collaborators
*   Publicly sharing your project (à la GitHub at [`github.com/`](https://github.com/), Gitlab [`gitlab.com/`](https://gitlab.com/), or Bitbucket at [`bitbucket.org`](https://bitbucket.org))
*   Maintaining different versions (branches) of your work

And that’s why you want to add and commit often.

* * *

Getting help on Git

For any Git command, you can type `git help [command]` to get usage information. For example, to learn about `git log`, type `git help log`.

* * *

Finding out who wrote what and when

In section 11.3.1, we implied that a good version control system can produce a lot of documentation on its own. One powerful example is the command `git blame`. Look what happens if we download the Git repository [`github.com/WinVector/PDSwR2`](https://github.com/WinVector/PDSwR2) (with the command `git clone git@github.com:WinVector/PDSwR2.git`) and run the command `git blame Buzz/buzzapp/server.R` (to see who “wrote” each line in the file).

Listing 11.8\. Finding out who committed what

git blame Buzz/buzzapp/server.R

4efb2b78 (John Mount 2019-04-24 16:22:43 -0700 1) #

4efb2b78 (John Mount 2019-04-24 16:22:43 -0700 2)

# This is the server logic of a Shiny web application. You can run the

4efb2b78 (John Mount 2019-04-24 16:22:43 -0700 3)

# application by clicking 'Run App' above.

4efb2b78 (John Mount 2019-04-24 16:22:43 -0700 4) #


The `git blame` information takes each line of the file and prints the following:

*   The prefix of the line’s Git commit hash. This is used to identify which commit the line we’re viewing came from.
*   Who committed the line.
*   When they committed the line.
*   The line number.
*   And, finally, the contents of the line.

* * *

git blame doesn’t tell the whole story

It is important to understand that many of the updates that `git blame` reports may be mechanical (somebody using a tool to reformat files), or somebody acting on somebody else’s behalf. You *must* look at the commits to see what happened. In this particular example, the commit message was “add Nina’s Shiny example,” so this was work done by Nina Zumel, who had delegated checking it in to John Mount.

A famous example of abusing similar lines of code metrics was the attempt to discredit Katie Bouman’s leadership in creating the first image of a black hole. One of the (false) points raised was that collaborator Andrew Chael had contributed more lines of code to the public repository. Fortunately, Chael himself responded, defending Bouman’s role and pointing out the line count attributed to him was machine-generated model files he had checked into the repository as part of his contribution, not authored lines of code.

* * *

Using git diff to compare files from different commits

The `git diff` command allows you to compare any two committed versions of your project, or even to compare your current uncommitted work to any earlier version. In Git, commits are named using large hash keys, but you’re allowed to use prefixes of the hashes as names of commits.^([5]) For example, the following listing demonstrates finding the differences in two versions of [`github.com/WinVector/PDSwR2`](https://github.com/WinVector/PDSwR2) in a diff or patch format.

> ⁵
> 
> You can also create meaningful names for commits with the `git tag` command.

Listing 11.9\. Finding line-based differences between two committed versions

diff --git a/CDC/NatalBirthData.rData b/CDC/NatalBirthData.rData

...

+++ b/CDC/prepBirthWeightData.R

@@ -0,0 +1,83 @@

+data <- read.table("natal2010Sample.tsv.gz",

  •               sep="\t", header = TRUE, stringsAsFactors = FALSE)
    

+# make a boolean from Y/N data

+makevarYN = function(col) {

  • ifelse(col %in% c("", "U"), NA, col=="Y")

+}

...


* * *

Try to not confuse Git commits and Git branches

A Git commit represents the complete state of a directory tree at a given time. A Git branch represents a sequence of commits and changes as you move through time. Commits are immutable; branches record progress.

* * *

Using git log to find the last time a file was around

* * *

Example

*At some point there was a file named Buzz/buzz.pdf in our repository. Somebody asks us a question about this file. How do we use Git to find when this file was last in the repository, and what its contents had been?*

* * *

After working on a project for a while, we often wonder, when did we delete a certain file and what was in it at the time? Git makes answering this question easy. We’ll demonstrate this in the repository [`github.com/WinVector/PDSwR2`](https://github.com/WinVector/PDSwR2). We remember the Buzz directory having a file named buzz.pdf, but there is no such file now and we want to know what happened to it. To find out, we’ll run the following:

git log --name-status -- Buzz/buzz.pdf

commit 96503d8ca35a61ed9765edff9800fc9302554a3b

Author: John Mount jmount@win-vector.com

Date: Wed Apr 17 16:41:48 2019 -0700

fix links and re-build Buzz example

D Buzz/buzz.pdf


We see the file was deleted by John Mount. We can view the contents of this older file with the command `git checkout 96503d8¹ -- Buzz/buzz.pdf`. The `96503d8` is the prefix of the commit number (which was enough to specify the commit that deleted the file), and the `¹` means “the state of the file one commit before the named commit” (the last version before the file was deleted).

### 11.3.4\. Using version control to share work

* * *

Example

*We want to work with multiple people and share results. One way to use Git to accomplish this is by individually setting up our own repository and sharing with a central repository.*

* * *

In addition to producing work, you must often share it with peers. The common (and bad) way to do this is emailing zip files. Most of the bad sharing practices take excessive effort, are error prone, and rapidly cause confusion. We advise using version control to share work with peers. To do that effectively with Git, you need to start using additional commands such as `git pull`, `git rebase`, and `git push`. Things seem more confusing at this point (though you still don’t need to worry about branching in its full generality), but are in fact far less confusing and less error-prone than ad hoc solutions. We almost always advise sharing work in *star workflow*, where each worker has their own repository, and a single common “naked” repository (a repository with only Git data structures and no ready-to-use files) is used to coordinate (thought of as a server or gold standard, often named *origin*). Figure 11.10 shows one arrangement of repositories that allows multiple authors to collaborate.

Figure 11.10\. Multiple repositories working together

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig10_alt.jpg)

The usual shared workflow is like this:

*   ***Continuously—*** Work, work, work.
*   ***Frequently—*** Commit results to the local repository using a `git add`/`git commit` pair.
*   ***Every once in a while—*** Pull a copy of the remote repository into our view with some variation of `git pull` and then use `git push` to push work upstream.

The main rule of Git is this: don’t try anything clever (push/pull, and so on) unless you’re in a “clean” state (everything committed, confirmed with `git status`).

Setting up remote repository relations

For two or more Git repositories to share work, the repositories need to know about each other through a relation called *remote*. A Git repository is able to share its work to a remote repository by the `push` command and pick up work from a remote repository by the `pull` command. The next listing shows the declared remotes for the authors’ local copy of the [`github.com/WinVector/PDSwR2`](https://github.com/WinVector/PDSwR2) repository.

Listing 11.10\. `git remote`

$ git remote --verbose

origin git@github.com:WinVector/PDSwR2.git (fetch)

origin git@github.com:WinVector/PDSwR2.git (push)


The remote relation is set when you create a copy of a repository using the `git clone` command or can be set using the `git remote add` command. In listing 11.10, the remote repository is called `origin`—this is the traditional name for a remote repository that you’re using as your master or gold standard. (Git tends not to use the name *master* for repositories because master is the name of the branch you’re usually working on.)

Using push and pull to synchronize work with remote repositories

Once your local repository has declared some other repository as remote, you can push and pull between the repositories. When pushing or pulling, always make sure you’re clean (have no uncommitted changes), and you usually want to pull before you push (as that’s the quickest way to spot and fix any potential conflicts). For a description of what version control conflicts are and how to deal with them, see [`mng.bz/5pTv`](http://mng.bz/5pTv).

Usually, for simple tasks we don’t use branches (a technical version control term), and we use the `rebase` option on pull so that it appears that every piece of work is recorded into a simple linear order, even though collaborators are actually working in parallel. This is what we call an *essential* difficulty of working with others: time and order become separate ideas and become hard to track (and this is *not* a needless complexity added by using Git—there *are* such needless complexities, but this is not one of them).

The new Git commands you need to learn are these:

*   `git push` (usually used in the `git push -u origin master` variation)
*   `git pull` (usually used in the `git fetch; git merge -m pull master origin/ master` or `git pull --rebase origin master` variations)

Typically, two authors may be working on different files in the same project at the same time. As you can see in figure 11.11, the second author to push their results to the shared repository must decide how to specify the parallel work that was performed. Either they can say the work was truly in parallel (represented by two branches being formed and then a merge record joining the work), or they can rebase their own work to claim their work was done “after” the other’s work (preserving a linear edit history and avoiding the need for any merge records). Note: *before* and *after* are tracked in terms of arrows, not time.

Figure 11.11\. `git pull`: rebase versus merge

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig11_alt.jpg)

Merging is what’s really happening, but *rebase* is much simpler to read. The general rule is that you should only rebase work you haven’t yet shared (in our example, Worker B should feel free to rebase their edits to appear to be after Worker A’s edits, as Worker B hasn’t yet successfully pushed their work anywhere). You should avoid rebasing records people have seen, as you’re essentially hiding the edit steps they may be basing their work on (forcing them to merge or rebase in the future to catch up with your changed record keeping).

* * *

Keep notes

Git commands are confusing; you’ll want to keep notes. One idea is to write a 3 × 5 card for each command you’re regularly using. Ideally, you can be at the top of your Git game with about seven cards.

* * *

For most projects, we try to use a rebase-only strategy. For example, this book itself is maintained in a Git repository. We have only two authors who are in close proximity (so able to easily coordinate), and we’re only trying to create one final copy of the book (we’re not trying to maintain many branches for other uses). If we always rebase, the edit history will appear totally ordered (for each pair of edits, one is always recorded as having come before the other), and this makes talking about versions of the book much easier (again, *before* is determined by arrows in the edit history, not by time stamp).

* * *

Don’t confuse version control with backup

Git keeps multiple copies and records of all of your work. But until you push to a remote destination, all of these copies are on your machine in the .git directory. So don’t confuse basic version control with remote backups; they’re complementary.

* * *

A bit on the Git philosophy

Git is interesting in that it automatically detects and manages so much of what you’d have to specify with other version control systems (for example, Git finds which files have changed instead of you having to specify them, and Git also decides which files are related). Because of the large degree of automation, beginners usually severely underestimate how much Git tracks for them. This makes Git fairly quick except when Git insists you help decide how a possible global inconsistency should be recorded in history (either as a rebase or a branch followed by a merge record). The point is this: Git suspects possible inconsistency based on global state (even when the user may not think there is such) and then forces the committer to decide how to annotate the issue *at the time of commit* (a great service to any possible readers in the future). Git automates so much of the record keeping that it’s always a shock when you have a conflict and have to express opinions on nuances you didn’t know were being tracked. Git is also an “anything is possible, but nothing is obvious or convenient” system. This is hard on the user at first, but in the end is much better than an “everything is smooth, but little is possible” version control system (which can leave you stranded).

## 11.4\. Deploying models

Good data science shares a rule with good writing: show, don’t tell. And a successful data science project should include at least a demonstration deployment of any techniques and models developed. Good documentation and presentation are vital, but at some point, people have to see things working and be able to try their own tests. We strongly encourage partnering with a development group to produce the actual production-hardened version of your model, but a good demonstration helps recruit these collaborators.

* * *

Example

*Suppose you are asked to make your model predictions available to other software so it can be reflected in reports and used to make decisions. This means you must somehow “deploy your model.” This can vary from scoring all data in a known database, exporting the model for somebody else to deploy, or setting up your own web application or HTTP service.*

* * *

The statistician or analyst’s job often ends when the model is created or a report is finished. For the data scientist, this is just the acceptance phase. The real goal is getting the model into production: scoring data that wasn’t available when the model was built and driving decisions made by other software. This means that helping with deployment is part of the job. In this section, we will outline useful methods for achieving different styles of R model deployment.

We outline some deployment methods in table 11.6.

Table 11.6\. Methods to deploy models

| Method | Description |
| --- | --- |
| Batch | Data is brought into R, scored, and then written back out. This is essentially an extension of what you’re already doing with test data. |
| Cross-language linkage | R supplies answers to queries from another language (C, C++, Python, Java, and so on). R is designed with efficient cross-language calling in mind (in particular the Rcpp package), but this is a specialized topic we won’t cover here. |
| Services | R can be set up as an HTTP service to take new data as an HTTP query and respond with results. |
| Export | Often, model evaluation is simple compared to model construction. In this case, the data scientist can export the model and a specification for the code to evaluate the model, and the production engineers can implement (with tests) model evaluation in the language of their choice (SQL, Java, C++, and so on). |
| PMML | *PMML*, or *Predictive Model Markup Language*, is a shared XML format that many modeling packages can export to and import from. If the model you produce is covered by R’s package pmml, you can export it without writing any additional code. Then any software stack that has an importer for the model in question can use your model. |

* * *

**Models in production**

There are some basic defenses one should set up when placing a model in production. We mention these as we rarely see these valuable precautions taken:

*   All models and all predictions from models should be annotated with the model version name and a link to the model documentation. This simple precaution has saved one of the authors when they were able to show a misclassification was not from the model they had just deployed, but from a human tagger.
*   Machine learning model results should never be directly used as decisions. Instead, they should be an input to configurable business logic that makes decisions. This allows both patching the model to make it more reasonable (such as bounding probability predictions into a reasonable range such as 0.01 to 0.99) and turning it off (changing the business logic to not use the model prediction in certain cases).

You always want the last stage in any automated system to be directly controllable. So even a trivial business logic layer that starts by directly obeying a given model’s determination is high value, as it gives a place where you can correct special cases.

* * *

We’ve already demonstrated batch operation of models each time we applied a model to a test set. We won’t work through an R cross-language linkage example as it’s very specialized and requires knowledge of the system you’re trying to link to. We’ll demonstrate service and export strategies.

### 11.4.1\. Deploying demonstrations using Shiny

* * *

Example

*Suppose we want to build an interactive dashboard or demo for our boss. Our boss wants to try different classification thresholds against our Buzz score to see what precision and recall are available at each threshold. We could do this as a graph, but we are asked do this as an interactive service (possibly part of a larger drill-down/exploration service).*

* * *

We will solve this scenario by using *Shiny*, a tool for building interactive web applications in R. Here we will use Shiny to let our boss pick the threshold that converts our Buzz score into a “will Buzz”/“won’t Buzz” decision. The entire code for this demonstration is in the Buzz/buzzapp directory of [`github.com/WinVector/PDSwR2`](https://github.com/WinVector/PDSwR2).

The easiest way to run the Shiny application is to open the file server.R from that directory in RStudio. Then, as shown in figure 11.12, there will be a button on the upper right of the RStudio editor pane called Run App. Clicking this button will run the application.

Figure 11.12\. Launching the Shiny server from RStudio

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig12_alt.jpg)

The running application will look like figure 11.13. The user can move the threshold control slider and get a new confusion matrix and model metrics (such as precision and recall) for each slider position.

Figure 11.13\. Interacting with the Shiny application

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig13.jpg)

Shiny’s program principles are based on an idea called *reactive programming* where the user specifies what values may change due to user interventions. The Shiny software then handles rerunning and updating the display as the user uses the application. Shiny is a very large topic, but you can get started by copying an example application and editing it to fit your own needs.

Further Shiny reading

We don’t currently have a Shiny book recommendation. A good place to start on Shiny documentation, examples, and tutorials is [`shiny.rstudio.com`](https://shiny.rstudio.com).

### 11.4.2\. Deploying models as HTTP services

* * *

Example

*Our model looked good in testing, and our boss likes working with our interactive web application. So we now want to fully “put our model in production.” In this case, the model is considered “in production” if other servers can send data to it and get scored results. That is, our model is to be partly deployed in production as part of a services oriented architecture (SOA).*

* * *

Our model can be used by other software either by linking to it or having the model exposed as a service. In this case, we will deploy our Buzz model as an HTTP service. Once we have done this, other services at our company can send data to our model for scoring. For example, a revenue management dashboard can send a set of articles it is managing to our model for “buzz scoring,” meaning the buzz score can be incorporated into this dashboard. This is more flexible than having our Buzz model score all known articles in a database, as the dashboard can ask about any article for which it has the details.

One easy way to demonstrate an R model in operation is to expose it as an HTTP service. In the following listing, we show how to do this for our Buzz model (predicting discussion topic popularity). Listing 11.11 shows the first few lines of the file PDSwR2/Buzz/plumber.R. This .R file can be used with the `plumber` R package to expose our model as an HTTP service, either for production use or testing.

Listing 11.11\. Buzz model as an R-based HTTP service

library("randomForest") ❶

lst <- readRDS("thRS500.RDS")

varslist <- lst$varslist

fmodel <- lst$fmodel

buzztest <- lst$buzztest

rm(list = "lst")

* Score a data frame.

* @param d data frame to score

* @post /score_data

function(d) {

predict(fmodel, newdata = d, type = "prob")

}


❶ Attaches the randomForest package, so we can run our randomForest model

We would then start the server with the following code:

library("plumber")

r <- plumb("plumber.R")

r$run(port=8000)


The next listing is the contents of the file PDSwR2/Buzz/RCurl_client_example.Rmd, and shows how to call the HTTP service from R. However, this is just to demonstrate the capability—the whole point of setting up an HTTP service is that something other than R wants to use the service.

Listing 11.12\. Calling the Buzz HTTP service

library("RCurl")

library("jsonlite")

post_query <- function(method, args) { ❶

hdr <- c("Content-Type" = "application/x-www-form-urlencoded")

resp <- postForm(

paste0("http://localhost:8000/", method),

.opts=list(httpheader = hdr,

        postfields = toJSON(args)))

fromJSON(resp)

}

data <- read.csv("buzz_sample.csv",

            stringsAsFactors = FALSE,

            strip.white = TRUE)

scores <- post_query("score_data",

                list(d = data))

knitr::kable(head(scores))

tab <- table(pred = scores[, 2]>0.5, truth = data$buzz)

knitr::kable(tab)


❶ Wraps the services as a function

This produces the result PDSwR2/Buzz/RCurl_client_example.md, shown in figure 11.14 (also saved in our example GitHub repository).

Figure 11.14\. Top of HTML form that asks server for Buzz classification on submit

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig14.jpg)

For more on `plumber`, we suggest starting with the `plumber` package documentation: [`CRAN.R-project.org/package=plumber`](https://CRAN.R-project.org/package=plumber).

### 11.4.3\. Deploying models by export

It often makes sense to export a copy of the finished model from R, instead of attempting to reproduce all the details of model construction in another system or to use R itself in production. When exporting a model, you’re depending on development partners to handle the hard parts of hardening a model for production (versioning, dealing with exceptional conditions, and so on). Software engineers tend to be good at project management and risk control, so sharing projects with them is a good opportunity to learn.

The steps required depend a lot on the model and data treatment. For many models, you only need to save a few coefficients. For random forests, you need to export the trees. In all cases, you need to write code in your target system (be it SQL, Java, C, C++, Python, Ruby, or other) to evaluate the model.

One of the issues of exporting models is that you must repeat any data treatment. So part of exporting a model is producing a specification of the data treatment (so it can be reimplemented outside of R).

Exporting random forests to SQL with tidypredict

* * *

Exercise: Run our random forest model in SQL

*Our goal is to export our random forest model as SQL code that can be then run in a database, without any further use of R.*

* * *

The R package `tidypredict`^([6]) provides methods to export models such as our random forest Buzz model to SQL, which could then be run in a database. We will just show a bit of what this looks like. The random forest model consists of 500 trees that vote on the answer. The top of the first tree is shown in figure 11.15 (random forest trees tend not to be that legible). Remember that trees classify by making sequential decisions from the top-most node down.

> ⁶
> 
> See [`CRAN.R-project.org/package=tidypredict`](https://CRAN.R-project.org/package=tidypredict).

Figure 11.15\. The top of the first tree (of 500) from the random forest model

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig15_alt.jpg)

Now let’s look at the model that `tidypredict` converted to SQL. The conversion was performed in the R markdown file PDSwR2/Buzz/model_export.Rmd, which produces the rendered result PDSwR2/Buzz/model_export.md. We won’t show the code here, but instead show the first few lines of the what the first random forest tree is translated into:

CASE

WHEN (num.displays_06 >= 1517.5 AND

`avg.auths.per.disc_00` < 2.25 AND

`num.displays_06` < 2075.0) THEN ('0')

WHEN (num.displays_03 >= 1114.5 AND

`atomic.containers_01` < 9.5 AND

`avg.auths.per.disc_00` >= 2.25 AND

`num.displays_06` < 2075.0) THEN ('0')

WHEN ...


The preceding code is enumerating each path from the root of the tree down. Remember that decision trees are just huge nested if/else blocks, and SQL writes `if`/`else` as `CASE`/`WHEN`. Each SQL `WHEN` clause is a path in the original decision tree. This is made clearer in figure 11.16.

Figure 11.16\. Annotating `CASE`/`WHEN` paths

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/11fig16_alt.jpg)

在 SQL 导出中,每棵树都被写为一系列覆盖其所有路径的 `WHEN` 情况,允许在 SQL 中执行树计算。作为用户,我们会从根节点开始追踪,向下移动节点,并根据节点条件向左或向右移动。相反,SQL 代码会评估从根到叶子的所有路径,并保留满足所有条件的唯一路径的结果。这是一种评估树的特殊方式,但它将所有内容转换为一个一次性公式,可以导出到 SQL。

整体思路是这样的:我们已经将随机森林模型导出为其他程序可以读取的格式,即 SQL。从那时起,其他人可以拥有这个完成的模型。

一个值得考虑的重要导出系统是 *预测模型标记语言* (PMML),它是一个用于在不同系统间共享模型的 XML 标准.^([7])

> ⁷
> 
> 例如,查看 PMML 包 [`CRAN.R-project.org/package=pmml`](https://CRAN.R-project.org/package=pmml)。

### 11.4.4\. 应该记住什么

现在,你应该能够舒适地向他人展示 R 模型。部署和展示技术包括

+   设置一个模型作为其他人可以实验的 HTTP 服务

+   使用 Shiny 设置微应用

+   导出模型以便在生产环境中重新实现模型应用

## 摘要

在本章中,我们致力于管理和分享你的工作。此外,我们还展示了设置演示 HTTP 服务和导出模型供其他软件使用的技巧(这样你就不需要在生产中添加 R 作为依赖)。到目前为止,你已经构建了一些时间机器学习模型,并且你现在有一些在一段时间内与合作伙伴高效工作使用模型的技巧。

这里有一些关键要点:

+   使用 knitr 生成重要的可重复里程碑/检查点文档。

+   写出有效的注释。

+   使用版本控制保存你的工作历史。

+   使用版本控制与他人协作。

+   使你的模型可供合作伙伴进行实验、测试和生产部署。

在下一章中,我们将探讨如何正式展示和解释你的工作。


# 第十二章. 制作有效的展示

本章涵盖

+   向项目赞助商展示您的结果

+   与您的模型最终用户沟通

+   向同行数据科学家展示您的结果

在上一章中,您看到了如何有效地记录您日常的项目工作以及如何将您的模型部署到生产中。这包括支持运营团队所需的额外文档。在本章中,我们将探讨如何向其他感兴趣的相关方展示您项目的成果。正如我们在心智模型(图 12.1)中所看到的,本章全部关于文档和展示。

图 12.1. 心智模型

![图片](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig01.jpg)

我们将继续使用上一章的例子。

* * *

示例

*假设您的公司(让我们称其为 WVCorp)生产和销售家用电子设备和相关的软件及应用。WVCorp 希望监控公司产品论坛和讨论板上的话题,以识别“即将热议”的问题:那些即将引起大量兴趣和活跃讨论的话题。这些信息可以被产品团队和市场团队用来主动识别未来发布所需的产品特性,并快速发现现有产品特性的问题。您的团队已经成功构建了一个模型来识别论坛上的即将热议的话题。现在您想向项目赞助商解释项目结果,同时也向将使用您模型结果的产品经理、市场经理和支持工程经理解释。*

* * *

表 12.1 总结了我们场景中的相关实体,包括贵公司及其竞争对手销售的产品。

表 12.1. buzz 模型场景中的实体

| 实体 | 描述 |
| --- | --- |
| WVCorp | 您所在的公司 |
| eRead | WVCorp 的电子书阅读器 |
| TimeWrangler | WVCorp 的时间管理应用 |
| BookBits | 竞争对手的电子书阅读器 |
| GCal | TimeWrangler 可以集成的第三方云日历服务 |

* * *

**关于数据和示例项目的免责声明**

我们用于 buzz 模型的数据集是从 Tom’s Hardware ([tomshardware.com](http://tomshardware.com)) 收集的,这是一个讨论电子和电子设备的实际论坛。Tom’s Hardware 与任何特定的产品供应商无关,数据集没有指定记录的主题。本章的示例场景是为了展示会产生与 Tom’s Hardware 数据集类似数据的情景。我们示例中的所有产品名称和论坛主题都是虚构的。

* * *

让我们从为项目赞助商的展示开始.^([1])

> ¹
> 
> 我们提供了示例演示文稿的 PDF 版本,可在[`github.com/WinVector/PDSwR2/tree/master/Buzz`](https://github.com/WinVector/PDSwR2/tree/master/Buzz)找到,文件名为 ProjectSponsorPresentation.pdf、UserPresentation.pdf 和 PeerPresentation.pdf。该目录还包括这些演示文稿的手册,包含简要笔记,格式为 xxxPresentation_withNotes.pdf。

## 12.1. 向项目赞助人展示你的结果

如第一章所述,项目赞助人是希望获得数据科学结果的人——通常是为了满足商业需求。尽管项目赞助人可能具有技术或定量背景,并且可能喜欢听关于技术细节和细微差别,但他们的主要兴趣是面向商业的,因此你应该用商业问题的术语讨论你的结果,尽量减少技术细节。

你还应该记住,赞助人通常会感兴趣将你的工作“推销”给组织中的其他人,以争取支持并获取额外的资源以维持项目的进行。你的演示文稿将是赞助人将与这些人分享的部分,这些人可能不如你和你的赞助人对项目的背景那么熟悉。

为了涵盖这些考虑因素,我们推荐以下类似的结构:

1.  总结项目背后的动机及其目标。

1.  陈述项目的成果。

1.  如有必要,用详细信息备份结果。

1.  讨论建议、未解决的问题和可能未来的工作。

有些人还建议添加一个“执行摘要”幻灯片:对要点 1 和 2 的一个单页摘要。

你如何处理每个要点——时长、详细程度——取决于你的听众和情况。一般来说,我们建议保持演示简短。在本节中,我们将提供一些在我们 buzz 模型示例背景下的示例幻灯片。

让我们逐一详细说明每个要点。

* * *

**我们将专注于内容,而非视觉**

本章的讨论将集中在演示文稿的内容上,而不是幻灯片的视觉格式。在实际演示中,你可能会更喜欢比我们提供的幻灯片更多的视觉元素和更少的文本。如果你在寻找有关演示视觉和引人注目的演示数据可视化的指导,两本好书是

+   Michael Alley,*《科学演示的技艺》*(Springer,2007)

+   Cole Nussbaumer Knaflic,*《用数据讲故事》*(Wiley,2015)

如果你阅读这些文本,你会注意到我们的例子演示文稿违反了他们所有的建议。想想我们的骨骼演示文稿就像是你将填充成更具视觉吸引力的格式的提纲。

值得指出的是,Alley 和 Knaflic 推荐的以视觉为导向、低文本格式的目的是**展示**,而不是阅读。在演讲幻灯片中,用报告或备忘录代替的情况很常见。如果你要将你的演示文稿分发给那些不会看到你亲自演示的人,确保包含全面的演讲者笔记。否则,可能更适合采用大量项目符号、文本密集的演示格式。

* * *

### 12.1.1\. 总结项目目标

本节演示文稿旨在为整个演讲提供背景,特别是如果它将被分发给公司中那些没有像你的项目赞助人那样密切参与的人。让我们为 WVCorp 嗡嗡模型示例准备目标幻灯片。

在图 12.2 中,我们通过展示业务需求和项目如何满足这一需求,为项目的动机提供背景。在我们的例子中,eRead 是 WVCorp 的电子书阅读器,它曾一度引领市场,直到我们的竞争对手发布了他们电子书阅读器 BookBits 的新版本。BookBits 的新版本有一个共享书架功能,而 eRead 没有提供——尽管许多 eRead 用户在论坛上表达了希望拥有这种功能的需求。不幸的是,论坛流量如此之高,以至于产品经理难以跟上,并且不知何故错过了这一用户需求的表达。因此,WVCorp 由于未能预见共享书架功能的需求而失去了市场份额。

图 12.2\. 项目动机

![图片](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig02_alt.jpg)

在图 12.3 中,我们根据图 12.2 中设定的动机,陈述了项目目标。我们希望检测论坛上即将引起热议的话题,以便产品经理能够及早发现新兴问题。

图 12.3\. 陈述项目目标

![图片](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig03_alt.jpg)

一旦你确定了项目的背景,你应该直接进入项目的结果。你的演示文稿不是惊悚电影——不要让你的观众保持悬念!

### 12.1.2\. 陈述项目结果

本节演示文稿简要描述了在满足业务需求的情况下你所做的工作以及结果。图 12.4 描述了嗡嗡模型试点研究以及你所发现的内容。

图 12.4\. 描述项目和其结果

![图片](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig04_alt.jpg)

保持对结果的讨论具体且非技术性。你的听众对模型本身的细节不感兴趣,而是更关心你的模型如何帮助你解决了在演讲动机部分提到的那个问题。不要用精确度、召回率或其他技术指标来谈论你的模型性能,而应该讨论它如何减轻模型最终用户的负担,他们觉得结果有多有用,以及模型遗漏了什么。在模型与货币结果更紧密相关的项目中,例如贷款违约预测,尝试估计你的模型可能为公司产生多少潜在收益或节省多少成本。

### 12.1.3\. 填充细节

一旦你的听众知道了你做了什么,为什么,以及你在商业角度上的成功程度,你就可以填充细节来帮助他们更好地理解。像之前一样,尽量保持讨论相对非技术性,并基于业务流程。描述模型在业务流程或工作流程中的位置以及一些有趣的发现示例将很好地融入这一部分,如图 12.5 所示。

图 12.5\. 更详细地讨论你的工作

![图 12.5 的替代文本](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig05_alt.jpg)

图 12.5 中的“如何工作”幻灯片展示了 buzz 模型在产品经理工作流程中的位置。我们强调(到目前为止)我们使用的是已经集成到系统中的指标(从而最小化需要引入工作流程的新流程数量)。我们还介绍了模型输出可能被潜在使用的途径:为潜在的新功能生成线索,并警告产品支持团队即将出现的问题。

图 12.5 的底部幻灯片展示了项目中的一个有趣发现(在实际演示中,你可能会展示多个)。在这个例子中,Time-Wrangler 是 WVCorp 的时间管理产品,而 GCal 是一个第三方基于云的日历服务,TimeWrangler 可以与之通信。在这个幻灯片中,我们展示了模型如何能够比 TimeWrangler 团队原本预期的时间更早地识别出 TimeWrangler 和 GCal 之间的集成问题(从客户支持日志中)。这样的例子使模型的价值具体化。

我们也在这次演示文稿中包含了一页来讨论建模算法(如图 12.6 所示)。您是否使用这一页取决于听众——一些听众可能具有技术背景,并会对您选择的建模方法感兴趣。其他听众可能不关心。无论如何,请保持简短,并专注于对技术的高层次描述以及为什么您认为这是一个好的选择。如果听众中有任何人对细节感兴趣,他们可以提问——如果您预计听众中有这样的人,您可以准备额外的幻灯片来涵盖可能的问题。否则,请准备好快速处理这一点,或者完全跳过。

图 12.6\. 关于建模方法的可选幻灯片

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig06_alt.jpg)

在本节中,您可能还想讨论其他一些细节。例如,如果参与您试点研究的产品经理给出了有趣的引言或反馈——使用模型后他们的工作多么容易,他们认为特别有价值的发现,他们对如何改进模型的想法——您可以在这里提及这些反馈。这是您让公司其他人对您在这个项目上的工作感兴趣并鼓动持续支持后续工作的机会。

### 12.1.4\. 提出建议和讨论未来工作

没有一个项目能产生完美的结果,您应该坦率(但乐观)地讨论您结果中的局限性。在嗡嗡模型示例中,我们在演示文稿结束时列出了一些我们希望进行的改进和后续工作,如图 12.7 所示。当然,作为数据科学家,您当然对提高模型性能感兴趣,但对于听众来说,提高模型的重要性不如提高过程(更好地满足业务需求)重要。从这一角度来框架讨论。

图 12.7\. 讨论未来工作

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig07_alt.jpg)

项目赞助人演示文稿侧重于整体情况以及您的结果如何帮助更好地满足业务需求。面向最终用户的演示文稿将涵盖许多相同的内容,但现在您将讨论框架放在最终用户的流程和关注点上。我们将在下一节中查看关于嗡嗡模型的用户演示。

### 12.1.5\. 项目赞助人演示文稿要点

关于项目赞助人演示文稿,您应该记住以下几点:

+   请保持简短。

+   请将重点放在业务问题上,而不是技术问题上。

+   您的项目赞助人可能会使用您的演示文稿来帮助向组织中的其他人推销项目或其结果。在介绍背景和动机时,请记住这一点。

+   在演示文稿的早期就介绍您的结果,而不是逐步构建。

## 12.2\. 向最终用户展示您的模型

无论您的模型表现得多好,重要的是实际使用它的人对其输出有信心,并愿意采用它。否则,模型将不会被使用,您的努力将白费。希望您在项目中涉及了最终用户——在我们的热点模型示例中,我们有五位产品经理帮助进行试点研究。最终用户可以帮助您向他们的同事推销模型的益处。

在本节中,我们将给出一个示例,说明您如何向最终用户展示您项目的成果。根据情况,您可能并不总是进行明确的演示:您可能提供用户手册或其他文档。然而,无论信息如何传递给用户,我们都认为让他们知道模型旨在使他们的工作流程更简单,而不是更复杂,是很重要的。为了本章节的目的,我们将使用演示格式。

对于最终用户演示,我们建议采用以下类似的结构:

1.  总结项目背后的动机及其目标。

1.  展示模型如何融入用户的工作流程(以及它如何改进该工作流程)。

1.  展示如何使用模型。

让我们逐一探讨这些观点,从项目目标开始。

### 12.2.1\. 总结项目目标

对于模型的目标用户,讨论商业动机不如关注模型对他们的影响那么重要。在我们的例子中,产品经理已经通过论坛来了解客户需求和问题。我们项目的目标是帮助他们将注意力集中在“好东西”——热点上。如图 12.8 图 12.8 中的示例幻灯片直接指向这一点。用户已经知道他们想要找到热点;我们的模型将帮助他们更有效地搜索。

图 12.8\. 项目动机

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig08_alt.jpg)

### 12.2.2\. 展示模型如何融入用户工作流程

在演示的这一部分,您解释模型如何帮助用户完成工作。一个很好的方法是提供典型用户工作流程的前后场景,就像我们在图 12.9 中展示的那样。

图 12.9\. 模型前后用户工作流程

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig09_alt.jpg)

假设用户已经明显意识到之前的流程及其缺点。之后的幻灯片强调模型将为他们进行一些初步的论坛主题筛选。模型的输出帮助用户管理他们已经存在的关注列表,当然用户也可以直接访问论坛。

下一张幻灯片(图 12.10,顶部)使用试点研究结果来展示模型可以减少监控论坛所需的工作量,并且确实提供了有用的信息。我们在图 12.10 的底部幻灯片中详细阐述了这一点(我们在项目赞助人演示中也用到的 TimeWrangler 示例)。

图 12.10. 从用户的角度展示模型的益处。

![图 12.10 替代](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig10_alt.jpg)

您可能还希望填写更多关于模型如何操作的详细信息。例如,用户可能想知道模型的输入是什么(图 12.11),这样他们就可以将这些输入与他们自己手动在论坛上寻找有趣信息时考虑的内容进行比较。

图 12.11. 提供对用户相关的技术细节。

![图 12.11 替代](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig11_alt.jpg)

一旦您展示了模型如何融入用户工作流程,您就可以解释用户将如何使用它。

### 12.2.3. 展示如何使用模型

本节可能是演示的主要内容,您将在其中教授用户如何使用模型。图 12.12 中的幻灯片描述了产品经理如何与 Buzz 模型交互。在这个示例场景中,我们假设产品经理已经有了一个机制,可以将论坛中的主题和讨论添加到观察列表中,以及一个机制来监控该观察列表。模型将单独向用户发送关于他们感兴趣的主题即将出现的嗡嗡声的通知。

图 12.12. 描述用户如何与模型交互。

![图 12.12 替代](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig12_alt.jpg)

在实际演示中,您将扩展每个要点,引导用户了解他们如何使用模型:他们用来与模型交互的 GUI 截图,以及模型输出的截图。我们在图 12.13 中提供了一个示例幻灯片:一个通知电子邮件的截图,并附有注释来解释用户视图。到本节结束时,用户应该了解如何使用 Buzz 模型以及如何处理 Buzz 模型的结果。

图 12.13. 一个示例教学幻灯片

![图 12.13 替代](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig13_alt.jpg)

最后,我们包括了一个幻灯片,要求用户在认真使用模型后对其提供反馈。这显示在图 12.14 中。用户的反馈可以帮助您(以及其他一旦模型投入运行就支持该模型的团队)改善用户体验,使模型被接受和广泛采用的可能性更大。

图 12.14. 向用户征求反馈。

![图 12.14 替代](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig14_alt.jpg)

除了向项目赞助人和最终用户展示您的模型之外,您可能还需要向您组织内的其他数据科学家或组织外的数据科学家展示您的工作。我们将在下一节中介绍同行展示。

### 12.2.4. 最终用户演示要点

关于最终用户展示,以下是你应该记住的:

+   你的主要目标是说服用户他们想要使用你的模型。

+   专注于模型如何影响(改善)最终用户日常流程。

+   描述如何使用模型以及如何解释或使用模型的输出。

## 12.3\. 向其他数据科学家展示你的工作

向其他数据科学家展示你的工作,这给了他们评估你的工作的机会,也给了你从他们的洞察力中受益的机会。他们可能会在问题中看到你忽略的东西,并可以提出对你方法或你未考虑过的替代方法的好建议。

其他数据科学家主要会对你所使用的建模方法、你尝试的标准技术的任何变化以及与建模过程相关的有趣发现感兴趣。向你的同行进行展示通常具有以下结构:

1.  介绍问题。

1.  讨论相关工作。

1.  讨论你的方法。

1.  提供结果和发现。

1.  讨论未来工作。

让我们详细地走一遍这些步骤。

### 12.3.1\. 介绍问题

你的同行通常对你试图解决的预测任务(如果有的话)最感兴趣,并且不需要像项目发起人或最终用户那样多的背景信息。在图 12.15 中,我们首先介绍了“热点”的概念以及为什么它很重要,然后直接进入预测任务。

图 12.15\. 介绍项目

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig15_alt.jpg)

当你在自己组织内的其他数据科学家面前进行展示时,这种方法最好,因为你们都共享组织需求的背景。当你向组织外的同行群体进行展示时,你可能想要从业务问题开始(例如,项目发起人展示的前两页幻灯片,图 12.2 和 12.3),为他们提供一些背景信息。

### 12.3.2\. 讨论相关工作

学术演讲通常有一个相关工作部分,其中你讨论了其他人对你问题相关的研究,他们采取了什么方法,以及他们的方法与你的方法相似或不同。热点模型项目的相关工作幻灯片如图 12.16 所示。

图 12.16\. 讨论相关工作

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig16_alt.jpg)

你不是在进行学术演讲;对你来说,你的方法成功比新颖更重要。对你来说,相关工作幻灯片是一个讨论你考虑过的其他方法以及为什么它们可能不完全适合你特定问题的机会。

在讨论了你考虑过并拒绝的方法之后,你接下来可以讨论你实际采用的方法。

### 12.3.3\. 讨论你的方法

详细地讨论你所做的工作,包括你必须做出的妥协和遇到的挫折。这为听众提供了背景信息,并增强了他们对你的工作和能力的信心。以我们的例子来说,图 12.17 介绍了我们进行的试点研究,我们使用的数据以及我们选择的建模方法。它还提到一组最终用户(五位产品经理)参与了该项目;这确立了我们确保模型输出有用和相关的做法。

图 12.17. 介绍试点研究

![图 12.17 的替代文本](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig17_alt.jpg)

在你介绍了试点研究之后,你介绍你使用的输入变量和建模方法(图 12.18)。在这种情况下,数据集没有合适的变量——如果我们有适当的数据,我们本可以做更多的时间序列分析,但我们想从已经在产品论坛系统中实施的一些指标开始。对此要坦率。

图 12.18. 讨论模型输入和建模方法

![图 12.18 的替代文本](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig18_alt.jpg)

幻灯片还讨论了我们选择的建模方法——随机森林——以及为什么选择它。由于我们必须修改标准方法(通过限制模型复杂性),我们也提到了这一点。

### 12.3.4. 讨论结果和未来工作

一旦你讨论了你的方法,你可以讨论你的结果。在图 12.19 中,我们讨论了我们的模型性能(精确度/召回率),并确认代表性最终用户确实发现模型输出对他们的工作有用。

图 12.19. 展示模型性能

![图 12.19 的替代文本](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig19_alt.jpg)

图 12.19 的底部幻灯片显示了在模型中最有影响力的变量(记住,变量重要性计算是构建随机森林的一个副作用)。在这种情况下,最重要的变量是主题在各个日子上显示的次数以及有多少作者为该主题做出贡献。这表明这两个变量的时间序列数据可能特别有助于提高模型性能。

你还希望在本节演讲中添加一些引人注目的发现示例——例如,我们在其他两个演示中展示的 TimeWrangler 集成问题。

一旦你展示了模型性能和其他工作成果,你可以在图 12.20 所示的方式中,讨论可能的改进和未来的工作。

图 12.20. 讨论未来工作

![图 12.20 的替代文本](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/12fig20_alt.jpg)

在未来工作幻灯片上的某些观点——特别是对速度变量的需求——自然地从之前的工作和发现讨论中产生。其他一些,如关于模型重新训练计划的工作,在演讲的前部分并没有那么强烈地预示,但可能会在您的观众中引起注意,并且在这里简要阐述是值得的。再次强调,您应该坦率地、乐观地说明您模型的局限性——特别是因为这个观众可能已经看到了这些局限性。

### 12.3.5. 同行演示要点

关于您向数据科学家同行展示的演示,以下是你应该记住的:

+   同行演示主要可以由建模任务激发。

+   与之前的演示不同,同行演示可以(并且应该)包含丰富的技术细节。

+   在构建模型时,要坦率地说明模型的局限性和所做的假设。您的观众可能已经发现了许多局限性。

## 摘要

在本章中,您已经看到了如何向三个不同的观众展示您的工作成果。每个观众都有自己的视角和兴趣点,您的演讲应该针对这些兴趣点进行调整。组织您的演示,宣布一个共同的目标并展示您如何实现这个目标。我们建议了组织每种类型演讲的方法,这将帮助您适当地调整讨论。

我们的建议并非一成不变:您可能有一个想要深入了解更技术细节的项目赞助商或其他感兴趣的执行人员,或者有对模型内部工作原理好奇的最终用户。您也可能有想要了解更多关于业务背景的同行观众。如果您提前知道这一点(可能是因为您之前向这个观众做过演示),那么您应该在演讲中包含适当级别的细节。如果您不确定,您也可以准备备用幻灯片,根据需要使用。只有一个硬性规则:要对您的观众表示同情。

在本章中,您已经学到了

+   如何为项目赞助商准备以业务为导向的演示

+   如何为最终用户准备演示(或文档),向他们展示如何使用您的模型并说服他们想要使用它

+   如何为您的同行准备更技术性的演示


# 附录 A. 从 R 和其他工具开始

在这个附录中,我们将展示如何安装工具并开始使用 R。我们将演示一些示例概念和步骤,但你可能需要进一步阅读以获取更多信息。

章节 A.1 是所有读者都应该回顾的内容,因为它展示了如何获取本书的所有软件支持材料。其他章节应根据需要考虑,因为它们概述了 R 的工作细节(读者可能已经知道)以及一些可能不是所有读者都需要的具体应用(例如使用数据库)。在本书中,我们尽量避免“以防万一”的教学,但在附录中,我们提供了一些你可能“可能”需要的东西。

## A.1. 安装工具

我们的工作示例的主要工具将是 R,以及可能还有 RStudio。但其他工具(数据库、版本控制、编译器等)也非常推荐。你可能还需要访问在线文档或其他帮助,以便在你的环境中使用所有这些工具。我们列出的发行站点是一个良好的起点。

### A.1.1. 安装工具

R 环境是一套可以安装在 Unix、Linux、Apple macOS 和 Windows 上的工具和软件。

R

我们推荐从综合 R 档案网络(CRAN)[`cran.r-project.org`](https://cran.r-project.org)或其镜像安装最新的 R 版本。CRAN 是 R 及其软件包的官方中央仓库。CRAN 由 R 基金会和 R 开发核心团队支持。R 本身是自由软件基金会 GNU 项目的官方部分,在 GPL 2 许可证下分发。R 被许多大型机构使用,包括美国食品药品监督管理局。^([[1)]

> ¹
> 
> 来源:[`www.r-project.org/doc/R-FDA.pdf`](https://www.r-project.org/doc/R-FDA.pdf)。

对于这本书,我们建议使用至少 R 版本 3.5.0 或更新的版本。

要使用 R,你需要一个专门用于处理非格式化(或非丰富)文本的文本编辑器。这些编辑器包括 Atom、Emacs、Notepad++、Pico、Programmer’s Notepad、RStudio、Sublime Text、text wrangler、vim 等。这些与富文本编辑器(不适用于编程任务)形成对比,例如 Microsoft Word 或 Apple Text Edit。

RStudio

我们建议在用 R 工作时考虑使用 RStudio。RStudio 是由 RStudio, Inc.提供的流行跨平台集成开发环境([`www.rstudio.com`](https://www.rstudio.com))。RStudio 提供内置文本编辑器和方便的用户界面,用于执行安装软件、渲染 R Markdown 文档和操作源代码控制等常见任务。RStudio 不是 R 或 CRAN 的官方部分,不应与 R 或 CRAN 混淆。

RStudio 的重要特性之一是文件浏览器和位于文件浏览面板齿轮图标中的隐藏的设置目录/转到目录控件,我们在图 A.1 中指出。

图 A.1\. RStudio 文件浏览控件

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app01fig01_alt.jpg)

RStudio 不是使用 R 或完成本书中的示例的必要条件。

Git

Git 是一种源代码控制或版本管理系统,对于保存和共享工作非常有用。要安装 Git,请遵循 [`git-scm.com`](https://git-scm.com) 上的适当说明。

数据科学总是涉及大量的工具和协作,因此愿意尝试新工具是必须培养的灵活性。

书籍支持材料

书中所有支持材料均可在 GitHub 上免费获取:[`github.com/WinVector/PDSwR2`](https://github.com/WinVector/PDSwR2),如图 A.2 所示。读者应下载全部内容,可以使用 `git clone` 命令和 URL [`github.com/WinVector/PDSwR2.git`](https://github.com/WinVector/PDSwR2.git) 进行下载,或者通过在 GitHub 页面右上角的“克隆或下载”控件中下载完整的 zip 文件。

图 A.2\. 从 GitHub 下载书籍材料

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app01fig02_alt.jpg)

下载书籍材料的另一种方法是使用 RStudio 和 Git。选择文件 > 新建项目 > 从版本控制创建项目 > Git。这将弹出一个对话框,如图 A.3 所示。您可以在其中填写 Git URL 并将书籍材料作为项目下载。

图 A.3\. 克隆书籍仓库

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app01fig03_alt.jpg)

我们将在整本书中引用此目录为 PDSwR2,我们提到的所有文件和路径要么在此目录中,要么在子目录中。请确保在此目录中查找任何 README 或勘误文件。

支持目录的一些特性包括以下内容:

+   书中使用的所有示例数据。

+   书中使用的所有示例代码。书中的示例可在 CodeExamples 子目录中找到,也可以作为 CodeExamples.zip 文件下载。此外,整个示例集,包括重新运行和重新渲染的示例,都在 RenderedExamples 中共享。(所有路径均相对于您解压的书籍目录 PDSwR2。) 

R 包

R 的一大优势是 CRAN 中心包仓库。R 通过 `install.packages()` 命令实现了标准化的包安装。一个安装的包通常在通过 `library()` 命令附加以供使用之前,在项目中并不完全可用.^([2]) 良好的做法是:任何类型的 R 脚本或工作都应首先附加它打算使用的所有包。此外,在大多数情况下,脚本不应调用 `install.packages()`,因为这会改变 R 的安装,这不应在没有用户监督的情况下进行。

> ²
> 
> 在 R 中,安装软件包是使用软件包的一个独立步骤。`install.packages()` 使软件包内容可能可用;之后,`library()` 准备它们以供使用。一个方便的记忆法是:`install.packages()` 在你的厨房中设置新的家电,而 `library()` 则是打开它们。你不必经常安装东西,然而你经常需要重新打开它们。

安装所需软件包

要安装本书中所有示例所需的一组软件包,首先按照之前描述的方法下载本书的仓库。然后查看该仓库的第一个目录或顶级目录:PDSwR2\. 在这个目录中,你会找到名为 packages.R 的文件。你可以用文本编辑器打开这个文件,它应该看起来像下面这样(尽管它可能比这里显示的更新)。

Please have an up to date version of R (3.5.*, or newer)

Answer "no" to:

Do you want to install from sources the packages which need compilation?

update.packages(ask = FALSE, checkBuilt = TRUE)

pkgs <- c(
"arules", "bitops", "caTools", "cdata", "data.table", "DBI",
"dbplyr", "DiagrammeR", "dplyr", "e1071", "fpc", "ggplot2",
"glmnet", "glmnetUtils", "gridExtra", "hexbin", "kernlab",
"igraph", "knitr", "lime", "lubridate", "magrittr", "MASS",
"mgcv", "pander", "plotly", "pwr", "randomForest", "readr",
"readxls", "rmarkdown", "rpart", "rpart.plot", "RPostgres",
"rqdatatable", "rquery", "RSQLite", "scales", "sigr", "sqldf",
"tidypredict", "text2vec", "tidyr", "vtreat", "wrapr", "WVPlots",
"xgboost", "xts", "webshot", "zeallot", "zoo")

install.packages(
pkgs,
dependencies = c("Depends", "Imports", "LinkingTo"))


要安装所有内容,请在 R 中运行此文件中的每一行代码.^([3])

> ³
> 
> 之前提到的代码可以在 [`github.com/WinVector/PDSwR2`](https://github.com/WinVector/PDSwR2) 的 packages.R 文件中找到。我们可以称之为 PDSwR2/packages.R,这可能意味着来自原始 GitHub URL 的文件或 GitHub 仓库的本地副本。

很不幸,安装可能会因为许多原因失败:错误的复制粘贴、没有网络连接、R 或 RStudio 配置不当、没有足够的权限管理 R 安装、R 或 RStudio 版本过旧、缺少系统要求,或者没有或错误的 C/C++/Fortran 编译器。如果你遇到这些问题,最好是找到一个论坛或专家来帮助你完成这些步骤。一旦所有内容都成功安装,R 就是一个自包含的环境,其中事物可以正常工作。

并非所有软件包对所有示例都是必需的,所以如果你在整体安装过程中遇到麻烦,只需尝试在书中工作示例。这里有一个警告:如果你看到一个 `library(pkgname)` 命令失败,请尝试 `install.packages('pkgname')` 来安装缺失的软件包。前面的软件包列表只是试图在一步中解决所有问题。

其他工具

可以通过使用 Perl、gcc/clang、gfortran、git、Rcpp、TeX、pandoc、ImageMagick 和 Bash shell 等工具来增强 R 的功能。这些工具都在 R 之外管理,如何维护它们取决于你的计算机、操作系统和系统权限。Unix/Linux 用户安装这些工具最简单,R 主要是在 Unix 环境中开发的.^([5]) RStudio 将安装一些额外的工具。macOS 用户可能需要 Apple 的 Xcode 工具和 Homebrew ([`brew.sh`](https://brew.sh)) 来获得所有必需的工具。希望编写软件包的 Windows 用户可能需要研究 RTools ([`cran.r-project.org/bin/windows/Rtools/`](https://cran.r-project.org/bin/windows/Rtools/))。

> ⁴
> 
> 查看 [`www.perl.org/get.html`](https://www.perl.org/get.html)。
> 
> ⁵
> 
> 例如,我们在这里分享如何在 Amazon EC2 实例上快速配置 R 和 RStudio Server 的笔记:[www.win-vector.com/blog/2018/01/setting-up-rstudio-server-quickly-on-amazon-ec2/](http://www.win-vector.com/blog/2018/01/setting-up-rstudio-server-quickly-on-amazon-ec2/)。

Windows 用户可能需要 RTools 来编译包;然而,这通常不是严格必要的,因为大多数当前包都以预编译形式从 CRAN 提供(至少对于 macOS 和 64 位 Windows)。macOS 用户可能需要安装 Xcode 编译器(由 Apple 提供)来编译包。所有这些都是在你需要编译能力之前可能想要跳过的步骤。

### A.1.2\. R 包系统

R 是一种广泛且强大的语言,本身就是一个分析工作平台。但它的真正优势之一是包系统的深度以及通过 CRAN 提供的包。要从 CRAN 安装一个包,只需输入 `install.packages('nameofpackage')`。要使用已安装的包,输入 `library(nameofpackage)`。^([6]) 每次你输入 `library('nameofpackage')` 或 `require('nameofpackage')`,你都在假设你正在使用一个内置包,或者如果你需要的话,能够运行 `install.packages('nameofpackage')`。在这本书中,我们将反复回到包系统。要查看你的会话中存在哪些包,输入 `sessionInfo()`。

> ⁶
> 
> 实际上,`library('nameofpackage')` 也可以使用引号。未使用引号的形式在 R 中有效,因为 R 有能力延迟参数评估(所以未定义的 `nameofpackage` 不会导致错误)以及能够窥探参数变量的名称(大多数编程语言只依赖于参数的引用或值)。鉴于数据科学家整天都要与许多工具和语言打交道,我们更喜欢不依赖于特定于一种语言的特性,除非我们真的需要这个特性。但“官方 R 风格”是不使用引号的。

* * *

更改你的 CRAN 镜像

你可以使用 `chooseCRANmirror()` 命令随时更改你的 CRAN 镜像。如果你正在使用的镜像很慢,这会很有用。

* * *

### A.1.3\. 安装 Git

我们建议在向你展示如何使用 R 和 RStudio 之前安装 Git 版本控制。这是因为如果没有 Git 或类似工具,你会丢失重要的工作。不仅仅是丢失 *你的* 工作——你还会丢失重要的 *客户* 工作。许多数据科学工作(尤其是分析任务)涉及尝试变体和学习新事物。有时你会学到一些令人惊讶的东西,需要重新进行早期的实验。版本控制保留了所有工作的早期版本,因此它是恢复早期实验中使用的代码和设置的完美工具。Git 可从 [`git-scm.com`](http://git-scm.com) 的预编译包中获得。

### A.1.4\. 安装 RStudio

RStudio 提供了一个文本编辑器(用于编辑 R 脚本)和 R 的集成开发环境。在从 [`rstudio.com`](http://rstudio.com) 获取 RStudio 之前,你应该安装 R 和 Git,就像我们之前描述的那样。

你最初想要的 RStudio 产品称为 *RStudio Desktop*,它为 Windows、Linux 和 macOS 提供了预编译版本。

当你刚开始使用 RStudio 时,我们强烈建议关闭“启动时将 .RData 恢复到工作区”和“退出时将工作区保存到 .RData”这两个功能。这些设置(默认)开启会使“干净工作”(我们将在 章节 A.3 中讨论)变得难以可靠。要关闭这些功能,请打开 RStudio 选项面板(全局选项可以通过例如菜单 RStudio > 预设,工具 > 全局选项,工具 > 选项或类似的方式找到,具体取决于你使用的操作系统),然后按照 图 A.4 中的指示更改这两个设置。

图 A.4\. RStudio 选项

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app01fig04_alt.jpg)

### A.1.5\. R 资源

R 的强大功能很大程度上来自于其庞大的包家族,这些包可以从 CRAN 仓库获取。在本节中,我们将指出一些包和文档。

安装 R 视图

R 提供了一套非常深入的可用库。通常,R 已经有了你想要的包;只是需要找到它。一种强大的查找 R 包的方法是使用 *视图*:[`cran.r-project.org/web/views/`](http://cran.r-project.org/web/views/)。

你也可以通过一个命令(尽管警告:这可能需要一个小时才能完成)安装所有包(包括帮助文档)。例如,这里我们一次性安装了一个庞大的时间序列库集合:

install.packages('ctv', repos = 'https://cran.r-project.org')
library('ctv')

install.views('TimeSeries') # can take a LONG time


一旦完成这些,你就可以尝试示例和代码了。

在线 R 资源

在线有很多 R 帮助资源。我们最喜欢的资源包括以下这些:

+   ***CRAN—*** 主要的 R 网站:[`cran.r-project.org`](http://cran.r-project.org)

+   ***Stack Overflow R 部分—*** 一个问答网站:[`stackoverflow.com/questions/tagged/r`](http://stackoverflow.com/questions/tagged/r)

+   ***Quick-R—*** 一个优秀的 R 资源:[`www.statmethods.net`](http://www.statmethods.net)

+   ***LearnR—*** 将 *Lattice: Multivariate Data Visualization with R (Use R!)*(由 D. Sarker;Springer,2008)中的所有图表翻译成 ggplot2:[`learnr.wordpress.com`](http://learnr.wordpress.com)

+   ***R-bloggers—*** 一个 R 博客聚合器:[`www.r-bloggers.com`](http://www.r-bloggers.com)

+   ***RStudio 社区—*** 一个以 RStudio/tidyverse 为导向的公司网站:[`community.rstudio.com/`](https://community.rstudio.com/)

## A.2\. 从 R 开始

R 实现了一种名为 *S* 的统计编程语言的方言。S 的原始实现演变成一个名为 S+ 的商业包。因此,R 的许多语言设计决策都可以追溯到 S。为了避免混淆,当我们描述功能时,我们大部分时间只说 *R*。您可能会想知道 S/R 是什么样的命令和编程环境。它非常强大,有一个很好的命令解释器,我们鼓励您直接输入。

* * *

**干净工作**

在 R 或 RStudio 中,重要的是“干净地工作”——也就是说,从一个空的工作区开始,并明确引入您想要的包、代码和数据。这确保您知道如何进入您的准备就绪状态(因为您必须执行或写下到达那里的步骤),并且您不会受到您不知道如何恢复的状态的束缚(我们称之为“无外来工件”规则)。

要在 R 中干净地工作,你必须关闭任何类型的工作区自动恢复。在“基础 R”中,这是通过使用带有`--no-restore`命令行标志重启 R 来实现的。在 RStudio 中,会话 > 重启 R 菜单选项起到类似的作用,*如果*未选中“启动时将 .Rdata 恢复到工作区”选项。

* * *

使用 R 和向 R 发送命令实际上是脚本编写或编程。我们假设您对脚本编写(可能使用 Visual Basic、Bash、Perl、Python、Ruby 等)或编程(可能使用 C、C#、C++、Java、Lisp、Scheme 等)有些了解,或者愿意使用我们的参考资料之一来学习。我们并不打算在 R 中编写长程序,但我们必须展示如何发出 R 命令。R 的编程虽然功能强大,但与许多流行的编程语言略有不同,但我们相信,只要有一些指导,任何人都可以使用 R。如果您不知道如何使用某个命令,请尝试使用`help()`调用获取一些文档。

在整本书中,我们将指导您在 R 中运行各种命令。这几乎总是意味着将文本或命令提示符 > 后面的文本输入到 RStudio 控制台窗口中,然后按 Return。例如,如果我们告诉您输入`1/5`,您可以将它输入到控制台窗口中,当您按下 Enter 键时,您将看到类似 `[1] 0.2` 的结果。结果中的 `[1]` 部分只是 R 标记结果行的方式(应忽略),而 `0.2` 是请求的五分之一的浮点表示。

* * *

帮助

总是尝试调用`help()`来了解命令。例如,`help('if')`将显示有关 R 的`if`命令的帮助信息。

* * *

让我们尝试几个命令,帮助您熟悉 R 和其基本数据类型。R 命令可以用换行符或分号(或两者)终止,但直到您按下 Return 键之前,交互式内容不会执行。以下列表显示了您应该在您的 R 复制中运行的几个实验。

列表 A.1\. 尝试几个 R 命令

1

[1] 1

1/2

[1] 0.5

'Joe'

[1] "Joe"

"Joe"

[1] "Joe"

"Joe"=='Joe'

[1] TRUE

c()

NULL

is.null(c())

[1] TRUE

is.null(5)

[1] FALSE

c(1)

[1] 1

c(1, 2)

[1] 1 2

c("Apple", 'Orange')

[1] "Apple" "Orange"

length(c(1, 2))

[1] 2

vec <- c(1, 2)
vec

[1] 1 2


* * *

# 是 R 的注释字符

`#` 符号是 R 语言中的注释字符。它表示该行剩余部分的内容将被忽略。我们使用它来添加注释,也可以用它将输出与结果一起包含。

* * *

### A.2.1\. R 的主要特性

R 命令看起来像典型的过程式编程语言。这有点误导,因为 R 语言(该语言实现了 S 语言)实际上受到了函数式编程的启发,并且也具有许多面向对象的特点。

赋值

R 有五种常见的赋值操作符:`=`, `<-`, `->`, `<<-`, 和 `->>`。在 R 中,`<-` 是首选的赋值操作符,而 `=` 被视为一个晚些时候的添加,并且是它的业余别名。

`<-` 符号的优点在于 `<-` 总是表示赋值,而 `=` 可以表示赋值、列表槽绑定、函数参数绑定或情况语句,具体取决于上下文。要避免的一个错误是意外地在赋值操作符中插入空格:

x <- 2
x < - 3

[1] FALSE

print(x)

[1] 2


我们实际上更喜欢 `=` 赋值,因为数据科学家通常同时使用多种语言,并且使用 `=` 可以更早地捕捉到更多错误。但这个建议太异端,不能强加于人(见 [`mng.bz/hfug`](http://mng.bz/hfug))。我们试图在这本书中一致地使用 `<-`,但一些习惯很难改变。

* * *

R 中的多行命令

R 很擅长处理多行命令。要输入多行命令,只需确保在断行处停止解析会导致语法错误即可。例如,要将 `1+2` 作为两行输入,在加号后添加换行符,而不是在前面。要从 R 的多行模式中退出,请按 Escape 键。许多神秘的 R 错误是由以下原因造成的:要么是语句结束得比你想要的早(一个不会在早期终止时强制语法错误的换行符),要么是结束得不是你预期的(需要额外的换行符或分号)。

* * *

`=` 操作符主要用于将值绑定到函数参数(而 `<-` 不能这样使用),如以下列表所示。

列表 A.2\. 将值绑定到函数参数

divide <- function(numerator,denominator) { numerator/denominator }
divide(1, 2)

[1] 0.5

divide(2, 1)

[1] 2

divide(denominator = 2, numerator = 1)

[1] 0.5

divide(denominator <- 2, numerator <- 1) # wrong symbol <-
, yields 2, a wrong answer!

[1] 2


`->` 操作符仅是一个从左到右的赋值,允许你编写类似 `x -> 5` 的内容。这很有趣,但并不改变游戏规则。

`<<-` 和 `->>` 操作符应尽量避免使用,除非你确实需要它们的特殊功能。它们旨在将值写入当前执行环境之外,这是副作用的一个例子。当你需要它们时(通常用于错误跟踪和日志记录),副作用看起来很棒,但过度使用会使代码维护、调试和文档编写变得非常困难。在以下列表中,我们展示了一个没有副作用的好函数和一个有副作用的坏函数。

列表 A.3\. 展示副作用

x<-1
good <- function() { x <- 5}
good()
print(x)

[1] 1

bad <- function() { x <<- 5}
bad()
print(x)

[1] 5


向量化操作

许多 R 操作被称为 *向量化*,这意味着它们作用于向量的每个元素。这些运算符很方便,并且应该优先于显式的代码,如 for 循环。例如,向量化的逻辑运算符是 `==`、`&` 和 `|`。接下来的列表展示了使用这些运算符在 R 的逻辑类型 `TRUE` 和 `FALSE` 上的示例。

列表 A.4\. R 中布尔运算符的真值表

c(TRUE, TRUE, FALSE, FALSE) == c(TRUE, FALSE, TRUE, FALSE)

[1] TRUE FALSE FALSE TRUE

c(TRUE, TRUE, FALSE, FALSE) & c(TRUE, FALSE, TRUE, FALSE)

[1] TRUE FALSE FALSE FALSE

c(TRUE, TRUE, FALSE, FALSE) | c(TRUE, FALSE, TRUE, FALSE)

[1] TRUE TRUE TRUE FALSE


要测试两个向量是否匹配,我们会使用 R 的 `identical()` 或 `all.equal()` 方法。

* * *

在 R 中何时使用 && 或 ||

`&&` 和 `||` 只在标量上工作,不在向量上。所以 *总是* 在 `if()` 语句中使用 `&&` 和 `||`,永远不要在 `if()` 语句中使用 `&` 或 `|`。类似地,当处理一般数据(可能需要这些向量化的版本)时,更倾向于使用 `&` 和 `|`。

* * *

R 还提供了一个名为 `ifelse(,,)` 的向量化函数(基本的 R 语言 `if` 语句并没有向量化)。

R 的对象系统

R 中的每个元素都是一个对象,并且有一个称为 *类* 的类型定义。你可以使用 `class()` 命令来询问任何对象的类型。例如,`class(c(1,2))` 的类型是 *numeric*。实际上,R 有两个面向对象的系统。第一个被称为 *S3*,它最接近 C++ 或 Java 程序员所期望的系统。在 S3 类系统中,你可以有多个具有相同名称的命令。例如,可能存在多个名为 `print()` 的命令。当你输入 `print(x)` 时,实际上调用的是哪个 `print()` 取决于 `x` 在运行时的类型。S3 是一个独特的对象系统,因为方法是全球函数,并且与对象定义、原型或接口没有强关联。R 还有一个名为 *S4* 的第二个面向对象系统,它支持更详细的类,并允许根据第一个参数的类型以外的类型来选择方法。除非你打算成为一名专业的 R 程序员(而不是专业的 R 用户或数据科学家),我们建议不要深入研究 R 的面向对象系统。大多数情况下,你只需要知道大多数 R 对象定义了有用的通用方法,如 `print()`、`summary()` 和 `class()`。我们还建议大量使用 `help()` 命令。要获取特定类的帮助,使用 *method.class* 语法;例如,要获取与类 `glm` 对象关联的 `predict()` 方法的信息,你应该输入 `help(predict.glm)`。

R 的按值共享特性

在 R 中,每个对值的引用都是隔离的:对一个引用的更改不会被其他引用看到。这是一个有用的特性,类似于其他语言所说的“按值调用语义”,甚至是某些语言的不可变数据类型。

这意味着,从程序员的视角来看,每个变量或函数的每个参数都表现得像它是一个传递给函数的单独副本。技术上,R 的调用语义实际上是引用和所谓的 *惰性复制* 的组合。但直到你开始直接操作函数参数引用,你看到的是类似值传递的行为。

值共享是分析软件的一个很好的选择:它减少了副作用和错误。但大多数编程语言都不是值共享,因此值共享语义通常是一个惊喜。例如,许多专业程序员依赖于函数内部值的变化在函数外部可见。下面是一个值传递工作的例子。

列表 A.5\. 值传递效应

a <- c(1, 2)
b <- a

print(b)

a[[1]] <- 5 ❶

print(a)

print(b) ❷


❶ 改变 a。这是通过构建一个全新的向量并将 a 重新分配到这个新向量来实现的。旧值保持不变,任何引用仍然看到未更改的旧值。

❷ 注意 b 的值没有改变。

### A.2.2\. R 的主要数据类型

虽然 R 语言及其特性很有趣,但 R 的数据类型才是 R 分析风格的主要原因。在本节中,我们将讨论主要数据类型以及如何使用它们。

向量

R 中最基本的数据类型是 *向量* 或数组。在 R 中,向量是相同类型值的数组。它们可以用 `c()` 符号构建,该符号将逗号分隔的参数列表转换为向量(参见 `help(c)`)。例如,`c(1,2)` 是一个向量,其第一个条目是 `1`,第二个条目是 `2`。尝试在 R 的命令提示符中输入 `print(c(1,2))` 来查看向量的样子,并注意 `print(class(1))` 返回 `numeric`,这是 R 对数值向量的称呼。

R 在没有标量类型方面相当独特。在 R 中,单个数字(如数字 5)被表示为一个只有一个条目的向量(5)。

* * *

**R 中的数字**

R 中的数字主要是以双精度浮点数表示。这与一些默认使用整数的编程语言(如 C 和 Java)不同。这意味着你不需要写 `1.0/5.0` 来防止 `1/5` 在 C 或 Java 中被舍入到 `0`,就像你通常做的那样。这也意味着某些分数不能完美地表示。例如,R 中的 `1/5` 实际上(当通过 `sprintf("%.20f", 1 / 5)` 格式化为 20 位时)是 `0.20000000000000001110`,而不是通常显示的 `0.2`。这并不特指 R;这是浮点数的本质。一个值得记住的好例子是 `1 / 5 != 3 / 5 - 2 / 5`,因为 `1 / 5 - (3 / 5 - 2 / 5)` 等于 `5.55e-17`。

* * *

R 通常不会向用户公开任何原始或标量类型。例如,数字 `1.1` 实际上被转换为一个长度为 1 的数值向量,其第一个条目是 `1.1`。请注意,`print(class(1.1))` 和 `print(class(c(1.1, 0)))` 是相同的。还要注意,`length(1.1)` 和 `length(c(1.1))` 也是相同的。我们所说的标量(或单个数字或字符串)在 R 中只是长度为 `1` 的向量。R 最常见的向量类型如下:

+   ***数值类型—*** 双精度浮点数的数组。

+   ***字符类型—*** 字符串的数组。

+   ***因子类型—*** 从一组固定可能性(在许多其他语言中称为 *枚举*)中选择字符串的数组。

+   ***逻辑类型—*** `TRUE`/`FALSE` 的数组。

+   ***NULL—*** 空向量 `c()`(始终具有类型 `NULL`)。请注意,`length(NULL)` 为 `0`,且 `is.null(c())` 为 `TRUE`。

R 使用方括号表示法(以及其他表示法)来引用向量中的条目.^([7]) 与大多数现代编程语言不同,R 从 1 而不是 0 开始对向量进行编号。以下是一些示例代码,展示了创建一个名为 `vec` 的变量,它包含一个数值向量。此代码还显示,大多数 R 数据类型是 *可变的*,这意味着我们可以更改它们:

> ⁷
> 
> 最常用的索引表示法是 `[]`。当提取单个值时,我们更喜欢双方括号表示法 `[[]]`,因为它在 `[]` 不适用的情况下会发出越界警告。

vec <- c(2, 3)
vec[[2]] <- 5
print(vec)

[1] 2 5


* * *

数字序列

使用像 `1:10` 这样的命令可以轻松生成数字序列。注意:冒号运算符绑定不是很紧密,因此你需要养成使用额外括号的习惯。例如,`1:5 * 4 + 1` 并不意味着 `1:21`。对于常量序列,尝试使用 `rep()`。

* * *

列表

除了使用 `c()` 运算符创建的向量之外,R 还有两种类型的列表。与向量不同,列表可以存储多种类型的对象,因此是返回多个结果的首选方式。基本的 R 列表使用 `list()` 运算符创建,例如 `list(6, 'fred')`。基本的列表实际上并不那么有用,所以我们将其跳过,直接到 *命名列表*。在命名列表中,每个条目都有一个名称。一个命名列表的示例可以通过 `list('a' = 6, 'b' = 'fred')` 创建。通常省略列表名称上的引号,但列表名称始终是常量字符串(不是变量或其他类型)。在 R 中,命名列表实际上是唯一的便利映射结构(另一种映射结构是环境,它提供可变列表)。访问列表中的条目的方式是 `$` 运算符和 `[[]]` 运算符(在 R 的帮助系统中查看 `help('[[')`)。以下是一个快速示例。

列表 A.6\. R 索引运算符的示例

x <- list('a' = 6, b = 'fred')
names(x)

[1] "a" "b"

x$a

[1] 6

x$b

[1] "fred"

x[['a']]

$a

[1] 6

x[c('a', 'a', 'b', 'b')]

$a

[1] 6

$a

[1] 6

$b

[1] "fred"

$b

[1] "fred"


* * *

**标签使用大小写敏感的局部匹配**

R 列表标签操作符(如 `$`)允许部分匹配。例如,`list('abe' = 'lincoln')$a` 返回 `lincoln`,这很好,直到你向这样的列表添加一个实际标记为 a 的槽位,你的旧代码就会出错。一般来说,如果 `list('abe'='lincoln')$a` 是一个错误,那么你就有机会在第一次犯这样的错误时得到信号。你可以尝试使用 `options(warnPartialMatchDollar = TRUE)` 来禁用此行为,但即使这在所有上下文中都有效,它也可能破坏任何依赖于这种简写符号的任何其他代码。

* * *

正如你在我们的示例中看到的,`[]` 操作符是向量化的,这使得列表作为翻译映射变得极其有用。

* * *

**选择:[[]] 与 []**

`[[]]` 是从列表或向量中选择单个元素的严格正确操作符。乍一看,`[]` 似乎是一个方便的别名 `[[]]`,但对于单值(标量)参数来说,这并不严格正确。实际上,`[]` 是一个可以接受向量作为其参数的操作符(尝试 `list(a='b')[c('a','a')]`),并且可以返回非平凡向量(长度大于 1 的向量或看起来不像标量的向量)或列表。操作符 `[[]]` 对于列表和向量都有不同的(更好的)单元素语义(尽管,不幸的是,`[[]]` 对于列表和向量的语义不同)。

真的,你应该**永远**不要在可以使用 `[[]]` 时使用 `[]`(当你只想得到单个结果时)。每个人,包括作者,都会忘记这一点,并且比安全地使用 `[]` 更频繁地使用它。对于列表,主要问题是 `[[]]` 有用地解包了从列表类型返回的值(正如你所期望的:比较 `class(list(a='b')['a'])` 与 `class(list(a='b')[['a']])`)。对于向量,问题是 `[]` 无法指示越界访问(比较 `c('a','b')[[7]]` 与 `c('a','b')[7]` 或,更糟糕的是,`c('a','b')[NA]`)。

* * *

数据框

R 的核心数据结构是 *数据框*。数据框组织成行和列。它是由不同类型的列组成的列表。每一行都有一个列的值。R 数据框类似于数据库表:列类型和名称是模式,行是数据。在 R 中,你可以使用 `data.frame()` 命令快速创建数据框。例如,`d = data.frame(x=c(1,2),y=c('x','y'))` 是一个数据框。

从数据框中读取列的正确方法是使用 `[[]]` 或 `$` 操作符,例如 `d[['x']]`、`d$x` 或 `d[[1]]`。列也通常用 `d[, 'x']` 或 `d['x']` 的表示法读取。请注意,并非所有这些操作符都返回相同类型(一些返回数据框,而一些返回数组)。

使用 `d[rowSet,]` 符号可以从数据框中访问行集,其中 `rowSet` 是一个布尔向量,每个数据行有一个条目。我们更倾向于使用 `d[rowSet,, drop = FALSE]` 或 `subset(d,rowSet)`,因为它们保证始终返回数据框,而不是某些意外的类型(如向量,它不支持数据框的所有相同操作).^([8]) 使用 `d[k,]` 符号可以访问单行,其中 `k` 是行索引。在数据框上可以调用的有用函数包括 `dim()`、`summary()` 和 `colnames()`。最后,可以使用行和列的符号来引用数据框中的单个单元格,例如 `d[1, 'x']`。

> ⁸
> 
> 要查看问题,请输入 `class(data.frame(x = c(1, 2))[1, ])`,它报告的类别为 `numeric`,而不是 `data.frame`。

从 R 的角度来看,数据框是一个单张表,其中每行对应你感兴趣的示例,每列对应你可能想要处理的特征。这当然是一个理想化的观点。数据科学家并不期望如此幸运,能够找到这样一个现成的数据集来工作。事实上,数据科学家的 90% 的工作是找出如何将数据转换成这种形式。我们称这项任务为 *数据管道*,它涉及将来自多个来源的数据连接起来,寻找新的数据来源,并与商业和技术合作伙伴合作。但数据框正是正确的抽象。将数据表视为理想的数据科学家 API。它代表了将数据准备成这种形式的工作步骤和分析步骤之间的一种良好分界。

数据框本质上是一列列的列表。这使得打印摘要或所有列的类型变得特别容易,但将批量操作应用于所有行则不太方便。R 矩阵按行组织,因此将数据框转换为/从矩阵转换(并使用转置 `t()`)是执行数据框行批量操作的一种方法。但请注意:使用类似 `model.matrix()` 命令(将分类变量转换为多个数值水平指示符的列)将数据框转换为矩阵时,不会跟踪多个列可能如何从一个变量派生出来,并且可能会使具有按变量启发式(如逐步回归和随机森林)的算法产生混淆。

如果填充数据框的唯一方式是手动输入,那么数据框将毫无用处。填充数据框的两种主要方式是 R 的 `read.table()` 命令和数据库连接器(我们将在 A.3 节 中介绍)。

矩阵

除了数据框之外,R 还支持矩阵。矩阵是通过行和列来寻址的二维结构。矩阵与数据框的不同之处在于,矩阵是行列表,并且矩阵中的每个单元格都具有相同的类型。在索引矩阵时,我们建议使用`drop = FALSE`的表示法;如果没有这个,本应返回单行矩阵的选择将返回向量。这似乎没问题,但在 R 中,向量不能替代矩阵,因此期望矩阵的下游代码在运行时会神秘地崩溃。而且崩溃可能很少见,难以演示或找到,因为这只有在选择恰好返回一行时才会发生。

NULL 和 NANA(不可用)值

R 有两个特殊值:`NULL` 和 `NA`。在 R 中,`NULL` 只是 `c()`(空向量)的别名。它不携带类型信息,因此空数字向量与空字符串向量属于同一类型(这是一个设计缺陷,但与大多数编程语言处理所谓的空指针的方式一致)。`NULL` 只能在期望向量或列表的地方出现;它不能表示缺失的标量值(如单个数字或字符串)。

对于缺失的标量值,R 使用一个特殊符号`NA`,表示缺失或不可用数据。在 R 中,`NA`的行为类似于在大多数浮点实现中看到的非数字或`NaN`(除了`NA`可以表示任何标量,而不仅仅是浮点数)。值`NA`表示非信号错误或缺失值。"非信号"意味着你不会得到打印警告,并且你的代码不会停止(这不一定是个好事)。如果`NA`重复出现,则`NA`是不一致的。"2+NA"是`NA`,正如我们所希望的那样,但`paste(NA,'b')`是一个有效的非`NA`字符串。

即使`class(NA)`声称是逻辑类型,`NA`也可以出现在任何向量、列表、槽或数据框中。

因素

除了名为`character`的字符串类型之外,R 还有一个特殊的“字符串集合”类型,类似于 Java 程序员所说的*枚举类型*。这种类型称为*因素*,因素只是一个保证从称为*级别*的指定值集中选择的字符串值。因素的优势在于,它们是表示分类变量不同值或级别的确切数据类型。

以下示例显示了字符串`red`被编码为因素(注意它携带了所有可能值的列表),以及尝试将`apple`编码到同一组因素中的失败尝试(返回`NA`,R 的特殊非值符号)。

列表 A.7\. R 处理意外因素级别的处理方式

factor('red', levels = c('red', 'orange'))

[1] red

Levels: red orange

factor('apple', levels = c('red', 'orange'))

[1]

Levels: red orange


因素在统计学中很有用,你会在数据科学流程的某个阶段将大多数字符串值转换为因素。通常,你越晚这样做越好(因为你随着工作会更多地了解数据的变异)——所以我们建议在读取数据或创建新的`data.frame`时使用可选参数`"StringsAsFactors = FALSE"`。

确保因子级别一致性

在本书中,我们经常分别准备训练数据和测试数据(模拟新数据通常会在原始训练数据之后准备)。对于因子,这引入了两个基本问题:训练期间因子级别的编号一致性,以及在应用期间应用和发现新的因子级别值。对于第一个问题,确保因子编号一致性是 R 代码的责任。以下列表演示了 `lm()` 正确处理因子作为字符串,并且在应用期间发现不同的一组因子时仍然保持一致性(这可能需要你为非核心库进行双重检查)。对于第二个问题,在应用期间发现新的因子是一个建模问题。数据科学家要么需要确保这种情况不会发生,要么开发应对策略(例如,回退到不使用相关变量的模型)。

列表 A.8\. 确认 `lm()` 正确编码新字符串

d <- data.frame(x=factor(c('a','b','c')),
y=c(1,2,3))
m <- lm(y~0+x,data=d) ❶
print(predict(m, ❷
newdata=data.frame(x='b'))[[1]])

[1] 2

print(predict(m,
newdata=data.frame(x=factor('b',levels=c('b'))))[[1]]) ❸

[1] 2


❶ 构建一个数据框和线性模型,将 a、b、c 映射到 1、2、3

❷ 展示了模型将 b 作为字符串正确预测

❸ 展示了模型将 b 作为因子,使用不同数量的级别进行编码时的正确预测。这表明 lm() 正确地将因子视为字符串。

插槽

除了列表之外,R 还可以通过对象槽以名称存储值。对象槽使用 `@` 运算符进行寻址(参见 `help('@')`)。要列出对象上的所有槽,请尝试 `slotNames()`。插槽和对象(特别是 S3 和 S4 对象系统)是本书中不涉及的高级主题。你需要知道 R 有对象系统,因为一些包会返回它们给你,但你不应在 R 生涯早期就创建自己的对象。

## A.3\. 使用 R 与数据库

有时候你可能想使用 R 来处理数据库中的数据。通常这是因为数据已经存在于数据库中,或者你希望使用高性能数据库(例如 Postgres 或 Apache Spark)来快速操作数据。

如果你的数据足够小,可以放入内存(或者你可以启动足够大的计算机来做到这一点,比如在 Amazon EC2、Microsoft Azure 或 Google Cloud 上),我们建议使用 `DBI::dbReadTable()` 将数据带到 R 中,然后使用 `data.table`。除了数据传输时间外,这将是很难被击败的。然而,请注意,将大型结果写回数据库并不被所有 R 数据库驱动程序完全支持(sparklyr,特别是明确不支持这一点)。

如果你想要在数据库中处理数据(我们通常为客户这样做),我们建议使用查询生成器,如 `rquery` 或 `dbplyr`。我们还认为,从 Codd 关系运算符的角度思考(或从 SQL 数据库的角度思考)的想法非常有益,因此尝试使用上述系统之一可能会非常值得。

### A.3.1\. 使用查询生成器运行数据库查询

* * *

示例

*客户出价排名*

* * *

我们得到了一个以客户名称和产品名称为键的数据表。对于这些键对,我们有一个建议的价格折扣分数和一个预测的折扣出价亲和度(都是由一些机器学习模型生成的,这类模型是我们在这本书中讨论过的)。我们的任务是取这个表,并选择每个客户预测亲和度最高的两个出价。业务目标是这样的:我们只想向客户展示这两个出价,而不展示其他任何出价。

为了模拟这个任务,我们将从 R 中取一些任意数据并将其复制到 Postgres 数据库中。要运行这个示例,你需要自己的 Postgres 数据库,并复制你的连接细节,包括主机、端口、用户名和密码。这个练习的目的是让你体验从 R 中操作数据库以及以 Codd 关系术语进行思考(这是许多数据处理系统的基础,包括 `dplyr`)。^([9])

> ⁹
> 
> 完整的示例和解决方案可以在以下链接找到:[`github.com/WinVector/PDSwR2/blob/master/BestOffers/BestOffers.md`](https://github.com/WinVector/PDSwR2/blob/master/BestOffers/BestOffers.md)。

首先,我们设置数据库连接并从一些数据中复制到这个新数据库中:

library("rquery")

raw_connection <- DBI::dbConnect(RPostgres::Postgres(),
host = 'localhost',
port = 5432,
user = 'johnmount',
password = '') ❶

dbopts <- rq_connection_tests(raw_connection) ❷
db <- rquery_db_info(
connection = raw_connection,
is_dbi = TRUE,
connection_options = dbopts)

data_handle <- rq_copy_to( ❸
db,
'offers',
wrapr::build_frame(
"user_name" , "product" , "discount", "predicted_of
fer_affinity" |
"John" , "Pandemic Board Game" , 0.1 , 0.8596
|
"Nina" , "Pandemic Board Game" , 0.2 , 0.1336
|
"John" , "Dell XPS Laptop" , 0.1 , 0.2402
|
"Nina" , "Dell XPS Laptop" , 0.05 , 0.3179
|
"John" , "Capek's Tales from Two Pockets", 0.05 , 0.2439
|
"Nina" , "Capek's Tales from Two Pockets", 0.05 , 0.06909
|
"John" , "Pelikan M200 Fountain Pen" , 0.2 , 0.6706
|
"Nina" , "Pelikan M200 Fountain Pen" , 0.1 , 0.616
),

temporary = TRUE,
overwrite = TRUE)


❶ 使用 DBI 连接到数据库。在这种情况下,它创建了一个新的内存中的 SQLite。

❷ 为连接构建一个 rquery 包装器

❸ 将一些示例数据复制到数据库中

现在,我们将通过关系性思维来解决这个问题。我们按步骤进行,并且随着经验的积累,我们会发现要解决这个问题,我们想要为每个用户分配每个出价的排名,然后筛选出我们想要的排名。

我们将使用 `rquery` 包来演示这个例子。在 `rquery` 中,可以通过 `extend()` 方法使用窗口函数.^([10]) `extend()` 可以根据数据的分区(通过 `user_name`)和这些分区内的列排序(通过 `predicted_offer_affinity`)来计算一个新列。通过实际操作来演示这一点最为直观。

> ¹⁰
> 
> 选择“extend”这个奇特的名字是为了尊重这些想法的来源:Codd 的关系代数。

data_handle %.>% extend(., ❶
simple_rank = rank(), ❷
partitionby = "user_name", ❸
orderby = "predicted_offer_affinity", ❹
reverse = "predicted_offer_affinity") %.>%
execute(db, .) %.>% ❺
knitr::kable(.) ❻

|user_name |product | discount| predicted_offer_affi

 nity| simple_rank|

|:---------|:------------------------------|--------😐---------------------

 ---:|-----------:|

|Nina |Pelikan M200 Fountain Pen | 0.10| 0.6

 1600|           1|

|Nina |Dell XPS Laptop | 0.05| 0.3

 1790|           2|

|Nina |Pandemic Board Game | 0.20| 0.1

 3360|           3|

|Nina |Capek's Tales from Two Pockets | 0.05| 0.0

 6909|           4|

|John |Pandemic Board Game | 0.10| 0.8

 5960|           1|

|John |Pelikan M200 Fountain Pen | 0.20| 0.6

 7060|           2|

|John |Capek's Tales from Two Pockets | 0.05| 0.2

 4390|           3|

|John |Dell XPS Laptop | 0.10| 0.2

 4020|           4|

❶ 将我们的数据通过 execute() 方法传递。注意我们使用了 wrapr 点管道。

❷ 我们将计算 rank() 或数据行的顺序。

❸ 将为每个用户(我们的窗口分区)重新计算排名。

❹ 控制排名的窗口排序将从预测出价亲和度开始,并反向排序(最大的排在前面)。

❺ 将操作计划转换为 SQL,发送到数据库执行,并将结果带回 R

❻ 美化打印结果

问题是这样的:我们是如何知道使用`extend`方法和设置哪些选项的?这需要一些关系型系统的经验。只有少数几个主要操作(添加派生列、选择列、选择行和连接表)以及少数几个选项(例如添加窗口列时的分区和排序)。因此,这种技术是可以学习的。理论的力量在于,几乎任何常见的数据转换都可以用这些基本数据操作符来表示。

现在,为了解决我们的完整问题,我们将这个操作符与几个更多的关系型操作符(再次使用`wrapr`点管道)结合起来。这次结果将被写入远程表(因此数据永远不会移动到或从 R!),然后在计算完成后仅复制结果。

ops <- data_handle %.>% ❶
extend(., ❷
simple_rank = rank(),
partitionby = "user_name",
orderby = "predicted_offer_affinity",
reverse = "predicted_offer_affinity") %.>%
select_rows(., ❸
simple_rank <= 2) %.>%
orderby(., c("user_name", "simple_rank") ❹

result_table <- materialize(db, ops) ❺

DBI::dbReadTable(db\(connection, result_table\)table_name) %.>% ❻
knitr::kable(.)

|user_name |product | discount| predicted_offer_affinity|

  simple_rank|

|:---------|:-------------------------|--------😐------------------------

 :|-----------:|

|John |Pandemic Board Game | 0.10| 0.8596|

            1|

|John |Pelikan M200 Fountain Pen | 0.20| 0.6706|

            2|

|Nina |Pelikan M200 Fountain Pen | 0.10| 0.6160|

            1|

|Nina |Dell XPS Laptop | 0.05| 0.3179|

            2|

❶ 定义我们的操作序列

❷ 为每行标记其简单的按用户排名

❸ 为每个用户选择排名最高的两行

❹ 按用户和产品排名对行进行排序

❺ 在数据库中运行结果,实例化一个新的结果表

❻ 将结果复制回 R 并格式化打印

我们将操作计划保存在变量`ops`中的原因是因为我们可以做很多不仅仅是执行计划的事情。例如,我们可以创建计划操作的图表,如图 A.5 所示。

图 A.5\. `rquery`操作计划图

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app01fig05.jpg)

此外——这是一个重要观点——我们可以看到实际发送到数据库的 SQL。如果没有查询规划器(如`rquery`或`dbplyr`),我们就必须编写如下 SQL:

ops %.>%
to_sql(., db) %.>%
cat(.)

SELECT * FROM (

SELECT * FROM (

SELECT

"user_name",

"product",

"discount",

"predicted_offer_affinity",

rank ( ) OVER ( PARTITION BY "user_name" ORDER BY "predicted_offer_aff

 inity" DESC ) AS "simple_rank"

FROM (

SELECT

"user_name",

"product",

"discount",

"predicted_offer_affinity"

FROM

"offers"

) tsql_17135820721167795865_0000000000

) tsql_17135820721167795865_0000000001

WHERE "simple_rank" <= 2

) tsql_17135820721167795865_0000000002 ORDER BY "user_name", "simple_rank"


问题在于,关系型思考是富有成效的,但 SQL 本身相当冗长。特别是,SQL 将顺序或组合表示为嵌套,这意味着我们是从内向外读取的。当我们转向操作符表示法(如`dplyr`或`rquery`中看到的那样)时,Codd 的思想中的许多优雅之处得到了恢复。

在这里可以找到对这个例子的更详细处理(包含更多参考):[`github.com/WinVector/PDSwR2/blob/master/BestOffers/BestOffers.md`](https://github.com/WinVector/PDSwR2/blob/master/BestOffers/BestOffers.md)。

关系型数据处理从操作符的角度思考,我们在这里简要提到了这一点,以及数据组织,这是我们下一节的主题。

### A.3.2\. 如何进行数据关系性思考

关于数据关系性思考的技巧是这样的:对于每一个表格,将列分类为几个重要的主题,并处理这些主题之间的自然关系。关于主要列主题的一个观点可以在表 A.1 中找到。

表 A.1\. 主要 SQL 列主题

| 列主题 | 描述 | 常见用途和处理 |
| --- | --- | --- |
| 自然键列 | 在许多表中,一个或多个列组合在一起形成一个自然键,该键唯一地标识行。某些数据(如运行日志)没有自然键(许多行可能对应于给定的时间戳)。 | 自然键用于排序数据、控制连接和指定聚合。 |
| 代理键列 | 代理键列是没有与问题自然相关联的关键列(唯一标识行的列集合)。代理键的例子包括行号和散列。在某些情况下(如分析时间序列),行号可以是自然键,但通常它是代理键。 | 代理键列可以用来简化连接;它们通常对排序和聚合没有用处。代理键列不得用作建模特征,因为它们不代表有用的测量。 |
| 来源列 | 来源列包含关于行的事实,例如何时加载。在第 2.3.1 节中添加的 ORIGIN-SERTTIME、ORIGFILENAME 和 ORIGFILEROWNUMBER 列是来源列的例子。 | 来源列不应用于分析,除非用于确认你正在处理正确的数据集、选择数据集(如果同一表中混合了不同的数据集)和比较数据集。 |
| 负载列 | 负载列包含实际数据。负载列可能包含价格和计数等数据。 | 负载列用于聚合、分组和条件。它们有时也可以用来指定连接。 |
| 实验设计列 | 实验设计列包括样本分组,如第 2.3.1 节中提到的 ORIGRANDGROUP,或数据权重,如我们在第 7.1.1 节中提到的 PWGTP*和 WGTP*列。 | 实验设计列可以用来控制分析(选择数据子集,在建模操作中使用作为权重),但它们永远不应该用作分析的特征。 |
| 派生列 | 派生列是其他列或列组的函数列。一个例子是星期几(从星期一到星期日),它是日期的函数。派生列可以是键的函数(这意味着在许多 GROUP BY 查询中它们是不变的,尽管 SQL 会坚持指定一个聚合器,如 MAX())或负载列的函数。 | 派生列在分析中很有用。一个*完全范式*数据库没有这样的列。在范式设计中,目的是不存储可以派生的任何内容,这消除了某些类型的不一致性(例如,日期为 2014 年 2 月 1 日,星期为星期三的行,而正确的星期应该是星期六)。但在分析过程中,始终将中间计算存储在表和列中是个好主意:它简化了代码,并使调试变得容易得多。 |

重点是,如果你为每个提供的数据源都有一个良好的列主题分类法,分析就会容易得多。然后,你设计 SQL 命令序列来将你的数据转换成一个新的表格,其中列正好适合分析。最终,你应该拥有这样的表格:每一行都是你感兴趣的事件,每个需要的细节都已经在一个列中(这个列长期以来被称为*模型矩阵*,或者从关系数据库的角度来说,是一个*非规范化表*)。

进一步的数据库阅读

我们首选的数据库参考书籍是 Joe Celko 的《SQL for Smarties》,第四版(Morgan Kauffman,2011 年)。

## A.4. 吸取的经验

在我们看来,R 生态系统是通往实质性数据科学、统计和机器学习成就的最快途径。其他系统可能拥有更先进的机器学习功能(例如 Python 的深度学习连接),但这些现在也通过一个名为*reticulate*的适配器对 R 用户开放了。^([11]) 没有数据科学家会期望永远只使用一种语言或一个系统工作;但我们认为 R 是许多人开始的好地方。

> ¹¹
> 
> 例如,请参阅 François Chollet 和 J. J. Allaire 合著的《Deep Learning with R》(Manning,2018 年)。


# 附录 B. 重要统计概念

统计学是一个如此广泛的话题,我们只能将其一部分纳入我们的数据科学叙事中。但它是一个重要的领域,它有很多关于当你尝试从数据中推断时会发生什么的内容。在这本书中,我们假设你已经了解一些统计概念(特别是均值、众数、中位数、方差和标准差等汇总统计量)。在本附录中,我们将演示一些与模型拟合、不确定性描述和实验设计相关的重要统计概念。

统计学是数学,所以这个附录有点数学性。它还旨在教你正确的**统计术语**,这样你就可以与其他数据科学家分享你的工作。本附录涵盖了你在“数据科学行话”中会听到的技术术语。你已经做了数据科学工作;现在,我们将讨论讨论和批评工作的工具。

**统计量**是数据的一种总结或度量。一个例子是房间里的人数。"统计学"是研究观察到的样本汇总如何与(未观察到的)我们希望建模的整个群体的真实汇总相关联的学科。统计学帮助我们描述和减轻估计的方差(或变异)、不确定性(我们不知道的范围或估计的范围)和偏差(我们的程序不幸引入的系统误差)。

例如,如果我们使用的是我们公司所有过去营销的数据库,这最多仍然是对所有可能销售(包括我们希望用我们的模型预测的未来营销和销售)的样本。如果我们不考虑抽样中的不确定性(以及来自许多其他原因的不确定性),我们将得出错误的推断和结论。^([[1)]

> ¹
> 
> 我们喜欢将机器学习称为对数据的乐观看法,将统计学称为悲观看法。在我们看来,你需要理解这两个观点才能与数据工作。

## B.1\. 分布

分布是对数据集中可能值的可能性的描述。例如,它可能是美国 18 岁成年男性可能的身高集合。对于一个简单的数值,分布的定义如下:对于值`b`,分布是看到值`x`的概率,其中`x <= b`。这被称为**累积分布函数**(CDF)。

我们通常可以通过命名一个分布和一些汇总统计量来总结一组可能的结果。例如,我们可以这样说,如果我们公平地掷一枚硬币 10 次,我们观察到的正面数量应该是二项分布(在 B.5.7 节中定义)的,期望平均值为 5 个正面。在所有情况下,我们都关心值的生成方式,以及获取比仅仅描述均值和标准差更详细的信息,比如获取分布的名称和形状。

在本节中,我们将概述几个重要的分布:正态分布、对数正态分布和二项分布。随着你进一步学习,你还将想要学习许多其他关键分布(如泊松、贝塔、负二项分布等),但这里提出的想法应该足以让你开始。

### B.1.1\. 正态分布

正态分布或高斯分布是经典的对称钟形曲线,如图 B.1 所示。#app02fig01。许多测量量,如一组学生的考试成绩,或特定人群的年龄或身高,通常可以用正态分布来近似。重复测量往往会落入正态分布。例如,如果一位医生使用经过正确校准的秤多次称量一位患者的体重,那么测量值(如果取足够多)将围绕患者的真实体重落入正态分布。这种变化将归因于测量误差(秤的变异性)。正态分布定义在所有实数上。

图 B.1\. 均值为 0,标准差为 1 的正态分布

![图片](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig01_alt.jpg)

此外,**中心极限定理**指出,当你观察许多独立、有界方差的随机变量的总和(或均值)时,随着你收集更多的数据,你的观察值的分布将趋近于正态分布。例如,假设你想要测量每天上午 9 点到 10 点之间有多少人访问你的网站。用于建模访问人数的适当分布是**泊松分布**;但如果你有足够的流量,并且观察足够长的时间,观察到的访问者分布将趋近于正态分布,你可以通过将访问者数量视为正态分布来对流量做出可接受的估计。

许多现实世界的分布大约是“正态”的——特别是,任何“接近”的概念倾向于累加的测量。一个例子就是成年人的身高:6 英寸的身高差异对于身高 5'6" 的人和身高 6' 的人都是很大的。

正态分布由两个参数描述:均值 `m` 和标准差 `s`(或者,也可以说是方差,它是 `s` 的平方)。均值代表分布的中心(也是其峰值);标准差代表分布的“自然长度单位”——你可以通过观察值与均值的多少个标准差来估计观察值的稀有程度。正如我们在第四章中提到的,对于一个正态分布的变量

+   大约 68% 的观察值将落在区间 `(m-s,m+s)` 内。

+   大约 95% 的观察值将落在区间 `(m-2*s,m+2*s)` 内。

+   大约 99.7% 的观察值将落在区间 `(m-3*s,m+3*s)` 内。

因此,一个观察值如果比均值大三个标准差,在大多数应用中可以被认为是相当罕见的。

许多机器学习算法和统计方法(例如,线性回归)假设未建模的误差是正态分布的。线性回归对违反这一假设的情况相当稳健;然而,对于连续变量,你至少应该检查变量的分布是否单峰且在一定程度上对称。当这种情况不成立时,你可能希望考虑使用变量转换,例如我们在 第四章 中讨论的对数转换。

在 R 中使用正态分布

在 R 中,函数 `dnorm(x, mean = m, sd = s)` 是 *正态概率密度函数*:当它从均值为 `m` 和标准差 `s` 的正态分布中抽取时,将返回观察到 `x` 的概率。默认情况下,`dnorm` 假设 `mean=0` 和 `sd = 1`(这里讨论的所有与正态分布相关的函数都如此)。让我们使用 `dnorm()` 来绘制 图 B.1。

列表 B.1\. 绘制理论正态密度

library(ggplot2)

x <- seq(from=-5, to=5, length.out=100) # the interval [-5 5]
f <- dnorm(x) # normal with mean 0 and sd 1
ggplot(data.frame(x=x,y=f), aes(x=x,y=y)) + geom_line()


函数 `rnorm(n, mean = m, sd = s)` 将生成 `n` 个从均值为 `m` 和标准差 `s` 的正态分布中抽取的点。

列表 B.2\. 绘制经验正态密度

library(ggplot2)

draw 1000 points from a normal with mean 0, sd 1

u <- rnorm(1000)

plot the distribution of points,

compared to normal curve as computed by dnorm() (dashed line)

ggplot(data.frame(x=u), aes(x=x)) + geom_density() +
geom_line(data=data.frame(x=x,y=f), aes(x=x,y=y), linetype=2)


如你在 图 B.2 中所见,由 `rnorm(1000)` 生成的点的经验分布与理论正态分布非常接近。从有限数据集中观察到的分布永远不能与理论上的连续分布(如正态分布)完全匹配;并且,与所有统计事物一样,对于给定样本大小,你期望偏离有多远有一个明确的分布。

图 B.2\. 从均值为 0 和标准差为 1 的正态分布中抽取的点的经验分布。虚线表示理论正态分布。

![图片](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig02_alt.jpg)

函数 `pnorm(x, mean = m, sd = s)` 是 R 所称的 *正态概率函数*,也称为 *正态累积分布函数*:它返回从均值为 `m` 和标准差 `s` 的正态分布中观察到小于 `x` 的数据点的概率。换句话说,这是分布曲线下落在 `x` 左侧的区域(回想一下,分布曲线下的面积是单位面积)。这如图 B.3 列表 所示。

列表 B.3\. 处理正态 CDF

--- estimate probabilities (areas) under the curve ---

50% of the observations will be less than the mean

pnorm(0)

[1] 0.5

about 2.3% of all observations are more than 2 standard

deviations below the mean

pnorm(-2)

[1] 0.02275013

about 95.4% of all observations are within 2 standard deviations

from the mean

pnorm(2) - pnorm(-2)

[1] 0.9544997


函数 `qnorm(p, mean = m, sd = s)` 是具有均值 `m` 和标准差 `s` 的正态分布的 *分位数函数*。它是 `pnorm()` 的逆函数,其中 `qnorm(p, mean = m, sd = s)` 返回值 `x`,使得 `pnorm(x, mean = m, sd = s) == p`。

图 B.3 展示了 `qnorm()` 的使用:垂直线在 `x = qnorm(0.75)` 处截断 x 轴;垂直线左侧的阴影区域表示面积为 0.75,即正态曲线下方的 75% 的面积。

图 B.3\. 说明 `x < qnorm(0.75)`

![图片](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig03_alt.jpg)

创建图 B.3(以及使用`qnorm()`的几个其他示例)的代码如下所示。

列表 B.4\. 绘制`x < qnorm(0.75)`

--- return the quantiles corresponding to specific probabilities ---

the median (50th percentile) of a normal is also the mean

qnorm(0.5)

[1] 0

calculate the 75th percentile

qnorm(0.75)

[1] 0.6744898

pnorm(0.6744898)

[1] 0.75

--- Illustrate the 75th percentile ---

create a graph of the normal distribution with mean 0, sd 1

x <- seq(from=-5, to=5, length.out=100)
f <- dnorm(x)
nframe <- data.frame(x=x,y=f)

calculate the 75th percentile

line <- qnorm(0.75)
xstr <- sprintf("qnorm(0.75) = %1.3f", line)

the part of the normal distribution to the left

of the 75th percentile

nframe75 <- subset(nframe, nframe$x < line)

Plot it.

The shaded area is 75% of the area under the normal curve

ggplot(nframe, aes(x=x,y=y)) + geom_line() +
geom_area(data=nframe75, aes(x=x,y=y), fill="gray") +
geom_vline(aes(xintercept=line), linetype=2) +
geom_text(x=line, y=0, label=xstr, vjust=1)


### B.1.2\. 总结 R 的分布命名约定

现在我们已经展示了一些具体的例子,我们可以总结一下 R 是如何命名与给定概率分布相关的不同函数的。假设概率分布被命名为`DIST`。那么以下都是正确的:

+   `dDIST(x, ...)`是*分布函数*(或*PDF*,见下一部分)返回观察值`x`的概率。

+   `pDIST(x, ...)`是累积分布函数,返回观察值小于`x`的概率。标志`lower.tail = FALSE`将导致`pDIST(x, ...)`返回观察值大于`x`的概率(右尾下的面积,而不是左尾)。

+   `rDIST(n, ...)`是随机数生成器,返回从分布`DIST`中抽取的`n`个值。

+   `qDIST(p, ...)`是分位数函数,返回对应于`DIST`的第`p`百分位的`x`。标志`lower.tail = FALSE`将导致`qDIST(p, ...)`返回对应于`1 - p`百分位的`x`。

* * *

R 的令人困惑的命名约定

由于某种原因,R 将累积分布函数(或 CDF)简称为*分布函数*。当使用 R 工作时,请注意检查您是想使用概率密度函数还是 CDF。

* * *

### B.1.3\. 对数正态分布

*对数正态分布*是随机变量`X`的自然对数`log(X)`服从正态分布的分布。高度偏斜的正态数据,如盈利客户的值、收入、销售额或股价,通常可以建模为对数正态分布。对数正态分布定义在所有非负实数上;如图 B.4(顶部)所示,它是不对称的,尾部向正无穷延伸。`log(X)`的分布(如图 B.4(底部)所示)是一个以`mean(log(X))`为中心的正态分布。对于对数正态总体,均值通常远高于中位数,向均值贡献的大部分来自少量最高值的数据点。

图 B.4。顶部:对数正态分布`X`,其`mean(log(X)) = 0`和`sd(log(X)) = 1`。虚线是理论分布,实线是随机对数正态样本的分布。底部:实线是`log(X)`的分布。

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig04_alt.jpg)

* * *

不要将对数正态总体的均值用作“典型”值

对于一个近似正态分布的总体,你可以使用总体的平均值作为典型成员的大致替代值。如果你将平均值用作对数正态总体的替代值,你会高估大多数数据点的值。

* * *

直观地说,如果数据的变化自然地表示为百分比或相对差异,而不是绝对差异,那么数据就适合用对数正态分布来建模。例如,你杂货店中典型的土豆袋可能重约五磅,上下浮动半磅。从特定类型的枪支发射特定类型的子弹时,子弹飞行的距离可能约为 2,100 米,上下浮动 100 米。这些观察结果的变化自然地用绝对单位表示,并且可以建模为正态分布。另一方面,货币数量的差异通常最好用百分比表示:一个工人群体的所有工人可能都会获得 5% 的加薪(而不是每人每年增加 5,000 美元);你可能希望将下季度的收入预测在 10% 以内(而不是在加减 1,000 美元以内)。因此,这些数量通常最好建模为具有对数正态分布。

在 R 中使用对数正态分布

让我们看看 R 中处理对数正态分布的函数(也参见 B.5.3 节)。我们将从 `dlnorm()` 和 `rlnorm()` 开始:

+   `dlnorm(x, meanlog = m, sdlog = s)` 是返回从对数正态分布 `X` 中抽取的值 `x` 的概率密度的 *概率密度函数* (PDF),其中 `mean(log(X)) = m` 和 `sd(log(X)) = s`。默认情况下,本节中讨论的所有函数的 `meanlog = 0` 和 `sdlog = 1`。

+   `rlnorm(n, meanlog = m, sdlog = s)` 是返回从具有 `mean(log(X)) = m` 和 `sd(log(X)) = s` 的对数正态分布中抽取的 `n` 个随机数的随机数。

我们可以使用 `dlnorm()` 和 `rlnorm()` 来生成前面显示的 图 8.4,以下列表演示了对数正态分布的一些特性。

列表 B.5\. 展示对数正态分布的一些特性

draw 1001 samples from a lognormal with meanlog 0, sdlog 1

u <- rlnorm(1001)

the mean of u is higher than the median

mean(u)

[1] 1.638628

median(u)

[1] 1.001051

the mean of log(u) is approx meanlog=0

mean(log(u))

[1] -0.002942916

the sd of log(u) is approx sdlog=1

sd(log(u))

[1] 0.9820357

generate the lognormal with meanlog = 0, sdlog = 1

x <- seq(from = 0, to = 25, length.out = 500)
f <- dlnorm(x)

generate a normal with mean = 0, sd = 1

x2 <- seq(from = -5, to = 5, length.out = 500)
f2 <- dnorm(x2)

make data frames

lnormframe <- data.frame(x = x, y = f)
normframe <- data.frame(x = x2, y = f2)
dframe <- data.frame(u=u)

plot densityplots with theoretical curves superimposed

p1 <- ggplot(dframe, aes(x = u)) + geom_density() +
geom_line(data = lnormframe, aes(x = x, y = y), linetype = 2)

p2 <- ggplot(dframe, aes(x = log(u))) + geom_density() +
geom_line(data = normframe, aes(x = x,y = y), linetype = 2)

functions to plot multiple plots on one page

library(grid)
nplot <- function(plist) {
n <- length(plist)
grid.newpage()
pushViewport(viewport(layout=grid.layout(n, 1)))
vplayout<-
function(x,y) { viewport(layout.pos.row = x, layout.pos.col = y) }
for(i in 1:n) {
print(plist[[i]], vp = vplayout(i, 1))
}
}

this is the plot that leads this section.

nplot(list(p1, p2))


剩下的两个函数是累积分布函数 `plnorm()` 和分位数函数 `qlnorm()`:

+   `plnorm(x, meanlog = m, sdlog = s)` 是累积分布函数,它返回从具有 `mean(log(X)) = m` 和 `sd(log(X)) = s` 的对数正态分布中观察到的值小于 `x` 的概率。

+   `qlnorm(p, meanlog = m, sdlog = s)` 是分位数函数,它返回对应于对数正态分布中 `p` 百分位数的 `x` 值,其中 `mean(log(X)) = m` 和 `sd(log(X)) = s`。它是 `plnorm()` 的逆函数。

以下列表演示了 `plnorm()` 和 `qlnorm()`。它使用前一个列表中的数据框 `lnormframe`。

列表 B.6\. 绘制对数正态分布图

the 50th percentile (or median) of the lognormal with

meanlog=0 and sdlog=10

qlnorm(0.5)

[1] 1

the probability of seeing a value x less than 1

plnorm(1)

[1] 0.5

the probability of observing a value x less than 10:

plnorm(10)

[1] 0.9893489

-- show the 75th percentile of the lognormal

use lnormframe from previous example: the

theoretical lognormal curve

line <- qlnorm(0.75)
xstr <- sprintf("qlnorm(0.75) = %1.3f", line)

lnormframe75 <- subset(lnormframe, lnormframe$x < line)

Plot it

The shaded area is 75% of the area under the lognormal curve

ggplot(lnormframe, aes(x = x, y = y)) + geom_line() +
geom_area(data=lnormframe75, aes(x = x, y = y), fill = "gray") +
geom_vline(aes(xintercept = line), linetype = 2) +
geom_text(x = line, y = 0, label = xstr, hjust = 0, vjust = 1)


如你在图 B.5 中所见,大多数数据集中在分布的左侧,剩余的四分之一数据分布在非常长的尾部。

图 B.5. 均值为 `meanlog = 1`,标准差为 `sdlog = 0` 的对数正态分布的 75 分位数

![图 B.5 替代文本](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig05_alt.jpg)

### B.1.4. 二项分布

假设你有一枚硬币,当你翻转它时,它落在头部的概率为 `p`(因此对于公平的硬币,`p = 0.5`)。在这种情况下,二项分布用于模拟翻转该硬币 `N` 次时观察到 `k` 个头部的概率。它用于建模二元分类问题(如我们在第八章中讨论的与逻辑回归相关的内容),其中正例可以被认为是“头部”。

图 B.6 展示了不同公平性的硬币在翻转 50 次时的二项分布形状。请注意,二项分布是*离散的*;它只定义了 `k` 的(非负)整数值。

图 B.6. 50 次抛掷硬币的二项分布,硬币的公平性各异(落在头部的概率)

![图 B.6 替代文本](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig06_alt.jpg)

在 R 中使用二项分布

让我们看看在 R 中处理二项分布的函数(参见 B.5.3 节)。我们将从概率密度函数 `dbinom()` 和随机数生成器 `rbinom()` 开始:

+   `dbinom(k, nflips, p)` 是 PDF,它返回从具有头部概率 `p` 的硬币中抛掷 `nflips` 次时观察到恰好 `k` 个头部的概率。

+   `rbinom(N, nflips, p)` 是随机数生成器,它返回从具有头部概率 `p` 的硬币中抽取的 `N` 个值,对应于 `nflips` 次抛掷。

你可以使用 `dbinom()`(如下面的列表所示)来生成图 B.6。

列表 B.7. 绘制二项分布

library(ggplot2)

use dbinom to produce the theoretical curves

numflips <- 50

x is the number of heads that we see

x <- 0:numflips

probability of heads for several different coins

p <- c(0.05, 0.15, 0.5, 0.75)
plabels <- paste("p =", p)

calculate the probability of seeing x heads in numflips flips

for all the coins. This probably isn't the most elegant

way to do this, but at least it's easy to read

flips <- NULL
for(i in 1:length(p)) {
coin <- p[i]
label <- plabels[i]
tmp <- data.frame(number_of_heads=x,
probability = dbinom(x, numflips, coin),
coin_type = label)
flips <- rbind(flips, tmp)
}

plot it

this is the plot that leads this section

ggplot(flips, aes(x = number_of_heads, y = probability)) +
geom_point(aes(color = coin_type, shape = coin_type)) +
geom_line(aes(color = coin_type))


你可以使用 `rbinom()` 来模拟抛硬币风格的实验。例如,假设你有一个 50% 女性的大型学生群体。如果学生随机分配到教室,并且你访问了有 20 名学生的 100 个教室,那么你预计每个教室中会有多少女孩?一个合理的结果显示在图 B.7 中,理论分布叠加在上面。

图 B.7. 当人口为 50% 女性时,在 100 个大小为 20 的教室中观察到的女孩数量分布。理论分布用虚线表示。

![图 B.7 替代文本](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig07_alt.jpg)

让我们编写代码来生成图 B.7。

列表 B.8. 处理理论二项分布

p = 0.5 # the percentage of females in this student population
class_size <- 20 # size of a classroom
numclasses <- 100 # how many classrooms we observe

what might a typical outcome look like?

numFemales <- rbinom(numclasses, class_size, p) ❶

the theoretical counts (not necessarily integral)

probs <- dbinom(0:class_size, class_size, p)
tcount <- numclasses*probs

the obvious way to plot this is with histogram or geom_bar

but this might just look better

zero <- function(x) {0} # a dummy function that returns only 0

ggplot(data.frame(number_of_girls = numFemales, dummy = 1),
aes(x = number_of_girls, y = dummy)) +

count the number of times you see x heads

stat_summary(fun.y = "sum", geom = "point", size=2) + ❷
stat_summary(fun.ymax = "sum", fun.ymin = "zero", geom = "linerange") +

superimpose the theoretical number of times you see x heads

geom_line(data = data.frame(x = 0:class_size, y = tcount),
aes(x = x, y = y), linetype = 2) +
scale_x_continuous(breaks = 0:class_size, labels = 0:class_size) +
scale_y_continuous("number of classrooms")


❶ 因为我们没有调用 `set.seed`,所以我们预计每次运行此行时都会得到不同的结果。

❷ `stat_summary` 是在绘图过程中控制数据聚合的一种方法。在这种情况下,我们使用它将来自经验数据的点状和条形图放置在理论密度曲线中。

正如您所看到的,即使只有 4 个或多达 16 个女孩的教室也不是完全闻所未闻,当来自这个群体中的学生随机分配到教室时。但是,如果您观察到太多的此类教室——或者观察到少于 4 个或超过 16 个女孩的教室——您会想调查这些班级的学生选择是否以某种方式存在偏见。

您还可以使用 `rbinom()` 来模拟抛掷单个硬币。

列表 B.9\. 模拟二项分布

use rbinom to simulate flipping a coin of probability p N times

p75 <- 0.75 # a very unfair coin (mostly heads)
N <- 1000 # flip it several times
flips_v1 <- rbinom(N, 1, p75)

Another way to generate unfair flips is to use runif:

the probability that a uniform random number from [0 1)

is less than p is exactly p. So "less than p" is "heads".

flips_v2 <- as.numeric(runif(N) < p75)

prettyprint_flips <- function(flips) {
outcome <- ifelse(flips==1, "heads", "tails")
table(outcome)
}

prettyprint_flips(flips_v1)

outcome

heads tails

756 244

prettyprint_flips(flips_v2)

outcome

heads tails

743 257


最后两个函数是累积分布函数 `pbinom()` 和分位数函数 `qbinom()`:

+   `pbinom(k, nflips, p)` 是返回从 `nflips` 次抛掷中观察到 `k` 个或更少正面的概率的累积分布函数(CDF)。`pbinom(k, nflips, p, lower.tail = FALSE)` 返回从 `nflips` 次抛掷中观察到超过 `k` 个正面的概率。请注意,左尾概率是在包含区间 `numheads <= k` 上计算的,而右尾概率是在排他区间 `numheads > k` 上计算的。

+   `qbinom(q, nflips, p)` 是分位数函数,它返回与 `nflips` 次抛掷中正面概率 `p` 对应的 `q` 百分位数所对应正面的数量 `k`。

下一个列表展示了使用 `pbinom()` 和 `qbinom()` 的几个示例。

列表 B.10\. 使用二项分布

pbinom example

nflips <- 100
nheads <- c(25, 45, 50, 60) # number of heads

what are the probabilities of observing at most that

number of heads on a fair coin?

left.tail <- pbinom(nheads, nflips, 0.5)
sprintf("%2.2f", left.tail)

[1] "0.00" "0.18" "0.54" "0.98"

the probabilities of observing more than that

number of heads on a fair coin?

right.tail <- pbinom(nheads, nflips, 0.5, lower.tail = FALSE)
sprintf("%2.2f", right.tail)

[1] "1.00" "0.82" "0.46" "0.02"

as expected:

left.tail+right.tail

[1] 1 1 1 1

so if you flip a fair coin 100 times,

you are guaranteed to see more than 10 heads,

almost guaranteed to see fewer than 60, and

probably more than 45.

qbinom example

nflips <- 100

what's the 95% "central" interval of heads that you

would expect to observe on 100 flips of a fair coin?

left.edge <- qbinom(0.025, nflips, 0.5)
right.edge <- qbinom(0.025, nflips, 0.5, lower.tail = FALSE)
c(left.edge, right.edge)

[1] 40 60

so with 95% probability you should see between 40 and 60 heads


需要注意的一点是,由于二项分布是离散的,`pbinom()` 和 `qbinom()` 不会像连续分布(如正态分布)那样是彼此的完美逆函数。

列表 B.11\. 使用二项累积分布函数

because this is a discrete probability distribution,

pbinom and qbinom are not exact inverses of each other

this direction works

pbinom(45, nflips, 0.5)

[1] 0.1841008

qbinom(0.1841008, nflips, 0.5)

[1] 45

this direction won't be exact

qbinom(0.75, nflips, 0.5)

[1] 53

pbinom(53, nflips, 0.5)

[1] 0.7579408


### B.1.5\. 用于分布的更多 R 工具

R 有许多用于处理分布的工具,而不仅仅是我们在演示中展示的 PDF、CDF 和生成工具。特别是,对于拟合分布,您可能想尝试来自 `MASS` 包的 `fitdistr` 方法。

## B.2\. 统计理论

在这本书中,我们必然要专注于(正确地)处理数据,而不会停下来解释很多理论。在回顾本节中的一些统计理论之后,我们将使用的步骤将更容易理解。

### B.2.1\. 统计哲学

我们在这本书中展示的预测工具和机器学习方法,其预测能力并非来自揭示因果关系(那将是一件好事),而是通过跟踪和尝试消除数据中的差异,以及通过减少不同来源的错误。在本节中,我们将概述一些关键概念,描述正在发生的事情以及为什么这些技术有效。

可交换性

由于基本的统计建模不足以可靠地将预测归因于真实原因,我们一直在默默地依赖一个称为 *可交换性* 的概念,以确保我们可以构建有用的预测模型。

交换性的正式定义是这样的:假设世界上所有的数据都是`x[i,],y[i]`(`i=1,...m`)。如果对于`1, ...m`的任何排列`j_1, ...j_m`,看到`x[i,],y[i]`的联合概率等于看到`x[j_i, ], y[j_i]`的联合概率,那么我们称这些数据为*可交换的*。换句话说,看到元组`x[i, ], y[i]`的联合概率不依赖于*何时*我们看到它,或者它在观察序列中的位置。

这个想法是,如果数据的所有排列都是等可能的,那么当我们仅使用索引(而不是窥探`x[i,],y[i]`)从数据中抽取子集时,每个子集中的数据,尽管不同,可以被认为是独立同分布的。我们在进行训练/测试划分(甚至训练/校准/测试划分)时依赖于此,我们希望(并且应该采取措施确保)我们的训练数据和未来在生产中遇到的数据之间也是如此。

我们构建模型的目标是在未知的未来,模型将应用到的数据可以与我们的训练数据交换。如果这种情况成立,那么我们预期在训练数据上的良好表现将转化为生产环境中模型的良好表现。防御数据交换性免受过度拟合和概念漂移等问题的影响是非常重要的。

一旦我们开始检查训练数据,我们就(不幸地)破坏了它与未来数据的交换性。包含大量训练数据的子集不再与不包含训练数据的子集(通过简单地记住所有训练数据)区分开来。我们试图通过在保留的测试数据上的性能来衡量这种损害的程度。这就是为什么泛化误差如此重要的原因。在模型构建过程中没有查看的数据应该与未来数据保持与之前相同的可交换性,因此测量保留数据的性能有助于预测未来的性能。这也是为什么你不使用测试数据进行校准(相反,你应该进一步划分你的训练数据来做这件事);一旦你查看你的测试数据,它就与未来在生产中看到的可交换性降低。

预测中交换性可能遭受的另一巨大损失总结为所谓的*Goodhart 定律*:“当一项度量成为目标时,它就不再是一个好的度量。”其要点是:仅仅与预测相关的因素是好的预测者——直到你过度优化它们,或者当其他人对你使用它们的反应时。例如,垃圾邮件发送者可能会通过使用与合法电子邮件高度相关的更多特征和短语,以及改变垃圾邮件过滤器认为与垃圾邮件高度相关的短语来尝试击败垃圾邮件检测系统。这是实际原因(当改变时确实对结果有影响)和仅仅相关性(可能与结果同时发生,并且只有通过示例的交换性才是好的预测者)之间的基本区别。

偏差方差分解

本书中的许多建模任务被称为*回归*,其中对于形式为`y[i],x[i,]`的数据,我们试图找到一个模型或函数`f()`,使得`f(x[i,])~E[y[j]|x[j,]~x[i,]]`(期望`E[]`是对所有示例取的,其中`x[j,]`被认为非常接近`x[i,]`)。通常这是通过选择`f()`来最小化`E[(y[i]-f(x[i,]))²]`来实现的。^([2]) 符合这种公式的显著方法包括回归、k-最近邻(KNN)和神经网络。

> ²
> 
> 最小化平方误差能够正确得到期望值是一个重要的事实,它在方法设计中反复被使用。

显然,最小化平方误差并不总是你的直接建模目标。但当你以平方误差为基准工作时,你可以将误差明确分解为有意义的组成部分,这被称为*偏差/方差分解*(参见 T. Hastie、R. Tibshirani 和 J. Friedman 所著的《统计学习的要素》;Springer,2009 年)。偏差/方差分解指出:

E[(y[i] - f(x[i, ]))²] = bias² + variance + irreducibleError


*模型偏差*是错误中你选择的建模技术永远无法正确处理的部分,通常是因为真实过程的某些方面无法在所选模型的假设中表示。例如,如果结果变量与输入变量之间的关系是曲线或非线性,你无法用只考虑线性关系的线性回归完全建模它。你通常可以通过转向更复杂的建模思想来减少偏差:核化、GAMs、添加交互作用等。许多建模方法可以自行增加模型复杂性(以尝试减少偏差),例如决策树、KNN、支持向量机和神经网络。但直到你拥有大量数据,增加模型复杂性有很大机会会增加模型方差。

*模型方差*是由于数据中的偶然关系而导致的错误部分。其想法是这样的:在新的数据上重新训练模型可能会产生不同的错误(这就是方差与偏差的区别)。一个例子是使用`k = 1`运行 KNN。当你这样做时,每个测试示例都会通过匹配到单个最近的训练示例来评分。如果那个示例碰巧是正的,你的分类将是正的。这就是我们倾向于使用较大的`k`值运行 KNN 的原因之一:它给我们提供了通过包括更多示例来获得更可靠的邻域性质估计的机会,尽管这会使邻域稍微不那么局部或具体。更多的数据和平均思想(如 bagging)可以大大减少模型方差。

*不可减少误差* 是问题的真正不可建模的部分(考虑到当前变量)。如果我们有两个数据 `x[i, ], y[i]` 和 `x[j,], y[j]`,使得 `x[i, ] == x[j, ]`,那么 `(y[i] - y[j])²` 会贡献到不可减少误差。我们强调,不可减少误差是相对于一组给定的变量来衡量的;添加更多变量,你就有了一个新的情况,可能具有其自己的更低不可减少误差。

重点是,你总是可以将建模误差视为来自三个来源:偏差、方差和不可减少误差。当你试图提高模型性能时,你可以根据你试图减少的这些因素来选择尝试的方法。

* * *

**平均化是一个强大的工具**

在相当温和的假设下,平均化可以降低方差。例如,对于具有相同分布的独立值的数据,大小为 `n` 的组平均值的期望方差为单个值方差的 `1/n`。这就是为什么即使预测单个事件很困难,你仍然可以构建准确预测人口或群体率的模型的原因之一。所以尽管预测旧金山的谋杀案数量可能很容易,但你无法预测谁会被杀害。除了缩小方差外,平均化还会使分布越来越像正态分布(这是中心极限定理,与大量定律相关)。

* * *

统计效率

无偏统计过程的 *效率* 定义为在给定数据集大小的情况下该过程有多少方差:也就是说,当在相同大小和来自相同分布的数据集上运行时,该过程产生的估计值会有多大的变化。更有效的程序需要更少的数据来达到给定的方差量。这与计算效率不同,计算效率是关于产生估计值需要多少工作量。

当你拥有大量数据时,统计效率变得不那么关键(这就是为什么我们在这本书中不强调它)。但是,当产生更多数据成本很高时(例如在药物试验中),统计效率是你的主要关注点。在这本书中,我们采取的方法是通常我们有很多数据,因此我们可以优先考虑相对统计效率较低的一般方法(例如使用测试保留集等),而不是更专业、统计效率更高的方法(例如 Wald 检验等特定的现成参数检验)。

记住:忽略统计效率是一种奢侈,而不是权利。如果你的项目有这样的需求,你将想要咨询专家统计学家,以获得最佳实践的益处。

### B.2.2\. A/B 测试

硬统计问题通常源于糟糕的实验设计。本节描述了一种简单、良好的统计设计哲学,称为 *A/B 测试*,它具有非常简单的理论。理想的实验是拥有两个组——控制组(A)和治疗组(B),并且以下条件成立:

+   每个组都足够大,以至于你可以得到可靠的测量结果(这推动了显著性)。

+   每个组(直到单个因素)都精确地分布得像你未来预期的群体(这推动了相关性)。特别是,两个样本都是同时并行运行的。

+   两个组只在你要测试的单个因素上有所不同。

在 A/B 测试中,提出一个新的想法、治疗方法或改进措施,然后对其进行效果测试。一个常见的例子是,对零售网站提出一个希望提高从浏览器到购买者转换率的改变。通常,治疗组被称为 *B* 组,未处理或对照组被称为 *A* 组。作为参考,我们推荐“网络受控实验实用指南”(R. Kohavi, R. Henne, 和 D. Sommerfield;KDD,2007)。

设置 A/B 测试

在运行 A/B 测试时需要格外小心。确保 A 组和 B 组同时运行非常重要。这有助于防御任何可能影响转换率变化的潜在混杂效应(如每小时效应、流量来源效应、星期几效应等)。此外,你需要知道你正在测量的差异实际上是由于你提出的改变造成的,而不是由于控制组和测试基础设施之间的差异。为了控制基础设施,你应该运行几个 A/A 测试(在 A 和 B 组中运行相同实验的测试)。

随机化是设计 A/B 测试的关键工具。但是,将用户分为 A 组和 B 组的方式需要合理。例如,对于用户测试,你不想将同一用户会话中的原始点击量分为 A/B 组,因为这样 A/B 组都会有可能看到过任何治疗网站的用户的点击。相反,你应该维护每个用户的记录,并在用户到达时永久性地将他们分配到 A 组或 B 组。避免在不同服务器之间进行大量记录保存的一个技巧是计算用户信息的哈希值,并根据哈希值是偶数还是奇数将用户分配到 A 组或 B 组(这样,所有服务器都会做出相同的决定,而无需进行通信)。

评估 A/B 测试

A/B 测试中的关键测量是效果的大小和测量的显著性。B 作为良好治疗的自然替代品(或零假设)是 B 没有差异,或者 B 甚至使事情变得更糟。不幸的是,典型的失败的 A/B 测试通常看起来并不像彻底的失败。它通常看起来是你正在寻找的积极效果确实存在,你只需要一个稍微大一点的后续样本大小来实现显著性。由于这类问题,在运行测试之前进行接受/拒绝条件的推理至关重要。

让我们做一个 A/B 测试的例子。假设我们已经运行了一个关于转化率的 A/B 测试并收集了以下数据。

列表 B.12\. 构建模拟的 A/B 测试数据

set.seed(123515)
d <- rbind( ❶
data.frame(group = 'A', converted = rbinom(100000, size = 1, p = 0.05)), ❷
data.frame(group = 'B', converted = rbinom(10000, size = 1, p = 0.055)) ❸
)


❶ 构建一个数据框来存储模拟示例

❷ 从 A 组添加 100,000 个示例,模拟 5%的转化率

❸ 从 B 组添加 10,000 个示例,模拟 5.5%的转化率

一旦我们有了数据,我们就使用一种称为**列联表**的数据结构将其总结为基本计数。^([3])

> ³
> 
> 我们在第 6.2.3 节中使用的混淆矩阵也是列联表的例子。

列表 B.13\. 将 A/B 测试总结到列联表中

tab <- table(d)
print(tab)

converted

group 0 1

A 94979 5021

B 9398 602


列联表是统计学家所说的**充分统计量**:它包含了我们关于实验结果所需知道的一切。我们可以打印出 A 组和 B 组的观察转化率。

列表 B.14\. 计算观察到的 A 和 B 转化率

aConversionRate <- tab['A','1']/sum(tab['A',])
print(aConversionRate)

[1] 0.05021

bConversionRate <- tab['B', '1'] / sum(tab['B', ])
print(bConversionRate)

[1] 0.0602

commonRate <- sum(tab[, '1']) / sum(tab)
print(commonRate)

[1] 0.05111818


我们看到 A 组的测量值接近 5%,B 组的测量值接近 6%。我们想知道的是:我们能信任这个差异吗?这样的差异可能是由于纯粹的偶然和测量噪声导致的这个样本大小吗?我们需要计算一个显著性来查看我们是否运行了一个足够大的实验(显然,我们希望设计一个足够大的实验——我们称之为*测试功效*,我们将在 B.6.5 节中讨论)。以下是一些快速运行的优秀测试。

费舍尔独立性检验

我们可以运行的第一项测试是费舍尔的列联表检验。在费舍尔检验中,我们希望拒绝的零假设是转化与组别无关,或者说 A 组和 B 组完全相同。费舍尔检验给出了一个概率,即看到独立数据集(A=B)显示出与独立性偏离的程度,与我们观察到的程度一样大。我们按照下一列表中的方式进行测试。

列表 B.15\. 计算观察到的差异在率上的显著性

fisher.test(tab)

Fisher's Exact Test for Count Data

data: tab

p-value = 2.469e-05

alternative hypothesis: true odds ratio is not equal to 1

95 percent confidence interval:

1.108716 1.322464

sample estimates:

odds ratio

1.211706


这是一个非常好的结果。p 值(在这种情况下,如果我们实际上有 A=B,观察到这种差异的概率)是 2.469e-05,非常小。这被认为是一个显著的结果。另一件需要关注的是 *优势比*:所声称效果的实际重要性(有时也称为 *临床意义*,这不属于统计意义)。优势比为 1.2 表示我们在 A 和 B 组之间测量到 20% 的相对转换率提升。你是否认为这是一个大或小的提升(通常,20% 被认为是大的)是一个重要的商业问题。

频率主义显著性测试

另一种估计显著性的方法是再次暂时假设 A 和 B 来自一个具有共同转换率的相同分布,并看看 B 组仅通过偶然机会获得如此高的分数的可能性有多大。如果我们考虑一个以共同转换率为中心的二项分布,我们希望看到在或高于 B 的水平的转换率上没有太多的概率质量。这意味着如果 A=B,观察到的差异不太可能。我们将在下面的列表中进行计算。

列表 B.16\. 计算频率主义显著性

print(pbinom( ❶
lower.tail = FALSE, ❷
q = tab['B', '1'] - 1, ❸
size = sum(tab['B', ]), ❹
prob = commonRate ❺
))

[1] 3.153319e-05


❶ 使用 pbinom() 调用来计算不同观察计数出现的可能性

❷ 我们想要计算大于给定 q 的概率的信号

❸ 询问看到至少与我们观察到的 B 组一样多的转换的概率。我们减去一个,使比较包括(大于或等于 tab['B', '1'])。

❹ 指定总试验次数等于我们在 B 组中看到的次数

❺ 指定估计的共同转换率下的转换概率

这又是一个非常好的结果。计算出的概率很小,这意味着如果 `A = B`,那么这种差异很难偶然观察到。

### B.2.3\. 测试的效力

要获得可靠的 A/B 测试结果,你必须首先设计和运行良好的 A/B 测试。我们需要防御两种类型的错误:未能看到差异,假设有差异(描述为测试效力);以及看到差异,假设没有差异(描述为显著性)。我们试图测量的 A 和 B 组之间的差异越接近,就越难获得一个好的正确测量的概率。我们唯一的工具是设计实验,希望 A 和 B 有很大的差异,或者增加实验规模。效力计算器让我们选择实验规模。

* * *

示例:设计一个测试来查看新广告是否具有更高的转换率

*假设我们正在运行一个每天有 6,000 个独立访客和 4%的转化率*^([4]) *从页面浏览到购买咨询(我们的可衡量目标)的旅游网站。我们希望测试网站的新设计,看看它是否能提高我们的转化率。这正是 A/B 测试旨在解决的问题!但我们还有一个问题:我们需要将多少用户路由到新设计才能得到可靠的测量?我们需要多长时间才能收集足够的数据?我们允许将不超过 10%的访客路由到新广告。*

> ⁴
> 
> 我们从[`mng.bz/7pT3`](http://mng.bz/7pT3)获取 4%的比率。

* * *

在这个实验中,我们将 90%的流量路由到旧广告,10%路由到新广告。对旧广告的转化率进行估计存在不确定性,但为了示例的简单性(以及因为九倍的流量将流向旧广告),我们将忽略这一点。因此,我们的问题是:我们应该将多少流量路由到新广告?

为了解决这个问题,我们需要为我们的实验设计一些标准:

+   我们对旧广告的转化率的估计是多少?让我们说这是 0.04 或 4%。

+   我们认为新广告足够大的改进的下限是多少?为了测试能够工作,这个值必须大于旧转化率。让我们说这是 0.046 或 4.5%,代表相对于销售转化的 10%以上的相对改进。

+   如果新广告没有更好的话,我们愿意以多大的概率出错?也就是说,如果新广告实际上并不比旧广告更好,我们愿意多频繁地“狼来了”并声称有改进(实际上并没有这种事)?让我们说,我们愿意以这种方式出错 5%的时间。让我们称这个为*显著性水平*。

+   当新广告实质上更好时,我们希望以多大的概率是正确的?也就是说,如果新广告实际上以至少 4.5%的比率转化,我们希望多频繁地检测到这一点?这被称为*功效*(与敏感性相关,我们在讨论分类模型时看到了这一点)。让我们说,我们希望功效为 0.8 或 80%。当有改进时,我们希望 80%的时间内找到它。

显然,我们*希望*能够检测到接近零的改进,在零的显著性水平,以及在 1 的功效。然而,如果我们坚持任何这些参数达到它们的“如果愿望是马的价值”(改进大小接近零,显著性水平接近零,功效接近 1),为了确保这些保证所需的测试规模变得巨大(甚至无限大!)因此,在项目开始前设定期望(始终是一个好习惯)的一部分,我们必须首先将这些“要求”协商到更可实现的价值,就像我们刚才描述的那样。

当试图确定样本量或实验持续时间时,重要的概念是 *统计测试功效*。统计测试功效是在零假设错误时拒绝零假设的概率。^([5]) 将统计测试功效视为 1 减去 p 值。其想法是这样的:如果你甚至无法识别哪些治疗方法是无用的,那么你就无法挑选出有用的治疗方法。因此,你希望设计你的测试以使测试功效接近 1,这意味着 p 值接近 0。

> ⁵
> 
> 参见 B. S. Everitt 所著的 *《剑桥统计学词典》* (剑桥大学出版社,2010 年)。

估算我们希望引导到新广告的访客数量的标准方法被称为 *功效计算*,由 R 包 `pwr` 提供。以下是我们是怎样使用 R 来得到答案的:

library(pwr)
pwr.p.test(h = ES.h(p1 = 0.045, p2 = 0.04),
sig.level = 0.05,
power = 0.8,
alternative = "greater")

proportion power calculation for binomial distribution (arcsine transfo

 rmation)

h = 0.02479642

n = 10055.18

sig.level = 0.05

power = 0.8

alternative = greater


注意,我们只是将我们的要求复制到 `pwr.p.test` 方法中,尽管我们确实通过 `ES.h()` 方法输入了我们要区分的两个假设比率,该方法将比率差异转换为 Cohen 风格的“效应量”。在这种情况下,`ES.h(p1 = 0.045, p2 = 0.04)` 为 0.025,这被认为相当小(因此难以测量)。效应量非常粗略地表示你试图测量的效应相对于个体自然变异的大小。因此,我们试图测量销售可能性变化,这是个体销售可能性变异的 1/0.025 或 40 倍小。对于任何小样本集来说,这是不可观察的,但足够大的样本可以观察到。^([6])

> ⁶
> 
> 效应量是一个好主意,有一个经验法则,即 0.2 是小的,0.5 是中等的,1.0 是大的。参见 [`en.wikipedia.org/wiki/Effect_size`](https://en.wikipedia.org/wiki/Effect_size)。

`n = 10056` 是我们需要发送到新广告上的流量量,以获得至少满足指定质量参数(显著性水平和功效)的测试结果。因此,我们需要向 10056 位访客展示新广告,以完成我们的 A/B 测试测量。我们的网站每天有 6,000 位访客,我们每天只能向其中 10%,即 600 位,展示新广告。因此,完成这项测试需要 10056/600 或 16.8 天。^([7])

> ⁷
> 
> 这实际上是 A/B 测试的一个不为人知的秘密:测量诸如广告转化成销售(通常称为“转化成销售”)等罕见事件的微小改进需要大量数据,而获取大量数据可能需要很长时间。

* * *

**场所购物降低测试功效**

我们在假设你正在运行一个大型测试的情况下讨论了测试功效和显著性。在实践中,你可能需要运行多个测试,尝试多种处理方法,以查看是否有任何处理方法能带来改进。这会降低你的测试功效。如果你运行 20 种处理方法,每种处理方法的 p 值目标为 0.05,你可能会期望有一个测试看起来显示出显著的改进,即使所有 20 种处理方法都毫无用处。测试多种处理方法或甚至多次重新检查同一处理方法是一种“地点购物”的形式(你会在不同的地点不断询问,直到得到对你有利的裁决)。计算测试功效的损失正式称为“应用 Bonferroni 校正”,其简单之处在于将你的显著性估计值乘以你的测试数量(记住,大数值对显著性或 p 值是不利的)。为了补偿这种测试功效的损失,你可以将每个基础测试在更紧的*p*截止值下运行:*p*除以你打算运行的测试数量。

* * *

### B.2.4. 专用统计测试

在整本书中,我们专注于构建预测模型和评估显著性,无论是通过建模工具内置的诊断工具,还是通过经验重采样(如 bootstrap 测试或排列测试)。在统计学中,对于你通常计算的大多数内容,都有一个高效的正确测试来检验其显著性。选择正确的标准测试为你提供了一个良好的测试实现,并可以访问解释测试背景和影响的文献。让我们来计算一个简单的相关系数,并找到匹配的正确测试。

我们将使用一个合成示例,这个示例应该会让你想起我们在第八章中进行的 PUMS 人口普查工作。第八章。假设我们已经测量了 100 个人的收入(以工资形式获得的钱)和资本收益(从投资中获得的钱)。进一步假设,对于我们的个人来说,这两者之间没有关系(在现实世界中,存在相关性,但我们需要确保我们的工具即使在没有任何关系的情况下也不会报告出来)。我们将使用一些对数正态分布的数据设置一个简单的数据集来表示这种情况。

列表 B.17. 构建合成的非相关收入

set.seed(235236) ❶
d <- data.frame(EarnedIncome = 100000 * rlnorm(100),
CapitalGains = 100000 * rlnorm(100)) ❷
print(with(d, cor(EarnedIncome, CapitalGains))) ❸

[1] -0.01066116


❶ 将伪随机种子设置为已知值,以便演示可重复进行

❷ 生成我们的合成数据

❸ 相关系数为-0.01,这非常接近 0——表明(如设计所示)没有关系。

我们声称观察到的-0.01 的相关性在统计学上与 0(或无效果)无法区分。这是我们应当量化的。一点研究告诉我们,常见的相关性被称为*皮尔逊系数*,对于正态分布数据的皮尔逊系数的显著性测试是学生 t 检验(自由度等于项目数减去 2)。我们知道我们的数据不是正态分布的(实际上是对数正态分布),所以我们进一步研究,发现首选的解决方案是通过排名(而不是值)比较数据,并使用斯皮尔曼的ρ或肯德尔的τ这样的测试。我们将使用斯皮尔曼的ρ,因为它可以追踪正负相关性(而肯德尔的τ追踪的是一致性程度)。

一个合理的问题是,我们如何知道使用哪个测试是正确的?答案是,通过学习统计学。请注意,有很多测试,这导致了像 N. D. Lewis 的《R 中的 100 个统计测试》(Heather Hills Press,2013 年)这样的书籍。我们还建议,如果你知道一个测试的名称,可以查阅 B. S.Everitt 和 A. Skrondal 的《剑桥统计学词典》,第四版(Cambridge University Press,2010 年)。

另一种找到正确测试的方法是使用 R 的帮助系统。`help(cor)`告诉我们`cor()`实现了三种不同的计算(皮尔逊、斯皮尔曼和肯德尔),并且有一个匹配的函数叫做`cor.test()`,它执行适当的显著性测试。由于我们并没有偏离常规路径太远,我们只需要了解这三种测试,并确定我们感兴趣的测试(在这种情况下,斯皮尔曼)。因此,让我们用选定的测试重新进行相关性计算,并检查显著性。

B.18. 列表:计算观察到的相关性的(非)显著性

with(d, cor(EarnedIncome, CapitalGains, method = 'spearman'))

[1] 0.03083108

(ctest <- with(d, cor.test(EarnedIncome, CapitalGains, method = 'spearman')))

Spearman's rank correlation rho

data: EarnedIncome and CapitalGains

S = 161512, p-value = 0.7604

alternative hypothesis: true rho is not equal to 0

sample estimates:

rho

0.03083108


我们看到斯皮尔曼相关系数为 0.03,p 值为 0.7604,这意味着真正不相关的数据大约有 76%的时间会显示出这样大的系数。因此,没有显著效果(这正是我们设计合成示例的方式)。

在我们自己的工作中,我们使用`sigr`包来封装这些测试结果,以便进行更简洁的正式展示。格式类似于 APA(美国心理学会)风格,而`n.s.`表示“不显著”。

sigr::wrapCorTest(ctest)

[1] "Spearman's rank correlation rho: (r=0.03083, p=n.s.)."


## B.3. 示例:数据的统计观点

与统计学相比,机器学习和数据科学对数据处理持乐观态度。在数据科学中,你迅速抓住非因果关系,希望它们会持续并有助于未来的预测。统计学的大部分内容是关于数据如何欺骗你以及这些关系如何误导你。我们只有空间举几个例子,所以我们将集中在两个最常见的问题上:抽样偏差和缺失变量偏差。

### B.3.1. 抽样偏差

*抽样偏差*是指任何系统地改变观察数据分布的过程.^([8]) 数据科学家必须意识到抽样偏差的可能性,并准备好检测和修复它。最有效的方法是修复你的数据收集方法。

> ⁸
> 
> 我们本想使用常见的术语“截断”来描述这个问题,但在统计学中,短语*censored observations*是保留给那些只记录到某个极限或界限的变量的。因此,使用该术语来描述缺失观察结果可能会造成混淆。

对于我们的抽样偏差示例,我们将继续使用我们在 B.4 节中开始讨论的收入示例。假设通过某种偶然,我们只研究我们原始人口中的高收入子集(也许我们在某个独家活动中对他们进行了民意调查)。以下列表显示了当我们限制为高收入集时,似乎收入和资本收益之间存在强烈的负相关性。我们得到的相关性为-0.86(因此,将负相关性视为解释了`(-0.86)² = 0.74 = 74%`的方差;参见[`mng.bz/ndYf`](http://mng.bz/ndYf)),p 值非常接近 0(因此,不太可能以这种方式产生的更多未知真实相关性实际上为 0)。以下列表展示了计算过程。

列表 B.19\. 偏差观察结果导致的误导性显著性结果

veryHighIncome <- subset(d, EarnedIncome+CapitalGains>=500000)
print(with(veryHighIncome,cor.test(EarnedIncome,CapitalGains,
method='spearman')))

Spearman's rank correlation rho

data: EarnedIncome and CapitalGains

S = 1046, p-value < 2.2e-16

alternative hypothesis: true rho is not equal to 0

sample estimates:

rho

-0.8678571


一些图表有助于展示正在发生的事情。图 B.8 显示了通过最佳线性关系线绘制的原始数据集。请注意,该线几乎是平的(表明`x`的变化不能预测`y`的变化)。

图 B.8\. 收入与资本收益

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig08_alt.jpg)

图 B.9 显示了穿过高收入数据集的最佳趋势线。它还显示了如何删除低于线`x+y=500000`的点,留下一些稀疏的高价值事件,这些事件按方向排列,粗略地近似我们的切割线(-0.8678571 是-1 的粗略近似)。值得注意的是,我们抑制的部分之间没有相关性,因此这种影响并不是通过从非相关云中抑制相关组来获得负相关。

图 B.9\. 偏差收入与资本收益

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig09_alt.jpg)

生成图 B.8、B.9 并计算被抑制点之间相关性的代码如下所示。

列表 B.20\. 绘制收入和资本收益的偏差视图

library(ggplot2)
ggplot(data=d,aes(x=EarnedIncome,y=CapitalGains)) +
geom_point() + geom_smooth(method='lm') +
coord_cartesian(xlim=c(0,max(d)),ylim=c(0,max(d))) ❶
ggplot(data=veryHighIncome,aes(x=EarnedIncome,y=CapitalGains)) +
geom_point() + geom_smooth(method='lm') +
geom_point(data=subset(d,EarnedIncome+CapitalGains<500000),
aes(x=EarnedIncome,y=CapitalGains),
shape=4,alpha=0.5,color='red') +
geom_segment(x=0,xend=500000,y=500000,yend=0,
linetype=2,alpha=0.5,color='red') +
coord_cartesian(xlim=c(0,max(d)),ylim=c(0,max(d))) ❷
print(with(subset(d,EarnedIncome+CapitalGains<500000),
cor.test(EarnedIncome,CapitalGains,method='spearman'))) ❸

Spearman's rank correlation rho

data: EarnedIncome and CapitalGains

S = 107664, p-value = 0.6357

alternative hypothesis: true rho is not equal to 0

sample estimates:

rho

-0.05202267


❶ 以线性趋势线(以及不确定性带)绘制所有收入数据

❷ 绘制非常高的收入数据和线性趋势线(包括截止点和被抑制数据的描绘)

❸ 计算被抑制数据的相关性

### B.3.2\. 漏掉变量偏差

许多数据科学客户期望数据科学是一个快速的过程,其中一次性将所有方便的变量投入其中,并迅速获得最佳结果。统计学家有理由对此类方法持谨慎态度,因为各种负面效应,如漏变量偏差、共线性变量、混杂变量和干扰变量。在本节中,我们将讨论一个更普遍的问题:漏变量偏差。

什么是漏变量偏差?

在其最简单的形式中,漏变量偏差发生在模型中未包含的变量既与我们试图预测的内容相关联,又与模型中包含的变量相关联。当这种影响强烈时,会导致问题,因为模型拟合过程试图使用模型中的变量来直接预测所需的输出,并代表缺失变量的影响。这可能会引入偏差,创建出不太合理的模型,并导致泛化性能不佳。

漏变量偏差的影响在回归示例中最容易看到,但它可以影响任何类型的模型。

漏变量偏差的一个例子

我们已经准备了一个名为 synth.RData 的合成数据集(从[`github.com/WinVector/PDSwR2/tree/master/bioavailability`](https://github.com/WinVector/PDSwR2/tree/master/bioavailability)下载),它具有数据科学项目中典型的漏变量问题。首先,请下载 synth.RData 并将其加载到 R 中,如下所示。

列表 B.21. 总结我们的合成生物数据

load('synth.RData')
print(summary(s))

week Caco2A2BPapp FractionHumanAbsorption

Min. : 1.00 Min. :6.994e-08 Min. :0.09347

1st Qu.: 25.75 1st Qu.:7.312e-07 1st Qu.:0.50343

Median : 50.50 Median :1.378e-05 Median :0.86937

Mean : 50.50 Mean :2.006e-05 Mean :0.71492

3rd Qu.: 75.25 3rd Qu.:4.238e-05 3rd Qu.:0.93908

Max. :100.00 Max. :6.062e-05 Max. :0.99170

head(s)

week Caco2A2BPapp FractionHumanAbsorption

1 1 6.061924e-05 0.11568186

2 2 6.061924e-05 0.11732401

3 3 6.061924e-05 0.09347046

4 4 6.061924e-05 0.12893540

5 5 5.461941e-05 0.19021858

6 6 5.370623e-05 0.14892154

View(s) ❶


❶ 在类似电子表格的窗口中显示日期。视图是 RStudio 中比基本 R 有更好实现的命令之一。

这加载了代表可能收集的药物 ADME^([9])或生物利用度项目历史数据的简化视图的合成数据。RStudio 的`View()`电子表格显示在图 B.10。此数据集的列在表 B.1 中描述。

> ⁹
> 
> ADME 代表吸收、分布、代谢、排泄;它有助于确定哪些分子可以通过摄入进入人体,因此甚至可能是口服药物的可行候选者。

图 B.10. 生物利用度数据集的行视图

![](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/app02fig10_alt.jpg)

表 B.1. 生物利用度列

| 列 | 描述 |
| --- | --- |
| week | 在这个项目中,我们假设一个研究小组每周提交一个新的候选药物分子进行检测。为了简化问题,我们使用周数(自项目开始以来的周数)作为分子的标识符和数据行的标识符。这是一个优化项目,这意味着每个提出的分子都是使用从所有先前分子中学到的经验制作的。这在许多项目中是典型的,但这意味着数据行不能互相交换(我们经常使用的重要假设,以证明统计和机器学习技术的合理性)。 |
| Caco2A2BPapp | 这是我们进行的第一次检测(也是“便宜”的一次)。Caco2 测试测量候选分子通过由特定大肠癌细胞系(癌症常用于测试,因为非癌性的人类细胞通常不能无限期地培养)衍生的细胞膜的速度。Caco2 测试是一种替代或类比测试。该测试被认为模拟了一层与它形态相似的小肠层(尽管它缺少实际小肠中存在的一些形态和机制)。将 Caco2 视为一种便宜的测试,用于评估与生物利用度(项目的实际目标)相关的因素。 |
| FractionHumanAbsorption | 这是第二次检测,表示药物候选物被人体测试对象吸收的比例。显然,这些测试的运行成本很高,并且受到许多安全协议的约束。在这个例子中,优化吸收是项目的实际最终目标。 |

我们构建了这组合成数据来代表一个试图通过候选药物分子的微小变化来优化人体吸收的项目。在项目开始时,他们有一个针对替代标准 Caco2(它与人体吸收相关)高度优化的分子,并且在项目的历史过程中,通过改变我们在这个简单模型中未跟踪的因素,实际的人体吸收大大增加。在药物优化过程中,通常会出现曾经占主导地位的替代标准在其它输入开始主导结果时,其价值似乎变得不那么理想。因此,在我们的示例项目中,人体吸收率正在上升(因为科学家们成功地对其进行了优化),而 Caco2 率正在下降(因为它一开始就很高,而我们不再对其优化,尽管它*确实*是一个有用的特征)。

使用合成数据为这些问题示例提供的一个优点是,我们可以设计数据以具有给定的结构,然后我们知道如果模型能够捕捉到这一点,那么模型就是正确的;如果它错过了这一点,那么模型就是错误的。特别是,这个数据集被设计成 Caco2 在整个数据集中始终是吸收分数的正向贡献。这些数据是通过使用合理的 Caco2 测量值的随机非递增序列生成的,然后生成虚构的吸收数值,如下所示(从 synth.RData 中加载的数据框 `d` 是我们基于合成示例发布的图形)。我们将在下一个列表中生成已知随时间改进的合成数据。

列表 B.22\. 随时间改进的数据构建

set.seed(2535251)
s <- data.frame(week = 1:100)
s\(Caco2A2BPapp <- sort(sample(d\)Caco2A2BPapp,100,replace=T),
decreasing=T)
sigmoid <- function(x) {1/(1 + exp(-x))}
s\(FractionHumanAbsorption <- ❶ sigmoid( 7.5 + 0.5 * log(s\)Caco2A2BPapp) + ❷
s\(week / 10 - mean(s\)week / 10) + ❸
rnorm(100) / 3 ❹
)
write.table(s, 'synth.csv', sep=',',
quote = FALSE, row.names = FALSE)


❶ 构建合成示例

❷ 将 Caco2 添加到从原始数据集学习到的吸收关系中。请注意,这种关系是正向的:在我们的合成数据集中,更好的 Caco2 总是推动更好的吸收。我们正在对 Caco2 进行对数变换,因为它有超过 30 年的范围。

❸ 添加一个依赖于时间的均值为 0 的项,以模拟项目推进过程中的改进效果

❹ 添加一个均值为 0 的噪声项

这组数据的设计是这样的:Caco2 总是具有正向效应(与我们的起始源数据相同),但这一效应被 `week` 因子所掩盖(因为 `week` 在增加,而 Caco2 是按递减顺序排列的)。时间不是我们最初希望建模的变量(这不是我们可以有效控制的),但省略时间的分析会受到省略变量偏差的影响。关于完整细节,请参阅我们的 GitHub 示例文档([`github.com/WinVector/PDSwR2/tree/master/bioavailability`](https://github.com/WinVector/PDSwR2/tree/master/bioavailability))。

一个被宠坏的分析

在某些情况下,Caco2 与 `FractionHumanAbsorption` 之间的真实关系被隐藏,因为变量 `week` 与 `FractionHumanAbsorption` 正相关(因为吸收随时间改善)且与 Caco2 负相关(因为 Caco2 随时间下降)。`week` 是代表我们未记录或建模的所有其他驱动人类吸收的分子因素的替代变量。列表 B.23 展示了当我们尝试不使用 `week` 变量或任何其他因素来建模 Caco2 与 `FractionHumanAbsorption` 之间的关系时会发生什么。

列表 B.23\. 一个糟糕的模型(由于省略变量偏差)

print(summary(glm(data = s,
FractionHumanAbsorption ~ log(Caco2A2BPapp),
family = binomial(link = 'logit'))))

Warning: non-integer #successes in a binomial glm!

Call:

glm(formula = FractionHumanAbsorption ~ log(Caco2A2BPapp),

data = s)

Deviance Residuals:

Min 1Q Median 3Q Max

-0.609 -0.246 -0.118 0.202 0.557

Coefficients:

Estimate Std. Error z value Pr(>|z|)

(Intercept) -10.003 2.752 -3.64 0.00028 ***

log(Caco2A2BPapp) -0.969 0.257 -3.77 0.00016 ***

---

Signif. codes: 0 '' 0.001 '' 0.01 '' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

Null deviance: 43.7821 on 99 degrees of freedom

Residual deviance: 9.4621 on 98 degrees of freedom

AIC: 64.7

Number of Fisher Scoring iterations: 6


关于如何阅读 `glm()` 摘要的详细信息,请参阅第 7.2 节。注意,Caco2 系数的符号是负的,而不是我们预期的或合理的。这是因为 Caco2 系数不仅记录了 Caco2 与 `FractionHumanAbsorption` 之间的关系,还必须记录任何通过省略的相关变量产生的任何关系。

解决省略变量偏差

有多种处理遗漏变量偏差的方法,最好的方法是更好的实验设计和更多的变量。其他方法包括使用固定效应模型和层次模型。我们将演示其中一种最简单的方法:添加可能重要的遗漏变量。在下面的列表中,我们包括了`week`变量重新进行了分析。

列表 B.24\. 一个更好的模型

print(summary(glm(data=s,
FractionHumanAbsorption~week+log(Caco2A2BPapp),
family=binomial(link='logit'))))

Warning: non-integer #successes in a binomial glm!

Call:

glm(formula = FractionHumanAbsorption ~ week + log(Caco2A2BPapp),

Deviance Residuals:

Min 1Q Median 3Q Max

-0.3474 -0.0568 -0.0010 0.0709 0.3038

Coefficients:

Estimate Std. Error z value Pr(>|z|)

(Intercept) 3.1413 4.6837 0.67 0.5024

week 0.1033 0.0386 2.68 0.0074 **

log(Caco2A2BPapp) 0.5689 0.5419 1.05 0.2938

---

Signif. codes: 0 '' 0.001 '' 0.01 '' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

Null deviance: 43.7821 on 99 degrees of freedom

Residual deviance: 1.2595 on 97 degrees of freedom

AIC: 47.82

Number of Fisher Scoring iterations: 6


我们恢复了 Caco2 和`week`系数的合理估计,但我们没有在 Caco2 的影响上达到统计显著性。请注意,修正遗漏变量偏差需要(即使在我们的合成示例中)一些领域知识来提出重要的遗漏变量,以及测量额外变量的能力(并尝试通过使用偏置来消除它们的影响;参见`help('offset')`)。

在这个阶段,你应该对变量有一个更详细的意向性看法。至少,有你可以控制的变量(解释变量),重要的变量你无法控制(干扰变量),以及你不知道的重要变量(遗漏变量)。你对所有这些变量类型的了解应该影响你的实验设计和分析。

## B.4\. 吸取的教训

统计学是一个深奥的领域,对数据科学有重要的影响。统计学包括对建模和分析中可能出错的研究,如果你没有为可能出错的情况做好准备,它往往会出错。我们希望你能将这个附录视为进一步学习的邀请。我们推荐的一本书是 David Freedman 所著的《统计模型:理论与实践》(剑桥出版社,2009 年)。


# 附录 C. 参考文献列表

+   Adler, Joseph. *《R 简明指南》*,第 2 版。O’Reilly 媒体,2012 年。

+   Agresti, Alan。*《分类数据分析》*,第 3 版。Wiley 出版物,2012 年。

+   Alley, Michael. *《科学演示技巧》*。Springer,2003 年。

+   Brooks, Jr.,Frederick P. *《神话般的月份:软件工程论文集》*。Addison-Wesley,1995 年。

+   Carroll, Jonathan。*《超越电子表格的 R》*。Manning 出版公司,2018 年。

+   Casella, George 和 Roger L. Berger. *《统计推断》*。Duxbury,1990 年。

+   Celko, Joe. *《SQL 高手指南》*,第 4 版。Morgan Kauffman,2011 年。

+   Chakrabarti, Soumen。*《网络挖掘》*。Morgan Kauffman,2003 年。

+   Chambers, John M. *《数据分析软件》*。Springer,2008 年。

+   Chang, Winston. *《R 图形烹饪书》*,第 2 版。O’Reilly 媒体,2018 年。

+   Charniak, Eugene。*《统计语言学习》*。MIT 压力出版社,1993 年。

+   Chollet, François,与 J. J. Allaire 合著。*《使用 R 进行深度学习》*。Manning 出版公司,2018 年。

+   Cleveland, William S. *《绘图数据要素》*。Hobart 压力出版社,1994 年。

+   Cohen, J. 和 P. Cohen. *《行为科学中的应用多重回归/相关分析》*,第 2 版。Lawrence Erlbaum Associates, Inc.,1983 年。

+   Cover, Thomas M. 和 Joy A. Thomas. *《信息论基础》*。Wiley,1991 年。

+   Cristianini, Nello 和 John Shawe-Taylor. *《支持向量机导论》*。剑桥出版社,2000 年。

+   Dalgaard, Peter. *《使用 R 的入门统计学》*,第 2 版。Springer,2008 年。

+   Dimiduk, Nick 和 Amandeep Khurana. *《HBase 实战》*。Manning 出版公司,2013 年。

+   Efron, Bradley 和 Robert Tibshirani. *《自举导论》*。Chapman and Hall,1993 年。

+   Everitt, B. S. *《剑桥统计学词典》*,第 2 版。剑桥出版社,2006 年。

+   Freedman, David。*《统计模型:理论与实践》*。剑桥出版社,2009 年。

+   Freedman, David,Robert Pisani 和 Roger Purves。*《统计学》*,第 4 版。Norton,2007 年。

+   Gandrud, Christopher. *《使用 R 和 RStudio 进行可重复研究》*,第 2 版。CRC 压力出版社,2015 年。

+   Gelman, Andrew,John B. Carlin,Hal S. Stern,David B. Dunson,Aki Vehtari 和 Donald B. Rubin. *《贝叶斯数据分析》*,第 3 版。CRC 压力出版社,2013 年。

+   Gentle, James E. *《计算统计学基础》*。Springer,2002 年。

+   Goldberg, David. “每个计算机科学家都应该知道的浮点运算。”ACM 计算评论,第 23 卷第 1 期,第 5-48 页,1991 年 3 月。

+   Good, Philip. *《排列检验》*。Springer,2000 年。

+   Hastie, Trevor,Robert Tibshirani 和 Jerome Friedman。*《统计学习基础》*,第 2 版。Springer,2009 年。

+   Hothorn, Torsten 和 Brian S. Everitt. *《使用 R 进行统计分析手册》*,第 3 版. CRC 压力出版社,2014 年。

+   James, Gareth,Daniela Witten,Trevor Hastie 和 Robert Tibshirani。*《统计学习导论》*。Springer,2013 年。

+   Kabacoff, Robert。*《R 实战》*,第 2 版。Manning 出版公司,2014 年。

+   Kennedy, Peter. *《计量经济学指南》*,第 5 版. MIT 压力出版社,2003 年。

+   Kohavi, R.,R. Henne 和 D. Sommerfield。“网络上的受控实验实用指南。”KDD,2007 年。

+   Koller, Daphne, and Nir Friedman. *《概率图模型:原理与技术》*. MIT Press, 2009.

+   Krzanowski, W. J., and F. H. C. Marriott. *《多元分析,第一部分》*. Edward Arnold, 1994.

+   Kuhn, Max, and Kjell Johnson. *《应用预测建模》*. Springer, 2013.

+   Lander, Jared P. *《R 语言入门》*. Addison-Wesley Data & Analytics Series, 2017.

+   Lewis, N. D. *《R 中的 100 个统计测试》*. Heather Hills Press, 2013.

+   Loeliger, Jon, and Matthew McCullough. *《使用 Git 进行版本控制》,第 2 版*. O’Reilly Media, 2012.

+   Magee, John. “Arthur D. Little, Inc. 的运筹学研究:早期岁月。” *运筹学*, 2002\. 50 (1), pp. 149–153.

+   Marz, Nathan, and James Warren. *《大数据》*. Manning Publications, 2014.

+   Matloff, Norman. *《统计回归与分类:从线性模型到机器学习》*. CRC Press, 2017.

+   ——*《R 编程艺术:统计软件设计之旅》*. No Starch Press, 2011.

+   Mitchell, Tom M. *《机器学习》*. McGraw-Hill, 1997.

+   Nussbaumer Knaflic, Cole. *《用数据讲故事》*. Wiley, 2015.

+   Provost, Foster, and Tom Fawcett. *《商业数据科学》*. O’Reilly Media, 2013.

+   R Core Team. *《R:统计计算的语言和环境》*. R Foundation for Statistical Computing. [`R-project.org/`](https://R-project.org/).

+   ——*《R 语言定义》*. R Foundation for Statistical Computing, 2019\. [`cran.r-project.org/doc/manuals/r-release/R-lang.html`](https://cran.r-project.org/doc/manuals/r-release/R-lang.html).

+   Raymond, Erick S. *《Unix 编程艺术》*. Addison-Wesley, 2003.

+   Sachs, Lothar. *《应用统计学》,第 2 版*. Springer, 1984.

+   Seni, Giovanni, and John Elder. *《数据挖掘中的集成方法》*. Morgan and Claypool, 2010.

+   Shawe-Taylor, John, and Nello Cristianini. *《模式分析的核方法》*. Cambridge Press, 2004.

+   Shumway, Robert, and David Stoffer. *《时间序列分析及其应用》,第 3 版*. Springer, 2013.

+   Spector, Phil. *《使用 R 进行数据处理》*. Springer, 2008.

+   Spiegel, Murray R., and Larry J. Stephens. *《Schaum 的统计学概要》,第 4 版*. McGraw-Hill, 2011.

+   Sweeney, R. E., and E. F. Ulveling. “回归分析中简化二元变量系数解释的转换。” *《美国统计学家》*, 26(5), 30–32, 1972.

+   Tibshirani, Robert. “通过 lasso 进行回归收缩和选择。” *《皇家统计学会会刊》,系列 B 58: 267–288, 1996.

+   Tsay, Ruey S. *《金融时间序列分析》,第 2 版*. Wiley, 2005.

+   Tukey, John W. *《探索性数据分析》*. Pearson, 1977.

+   Vapnik, Vladimir N. *《统计学习理论》*. Wiley-Interscience, 1998.

+   ——*《统计学习理论的自然属性》,第 2 版*. Springer, 2000.

+   Wasserman, Larry. *《所有非参数统计学》*. Springer, 2006.

+   ——*《所有统计学》*. Springer, 2004.

+   Wickham, Hadley. *《高级 R》*. CRC, 2014.

+   ——*《ggplot2:数据分析中的优雅图形(使用 R!)》*. Springer, 2009.

+   ——*R 包:组织、测试、文档和分享您的代码*. O’Reilly 媒体,2015。

+   Wilkinson, Leland. *图形语法*, 第 2 版。斯普林格,2005。

+   Xie, Yihui. *使用 R 和 knitr 创建动态文档*. CRC 压力,2013。

+   Zumel, Nina, 和 John Mount. “vtreat:用于预测建模的数据框处理器。” 2016\. [`arxiv.org/abs/1611.09477`](https://arxiv.org/abs/1611.09477).


# 使用 R 进行实用数据科学

**数据科学项目生命周期:循环中的循环**

![图片描述](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/prac-ds-r-2e/img/ifc_alt.jpg)

  1. 4 ↩︎

  2. [1] ↩︎

  3. 3 ↩︎

posted @ 2025-11-19 09:21  绝不原创的飞龙  阅读(33)  评论(0)    收藏  举报