R-机器学习第四版-全-
R 机器学习第四版(全)
原文:
annas-archive.org/md5/e01de45f59df828d0f9928d7771beee5译者:飞龙
前言
机器学习,从本质上讲,描述的是将数据转化为可操作智能的算法。这一事实使得机器学习非常适合当今的大数据时代。没有机器学习,我们几乎无法理解现在围绕我们的海量信息流。
被称为 R 的跨平台、零成本的统计编程环境提供了一个理想的途径来开始应用机器学习。R 提供了强大且易于学习的工具,可以帮助你在自己的数据中找到洞察。
通过结合实际案例研究和理解这些算法如何工作的必要理论,本书提供了你开始机器学习并将其方法应用于自己项目的所有知识。
本书面向的对象
本书旨在面向应用领域的人士——如商业分析师、社会科学家等,他们可以访问数据并希望将其用于实际行动。也许你已经对机器学习有所了解,但从未使用过 R;或者,也许你对 R 有所了解,但对机器学习却是新手。也许你对两者都一无所知!无论如何,这本书将让你迅速上手。对基本数学和编程概念有一定的了解会有所帮助,但不需要任何先前的经验。你所需要的只是好奇心。
本书涵盖的内容
第一章,介绍机器学习,介绍了定义和区分机器学习者的术语和概念,以及将学习任务与适当的算法匹配的方法。
第二章,管理和理解数据,提供了一个机会,让你在 R 中动手处理数据。讨论了用于加载数据、探索和理解数据的基本数据结构和程序。
第三章,懒惰学习 – 使用最近邻进行分类,教你如何理解和应用一个简单但强大的机器学习算法到你的第一个真实世界任务:识别癌症的恶性样本。
第四章,概率学习 – 使用朴素贝叶斯进行分类,揭示了在尖端垃圾邮件过滤系统中使用的概率的基本概念。在构建自己的垃圾邮件过滤器过程中,你将学习文本挖掘的基础知识。
第五章,分而治之 – 使用决策树和规则进行分类,探讨了几个预测不仅准确,而且易于解释的学习算法。我们将将这些方法应用于需要透明度的重要任务中。
第六章,预测数值数据 – 回归方法,介绍了用于进行数值预测的机器学习算法。由于这些技术深深植根于统计学领域,你还将学习理解数值关系所需的基本指标。
第七章,黑盒方法 – 神经网络和支持向量机,涵盖了两个复杂但强大的机器学习算法。尽管数学可能看起来令人畏惧,但我们将通过简单术语说明其内部工作的示例。
第八章,寻找模式 – 使用关联规则进行市场篮子分析,揭示了众多零售商使用的推荐系统中使用的算法。如果你曾好奇零售商似乎比你更了解你的购买习惯,这一章将揭示他们的秘密。
第九章,寻找数据组 – 使用 k-means 进行聚类,致力于一种定位相关项目聚类的程序。我们将利用此算法来识别在线社区内的个人资料。
第十章,评估模型性能,提供了衡量机器学习项目成功和获得对未来数据学习者性能的可靠估计的信息。
第十一章,在机器学习中取得成功,描述了从教科书数据集过渡到现实世界机器学习问题时面临的常见陷阱,以及对抗这些问题的工具、策略和软技能。
第十二章,高级数据准备,介绍了“tidyverse”包集,这些包有助于整理大型数据集以提取有意义的信息,从而辅助机器学习过程。
第十三章,挑战性数据 – 过多、过少、过于复杂,考虑了解决在大量数据集中丢失有用信息时可能破坏机器学习项目的常见问题,就像在干草堆里找针一样。
第十四章,打造更好的学习者,揭示了机器学习竞赛排行榜上顶尖团队所采用的方法。如果你有竞争心,或者只是想充分利用你的数据,你需要将这些技巧添加到你的技能库中。
第十五章,利用大数据,探讨了机器学习的边界。从处理极大型数据集到使 R 运行更快,涵盖的主题将帮助你推动 R 所能实现的可能性的边界,甚至允许你利用像 Google 这样的大型组织开发的复杂工具,用于图像识别和理解文本数据。
你需要这本书的
本书中的示例在 Microsoft Windows、Mac OS X 和 Linux 上的 R 版本 4.2.2 上进行了测试,尽管它们可能适用于任何较新的 R 版本。R 可以在cran.r-project.org/免费下载。
RStudio 界面,在第一章 介绍机器学习中详细描述,是 R 的一个高度推荐的附加组件,它极大地增强了用户体验。RStudio 开源版可以从 Posit 免费获得(www.posit.co/),同时还有一个付费的 RStudio Pro 版,为商业组织提供优先支持和额外功能。
下载示例代码文件
书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Machine-Learning-with-R-Fourth-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/TZ7os。
使用的约定
文本中的代码:函数名称、文件名、文件扩展名和 R 包名称如下所示:“class包中的knn()函数提供了一个标准、经典的 k-NN 算法实现。”
R 用户输入和输出如下所示:
> reg(y = launch$distress_ct, x = launch[2:4])
estimate
Intercept 3.527093383
temperature -0.051385940
field_check_pressure 0.001757009
flight_num 0.014292843
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“在 RStudio 中,可以使用文件菜单创建新文件,选择新建文件,然后选择R 笔记本选项。”
对额外资源或背景信息的引用看起来是这样的。
有用的提示和重要的注意事项看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍的标题。如果你对这本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com。
分享你的想法
一旦您阅读了《使用 R 进行机器学习 - 第四版》,我们非常乐意听到您的想法!请点击此处直接转到此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢随时随地阅读,但无法携带您的印刷书籍到任何地方吗?您的电子书购买是否与您选择的设备不兼容?
别担心,现在购买每一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何地点、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容。
按照以下简单步骤获取这些好处:
- 扫描二维码或访问以下链接

packt.link/free-ebook/978-1-80107-132-1
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一章:介绍机器学习
如果相信科幻故事,人工智能的发明不可避免地会导致机器与其创造者之间的末日战争。故事从今天的现实开始:计算机被训练来玩简单的游戏,如井字棋,以及自动化常规任务。随着故事的发展,机器后来被赋予了控制交通灯和通信的权利,接着是军事无人机和导弹。一旦计算机变得有意识并学会如何自我教学,机器的进化就会变得不祥。不再需要人类程序员,人类随后就被“删除”了。
幸运的是,在写作的时候,机器仍然需要用户输入。
虽然你对机器学习的印象可能被这些大众媒体的描绘所影响,但今天的算法几乎没有成为自我意识的危险。今天机器学习的目标不是创造一个人工大脑,而是帮助我们理解并行动于世界快速积累的数据存储。
放弃流行的错误观念,到本章结束时,你将获得对机器学习的更深入理解。你还将了解到定义和区分最常见机器学习方法的根本概念。你将学习:
-
机器学习的起源、应用、伦理和陷阱
-
计算机如何将数据转化为知识和行动
-
将机器学习算法与您的数据匹配所需的步骤
机器学习领域提供了一套算法,可以将数据转化为可操作的知识。继续阅读,看看使用 R 语言开始将机器学习应用于现实世界问题有多简单。
机器学习的起源
从出生开始,我们就被数据淹没。我们身体的传感器——眼睛、耳朵、鼻子、舌头和神经——不断地被原始数据所攻击,我们的大脑将这些数据转化为视觉、声音、气味、味道和触感。使用语言,我们可以与他人分享这些经历。
自有文字以来,人类就记录了自己的观察。猎人监控动物群体的移动;早期的天文学家记录了行星和星星的对齐;城市记录了税收、出生和死亡。今天,这样的观察以及许多其他观察越来越多地自动化,并系统地记录在日益增长的计算机化数据库中。
电子传感器的发明还进一步促进了记录数据的数量和丰富性的爆炸式增长。专门的传感器,如摄像头、麦克风、化学鼻、电子舌头和压力传感器模仿了人类看、听、闻、尝和感觉的能力。这些传感器处理数据的方式与人类大不相同。与人类有限的、主观的注意力不同,电子传感器从不休息,也没有情感来扭曲其感知。
尽管传感器不受主观性的影响,但它们并不一定报告现实的一个单一、确定的描述。一些由于硬件限制而固有测量误差。其他则受其范围的限制。黑白照片对其主题的描述与彩色照片不同。同样,显微镜提供的现实描述与望远镜提供的截然不同。
在数据库和传感器之间,我们生活的许多方面都被记录下来。政府、企业和个人都在记录和报告信息,从宏大到日常琐事。气象传感器获取温度和压力数据;监控摄像头监视人行道和地铁隧道;各种电子行为都被监控:交易、通讯、社交媒体关系,以及其他许多方面。
这场数据洪流导致一些人声称我们已进入大数据时代,但这可能有点名不副实。人类一直被大量数据所包围——只需抬头看看天空,尝试数数星星,就会发现几乎无限的供应。当前时代独特之处在于,我们有大量的记录数据,其中大部分可以直接由计算机访问。更大、更有趣的数据集越来越容易获取,只需轻轻一点鼠标,通过网络搜索即可。这种信息财富有可能在系统地理解所有这些信息后,指导行动。
专注于开发将数据转化为智能行动的计算机算法的研究领域被称为机器学习。这个领域起源于一个环境,其中可用的数据、统计方法和计算能力迅速且同时发展。数据量的增长需要额外的计算能力,这反过来又推动了分析大数据集的统计方法的发展。这创造了一个进步的周期,使得更大、更有趣的数据可以被收集,并使得今天的环境中,几乎任何主题都可以获取到无尽的数据流。

图 1.1:推动机器学习的进步周期
与机器学习密切相关的一个领域是数据挖掘,它关注于从大型数据库中生成新的见解。正如其名称所暗示的,数据挖掘涉及系统地寻找可操作的智能金块。尽管对于机器学习和数据挖掘重叠的程度存在一些争议,但一个区别点是,机器学习专注于教会计算机如何使用数据来解决问题,而数据挖掘则专注于教会计算机识别人类随后用于解决问题的模式。
几乎所有数据挖掘都涉及到机器学习的使用,但并非所有的机器学习都需要数据挖掘。例如,你可能会应用机器学习来挖掘与事故率相关的汽车交通数据模式。另一方面,如果计算机正在学习如何识别交通标志,这纯粹是机器学习,而不涉及数据挖掘。
“数据挖掘”这个短语有时也被用作贬义词,用来描述为了支持一个理论而挑选数据的欺骗性做法。
机器学习也与人工智能领域(AI)交织在一起,这是一个模糊的学科,根据你询问的人不同,它可能仅仅是带有强烈营销色彩的学习,或者是一个完全不同的研究领域。一个愤世嫉俗者可能会建议人工智能领域倾向于夸大其重要性,例如将一个简单的预测模型称为“AI 机器人”,而一个 AI 支持者可能会指出该领域倾向于解决最具挑战性的学习任务,同时追求达到人类水平的表现。事实可能介于两者之间。
正如机器学习本身依赖于统计方法一样,人工智能在很大程度上依赖于机器学习,但商业背景和应用往往有所不同。下表突出了传统统计学、机器学习和人工智能之间的不同之处;然而,请记住,这三个学科之间的界限往往不如它们看起来那么严格。
| 传统统计学 | 机器学习 | 人工智能 | |
|---|---|---|---|
| 应用 | 假设检验和洞察 | 预测和知识生成 | 自动化 |
| 成功标准 | 更深入的理解 | 在事情发生前进行干预的能力 | 效率和成本节约 |
| 成功指标 | 统计显著性 | 预测的可信度 | 投资回报率 (ROI) |
| 输入数据量 | 小数据 | 中等数据 | 大数据 |
| 实施 | 知识共享的报告和演示 | 评分数据库或商业实践中的干预 | 定制应用程序和自动化流程 |
在这个公式中,机器学习稳固地位于人类与计算机合作的交汇点,而传统统计学主要依赖人类来驱动洞察力,人工智能则尽可能减少人类参与。学习如何最大化人机合作,并将学习算法应用于现实世界问题是本书的重点。理解机器学习的用例和限制是这个旅程的重要起点。
机器学习的使用与滥用
大多数人都听说过 Deep Blue,这是一台下棋的电脑,它在 1997 年首次击败了世界冠军。另一台著名的电脑 Watson 在 2011 年击败了电视智力游戏节目《Jeopardy》中的两位人类对手。基于这些令人瞩目的成就,有些人推测计算机智能将取代信息技术职业中的工人,就像汽车取代了马,机器取代了田野和生产线上的工人一样。最近,随着基于人工智能的算法,如来自 OpenAI 研究小组的 GPT-3 和 DALL·E 2(openai.com/)达到了令人印象深刻的里程碑,并证明计算机能够写出几乎与人类产生的文本和创造艺术品无差别的文本。最终,这可能导致营销、客户支持、插图等职业发生巨大变化,因为创造力被外包给了能够以比前员工更便宜的价格生产无限材料机器。
在这种情况下,人类可能仍然是必要的,因为事实是,尽管机器达到了如此令人印象深刻的里程碑,但它们在彻底理解问题或理解工作如何应用于现实世界目标的能力上仍然相对有限。学习算法没有方向就是纯粹的知识力量。一台电脑可能在发现大型数据库中的微妙模式方面比人类更有能力,但它仍然需要人类来激发分析并使结果转化为有意义的行动。在大多数情况下,人类将决定机器的输出是否有价值,并帮助机器避免创造出无限的无用之物。
在完全不考虑 Deep Blue 和 Watson 的成就之前,重要的是要注意,它们甚至都不如一个典型的五岁儿童聪明。关于为什么“比较智能是一个滑稽的业务”的更多内容,请参阅《大众科学》杂志的 FYI 文章,威尔·格鲁内沃尔德撰写的《哪个电脑更聪明,Watson 还是 Deep Blue?》,2012 年:www.popsci.com/science/article/2012-12/fyi-which-computer-smarter-watson-or-deep-blue。
机器不擅长提问,甚至不知道该问什么问题。只要问题是以计算机能够理解的方式提出的,它们在回答问题方面就表现得更好。当今的机器学习算法与人类的合作方式类似于猎犬与其训练师的合作:猎犬的嗅觉可能比其主人强许多倍,但如果没有得到仔细的指导,猎犬可能会最终追逐自己的尾巴。

图 1.2:机器学习算法是强大的工具,需要谨慎的指导
在最坏的情况下,如果机器学习被粗心大意地实施,可能会导致有争议的技术亿万富翁埃隆·马斯克挑衅性地称之为“召唤恶魔”的情况。这种观点表明,我们可能正在释放我们无法控制的力量,尽管我们有自大的感觉,认为在需要时我们能够控制它们。鉴于人工智能自动化流程和快速、客观地应对变化条件的能力,可能有一天潘多拉的盒子已经被打开,我们难以或无法回到人类掌控的旧生活方式。正如马斯克所描述的:
“如果人工智能有一个目标,而人类恰好挡了路,它将毫不犹豫地毁灭人类,甚至不会考虑这一点。没有恶意……就像我们正在修建一条道路,而一只蚂蚁窝恰好挡了路,我们并不恨蚂蚁,我们只是在修建道路,所以,再见,蚂蚁窝。”
虽然这看起来可能是一种悲观的表现,但它仍然是遥远未来的科幻领域,正如你将在阅读关于当今最先进的机器学习成功案例时很快就会了解到的那样。
然而,马斯克的警告确实有助于强调理解机器学习和人工智能可能成为双刃剑的重要性。尽管它有诸多益处,但仍有一些地方需要改进,有些情况下它可能弊大于利。如果机器学习从业者不能被信任以道德行事,政府可能需要介入以防止对社会造成最大的伤害。
关于马斯克对“召唤恶魔”的恐惧,请参阅以下 2018 年 CNBC 的文章:www.cnbc.com/2018/04/06/elon-musk-warns-ai-could-create-immortal-dictator-in-documentary.html。
机器学习成功案例
机器学习最成功的时候是当它增强某个领域专家的专业知识,而不是完全取代专家。它与在癌症治疗前沿的医生合作;帮助工程师创造更智能的家庭和汽车;帮助社会科学家和经济学家构建更好的社会;并为商业和市场营销专业人士提供有价值的见解。为了这些目标,它在无数的实验室、医院、公司和政府机构中得到应用。任何生成或聚合数据的努力都可能至少使用一种机器学习算法来帮助理解这些数据。
尽管无法列出机器学习的每一个成功应用,以下是一些突出的例子:
-
在电子邮件中识别不受欢迎的垃圾邮件
-
针对广告的目标客户行为细分
-
天气和长期气候变化的预测
-
对可能流失(停止购买)的客户进行预防性干预
-
减少欺诈信用卡交易
-
飓风和自然灾害造成的财务损失精算估计
-
预测和影响选举结果
-
开发用于无人机自动驾驶和自动驾驶汽车的算法
-
优化家庭和办公建筑中的能源使用
-
预测犯罪活动最可能发生的区域
-
发现对精准医学有用的遗传序列
在这本书的结尾,你将了解用于教会计算机执行这些任务的基本机器学习算法。现在,只需说无论在什么情况下,基本的机器学习过程都是相同的。在每一项任务中,算法都会处理数据并识别出形成进一步行动基础的模式。
机器学习的局限性
尽管机器学习被广泛使用并且具有巨大的潜力,但了解其局限性很重要。今天使用的算法——甚至那些处于人工智能前沿的算法——模仿的是人类大脑能力的一个相对有限的子集。它们在严格参数之外进行外推的能力有限,而且不知道常识。考虑到这一点,在将算法释放到现实世界之前,应该非常小心地识别算法到底学到了什么。
没有过去一生的经验作为基础,计算机在做出关于逻辑下一步简单推断的能力有限。考虑一下网站上的横幅广告,这些广告是根据挖掘数百万用户浏览历史学习到的模式提供的。根据这些数据,查看销售床垫的网站的人对购买床垫感兴趣,因此应该看到床垫的广告。问题是,这变成了一个永无止境的循环,即使床垫已经购买,还会显示额外的床垫广告,而不是枕头和床单的广告。
许多人熟悉机器学习在理解或翻译语言、识别语音和手写方面的不足。或许这种类型失败的最早例子出现在 1994 年电视节目《辛普森一家》的一集中,该集展示了苹果 Newton 平板电脑的讽刺模仿。在当时,Newton 因其最先进的笔迹识别技术而闻名。不幸的是,对于苹果来说,它偶尔会以极大的效果失败。电视集通过一系列场景来展示这一点,其中一个小霸王给“打倒马丁”的便条被 Newton 误解释为“吃掉玛莎”。

图 1.3:《辛普森一家,丽莎在冰上,20 世纪福克斯》(1994)屏幕截图
自从苹果 Newton 以来,机器语言处理已经改进了很多,以至于谷歌、苹果和微软都对其提供语音激活的虚拟礼宾服务的能力充满信心,例如谷歌助手、Siri 和 Cortana。尽管如此,这些服务通常难以回答相对简单的问题。此外,在线翻译服务有时会误解一个幼儿都能轻易理解的句子,而许多设备上的预测文本功能导致了幽默的“自动更正失败”网站,这些网站展示了计算机理解基本语言的能力,但完全误解了上下文。
一些错误是可以预见的。语言很复杂,有多个文本层次和隐含意义,即使是人类有时也会误解上下文。尽管机器学习在语言处理方面正在迅速改进,并且与上一代相比,当前最先进的算法如 GPT-3 相当出色,但机器仍然会犯人类一眼就能看出的错误。这些可预测的不足说明了这样一个重要的事实:机器学习的好坏取决于它所学习的数据。如果输入数据中的上下文不是明确的,那么就像人类一样,计算机将不得不从它过去的一套经验中做出最好的猜测。然而,计算机的过去经验通常比人类的要有限得多。
机器学习伦理
在其核心,机器学习仅仅是一个帮助我们理解世界复杂数据的工具。像任何工具一样,它可以被用于善或恶。机器学习出错大多是因为它被广泛或粗心地应用,以至于人类被视为实验室老鼠、自动机或无意识的消费者。一个看似无害的过程,当由无情的计算机自动化时,可能会导致意想不到的后果。因此,使用机器学习或数据挖掘的人如果不至少简要考虑这一技术的伦理影响,就会犯错误。
由于机器学习作为一个学科相对较年轻,且其发展速度非常快,相关的法律问题和社交规范往往相当不确定,并且不断变化。在获取或分析数据时应该谨慎行事,以避免违法、违反服务条款或数据使用协议,或者滥用信任或侵犯顾客或公众的隐私。谷歌这家可能收集比任何其他组织更多个人数据的公司,曾经有一句非正式的企业座右铭是:“不要做坏事。”虽然这似乎足够明确,但可能还不够。更好的方法可能是遵循希波克拉底誓言,这是一项医学原则,声明“首先,不要造成伤害”。遵循“不造成伤害”的原则可能有助于避免 Facebook 和其他公司最近的丑闻,例如剑桥分析公司的争议,该争议声称社交媒体数据被用于操纵选举。
零售商通常使用机器学习进行广告、定向促销、库存管理或商店商品布局。许多零售商在结账通道配备了根据顾客购买历史打印促销优惠券的设备。顾客为了换取一些个人数据,可以享受他们想要购买的具体产品的折扣。起初,这可能看起来相对无害,但考虑一下当这种做法更进一步时会发生什么。
一则可能被误传的故事涉及美国一家大型零售商,该零售商使用机器学习来识别即将成为母亲的顾客以便发送优惠券。零售商希望,如果这些准妈妈们收到大幅折扣,她们将成为忠诚的客户,并在以后购买利润丰厚的商品,如尿布、婴儿配方奶粉和玩具。借助机器学习方法,零售商识别出顾客购买历史中可用于高度预测女性是否怀孕以及宝宝预产期的大致时间的商品。
在零售商使用这些数据进行促销邮件发送后,一位愤怒的男士联系了该连锁店,要求知道为什么他的年轻女儿收到了孕妇用品的优惠券。他非常愤怒,因为零售商似乎在鼓励少女怀孕!据故事所说,当零售连锁店打电话表示道歉时,最终是那位父亲在质问女儿并发现她确实怀孕后道歉的!
关于零售商如何使用机器学习来识别怀孕的更多细节,请参阅查尔斯·杜希格 2012 年撰写的《纽约时报杂志》文章《公司如何了解你的秘密》:www.nytimes.com/2012/02/19/magazine/shopping-habits.html。
无论这个故事是否完全真实,从前面故事中学到的教训是,在盲目应用机器学习分析的结果之前,应该先应用常识。这在涉及敏感信息,如健康数据的情况下尤其如此。如果零售商稍微多加小心,本可以预见这种情景,并在选择如何揭示其机器学习分析发现的怀孕状态时更加谨慎。不幸的是,正如历史往往会重演一样,社交媒体公司最近因针对怀孕的母亲投放婴儿产品广告而受到批评,即使这些母亲经历了流产的悲剧。
由于机器学习算法是用历史数据开发的,计算机可能会学会一些人类社会的不幸行为。遗憾的是,这有时包括持续种族或性别歧视和强化负面刻板印象。例如,研究人员发现,谷歌的在线广告服务更有可能向男性展示高薪工作的广告,而不是女性,更有可能向黑人展示犯罪背景调查的广告,而不是白人。尽管机器可能正确地学会了男性曾经担任的工作通常不向大多数女性提供,但让算法持续这种不公正是不理想的。相反,可能有必要教会机器反映的不是社会当前的样子,而是它应该成为的样子。
有时,那些旨在“内容中立”的专门设计的算法最终却反映了不受欢迎的信念或意识形态。在一个令人震惊的案例中,微软开发的一个 Twitter 聊天机器人服务在开始传播纳粹和反女权主义宣传后,很快就被关闭了,这些宣传可能来自所谓的“网络暴民”在互联网论坛和聊天室中发布的煽动性内容。在另一个案例中,一个旨在反映人类美的客观概念的算法在它几乎只偏向白人时引发了争议。想象一下,如果这种情况被应用于犯罪活动的面部识别软件,会有什么后果!
有关机器学习和歧视的实际情况,请参阅 2019 年迈克尔·李在《哈佛商业评论》上发表的文章《解决算法中的偏见》:hbr.org/2019/05/addressing-the-biases-plaguing-algorithms。
为了限制算法非法歧视的能力,某些司法管辖区有良好的意图的法律,禁止出于商业原因使用种族、民族、宗教或其他受保护类别数据。然而,排除这些数据可能不足以解决问题,因为机器学习算法仍然可能无意中学会歧视。如果某些人群倾向于居住在某个地区,购买某种产品或以其他方式以独特的方式识别他们作为一个群体,机器学习算法可以从其他因素中推断出受保护的信息。在这种情况下,您可能需要通过排除任何可能识别的数据来完全去识别这些人,除了已经受保护的状态之外。
在最近的一个此类所谓算法偏见案例中,2019 年推出的苹果信用卡几乎立即被指控为向男性提供的信用额度显著高于女性——有时高达 10 到 20 倍,即使对于有共同资产和相似信用记录的配偶也是如此。尽管苹果和发行银行高盛否认存在性别偏见,并确认算法中没有使用受法律保护的申请人特征,但这并没有减缓人们对于可能无意中渗入了一些偏见的猜测。由于竞争原因,苹果和高盛选择保密算法的细节,这导致人们做出了最坏的假设。如果系统性的偏见指控是不真实的,能够解释真正发生的事情以及决策的确切方式可能会减轻大部分的愤怒。如果苹果和高盛因算法的复杂性而受到调查却无法向监管机构解释结果,那么可能出现的最坏情况就是如此!
苹果信用卡的丑闻在 2019 年 BBC 的一篇文章中有描述,标题为美国监管机构调查苹果的“性别歧视”信用卡:www.bbc.com/news/business-50365609。
除了法律后果之外,如果人们认为私密的个人生活被公之于众,他们可能会感到不舒服或变得沮丧。挑战在于人们对隐私的期望在不同的人和情境中是不同的。为了说明这一点,想象一下开车经过某人的房子,偶然瞥了一眼窗户。这不太可能冒犯大多数人。相比之下,从街对面用相机拍照可能会让大多数人感到不舒服;走到房子前,将脸贴在玻璃上窥视可能会让几乎每个人都感到愤怒。尽管这三个场景都可以说是使用了“公共”信息,但其中两个已经越过了大多数人会感到不舒服的界限。同样,在使用数据时,也有可能做得太过分,越过许多人认为至少是不考虑他人感受,最坏的情况下是令人毛骨悚然的界限。
正如计算硬件和统计方法开启了大数据时代一样,这些方法也开启了一个后隐私时代,在这个时代,我们生活中曾经私密的许多方面现在都是公开的,或者以一定的价格对公众开放。在大数据时代之前,通过观察公开信息,就有可能了解某人的很多情况。观察他们的来去可能揭示他们的职业或休闲活动,而快速查看他们的垃圾桶和回收箱可能揭示他们的饮食、饮料和阅读习惯。私人侦探通过一些专注的挖掘和观察,甚至可以了解更多信息。应用机器学习方法处理大数据集的公司本质上是在充当大规模的私人侦探,尽管他们声称正在处理匿名数据集,但许多人仍然认为这些公司在数字监控方面已经做得太过分了。
近年来,一些高调的 Web 应用经历了大量用户流失,这些用户在应用的服务条款协议变更或他们的数据被用于超出他们最初意图的目的时感到被利用。隐私期望因情境、年龄群体和地区而异,这增加了决定个人数据适当使用方式的复杂性。在开始你的项目之前,除了意识到越来越严格的法规,如欧盟的通用数据保护条例(GDPR)以及其后的必然政策,考虑你工作的文化影响也是明智的。
你可以使用数据达到特定目的的事实,并不总是意味着你应该这样做。
最后,值得注意的是,随着机器学习算法在我们日常生活中的重要性日益增加,恶意行为者利用它们的动机也更大。有时,攻击者只是想为了乐趣或名声而破坏算法——比如“谷歌炸弹”,这是一种众包方法,通过欺骗谷歌的算法将一个页面推到很高的排名。有时,影响更为显著。一个及时的例子是最近一波所谓的假新闻和选举干预,这是通过操纵针对人们个性的广告和推荐算法来传播的。为了避免将这样的控制权交给外部人士,在构建机器学习系统时,考虑它们可能受到一个坚定个人或群体的何种影响是至关重要的。
社交媒体学者 danah boyd(名字以小写形式呈现)在纽约市的 Strata 数据大会上发表了一次主题演讲,讨论了加强机器学习算法以抵御攻击者的重要性。若要回顾,请参阅points.datasociety.net/your-data-is-being-manipulated-a7e31a83577b。
对机器学习算法的恶意攻击的后果也可能是致命的。研究人员已经表明,通过创建一个“对抗性攻击”,巧妙地扭曲一个带有精心选择的涂鸦的交通标志,攻击者可能会使自动驾驶汽车错误地解读停车标志,可能导致致命的事故。即使没有恶意,软件漏洞和人为错误也已经导致 Uber 和特斯拉的自动驾驶汽车技术发生了致命事故。考虑到这样的例子,机器学习从业者担心他们的算法在现实世界中的使用和滥用,这是极其重要和道德上的关注。
机器是如何学习的
机器学习的正式定义,归功于计算机科学家汤姆·M·米切尔,指出机器学习是指每当它利用其经验,使其在未来类似经验中的表现得到改善时,它就进行了学习。尽管这个定义在直觉上是有意义的,但它完全忽略了经验如何转化为未来行动的具体过程——当然,学习总是说起来容易做起来难!
人类大脑天生具有从出生就学习的条件,而计算机学习的必要条件必须由希望利用机器学习方法的程序员明确制定。因此,尽管理解学习理论的基础不是严格必要的,但拥有强大的理论基础有助于从业者理解、区分和实现机器学习算法。
当你将机器学习与人类学习联系起来时,你可能会以不同的方式审视自己的思维。
无论学习者是人还是机器,基本的学习过程都是相同的。它可以分为四个相互关联的组成部分:
-
数据存储利用观察、记忆和回忆来为进一步的推理提供事实基础。
-
抽象涉及将存储的数据转换为更广泛的表现和概念。
-
泛化使用抽象数据来创建知识,并在新的环境中驱动行动。
-
评估提供了一个反馈机制来衡量学习到的知识的效用,并告知潜在的改进。

图 1.4:学习过程中的四个步骤
虽然在这里将学习过程概念化为四个不同的组成部分,但它们只是为了说明目的而这样组织的。实际上,整个学习过程是密不可分的。在人类身上,这个过程是潜意识发生的。我们在心中回忆、演绎、归纳和直觉,因为这个过程是隐藏的,所以任何人与人之间的差异都被归因于一种模糊的主观性概念。相比之下,计算机使这些过程变得明确,因为整个过程是透明的,所以学习到的知识可以被检查、转移、用于未来的行动,并被视为一种数据“科学”。
数据科学这个流行词汇暗示了数据、机器以及引导学习过程的人之间的关系。这个术语在职位描述和学术学位项目中的日益普及反映了它作为一个研究领域的实现,这个领域既涉及统计和计算理论,也涉及使机器学习和其应用成为可能的技术基础设施。这个领域通常要求其从业者成为有说服力的讲故事者,在数据的使用上保持大胆,同时考虑到从数据中推断和预测的限制。因此,要成为一名强大的数据科学家,需要深入了解学习算法在商业应用中的工作原理,正如我们将在第十一章“利用机器学习取得成功”中更详细地讨论的那样。
数据存储
所有的学习都始于数据。人类和计算机一样,利用数据存储作为更高级推理的基础。在人类身上,这包括一个使用生物细胞网络中的电化学信号来存储和处理观察结果以供短期和长期回忆的大脑。计算机具有类似的短期和长期回忆能力,使用硬盘驱动器、闪存以及随机存取存储器(RAM)与中央处理单元(CPU)相结合。
这可能看起来很明显,但仅仅存储和检索数据的能力对于学习来说是不足够的。存储的数据在磁盘上仅仅是零和一。它是一系列记忆的集合,如果没有更广泛的环境,这些记忆是没有意义的。没有更高层次的理解,知识仅仅是回忆,局限于之前所见,而别无其他。
为了更好地理解这个想法的细微差别,可以思考一下你上次为艰难的考试做准备的情况,可能是大学的期末考试或职业认证考试。你是否希望拥有一种摄影般的记忆?如果是这样,你可能会对完美回忆不太有帮助而感到失望。即使你能够完美地记住材料,这种死记硬背的学习如果没有知道考试中会出现的确切问题和答案,也不会带来任何好处。否则,你需要记住可能被问到的问题的答案,而这个问题领域可能有无穷多个问题。显然,这是一种不可持续的战略。
相反,更好的方法是花时间有选择性地记忆相对较小的一组代表性想法,同时理解这些想法如何与不可预见的情况相关联和应用。这样,可以识别出重要的更广泛的模式,而不是记住每一个细节、细微差别和潜在的应用。
抽象
这种将存储数据赋予更广泛意义的工作发生在 抽象 过程中,在这个过程中,原始数据开始代表更广泛、更抽象的概念或想法。这种类型的连接,比如一个物体与其表示之间的连接,可以通过著名的雷内·马格利特画作 图像的背叛 来举例说明。

图 1.5:“这不是一根烟斗。”来源:http://collections.lacma.org/node/239578
这幅画描绘了一根烟斗,标题为 Ceci n’est pas une pipe (“这不是一根烟斗”)。马格利特想要说明的是,对烟斗的描绘并不真正是一根烟斗。尽管烟斗不是真实的,但任何观看这幅画的人都能轻易地认出它是一根烟斗。这表明观察者可以将烟斗的 画面 与烟斗的 概念 相连接,与可以握在手里的 物理 烟斗的记忆相连接。这种抽象的连接是 知识表示 的基础,即形成逻辑结构以帮助将原始感官信息转化为有意义的洞察。
将这一概念完整地呈现出来,知识表示是使基于人工智能的工具如 Midjourney (www.midjourney.com) 能够在虚拟中模仿雷内·马格利特风格作画的基础。以下图像完全由人工智能根据算法对“机器人”、“管道”和“吸烟”等概念的理解生成。如果他今天还活着,马格利特本人可能会觉得他的超现实主义作品,挑战了人类对现实和图像与思想之间联系的认识,现在被纳入了计算机的头脑中,并以一种迂回的方式将机器的思想和图像与现实联系起来。机器通过观察马格利特艺术作品中的管道图像,部分地学习了什么是管道。

图 1.6:“我是管道吗?”由 Midjourney AI 根据提示“以雷内·马格利特风格画一个吸烟的机器人”创建的图像
为了在算法中具体化知识表示的过程,计算机使用模型来总结存储的原始数据,这是一个对数据中模式的具体描述。就像马格利特的管道一样,模型表示超越了原始数据本身。它代表了一个比其各部分总和更大的想法。
有许多不同类型的模型。你可能已经熟悉其中的一些。例如包括:
-
数学方程式
-
关系图,例如树和图
-
逻辑 if/else 规则
-
被称为聚类的数据分组
模型的选择通常不会留给机器。相反,学习任务和手头的数据类型决定了模型的选择。在本章的后面部分,我们将更详细地讨论选择适当模型类型的方法。
将模型拟合到数据集的过程被称为训练。当模型经过训练后,数据已经被转换成一种抽象形式,总结了原始信息。这一步骤被称为“训练”而不是“学习”,揭示了该过程的一些有趣方面。首先,请注意,学习过程并不以数据抽象结束——学习者必须仍然进行泛化和评估其训练。其次,“训练”这个词更好地暗示了人类教师训练机器学生使用数据达到特定目的的事实。
训练与学习的区别微妙但很重要。计算机不会“学习”一个模型,因为这会意味着存在一个单一的、正确的模型需要学习。当然,计算机必须从数据中学习某些信息以完成其训练,但它有选择如何或具体学习什么的自由。当使用给定的数据集训练学习者时,每个学习者都会找到自己的方法来建模数据并识别对给定任务有用的模式。
重要的是要注意,一个学习到的模型本身并不提供新的数据,但它确实产生了新的知识。这怎么可能呢?答案是,将假设的结构强加于底层数据,可以洞察未见之处。它假设了一个新概念,描述了现有数据元素之间可能存在的关系。
以万有引力的发现为例。通过将方程拟合到观测数据,艾萨克·牛顿爵士推断出了万有引力的概念,但我们现在所知的万有引力始终存在。它只是直到牛顿将其表达为一个抽象概念,将某些数据与其它数据联系起来——具体来说,通过成为解释物体下落观测的模型中的 g 项——才被认识。物体在不同时间段内下落的距离可以通过一个简单的模型联系起来,该模型将恒定的重力作用力应用于物体每单位时间。

图 1.7:模型是解释观测数据的抽象
大多数模型不会导致数百年来动摇科学思想的理论的产生。然而,你的抽象可能会导致发现重要但之前未见过的数据中的模式和关系。在基因组数据上训练的模型可能会发现几个基因,当它们结合在一起时,是糖尿病发病的原因,银行可能会发现一种看似无害的交易类型,这种交易类型在欺诈活动之前系统性地出现,或者心理学家可能会确定一组表明新疾病的个性特征。这些潜在的模式始终存在,但通过以不同的格式呈现信息,新的想法被概念化。
泛化
学习过程中的第三步是利用抽象知识进行未来的行动。然而,在抽象过程中可能识别出的无数潜在模式和建模这些模式的各种方式中,有些模式会比其他模式更有用。除非抽象的产生仅限于有用的集合,否则学习者将停留在起点,拥有大量信息但没有可操作的洞察力。
正式来说,泛化被定义为将抽象知识转化为可以用于未来对与学习者之前所见相似但又不完全相同任务进行行动的形式的过程。它相当于在整个模型集(即,理论或推断)中搜索,这些模型(即,理论或推断)可能在训练期间从数据中建立。如果你能想象一个假设的集合,包含数据可能被抽象化的所有可能方式,泛化涉及将这个集合缩减为一个更小、更易于管理的具有重要发现集合。
在泛化过程中,学习者被要求将其发现的模式限制在其未来任务中最相关的那些。通常,通过逐个检查模式并按未来价值进行排序来减少模式数量是不切实际的。相反,机器学习算法通常采用捷径,以更快地减少搜索空间。为此,算法将采用启发式方法,这是关于在哪里找到最有用推理的合理猜测。人类通常使用启发式方法来快速将经验推广到新场景。如果你曾经在没有完全评估你的情况下,利用你的直觉做出快速决定,那么你就是在直觉上使用心理启发式方法。
启发式方法利用近似和其他经验法则,这意味着它们不能保证找到数据最佳模型。然而,如果不采取这些捷径,在大型数据集中找到有用信息将是不切实际的。
人类做出快速决策的惊人能力往往不是依赖于计算机般的逻辑,而是依赖于情绪引导的启发式方法。有时,这可能导致不合理的结论。例如,更多的人表达了对航空旅行的恐惧,而不是汽车旅行,尽管从统计数据上看汽车更危险。这可以通过可用性启发式来解释,即人们倾向于通过例子可以多容易地回忆起来来估计事件的可能性。涉及航空旅行的意外事件被高度公开报道。作为创伤性事件,它们很容易被回忆起来,而汽车事故几乎不值得报纸提及。
错误应用启发式方法的愚蠢不仅限于人类。机器学习算法采用的启发式方法有时也会导致错误的结论。如果结论是系统性地错误的,则称算法具有偏差,这意味着它们以一致或可预测的方式错误。例如,假设一个机器学习算法通过找到代表眼睛的两个圆圈,位于表示嘴巴的直线之上来学习识别面部。该算法可能难以处理,或对不符合其模型的面部有偏见。戴眼镜、倾斜、侧视或具有特定肤色的人脸可能无法被算法检测到。同样,它可能对符合其世界理解的其他肤色、面部形状或特征有偏见。

图 1.8:将学习者的经验结果泛化的过程会导致偏差
在现代用法中,单词“偏见”已经带有相当负面的含义。各种形式的媒体经常声称自己没有偏见,并声称客观地报告事实,不受情感的影响。然而,考虑一下这种可能性:一点偏见可能是有用的。如果没有一点任意性,是否在几个具有不同优势和劣势的竞争选择之间做出决定会有些困难?事实上,心理学领域的研究表明,大脑中负责情感的部分受损的个体可能在决策上无效,可能会花费数小时争论简单的决定,比如穿什么颜色的衬衫或在哪里吃午餐。
令人 paradoxically,偏见正是让我们无法看到某些信息,同时又能让我们利用其他信息采取行动的原因。这就是机器学习算法在理解一组数据时,在无数种方式中选择的方法。
评估
偏见是与任何学习任务中固有的抽象和泛化过程相关的一个必要的邪恶。为了在无限的可能性面前驱动行动,所有学习都必须有偏见。因此,学习过程中的最后一步是评估其成功,并衡量学习者的表现,尽管存在偏见。在评估阶段获得的信息可以用来告知额外的训练,如果需要的话。
一旦你在一种机器学习技术上取得了成功,你可能会倾向于将其应用于每个任务。重要的是要抵制这种诱惑,因为没有任何机器学习方法适合所有情况。这一事实由 David Wolpert 在 1996 年提出的 没有免费午餐 定理所描述。基于流行的格言“没有免费的午餐”,该定理表明,每个决策都有成本或权衡——这是一个在机器学习之外也普遍适用的生活教训!更多信息,请访问:www.no-free-lunch.org。
通常,评估发生在模型在初始 训练数据集 上训练之后。然后,该模型在单独的 测试数据集 上进行评估,以判断其对训练数据的描述如何推广到新的、未见过的案例。值得注意的是,一个模型完美地推广到每个不可预见的情况是非常罕见的——错误几乎是不可避免的。
在一定程度上,模型无法完美推广的原因是由于 噪声 的问题,这是一个描述数据中未解释或无法解释的变动的术语。噪声数据是由看似随机的事件引起的,例如:
-
由于有时会从读数中添加或减去一小部分的不精确传感器导致的测量误差
-
与人类受试者相关的问题,例如调查受访者为了更快地完成调查而随机回答问题
-
数据质量问题,包括缺失、空值、截断、编码错误或损坏的值
-
那些复杂或理解程度较低的现象,它们以看似随机的方式影响数据
建模噪声是称为过拟合的问题的基础;因为大多数噪声数据按定义是无法解释的,试图解释噪声将导致无法很好地推广到新案例的模型。解释噪声的努力通常还会导致更复杂的模型,这些模型错过了学习者试图识别的真正模式。

图 1.9:建模噪声通常会导致模型更加复杂,并错过潜在的规律
在训练期间表现相对良好但在评估期间表现相对较差的模型被称为对训练数据集过拟合,因为它无法很好地推广到测试数据集。在实践中,这意味着它已经识别出数据中的模式,这些模式对未来的行动没有用;泛化过程失败了。解决过拟合问题的方法因机器学习方法而异。目前,重要的是要意识到这个问题。方法处理噪声数据并避免过拟合的能力是它们之间的重要区别点。
实践中的机器学习
到目前为止,我们一直关注机器学习在理论上的工作方式。为了将学习过程应用于现实世界的任务,我们将使用一个五步法。无论任务是什么,每个机器学习算法都使用以下一系列步骤:
-
数据收集:数据收集步骤涉及收集算法将用于生成可操作知识的学习材料。在大多数情况下,数据需要合并成一个单一的资源,例如文本文件、电子表格或数据库。
-
数据探索和准备:任何机器学习项目的质量在很大程度上取决于其输入数据的质量。因此,了解数据及其细微差别非常重要。数据准备包括修复或清理所谓的“杂乱”数据,消除不必要的数据,并将数据重新编码以符合学习者的预期输入。
-
模型训练:在数据准备分析之前,你可能会对从数据中希望和能够学习到什么有一个感觉。所选择的特定机器学习任务将告知选择合适的算法,算法将以模型的形式表示数据。
-
模型评估:每个机器学习模型都导致对学习问题的一个有偏的解决方案,这意味着评估算法从其经验中学习得如何非常重要。根据所使用的模型类型,您可能能够使用测试数据集评估模型的准确性,或者您可能需要开发针对预期应用的特定性能指标。
-
模型改进:如果需要更好的性能,就需要利用更高级的策略来增强模型的表现。有时可能需要完全切换到另一种类型的模型。你可能需要补充数据或执行额外的准备工作,就像在这个过程中第 2 步所做的那样。
完成这些步骤后,如果模型看起来表现良好,它可以部署到预期的任务中。你可能可以利用你的模型为预测提供评分数据(可能是实时);用于财务数据的预测;为营销或研究生成有用的见解;或自动化任务,如邮件投递或飞行飞机。部署的模型的成功与失败甚至可能为训练下一代学习器提供额外的数据。
输入数据类型
机器学习的实践涉及将输入数据的特征与现有学习算法的偏差相匹配。因此,在将机器学习应用于现实世界问题之前,了解区分输入数据集的术语非常重要。
术语观察单位用于描述研究感兴趣的最小实体,其具有可测量的属性。通常,观察单位以个人、物体或事物、交易、时间点、地理区域或测量的形式存在。有时,观察单位会组合成单位,如人年,表示在多年内跟踪同一人,每人年包含一个人一年的数据。
观察单位与分析单位相关,但并不相同,分析单位是从中得出推论的 smallest unit。尽管通常情况下观察单位和分析单位是相同的,但并不总是如此。例如,从人们(观察单位)观察到的数据可能用于分析跨国的趋势(分析单位)。
存储观察单位和其属性的数据库可以描述为以下集合:
-
示例:记录了属性的观察单位的实例
-
特征:记录的示例属性或特征,可能对学习有用
通过现实世界的场景最容易理解特征和示例。例如,为了构建一个用于识别垃圾邮件的学习算法,观察单位可以是电子邮件消息,示例可以是具体的单个消息,而特征可能包括消息中使用的单词。对于一个癌症检测算法,观察单位可以是患者,示例可能包括癌症患者的随机样本,特征可能包括活检细胞的基因组标记,以及患者的特征,如体重、身高或血压。
人和机器在处理输入数据中的复杂性的类型上有所不同。人类在消费非结构化数据,如自由形式的文本、图片或声音时感到舒适。他们也在处理某些观察具有大量特征,而其他观察则很少特征的情况时表现出灵活性。另一方面,计算机通常需要数据是结构化的,这意味着现象的每个例子都有完全相同的特征集,并且这些特征以计算机可以理解的形式组织。在大型非结构化数据集上使用机器的蛮力通常需要将输入数据转换为结构化形式。
下面的电子表格显示了以矩阵格式收集的数据。在矩阵数据中,每一行是一个例子,每一列是一个特征。在这里,行表示待售汽车的例子,而列记录汽车的特征,如价格、里程、颜色和变速类型。矩阵格式数据是机器学习中使用的最常见形式。正如你将在后面的章节中看到的,当在特定应用中遇到非结构化数据形式时,它们最终在机器学习之前被转换为结构化矩阵格式。

图 1.10:描述待售汽车的简单数据集矩阵格式
一个数据集的特征可能以各种形式出现。如果一个特征代表的是用数字测量的特性,那么它不出意外地被称为数值型。另一方面,如果一个特征由一系列类别组成,那么这个特征被称为分类型或名义型。一种特殊的分类型特征被称为有序型,它指定了一个具有有序列表中类别的名义型特征。有序型特征的一个例子是服装尺码,如小号、中号和大号;另一个例子是客户满意度的测量,从“完全不高兴”到“有点高兴”再到“非常高兴”。对于任何给定的数据集,思考特征代表什么,它们的类型和单位,将有助于确定适合学习任务的适当机器学习算法。
机器学习算法的类型
机器学习算法根据其目的被分为不同的类别。了解学习算法的类别是使用数据驱动所需行动的第一步。
预测模型用于涉及预测一个值(正如其名称所暗示的)使用数据集中的其他值的任务。学习算法试图发现并建模目标特征(被预测的特征)与其他特征之间的关系。尽管“预测”一词常用来暗示预测,但预测模型不一定必须预见未来的事件。例如,预测模型可以用来预测过去的事件,例如,使用母亲的当前激素水平预测婴儿的受孕日期。预测模型还可以在实时中用于控制高峰时段的交通信号灯。
由于预测模型被赋予了明确的指令,告诉它们需要学习什么以及如何学习,因此训练预测模型的过程被称为监督学习。这种监督并不涉及人类的参与,而是指目标值提供了一种让学习器知道它学习所需任务的程度的方法。更正式地说,给定一组数据,监督学习算法试图优化一个函数(模型),以找到最佳的特征值组合,从而在训练数据的所有行中产生目标输出。
经常使用的监督机器学习任务,预测一个例子属于哪个类别,被称为分类。很容易想到分类器的潜在用途。例如,你可以预测:
-
一封电子邮件是垃圾邮件
-
一个人患有癌症
-
一支足球队将赢或输
-
一个申请人将违约贷款
在分类中,要预测的目标特征被称为类别,它被分为称为级别的类别。一个类别可以有两个或更多级别,这些级别可能是有序的,也可能不是。分类在机器学习中应用如此广泛,以至于有各种类型的分类算法,它们具有针对不同类型输入数据的优势和劣势。我们将在本章后面和整本书的许多地方看到这些例子。分类的第一个实际应用出现在第三章,懒惰学习 - 使用最近邻进行分类,其他例子还出现在第四章,概率学习 - 使用朴素贝叶斯进行分类,以及第五章,分而治之 - 使用决策树和规则进行分类等。
监督学习器也可以用来预测数值数据,如收入、实验室值、考试成绩或物品计数。为了预测此类数值,一种常见的数值预测形式是将线性回归模型拟合到输入数据。尽管回归不是数值预测的唯一方法,但它是最广泛使用的方法。回归方法广泛用于预测,因为它们以精确的术语量化了输入和目标之间的关联,包括关系的幅度和不确定性。许多监督学习算法可以执行数值预测,但回归方法和数值预测在第六章预测数值数据——回归方法中有详细说明。
由于将数字转换为类别(例如,13 至 19 岁的年龄是青少年)和将类别转换为数字(例如,将所有男性分配为 1,所有女性分配为 0)很容易,因此分类模型和数值预测模型之间的边界并不一定明确。
描述性模型用于那些从以新颖有趣的方式总结数据中获益的任务。与预测模型预测感兴趣的目标不同,在描述性模型中,没有单个特征是特别感兴趣的。因为没有要学习的目标,训练描述性模型的过程被称为无监督学习。尽管思考描述性模型的应用可能更困难——毕竟,一个没有特定学习内容的学习者有什么用——但它们在数据挖掘中却被相当频繁地使用。
例如,被称为模式发现的描述性建模任务用于在数据中识别有用的关联。模式发现是市场篮子分析的目标,该分析应用于零售商的交易购买数据。在这里,零售商希望识别出经常一起购买的商品,以便所学习的信息可以用来改进营销策略。例如,如果零售商发现泳装和防晒霜通常同时购买,零售商可能会将商品重新定位在商店中更接近的位置,或者进行促销活动来“升级销售”相关商品给顾客。执行此类分析所需的方法包括在第八章发现模式——使用关联规则进行市场篮子分析中。
最初仅用于零售环境,模式发现现在正开始以相当创新的方式被使用。例如,它可以用来检测欺诈行为模式,筛选遗传缺陷,或识别犯罪活动热点。
将数据集划分为同质组的描述性建模任务被称为聚类。这有时用于分割分析,它识别具有相似行为或人口统计信息的个体群体,以便根据他们的共同特征对他们进行广告活动。使用这种方法,机器可以识别集群,但需要人工干预来解释它们。例如,给定一家杂货店的五个客户集群,营销团队需要了解这些群体之间的差异,以便创建最适合每个群体的促销活动。尽管需要人力,但这仍然比为每个客户创造独特吸引力的工作要少。这种类型的分割分析在第九章,寻找数据群组 – 使用 k-means 聚类中进行了演示。
无监督学习还可以用于辅助监督学习任务,在这些任务中,没有标记的数据不可用或获取成本高昂。一种称为半监督学习的方法结合少量标记数据和未监督学习分析来帮助分类未标记的记录,然后可以直接在监督学习模型中使用这些记录。例如,如果医生标记肿瘤样本为癌症或非癌症很昂贵,只有一小部分患者记录可能有这些标签。然而,在执行患者数据的未监督聚类后,可能的情况是已确认的癌症和非癌症患者主要属于不同的群体,因此未标记的记录可以继承其集群的标签。因此,可以在完整的数据集上构建预测模型,而不是在手动标记的小部分数据上。半监督学习的一个应用包括在第九章,寻找数据群组 – 使用 k-means 聚类中。
这种方法的更极端版本被称为自监督学习,它根本不需要任何手动标记的数据;相反,它采用两步法,其中复杂的模型首先尝试在记录中识别有意义的分组,第二个模型则尝试识别组之间的关键区别。这是一种相对较新的创新,主要用于处理大型、非结构化数据源,如音频、文本和图像数据。自监督学习的基本原理在第七章,黑盒方法 – 神经网络和支持向量机,以及第十五章,利用大数据中有所介绍。
最后,一类被称为元学习器的机器学习算法并不局限于特定的学习任务,而是专注于学习如何更有效地学习。元学习对于非常具有挑战性的问题或当预测算法的性能需要尽可能准确时非常有用。所有元学习算法都使用过去学习的成果来指导额外的学习。最常见的是,这包括学习如何协作工作的算法,称为集成。正如互补的技能和积累的经验是人类团队成功的重要因素一样,它们对于机器学习者同样有价值,而集成是今天可用的最强大的现成算法之一。在第十四章,构建更好的学习者中涵盖了几个最受欢迎的集成学习算法。
一种涉及似乎随时间演变的算法的元学习方法被称为强化学习。这种技术涉及一个模拟过程,其中学习者在成功时得到奖励,在失败时受到惩罚,并在多次迭代中寻求最高的累积奖励。算法通过奖励和惩罚的进化压力在模拟的“后代”中积累更多有益的随机适应,并在连续几代中淘汰最无帮助的突变,从而在期望的学习任务上提高性能。
与基于标记数据集训练的监督学习算法相比,强化学习在某个学习任务上的表现甚至可以超过人类训练师,因为它不受限于在现有数据上训练。换句话说,传统的监督学习者倾向于模仿现有数据,但强化学习可以识别任务的新颖和未预见的解决方案——有时甚至令人惊讶或甚至滑稽的结果。例如,训练用于玩电子游戏的强化学习者在完成像《刺猬索尼克》这样的游戏时比人类快得多,但以设计师没有预料到的方式。同样,训练用于驾驶模拟登月舱的学习算法发现,硬着陆比温柔着陆更快——似乎对这种做法可能对幸运的假设人类乘客造成的影响毫不在意!
强化学习技术虽然非常强大,但计算成本很高,并且与之前描述的传统学习方法大相径庭。它通常应用于学习者可以快速且反复执行任务以确定其成功与否的现实世界案例中。这意味着它对预测癌症或业务结果(如客户流失和贷款违约)不太有用,而对典型的人工智能应用(如自动驾驶汽车和其他自动化形式)更有用,在这些应用中,成功或失败很容易衡量,是即时的,并且可以在受控环境中进行模拟。因此,强化学习超出了本书的范围,尽管它是一个值得密切关注的有趣话题。
想了解更多关于强化学习者在意外解决方案中发现的幽默故事,请参阅汤姆·西蒙特的文章,当机器人自己学会欺骗时,见www.wired.com/story/when-bots-teach-themselves-to-cheat/。
显然,目前在机器学习领域进行的许多最激动人心的研究工作都在元学习领域。除了集成学习和强化学习之外,有希望的对抗学习领域涉及了解一个模型的优势以增强其未来的性能或使其对恶意攻击具有抵抗力。这可能包括将算法相互对抗,例如构建一个强化学习算法来生成可以欺骗面部检测算法或识别可以绕过欺诈检测的交易模式的假照片。这只是构建更好的学习算法的一条途径;还有大量投资于研究和开发工作,以制造更大、更快的集成,这些集成可以使用高性能计算机或云计算环境来模拟大量数据集。从基础算法开始,我们将在接下来的章节中涉及许多这些迷人的创新。
将输入数据与算法匹配
以下表格列出了本书涵盖的机器学习算法的一般类型。尽管这仅涵盖了整个学习算法集合的一小部分,但学习这些方法将为理解未来可能遇到的任何其他方法提供足够的基础。
| 模型 | 学习任务 | 章节 |
|---|---|---|
| 监督学习算法 | ||
| k-最近邻 | 分类 | 第三章 |
| 朴素贝叶斯 | 分类 | 第四章 |
| 决策树 | 分类 | 第五章 |
| 分类规则学习器 | 分类 | 第五章 |
| 线性回归 | 数值预测 | 第六章 |
| 回归树 | 数值预测 | 第六章 |
| 模型树 | 数值预测 | 第六章 |
| 逻辑回归 | 分类 | 第六章 |
| 神经网络 | 双用途 | 第七章 |
| 支持向量机 | 双用途 | 第七章 |
| 无监督学习算法 | ||
| 关联规则 | 模式检测 | 第八章 |
| k-means 聚类 | 聚类 | 第九章 |
| 元学习算法 | ||
| Bagging | 双用途 | 第十四章 |
| Boosting | 双用途 | 第十四章 |
| 随机森林 | 双用途 | 第十四章 |
| 梯度提升 | 双用途 | 第十四章 |
要开始将机器学习应用于实际项目,您需要确定您的项目代表哪种四种学习任务:分类、数值预测、模式检测或聚类。任务将驱动算法的选择。例如,如果您正在进行模式检测,您可能会使用关联规则。同样,聚类问题可能会使用 k-means 算法,而数值预测将使用回归分析或回归树。
对于分类,需要更多的思考来将学习问题与适当的分类器相匹配。在这些情况下,考虑算法之间的各种区别是有帮助的——这些区别只有通过深入研究每个分类器才能显现。例如,在分类问题中,决策树产生的模型易于理解,而神经网络模型则因其难以解释而闻名。如果您正在设计信用评分模型,这可能是一个重要的区别,因为法律通常要求申请者必须被告知他们被拒绝贷款的原因。即使神经网络在预测贷款违约方面表现更好,如果其预测无法解释,那么它在这个应用中就毫无用处。
为了帮助选择算法,在每一章中都会列出每个学习算法的关键优势和劣势。尽管有时你会发现这些特性会排除某些模型,但在许多情况下,算法的选择是任意的。当这种情况发生时,请随意使用您最熟悉的算法。在其他时候,当预测准确性是主要目标时,您可能需要测试几个模型,并选择最适合的一个,或者使用结合几个不同学习者的元学习方法来利用每个学习者的优势。
使用 R 进行机器学习
许多用于机器学习的算法并未包含在 R 的基础安装中。相反,这些算法可以通过一个庞大的专家社区获得,他们已经免费分享了他们的工作。这些算法需要手动在基础 R 上安装。由于 R 是免费的开源软件,因此这项功能无需额外收费。
一组可以被用户共享的 R 函数称为包。本书涵盖的每个机器学习算法都有免费的包。实际上,本书只涵盖了 R 所有机器学习包中的一小部分。
如果您对 R 包的广度感兴趣,可以在 Comprehensive R Archive Network (CRAN) 上查看列表,这是一个位于世界各地的网站和 FTP 站点的集合,提供最新版本的 R 软件和包。如果您通过下载获得了 R 软件,它很可能是来自 CRAN。CRAN 网站可在 cran.r-project.org/index.html 访问。
如果您还没有 R,CRAN 网站还提供了安装说明以及如果您遇到困难时如何寻找帮助的信息。
CRAN 页面左侧的 Packages 链接将带您到一个可以按字母顺序或按出版日期排序浏览包的页面。在撰写本文时,共有 18,910 个包可用——比本书第三版撰写时多了 35% 以上,几乎是第二版的三倍,比第一版大约 10 年前多了四倍!显然,R 社区一直在蓬勃发展,而且这种趋势没有放缓的迹象!
CRAN 页面左侧的 Task Views 链接提供了一个按主题区域精选的包列表。机器学习的任务视图,列出了本书中涵盖的包(以及更多),可在 cran.r-project.org/view=MachineLearning 访问。
安装 R 包
尽管有大量的 R 插件可用,但包格式使得安装和使用几乎无需费力。为了演示包的使用,我们将安装并加载由 Gregory R. Warnes 维护的 gmodels 包,该包包含各种辅助模型拟合和数据分析的功能。我们将在本书的许多章节中使用该包的一个功能来比较模型预测与真实值。有关此包的更多信息,请参阅 cran.r-project.org/package=gmodels。
安装包的最直接方法是使用 install.packages() 函数。要安装 gmodels 包,在 R 命令提示符下只需键入:
> install.packages("gmodels")
然后,R 将连接到 CRAN 并下载适合您操作系统的正确格式的包。许多包在使用之前需要安装额外的包。这些被称为 依赖项。默认情况下,安装程序将自动下载并安装任何依赖项。
第一次安装包时,R 可能会要求您选择一个 CRAN 镜像。如果发生这种情况,请选择位于您附近位置的镜像。这通常可以提供最快的下载速度。
默认的安装选项适用于大多数系统。然而,在某些情况下,您可能希望将包安装在其他位置。例如,如果您在系统上没有 root 或管理员权限,您可能需要指定一个替代的安装路径。
这可以通过以下 lib 选项实现:
> install.packages("gmodels", lib = "/path/to/library")
安装功能还提供了从本地文件安装、从源安装或使用实验版本安装的附加选项。您可以通过以下命令在帮助文件中了解这些选项:
> ?install.packages
更普遍地,问号运算符可以用来获取任何 R 函数的帮助。只需在函数名称前输入 ? 即可。
加载和卸载 R 软件包
为了节省内存,R 默认不会加载每个已安装的软件包。相反,当需要时,用户使用 library() 函数来加载软件包。
这个函数的名称使一些人错误地交替使用“库”和“包”这两个术语。然而,为了精确起见,库指的是软件包安装的位置,而不是软件包本身。
要加载之前安装的 gmodels 软件包,您可以输入以下命令:
> library(gmodels)
除了 gmodels 之外,还有许多其他的 R 软件包,这些软件包将在后面的章节中使用。当需要这些软件包时,将提供安装这些软件包的提醒。
要卸载 R 软件包,请使用 detach() 函数。例如,要卸载之前显示的 gmodels 软件包,请使用以下命令:
> detach("package:gmodels", unload = TRUE)
这将释放出该软件包使用的任何资源。
安装 RStudio
在您从 CRAN 网站安装 R 之后,强烈建议您还安装开源的 RStudio 桌面应用程序。RStudio 是 R 的一个附加界面,它包括使使用 R 代码更加容易、方便和互动的功能。
RStudio 开源版可以从 Posit 免费获得(www.posit.co/),同时还有一个付费的 RStudio Pro 版本,为商业组织提供优先支持和附加功能。

图 1.11:RStudio 桌面环境使 R 更易于使用和更方便
RStudio 界面包括一个 集成开发环境(IDE),包括代码编辑器、R 命令行控制台、文件浏览器和 R 对象浏览器。R 代码语法会自动着色,代码的输出、图表和图形会直接在环境中显示,这使得跟踪长或复杂的语句和程序变得更加容易。更高级的功能允许 R 项目和软件包管理;与源代码控制或版本控制工具(如 Git 和 Subversion)的集成;数据库连接管理;以及将 R 输出编译为 HTML、PDF 或 Microsoft Word 格式。甚至可以在 RStudio 中编写 Python 代码!
RStudio 是 R 成为数据科学家今天首选工具的关键原因。它将 R 编程的强大功能和其庞大的机器学习和统计包库封装在一个易于使用和安装的开发界面中。它不仅适合学习 R,还可以随着你对 R 的高级功能的学习而成长。
RStudio 桌面软件是由一家名为 Posit 的公司开发的,该公司本身也曾经被称为 RStudio。这次品牌重塑对 RStudio 的粉丝来说有些令人惊讶,发生在 2022 年底,目的是反映公司对 Python 和 R 的广泛关注。在撰写本文时,桌面 IDE 软件的名称仍然是 RStudio。更多信息,请参阅posit.co/blog/rstudio-is-now-posit/。
为什么选择 R,为什么现在选择 R?
根据你询问的对象,当这本书的第一版在 2013 年出版时,R 在机器学习和现在称为数据科学的用户采用上可能略领先于 Python,如果不是实质性的领先。自那时以来,Python 的使用已经大幅增长,很难否认 Python 是新的领跑者,尽管考虑到 Python 粉丝对这款新工具的热情支持,这场竞赛可能比人们预期的要接近。
当然,在过去的 10 年里,Python 从诸如 scikit-learn 机器学习框架、pandas 数据结构库、Matplotlib 绘图库和 Jupyter 笔记本界面等众多开源库的快速成熟中受益匪浅,这些库使得在 Python 中进行数据科学工作变得比以往任何时候都要容易。当然,这些库仅仅使 Python 与 R 和 RStudio 能够做到的功能相当!然而,这些附加组件与相对快速且内存高效的 Python 代码相结合——至少相对于 R 来说——可能有助于解释为什么 Python 现在无疑是正式数据科学学位课程中最常教授的语言,并且在商业领域得到了迅速的采用。
与其说 R 的兴起预示着 R 的消亡,不如说 Python 的兴起仅仅反映了该领域的发展。事实上,R 的使用也在快速增长,R 和 RStudio 可能比以往任何时候都更受欢迎。尽管学生有时会问是否值得从 R 开始学习而不是直接跳入 Python,但仍有许多很好的理由可以选择用 R 而不是其他选择来学习机器学习。请注意,这些理由相当主观,并没有一个适用于所有人的正确答案,所以我犹豫是否将其写下来!然而,作为支持这本书近十年的人,以及作为在一家大型国际公司的工作中几乎每天都会使用 R 的人,以下是我注意到的一些事情:
-
对于有社会科学或商业背景的人来说(例如经济学、市场营销等),R 可能更直观、更容易学习,而 Python 可能对计算机科学家和其他类型的工程师更有意义。
-
R 通常更像是一个“计算器”,你输入一个命令,然后就会发生一些事情;一般来说,Python 中的编码往往需要更多关于循环和其他程序流程命令的思考(这种区别随着时间的推移以及流行 Python 库的额外功能而逐渐消失)。
-
R 使用相对较少的数据结构类型(其中包含的数据结构是为数据分析量身定制的),常用的类似电子表格的数据格式是内置的数据类型;相比之下,Python 有许多专门的数据结构,并使用 NumPy 或 pandas 等库来处理矩阵数据格式,每种都有其自己的语法需要学习。
-
R 及其包可能比 Python 更容易安装和更新,部分原因在于 Python 默认由某些操作系统管理,而保持依赖项和环境分离具有挑战性(现代 Python 安装工具和包管理器虽然解决了这个问题,但在某些方面也使得问题变得更糟!)。
-
R 在数据操作和遍历大型数据结构方面通常比 Python 慢,并且占用更多内存,但如果数据可以放入内存中,这种差异在一定程度上是可以忽略的;R 在这个领域有所改进(参见第十二章,高级数据准备,了解 R 如何使数据准备更快、更简单的一些方法),对于无法放入内存的数据,有替代方案(如第十五章,利用大数据所述),但这是公认 Python 的一个主要优势。
-
Posit 团队(原名 RStudio)的支持和愿景推动了创新,使得 R 在统一的 RStudio 桌面软件环境中更容易使用、更愉快;相比之下,Python 的创新正在多个方面发生,提供了更多“正确”的方法来完成同一件事(无论好坏)。
希望上述原因能让你对开始 R 之旅充满信心!从这里开始并不丢人,无论你是否长期使用 R,还是将其与其他语言如 Python 并行使用,或者完全转向其他东西,你在本书中学到的基本原理都将转移到你选择的任何工具上。尽管本书的代码是用 R 编写的,但强烈建议你使用最适合工作的工具,无论你感觉那是什么。你可能发现,正如我自己所发现的那样,R 和 RStudio 是许多现实世界数据科学和机器学习项目的首选工具——即使你偶尔也会利用 Python 的独特优势!
摘要
机器学习起源于统计学、数据库科学和计算机科学的交汇点。它是一个强大的工具,能够在大量数据中找到可操作见解。然而,正如我们在本章所看到的,为了避免在现实世界中常见的机器学习滥用,我们必须谨慎行事。
从概念上讲,学习过程涉及将数据抽象成结构化的表示,并将结构泛化成可以评估其效用的行动。在实践中,机器学习者使用包含待学习概念示例和特征的示例数据,然后以模型的形式总结这些数据,该模型用于预测或描述目的。这些目的可以归纳为包括分类、数值预测、模式检测和聚类在内的任务。在众多可能的方法中,机器学习算法是根据输入数据和学习任务来选择的。
R 语言提供了社区编写的机器学习包的支持。这些强大的工具可以免费下载,但在使用之前需要安装。本书的每一章都会在需要时介绍这些包。
在下一章中,我们将进一步介绍用于管理数据以供机器学习使用的基本 R 命令。虽然你可能想跳过这一步直接进入应用,但一个常见的经验法则是,在典型的机器学习项目中,80%或更多的时间都花在了数据准备阶段,也称为“数据整理”。因此,投入一些努力学习如何有效地进行这项工作将为你带来回报。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人见面,并在以下链接处与超过 4000 人一起学习:

第二章:管理和理解数据
任何机器学习项目的关键早期组成部分都涉及管理和理解数据。尽管这可能不如构建和部署模型——你开始看到劳动成果的阶段——那么令人满意,但忽视这项重要的准备工作是不明智的。
任何学习算法都只有与其训练数据一样好,在很多情况下,这些数据是复杂的、混乱的,并且分布在多个来源和格式中。由于这种复杂性,机器学习项目中投入的大部分努力通常都花费在数据准备和探索上。
本章从三个方面探讨数据准备。第一部分讨论了 R 用于存储数据的基本数据结构。随着你创建和操作数据集,你将非常熟悉这些结构。第二部分是实用的,因为它涵盖了用于将数据输入和输出 R 的几个函数。在第三部分,通过探索一个真实世界的数据集,展示了理解数据的方法。
到本章结束时,你将了解:
-
如何使用 R 的基本数据结构来存储和操作值
-
将数据从常见源格式导入 R 的简单函数
-
理解和可视化复杂数据的典型方法
R 处理数据的方式将决定你必须如何处理数据,因此在直接进行数据准备之前了解 R 的数据结构是有帮助的。然而,如果你已经熟悉 R 编程,可以自由地跳到数据预处理的部分。
本书所有代码文件均可在github.com/PacktPublishing/Machine-Learning-with-R-Fourth-Edition找到
R 数据结构
编程语言中存在多种类型的数据结构,每种都有适合特定任务的优点和缺点。由于 R 是一种广泛用于统计数据分析的编程语言,因此它所利用的数据结构是针对这种类型的工作设计的。
在机器学习中,最频繁使用的 R 数据结构是向量、因子、列表、数组、矩阵和数据框。每个都是针对特定的数据管理任务定制的,这使得了解它们在你的 R 项目中如何交互变得很重要。在接下来的部分中,我们将回顾它们的相似之处和不同之处。
向量
R 的基本数据结构是 向量,它存储了一组有序的值,称为 元素。向量可以包含任意数量的元素。然而,向量的所有元素必须属于同一类型;例如,向量不能同时包含数字和文本。要确定向量 v 的类型,请使用 typeof(v) 命令。请注意,R 是一个 区分大小写 的语言,这意味着小写的 v 和大写的 V 可能代表两个不同的向量。这同样适用于 R 的内置函数和关键字,因此在输入 R 命令或表达式时,务必确保使用正确的首字母大小写。
在机器学习中,常用的几种向量类型包括:integer(没有小数的数字)、double(有小数的数字)、character(文本数据,也常称为“字符串”数据)和 logical(TRUE 或 FALSE 值)。某些 R 函数将 integer 和 double 向量都报告为 numeric,而其他函数则区分两者;通常,这种区别并不重要。在 R 中,逻辑值向量被广泛使用,但请注意,TRUE 和 FALSE 值必须全部大写。这与一些其他编程语言略有不同。
对于所有向量类型,也存在两个相关的特殊值:NA,表示一个 缺失 的值,以及 NULL,用于表示 任何 值的缺失。尽管这两个值看起来似乎是同义的,但它们实际上是略有不同的。NA 值是其他某物的占位符,因此其长度为 1,而 NULL 值确实是空的,其长度为 0。
手动输入大量数据是件麻烦事,但可以通过使用 c() 组合函数创建简单的向量。向量也可以使用箭头 <- 操作符来命名。这是 R 的赋值操作符,其用法与许多其他编程语言中的 = 赋值操作符类似。
R 也允许使用 = 操作符进行赋值,但根据普遍接受的编码风格指南,这被认为是一种较差的编码风格。
例如,让我们构建一个包含三个医疗患者数据的向量集。我们将创建一个名为 subject_name 的字符向量来存储三个患者的姓名,一个名为 temperature 的数值向量来存储每个患者的体温(华氏度),以及一个名为 flu_status 的逻辑向量来存储每个患者的诊断(如果他们患有流感则为 TRUE,否则为 FALSE)。如下面的代码所示,这三个向量是:
> subject_name <- c("John Doe", "Jane Doe", "Steve Graves")
> temperature <- c(98.1, 98.6, 101.4)
> flu_status <- c(FALSE, FALSE, TRUE)
存储在 R 向量中的值保留其顺序。因此,可以通过在集合中的位置来访问每个患者的数据,从 1 开始,然后在向量名称后面提供这个数字(即 [ 和 ] 方括号内)。例如,要获取 Jane Doe 患者的体温值,即第二个患者,只需简单地输入:
> temperature[2]
[1] 98.6
R 提供了各种方法从向量中提取数据。可以使用冒号运算符获取一系列值。例如,要获取第二位和第三位患者的体温,请输入以下内容:
> temperature[2:3]
[1] 98.6 101.4
可以通过指定负项目编号来排除项目。要排除第二位患者的体温数据,请输入以下内容:
> temperature[-2]
[1] 98.1 101.4
有时指定一个表示每个项目是否应包含的逻辑向量也是有用的。例如,要包含前两个体温读数但排除第三个,请输入以下内容:
> temperature[c(TRUE, TRUE, FALSE)]
[1] 98.1 98.6
通过意识到像temperature > 100这样的逻辑表达式的结果是逻辑向量,这种操作的的重要性变得更加清晰。这个表达式根据温度是否超过 100 华氏度返回TRUE或FALSE,这表明发烧。因此,以下命令将识别出发烧的患者:
> fever <- temperature > 100
> subject_name[fever]
[1] "Steve Graves"
逻辑表达式也可以移入括号内,这可以在一步中返回相同的结果:
> subject_name[temperature > 100]
[1] "Steve Graves"
如您将很快看到的,向量是许多其他 R 数据结构的基础,并且可以与编程表达式结合使用,以完成更复杂的数据选择和构建新特征的操作。因此,了解各种向量操作对于在 R 中处理数据至关重要。
因子
回想一下第一章,介绍机器学习,名义特征表示具有值类别的特征。虽然可以使用字符向量来存储名义数据,但 R 提供了一种专门为此任务的数据结构。
因子是一种特殊类型的向量,仅用于表示分类或有序数据。在我们构建的医疗数据集中,我们可能会使用因子来表示患者的生物性别,并记录两个类别:男性和女性。
为什么使用因子而不是字符向量?因子的一项优点是类别标签只存储一次。而不是存储MALE,MALE,FEMALE,计算机可能存储1,1,2,这可以减少存储值所需的内存。此外,许多机器学习算法以不同的方式处理名义和数值特征。将分类特征编码为因子允许 R 适当地处理分类特征。
因子不应用于存储值不真正属于类别的字符向量。如果一个向量存储了大部分唯一的值,如姓名或识别码,如社会保障号码,请将其保留为字符向量。
要从字符向量创建因子,只需应用factor()函数。例如:
> gender <- factor(c("MALE", "FEMALE", "MALE"))
> gender
[1] MALE FEMALE MALE
Levels: FEMALE MALE
注意,当gender因子被显示时,R 打印了有关其级别的额外信息。级别包含因子可能采取的可能类别集合,在这种情况下,MALE或FEMALE。
当我们创建因素时,我们可以添加可能不在原始数据中出现的附加水平。例如,我们可以创建另一个血型因素,如下面的示例所示:
> blood <- factor(c("O", "AB", "A"),
levels = c("A", "B", "AB", "O"))
> blood
[1] O AB A
Levels: A B AB O
当我们定义blood因素时,我们使用levels参数指定了一个包含四种可能血型的附加向量。因此,尽管我们的数据中只包括 O 型、AB 型和 A 型血型,但所有四种血型都通过blood因素保留,正如输出所示。存储附加水平允许将来添加具有其他血型的患者。这也确保了,如果我们创建一个血型表,我们会知道存在 B 型血,尽管它在我们的初始数据中未被发现。
因素数据结构还允许我们包含有关名义特征类别顺序的信息,这为创建序数特征提供了一种方法。例如,假设我们有关于患者症状严重程度的数据,按严重程度递增的顺序编码,从轻微到中度,再到严重。我们通过提供因素的水平以所需的顺序,从最低到最高列出,并将ordered参数设置为TRUE来表示序数数据的存在,如下所示:
> symptoms <- factor(c("SEVERE", "MILD", "MODERATE"),
levels = c("MILD", "MODERATE", "SEVERE"),
ordered = TRUE)
结果的症状因素现在包括有关请求顺序的信息。与我们的先前因素不同,此因素的水平由<符号分隔,以表示从MILD到SEVERE的顺序存在:
> symptoms
[1] SEVERE MILD MODERATE
Levels: MILD < MODERATE < SEVERE
有序因素的便利之处在于逻辑测试按预期工作。例如,我们可以测试每位患者的症状是否比中度更严重:
> symptoms > "MODERATE"
[1] TRUE FALSE FALSE
能够对序数数据进行建模的机器学习算法将期望有序因素,因此请确保按照相应的方式对您的数据进行编码。
列表
列表是一种数据结构,类似于向量,因为它用于存储有序元素集。然而,与向量要求所有元素必须是相同类型不同,列表允许收集不同的 R 数据类型。由于这种灵活性,列表常用于存储各种类型的输入和输出数据以及机器学习模型的配置参数集。
为了说明列表,考虑我们一直在构建的医疗患者数据集,其中三个患者的数据存储在六个向量中。如果我们想显示第一位患者的所有数据,我们需要输入五个 R 命令:
> subject_name[1]
[1] "John Doe"
> temperature[1]
[1] 98.1
> flu_status[1]
[1] FALSE
> gender[1]
[1] MALE
Levels: FEMALE MALE
> blood[1]
[1] O
Levels: A B AB O
> symptoms[1]
[1] SEVERE
Levels: MILD < MODERATE < SEVERE
如果我们预计将来再次检查患者的数据,而不是重新输入这些命令,列表允许我们将所有值组合成一个可以重复使用的对象。
与使用 c() 创建向量类似,列表是通过 list() 函数创建的,如下面的示例所示。一个值得注意的区别是,当构建列表时,序列中的每个组件都应该有一个名称。名称不是必需的,但允许以后通过名称而不是通过编号位置和一串方括号来访问值。要为第一个患者的值创建具有命名组件的列表,请输入以下内容:
> subject1 <- list(fullname = subject_name[1],
temperature = temperature[1],
flu_status = flu_status[1],
gender = gender[1],
blood = blood[1],
symptoms = symptoms[1])
这位患者的数据现在收集在 subject1 列表中:
> subject1
$fullname
[1] "John Doe"
$temperature
[1] 98.1
$flu_status
[1] FALSE
$gender
[1] MALE
Levels: FEMALE MALE
$blood
[1] O
Levels: A B AB O
$symptoms
[1] SEVERE
Levels: MILD < MODERATE < SEVERE
注意,值被标记为我们之前命令中指定的名称。由于列表像向量一样保留顺序,其组件可以通过数字位置访问,如下面所示的 temperature 值:
> subject1[2]
$temperature
[1] 98.1
在列表对象上使用向量风格运算符的结果是另一个列表对象,它是原始列表的子集。例如,前面的代码返回了一个只有一个 temperature 组件的列表。要返回单个列表项的 原生 数据类型,请在选择列表组件时使用双括号 ([[ 和 ]])。例如,以下命令返回了一个长度为 1 的数值向量:
> subject1[[2]]
[1] 98.1
为了清晰起见,通常最好通过名称访问列表组件,方法是将 $ 和组件名称附加到列表名称上,如下所示:
> subject1$temperature
[1] 98.1
与双括号表示法类似,这返回了列表组件的 原生 数据类型(在这种情况下,长度为 1 的数值向量)。
通过名称访问值也确保了即使列表元素的顺序后来发生变化,也能检索到正确的项目。
通过指定名称向量,可以获取多个列表项。以下返回 subject1 列表的子集,仅包含 temperature 和 flu_status 组件:
> subject1[c("temperature", "flu_status")]
$temperature
[1] 98.1
$flu_status
[1] FALSE
整个数据集可以使用列表和列表的列表来构建。例如,你可能考虑创建一个名为 subject2 和 subject3 的列表,并将这些列表组合成一个名为 pt_data 的列表对象。然而,以这种方式构建数据集是如此常见,以至于 R 提供了一种专门的数据结构来专门处理这项任务。
数据框
到目前为止,对于机器学习来说,最重要的 R 数据结构是 数据框,它类似于电子表格或数据库的结构,因为它既有行又有列的数据。在 R 的术语中,数据框可以理解为向量的列表或因子,每个都具有相同数量的值。因为数据框实际上是向量类型对象的列表,它结合了向量和列表的方面。
让我们为我们的患者数据集创建一个数据框。使用我们之前创建的患者数据向量,data.frame() 函数将它们组合成一个数据框:
> pt_data <- data.frame(subject_name, temperature,
flu_status, gender, blood, symptoms)
当显示 pt_data 数据框时,我们看到其结构与之前我们处理过的数据结构相当不同:
> pt_data
subject_name temperature flu_status gender blood symptoms
1 John Doe 98.1 FALSE MALE O SEVERE
2 Jane Doe 98.6 FALSE FEMALE AB MILD
3 Steve Graves 101.4 TRUE MALE A MODERATE
与一维向量、因子和列表相比,数据框有两个维度,并以表格格式显示。我们的数据框为每位患者有一行,为每位患者的测量值向量有一列。在机器学习的术语中,数据框的行是示例,列是特征或属性。
要提取整个数据列(向量),我们可以利用数据框实际上是一个向量列表的事实。像列表一样,提取单个元素的最直接方法是通过其名称引用。例如,要获取subject_name向量,输入以下内容:
> pt_data$subject_name
[1] "John Doe" "Jane Doe" "Steve Graves"
像列表一样,名称向量可以用来从数据框中提取多个列:
> pt_data[c("temperature", "flu_status")]
temperature flu_status
1 98.1 FALSE
2 98.6 FALSE
3 101.4 TRUE
当我们按名称请求数据框列时,结果是包含指定列的所有行的数据框。命令pt_data[2:3]也将提取temperature和flu_status列。然而,通过名称引用的列会产生清晰且易于维护的 R 代码,如果数据框稍后重新排序,代码也不会出错。
要从数据框中提取特定值,可以使用与访问向量值相同的方法。然而,有一个重要的区别——因为数据框是二维的,必须指定所需的行和列。首先指定行,然后是逗号,然后是类似这样的列格式:[行, 列]。与向量一样,行和列都是从一开始计数的。
例如,要提取患者数据框的第一行和第二列的值,使用以下命令:
> pt_data[1, 2]
[1] 98.1
如果你需要多于一行或一列的数据,请指定表示所需行和列的向量。以下语句将从第一行和第三行以及第二行和第四列中提取数据:
> pt_data[c(1, 3), c(2, 4)]
temperature gender
1 98.1 MALE
3 101.4 MALE
要引用每一行或每一列,只需将行或列部分留空。例如,要提取第一列的所有行:
> pt_data[, 1]
[1] "John Doe" "Jane Doe" "Steve Graves"
要提取第一行的所有列:
> pt_data[1, ]
subject_name temperature flu_status gender blood symptoms
1 John Doe 98.1 FALSE MALE O SEVERE
要提取所有内容:
> pt_data[ , ]
subject_name temperature flu_status gender blood symptoms
1 John Doe 98.1 FALSE MALE O SEVERE
2 Jane Doe 98.6 FALSE FEMALE AB MILD
3 Steve Graves 101.4 TRUE MALE A MODERATE
当然,通过名称访问列比通过位置访问列更好,并且可以使用负号排除数据行或列。因此,命令的输出:
> pt_data[c(1, 3), c("temperature", "gender")]
temperature gender
1 98.1 MALE
3 101.4 MALE
等价于:
> pt_data[-2, c(-1, -3, -5, -6)]
temperature gender
1 98.1 MALE
3 101.4 MALE
我们经常需要在数据框中创建新的列——例如,可能作为现有列的函数。例如,我们可能需要将患者数据框中的华氏温度读数转换为摄氏度。为此,我们只需使用赋值运算符将转换计算的结果分配给新的列名,如下所示:
> pt_data$temp_c <- (pt_data$temperature - 32) * (5 / 9)
为了确认计算是否成功,让我们将基于摄氏度的temp_c新列与之前的华氏温度temperature列进行比较:
> pt_data[c("temperature", "temp_c")]
temperature temp_c
1 98.1 36.72222
2 98.6 37.00000
3 101.4 38.55556
将这些并排放置,我们可以确认计算已经正确完成。
由于这些类型的操作对于我们在接下来的章节中将要做的许多工作至关重要,因此熟悉数据框非常重要。你可以尝试使用患者数据集练习类似的操作,或者更好的是,使用你自己的项目中的数据——在本章后面将描述将你的数据文件加载到 R 中的函数。
矩阵和数组
除了数据框之外,R 还提供了其他以表格形式存储值的结构。矩阵是一种表示二维数据表的二维数据结构。与向量一样,R 矩阵只能包含一种类型的数据,尽管它们通常用于数学运算,因此通常只存储数字。
要创建一个矩阵,只需向matrix()函数提供一个数据向量,以及一个指定行数(nrow)或列数(ncol)的参数。例如,要创建一个 2x2 矩阵存储数字一到四,我们可以使用nrow参数请求数据被分成两行:
> m <- matrix(c(1, 2, 3, 4), nrow = 2)
> m
[,1] [,2]
[1,] 1 3
[2,] 2 4
这与使用ncol = 2产生的矩阵等效:
> m <- matrix(c(1, 2, 3, 4), ncol = 2)
> m
[,1] [,2]
[1,] 1 3
[2,] 2 4
你会注意到 R 首先加载矩阵的第一列,然后加载第二列。这被称为列主序,它是 R 加载矩阵的默认方法。
要覆盖此默认设置并按行加载矩阵,在创建矩阵时设置参数byrow = TRUE。
为了进一步说明这一点,让我们看看如果我们向矩阵中添加更多值会发生什么。有六个值时,请求两行会创建一个三列的矩阵:
> m <- matrix(c(1, 2, 3, 4, 5, 6), nrow = 2)
> m
[,1] [,2] [,3]
[1,] 1 3 5
[2,] 2 4 6
请求两列会创建一个三行的矩阵:
> m <- matrix(c(1, 2, 3, 4, 5, 6), ncol = 2)
> m
[,1] [,2]
[1,] 1 4
[2,] 2 5
[3,] 3 6
与数据框一样,矩阵中的值可以使用[行, 列]表示法提取。例如,m[1, 1]将返回值1,而m[3, 2]将从m矩阵中提取6。此外,还可以请求整个行或列:
> m[1, ]
[1] 1 4
> m[, 1]
[1] 1 2 3
与矩阵结构密切相关的是数组,它是一个多维数据表。矩阵有行和列的值,而数组有行、列和一或多个额外的值层。尽管我们将在后面的章节中偶尔使用矩阵,但在这个书的范围内使用数组是不必要的。
使用 R 管理数据
在处理大规模数据集时面临的挑战之一涉及从各种来源收集、准备和管理工作数据。尽管我们将在后面的章节中通过实际机器学习任务深入探讨数据准备、数据清洗和数据管理,但本节突出了将数据输入和输出 R 的基本功能。
保存、加载和删除 R 数据结构
当你花费了大量时间将数据框调整到所需的形式后,每次重启 R 会话时,你不需要重新创建你的工作。
为了将数据结构保存到可以稍后重新加载或传输到另一个系统的文件中,可以使用save()函数将一个或多个 R 数据结构写入由file参数指定的位置。R 数据文件具有.RData或.rda扩展名。
假设您有三个名为x、y和z的对象,您希望将它们保存到永久文件中。这些可能是向量、因子、列表、数据框或任何其他 R 对象。要将它们保存到名为mydata.RData的文件中,请使用以下命令:
> save(x, y, z, file = "mydata.RData")
load()命令可以重新创建已保存到.RData文件中的任何数据结构。要加载前面代码中创建的mydata.RData文件,只需键入以下内容:
> load("mydata.RData")
这将在您的 R 环境中重新创建x、y和z数据结构。
在加载时要小心!使用load()命令导入的文件中存储的所有数据结构都将添加到您的工作空间中,即使它们覆盖了您正在工作的其他内容。
或者,可以使用saveRDS()函数将单个 R 对象保存到文件中。尽管它与save()函数非常相似,但一个关键的区别是相应的loadRDS()函数允许以与原始对象不同的名称加载对象。因此,在跨项目传输 R 对象时,saveRDS()可能更安全使用,因为它减少了意外覆盖 R 环境中现有对象的风险。
saveRDS()函数对于保存机器学习模型对象特别有帮助。因为一些机器学习算法需要很长时间来训练模型,将模型保存到.rds文件中可以帮助避免在项目恢复时进行长时间的重训练过程。例如,要将名为my_model的模型对象保存到名为my_model.rds的文件中,请使用以下语法:
> saveRDS(my_model, file = "my_model.rds")
要加载模型,请使用readRDS()函数,并将结果分配给一个对象名,如下所示:
> my_model <- readRDS("my_model.rds")
在您使用 R 会话工作一段时间后,您可能已经积累了未使用的数据结构。在 RStudio 中,这些对象在界面的环境选项卡中可见,但也可以使用列表函数ls()以编程方式访问这些对象,该函数返回当前内存中所有数据结构的向量。
例如,如果您一直跟随本章中的代码,ls()函数将返回以下内容:
> ls()
[1] "blood" "fever" "flu_status" "gender"
[5] "m" "pt_data" "subject_name" "subject1"
[9] "symptoms" "temperature"
R 在退出会话时会自动从内存中清除所有数据结构,但对于大型对象,您可能希望更早地释放内存。移除函数rm()可用于此目的。例如,要消除m和subject1对象,只需键入以下内容:
> rm(m, subject1)
rm()函数也可以接受一个字符向量作为对象名来删除。这与ls()函数一起使用,可以清除整个 R 会话:
> rm(list = ls())
在执行前面的代码时要非常小心,因为您的对象被删除之前不会收到提示!
如果你需要急忙结束你的 R 会话,save.image() 命令会将你的整个会话写入一个简单地称为 .RData 的文件。默认情况下,当你退出 R 或 RStudio 时,你会被询问是否要创建此文件。R 将在下次启动 R 时查找此文件,如果它存在,你的会话将像你离开时一样被重新创建。
从 CSV 文件导入和保存数据集
公共数据集通常存储在文本文件中。文本文件几乎可以在任何计算机或操作系统上读取,这使得该格式几乎通用。它们还可以从 Microsoft Excel 等程序导出和导入,提供了一种快速简单的方式来处理电子表格数据。
表格(类似于“表格”)数据文件以矩阵形式结构化,这样每一行文本反映一个示例,每个示例都有相同数量的特征。每一行上的特征值由一个预定义的符号分隔,这个符号称为分隔符。通常,表格数据文件的第一行列出数据列的名称。这被称为标题行。
可能最常用的表格文本文件格式是逗号分隔值(CSV)文件,正如其名所示,使用逗号作为分隔符。CSV 文件可以导入和导出到许多常见应用程序。表示之前构建的医学数据集的 CSV 文件可以存储如下:
subject_name,temperature,flu_status,gender,blood_type
John Doe,98.1,FALSE,MALE,O
Jane Doe,98.6,FALSE,FEMALE,AB
Steve Graves,101.4,TRUE,MALE,A
给定一个名为 pt_data.csv 的患者数据文件,该文件位于 R 的工作目录中,可以使用以下 read.csv() 函数将其加载到 R 中:
> pt_data <- read.csv("pt_data.csv")
这将读取 CSV 文件到一个名为 pt_data 的数据框中。如果你的数据集位于 R 的工作目录之外,可以在调用 read.csv() 函数时使用 CSV 文件的完整路径(例如,"/path/to/mydata.csv")。
默认情况下,R 假设 CSV 文件包含一个标题行,列出数据集中特征的名称。如果一个 CSV 文件没有标题,可以在以下命令中指定选项 header = FALSE,R 将按顺序给列分配通用的特征名称,如 V1、V2 等:
> pt_data <- read.csv("pt_data.csv", header = FALSE)
作为一条重要的历史注释,在 R 4.0 之前的版本中,read.csv() 函数自动将所有字符类型列转换为因子,这是由于默认设置为 TRUE 的 stringsAsFactors 参数。这个特性有时很有帮助,尤其是在 R 的早期年份中使用的较小和较简单的数据集中。然而,随着数据集变得更大和更复杂,这个特性开始造成的问题比解决的问题还多。现在,从版本 4.0 开始,R 默认将 stringsAsFactors 设置为 FALSE。如果你确定 CSV 文件中的每个字符列确实是一个因子,你可以使用以下语法将它们转换:
> pt_data <- read.csv("pt_data.csv", stringsAsFactors = TRUE)
在本书中,当处理所有字符列都是真正因子的数据集时,我们会偶尔设置 stringsAsFactors = TRUE。
从 R 中获取结果数据几乎和获取数据一样重要!要将数据框保存到 CSV 文件中,请使用 write.csv() 函数。对于名为 pt_data 的数据框,只需输入:
> write.csv(pt_data, file = "pt_data.csv", row.names = FALSE)
这将在 R 的工作文件夹中写入一个名为 pt_data.csv 的 CSV 文件。row.names 参数覆盖了 R 的默认设置,即在 CSV 文件中输出行名。通常,这种输出是不必要的,并且只会增加结果文件的大小。
对于读取文件的更复杂控制,请注意,read.csv() 是 read.table() 函数的一个特例,它可以读取多种不同形式的表格数据。这包括其他分隔符格式,如制表符分隔值(TSV)和竖线(|)分隔的文件。有关 read.table() 函数族的更详细信息,请使用 ?read.table 命令在 R 帮助页面中查找。
使用 RStudio 导入常见数据集格式
对于更复杂的导入场景,RStudio 桌面软件提供了一个简单的界面,它将引导您编写 R 代码,以便将数据加载到您的项目中。尽管加载像 CSV 这样的纯文本数据格式一直相对容易,但导入其他常见分析数据格式(如 Microsoft Excel(.xls 和 .xlsx)、SAS(.sas7bdat 和 .xpt)、SPSS(.sav 和 .por)和 Stata(.dta)曾经是一个繁琐且耗时的过程,需要了解多个 R 包中的特定技巧和工具。现在,该功能可通过 RStudio 界面右上角的导入数据集命令获得,如图 2.1 所示:

图 2.1:RStudio 的“导入数据集”功能提供了从各种常见格式加载数据的选项
根据选择的数据格式,您可能需要安装 R 包以实现所需的功能。在幕后,这些包将转换数据格式,以便在 R 中使用。然后,您将看到一个对话框,允许您选择数据导入过程选项,并实时预览数据在 R 中的外观,随着这些更改的进行。
以下屏幕截图说明了使用 readxl 包(readxl.tidyverse.org)导入使用过的汽车数据集的 Microsoft Excel 版本的过程,但该过程对于任何数据集格式都是类似的:

图 2.2:数据导入对话框提供了一个“代码预览”,可以复制并粘贴到您的 R 代码文件中
此对话框右下角的代码预览提供了使用指定选项执行导入的 R 代码。选择导入按钮将立即执行代码;然而,更好的做法是将代码复制并粘贴到您的 R 源代码文件中,这样您就可以在未来会话中重新导入数据集。
RStudio 使用的read_excel()函数将 Excel 数据加载到一个称为“tibble”的 R 对象中,而不是数据框。这些差异如此微妙,您可能甚至都没有注意到!然而,tibbles 是 R 的一个重要创新,它使我们可以以新的方式处理数据框。tibble 及其功能将在第十二章,高级数据准备中讨论。
RStudio 界面使以各种格式处理数据变得比以往任何时候都容易,但还有更高级的功能用于处理大型数据集。特别是,如果您有存储在 Microsoft SQL、MySQL、PostgreSQL 和其他数据库平台中的数据,您可以使用 R 连接到这些数据库,将数据拉入 R,甚至利用数据库硬件本身在将结果带入 R 之前执行大数据计算。第十五章,利用大数据介绍了这些技术,并提供了使用 RStudio 连接到常用数据库的说明。
探索和理解数据
在收集数据并将其加载到 R 数据结构之后,机器学习过程中的下一步是详细检查数据。在这一步中,您将开始探索数据的特征和示例,并意识到使您的数据独特的独特之处。您对数据的理解越好,您将能够更好地将机器学习模型与您的学习问题相匹配。
通过示例学习数据探索的过程是最好的方法。在本节中,我们将探索usedcars.csv数据集,该数据集包含有关 2012 年在美国一个流行的网站上广告出售的二手车的实际数据。
usedcars.csv数据集可在本书的 Packt Publishing 支持页面上下载。如果您正在跟随示例进行操作,请确保此文件已下载并保存到您的 R 工作目录中。
由于数据集以 CSV 格式存储,我们可以使用read.csv()函数将数据加载到 R 数据框中:
> usedcars <- read.csv("usedcars.csv")
使用usedcars数据框,我们现在将扮演一个数据科学家的角色,他的任务是理解二手车数据。尽管数据探索是一个流动的过程,但可以将其想象为一种调查,其中回答有关数据的问题。具体问题可能因项目而异,但问题的类型始终相似。
您应该能够将这项调查的基本步骤适应到您喜欢的任何数据集中,无论大小。

图 2.3:“定价算法是否经过测试?”(由 Midjourney AI 根据提示“可爱卡通机器人购买二手车”创建的图像)
探索数据结构
在调查新数据集时,首先要问的问题应该是关于数据集的组织方式。如果你很幸运,你的来源将提供一个 数据字典,这是一个描述数据集特征的文档。在我们的案例中,二手车数据没有附带这份文档,因此我们需要自己创建。
str() 函数提供了一种显示 R 对象结构的方法,例如数据框、向量或列表。它可以用来创建我们的数据字典的基本轮廓:
> str(usedcars)
'data.frame': 150 obs. of 6 variables:
$ year : int 2011 2011 2011 2011 ...
$ model : chr "SEL" "SEL" "SEL" "SEL" ...
$ price : int 21992 20995 19995 17809 ...
$ mileage : int 7413 10926 7351 11613 ...
$ color : chr "Yellow" "Gray" "Silver" "Gray" ...
$ transmission: chr "AUTO" "AUTO" "AUTO" "AUTO" ...
对于这样一个简单的命令,我们了解到了关于数据集的大量信息。150 obs 的语句告诉我们数据包括 150 观测值,这仅仅是另一种说法,即数据集包含 150 行或示例。观测值的数量通常简单地缩写为 n。
由于我们知道数据描述的是二手车,我们现在可以假设我们有 n = 150 辆待售汽车的示例。
6 variables 语句指的是数据中记录的六个特征。术语 变量 是从统计学领域借用的,简单地说,它是一个可以取各种值的数学对象——就像你在代数方程中求解的 x 和 y 变量。这些特征或变量按名称单独列出。查看名为 color 的特征所在的行,我们注意到一些额外的细节:
$ color : chr "Yellow" "Gray" "Silver" "Gray" ...
在变量的名称之后,chr 标签告诉我们该特征是字符类型。在这个数据集中,有三个变量是字符类型,而另外三个被标记为 int,这代表整数类型。尽管 usedcars 数据集只包含字符和整数特征,但在使用非整数数据时,你也可能会遇到 num 或数值类型。任何因素都会被列为 factor 类型。在每个变量的类型之后,R 会展示一系列的前几个特征值。"Yellow" "Gray" "Silver" "Gray" 是 color 特征的前四个值。
将一些领域知识应用到特征名称和值上,使我们能够对特征所代表的内容做出一些假设。year 可能指的是车辆制造的年份,或者它可能指定了广告发布的年份。我们将在稍后更详细地调查这个特征,因为四个示例值 (2011 2011 2011 2011) 可以用来支持这两种可能性。model、price、mileage、color 和 transmission 很可能指的是待售汽车的特性。
虽然我们的数据看起来已经被赋予了有意义的名称,但这并不总是如此。有时数据集具有无意义的名称或代码,如V1。在这些情况下,可能需要进行额外的调查来确定一个特征代表什么。即使有有帮助的特征名称,也要始终对提供的标签持怀疑态度。让我们进一步调查。
探索数值特征
要调查二手车数据中的数值特征,我们将使用一组常见的测量值来描述值,这些值被称为摘要统计量。summary()函数显示了一些常见的摘要统计量。让我们看看单个特征,year:
> summary(usedcars$year)
Min. 1st Qu. Median Mean 3rd Qu. Max.
2000 2008 2009 2009 2010 2012
忽略值的含义,我们看到2000、2008和2009这样的数字,这让我们相信year表示的是制造年份,而不是广告发布的年份,因为我们知道车辆列表是在 2012 年获得的。
通过提供一列列名向量,我们也可以使用summary()函数同时获取多个数值列的摘要统计信息:
> summary(usedcars[c("price", "mileage")])
price mileage
Min. : 3800 Min. : 4867
1st Qu.:10995 1st Qu.: 27200
Median :13592 Median : 36385
Mean :12962 Mean : 44261
3rd Qu.:14904 3rd Qu.: 55124
Max. :21992 Max. :151479
summary()函数提供的六个摘要统计量是简单而强大的工具,用于调查数据。它们可以分为两类:中心趋势的度量以及离散趋势的度量。
测量集中趋势——平均值和中位数
集中趋势的度量是一类用于识别数据集中位于中间位置的值的统计方法。你很可能已经熟悉一个常见的中心度量:平均值。在常规用法中,当某事物被认为是平均值时,它位于刻度两端的中间位置。一个平均的学生可能的成绩位于其同学的中等水平。一个平均的体重既不特别轻也不特别重。一般来说,一个平均的项目是典型的,并且与其组中的其他项目不太相似。你可以将其视为一个典范,其他所有项目都是根据这个典范来评判的。
在统计学中,平均值也被称为均值,它是一个定义为所有值之和除以值数量的度量。例如,要计算三个收入为$36,000、$44,000 和$56,000 的人的平均收入,我们可以输入:
> (36000 + 44000 + 56000) / 3
[1] 45333.33
R 还提供了一个mean()函数,用于计算数字向量的平均值:
> mean(c(36000, 44000, 56000))
[1] 45333.33
这个群体的人均收入约为$45,333。从概念上讲,这可以想象为如果总收入平均分配给每个人,每个人将拥有的收入。
回想一下,前面的summary()输出列出了price和mileage的平均值。这些值表明,在这个数据集中,典型的二手车标价为 12,962 美元,里程表读数为 44,261 英里。这告诉我们关于我们数据什么信息?我们可以注意到,由于平均价格相对较低,我们可能会预期数据集中包含经济型汽车。当然,数据也可能包括里程数高的新款豪华车,但相对较低的均值里程数统计信息并不提供支持这一假设的证据。另一方面,它也不提供忽略这种可能性的证据。在我们进一步检查数据时,我们需要记住这一点。
尽管平均值是衡量数据集中心位置引用最多的统计量,但它并不总是最合适的。另一个常用的中心趋势度量是中位数,它是有序值列表中点值。与平均值一样,R 提供了一个median()函数,我们可以将其应用于以下示例中的工资数据:
> median(c(36000, 44000, 56000))
[1] 44000
因此,因为中间值是44000,中位数收入是$44,000。
如果一个数据集有偶数个值,就没有中间值。在这种情况下,中位数通常被计算为有序列表中中心两个值的平均值。例如,值 1, 2, 3 和 4 的中位数是 2.5。
初看之下,中位数和平均值似乎是两个非常相似的度量。当然,平均值为 45,333 美元,中位数为 44,000 美元,两者并不相差很远。为什么有两个中心趋势的度量?原因与平均值和中位数对范围两端值的影响不同有关。特别是,平均值对异常值非常敏感,即相对于大多数数据而言,异常值异常地高或低。关于异常值的更细致的分析将在第十一章用机器学习取得成功中介绍,但就现在而言,我们可以将它们视为相对于中位数而言,倾向于将平均值推向更高或更低的极端值,因为中位数对异常值不太敏感。
再次回想一下用于二手车数据集的summary()输出中报告的中位数值。尽管价格的平均值和中位数相似(相差大约五个百分点),但平均值和中位数之间的差异却大得多。对于里程数,平均值为 44,261,比中位数 36,385 高出超过 20%。由于平均值比中位数对极端值更敏感,平均值远高于中位数的事实可能会让我们怀疑数据集中存在一些与其他车辆相比里程数极高的二手车。为了进一步调查这个问题,我们需要在我们的分析中添加额外的摘要统计信息。
测量离散程度——四分位数和五数摘要
均值和中位数提供了快速总结值的方法,但这些中心度量告诉我们很少关于测量中是否存在多样性的信息。为了测量多样性,我们需要采用另一种类型的摘要统计,这种统计关注数据的 离散度,即值是如何紧密或松散地分布的。了解离散度可以让我们对数据的最高和最低值有一个概念,以及大多数值是否与均值和中位数相似或不同。
五数摘要 是一组五个统计量,大致描述了特征值的离散度。所有五个统计量都包含在 summary() 函数的输出中。按顺序写出,它们是:
-
最小值 (
Min.) -
第一四分位数,或 Q1 (
1st Qu.) -
中位数,或 Q2 (
Median) -
第三四分位数,或 Q3 (
3rd Qu.) -
最大值 (
Max.)
如您所预期,最小值和最大值是最极端的特征值,分别表示最小和最大的值。R 提供了 min() 和 max() 函数来计算向量的这些值。
最小值和最大值之间的范围称为范围。在 R 中,range() 函数返回最小值和最大值:
> range(usedcars$price)
[1] 3800 21992
将 range() 与差分函数 diff() 结合使用,可以让你用一行代码计算范围统计量:
> diff(range(usedcars$price))
[1] 18192
数据集的四分之一值低于第一四分位数(Q1),另一四分之一高于第三四分位数(Q3)。与中位数一样,中位数是数据值的中间点,四分位数将数据集分为四个部分,每个部分包含 25% 的值。
四分位数是称为 分位数 的一种统计的特殊情况,分位数是将数据分成等量部分的数字。除了四分位数外,常用的分位数还包括 三分位数(三部分)、五分位数(五部分)、十分位数(十部分)和 百分位数(一百部分)。百分位数常用于描述值的排名;例如,一个测试分数排名在 99 分位数的学生表现优于或等于其他 99% 的测试者。
数据中间的 50%,位于第一四分位数和第三四分位数之间,特别引人注目,因为它是一个简单的离散度度量。Q1 和 Q3 之间的差异被称为 四分位距 (IQR),可以使用 IQR() 函数来计算:
> IQR(usedcars$price)
[1] 3909.5
我们也可以通过从 usedcars$price 向量的 summary() 输出中手动计算这个值,即 14904 – 10995 = 3909。我们计算结果与 IQR() 输出之间的微小差异是由于 R 自动四舍五入 summary() 输出的原因。
quantile() 函数提供了一种灵活的工具,用于识别一组值的分位数。默认情况下,quantile() 返回五个数字的摘要。将函数应用于 usedcars$price 向量会产生与之前相同的摘要统计量:
> quantile(usedcars$price)
0% 25% 50% 75% 100%
3800.0 10995.0 13591.5 14904.5 21992.0
在计算分位数时,处理没有单一中间值的值集中存在许多处理成对的方法。quantile() 函数允许您通过指定 type 参数来在九种不同的断键算法中选择。如果您的项目需要精确定义的分位数,那么使用 ?quantile 命令阅读函数文档是很重要的。
通过为表示截断点的向量提供一个额外的 probs 参数,我们可以获得任意分位数,例如第 1 百分位和第 99 百分位:
> quantile(usedcars$price, probs = c(0.01, 0.99))
1% 99%
5428.69 20505.00
序列函数 seq() 生成等间距值的向量。这使得获取其他数据切片变得容易,例如以下命令中显示的五分位数(五组):
> quantile(usedcars$price, seq(from = 0, to = 1, by = 0.20))
0% 20% 40% 60% 80% 100%
3800.0 10759.4 12993.8 13992.0 14999.0 21992.0
在了解了五数摘要之后,我们可以重新审视二手车 summary() 输出。对于 price,最小值是 $3,800,最大值是 $21,992。有趣的是,最小值和 Q1 之间的差异大约是 $7,000,Q3 和最大值之间的差异也是 $7,000;然而,从 Q1 到中位数再到 Q3 的差异大约是 $2,000。这表明值的前 25% 和后 25% 的分布比中间 50% 的值分布得更广,而中间 50% 的值似乎更紧密地围绕中心分布。我们还在 mileage 中看到了类似的趋势。正如你将在本章后面学到的那样,这种分布模式足够常见,以至于它被称为数据的“正态”分布。
mileage 的分布也表现出另一个有趣的特性——Q3 和最大值之间的差异远大于最小值和 Q1 之间的差异。换句话说,较大的值分布得更广,而较小的值分布得更窄。这一发现有助于解释为什么平均值远大于中位数。因为平均值对极端值敏感,所以它被拉得更高,而中位数则保持在相对相同的位置。这是一个重要的特性,当数据以视觉形式呈现时,这一特性变得更加明显。
可视化数值特征——箱线图
可视化数值特征可以帮助诊断可能对机器学习模型性能产生负面影响的数据问题。五数摘要的常见可视化是 箱线图,也称为 箱线和须图。箱线图以一种格式显示数值变量的中心和分布,使您可以快速获得其范围和偏斜或与其他特征进行比较的感觉。
让我们看看二手车价格和里程数据的箱线图。为了获取数值向量的箱线图,我们将使用 boxplot() 函数。我们还将指定一对额外的参数,main 和 ylab,分别用于添加图表标题和标记 y 轴(垂直轴)。创建 price 和 mileage 箱线图的命令如下:
> boxplot(usedcars$price, main = "Boxplot of Used Car Prices",
ylab = "Price ($)")
> boxplot(usedcars$mileage, main = "Boxplot of Used Car Mileage",
ylab = "Odometer (mi.)")
R 将生成以下图表:

图 2.4:二手车价格和里程数据的箱线图
箱线图使用水平线和点来表示五数摘要。当从底部到顶部读取图表时,图中中间的箱体形成的水平线代表 Q1、Q2(中位数)和 Q3。中位数用深色线表示,与价格的垂直轴上的 13,592 对齐,以及里程的垂直轴上的 36,385 英里。
在简单的箱线图中,例如前面图中的那些,箱宽是任意的,并不说明数据的任何特征。对于更复杂的分析,可以使用箱子的形状和大小来促进跨多个组的数据比较。要了解更多关于这些功能的信息,请通过输入?boxplot命令来检查 R 的boxplot()文档中的notch和varwidth选项。
最小值和最大值可以通过延伸到箱体下方和上方的触须来表示;然而,一个广泛使用的惯例只允许触须延伸到 Q1 以下或 Q3 以上的 1.5 倍 IQR。任何超出此阈值的值都被认为是异常值,并用圆圈或点表示。例如,回想一下价格的 IQR 是 3,909,Q1 是 10,995,Q3 是 14,904。因此,任何小于 10995 - 1.5 * 3909 = 5131.5 或大于 14904 + 1.5 * 3909 = 20767.5 的值都是异常值。
价格箱线图显示在高低两端都有两个异常值。在里程箱线图中,低端没有异常值,因此底部触须延伸到最小值 4,867。在高端,我们看到超过 10 万英里标记的几个异常值。这些异常值是我们之前发现的原因,即平均值远大于中位数。
可视化数值特征 – 直方图
直方图是另一种可视化数值特征分布的方法。它就像箱线图一样,将特征值分成预定义的若干部分或箱,这些箱作为值的容器。然而,它们的相似之处到此为止。箱线图创建四个包含相同数量值但范围不同的部分,而直方图使用相同范围的更多部分,并允许箱包含不同数量的值。
我们可以使用hist()函数为二手车价格和里程数据创建直方图。就像我们使用箱线图一样,我们将使用main参数为图形指定标题,并使用xlab参数标记x轴。创建直方图的命令如下:
> hist(usedcars$price, main = "Histogram of Used Car Prices",
xlab = "Price ($)")
> hist(usedcars$mileage, main = "Histogram of Used Car Mileage",
xlab = "Odometer (mi.)")
这产生了以下图表:

图 2.5:二手车价格和里程数据的直方图
直方图由一系列高度表示计数的条形组成,这些条形表示落在每个等宽柱子中的值的数量,或频率。在水平轴上标注的垂直线分隔条形,表示落在柱子中的值的范围起点和终点。
你可能已经注意到前面的直方图有不同的柱子数量。这是因为hist()函数试图识别特征范围的理想柱子数量。如果你想覆盖这个默认值,请使用breaks参数。提供整数,如breaks = 10,将创建恰好 10 个等宽的柱子,而提供向量,如c(5000, 10000, 15000, 20000),将创建在指定值处断开的柱子。
在price直方图中,每个 10 个条形跨越$2,000 的间隔,从$2,000 开始,到$22,000 结束。图中中心的最高条形覆盖了$12,000 到$14,000 的范围,频率为 50。由于我们知道我们的数据包括 150 辆车,我们知道三分之一的车辆定价在$12,000 到$14,000 之间。近 90 辆车——超过一半——定价在$12,000 到$16,000 之间。
mileage直方图包括八个条形,代表 20,000 英里一个柱子的区间,从 0 开始,到 160,000 英里结束。与price直方图不同,最高的条形不在数据的中心,而是在图的左侧。这个柱子包含 70 辆车,其里程表读数在 20,000 到 40,000 英里之间。
你可能还会注意到两个直方图的形状略有不同。似乎二手车价格在中间两侧均匀分布,而汽车里程数则进一步向右延伸。
这种特性被称为偏斜,或更具体地说,是右偏斜,因为高端(右侧)的值比低端(左侧)的值分布得更广。如图所示,偏斜数据的直方图在一边看起来被拉伸:

图 2.6:使用理想化直方图可视化的三种偏斜模式
快速诊断我们数据中的这种模式是直方图作为数据探索工具的优势之一。当我们开始检查数值数据的其他分布模式时,这一点将变得更加重要。
理解数值数据 - 均匀和正态分布
直方图、箱线图以及描述中心位置和分布范围的统计方法,提供了检查特征值分布的方式。一个变量的分布描述了值落在各种范围内的可能性。
如果所有值发生的可能性相同——比如说,在一个记录公平六面骰子投掷值的数据库中——那么这种分布被称为均匀分布。均匀分布很容易通过直方图来检测,因为柱子的高度大约相同。直方图可能看起来像以下图示:

图 2.7:用理想化直方图可视化的均匀分布
重要的是要注意,并非所有随机事件都是均匀的。例如,掷一个加重的六面骰子会导致某些数字比其他数字出现得更频繁。虽然每次掷骰子都会得到一个随机选择的数字,但它们并不等可能。
二手车的 价格 和 里程 数据也显然不是均匀的,因为一些值似乎比其他值更有可能发生。实际上,在 价格 直方图中,似乎随着值远离中心柱的两侧,其发生的可能性会降低,这导致数据呈钟形分布。这种特征在现实世界数据中如此普遍,以至于它是所谓正态分布的标志。正态分布的典型钟形曲线如下图所示:

图 2.8:用理想化直方图可视化的正态分布
尽管存在许多非正态分布类型,但许多现实世界现象生成可以由正态分布描述的数据。因此,正态分布的性质已经被详细研究。
测量扩散——方差和标准差
分布使我们能够使用较少的参数来描述大量值。描述许多类型现实世界数据的正态分布可以用两个参数来定义:中心和扩散。正态分布的中心由其平均值定义,这是我们之前使用的。扩散由一个称为标准差的统计量来衡量。
要计算标准差,我们首先必须获得方差,它被定义为每个值与平均值之间平方差的平均值。在数学符号中,集合 x 中 n 个值的方差由以下公式定义:

在这个公式中,希腊字母 mu(写作
)表示值的平均值,方差本身由希腊字母 sigma 的平方(写作
)表示。
标准差是方差的平方根,用 sigma(写作
)表示,如下公式所示:

在 R 中,var()和sd()函数可以避免我们手动计算方差和标准差。例如,计算price和mileage向量的方差和标准差,我们发现:
> var(usedcars$price)
[1] 9749892
> sd(usedcars$price)
[1] 3122.482
> var(usedcars$mileage)
[1] 728033954
> sd(usedcars$mileage)
[1] 26982.1
在解释方差时,较大的数字表示数据在平均值周围分布得更广。标准差表示平均来说,每个值与平均值相差多少。
如果你使用前面图表中的公式手动计算这些统计量,你将得到与内置 R 函数略有不同的结果。这是因为前面的公式使用的是总体方差(除以n),而 R 使用的是样本方差(除以n - 1)。除了非常小的数据集外,这种区别很小。
标准差可以用来快速估计给定值在假设它来自正态分布的情况下有多极端。68-95-99.7 规则指出,在正态分布中,68%的值在平均值的一个标准差范围内,而 95%和 99.7%的值分别在两个和三个标准差范围内。这在下图中得到说明:

图 2.9:在正态分布平均值的一个、两个和三个标准差范围内的值的百分比
将此信息应用于二手车数据,我们知道price的平均值和标准差分别为$12,962 和$3,122。因此,假设价格呈正态分布,我们数据中大约 68%的汽车广告价格在$12,962 - $3,122 = $9,840 和$12,962 + $3,122 = $16,804 之间。
虽然严格来说,68-95-99.7 规则仅适用于正态分布,但其基本原理适用于几乎任何数据;距离平均值超过三个标准差的数据值往往是非常罕见的事件。
探索分类特征
如果你记得,二手车数据集包含三个分类特征:model、color和transmission。此外,尽管year被存储为数值向量,但每个年份可以想象成适用于多辆车的类别。因此,我们可能将其视为分类数据。
与数值数据不同,分类数据通常使用表格而不是汇总统计来检查。展示单个分类特征的表格被称为单向表。table()函数可以用于为二手车数据生成单向表:
> table(usedcars$year)
2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012
3 1 1 1 3 2 6 11 14 42 49 16 1
> table(usedcars$model)
SE SEL SES
78 23 49
> table(usedcars$color)
Black Blue Gold Gray Green Red Silver White Yellow
35 17 1 16 5 25 32 16 3
table()函数的输出列出了名义变量的类别以及每个类别中值的数量。由于我们知道数据集中有 150 辆二手车,我们可以确定大约三分之一的汽车是在 2010 年制造的,因为 49/150 = 0.327。
R 也可以直接通过在由 table() 函数生成的表格上使用 prop.table() 命令来计算表格比例:
> model_table <- table(usedcars$model)
> prop.table(model_table)
SE SEL SES
0.5200000 0.1533333 0.3266667
prop.table() 的结果可以与其他 R 函数结合使用以转换输出。假设我们希望以百分比形式显示结果,并且保留一位小数。我们可以通过将比例乘以 100 来实现这一点,然后使用 round() 函数并指定 digits = 1,如下面的示例所示:
> color_table <- table(usedcars$color)
> color_pct <- prop.table(color_table) * 100
> round(color_pct, digits = 1)
Black Blue Gold Gray Green Red Silver White Yellow
23.3 11.3 0.7 10.7 3.3 16.7 21.3 10.7 2.0
虽然这包括与默认 prop.table() 输出相同的信息,但这些更改使其更容易阅读。结果显示,黑色是最常见的颜色,占所有广告汽车的近四分之一(23.3%)。银色紧随其后,占 21.3%,红色位居第三,占 16.7%。
测量中心趋势——众数
在统计学术语中,一个特征的众数是出现次数最多的值。像均值和中位数一样,众数是中心趋势的另一种度量。它通常用于分类数据,因为均值和中位数在名义变量中未定义。
例如,在二手车数据中,year 的众数是 2010 年,而 model 和 color 变量的众数分别是 SE 和 Black。一个变量可能有多个众数;只有一个众数的变量是单峰的,而有两个众数的变量是双峰的。具有多个众数的数据更一般地被称为多峰。
虽然你可能怀疑可以使用 mode() 函数,但 R 使用它来获取变量的类型(如数值、列表等),而不是统计众数。相反,为了找到统计众数,只需查看具有最大值的类别的 table() 输出即可。
众数或众数在定性意义上被用来理解重要值。尽管如此,过分强调众数是危险的,因为最常出现的值不一定是多数。例如,尽管黑色是最常见的汽车颜色,但它只占所有广告汽车的约四分之一。
最好将众数与其他类别联系起来思考。是否存在一个类别支配所有其他类别,或者有几个?以这种方式思考众数可能会通过提出关于什么使某些值比其他值更常见的问题来帮助生成可检验的假设。例如,如果黑色和银色是常用的汽车颜色,我们可能会认为数据代表豪华汽车,这些汽车倾向于以更保守的颜色出售。或者,这些颜色可能表明经济型汽车,这些汽车的颜色选择较少。我们将保持这些问题在继续检查这些数据时。
将模式视为常见值,这使我们能够将统计模式的概念应用于数值数据。严格来说,对于连续变量来说,不太可能有模式,因为两个值不太可能重复。然而,如果我们把模式看作是直方图上的最高柱状,我们就可以讨论像price和mileage这样的变量的模式。在探索数值数据时考虑模式可能会有所帮助,尤其是检查数据是否是多模态的。

图 2.10:具有一个和两个模式的数值数据的假设分布
探索特征之间的关系
到目前为止,我们一次只检查一个变量,只计算单变量统计。在我们的调查中,我们提出了之前无法回答的问题:
-
price和mileage数据是否意味着我们只检查经济型汽车,还是有高里程的豪华汽车? -
model和color之间的关系是否提供了我们正在检查的汽车类型的见解?
这些类型的问题可以通过查看双变量关系来解决,它考虑了两个变量之间的关系。多于两个变量的关系称为多变量关系。让我们从双变量情况开始。
可视化关系 - 散点图
散点图是一种可视化数值特征之间双变量关系的图表。它是一个二维图形,其中在坐标平面上绘制点,使用一个特征的值提供水平x坐标,使用另一个特征的值提供垂直y坐标。点的放置模式揭示了两个特征之间的潜在关联。
要回答我们关于price和mileage之间关系的问题,我们将检查散点图。我们将使用plot()函数,以及之前使用的main、xlab和ylab参数来标记图表。
要使用plot(),我们需要指定包含用于在图上定位点的值的x和y向量。尽管使用哪种变量提供x和y坐标的结论都会相同,但惯例规定y变量是假定依赖于另一个的变量(因此被称为因变量)。由于卖家无法修改汽车的里程表读数,因此里程不太可能依赖于汽车的价格。相反,我们的假设是汽车的价格取决于里程表读数。因此,我们将选择price作为因变量y。
创建我们的散点图的完整命令是:
> plot(x = usedcars$mileage, y = usedcars$price,
main = "Scatterplot of Price vs. Mileage",
xlab = "Used Car Odometer (mi.)",
ylab = "Used Car Price ($)")
这产生了以下散点图:

图 2.11:二手车价格与里程的关系
使用散点图,我们注意到二手车价格和里程表读数之间存在明显的关系。要读取图表,检查y轴变量的值如何随着x轴值的增加而变化。在这种情况下,随着里程的增加,汽车价格往往较低。如果你曾经卖过或买过二手车,这并不是一个深刻的见解。
可能更有趣的发现是,很少有汽车同时具有高价格和高里程,除了一个大约 125,000 英里和 14,000 美元的孤立异常值。这种点的缺失为支持结论提供了证据,即我们的数据集不太可能包含任何高里程豪华车。数据中所有最昂贵的汽车,尤其是那些超过 17,500 美元的汽车,似乎里程非常低,这意味着我们可能正在查看一种新售价约为 20,000 美元的单一类型的汽车。
我们观察到的汽车价格和里程之间的关系被称为负关联,因为它形成了一条向下倾斜的点的模式。正关联似乎会形成一条向上倾斜的线。一条平坦的线或看似随机的点分布是两个变量完全不相关的证据。两个变量之间线性关联的强度由一个称为相关性的统计量来衡量。相关性在第六章,预测数值数据 - 回归方法中详细讨论,该章节涵盖了建模线性关系的方法。
请记住,并非所有的关联都形成直线。有时点会形成 U 形或 V 形,而有时模式似乎随着x或y变量值的增加而变得更弱或更强。这样的模式表明两个变量之间的关系不是线性的,因此相关性将是一个衡量它们关联的糟糕指标。
检查关系 - 双向交叉表
要检查两个名义变量之间的关系,可以使用双向交叉表(也称为交叉表或列联表)。交叉表就像散点图一样,它允许你检查一个变量的值如何随着另一个变量的值而变化。其格式是一个表格,其中行是一个变量的水平,而列是另一个变量的水平。表格中每个单元格的计数表示落入行和列组合中的值的数量。
为了回答我们之前关于 model 和 color 之间是否存在关系的疑问,我们将检查一个交叉表。在 R 中有几个函数可以生成双向表,包括我们之前用于单变量表的 table() 函数。Gregory R. Warnes 的 gmodels 包中的 CrossTable() 函数可能是最用户友好的,因为它在一个表格中展示了行、列和边缘百分比,从而节省了我们自己计算这些百分比的时间。如果你还没有按照前一章中的说明安装 gmodels 包,请使用以下命令:
> install.packages("gmodels")
包安装完成后,输入 library(gmodels) 来加载包。虽然你只需要安装一次包,但在你计划使用 CrossTable() 函数的每个 R 会话中,你都需要使用 library() 命令来加载包。
在继续我们的分析之前,让我们通过减少 color 变量的级别数量来简化我们的项目。这个变量有九个级别,但我们实际上并不需要这么多细节。我们真正感兴趣的是汽车的颜色是否保守。为此,我们将九种颜色分为两组——第一组将包括保守色彩黑色、灰色、银色和白色;第二组将包括蓝色、金色、绿色、红色和黄色。我们将创建一个逻辑向量,以指示汽车的颜色是否根据我们的定义是保守的。以下代码返回 TRUE 如果汽车是四种保守颜色之一,否则返回 FALSE:
> usedcars$conservative <-
usedcars$color %in% c("Black", "Gray", "Silver", "White")
你可能注意到了这里有一个新的命令。%in% 操作符为操作符左侧向量中的每个值返回 TRUE 或 FALSE,表示该值是否在操作符右侧的向量中。简单来说,你可以将这一行翻译为“二手车颜色是否在黑色、灰色、银色和白色的集合中?”
检查我们新创建变量的 table() 输出,我们看到大约三分之二的汽车具有保守色彩,而三分之一不具有:
> table(usedcars$conservative)
FALSE TRUE
51 99
现在,让我们通过交叉表来查看不同车型中保守色彩汽车的比例如何变化。由于我们假设车型决定了色彩的选择,因此我们将保守色彩指标视为因变量(y)。因此,CrossTable() 命令如下:
> CrossTable(x = usedcars$model, y = usedcars$conservative)
这将产生以下表格:
Cell Contents
|-------------------------|
| N |
| Chi-square-contribution |
| N / Row Total |
| N / Col Total |
| N / Table Total |
|-------------------------|
Total Observations in Table: 150
| usedcars$conservative
usedcars$model | FALSE | TRUE | Row Total |
-----------------------|----------|-------------|-------------|
SE | 27 | 51 | 78 |
| 0.009 | 0.004 | |
| 0.346 | 0.654 | 0.520 |
| 0.529 | 0.515 | |
| 0.180 | 0.340 | |
-----------------------|----------|-------------|-------------|
SEL | 7 | 16 | 23 |
| 0.086 | 0.044 | |
| 0.304 | 0.696 | 0.153 |
| 0.137 | 0.612 | |
| 0.047 | 0.107 | |
-----------------------|----------|-------------|-------------|
SES | 17 | 32 | 49 |
| 0.007 | 0.004 | |
| 0.347 | 0.653 | 0.327 |
| 0.333 | 0.323 | |
| 0.113 | 0.213 | |
-----------------------|----------|-------------|-------------|
Column Total | 51 | 99 | 150 |
| 0.340 | 0.660 | |
-----------------------|----------|-------------|-------------|
CrossTable() 的输出充满了数字,但顶部的图例(标记为 Cell Contents)说明了如何解释每个值。表格的行表示三种二手车车型:SE、SEL 和 SES(以及一个表示所有车型总体的额外行)。列表示汽车的颜色是否保守(以及一个表示两种颜色类型的总列)。
每个单元格中的第一个值表示具有该车型和颜色组合的汽车数量。比例表示每个单元格对卡方统计量的贡献,行总计,列总计以及表格的总计。
我们最感兴趣的是每个模型的保守型汽车的比例。行比例告诉我们,SE型汽车中有 65.4%(即 0.654)是保守色调,相比之下,SEL型汽车中有 70%(即 0.696),而SES型汽车中有 65.3%(即 0.653)。这些差异相对较小,这表明在每种车型中选择颜色类型上没有实质性的差异。
卡方值指的是单元格对两个变量之间独立性的皮尔逊卡方检验的贡献。尽管对这个测试背后的统计学的完整讨论非常技术性,但这个测试衡量的是表格中单元格计数差异仅由偶然性引起的可能性,这有助于我们证实我们的假设,即组间差异并不显著。通过将表格中六个单元格的贡献相加,我们得到0.009 + 0.004 + 0.086 + 0.044 + 0.007 + 0.004 = 0.154。这是卡方检验统计量。
要计算在假设变量之间没有关联的假设下观察到这个统计量的概率,我们将检验统计量传递给pchisq()函数,如下所示:
> pchisq(0.154, df = 2, lower.tail = FALSE)
[1] 0.9258899
df参数指的是自由度,这是与表格中行和列的数量相关的统计测试的一个组成部分;再次忽略它的含义,它可以计算为(行数 - 1) * (列数 - 1),对于 2x2 表格是 1,对于这里使用的 3x2 表格是 2。设置lower.tail = FALSE请求大约 0.926 的右尾概率,这可以直观地理解为仅由于偶然性获得至少 0.154 或更大测试统计量的概率。
如果卡方检验的概率非常低——可能低于十、五或甚至一百分之一——它提供了强有力的证据表明两个变量是相关的,因为表格中观察到的关联不太可能仅由偶然性引起。在我们的案例中,概率更接近 100%而不是 10%,因此我们不太可能观察到在这个数据集中车型和颜色之间的关联。
而不是手动计算,你还可以通过在调用CrossTable()函数时添加一个额外的参数来指定chisq = TRUE来获得卡方检验的结果。例如:
> CrossTable(x = usedcars$model, y = usedcars$conservative,
chisq = TRUE)
Pearson's Chi-squared test
------------------------------------------------------------
Chi² = 0.1539564 d.f. = 2 p = 0.92591
注意,除了由于四舍五入产生的微小差异外,这会产生与手工计算相同的卡方检验统计量和概率。
在这里进行的卡方检验是许多可以使用传统统计学进行的正式假设检验类型之一。如果你曾经听说过“统计显著”这个短语,这意味着像卡方(或许多其他)这样的统计检验已经执行,并且达到了“显著”的水平——通常是一个小于五 percent 的概率。尽管假设检验超出了这本书的范围,但它将在 第六章,预测数值数据 – 回归方法 中再次简要介绍。
摘要
在本章中,我们学习了在 R 中管理数据的基础知识。我们首先深入研究了用于存储各种类型数据的结构。R 的基本数据结构是向量,它可以扩展和组合成更复杂的数据类型,如列表和数据框。数据框是 R 的一种数据结构,对应于具有特征和示例的数据集的概念。R 提供了用于读取和写入数据框到类似电子表格的表格数据文件的函数。
我们随后探索了一个包含二手车价格的现实世界数据集。我们使用中心性和离散性的常用汇总统计量来检查数值变量,并使用散点图可视化价格和里程表读数之间的关系。接下来,我们使用表格来检查名义变量。在检查二手车数据时,我们遵循了一个可以用于理解任何数据集的探索性过程。这些技能将贯穿本书的其他项目。
现在我们已经花了一些时间了解使用 R 进行数据管理的基础知识,你就可以开始使用机器学习来解决现实世界的问题了。在下一章中,我们将使用最近邻方法来处理我们的第一个分类任务。你可能会惊讶地发现,只需几行 R 代码,机器就能在具有挑战性的医疗诊断任务上实现类似人类的性能。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人见面,并与其他 4000 多人一起学习:

第三章:懒惰学习 – 使用最近邻进行分类
一种好奇的餐饮体验在全球各地城市中出现。顾客在完全黑暗的餐厅中被服务员服务,服务员通过记忆中的路线移动,仅使用他们的触觉和听觉。这些场所的吸引力在于相信剥夺自己的视觉将增强味觉和嗅觉,食物将以新的方式被体验。每一口都提供了一种惊奇感,同时发现了厨师准备的风味。
你能想象一位食客是如何体验未见过的食物的吗?第一口咬下去,感官会感到震惊。主要的味道是什么?食物是咸的还是甜的?它尝起来像他们以前吃过的东西吗?我个人认为这个过程可以用一个稍微修改过的谚语来描述——如果它闻起来像鸭子,尝起来也像鸭子,那么你很可能在吃鸭子。
这说明了可以用于机器学习的一个想法——就像另一个涉及家禽的格言——物以类聚。换句话说,相似的事物往往具有相似的性质。机器学习使用这个原则通过将其放置在与相似或“最近”邻居相同的类别中来对数据进行分类。本章致力于使用这种方法进行分类的分类器。你将学习:
-
定义最近邻分类器的关键概念以及为什么它们被认为是“懒惰”的学习者
-
测量两个例子之间相似度的方法
-
如何应用一个流行的最近邻分类器,称为 k-NN
如果所有关于食物的谈话让你感到饥饿,我们的第一个任务将是通过将其应用于解决一个长期存在的烹饪辩论,来理解 k-NN 方法。
理解最近邻分类
用一句话来说,最近邻分类器通过其将未标记的例子分类为相似标记例子类别的特性来定义。这与章节引言中描述的餐饮体验类似,其中一个人通过比较之前遇到的食物来识别新食物。在最近邻分类中,计算机应用了一种类似人类的能力,回忆过去的经验,对当前情况做出结论。尽管这个想法很简单,但最近邻方法非常强大。它们已被成功用于:
-
计算机视觉应用,包括静态图像和视频中的光学字符识别和面部识别
-
推荐系统预测一个人是否会喜欢一部电影或一首歌
-
识别遗传数据中的模式以检测特定的蛋白质或疾病
通常,最近邻分类器非常适合于特征与目标类别之间存在众多、复杂或难以理解的关系的分类任务,而相似类别类型的项往往相当同质。另一种说法可能是,如果一个概念难以定义,但你一看到就知道,那么最近邻可能适用。另一方面,如果数据有噪声,因此组间没有明显的区分,最近邻算法可能难以识别类别边界。
k-NN 算法
分类中的最近邻方法以k-最近邻算法(k-NN)为例。尽管这可能是最简单的机器学习算法之一,但它仍然被广泛使用。该算法的优点和缺点如下:
| 优点 | 缺点 |
|---|
|
-
简单而有效
-
对底层数据分布不做假设
-
训练阶段快速
|
-
不产生模型,限制了理解特征与类别之间关系的能力
-
需要选择合适的k
-
分类阶段较慢
-
名义特征和缺失数据需要额外处理
|
k-NN 算法的名字来源于它使用关于示例的k个最近邻的信息来对未标记的示例进行分类。字母k是一个变量,意味着可以使用任意数量的最近邻。选择k之后,算法需要一个由已分类的示例组成的训练数据集,这些示例被一个名义变量标记。然后,对于测试数据集中的每个未标记记录,k-NN 识别出训练数据中与相似度“最近”的k个记录。未标记的测试实例被分配给代表k个最近邻中大多数的类别。
为了说明这个过程,让我们回顾一下引言中描述的盲品体验。假设在品尝神秘餐点之前,我们创建了一个数据集,记录了我们之前品尝的一组成分的印象。为了简化问题,我们只对每个成分的两个特征进行了评分。第一个是从 1 到 10 的脆度度量,第二个是从 1 到 10 的甜度评分。然后,我们将每个成分标记为三种食物类型之一:水果、蔬菜或蛋白质,忽略其他食物如谷物和脂肪。
这样一个数据集的前几行可能结构如下:
| 成分 | 甜度 | 脆度 | 食物类型 |
|---|---|---|---|
| 苹果 | 10 | 9 | 水果 |
| 火腿 | 1 | 4 | 蛋白质 |
| 香蕉 | 10 | 1 | 水果 |
| 胡萝卜 | 7 | 10 | 蔬菜 |
| 芹菜 | 3 | 10 | 蔬菜 |
k-NN 算法将特征视为多维特征空间中的坐标,这是一个包含所有可能的特征值组合的空间。因为成分数据集只包括两个特征,所以其特征空间是二维的。我们可以在散点图上绘制二维数据,其中x轴表示成分的甜度,而y轴表示脆度。在味道数据集中添加更多成分后,散点图可能看起来像这样:

图 3.1:所选食品的脆度与甜度散点图
您注意到一个模式吗?相似类型的食品往往紧密地聚集在一起。如图3.2所示,蔬菜往往脆而不甜;水果往往甜且要么脆要么不脆;蛋白质往往既不脆也不甜:

图 3.2:被相似分类的食品往往具有相似的特征
假设我们在构建了这个数据集之后,决定用它来解决一个古老的疑问:番茄是水果还是蔬菜?我们可以使用最近邻方法来确定哪个类别更合适,如图3.3所示:

图 3.3:番茄的最近邻提供了关于它是水果还是蔬菜的见解
使用距离测量相似性
定位番茄的最近邻需要一个距离函数,这是一个衡量两个实例之间相似性的公式。
有许多种计算距离的方法。距离函数的选择可能会对模型的性能产生重大影响,尽管除了直接在期望的学习任务上比较它们之外,很难知道应该使用哪种。传统上,k-NN 算法使用欧几里得距离,这是如果可以使用尺子连接两个点时测量的距离。欧几里得距离是“如鸟飞”测量的,这暗示了最短的直接路线。这在上一个图中通过连接番茄及其邻居的虚线说明了。
另一种常见的距离度量是曼哈顿距离,它基于行人通过走城市街区所走的路径。如果您想了解更多关于其他距离度量的信息,可以使用 R 的?dist命令查看距离函数的文档。
欧几里得距离由以下公式指定,其中p和q是要比较的例子,每个例子都有n个特征。术语p¹指的是例子p的第一个特征值,而q¹指的是例子q的第一个特征值:

距离公式涉及比较每个示例特征的值。例如,为了计算番茄(甜度=6,脆度=4)和绿豆(甜度=3,脆度=7)之间的距离,我们可以使用以下公式:

同样地,我们可以计算番茄与其几个最近邻居之间的距离,如下所示:
| 成分 | 甜度 | 脆度 | 食物类型 | 到番茄的距离 |
|---|---|---|---|---|
| 葡萄 | 8 | 5 | 水果 | sqrt((6 - 8)² + (4 - 5)²) = 2.2 |
| 绿豆 | 3 | 7 | 蔬菜 | sqrt((6 - 3)² + (4 - 7)²) = 4.2 |
| 坚果 | 3 | 6 | 蛋白质 | sqrt((6 - 3)² + (4 - 6)²) = 3.6 |
| 橙子 | 7 | 3 | 水果 | sqrt((6 - 7)² + (4 - 3)²) = 1.4 |
为了将番茄分类为蔬菜、蛋白质或水果,我们首先将番茄分配给其单个最近邻居的食物类型。这被称为 1-NN 分类,因为k = 1。橙子是番茄的单个最近邻居,距离为 1.4。因为橙子是水果,所以 1-NN 算法会将番茄分类为水果。
如果我们使用k = 3的 k-NN 算法,它将在三个最近邻居中进行投票:橙子、葡萄和坚果。现在,因为在这三个邻居中,多数类别是水果(有三个投票中的两个),所以番茄再次被分类为水果。
选择合适的 k 值
如何决定 k-NN 算法中使用的邻居数量决定了模型将如何泛化到未来的数据。在过拟合和欠拟合训练数据之间的平衡是一个被称为偏差-方差权衡的问题。选择较大的k值可以减少由噪声数据引起的方差的影响,但可能会使学习器产生偏差,从而有忽略小但重要模式的风险。
假设我们采取极端立场,将一个非常大的k值设定为与训练数据中观察到的总数一样大。由于每个训练实例都代表最终投票,最常见的类别总是拥有大多数投票者。因此,模型将始终预测多数类别,无论最近的邻居是什么。
在相反的极端情况下,使用单个最近邻居允许噪声数据和异常值过度影响示例的分类。例如,假设一些训练示例被意外地错误标记。任何恰好与错误标记的邻居最近的未标记示例将被预测为具有错误的类别,即使其他九个附近的邻居会投票不同。
显然,最佳的k值介于这两个极端之间。
图 3.4 展示了更一般的情况,说明了决策边界(由虚线表示)如何受到较大或较小的 k 值的影响。较小的值允许更复杂的决策边界,更仔细地拟合训练数据。问题是,我们不知道直线边界还是曲线边界更好地代表了要学习的真实概念。

图 3.4:较大的 k 相比较小的 k 具有更高的偏差和更低的方差
在实践中,k 的选择取决于要学习概念的程度和训练数据中的记录数量。一种常见的方法是从 k 等于训练示例数量的平方根开始。在之前开发的食品分类器中,我们可能会将 k = 4,因为训练数据中有 15 种示例成分,15 的平方根是 3.87。
然而,这样的规则并不总是导致最佳的 k。一种替代方法是测试多种 k 值在各种测试数据集上的表现,并选择提供最佳分类性能的那个。尽管如此,除非数据非常嘈杂,否则大量训练数据集可以使 k 的选择不那么重要。这是因为即使是微妙的概念也会有一个足够大的示例池来作为最近邻进行投票。
对于这个问题的一个不太常见但仍然有趣的解决方案是选择一个较大的 k,并使用加权投票过程,其中较近的邻居的投票被认为比较远的邻居的投票更有权威性。一些 k-NN 实现提供了这个选项。
准备数据以用于 k-NN
在应用 k-NN 算法之前,特征通常会被转换到标准范围内。这一步骤的合理性在于距离公式高度依赖于特征的测量方式。特别是,如果某些特征的范围值比其他特征大得多,距离测量将强烈地受较大范围的特征所支配。在食品品尝的例子中,这不是问题,因为甜味和脆性都是在 1 到 10 的尺度上测量的。
然而,假设我们向数据集添加了一个额外的特征来表示食物的辣度,这是使用斯科维尔尺度测量的。如果你不熟悉这个指标,它是一种标准化的辣度衡量,范围从零(一点也不辣)到超过一百万(对于最热的辣椒)。由于辣味和非辣味食物之间的差异可能超过一百万,而甜味和非甜味或脆性和非脆性食物之间的差异最多为 10,因此这种尺度差异使得辣度对距离函数的影响远大于其他两个因素。如果不调整我们的数据,我们可能会发现我们的距离度量只能区分食物的辣度;脆性和甜味的影响会被辣度的贡献所淹没。
解决方案是通过缩小或扩大特征的范围来重新缩放特征,使得每个特征对距离公式的贡献相对相等。例如,如果甜度和脆度都是用 1 到 10 的尺度来衡量的,我们也希望辣度也是用 1 到 10 的尺度来衡量。有几种常见的方法可以实现这种缩放。
k-NN 特征缩放的传统方法是最小-最大归一化。这个过程将特征转换为一个值,使其落在 0 到 1 之间的范围内。归一化特征的公式如下:

要转换特征X的每个值,公式从最小X值中减去,然后除以X的范围。得到的归一化特征值可以解释为表示原始值在原始最小值和最大值之间的范围内,从 0%到 100%的距离。
另一种常见的转换方法被称为z 分数标准化。以下公式从特征X的均值中减去,然后将结果除以X的标准差:

该公式基于第二章中介绍的正态分布的性质,根据特征值相对于均值的多少个标准差进行重新缩放。得到的值称为z 分数。z 分数落在负数和正数的无界范围内。与归一化值不同,它们没有预定义的最小值和最大值。
在 k-NN 训练数据集上使用的相同缩放方法也必须应用于算法随后将进行分类的测试示例。这可能导致最小-最大归一化出现棘手的情况,因为未来的案例的最小值或最大值可能超出训练数据中观察到的值范围。如果你事先知道理论上的最小值或最大值,你可以使用这些常数而不是观察到的最小值和最大值。或者,你可以假设未来的示例来自与训练示例具有相同均值和标准差的分布,使用 z 分数标准化。
欧几里得距离公式对于名义数据是未定义的。因此,为了计算名义特征之间的距离,我们需要将它们转换为数值格式。一个典型的解决方案是使用虚拟编码,其中 1 表示一个类别,0 表示另一个。例如,男性或非男性性别变量的虚拟编码可以构建如下:

注意,二元(二进制)性别变量的虚拟编码会产生一个名为 male 的单个新特征。不需要为非男性构建单独的特征。由于两者互斥,知道其中一个就足够了。
这在更广泛的意义上也是正确的。一个n-类别的名义特征可以通过为特征的n - 1个级别创建二进制指示变量来进行虚拟编码。例如,对于一个三分类的温度变量(例如,热、中等或冷)的虚拟编码可以设置为(3 - 1) = 2个特征,如下所示:

知道热和中等都是 0,就足以知道温度是冷的,因此,对于冷类别不需要第三个二进制特征。然而,一个广泛使用的虚拟编码的近亲,称为独热编码,为特征的n个级别创建二进制特征,而不是像虚拟编码那样n - 1。它被称为“独热”,因为只有一个属性被编码为 1,其他都被设置为 0。
在实践中,这两种方法几乎没有任何区别,机器学习的成果也不会受到编码选择的影响。尽管如此,独热编码可能会给线性模型带来问题,例如在第六章中描述的预测数值数据 - 回归方法,因此独热编码在统计学家或像经济学这样高度依赖此类模型的领域中通常被避免。另一方面,独热编码在机器学习领域已经变得普遍,并且通常与虚拟编码同义,仅仅是因为这种选择对模型拟合几乎没有影响;然而,在独热编码中,模型本身可能更容易理解,因为所有分类特征的级别都被明确指定。这本书只使用虚拟编码,因为它可以通用,但你可能在其他地方遇到独热编码。
虚拟编码和独热编码的一个方便之处在于,虚拟编码的特征之间的距离总是 1 或 0,因此,这些值与 min-max 归一化的数值数据处于相同的尺度上。不需要额外的转换。
如果一个名义特征是序数的(有人可以为温度提出这样的论点),虚拟编码的一个替代方案是对类别进行编号并应用归一化。例如,冷、暖和热可以编号为 1、2 和 3,这归一化到 0、0.5 和 1。这种方法的注意事项是,它应该只在类别之间的步骤相等时使用。例如,尽管贫困、中产阶级和富裕的收入类别是有序的,但贫困和中产阶级之间的差异可能不同于中产阶级和富裕之间的差异。由于组之间的步骤不相等,虚拟编码是一个更安全的方法。
为什么 k-NN 算法是懒惰的?
基于最近邻方法的分类算法被认为是懒惰学习算法,因为从技术角度来说,没有发生抽象化。抽象化和泛化过程完全被跳过,这违反了在第一章,“介绍机器学习”中提出的学习的定义。
在严格的学习定义下,懒惰学习器实际上并没有学习任何东西。相反,它只是逐字逐句地存储训练数据。这使得训练阶段,实际上并没有进行任何训练,可以非常快速地进行。当然,缺点是预测过程通常相对较慢。由于高度依赖于训练实例而不是抽象化的模型,懒惰学习也被称为基于实例的学习或死记硬背学习。
由于基于实例的学习者不构建模型,这种方法被称为非参数学习方法的类别——关于数据没有学习任何参数。由于没有生成关于潜在数据的理论,非参数方法限制了我们对分类器如何使用数据的理解,尽管它仍然可以做出有用的预测。非参数学习允许学习者找到自然模式,而不是试图将数据拟合到预先设定的和可能存在偏差的函数形式。

图 3.5:机器学习算法有不同的偏差,可能会得出不同的结论!
虽然 k-NN 分类器可能被认为是懒惰的,但它们仍然非常强大。正如你很快就会看到的,最近邻学习的简单原理可以用来自动化癌症筛查的过程。
示例 - 使用 k-NN 算法诊断乳腺癌
定期乳腺癌筛查可以在疾病引起明显症状之前对其进行诊断和治疗。早期检测的过程涉及检查乳腺组织中的异常肿块或团块。如果发现肿块,则进行细针穿刺活检,使用空心针从肿块中提取一小部分细胞。然后,临床医生在显微镜下检查这些细胞,以确定肿块是否可能是恶性的或良性的。
如果机器学习能够自动化识别癌细胞,将对医疗系统带来相当大的好处。自动化的流程可能会提高检测过程的效率,让医生有更多时间用于治疗疾病而不是诊断。自动筛查系统也可能通过消除过程中固有的主观性人类因素,提供更高的检测准确性。
让我们通过将 k-NN 算法应用于来自有异常乳腺肿块女性的活检细胞测量值,来调查机器学习在检测癌症方面的效用。
第 1 步 - 收集数据
我们将利用来自 UCI 机器学习仓库的威斯康星乳腺癌(诊断)数据集,网址为http://archive.ics.uci.edu/ml。这些数据由威斯康星大学的研究人员捐赠,包括来自乳腺肿块细针吸取的数字化图像的测量值。这些值代表数字图像中存在的细胞核的特征。
想要了解更多关于这个数据集的信息,请参阅 《通过线性规划进行乳腺癌诊断和预后,Mangasarian OL,Street WN,Wolberg WH,运筹学,1995,第 43 卷,第 570-577 页》。
乳腺癌数据包括 569 个癌症活检示例,每个示例有 32 个特征。其中一个特征是识别号,另一个是癌症诊断,其余 30 个是数值型实验室测量值。诊断用“M”表示恶性,用“B”表示良性。
这 30 个数值测量值包括 10 个不同特征的平均值、标准误差和最差(即最大)值,例如数字化细胞核的半径、纹理、面积、平滑度和紧密度。根据特征名称,该数据集似乎测量细胞核的形状和大小,但除非你是肿瘤学家,否则你不太可能知道这些特征中的每一个如何与良性或恶性肿块相关。不需要这样的专业知识,因为计算机将在机器学习过程中发现重要的模式。
第 2 步 – 探索和准备数据
通过探索数据,我们可能能够揭示特征与癌症状态之间的关系。在这样做的时候,我们将为使用 k-NN 学习方法准备数据。
如果你打算跟随操作,请从 GitHub 仓库下载代码和wisc_bc_data.csv文件,并将它们保存到你的 R 工作目录中。对于这本书,数据集与其原始形式略有不同。特别是,添加了一个标题行,并且数据行的顺序是随机排列的。
我们将像前几章所做的那样,首先导入 CSV 数据文件,将威斯康星乳腺癌数据保存到wbcd数据框中:
> wbcd <- read.csv("wisc_bc_data.csv")
使用命令str(wbcd),我们可以确认数据结构为 569 个示例和 32 个特征,正如我们所预期的。输出的一些前几行如下:
> str(wbcd)
'data.frame': 569 obs. of 32 variables:
$ id : int 87139402 8910251 905520 ...
$ diagnosis : chr "B" "B" "B" "B" ...
$ radius_mean : num 12.3 10.6 11 11.3 15.2 ...
$ texture_mean : num 12.4 18.9 16.8 13.4 13.2 ...
$ perimeter_mean : num 78.8 69.3 70.9 73 97.7 ...
$ area_mean : num 464 346 373 385 712 ...
第一个特征是一个名为id的整数变量。由于这只是一个为数据中的每个患者提供的唯一标识符(ID),它不提供有用的信息,因此我们需要将其排除在模型之外。
无论使用哪种机器学习方法,ID 变量都应该始终排除。忽略这一点可能导致错误的结果,因为 ID 可以用来正确预测每个示例。因此,包含 ID 列的模型几乎肯定会过度拟合,并且对未来数据的泛化能力较差。
让我们从我们的数据框中删除id特征。由于它位于第一列,我们可以通过复制不带第 1 列的wbcd数据框来排除它:
> wbcd <- wbcd[-1]
下一个特征diagnosis特别有趣,因为它是我们希望预测的目标结果。这个特征表示示例是否来自良性或恶性的肿块。table()输出表明有 357 个肿块是良性的,而 212 个是恶性的:
> table(wbcd$diagnosis)
B M
357 212
许多 R 机器学习分类器需要将目标特征编码为因子,因此我们需要重新编码diagnosis列。我们也将利用这个机会,使用labels参数给"B"和"M"值赋予更具有信息量的标签:
> wbcd$diagnosis <- factor(wbcd$diagnosis, levels = c("B", "M"),
labels = c("Benign", "Malignant"))
当我们查看prop.table()的输出时,现在我们看到值已经被标记为良性和恶性,分别占总体质量的 62.7%和 37.3%:
> round(prop.table(table(wbcd$diagnosis)) * 100, digits = 1)
Benign Malignant
62.7 37.3
剩下的 30 个特征都是数值型,并且如预期的那样,由 10 个特性的三种不同测量组成。为了说明目的,我们只将更仔细地查看这三个特征:
> summary(wbcd[c("radius_mean", "area_mean", "smoothness_mean")])
radius_mean area_mean smoothness_mean
Min. : 6.981 Min. : 143.5 Min. :0.05263
1st Qu.:11.700 1st Qu.: 420.3 1st Qu.:0.08637
Median :13.370 Median : 551.1 Median :0.09587
Mean :14.127 Mean : 654.9 Mean :0.09636
3rd Qu.:15.780 3rd Qu.: 782.7 3rd Qu.:0.10530
Max. :28.110 Max. :2501.0 Max. :0.16340
看着这三个并排,你注意到值有什么问题吗?回想一下,k-NN 的距离计算高度依赖于输入特征的测量尺度。由于平滑度范围从 0.05 到 0.16,而面积范围从 143.5 到 2501.0,面积在距离计算中的影响将远大于平滑度。这可能会给我们的分类器带来潜在问题,所以让我们应用归一化来重新缩放特征到标准值范围内。
转换 – 归一化数值数据
为了归一化这些特征,我们需要在 R 中创建一个normalize()函数。这个函数接受一个数值值向量x,并对x中的每个值,减去x的最小值,然后除以x值的范围。最后,返回结果向量。该函数的代码如下:
> normalize <- function(x) {
return ((x - min(x)) / (max(x) - min(x)))
}
执行前面的代码后,normalize()函数在 R 中可供使用。让我们在几个向量上测试这个函数:
> normalize(c(1, 2, 3, 4, 5))
[1] 0.00 0.25 0.50 0.75 1.00
> normalize(c(10, 20, 30, 40, 50))
[1] 0.00 0.25 0.50 0.75 1.00
函数看起来工作正常。尽管第二个向量中的值是第一个向量的 10 倍,但在归一化后,它们是相同的。
现在,我们可以将normalize()函数应用于我们的数据框中的数值特征。我们不会单独归一化 30 个数值变量中的每一个,而是将使用 R 的一个函数来自动化这个过程。
lapply()函数接受一个列表,并将指定的函数应用于每个列表元素。由于数据框是等长向量的列表,我们可以使用lapply()将normalize()应用于数据框中的每个特征。最后一步是使用as.data.frame()函数将lapply()返回的列表转换为数据框。整个过程如下所示:
> wbcd_n <- as.data.frame(lapply(wbcd[2:31], normalize))
用简单的话说,这个命令将normalize()函数应用于wbcd数据框的第 2 至 31 列,将结果列表转换为数据框,并将其命名为wbcd_n。这里使用_n后缀作为提醒,说明wbcd中的值已经被归一化。
为了确认转换是否正确应用,让我们看一下一个变量的摘要统计信息:
> summary(wbcd_n$area_mean)
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.0000 0.1174 0.1729 0.2169 0.2711 1.0000
如预期的那样,area_mean 变量,最初的范围是 143.5 到 2501.0,现在范围是 0 到 1。
为了简化本例的数据准备,对整个数据集(包括后来将成为测试集的行)应用了最小-最大归一化。从某种意义上说,这违反了我们模拟未知未来数据的假设,因为在实践中,在模型训练时通常不知道真正的最小值和最大值,未来的值可能会超出之前观察到的范围。可能更好的方法是在训练数据中仅使用观察到的最小值和最大值来归一化测试集,甚至可能将任何未来的值限制在先前的最小值或最大值水平。尽管如此,无论是将归一化应用于训练集和测试集一起还是分开,都不太可能对模型的性能产生显著影响,并且在这里也没有这样做。
数据准备 – 创建训练集和测试集
尽管所有 569 个活检都被标记为良性或恶性,但预测我们已经知道的事情并不很有趣。此外,我们在训练过程中获得的任何性能指标可能具有误导性,因为我们不知道数据过度拟合的程度以及学习者对新案例的泛化能力有多好。因此,一个更有趣的问题是我们的学习者在未见数据集上的表现如何。如果我们能够访问一个实验室,我们可以将我们的学习者应用于从下一个 100 个未知癌症状态的肿瘤中获得的测量值,并查看机器学习者的预测与使用传统方法获得的诊断相比有多好。
在没有此类数据的情况下,我们可以通过将我们的数据分成两部分来模拟这种场景:一个用于构建 k-NN 模型的训练集和一个用于估计模型预测准确性的测试集。我们将使用前 469 条记录作为训练集,剩余的 100 条记录来模拟新患者。
使用第二章中介绍的 数据管理和理解 数据提取方法,我们将 wbcd_n 数据框拆分为 wbcd_train 和 wbcd_test:
> wbcd_train <- wbcd_n[1:469, ]
> wbcd_test <- wbcd_n[470:569, ]
如果之前的命令令人困惑,请记住数据是通过使用 [行, 列] 语法从数据框中提取的。行或列值为空表示应包含所有行或列。因此,第一行代码请求第 1 行到第 469 行的所有列,第二行请求第 470 行到第 569 行的 100 行和所有列。
在构建训练集和测试集时,每个数据集必须是完整数据集的代表性子集。wbcd记录已经被随机排序,因此我们可以简单地提取 100 个连续记录来创建一个代表性的测试集。如果数据是按时间顺序或按相似值分组排序的,这就不合适了。在这些情况下,需要使用随机抽样方法。随机抽样将在第五章,分而治之 – 使用决策树和规则进行分类中讨论。
当我们构建归一化的训练集和测试集时,我们排除了目标变量diagnosis。为了训练 k-NN 模型,我们需要将这些类别标签存储在因子向量中,并在训练集和测试集之间分割:
> wbcd_train_labels <- wbcd[1:469, 1]
> wbcd_test_labels <- wbcd[470:569, 1]
此代码从wbcd数据框的第一列中的diagnosis因子创建向量wbcd_train_labels和wbcd_test_labels。我们将在训练和评估分类器的下一步中使用这些向量。
第 3 步 – 在数据上训练模型
配备了我们的训练数据和标签向量,我们现在可以准备好对测试记录进行分类。对于 k-NN 算法,训练阶段不涉及模型构建;训练所谓的“懒惰”学习器(如 k-NN)的过程只是将输入数据以结构化格式存储。
为了对测试实例进行分类,我们将使用class包中的 k-NN 实现,该包提供了一组基本的 R 分类函数。如果此包尚未安装到您的系统上,您可以通过键入以下命令进行安装:
> install.packages("class")
要在任何会话中加载包以使用函数,只需输入library(class)命令。
class包中的knn()函数提供了 kNN 算法的标准、传统实现。对于测试数据中的每个实例,该函数将使用欧几里得距离识别最近的k个邻居,其中k是一个用户指定的数字。通过在最近的k个邻居中进行“投票”,对测试实例进行分类——具体来说,这涉及到将大多数邻居的类别分配给测试实例。平票将通过随机方式打破。
其他 R 包中还有几个其他 k-NN 函数,它们提供了更复杂或更高效的实现。如果您在使用knn()时遇到限制,请在 CRAN 网站上搜索 k-NN:cran.r-project.org。
使用knn()函数进行训练和分类是通过单个命令完成的,该命令需要四个参数,如下表所示:

图 3.6:kNN 分类语法
现在我们几乎已经拥有了应用 k-NN 算法到这些数据所需的一切。我们已经将数据分为训练集和测试集,每个集都有相同的数值特征。训练数据的标签存储在一个单独的因子向量中。唯一剩下的参数是k,它指定了投票中要包含的邻居数量。
由于我们的训练数据包括 469 个实例,我们可能会尝试k = 21,这是一个大约等于 469 平方根的奇数。在双类别结果中,使用奇数消除了最终出现平局投票的可能性。
现在我们可以使用knn()函数对测试数据进行分类:
> wbcd_test_pred <- knn(train = wbcd_train, test = wbcd_test,
cl = wbcd_train_labels, k = 21)
knn()函数返回一个因子向量,其中包含wbcd_test数据集中每个示例的预测标签。我们已经将这些预测分配给了wbcd_test_pred。
第 4 步 – 评估模型性能
流程的下一步是评估wbcd_test_pred向量中的预测类别与wbcd_test_labels向量中的实际值匹配得有多好。为此,我们可以使用gmodels包中的CrossTable()函数,该函数在第二章,管理和理解数据中介绍。如果您还没有这样做,请使用install.packages("gmodels")命令安装此包。
使用library(gmodels)命令加载包后,我们可以创建一个交叉表,表示预测标签和实际标签向量之间的协议。指定prop.chisq = FALSE将排除输出中的不必要卡方值:
> CrossTable(x = wbcd_test_labels, y = wbcd_test_pred,
prop.chisq = FALSE)
生成的表格看起来像这样:
| wbcd_test_pred
Wbcd_test_labels | Benign | Malignant | Row Total |
-----------------------|----------|-------------|-------------|
Benign | 61 | 0 | 61 |
| 1.000 | 0.000 | 0.610 |
| 0.968 | 0.000 | |
| 0.610 | 0.000 | |
-----------------------|----------|-------------|-------------|
Malignant | 2 | 37 | 39 |
| 0.051 | 0.949 | 0.390 |
| 0.032 | 1.000 | |
| 0.020 | 0.370 | |
-----------------------|----------|-------------|-------------|
Column Total | 63 | 37 | 100 |
| 0.630 | 0.370 | |
-----------------------|----------|-------------|-------------|
表格中的单元格百分比表示落入四个类别的值的比例。左上角的单元格表示真阴性结果。这 100 个值中的 61 个是肿块良性且 k-NN 算法正确识别为良性病例。右下角的单元格表示真阳性结果,其中分类器和临床确定的标签都认为肿块是恶性的。总共 100 个预测中有 37 个是真正的阳性。
在另一对角线上的单元格包含着 k-NN 预测与真实标签不一致的示例计数。左下角的两个示例是假阴性结果;在这种情况下,预测值是良性的,但实际上肿瘤是恶性的。这种方向的错误可能极其昂贵,因为它们可能导致患者相信他们是癌症-free,但实际上疾病可能仍在扩散。
如果有的话,右上角的单元格将包含假阳性结果。这些值发生在模型将一个良性肿块分类为恶性的情况下。尽管这种错误比假阴性结果危险更小,但它们也应该被避免,因为它们可能导致医疗保健系统或患者的额外财务负担或压力,因为可能提供不必要的测试或治疗。
如果我们愿意,我们可以通过将每个样本分类为恶性来消除所有假阴性。显然,这不是一个现实的策略。然而,这说明了预测涉及在假阳性率和假阴性率之间取得平衡的事实。在第十章,评估模型性能中,你将学习到评估预测准确性的方法,这些方法可以用来优化性能,并考虑到每种类型错误的成本。
在 100 个样本中,共有 2 个样本被 k-NN 方法错误分类,即 2%。虽然 98%的准确率对于几行 R 代码来说似乎很令人印象深刻,但我们可能尝试对模型进行另一轮迭代,看看是否可以提高性能并减少错误分类的样本数量,尤其是因为这些错误是危险的假阴性。
第 5 步 – 提高模型性能
我们将尝试对之前的分类器进行两种简单的变化。首先,我们将采用一种替代方法来缩放我们的数值特征。其次,我们将尝试几个不同的k值。
转换 – z 分数标准化
虽然归一化通常用于 k-NN 分类,但 z 分数标准化可能是在癌症数据集中缩放特征的更合适方式。
由于 z 分数标准化的值没有预定义的最小值和最大值,极端值不会被压缩到中心。即使没有医学培训,一个人也可能怀疑恶性肿瘤可能导致极端的异常值,因为肿瘤不受控制地生长。考虑到这一点,允许在距离计算中更重视异常值可能是合理的。让我们看看 z 分数标准化是否可以提高我们的预测准确性。
要标准化一个向量,我们可以使用 R 的内置scale()函数,该函数默认使用 z 分数标准化来缩放值。scale()函数可以直接应用于数据框,因此不需要使用lapply()函数。要创建wbcd数据的 z 分数标准化版本,我们可以使用以下命令:
> wbcd_z <- as.data.frame(scale(wbcd[-1]))
这将重新缩放所有特征,除了第一列的diagnosis,并将结果存储为wbcd_z数据框。_z后缀是一个提醒,表示这些值已经进行了 z 分数转换。
为了确认转换是否正确应用,我们可以查看摘要统计信息:
> summary(wbcd_z$area_mean)
Min. 1st Qu. Median Mean 3rd Qu. Max.
-1.4530 -0.6666 -0.2949 0.0000 0.3632 5.2460
z 分数标准化的变量的平均值应该始终为零,范围应该相当紧凑。z 分数小于-3 或大于 3 表示一个极其罕见的价值。考虑到这些标准,检查摘要统计信息,转换似乎已经生效。
如我们之前所做的那样,我们需要将 z 分数转换后的数据分为训练集和测试集,并使用knn()函数对测试实例进行分类。然后我们将使用CrossTable()比较预测标签和实际标签:
> wbcd_train <- wbcd_z[1:469, ]
> wbcd_test <- wbcd_z[470:569, ]
> wbcd_train_labels <- wbcd[1:469, 1]
> wbcd_test_labels <- wbcd[470:569, 1]
> wbcd_test_pred <- knn(train = wbcd_train, test = wbcd_test,
cl = wbcd_train_labels, k = 21)
> CrossTable(x = wbcd_test_labels, y = wbcd_test_pred,
prop.chisq = FALSE)
不幸的是,在下面的表格中,我们新转换的结果显示准确性略有下降。使用我们之前正确分类 98 个百分比的相同实例,我们现在只正确分类了 95 个百分比。更糟糕的是,我们在分类危险的反例方面也没有做得更好。
| wbcd_test_pred
Wbcd_test_labels | Benign | Malignant | Row Total |
-----------------------|----------|-------------|-------------|
Benign | 61 | 0 | 61 |
| 1.000 | 0.000 | 0.610 |
| 0.924 | 0.000 | |
| 0.610 | 0.000 | |
-----------------------|----------|-------------|-------------|
Malignant | 5 | 34 | 39 |
| 0.128 | 0.872 | 0.390 |
| 0.076 | 1.000 | |
| 0.050 | 0.340 | |
-----------------------|----------|-------------|-------------|
Column Total | 66 | 34 | 100 |
| 0.660 | 0.340 | |
-----------------------|----------|-------------|-------------|
测试 k 的不同值
我们可能可以通过检查其在各种k值上的性能来优化 k-NN 模型的性能。使用归一化的训练和测试数据集,需要使用几种不同的k值对相同的 100 条记录进行分类。鉴于我们只测试了六个k值,这些迭代可以通过复制粘贴我们之前的knn()和CrossTable()函数来最简单地执行。然而,也可以编写一个for循环,为名为k_values的向量中的每个值运行这两个函数,如下面的代码所示:
> k_values <- c(1, 5, 11, 15, 21, 27)
> for (k_val in k_values) {
wbcd_test_pred <- knn(train = wbcd_train,
test = wbcd_test,
cl = wbcd_train_labels,
k = k_val)
CrossTable(x = wbcd_test_labels,
y = wbcd_test_pred,
prop.chisq = FALSE)
}
for循环几乎可以读作一个简单的句子:对于k_values向量中命名的每个k_val值,运行knn()函数,同时将参数k设置为当前的k_val,然后为结果预测生成CrossTable()。
在第七章黑盒方法 - 神经网络和支持向量机中描述了一种更复杂的方法,使用 R 的apply()函数之一进行循环,以测试成本参数的各种值并绘制结果。
对于每个迭代,显示了假阴性、假阳性和总体错误率:
| k 值 | 假阴性 | 假阳性 | 错误率 |
|---|---|---|---|
| 1 | 1 | 3 | 4 百分比 |
| 5 | 2 | 0 | 2 百分比 |
| 11 | 3 | 0 | 3 百分比 |
| 15 | 3 | 0 | 3 百分比 |
| 21 | 2 | 0 | 2 百分比 |
| 27 | 4 | 0 | 4 百分比 |
虽然分类器从未完美,但 1-NN 方法能够通过增加假阳性来避免一些假阴性。然而,重要的是要记住,将我们的方法过于紧密地定制到测试数据上是不明智的;毕竟,一组不同的 100 份患者记录可能与我们用来衡量我们性能的记录有所不同。
如果你需要确保学习者能够推广到未来的数据,你可能会随机创建几组 100 名患者的集合,并反复重新测试结果。这些方法将在第十章评估模型性能中进一步讨论,以仔细评估机器学习模型的性能。
摘要
在本章中,我们学习了使用 k-NN 进行分类。与许多分类算法不同,k-最近邻算法不做任何学习——至少不是根据机器学习的正式定义。相反,它只是逐字存储训练数据。然后使用距离函数将未标记的测试示例与训练集中最相似的记录相匹配,并将未标记的示例分配给其最近的邻居的标签。
尽管 k-NN 是一个非常简单的算法,但它可以处理极其复杂的任务,例如癌变组织的识别。在几行简单的 R 代码中,我们能够在使用真实世界数据的例子中,98% 的时间正确地识别出组织是恶性还是良性。尽管这个教学数据集是为了简化建模过程而设计的,但这个练习展示了学习算法能够像人类一样做出准确预测的能力。
在下一章中,我们将探讨一种使用概率来估计观察值落入某些类别可能性的分类方法。比较这种方法与 k-NN 的不同之处将非常有趣。稍后,在第九章 寻找数据组 - 使用 k-means 进行聚类 中,我们将了解 k-NN 的一个近亲,它使用距离度量来完成一个完全不同的学习任务。
加入我们书籍的 Discord 空间
在以下链接加入我们的 Discord 社区,与志同道合的人交流,并与其他 4000 多人一起学习:

第四章:概率学习 - 使用朴素贝叶斯进行分类
当气象学家提供天气预报时,降水通常用“70%降雨可能性”这样的短语来描述。这类预报被称为降水概率报告。你有没有考虑过它们是如何计算的?这是一个令人困惑的问题,因为在现实中,要么下雨,要么不下雨,这是绝对确定的。
天气估计基于概率方法,这些方法涉及描述不确定性。它们使用过去事件的数据来预测未来事件。在天气的情况下,降雨的可能性描述了在相似大气条件下发生降水的先前天数所占的比例。70%的降雨可能性意味着在 10 个过去类似条件下,有 7 个地方发生了降水。
本章介绍了朴素贝叶斯算法,它使用概率的方式与天气预报非常相似。在研究这种方法时,你将了解:
-
概率的基本原理
-
使用 R 分析文本数据所需的专业方法和数据结构
-
如何使用朴素贝叶斯构建短信服务(SMS)垃圾信息过滤器
如果你之前上过统计学课程,本章的一些材料可能对你来说是复习。即便如此,刷新你对概率的了解可能也有帮助。你会发现这些原则是朴素贝叶斯获得这样一个奇怪名称的基础。
理解朴素贝叶斯
理解朴素贝叶斯算法所需的基本统计思想已经存在了几个世纪。这项技术源于 18 世纪数学家托马斯·贝叶斯的工作,他开发了描述事件概率及其在额外信息的基础上如何修订的基础原则。这些原则构成了现在被称为贝叶斯方法的基础。
我们将在稍后更详细地介绍这些方法。现在,只需说一个概率是一个介于零和一之间的数字(或从 0 到 100%)即可,它捕捉了在现有证据的基础上事件发生的可能性。概率越低,事件发生的可能性越小。零概率表示事件肯定不会发生,而一概率表示事件将以绝对确定性发生。生活中最有趣的事件往往具有不确定的概率;估计它们发生的可能性有助于我们通过揭示最可能的结果来做出更好的决策。
基于贝叶斯方法的分类器利用训练数据根据特征值提供的证据计算每个结果的概率。当分类器后来应用于未标记的数据时,它使用这些计算出的概率来预测新示例最可能的类别。这是一个简单的想法,但结果是一个可以与更复杂算法相媲美的方法。事实上,贝叶斯分类器已被用于:
-
文本分类,如垃圾邮件(垃圾邮件过滤)
-
计算机网络中的入侵或异常检测
-
根据一组观察到的症状诊断医疗状况
通常,贝叶斯分类器最适合应用于需要同时考虑多个属性信息以估计结果整体概率的问题。虽然许多机器学习算法忽略了具有较弱影响特征,但贝叶斯方法利用所有可用证据微妙地改变预测。这意味着即使大部分特征的影响相对较小,但在贝叶斯模型中它们的综合影响可能相当大。
贝叶斯方法的基本概念
在深入研究朴素贝叶斯算法之前,花些时间定义贝叶斯方法中使用的概念是值得的。用一句话总结,贝叶斯概率理论根植于这样一个观点:估计一个 事件 或潜在结果的似然性应该基于多个 试验 或事件发生机会的证据。
下表展示了几个现实世界结果的事件和试验:
| 事件 | 试验 |
|---|---|
| 正面朝上 | 抛硬币 |
| 雨天 | 单日(或另一个时间段) |
| 消息是垃圾邮件 | 一封 incoming 电子邮件 |
| 候选人成为总统 | 总统选举 |
| 死亡率 | 医院病人 |
| 中奖 | 一张彩票 |
贝叶斯方法提供了从观察数据中估计这些事件概率的见解。为了了解这一点,我们需要形式化我们对概率的理解。
理解概率
通过将事件发生的试验次数除以总试验次数来估计事件的概率。例如,如果今天有类似条件的 10 天中有 3 天下雨,那么今天下雨的概率可以估计为 3 / 10 = 0.30 或 30%。同样,如果 50 封之前的电子邮件中有 10 封是垃圾邮件,那么任何新收到的邮件是垃圾邮件的概率可以估计为 10 / 50 = 0.20 或 20%。
为了表示这些概率,我们使用形式为 P(A) 的符号,它表示事件 A 的概率。例如,P(rain) = 0.30 表示有 30% 的降雨概率,或 P(spam) = 0.20 描述一个新收到的消息有 20% 的概率是垃圾邮件。
由于试验总是导致某些结果发生,因此试验所有可能结果的概率总和必须始终为 1。因此,如果试验恰好有两个结果且这些结果不能同时发生,那么知道任意一个结果发生的概率就可以揭示另一个结果发生的概率。这种情况适用于许多结果,例如硬币的正反面,或垃圾邮件与合法电子邮件(也称为“ham”),使用这个原理,知道P(spam) = 0.20可以让我们计算出P(ham) = 1 – 0.20 = 0.80。这仅适用于垃圾邮件和 ham 是互斥且穷尽的事件,这意味着它们不能同时发生,并且是唯一的可能结果。
单个事件不能同时发生和未发生。这意味着事件总是与其补集互斥且穷尽,或者包含所有其他结果的补集,其中感兴趣的事件未发生。事件A的补集通常表示为A^c 或A’。
此外,可以使用简写符号P(A^c)或P(¬A)来表示事件A不发生的概率。例如,符号P(¬spam) = 0.80表示消息不是垃圾邮件的概率为 80%。
为了说明事件及其补集,想象一个二维空间,该空间被划分为每个事件的概率,通常是有帮助的。在以下图中,矩形代表电子邮件消息的可能结果。圆圈代表消息是垃圾邮件的 20%概率。剩余的 80%代表补集P(¬spam),或不是垃圾邮件的消息:

图 4.1:所有电子邮件的概率空间可以表示为垃圾邮件和正常邮件的分区
理解联合概率
通常,我们在同一试验中会对几个非互斥事件进行监控。如果某些事件与感兴趣的事件同时发生,我们可能能够利用它们进行预测。例如,考虑一个基于电子邮件消息包含单词Viagra的结果的第二个事件。更新此第二个事件的先前列表可能如下所示:

图 4.2:非互斥事件表示为重叠的分区
注意在图中,Viagra 圆圈与图中的垃圾邮件和 ham 区域重叠,并且垃圾邮件圆圈包括 Viagra 圆圈未覆盖的区域。这表明并非所有垃圾邮件都包含 Viagra 这个词,并且一些包含 Viagra 的消息是 ham。然而,由于这个词在垃圾邮件之外出现得非常少,它在新的传入消息中的出现将是该消息是垃圾邮件的强烈证据。
为了更仔细地观察这两个圆之间的重叠,我们将使用一种称为维恩图的可视化方法。这种图最早在 19 世纪末由数学家约翰·文恩使用,它使用圆来表示项目集合的重叠。与大多数维恩图一样,图中圆的大小和重叠程度没有意义。相反,它被用作提醒,将概率分配给所有事件组合。垃圾邮件和 Viagra 的维恩图可能如下所示:

图 4.3:一个维恩图展示了垃圾邮件和 Viagra 事件的交集
我们知道所有消息中有 20%是垃圾邮件(左边的圆),所有消息中有 5%包含Viagra这个词(右边的圆)。我们希望量化这两个比例之间的重叠程度。换句话说,我们希望估计P(spam)和P(Viagra)同时发生的概率,这可以写成P(spam
Viagra)。
符号表示两个事件的交集;A
B的表示法指的是A和B同时发生的事件。
计算P(spam
Viagra)取决于两个事件的联合概率,即一个事件的概率如何与另一个事件的概率相关。如果两个事件完全无关,它们被称为独立事件。这并不是说独立事件不能同时发生;事件独立性仅仅意味着知道一个事件的结局不会提供任何关于另一个事件结局的信息。例如,抛硬币得到正面结果的结局与某一天是雨天还是晴天无关。
如果所有事件都是独立的,那么通过观察另一个事件来预测一个事件将是不可能的。换句话说,相关事件是预测建模的基础。就像云的存在预示着雨天一样,Viagra这个词的出现预示着垃圾邮件。

图 4.4:机器学习如何识别有用模式需要相关事件
计算相关事件的概率比独立事件要复杂一些。如果P(spam)和P(Viagra)是独立的,我们可以轻松地计算出P(spam
Viagra),即两个事件同时发生的概率。因为所有消息中有 20%是垃圾邮件,所有邮件中有 5%包含Viagra这个词,我们可以假设所有包含Viagra这个词的消息中有 1%是垃圾邮件。这是因为0.05 * 0.20 = 0.01。更普遍地,对于独立事件A和B,两个事件同时发生的概率可以计算为P(A
B) = P(A) * P(B)。
话虽如此,我们知道P(spam)和P(Viagra)很可能高度相关,这意味着这个计算是不正确的。为了得到一个更合理的估计,我们需要使用这两个事件之间关系的更谨慎的公式,这个公式基于更先进的贝叶斯方法。
使用贝叶斯定理计算条件概率
可以使用贝叶斯定理来描述相关事件之间的关系,它提供了一种思考如何根据另一个事件提供的证据来修订一个事件概率估计的方法。一种公式如下:

符号P(A|B)读作事件B发生的情况下事件A的概率。这被称为条件概率,因为A的概率依赖于(即条件于)事件B的发生。
贝叶斯定理表明,P(A|B)的最佳估计是在所有发生事件B的试验中,事件A发生的试验比例。这意味着如果每次观察到B时A和B经常一起发生,事件A的概率就会更高。请注意,这个公式调整了P(A
B)以反映B发生的概率。如果B非常罕见,P(B)和P(A
B)将始终很小;然而,如果A几乎总是与B一起发生,尽管B很罕见,P(A|B)仍然会很高。
根据定义,P(A
B) = P(A|B) * P(B),这是一个可以通过对先前公式应用一点代数轻松推导出的事实。利用P(A
B) = P(B
A)的知识重新排列这个公式,我们得出结论,P(A
B) = P(B|A) * P(A),我们可以在贝叶斯定理的以下公式中使用它:

事实上,这是基于我们将它应用于机器学习时将变得清晰的原因的传统贝叶斯定理公式。首先,为了更好地理解贝叶斯定理在实际中的工作原理,让我们回顾一下我们的假设性垃圾邮件过滤器。
在不知道收到的邮件内容的情况下,对其是否为垃圾邮件的最佳估计将是P(spam),即任何先前邮件是垃圾邮件的概率。这个估计被称为先验概率。我们之前发现这个概率是 20%。
假设你通过更仔细地查看先前收到的邮件集并检查“Viagra”一词出现的频率获得了额外的证据。单词Viagra在先前垃圾邮件中被使用的概率,或P(Viagra|spam),被称为似然性。Viagra在任何邮件中出现的概率,或P(Viagra),被称为边缘似然性。
通过将贝叶斯定理应用于这一证据,我们可以计算一个后验概率,该概率衡量一条消息是垃圾邮件的可能性。如果后验概率大于 50%,则该消息更有可能是垃圾邮件而不是正常邮件,可能需要过滤。以下公式显示了贝叶斯定理是如何应用于先前电子邮件消息提供的证据的:

图 4.5:贝叶斯定理作用于先前收到的电子邮件
要计算贝叶斯定理的组成部分,构建一个频率表(如下表中左侧所示)记录Viagra在垃圾邮件和正常邮件中出现的次数是有帮助的。就像一个双向交叉表一样,表的其中一个维度表示类别变量(垃圾邮件或正常邮件)的水平,而另一个维度表示特征(Viagra:是或否)的水平。然后,单元格表示具有指定类别值和特征值的实例数量。
然后,可以使用频率表来构建一个似然表,如下表中右侧所示。似然表的行表示在电子邮件是垃圾邮件或正常邮件的情况下,对于Viagra(是/否)的条件概率。

图 4.6:频率和似然表是计算垃圾邮件后验概率的基础
似然表显示P(Viagra=Yes|spam) = 4 / 20 = 0.20,这表明如果一条消息是垃圾邮件,那么包含术语Viagra的概率是 20%。此外,由于P(A
B) = P(B|A) * P(A),我们可以计算P(spam
Viagra)为P(Viagra|spam) * P(spam) = (4 / 20) * (20 / 100) = 0.04。同样的结果可以在频率表中找到,该表指出 100 条消息中有 4 条是垃圾邮件并包含术语Viagra。无论如何,这比我们之前在错误假设独立性下计算的P(A
B) = P(A) * P(B)的估计值 0.01 高出四倍。这当然说明了贝叶斯定理在估计联合概率中的重要性。
要计算后验概率,P(spam|Viagra),我们只需取P(Viagra|spam) * P(spam) / P(Viagra),或者(4 / 20) * (20 / 100) / (5 / 100) = 0.80。因此,如果一条消息包含单词Viagra,那么这条消息是垃圾邮件的概率是 80%。鉴于这一发现,任何包含此术语的消息可能应该被过滤。
这正是商业垃圾邮件过滤器的工作方式,尽管在计算频率和似然表时,它们会同时考虑更多的单词。在下一节中,我们将看到如何将这种方法适应涉及额外特征的情况。
简单贝叶斯算法
朴素贝叶斯算法定义了一种简单的方法,将贝叶斯定理应用于分类问题。尽管它不是唯一利用贝叶斯方法的机器学习方法,但它是最常见的。由于它在文本分类中的成功,朴素贝叶斯变得非常流行,一度成为事实上的标准。该算法的优点和缺点如下:
| 优点 | 缺点 |
|---|
|
-
简单、快速且非常有效
-
在有噪声和缺失数据以及大量特征的情况下表现良好
-
需要相对较少的训练示例
-
容易获得预测的估计概率
|
-
依赖于一个经常是错误的假设,即特征同等重要且相互独立
-
不适用于具有许多数值特征的集合
-
估计的概率不如预测的类别可靠
|
朴素贝叶斯算法之所以被称为“朴素”,是因为它对数据做出了一些所谓的“朴素”假设。特别是,朴素贝叶斯假设数据集中的所有特征都是同等重要且相互独立的。在大多数实际应用中,这些假设很少是真实的。
例如,当尝试通过监控电子邮件消息来识别垃圾邮件时,几乎可以肯定的是,某些特征将比其他特征更重要。例如,电子邮件发送者可能是比消息文本更重要的垃圾邮件指示器。此外,消息正文中的单词并不是相互独立的,因为某些单词的出现是其他单词也很可能出现的很好指示。包含“Viagra”一词的消息很可能也包含“prescription”或“drugs”一词。
然而,在大多数情况下,即使这些假设被违反,朴素贝叶斯仍然表现出惊人的性能。即使在特征之间存在强依赖性的情况下,也是如此。由于该算法在各种条件下的灵活性和准确性,尤其是在较小的训练数据集上,朴素贝叶斯经常是分类学习任务的合理基线候选者。
尽管朴素贝叶斯存在错误的假设,但它为何表现良好的确切原因一直是许多猜测的对象。一种解释是,只要预测准确,获得概率的精确估计并不重要。例如,如果一个垃圾邮件过滤器正确地识别出垃圾邮件,那么预测垃圾邮件的概率是 51%还是 99%又有什么关系呢?关于这个话题的一个讨论,请参阅《在零一损失下简单贝叶斯分类器的最优性》,作者:Domingos, P. 和 Pazzani, M.,机器学习,1997 年,第 29 卷,第 103-130 页。
使用朴素贝叶斯进行分类
让我们通过添加一些额外的监控术语来扩展我们的垃圾邮件过滤器,除了术语Viagra之外,还包括money、groceries和unsubscribe。朴素贝叶斯学习器通过构建这四个词(标记为W¹、W²、W³和W⁴)出现的可能性表来训练,如下图中 100 封电子邮件所示:

图 4.7:扩展的表格增加了垃圾邮件和正常邮件中额外术语的可能性
当收到新消息时,我们需要计算后验概率,以确定它们更有可能是垃圾邮件还是正常邮件,给定在消息文本中找到这些词的可能性。例如,假设一条消息包含术语Viagra和unsubscribe,但不包含money或groceries。
使用贝叶斯定理,我们可以将问题定义为以下公式。这计算了在Viagra = 是、Money = 否、Groceries = 否和Unsubscribe = 是的条件下,一条消息是垃圾邮件的概率:

由于两个原因,这个公式在计算上很难解决。首先,随着附加特征的添加,需要大量的内存来存储所有可能交点事件的概率。想象一下四个词事件 Venn 图的复杂性,更不用说数百个或更多。其次,许多这些潜在的交点在过去的资料中从未被观察到,这会导致联合概率为零,并导致后面会变得明显的问题。
如果我们利用朴素贝叶斯对事件之间独立性的朴素假设,计算将变得更加合理。具体来说,它假设类条件独立性,这意味着只要事件基于相同的类值,它们就是独立的。条件独立性假设允许我们使用独立事件的概率规则,该规则指出 P(A
B) = P(A) * P(B)。这通过允许我们乘以单个条件概率而不是计算复杂的条件联合概率来简化分子。
最后,因为分母不依赖于目标类(垃圾邮件或正常邮件),它被视为一个常数,暂时可以忽略。这意味着垃圾邮件的条件概率可以表示为:

并且可以表示消息是正常邮件的概率为:

注意,等号已被比例符号(类似于侧向的、开口的“8”)替换,以表明分母已被省略。
使用可能性表中的值,我们可以开始填写这些方程中的数字。然后,垃圾邮件的整体可能性如下:
(4 / 20) * (10 / 20) * (20 / 20) * (12 / 20) * (20 / 100) = 0.012
而正常邮件的可能性是:
(1 / 80) * (66 / 80) * (71 / 80) * (23 / 80) * (80 / 100) = 0.002
因为0.012 / 0.002 = 6,我们可以说这条消息有 6 倍的可能性是垃圾邮件,而不是 ham。然而,为了将这些数字转换为概率,我们需要最后一步重新引入之前排除的除数。本质上,我们必须通过除以所有可能结果的总似然值来重新缩放每个结果的似然值。
这样,垃圾邮件的概率等于消息是垃圾邮件的可能性除以消息是垃圾邮件或 ham 的可能性:
0.012 / (0.012 + 0.002) = 0.857
同样,ham 的概率等于消息是 ham 的可能性除以消息是垃圾邮件或 ham 的可能性:
0.002 / (0.012 + 0.002) = 0.143
根据这条消息中找到的单词模式,我们预计这条消息有 85.7%的概率是垃圾邮件,有 14.3%的概率是 ham。因为这些是互斥且穷尽的概率事件,所以它们的概率总和为 1。
在前一个例子中使用的朴素贝叶斯分类算法可以用以下公式总结。给定特征[F][1]到[F][n]提供的证据,对于类别C的L级概率等于每个证据在类别级条件下的概率乘积,类别级的先验概率,以及一个缩放因子1 / Z,它将似然值转换为概率。这被公式化为:

尽管这个方程看起来令人畏惧,但正如垃圾邮件过滤示例所示,这一系列步骤相当简单。首先构建一个频率表,然后使用这个表构建一个似然表,并使用朴素假设的独立性乘出条件概率。最后,除以总似然值,将每个类别的似然值转换为概率。尝试手动计算几次后,这将成为第二本能。
拉普拉斯估计器
在我们使用朴素贝叶斯解决更复杂的问题之前,有一些细微之处需要考虑。假设我们收到了另一条消息,这次包含所有四个术语:Viagra,groceries,money,和unsubscribe。使用之前的方法,我们可以计算垃圾邮件的可能性为:
(4 / 20) * (10 / 20) * (0 / 20) * (12 / 20) * (20 / 100) = 0
并且 ham 的似然值为:
(1 / 80) * (14 / 80) * (8 / 80) * (23 / 80) * (80 / 100) = 0.00005
因此,垃圾邮件的概率为:
0 / (0 + 0.00005) = 0
并且 ham 的概率为:
0.00005 / (0 + 0.00005) = 1
这些结果表明,该消息有 0%的概率是垃圾邮件,有 100%的概率是正常邮件。这个预测有道理吗?可能没有。消息中包含了一些通常与垃圾邮件相关的词汇,包括伟哥,这在合法消息中很少使用。因此,该消息被错误分类的可能性非常大。
如果一个事件在类的某个或多个级别上从未发生,那么由此产生的似然值将是零。例如,术语杂货以前从未出现在垃圾邮件中。因此,P(groceries|spam) = 0%。
现在,由于朴素贝叶斯公式中的概率是链式相乘的,这个零值导致垃圾邮件的后验概率为零,使得单词杂货能够有效地抵消并推翻所有其他证据。即使电子邮件在其他方面几乎肯定会被认为是垃圾邮件,垃圾邮件中缺少单词杂货也会始终否决其他证据,导致垃圾邮件的概率为零。
解决这个问题的方法涉及使用一种称为拉普拉斯估计器的东西,该估计器是以法国数学家皮埃尔-西蒙·拉普拉斯的名字命名的。拉普拉斯估计器将一个小数加到频率表中的每个计数上,这确保了每个特征在每种类别中都有非零发生的概率。通常,拉普拉斯估计器设置为 1,这确保每个类别-特征组合至少在数据中出现一次。
拉普拉斯估计器可以设置为任何值,并且不一定需要对每个特征都相同。如果你是一个虔诚的贝叶斯主义者,你可以使用拉普拉斯估计器来反映一个特征与类别相关的假设先验概率。在实践中,给定足够大的训练数据集,这通常是过度的。因此,值 1 几乎总是被使用。
让我们看看这如何影响我们对这条消息的预测。使用拉普拉斯值为 1,我们在似然函数的每个分子上加上 1。然后,我们需要在每个条件概率的分母上加上 4,以补偿分子上增加的 4 个额外值。因此,垃圾邮件的可能性是:
(5 / 24) * (11 / 24) * (1 / 24) * (13 / 24) * (20 / 100) = 0.0004
正常邮件的可能性是:
(2 / 84) * (15 / 84) * (9 / 84) * (24 / 84) * (80 / 100) = 0.0001
通过计算0.0004 / (0.0004 + 0.0001),我们发现垃圾邮件的概率是 80%,因此正常邮件的概率大约是 20%。这个结果比仅用术语杂货确定结果时计算的P(spam) = 0更合理。
尽管拉普拉斯估计量被添加到了似然函数的分子和分母中,但它并没有被添加到先验概率中——即 20/100 和 80/100 的值。这是因为,根据数据中观察到的结果,我们对于垃圾邮件和正常邮件的整体概率的最佳估计仍然是 20%和 80%。
使用数值特征与简单贝叶斯
简单贝叶斯使用频率表来学习数据,这意味着每个特征必须是分类的,以便创建由类和特征值组成的矩阵的组合。由于数值特征没有值类别,因此前面的算法不能直接处理数值数据。然而,有几种方法可以解决这个问题。
一个简单而有效的方法是将数值特征离散化,这仅仅意味着将数字放入称为分组的类别中。因此,离散化有时也被称为分组。这种方法在有大量训练数据时效果最佳。
有几种不同的方法可以对数值特征进行离散化。可能最常见的方法是探索数据中的自然类别或分布中的切割点。例如,假设你向垃圾邮件数据集中添加了一个特征,记录了邮件发送的白天或夜晚时间,从午夜过后的 0 到 24 小时。使用直方图表示,时间数据可能看起来像以下图表:

图 4.8:可视化接收电子邮件时间分布的直方图
在清晨时分,消息频率较低。在办公时间活动增加,而在晚上逐渐减少。这形成了四个自然的活动分组,如图中虚线所示。这些表示可以将数值数据划分为不同级别以创建新的分类特征的地方,然后可以使用简单贝叶斯。
四个分组的选取是基于数据的自然分布以及对于一整天中垃圾邮件比例可能变化的直觉。我们可能预期垃圾邮件发送者会在深夜活动,或者他们可能在白天人们检查邮件时活动。尽管如此,为了捕捉这些趋势,我们同样可以使用三个或十二个分组。
如果没有明显的切割点,一个选项是使用在第二章中介绍的量数对特征进行离散化。你可以用三分位数将数据分为三个分组,用四分位数分为四个分组,或者用五分位数分为五个分组。
需要注意的是,将数值特征离散化总是会导致信息量的减少,因为特征的原有粒度被减少到更少的类别。重要的是要找到平衡点。分类太少可能导致重要趋势被掩盖。分类太多可能导致朴素贝叶斯频率表中的计数很小,这可能会增加算法对噪声数据的敏感性。
示例 – 使用朴素贝叶斯算法过滤手机垃圾邮件
随着全球手机使用的增长,不道德的营销人员开辟了一条新的电子垃圾邮件途径。这些广告商利用短信文本消息针对潜在消费者发送不受欢迎的广告,即短信垃圾邮件。这种垃圾邮件很麻烦,因为与电子邮件垃圾邮件不同,短信消息由于手机无处不在,特别具有破坏性。开发一个能够过滤短信垃圾邮件的分类算法将为手机服务提供商提供一个有用的工具。
由于朴素贝叶斯分类器在电子邮件垃圾邮件过滤中已被成功应用,因此它似乎也可以应用于短信垃圾邮件。然而,相对于电子邮件垃圾邮件,短信垃圾邮件对自动过滤器提出了额外的挑战。短信消息通常限制在 160 个字符以内,这减少了用于识别消息是否为垃圾信息的文本量。这种限制,加上小型手机键盘,导致许多人采用一种短信缩写语,这进一步模糊了合法消息和垃圾邮件之间的界限。让我们看看简单的朴素贝叶斯分类器如何应对这些挑战。
第 1 步 – 收集数据
为了开发朴素贝叶斯分类器,我们将使用从www.dt.fee.unicamp.br/~tiago/smsspamcollection/的短信垃圾邮件收集中改编的数据。
要了解更多关于短信垃圾邮件收集如何发展的信息,请参阅关于新的短信垃圾邮件收集的有效性,Gómez, J. M.,Almeida, T. A.,和 Yamakami, A.,2012 年第 11 届 IEEE 国际机器学习与应用会议论文集。
此数据集包括短信文本,以及一个标签,表示消息是否不受欢迎。垃圾消息被标记为垃圾邮件,而合法消息被标记为 ham。以下表格显示了垃圾邮件和 ham 的一些示例:
| 样本短信垃圾信息 | 样本短信垃圾邮件 |
|---|
|
-
更好。周五我补上了,昨天吃得像猪一样。现在感觉有点糟糕。但至少不是那种让人难以忍受的疼痛。
-
如果他开始寻找,几天内就能找到工作。他有很大的潜力和才能。
-
我又找到一份工作了!是在医院做数据分析之类的,周一开始!不确定我的论文什么时候能完成。
|
-
恭喜你,你获得了 500 元的 CD 代金券或 125 元的礼品保证,并且免费参加每周一次的抽奖活动,发送短信 MUSIC 到 87066。
-
12 月特惠!你的手机使用 11 个月以上了吗?你有权免费升级到最新的彩色摄像头手机!免费拨打 08002986906 至移动更新公司。
-
情人节特别优惠!在我们的问答比赛中赢得 1000 英镑,并带你的伴侣去一次终身难忘的旅行!现在发送 GO 到 83600。每条信息 150 便士。
|
观察前面的消息,你是否注意到垃圾邮件的任何显著特征?一个值得注意的特征是,三个垃圾邮件中有两个使用了单词免费,而这个词并没有出现在任何正常邮件中。另一方面,两条正常邮件提到了具体的星期几,而垃圾邮件中则没有。
我们的朴素贝叶斯分类器将利用单词频率中的这些模式来确定短信消息似乎更适合垃圾邮件还是正常邮件。虽然不能排除单词免费出现在垃圾邮件之外的情况,但合法的消息更有可能提供额外的单词来提供上下文。
例如,一条正常邮件可能会问:“你星期天有空吗?”而一条垃圾邮件可能会使用短语“免费铃声”。分类器将根据消息中所有单词提供的证据计算垃圾邮件和正常邮件的概率。
第 2 步 - 探索和准备数据
构建我们的分类器第一步涉及对原始数据进行处理以进行分析。文本数据准备起来具有挑战性,因为需要将单词和句子转换成计算机可以理解的形式。我们将把我们的短信数据转换成一种称为词袋的表示形式,它提供了一种二元特征,表示每个单词是否出现在给定的示例中,同时忽略单词顺序或单词出现的上下文。尽管这是一种相对简单的表示形式,但正如我们很快将展示的,它对于许多分类任务来说已经足够好了。
这里使用的数据集已经从原始数据集稍作修改,以便更容易在 R 中使用。如果你打算跟随示例,请从 Packt 网站下载sms_spam.csv文件并将其保存到你的 R 工作目录中。
我们将首先导入 CSV 数据并将其保存到数据框中:
> sms_raw <- read.csv("sms_spam.csv")
使用str()函数,我们可以看到sms_raw数据框包括 5559 条总短信消息,具有两个特征:type和text。短信类型已被编码为 ham 或 spam。text元素存储完整的原始短信消息文本:
> str(sms_raw)
'data.frame': 5559 obs. of 2 variables:
$ type: chr "ham" "ham" "ham" "spam" ...
$ text: chr "Hope you are having a good week. Just checking in"
"K..give back my thanks." "Am also doing in cbe only. But have to
pay." "complimentary 4 STAR Ibiza Holiday or £10,000 cash needs your
URGENT collection. 09066364349 NOW from Landline not to lose out"|
__truncated__ ...
type元素目前是一个字符向量。由于这是一个分类变量,最好将其转换为因子,如下面的代码所示:
> sms_raw$type <- factor(sms_raw$type)
使用str()和table()函数检查,我们发现type现在已被适当地重新编码为因子。此外,我们还可以看到在我们的数据中,有 747 条(大约 13%)短信消息被标记为spam,其余的则被标记为ham:
> str(sms_raw$type)
Factor w/ 2 levels "ham","spam": 1 1 1 2 2 1 1 1 2 1 ...
> table(sms_raw$type)
ham spam
4812 747
目前,我们将保持短信文本不变。您将在下一节中了解到,处理原始短信需要使用一套专门为处理文本数据设计的强大工具。
数据准备——清洗和标准化文本数据
短信消息是由单词、空格、数字和标点符号组成的文本字符串。处理这种复杂的数据类型需要大量的思考和努力。需要考虑如何删除数字和标点;处理无趣的词,例如和、但和或;以及将句子分解成单个单词。幸运的是,这种功能已经由 R 社区的成员在一个名为tm的文本挖掘包中提供。
tm包最初是由维也纳经济和商业大学的 Ingo Feinerer 作为学位论文项目创建的。要了解更多信息,请参阅《R 中的文本挖掘基础设施》,作者:Feinerer, I.,Hornik, K.和 Meyer, D.,统计软件杂志,2008 年,第 25 卷,第 1-54 页。
可以通过install.packages("tm")命令安装tm包,并通过library(tm)命令加载。即使您已经安装了它,重新安装也可能值得,以确保您的版本是最新的,因为tm包仍在积极开发中。这偶尔会导致其功能发生变化。
本章使用tm版本 0.7-11 进行了测试,截至 2023 年 5 月,这是当前版本。如果您看到输出有差异或代码无法工作,您可能使用的是不同版本。本书的 Packt 支持页面以及其 GitHub 仓库将发布针对未来tm包版本的解决方案,如果发现重大变化。
处理文本数据的第一个步骤是创建一个语料库,它是一组文本文档的集合。文档可以是短或长的,从单个新闻文章、书籍的页面、网页页面,甚至整本书。在我们的情况下,语料库将是一组短信。
要创建一个语料库,我们将使用tm包中的VCorpus()函数,它指的是一个易失性语料库——这里的“易失性”意味着它存储在内存中,而不是存储在磁盘上(使用PCorpus()函数来访问存储在数据库中的永久性语料库)。此函数要求我们指定语料库文档的来源,这可能是计算机的文件系统、数据库、网络或其他地方。
由于我们已经在 R 中加载了短信文本,我们将使用VectorSource()读取器函数从现有的sms_raw$text向量创建一个源对象,然后可以将其如下提供给VCorpus():
> sms_corpus <- VCorpus(VectorSource(sms_raw$text))
生成的语料库对象以sms_corpus命名保存。
通过指定可选的readerControl参数,VCorpus()函数可以用于从 PDF 和 Microsoft Word 文件等来源导入文本。要了解更多信息,请使用vignette("tm")命令检查tm包的数据导入部分。
通过打印语料库,我们看到它包含训练数据中 5,559 条短信消息的文档:
> print(sms_corpus)
<<VCorpus>>
Metadata: corpus specific: 0, document level (indexed): 0
Content: documents: 5559
现在,因为tm语料库本质上是一个复杂列表,我们可以使用列表操作来选择语料库中的文档。inspect()函数显示了结果的摘要。例如,以下命令将查看语料库中第一条和第二条短信消息的摘要:
> inspect(sms_corpus[1:2])
<<VCorpus>>
Metadata: corpus specific: 0, document level (indexed): 0
Content: documents: 2
[[1]]
<<PlainTextDocument>>
Metadata: 7
Content: chars: 49
[[2]]
<<PlainTextDocument>>
Metadata: 7
Content: chars: 23
要查看实际的消息文本,必须对所需的消息应用as.character()函数。要查看一条消息,请在单个列表元素上使用as.character()函数,注意需要使用双括号表示法:
> as.character(sms_corpus[[1]])
[1] "Hope you are having a good week. Just checking in"
要查看多个文档,我们需要将as.character()应用于sms_corpus对象中的多个项。为此,我们将使用lapply()函数,它是 R 数据结构中应用程序的一个函数家族的一部分。这些函数,包括apply()和sapply()等,是 R 语言的关键习惯用法之一。经验丰富的 R 程序员使用这些函数的方式类似于在其他编程语言中使用for或while循环,因为它们会产生更易读(有时更高效)的代码。将as.character()应用于语料库元素子集的lapply()函数如下:
> lapply(sms_corpus[1:2], as.character)
$'1'
[1] "Hope you are having a good week. Just checking in"
$'2'
[1] "K..give back my thanks."
如前所述,语料库包含 5,559 条文本消息的原始文本。为了进行我们的分析,我们需要将这些消息划分为单个单词。首先,我们需要清理文本以标准化单词并删除会干扰结果的标点符号。例如,我们希望字符串Hello!、HELLO和hello都被计为同一单词的实例。
tm_map()函数提供了一个将转换(也称为映射)应用于tm语料库的方法。我们将使用此函数通过一系列转换清理我们的语料库,并将结果保存在一个名为corpus_clean的新对象中。
我们的第一步转换将标准化消息以仅使用小写字母。为此,R 提供了一个tolower()函数,该函数返回文本字符串的小写版本。为了将此函数应用于语料库,我们需要使用tm包装函数content_transformer()将tolower()视为一个转换函数,该函数可以用于访问语料库。完整的命令如下:
> sms_corpus_clean <- tm_map(sms_corpus,
content_transformer(tolower))
为了检查命令是否按预期工作,让我们检查原始语料库中的第一条消息并将其与转换语料库中的相同消息进行比较:
> as.character(sms_corpus[[1]])
[1] "Hope you are having a good week. Just checking in"
> as.character(sms_corpus_clean[[1]])
[1] "hope you are having a good week. just checking in"
如预期的那样,清理后的语料库中的大写字母已被替换为小写版本。
content_transformer() 函数可用于应用更复杂的文本处理和清理过程,例如 grep 模式匹配和替换。只需编写一个自定义函数,并在应用 tm_map() 函数之前将其包装起来。
让我们继续清理过程,通过从短信中移除数字。尽管一些数字可能提供有用信息,但大多数数字可能仅对个别发送者独特,因此不会在所有消息中提供有用的模式。考虑到这一点,我们将从语料库中移除所有数字,如下所示:
> sms_corpus_clean <- tm_map(sms_corpus_clean, removeNumbers)
注意,前面的代码没有使用 content_transformer() 函数。这是因为 removeNumbers() 与 tm 包一起提供,还包括其他不需要包装的映射函数。要查看其他内置转换,请简单地输入 getTransformations()。
我们接下来的任务是移除短信中的填充词,如 to、and、but 和 or。这些术语被称为 停用词,通常在文本挖掘之前被移除。这是因为尽管它们出现频率很高,但它们对我们模型的有用信息不多,因为它们不太可能区分垃圾邮件和正常邮件。
我们不会自己定义停用词列表,而是使用 tm 包提供的 stopwords() 函数。此函数允许我们访问来自各种语言的停用词集合。默认情况下,使用的是常见的英语停用词。要在 R 命令提示符中查看默认列表,请输入 stopwords()。要查看其他语言和选项,请输入 ?stopwords 以获取文档页面。
即使在单一语言中,也没有一个单一的、确定的停用词列表。例如,tm 中的默认英语列表包含大约 174 个单词,而另一个选项包含 571 个单词。你甚至可以指定自己的停用词列表。无论你选择哪个列表,都要记住这个转换的目标,即消除无用数据,同时尽可能保留有用信息。
仅定义停用词本身不是一种转换。我们需要的是一种方法来移除任何出现在停用词列表中的单词。解决方案在于 tm 包中包含的 removeWords() 函数。像之前一样,我们将使用 tm_map() 函数应用此映射到数据上,将 stopwords() 函数作为参数提供,以指示我们想要移除的单词。完整的命令如下:
> sms_corpus_clean <- tm_map(sms_corpus_clean,
removeWords, stopwords())
由于 stopwords() 仅返回一个停用词向量,如果我们选择这样做,我们可以用我们自己的单词移除向量替换此函数调用。这样,我们可以根据喜好扩展或减少停用词列表,或者完全移除不同的单词集。
继续我们的清理过程,我们还可以使用内置的 removePunctuation() 转换从文本消息中消除任何标点符号:
> sms_corpus_clean <- tm_map(sms_corpus_clean, removePunctuation)
removePunctuation() 转换会完全从文本中移除标点符号,这可能会导致意想不到的后果。例如,考虑以下应用方式会发生什么:
> removePunctuation("hello...world")
[1] "helloworld"
如所示,省略号后面的空白缺失导致单词 hello 和 world 被合并为一个单词。虽然这目前不是一个重大问题,但值得注意。
为了绕过 removePunctuation() 的默认行为,可以创建一个自定义函数来替换而不是移除标点符号:
> replacePunctuation <- function(x) {
gsub("[[:punct:]]+", " ", x)
}
这使用 R 的 gsub() 函数将 x 中的任何标点符号替换为一个空格。然后可以使用 tm_map() 与其他转换一样使用 replacePunctuation() 函数。这里 gsub() 命令的奇怪语法是由于使用了 正则表达式,它指定了一个匹配文本字符的模式。正则表达式在 第十二章,高级数据准备 中有更详细的介绍。
文本数据的另一个常见标准化涉及通过称为 词干提取 的过程将单词还原到其根形式。词干提取过程将像 learned、learning 和 learns 这样的单词去除后缀,以便将它们转换为基本形式,learn。这允许机器学习算法将相关术语视为单一概念,而不是尝试为每个变体学习一个模式。
tm 包通过集成 SnowballC 包提供词干提取功能。在撰写本文时,SnowballC 并未默认与 tm 一起安装,因此如果您尚未安装,请使用 install.packages("SnowballC") 进行安装。
SnowballC 包由 Milan Bouchet-Valat 维护,并为基于 C 的 libstemmer 库提供 R 接口,该库本身基于 M. F. Porter 的“Snowball”词干提取算法,这是一种广泛使用的开源词干提取方法。有关更多详细信息,请参阅 snowballstem.org。
SnowballC 包提供了一个 wordStem() 函数,对于一个字符向量,它会返回其根形式的相同术语向量。例如,该函数正确地处理了之前描述的单词 learn 的变体:
> library(SnowballC)
> wordStem(c("learn", "learned", "learning", "learns"))
[1] "learn" "learn" "learn" "learn"
要将 wordStem() 函数应用于整个文本文档集合,tm 包包括一个 stemDocument() 转换。我们使用 tm_map() 函数以与之前相同的方式应用此转换:
> sms_corpus_clean <- tm_map(sms_corpus_clean, stemDocument)
如果在应用 stemDocument() 转换时收到错误消息,请确认您已安装 SnowballC 包。
在移除数字、停用词和标点符号后,然后也进行词干提取,文本消息就只剩下之前分隔现在缺失部分的空白空间。因此,我们文本清理过程的最后一步是使用内置的 stripWhitespace() 转换来移除额外的空白:
> sms_corpus_clean <- tm_map(sms_corpus_clean, stripWhitespace)
下表显示了在清洗过程前后,短信语料库中的前三条消息。消息已被限制为最有趣的单词,并且已经移除了标点和大小写:
| 清洗前的短信消息 | 清洗后的短信消息 |
|---|---|
> as.character(sms_corpus[1:3])``[[1]] Hope you are having a good``week. Just checking in``[[2]] K..give back my thanks.``[[3]] Am also doing in cbe only.``But have to pay. |
> as.character(sms_corpus_clean[1:3])``[[1]] hope good week just check``[[2]] kgive back thank``[[3]] also cbe pay |
数据准备 – 将文本文档分割成单词
现在数据已经被处理成我们所期望的样子,最后一步是通过一个叫做分词的过程将消息分割成单个术语。术语是文本字符串的单个元素;在这种情况下,术语是单词。
如你所想,tm包提供了对 SMS 消息语料库进行分词的功能。DocumentTermMatrix()函数接受一个语料库并创建一个称为文档-术语矩阵(DTM)的数据结构,其中行表示文档(短信消息),列表示术语(单词)。
tm包还提供了一个术语-文档矩阵(TDM)的数据结构,它实际上是一个转置的文档-术语矩阵(DTM),其中行是术语,列是文档。为什么需要两者?有时,使用其中一个更方便。例如,如果文档数量很少,而单词列表很大,那么使用 TDM 可能是有意义的,因为通常显示多行比显示多列更容易。话虽如此,机器学习算法通常需要 DTM,因为列是特征,行是示例。
矩阵中的每个单元格存储一个数字,表示列中单词在行中文档中出现的次数。以下图仅显示了 SMS 语料库 DTM 的一小部分,因为完整的矩阵有 5,559 行和超过 7,000 列:

图 4.9:SMS 消息的 DTM 主要由零填充
表格中每个单元格为零的事实意味着列顶部的单词列表中的任何一个单词都没有出现在语料库的前五条消息中。这突出了为什么这种数据结构被称为稀疏矩阵的原因;矩阵中的绝大多数单元格都是零。用现实世界的术语来说,尽管每条消息必须至少包含一个单词,但任何单个单词出现在给定消息中的概率很小。
从tm语料库创建 DTM 稀疏矩阵只需要一个命令:
> sms_dtm <- DocumentTermMatrix(sms_corpus_clean)
这将创建一个sms_dtm对象,它使用默认设置包含分词后的语料库,这些设置应用了最小的额外处理。默认设置是合适的,因为我们已经手动准备了语料库。
另一方面,如果我们还没有执行预处理,我们可以通过提供一个control参数选项的列表来覆盖默认设置。例如,要从原始、未经处理的短信语料库直接创建 DTM,我们可以使用以下命令:
> sms_dtm2 <- DocumentTermMatrix(sms_corpus, control = list(
tolower = TRUE,
removeNumbers = TRUE,
stopwords = TRUE,
removePunctuation = TRUE,
stemming = TRUE
))
这将按照之前相同的顺序对 SMS 语料库进行相同的预处理步骤。然而,将sms_dtm与sms_dtm2比较,我们发现矩阵中的术语数量略有差异:
> sms_dtm
<<DocumentTermMatrix (documents: 5559, terms: 6559)>>
Non-/sparse entries: 42147/36419334
Sparsity : 100%
Maximal term length: 40
Weighting : term frequency (tf)
> sms_dtm2
<<DocumentTermMatrix (documents: 5559, terms: 6961)>>
Non-/sparse entries: 43221/38652978
Sparsity : 100%
Maximal term length: 40
Weighting : term frequency (tf)
这种差异的原因与预处理步骤顺序的微小差异有关。DocumentTermMatrix()函数在将文本字符串分割成单词之后,才对其应用清理函数。因此,它使用了一个稍微不同的停用词去除函数。因此,某些单词在清理之前分割的方式与清理后不同。
要强制两个先前的 DTM(文档-词矩阵)相同,我们可以用我们自己的使用原始替换函数的函数覆盖默认的停用词函数。只需将stopwords = TRUE替换为以下内容:
stopwords = function(x) { removeWords(x, stopwords()) }
本章的代码文件包括创建一个相同的 DTM 所需的完整步骤,只需一个函数调用即可。
这些差异提出了清理文本数据的一个重要原则:操作的顺序很重要。考虑到这一点,思考过程早期步骤如何影响后续步骤非常重要。这里提供的顺序在许多情况下都会有效,但当过程更细致地针对特定数据集和用例定制时,可能需要重新思考。例如,如果你希望从矩阵中排除某些术语,考虑是否在词干提取之前或之后搜索它们。还要考虑移除标点符号(以及标点符号是否被替换为空白空间)如何影响这些步骤。
数据准备 – 创建训练和测试数据集
在我们的数据准备好分析后,我们现在需要将数据分为训练和测试数据集,以便在构建垃圾邮件分类器之后,它可以在之前未见过的数据上评估。然而,尽管我们需要保持分类器对测试数据集的内容不知情,但在数据清理和加工之后进行分割很重要。我们需要确保训练和测试数据集上发生的准备步骤完全相同。
我们将数据分为两部分:75%用于训练,25%用于测试。由于短信消息是随机排序的,我们可以简单地取前 4,169 条用于训练,剩下的 1,390 条用于测试。幸运的是,DTM 对象非常类似于数据框,可以使用标准的[行,列]操作进行分割。由于我们的 DTM 将短信消息存储为行,将单词存储为列,我们必须为每个请求特定的行范围和所有列:
> sms_dtm_train <- sms_dtm[1:4169, ]
> sms_dtm_test <- sms_dtm[4170:5559, ]
为了方便起见,保存一对向量,其中包含训练和测试矩阵中每行的标签也是有帮助的。这些标签没有存储在 DTM 中,因此我们需要从原始 sms_raw 数据框中提取它们:
> sms_train_labels <- sms_raw[1:4169, ]$type
> sms_test_labels <- sms_raw[4170:5559, ]$type
为了确认这些子集代表完整的短信数据集,让我们比较训练和测试数据框中垃圾邮件的比例:
> prop.table(table(sms_train_labels))
ham spam
0.8647158 0.1352842
> prop.table(table(sms_test_labels))
ham spam
0.8683453 0.1316547
训练数据和测试数据中大约有 13%的垃圾邮件。这表明垃圾邮件在这两个数据集中是平均分配的。
可视化文本数据 – 单词云
单词云是一种以视觉方式表示文本数据中单词出现频率的方法。云由围绕图形随机散布的单词组成。在文本中出现频率更高的单词以较大的字体显示,而较少出现的术语则以较小的字体显示。这种类型的图表作为观察社交媒体网站上趋势话题的一种方式而越来越受欢迎。
wordcloud 包提供了一个简单的 R 函数来创建此类图表。我们将使用它来可视化短信中的单词。比较垃圾邮件和正常邮件的云图将帮助我们判断我们的朴素贝叶斯垃圾邮件过滤器是否可能成功。如果您还没有这样做,请在 R 命令行中输入 install.packages("wordcloud") 和 library(wordcloud) 来安装和加载此包。
wordcloud 包是由 Ian Fellows 编写的。有关此包的更多信息,请访问他的博客blog.fellstat.com/?cat=11。
可以直接使用以下语法从 tm 语料库对象创建单词云:
> wordcloud(sms_corpus_clean, min.freq = 50, random.order = FALSE)
这将根据我们准备好的短信语料库创建单词云。由于我们指定了 random.order = FALSE,云将按非随机顺序排列,高频词将放置在中心附近。如果我们不指定 random.order,则默认随机排列。
min.freq 参数指定单词在语料库中必须出现的次数,才能在云中显示。由于频率为 50 大约是语料库的 1%,这意味着一个单词必须至少出现在 1%的短信中才能包含在云中。
您可能会收到一条警告消息,指出 R 无法将所有单词拟合到图中。如果是这样,请尝试增加 min.freq 以减少云中的单词数量。使用 scale 参数减小字体大小也可能有所帮助。
生成的单词云应类似于以下示例:

图 4.10:展示所有短信中出现的单词的单词云
一个可能更有趣的可视化是将垃圾邮件和正常短信的云进行比较。由于我们没有为垃圾邮件和正常短信构建单独的语料库,这是注意 wordcloud() 函数的一个非常有用的特性的合适时机。给定一个原始文本字符串向量,它将在显示云之前自动应用常见的文本准备过程。
让我们使用 R 的 subset() 函数通过短信类型对 sms_raw 数据进行子集化。首先,我们将创建一个 type 为 spam 的子集:
> spam <- subset(sms_raw, type == "spam")
接下来,我们将对 ham 子集做同样的事情:
> ham <- subset(sms_raw, type == "ham")
注意双等号的使用。像许多编程语言一样,R 使用 == 来测试相等性。如果你不小心使用了单个等号,你最终会得到一个比你预期的更大的子集!
现在我们有两个数据框,spam 和 ham,每个数据框都有一个包含短信原始文本字符串的 text 特征。创建词云就像以前一样简单。这次,我们将使用 max.words 参数来查看每个集合中最常见的 40 个单词。scale 参数调整云中单词的最大和最小字体大小。你可以根据需要更改这些参数。以下代码展示了这一点:
> wordcloud(spam$text, max.words = 40, scale = c(3, 0.5))
> wordcloud(ham$text, max.words = 40, scale = c(3, 0.5))
注意,当运行此代码时,R 会提供警告信息,指出“转换丢弃文档”。这些警告与 wordcloud() 在接收到原始文本数据而不是术语矩阵时默认执行的 removePunctuation() 和 removeWords() 程序有关。基本上,有一些消息被排除在结果之外,因为在清理后没有剩余的消息文本。例如,带有文本 😃 表示笑脸表情的垃圾邮件消息在清理后被从集合中移除。这对词云没有问题,可以忽略这些警告。
生成的词云应该看起来与下面的类似。你有没有一种预感,哪一个是垃圾邮件云,哪一个是正常短信云?

图 4.11:并排显示的词云,描绘了垃圾邮件和正常短信消息
如你所猜,垃圾邮件云在左边。垃圾邮件包括诸如 call、free、mobile、claim 和 stop 等单词;这些术语根本不会出现在垃圾邮件云中。相反,垃圾邮件使用诸如 can、sorry、love 和 time 等单词。这些明显的差异表明,我们的朴素贝叶斯模型将有一些强有力的关键词来区分这两类。
数据准备 – 为频繁单词创建指示特征
数据准备过程的最后一步是将稀疏矩阵转换成可以用于训练 Naive Bayes 分类器的数据结构。目前,稀疏矩阵包括超过 6,500 个特征;这是每个至少出现在一条短信中的单词的特征。不太可能所有这些都有助于分类。为了减少特征数量,我们将删除在不到 5 条消息或训练数据中不到约 0.1% 的记录中出现的任何单词。
寻找频繁单词需要使用 tm 包中的 findFreqTerms() 函数。此函数接受一个 DTM 并返回一个包含至少出现最小次数的单词的字符向量。例如,以下命令显示在 sms_dtm_train 矩阵中至少出现五次的单词:
> findFreqTerms(sms_dtm_train, 5)
函数的结果是一个字符向量,所以让我们将我们的频繁单词保存起来以备后用:
> sms_freq_words <- findFreqTerms(sms_dtm_train, 5)
查看向量的内容显示,至少在 5 条短信中出现的术语有 1,139 个:
> str(sms_freq_words)
chr [1:1137] "£wk" "abiola" "abl" "abt" "accept" "access" "account" "across" "act" "activ" ...
现在我们需要过滤我们的 DTM,只包含频繁单词向量中出现的术语。像之前一样,我们将使用数据框风格的 [row, col] 操作来请求 DTM 的特定部分,注意 DTM 的列名是基于 DTM 包含的单词。我们可以利用这个事实来限制 DTM 到特定的单词。由于我们想要所有行,但只有 sms_freq_words 向量中单词表示的列,我们的命令如下:
> sms_dtm_freq_train <- sms_dtm_train[ , sms_freq_words]
> sms_dtm_freq_test <- sms_dtm_test[ , sms_freq_words]
训练和测试数据集现在包括 1,137 个特征,这些特征对应于至少在 5 条消息中出现的单词。
Naive Bayes 分类器通常在具有分类特征的数据上训练。这带来一个问题,因为稀疏矩阵中的单元格是数值型的,并衡量一个单词在消息中出现的次数。我们需要将其转换为表示是否出现的分类变量,是或否。
以下定义了一个 convert_counts() 函数,用于将计数转换为 Yes 或 No 字符串:
> convert_counts <- function(x) {
x <- ifelse(x > 0, "Yes", "No")
}
到目前为止,前面函数的一些部分应该看起来很熟悉。第一行定义了函数。语句 ifelse(x > 0, "Yes", "No") 将 x 中的值进行转换,如果值大于 0,则将其替换为 "Yes";否则,将其替换为一个 "No" 字符串。最后,返回新转换的向量 x。
现在我们需要将 convert_counts() 应用到稀疏矩阵的每一列。你可能能够猜到执行此操作的 R 函数的名称。该函数简单地称为 apply(),其用法与之前使用的 lapply() 类似。
apply()函数允许对矩阵中的每一行或每一列使用一个函数。它使用MARGIN参数来指定是行还是列。在这里,我们将使用MARGIN = 2,因为我们感兴趣的是列(MARGIN = 1用于行)。转换训练和测试矩阵的命令如下:
> sms_train <- apply(sms_dtm_freq_train, MARGIN = 2,
convert_counts)
> sms_test <- apply(sms_dtm_freq_test, MARGIN = 2,
convert_counts)
结果将是两个字符类型的矩阵,每个矩阵的单元格都表示列中单词是否在任何时刻出现在行中代表的邮件中,是“是”还是“否”。
第 3 步 – 在数据上训练模型
现在我们已经将原始短信消息转换成了可以由统计模型表示的格式,是时候应用朴素贝叶斯算法了。该算法将使用单词的存在或不存在来估计给定短信消息是垃圾邮件的概率。
我们将使用的朴素贝叶斯实现是在naivebayes包中。这个包由 Michal Majka 维护,是一个现代且高效的 R 实现。如果您还没有这样做,请确保在继续之前使用install.packages("naivebayes")和library(naivebayes)命令安装并加载该包。
许多机器学习方法在多个 R 包中实现,朴素贝叶斯也不例外。另一个选项是e1071包中的naiveBayes(),这在本书的旧版本中使用过,但在使用上几乎与naive_bayes()相同。本版使用的naivebayes包提供了更好的性能和更高级的功能,详情请访问其网站:majkamichal.github.io/naivebayes/。
与我们在上一章中用于分类的 k-NN 算法不同,朴素贝叶斯学习器的训练和使用分类发生在不同的阶段。尽管如此,如以下表格所示,这些步骤相当直接:

图 4.12:朴素贝叶斯分类语法
使用sms_train矩阵,以下命令训练了一个naive_bayes分类器对象,可以用来进行预测:
> sms_classifier <- naiveBayes(sms_train, sms_train_labels)
执行前面的命令后,您可能会注意到以下输出:
There were 50 or more warnings (use warnings() to see the first 50)
目前这没有什么好担心的;输入warnings()命令可以揭示这个问题的原因:
> warnings()
Warning messages:
1: naive_bayes(): Feature £wk – zero probabilities are present. Consider Laplace smoothing.
2: naive_bayes(): Feature 60 biola – zero probabilities are present. Consider Laplace smoothing.
3: naive_bayes(): Feature abl – zero probabilities are present. Consider Laplace smoothing.
4: naive_bayes(): Feature abt – zero probabilities are present. Consider Laplace smoothing.
5: naive_bayes(): Feature accept – zero probabilities are present. Consider Laplace smoothing.
这些警告是由那些在零垃圾邮件或零正常邮件中出现的单词引起的,由于它们关联的零概率,它们在分类过程中具有否决权。例如,因为单词接受仅在训练数据中的正常邮件中出现,这并不意味着每个包含这个单词的未来邮件都应该自动被分类为正常邮件。
使用前面描述的拉普拉斯估计器可以轻松解决这个问题,但到目前为止,我们将使用laplace = 0来评估这个模型,这是模型的默认设置。
第 4 步 – 评估模型性能
为了评估 SMS 分类器,我们需要在测试数据中未见过的新消息上测试其预测结果。回想一下,未见过的新消息特征存储在一个名为 sms_test 的矩阵中,而类别标签(垃圾邮件或正常邮件)存储在一个名为 sms_test_labels 的向量中。我们训练的分类器已被命名为 sms_classifier。我们将使用这个分类器来生成预测,然后比较预测值和真实值。
使用 predict() 函数进行预测。我们将这些预测存储在一个名为 sms_test_pred 的向量中。我们只需向这个函数提供我们分类器和测试数据集的名称,如下所示:
> sms_test_pred <- predict(sms_classifier, sms_test)
为了将预测值与真实值进行比较,我们将使用 gmodels 包中的 CrossTable() 函数,我们在前面的章节中使用过它。这次,我们将添加一些额外的参数来消除不必要的单元格比例,并使用 dnn 参数(维度名称)来重新标记行和列,如下面的代码所示:
> library(gmodels)
> CrossTable(sms_test_pred, sms_test_labels,
prop.chisq = FALSE, prop.c = FALSE, prop.r = FALSE,
dnn = c('predicted', 'actual'))
这产生了以下表格:
Total Observations in Table: 1390
| actual
predicted | ham | spam | Row Total |
-------------|-----------|-----------|-----------|
ham | 1201 | 30 | 1231 |
| 0.864 | 0.022 | |
-------------|-----------|-----------|-----------|
spam | 6 | 153 | 159 |
| 0.004 | 0.110 | |
-------------|-----------|-----------|-----------|
Column Total | 1207 | 183 | 1390 |
-------------|-----------|-----------|-----------|
观察表格,我们可以看到总共只有 6 + 30 = 36 条 1,390 条短信被错误分类(2.6%)。在这些错误中,有 6 条在 1,207 条正常邮件中被错误地识别为垃圾邮件,以及 30 条在 183 条垃圾邮件中被错误地标记为正常邮件。考虑到我们在项目上投入的少量努力,这种性能水平似乎相当令人印象深刻。这个案例研究说明了为什么朴素贝叶斯在文本分类中如此经常被使用:直接使用,它表现出令人惊讶的良好性能。
另一方面,被错误分类为垃圾邮件的六条合法消息可能会对我们过滤算法的部署造成重大问题,因为过滤器可能会使一个人错过一条重要的短信。我们应该尝试看看我们是否可以稍微调整模型以获得更好的性能。
第 5 步 – 提高模型性能
你可能还记得,我们在训练模型时没有为拉普拉斯估计器设置值;事实上,很难错过 R 警告我们超过 50 个特征概率为零的信息!为了解决这个问题,我们将像以前一样构建一个朴素贝叶斯模型,但这次将 laplace = 1 设置:
> sms_classifier2 <- naiveBayes(sms_train, sms_train_labels,
laplace = 1)
接下来,我们将像以前一样进行预测:
> sms_test_pred2 <- predict(sms_classifier2, sms_test)
最后,我们将使用交叉表比较预测类别与实际分类:
> CrossTable(sms_test_pred2, sms_test_labels,
prop.chisq = FALSE, prop.c = FALSE, prop.r = FALSE,
dnn = c('predicted', 'actual'))
这产生了以下表格:
Total Observations in Table: 1390
| actual
predicted | ham | spam | Row Total |
-------------|-----------|-----------|-----------|
ham | 1202 | 28 | 1230 |
| 0.865 | 0.020 | |
-------------|-----------|-----------|-----------|
spam | 5 | 155 | 160 |
| 0.004 | 0.112 | |
-------------|-----------|-----------|-----------|
Column Total | 1207 | 183 | 1390 |
-------------|-----------|-----------|-----------|
通过设置laplace = 1添加拉普拉斯估计器,将误报(将正常邮件错误分类为垃圾邮件)的数量从 6 减少到 5,将漏报(将垃圾邮件错误分类为正常邮件)的数量从 30 减少到 28。尽管这似乎是一个微小的变化,但考虑到模型的准确率已经相当令人印象深刻,这是一个重大的改进。在进一步调整模型之前,我们需要谨慎,因为在过滤垃圾邮件时,保持过于激进和过于被动之间的平衡是很重要的。用户更希望少量垃圾邮件能够通过过滤器,而不是另一种情况,即正常邮件被过度过滤。
摘要
在本章中,我们学习了使用天真贝叶斯进行分类。该算法构建用于估计新示例属于各种类别的概率表。这些概率是通过称为贝叶斯定理的公式计算的,该公式指定了依赖事件之间的关系。尽管贝叶斯定理在计算上可能很昂贵,但一个简化的版本,即所谓的“天真”假设关于特征的独立性,能够处理更大的数据集。
天真贝叶斯分类器常用于文本分类。为了说明其有效性,我们在涉及垃圾短信的分类任务中使用了天真贝叶斯。准备文本数据进行分析需要使用专门的 R 包进行文本处理和可视化。最终,该模型能够正确地将 97%以上的短信分类为垃圾邮件或正常邮件。
在下一章中,我们将探讨两种更多的机器学习方法。每种方法通过将数据划分为相似值组来进行分类。正如你很快会发现的那样,这些方法本身非常有用。然而,展望未来,这些基本算法也成为了当今一些最强大的机器学习方法的重要基础,这些方法将在第十四章“构建更好的学习者”中介绍。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人相聚,并和超过 4000 人一起学习:

第五章:分而治之 - 使用决策树和规则进行分类
在决定是否接受工作邀请时,许多人首先列出利弊清单,然后使用简单的规则消除选项。例如,他们可能会决定,“如果我必须通勤超过一小时,我会不开心,”或者“如果我收入低于 5 万美元,我就无法养家糊口。”通过这种方式,预测个人未来职业幸福感的复杂决策可以简化为一系列简单决策。
本章介绍了决策树和规则学习器——两种机器学习方法,它们也能从简单选择集合中做出复杂决策。这些方法以逻辑结构的形式呈现其知识,无需统计知识即可理解。这一特性使得这些模型在商业策略和流程改进方面特别有用。
到本章结束时,你将学到:
-
树和规则如何“贪婪”地将数据分割成有趣的片段
-
最常见的决策树和分类规则学习器,包括 C5.0、1R 和 RIPPER 算法
-
如何使用这些算法执行现实世界的分类任务,例如识别有风险的银行贷款和有毒蘑菇
我们将首先检查决策树,然后看看分类规则。然后,我们将通过预览后续章节来总结我们所学的知识,这些章节讨论了使用树和规则作为更高级机器学习技术基础的方法。
理解决策树
决策树学习器是强大的分类器,它们利用树结构来模拟特征与潜在结果之间的关系。如图所示,这种结构之所以得名,是因为它反映了真实树木的生长方式,从底部宽大的树干开始,随着向上生长,逐渐分裂成越来越窄的树枝。同样地,决策树分类器使用分支决策的结构来引导示例进入最终的预测类别值。
为了更好地理解这在实践中是如何工作的,让我们考虑以下树,它预测是否应该接受工作邀请。一个正在考虑的工作邀请从根节点开始,然后通过决策节点,这些节点需要根据工作的属性做出选择。这些选择将数据分割成表示决策潜在结果的分支。在这里,它们被描绘为是或否的结果,但在其他情况下,可能存在超过两种可能性。
如果可以做出最终决策,树将终止在叶节点(也称为终端节点),这些节点表示一系列决策的结果所采取的行动。在预测模型的情况下,叶节点提供了给定树中的事件序列的预期结果。

图 5.1:一个表示确定是否接受新工作邀请过程的决策树
决策树算法的一个巨大优势是,类似于流程图的树结构不仅用于机器的内部使用。在模型创建之后,许多决策树算法会将结果结构以人类可读的格式输出。这为理解模型如何以及为什么在特定任务中表现良好或不好提供了洞察。这也使得决策树特别适合于需要出于法律原因使分类机制透明或需要与他人共享结果以告知未来业务实践的应用。考虑到这一点,一些潜在用途包括以下内容:
-
需要明确记录并消除偏见的信用评分模型,其中导致申请人被拒绝的标准需要被清楚地记录
-
市场研究客户行为,如满意度或流失,这些将与管理层或广告机构共享
-
基于实验室测量、症状或疾病进展率的医疗状况诊断
尽管前面的应用说明了树在告知决策过程的价值,但这并不意味着它们的效用到此为止。实际上,决策树是单一最广泛使用的机器学习技术之一,可以应用于几乎任何类型的数据——通常具有出色的即插即用性能。
话虽如此,尽管它们的适用性很广,但值得注意的是,在某些情况下,树可能不是理想的选择。这包括数据具有许多名义特征和多个级别或大量数值特征的任务。这些情况可能导致非常多的决策和过于复杂的树。它们还可能加剧决策树过度拟合数据的倾向,尽管正如我们很快就会看到的,通过调整一些简单的参数,甚至这种弱点也可以克服。
分而治之
决策树是使用一种称为递归划分的启发式方法构建的。这种方法也通常被称为分而治之,因为它将数据分割成子集,然后这些子集被反复分割成更小的子集,如此类推,直到算法确定子集中的数据足够同质,或者满足另一个停止标准。
要了解如何分割数据集可以创建决策树,想象一个根节点,它将成长为一棵成熟的树。最初,根节点代表整个数据集,因为还没有发生分割。在这里,决策树算法必须选择一个特征进行分割;理想情况下,它选择对目标类最具有预测性的特征。
然后将示例根据该特征的独特值进行分组,并形成第一组树分支。
沿着每个分支向下工作,算法继续分割和征服数据,每次选择最佳候选特征来创建另一个决策节点,直到达到停止标准。如果以下条件之一成立,分割和征服可能会在节点处停止:
-
节点上的所有(或几乎所有)示例都属于同一类别
-
没有剩余的特征来区分示例
-
树已经增长到预定义的大小限制
为了说明树构建过程,让我们考虑一个简单的例子。想象一下,你为一家好莱坞工作室工作,你的角色是决定工作室是否应该继续制作由有潜力的新作者提出的剧本。度假回来后,你的桌子堆满了提案。没有时间逐个阅读每个提案,你决定开发一个决策树算法来预测潜在电影是否会落入以下三个类别之一:评论家成功、主流热门或票房失败。
为了创建决策树模型而获取数据,你转向电影制片厂的档案来检查导致公司最近 30 部发行作品成功或失败的因素。你很快注意到电影的预估拍摄预算、主演角色的 A 名单明星数量以及电影的成功程度之间存在关联。对这个发现感到兴奋,你制作了一个散点图来展示这种模式:

图 5.2:散点图展示了电影预算与名人数量之间的关系
使用分割和征服策略,你可以从这个数据中构建一个简单的决策树。首先,为了创建树的根节点,你分割了表示名人数量的特征,将电影分为有显著数量的 A 名单明星和无显著数量的 A 名单明星的组:

图 5.3:决策树的第一次分割将数据分为高名人数量和低名人数量电影
接下来,在拥有更多名人的电影组中,你在有高预算和无高预算的电影之间进行另一个分割:

图 5.4:决策树的第二次分割进一步将高名人数量电影分为低预算和高预算两类
到目前为止,你已经将数据分为三个组。图的最左上角组完全由获得评论家赞誉的电影组成。这个组的特点是名人数量多而预算相对较低。在右上角,几乎所有电影都是票房大赢家,预算高且名人众多。最后一个组,虽然星光不足,但预算从小到大不等,包含了失败的作品。
如果需要,你可以通过在预算和名人数量越来越具体的范围内分割数据来继续细分和征服数据,直到当前所有错误分类的值都正确分类在其自己的小分区中。然而,以这种方式过度拟合决策树是不明智的。尽管算法可以无限分割数据,但过于具体的决策并不总是能更广泛地推广。因此,你选择在这里停止算法,因为每个组中超过 80%的例子都来自单个类别。这是决策树模型的停止标准。
你可能已经注意到,对角线可能更干净地分割了数据。这是决策树知识表示的一个局限性,它使用轴平行分割。每个分割只考虑一个特征的事实阻止了决策树形成更复杂的决策边界。例如,可以通过一个询问“名人的数量是否大于估计的预算?”的决策来创建一条对角线。如果是这样,那么“它将是一个关键的成功。”
预测电影未来成功的模型可以用一个简单的树来表示,如下面的图所示。树中的每一步都显示了落入每个类别的示例比例,这显示了随着分支接近叶子,数据如何变得更加同质化。为了评估一个新电影剧本,沿着每个决策分支前进,直到预测出剧本的成功或失败。使用这种方法,你将能够快速识别剧本库中最有希望的选项,并回到更重要的工作,例如撰写奥斯卡颁奖典礼的获奖感言!

图 5.5:基于历史电影数据的决策树可以预测未来电影的性能
由于现实世界的数据包含超过两个特征,决策树很快就会比这复杂得多,具有更多的节点、分支和叶子。在下一节中,你将了解一个流行的自动构建决策树模型的算法。
C5.0 决策树算法
决策树有多种实现方式,但其中最著名的一种是 C5.0 算法。该算法由计算机科学家 J. Ross Quinlan 开发,是他先前算法 C4.5 的改进版本,而 C4.5 本身又是他迭代二分器 3(ID3)算法的改进。尽管 Quinlan 将 C5.0 推广给商业客户(详情请见www.rulequest.com/),但该算法的单线程版本源代码已被公开,因此被整合到 R 等程序中。
为了进一步混淆问题,一个流行的基于 Java 的开源 C4.5 替代品,名为J48,包含在 R 的RWeka包中(在本章后面介绍)。由于 C5.0、C4.5 和 J48 之间的差异很小,本章中的原则适用于这三种方法中的任何一种,并且算法应被视为同义的。
C5.0 算法已成为生成决策树的行业标准,因为它可以直接解决大多数类型的问题。与其他高级机器学习模型相比,例如在第七章中描述的黑盒方法 - 神经网络和支持向量机,C5.0 构建的决策树通常表现几乎一样好,但更容易理解和部署。此外,如以下表格所示,该算法的缺点相对较小,并且可以很大程度上避免。
| 优点 | 缺点 |
|---|
|
-
一种通用的分类器,在许多类型的问题上表现良好
-
高度自动化的学习过程,可以处理数值或名义特征,以及缺失数据
-
排除不重要的特征
-
可用于小型和大型数据集
-
生成的模型可以在没有数学背景的情况下进行解释(对于相对较小的树)
-
比其他复杂模型更高效
|
-
决策树模型往往偏向于具有大量级别的特征的分割
-
容易过拟合或欠拟合模型
-
由于依赖于轴平行分割,可能难以模拟某些关系
-
训练数据的小幅变化可能导致决策逻辑发生大幅变化
-
大型树可能难以解释,它们做出的决策可能看起来不符合直觉
|
为了保持简单,我们之前的决策树示例忽略了机器如何采用分而治之策略涉及的数学。让我们更详细地探讨这一点,以检查这种启发式方法在实际中的工作方式。
选择最佳分割点
决策树将面临的第一挑战是确定要分割哪个特征。在前面的例子中,我们寻找一种分割数据的方法,使得结果分区主要包含单个类别的示例。
一个示例子集只包含单个类别的程度被称为纯度,而只由单个类别组成的任何子集都称为纯集。
有各种纯度度量可以用来识别最佳的决策树分割候选者。C5.0 使用熵,这是从信息理论中借用的一个概念,它量化了类别值集合中的随机性或无序性。具有高熵的集合非常多样化,并且关于可能也属于该集合的其他项目提供的信息很少,因为没有明显的共同点。决策树希望找到可以减少熵的分割点,从而最终增加组内的同质性。
通常,熵以 比特 为单位测量。如果只有两个可能的类别,熵值可以从 0 到 1 变化。对于 n 个类别,熵的范围从 0 到 log2。在每种情况下,最小值表示样本完全同质,而最大值表示数据尽可能多样化,并且没有组有哪怕是很小的多数。
在数学概念中,熵被定义为:

在这个公式中,对于给定数据段 (S),术语 c 指的是类别级别数,而 p[i] 指的是落在第 i 个类别级别中的值的比例。
例如,假设我们有一个包含两个类别的数据分区:红色(60%)和白色(40%)。我们可以计算熵如下:
> -0.60 * log2(0.60) - 0.40 * log2(0.40)
[1] 0.9709506
我们可以可视化所有可能的二分类排列的熵。如果我们知道一个类别的示例比例是 x,那么另一个类别的比例是 (1 – x)。然后,使用 curve() 函数,我们可以绘制所有可能的 x 值的熵:
> curve(-x * log2(x) - (1 - x) * log2(1 - x),
col = "red", xlab = "x", ylab = "Entropy", lwd = 4)
这导致了以下图表:

图 5.6:在二分类结果中,随着一个类别的比例变化,总熵
如 x = 0.50 处的峰值所示,50-50 的分裂会产生最大熵。当一个类别越来越多地主导另一个类别时,熵减少到零。
要使用熵来确定最优的分裂特征,算法会计算在每种可能的特征上分裂所导致的同质性的变化,这种度量称为 信息增益。特征 F 的信息增益是分裂前段 (S[1]) 的熵与分裂后的分区 (S[2]) 的熵之间的差值:
InfoGain(F) = Entropy(S[1]) – Entropy(S[2])
一个复杂的问题是,在分裂之后,数据被分成多个分区。因此,计算 Entropy(S[2]) 的函数需要考虑分裂后所有分区的总熵。它是通过根据所有记录落在该分区中的比例来加权每个分区的熵来做到这一点的。这可以用以下公式表示:

简而言之,分裂产生的总熵是每个 n 个分区的熵的总和,每个分区的熵都按落在该分区中的示例比例 (w[i]) 加权。
信息增益越高,特征在分裂该特征后创建同质组的性能就越好。如果信息增益为零,则在该特征上分裂不会减少熵。另一方面,最大信息增益等于分裂前的熵。这表明分裂后的熵为零,这意味着分裂产生了完全同质的组。
之前的公式假设名义特征,但决策树在分割数值特征时也使用信息增益。为此,一种常见的做法是测试各种分割,将值分为大于或小于阈值的组。这把数值特征简化为两个级别的分类特征,从而允许像往常一样计算信息增益。产生最大信息增益的数值切割点被选为分割点。
虽然 C5.0 使用了信息增益,但它不是构建决策树时可以使用的唯一分割标准。其他常用的标准包括基尼指数、卡方统计量和增益率。关于这些(以及许多其他)标准的综述,请参阅Mingers, J, Machine Learning, 1989, Vol. 3, pp. 319-342。
剪枝决策树
如前所述,决策树可以无限增长,选择分割特征并将数据分成越来越小的分区,直到每个示例都被完美分类或算法耗尽可用于分割的特征。然而,如果树长得过于庞大,它所做的许多决策将过于具体,模型将过度拟合训练数据。剪枝决策树的过程涉及减小其大小,以便更好地泛化到未见数据。
解决这个问题的方法之一是在树达到一定数量的决策或决策节点只包含少量示例时停止树的生长。这被称为早期停止或预剪枝决策树。由于树避免了做无谓的工作,这是一种吸引人的策略。然而,这种方法的一个缺点是,无法知道树是否会错过它如果长得更大可能会学习到的微妙但重要的模式。
另一种称为后剪枝的方法涉及有意生长一个过大的树,并剪枝叶节点以将树的大小减小到更合适的水平。这通常比预剪枝更有效,因为在不首先生长树的情况下很难确定决策树的最佳深度。在稍后剪枝树允许算法确信所有重要的数据结构都已发现。
剪枝操作的实现细节非常技术性,超出了本书的范围。关于一些可用方法的比较,请参阅Esposito, F, Malerba, D, Semeraro, G, IEEE Transactions on Pattern Analysis and Machine Intelligence, 1997, Vol. 19, pp. 476-491。
C5.0 算法的一个优点是它在剪枝方面有明确的观点——它使用合理的默认值自动处理许多决策。其整体策略是在剪枝后对树进行修剪。它首先生长出一个大的树,该树过度拟合了训练数据。随后,移除对分类错误影响较小的节点和分支。在某些情况下,整个分支会被移动到树的更高位置或被更简单的决策所取代。这些移除分支的过程分别被称为子树提升和子树替换。
在过度拟合和欠拟合之间取得正确的平衡有点像一门艺术,但如果模型准确性至关重要,那么花些时间尝试不同的剪枝选项以查看是否可以提高测试数据集的性能可能是值得的。正如你很快就会看到的,C5.0 算法的一个优点是它非常容易调整训练选项。
示例 – 使用 C5.0 决策树识别风险银行贷款
2007-2008 年的全球金融危机突出了银行实践中透明度和严谨性的重要性。由于信贷的可用性有限,银行收紧了其贷款系统,并转向机器学习以更准确地识别风险贷款。
由于决策树具有高准确性和用普通语言制定统计模型的能力,因此在银行业中得到广泛应用。由于许多国家的政府仔细监控贷款实践的公平性,因此高管必须能够解释为什么某个申请人被拒绝贷款而另一个被批准。这些信息对希望确定其信用评级为何不满意的客户也很有用。
自动化信用评分模型可能用于信用卡邮寄和即时在线批准流程。在本节中,我们将使用 C5.0 决策树开发一个简单的信用批准模型。我们还将看到如何调整模型结果以最小化导致财务损失的错误。
第 1 步 – 收集数据
我们信用模型的动力在于识别与贷款违约高风险相关的因素。为此,我们必须获取关于过去银行贷款的数据以及当时在信用申请时可能可用的贷款申请人信息。
具有这些特征的数据集由汉堡大学的 Hans Hofmann 捐赠给 UCI 机器学习仓库 (archive.ics.uci.edu/ml)。该数据集包含来自德国一家信用机构的贷款信息。
本章中展示的数据集已经对原始数据进行了轻微修改,以消除一些预处理步骤。为了跟随示例,请从本章的 Packt Publishing GitHub 仓库下载 credit.csv 文件,并将其保存到您的 R 工作目录中。
信用数据集包括 1,000 个贷款示例,以及一组表示贷款和贷款申请人特征的数值和名义特征。一个类别变量表示贷款是否违约。让我们看看我们是否能识别出任何预测这种结果的模式。
第 2 步 – 探索和准备数据
如我们之前所做的那样,我们将使用read.csv()函数导入数据。现在,因为字符数据完全是分类的,我们可以将stringsAsFactors = TRUE设置为自动将所有字符类型列转换为结果数据框中的因子:
> credit <- read.csv("credit.csv", stringsAsFactors = TRUE)
我们可以通过检查str()函数输出的前几行来检查结果对象:
> str(credit)
'data.frame':1000 obs. of 17 variables:
$ checking_balance : Factor w/ 4 levels "< 0 DM","> 200 DM",..
$ months_loan_duration: int 6 48 12 42 24 36 24 36 12 30 ...
$ credit_history : Factor w/ 5 levels "critical","good",..
$ purpose : Factor w/ 6 levels "business","car",..
$ amount : int 1169 5951 2096 7882 4870 9055 2835 6948 ...
我们看到预期的 1,000 个观测值和 17 个特征,这些特征是因子和整型数据类型的组合。
让我们看看几个似乎可以预测违约的贷款特征的table()输出。申请人的检查和储蓄账户余额被记录为分类变量:
> table(credit$checking_balance)
< 0 DM > 200 DM 1 - 200 DM unknown
274 63 269 394
> table(credit$savings_balance)
< 100 DM > 1000 DM 100 - 500 DM 500 - 1000 DM unknown
603 48 103 63 183
检查和储蓄账户余额可能是贷款违约状态的重要预测指标。请注意,由于贷款数据来自德国,这些值使用的是德国马克(DM),这是欧元采用之前德国使用的货币。
一些贷款的特征是数值型的,例如其期限和请求的信贷金额:
> summary(credit$months_loan_duration)
Min. 1st Qu. Median Mean 3rd Qu. Max.
4.0 12.0 18.0 20.9 24.0 72.0
> summary(credit$amount)
Min. 1st Qu. Median Mean 3rd Qu. Max.
250 1366 2320 3271 3972 18424
贷款金额从 250 DM 到 18,420 DM 不等,期限为 4 到 72 个月。它们的中间值为 2,320 DM,中间期限为 18 个月。
default向量表示贷款申请人是否能够满足约定的还款条款,或者他们是否违约。在这个数据集中,总共有 30%的贷款违约:
> table(credit$default)
no yes
700 300
对于银行来说,高违约率是不可取的,因为这意味着银行不太可能完全收回其投资。如果我们成功,我们的模型将识别出高违约风险的申请人,使银行能够在发放资金之前拒绝信贷申请。
数据准备 – 创建随机训练和测试数据集
如我们在前面的章节中所做的那样,我们将把我们的数据分成两部分:一个用于构建决策树的训练数据集和一个用于评估其在新数据上性能的测试数据集。我们将使用 90%的数据进行训练,10%的数据进行测试,这将为我们提供 100 条记录来模拟新申请人。在这里使用 90-10 的分割而不是更常见的 75-25 分割,是因为信用数据集相对较小;鉴于预测贷款违约是一个具有挑战性的学习任务,我们需要尽可能多的训练数据,同时还要保留足够的测试样本。
在第十章“评估模型性能”中介绍了用于训练和评估相对较小数据集的更复杂方法。
由于前几章使用的数据是随机排序的,我们只需通过取记录的第一个子集用于训练,剩余的子集用于测试,就可以将数据集分为两部分。相比之下,信用数据集并不是随机排序的,这使得先前的做法不明智。假设银行是按照贷款金额对数据进行排序的,最大的贷款位于文件末尾。如果我们用前 90%的数据用于训练,剩下的 10%用于测试,那么我们将在只有小额贷款上训练模型,并在大额贷款上测试模型。显然,这可能会出现问题。
我们将通过在信用数据的随机样本上训练模型来解决此问题。随机样本简单来说就是一个随机选择记录子集的过程。在 R 中,sample()函数用于执行随机抽样。然而,在付诸实践之前,一个常见的做法是设置一个种子值,这会导致随机化过程遵循一个可以后来复制的序列。这似乎违背了生成随机数的初衷,但这样做有很好的理由。通过set.seed()函数提供种子值可以确保,如果分析在未来重复进行,将获得相同的结果。
你可能会想知道一个所谓的随机过程如何被播种以产生相同的结果。这是因为计算机使用一种称为伪随机数生成器的数学函数来创建看似非常随机的随机数序列,但实际上,如果知道序列中的前一个值,它们是非常可预测的。在实践中,现代伪随机数序列几乎与真正的随机序列无法区分,但它们的好处是计算机可以快速轻松地生成它们。
以下命令使用带有种子值的sample()。请注意,set.seed()函数使用任意值9829。省略此种子值将导致您的训练和测试分割与本章其余部分所示的不同。以下命令从 1 到 1,000 的整数序列中随机选择 900 个值:
> set.seed(9829)
> train_sample <- sample(1000, 900)
如预期的那样,生成的train_sample对象是一个包含 900 个随机整数的向量:
> str(train_sample)
int [1:900] 653 866 119 152 6 617 250 343 367 138 ...
通过使用这个向量从信用数据中选择行,我们可以将其分为我们想要的 90%训练数据和 10%测试数据集。回想一下,在测试记录的选择中使用的否定运算符(-字符)告诉 R 选择不在指定行中的记录;换句话说,测试数据只包括不在训练样本中的行:
> credit_train <- credit[train_sample, ]
> credit_test <- credit[-train_sample, ]
如果随机化做得正确,我们应在每个数据集中大约有 30%的贷款出现违约:
> prop.table(table(credit_train$default))
no yes
0.7055556 0.2944444
> prop.table(table(credit_test$default))
no yes
0.65 0.35
训练集和测试集在贷款违约的分布上大致相似,因此我们现在可以构建我们的决策树。如果比例差异很大,我们可能会决定重新采样数据集,或者尝试更复杂的采样方法,例如在第十章“评估模型性能”中介绍的方法。
如果您的结果不完全匹配,请确保在创建train_sample向量之前立即运行了set.seed(9829)命令。请注意,R 的默认随机数生成器在 R 版本 3.6.0 中发生了变化,如果在此代码在早期版本上运行,则结果将不同。这也意味着这里的结果与本书先前版本中的结果略有不同。
第 3 步 – 在数据上训练模型
我们将使用C50包中的 C5.0 算法来训练我们的决策树模型。如果您尚未安装,请使用install.packages("C50")安装该包,并使用library(C50)将其加载到 R 会话中。
以下语法框列出了在构建决策树时使用的一些最常见参数。与之前使用的机器学习方法相比,C5.0 算法提供了许多更多的方式来定制模型以适应特定的学习问题。

图 5.7:C5.0 决策树语法
C5.0()函数使用一种称为R 公式接口的新语法来指定要训练的模型。公式语法使用~运算符(称为波浪号)来表示目标变量与其预测变量之间的关系。要学习的类别变量放在波浪号的左侧,预测特征写在右侧,由+运算符分隔。
如果您想建模目标y与预测变量x1和x2之间的关系,您将公式写成y ~ x1 + x2。要包含模型中的所有变量,使用点字符。例如,y ~ .指定了y与数据集中所有其他特征之间的关系。
R 公式接口在许多 R 函数中使用,并提供了一些强大的功能来描述预测变量之间的关系。我们将在后面的章节中探讨一些这些功能。然而,如果您急于预览,请随时使用?formula命令阅读文档。
对于信用审批模型的第一次迭代,我们将使用默认的 C5.0 设置,如下面的代码所示。目标类别命名为default,所以我们将其放在波浪号~的左侧,后面跟着一个点表示credit_train数据框中的所有其他列都将用作预测变量:
> credit_model <- C5.0(default ~ ., data = credit_train)
credit_model对象现在包含一个 C5.0 决策树。我们可以通过输入其名称来查看一些关于树的基本数据:
> credit_model
Call:
C5.0.formula(formula = default ~ ., data = credit_train)
Classification Tree
Number of samples: 900
Number of predictors: 16
Tree size: 67
Non-standard options: attempt to group attributes
输出显示了关于树的一些简单事实,包括生成它的函数调用、用于构建树的特性数量(标记为predictors)和示例(标记为samples)。还包括树的大小为67,这表明树有 67 个决策深度——比我们迄今为止考虑的示例树大得多!
要查看树的决策,我们可以在模型上调用summary()函数:
> summary(credit_model)
这会导致以下输出,其中已截断以仅显示前几行:
> summary(credit_model)
Call:
C5.0.formula(formula = default ~ ., data = credit_train)
C5.0 [Release 2.07 GPL Edition]
-------------------------------
Class specified by attribute `outcome'
Read 900 cases (17 attributes) from undefined.data
Decision tree:
checking_balance in {> 200 DM,unknown}: no (415/55)
checking_balance in {< 0 DM,1 - 200 DM}:
:...credit_history in {perfect,very good}: yes (59/16)
credit_history in {critical,good,poor}:
:...months_loan_duration > 27:
:...dependents > 1:
: :...age <= 45: no (12/2)
: : age > 45: yes (2)
上述输出显示了决策树的一些最初分支。前三行可以用普通语言表示为:
-
如果支票账户余额未知或超过 200 DM,则将其分类为“不太可能违约”
-
否则,如果支票账户余额小于零 DM 或介于 1 到 200 DM 之间...
-
…如果信用记录是完美或非常好的,则将其分类为“可能违约”
括号中的数字表示满足该决策标准的示例数量和被该决策错误分类的示例数量。例如,在第一行中,415/55表示,在达到决策的 415 个示例中,有 55 个被错误地分类为“不太可能违约”。换句话说,415 个申请人中有 55 人实际上违约了,尽管模型预测他们不会违约。
有时候,一棵树会导致一些逻辑上不太合理的决策。例如,为什么信用记录完美或非常好的申请人可能会违约,而那些支票账户余额未知的人却不太可能违约?这样的矛盾规则有时会出现。它们可能反映了数据中的真实模式,或者可能是统计异常。在两种情况下,调查这些奇怪的决策以查看树的逻辑是否适合商业用途都是非常重要的。
在树之后,summary(credit_model)输出显示了一个混淆矩阵,它是一个交叉表,指示模型在训练数据中的错误分类记录:
Evaluation on training data (900 cases):
Decision Tree
----------------
Size Errors
66 118(13.1%) <<
(a) (b) <-classified as
---- ----
604 31 (a): class no
87 178 (b): class yes
Errors标题显示,该模型正确分类了 900 个训练实例中的 878 个,错误率为 13.1%。共有 31 个实际值为no的实例被错误地分类为yes(假阳性),而 87 个值为yes的实例被错误地分类为no(假阴性)。鉴于决策树倾向于过度拟合训练数据,这里报告的错误率,即基于训练数据性能的错误率,可能过于乐观。因此,将决策树应用于未见过的测试数据集尤为重要,我们将在稍后进行。
输出还包括一个标记为Attribute usage的部分,它提供了关于决策树模型中使用的重要预测因子的一般感觉。以下是一些输出内容的前几行:
Attribute usage:
100.00% checking_balance
53.89% credit_history
47.33% months_loan_duration
26.11% purpose
24.33% savings_balance
18.22% job
12.56% dependents
12.11% age
决策树输出中的属性使用统计信息指的是在训练数据中使用列出的特征进行最终预测的行百分比。例如,100%的行需要checking_balance特征,因为检查账户余额在树的第一个分裂点被使用。第二个分裂使用credit_history,但 46.11%的行已经根据检查账户余额被分类为非违约。这留下了只有 53.89%的行需要考虑申请人的信用历史。在这个列表的底部,只有 12.11%的例子需要申请人的年龄来做出预测,这表明申请人的年龄不如他们的检查账户余额或信用历史重要。
这条信息,连同树结构本身,提供了对模型工作方式的洞察。这两者都易于理解,即使没有统计学背景也是如此。当然,即使模型易于理解,如果它不能做出准确的预测,那么它也是无用的,因此我们现在将对其进行更正式的性能评估。
C5.0 决策树模型可以使用plot()函数进行可视化,该函数依赖于partykit包中的功能。不幸的是,这仅适用于相对较小的决策树。例如,我们可以通过输入plot(credit_model)来可视化我们的决策树,但除非你有非常大的显示屏,否则由于树中节点和分裂的数量很大,生成的图表可能会显得杂乱无章。
第 4 步 – 评估模型性能
要将我们的决策树应用于测试数据集,我们使用predict()函数,如下面的代码行所示:
> credit_pred <- predict(credit_model, credit_test)
这创建了一个预测类值的向量,我们可以使用gmodels包中的CrossTable()函数将其与实际类值进行比较。将prop.c和prop.r参数设置为FALSE将从表中删除列和行百分比。剩余的百分比(prop.t)表示单元格中记录数占总记录数的比例:
> library(gmodels)
> CrossTable(credit_test$default, credit_pred,
prop.chisq = FALSE, prop.c = FALSE, prop.r = FALSE,
dnn = c('actual default', 'predicted default'))
这导致了以下表格:
| predicted default
actual default | no | yes | Row Total |
---------------|-----------|-----------|-----------|
no | 56 | 9 | 65 |
| 0.560 | 0.090 | |
---------------|-----------|-----------|-----------|
yes | 24 | 11 | 35 |
| 0.240 | 0.110 | |
---------------|-----------|-----------|-----------|
Column Total | 80 | 20 | 100 |
---------------|-----------|-----------|-----------|
在测试集的 100 个贷款申请中,我们的模型正确预测了 56 个没有违约,11 个违约,从而实现了 67%的准确率和 33%的错误率。这比它在训练数据上的表现略差,但考虑到模型在未见数据上的表现通常更差,这是可以预料的。此外,请注意,该模型在测试数据中只正确预测了 35 个实际贷款违约中的 11 个,即 31%。不幸的是,这种类型的错误可能是一个代价非常高的错误,因为银行在每次违约中都会损失金钱。让我们看看我们是否可以通过更多的努力来提高结果。
第 5 步 – 提高模型性能
我们模型的错误率可能太高,无法将其部署到实时信用评分应用中。事实上,如果模型对每个测试案例都预测“无违约”,那么它将有 65%的时间是正确的——这个结果几乎与我们的模型一样,但需要付出多得多的努力!仅使用 900 个训练示例来预测贷款违约似乎是一个具有挑战性的问题。
更糟糕的是,我们的模型在识别那些违约的申请人方面表现特别差。幸运的是,有几个简单的方法可以调整 C5.0 算法,这可能会帮助提高模型的整体性能,以及对于更昂贵的错误类型的性能。
提升决策树的准确性
C5.0 算法在 C4.5 算法的基础上进行改进的一种方式是通过添加自适应提升法。这是一个构建许多决策树的过程,这些树对每个示例的最佳类别进行投票。
提升法的想法主要基于 Rob Schapire 和 Yoav Freund 的研究。有关更多信息,请尝试在网络上搜索他们的出版物或他们的教科书 Boosting: Foundations and Algorithms, Cambridge, MA, The MIT Press, 2012。
由于提升法可以更普遍地应用于任何机器学习算法,它将在本书的 第十四章,构建更好的学习者 中更详细地介绍。现在,只需说提升法基于这样的观点:通过结合几个表现不佳的学习者,可以创建一个比任何单个学习者都强大的团队。每个模型都有其独特的一组优势和劣势,可能在某些问题上表现更好或更差。因此,使用具有互补优势和劣势的几个学习者的组合可以显著提高分类器的准确性。
C5.0() 函数使得将提升法添加到我们的决策树变得简单。我们只需添加一个额外的 trials 参数,表示在提升团队中使用的独立决策树的数量。trials 参数设置了一个上限;如果算法认识到额外的试验似乎没有提高准确性,它将停止添加树。我们将从 10 次试验开始,这个数字已经成为事实上的标准,因为研究表明这可以减少测试数据上的错误率大约 25%。
除了新的参数之外,命令与之前相似:
> credit_boost10 <- C5.0(default ~ ., data = credit_train,
trials = 10)
在检查生成的模型时,我们可以看到输出现在指示了提升法的添加:
> credit_boost10
Number of boosting iterations: 10
Average tree size: 57.3
新的输出显示,在 10 次迭代中,我们的树大小缩小了。如果您愿意,可以在命令提示符中键入summary(credit_boost10)来查看所有 10 棵树。请注意,其中一些树,包括为第一次试验构建的树,包含一个或多个子树,如下面的输出摘录中所示,其中一个子树被标记为 [S1]:
dependents > 1: yes (8.8/0.8)
dependents <= 1:
:...years_at_residence <= 1: no (13.4/1.6)
years_at_residence > 1:
:...age <= 23: yes (11.9/1.6)
age > 23: [S1]
注意到那条说如果 age > 23,结果将是 [S1] 的行。为了确定这意味着什么,我们必须将 [S1] 与输出中稍低位置的对应子树匹配,在那里我们看到最终的决策需要几个额外的步骤:
SubTree [S1]
employment_duration in {< 1 year,> 7 years,4 - 7 years,
: unemployed}: no (27.7/6.3)
employment_duration = 1 - 4 years:
:...months_loan_duration > 30: yes (7.2)
months_loan_duration <= 30:
:...other_credit = bank: yes (2.4)
other_credit in {none,store}: no (16.6/5.6)
这样的子树是后剪枝选项(如子树提升和子树替换)的结果,如本章前面提到的。
树的 summary() 输出还显示了该树在训练数据上的性能:
> summary(credit_boost10)
(a) (b) <-classified as
---- ----
633 2 (a): class no
17 248 (b): class yes
分类器在 900 个训练示例上犯了 19 个错误,错误率为 2.1%。这比我们之前提升前的 13.1% 训练错误率有了相当大的改进!然而,我们还需要看看测试数据上是否会有类似的改进。让我们看一下:
> credit_boost_pred10 <- predict(credit_boost10, credit_test)
> CrossTable(credit_test$default, credit_boost_pred10,
prop.chisq = FALSE, prop.c = FALSE, prop.r = FALSE,
dnn = c('actual default', 'predicted default'))
结果表格如下:
| predicted default
actual default | no | yes | Row Total |
---------------|-----------|-----------|-----------|
no | 58 | 7 | 65 |
| 0.580 | 0.070 | |
---------------|-----------|-----------|-----------|
yes | 19 | 16 | 35 |
| 0.190 | 0.160 | |
---------------|-----------|-----------|-----------|
Column Total | 77 | 23 | 100 |
---------------|-----------|-----------|-----------|
在这里,我们将提升前的总错误率从 33% 降低到提升模型中的 26%。这可能看起来改进不大,但它离我们预期的 25% 减少并不太远。话虽如此,如果提升可以这么容易地添加,为什么不将其默认应用于每个决策树呢?原因有两个。首先,如果构建一个决策树需要大量的计算时间,构建多个树可能在计算上不切实际。其次,如果训练数据非常嘈杂,那么提升可能根本不会带来改进。尽管如此,如果需要更高的准确性,尝试提升是值得的。
另一方面,模型在识别真实违约方面仍然表现不佳,正确预测的只有 46%(35 个中的 16 个),而简单模型中的正确率是 31%(35 个中的 11 个)。让我们再调查一个选项,看看我们是否可以减少这些代价高昂的错误。
一些错误比其他错误代价更高
给一个可能违约的申请人贷款可能是一个代价高昂的错误。减少错误阴性的一个解决方案可能是拒绝更多的边缘申请人,假设银行从风险贷款中获得的利息远远低于如果钱全部不还所造成的巨大损失。
C5.0 算法允许我们为不同类型的错误分配惩罚,以阻止树犯更多代价高昂的错误。这些惩罚在 成本矩阵 中指定,该矩阵指定每个错误相对于任何其他错误的代价高多少倍。
要开始构建成本矩阵,我们需要首先指定维度。由于预测值和实际值都可以取两个值,是 或 否,我们需要使用两个值组成的两个向量的列表来描述一个 2x2 的矩阵。同时,我们还将命名矩阵维度,以避免以后混淆:
> matrix_dimensions <- list(c("no", "yes"), c("no", "yes"))
> names(matrix_dimensions) <- c("predicted", "actual")
检查新对象显示我们的维度已经设置正确:
> matrix_dimensions
$predicted
[1] "no" "yes"
$actual
[1] "no" "yes"
接下来,我们需要通过提供四个值来填充矩阵,以分配各种类型错误的惩罚。由于 R 是从上到下逐列填充矩阵,因此我们需要按照特定顺序提供这些值:
-
预测为“否”,实际为“否”
-
预测为“是”,实际为“否”
-
预测为“否”,实际为“是”
-
预测为“是”,实际为“是”
假设我们认为贷款违约的成本是银行错过机会的四倍。那么我们的惩罚值可以这样定义:
> error_cost <- matrix(c(0, 1, 4, 0), nrow = 2,
dimnames = matrix_dimensions)
这就产生了以下矩阵:
> error_cost
actual
predicted no yes
no 0 4
yes 1 0
如此矩阵定义,当算法正确地将“否”或“是”分类时,没有分配成本,但假阴性有 4 的成本,而假阳性有 1 的成本。为了了解这如何影响分类,让我们使用C5.0()函数的costs参数将其应用于我们的决策树。我们还将使用之前相同的步骤:
> credit_cost <- C5.0(default ~ ., data = credit_train,
costs = error_cost)
> credit_cost_pred <- predict(credit_cost, credit_test)
> CrossTable(credit_test$default, credit_cost_pred,
prop.chisq = FALSE, prop.c = FALSE, prop.r = FALSE,
dnn = c('actual default', 'predicted default'))
这产生了以下混淆矩阵:
| predicted default
actual default | no | yes | Row Total |
---------------|-----------|-----------|-----------|
no | 34 | 31 | 65 |
| 0.340 | 0.310 | |
---------------|-----------|-----------|-----------|
yes | 5 | 30 | 35 |
| 0.050 | 0.300 | |
---------------|-----------|-----------|-----------|
Column Total | 39 | 61 | 100 |
---------------|-----------|-----------|-----------|
与我们的提升模型相比,这个版本总体上犯的错误更多:这里的错误率为 36%,而在提升模型中为 26%。然而,错误类型非常不同。在先前的模型中,只有 31%和 46%的违约被正确分类,而在本模型中,30/35=86%的实际违约被正确预测为违约。这种权衡在减少假阴性错误的同时增加了假阳性错误,如果我们的成本估计准确,这可能是可以接受的。
理解分类规则
分类规则以逻辑 if-else 语句的形式表示知识,为未标记的示例分配一个类别。它们由前件和后件指定,形成一个陈述,即“如果这个发生,那么那个就会发生。”前件包括某些特征值的组合,而后件指定如果规则的条件得到满足,则分配的类别值。一个简单的规则可能声明,“如果计算机正在发出点击声,那么它即将失败。”
规则学习者是决策树学习者的紧密相关的兄弟,通常用于类似类型的任务。像决策树一样,它们可以用于生成未来行动知识的应用,例如:
-
识别导致机械设备硬件故障的条件
-
描述人群群体的关键特征以进行客户细分
-
寻找导致股票市场价格大幅下跌或上涨的条件
规则学习者在与决策树相比有一些明显的差异。与必须通过一系列分支决策的树不同,规则是类似于独立事实陈述的命题。此外,由于以下将要讨论的原因,规则学习者的结果可能比基于相同数据的决策树更简单、更直接、更容易理解。
你可能已经意识到决策树的分支几乎与规则学习算法的 if-else 语句相同,实际上,规则可以从树中生成。那么,为什么还要有一个单独的规则学习算法组呢?继续阅读以发现区分两种方法的细微差别。
规则学习器通常应用于特征主要是或完全是名义性的问题。它们在识别罕见事件方面做得很好,即使罕见事件只发生在特征值之间非常具体的交互中。
分而治之
规则学习分类算法利用一种称为分而治之的启发式方法。这个过程包括识别一个覆盖训练数据中子集的规则,然后从这个分区中分离出剩余的数据。随着规则的添加,更多的数据子集被分离,直到整个数据集都被覆盖,没有更多的例子剩下。尽管分而治之在很多方面与之前提到的分而治之启发式方法相似,但它以微妙的方式有所不同,很快就会变得清楚。
想象分而治之的规则学习过程的一种方式是通过创建越来越具体的规则来深入数据。假设你被要求创建规则来识别动物是否是哺乳动物。你可以将所有动物集合表示为一个大的空间,如下面的图表所示:

图 5.8:一个规则学习算法可能有助于将动物分为哺乳动物和非哺乳动物组
规则学习器首先使用可用的特征来寻找同质群体。例如,使用一个表示物种是否通过陆地、海洋或空中旅行的特征,第一条规则可能建议任何陆生动物都是哺乳动物:

图 5.9:一个潜在的规则将陆地上旅行的动物视为哺乳动物
你注意到这个规则有任何问题吗?如果你是一个动物爱好者,你可能已经意识到青蛙是两栖动物,而不是哺乳动物。因此,我们的规则需要更加具体。让我们进一步深入,建议哺乳动物必须在陆地上行走并且有尾巴:

图 5.10:一个更具体的规则表明,在陆地上行走并且有尾巴的动物是哺乳动物
如前图所示,新规则导致了一组完全由哺乳动物组成的动物子集。因此,哺乳动物的子集可以与其他数据分开,青蛙被返回到剩余动物池中——无意中开玩笑!
可以定义一个额外的规则来区分蝙蝠,这是唯一剩下的哺乳动物。一个可能区分蝙蝠和其他动物的特征是毛皮的存在。使用围绕这个特征构建的规则,我们正确地识别了所有动物:

图 5.11:一条规则表明有毛皮的动物完美地分类了剩余的动物
在这一点上,由于所有训练实例都已分类,规则学习过程将停止。我们总共学习了三条规则:
-
在陆地上行走并带有尾巴的动物是哺乳动物
-
如果动物没有毛皮,它就不是哺乳动物
-
否则,该动物是哺乳动物
之前的例子说明了规则如何逐渐消耗越来越大的数据段,最终对所有实例进行分类。由于规则似乎覆盖了数据的一部分,因此分离和征服算法也被称为覆盖算法,而由此产生的规则被称为覆盖规则。在下一节中,我们将通过检查一个简单的规则学习算法来了解覆盖规则在实际中的应用。然后我们将检查一个更复杂的规则学习器,并将这两个算法应用于一个现实世界的问题。
1R 算法
假设一个电视游戏节目有一个动物隐藏在大型帘子后面。你被要求猜测它是否是哺乳动物,如果猜对了,你将赢得一大笔现金奖金。你没有得到任何关于动物特征的线索,但你知道世界上非常少的动物是哺乳动物。因此,你猜测“非哺乳动物”。你认为你赢得的机会有多大?
当然,选择这个选项最大化了你赢得奖金的机会,因为在假设动物是随机选择的情况下,这是最可能的结果。显然,这个游戏节目有点荒谬,但它展示了最简单的分类器,ZeroR,这是一个不考虑任何特征的规则学习器,实际上它没有学习任何规则(因此得名)。对于每个未标记的例子,无论其特征值如何,它都预测最常见的类别。这个算法在现实世界中的实用性非常有限,除了它为比较其他更复杂的规则学习器提供了一个简单的基线。
1R 算法(也称为One Rule或OneR),通过选择一条规则来改进 ZeroR。尽管这可能看起来过于简单,但它往往比你预期的表现要好。正如实证研究所证明的,这个算法的准确性可以接近许多现实世界任务中更复杂算法的准确性。
想要深入了解 1R 令人惊讶的性能,请参阅非常简单的分类规则在大多数常用数据集上表现良好,Holte, RC, 机器学习,1993,第 11 卷,第 63-91 页。
1R 算法的优缺点如下表所示:
| 优点 | 缺点 |
|---|
|
-
生成一个单一、易于理解、人类可读的规则
-
通常表现惊人地好
-
可以作为更复杂算法的基准
|
-
只使用单个特征
-
可能过于简单化
|
该算法的工作方式很简单。对于每个特征,1R 将数据划分为具有相似特征值的组。然后,对于每个段,算法预测多数类别。基于每个特征的规则错误率被计算,选择错误最少的规则作为单一规则。
以下表格显示了这对于我们之前查看的动物数据是如何工作的:

图 5.12:1R 算法选择具有最低误分类率的单一规则
对于通过特征,数据集被划分为三个组:空中、陆地和海洋。空中和海洋组的动物被预测为非哺乳动物,而陆地组的动物被预测为哺乳动物。这导致了两个错误:蝙蝠和青蛙。
有毛皮特征将动物分为两组。有毛皮的动物被预测为哺乳动物,而无毛皮的动物则不是。共有三个错误:猪、大象和犀牛。由于通过特征导致的错误较少,1R 算法会返回以下结果:
-
如果动物通过空中旅行,它就不是哺乳动物
-
如果动物通过陆地旅行,它就是哺乳动物
-
如果动物通过海洋旅行,它就不是哺乳动物
算法在这里停止,因为它找到了最重要的单一规则。
显然,这种规则学习算法可能对于某些任务来说过于基础。您希望医疗诊断系统只考虑单一症状,或者自动驾驶系统只基于单一因素来停止或加速您的汽车吗?对于这些类型的任务,一个更复杂的规则学习者可能更有用。我们将在下一节中了解一个。
RIPPER 算法
早期的规则学习算法存在几个问题。首先,它们以速度慢而闻名,这使得它们对于日益增多的大型数据集无效。其次,它们通常在噪声数据上容易不准确。
1994 年,Johannes Furnkranz 和 Gerhard Widmer 提出了解决这些问题的第一步。他们的增量减少错误剪枝(IREP)算法结合了预剪枝和后剪枝方法,这些方法可以生成非常复杂的规则,并在从完整数据集中分离实例之前对其进行剪枝。尽管这种策略有助于规则学习者的性能,但决策树通常仍然表现更好。
关于 IREP 的更多信息,请参阅 Incremental Reduced Error Pruning, Furnkranz, J and Widmer, G, Proceedings of the 11th International Conference on Machine Learning, 1994, pp. 70-77.
1995 年,威廉·W·科恩引入了重复增量剪枝以产生误差减少(RIPPER)算法,该算法在 IREP 的基础上进行了改进,以生成与决策树性能匹配或超过性能的规则,这使得规则学习者在 1995 年又迈出了新的一步。
关于 RIPPER 的更多详细信息,请参阅Cohen, WW,第 12 届国际机器学习会议论文集,1995 年,第 115-123 页《快速有效的规则归纳》。
如下表概述,RIPPER 的优点和缺点通常与决策树相当。主要好处是它们可能导致一个略微更节俭的模型。
| 优点 | 缺点 |
|---|
|
-
生成易于理解、人类可读的规则
-
在大型和噪声数据集上效率高
-
通常,产生的模型比可比较的决策树更简单
|
-
可能会导致看似违反常识或专家知识的规则
-
不适合处理数值数据
-
可能不如更复杂的模型表现得好
|
RIPPER 算法是从几个规则学习算法的迭代中演变而来的,它是一系列用于规则学习的有效启发式方法的拼凑。由于其复杂性,对实现细节的讨论超出了本书的范围。然而,可以将其大致理解为三个步骤的过程:
-
生长
-
剪枝
-
优化
生长阶段使用分离和征服技术贪婪地向规则添加条件,直到它完美地分类数据子集或耗尽用于分割的属性。像决策树一样,使用信息增益标准来识别下一个分割属性。当增加规则的具体性不再减少熵时,规则立即被剪枝。第一步和第二步重复进行,直到达到停止标准,此时使用各种启发式方法对整个规则集进行优化。
与 1R 算法相比,RIPPER 算法可以创建更复杂的规则,因为它可以考虑多个特征。这意味着它可以创建具有多个前件的规则,例如“如果一个动物会飞并且有毛皮,那么它是一种哺乳动物。”这提高了算法建模复杂数据的能力,但就像决策树一样,这也意味着规则可能很快变得难以理解。
分类规则学习者的进化并没有随着 RIPPER 而停止。新的规则学习算法正在迅速提出。文献综述显示,有 IREP++、SLIPPER、TRIPPER 等许多其他算法。
决策树中的规则
分类规则也可以直接从决策树中获得。从叶节点开始,沿着分支回溯到根节点,你可以得到一系列决策。这些决策可以组合成一条规则。以下图显示了如何从预测电影成功的决策树构建规则:

图 5.13:可以通过从根节点到每个叶节点的路径生成规则
沿着从根节点到每个叶节点的路径,规则如下:
-
如果名人数量少,那么这部电影将是一个 票房炸弹
-
如果名人数量多且预算高,那么这部电影将是一个 主流热门
-
如果名人数量多且预算低,那么这部电影将是一个 成功的关键
在下一节中将要解释的原因是,使用决策树生成规则的缺点是生成的规则通常比规则学习算法学习的规则更复杂。决策树使用的划分和征服策略与规则学习器的结果偏差不同。另一方面,有时从树中生成规则在计算上更有效。
在 C50 包中,使用 C5.0() 函数生成模型时,如果您在训练模型时指定 rules = TRUE,则会使用分类规则。
什么使得树和规则是贪婪的?
决策树和规则学习器被称为 贪婪学习器,因为它们基于先到先得的原则使用数据。决策树使用的划分和征服启发式方法以及规则学习器使用的单独和征服启发式方法都试图一次划分一个部分,首先找到最同质的划分,然后是下一个最好的,以此类推,直到所有示例都被分类。
贪婪方法的缺点是贪婪算法不能保证为特定数据集生成最优、最准确或最少数量的规则。通过早期获取低垂的果实,贪婪学习器可能会迅速找到一个适用于数据子集的准确规则;然而,在这样做的同时,学习器可能会错过开发一个更细致的规则集的机会,该规则集在整个数据集上具有更好的整体准确性。然而,如果不使用贪婪方法进行规则学习,对于除了最小的数据集之外的所有数据集,规则学习可能从计算上不可行。

图 5.14:决策树和分类规则学习器都是贪婪算法
虽然树和规则都采用贪婪学习启发式方法,但它们构建规则的方式存在细微差别。也许最好的区分方式是注意,一旦在特征上进行了划分和征服的分割,分割创建的分区可能不会被重新征服,而只会进一步细分。这样,树就永久性地受限于其过去的决策历史。相比之下,一旦单独和征服找到一个规则,任何未覆盖到该规则所有条件的示例可能会被重新征服。
为了说明这种对比,考虑我们之前构建规则学习器来确定动物是否是哺乳动物的情况。规则学习器确定了三条完美分类示例动物的规则:
-
在陆地上行走并且有尾巴的动物是哺乳动物(熊、猫、狗、大象、猪、兔子、老鼠和犀牛)
-
如果动物没有皮毛,它就不是哺乳动物(鸟类、鳗鱼、鱼类、青蛙、昆虫和鲨鱼)
-
否则,动物是哺乳动物(蝙蝠)
相比之下,基于相同数据的决策树可能提出了四条规则来实现相同的完美分类:
-
如果动物在陆地上行走并且有尾巴,那么它是哺乳动物(熊、猫、狗、大象、猪、兔子、老鼠和犀牛)
-
如果动物在陆地上行走但没有尾巴,那么它不是哺乳动物(青蛙)
-
如果动物不在陆地上行走并且有皮毛,那么它就是哺乳动物(蝙蝠)
-
如果动物不在陆地上行走并且没有皮毛,那么它不是哺乳动物(鸟类、昆虫、鲨鱼、鱼类和鳗鱼)
这两种方法的不同结果与青蛙在“在陆地上行走”决策后发生的情况有关。规则学习器允许青蛙被“没有皮毛”的决策重新征服,而决策树不能修改现有的分区,因此必须将青蛙放入它自己的规则中。

图 5.15:青蛙的处理区分了分而治之与分离和征服启发式方法。后者允许青蛙被后来的规则重新征服。
一方面,因为规则学习器可以重新审视那些被视为但最终未作为先前规则一部分的案件,规则学习器通常比决策树生成的规则更简洁。另一方面,这种数据重用意味着规则学习器的计算成本可能比决策树高一些。
示例 - 使用规则学习器识别有毒蘑菇
每年,许多人因食用有毒野生蘑菇而生病,甚至有些人因此死亡。由于许多蘑菇在外观上非常相似,有时即使是经验丰富的蘑菇采集者也会中毒。
与识别有毒植物,如毒橡树或毒常春藤不同,没有像“三叶草,让它去吧”这样的明确规则来识别野生蘑菇是有毒的还是可食用的。
事情变得更加复杂,许多传统规则,例如“有毒蘑菇颜色鲜艳”,提供了危险或误导性的信息。如果能够有简单、明确和一致的规则来识别有毒蘑菇,它们就能拯救采集者的生命。
规则学习算法的一个优点是它们生成的规则易于理解,这使得它们似乎非常适合这个分类任务。然而,这些规则的有效性将取决于它们的准确性。
第 1 步 – 收集数据
为了识别区分有毒蘑菇的规则,我们将使用卡内基梅隆大学的杰夫·施利默(Jeff Schlimmer)提供的蘑菇数据集。原始数据集可以从 UCI 机器学习仓库免费获取(archive.ics.uci.edu/ml)。
该数据集包括来自 23 种有菌盖蘑菇的 8,124 个蘑菇样本的信息,这些蘑菇在《奥杜邦北美蘑菇野外指南》(1981 年)中列出。在野外指南中,每种蘑菇物种都被标识为“肯定可食用”、“肯定有毒”或“可能有毒,不建议食用”。为了本数据集的目的,后者组被合并到“肯定有毒”组中,形成两个类别:有毒和非有毒。UCI 网站上可用的数据字典描述了蘑菇样本的 22 个特征,包括如菌盖形状、菌盖颜色、气味、菌褶大小和颜色、菌柄形状和栖息地等特征。
本章使用蘑菇数据的略微修改版本。如果你打算跟随示例进行操作,请从本章的 Packt Publishing GitHub 仓库下载mushrooms.csv文件,并将其保存到你的 R 工作目录中。
第 2 步 – 探索和准备数据
我们首先使用read.csv()函数导入分析所需的数据。由于所有 22 个特征和目标类别都是名义的,我们将stringsAsFactors = TRUE设置为利用自动因子转换的优势:
> mushrooms <- read.csv("mushrooms.csv", stringsAsFactors = TRUE)
str(mushrooms)命令的输出指出,数据包含 8,124 个观测值和 23 个变量,正如数据字典所描述的那样。虽然大多数str()输出并不引人注目,但有一个特征值得提及。你注意到以下行中的veil_type变量有什么特别之处吗?
$ veil_type : Factor w/ 1 level "partial": 1 1 1 1 1 1 ...
如果你认为一个因子只有一个级别很奇怪,你是正确的。数据字典列出了该特征的两个级别:partial和universal;然而,我们数据中的所有示例都被分类为partial。很可能这个数据元素被错误地编码了。无论如何,由于菌幕类型在样本之间没有变化,它不会为预测提供任何有用的信息。我们将使用以下命令从分析中删除这个变量:
> mushrooms$veil_type <- NULL
通过将NULL赋值给veil_type向量,R 将消除蘑菇数据框中的该特征。
在深入探讨之前,我们应该快速查看我们数据集中蘑菇类型变量的分布情况:
> table(mushrooms$type)
edible poisonous
4208 3916
大约 52%的蘑菇样本是可食用的,而 48%是有毒的。
对于这个实验,我们将蘑菇数据中的 8,214 个样本视为所有可能野生蘑菇的完整集合。这是一个重要的假设,因为它意味着我们不需要为测试目的从训练数据中保留一些样本。我们不是试图开发覆盖未预见蘑菇类型的规则;我们只是试图找到准确描述已知蘑菇类型完整集合的规则。因此,我们可以在相同的数据上构建和测试模型。
第 3 步 – 在数据上训练模型
如果我们在这个数据上训练一个假设的 ZeroR 分类器,它会预测什么?由于 ZeroR 忽略了所有特征,只是简单地预测目标的众数,用简单的话说,它的规则会声明“所有蘑菇都是可食用的”。显然,这不是一个非常有用的分类器,因为它会让蘑菇采集者在近一半的蘑菇样本中生病或死亡!我们的规则需要比这个基准做得更好,才能提供可以发布的安全建议。同时,我们需要简单易记的规则。
由于简单的规则仍然可能是有用的,让我们看看一个非常简单的规则学习者在蘑菇数据上的表现。为此,我们将应用 1R 分类器,它将识别对目标类别最有预测性的单个特征,并使用这个特征来构建规则。
我们将使用阿沙芬堡应用科学大学霍格·冯·约恩-迪德里希(Holger von Jouanne-Diedrich)在OneR包中找到的 1R 实现。这是一个相对较新的包,它使用原生 R 代码实现了 1R,以提高速度和易用性。如果您还没有这个包,可以使用install.packages("OneR")命令进行安装,并通过输入library(OneR)来加载。

图 5.16:1R 分类规则语法
与 C5.0 一样,OneR()函数使用 R 公式语法来指定要训练的模型。使用type ~ .公式与OneR()一起允许我们的第一个规则学习者在预测蘑菇类型时考虑蘑菇数据中的所有可能特征:
> mushroom_1R <- OneR(type ~ ., data = mushrooms)
要检查它创建的规则,我们可以输入分类器对象的名称:
> mushroom_1R
Call:
OneR.formula(formula = type ~ ., data = mushrooms)
Rules:
If odor = almond then type = edible
If odor = anise then type = edible
If odor = creosote then type = poisonous
If odor = fishy then type = poisonous
If odor = foul then type = poisonous
If odor = musty then type = poisonous
If odor = none then type = edible
If odor = pungent then type = poisonous
If odor = spicy then type = poisonous
Accuracy:
8004 of 8124 instances classified correctly (98.52%)
检查输出,我们看到odor特征被用于规则生成。odor的类别,如almond、anise等,指定了蘑菇是否可能为edible或poisonous的规则。例如,如果蘑菇闻起来有fishy、foul、musty、pungent、spicy或像creosote,那么蘑菇很可能是有毒的。另一方面,有更愉快气味的蘑菇,如almond和anise,以及完全没有气味的蘑菇,被预测为edible。对于蘑菇采集野外指南的目的,这些规则可以总结为一条简单的经验法则:“如果蘑菇闻起来令人不快,那么它很可能是有毒的。”
第 4 步 – 评估模型性能
输出的最后一行指出,规则正确预测了 8,124 个蘑菇样本中的 8,004 个的可食用性,即近 99%。然而,任何不够完美的预测都存在风险,如果模型将有毒蘑菇错误地分类为可食用,可能会导致中毒。
为了确定是否发生了这种情况,让我们检查预测值与实际值之间的混淆矩阵。这需要我们首先生成 1R 模型的预测,然后将预测与实际值进行比较:
> mushroom_1R_pred <- predict(mushroom_1R, mushrooms)
> table(actual = mushrooms$type, predicted = mushroom_1R_pred)
predicted
actual edible poisonous
edible 4208 0
poisonous 120 3796
在这里,我们可以看到我们的规则出错的地方。表格的列表示预测的蘑菇可食用性,而表格的行将 4,208 个实际可食用的蘑菇和 3,916 个实际有毒的蘑菇分开。查看表格,我们可以看到尽管 1R 分类器没有将任何可食用的蘑菇分类为有毒,但它将 120 个有毒蘑菇错误地分类为可食用,这造成了极其危险的错误!
考虑到学习器只利用了一个特征,它表现得相当不错;如果你在寻找蘑菇时避免不愉快的气味,你几乎总是能避免去医院。然而,当涉及到生命安全时,即使是接近完美也不够,更不用说当读者生病时,指南出版商可能不会对诉讼的前景感到高兴。让我们看看我们能否添加一些更多的规则,并开发出一个更好的分类器。
第 5 步 - 提高模型性能
对于一个更复杂的规则学习器,我们将使用JRip(),这是基于 Java 实现的 RIPPER 算法。JRip()函数包含在RWeka包中,它通过 Ian H. Witten 和 Eibe Frank 开发的基于 Java 的 Weka 软件应用,为 R 提供了访问这些机器学习算法的途径。
Weka 是一个流行的开源和功能齐全的图形应用程序,用于执行数据挖掘和机器学习任务——这类工具中最早的之一。有关 Weka 的更多信息,请参阅www.cs.waikato.ac.nz/~ml/weka/。
RWeka包依赖于rJava包,而rJava包本身要求在主机计算机上安装Java 开发工具包(JDK)才能安装。这可以从https://www.java.com/下载,并使用针对您平台的特定说明进行安装。安装 Java 后,使用install.packages("RWeka")命令安装RWeka及其依赖项,然后使用library(RWeka)命令加载RWeka包。
Java 是一套无需付费的编程工具,它允许开发和使用跨平台应用程序,如 Weka。尽管它曾经被许多计算机默认包含,但这已经不再是这样了。不幸的是,它可能很难安装,尤其是在苹果电脑上。如果你遇到麻烦,确保你有最新的 Java 版本。此外,在 Microsoft Windows 上,你可能需要正确设置你的环境变量,如 JAVA_HOME,并检查你的 PATH 设置(在网上搜索详细信息)。在 macOS 或 Linux 电脑上,你也可以尝试从终端窗口执行 R CMD javareconf,然后使用 R 命令 install.packages("rJava", type = "source") 从源安装 R 包 rJava。如果所有其他方法都失败了,你可以尝试一个免费的 Posit Cloud 账户 (posit.cloud/),它提供了一个已经安装了 Java 的 RStudio 环境。
在安装了 rJava 和 RWeka 之后,训练 JRip() 模型的过程与训练 OneR() 模型的过程非常相似,如下面的语法框所示。这是 R 公式接口的一个令人愉悦的好处:语法在算法之间是一致的,这使得比较各种模型变得简单。

图 5.17:RIPPER 分类规则语法
让我们像训练 OneR() 一样训练 JRip() 规则学习器,允许它在所有可用特征中找到规则:
> mushroom_JRip <- JRip(type ~ ., data = mushrooms)
要检查规则,请输入分类器的名称:
> mushroom_JRip
JRIP rules:
===========
(odor = foul) => type=poisonous (2160.0/0.0)
(gill_size = narrow) and (gill_color = buff)
=> type=poisonous (1152.0/0.0)
(gill_size = narrow) and (odor = pungent)
=> type=poisonous (256.0/0.0)
(odor = creosote) => type=poisonous (192.0/0.0)
(spore_print_color = green) => type=poisonous (72.0/0.0)
(stalk_surface_below_ring = scaly)
and (stalk_surface_above_ring = silky)
=> type=poisonous (68.0/0.0)
(habitat = leaves) and (gill_attachment = free)
and (population = clustered)
=> type=poisonous (16.0/0.0)
=> type=edible (4208.0/0.0)
Number of Rules : 8
JRip() 分类器从蘑菇数据中学习了总共八条规则。
读取这些规则的一个简单方法是将它们视为一系列的 if-else 语句,类似于编程逻辑。前三个规则可以表示为:
-
如果气味难闻,那么蘑菇类型是有毒的
-
如果菌盖大小狭窄且菌盖颜色为米色,那么蘑菇类型是有毒的
-
如果菌盖大小狭窄且气味刺鼻,那么蘑菇类型是有毒的
最后,第八条规则意味着任何前七条规则未涵盖的蘑菇样本都是可食用的。按照我们的编程逻辑,这可以读作:
- 否则,蘑菇是可食用的
每条规则旁边的数字表示该规则覆盖的实例数量和误分类实例的数量。值得注意的是,使用这八条规则没有误分类的蘑菇样本。因此,最后一条规则覆盖的实例数量正好等于数据中可食用蘑菇的数量(N = 4,208)。
下图提供了一个如何将规则应用于蘑菇数据的粗略说明。如果你想象这个大椭圆形包含所有蘑菇种类,规则学习器会识别出将同质段与较大群体区分开来的特征,或特征集。首先,算法发现了一群独特的有毒蘑菇,它们以其恶臭而闻名。接下来,它发现了更小、更具体的有毒蘑菇群体。通过为每种有毒蘑菇识别覆盖规则,所有剩余的蘑菇都是可食用的。
感谢大自然母亲,每种蘑菇的独特性足够让分类器达到 100% 的准确性。

图 5.18:一个复杂的规则学习算法识别了规则,完美地覆盖了所有类型的有毒蘑菇
摘要
本章介绍了两种使用所谓“贪婪”算法根据特征值对数据进行划分的分类方法。决策树使用分而治之的策略来创建类似流程图的结构,而规则学习器则分离并征服数据以识别逻辑的 if-else 规则。这两种方法产生的模型可以在没有统计背景的情况下进行解释。
一种流行且高度可配置的决策树算法是 C5.0。我们使用 C5.0 算法创建了一棵树,用于预测贷款申请人是否会违约。通过使用提升和成本敏感的错误选项,我们提高了准确性,并避免了可能给银行带来更多金钱损失的风险贷款。
我们还使用了两个规则学习器,1R 和 RIPPER,来开发识别有毒蘑菇的规则。1R 算法使用单个特征实现了 99% 的准确性,用于识别可能致命的蘑菇样本。另一方面,由更复杂的 RIPPER 算法生成的八条规则正确地识别了每种蘑菇的可食用性。
这仅仅触及了如何使用树和规则的一角。下一章,第六章,预测数值数据 – 回归方法,描述了被称为回归树和模型树的技巧,这些技巧使用决策树进行数值预测而不是分类。在第八章,寻找模式 – 使用关联规则进行市场篮子分析中,我们将看到关联规则——分类规则的近亲——如何被用来识别交易数据中的物品组。最后,在第十四章,构建更好的学习者中,我们将发现如何通过将决策树组合在一个称为随机森林的模型中来提高决策树的表现,此外还有其他依赖于决策树的先进建模技术。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人相聚,并和超过 4000 人在以下链接处一起学习:

第六章:预测数值数据 – 回归方法
数学关系帮助我们理解日常生活的许多方面。例如,体重是个人摄入卡路里的函数;收入通常与教育和工作经验相关;而民意调查数字有助于估计总统候选人连任的机会。
当用数字来表述这些模式时,我们能够获得额外的清晰度。例如,每天额外摄入 250 千卡路里可能会导致每月增加近 1 公斤的体重;每增加一年的工作经验可能会使年薪增加 1000 美元;当经济强劲时,总统更有可能获得连任。显然,这些方程并不完美地适用于每一种情况,但我们预期它们在大多数时候是相当准确的。
本章通过超越之前介绍的分类方法,并引入用于估计数值数据中关系的技巧,扩展了我们的机器学习工具箱。在考察几个现实世界的数值预测任务时,你将学习:
-
回归中使用的基本统计原理,这是一种模拟数值关系大小和强度的技术
-
如何为回归分析准备数据,估计和解释回归模型,以及应用回归变体,如广义线性模型
-
一对称为回归树和模型树的混合技术,这些技术将决策树分类器适应于数值预测任务
基于统计学领域的丰富研究成果,本章所使用的方法在数学方面比之前介绍的方法更为复杂,但请放心!即使你的代数技能有些生疏,R 语言会帮你处理繁重的工作。
理解回归
回归涉及指定单个数值因变量(要预测的值)与一个或多个数值自变量(预测因子)之间的关系。正如其名称所暗示的,因变量依赖于自变量或变量的值。回归的最简单形式假设自变量和因变量之间的关系遵循一条直线。
术语“回归”用来描述将线拟合到数据的过程,其起源可以追溯到 19 世纪末弗朗西斯·高尔顿爵士对遗传学的研究。他发现,身高极矮或极高的父亲往往会有身高更接近平均值的儿子。他把这种现象称为“回归到平均值”。
你可能还记得从基础代数中,直线可以用类似于 y = a + bx 的斜率截距形式来定义。在这种形式中,字母 y 表示因变量,x 表示自变量。斜率项 b 指定了直线在 x 增加时上升的量。正值定义了向上倾斜的直线,而负值定义了向下倾斜的直线。项 a 被称为截距,因为它指定了直线与垂直 y 轴相交或截取的点。它表示当 x = 0 时 y 的值。

图 6.1:具有各种斜率和截距的直线示例
回归方程使用类似的斜率截距格式来模拟数据。机器的任务是确定* a* 和 b 的值,使得指定的直线最能将提供的 x 值与 y 的值联系起来。
可能并不总是存在一组完美的 a 和 b 参数来完美地关联这些值,因此机器还必须有一些方法来量化误差范围并选择最佳拟合。我们将在稍后深入讨论这个问题。
回归分析被用于各种任务——它几乎肯定是应用最广泛的机器学习方法。它可以用于解释过去并预测未来,并且可以应用于几乎任何任务。一些具体的用例包括:
-
检验人口和个体通过其测量的特征如何变化,这在经济学、社会学、心理学、物理学和生态学等科学研究中
-
量化事件与其响应之间的因果关系,例如在临床试验、工程安全测试或市场研究中
-
识别可以使用已知标准来预测未来行为的模式,例如用于预测保险索赔、自然灾害损失、选举结果和犯罪率
回归方法也用于统计假设检验,这决定了在观察数据的基础上,一个前提是否可能是真实的或错误的。回归模型对关系强度和一致性的估计提供了可用于评估观察结果是否仅由偶然性引起的信息。
假设检验非常微妙,超出了机器学习的范围。如果你对这个主题感兴趣,一本入门统计学教科书是一个好的起点,例如,直观入门统计学,沃尔夫,D. A. 和施奈德,G.,斯普林格,2017。
回归分析并不等同于单个算法。相反,它是一个涵盖许多方法的术语,这些方法可以适应几乎任何机器学习任务。如果你只能选择一个机器学习方法来研究,回归将是一个不错的选择。一个人可能可以将整个职业生涯都投入到这个领域,也许仍然有很多东西要学习。
在本章中,我们将从最基本的线性回归模型开始——那些使用直线的模型。只有一个自变量的情况被称为简单线性回归。有两个或更多自变量的情况被称为多元线性回归,或简称多元回归。这两种技术都假设有一个单一的因变量,它在连续尺度上被测量。
回归还可以用于其他类型的因变量,甚至可以用于某些分类任务。例如,逻辑回归用于建模二元分类结果,而泊松回归(以法国数学家西莫恩·泊松的名字命名)用于建模整数计数数据。被称为多项式逻辑回归的方法用于建模分类结果,因此可以用于分类。
这些专门的回归方法属于广义线性模型(GLMs)类别,它们将传统回归模型的直线调整为允许建模其他形式的数据。这些将在本章后面进行描述。
由于类似的统计原理适用于所有回归方法,一旦你理解了线性情况,了解其他变体就变得简单直接。我们将从简单线性回归的基本情况开始。尽管名称上看似简单,但这种方法并不简单到无法解决复杂问题。在下一节中,我们将看到简单线性回归模型的使用如何可能避免一场悲剧性的工程灾难。
简单线性回归
1986 年 1 月 28 日,美国航天飞机“挑战者”号的七名机组人员在火箭助推器故障导致灾难性解体时丧生。在事故发生后,专家们迅速将发射温度视为潜在的罪魁祸首。负责密封火箭接头的橡胶 O 形圈从未在 40°F(4°C)以下进行过测试!F (4
C),而发射当天的天气异常寒冷,低于冰点。
借助事后诸葛的优势,这次事故已成为数据分析可视化的重要性案例研究。尽管不清楚火箭工程师和决策者在发射前可以获得哪些信息,但不可否认的是,更好的数据,如果被谨慎使用,很可能避免这场灾难。
本节的分析基于《航天飞机风险分析:挑战者号之前的故障预测》,Dalal, S. R., Fowlkes, E. B., 和 Hoadley, B.,美国统计学会杂志,1989 年,第 84 卷,第 945-957 页中呈现的数据。关于数据如何可能改变结果的一个观点,请参阅《视觉解释:图像与数量,证据与叙事》,Tufte, E. R.,Cheshire, C. T.:Graphics Press,1997 年。对于相反的观点,请参阅《表现与误表现:图夫特与挑战者号上的莫顿·索尔科工程师》,Robison, W.,Boisjoly, R.,Hoeker, D.,和 Young, S.,《科学和工程伦理》,2002 年,第 8 卷,第 59-81 页。
火箭工程师几乎肯定知道低温会使组件变得更加脆弱,密封性能降低,这会导致危险燃料泄漏的概率增加。然而,鉴于继续发射的政治压力,他们需要数据来支持这一假设。一个展示温度与 O 形圈故障之间联系的回归模型,并且能够根据预期的发射温度预测故障概率,可能会非常有帮助。
为了构建回归模型,科学家可能使用了在 23 次之前成功的航天飞机发射中记录的发射温度和组件故障的数据。组件故障表明两种类型的问题之一。第一种问题,称为侵蚀,发生在过度的热量烧毁 O 形圈时。第二种问题,称为吹过,发生在热气体通过或“吹过”密封不良的 O 形圈时。由于航天飞机共有六个主要 O 形圈,每次飞行可能发生多达六个故障。尽管火箭可以生存一个或多个故障事件或被一个故障摧毁,但每个额外的故障都会增加灾难性故障的概率。以下散点图显示了之前 23 次发射检测到的初级 O 形圈故障,与发射温度的对比:

图 6.2:航天飞机 O 形圈故障与发射温度的可视化
检查图表,存在一个明显的趋势:在较高温度下进行的发射往往有较少的 O 形圈故障事件。此外,最冷的发射(53°F)发生了两个故障事件,这个水平只在另一次发射中达到。考虑到这些信息,挑战者号计划在比这低 20 多度的条件下发射似乎令人担忧。但他们应该有多担心?为了回答这个问题,我们可以转向简单线性回归。
简单线性回归模型定义了因变量与单个自变量预测变量之间的关系,使用以下形式的方程定义的线:

除了希腊字母外,这个方程几乎与之前描述的斜截式相同。截距
(alpha)描述了直线与 y 轴的交点,而斜率
(beta)描述了在 x 增加时 y 的变化。对于航天飞机发射数据,斜率将告诉我们发射温度每增加一度,O 形圈故障预期的变化。
在统计学领域,希腊字母通常用于表示统计函数的参数变量。因此,进行回归分析涉及找到
和
的参数估计。alpha 和 beta 的参数估计通常用 a 和 b 表示,尽管你可能发现一些术语和符号被交替使用。
假设我们知道航天飞机发射数据方程中估计的回归参数是 a = 3.70 和 b = -0.048。因此,完整的线性方程是 y = 3.70 – 0.048x。暂时忽略这些数字是如何得到的,我们可以像这样在散点图上绘制这条线:

图 6.3:一个回归线,用于模拟压力事件与发射温度之间的关系
如线所示,在 60 华氏度时,我们预测不到一个 O 形圈的故障事件。在 50 华氏度时,我们预计大约有 1.3 次故障。如果我们使用该模型外推到 31 度——挑战者号预测的温度——我们预计大约会有 3.70 - 0.048 * 31 = 2.21 次 O 形圈的故障事件。
假设每个 O 形圈故障同样可能引起灾难性的燃料泄漏,这意味着挑战者号在 31 度发射的风险几乎是 60 度典型发射的三倍,比 70 度发射的风险超过八倍。
注意到直线并没有精确地穿过每个数据点。相反,它在大约均匀地穿过数据,一些预测值低于或高于直线。在下一节中,我们将学习为什么选择这条特定的线。
普通最小二乘估计
为了确定
和
的最佳估计值,使用了一种称为普通最小二乘法(OLS)的估计方法。在 OLS 回归中,斜率和截距被选择以最小化平方误差和(SSE)。误差,也称为残差,是预测的 y 值与实际 y 值之间的垂直距离。由于误差可能是高估或低估,它们可以是正数或负数;平方它们使得误差无论方向如何都是正数。以下图表展示了几个点的残差:

图 6.4:回归线的预测值与实际值之间的差异由残差量决定
用数学术语来说,OLS 回归的目标可以表达为最小化以下方程的任务:

用简单的话说,这个方程定义e(误差)为实际y值与预测y值之间的差异。误差值被平方以消除负值,并在数据中的所有点上进行求和。
在y项上方的撇号(^)是统计符号中常用的一个特征。它表示该项是对真实y值的估计。这被称为y的估计值。
a的解取决于b的值。可以使用以下公式获得:

要理解这些方程,你需要了解另一部分统计符号。出现在x和y项上方的水平横线表示x或y的均值值。这被称为x横或y横。
虽然证明超出了本书的范围,但可以使用微积分证明,导致最小平方误差的b的值是:

如果我们将这个方程分解为其组成部分,我们可以稍微简化它。b的分母应该看起来很熟悉;它与x的方差非常相似,表示为 Var(x)。正如我们在第二章,管理和理解数据中学到的,方差涉及找到x的均值与均值的平均平方偏差。这可以表示为:

分子涉及将每个数据点的偏差(与均值x值的偏差)乘以该点偏离均值y值的偏差之和。这与x和y的协方差函数类似,表示为 Cov(x, y)。协方差公式是:

如果我们将协方差函数除以方差函数,分子和分母中的n项会相互抵消,我们可以将b的公式重写为:

给出这种重述后,使用内置的 R 函数很容易计算出b的值。让我们将它们应用于航天飞机发射数据来估计回归线。
如果你想跟随这些示例,请从 Packt Publishing 网站下载challenger.csv文件,并使用launch <- read.csv("challenger.csv")命令将其加载到数据框中。
如果航天飞机数据存储在名为launch的数据框中,自变量x命名为temperature,因变量y命名为distress_ct,则可以使用 R 函数cov()和var()来估计b:
> b <- cov(launch$temperature, launch$distress_ct) /
var(launch$temperature)
> b
[1] -0.04753968
然后,我们可以通过使用计算出的b值并应用mean()函数来估计a:
> a <- mean(launch$distress_ct) - b * mean(launch$temperature)
> a
[1] 3.698413
手动估计回归方程显然不是最佳选择,因此 R 预期地提供了一个用于自动拟合回归模型的功能。我们很快就会使用这个功能。在此之前,通过首先学习一种衡量线性关系强度的方法,来扩展你对回归模型拟合的理解是很重要的。此外,你很快就会学习如何将多元线性回归应用于具有多个自变量的问题。
相关性
两个变量之间的相关性是一个数字,表示它们之间的关系有多接近一条直线。如果没有额外的限定,相关性通常指的是皮尔逊相关系数,这是 20 世纪数学家卡尔·皮尔逊开发的。相关性的范围在 -1 到 +1 之间。最大值和最小值表示完全的线性关系,而接近零的相关性表示没有线性关系。
以下公式定义了皮尔逊相关系数:

这里引入了更多的希腊符号:第一个符号(看起来像小写的 p)是 rho,它用来表示皮尔逊相关统计量。看起来像 q 字符逆时针旋转的符号是希腊字母小写 sigma,它们表示 x 或 y 的标准差。
使用这个公式,我们可以计算发射温度和 O 型圈故障事件数之间的相关性。回想一下,协方差函数是 cov(),标准差函数是 sd()。我们将结果存储在 r 中,这是一个常用来表示估计相关性的字母:
> r <- cov(launch$temperature, launch$distress_ct) /
(sd(launch$temperature) * sd(launch$distress_ct))
> r
[1] -0.5111264
或者,我们可以使用 cor() 相关函数得到相同的结果:
> cor(launch$temperature, launch$distress_ct)
[1] -0.5111264
温度和故障 O 型圈数量之间的相关系数是 -0.51。负相关性表明温度的增加与故障 O 型圈数量的减少有关。对于研究 O 型圈数据的 NASA 工程师来说,这将是一个非常明确的指标,表明低温发射可能存在问题。相关性还告诉我们温度和 O 型圈故障之间的相对强度。因为 -0.51 是最大负相关系数 -1 的一半,这意味着存在中等强度的负线性关联。
在解释相关性强度方面,有许多经验法则。一种方法是将 0.1 到 0.3 之间的值标记为“弱”;0.3 到 0.5 之间的范围标记为“中等”;而高于 0.5 的值标记为“强”(这些也适用于类似范围的负相关性)。然而,这些阈值对于某些目的可能过于严格或过于宽松。通常,相关性必须在特定背景下进行解释。
对于涉及人类的数据,0.5 的相关性可能被认为非常高;对于由机械过程生成的数据,0.5 的相关性可能非常弱。
你可能听说过“相关性不等于因果关系”这个说法。这源于这样一个事实,即相关性仅描述了一对变量之间的关联,但可能存在其他未考虑的解释,这些解释可能是观察到的关系的责任。例如,寿命与每天观看电影的时间之间可能存在强烈的关联,但在医生建议我们所有人都看更多电影之前,我们需要排除另一个解释:年轻人看更多的电影,而年轻人(总的来说)不太可能死亡。
测量两个变量之间的相关性为我们提供了一种快速检查独立变量和因变量之间线性关系的方法。随着我们开始定义具有更多预测因子的回归模型,这一点将变得越来越重要。
多元线性回归
大多数现实世界的分析都有多个自变量。因此,你很可能会在大多数数值预测任务中使用多元线性回归。多元线性回归的优缺点如下表所示:
| 优点 | 缺点 |
|---|
|
-
到目前为止,建模数值数据最常见的方法
-
可以适应几乎任何建模任务
-
提供了特征和结果之间关系的大小和强度的估计
|
-
对数据有强烈的假设
-
模型的形式必须由用户事先指定
-
无法处理缺失数据
-
只适用于数值特征,因此分类数据需要额外的准备
-
需要一些统计学知识来理解模型
|
我们可以将多元回归视为简单线性回归的扩展。在两种情况下,目标都是相似的——找到斜率系数的值,以最小化线性方程的预测误差。关键的区别是,对于额外的自变量,有额外的项。
多元回归模型具有以下方程的形式。因变量 y 被指定为截距项
与每个 i 个特征对应的估计值
与 x 变量的乘积之和。这里增加了一个误差项
(用希腊字母 epsilon 表示),作为提醒预测并不完美。这代表了之前提到的残差项:

让我们暂时考虑一下估计的回归参数的解释。你会注意到,在前面的方程中,为每个特征提供了一个系数。这允许每个特征对 y 的值有单独的估计影响。换句话说,当特征 ![img/B17290_06_023.png] 每增加一个单位时,y 的变化量为 ![img/B17290_06_022.png]。因此,截距 ![img/B17290_06_005.png] 是当独立变量都为零时 y 的期望值。
由于截距项 ![img/B17290_06_005.png] 真的与其他任何回归参数没有区别,它有时也用 ![img/B17290_06_024.png] (发音为 beta naught)表示,如下面的公式所示:
![img/B17290_06_025.png]
就像之前一样,截距与任何独立变量 x 都无关。然而,由于以下原因将在短时间内变得清楚,想象 ![img/B17290_06_024.png] 被乘以一个项 x[0] 有助于理解。我们将 x[0] 分配为一个常量,其值为 1:
![img/B17290_06_027.png]
为了估计回归参数,必须将因变量 y 的每个观测值与独立变量 x 的观测值通过前面形式的回归方程联系起来。以下图是多个回归任务设置的图形表示:

图 6.5:多重回归试图找到![img/B17290_06_006.png]值,这些值将 X 值与 Y 值相关联,同时最小化![img/B17290_06_020.png]
上述图中所展示的许多行和列的数据可以用加粗的矩阵符号来压缩表示,以表明每个项代表多个值。以这种方式简化后,公式如下:
![img/B17290_06_030.png]
在矩阵表示法中,因变量是一个向量,Y,其中每一行代表一个示例。独立变量被组合成一个矩阵,X,其中每一列代表一个特征,还有一个额外的截距列,值为 1。每一列都有每一行的示例。回归系数 ![img/B17290_06_031.png] 和残差误差 ![img/_eqn_032.png] 现在也是向量。
目标现在是要求解![img/B17290_06_031.png],这是使预测值和实际Y值之间平方误差和最小的回归系数向量。找到最优解需要使用矩阵代数;因此,推导需要比本文中提供的更仔细的注意。然而,如果你愿意相信他人的工作,向量![img/B17290_06_031.png]的最佳估计可以计算如下:
![img/B17290_06_034.png]
这个解决方案使用了一对矩阵运算:T 表示矩阵 X 的转置,而负指数表示 矩阵逆。利用 R 的内置矩阵运算,我们可以实现一个简单的多元回归学习器。让我们将这个公式应用到挑战者号发射数据上。
如果你对前面的矩阵运算不熟悉,Wolfram MathWorld 的转置页面 (mathworld.wolfram.com/Transpose.html) 和矩阵逆页面 (mathworld.wolfram.com/MatrixInverse.html) 提供了全面的介绍,即使没有高级数学背景也能理解。
使用以下代码,我们可以创建一个名为 reg() 的基本回归函数,它接受参数 y 和 x,并返回一个估计的贝塔系数向量:
> reg <- function(y, x) {
x <- as.matrix(x)
x <- cbind(Intercept = 1, x)
b <- solve(t(x) %*% x) %*% t(x) %*% y
colnames(b) <- "estimate"
print(b)
}
这里创建的 reg() 函数使用了几个我们之前没有用过的 R 命令。首先,由于我们将使用该函数与数据框的列集一起使用,as.matrix() 函数将数据框转换为矩阵形式。
接下来,cbind() 函数将一个额外的列绑定到 x 矩阵上;命令 Intercept = 1 指示 R 将新列命名为 Intercept 并用重复的 1 值填充该列。然后,对 x 和 y 对象执行一系列矩阵运算:
-
solve()取矩阵的逆 -
t()用于矩阵的转置 -
%*%用于两个矩阵的乘法
通过将这些 R 函数组合起来,我们的函数将返回一个向量 b,其中包含与 x 到 y 相关的线性模型的估计参数。函数中的最后两行给 b 向量命名并在屏幕上打印结果。
让我们将这个函数应用到航天飞机发射数据上。如下面的代码所示,数据集包括三个特征和灾情计数(distress_ct),这是我们感兴趣的结果:
> str(launch)
'data.frame': 23 obs. of 4 variables:
$ distress_ct : int 0 1 0 0 0 0 0 0 1 1 ...
$ temperature : int 66 70 69 68 67 72 73 70 57 63 ...
$ field_check_pressure: int 50 50 50 50 50 50 100 100 200 200 ...
$ flight_num : int 1 2 3 4 5 6 7 8 9 10 ...
我们可以通过比较其对于之前找到的 O 型环故障与温度的简单线性回归模型的结果来确认我们的函数是否正确工作,该模型具有参数 a = 3.70 和 b = -0.048。由于温度位于发射数据的第二列,我们可以如下运行 reg() 函数:
> reg(y = launch$distress_ct, x = launch[2])
estimate
Intercept 3.69841270
temperature -0.04753968
这些值与我们之前的结果完全匹配,因此让我们使用这个函数来构建一个多元回归模型。我们将像之前一样应用它,但这次我们将指定 x 参数的第二到第四列以添加两个额外的预测因子:
> reg(y = launch$distress_ct, x = launch[2:4])
estimate
Intercept 3.527093383
temperature -0.051385940
field_check_pressure 0.001757009
flight_num 0.014292843
该模型使用温度、现场检查压力和发射 ID 号来预测 O 形圈故障事件的数量。值得注意的是,加入这两个新的预测因子并没有改变我们从简单线性回归模型中得到的结果。正如之前一样,温度变量的系数为负,这表明随着温度的升高,预期的 O 形圈事件数量会减少。这种影响的大小也大致相同:发射温度每增加一度,预计的故障事件将减少大约 0.05 次。
这两个新的预测因子也对预测的故障事件有贡献。现场检查压力是指在发射前测试中对 O 形圈施加的压力量。虽然检查压力最初是 50 磅力,但在某些发射中提高到 100 和 200 磅力,这导致一些人认为这可能是 O 形圈侵蚀的原因。系数为正,但很小,至少为这个假设提供了一些证据。飞行次数代表了航天飞机的年龄。每次飞行,它都会变老,部件可能会更脆弱或更容易损坏。飞行次数与故障计数之间的小的正相关可能反映了这一事实。
总体而言,我们对航天飞机数据的回顾性分析表明,考虑到天气条件,挑战者号的发射风险很高。也许如果工程师们事先应用线性回归,灾难可能就可以避免。当然,当时的情况现实,以及涉及的政治影响,肯定没有现在回望时看起来那么简单。
广义线性模型和逻辑回归
如同在挑战者号航天飞机发射数据分析中所展示的那样,标准线性回归是用于建模数值结果与一个或多个预测因子之间关系的有用方法。回归能够经受时间的考验,这并不奇怪。即使在一百多年后,它仍然是我们工具箱中最重要的技术之一,尽管它并不比找到最佳直线来拟合数据更复杂。
然而,并非每个问题都适合用一条线来建模,而且,回归模型在许多实际任务中违反了统计假设。即使是挑战者号的数据,对于线性回归来说也不够理想,因为它违反了回归假设,即目标变量是在连续尺度上测量的。由于 O 形圈的故障次数只能取可数值,因此模型预测出恰好 2.21 次故障事件,而不是两个或三个,这是没有意义的。
对于建模计数值、分类或二元结果,以及其他目标变量不是正态分布的连续变量的情况,标准线性回归并不是最佳工具——尽管许多人仍然将这些类型的问题应用于它,并且它通常表现得相当出色。
为了解决这些不足,可以使用名为 GLM 的适当名称来适应其他用例,该模型最早由统计学家 John Nelder 和 Robert Wedderburn 于 1972 年描述。GLM 放宽了传统回归模型的两个假设。首先,它允许目标变量是非正态分布的、非连续变量。其次,它允许目标变量的方差与其均值相关。前者为建模分类数据或计数数据,甚至预测值范围有限的情况(例如,落在 0 到 1 之间的概率值)打开了大门。后者允许模型更好地拟合预测变量与预测以非线性方式相关的情况,例如指数增长,其中时间的增加导致结果的增加越来越大。
要阅读关于广义线性模型(GLM)的原始出版物,请参阅Nelder, J. A. 和 Wedderburn, T. W. M.,皇家统计学会杂志,1972 年,第 135 卷,第 370-384 页。对于更温和、非数学性的介绍,请参阅Dunteman, G. H. 和 Ho, M. H. R.,社会科学定量应用,2006 年,第 145 卷。
这两种对线性回归的推广反映在任何 GLM 的两个关键组件中:
-
家族指的是目标特征的分布,必须从指数分布族的成员中选择,该族包括正态高斯分布以及其他如泊松、二项和伽马分布。所选分布可以是离散的或连续的,并且可以跨越不同的值范围,例如仅正数或仅介于零和一之间的值。
-
链接函数将预测变量与目标变量之间的关系转换为可以使用线性方程进行建模的形式,尽管原始关系是非线性的。始终存在一个标准链接函数,它由所选的家族确定并默认使用,但在某些情况下,可以选择不同的链接来改变模型的理解方式或获得更好的模型拟合。
调整家族和链接函数使广义线性模型(GLM)方法具有极大的灵活性,以适应许多不同的实际应用场景,并符合目标变量的自然分布。了解使用哪种组合取决于模型的应用方式以及目标变量的理论分布。详细了解这些因素需要了解指数族中的各种分布以及统计学理论背景。幸运的是,大多数广义线性模型(GLM)的应用符合一些常见的家族和链接组合,这些组合在下面的表中列出:
| 家族 | 规范链接函数 | 目标范围 | 注意事项和应用 |
|---|---|---|---|
| 高斯(正态) | 恒等 | -![img/B17290_06_036.png]到![img/B17290_06_036.png] | 用于线性响应建模;将广义线性模型(GLM)简化为标准线性回归。 |
| 泊松 | 对数 | 整数 0 到![img/B17290_06_036.png] | 称为泊松回归;通过估计事件发生的频率来建模事件发生的次数(如 O 型圈故障的总数)。 |
| 二项式 | 对数几率 | 0 到 1 | 称为逻辑回归;通过估计结果发生的概率来建模二元结果(如是否有 O 型圈故障)。 |
| 伽马 | 负逆 | 0 到![img/B17290_06_036.png] | 建模右偏斜数据的一种可能性;可用于建模事件发生的时间(如 O 型圈故障的秒数)或成本数据(如汽车事故的保险索赔成本)。 |
| 多项式 | 对数几率 | K个类别中的 1 个 | 称为多项式逻辑回归;通过估计示例落在每个类别中的概率来建模分类结果(如成功、失败或中止的航天飞机发射)。通常使用专门的软件包而不是广义线性模型(GLM)函数来帮助解释。 |
由于解释广义线性模型(GLM)的细微差别,要熟练应用一个模型需要大量的实践和仔细的研究,而且很少有人能声称自己是所有这些模型的专家。整本教科书都致力于每种广义线性模型(GLM)变体。幸运的是,在机器学习的领域,解释和理解的重要性不如能够将正确的广义线性模型(GLM)形式应用于实际问题并产生有用的预测。虽然本章不能涵盖列出的每种方法,但关键细节的介绍将允许你后来追求与你自己的工作最相关的广义线性模型(GLM)变体。
从表中列出的最简单变体开始,标准线性回归可以被视为一种特殊的 GLM,它使用高斯族和恒等链接函数。恒等链接意味着目标 y 与预测变量 x[i] 之间的关系没有进行任何转换。因此,与标准回归一样,一个估计的回归参数
可以相当简单地解释为在 x[i] 增加 1 个单位时 y 的增加量,假设所有其他因素都保持不变。
使用其他链接函数的 GLM 形式并不容易解释,要完全理解单个预测变量的影响需要更加仔细的分析。这是因为回归参数必须被解释为在 x 增加 1 个单位时 y 的增加量,但这是在通过链接函数转换之后。
例如,使用 对数链接 函数来模拟事件预期计数的泊松族,通过自然对数将 y 与预测变量 x[i] 相关联;因此,
对 y 的加性效应在响应变量的原始尺度上变成了乘性效应。这是因为利用对数的性质,我们知道
,在指数化以消除对数后,这变成了
。
由于这种乘性影响,参数估计被理解为相对增加率,而不是像线性回归中那样是 y 的绝对增加量。
为了在实践中看到这一点,假设我们构建了一个关于 O 型圈故障数与发射温度的泊松回归模型。如果 x[1] 是温度,并且估计的
= -0.103,那么我们可以确定,在发射时每增加 1 度温度,平均 O 型圈故障数会减少约 9.8%。这是因为 exp(-0.103) = 0.902,或者说每度故障的 90.2%,这意味着我们预计每增加 1 度温度,故障数会减少 9.8%。将此应用于挑战者号发射时的 36 华氏度温度,我们可以外推,如果发射温度再高 17 度(53 华氏度是之前的最低发射温度),那么预期的故障数将大约是 (0.902)¹⁷ = 17.2%,相当于减少 82.8%。
在航天飞机数据的背景下,假设我们为预测发射过程中是否会发生一个或多个 O 形圈故障的二元分类任务构建了一个逻辑回归模型。一个不会改变 O 形圈故障概率的因素将保持赔率为 1:1(50-50 的概率),这转化为对数赔率 log(0.5 / (1 - 0.5)) = 0 以及对于这个特征的估计回归系数
= 0。找到赔率比 exp(0) = 1 表明,无论这个因素的价值如何,赔率保持不变。现在,假设像温度这样的因素降低了结果发生的可能性,并且在这个以 x[1] 作为温度的逻辑回归模型中,那么估计的
= -0.232。通过指数化这个值,我们找到赔率比 exp(-0.232) = 0.793,这意味着在保持其他条件不变的情况下,温度每上升一度,故障的赔率降低约 20%。需要注意的是,这并不意味着每次温度上升一度,故障的概率就会降低 20%。
因为赔率和概率之间的关系是非线性的,温度变化对失效概率的影响取决于温度变化发生的具体环境!
概率和几率通过 logit 和逻辑函数之间的逆关系相关联。逻辑函数有一个方便的性质,即对于任何输入x值,输出都在 0 到 1 的范围内——正好与概率的范围相同。此外,逻辑函数在绘制时创建一个 S 形曲线,如图图 6.6所示,该图显示了 O 形圈故障概率与发射温度的假设逻辑回归模型。在温度范围的中间,失败概率在y轴上变化最为强烈;在温度极端情况下,每增加或减少一度温度,预测的失败概率变化很小。

图 6.6:表示航天飞机发射数据的假设逻辑回归曲线
拟合的逻辑回归模型在 0 到 1 的范围内创建了一条曲线,表示对连续尺度上的概率估计,尽管目标结果(如图中圆圈所示)只取值为y = 0 或y = 1。为了获得二元预测,只需定义一个概率阈值,目标结果将据此进行预测。例如,如果预测的 O 形圈故障概率大于 0.50,则预测“故障”,否则预测“无故障”。使用 50%的阈值是常见的,但可以使用更高的或更低的阈值来调整模型对成本的敏感度。
检视图 6.6中的逻辑曲线会引出另一个问题:建模算法是如何确定最适合数据的曲线的呢?毕竟,鉴于这并非一条直线,标准线性回归中使用的 OLS 算法似乎不再适用。
事实上,广义线性模型使用一种称为最大似然估计(MLE)的不同技术,该技术找到指定分布中最有可能生成观察数据的参数值。
由于 OLS 估计是最大似然估计的特殊情况,只要满足 OLS 建模的假设,使用 OLS 或 MLE 对线性模型进行建模就没有区别。对于线性建模之外的应用,MLE 技术会产生不同的结果,并且必须使用 MLE 而不是 OLS。MLE 技术内置在 GLM 建模软件中,通常通过在数据上反复迭代以识别最优模型参数,而不是直接找到正确解。幸运的是,正如我们很快将看到的,在 R 中构建 GLM 几乎不比训练一个更简单的线性模型更具挑战性。
本介绍仅触及了线性回归和 GLM 可能性的表面。尽管理论和像挑战者数据集这样的简单例子有助于理解回归模型的工作原理,但构建一个有用的模型所涉及的不仅仅是我们所看到的。R 内置的回归函数包括拟合更复杂模型所需的功能,同时提供额外的诊断输出,以帮助模型解释和评估拟合度。让我们应用这些函数,通过尝试一个现实世界的学习任务来扩展我们对回归的了解。
示例 – 使用线性回归预测汽车保险索赔成本
对于一家汽车保险公司来说,为了盈利,它需要收取的会员费比支付给其受益人的车辆盗窃、损坏或事故中生命损失索赔要多。因此,保险公司投入时间和金钱来开发模型,以准确预测受保人群的索赔成本。这是被称为精算科学的领域,它使用复杂的统计技术来估计受保人群的风险。
由于事故,尤其是致命事故相对罕见——在美国,每行驶 1 亿英里车辆中略超过 1 起死亡事故——因此,个人保险费用难以准确预测。然而,当事故发生时,它们代价极高。此外,导致任何特定事故的具体条件基于难以衡量的因素,它们看起来似乎是随机的。一个有良好驾驶记录的优秀驾驶员可能会遭遇不幸,被醉酒驾驶员撞到,而另一个人可能会因分心驾驶手机,由于好运,从未造成事故。
由于预测个人费用的几乎不可能性,保险公司应用平均法则,计算具有相似风险特征的人群保险段的平均成本。如果每个风险段的费用估计是正确的,保险公司可以为风险较低的段定价较低的保险费,并可能从竞争的保险公司吸引新的低风险客户。在接下来的分析中,我们将模拟这种场景。
第 1 步 – 收集数据
本例的数据集是基于美国政府的人口统计和交通统计数据创建的模拟数据。它的目的是近似美国密歇根州汽车保险公司的现实世界条件,该州约有 1000 万居民和 700 万持牌驾驶员。
如果你想交互式地跟随,请从本书的 Packt Publishing GitHub 仓库下载autoinsurance.csv文件,并将其保存到你的 R 工作文件夹中。
保险数据集包括 20,000 个受益者参加的假设汽车车辆保险计划的示例。这比实际精算师使用的典型数据集要小得多,特别是对于非常罕见的结果,但规模已经缩小,以便即使在内存有限的计算机上也能进行分析。每个示例代表一个被保险个人的特征和计划在日历年度中收取的总保险索赔成本(费用)。在登记时可用的是以下特征:
-
age: 驾驶者的年龄,从 16 岁到 89 岁 -
geo_area: 车主主要居住地的地理区域,以及车辆最常使用的地方;邮编被归入城市、郊区和农村类别 -
est_value: 根据年龄和折旧估计的车辆(们)的市场价值;上限为 125,000 美元——允许的最大保险价值 -
vehicle_type: 乘客车辆的类型,可以是汽车、卡车、微型货车或运动型多功能车(SUV) -
miles_driven: 在日历年度内驾驶的距离(英里) -
college_grad_ind: 如果受益者拥有大学学历或更高,则该二进制指示器设置为1 -
speeding_ticket_ind: 如果在过去五年内收到过超速罚单或违规,则该二进制指示器设置为1 -
clean_driving_ind: 如果在过去五年内没有支付过责任保险索赔,则该二进制指示器设置为1
在这个示例场景中,有 20,000 名受益者参加了“安全驾驶折扣”计划,该计划要求使用设备或手机应用程序进行位置跟踪,以监控全年安全驾驶条件。这有助于验证miles_driven的准确性,并创建了以下两个额外的预测因子,旨在反映更危险的驾驶行为:
-
hard_braking_ind: 如果车辆经常应用“硬刹车”(例如突然停车的情况),则该二进制指示器设置为1 -
late_driving_ind: 如果车辆在午夜后经常驾驶,则该二进制指示器设置为1
考虑这些变量如何与保险费用相关是很重要的——有些方式比其他方式更明显。例如,我们显然预期经常驾驶的汽车比那些停在车库里的汽车更容易发生事故。另一方面,城市、农村或郊区的驾驶员哪个风险更高并不那么明显;农村驾驶员可能驾驶的距离更远,但城市驾驶涉及更多的交通,可能更容易发生车辆盗窃。回归模型将帮助我们解开这些关系,但需要我们自己指定特征之间的联系,而不是自动检测,这与许多其他机器学习方法不同。我们将在下一节中探讨一些潜在的关系。
考虑哪些可能的有用预测因子没有被包含在训练数据集中也许也很有趣。性别常被用于汽车保险定价(并且男女成本不同!)但密歇根州在 2020 年禁止了为此目的使用性别和信用评分。这些特征可能具有高度预测性,但可能导致对受保护群体的系统性偏见,如第一章介绍机器学习中所述。
第 2 步 – 探索和准备数据
如我们之前所做的那样,我们将使用read.csv()函数来加载数据进行分析。我们可以安全地使用stringsAsFactors = TRUE,因为将三个名义变量转换为因子是合适的:
> insurance <- read.csv("insurance.csv", stringsAsFactors = TRUE)
str()函数确认数据格式正如我们所期望的:
> str(insurance)
'data.frame': 20000 obs. of 11 variables:
$ age : int 19 30 39 64 33 27 62 39 67 38 ...
$ geo_area : Factor w/ 3 levels "rural","suburban", ...
$ vehicle_type : Factor w/ 4 levels "car","minivan", ...
$ est_value : int 28811 52603 113870 35228 ...
$ miles_driven : int 11700 12811 9784 17400 ...
$ college_grad_ind : int 0 1 1 0 0 1 1 0 1 1 ...
$ speeding_ticket_ind: int 1 0 0 0 0 0 0 0 0 0 ...
$ hard_braking_ind : int 1 0 0 0 0 0 0 0 0 0 ...
$ late_driving_ind : int 0 0 0 0 0 0 0 0 0 0 ...
$ clean_driving_ind : int 0 1 0 1 1 0 1 1 0 1 ...
$ expenses : num 0 6311 49684 0 0 ...
我们的模型因变量是expenses,它衡量每个人在保险计划下一年内所声称的损失或损害。在构建线性回归模型之前,检查正态性通常很有帮助。尽管没有正态分布的因变量,线性回归也不会失败,但模型在这一点上通常拟合得更好。让我们看看总结统计:
> summary(insurance$expenses)
Min. 1st Qu. Median Mean 3rd Qu. Max.
0 0 0 1709 0 232797
最小值、第一四分位数、中位数和第三四分位数都是零,这意味着至少 75%的受益人在日历年度内没有费用。平均值大于中位数的事实让我们感觉到保险费用的分布是右偏的,但偏斜可能非常极端,因为平均费用是 1,709 美元,而最大值是 232,797 美元。我们可以使用直方图来直观地确认这一点:
> hist(insurance$expenses)

图 6.7:年度保险索赔成本的分布
如预期的那样,该图显示了一个右偏分布,在零处有一个巨大的峰值,反映了只有一小部分(大约 8.6%)提出了保险索赔。在那些确实提出了车辆损失或损害索赔的人中,分布的尾部延伸到右侧,超过了 200,000 美元的昂贵伤害费用。尽管这种分布不适合线性回归,但提前知道这种弱点可能有助于我们稍后设计一个更好的拟合模型。现在,仅使用expenses的分布,我们可以这样说,平均受益人应该被收取每年 1,709 美元的保险费,这样保险公司才能收支平衡,或者每月每名订阅者约 150 美元的轻微利润。当然,这假设风险和成本是平均分担的。一个改进的保险模型会将更大的成本转嫁给风险较高的驾驶员,并为安全驾驶员提供经济上的节省。
在添加额外的预测变量之前,重要的是要注意回归模型要求每个特征都是数值型的,而我们的数据框中有两个因素类型的特征。例如,geo_area变量被分为urban、suburban和rural等级,而vehicle_type有car、truck、suv和minivan等类别。
让我们更仔细地看看它们的分布情况:
> table(insurance$geo_area)
rural suburban urban
3622 8727 7651
> table(insurance$vehicle_type)
car minivan suv truck
5801 726 9838 3635
在这里,我们看到数据几乎均匀地分布在城市和郊区之间,但农村数据占比较小。此外,SUV 是最受欢迎的车型,其次是汽车和卡车,微型货车位于遥远的第四位。我们将很快看到 R 的线性回归函数如何处理这些因素变量。
探索特征之间的关系——相关矩阵
在将回归模型拟合到数据之前,确定自变量如何与因变量以及彼此相关可能很有用。相关矩阵提供了这些关系的快速概述。给定一组变量,它为每对关系提供相关性。
要为保险数据框中的四个数值型、非二进制变量创建相关矩阵,请使用cor()命令:
> cor(insurance[c("age", "est_value", "miles_driven", "expenses")])
age est_value miles_driven expenses
age 1.000000000 -0.05990552 0.04812638 -0.009121269
est_value -0.059905524 1.00000000 -0.01804807 0.088100468
miles_driven 0.048126376 -0.01804807 1.00000000 0.062146507
expenses -0.009121269 0.08810047 0.06214651 1.000000000
在每一行和每一列的交叉处,列出该行和列所指示变量的相关性。对角线始终是1.0000000,因为变量与其自身之间总是存在完美的相关性。对角线上方和下方的值是相同的,因为相关性是对称的。换句话说,cor(x, y)等于cor(y, x)。
矩阵中的相关性都不强,但关联与常识相符。例如,age和expenses似乎存在弱负相关性,这意味着随着年龄的增长,预期的保险费用会略有下降——这可能是由于更丰富的驾驶经验。还有est_value和expenses以及miles_driven和expenses之间的正相关性,这表明更昂贵的汽车和更广泛的驾驶会导致更高的费用。当我们构建最终的回归模型时,我们将尝试更清晰地揭示这些类型的关系。
可视化特征之间的关系——散点图矩阵
使用散点图可视化数值特征之间的关系也可能很有帮助。尽管我们可以为每种可能的关系创建一个散点图,但对于大量特征来说,这样做很快就会变得繁琐。
另一种选择是创建一个散点图矩阵(有时简称为SPLOM),它只是按网格排列的散点图的集合。它用于检测三个或更多变量之间的模式。散点图矩阵不是真正的多维可视化,因为一次只检查两个特征。尽管如此,它提供了数据可能相互关联的一般感觉。
我们可以使用 R 的图形功能来创建四个非二进制数值特征(age、est_value、miles_driven和expenses)的散点图矩阵。pairs()函数是 R 默认安装的一部分,提供了生成散点图矩阵的基本功能。要调用此函数,只需提供要绘制的数据框的子集。鉴于我们的insurance数据集相对较大,我们将设置绘图字符参数pch = "."为点,以便使可视化更容易阅读,然后限制列为我们感兴趣的四个变量:
> pairs(insurance[c("age", "est_value", "miles_driven",
"expenses")], pch = ".")
这会产生以下散点图矩阵:

图 6.8:保险数据集中数值特征的散点图矩阵
在散点图矩阵中,每一行和每一列的交叉处持有由行和列对指示的变量的散点图。对角线以上和以下的图是转置的,因为x轴和y轴已经交换了。您在这些图中注意到任何模式吗?尽管它们大多看起来像随机的点云,但其中一些似乎显示出一些微妙趋势。est_value和miles_driven与expenses的关系似乎显示出轻微的上升趋势,这从相关矩阵中我们已经学到的内容得到了视觉上的证实。
通过向图中添加更多信息,可以使它变得更加有用。可以使用psych包中的pairs.panels()函数创建增强的散点图矩阵。如果您尚未安装此包,请在系统上输入install.packages("psych")进行安装,并使用library(psych)命令加载它。然后,我们可以使用pch参数设置绘图字符,就像我们之前做的那样来创建散点图矩阵:
> library(psych)
> pairs.panels(insurance[c("age", "est_value", "miles_driven",
"expenses")], pch = ".")
这会产生一个更信息丰富的散点图矩阵,如下所示:

图 6.9:pairs.panels()函数为散点图矩阵添加了细节
在pairs.panels()输出中,对角线以上的散点图被相关矩阵所取代。对角线现在包含描述每个特征值分布的直方图。最后,对角线以下的散点图展示了额外的视觉信息。
每个散点图上的椭圆形物体(由于大量黑色点可能难以在打印中看到,但在电脑屏幕上更容易看到)是一个相关椭圆。它提供了一个简单的视觉指示,表示相关强度。在这个数据集中,没有强烈的关联,所以椭圆大多是平的;有更强的关联时,椭圆会向上或向下倾斜,以表示正相关或负相关。椭圆中心的小点是一个反映x轴和y轴变量平均值的点。
投影在散点图上的线(在计算机屏幕上显示为红色)称为局部加权回归曲线。它表示x轴和y轴变量之间的一般关系。最好通过例子来理解。尽管图表的小尺寸使得这种趋势难以看到,但age和miles_driven的曲线在达到中年之前略微上升,然后趋于平稳。这意味着驾驶往往随着年龄的增长而增加,直到它随时间大致保持恒定。
虽然在此处未观察到,但局部加权回归曲线有时可以非常显著,具有 V 形或 U 形曲线以及阶梯状模式。识别这些模式可以帮助后来开发更好的拟合回归模型。
第 3 步 – 在数据上训练模型
要使用 R 拟合线性回归模型,可以使用lm()函数。这是stats包的一部分,应该默认包含并加载到您的 R 安装中。lm()的语法如下:

图 6.10:多重回归语法
以下命令拟合了一个线性回归模型,该模型将十个独立变量与总保险费用相关联。R 公式语法使用波浪线字符(~)来描述模型;因变量expenses写在波浪线的左侧,而独立变量则写在右侧,由加号(+)分隔。
没有必要指定回归模型的截距项,因为它默认包含:
> ins_model <- lm(expenses ~ age + geo_area + vehicle_type +
est_value + miles_driven +
college_grad_ind + speeding_ticket_ind +
hard_braking_ind + late_driving_ind +
clean_driving_ind,
data = insurance)
因为点号字符(.)可以用来指定所有特征(不包括公式中已指定的那些),所以以下命令与之前的命令等价:
> ins_model <- lm(expenses ~ ., data = insurance)
在构建模型后,只需输入模型对象的名称即可查看估计的贝塔系数。请注意,options(scipen = 999)命令关闭了科学记数法,以便更容易阅读输出:
> options(scipen = 999)
> ins_model
Call:
lm(formula = expenses ~ ., data = insurance)
Coefficients:
(Intercept) age geo_areasuburban
-1154.91486 -1.88603 191.07895
geo_areaurban vehicle_typeminivan vehicle_typesuv
169.11426 115.27862 -19.69500
vehicle_typetruck est_value miles_driven
21.56836 0.03115 0.11899
college_grad_ind speeding_ticket_ind hard_braking_ind
-25.04030 155.82410 11.84522
late_driving_ind clean_driving_ind
362.48550 -239.04740
理解线性回归模型的回归系数相对简单。截距是当独立变量等于零时expenses的预测值。然而,在许多情况下,截距本身具有很小的解释价值,因为通常不可能所有特征都具有零值。
这就是这种情况,因为没有任何投保人可以有零岁或没有行驶英里,因此截距没有实际世界的解释。因此,在实践中,截距通常被忽略。
贝塔系数表示每个特征增加一个单位时,保险索赔成本的估计增加量,假设其他所有值保持不变。例如,对于每增加一岁,我们预计平均保险索赔成本将降低 1.89 美元,假设其他所有条件保持不变。同样,我们预计每增加一英里行驶将增加 0.12 美元的索赔,每增加一美元的保险价值将增加 0.03 美元,其他条件相同。
您可能会注意到,尽管我们在模型公式中只指定了 10 个特征,但除了截距之外,还报告了 13 个系数。这是因为 lm() 函数自动对模型中包含的每个因子类型变量应用虚拟编码。
如 第三章 所述,懒惰学习 – 使用最近邻进行分类,虚拟编码允许将名义特征通过为特征的每个类别(除了一个参考类别外)创建一个二元变量来处理为数值。每个虚拟变量在观测值属于指定类别时设置为 1,否则设置为 0。例如,geo_area 特征有三个类别:urban、suburban 和 rural。因此,使用了两个虚拟变量,分别命名为 geo_areaurban 和 geo_areasuburban。对于 geo_area = "rural" 的观测值,geo_areaurban 和 geo_areasuburban 都将设置为零。同样,对于四类 vehicle_type 特征,R 创建了三个虚拟变量,分别命名为 vehicle_typeminivan、vehicle_typesuv 和 vehicle_typetruck。这使 vehicle_type = "car" 在三个虚拟变量都为零时作为参考类别。
当在回归模型中使用虚拟编码特征时,回归系数的解释是相对于省略的类别。在我们的模型中,R 自动保留了 geo_arearural 和 vehicle_typecar 变量,使农村汽车所有者成为参考组。因此,与农村地区相比,城市居民每年的索赔成本高出 $169.11,而卡车每年比汽车使保险公司多花费 $21.57。为了清楚起见,这些差异假设所有其他特征都保持相等,因此它们独立于农村驾驶员可能行驶更多里程或拥有更便宜车辆的事实。我们预计两个在其他方面完全相同的人,除了一个住在农村地区,一个住在城市地区,平均差异约为 $170。
默认情况下,R 使用因子变量的第一级作为参考。如果您希望使用其他级别,可以使用 relevel() 函数手动指定参考组。在 R 中使用 ?relevel 命令获取更多信息。
通常,线性回归模型的结果具有逻辑意义;然而,我们目前还没有关于模型如何拟合数据的良好感觉。我们将在下一节回答这个问题。
第 4 步 – 评估模型性能
通过输入 ins_model 获得的参数估计告诉我们独立变量与因变量之间的关系,但它们没有告诉我们模型如何拟合我们的数据。为了评估模型性能,我们可以使用存储的模型上的 summary() 命令:
> summary(ins_model)
这产生了以下输出,其中已添加注释以供说明:

图 6.11:回归模型的总结输出可以分为三个主要部分,如图中所示
summary()输出的内容一开始可能看起来令人不知所措,但基本内容很容易掌握。如前述输出中的编号标签所示,评估我们模型性能或拟合度主要有三种方式:
-
残差部分提供了预测误差的汇总统计,其中一些误差显然相当大。由于残差等于真实值减去预测值,最大误差
231252表明模型至少对一个观测值预测费用低于 230,000 美元以上。另一方面,大多数误差是相对较小的负值,这意味着我们对大多数受保人的费用估计过高。这正是保险公司能够承担昂贵事故费用的原因。 -
对于每个估计的回归系数,p 值(用
Pr(>|t|)表示),提供了在给定估计值的情况下,真实系数为零的概率估计。小的 p 值表明真实系数极不可能为零,这意味着该特征极不可能与因变量没有关系。请注意,一些 p 值有星号(***),这对应于指定估计所达到的显著性水平的脚注。这个水平是一个阈值,在构建模型之前选择,将用于指示“真实”发现,而不是仅由偶然引起的那些;小于显著性水平的 p 值被认为是统计显著的。如果模型中这样的项很少,这可能是一个值得关注的问题,因为这表明所使用的特征对结果不是很有预测性。在这里,我们的模型有几个高度显著的自变量,并且它们似乎以预期的方式与结果相关。 -
多重 R 平方值(也称为确定系数)提供了衡量我们的模型整体解释因变量值好坏的指标。它与相关系数类似,即值越接近 1.0,模型对数据的解释就越完美。由于 R 平方值为 0.01241,我们知道该模型解释了因变量变化的约 1.2%。由于具有更多特征的模型总是解释更多的变化,调整 R 平方值通过惩罚具有大量独立变量的模型来纠正 R 平方。这对于比较具有不同数量解释变量的模型性能是有用的。
根据前面的三个性能指标,我们的模型表现足够好。一些误差的大小有点令人担忧,但考虑到保险费用数据的性质,这并不令人惊讶。
此外,现实世界数据的回归模型具有低 R-squared 值并不罕见。尽管 0.01241 的值特别低,但它反映了我们没有汽车事故的近因预测因子;要真正预测事故,我们需要实时驾驶数据,或者至少需要一些衡量真实驾驶技能的指标。话虽如此,正如我们将在下一节中看到的,我们仍然可以通过以略微不同的方式指定模型来提高模型的表现。
第 5 步 – 提高模型性能
如前所述,回归建模与其他机器学习方法的显著区别在于,回归通常将特征选择和模型指定留给用户。因此,如果我们对某个特征与结果之间的关系有专业知识,我们可以利用这些信息来指导模型指定,并可能提高模型的表现。
模型指定 – 添加非线性关系
在线性回归中,假设自变量和因变量之间的关系是线性的,但这并不一定正确。例如,年龄对保险支出的影响可能不会在所有年龄值上保持恒定;对于最年轻和最年长的群体,治疗可能变得不成比例地昂贵——如果将费用与年龄绘制成曲线,则呈现 U 形曲线。
如果你还记得,典型的回归方程遵循类似以下的形式:

为了考虑非线性关系,我们可以在回归方程中添加一个更高阶的项,将模型视为多项式。实际上,我们将模拟如下关系:

这两个模型之间的区别在于,将估计一个额外的回归参数,目的是捕捉x²项的影响。这允许将年龄的影响作为年龄和年龄平方的函数来衡量。
要将非线性年龄添加到模型中,我们只需创建一个新的变量:
> insurance$age2 <- insurance$age²
然后,当我们生成改进的模型时,我们将使用形式expenses ~ age + age2将age和age2都添加到lm()公式中。这将允许模型分离年龄对医疗费用的线性和非线性影响。
模型指定 – 添加交互效应
到目前为止,我们只考虑了每个特征对结果的单个贡献。如果某些特征对因变量有联合影响怎么办?例如,硬刹车和晚开车的不良习惯可能分别有有害的影响,但可以合理地假设它们的联合影响可能比单独每个的影响更糟。
当两个特征有联合效应时,这被称为交互作用。如果我们怀疑两个变量之间存在交互作用,我们可以通过将它们的交互作用添加到模型中来测试这个假设。交互作用使用 R 公式语法来指定。为了将硬刹车指标(hard_braking_ind)与晚驾指标(late_driving_ind)进行交互,我们将编写一个形式为expenses ~ hard_braking_ind*late_driving_ind的公式。
*运算符是一个简写,指示 R 对expenses ~ hard_braking_ind + late_driving_ind + hard_braking_ind:late_driving_ind进行建模。在展开形式中,冒号运算符(:)表示hard_braking_ind:late_driving_ind是两个变量之间的交互作用。请注意,展开形式自动还包括了单个hard_braking_ind和late_driving_ind变量以及它们的交互作用。
如果你在决定是否包含一个变量时遇到困难,一个常见的做法是先包含它,然后检查 p 值。如果变量在统计上不显著,你就有了一个合理的理由将其排除在模型之外。
将所有内容整合在一起——一个改进的回归模型
基于一些关于保险成本可能与报名者特征相关的主观知识,我们开发了一个我们认为更精确指定的回归公式。为了总结改进之处,我们:
-
为年龄添加了一个非线性项
-
指定硬刹车和晚驾之间的交互作用
我们将使用与之前相同的lm()函数来训练模型,但这次我们将添加交互项以及age2,它将自动包含:
> ins_model2 <- lm(expenses ~ . + hard_braking_ind:late_driving_ind,
data = insurance)
接下来,我们总结一下结果:
> summary(ins_model2)
输出如下:
Call:
lm(formula = expenses ~ . hard_barking_ind:late_driving_ind,
data = insurance)
Residuals:
Min 1Q Median 3Q Max
-6618 -1996 -1491 -1044 231358
Coefficients:
Estimate Std. Error t value Pr(>|z|)
(Intercept) -535.038171 457.146614 -1.170 0.2419
age -33.142400 15.366892 -2.157 0.0310 *
geo_areasuburban 178.825158 143.305863 1.248 0.2121
geo_areaurban 132.463265 158.726709 0.835 0.4040
vehicle_typeminivan 178.825158 143.305863 1.248 0.2121
vehicle_typesuv -8.006108 118.116633 -0.068 0.9460
vehicle_typetruck 26.426396 153.650455 0.172 0.8634
est_value 0.031179 0.002496 12.489 <0.000000002 ***
miles_driven 0.118748 0.014327 8.289 <0.000000002 ***
college_grad_ind 17.248581 117.398583 0.147 0.8832
speeding_ticket_ind 155.061583 140.143658 1.107 0.2658
hard_braking_ind -12.442358 109.794208 -0.113 0.9098
late_driving_ind 183.329848 284.218859 0.645 0.5189
clean driving_ind -232.843170 111.106714 -2.096 0.0361
age2 0.343165 0.165340 2.076 0.0380
hard_braking_ind: 469.079140 461.685886 1.016 0.3096
late_driving_ind
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 6995 on 19984 degrees of freedom
Multiple R-squared: 0.01267, Adjusted R-squared: 0.01193
F-statistic: 17.1 on 15 and 19984 DF,
p-value: <0.00000000000000022
尽管与之前的模型相比,R 平方和调整 R 平方值变化不大,但新特征提供了一些有趣的见解。特别是,age的估计值相对较大且为负(支出较低),而age2的估计值相对较小且为正(支出较高)。然而,由于年龄平方的增长速度快于年龄,对于年龄非常高的群体,支出将会开始上升。整体效果是一个 U 形的支出曲线,预测最年轻和最年长的报名者将有更高的支出。hard_braking_ind和late_driving_ind的交互作用也很有趣,因为它是一个相对较大的正值。尽管交互作用在统计上不显著,但效应的方向暗示了如果你是那种已经危险驾驶的司机,那么在晚上晚些时候驾驶尤其危险。
严格来说,回归建模对数据做出了一些强烈的假设。这些假设对于数值预测并不那么重要,因为模型的价值并不在于它是否真正捕捉到了潜在的过程——我们只关心其预测的准确性。然而,如果你想要从回归模型系数中得出明确的推断,就必须运行诊断测试以确保回归假设没有被违反。关于这个主题的优秀介绍,请参阅 《多元回归:入门》,Allison, P. D.,Pine Forge Press,1998 年。
使用回归模型进行预测
在检查了估计的回归系数和拟合统计量之后,我们还可以使用该模型来预测保险计划未来参保人的支出。为了说明预测过程,让我们首先使用 predict() 函数将模型应用于原始训练数据,如下所示:
> insurance$pred <- predict(ins_model2, insurance)
这将预测结果保存为名为 pred 的新向量,在保险数据框中。然后我们可以计算预测的保险成本与实际成本之间的相关性:
> cor(insurance$pred, insurance$expenses)
[1] 0.1125714
0.11 的相关性表明预测值和实际值之间存在着相对较弱的线性关系,这在某种程度上令人失望,但鉴于交通事故看似随机的性质,这并不太令人惊讶。将这一发现作为散点图来考察也是有用的。以下 R 命令绘制了这种关系,并添加了一条截距为零、斜率为一的识别线。col、lwd 和 lty 参数分别影响线的颜色、宽度和类型:
> plot(insurance$pred, insurance$expenses)
> abline(a = 0, b = 1, col = "red", lwd = 3, lty = 2)

图 6.12:在这个散点图中,落在或接近对角虚线(y = x)上的点表示预测值与实际值非常接近
位于线上方的非对角点表示实际支出大于预期的案例,而位于线下方的案例表示支出小于预期的案例。我们可以看到,少数支出远大于预期的个人被大量支出略小于预期的个人所平衡。
现在,假设你想预测保险计划中潜在新参保人的支出。为此,你必须向 predict() 函数提供一个包含潜在驾驶员数据的数据框。对于许多驾驶员,你可能考虑创建一个 CSV 电子表格文件以在 R 中加载,或者对于较少的驾驶员,你可以在 predict() 函数内部直接创建一个数据框。例如,为了估计一个 30 岁、居住在农村、驾驶价值为 25,000 美元的卡车、每年行驶约 14,000 英里且驾驶记录良好的驾驶员的保险费用:
> predict(ins_model2,
data.frame(age = 30, age2 = 30², geo_area = "rural",
vehicle_type = "truck", est_value = 25000,
miles_driven = 14000, college_grad_ind = 0,
speeding_ticket_ind = 0, hard_braking_ind = 0,
late_driving_ind = 0, clean_driving_ind = 1))
1
1015.059
使用这个值,保险公司需要每年收取约 1015 美元才能为这个人口群体实现盈亏平衡。要比较一个在其他方面都与上述情况相似,但最近有过事故记录的人的费率,可以使用predict()函数以类似的方式:
> predict(ins_model2,
data.frame(age = 30, age2 = 30², geo_area = "rural",
vehicle_type = "truck", est_value = 25000,
miles_driven = 14000, college_grad_ind = 0,
speeding_ticket_ind = 0, hard_braking_ind = 0,
late_driving_ind = 0, clean_driving_ind = 0))
1
1247.903
注意,这两个值之间的差异,1015.059 – 1247.903 = -232.844,与估计的回归模型系数clean_driving_ind相同。平均而言,拥有良好驾驶记录的驾驶员预计每年在计划上的支出将少约 232.84 美元,其他条件相同。
这说明了更普遍的事实,即预测的支出是每个回归系数与其预测数据框中相应值的乘积之和。例如,使用模型对行驶里程的回归系数 0.118748,我们可以预测增加 10,000 英里将导致支出增加10,000 * 0.118748 = 1187.48,如下所示:
> predict(ins_model2,
data.frame(age = 30, age2 = 30², geo_area = "rural",
vehicle_type = "truck", est_value = 25000,
miles_driven = 14000, college_grad_ind = 0,
speeding_ticket_ind = 0, hard_braking_ind = 0,
late_driving_ind = 0, clean_driving_ind = 0))
1
1247.903
> 2435.384 - 1247.903
[1] 1187.481
通过对多个额外的客户风险细分进行类似的步骤,保险公司能够开发出一个定价结构,该结构根据驾驶员的估计风险水平公平地设定成本,同时在整个细分市场保持一致的利润。
导出模型的回归系数允许你构建自己的预测函数。这样做的一个潜在用例是在客户数据库中实现回归模型,以进行实时预测。
进一步——使用逻辑回归预测保险保单持有人流失率
在保险公司内部,机器学习的潜在应用不仅限于对索赔成本的精算估计。营销和客户保留团队很可能对预测流失率,即选择不续保保险计划后离开公司的客户感兴趣。在许多业务中,防止流失率被高度重视,因为流失的客户不仅会减少一个企业的收入流,而且通常还会增加直接竞争对手的收入流。此外,营销团队知道,获取新客户的成本通常远高于保留现有客户的成本。
因此,提前知道哪些客户最有可能流失可以帮助将保留资源用于干预,防止流失发生。
历史上,营销团队使用一个名为最近购买频率、货币价值(RFM)的简单模型来识别高价值客户以及最有可能流失的客户。RFM 分析考虑了每位客户的三个特征:
-
他们最近一次购买是什么时候?一段时间没有购买的客户可能价值较低,也更可能永远不会回来。
-
他们购买频率如何?他们是一年复一年地回来,还是购买行为中存在不规则的间隔?表现出忠诚度的客户可能更有价值,也更可能回头。
-
他们在购买时花费多少钱?他们是否比平均客户花费更多或升级到高级产品?这些客户在财务上更有价值,同时也表现出对品牌的喜爱。
收集历史客户购买数据是为了开发这三个因素中每个因素的度量标准。然后,将这些度量标准转换为每个三个领域的标准尺度(例如,从零到十的尺度),并将它们相加以创建每个客户的最终 RFM 分数。一个最近且频繁购买且平均花费的客户可能的总分为 10 + 10 + 5 = 25,而一个很久以前只购买过一次的客户可能得分为 2 + 1 + 4 = 7,这在 RFM 尺度上要低得多。
这种分析是一种粗略但有用的工具,用于理解一组客户并帮助识别可能对预测流失有用的数据类型。然而,RFM 分析并不特别科学,也不提供正式的流失概率估计或增加其可能性的因素。相比之下,预测二元流失结果的逻辑回归模型为每个客户提供流失的估计概率,以及每个预测变量的影响。
就像保险索赔成本示例一样,我们将使用为本书创建的模拟数据集构建一个流失模型,该数据集旨在模拟汽车保险公司的客户行为。
如果您想交互式地跟随学习,请从本书的 Packt Publishing GitHub 仓库下载 insurance_churn.csv 文件,并将其保存到您的 R 工作文件夹中。
流失数据集包括 5,000 个当前和以前的受益人,他们参加了假设的汽车保险计划。每个示例包括衡量计划年度客户行为的特征,以及一个二进制指标(churn),表示他们是否在年底未续约而退出计划。可用的特征包括:
-
member_id:一个随机分配的客户识别号。 -
loyalty_years:连续参加保险计划的年数。 -
vehicles_covered:保险计划覆盖的车辆数量。 -
premium_plan_ind:一个二进制指标,表示成员支付了包含额外福利的昂贵计划的高级版本。 -
mobile_app_user:一个二进制指标,表示成员使用手机伴侣应用程序。 -
home_auto_bundle:一个二进制指标,表示成员还持有由同一家公司提供的房主保险计划。 -
auto_pay_ind:一个二进制指标,表示成员已开启自动支付。 -
recent_rate_increase:一个二进制指标,表示成员的价格最近有所提高。
注意,许多这些因素与 RFM 的三个组成部分相关,因为它们是忠诚度和货币价值的衡量标准。因此,即使后来构建了一个更复杂的模型,仍然可以在处理过程的早期步骤中进行 RFM 分析,这仍然是有帮助的。
要将此数据集读入 R,请输入:
> churn_data <- read.csv("insurance_churn.csv")
使用 table() 和 prop.table() 函数,我们可以看到整体流失率仅为 15%以上:
> prop.table(table(churn_data$churn))
0 1
0.8492 0.1508
在更正式的分析中,在进行进一步分析之前进行更多数据探索是明智的。在这里,我们将跳过创建逻辑回归模型来预测这些流失客户。
glm() 函数是 R 内置的 stats 包的一部分,用于拟合 GLM 模型,如逻辑回归以及本章前面描述的其他变体,如泊松回归。逻辑回归的语法如下所示:

图 6.13:逻辑回归语法
注意 glm() 函数语法与之前用于标准线性回归的 lm() 函数之间的许多相似之处。除了指定家族和链接函数外,拟合模型并不更困难。主要差异主要在于如何解释生成的模型。
注意,R 的 glm() 函数默认使用高斯分布和恒等链接,因此在需要其他 GLM 形式时,很容易意外地执行标准线性回归!因此,在 R 中构建 GLM 时,始终指定家族和链接是一个明智的习惯。
要拟合逻辑回归流失模型,我们指定 binomial 家族和 logit 链接函数。在这里,我们将流失建模为数据集中除 member_id 之外所有其他特征的函数,member_id 对每个成员来说是唯一的,因此对预测无用:
> churn_model <- glm(churn ~ . -member_id, data = churn_data,
family = binomial(link = "logit"))
使用 summary() 对生成的 churn_model 对象进行操作,可以显示估计的回归参数:
> summary(churn_model)
Call:
glm(formula = churn ~ . - member_id,
family = binomial(link = "logit"), data = ins_churn)
Deviance Residuals:
Min 1Q Median 3Q Max
-1.1244 -0.6152 -0.5033 -0.3950 2.4995
Coefficients:
Estimate Std. Error z value Pr(>|z|)
(Intercept) -0.488893 0.141666 -3.451 0.000558 ***
loyalty_years -0.072284 0.007193 -10.050 < 2e-16 ***
vehicles_covered -0.212980 0.055237 -3.856 0.000115 ***
premium_plan_ind -0.370574 0.148937 -2.488 0.012842 *
mobile_app_user -0.292273 0.080651 -3.624 0.000290 ***
home_auto_bundle -0.267032 0.093932 -2.843 0.004472 **
auto_pay_ind -0.075698 0.106130 -0.713 0.475687
recent_rate_increase 0.648100 0.102596 6.317 2.67e-10 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
(Dispersion parameter for binomial family taken to be 1)
Null deviance: 4240.9 on 4999 degrees of freedom
Residual deviance: 4059.2 on 4992 degrees of freedom
AIC: 4075.2
Number of Fisher Scoring iterations: 5
从高层次来看,逻辑回归的输出与线性回归的输出相当相似。p 值(标记为 Pr(>|z|)) 和显著性代码(由 * 字符表示)表明变量是否具有统计学意义。除了 auto_pay_ind 之外的所有特征在 0.05 水平或更好上都是显著的。通过查看 Estimate 值之前的关系(正或负)也可以简单地理解预测变量与目标结果之间的关系。几乎所有估计值都是负的,这意味着这些特征会减少流失,除了 recent_rate_increase 是正的,因此会增加流失。这些联系是有意义的;预计保险计划的价格上涨会增加流失,而忠诚度多年或支付高级计划特征的成员不太可能离开。
解释特定特征对流失的影响比线性回归更困难,因为估计值以对数优势形式显示。假设我们想知道在保险计划价格最近增加后,流失的可能性增加了多少。由于recent_rate_increase的估计值为 0.6481,这意味着当增加指标为1时,流失的对数优势增加 0.6481,而当它为0时。对此进行指数化以消除对数并找到优势比,我们发现exp(0.6481) = 1.911905,这意味着在增加费率后,流失的可能性几乎是两倍(或增加了 91.2%)。
在相反的方向上,使用移动应用(mobile_app_user)的成员与未使用应用的成员相比,估计的对数优势差异为-0.292273。将优势比计算为exp(-0.292273) = 0.7465647表明,应用用户的流失率大约是未使用应用用户的 75%,或者应用用户减少了大约 25%。同样,我们可以发现,对于忠诚度每增加一年,流失率会减少大约 7%,如exp(-0.072284) = 0.9302667。对于模型中的所有其他预测因子,包括截距(代表所有预测因子为零时的流失优势),都可以进行类似的计算。
要使用此模型来防止流失,我们可以在当前计划成员的数据库上进行预测。让我们从这个章节可用的test数据集开始,加载包含 1000 个订阅者的数据集:
> churn_test <- read.csv("insurance_churn_test.csv")
然后,我们将使用带有predict()函数的逻辑回归模型对象来向这个数据框添加一个新列,其中包含每个成员的预测值:
> churn_test$churn_prob <- predict(churn_model, churn_test,
type = "response")
注意,type = "response"参数被设置为使预测以概率形式呈现,而不是默认的type = "link"设置,后者以对数优势值的形式产生预测。
总结这些预测概率,我们看到平均流失概率大约为 15%,但一些用户预测的流失率非常低,而其他用户的流失概率高达 41%:
> summary(churn_test$churn_prob)
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.02922 0.09349 0.13489 0.14767 0.18452 0.41604
假设客户保留团队有资源在有限数量的案例中干预。通过排序成员以识别那些预测流失可能性最高的成员,我们可以为团队提供最有可能产生最大影响的指导方向。
首先,使用order()函数获取一个按流失概率降序排列的行号向量:
> churn_order <- order(churn_test$churn_prob, decreasing = TRUE)
接下来,在根据churn_order向量对churn_test数据框进行排序并取两个感兴趣的列之后,使用head()函数取前n行;在这种情况下,我们将n = 5设置为限制流失可能性最高的五个成员:
> head(churn_test[churn_order,
c("member_id", "churn_prob")], n = 5)
member_id churn_prob
406 29603520 0.4160438
742 12588881 0.4160438
390 23228258 0.3985958
541 86406649 0.3985958
614 49806111 0.3985958
在将结果保存到具有n设置为更高数值的电子表格后,就可以为保留团队提供一份最有可能流失的保险计划成员名单。将保留努力集中在这些成员上,可能比随机针对成员更有利于营销预算的利用,因为大多数成员的流失概率非常低。通过这种方式,机器学习可以在最小投资的情况下提供实质性的回报,其影响可以通过比较干预前后的流失率来轻松量化。
可以通过关于将响应保留努力的客户比例的简单假设来获得防止客户流失所保留的收益的估计。例如,如果我们假设流失模型针对的N名成员将被保留,这将导致N倍的\(X*保留收入,其中*\)X是平均客户消费。将这个数字带给利益相关者有助于为实施机器学习项目提供正当理由。
这个例子只是冰山一角,因为通过额外的努力,客户流失建模可以变得更加复杂。例如,我们不仅可以将目标对准流失概率最高的客户,还可以考虑如果客户流失将失去的收入;即使比低价值客户流失概率低,优先考虑高价值客户也可能是值得的。此外,由于一些客户无论是否干预都肯定会流失,而另一些客户可能更愿意留下,因此除了建模流失概率外,还可以建模保留的可能性。在任何情况下,即使以简单形式,客户流失建模对大多数企业来说都是低垂的果实,并且是实施机器学习的绝佳起点。
理解回归树和模型树
如果你还记得第五章,分而治之 – 使用决策树和规则进行分类,决策树构建了一个模型,就像流程图一样,其中决策节点、叶节点和分支定义了一系列决策,这些决策用于分类示例。这样的树也可以通过仅对树生长算法进行微小调整来用于数值预测。在本节中,我们将考虑用于数值预测的树与用于分类的树的不同之处。
用于数值预测的树分为两类。第一类,称为回归树,在 20 世纪 80 年代作为分类与回归树(CART)算法的一部分被引入。尽管名称如此,回归树并不使用本章前面描述的线性回归方法;相反,它们根据达到叶节点的示例的平均值进行预测。
CART 算法在《分类与回归树》,Breiman, L.,Friedman, J. H.,Stone, C. J.,Olshen, R. A.,Chapman and Hall,1984中有详细描述。
用于数值预测的第二种树被称为模型树。比回归树晚几年引入,它们不太为人所知,但可能更强大。模型树的生长方式与回归树非常相似,但在每个叶节点,都会从到达该节点的示例中构建一个多元线性回归模型。根据叶节点的数量,模型树可能会构建数十甚至数百个这样的模型。
这使得模型树比等效的回归树更难以理解,但它们可能产生更精确的模型。
最早的模型树算法M5在使用连续类别的学习,Quinlan, J. R.,第五次澳大利亚联合人工智能会议论文集,1992 年,第 343-348 页中进行了描述。
将回归添加到树中
能够执行数值预测的树提供了一个引人注目但往往被忽视的回归建模替代方案。以下表格列出了相对于更常见的回归方法,回归树和模型树的优缺点:
| 优点 | 缺点 |
|---|
|
-
结合了决策树的优势以及建模数值数据的能力
-
不需要用户事先指定模型
-
使用自动特征选择,这使得方法可以与大量特征一起使用
-
可能比线性回归更适合某些类型的数据
-
解释模型不需要了解统计学知识
|
-
不如线性回归知名
-
需要大量的训练数据
-
难以确定单个特征对结果的整体净效应
-
大树可能比回归模型更难以解释
|
尽管传统的回归方法通常是数值预测任务的第一个选择,但在某些情况下,数值决策树提供了独特的优势。例如,决策树可能更适合具有许多特征或特征与结果之间存在许多复杂、非线性关系的任务;这些情况对回归构成了挑战。回归建模也假设数据具有某些属性,但在现实世界的数据中这些属性通常被违反;而对于树来说并非如此。
用于数值预测的树与用于分类的树构建方式非常相似。从根节点开始,数据根据将导致分割后结果同质性最大增加的特征,使用分而治之的策略进行分区。
在分类树中,你可能记得同质性是通过熵来衡量的。对于数值数据,这将是未定义的。相反,对于数值决策树,同质性是通过诸如方差、标准差或平均值的绝对偏差等统计量来衡量的。
一种常见的分割标准被称为标准差减少(SDR)。它由以下公式定义:

在这个公式中,sd(T)函数指的是集合T中值的标准差,而T[1],T[2],...,T[n]是特征分割后得到的值集。|T|项指的是集合T中的观测值数量。本质上,该公式通过比较分割前后的加权标准差来衡量标准差的减少。
例如,考虑以下情况,其中树正在决定是否在二元特征 A 上进行分割或在二元特征 B 上进行分割:

图 6.14:算法考虑在特征 A 和 B 上进行分割,这会创建不同的 T[1]和 T[2]组
使用由提议的分割产生的组,我们可以计算 A 和 B 的 SDR 如下。这里使用的length()函数返回向量的元素数量。请注意,整体组 T 被命名为tee,以避免覆盖 R 的内置T()和t()函数。
> tee <- c(1, 1, 1, 2, 2, 3, 4, 5, 5, 6, 6, 7, 7, 7, 7)
> at1 <- c(1, 1, 1, 2, 2, 3, 4, 5, 5)
> at2 <- c(6, 6, 7, 7, 7, 7)
> bt1 <- c(1, 1, 1, 2, 2, 3, 4)
> bt2 <- c(5, 5, 6, 6, 7, 7, 7, 7)
> sdr_a <- sd(tee) - (length(at1) / length(tee) * sd(at1) +
length(at2) / length(tee) * sd(at2))
> sdr_b <- sd(tee) - (length(bt1) / length(tee) * sd(bt1) +
length(bt2) / length(tee) * sd(bt2))
让我们比较 A 的 SDR 与 B 的 SDR:
> sdr_a
[1] 1.202815
> sdr_b
[1] 1.392751
在特征 A 上的分割 SDR 约为 1.2,而在特征 B 上的分割 SDR 为 1.4。由于 B 的分割降低了更多的标准差,决策树将首先使用 B。这比 A 产生了稍微更均匀的集合。
假设树在这里停止生长,使用这个唯一的一次分割。回归树的工作就完成了。它可以根据示例在特征 B 上的值,将示例放入组T[1]或T[2],从而对新示例进行预测。如果示例最终落在T[1]中,模型将预测mean(bt1) = 2,否则它将预测mean(bt2) = 6.25。
相比之下,模型树会更进一步。使用落在T[1]组的七个训练示例和T[2]组的八个示例,模型树可以构建一个关于特征 A 的线性回归模型。请注意,特征 B 在构建回归模型时没有帮助,因为所有叶节点上的 B 值都相同——它们根据 B 的值被放入T[1]或T[2]。然后模型树可以使用这两个线性模型中的任何一个对新示例进行预测。
为了进一步说明这两种方法之间的差异,让我们通过一个现实世界的例子来分析。
示例 - 使用回归树和模型树估计葡萄酒的质量
葡萄酒酿造是一项具有挑战性和竞争性的业务,具有巨大的盈利潜力。然而,有许多因素会影响酒庄的盈利能力。作为一个农产品,从天气到生长环境等各种各样的变量都会影响品种的质量。装瓶和制造也可能影响口味,无论是好是坏。甚至产品的营销方式,从瓶子设计到价格点,都可能影响顾客对味道的认知。
因此,酿酒业在数据收集和可能协助酿酒决策科学的机器学习方法上投入了大量资金。例如,机器学习已被用于发现不同地区葡萄酒化学成分的关键差异,以及识别导致葡萄酒味道更甜的化学因素。
最近,机器学习已被用于协助评估葡萄酒的质量——这是一个众所周知困难的任务。一篇由著名葡萄酒评论家撰写的评论通常决定了产品最终会放在货架的顶部还是底部,尽管即使是专家评委在盲品测试中对葡萄酒的评分也不一致。
在本案例研究中,我们将使用回归树和模型树创建一个能够模仿专家葡萄酒评分的系统。由于树可以产生易于理解的模型,这可能会让酿酒师识别出有助于获得更高评分的关键因素。也许更重要的是,该系统不受品尝过程中的人类因素影响,如评分者的情绪或味蕾疲劳。因此,计算机辅助的葡萄酒测试可能因此产生更好的产品,以及更客观、一致和公平的评分。
第一步 – 收集数据
为了开发葡萄酒评分模型,我们将使用 P. Cortez、A. Cerdeira、F. Almeida、T. Matos 和 J. Reis 捐赠给 UCI 机器学习仓库(archive.ics.uci.edu/ml)的数据。他们的数据集包括来自葡萄牙的红色和白色 Vinho Verde 葡萄酒的示例——葡萄牙是世界上领先的葡萄酒生产国之一。由于影响高度评分葡萄酒的因素可能在红葡萄酒和白葡萄酒品种之间有所不同,因此,在本分析中,我们将仅检查更受欢迎的白葡萄酒。
为了跟随这个示例,请从本书的 Packt Publishing GitHub 仓库下载whitewines.csv文件,并将其保存到您的 R 工作目录中。如果您想自己探索这些数据,redwines.csv文件也是可用的。
白葡萄酒数据包括 4,898 个酒样 11 种化学特性的信息。对于每一款酒,实验室分析测量了如酸度、糖含量、氯化物、硫、酒精、pH 值和密度等特征。然后,由不少于三位评委在从 0(非常差)到 10(优秀)的质量等级上进行盲品评分。如果评委对评分有分歧,则使用中位数值。
Cortez 的研究评估了三种机器学习方法建模葡萄酒数据的能力:多元回归、人工神经网络和支持向量机。我们在本章前面介绍了多元回归,我们将在第七章,黑盒方法 – 神经网络和支持向量机中学习神经网络和支持向量机。该研究发现,支持向量机模型比线性回归模型提供了显著更好的结果。然而,与回归不同,支持向量机模型难以解释。使用回归树和模型树,我们可能能够在保持模型相对容易理解的同时提高回归结果。
要了解更多关于这里描述的葡萄酒研究的信息,请参阅Cortez, P.,Cerdeira, A.,Almeida, F.,Matos, T.,和 Reis, J.,通过数据挖掘物理化学特性建模葡萄酒偏好,决策支持系统,2009 年,第 47 卷,第 547-553 页。
第 2 步 – 探索和准备数据
如往常一样,我们将使用read.csv()函数将数据加载到 R 中。由于所有特征都是数值型的,我们可以安全地忽略stringsAsFactors参数:
> wine <- read.csv("whitewines.csv")
wine数据包括 11 个特征和品质结果,如下所示:
> str(wine)
'data.frame': 4898 obs. of 12 variables:
$ fixed.acidity : num 6.7 5.7 5.9 5.3 6.4 7 7.9 ...
$ volatile.acidity : num 0.62 0.22 0.19 0.47 0.29 ...
$ citric.acid : num 0.24 0.2 0.26 0.1 0.21 0.41 ...
$ residual.sugar : num 1.1 16 7.4 1.3 9.65 0.9 ...
$ chlorides : num 0.039 0.044 0.034 0.036 0.041 ...
$ free.sulfur.dioxide : num 6 41 33 11 36 22 33 17 34 40 ...
$ total.sulfur.dioxide: num 62 113 123 74 119 95 152 ...
$ density : num 0.993 0.999 0.995 0.991 0.993 ...
$ pH : num 3.41 3.22 3.49 3.48 2.99 3.25 ...
$ sulphates : num 0.32 0.46 0.42 0.54 0.34 0.43 ...
$ alcohol : num 10.4 8.9 10.1 11.2 10.9 ...
$ quality : int 5 6 6 4 6 6 6 6 6 7 ...
与其他类型的机器学习模型相比,树模型的一个优点是它们可以处理许多类型的数据而无需预处理。这意味着我们不需要对特征进行归一化或标准化。
然而,为了了解模型性能的评估,我们需要对结果变量的分布进行一些检查。例如,假设葡萄酒之间的质量变化非常小,或者葡萄酒落入双峰分布:要么非常好,要么非常差。这可能会影响我们设计模型的方式。为了检查这种极端情况,我们可以使用直方图检查葡萄酒质量的分布:
> hist(wine$quality)
这产生了以下图示:

图 6.15:白葡萄酒质量评分的分布
葡萄酒质量值似乎遵循一个大致正常的、钟形的分布,中心值为六。从直观上看,这是有道理的,因为大多数葡萄酒的平均质量;很少有特别差或好的。尽管这里没有显示结果,但检查summary(wine)输出以查找异常值或其他潜在的数据问题也是有用的。尽管树模型对杂乱的数据相当稳健,但始终检查严重问题总是谨慎的。目前,我们假设数据是可靠的。
因此,我们的最后一步是将数据集划分为训练集和测试集。由于wine数据集已经被随机排序,我们可以将其划分为两个连续行集,如下所示:
> wine_train <- wine[1:3750, ]
> wine_test <- wine[3751:4898, ]
为了与 Cortez 使用的条件相匹配,我们分别使用了 75% 和 25% 的数据集用于训练和测试。我们将评估基于树的模型在测试数据上的性能,以查看我们是否能获得与先前研究相似的结果。
第 3 步 – 在数据上训练模型
我们将首先训练一个回归树模型。尽管几乎任何决策树的实现都可以用于回归树建模,但 rpart(递归分区)包提供了最忠实的回归树实现,正如 CART 团队所描述的那样。作为经典的 R 实现 CART,rpart 包也具有良好的文档支持,并提供了用于可视化和评估 rpart 模型的函数。
使用 install.packages("rpart") 命令安装 rpart 包。然后可以使用 library(rpart) 语句将其加载到您的 R 会话中。以下语法将使用默认设置训练一个树,这些设置在大多数情况下都很好,但并不总是如此。如果您需要更精细的设置,请参阅 ?rpart.control 命令的文档以了解控制参数。

图 6.16:回归树语法
使用 R 公式接口,我们可以将 quality 指定为结果变量,并使用点符号允许 wine_train 数据框中的所有其他列用作预测变量。生成的回归树模型对象命名为 m.rpart,以区分我们稍后将要训练的模型树:
> m.rpart <- rpart(quality ~ ., data = wine_train)
要获取关于树的基本信息,只需输入模型对象的名称:
> m.rpart
n= 3750
node), split, n, deviance, yval
* denotes terminal node
1) root 3750 2945.53200 5.870933
2) alcohol< 10.85 2372 1418.86100 5.604975
4) volatile.acidity>=0.2275 1611 821.30730 5.432030
8) volatile.acidity>=0.3025 688 278.97670 5.255814 *
9) volatile.acidity< 0.3025 923 505.04230 5.563380 *
5) volatile.acidity< 0.2275 761 447.36400 5.971091 *
3) alcohol>=10.85 1378 1070.08200 6.328737
6) free.sulfur.dioxide< 10.5 84 95.55952 5.369048 *
7) free.sulfur.dioxide>=10.5 1294 892.13600 6.391036
14) alcohol< 11.76667 629 430.11130 6.173291
28) volatile.acidity>=0.465 11 10.72727 4.545455 *
29) volatile.acidity< 0.465 618 389.71680 6.202265 *
15) alcohol>=11.76667 665 403.99400 6.596992 *
对于树中的每个节点,都会列出到达决策点的示例数量。例如,所有 3,750 个示例都从根节点开始,其中 2,372 个具有 alcohol < 10.85,1,378 个具有 alcohol >= 10.85。因为 alcohol 在树中首先被使用,所以它是葡萄酒质量的最重要预测变量。
由 * 标记的节点是终端或叶节点,这意味着它们会导致一个预测(在此处列为 yval)。例如,节点 5 的 yval 值为 5.971091。当使用此树进行预测时,任何酒精含量小于 10.85 且挥发性酸度小于 0.2275 的葡萄酒样品都会被预测为具有 5.97 的质量值。
使用 summary(m.rpart) 命令可以获得关于树拟合的更详细摘要,包括每个节点的均方误差以及特征重要性的总体度量。
可视化决策树
虽然可以使用前面的输出理解树,但使用可视化通常更容易理解。Stephen Milborrow 的 rpart.plot 包提供了一个易于使用的函数,可以生成高质量的决策树。
关于rpart.plot的更多信息,包括该函数可以生成的决策树图类型的额外示例,请参阅作者的网站www.milbo.org/rpart-plot/。
使用install.packages("rpart.plot")命令安装包后,rpart.plot()函数可以从任何rpart模型对象生成树形图。以下命令绘制了我们之前构建的回归树:
> library(rpart.plot)
> rpart.plot(m.rpart, digits = 3)
生成的树形图如下:

图 6.17:葡萄酒质量回归树模型的可视化
除了控制图中包含的数字位数的digits参数外,许多其他可视化方面都可以进行调整。以下命令显示了其中一些有用的选项:
> rpart.plot(m.rpart, digits = 4, fallen.leaves = TRUE,
type = 3, extra = 101)
fallen.leaves参数强制叶节点对齐在图的底部,而type和extra参数影响决策和节点标签的方式。数字3和101指的是特定的样式格式,可以在命令的文档中找到,或者通过尝试不同的数字进行实验。
这些更改的结果是一个看起来非常不同的树形图:

图 6.18:更改绘图函数参数允许自定义树形可视化
这种类型的可视化有助于传播回归树结果,因为即使没有数学背景也能轻松理解。在两种情况下,叶节点中显示的数字都是到达该节点的示例的预测值。因此,向葡萄酒生产商展示此图可能有助于识别预测高分葡萄酒的关键因素。
第 4 步 – 评估模型性能
要使用回归树模型对测试数据进行预测,我们使用predict()函数。默认情况下,这返回了结果变量的估计数值,我们将将其保存在名为p.rpart的向量中:
> p.rpart <- predict(m.rpart, wine_test)
快速查看预测的摘要统计数据显示了一个潜在问题:预测值落在比真实值更窄的范围内:
> summary(p.rpart)
Min. 1st Qu. Median Mean 3rd Qu. Max.
4.545 5.563 5.971 5.893 6.202 6.597
> summary(wine_test$quality)
Min. 1st Qu. Median Mean 3rd Qu. Max.
3.000 5.000 6.000 5.901 6.000 9.000
这个发现表明,模型没有正确识别极端情况,特别是最好的和最差的葡萄酒。另一方面,在第一和第三四分位数之间,我们可能做得很好。
预测值和实际质量值之间的相关性提供了一个简单的方法来衡量模型的表现。回想一下,cor()函数可以用来测量两个等长向量之间的关系。我们将使用它来比较预测值与真实值之间的对应程度:
> cor(p.rpart, wine_test$quality)
[1] 0.5369525
0.54的相关性当然是可以接受的。然而,相关性仅衡量预测与真实值之间的相关性强度;它不是衡量预测偏离真实值程度的指标。
使用平均绝对误差衡量性能
另一种思考模型性能的方法是考虑其预测值平均偏离真实值的程度。这种测量称为平均绝对误差(MAE)。
MAE 的公式如下,其中n表示预测的数量,而e[i]表示预测i的错误:

如其名所示,此公式计算误差绝对值的平均值。由于误差仅仅是预测值与实际值之间的差异,我们可以创建一个简单的MAE()函数,如下所示:
> MAE <- function(actual, predicted) {
mean(abs(actual - predicted))
}
我们的预测的 MAE 如下:
> MAE(p.rpart, wine_test$quality)
[1] 0.5872652
这意味着,平均而言,我们的模型预测与真实质量评分之间的差异约为0.59。在 0 到 10 的质量尺度上,这似乎表明我们的模型表现相当不错。
另一方面,回想一下,大多数酒既不是非常好也不是非常差;典型的质量评分在 5 到 6 之间。因此,仅预测平均值的分类器根据这个指标也可能表现相当不错。
训练数据中的平均质量评分如下:
> mean(wine_train$quality)
[1] 5.870933
如果我们预测每个酒样值为5.87,我们的 MAE 将只有大约0.67:
> MAE(5.87, wine_test$quality)
[1] 0.6722474
我们的回归树(MAE = 0.59)平均来说比插补的均值(MAE = 0.67)更接近真实质量评分,但差距不大。相比之下,Cortez 报告了神经网络模型的 MAE 为 0.58,支持向量机的 MAE 为0.45。这表明还有改进的空间。
第 5 步 - 提高模型性能
为了提高学习器的性能,让我们应用模型树算法,这是一种更复杂的将树应用于数值预测的应用。回想一下,模型树通过用回归模型替换叶节点来扩展回归树。这通常比只使用单个数值值进行叶节点预测的回归树产生更准确的结果。
在模型树领域,当前最先进的技术是Cubist算法,该算法本身是对 M5 模型树算法的改进——这两者都是由 J. R. Quinlan 在 20 世纪 90 年代初发表的。尽管实现细节超出了本书的范围,但 Cubist 算法涉及构建决策树,根据树的分支创建决策规则,并在每个叶节点构建回归模型。使用额外的启发式方法,如剪枝和提升,以提高预测质量并平滑预测值的范围。
关于 Cubist 和 M5 算法的更多背景信息,请参阅《使用连续类学习》,Quinlan, J. R.,1992 年第五次澳大利亚联合人工智能会议论文集,第 343-348 页。此外,请参阅《结合实例学习和基于模型的学习》,Quinlan, J. R.,1993 年第十次国际机器学习会议论文集,第 236-243 页。
Cubist 算法在 R 中通过Cubist包和相关的cubist()函数可用。此函数的语法在以下表格中显示:

图 6.19:模型树语法
我们将使用与回归树不同的语法来拟合 Cubist 模型树,因为cubist()函数不接受 R 公式语法。相反,我们必须指定用于x独立变量和y因变量的数据帧列。由于要预测的葡萄酒质量位于第 12 列,并使用所有其他列作为预测变量,完整的命令如下:
> library(Cubist)
> m.cubist <- cubist(x = wine_train[-12], y = wine_train$quality)
模型树的基本信息可以通过输入其名称来检查:
> m.cubist
Call:
cubist.default(x = wine_train[-12], y = wine_train$quality)
Number of samples: 3750
Number of predictors: 11
Number of committees: 1
Number of rules: 25
在这个输出中,我们看到算法生成了 25 条规则来模拟葡萄酒质量。要检查这些规则中的几个,我们可以对模型对象应用summary()函数。由于完整的树非常大,这里只包括描述第一个决策规则的输出前几行:
> summary(m.cubist)
Rule 1: [21 cases, mean 5.0, range 4 to 6, est err 0.5]
if
free.sulfur.dioxide > 30
total.sulfur.dioxide > 195
total.sulfur.dioxide <= 235
sulphates > 0.64
alcohol > 9.1
then
outcome = 573.6 + 0.0478 total.sulfur.dioxide
- 573 density - 0.788 alcohol
+ 0.186 residual.sugar - 4.73 volatile.acidity
你会注意到输出中的if部分与我们在早期构建的回归树有些相似。一系列基于二氧化硫、硫酸盐和酒精的葡萄酒特性的决策创建了一个以最终预测为终点的规则。然而,这个模型树输出与早期回归树输出的一个关键区别是,这里的节点不是以数值预测结束,而是一个线性模型。
该规则的线性模型显示在outcome =语句之后的then输出中。这些数字可以与我们在本章早期构建的多元回归模型完全相同地解释。每个值都是相关特征的估计影响,即该特征单位增加对预测葡萄酒质量的净效应。例如,残留糖的系数为 0.186 意味着残留糖增加一个单位时,预计葡萄酒质量评分将增加 0.186。
重要的是要注意,此模型估计的回归效应仅适用于达到此节点的葡萄酒样本;检查 Cubist 输出的全部内容揭示,在这个模型树中总共构建了 25 个线性模型,每个决策规则一个,每个模型对残留糖和 10 个其他特征的参数估计都不同。
为了检验这个模型的表现,我们将查看它在未见过的测试数据上的表现如何。predict()函数为我们提供了一个预测值的向量:
> p.cubist <- predict(m.cubist, wine_test)
模型树似乎在预测值范围上比回归树更广:
> summary(p.cubist)
Min. 1st Qu. Median Mean 3rd Qu. Max.
3.677 5.416 5.906 5.848 6.238 7.393
相关性似乎也显著更高:
> cor(p.cubist, wine_test$quality)
[1] 0.6201015
此外,该模型略微降低了 MAE:
> MAE(wine_test$quality, p.cubist)
[1] 0.5339725
尽管我们没有在回归树模型上取得很大的改进,但我们超越了 Cortez 发布的神经网络模型的性能,并且在使用一个简单得多的学习方法的同时,我们正接近支持向量机模型发布的 MAE 值 0.45。
毫不奇怪,我们证实了预测葡萄酒质量是一个困难的问题;毕竟,品酒本质上是一种主观行为。如果您想进行额外的练习,您可以在阅读第十四章,构建更好的学习者之后,再次尝试这个问题,该章节涵盖了可能带来更好结果的技术。
摘要
在本章中,我们研究了两种建模数值数据的方法。第一种方法,线性回归,涉及将直线拟合到数据上,但一种称为广义线性建模的技术可以使回归适应其他环境。第二种方法使用决策树进行数值预测。后者有两种形式:回归树,它使用叶节点上示例的平均值进行数值预测,以及模型树,它以混合方法在每个叶节点构建回归模型,这种混合方法在某些方面是两者的最佳结合。
我们通过使用回归模型来调查挑战者号航天飞机灾难的原因,开始理解回归模型的有用性。然后,我们使用线性回归模型来计算各种汽车驾驶者的预期保险索赔成本。
由于估计的回归模型很好地记录了特征与目标变量之间的关系,我们能够识别出某些人口统计特征,例如高里程和深夜驾驶者,他们可能需要支付更高的保险费来覆盖他们高于平均的索赔成本。然后,我们将逻辑回归,一种用于二元分类的回归变体,应用于建模保险客户保留的任务。这些例子展示了回归如何灵活地适应许多类型的现实世界问题。
在机器学习的某种不太商业化的应用中,回归树和模型树被用来根据可测量的特征建模葡萄酒的主观质量。在这个过程中,我们了解到回归树提供了一种简单的方法来解释特征与数值结果之间的关系,但更复杂的模型树可能更准确。在这个过程中,我们还学习了新的方法来评估数值模型的表现。
与本章所涵盖的、导致对输入和输出之间关系有清晰理解的机器学习方法截然不同,下一章涵盖了导致几乎无法理解模型的算法。优点是它们是极其强大的技术——属于最强大的股票分类器之一——可以应用于分类和数值预测问题。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人相聚,并与其他 4000 多人一起学习:

第七章:黑盒方法 – 神经网络和支持向量机
已故科幻小说作家亚瑟·C·克拉克写道:“任何足够先进的技术都与魔法无法区分。”本章涵盖了两种机器学习方法,它们乍一看可能像是魔法。虽然它们非常强大,但它们的内部工作原理可能难以理解。
在工程学中,这些被称为黑盒过程,因为将输入转换为输出的机制被一个想象中的盒子所掩盖。例如,封闭源代码软件的黑盒有意隐藏了专有算法,政治立法的黑盒根植于官僚程序,而香肠制作的黑盒则涉及一点有意的(但美味的)无知。在机器学习的情况下,黑盒是由于复杂的数学使得它们能够运作。
虽然它们可能不容易理解,但盲目地应用黑盒模型是危险的。因此,在本章中,我们将窥视盒子内部,调查在拟合此类模型中涉及的统计香肠制作过程。你会发现如何:
-
神经网络模仿生物大脑来模拟数学函数
-
支持向量机使用多维表面来定义特征与结果之间的关系
-
尽管它们很复杂,但它们可以轻松应用于现实世界的问题
希望你能意识到,你不需要在统计学上拥有黑带级别的能力来应对黑盒机器学习方法——无需感到害怕!
理解神经网络
人工神经网络(ANN)通过从我们对生物大脑如何对感官输入的刺激做出反应的理解中得出的模型,模拟了一组输入信号与输出信号之间的关系。就像大脑使用称为神经元的相互连接的细胞网络来提供强大的学习能力一样,ANN 使用人工神经元或节点来解决具有挑战性的学习问题。
人类大脑由大约 850 亿个神经元组成,形成一个能够表示大量知识网络的网络。正如你所预期的那样,这使其他生物的大脑相形见绌。例如,一只猫大约有 10 亿个神经元,一只老鼠大约有 7500 万个神经元,而一只蟑螂只有大约 100 万个神经元。相比之下,许多 ANN 包含的神经元要少得多,通常只有数百或数千个,所以我们不会在近期内创造出人工大脑——即使是一只拥有 10 万个神经元的果蝇也远远超过在标准计算硬件上运行的最新 ANN——而且这些 ANN 仍然被小型动物的大脑所 dwarf,更不用说人类的大脑了——而生物大脑可以装入一个更小的包装中!
尽管可能无法完全模拟蟑螂的大脑,但神经网络仍然可以提供某些行为的足够启发式模型。假设我们开发了一个算法,可以模仿蟑螂被发现时逃跑的方式。如果机器人蟑螂的行为令人信服,那么它的脑部是否像活体生物一样复杂就无关紧要了。同样,如果基于神经网络工具(如 ChatGPT openai.com/blog/ChatGPT/)产生的文本大多数时候可以冒充人类文本,那么神经网络是否是完美的人脑模型就无关紧要了。这个问题是 1950 年由计算机科学先驱艾伦·图灵提出的有争议的图灵测试的核心,该测试将机器视为智能,如果人类无法区分其行为与生物体的行为。
更多关于围绕图灵测试的神秘和争议的信息,请参阅斯坦福哲学百科全书:plato.stanford.edu/entries/turing-test/。
简单的 ANNs 已经超过 60 年用于模拟大脑的解决问题的方法。最初,这涉及到学习简单的函数,如逻辑与函数或逻辑或函数。这些早期的练习主要用于帮助科学家了解生物大脑可能如何运作。然而,随着近年来计算机变得越来越强大,ANNs 的复杂性也相应增加,以至于它们现在经常应用于更实际的问题,包括:
-
语音、手写和图像识别程序,如智能手机应用、邮件分拣机和搜索引擎所使用的程序
-
智能设备的自动化,例如办公大楼的环境控制,或自动驾驶汽车和自主飞行的无人机控制
-
复杂的天气和气候模式、抗拉强度、流体动力学以及许多其他科学、社会或经济现象的模型
从广义上讲,ANNs 是多才多艺的学习者,可以应用于几乎任何学习任务:分类、数值预测,甚至无监督模式识别。
无论是否值得,ANN 学习者经常在媒体上被大肆报道。例如,谷歌开发的一个“人工大脑”因其能够识别 YouTube 上的猫视频而备受赞誉。这种炒作可能更多与 ANNs 的独特性无关,而更多与 ANNs 因其与生物大脑的相似性而具有吸引力的事实有关。
ANNs 通常应用于输入数据和输出数据定义良好,但将输入与输出联系起来的过程极其复杂且难以定义的问题。作为一种黑盒方法,ANNs 对于这些类型的黑盒问题非常有效。
从生物神经元到人工神经元
由于人工神经网络(ANNs)被有意设计成人类大脑活动的概念模型,因此首先了解生物神经元是如何工作的是有帮助的。如图所示,细胞通过生化过程接收传入的信号,这些信号通过细胞树突。这个过程允许根据其相对重要性或频率对冲动进行加权。当细胞体开始积累传入的信号时,会达到一个阈值,此时细胞会放电,输出信号通过电化学过程沿着轴突传递。在轴突的末端,电信号再次被处理成化学信号,通过一个称为突触的微小间隙传递给相邻的神经元。

图 7.1:生物神经元的艺术描绘
单个人工神经元的模型可以用与生物模型非常相似的方式来理解。如图所示,一个有向网络图定义了通过树突接收的输入信号(x变量)和输出信号(y变量)之间的关系。就像生物神经元一样,每个树突的信号都会根据其重要性进行加权(现在忽略这些权重是如何确定的)。输入信号由细胞体相加,然后根据一个表示为f的激活函数传递信号。

图 7.2:人工神经元的设计旨在模仿生物神经元的结构和功能
典型的人工神经元可以用以下公式表示,其中n表示输入树突的数量。w权重允许每个n个输入(用x[i]表示)对输入信号的总和贡献更多或更少的量。净总信号由激活函数f(x)使用,产生的信号y(x)是输出轴突:

神经网络使用这种方式定义的神经元作为构建复杂数据模型的基石。尽管神经网络有无数种变体,但每种都可以用以下特征来定义:
-
激活函数,它将神经元的净输入信号转换成单个输出信号,以便在网络中进一步广播
-
网络拓扑(或架构),它描述了模型中的神经元数量,以及层数和它们之间的连接方式
-
训练算法,它指定了如何根据输入信号的比例来设置连接权重以抑制或兴奋神经元
让我们来看看每个类别中的一些变体,看看它们如何被用来构建典型的神经网络模型。
激活函数
激活函数是人工神经元处理传入信息并确定是否将信号传递给网络中其他神经元的机制。正如人工神经元是模仿生物版本一样,激活函数也是模仿自然的设计。
在生物案例中,激活函数可以想象为一个涉及求和总输入信号并确定它是否达到触发阈值的进程。如果是这样,神经元就会传递信号;否则,它就什么都不做。在 ANN 术语中,这被称为阈值激活函数,因为它只在达到指定的输入阈值时产生输出信号。
以下图描绘了一个典型的阈值函数;在这种情况下,当输入信号的求和至少为零时,神经元就会触发。由于其形状类似于楼梯,有时也被称为单位步激活函数。

图 7.3:阈值激活函数仅在输入信号达到阈值后才“开启”
尽管阈值激活函数由于其与生物学的相似性而有趣,但在 ANN 中很少使用。摆脱生物化学的限制,ANN 激活函数可以根据其展示的期望数学特征和准确模拟数据之间关系的能力来选择。
可能最常用的替代方案是以下图中所示的S 型激活函数(更具体地说,是逻辑 S 型)。请注意,在所示公式中,e是自然对数的底(大约为 2.72)。尽管它与阈值激活函数具有相似的步骤或“S”形状,但输出信号不再是二进制的;输出值可以落在零到一之间的任何地方。此外,S 型函数是可微分的,这意味着可以计算整个输入范围内的导数(曲线上的某一点的切线斜率)。正如你稍后将会学到的那样,这一特性对于创建高效的 ANN 优化算法至关重要。

图 7.4:S 型激活函数使用平滑曲线来模拟自然界中发现的单位步激活函数
尽管 S 型函数可能是最常用的激活函数,并且通常默认使用,但一些神经网络算法允许选择替代方案。以下图 7.5 中展示了这类激活函数的选择:

图 7.5:几种常见的神经网络激活函数
区分这些激活函数的主要细节是输出信号的范围。通常,这可能是以下之一:(0, 1),(-1, +1),或 (-∞, +∞)。激活函数的选择会影响神经网络的偏差,使其可能更适合某些类型的数据,从而允许构建专门的神经网络。例如,线性激活函数会导致神经网络非常类似于线性回归模型,而高斯激活函数是径向基函数(RBF)网络的基础。这些中的每一个都有适合某些学习任务的优点,而不适合其他任务。
神经网络几乎只使用非线性激活函数,因为这是网络随着节点数量的增加而变得更智能的原因。仅限于线性激活函数的网络将限于线性解决方案,并且其表现不会优于许多更简单的回归方法。
重要的是要认识到,对于许多激活函数,影响输出信号的输入值范围相对较窄。例如,在 sigmoid 函数的情况下,当输入信号低于-5 时,输出信号非常接近 0,而当输入信号高于 5 时,输出信号非常接近 1。以这种方式压缩信号会导致动态输入的高低端饱和,就像将吉他放大器音量调得过高,由于声音波峰的截断而导致失真声音一样。因为这种做法本质上是将输入值挤压到一个更小的输出值范围内,所以像 sigmoid 这样的激活函数有时被称为挤压函数。
解决挤压问题的方法之一是将所有神经网络输入转换,使得特征值落在围绕零的小范围内。这可能涉及标准化或归一化特征。通过限制输入值的范围,激活函数将能够在整个范围内工作。一个附带的好处是,模型也可能训练得更快,因为算法可以更快地遍历可操作的输入值范围。
虽然从理论上讲,神经网络可以通过多次迭代调整其权重来适应非常动态的特征,但在极端情况下,许多算法会在这种情况发生之前就停止迭代。如果你的模型未能收敛,请务必检查你是否已正确标准化了输入数据。选择不同的激活函数也可能是合适的。
网络拓扑
神经网络学习的能力根植于其拓扑结构,即相互连接的神经元的模式和结构。尽管有无数种网络架构形式,但它们可以通过三个关键特征来区分:
-
层数的数量
-
网络中信息是否允许向后传播
-
网络每一层中的节点数量
拓扑决定了网络可以学习的任务复杂性。一般来说,更大、更复杂的网络可以识别更微妙的模式和更复杂的决策边界。然而,网络的力量不仅取决于网络的大小,还取决于单元的排列方式。
层数的数量
要定义拓扑,我们需要术语来区分网络中位置不同的人工神经元。图 7.6展示了一个非常简单的网络的拓扑结构。一组称为输入节点的神经元直接从输入数据接收未经处理的信号。每个输入节点负责处理数据集中单个特征;该特征值将通过相应节点的激活函数进行转换。输入节点发送的信号被输出节点接收,该节点使用自己的激活函数生成最终的预测(在此处表示为p)。
输入和输出节点被安排在称为层的组中。因为输入节点以接收到的数据完全相同的方式处理传入的数据,所以网络只有一个连接权重集(在此处标记为w[1],w[2],和w[3])。因此,它被称为单层网络。单层网络可用于基本的模式分类,尤其是对于线性可分模式的分类,但对于大多数学习任务,需要更复杂的网络。

图 7.6:具有三个输入节点的简单单层 ANN
如您所预期的那样,创建更复杂网络的一个明显方法是通过添加额外的层。如图图 7.7所示,一个多层网络添加一个或多个隐藏层,这些隐藏层在信号到达输出节点之前处理来自输入节点的信号。隐藏节点之所以得名,是因为它们在网络中心被遮挡,并且它们与数据和输出的关系更难以理解。隐藏层使得人工神经网络成为一个黑盒模型;了解这些层内部发生的事情实际上是不可能的,尤其是当拓扑变得更加复杂时。

图 7.7:具有单个两个节点隐藏层的多层网络
更复杂拓扑的示例在图 7.8中展示。可以使用多个输出节点来表示具有多个类别的结果。可以使用多个隐藏层来允许黑盒内部有更多的复杂性,从而模拟更复杂的问题。

图 7.8:复杂的 ANN 可以具有多个输出节点或多个隐藏层
具有多个隐藏层的神经网络被称为深度神经网络(DNN),训练此类网络的实践被称为深度学习。在大数据集上训练的 DNN 在复杂任务如图像识别和文本处理上能够达到类似人类的性能。因此,深度学习被炒作成为机器学习中的下一个重大飞跃,但深度学习更适合某些任务而不是其他任务。
尽管深度学习在传统模型难以应对的复杂学习任务上表现相当出色,但它需要比大多数项目中找到的更大的数据集和更丰富的特征集。典型的学习任务包括对图像、音频或文本等非结构化数据进行建模,以及随时间重复测量的结果,如股票市场价格或能源消耗。在这些类型的数据上构建深度神经网络(DNN)需要专门的计算软件(有时还需要硬件),其使用难度比简单的 R 包要大。第十五章“利用大数据”提供了如何使用这些工具在 R 中执行深度学习和图像识别的详细信息。
信息传递的方向
简单的多层网络通常是全连接的,这意味着一个层中的每个节点都与下一层的每个节点相连,但这并非必需。更大的深度学习模型,例如将在第十五章“利用大数据”中介绍的用于图像识别的卷积神经网络(CNN),只是部分连接。移除一些连接有助于限制在众多隐藏层中可能发生的过度拟合的数量。然而,这并非我们操纵拓扑的唯一方式。除了节点是否连接之外,我们还可以指定信息流在整个连接中的方向,并产生适合不同类型学习任务的神经网络。
你可能已经注意到,在前面的例子中,箭头被用来指示只在一个方向上传递的信号。从输入层到输出层连续单向输入输入信号的神经网络被称为前馈网络。尽管对信息流有限制,但前馈网络提供了令人惊讶的灵活性。
例如,可以改变每个级别的级别和节点数量,可以同时建模多个结果,或者应用多个隐藏层。
与前馈网络不同,循环神经网络(RNN)(或反馈网络)允许信号通过循环向后传递。这种特性更接近于生物神经网络的工作方式,使得学习极其复杂的模式成为可能。加入短期记忆,或延迟,极大地增强了循环网络的能力。值得注意的是,这包括理解随时间推移的事件序列的能力。这可以用于股市预测、语音理解或天气预报。一个简单的循环网络如下所示:

图 7.9:允许信息在网络中向后传递可以模拟时间延迟
由于 RNN 的短期记忆在定义上显然是短的,因此一种名为长短期记忆(LSTM)的 RNN 形式调整了模型,使其具有显著更长的回忆能力——就像既有短期又有长期记忆的活物一样。虽然这似乎是一种明显的改进,但计算机具有完美的记忆,因此需要明确告知何时忘记和何时记住。挑战在于在过早忘记和过长记住之间找到平衡,这比说起来要难得多,因为用于训练模型的数学函数自然地被拉向这两个极端,原因将在本章继续阅读时变得更加清晰。
更多关于 LSTM 神经网络的信息,请参阅理解 LSTM——关于长短期记忆循环神经网络教程,Staudemeyer RC 和 Morris ER,2019。arxiv.org/abs/1909.09586。
LSTM 神经网络的发展导致了人工智能的进步,例如使机器人能够模仿控制机械、驾驶和玩电子游戏所需的人类行为序列。LSTM 模型也显示出在语音和文本识别、理解语言语义以及在不同语言之间翻译和学习复杂策略方面的能力。DNN 和循环网络越来越多地被用于各种高调应用,因此自本书第一版出版以来,它们变得更加流行。然而,构建这样的网络需要超出本书范围的技术和软件,并且通常需要访问专门的计算硬件或云服务器。
另一方面,简单的前馈网络也非常擅长模拟许多现实世界的任务,尽管这些任务可能不如自动驾驶汽车和玩电子游戏的计算机那样令人兴奋。虽然深度学习正在迅速成为主流,但多层前馈网络,也称为多层感知器(MLP),可能仍然是传统学习任务的默认标准人工神经网络拓扑。此外,理解 MLP 拓扑为以后构建更复杂的深度学习模型提供了强大的理论基础。
每层的节点数量
除了层数和信息传递方向的变化之外,神经网络还可以通过每层的节点数量来变化其复杂性。输入节点的数量由输入数据中的特征数量预先确定。同样,输出节点的数量由要模拟的结果数量或结果中的类别级别数量预先确定。然而,隐藏节点的数量留给用户在训练模型之前决定。
不幸的是,没有可靠的规则来确定隐藏层中神经元的数量。合适的数量取决于输入节点的数量、训练数据量、噪声数据量以及学习任务的复杂性等因素。
通常,具有更多网络连接的更复杂网络拓扑允许学习更复杂的问题。更多的神经元将导致一个模型更接近训练数据,但这也存在过拟合的风险;它可能对未来数据的泛化能力较差。大型神经网络也可能计算成本高昂且训练缓慢。最佳实践是使用最少节点,这些节点在验证数据集上能够实现足够的性能。在大多数情况下,即使只有少量隐藏节点——通常只有几个——神经网络也能展现出惊人的学习能力。
已被证明,具有至少一个具有足够多神经元和非线性激活函数的隐藏层的神经网络是一个通用函数逼近器。这意味着神经网络可以用来逼近任何连续函数,在有限区间内达到任意精度的近似。这就是神经网络获得在章节引言中描述的那种“魔法”能力的地方;将一组输入放入神经网络的黑盒中,它可以学会产生任何一组输出,无论输入和输出之间的关系有多么复杂。当然,这假设“足够多的神经元”以及足够的数据来合理训练网络——同时避免对噪声过拟合,以便近似可以泛化到训练示例之外。我们将在下一节进一步探讨允许这种魔法发生的黑盒。
要查看网络拓扑变化如何使神经网络成为通用函数逼近器的实时可视化,请访问playground.tensorflow.org/的深度学习游乐场。游乐场允许你实验预测模型,其中特征与目标之间的关系复杂且非线性。虽然回归和决策树等方法在解决这些问题上会遇到困难,但你将发现,添加更多的隐藏节点和层可以使网络在足够的训练时间内为每个示例提供一个合理的近似。请注意,特别是选择线性激活函数而不是 Sigmoid 或双曲正切(tanh)函数,可以防止网络无论网络拓扑的复杂性如何都能学习到一个合理的解决方案。
通过调整连接权重来训练神经网络
网络拓扑是一个白板,它本身并没有学习到任何东西。就像一个新生儿一样,它必须通过经验来训练。随着神经网络处理输入数据,神经元之间的连接会加强或减弱,这类似于婴儿的大脑在经历环境时的发展。网络的连接权重会调整以反映随时间观察到的模式。
通过调整连接权重来训练神经网络是非常计算密集的。因此,尽管它们在几十年前就已经被研究,但直到 20 世纪 80 年代中后期,当发现了一种有效的训练 ANN 的方法之前,ANNs 很少应用于实际的学习任务。这个使用反向传播错误策略的算法现在简单地被称为反向传播。
意外的是,几个研究团队几乎在同一时间独立发现了并发表了反向传播算法。其中,最常被引用的工作可能是Rumelhart, DE, Hinton, GE, Williams, RJ, Nature, 1986, Vol. 323, pp. 533-566,通过反向传播错误学习表示。
尽管与许多其他机器学习算法相比,反向传播方法在计算上仍然有些昂贵,但它导致了人工神经网络(ANNs)兴趣的复苏。因此,使用反向传播算法的多层前馈网络现在在数据挖掘领域很常见。这些模型具有以下优点和缺点:
| 优点 | 缺点 |
|---|
|
-
可以适应分类或数值预测问题
-
一种“通用逼近器”,能够模拟比几乎所有算法更复杂的模式
-
对数据的潜在关系假设很少
|
-
极其计算密集且训练缓慢,尤其是如果网络拓扑复杂
-
极易过拟合训练数据,导致泛化能力差
-
导致了一个复杂的黑盒模型,难以解释,如果不是不可能的话
|
在其最一般的形式中,反向传播算法通过许多两个过程的循环迭代。每个循环被称为一个时代。因为网络不包含先验(现有)知识,所以起始权重通常设置为随机。然后,算法通过这些过程迭代,直到达到停止标准。反向传播算法中的每个时代包括:
-
一个前向阶段,在这个阶段,神经元按顺序从输入层到输出层被激活,沿途应用每个神经元的权重和激活函数。当达到最后一层时,产生一个输出信号。
-
一个反向阶段,在这个阶段,网络的前向阶段产生的输出信号与训练数据中的真实目标值进行比较。网络输出信号与真实值之间的差异导致一个错误,该错误在网络中向后传播以修改神经元之间的连接权重并减少未来的错误。
随着时间的推移,算法使用向后发送的信息来减少网络犯下的总错误。然而,一个问题仍然存在:由于每个神经元的输入和输出之间的关系复杂,算法如何确定权重应该改变多少?
这个问题的答案涉及一种称为梯度下降的技术。从概念上讲,这类似于一个被困在丛林中的探险者如何找到通往水源的道路。通过检查地形并持续向最大下坡方向行走,探险者最终会到达最低的谷地,这很可能是河床。
在一个类似的过程中,反向传播算法使用每个神经元的激活函数的导数来识别每个输入权重的方向梯度——因此具有可微分的激活函数的重要性。梯度表明误差将如何随着权重的变化而减少或增加。算法将尝试通过称为学习率的量来改变权重,以实现误差的最大减少。学习率越大,算法尝试沿着梯度下降的速度就越快,这可能会减少训练时间,但也可能超过谷地。

图 7.10:梯度下降算法寻求最小误差,但也可能找到一个局部最小值
尽管使用梯度下降法找到最小误差率所需的数学知识复杂,因此超出了本书的范围,但在实践中通过其在 R 中神经网络算法的实现应用起来却很容易。让我们将我们对多层前馈网络的理解应用于一个实际问题。
示例 - 使用人工神经网络建模混凝土强度
在工程领域,拥有建筑材料的准确性能估计至关重要。这些估计是制定用于建筑、桥梁和道路建设中所用材料的安全生产指南所必需的。
估计混凝土的强度是一个特别有趣的问题。尽管它在几乎每个建设项目中都被使用,但由于各种成分以复杂的方式相互作用,混凝土的性能差异很大。因此,准确预测最终产品的强度是困难的。一个能够根据输入材料的成分列表可靠地预测混凝土强度的模型,可能导致更安全的施工实践。
第 1 步 – 收集数据
对于这次分析,我们将利用 I-Cheng Yeh 捐赠给 UCI 机器学习仓库(archive.ics.uci.edu/ml)的混凝土抗压强度数据。由于他发现使用神经网络来模拟这些数据是成功的,我们将尝试使用 R 中的简单神经网络模型来复制 Yeh 的工作。
关于 Yeh 对这一学习任务的方法的更多信息,请参阅使用人工神经网络建模高性能混凝土强度,Yeh, IC,水泥与混凝土研究,1998,第 28 卷,第 1797-1808 页。
根据网站信息,该数据集包含 1,030 个混凝土示例,其中包含 8 个特征,描述了混合物中使用的成分。这些特征被认为与最终的抗压强度相关,包括产品中使用的水泥、矿渣、灰、水、超塑化剂、粗骨料和细骨料的量(以每立方米千克计),以及老化时间(以天为单位)。
要跟随这个示例,请从 Packt Publishing 网站下载concrete.csv文件,并将其保存到您的 R 工作目录中。
第 2 步 – 探索和准备数据
如同往常,我们将通过使用read.csv()函数将数据加载到 R 对象中,并确认它符合预期的结构来开始我们的分析:
> concrete <- read.csv("concrete.csv")
> str(concrete)
'data.frame': 1030 obs. of 9 variables:
$ cement : num 141 169 250 266 155 ...
$ slag : num 212 42.2 0 114 183.4 ...
$ ash : num 0 124.3 95.7 0 0 ...
$ water : num 204 158 187 228 193 ...
$ superplastic: num 0 10.8 5.5 0 9.1 0 0 6.4 0 9 ...
$ coarseagg : num 972 1081 957 932 1047 ...
$ fineagg : num 748 796 861 670 697 ...
$ age : int 28 14 28 28 28 90 7 56 28 28 ...
$ strength : num 29.9 23.5 29.2 45.9 18.3 ...
数据框中的九个变量对应于八个特征和一个预期的结果,尽管已经出现了一个问题。神经网络在输入数据缩放到零附近的狭窄范围内表现最佳,而在这里我们看到值从零到超过一千。
通常,解决这个问题的方法是使用归一化或标准化函数对数据进行缩放。如果数据遵循钟形曲线(如第二章中描述的管理和理解数据的正常分布),那么使用 R 内置的scale()函数进行标准化可能是有意义的。另一方面,如果数据遵循均匀分布或严重非正态分布,那么将数据归一化到零到一的范围可能更合适。在这种情况下,我们将使用后者。
在 第三章,懒惰学习 – 使用最近邻进行分类 中,我们定义了自己的 normalize() 函数如下:
> normalize <- function(x) {
return((x - min(x)) / (max(x) - min(x)))
}
执行此代码后,我们可以使用 lapply() 函数将 normalize() 函数应用于混凝土数据框中的每一列,如下所示:
> concrete_norm <- as.data.frame(lapply(concrete, normalize))
为了确认归一化工作正常,我们可以看到现在最小和最大的强度分别为零和一:
> summary(concrete_norm$strength)
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.0000 0.2664 0.4001 0.4172 0.5457 1.0000
与此相比,原始的最小值和最大值分别为 2.33 和 82.60:
> summary(concrete$strength)
Min. 1st Qu. Median Mean 3rd Qu. Max.
2.33 23.71 34.45 35.82 46.13 82.60
在训练模型之前应用于数据的任何转换都必须反向应用,以便将其转换回原始的单位。为了便于缩放,保存原始数据或至少原始数据的摘要统计信息是明智的。
沿用原始出版物中 Yeh 的先例,我们将数据分为包含 75% 示例的训练集和包含 25% 的测试集。我们使用的 CSV 文件已经随机排序,所以我们只需将其分为两部分:
> concrete_train <- concrete_norm[1:773, ]
> concrete_test <- concrete_norm[774:1030, ]
我们将使用训练数据集来构建神经网络,并使用测试数据集来评估模型对未来结果的泛化能力。由于神经网络很容易过拟合,这一步非常重要。
第 3 步 – 在数据上训练模型
为了模拟混凝土中使用的成分与最终产品强度之间的关系,我们将使用多层前馈神经网络。Stefan Fritsch 和 Frauke Guenther 的 neuralnet 包提供了一个标准且易于使用的此类网络实现。它还提供了一个用于绘制网络拓扑的功能。因此,neuralnet 实现是学习更多关于神经网络的一个很好的选择,尽管这并不意味着它不能用于完成实际工作——它是一个非常强大的工具,你很快就会看到。
在 R 中训练简单的 ANN 模型有几个其他常用的包,每个包都有其独特的优点和缺点。由于它作为标准 R 安装的一部分提供,nnet 包可能是引用最多的 ANN 实现。它使用比标准反向传播稍微复杂一点的算法。另一个选择是 RSNNS 包,它提供了一个完整的神经网络功能套件,缺点是它更难学习。构建或使用深度学习神经网络的专用软件在第十五章 利用大数据 中介绍。
由于 neuralnet 不包含在基础 R 中,您需要通过输入 install.packages("neuralnet") 来安装它,并使用 library(neuralnet) 命令加载它。包含的 neuralnet() 函数可以用于使用以下语法训练用于数值预测的神经网络:

图 7.11:神经网络语法
我们将开始使用默认设置训练最简单的多层前馈网络,仅使用单个隐藏节点。由于训练人工神经网络的过程涉及随机化,这里使用的 set.seed() 函数将确保在运行 neuralnet() 函数时产生相同的结果:
> set.seed(12345)
> concrete_model <- neuralnet(strength ~ cement + slag +
ash + water + superplastic + coarseagg + fineagg + age,
data = concrete_train)
然后,我们可以使用结果模型对象的 plot() 函数来可视化网络拓扑:
> plot(concrete_model)

图 7.12:简单多层前馈网络的拓扑可视化
在这个简单模型中,每个特征都有一个输入节点,接着是一个单一的隐藏节点和一个单一的输出节点,该输出节点预测混凝土强度。每个连接的权重也被描绘出来,以及由标记为数字 1 的节点表示的偏差项。偏差项是数值常数,允许指示节点的值向上或向下移动,就像线性方程中的截距一样。在形式为 y = ax + b 的线性方程中,截距 b 允许当 x = 0 时,y 有一个非零的值。同样,神经网络中的偏差项允许当输入为零时,节点传递一个非零的值。这为学习数据中找到的真实模式提供了更多的灵活性,从而有助于模型更好地拟合。在具体到我们的混凝土模型的情况下,尽管在现实世界中不可能所有输入到混凝土中的因素(如水泥、年龄和水)都为零,但我们不一定会期望强度在接近零的这些因素值时,强度会精确地穿过原点。我们可能期望体重与年龄的关系模型具有一个大于零的偏差项,因为出生时的体重(年龄 = 0)是大于零的。
一个具有单个隐藏节点的神经网络可以被视为我们在 第六章 中研究的线性回归模型的远亲,即 预测数值数据 – 回归方法。每个输入节点与隐藏节点之间的权重类似于 beta 系数,而偏差项的权重类似于截距。如果使用线性激活函数,神经网络几乎就是线性回归。然而,一个关键的区别是,人工神经网络使用梯度下降进行训练,而线性回归通常使用最小二乘法。
在图的下部,R 报告了训练步骤的数量和一个称为平方误差和(SSE)的错误度量,正如你可能预期的,它是预测值和实际值之间平方差的和。SSE 越低,模型与训练数据的符合度越高,这告诉我们关于训练数据的表现,但很少告诉我们它在未见数据上的表现。
第 4 步 – 评估模型性能
网络拓扑图让我们窥视了人工神经网络(ANN)的黑箱,但它并没有提供太多关于模型如何适应未来数据的信息。为了在测试数据集上生成预测,我们可以使用compute()函数如下:
> model_results <- compute(concrete_model, concrete_test[1:8])
compute() 函数与我们迄今为止使用的 predict() 函数工作方式略有不同。它返回一个包含两个组件的列表:$neurons,它存储网络中每一层的神经元,以及$net.result,它存储预测值。我们想要后者:
> predicted_strength <- model_results$net.result
由于这是一个数值预测问题而不是分类问题,我们不能使用混淆矩阵来检查模型精度。相反,我们将测量我们预测的混凝土强度与真实值之间的相关性。如果预测值和实际值高度相关,则该模型很可能是混凝土强度的有用衡量标准。
记住,cor() 函数用于获取两个数值向量之间的相关性:
> cor(predicted_strength, concrete_test$strength)
[,1]
[1,] 0.8064656
接近 1 的相关性表明两个变量之间存在强烈的线性关系。因此,这里大约 0.806 的相关性表明存在相当强的关系。这表明我们的模型做得相当不错,即使只有一个隐藏节点。然而,鉴于我们只使用了一个隐藏节点,我们很可能会提高我们模型的表现。让我们尝试做得更好。
第 5 步 - 提高模型性能
由于具有更复杂拓扑结构的网络能够学习更复杂的概念,让我们看看当我们把隐藏节点的数量增加到五个时会发生什么。我们像以前一样使用neuralnet()函数,但添加参数hidden = 5。请注意,由于这个神经网络复杂性的增加,根据您计算机的能力,新的模型可能需要 30 到 60 秒来训练:
> set.seed(12345)
> concrete_model2 <- neuralnet(strength ~ cement + slag +
ash + water + superplastic +
coarseagg + fineagg + age,
data = concrete_train, hidden = 5)
再次绘制网络图,我们发现连接数量急剧增加。这对性能有何影响?
> plot(concrete_model2)

图 7.13:具有额外隐藏节点的神经网络的拓扑可视化
注意,报告的误差(再次通过 SSE 测量)已从先前模型的 5.08 降低到这里的 1.63。此外,训练步骤的数量从 4,882 增加到 86,849,考虑到模型变得更加复杂,这一点并不令人惊讶。更复杂的网络需要更多的迭代来找到最优权重。
将相同的步骤应用于将预测值与真实值进行比较,我们现在获得大约 0.92 的相关性,这比之前单个隐藏节点时的 0.80 结果有相当大的改进:
> model_results2 <- compute(concrete_model2, concrete_test[1:8])
> predicted_strength2 <- model_results2$net.result
> cor(predicted_strength2, concrete_test$strength)
[,1]
[1,] 0.9244533426
尽管有这些显著的改进,我们仍然可以做一些更多的事情来尝试提高模型的表现。特别是,我们有能力添加额外的隐藏层并更改网络的激活函数。在做出这些更改时,我们为一个非常简单的深度神经网络(DNN)奠定了基础。
激活函数的选择通常对深度学习非常重要。对于特定的学习任务,最佳函数通常是通过实验确定的,然后在机器学习研究社区中更广泛地共享。随着深度学习研究的深入,一种称为rectifier的激活函数因其成功应用于图像识别等复杂任务而变得极为流行。使用 rectifier 激活函数的神经网络中的节点被称为rectified linear unit(ReLU)。如图所示,ReLU 激活函数的定义是,当x至少为零时返回x,否则返回零。这种函数在深度学习中的流行是因为它非线性,但具有简单的数学性质,这使得它在计算上既便宜又高效,非常适合梯度下降。不幸的是,其导数在x = 0时未定义,因此不能与neuralnet()函数一起使用。
相反,我们可以使用 ReLU 的平滑近似,称为softplus或SmoothReLU,这是一个定义为log(1 + e^x)的激活函数。如图所示,当x小于零时,softplus 函数几乎为零,而当x大于零时,大约等于x:

图 7.14:softplus 激活函数提供了 ReLU 的平滑、可微近似的描述
要在 R 中定义softplus()函数,请使用以下代码:
> softplus <- function(x) { log(1 + exp(x)) }
可以通过act.fct参数将此激活函数提供给neuralnet()。此外,我们还将通过将hidden参数设置为整数向量c(5, 5)来添加一个包含五个节点的第二隐藏层。这创建了一个两层网络,每层都有五个节点,都使用 softplus 激活函数。和之前一样,这可能需要一分钟或更长时间来运行:
> set.seed(12345)
> concrete_model3 <- neuralnet(strength ~ cement + slag +
ash + water + superplastic +
coarseagg + fineagg + age,
data = concrete_train,
hidden = c(5, 5),
act.fct = softplus)
网络可视化现在显示了一个由每个包含五个节点的两层隐藏层组成的拓扑结构:
> plot(concrete_model3)

图 7.15:使用软 plus 激活函数,用两层隐藏节点可视化我们的网络
再次,让我们计算预测值和实际混凝土强度之间的相关性:
> model_results3 <- compute(concrete_model3, concrete_test[1:8])
> predicted_strength3 <- model_results3$net.result
> cor(predicted_strength3, concrete_test$strength)
[,1]
[1,] 0.9348395359
预测值和实际强度之间的相关性为 0.935,这是我们迄今为止的最佳性能。有趣的是,在原始出版物中,Yeh 报告的相关性为 0.885。这意味着我们只需付出相对较小的努力,就能匹配甚至超过领域专家的性能。当然,Yeh 的结果是在 1998 年发表的,这使我们能够受益于 25 年额外的神经网络研究!
有一个重要的事情需要注意,因为我们训练模型之前已经对数据进行归一化处理,所以预测也是在从零到一的归一化尺度上。例如,以下代码显示了一个数据框,它将原始数据集的混凝土强度值与其对应的预测值并排比较:
> strengths <- data.frame(
actual = concrete$strength[774:1030],
pred = predicted_strength3
)
> head(strengths, n = 3)
actual pred
774 30.14 0.2860639091
775 44.40 0.4777304648
776 24.50 0.2840964250
使用相关性作为性能指标,归一化或未归一化数据的选择不会影响结果。例如,无论预测强度与原始、未归一化的混凝土强度值(strengths$actual)还是与归一化值(concrete_test$strength)进行比较,相关性为 0.935 都是相同的:
> cor(strengths$pred, strengths$actual)
[1] 0.9348395
> cor(strengths$pred, concrete_test$strength)
[1] 0.9348395
然而,如果我们计算不同的性能指标,例如预测值和实际值之间的百分比差异,那么选择的比例就会相当重要。
在这个前提下,我们可以创建一个unnormalize()函数,它反转了 min-max 归一化过程,并允许我们将归一化的预测值转换为原始尺度:
> unnormalize <- function(x) {
return(x * (max(concrete$strength) -
min(concrete$strength)) + min(concrete$strength))
}
在将自定义的unnormalize()函数应用于预测之后,我们可以看到新的预测值(pred_new)与原始混凝土强度值处于相似的尺度上。这使我们能够计算一个有意义的百分比误差值。得到的error_pct是真实值和预测值之间的百分比差异:
> strengths$pred_new <- unnormalize(strengths$pred)
> strengths$error_pct <- (strengths$pred_new - strengths$actual) /
strengths$actual
> head(strengths, n = 3)
actual pred pred_new error_pct
774 30.14 0.2860639 25.29235 -0.16083776
775 44.40 0.4777305 40.67742 -0.08384179
776 24.50 0.2840964 25.13442 -0.02589470
出乎意料的是,尽管反转了归一化,相关性仍然保持不变:
> cor(strengths$pred_new, strengths$actual)
[1] 0.9348395
当将神经网络应用于自己的项目时,你需要执行一系列类似的步骤,以将数据返回到其原始尺度。
你可能会发现,随着神经网络应用于更具挑战性的学习任务,它们会迅速变得更加复杂。例如,你可能会遇到所谓的消失梯度问题和与之密切相关的爆炸梯度问题,在这些问题中,由于无法在合理的时间内收敛,反向传播算法无法找到有用的解决方案。
作为这些问题的补救措施,人们可能会尝试改变隐藏节点的数量,应用不同的激活函数,如 ReLU,调整学习率等等。?neuralnet帮助页面提供了更多关于可以调整的各种参数的信息。然而,这又导致另一个问题,即测试许多参数成为构建高性能模型的一个瓶颈。这是人工神经网络(ANNs)和,尤其是深度神经网络(DNNs)的权衡:利用它们的巨大潜力需要巨大的时间和计算资源投入。
正如生活中更普遍的情况一样,在机器学习中,我们可以权衡时间和金钱。使用付费的云计算资源,如亚马逊网络服务(AWS)和微软 Azure,可以更快地构建更复杂的模型或测试许多模型。
理解支持向量机
可以将支持向量机(SVM)想象为在多维空间中绘制数据点所形成的表面,该空间代表示例及其特征值。SVM 的目标是创建一个称为超平面的平坦边界,将空间分割成两侧相对均匀的分区。通过这种方式,SVM 学习结合了第三章中介绍的基于实例的最近邻学习(Lazy Learning – Classification Using Nearest Neighbors)和第六章中描述的线性回归建模(Forecasting Numeric Data – Regression Methods)的方面。这种组合非常强大,使得 SVM 能够模拟高度复杂的关系。
尽管驱动 SVM 的基本数学原理已经存在了几十年,但自从机器学习社区采用它们之后,对它们的兴趣大大增加。在关于困难学习问题的知名成功故事以及获奖的 SVM 算法在许多编程语言(包括 R)中得到实现之后,它们的受欢迎程度爆炸式增长。因此,SVM 被广泛采用,这可能会让那些否则无法应用实现 SVM 所需的相对复杂数学的受众。好消息是,尽管数学可能很难,但基本概念是可理解的。
支持向量机(SVMs)可以适应用于几乎任何类型的机器学习任务,包括分类和数值预测。该算法的关键成功之一在于模式识别。值得注意的应用包括:
-
在生物信息学领域对微阵列基因表达数据进行分类,以识别癌症或其他遗传疾病
-
文本分类,例如识别文档中使用的语言或根据主题内容对文档进行分类
-
检测罕见但重要的事件,如发动机故障、安全漏洞或地震
当用于二元分类时,SVM 最容易理解,这也是该方法传统上应用的方式。因此,在接下来的部分中,我们将仅关注 SVM 分类器。这里提出的原则也适用于将 SVM 适应于数值预测。
使用超平面进行分类
如前所述,SVM 使用称为超平面的边界将数据分组为具有相似类值的组。例如,以下图展示了在二维和三维空间中分离圆形和正方形组的超平面。因为圆形和正方形可以通过一条直线或平坦的表面完美地分开,所以它们被称为线性可分。最初,我们将考虑这种情况是简单的情况,但 SVM 也可以扩展到点不是线性可分的问题。

图 7.16:在二维和三维空间中,正方形和圆形都是线性可分的
为了方便起见,超平面在二维空间中被传统地描绘为一条线,但这仅仅是因为在超过 2 维的空间中很难描绘空间。实际上,超平面是高维空间中的一个平面——这是一个可能难以理解的概念。
在二维空间中,SVM 算法的任务是识别一条将两个类别分开的线。如图所示,在圆和正方形的组之间有不止一条分割线。三种这样的可能性被标记为a,b和c。算法是如何选择的?

图 7.17:许多可能的分割正方形和圆的线中的三条
那个问题的答案涉及到寻找最大间隔超平面(MMH)以在两个类别之间创建最大的分离。尽管任何三条分割圆和正方形的线都可以正确分类所有数据点,但预期导致最大分离的线将最好地推广到未来的数据。最大间隔将提高即使添加随机噪声,每个类别仍然保持在边界一侧的机会。
支持向量(如图中箭头所示)是每个类别中最接近 MMH 的点。每个类别至少必须有一个支持向量,但可能有多个。支持向量本身定义了 MMH。这是 SVM 的一个关键特性;支持向量提供了一种非常紧凑的方式来存储分类模型,即使特征数量非常大。

图 7.18:MMH 由支持向量定义
识别支持向量的算法依赖于向量几何,并涉及到一些超出本书范围的复杂数学。然而,这个过程的基本原理是直接的。
更多关于 SVM 数学的信息可以在经典论文《支持向量机,Cortes, C 和 Vapnik, V,机器学习,1995,第 20 卷,第 273-297 页》中找到。入门级别的讨论可以在《支持向量机:炒作还是赞美?,Bennett, KP 和 Campbell, C,SIGKDD Explorations,2000,第 2 卷,第 1-13 页》中找到。更深入的探讨可以在《支持向量机,Steinwart, I 和 Christmann, A,纽约:Springer,2008》中找到。
线性可分数据的案例
在假设类别是线性可分的情况下,找到最大间隔是最容易的。在这种情况下,最大间隔距离两组数据点的外部边界尽可能远。这些外部边界被称为凸包。最大间隔是两个凸包之间最短路径的垂直平分线。使用称为二次优化的技术的高级计算机算法可以通过这种方式找到最大间隔。

图 7.19:最大间隔超平面是凸包之间最短路径的垂直平分线
另一种(但等效)的方法是通过搜索每个可能超平面的空间来找到一组平行平面,这些平面将点分成同质组,同时它们彼此尽可能远。用比喻来说,可以想象这个过程就像尝试找到可以塞进你卧室楼梯井的最厚的床垫。
要理解这个搜索过程,我们需要精确地定义我们所说的超平面。在 n-维空间中,以下方程被使用:

如果你对这个符号不熟悉,字母上方的箭头表示它们是向量而不是单个数字。特别是,w 是一个包含 n 个权重的向量,即 {w[1], w[2], ..., w[n]},而 b 是一个称为偏置的单个数字。偏置在概念上等同于在第六章“预测数值数据 - 回归方法”中讨论的斜截式中的截距项。
如果你难以想象多维空间中的平面,不必担心细节。只需将方程视为指定一个表面的方式,就像斜截式(y = mx + b)在二维空间中用于指定直线一样。
使用这个公式,该过程的目的是找到一组权重,以指定两个超平面,如下所示:


我们还要求这些超平面被指定得使得一个类别的所有点都位于第一个超平面上方,而另一个类别的所有点都位于第二个超平面下方。这两个平面应该产生一个间隙,使得两个平面之间的空间中没有来自任一类的点。只要数据是线性可分的,这是可能的。向量几何定义了这两个平面之间的距离——我们希望尽可能大的距离——如下:

这里,||w|| 表示 欧几里得范数(从原点到向量 w 的距离)。因为 ||w|| 是分母,为了最大化距离,我们需要最小化 ||w||。这个任务通常被重新表述为以下一组约束条件:


虽然这看起来很混乱,但从概念上理解其实并不复杂。基本上,第一行意味着我们需要最小化欧几里得范数(平方并除以二以简化计算)。第二行指出,这受制于(s.t.)每个 y[i] 数据点被正确分类的条件。请注意,y 表示类别值(转换为+1 或-1),倒置的“A”是“对于所有”的简称。
与寻找最大边缘的其他方法一样,找到这个问题的解决方案最好是留给二次优化软件的任务。尽管它可能需要大量的处理器资源,但专门的算法甚至可以在大型数据集上快速解决这个问题。
非线性可分数据的情况
随着我们研究 SVM 背后的理论,你可能想知道房间里的大象:当数据不是线性可分时会发生什么?这个问题的解决方案是使用松弛变量,它创建了一个软边缘,允许一些点落在边缘的错误一侧。下面的图示说明了两个点落在错误一侧的线,以及相应的松弛项(用希腊字母 Xi 表示):

图 7.20:落在边界错误一侧的点会带来成本惩罚
对所有违反约束的点应用一个成本值(表示为 C),而不是寻找最大边缘,算法试图最小化总成本。因此,我们可以将优化问题修改为:


如果你现在感到困惑,不要担心,你不是一个人。幸运的是,SVM 软件包会乐意为你优化,而无需你理解技术细节。重要的是要理解成本参数 C 的添加。修改这个值将调整落在超平面错误一侧的点的惩罚。成本参数越大,优化尝试达到 100%分离的努力就越大。另一方面,较低的成本参数将强调更宽的整体边缘。为了创建一个对未来数据泛化良好的模型,重要的是在这两者之间取得平衡。
用于非线性空间的核函数
在许多现实世界的数据集中,变量之间的关系是非线性的。正如我们刚刚发现的,通过添加松弛变量,SVM 仍然可以在这种数据上训练,这允许一些示例被错误分类。然而,这并不是解决非线性问题的唯一方法。SVM 的一个关键特性是它们能够使用称为核技巧的过程将问题映射到更高维的空间。在这个过程中,非线性关系可能突然显得非常线性。
虽然这听起来可能有些荒谬,但实际上用例子来说明非常简单。在下面的图中,左边的散点图描绘了天气类别(晴朗或雪)与两个特征:纬度和经度之间的非线性关系。图中中心的点是雪类别成员,而边缘的点都是晴朗的。这样的数据可能来自一组天气预报,其中一些是从山顶附近的站点获得的,而另一些是从山脉底部附近的站点获得的。

图 7.21:核技巧可以帮助将非线性问题转化为线性问题
在图右侧,在应用核技巧之后,我们通过新维度来观察数据:高度。通过添加这个特征,类别现在可以完美地线性分离。这是可能的,因为我们获得了对数据的新视角。在左侧的图中,我们是从鸟瞰的角度观察山脉,而在右侧,我们是从地面水平距离观察山脉。在这里,趋势很明显:在较高的海拔处发现了雪天气候。
以这种方式,具有非线性核的 SVM 通过向数据添加额外维度来创建分离。本质上,核技巧涉及构建新特征的过程,这些特征表达了测量特征之间的数学关系。例如,高度特征可以数学上表示为纬度和经度之间的相互作用——点越接近每个尺度中心,高度就越大。这使得 SVM 能够学习原始数据中未明确测量的概念。
具有非线性核的 SVM 是极其强大的分类器,尽管它们也有一些缺点,如下表所示:
| 优点 | 缺点 |
|---|
|
-
可用于分类或数值预测问题
-
不太受噪声数据的影响,并且不太容易过拟合
-
可能比神经网络更容易使用,尤其是由于存在几个得到良好支持的 SVM 算法
-
由于其在数据挖掘竞赛中的高准确率和显眼的胜利而受到欢迎
|
-
寻找最佳模型需要测试各种核和模型参数的组合
-
训练可能较慢,尤其是如果输入数据集具有大量特征或示例
-
导致产生复杂的黑盒模型,难以解释,甚至可能无法解释
|
核函数通常具有以下形式。由希腊字母 phi 表示的函数,即
,是将数据映射到另一个空间的映射。因此,一般的核函数会对特征向量 x[i] 和 x[j] 进行一些变换,并使用点积将它们结合起来,点积将两个向量作为输入并返回一个单一数值:

使用这种形式,已经为许多不同的领域开发了核函数。以下列出了最常用的几个核函数。几乎所有的 SVM 软件包都将包括这些核函数以及其他许多核函数。
线性核根本不转换数据。因此,它可以简单地表示为特征的点积:

多项式核将数据添加一个简单的非线性变换:

Sigmoid 核导致 SVM 模型类似于使用 sigmoid 激活函数的神经网络。希腊字母 kappa 和 delta 用作核参数:

高斯径向基函数核类似于径向基神经网络。径向基函数核在许多类型的数据上表现良好,并被认为是为许多学习任务提供一个合理的起点:

没有可靠的规则来匹配核函数与特定的学习任务。拟合程度很大程度上取决于要学习的概念、训练数据量以及特征之间的关系。通常,需要在验证数据集上训练和评估几个 SVM 来尝试和错误地找到合适的核函数。尽管如此,在许多情况下,核函数的选择是任意的,因为性能可能只有细微的差异。为了了解这在实践中是如何工作的,让我们将我们对 SVM 分类的理解应用到现实世界的问题中。
示例 - 使用 SVM 进行 OCR
对于许多机器学习算法来说,图像处理是一个困难的任务。将像素模式与更高层次概念联系起来的关系极其复杂且难以定义。例如,人类很容易识别人脸、猫或字母“A”,但在严格的规则中定义这些模式是困难的。此外,图像数据通常很嘈杂。根据光线、方向和主题的位置,图像的捕获可能会有许多细微的变化。
SVM 非常适合解决图像数据的挑战。它们能够学习复杂的模式,同时对噪声不太敏感,可以以高精度识别视觉模式。此外,SVM 的关键弱点——黑盒模型表示——对于图像处理来说不那么关键。如果一个 SVM 能够区分猫和狗,那么它如何做到这一点并不重要。
在本节中,我们将开发一个模型,类似于通常与桌面文档扫描仪或智能手机应用程序捆绑的光学字符识别(OCR)软件的核心。此类软件的目的是通过将打印或手写文本转换为电子形式以保存到数据库中来处理基于纸张的文档。
当然,这是一个困难的问题,因为手写风格和印刷字体有很多变体。尽管如此,软件用户期望完美无缺,因为错误或打字错误可能导致在商业环境中尴尬或昂贵的错误。让我们看看我们的 SVM 是否能够胜任这项任务。
第一步 – 收集数据
当 OCR 软件首次处理文档时,它会将纸张划分为一个矩阵,使得网格中的每个单元格都包含一个单个符号,这是一个指代字母、符号或数字的术语。接下来,对于每个单元格,软件将尝试将符号与它所识别的所有字符集合进行匹配。最后,单个字符可以组合成单词,这些单词可以选择性地与文档语言的词典进行拼写检查。
在这个练习中,我们假设我们已经开发出了将文档划分为矩形区域的算法,每个区域只包含一个符号。我们还将假设文档只包含英语字母。因此,我们将模拟一个将符号与 26 个字母之一(从 A 到 Z)匹配的过程。
为了达到这个目的,我们将使用 W. Frey 和 D. J. Slate 捐赠给 UCI 机器学习仓库(archive.ics.uci.edu/ml)的数据集。该数据集包含 20,000 个示例,展示了使用 20 种不同随机变形和扭曲的黑白字体打印的 26 个英文字母大写字母。
关于这些数据的更多信息,请参阅使用荷兰风格自适应分类器的字母识别,Slate, DJ 和 Frey, PW,机器学习,1991,第 6 卷,第 161-182 页。
下图由 Frey 和 Slate 发布,提供了一些打印符号的示例。以这种方式扭曲后,字母对计算机来说具有挑战性,但对人类来说很容易识别:

图 7.22:SVM 算法将尝试识别的符号示例
第二步 – 探索和准备数据
根据 Frey 和 Slate 提供的文档,当符号被扫描到计算机中时,它们被转换为像素,并记录了 16 个统计属性。
属性衡量诸如符号的水平尺寸和垂直尺寸、黑色(相对于白色)像素的比例以及像素的平均水平和垂直位置等特征。据推测,盒子不同区域黑色像素浓度的差异应该提供一种区分 26 个字母的方法。
要跟随这个示例,请从 Packt Publishing 网站下载letterdata.csv文件,并将其保存到您的 R 工作目录中。
将数据读入 R 中,我们确认我们已经收到了定义字母类每个示例的 16 个特征的完整数据。正如预期的那样,它有 26 个级别:
> letters <- read.csv("letterdata.csv", stringsAsFactors = TRUE)
> str(letters)
'data.frame': 20000 obs. of 17 variables:
$ letter: Factor w/ 26 levels "A","B","C","D",..: 20 9 4 14 ...
$ xbox : int 2 5 4 7 2 4 4 1 2 11 ...
$ ybox : int 8 12 11 11 1 11 2 1 2 15 ...
$ width : int 3 3 6 6 3 5 5 3 4 13 ...
$ height: int 5 7 8 6 1 8 4 2 4 9 ...
$ onpix : int 1 2 6 3 1 3 4 1 2 7 ...
$ xbar : int 8 10 10 5 8 8 8 8 10 13 ...
$ ybar : int 13 5 6 9 6 8 7 2 6 2 ...
$ x2bar : int 0 5 2 4 6 6 6 2 2 6 ...
$ y2bar : int 6 4 6 6 6 9 6 2 6 2 ...
$ xybar : int 6 13 10 4 6 5 7 8 12 12 ...
$ x2ybar: int 10 3 3 4 5 6 6 2 4 1 ...
$ xy2bar: int 8 9 7 10 9 6 6 8 8 9 ...
$ xedge : int 0 2 3 6 1 0 2 1 1 8 ...
$ xedgey: int 8 8 7 10 7 8 8 6 6 1 ...
$ yedge : int 0 4 3 2 5 9 7 2 1 1 ...
$ yedgex: int 8 10 9 8 10 7 10 7 7 8 ...
SVM 学习器需要所有特征都是数值的,并且每个特征都缩放到一个相当小的区间。在这种情况下,每个特征都是整数,因此我们不需要将任何因素转换为数字。另一方面,这些整数变量的范围似乎很宽。这表明我们需要对数据进行归一化或标准化。然而,我们现在可以跳过这一步,因为我们将要使用的 R 包将自动执行缩放。
由于没有剩余的数据准备要执行,我们可以直接进入机器学习过程的训练和测试阶段。在先前的分析中,我们随机地将数据分配到训练集和测试集中。虽然我们也可以这样做,但 Frey 和 Slate 已经随机化了数据,因此建议使用前 16,000 条记录(80%)来构建模型,以及接下来的 4,000 条记录(20%)用于测试。遵循他们的建议,我们可以创建以下训练和测试数据框:
> letters_train <- letters[1:16000, ]
> letters_test <- letters[16001:20000, ]
我们的准备工作已经就绪,现在让我们开始构建我们的分类器。
第 3 步 – 在数据上训练模型
当涉及到在 R 中拟合 SVM 模型时,有多个出色的包可供选择。维也纳科技大学(TU Wien)统计学系的e1071包提供了一个 R 接口到获奖的 LIBSVM 库,这是一个广泛使用的开源 SVM 程序,用 C++编写。如果您已经熟悉 LIBSVM,您可能想从这里开始。
关于 LIBSVM 的更多信息,请参考作者网站www.csie.ntu.edu.tw/~cjlin/libsvm/。
同样,如果您已经投资于 SVMlight 算法,杜伊斯堡-埃森科技大学(TU Dortmund)统计学系的klaR包提供了直接从 R 使用此 SVM 实现的功能。
关于 SVMlight 的信息,请参阅www.cs.cornell.edu/people/tj/svm_light/。
最后,如果您是从零开始,那么最好从kernlab包中的 SVM 函数开始。这个包的一个有趣的优势是它是在 R 中本地开发的,而不是在 C 或 C++中,这使得它可以很容易地进行定制;内部没有任何东西隐藏在幕后。也许更重要的是,与其他选项不同,kernlab可以与caret包一起使用,这使得可以使用各种自动化方法(在第十四章构建更好的学习者中介绍)来训练和评估 SVM 模型。
要更全面地了解kernlab,请参阅作者在www.jstatsoft.org/v11/i09/上的论文。
使用kernlab训练 SVM 分类器的语法如下。如果您确实在使用其他包,命令在很大程度上是相似的。默认情况下,ksvm()函数使用高斯 RBF 核,但还提供了许多其他选项:

图 7.23:SVM 语法
为了提供一个 SVM 性能的基线度量,让我们首先训练一个简单的线性 SVM 分类器。如果您还没有安装,请使用install.packages("kernlab")命令将kernlab包安装到您的库中。然后,我们可以在训练数据上调用ksvm()函数,并使用vanilladot选项指定线性(即“纯”)核,如下所示:
> library(kernlab)
> letter_classifier <- ksvm(letter ~ ., data = letters_train,
kernel = "vanilladot")
根据您计算机的性能,此操作可能需要一些时间才能完成。完成后,输入存储模型的名称,以查看有关训练参数和模型拟合的一些基本信息:
> letter_classifier
Support Vector Machine object of class "ksvm"
SV type: C-svc (classification)
parameter : cost C = 1
Linear (vanilla) kernel function.
Number of Support Vectors : 7037
Objective Function Value : -14.1746 -20.0072 -23.5628 -6.2009 -7.5524
-32.7694 -49.9786 -18.1824 -62.1111 -32.7284 -16.2209...
Training error : 0.130062
这些信息对我们了解模型在现实世界中的表现帮助甚微。我们需要检查它在测试数据集中的表现,才能知道它是否很好地泛化到未见过的数据。
第 4 步 – 评估模型性能
predict()函数允许我们使用字母分类模型对测试数据集进行预测:
> letter_predictions <- predict(letter_classifier, letters_test)
由于我们没有指定type参数,因此使用了默认的type = "response"。这返回一个包含测试数据中每行值预测字母的向量。使用head()函数,我们可以看到前六个预测字母分别是U、N、V、X、N和H:
> head(letter_predictions)
[1] U N V X N H
Levels: A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
为了检查我们的分类器表现如何,我们需要将预测字母与测试数据集中的真实字母进行比较。我们将使用table()函数来完成此操作(此处仅显示部分完整表格):
> table(letter_predictions, letters_test$letter)
letter_predictions A B C D E
A 144 0 0 0 0
B 0 121 0 5 2
C 0 0 120 0 4
D 2 2 0 156 0
E 0 0 5 0 127
对角线上的144、121、120、156和127值表示预测字母与真实值匹配的记录总数。同样,错误数量也被列出。例如,行B和列D中的5表示有 5 个案例,其中字母D被错误地识别为B。
单独查看每种类型的错误可能会揭示模型在特定字母类型上遇到困难的一些有趣模式,但这很耗时。我们可以通过计算整体准确率来简化评估。这仅考虑预测是否正确或错误,而忽略错误类型。
以下命令返回一个包含TRUE或FALSE值的向量,指示模型预测的字母是否与测试数据集中的实际字母相符(即匹配):
> agreement <- letter_predictions == letters_test$letter
使用table()函数,我们看到分类器在 4000 个测试记录中的 3357 个中正确识别了字母:
> table(agreement)
agreement
FALSE TRUE
643 3357
从百分比的角度来看,准确率大约是 84%:
> prop.table(table(agreement))
agreement
FALSE TRUE
0.16075 0.83925
注意,当 Frey 和 Slate 在 1991 年发布数据集时,他们报告的识别准确率约为 80%。仅用几行 R 代码,我们就能够超越他们的结果,尽管我们也受益于数十年的额外机器学习研究。考虑到这一点,我们很可能会做得更好。
第 5 步 - 提高模型性能
让我们花点时间来了解一下我们训练的 SVM 模型在从图像数据中识别字母时的性能。通过一行 R 代码,该模型能够达到近 84%的准确率,略高于 1991 年学术研究人员发布的基准百分比。尽管 84%的准确率远远不足以用于 OCR 软件,但相对简单的模型能够达到这个水平本身就是一项了不起的成就。请记住,模型预测与实际值仅靠运气匹配的概率相当小,不到四%。这表明我们的模型的表现比随机机会高出 20 倍以上。尽管如此出色,也许通过调整 SVM 函数参数来训练一个稍微复杂一些的模型,我们还可以发现该模型在现实世界中是有用的。
为了计算 SVM 模型预测与实际值偶然匹配的概率,应用第四章中涵盖的独立事件的联合概率规则,即朴素贝叶斯分类的概率学习。因为测试集中有 26 个字母,每个字母出现的频率大约相同,所以任何单个字母被正确预测的概率是(1 / 26) * (1 / 26)。由于有 26 个不同的字母,总的一致性概率是26 * (1 / 26) * (1 / 26) = 0.0384,或 3.84%。
改变 SVM 核函数
我们之前的 SVM 模型使用了简单的线性核函数。通过使用更复杂的核函数,我们可以将数据映射到更高维的空间,并可能获得更好的模型拟合。
然而,从众多不同的核函数中选择可能具有挑战性。一个流行的惯例是从高斯 RBF 核开始,它已被证明对许多类型的数据表现良好。
我们可以使用ksvm()函数训练基于 RBF 的 SVM,如下所示。请注意,与之前使用的方法类似,我们需要设置随机种子以确保结果可重复:
> set.seed(12345)
> letter_classifier_rbf <- ksvm(letter ~ ., data = letters_train,
kernel = "rbfdot")
接下来,我们像之前一样进行预测:
> letter_predictions_rbf <- predict(letter_classifier_rbf,
letters_test)
最后,我们将比较其准确性与我们的线性 SVM:
> agreement_rbf <- letter_predictions_rbf == letters_test$letter
> table(agreement_rbf)
agreement_rbf
FALSE TRUE
278 3722
> prop.table(table(agreement_rbf))
agreement_rbf
FALSE TRUE
0.0695 0.9305
仅通过改变核函数,我们就能够将我们的字符识别模型的准确率从 84%提高到 93%。
确定最佳的 SVM 成本参数
如果这个性能水平对于 OCR 程序来说仍然不满意,当然可以测试额外的核函数。然而,另一种富有成效的方法是改变成本参数,这会修改 SVM 决策边界的宽度。这决定了模型在过拟合和欠拟合训练数据之间的平衡——成本值越大,学习器将越努力地尝试完美地分类每个训练实例,因为每个错误都有更高的惩罚。一方面,高成本可能导致学习器过拟合训练数据。另一方面,设置得太小的成本参数可能导致学习器错过训练数据中的重要、微妙的模式,并欠拟合真实模式。
没有先验的规则可以知道理想值,因此,我们将检查模型在C(成本参数)的各种值下的表现。而不是反复重复训练和评估过程,我们可以使用sapply()函数将自定义函数应用于潜在成本值的向量。我们首先使用seq()函数生成这个向量,它是一个从 5 开始计数到 40 的序列,每次增加 5。然后,如以下代码所示,自定义函数像以前一样训练模型,每次使用成本值并在测试数据集上进行预测。每个模型的准确率是匹配实际值的预测数量除以总预测数量。结果使用plot()函数进行可视化。请注意,根据您计算机的能力,这可能需要几分钟才能完成:
> cost_values <- c(1, seq(from = 5, to = 40, by = 5))
> accuracy_values <- sapply(cost_values, function(x) {
set.seed(12345)
m <- ksvm(letter ~ ., data = letters_train,
kernel = "rbfdot", C = x)
pred <- predict(m, letters_test)
agree <- ifelse(pred == letters_test$letter, 1, 0)
accuracy <- sum(agree) / nrow(letters_test)
return (accuracy)
})
> plot(cost_values, accuracy_values, type = "b")

图 7.24:映射准确率与 RBF 核的 SVM 成本
如可视化所示,在 93%的准确率下,默认的 SVM 成本参数C = 1在评估的 9 个模型中产生了最不准确的模式。相反,将C设置为 10 或更高的值,准确率可达到约 97%,这在性能上是一个相当大的提升!也许这已经足够接近完美,以至于可以在实际环境中部署该模型,尽管仍然值得进一步实验,以查看是否有可能达到 100%的准确率。每次准确率的提升都将减少 OCR 软件的错误,并为最终用户提供更好的整体体验。
摘要
在本章中,我们探讨了两种具有巨大潜力但常因复杂性而被忽视的机器学习方法。希望你现在能看出这种声誉至少在一定程度上是不应得的。驱动人工神经网络(ANNs)和支持向量机(SVMs)的基本概念并不难理解。
另一方面,由于人工神经网络(ANNs)和支持向量机(SVMs)已经存在了几十年,它们各自都有许多变体。本章只是对这些方法可能实现的内容进行了初步探讨。通过利用在这里学到的术语,你应该能够捕捉到每天正在开发的许多进步的细微差别,包括不断发展的深度学习领域。我们将在第十五章“利用大数据”中重新探讨深度学习,看看它如何解决机器学习中最具挑战性的问题。
在过去的几章中,我们学习了多种不同类型的预测模型,从基于简单启发式算法如最近邻算法的模型到复杂的黑盒模型以及其他许多模型。在下一章中,我们将开始考虑另一种学习任务的方法。这些无监督学习技术将在帮助我们找到“大海捞针”的过程中,揭示数据中的迷人模式。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人相聚,并在以下地点与超过 4000 人一起学习:

第八章:寻找模式 - 使用关联规则进行篮子分析
想想你上一次的冲动购买。也许在杂货店的结账通道,你买了一包口香糖或一块巧克力棒。也许在深夜为尿布和配方奶粉的购物之旅中,你买了一瓶含咖啡因的饮料或一箱啤酒。你甚至可能是在书店推荐下买了这本书。这些冲动购买并非巧合,因为零售商使用复杂的数据分析技术来识别用于营销促销和通过产品定位推动交叉销售的有用模式。
在过去,这样的推荐基于市场营销专业人士和库存管理人员的直观直觉。现在,条形码扫描仪、库存数据库和在线购物车都生成交易数据,机器学习可以使用这些数据来学习购买模式。这种做法通常被称为篮子分析,因为它已经被频繁应用于超市数据。
尽管这项技术起源于购物数据,但它也适用于其他环境。在你完成这一章的时候,你将知道如何将篮子分析技术应用于你自己的任务,无论它们是什么。通常,这项工作涉及:
-
理解交易数据的特殊性
-
使用简单的性能指标在大数据库中寻找关联
-
知道如何识别有用和可操作的规律
由于篮子分析能够发现许多类型的大型数据集中的洞察力,当我们应用这项技术时,你可能会发现你的工作中也有应用,即使你没有与零售行业有联系。
理解关联规则
篮子分析的基础是可能出现在任何给定交易中的项目。一组或多个项目被括号包围,以表示它们形成一个集合,或者更具体地说,是一个在数据中出现频率较高的项目集。交易是以项目集来指定的,如下面在典型杂货店可能找到的交易:
{面包, 花生酱, 果酱}
篮子分析的结果是一系列关联规则,这些规则指定了在项目集项目之间的关系中发现的模式。关联规则总是由项目集的子集组成,并通过将规则的一侧的左侧(LHS)与另一侧的右侧(RHS)关联来表示。LHS 是触发规则需要满足的条件,而 RHS 是满足该条件后的预期结果。从前面的示例交易中识别出的规则可能以以下形式表示:
{花生酱, 果酱} → {面包}
用简单的话来说,这个关联规则表明,如果花生酱和果酱一起购买,那么面包也很可能被购买。换句话说,“花生酱和果酱意味着面包。”
在零售交易数据库的背景下开发,关联规则不用于预测,而是用于在大数据库中进行无监督的知识发现。这与前几章中介绍的分类和数值预测算法不同。即便如此,你会发现关联规则学习的结果与第五章中介绍的分类规则学习的结果密切相关,并共享许多特征,即“分而治之 – 使用决策树和规则进行分类”。
由于关联规则学习器是无监督的,因此不需要对算法进行训练,数据也不需要在事先进行标记。程序只是简单地应用于数据集,希望找到有趣的相关性。当然,缺点是没有简单的方法可以客观地衡量规则学习器的性能,除了评估其定性有用性——通常,是一种某种形式的目视检查。
尽管关联规则最常用于篮子分析,但它们对于在许多不同类型的数据中寻找模式很有帮助。其他潜在的应用包括:
-
在癌症数据中寻找有趣的 DNA 和蛋白质序列模式
-
寻找与欺诈性信用卡或保险使用相结合的购买或医疗索赔模式
-
识别出导致客户放弃手机服务或升级有线电视套餐的行为组合
关联规则分析用于在大量元素中寻找有趣的相关性。人类能够相当直观地做到这一点,但通常需要专家级知识或大量的经验才能在几分钟或几秒钟内完成规则学习算法所能做到的事情。此外,有些数据集太大、太复杂,以至于人类难以在“大海捞针”中找到所需的信息。
关联规则学习的 Apriori 算法
正如大型交易数据集给人类带来挑战一样,这些数据集也给机器带来了挑战。交易数据集在交易数量以及记录的项目或特征数量上都可以很大。搜索有趣的项目集的基本问题在于,潜在的项目集数量随着项目数量的指数增长。给定可以出现在集合中或不出现在集合中的k个项目,有2^k 种可能的项目集,这些项目集可能是潜在的规则。一个只销售 100 种不同商品的零售商可能需要评估大约2¹⁰⁰ = 1.27e+30个项目集——这看起来是一项看似不可能的任务。
而不是逐个评估这些项集,一个更智能的规则学习算法利用了这样一个事实:在实际情况中,许多潜在的项目组合很少甚至从未被发现。例如,即使一家商店同时销售汽车用品和食品产品,集合{机油,香蕉}可能非常罕见。通过忽略这些罕见(也许不那么重要)的组合,可以将搜索规则的范围限制在更易于管理的规模。
已做了大量工作来识别用于减少搜索项集数量的启发式算法。也许最广为人知的用于高效搜索大型数据库规则的算法被称为Apriori。该算法由 Rakesh Agrawal 和 Ramakrishnan Srikant 于 1994 年提出,尽管后来发明了更新、更快的算法,但 Apriori 算法仍然与关联规则学习有些同义。该名称来源于算法利用关于频繁项集属性的一种简单先验(即先验)信念。
在我们更深入地讨论这一点之前,值得注意的是,这个算法,像所有学习算法一样,并非没有其优点和缺点。以下列出了一些:
| 优点 | 缺点 |
|---|
|
-
能够处理大量交易数据
-
生成易于理解的规则
-
适用于数据挖掘和发现数据库中的意外知识
|
-
对于相对较小的数据集不太有帮助
-
需要努力区分真正的洞察力和常识
-
容易从随机模式中得出错误的结论
|
如前所述,Apriori 算法采用一种简单的先验信念作为减少关联规则搜索空间的指导原则:频繁项集的所有子集也必须是频繁的。这种启发式方法被称为Apriori 属性。利用这一精明的观察,可以显著减少需要搜索的规则数量。例如,集合{机油,香蕉}只有在{机油}和{香蕉}都频繁出现的情况下才能是频繁的。因此,如果{机油}或{香蕉}不频繁,那么包含这些项目的任何集合都可以从搜索中排除。
关于 Apriori 算法的更多详细信息,请参阅Agrawal, R., Srikant, R.,《快速挖掘关联规则算法》,第 20 届国际非常大型数据库会议论文集,1994 年,第 487-499 页。
为了看到这一原则如何在更现实的设置中应用,让我们考虑一个简单的交易数据库。以下表格显示了在一个虚构医院礼品店中完成的五个交易:

图 8.1:表示假设医院礼品店中五个交易的项集
通过观察购买集合,我们可以推断出存在几种典型的购买模式。一个人去看望生病的亲朋好友时,往往会购买一张祝福卡和鲜花,而看望新妈妈的访客则倾向于购买毛绒玩具熊和气球。这些模式值得关注,因为它们出现的频率足够高,足以引起我们的兴趣;我们只需运用一点逻辑和专业知识来解释这些规则。
类似地,Apriori 算法使用项集“有趣性”的统计指标在更大的交易数据库中定位关联规则。在接下来的章节中,我们将发现 Apriori 如何计算这些兴趣度量,以及它们如何与 Apriori 属性结合以减少要学习的规则数量。
测量规则兴趣——支持度和置信度
一个关联规则是否被认为是有趣的,由两个统计指标决定:支持度和置信度。通过为这些指标提供最小阈值并应用 Apriori 原则,可以很容易地大幅减少报告的规则数量。如果这个限制过于严格,可能会导致只有最明显或常识性的规则被识别。因此,了解在这些标准下被排除的规则类型非常重要,以便获得正确的平衡。
项集或规则的支持度衡量它在数据中出现的频率。例如,项集{祝福卡,鲜花}在医院的礼品店数据中的支持度为3/5 = 0.6。同样,项集{祝福卡}
{鲜花}的支持度也是0.6。支持度可以计算任何项集或单个项;例如,项集{巧克力棒}的支持度为2/5 = 0.4,因为巧克力棒出现在 40%的购买中。可以定义一个函数来定义项集X的支持度:

这里,N是数据库中的交易数量,而count(X)是包含项集X的交易数量。
规则的置信度是对其预测能力或准确性的衡量。它定义为包含X和Y的项集的支持度除以只包含X的项集的支持度:

实质上,置信度告诉我们交易中存在项或项集X导致存在项或项集Y的比例。记住,X导致Y的置信度与Y导致X的置信度不同。
例如,{flowers}
{get-well card}的置信度为0.6 / 0.8 = 0.75。相比之下,{get-well card}
{flowers}的置信度为0.6 / 0.6 = 1.0。这意味着购买鲜花 75%的时间也包括购买祝福卡,而购买祝福卡 100%的时间也包括鲜花。这些信息对礼品店的管理可能非常有用。
您可能已经注意到了支持度、置信度和在第四章“概率学习 - 使用朴素贝叶斯进行分类”中介绍的贝叶斯概率规则之间的相似性。事实上,support(A, B)与P(A
B)相同,而confidence(A
B)与P(B | A)相同。只是上下文不同。
类似于{get-well card}
{flowers}的规则被称为强规则,因为它们既有高的支持度又有高的置信度。找到更多强规则的一种方法就是检查礼品店中所有可能的商品组合,测量支持度和置信度,并仅报告那些达到一定兴趣水平的规则。然而,正如之前所述,这种策略通常只适用于数据集非常小的情况。
在下一节中,您将看到 Apriori 算法如何使用 Apriori 原则和最小支持度、置信度水平,通过减少规则数量到更易管理的水平来快速找到强规则。
使用 Apriori 原则构建一组规则
记住,Apriori 原则指出,频繁项集的所有子集也必须是频繁的。换句话说,如果{A, B}是频繁的,那么{A}和{B}都必须是频繁的。还要记住,根据定义,支持度指标表示项集在数据中出现的频率。因此,如果我们知道{A}没有达到期望的支持度阈值,就没有理由考虑{A, B}或任何包含{A}的其他项集;这些不可能频繁出现。
Apriori 算法使用这种逻辑在评估之前排除潜在的关联规则。创建规则的过程分为两个阶段:
-
识别所有满足最小支持度阈值的项集
-
使用满足最小置信度阈值的项集创建规则
第一阶段发生在多次迭代中。每一次连续的迭代都涉及评估一组越来越大项集的支持度。例如,第一次迭代涉及评估 1 项项集(1 项集),第二次迭代评估 2 项集,依此类推。每一次迭代i的结果是所有满足最小支持度阈值的i项集的集合。
所有来自迭代 i 的项集都被组合起来,以生成在迭代 i + 1 中评估的候选项集。但 Apriori 原则可以在下一轮开始之前消除其中的一些。如果在一轮迭代中 {A}, {B}, 和 {C} 是频繁的,而 {D} 不是频繁的,那么第二轮迭代将只考虑 {A, B}, {A, C}, 和 {B, C}。因此,算法只需要评估三个项集,而不是如果包含 D 的集合没有被提前消除,将需要评估的六个 2 项项集。
继续这个想法,假设在第二次迭代中发现 {A, B} 和 {B, C} 是频繁的,但 {A, C} 不是。尽管第三次迭代通常从评估 3 项项集 {A, B, C} 的支持度开始,但这一步不是必要的。为什么不是?Apriori 原则指出,由于子集 {A, C} 不是频繁的,因此 {A, B, C} 不可能是频繁的。因此,在第三次迭代中没有生成新的项集,算法可能停止。

图 8.2:在这个例子中,Apriori 算法只评估了 15 个潜在项集(对于四个项目,0 项项集未显示)中的 7 个,这些项集可能出现在事务数据中
在这一点上,Apriori 算法的第二阶段可能开始。给定频繁项集的集合,从所有可能的子集中生成关联规则。例如,{A, B} 将导致为 {A}
{B} 和 {B}
{A} 生成候选规则。这些规则将根据最小置信度阈值进行评估,任何不符合所需置信度水平的规则都将被淘汰。
示例 - 使用关联规则识别经常购买的杂货
如本章引言所述,市场篮子分析在许多实体店和在线零售商使用的推荐系统背后被使用。学习到的关联规则表明了经常一起购买的商品。了解这些模式可以为杂货连锁店优化库存、宣传促销或组织商店的物理布局提供新的见解。例如,如果购物者经常在早餐糕点时购买咖啡或橙汁,那么通过将糕点移至咖啡和果汁附近,可能有可能增加利润。
同样,在线零售商可以使用这些信息为动态推荐引擎提供支持,这些引擎建议与您已经查看的商品相关的商品,或者在网站访问或在线购买后通过电子邮件建议附加商品,这种做法被称为主动营销。
在本教程中,我们将对一家杂货店的交易数据进行市场篮子分析。这样做,我们将看到 Apriori 算法如何高效地评估潜在的庞大关联规则集。同样的技术也可以应用于许多其他商业任务,从电影推荐到交友网站,再到寻找药物之间的危险相互作用。
第 1 步 – 收集数据
我们的市场篮子分析将利用一家真实杂货店一个月运营的购买数据。数据包含 9,835 笔交易,或大约每天 327 笔交易(在 12 小时营业日中,每小时大约 30 笔交易),这表明零售商既不是特别大,也不是特别小。
这里使用的数据集是从arules R 包中的Groceries数据集改编的。更多信息,请参阅概率数据建模对挖掘关联规则的影响,Hahsler, M.,Hornik, K.,Reutterer, T.,2005。在从数据和信息分析到知识工程,Gaul, W.,Vichi, M.,Weihs, C.,分类、数据分析和知识组织研究,2006,第 598-605 页。
一个典型的杂货店提供大量商品。可能有五种牛奶品牌,一打洗衣剂类型,以及三种咖啡品牌。鉴于本例中零售商的适度规模,我们假设它并不特别关注仅适用于特定品牌牛奶或洗衣剂的规则,因此所有品牌名称都从购买中去除。这减少了杂货商品的种类,使其更易于管理,共有 169 种类型,使用广泛的类别,如鸡肉、冷冻食品、人造黄油和汽水。
如果你希望识别高度具体的关联规则——例如,客户是否更喜欢葡萄或草莓果酱与花生酱搭配——你需要大量的交易数据。大型连锁零售商使用数百万笔交易的数据库,以找到特定品牌、颜色或口味之间的关联。
你对哪些类型的商品可能一起购买有什么猜测吗?葡萄酒和奶酪会是常见的搭配吗?面包和黄油?茶和蜂蜜?让我们深入挖掘这些数据,看看这些猜测是否可以得到证实。
第 2 步 – 探索和准备数据
事务数据存储的格式与我们之前使用的略有不同。我们之前的大多数分析都使用了矩阵格式,其中行表示示例实例,列表示特征。如第一章,介绍机器学习中所述,在矩阵格式中,所有示例必须具有完全相同的特征集。
相比之下,交易数据更为自由形式。通常,数据中的每一行指定一个单独的例子——在本例中,是一个交易。然而,与具有固定数量的特征不同,每条记录包含一个由逗号分隔的项目列表,数量从一到多个。本质上,特征可能因例子而异。
要跟随这个分析,请从本章的 Packt Publishing GitHub 仓库下载 groceries.csv 文件,并将其保存在您的 R 工作目录中。
原始 groceries.csv 文件的头五行如下:
citrus fruit,semi-finished bread,margarine,ready soups
tropical fruit,yogurt,coffee
whole milk
pip fruit,yogurt,cream cheese,meat spreads
other vegetables,whole milk,condensed milk,long life bakery product
这些行表示五个不同的杂货店交易。第一个交易包含四个项目:柑橘水果、半成品面包、黄油和即食汤品。相比之下,第三个交易只包含一个项目:全脂牛奶。
假设我们尝试使用与先前分析中相同的 read.csv() 函数加载数据。R 会欣然同意,并将数据以矩阵格式读入数据框,如下所示:

图 8.3:将交易数据作为数据框读入 R 将会在以后造成问题
您会注意到 R 创建了四个列来存储交易数据中的项目:V1、V2、V3 和 V4。虽然这看起来似乎是合理的,但如果我们以这种形式使用数据,我们以后会遇到问题。R 选择创建四个变量,因为第一行恰好有四个由逗号分隔的值。然而,我们知道杂货购买可能包含超过四个项目;在四列设计中,这样的交易将在矩阵的多个行中拆分。我们可以尝试通过将项目数量最多的交易放在文件顶部来纠正这个问题,但这忽略了另一个更成问题的问题。
通过这种方式结构化数据,R 构建了一套特征,不仅记录了交易中的项目,还记录了它们出现的顺序。如果我们把我们的学习算法想象成试图在 V1、V2、V3 和 V4 之间找到关系,那么 V1 中的 whole milk 项目可能与 V2 中出现的 whole milk 项目被不同对待。相反,我们需要一个数据集,它不将交易视为需要(或不需)用特定项目填充(或不填充)的位置集合,而将其视为一个包含或不包含每个项目的市场篮子。
数据准备 – 为交易数据创建稀疏矩阵
这个问题的解决方案使用了一种称为稀疏矩阵的数据结构。你可能记得我们在第四章,概率学习 - 使用朴素贝叶斯进行分类中使用了稀疏矩阵来处理文本数据。就像先前的数据集一样,稀疏矩阵中的每一行都表示一个事务。然而,稀疏矩阵为可能出现在某人购物袋中的每个商品都有一个列(即特征)。由于我们的杂货店数据中有 169 种不同的商品,我们的稀疏矩阵将包含 169 列。
为什么不就像我们之前的大多数分析那样将其存储为数据框呢?原因是随着更多的事务和商品的添加,传统的数据结构很快就会变得太大,无法适应可用的内存。即使使用这里相对较小的交易数据集,矩阵也包含近 170 万个单元格,其中大多数包含零(因此得名“稀疏”矩阵——非零值非常少)。
由于存储所有这些零没有好处,稀疏矩阵实际上并不在内存中存储完整的矩阵;它只存储被商品占用的单元格。这使得结构比同等大小的矩阵或数据框更节省内存。
为了从事务数据中创建稀疏矩阵数据结构,我们可以使用arules(关联规则)包提供的功能。使用install.packages("arules")和library(arules)命令安装并加载该包。
关于arules包的更多信息,请参阅arules - A Computational Environment for Mining Association Rules and Frequent Item Sets, Hahsler, M., Gruen, B., Hornik, K., Journal of Statistical Software, 2005, Vol. 14。
由于我们正在加载事务数据,我们不能简单地使用之前使用的read.csv()函数。相反,arules提供了一个read.transactions()函数,它类似于read.csv(),但区别在于它生成一个适合事务数据的稀疏矩阵。参数sep = ","指定输入文件中的项由逗号分隔。要将groceries.csv数据读取到名为groceries的稀疏矩阵中,请输入以下行:
> groceries <- read.transactions("groceries.csv", sep = ",")
要查看我们刚刚创建的groceries矩阵的一些基本信息,请在对象上使用summary()函数:
> summary(groceries)
transactions as itemMatrix in sparse format with
9835 rows (elements/itemsets/transactions) and
169 columns (items) and a density of 0.02609146
输出的第一块信息提供了我们创建的稀疏矩阵的摘要。输出9835 rows指的是事务数量,169 columns表示可能出现在某人购物篮中的 169 种不同商品。矩阵中的每个单元格如果是为相应事务购买的商品,则为1,否则为0。
密度值 0.02609146(2.6%)指的是非零矩阵单元格的比例。由于矩阵中有 9,835 * 169 = 1,662,115 个位置,我们可以计算出在商店运营的 30 天内总共购买了 1,662,115 * 0.02609146 = 43,367 件商品(不考虑可能购买相同商品的重复)。通过额外的步骤,我们可以确定平均交易包含 43,367 / 9,835 = 4.409 种不同的杂货商品。当然,如果我们进一步查看输出,我们会看到每笔交易的商品数量的平均值已经提供。
下一个 summary() 输出块列出了在交易数据中最常出现的商品。由于 2,513 / 9,835 = 0.2555,我们可以确定全脂牛奶出现在 25.6%的交易中。
其他蔬菜、面包卷/面包、汽水和酸奶构成了其他常见商品的列表,如下所示:
most frequent items:
whole milk other vegetables rolls/buns
2513 1903 1809
soda yogurt (Other)
1715 1372 34055
我们还提供了一组关于交易大小的统计数据。共有 2,159 笔交易只包含一个商品,而有一笔交易包含 32 个商品。第一四分位数和中位数购买量分别是两个和三个商品,这意味着 25%的交易包含两个或更少的商品,大约一半的交易包含三个或更少的商品。每笔交易 4.409 个商品的均值与我们手工计算出的值相匹配:
element (itemset/transaction) length distribution:
sizes
1 2 3 4 5 6 7 8 9 10 11 12
2159 1643 1299 1005 855 645 545 438 350 246 182 117
13 14 15 16 17 18 19 20 21 22 23 24
78 77 55 46 29 14 14 9 11 4 6 1
26 27 28 29 32
1 1 1 3 1
Min. 1st Qu. Median Mean 3rd Qu. Max.
1.000 2.000 3.000 4.409 6.000 32.000
最后,输出底部包括与商品矩阵可能关联的任何元数据的附加信息,例如商品层次结构或标签。我们没有使用这些高级功能,但输出仍然表明数据有标签。read.transactions() 函数在加载时自动使用原始 CSV 文件中的商品名称添加了这些标签,前三个标签(按字母顺序排列)如下所示:
includes extended item information – examples:
labels
1 abrasive cleaner
2 artif. Sweetener
3 baby cosmetics
注意,arules 包内部使用没有与真实世界中的商品关联的数字项 ID 来表示商品。默认情况下,大多数 arules 函数将使用商品标签解码这些数字。然而,为了说明数字 ID,我们可以使用所谓的“长”格式来检查前两个交易,而不进行解码。在长格式交易数据中,每一行代表单个交易中的一个单独商品,而不是每一行代表一个包含多个商品的单独交易。例如,因为第一个和第二个交易分别有四个和三个商品,所以长格式用七行来表示这些交易:
> head(toLongFormat(groceries, decode = FALSE), n = 7)
TID item
1 1 30
2 1 89
3 1 119
4 1 133
5 2 34
6 2 158
7 2 168
在这种交易数据的表示中,名为 TID 的列指的是交易 ID——即第一个或第二个购物篮,而名为 item 的列指的是分配给商品的内部 ID 号。由于第一个交易包含 {柑橘类水果、黄油、即食汤和半成品面包},我们可以假设商品 ID 30 指的是 柑橘类水果,而 89 指的是 黄油。
当然,arules包包括用于以更直观的格式检查交易数据的特性。要查看稀疏矩阵的内容,请使用inspect()函数与 R 的向量运算符结合。以下是如何查看前五笔交易的示例:
> inspect(groceries[1:5])
items
[1] {citrus fruit,
margarine,
ready soups,
semi-finished bread}
[2] {coffee,
tropical fruit,
yogurt}
[3] {whole milk}
[4] {cream cheese,
meat spreads,
pip fruit,
yogurt}
[5] {condensed milk,
long life bakery product,
other vegetables,
whole milk}
当使用inspect()函数格式化时,数据看起来与我们之前在原始 CSV 文件中看到的数据没有太大区别。
由于groceries对象存储为稀疏项矩阵,可以使用[行,列]表示法来检查所需的项以及所需的交易。使用此功能与itemFrequency()函数结合,我们可以看到包含指定项的所有交易的比例。例如,要查看所有行中前三个项的支持水平,请使用以下命令:
> itemFrequency(groceries[, 1:3])
abrasive cleaner artif. sweetener baby cosmetics
0.0035587189 0.0032536858 0.0006100661
注意到稀疏矩阵中的项按字母顺序排列在列中。磨料清洁剂和人造甜味剂在约 0.3%的交易中找到,而婴儿化妆品在约 0.06%的交易中找到。
可视化项支持 - 项频率图
要将这些统计数据可视化,请使用itemFrequencyPlot()函数。这会创建一个条形图,表示包含指定项的交易比例。由于交易数据包含大量项,你通常需要限制图表中出现的项,以便生成可读的图表。
如果你希望显示出现在交易中比例最小的项,请使用带有support参数的itemFrequencyPlot()函数:
> itemFrequencyPlot(groceries, support = 0.1)
如以下图表所示,这会产生一个直方图,显示了groceries数据中至少有 10%支持的八个项:

图 8.4:至少在 10%的交易中所有杂货项的支持水平
如果你希望将图表限制在特定数量的项上,请使用带有topN参数的函数:
> itemFrequencyPlot(groceries, topN = 20)
然后直方图按支持度递减排序,如下图中groceries数据的前 20 项所示:

图 8.5:前 20 个杂货项的支持水平
可视化交易数据 - 绘制稀疏矩阵
除了查看特定项之外,还可以使用image()函数从整体上获得稀疏矩阵的鸟瞰图。当然,由于矩阵本身非常大,通常最好请求整个矩阵的一个子集。显示前五笔交易的稀疏矩阵的命令如下:
> image(groceries[1:5])
生成的图表显示了一个 5 行 169 列的矩阵,表示我们请求的 5 笔交易和 169 个可能的项。矩阵中的单元格在交易(行)中购买项(列)时填充为黑色。

图 8.6:前五笔交易的稀疏矩阵可视化
虽然图 8.6 很小,可能稍微难以阅读,但您可以看到第一、第四和第五笔交易各包含四个项目,因为它们的行有四个单元格被填充。在图表的右侧,您还可以看到第三行和第五行,以及第二行和第四行共享一个共同的项目。
这种可视化可以是一个有用的工具来探索交易数据。首先,它可能有助于识别潜在的数据问题。完全填满的列可能表明在每次交易中都购买的项目——这可能是由于零售商的名称或识别号码意外包含在交易数据集中而引起的问题。
此外,图中的模式可能有助于揭示有趣的交易和项目段,尤其是如果数据以有趣的方式排序。例如,如果交易按日期排序,黑色点的模式可能揭示购买数量或类型的季节性影响。也许在圣诞节或光明节期间,玩具更为常见;在万圣节期间,糖果可能变得流行。如果项目也被分类排序,这种类型的可视化可能特别强大。然而,在大多数情况下,图表看起来相当随机,就像电视屏幕上的静电一样。
请记住,这种可视化对于非常大的交易数据库可能不太有用,因为单元格太小,无法辨别。不过,通过结合sample()函数,您可以查看随机采样交易集的稀疏矩阵。创建 100 笔随机交易的选择命令如下:
> image(sample(groceries, 100))
这创建了一个 100 行 169 列的矩阵图:

图 8.7:100 笔随机选择交易的稀疏矩阵可视化
几列似乎相当密集,表明商店中一些非常受欢迎的商品。然而,点的分布总体上似乎相当随机。如果没有其他值得注意的事项,让我们继续我们的分析。
第 3 步 – 在数据上训练模型
数据准备完成后,我们现在可以着手寻找购物车项目之间的关联。我们将使用我们一直在使用的arules包中的 Apriori 算法实现来探索和准备groceries数据。如果您还没有安装和加载此包,您需要这样做。
下表显示了使用apriori()函数创建规则集的语法:

图 8.8:Apriori 关联规则学习语法
虽然运行apriori()函数很简单,但有时为了找到产生合理数量关联规则的支持和置信度参数,可能需要进行相当多的试错。如果你将这些级别设置得太高,那么你可能会找不到任何规则,或者可能会找到过于通用的规则,不太有用。另一方面,如果阈值设置得太低,可能会导致规则数量过多。更糟糕的是,操作可能会花费很长时间,或者在学习阶段耗尽内存。
在groceries数据上,使用默认的support = 0.1和confidence = 0.8设置导致令人失望的结果。虽然为了简洁省略了完整的输出,但最终结果是零规则集:
> apriori(groceries)
...
set of 0 rules
显然,我们需要稍微扩大搜索范围。
如果你仔细想想,这个结果不应该令人特别惊讶。因为默认的support = 0.1,为了生成一个规则,一个商品必须至少出现在0.1 * 9,385 = 938.5次交易中。由于在我们的数据中只有八个商品这么频繁地出现,难怪我们没有找到任何规则。
解决设置最小支持度问题的一种方法是想一下在利益相关者认为一个模式有趣之前需要的最小交易数量。例如,可以争论如果一项商品每天被购买两次(在一个月的数据中大约是 60 次),那么它可能很重要。从那里,可以计算出找到至少那么多交易的规则所需的支持水平。由于 60 除以 9,835 大约等于 0.006,我们将首先尝试将支持度设置为那里。
设置最低置信度需要微妙的平衡。一方面,如果置信度太低,我们可能会被许多不可靠的规则淹没——例如,几十条规则表明面包和电池等商品偶然经常一起购买。那么我们如何知道在哪里投放我们的广告预算呢?另一方面,如果我们设置置信度太高,那么我们将局限于明显或不可避免的规则——比如烟雾探测器总是与电池一起购买的事实。在这种情况下,将烟雾探测器移近电池不太可能产生额外的收入,因为这两个商品几乎总是一起购买。
适当的最低置信度水平在很大程度上取决于你分析的目标。如果你从一个保守的值开始,如果你没有找到可操作的信息,你可以总是将其降低以扩大搜索范围。
我们将从一个置信度阈值为 0.25 开始,这意味着为了被包含在结果中,规则至少必须有 25%的时间是正确的。这将消除最不可靠的规则,同时为我们留出一些空间,通过有针对性的促销来修改行为。
现在我们准备生成一些规则。除了最小支持和置信度参数外,设置minlen = 2以消除包含少于两个项目的规则是有帮助的。这可以防止仅因为项目经常被购买而创建无趣的规则,例如,{} => whole milk。此规则满足最小支持和置信度,因为全脂牛奶在超过 25%的交易中被购买,但这并不是一个非常有用的洞察。
使用 Apriori 算法查找关联规则的全命令如下:
> groceryrules <- apriori(groceries, parameter = list(support =
0.006, confidence = 0.25, minlen = 2))
输出的前几行描述了我们指定的参数设置,以及一些保持默认设置的参数;有关这些参数的定义,请使用?APparameter帮助命令。第二组行显示了幕后算法控制参数,这些参数对于处理更大的数据集可能很有帮助,因为它们控制着计算权衡,如优化速度或内存使用。有关这些参数的信息,请使用?APcontrol帮助命令:
Apriori
Parameter specification:
confidence minval smax arem aval originalSupport maxtime support
0.25 0.1 1 none FALSE TRUE 5 0.006
minlen maxlen target ext
2 10 rules TRUE
Algorithmic control:
filter tree heap memopt load sort verbose
0.1 TRUE TRUE FALSE TRUE 2 TRUE
接下来,输出包括关于 Apriori 算法执行步骤的信息:
Absolute minimum support count: 59
set item appearances ...[0 item(s)] done [0.00s].
set transactions ...[169 item(s), 9835 transaction(s)] done [0.00s].
sorting and recoding items ... [109 item(s)] done [0.00s].
creating transaction tree ... done [0.00s].
checking subsets of size 1 2 3 4 done [0.00s].
writing ... [463 rule(s)] done [0.00s].
creating S4 object ... done [0.00s].
由于事务数据集规模较小,大多数行显示的步骤几乎不需要时间运行——在此输出中用[0.00s]表示,但你的输出可能因计算机性能而略有不同。
绝对最小支持计数指的是满足我们指定的支持阈值 0.006 所需的最小交易计数。由于0.006 * 9,835 = 59.01,算法要求项目至少出现在 59 个交易中。检查大小为 1 2 3 4 的子集输出表明算法在停止迭代过程并写入最终的 463 条规则之前测试了 1、2、3 和 4 个项目的-i 项集。
apriori()函数的最终结果是规则对象,我们可以通过输入其名称来查看:
> groceryrules
set of 463 rules
我们的groceryrules对象包含大量关联规则!为了确定其中是否有任何有用的规则,我们还需要深入研究。
步骤 4 – 评估模型性能
为了获得关联规则的高级概述,我们可以使用summary()如下。规则长度分布告诉我们有多少规则具有每个项目计数。在我们的规则集中,150 条规则只有两个项目,而 297 条规则有三个,16 条规则有四个。与此分布相关的摘要统计信息也提供在输出的前几行:
> summary(groceryrules)
set of 463 rules
rule length distribution (lhs + rhs):sizes
2 3 4
150 297 16
Min. 1st Qu. Median Mean 3rd Qu. Max.
2.000 2.000 3.000 2.711 3.000 4.000
如前一个输出所示,规则的尺寸是规则左侧(lhs)和右侧(rhs)的总和。这意味着像{bread} => {butter}这样的规则包含两个项目,而{peanut butter, jelly} => {bread}则包含三个。
接下来,我们看到规则质量度量指标的摘要统计信息,包括支持和置信度,以及覆盖率、提升度和计数:
summary of quality measures:
support confidence coverage
Min. :0.006101 Min. :0.2500 Min. :0.009964
1st Qu.:0.007117 1st Qu.:0.2971 1st Qu.:0.018709
Median :0.008744 Median :0.3554 Median :0.024809
Mean :0.011539 Mean :0.3786 Mean :0.032608
3rd Qu.:0.012303 3rd Qu.:0.4495 3rd Qu.:0.035892
Max. :0.074835 Max. :0.6600 Max. :0.255516
lift count
Min. :0.9932 Min. : 60.0
1st Qu.:1.6229 1st Qu.: 70.0
Median :1.9332 Median : 86.0
Mean :2.0351 Mean :113.5
3rd Qu.:2.3565 3rd Qu.:121.0
Max. :3.9565 Max. :736.0
支持和置信度度量不应该非常令人惊讶,因为我们已经将这些作为规则选择标准。如果我们发现大多数或所有规则的支持和置信度都非常接近最小阈值,我们可能会感到惊讶,因为这意味着我们可能把门槛设得太高。但这里并非如此,因为有许多规则的支持值和置信度都远高于这个值。
计数和覆盖度度量与支持度和置信度密切相关。在这里定义的计数是支持度度量的分子,或者是包含该商品的交易数量(而不是比例)。由于绝对最小支持度计数为 59,因此观察到的最小计数为 60,这与参数设置非常接近并不令人惊讶。最大计数为 736 表明,该商品出现在 9,835 笔交易中的 736 笔;这与观察到的最大支持度相关,即736 / 9,835 = 0.074835。
关联规则的覆盖度简单地是规则左侧的支持度,但它有一个有用的现实世界解释:它可以理解为规则在数据集中随机选择的任何给定交易中应用的概率。因此,最小的覆盖度为 0.009964 表明,最不适用规则的覆盖范围仅占交易的大约 1%;最大的覆盖度为 0.255516 表明,至少有一个规则覆盖了超过 25%的交易。当然,这个规则涉及到全脂牛奶,因为它是唯一出现在如此多交易中的商品。
最后一列是我们尚未考虑的度量。规则的提升度衡量的是,在已知另一个商品或商品集已被购买的情况下,一个商品或商品集相对于其典型购买率的购买可能性。这由以下方程定义:

与置信度不同,其中项目顺序很重要,提升(X
Y)与提升(Y
X)相同。
例如,假设在一家杂货店,大多数人会购买牛奶和面包。仅凭运气,我们预计会发现许多同时购买牛奶和面包的交易。然而,如果提升(milk
bread)大于 1,这表明这两个商品比仅凭运气更频繁地一起出现。换句话说,购买其中一个商品的人更有可能购买另一个商品。因此,一个大的提升值是规则重要的强烈指标,反映了商品之间的真实联系,并且该规则对商业用途是有用的。然而,请注意,这仅适用于足够大的交易数据集;对于支持度低的商品,提升值可能会被夸大。
apriori包的一对作者提出了新的度量标准,称为超提升和超置信度,以解决这些度量标准在稀疏数据中的不足。更多信息,请参阅M. Hahsler 和 K. Hornik,关联规则的新概率兴趣度量(2018). arxiv.org/pdf/0803.0966.pdf。
在summary()输出的最后部分,我们收到挖掘信息,告诉我们规则是如何被选择的。在这里,我们看到groceries数据,其中包含 9,835 笔交易,被用来构建最小支持度为 0.006 和最小置信度为 0.25 的规则:
mining info:
data transactions support confidence
groceries 9835 0.006 0.25
我们可以使用inspect()函数查看具体的规则。例如,groceryrules对象中的前三条规则可以如下查看:
> inspect(groceryrules[1:3])
lhs rhs support
[1] {potted plants} => {whole milk} 0.006914082
[2] {pasta} => {whole milk} 0.006100661
[3] {herbs} => {root vegetables} 0.007015760
confidence coverage lift count
[1] 0.4000000 0.01728521 1.565460 68
[2] 0.4054054 0.01504830 1.586614 60
[3] 0.4312500 0.01626843 3.956477 69
第一条规则可以用普通语言读作:“如果一个顾客购买了盆栽植物,他们也会购买全脂牛奶。”支持度约为 0.007,置信度为 0.400,我们可以确定这条规则覆盖了大约 0.7%的交易,并且在涉及盆栽植物的 40%的购买中是正确的。提升值告诉我们,在顾客购买了盆栽植物的情况下,他们购买全脂牛奶的可能性相对于平均顾客要高多少。由于我们知道大约 25.6%的顾客购买了全脂牛奶(support),而 40%购买盆栽植物的顾客购买了全脂牛奶(confidence),我们可以计算出提升值为0.40 / 0.256 = 1.56,这与显示的值相匹配。
注意,标有support的列表示规则的支撑度,而不是lhs或rhs单独的支撑度。标有coverage的列是左侧的支撑度。
尽管置信度和提升度都很高,但{盆栽植物}
{全脂牛奶}看起来像一条非常有用的规则吗?可能不是,因为没有明显的逻辑原因说明为什么有人会更有可能和盆栽植物一起购买牛奶。然而,我们的数据表明情况并非如此。我们如何理解这一事实?
一种常见的方法是将关联规则分为以下三个类别:
-
可执行
-
琐碎
-
无法解释
显然,市场篮子分析的目标是找到可执行的规则,这些规则提供了清晰且有趣的见解。有些规则是清晰的,有些是有趣的;同时具备这两个因素的规则较为罕见。
所说的琐碎规则包括任何如此明显以至于不值得提及的规则——它们是清晰的,但并不有趣。假设你是一名营销顾问,被支付大笔金钱来识别跨推广商品的新机会。如果你报告的发现是{纸尿布}
{配方},你可能不会被邀请回来进行另一项咨询工作。
简单的规则也可能伪装成更有趣的结果。例如,如果你发现某种儿童谷物品牌与一部流行的动画片之间存在关联。如果这部电影的主要角色出现在谷物盒的正面,这个发现就不是很具有洞察力。
如果项目之间的联系如此不清楚,以至于无法或几乎无法弄清楚如何使用这些信息,则规则是无法解释的。该规则可能仅仅是数据中的随机模式,例如,一条声称{腌黄瓜}与{巧克力冰淇淋}之间有关系的规则可能仅是由于一位孕妇妻子对奇怪食物组合有定期渴望的单一客户。
最好的规则是隐藏的宝石——一旦被发现,似乎就显而易见的未发现见解。如果时间足够,可以评估每一条规则以找到这些宝石。然而,从事分析的数据科学家可能不是判断规则是否具有可操作性、平凡或无法解释的最佳评判者。因此,更好的规则很可能是通过与负责管理零售连锁店的领域专家合作而产生的,他们可以帮助解释这些发现。在下一节中,我们将通过采用排序和导出学习规则的方法来促进这种共享,以便最有趣的结果浮出水面。
第 5 步 – 提高模型性能
主题专家可能能够非常快速地识别出有用的规则,但要求他们评估数百或数千条规则则是对他们时间的低效利用。因此,能够根据不同的标准对规则进行排序,并以可以与营销团队共享并深入审查的形式从 R 中提取它们,是非常有用的。这样,我们可以通过使结果更具可操作性来提高我们规则的表现力。
如果你遇到内存限制问题,或者 Apriori 运行时间过长,也可以通过使用更近期的算法来提高关联规则挖掘过程的计算性能。
对关联规则集进行排序
根据市场篮子分析的目标,最有用的规则可能是那些具有最高支持度、置信度或提升度的规则。arules包与 R 的sort()函数一起工作,允许重新排序规则列表,使得具有最高或最低质量度量值的规则排在最前面。
为了重新排序groceryrules对象,我们可以使用sort()函数,同时指定by参数的值为"support"、"confidence"或"lift"。通过将排序与向量运算符结合使用,我们可以获得特定数量的有趣规则。例如,可以使用以下命令检查根据lift统计量得出的最佳五条规则:
> inspect(sort(groceryrules, by = "lift")[1:5])
输出如下:
lhs rhs support
[1] {herbs} => {root vegetables} 0.007015760
[2] {berries} => {whipped/sour cream} 0.009049314
[3] {other vegetables,
tropical fruit,
whole milk} => {root vegetables} 0.007015760
[4] {beef,
other vegetables} => {root vegetables} 0.007930859
[5] {other vegetables,
tropical fruit} => {pip fruit} 0.009456024
confidence coverage lift count
[1] 0.4312500 0.01626843 3.956477 69
[2] 0.2721713 0.03324860 3.796886 89
[3] 0.4107143 0.01708185 3.768074 69
[4] 0.4020619 0.01972547 3.688692 78
[5] 0.2634561 0.03589222 3.482649 93
这些规则似乎比我们之前看到的规则更有趣。第一条规则,其 lift 值约为 3.96,意味着购买香草的人比典型客户更有可能购买根类蔬菜,可能是为了某种炖菜。第二条规则也很有趣。与其他购物车相比,奶油在装有浆果的购物车中出现的可能性超过三倍,这可能表明是一种甜点搭配。
默认情况下,排序顺序是降序,这意味着最大的值排在前面。要反转此顺序,添加一个额外的参数,decreasing = FALSE。
考虑关联规则的子集
假设,根据前面的规则,营销团队对创建广告推广浆果的可能性感到兴奋,因为浆果现在正值季节。然而,在最终确定活动之前,他们要求你调查浆果是否经常与其他商品一起购买。为了回答这个问题,我们需要找到所有包含浆果的规则。
subset() 函数提供了一种搜索事务、项目或规则子集的方法。要使用它来查找规则中包含浆果的任何规则,请使用以下命令。这将把规则存储在一个名为 berryrules 的新对象中:
> berryrules <- subset(groceryrules, items %in% "berries")
我们可以像之前处理较大集合那样检查规则:
> inspect(berryrules)
结果是以下规则集:
lhs rhs support
[1] {berries} => {whipped/sour cream} 0.009049314
[2] {berries} => {yogurt} 0.010574479
[3] {berries} => {other vegetables} 0.010269446
[4] {berries} => {whole milk} 0.011794611
confidence coverage lift count
[1] 0.2721713 0.0332486 3.796886 89
[2] 0.3180428 0.0332486 2.279848 104
[3] 0.3088685 0.0332486 1.596280 101
[4] 0.3547401 0.0332486 1.388328 116
有四个涉及浆果的规则,其中两个似乎足够有趣,可以被称为可执行的。除了奶油,浆果也经常与酸奶一起购买——这种搭配可以作为早餐或午餐,以及甜点。
subset() 函数非常强大。选择子集的标准可以用几个关键词和操作符来定义:
-
关键词
items,如前所述,匹配规则中出现的任何项目。要限制子集只匹配左侧或右侧,请使用lhs或rhs。 -
操作符
%in%表示至少有一个项目必须出现在你定义的列表中。如果你想找到匹配“浆果”或“酸奶”的任何规则,你可以写items %in% c("berries", "yogurt")。 -
可用其他操作符进行部分匹配 (
%pin%) 和完全匹配 (%ain%)。部分匹配允许你使用一个搜索找到柑橘类水果和热带水果:items %pin% "fruit"。完全匹配要求所有列出的项目都必须存在。例如,items %ain% c("berries", "yogurt")仅找到包含berries和yogurt的规则。 -
子集也可以通过
support、confidence或lift来限制。例如,confidence > 0.50将规则限制在置信度大于 50%的规则。 -
匹配标准可以与标准的 R 逻辑操作符(如 AND (
&), OR (|), 和 NOT (!)) 结合使用。
使用这些选项,你可以将规则的选取限制得尽可能具体或一般。
将关联规则保存到文件或数据框
要分享您的购物篮分析结果,您可以使用write()函数将规则保存到 CSV 文件。这将生成一个 CSV 文件,可以在大多数电子表格程序中使用,包括 Microsoft Excel:
> write(groceryrules, file = "groceryrules.csv",
sep = ",", quote = TRUE, row.names = FALSE)
有时,将规则转换为 R 数据框也很方便。这可以通过使用as()函数实现,如下所示:
> groceryrules_df <- as(groceryrules, "data.frame")
这将创建一个包含规则字符格式和数据框的规则,以及支持度、置信度、覆盖度、提升度和计数等数值向量:
> str(groceryrules_df)
'data.frame': 463 obs. of 6 variables:
$ rules : chr "{potted plants} => {whole milk}" "{pasta} => {whole milk}" "{herbs} => {root vegetables}" "{herbs} => {other vegetables}" ...
$ support : num 0.00691 0.0061 0.00702 0.00773 0.00773 ...
$ confidence: num 0.4 0.405 0.431 0.475 0.475 ...
$ coverage : num 0.0173 0.015 0.0163 0.0163 0.0163 ...
$ lift : num 1.57 1.59 3.96 2.45 1.86 ...
$ count : int 68 60 69 76 76 69 70 67 63 88 ...
将规则保存到数据框中可能很有用,如果您想对规则进行额外的处理或需要将它们导出到另一个数据库。
使用 Eclat 算法提高效率
Eclat 算法,该算法以“等价类项集聚类和自底向上的格遍历”方法命名,是一种稍微现代且实质上更快的关联规则学习算法。虽然实现细节超出了本书的范围,但它可以理解为 Apriori 的近亲;它也假设所有频繁项集的子集也是频繁的。然而,Eclat 通过利用提供识别潜在最大频繁项集的快捷方式的巧妙技巧,能够搜索更少的子集。由于 Apriori 在深入搜索之前先进行广泛搜索,因此它是一种广度优先算法,而 Eclat 被认为是一种深度优先算法,因为它深入到底端并只搜索所需的宽度。对于某些用例,这可能导致性能提升一个数量级并减少内存消耗。
关于 Eclat 的更多信息,请参阅快速发现关联规则的新算法,Zaki, M. J., Parthasarathy, S., Ogihara, M., Li, W., KDD-97 会议论文集,1997。
与 Eclat 的快速搜索相比,一个关键的权衡是它跳过了 Apriori 中计算置信度的阶段。它假设一旦获得了具有高支持度的项集,就可以在以后识别最有用的关联——无论是通过主观的目测,还是通过另一轮处理来计算置信度和提升度等指标。尽管如此,arules包使得应用 Eclat 与 Apriori 一样简单,尽管在处理过程中增加了额外的一步。
我们从eclat()函数开始,并将support参数设置为之前相同的 0.006;然而,请注意,在这个阶段并未设置置信度:
> groceryitemsets_eclat <- eclat(groceries, support = 0.006)
这里省略了一些输出,但最后几行与我们从apriori()函数获得的输出类似,关键区别在于写入了 747 个项集而不是 463 条规则:
Absolute minimum support count: 59
create itemset …
set transactions …[169 item(s), 9835 transaction(s)] done [0.00s].
sorting and recoding items … [109 item(s)] done [0.00s].
creating sparse bit matrix … [109 row(s), 9835 column(s)] done [0.00s].
writing … [747 set(s)] done [0.02s].
Creating S4 object … done [0.00s].
生成的 Eclat 项集对象可以使用inspect()函数,就像我们使用 Apriori 规则对象一样。以下命令显示了前五个项集:
> inspect(groceryitemsets_eclat[1:5])
items support count
[1] {potted plants, whole milk} 0.006914082 68
[2] {pasta, whole milk} 0.006100661 60
[3] {herbs, whole milk} 0.007727504 76
[4] {herbs, other vegetables} 0.007727504 76
[5] {herbs, root vegetables} 0.007015760 69
要从项集中生成规则,请使用 ruleInduction() 函数,并设置所需的 confidence 参数值,如下所示:
> groceryrules_eclat <- ruleInduction(groceryitemsets_eclat,
confidence = 0.25)
将 support 和 confidence 设置为之前的值 0.006 和 0.25,Eclat 算法产生了与 Apriori 相同的 463 条规则,这并不令人惊讶:
> groceryrules_eclat
set of 463 rules
结果的规则对象可以像之前一样进行检查:
> inspect(groceryrules_eclat[1:5])
lhs rhs support
[1] {potted plants} => {whole milk} 0.006914082
[2] {pasta} => {whole milk} 0.006100661
[3] {herbs} => {whole milk} 0.007727504
[4] {herbs} => {other vegetables} 0.007727504
[5] {herbs} => {root vegetables} 0.007015760
confidence lift
[1] 0.4000000 1.565460
[2] 0.4054054 1.586614
[3] 0.4750000 1.858983
[4] 0.4750000 2.454874
[5] 0.4312500 3.956477
由于两种方法都易于使用,如果你有一个非常大的交易数据集,那么在较小的随机交易样本上测试 Eclat 和 Apriori 算法可能值得,以查看哪一个表现更好。
摘要
关联规则用于发现大型零售商的大量交易数据库中的洞察。作为一个无监督学习过程,关联规则学习器能够从大型数据库中提取知识,而无需任何关于要寻找的模式的先验知识。难点在于需要付出一些努力,将丰富的信息减少到更小、更易于管理的结果集。我们在本章研究的 Apriori 算法通过设置最小有趣性阈值,并仅报告满足这些标准的关联来实现这一点。
我们在为一家规模适中的超市进行一个月的交易市场篮子分析时使用了 Apriori 算法。即使在这样一个小例子中,也发现了大量的关联。在这些关联中,我们注意到了一些可能对未来的营销活动有用的模式。我们应用的方法在规模大得多的零售商处使用,其数据库规模是这个大小的多倍,也可以应用于零售环境之外的项目。
在下一章中,我们将检查另一个无监督学习算法。就像关联规则一样,它的目的是在数据中找到模式。但与寻求相关项目或特征的关联规则不同,下一章中的方法关注于在示例之间找到联系和关系。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人见面,并在以下地方与超过 4000 人一起学习:

第九章:寻找数据组——使用 k-means 进行聚类
你是否曾经花时间观察过人群?如果是的话,你可能已经看到了一些反复出现的个性特征。也许某种类型的人,通过一套新熨烫的西装和公文包,可以代表“肥猫”商业高管。一个二十多岁穿着紧身牛仔裤、法兰绒衬衫和太阳镜的人可能被称为“嬉皮士”,而一个从微型货车卸下孩子的女人可能被贴上“足球妈妈”的标签。
当然,将这些类型的刻板印象应用于个人是危险的,因为没有人是完全相同的。然而,如果将其理解为描述集体的一种方式,这些标签就能捕捉到群体中个体之间共享的一些潜在相似性。
如你很快就会了解到的那样,聚类或发现数据中的模式这一行为,与发现人群中的模式并没有太大的区别。本章将描述:
-
聚类任务与我们之前考察的分类任务的不同之处
-
聚类如何定义一个组以及这些组如何通过 k-means,一种经典且易于理解的聚类算法来识别
-
将聚类应用于识别青少年社交媒体用户中营销细分市场的实际任务所需的步骤
在采取行动之前,我们将首先深入探讨聚类究竟意味着什么。
理解聚类
聚类是一种无监督的机器学习任务,它自动将数据划分为簇,或相似项的组。它这样做,而无需事先被告知这些组应该如何看起来。因为我们没有告诉机器我们具体在寻找什么,所以聚类用于知识发现而不是预测。它提供了对数据中自然分组洞察。
没有关于构成簇的先进知识,计算机怎么可能知道一个组在哪里结束,另一个组在哪里开始呢?答案是简单的:聚类是由这样一个原则指导的,即簇内的项目应该彼此非常相似,但与簇外的项目非常不同。相似性的定义可能因应用而异,但基本思想始终相同:将数据分组,使得相关元素放在一起。
然后可以使用这些簇进行行动。例如,你可能会发现聚类方法被用于以下应用:
-
将客户分成具有相似人口统计或购买模式的小组,以进行定向营销活动
-
通过识别使用模式落在已知簇之外的模式来检测异常行为,例如未经授权的网络入侵
-
通过创建少量类别来简化极其“宽”的数据集——那些具有大量特征的数据集——以描述具有相对同质特征值的行
总体而言,当可以用更少的组来代表多样化和多变的数据时,聚类是有用的。它产生了有意义的可操作数据结构,减少了复杂性,并提供了对关系模式的洞察。
聚类作为机器学习任务
聚类与我们迄今为止考察的分类、数值预测和模式检测任务有所不同。在这些任务中,目标是建立一个将特征与结果相关联的模型,或者将一些特征与另一些特征相关联。这些任务中的每一个都描述了数据中的现有模式。相比之下,聚类的目标是创建新的数据。在聚类中,未标记的示例被赋予一个新的聚类标签,这个标签完全是从数据中的关系推断出来的。因此,有时你会看到聚类任务被称为无监督分类,因为在某种程度上,它对未标记的示例进行了分类。
但问题是,从无监督分类器获得的类别标签没有内在的意义。聚类会告诉你哪些示例组紧密相关——例如,它可能会返回组 A、B 和 C——但具体应用一个有意义的可操作标签,以及讲述“为什么 A 与 B 不同”的故事,取决于你。为了了解这如何影响聚类任务,让我们考虑一个简单的假设例子。
假设你正在组织一个关于数据科学的会议。为了促进专业网络和协作,你计划根据他们的研究专长将人们安排在三个桌子中的一张。不幸的是,在发出会议邀请后,你意识到你忘记包括一个调查,询问与会者希望坐在哪个学科组内。
在一次灵光一闪中,你意识到你可能能够通过检查每位学者的出版物历史来推断他们的研究专长。为此,你开始收集每位与会者在计算机科学相关期刊上发表的文章数量以及发表在数学或统计学相关期刊上的文章数量。使用为学者收集的数据,你创建了一个散点图:

图 9.1:通过数学和计算机科学出版物数据可视化学者
如预期的那样,似乎存在一种模式。我们可能会猜测左上角,代表那些有众多计算机科学出版物但数学文章很少的人,是一群计算机科学家。按照这个逻辑,右下角可能是一群数学家或统计学家。同样,右上角,那些既有数学又有计算机科学经验的人,可能是机器学习专家。
应用这些标签会产生以下可视化效果:

图 9.2:可以根据对每组学者的假设来识别集群
我们的分组是通过视觉形成的;我们只是将紧密聚集的数据点识别为集群。然而,尽管看似明显的分组,如果没有亲自询问每位学者的学术专长,我们就无法知道这些群体是否真正同质。标签是关于每个群体中人的类型的定性、假设性判断,基于有限的一组定量数据。
与主观定义群体边界相比,使用机器学习来客观地定义它们会更好。鉴于前一个图中的轴平行分割,我们的问题似乎是一个明显的决策树应用,如第五章,分而治之 – 使用决策树和规则进行分类中所述。这将为我们提供一个干净的规则,例如:“如果一个学者数学出版物很少,那么他们是计算机科学专家。”不幸的是,这个计划有问题。没有每个点的真实类别值数据,监督学习算法就无法学习这样的模式,因为它无法知道哪些分割会产生同质群体。
与监督学习相反,聚类算法使用的过程与我们通过视觉检查散点图所做的过程非常相似。使用示例之间关系的度量,可以识别出同质群体。在下一节中,我们将开始探讨聚类算法是如何实现的。
这个例子突出了聚类的一个有趣应用。如果您从未标记的数据开始,可以使用聚类来创建类标签。从那里,您可以应用像决策树这样的监督学习器来找到这些类别的最重要的预测因子。这是第一章,介绍机器学习中描述的半监督学习的一个例子。
聚类算法的集群
正如构建预测模型有许多方法一样,执行聚类描述性任务的方法也有很多。许多此类方法列在以下网站上,CRAN 聚类任务视图:cran.r-project.org/view=Cluster。在这里,您可以找到用于在数据中发现自然分组的众多 R 包。不同的算法主要根据两个特征来区分:
-
相似度度量,它提供了两个示例之间关系的定量度量
-
聚合函数,它控制着根据示例之间的相似性将示例分配到集群的过程
尽管这些方法之间可能存在细微的差异,但它们当然可以以各种方式聚类。存在多种这样的类型,但一个简单的三部分框架有助于理解主要区别。使用这种方法,从最简单到最复杂,以下是聚类算法的三个主要类别:
-
层次方法,这些方法创建了一种家族树式的层次结构,将最相似的示例在图结构中放置得更近。
-
基于划分的方法,这些方法将示例视为多维空间中的点,并试图找到这个空间中的边界,以形成相对同质的小组。
-
基于模型或密度的方法,这些方法依赖于统计原理和/或点的密度来发现簇之间的模糊边界;在某些情况下,示例可能部分分配到多个簇中,甚至没有任何簇。
尽管层次聚类是这些方法中最简单的,但它并非没有两个有趣的优点。首先,它产生了一种称为树状图的层次图可视化,它描绘了示例之间的关联,使得最相似的示例在层次结构中位置更近。
这可以是一个有用的工具,用来理解哪些示例和示例的子集是最紧密地分组的。其次,层次聚类不需要预先定义数据集中存在多少簇的期望。相反,它实施了一个过程,在这个过程中,在一种极端情况下,每个示例都包含在一个包含所有其他示例的单个大簇中;在另一种极端情况下,每个示例都发现自己在一个只包含它自己的小簇中;在两者之间,示例可能包含在其他大小不一的簇中。
图 9.3 展示了一个包含八个示例的简单数据集的假设树状图,这些示例分别标记为 A 到 H。请注意,最相关的示例(通过x轴上的邻近性表示)在图中被更紧密地连接,作为兄弟姐妹。例如,示例 D 和 E 是最相似的,因此它们是最先被分组的。然而,所有八个示例最终都会连接到一个大的簇中,或者可能包含在任何数量的簇之间。在树状图的横向不同位置切割,会创建不同数量的簇,如图所示为三个和五个簇:

图 9.3:层次聚类产生一个树状图,描绘了所需簇数的自然分组。
层次聚类的树状图可以使用“自下而上”或“自上而下”的方法生成。前者称为聚合聚类,它从每个示例自己的簇开始,然后首先连接最相似的示例,直到所有示例都连接到一个单独的簇中。后者称为分裂聚类,它从一个大型簇开始,以所有示例都在自己的单独簇中结束。
在将示例连接到示例组时,可以使用不同的度量标准,例如示例与组中最相似、最不相似或平均成员的相似度。一种更复杂的度量标准称为沃德方法,它不使用示例之间的相似度,而是考虑簇同质性的度量来构建链接。无论如何,结果是旨在将最相似的示例分组到任意数量的子组中的层次结构。
层次聚类技术的灵活性是以计算复杂性为代价的,这是由于需要计算每个示例与其他每个示例之间的相似性。随着示例数量(N)的增长,计算数量会增长到 N*N = N²,存储结果的相似性矩阵所需的内存也会增加。因此,层次聚类仅用于非常小的数据集,本章没有演示。然而,R 的stats包中包含的hclust()函数提供了一个简单的实现,该实现默认与 R 一起安装。
分裂聚类的巧妙实现可能比聚合聚类在计算上稍微高效一些,因为算法可能会在不需要创建更多簇的情况下提前停止。尽管如此,聚合聚类和分裂聚类都是“贪婪”算法的例子,正如在第五章“分而治之——使用决策树和规则进行分类”中定义的那样,因为它们基于“先来先服务”的原则使用数据,因此不能保证为给定的数据集产生整体最优的簇集。
基于划分的聚类方法在效率上具有比层次聚类明显的优势,因为它们通过应用启发式方法将数据划分为集群,而不需要评估每对示例之间的相似性。我们将在稍后更详细地探讨一种广泛使用的基于划分的方法,但就目前而言,只需了解这种方法关注的是寻找集群之间的边界,而不是将示例相互连接——这种方法需要远少于示例之间的比较。这种启发式方法在计算上可能非常高效,但有一个缺点是,在分组分配方面可能有些僵硬甚至任意。例如,如果请求五个集群,它将示例划分为所有五个集群;如果某些示例位于两个集群之间的边界上,这些示例将被随意但坚定地放入一个集群或另一个集群。同样,如果四个或六个集群可能更好地分割数据,这不会像层次聚类树状图那样明显。
更复杂的基于模型和密度聚类方法通过估计示例属于每个集群的概率来解决了一些这些不灵活的问题,而不是仅仅将其分配到单个集群。其中一些可能允许集群边界遵循在数据中识别出的自然模式,而不是强制在组之间进行严格的划分。基于模型的方法通常假设一个统计分布,认为示例是从该分布中抽取的。
其中一种方法,称为混合建模,试图解开由从统计分布混合中抽取的示例组成的集合数据——通常是高斯分布(正态钟形曲线)。例如,想象你有一个由混合男性和女性音域的语音数据组成的集合数据,如图图 9.4所示(请注意,分布是假设的,并非基于现实世界的数据)。尽管两者之间有一些重叠,但平均而言,男性的音域通常低于女性的音域。

图 9.4:混合建模为每个示例分配属于潜在分布之一的概率
考虑到未标记的整体分布(图的下部),混合模型能够为任何给定示例属于男性集群或女性集群的概率分配一个概率,令人难以置信的是,它从未在图的上部单独对男声或女声进行过训练!这是通过发现最有可能生成观察到的整体分布的统计参数,如均值和标准差,在假设涉及特定数量的不同分布的情况下实现的——在这种情况下,是两个高斯分布。
作为一种无监督方法,混合模型将无法知道左边的分布是男性,而右边的分布是女性,但一个人类观察者比较记录时,如果左簇中男性出现的可能性高于右簇,这将是显而易见的。这种技术的缺点是,它不仅需要了解涉及多少分布,还需要假设分布的类型。这可能对许多实际聚类任务来说过于僵化。
另一种名为DBSCAN的强大聚类技术,其命名来源于它所使用的“基于密度的空间聚类应用噪声”方法,该方法用于在数据中识别自然簇。这项获奖技术极其灵活,在处理许多聚类挑战方面表现良好,例如适应数据集的自然簇数量、对簇之间的边界灵活处理,以及不对数据进行特定的统计分布假设。
虽然实现细节超出了本书的范围,但 DBSCAN 算法可以直观地理解为创建一个过程,该过程为簇中的示例创建邻域,这些示例都在给定半径内。在指定半径内预定义的核心点形成初始簇核,然后位于任何核心点指定半径内的点被添加到簇中,并构成簇的最外层边界。与许多其他聚类算法不同,一些示例可能根本不会被分配到任何簇中,因为任何距离核心点不够近的点将被视为噪声。
尽管 DBSCAN 强大且灵活,但可能需要实验来优化参数以适应数据,例如构成核心点的数量或点之间的允许半径,这增加了机器学习项目的时间复杂度。当然,仅仅因为基于模型的方法更复杂,并不意味着它们适合每个聚类项目。正如我们将在本章剩余部分看到的那样,一个简单的基于分区的方法在具有挑战性的实际聚类任务上可以表现得非常出色。
尽管混合模型和 DBSCAN 在本章中没有演示,但有一些 R 包可以用来将这些方法应用于您自己的数据。mclust包可以将模型拟合到高斯分布的混合,而dbscan包提供了 DBSCAN 算法的快速实现。
k-means 聚类算法
k-means 算法可能是最常使用的聚类方法,并且是分区聚类方法的一个例子。经过几十年的研究,它成为了许多更复杂聚类技术的基础。如果你理解它使用的简单原则,你将拥有理解今天使用的几乎所有聚类算法所需的知识。
随着 k-means 算法随着时间的推移而发展,出现了许多算法的实现。一种早期的方法在《k-means 聚类算法,Hartigan, J.A., Wong, M.A., 应用统计学,1979,第 28 卷,第 100-108 页》中进行了描述。
尽管自 k-means 算法诞生以来聚类方法已经发展,但这并不意味着 k-means 已经过时。事实上,这个方法可能比以往任何时候都更受欢迎。以下表格列出了 k-means 仍然被广泛使用的一些原因:
| 优点 | 缺点 |
|---|
|
-
使用可以非统计术语解释的简单原则
-
非常灵活,可以通过简单的调整来应对许多其不足之处
-
在许多实际应用场景下表现良好
|
-
不像更现代的聚类算法那样复杂
-
由于它使用随机性的元素,不能保证找到最优的聚类集
-
需要对数据中自然存在的聚类数量进行合理的猜测
-
不适合非球形聚类或密度差异很大的聚类
|
如果你熟悉 k-means 这个名字,你可能是在回忆第三章中提出的k 近邻算法(k-NN)。正如你很快就会看到的,k-means 与 k-NN 的共同之处不仅仅在于字母 k。
k-means 算法将每个n个示例分配给k个聚类中的一个,其中k是一个事先确定的数字。目标是使每个聚类内示例的特征值差异最小化,并使聚类之间的差异最大化。
除非k和n非常小,否则无法计算所有可能的示例组合的最优聚类。相反,算法使用一种启发式过程来找到局部最优解。简单来说,这意味着它从一个初始的聚类分配猜测开始,然后稍微修改分配以查看这些变化是否改善了聚类内的同质性。
我们将在稍后深入探讨这个过程,但算法本质上涉及两个阶段。首先,它将示例分配给一组初始的k个聚类。然后,根据当前属于聚类的示例调整聚类边界来更新分配。这个过程会多次更新和分配,直到不再通过改变来改善聚类拟合。此时,过程停止,聚类被最终确定。
由于 k-means 的启发式性质,您可能只需对起始条件进行轻微的改变就会得到不同的结果。如果结果差异很大,这可能表明存在问题。例如,数据可能没有自然的分组,或者k的值选择不当。考虑到这一点,尝试多次进行聚类分析以测试您发现结果的稳健性是个好主意。
为了了解分配和更新过程在实际中的工作方式,让我们回顾一下假设的数据科学会议案例。虽然这是一个简单的例子,但它将说明 k-means 在底层是如何工作的。
使用距离分配和更新聚类
与 k-NN 一样,k-means 将特征值视为多维特征空间中的坐标。对于会议数据,只有两个特征,因此我们可以将特征空间表示为之前描述的两个维度的散点图。
k-means 算法首先在特征空间中选择k个点作为聚类中心。这些中心是推动剩余示例归位的催化剂。通常,这些点是通过从训练数据集中选择k个随机示例来选择的。因为我们希望识别三个聚类,所以使用这种方法,k = 3个点将被随机选择。
这些点在图 9.5中由星号、三角形和菱形表示:

图 9.5:k-means 聚类算法首先通过选择 k 个随机聚类中心开始
值得注意的是,尽管前面图中三个聚类中心恰好分布得很远,但这并不总是必然的情况。因为起始点是随机选择的,三个中心也可能只是三个相邻的点。结合 k-means 算法对聚类中心起始位置高度敏感的事实,一组好的或坏的初始聚类中心可能会对最终的聚类集产生重大影响。
为了解决这个问题,k-means 可以被修改为使用不同的方法来选择初始中心。例如,一个变体选择在特征空间中任何地方出现的随机值,而不是仅从数据中观察到的值中选择。另一个选项是完全跳过这一步;通过随机将每个示例分配给一个聚类,算法可以立即跳到更新阶段。这些方法中的每一种都会给最终的聚类集添加特定的偏差,您可能可以利用这些偏差来改进您的结果。
在 2007 年,引入了一种名为k-means++的算法,它提出了一种选择初始簇中心的不同方法。它声称这是一种更有效的方法,可以在减少随机机会影响的同时,更接近最优聚类解决方案。更多信息,请参阅《k-means++:谨慎播种的优势,Arthur, D, Vassilvitskii, S, 第十八届 ACM-SIAM 离散算法年度会议论文集,2007 年,第 1,027–1,035 页》。
在选择初始簇中心之后,其他示例根据距离函数分配到最近的簇中心,该距离函数用作相似性度量。你可能还记得,我们在学习 k-NN 监督学习算法时使用了距离函数作为相似性度量。像 k-NN 一样,k-means 传统上使用欧几里得距离,但如果需要,也可以使用其他距离函数。
有趣的是,任何返回相似性数值度量的函数都可以用来代替传统的距离函数。事实上,k-means 甚至可以通过使用测量图像或文本对相似性的函数来适应聚类图像或文本文档。
要应用距离函数,请记住,如果n表示特征的数量,那么示例x和示例y之间的欧几里得距离的公式如下:

例如,为了比较一个有五个计算机科学出版物和一个数学出版物的访客与一个没有计算机科学论文但有两位数学论文的访客,我们可以在 R 中这样计算:
> sqrt((5 - 0)² + (1 - 2)²)
[1] 5.09902
使用这种方式的距离函数,我们可以找到每个示例与每个簇中心的距离。然后,每个示例被分配到最近的簇中心。
请记住,因为我们使用距离计算,所以所有特征都需要是数值的,并且应该在事先将值归一化到标准范围内。第三章中提出的《懒惰学习 – 使用最近邻进行分类》方法将有助于这项任务。
如以下图所示,三个簇中心将示例划分为三个分区,分别标记为Cluster A、Cluster B和Cluster C。虚线表示由簇中心创建的Voronoi 图的边界。Voronoi 图表示比其他簇中心更接近一个簇中心的区域;所有三个边界相交的顶点是离所有三个簇中心的最大距离。
使用这些边界,我们可以轻松地看到每个初始 k-means 种子的所声称的区域:

图 9.6:初始簇中心创建了三个“最近”点的三组
现在初始分配阶段已经完成,k-means 算法进入更新阶段。更新簇的第一步是将初始中心移动到新的位置,称为质心,它是当前分配给该簇的点的平均位置。以下图示说明了随着簇中心移动到新的质心,Voronoi 图中的边界也移动,一个曾经位于簇 B(由箭头指示)的点被添加到簇 A:

图 9.7:更新阶段移动簇中心,导致一个点的重新分配
由于这次重新分配,k-means 算法将继续通过另一个更新阶段。在移动簇质心、更新簇边界并将点重新分配到新的簇(如箭头所示)之后,图看起来是这样的:

图 9.8:在另一次更新后,另外两个点被重新分配到最近的簇中心
由于另外两个点被重新分配,必须进行另一次更新,这将移动质心并更新簇边界。然而,因为这些变化没有导致重新分配,k-means 算法停止。簇分配现在是最终的:

图 9.9:更新阶段没有导致新的簇分配后,聚类停止
最终的簇可以通过两种方式之一进行报告。首先,你可能只是简单地报告每个示例的 A、B 或 C 簇的分配。或者,你可以在最终更新后报告簇质心的坐标。
无论是采用哪种报告方法,你都可以计算另一种方法;你可以使用每个簇示例的坐标来计算质心,或者你可以使用质心坐标来将每个示例分配到其最近的簇中心。
选择合适的簇数量
在 k-means 的介绍中,我们了解到该算法对随机选择的簇中心很敏感。确实,如果我们之前在示例中选择了不同的三个起始点组合,我们可能会找到与我们预期不同的数据分割的簇。同样,k-means 对簇的数量也很敏感;选择需要微妙的平衡。将k设置得非常大可以提高簇的同质性,同时它也冒着过度拟合数据的风险。
理想情况下,你将拥有关于真实分组的前置知识(即先验信念),并可以应用这些信息来选择簇的数量。例如,如果你对电影进行聚类,你可能首先将k设置为考虑的奥斯卡奖项类别数量。在我们之前解决的数据科学会议座位问题中,k可能反映了受邀者所属的学术研究领域数量。
有时,簇的数量是由业务需求或分析的动机决定的。例如,会议室中的桌子数量可能决定了从数据科学参会者名单中应该创建多少组人。将这个想法扩展到另一个业务案例,如果营销部门只有资源创建三个不同的广告活动,那么将k = 3分配所有潜在客户到三个吸引之一可能是有意义的。
如果没有任何先验知识,一个经验法则建议将k设置为(n / 2)的平方根,其中n是数据集中示例的数量。然而,这个经验法则很可能会导致大型数据集簇的数量过多。幸运的是,还有其他定量方法可以帮助找到合适的 k-means 簇集。
一种称为肘部方法的技术试图评估簇内同质性和异质性与k的不同值如何变化。如图中所示,随着额外簇的增加,簇内的同质性预计会增加;同样,簇内的异质性应该随着簇的增加而减少。因为你可能会继续看到改进,直到每个示例都在自己的簇中,所以目标不是无限期地最大化同质性或最小化异质性,而是找到k,这样在该值之后就没有递减的回报。这个k值被称为肘点,因为它像人手臂的肘关节一样弯曲。

图 9.10:肘部是增加k导致相对较小改进的点
有许多用于测量簇内同质性和异质性的统计数据,可以与肘部方法(以下信息框提供了更多细节的引用)一起使用。然而,在实践中,并不总是可行地迭代测试大量k值。这部分是因为聚类大型数据集可能相当耗时;重复聚类数据甚至更糟。此外,需要精确最优簇集集的应用很少。在大多数聚类应用中,基于便利性选择k值就足够了,而不是创建最同质簇的值。
对于大量聚类性能指标的全面回顾,请参阅 《聚类验证技术》,Halkidi, M, Batistakis, Y, Vazirgiannis, M, 智能信息系统杂志,2001 年,第 17 卷,第 107-145 页。
设置 k 的过程本身有时可以带来有趣的洞察。通过观察随着 k 的变化聚类特征如何变化,人们可以推断数据自然定义的边界在哪里。更加紧密聚类的群体变化很小,而不够同质的群体则会在一段时间内形成和解散。
通常来说,花费少量时间去担心如何精确地确定 k 可能是明智的。接下来的例子将展示即使是来自好莱坞电影的一点点主题知识,也可以用来设定 k,从而找到可操作且有趣的聚类。由于聚类是无监督的,这项任务实际上取决于你如何利用它;价值在于你从算法的发现中获得的洞察。
使用 k-means 聚类寻找青少年市场细分
与 Facebook、TikTok 和 Instagram 等社交网络服务(SNS)上的朋友互动,已成为全球青少年的一种仪式。这些青少年拥有相当可观的零花钱,因此他们成为了希望销售零食、饮料、电子产品、娱乐和卫生用品的企业所渴望的目标群体。
使用这些网站的数百万青少年消费者吸引了那些在日益竞争激烈的市场中寻找优势的营销人员的注意。获得这种优势的一种方式是识别具有相似口味的青少年群体,这样客户就可以避免向对所售产品不感兴趣的青少年投放广告。如果向 1,000 名网站访客展示一次广告的成本是 10 美元——这是一种每印象成本的衡量标准——如果我们对目标受众进行选择,广告预算将会更加充裕。例如,运动服装的广告应该针对更有可能对运动感兴趣的个体群体。
通过分析青少年的 SNS 帖子文本,我们可以识别出具有共同兴趣的群体,如体育、宗教或音乐。聚类可以自动化发现该人群自然段落的流程。然而,我们将决定这些聚类是否有趣以及如何用于广告。让我们从头到尾尝试这个过程。
第 1 步 – 收集数据
在这次分析中,我们将使用一个数据集,它代表 2006 年在一家知名 SNS 上有资料的 30,000 名美国高中学生的随机样本。为了保护用户的匿名性,SNS 将不会被命名。然而,在数据收集时,该 SNS 是美国青少年非常受欢迎的网站。因此,可以合理地假设这些资料代表了 2006 年美国青少年的广泛横截面。
我在圣母大学进行自己的青少年身份社会学研究时编制了这个数据集。如果你用于研究目的,请引用这本书的章节。完整的数据库可以在本书的 Packt Publishing GitHub 存储库中找到,文件名为snsdata.csv。为了互动式地跟随,本章假设你已经将此文件保存到你的 R 工作目录中。
数据在四个高中毕业年份(2006 年至 2009 年)之间均匀采样,代表当时的数据收集时的大学一年级、二年级、三年级和四年级学生。使用自动网络爬虫下载了 SNS 个人资料的全文,并记录了每个青少年的性别、年龄和 SNS 朋友数量。
使用文本挖掘工具将剩余的 SNS 页面内容划分为单词。从所有页面中出现的最顶部的 500 个单词中,选择了 36 个单词来代表五个兴趣类别:课外活动、时尚、宗教、浪漫和反社会行为。这 36 个单词包括诸如足球、性感、亲吻、圣经、购物、死亡和毒品等术语。最终的数据库表明,对于每个人,每个单词在个人的 SNS 资料中出现的次数。
第 2 步 – 探索和准备数据
我们将使用read.csv()来加载数据集并将字符数据转换为因子类型:
> teens <- read.csv("snsdata.csv", stringsAsFactors = TRUE)
让我们也快速看一下数据的详细信息。str()输出的前几行如下所示:
> str(teens)
'data.frame': 30000 obs. of 40 variables:
$ gradyear : int 2006 2006 2006 2006 2006 2006 2006 ...
$ gender : Factor w/ 2 levels "F","M": 2 1 2 1 NA 1 1 2 ...
$ age : num 19 18.8 18.3 18.9 19 ...
$ friends : int 7 0 69 0 10 142 72 17 52 39 ...
$ basketball : int 0 0 0 0 0 0 0 0 0 0 ...
正如我们所预期的,数据包括 30,000 名青少年,其中四个变量表示个人特征,36 个单词表示兴趣。
你注意到gender行周围有什么奇怪的地方吗?如果你仔细观察,你可能已经注意到那个NA值,它与1和2值相比显得格格不入。NA是 R 告诉我们记录有一个缺失值的方式——我们不知道这个人的性别。到目前为止,我们还没有处理缺失数据,但它可能对许多类型的分析是一个重大问题。
让我们看看这个问题有多严重。一个选项是使用table()命令,如下所示:
> table(teens$gender)
F M
22054 5222
虽然这告诉我们有多少个F和M值存在,但table()函数排除了NA值,而不是将其作为一个单独的分类处理。要包括NA值(如果有的话),我们只需要添加一个额外的参数:
> table(teens$gender, useNA = "ifany")
F M <NA>
22054 5222 2724
在这里,我们看到有 2,724 条记录(九%)有缺失的性别数据。有趣的是,SNS 数据中女性的数量是男性的四倍多,这表明男性不像女性那样倾向于使用这个社交媒体网站。
如果你检查数据框中的其他变量,你会发现除了gender之外,只有age有缺失值。对于数值特征,summary()函数的默认输出包括NA值的计数:
> summary(teens$age)
Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
3.086 16.310 17.290 17.990 18.260 106.900 5086
总共有 5,086 条记录(17%)有缺失的年龄。另外,最小值和最大值似乎不合理;一个三岁的孩子或一个 106 岁的孩子上高中是不太可能的。为了确保这些极端值不会对分析造成问题,我们将在继续之前清理它们。
高中生可能更合理的年龄范围包括那些至少 13 岁且未满 20 岁的人。任何超出这个范围的年龄值应被视为缺失数据——我们无法相信提供的年龄。为了重新编码age变量,我们可以使用ifelse()函数,如果年龄至少为 13 岁且小于 20 岁,则将teen$age赋予原始值;否则,它将接收值NA:
> teens$age <- ifelse(teens$age >= 13 & teens$age < 20,
teens$age, NA)
通过重新检查summary()输出,我们看到范围现在遵循的分布看起来更像是实际的高中:
> summary(teens$age)
Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
13.03 16.30 17.27 17.25 18.22 20.00 5523
不幸的是,现在我们创建了一个更大的缺失数据问题。在我们继续分析之前,我们需要找到处理这些值的方法。
数据准备 - 虚拟编码缺失值
处理缺失值的一个简单方法是不包含任何缺失值的记录。然而,如果你考虑这种做法的后果,你可能在做之前会三思而后行——仅仅因为它容易并不意味着这是一个好主意!这种方法的问题在于,即使缺失并不广泛,你也很容易排除大量数据。
例如,假设在我们的数据中,性别为NA的人与缺失年龄数据的人完全不同。这表明,通过排除缺失性别或年龄的人,你会排除9% + 17% = 26%的数据,或者超过 7,500 条记录。而且这仅仅是两个变量的缺失数据!数据集中缺失值的数量越多,任何给定记录被排除的可能性就越大。很快,你将只剩下一个非常小的数据子集,或者更糟糕的是,剩余的记录将系统性地不同或不能代表整个总体。
对于性别等分类数据,一个替代方案是将缺失值视为一个单独的分类。例如,我们不仅可以限制为女性和男性,还可以添加一个未知性别的额外分类。这使我们能够利用虚拟编码,这在第三章,懒惰学习 - 使用最近邻进行分类中已有介绍。
如果你记得,虚拟编码涉及为每个名义特征的每个级别创建一个单独的二进制(1 或 0)值虚拟变量,除了一个作为参考组保留的外。一个类别可以排除的原因是,其状态可以从其他类别中推断出来。例如,如果某人既不是女性也不是未知性别,那么他们一定是男性。因此,在这种情况下,我们只需要为女性和未知性别创建虚拟变量:
> teens$female <- ifelse(teens$gender == "F" &
!is.na(teens$gender), 1, 0)
> teens$no_gender <- ifelse(is.na(teens$gender), 1, 0)
如你所预期,is.na() 函数测试性别是否等于 NA。因此,第一个语句在性别等于 F 且性别不等于 NA 时将 teens$female 赋值为 1;否则,它赋值为 0。在第二个语句中,如果 is.na() 返回 TRUE,意味着性别缺失,那么 teens$no_gender 变量被赋值为 1;否则,它被赋值为 0。
为了确认我们工作正确,让我们将我们构建的虚拟变量与原始的 gender 变量进行比较:
> table(teens$gender, useNA = "ifany")
F M <NA>
22054 5222 2724
> table(teens$female, useNA = "ifany")
0 1
7946 22054
> table(teens$no_gender, useNA = "ifany")
0 1
27276 2724
对于 teens$female 和 teens$no_gender 的 1 值数量与 F 和 NA 值的数量相匹配,所以编码已经被正确执行。
数据准备 – 填充缺失值
接下来,让我们消除 5,523 个缺失的年龄。由于 age 是一个数值特征,为未知值创建一个额外的类别是没有意义的——你将如何将“未知”与其他年龄进行比较?相反,我们将使用一种称为插补的不同策略,它涉及用对真实值的猜测来填充缺失数据。
你能想到一种方法,我们可以利用 SNS 数据来对青少年的年龄做出有根据的猜测吗?如果你想到了使用毕业年份,你的想法是对的。在一个毕业班级中,大多数人都是在同一年出生的。如果我们能确定每个班级的典型年龄,那么我们就能对那个毕业年份的学生年龄有一个合理的估计。
找到一个典型值的一种方法是通过计算平均值或均值。如果我们尝试像之前分析那样应用 mean() 函数,会出现问题:
> mean(teens$age)
[1] NA
问题在于,对于包含缺失数据的向量,平均值是未定义的。由于我们的年龄数据包含缺失值,mean(teens$age) 返回一个缺失值。我们可以通过在计算平均值之前添加一个额外的 na.rm 参数来删除缺失值来纠正这个问题:
> mean(teens$age, na.rm = TRUE)
[1] 17.25243
这表明我们数据中的平均学生年龄大约是 17 岁。这只能让我们走了一半的路;我们实际上需要每个毕业年份的平均年龄。你可能会首先尝试计算四次平均值,但 R 的一个好处通常是有一个避免重复的方法。在这种情况下,aggregate() 函数就是这项工作的工具。它为数据的子组计算统计数据。在这里,它通过删除 NA 值来计算按毕业年份的平均年龄:
> aggregate(data = teens, age ~ gradyear, mean, na.rm = TRUE)
gradyear age
1 2006 18.65586
2 2007 17.70617
3 2008 16.76770
4 2009 15.81957
aggregate()的输出是一个数据框。这需要额外的工作才能将其合并回我们的原始数据。作为替代方案,我们可以使用ave()函数,该函数返回一个向量,其中每个组的平均值被重复,使得结果向量与原始向量长度相同。当aggregate()为每个毕业年份返回一个平均年龄(总共四个值)时,ave()函数为所有 30,000 名青少年返回一个值,反映该学生毕业年份的学生平均年龄(相同的四个值被重复以达到总共 30,000 个值)。
当使用ave()函数时,第一个参数是要计算组平均值的数值向量,第二个参数是提供组分配的类别向量,而FUN参数是要应用于数值向量的函数。在我们的情况下,我们需要定义一个新的函数,该函数在计算平均值时移除NA值。完整的命令如下:
> ave_age <- ave(teens$age, teens$gradyear, FUN =
function(x) mean(x, na.rm = TRUE))
为了将这些平均值填充到缺失值中,我们需要再调用一次ifelse()函数,仅当原始年龄值为NA时才使用ave_age值:
> teens$age <- ifelse(is.na(teens$age), ave_age, teens$age)
summary()的结果显示,缺失值现在已经消除:
> summary(teens$age)
Min. 1st Qu. Median Mean 3rd Qu. Max.
13.03 16.28 17.24 17.24 18.21 20.00
数据分析准备就绪后,我们就准备好深入这个项目的有趣部分了。让我们看看我们的努力是否得到了回报。
第 3 步 – 在数据上训练模型
为了将青少年聚类到营销细分市场,我们将使用stats包中的 k-means 实现,这应该默认包含在您的 R 安装中。尽管其他 R 包中提供了许多更复杂的 k-means 函数,但默认stats包中的kmeans()函数被广泛使用,并提供了简单而强大的算法实现。

图 9.11:K-means 聚类语法
kmeans()函数需要一个只包含数值数据的数据框或矩阵,以及指定k,即所需聚类数量的参数。如果您准备好了这两件事,构建模型的实际过程就很简单了。麻烦的是,选择正确的数据和聚类组合可能有点像艺术;有时需要大量的尝试和错误。
我们将开始我们的聚类分析,仅考虑 36 个特征,这些特征衡量了各种基于兴趣的关键词在青少年社交媒体个人资料文本中出现的次数。换句话说,我们不会基于年龄、毕业年份、性别或朋友数量进行聚类。当然,如果我们愿意,我们可以使用这四个特征,但我们选择不这样做,因为基于这些特征建立的任何聚类都比基于兴趣的聚类缺乏洞察力。这主要是因为年龄和性别已经是事实上的聚类,而基于兴趣的聚类在我们的数据中尚未被发现。其次,稍后更感兴趣的是看看兴趣聚类是否与聚类过程中保留的性别和受欢迎程度特征相关。如果基于兴趣的聚类可以预测这些个体特征,这提供了证据表明聚类可能是有用的。
为了避免意外包含其他特征,让我们创建一个名为 interests 的数据框,通过子集化数据框仅包含 36 个关键词列:
> interests <- teens[5:40]
如果你还记得 第三章,懒惰学习 – 使用最近邻进行分类,在执行任何使用距离计算的分析的任何分析之前,一个常见的做法是对特征进行归一化或 z 分数标准化,以便每个特征都利用相同的范围。通过这样做,你可以避免一个问题,即某些特征仅仅因为它们的值范围比其他特征大而主导。
z 分数标准化的过程重新调整特征,使它们具有均值为零和标准差为一。这种转换以可能在这里有用的方式改变了数据的解释。具体来说,如果有人在他们的个人资料中提到篮球三次,没有其他信息,我们无法知道这是否意味着他们比同龄人更喜欢篮球或更不喜欢篮球。另一方面,如果 z 分数是三,我们知道他们比平均青少年提到了篮球许多次。
要将 z 分数标准化应用于 interests 数据框,我们可以使用 scale() 函数结合 lapply()。由于 lapply() 返回一个列表对象,必须使用 as.data.frame() 函数将其转换回数据框形式,如下所示:
> interests_z <- as.data.frame(lapply(interests, scale))
为了确认转换是否正确,我们可以比较旧 interests 数据中 basketball 列的摘要统计:
> summary(interests$basketball)
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.0000 0.0000 0.0000 0.2673 0.0000 24.0000
> summary(interests_z$basketball)
Min. 1st Qu. Median Mean 3rd Qu. Max.
-0.3322 -0.3322 -0.3322 0.0000 -0.3322 29.4923
如预期的那样,interests_z 数据集将篮球特征转换成了均值为零,范围跨越零上和零下的值。现在,一个小于零的值可以解释为一个人在其个人资料中篮球提及次数少于平均水平。一个大于零的值则意味着这个人比平均水平更频繁地提及篮球。
我们最后的决定是决定使用多少个簇来分割数据。如果我们使用太多的簇,我们可能会发现它们过于具体而无法使用;相反,选择太少的簇可能会导致异质分组。你应该对实验k的值感到舒适。如果你不喜欢结果,你可以轻松尝试另一个值并重新开始。
如果你熟悉分析人群,选择簇的数量会更容易。对真实自然分组数量的直觉猜测可以节省一些尝试和错误。
为了帮助选择数据中的簇数量,我将参考我最喜欢的电影之一,《早餐俱乐部》,这是一部 1985 年上映的青春喜剧,由约翰·休斯执导。这部电影中的青少年角色根据以下五个身份进行自我描述:
-
一个大脑 - 也常被称为“书呆子”或“极客”
-
一个运动员 - 有时也被称为“运动健将”或“预备役”
-
一个篮子案 - 指的是焦虑或神经质的人的俚语术语,在电影中被描绘为一个反社会的局外人
-
一个公主 - 描绘为受欢迎、富有且具有刻板女性形象的女孩
-
一个罪犯 - 代表社会学研究中描述的传统“燃尽”身份,参与反学校和反权威的叛逆行为
尽管这部电影描绘了五个具体的身份群体,但它们已经在多年的流行青少年小说中被描述,尽管随着时间的推移,这些刻板印象已经发生了变化,但美国青少年可能仍然会本能地理解它们。因此,五个似乎是一个合理的起始点来选择k,尽管诚然,它不太可能捕捉到高中身份的全貌。
要使用 k-means 算法将青少年的兴趣数据划分为五个簇,我们使用kmeans()函数对interests数据框进行操作。请注意,由于 k-means 使用随机起始点,因此使用set.seed()函数以确保结果与以下示例中的输出相匹配。如果你还记得前面的章节,此命令初始化 R 的随机数生成器到一个特定的序列。如果没有这个语句,每次运行 k-means 算法时结果可能会变化。按照以下方式运行 k-means 聚类过程将创建一个名为teen_clusters的列表,该列表存储了五个簇的属性:
> set.seed(2345)
> teen_clusters <- kmeans(interests_z, 5)
让我们深入探讨一下,看看算法如何将青少年的兴趣数据进行了划分。
如果你发现你的结果与以下章节中显示的结果不同,请确保在运行kmeans()函数之前立即运行set.seed(2345)命令。此外,由于 R 的随机数生成器的行为随着 R 版本 3.6 而改变,如果你使用的是较旧的 R 版本,你的结果也可能与这里显示的结果略有不同。
第 4 步 – 评估模型性能
评估聚类结果可能具有一定的主观性。最终,模型的成功或失败取决于聚类是否适用于其预期目的。由于本分析的目标是识别具有相似兴趣的青少年聚类以用于营销目的,我们将主要从定性角度衡量我们的成功。对于其他聚类应用,可能需要更多定量成功的衡量标准。
评估一组聚类的有用性的最基本方法之一是检查每个组中落下的示例数量。如果某些组太大或太小,那么它们不太可能非常有用。
要获取kmeans()聚类的尺寸,只需检查teen_clusters$size组件,如下所示:
> teen_clusters$size
[1] 1038 601 4066 2696 21599
在这里,我们看到我们请求的五个聚类。最小的聚类有 601 名青少年(2%),而最大的有 21,599 名(72%)。尽管最大和最小聚类人数之间的差距略令人担忧,但如果不仔细检查这些组,我们不会知道这是否表明了问题。可能的情况是,聚类的尺寸差异表明了某些真实情况,例如一个拥有相似兴趣的大青少年群体,或者这可能是由初始 k-means 聚类中心引起的随机巧合。随着我们开始查看每个聚类的特征,我们将了解更多信息。
有时,k-means 可能会找到极小的聚类——有时小到只有一个点。这可能发生在初始聚类中心之一恰好落在远离其他数据的异常值上。是否将此类小型聚类视为代表极端案例的真正发现,还是随机机会造成的问题,并不总是很清楚。如果你遇到这个问题,可能值得用不同的随机种子重新运行 k-means 算法,看看小型聚类是否对不同的起始点具有鲁棒性。
要更深入地了解聚类,我们可以检查聚类质心的坐标,使用teen_clusters$centers组件,以下是对前四个兴趣的说明:
> teen_clusters$centers
basketball football soccer softball
1 0.362160730 0.37985213 0.13734997 0.1272107
2 -0.094426312 0.06691768 -0.09956009 -0.0379725
3 0.003980104 0.09524062 0.05342109 -0.0496864
4 1.372334818 1.19570343 0.55621097 1.1304527
5 -0.186822093 -0.18729427 -0.08331351 -0.1368072
输出的行(标记为1到5)指的是五个聚类,而每行的数字表示该聚类对列顶部列出的兴趣的平均值。由于这些值是 z 分数标准化,正值表示所有青少年整体平均水平的上方,而负值表示整体平均水平的下方。
例如,第四行在“篮球”列中具有最高值,这意味着在所有聚类中,聚类4对篮球的平均兴趣最高。
通过检查集群是否在每个兴趣类别的平均水平之上或之下,我们可以发现区分集群之间的模式。在实践中,这涉及到打印集群中心,并搜索它们以寻找任何模式或极端值,就像一个数字搜索谜题,但使用的是数字。以下标注的屏幕截图显示了五个集群中的每个集群的突出模式,针对 36 个青少年兴趣中的 18 个:

图 9.12:为了区分集群,突出其质心的坐标中的模式可能很有帮助
给定这个兴趣数据的快照,我们已能推断出一些集群的特征。集群四在几乎所有运动项目上的兴趣水平都显著高于平均水平,这表明这可能是一个运动员群体,按照《早餐俱乐部》的刻板印象。集群三包括最多的拉拉队、舞蹈和“热”这个词的提及。这些是所谓的公主吗?
通过继续以这种方式检查这些集群,可以构建一个表格,列出每个群体的主导兴趣。在下面的表格中,每个集群都展示了使其与其他集群最不同的特征,以及似乎最能准确捕捉群体特征的《早餐俱乐部》身份。
有趣的是,集群五之所以与众不同,是因为它并不出众:其成员在所有测量的活动中兴趣水平都低于平均水平。它也是成员数量最多的单个最大群体。我们如何调和这些明显的矛盾?一个可能的解释是,这些用户在网站上创建了一个个人资料,但从未发布过任何兴趣。

图 9.13:可以使用表格列出每个集群的重要维度
当与利益相关者分享分割分析的结果时,应用易于记忆且信息丰富的标签,即所谓的角色,通常很有帮助,这些标签简化并捕捉了群体的本质,例如在此处应用的《早餐俱乐部》类型。添加此类标签的风险是,它们可能会掩盖群体的细微差别,甚至如果使用负面刻板印象,可能会冒犯群体成员。为了更广泛的传播,像“罪犯”和“公主”这样的挑衅性标签可能被更中性的术语如“叛逆青少年”和“时尚青少年”所取代。此外,因为即使是相对无害的标签也可能导致我们的思维产生偏见,如果标签被理解为整个真相而不是复杂性的简化,我们可能会错过重要的模式。
给出如图 9.13 所示的难忘标签和表格,营销主管会对社交网站上的五种青少年访问者类型有一个清晰的思维图像。基于这些人物角色,主管可以向与一个或多个聚类相关的产品相关的企业销售定向广告印象。在下一节中,我们将看到如何将聚类标签应用于原始人口以实现此类用途。
可以使用将多维特征数据展平为二维的技术来可视化聚类分析的结果,然后根据聚类分配给点着色。factoextra 包中的 fviz_cluster() 函数允许轻松构建此类可视化。如果您对此感兴趣,请加载该包并尝试以下命令以查看青少年 SNS 聚类的可视化:fviz_cluster(teen_clusters, interests_z, geom = "point")。尽管由于重叠点数量众多,这种视觉在 SNS 示例中用途有限,但有时它可以是演示目的的有用工具。为了更好地理解如何创建和理解这些图表,请参阅第十五章,利用大数据。
第 5 步 – 提高模型性能
由于聚类创建了新的信息,聚类算法的性能至少在一定程度上取决于聚类本身的质量以及如何使用这些信息。在前一节中,我们展示了五个聚类为青少年的兴趣提供了有用且新颖的见解。从这个角度来看,算法似乎表现相当好。因此,我们现在可以将精力集中在将这些见解转化为行动上。
我们将首先将聚类应用于完整的数据集。由 kmeans() 函数创建的 teen_clusters 对象包含一个名为 cluster 的组件,其中包含样本中所有 30,000 个个体的聚类分配。我们可以使用以下命令将其添加到 teens 数据框中:
> teens$cluster <- teen_clusters$cluster
给定这些新数据,我们可以开始检查聚类分配与个人特征之间的关系。例如,以下是 SNS 数据中前五个青少年的个人信息:
> teens[1:5, c("cluster", "gender", "age", "friends")]
cluster gender age friends
1 5 M 18.982 7
2 3 F 18.801 0
3 5 M 18.335 69
4 5 F 18.875 0
5 1 <NA> 18.995 10
使用 aggregate() 函数,我们还可以查看聚类的人口统计特征。平均年龄在各个聚类之间变化不大,这并不太令人惊讶,因为青少年的身份通常在高中之前就已经确立。这如下所示:
> aggregate(data = teens, age ~ cluster, mean)
cluster age
1 1 17.09319
2 2 17.38488
3 3 17.03773
4 4 17.03759
5 5 17.30265
另一方面,各个聚类中女性比例的差异相当显著。这是一个非常有趣的发现,因为我们没有使用性别数据来创建聚类,但聚类仍然可以预测性别:
> aggregate(data = teens, female ~ cluster, mean)
cluster female
1 1 0.8025048
2 2 0.7237937
3 3 0.8866208
4 4 0.6984421
5 5 0.7082735
回想一下,总体而言,大约 74%的社交网络用户是女性。第三组,所谓的“公主”,女性比例高达 89%,而第四组和第五组女性比例仅为大约 70%。这些差异表明,青少年男孩和女孩在社交网络页面讨论的兴趣存在差异。
由于我们在预测性别方面的成功,你可能会怀疑聚类也可以预测用户拥有的朋友数量。这个假设似乎得到了以下数据的支持:
> aggregate(data = teens, friends ~ cluster, mean)
cluster friends
1 1 30.66570
2 2 32.79368
3 3 38.54575
4 4 35.91728
5 5 27.79221
平均而言,“公主”拥有最多的朋友(38.5 人),其次是“运动员”(35.9 人)和“大脑”(32.8 人)。在低端是“罪犯”(30.7 人)和“疯子”(27.8 人)。与性别一样,考虑到我们没有将友谊数据作为聚类算法的输入,一个青少年的朋友数量与其预测的聚类之间的联系是显著的。同样有趣的是,朋友数量似乎与每个聚类的刻板印象中的高中受欢迎程度有关:那些刻板印象中受欢迎的群体在现实中往往有更多的朋友。
团体成员资格、性别和朋友的数量之间的关联表明,聚类可以作为行为的有用预测指标。以这种方式验证其预测能力可能会使得在向营销团队推销时,聚类分析结果更容易被接受,从而最终提高算法的性能。
正如《早餐俱乐部》中的角色最终意识到的那样,“我们每个人都是一个大脑、一个运动员、一个疯子、一个公主和一个罪犯”,数据科学家意识到我们分配给每个聚类的标签或角色是刻板印象,并且个体可能以不同程度体现这些刻板印象,这一点很重要。在采取聚类分析结果时,请记住这个警告;一个群体可能相对同质,但每个成员仍然是独一无二的。
摘要
我们的研究支持了那句流行的谚语:“物以类聚,人以群分。”通过使用机器学习方法将具有相似兴趣的青少年进行聚类,我们能够开发出青少年身份类型的分类,这些分类可以预测个人特征,如性别和朋友的数量。这些相同的方法可以应用于其他具有相似结果的环境中。
本章仅涵盖了聚类的基础知识。k-means 算法有许多变体,以及其他许多聚类算法,它们为任务带来了独特的偏见和启发式方法。基于本章的基础,你将能够理解这些聚类方法并将它们应用于新的问题。
在下一章中,我们将开始探讨适用于许多机器学习任务的测量学习算法成功的方法。虽然我们的过程一直致力于评估学习的成功,但为了获得最高程度的性能,能够以最严格的标准定义和衡量它是至关重要的。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人相聚,并与超过 4000 人一起学习:

第十章:评估模型性能
当只有富人才能负担得起教育时,考试和测试并不是用来评估学生的。相反,测试评估的是教师,以便父母了解他们的孩子是否学到了足够的知识来证明教师工资的合理性。显然,这与今天的情况不同。现在,这样的评估被用来区分表现优异和表现不佳的学生,将他们筛选到不同的职业和其他机会中。
由于这个过程的重要性,大量的努力被投入到开发准确的学业评估中。公平的评估包含大量的问题,覆盖广泛的主题,并奖励真正的知识而非幸运猜测。一个好的评估还要求学生思考他们以前从未面临过的问题。因此,正确的回答反映了更广泛地概括知识的能力。
评估机器学习算法的过程与评估学生的过程非常相似。由于算法具有不同的优势和劣势,测试应该区分学习者。了解学习者如何在未来的数据上表现也很重要。
本章提供了评估机器学习者的所需信息,例如:
-
为什么预测准确性不足以衡量性能,以及你可能使用的其他性能衡量指标
-
确保性能衡量指标合理反映模型预测或预测未见案例的能力的方法
-
如何使用 R 将这些更有用的衡量指标和方法应用于前面章节中涵盖的预测模型
正如学习一个主题的最佳方式是尝试向其他人教授它一样,教授和评估机器学习者的过程将使你对迄今为止学到的方法有更深入的了解。
测量分类性能
在前面的章节中,我们通过将正确预测的数量除以总预测数量来衡量分类器的准确性。这找到了学习者正确案例的比例,错误案例的比例直接得出。例如,假设一个分类器在 10,000 个案例中有 99,990 个正确预测了新生婴儿是否携带可治疗但可能致命的遗传缺陷。这将意味着准确率为 99.99%,错误率仅为 0.01%。
初看起来,这似乎是一个非常宝贵的分类器。然而,在将孩子的生命托付给测试之前收集更多信息是明智的。如果仅在每 10 万个婴儿中有 10 个发现遗传缺陷呢?一个总是预测无缺陷的测试在所有案例中都是正确的,但在最重要的案例中却是错误的。换句话说,尽管分类器非常准确,但它对预防可治疗的出生缺陷并不很有用。
这是类别不平衡问题的一个后果,它指的是数据中大多数记录属于单个类别时所带来的麻烦。
虽然有许多方法可以衡量分类器的性能,但最好的衡量标准始终是捕捉分类器是否在其预期目的上成功的标准。在定义性能指标时,以效用而不是原始准确率为准至关重要。为此,我们将探讨从混淆矩阵中衍生出的各种替代性能指标。然而,在我们开始之前,我们需要考虑如何为评估准备分类器。
理解分类器的预测
评估分类模型的目标是更好地理解其性能如何外推到未来的案例。由于通常在真实环境中测试未经证实的模型是不切实际的,我们通常通过要求模型对由类似未来将要求其执行的任务的案例组成的测试数据集中的案例进行分类来模拟未来条件。通过观察学习者的响应,我们可以了解其优势和劣势。
虽然我们在前面的章节中已经评估了分类器,但值得反思我们可用的数据类型:
-
实际的类别值
-
预测的类别值
-
预测的估计概率
实际和预测的类别值可能显而易见,但它们是评估的关键。就像老师使用答案键——一个正确答案的列表——来评估学生的答案一样,我们需要知道机器学习者的预测的正确答案。目标是维护两个数据向量:一个包含正确或实际类别值,另一个包含预测类别值。这两个向量必须存储相同数量的值,并且顺序相同。预测和实际值可以存储为单独的 R 向量,或者作为单个 R 数据框中的列。
获取这些数据很容易。实际的类别值直接来自测试数据集中的目标。预测的类别值是从基于训练数据构建的分类器中获得的,然后将它应用于测试数据。对于大多数机器学习包来说,这涉及到对一个模型对象和一个测试数据框应用predict()函数,例如predictions <- predict(model, test_data)。
到目前为止,我们只使用这两个数据向量来检查分类预测,但大多数模型可以提供另一条有用的信息。尽管分类器对每个示例只做出一个预测,但它可能对某些决策比其他决策更有信心。
例如,一个分类器可能对包含“免费”和“铃声”这两个词的短信有 99%的确定性认为是垃圾邮件,但对包含“tonight”这个词的短信只有 51%的确定性认为是垃圾邮件。在这两种情况下,分类器都将消息分类为垃圾邮件,但它对其中一个决策的确定性远高于另一个。

图 10.1:即使训练数据相同,学习者的预测信心也可能不同
研究这些内部预测概率为评估模型性能提供了有用的数据。如果两个模型犯同样的错误次数,但其中一个更能准确评估其不确定性,那么它就是一个更智能的模型。理想的情况是找到一个在做出正确预测时非常自信,但在面对怀疑时又很谨慎的学习者。信心与谨慎之间的平衡是模型评估的关键部分。
获取内部预测概率的函数调用在 R 包之间有所不同。对于大多数分类器,predict()函数允许一个额外的参数来指定所需的预测类型。要获取单个预测类别,例如垃圾邮件或正常邮件,通常设置type = "class"参数。要获取预测概率,type参数应根据所使用的分类器设置为"prob"、"posterior"、"raw"或"probability"之一。
本书介绍的所有分类器都可以提供预测概率。每个模型的type参数的正确设置都包含在每个模型的语法框中。
例如,要输出在第五章中构建的 C5.0 分类器的预测概率,请使用predict()函数并设置type = "prob",如下所示:
> predicted_prob <- predict(credit_model, credit_test, type = "prob")
要输出在第四章中开发的短信垃圾邮件分类模型的朴素贝叶斯预测概率,请使用predict()函数并设置type = "raw",如下所示:
> sms_test_prob <- predict(sms_classifier, sms_test, type = "raw")
在大多数情况下,predict()函数为每个结果类别返回一个概率。例如,在像短信分类器这样的双结局模型中,预测概率可能存储在一个矩阵或数据框中,如下所示:
> head(sms_test_prob)
ham spam
[1,] 9.999995e-01 4.565938e-07
[2,] 9.999995e-01 4.540489e-07
[3,] 9.998418e-01 1.582360e-04
[4,] 9.999578e-01 4.223125e-05
[5,] 4.816137e-10 1.000000e+00
[6,] 9.997970e-01 2.030033e-04
输出的每一行显示了分类器对垃圾邮件和正常邮件的预测概率。根据概率规则,每行的概率之和为 1,因为这些是相互排斥且穷尽的结局。为了方便起见,在评估过程中,构建一个收集预测类别、实际类别以及感兴趣类别级别的预测概率的数据框可能会有所帮助。
本章 GitHub 仓库中可用的sms_results.csv文件是一个符合这种格式的数据框的示例,它是由第四章中构建的短信分类器的预测构建的。为了简洁起见,省略了构建此评估数据集所需的步骤,因此要跟随这里的示例,只需下载文件并将其使用以下命令加载到数据框中:
> sms_results <- read.csv("sms_results.csv", stringsAsFactors = TRUE)
生成的sms_results数据框很简单。它包含四个包含 1,390 个值的向量。一列包含表示实际短信消息类型(垃圾邮件或正常邮件)的值,另一列表示朴素贝叶斯模型预测的消息类型,第三和第四列分别表示消息是垃圾邮件或正常邮件的概率:
> head(sms_results)
actual_type predict_type prob_spam prob_ham
1 ham ham 0.00000 1.00000
2 ham ham 0.00000 1.00000
3 ham ham 0.00016 0.99984
4 ham ham 0.00004 0.99996
5 spam spam 1.00000 0.00000
6 ham ham 0.00020 0.99980
对于这六个测试案例,预测值和实际短信消息类型一致;模型正确预测了它们的状态。此外,预测概率表明模型对这些预测非常有信心,因为它们都接近或正好是 0 或 1。
当预测值和实际值与 0 和 1 的距离更远时会发生什么?使用subset()函数,我们可以识别出这些记录中的一小部分。以下输出显示了模型估计垃圾邮件概率在 40%到 60%之间的测试案例:
> head(subset(sms_results, prob_spam > 0.40 & prob_spam < 0.60))
actual_type predict_type prob_spam prob_ham
377 spam ham 0.47536 0.52464
717 ham spam 0.56188 0.43812
1311 ham spam 0.57917 0.42083
根据模型自己的估计,这些是正确预测几乎等同于抛硬币的情况。然而,所有三个预测都是错误的——一个不幸的结果。让我们看看更多模型预测错误的情况:
> head(subset(sms_results, actual_type != predict_type))
actual_type predict_type prob_spam prob_ham
53 spam ham 0.00071 0.99929
59 spam ham 0.00156 0.99844
73 spam ham 0.01708 0.98292
76 spam ham 0.00851 0.99149
184 spam ham 0.01243 0.98757
332 spam ham 0.00003 0.99997
这些案例说明了重要的事实,即一个模型可以非常自信,但仍然可能非常错误。所有六个测试案例都是垃圾邮件,分类器认为它们至少有 98%的概率是正常邮件。
尽管存在这样的错误,模型仍然有用吗?我们可以通过将各种错误度量应用于评估数据来回答这个问题。实际上,许多这样的度量都是基于我们在前几章中广泛使用的工具。
混淆矩阵的更详细分析
混淆矩阵是一个表格,根据预测值是否与实际值匹配来分类预测。表格的一个维度表示预测值的可能类别,而另一个维度表示实际值的相同类别。尽管我们到目前为止主要使用的是 2x2 的混淆矩阵,但可以为预测任何数量类别值的模型创建矩阵。以下图显示了熟悉的二类二元模型的混淆矩阵,以及三类的 3x3 混淆矩阵。
当预测值与实际值相同时,这是一种正确的分类。正确的预测位于混淆矩阵的对角线上(用O表示)。对角线外的矩阵单元格(用X表示)表示预测值与实际值不一致的情况。这些都是错误的预测。分类模型的性能度量基于这些表中位于对角线和偏离对角线上的预测数量:

图 10.2:混淆矩阵统计预测类别与实际值一致或不一致的情况
最常见的性能度量考虑模型区分一个类别与其他所有类别的能力。目标类别被称为正类,而所有其他类别被称为负类。
使用正负术语并不旨在暗示任何价值判断(即,好与坏),也不一定意味着结果的存在或不存在(例如,存在出生缺陷或不存在)。正结果的选择甚至可以是任意的,例如在模型预测晴朗与雨天、狗与猫等类别的情况下。
正类和负类预测之间的关系可以用一个 2x2 的混淆矩阵来表示,该矩阵列出了预测是否属于以下四个类别之一:
-
真阳性(TP):正确地被分类为目标类
-
真阴性(TN):正确地被分类为非目标类
-
假阳性(FP):错误地被分类为目标类
-
假阴性(FN):错误地被分类为非目标类
对于垃圾邮件分类器,正类是垃圾邮件,因为这是我们希望检测的结果。然后我们可以想象混淆矩阵如图图 10.3所示:

图 10.3:区分正类和负类使混淆矩阵更加详细
以这种方式展示的混淆矩阵是许多最重要的模型性能度量指标的基础。在下一节中,我们将使用这个矩阵来更好地理解准确率的确切含义。
使用混淆矩阵来衡量性能
使用 2x2 混淆矩阵,我们可以将预测准确率(有时称为成功率)的定义形式化如下:

在这个公式中,术语TP、TN、FP和FN分别指模型预测落在这些类别中的次数。因此,准确率是一个比例,表示真实正例和真实负例的数量除以预测总数。
错误率,即错误分类的样本比例,被定义为:

注意,错误率可以计算为 1 减去准确率。直观上,这是有道理的;一个正确率 95%的模型在 5%的时间内是错误的。
将分类器的预测整理成混淆矩阵的一个简单方法是使用 R 的table()函数。创建 SMS 数据混淆矩阵的命令如下所示。该表中的计数可以用来计算准确率和其他统计量:
> table(sms_results$actual_type, sms_results$predict_type)
ham spam
ham 1203 4
spam 31 152
如果你想要创建一个具有更多信息的混淆矩阵,gmodels包中的CrossTable()函数提供了一个可定制的解决方案。如果你还记得,我们第一次使用这个函数是在第二章,管理和理解数据。如果你当时没有安装这个包,你需要使用install.packages("gmodels")命令来安装。
默认情况下,CrossTable()的输出包括每个单元格中的比例,这些比例表示单元格计数占表格行、列和总计数百分比。输出还包括行和列总计。如下面的代码所示,语法与table()函数类似:
> library(gmodels)
> CrossTable(sms_results$actual_type, sms_results$predict_type)
结果是一个包含大量额外详细信息的混淆矩阵:
Cell Contents
|-------------------------|
| N |
| Chi-square contribution |
| N / Row Total |
| N / Col Total |
| N / Table Total |
|-------------------------|
Total Observations in Table: 1390
| sms_results$predict_type
sms_results$actual_type | ham | spam | Row Total |
------------------------|-----------|-----------|-----------|
ham | 1203 | 4 | 1207 |
| 16.128 | 127.580 | |
| 0.997 | 0.003 | 0.868 |
| 0.975 | 0.026 | |
| 0.865 | 0.003 | |
------------------------|-----------|-----------|-----------|
spam | 31 | 152 | 183 |
| 106.377 | 841.470 | |
| 0.169 | 0.831 | 0.132 |
| 0.025 | 0.974 | |
| 0.022 | 0.109 | |
------------------------|-----------|-----------|-----------|
Column Total | 1234 | 156 | 1390 |
| 0.888 | 0.112 | |
------------------------|-----------|-----------|-----------|
我们在几个前面的章节中使用了CrossTable(),所以到现在你应该熟悉它的输出了。如果你忘记了如何解释输出,只需参考键(标记为“单元格内容”),它提供了表格单元格中每个数字的定义。
我们可以使用混淆矩阵来获取准确率和错误率。由于准确率是(TP + TN)/(TP + TN + FP + FN),我们可以按以下方式计算:
> (152 + 1203) / (152 + 1203 + 4 + 31)
[1] 0.9748201
我们还可以计算错误率(FP + FN)/(TP + TN + FP + FN)如下:
> (4 + 31) / (152 + 1203 + 4 + 31)
[1] 0.02517986
这与 1 减去准确率相同:
> 1 – 0.9748201
[1] 0.0251799
虽然这些计算可能看起来很简单,但重要的是要练习思考混淆矩阵的各个组成部分是如何相互关联的。在下一节中,你将看到这些相同的部分可以以不同的方式组合,以创建各种额外的性能指标。
不仅仅是准确性 – 其他性能指标
无数性能指标已经被开发并用于各种学科中,如医学、信息检索、营销和信号检测理论等特定目的。要涵盖所有这些指标可能需要数百页,这使得在这里进行全面的描述变得不可行。相反,我们将仅考虑机器学习文献中最有用和最常引用的一些指标。
Max Kuhn 的caret包包括计算许多此类性能指标的功能。这个包提供了准备、训练、评估和可视化机器学习模型和数据工具;"caret"这个名字是“分类和回归训练”的缩写。由于它对调整模型也很有价值,除了在这里的使用外,我们还将广泛使用caret包在第十四章,构建更好的学习者。在继续之前,你需要使用install.packages("caret")命令来安装这个包。
关于caret的更多信息,请参阅Kuhn, M, 使用 caret 包在 R 中构建预测模型,统计软件杂志,2008,第 28 卷或包的非常详尽的文档页面topepo.github.io/caret/index.html
caret 包添加了创建混淆矩阵的另一个函数。如下所示,语法与 table() 类似,但略有不同。因为 caret 计算反映分类正类能力的模型性能度量,所以应指定 positive 参数。在这种情况下,由于 SMS 分类器旨在检测垃圾邮件,我们将设置 positive = "spam" 如下:
> library(caret)
> confusionMatrix(sms_results$predict_type,
sms_results$actual_type, positive = "spam")
这会导致以下输出:
Confusion Matrix and Statistics
Reference
Prediction ham spam
ham 1203 31
spam 4 152
Accuracy : 0.9748
95% CI : (0.9652, 0.9824)
No Information Rate : 0.8683
P-Value [Acc > NIR] : < 2.2e-16
Kappa : 0.8825
Mcnemar’s Test P-Value : 1.109e-05
Sensitivity : 0.8306
Specificity : 0.9967
Pos Pred Value : 0.9744
Neg Pred Value : 0.9749
Prevalence : 0.1317
Detection Rate : 0.1094
Detection Prevalence : 0.1122
Balanced Accuracy : 0.9136
‘Positive’ Class : spam
输出顶部是一个类似于 table() 函数生成的混淆矩阵,但已转置。输出还包括一组性能度量。其中一些,如准确度,是熟悉的,而许多其他则是新的。让我们看看一些最重要的指标。
卡方统计量
卡方统计量(在之前的输出中标记为 Kappa)通过考虑仅凭偶然正确预测的可能性来调整准确性。这对于具有严重类别不平衡的数据集尤为重要,因为分类器可以通过始终猜测最频繁的类别来获得高准确率。卡方统计量只会奖励那些比这种简单策略更频繁正确分类的分类器。
定义卡方统计量的方法不止一种。这里描述的最常见的方法使用 Cohen 的卡方系数,如论文 《名义量度的协议系数,Cohen, J, 教育与心理测量,1960,第 20 卷,第 37-46 页》 所述。
卡方值通常在 0 到最大值 1 之间,更高的值反映了模型预测与真实值之间更强的协议。如果预测始终错误,则可能观察到小于 0 的值——也就是说,预测与实际值不一致或错误率高于随机猜测的预期。这种情况在机器学习模型中很少发生,通常反映编码问题,可以通过简单地反转预测来修复。
根据模型的使用方式,卡方统计量的解释可能会有所不同。以下是一个常见的解释示例:
-
差一致性 = 小于 0.2
-
公平一致性 = 0.2 至 0.4
-
中等一致性 = 0.4 至 0.6
-
良好一致性 = 0.6 至 0.8
-
非常好一致性 = 0.8 至 1.0
重要的是要注意,这些类别是主观的。虽然“良好一致性”可能足以预测某人的最爱冰淇淋口味,但如果目标是识别出生缺陷,“非常好一致性”可能就不够了。
关于前述量表更详细的信息,请参阅 《分类数据的观察者一致性测量,Landis, JR, Koch, GG. 生物统计学,1997,第 33 卷,第 159-174 页》。
以下是为计算 kappa 统计量提供的公式。在这个公式中,Pr(a) 指的是实际协议的比例,而 Pr(e) 指的是在假设它们是随机选择的情况下,分类器和真实值之间预期协议的比例:

这些比例一旦你知道在哪里寻找,就可以从混淆矩阵中获得。让我们考虑使用 CrossTable() 函数创建的 SMS 分类模型的混淆矩阵,这里为了方便起见重复列出:
| sms_results$predict_type
sms_results$actual_type | ham | spam | Row Total |
------------------------|-----------|-----------|-----------|
ham | 1203 | 4 | 1207 |
| 16.128 | 127.580 | |
| 0.997 | 0.003 | 0.868 |
| 0.975 | 0.026 | |
| 0.865 | 0.003 | |
------------------------|-----------|-----------|-----------|
spam | 31 | 152 | 183 |
| 106.377 | 841.470 | |
| 0.169 | 0.831 | 0.132 |
| 0.025 | 0.974 | |
| 0.022 | 0.109 | |
------------------------|-----------|-----------|-----------|
Column Total | 1234 | 156 | 1390 |
| 0.888 | 0.112 | |
------------------------|-----------|-----------|-----------|
记住,每个单元格的底部值表示所有实例落入该单元格的比例。因此,为了计算观察到的协议比例 Pr(a),我们只需将预测类型和实际短信类型达成一致的实例比例相加。
因此,我们可以计算 Pr(a) 如下:
> pr_a <- 0.865 + 0.109
> pr_a
[1] 0.974
对于这个分类器,观察值和实际值有 97.4% 的时间达成一致——你会注意到这与准确率相同。kappa 统计量调整了相对于预期一致性 Pr(e) 的准确率,即仅凭偶然,在假设两者都是根据观察比例随机选择的情况下,预测值和实际值匹配的概率。
为了找到这些观察到的比例,我们可以使用我们在 第四章 中学到的概率规则。假设两个事件是独立的(意味着一个不会影响另一个),概率规则指出,两个事件同时发生的概率等于各自发生的概率的乘积。例如,我们知道选择非垃圾邮件的概率是:
Pr(实际类型是非垃圾邮件) * Pr(预测类型是非垃圾邮件)
选择垃圾邮件的概率是:
Pr(实际类型是垃圾邮件) * Pr(预测类型是垃圾邮件)
预测或实际类型是垃圾邮件或非垃圾邮件的概率可以从行或列总数中获得。例如,Pr(实际类型是非垃圾邮件) = 0.868 和 Pr(预测类型是非垃圾邮件) = 0.888。
Pr(e) 可以通过预测值和实际值都认为消息是垃圾邮件或非垃圾邮件的概率之和来计算。回想一下,对于互斥事件(不能同时发生的事件),任一事件发生的概率等于其概率之和。因此,为了获得最终的 Pr(e),我们只需将两个乘积相加,如下所示:
> pr_e <- 0.868 * 0.888 + 0.132 * 0.112
> pr_e
[1] 0.785568
由于 Pr(e) 是 0.786,仅凭偶然,我们预计观察值和实际值将有大约 78.6% 的时间达成一致。
这意味着我们现在拥有了完成 kappa 公式的所有信息。将 Pr(a) 和 Pr(e) 值代入 kappa 公式,我们得到:
> k <- (pr_a - pr_e) / (1 - pr_e)
> k
[1] 0.8787494
kappa 大约是 0.88,这与之前 caret 的 confusionMatrix() 输出相符(小的差异是由于四舍五入)。使用建议的解释,我们注意到分类器的预测值和实际值之间有非常好的协议。
有几个 R 函数可以自动计算 kappa。可视化分类数据(VCD)包中的Kappa()函数(请注意大写的“K”),使用预测值和实际值的混淆矩阵。通过输入install.packages("vcd")安装包后,可以使用以下命令获取 kappa:
> library(vcd)
> Kappa(table(sms_results$actual_type, sms_results$predict_type))
value ASE z Pr(>|z|)
Unweighted 0.8825 0.01949 45.27 0
Weighted 0.8825 0.01949 45.27 0
我们对无权重的 kappa 值感兴趣。0.88 的值与我们手动计算的结果相符。
当存在不同程度的协议时,使用加权 kappa。例如,使用冷、凉爽、温暖和热的刻度,温暖与热的值比与冷的值更一致。在两个结果事件的情况下,如垃圾邮件和正常邮件,加权 kappa 和未加权 kappa 统计量将是相同的。
Interrater Reliability(irr)包中的kappa2()函数可以用来从数据框中存储的预测值和实际值的向量中计算 kappa。在通过install.packages("irr")安装包之后,可以使用以下命令获取 kappa:
> library(irr)
> kappa2(sms_results[1:2])
Cohen's Kappa for 2 Raters (Weights: unweighted)
Subjects = 1390
Raters = 2
Kappa = 0.883
z = 33
p-value = 0
Kappa()和kappa2()函数报告相同的 kappa 统计量,因此使用您更舒适的选项。
请注意不要使用内置的kappa()函数。它与之前报告的 kappa 统计量完全无关!
矩阵相关系数
尽管准确性和 kappa 多年来一直是性能的流行指标,但第三个选项迅速成为机器学习领域的实际标准。与先前的指标一样,矩阵相关系数(MCC)是一个单一统计量,旨在反映分类模型的总体性能。此外,MCC 与 kappa 类似,即使在数据集严重不平衡的情况下(在这种情况下,传统的准确度度量可能会非常误导),它也是有用的。
由于其易于解释,以及越来越多的证据表明它在比 kappa 更广泛的情境下表现更好,MCC 越来越受欢迎。最近的经验研究表明,MCC 可能是描述二元分类模型现实世界性能的最佳单一指标。其他研究已经确定了可能导致 kappa 统计量提供误导或不正确模型性能描述的潜在情境。在这些情况下,当 MCC 和 kappa 不一致时,MCC 指标往往能更合理地评估模型的真正能力。
关于马修斯相关系数与 k 值相对优势的更多信息,请参阅 The Matthews correlation coefficient (MCC) is more informative than Cohen’s kappa and brier score in binary classification assessment, Chicco D, Warrens MJ, Jurman G, IEEE Access, 2021, Vol. 9, pp. 78368-78381。或者,参考 Why Cohen’s Kappa should be avoided as performance measure in classification, Delgado R, Tibau XA, PLoS One, 2019, Vol. 14(9):e0222916。
MCC 的值解释与皮尔逊相关系数相同,该系数在 第六章,预测数值数据 – 回归方法 中介绍。这个范围从 -1 到 +1,分别表示完全不准确和完全准确的预测。值为 0 表示模型的表现不优于随机猜测。由于大多数 MCC 分数都位于 0 和 1 之间的某个值域内,因此“良好”分数的判断具有一定的主观性。与皮尔逊相关系数使用的刻度类似,一种可能的解释如下:
-
完全错误 = -1.0
-
强度错误 = -0.5 到 -1.0
-
中度错误 = -0.3 到 -0.5
-
弱度错误 = -0.1 到 0.3
-
随机正确 = -0.1 到 0.1
-
轻度正确 = 0.1 到 0.3
-
中度正确 = 0.3 到 0.5
-
强度正确 = 0.5 到 1.0
-
完全正确 = 1.0
注意,表现最差的模型位于刻度中间。换句话说,位于刻度负侧(从完全错误到轻度错误)的模型仍然比随机预测的模型表现更好。例如,即使强度错误的模型的准确度很差,预测结果也可以简单地反转以获得正确的结果。
与所有此类刻度一样,这些刻度只能作为粗略的指南。此外,像 MCC 这样的度量标准的关键好处不是理解模型在孤立状态下的性能,而是促进跨多个模型的性能比较。
对于二分类器的混淆矩阵,MCC 可以通过以下公式计算:

使用 SMS 垃圾邮件分类模型的混淆矩阵,我们得到以下值:
-
TN = 1203
-
FP = 4
-
FN = 31
-
TP = 152
然后,可以在 R 中手动计算 MCC,如下所示:
> (152 * 1203 - 4 * 31) /
sqrt((152 + 4) * (152 + 31) * (1203 + 4) * (1203 + 31))
[1] 0.8861669
Ben Gorman 开发的 mltools 包提供了一个 mcc() 函数,该函数可以使用预测值和实际值的向量执行 MCC 计算。安装该包后,以下 R 代码产生的结果与手动计算的结果相同:
> library(mltools)
> mcc(sms_results$actual_type, sms_results$predict_type)
[1] 0.8861669
或者,对于将正类编码为 1 且将负类编码为 0 的二分类器,MCC 与预测值和实际值之间的皮尔逊相关系数相同。我们可以使用 R 中的 cor() 函数来演示这一点,在将分类值("spam" 或 "ham")转换为二进制值(1 或 0)后,如下所示:
> cor(ifelse(sms_results$actual_type == "spam", 1, 0),
ifelse(sms_results$predict_type == "spam", 1, 0))
[1] 0.8861669
这样一个显然的分类性能指标竟然隐藏在显而易见的地方,作为一个简单的对 19 世纪末引入的皮尔逊相关性的改编,这使得 MCC(Matthews Correlation Coefficient)仅在最近几十年才变得流行!生物化学家布莱恩·W·马修斯在 1975 年负责推广这个指标用于双分类问题,因此他因这一特定应用而获得命名荣誉。然而,似乎很可能是这个指标已经被广泛使用,即使它直到很久以后才引起了很多关注。如今,它在工业界、学术研究和甚至作为机器学习竞赛的基准中得到应用。可能没有单一的指标能更好地捕捉二元分类模型的总体性能。然而,正如你很快就会看到的,通过组合多个指标可以获得对模型性能的更深入理解。
虽然 MCC 在这里是为二元分类定义的,但它是否是多类结果的最佳指标尚不清楚。关于这一点和其他替代方案的讨论,请参阅“多类预测中 MCC 和 CEN 误差测量的比较,Jurman G,Riccadonna S,Furlanello C,2012,PLOS One 7(8): e41882”。
灵敏度和特异性
寻找一个有用的分类器通常需要在过于保守的预测和过于激进的预测之间取得平衡。例如,一个电子邮件过滤器可以通过激进地过滤几乎所有的正常邮件来保证消除每一封垃圾邮件。另一方面,为了保证没有正常邮件被意外过滤,可能需要我们允许通过过滤器的不合理数量的垃圾邮件。一对性能指标捕捉了这种权衡:灵敏度和特异性。
模型的灵敏度(也称为真正率)衡量的是正确分类的正面样本占所有正面样本的比例。因此,正如以下公式所示,它是通过将真正例的数量除以所有正面样本的总数来计算的,包括那些正确分类的(真正例)和那些错误分类的(假阴性):

模型的特异性(也称为真正率)衡量的是正确分类的负面样本占所有负面样本的比例。与灵敏度一样,这是通过将真正例的数量除以所有负面样本的总数来计算的——包括真正例和假阳性。

对于短信分类器的混淆矩阵,我们可以很容易地手动计算这些指标。假设垃圾邮件是一个正面类别,我们可以确认confusionMatrix()输出中的数字是正确的。例如,灵敏度的计算如下:
> sens <- 152 / (152 + 31)
> sens
[1] 0.8306011
同样,对于特异性,我们可以计算:
> spec <- 1203 / (1203 + 4)
> spec
[1] 0.996686
caret包提供了从预测值和实际值向量直接计算敏感性和特异性的函数。请确保适当地指定positive或negative参数,如下所示:
> library(caret)
> sensitivity(sms_results$predict_type, sms_results$actual_type,
positive = "spam")
[1] 0.8306011
> specificity(sms_results$predict_type, sms_results$actual_type,
negative = "ham")
[1] 0.996686
敏感性和特异性范围从 0 到 1,接近 1 的值更受欢迎。当然,找到两者之间的适当平衡是很重要的——这是一个通常非常具体于上下文的任务。
例如,在这种情况下,0.831 的敏感性意味着 83.1%的垃圾邮件被正确分类。同样,0.997 的特异性意味着 99.7%的非垃圾邮件被正确分类,或者换句话说,0.3%的有效消息被错误地标记为垃圾邮件。拒绝 0.3%的有效短信消息可能是不被接受的,或者考虑到垃圾邮件数量的减少,这可能是合理的权衡。
敏感性和特异性提供了思考此类权衡的工具。通常,会调整模型并测试不同的模型,直到找到一个满足所需敏感性和特异性阈值的模型。本章后面讨论的可视化也可以帮助理解敏感性和特异性之间的平衡。
精确度和召回率
与敏感性和特异性密切相关的是另外两个与分类中做出的妥协相关的性能指标:精确度和召回率。主要在信息检索的背景下使用,这些统计数据旨在表明模型的结果有多有趣和相关性,或者预测是否被无意义的噪声稀释。
精确度(也称为阳性预测值)定义为真正阳性的预测比例;换句话说,当模型预测阳性类别时,它有多正确?一个精确的模型只会预测那些非常可能是阳性的情况。它将非常可靠。

考虑一下,如果模型非常不精确会发生什么。随着时间的推移,结果不太可能被信任。在信息检索的背景下,这类似于像 Google 这样的搜索引擎返回不相关结果。最终,用户可能会转向像 Bing 这样的竞争对手。在短信垃圾邮件过滤器的例子中,高精确度意味着模型能够仔细地只针对垃圾邮件,同时避免在正常邮件中出现误报。
另一方面,召回率是衡量结果完整性的一个指标。如下公式所示,这定义为真正阳性数除以总阳性数。你可能已经认识到这与敏感性相同;然而,解释略有不同。

具有高召回率的模型能够捕获大量正例,这意味着它具有广泛的覆盖面。例如,具有高召回率的搜索引擎会返回大量与搜索查询相关的文档。同样,如果大多数垃圾邮件消息被正确识别,短信垃圾邮件过滤器也具有高召回率。
我们可以从混淆矩阵中计算出精确度和召回率。再次假设垃圾邮件是一个正类,精确度是:
> prec <- 152 / (152 + 4)
> prec
[1] 0.974359
召回率是:
> rec <- 152 / (152 + 31)
> rec
[1] 0.8306011
可以使用caret包从预测和实际类别的向量中计算这些度量之一。精确度使用posPredValue()函数:
> library(caret)
> posPredValue(sms_results$predict_type, sms_results$actual_type,
positive = "spam")
[1] 0.974359
召回率使用我们之前使用的sensitivity()函数:
> sensitivity(sms_results$predict_type, sms_results$actual_type,
positive = "spam")
[1] 0.8306011
就像敏感性和特异性之间的权衡一样,对于大多数现实世界的问题,很难构建一个既具有高精确度又具有高召回率的模型。如果你只针对容易分类的例子(即低垂的果实),那么很容易做到精确。同样,一个模型通过撒一个非常宽的网,意味着模型在识别正例时过于激进,也容易具有高召回率。相比之下,同时具有高精确度和召回率是非常具有挑战性的。因此,为了找到满足你项目需求的精确度和召回率的组合,测试各种模型是非常重要的。
F 度量
将精确度和召回率结合成一个单一数字的模型性能度量称为F 度量(有时也称为F[1] 分数或F 分数)。F 度量通过调和平均数结合精确度和召回率,这是一种用于变化率的平均数类型。由于精确度和召回率都表示为 0 到 1 之间的比例,可以解释为变化率,因此使用调和平均数而不是更常见的算术平均数。以下为 F 度量的公式:

要计算 F 度量,使用之前计算出的精确度和召回率值:
> f <- (2 * prec * rec) / (prec + rec)
> f
[1] 0.8967552
这与使用混淆矩阵中的计数完全相同:
> f <- (2 * 152) / (2 * 152 + 4 + 31)
> f
[1] 0.8967552
由于 F 度量将模型性能描述为一个单一数字,它提供了一个方便的、定量的指标,可以直接比较多个模型。确实,F 度量曾经几乎成为衡量模型性能的黄金标准,但今天,它似乎比以前使用得少得多。一个可能的解释是,它假设精确度和召回度应该被赋予相同的权重,这个假设并不总是有效的,这取决于现实世界中假阳性和假阴性的实际成本。当然,可以使用不同的精确度和召回度权重来计算 F 分数,但选择权重可能最坏的情况是随意的。尽管如此,也许这个指标不再受欢迎的更重要原因是采用了方法,这些方法可以直观地描绘模型在不同数据子集上的性能,如下一节所述。
使用 ROC 曲线可视化性能权衡
可视化有助于更详细地理解机器学习算法的性能。当诸如敏感度、特异性、精确度和召回率等统计量试图将模型性能简化为一个单一数字时,可视化则描绘了学习者在广泛条件下的表现。
由于学习算法有不同的偏差,两个具有相似准确率的模型可能在达到准确率的方式上存在巨大差异。一些模型可能在某些预测上挣扎,而其他模型则轻松完成,同时轻松处理其他模型难以正确处理的案例。可视化提供了一种方法,通过在单个图表中并排比较学习器来理解这些权衡。
接收者操作特征(ROC)曲线通常用于检查在避免假阳性同时检测真阳性的权衡。正如你可能从其名称中猜测到的,ROC 曲线是由通信领域的工程师开发的。在第二次世界大战期间,雷达和无线电操作员使用 ROC 曲线来衡量接收器区分真实信号和虚假警报的能力。同样的技术今天对于可视化机器学习模型的功效也很有用。
关于 ROC 曲线的更多阅读,请参阅《ROC 分析简介》,Fawcett T,Pattern Recognition Letters,2006 年,第 27 卷,第 861-874 页。
典型 ROC 图的特征在图 10.4中展示。ROC 曲线使用垂直轴上的真阳性比例和水平轴上的假阳性比例来绘制。因为这些值分别等同于敏感度和(1 – 特异性),所以该图也被称为敏感度/特异性图。

图 10.4:ROC 曲线描绘了分类器形状相对于完美和无用分类器
组成 ROC 曲线的点表示在变化的假阳性阈值下的真正例率。为了说明这个概念,前一个图表中对比了三个假设的分类器。首先,完美分类器的曲线通过 100%真正例率和 0%假阳性率的点。它能够在错误地分类任何负例之前正确地识别所有真正例。接下来,从图的下左角到上右角的斜线代表一个无预测价值的分类器。这种分类器以相同的速率检测真正例和假阳性,这意味着分类器无法区分两者。这是其他分类器可以评判的基准。接近这条线的 ROC 曲线表示模型不太有用。最后,大多数现实世界的分类器都像测试分类器一样,它们位于完美和无用之间的区域。
理解 ROC 曲线构建的最佳方式是亲手绘制一个。图 10.5中表格中的数值表示了一个假设的垃圾邮件模型在包含 20 个示例的测试集上的预测结果,其中 6 个是正类(垃圾邮件),14 个是负类(正常邮件)。

图 10.5:为了构建 ROC 曲线,将正类的估计概率值按降序排序,然后与实际类别值进行比较
要创建曲线,需要按照模型对正类估计概率的降序对分类器的预测进行排序,最大的值排在前面,如表中所示。然后,从图表的原点开始,每个预测对真正例率和假阳性率的影响导致曲线垂直于每个正例进行追踪,水平于每个负例进行追踪。这个过程可以在一张坐标纸上手工完成,如图图 10.6所示:

图 10.6:可以在坐标纸上通过绘制正例数量与负例数量的对比来手工绘制 ROC 曲线
注意,此时 ROC 曲线并不完整,因为测试集中负例的数量是正例的两倍以上,导致坐标轴倾斜。一个简单的解决方案是将图表按比例缩放,使得两个坐标轴的大小相等,如图图 10.7所示:

图 10.7:调整图表的坐标轴比例,可以创建一个无论初始正负例平衡如何都成比例的比较
如果我们想象现在 x 轴和 y 轴的范围都是从 0 到 1,我们可以将每个轴解释为百分比。y 轴表示正例的数量,最初的范围是从 0 到 6;将其缩小到 0 到 1 的比例后,每个增量变为 1/6。在这个比例上,我们可以将 ROC 曲线的垂直坐标视为真阳性数除以总正例数,即真阳性率,或灵敏度。同样,x 轴衡量的是负例的数量;通过除以总负例数(本例中为 14),我们得到真阴性率,或特异性。
图 10.8 中的表格描述了假设测试集中所有 20 个示例的计算:

图 10.8:ROC 曲线追踪模型真阳性率与假阳性率随示例集规模逐渐增大而发生的变化
ROC 曲线的一个重要特性是它们不受类别不平衡问题的影响,其中一个结果(通常是正类)比另一个结果要罕见得多。许多性能指标,如准确率,对于不平衡数据可能会产生误导。ROC 曲线并非如此,因为图表的两个维度完全基于正负值内的比率,因此正负之间的比率不会影响结果。由于许多最重要的机器学习任务都涉及严重不平衡的结果,ROC 曲线是理解模型整体质量的一个非常有用的工具。
比较 ROC 曲线
如果 ROC 曲线有助于评估单个模型,那么它们也用于跨模型比较也就不足为奇了。直观上,我们知道靠近图表区域右上角的曲线更好。在实践中,这种比较往往比这更具有挑战性,因为曲线之间的差异通常是微妙的而不是明显的,而且解释是细微的、具体的,并且与模型的使用方式有关。
要理解细微差别,让我们首先考虑是什么原因导致两个模型在 ROC 图上绘制出不同的曲线。从原点开始,曲线长度随着预测为正的测试集示例数量的增加而延长。因为 y 轴代表真阳性率,而 x 轴代表假阳性率,更陡峭的上升轨迹是一个隐含的比率,意味着模型在识别正例时犯的错误更少。这如图 10.9 所示,它描绘了两个虚构模型的 ROC 曲线的起点。对于相同数量的预测——由从原点发出的向量的长度相等表示——第一个模型具有更高的真阳性率和更低的假阳性率,这意味着它是两个模型中表现更好的一个:

图 10.9:对于相同数量的预测,模型 1 优于模型 2,因为它具有更高的真正阳性率
假设我们继续追踪这两个模型的 ROC 曲线,评估模型在整个数据集上的预测。在这种情况下,也许第一个模型在曲线的所有点上继续优于第二个模型,如图 10.10所示。
在曲线的所有点上,第一个模型具有更高的真正阳性率和更低的假阳性率,这意味着它在整个数据集上是更好的表现者:

图 10.10:模型 1 在所有曲线点上始终优于模型 2,具有更高的真正阳性和更低的假阳性率
尽管在先前的例子中第二个模型明显劣于第一个模型,但选择更好的表现者并不总是那么容易。图 10.11展示了相交的 ROC 曲线,这表明没有哪个模型是所有应用的最好表现者:

图 10.11:对于数据的不同子集,模型 1 和模型 2 都是更好的表现者
两个 ROC 曲线的交点将图表分为两个区域:一个区域中第一个模型具有更高的真正阳性率,另一个区域中则相反。那么,我们如何知道哪个模型对于任何特定的用例是“最佳”的呢?
为了回答这个问题,当比较两条曲线时,了解两个模型都在尝试按照每个示例属于正类概率从高到低的顺序对数据集进行排序是有帮助的。那些能够更好地以这种方式排序数据集的模型将具有更靠近图表左上角的 ROC 曲线。
图 10.11中的第一个模型之所以能迅速领先,是因为它能够将更多的正例排序到数据集的前端,但在此初始激增之后,第二个模型能够逐渐赶上,并在数据集剩余部分中缓慢而稳定地将正例排序在负例之前,从而超越了其他模型。尽管第二个模型可能在整个数据集上具有更好的整体性能,但我们更倾向于选择早期表现更好的模型——那些在数据集中“低垂的果实”上表现更好的模型。选择这些模型的理由是,许多现实世界的模型仅用于对数据子集采取行动。
例如,考虑一个用于识别最有可能对直接邮件广告活动做出反应的客户的模型。如果我们能够向所有潜在客户发送邮件,那么模型就是不必要的。但由于我们没有足够的预算向每个地址发送广告,因此模型被用来估计收件人在查看广告后购买产品的概率。一个能够更好地将真正最有可能购买的产品放在列表前面的模型将会有一个更陡峭的 ROC 曲线早期斜率,并将缩小获取购买者所需的营销预算。在图 10.11中,第一个模型更适合这项任务。
与这种方法相反,另一个考虑因素是各种类型错误的相对成本;在现实世界中,假阳性和假阴性通常有不同的影响。如果我们知道垃圾邮件过滤器或癌症筛查需要针对特定的真正阳性率,例如 90%或 99%,我们将倾向于选择在期望水平上具有较低假阳性率的模型。尽管由于高假阳性率,这两个模型都不会很好,但图 10.11表明,第二个模型对于这些应用来说稍微更可取。
如这些示例所示,ROC 曲线允许比较模型性能,同时也考虑了模型的使用方式。这种灵活性比简单的数值指标如准确度或 kappa 更受欢迎,但可能也希望通过一个单一的指标来量化 ROC 曲线,以便可以进行定量比较,就像这些统计数据一样。下一节将介绍这种类型的度量。
ROC 曲线下的面积
比较 ROC 曲线可能具有一定的主观性和情境特异性,因此将性能简化为单一数值的指标总是有需求的,以便简化并使比较具有客观性。虽然可能难以说清楚什么是一个“好的”ROC 曲线,但一般来说,我们知道 ROC 曲线越接近图表的右上角,它在识别正值方面的能力就越好。这可以通过一个称为ROC 曲线下面积(AUC)的统计量来衡量。AUC 将 ROC 图视为一个二维正方形,并测量 ROC 曲线下的总面积。AUC 的范围从 0.5(对于没有预测价值的分类器)到 1.0(对于完美的分类器)。解释 AUC 分数的惯例使用了一个类似于学术成绩等级的系统:
-
A:杰出 = 0.9 到 1.0
-
B:优秀/良好 = 0.8 到 0.9
-
C:可接受/公平 = 0.7 到 0.8
-
D:差 = 0.6 到 0.7
-
E:无区分度 = 0.5 到 0.6
与大多数此类量表一样,某些任务可能比其他任务更适合这些级别;类别之间的边界自然是有些模糊的。
ROC 曲线低于对角线的情况虽然罕见但可能发生,这会导致 AUC 小于 0.50。这意味着分类器的性能不如随机。通常,这是由于编码错误造成的,因为一个始终做出错误预测的模型显然已经从数据中学习到了一些有用的信息——它只是错误地应用了预测。要解决这个问题,请确认正例的编码是否正确,或者简单地反转预测,使得当模型预测负类时,选择正类代替。
当 AUC 的使用开始变得普遍时,有些人将其视为模型性能的最终衡量标准,尽管不幸的是,在所有情况下这并不成立。一般来说,更高的 AUC 值反映了分类器在将随机正例排序高于随机负例方面表现更好。然而,图 10.12说明了重要的事实:两条 ROC 曲线可能形状非常不同,但 AUC 却相同:

图 10.12:尽管 AUC 相同,ROC 曲线可能具有不同的性能
由于 AUC 是 ROC 曲线的简化,仅凭 AUC 本身不足以识别适用于所有用例的“最佳”模型。最安全的做法是将 AUC 与对 ROC 曲线的定性检查结合起来,正如本章前面所述。如果两个模型的 AUC 相同或相似,通常更倾向于选择早期表现更好的模型。此外,即使一个模型的整体 AUC 更好,对于仅将使用最自信预测子集的应用,具有更高初始真正阳性率的模型可能更受欢迎。
在 R 中创建 ROC 曲线和计算 AUC
pROC包提供了一套易于使用的函数,用于创建 ROC 曲线和计算 AUC。pROC网站([web.expasy.org/pROC/](https://web.expasy.org/pROC/))列出了完整的功能列表,以及几个可视化功能的示例。在继续之前,请确保您已使用install.packages("pROC")命令安装了该包。
关于pROC包的更多信息,请参阅pROC:用于 R 和 S+的开源包,用于分析和比较 ROC 曲线,Robin, X, Turck, N, Hainard, A, Tiberti, N, Lisacek, F, Sanchez, JC, 和 Mueller M, BMC Bioinformatics, 2011, 第 12-77 页。
要使用pROC创建可视化,需要两个数据向量。第一个必须包含正类估计概率,第二个必须包含预测类别值。
对于 SMS 分类器,我们将按照以下方式将估计的垃圾邮件概率和实际类别标签提供给roc()函数:
> library(pROC)
> sms_roc <- roc(sms_results$prob_spam, sms_results$actual_type)
使用sms_roc对象,我们可以通过 R 的plot()函数来可视化 ROC 曲线。如下所示,许多用于调整图形的标准参数都可以使用,例如main(用于添加标题)、col(用于更改线条颜色)和lwd(用于调整线条宽度)。grid参数在图形上添加了浅色的网格线,有助于提高可读性,而legacy.axes参数指示pROC将x轴标记为 1 – 特异性,这是一个流行的约定,因为它等同于假阳性率:
> plot(sms_roc, main = "ROC curve for SMS spam filter",
Col = "blue", lwd = 2, grid = TRUE, legacy.axes = TRUE)
结果是一个 Naive Bayes 分类器的 ROC 曲线和一个表示无预测价值的基线分类器的对角参考线:

图 10.13:Naive Bayes SMS 分类器的 ROC 曲线
定性来看,我们可以看到这条 ROC 曲线似乎占据了图表的右上角空间,这表明它比代表无用分类器的虚线更接近完美分类器。
为了将此模型的性能与其他在同一数据集上预测的其他模型进行比较,我们可以在同一图表上添加额外的 ROC 曲线。假设我们已经在 SMS 数据上使用第三章中描述的knn()函数训练了一个 k-NN 模型。使用此模型,我们计算了测试集中每个记录的垃圾邮件预测概率,并将其保存到 CSV 文件中,我们可以在这里加载它。加载文件后,我们将像之前一样应用roc()函数来计算 ROC 曲线,然后使用plot()函数并设置参数add = TRUE将曲线添加到之前的图表中:
> sms_results_knn <- read.csv("sms_results_knn.csv")
> sms_roc_knn <- roc(sms_results$actual_type,
sms_results_knn$p_spam)
> plot(sms_roc_knn, col = "red", lwd = 2, add = TRUE)
结果可视化中还有一个第二曲线,描述了 k-NN 模型在相同的测试集上对 Naive Bayes 模型进行预测的性能。k-NN 的曲线始终较低,表明它比 Naive Bayes 方法是一个持续较差的模型:

图 10.14:比较 Naive Bayes(最上面的曲线)和 k-NN(底部曲线)在 SMS 测试集上的性能的 ROC 曲线
为了定量地确认这一点,我们可以使用pROC包来计算 AUC。为此,我们只需将包的auc()函数应用于每个模型的sms_roc对象,如下所示:
> auc(sms_roc)
Area under the curve: 0.9836
> auc(sms_roc_knn)
Area under the curve: 0.8942
Naive Bayes SMS 分类器的 AUC 为 0.98,这非常高,并且比 k-NN 分类器的 AUC 0.89 要好得多。但我们是怎样知道模型在另一个数据集上表现同样好的可能性,或者这种差异是否大于仅由偶然性预期的?为了回答这些问题,我们需要更好地理解我们可以将模型的预测外推多远超出测试数据。这些方法将在接下来的章节中描述。
这一点之前已经提到过,但值得再次强调:仅凭 AUC 值往往不足以确定一个“最佳”模型。在这个例子中,AUC 值确实能够识别出更好的模型,因为 ROC 曲线没有交叉——朴素贝叶斯模型在 ROC 曲线的所有点上都具有更好的真正阳性率。当 ROC 曲线确实交叉时,“最佳”模型将取决于模型的使用方式。此外,还可以使用第十四章中介绍的构建更好的学习器技术,将具有交叉 ROC 曲线的学习者组合成更强大的模型。
估计未来性能
一些 R 机器学习包在模型构建过程中会展示混淆矩阵和性能指标。这些统计数据的目的是为了提供对模型重新替换误差的洞察,这种误差发生在尽管模型是在这些数据上训练的,但训练样本的目标值被错误预测的情况下。这可以用作粗略的诊断工具,以识别明显表现不佳的模型。一个在训练数据上表现不佳的模型不太可能在未来的数据上表现良好。
反过来则不成立。换句话说,一个在训练数据上表现良好的模型不能假设它在未来的数据集上也会表现良好。例如,一个使用死记硬背来完美分类每个训练实例且零重新替换误差的模型将无法将其预测推广到它以前从未见过的数据。因此,训练数据上的错误率可以假设是对模型未来性能的乐观估计。
与依赖于重新替换误差相比,更好的做法是评估模型在尚未见过的数据上的性能。我们在前面的章节中已经使用过这种方法,当时我们将可用的数据分成训练集和测试集。然而,在某些情况下,创建训练集和测试集并不总是理想的。例如,在你只有一小部分数据的情况下,你可能不想进一步减少样本量。
幸运的是,你很快就会了解到,还有其他方法可以估计模型在未见数据上的性能。我们用来计算性能指标的caret包也提供了估计未来性能的函数。如果你正在跟随 R 代码示例,并且尚未安装caret包,请先安装。你还需要使用library(caret)命令将包加载到 R 会话中。
保留法
我们在前面章节中使用的数据划分为训练集和测试集的过程被称为保留法。如图 10.15 所示,训练集用于生成模型,然后该模型应用于测试集以生成用于评估的预测。通常,大约三分之一的用于测试,三分之二用于训练,但这个比例可能会根据可用数据的数量或学习任务的复杂性而变化。为了确保训练集和测试集没有系统性差异,它们的示例被随机分为两组。

图 10.15:最简单的保留法将数据分为训练集和测试集
为了使保留法得到对未来性能的真正准确估计,在任何时候都不应允许测试集上的性能影响建模过程。正如斯坦福大学教授、著名机器学习专家 Trevor Hastie 所说:“理想情况下,测试集应该被保存在一个‘保险库’中,只有在数据分析结束时才取出。”换句话说,测试数据除了其唯一目的之外,不应被触及,即评估一个单一、最终的模型。
更多信息,请参阅《统计学习元素》(第 2 版),Hastie,Tibshirani 和 Friedman(2009),第 222 页。
很容易在不经意间违反这个规则,在选择多个模型之一或根据重复测试的结果更改单个模型时窥视这个比喻性的“保险库”。例如,假设我们在训练数据上构建了几个模型,并在测试数据上选择了准确率最高的模型。在这种情况下,因为我们已经使用了测试集来挑选最佳结果,所以测试性能并不是对未来未见数据性能的无偏度量
留心观察的读者会发现,在前几章中使用了保留测试数据来评估模型并提高模型性能。这样做是为了说明目的,但实际上违反了之前陈述的规则。因此,所显示的模型性能统计数据并不是对未来未见数据的真正无偏估计。
。
为了避免这个问题,最好将原始数据划分为除了训练集和测试集之外,还有一个验证集。验证集可以用于迭代和细化选定的模型或模型,而将测试集仅用于最终步骤,以报告对未来预测的估计错误率。典型的划分比例是训练集 50%,测试集 25%,验证集 25%。

图 10.16:验证集可以从训练集中保留出来,以选择多个候选模型
使用随机数生成器将记录分配到分区是一种创建保留样本的简单方法。这种技术首次在第五章,分而治之 – 使用决策树和规则进行分类中使用,用于创建训练和测试数据集。
如果你想要跟随以下示例,请从 Packt Publishing 的网站上下载credit.csv数据集,并使用credit <- read.csv("credit.csv", stringsAsFactors = TRUE)命令将其加载到数据框中。
假设我们有一个名为credit的数据框,包含 1,000 行数据。我们可以将其划分为三个分区,如下所示。首先,我们使用runif()函数创建一个从 1 到 1,000 的随机排序行 ID 向量,该函数默认在 0 和 1 之间生成指定数量的随机值。runif()函数的名字来源于随机均匀分布,这在第二章,管理和理解数据中讨论过。
然后,order()函数返回一个指示 1,000 个随机数排名顺序的向量。例如,order(c(0.5, 0.25, 0.75, 0.1))返回序列4 2 1 3,因为最小的数字(0.1)出现在第四位,第二小的(0.25)出现在第二位,以此类推:
> random_ids <- order(runif(1000))
接下来,使用随机 ID 将信用数据框划分为包含训练、验证和测试数据集的 500、250 和 250 条记录:
> credit_train <- credit[random_ids[1:500], ]
> credit_validate <- credit[random_ids[501:750], ]
> credit_test <- credit[random_ids[751:1000], ]
保留样本的一个问题是,每个分区可能包含某些类别的较大或较小的比例。在某个(或多个)类别在数据集中占非常小比例的情况下,这可能导致该类别被排除在训练数据集之外——这是一个重大问题,因为模型无法学习这个类别。
为了减少这种情况发生的可能性,可以使用一种称为分层随机抽样的技术。尽管随机样本通常应该包含与完整数据集大致相同的每个类别值的比例,但分层随机抽样保证随机分区几乎与完整数据集具有相同的每个类别的比例,即使某些类别很小。
caret包提供了一个createDataPartition()函数,它根据分层保留样本创建分区。以下命令显示了为credit数据集创建训练和测试数据集分层样本的步骤。要使用此函数,必须指定一个类别值向量(在这里,default表示一笔贷款是否违约),以及一个参数p,它指定要包含在分区中的实例比例。list = FALSE参数防止结果被存储为列表对象——这是更复杂采样技术所需的,但在这里是不必要的:
> in_train <- createDataPartition(credit$default, p = 0.75, list = FALSE)
> credit_train <- credit[in_train, ]
> credit_test <- credit[-in_train, ]
in_train向量指示包含在训练样本中的行号。我们可以使用这些行号来选择credit_train数据框中的示例。同样,通过使用负号,我们可以使用in_train向量中未找到的行号来为credit_test数据集。
虽然分层抽样将类别均匀分布,但它并不能保证其他类型的代表性。一些样本可能包含过多或过少的困难案例、易于预测的案例或异常值。这对于较小的数据集尤其如此,因为可能没有足够多的此类案例来分配到训练集和测试集中。
除了可能存在偏差的样本外,保留法还存在另一个问题,即必须保留大量数据用于测试和验证模型。由于在测量其性能之前,这些数据不能用于训练模型,因此性能估计可能过于保守。
由于在较大数据集上训练的模型通常表现更好,一个常见的做法是在选择并评估最终模型后,在全部数据集(即训练、测试和验证)上重新训练模型。
一种称为重复保留法的技术有时被用来减轻随机组成训练数据集的问题。重复保留法是保留法的一个特例,它使用几个随机保留样本的平均结果来评估模型性能。由于使用了多个保留样本,因此模型训练或测试在非代表性数据上的可能性较小。我们将在下一节中进一步阐述这一想法。
交叉验证
重复保留法是称为k 折交叉验证(k-fold CV)的技术基础,它已成为估计模型性能的行业标准。k 折交叉验证不是采取重复的随机样本,这些样本可能会多次使用相同的记录,而是将数据随机分为k个独立的随机分区,称为折。
虽然k可以设置为任何数字,但到目前为止最常用的惯例是使用 10 折交叉验证。为什么是 10 折?原因是经验证据表明,使用更多折数的好处很小。对于每个 10 折(每个包含总数据的 10%),在剩余的 90%数据上构建一个机器学习模型。然后使用该折的 10%样本进行模型评估。经过 10 次训练和评估模型的过程(使用 10 种不同的训练/测试组合)后,报告所有折的平均性能。
k 折交叉验证的一个极端情况是留一法,它使用数据的一个示例作为每个折进行 k 折交叉验证。这确保了用于训练模型的数据量最大。尽管这可能看起来很有用,但由于计算成本极高,因此在实践中很少使用。
可以使用caret包中的createFolds()函数创建 CV 数据集。类似于分层随机留出采样,此函数将尝试在每个折叠中保持与原始数据集相同的类别平衡。以下命令用于创建 10 个折叠,使用set.seed(123)确保结果可重复:
> set.seed(123)
> folds <- createFolds(credit$default, k = 10)
createFolds()函数的结果是一个包含每个请求的k = 10个折叠的行号的向量列表。我们可以使用str()来查看其内容:
> str(folds)
List of 10
$ Fold01: int [1:100] 14 23 32 42 51 56 65 66 77 95 ...
$ Fold02: int [1:100] 21 36 52 55 96 115 123 129 162 169 ...
$ Fold03: int [1:100] 3 22 30 34 37 39 43 58 70 85 ...
$ Fold04: int [1:100] 12 15 17 18 19 31 40 45 47 57 ...
$ Fold05: int [1:100] 1 5 7 20 26 35 46 54 106 109 ...
$ Fold06: int [1:100] 6 27 29 48 68 69 72 73 74 75 ...
$ Fold07: int [1:100] 10 38 49 60 61 63 88 94 104 108 ...
$ Fold08: int [1:100] 8 11 24 53 71 76 89 90 91 101 ...
$ Fold09: int [1:100] 2 4 9 13 16 25 28 44 62 64 ...
$ Fold10: int [1:100] 33 41 50 67 81 82 100 105 107 118 ...
在这里,我们看到第一个折叠被命名为Fold01,并存储了 100 个整数,表示第一个折叠中credit数据框的 100 行。为了创建用于构建和评估模型的训练和测试数据集,需要额外的步骤。以下命令显示了如何为第一个折叠创建数据。我们将选定的 10%分配给测试数据集,并使用负号将剩余的 90%分配给训练数据集:
> credit01_test <- credit[folds$Fold01, ]
> credit01_train <- credit[-folds$Fold01, ]
要执行完整的 10 折交叉验证,这个步骤需要重复 10 次,每次都构建一个模型并计算模型性能。最后,将性能度量平均以获得整体性能。幸运的是,我们可以通过应用我们之前学到的几种技术来自动化这项任务。
为了演示这个过程,我们将使用 10 折交叉验证来估计信用数据 C5.0 决策树模型的 kappa 统计量。首先,我们需要加载一些 R 包:caret(用于创建折叠)、C50(用于构建决策树)和irr(用于计算 kappa)。后两个包是为了演示目的选择的;如果你愿意,你可以使用不同的模型或不同的性能度量,但步骤序列保持不变:
> library(caret)
> library(C50)
> library(irr)
接下来,我们将创建一个包含 10 个折叠的列表,就像之前做的那样。同样,这里使用set.seed()函数是为了确保如果再次运行相同的代码,结果是一致的:
> set.seed(123)
> folds <- createFolds(credit$default, k = 10)
最后,我们将使用lapply()函数对折叠列表应用一系列相同的步骤。如以下代码所示,因为没有现成的函数能完全满足我们的需求,我们必须定义自己的函数并将其传递给lapply()。我们的自定义函数将credit数据框分为训练数据和测试数据,使用训练数据上的C5.0()函数构建决策树,从测试数据生成一组预测,并使用kappa2()函数比较预测值和实际值:
> cv_results <- lapply(folds, function(x) {
credit_train <- credit[-x, ]
credit_test <- credit[x, ]
credit_model <- C5.0(default ~ ., data = credit_train)
credit_pred <- predict(credit_model, credit_test)
credit_actual <- credit_test$default
kappa <- kappa2(data.frame(credit_actual, credit_pred))$value
return(kappa)
})
最终得到的 kappa 统计量被编译成一个列表,存储在cv_results对象中,我们可以使用str()来检查它:
> str(cv_results)
List of 10
$ Fold01: num 0.381
$ Fold02: num 0.525
$ Fold03: num 0.247
$ Fold04: num 0.316
$ Fold05: num 0.387
$ Fold06: num 0.368
$ Fold07: num 0.122
$ Fold08: num 0.141
$ Fold09: num 0.0691
$ Fold10: num 0.381
10 折交叉验证过程只剩下一步:我们必须计算这 10 个值的平均值。虽然你可能会想输入mean(cv_results),因为cv_results不是一个数值向量,所以结果会出错。相反,使用unlist()函数,它可以消除列表结构并将cv_results简化为一个数值向量。从那里,我们可以计算出预期的平均 kappa 值:
> mean(unlist(cv_results))
[1] 0.2939567
这个 kappa 统计量相对较低,对应于解释尺度上的“公平”,这表明信用评分模型仅略优于随机机会。在第十四章,“构建更好的学习者”中,我们将检查基于 10 折交叉验证的自动化方法,这些方法可以帮助我们提高该模型的表现。
由于 CV 从多个测试集中提供性能估计,我们还可以计算估计的变异性。例如,10 次迭代的方差可以计算如下:
> sd(unlist(cv_results))
[1] 0.1448565
在找到性能指标的平均值和标准差后,可以计算置信区间或确定两个模型在性能上是否有统计显著的差异,这意味着差异很可能是真实的,而不是由于随机变化。
不幸的是,最近的研究表明 CV 违反了此类统计测试的假设,尤其是数据需要来自独立随机样本的需要,而 CV 的折叠由于定义上的原因相互关联,这显然是不成立的。
关于从 10 折交叉验证中获得的性能估计局限性的讨论,请参阅Bates S, Hastie T, and Tibshirani R, 2022, https://arxiv.org/abs/2104.00673中的“交叉验证:它估计了什么以及它做得如何?”。
CV 的更复杂变体已被开发出来,以提高模型性能估计的鲁棒性。其中一种技术是重复 k 折交叉验证,它涉及反复应用 k 折交叉验证并平均结果。一种常见的策略是进行 10 折交叉验证 10 次。尽管计算量较大,但这种方法提供的性能估计比标准的 10 折交叉验证更加鲁棒,因为性能是在许多更多次试验中平均得出的。然而,它也违反了统计假设,因此对结果进行的统计测试可能略有偏差。
目前估计模型性能的黄金标准可能是嵌套交叉验证,它实际上是在另一个 k 折交叉验证过程中执行 k 折交叉验证。这项技术在本章 11 的《用机器学习取得成功》中有所描述,它不仅计算成本极高,而且实施和解释起来也更具挑战性。嵌套 k 折交叉验证的优点是,它产生了与标准 k 折交叉验证相比真正有效的模型性能比较,因为标准 k 折交叉验证由于违反了统计假设而存在偏差。另一方面,这个问题引起的偏差对于非常大的数据集似乎不太重要,因此使用从更简单的 CV 方法中得出的置信区间或显著性测试来帮助识别“最佳”模型仍然是合理且常见的做法。
自举采样
相比于 k 折交叉验证(k-fold CV)来说,一个稍微不那么流行但非常重要的替代方法是称为自举采样、自举或简称为bootstrapping。一般来说,这些指的是使用数据的随机样本来估计更大集属性的统计方法。当这个原理应用于机器学习模型性能时,它意味着创建几个随机选择的训练和测试数据集,然后使用这些数据集来估计性能统计量。然后,从各种随机数据集中得出的结果被平均,以获得对未来性能的最终估计。
那么,是什么使得这个程序与 k 折交叉验证(k-fold CV)不同呢?CV 将数据分成单独的分区,其中每个示例只能出现一次,而自举允许通过有放回抽样的过程多次选择示例。这意味着从原始的 n 个示例数据集中,自举过程将创建一个或多个新的训练数据集,这些数据集也包含 n 个示例,其中一些是重复的。
然后从未被选为相应训练数据集的示例集中构建相应的测试数据集。
在自举数据集中,任何给定实例被排除在训练数据集之外的几率是 36.8%。我们可以通过认识到每个示例在每次向训练数据集添加 n 行时都有 1/n 的机会被采样来数学上证明这一点。因此,要进入测试集,一个示例必须没有被选择 n 次。由于被选择的机会是 1/n,因此未被选择的机会是 1 - 1/n,未被选择 n 次的概率如下:

使用这个公式,如果自举的数据集包含 1,000 行,随机记录未被选中的概率是:
> (1 - (1/1000))¹⁰⁰⁰
[1] 0.3676954
类似地,对于一个有 100,000 行的数据集:
> (1 - (1/100000))¹⁰⁰⁰⁰⁰
[1] 0.3678776
当 n 趋近于无穷大时,公式简化为 1/e,如下所示:
> 1 / exp(1)
[1] 0.3678794
由于未被选中的概率为 36.8%,任何实例被选入训练数据集的概率为 100% - 36.8% = 63.2%。换句话说,训练数据仅代表可用示例的 63.2%,其中一些是重复的。与使用 90%示例进行训练的 10 折交叉验证相比,自举样本对整个数据集的代表性较低。
由于仅用 63.2%的训练数据进行训练的模型可能比在更大的训练集上训练的模型表现更差,因此自举的性能估计可能比模型稍后训练在完整数据集上获得的估计要低得多。
一种称为0.632 自举的特殊自举情况,通过将最终性能指标视为训练数据(过于乐观)和测试数据(过于悲观)性能的函数来解决这个问题。然后,最终错误率估计如下:

与交叉验证相比,自举采样的一项优势是它通常在非常小的数据集上表现更好。此外,自举采样在性能测量之外还有应用。特别是,在第十四章 构建更好的学习者中,你将了解如何使用自举采样的原则来提高模型性能。
摘要
本章介绍了评估机器学习分类模型性能的几种最常见指标和技术。尽管准确率提供了一种简单的方法来检查模型正确性的频率,但在罕见事件的情况下,这可能会产生误导,因为这类事件在现实生活中的重要性可能与它们在数据中出现的频率成反比。
一些基于混淆矩阵的指标更好地捕捉了模型性能以及各种类型错误成本的平衡。Kappa 统计量和 Matthews 相关系数是两种更复杂的性能指标,即使在严重不平衡的数据集上也能很好地工作。此外,仔细检查敏感性和特异性,或精确率和召回率之间的权衡,可以成为思考现实世界中错误影响的有用工具。ROC 曲线等可视化也有助于此目的。
值得注意的是,有时衡量模型性能的最佳方法就是考虑它如何满足,或未能满足,其他目标。例如,你可能需要用简单语言解释模型的逻辑,这将排除一些模型。此外,即使模型表现非常好,但如果模型运行速度过慢或难以扩展到生产环境,那么它将完全无用。
展望接下来的章节,对性能进行测量的明显扩展是找到提高性能的方法。随着你继续阅读本书,你将应用本章中的许多原则,同时加强你的机器学习能力并增加更多高级技能。在接下来的页面中,CV 技术、ROC 曲线、自助法和 caret 包将定期出现,因为我们将在已有的工作基础上,通过系统地迭代、精炼和组合学习算法来研究如何制作更智能的模型。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人相聚,并和超过 4000 人在以下地点一起学习:

第十一章:机器学习的成功之道
在机器学习领域,一个过于常见的问题发生在学生从学习方法中获得新鲜兴奋之后,却难以将所学应用于现实世界项目。就像森林小径在夜幕下的黑暗中显得邪恶一样,最初看似简单的代码和方法在没有一步一步的路线图的情况下显得令人畏惧。没有这样的指南,学习曲线显得更加陡峭,陷阱看起来更深。
想到由于机器学习理论与实践之间的鸿沟,无数学生被拒之门外,这令人沮丧。在机器学习领域工作超过十年,并培训、面试、雇佣和监督了许多新从业者后,我亲眼目睹了这种“鸡生蛋,蛋生鸡”的挑战。这似乎是一个悖论:在没有首先获得机器学习经验的情况下,获得机器学习的实际经验似乎是不可能的!
本章的目的,以及随后的章节,是作为先前章节中简单教学示例和现实世界不可抗拒的复杂性之间的桥梁。在本章中,你将学习:
-
影响机器学习模型成功与失败的因素
-
设计可能表现良好的项目的策略
-
如何进行数据探索以提前发现潜在问题
-
为什么数据科学和竞赛与机器学习相关
无论你的成功定义是在该领域找到工作、构建更好的机器学习模型,还是仅仅深化你对该领域工具和技术的了解,你将在接下来的页面中找到一些可以学习的东西。你甚至可能会发现自己突然有了加入许多在线机器学习竞赛的新愿望,这些竞赛可以拓展你的技能并检验你的知识。
什么使机器学习从业者成功?
为了明确,现实世界机器学习的挑战并非源于更多高级或复杂方法的添加;毕竟,本书的前九章涵盖了各种各样、具有挑战性的实际问题,如识别癌细胞、过滤垃圾邮件和预测风险银行贷款。相反,现实世界机器学习的挑战与该领域难以在脚本设置中传达的方面有很大关系,如教科书或讲座。机器学习既是艺术也是科学,就像在没有现实世界实践的情况下学习绘画、跳舞或说一门外语一样具有挑战性,同样难以将机器学习方法应用于新的、未知的领域。
就像探索遥远土地的先驱者一样,你将遇到前所未有的挑战,这些挑战需要软技能,包括坚持和创造力。你将遇到大量、混乱和复杂的数据集,需要深入探索和记录;图表和可视化是这个领域的先驱者图表和地图的等价物。你的分析和编程技能将受到考验,当你失败时,正如常常发生的那样,尤其是在早期,你需要迭代并改进你的错误。使用科学方法创建可重复的实验将成为防止你绕圈子走的面包屑。要成为机器学习领域的坚韧个体主义者,需要既灵活又适应性强,同时还要像狗咬住骨头一样有着无法满足的好奇心——也就是说,一旦咬住,就不会放手。
虽然坚韧个体主义者的概念是机器学习独立元素的绝佳隐喻,但这项工作在很大程度上也是一种团队运动。探索南极洲的苔原或攀登珠穆朗玛峰是一项艰巨的努力,大多数现实世界的机器学习项目也是如此。独自承担这样的任务是不明智的或危险的,如果风险很高,甚至可能非常危险。然而,即使在团队中,由于计划不周或沟通不畅,也可能发生失败。数据科学家开发模型和数据工程师提供数据之间的交接尤其危险。如果一个团队走到了这一步,那么在模型必须实施到业务实践和 IT 系统中的时候,还会发生一个更具挑战性的交接。因此,在许多其他原因中,大多数机器学习模型从未被部署。
如果感觉现实世界的机器学习需要超人的技能集,那么如果查看最近的在线招聘信息,这可能并不离真相太远。一个非常具体的职位要求有构建推荐系统和设计图像识别工具的经验,以及熟悉图表示学习和自然语言处理。另一个要求有在非常大数据集上构建“高性能推理管道”的经验。许多人希望有深度神经网络的经验,而有些人则更通用,需要“对机器学习基础有扎实的理解”以及“能够分析各种结构化和非结构化数据的能力”。这些多样的技能组合可能会让初入职场的从业者感到有些害怕,并使他们想知道从哪里开始。
图 11.1列出了该领域常用的所谓“硬”技术技能以及一些有益的“软”特质。到本书结束时,你将接触到图中左侧的大部分工具和技能,通过完成练习,你将培养出右侧的特性。请记住,找到一个对这些技能中的每一个都有深入了解的人是非常罕见的。这些是传说中的“独角兽”。尽管如此,他们可能会承认还有很多东西要学。总是有可能更深入地学习特定主题。鉴于有限的时间和精力,大多数人必须在广泛涉猎多个领域或深入少数几个领域之间做出妥协。

图 11.1:现实世界的机器学习需要众多技术技能(左侧)和软技能(右侧)
提高机器学习技能,尤其是软技能的最好方法之一是通过竞赛。如图 11.1 所示,竞赛以多种方式提高了机器学习的结果;它是个人创新和提升自身表现动力的关键组成部分,同时它也促进了团队为实现共同目标而进行的强大合作。出于这些原因,竞赛长期以来一直是机器学习培训的一部分。
例如,学术计算机科学家已经参加了超过 25 年的名为知识发现与数据挖掘(KDD)杯的竞赛(www.kdd.org/kdd-cup),该竞赛根据参赛者在每年变化的机器学习任务中的表现来选择获胜者。类似的竞赛存在于图像、文本和音频数据等专门主题,以及许多其他领域。
在盈利空间中最早广泛知名的机器学习竞赛之一,2006 年,Netflix 视频流媒体服务开始提供 100 万美元的奖金,以使其电影推荐准确率提高 10%。这一事件带来的宣传促使了一系列由企业赞助的挑战活动,包括在 Kaggle(www.kaggle.com)上列出的那些,该网站举办竞赛,为在各个领域推进复杂机器学习任务的先进状态提供现金奖励。Kaggle 很快变得非常受欢迎,激发了一代机器学习实践者的灵感,其中一些人利用他们的胜利作为未来在咨询和科技公司工作的跳板。在第十二章,高级数据准备中,你将学习一些这些 Kaggle 冠军的经验。
并非每个人都喜欢面对面的竞争,但你仍然可以与自己竞争,或者想象你的公司在与其他企业竞争。一些从业者满足于挑战自己,在模型性能统计上超越自己的“高分”。其他人则受到“适者生存”和这样一个想法的激励,即商业市场奖励那些超越他人的公司——其中一些最终会灭绝。无论如何,竞争的目标不是提升个人的自尊,而是激励创新和持续的质量改进,确保你的技能保持最新。
持续学习和不断应用所学知识可能是机器学习爱好者最重要的特征。正如在第一章,介绍机器学习中所描述的,该领域是在数据量、复杂性以及处理这些数据所需的计算能力和统计方法共同增长的环境中演化的。这种演变并没有放缓的迹象。数据、工具和方法会发生变化,但总会有需要人们去应用它们的需求。因此,将每个项目视为学习新知识的机会。构建更好模型的迭代过程,有时甚至具有上瘾性,是开始这段旅程的一个合适起点。
什么因素使机器学习模型成功?
到目前为止,我们主要从定量的角度来理解一个成功的机器学习模型意味着什么。监督学习者在最初被认为如果准确率高就会表现良好。
在第十章,评估模型性能中,我们将这个定义扩展到包括其他更复杂的性能度量,如马修斯相关系数和 ROC 曲线下的面积,以解释准确性对于不平衡数据集来说是误导性的,并考虑潜在用例的性能权衡。
到目前为止,我们将模型性能的定性度量限制在无监督学习的领域,尽管在预测建模领域也存在一些无法量化的考虑因素。例如,考虑一个计算成本如此之高以至于无法在实时应用中实施,或者算法如此复杂以至于无法向申请人提供其决策解释的信用评分模型。考虑到这一点,如果选择没有模型,我们可能会倾向于选择一个更简单、不那么精确的模型。毕竟,即使是简单的预测模型通常也比没有好——这里的“通常”是一个关键限定词,考虑到我们很快将要探讨的模型在现实世界中失败得非常严重的情况!
可能存在难以融入模型本身的业务成本、资源限制或人力资源因素,这些因素会影响建模项目的成功。为了说明这一点,想象你创建了一个客户流失预测算法,该算法可以以高精度识别最有可能停止购买产品的客户。不幸的是,在部署模型后,你收到了销售代表的投诉,他们试图保留这些客户:
-
“我已经知道这些人会流失。你告诉我什么新东西?”
-
“那个客户两个月前就停止购买了。他们已经流失了。”
-
“我们实际上希望这些人流失,因为他们是低价值客户。”
-
“我已经和那个客户谈过了,我们无能为力。”
-
“你的模型预测没有意义。我不信任它。”
-
“你怎么知道这个客户会流失?他们看起来对我很满意。”
-
“我们试图保留那个客户会亏损。”
-
“预测似乎没有以前好。发生了什么?”
这类评论在现实世界的机器学习中很常见,代表了项目整体成功中的常见障碍,即使按照传统的统计性能指标,该模型被认为准确或有效。这些障碍的问题在于,没有深入了解模型将用于其中的业务,它们就不容易被克服。另一方面,这类问题往往遵循相似的模式,通过实践可以预见。
以下表格将问题分为四组,以及典型的症状和可能的解决方案:
| 陷阱 | 症状 | 可能的解决方案 |
|---|---|---|
| 预测显而易见的事情 |
-
一个更简单的模型(或一个众所周知的经验法则)几乎表现得一样好
-
模型的性能统计数据看起来“太好了,以至于不真实”
-
模型在训练集和测试集上表现良好,但在部署时没有产生影响
-
结果似乎不可避免;拥有预测结果并没有提供可采取的行动来干预
|
-
重新表述问题,使其对学习算法更具挑战性
-
注意避免死记硬背、循环逻辑或目标泄露(拥有一个本质上是对目标的代理的预测器)
-
重新编码目标变量,或限制对与目标高度相关的某些预测器的访问,这样模型就能找到新的联系而不是显而易见的联系
-
检查预测概率的中值,或过滤掉最明显或不可避免预测
|
| 进行不公平的评估 |
|---|
-
模型在实际世界中的表现远不如在测试中
-
确定使用的“最佳”模型需要大量的迭代或调整
-
正确或错误的预测似乎是可以预测的;模型在某些数据段上的表现可能比预期好或差
|
-
确保使用适当的评估数据集
-
正确使用交叉验证并了解其局限性
-
警惕内部相关数据的常见形式,并了解在这些情况下如何构建公平的测试集
|
| 忽视现实世界的影响 |
|---|
-
结果很有趣,但影响不大
-
实施模型的不明确业务案例
-
模型忽略了重要的示例子集
-
过度依赖简单的定量性能指标
-
忽略预测概率,对所有预测给予同等权重
|
-
使用模拟和实验预测项目在各种可能情景下的影响
-
在输出上设置反映现实世界约束的过滤器
-
创建 ROC 曲线和其他成本感知的性能指标
-
关注高影响的结果,而不是“低垂的果实”
|
| 缺乏信任 |
|---|
-
利益相关者拒绝根据数据采取行动,而依赖直觉
-
倾向于用“老”方法做事
-
对系统地处理预测缺乏兴趣
-
利益相关者挑选他们同意/不同意的结果
-
反复要求证明预测的合理性
|
-
确定将有助于推广项目的“冠军”
-
将利益相关者纳入建模过程(特别是数据准备)并迭代他们的反馈
-
制定“电梯演讲”和“路演”幻灯片,以预先解决常见问题
-
记录模型产生影响的案例,并反复讲述这些故事
-
以可操作的形式输出预测,例如“交通灯”方法
-
使用模型可解释性工具
|
很可能需要花费许多页面来描述每个这些陷阱类别的职业生涯经验,但遗憾的是,这并不能替代自己艰难学习的过程。话虽如此,有一些广泛的指导方针可能有助于你避开道路上的某些颠簸。
避免明显的预测
当涉及到预测显而易见的事情时,可能根本不明显的是它最初是如何或为什么发生的!这个问题的简短答案是,往往比人们想象的更容易意外构建一个“作弊”的模型,通过找到一种简化问题的方法,或者“短路”问题,而不做真正理解问题的必要深入工作。
这对于跟踪特征和结果随时间变化的项目尤其正确,例如预测未来将发生的事件。这类项目通常从时间序列数据开始,这些数据反复测量随时间变化的示例的相同属性。
我们将在第十二章“高级数据准备”中从数据准备的角度考虑时间序列数据,但就目前而言,只需说具有时间维度的数据必须仔细编码,以避免使用未来的值来预测过去。这个问题属于更广泛的泄露类别,或者更具体地说,是目标泄露,它描述了学习算法对要预测的目标有所了解的情况,而在现实世界的部署环境中,它不会拥有这种了解。当存在明显的顺序问题时,例如使用当前的信用评分来预测过去的贷款违约,目标泄露在事后看来非常明显。它仍然令人惊讶地经常发生,尤其是在分析师简单地将所有潜在的预测变量放入模型中,而没有考虑它们的意义时。在其他时候,目标泄露非常微妙且难以检测,只有在利益相关者拒绝结果为“太好了,以至于不可能是真的”时,才会通过更深入的分析揭示出来。
当目标变量以这种方式定义,从而产生同义反复或定义性预测时,目标变量与预测变量之间的关系在这种情况下不必总是完全确定性的,但可能是过度相关的,或者以某种不清楚的方式相互关联。例如,假设我们使用一个业务定义,该定义使用销售中的 3 个月滚动平均来定义这个月的流失状态。然后,使用上个月和两个月前的数据作为当前月份流失的预测变量,算法就能得到正确答案的三分之二——那些月份销售量低的客户,按照定义,很可能流失。当使用复杂的调查数据时,这种错误很容易发生,其中个人调查响应被用作预测变量,从调查响应集中计算出的分数被用作要预测的目标。一般来说,为了避免目标泄露,最好使用由完全独立的过程生成的目标,并且收集时间晚于用于预测的数据。
小心使用未来预测过去!对于一个将在现实世界中部署的模型,这几乎总是目标泄露存在的明显迹象。
当目标变量以某种隐藏方式通过业务实践与预测变量相关联时,也可能发生泄露。例如,想象一个场景,一个制造商试图通过建立一个模型来预测哪些客户最有可能购买他们品牌的汽车,以此来提高客户获取率。
创建一个预测人员是否打开或点击营销电子邮件的预测器似乎是合理的,但如果营销电子邮件只发送给以前的客户,那么模型很可能会做出常识性的预测,即忠诚的客户往往会保持忠诚,销售人员不太可能印象深刻。由于目标和预测器之间的强烈联系,模型基本上忽略了之前从未购买过该品牌的人群,这是最有可能影响公司利润的人群!从模型中排除这个预测器,或者仅基于首次购车者构建模型,将使算法专注于最具影响力的预测,或者那些不太不可避免的预测。
导致明显预测的另一个因素与自相关有关,它描述了类似惯性的现象,即时间上接近的测量值往往在数值上也接近。基于这一观察,可以得出结论,今天某物的最佳预测通常是昨天该物的价值。这几乎是普遍适用的:今天的能源消耗、支出、卡路里摄入、幸福感以及几乎所有可以想象的东西都与前一天这些事物的状态紧密相关。换句话说,自相关意味着在人们或其他分析单位(如家庭、企业、股票价值等)之间的长期变化往往大于这些单位在较短时间内内的变化。
机器学习算法可以快速识别自相关实例,并且乐于构建一个使用昨天数据来预测今天情况的模型。它甚至会在测试集上拥有很高的准确率,这导致许多缺乏经验的分析师忽略了潜在的问题。具体来说,这仅仅是一种过度复杂的列表排序方法!如果一家企业想要预测明天最有可能大量消费的客户,他们可以直接查询数据库中今天花费最高的客户,并在简单的电子表格应用程序中对列表进行排序。实际上,这种排序方法在名为最近性、频率、货币价值(RFM)分析下已经行之有效多年,该分析在第六章,预测数值数据 – 回归方法中介绍,与基于机器学习的方法形成对比。RFM 方法基本上表明,购买频率更高、购买金额更大的客户更有可能继续这些趋势。不幸的是,这几乎对预测哪些新客户最有可能在未来成为顶级消费者毫无帮助。
强迫学习算法处理这个更具操作性的问题,需要围绕“行动”重新定义目标变量。目标需要非常具体,并指明模型将产生影响的精确情况。在先前的例子中,与其建模总支出,不如建模随时间变化的支出增加或减少。
这被称为delta,预测销售 delta 将允许销售代表在预测的增加或减少发生之前进行干预。或者,也可以建模干预本身的影响;例如,与其预测最有可能流失的客户,不如建模最有可能对流失干预措施做出积极反应的客户。当然,这需要记录先前反流失干预措施属性的历史数据。通常,这种类型的数据在大多数企业中是缺乏的。
进行公平的评价
在机器学习项目中,在纸上表现良好但在现实世界中表现不佳的情况并不少见。这有时与进行不公平的评价的问题有关,无论是因为疏忽还是故意欺骗,这种错误都不应该存在。鉴于前几章案例研究的成果,我们知道不应假设训练性能是未来性能的无偏估计。因此,我们始终构建一个保留测试集来模拟未来的未见数据,并提供这个公平的估计。在第十章“评估模型性能”中,我们了解到为了比较和选择多个候选模型,我们应该使用验证数据集,这样测试集就可以“存放在保险库中”,并保持对未来性能的无偏估计。根本问题是,通过选择在测试集上表现最佳的模型,本质上是对测试集进行了过拟合,性能被夸大,就像对训练集进行过拟合时一样。违反“保险库”规则,不出所料,会导致现实世界中性能模型出人意料地差。
也许更令人惊讶的是,正如可能对训练和测试数据进行过拟合一样,也可能对验证集进行过拟合。这尤其适用于在迭代过程中构建了众多模型,或者模型被广泛“调整”以识别最佳参数值的情况,正如将在第十四章“构建更好的学习者”中讨论的那样。问题是,通过反复使用相同的数据,关于数据的一些信息不可避免地“泄露”到学习算法中,最终可能导致对验证集的过拟合。
将模型构建、模型选择和模型评估的过程可视化为一系列步骤可能会有所帮助,如下面的图所示。在训练步骤中,算法确定数据的最佳拟合;在此过程中,它优化了内部值,称为参数,这是模型抽象的基础。在某些情况下,例如回归模型、神经网络和支持向量机,参数对最终用户来说是可见的,如系数、权重或支持向量。在其他情况下,例如 k-NN、决策树和规则学习器,参数则更具有概念性;将参数视为算法为了拟合数据而做出的内部选择。
在任何情况下,由于模型已经选择了一组“最佳”参数来拟合训练数据,任何性能估计都可能过于乐观,并且它在推广到测试集时可能会至少稍微表现得更差。

图 11.2:由于在训练和验证过程中选择了“最佳”模型,性能估计往往过于乐观
验证数据集用于在多种类型的模型之间进行选择,测试单一类型模型的多个迭代,或者同时进行这两者。这可以理解为识别学习器最优超参数的过程,或者任何设置在学习器外部且不由算法本身估计的参数。在阅读了前面的章节后,你将已经熟悉了几个超参数,例如 k-NN 算法中k的值、SVM 算法的成本参数C和核函数,以及神经网络的学习率和隐藏节点的数量,等等。广义而言,超参数的概念不仅指直接影响特定算法的选择,还包括算法的整体选择,以及算法如何与其他算法结合,正如你将在第十四章“构建更好的学习器”中学习到的那样。由于即将成为明显的原因,将“超参数”视为任何在学习过程之外做出的决策,并且可能影响模型拟合,可能是有帮助的。
现在,假设你有一个验证数据集,并且你在这个数据集上系统地评估了多种方法。你可能测试各种算法,比如神经网络与决策树和 SVMs,然后使用不同的超参数值测试这些模型的各个迭代版本。在所有这些数十或数百种可能性中选择“最佳”表现者,基于验证集的性能,当应用于测试数据集时,这个模型的性能很可能会因为对验证集的潜在过拟合而下降,就像它在训练和验证之间所做的那样。我们留下的问题是,我们是否真正选择了最佳模型,以及性能的稳健性如何。
在第十章“评估模型性能”中介绍的 10 折交叉验证方法,乍一看似乎可以解决这两个问题。确实,计算 10 个折叠的平均值和标准差的做法确实提供了模型性能稳健性的度量,因为底层训练数据发生变化。这提供了关于模型将如何泛化到未来未见数据的感觉。因此,一个常见的做法是在同一数据集上重复运行 10 折交叉验证,以比较各种超参数并选择胜者。
然而,如图 11.3 所示,标准形式的 10 折交叉验证(左侧)并不提供验证数据集,因此只能估计模型内部搜索最优参数时产生的泛化误差。例如,假设我们比较 10 折交叉验证的性能统计信息,以帮助决定神经网络是否比决策树表现更好,或者我们使用 10 折交叉验证来确定对于 SVM 方法来说,25 个潜在值中的哪一个C是最好的。在这两种情况下,请注意我们再次在赢家中进行挑选,这会导致我们的性能估计被夸大。最终,如果使用交叉验证进行广泛的超参数调整,我们可能会对模型的真实未来性能过于乐观。

图 11.3:嵌套 10 折交叉验证为在验证集上学习最优超参数添加了一个“内部循环”的 10 折交叉验证
最好将交叉验证视为不仅仅是估计模型拟合训练数据的能力(学习最佳参数),还包括在选择最终模型过程中做出的所有决策的整个流程(学习最佳超参数)。假设我们可以编写一个 R 函数来自动化这些决策;它接受几个候选方法,并从中选择表现最好的一个。
由于缺乏一个更正式的术语,让我们称这个为“评估”函数。在这种情况下,我们可能会考虑通过将每个 10 折中的每个折分为训练集和验证集,而不是训练集和测试集,来修改我们的标准 10 折交叉验证方法。每个模型将由评估函数评估其在验证数据集上的性能,获胜的模型将由评估函数选择,作为在 10 个折上平均性能最佳的模型。此时,我们仍然试图弄清楚性能在未来对新、未见过的数据集的泛化程度如何。
如图 11.3所示,嵌套交叉验证(通常是嵌套的 10 折交叉验证)的目的是将交叉验证作为一个内部循环,在其中每个内部折上学习参数,最佳超参数由外部循环(通常也是 10 折交叉验证)的每个折上的评估函数确定。在内部循环的每个折中,评估函数可以在验证数据集上评估任意数量的模型。也许我们正在评估三种不同类型的模型,如决策树、k-NN 和 SVMs,或者我们可能正在评估单个神经网络的 25 个不同的学习率。最后,无论我们评估的是 3 个还是 25 个模型,评估函数只提名一个最佳模型进入外部循环进行评估,在外部循环中,10 个最佳内部循环模型的性能值在相应的测试集上平均。这意味着嵌套交叉验证不仅测量单个模型的性能,还测量选择模型、学习最佳参数和超参数的整个过程。
如同在第十章“评估模型性能”中所述,鉴于嵌套交叉验证的复杂性——无论是实现还是解释——标准的 10 折交叉验证对于大多数机器学习的实际应用通常已经足够。一方面,由于验证过程中发生的信息泄露相对较小,数据集越大,对分析的影响就越小。另一方面,如果两个模型之间的性能差异很小,那么过拟合的程度可能足以导致错误的选择。这一点在调整、迭代和超参数化的数量增加时尤其正确。
总体而言,嵌套交叉验证是否必要或过度,很大程度上取决于结果将如何被使用。对于一个行业基准或学术出版物,更复杂的嵌套技术可能是合理的。对于风险较低的工作,选择更简单的标准 10 折交叉验证可能更明智,并用节省下来的时间来考虑模型如何部署。正如下一节将清楚说明的,一个在理论上表现良好的模型在现实世界中失败并不罕见。因此,能够更快地达到失败点的简单方法将为你提供更多时间进行纠正。
考虑现实世界的影响
创建一个机器学习项目,从所有客观指标来看,似乎将在其预期任务中表现良好,这种肾上腺素激增往往会导致另一个常见的陷阱:不考虑现实世界的影响。现实世界的机器学习项目通常不是为了娱乐而进行的练习;它们通常是成本高昂、耗时的工作。委托机器学习项目的利益相关者通常期望投资回报率(ROI),而不仅仅是生产模型所需的时间和金钱,还包括将用于采取行动的资源。一个完全不工作的模型是一次性的沉没成本,但一个提供糟糕建议或浪费或误导公司未来时间和资源的模型,则是将好钱扔到坏钱里。对一个好主意不断投入,而它根本就没有按预期工作,这是沉没成本谬误的根源,其中一个人相信可以通过更多的投资来挽救一个失败的项目。以这种方式使用的机器学习项目比什么都没有还要糟糕。
除了浪费实施团队的资源外,一个项目还可能造成意外的伤害。即使机器学习算法在其训练的基础上做的是合理的事情,这也是正确的。为了提供一个幽默的个人例子,请看以下图片,它展示了我在短短几个月内从同一家信用卡公司收到的数十封信件。更糟糕的是,这甚至不是全部,因为我直到意识到发生了什么才开始收集它们!甚至有些时候我每天都会收到一封信。
既然知道银行通常不会在没有充分理由的情况下浪费邮资,我的怀疑是,一个客户获取机器学习模型判定我将成为一个值得获取的宝贵客户,即使这意味着花费大量资金。

图 11.4:一个算法看起来合理的解决方案可能对现实世界产生负面影响,例如“垃圾邮件”式地向最终用户发送请求
尽管我应该为算法认为我足够有价值而感到荣幸,不断给我发送邮件,但最终可能损害了我对信用卡公司的印象。虽然我不能确定这不是由某种故障引起的,但在发生时,我不禁回想起我在雷伊德·加尼(Rayid Ghani)的演讲中听到的话。具体来说,该竞选活动对其电子邮件征集进行了数千次实验,并在此过程中发现,他们发送的电子邮件越多,赚的钱就越多。这种情况几乎没有上限。
在任何时候,从其邮件列表中取消订阅的人数都没有超过他们通过不断占据电子邮件收件箱而收到的额外捐款。也许这样的发现可以解释我收到信用卡公司的大量纸质邮件;这当然可以解释消费者现在在他们的收件箱中看到的电子邮件营销的巨大增加。只有时间才能告诉我们这种方法的长期后果是什么。
想了解更多关于奥巴马竞选活动分析电子邮件行为数据时发现的有趣发现,请访问www.wired.com/2013/06/dont-dismiss-email-a-case-study-from-the-obama-campaign/
而不是将其留给运气,对现实世界影响表示关心的最佳方式是将机器学习项目设计成与最终部署场景的紧密近似。模拟、实验以及小规模的概念验证(POC)试验运行是这一目标的特别有用的工具。在项目甚至被考虑部署之前,机器学习实践者应该能够回答典型的利益相关者问题,例如:
-
使用这个模型可以节省多少美元、生命、小工具等等?
-
对于每一次成功的预测,会有多少“失误”发生?
-
投资回报率(ROI)是否依赖于高风险、高回报的事件,还是它积累的是较小的、缓慢而稳定的胜利?
-
模型的性能在某些类型的示例上是否明显更好,或者它在整个集合中表现均匀?
-
模型是否系统地偏向或忽略感兴趣的类别,例如受保护的年龄、种族或民族群体、地理区域或客户细分?
-
可能会有哪些意想不到的后果?算法是否可能造成伤害,或者被不良行为者利用来造成伤害?
设计巧妙的项目,包括模拟部署和估计投资回报率(ROI)的计算,将有助于提供数据来回答这些问题。在这些项目中,重要的是不仅要计算准确性,还要更进一步,计算一个数字,描述一旦模型部署后这种准确性如何转化为现实世界的影响。
进行公平的比较需要足够的商业知识来构建或识别一个合适的对照组——现有的现状或作为基准或基线参照框架的情况。
对于一个癌症识别模型,例如,人们可能会估计使用该模型的预测结果所挽救的生命数量,然后将其与使用传统人工诊断所挽救的生命数量进行比较。在机器学习模型的干预没有明显参照框架的情况下,基线可能是一个完全不使用模型的情况,其结果基本上是随机决定的,或者使用一个“愚蠢”的模型,该模型总是选择多数类或预测平均值。你也可以使用像第五章中讨论的 OneR 规则学习算法这样的简单模型来模拟一个简单的经验法则。
自然地,现实世界极其复杂且不断变化,因此,不仅要估计模型的影响,还要检查其在各种约束下的影响稳健性。这些约束中的一些可能是伦理方面的,例如确保它在感兴趣的子群体中表现均匀。在商业环境中,这些重要的子群体可能包括年龄、种族、民族、性别和经济状况等类别;在医疗环境中,这些可能还包括基于健康特征,如体重指数和受试者是否吸烟。分析员需要确定哪些现实世界情境对于评估性能最为重要。一旦这些情境确定,分析员就可以对每个子群体进行预测,并比较模型在不同群体中的性能,检查是否存在系统性过度表现或表现不佳的群体所反映的偏差。
除了子群体之间的差异外,由于用于训练或评估的数据随时间变化,模型在现实世界中的影响也可能发生变化。在实践中,10 折交叉验证甚至更复杂的嵌套交叉验证变体过于简化了机器学习模型在现实世界中构建和部署的许多方面。它们没有反映许多潜在的外部因素,而不仅仅是训练数据的变异,这些因素可能会影响模型未来的性能。
为了帮助理解这些其他因素,图 11.5 提供了在大多数现实世界环境中模型通常是如何构建和评估的简化视图。它想象了一个由模型需要预测其预测的实体组成的数据流;你可以想象这是一行潜在癌症患者、客户、贷款申请人等等的单文件。为了预测他们的未来结果,我们通常会在今天的历史时间点对这一数据流进行快照,或者记录一段时间内的观察结果,然后将这些数据分成用于训练、验证和测试的单独集合——可能使用 10 折交叉验证或类似方法。从这个快照上构建和评估的模型性能估计被认为是未来性能的合理估计,但我们不能确定,除非未来最终发生。
当然,在一个理想场景中,我们只需在今天的数据(或过去的数据)上构建我们的模型,然后等待一段时间“未来”发生并执行评估,但在实践中这非常罕见。如果你所在的企业有远见卓识,能够收集足够的历史数据,或者有耐心等待数据收集,那么你就很幸运了;在许多情况下,商业发展得太快,资源又太稀缺,以至于这种情况不成立。

图 11.5:用于训练和评估模型的通常是早于部署时间的数据快照
即使模型可以在真正从未见过的未来数据上进行评估,时间的无情和数据流的持续往往会在部署后造成难以预见的问题。具体来说,由于现实世界不断变化,部署的机器学习项目往往会受到模型退化的影响,这是一个描述在实施后随着时间的推移其性能逐渐下降的常见现象的术语。
有时,这可能是由于输入数据随时间发生的系统性变化,称为数据漂移,这可能是由于购买行为或疾病传播等周期性模式,这些模式随季节变化,以及数据本身的意义或幅度的变化。数据漂移发生在改变测量某物的尺度之后,例如从 1 到 5 的尺度切换到 1 到 10,以及整体膨胀,如货币通货膨胀。即使属性值本身没有变化,也可能发生微妙的变化,例如,如果调查问题的措辞发生了变化。
也许调查使用了三个值来表示“既不同意也不反对”,但后来被翻译成另一种语言为“没有意见”。这种随时间推移的意义漂移可能会随着时间的推移导致性能下降,但可以通过严格维护代码和定义来在一定程度上缓解——说起来容易做起来难!
模型退化另一个贡献者是模型漂移,这发生在目标变量与预测变量之间的关系随时间变化时,即使底层数据的含义保持不变。这通常反映了模型外部的变化,或者是一个难以预见的外部力量。例如,经济或客户行为和偏好的广泛变化,疾病行为的进化变化,或其他会从根本上破坏学习算法在训练期间发现的模式的因素。幸运的是,模型漂移可以通过更频繁的训练来纠正——模型只是学习新的模式——但这会导致困惑和进一步的复杂性,因为它并不清楚应该多频繁或多久重新训练模型。
尽管人们可能会认为频繁或近乎实时的训练总是最好的,但这实际上大大增加了部署的复杂性,并且可能导致模型预测随时间增加的变异性,从而可能导致对模型输出的不信任——这是下一节中描述的问题。或许最好的方法是进行实验,确定一个最适合特定用例的模型刷新计划,比如每年、季节性或是在怀疑数据漂移时。然后,密切监控结果并根据需要调整。像往常一样,没有免费的午餐!
建立对模型的信任
导致数据科学项目失败的陷阱的最后一类与模型实现的细节或模型本身的性能关系不大;相反,它源于关键利益相关者对项目的基本不信任。这个陷阱特别容易导致燃尽,因为它发生在工作流程的后期。在投入无数小时的项目中,却看到它未能获得请求它的利益相关者或最有可能从其实施中受益的最终用户的认可,这是令人沮丧的。尽管听起来很令人沮丧,但听到关于问题严重性的原始统计数据更是令人不安;快速的网络搜索揭示了关于机器学习项目进入生产的比例的各种估计——没有一个是好的。一些公司估计失败的机器学习项目数量超过 60%,而其他估计高达惊人的 85%。
怎么可能只有大约 15%的项目能够成功启动?即使使用更乐观的估计,也少于一半的项目会被实施!这可能是真的吗?不幸的是,如果这个数字看起来不可思议地低,你很可能在机器学习领域工作的时间不长,或者你是那些幸运的少数之一,在一家已经找到解决这个失败项目流行病的方法的公司工作。相反,大多数从业者遵循类似的模式,他们的努力在组织中不断受挫,因此他们寻找另一个可以产生更大影响的工作场所。由于大多数组织都存在同样的问题,不满的循环不可避免地很快就会再次开始。
导致这次失败启动的原因多种多样,很容易将责任归咎于那些常常对机器学习的潜在收益抱有不切实际的期望、对完成项目所需的成本和资源缺乏了解的利益相关者。也许他们被围绕人工智能的炒作所吸引,并期望它能够以最小的投资实现即插即用。毕竟,资源不足的信息技术团队并非最近才出现。然而,机器学习从业者可以采取许多措施来积极建立利益相关者对项目的信任,并大大增加项目成功扎根的可能性。在建立对建模项目的信任方面,一个关键部分是认识到机器学习既是艺术也是科学。随着经验的积累,人们会理解虽然软技能并非执行工作的根本,但它们对于工作的最终成功几乎是必不可少的。
机器学习从业者可以从对魔术师或幻术师的研究中学习到很多,特别是在表演技巧方面。当然,这并不是说他们的工作本身应该是虚假的——你不想成为一个卖假药的人——相反,考虑这样一个事实:对于最终用户来说,机器学习和人工智能可能就像魔法一样是一个黑盒子。如果它按预期工作,无疑会激发一种神奇的感觉。由于对构建工具投入了大量精力,从业者可能甚至没有意识到这一点。
正如世界历史上最成功的商业魔术师大卫·科波菲尔所说,“魔术师失去了体验惊奇感的机会。”精明的从业者将利用机器学习的神奇吸引力,并确定早期采用“倡导者”,他们将推广这项工作并帮助它在组织中扎根。这些利益相关者将更多地了解业务运营,在构建模型的过程中包括这样的利益相关者,尤其是在收集数据和将模型转化为行动的阶段,将提供最终用户视角,并有助于确保项目最终是有用的而不是仅仅有趣的。
大多数成功的魔术师都会将他们的表演带到世界各地的观众面前。在机器学习中,这不仅有利于向不断增长的利益相关者受众推销项目,而且还有助于收集关于哪些做法可行、哪些不可行的反馈。在这个巡回演出期间,制作一个电梯演讲稿,或者一个简短的两到三句话的项目描述,这在简短的电梯之旅中可以提供,是明智的。通过在一对一的场合反复练习,与潜在最终用户进行练习,不仅可以提高你提供演讲稿的能力,还可以帮助识别项目的其他支持者,并收集成功故事。这些成功故事可以后来穿插到未来的对话中,以在项目周围营造更大的轰动。当然,确保为受众提供适当的细节水平。借用著名的手法魔术师杰里·安德鲁斯的名言,我们的工作是让受众眼花缭乱,让他们“上当”,但不是让他们觉得自己是傻瓜。
问题、批评,甚至直接的负面评论都可能是有益的;这些可以促进项目的改进,也可以添加到 FAQ 文档或演示文稿中,以便向更广泛的受众展示。在最初的几次面向大型受众的演示中,引入已知出席的观众的成功故事,或者甚至在观众中培养一个支持者来提出预定义的问题,可以增强项目在受众中的信任度,就像魔术师经常在观众中培养一个“志愿者”来“自愿”参与魔术一样。随着时间的推移,你将开始识别类似的问题和批评,并提前解决它们,或者更好的是,提供足够的信息,让受众自己发现答案。将项目的一些缺陷公之于众,并似乎意外地向观众展示,对于已知的批评者来说,这种方法可能特别有效。这尤其强大,因为根据长期在拉斯维加斯表演的搭档彭斯和泰勒的说法,“当魔术师让你自己注意到某件事时,他的谎言就变得无法穿透。”
有时,建立信任的道路不是通过华丽和表演,而是使用统计工具和方法提供一种更直观的方式来采取基于模型的预测的行动。例如,假设已经建立了一个模型来预测某人是否会患上肺癌。由于肺癌在总体人群中相对罕见(大约每 16 个美国人中就有 1 个会在其一生中患上肺癌),许多模型将倾向于预测癌症的低概率,即使在最有可能患上肺癌的人群中也是如此。对于一个比普通人有 8 倍可能性患上肺癌的人来说,他们是否会在一生中患上肺癌仍然是一个硬币的两面,因此对于这个人来说,合理的预测可能仍然是“无癌症”。鉴于这个人的高相对风险,早期干预可能是有影响力的,但仅仅声明“无癌症”的预测对于区分这个人与风险较低的其他人没有帮助;同样,如果没有了解基线癌症概率为 6.25%,那么 49.999%的预测概率几乎没有用处。
由于许多机器学习项目专注于罕见事件,将原始预测概率转换为相对风险类别可以帮助建立信任并驱动行动。对于之前描述的肺癌模型,这可能意味着创建一个红灯、黄灯和绿灯的“交通灯”系统,其中红灯表示最高风险,黄灯表示中等风险,绿灯表示低风险。这些红、黄、绿标签可以直接在报告或仪表板上展示,并能够被需要采取行动的代理人员直观理解。
对于旨在其他用例的模型,其他格式可能更合适以驱动行动。一家企业可能会使用小学的 A、B、C、D 或 E 等级制度来评估贷款申请人,另一家企业可能会将原始预测的流失概率转换为基于百分比的系统,按客户流失的可能性对客户进行排名,而一些模型可能甚至需要更复杂的展示方式,不仅包括预测,还包括置信度评级。路演的一部分好处是,在用模型的预测能力眩惑观众的同时,你也许已经获得了关于哪种输出格式能驱动他们使用它的感觉。
即使最终用户的使用率很高,或者也许尤其是最终用户的使用率很高,也可能会出现一些令人费解或看似无意义的预测。有时,最终用户只是好奇为什么做出了特定的预测。这些问题只能通过深入模型本身来回答,这对于像神经网络这样的黑盒模型几乎是不可能的,即使是对于回归和决策树等更简单的方法来说也是一项挑战,尤其是当这些模型被应用于大型和复杂的数据集时。这些差异触及了模型可解释性的概念,即人类理解模型工作方式的能力。如果一个模型简单且透明,它将很容易被理解,利益相关者也倾向于信任它。
与可解释性密切相关,可解释性通常描述了一个模型是如何做出预测的,了解为什么做出特定的预测也同样重要。随着深度学习模型在金融和医学等对模型可解释性有严格要求的领域的广泛应用,模型可解释性这一领域正在迅速发展。模型可解释性涉及开发可以用来探测模型、发展对预测所依据因素简化或直观理解的方法。由于深度学习模型因其难以解释而臭名昭著,但它们在金融和医学等领域被广泛使用,因此模型可解释性可能是机器学习和人工智能中发展最快的子领域之一。模型可解释性工具允许使用即使对于需要证明决策合理性的应用,也可以使用强大但难以解释的模型。
模型可解释性是一个快速发展的研究领域,新的方法和最佳实践正在不断被发现。一种有希望的技巧,称为Shapley 加性解释(SHAP),利用博弈论原理将预测的信用分配给对预测值最有责任的个别特征。这比最初看起来更具挑战性,因为对于复杂模型,一个给定的特征可能不会对结果有简单、线性的影响。相反,该特征的影响还可能取决于其他特征的价值,而这些特征本身可能根据它们的组合方式有不同的影响。由于计算所有可能的排列组合都是计算上昂贵的,大多数 SHAP 实现都使用启发式方法来简化这种计算,并测量每个特征的平均影响。
R 语言的 SHAP 实现可以在shapr和shapper包中找到,但最活跃的开发工作是在 Python 的shap包中。即使你计划在 R 中工作,这个包的文档也是学习 SHAP 基础知识的杰出资源。它可以在shap.readthedocs.io上找到。
因为机器学习在本质上是将数据转化为行动,可解释性工具有助于建立对模型的信任,这导致更广泛的应用和更大的影响。例如,一个识别医院患者有高死亡风险的模型,除非我们知道导致风险升高的因素是什么,否则它带来的伤害将大于好处。没有解释,患者将无端地感到恐惧。另一方面,知道风险是由于可预防的因素导致的,将导致挽救生命的干预措施。将这些模型与真实世界之间的联系留给从业者去做。因此,与可解释性技术可以导致更有效的模型一样,你自己的实践和讲故事技巧可以有助于项目的成功,正如接下来的章节中所展示的那样。
数据科学中的“科学”元素
自从《使用 R 进行机器学习》第一版出版以来,一个新的短语在机器学习领域变得相当普遍。当然,这个热门词汇就是数据科学——一个被许多人定义但普遍认为描述了一个涵盖统计学、数据准备和可视化、专业知识以及机器学习方面的研究或工作领域的术语。
关于数据科学是否等同于过去所说的数据挖掘,这是一个有争议的问题,但可以肯定的是,两者之间有很大的重叠。一个合理的局外人可能会观察到,数据科学仅仅是数据挖掘的一个更正式的版本。数据挖掘中的方法和技巧通常是在工作中非正式地学习,或者在行业活动中由从业者之间传递。这与数据科学领域形成鲜明对比,该领域提供了无数通过在线培训课程和面对面学位项目获得正式证书和经验的机会。
如图 11.6所示,根据 Google Trends 数据搜索,该术语真正开始流行起来,恰逢本书第一版出版之时。虽然我很乐意为此流行语申领功劳,但不幸的是,我无法做到,因为在第一版中它几乎根本没出现过!在文本中该短语的两次出现中,最引人注目的是它实际上出现在书的最后一页,我在那里简要地提到了“蓬勃发展的”数据科学社区。如果我知道这会是多么的真实就好了!至少我并不孤单;甚至在 2012 年,当本书第一版的最早几页正在撰写时,数据科学的维基百科页面也还处于起步阶段。

图 11.6:Google Trends 搜索显示“数据科学”在过去十年中的快速增长
从那时起,我在劳动力中的机器学习实践者的角色,就像当时世界上许多其他人的角色一样,从数据分析师的职位转变为数据科学家。尽管标题和观念发生了快速变化,但似乎工作本身变化很小。应用机器学习在历史上本质上只是数据挖掘,今天的数据科学家被期望使用统计学和机器学习,以及强大的黑客或修补者的职业道德,在数据中寻找有用的洞察力——就像古代的数据挖掘者一样。是什么让这个新的数据科学领域与我们之前所做的工作不同?
许多博客和新闻出版物试图回答这个问题,在理解炒作的原因以及为什么数据科学突然成为 21 世纪“最热门的新职业领域”之一的过程中,这个问题被频繁提及。
在这个趋势的早期,一个常见的主题是使用维恩图来展示所需的技能。如图所示的 Bing 图片搜索所示,这种对数据科学的描述一直存在,并且继续普遍存在,维恩图可视化几乎达到了迷因般的地位。虽然存在细微的差别,但大多数都共享相同的基本结构:数据科学位于计算机科学、统计学和领域专业知识交汇处。

图 11.7:数据科学维恩图已经达到了迷因般的地位;大多数人将数据科学定位在编程、统计学和领域专业知识交汇处
这种还原论观点的问题在于,虽然它捕捉到了数据科学的大致轮廓,但它忽略了灵魂和关键的区别:科学思维。一个人可以拥有统计学、编程和领域知识的必要技能,但如果工作没有科学严谨性,那么它与多年前进行的数据挖掘并无不同。明确地说,这并不是说之前进行的数据挖掘在当时是无用的,但确实值得怀疑,任何了解在战场上如何执行这项工作的人都会认为它是科学的。
那么,我们如何将“科学”融入数据科学中?回答这个问题需要认识到为什么科学现在很重要,尽管它可能之前并不重要。特别是,随着各种规模的组织和许多领域的组织开始迅速组建和资源化商业智能团队,以在所谓的“宝藏”大数据中寻找洞察力,数据科学的实现发生了。
这些不断增长的复杂性层级需要更高级的工具和流程来协调努力,并将组织的各个部分的研究成果联系起来。当一两个人在商业的阴暗角落进行数据挖掘时,并不需要非常科学,但随着团队和工具的增长,需要更系统的方法来避免工作陷入混乱。
既然数据科学本质上是一项团队运动,那么将科学方法的要素融入个人机器学习项目也同样重要。即使是相对简单的任务,小型机器学习项目也会迅速增加复杂性,这得益于试错和迭代,这些是科学方法的基本要素。图 11.8旨在说明在一个严格的机器学习项目中,人们可能会探索的一些死胡同和歧途。在数据探索过程中,会生成和检验假设,其中只有一部分被证明是有效的。这些见解指导了特征工程,而特征工程本身也可能有多个失败尝试。测试了多个模型;一些失败了,而另一些则可以用来作为更复杂模型的跳板。最终,最有希望的模型将被评估,并在部署前与额外的特征工程一起调整以获得更好的性能。从开始到结束,整个序列可能需要几天、几周,甚至对于复杂、现实世界项目来说,需要几个月甚至几年。将这个过程的不稳定和波动视为科学方法中的自然步骤,有助于让利益相关者知道,进步并不总是直线前进,与投入的时间成比例。

图 11.8:机器学习项目很少从开始到结束都是直线进行的
同样,对于你,作为数据科学实践者,认识到你的工作不会以线性方式进行也很重要。与书籍和网络上机器学习教程不同,现实世界项目的混乱复杂性需要更加细致的注意,以避免迷失在代码中或重新发明轮子。不再仅仅创建一个 R 代码文件,手动逐行执行。相反,我们将代码和输出组织在一个单一、有序的地方,我们希望这也能作为我们调查的成果,供未来的读者或我们未来的健忘的自我参考。幸运的是,R 和 RStudio 使这项工作变得无缝。
使用 R Notebooks 和 R Markdown
在完成一个大型机器学习项目后,在松了一口气之后,你可能会发现自己回顾过去,想知道时间都去哪儿了。对这个问题的过度思考可能会导致不安全感,因为不可避免地,你可能会开始质疑自己是否可能避免了某些更明显的错误,或者是否可能做出了不同的设计选择。“如果只有!”你可能会反复说。为什么一个在事后看似如此简单的项目会消耗如此多的时间和精力?
这个问题源于在艰难的数据分析项目顶峰上获得的新视角,对结果有清晰的看法。回想一下前一部分中图 11.8所描述的典型机器学习项目的蜿蜒路径。在项目完成后,我们往往会忘记那些耗时耗力的死胡同和错误开始,并将旅程简化为从开始到结束的直线,而不是回忆它实际走过的复杂路径。
在数据科学职业生涯的早期,人们往往会假设有一种方法可以避免这些绕路,直接跳到结论,而不会“浪费”太多时间去追逐无意义的线索。实际上并没有。这项工作并非徒劳,而是机器学习过程中的一个必要部分。这项工作根本就没有浪费;随着你对数据的了解越来越深入,机器也会变得越来越聪明。
在数据科学职业生涯的后期,你可能会意识到这些初步的探索性尝试是所有项目中必要的一步。然而,你可能会有一种怀疑,认为这项工作的影响力并不像它本可以的那样大。虽然数据探索可能会为单一的分析提供信息,但它似乎并没有留下持久的印象,同样的错误经常被重复。这部分可能与其事实有关,即回忆起什么有效比回忆什么无效要容易得多。成功的事情会留在人们的脑海中,而失败的事情则被遗忘,在许多情况下,甚至被从 R 代码文件中删除。因此,探索性工作似乎并没有以其他经验相同的方式积累。失败、被排除的假设和未走的路径不容易被记住,也不容易转移到他人那里,以构建关于项目根源的历史知识。这对你的未来自己,或者在你退休后可能接管你代码的人都有害。与其删除除最终干净解决方案之外的所有内容,不如有一种方式来展示完整的调查——包括死胡同、错误等一切。
RStudio 开发环境以R 笔记本的形式提供了解决方案,这是一种特殊的 R 代码文件,它结合了 R 代码和解释性的自由格式文本。这些笔记本可以轻松地编译成 HTML、PDF 或 Microsoft Word 格式,或者通过更多的努力甚至可以制作成幻灯片和书籍。生成的输出文档将代码嵌入到报告的文本中,或者根据你的视角,文本嵌入到代码中。
这提供了一个可以用来记录从开始到结束整个机器学习过程的工件,但由于代码在开发过程中仍然可以逐行或逐块交互式运行,所以它并不觉得繁琐。通过花一点额外的时间在 R 代码文件中添加解释性或上下文文档,结果是一个可以与他人分享或由未来的自己回顾以刷新记忆的报告。
R 笔记本仅仅是纯文本文件,就像标准的 R 代码文件一样,但保存为.Rmd文件扩展名。这些笔记本允许在笔记本中交互式地执行代码,输出将显示在周围文本中。在 RStudio 中,可以通过使用文件菜单,选择新建文件,然后选择R 笔记本选项来创建一个新文件。这将创建一个新的 R 笔记本,使用默认模板,如下面的图像所示:

图 11.9:在 RStudio 中打开的 R 笔记本文件允许代码和输出在报告中集成
文件顶部在---破折号之间包括笔记本的元数据,如标题和预期输出格式。在 RStudio 中,预览按钮右侧的“齿轮”图标提供了设置,用于在默认 HTML 笔记本格式和 PDF 或 Microsoft Word 文档之间切换,如果您不想手动编辑此设置。这些设置控制了在项目完成后编译 R 笔记本时的输出格式。
在标题元数据下方,我们可以找到一个关键的区别,即 R 笔记本与传统 R 代码文件之间的区别。特别是,本节不是 R 代码,而是R Markdown,它是在纯文本文件中格式化报告的简单规范。由于 R 和 RStudio 并非设计为文字处理器,因此样式不是通过图形用户界面控制,而是通过简单的格式化代码控制,例如*斜体*和**粗体**,它们分别被转换为斜体和粗体,在最终的输出文件中显示。
笔记本模板提供了几种基本格式化选项的示例,但并未开始描述完整的 R Markdown 格式化功能。其他格式,如标题、列表,甚至嵌入的数学方程式,也是可能的。更多信息,包括一页速查表,可在 R Markdown 网站上找到:rmarkdown.rstudio.com
由于 R 笔记本格式默认为 R Markdown,任何 R 代码都必须使用特殊指示符嵌入到文件中,以标明代码的开始和结束位置。这些指示符是三个反引号字符,后跟代码语言,并包围在大括号内。例如,一段 R 代码的开始可以使用 py` ```{r} ```py` statement. The end of this section is denoted by three backtick characters as in a py 语句。或者,这些部分可以通过编辑窗口上方和“预览”及“齿轮”按钮右侧的 插入 图标添加到笔记本中。插入 按钮提供了一个下拉菜单,可以选择在笔记本中使用的编程语言,但请注意,这些其他语言可能无法利用 R 环境中的对象——至少不是不经过一些额外步骤。
通过在代码块内点击 运行(绿色三角形)按钮或按环境的关键组合键来执行代码块,将命令的输出直接显示在 R Markdown 文本中。可以通过在块右上角的“齿轮”图标中找到的选项来管理每个代码块的输出格式。在这里,可以控制代码或结果是否在最终文档中隐藏,以及是否应该执行代码。这些功能可能有助于抑制报告中的无关输出或防止不必要的长时间运行代码。
点击笔记本文件顶部的 预览 按钮将生成最终输出报告的预览版本,使用已经交互式运行的任何 R 代码输出。对于 HTML 笔记本,此文件将在简单查看器中打开,如随后的屏幕截图所示,或者可以在网页浏览器中打开。
由于文件仅使用实时生成的输出,RStudio 每次保存笔记本时都会自动重新生成预览文件。将其在查看器窗口中保持打开状态将允许您看到项目结束时最终报告的大致外观。

图 11.10:HTML 笔记本的预览文件将输出嵌入到文本文档中
预览 按钮右侧的下拉菜单按钮提供了一个将文档编译或“编织”成最终输出格式的手段。这会从开始到结束运行完整的 R 代码块集合,并使用 knitr 包将代码和文本合并成一个单一的报告。将文档编织成 HTML 通常很简单,但将文档编织成 PDF 或 Microsoft Word 可能需要安装额外的包。
生成的文件包含代码、文本和图像,且没有依赖项,因此可以通过电子邮件轻松共享。即使是最终的 HTML 笔记本格式也是如此,它以 .nb.html 文件扩展名保存,并在网页浏览器中查看时提供一些简单的交互性。此格式还嵌入原始的 .Rmd R Markdown 文件,以便接收者可以在需要时打开文件并重新创建分析。
执行高级数据探索
数据探索完全属于“艺术”这一面,在学术教科书中,这个主题很少得到充分的讨论。教程可能只是口头上提到实践,向学习者展示如何创建图表和可视化,但很少解释这些工具的用途或为什么可能需要它们。即使是这本书,也犯了这个错误;尽管前几章进行了简单的数据探索,但这些探索性分析很少超出第二章中描述的五个数字摘要统计量,即《管理和理解数据》。基于对这个主题的有限覆盖,人们可能会得到这样的印象,即它在实践中并不重要,但这与事实相去甚远;实际上,数据探索是现实世界数据科学的关键组成部分,对于大型、复杂和陌生的数据集尤其重要。
尽管我们已经进行了简单的探索性分析,但我们还没有正式定义这样做意味着什么。开创性的数学家和统计学家 John W. Tukey,他 1977 年的关于这个主题的书籍使这个术语广为人知,指出探索性数据分析(EDA)涉及让数据集提出假设并揭示有用的见解,而不是简单地回答预先设定的问题。它通常由图表和图表辅助,在 Tukey 看来,这些图表和图表强迫我们“注意到我们从未期望看到的东西。”可以想象 Tukey 的观点是,以清晰而令人惊讶的方式呈现数据,并仔细倾听这些分析告诉我们什么,不仅是为了理解数据本身,而且是为了更好地理解我们如何提出关于数据的问题。简而言之,严格的初步探索性分析很可能导致更准确的主要分析。
考虑到他对该领域的广泛贡献,John Tukey 可以合理地被认为是探索性数据分析(EDA)的祖师爷。你已经熟悉他最著名的发明之一:箱线图。他还真正地撰写了关于 EDA 技术的原始教科书,《探索性数据分析》,Tukey, JW,Addison-Wesley;1977。
由于机器学习的目标不仅仅是回答预定的疑问,因此与机器学习项目一起进行的探索性数据分析的形式非常符合图基的思路。高级数据探索,如果做得好,可以让数据提出可以用来提高机器学习任务性能的见解。鉴于提高机器学习模型的目标,系统化和迭代式地进行数据探索是最好的,但没有先前的经验这并不容易。没有方向,人们可能会探索无数的死胡同。为了应对这种情况,接下来的几节提供了一些关于如何以及从哪里开始这段旅程的想法。
构建数据探索路线图
如果我们要遵循图基的数据探索观念,我们应该相信数据探索更像是一场对话,或者甚至可能是一个单方面的倾听会,其中数据分享其智慧的精华。不幸的是,当这种印象与许多情况下对探索性数据分析的表面描述相结合时,许多新的数据探索者陷入了所谓的“分析瘫痪”状态,无法确定从哪里开始。就像数据科学家被引导进入一个黑暗的房间,被告知进行降神会,并等待数据的启发性回应。这并不奇怪,这有点令人畏惧!
没有探索性分析是完全相同的,每位数据科学家都应该培养自信,以自己的方式完成这项工作。然而,在构建自己的经验和自己的数据探索路线图时,通过学习示例可能会有所帮助。考虑到这一点,本节提供了一些可能有助于指导一般探索性分析的忠告。它不能涵盖所有的方法,也不打算暗示数据探索有唯一最佳方法。再次强调,最佳方法是系统化、迭代式,甚至可能是与数据集亲密的对话,就像没有教科书能完全为你准备与人类的对话一样,也没有单一的公式可以用来与数据对话。
话虽如此,尽管每一次口头交谈可能都是独特的,但它们通常都以问候和交换名字及寒暄开始。同样,你的数据探索路线图也可能从你简单地熟悉数据开始。获取数据字典,或者在文本文件或电子表格中创建一个,描述每个可用的特征。你还可以记录额外的元数据,例如行数、数据源、收集的时间和地点,以及数据是否存在已知问题。这些细节可能在分析过程中引发问题,或者它们可能在遇到意外结果时提供启示。
你可能会发现打印数据字典的纸质副本并系统地逐行工作,一次探索一个特征,是有益的。尽管这项工作当然可以在电子文档中完成,但对于具有数百个或更多预测变量的大数据集来说,使用笔、纸和荧光笔进行操作似乎不那么令人畏惧——更不用说勾选和记笔记以及从列表中划掉项目的满足感了!以下图展示了这样一个真实世界数据探索过程的成果;行表示可用的特征,它们已被星号、突出显示和注释标注,以突出显示每个潜在预测变量的感知重要性,以及探索过程中发现的任何潜在问题。

图 11.11:在进行数据探索时,打印数据字典(或可用属性的列表)并在纸上直接手写笔记可能会有所帮助
系统地检查特征列表,你可能会首先寻找任何潜在的陷阱。一个最初看起来非常有用的属性,最终可能因为新发现的缺陷或问题而被证明是无用的。对于每个变量,你可能需要考虑以下潜在问题是否存在:
-
缺失或意外的值
-
异常值或极端或异常值
-
高偏度的数值特征
-
具有多个模式的数值特征
-
变化范围非常广或非常低的数值特征
-
水平非常多分类特征(称为高基数)
-
水平非常少观察值的分类特征(称为“稀疏”数据)
-
与目标或彼此强烈或弱相关特征
请记住,即使遇到这些潜在问题,它们并不总是表明存在问题。其中许多问题可以解决,实际上,我们将在本书中探索(或已经探索)所有这些问题的解决方案。目前,通过只关注探索而不是解决方案,你很可能已经熟悉了可能有助于识别所列问题案例的分析方法。
简单的单向表格或直方图等可视化可以提供对单个特征的深入了解,并识别出有问题的值,但可能需要更复杂的可视化来深入调查数据。重要的是不仅要进行单变量分析,这些分析将特征孤立考虑,还要考虑每个特征与其他特征和目标的关系。这需要双变量分析,如交叉表或可视化,如堆叠条形图、热图和散点图。鉴于大量预测变量的大数据集的双变量分析数量很大,本章后面将描述的 R 的复杂可视化能力使得数据探索比其他方式更为轻松。
数据探索的力量不仅仅是探测数据中的任何负面价值,还包括识别具有正面价值的方面。通过逐个检查列表,你应该问自己每个潜在特征是否能够提供关于结果的任何有用信息。相反,你可能会问这个特征是否完全无用,或者它是否可能为模型的目标提供哪怕是一点点帮助。这就是人类智慧和专业知识在与人进行有洞察力的数据对话中有所帮助的地方。
由于真正无用的数据极为罕见,你可能会将其变成一种游戏,在其中你扮演侦探的角色,试图发现隐藏在所谓“无用”属性中的信息。俗话说,“垃圾是放错位置的金子。”那些非常擅长将垃圾变成宝藏的数据科学家将在竞争中拥有强大的优势,因为他们将开发出使用更多和更好数据的模型。
对于所谓的“大数据集”,数据探索可能会有所不同,这些数据集有数百万行或特征数量众多且混乱,以至于手动探索不可行。与其手动探索这些数据集,一种典型的做法是编写程序来系统地确定哪些特征是有用的,或者,否则,降低数据的复杂性。我们将在后面的章节中探讨一些这些方法。
遭遇异常值:现实世界的陷阱
正如数据探索的过程,曾经看起来很古老,但在现实世界的复杂性面前变得更为复杂一样,数据探索的许多看似简单的概念在现实中实际上比它们最初看起来要复杂得多。当我们在这本书剩余的章节中处理更复杂的现实世界例子时,我们将亲身体验这种现象多次;然而,异常值的本质可能是这一现象的极致。
到目前为止,我们一直理所当然地接受我们对异常值的定义;在第二章,管理和理解数据中,我们只是简单地说,异常值是“相对于大多数数据而言,异常地高或低。”我们在箱线图中很容易观察到这样的异常值,这些异常值用圆圈表示,位于中位数之上或之下 1.5 倍于四分位数范围(IQR)。实际上,这些不仅仅是异常值,而是具体来说,是Tukey 异常值,以——如果你还没有猜到——约翰·W·Tukey 的名字命名,他是我们之前提到的探索性数据分析的先驱。这种异常值的定义绝对不是错误的,但它可能稍微有些狭隘。很可能 Tukey 本人会同意,他的定义只是构想“异常值”的许多方式之一。
让我们稍微扩展一下这个术语的定义,将异常值定义为与数据集中其他值相比不寻常的值;它不一定很高或很低,只是“不寻常”。虽然这看起来只略有不同,但从技术上来说,与先前的定义相比,单词“不寻常”已经被精确地选择来传达一个非常具体的意思。特别是,单词“不寻常”并不暗示一种特定的数据修复方式,而像“高”和“低”这样的术语则暗示数据点以一种特定的方式是错误的。你通常不能轻易地将“不寻常”更正为“寻常”,除非首先对“寻常”的含义有一个牢固的理解。不寻常的事物只是奇怪或好奇;我们应该进一步调查它们。
以这种心态,研究以下假设数据集,该数据集包含从简单的必应图片搜索中获取的道路标志图像。其中哪些是异常值?大多数停车标志是红色的,所以黄色(中间)和蓝色(左下角)的停车标志,以及“前方停车”标志,显然是异常值,但也有一些其他奇怪之处。有一个带有手的停车标志,一些带有附加文本,以及许多标志的字体和边框有轻微的变化。此外,关于纯白色背景上的停车标志与自然景观上的停车标志呢?或者,如果你来自另一个国家,实际上所有这些都会显得不寻常,因此都会被认为是异常值。如果你来自夏威夷,那里蓝色停车标志的图片显然是拍摄的,那么即使是蓝色停车标志也可能完全在普通范围内!

图 11.12:在这个假设的停车标志数据集中,哪些图像是异常值?
当然,从这个练习中得到的启示是,异常值几乎总是视角问题,因此检测和修正异常值变得更加复杂。一方面,如果异常值明显是数据错误的结果,例如在记录数值时犯的“错误”,那么辨别异常值会变得容易一些。例如,假设一个数据录入错误将某人的财富记录为 1 万亿美元而不是 100 亿美元。这个值相对于其他富有的人来说极端性,使得这个值很容易被发现,而且它显然是错误的,这使得修正变得简单:只需输入正确的值即可。另一方面,对于“真实”的异常值,例如撰写本文时,埃隆·马斯克的价值接近 2000 亿美元,处理起来就不再那么直接。这种“真实”和“错误”异常值之间的区别旨在说明是否可以解释异常值。通常但并非总是最好尝试对可解释的异常值进行建模;这使得模型更加鲁棒。另一方面,对“错误”异常值进行建模,这些异常值基本上是随机变化,通常只会增加噪声并使模型变得更弱。
在数据探索过程中遇到异常值时,需要考虑的最重要问题是,将异常值包含在训练数据中是否会最终提高或降低学习算法执行所需任务的能力。这涉及到模型的泛化能力,即其在新数据上表现良好的能力。在进行彻底的数据探索时,要牢记部署场景以及模型是否需要在未来对类似的异常值具有鲁棒性。例如,如果之前使用的停车标志图像是用来训练自动驾驶车辆算法的,那么可能会移除那些在公共道路上不太可能遇到的异常值。然而,现实中的自动驾驶车辆可能会遇到被涂鸦、被黑暗遮蔽或被植物和天气条件遮挡的标志,因此,也有人可能会认为这个数据集异常值太少!
正如现实世界机器学习的主题一直如此,处理这个问题没有一种万能的解决方案。删除异常值可能是最常见的方法,并且在入门统计学课程中经常被教授,但它可能是最糟糕的方法之一。这确实很容易,但这种便利性伴随着一个阴暗面:删除异常值可能会丢弃关于学习任务的重要细节。这种做法阻止了数据科学家与数据集进行更深入的对话,以确定信息是有用还是无用。
其他方法可能需要更多的努力,但更有可能提高模型的泛化能力。在事件表现为异常值(由于它们的罕见性)的情况下,可能可以收集更多关于这些罕见事件的资料。或者,可能可以通过分箱或分桶罕见值,或将值限制在最大水平,将异常值组合成一个更频繁的单一类别。理想情况下,这些组将基于对学习算法如何使用数据的直观理解,但在缺乏专业知识的情况下,通常足够将它们组合成对目标变量有相似影响的最高十分之一或创建具有相似影响的值组。
回到成为异类意味着什么的问题,我们已经观察到上下文是关键。在某个上下文中看似不寻常的事物,例如蓝色的停车标志,在另一个上下文中可能很普通。同样,在某个上下文中看似普通的事物,在另一个上下文中可能非常不规则。简而言之,不仅合理的值可能错误地看起来像是异类,实际的异类也可能隐藏在明显的地方。真正理解这一事实是严谨数据探索的核心。例如,考虑一个具有典型人口分布的数据集。我们预计会看到相当数量的老年妇女和怀孕妇女,但观察到怀孕的老年妇女将是非常不寻常的!良好的探索性数据分析有助于识别这些类型的异常,并最终导致性能更好的模型。
示例 - 使用 ggplot2 进行可视化数据探索
如前所述,数据探索在图表和图表的帮助下达到最佳状态,正如 John Tukey(他自己也是创新数据可视化技术的先驱)所说,这些图表和图表帮助我们“注意到我们从未期望看到的东西。”我们在前几章中探索了各种数据集,但直到现在,我们只使用了 R 的内置绘图功能来创建简单的可视化,如箱线图、直方图和散点图。
为了进行更深入、更彻底的数据探索,我们需要构建更复杂的视觉元素,尽管我们可以使用基础 R 来完成,但有一个更好的选择。这个选择以ggplot2包的形式出现,它提供了一种“图形语法”,描述了图表元素之间的关系以及可视化本身。该包已经广泛使用超过十年,并且非常受欢迎。它可以创建专业、可发表的图像,其输出可以在许多学术期刊和许多常见网站上看到。即使当时你不知道,你也很可能之前已经见过它的输出。
已经有整本书专门介绍ggplot2包和“图形语法”。本节仅涵盖开始使用此包所必需的精华。有关此主题的许多免费资源,请访问ggplot2.tidyverse.org网站,您甚至可以下载一张包含最常用命令的单页速查表。
要展示ggplot2包的功能,需要写满整本书,但基本原理可以通过几个基本食谱来展示。为此,我们将用它来探索一个历时 100 多年的数据集。该数据集描述了泰坦尼克号船上的乘客,该船于 1912 年沉没。机器学习应用被用来预测在这次灾难中哪些 1309 名乘客不幸遇难,尽管预测模型在当今社会用处不大,但由于数据集中有许多隐藏的模式,可视化可以帮助揭示这些模式。
泰坦尼克号数据集是一个广受欢迎的教学数据集,可以从多个在线来源获取。原始文件和文档可通过位于网页hbiostat.org/data/的范德比尔特大学生物统计学系获取。本书使用的是泰坦尼克号数据集的一个变体,旨在向学习者介绍 Kaggle 竞赛格式。要了解更多信息或参加竞赛,请访问www.kaggle.com/c/titanic
我们将首先加载泰坦尼克号模型训练数据集并检查其特征:
> titanic_train <- read.csv("titanic_train.csv")
> str(titanic_train)
'data.frame': 891 obs. of 12 variables:
$ PassengerId: int 1 2 3 4 5 6 7 8 9 10 ...
$ Survived : int 0 1 1 1 0 0 0 0 1 1 ...
$ Pclass : int 3 1 3 1 3 3 1 3 3 2 ...
$ Name : chr "Braund, Mr. Owen Harris" ...
$ Sex : chr "male" "female" "female" "female" ...
$ Age : num 22 38 26 35 35 NA 54 2 27 14 ...
$ SibSp : int 1 1 0 1 0 0 0 3 0 1 ...
$ Parch : int 0 0 0 0 0 0 0 1 2 0 ...
$ Ticket : chr "A/5 21171" "PC 17599" "STON/O2\. 3101282" ...
$ Fare : num 7.25 71.28 7.92 53.1 8.05 ...
$ Cabin : chr "" "C85" "" "C123" ...
$ Embarked : chr "S" "C" "S" "S" ...
输出显示,数据集包括泰坦尼克号 1309 名乘客中的 891 名乘客的 12 个特征;其余 418 名乘客可以在titanic_test.csv文件中找到,代表大约 70/30 的训练和测试分割。二元目标特征Survived表示乘客是否在沉船中幸存,其中1表示幸存,0表示不幸的结局。请注意,在测试集中,Survived留空以模拟未知的未来数据进行预测。
在构建数据探索路线图的精神下,我们可以开始思考每个特征对预测的潜在价值。Pclass列表示乘客等级,如一等、二等或三等票的状态。此外,Sex和Age属性似乎是有用的生存预测指标。我们将使用ggplot2包来更深入地探索这些潜在关系。如果您尚未安装此包,请在继续之前使用install.packages("ggplot2")进行安装。
每个 ggplot2 可视化都是由图层组成的,这些图层将图形放置在空白画布上。单独执行ggplot()函数会创建一个没有数据点的空灰色绘图区域:
> library(ggplot2)
> p <- ggplot(data = titanic_train)
> p
要创建比空白灰色坐标系更有趣的东西,我们需要向存储在p对象中的绘图对象添加额外的图层。额外的图层由一个geom函数指定,该函数确定要添加的图层类型。许多geom函数中的每一个都需要一个mapping参数,该参数调用包的美学函数aes(),将数据集的特征与其视觉表示联系起来。这一系列步骤可能会有些令人困惑,所以最好的学习方法是举例说明。
让我们从创建一个简单的Age特征的箱线图开始。你可能会记得,在第二章,管理和理解数据中,我们使用了 R 的内置boxplot()功能来构建如下这样的可视化:
> boxplot(titanic_train$Age)
要在 ggplot2 环境中实现相同的功能,我们只需将geom_boxplot()添加到空白坐标系中,使用aes()美学映射函数指示我们希望将Age特征映射到y坐标,如下所示:
> p + geom_boxplot(mapping = aes(y = Age))
得到的图形在很大程度上是相似的,只是在数据呈现的风格上存在一些细微的差异。甚至两个图表中 Tukey 异常值的使用也是相同的:

图 11.13:R 的内置箱线图函数(左)与 ggplot2 版本的相同(右)。两者都描绘了泰坦尼克号乘客的年龄分布。
虽然当更简单的函数足以满足需求时,使用更复杂的ggplot()可视化可能看似没有意义,但该框架的优势在于它只需对代码进行少量修改就能可视化双变量关系。例如,假设我们想检查年龄与生存状态之间的关系。我们可以通过简单修改之前的代码来实现这一点。请注意,Age已被映射到x维度,以创建一个水平箱线图,而不是之前使用的垂直箱线图。将因子转换后的Survived作为y维度创建了一个针对因子两个级别的箱线图。使用这个图表,看起来幸存者通常比非幸存者年轻一些:
> p + geom_boxplot(aes(x = Age, y = as.factor(Survived)))

图 11.14:并排箱线图有助于比较泰坦尼克号幸存者和非幸存者的年龄分布
有时,略微不同的可视化可以更好地讲述一个故事。考虑到这一点,回忆一下在第二章,管理和理解数据中,我们也使用了 R 的hist()函数来检查数值特征的分布。我们将首先在 ggplot 中复制这个操作,以便并排比较。内置函数相当简单:
> hist(titanic_train$Age)
ggplot版本使用geom_histogram():
> p + geom_histogram(aes(x = Age))
得到的图形在很大程度上是相同的,除了风格上的差异和关于箱数默认设置的不同:

图 11.15:R 内置的直方图(左)与 ggplot2 版本的相同直方图(右)比较泰坦尼克号乘客年龄的分布
再次强调,ggplot2 框架的亮点在于能够通过一些小的调整揭示数据中的有趣关系。在这里,让我们检查年龄和生存的相同比较的三个变体。
首先,我们可以通过向aes()函数添加fill参数来构建重叠的直方图。这将根据提供的因子的水平对条形进行着色。我们还将使用ggtitle()函数为图形添加一个信息性标题:
> p + geom_histogram(aes(x = Age, fill = as.factor(Survived))) +
ggtitle("Distribution of Age by Titanic Survival Status")
第二,我们不是创建重叠的直方图,而是可以使用facet_grid()函数创建并排的网格图。这个函数使用rows和cols参数来定义网格中的单元格。在我们的情况下,为了创建并排图,我们需要使用Survived变量定义幸存者和非幸存者的列。这必须被vars()函数包裹,以表示它是伴随数据集的特征:
> p + geom_histogram(aes(x = Age)) +
facet_grid(cols = vars(Survived)) +
ggtitle("Distribution of Age by Titanic Survival Status")
第三,我们不是使用直方图geom,而是可以使用geom_density()创建密度图。这种可视化类型类似于直方图,但使用平滑曲线而不是单独的条形来表示x维度的每个值所记录的比例。我们将根据Survived的水平设置线条颜色,并用相同的颜色填充曲线下方的区域。由于区域重叠,alpha参数允许我们控制透明度级别,以便同时看到两者。请注意,这是一个geom函数的参数,而不是aes()函数的参数。完整的命令如下:
> p + geom_density(aes(x = Age,
color = as.factor(Survived),
fill = as.factor(Survived)),
alpha = 0.25) +
ggtitle("Density of Age by Titanic Survival Status")
生成的三个图以不同的方式可视化相同的数据:

图 11.16:ggplot()函数调用中的微小变化可以产生截然不同的输出
这三个可视化展示了相同数据的不同可视化可以帮助讲述不同的故事。例如,顶部图中的重叠直方图似乎强调了相对较少的人幸存的事实。相比之下,底部图清楚地描绘了 10 岁以下旅客的生存率激增;这为“妇女和儿童优先”的生命艇政策提供了证据——至少就儿童而言。
让我们再考察几个图表,看看我们是否能揭示泰坦尼克号疏散政策的更多细节。我们将首先通过创建一个简单的条形图来确认假设的性别生存差异。为此,我们将使用geom_bar()层创建一个条形图。默认情况下,这仅仅计算提供的维度的发生次数。以下命令创建了一个条形图,说明了在泰坦尼克号上男性人数几乎是女性的两倍:
> p + geom_bar(aes(x = Sex)) +
ggtitle("Titanic Passenger Counts by Gender")

图 11.17:单个特征的简单条形图有助于将计数数据置于适当的视角
一个更有趣的可视化是比较按性别的生存率。为此,我们不仅需要将Survived结果作为y参数提供给aes()函数,还要告诉geom_bar()函数使用stat和fun参数计算数据的汇总统计量——特别是使用mean函数,如下所示:
> p + geom_bar(aes(x = Sex, y = Survived),
stat = "summary", fun = "mean") +
ggtitle("Titanic Survival Rate by Gender")
结果图证实了“妇女和儿童优先”的生命艇政策的假设。尽管船上有几乎两倍的男人,但女性的生存可能性是男性的三倍:

图 11.18:更复杂的条形图可以说明性别在生存率上的差异
为了再次展示 ggplot 能够通过相对较小的代码更改创建大量可视化并讲述关于数据的不同故事的能力,我们将以几种不同的方式检查乘客等级(Pclass)特征。首先,我们将创建一个简单的条形图,使用stat和fun参数来表示生存率,就像我们之前按性别表示生存率一样:
> p + geom_bar(aes(x = Pclass, y = Survived),
stat = "summary", fun = "mean") +
ggtitle("Titanic Survival Rate by Passenger Class")
结果图描绘了二等和三等舱乘客生存可能性的大幅下降:

图 11.19:条形图显示了泰坦尼克号下层乘客等级在生存结果上的明显差异
颜色可以是一个有效的工具来传达额外的维度。使用fill参数,我们将创建一个简单的乘客计数条形图,条形用颜色填充,颜色根据生存状态,即转换为因子:
> p + geom_bar(aes(x = Pclass,
fill = factor(Survived,
labels = c("No", "Yes")))) +
labs(fill = "Survived") +
ylab("Number of Passengers") +
ggtitle("Titanic Survival Counts by Passenger Class")
结果突出了这样一个事实:绝大多数死者来自船上的三等舱:

图 11.20:强调泰坦尼克号上三等舱乘客死亡数量的条形图
接下来,我们将使用position参数修改这个图表,该参数告诉ggplot()如何排列着色条形。在这种情况下,我们将设置position = "fill",这将创建一个堆叠条形图,填充垂直空间——基本上给堆叠中的每个颜色分配 100%的相对比例:
> p + geom_bar(aes(x = Pclass,
fill = factor(Survived,
labels = c("No", "Yes"))),
position = "fill") +
labs(fill = "Survived") +
ylab("Proportion of Passengers") +
ggtitle("Titanic Survival by Passenger Class")
结果图强调了低等级生存机会的降低:

图 11.21:按乘客等级对比的生存率条形图
最后,我们将尝试可视化三个维度之间的关系:乘客等级、性别和生存。Pclass和Survived特征定义了x和y维度,而Sex通过fill参数定义了条形图的色彩。设置position = "dodge"告诉ggplot()将彩色条形并排放置而不是堆叠,而stat和fun参数计算生存率。完整的命令如下:
> p + geom_bar(aes(x = Pclass, y = Survived, fill = Sex),
position = "dodge", stat = "summary", fun = "mean") +
ylab("Survival Proportion") +
ggtitle("Titanic Survival Rate by Class and Sex")
此图揭示了这样一个事实:几乎所有一等和二等舱的女性乘客都幸存了下来,而所有阶级的男性乘客更有可能丧生:

图 11.22:一个条形图,说明了无论乘客等级如何,男性的生存率都很低
检查泰坦尼克号数据的更多方面最好留给读者去练习。毕竟,数据探索最好被看作是数据与数据科学家之间的一次个人对话。同样,如前所述,本书的范围并不包括ggplot2包的每个方面。然而,本节应该已经展示了数据可视化如何帮助识别特征之间的联系,这对于深入理解数据是有用的。通过探索对你个人感兴趣的数据库集,更深入地了解ggplot()函数的能力,将大大提高你的模型构建和讲故事技巧——这两者都是机器学习成功的重要因素。
Winston Chang 的《R Graphics Cookbook》(r-graphics.org)可在网上免费获取,提供了大量的食谱,涵盖了几乎所有的ggplot2可视化类型。
摘要
在本章中,你学习了成为一名成功的机器学习实践者的基本要素以及构建成功机器学习模型所需的技能。这需要不仅是一套广泛的知识和经验,还需要对学习算法、训练数据集、现实世界的部署场景以及工作可能出错的各种方式有深入的了解——无论是意外还是有意为之。
数据科学的热门词汇暗示了数据、机器以及引导学习过程的人之间的关系。这是一个团队努力,数据科学作为之前数据挖掘领域的一个独立分支,其日益增长的关注度,以及众多的学位课程和在线认证,反映了它作为一个研究领域的实现,不仅关注统计学、数据和计算机算法,还关注使应用机器学习成功的科技和官僚基础设施。
应用机器学习和数据科学要求从业者成为引人入胜的探险家和讲故事的人。对数据的勇敢使用必须与从数据中真正可以学到的东西、以及可以合理地用所学知识做的事情进行仔细平衡。这无疑是一门艺术和科学,因此,很少有人能够完全掌握这个领域。相反,不断努力改进、迭代和竞争将导致自我提升,这不可避免地会导致在预期的实际应用中表现更好的模型。这有助于所谓的“良性循环”,其中类似飞轮的效果迅速提高了采用数据科学方法的组织的生产力。
正如本章回顾了熟悉的话题,并揭示了机器学习在现实世界实践中的新发现复杂性一样,下一章将回顾数据准备,以考虑解决大型和杂乱数据集中发现的常见问题。我们将通过学习一种全新的 R 语言编程方式回到前线,这种方式不仅能够更好地处理这些挑战,而且一旦克服了初始的学习曲线,也许甚至更加有趣和直观易用。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人相聚,并和超过 4000 人一起学习:

第十二章:高级数据准备
80%的时间投资在现实世界的机器学习项目中用于数据准备这一普遍真理被广泛引用,以至于它通常被无异议地接受。本书的早期章节通过不附加任何条件地将这一说法作为事实陈述来帮助延续这一陈词滥调,尽管这确实是一种常见的经验和感知,但它也是一种过度简化,正如从统计数据中概括时通常发生的那样。实际上,数据准备并没有统一的单一经验。然而,确实是真的,数据准备工作几乎总是比预期的要花费更多的努力。
很少有情况会提供单个 CSV 格式的文本文件,可以很容易地被 R 读取并使用几行 R 代码进行处理,就像前几章所描述的那样。相反,必要的数据元素通常分布在数据库中,然后必须收集、过滤、重新格式化并组合,才能使用机器学习来使用特征。这甚至在没有考虑从利益相关者那里获取数据所需的时间以及探索和理解数据之前,就可能需要大量的努力。
本章旨在为你(有意为之!)准备你在现实世界中将要准备的大型且更复杂的数据集。你将学习:
-
为什么数据准备对于构建更好的模型至关重要
-
将数据转换为更有用的预测因子的技巧和窍门
-
用于高效准备数据的专用 R 包
不同的团队和不同的项目需要他们的数据科学家投入不同数量的时间来准备机器学习过程中的数据,因此,80%的统计数据可能高估或低估了任何特定项目或单个贡献者所需的努力。
然而,无论是由你还是别人执行这项工作,你很快就会发现一个不可否认的事实,那就是高级数据准备是构建强大的机器学习项目过程中的一个必要步骤。
执行特征工程
时间、努力和想象力是特征工程过程的核心,该过程涉及将专业知识应用于创建用于预测的新特征。简单来说,它可能被描述为使数据更有用的艺术。更复杂地说,它涉及领域专业知识和数据转换的结合。一个人不仅需要知道对于机器学习项目哪些数据是有用的,还需要知道如何合并、编码和清理数据以满足算法的期望。
特征工程与数据探索密切相关,如第十一章,在机器学习中取得成功中所述。两者都涉及通过生成和测试假设来质询数据。探索和头脑风暴可能导致关于哪些特征对预测有用的见解,而特征工程的行为可能导致需要探索的新问题。

图 12.1:特征工程是帮助模型和数据协同工作的循环的一部分
特征工程是循环中的循环的一部分,其中投入努力以帮助模型和数据更好地协同工作。一轮数据探索和特征工程导致数据改进,进而导致训练更好模型的迭代,然后又指导另一轮潜在的数据改进。这些潜在改进不仅包括解决简单数据问题并允许算法在 R 中运行所需的最低限度的清理和准备任务,还包括使算法更有效地学习的步骤。这些可能包括:
-
执行复杂的数据转换,帮助算法更快地学习或学习数据的简化表示
-
创建易于解释或更好地表示潜在理论概念的特性
-
利用非结构化数据或将额外特征合并到主要来源上
所有这三者都需要深入的思考和创造力,并且是即兴的和领域特定的,而不是公式化的。话虽如此,计算机和从业者可以通过互补的优势来共享这项工作。计算机在创造力和即兴能力方面的不足,可能可以通过计算能力、蛮力和坚定不移的毅力来弥补。
人类和机器的作用
特征工程可以看作是人类和机器在学习过程阶段抽象阶段的合作。回想一下在第一章,介绍机器学习中,抽象步骤被定义为将存储的数据转换为更广泛的概念和表示。换句话说,在抽象过程中,原始数据元素之间建立了联系,这些联系将代表学习目标的重要概念。这些关系通常由一个模型定义,该模型将学习到的概念与感兴趣的输出联系起来。在特征工程过程中,人类温和地引导或推动抽象过程向特定方向前进,目标是产生性能更好的模型。
想象一下这种情况:回忆一下你过去试图学习一个困难概念的时刻——可能甚至就在阅读这本教科书的时候!阅读以及后来重读文本证明对理解概念没有帮助,沮丧的你联系了一个朋友或同事寻求帮助。也许这个朋友会用不同的方式解释这个概念,使用类比或例子帮助你将概念与你先前经验联系起来,在这个过程中,它引导你进入一个顿悟的时刻:“我找到了!”所有东西突然变得清晰,你 wonder how you couldn’t understand the concept in the first place. Such is the power of abstractions, which can be transferred from one learner to another to aid the learning process. The process of feature engineering allows the human to transfer their intuitive knowledge or subject-matter expertise to the machine through intentionally and purposefully designed input data.
考虑到抽象是学习过程的基础,可以说机器学习本质上就是特征工程。著名的计算机科学家和人工智能先驱 Andrew Ng 说,“提出特征是困难的,耗时且需要专业知识。应用机器学习基本上就是特征工程。”计算机科学教授和机器学习书籍 The Master Algorithm 的作者 Pedro Domingos 说,“一些机器学习项目成功了,而一些失败了。是什么造成了这种差异?最关键的因素无疑是使用的特征。”
Andrew Ng 的名言出现在一个名为 Machine Learning and AI via Brain simulations 的讲座中,该讲座可通过网络搜索在线获取。除了 Pedro Domingos 的书籍 The Master Algorithm(2015)外,还可以参考他在 Communications of the ACM(2012)上发表的优秀论文“关于机器学习的几个有用的事情要知道”。doi.org/10.1145/2347736.2347755。
功能工程做得好的话,可以将较弱的学习者转变为更强的学习者。许多机器学习和人工智能问题可以通过简单的线性回归方法解决,假设数据已经被充分清理。即使是非常复杂的机器学习方法,在足够的特征工程下也可以在标准线性回归中复制。线性回归可以适应模型非线性模式,使用样条和二次项,并且可以接近甚至最复杂的神经网络的表现,前提是有足够数量的新特征已经被精心设计为原始输入数据的交互或转换。
简单的学习算法可以适应更复杂问题的想法并不仅限于回归。例如,决策树可以通过输入数据的旋转来绕过它们的轴平行决策边界,而基于超平面的支持向量机可以通过选择合适的核技巧来模拟复杂的非线性模式。只要付出足够的努力,并且对输入数据和学习问题有足够的理解,一个像 k-NN 这样简单的方法也可以用来模拟回归,甚至可能是更复杂的方法,但这里有一个问题。为什么要在使用一个更复杂的算法也能表现同样好或更好的情况下,投入大量时间进行特征工程,而该算法还能自动为我们进行特征工程?
事实上,可能最好是将数据的潜在模式复杂性与能够轻松处理这些模式的学习算法相匹配。当计算机可以自动完成时,手动进行特征工程不仅是一种浪费,而且容易出错,并可能错过重要的模式。像决策树和具有足够多隐藏节点的神经网络这样的算法,尤其是深度学习神经网络,特别擅长进行它们自己的形式特征工程,这可能会比手工操作更加严格和彻底。不幸的是,这并不意味着我们可以盲目地将这些相同的方法应用到每个任务中——毕竟,在机器学习中没有免费的午餐!
将相同的算法应用到每个问题上,似乎表明存在一种适用于所有特征工程的通用方法,但我们知道这既是艺术也是科学。因此,如果所有从业者都将相同的方法应用到所有任务中,他们将无法知道是否可能获得更好的性能。也许稍微不同的特征工程方法可能会产生一个更准确地预测客户流失或癌症的模型,这将导致更大的利润或更多生命的挽救。这在现实世界中显然是一个问题,即使是一点点性能的提升也可能意味着在竞争中占据实质性的优势。
在高风险的竞赛环境中,例如 Kaggle 上的机器学习竞赛,每个团队都能访问到相同的机器学习算法,并且能够迅速地将它们中的每一个应用到识别哪个表现最佳。因此,在阅读 Kaggle 冠军的访谈时出现一个主题并不令人惊讶:他们通常会在特征工程上投入大量的努力。2012-2013 年 Kaggle 上评分最高的数据科学家 Xavier Conort 在一次访谈中说:
“我们使用的算法对于 Kagglers 来说非常标准……我们的大部分努力都花在了特征工程上……我们也非常小心地丢弃了可能让我们面临过拟合风险的特性。”
由于特征工程是机器学习少数专有方面之一,因此它是团队之间区分度的一个少数点。换句话说,那些在特征工程方面做得好的团队往往能超越竞争对手。
要阅读与 Xavier Conort 的完整访谈,该访谈最初发布在 Kaggle 的“No Free Hunch”博客上,请访问web.archive.org/web/20190609154949/http://blog.kaggle.com/2013/04/10/qa-with-xavier-conort/。其他 Kaggle 冠军的访谈可在medium.com/kaggle-blog/tagged/kaggle-competition找到。
根据 Conort 的声明,人们可能会轻易地认为,在特征工程上的投资需求意味着需要更大的人力和学科专业知识的应用,但事实并非总是如此。Kaggle 上表现优异的“DataRobot”团队的一员 Jeremy Achin 评论了人类专业知识出人意料地有限的效用。在谈到他的团队在特征工程上花费的时间时,他在一次采访中说:
“最令人惊讶的是,几乎所有试图使用学科知识或从数据可视化中得出的洞察来指导的尝试,都导致了结果的大幅下降。我们实际上安排了一位非常出色的生物化学家进行了 2 小时的板书讲座,并基于我们所学提出了一些想法,但它们都没有奏效。”
Jeremy Achin 与 Xavier Conort 以及其他几位知名 Kaggle 大师一起,将他们在 Kaggle 竞赛中的成功转化为了一家名为 DataRobot 的人工智能公司,该公司现在价值数十亿美元。他们的软件能自动执行机器学习过程,这表明从他们的 Kaggle 工作中学到的关键教训是,计算机在机器学习过程中的许多步骤上可以像人类一样做得很好,甚至更好。
要阅读与 Jeremy Achin 的完整访谈,该访谈最初发布在 Kaggle 的“No Free Hunch”博客上,请访问web.archive.org/web/20190914030000/http://blog.kaggle.com/2012/11/04/team-datarobot-merck-2nd-place-interview/。DataRobot 公司可以在www.datarobot.com上找到。
当然,在利用专业知识逐步构建模型和将所有内容都投入机器以观其效之间需要保持平衡。尽管今天特征工程在很大程度上仍然是一个手动过程,但该领域的未来似乎正朝着散弹式的“观其效”方法发展,因为自动化特征工程是一个快速增长的研究领域。自动化特征工程工具的基础是这样一个观点:计算机可以通过测试比人类有更多时间尝试的更多特征组合来弥补其缺乏创造力和领域知识。自动化特征工程用狭窄但受指导的人类思维交换了广泛而系统的计算机思维,其潜在优势是找到更优的解决方案,潜在的劣势包括可解释性降低和过拟合的可能性增加。
在过于兴奋于自动化的潜力之前,值得注意的是,尽管这些工具可能允许人类将特征工程中某些“思考”的部分外包出去,但仍然需要在任务的“编码”部分投入努力。也就是说,曾经用于逐个手动编码特征的时间现在被用于编写系统性地寻找或构建有用特征的函数。
正在开发中的一些有希望的算法,如基于 Python 的Featuretools包(以及相应的 R 包featuretoolsR,它与 Python 代码交互),可以帮助自动化特征构建过程,但这类工具的使用尚未普及。此外,这些方法需要数据和计算时间,这两者可能是许多机器学习项目的限制因素。
想了解更多关于 Featuretools 的信息,请访问:www.featuretools.com。
大数据和深度学习的影响
无论特征工程是由人类还是由自动化机器方法执行,都会不可避免地达到一个点,即额外的投入努力几乎不会提高学习算法的性能。应用更复杂的学习算法也可能在一定程度上提高模型性能,但这种提升也是递减的,因为可应用的方法数量有限,它们的性能差异通常相对较小。因此,如果确实需要额外的性能提升,我们只剩下最后一个选择:通过添加额外的特征或示例来增加训练数据集的大小。此外,由于添加额外的列需要修订过去业务过程中生成的数据,在许多情况下,收集更多行数据是两个选择中较容易的一个。
在实践中,通过包含更多行数据所能实现的性能提升相对有限。本书中描述的大多数算法很快就会达到顶峰,在包含一百万行数据的集合上与包含几千行数据的集合相比,性能提升很小。如果你已经在你感兴趣的领域将机器学习方法应用于实际项目,你可能已经亲身体验到这一点。一旦数据集足够大——对于许多实际应用来说,通常只是几千行——额外的例子只会带来更多问题,比如计算时间延长和内存不足。如果更多的数据导致更多问题,那么自然而然地,人们会问,为什么所谓的“大数据时代”会有如此多的炒作?
要回答这个问题,我们首先必须对各种大小的数据集进行哲学上的区分。为了明确,“大数据”不仅仅意味着数据库或文件系统中行数或存储消耗量很大。实际上,它包括这两者以及更多,因为大小只是可能表明大数据存在的四个要素之一。
这些就是所谓的大数据的四个 V:
-
数量:数据的实际大小,无论是行数更多、列数更多还是存储更多
-
速度:数据积累的速度,这不仅影响数量,还影响数据处理复杂性
-
多样性:不同系统间数据类型或定义的差异,尤其是文本、图像和音频数据等非结构化来源的增加
-
真实性:输入数据的可信度和跨来源匹配数据的能力
从上到下阅读这个列表,元素变得不那么直观,但在遇到时却更具挑战性。前两个元素,数量和速度,是所谓的中等数据空间的基础。虽然这并不是说处理高数量、高速度的数据没有挑战,但这些挑战通常可以通过扩大我们正在做的事情来解决。例如,可能可以使用具有更多内存的更快计算机或应用更计算高效的算法。数据多样性的增加和真实性的降低需要完全不同的方法来在机器学习项目中使用,尤其是在高速和高数量规模上。以下表格列出了小、中、大数据空间之间的一些区别:

图 12.2:大多数机器学习项目处于“中等数据”规模,而要利用“大数据”则需要额外的技能和工具
从小数据过渡到中等数据,再从中等数据过渡到大数据,需要指数级投资。随着数据集规模和复杂性的增加,所需的基础设施变得更加复杂,增加了越来越多的专用数据库、计算硬件和分析工具,其中一些将在第十五章“利用大数据”中介绍。这些工具正在迅速变化,这需要不断的培训和技能提升。随着数据规模的增加,时间成为一个更重要的约束;项目更加复杂,涉及更多的移动部件,需要更多的迭代和改进周期,而且工作完成的时间更长——实际上是这样!在一个中等规模的数据集上运行只需几分钟的机器学习算法,在一个大得多得数据集上可能需要几个小时或几天,即使有云计算能力的帮助。
由于大数据的高风险,这类项目的人员配置和资源投入通常存在一个数量级的差异——这被认为是“业务成本”的一部分。可能会有几十个数据科学家,以及相应数量的 IT 专业人员来支持所需的基础设施和数据处理流程。典型的大数据解决方案需要多种工具和技术协同工作。这为数据架构师提供了规划和构建各种计算资源的机会,并监控其安全性、性能和云托管成本。同样,数据科学家通常与相等或更多的数据工程师相匹配,他们负责在数据源之间传输数据,并执行最复杂的编程工作。他们在处理大数据集方面的努力使得数据科学家能够专注于分析和机器学习模型构建。
从今天从事最大和最具挑战性的机器学习项目的人员的角度来看,包括本书中涵盖的几乎所有示例在内的大多数日常项目,都完全属于所谓的小数据范畴。在这个范式下,数据集在行数或存储容量方面可以增长到“大”规模,但它们永远不会真正成为“大数据”。计算机科学和机器学习专家安德鲁·吴(Andrew Ng)指出,在小数据领域,人类的作用仍然具有影响力;人类可以通过手动设计特征或选择最有效的学习算法来极大地影响项目的性能。然而,当数据集规模从“大”增长到“巨大”并进入大数据范畴时,一类不同的算法突破了性能瓶颈,超越了手动调整的小幅提升。
图 12.3,改编自吴的工作,说明了这一现象:

图 12.3:在小数据规模下,传统机器学习算法与更复杂的方法具有竞争力,甚至可能表现更好,而随着数据规模的增加,这些方法的表现会更好。
在小数据规模范围内,没有任何单个算法或算法类别比其他算法可预测地表现更好。在这里,包括领域专业知识的手工编码特征在内的巧妙特征工程可能允许简单的算法超越更复杂的方法或深度学习神经网络。
当数据规模增加到中等数据规模时,集成方法(在第十四章“构建更好的学习者”中描述)往往比使用传统机器学习算法的精心手工制作的模型表现更好。对于大数据环境中发现的最大数据集,只有深度学习神经网络(在第七章“黑盒方法——神经网络和支持向量机”中介绍,将在第十五章“利用大数据”中更详细地介绍)似乎能够达到极致的性能,因为它们从额外数据中学习的能力几乎从未达到平台期。这难道意味着“没有免费的午餐”定理是错误的,并且真的存在一个可以统治所有算法的学习算法吗?
在小数据和大数据环境下不同学习算法性能的可视化,可以在 Andrew Ng 用自己的话描述中找到。要找到这些,只需在 YouTube 上搜索“应用深度学习的螺丝和螺母”(视频 3 分钟处出现)或“人工智能是新的电力”(视频 20 分钟处出现)。
要理解为什么某些算法在大数据环境下比其他算法表现更好,以及“没有免费的午餐”原则为何仍然适用,我们首先必须考虑数据的大小和复杂性、模型学习复杂模式的能力以及过拟合的风险之间的关系。让我们从一个案例开始,在这个案例中,数据的大小和复杂性保持不变,但我们增加学习算法的复杂性,使其更接近训练数据中观察到的模式。例如,我们可能将决策树扩展到过大的规模,增加回归模型中的预测变量数量,或者在神经网络中添加隐藏节点。这种关系与偏差-方差权衡的概念紧密相关;通过增加模型复杂性,我们允许模型更紧密地符合训练数据,因此减少其固有的偏差并增加其方差。
图 12.4展示了随着模型复杂性增加时出现的典型模式。最初,当模型对训练数据集欠拟合时,模型复杂性的增加会导致模型错误减少和模型性能提高。然而,存在一个点,在这个点上,模型复杂性的增加会导致训练数据集过拟合。超过这个点,尽管模型在训练数据集上的错误率继续降低,但测试集错误率增加,因为模型泛化到训练数据之外的能力受到严重影响。再次强调,这假设数据集支持模型增加复杂性的能力有限。

图 12.4:对于许多训练数据集,增加学习算法的复杂性可能会带来过拟合和测试集错误增加的风险
如果我们可以增加训练数据集的大小和范围,大数据环境可能会解锁机器学习性能的第二层次,但前提是学习算法同样能够增加其复杂性以利用额外的数据。许多传统算法,如本书中迄今为止所涵盖的,无法实现这样的进化飞跃——至少不是没有一些额外帮助。
传统机器学习算法与能够实现这一飞跃的算法之间的缺失环节,与算法试图学习的数据的参数数量有关。回想一下,在第十一章中,成功运用机器学习,参数被描述为学习者内部值,代表其对数据的抽象。传统上,由于各种原因,包括上面所示的偏差-方差权衡,以及认为应该优先考虑简单、更节俭的模型而不是更复杂的模型,因此更倾向于选择参数较少的模型。人们认为,参数数量过高会增加数据集简单地记住训练数据,从而导致严重的过拟合。
有趣的是,这在某种程度上是正确的,但仅限于此,如图 12.5 所示。随着模型复杂性的增加——即参数数量——测试集错误遵循与之前相同的 U 形模式。然而,一旦复杂性和参数化达到插值阈值,或者说有足够的参数来记住并准确分类几乎所有的训练集示例,就会出现新的模式。在这个阈值,泛化误差达到最大,因为模型已经极大地过拟合了训练数据。然而,随着模型复杂性的进一步增加,测试集错误再次开始下降。在足够额外的复杂性下,一个严重过拟合的模型甚至可能超越一个调优良好的传统模型的表现,至少根据我们现有的“过拟合”概念来看。

图 12.5:一些算法能够在看似过度拟合训练数据后,仍然能够利用大数据进行很好的泛化
关于此处所绘“双下降”曲线的明显矛盾,更多信息请参阅这篇开创性的论文:《调和现代机器学习实践与经典偏差-方差权衡,Belkin M, Hsu D, Ma S, 和 Mandal S, 2019, 美国国家科学院院刊,第 116 卷第 32 期,第 15,849-15,854 页》。
解释这一意外结果的机制与模型中发生的一种有趣甚至可能是神奇的变化有关,这种变化发生在能够进行额外参数化的模型中,而这种参数化超出了插值阈值。一旦学习者拥有足够的参数来插值(足够地符合)训练数据,额外的参数就会导致过参数化的状态,在这种状态下,额外的复杂性使得能够达到更高的思维和抽象水平。本质上,一个过参数化的学习者能够学习更高阶的概念;在实践中,这意味着它能够学习如何工程化特征或学习如何学习。模型复杂性的显著跳跃,超出插值阈值,可能会在算法处理问题的方式上带来显著的飞跃,但当然,并非每个算法都具备这种飞跃的能力。
深度神经网络,可以通过添加层中排列的隐藏节点无限且轻易地增加额外的复杂性,是消耗大数据的理想候选者。正如你将在第十五章利用大数据中了解到的那样,一个巧妙设计的神经网络可以从非结构化数据,如图像、文本或音频中,构建自己的特征。同样,它被指定为通用函数逼近器意味着它可以识别出最佳函数形式来模拟数据中识别出的任何模式。因此,我们必须再次回顾早期的问题,即这种做法如何不违反“没有免费的午餐”的原则。似乎对于足够大的数据集,深度学习神经网络是唯一最佳的方法。
将一些实际问题暂且放在一边——特别是,大多数现实世界项目位于小数据领域的事实,以及深度神经网络计算成本高且难以训练的事实——深度学习不违反“没有免费的午餐”原则的一个关键原因是,一旦神经网络变得很大并且过度参数化,并且假设它能够访问足够大且复杂的训练数据集,它就不再是一个单一的学习算法,而变成了一个通用的学习过程。如果这听起来像是一个没有区别的区别,也许一个比喻会有所帮助:深度学习的过程不是提供免费的午餐,而是为算法提供如何自己制作午餐的机会。鉴于真正大数据的有限可用性和深度学习在大多数商业任务中的有限适用性,为了产生最强大的模型,机器学习从业者仍然需要在特征工程过程中提供帮助。
实践中的特征工程
根据项目或情况的不同,特征工程的实际做法可能会有很大差异。一些大型、技术导向的公司为每位数据科学家配备一名或多名数据工程师,这使得机器学习从业者可以减少对数据准备的关注,更多地专注于模型构建和迭代。某些项目可能依赖于非常小或非常大的数据量,这可能会排除或需要使用深度学习方法或自动特征工程技术。即使是那些初始特征工程工作量较小的项目,也可能遭受所谓的“最后一公里问题”,这描述了在分销的“最后一公里”中,成本和复杂度与需要走过的短距离不成比例地高。将这一概念与特征工程联系起来意味着,即使大部分工作由其他团队或自动化完成,准备模型数据最终步骤所需的努力可能仍然相当可观。
很可能,今天的大多数现实世界机器学习项目都需要大量的特征工程。大多数公司尚未达到组织层面的分析成熟度,这允许数据科学家专注于模型构建。许多公司和项目可能永远不会达到这一水平,因为它们的规模较小或范围有限。对于许多中小型公司和中小型项目,数据科学家必须从项目的开始到结束负责所有方面。因此,数据科学家有必要了解特征工程师的角色,并准备在需要时执行这一角色。
如前所述,特征工程更多的是艺术而非科学,它需要的想象力与编程技能一样多。简而言之,特征工程的主要目标可能可以描述为:
-
补充现有数据,利用额外的外部信息来源
-
将数据转换为符合机器学习算法的要求,并协助模型进行学习
-
在最小化有用信息损失的同时消除噪声——相反,最大化利用可用信息
在实践特征工程时,要记住的一个总体格言是“要聪明”。一个人应该努力成为一个聪明、节俭的数据挖掘者,并尝试思考你可能在每个特征中找到的微妙见解,系统地工作,避免让任何数据浪费。应用这一规则既是创造力的提醒,也有助于激发构建最强性能学习者的竞争精神。
尽管每个项目都需要你以独特的方式应用这些技能,但经验将揭示在许多类型的项目中出现的某些模式。以下各节,提供了七个关于特征工程艺术的“提示”,并非旨在详尽无遗,而是提供一些灵感,以激发如何创造性地思考使数据更有用。
市场上关于特征工程的书籍一直很少,直到最近,才出版了一些。关于这个主题的两本早期书籍是 Packt Publishing 的《Feature Engineering Made Easy》(Ozdemir & Susara,2018)和 O’Reilly 的《Feature Engineering for Machine Learning》(Zheng & Casari,2018)。《Feature Engineering and Selection》(Kuhn & Johnson,2019)也是一本杰出的书籍,甚至还有一个免费版本,可在网上找到:www.feat.engineering。
提示 1:头脑风暴新特征
新机器学习项目主题的选择通常是由未满足的需求驱动的。它可能是由追求更多利润、拯救生命或简单的好奇心驱动的,但无论如何,主题几乎肯定不是随机选择的。相反,它与公司核心问题或好奇者珍视的主题相关,这都表明对这项工作的根本兴趣。追求这个项目的公司或个人可能已经对该主题及其对感兴趣结果有重要贡献的因素有深入了解。有了这种领域经验和专业知识,委托项目的公司、团队或个人可能对任务有专有的见解,他们可以独自带来。
为了利用这些见解,在机器学习项目的开始阶段,在特征工程之前,进行一次头脑风暴会议可能会有所帮助,在这个会议上,利益相关者聚集在一起,就与感兴趣的结果相关的潜在因素提出想法。在这个过程中,重要的是避免将自己限制在现有数据集中容易获得的内容。相反,考虑因果关系的更抽象层次,想象可以拉动以影响结果的正向或负向方向的各个隐喻性“杠杆”。尽可能彻底,并在这次会议中耗尽所有想法。如果你可以在模型中 literally 任何你想要的东西,什么最有用?
头脑风暴会议的成果可能是一个心智图,这是一种围绕中心主题绘制想法的方法。将感兴趣的结果置于心智图的中心,各种潜在预测因子从中心主题辐射出来,如下面一个心智图会议设计预测心脏病死亡率的示例所示。
心智图图可能使用层次结构来链接相关的概念或分组在类似数据源中相关的因素:

图 12.6:心智图有助于想象导致结果的各个因素
在构建心智图的过程中,你可能会发现一些期望的特征在现有数据源中不可用。也许头脑风暴小组可以帮助识别这些数据元素的替代来源,或者找到愿意帮助收集它们的人。或者,可能有可能开发一个代理度量,通过不同的方法有效地衡量相同的概念。例如,可能无法直接测量某人的饮食,但可能可以通过计算他们关注的快餐店数量来使用他们的社交媒体活动作为代理。这并不完美,但总比没有好。
心智图会议还可以帮助揭示特征之间的潜在相互作用,在这些相互作用中,两个或更多因素对结果的影响不成比例;整体效应可能大于(或小于)其各部分之和。在心脏病例中,有人可能会假设压力和肥胖的联合效应比它们各自效应的总和更有可能引起心脏病。决策树和神经网络等算法可以自动找到这些相互作用效应,但许多其他算法不能,而且在任何情况下,如果这些组合在数据中明确编码,可能有助于学习过程,或者导致一个更简单、更可解释的模型。
提示 2:在文本中寻找隐藏的见解
文本数据是隐藏数据最丰富的来源之一,因此也是特征工程最有成效的领域之一。机器学习算法通常不太擅长实现文本数据的全部价值,因为它们缺乏人类在一生语言使用中获得的语义意义的外部知识。
当然,给定大量的文本数据,计算机可能能够学习到相同的东西,但这对于许多项目来说并不可行,并且会极大地增加项目的复杂性。此外,文本数据不能直接使用,因为它受到维度灾难的困扰;每一块文本都是独特的,因此它作为一种指纹将文本与结果联系起来。如果在学习过程中使用,算法可能会严重过拟合或完全忽略文本数据。
维度灾难普遍适用于非结构化的“大数据”,因为图像和音频数据同样难以直接在机器学习模型中使用。第十五章“利用大数据”介绍了允许这些类型的数据源与传统机器学习方法一起使用的一些方法。
负责为学习算法构建特征的负责人可以通过编码从文本解释中得出的降维特征来为文本数据增加洞察力。在选择少量类别时,隐含的意义被明确化。例如,在客户流失分析中,假设一家公司可以访问其客户的公共 Twitter 时间线。每位客户的推文都是独特的,但人类可能能够将它们编码为三个类别:正面、负面和中性。这是一种简单的情感分析形式,它分析语言的情感。计算机软件,包括一些 R 包,可能能够通过使用旨在理解简单语义的模型或规则来自动化这一过程。除了情感分析之外,还可能根据主题对文本数据进行分类;在流失的例子中,也许提到客户服务的推文的客户比提到价格的客户更有可能转向另一家公司。
有许多 R 包可以进行情感分析,其中一些需要订阅付费服务。为了快速轻松地开始,请查看名为SentimentAnalysis和RSentiment的包,以及Syuzhet包。所有这些都可以用几行 R 代码将句子分类为正面或负面。对于更深入的文本挖掘和情感分析,请参阅 2017 年出版的书籍《使用 R 进行文本挖掘:整洁方法,Silge J 和 Robinson D》,可在网上找到www.tidytextmining.com。此外,还可以参考 2017 年出版的《使用 R 进行实践文本挖掘,Kwartler T》。
除了编码文本的明显意义之外,特征工程中的一项微妙艺术在于寻找隐藏在文本数据中的隐蔽洞察。特别是,文本中可能包含与文本的直接解释无关的有用信息,但它可能偶然或意外地出现在文本中,就像扑克游戏中的“提示”——一种揭示玩家秘密意图的微小表情。
隐藏的文本数据可能有助于揭示一个人的身份特征,如年龄、性别、职业级别、位置、财富或社会经济地位。以下是一些例子:
-
名称和称呼,如先生和夫人,或 Jr.和 Sr.,传统和现代名称,男性或女性名称,或与财富相关的名称
-
职位名称和类别,如 CEO、总裁、助理、高级或总监
-
地理和空间代码,如邮政编码、建筑楼层号、国内外地区、一等舱票、邮政信箱等
-
语言标记,如俚语或其他可能揭示身份相关方面的表达
要开始寻找这些类型的隐藏洞察,在系统地审查文本数据时,要牢记感兴趣的结果。尽可能多地阅读文本,同时思考文本可能以任何方式揭示可能影响结果微妙线索的方法。当出现模式时,基于洞察构建一个特征。例如,如果文本数据通常包括职位名称,可以创建规则将工作分类为职业级别,如初级、中级和高级。然后可以使用这些职业级别来预测结果,如贷款违约或流失可能性。
提示 3:转换数值范围
某些学习算法比其他算法更有能力从数值数据中学习。在所有可以利用数值数据的算法中,一些算法在学习数值值范围内的重要切割点方面表现更好,或者更擅长处理严重倾斜的数据。即使是像决策树这样的方法,虽然确实擅长使用数值特征,但也倾向于在数值数据上过度拟合,因此可能从将数值范围减少到更少的潜在切割点的转换中受益。其他方法,如回归和神经网络,可能从数值数据的非线性转换中受益,例如对数缩放、归一化和阶梯函数。
许多这些方法已在之前的章节中介绍并应用。例如,在第四章,概率学习 – 使用朴素贝叶斯进行分类中,我们考虑了离散化(也称为“分箱”或“桶化”)技术,作为将数值数据转换为分类数据的一种手段,以便朴素贝叶斯算法可以使用。这种技术对于可以原生处理数值数据的学习者来说有时也很有用,因为它可以帮助明确决策边界。
以下图示展示了使用数值年龄预测器预测心脏病的一个假设模型的过程。在左侧,我们看到随着数值年龄的增加,颜色变得越来越深,这表明随着年龄的增长,心脏病的发病率也在增加。尽管这个趋势看似明显,但决策树模型可能难以确定合适的分割点,它可能随意分割,或者选择多个小的分割点;这两种情况都可能导致对训练数据的过度拟合。与其让模型自行选择,不如使用先验知识为“年轻”和“老年”患者创建预定义的群体。虽然这会失去一些真实潜在梯度的细微差别,但它可能通过将决策树的“高方差”方法替换为理论驱动的离散化“高偏差”方法,帮助模型更好地泛化到未来的数据。

图 12.7:离散化和其他数值变换可以帮助学习者更容易地识别模式
通常,对于包含数值特征的数据集,系统地探索每个特征可能值得考虑,同时也要考虑学习算法对数值数据的方法,以确定是否需要进行转换。将您可能拥有的任何领域或主题专业知识应用于创建最终特征版本中的桶、桶、步长点或非线性变换。尽管许多算法能够处理数值数据而无需重新编码或转换,但额外的人类智慧可能有助于引导模型达到更好的整体拟合。
提示 4:观察邻居的行为
在特征工程过程中揭示隐藏洞察力的一种不太为人所知的方法是应用常识“物以类聚”。我们在第三章中应用了这一原则,即使用最近邻进行分类的懒惰学习,但这也是识别有用预测因子的一种有用心态。这个想法基于这样一个事实,即数据集中可能存在显式或隐式的分组,通过检查一个示例与其邻近或分组中的其他示例的关系,可能会发现一些洞察力。
在现实世界中数据中经常发现的显式分组的一个例子是家庭。许多数据集不仅包括基于个人的行,还包括家庭标识符,这允许您将行链接到家庭组中,从而根据组的组成创建新的特征。
例如,知道某人在一个家庭中可能表明婚姻状况以及子女或依赖人数,即使这些特征没有包含在原始的个人层级数据集中。简单地计数或聚合一些群体的特征可以产生非常有用的预测因子。
从这里,也可以在组内的记录之间共享信息。例如,知道一位配偶的收入是有帮助的,但知道两位配偶可以更好地表明可用的总收入。组内差异的度量也可能是有启发性的。如果伴侣在某些属性上匹配或不同意,家庭的一些方面可能会提供额外的效果;例如,如果两位伴侣都表示对某家电话公司满意,他们可能比只有一位成员满意的户主更加忠诚。
这些原则也适用于不那么明显但仍很明确的分组,例如邮政编码或地理区域。通过收集属于该组的行,可以计数、求和、计算平均值、取最大值或最小值,或者检查组内的多样性以构建新的、可能有用的预测因子。在更多或更少的共识或多样性方面的组可能对某些结果更稳健或更不稳健。
识别隐含的分组也可能有价值——也就是说,这种分组在数据集中没有直接编码。例如,在第九章 寻找数据组 - 使用 k-means 进行聚类 中描述的聚类方法是一种潜在的寻找这些类型分组的方法,并且这些结果簇可以直接用作模型中的预测因子。例如,在一个客户流失项目中,使用簇作为模型的特征可能会揭示某些簇比其他簇更有可能流失。这可能意味着流失与簇的潜在人口统计特征有关,或者流失在簇成员之间有一定的传染性。
换句话说,如果物以类聚,那么从类似邻居的经验中借用领先指标是有意义的——他们可能对某些外部因素有类似的反应,或者可能直接相互影响。表现出罕见或独特特征的隐含组本身可能很有趣;也许其中一些是趋势领导者或“煤矿中的金丝雀”——比其他群体更早对变化做出反应的趋势领导者。观察他们的行为并将这些群体明确编码到模型中可能会提高模型的预测能力。
如果你确实使用了邻居(或下一节中描述的相关行)的信息,请注意数据泄露的问题,这在第十一章 用机器学习取得成功 中已有描述。确保只使用在模型部署时将可用的信息来构建特征。例如,如果只有一位家庭成员完成贷款申请,而另一位配偶的数据是在贷款批准后添加的,那么在信用评分模型中使用两位配偶的数据是不明智的。
提示 5:利用相关行
在前一部分中提到的“跟随领导者”行为的实践,考虑到时间序列数据集中相关行,在时间不同点重复测量同一属性,可以特别强大。包含重复测量的数据提供了许多构建有用预测因子的额外机会。而前一部分考虑了在分析单位“跨”分组相关数据,当前部分考虑了在分析单位“内”分组相关观察的价值。本质上,通过重复观察相同的分析单位,我们可以检查它们的历史趋势并更好地预测未来。
回顾假设的流失率示例,假设我们能够访问过去 24 个月来自在线视频流媒体服务订阅者的数据。观察的单位是客户-月(每位客户每月一行),而我们的分析单位是客户。我们的目标是预测哪些客户最有可能流失,以便我们可能进行干预。为了构建机器学习的数据集,我们必须收集观察单位并将它们聚合为每位客户一行。这正是特征工程特别需要的地方。在将历史数据“汇总”为分析的单行过程中,我们可以构建检查趋势和忠诚度的特征,提出如下问题:
-
客户的平均月度活动是否高于或低于他们的同龄人?
-
客户随时间推移的月度活动是什么?它是上升、下降还是稳定的?
-
他们的活动频率如何?他们是忠诚的吗?他们的忠诚度在月份间是否稳定?
-
客户的行为一致性如何?行为是否每月都有很大变化?
如果你熟悉基本的微积分,反思一阶和二阶导数的概念可能会有所帮助,因为它们都可以作为时间序列模型中的有用特征。这里的一阶导数指的是行为的速度——即单位时间内的行为计数。例如,我们可能计算每月在流媒体服务上花费的美元数,或者每月流过的电视节目和电影的数量。这些单独就是有用的预测因子,但在二阶导数的背景下,它们可以变得更加有用。二阶导数是行为的加速度(或减速度),即速度随时间的变化,如每月支出的变化或每月流过的节目数量的变化。高消费和高使用率的客户可能不太可能流失,但来自这些客户的快速减速度(即使用或支出的大幅减少)可能表明即将流失。
除了速度和加速度之外,还可以构建一致性、可靠性和变异性的度量,以进一步增强预测能力。一种突然改变的行为可能比类似变化但波动剧烈的行为更令人担忧。计算最近几个月有购买、支出或行为达到给定阈值的比例,可以提供一个简单的忠诚度指标,但使用方差等更复杂的度量也是可能的。
提示 6:分解时间序列
上文所述的重复测量时间序列数据,每个分析单位有多个相关行,被称为长格式。这与大多数基于 R 的机器学习方法所需的数据类型形成对比。除非学习算法被设计成能够理解重复测量数据的关联行,否则它将需要以宽格式指定时间序列数据,即将数据的重复行转换为重复的列。例如,如果一个体重测量值每月记录 3 个月,针对 1,000 名患者,长格式数据集将包含 3 * 1,000 = 3,000 行和 3 列(患者标识符、月份和体重)。如图 12.8 所示,同样的数据集在宽格式下将只有 1,000 行但 4 列:1 列用于患者标识符,3 列用于每月的体重读数:

图 12.8:大多数机器学习模型都需要将长格式时间序列数据转换为宽格式
要构建一个宽格式数据集,首先必须确定多少历史数据对预测是有用的。所需的历史数据越多,就需要在宽数据集中添加更多的列。例如,如果我们想要预测客户未来一个月的能源使用情况,我们可能会决定使用他们前 12 个月的能源使用数据作为预测因子,以便覆盖整个一年的季节性变化。因此,为了构建预测 2023 年 6 月能源使用的模型,我们可能会创建 12 个预测因子列,分别测量 2023 年 5 月、4 月、3 月的能源使用情况,以及 6 月之前每个月的能源使用情况。第 13 列将是目标或因变量,记录 2023 年 6 月的实际能源使用情况。请注意,基于此数据集训练的模型将学会根据 2022 年 6 月至 2023 年 5 月的数据来预测 2023 年 6 月的能源使用情况,但它无法预测其他未来的月份,因为目标和预测因子与特定的月份相关联。
相反,更好的方法是构建滞后变量,这些变量相对于目标月份进行计算。滞后变量基本上是延迟一段时间以传递到数据集中更晚、更近的行的度量。使用滞后变量的模型可以在有更多月份数据可用时按滚动、每月重新训练。而不是有像energy_june2023和energy_may2023这样的列名,生成的数据集将具有表示测量相对性质的名称,如energy_lag0、energy_lag1和energy_lag2,这表示当前月份、上个月和两个月前的能源消耗。此模型将始终应用于最新数据以预测即将到来的时间段。
图 12.9 展示了这种方法。每个月,模型都会在过去的 13 个月数据上训练;最近的一个月被用作目标或因变量(表示为 DV),而早先的 12 个月则用作滞后预测变量。然后,模型可以用来预测尚未观察到的未来月份。第一个月之后的每个月都会将滚动窗口向前移动 1 个月,这样超过 13 个月的数据就不会在模型中使用。使用这种方式构建的数据训练的模型不会学习特定日历月份之间的关系,正如非滞后变量那样;相反,它学习的是先前行为与未来行为之间的关系,而不考虑日历月份。

图 12.9:构建滞后预测变量是建模时间序列数据的一种方法
然而,这种方法的一个问题是,它忽略了日历时间,而某些日历月份可能对目标变量有重要影响。例如,能源消耗在冬季和夏季可能比春季和秋季高,因此,模型不仅要知道过去和未来的行为之间的关系,还要了解季节效应或其他比局部模式更广泛的模式,这些模式与分析单位相关。
可以想象,要预测的目标值由三个来源的变异性组成,我们希望将其分解为模型的特征:
-
有局部或内部变异性,这是基于分析单位的独特属性。在预测能源需求的例子中,局部变异性可能与家庭的大小和结构、居民的能源需求、房屋的位置等因素有关。
-
可能存在更广泛的全球趋势,如燃料价格或天气模式,这些都会影响大多数家庭的能源消耗。
-
可能存在季节性影响,独立于本地和全球影响,可以解释目标的变化。这不仅仅限于之前提到的年度天气模式,任何周期性或可预测的模式都可以被认为是季节性影响。
与能源预测项目相关的某些具体例子可能包括对以下方面的需求更高或更低:
-
一周中的不同日子,尤其是工作日与周末
-
宗教或政府假日
-
传统学校或商务假期期间
-
像体育赛事、音乐会和选举这样的大规模集会
如果可以将本地、全球和季节性特征作为预测因子纳入训练数据集中,模型就可以学习它们对结果的影响。接下来的挑战有两个方面:需要专业知识或数据探索来识别重要的季节性因素,并且必须有足够的训练数据,以便在每个包含的季节中观察到目标。后者意味着训练数据应该由超过一个月的时间横截面组成;如果没有这一点,学习算法显然将无法发现季节与目标之间的关联!
虽然看起来我们应该回归到原始的长格式数据,但实际上并非如此。实际上,包含每月滞后变量的宽数据可以堆叠在一个单一统一的数据集中,每个分析单位有多行。每一行表示在特定时间点的个人,目标变量衡量该时刻的结果,以及一组作为目标之前时间段滞后变量的宽列。还可以添加额外的列来进一步拓宽矩阵并分解时间变化的各个组成部分,例如季节、星期几和假日的指标;这些列将指示给定的行是否属于这些感兴趣的时期之一。
下一个图表展示了使用这种方法的一个假设数据集。每个家庭(由household_id列表示)可以重复出现,具有不同的目标值(energy_use)和预测因子值(season、holiday_month、energy_lag1等)。请注意,数据集的前几行中缺失了滞后变量(如NA值所示),这意味着这些行不能用于训练或预测。然而,剩余的行可以使用任何能够进行数值预测的机器学习方法,并且训练好的模型将能够根据当前月份的数据行预测下个月的能源使用。

图 12.10:包括历史数据的数据集可能包括季节性效应和滞后预测因子
在匆忙进行时间序列数据建模之前,理解这里描述的数据准备方法的一个重要注意事项至关重要:由于来自同一分析单元的重复观察的行彼此相关,将它们包含在训练数据中违反了回归等方法的独立观察假设。尽管基于此类数据构建的模型可能仍然有用,但其他正式时间序列建模方法可能更合适,最好将这里描述的方法视为使用之前介绍的机器学习方法的替代方案来进行预测。线性混合模型和循环神经网络是两种可以原生处理此类数据的潜在方法,尽管这两种方法都不在本书的范围之内。
R 中的lme4包用于构建混合模型,但如果不了解这些类型模型的统计基础就贸然进入,那将是不明智的;与传统的回归建模相比,它们在复杂性上有了显著的提升。《使用 R 进行线性混合效应模型》(Gałecki & Burzykowski,2013)一书提供了构建此类模型所需的理论背景。然而,对于构建循环神经网络,R 可能不是完成这项工作的最佳工具,因为存在专门为此目的而设计的工具。不过,rnn包可以构建简单的循环神经网络模型用于时间序列预测。
提示 7:附加外部数据
与本书中的教学示例不同,当机器学习项目在现实世界中开始时,数据集不能简单地从互联网上下载,其中包含预先构建的特征和描述感兴趣主题的示例。遗憾的是,许多极具趣味的项目因为这一简单原因在开始之前就被扼杀。希望预测客户流失的企业意识到他们没有可用于构建模型的历史数据;希望优化贫困地区食品分配的学生受限于这些地区稀缺的数据量;以及无数可能增加利润或改善世界的项目在开始之前就被阻碍。原本围绕机器学习项目的兴奋很快因为数据不足而消散。
不应以失望告终,而应将这种能量转化为从头开始创建必要数据的努力。这可能意味着打电话给同事或发送一系列电子邮件以联系那些可以提供访问包含相关数据片段的数据库的人。这也可能需要你亲自动手,因为毕竟我们生活在这个所谓的“大数据时代”,数据不仅丰富,而且易于记录,得益于电子传感器和自动数据录入工具。

图 12.11:很少的努力就足以生成对机器学习有用的数据集
在最坏的情况下,投入时间、精力和想象力可以从无到有地构建有用的数据集。通常,这比人们想象的要容易。前面的图示说明了几个我创建数据集以满足自己好奇心的情况。
对自动驾驶汽车着迷,我在我的社区周围开车并拍摄路标照片,以构建一个停车标志分类算法。为了预测二手车价格,我从二手车网站上复制粘贴了数百个列表。而且,为了确切了解为什么与“Aiden”押韵的名字在美国变得如此流行,我从社会保障婴儿名字数据库中收集了几十年的数据。这些项目中的任何一个都不需要超过几个小时的努力,但通过将朋友、同事或互联网论坛作为众包努力的一种形式,甚至支付数据录入助手,可以并行化任务并帮助我的数据库更大或更快地增长。像 Amazon Mechanical Turk (www.mturk.com)这样的付费服务提供了一种经济实惠的方式,用于分配大量且繁琐的数据录入或收集任务。
为了进一步丰富现有的数据集,通常有从外部来源附加额外特征的可能性。这尤其适用于感兴趣的原始数据集包括地理标识符,如邮政编码,因为许多公开可用的数据库测量这些地区的属性。当然,邮政编码级别的数据集不会揭示特定个人的确切特征;然而,它可能提供有关该地区平均个人是否更富有、更健康、更年轻或更有可能拥有孩子的见解,以及其他许多可能有助于提高预测模型质量的因素。这些类型的数据可以在许多政府机构网站上轻松找到,并且免费下载;只需将它们合并到主数据集,以添加可能的预测因子。
最后,许多社交媒体公司和数据聚合服务,如 Facebook、Zillow 和 LinkedIn,提供对其数据有限部分的免费访问。例如,Zillow 提供邮政编码地区的房屋价值估算。在某些情况下,这些公司或其他供应商可能会出售对这些数据集的访问权限,这可以是一种强大的增强预测模型的方法。除了这些收购的财务成本外,它们通常在 记录链接 方面也提出了重大挑战,这涉及到在没有任何共同唯一标识符的跨数据集中匹配实体。解决此问题需要构建一个 映射表,它将一个来源中的每一行映射到另一个来源中相应的行。例如,映射可能将主数据集中通过客户识别号识别的个人与外部社交媒体数据集中唯一的网站 URL 链接起来。尽管存在像 RecordLinkage 这样的 R 包可以帮助在来源之间执行此类匹配,但这些包依赖于启发式方法,可能不如人类智能表现得好,并且需要大量的计算成本,尤其是在大型数据库中。总的来说,从人力资源和计算成本的角度来看,可以安全地假设记录链接通常成本高昂。
在考虑是否获取外部数据时,务必研究数据源的使用条款,以及您所在地区关于使用此类数据源的法律和组织规则。一些司法管辖区比其他地区更为严格,许多规则随着时间的推移也在变得更加严格,因此了解与外部数据相关的合法性和责任至关重要。
由于高级数据准备涉及的工作量,R 本身也在不断进化以跟上新的需求。历史上,R 以处理非常大的和复杂的数据集而闻名,但随着时间的推移,已经开发出新的包来解决这些不足,并使执行本章中迄今为止描述的操作变得更加容易。在本章的剩余部分,您将了解这些包,它们使 R 语法现代化,以应对现实世界的数据挑战。
探索 R 的 tidyverse
一种新的方法迅速成为在 R 中处理数据的占主导地位的模式。由 Hadley Wickham(许多推动 R 初始流行潮的包背后的思想)倡导,这一新趋势现在得到了 Posit(原名 RStudio)的一个更大团队的支撑。该公司的用户友好的 RStudio 桌面应用程序很好地整合到了这个新生态系统中,称为 tidyverse,因为它提供了一系列致力于整洁数据的包。整个 tidyverse 包集可以通过 install.packages("tidyverse") 命令安装。
在线有越来越多的资源可以帮助你了解 tidyverse,从其主页www.tidyverse.org开始。在这里,你可以了解包含在该套件中的各种包,其中一些将在本章中描述。此外,Hadley Wickham 和 Garrett Grolemund 合著的《R for Data Science》一书可在r4ds.hadley.nz免费在线阅读,并展示了 tidyverse 自诩的“有见地”的方法如何简化数据科学项目。
我经常被问到 R 与 Python 在数据科学和机器学习方面的比较问题。RStudio 和 tidyverse 可能是 R 最大的资产和区别点。可以说,没有比开始数据科学之旅更容易的方法了。一旦你学会了“tidy”的数据分析方法,你很可能会希望 tidyverse 的功能无处不在!
使用 tibbles 制作整洁的表格结构
虽然数据框是基础 R 宇宙的中心,但 tidyverse 的核心数据结构位于tibble包中(tibble.tidyverse.org),其名称是对“table”一词的双关语,同时也是对《星际迷航》传说中著名的“tribble”的致敬。一个tibble几乎与数据框完全相同,但为了方便和简洁,还包括了额外的现代功能。tibbles 几乎可以在数据框可以使用的任何地方使用。有关 tibbles 的详细信息,可以在 R 中输入命令vignette("tibble")来获取。
大多数时候,使用 tibbles 将是透明和无缝的,因为 tibbles 可以在大多数 R 包中充当数据框。然而,在极少数需要将 tibble 转换为数据框的情况下,请使用as.data.frame()函数。要反向操作并将数据框转换为 tibble,请使用as_tibble()函数。在这里,我们将首先从上一章中首次介绍的 Titanic 数据集中创建一个 tibble:
> library(tibble) # not necessary if tidyverse is already loaded
> titanic_csv <- read.csv("titanic_train.csv")
> titanic_tbl <- as_tibble(titanic_csv)
输入这个对象的名称展示了 tibble 比标准数据框具有更简洁和更丰富的输出:

图 12.12:显示 tibble 对象的结果比标准数据框更具有信息量
重要的是要注意 tibbles 和数据框之间的区别,因为 tidyverse 将为许多操作自动创建一个 tibble 对象。总的来说,你可能会发现 tibbles 比数据框更快、更容易处理。它们通常对数据的假设更智能,这意味着你将花费更少的时间重做 R 的工作——比如将字符串重新编码为因子或反之亦然。
事实上,tibbles 和数据框之间一个简单的区别是,tibble 从不假设 stringsAsFactors = TRUE,这是 R 版本 4.0 之前在基础 R 中的默认行为。正如前几章所述,R 的 stringsAsFactors 设置有时会导致混淆或编程错误,因为字符列默认自动转换为因子。tibbles 和数据框之间的另一个区别是,只要名称被反引号(`)包围,tibble 就可以使用非标准列名,如 `my var`,这违反了基础 R 的对象命名规则。其他 tibbles 的好处可以通过后续章节中描述的互补 tidyverse 包来解锁。
使用 readr 和 readxl 更快地读取矩形文件
几乎到目前为止的每一章都使用了 read.csv() 函数将数据加载到 R 数据框中。虽然我们可以将这些数据框转换为 tibbles,但有一条更快、更直接的方法将数据导入 tibble 格式。tidyverse 包含了用于加载表格数据的 readr 包(readr.tidyverse.org)。这在本节 R for Data Science 的数据导入章节中有描述(r4ds.hadley.nz/data-import.html),但基本功能很简单。
readr 包提供了一个 read_csv() 函数,它从 CSV 文件加载数据,类似于基础 R 的 read.csv() 函数。除了它们函数名之间的细微差别之外,tidyverse 的版本要快得多——不仅仅是因为它自动将数据转换为 tibble。根据包作者的说明,它在读取数据方面大约快 10 倍。它还对要加载的列的格式更智能。例如,它具有处理带货币字符的数字、解析日期列的能力,并且在国际数据方面处理得更好。
要从 CSV 文件创建 tibble,只需使用以下 read_csv() 函数:
> library(readr) # not necessary if tidyverse is already loaded
> titanic_train <- read_csv("titanic_train.csv")
这将使用默认的解析设置,尝试为每一列推断正确的数据类型(即字符或数值)。在完成文件读取后,列规范将在 R 输出中显示。可以通过向 read_csv() 函数传递一个 col() 函数调用来提供正确的列规范,从而覆盖推断的数据类型。有关语法的更多信息,请使用 vignette("readr") 命令查看文档。
readxl 包(readxl.tidyverse.org)提供了一种直接从 Microsoft Excel 电子表格格式读取数据的方法。要从 XLSX 文件创建 tibble,只需使用以下 read_excel() 函数:
> library(readxl)
> titanic_train <- read_excel("titanic_train.xlsx")
或者,如第二章中首次介绍的那样,管理和理解数据,RStudio 桌面应用程序可以为您编写数据导入代码。在界面的右上角,在环境选项卡下,有一个导入数据集按钮。此菜单显示一系列数据导入选项,包括 CSV 文件等纯文本格式(使用基础 R 或 readr 包),以及由其他统计计算软件工具创建的 Excel、SPSS、SAS 和 Stata 格式。使用从文本(readr)选项显示以下图形界面,允许轻松定制导入过程:

图 12.13:RStudio 的导入数据集功能自动编写 R 代码,以便轻松导入各种数据格式
界面显示数据的预览,随着导入参数的定制而更新。默认列数据类型可以通过点击列标题中的下拉菜单进行定制,右下角的代码预览将相应更新。
点击导入按钮将立即执行代码,但更好的做法是将代码复制并粘贴到您的 R 源代码文件中,以便将来可以轻松再次运行导入过程。
使用 dplyr 准备和管道数据
dplyr包(dplyr.tidyverse.org)为 tidyverse 提供了基础设施,因为它包括允许数据转换和操作的基本功能。它还提供了一种简单的方法来开始使用 R 中的大型数据集。尽管有其他包具有更高的原始速度或能够处理更大的数据集,但 dplyr 仍然非常强大,如果您在基础 R 中遇到速度或内存限制时,它是一个很好的第一步。
当与 tibble 对象一起使用时,dplyr 解锁了一些令人印象深刻的功能:
-
由于 dplyr 专注于数据框而不是向量,因此引入了新的运算符,允许以更少的代码执行常见的数据处理转换,同时保持高度可读性。
-
该包对数据框做出了合理的假设,从而优化了您的努力以及内存使用。如果可能的话,它通过指向原始值而不是创建副本来避免复制数据。
-
代码的关键部分是用 C++编写的,据作者称,这使许多操作的性能比基础 R 提高了 20 倍到 1,000 倍。
-
R 数据框受可用内存的限制。使用 dplyr,tibbles 可以透明地链接到基于磁盘的数据库,其容量超过内存可以存储的内容。
在克服了初始的学习曲线之后,dplyr 的数据处理语法变得自然而然。语法中有五个关键动词,执行了数据表中最常见的许多转换。从 tibble 开始,可以选择:
-
通过列的值
filter()筛选数据行 -
通过名称
select()选择数据列 -
arrange()通过排序值来排序数据行 -
mutate()通过变换值将列转换为新列 -
summarize()通过聚合值来汇总数据行
这五个 dplyr 动词通过管道操作符串联起来,该操作符从 R 4.1 或更高版本开始原生支持。管道操作符由|>符号表示,其形状略似一个指向右方的箭头,通过“管道”将数据从一个函数移动到另一个函数。使用管道操作符可以创建强大的函数链,以顺序处理数据集。
在 R 4.1.0 更新之前的版本中,管道操作符由%>%字符序列表示,并需要magrittr包。新旧管道功能之间的差异相对较小,但作为原生操作符,新的管道可能具有轻微的速度优势。为了快速输入管道操作符,RStudio 桌面 IDE 中,ctrl + shift + m快捷键可以插入字符序列。请注意,为了使此快捷键生成更新的管道,您可能需要将 RStudio“代码”标题下的“全局选项”菜单中的设置更改为“使用原生管道操作符,|>”。
在使用library(dplyr)命令加载包之后,数据转换从将 tibble 通过管道操作符传入包中的一个动词开始。例如,有人可能filter()泰坦尼克号数据集的行,以限制行只包含女性:
> titanic_train |> filter(Sex == "female")
类似地,有人可能只select()名称、性别和年龄列:
> titanic_train |> select(Name, Sex, Age)
dplyr 开始发光之处在于其能够通过管道操作符串联一系列动词。例如,我们可以结合前两个动词,使用arrange()动词按字母顺序排序,并将输出保存到 tibble 中,如下所示:
> titanic_women <- titanic_train |>
filter(Sex == "female") |>
select(Name, Sex, Age) |>
arrange(Name)
尽管这目前可能看起来并不像是一个重大的发现,但当与mutate()动词结合使用时,我们可以使用比基础 R 语言更简单、更易读的代码执行复杂的数据转换。我们将在稍后看到几个mutate()的例子,但现阶段,重要的是要记住它用于在 tibble 中创建新列。例如,我们可能创建一个表示乘客是否至少 65 岁的二进制elderly特征。
这使用了 dplyr 包的if_else()函数,如果乘客是老年人,则分配值为1,否则为0:
> titanic_train |>
mutate(elderly = if_else(Age >= 65, 1, 0))
通过逗号分隔语句,可以在单个mutate()语句中创建多个列。这里展示了如何创建一个额外的child特征,表示乘客是否小于 18 岁:
> titanic_train |>
mutate(
elderly = if_else(Age >= 65, 1, 0),
child = if_else(Age < 18, 1, 0)
)
剩余的dplyr动词summarize()允许我们通过分组tibble中的行来创建汇总或汇总度量。例如,假设我们想要计算按年龄或性别计算的存活率。我们将从性别开始,因为它比另一个案例更容易。我们只需将数据通过group_by(Sex)函数管道化以创建男性和女性组,然后跟随一个summarize()语句来创建一个survival_rate特征,该特征计算按组的平均存活率:
> titanic_train |>
group_by(Sex) |>
summarize(survival_rate = mean(Survived))
# A tibble: 2 x 2
Sex survival_rate
<chr> <dbl>
1 female 0.742
2 male 0.189
如输出所示,女性比男性更有可能存活。
要按年龄计算存活率,由于缺少年龄值,事情稍微复杂一些。我们需要过滤掉这些行,并使用group_by()函数来比较儿童(18 岁以下)和成人,如下所示:
> titanic_train |>
filter(!is.na(Age)) |>
mutate(child = if_else(Age < 18, 1, 0)) |>
group_by(child) |>
summarize(survival_rate = mean(Survived))
# A tibble: 2 x 2
child survival_rate
<dbl> <dbl>
1 0 0.381
2 1 0.540
结果表明,儿童比成人有大约 40%的存活率。当与男性和女性的比较结合起来时,这为假设的“妇女和儿童优先”的撤离沉船政策提供了强有力的证据。
由于可以使用 R 的基础方法(包括前几章中描述的ave()和aggregate()函数)计算按组汇总的统计量,因此值得注意的是summarize()命令也具有更多功能。特别是,人们可能使用它来完成本章前面描述的特征工程提示,例如观察邻居的行为、利用相关行和时间序列分解。这三种情况都涉及group_by()选项,如家庭、邮政编码或时间单位。使用dplyr对这些数据准备操作进行聚合比在基础 R 中尝试这样做要容易得多。
为了将我们迄今为止学到的知识结合起来,并使用管道提供一个更多示例,让我们构建泰坦尼克号数据集的决策树模型。我们将filter()缺失的年龄值,使用mutate()创建一个新的AgeGroup特征,并select()决策树模型感兴趣的列。结果数据集通过管道传递到rpart()决策树算法,这展示了将数据传递到 tidyverse 之外的函数的能力:
> library(rpart)
> m_titanic <- titanic_train |>
filter(!is.na(Age)) |>
mutate(AgeGroup = if_else(Age < 18, "Child", "Adult")) |>
select(Survived, Pclass, Sex, AgeGroup) |>
rpart(formula = Survived ~ ., data = _)
注意,这一系列步骤几乎就像伪代码。还值得注意的是rpart()函数调用中的参数。formula = Survived ~ .参数使用 R 的公式接口将存活率建模为所有预测器的函数;这里的点代表数据集中未明确列出的其他特征。data = _参数使用下划线(_)作为占位符来表示通过管道传递给rpart()的数据。下划线可以以这种方式使用,以指示数据应传递到的函数参数。
对于 dplyr 的内置函数来说,这通常是不必要的,因为它们默认将管道数据作为第一个参数查找,但 tidyverse 之外的函数可能需要以这种方式将管道目标特定函数参数。
重要的是要注意,下划线占位符字符是 R 版本 4.2 中引入的,在之前的版本中不会工作!在旧代码中使用magrittr包时,点字符(.)被用作占位符。
为了娱乐,我们可以可视化生成的决策树,该树显示妇女和儿童比成年人、男性和第三乘客舱的人更有可能生存:
> library(rpart.plot)
> rpart.plot(m_titanic)
这会产生以下决策树图:

图 12.14:使用一系列管道构建的预测泰坦尼克号生还的决策树
这些只是几个简单的例子,说明了 dplyr 命令序列如何使复杂的数据操作任务变得简单。实际上,由于 dplyr 代码的效率更高,这些步骤通常比 R 基础命令执行得更快!提供完整的 dplyr 教程超出了本书的范围,但网上有许多学习资源,包括https://r4ds.hadley.nz/transform.html上的R for Data Science章节。
使用 stringr 转换文本
stringr包(https://stringr.tidyverse.org)添加了分析并转换字符字符串的功能。当然,基础 R 也可以这样做,但函数在处理向量时的表现不一致,而且相对较慢;stringr以更适合 tidyverse 工作流程的形式实现了这些函数。《R for Data Science》这个免费资源有一个教程,介绍了该包的全部功能,可在r4ds.hadley.nz/strings.html找到,但在这里,我们将检查与特征工程最相关的方面。如果您想跟上来,请确保在继续之前加载 Titanic 数据集并安装并加载stringr包。
在本章前面,关于特征工程的第二个提示是“在文本中找到隐藏的见解。”stringr包可以通过提供切片字符串和检测文本中模式的函数来帮助这一努力。所有stringr函数都以前缀str_开头,以下是一些相关示例:
-
str_detect()确定一个搜索词是否在字符串中找到 -
str_sub()通过位置切片字符串并返回子字符串 -
str_extract()搜索字符串并返回匹配的模式 -
str_replace()将字符串中的字符替换为其他内容
虽然这些函数看起来相当相似,但它们用于完全不同的目的。为了演示这些目的,我们将首先检查Cabin特征,以确定某些泰坦尼克号上的房间是否与更高的生存率相关。我们不能直接使用这个特征,因为每个舱位代码都是唯一的。
然而,由于代码的形式类似于A10、B101或E67,也许字母前缀表示船上的一个位置,也许在这些位置的一些乘客可能更有能力逃离灾难。我们将使用str_sub()函数来提取从位置 1 开始和结束的 1 个字符子串,并将其保存为CabinCode特征,如下所示:
> titanic_train <- titanic_train |>
mutate(CabinCode = str_sub(Cabin, start = 1, end = 1))
为了确认舱位代码是有意义的,我们可以使用table()函数来查看它和乘客等级之间的清晰关系。useNA参数设置为"ifany"以显示由一些乘客缺失舱位代码而引起的NA值:
> table(titanic_train$Pclass, titanic_train$CabinCode,
useNA = "ifany")
A B C D E F G T <NA>
1 15 47 59 29 25 0 0 1 40
2 0 0 0 4 4 8 0 0 168
3 0 0 0 0 3 5 4 0 479
NA值似乎在较低票价的等级中更为常见,因此似乎合理的推测是较便宜的票价可能没有收到舱位代码。
我们也可以通过将文件管道输入到ggplot()函数中来绘制按舱位代码划分的生存概率图:
> library(ggplot2)
> titanic_train |> ggplot() +
geom_bar(aes(x = CabinCode, y = Survived),
stat = "summary", fun = "mean") +
ggtitle("Titanic Survival Rate by Cabin Code")
结果图显示,即使在头等舱类型(代码 A、B 和 C)中,生存率也存在差异;此外,没有舱位代码的乘客最不可能生存:

图 12.15:舱位代码特征似乎与生存相关,即使在头等舱(A、B 和 C)中也是如此
在没有首先处理Cabin文本数据的情况下,学习算法将无法使用该特征,因为代码对每个舱位都是唯一的。然而,通过应用简单的文本转换,我们已经将舱位代码解码为可以用来提高模型生存预测的东西。
在这个成功的基础上,让我们检查另一个潜在的数据来源:Name列。有人可能会认为这在一个模型中是不可用的,因为名字是每行的唯一标识符,在这个数据上训练模型不可避免地会导致过拟合。尽管这是真的,但名字中隐藏着有用的信息。查看前几行揭示了一些可能有用的文本字符串:
> head(titanic_train$Name)
[1] "Braund, Mr. Owen Harris"
[2] "Cumings, Mrs. John Bradley (Florence Briggs Thayer)"
[3] "Heikkinen, Miss. Laina"
[4] "Futrelle, Mrs. Jacques Heath (Lily May Peel)"
[5] "Allen, Mr. William Henry"
[6] "Moran, Mr. James"
首先,问候语(先生、夫人、小姐)可能有助于预测。问题是这些头衔位于姓名字符串的不同位置,所以我们不能简单地使用str_sub()函数来提取它们。这项工作的正确工具是str_extract(),它用于从较长的字符串中匹配和提取较短的模式。使用这个函数的技巧在于知道如何表达一个文本模式,而不是单独地输入每个可能的问候语。
用于表达文本搜索模式的简写称为正则表达式,或简称regex。了解如何创建正则表达式是一项极其有用的技能,因为它们被用于许多文本编辑器的高级查找和替换功能,此外,在 R 中的特征工程中也很有用。我们将创建一个简单的正则表达式来从姓名字符串中提取称呼。
使用正则表达式的第一步是确定所有目标字符串中的共同元素。在泰坦尼克号姓名的情况下,看起来每个称呼都由一个逗号和一个空格开头,然后是一系列字母,最后以句号结束。这可以编码为以下正则表达式字符串:
", [A-z]+\\."
这看起来似乎是胡言乱语,但可以理解为尝试逐个字符匹配模式的序列。匹配过程从预期的逗号和空格开始。接下来,方括号告诉搜索函数在括号内寻找任何字符。例如,[AB]会搜索A或B,而[ABC]会搜索A、B或C。在我们的用法中,破折号用于搜索A和z之间的任何字符。请注意,大小写很重要——即[A-Z]与[A-z]不同。前者将搜索包含大写字母的 26 个字符,而后者将搜索包括大小写字母在内的 52 个字符。请记住,[A-z]只匹配单个字符。
要使表达式匹配更多字符,我们可以在括号后跟一个+符号来告诉算法继续匹配字符,直到它遇到括号内的内容为止。然后,它检查正则表达式的剩余部分是否匹配。
剩下的部分是\\.序列,这是代表我们搜索模式末尾的单个句点字符的三个字符。因为点是一个特殊术语,代表任意字符,我们必须通过在前面加上斜杠来转义点。不幸的是,斜杠在 R 中也是一个特殊字符,因此我们必须通过在前面加上另一个斜杠来转义它。
正则表达式可能难以学习,但付出努力是值得的。您可以在www.regular-expressions.info深入了解它们是如何工作的。或者,有许多文本编辑器和网络应用程序可以实时演示匹配。这些工具对于理解如何开发正则表达式搜索模式和诊断错误非常有帮助。其中最好的工具之一可以在regexr.com找到。
我们可以通过将mutate()函数与str_extract()结合来将这个表达式应用于泰坦尼克号姓名数据,如下所示:
> titanic_train <- titanic_train |>
mutate(Title = str_extract(Name, ", [A-z]+\\."))
看一下前几个例子,这些似乎需要稍微清理一下:
> head(titanic_train$Title)
[1] ", Mr." ", Mrs." ", Miss." ", Mrs." ", Mr." ", Mr."
让我们使用 str_replace() 函数消除这些标题中的标点和空白空间。我们首先构建一个正则表达式来匹配标点和空格。一种方法是通过使用 "[, \\.]" 搜索字符串来匹配逗号、空白和句号。像这里所示的那样与 str_replace() 结合使用,Title 中的任何逗号、空白和句号字符都将被替换为空字符串(null):
> titanic_train <- titanic_train |>
mutate(Title = str_replace_all(Title, "[, \\.]", ""))
注意,由于需要替换多个字符,使用了 str_replace_all() 替换函数的变体;基本的 str_replace() 只会替换匹配字符的第一个实例。stringr 的许多函数都有“all”变体用于此用途。让我们看看我们努力的成果:
> table(titanic_train$Title)
Capt Col Don Dr Jonkheer Lady
1 2 1 7 1 1
Major Master Miss Mlle Mme Mr
2 40 182 2 1 517
Mrs Ms Rev Sir
125 1 6 1
由于一些标题和问候语的计数较少,将它们分组在一起可能是有意义的。为此,我们可以使用 dplyr 的 recode() 函数来更改类别。我们将保持几个高计数级别不变,而将其他部分组合成 Miss 的变体和一个总括的桶,使用 .missing 和 .default 值将 Other 标签分配给 NA 值和其他尚未编码的内容:
> titanic_train <- titanic_train |>
mutate(TitleGroup = recode(Title,
"Mr" = "Mr", "Mrs" = "Mrs", "Master" = "Master",
"Miss" = "Miss",
"Ms" = "Miss", "Mlle" = "Miss", "Mme" = "Miss",
.missing = "Other",
.default = "Other"
)
)
检查我们的工作,我们看到我们的清理工作按计划进行:
> table(titanic_train$TitleGroup)
Master Miss Mr Mrs Other
40 186 517 125 23
我们还可以通过查看按标题划分的生存率图来验证标题是否有意义:
> titanic_train |> ggplot() +
geom_bar(aes(x = TitleGroup, y = Survived),
stat = "summary", fun = "mean") +
ggtitle("Titanic Survival Rate by Salutation")
这产生了以下条形图:

图 12.16:构建的问候语捕捉了年龄和性别对生存可能性的影响
创建 CabinCode 和 TitleGroup 特征是发现文本数据中隐藏信息的特征工程技术的例证。这些新特征可能会提供比泰坦尼克号数据集中基础特征更多的信息,学习算法可以使用这些信息来提高性能。一点创意加上 stringr 和正则表达式知识可能提供超越竞争所需的边缘。
使用 lubridate 清理日期
lubridate 包(lubridate.tidyverse.org)是处理日期和时间数据的 重要工具。它可能不是每个分析都必须的,但一旦需要,它就能节省很多麻烦。由于闰年和时区等不可预见的细微差别,看似简单的任务会迅速变成冒险,这就像询问那些从事生日计算、账单周期或类似日期敏感任务的人一样。
与其他 tidyverse 包一样,R for Data Science 资源在 https://r4ds.hadley.nz/datetimes.html 提供了深入浅出的 lubridate 教程,但在这里我们将简要介绍其三个最重要的特征工程优势:
-
确保日期和时间数据正确加载到 R 中,同时考虑到日期和时间表达的区域差异
-
准确计算日期和时间之间的差异,同时考虑时区和闰年
-
考虑到在现实世界中人们对时间增量理解的不同,例如,人们在生日时“长了一岁”
将日期读入 R 是挑战之一,因为日期以许多不同的格式呈现。例如,使用 R 进行机器学习 第一版的出版日期可以表示为:
-
October 25, 2013(在美国常见的长写格式)
-
10/25/13(在美国常见的简写格式)
-
25 October 2013(在欧洲常见的长写格式)
-
25.10.13(在欧洲常见的简写格式)
-
2013-10-25(国际标准)
给定这些不同的格式,lubridate 无法在没有帮助的情况下确定正确的格式,因为月份、天数和年份都可以在 1 到 12 的范围内。因此,我们提供正确的日期构造函数——mdy()、dmy() 或 ymd(),具体取决于输入数据中月份(m)、天数(d)和年份(y)组件的顺序。根据日期组件的顺序,这些函数将自动解析长写和简写变体,并处理前导零和两位或四位数的年份。为了演示这一点,之前表示的日期可以使用适当的 lubridate 函数处理,如下所示:
> mdy(c("October 25, 2013", "10/25/2013"))
[1] "2013-10-25" "2013-10-25"
> dmy(c("25 October 2013", "25.10.13"))
[1] "2013-10-25" "2013-10-25"
> ymd("2013-10-25")
[1] "2013-10-25"
注意,在每种情况下,生成的 Date 对象都是完全相同的。让我们为这本书的前三个版本创建类似的对象:
> MLwR_1stEd <- mdy("October 25, 2013")
> MLwR_2ndEd <- mdy("July 31, 2015")
> MLwR_3rdEd <- mdy("April 15, 2019")
我们可以通过简单的数学计算两个日期之间的差异:
> MLwR_2ndEd - MLwR_1stEd
Time difference of 644 days
> MLwR_3rdEd - MLwR_2ndEd
Time difference of 1354 days
注意,默认情况下,两个日期之间的差异以天为单位返回。如果我们希望得到以年为单位的结果呢?遗憾的是,因为这些差异是一个特殊的 lubridate difftime 对象,我们不能简单地通过 365 天来除以这些数字进行明显的计算。一个选择是将它们转换为持续时间,这是 lubridate 计算日期差异的方法之一,特别是跟踪物理时间的流逝——想象它就像一个秒表。as.duration() 函数执行所需的转换:
> as.duration(MLwR_2ndEd - MLwR_1stEd)
[1] "55641600s (~1.76 years)"
> as.duration(MLwR_3rdEd - MLwR_2ndEd)
[1] "116985600s (~3.71 years)"
我们可以看到,使用 R 进行机器学习 第二版和第三版之间的差距几乎是第一版和第二版之间差距的两倍。我们还可以看到,持续时间似乎默认为秒,同时也提供了大约的年数。要仅获取年数,我们可以将持续时间除以 lubridate 提供的 dyears() 函数表示的 1 年持续时间:
> dyears()
[1] "31557600s (~1 years)"
> as.duration(MLwR_2ndEd - MLwR_1stEd) / dyears()
[1] 1.763176
> as.duration(MLwR_3rdEd - MLwR_2ndEd) / dyears()
[1] 3.70705
你可能会觉得 time_length() 函数更方便或更容易记住,它可以执行相同的计算:
> time_length(MLwR_2ndEd - MLwR_1stEd, unit = "years")
[1] 1.763176
> time_length(MLwR_3rdEd - MLwR_2ndEd, unit = "years")
[1] 3.70705
unit 参数可以设置为天、月和年等单位,具体取决于所需的结果。然而,请注意,这些持续时间精确到秒,就像秒表一样,但这并不总是人们考虑日期的方式。
尤其是在生日和周年纪念日,人们倾向于用日历时间来思考——也就是说,日历达到特定里程碑的次数。在 lubridate 中,这种方法被称为间隔,这暗示了基于时间线或日历的日期差异视图,而不是之前讨论的基于秒表的持续时间方法。
让我们想象一下,我们想要计算美国的年龄,从字面上说,美国是在 1776 年 7 月 4 日诞生的。这意味着在 2023 年 7 月 3 日,这个国家将满 246 岁生日,而在 2023 年 7 月 5 日,它将满 247 岁。使用持续时间,我们得到的答案并不完全正确:
> USA_DOB <- mdy("July 4, 1776") # USA's Date of Birth
> time_length(mdy("July 3 2023") - USA_DOB, unit = "years")
[1] 246.9897
> time_length(mdy("July 5 2023") - USA_DOB, unit = "years")
[1] 246.9952
这个问题与持续时间由于日历不规则性(如闰年和时区变化)而偏离日历时间的事实有关。通过显式地将日期差转换为interval()函数中的间隔,然后除以years()函数,我们就能更接近正确答案:
> interval(USA_DOB, mdy("July 3 2023")) / years()
[1] 246.9973
> interval(USA_DOB, mdy("July 5 2023")) / years()
[1] 247
在继续之前,请务必注意interval()函数使用的是start, end语法,这与使用end - start的日期差不同。此外,请注意years()函数返回的是一个 lubridate 周期,这是理解日期和时间差异的另一种方式。周期总是相对于日历上的位置而言的,这意味着在时区变化期间,1 小时的周期可能相当于 2 小时的持续时间,而 1 年的周期可能包括 365 天或 366 天,这取决于日历年——这些都是本节开头段落中提到的处理日期时可能遇到的具有挑战性的细微差别!
为了创建我们最终的年龄计算,我们将使用%--%间隔构造操作符作为缩写,并使用整数除法操作符%/%来返回年龄的整数部分。这些返回预期的年龄值:
> USA_DOB %--% mdy("July 3 2023") %/% years()
[1] 246
> USA_DOB %--% mdy("July 5 2023") %/% years()
[1] 247
将这项工作推广,我们可以创建一个函数来计算给定出生日期的基于日历的年龄:
> age <- function(birthdate) {
birthdate %--% today() %/% years()
}
为了证明它的工作,我们将检查几位著名科技亿万富翁的年龄:
> age(mdy("February 24, 1955")) # Jeff Bezos
[1] 59
> age(mdy("June 28, 1971")) # Elon Musk
[1] 51
> age(mdy("Oct 28, 1955")) # Bill Gates
[1] 67
如果你正在 R 中跟随,请注意你的结果可能会根据你运行代码的时间而变化——不幸的是,我们每天都在变老!
摘要
本章展示了数据准备的重要性。因为用于构建机器学习模型的工具和算法在各个项目中都是相同的,所以数据准备是开启模型性能最高水平的钥匙。这允许人类智能和创造力的某些方面对机器的学习过程产生重大影响,尽管聪明的实践者通过开发利用计算机无休止地搜索数据中有用见解的自动化数据工程管道,与机器的优势相结合。这些管道在所谓的“大数据环境”中尤为重要,在这种环境中,像深度学习这样的数据饥渴方法必须提供大量数据以避免过拟合。
在传统的中小数据环境中,手动特征工程仍然占据主导地位。通过直觉和专业知识,可以引导模型找到训练数据集中最有用的信号。由于这更多的是艺术而非科学,技巧和窍门是在工作中学习到的,或者是从一个数据科学家那里二手传到另一个数据科学家那里。本章提供了七个提示,以帮助你在特征工程的旅程中找到方向,但真正精通特征工程的方法只有通过实践。
类似于 tidyverse 套件的 R 包等工具,使得在过去几年中获取执行特征工程任务所需的经验要容易得多。本章展示了如何使用 tidyverse 包将数据转化为更有用的预测因子,以及如何从文本数据中提取隐藏的信息,将看似无用的特征转化为重要的预测因子。tidyverse 包在处理大型和不断增长的数据库方面比基础 R 函数更强大,并且随着数据集的大小和复杂性的增加,它们使 R 的使用变得更加愉快。
本章中培养的技能将为后续工作提供基础。在下一章中,你将向你的工具箱中添加新的 tidyverse 包,并看到更多关于它如何集成到机器学习工作流程中的例子。当你探索那些开始时相对较小的挑战但若被推向极端会迅速演变成巨大问题的数据问题时,你将继续看到数据准备技能的重要性。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人见面,并在以下地点与超过 4000 人一起学习:

第十三章:具挑战性的数据 – 过多、过少、过于复杂
在机器学习项目的整个过程中,具有挑战性的数据呈现出多种形式,每个新项目的旅程都代表着一次需要开拓精神的冒险。从必须探索的未知数据开始,然后必须整理数据,才能使用学习算法。即便如此,可能仍然存在需要驯服的数据的狂野方面,以使项目成功。必须剔除无关信息,培养重要但微小的细节,并清除学习者道路上的复杂网络。
在大数据时代,传统智慧认为数据是宝藏,但正如俗话所说,好事可能过多。大多数机器学习算法都会欣然接受它们所得到的数据,这导致了一系列类似过度饮食的问题。过多的数据可能会用不必要的信息淹没学习者,掩盖重要的模式,并将学习者的注意力从重要的细节转移到显而易见的地方。因此,可能更好的是避免“越多越好”的心态,而是找到数量和质量之间的平衡。
本章的目的是考虑可以用来适应数据集的信噪比的技术。你将学习:
-
如何处理具有大量特征的庞大数据集
-
利用缺失或出现频率非常低的特征值的技巧
-
模型罕见目标结果的途径
你会发现,一些学习算法在独立执行这些技术方面更有优势,而另一些则可能需要你在过程中进行更广泛的干预。在任何情况下,由于这些数据问题的普遍性和它们作为机器学习中最具挑战性的问题之一的地位,了解它们可以如何得到解决是很重要的。
高维数据的挑战
如果有人说他们在处理数据集的大小方面有困难,很容易假设他们在谈论有太多的行,或者数据使用了太多的内存或存储空间。确实,这些是常见的问题,会给新的机器学习从业者带来麻烦。在这种情况下,解决方案往往是技术性的而不是方法性的;通常会选择一个更有效的算法,或者使用能够处理大数据集的硬件或云计算平台。在最坏的情况下,可以进行随机抽样,简单地丢弃一些过多的行。
数据过多的挑战也可能适用于数据集的列,使得数据集过于宽而不是过于长。可能需要一些创造性思维来想象为什么会发生这种情况,或者为什么这是一个问题,因为在整洁的教学示例中很少遇到这种情况。即使在现实世界的实践中,也可能需要相当长的时间才会有人遇到这个问题,因为有用的预测因子可能很少,数据集通常是零散收集的。对于这样的项目,拥有过多的预测因子可能是一个好问题!
然而,考虑这样一种情况:一个数据驱动的组织,对大数据的竞争优势有深刻的认识,已经从各种来源积累了大量的信息储备。也许他们通过日常业务直接收集了一些数据,从供应商那里购买了补充数据,并通过额外的传感器或通过互联网的间接、被动的交互收集了一些数据。所有这些来源都被合并到一个单一的表格中,该表格提供了一个丰富但高度复杂且多样化的特征集。这个结果表格并不是通过仔细构建的,而是通过数据元素的杂乱组合,其中一些可能比其他的有用。如今,这种类型的数据宝藏主要存在于规模很大或数据非常敏锐的组织中,但未来可能会有越来越多的组织能够访问类似的数据库。数据库随着时间的推移越来越广泛,甚至在考虑固有的特征丰富的来源,如文本、音频、图像或遗传数据之前。
这些高维数据集的挑战,简而言之,与收集的数据点比真正需要表示潜在模式的数据点要多的事实有很大关系。
这些额外的数据点可能会在示例之间增加噪声或细微的变化,可能会使学习算法偏离重要趋势。这描述了维度诅咒,即随着特征数量的增加,学习者会失败。如果我们想象每个额外的特征都是示例的一个新维度——在这里,“维度”一词既用于字面意义,也用于比喻意义——那么随着维度的增加,我们对任何给定示例的理解的丰富性增加,但示例的相对独特性也增加。在一个足够高的维度空间中,每个示例都是独特的,因为它由其独特的特征值组合组成。
考虑一个类比。指纹可以唯一地识别个人,但并不需要存储指纹的所有细节来做出准确的匹配。事实上,在每一个指纹中发现的无限细节中,法医调查员可能只使用 12 到 20 个不同的点来确认匹配;即使是计算机化的指纹扫描仪也只使用 60 到 80 个点。任何额外的细节都可能是不必要的,并会降低匹配质量,甚至可能因为指纹来自同一个人而造成匹配失败!例如,包括过多的细节可能会导致学习算法因为指纹的朝向或图像质量而分心,从而导致假阴性;而细节过少可能会导致假阳性,因为算法没有足够多的特征来区分相似候选人。显然,找到过多和过少细节之间的平衡是非常重要的。这本质上就是降维的目标,它通过识别重要细节来纠正维度诅咒。

图 13.1:降维有助于忽略噪声并强调有助于学习潜在模式的关键细节
与非常长的数据集问题相比,从宽数据集中学习所需的解决方案完全不同,它们在概念上和实际应用上同样重要。不能像处理行那样简单地随机丢弃列,因为有些列比其他列更有用。相反,采取了一种系统性的方法,通常与学习算法本身合作,以在过多和过少细节之间找到平衡。正如你将在接下来的章节中了解到的那样,其中一些方法被集成到学习过程中,而其他方法则需要更实际的方法。
应用特征选择
在监督机器学习的背景下,特征选择的目标是通过仅选择最重要的预测因子来减轻维度诅咒。即使在无监督学习的情况下,特征选择也可能是有益的,因为它能够通过消除冗余或无用的信息来简化数据集。除了特征选择的主要目标是帮助学习算法尝试将信号与噪声分离之外,该实践的额外好处还包括:
-
缩小数据集的大小并减少存储需求
-
减少模型训练所需的时间或计算成本
-
使数据科学家能够专注于更少的功能进行数据探索和可视化
而不是试图找到单个最优化完整的预测因子集,这可能会非常耗费计算资源,特征选择往往侧重于识别有用的单个特征或特征子集。为此,特征选择通常依赖于减少搜索子集数量的启发式方法。这降低了计算成本,但可能会导致错过最佳可能的解决方案。
搜索有用特征的子集意味着假设某些预测因子是无用的,或者至少不如其他预测因子有用。尽管这个前提是有效的,但并不总是清楚是什么使某些特征有用而其他特征无用。当然,可能存在显然不相关的特征,它们没有任何预测价值,但也可能存在有用的特征,它们是冗余的,因此对于学习算法来说是不必要的。关键是认识到,在一种情况下看似冗余的东西,实际上在另一种情况下可能是有用的。
下图说明了有用特征伪装成看似无用且冗余的预测因子的能力。散点图描绘了两个假设特征之间的关系,每个特征具有大约-50 到 50 的值,并用于预测二元结果,即三角形与圆形。

图 13.2:特征 1 和 2 看似无用且冗余,但一起使用时具有预测价值
仅知道特征 1 或 2 的值几乎对预测目标的结果没有价值,因为圆圈和三角形在任一特征的任何值上几乎完全均匀分布。从定量角度来看,这体现在特征与结果之间非常弱的相关性。因此,一个仅检查一个特征与结果之间关系的简单特征选择算法可能会确定这两个特征对预测都没有用。此外,由于两个特征之间的相关性约为 0.90,一个同时考虑这对的更复杂的特征选择算法可能会由于看似冗余而意外地排除其中一个。
尽管这两个特征看似无用且冗余,但当它们一起使用时,散点图清晰地描绘了它们的预测能力:如果特征 2 大于特征 1,则预测三角形;否则,预测圆形。一个有用的特征选择方法应该能够识别这些类型的模式;否则,它可能会使学习算法排除重要的预测因子。然而,特征选择技术还需要考虑计算效率,因为除了最小的数据集外,检查每个潜在的特征组合都是不可行的。
平衡寻找有用、非冗余特征与特征可能仅与其他特征结合使用之间的可能性,是为什么没有一种适合所有情况的特性选择方法的部分原因。根据用例和选择的学习算法,可以应用不同的技术,这些技术对特征的搜索要么不那么严格,要么更彻底。
想要深入了解特性选择,请参阅《变量与特征选择导论》,2003 年,Guyon, I. 和 Elisseeff, A.,机器学习研究杂志,第 3 卷,第 1157-1182 页。
过滤方法
可能最易于理解的特性选择形式是过滤方法这一类别,它使用相对简单的评分函数来衡量每个特征的重要性。这些得分随后可以用来对特征进行排序并限制在预测模型中使用特征的数量。由于这种方法简单,过滤方法通常被用作数据探索、特征工程和模型构建迭代过程中的第一步。一开始可能应用一个粗略的过滤器来识别最有趣的候选特征进行深入探索和可视化,如果需要进一步减少特征数量,则稍后应用更严格的特征选择方法。
过滤方法的一个单一定义特征是使用特征重要性的代理度量。这种度量是代理的,因为它是我们真正关心的——特征预测能力——的替代品,但我们无法在没有首先构建预测模型的情况下知道这一点。相反,我们选择一个更简单的指标,我们希望它在稍后添加到模型中时能反映特征的效用。例如,在一个数值预测模型中,可能会计算每个特征与目标之间的双变量相关系数,并仅选择与目标高度相关的特征。对于二元或分类目标,类似的方法可能涉及构建单变量分类器,检查特征与目标之间强烈的双变量关系,或者使用信息增益等指标,这些在第五章 分而治之——使用决策树和规则进行分类中已有描述。这些简单特征选择指标的优点是,它们不太可能导致过拟合,因为代理度量使用不同的方法,并对数据做出了与学习算法不同的假设。
过滤方法最大的好处可能是它们即使对于具有大量特征的数据库也是可扩展的。这种效率源于过滤方法只为每个特征计算一个重要性分数,然后按这些分数从最重要到最不重要对预测因子进行排序。因此,随着特征数量的增加,计算成本相对较慢,并且与预测因子的数量成直接比例。请注意,这种方法的产品是一个按顺序排列的特征列表,而不是一个最佳特征集;因此,需要主观判断来确定重要特征和非重要特征之间的最佳截止点。
尽管过滤方法在计算上效率很高,但它们缺乏考虑特征组的能力,这意味着如果某些特征只有与其他特征结合时才有用,那么重要的预测因子可能会被排除。此外,过滤方法不太可能导致过拟合的事实也伴随着潜在的缺点,即它们可能不会产生最适合与所需学习算法一起工作的特征集。下一节中描述的特征选择方法牺牲了计算效率来解决这些关注点。
包装方法和嵌入式方法
与使用变量重要性的代理度量相比,包装方法使用机器学习算法本身来识别变量或变量子集的重要性。包装方法基于这样一个简单想法:随着更多重要特征被提供给算法,其执行学习任务的能力应该提高。换句话说,随着重要预测因子的包含或正确组合的包含,其错误率应该降低。
因此,通过迭代构建由不同特征组合组成的模型,并检查模型性能的变化,可以识别出重要的预测因子和预测因子集。通过系统地测试所有可能的特征组合,甚至可以识别出整体最佳的预测因子集。
然而,正如人们可能预料的,测试所有可能特征组合的过程在计算上极其低效。对于一个有 p 个预测器的数据集,有 2^p 个潜在的预测器组合需要被测试,这导致随着额外特征的添加,这种技术的计算成本相对迅速增长。例如,只有一个预测器的数据集就需要评估 2¹⁰ = 1,024 个不同的模型,而增加五个更多预测器的数据集则需要 2¹⁵ = 32,768 个模型,这超过了 30 倍的计算成本!显然,这种方法除了对于最小的数据集和最简单的机器学习算法之外是不可行的。解决这个问题的一个可能方法可能是首先使用过滤方法减少特征的数量,但这不仅可能导致错过重要的特征组合,而且这种维度的减少可能会抵消许多包装方法的好处。
而不是让它的低效性阻止我们利用其优点,我们可以使用启发式方法来避免搜索每个特征的组合。特别是,第五章中描述的“贪婪”方法,即分割与征服 – 使用决策树和规则进行分类,它有助于有效地增长树,也可以在这里使用。你可能还记得,贪婪算法的想法是按照先来先服务的原则使用数据,首先使用最具预测性的特征。尽管这种技术不能保证找到最优解,但它极大地减少了必须测试的组合数量。
对于将包装方法应用于贪婪特征选择,有两种基本方法。两者都涉及通过一次改变一个变量来探测学习算法。前向选择技术从将每个特征逐个输入模型开始,以确定哪个特征会产生最佳的单一预测器模型。前向选择的下一次迭代保留模型中的第一个最佳预测器,并测试剩余的特征以确定哪个特征能构成最佳的二元预测器模型。正如预期的那样,这个过程可以继续选择最佳的三个预测器模型、四个预测器模型,等等,直到所有特征都被选择。然而,由于特征选择的具体目的并不是选择整个特征集,因此前向选择过程在添加额外特征不再能提高模型性能超过特定阈值时提前停止。
类似的后向消除技术以相同的方式工作,但方向相反。从一个包含所有特征的模型开始,模型反复迭代,每次消除预测性最差的特征,直到消除一个特征使模型性能下降超过期望的阈值时停止。
被称为嵌入式方法的学习算法具有类似于前向选择的内置包装形式。这些方法在模型训练过程中自动选择最佳特征。你已经熟悉其中一种方法,即决策树,它使用贪婪前向选择来确定最佳特征子集。大多数机器学习技术没有嵌入式特征选择;必须在之前减少维度。下一节将演示如何通过第六章中引入的机器学习算法的变体在 R 中应用这些方法。
示例 - 使用逐步回归进行特征选择
包装方法的一个广为人知的实现是逐步回归,它使用前向或后向选择来识别回归模型的一组特征。为了演示这项技术,我们将回顾前两章中使用的泰坦尼克号乘客数据集,并构建一个逻辑回归模型,预测每位乘客是否在不幸的航行中幸存。首先,我们将使用 tidyverse 读取数据并应用一些简单的数据准备步骤。以下命令序列创建了一个Age的缺失值指示器,为缺失的Age值填充平均年龄,为缺失的Cabin和Embarked值填充X,并将Sex转换为因子:
> library(tidyverse)
> titanic_train <- read_csv("titanic_train.csv") |>
mutate(
Age_MVI = if_else(is.na(Age), 1, 0),
Age = if_else(is.na(Age), mean(Age, na.rm = TRUE), Age),
Cabin = if_else(is.na(Cabin), "X", Cabin),
Embarked = factor(if_else(is.na(Embarked), "X", Embarked)),
Sex = factor(Sex)
)
逐步过程需要知道特征选择的起始和结束条件,或者可以包含的最小和最大变量集。在我们的案例中,我们将定义最简单的模型,即不包含任何变量——仅包含一个常数截距项的模型。
要在 R 中定义此模型,我们将使用glm()函数,通过Survived ~ 1公式将生存率建模为常数截距的函数。将family参数设置为binomial定义了一个逻辑回归模型:
> simple_model <- glm(Survived ~ 1, family = binomial,
data = titanic_train)
完整模型仍然使用逻辑回归,但包括更多的预测变量:
> full_model <- glm(Survived ~ Age + Age_MVI + Embarked +
Sex + Pclass + SibSp + Fare,
family = binomial, data = titanic_train)
前向选择将从简单模型开始,并确定完整模型中哪些特征值得包含在最终模型中。基础 R 的stats包中的step()函数提供了这一功能;然而,由于其他包也有step()函数,指定stats::step()确保使用正确的函数。第一个函数参数提供起始模型,scope参数需要完整模型的formula(),方向设置为forward逐步回归:
> sw_forward <- stats::step(simple_model,
scope = formula(full_model),
direction = "forward")
此命令为逐步过程的每个迭代生成一组输出,但为了简洁起见,这里只包括第一个和最后一个迭代。
如果你正在从大量变量中进行选择,请在step()函数中将trace = 0设置为关闭每个迭代的输出。
在逐步过程的开始,它从一个简单的模型开始,使用Survived ~ 1公式,该公式仅使用常数截距项来建模生存率。因此,第一块输出显示了模型在开始时的质量和在评估了七个其他候选模型(每个模型添加了一个额外的预测因子)之后的模型质量。《无》行指的是此迭代开始时模型的质量以及它与七个其他候选模型的排名比较:
Start: AIC=1188.66
Survived ~ 1
Df Deviance AIC
+ Sex 1 917.8 921.8
+ Pclass 1 1084.4 1088.4
+ Fare 1 1117.6 1121.6
+ Embarked 3 1157.0 1165.0
+ Age_MVI 1 1178.9 1182.9
+ Age 1 1182.3 1186.3
<none> 1186.7 1188.7
+ SibSp 1 1185.5 1189.5
使用的质量度量,AIC,是模型相对于其他模型的相对质量度量。特别是,它指的是赤池信息量准则。虽然 AIC 的正式定义超出了本章的范围,但这个度量旨在平衡模型复杂度和模型拟合度。AIC 值越低越好。因此,包含Sex的模型在六个其他候选模型以及原始模型中都是最好的。在最终迭代中,基线模型使用Sex、Pclass、Age和SibSp,没有额外的特征可以进一步降低 AIC——《无》行被排在添加Embarked、Fare和Age_MVI特征的候选模型之上:
Step: AIC=800.84
Survived ~ Sex + Pclass + Age + SibSp
Df Deviance AIC
<none> 790.84 800.84
+ Embarked 3 785.27 801.27
+ Fare 1 789.65 801.65
+ Age_MVI 1 790.59 802.59
在这一点上,前向选择过程停止。我们可以获得最终模型的公式:
> formula(sw_forward)
Survived ~ Sex + Pclass + Age + SibSp
我们还可以获得最终模型的估计回归系数:
> sw_forward$coefficients
(Intercept) Sexmale Pclass Age SibSp
5.19197585 -2.73980616 -1.17239094 -0.03979317 -0.35778841
向后消除法甚至更容易执行。通过向模型提供要测试的完整特征集并设置direction = "backward",模型将迭代并系统地消除任何会导致更好的 AIC 的特征。例如,第一步从一组完整的预测因子开始,但消除Fare、Age_MVI或Embarked特征会导致 AIC 值降低:
> sw_backward <- stats::step(full_model, direction = "backward")
Start: AIC=803.49
Survived ~ Age + Age_MVI + Embarked + Sex + Pclass + SibSp +
Fare
Df Deviance AIC
- Fare 1 783.88 801.88
- Age_MVI 1 784.81 802.81
- Embarked 3 789.42 803.42
<none> 783.49 803.49
- SibSp 1 796.34 814.34
- Age 1 810.97 828.97
- Pclass 1 844.74 862.74
- Sex 1 1016.36 1034.36
在每次迭代中,最差的特征被消除,但到最后一步,消除任何剩余的特征都会导致更高的 AIC 值,因此会导致比基线模型质量更低的模型。因此,过程在这里停止:
Step: AIC=800.84
Survived ~ Age + Sex + Pclass + SibSp
Df Deviance AIC
<none> 790.84 800.84
- SibSp 1 805.33 813.33
- Age 1 819.32 827.32
- Pclass 1 901.80 909.80
- Sex 1 1044.10 1052.10
在这种情况下,前向选择和向后消除产生了相同的预测因子集,但这并不一定总是如此。如果某些特征在组内工作得更好,或者它们以某种方式相互关联,可能会出现差异。
如前所述,包装方法使用的启发式方法的缺点之一是它们不能保证找到单个最优化的一组预测因子;然而,正是这个缺点使得特征选择过程在计算上是可行的。
示例 - 使用 Boruta 进行特征选择
为了实现更稳健但计算量更大的特征选择方法,Boruta包在随机森林算法周围实现了一个包装器,这将在第十四章构建更好的学习者中介绍。现在,只需知道随机森林是决策树的一种变体,它提供变量重要性的度量。通过系统地测试变量的随机子集,并反复进行测试,可以使用统计假设检验技术确定一个特征是否比其他特征显著更重要或更不重要。
由于其高度依赖随机森林技术,这个技术与传说中的斯拉夫生物 Boruta 同名也就不足为奇了,Boruta 被认为居住在沼泽和森林中。要了解更多关于 Boruta 实现细节的信息,请参阅Kursa, M. B. 和 Rudnicki, W. R.,2010,统计软件杂志,第 36 卷,第 11 期,使用 Boruta 包进行特征选择。
Boruta技术使用所谓的“影子特征”这一巧妙技巧来确定变量是否重要。这些影子特征是数据集原始特征的副本,但它们的值被随机打乱,以至于任何特征与目标结果之间的关联都被打破。因此,这些影子特征按定义就是无意义的、无关紧要的,并且除了随机机会之外,不应为模型提供任何预测优势。它们作为其他特征判断的基准。
在将原始特征和影子特征通过随机森林建模过程处理后,每个原始特征的重要性与最重要的影子特征进行比较。那些显著优于影子特征的被认为是重要的;那些显著较差的被认为是无关紧要的,并永久移除。算法会反复迭代,直到所有特征都被认为是重要的或无关紧要的,或者达到预定的迭代次数限制。
为了看到这个方法在实际中的应用,让我们将Boruta算法应用于上一节中构建的同一泰坦尼克号训练数据集。为了证明算法可以检测到真正无用的特征,为了演示目的,我们可以在数据集中添加一个。首先,我们将随机种子设置为任意数12345,以确保你的结果与这里显示的结果相匹配。然后,我们将为 891 个训练示例中的每一个分配一个介于 1 到 100 之间的随机值。
由于数字是完全随机的,这个特征几乎肯定会被发现是无用的,除非是运气极好:
> set.seed(12345)
> titanic_train$rand_vals <- runif(n = 891, min = 1, max = 100)
接下来,我们将加载Boruta包并将其应用于泰坦尼克号数据集。语法与训练机器学习模型类似;在这里,我们使用公式接口指定模型,列出目标和预测变量:
> library(Boruta)
> titanic_boruta <- Boruta(Survived ~ PassengerId + Age +
Sex + Pclass + SibSp + random_vals,
data = titanic_train, doTrace = 1)
将 doTrace 参数设置为 1 以请求详细输出,这会在算法达到迭代过程中的关键点时产生状态更新。在这里,我们看到第 10 次迭代的输出,它显示 rand_vals 特征意外地被拒绝为不重要,同时确认了四个特征是重要的,一个特征仍然不确定:
After 10 iterations, +0.51 secs:
confirmed 4 attributes: Age, Pclass, Sex, SibSp;
rejected 1 attribute: rand_vals;
still have 1 attribute left.
算法完成后,键入对象的名称以查看结果:
> titanic_boruta
Boruta performed 99 iterations in 4.555043 secs.
4 attributes confirmed important: Age, Pclass, Sex, SibSp;
1 attributes confirmed unimportant: rand_vals;
1 tentative attributes left: PassengerId;
默认情况下,Boruta() 函数设置为 100 次运行的限制,它在迭代了大约 4.5 秒后的第 99 次迭代时达到了这个限制。在停止之前,发现四个特征是重要的,一个是不重要的。被列为试验性的 PassengerId 特征无法被确认是重要或不重要。将 maxRuns 参数设置为高于 100 的值可以帮助得出结论——在这种情况下,将 maxRuns = 500 后,经过 486 次迭代将确认 PassengerId 是不重要的。
也可以绘制特征相对于彼此的重要性:
> plot(titanic_boruta)
结果可视化显示在 图 13.3 中。对于每个六个特征,以及最大、平均(平均值)和最小表现阴影特征,箱线图显示了该特征的重要性指标分布。使用这些结果,我们可以确认 PassengerId 的相对重要性略低于最大阴影特征,而 rand_vals 的相对重要性则更低:

图 13.3:绘制 Boruta 输出显示了特征相对于彼此以及阴影特征的相对重要性
根据我们在 第十一章 中进行的泰坦尼克号数据集探索,在机器学习中取得成功,Sex 和 Pclass 特征的重要性之高并不令人惊讶。同样,我们不会期望 PassengerId 是重要的,除非这些 ID 以某种方式与泰坦尼克号的生存有关,而不是随机分配。话虽如此,尽管这个特征选择过程的结果没有揭示新的见解,但这项技术对于不易手工探索的数据集或特征的真实世界含义未知的情况会非常有帮助。当然,这只是处理大量不确定重要性的特征的一种方法;下一节将描述一种可能表现更好的替代方法,特别是如果许多特征是相关的话。
Boruta技术可能非常计算密集,在现实世界的数据集中,它通常需要几分钟甚至几小时才能完成,而不是像泰坦尼克号数据那样只需要几秒钟。该软件包的作者估计,在现代计算机上,它需要大约每小时处理一百万个特征-示例组合。例如,具有 10,000 行和 50 个特征的数据库将需要大约半小时来完成。将此数据集的大小增加到 100,000 行将需要大约五小时的处理时间!
执行特征提取
特征选择不是唯一可用的方法来降低高维数据集的维度。另一种可能性是合成更少数量的组合预测指标。这是特征提取的目标,这是一种降维技术,它创建新的特征而不是选择现有特征的一个子集。提取的特征被构建成减少冗余信息的同时尽可能保留尽可能多的有用信息。当然,在过多和过少信息之间找到理想的平衡本身就是一项挑战。
理解主成分分析
要开始理解特征提取,首先想象一个具有非常大量特征的数据集。例如,为了预测可能违约的申请人,一个数据集可能包括数百个申请人属性。显然,一些特征将是预测目标结果的有力指标,但很可能许多特征也是相互预测的。例如,一个人的年龄、教育水平、收入、邮政编码和职业都是他们偿还贷款可能性的预测指标,但它们也在不同程度上相互预测。它们的相关性表明它们之间存在一定程度的重叠或联合依赖,这反映在它们的协方差和相关性中。
这些贷款申请人的五个属性之所以相关,可能是因为它们是更少数量的真正、潜在驱动贷款支付行为的属性组成部分。特别是,我们可能认为贷款支付的可能性基于申请人的责任感和富裕程度,但由于这些概念难以直接衡量,我们转而使用多个易于获取的代理指标。以下图示说明了这五个特征如何可能捕捉到两个隐藏维度的方面。请注意,没有任何一个特征完全捕捉到任何一个组成部分维度,相反,每个组成部分维度是几个特征的组合。
例如,一个人的责任感可能通过他们的年龄和教育水平来体现,而他们的富裕程度可能反映在他们的收入、职业和邮政编码上。

图 13.4:贷款申请人的五个假设属性可能更简单地用每个属性协方差组合而成的两个维度来表示
主成分分析(PCA)的目标是从更多的特征中提取出更少的潜在维度,通过将多个相关属性的协方差表示为一个单一向量来实现。简单来说,协方差指的是属性共同变化的程度。当一个属性上升或下降时,另一个属性往往也会上升或下降。这些结果向量被称为主成分,它们是原始属性的加权组合。当应用于具有许多相关特征的数据库时,更少的主成分可能能够表达高维数据集的大部分总方差。尽管这似乎有很多技术术语,并且实现 PCA 所需的数学知识超出了本书的范围,但我们仍将努力理解这个过程的概念。
主成分分析与另一种称为因子分析的技术密切相关,这是一种更正式的方法,用于探索观察到的和未观察到的(潜在)因素之间的关系,如图中所示。在实践中,两者可以类似地应用,但 PCA 更简单,避免了构建正式模型;它仅仅减少了维度数量,同时保留了最大变异。对于更深入的了解许多细微的区别,请参阅以下 Stack Exchange 线程:stats.stackexchange.com/questions/1576/what-are-the-differences-between-factor-analysis-and-principal-component-analysi/。
回顾图 13.4,每个圆圈都旨在表示五个特征之间的关系。重叠程度更大的圆圈代表可能测量相似潜在概念的关联特征。请注意,这是一个高度简化的表示,它并没有描绘出用于计算特征之间相关性的个别数据点。实际上,这些个别数据点将代表个别贷款申请人,并将位于一个五维空间中,其坐标由每个申请人的五个特征值确定。当然,这在本书页面的二维空间中很难描绘,因此在这个简化表示中的圆圈应被视为具有高属性值的类似云状的人群。在这种情况下,如果两个特征高度相关,例如收入和教育,两个云团将重叠,因为具有一个属性高值的人往往也会具有另一个属性的高值。图 13.5展示了这种关系:

图 13.5:当两个特征高度相关时,一个特征值高的点往往另一个特征值也高
当检查图 13.5时,请注意,代表收入和教育之间关系的对角箭头反映了这两个特征之间的协方差。知道一个点是否更靠近箭头的起点或终点将提供对收入和教育的良好估计。高度相关的特征因此可能表达相似的潜在属性,因此可能是冗余的。通过这种方式,两个维度(收入和教育)所表达的信息可以在一个维度上更简单地表达,这将成为这两个特征的主成分。
将这种关系应用于三维图,我们可能会想象这个主成分如下图的z维度:

图 13.6:三个维度中具有不同协方差程度的五个属性
与二维情况一样,圆的位置旨在表示特征之间的协方差;圆的大小旨在表示深度,较大的或较小的圆更靠近空间的前部或后部。在这里的三维空间中,年龄和教育在一个维度上很接近,职业和邮编在另一个维度上很接近,而收入在第三个维度上变化。
如果我们希望捕捉大部分的方差,同时将维度从三维减少到二维,我们可能会如下将这个三维图投影到二维图上:

图 13.7:主成分分析将许多维度减少到更少的几个关键成分
通过这两个维度,我们已经构建了数据集的两个主成分,并且在这个过程中,我们将数据集的维度从具有现实意义的五个维度减少到没有内在现实联系的二维,x和y。相反,这两个生成的维度现在反映了潜在数据点的线性组合;它们是潜在数据的有用总结,但不易解释。
我们可以通过将数据集投影到一条线上来进一步降低维度,从而创建一个单一的主成分,如图图 13.8所示:

图 13.8:第一个主成分捕捉了具有最大方差的空间维度
在这个例子中,PCA 方法从数据集的五个原始维度中提取了一个混合特征。年龄和教育被视为有些冗余,职业和收入也是如此。
此外,年龄和教育对新特征的影响与邮编相反——它们在相反方向上拉扯x的值。如果这种一维表示丢失了原始五个特征中存储的太多信息,可以使用之前提到的两个或三个成分的方法。与机器学习中的许多技术一样,存在过度拟合和欠拟合数据之间的平衡。我们将在接下来的实际例子中看到这一点。
在应用 PCA 之前,重要的是要知道主成分是由确定性算法确定的,这意味着每次在给定数据集上完成该过程时,解决方案都是一致的。每个成分向量也始终与所有先前成分向量正交,或垂直。第一个主成分捕获最大方差维度,下一个捕获下一个最大的,依此类推,直到为原始数据集中的每个特征构建了主成分,或者当达到所需成分数量时,算法提前停止。
示例 - 使用 PCA 降低高度维度的社交媒体数据
如前所述,PCA 是一种特征提取技术,通过从完整特征集中合成一组较小的特征来降低数据集的维度。我们将首先将此技术应用于第九章中描述的社交媒体数据,即寻找数据组 - 使用 k-means 进行聚类。你可能还记得,这个数据集包括 36 个不同单词的计数,这些单词出现在美国 3 万名青少年的社交媒体页面上。这些单词反映了各种兴趣和活动,如体育、音乐、宗教和购物,尽管 36 对于大多数机器学习算法来说不是一个不合理的数字,如果我们有更多——可能是数百个特征——一些算法可能会开始受到维度诅咒的影响。
我们将使用 tidyverse 函数集来读取和准备数据。首先,我们将加载包,并使用其read_csv()函数将社交媒体数据读取为 tibble:
> library(tidyverse)
> sns_data <- read_csv("snsdata.csv")
接下来,我们将select()仅选择记录每个社交媒体个人资料中 36 个单词使用次数的特征列。这里的表示法从名为basketball的列选择到名为drugs的列,并将结果保存在一个新的 tibble 中,称为sns_terms:
> sns_terms <- sns_data |> select(basketball:drugs)
PCA 技术仅适用于数值数据的矩阵。然而,由于每个生成的 36 列都是一个计数,因此不需要更多的数据准备。如果数据集包含分类特征,在继续之前需要将这些特征转换为数值。
基础 R 包括一个名为prcomp()的内置 PCA 函数,随着数据集的增大,其运行速度会变慢。我们将使用 Bryan W. Lewis 提供的irlba包中的替代品,该替代品可以在早期停止以仅返回完整集合的潜在主成分子集。这种截断方法,加上使用通常更高效的算法,使得irlba_prcomp()函数在大型数据集上比prcomp()函数运行得更快,同时语法和兼容性几乎与基础函数相同,以防您正在跟随较旧的在线教程。
irlba包的奇特名称来源于它所使用的技巧:“隐式重启兰索斯双对角化算法”,由 Jim Baglama 和 Lothar Reichel 开发。有关此方法的更多信息,请参阅使用vignette("irlba")命令的包示例文档。
在开始之前,我们将随机种子设置为任意的2023,以确保您的结果与书中的一致。然后,在加载所需的包之后,我们将sns_terms数据集通过管道传递到 PCA 函数。这三个参数允许我们将结果限制在前 10 个主成分,同时通过将每个特征围绕零中心并缩放到方差为一来标准化数据。这通常与 k-最近邻方法中的原因相同:防止具有较大方差的特征主导主成分。结果保存为名为sns_pca的对象:
> set.seed(2023)
> library(irlba)
> sns_pca <- sns_terms |>
prcomp_irlba(n = 10, center = TRUE, scale = TRUE)
虽然 PCA 是一个确定性算法,但符号——正或负——是任意的,并且可能因运行而异,因此需要事先设置随机种子以保证可重复性。有关此现象的更多信息,请参阅 Stack Exchange 线程:stats.stackexchange.com/questions/88880/
请记住,PCA 中的每个成分都捕捉到数据集方差的一个递减部分,而我们请求了 36 个可能成分中的 10 个。碎石图,以悬崖底部形成的“碎石”滑坡模式命名,有助于可视化每个成分捕捉到的方差量,从而有助于确定使用最佳成分数量。R 的内置screeplot()函数可以应用于我们的结果以创建此类图表。这四个参数提供我们的 PCA 结果,表明我们想要绘制所有 10 个成分,使用线图而不是条形图,并应用图表标题:
> screeplot(sns_pca, npcs = 10, type = "lines",
main = "Scree Plot of SNS Data Principal Components")
最终生成的图表如下所示:

图 13.9:展示社交媒体数据集前 10 个主成分方差的碎石图
前置图显示,第一和第二成分之间的方差捕获量有显著下降。第二到第五成分捕获了大约相同数量的方差,然后在第五和第六成分之间以及第六和第七成分之间有额外的显著下降。第七到第十成分捕获了大约相同数量的方差。基于这个结果,我们可能会决定使用一个、五个或六个主成分作为我们的降维数据集。我们可以通过将summary()函数应用于我们的 PCA 结果对象来数值化地看到这一点:
> summary(sns_pca)
Importance of components:
PC1 PC2 PC3 PC4 PC5
Standard deviation 1.82375 1.30885 1.27008 1.22642 1.20854
Proportion of Variance 0.09239 0.04759 0.04481 0.04178 0.04057
Cumulative Proportion 0.09239 0.13998 0.18478 0.22657 0.26714
PC6 PC7 PC8 PC9 PC10
Standard deviation 1.11506 1.04948 1.03828 1.02163 1.01638
Proportion of Variance 0.03454 0.03059 0.02995 0.02899 0.02869
Cumulative Proportion 0.30167 0.33227 0.36221 0.39121 0.41990
输出显示了每个 10 个成分(标记为PC1到PC10)的标准差、总方差的占比和累积方差占比。因为标准差是方差的平方根,所以标准差的平方产生的是散点图上显示的方差值;例如,1.8237**5² = 3.326064,这是散点图中第一个成分显示的值。一个成分的方差占比是其方差除以所有成分的总方差——不仅包括这里显示的 10 个,还包括我们可能创建的剩余 26 个。因此,累积方差占比达到 41.99%,而不是由所有 36 个成分解释的 100%。
使用 PCA 作为降维技术需要用户确定要保留多少个成分。在这种情况下,如果我们选择五个成分,我们将捕获 26.7%的方差,即原始数据中总信息的四分之一。这是否足够取决于剩余的 73.3%方差中有多少是信号或噪声——这只能通过尝试构建一个有用的学习算法来确定。使这个过程更容易的一个因素是,无论我们最终决定保留多少个成分,我们的 PCA 过程都是完整的;我们可以根据需要简单地使用 10 个成分中的任意数量。例如,没有必要重新运行算法来获得最佳三个与最佳七个成分;找到前七个成分自然会包括最佳三个,结果将是相同的。在 PCA 的实际应用中,测试几个不同的切割点可能是明智的。
为了简单起见,我们将原始的 36 维数据集减少到五个主成分。默认情况下,irlba_prcomp()函数会自动保存一个版本,该版本已将原始数据集转换到低维空间。这可以在结果sns_pca列表对象中的x名称下找到,我们可以使用str()命令来检查它:
> str(sns_pca$x)
num [1:30000, 1:10] 1.448 -3.492 0.646 1.041 -4.322 ...
- attr(*, "dimnames")=List of 2
..$ : NULL
..$ : chr [1:10] "PC1" "PC2" "PC3" "PC4" ...
转换后的数据集是一个具有 30,000 行与原始数据集相同的数值矩阵,但列数从 36 列减少到 10 列,列名从PC1到PC10。我们可以通过使用head()命令输出查看前几行来更清楚地看到这一点:
> head(sns_pca$x)
PC1 PC2 PC3 PC4 PC5
[1,] -1.4477620 0.07976310 0.3357330 -0.3636082 0.03833596
[2,] 3.4922144 0.36554520 0.7966735 -0.1871626 0.57126163
[3,] -0.6459385 -0.67798166 0.8000251 0.6243070 0.25122261
[4,] -1.0405145 0.08118501 0.4099638 -0.2555128 -0.02620989
[5,] 4.3216304 -1.01754361 3.4112730 -1.9209916 -0.43409869
[6,] 0.2131225 -0.65882053 1.6215828 0.9372545 1.47217369
PC6 PC7 PC8 PC9 PC10
[1,] -0.01559079 0.007278589 -0.004582346 0.19226144 0.08086065
[2,] 3.02758235 -0.306304037 -1.142422251 0.72992534 0.11203923
[3,] -0.40751994 0.454614417 0.704544996 -0.43734980 -0.07735574
[4,] 0.27837411 0.462898314 -0.175251793 -0.08843005 0.26784326
[5,] -1.11734548 -2.122420077 -2.287638056 2.19992650 -0.26536161
[6,] 0.04614790 -0.654207687 0.285263646 0.69439745 -0.89649127
回想一下,在原始数据集中,36 列中的每一列都表示特定单词在社交媒体个人资料文本中出现的次数。如果我们标准化数据,使其均值为零,就像我们在第九章中做的那样,在寻找数据组 – 使用 k-means 进行聚类中,以及在这里对主成分所做的,那么正负值分别表示高于或低于平均值的个人资料。技巧是,36 个原始列中的每一个都有一个明显的解释,而 PCA 的结果则没有明显的意义。
我们可以通过可视化 PCA 负载来尝试理解这些成分,即转换原始数据为每个主成分的权重。较大的负载对特定成分更重要。这些负载可以在名为rotation的sns_pca列表对象中找到。
这是一个 36 行 10 列的数值矩阵,对应于数据集中原始的每个列,以及提供主成分负载的 10 列。为了构建我们的可视化,我们需要将此数据旋转,使得每个社交媒体术语和每个主成分对应一行;也就是说,在数据集的长版本中,我们将有36 * 10 = 360行。
以下命令使用两个步骤来创建所需的长数据集。第一步创建一个包含SNS_Term列的 tibble,每个术语一行,以及sns_pca$rotation矩阵,该矩阵通过as_tibble()转换为 tibble。合并后的 tibble,有 11 列和 36 行,被管道输入到pivot_longer()函数中,该函数将表格从宽格式转换为长格式。三个参数告诉函数将PC1到PC10的 10 列进行旋转,原来的列名现在成为名为PC的列的行,原来的列值现在成为名为Contribution的列的行值。完整的命令创建了一个有 3 列和 360 行的 tibble:
> sns_pca_long <- tibble(SNS_Term = colnames(sns_terms),
as_tibble(sns_pca$rotation)) |>
pivot_longer(PC1:PC10, names_to = "PC", values_to = "Contribution")
现在可以使用ggplot()函数来绘制给定主成分的最重要贡献术语。例如,要查看第三个主成分,我们将filter()行限制为仅PC3,选择最大的 15 个贡献值——使用abs()绝对值函数考虑正负值——并将SNS_Term修改为按贡献量重新排序。最终,这将通过一系列格式调整被管道输入到ggplot()中:
> sns_pca_long |>
filter(PC == "PC3") |>
top_n(15, abs(Contribution)) |>
mutate(SNS_Term = reorder(SNS_Term, Contribution)) |>
ggplot(aes(SNS_Term, Contribution, fill = SNS_Term)) +
geom_col(show.legend = FALSE, alpha = 0.8) +
theme(axis.text.x = element_text(angle = 90, hjust = 1,
vjust = 0.5), axis.ticks.x = element_blank()) +
labs(x = "Social Media Term",
y = "Relative Importance to Principal Component",
title = "Top 15 Contributors to PC3")
下面的图表显示了结果。因为具有正面和负面影响的术语似乎跨越了与性别、毒品和摇滚乐相关的主题,有人可能会认为这个主成分已经识别了青少年身份的典型维度:

图 13.10:对 PC3 贡献最大的前 15 个术语
通过重复上述ggplot代码来处理前五个主成分中的其他四个,我们观察到类似的区分,如下面的图所示:

图 13.11:对其他四个主成分贡献最大的前 15 个词汇
PC1特别有趣,因为每个项都有积极的影响;这可能是区分那些在社交媒体资料上有所作为与一无所有的人。PC2似乎偏好与购物相关的词汇,而PC4似乎结合了音乐和体育,没有性与毒品。最后,似乎PC5可能是在区分体育与非体育相关的词汇。以这种方式查看图表将有助于理解每个组成部分对预测模型的影响。
之前可视化方法是从 Julia Silge 的杰出教程中改编的,她是《使用 R 进行文本挖掘:整洁方法》(2017 年)的作者。要深入了解 PCA,请参阅juliasilge.com/blog/stack-overflow-pca/。
如果这项技术对构建机器学习模型没有帮助,那么对主成分分析的理解就几乎没有价值。在先前的例子中,我们将社交媒体数据集的维度从 36 降低到 10 个或更少的成分。通过将这些成分合并回原始数据集,我们可以使用它们来预测个人资料的性别或朋友数量。我们将首先使用cbind()函数将原始数据框的前四列与 PCA 结果中的转换后的个人资料数据相结合:
> sns_data_pca <- cbind(sns_data[1:4], sns_pca$x)
接下来,我们将构建一个线性回归模型,预测社交媒体朋友的数量作为前五个主成分的函数。这种建模方法在第六章,预测数值数据 – 回归方法中介绍。结果输出如下:
> m <- lm(friends ~ PC1 + PC2 + PC3 + PC4 + PC5, data = sns_data_pca)
> m
Call:
lm(formula = friends ~ PC1 + PC2 + PC3 + PC4 + PC5, data = sns_data_pca)
Coefficients:
(Intercept) PC1 PC2 PC3 PC4
30.1795 1.9857 0.9748 -2.5230 1.1160
PC5
0.8780
由于截距的值大约为 30.18,因此在这个数据集中,平均每人有大约 30 个朋友。预期PC1、PC2、PC4和PC5的值较高的个人会有更多的朋友,而PC3的值较高则与较少的朋友相关,假设其他条件相同。例如,对于PC2每增加一个单位,我们预计平均会增加一个朋友。鉴于我们对这些成分的理解,这些发现是有意义的;PC2、PC3和PC5的正值与更多的社交活动相关。相比之下,PC3涉及性与毒品,摇滚乐,这可能有些反社会。
尽管这是一个非常简单的例子,但 PCA 可以以相同的方式应用于更大的数据集。除了减轻维度诅咒之外,它还有减少复杂性的好处。例如,具有大量预测器的数据集可能对 k-最近邻或人工神经网络来说计算成本过高,但通过选择更少的成分,这些技术可能变得可行。
随意尝试 PCA,并将此类特征提取与其他特征选择方法(如过滤器和方法包装器)进行对比;你可能发现其中一个方法比另一个方法更有效。即使你选择不使用降维,高度维度的数据也存在其他问题,你将在下一节中发现这些问题。
利用稀疏数据
随着数据集维度的增加,一些属性很可能是稀疏的,这意味着大多数观测值不共享属性的值。这是维度诅咒的自然结果,其中这种不断增长的细节将观测值转化为由其独特的属性组合所识别的异常值。稀疏数据具有任何特定值或甚至任何值的情况非常罕见——正如在第四章,概率学习——使用朴素贝叶斯进行分类中发现的文本数据的稀疏矩阵,以及第八章,寻找模式——使用关联规则进行市场篮子分析中的购物车数据的稀疏矩阵所示。
这与缺失数据不同,在缺失数据中,通常只有相对较小的一部分值是未知的。在稀疏数据中,大多数值是已知的,但有趣且有意义的值的数量被大量添加到学习任务中几乎没有价值的值所淹没。在缺失数据的情况下,机器学习算法难以从无中学习;在稀疏数据的情况下,机器学习算法难以在稻草堆中找到针。
识别稀疏数据
稀疏数据可以以几种相互关联的形式出现。最常遇到的形式是分类,其中单个特征具有非常多的级别或类别,其中一些相对于其他类别具有极小的计数。这样的特征被称为具有高基数,当输入到学习算法中时会导致稀疏数据问题。例如,邮政编码;在美国,有超过 40,000 个邮政编码,其中一些有超过 100,000 居民,而另一些则少于 100。因此,如果将稀疏的邮政编码特征包含在一个建模项目中,学习算法可能难以在忽略和过度强调居民数量较少的区域之间找到平衡。
具有多个级别的分类特征通常表示为一系列二进制特征,每个级别一个特征。我们在手动构建二元虚拟变量时多次使用过这样的特征,许多学习算法也会自动对分类数据进行同样的处理。这可能导致二进制特征变得稀疏,因为 1 值被 0 值所淹没。
例如,在一个针对美国人口的邮编数据集中,330 百万居民中的极小一部分会落入 40,000 个邮政编码中的每一个,这使得每个二进制邮编特征高度稀疏,难以被学习算法使用。
许多所谓的“大数据”形式本质上具有高度的多维性和稀疏性。稀疏性与维度灾难密切相关。就像不断扩张的宇宙在物体之间创造了更大的空隙一样,有人可能会认为,随着维度的增加,每个数据集都会变得稀疏。文本数据通常很稀疏,因为每个单词都可以被视为一个维度,而且有无数个单词可能出现,每个单词在特定文档中出现的概率都很低。其他大数据形式,如 DNA 数据、交易市场篮子数据和图像数据,也常常表现出稀疏性问题。除非增加数据集的密度,否则许多学习算法将难以充分利用丰富的、大数据集。
即使是简单的数值范围也可能很稀疏。这种情况发生在数值分布范围很广时,导致分布的一些范围具有非常低的密度。收入就是一个例子,因为数值通常随着收入的增加而变得越来越稀疏。这与异常值问题密切相关,但在这里我们明确希望对异常值进行建模。当数值以过度的精度存储时,也会发现稀疏的数值数据。例如,如果年龄值以小数而不是整数存储,例如 24.9167 和 36.4167 而不是简单的 24 和 36,这会在数值之间产生一些学习算法可能难以忽略的隐含空隙。例如,一个决策树可能会区分 24.92 岁和 24.90 岁的人——这可能与过度拟合比在现实世界中的有意义区分更有可能。
手动减少数据集的稀疏性可以帮助学习算法识别重要的信号并忽略噪声。所使用的方法取决于稀疏数据的类型和程度以及所使用的建模算法。某些算法在处理某些类型的稀疏数据方面比其他算法更好。例如,朴素贝叶斯在处理稀疏分类数据时表现相对较好,回归方法在处理稀疏数值数据时表现相对较好,而决策树由于偏好具有更多类别的特征,通常在处理稀疏数据时遇到困难。更复杂的方法,如深度神经网络和提升,可能会有所帮助,但一般来说,在学习过程之前使数据集变得更密集会更好。
示例 - 重新映射稀疏分类数据
正如我们在前面的章节中看到的,当将一个分类特征添加到数据集中时,它通常会被转换为一组与原始特征级别数量相等的二元变量,使用虚拟变量或独热编码。
例如,如果美国有 40,000 个邮政编码,机器学习算法将为此特征提供 40,000 个二元预测因子。这被称为one-of-n 映射,因为在这 40,000 个特征中只有一个会有值为 1,其余的都会是 0——这是一个维度和稀疏性极端增长的情况。
为了增加 one-of-n 映射的密度,可以使用m-of-n 映射,这会将n个二元变量减少到一组较小的m个变量。例如,对于邮政编码,而不是创建一个 40,000 级的特征,每个邮政编码一个级别,可以选择使用邮政编码的前两位数字(从 00 到 99)映射到 100 个级别。同样,如果包含世界上每个国家的二元特征会导致过多的稀疏性,可能可以将国家映射到一个更小的洲集合,如欧洲、北美洲和亚洲。
在创建一个 m-of-n 映射时,如果分组代表一个共享的潜在特征,那将是最理想的,但也可以使用其他方法。领域知识对于创建反映更细粒度单元共享特征的重新映射是有帮助的。在没有领域专业知识的情况下,以下方法可能适用:
-
保持较大的类别不变,仅对观察数量较少的类别进行分组。例如,密集城市地区的邮政编码可以直接包括在内,但稀疏的农村邮政编码可以组合成更大的地理区域。
-
通过创建一个双向交叉表或计算按级别和组级别平均结果来检查类别对目标变量的影响。例如,如果某些邮政编码更有可能违约,可以创建一个由这些邮政编码组成的新类别。
-
作为先前方法的更复杂变体,也可能构建一个简单的机器学习模型,使用高维特征来预测目标,然后根据特征级别与目标或其他预测器的相似关系进行分组。回归和决策树等简单方法非常适合这种方法。
一旦选择了重新映射策略,可以在forcats包中找到有用的重新编码分类变量的函数(forcats.tidyverse.org),它是 tidyverse 基础包集的一部分。该包包括自动重新编码具有稀疏级别的分类变量的选项,或者如果需要更引导的方法,则手动重新编码。有关该包的详细信息,请参阅R for Data Science章节,网址为r4ds.hadley.nz/factors.html。
我们将使用泰坦尼克号数据集和在第十二章“高级数据准备”中创建的乘客头衔来检查几种重新映射的方法。由于forcats包包含在基础 tidyverse 中,因此可以使用整个套件或单独使用library(forcats)命令来加载。我们将首先加载 tidyverse,读取泰坦尼克号数据集,然后检查头衔特征的级别:
> library(tidyverse)
> titanic_train <- read_csv("titanic_train.csv") |>
mutate(Title = str_extract(Name, ", [A-z]+\\.")) |>
mutate(Title = str_replace_all(Title, "[, \\.]", ""))
> table(titanic_train$Title, useNA = "ifany")
Capt Col Don Dr Jonkheer Lady Major
1 2 1 7 1 1 2
Master Miss Mlle Mme Mr Mrs Ms
40 182 2 1 517 125 1
Rev Sir <NA>
6 1 1
在上一章中,我们使用了基础 R 的recode()函数将“Miss”的变体,如“Ms”、“Mlle”和“Mme”,合并到一个单独的组中。forcats包包括一个fct_collapse()函数,它对于具有大量级别的分类特征来说更方便使用。我们将在这里使用它来创建基于对头衔现实意义了解的 m-of-n 映射。请注意,新类别中的几个是先前类别的单一映射,但通过包含一个标签向量,我们可以将几个旧级别映射到单个新级别,如下所示:
> titanic_train <- titanic_train |>
mutate(TitleGroup = fct_collapse(Title,
Mr = "Mr",
Mrs = "Mrs",
Master = "Master",
Miss = c("Miss", "Mlle", "Mme", "Ms"),
Noble = c("Don", "Sir", "Jonkheer", "Lady"),
Military = c("Capt", "Col", "Major"),
Doctor = "Dr",
Clergy = "Rev",
other_level = "Other")
) |>
mutate(TitleGroup = fct_na_value_to_level(TitleGroup,
level = "Unknown"))
检查新的分类,我们发现 17 个原始类别已减少到 9 个:
> table(titanic_train$TitleGroup)
Military Noble Doctor Master Miss Mr Mrs
5 4 7 40 186 517 125
Clergy Unknown
6 1
如果我们有一个包含许多级别的更大集合,或者在没有关于类别应该如何分组的知识的情况下,我们可以保留大型类别不变,并将具有少量示例的级别分组。forcats包包括一个用于检查我们特征级别的简单函数。尽管这也可以使用基础 R 函数完成,但fct_count()函数提供了一个按特征级别及其在总体总数中的比例排序的列表:
> fct_count(titanic_train$Title, sort = TRUE, prop = TRUE)
# A tibble: 17 × 3
f n p
<fct> <int> <dbl>
1 Mr 517 0.580
2 Miss 182 0.204
3 Mrs 125 0.140
4 Master 40 0.0449
5 Dr 7 0.00786
6 Rev 6 0.00673
7 Col 2 0.00224
8 Major 2 0.00224
9 Mlle 2 0.00224
10 Capt 1 0.00112
11 Don 1 0.00112
12 Jonkheer 1 0.00112
13 Lady 1 0.00112
14 Mme 1 0.00112
15 Ms 1 0.00112
16 Sir 1 0.00112
17 NA 1 0.00112
此输出可以基于最小观测数或最小比例来告知分组。forcats包有一组fct_lump()函数来帮助将因子级别“合并”到“其他”组的过程。例如,我们可能将前三个级别视为其他:
> table(fct_lump_n(titanic_train$Title, n = 3))
Miss Mr Mrs Other
182 517 125 66
或者,我们可以将所有观测数少于百分之一的级别合并在一起:
> table(fct_lump_prop(titanic_train$Title, prop = 0.01))
Master Miss Mr Mrs Other
40 182 517 125 26
最后,我们可能选择将所有低于五个观察值的级别合并在一起:
> table(fct_lump_min(titanic_train$Title, min = 5))
Dr Master Miss Mr Mrs Rev Other
7 40 182 517 125 6 13
选择使用这三个函数中的哪一个,以及适当的参数值,将取决于所使用的数据集和所需的 m-of-n 映射的级别数。
示例 - 分箱稀疏数值数据
虽然许多机器学习方法可以轻松处理数值数据,但一些方法(如决策树)更可能难以处理数值数据,尤其是在数据表现出稀疏性特征时。解决这个问题的常见方法被称为离散化,它将数值范围转换为更少的离散类别,称为箱。我们在第四章,概率学习 - 使用朴素贝叶斯进行分类中遇到了这种方法,当时我们离散化数值数据以使用朴素贝叶斯算法。在这里,我们将采用类似的方法,使用现代 tidyverse 方法,以降低数值范围的维度,帮助解决某些方法对稀疏数值数据过度拟合或欠拟合的倾向。
就像许多机器学习方法一样,理想情况下,人们会应用专业知识来确定将数值范围离散化的截断点。例如,在年龄值的范围内,可能有意义的中断点可能出现在已确立的儿童、成年和老年年龄组之间,以反映这些年龄值在现实世界中的影响。同样,可以创建用于薪资水平(如低收入、中收入和高收入)的箱。
在没有现实世界中重要类别的知识的情况下,通常建议使用反映数据自然百分位数或值直观增量截断点。这可能意味着使用以下策略之一来划分数值范围:
-
基于包含相等比例示例(33%、25%、20%、10%或 1%)的三分位数、四分位数、五分位数、十分位数或百分位数创建组。
-
使用底层数值范围的熟悉截断点,例如按小时、半小时或四分之一小时分组时间值;按五、十或二十五个分组 0-100 的刻度值;或将大数值范围(如收入)按 10 或 25 的较大倍数进行分桶。
-
将对数缩放的概念应用于偏斜数据,使得偏斜数据部分的箱按比例更宽,其中值更稀疏;例如,收入可能被分入 0-10,000、10,000-100,000、100,000-1,000,000 和 1,000,000 或以上的组。
为了说明这些方法,我们将对之前使用的泰坦尼克号数据集中的票价值应用离散化技术。head()和summary()函数表明,这些值在高端非常细粒度且非常稀疏,因为它们有严重的右偏斜:
> head(titanic_train$Fare)
[1] 7.2500 71.2833 7.9250 53.1000 8.0500 8.4583
> summary(titanic_train$Fare)
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.00 7.91 14.45 32.20 31.00 512.33
假设我们最感兴趣的是头等舱乘客与其他乘客之间的差异,并且我们假设前 25% 的票价反映了头等舱票价。我们可以轻松地使用 tidyverse 的 if_else() 函数创建一个二元特征,如下所示。如果票价至少为 £31,即第三四分位数,则我们假设它是头等舱票价,并将 1 的值分配给二元编码的 fare_firstclass 特征;如果不是,它将获得 0 的值。missing 参数告诉函数,如果票价缺失,则假设头等舱票价几乎不可能未知,并将值分配为 0:
> titanic_train <- titanic_train |> mutate(
fare_firstclass = if_else(Fare >= 31, 1, 0, missing = 0)
)
这将一个具有近 250 个不同值的特征减少到只有一个新特征:
> table(titanic_train$fare_firstclass)
0 1
666 225
虽然这是一个非常简单的例子,但它是我们向更复杂的分箱策略迈出的第一步。虽然在这里 if_else() 函数很简单,但用于创建具有两个以上级别的新的特征会变得难以管理。这需要将 if_else() 函数嵌套使用,这很快就会变得难以维护。相反,tidyverse 中的一个函数 case_when() 允许构建一个更复杂的检查系列来确定结果。
在下面的代码中,票价数据被分箱到三个级别,大致对应于头等、二等和三等舱票价水平。case_when() 语句被评估为一系列有序的 if-else 语句。第一条语句检查票价是否至少为 31,并将这些示例分配到头等舱类别。第二条可以读作 else-if 语句;也就是说,如果第一条语句不成立——“else”——我们检查“if”票价是否至少为 15,如果为真则分配二等舱级别。最后一条语句是最终的“else”,因为 TRUE 总是评估为真,因此所有未被前两条语句分类的记录都被分配到三等舱级别:
> titanic_train <- titanic_train |>
mutate(
fare_class = case_when(
Fare >= 31 ~ "1st Class",
Fare >= 15 ~ "2nd Class",
TRUE ~ "3rd Class"
)
)
生成的特征有三个级别,正如预期的那样:
> table(titanic_train$fare_class)
1st Class 2nd Class 3rd Class
225 209 457
在我们对于票价的真实世界含义一无所知的情况下,例如对头等、二等和三等舱票价的了解,我们可能反而会应用之前描述的离散化启发式方法,该方法使用自然百分位数或直观的值切分点,而不是有意义的组。
cut() 函数包含在基础 R 中,并提供了一种从数值向量创建因子的简单方法。breaks 参数指定了数值范围的切分点,以下是一个三级的因子示例,与之前的离散化相匹配。right = FALSE 参数表示级别不应包括最右侧或最高值,而 Inf 切分点表示最终类别可以跨越从 31 到无穷大的值范围。生成的类别与先前结果相同,但使用不同的标签:
> table(cut(titanic_train$Fare, breaks = c(0, 15, 31, Inf),
right = FALSE))
0,15) [15,31) [31,Inf)
457 209 225
默认情况下,cut()为每个水平中值落在的范围设置标签。方括号表示括号内的数字包含在该水平内,而圆括号表示不包含的数字。如果需要,可以将labels参数分配给结果的一个因子标签向量。
当与seq()函数生成的值序列结合使用时,cut()函数变得更加有趣。在这里,我们为从 0 到 550 的 11 个值范围创建水平,增量是 50:
> table(cut(titanic_train$Fare, right = FALSE,
breaks = seq(from = 0, to = 550, by = 50)))
[0,50) [50,100) [100,150) [150,200) [200,250) [250,300)
730 108 24 9 11 6
[300,350) [350,400) [400,450) [450,500) [500,550)
0 0 0 0 3
使用等宽区间可以减少维度,但并不能解决稀疏性问题。前两个水平包含大部分示例,但其余的示例数量很少,甚至在某些情况下为零。
作为等大小区间的一个替代方案,我们可以构建具有相等示例数量的箱。我们在前几章中使用了quantile()函数来识别五分位数和百分位数的分割点,但我们仍然需要使用这些值与cut()函数一起创建因子水平。以下代码为五分位数创建了五个箱,但可以调整用于四分位数、十分位数或百分位数:
> table(cut(titanic_train$Fare, right = FALSE,
breaks = quantile(titanic_train$Fare,
probs = seq(0, 1, 0.20))))
[0,7.85) [7.85,10.5) [10.5,21.7) [21.7,39.7) [39.7,512)
166 173 196 174 179
注意,由于存在重复值,箱中并不包含完全相同数量的示例。
tidyverse 还包括一个用于创建基于分位数组的函数,在某些情况下可能更容易使用。这个ntile()函数将数据分为n个大小相等的组。例如,它可以创建以下五个组:
> table(ntile(titanic_train$Fare, n = 5))
1 2 3 4 5
179 178 178 178 178
由于函数分配了数字标签给组,因此将结果向量转换为因子很重要。这可以直接在mutate()语句中完成:
> titanic_train <- titanic_train |>
mutate(fare_level = factor(ntile(Fare, n = 11)))
结果特征有 11 个等比例的水平:
> table(titanic_train$fare_level)
1 2 3 4 5 6 7 8 9 10 11
81 81 81 81 81 81 81 81 81 81 81
尽管水平仍然有数字标签,但由于该特征已被编码为因子,它仍将被大多数 R 函数视为分类变量。当然,找到过少和过多水平之间的正确平衡仍然很重要。
处理缺失数据
在前几章的示例中使用的教学数据集很少出现缺失数据的问题,即应该存在的值却缺失了。R 语言使用特殊值NA来表示这些缺失值,这些值不能被大多数机器学习函数原生处理。在第九章,寻找数据组 – 使用 k-means 聚类中,我们能够通过基于数据集中其他可用信息的猜测来替换缺失值,这个过程称为插补。具体来说,高中学生的缺失年龄值被插补为具有相同毕业年份的学生平均年龄。这提供了一个对未知年龄值的合理估计。
与其罕见性相比,缺失数据在现实世界的机器学习项目中是一个更大的问题。这不仅是因为现实世界的项目比简单的教科书例子更混乱和复杂。此外,随着数据集规模的增加——包括更多的行或列——相对较小的缺失比例将导致更多的问题,因为任何给定的行或列至少包含一个缺失值的可能性变得更大。例如,即使缺失率仅为百分之一,在一个有 100 列的数据集中,我们预计平均行会有一个缺失值。在这种情况下,简单地排除所有包含缺失值的行将极大地减少数据集的大小,以至于变得微不足道!
在经济学、生物统计学和社会科学等领域,缺失数据的黄金标准方法是多重插补,它使用统计建模或机器学习技术,根据非缺失特征值来插补所有缺失的特征值。因为这种方法往往会降低数据的变异性,从而增加预测的确定性,所以现代多重插补软件往往会向插补值添加随机变异性,以避免对数据集得出的推断产生偏差。R 有许多用于执行多重插补的包,例如:
-
mice: 通过链式方程的多变量插补 -
Amelia: 缺失数据程序(以 1937 年试图成为第一位环球飞行的女性飞行员而失踪的著名飞行员阿米莉亚·埃尔哈特命名) -
Simputation: 简单插补,它试图通过使用 tidyverse 兼容函数来简化缺失数据处理 -
missForest: 使用随机森林的非参数缺失值插补,这是一个使用最先进的机器学习方法来插补任何类型数据的包,即使特征之间存在复杂、非线性的关系
尽管有丰富的多重插补软件工具,但与传统的统计学和社会科学项目相比,机器学习项目在处理缺失数据时应用更简单的方法。这是因为目标和考虑因素不同。机器学习项目倾向于关注在非常大的数据集上工作的方法,并促进对未来未见测试集的预测,即使违反了某些统计假设。另一方面,社会科学中更正式的方法侧重于那些通常计算量更大但会导致推断和假设检验无偏估计的策略。在阅读以下部分时,请记住这个区别,这些部分涵盖了处理缺失数据的常见实用技术,但通常不适用于正式的科学分析。
理解缺失数据的类型
并非所有缺失数据都是同等重要的,有些类型比其他类型更成问题。因此,在准备包含缺失值的数据时,考虑特定值缺失的潜在原因是有用的。试着想象自己处于生成数据集的过程之中,并问自己为什么某些值被留空。这是否有合理的缺失原因?或者,它是纯粹由于错误或偶然而留空的?回答这些问题有助于以负责任的方式确定替换缺失值的解决方案。这些问题的答案还区分了三种不同类型的缺失数据,从最不严重到最严重:
-
完全随机缺失(MCAR)的数据与其其他特征和自身值无关;换句话说,不可能预测任何特定值是否缺失。缺失可能是由于随机数据输入错误或其他随机跳过值的某些过程造成的。完全随机缺失可以想象为一个完全不可预测的过程,它选择数据的最终矩阵并随机选择单元格进行删除。
-
随机缺失(MAR)的数据可能依赖于其他特征,但不依赖于基础值,这意味着某些可预测的行比其他行更有可能包含缺失值。例如,某些地理区域的家庭可能不太愿意报告他们的家庭收入,但假设他们确实披露了此类信息,他们也会诚实地披露。本质上,MAR 意味着在控制了导致缺失的基础因素或因素之后,缺失值是随机选择的。
-
非随机缺失(MNAR)的数据是由于与缺失值本身相关的某种原因而缺失的。从本质上讲,这种数据由于某种无法从数据集的其他特征中辨别的原因而被从数据集中删除。例如,收入较低的个人可能不太愿意分享他们的收入,所以他们简单地将其留空。另一个例子可能是一个温度传感器,在极端高温或低温下报告缺失值。很可能大多数现实世界的缺失值都是 MNAR,因为通常存在一些未测量的、隐藏的机制导致数据缺失。在现实世界中,真正随机的情况非常少。
估计方法对前两种缺失数据类型效果良好。尽管人们可能会认为由于其独立性和不可预测性,MCAR 数据是估计中最具挑战性的,但实际上它是处理缺失数据的理想类型。尽管缺失是完全随机的,但考虑到其他可用特征,随机隐藏的值可能是可预测的。换句话说,缺失性本身是不可预测的,但潜在的缺失值可能是相当可预测的。同样,MAR 数据也可以通过给定的特征轻松预测。
不幸的是,NMAR 数据,可能是最常见的缺失数据类型,其预测能力最弱。因为缺失值被一个不可知的过程所屏蔽,基于此类数据构建的任何模型都将无法完整地展现缺失值和非缺失值之间的关系,结果很可能会偏向于非缺失数据。例如,假设我们正在尝试构建一个贷款违约模型,而收入较低的人更有可能在贷款申请中留空收入字段。如果我们对缺失的收入进行估算,估算的值往往会高于真实值,因为我们的估算仅基于可用的数据,而这些数据中低值缺失的比高值多。如果低收入家庭更有可能违约,那么使用带有偏差的估算收入值来预测贷款结果的模型将低估留空收入的家庭的违约概率。
由于存在这种偏差的可能性,严格来说,我们只应该估算 MCAR 和 MAR 数据。然而,估算可能是两个不完美选项中较不糟糕的一个,因为如果数据并非完全随机缺失,那么从训练数据集中排除带有缺失数据的行也会使模型产生偏差。因此,尽管违反了统计假设,机器学习从业者通常还是倾向于估算缺失值,而不是从数据集中移除缺失数据。以下章节将展示一些常见的策略,这些策略被用于实现这一目标。
执行缺失值估算
由于NA值不能被许多 R 函数或大多数机器学习算法直接处理,它们必须被替换为其他东西,并且理想情况下,以一种可以改善模型性能的方式。在机器学习中,这种缺失值估算是一种预测障碍,这意味着那些工作得相对合理的简单方法比更复杂的方法更受欢迎——即使复杂的方法可能在方法论和理论上更为合理。
缺失的字符型数据可能提供了一种最简单的缺失值处理方式,因为我们可以简单地将缺失值像其他任何值一样处理,只需将NA值重新编码为一个字面字符串,如'Missing'、'Unknown'或你选择的任何其他标签。字符串本身是任意的;它只需要在列中的每个缺失值中保持一致。例如,泰坦尼克号数据集包括两个具有缺失数据的分类特征:Cabin和Embarked。我们可以轻松地将'X'用于替代缺失的Cabin值,将'Unknown'用于替代缺失的Embarked值,如下所示:
> titanic_train <- titanic_train |>
mutate(
Cabin = if_else(is.na(Cabin), "X", Cabin),
Embarked = if_else(is.na(Embarked), "Unknown", Embarked)
)
尽管这种方法通过用有效的字符字符串替换 NA 值来消除了这些值,但似乎更复杂的方法应该是可能的。毕竟,我们能否使用机器学习来预测数据集中剩余列的缺失值?确实,这是可能的,正如我们很快就会学到的那样。然而,如果这种高级方法实际上损害了模型的预测性能,那么使用这种方法可能是过度杀鸡用牛刀。
进行缺失值插补的原因在不同学科中有所不同。在传统统计学和社会科学中,模型通常用于推理和假设检验,而不是用于预测和预测。当用于推理时,尽可能仔细地保留数据集中特征之间的关系非常重要,因为统计学家寻求仔细估计和理解每个特征与感兴趣的结果之间的个体联系。将任意值插入缺失位置可能会扭曲这些关系——特别是在值不是完全随机缺失的情况下。
相反,更复杂的方法使用其他可用的信息来推断一个关于真实值的合理猜测,尽可能保留尽可能多的数据行,同时确保数据在特征之间的重要内部关系得到保留。最终,这增加了分析的统计功效,这与检测模式和测试假设的能力相关。
相比之下,机器学习从业者通常对数据集中特征的内部关系不太关心,而更关注特征与外部目标结果的关系。从这个角度来看,没有强烈的理由应用复杂的插补策略。这些方法不会提供新的信息,这些信息可以用来更好地预测目标,因为它们仅仅加强了内部模式。另一方面,根据数据不是随机缺失的假设,可能不太有助于关注缺失位置中要插补的具体值,而应将精力集中在尝试将缺失本身作为目标预测的预测因子。
带有缺失值指示符的简单插补
上一个章节中描述的实践,即用像 'Missing' 或 'Unknown' 这样的任意字符串替换缺失的分类值,是简单插补的一种形式,其中 NA 值被简单地替换为一个常数。对于数值特征,我们可以使用等效的方法。对于每个有缺失值的特征,选择一个值来替换 NA 值。这可以是一个汇总统计量,如均值、中位数或众数,或者它可能是一个任意数——具体值通常并不重要。
尽管确切值通常并不重要,但最常见的方法可能是均值插补,这可能是由于在传统统计学领域普遍存在这种做法。另一种方法是使用与实际数据中找到的值相同数量级的值,但不在实际值范围内。例如,对于 0 到 100 范围内的缺失年龄值,您可以选择插补值为-1 或 999。请注意,无论选择什么值,任何在特征上计算的汇总统计量都将被插补值扭曲。
除了在NA的位置输入一个值之外,创建一个缺失值指示器(MVI),这是一个二元变量,表示特征值是否被插补,这一点尤为重要。我们将使用以下代码为缺失的泰坦尼克号乘客年龄值进行插补:
> titanic_train <- titanic_train |>
mutate(
Age_MVI = if_else(is.na(Age), 1, 0),
Age = if_else(is.na(Age), mean(Age, na.rm = TRUE), Age)
)
应将已插补的特征和 MVI 都包括在机器学习模型中预测因子中。一个值缺失的事实通常是目标的重要预测因子,而且令人惊讶的是,它往往是目标的最强预测因子之一。在简单的信念下,现实世界中很少有事情是随机发生的,如果值缺失,可能有一个解释。例如,在泰坦尼克号数据集中,缺失的年龄可能暗示了乘客的社会地位或家庭背景。同样,有人拒绝在贷款申请上报告他们的收入或职业,可能是在隐藏他们收入非常少的事实——这可能是贷款违约的强预测因子。这个发现,即缺失值是有趣的预测因子,也适用于大量缺失数据;更多的缺失可能导致更有趣的预测因子。
缺失值模式
在相信缺失值可能是一个非常有用的预测因子的基础上,每个额外的缺失值都可能有助于我们预测特定结果的能力。在贷款申请数据的情况下,一个在单个特征上缺失数据的个人可能是有意隐藏他们的答案,或者他们可能只是不小心在贷款申请表上跳过了这个问题。如果一个人在多个特征上缺失数据,后一个借口不再适用,或者这可能意味着他们在申请过程中匆忙或通常更不负责任。我们可能会假设拥有更多缺失数据的人更有可能违约,但当然,我们不会知道直到我们训练模型;也可能的情况是,拥有更多缺失数据的人实际上不太可能违约,可能是因为一些贷款申请问题不适用于他们的情况。
假设实际上在大量缺失数据的记录中存在某种模式,但这并不仅仅基于缺失值的数量,而是基于缺失的具体特征。例如,有些人因为收入太低而不敢报告,可能会跳过贷款申请中的相关问题,而小企业主可能会跳过就业历史中的不同问题部分,因为他们不适用于自雇人士。这两种情况可能有大致相等的缺失值数量,但它们的模式可能差异很大。
可以构建一个 缺失值模式(MVP)来捕捉这种行为,并将其用作机器学习的特征。缺失值模式本质上是由一系列 MVIs 组成的字符字符串,字符串中的每个字符代表一个具有缺失值的特征。图 13.12 展示了对于简化的贷款申请数据集,这个过程是如何工作的。对于每个八个特征,我们构建一个缺失值指示器,以指示相应的单元格是否缺失:
![表格描述自动生成
图 13.12:构建缺失值模式从为每个具有缺失数据的特征创建缺失值指示器开始
这些二进制 MVIs 然后连接成一个单一的字符串。例如,第一行将表示为字符串 '11100000',这表明对于这位贷款申请人来说,前三个特征是缺失的。没有缺失数据的第二位申请人将被表示为 '00000000',而第二和第三位申请人将分别表示为 '00000111' 和 '01011101'。生成的 mvp R 字符向量将被转换为因子,以便学习算法可以使用它进行预测。因子的每个级别代表一个特定的缺失模式;遵循相同模式的贷款申请人可能具有相似的结果。
尽管缺失值模式可以是非常强大的预测因子,但它们并非没有一些挑战。首先,在一个包含 k 个特征的数据集中,缺失值模式的潜在值有 2^k 种。仅包含 10 个特征的数据集可能有高达 1,024 个 MVP 预测器的级别,而包含 25 个特征的数据集将会有超过 3300 万个潜在级别。一个包含 50 个特征的相对较小的数据集将会有几乎无法计数的潜在 MVP 级别,这将使预测器对建模变得无用。
尽管存在这个问题,但 MVP 方法所抱的希望是,大量级别的潜在数量避免了由于缺失模式远非均匀或随机而产生的维度诅咒。换句话说,MVP 方法强烈依赖于非随机缺失的数据;我们希望存在一个强大的潜在模式驱动缺失值,缺失值模式将反映出来。总的来说,缺失中的异质性越少,某些缺失值模式在数据中出现的频率就越高。不幸的是,即使一个特征完全随机缺失,它也可能降低 MVP 方法的有效性,因为即使行在几乎所有的二元缺失值指示器上都很相似,如果有一个不同,它将被视为一个完全不同的缺失值模式。为了解决这个问题,一种替代方法是使用带有无监督聚类算法(如第九章中介绍的 k-means 算法)的 MVI 数据集,以创建具有相似缺失值模式的相似人群。
不平衡数据问题
最具挑战性的数据问题之一是不平衡数据,它发生在其中一个或多个类别级别比其他类别更常见的情况下。许多,如果不是大多数,机器学习算法在处理高度不平衡的数据集时都遇到了极大的困难,尽管没有特定的阈值来确定数据集何时过于不平衡,但随着问题的加剧,不平衡带来的问题变得越来越严重。
在类别不平衡的早期阶段,会发现一些小问题。例如,简单的性能指标如准确率开始失去相关性,需要更复杂的性能指标,如第十章中描述的评估模型性能。随着不平衡的加剧,会出现更大的问题。例如,在极端不平衡的数据集中,某些机器学习算法可能根本无法预测少数群体。考虑到这一点,当分割比例不如 80%对 20%时,可能开始担心不平衡数据,当分割比例不如 90%对 10%时,应更加担心,当分割比例比 99%对 1%更严重时,应假设最坏的情况。
如果一个或多个类别级别的实际分类成本显著高于或低于其他类别,也可能出现类别不平衡。
不平衡数据是一个普遍而重要的挑战,因为许多我们关心的现实世界结果之所以重要,主要是因为它们既罕见又昂贵。这包括识别以下结果的预测任务:
-
严重疾病或疾病
-
极端天气和自然灾害
-
欺诈活动
-
贷款违约
-
硬件或机械故障
-
财富或所谓的“鲸鱼”客户
如你很快就会学到的那样,不幸的是,处理这类不平衡分类问题并没有一种单一的最佳方法,即使是更高级的技术也存在缺点。可能最重要的方法就是意识到不平衡数据的问题,同时认识到所有解决方案都不完美。
数据重平衡的简单策略
如果数据集存在严重的不平衡,某些类别的示例过多或过少,解决这个问题的简单方法是从大多数类别中减去示例或添加少数类别的示例。前者策略称为少样本,在最简单的情况下涉及从大多数类别中随机丢弃记录。后者方法称为过样本。理想情况下,人们会简单地收集更多的数据行,但这通常是不可能的。相反,少数类别的示例会随机复制,直到达到所需的类别平衡。
少样本和过样本各自都有显著的缺点,但在某些情况下可能有效。少样本的主要危险是丢失表达数据中微小但重要模式的示例。因此,如果数据集足够大,可以减少移除大多数类别的较大部分而完全排除关键训练示例的风险,那么少样本工作得最好。此外,在大数据时代自愿放弃信息总是让人感到挫败。
过样本通过生成额外的少数类别示例来避免这种失望,但风险是过度拟合到少数案例中的不重要模式或噪声。少样本和过样本都包含在更高级的聚焦采样方法中,这些方法避免简单的随机采样,而是倾向于最大化组间决策边界的记录。
由于这些技术的计算效率低下和有限的实际效果,这些技术很少在实践中使用。
若想深入了解处理不平衡数据的策略,请参阅 《不平衡数据集数据挖掘:概述》,Chawla, N.,2010,载于《数据挖掘与知识发现手册》第 2 版,Maimon, O. 和 Rokach, L.。
为了说明重采样技术,我们将回到本章之前使用的青少年社交媒体数据集,并开始通过几个 tidyverse 命令加载和准备它。首先,使用forcats包中的fct_函数,将gender特征重新编码为带有Male和Female标签的因素,并将NA值重新编码为Unknown。然后,将低于 13 岁或高于 20 岁的异常年龄值替换为NA值。接下来,通过结合使用group_by()和mutate(),我们可以通过毕业年份用中位数年龄来填补缺失的年龄。最后,我们使用ungroup()取消数据分组,并使用select()重新排列列,使得我们感兴趣的特征在数据集中排在最前面。完整的命令如下:
> snsdata <- read_csv("snsdata.csv") |>
mutate(
gender = fct_recode(gender, Female = "F", Male = "M"),
gender = fct_na_value_to_level(gender, level = "Unknown"),
age = ifelse(age < 13 | age > 20, NA, age)
) |>
group_by(gradyear) |>
mutate(age_imp = if_else(is.na(age),
median(age, na.rm = TRUE), age)) |>
ungroup() |>
select(gender, friends, gradyear, age_imp, basketball:drugs)
在这个数据集中,男性和未知性别的人代表性不足,我们可以用 fct_count() 函数来确认:
> fct_count(snsdata$gender, prop = TRUE)
# A tibble: 3 × 3
f n p
<fct> <int> <dbl>
1 Female 22054 0.735
2 Male 5222 0.174
3 Unknown 2724 0.0908
一种方法是对女性和男性群体进行 undersampling,使得所有三个类别都有相同数量的记录。首次在 第十章,评估模型性能 中介绍的 caret 包包括一个 downSample() 函数,可以执行此技术。y 参数是要平衡的类别特征的水平,x 参数指定要包含在重采样数据框中的剩余列,而 yname 参数是目标列的名称:
> library(caret)
> sns_undersample <- downSample(x = snsdata[2:40],
y = snsdata$gender,
yname = "gender")
结果数据集包括每个三个类别水平各有 2,724 个示例:
> fct_count(sns_undersample$gender, prop = TRUE)
# A tibble: 3 × 3
f n p
<fct> <int> <dbl>
1 Female 2724 0.333
2 Male 2724 0.333
3 Unknown 2724 0.333
caret 包的 upSample() 函数执行 oversampling,使得所有三个级别都有与多数类相同数量的示例:
> library(caret)
> sns_oversample <- upSample(x = snsdata[2:40],
y = snsdata$gender,
yname = "gender")
结果数据集包括三个性别类别中每种类别各有 22,054 个示例:
> fct_count(sns_oversample$gender, prop = TRUE)
# A tibble: 3 × 3
f n p
<fct> <int> <dbl>
1 Female 22054 0.333
2 Male 22054 0.333
3 Unknown 22054 0.333
是否 oversampling 或 undersampling 方法更有效取决于数据集以及所使用的机器学习算法。明智的做法可能是构建基于这些重采样技术各自创建的数据集训练的模型,并查看哪个在测试中表现更好。然而,非常重要的一点是要记住,性能指标应该在未平衡的测试集上计算;评估应该反映原始类别不平衡,因为这是模型在实际部署中需要表现的方式。
使用 SMOTE 生成合成的平衡数据集
除了 undersampling 和 oversampling,还有一种称为 合成生成 的第三种平衡方法,其目的是通过创建新的少数类示例来减少 oversampling 过度拟合少数类示例的倾向。今天,有许多合成生成平衡方法,但最早获得广泛认可的是由 Chawla 等人在 2002 年提出的 SMOTE 算法,其名称指的是它使用合成少数类 oversampling 技术。简单来说,该算法使用一系列启发式方法来构建与先前观察到的记录相似但不完全相同的新的记录。为了构建相似的记录,SMOTE 使用了 第三章 中描述的相似性概念,即 Lazy Learning – 使用最近邻进行分类,并且实际上直接使用了 k-NN 方法的一些方面。
关于 SMOTE 算法的更多信息,请参阅 SMOTE: Synthetic Minority Over-Sampling Technique, 2002, Chawla, N., Bowyer, K., Hall, L., and Kegelmeyer, W., Journal of Artificial Intelligence Research, Vol. 16, pp. 321-357。
要了解 SMOTE 是如何工作的,假设我们想要对少数类进行过采样,使得结果数据集的这个类的示例数量是原来的两倍。在标准过采样的情况下,我们会简单地复制每个少数记录,使其出现两次。在像 SMOTE 这样的合成过采样技术中,我们不会复制每个记录,而是创建一个新的合成记录。如果需要更多或更少的过采样,我们只需为每个原始记录生成更多或更少的合成记录。
仍然有一个问题:合成记录是如何具体构建的?这正是 k-Nearest Neighbors 技术发挥作用的地方。算法找到少数类每个原始观察的k个最近邻。按照惯例,k通常设置为五,但如果需要,可以设置得更大或更小。对于要创建的每个合成记录,算法随机选择原始观察的k个最近邻中的一个。例如,为了加倍少数类,它将随机选择每个原始观察中的五个最近邻中的一个;为了将原始数据增加到三倍,每个观察将选择五个最近邻中的两个,依此类推。
由于随机选择最近邻只是复制原始数据,因此需要额外一步来生成合成观察。在这一步中,算法确定每个原始观察与其随机选择的最近邻之间的向量。选择一个介于 0 和 1 之间的随机数,以反映沿此线的距离比例,以放置合成数据点。此点的特征值将在 100%与原始观察的特征值相同和 100%与邻居的特征值相同之间,或者介于两者之间。这将在以下图中展示,该图说明了合成观察如何随机放置在连接四个原始数据点及其邻居的线路上。添加六个合成观察将大大改善圆圈和正方形类别的平衡,从而加强决策边界,并可能使模式更容易被学习算法发现。

图 13.13:SMOTE 可以从四个原始少数观察中创建六个合成观察,这加强了两个类别(圆圈和正方形)之间的决策边界
当然,SMOTE 算法对最近邻的依赖以及距离函数的使用意味着与 k-NN 相同的数据准备注意事项同样适用。首先,数据集需要完全为数值型。其次,尽管这不是严格必要的,但将数值特征值转换到相同的尺度可能是个好主意,这样大的范围就不会主导最近邻的选择。我们将在接下来的部分中看到这一点。
示例 - 在 R 中应用 SMOTE 算法
有几个 R 包包含了 SMOTE 算法的实现。DMwR包中的SMOTE()函数是许多教程的主题,但目前对 R 的较新版本不可用。
smotefamily包包含各种 SMOTE 函数,并且有很好的文档,但已经几年没有更新了。因此,我们将使用themis包中的smote()函数(themis.tidymodels.org),这个包是以希腊女神忒弥斯的名字命名的,她经常被描绘为手持天平。这个包既易于使用,又很好地整合到了 tidyverse 中。
为了说明smote()函数的基本语法,我们将首先将snsdata数据集通过管道输入,并使用gender作为平衡的特征:
> library(themis)
> sns_balanced <- snsdata |> smote("gender")
检查结果时,我们使用table()函数对数据集进行操作,数据集从 30,000 行增长到 66,162 行,但现在在三个性别类别之间是平衡的:
> table(sns_balanced$gender)
Female Male Unknown
22054 22054 22054
虽然这创建了一个性别平衡,但由于 SMOTE 算法依赖于由距离计算确定的最近邻,因此在生成合成数据之前对数据进行归一化可能更好。例如,因为friends特征的范围是从 0 到 830,而football特征的范围仅从 0 到 15,所以最近的邻居可能会倾向于那些有相似朋友数量的人,而不是有相似兴趣的人。应用最小-最大归一化可以通过将所有特征重新缩放到 0 到 1 的范围内来帮助缓解这些担忧。
我们之前编写了自己的归一化函数,现在我们将再次在这里实现它:
> normalize <- function(x) {
return ((x - min(x)) / (max(x) - min(x)))
}
为了将数据恢复到其原始尺度,我们还需要一个unnormalize()函数。如这里定义的,该函数接受两个参数:第一个是一个向量,norm_values,它存储了已经归一化的值;第二个是一个字符串,包含已归一化的列名。我们需要这个列名,以便从snsdata数据集中的原始、未归一化的数据中获取该列的最小值和最大值。生成的unnormalized_vals向量使用这些最小值和最大值来反转归一化,然后值被四舍五入到整数,就像原始数据中的那样,除了age_imp特征,它最初是十进制。
完整的unnormalize()函数如下:
> unnormalize <- function(norm_vals, col_name) {
old_vals <- snsdata[col_name]
unnormalized_vals <- norm_vals *
(max(old_vals) - min(old_vals)) + min(old_vals)
rounded_vals <- if(col_name != "age_imp")
{ round(unnormalized_vals) }
else {unnormalized_vals}
return (rounded_vals)
}
使用一系列管道,我们可以在使用smote()函数之前应用归一化,之后进行反归一化。这通过 dplyr 的across()函数来归一化和反归一化数据类型为数值的列。在unnormalize()函数的情况下,语法稍微复杂一些,因为使用了 lambda 表达式,用波浪号(~)字符表示,它定义了一个要在数据类型为数值的列上使用的函数。normalize()函数不需要使用 lambda,因为它只使用一个参数,而unnormalize()使用两个。.x指的是列中的数据向量,并作为第一个参数传递,而cur_column()函数用于传递当前列的名称作为第二个参数。完整的命令序列如下:
> snsdata_balanced <- snsdata |>
mutate(across(where(is.numeric), normalize)) |>
smote("gender") |>
mutate(across(where(is.numeric), ~unnormalize(.x, cur_column())))
如前所述,比较 SMOTE 前后性别平衡的变化,我们看到现在类别是等效的:
> table(snsdata$gender)
Female Male Unknown
22054 5222 2724
> table(snsdata_balanced$gender)
Female Male Unknown
22054 22054 22054
注意,我们现在有超过四倍的男性记录和超过八倍的未知性别记录——或者说,大约为每个原始的男性或未知性别的记录分别添加了三个合成男性记录和七个合成未知性别记录。女性示例的数量保持不变。这个平衡后的数据集现在可以与机器学习算法一起使用,同时要注意模型将主要基于合成案例而不是少数类别的“真实”示例。这种结果是否会导致性能改进可能因项目而异,原因将在下一节讨论。
考虑平衡是否总是更好的
虽然严重不平衡的数据集对学习算法造成挑战是不容否认的,但处理不平衡的最佳方法尚不清楚。有些人甚至认为最佳方法是根本不采取任何行动!问题是人工平衡数据集是否可以改善学习算法的整体性能,或者它只是以降低特异性为代价来提高敏感性。因为在一个人工平衡的数据集上训练过的学习算法最终将部署在原始的不平衡数据集上,这似乎表明平衡的做法仅仅是调整学习者对一种错误类型相对于另一种错误类型的成本的感觉。因此,理解丢弃数据如何导致更智能的模型——即一个更能真正区分结果之间的模型——是不直观的。
在那些对人工平衡训练数据持怀疑态度的人中,多产的生物统计学家 Frank Harrell 就这个主题写了大量内容。在一篇深思熟虑的博客文章中,他写道:
“机器分类器的用户都知道,关于二元结果变量 Y 的高度不平衡样本会导致一个奇怪的分类器……因此,为了平衡频率并得到一些能够产生合理分类器的变化,人们会采用一种奇怪的子采样控制的方法。然后,他们必须以某种不明确的方式构建分类器,以弥补样本偏差。”
显然,Harrell 并不认为平衡样本通常是一种明智的方法!
有关 Harrell 关于这个主题的更多写作,请参阅www.fharrell.com/post/classification/以及www.fharrell.com/post/class-damage/。
《实用数据科学 R 语言》一书的作者 Nina Zumel 进行了实验,以确定人工平衡数据集是否可以提高分类性能。在进行了实验后,她得出结论:
“当类别几乎平衡时,分类往往更容易……但我一直怀疑,当模型要在具有天然类别普遍性的群体上运行时,人工平衡类别总是有帮助的说法。如果你的目标是应用类别标签,那么平衡类别或一般增强的价值有限……[它]对于逻辑回归模型来说不是一个好主意。”
与 Frank Harrell 一样,Nina Zumel 也对为分类模型人工平衡数据集的需求表示怀疑。然而,这两种观点都与大量实证和轶事证据相矛盾,这些证据表明,实际上,人工平衡数据集确实可以提高模型性能。
有关 Zumel 关于不平衡数据分类的实验的完整描述,请参阅win-vector.com/2015/02/27/does-balancing-classes-improve-classifier-performance/。
这矛盾的结果是什么解释的呢?这可能与工具的选择有关。统计学习算法,如回归,可能被很好地校准,这意味着它们在估计结果的真正潜在概率方面做得很好——即使是对于罕见的结果。许多机器学习算法,如决策树和朴素贝叶斯,明显没有被很好地校准,因此可能需要通过人工平衡来获得合理的概率。
无论是否采用平衡策略,使用一种反映模型在部署期间预期执行的天然不平衡的自然评估方法是很重要的。这意味着优先考虑成本感知措施,如 kappa、敏感度和特异性,或精确度和召回率,以及检查第十章中讨论的接收者操作特征(ROC)曲线。
虽然对人工平衡数据集持怀疑态度是个好主意,但对于最具挑战性的数据问题,尝试一下也可能值得一试。
摘要
本章旨在向您介绍几种新的具有挑战性的数据类型,尽管这些数据类型在简单的教学示例中很少出现,但在实际应用中却经常遇到。尽管有流行的谚语告诉我们“好事做到底,绝无坏处”或“越多越好”,但这并不总是适用于机器学习算法,因为它们可能会被无关数据所分散注意力,或者在细节过多的情况下难以找到“针尖上的麦芒”。所谓的大数据时代的一个看似矛盾的现象是,更多的数据既是使机器学习成为可能的原因,也是使其具有挑战性的原因;实际上,过多的数据甚至可能导致所谓的“维度诅咒”。
虽然丢弃大数据的一部分宝藏令人失望,但有时这是帮助学习算法按预期运行的必要手段。也许将其视为数据整理会更好,其中最相关的细节被置于最前沿。对于没有内置选择方法的算法,降维技术如特征选择和特征提取很重要,但它们也提供了诸如提高计算效率等好处,这可能是大型数据集的关键瓶颈。稀疏数据也需要帮助,以便将重要的细节带到学习算法的注意中,就像异常值和缺失数据的问题一样。
尽管缺失数据在本书中一直只是一个小问题,但在许多现实世界的数据集中,它却提出了一个重大的挑战。机器学习从业者通常选择最简单的方法来解决该问题——即,完成模型合理表现所需的最少工作量——然而,在传统统计学、生物统计学和社会科学领域,基于机器学习的方法,如多重插补,正被用来创建完整的数据集。
不平衡数据的问题可能是最难应对的挑战性数据问题。许多最重要的机器学习应用都涉及到对不平衡数据集的预测,但并没有简单的解决方案,只有妥协。像过采样和欠采样这样的技术虽然简单,但存在显著的缺点,而像 SMOTE 这样的更复杂技术虽然很有前景,但可能会引入新的问题,而且社区在最佳方法上存在分歧。无论如何,最重要的教训是确保评估策略反映了模型在部署期间将遇到的条件。
例如,即使模型是在人工平衡的数据集上训练的,也应该使用结果的天然平衡来对其进行测试和评估。
现在我们已经克服了这些数据挑战,下一章再次聚焦于模型构建,尽管数据准备是构建更好学习者的一个重要组成部分——毕竟,垃圾输入导致垃圾输出——但我们还可以做更多的事情来增强学习过程本身。然而,这样的技术将需要不仅仅是现成的算法;它们需要创造力和决心来最大化学习者的潜力。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人交流,并在以下地点与超过 4000 人一起学习:

第十四章:构建更好的学习者
当一支运动队未能达到其目标时——无论是获得奥运金牌、联赛冠军还是世界纪录时间——它必须寻找可能的改进。想象一下你是这支球队的教练。你会在训练中如何安排?也许你会指导运动员更加努力或以不同的方式训练,以最大限度地发挥他们的潜力。你也可能会专注于团队合作,更明智地利用每位运动员的优势和劣势。
现在想象一下你正在训练一个冠军机器学习算法。也许你希望参加机器学习竞赛,或者你可能只是需要超越商业竞争对手。你从哪里开始?尽管背景不同,提高运动队表现的战略与提高统计学习器表现的战略相似。作为教练,你的任务是找到训练技巧和团队合作技能的组合,使机器学习项目能够达到你的性能目标。
本章基于本书所涵盖的内容,介绍了提高学习算法预测能力的技巧。你将学习:
-
通过系统地搜索最佳训练条件集来自动化模型性能调整的技术
-
将模型组合成团队,利用团队合作解决困难的学习任务的方法
-
如何使用并区分因表现卓越而流行的决策树的不同变体
这些方法并不适用于每个问题。然而,查看机器学习竞赛的获胜作品,你可能会发现至少其中之一已被采用。为了具有竞争力,你也需要将这些技能添加到你的技能库中。
调整股票模型以获得更好的性能
一些机器学习任务非常适合由前几章中介绍的股票模型来解决。对于这些任务,可能没有必要花费太多时间迭代和改进模型,因为它可能在没有额外努力的情况下表现良好。另一方面,许多现实世界的任务本质上是更困难的。对于这些任务,需要学习的基本概念往往非常复杂,需要理解许多微妙的关系,或者问题可能受到大量随机变异性的影响,这使得在噪声中找到信号变得困难。
开发在这些具有挑战性的问题上表现极好的模型,既是一门艺术,也是一门科学。有时,在尝试确定可以改进性能的领域时,一点直觉是有帮助的。在其他情况下,找到改进可能需要一种暴力搜索、尝试和错误的办法。当然,这是使用机器的一个优点,因为机器永远不会感到疲倦,也永远不会感到无聊;通过自动化程序可以更容易地搜索众多潜在改进。然而,我们将看到,人力和计算时间并不总是可以互换的,创建一个精心调整的学习算法可能会带来自己的成本。
我们在第五章《分而治之 - 使用决策树和规则进行分类》中尝试了一个困难的机器学习问题,我们试图预测可能进入违约的银行贷款。尽管我们能够实现 82%的可敬的分类准确率,但在第十章《评估模型性能》中更仔细地检查后,我们发现准确度统计指标有些误导。kappa 统计量——不平衡结果性能的更好衡量指标——通过 10 折交叉验证(CV)测量仅为 0.294,这表明尽管准确率很高,但模型的表现有些不佳。在本节中,我们将重新审视信用评分模型,看看我们是否可以改进结果。
要跟随示例进行操作,请从 Packt Publishing 网站下载credit.csv文件,并将其保存到你的 R 工作目录中。使用以下命令将文件加载到 R 中:credit <- read.csv("credit.csv")。
你可能还记得,我们最初使用了一个标准的 C5.0 决策树来构建信用数据的分类器,后来尝试通过调整trials选项来增加提升迭代的次数,以提高分类器的性能。
通过将迭代次数从默认值 1 增加到 10,我们能够提高模型的准确度。如第十一章《用机器学习取得成功》中定义的,这些被称为超参数的模型选项不是从数据中自动学习的,而是在训练之前设置的。因此,测试各种超参数设置以实现更好的模型拟合的过程被称为超参数调整,调整策略从简单的临时尝试和错误到更严格和系统的迭代。
超参数调整不仅限于决策树。例如,我们在寻找最佳 k 值时调整了 k-NN 模型。我们还调整了神经网络和支持向量机,当我们调整节点数和隐藏层数,或选择不同的核函数时。大多数机器学习算法至少允许调整一个超参数,而最复杂的模型提供了许多调整模型拟合的方法。虽然这允许模型紧密地适应学习任务,但众多选项的复杂性可能会令人望而却步。需要一种更系统的方法。
确定超参数调整的范围
在执行超参数调整时,重要的是要对范围设定界限,以防止搜索无休止地进行。计算机提供力量,但决定在哪里寻找以及寻找多长时间的责任在于人类。即使计算能力在增长,云计算成本在下降,但在筛选几乎无尽的值组合时,搜索很容易失控。一个狭窄或浅层次的调整范围可能足以让你喝上一杯咖啡,而一个广泛或深层次的调整范围可能会给你足够的时间好好睡一觉——或者更多!
时间和金钱往往是可互换的,因为你可能能够通过购买额外的计算资源或招募额外的团队成员来更快或并行地构建模型来购买时间。即便如此,这种假设可能导致预算超支或错过截止日期,因为如果没有计划,工作可能会迅速偏离无数的方向和死胡同。为了避免这样的陷阱,事先对调整过程的广度和深度进行战略规划是明智的。
你可能会首先将调整过程想象成玩经典的棋盘游戏 Battleship。在这个游戏中,你的对手在一个二维网格上放置了一支舰队,而你无法看到。你的目标是猜测他们所有舰船的坐标,并在他们对你做同样的事情之前摧毁他们的舰队。因为舰船的大小和形状是已知的,一个聪明的玩家会首先以棋盘格的图案广泛地探测搜索网格,但一旦被击中,就会迅速聚焦于一个特定的目标。
这比随机猜测坐标或系统地迭代每个坐标都要好,这两种方法在效率上都比较低。

图 14.1:寻找最优机器学习超参数的过程可以类似于玩经典的“战舰”棋盘游戏
类似地,有一些比在无限值和值组合上系统迭代更有效的调整方法。随着经验的积累,你会发展出如何进行的感觉,但最初的几次尝试,有意识地思考这个过程可能是有用的。以下列出的一系列一般策略,可以根据你的机器学习项目、计算和人力资源以及工作风格进行调整:
-
复制现实世界的评估标准:为了找到最佳的模型超参数集,重要的是模型应该使用与部署时相同的标准进行评估。这可能意味着选择一个反映最终现实指标的评估指标,或者可能涉及编写一个模拟部署环境的函数。
-
考虑一次迭代的资源使用:由于调整将在同一算法上迭代多次,你应该有一个关于单次迭代所需的时间和计算资源的估计。如果训练单个模型需要一小时,那么 100 次迭代将需要 100 小时或更多。如果计算机内存已经达到极限,那么在调整过程中很可能会超过这个限制。如果这是一个问题,你需要投资额外的计算能力,并行运行实验,或者通过随机抽样减少数据集的大小。
-
从浅层搜索开始以寻找模式:初始调整过程应该是交互式的和浅层的。它的目的是发展你对自己认为哪些选项和值是重要的理解。在探测单个超参数时,应合理地增加或减少其设置,直到性能停止提高(或开始下降)。根据选项,这可能是 1 的增量,5 或 10 的倍数,或者增量很小的分数,如 0.1、0.01、0.001 等。当调整两个或更多超参数时,一次关注一个并保持其他值不变可能会有所帮助。这种方法比测试所有可能的设置组合更有效,但最终可能会错过如果测试了所有组合本可以发现的组合。
-
聚焦于最优的超参数值集:一旦你对包含最优设置的值范围有了感觉,你可以减少测试值之间的增量,并使用更高的精度测试更窄的范围,或者测试更多值的组合。前一步应该已经产生了一组合理的超参数,所以这一步应该只会提高而不会降低模型的性能;它可以在任何时候停止。
-
确定一个合理的停止点:决定何时停止调整过程比说起来容易——追求更好模型的冲动和可能出现的略微改进可以导致固执地继续下去!有时,停止点是一个项目截止日期,当时时间正在流逝。在其他情况下,只有在达到期望的性能水平后,工作才能停止。在任何情况下,因为保证找到最佳超参数值的唯一方法是在无限多的可能性中进行测试,而不是朝着燃尽努力,你需要定义性能“足够好”以停止过程的点。
图 14.2说明了针对单参数调整聚焦超参数值的过程。在初始遍历中,对五个潜在值(1、2、3、4 和 5)进行了评估,当超参数设置为 3 时,准确率最高。为了检查是否存在更好的超参数设置,在 2 到 4 的范围内测试了八个额外的值(从 2.2 到 3.8,以 0.2 的增量,由垂直刻度表示),当超参数设置为 3.2 时,发现了更高的准确率。如果时间允许,可以在围绕这个值更窄的范围内测试更多的值,以可能找到更好的设置。

图 14.2:参数调整策略通过广泛搜索然后缩小搜索范围来聚焦于最佳值
调整两个或更多超参数更为复杂,因为一个参数的最佳值可能取决于其他参数的值。构建类似于图 14.3所示的可视化可以帮助理解如何找到最佳参数组合;在那些某些值组合导致模型性能更好的热点区域,可以在越来越窄的范围内测试更多的值:

图 14.3:随着更多超参数的添加,调整策略变得更加具有挑战性,因为模型的最佳性能取决于值的组合
这种类似战舰风格的网格搜索,其中系统地测试超参数及其组合,虽然不是调整的唯一方法,但可能是最广泛使用的方法。一种更智能的方法称为贝叶斯优化,将调整过程视为一个可以通过建模解决的问题。这种方法包含在一些自动机器学习软件中,但超出了本书的范围。相反,在本节的剩余部分,我们将专注于将网格搜索的理念应用于我们的实际数据集。
示例 - 使用 caret 进行自动调整
幸运的是,我们可以使用 R 来通过许多可能的超参数值和值的组合进行迭代搜索,以找到最佳集。这种方法是一种相对简单但有时计算成本较高的暴力方法,用于优化学习算法的性能。
在第十章“评估模型性能”中使用的caret包提供了辅助工具,以帮助进行这种形式的自动化调整。核心调整功能由一个train()函数提供,该函数作为分类和数值预测任务中 200 多个不同机器学习模型的标准化接口。使用此函数,可以使用选择的评估方法和指标自动搜索最佳模型。
使用caret进行自动参数调整需要你考虑三个问题:
-
应该在数据上训练哪种机器学习算法(以及该算法的特定 R 实现)?
-
对于此算法,哪些超参数可以调整,以及应该调整到何种程度以找到最佳设置?
-
应该使用什么标准来评估候选模型,以确定最佳的整体调整值集?
回答第一个问题涉及在机器学习任务与caret包提供的许多模型之一之间找到匹配。这可能需要你对机器学习模型类型的一般理解,如果你按时间顺序阅读本书,你可能已经具备这种理解。消除过程也可能有所帮助。几乎一半的模型可以根据任务是分类还是数值预测来消除;其他模型可以根据训练数据的格式或避免黑盒模型的需求等因素排除,等等。在任何情况下,也没有理由你不能创建几个高度调整的模型并在整个集合中比较它们。
回答第二个问题在很大程度上取决于模型的选择,因为每个算法都使用自己的一组超参数。本书中涵盖的预测模型的可调选项列在以下表中。请注意,尽管一些模型有未显示的附加选项,但只有表中列出的选项由caret支持用于自动调整。
| 模型 | 学习任务 | 方法名称 | 超参数 |
|---|---|---|---|
| k-最近邻 | 分类 | knn |
k |
| 朴素贝叶斯 | 分类 | nb |
fL, usekernel |
| 决策树 | 分类 | C5.0 |
model, trials, winnow |
| OneR 规则学习器 | 分类 | OneR |
None |
| RIPPER 规则学习器 | 分类 | JRip |
NumOpt |
| 线性回归 | 回归 | lm |
None |
| 回归树 | 回归 | rpart |
cp |
| 模型树 | 回归 | M5 |
pruned, smoothed, rules |
| 神经网络 | 双重用途 | nnet |
size, decay |
| 支持向量机(线性核) | 双重用途 | svmLinear |
C |
| 支持向量机(径向基核) | 双重用途 | svmRadial |
C, sigma |
| 随机森林 | 双重用途 | rf |
mtry |
| 梯度提升机(GBM) | 双重用途 | gbm |
n.trees, interaction.depth, shrinkage, n.minobsinnode |
| XGBoost (XGB) | 双重用途 | xgboost |
eta, max_depth, colsample_bytree, subsample, nrounds, gamma, min_child_weight |
要获取caret支持的模型和相应调优选项的完整列表,请参阅包作者 Max Kuhn 提供的表格,链接为topepo.github.io/caret/available-models.html。
如果你忘记了特定模型的调优参数,可以使用modelLookup()函数来查找它们。只需提供如 C5.0 模型所示的方法名:
> modelLookup("C5.0")
model parameter label forReg forClass probModel
1 C5.0 trials # Boosting Iterations FALSE TRUE TRUE
2 C5.0 model Model Type FALSE TRUE TRUE
3 C5.0 winnow Winnow FALSE TRUE TRUE
自动调优的目标是在由潜在参数组合的搜索网格组成的候选模型集合上进行迭代。由于搜索所有可能的组合是不切实际的,因此只使用可能性的子集来构建网格。默认情况下,caret为每个模型的p个超参数最多搜索三个值,这意味着最多将测试3^p 个候选模型。例如,默认情况下,k-近邻自动调优将比较3¹ = 3个候选模型,其中k=5、k=7和k=9。同样,调整决策树将导致最多 27 个不同候选模型的比较,包括model、trials和winnow设置的3³ = 27组合网格。然而,在实践中,只测试了 12 个模型。这是因为model和winnow只能取两个值(tree与rules以及TRUE与FALSE),这使得网格大小为3²² = 12。
由于默认的搜索网格可能不适合你的学习问题,caret允许你通过简单的命令提供自定义搜索网格,我们将在后面进行介绍。
自动模型调优的第三步和最后一步是确定候选模型中最好的模型。这使用了在第十章“评估模型性能”中讨论的方法,包括选择重采样策略来创建训练和测试数据集,以及使用模型性能统计来衡量预测准确性。我们学到的所有重采样策略和许多性能统计都由caret支持。这些包括如分类器的准确率和 kappa,以及数值模型的 R-squared 或均方根误差(RMSE)。如果需要,也可以使用成本敏感度指标,如灵敏度、特异性和 AUC。
默认情况下,caret 将选择具有所需性能度量最佳值的候选模型。由于这种做法有时会导致选择通过增加模型复杂度来实现微小性能改进的模型,因此提供了替代模型选择函数。这些替代方案允许我们选择更简单的模型,这些模型仍然与最佳模型相当接近,这在需要牺牲一点预测性能以换取计算效率提高的情况下可能是可取的。
由于 caret 调优过程中有各种各样的选项,许多函数的默认设置是合理的,这很有帮助。例如,不手动指定设置,caret 会使用在自助样本上的预测准确度或 RMSE 来选择分类和数值预测模型的最佳表现者。同样,它将自动定义一个有限的网格进行搜索。这些默认设置使我们能够从简单的调优过程开始,并学习如何调整 train() 函数来设计我们选择的广泛实验。
创建一个简单的调优模型
为了说明调优模型的过程,让我们首先观察当我们尝试使用 caret 包的默认设置来调优信用评分模型时会发生什么。调整学习者的最简单方法只需要你通过 method 参数指定一个模型类型。由于我们之前已经使用 C5.0 决策树与信用模型一起使用,我们将通过优化这个学习者继续我们的工作。使用默认设置调优 C5.0 决策树的基本 train() 命令如下:
> library(caret)
> set.seed(300)
> m <- train(default ~ ., data = credit, method = "C5.0")
首先,使用 set.seed() 函数初始化 R 的随机数生成器到一个固定的起始位置。你可能还记得我们在几个先前的章节中使用了这个函数。通过设置 seed 参数(在这种情况下,为任意数 300),随机数将遵循预定义的序列。这允许使用随机抽样的模拟能够重复产生相同的结果——如果你正在共享代码或尝试复制先前的结果,这是一个非常有帮助的特性。
接下来,我们使用 R 公式接口定义一棵树为 default ~ .。这使用 credit 数据集中的所有其他特征来模拟贷款违约状态(是或否)。参数 method = "C5.0" 告诉函数使用 C5.0 决策树算法。
在您输入上述命令后,根据您计算机的能力,在调整过程中可能会有显著的延迟。尽管这是一个小数据集,但必须进行大量的计算。R 必须反复生成数据的随机自助样本,构建决策树,计算性能统计信息,并评估结果。因为有 12 个候选模型具有不同的超参数值需要评估,每个候选模型有 25 个自助样本来计算平均性能指标,所以使用 C5.0 构建了 300 个决策树模型——而且这还不包括在设置提升试验时构建的额外决策树!
命名为 m 的列表存储了 train() 实验的结果,使用 str(m) 命令将显示相关结果,但内容可能相当多。相反,只需输入对象名称即可获得结果的简明摘要。例如,输入 m 将产生以下输出(注意,为了清晰起见,已添加编号标签):

图 14.4:caret 实验的结果分为四个部分,如图中所示
标签突出了输出中的四个主要组成部分:
-
输入数据集的简要描述:如果您熟悉您的数据并且正确应用了
train()函数,这些信息不应令人惊讶。 -
应用的预处理和重采样方法的报告:在这里,我们可以看到使用了 25 个包含 1,000 个示例的自助样本来训练模型。
-
评估的候选模型列表:在本节中,我们可以确认基于三个 C5.0 超参数(
model、trials和winnow)的组合测试了 12 个不同的模型。每个候选模型的平均准确率和 kappa 统计量也显示出来。 -
最佳模型的选择:正如脚注所述,选择了具有最佳准确率(换句话说,“最大”)的模型。这是使用设置
winnow = FALSE和trials = 20的决策树的 C5.0 模型。
在确定最佳模型后,train() 函数使用调整后的超参数在完整输入数据集上构建一个模型,该模型存储在 m 中作为 m$finalModel。在大多数情况下,您不需要直接与 finalModel 子对象一起工作。相反,只需使用 predict() 函数并带上 m 对象,如下所示:
> p <- predict(m, credit)
预测结果向量按预期工作,使我们能够创建一个混淆矩阵,该矩阵比较了预测值和实际值:
> table(p, credit$default)
p no yes
no 700 2
yes 0 298
在用于训练最终模型的 1,000 个示例中,只有两个被错误分类,准确率达到 99.8%。然而,非常重要的一点是,由于模型是在训练数据和测试数据上构建的,因此这种准确率是乐观的,因此不应将其视为对未见数据的性能指标。在图 14.4的train()输出的第三部分的最后一行中可以找到的 72.996%的 bootstrap 准确率估计是一个更现实的未来准确率估计。
除了自动超参数调优之外,使用caret包的train()和predict()函数还提供了一对超越标准包中函数的好处。
首先,train()函数应用的所有数据准备步骤将同样应用于用于生成预测的数据。这包括中心化和缩放等转换,以及缺失值的填充。允许caret处理数据准备将确保在模型部署时,有助于最佳模型性能的步骤仍然保持不变。
其次,predict()函数为获取预测类别值和预测类别概率提供了一个标准化的接口,即使对于通常需要额外步骤才能获取这些信息的模型类型也是如此。对于分类模型,默认提供预测类别:
> head(predict(m, credit))
[1] no yes no no yes no
Levels: no yes
要获取每个类别的估计概率,请使用type = "prob"参数:
> head(predict(m, credit, type = "prob"))
no yes
1 0.9606970 0.03930299
2 0.1388444 0.86115560
3 1.0000000 0.00000000
4 0.7720279 0.22797207
5 0.2948061 0.70519387
6 0.8583715 0.14162853
即使在底层模型使用不同的字符串(例如,对于naiveBayes模型使用"raw")来引用预测概率的情况下,predict()函数也会自动将type = "prob"转换为适当的参数设置。
定制调优过程
我们之前创建的决策树展示了caret包产生优化模型的最小干预能力。默认设置允许轻松创建优化模型。然而,也可以根据需要更改默认设置,这可能会帮助解锁性能的上层水平。在调优过程开始之前,回答一系列问题将有助于指导caret实验的设置:
-
一个迭代需要多长时间?换句话说,训练正在调优的模型的一个实例需要多长时间?
-
考虑到训练单个实例所需的时间,使用所选的重新采样方法进行模型评估需要多长时间?例如,10 折交叉验证将比训练单个模型多花费 10 倍的时间。
-
你愿意在调优上花多少时间?根据这个数字,可以确定可以测试的超参数值的总数。例如,如果使用 10 折交叉验证评估模型需要一分钟,那么每小时可以测试 60 个超参数设置。
使用时间作为关键限制因素将有助于对调优过程进行限制,并防止你无休止地追求更好的性能。
一旦你决定了在试验上花费多少时间,就可以很容易地根据你的喜好定制这个过程。为了说明这种灵活性,让我们修改我们在 第十章,评估模型性能 中使用的信用决策树的工作,以反映我们在该章中使用的过程。在那个章节中,我们使用 10 折交叉验证来估计卡方统计量。我们在这里也将这样做,使用卡方来调整 C5.0 决策树算法的增强试验,并找到我们数据的最佳设置。请注意,决策树增强首先在第五章,分而治之 – 使用决策树和规则进行分类 中介绍,也将在本章的后面部分进行更详细的介绍。
trainControl() 函数用于创建一组称为 控制对象 的配置选项。此对象指导 train() 函数,并允许选择模型评估标准,如重抽样策略和用于选择最佳模型的度量。尽管此函数可以用来修改 caret 调优实验的几乎所有方面,但我们将关注两个重要参数:method 和 selectionFunction。
如果你渴望了解更多关于控制对象的信息,你可以使用 ?trainControl 命令来查看所有参数的列表。
当使用 trainControl() 函数时,method 参数设置重抽样方法,例如保留样本法或 k 折交叉验证。下表列出了可能的 method 值,以及调整样本大小和迭代次数的任何附加参数。尽管这些重抽样方法的默认选项遵循了流行的惯例,但你可能需要根据你的数据集大小和模型复杂性进行调整。
| 重抽样方法 | 方法名称 | 附加选项和默认值 |
|---|---|---|
| 保留样本法 | LGOCV |
p = 0.75 (训练数据比例) |
| k 折交叉验证 | cv |
number = 10 (折数) |
| 重复 k 折交叉验证 | repeatedcv |
number = 10 (折数)repeats = 10 (迭代次数) |
| 重抽样法 | boot |
number = 25 (重抽样迭代次数) |
| 0.632 重抽样 | boot632 |
number = 25 (重抽样迭代次数) |
| 留一法交叉验证 | LOOCV |
无 |
selectionFunction 参数用于指定将选择候选模型中最佳模型的功能。包含三个这样的功能。best 函数简单地选择在指定性能度量上具有最佳值的候选者。这是默认使用的。其他两个函数用于选择最简约的,或最简单的模型,该模型在最佳模型性能的一定阈值内。oneSE 函数选择在最佳性能的一个标准误差内的最简单候选者,而 tolerance 使用用户指定的百分比内的最简单候选者。
在 caret 包按简单性对模型进行排名时,涉及一些主观性。有关模型排名的信息,请参阅选择函数的帮助页面,在 R 命令提示符中键入 ?best。
要创建一个名为 ctrl 的控制对象,该对象使用 10 倍交叉验证(CV)和 oneSE 选择函数,请使用以下命令,注意 number = 10 仅用于清晰起见;由于这是 method = "cv" 的默认值,因此可以省略:
> ctrl <- trainControl(method = "cv", number = 10,
selectionFunction = "oneSE")
我们将很快使用这个函数的结果。
同时,设置实验的下一步是创建超参数调整的搜索网格。网格必须包括一个列名,对应于所需模型中的每个超参数,无论它是否将被调整。它还必须包括一个行名,对应于要测试的每个所需值组合。由于我们使用的是 C5.0 决策树,这意味着我们需要名为 model、trials 和 winnow 的列,对应于可以调整的三个选项。对于其他机器学习模型,请参阅本章前面提供的表格或使用 modelLookup() 函数查找超参数,如之前所述。
而不是逐个填充网格数据帧的单元格——如果有许多可能的值组合,这将是一项繁琐的任务——我们可以使用 expand.grid() 函数,该函数从提供的所有值的组合中创建数据帧。例如,假设我们希望在搜索时保持 model = "tree" 和 winnow = FALSE 不变,同时搜索 trials 的八个不同值。
这可以通过以下方式创建:
> grid <- expand.grid(model = "tree",
trials = c(1, 5, 10, 15, 20, 25, 30, 35),
winnow = FALSE)
结果 grid 数据帧包含 181 = 8 行:
> grid
model trials winnow
1 tree 1 FALSE
2 tree 5 FALSE
3 tree 10 FALSE
4 tree 15 FALSE
5 tree 20 FALSE
6 tree 25 FALSE
7 tree 30 FALSE
8 tree 35 FALSE
train() 函数将使用每个 grid 行的模型参数组合构建一个候选模型以进行评估。
在给定搜索网格和之前创建的控制对象后,我们准备运行一个彻底定制的 train() 实验。和之前一样,我们将随机种子设置为任意数字 300 以确保可重复的结果。但这次,我们将传递我们的控制对象和调整网格,同时添加参数 metric = "Kappa",指示模型评估函数将使用的统计量——在这种情况下,"oneSE"。完整的命令集如下:
> set.seed(300)
> m <- train(default ~ ., data = credit, method = "C5.0",
metric = "Kappa",
trControl = ctrl,
tuneGrid = grid)
这将产生一个我们可以通过键入其名称来查看的对象:
> m
C5.0
1000 samples
16 predictor
2 classes: 'no', 'yes'
No pre-processing
Resampling: Cross-Validated (10 fold)
Summary of sample sizes: 900, 900, 900, 900, 900, 900, ...
Resampling results across tuning parameters:
trials Accuracy Kappa
1 0.710 0.2859380
5 0.726 0.3256082
10 0.725 0.3054657
15 0.726 0.3204938
20 0.733 0.3292403
25 0.732 0.3308708
30 0.733 0.3298968
35 0.738 0.3449912
Tuning parameter 'model' was held constant at a value of tree
Tuning parameter 'winnow' was held constant at a value of FALSE
Kappa was used to select the optimal model using the one SE rule.
The final values used for the model were trials = 5, model = tree
and winnow = FALSE.
虽然输出与自动调整的模型相似,但有一些显著的不同之处。因为使用了 10 折交叉验证,构建每个候选模型的样本量减少到 900,而不是之前自举中使用的 1,000。此外,测试了 8 个候选模型,而不是之前实验中的 12 个。最后,由于model和winnow保持不变,它们的结果中不再显示其值;相反,它们被列在脚注中。
这里最好的模型与之前的实验差异很大。之前,最好的模型使用trials = 20,而在这里,它使用trials = 1。这种变化是因为我们使用了oneSE函数而不是best函数来选择最佳模型。尽管trials = 35的模型获得了最好的 kappa 值,但单次试验的模型提供了一个简单得多的算法,并且性能相当接近。
由于配置参数数量众多,caret在最初可能会让人感到不知所措。不要让这一点阻碍你——没有比使用 10 折交叉验证测试模型性能更简单的方法了。相反,将实验视为由两部分组成:一个trainControl()对象,它规定了测试标准;以及一个确定要评估的模型参数的调整网格。将这些提供给train()函数,并投入一点计算时间,你的实验就会完成!
当然,调整只是构建更好学习者的可能性之一。在下一节中,你将发现,除了增强单个学习者使其更强之外,还可以将几个较弱的模型结合起来形成一个更强大的团队。
使用集成方法提高模型性能
正如最好的运动队拥有互补而不是重叠技能的球员一样,一些最好的机器学习算法利用了互补模型的团队。由于模型为学习任务带来独特的偏差,它可能很容易学会一组示例,但可能在另一组上遇到困难。因此,通过智能地利用几个不同团队成员的才能,可以创建一个由多个弱学习者组成的强大团队。
这种结合和管理多个模型预测的技术属于更广泛的元学习方法范畴,这些方法是涉及学习如何学习的技术。这包括从简单的算法开始,通过迭代设计决策逐渐提高性能——例如,本章早期使用的自动化参数调整——到高度复杂的算法,这些算法借鉴了进化生物学和遗传学的概念来实现自我修改和适应学习任务。
假设你是一个电视智力游戏节目的参赛者,这个节目允许你选择一个由五位朋友组成的团队来帮助你回答价值百万美元的最终问题。大多数人会尝试让这个团队包含各种学科领域的专家。一个包含文学、科学、历史和艺术教授,以及一个当前流行文化专家的团队将是一个安全而全面的团队。鉴于他们的知识广度,一个难以回答的问题不太可能难倒这个团队。
利用创建一个多样化的专家团队类似原则的元学习方法是称为集成。在本章的剩余部分,我们将只关注与集成相关的元学习——即建模多个模型预测与期望结果之间关系的工作。这里介绍基于团队合作的方法非常强大,并且经常被用来构建更有效的分类器。
理解集成学习
所有集成方法都是基于这样一个想法:通过结合多个较弱的学习者,可以创建一个更强的学习者。集成包含两个或多个机器学习模型,这些模型可以是同一类型的,例如多个决策树,也可以是不同类型的,例如决策树和神经网络。尽管构建集成有许多方法,但它们往往可以分为几个一般类别,这些类别在很大程度上可以通过回答两个问题来区分:
-
集成模型的模型是如何选择和训练的?
-
模型的预测是如何组合起来以做出一个单一最终预测的?
当回答这些问题时,想象集成如下流程图可能会有所帮助,它几乎涵盖了所有集成方法:

图 14.5:集成将多个较弱的模型组合成一个更强的模型
在这个设计模式中,输入训练数据被用来构建多个模型。分配函数决定了每个模型接收多少以及哪些训练数据子集。它们是否都接收完整的训练数据集,或者只是样本?它们是否都接收每个特征或特征子集?在这里做出的决定将塑造构成更强集成模型的较弱学习者的训练。
就像你在电视智力游戏节目中希望有一群各种专家来为你提供关于外表的建议一样,集成模型依赖于一组多样化的分类器,这意味着它们具有不相关的分类,但仍然比随机机会表现得更好。换句话说,每个分类器都必须做出独立的预测,但每个分类器也必须做的不只是猜测。
通过包括各种机器学习技术,如将决策树、神经网络和逻辑回归模型组合在一起的集成,可以增加集成的多样性。
或者,分配函数本身也可以通过充当数据操作器并人为地改变输入数据来偏置结果学习者,即使它们使用相同的学习算法,从而成为多样性的来源。正如我们稍后将在实践中看到的那样,分配和数据操作过程可能是自动化的,或者作为集成算法本身的一部分,或者它们可能是作为数据工程和模型构建过程的一部分手动执行的。总的来说,增加集成多样性的模式通常分为五类:
-
使用各种基础学习算法
-
通过随机抽取不同的样本来操纵训练样本,通常通过使用自助采样
-
通过使用不同的超参数设置来操纵单个学习算法
-
改变目标特征的表示方式,例如将结果表示为二元、分类或数值
-
将训练数据划分为代表不同学习模式的子组;例如,可以通过关键特征对示例进行分层,并让集成中的模型成为训练数据不同子集的专家
例如,在一个决策树集成中,分配函数可能使用自助采样为每棵树构建独特的训练数据集,或者它可能为每个算法传递不同的特征子集。另一方面,如果集成已经包含了一个多样化的算法集——例如神经网络、决策树和 k-NN 分类器——那么分配函数可能将训练数据相对不变地传递给每个算法。
在集成模型训练完成后,它们可以用来对未来数据进行预测,但必须以某种方式协调这组多个预测以生成一个单一的最终预测。组合函数是集成过程中的一个步骤,它将每个预测组合成一个权威的单一预测。当然,由于一些模型可能在预测值上存在分歧,该函数必须以某种方式混合或统一学习者的信息。组合函数也被称为作曲家,因为它的工作是综合最终预测。
合并或组合最终预测有两种主要策略。其中较简单的方法涉及加权方法,它为每个预测分配一个分数,该分数决定了它在最终预测中的重要性。这些方法从简单的多数投票(其中每个分类器被平等加权)到更复杂的基于性能的方法,后者如果某些模型在过去的证据上证明比其他模型更可靠,则赋予某些模型比其他模型更多的权威。
第二种方法使用更复杂的元学习技术,例如模型堆叠技术,这些内容将在本章后面进行深入探讨。这些方法使用弱学习器的初始预测集来训练一个二级机器学习算法以进行最终预测——这个过程类似于一个委员会向做出最终决策的领导者提供建议。
集成方法用于获得比仅使用单个学习算法更好的性能——集成的主要目标是把一群较弱的学习者转变为一个更强、更统一的团队。尽管如此,还有很多额外的优势,其中一些可能令人惊讶。这些表明了为什么人们可能会转向集成,即使在机器学习竞赛环境之外:
-
独立集成的方法允许并行工作:分别训练独立的分类器意味着工作可以分配给多个人。这允许更快的迭代,并可能增加创造力。每个团队成员构建他们最好的模型,最终的结果可以很容易地组合成一个集成。
-
在大型或小型数据集上提高性能:当使用大量特征或示例时,许多算法会遇到内存或复杂度限制。一个独立的模型集可以输入特征或示例的子集,这些子集比单个完整模型更容易训练,并且重要的是,通常可以使用分布式计算方法并行运行。在另一端,集成在最小数据集上也做得很好,因为重采样方法(如自助法)是许多集成设计分配函数的固有部分。
-
能够综合来自不同领域的数据:由于没有一种适合所有情况的算法,每个学习算法都有自己的偏见和启发式方法,因此集成能够结合来自多种类型学习者的证据,对于建模最具有挑战性的学习任务(依赖于来自不同领域的数据)变得越来越重要。
-
对困难学习任务的更细致的理解:现实世界现象通常非常复杂,有许多相互作用的复杂性。像集成这样的方法,将任务分解成更小的建模部分,更有可能捕捉到单个模型可能错过的微妙模式。集合中的某些学习者可以更窄、更深入地学习最具挑战性的案例的特定子集。
如果你不能轻松地在 R 中应用集成方法,那么这些好处将不会非常有帮助,而且有许多包可以做到这一点。让我们看看几种最流行的集成方法以及它们如何帮助我们提高一直在工作的信用模型的性能。
流行的基于集成的方法
幸运的是,使用机器学习团队来提高预测性能并不意味着你需要手动分别训练每个集成成员,尽管这个选项是存在的,正如你将在本章后面学到的那样。相反,有一些基于集成的算法可以操纵分配函数,以单步自动训练大量简单的模型。这样,包含一百个或更多学习者的集成可以在不比训练单个学习者更多的时间和输入的情况下进行训练。就像一个人可能构建一个单一的决策树模型一样,可以构建一个包含数百个此类树木的集成,并利用团队合作的力量。虽然这可能会让人联想到这是一个神奇的子弹,但这种力量当然也伴随着一些缺点,比如可解释性的损失和选择基础算法的多样性较少。这一点将在接下来的章节中变得明显,这些章节涵盖了二十年来流行的集成算法的演变——所有这些算法都不是巧合地基于决策树。
Bagging
最早获得广泛认可的集成方法之一使用了一种称为 自助聚合 或简称为 Bagging 的技术。正如 Leo Breiman 在 1990 年代中期所描述的,Bagging 首先通过在原始训练数据上使用自助采样生成几个新的训练数据集。然后,使用单个学习算法使用这些数据集生成一组模型。模型的预测通过分类投票和数值预测的平均值进行组合。
关于 Bagging 的更多信息,请参阅 Bagging predictors. Breiman L., Machine Learning, 1996, Vol. 24, pp. 123-140。
虽然 Bagging 是一种相对简单的集成方法,但如果与相对不稳定的学习者一起使用,即那些在输入数据仅发生微小变化时模型倾向于发生实质性变化的学习者,它可以表现得相当好。不稳定的模型对于确保集成在自助训练数据集的微小变化中保持多样性是必不可少的。
因此,Bagging 最常与决策树一起使用,因为决策树倾向于在输入数据发生微小变化时发生显著变化。
ipred 包提供了一个经典的袋装决策树的实现。为了训练模型,bagging() 函数的工作方式与之前使用的许多模型类似。nbagg 参数用于控制参与集成的决策树数量,默认值为 25。根据学习任务的难度和训练数据量,增加这个数量可能会提高模型性能,但有一个上限。缺点是这会增加额外的计算开销,大量树木的训练可能需要一些时间。
安装 ipred 包后,我们可以创建集成如下。我们将坚持默认的 25 个决策树值:
> library(ipred)
> credit <- read.csv("credit.csv", stringsAsFactors = TRUE)
> set.seed(123)
> mybag <- bagging(default ~ ., data = credit, nbagg = 25)
结果的mybag模型与predict()函数协同工作,如预期:
> credit_pred <- predict(mybag, credit)
> table(credit_pred, credit$default)
credit_pred no yes
no 699 4
yes 1 296
根据前面的结果,模型似乎与数据拟合得非常好——可能太好了,因为结果仅基于训练数据,因此可能反映了过度拟合而不是对未来未见数据的真实性能。为了获得对未来性能的更好估计,我们可以使用caret包中的袋装决策树方法来获得 10 折交叉验证的准确性和 kappa 估计。请注意,ipred袋装函数的方法名称为treebag:
> library(caret)
> credit <- read.csv("credit.csv")
> set.seed(300)
> ctrl <- trainControl(method = "cv", number = 10)
> train(default ~ ., data = credit, method = "treebag",
trControl = ctrl)
Bagged CART
1000 samples
16 predictor
2 classes: 'no', 'yes'
No pre-processing
Resampling: Cross-Validated (10 fold)
Summary of sample sizes: 900, 900, 900, 900, 900, 900, ...
Resampling results:
Accuracy Kappa
0.732 0.3319334
该模型的 kappa 统计量为 0.33,表明袋装树模型的表现大致与我们在本章早期调整的 C5.0 决策树相当,其 kappa 统计量在 0.32 到 0.34 之间变化,具体取决于调整参数。在阅读下一节时,请记住这一性能,并考虑简单袋装技术与在此基础上构建的更复杂方法之间的差异。
提升法
另一种常见的基于集合的方法被称为提升法,因为它提高了或“提升”了弱学习者的性能,以达到强学习者的性能。这种方法主要基于 Robert Schapire 和 Yoav Freund 的工作,自 1990 年代以来,他们在该主题上发表了大量论文。
如需有关提升法的更多信息,请参阅《提升法:基础与算法》,Schapire RE,Freund Y,剑桥,MA:麻省理工学院出版社,2012。
与袋装法类似,提升法使用在重采样数据上训练的模型集合,并通过投票来确定最终预测。有两个关键的区别。首先,提升法中的重采样数据集是专门构建的,以生成互补的学习者。这意味着工作不能并行进行,因为集合中的模型不再是相互独立的。其次,提升法并不是给每个学习者平等的投票,而是根据其过去的性能给每个学习者一个加权投票。表现更好的模型对集合的最终预测有更大的影响力。
提升法的结果通常略好,但绝对不差于集合中最好的模型。由于集合中的模型是故意构建为互补的,因此只需向该组添加额外的分类器,假设每个额外的分类器都比随机机会表现更好,就可以通过添加额外的分类器来提高集合的性能到一个任意阈值。鉴于这一发现的明显效用,提升法被认为是机器学习中最重大的发现之一。
尽管增强可以创建一个满足任意低错误率的模型,但在实践中这并不总是合理的。原因之一是,随着更多学习者的加入,性能增益逐渐减小,使得某些阈值实际上不可行。此外,追求纯粹的准确性可能导致模型过度拟合训练数据,无法推广到未见过的数据。
一种名为AdaBoost的增强算法,全称为自适应增强,由 Freund 和 Schapire 于 1997 年提出。该算法基于生成弱学习者的想法,通过迭代地学习训练数据中难以分类的样本的更大部分,通过对经常被错误分类的样本给予更多关注(即给予更多权重)来实现。
从一个无权重的数据集开始,第一个分类器试图模拟结果。分类器预测正确的示例不太可能出现在下一个分类器的训练数据集中,反之亦然,难以分类的示例将更频繁地出现。随着更多弱学习者的加入,它们将在具有越来越困难示例的数据上训练。这个过程一直持续到达到所需的总体错误率或性能不再提高。在此之后,每个分类器的投票将根据其在构建时的训练数据上的准确性进行加权。
虽然增强原理可以应用于几乎任何类型的模型,但这些原理最常与决策树一起使用。我们已经在本章前面以及第五章,分而治之 - 使用决策树和规则进行分类中应用了增强技术,作为一种提高 C5.0 决策树性能的方法。在 C5.0 中,只需将trials参数设置为大于一的整数值即可启用增强。
AdaBoost.M1算法为分类提供了 AdaBoost 的独立实现。该算法可在adabag包中找到。
关于adabag包的更多信息,请参阅adabag:用于分类的增强和 Bagging 的 R 包,Alfaro, E, Gamez, M, Garcia, N, 统计软件杂志,2013,第 54 卷,第 1-35 页。
让我们为信用数据创建一个AdaBoost.M1分类器。此算法的一般语法与其他建模技术类似:
> library(adabag)
> credit <- read.csv("credit.csv", stringsAsFactors = TRUE)
> set.seed(300)
> m_adaboost <- boosting(default ~ ., data = credit)
通常,将predict()函数应用于结果对象以进行预测:
> p_adaboost <- predict(m_adaboost, credit)
与常规不同,它返回一个包含模型信息的对象,而不是返回一个预测的向量。预测存储在名为class的子对象中:
> head(p_adaboost$class)
[1] "no" "yes" "no" "no" "yes" "no"
可以在confusion子对象中找到混淆矩阵:
> p_adaboost$confusion
Observed Class
Predicted Class no yes
no 700 0
yes 0 300
在你对完美的准确率抱有希望之前,请注意,前面的混淆矩阵是基于模型在训练数据上的性能。由于提升允许错误率降低到任意低水平,学习器简单地继续直到不再犯错误。这可能导致在训练数据集上的过度拟合。
为了对未见数据上的性能进行更准确的评估,我们需要使用另一种评估方法。adabag 包提供了一个简单的函数来使用 10 折交叉验证:
> set.seed(300)
> adaboost_cv <- boosting.cv(default ~ ., data = credit)
根据您计算机的能力,这可能需要一些时间来运行,在此期间,它将记录每次迭代的日志到屏幕上——在最新的 MacBook Pro 计算机上,大约需要一分钟。完成后,我们可以查看一个更合理的混淆矩阵:
> adaboost_cv$confusion
Observed Class
Predicted Class no yes
no 598 160
yes 102 140
我们可以使用 vcd 包找到卡方统计量,如第十章中所示,评估模型性能:
> library(vcd)
> Kappa(adaboost_cv$confusion)
value ASE z Pr(>|z|)
Unweighted 0.3397 0.03255 10.44 1.676e-25
Weighted 0.3397 0.03255 10.44 1.676e-25
在卡方值为0.3397的情况下,提升模型略优于卡方值约为0.3319的装袋决策树。让我们看看提升与另一种集成方法相比如何。
注意,先前结果是在 Windows PC 上使用 R 版本 4.2.3 获得的,并在 Linux 上进行了验证。在撰写本文时,使用 R 4.2.3 在最新的 MacBook Pro 上的 Apple 硅上获得的结果略有不同。此外,请注意,AdaBoost.M1 算法可以通过指定method = "AdaBoost.M1"来使用caret进行调整。
随机森林
另一种基于树的集成方法,称为随机森林,建立在装袋原则的基础上,但通过每次只允许算法从随机选择的特征子集中选择来为决策树增加额外的多样性。从根节点开始,随机森林算法可能只能从从完整预测器集合中随机选择的少量特征中选择;在每次后续分割时,提供不同的随机子集。与装袋类似,一旦生成树集成(森林),算法就会进行简单的投票来做出最终预测。
关于随机森林如何构建的更多详细信息,请参阅随机森林,Breiman L,机器学习,2001,第 45 卷,第 5-32 页。请注意,“随机森林”一词是 Breiman 和 Cutler 的商标,但通常用来指任何类型的决策树集成。一个严谨的人会使用更一般的术语决策树森林,除非在提及它们的特定实现时。
每棵树都是基于不同且随机选择的特征集构建的,这有助于确保集成中的每棵树都是独特的。甚至可能森林中的两棵树是从完全不同的特征集构建的。随机特征选择限制了决策树在每次生长时选择相同低垂果实的贪婪启发式方法,这可能有助于算法发现标准树增长方法可能错过的微妙模式。另一方面,由于每棵树在森林中只有一票,因此过拟合的可能性有限。
考虑到这些优点,随机森林算法迅速成为最受欢迎的学习算法之一——最近,其炒作已被一种新的集成方法所超越,你将在稍后了解该方法。随机森林将多功能性和强大功能结合为单一机器学习方法,并且不太容易过拟合或欠拟合。由于树增长算法仅使用整个特征集的一小部分随机样本,随机森林可以处理极其庞大的数据集,而所谓的维度诅咒可能会使其他模型失败。同时,它在大多数学习任务上的预测性能与最复杂的方法相当,甚至更好。以下表格总结了随机森林模型的优缺点:
| 优点 | 缺点 |
|---|
|
-
一种适用于大多数问题的通用模型,在分类和数值预测方面都表现出色
-
可以处理噪声或缺失数据,以及分类或连续特征
-
只选择最重要的特征
-
可以用于具有极多特征或示例的数据
|
-
与决策树不同,该模型不易解释
-
可能难以处理具有非常大量级别的分类特征
-
如果需要更高的性能,则无法进行大量调整
|
它们的强大性能与易用性使得随机森林成为大多数现实世界机器学习项目的绝佳起点。该算法还为其他与高度调整的模型以及你稍后将要学习的其他更复杂方法进行了可靠的基准测试。
为了直观演示随机森林,我们将应用本章中使用的信用评分数据。尽管 R 中有几个包含随机森林实现的包,但名为randomForest的包可能是最简单的,而ranger包在大型数据集上提供了更好的性能。这两个包都由caret包支持,用于实验和自动参数调整。使用randomForest训练模型的语法如下:

图 14.6:随机森林语法
默认情况下,randomForest()函数创建了一个包含 500 棵决策树的集成,每棵树在每个分割点考虑sqrt(p)个随机特征,其中p是训练数据集中的特征数量,sqrt()指的是 R 的平方根函数。例如,由于信用数据有 16 个特征,所以每棵 500 棵决策树在算法尝试分割时只能考虑sqrt(16) = 4个预测因子。
这些默认的ntree和mtry参数是否合适取决于学习任务和训练数据的性质。一般来说,更复杂的学习问题和更大的数据集(包括更多的特征和更多的示例)需要更多的树,尽管这需要与训练更多树的计算成本相平衡。一旦将ntree参数设置为一个足够大的值,就可以调整mtry参数以确定最佳设置;然而,在实践中默认设置往往效果良好。假设树的数量足够大,在性能下降之前,随机选择特征的数量可以出奇地低——但尝试几个值仍然是好习惯。理想情况下,应该将树的数量设置得足够大,以便每个特征都有机会出现在几个模型中。
让我们看看默认的randomForest()参数如何与信用数据一起工作。我们将像对其他学习器所做的那样训练模型。像往常一样,set.seed()函数确保结果可以复制:
> library(randomForest)
> set.seed(300)
> rf <- randomForest(default ~ ., data = credit)
为了总结模型性能,我们可以简单地输入结果对象的名称:
> rf
Call:
randomForest(formula = default ~ ., data = credit)
Type of random forest: classification
Number of trees: 500
No. of variables tried at each split: 4
OOB estimate of error rate: 23.3%
Confusion matrix:
no yes class.error
no 638 62 0.08857143
yes 171 129 0.57000000
输出结果显示,随机森林包含 500 棵树,并在每个分割点尝试了四个变量,正如预期的那样。乍一看,根据混淆矩阵,你可能会对看似糟糕的性能感到惊讶——23.3%的错误率远高于迄今为止任何其他集成方法的重新替换错误。然而,这个混淆矩阵并没有显示重新替换错误。相反,它反映了袋外误差率(在输出中列为OOB estimate of error rate),与重新替换错误不同,它是对测试集误差的无偏估计。这意味着它应该是对未来性能的公平估计。
在随机森林构建过程中,使用一种巧妙的技术来计算袋外估计。本质上,任何未被选为单个树的自举样本的示例都可以用来测试模型在未见数据上的性能。在森林构建结束时,对于数据集中的每个 1,000 个示例,任何在训练中未使用该示例的树都可以进行预测。这些预测被汇总,并通过投票来确定该示例的单个最终预测。所有 1,000 个示例的这种预测的总错误率成为袋外错误率。因为每个预测只使用森林的子集,所以它不等同于真正的验证或测试集估计,但它是一个合理的替代品。
在第十章评估模型性能中提到,任何给定的示例有 63.2%的几率被包含在自举样本中。这意味着平均有 36.8%的随机森林中的 500 棵树在袋外估计中对每个 1,000 个示例进行了投票。
要在袋外预测上计算 kappa 统计量,我们可以使用vcd包中的函数如下。代码将Kappa()函数应用于confusion对象的前两行和两列,该对象存储了rf随机森林模型对象的袋外预测混淆矩阵:
> library(vcd)
> Kappa(rf$confusion[1:2,1:2])
value ASE z Pr(>|z|)
Unweighted 0.381 0.03215 11.85 2.197e-32
Weighted 0.381 0.03215 11.85 2.197e-32
在 kappa 统计量为0.381的情况下,随机森林是我们迄今为止表现最好的模型。其性能优于具有约0.332的 kappa 值的袋装决策树集成,以及具有约0.340的 kappa 值的 AdaBoost.M1 模型。
如前所述,ranger包是一个随机森林算法的实质性快速实现。对于像信用数据集这样小的数据集,优化计算效率可能不如易用性重要,默认情况下,ranger牺牲了一些便利性以提高速度并减少内存占用。因此,尽管ranger函数在语法上几乎与randomForest()相同,但在实践中,你可能会发现它破坏了现有的代码或需要查阅帮助页面。
要使用ranger重新创建先前的模型,我们只需更改函数名:
> library(ranger)
> set.seed(300)
> m_ranger <- ranger(default ~ ., data = credit)
结果模型具有相当相似的袋外预测错误:
> m_ranger
Ranger result
Call:
ranger(default ~ ., data = credit)
Type: Classification
Number of trees: 500
Sample size: 1000
Number of independent variables: 16
Mtry: 4
Target node size: 1
Variable importance mode: none
Splitrule: gini
OOB prediction error: 23.10 %
我们可以像以前一样计算 kappa 值,同时注意模型混淆矩阵子对象命名的细微差别:
> Kappa(m_ranger$confusion.matrix)
value ASE z Pr(>|z|)
Unweighted 0.381 0.0321 11.87 1.676e-32
Weighted 0.381 0.0321 11.87 1.676e-32
kappa 值为0.381,与早期随机森林模型的结果相同。请注意,这是巧合,因为这两个算法不保证产生相同的结果。
与 AdaBoost 一样,先前的结果是在 Windows PC 上的 R 版本 4.2.3 上获得的,并在 Linux 上进行了验证。在撰写本文时,使用 R 4.2.3 在最新的 MacBook Pro 上的 Apple 硅上获得的结果略有不同。
梯度提升
梯度提升是基于以下发现的提升算法的演变:可以将提升过程视为一个可以使用梯度下降技术解决的优化问题。我们首次在第七章,黑盒方法——神经网络和支持向量机中遇到梯度下降,它被介绍为优化神经网络权重的一种解决方案。您可能还记得,成本函数——本质上,预测误差——将输入值与目标相关联。然后,通过系统地分析权重变化如何影响成本,可以找到最小化成本的一组权重。梯度提升以类似的方式处理提升过程,将集成中的弱学习器视为需要优化的参数。使用这种技术的模型被称为梯度提升机或广义提升模型——两者都可以缩写为GBMs。
更多关于 GBMs 的信息,请参阅Greedy Function Approximation: A Gradient Boosting Machine, Friedman JH, 2001, Annals of Statistics 29(5):1189-1232。
以下表格总结了 GBMs 的优缺点。简而言之,梯度提升非常强大,可以产生一些最精确的模型,但可能需要调整以找到过拟合和欠拟合之间的平衡。
| 优点 | 缺点 |
|---|
|
-
一种通用的分类器,可以在分类和数值预测上表现出色。
-
可以实现比随机森林更好的性能。
-
在大型数据集上表现良好。
|
-
可能需要调整以匹配随机森林算法的性能,并且需要更广泛的调整才能超越其性能。
-
因为有多个超参数需要调整,找到最佳组合需要多次迭代和更多的计算能力。
|
我们将使用gbm()函数在gbm包中创建用于分类和数值预测的 GBMs。如果您还没有安装和加载此包到 R 会话中,您需要这样做。如下方框所示,语法类似于之前使用的机器学习函数,但它有几个可能需要调整的新参数。这些参数控制模型的复杂性和过拟合与欠拟合之间的平衡。未经调整,GBM 可能不如简单方法表现得好,但一旦参数值被优化,它通常可以超越大多数其他方法的性能。

图 14.7:梯度提升机(GBM)语法
我们可以训练一个简单的 GBM 模型,在credit数据集上预测贷款违约,如下所示。为了简化,我们将stringsAsFactors = TRUE设置为避免重新编码预测变量,但随后必须将目标default特征转换回二进制结果,因为gbm()函数需要这个二进制分类。我们将创建一个用于训练和测试的随机样本,然后将gbm()函数应用于训练数据,参数设置为默认值:
> credit <- read.csv("credit.csv", stringsAsFactors = TRUE)
> credit$default <- ifelse(credit$default == "yes", 1, 0)
> set.seed(123)
> train_sample <- sample(1000, 900)
> credit_train <- credit[train_sample, ]
> credit_test <- credit[-train_sample, ]
> library(gbm)
> set.seed(300)
> m_gbm <- gbm(default ~ ., data = credit_train)
输入模型的名称会提供一些关于 GBM 过程的基本信息:
> m_gbm
gbm(formula = default ~ ., data = credit_train)
A gradient boosted model with 59 bernoulli loss function.
100 iterations were performed.
There were 16 predictors of which 14 had non-zero influence.
更重要的是,我们可以在测试集上评估模型。请注意,我们需要将预测转换为二进制,因为它们是以概率给出的。如果贷款违约的概率大于 50%,我们将预测违约,否则,我们预测非违约。表格显示了预测值和实际值之间的协议:
> p_gbm <- predict(m_gbm, credit_test, type = "response")
> p_gbm_c <- ifelse(p_gbm > 0.50, 1, 0)
> table(credit_test$default, p_gbm_c)
p_gbm_c
1
0 60 5
1 21 14
为了衡量性能,我们将Kappa()函数应用于此表:
> library(vcd)
> Kappa(table(credit_test$default, p_gbm_c))
value ASE z Pr(>|z|)
Unweighted 0.3612 0.09529 3.79 0.0001504
Weighted 0.3612 0.09529 3.79 0.0001504
得到的 kappa 值约为0.361,比使用提升决策树得到的好,但比随机森林模型差。也许经过一点调整,我们可以将其提高。
我们将使用caret包来调整 GBM 模型,以获得更稳健的性能指标。回想一下,调整需要搜索网格,我们可以为 GBM 定义如下网格。这将测试gbm()函数三个参数的三个值以及剩余参数的一个值,从而得到3 * 3 * 3 * 1 = 27个模型进行评估:
> grid_gbm <- expand.grid(
n.trees = c(100, 150, 200),
interaction.depth = c(1, 2, 3),
shrinkage = c(0.01, 0.1, 0.3),
n.minobsinnode = 10
)
接下来,我们将trainControl对象设置为从 10 折交叉验证实验中选择最佳模型:
> library(caret)
> ctrl <- trainControl(method = "cv", number = 10,
selectionFunction = "best")
最后,我们读取credit数据集,并将所需的对象提供给caret()函数,同时指定gbm方法和Kappa性能指标。根据您计算机的能力,这可能需要几分钟才能运行:
> credit <- read.csv("credit.csv", stringsAsFactors = TRUE)
> set.seed(300)
> m_gbm_c <- train(default ~ ., data = credit, method = "gbm",
trControl = ctrl, tuneGrid = grid_gbm,
metric = "Kappa",
verbose = FALSE)
输入对象的名称会显示实验结果。请注意,为了简洁,省略了一些输出行,但完整的输出包含 27 行——每个评估的模型一行:
> m_gbm_c
Stochastic Gradient Boosting
1000 samples
16 predictor
2 classes: 'no', 'yes'
No pre-processing
Resampling: Cross-Validated (10 fold)
Summary of sample sizes: 900, 900, 900, 900, 900, 900, ...
Resampling results across tuning parameters:
shrinkage interaction.depth n.trees Accuracy Kappa
0.10 1 100 0.737 0.269966697
0.10 1 150 0.738 0.295886773
0.10 1 200 0.742 0.320157816
0.10 2 100 0.747 0.327928587
0.10 2 150 0.750 0.347848347
0.10 2 200 0.759 0.380641164
0.10 3 100 0.747 0.342691964
0.10 3 150 0.748 0.356836684
0.10 3 200 0.764 0.394578005
Tuning parameter 'n.minobsinnode' was held constant at a value of 10
Kappa was used to select the optimal model using the largest value.
The final values used for the model were n.trees = 200,
interaction.depth = 3, shrinkage = 0.1 and n.minobsinnode = 10.
从输出中,我们可以看到最佳 GBM 模型的 kappa 值为0.394,超过了之前训练的随机森林。通过进一步的调整,可能将 kappa 值进一步提高。或者,正如您将在下一节中看到的,可以采用更加强化的提升形式来追求更好的性能。
基于 XGBoost 的极端梯度提升
XGBoost 算法(https://xgboost.ai)是梯度提升技术的尖端实现,它通过提高算法的效率和性能将提升技术推向了“极致”。自 2014 年算法推出以来,XGBoost 已经在许多机器学习竞赛的排行榜上名列前茅。事实上,根据算法作者的统计,在 2015 年的 Kaggle 上,29 个获胜方案中,共有 17 个使用了 XGBoost 算法。同样,在 2015 年的 KDD Cup(在第十一章用机器学习取得成功中描述),前 10 名获胜者都使用了 XGBoost。如今,该算法仍然是传统机器学习问题(涉及分类和数值预测)的冠军,而其最接近的挑战者,深度神经网络,往往只在非结构化数据(如图像、音频和文本处理)上获胜。
关于 XGBoost 的更多信息,请参阅 XGBoost: A Scalable Tree Boosting System, Chen T and Guestrin C, 2016。arxiv.org/abs/1603.02754.
XGBoost 算法的强大功能伴随着算法并不那么容易使用,并且比迄今为止考察的其他方法需要更多的调整。另一方面,其性能上限往往高于任何其他方法。XGBoost 的优点和缺点如下表所示:
| 优点 | 缺点 |
|---|
|
-
一个通用的分类器,能够在分类和数值预测上表现出色
-
在传统学习问题上的性能可能是无争议的冠军;在结构化数据上的几乎每个机器学习竞赛中都能获胜
-
高度可扩展,在大数据集上表现良好,可以在分布式计算平台上并行运行
|
-
比其他函数更难使用,因为它依赖于不使用原生 R 数据结构的外部框架
-
需要对大量超参数进行广泛的调整,这些超参数在没有强大数学背景的情况下可能难以理解
-
因为有很多调整参数,找到最佳组合需要多次迭代和更多的计算能力
-
导致一个“黑盒”模型,没有解释性工具几乎无法解释
|
为了应用算法,我们将使用xgboost包中的xgboost()函数,它为 XGBoost 框架提供了 R 接口。关于这个框架,可以写出整本书,因为它包括了许多类型机器学习任务的功能,并且具有高度的可扩展性和适应性强,适用于许多高性能计算环境。有关 XGBoost 框架的更多信息,请参阅网络上的优秀文档xgboost.readthedocs.io。我们的工作将专注于其功能的一个狭窄部分,如下面的语法框所示,由于复杂性和可能调整的超参数的大量增加,它比其他算法的语法要密集得多:

图 14.8:XGBoost(XGB)语法
使用 R 中的 XGBoost 的一个挑战是它需要使用矩阵格式而不是 R 首选的 tibbles 或数据框格式。因为 XGBoost 是为极大数据集设计的,它也可以使用稀疏矩阵,如前几章中讨论的。你可能记得,稀疏矩阵只存储非零值,当许多特征值为零时,这使得它比传统矩阵更节省内存。
矩阵形式的数据通常是稀疏的,因为因素通常在数据框和矩阵之间的转换过程中进行 one-hot 或虚拟编码。这些编码为因素级别的额外级别创建了额外的列,除了表示给定示例级别的“热”值之外,所有列都设置为 0。在虚拟编码的情况下,转换中省略了一个特征级别,因此它比 one-hot 少一个列;缺失的级别可以通过所有n - 1列中存在零来表示。
One-hot 编码和虚拟编码通常会产生相同的结果,但有一个例外,即基于统计的模型如回归需要虚拟编码,如果使用 one-hot 编码,则会显示错误或警告信息。
让我们从读取credit.csv文件并从credit数据框创建一个稀疏矩阵开始。Matrix包提供了一个执行此任务的功能,它使用 R 公式接口来确定矩阵中要包含的列。在这里,公式~ . -default告诉函数使用除了default之外的所有特征,因为我们不希望在矩阵中包含这个特征,因为这是我们预测的目标特征:
> credit <- read.csv("credit.csv", stringsAsFactors = TRUE)
> library(Matrix)
> credit_matrix <- sparse.model.matrix(~ . -default, data = credit)
为了确认我们的工作,让我们检查矩阵的维度:
> dim(credit_matrix)
[1] 1000 36
我们仍然有 1,000 行,但列数从原始数据框中的 16 个特征增加到稀疏矩阵中的 36 个。这是由于在转换为矩阵形式时自动应用的虚拟编码。我们可以使用print()函数检查稀疏矩阵的前五行和 15 列来看到这一点:
> print(credit_matrix[1:5, 1:15])
5 x 15 sparse Matrix of class "dgCMatrix"
[[ suppressing 15 column names '(Intercept)', 'checking_balance> 200 DM', 'checking_balance1 - 200 DM' ... ]]
1 1 . . . 6 . . . . . . . 1 . 1169
2 1 . 1 . 48 1 . . . . . . 1 . 5951
3 1 . . 1 12 . . . . . . 1 . . 2096
4 1 . . . 42 1 . . . . . . 1 . 7882
5 1 . . . 24 . . 1 . 1 . . . . 4870
矩阵用点(.)字符表示零值单元格。第一列(1, 2, 3, 4, 5)是行号,第二列(1, 1, 1, 1, 1)是截距项的列,这是由 R 公式接口自动添加的。两列有数字(6, 48, …)和(1169, 5951, …),分别对应于months_loan_duration和amount特征的数值。所有其他列都是因子变量的虚拟编码版本。例如,第三、第四和第五列反映了checking_balance特征,第三列中的1表示'> 200 DM'的值,第四列中的1表示'1 – 200 DM',第五列中的1表示'unknown'特征值。在第三、第四和第五列中显示序列...的行属于参考类别,这是'< 0 DM'特征级别。
由于我们不是构建回归模型,所以充满1值的截距列对于这次分析是无用的,可以从矩阵中移除:
> credit_matrix <- credit_matrix[, -1]
接下来,我们将矩阵随机分割成训练集和测试集,使用之前 90-10 的分割比例:
> set.seed(12345)
> train_ids <- sample(1000, 900)
> credit_train <- credit_matrix[train_ids, ]
> credit_test <- credit_matrix[-train_ids, ]
为了确认工作是否正确完成,我们将检查这些矩阵的维度:
> dim(credit_train)
[1] 900 35
> dim(credit_test)
[1] 100 35
如预期,训练集有 900 行和 35 列,测试集有 100 行和与之匹配的列数。
最后,我们将为default目标创建训练和测试标签向量。这些标签通过ifelse()函数从因子转换为二进制1或0值,以便可以分别用于训练和评估 XGBoost 模型:
> credit_train_labels <-
ifelse(credit[train_ids, c("default")] == "yes", 1, 0)
> credit_test_labels <-
ifelse(credit[-train_ids, c("default")] == "yes", 1, 0)
现在我们已经准备好开始构建模型。在安装了xgboost包之后,我们将加载库并开始定义训练的超参数。由于不知道从哪里开始,我们将值设置为默认值:
> library(xgboost)
> params.xgb <- list(objective = "binary:logistic",
max_depth = 6,
eta = 0.3,
gamma = 0,
colsample_bytree = 1,
min_child_weight = 1,
subsample = 1)
接下来,在设置随机种子之后,我们将训练模型,提供我们的参数对象以及训练数据矩阵和目标标签。nrounds参数决定了提升迭代的次数。由于没有更好的猜测,我们将此设置为100,这是一个常见的起点,因为经验证据表明结果在此值之后很少有所改善。最后,verbose和print_every_n选项用于开启诊断输出,并在每 10 次提升迭代后显示进度:
> set.seed(555)
> xgb_credit <- xgboost(params = params.xgb,
data = credit_train,
label = credit_train_labels,
nrounds = 100,
verbose = 1,
print_every_n = 10)
完成训练后,输出应该显示所有 100 次迭代都已发生,训练错误(标记为train-logloss)在额外的提升轮次中继续下降:
[1] train-logloss:0.586271
[11] train-logloss:0.317767
[21] train-logloss:0.223844
[31] train-logloss:0.179252
[41] train-logloss:0.135629
[51] train-logloss:0.108353
[61] train-logloss:0.090580
[71] train-logloss:0.077314
[81] train-logloss:0.065995
[91] train-logloss:0.057018
[100] train-logloss:0.050837
了解额外的迭代是否有助于提高模型性能或导致过拟合是我们可以在调整后确定的事情。在这样做之前,让我们看看这个训练模型在之前保留的测试集上的表现。首先,predict() 函数获取测试数据每行的贷款违约预测概率:
> prob_default <- predict(xgb_credit, credit_test)
然后,我们使用 ifelse() 函数来预测违约(值为 1)的概率至少为 0.50,否则预测非违约(值为 0):
> pred_default <- ifelse(prob_default > 0.50, 1, 0)
将预测值与实际值进行比较,我们发现准确率为 (62 + 14) / 100 = 76 百分比:
> table(pred_default, credit_test_labels)
credit_test_labels
pred_default 0 1
0 62 13
1 11 14
另一方面,kappa 统计量表明仍有改进的空间:
> library(vcd)
> Kappa(table(pred_default, credit_test_labels))
value ASE z Pr(>|z|)
Unweighted 0.3766 0.1041 3.618 0.0002967
Weighted 0.3766 0.1041 3.618 0.0002967
0.3766 的值略低于我们使用 GBM 模型获得的 0.394,因此可能需要一点超参数调整来帮助。为此,我们将使用 caret,从包含每个超参数各种选项的调整网格开始:
> grid_xgb <- expand.grid(
eta = c(0.3, 0.4),
max_depth = c(1, 2, 3),
colsample_bytree = c(0.6, 0.8),
subsample = c(0.50, 0.75, 1.00),
nrounds = c(50, 100, 150),
gamma = c(0, 1),
min_child_weight = 1
)
生成的网格包含 2 * 3 * 2 * 3 * 3 * 2 * 1 = 216 种不同的 xgboost 超参数值组合。我们将像对其他模型所做的那样,在 caret 中使用 10 折交叉验证来评估这些潜在模型。请注意,verbosity 参数设置为零,以便抑制 xgboost() 函数输出的多次迭代:
> library(caret)
> ctrl <- trainControl(method = "cv", number = 10,
selectionFunction = "best")
> credit <- read.csv("credit.csv", stringsAsFactors = TRUE)
> set.seed(300)
> m_xgb <- train(default ~ ., data = credit, method = "xgbTree",
trControl = ctrl, tuneGrid = grid_xgb,
metric = "Kappa", verbosity = 0)
根据你电脑的能力,实验可能需要几分钟才能完成,但一旦完成,输入 m_xgb 将提供所有 216 个测试模型的成果。我们也可以直接获得最佳模型,如下所示:
> m_xgb$bestTune
nrounds max_depth eta gamma colsample_bytree
50 3 0.4 1 0.6
min_child_weight subsample
1
使用 max() 函数可以找到这个模型的最大值,从而找到该模型的 kappa 值如下:
> max(m_xgb$results["Kappa"])
[1] 0.4062946
0.406 的 kappa 值是我们迄今为止表现最好的模型,超过了 GBM 模型的 0.394 和随机森林的 0.381。XGBoost 在经过一点微调后仍然需要如此少的努力就能超越其他强大的技术,这提供了为什么它似乎总是赢得机器学习竞赛的原因。然而,通过更多的调整,我们可能还能进一步提高!将这个作为留给读者你的练习,我们现在将注意力转向为什么所有这些流行的集成方法似乎都专注于基于决策树的方法。
为什么基于树的集成方法如此受欢迎?
在阅读了前面的章节之后,你不会是第一个想知道为什么集成算法似乎总是建立在决策树之上的。尽管构建集成算法不需要树,但有几个原因说明它们特别适合这个过程。你可能已经注意到了其中的一些:
-
集成方法与多样性结合得最好,因为决策树对数据中的微小变化不稳健,随机采样相同的训练数据可以轻松创建一组基于树的模型
-
由于基于贪婪的“分而治之”算法,决策树在计算上效率高,尽管如此,其表现相对较好
-
决策树可以根据需要有意地生长得很大或很小,以过度拟合和欠拟合
-
决策树可以自动忽略无关特征,从而减少“维度诅咒”的负面影响
-
决策树不仅可以用于数值预测,还可以用于分类
基于这些特性,不难看出我们为何拥有如此丰富的基于树的集成方法,如 bagging、boosting 和随机森林。它们之间的区别细微但很重要。
以下表格可能有助于对比本章所涵盖的基于树的集成算法:
| 集成算法 | 分配函数 | 组合函数 | 其他说明 |
|---|---|---|---|
| Bagging | 为每个学习者提供一个训练数据的自助样本 | 学习者通过投票进行分类或通过加权平均进行数值预测进行组合 | 使用独立的集成——学习者可以并行运行 |
| Boosting | 第一个学习者得到一个随机样本;后续样本被加权以包含更多难以预测的案例 | 学习者的预测按照上述方法组合,但根据它们在训练数据上的表现进行加权 | 使用依赖集成——序列中的每棵树都接收早期树发现具有挑战性的数据 |
| 随机森林 | 与 bagging 类似,每棵树都接收训练数据的自助样本;然而,对于每棵树的分裂,也会随机选择特征 | 类似于 bagging | 类似于 bagging,但通过随机特征选择增加的多样性为更大的集成提供了额外的优势 |
| 梯度提升机(GBM) | 概念上类似于 boosting | 类似于 boosting,但学习者更多,它们构成了一个复杂的数学函数 | 使用梯度下降来制作更高效的 boosting 算法;树通常不是很深(决策树“桩”),但数量更多;需要更多调整 |
| 极端梯度提升(XGB) | 类似于 GBM | 类似于 GBM | 类似于 GBM 但更极端;使用优化的数据结构、并行处理和启发式方法来创建非常高效的 boosting 算法;调整是必要的 |
能够区分这些方法,表明了对集成几个方面的深刻理解。此外,最新的技术,如随机森林和梯度提升,是性能最好的学习算法之一,并被用作现成的解决方案来解决一些最具挑战性的商业问题。这可能有助于解释为什么雇佣数据科学家和机器学习工程师的公司通常要求候选人在面试过程中描述或比较这些算法。因此,尽管基于树的集成算法不是机器学习的唯一方法,但了解它们的潜在用途是很重要的。然而,正如下一节所描述的,树并不是构建多样化集成的唯一方法。
为元学习堆叠模型
与使用诸如 bagging、boosting 或随机森林等现成的集成方法相比,在某些情况下,采用定制的集成方法是有必要的。尽管这些基于树的集成技术将数百甚至数千个学习器组合成一个单一、更强的学习器,但这个过程与传统机器学习算法的训练并没有太大的不同,并且存在一些相同的局限性,尽管程度较轻。基于弱训练和最小调优的决策树,在某些情况下,可能会对集成性能设置一个上限,相对于由更多样化的学习算法组成的集成,这些算法在人类智能的帮助下进行了广泛的调优。此外,尽管可以并行化基于树的集成,如随机森林和 XGB,但这只并行化了计算机的努力——而不是模型构建的人类努力。
事实上,通过不仅添加额外的学习算法,还将模型构建的工作分配给并行工作的额外人类团队,可以增加集成的多样性。实际上,世界上许多获奖的模型都是通过采用其他团队的最佳模型并将它们集成在一起来构建的。
这种集成在概念上非常简单,提供了在其他情况下无法获得的性能提升,但在实践中可能会变得复杂。正确实现实现细节至关重要,以避免灾难性的过拟合水平。如果正确执行,集成将至少与集成中最强的模型一样好,通常要好得多。
检查接收者操作特征(ROC)曲线,如第十章评估模型性能中所述,提供了一种简单的方法来确定两个或多个模型是否可以从集成中受益。如果两个模型的 ROC 曲线相交,它们的凸包——通过围绕曲线拉紧一个想象中的橡皮筋所获得的边界——代表了一个假设的模型,可以通过插值或组合这些模型的预测来获得。如图 14.9 所示,两个具有相同曲线下面积(AUC)值0.70的 ROC 曲线,当在集成中配对时,可能会创建一个具有 AUC 为0.72的新模型:

图 14.9:当两个或多个 ROC 曲线相交时,它们的凸包代表一个潜在的更好分类器,可以通过组合它们的预测在集成中生成
由于这种集成形式主要是由人工完成的,因此需要人类为集成中的模型提供分配和组合函数。在最简单的形式中,这些函数可以相当实用地实现。例如,假设相同的训练数据集被分配给了三个不同的团队。这就是分配函数。这些团队可以使用这个数据集,按照他们认为合适的方式构建最佳模型,并使用他们选择的评估标准。
接下来,每个团队都会收到测试集,并使用他们的模型进行预测,这些预测必须组合成一个单一的最终预测。组合函数可以采取多种不同的形式:组可以投票,预测可以平均,或者根据每个组过去的表现进行加权。甚至随机选择一个组的方法也是一个可行的策略,前提是每个组至少偶尔表现得比其他所有组都要好。当然,还有更多更智能的方法是可能的,你很快就会学到。
理解模型堆叠和混合
一些最复杂的自定义集成将机器学习应用于学习最终预测的组合函数。本质上,它试图学习哪些模型可以信赖,哪些模型则不可信。这个仲裁学习器可能会意识到集成中的一个模型表现不佳,不应被信赖,或者另一个模型在集成中应该得到更多的权重。仲裁函数也可能学习更复杂的模式。例如,假设当模型 M1 和 M2 在结果上达成一致时,预测几乎总是准确的,但否则 M3 通常比这两个模型中的任何一个都要准确。在这种情况下,一个额外的仲裁模型可以学会忽略 M1 和 M2 的投票,除非他们达成一致。这个过程被称为堆叠,即使用多个模型的预测来训练一个最终模型。

图 14.10:堆叠是一种复杂的集成,它使用仲裁学习算法来组合一组学习者的预测并做出最终预测
更广泛地说,堆叠属于一种称为堆叠泛化的方法。根据正式定义,堆叠是通过使用通过交叉验证训练的一级模型构建的,以及使用超出折叠样本的预测来训练的二级模型或元模型。
例如,假设堆叠中包含三个一级模型,并且每个模型都使用 10 折交叉验证进行训练。如果训练数据集包括 1,000 行,那么每个一级模型在 900 行上训练,并在 100 行上测试十次。当这些 100 行的测试集合并时,构成了整个训练数据集。
由于所有三个模型都对训练数据中的每一行进行了预测,可以构建一个包含四列和 1,000 行的新的表格:前三个列代表三个模型的预测,第四列代表目标的真实值。请注意,由于对这 100 行中的每一行所做的预测都是在其他 900 行上做出的,因此所有 1,000 行都是对未见数据的预测。这允许第二阶段的元模型,通常是一个回归或逻辑回归模型,通过使用预测值作为真实值的预测因子来训练,从而学习哪些一级模型表现更好。这个过程有时被称为超级学习,而得到的模型可能被称为超级学习器。这个过程通常由机器学习软件或包执行,它们并行训练多个学习算法并将它们自动堆叠在一起。

图 14.11:在堆叠集成中,第二阶段的元模型或“超级学习器”从第一级模型对超出折叠样本的预测中学习
为了更实际的方法,一种称为混合或保留样本堆叠的堆叠泛化特殊案例提供了一种简化堆叠实现的方法,通过用保留样本替换交叉验证。这可以通过将训练数据分为一级模型的训练集和使用保留集作为二级元学习器的集合,使工作更容易地在团队之间分配。这也可能不太容易受到第十一章中描述的交叉验证“信息泄露”的影响,即在机器学习中取得成功。因此,尽管这是一个简单的方法,但它可以非常有效;混合通常是赢得比赛的团队在将其他模型组合在一起以获得更好的结果时所做的事情。
关于堆叠、混合和超级学习的术语有些模糊,许多人可以互换使用这些术语。
R 中混合和堆叠的实用方法
在 R 中执行混合操作需要一个详细的路线图,因为细节错误可能导致过度拟合,并且模型的表现不如随机猜测。以下图示说明了这个过程。首先,想象一下你被要求预测贷款违约,并且可以访问一百万行的历史数据。立即,你应该将数据集划分为训练集和测试集;当然,测试集应该被保存在保险库中,以便稍后评估集成。假设训练集有 75 万行,测试集有 25 万行。然后,必须再次将训练集划分为用于训练一级模型和二级元学习者的数据集。确切的比率有些任意,但通常使用较小的集来作为第二阶段模型——有时低至百分之十。如图 14.12 所示,我们可能使用 50 万行用于一级,25 万行用于二级:

图 14.12:完整的训练数据集必须划分为不同的子集,用于训练一级和二级模型
50 万行的第一级训练数据集被用来训练一级模型,正如我们在本书中多次做的那样。M1、M2和M3模型可以使用任何学习算法,构建这些模型的工作甚至可以分布到独立工作的不同团队中。
假设每个团队的特征工程流程可以在集成部署时被复制或自动化,模型或团队不需要使用训练数据中的相同特征集或相同的特征工程形式。重要的是,M1、M2和M3应该能够接受具有相同特征的训练数据集,并为每一行产生一个预测。
在经过它们各自的特征工程流程处理后,25 万行的二级训练数据集被输入到M1、M2和M3模型中,得到三个包含 25 万预测值的向量。这些向量在图中被标记为p1、p2和p3。当与从二级训练数据集中获得的 25 万真实目标值(图中标记为c)结合时,就生成了一个四列的数据框,如图 14.13 所示:

图 14.13:用于训练元模型的训练数据集由一级模型的预测值和二级训练数据的实际目标值组成
这种类型的数据框用于创建元模型,通常使用回归或逻辑回归,它使用M1、M2和M3的预测(图 14.12中的p1、p2和p3)作为预测因子来预测实际的目标值(图 14.12中的c)。在一个 R 公式中,这可能被指定为c ~ p1 + p2 + p3的形式,这会导致一个模型,它根据来自三个不同预测的输入来做出自己的最终预测。
为了估计这个最终元模型未来的性能,我们必须使用 250,000 行的测试集,正如之前在图 14.12中所示,这个测试集在训练过程中被保留出来。如图图 14.14所示,测试数据集随后被输入到M1、M2和M3模型及其相关的特征工程管道中,并且与之前的步骤类似,获得了三个包含 250,000 个预测的向量。然而,与p1、p2和p3用于训练元模型不同,现在它们被用作现有元模型的预测因子,以获得每个 250,000 个测试案例的最终预测(标记为p4)。这个向量可以与测试集中目标值的 250,000 个真实值进行比较,以执行性能评估并获得集成未来性能的无偏估计。

图 14.14:为了获得集成未来性能的无偏估计,测试集被用来为第一级模型生成预测,然后这些预测被用来获得元模型的最终预测
上述方法灵活,可以创建其他有趣类型的集成。图 14.15展示了结合了在不同特征子集上训练的模型的混合集成。具体来说,它设想了一个学习任务,其中使用 Twitter 个人资料数据来预测用户——可能是他们的性别或他们是否会对购买特定产品感兴趣:

图 14.15:堆栈的第一级模型可以在训练集的不同特征上训练,而第二级模型则在其预测上训练
第一个模型接收个人资料的图片,并使用图像数据训练一个深度学习神经网络来预测结果。第二个模型接收用户的推文集合,并使用基于文本的模型如朴素贝叶斯来预测结果。最后,第三个模型是一个更传统的模型,使用传统的数据框,如位置、推文总数、最后登录日期等人口统计数据。
这三个模型被组合起来,元模型可以学习图像、文本或个人资料数据对于预测性别或购买行为最有帮助。或者,因为元模型是一个类似于M3的逻辑回归模型,可以直接将个人资料数据提供给第二阶段模型,从而完全跳过M3的构建。
除了像这里描述的那样手工构建混合集成之外,还有越来越多的 R 包可以帮助这个过程。caretEnsemble包可以帮助使用caret包训练的集成模型,并确保堆叠的采样正确处理。SuperLearner包提供了一种创建超级学习器简单的方法;它可以对同一数据集应用数十种基础算法,并自动将它们堆叠在一起。作为一种现成的算法,这可能有助于以最少的努力构建一个强大的集成。
摘要
在阅读本章之后,你现在应该知道用于赢得数据挖掘和机器学习竞赛的方法。自动化调整方法可以帮助从单个模型中榨取每一丝性能。另一方面,通过创建称为集成的一组机器学习模型,可以实现巨大的进步,这些模型协同工作,其性能优于单独工作的单个模型。包括随机森林和梯度提升在内的各种基于树的算法提供了集成的优势,但可以像单个模型一样轻松训练。另一方面,学习者可以通过手工堆叠或混合到集成中,这允许方法被仔细调整以适应学习问题。
在众多提高模型性能的选项中,我们应该从哪里开始呢?没有一种唯一最佳的方法,但从业者往往会落入三个阵营之一。首先,一些人会从更复杂的集成方法开始,例如随机森林或 XGBoost,并将大部分时间用于调整和特征工程,以实现该模型可能达到的最高性能。第二组人可能会尝试多种方法,然后将模型收集到一个单一的堆叠或混合集成中,以创建一个更强大的学习器。第三种方法可以描述为“把所有东西都扔给计算机,看看什么能粘住。”这种方法试图尽可能快地给学习算法提供尽可能多的数据,有时还会结合前几章中描述的自动化特征工程或降维技术。随着实践,你可能会对某些想法比对其他想法更感兴趣,所以请随意使用对你来说效果最好的方法。
尽管本章旨在帮助你准备竞赛所需的模型,请注意你的竞争对手也能接触到同样的技术。你不能仅仅依靠停滞不前;因此,继续将专有方法添加到你的技巧包中。也许你可以带来独特的专业知识,或者也许你的优势包括在数据准备中对细节的关注。无论如何,熟能生巧,所以利用竞赛来测试、评估和提升你的机器学习技能集。在本书的下一章——也是最后一章中,我们将探讨如何使用 R 将尖端“大数据”技术应用于一些高度专业化和困难的数据任务。
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人交流,并与其他 4000 多人一起学习:

第十五章:利用大数据
尽管今天最令人激动的机器学习研究是在大数据领域——计算机视觉、自然语言处理、自动驾驶汽车等——但大多数商业应用规模较小,使用的数据最多只能称为“中等”数据。正如第十二章,高级数据准备中所述,真正的大数据工作需要访问数据集和计算设施,这些设施通常只有在非常大的科技公司或研究型大学才能找到。即便如此,使用这些资源的实际工作通常主要是数据工程的壮举,它在数据用于传统商业应用之前极大地简化了数据。
好消息是,大数据公司进行的引人注目的研究最终会逐渐渗透到更传统的机器学习任务中。本章涵盖了在 R 中使用这些大数据方法的多种方法。您将学习:
-
如何借鉴大数据公司开发的深度学习模型并将其应用于传统的建模任务
-
减少大型和非结构化大数据格式(如文本和图像)的复杂性的策略,以便它们可用于预测
-
访问和建模大数据集的尖端包和途径,这些数据集可能太大而无法装入内存
尽管 R 以其不适合大数据项目而闻名,但 R 社区的努力逐渐将其转变为一种能够处理大量高级任务的工具。本章的目标是展示 R 在深度学习和大数据时代保持相关性的能力。尽管 R 不太可能成为最大大数据项目的核心,并且尽管面临来自 Python 和基于云的工具的日益激烈的竞争,R 的优势仍然使其保持在许多实践数据科学家的桌面上。
深度学习的实际应用
深度学习最近因其成功解决传统方法难以解决的机器学习任务而受到广泛关注。使用复杂的神经网络教会计算机更像人类思考,使得机器在许多人类曾经占据压倒性优势的任务上能够赶上甚至超越人类的表现。也许更重要的是,即使人类在某些任务上仍然表现更好,机器学习的优势——工人从不疲倦、从不厌倦,且无需工资——甚至将不完美的自动机变成了许多任务的实用工具。
不幸的是,对于我们这些在大科技公司和研究机构外工作的人来说,利用深度学习方法并不总是那么容易。训练深度学习模型通常不仅需要最先进的计算硬件,还需要大量的训练数据。事实上,正如在第十二章 《高级数据准备》 中提到的,在商业环境中,大多数实际机器学习应用都是在所谓的少量或中等数据规模下进行的,而在这里,深度学习方法可能并不比传统的机器学习方法(如回归和决策树)表现得更好,甚至可能更差。因此,许多在深度学习上投入大量资金的组织,这样做更多的是因为炒作而非真正的需求。
尽管围绕深度学习的一些炒作无疑是基于其新颖性以及商业领袖对人工智能取代昂贵的人工工人的愿景所带来的兴奋,但实际上,这项技术有一些实际应用,可以与传统的机器学习方法结合使用,而不是作为替代品。因此,本章的目的不是提供一个从头到尾的构建深度神经网络的教程,而是展示如何将深度学习的成功融入传统的机器学习项目中,包括那些在大数据环境之外的项目。
Packt Publishing 提供了关于深度学习的众多资源,例如 M. Pawlus 和 R. Devine 所著的 《使用 R 进行深度学习实战:设计、构建和改进神经网络模型的实际指南》(2020 年),B. Rai 所著的 《使用 R 进行高级深度学习》(2019 年),以及 S. Gupta、R. A. Ansari 和 D. Sarkar 所著的 《深度学习 R 烹饪书》(2020 年)。
从深度学习开始
近年来,随着新一代在深度学习时代受过训练的数据科学实践者的出现,机器学习社区中形成了一种“代沟”。在深度学习发展之前,该领域主要由受过统计学或计算机科学培训的人士组成。特别是在最初几年,机器学习从业者带着他们先前领域的隐喻性负担,他们所使用的软件和算法沿着党派路线分成了阵营。统计学家通常更喜欢基于回归的技术和软件,如 R,而计算机科学家则更喜欢用 Python 和 Java 等语言编写的迭代和启发式算法,如决策树。深度学习模糊了这些阵营之间的界限,下一代数据科学家可能对前辈们显得有些陌生,就像他们说的是另一种语言。
虽然事后我们可以清楚地看到其起源,但这次分歧似乎是从无中来,正如著名作家欧内斯特·海明威曾经写的那样,“它逐渐发生,然后突然发生。” 正如机器学习本身只有在计算能力、统计方法和可用数据的同步演变下才成为可能一样,下一个大的进化飞跃从这三个相同组件的一系列较小进化中产生,这是有道理的。回忆一下第一章中提出的类似图像,介绍机器学习,一个修订后的进步周期图描绘了当今最先进的机器学习周期,它说明了深度学习发展的环境:

图 15.1:进步周期中的多种因素共同导致了深度学习的发展
深度学习起源于大数据时代,同时得到了必要的计算硬件——图形处理单元(GPUs)和基于云的并行处理工具——这些将在本章后面进行介绍,用于处理既长又宽的数据集,这并不令人惊讶。但更不明显的是,这种演变也离不开必要的学术和研究环境。如果没有一个强大的数据科学社区,其中研究人员在统计学和计算机科学方面都有专业知识,再加上那些致力于解决大型和复杂真实世界数据集上实际业务问题的应用数据科学家,深度学习可能就不会以这种方式出现。换句话说,数据科学现在作为一个专门的学术学科存在,无疑加速了进步周期。借用科幻小说中的类比,系统现在就像一个机器人,它学会了如何学习,因此现在它变得自我意识,并且学习得更快!
深度学习的快速发展导致了之前提到的代沟,但这并不是唯一因素。正如你很快就会学到的那样,深度学习不仅在大型数据任务上提供了令人印象深刻的性能,而且在较小任务上也能像传统学习方法一样执行。这导致一些人几乎完全专注于这项技术,就像早期机器学习实践者只专注于回归或决策树一样。深度学习还利用专门的软件工具和数学术语来执行这些任务,这意味着在某些情况下,其从业者实际上是在用另一种语言描述相同的步骤。正如以前多次说过的,“在机器学习领域没有免费的午餐”,因此在你继续机器学习之旅时,最好将其视为许多有用工具之一——而不是这项工作的唯一工具。
深度学习实践者使用的术语,即使是像线性回归这样的简单方法,也包括“代价函数”、“梯度下降”和“优化”等短语。这是一个很好的提醒,尽管深度学习可以近似回归和其他机器学习方法,但它找到解决方案的方式是完全不同的。
选择适合深度学习的任务
如同在第七章中提到的,黑盒方法 – 神经网络和支持向量机,至少包含一个隐藏层的神经网络可以作为通用函数逼近器。对此原理进行阐述,我们可能会说,在给定足够多的训练数据的情况下,一个设计巧妙的神经网络可以学会模仿任何其他函数的输出。
这意味着本书中涵盖的传统学习方法同样可以被设计良好的神经网络所近似。事实上,设计一个几乎完全匹配线性或逻辑回归的神经网络是非常简单的,通过更多的工作,也有可能近似 k-最近邻和朴素贝叶斯等技术。在数据足够的情况下,神经网络可以越来越接近甚至最好的基于树的算法,如随机森林或梯度提升机。
那为什么不将深度学习应用于每一个问题呢?确实,神经网络模仿所有其他学习方法的特性似乎违反了“没有免费午餐”定理,这个定理简单来说就是指出没有一种机器学习算法可以在所有潜在的建模任务中表现最佳。有几个关键原因使得这个定理在深度学习的魔力下依然安全。首先,神经网络逼近函数的能力与其拥有的训练数据量相关。在小数据规模下,传统技术可以表现得更好,尤其是在与仔细的特征工程相结合时。其次,为了减少神经网络训练所需的数据量,网络必须具有便于学习潜在函数的拓扑结构。当然,如果构建模型的人知道应该使用哪种拓扑结构,那么最初使用更简单的传统模型可能更可取。
使用深度学习进行传统学习任务的人们可能会倾向于选择黑盒方法,这种方法在大数据环境中效果显著。然而,大数据并不仅仅是数据行数的增加,还包括许多特征。大多数传统学习任务,包括那些拥有数百万行数据的情况,都处于中等数据规模,在这种规模下,传统学习算法仍然表现良好。在这种情况下,神经网络是否表现更好最终将取决于过拟合和欠拟合之间的平衡——这种平衡有时在神经网络中是难以找到的,因为该方法容易对训练数据进行过拟合。
可能正因为如此,深度学习不适合在机器学习竞赛中取得胜利。如果你问一个 Kaggle 大师,他们可能会告诉你神经网络在标准、现实生活中的问题以及传统监督学习任务上不起作用,梯度提升法才是赢家。人们也可以通过浏览排行榜并注意深度学习的缺席来证明这一点。也许一个聪明的团队会使用神经网络进行特征工程,并将深度学习模型与其他模型结合成集成模型以提高性能,但这种做法很少见。深度学习的优势在于其他方面。一般来说,基于树的集成方法在结构化、表格数据上获胜,而神经网络在图像、声音和文本等非结构化数据上获胜。
阅读关于研究突破和技术初创公司的最新新闻,可能会遇到利用该方法独特能力解决非常规任务的深度学习应用。一般来说,这些非常规学习任务可以分为三类:计算机视觉、自然语言处理或涉及时间重复测量或具有异常大量相互关联预测因子的不寻常数据格式。以下表格列出了每个类别的具体成功案例:
| 具有挑战性的机器学习任务 | 深度学习成功案例 |
|---|---|
| 涉及对静止图片或视频数据中图像进行分类的计算机视觉任务 |
-
在安全摄像头录像中识别人脸
-
对植物或动物进行分类以进行生态监测
-
诊断 X 射线、MRI 或 CT 扫描等医疗图像
-
测量运动员在运动场上的活动
-
自动驾驶
|
| 需要理解上下文中词语含义的自然语言应用 |
|---|
-
处理社交媒体帖子以过滤掉假新闻或仇恨言论
-
监测 Twitter 或客户支持电子邮件以了解消费者情绪或其他市场洞察
-
检查处于不良结果风险中的患者或符合新治疗方案资格的患者的电子健康记录
| | |
| 涉及许多重复测量或大量预测因子的预测分析 |
|---|
-
预测公开市场上商品或股票的价格
-
估算能源、资源或医疗保健利用
-
使用保险账单代码预测生存或其他医疗结果
|
尽管有些人确实使用深度学习来解决传统学习问题,但本章仅关注无法通过传统建模技术解决的问题。深度学习非常擅长挖掘大数据时代特征的数据类型,如图像和文本,这些数据类型用传统方法难以建模。
要解锁这些功能,需要使用专门的软件和专门的数据结构,这些内容你将在下一节中学习。
TensorFlow 和 Keras 深度学习框架
或许没有哪个软件工具像 TensorFlow (www.tensorflow.org) 这样对深度学习的快速发展做出了如此大的贡献,TensorFlow 是一个开源的数学库,由谷歌开发,用于高级机器学习。TensorFlow 提供了一个使用有向图进行数据结构“流动”的数学运算序列的计算接口。
Packt Publishing 提供了许多关于 TensorFlow 的书籍。要搜索当前提供的书籍,请访问 subscription.packtpub.com/search?query=tensorflow。
TensorFlow 的基本数据结构不出所料地被称为 张量,它是一个零个或多个维度的数组。在 0-D 和 1-D 张量(分别代表单个值和值序列)的基础上构建,增加额外的维度允许表示更复杂的数据结构。请注意,因为我们通常分析结构集,第一个维度通常保留以允许堆叠多个对象;第一个维度因此指的是每个结构的批次或样本编号。例如:
-
一组一维张量,收集一组人的特征值,是一个二维张量,类似于 R 中的数据框:
[person_id, feature_values] -
对于随时间重复测量的情况,二维张量可以堆叠成三维张量:
[person_id, time_sequence, feature values] -
2-D 图像由一个 4-D 张量表示,第四维存储 2-D 网格中每个像素的颜色值:
[image_id, row, column, color_values] -
视频或动画图像以 5 维表示,并增加一个时间维度:
[image_id, time_sequence, row, column, color_values]
大多数张量是完全填充数字数据的矩形矩阵,但还有更复杂的结构,如稀疏张量和稀疏张量,可用于文本数据。
要深入了解 TensorFlow 的张量对象,文档可在 www.tensorflow.org/guide/tensor 查找。
TensorFlow 的图,更具体地可以称为 数据流图,使用由称为边的方向箭头连接的节点来表示数据结构之间的依赖关系,这些数据结构上的数学运算以及输出。节点代表数学运算,边代表在运算之间流动的张量。该图有助于并行化工作,因为它清楚地表明哪些步骤必须按顺序完成,哪些步骤可以同时完成。
如果需要,可以可视化数据流图,它会产生类似于图 15.2中描述的理想化图。尽管这是一个高度简化的表示,现实世界的 TensorFlow 图通常要复杂得多,但此图表明,在完成第一个操作后,第二个和第四个操作可以并行开始:

图 15.2:TensorFlow 图的简化表示
当张量在图中流动时,它们会被节点表示的操作序列所转换。步骤由构建图表的人定义,每个步骤都使目标更接近完成某种数学任务。流程图中的某些步骤可能对数据进行简单的数学转换,如归一化、平滑或分桶;其他步骤可能通过迭代重复训练模型,同时监控一个损失函数,该函数衡量模型预测与真实值之间的拟合度。
R 接口的 TensorFlow 是由 RStudio 团队开发的。tensorflow包提供了对核心 API 的访问,而tfestimators包提供了对高级机器学习功能的访问。请注意,TensorFlow 的定向图方法可以用来实现许多不同的机器学习模型,包括本书中讨论的一些模型。然而,要这样做需要彻底理解定义每个模型的矩阵数学,因此超出了本文的范围。有关这些包和 RStudio 与 TensorFlow 接口能力的更多信息,请访问tensorflow.rstudio.com。
由于 TensorFlow 严重依赖于必须由手编程的复杂数学运算,因此开发了Keras库(keras.io),以提供对 TensorFlow 的高级接口,并允许更轻松地构建深度神经网络。Keras 是用 Python 开发的,通常与 TensorFlow 配对作为后端计算引擎。使用 Keras,只需几行代码就可以进行深度学习——即使是像图像分类这样的挑战性应用,你将在本章后面的示例中看到。
Packt Publishing 提供了许多书籍和视频来学习 Keras。要搜索当前提供的内容,请访问subscription.packtpub.com/search?query=keras。
由 RStudio 创始人 J. J. Allaire 开发的keras包允许 R 与 Keras 接口。尽管使用此包所需的代码非常少,但要从头开始开发有用的深度学习模型,需要广泛了解神经网络以及熟悉 TensorFlow 和 Keras API。因此,教程超出了本书的范围。相反,请参阅 RStudio TensorFlow 文档或由 Keras 和keras包的创造者 Francois Chollet 和 J. J. Allaire 合著的书籍《深度学习与 R》(2018 年),这是开始学习这个工具的绝佳起点。
尽管 Keras 和 TensorFlow 的组合可能是最受欢迎的深度学习工具包,但这并不是唯一的工具。Facebook 开发的PyTorch框架迅速获得了人气,尤其是在学术研究社区中,作为一个易于使用的替代品。更多信息,请参阅pytorch.org。
TensorFlow 使用简单的图抽象来表示复杂数学函数的创新理念,结合 Keras 框架使其更容易指定网络拓扑,这已经使得构建更深更复杂的神经网络成为可能,如下一节所述。Keras 甚至使得仅用几行代码就能轻松地将预构建的神经网络适应新任务。
理解卷积神经网络
神经网络已经被研究超过 60 年,尽管深度学习最近才变得普遍,但深度神经网络的概念已经存在了几十年。正如在第七章,黑盒方法 – 神经网络和支持向量机中首次介绍的那样,深度神经网络(DNN)只是一个具有多个隐藏层的神经网络。
这低估了深度学习在实践中是什么,因为典型的 DNN 比我们之前构建的神经网络类型要复杂得多。仅仅在新的隐藏层中添加几个额外的节点并称之为“深度学习”是不够的。相反,典型的 DNN 使用极其复杂但故意设计的拓扑结构来促进在大数据上的学习,并且在这个过程中能够在具有挑战性的学习任务上实现类似人类的性能。
深度学习的一个转折点出现在 2012 年,当时一个名为 SuperVision 的团队使用深度学习赢得了 ImageNet 大规模视觉识别挑战赛。这项年度竞赛测试了分类 100 万张手标注图像的能力,这些图像分布在 10,000 个物体类别中。在竞赛的早期年份,人类在视觉分类方面远远优于计算机,但 SuperVision 模型的性能显著缩小了差距。如今,计算机在视觉分类方面几乎与人类一样好,在某些特定情况下甚至更好。人类在识别小、细或变形物品方面往往更擅长,而计算机在区分特定类型的物品,如狗的品种方面具有更大的能力。不久的将来,计算机很可能在两种视觉任务类型上都优于人类。
一种专门为图像识别设计的创新网络拓扑结构,是性能激增的原因。卷积神经网络(CNN)是一种用于视觉任务的深度前馈网络,它独立地学习重要的区分图像特征,而不是在事先需要这样的特征工程。例如,为了对“停止”或“让行”等路标进行分类,传统的学习算法需要预先设计好的特征,如标志的形状和颜色。相比之下,CNN 只需要每个图像像素的原始数据,网络将自行学习如何区分形状和颜色等重要特征。
由于使用原始图像数据时维度的大幅增加,这些特征的提取成为可能。传统的学习算法会为每张图像使用一行,形式如(停止标志,红色,八边形),而 CNN 使用的数据形式为(停止标志,x,y,颜色),其中x和y是像素坐标,颜色是给定像素的颜色数据。这看起来只是维度增加了一个,但请注意,即使是非常小的图像也由许多(x,y)组合构成,颜色通常指定为 RGB(红色,绿色,蓝色)值的组合。这意味着单个训练数据行的更准确表示将是:
(停止标志,x[1]y[1]r,x[1]x[1]g,x[1]y[1]b,x[2]y[1]r,x[2]y[1]g,x[2]y[1]b,……,x[n]y[n]r,x[n]x[n]g,x[n]y[n]b)
每个预测因子都指的是在指定的(x,y)组合中红色、绿色或蓝色的程度,以及r、g或b值。因此,维度大大增加,随着图像变大,数据集也变得更大。
一个 100x100 像素的小图像将有100x100x3 = 30,000个预测因子。即使这样,与 2012 年赢得视觉识别挑战赛的 SuperVision 团队使用的超过 6000 万个参数相比,这仍然很小!
第十二章,高级数据准备指出,如果一个模型过度参数化,它将达到一个插值阈值,此时有足够的参数来记忆并完美地分类所有训练样本。包含 1000 万张图片的 ImageNet 挑战数据集比获胜团队使用的 6000 万个参数要小得多。直观上看,这是有道理的;假设数据库中没有完全相同的图片,至少每个图像的一个像素会有所不同。因此,算法可以简单地记忆每一张图片以实现训练数据的完美分类。问题是模型将在未见过的数据集上评估,因此对训练数据的严重过拟合将导致巨大的泛化误差。
CNN 的拓扑结构阻止了这种情况的发生。我们不会深入探讨 CNN 的黑盒,但我们将它理解为一组以下类别的层:
-
卷积层在网络中放置得较早,通常构成了网络中最计算密集的步骤,因为它们是唯一直接处理原始图像数据的层;我们可以将卷积理解为将原始数据通过一个过滤器,创建一组代表整个区域的小型重叠部分的瓦片
-
池化层,也称为下采样或子采样层,从一层中的一簇神经元中收集输出信号,并将它们总结为下一层的单个神经元,通常是通过取被总结的信号中的最大值或平均值来实现
-
全连接层与传统的多层感知器中的层非常相似,通常在 CNN 的末尾用于构建预测模型
网络中的卷积层和池化层服务于识别要学习的图像的重要特征以及在使用进行预测的全连接层之前降低数据集的维度的相关目的。换句话说,网络的早期阶段执行特征工程,而后期阶段使用构建的特征进行预测。
为了更好地理解 CNN 中的层,请参阅 Adam W. Harley 在adamharley.com/nn_vis/上发表的《卷积神经网络的交互式节点-链接可视化》*。该交互式工具让您从零到九画一个数字,然后使用神经网络对其进行分类。二维和三维卷积网络可视化清楚地显示了您所画的数字是如何通过卷积、下采样和全连接层,最终到达输出层进行预测的。用于通用图像分类的神经网络以类似的方式工作,但使用的是一个更大、更复杂的网络。
迁移学习和微调
从头开始构建 CNN 需要大量的数据、专业知识和计算能力。幸运的是,许多拥有数据和计算资源的大型组织已经构建了图像、文本和音频分类模型,并将它们与数据科学社区共享。通过迁移学习的过程,深度学习模型可以从一个上下文适应到另一个上下文。不仅可以将保存的模型应用于与训练时相似类型的问题,而且它也可能对原始领域之外的问题有用。例如,一个在卫星照片中训练以识别濒危象种的神经网络,也可能有助于识别在战区上空拍摄的红外无人机图像中坦克的位置。
如果知识不能直接迁移到新任务,可以通过微调过程使用额外的训练来磨练预训练的神经网络。从一个一般性的模型开始训练,例如一个可以识别 10,000 类对象的通用图像分类模型,并将其微调以擅长识别单一类型的对象,这不仅减少了所需的训练数据和计算能力,还可能比仅在单一类图像上训练的模型提供更好的泛化能力。
Keras 可以通过下载带有预训练权重的神经网络来进行迁移学习和微调。可用的预训练模型列表可在keras.io/api/applications/找到,一个将图像处理模型微调以更好地预测猫和狗的示例可在tensorflow.rstudio.com/guides/keras/transfer_learning找到。在下一节中,我们将应用一个预训练的图像模型到现实世界的图像上。
示例 - 使用预训练的 CNN 在 R 中分类图像
R 可能不是处理最重深度学习任务的正确工具,但有了合适的包集,我们可以将预训练的 CNN 应用于执行图像识别等任务,这些任务传统机器学习算法难以解决。R 代码生成的预测可以直接用于图像识别任务,如过滤不雅的资料照片、确定图像是否描绘了猫或狗,甚至识别简单自动驾驶车辆内的停车标志。也许更常见的是,这些预测可以用作包含使用表格结构数据的传统机器学习模型以及消耗非结构化图像数据的深度学习神经网络的集成模型的预测因子。你可能还记得,第十四章,构建更好的学习者,描述了一种潜在的堆叠集成,它以这种方式结合了图像、文本和传统机器学习模型,以预测 Twitter 用户未来行为的元素。以下图片展示了假设的 Twitter 个人资料图片,我们将使用深度神经网络对其进行分类:

图 15.3:可以在 R 中使用预训练的神经网络识别这类图像
首先,在使用预训练模型之前,考虑用于训练神经网络的训练数据集非常重要。大多数公开可用的图像网络都是在包含各种日常物体和动物(如汽车、狗、房屋、各种工具等)的巨大图像数据库上训练的。如果目标是区分日常物体,这是合适的,但对于更具体的工作可能需要更具体的训练数据集。例如,面部识别工具或识别停车标志的算法可能需要在面部和道路标志的数据集上分别进行更有效的训练。通过迁移学习,可以将训练于各种图像的深度神经网络微调以更好地完成更具体的工作——例如,它可能非常擅长识别猫的图片——但很难想象一个在面部或道路标志上训练的网络,即使经过额外的调整,也能非常擅长识别猫!
在这个练习中,我们将使用名为ResNet-50的 CNN 对我们的小图像集进行分类,这是一个在大量和综合的标记图像上训练的 50 层深度网络。这个模型由一组研究人员在 2015 年作为最先进的、获奖的计算机视觉算法引入,尽管后来被更复杂的方法所超越,但由于其易用性和与 R 和 Keras 等工具的集成,它仍然非常受欢迎和有效。
关于 ResNet-50 的更多信息,请参阅《深度残差学习用于图像识别》,He, K.,Zhang, X.,Ren, S.,和 Sun, J.,2015 年 arxiv.org/abs/1512.03385v1。
用于训练 ResNet-50 模型的 ImageNet 数据库 (www.image-net.org) 与用于 ImageNet 视觉识别挑战的数据库相同,自 2010 年推出以来对计算机视觉做出了巨大贡献。它由超过 1400 万张手工标注的图像组成,消耗了数 GB 的存储空间(在完整、学术版本的情况下甚至达到 TB 级别),幸运的是,我们无需下载此资源从头开始训练模型。相反,我们只需下载研究人员在完整数据库上训练的 ResNet-50 模型的神经网络权重,从而节省了大量计算开销。
要开始这个过程,我们需要将 tensorflow 和 keras 包添加到 R 中,以及各种依赖项。大多数这些步骤只需执行一次。devtools 包为开发 R 包和使用处于开发中的包添加了工具,因此我们将像往常一样安装并加载这个包:
> install.packages("devtools")
> library(devtools)
接下来,我们将使用 devtools 包从 GitHub 获取 tensorflow 包的最新版本。通常,我们从 CRAN 安装包,但对于处于开发中的包,直接从最新源代码安装可能更好。从其 GitHub 路径安装 tensorflow 包的命令是:
> devtools::install_github("rstudio/tensorflow")
这将 R 指向 RStudio 的 GitHub 账户,该账户存储了包的源代码。要在线阅读文档并查看代码,请在网络浏览器中访问 github.com/rstudio/tensorflow。
安装 tensorflow 包后,有几个依赖项是开始使用 TensorFlow 在 R 中所必需的。特别是,tensorflow 包仅仅是 R 和 TensorFlow 之间的接口,因此我们必须首先安装 TensorFlow 本身。也许有些讽刺,Python 及其一些包是完成此任务所必需的。因此,我们使用 R 的 reticulate 包 (rstudio.github.io/reticulate/) 来管理 R 和 Python 之间的接口。尽管这听起来很令人困惑,但完整的安装过程是由 tensorflow 包的单个命令驱动的,如下所示:
> library(tensorflow)
> install_tensorflow()
当命令运行时,你应该看到 R 正在安装大量 Python 工具和包。如果一切顺利,你可以继续从 GitHub 安装 Keras 包:
> devtools::install_github("rstudio/keras")
如果出现问题,请记住,尽管此示例的代码已在多个平台和 R 版本上进行了测试,但在 R 与 Python 和 TensorFlow 交互所需的众多依赖项中,出现问题的可能性相当大。不要害怕在网络上搜索特定的错误消息,或者检查 Packt Publishing 的 GitHub 仓库以获取本章更新的 R 代码。
在安装了必要的包之后,Keras 可以帮助加载在 ImageNet 数据库上训练的 ResNet-50 模型:
> library(keras)
> model <- application_resnet50(weights = 'imagenet')
我们在数百万张日常图像上训练的 50 层深度图像分类模型现在可以开始做出预测了;然而,我们加载模型时的轻松程度掩盖了即将到来的工作。
使用预训练模型的一个更大挑战是将我们希望分类的无结构图像数据转换成它在训练期间看到的相同结构格式。ResNet-50 使用了 224x224 像素的方形图像,每个像素反映由三个通道组成的颜色,红色、绿色和蓝色,每个通道都有 255 个亮度级别。我们希望分类的所有图像都必须使用这种表示从其原始格式(如 PNG、GIF 或 JPEG)转换为 3-D 张量。我们将通过之前描述的cat.jpg、ice_cream.jpg和pizza.jpg文件来实际看到这一点,这些文件位于本章 R 代码文件夹中,但这个过程对任何图像都是类似的。
keras包中的image_load()函数将启动这个过程。只需提供文件名和所需的目标尺寸,如下所示:
> img <- image_load("ice_cream.jpg", target_size = c(224,224))
这将创建一个图像对象,但我们还需要一个额外的命令将其转换为 3-D 张量:
> x <- image_to_array(img)
为了证明这一点,我们可以检查对象的大小和结构,如下所示。正如预期的那样,对象是一个数值矩阵,其维度为224 x 224 x 3:
> dim(x)
[1] 224 224 3
矩阵中的前几个值都是 255,这并没有什么意义:
> str(x)
num [1:224, 1:224, 1:3] 255 255 255 255 255 255 255 255 255 255 ...
让我们进行一些调查,以便更好地理解这些数据结构。由于 R 的行列矩阵格式,矩阵坐标是(y, x),其中(1, 1)代表图像的左上角像素,(1, 224)代表右上角像素。为了说明这一点,让我们获取冰淇淋图像中几个像素的三个颜色通道:
> x[1, 224, 1:3]
[1] 253 253 255
> x[40, 145, 1:3]
[1] 149 23 34
像素(1, 224)的颜色为(r, g, b),颜色值为(253, 253, 255),这几乎是最亮的白色,而像素(40, 145)的颜色值为(149, 23, 34),翻译成深红色——冰淇淋中的一块草莓。这些坐标在以下图中进行了说明:

图 15.4:冰淇淋的图片已经从 1,000x1,000 像素的矩阵减少到 224x224;图像中的每个像素都有三个颜色通道
另一个额外的复杂性是,ResNet-50 模型期望一个四维张量,其中第四维代表批次。由于只有一个图像需要分类,我们不需要这个参数,因此我们将简单地将其分配一个常数值 1,以创建一个1x224x224x3的矩阵。命令c(1, dim(x))以这种格式定义新的矩阵,然后array_reshape()函数使用 TensorFlow 使用的 Python 风格行行顺序而不是 R 风格的列列填充,将x的内容填充到这个矩阵中。完整的命令如下:
> x <- array_reshape(x, c(1, dim(x)))
为了确认x具有正确的维度,我们可以使用dim()命令:
> dim(x)
[1] 1 224 224 3
最后,我们运行imagenet_preprocess_input()函数来将颜色值归一化,以匹配 ImageNet 数据库:
> x <- imagenet_preprocess_input(x)
这种转换的主要功能是将每个颜色相对于数据库进行零中心化,基本上是将每个颜色值视为大于或小于 ImageNet 图像中该颜色的平均值。例如,在冰淇淋中,位于(40, 145)的红色像素在之前具有 149、23 和 34 的颜色值;现在,它具有非常不同的值:
> x[1, 40, 145, 1:3]
[1] -69.939 -93.779 25.320
负值表示该颜色的颜色级别低于 ImageNet 的平均值,而正值表示更高。预处理步骤还将红绿蓝格式反转为蓝绿红,因此只有红色通道高于 ImageNet 的平均水平,这并不令人特别惊讶,因为草莓非常红!
现在,让我们看看 ResNet-50 网络认为图像中描绘了什么。我们将首先在模型对象和图像矩阵上使用predict()函数,然后使用keras函数imagenet_decode_predictions()将网络的预测概率转换为基于文本的标签,这些标签将分类 ImageNet 中的每个图像。由于 ImageNet 数据库中有 1,000 种图像类别,preds对象包含 1,000 个预测概率——每个可能性一个。解码函数允许我们将输出限制为最可能的前N个可能性——在这个例子中是十个:
> p_resnet50 <- predict(m_resnet50, x)
> c_resnet50 <- imagenet_decode_predictions(p_resnet50, top = 10)
c_resnet50对象是一个列表,其中包含我们单个图像的前十个预测。要查看预测结果,我们只需输入列表的名称,就可以发现网络正确地将图像识别为冰淇淋,概率约为 99.6%:
> c_resnet50
[[1]]
class_name class_description score
1 n07614500 ice_cream 0.99612110853
2 n07836838 chocolate_sauce 0.00257453066
3 n07613480 trifle 0.00017260048
4 n07932039 eggnog 0.00011857488
5 n07930864 cup 0.00011558698
6 n07745940 strawberry 0.00010969469
7 n15075141 toilet_tissue 0.00006556125
8 n03314780 face_powder 0.00005355201
9 n03482405 hamper 0.00004582879
10 n04423845 thimble 0.00004054611
尽管其他潜在分类的预测概率并没有远大于零,但一些其他顶级预测还是有点道理;不难理解为什么它们被视为可能性。例如,蛋酒属于正确的食物类别,而冰淇淋锥可能看起来有点像杯子,或者像顶针。
模型甚至将草莓列为第六个最有可能的选项,这是正确的冰淇淋口味。
作为练习,我们将对另外两张图片执行相同的过程。以下步骤序列使用lapply()函数将图像处理步骤应用于图像对,每次创建一个新的列表以供后续函数使用。最后一步将包含两个准备好的图像数组的列表提供给lapply()函数,该函数对每个图像应用predict()命令:
> img_list <- list("cat.jpg", "pizza.jpg")
> img_data <- lapply(img_list, image_load, target_size = c(224,224))
> img_arrs <- lapply(img_data, image_to_array)
> img_resh <- lapply(img_arrs, array_reshape, c(1, 224, 224, 3))
> img_prep <- lapply(img_resh, imagenet_preprocess_input)
> img_prob <- lapply(img_prep, predict, object = m_resnet50)
最后,使用sapply()函数将解码函数应用于两个预测集的每个集合,同时简化结果。lapply()函数在这里也可以工作,但由于imagenet_decode_predictions()返回一个列表,结果是列表中的一个长度为 1 的子列表;sapply()识别出这是多余的,并且将消除额外的层次结构:
> img_classes <- sapply(img_prob, imagenet_decode_predictions,
top = 3)
输入结果的名称将显示两张图像各自的前三个预测:
> img_classes
[[1]]
class_name class_description score
1 n02123045 tabby 0.63457680
2 n02124075 Egyptian_cat 0.08966244
3 n02123159 tiger_cat 0.06287414
[[2]]
class_name class_description score
1 n07873807 pizza 0.9890466332
2 n07684084 French_loaf 0.0083064679
3 n07747607 orange 0.0002433858
ResNet-50 算法不仅正确地分类了图像;它还正确地将猫图片识别为虎斑猫。这证明了神经网络在某些任务上超越人类特定性的能力;许多人或大多数人可能只是将图像标记为猫,而计算机可以确定猫的具体类型。另一方面,人类在识别非最佳条件下的物体方面仍然更胜一筹。例如,在黑暗中或杂草中隐藏的猫对计算机来说可能比对人更具挑战性。尽管如此,计算机不知疲倦的能力使其在自动化人工智能任务方面具有巨大的优势。如前所述,应用于大型数据集,如 Twitter 个人资料图片,此类计算机视觉模型的预测可以用于预测无数不同用户行为的集成模型。
无监督学习和大数据
前一节说明了如何使用深度神经网络将无限供应的输入图像分类为日常生物或物体的实例。从另一个角度来看,这也可能被理解为一种机器学习任务,它将图像像素数据的高度维输入降低到一组低维度的图像标签。然而,值得注意的是,深度学习神经网络是一种监督学习技术,这意味着机器只能学习人类告诉它学习的内容——换句话说,它只能从已经被标记的内容中学习。
本节的目的在于介绍在大数据背景下无监督学习技术的有用应用。这些应用在很多方面与第九章中介绍的技术相似,即寻找数据组 - 使用 k-means 进行聚类。然而,在先前无监督学习技术中,人类在解释结果方面承担了很大的责任,而在大数据的背景下,机器可以比以前更进一步,提供对数据及其发现算法连接的更深、更丰富的理解。
用实际的话来说,想象一个深度神经网络,它可以学会识别猫,而从未被告知猫是什么。当然,如果没有事先给出标签,计算机可能不会明确地将它标记为“猫”本身,但它可能理解猫与图片中出现的其他事物之间存在某些一致的关系:人、猫砂盆、老鼠、一团毛线——但很少或从不包括狗!这样的关联有助于形成对猫的概念,认为它与人类、猫砂盆和毛线密切相关,但可能与另一种有四条腿和尾巴的东西相对立。如果有足够的图片,神经网络最终可能会通过识别猫粮袋附近的猫或互联网上无数的以猫为主题的梗,将对其印象与英语单词“猫”联系起来!
开发这样一个复杂的猫的模型需要比大多数机器学习从业者所能获取的数据和计算能力更多,但当然有可能开发更简单的模型,或者借鉴那些确实能获取这些资源的大数据公司。这些技术提供了另一种将非结构化数据源纳入更传统学习任务的方法,因为机器可以将大数据的复杂性降低到更易于消化的程度。
将高维概念表示为嵌入
我们在日常生活中遇到的事物可以用无数个属性来描述。此外,不仅存在无数可以用来描述每个对象的数据点,而且人类主观性的本质使得任何两个人都不太可能以相同的方式描述一个对象。例如,如果你问一些人描述典型的恐怖电影,一个人可能会想象血腥和血腥的砍杀电影,另一个人可能会想到僵尸或吸血鬼电影,还有一个人可能会想到阴森的鬼故事和闹鬼的房子。这些描述可以用以下陈述来表示:
-
恐怖 = 杀手 + 血液 + 血腥
-
恐怖 = 诡异 + 僵尸 + 吸血鬼
-
恐怖 = 阴森 + 鬼魂 + 闹鬼
如果我们将这些定义编程到计算机中,它就可以相互替换任何关于恐怖的表示,从而使用更广泛的“恐怖”概念,而不是像“血腥”、“诡异”或“阴森”这样的更具体特征来进行预测。例如,一个学习算法可能会发现,任何写有这些恐怖相关术语的社交媒体用户更有可能点击观看新片 Scream 的广告。
不幸的是,如果用户发布“我简直喜欢一部好恐怖的电影!”或“万圣节季节是我一年中最喜欢的时光!”这样的帖子,算法将无法将文本与先前的恐怖概念联系起来,因此将无法意识到应该显示恐怖电影的广告。这同样适用于计算机之前未曾直接看到的数百个与恐怖相关的关键词,包括许多对人类观察者来说显然的关键词,如女巫、恶魔、墓地、蜘蛛、骷髅等等。所需要的,是一种将恐怖概念推广到几乎无限的描述方式的方法。
嵌入是一个数学概念,指的是使用更少的维度来表示高维向量的能力;在机器学习中,嵌入是有意构建的,使得在高维空间中相关联的维度在低维空间中位置更接近。
如果嵌入构建得当,低维空间将保留高维的语义或意义,同时成为一个更紧凑的表示,可用于分类任务。创建嵌入的核心挑战是建模高度维度的非结构化或半结构化数据集中嵌入的语义意义,这是一个无监督学习任务。
人类在构建概念的低维表示方面非常擅长,因为我们总是在为粗略上相似但在细节上可能有所不同的对象或现象分配标签时直觉地这样做。当我们给电影贴上喜剧、科幻或恐怖的标签时;当我们谈论像嘻哈、流行或摇滚这样的音乐类别时;或者当我们创建食物、动物或疾病的分类法时,我们就是这样做的。在 第九章,寻找数据组 - 使用 k-means 进行聚类 中,我们看到了机器学习过程中的聚类如何通过“无监督分类”的过程模仿人类标签化过程,通过将不同但相似的项目分组。然而,尽管这种方法减少了数据集的维度,但在它能够将类似的项目关联起来之前,它需要一个具有每个示例相同特定特征的具有结构的数据集。对于像电影文本描述这样的非结构化事物,特征太多且稀疏,无法进行聚类。
反之,如果我们想模仿人类通过联想学习的能力呢?具体来说,人类可以观看一系列电影,并将类似的电影进行分类,而不需要为每部电影提供具体的可测量特征;我们可以将一组电影归类为恐怖片,而无需看到相同的陈词滥调的故事情节或计算每部电影引发的尖叫次数。诀窍在于人类不需要“恐怖”的明确定义,因为我们将其作为一个相对于集合中其他元素的概念来直观地理解。就像一只猫突然跳入画面可以在喜剧电影中用作滑稽幽默,或者与悬疑音乐搭配来引发惊吓一样,恐怖的语义含义总是由其上下文决定的。
同样地,学习算法可以通过上下文来构建嵌入。好莱坞制作的成千上万部电影都可以相对于其他电影来理解,而且无需研究电影《万圣节》和《活死人之夜》有哪些共同特征,算法可以观察到它们出现在相似上下文中,并且在不同的上下文中可以相互替代。这种可替代性的概念是大多数嵌入算法的基础,并且确实被用来构建用于电影推荐算法和其他领域的嵌入。在下一节中,我们将看到一种流行的语言嵌入算法是如何使用可替代性来发现词语的语义含义的。
理解词嵌入
如果英语中大约有一百万个单词,那么基于语言模型的特征空间在考虑短语和词序之前就已经有大约一百万维!这显然对于大多数传统学习算法来说太大且稀疏,以至于无法找到有意义的信号。正如在第四章“概率学习——使用朴素贝叶斯进行分类”中描述的,词袋方法在足够的计算能力下可能可行,但它也需要大量的训练数据来将单词与期望的结果关联起来。那么,如果我们能够使用在大数据上预训练的语言嵌入会怎样呢?
为了说明这种替代方法的优点,让我们设想一个机器学习任务:决定是否向在社交媒体网站上发帖的用户展示午餐咖啡馆的广告。考虑以下由假设用户发表的帖子:
-
我在早上吃了培根和鸡蛋,这是一天中最重要的餐食!
-
我下午要去健身房之前,打算快速吃个三明治。
-
谁能为我今晚的约会推荐一些餐厅?
对于朴素贝叶斯方法,我们首先需要许多这类句子,但由于该算法是一个监督学习器,我们还需要一个目标特征,用来指示撰写句子的用户是否对从咖啡馆购买午餐感兴趣。然后我们可以训练模型来识别哪些单词可以预测购买午餐。
与此相比,一个阅读这些句子的人类可以很容易地猜测出三位用户中哪一位最有可能对今天购买午餐感兴趣。人类的猜测并不是基于专门训练来预测午餐购买行为,而是基于对每个句子中单词嵌入意义的理解。换句话说,因为人类理解了用户单词的意义,所以我们不需要猜测他们的行为,我们只需倾听他们告诉我们他们计划做什么。
最有效的语言模型不仅仅是查看单词的意义;它们还会考虑单词与其他单词之间的关系。语法和句式的使用可以完全改变句子的含义。例如,“我今天跳过了早餐,所以午餐可以大吃一顿”这句话与“我今天早餐大吃一顿,所以今天需要跳过午餐”这句话虽然几乎包含完全相同的单词,但意义却大相径庭!
现在先不考虑这种结构是如何构建的,假设我们有一个非常简单的语言嵌入,它可以在两个维度上捕捉所有英语单词的意义:一个“午餐”维度,用来衡量一个术语与午餐的相关性,以及一个“食物”维度,用来表示一个术语是否与食物相关。在这个模型中,曾经由独特的、具体的术语如“汤”和“沙拉”传达的语义意义,现在由这些概念在二维空间中的位置来表示,如图 15.5 所示:

图 15.5:一个非常简单的嵌入将各种单词高度维度的意义简化为机器可以用来理解“食物”和“午餐”主观概念的二维空间
嵌入本身是将一个单词映射到低维空间中的坐标。因此,查找函数可以提供特定单词的值。例如,使用上述 2 维单词嵌入,我们可以获得可能出现在社交媒体帖子中的术语的坐标:
-
f(三明治) = (0.97, 0.54)
-
f(培根) = (-0.88, 0.75)
-
f(苹果) = (0.63, 0.25)
-
f(橙子) = (-0.38, 0.13)
第一维数值较高的术语与午餐(仅限于午餐)有更具体的关联性,而较低的数值表示与午餐具体不相关的术语。例如,“三明治”有较高的午餐值,而“培根”有较低的午餐值,因为它们分别与午餐和早餐有紧密的关联。同样,第二维数值较高或较低的术语更有可能或不太可能是食物。单词“橙子”和“苹果”都可以是食物,但前者还可以代表一种颜色,而后者可以代表计算机,所以它们在食物维度上接近中间位置。相比之下,单词“培根”和“三明治”在这个维度上较高,但低于“玉米卷”或“意大利面”,因为它们在烹饪语境之外的意义;有人可以“带回家培根”(即他们可以赚钱)或一个物品可以被“夹在”其他物品之间。
这种嵌入类型的一个有趣且有用的特性是,单词可以通过简单的数学和最近邻样式的距离计算相互关联。在二维图中,我们可以通过检查水平或垂直轴上的镜像词或邻近词来观察这一特性。这导致以下观察:
-
苹果是比橙子更与午餐相关的版本
-
牛肉像鸡肉,但与午餐的关联性不如鸡肉
-
皮塔饼和玉米卷在某种程度上是相似的,烤肉串和三明治也是如此
-
汤和沙拉密切相关,是鸡蛋和意面的午餐版本
-
在午餐方面,“重”和“轻”是相对的,下午和晚上也是如此
-
“棕色纸袋”像“苹果”一样有午餐的感觉,但食物感较弱
尽管这是一个简单、人为编造的例子,但使用大数据开发出的词嵌入具有类似的数学特性——尽管维度数量要高得多。正如你很快就会看到的,这些额外的维度允许对词义的其他方面进行建模,并极大地丰富了嵌入,远远超出了迄今为止所展示的“午餐”和“食物”维度。
示例——在 R 中使用 word2vec 理解文本
前几节介绍了将嵌入作为一种在低维空间中编码高度概念的方法。我们还了解到,从概念上讲,这个过程涉及训练计算机通过应用类似人类的学习联想过程来了解各种术语的可替换性。但到目前为止,我们还没有探讨执行这一壮举的算法。有几种这样的方法,这些方法是由大数据公司或研究大学开发的,并已与公众分享。
可能最广泛使用的词嵌入技术之一是word2vec,该技术于 2013 年由谷歌研究团队发布,正如其名所示,它将词汇直接转换为向量。根据作者的说法,它不是一个单一的算法,而是一系列可用于自然语言处理任务的方法集合。尽管自 word2vec 发布以来已经有许多新的方法被提出,但它仍然很受欢迎,并且至今仍被广泛研究。理解 word2vec 的全貌超出了本章的范围,但了解其一些关键组件将为理解许多其他自然语言处理技术提供一个基础。
要深入了解 word2vec 方法,请参阅 Mikolov, T.,Chen, K.,Corrado, G.和 Dean, J.于 2013 年发表的《在向量空间中高效估计词表示》,链接为arxiv.org/abs/1301.3781。另一个早期但广泛使用的词嵌入方法是GloVe 算法,该算法于 2014 年由斯坦福大学的研究团队发布,并使用了一套类似的方法。有关 GloVe 的更多信息,请参阅nlp.stanford.edu/projects/glove/。
考虑一台计算机试图通过阅读大量文本(如网页或教科书)来学习。为了开始学习哪些词汇是相互关联并可互相替换的,计算机需要一个关于“上下文”的正式定义,以将范围限制在比整个文本更合理的东西上,尤其是如果文本很大。为此,word2vec 技术定义了一个窗口大小参数,该参数决定了在尝试理解单个词汇时将使用多少上下文词汇。较小的窗口大小保证了上下文中词汇之间的紧密关联,但由于相关词汇可以出现在句子中的较后位置,窗口太小可能会导致错过词汇和思想之间的重要关系。需要平衡,因为窗口太大可能会在文本的较早或较晚位置引入无关的思想。通常,窗口被设置为大约句子的长度,即大约五到十个单词,不包括像“和”、“但是”和“the”这样的无意义停用词。
给定由大约句子长度的词汇集组成的上下文,word2vec 过程采用两种方法之一。连续词袋模型(CBOW)方法训练一个模型来预测上下文中的每个词汇;跳字模型则相反,当提供一个输入词汇时,尝试猜测周围的上下文词汇。尽管两种方法的基本过程几乎相同,但数学上的细微差别会导致使用不同方法时产生不同的结果。
因为我们仅仅是概念上理解这些方法,所以可以说 CBOW 方法倾向于创建偏好于彼此几乎相同替换或真正同义词的嵌入,例如“apple”(苹果)和“apples”(苹果),或者“burger”(汉堡)和“hamburger”(汉堡包),而 skip-gram 方法则偏好概念上相似的术语,如“apple”(苹果)和“fruit”(水果)或“burger”(汉堡)和“fries”(薯条)。
对于 CBOW 和 skip-gram,开发嵌入的过程是相似的,可以理解为以下。从一个句子如“an apple is a fruit I eat for lunch”(我午餐吃苹果是一种水果)开始,构建一个模型,试图将一个词如“apple”(苹果)与其上下文如“fruit”(水果)、“eat”(吃)和“lunch”(午餐)联系起来。通过迭代大量这样的句子——如“a banana is a fruit people eat for breakfast”(香蕉是一种人们早餐吃的水果)或“an orange is both a fruit and a color”(橙子既是水果也是颜色)等等——可以确定嵌入的值,使得嵌入最小化单词与其上下文之间的预测误差。因此,在相似上下文中一致出现的单词将具有相似的嵌入值,因此可以被视为相似、可互换的概念:

图 15.6:word2vec 过程创建了一个将每个术语与其上下文相关联的嵌入
从技术上来说,尽管 word2vec 方法在许多方面与深度学习类似,但它不被认为是“深度学习”。如图所示,嵌入本身可以想象成神经网络中的一个隐藏层,这里用四个节点表示。在 CBOW 方法中,输入层是输入项的一个 one-hot 编码,每个可能的词汇都有一个节点,但只有一个节点值为 1,其余节点设置为 0 值。输出层也有每个词汇中的一个节点,但可以有多个“1”值——每个值代表出现在输入项上下文中的单词。
注意,对于 skip-gram 方法,这种安排将会相反:

图 15.7:开发嵌入涉及训练一个模型,其过程类似于深度学习
调整隐藏层中节点的数量会影响网络的复杂性和模型对每个术语语义理解的深度。节点数量越多,对每个术语在其上下文中的理解就越丰富,但训练成本会大幅增加,并且需要更多的训练数据。每个额外的节点都为区分每个术语提供了一个额外的维度。节点过少时,模型将没有足够的维度来捕捉每个术语使用的许多细微差别——例如,“orange”作为颜色与“orange”作为食物之间的区别——但使用过多的维度可能会增加模型被噪声分散注意力的风险,或者更糟,使得模型对于嵌入的初始目的——降维——变得无用!正如你很快就会亲身体验到的,尽管到目前为止展示的嵌入仅使用了几个维度以保持简单和说明性,但实际中使用的词嵌入通常具有数百个维度,并且需要大量的训练数据和计算能力来训练。
在 R 中,通过 Jan Wijffels 安装的word2vec包将提供对 word2vec 算法 C++实现的封装。如果需要,该包可以在提供文本数据语料库的情况下训练词嵌入,但通常更倾向于使用可以从网络上下载的预训练嵌入。在这里,我们将使用一个使用包含 1000 亿个书面单词的 Google 新闻存档进行训练的嵌入。
结果嵌入包含 300 维向量,用于 300 万个单词和简单短语,可在以下 Google word2vec 项目页面下载:code.google.com/archive/p/word2vec/。为了跟随示例,查找GoogleNews-vectors-negative300.bin.gz文件的链接,然后下载、解压并将文件保存到您的 R 项目文件夹中,然后再继续。
作为一句警告,Google 新闻嵌入相当大,大约为 1.5 GB 的压缩文件(解压后为 3.4 GB),并且不幸的是,不能与本章的代码一起分发。此外,该文件在项目网站上可能难以找到。如果需要,请在您的网络浏览器中使用查找命令(Ctrl + F 或 Command + F)搜索页面上的文件名。根据您的平台,您可能需要一个额外的程序来解压使用 Gzip 压缩算法(.gz文件扩展名)的文件。
如以下代码所示,要将 Google 新闻嵌入读取到 R 中,我们将加载word2vec包并使用read.word2vec()函数。在尝试此步骤之前,请确保您已下载并安装了word2vec包和 Google 新闻嵌入:
> library(word2vec)
> m_w2v <- read.word2vec(file = "GoogleNews-vectors-negative300.bin",
normalize = TRUE)
如果嵌入加载正确,str()命令将显示有关此预训练模型的相关细节:
> str(m_w2v)
List of 4
$ model :<externalptr>
$ model_path: chr "GoogleNews-vectors-negative300.bin"
$ dim : int 300
$ vocabulary: num 3e+06
- attr(*, "class")= chr "word2vec"
如预期的那样,嵌入向量对于每个 300 万个术语都有 300 个维度。我们可以使用predict()作为模型对象的查找函数来获取术语(或术语)的这些维度。type = "embedding"参数请求术语的嵌入向量,而不是最相似的术语,这将在稍后演示。
在这里,我们将请求与早餐、午餐和晚餐相关的一些术语的词向量:
> foods <- predict(m_w2v, c("cereal", "bacon", "eggs",
"sandwich", "salad", "steak", "spaghetti"),
type = "embedding")
> meals <- predict(m_w2v, c("breakfast", "lunch", "dinner"),
type = "embedding")
之前的命令创建了一个名为foods和meals的矩阵,行反映了术语,列表示嵌入的 300 个维度。我们可以如下检查单个词向量cereal的前几个值:
> head(foods["cereal", ])
[1] -1.1961552 0.7056815 -0.4154012 3.3832674 0.1438890 -0.2777683
或者,我们可以检查所有食物的前几列:
> foods[, 1:5]
[,1] [,2] [,3] [,4] [,5]
cereal -1.1961552 0.7056815 -0.4154012 3.383267 0.1438890
bacon -0.4791541 -0.8049789 0.5749849 2.278036 1.2266345
eggs -1.0626601 0.3271616 0.3689792 1.456238 -0.3345411
sandwich -0.7829969 -0.3914984 0.7379323 2.996794 -0.2267311
salad -0.6817439 0.9336928 0.6224619 2.647933 0.6866841
steak -1.5433296 0.4492917 0.2944511 2.030697 -0.5102126
spaghetti -0.2083995 -0.6843739 -0.4476731 3.828377 -1.3121454
尽管我们不知道这五个维度代表什么(也不了解未显示的其余 295 个维度),但我们预计相似、可替代性更强的食物和概念在 300 维空间中会更接近。我们可以利用这一点,使用word2vec_similarity()函数来测量食物与一天三餐的相关性,如下所示:
> word2vec_similarity(foods, meals)
breakfast lunch dinner
cereal 0.6042315 0.5326227 0.3473523
bacon 0.6586656 0.5594635 0.5982034
eggs 0.4939182 0.4477274 0.4690089
sandwich 0.6928092 0.7046211 0.5999536
salad 0.6797127 0.6867730 0.6821324
steak 0.6580227 0.6383550 0.7106042
spaghetti 0.6301417 0.6122567 0.6742931
在这个输出中,更高的值表示食物与三个用餐时间之间的相似性更高,根据 300 维词嵌入。不出所料,像谷物、培根和鸡蛋这样的早餐食品比午餐或晚餐更接近单词breakfast。三明治和沙拉最接近午餐,而牛排和意大利面最接近晚餐。
虽然它在前面的例子中没有使用,但使用余弦相似度度量是一个流行的约定,它只考虑比较向量的方向,而不是默认的类似于欧几里得距离的度量,后者考虑方向和大小。可以通过在调用word2vec_similarity()函数时指定type = "cosine"来获得余弦相似度。在这里,它不太可能对结果产生重大影响,因为当 Google 新闻向量被加载到 R 中时,它们已经被归一化了。
为了更实际地应用 word2vec 概念,让我们回顾一下之前提出的假设社交媒体帖子,并尝试确定是否向用户展示早餐、午餐或晚餐广告。我们将首先创建一个user_posts字符向量,该向量存储每篇帖子的原始文本:
> user_posts = c(
"I eat bacon and eggs in the morning for the most important meal of the day!",
"I am going to grab a quick sandwich this afternoon before hitting the gym.",
"Can anyone provide restaurant recommendations for my date tonight?"
)
重要的是,在将 word2vec 应用于每个用户帖子之前,我们必须克服一个重大的障碍;具体来说,每个帖子是由多个术语组成的句子,而 word2vec 仅设计用于返回单个单词的向量。不幸的是,这个问题没有完美的解决方案,选择正确的解决方案可能取决于预期的用例。例如,如果应用程序的目的是仅识别发布特定主题的人,那么遍历帖子中的每个单词并确定是否有任何单词达到相似度阈值可能就足够了。
存在更多复杂的替代方案来解决将 word2vec 应用于较长的文本字符串的问题。一个常见的但相对粗糙的解决方案是简单地平均句子中所有单词的 word2vec 向量,但这种方法往往会导致较差的结果,原因与混合过多的油漆颜色导致难看的棕色色调相似。随着句子的变长,对所有单词的平均处理会由于一些单词的向量不可避免地指向相反方向而造成混乱,导致平均结果毫无意义。此外,随着句子的复杂性增加,单词顺序和语法更有可能影响句子中单词的意义。
一种称为 doc2vec 的方法试图通过调整 word2vec 的训练以适应更长的文本块,称为文档,这些文档不必是完整的文档,但可能是段落或句子。doc2vec 的原理是基于文档中出现的单词为每个文档创建一个嵌入。然后,可以通过比较文档向量来确定两个文档的整体相似度。在我们的案例中,目标将是比较两个文档(即句子)是否传达了相似的想法——例如,用户的帖子是否与其他关于早餐、午餐或晚餐的句子相似?
不幸的是,我们无法访问 doc2vec 模型来使用这种更复杂的方法,但我们可以应用word2vec包的doc2vec()函数为每个用户帖子创建一个文档向量,并将文档向量视为一个单独的单词。正如之前所述,对于较长的句子,这可能会创建一个混乱的向量,但由于社交媒体帖子通常简短且直接,这个问题可能得到缓解。
我们将首先加载tm包,该包在第四章,概率学习 - 使用朴素贝叶斯进行分类中介绍,作为处理文本数据的一系列工具。该包提供了一个stopwords()函数,可以与它的removeWords()函数结合使用,从社交媒体帖子中删除无用的术语。然后,使用txt_clean_word2vec()函数为使用doc2vec做准备:
> library(tm)
> user_posts_clean <- removeWords(user_posts, stopwords())
> user_posts_clean <- txt_clean_word2vec(user_posts_clean)
要查看这种处理的成果,让我们看看第一个清理过的用户帖子:
> user_posts_clean[1] # look at the first cleaned user post
[1] "i eat bacon eggs morning important meal day"
如预期的那样,文本已经被标准化,并且所有无用的词汇都被移除了。然后我们可以将帖子提供给doc2vec()函数,并附带预训练的 Google News word2vec 模型,如下所示:
> post_vectors <- doc2vec(m_w2v, user_posts_clean)
这个操作的结果是包含三行(每行代表一个文档)和 300 列(每列代表嵌入中的每个维度)的矩阵。str()命令显示了该矩阵的前几个值:
> str(post_vectors)
num [1:3, 1:300] -1.541 0.48 -0.825 -0.198 0.955 ...
我们需要将这些伪文档向量与早餐、午餐和晚餐的词向量进行比较。这些向量之前使用predict()函数和 word2vec 模型创建,但在此处重复代码以保持清晰:
> meals <- predict(m_w2v, c("breakfast", "lunch", "dinner"),
type = "embedding")
最后,我们可以计算这两个向量之间的相似度。每一行代表一个用户的帖子,列值表示该帖子的文档向量与相应术语之间的相似度:
> word2vec_similarity(post_vectors, meals)
breakfast lunch dinner
[1,] 0.7811638 0.7695733 0.7151590
[2,] 0.6262028 0.6700359 0.5391957
[3,] 0.5475215 0.5308735 0.5646606
出乎意料的是,关于培根和鸡蛋的用户帖子与早餐这个词最相似,而关于三明治的帖子与午餐最相似,而晚上的约会与晚餐最相关。我们可以使用每行的最大相似度来确定是否向每个用户显示早餐、午餐或晚餐广告。
文档向量也可以直接用作监督机器学习任务中的预测器。例如,第十四章,构建更好的学习者,描述了一个基于用户的基线数据、个人资料图片和社交媒体帖子文本预测 Twitter 用户性别或未来购买行为的理论模型。
该章节提出了将传统机器学习模型与用于图像数据的深度学习模型以及用于用户帖子的朴素贝叶斯文本模型进行集成。或者,也可以直接使用文档向量,将 300 个维度视为 300 个单独的预测器,监督学习算法可以使用这些预测器来确定哪些与预测用户的性别相关:

图 15.8:来自非结构化文本数据的文档向量的值可以与更传统的预测器并排用于预测模型
这种为无结构的文本块创建文档向量,并使用结果嵌入值作为监督学习预测器的策略,作为一种增强传统机器学习性能的方法,相当具有通用性。许多数据集包括未使用的非结构化文本字段,因为它们的复杂性或无法训练语言模型。然而,通过预训练的词嵌入实现的相对简单的转换使得文本数据可以在模型中与其他预测器一起使用。因此,没有理由不采用这种方法,并在下次遇到此类机器学习任务时为学习算法提供大数据的注入。
可视化高维数据
数据探索是任何机器学习项目中涉及的五个关键步骤之一,因此不会免受所谓的维度诅咒——随着特征数量的增加,项目变得越来越具有挑战性的趋势。在处理更简单数据集上的可视化技术可能随着维度的增加而变得无用;例如,散点图矩阵可能有助于识别十几个特征之间的关系,但当特征数量增加到几十或几百时,曾经有帮助的可视化可能迅速变成信息过载。
同样,我们可以没有太多困难地解释二维甚至三维的图表,但如果我们希望理解四个或更多维度之间的关系,则需要一种完全不同的方法。
虽然物理学表明宇宙有十个或十一个维度,但我们只体验到四个,并且只直接与其中三个互动。也许正因为如此,我们的大脑适应了最多在三个维度上理解视觉;此外,因为我们的大部分智力工作都是在黑板、白板、纸张或计算机屏幕这样的二维表面上进行的,所以我们习惯于看到最多在两个维度上表示的数据。有一天,随着虚拟或增强现实计算机界面的更加普及,我们可能会看到三维可视化创新的爆炸式增长,但直到那一天到来之前,我们需要工具来帮助在最多两个维度内展示高度维度的关系。
将高度维度的可视化降低到仅两个维度似乎是不可能的,但指导这一过程的原理却出奇地简单:在高度维度空间中位置接近的点需要在二维空间中保持接近的位置。如果你认为这个想法听起来有些熟悉,你并不会错;这正是本章前面描述的嵌入所指导的相同概念。关键的区别在于,虽然像 word2vec 这样的嵌入技术可以将高度维度的数据降低到几百维,但用于可视化的嵌入必须进一步降低维度,仅保留两个维度。
使用 PCA 进行大数据可视化的局限性
主成分分析(PCA),在第十三章“挑战性数据 – 过多、过少、过于复杂”中介绍,是一种能够将高度维度的数据集降低到二维的方法。你可能还记得,PCA 通过将多个相关属性的协方差表示为一个单一向量来工作。通过这种方式,从更大的特征集中,可以合成较少的新特征,称为成分。如果将成分的数量设置为两个,那么高度维度的数据集就可以通过简单的散点图进行可视化。
我们将首先将这种可视化技术应用于在第九章寻找数据组 – 使用 k-means 进行聚类中首次介绍过的 36 维社交媒体个人资料数据集。前几个步骤很简单;我们使用 tidyverse 读取数据并选择感兴趣的 36 列,设置随机种子为123456以确保你的结果与书中的一致,然后使用irlba包中的prcomp_irlba()函数找到数据集的两个主成分:
> library(tidyverse)
> sns_terms <- read_csv("snsdata.csv") |> select(basketball:drugs)
> library(irlba)
> set.seed(123456)
> sns_pca <- sns_terms |>
prcomp_irlba(n = 2, center = TRUE, scale = TRUE)
sns_pca$x 对象包含原始数据集的转换版本,其中 36 个原始维度已减少到 2。由于这是以矩阵形式存储的,我们首先将其转换为数据框,然后再将其传递到ggplot()函数中创建散点图:
> library(ggplot2)
> as.data.frame(sns_pca$x) |>
ggplot(aes(PC1, PC2)) + geom_point(size = 1, shape = 1)
结果可视化如下:

图 15.9:主成分分析(PCA)可以用于创建高维数据集的二维可视化,但结果并不总是特别有帮助
不幸的是,这个散点图揭示了使用 PCA 进行数据探索的一个局限性,即两个主成分通常在二维空间中在点之间产生很少的视觉分离。根据我们在第九章,寻找数据组 – 使用 k-means 进行聚类中的先前工作,我们知道存在使用社交媒体用户在社交媒体个人资料中使用相似关键词的集群。这些集群应该作为不同的分组在散点图中可见,但相反,我们看到一个大的点群和围绕边缘的明显异常值的散布。这里令人失望的结果并不仅限于这里使用的数据集,而且在这种方式使用 PCA 时是典型的。幸运的是,还有一种更适合数据探索的算法,将在下一节中介绍。
理解 t-SNE 算法
PCA 技术的底层数学利用协方差矩阵执行线性降维,并且得到的主成分旨在捕捉数据集的整体方差。这种效果就像是一种压缩算法,通过消除冗余信息来减少数据集的维度。虽然这显然是降维技术的一个重要且有用的属性,但对于数据可视化来说帮助不大。正如我们在上一节中观察到的,PCA“压缩”维度的这种趋势可能会掩盖数据中的重要关系——这正是我们在进行大数据探索时希望发现的关系的确切类型。
一种称为t-Distributed Stochastic Neighbor Embedding的技术,简称t-SNE,正是为了精确地作为高维数据集可视化的工具,因此解决了之前提到的 PCA 的不足。t-SNE 方法由 Laurens van der Maaten 于 2008 年发表,并迅速成为高维现实数据集大数据可视化的实际标准。Van der Maaten 及其他人发表了大量的案例研究,对比 PCA 和 t-SNE,并说明了后者的优势。然而,由于驱动 t-SNE 的数学非常复杂,我们将专注于从概念上理解它,并将其与其他之前介绍的相关方法进行比较。
要深入了解 t-SNE 算法的机制,请参阅原始出版物,《使用 t-SNE 可视化数据》,作者 van der Maaten, L.和 Hinton, G.,发表于《机器学习研究》第 9 卷,2008 年,第 2579-2606 页。
就像任何用于可视化高维数据集的技术一样,t-Distributed Stochastic Neighbor Embedding 的目标是确保在多维空间中靠近的点或“邻居”在低维(2-D 或 3-D)空间中也是紧密排列的。
t-SNE 名称中的单词embedding突出了其与更一般任务之间的紧密联系,即构建嵌入,如前几节所述。然而,正如很快就会显现的,t-SNE 使用的方法与用于创建词嵌入的深度学习类似物不同。首先,t-SNE 名称中的单词stochastic描述了算法的非确定性,这意味着输出中存在相当大的随机性。但还有更多根本性的差异。
要开始理解 t-SNE 算法,想象一下如果任务仅仅是把三维降低到二维。在这种情况下,如果数据点以某种方式在三维空间中描绘为悬挂在空中的小球,而在二维空间中放置相同数量的数据点作为平面的圆盘,那么人类可以通过观察三维空间中的每个球,识别其邻居集,然后仔细地将二维空间中的圆盘移动到使邻居更靠近的位置来完成降维。当然,这比听起来要困难得多,因为在平面上移动圆盘使其更靠近或更远可能会无意中在三维空间中创建或消除分组。例如,将点 A 移动到更靠近其邻居点 B 的位置时,也可能使 A 更靠近点 C,而根据高维空间,A 和 C 应该是遥远的。因此,迭代观察每个三维点的邻居并移动其二维邻居,直到整体二维表示相对稳定,这一点非常重要。
同样的基本过程可以通过一系列数学步骤在更多的维度上算法化执行。首先,计算高维空间中每个点的相似性——传统上,使用熟悉的欧几里得距离度量为标准,如前几章中的 k-means 和 k-最近邻。这个相似性度量用于定义一个条件概率分布,表明相似点在更高维空间中成为邻居的可能性成比例更高。同样,为低维空间定义了类似的距离度量和条件概率分布。定义了这两个度量后,算法必须优化整个系统,使得高维和低维概率分布的整体误差最小化。记住,这两个度量通过它们依赖于相同的一组示例而不可分割地联系在一起;高维空间的坐标是已知的,因此本质上是在寻找一种方法,将高维坐标转换到低维空间,同时尽可能保留相似性。
由于 t-SNE 算法与 PCA 如此不同,它们在性能上的许多差异也就不足为奇了。以下表格展示了这两种方法的总体比较:
| PCA | t-SNE |
|---|
|
-
倾向于压缩可视化
-
展示全局(总体)方差
-
确定性算法每次运行都会产生相同的结果
-
没有需要设置的超参数
-
相对较快(对于可以放入内存的数据集)
-
涉及线性变换
-
通过创建额外的主成分,可以作为通用的降维技术使用
|
-
倾向于将可视化聚类
-
局部方差更为明显
-
随机算法将随机性引入结果
-
结果可能对超参数敏感
-
相对较慢(但存在更快的近似方法)
-
涉及非线性变换
-
通常仅作为数据可视化技术(二维或三维)使用
|
按照惯例,t-SNE 通常是大数据可视化的更合适工具,但值得注意的是一些差异,这些差异在某些情况下可能是弱点或挑战。首先,我们观察到主成分分析(PCA)在描绘数据中的自然聚类方面可能做得不好,但 t-SNE 在呈现聚类方面非常擅长,有时甚至可以在没有这些自然划分的数据集中形成聚类。这种错误由于 t-SNE 是一个非确定性算法,通常对超参数的值非常敏感而加剧;设置这些参数不当更有可能创建虚假的聚类或掩盖真实的聚类。最后,t-SNE 算法涉及反复迭代一个相对较慢的过程,但过早停止通常会产生较差的结果或产生对数据集结构的错误感觉;不幸的是,过多的迭代也可能导致相同的问题!
列出这些挑战并不是为了暗示 t-SNE 的工作量大于其价值,而是为了鼓励在彻底探索之前对输出持一定程度的怀疑态度。这可能意味着测试各种超参数组合,或者可能涉及对可视化进行定性检查,例如通过手动调查已识别的聚类来确定邻域有哪些共同特征。我们将在下一节中看到一些这些潜在陷阱的实际应用,该节将 t-SNE 应用于熟悉的现实世界数据集。
示例 - 使用 t-SNE 可视化数据的自然聚类
为了说明 t-SNE 描绘数据集自然聚类的能力,我们将该方法应用于之前与 PCA 一起使用的相同的 36 维社交媒体配置文件数据集。像之前一样,我们将使用 tidyverse 将原始数据读入 R,但由于 t-SNE 在计算上有些昂贵,我们使用 slice_sample() 命令将数据集限制为 5,000 个用户的随机样本。这并非绝对必要,但可以加快执行时间并使可视化不那么密集,从而更容易阅读。别忘了使用 set.seed(123) 命令以确保你的结果与以下结果匹配:
> library(tidyverse)
> set.seed(123)
> sns_sample <- read_csv("snsdata.csv") |>
slice_sample(n = 5000)
即使是相对较小的样本,标准的 t-SNE 实现也可能相当慢。相反,我们将使用一个名为Barnes-Hut 实现的更快版本。Barnes-Hut 算法最初是为了模拟所谓的“n-body”问题——一组n个天体之间出现的复杂引力关系系统。由于每个物体都对其他每个物体施加力,精确计算每个物体的总力需要n × n = n²次计算。由于宇宙的规模和其中几乎无限数量的物体,这在天文尺度上变得计算上不可行。Barnes-Hut 通过使用一种启发式方法简化了这个问题,该方法将更远的物体视为一个以其质心为标识的组,并且只对距离小于由希腊字母theta表示的阈值的物体进行精确计算。theta 值越大,所需的计算次数就越少,而将 theta 设置为零则执行精确计算。
因为 t-SNE 的作用可以想象为在空间中定位点的n-body 问题,其中每个点对其他点在 2-D 空间中的吸引力基于它与高维空间中相同点的相似程度,Barnes-Hut 简化可以应用于简化系统类似重力作用的计算。这提供了一个在大型数据集上运行更快且扩展性更好的 t-SNE 实现。
Rtsne 包,如果你还没有安装,应该安装它,它提供了一个对 C++实现的 Barnes-Hut t-SNE 的包装。它还包括用于处理高维数据集的其他优化。这些优化之一包括一个初始的 PCA 步骤,默认情况下将数据集减少到其前 50 个主成分。
虽然使用 PCA 作为 t-SNE 过程的一部分可能看起来有些奇怪,但这两个方法各有互补的优势和劣势。t-SNE 往往难以处理维度诅咒,而 PCA 在降维方面很强;同样,PCA 往往掩盖局部方差,而 t-SNE 突出了数据的天然结构。使用 PCA 来降低维度,然后跟随 t-SNE 过程应用了两种技术的优势。在我们的案例中,由于数据集只有 36 个维度,PCA 步骤对结果没有实质性影响。
我们将首先使用默认参数运行 t-SNE 过程。在设置随机种子后,5,000 行样本通过 select() 命令被导入,以仅选择每个用户资料中使用的各种术语计数的 36 列。然后,这些数据通过 Rtsne() 函数导入,其中 check_duplicates = FALSE 以防止当数据集存在重复行时出现的错误信息。在社交媒体数据集中发现重复行主要是因为许多用户对所有 36 个术语的计数为零。没有理由认为 t-SNE 方法不能处理这些重复项,但包括它们可能导致算法在尝试排列如此紧密的点集时出现意外或不美观的结果。对于社交媒体用户来说,看到这个簇将是有帮助的,因此我们将覆盖 Rtsne() 函数的默认设置如下:
> library(Rtsne)
> set.seed(123)
> sns_tsne <- sns_sample |>
select(basketball:drugs) |>
Rtsne(check_duplicates = FALSE)
将数据集导入 distinct() 函数将消除重复行,可以在 Rtsne() 命令之前使用。
36 维数据集的二维表示存储在 sns_tsne 列表对象中,名为 Y 的矩阵由 Rtsne() 函数创建。这个矩阵有 5,000 行,代表社交媒体用户,两列代表每个用户的 (x, y) 坐标。在将矩阵转换为数据框后,我们可以将这些值导入 ggplot() 函数,如下所示可视化 t-SNE 结果:
> library(ggplot2)
> data.frame(sns_tsne$Y) |>
ggplot(aes(X1, X2)) + geom_point(size = 2, shape = 1)
与早期的 PCA 可视化并排显示,可以看到 t-SNE 技术提供的视觉清晰度的巨大改进。可以观察到不同的用户簇,反映了这些用户在 36 维空间中的相似性:

图 15.10:与 PCA 相比,t-SNE 技术倾向于创建更多有用的可视化,这些可视化描绘了数据的自然簇
当然,t-SNE 可视化像这样第一次就工作得如此之好是相当不寻常的。如果你的结果令人失望,可能只是设置一个不同的随机种子就会因为 t-SNE 的随机化而产生更好的结果。此外,Rtsne() 函数的 perplexity 和 max_iter 参数可以调整以影响结果的尺寸和密度。perplexity 控制在从高维到低维调整时考虑的最近邻的数量,上下调整最大迭代次数 (max_iter) 可能会导致算法得出完全不同的解决方案。
很遗憾,调整这些参数的经验法则非常少,因此通常需要一些尝试和错误才能得到恰到好处。t-SNE 的创造者 Laurens van der Maaten 提供了一些智慧的话语:
…可以说,更大的/更密集的数据集需要更大的困惑度。困惑度的典型值介于 5 到 50 之间…[看到一个“球”中均匀分布的点]通常表明你设置的困惑度过高。 [如果你在调整后仍然看到不良结果]可能最初你的数据中并没有太多好的结构。
警告:Rtsne() 函数的参数,如 perplexity 和 max_iter,会极大地影响 t-SNE 算法收敛所需的时间。如果你不小心,你可能需要强制终止进程而不是无限期地等待。在 Rtsne() 函数调用中设置 verbose = TRUE 可能会提供关于工作进展的见解。
要了解 t-SNE 的参数和超参数的杰出处理,以及展示每个调整影响的交互式可视化,请参阅 如何有效地使用 t-SNE,Wattenberg, M.,Viégas, F.,和 Johnson, I.,2016,https://distill.pub/2016/misread-tsne/。
由于 t-SNE 是一种无监督方法,除了可视化右上角显著大且圆的簇——我们可以合理地假设它由没有社交媒体关键词的相同用户组成——我们不知道其他簇代表什么。尽管如此,我们可以通过根据其基础值用不同颜色或形状标记点来调查数据,以探究这些簇。
例如,我们可以通过创建每个用户页面上使用的关键词数量的分类度量来验证关于右上角簇的假设。以下 tidyverse 代码首先使用 bind_cols() 将 t-SNE 坐标附加到原始数据集上。接下来,它使用 rowwise() 函数改变 dplyr 的行为,使命令作用于行而不是列。因此,我们可以使用 sum() 函数计算每个用户在其个人资料中使用的术语数量,使用 c_across() 选择包含词频的列。在 ungroup() 移除行行为后,这个计数通过 if_else() 函数转换为一个两结果分类变量:
> sns_sample_tsne <- sns_sample |>
bind_cols(data.frame(sns_tsne$Y)) |> # add the t-SNE data
rowwise() |>
mutate(n_terms = sum(c_across(basketball:drugs))) |>
ungroup() |>
mutate(`Terms Used` = if_else(n_terms > 0, "1+", "0"))
使用这一系列步骤的结果,我们将再次绘制 t-SNE 数据,但根据使用的术语数量改变点的形状和颜色:
> sns_sample_tsne |>
ggplot(aes(X1, X2, shape = `Terms Used`, color = `Terms Used`)) +
geom_point(size = 2) +
scale_shape(solid = FALSE)
结果图证实了我们的假设,因为在其社交媒体个人资料中未使用任何术语的用户(用圆圈表示)构成了图右上角的密集簇,而使用一个或多个术语的用户(用三角形表示)散布在图的其余部分:

图 15.11:添加颜色或更改点样式可以帮助理解 t-SNE 可视化中描述的簇
t-SNE 技术不仅仅是一个制作精美图片的工具,尽管它在这方面也做得很好!首先,它可能有助于确定用于 k-means 聚类的k值。t-SNE 技术也可以在聚类完成后使用,根据点的聚类分配给它们上色,以展示聚类以便于展示。利益相关者更可能信任那些可以在 PowerPoint 演示中看到结果的模型。同样,t-SNE 可以用来定性评估嵌入(如 word2vec)的性能;如果嵌入是有意义的,将 300 维向量绘制在 2 维空间中将会揭示具有相关意义的单词簇。鉴于 t-SNE 有如此多的实用应用,它迅速成为数据科学工具箱中的流行工具也就不足为奇了。
对于一个有趣的应用,使用 word2vec 和 t-SNE,其中计算机学习了表情符号的意义,请参阅emoji2vec: Learning Emoji Representations from their Description, Eisner, B., Rocktäschel, T., Augenstein, I., Bošnjak, M., and Riedel, S., 2016, in Proceedings of the 4th International Workshop on Natural Language Processing for Social Media at EMNLP 2016。
尽管 word2vec 和 t-SNE 等工具提供了理解大数据的方法,但如果 R 无法处理工作负载,它们就没有用处。本章的剩余部分将为您提供额外的工具,用于加载、处理和建模如此大的数据源。
适应 R 以处理大型数据集
虽然短语“大数据”不仅仅意味着数据集的行数或数据集消耗的内存量,但有时处理大量数据本身就是一个挑战。当系统内存耗尽时,大型数据集可能导致计算机冻结或速度减慢到几乎不动,或者模型无法在合理的时间内构建。即使它们不是真正的“大”,许多现实世界的数据集也非常大,因此你可能在未来的项目中遇到这些问题。在这样做的时候,你可能会发现将数据转化为行动的任务比最初看起来更困难。
幸运的是,有一些包使得即使在 R 环境中也能更容易地处理大型数据集。我们将从查看允许 R 连接到数据库并处理可能超过可用系统内存的数据集的功能开始,以及允许 R 并行工作的包,还有一些利用云中现代机器学习框架的包。
在 SQL 数据库中查询数据
大型数据集通常存储在数据库管理系统(DBMS)中,如 Oracle、MySQL、PostgreSQL、Microsoft SQL 或 SQLite。这些系统允许使用结构化查询语言(SQL)访问数据集,这是一种旨在从数据库中提取数据的编程语言。
管理数据库连接的整洁方法
2017 年发布的 RStudio 版本 1.1 引入了一种连接到数据库的图形方法。界面右上角的 连接 选项卡提供了与系统上找到的数据库连接交互的能力。在此界面选项卡中单击 新建连接 按钮时,您将看到一个包含可用连接选项的窗口。以下截图显示了某些可能的连接类型,但您自己的系统可能具有与这里显示不同的选择:

图 15.12:RStudio v1.1 或更高版本中的“新建连接”按钮打开一个界面,该界面将帮助您连接到任何预定义的数据源
这些连接的创建通常由数据库管理员执行,并且特定于数据库类型以及操作系统。例如,在 Microsoft Windows 上,您可能需要安装适当的数据库驱动程序以及使用 ODBC 数据源管理员应用程序;在 macOS 和 Unix/Linux 上,您可能需要安装驱动程序并编辑 odbc.ini 文件。有关潜在连接类型和安装说明的完整文档可在 solutions.posit.co/connections/db/ 找到。
在幕后,图形界面使用各种 R 包来管理连接到这些数据源。此功能的核心是 DBI 包,它提供了一个符合 tidyverse 标准的前端界面到数据库。DBI 包还管理后端数据库驱动程序,这必须由另一个 R 包提供。这样的包让 R 可以连接到 Oracle (ROracle)、MySQL (RMySQL)、PostgreSQL (RPostgreSQL) 和 SQLite (RSQLite) 等多种数据库。
为了说明这一功能,我们将使用 DBI 和 RSQLite 包连接到一个包含之前使用的信用数据集的 SQLite 数据库。SQLite 是一个简单的数据库,不需要运行服务器。它只需连接到机器上的数据库文件,在这里命名为 credit.sqlite3。在开始之前,请确保您已安装了所需的两个包并将数据库文件保存到您的 R 工作目录中。完成此操作后,您可以使用以下命令连接到数据库:
> con <- dbConnect(RSQLite::SQLite(), "credit.sqlite3")
为了证明连接已成功建立,我们可以列出数据库表以确认预期的信用表存在:
> dbListTables(con)
[1] "credit"
从这里,我们可以向数据库发送 SQL 查询命令,并将记录作为 R 数据框返回。例如,为了返回年龄为 45 岁或以上的贷款申请人,我们将按以下方式查询数据库:
> res <- dbSendQuery(con, "SELECT * FROM credit WHERE age >= 45")
可以使用以下命令获取整个结果集作为数据框:
> credit_age45 <- dbFetch(res)
为了确认其工作正常,我们将检查摘要统计信息,这些信息确认年龄从 45 岁开始:
> summary(credit_age45$age)
Min. 1st Qu. Median Mean 3rd Qu. Max.
45.00 48.00 52.00 53.98 60.00 75.00
当我们的工作完成时,建议清除查询结果集并关闭数据库连接以释放这些资源:
> dbClearResult(res)
> dbDisconnect(con)
除了 SQLite 和特定数据库的 R 包之外,odbc 包允许 R 使用称为 开放数据库连接(ODBC)标准的单一协议连接到许多不同类型的数据库。无论操作系统或 DBMS 如何,都可以使用 ODBC 标准。
如果你之前已经连接到 ODBC 数据库,你可能通过其 数据源名称(DSN)来引用它。你可以使用 DSN 通过一行 R 代码创建数据库连接:
> con <- dbConnect(odbc:odbc(), "my_data_source_name")
如果你有一个更复杂的设置,或者想要手动指定连接属性,你可以将完整的连接字符串作为 DBI 包 dbConnect() 函数的参数指定,如下所示:
> library(DBI)
> con <- dbConnect(odbc::odbc(),
database = "my_database",
uid = "my_username",
pwd = "my_password",
host = "my.server.address",
port = 1234)
建立连接后,可以将查询发送到 ODBC 数据库,并使用与之前 SQLite 示例中相同的函数将表作为数据框返回。
由于安全和防火墙设置,配置 ODBC 网络连接的说明非常具体,针对每种情况。如果你在设置连接时遇到困难,请咨询你的数据库管理员。Posit 团队(以前称为 RStudio)也在 solutions.posit.co/connections/db/best-practices/drivers/ 提供了有用的信息。
使用 dbplyr 为 dplyr 提供数据库后端
使用 tidyverse 的 dplyr 函数与外部数据库相比,与传统的数据框使用并没有更难。dbplyr 包(简称“数据库 plyr”)允许使用 DBI 包支持的任何数据库作为 dplyr 的后端进行透明使用。这个连接允许从数据库中提取 tibble 对象。通常,你不需要做更多的事情,只需安装 dbplyr 包,然后 dplyr 就可以利用其功能。
例如,让我们连接到之前使用的 SQLite credit.sqlite3 数据库,然后使用 tbl() 函数将其 credit 表保存为 tibble 对象,如下所示:
> library(DBI)
> library(dplyr)
> con <- dbConnect(RSQLite::SQLite(), "credit.sqlite3")
> credit_tbl <- con |> tbl("credit")
因为 dplyr 已经通过数据库进行路由,所以这里的 credit_tbl 对象并不是以本地 R 对象的形式存储,而是一个数据库内的表。尽管如此,credit_tbl 将会像普通的 tibble 一样工作,并且会获得 dplyr 包的所有其他好处,唯一的例外是计算工作将在数据库内部而不是在 R 中进行。这意味着,如果将 SQLite 数据库替换为位于网络另一端的更传统的 SQL 服务器上的数据库,工作可以卸载到计算能力更强的机器上,而不是在本地机器上执行。
例如,为了查询数据库并显示至少 45 岁的信用申请人的年龄汇总统计信息,我们可以通过以下函数序列将 tibble 管道化:
> library(dplyr)
> credit_tbl |>
filter(age >= 45) |>
select(age) |>
collect() |>
summary()
结果如下:
age
Min. :45.00
1st Qu.:48.00
Median :52.00
Mean :53.98
3rd Qu.:60.00
Max. :75.00
注意,dbplyr函数是“惰性的”,这意味着在必要时才在数据库中执行工作。因此,collect()函数迫使dplyr从“服务器”(在这种情况下是一个 SQLite 实例,但更常见的是强大的数据库服务器)检索结果,以便计算汇总统计量。如果省略collect()语句,代码将失败,因为summary()函数不能直接与数据库连接对象一起工作。
给定一个数据库连接,大多数dplyr命令将无缝地在后端转换为 SQL。要了解这是如何工作的,我们可以要求dbplyr显示为一系列dplyr步骤生成的 SQL 代码。让我们构建一个稍微复杂一些的命令序列,以显示在过滤年龄为 45 岁及以上并按贷款违约状态分组后的平均贷款金额:
> credit_tbl |>
filter(age >= 45) |>
group_by(default) |>
summarize(mean_amount = avg(amount))
输出显示,那些违约的人平均倾向于要求更大的贷款金额:
# Source: SQL [2 x 2]
# Database: sqlite 3.41.2 [/MLwR/Chapter 15/credit.sqlite3]
default mean_amount
<chr> <dbl>
1 no 2709.
2 yes 4956.
注意,这与正常的dplyr输出不同,因为它包含了关于所使用数据库的信息,因为工作是在数据库中而不是在 R 中完成的。要查看执行此分析生成的 SQL 代码,只需将步骤通过show_query()函数管道传输即可:
> credit_tbl |>
filter(age >= 45) |>
group_by(default) |>
summarize(mean_amount = avg(amount)) |>
show_query()
输出显示了在 SQLite 数据库上运行的 SQL 查询:
<SQL>
SELECT `default`, avg(`amount`) AS `mean_amount`
FROM `credit`
WHERE (`age` >= 45.0)
GROUP BY `default`
使用dbplyr功能,在较小的数据框上使用的相同 R 代码也可以用来准备存储在 SQL 数据库中的大型数据集——繁重的工作是在远程服务器上完成的,而不是在您的本地笔记本电脑或台式机上。通过这种方式,学习 tidyverse 套件确保您的代码适用于从小型到大型任何类型的项目。当然,还有更多方法可以启用 R 与大型数据集一起工作,您将在接下来的章节中看到。
使用并行处理更快地完成工作
在计算机的早期阶段,计算机处理器总是顺序执行指令,这意味着它们一次只能执行一个任务。在顺序计算中,下一个指令不能开始,直到前一个指令完成:

图 15.13:在顺序计算中,任务不能开始,直到先前任务完成
尽管众所周知,许多任务可以通过同时完成步骤来更有效地完成,但这项技术当时并不存在。这个问题通过开发并行计算方法得到了解决,这些方法使用一组两个或更多的处理器或计算机来同时执行任务:

图 15.14:并行计算允许同时执行多个任务,这可以加快处理速度,但最终必须将结果合并
许多现代计算机都是为并行计算而设计的。即使在它们只有一个处理器的情况下,它们通常也有两个或更多个并行工作的核心。核心本质上是一个处理器内的处理器,它允许即使在其他核心忙于其他任务时也能进行计算。
由多台计算机组成的集群也可以用于并行计算。一个大型的集群可能包括各种硬件,并且分布在很大的距离上。在这种情况下,该集群被称为网格。如果将集群或网格扩展到数百或数千台运行通用硬件的计算机,这将是一个非常强大的系统。像 Amazon Web Services (AWS)和 Microsoft Azure 这样的云计算系统使得使用集群进行数据科学项目变得比以往任何时候都容易。
然而,并不是每个问题都可以并行化。某些问题比其他问题更适合并行执行。人们可能会预期增加 100 个处理器会在相同的时间内完成 100 倍的工作(也就是说,总执行时间将是 1/100),但通常并非如此。原因是管理工作者需要付出努力。工作必须分成相等且不重叠的任务,并且每个工作者的结果必须合并成一个最终答案。
所说的令人尴尬的并行问题是最理想的。这些任务很容易被简化为不重叠的工作块,并且结果很容易重新组合。一个令人尴尬的并行机器学习任务的例子是 10 折交叉验证;一旦将 10 个样本分开,每个工作块都是独立的,这意味着它们不会相互影响。正如你很快就会看到的,这个任务可以通过并行计算大大加快。
测量 R 的执行时间
如果无法系统地测量节省了多少时间,那么加快 R 的努力将是徒劳的。尽管秒表是一个选择,但一个更容易的解决方案是将有问题的代码包裹在system.time()函数中。
例如,在作者的笔记本电脑上,system.time()函数记录生成一百万个随机数大约需要 0.026 秒:
> system.time(rnorm(1000000))
user system elapsed
0.025 0.001 0.026
可以使用相同的函数来评估使用刚刚描述的方法或任何 R 函数获得的性能改进。
就其价值而言,当这本书的第一版出版时,生成一百万个随机数需要 0.130 秒;第二版需要大约 0.093 秒,第三版需要 0.067 秒。在这里,只需要 0.026 秒。尽管我每次都使用了一台稍微更强大的计算机,但在这大约十年的过程中,处理时间减少了大约 80%,这仅仅说明了计算机硬件和软件的进步是多么迅速!
在 R 中启用并行处理
R 版本 2.14.0 及以后的版本中包含的parallel包,通过提供一个标准框架来设置可以同时完成任务的工作进程,降低了部署并行算法的入门门槛。它是通过包含multicore和snow包的组件来实现的,每个包都采用不同的多任务处理方法。
如果你的计算机相对较新,你很可能能够使用并行处理。要确定你的机器有多少核心,可以使用以下detectCores()函数。请注意,你的输出将取决于你的硬件规格:
> library(parallel)
> detectCores()
[1] 10
由 Simon Urbanek 开发的multicore包允许在具有多个处理器或处理器核心的单台机器上进行并行处理。它利用计算机操作系统的多任务处理能力来fork,或创建共享相同内存的额外 R 会话的副本,可能是开始使用 R 进行并行处理的最简单方法。
注意,由于 Microsoft Windows 操作系统不支持 fork,multicore示例只能在 macOS 或 Linux 机器上运行。对于 Windows 兼容的解决方案,请跳到下一节关于foreach和doParallel。
开始使用multicore功能的一个简单方法是使用mclapply()函数,它是lapply()的多核版本。例如,以下代码块说明了如何将生成 1000 万个随机数的任务分配到 1、2、4 和 8 个核心。在每台核心完成其工作块后,使用unlist()函数将并行结果(一个列表)组合成一个单一的向量:
> system.time(l1 <- unlist(mclapply(1:10, function(x) {
rnorm(10000000)}, mc.cores = 1)))
user system elapsed
2.840 0.183 3.027
> system.time(l2 <- unlist(mclapply(1:10, function(x) {
rnorm(10000000)}, mc.cores = 2)))
user system elapsed
2.876 0.840 2.361
> system.time(l4 <- unlist(mclapply(1:10, function(x) {
rnorm(10000000) }, mc.cores = 4)))
user system elapsed
2.901 0.824 1.459
> system.time(l8 <- unlist(mclapply(1:10, function(x) {
rnorm(10000000) }, mc.cores = 8)))
user system elapsed
2.975 1.146 1.481
注意,随着核心数量的增加,经过的时间会减少,尽管这种好处会逐渐减弱,一旦添加了过多的核心,甚至可能产生负面影响。尽管这是一个简单的例子,但它可以很容易地适应许多其他任务。
由 Luke Tierney、A. J. Rossini、Na Li 和 H. Sevcikova 开发的snow包(简单工作站网络)允许在多核或多处理器机器以及多个机器的网络上进行并行计算。它使用起来稍微困难一些,但提供了更多的功能和灵活性。snow功能包含在parallel包中,因此要在单台机器上设置集群,请使用带有要使用核心数的makeCluster()函数:
> cl1 <- makeCluster(4)
由于snow通过网络流量进行通信,根据你的操作系统,你可能会收到一条消息,要求你批准通过防火墙的访问。
为了确认集群正在运行,我们可以要求每个节点报告其主机名。clusterCall()函数在集群中的每台机器上执行一个函数。在这种情况下,我们将定义一个简单的函数,该函数仅调用Sys.info()函数并返回nodename参数:
> clusterCall(cl1, function() { Sys.info()["nodename"] } )
[[1]]
nodename
"Bretts-Macbook-Pro.local"
[[2]]
nodename
"Bretts-Macbook-Pro.local"
[[3]]
nodename
"Bretts-Macbook-Pro.local"
[[4]]
nodename
"Bretts-Macbook-Pro.local"
出乎意料的是,由于所有四个节点都在同一台机器上运行,它们报告的宿主名称相同。为了使四个节点运行不同的命令,可以通过clusterApply()函数为它们提供唯一的参数。在这里,我们将为每个节点提供不同的字母。然后,每个节点将并行地对它的字母执行一个简单函数:
> clusterApply(cl1, c('A', 'B', 'C', 'D'),
function(x) { paste("Cluster", x, "ready!") })
[[1]]
[1] "Cluster A ready!"
[[2]]
[1] "Cluster B ready!"
[[3]]
[1] "Cluster C ready!"
[[4]]
[1] "Cluster D ready!"
当我们完成集群操作后,终止它所启动的进程是很重要的。这将释放每个节点所使用的资源:
> stopCluster(cl1)
使用这些简单的命令,可以加速许多机器学习任务。对于最大的大数据问题,可能的snow配置更加复杂。例如,你可能尝试配置一个Beowulf 集群——一个由许多消费级机器组成的网络。在具有专用计算集群的学术和工业研究环境中,snow可以使用Rmpi包来访问这些高性能的消息传递接口(MPI)服务器。与这样的集群一起工作需要了解超出本书范围的网络配置和计算硬件知识。
对于snow的更详细介绍,包括如何在多台计算机上配置网络并行计算的一些信息,请参阅 Luke Tierney 的以下讲座:homepage.stat.uiowa.edu/~luke/classes/295-hpc/notes/snow.pdf。
利用 foreach 和 doParallel 进行并行计算
Rich Calaway 和 Steve Weston 的foreach包提供了开始并行计算的最简单方法,特别是如果你在 Windows 操作系统上运行 R,因为其他一些包是特定平台的。
该包的核心是一个foreach循环结构。如果你使用过其他编程语言,这可能会很熟悉。本质上,它允许遍历一组项目,而不需要显式地计数项目数量;换句话说,对集合中的每个项目,都执行某些操作。
如果你认为 R 已经提供了一套 apply 函数来遍历项目集(例如,apply()、lapply()、sapply()等),你是正确的。然而,foreach循环有一个额外的优点:循环的迭代可以使用非常简单的语法并行完成。让我们看看这是如何工作的。
回想一下我们用来生成数百万随机数的命令。为了使这个任务更具挑战性,让我们将计数增加到一亿,这将使进程大约需要 2.5 秒:
> system.time(l1 <- rnorm(100000000))
user system elapsed
2.466 0.080 2.546
在安装了foreach包之后,可以使用一个循环来表示相同的任务,该循环结合了四组 2500 万个随机生成的数字。.combine参数是一个可选设置,它告诉foreach应该使用哪个函数来组合每个循环迭代的最终结果集。在这种情况下,由于每个迭代都生成一组随机数,我们只需使用c()连接函数来创建一个单一的、组合的向量:
> system.time(l4 <- foreach(i = 1:4, .combine = 'c')
%do% rnorm(25000000))
user system elapsed
2.603 0.106 2.709
如果您注意到这个函数没有导致速度提升,那是个很好的发现!实际上,这个过程更慢。原因是默认情况下,foreach包以串行方式运行每个循环迭代,并且该函数会给过程增加少量的计算开销。姐妹包doParallel为foreach提供了一个并行后端,它利用了 R 中包含的parallel包,这在本章前面已经描述过。
在并行化这项工作之前,明智的做法是确认您系统上可用的核心数,如下所示:
> detectCores()
[1] 10
您的结果将取决于您的系统能力。
接下来,在安装和加载doParallel包之后,只需注册所需的核心数,并将%do%命令与%dopar%运算符交换。在这里,我们最多只需要四个核心,因为只有四组随机数需要组合:
> library(doParallel)
> registerDoParallel(cores = 4)
> system.time(l4p <- foreach(i = 1:4, .combine = 'c')
%dopar% rnorm(25000000))
user system elapsed
2.868 1.041 1.571
如输出所示,这导致性能提升,将执行时间减少了大约 40%。
警告:如果将cores参数设置为大于您系统上可用核心数的数字,或者如果总工作量超过了您计算机上的空闲内存,R 可能会崩溃!在这种情况下,随机数向量几乎是一个 GB 的数据,因此具有低 RAM 的系统可能在这里特别容易崩溃。
要关闭doParallel集群,只需输入以下命令:
> stopImplicitCluster()
尽管集群将在 R 会话结束时自动关闭,但明确地这样做是更好的做法。
使用 caret 并行训练和评估模型
Max Kuhn 的caret包(在第十章 评估模型性能和第十四章 构建更好的学习者中已有介绍)如果使用前面描述的foreach包在 R 中注册了并行后端,将透明地利用并行后端。
让我们来看一个简单的例子,我们尝试在信用数据集上训练一个随机森林模型。在没有并行化的情况下,模型大约需要 65 秒来训练:
> library(caret)
> credit <- read.csv("credit.csv")
> system.time(train(default ~ ., data = credit, method = "rf",
trControl = trainControl(allowParallel = FALSE)))
user system elapsed
64.009 0.870 64.855
另一方面,如果我们使用doParallel包将八个核心注册为并行使用(如果您有少于八个核心可用,请确保降低此数字),模型大约需要 10 秒来构建——不到六分之一的时间——而且我们不需要更改剩余的caret代码:
> library(doParallel)
> registerDoParallel(cores = 8)
> system.time(train(default ~ ., data = credit, method = "rf"))
user system elapsed
68.396 1.692 10.569
在训练和评估模型的过程中涉及到的许多任务,例如创建随机样本和重复测试预测以进行 10 折交叉验证,都是可以并行处理的,非常适合性能提升。因此,在开始一个caret项目之前,始终注册多个核心是明智的。
在项目的网站上提供了配置说明和启用caret中并行处理性能提升的案例研究:topepo.github.io/caret/parallel-processing.html。
利用专用硬件和算法
基础 R 以其速度慢和内存效率低而闻名,这种声誉至少在一定程度上是应得的。对于包含数万条记录的数据集,这些错误在很大程度上是不被注意到的,但包含数百万条记录或更多记录的数据集可能会超过当前消费级硬件所能实现的极限。如果数据集包含许多特征或正在使用复杂的机器学习算法,问题会更加严重。
CRAN 有一个高性能计算任务视图,列出了在cran.r-project.org/web/views/HighPerformanceComputing.html上推动 R 可能性的边界包。
正在快速开发扩展 R 基础包功能的包。这些包允许 R 运行得更快,可能通过在额外的计算机或处理器上分散工作,利用专用计算机硬件,或者通过提供针对大数据问题优化的机器学习来实现。
通过 Apache Spark 实现具有 MapReduce 概念的并行计算
MapReduce编程模型是在谷歌开发的,用于在大型网络计算机集群上处理其数据。MapReduce 将并行编程概念化为一个两步过程:
-
映射步骤,其中将问题分解为更小的任务,这些任务分布在集群中的计算机上
-
归约步骤,其中将小块工作的结果收集并综合成解决原始问题的解决方案
作为专有 MapReduce 框架的流行开源替代品是Apache Hadoop。Hadoop 软件包括 MapReduce 概念以及一个能够存储大量数据并在计算机集群中分布的分布式文件系统。Hadoop 需要一定的专用编程技能来利用其功能,以及执行甚至基本的机器学习任务。此外,尽管 Hadoop 在处理极大量数据方面非常出色,但它可能不是最快的选项,因为它将所有数据都保存在磁盘上,而不是利用可用的内存。
Apache Spark 是一个用于大数据的集群计算框架,通过 Hadoop 解决这些问题。Spark 利用集群的可用内存,将数据处理速度提高约 100 倍于 Hadoop。此外,它还提供了易于使用的库,用于许多常见的数据处理、分析和建模任务。这些包括 SparkSQL 数据查询语言、MLlib 机器学习库、GraphX 用于图和网络分析,以及 Spark Streaming 库用于处理实时数据流。因此,Spark 可能是当前开源大数据处理的行业标准。
Packt Publishing 出版了许多关于 Spark 的书籍。要搜索他们的当前产品,请访问 subscription.packtpub.com/search?query=spark。
Apache Spark 通常在远程运行的云托管虚拟机集群上运行,但它的好处也可以在您自己的硬件上看到。在两种情况下,sparklyr 包连接到集群,并为使用 Spark 分析数据提供 dplyr 接口。
在 spark.rstudio.com 可以找到使用 Spark 与 R 的更详细说明,但启动和运行的基本说明很简单。
为了说明基础知识,让我们在信用数据集上构建一个随机森林模型来预测贷款违约。首先,您需要安装并加载 sparklyr 包。然后,您第一次使用 Spark 时,需要运行 spark_install() 函数,该函数将 Spark 下载到您的计算机上。请注意,这是一个大约 220 兆字节的较大下载,因为它包括了完整的 Spark 环境:
> library(sparklyr)
> spark_install()
此外,Spark 本身需要安装 Java,如果您还没有安装,可以从 http://www.java.com 下载。一旦 Spark 和 Java 安装完成,您可以使用以下命令在本地机器上实例化一个 Spark 集群:
> spark_cluster <- spark_connect(master = "local")
接下来,我们将从本地机器上的 credit.csv 文件加载贷款数据集到 Spark 实例中,然后使用 Spark 函数 sdf_random_split() 分别随机分配 75% 和 25% 的数据到训练集和测试集。seed 参数是随机种子,以确保每次运行此代码时结果都相同:
> splits <- sdf_random_split(credit_spark,
train = 0.75, test = 0.25,
seed = 123)
最后,我们将训练数据管道输入到随机森林模型函数中,进行预测,并使用分类评估器在测试集上计算 AUC:
> credit_rf <- splits$train |>
ml_random_forest(default ~ .)
> pred <- ml_predict(credit_rf, splits$test)
> ml_binary_classification_evaluator(pred,
metric_name = "areaUnderROC")
[1] 0.7824574
然后,我们将从集群断开连接:
> spark_disconnect(spark_cluster)
仅用几行 R 语言代码,我们就构建了一个使用 Spark 的随机森林模型,该模型可以扩展以处理数百万条记录。如果需要更多的计算能力,只需将 spark_connect() 函数指向正确的主机名,代码就可以在云中使用大规模并行 Spark 集群运行。代码也可以轻松地适应 Spark 机器学习库(MLlib)中列出的其他建模方法,这些方法包括在 spark.rstudio.com/mlib/ 中的监督学习函数。
可能开始使用 Spark 的最简单方式是通过 Databricks,这是一个由 Spark 的创造者开发的云平台,它通过基于网络的界面使管理集群和扩展集群变得容易。免费的“社区版”提供了一个小型集群,您可以尝试教程,甚至可以对自己的数据进行实验。请访问 databricks.com 了解详情。
使用 H2O 通过分布式和可扩展算法进行学习
H2O 项目 (h2o.ai) 是一个大数据框架,它提供了机器学习算法的快速内存实现,这些算法也可以在集群计算环境中运行。它包括本书中涵盖的许多方法,包括朴素贝叶斯、回归、深度神经网络、k-means 聚类、集成方法和随机森林等。
H2O 通过对数据的小块进行重复迭代来寻找机器学习问题的近似解。这使用户能够精确地确定学习者应该使用多少大规模数据集。对于某些问题,快速解决方案可能是可以接受的,但对于其他问题,可能需要完整的集合,这将需要额外的训练时间。
相比于 Spark 的机器学习函数,H2O 通常在处理非常大的数据集时速度更快,性能也更好,而 Spark 的机器学习函数已经比基础 R 语言快得多。然而,由于 Apache Spark 是一个常用的集群计算和大数据准备环境,H2O 可以通过 Sparkling Water 软件在 Apache Spark 上运行。使用 Sparkling Water,数据科学家可以同时享受到 Spark 在数据准备方面的优势和 H2O 在机器学习方面的优势。
h2o 包提供了在 R 环境中访问 H2O 实例的功能。关于 H2O 的完整教程超出了本书的范围,文档可在 http://docs.h2o.ai 查找,但基本概念是直接的。
要开始使用,请确保您的计算机上已安装 Java (www.java.com),并在 R 中安装 h2o 包。然后,使用以下代码初始化一个本地 H2O 实例:
> library(h2o)
> h2o_instance <- h2o.init()
这将在您的计算机上启动一个 H2O 服务器,您可以通过 H2O Flow 在http://localhost:54321查看它。H2O Flow 网络应用程序允许您管理并向 H2O 服务器发送命令,甚至可以使用简单的基于浏览器的界面构建和评估模型:

图 15.15:H2O Flow 是一个用于与 H2O 实例交互的 Web 应用程序
虽然您可以在该界面内完成分析,但让我们回到 R,并使用之前检查过的贷款违约数据应用 H2O。首先,我们需要使用以下命令将credit.csv数据集上传到此实例:
> credit.hex <- h2o.uploadFile("credit.csv")
注意,.hex扩展名用于指代 H2O 数据帧。
接下来,我们将使用以下命令将 H2O 的随机森林实现应用于此数据集:
> h2o.randomForest(y = "default",
training_frame = credit.hex,
ntrees = 500,
seed = 123)
此命令的输出包括模型性能的袋外估计信息:
** Reported on training data. **
** Metrics reported on Out-Of-Bag training samples **
MSE: 0.1637001
RMSE: 0.4045987
LogLoss: 0.4956604
Mean Per-Class Error: 0.2835714
AUC: 0.7844833
AUCPR: 0.6195022
Gini: 0.5689667
R²: 0.2204758
虽然这里使用的信用数据集并不大,但这里使用的 H2O 代码可以扩展到几乎任何大小的数据集。此外,如果要在云中运行,代码几乎不会改变——只需将h2o.init()函数指向远程主机即可。
GPU 计算
并行处理的一个替代方案是使用计算机的图形处理单元(GPU)来提高数学计算的效率。GPU 是一种专门处理器,它针对快速在计算机屏幕上显示图像进行了优化。由于计算机通常需要显示复杂的 3D 图形(尤其是视频游戏),许多 GPU 使用了专为并行处理和极其高效的矩阵和向量计算设计的硬件。
一个附带的好处是,它们可以用来高效地解决某些类型的数学问题。如图所示,一个典型的笔记本电脑或台式计算机处理器可能有大约 16 个核心,而一个典型的 GPU 可能有数千甚至数万个:

图 15.16:一个图形处理单元(GPU)的核心数量比典型的中央处理器(CPU)多得多
GPU 计算的一个缺点是它需要特定的硬件,而这并不是许多计算机都包含的。在大多数情况下,需要 NVIDIA 制造商的 GPU,因为它提供了一个专有的框架,称为完整统一设备架构(CUDA),这使得 GPU 可以使用如 C++等通用语言进行编程。
有关 NVIDIA 在 GPU 计算中作用的更多信息,请访问www.nvidia.com/en-us/deep-learning-ai/solutions/machine-learning/。
Josh Buckner、Mark Seligman 和 Justin Wilson 开发的gputools包实现了几个 R 函数,例如使用 NVIDIA CUDA 工具包进行矩阵运算、聚类和回归建模。该包需要 CUDA 1.3 或更高版本的 GPU 以及 NVIDIA CUDA 工具包的安装。这个包曾经是 R 中 GPU 计算的标准方法,但似乎自 2017 年以来没有更新,并且已经被从 CRAN 存储库中移除。
相反,GPU 工作似乎已经转向了 TensorFlow 数学库。RStudio 团队在以下页面提供了有关在本地或云上使用 GPU 的信息:
在出版时,用于深度学习的典型 GPU,入门级模型的价格为几百美元,而中等价格、性能更高的单元的价格在 1000-3000 美元之间。高端单元可能要花费数千美元。
而不是一开始就花费这么多,许多人选择在 AWS 和 Microsoft Azure 等云服务提供商按小时租用服务器时间,那里一个最小的 GPU 实例每小时大约花费 1 美元——只是别忘了当你的工作完成后关闭它,因为它可能会很快变得很昂贵!
摘要
研究机器学习无疑是激动人心的时刻。在相对未知的并行和分布式计算前沿领域的研究,为挖掘大数据洪流中的知识提供了巨大的潜力。而数据科学社区的蓬勃发展得益于免费开源的 R 编程语言,它为入门提供了非常低的门槛——你只需要愿意学习。
你在本章以及之前章节中学到的主题,为理解更高级的机器学习方法提供了基础。现在,你的责任是继续学习和添加工具到你的工具箱中。在这个过程中,务必牢记没有免费午餐定理——没有学习算法适用于所有情况,它们都有各自的优势和劣势。因此,机器学习中始终会有人类因素,这包括特定领域的知识以及将适当的算法匹配到当前任务的能力。
在未来几年里,将很有趣地看到随着机器学习和人类学习之间的界限变得模糊,人类方面将如何改变。像 Amazon 的 Mechanical Turk 这样的服务提供了众包智能,提供了一群随时准备执行简单任务的人类大脑。也许有一天,就像我们使用计算机来完成人类难以轻易完成的任务一样,计算机将利用人类来完成相反的任务。这真是一个有趣的思考!
加入我们书籍的 Discord 空间
加入我们的 Discord 社区,与志同道合的人相聚,并和超过 4000 人一起学习,详情请访问:



浙公网安备 33010602011771号