R-机器学习第二版-全-
R 机器学习第二版(全)
原文:
annas-archive.org/md5/e79782597ad902714a518953c5d1e70b
译者:飞龙
前言
机器学习的核心关注点是将信息转化为可操作的智能的算法。正是这一点使得机器学习非常适合当今大数据的时代。如果没有机器学习,几乎不可能跟上庞大的信息流。
鉴于 R 语言——一种跨平台、零成本的统计编程环境——的日益流行,现在是开始使用机器学习的最佳时机。R 提供了一套功能强大且易于学习的工具,可以帮助你挖掘数据中的洞察。
本书通过结合实际案例研究与必需的理论,帮助你理解事物在幕后是如何运作的,提供了所有你需要的知识,来开始将机器学习应用到你自己的项目中。
本书内容概述
第一章,机器学习简介,介绍了定义和区分机器学习者的术语和概念,并提供了一种将学习任务与合适算法匹配的方法。
第二章,数据管理与理解,提供了在 R 中动手处理数据的机会。讨论了加载、探索和理解数据时使用的基本数据结构和操作过程。
第三章,懒惰学习——使用最近邻进行分类,教你如何理解并应用一种简单而强大的机器学习算法来解决你的第一个现实世界任务——识别恶性癌症样本。
第四章,概率学习——使用朴素贝叶斯进行分类,揭示了在前沿垃圾邮件过滤系统中使用的概率基本概念。你将在构建自己垃圾邮件过滤器的过程中学习文本挖掘的基础。
第五章,分治法——使用决策树和规则进行分类,探讨了几种学习算法,这些算法不仅预测准确,而且易于解释。我们将把这些方法应用到那些透明性至关重要的任务中。
第六章,数值数据预测——回归方法,介绍了用于进行数值预测的机器学习算法。由于这些技术深深植根于统计学领域,你还将学习解读数值关系所需的基本度量。
第七章,黑箱方法 – 神经网络与支持向量机,讲解了两种复杂但强大的机器学习算法。虽然数学可能让人感到望而却步,但我们将通过示例简单地阐述这些算法的内部运作。
第八章,寻找模式 – 使用关联规则的市场篮子分析,揭示了许多零售商推荐系统中使用的算法。如果你曾经好奇零售商如何比你自己还了解你的购物习惯,本章将揭示它们的秘密。
第九章,寻找数据群体 – 使用 k-means 聚类,专注于一个寻找相关项目聚类的过程。我们将使用该算法来识别在线社区中的用户画像。
第十章,评估模型表现,提供了衡量机器学习项目成功与否的信息,以及如何获得对未来数据上学习者表现的可靠估计。
第十一章,改善模型表现,揭示了机器学习竞赛排行榜顶端团队使用的方法。如果你有竞争心,或者只是想从你的数据中获取更多的价值,那么你将需要将这些技巧加入到你的技能库中。
第十二章,专业化机器学习主题,探索了机器学习的前沿。从大数据处理到提高 R 的运行速度,本章涵盖的主题将帮助你突破 R 的应用边界。
本书所需条件
本书中的示例是为 R 版本 3.2.0 在 Microsoft Windows 和 Mac OS X 系统上编写并测试的,尽管它们也很可能适用于任何最新版本的 R。
本书适合谁
本书面向任何希望将数据用于实际行动的人。也许你已经了解一些机器学习的知识,但从未使用过 R;或者你知道一点 R,但对机器学习还很陌生。无论如何,本书将帮助你快速上手。稍微了解一些基础数学和编程概念会有所帮助,但不要求有任何先前的经验。你所需要的只是好奇心。
约定
在本书中,你会看到多种文本样式,用于区分不同类型的信息。以下是这些样式的示例以及它们的含义。
文本中的代码字、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号如下所示:“安装包最直接的方式是通过install.packages()
函数。”
一块代码设置如下:
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
任何命令行输入或输出如下所示:
> 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
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,出现在文本中如:“CRAN 页面左侧的任务视图链接提供了一个经过筛选的包列表。”
注意
警告或重要提示会显示在如下框内。
提示
小贴士和技巧如下所示。
读者反馈
我们随时欢迎读者的反馈。告诉我们您对本书的看法——喜欢什么,或不喜欢什么。读者反馈对我们非常重要,它帮助我们开发出您能够真正受益的书籍。
如需向我们提供一般反馈,请通过电子邮件发送至<feedback@packtpub.com>
,并在邮件主题中提到书籍的标题。
如果您在某个领域具有专业知识,并且有兴趣撰写或参与书籍的创作,请参考我们的作者指南:www.packtpub.com/authors。
客户支持
既然您已经是 Packt 图书的骄傲拥有者,我们为您准备了多项资源,帮助您充分利用您的购买。
下载示例代码
您可以从您的账户中下载示例代码文件,访问www.packtpub.com
,获取您购买的所有 Packt 出版书籍的文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
本书第二版新增了示例代码,您也可以通过 GitHub 获取,地址为:github.com/dataspelunking/MLwR/
。请在此查看最新的 R 代码、问题跟踪以及公共 Wiki。欢迎加入社区!
下载本书的彩色图片
我们还为您提供了一份包含本书中截图/图表彩色图片的 PDF 文件。彩色图片将帮助您更好地理解输出变化。您可以从www.packtpub.com/sites/default/files/downloads/Machine_Learning_With_R_Second_Edition_ColoredImages.pdf
下载该文件。
勘误表
尽管我们已经尽最大努力确保内容的准确性,但错误仍然会发生。如果您在我们的书籍中发现错误——可能是文本或代码中的错误——我们将非常感激您能向我们报告。通过这样做,您可以帮助其他读者避免困扰,并帮助我们改进后续版本的书籍。如果您发现任何勘误,请访问www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入勘误的详细信息。一旦您的勘误被验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该书籍标题下的勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support
,并在搜索框中输入书名。所需的信息将出现在勘误部分。
盗版
互联网盗版问题在所有媒体中普遍存在。Packt 公司非常重视版权和许可证的保护。如果您在互联网上发现我们的作品的任何非法副本,请立即提供该地址或网站名称,以便我们采取相应措施。
如果您发现有涉嫌侵权的材料,请通过<copyright@packtpub.com>
与我们联系,并提供相关链接。
我们感谢您帮助保护我们的作者和我们向您提供有价值内容的能力。
问题
如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>
与我们联系,我们将尽力解决问题。
第一章:介绍机器学习
如果相信科幻小说的描写,人工智能的发明必然会引发机器与制造者之间的末日战争。在早期,计算机被教导玩简单的井字棋和国际象棋游戏。后来,机器被赋予控制交通灯和通讯的权限,接着是军事无人机和导弹。机器的进化在计算机变得具有感知能力并学会自我教学后走向了一个不祥的转折。它们不再需要人类程序员,因此人类便被“删除”了。
值得庆幸的是,直到目前为止,机器仍然需要用户输入。
虽然你对机器学习的印象可能受到这些大众传媒描绘的影响,但今天的算法过于特定于应用,无法威胁到它们自我意识的产生。当前机器学习的目标不是创造一个人工大脑,而是帮助我们理解世界庞大的数据存储。
摆脱流行误解后,阅读完本章,你将对机器学习有更细致的理解。同时,你也将了解定义并区分最常用机器学习方法的基本概念。
你将学到:
-
机器学习的起源与实际应用
-
计算机如何将数据转化为知识与行动
-
如何将机器学习算法与数据匹配
机器学习领域提供了一套算法,将数据转化为可操作的知识。继续阅读,看看使用 R 语言将机器学习应用于现实问题有多么简单。
机器学习的起源
自出生以来,我们便被数据淹没。我们身体的感官——眼睛、耳朵、鼻子、舌头和神经——不断受到原始数据的冲击,这些数据被我们的脑袋转化为视觉、声音、气味、味道和触觉。通过语言,我们能够与他人分享这些体验。
自从书面语言的出现,人类的观察就被记录了下来。猎人监控动物群体的移动,早期的天文学家记录行星和星星的排列,城市则记录税款、出生和死亡情况。今天,这些观察以及更多的内容正越来越多地被自动化,并系统地记录在不断增长的计算机化数据库中。
电子传感器的发明进一步促进了记录数据的量和丰富性的爆炸性增长。专业传感器能够看、听、嗅、尝和触感。这些传感器处理数据的方式与人类截然不同。与人类有限且主观的注意力不同,电子传感器从不休息,且永远不会让判断偏离其感知。
提示
尽管传感器不受主观性影响,但它们不一定能报告现实的单一、确定的描绘。由于硬件限制,某些传感器具有固有的测量误差。其他传感器则受到其范围的限制。一张黑白照片与一张彩色照片提供的对象描绘不同。同样,显微镜提供的现实描绘与望远镜提供的截然不同。
在数据库和传感器之间,我们生活的许多方面都被记录下来。政府、企业和个人正在记录和报告各种信息,从重大的到琐碎的。天气传感器记录温度和压力数据,监控摄像头观察人行道和地铁隧道,各种电子行为都在被监控:交易、通讯、友谊等。
这股数据洪流让一些人声称我们已经进入了大数据时代,但这可能有点不准确。人类一直以来都被大量的数据包围。当前时代的独特之处在于,我们拥有大量的已记录数据,而且这些数据中的许多可以被计算机直接访问。更大、更有趣的数据集越来越容易获取,只需通过网络搜索即可。这个信息的财富,若能通过系统化的方法加以理解,便具有潜力引导行动。
研究计算机算法以将数据转化为智能行动的学科被称为机器学习。这个领域起源于一个数据、统计方法和计算能力迅速同时发展的环境。数据的增长促使了额外计算能力的需求,进而推动了统计方法的发展,以分析大数据集。这创造了一个进步的循环,使得更大、更有趣的数据得以收集。
与机器学习密切相关的数据挖掘关注的是从大型数据库中生成新颖的见解。顾名思义,数据挖掘涉及的是对可操作性智能的系统化挖掘。尽管人们对机器学习和数据挖掘的重叠程度有不同看法,但一个可能的区别点是,机器学习侧重于教计算机如何利用数据来解决问题,而数据挖掘则侧重于教计算机识别模式,这些模式随后由人类用来解决问题。
几乎所有的数据挖掘都涉及机器学习的使用,但并非所有机器学习都涉及数据挖掘。例如,你可能会应用机器学习对汽车交通数据进行挖掘,以寻找与事故率相关的模式;另一方面,如果计算机正在学习如何自己驾驶汽车,那么这纯粹是机器学习,而不涉及数据挖掘。
提示
“数据挖掘”这个词有时也被用作贬义词,描述一种用挑选数据来支持理论的欺骗性做法。
机器学习的使用与滥用
大多数人都听说过下棋的计算机深蓝——它是第一个战胜世界冠军的计算机——或者沃森,这个计算机在电视问答节目《危险边缘》中战胜了两位人类对手。基于这些惊人的成就,一些人猜测计算机智能将取代许多信息技术职业中的人类,就像机器取代了人类在田间工作,机器人取代了人类在生产线上的工作一样。
事实是,即便机器取得了如此令人印象深刻的成就,它们在全面理解问题的能力上仍然相对有限。它们只是纯粹的智力马力,没有方向。计算机在发现大数据中的微妙模式方面可能比人类更有能力,但它仍然需要人类来推动分析并将结果转化为有意义的行动。
机器并不擅长提问,甚至不知道该问什么问题。它们更擅长回答问题,前提是问题以计算机能够理解的方式表达出来。现今的机器学习算法与人类合作,就像血犬与它的训练师一样;虽然狗的嗅觉可能远比主人灵敏,但如果没有正确的指引,它可能最终会追着自己的尾巴转圈。
为了更好地理解机器学习的现实世界应用,我们现在来看看一些机器学习成功应用的案例,也有一些地方它仍然有提升空间,还有一些情况下它可能弊大于利。
机器学习的成功案例
机器学习最成功的地方是它能增强而非取代专业领域专家的专业知识。它与医疗医生共同努力,处于消除癌症斗争的最前线,协助工程师和程序员创造更智能的家居和汽车,并帮助社会科学家了解社会如何运作。为实现这些目标,机器学习被广泛应用于无数的企业、科研实验室、医院和政府机构。任何产生或聚合数据的组织,都可能会使用至少一个机器学习算法来帮助理解数据。
尽管不可能列举机器学习的每一个应用案例,但最近的成功案例调查包括了几个突出的应用:
-
识别电子邮件中的垃圾邮件
-
按客户行为细分进行精准广告投放
-
预测天气行为和长期气候变化
-
减少信用卡欺诈交易
-
风暴和自然灾害的财务损失精算估算
-
预测流行的选举结果
-
自动驾驶无人机和自动驾驶汽车算法的开发
-
住宅和办公楼中能源使用的优化
-
犯罪活动最可能发生的区域预测
-
与疾病相关的基因序列的发现
在本书结束时,你将理解用于教计算机执行这些任务的基本机器学习算法。现在,只需要知道,不论在什么上下文中,机器学习的过程都是相同的。无论任务是什么,算法都会获取数据并识别其中的模式,这些模式形成了进一步行动的基础。
机器学习的局限性
尽管机器学习被广泛使用,并具有巨大的潜力,但理解其局限性同样重要。目前,机器学习绝不是人脑的替代品。它在超出学习范围的推断方面缺乏灵活性,也没有常识。鉴于此,在将算法投入实际应用之前,人们应该非常小心,确保准确理解算法已经学到的内容。
由于计算机缺乏过去的经验积累,它们在做出关于逻辑下一步的简单常识推断时也会受到限制。例如,许多网站上看到的横幅广告,可能是基于通过数据挖掘数百万用户浏览历史得出的模式。根据这些数据,查看卖鞋网站的人应该看到鞋子的广告,而查看床垫网站的人应该看到床垫的广告。问题在于,这种方式形成了一个永无止境的循环,展示的是更多的鞋子或床垫广告,而不是鞋带和鞋油广告,或者床单和毯子的广告。
许多人都熟悉机器学习在理解或翻译语言、识别语音和手写文字方面的不足。或许最早的一个例子是 1994 年《辛普森一家》中的一集,该集讽刺了苹果的 Newton 平板电脑。对于当时的技术水平,Newton 以其先进的手写识别技术而闻名。不幸的是,对于苹果来说,它有时会出错,造成很大的影响。该集通过一段情节展示了这一点,其中一位恶霸写给马丁的殴打信被 Newton 误解为吃掉玛莎,如下图所示:
《冰上的丽莎》截图,《辛普森一家》,20 世纪福克斯(1994)
自 1994 年以来,机器理解语言的能力已经足够强大,以至于 Google、Apple 和 Microsoft 都足够自信地提供通过语音识别操作的虚拟礼宾服务。尽管如此,即使这些服务仍然常常难以回答相对简单的问题。更何况,在线翻译服务有时会误解那些连幼儿都能理解的句子。许多设备上的预测文本功能也导致了一些幽默的自动纠正失败网站,这些网站展示了计算机能够理解基本语言,但完全误解上下文的能力。
这些错误有些是可以预见的,毕竟,语言复杂,包含多层文本和潜在意思,有时人类也会误解上下文。话虽如此,这些机器中的失败实例恰恰说明了机器学习仅仅取决于其学习的数据的质量。如果上下文没有直接隐含在输入数据中,那么就像人类一样,计算机将不得不做出最好的猜测。
机器学习伦理
从本质上讲,机器学习只是一个帮助我们理解世界复杂数据的工具。像任何工具一样,它可以用于善事或恶事。当机器学习被广泛或粗暴地应用到人类,甚至将人类视为实验鼠、自动化机器或盲目消费者时,可能会带来问题。一个看似无害的过程,经过无情计算机的自动化处理后,可能会导致意想不到的后果。因此,使用机器学习或数据挖掘的人,不能忽视考虑这一艺术的伦理影响。
由于机器学习作为一个学科相对年轻,并且发展速度极快,因此与之相关的法律问题和社会规范常常不确定,并且处于不断变化之中。在获取或分析数据时应保持谨慎,以避免违反法律、服务条款或数据使用协议,避免滥用顾客或公众的信任或侵犯其隐私。
提示
Google 的非正式公司口号是“别做坏事”,作为一家或许比任何其他公司都更收集个人数据的组织,这看似已经足够明确,但可能还不够充分。一个更好的方法可能是遵循希波克拉底誓言,这一医学原则指出“首先,不做伤害”。
零售商常常使用机器学习进行广告投放、定向促销、库存管理或店内商品布局。许多零售商甚至已经在结账通道上配备了能够根据顾客购买历史打印优惠券的设备。顾客只需提供一些个人数据,就能在其希望购买的特定商品上获得折扣。起初,这看起来无害。然而,考虑到当这种做法进一步发展时可能发生的情况。
有一个可能是传说的故事,讲述了一家美国的大型零售商使用机器学习来识别准妈妈进行优惠券邮寄。零售商希望通过向这些准妈妈提供丰厚的折扣,能够吸引她们成为忠实客户,进而购买如尿布、婴儿奶粉和玩具等有利可图的商品。
这家零售商利用机器学习方法,识别了客户购买历史中的物品,这些物品能够高概率地预测出一个女性是否怀孕,并且预测宝宝的预计出生时间。
在零售商使用这些数据进行促销邮寄后,一位愤怒的男子联系了这家零售商,要求了解为什么他的十几岁女儿收到了孕妇用品的优惠券。他非常愤怒,认为零售商似乎在鼓励未成年怀孕!故事是这样的,当零售商的经理打电话道歉时,最终是父亲向零售商道歉,因为在与女儿对话后,他发现她确实怀孕了!
无论前述故事是否完全真实,从中学到的教训是,在盲目应用机器学习分析结果之前,应当应用常识。在涉及健康数据等敏感信息的情况下,这一点尤为重要。稍加小心,零售商本可以预见到这种情况,并在选择如何展示其机器学习分析发现的模式时更加谨慎。
某些司法管辖区可能会因商业原因阻止你使用种族、民族、宗教或其他受保护类别的数据。请记住,仅仅从分析中排除这些数据可能还不够,因为机器学习算法可能会无意中独立地学习到这些信息。例如,如果某一特定人群通常生活在某个地区,购买某种产品,或者以某种方式表现出独特的群体特征,一些机器学习算法可能会从这些其他因素中推断出受保护的信息。在这种情况下,你可能需要完全“去标识化”这些人,除了排除受保护信息之外,还需要排除任何可能的识别性数据。
除了法律后果外,不当使用数据可能还会伤害公司的底线。如果客户觉得自己的隐私被侵犯,或者他们认为私密的生活细节被公开,他们可能会感到不舒服或受到惊吓。近年来,几款高知名度的网页应用程序在服务条款变动后,遭遇了大量用户的流失,许多人认为自己被利用了,因为这些应用程序将数据用于了用户原本未同意的目的。由于隐私期望因上下文、年龄层和地域而异,因此在决定如何恰当使用个人数据时,事情变得更加复杂。在开始项目之前,明智的做法是考虑到工作可能带来的文化影响。
提示
你可以使用数据来达到某个特定目的,并不意味着你应该这么做。
机器如何学习
计算机科学家汤姆·M·米切尔提出的机器学习正式定义指出,机器每当能够利用其经验,从而在未来类似的经历中提升表现时,就说明机器在学习。虽然这个定义直观易懂,但它完全忽略了经验如何转化为未来行动的过程——当然,学习总是说起来容易做起来难!
虽然人类大脑从出生起就具备自然的学习能力,但计算机学习所需的条件必须明确提出。因此,尽管了解学习的理论基础并非绝对必要,但这一基础有助于理解、区分和实现机器学习算法。
提示
当你将机器学习与人类学习进行对比时,你可能会以不同的角度审视自己的思维。
无论学习者是人类还是机器,基本的学习过程是相似的。它可以分为四个相互关联的组成部分:
-
数据存储利用观察、记忆和回忆,为进一步推理提供事实基础。
-
抽象涉及将存储的数据转化为更广泛的表示和概念。
-
泛化利用抽象数据创造知识和推理,从而驱动在新环境中的行动。
-
评估提供了一种反馈机制,用于衡量所学知识的效用并提供潜在的改进建议。
下图展示了学习过程的步骤:
请记住,尽管学习过程已被概念化为四个独立的组成部分,但它们仅仅是为了说明方便而如此组织。实际上,整个学习过程是密不可分的。在人类中,这一过程是潜意识的。我们在思维的框架内回忆、推理、归纳和直觉地进行操作,而由于这一过程是隐性的,任何人与人之间的差异都归因于一种模糊的主观性概念。相比之下,计算机的这些过程是显式的,并且由于整个过程是透明的,所学到的知识可以被检查、转移并用于未来的行动。
数据存储
所有学习必须以数据为基础。人类和计算机都利用数据存储作为更高级推理的基础。在人类中,这包括大脑利用电化学信号通过生物细胞网络来存储和处理观察到的信息,以便进行短期和长期的回忆。计算机也有类似的短期和长期回忆能力,通过硬盘驱动器、闪存和随机存取内存(RAM)与中央处理单元(CPU)结合使用。
说出来可能很显然,但仅仅有存储和检索数据的能力是不足以进行学习的。没有更高层次的理解,知识仅限于回忆,即仅仅是之前见过的东西,别无其他。数据只是磁盘上的零和一。它们是没有更广泛意义的存储记忆。
为了更好地理解这个概念的细微差别,可能有助于回想一下你上次为一场艰难的考试做准备的情景,也许是大学的期末考试或职业资格认证考试。你是否曾渴望拥有一个记忆力极好的(照相式)记忆?如果是这样,你可能会失望地发现,完美的回忆并不一定会有太大帮助。即便你能完美地记住材料,你的死记硬背也没什么用,除非你事先知道考试中会出现的确切问题和答案。否则,你将陷入试图记住每一个可能被问到的问题的困境。显然,这是一个不可持续的策略。
相反,更好的方法是有选择地花时间,记住一小部分具有代表性的想法,同时发展如何将这些想法联系起来以及如何使用存储信息的策略。通过这种方式,可以在不需要死记硬背的情况下理解更大的思想。
抽象
这一赋予存储数据意义的工作发生在抽象过程中,在此过程中,原始数据变得具有更抽象的意义。这种连接,例如对象与其表示之间的关系,可以通过著名的雷内·马格里特画作《影像的背叛》来体现:
来源:http://collections.lacma.org/node/239578
画作描绘了一只烟斗,旁边的说明文字写着Ceci n'est pas une pipe(“这不是一只烟斗”)。马格里特想表达的观点是,烟斗的表现形式并不等同于真正的烟斗。然而,尽管这只烟斗不是真的,任何观看这幅画的人都很容易将它认作一只烟斗。这表明,观察者的思维能够将烟斗的图像与烟斗的概念、与可以拿在手中的实物烟斗联系起来。像这样的抽象连接是知识表示的基础,形成逻辑结构,帮助将原始的感官信息转化为有意义的洞察。
在机器进行知识表示的过程中,计算机通过模型总结存储的原始数据,模型是数据中模式的明确描述。就像马格里特的烟斗一样,模型表示超越了原始数据的生命。它代表了一个比组成部分之和更大的概念。
有许多不同类型的模型。你可能已经熟悉其中一些。举例来说:
-
数学方程
-
关系图表,如树形图和图形
-
逻辑的 if/else 规则
-
数据的分组,称为簇
模型选择通常不是由机器决定的。相反,学习任务和手头的数据决定了模型的选择。本章稍后将更详细地讨论选择模型类型的方法。
将模型拟合到数据集的过程称为训练。当模型被训练后,数据被转换成一个抽象形式,总结了原始信息。
提示
您可能会想知道为什么这一步骤称为训练而不是学习。首先,请注意,学习过程并不随着数据抽象而结束;学习者仍需进行概括和评估其训练。其次,词语“训练”更能准确地表达人类教师训练机器学生以特定方式理解数据的事实。
需要注意的是,学得的模型本身并不提供新数据,但确实产生了新的知识。这是如何做到的呢?答案是通过对潜在数据施加假设的结构来揭示看不见的东西,假设数据元素如何相关的概念。比如,重力的发现。通过将观测数据拟合到方程中,艾萨克·牛顿推断出了重力的概念。但我们现在所知道的重力力量一直存在。只是直到牛顿将其认识为一个解释物体自由落体观测的模型中的g项的抽象概念,它才被认可。
大多数模型可能不会导致颠覆科学思想几个世纪的理论发展。但是,您的模型可能会发现数据之间以前未曾见过的关系。一个以基因组数据为基础的模型可能会发现几种基因,这些基因结合起来导致糖尿病的发作;银行可能会发现一种看似无害的交易类型在欺诈活动发生之前系统性出现;心理学家可能会识别一种新疾病的特定人格特征组合。这些潜在模式一直存在,但仅仅通过以不同格式呈现信息,就可以概念化一个新的想法。
概括
学习过程直到学习者能够利用其抽象化的知识进行未来行动才算完成。然而,在抽象化过程中可能识别出无数潜在模式和建模这些模式的多种方式中,有些模式比其他模式更有用。除非抽象化的产生受到限制,否则学习者将无法继续前进。它将停留在起点——具有大量信息但缺乏可操作的洞察力。
泛化一词描述了将抽象的知识转化为可以用于未来行动的形式的过程,适用于与之前见过的任务相似但不完全相同的任务。泛化是一个有些模糊的过程,难以用语言准确描述。传统上,它被视为在训练过程中抽象出的所有可能模型(即理论或推论)中进行搜索。换句话说,如果你能想象一个包含所有可能由数据建立的理论的假设集合,泛化就是将这个集合缩减为一个可以管理的重要发现的数量。
在泛化过程中,学习者的任务是将其发现的模式限制为那些对未来任务最相关的模式。通常,通过逐一检查这些模式并按未来效用进行排名来减少模式数量并不可行。相反,机器学习算法通常采用捷径,更快速地缩小搜索空间。为了实现这一点,算法将使用启发式方法,这是一种关于在哪找到最有用推论的有根据的猜测。
提示
由于启发式方法利用近似值和其他经验法则,它们不能保证找到唯一最优的模型。然而,如果不采取这些捷径,在一个大型数据集中找到有用的信息将是不可行的。
启发式方法通常被人类用来迅速将经验推广到新场景。如果你曾在完全评估情况之前,仅凭直觉做出迅速决策,那么你直观地使用了心理启发式方法。
人类做出快速决策的惊人能力,往往不是依赖于计算机般的逻辑,而是依赖于由情绪指导的启发式方法。有时,这会导致不合逻辑的结论。例如,尽管汽车在统计上更危险,更多的人对乘坐飞机感到恐惧而非乘坐汽车。这可以通过可得性启发式来解释,指人们通过事件的例子多容易被回忆来估计事件发生的可能性。航空旅行的事故被广泛报道,作为创伤性事件,它们很容易被回忆起来,而汽车事故几乎不在报纸上提及。
错误使用启发式方法的愚蠢不仅限于人类。机器学习算法使用的启发式方法有时也会导致错误的结论。如果结论系统性地错误,或者以可预测的方式错误,则说该算法存在偏见。
例如,假设一个机器学习算法通过寻找代表眼睛的两个黑色圆圈,位于一条表示嘴巴的直线之上,学习识别面部特征。那么该算法可能会遇到困难,或者存在偏见,无法识别那些不符合其模型的面孔。带眼镜的面孔、角度倾斜的面孔、侧脸或者具有不同肤色的面孔可能无法被算法检测到。同样,它也可能偏向那些具有特定肤色、面部特征或其他与其理解世界不符的特征的面孔。
在现代用法中,“偏见”一词常带有负面含义。各种形式的媒体频繁声称自己没有偏见,并宣称客观报道事实,不受情感污染。然而,试想一下,适度的偏见可能是有益的。如果没有一点武断的决策,是否在多种竞争选择中作出决定会显得困难,尤其是当每个选择都有其独特的优缺点时?实际上,一些心理学领域的最新研究表明,天生有大脑某些负责情感的部分受损的个体,在决策时往往无效,可能花费数小时辩论简单的决策,比如穿什么颜色的衬衫或者去哪儿吃午餐。矛盾的是,偏见正是让我们忽视某些信息的同时,又能利用其他信息作出行动决策的原因。这正是机器学习算法在理解一组数据的无数方式中做出选择的方式。
评估
偏见是与任何学习任务中固有的抽象化和概括化过程相关的必要之恶。为了在无限可能性面前推动行动,每个学习者必须以某种特定方式产生偏见。因此,每个学习者都有其弱点,没有一种学习算法能够统治所有任务。因此,概括过程中的最后一步是评估或衡量学习者在偏见的影响下的成功,并利用这些信息在必要时进行额外的训练。
提示
一旦你在某一机器学习技术上获得成功,你可能会倾向于将其应用于所有问题。但重要的是要抵制这种诱惑,因为没有任何一种机器学习方法适用于所有的情况。这个事实由 David Wolpert 在 1996 年提出的无免费午餐定理描述。欲了解更多信息,请访问:www.no-free-lunch.org
。
通常,评估发生在模型在初始训练数据集上训练完成之后。然后,模型会在新的测试数据集上进行评估,以判断其对训练数据的刻画能否推广到新的、未见过的数据。值得注意的是,模型能够完美推广到每一个未预见的情况是极为罕见的。
部分模型未能完美地泛化,这是由于噪声问题,即数据中无法解释或无法说明的变化。噪声数据是由看似随机的事件引起的,比如:
-
由于传感器不精确,测量误差有时会导致读数增加或减少一些。
-
人类受试者的问题,例如调查问卷回答者为了更快完成而随意填写答案
-
数据质量问题,包括缺失、空值、截断、编码错误或损坏的值
-
现象过于复杂或理解不足,以至于它们以无法预测的方式影响数据。
尝试建模噪声是导致过拟合问题的根源。由于大多数噪声数据定义上是无法解释的,试图解释这些噪声会导致错误的结论,这些结论无法很好地泛化到新的案例中。试图解释噪声通常还会导致更复杂的模型,错失学习者试图识别的真实模式。一个在训练过程中表现良好,但在评估过程中表现不佳的模型,被称为对训练数据集过拟合,因为它无法很好地泛化到测试数据集。
解决过拟合问题的方案通常依赖于特定的机器学习方法。目前,重要的是要意识到这一问题。模型能够处理噪声数据的能力,是它们之间重要的区别来源。
机器学习在实践中的应用
到目前为止,我们专注于机器学习理论的运作方式。为了将学习过程应用于现实任务,我们将采用五步法。无论任务是什么,通过以下步骤都可以部署任何机器学习算法:
-
数据收集:数据收集步骤包括收集算法用于生成可操作知识的学习材料。在大多数情况下,这些数据需要合并成一个单一来源,如文本文件、电子表格或数据库。
-
数据探索与准备:任何机器学习项目的质量在很大程度上取决于其输入数据的质量。因此,在数据探索这一实践过程中,了解数据及其细节非常重要。在为学习过程准备数据时,还需要进行额外的工作。这包括修复或清理所谓的“脏数据”,去除不必要的数据,以及重新编码数据以符合学习者的预期输入。
-
模型训练:当数据准备好进行分析时,你可能已经有了对数据中可以学习内容的初步认识。所选的具体机器学习任务将决定合适算法的选择,而算法将以模型的形式呈现数据。
-
模型评估:由于每个机器学习模型都会对学习问题产生偏向性的解决方案,因此评估算法从经验中学习的效果非常重要。根据使用的模型类型,你可能能够通过测试数据集来评估模型的准确性,或者可能需要开发特定于预期应用的性能评估标准。
-
模型改进:如果需要更好的性能,就必须使用更高级的策略来增强模型的表现。有时,可能需要完全切换到另一种类型的模型。你可能需要通过额外的数据或进行额外的准备工作来补充数据,如该过程的第二步所示。
完成这些步骤后,如果模型的表现良好,可以将其部署到预定的任务中。根据具体情况,你可能会利用模型提供预测的得分数据(可能是实时的),财务数据的预测,生成营销或研究的有用见解,或自动化任务,如邮件投递或飞行器操作。部署模型的成功与失败甚至可能为训练下一个代际的学习器提供额外数据。
输入数据类型
机器学习的实践涉及将输入数据的特征与现有方法的偏差相匹配。因此,在将机器学习应用于实际问题之前,了解区分输入数据集的术语非常重要。
观察单位一词用来描述研究中测量特定属性的最小实体。通常,观察单位可以是人、物体、事务、时间点、地理区域或测量值。有时,观察单位会结合形成其他单位,例如“人年”,指的是同一个人在多个年份内被追踪的情况;每个人年包括一个人在一年中的数据。
提示
观察单位与分析单位相关,但不完全相同,后者是进行推理时使用的最小单位。尽管通常情况下,观察单位和分析单位是相同的,但也不总是如此。例如,来自个人的数据可能用于分析不同国家之间的趋势。
存储观察单位及其属性的数据集可以想象成由以下内容组成的数据集合:
-
示例:记录了属性的观察单位实例
-
特征:记录的示例属性或特征,可能对学习有所帮助
最容易通过现实案例来理解特征和示例。为了构建一个识别垃圾邮件的学习算法,观察单位可以是电子邮件,示例将是具体的邮件,而特征可能包括邮件中使用的单词。对于癌症检测算法,观察单位可以是患者,示例可能包括癌症患者的随机样本,特征可能是来自活检细胞的基因组标记,以及患者的其他特征,如体重、身高或血压。
尽管示例和特征不需要以任何特定形式收集,但通常采用矩阵格式,即每个示例具有完全相同的特征。
以下电子表格展示了一个矩阵格式的数据集。在矩阵数据中,电子表格中的每一行是一个示例,每一列是一个特征。在这里,行表示汽车的示例,而列记录了每辆汽车的各种特征,如价格、里程、颜色和传动方式。矩阵格式数据是机器学习中最常见的数据形式。不过,正如你将在后续章节中看到的那样,在一些特定场景下,也会使用其他形式的数据:
特征也有多种形式。如果一个特征代表以数字衡量的特征,它通常被称为数值型。或者,如果特征是由一组类别组成的属性,则该特征被称为分类变量或名义变量。分类变量的一个特殊情况被称为顺序变量,它是指类别有顺序的名义变量。一些顺序变量的例子包括服装尺寸(如小号、中号、大号);或者客户满意度的测量尺度,从“非常不满意”到“非常满意”。考虑特征所代表的内容非常重要,因为数据集中特征的类型和数量将帮助确定适合任务的机器学习算法。
机器学习算法的类型
机器学习算法根据其目的分为不同类别。了解学习算法的类别是使用数据推动所需操作的关键第一步。
预测模型用于涉及根据数据集中其他值预测一个值的任务,正如名称所示。学习算法尝试发现并建模目标特征(被预测的特征)与其他特征之间的关系。尽管“预测”一词通常意味着预见未来的事件,但预测模型不一定需要预测未来的事件。例如,预测模型可以用于预测过去的事件,如使用母亲当前的激素水平预测婴儿的受孕日期。预测模型也可以实时使用,控制高峰时段的交通信号灯。
由于预测模型会明确指示它们需要学习什么以及它们如何学习,因此训练预测模型的过程被称为监督学习。监督并不指人类的参与,而是指目标值为学习者提供了一种了解其学习任务完成情况的方式。更正式地说,给定一组数据,监督学习算法尝试优化一个函数(模型),以找到特征值的组合,从而得到目标输出。
常用的监督学习任务之一是预测一个样本属于哪个类别,这被称为分类。可以很容易地想到分类器的潜在用途。例如,你可以预测以下内容:
-
一封电子邮件是否是垃圾邮件
-
一个人患有癌症
-
一支足球队将赢得比赛或输掉比赛
-
一名申请人将违约
在分类中,要预测的目标特征是一个类别特征,称为类别,并被划分为称为级别的类别。一个类别可以有两个或更多的级别,而这些级别可能是有序的,也可能不是有序的。由于分类在机器学习中的广泛应用,存在许多类型的分类算法,各有优缺点,适用于不同类型的输入数据。我们将在本章及全书中看到这些算法的示例。
监督学习算法也可以用于预测数值数据,例如收入、实验室值、测试分数或物品计数。为了预测这些数值,一个常见的数值预测方法是将线性回归模型拟合到输入数据中。尽管回归模型不是唯一的数值模型类型,但它们是迄今为止最广泛使用的。回归方法在预测中被广泛应用,因为它们能够准确量化输入与目标之间的关联,包括关系的大小和不确定性。
提示
由于将数字转换为类别(例如,年龄 13 至 19 岁是青少年)以及将类别转换为数字(例如,将 1 分配给所有男性,将 0 分配给所有女性)是很容易的,因此分类模型和数值预测模型之间的边界并不一定是明确的。
描述性模型用于从数据中总结出新的、有趣的见解,适用于那些能够从数据总结中受益的任务。与预测模型不同,预测模型是为了预测某个目标,而在描述性模型中,没有任何单一特征比其他特征更为重要。事实上,由于没有特定的目标需要学习,因此训练描述性模型的过程称为无监督学习。虽然很难为描述性模型找到应用——毕竟,若没有特定学习目标,这种学习有什么用处呢——但它们在数据挖掘中被广泛使用。
例如,名为模式发现的描述性建模任务用于识别数据中的有用关联。模式发现常用于零售商的市场篮分析,以分析其交易购买数据。在这里,目标是识别经常一起购买的商品,以便将学到的信息用于优化营销策略。例如,如果零售商发现游泳裤和太阳镜经常一起购买,零售商可能会将这些商品重新摆放得更近,或者进行促销以“追加销售”相关商品。
提示
最初仅在零售领域使用的模式发现,现在开始以非常创新的方式使用。例如,它可以用于检测欺诈行为的模式、筛查基因缺陷或识别犯罪活动的热点。
将数据集划分为同质群体的描述性建模任务称为聚类。这有时用于细分分析,通过识别具有相似行为或人口统计信息的个体群体,以便广告活动能够针对特定的受众进行定制。尽管机器能够识别出这些簇,但仍然需要人工干预来解释它们。例如,在杂货店中,如果有五个不同的购物者簇,营销团队需要了解各组之间的差异,以便制定最适合每组的促销活动。
最后,一类称为元学习者的机器学习算法并不专注于特定的学习任务,而是侧重于如何更有效地学习。元学习算法利用某些学习的结果来指导进一步的学习。这对于非常具有挑战性的问题或需要尽可能精确的预测算法性能时非常有帮助。
输入数据与算法的匹配
下表列出了本书中涵盖的机器学习算法的一般类型。虽然这仅覆盖了整个机器学习算法集的一小部分,但学习这些方法将为您提供足够的基础,以便理解未来可能遇到的任何其他方法。
模型 | 学习任务 | 章节 |
---|---|---|
监督学习算法 | ||
最近邻 | 分类 | 3 |
朴素贝叶斯 | 分类 | 4 |
决策树 | 分类 | 5 |
分类规则学习者 | 分类 | 5 |
线性回归 | 数值预测 | 6 |
回归树 | 数值预测 | 6 |
模型树 | 数值预测 | 6 |
神经网络 | 双重用途 | 7 |
支持向量机 | 双重用途 | 7 |
无监督学习算法 | ||
关联规则 | 模式检测 | 8 |
K 均值聚类 | 聚类 | 9 |
元学习算法 | ||
集成法 | 双重用途 | 11 |
提升法 | 双重用途 | 11 |
随机森林 | 双重用途 | 11 |
要开始将机器学习应用于真实世界的项目,你需要确定你的项目代表的是哪种学习任务:分类、数值预测、模式检测或聚类。任务的类型将决定算法的选择。例如,如果你正在进行模式检测,你可能会使用关联规则。类似地,聚类问题可能会使用 K 均值算法,数值预测则会使用回归分析或回归树。
对于分类问题,需要更多的思考来将学习问题与合适的分类器匹配。在这些情况下,考虑算法之间的各种区别是有帮助的——这些区别只有通过深入研究每个分类器才能显现出来。例如,在分类问题中,决策树生成的模型容易理解,而神经网络的模型则以难以解释而著称。如果你正在设计一个信用评分模型,这可能是一个重要的区别,因为法律通常要求必须通知申请人其贷款被拒绝的原因。即使神经网络在预测贷款违约方面表现更好,如果其预测无法解释,那么对于这个应用来说也没有用处。
为了帮助算法选择,在每一章中列出了每种学习算法的关键优缺点。尽管有时你会发现这些特点排除了某些模型的考虑,但在许多情况下,算法的选择是任意的。当这种情况发生时,可以自由选择你最熟悉的算法。其他时候,当预测准确性是主要考虑时,你可能需要测试几个算法,并选择最合适的一个,或者使用一个元学习算法,结合几种不同的学习者,利用每个算法的优势。
使用 R 进行机器学习
许多用于 R 机器学习的算法并没有包含在基础安装包中。相反,机器学习所需的算法可以通过一个庞大的专家社区获得,这些专家们自由分享了他们的工作。你需要手动将这些算法安装到基础 R 中。由于 R 是免费的开源软件,因此这一功能没有额外费用。
一组可以在用户之间共享的 R 函数被称为包。每种本书中涵盖的机器学习算法都有免费的包存在。事实上,本书仅涵盖了 R 中机器学习包的一个小部分。
如果你对 R 包的广度感兴趣,可以在综合 R 存档网络(CRAN)查看包的列表,CRAN 是一个遍布全球的 web 和 FTP 网站集合,提供 R 软件和包的最新版本。如果你通过下载获取了 R 软件,极有可能是从 CRAN 获取的,网址为 cran.r-project.org/index.html
。
提示
如果你还没有安装 R,CRAN 网站还提供了安装说明以及如果遇到问题时如何寻求帮助的信息。
页面左侧的Packages链接将带你到一个页面,你可以按字母顺序或按发布时间顺序浏览包。在撰写本文时,总共有 6,779 个包可用——与第一版编写时相比,增长超过 60%,而且这一趋势看不到放缓的迹象!
CRAN 页面左侧的Task Views链接提供了根据主题领域整理的包的列表。机器学习任务视图列出了本书中涉及的包(以及更多包),可以访问 cran.r-project.org/web/views/MachineLearning.html
。
安装 R 包
尽管 R 的附加组件种类繁多,但包的格式使得安装和使用几乎成为一项轻松的过程。为了演示包的使用,我们将安装并加载RWeka
包,该包由 Kurt Hornik、Christian Buchta 和 Achim Zeileis 开发(更多信息请参阅《开源机器学习:R 遇见 Weka》,《计算统计学》第 24 期:225-232)。RWeka
包提供了一系列函数,使 R 能够访问由 Ian H. Witten 和 Eibe Frank 开发的基于 Java 的 Weka 软件包中的机器学习算法。关于 Weka 的更多信息可以参考 www.cs.waikato.ac.nz/~ml/weka/
。
提示
要使用RWeka
包,你需要安装 Java(许多计算机已经预装了 Java)。Java 是一套可免费使用的编程工具,允许使用像 Weka 这样的跨平台应用程序。有关更多信息以及如何在你的系统上下载 Java,请访问 java.com
。
安装包的最直接方法是通过install.packages()
函数。要安装RWeka
包,只需在 R 命令提示符下输入:
> install.packages("RWeka")
R 随后将连接到 CRAN,并以适合你操作系统的格式下载包。一些包(如RWeka
)需要在使用之前安装额外的包(这些称为依赖项)。默认情况下,安装程序将自动下载并安装所有依赖项。
提示
第一次安装包时,R 可能会要求你选择一个 CRAN 镜像。如果发生这种情况,请选择一个靠近你位置的镜像。这通常会提供最快的下载速度。
默认的安装选项适用于大多数系统。然而,在某些情况下,你可能希望将包安装到另一个位置。例如,如果你没有系统的 root 或管理员权限,你可能需要指定一个备用安装路径。这可以通过使用lib
选项来实现,如下所示:
> install.packages("RWeka", lib="/path/to/library")
安装函数还提供了从本地文件安装、从源代码安装或使用实验版本的附加选项。你可以通过以下命令阅读有关这些选项的帮助文件:
> ?install.packages
更一般来说,可以使用问号运算符来获取任何 R 函数的帮助。只需在函数名之前键入?
。
加载和卸载 R 包
为了节省内存,R 默认不会加载每个已安装的包。相反,包是根据用户的需要动态加载的,使用library()
函数。
提示
该函数的名称使得一些人错误地将库(library)和包(package)这两个术语互换使用。然而,准确来说,库指的是包安装的位置,而绝不是包本身。
要加载我们之前安装的RWeka
包,可以键入以下命令:
> library(RWeka)
除了RWeka
之外,还有若干其他 R 包将在后续章节中使用。安装说明将在使用其他包时提供。
要卸载一个 R 包,使用detach()
函数。例如,要卸载之前提到的RWeka
包,可以使用以下命令:
> detach("package:RWeka", unload = TRUE)
这将释放包占用的任何资源。
概述
机器学习起源于统计学、数据库科学和计算机科学的交叉点。它是一种强大的工具,能够从大量数据中发现可操作的洞察。然而,仍然需要谨慎使用,以避免在现实世界中对机器学习的常见滥用。
从概念上讲,学习涉及将数据抽象成结构化的表示,并将这种结构概括为可以评估其效用的行动。从实践角度看,机器学习者使用包含待学习概念的示例和特征的数据,并将这些数据总结成模型,然后用于预测或描述目的。这些目的可以分为任务,包括分类、数值预测、模式检测和聚类。在众多选项中,机器学习算法的选择是基于输入数据和学习任务的。
R 提供了通过社区贡献的包来支持机器学习。这些强大的工具可以免费下载,但在使用之前需要安装。本书中的每一章都会根据需要介绍这些包。
在下一章,我们将进一步介绍用于管理和准备机器学习数据的基本 R 命令。尽管你可能会想跳过这一步,直接进入机器学习的核心部分,但常见的经验法则表明,典型的机器学习项目中,80%或更多的时间都花费在这一步。因此,投资于这项早期工作将在后续带来回报。
第二章。管理和理解数据
任何机器学习项目的关键早期组成部分涉及管理和理解数据。虽然这可能不像构建和部署模型那样令人满足——在这些阶段中,您开始看到劳动成果的时候,但忽视这项重要的准备工作是不明智的。
任何学习算法的好坏取决于其输入数据,在许多情况下,输入数据是复杂的、混乱的,并且分布在多个来源和格式中。由于这种复杂性,机器学习项目中投入的大部分精力通常用于数据准备和探索。
本章通过三种方式探讨这些主题。第一部分讨论了 R 用于存储数据的基本数据结构。在创建和操作数据集时,您将对这些结构非常熟悉。第二部分是实用的,因为它涵盖了几个有助于将数据输入和输出 R 的函数。在第三部分中,通过探索一个真实世界的数据集来说明理解数据的方法。
通过本章的结尾,您将理解:
-
如何使用 R 的基本数据结构存储和提取数据
-
从常见来源格式将数据导入 R 的简单函数
-
理解和可视化复杂数据的典型方法
由于 R 如何思考数据将定义您处理数据的方式,在直接进行数据准备之前了解 R 的数据结构是有帮助的。但是,如果您已经熟悉 R 编程,请随时跳到数据预处理部分。
R 数据结构
各种编程语言都有各种类型的数据结构,每种都有其特定任务的优缺点。由于 R 语言广泛用于统计数据分析,它使用的数据结构是为了这种类型的工作而设计的。
在机器学习中最常用的 R 数据结构包括向量、因子、列表、数组和矩阵以及数据框。每种都针对特定的数据管理任务,这使得了解它们在你的 R 项目中如何互动非常重要。接下来的章节中,我们将回顾它们的相似性和差异。
向量
基本的 R 数据结构是向量,它存储称为元素的有序值集。向量可以包含任意数量的元素,但所有元素必须属于相同类型的值。例如,向量不能同时包含数字和文本。要确定向量v
的类型,请使用typeof(v)
命令。
在机器学习中常用几种向量类型:integer
(无小数点的数字)、double
(带有小数点的数字)、character
(文本数据)和logical
(TRUE
或FALSE
值)。还有两个特殊值:NULL
,用于表示没有任何值,以及NA
,表示缺失值。
提示
一些 R 函数会将 integer
和 double
向量都报告为 numeric
,而其他函数则会区分这两者。因此,尽管所有 double
向量都是 numeric
,但并非所有 numeric
向量都是 double
类型。
手动输入大量数据是很繁琐的,但可以使用 c()
合并函数创建小型向量。还可以使用 <-
箭头运算符为向量命名,这是 R 赋值的方式,类似于许多其他编程语言中使用的 =
赋值运算符。
例如,我们构建几个向量来存储三位病人的诊断数据。我们将创建一个名为 subject_name
的 character
向量来存储三位病人的姓名,一个名为 temperature
的 double
向量来存储每位病人的体温,以及一个名为 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 向量天生是有序的,可以通过计算集合中项目的编号来访问记录,从一开始,并在向量名称后用方括号(即 [
和 ]
)括住这个编号。例如,要获取病人 Jane Doe 的体温(即 temperature
向量中的第二个元素),只需输入:
> temperature[2]
[1] 98.6
R 提供了多种方便的方法来从向量中提取数据。可以使用 (:
) 冒号运算符获取一系列的值。例如,要获取 Jane Doe 和 Steve Graves 的体温,输入:
> temperature[2:3]
[1] 98.6 101.4
可以通过指定负的项目编号来排除项目。要排除 Jane Doe 的 temperature
数据,输入:
> temperature[-2]
[1] 98.1 101.4
最后,有时指定一个逻辑向量也很有用,用于指示每个项目是否应被包括。例如,要包括前两个 temperature
读数,但排除第三个,输入:
> temperature[c(TRUE, TRUE, FALSE)]
[1] 98.1 98.6
正如你将很快看到的,向量为许多其他 R 数据结构提供了基础。因此,了解各种向量操作对于在 R 中处理数据至关重要。
提示
下载示例代码
你可以从你的账户中下载所有已购买的 Packt 书籍的示例代码文件,网址为 www.packtpub.com
。如果你在其他地方购买了本书,可以访问 www.packtpub.com/support
注册并直接通过电子邮件获取文件。
本书第二版新增了示例代码,并且可以通过 GitHub 在 github.com/dataspelunking/MLwR/
获得。在这里查看最新的 R 代码、问题追踪和公共 Wiki。请加入社区!
因子
如果你还记得来自第一章,机器学习导论,表示具有类别值特征的特征被称为名义型。尽管可以使用字符向量来存储名义数据,R 提供了一个专门用于此目的的数据结构。因子是一种特殊的向量,仅用于表示类别变量或有序变量。在我们正在构建的医疗数据集中,我们可能会使用因子来表示性别,因为它使用两个类别:MALE
和FEMALE
。
为什么不使用字符向量?因子的一个优势是类别标签只存储一次。例如,计算机可以存储1
、1
、2
,而不是存储MALE
、MALE
、FEMALE
,这减少了存储相同信息所需的内存空间。此外,许多机器学习算法会以不同方式处理名义数据和数值数据。将数据编码为因子通常是必要的,能够告诉 R 函数如何正确处理类别数据。
提示
对于不是严格类别型的字符向量,不应该使用因子。如果一个向量主要存储唯一值,比如名字或身份识别字符串,应保持其为字符向量。
要从字符向量创建因子,只需应用factor()
函数。例如:
> gender <- factor(c("MALE", "FEMALE", "MALE"))
> gender
[1] MALE FEMALE MALE
Levels: FEMALE MALE
请注意,当显示 John Doe 和 Jane Doe 的性别数据时,R 打印了关于gender
因子的额外信息。levels
变量包含因子可能取的类别集,在这个例子中是:MALE
或FEMALE
。
当我们创建因子时,可以添加在数据中可能未出现的额外级别。假设我们为血型添加了另一个因子,如以下示例所示:
> blood <- factor(c("O", "AB", "A"),
levels = c("A", "B", "AB", "O"))
> blood[1:2]
[1] O AB
Levels: A B AB O
请注意,当我们为三位患者定义blood
因子时,我们使用levels
参数指定了一个包含四种可能血型的额外向量。因此,尽管我们的数据仅包括O
、AB
和A
型,所有四种类型都被与blood
因子一起存储,如输出所示。存储额外的级别为将来可能添加其他血型数据提供了可能性。它还确保了,如果我们要创建一个血型表格,即使数据中没有记录B
型,我们也知道B
型是存在的。
因子数据结构还允许我们包含关于名义变量类别顺序的信息,这为存储有序数据提供了一种便捷的方法。例如,假设我们有关于患者symptoms
(症状)严重性的数据显示,严重性从轻度到中度再到重度依次递增。我们通过按所需顺序提供因子的levels
,从最低到最高升序排列,并将ordered
参数设置为TRUE
,如所示,以表示有序数据。
> symptoms <- factor(c("SEVERE", "MILD", "MODERATE"),
levels = c("MILD", "MODERATE", "SEVERE"),
ordered = TRUE)
现在得到的symptoms
因子包括我们请求的顺序信息。与之前的因子不同,这个因子的水平值由<
符号分隔,用以表示从轻到重的顺序:
> symptoms
[1] SEVERE MILD MODERATE
Levels: MILD < MODERATE < SEVERE
有序因子的一个有用特性是逻辑测试按预期工作。例如,我们可以测试每个患者的症状是否大于中度:
> symptoms > "MODERATE"
[1] TRUE FALSE FALSE
能够建模有序数据的机器学习算法会期待有序因子,因此请确保按此要求对数据进行编码。
列表
列表是一种数据结构,类似于向量,用于存储有序的元素集合。然而,与向量要求所有元素类型相同不同,列表允许包含不同类型的元素。由于这种灵活性,列表常用于存储各种类型的输入输出数据,以及机器学习模型的配置参数集。
为了说明列表的使用,考虑我们构建的医疗患者数据集,其中包含存储在六个向量中的三名患者数据。如果我们想展示 John Doe(患者 1)的所有数据,我们需要输入五个 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()
函数创建的,如下所示。一个显著的不同是,当构造列表时,序列中的每个组件几乎总是被赋予一个名称。名称在技术上不是必需的,但它允许以后通过名称而非编号位置访问列表的值。为了创建一个包含患者 1 所有数据的命名组件的列表,可以输入以下内容:
> 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 数据结构是 数据框(data frame),它类似于电子表格或数据库结构,因为它具有行和列的数据。在 R 术语中,数据框可以理解为一个向量或因子的列表,每个向量或因子具有相同数量的值。因为数据框本质上是向量类型对象的列表,它结合了向量和列表的特点。
让我们为患者数据集创建一个数据框。使用我们之前创建的患者数据向量,data.frame()
函数将它们组合成一个数据框:
> pt_data <- data.frame(subject_name, temperature, flu_status,
gender, blood, symptoms, stringsAsFactors = FALSE)
你可能会注意到前面的代码中有些新内容。我们加入了一个额外的参数:stringsAsFactors = FALSE
。如果我们没有指定这个选项,R 会自动将每个字符向量转换为因子。
这个特性偶尔会有用,但有时也没有必要。例如,这里 subject_name
字段显然不是类别数据,因为名字不是值的类别。因此,将 stringsAsFactors
选项设置为 FALSE
,让我们只在对项目有意义的情况下将字符向量转换为因子。
当我们显示 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
相较于一维的向量、因子和列表,数据框(data frame)有两个维度,并且以矩阵格式显示。这个特定的数据框每个患者数据的向量对应一个列,每个患者对应一行。在机器学习的术语中,数据框的列是特征或属性,行是示例。
要提取整个列(向量)数据,我们可以利用数据框本质上是一个向量的列表这一特性。类似于列表,提取单个元素最直接的方式是通过名称引用它。例如,要获取 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 代码,如果数据框将来重新结构化,也不会出错。
要提取数据框中的值,可以使用类似于访问向量值的方法。然而,有一个重要的例外。由于数据框是二维的,因此必须同时指定要提取的行和列。行先指定,后面跟着一个逗号,然后是列,格式如下:[行, 列]
。与向量一样,行和列从 1 开始计数。
例如,要提取患者数据框中第一行和第二列的值(即 John Doe 的temperature
值),请使用以下命令:
> 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")]
等价于:
> pt_data[-2, c(-1, -3, -5, -6)]
为了更熟悉数据框,尝试用患者数据集进行类似的操作,或者更好的是,使用你自己项目中的数据。这些类型的操作对于我们将在接下来的章节中做的许多工作至关重要。
矩阵和数组
除了数据框,R 还提供了其他以表格形式存储值的结构。矩阵是一种数据结构,表示一个具有行和列数据的二维表格。与向量类似,R 矩阵可以包含任何一种类型的数据,尽管它们通常用于数学运算,因此通常只存储数值数据。
要创建一个矩阵,只需向matrix()
函数提供一个数据向量,并指定行数(nrow
)或列数(ncol
)的参数。例如,要创建一个存储数字 1 到 4 的 2 x 2 矩阵,我们可以使用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()
函数。save()
函数将一个或多个 R 数据结构写入由file
参数指定的位置。R 数据文件的扩展名为.RData
。
假设你有三个对象,名为x
、y
和z
,并希望将它们保存到一个永久文件中。无论它们是向量、因子、列表还是数据框,我们都可以使用以下命令将它们保存到名为mydata.RData
的文件中:
> save(x, y, z, file = "mydata.RData")
load()
命令可以重新创建已保存到.RData
文件中的任何数据结构。要加载我们在前面代码中保存的mydata.RData
文件,只需输入:
> load("mydata.RData")
这将重新创建x
、y
和z
数据结构。
提示
小心你正在加载的内容!所有存储在你通过load()
命令导入的文件中的数据结构都会被添加到你的工作区,即使它们覆盖了你正在处理的其他内容。
如果你需要急速结束你的 R 会话,save.image()
命令将会把你的整个会话保存到一个名为.RData
的文件中。默认情况下,下次启动 R 时,R 会查找这个文件,你的会话将会如你上次离开时那样被重新创建。
在 R 会话中工作了一段时间后,你可能会积累许多数据结构。ls()
列出函数返回当前内存中所有数据结构的向量。例如,如果你跟随本章的代码操作,ls()
函数将返回如下内容:
> ls()
[1] "blood" "flu_status" "gender" "m"
[5] "pt_data" "subject_name" "subject1" "symptoms"
[9] "temperature"
R 会在退出会话时自动从内存中删除这些对象,但对于大型数据结构,您可能希望更早释放内存。可以使用 rm()
删除函数来达到此目的。例如,要删除 m
和 subject1
对象,只需输入:
> rm(m, subject1)
rm()
函数也可以接受一个包含要删除的对象名称的字符向量。结合 ls()
函数,可以清除整个 R 会话:
> rm(list=ls())
执行前述命令时请务必小心,因为在删除对象之前不会提示您确认!
从 CSV 文件导入和保存数据
公共数据集通常存储在文本文件中。文本文件几乎可以在任何计算机或操作系统上读取,这使得这种格式几乎是通用的。它们还可以导入到 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", stringsAsFactors = FALSE)
这将把 CSV 文件读入名为 pt_data
的数据框。正如我们在构建数据框时所做的那样,我们需要使用 stringsAsFactors = FALSE
参数,防止 R 将所有文本变量转换为因子。此步骤最好由您而非 R 执行。
提示
如果您的数据集位于 R 工作目录之外,则可以在调用 read.csv()
函数时使用 CSV 文件的完整路径(例如,/path/to/mydata.csv
)。
默认情况下,R 假定 CSV 文件包含一个列出数据集中各特征名称的标题行。如果 CSV 文件没有标题行,请指定选项 header = FALSE
,如以下命令所示,R 会为每个特征分配默认名称,如 V1
、V2
等:
> mydata <- read.csv("mydata.csv", stringsAsFactors = FALSE,
header = FALSE)
read.csv()
函数是 read.table()
函数的一个特例,它可以读取许多不同形式的表格数据,包括其他分隔格式,如制表符分隔值(TSV)。有关 read.table()
函数族的更多详细信息,请使用 ?read.table
命令查看 R 帮助页面。
若要将数据框保存为 CSV 文件,使用write.csv()
函数。如果你的数据框名为pt_data
,只需输入:
> write.csv(pt_data, file = "pt_data.csv", row.names = FALSE)
这将把一个名为pt_data.csv
的 CSV 文件写入 R 工作文件夹。row.names
参数覆盖了 R 的默认设置,默认设置是将行名输出到 CSV 文件中。除非数据框中已添加行名,否则这种输出是多余的,且只会增加文件的大小。
探索并理解数据
在收集数据并将其加载到 R 的数据结构中后,机器学习过程中的下一步是详细检查数据。正是在这一步,你将开始探索数据的特征和示例,并意识到使数据独特的特殊性。你对数据理解得越透彻,就越能将合适的机器学习模型应用于你的学习问题。
学习数据探索过程的最佳方式是通过一个例子。在本节中,我们将探索usedcars.csv
数据集,该数据集包含了最近在美国一个流行网站上广告出售的二手车的实际数据。
提示
usedcars.csv
数据集可以在本书的 Packt Publishing 支持页面下载。如果你正在跟随本书的例子进行操作,确保已将该文件下载并保存到你的 R 工作目录中。
由于数据集以 CSV 形式存储,我们可以使用read.csv()
函数将数据加载到 R 数据框中:
> usedcars <- read.csv("usedcars.csv", stringsAsFactors = FALSE)
给定usedcars
数据框,我们现在将扮演数据科学家的角色,任务是理解二手车数据。尽管数据探索是一个流动的过程,但可以将这些步骤想象成一种调查,其中关于数据的问题得到解答。确切的问题可能因项目而异,但问题的类型始终相似。你应该能够将这种调查的基本步骤适应到任何你喜欢的数据集上,无论它们是大是小。
探索数据的结构
在调查一个新数据集时,首先要问的问题之一是数据集是如何组织的。如果幸运的话,你的源数据将提供一个数据字典,这是一份描述数据集特征的文档。在我们的案例中,二手车数据并没有随附该文档,因此我们需要自己创建一个数据字典。
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
语句指的是数据中记录的六个特征。这些特征按名称列出,每个特征占一行。查看 color
特征的这一行,我们可以注意到一些额外的细节:
$ color : chr "Yellow" "Gray" "Silver" "Gray" ...
在变量名之后,chr
标签告诉我们该特征是 character
类型。在这个数据集中,三个变量是字符型,而三个变量标记为 int
,表示整数类型。尽管 usedcars
数据集只包含 character
和 integer
类型的变量,但在使用非整数数据时,你也可能遇到 num
或 numeric
类型。任何因素都将列为 factor
类型。在每个变量的类型后,R 会显示几个特征值的序列。color
特征的前四个值是 "Yellow" "Gray" "Silver" "Gray"
。
将一些领域知识应用于特征名称和值,可以让我们对变量代表的内容做出一些假设。year
变量可能指的是车辆的生产年份,也可能指广告发布年份。我们将需要更详细地调查这个特征,因为四个示例值(2011 2011 2011 2011
)可能支持这两种可能性。model
、price
、mileage
、color
和 transmission
变量很可能指的是待售汽车的特征。
虽然我们的数据似乎已经给出了有意义的变量名称,但这并不总是如此。有时,数据集中的特征名称或代码可能像 V1
这样毫无意义。在这种情况下,可能需要进一步调查才能确定一个特征实际代表什么。不过,即使特征名称有帮助,我们仍然应对所提供的标签保持怀疑态度。让我们进一步调查。
探索数值型变量
为了调查 usedcar
数据中的数值型变量,我们将使用一组常见的度量来描述这些值,这些度量被称为汇总统计量。summary()
函数会显示几个常见的汇总统计量。让我们来看看一个特征,year
:
> summary(usedcars$year)
Min. 1st Qu. Median Mean 3rd Qu. Max.
2000 2008 2009 2009 2010 2012
即使你还不熟悉汇总统计量,你也可以从 summary()
输出前的标题中猜测一些统计量。暂时忽略值的含义,看到像 2000
、2008
和 2009
这样的数字,可能会让我们认为 year
变量指示的是生产年份,而不是广告发布年份,因为我们知道这些车辆是最近列出的待售车。
我们还可以使用 summary()
函数同时获取多个 numeric
变量的汇总统计量:
> 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.: 55125
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()
输出中报告的二手车数据集的中位数值。尽管均值和中位数价格非常相似(相差大约 5%),但里程的均值和中位数之间有很大的差异。对于里程,均值 44,261 比中位数 36,385 高出大约 20%。由于均值对极端值更为敏感,因此均值远高于中位数,可能暗示数据集中存在一些极高里程的二手车。为了进一步调查这一点,我们需要在分析中加入更多的汇总统计数据。
测量分布——四分位数和五数概括
测量均值和中位数提供了一种快速总结数值的方法,但这些集中趋势的度量对数据是否存在多样性并没有提供太多信息。为了衡量多样性,我们需要采用另一种关注数据分布的汇总统计方法,即数据的值是如何紧密或松散分布的。了解数据的分布可以帮助我们了解数据的极端值,以及大多数值是类似于均值和中位数,还是与其不同。
五数概括是一组五个统计值,粗略地描述了特征值的分布情况。所有这五个统计值都包含在 summary()
函数的输出中。按顺序排列,它们是:
-
最小值 (
Min.
) -
第一四分位数,或 Q1 (
1st Qu.
) -
中位数,或 Q2 (
Median
) -
第三四分位数,或 Q3 (
3rd Qu.
) -
最大值 (
Max.
)
正如你所预期的,最小值和最大值是最极端的特征值,分别表示最小值和最大值。R 提供了 min()
和 max()
函数,用于计算数据向量的这些值。
最小值和最大值之间的跨度被称为范围。在 R 中,range()
函数返回最小值和最大值。将 range()
与 diff()
差异函数结合使用,可以通过一行代码检查数据的范围:
> range(usedcars$price)
[1] 3800 21992
> diff(range(usedcars$price))
[1] 18192
第一四分位数和第三四分位数——Q1 和 Q3——是指其中一个值以下或以上的四分之一数据值。与中位数(Q2)一起,四分位数将数据集划分为四个部分,每部分包含相同数量的数据值。
四分位数是一种特殊情况,属于称为分位数的统计类型,它将数据等分为相同大小的数量。除了四分位数,常用的分位数还包括三分位数(三部分)、五分位数(五部分)、十分位数(十部分)和百分位数(100 部分)。
提示
百分位数通常用来描述值的排名;例如,考试成绩排在第 99 百分位数的学生比其他考生中的 99%表现更好或相等。
介于第一四分位数(Q1)和第三四分位数(Q3)之间的中间 50%数据特别重要,因为它本身是一个简单的展布度量。Q1 到 Q3 的差异被称为四分位距(IQR),可以通过IQR()
函数计算:
> IQR(usedcars$price)
[1] 3909.5
我们还可以通过从summary()
输出的usedcars$price
变量计算14904 – 10995 = 3909来手动计算这个值。我们计算和IQR()
输出之间的小差异是因为 R 自动舍入summary()
输出。
quantile()
函数提供了一个强大的工具来识别一组值的分位数。默认情况下,quantile()
函数返回五数概括。将该函数应用于二手车数据会产生与之前相同的统计结果:
> 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 到最大值之间的差异也是如此;然而,从 Q1 到中位数再到 Q3 的差异大约为$2,000。这表明,值的下 25%和上 25%比中间 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 将生成如下图形:
箱线图通过水平线和点来展示五数概括的值。图形中间箱体的水平线表示 Q1、Q2(中位数)和 Q3,按从下到上的顺序阅读图表。中位数由深色线表示,在price
的纵轴上与$13,592 对齐,在mileage
的纵轴上与 36,385 mi.对齐。
提示
在像前图那样简单的箱线图中,箱体和胡须的宽度是任意的,并不反映数据的任何特征。对于更复杂的分析,可以通过箱体的形状和大小来比较多个组的数据。要了解这些功能,可以首先查看 R 语言boxplot()
文档中的notch
和varwidth
选项,方法是键入?boxplot
命令。
最小值和最大值可以通过延伸到箱体上下方的胡须来表示;然而,广泛使用的约定只允许胡须延伸到距离 Q1 下方或 Q3 上方 1.5 倍 IQR 的最小值或最大值。超出此阈值的任何值被认为是离群值,并以圆圈或点表示。例如,回想一下price
变量的 IQR 为 3909,Q1 为 10995,Q3 为 14904。因此,离群值是任何小于10995 - 1.5 * 3909 = 5131.5或大于14904 + 1.5 * 3909 = 20767.5的值。
图表显示了在高端和低端都有两个此类异常值。在mileage
箱线图中,低端没有异常值,因此底部的胡须延伸到最小值 4,867。高端则出现了多个超过 100,000 英里标记的异常值。这些异常值是我们之前发现的原因,表明均值远大于中位数。
可视化数值变量 – 直方图
直方图是另一种图形化展示数值变量分布的方式。它与箱线图相似,因为它将变量的值划分为预定数量的部分或区间,这些区间充当值的容器。然而,它们的相似性仅止于此。一方面,箱线图要求数据的四个部分包含相同数量的值,并根据需要调整区间的宽度。另一方面,直方图使用任意数量的等宽区间,但允许每个区间包含不同数量的值。
我们可以使用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.)")
这将生成以下图表:
该直方图由一系列条形图组成,每个条形的高度表示值在各个相等宽度的区间(区间)内的数量或频率。分隔条形的垂直线,如横轴上所标记的,表示每个区间的起始和结束位置。
提示
你可能注意到,前面的直方图有不同数量的区间。这是因为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 英里结束。与价格直方图不同,最高的柱状图不在数据的中心,而是在图表的左侧。这个区间内的 70 辆车的里程数从 20,000 到 40,000 英里不等。
你可能还会注意到,两个直方图的形状有些不同。二手车的价格似乎在中间两侧比较均匀分布,而汽车的行驶里程则更多地集中在右侧。这种特征被称为偏斜,或者更具体地说是右偏,因为高端(右侧)的值比低端(左侧)的值分布得更广。如下面的图所示,偏斜数据的直方图在一侧看起来被拉长:
快速诊断数据中的这种模式是直方图作为数据探索工具的优点之一。随着我们开始检视数值数据中其他离散模式,这一点将变得更加重要。
理解数值数据 – 均匀分布和正态分布
直方图、箱形图以及描述中心和离散度的统计量提供了检视变量值分布的方法。一个变量的分布描述了一个值在不同范围内出现的可能性。
如果所有值的出现概率相等——比如说,在记录公平六面骰子掷出的数值的数据集中——这种分布被称为均匀分布。均匀分布通过直方图很容易被检测到,因为条形大致相同高度。通过直方图可视化时,它可能看起来像下面的图:
需要注意的是,并非所有的随机事件都是均匀分布的。例如,掷一颗加权的六面骰子会导致某些数字比其他数字更频繁地出现。虽然每次掷骰子的结果都是随机选出的数字,但它们并不是等概率的。
以二手车数据为例。这显然不是均匀分布,因为某些值的出现概率远高于其他值。实际上,在价格直方图中,值似乎越是远离中心条的两侧,出现的可能性就越小,从而形成了数据的钟形分布。这个特征在现实世界的数据中非常常见,它是所谓正态分布的标志。正态分布的典型钟形曲线如下面的图所示:
尽管存在许多类型的非正态分布,但许多现实世界现象生成的数据可以用正态分布来描述。因此,正态分布的属性已经被深入研究。
测量离散度 – 方差和标准差
分布让我们能够使用更少的参数来表征大量的值。正态分布描述了许多类型的现实世界数据,它只需要两个参数来定义:中心和分布。正态分布的中心由它的平均值定义,我们之前已经使用过了。分布则通过一个叫做标准差的统计量来衡量。
为了计算标准差,我们必须首先获得方差,它被定义为每个值与平均值之间差异的平方的平均值。用数学符号表示,一组n个x值的方差通过以下公式定义。希腊字母mu(看起来类似于m或u)表示值的平均数,而方差本身用希腊字母sigma的平方表示(看起来像是一个横着的b):
标准差是方差的平方根,用sigma表示,如下公式所示:
var()
和sd()
函数可以用来获取 R 中的方差和标准差。例如,计算我们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%的值分别落在两个和三个标准差的范围内。如下图所示:
将这些信息应用于二手车数据,我们知道,由于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
。由于在加载数据时我们使用了 stringsAsFactors = FALSE
参数,R 将它们保留为 character
(chr
)类型向量,而不是自动将它们转换为 factor
类型。此外,我们可能考虑将年份变量视为类别变量;尽管它已作为 numeric
(int
)类型向量加载,但每个年份都是可以应用于多辆车的类别。
与 numeric
数据不同,类别数据通常通过表格而不是摘要统计量进行检查。呈现单个类别变量的表格称为 单向表格。可以使用 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_pct <- 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%)的广告车是 Black
。Silver
紧随其后,占 21.3%,Red
排名第三,占 16.7%。
衡量集中趋势——众数
从统计学的角度来看,众数是出现频率最高的值。像均值和中位数一样,众数也是一种集中趋势的度量。它通常用于类别数据,因为均值和中位数对于名义变量是无法定义的。
例如,在二手车数据中,year
变量的众数是 2010,而 model
和 color
变量的众数分别是 SE
和 Black
。一个变量可能有多个众数;具有单一众数的变量称为 单峰,具有两个众数的变量称为 双峰。具有多个众数的数据通常称为 多峰。
提示
虽然你可能怀疑可以使用 mode()
函数,R 语言使用它来获取变量的类型(如 numeric
、list
等),而不是统计学中的众数。相反,要找到统计学中的众数,只需查看类别表格输出中出现次数最多的值。
众数或众数们在定性意义上被用来理解重要值。然而,过于强调众数是危险的,因为最常见的值不一定是多数值。例如,尽管 color
变量中 Black
是最常见的值,但黑色轿车仅占所有广告车的大约四分之一。
最好将众数与其他类别进行对比。是否有一个类别主导了其他所有类别,还是有几个类别?在这里,我们可以问,最常见的值告诉我们有关被测量变量的信息。如果黑色和银色是常见的汽车颜色,我们可能会假设数据是关于豪华车的,因为豪华车通常采用更加保守的颜色。这些颜色也可能表示经济型轿车,它们的颜色选择较少。在我们继续分析数据时,我们会保持这个问题在心。
将众数看作是常见值,允许我们将统计学中的众数概念应用于数值数据。严格来说,对于连续变量,几乎不可能有众数,因为两个值重复的可能性极小。然而,如果我们将众数看作直方图中的最高柱状条,就能讨论 price
和 mileage
等变量的众数。在探索数值数据时,考虑众数是很有帮助的,尤其是在检查数据是否是多众数的情况下。
探索变量之间的关系
到目前为止,我们已经逐一检查了变量,只计算了单变量统计数据。在我们的调查过程中,我们提出了一些当时无法回答的问题:
-
price
数据是否意味着我们仅在研究经济型轿车,还是也有高里程的豪华车? -
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 ($)")
这将生成以下散点图:
使用散点图时,我们注意到二手车价格和里程表读数之间存在明确的关系。要读取图表,请检查当x轴的值增大时,y轴变量的值如何变化。在这种情况下,随着里程的增加,汽车价格趋向于下降。如果你曾经出售或购买过二手车,你一定能体会到这一点。
也许一个更有趣的发现是,几乎没有汽车既有高价格又有高里程,除了一个大约 125,000 英里和 14,000 美元的孤立点。缺乏更多类似的点为我们提供了证据,支持我们的结论,即数据中不太可能包含高里程的豪华车。数据中所有最贵的汽车,特别是那些价格超过 17,500 美元的,似乎都有异常低的里程,这意味着我们可能在看一种零售价格约为 20,000 美元的新车。
我们发现的汽车价格和里程之间的关系被称为负相关,因为它形成了一个向下倾斜的直线状数据点模式。正相关则会形成一个向上倾斜的直线。平坦的直线或看似随机分散的数据点表明这两个变量之间没有任何关联。两个变量之间线性关系的强度通过一个称为相关性的统计量来衡量。相关性在第六章《预测数值数据–回归方法》中有详细讨论,该章节介绍了建模线性关系的方法。
提示
请记住,并非所有的关联都形成直线。有时候,数据点会形成一个U形状,或者V形状;有时候,随着x
或y
变量的增大,模式可能会显得更弱或更强。这些模式表明两个变量之间的关系并非线性。
检查关系 – 双向交叉表
为了检验两个名义变量之间的关系,使用双向列联表(也称为列联表或列联表格)。列联表类似于散点图,它可以帮助你检视一个变量的值如何随另一个变量的值变化。其格式为一个表格,其中行表示一个变量的各个水平,而列表示另一个变量的各个水平。表格中每个单元格的计数值表示该行和该列组合所对应的值的数量。
为了回答我们之前提出的关于汽车model
(型号)和color
(颜色)之间是否存在关系的问题,我们将检查列联表。在 R 中,有多个函数可以生成双向表格,包括我们用来生成单向表格的table()
。gmodels
包中的CrossTable()
选项由 Gregory R. Warnes 编写,可能是最易于使用的函数,因为它将行、列和边际百分比都呈现在一个表格中,节省了我们自己合并这些数据的麻烦。要安装gmodels
包,输入:
> install.packages("gmodels")
在包安装完成后,输入library(gmodels)
来加载该包。在每次使用CrossTable()
函数的 R 会话中,你都需要执行此操作。
在继续进行分析之前,让我们通过减少color
变量的水平数量来简化项目。该变量有九个水平,但我们不需要这么多细节。我们真正关心的是汽车颜色是否为保守色。为此,我们将九种颜色分为两组:第一组包括保守色Black
(黑色)、Gray
(灰色)、Silver
(银色)和White
(白色);第二组包括Blue
(蓝色)、Gold
(金色)、Green
(绿色)、Red
(红色)和Yellow
(黄色)。我们将创建一个二元指示变量(通常称为虚拟变量),表示汽车颜色是否符合我们定义的保守色。若为保守色,值为1
,否则为0
:
> usedcars$conservative <-
usedcars$color %in% c("Black", "Gray", "Silver", "White")
你可能已经注意到一个新命令:%in%
运算符会返回TRUE
或FALSE
,取决于运算符左侧向量中的每个值是否在右侧向量中找到。简单来说,可以将这行代码理解为“该汽车颜色是否属于Black
(黑色)、Gray
(灰色)、Silver
(银色)和White
(白色)这一集合?”
查看我们新创建的变量的table()
输出,我们看到约三分之二的汽车颜色为保守色,而三分之一的汽车颜色则不是保守色:
> table(usedcars$conservative)
FALSE TRUE
51 99
现在,我们来看看一个列联表,了解保守色彩的汽车比例如何随车型变化。由于我们假设汽车的型号决定了颜色的选择,因此我们将保守色彩指示器视为因变量(y
)。因此,CrossTable()
命令如下:
> CrossTable(x = usedcars$model, y = usedcars$conservative)
上述命令将生成以下表格:
CrossTable()
输出中包含了大量数据。顶部的图例(标记为Cell Contents
)表示如何解释每个值。表格的行表示三种二手车模型:SE
、SEL
和SES
(以及表示所有车型总和的附加行)。列表示车辆的颜色是否为保守色(并且还有一列显示两种颜色类型的总和)。每个单元格中的第一个值表示具有该车型和颜色组合的车辆数量。比例表示该单元格的比例是相对于卡方统计量、行总数、列总数和表格总数的。
我们最感兴趣的是每个车型中保守色车的行比例。行比例告诉我们,SE
车型中有 0.654(65%)的车辆是保守色的,相比之下,SEL
车型中有 0.696(70%)的车辆是保守色,SES
车型中则为 0.653(65%)。这些差异相对较小,表明不同车型之间在选择颜色类型上没有实质性的差异。
卡方值指的是皮尔逊卡方独立性检验中每个单元格的贡献。该检验衡量的是表格中单元格计数差异仅由偶然因素造成的可能性。如果概率非常低,则提供了强有力的证据,表明这两个变量之间存在关联。
你可以通过在调用CrossTable()
函数时添加额外的参数chisq = TRUE
来获得卡方检验结果。在这种情况下,概率约为 93%,这表明单元格计数的变化很可能仅由偶然因素造成,而不是由于车型和颜色之间的真正关联。
总结
本章我们学习了在 R 中管理数据的基础知识。我们首先深入了解了用于存储各种类型数据的结构。R 的基础数据结构是向量,它被扩展并组合成更复杂的数据类型,如列表和数据框。数据框是 R 中的数据结构,类似于数据集的概念,既有特征也有样本。R 提供了读取和写入数据框的函数,可以将数据存储为类似电子表格的表格数据文件。
接下来,我们探讨了一个包含二手车价格数据的真实数据集。我们使用常见的汇总统计量(如集中趋势和离散程度)来分析数值变量,并通过散点图可视化了价格与里程表读数之间的关系。我们使用表格分析了名义变量。在分析二手车数据时,我们遵循了一种探索性过程,这种过程可用于理解任何数据集。这些技能将在本书的其他项目中得到应用。
现在我们已经花了一些时间了解了 R 的数据管理基础,你已经准备好开始使用机器学习来解决现实世界的问题了。在下一章中,我们将使用最近邻方法解决我们的第一个分类任务。
第三章:懒惰学习——使用最近邻进行分类
一种有趣的新型用餐体验正在世界各地的城市中出现。顾客在完全黑暗的餐厅中,由记住路线并仅凭触觉和听觉在餐厅中小心走动的服务员进行服务。这些餐厅的吸引力在于,人们相信剥夺视觉感官输入将增强味觉和嗅觉,使食物以新的方式被体验。每一口都充满惊奇,同时发现厨师所准备的美味。
你能想象一下食客是如何体验那些看不见的食物吗?第一次咬下去时,感官会被完全压倒。主导的味道是什么?食物是咸的还是甜的?味道是否像以前吃过的东西?就个人而言,我将这个发现的过程想象成一种略微修改过的谚语:如果闻起来像鸭子,吃起来也像鸭子,那你很可能是在吃鸭子。
这说明了一个可以用于机器学习的思想——另一个涉及家禽的格言也有类似的启示:“物以类聚”。换句话说,相似的事物很可能具有相似的特征。机器学习运用这一原则,通过将数据与相似或“最近”的邻居放在同一类别中来进行分类。本章将专门讲解使用这种方法的分类器。你将学习:
-
定义最近邻分类器的关键概念,以及它们为何被认为是“懒惰”学习者
-
使用距离来衡量两个示例相似度的方法
-
应用一种流行的最近邻分类器,称为 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 | 蔬菜 |
奶酪 | 1 | 1 | 蛋白质 |
k-NN 算法将特征视为多维特征空间中的坐标。由于我们的数据集只包含两个特征,因此特征空间是二维的。我们可以在散点图上绘制二维数据,其中 x 维表示食材的甜度,y 维表示脆度。在向味道数据集中添加更多食材后,散点图可能会像这样:
你注意到这个模式了吗?相似类型的食物往往被聚集在一起。正如下图所示,蔬菜往往脆但不甜,水果通常是甜的,并且可能脆也可能不脆,而蛋白质则通常既不脆也不甜:
假设在构建完这个数据集后,我们决定用它来解决那个长期悬而未决的问题:番茄是水果还是蔬菜?我们可以使用最近邻方法来确定哪个类别更合适,如下图所示:
用距离度量相似性
找到番茄的最近邻需要一个距离函数,即一个衡量两个实例相似性的公式。
计算距离有许多不同的方法。传统上,k-NN 算法使用欧几里得距离,这是一种如果可以用尺子连接两点时所测量的距离,如前图中虚线连接番茄和邻居所示。
提示
欧几里得距离是“按直线飞行”的方式来度量的,意味着最短的直接路径。另一种常见的距离度量是曼哈顿距离,它是基于行人走过城市街区的路径。如果你想了解更多其他的距离度量,可以阅读 R 的距离函数文档(这个工具本身也非常有用),使用?dist
命令。
欧几里得距离由以下公式指定,其中 p 和 q 是要比较的示例,每个示例有 n 个特征。术语 p[1] 指示示例 p 的第一个特征的值,而 q[1] 指示示例 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值应该介于这两种极端之间。
下图更普遍地展示了决策边界(由虚线表示)如何受到较大或较小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的范围。
标准化特征值可以解释为指示原始值在原始最小值和最大值之间的范围内,距离 0%到 100%的位置。
另一个常见的转换方法叫做z-score 标准化。以下公式减去特征X的均值,并将结果除以X的标准差:
这个公式基于第二章中讲解的正态分布的特性,管理与理解数据,将每个特征的值重新缩放为它们相对于均值的标准差数量。结果值称为z-score。z-score 值在负数和正数的无限范围内波动。与标准化值不同,它们没有预定义的最小值和最大值。
提示
用于 k-NN 训练数据集的相同重缩放方法也必须应用于算法后续分类的示例。这可能导致最小-最大标准化的棘手情况,因为未来案例的最小值或最大值可能超出了训练数据中观察到的值范围。如果您提前知道合理的最小值或最大值,可以使用这些常数,而不是观察到的值。或者,您可以使用 z-score 标准化,假设未来的示例将具有与训练示例相似的均值和标准差。
欧几里得距离公式对名义数据没有定义。因此,为了计算名义特征之间的距离,我们需要将其转换为数值格式。一种典型的解决方案是使用虚拟编码,其中1表示一个类别,0表示另一个类别。例如,性别变量的虚拟编码可以构建如下:
请注意,二类(即二进制)性别变量的虚拟编码结果是一个名为 male 的新特征。无需为 female 构建单独的特征;由于两性是互斥的,知道其中之一就足够了。
更普遍地说,n类名义特征可以通过为特征的(n - 1)个水平创建二进制指示变量来进行虚拟编码。例如,对于一个三类温度变量(例如,热、中等或冷),可以将虚拟编码设置为(3 - 1) = 2个特征,如下所示:
知道热和中等的值都是0就足够知道温度是冷的。因此,我们不需要为冷类构建第三个特征。
虚拟编码的一个方便之处在于,虚拟编码特征之间的距离始终为 1 或 0,因此,这些值与最小-最大标准化的数值数据处于相同的尺度上。不需要额外的转换。
提示
如果一个名义特征是有序的(例如温度可以这样认为),那么除了虚拟编码之外,还可以通过对类别编号并应用归一化来实现。例如,冷、温暖和热可以被编号为 1、2 和 3,然后归一化为 0、0.5 和 1。需要注意的是,这种方法只应在类别之间的差距是等价时使用。例如,尽管贫穷、中产阶级和富裕的收入类别是有序的,但贫穷与中产阶级之间的差距可能与中产阶级与富裕之间的差距不同。由于各组之间的差距不等,虚拟编码是更安全的方法。
为什么 k-NN 算法是懒惰的?
基于最近邻方法的分类算法被认为是懒惰学习算法,因为从技术上讲,没有发生抽象化。抽象和泛化过程完全跳过了,这削弱了第一章中提出的学习定义,机器学习介绍。
在严格的学习定义下,懒惰学习者实际上并没有学到任何东西。相反,它只是逐字地存储训练数据。这使得训练阶段(实际上并没有训练任何东西)可以非常快速地完成。当然,缺点是,与训练相比,做出预测的过程通常较慢。由于高度依赖于训练实例而非抽象模型,懒惰学习也被称为基于实例的学习或死记硬背学习。
由于基于实例的学习者不建立模型,因此这种方法被归类为非参数学习方法——即没有关于数据的任何参数被学习。由于不生成关于底层数据的理论,非参数方法限制了我们理解分类器如何使用数据的能力。另一方面,这使得学习者能够发现自然的模式,而不是试图将数据拟合到一个预设的且可能存在偏差的功能形式中。
虽然 k-NN 分类器可能被认为是懒惰的,但它们仍然非常强大。正如你很快会看到的,最近邻学习的简单原理可以用于自动化癌症筛查过程。
示例 – 使用 k-NN 算法诊断乳腺癌
常规的乳腺癌筛查可以在疾病引发明显症状之前就诊断并治疗。早期检测过程涉及检查乳腺组织是否有异常的肿块或肿块。如果发现肿块,会进行细针穿刺活检,使用一个中空的针头从肿块中提取少量细胞样本。然后,临床医生在显微镜下检查细胞,以确定肿块是良性还是恶性。
如果机器学习能够自动化识别癌细胞,将为健康系统带来显著益处。自动化过程可能会提高检测效率,使医生能够花更少的时间诊断、更多的时间治疗疾病。自动筛查系统还可能通过去除过程中的人为主观成分,提高检测的准确性。
我们将通过应用 k-NN 算法,对来自患有异常乳腺肿块的女性的活检细胞测量数据进行研究,以探讨机器学习在癌症检测中的应用。
第 1 步 – 收集数据
我们将利用来自 UCI 机器学习库的威斯康星乳腺癌诊断数据集,网址为 archive.ics.uci.edu/ml
。该数据由威斯康星大学的研究人员捐赠,包含来自乳腺肿块的细针抽吸数字化图像的测量数据。这些数值代表了数字图像中细胞核的特征。
注意
如需了解更多关于此数据集的信息,请参考:Mangasarian OL, Street WN, Wolberg WH. Breast cancer diagnosis and prognosis via linear programming. Operations Research. 1995; 43:570-577.
乳腺癌数据集包括 569 个癌症活检样本,每个样本具有 32 个特征。其中一个特征是标识号,另一个是癌症诊断结果,30 个是数值型实验室测量值。诊断结果用 "M"
表示恶性,"B"
表示良性。
另外的 30 个数值型测量包含了 10 个不同细胞核特征的均值、标准误差和最差(即最大)值。这些特征包括:
-
半径
-
纹理
-
周长
-
面积
-
平滑度
-
紧凑度
-
凹陷度
-
凹点
-
对称性
-
分形维度
根据这些名称,所有特征似乎都与细胞核的形状和大小有关。除非你是肿瘤学家,否则你不太可能知道每个特征如何与良性或恶性肿块相关。这些模式将在我们继续进行机器学习过程时揭示出来。
第 2 步 – 探索和准备数据
让我们探索数据,看看是否能揭示一些关系。在此过程中,我们将为 k-NN 学习方法准备数据。
提示
如果你计划跟着一起操作,请从 Packt 网站下载 wisc_bc_data.csv
文件,并将其保存到你的 R 工作目录中。该数据集在原始形式的基础上略作修改,特别是添加了一个标题行,并且数据行已被随机排序。
我们将像以前章节一样,首先导入 CSV 数据文件,并将威斯康星乳腺癌数据保存到 wbcd
数据框中:
> wbcd <- read.csv("wisc_bc_data.csv", stringsAsFactors = FALSE)
使用 str(wbcd)
命令,我们可以确认数据结构与预期相符,包含 569 个样本和 32 个特征。输出的前几行如下所示:
'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
特征。由于它位于第一列,我们可以通过制作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()
输出时,注意到值已被标记为Benign
和Malignant
,分别占 62.7%和 37.3%的肿块:
> round(prop.table(table(wbcd$diagnosis)) * 100, digits = 1)
Benign Malignant
62.7 37.3
剩下的 30 个特征都是数值型的,正如预期的,它们由十个特征的三种不同测量组成。为了便于说明,我们将只对其中三个特征进行更深入的分析:
> 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()
函数提供了 k-NN 算法的标准经典实现。对于测试数据中的每个实例,函数将使用欧几里得距离识别 k 个最近邻,其中k是用户指定的数字。通过在 k 个最近邻之间进行“投票”,对测试实例进行分类——具体来说,这包括为多数* k *邻居分配类别。如果出现平局投票,则随机打破平局。
提示
还有其他一些 k-NN 函数在其他 R 包中,它们提供了更复杂或更高效的实现。如果你在使用knn()
时遇到限制,可以在综合 R 档案网络(CRAN)上搜索 k-NN。
使用knn()
函数进行训练和分类是通过一次函数调用完成的,使用四个参数,如下表所示:
现在我们几乎具备了应用 k-NN 算法所需的一切。我们已经将数据分成了训练数据集和测试数据集,每个数据集都有完全相同的数值特征。训练数据的标签存储在一个单独的因子向量中。唯一剩下的参数是 k
,它指定了投票中要包含的邻居数量。
由于我们的训练数据包含 469 个实例,我们可以尝试使用 k = 21
,这是一个奇数,接近 469 的平方根。在二分类结果的情况下,使用奇数可以避免最终出现平局投票的情况。
现在我们可以使用 knn()
函数对 test
数据进行分类:
> wbcd_test_pred <- knn(train = wbcd_train, test = wbcd_test,
cl = wbcd_train_labels, k = 21)
knn()
函数返回一个预测标签的因子向量,对应 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)
结果表格如下所示:
表格中的单元格百分比表示四个类别中值的比例。左上角的单元格表示真负结果。这 100 个值中有 61 个是良性肿块,且 k-NN 算法正确地将其识别为良性。右下角的单元格表示真阳性结果,其中分类器和临床确定的标签一致,表明肿块为恶性。总共 100 个预测中,有 37 个是真阳性。
其他对角线上的单元格包含的是 k-NN 方法与真实标签不一致的示例数量。左下角的两个示例是假阴性结果;在这种情况下,预测值为良性肿块,但实际上肿瘤是恶性的。此类错误可能极为代价高昂,因为它们可能导致患者误以为自己没有癌症,而实际上,疾病可能还在继续扩散。右上角的单元格将包含假阳性结果(如果有的话)。这些值发生在模型将肿块分类为恶性时,但实际上它是良性的。尽管此类错误比假阴性结果危害较小,但仍应避免,因为它们可能导致医疗系统额外的经济负担,或由于需要额外的检测或治疗而给患者带来更多压力。
提示
如果我们愿意的话,完全可以通过将每个肿块分类为恶性肿瘤来消除假阴性。显然,这并不是一种现实的策略。尽管如此,这仍然说明了预测需要在假阳性率和假阴性率之间找到平衡。在第十章,评估模型表现,你将学习到更复杂的预测准确性衡量方法,这些方法可以用来识别那些错误率可以根据每种类型错误的成本进行优化的地方。
总共有 100 个肿块中有 2 个被错误分类,即 2%的肿块被 k-NN 方法错误分类。虽然 98%的准确率看起来对于几行 R 代码来说相当令人印象深刻,但我们可能会尝试模型的另一个迭代版本,看看能否改善性能,减少错误分类的数量,特别是因为这些错误是危险的假阴性。
第 5 步 – 提高模型性能
我们将尝试对之前的分类器做两个简单的变动。首先,我们将采用一种替代方法对数值特征进行重缩放。其次,我们将尝试几个不同的k值。
转换 – z 分数标准化
尽管归一化通常用于 k-NN 分类,但它并不总是最合适的特征重缩放方法。由于 z 分数标准化后的值没有预定义的最小值和最大值,极端值不会被压缩到中心。因此,人们可能会怀疑在恶性肿瘤的情况下,肿瘤可能会 uncontrollably 地生长,产生一些非常极端的异常值。因此,允许异常值在距离计算中有更高的权重可能是合理的。让我们看看 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 分数标准化变量的均值应该始终为零,范围应该相对紧凑。大于 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%。更糟糕的是,我们在分类危险的假阴性时也没有取得更好的成绩:
测试不同的 k 值
我们也许可以通过检查不同k值下的表现来做得更好。使用标准化后的训练集和测试集,使用多个不同的k值对相同的 100 条记录进行了分类。每次迭代的假阴性和假阳性的数量如下所示:
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-NN 不进行任何学习。它只是逐字存储训练数据。然后,使用距离函数将未标记的测试样本与训练集中最相似的记录进行匹配,并将未标记样本分配给其邻居的标签。
尽管 k-NN 是一个非常简单的算法,但它能够处理极为复杂的任务,比如癌症肿块的识别。在几行简单的 R 代码中,我们能够以 98%的准确率正确识别一个肿块是恶性还是良性。
在下一章,我们将研究一种分类方法,它利用概率来估计一个观察值属于某些类别的可能性。将会很有趣地比较这种方法与 k-NN 的不同之处。稍后,在第九章,数据分组——使用 k-means 进行聚类,我们将学习与 k-NN 关系密切的另一种方法,它使用距离度量进行完全不同的学习任务。
第四章:概率学习——使用朴素贝叶斯进行分类
当气象学家提供天气预报时,降水通常用诸如“70%可能下雨”这样的术语来描述。这类预报被称为降水概率报告。你有没有想过它们是如何计算出来的?这是一个令人困惑的问题,因为实际上,要么下雨,要么不下雨。
天气预估基于概率方法或那些描述不确定性的方法。它们使用过去事件的数据来推测未来事件。以天气为例,下雨的机会描述了过去在类似的可测量大气条件下降水发生的天数比例。70%的降雨概率意味着,在过去 10 个类似条件的案例中,有 7 个地方发生了降水。
本章介绍了朴素贝叶斯算法,它通过概率与天气预报的方式类似。学习该方法时,你将了解到:
-
概率的基本原理
-
使用 R 分析文本数据所需的专业方法和数据结构
-
如何运用朴素贝叶斯构建短信垃圾信息过滤器
如果你以前上过统计学课,本章中的部分内容可能是复习内容。尽管如此,重新温习一下概率知识可能还是有帮助的,因为这些原理是朴素贝叶斯得名的基础。
理解朴素贝叶斯
理解朴素贝叶斯算法所需的基本统计学理念已经存在了几个世纪。这一技术源于 18 世纪数学家托马斯·贝叶斯的工作,他发展了描述事件概率的基础原理,并阐述了在获得额外信息的情况下,如何修正概率。这些原理构成了如今被称为贝叶斯方法的基础。
我们将在后面更详细地讲解这些方法。但目前,只需说概率是一个介于 0 和 1 之间的数字(即 0%到 100%之间),它表示根据现有证据,某个事件发生的概率。概率越低,事件发生的可能性就越小。概率为 0 表示事件肯定不会发生,而概率为 1 表示事件将以 100%的确定性发生。
基于贝叶斯方法的分类器利用训练数据计算每个结果的观察概率,这些结果基于特征值提供的证据。当分类器应用于未标记的数据时,它使用观察到的概率来预测新特征的最可能类别。这是一个简单的理念,但它产生了一种通常能与更复杂算法相媲美的效果。实际上,贝叶斯分类器已被用于:
-
文本分类,如垃圾邮件(垃圾邮件)过滤
-
计算机网络中的入侵或异常检测
-
根据一组观察到的症状诊断医疗状况
通常,贝叶斯分类器最好应用于需要同时考虑多个属性信息的情境,以估算结果的总体概率。尽管许多机器学习算法会忽略效果较弱的特征,贝叶斯方法利用所有可用的证据来微妙地改变预测。如果大量特征的效果相对较小,结合起来,它们的综合影响可能会非常大。
贝叶斯方法的基本概念
在深入研究朴素贝叶斯算法之前,值得花些时间定义贝叶斯方法中使用的概念。用一句话总结,贝叶斯概率论的核心思想是,事件或潜在结果的估计可能性应该基于当前证据,并且通过多个试验或事件发生的机会来加以判断。
以下表格展示了几个现实世界结果的事件和试验:
事件 | 试验 |
---|---|
正面结果 | 投币实验 |
雨天 | 单一天 |
消息是垃圾邮件 | 收到的电子邮件消息 |
候选人当选总统 | 总统选举 |
中头奖 | 彩票 |
贝叶斯方法提供了有关如何从观察到的数据估算这些事件概率的洞见。为了了解这一点,我们需要正式化我们的概率理解。
理解概率
事件的概率是通过将事件发生的试验次数除以总的试验次数来估算的。例如,如果在与今天类似的条件下,10 天中有 3 天下雨,那么今天下雨的概率可以估算为3 / 10 = 0.30,即 30%。类似地,如果 50 封先前的电子邮件中有 10 封是垃圾邮件,那么任何收到的电子邮件是垃圾邮件的概率可以估算为10 / 50 = 0.20,即 20%。
为了表示这些概率,我们使用类似P(A)的符号,表示事件A的概率。例如,P(rain) = 0.30 和 P(spam) = 0.20。
一次试验的所有可能结果的概率之和必须始终为 1,因为一次试验总会产生某种结果。因此,如果试验有两个无法同时发生的结果,比如雨天与晴天,或者垃圾邮件与非垃圾邮件(ham),那么知道其中一个结果的概率就能推断出另一个结果的概率。例如,给定P(spam) = 0.20,我们可以计算出P(ham) = 1 – 0.20 = 0.80。这表明垃圾邮件和非垃圾邮件是互斥且穷尽的事件,意味着它们不能同时发生,并且是唯一可能的结果。
因为一个事件不能同时发生又不发生,所以一个事件与其补集始终是互斥的且穷尽的,补集是指事件不发生时所有可能结果的集合。事件A的补集通常表示为A^c或A'。此外,简写符号P(¬A)可以用来表示事件A不发生的概率,如P(¬spam) = 0.80。该符号等同于P(A^c)。
为了说明事件及其补集,通常有助于想象一个被划分为各个事件概率的二维空间。在下图中,矩形表示电子邮件可能的结果。圆圈表示邮件是垃圾邮件的 20%的概率。其余的 80%表示补集P(¬spam),即那些不是垃圾邮件的消息:
理解联合概率
通常,我们对监控多个非互斥事件感兴趣,尤其是在同一次试验中。如果某些事件与感兴趣的事件同时发生,我们可能能够利用这些事件进行预测。以电子邮件中包含“伟哥”一词为例,在大多数情况下,这个词只出现在垃圾邮件中;因此它出现在一封邮件中,是垃圾邮件的强烈证据。更新后的图示,加入了第二个事件,可能如下所示:
在图示中可以看到,伟哥圆圈并没有完全填满垃圾邮件圆圈,也没有完全被垃圾邮件圆圈包含。这意味着并不是所有的垃圾邮件都包含“伟哥”这个词,而包含“伟哥”一词的邮件也并不一定是垃圾邮件。
为了更仔细地查看垃圾邮件和伟哥圆圈之间的重叠部分,我们将使用一种叫做维恩图的可视化方法。维恩图最早由约翰·维恩在 19 世纪末期使用,利用圆圈来展示各个集合之间的重叠。在大多数维恩图中,圆圈的大小和重叠程度并没有实际意义。它更多地用于提醒我们给所有可能的事件组合分配概率:
我们知道,20%的邮件是垃圾邮件(左侧圆圈),5%的邮件包含“伟哥”一词(右侧圆圈)。我们希望量化这两个比例之间的重叠程度。换句话说,我们希望估算“垃圾邮件”和“伟哥”同时发生的概率,可以表示为P(spam ∩ Viagra)。倒“U”符号表示两个事件的交集;符号A ∩ B表示事件A和B同时发生的事件。
计算P(spam ∩ Viagra)取决于两个事件的联合概率,或者说一个事件的概率与另一个事件的概率之间的关系。如果两个事件完全无关,则称为独立事件。这并不意味着独立事件不能同时发生;事件独立性仅意味着知道一个事件的结果不会提供关于另一个事件结果的任何信息。例如,抛硬币的正面结果与某天的天气是否晴天或下雨是独立的。
如果所有事件都是独立的,就不可能通过观察一个事件来预测另一个事件。换句话说,依赖事件是预测建模的基础。就像云的存在可以预测下雨天一样,Viagra 这个词的出现可以预测垃圾邮件。
计算依赖事件的概率比计算独立事件的概率更为复杂。如果P(spam)和P(Viagra)是独立的,我们可以轻松计算P(spam ∩ Viagra),即两个事件同时发生的概率。因为所有消息中有 20%是垃圾邮件,而 5%的电子邮件包含 Viagra 这个词,我们可以假设 1%的消息是含有 Viagra 的垃圾邮件。这是因为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)的估计应该基于P(A ∩ B),即A和B共同发生的频率,以及P(B),即B发生的频率。
贝叶斯定理指出,P(A|B)的最佳估计是在所有B发生的试验中,A与B同时发生的比例。简单来说,这告诉我们,如果我们知道事件B发生了,那么事件A发生的概率会随着每次观察到A和B同时发生的次数而增加。在某种程度上,这调整了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%。这个估计被称为先验概率。
假设你通过仔细查看先前接收到的消息集合,检查“伟哥”一词出现的频率,获得了额外的证据。在先前的垃圾邮件中使用“伟哥”一词的概率,或P(Viagra|spam),被称为似然性。任何消息中出现“伟哥”的概率,或P(Viagra),被称为边际似然性。
通过将贝叶斯定理应用于这些证据,我们可以计算一个衡量消息可能是垃圾邮件的后验概率。如果后验概率大于 50%,那么消息更可能是垃圾邮件而不是正常邮件,可能应该被过滤。以下公式显示了如何将贝叶斯定理应用于先前电子邮件消息提供的证据:
为了计算贝叶斯定理的这些组成部分,有助于构建一个频率表(如下图左侧所示),记录了“Viagra”出现在垃圾邮件和正常邮件中的次数。就像一个双向交叉表一样,表的一个维度表示类变量的级别(垃圾邮件或正常邮件),而另一个维度表示特征的级别(Viagra: 是或否)。然后单元格指示具有特定类值和特征值组合的实例数。然后可以使用频率表构建一个似然表,如下图右侧所示。似然表的行表示给定邮件是垃圾邮件或正常邮件时“Viagra”(是/否)的条件概率:
似然表表明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”的垃圾邮件。无论哪种方式,这都比我们在错误假设独立性下计算的 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[1],W[2],W[3],W[4])的出现构建似然性表来训练朴素贝叶斯学习器,如下图所示,基于 100 封电子邮件:
随着新邮件的到来,我们需要计算后验概率,以确定根据邮件文本中单词的出现情况,邮件更可能是垃圾邮件还是正常邮件。例如,假设一封邮件包含“Viagra”和“Unsubscribe”这两个词,但不包含“Money”或“Groceries”。
使用贝叶斯定理,我们可以像以下公式所示那样定义问题。它捕获了在给定Viagra = Yes,Money = No,Groceries = No,Unsubscribe = Yes的情况下,邮件是垃圾邮件的概率:
出于多种原因,这个公式在计算上是困难的。随着附加特征的增加,需要巨大的内存来存储所有可能交集事件的概率;想象一下,如果四个单词的事件形成一个维恩图的复杂性,更不用说成百上千个单词了。
如果我们能够利用朴素贝叶斯假设事件之间独立性的事实,工作将变得更轻松。具体来说,它假设类条件独立性,意味着只要事件在相同的类别值条件下,它们是独立的。假设条件独立性使我们能够使用独立事件的概率规则简化公式,概率规则是P(A ∩ B) = P(A) * P(B)。因为分母与类别(垃圾邮件或正常邮件)无关,它被视为常数值,可以暂时忽略。这意味着垃圾邮件的条件概率可以表示为:
并且消息是“ham”的概率可以表示为:
请注意,等号被替换为与符号(类似于一个横向的开放“8”),以表示分母被省略了。
使用似然表中的数值,我们可以开始在这些方程中填写数字。垃圾邮件的总体似然度为:
(4/20) * (10/20) * (20/20) * (12/20) * (20/100) = 0.012
而“ham”的似然度为:
(1/80) * (66/80) * (71/80) * (23/80) * (80/100) = 0.002
因为0.012/0.002 = 6,我们可以说这条信息是垃圾邮件的可能性是“ham”的六倍。然而,为了将这些数字转换为概率,我们需要执行最后一步,重新引入之前被排除的分母。实质上,我们必须通过将其除以所有可能结果的总似然度,来重新调整每个结果的似然度。
通过这种方式,垃圾邮件的概率等于该消息是垃圾邮件的似然度除以该消息是垃圾邮件或正常邮件的似然度:
0.012/(0.012 + 0.002) = 0.857
类似地,正常邮件的概率等于该消息是正常邮件的似然度除以该消息是垃圾邮件或正常邮件的似然度:
0.002/(0.012 + 0.002) = 0.143
根据这条信息中找到的单词模式,我们可以预计该信息是垃圾邮件的概率为 85.7%,是“ham”的概率为 14.3%。因为这两者是互斥且穷尽的事件,概率加和为 1。
我们在前面的示例中使用的朴素贝叶斯分类算法可以通过以下公式总结。给定特征F[1]到F[n]所提供的证据,类C的L级别的概率等于每个证据在类级别条件下的概率与类级别的先验概率以及一个缩放因子1/Z的乘积,后者将似然值转化为概率:
尽管这个公式看起来令人畏惧,但正如前面的例子所示,步骤非常简单。首先构建一个频率表,使用这个表构建一个似然表,然后按照朴素贝叶斯规则相乘条件概率。最后,通过总似然性进行除法,将每个类的似然性转化为概率。经过几次手动计算后,这将变成第二天性。
拉普拉斯估计量
在我们将朴素贝叶斯应用于更复杂的问题之前,有一些细节需要考虑。假设我们收到了另一条消息,这次包含了所有四个词:Viagra、Groceries、Money 和 Unsubscribe。像之前一样使用朴素贝叶斯算法,我们可以计算垃圾邮件的似然性为:
(4/20) * (10/20) * (0/20) * (12/20) * (20/100) = 0
正常邮件的似然性为:
(1/80) * (14/80) * (8/80) * (23/80) * (80/100) = 0.00005
因此,垃圾邮件的概率为:
0/(0 + 0.00005) = 0
正常邮件的概率为:
0.00005/(0 + 0.00005) = 1
这些结果表明,该消息的垃圾邮件概率为 0%,而正常邮件的概率为 100%。这个预测合理吗?可能不合理。该消息包含多个通常与垃圾邮件相关的词汇,包括 Viagra,这是合法邮件中很少使用的词汇。因此,很可能该邮件被错误地分类了。
如果某个事件在一个或多个类别级别上从未发生,可能会出现这个问题。例如,术语 Groceries 以前从未出现在垃圾邮件中。因此,P(spam|groceries) = 0%。
由于朴素贝叶斯公式中的概率是按链式相乘的,这个 0%的值导致垃圾邮件的后验概率为零,从而使得“Groceries”这个词能够有效地否定并推翻所有其他证据。即使这封邮件在其他方面明显应该是垃圾邮件,垃圾邮件中缺少“Groceries”一词也总是会否决其他证据,导致垃圾邮件的概率为零。
解决这个问题的方法之一是使用被称为拉普拉斯估计器的工具,该工具以法国数学家皮埃尔-西蒙·拉普拉斯的名字命名。拉普拉斯估计器本质上是向频率表中的每个计数值添加一个小的数字,确保每个特征在每个类别中都有非零的发生概率。通常,拉普拉斯估计器设置为 1,这确保每个类别-特征组合至少在数据中出现一次。
提示
拉普拉斯估计器可以设置为任何值,并不一定对每个特征都相同。如果你是一个坚定的贝叶斯信徒,你可以使用拉普拉斯估计器来反映特征与类别之间的假定先验概率。在实践中,给定一个足够大的训练数据集,这一步通常是不必要的,值通常设置为 1。
让我们看看这如何影响我们对这条信息的预测。使用拉普拉斯值为 1 时,我们将 1 加到似然函数中的每个分子。每个条件概率的分母也必须加上 1 的总数。因此,垃圾邮件的似然性是:
(5/24) * (11/24) * (1/24) * (13/24) * (20/100) = 0.0004
火腿的似然性是:
(2/84) * (15/84) * (9/84) * (24/84) * (80/100) = 0.0001
这意味着垃圾邮件的概率是 80%,火腿的概率是 20%,这个结果比仅凭“杂货”一项就决定结果的情况更为合理。
在朴素贝叶斯中使用数值特征
由于朴素贝叶斯使用频率表来学习数据,因此每个特征必须是类别型的,以便创建包含类别和特征值组合的矩阵。由于数值特征没有类别值,前述算法不能直接应用于数值数据。然而,有一些方法可以解决这个问题。
一个简单有效的解决方案是离散化数值特征,这意味着将数字放入被称为箱的类别中。因此,离散化有时也被称为分箱。当训练数据量非常大时,这种方法非常理想,而在使用朴素贝叶斯时,通常会遇到这种情况。
有几种不同的方法可以对数值特征进行离散化。最常见的一种方法是通过探索数据中自然的类别或切点来实现。例如,假设你在垃圾邮件数据集中添加了一个特征,记录电子邮件发送的时间,时间从午夜后的 0 到 24 小时不等。
使用直方图表示,时间数据可能类似于以下图示。在清晨时段,消息频率较低。活动在工作时间期间增多,并在晚上逐渐减少。这似乎形成了四个自然的活动区间,由虚线分隔,虚线表示数据在这些位置被分割为新类别特征的不同水平,这些特征随后可以与朴素贝叶斯算法一起使用:
请记住,选择四个区间是基于数据的自然分布和对垃圾邮件在一天内比例变化的直觉判断,这一选择有些任意。我们可能会预期垃圾邮件发送者会在深夜时分活跃,或者在白天活跃,当人们更可能查看电子邮件时。也就是说,为了捕捉这些趋势,我们也可以使用三个区间或十二个区间。
提示
如果没有明显的切分点,一个选择是使用分位数对特征进行离散化。你可以通过三分位将数据分为三个区间,四分位分为四个区间,或者五分位分为五个区间。
需要记住的一点是,离散化数值特征总是会导致信息的丧失,因为特征的原始粒度被减少为更少的类别数量。在这里找到平衡点非常重要。区间过少可能会导致重要的趋势被忽视,而区间过多则可能导致朴素贝叶斯频率表中的计数过小,从而增加算法对噪声数据的敏感性。
示例 – 使用朴素贝叶斯算法过滤手机垃圾短信
随着全球手机使用量的增加,一个新的电子垃圾邮件渠道为不道德的营销商打开了大门。这些广告商利用短信(SMS)文本信息,通过 SMS 垃圾短信向潜在消费者推送不必要的广告。此类垃圾短信特别麻烦,因为与电子邮件垃圾邮件不同,许多手机用户每接收到一条短信都需要支付费用。开发一种能够过滤 SMS 垃圾短信的分类算法,将为手机服务提供商提供一个有用的工具。
由于朴素贝叶斯算法在电子邮件垃圾邮件过滤中取得了成功,因此它也可能适用于 SMS 垃圾短信。然而,与电子邮件垃圾邮件相比,SMS 垃圾短信为自动过滤器带来了额外的挑战。SMS 消息通常限制为 160 个字符,减少了可以用来判断消息是否为垃圾信息的文本量。这一限制,再加上手机小巧的键盘,促使许多人采用了一种短信简写语言,这进一步模糊了合法消息和垃圾信息之间的界限。让我们看看一个简单的朴素贝叶斯分类器如何应对这些挑战。
步骤 1 – 收集数据
为了开发朴素贝叶斯分类器,我们将使用从 SMS 垃圾信息数据集中改编的数据,数据来源:www.dt.fee.unicamp.br/~tiago/smsspamcollection/
。
注意
若想了解更多关于如何开发 SMS 垃圾信息数据集的内容,请参考:Gómez JM, Almeida TA, Yamakami A. 《新 SMS 垃圾信息数据集的有效性》。《第 11 届 IEEE 国际机器学习与应用大会论文集》,2012 年。
该数据集包括短信文本及其标签,标签指示该短信是否为垃圾信息。垃圾信息标记为 spam,正常信息标记为 ham。以下表格展示了一些垃圾信息和正常信息的示例:
示例短信正常信息 | 示例短信垃圾信息 |
---|
|
-
更好。为了弥补星期五的空缺,我昨天吃得像个猪一样。现在感觉有点不舒服。不过,至少不是什么剧烈的痛苦那种不舒服。
-
如果他开始找工作,他很快就能找到工作。他有很大的潜力和才能。
-
我找到另一份工作了!那份在医院的工作,做数据分析之类的,星期一开始!不确定我的论文什么时候完成。
|
-
恭喜你获得 500 元 CD 优惠券或 125 元礼品,保证免费参加 100 次每周抽奖,发送 MUSIC 到 87066。
-
仅限 12 月!你的手机已使用超过 11 个月了吗?你有权免费更新为最新的彩色相机手机!拨打 08002986906 免费联系移动更新公司。
-
情人节特别活动!在我们的问答比赛中赢取超过 1000 英镑,并带上你的伴侣一起去体验一生一次的旅行!现在发送 GO 到 83600。每条信息收费 150 便士。
|
查看之前的消息时,你是否注意到垃圾信息的一些显著特点?一个显著特点是三条垃圾信息中有两条使用了“free”这个词,而在任何一条正常信息中都没有出现这个词。另一方面,两条正常信息引用了具体的星期几,而垃圾信息中没有一条这样做。
我们的朴素贝叶斯分类器将利用单词频率中的这些模式来判断短信是否更符合垃圾信息或正常信息的特征。虽然不能排除“free”这个词出现在正常短信中的可能性,但合法信息通常会提供更多解释上下文的单词。例如,正常信息可能会写道:“你星期天有空吗?”而垃圾信息可能会使用“免费铃声”这个词组。分类器将根据消息中的所有单词所提供的证据计算垃圾信息和正常信息的概率。
步骤 2——探索与准备数据
构建分类器的第一步是处理原始数据以供分析。文本数据的准备非常具有挑战性,因为需要将单词和句子转化为计算机能够理解的形式。我们将把数据转化为一种称为词袋模型的表示方法,该方法忽略单词顺序,仅提供一个变量,表示该单词是否出现过。
提示
这里使用的数据已从原始数据中稍作修改,以便在 R 中更容易处理。如果你打算跟随本示例进行操作,请从 Packt 网站下载 sms_spam.csv
文件,并将其保存在你的 R 工作目录中。
我们将首先导入 CSV 数据并将其保存在数据框中:
> sms_raw <- read.csv("sms_spam.csv", stringsAsFactors = FALSE)
使用 str()
函数,我们看到 sms_raw
数据框包含了 5,559 条短信,总共有两个特征: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%)短信被标记为垃圾短信,而其他的则被标记为正常短信:
> 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
目前,我们将暂时不处理短信文本。正如你将在下一节中学到的那样,处理原始短信消息将需要使用一套专门设计用于处理文本数据的新工具。
数据准备——清洗和标准化文本数据
短信消息是由单词、空格、数字和标点符号组成的文本字符串。处理这种复杂数据需要大量的思考和努力。需要考虑如何去除数字和标点符号;处理如 and、but 和 or 等无关紧要的词;以及如何将句子拆解成单独的词。幸运的是,这一功能已经由 R 社区的成员通过一个名为tm
的文本挖掘包提供。
注意
tm
包最初是由 Ingo Feinerer 在维也纳经济与商业大学作为论文项目创建的。欲了解更多信息,请参见:Feinerer I, Hornik K, Meyer D. 《R 中的文本挖掘基础设施》.统计软件杂志。2008;25:1-54。
可以通过 install.packages("tm")
命令安装 tm
包,并通过 library(tm)
命令加载它。即使你已经安装了该包,也值得重新运行安装过程,以确保你的版本是最新的,因为 tm
包仍在积极开发中。这有时会导致其功能发生变化。
提示
本章是使用 tm 版本 0.6-2 编写和测试的,该版本在 2015 年 7 月时是最新的。如果你发现输出不同或代码无法运行,可能是你使用的是不同的版本。此书的 Packt Publishing 支持页面会发布未来 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"
要查看多个文档,我们需要对sms_corpus
对象中的多个项使用as.character()
。为此,我们将使用lapply()
函数,它是 R 函数家族的一部分,能够对 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 消息中的数字。尽管某些数字可能提供有用信息,但大多数数字可能是特定于发送者的,因此无法在所有消息中提供有用的模式。考虑到这一点,我们将按照以下方式从语料库中删除所有数字:
> sms_corpus_clean <- tm_map(sms_corpus_clean, removeNumbers)
提示
请注意,前面的代码没有使用content_transformer()
函数。这是因为removeNumbers()
是tm
内置的,并且还有其他一些不需要包装的映射函数。要查看其他内置转换,只需输入getTransformations()
。
我们的下一个任务是从 SMS 消息中删除填充词,如to、and、but和or。这些词被称为停用词,通常在文本挖掘之前被移除。这是因为,尽管它们出现频繁,但对机器学习来说并未提供太多有用信息。
我们不打算自己定义停用词列表,而是使用tm
包提供的stopwords()
函数。该函数允许我们访问多个语言的停用词集合。默认情况下,使用的是常见的英语停用词。要查看默认列表,请在命令行输入stopwords()
。要查看其他语言和可用选项,请输入?stopwords
以访问文档页面。
提示
即使在同一种语言中,也没有一个统一的停用词列表。例如,tm
中的默认英文停用词列表包含约 174 个单词,而另一个选项包含 571 个单词。如果需要,您甚至可以指定自己的停用词列表。无论选择哪个列表,都请记住这次转换的目标,即在尽可能保留有用信息的同时,消除所有无用数据。
停用词本身并不是一个有用的转换。我们需要的是一种方法,能够移除停用词列表中出现的任何单词。解决方案在于removeWords()
函数,这是tm
包中包含的一个转换功能。正如我们之前所做的那样,我们将使用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
中的任何标点符号字符替换为空格。然后,可以像其他转换一样,使用replacePunctuation()
函数与tm_map()
一起使用。
另一个常见的文本数据标准化方法是通过一个叫做词干提取的过程,将单词简化为其词根形式。词干提取过程将像learned、learning和learns这样的单词去除后缀,转化为基础形式learn。这使得机器学习算法能够将相关术语视为单一概念,而不是尝试为每个变体学习一个模式。
tm
包通过与SnowballC
包的集成提供词干提取功能。在撰写本文时,SnowballC
并未默认安装在tm
中。如果尚未安装,可以使用install.packages("SnowballC")
进行安装。
注意
SnowballC
包由 Milan Bouchet-Valat 维护,并提供了一个 R 接口,连接到基于 C 的libstemmer
库,后者基于 M.F. Porter 的“Snowball”词干提取算法,这是一个广泛使用的开源词干提取方法。更多细节,见snowball.tartarus.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
包。如果在安装包后仍然遇到“所有计划的核心都遇到错误”的消息,你还可以尝试强制将tm_map()
命令限制为单一核心,通过添加一个额外参数mc.cores=1
来指定。
在移除数字、停用词和标点符号并进行词干提取之后,文本消息留下了原先分隔这些已消失部分的空格。我们文本清理过程的最后一步是去除额外的空白字符,使用内置的stripWhitespace()
转换:
> sms_corpus_clean <- tm_map(sms_corpus_clean, stripWhitespace)
以下表格展示了 SMS 语料库中前 3 条消息在清理过程前后的样子。消息已经缩减为最有趣的单词,标点符号和大小写已被移除:
清理前的 SMS 消息 | 清理后的 SMS 消息 |
---|
|
> 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
|
数据准备 – 将文本文件拆分为单词
现在数据已经按照我们的需求处理完成,最后一步是通过一个叫做分词(tokenization)的过程将消息拆分成单独的组件。一个词元(token)是文本字符串的一个单独元素;在这里,词元指的是单词。
正如你可能猜到的,tm
包提供了将 SMS 消息语料库进行分词的功能。DocumentTermMatrix()
函数将处理一个语料库并创建一个叫做文档-词矩阵(Document Term Matrix,DTM)的数据结构,其中行表示文档(SMS 消息),列表示术语(单词)。
提示
tm
包还提供了一种叫做词-文档矩阵(Term Document Matrix,TDM)的数据结构,它仅仅是 DTM 的转置,其中行是词语,列是文档。为什么需要两者?有时,使用其中一个更方便。例如,如果文档的数量较少,而词汇表很大,使用 TDM 可能更合适,因为显示许多行通常比显示许多列更为容易。话虽如此,二者常常是可以互换的。
矩阵中的每个单元格存储一个数字,表示由列所代表的单词在由行所代表的文档中出现的次数。以下插图仅展示了 SMS 语料库 DTM 的一个小部分,完整的矩阵有 5,559 行和超过 7,000 列:
表中每个单元格为零,意味着列顶部列出的单词在语料库中的前五条消息中都没有出现。这突显了为什么这种数据结构被称为稀疏矩阵的原因;矩阵中的绝大多数单元格都填充的是零。用现实世界的术语来说,尽管每条消息必须至少包含一个单词,但某个单词出现在给定消息中的概率是很小的。
给定一个tm
语料库,创建一个 DTM 稀疏矩阵只需要一条命令:
> sms_dtm <- DocumentTermMatrix(sms_corpus_clean)
这将创建一个包含使用默认设置进行标记化语料库的 sms_dtm
对象,默认设置应用最小处理。默认设置是合适的,因为我们已经手动准备了语料库。
另一方面,如果我们没有执行预处理操作,可以在此通过提供 control
参数选项来覆盖默认设置。比如,直接从原始未处理的 SMS 语料库创建 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: 6518)>>
Non-/sparse entries: 42113/36191449
Sparsity : 100%
Maximal term length: 40
Weighting : term frequency (tf)
> sms_dtm2
<<DocumentTermMatrix (documents: 5559, terms: 6909)>>
Non-/sparse entries: 43192/38363939
Sparsity : 100%
Maximal term length: 40
Weighting : term frequency (tf)
这种差异的原因与预处理步骤的顺序微小差异有关。 DocumentTermMatrix()
函数仅在文本字符串被拆分成单词后才会应用其清理功能。因此,它使用略有不同的停用词移除函数。因此,一些单词的拆分方式与它们在标记化之前清理时有所不同。
提示
为了强制将两个先前的文档术语矩阵保持一致,我们可以用自己的函数覆盖默认的停用词功能,该函数使用原始的替换功能。只需将stopwords = TRUE
替换为以下内容:
stopwords = function(x) { removeWords(x, stopwords()) }
这两种情况的差异说明了清理文本数据时一个重要的原则:操作的顺序很重要。考虑到这一点,仔细思考处理过程中的早期步骤如何影响后续步骤是非常重要的。这里提供的顺序在许多情况下有效,但当该过程针对特定数据集和使用案例进行更加精细的定制时,可能需要重新思考。例如,如果有某些术语是你希望从矩阵中排除的,考虑是否应该在词干提取之前或之后搜索它们。此外,还要考虑如何去除标点符号——以及标点符号是被消除还是被空格替代——对这些步骤的影响。
数据准备——创建训练集和测试集
在数据准备好进行分析后,我们现在需要将数据分成训练集和测试集,以便在构建完垃圾邮件分类器后,可以在未曾见过的数据上进行评估。但即便如此,我们需要确保在数据清洗和处理之后再进行拆分;我们需要在训练集和测试集上进行相同的准备步骤。
我们将把数据分成两部分:75%用于训练,25%用于测试。由于短信是随机排序的,我们可以简单地取前 4,169 条用于训练,将剩下的 1,390 条用于测试。幸运的是,DTM 对象非常像数据框,可以使用标准的[row, col]
操作进行拆分。由于我们的 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
参数来缩小字体大小也可能有所帮助。
结果的词云应类似于下图所示:
另一个可能更有趣的可视化是比较短信垃圾信息(spam)和正常信息(ham)的词云。由于我们没有为垃圾信息和正常信息构建单独的语料库,这是一个非常适合说明wordcloud()
函数的有用功能的时机。给定一个原始文本字符串的向量,它会在显示词云之前自动应用常见的文本预处理过程。
让我们使用 R 的subset()
函数通过短信type
来获取 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))
结果的词云显示在下图中:
你能猜到哪个是垃圾信息词云,哪个是正常信息词云吗?
提示
由于随机化过程,每个词云可能看起来略有不同。运行wordcloud()
函数几次可以让你选择一个在展示中最具视觉吸引力的词云。
如你所猜测的,垃圾短信云在左边。垃圾短信中包含如urgent(紧急)、free(免费)、mobile(手机)、claim(索赔)和stop(停止)等词汇;这些词汇在正常短信云中完全没有出现。相反,正常短信使用如can(可以)、sorry(抱歉)、need(需要)和time(时间)等词汇。这些显著的差异表明,我们的朴素贝叶斯模型将拥有一些强有力的关键词来区分这两类信息。
数据准备——为频繁出现的词汇创建指示特征
数据准备过程的最后一步是将稀疏矩阵转换为一个可以用来训练朴素贝叶斯分类器的数据结构。目前,稀疏矩阵包含超过 6,500 个特征;每个特征对应一个至少在一条短信中出现的词汇。这些特征不一定都是有用的,因此为了减少特征的数量,我们将剔除任何在少于五条短信中出现的词汇,或者出现在训练数据中不到 0.1%的记录中的词汇。
查找频繁词汇需要使用findFreqTerms()
函数,该函数位于tm
包中。该函数接受一个文档词项矩阵(DTM)并返回一个字符向量,包含在至少指定次数中出现的词汇。例如,以下命令将显示在sms_dtm_train
矩阵中至少出现五次的词汇:
> findFreqTerms(sms_dtm_train, 5)
该函数的结果是一个字符向量,因此让我们将频繁出现的词保存以供后续使用:
> sms_freq_words <- findFreqTerms(sms_dtm_train, 5)
向量内容的预览显示,我们发现有 1,136 个术语在至少五条短信中出现:
> str(sms_freq_words)
chr [1:1136] "abiola" "abl" "abt" "accept" "access" "account" "across" "act" "activ" ...
我们现在需要过滤我们的 DTM,只保留在指定向量中出现的术语。与之前一样,我们将使用数据框风格的[row, col]
操作来请求 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,136 个特征,这些特征对应于在至少五条短信中出现的词汇。
朴素贝叶斯分类器通常在具有类别特征的数据上进行训练。这就带来了一个问题,因为稀疏矩阵中的单元格是数字型的,表示一个词在消息中出现的次数。我们需要将其转换为一个类别变量,简单地根据该词是否出现来指示“是”或“否”。
以下定义了一个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 – 在数据上训练模型
现在我们已经将原始的短信消息转化为可以通过统计模型表示的格式,是时候应用朴素贝叶斯算法了。该算法将利用单词的出现与否来估算给定短信是垃圾邮件的概率。
我们将使用的朴素贝叶斯实现位于e1071
包中。这个包是由维也纳工业大学(TU Wien)统计学系开发的,包含了多种机器学习函数。如果你还没有安装该包,请确保在继续之前使用install.packages("e1071")
和library(e1071)
命令安装并加载该包。
提示
许多机器学习方法在多个 R 包中都有实现,朴素贝叶斯也不例外。另一个选择是klaR
包中的NaiveBayes()
函数,它与e1071
包中的函数几乎相同。你可以随意选择你喜欢的选项。
与我们在上一章中使用的 k-NN 算法不同,朴素贝叶斯学习器是分阶段进行训练和分类的。尽管如此,正如下表所示,这些步骤相对简单:
为了在sms_train
矩阵上构建我们的模型,我们将使用以下命令:
> sms_classifier <- naiveBayes(sms_train, sms_train_labels)
sms_classifier
对象现在包含一个naiveBayes
分类器对象,可以用来进行预测。
步骤 4 – 评估模型性能
为了评估短信分类器,我们需要在测试数据中的未见过的消息上测试其预测结果。回想一下,未见消息的特征存储在一个名为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.t = FALSE,
dnn = c('predicted', 'actual'))
这产生了以下表格:
看着表格,我们可以看到总共有6 + 30 = 36条短信被错误分类(占 2.6%)。这些错误包括 1,207 条正常短信中有 6 条被误分类为垃圾邮件,以及 183 条垃圾邮件中有 30 条被错误标记为正常邮件。考虑到我们在项目中投入的精力不多,这个性能水平似乎相当令人印象深刻。这个案例研究展示了朴素贝叶斯成为文本分类标准的原因;直接使用时,它的表现出乎意料地好。
另一方面,那些被错误分类为垃圾邮件的六条合法信息可能会对我们的过滤算法部署造成重大问题,因为过滤器可能会导致某个人错过一条重要的短信。我们应该调查一下是否可以稍微调整模型以提高性能。
步骤 5 – 改进模型性能
你可能已经注意到,在训练模型时,我们没有为拉普拉斯估计器设置值。这使得在零垃圾邮件或零正常邮件中出现的词汇在分类过程中拥有不可争辩的发言权。仅仅因为“铃声”这个词只出现在训练数据的垃圾邮件中,并不意味着包含该词的每条信息都应该被分类为垃圾邮件。
我们将像之前一样构建一个朴素贝叶斯模型,但这次设置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.t = FALSE, prop.r = FALSE,
dnn = c('predicted', 'actual'))
这导致了以下表格:
添加拉普拉斯估计器将误报(正常邮件错误地分类为垃圾邮件)的数量从六个减少到了五个,将漏报的数量从 30 个减少到了 28 个。虽然这看起来是一个小变化,但考虑到模型的准确性已经相当令人印象深刻,这个变化还是相当显著的。在过度调整模型之前,我们需要小心,以确保在过滤垃圾邮件时保持过于激进和过于被动之间的平衡。用户更愿意接受少数垃圾邮件漏过过滤器,而不是正常邮件被过度过滤的情况。
总结
在本章中,我们了解了使用朴素贝叶斯进行分类。这种算法构建了概率表,用于估算新样本属于不同类别的可能性。这些概率通过一个被称为贝叶斯定理的公式来计算,该定理说明了事件之间的依赖关系。尽管贝叶斯定理计算开销较大,但其简化版在做出所谓的“朴素”假设,即特征独立性假设的基础上,能够处理极其庞大的数据集。
朴素贝叶斯分类器通常用于文本分类。为了说明其有效性,我们将朴素贝叶斯应用于一个涉及垃圾短信分类的任务。在进行文本数据分析时,我们使用了专门的 R 包进行文本处理和可视化。最终,该模型能够正确分类超过 97%的短信,准确地识别出垃圾短信和正常短信。
在下一章中,我们将介绍两种机器学习方法。每种方法通过将数据分割成相似值的组来执行分类。
第五章. 分治法 – 使用决策树和规则进行分类
在决定是否接受多个薪资和福利不同的工作邀请时,许多人通常通过列出利弊清单,并根据简单的规则排除选项。例如,“如果我需要通勤超过一个小时,我会感到不高兴。”或者,“如果我的收入低于 50k 美元,我将无法养活我的家人。”通过这种方式,预测未来幸福的复杂且艰难的决策可以简化为一系列简单的决策。
本章讲解了决策树和规则学习器——这两种机器学习方法也从一系列简单的选择中做出复杂决策。这些方法然后以逻辑结构的形式呈现其知识,且无需统计学知识也能理解。这一特点使得这些模型在商业战略和流程改进方面特别有用。
在本章结束时,你将学到:
-
树和规则如何“贪婪地”将数据划分为有趣的片段
-
最常见的决策树和分类规则学习器,包括 C5.0、1R 和 RIPPER 算法
-
如何使用这些算法执行现实世界中的分类任务,例如识别风险较高的银行贷款和有毒蘑菇
我们将从研究决策树开始,随后探讨分类规则。然后,我们将通过预览后面的章节来总结所学内容,这些章节讨论了将树和规则作为基础的更先进的机器学习技术。
理解决策树
决策树学习器是强大的分类器,它们利用树结构来建模特征与潜在结果之间的关系。正如下图所示,这种结构得名于其形状类似于字面意义上的树木,它从粗大的树干开始,向上延伸时分成越来越细的枝条。决策树分类器以相同的方式使用分支决策的结构,将示例引导到最终的预测类别值。
为了更好地理解其实际应用,假设我们考虑以下这棵树,它预测一个工作邀请是否应该被接受。一个待考虑的工作邀请从根节点开始,然后通过决策节点传递,根据工作的属性做出选择。这些选择将数据分割到分支上,指示决策的潜在结果,这里表现为“是”或“否”的结果,尽管在某些情况下可能有多个可能性。如果可以做出最终决定,树将通过叶节点(也叫终端节点)终止,叶节点表示一系列决策的结果应该采取的行动。在预测模型中,叶节点提供给定树中一系列事件后的预期结果。
决策树算法的一个巨大优势是,其流程图式的树结构并不一定仅供学习者内部使用。在模型创建之后,许多决策树算法会以人类可读的格式输出生成的结构。这为我们提供了对模型如何以及为何在某项任务中工作或不工作的深刻理解。这也使得决策树特别适用于那些需要因法律原因或为了向他人分享结果以指导未来业务实践而使分类机制透明的应用场景。考虑到这一点,一些潜在的应用包括:
-
信贷评分模型,其中导致申请人被拒绝的标准需要明确记录,并且必须排除偏见。
-
关于客户行为的营销研究,如满意度或流失率,这些研究将与管理层或广告公司共享。
-
基于实验室测量、症状或疾病进展速度的医疗状况诊断。
尽管前述应用展示了树在决策过程中提供价值,但这并不意味着它们的效用就此结束。事实上,决策树可能是最广泛使用的机器学习技术之一,可以应用于几乎任何类型的数据——通常可以获得出色的开箱即用的应用效果。
尽管决策树有广泛的应用,但值得注意的是,在某些情况下,树可能不是理想选择。一个这样的例子是当数据包含大量具有多个级别的名义特征,或者数据包含大量数值特征时。这些情况可能导致决策数量非常庞大,并且树结构过于复杂。它们还可能导致决策树出现过拟合数据的倾向,尽管正如我们很快会看到的,甚至这种弱点也可以通过调整一些简单的参数来克服。
分治法
决策树是使用一种名为递归划分的启发式方法构建的。这种方法通常也被称为分治法,因为它将数据划分成子集,然后将这些子集反复划分成更小的子集,依此类推,直到算法判断子集内的数据足够同质化,或满足其他停止准则为止。
要了解如何将数据集拆分以创建决策树,可以想象一个裸根节点,它将成长为一棵成熟的树。最初,根节点代表整个数据集,因为此时尚未进行拆分。接下来,决策树算法必须选择一个特征来进行拆分;理想情况下,它选择的是最能预测目标类别的特征。然后,根据该特征的不同值,示例数据被分成多个组,树的第一个分支就此形成。
沿着每个分支往下,算法继续分治数据,每次选择最合适的特征来创建另一个决策节点,直到达到停止标准。分治法可能会在某个节点停止,情形如下:
-
节点处的所有(或几乎所有)示例都属于同一类
-
没有剩余的特征可以用来区分各个示例
-
该树已经增长到预设的大小限制
为了说明树形结构的构建过程,让我们考虑一个简单的例子。假设你在一家好莱坞电影公司工作,负责决定公司是否应该继续制作那些有潜力的新人作家所提交的电影剧本。度假回来后,你的办公桌上堆满了提案。由于没有时间逐一阅读每份提案,你决定开发一个决策树算法,用来预测一部潜在电影是否会落入以下三类之一:关键成功、主流热片或票房失败。
为了构建决策树,你查阅了公司档案,分析了影响公司最近发布的 30 部电影成功与失败的因素。你很快注意到,电影的预估拍摄预算、一线明星的出演数量和电影的成功程度之间存在某种关系。对这一发现感到兴奋,你制作了一个散点图来展示这种模式:
使用分治策略,我们可以从这些数据中构建一个简单的决策树。首先,为了创建树的根节点,我们根据明星数量这一特征进行划分,将电影分为有无大量一线明星的两组:
接下来,在具有较多明星的电影组中,我们可以再次进行划分,区分有无高预算的电影:
到这个阶段,我们已经将数据划分为三组。图表左上角的组完全由获得好评的电影组成。这个组的特点是有大量的明星出演,且预算相对较低。在右上角,绝大多数电影都是票房热片,具有高预算和大量的明星阵容。最后一个组虽然没有太多明星,但预算从小到大不等,包含了票房失败的电影。
如果我们愿意,我们可以继续通过基于越来越具体的预算和名人数量范围来划分数据,直到每个当前错误分类的值都位于自己的小分区中,并且被正确分类。然而,不建议以这种方式过度拟合决策树。虽然没有什么可以阻止我们无限制地划分数据,但过于具体的决策并不总是能够更广泛地泛化。我们将通过在此停止算法来避免过拟合的问题,因为每个组中超过 80%的示例都来自同一类。这构成了我们停止标准的基础。
提示
你可能已经注意到,斜线可能会更加干净地划分数据。这是决策树知识表示的一个局限性,它使用的是轴对齐划分。每次划分只考虑一个特征,这使得决策树无法形成更复杂的决策边界。例如,可以通过一个决策来创建一条斜线,询问:“名人数量是否大于预估预算?”如果是,那么“它将是一个关键性的成功”。
我们用于预测电影未来成功的模型可以用一个简单的树表示,如下图所示。为了评估剧本,按照每个决策的分支,直到预测出剧本的成功或失败。很快,你将能够从一堆积压的剧本中识别出最有前途的选项,然后回到更重要的工作,如写奥斯卡颁奖典礼的获奖感言。
由于现实世界的数据包含超过两个特征,决策树很快就会变得比这复杂得多,包含更多的节点、分支和叶子。在接下来的部分中,你将学习一种流行的算法,它可以自动构建决策树模型。
C5.0 决策树算法
决策树有很多实现方式,但最著名的一种实现是C5.0 算法。该算法由计算机科学家 J. Ross Quinlan 开发,是他之前算法C4.5的改进版,而C4.5本身则是他迭代二分法 3(ID3)算法的改进。尽管 Quinlan 将 C5.0 推向商业客户(详情见www.rulequest.com/
),但该算法的单线程版本的源代码已经公开,因此被像 R 这样的程序所采用。
注意
更令人困惑的是,一个流行的基于 Java 的开源替代方案J48,它是 C4.5 的替代品,已包含在 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)
这会产生如下图所示的结果:
正如在x = 0.50时熵的峰值所示,50-50 的划分会导致最大熵。当一个类别逐渐主导另一个类别时,熵会减少到零。
为了使用熵来确定最佳划分特征,算法会计算在每个可能的特征上进行划分后同质性变化的量,这个度量被称为信息增益。特征F的信息增益是通过计算划分前的片段熵(S[1])与划分后分区的熵(S[2])之间的差值来得到的:
一个复杂的地方是,划分后数据被分成了多个分区。因此,计算Entropy(S[2])的函数需要考虑所有分区的总熵。它通过根据每个分区中记录所占比例来加权每个分区的熵。可以用以下公式来表示:
简单来说,划分后的总熵是每个n个分区的熵之和,加权每个分区的示例比例(w[i])。
信息增益越高,特征在此特征上进行划分后,生成同质群体的效果越好。如果信息增益为零,则说明在该特征上进行划分不会减少熵。另一方面,最大信息增益等于划分前的熵。这意味着划分后的熵为零,表示该划分结果形成了完全同质的群体。
之前的公式假设了名义特征,但决策树也使用信息增益对数值特征进行分裂。为此,一个常见的做法是测试不同的分裂方法,将值划分为大于或小于某个数值阈值的组。这将数值特征转换为一个二级类别特征,从而可以像往常一样计算信息增益。选择具有最大信息增益的数值切分点进行分裂。
注意
尽管 C5.0 使用了信息增益,但信息增益并不是构建决策树时可以使用的唯一分裂准则。其他常用的准则包括基尼指数、卡方统计量和增益比。有关这些(以及更多)准则的回顾,请参考 Mingers J. 决策树归纳的选择度量的实证比较。机器学习。1989; 3:319-342。
剪枝决策树
决策树可以无限地生长,选择分裂特征并将数据分割成越来越小的部分,直到每个示例都被完美分类,或者算法无法再找到特征进行分裂。然而,如果树过度生长,许多决策将变得过于具体,模型将过拟合训练数据。剪枝决策树的过程涉及缩小树的大小,以便它能够更好地泛化到未见过的数据。
解决此问题的一个方法是,当树达到一定的决策数量或决策节点仅包含少量示例时,停止树的生长。这被称为早期停止或预剪枝决策树。由于树避免了不必要的工作,这是一个有吸引力的策略。然而,这种方法的一个缺点是,无法知道树是否会错过那些微妙但重要的模式,这些模式如果树生长到更大规模时可能会学习到。
另一种方法,称为后剪枝,包括先生长一棵故意过大的树,并通过剪枝叶子节点将树的大小减少到一个更合适的水平。这通常比预剪枝更有效,因为在没有先生长树的情况下很难确定决策树的最优深度。稍后对树进行剪枝可以确保算法发现了所有重要的数据结构。
注意
剪枝操作的实现细节非常技术性,超出了本书的范围。如需了解一些可用方法的比较,请参阅 Esposito F, Malerba D, Semeraro G. 决策树剪枝方法的比较分析。IEEE 模式分析与机器智能学报。1997;19: 476-491。
C5.0 算法的一个优点是它在修剪过程中有明确的方向——它会使用相当合理的默认设置自动做出许多决策。其整体策略是后期修剪树形结构。它首先生成一个过拟合训练数据的大树,然后删除那些对分类错误影响较小的节点和分支。在某些情况下,整个分支会被移动到树的更高位置,或被更简单的决策所替代。这些移植分支的过程分别被称为子树提升和子树替换。
平衡决策树的过拟合与欠拟合是一项艺术,但如果模型准确性至关重要,花时间调整不同的修剪选项,看看是否能提高测试数据的表现,是值得投入的。正如你将很快看到的,C5.0 算法的一个优点是它非常容易调整训练选项。
示例 – 使用 C5.0 决策树识别高风险银行贷款
2007-2008 年的全球金融危机突显了银行业务中透明度和严格性的重要性。由于信贷供应受到限制,银行收紧了贷款系统,并转向机器学习,以更准确地识别高风险贷款。
由于决策树具有高准确性和用通俗语言制定统计模型的能力,因此在银行业得到了广泛应用。由于许多国家的政府组织严格监控贷款实践,银行高层必须能够解释为什么一个申请人被拒绝贷款,而其他申请人却被批准。这些信息对于希望了解为什么自己的信用评级不合格的客户也非常有用。
自动化信用评分模型可能被用来在电话和网络上即时批准信用申请。在本节中,我们将使用 C5.0 决策树开发一个简单的信用批准模型。我们还将看到如何调整模型结果,以最小化可能导致机构经济损失的错误。
步骤 1 – 收集数据
我们的信用模型背后的理念是识别那些能够预测较高违约风险的因素。因此,我们需要获取大量过去银行贷款的数据,了解这些贷款是否发生了违约,以及有关申请人的信息。
具有这些特征的数据可以在由汉斯·霍夫曼(Hans Hofmann)捐赠给 UCI 机器学习数据仓库的一个数据集中找到 (archive.ics.uci.edu/ml
)。该数据集包含来自德国一家信用机构的贷款信息。
提示
本章中展示的数据集与原始数据集略有修改,目的是消除一些预处理步骤。为了跟随示例操作,请从 Packt Publishing 的网站下载 credit.csv
文件并将其保存到你的 R 工作目录中。
信用数据集包括 1,000 个贷款实例,另外还有一组数值型和名义型特征,表示贷款和贷款申请者的特点。一个类别变量表示贷款是否违约。让我们看看是否能发现一些预测这一结果的模式。
步骤 2 – 探索和准备数据
正如我们之前所做的那样,我们将使用read.csv()
函数导入数据。我们将忽略stringsAsFactors
选项,因此使用默认值TRUE
,因为数据中的大多数特征都是名义型的:
> credit <- read.csv("credit.csv")
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 ...
$ credit_history : Factor w/ 5 levels "critical","good",..
$ purpose : Factor w/ 6 levels "business","car",..
$ amount : int 1169 5951 2096 ...
我们看到期望的 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 18420
贷款金额从 250 DM 到 18,420 DM 不等,期限从 4 个月到 72 个月不等,贷款的中位数期限为 18 个月,金额为 2,320 DM。
default
向量表示贷款申请者是否未能按约定的付款条款履约并进入违约。该数据集中有 30%的贷款进入了违约状态:
> table(credit$default)
no yes
700 300
高违约率对银行来说是不利的,因为这意味着银行不太可能完全收回其投资。如果我们成功了,我们的模型将能够识别出高违约风险的申请者,从而允许银行拒绝这些申请的信用请求。
数据准备 – 创建随机的训练和测试数据集
正如我们在前面的章节中所做的那样,我们将把数据分成两部分:一个用于构建决策树的训练数据集和一个用于评估模型在新数据上表现的测试数据集。我们将使用 90%的数据用于训练,10%的数据用于测试,这将为我们提供 100 条记录来模拟新申请者。
与前几章使用的数据是按随机顺序排序的不同,我们简单地将数据集分为两部分,取前 90%的记录用于训练,剩余的 10%用于测试。相比之下,信用数据集没有经过随机排序,因此采用之前的方法是不明智的。假设银行按贷款金额排序数据,最大贷款位于文件末尾。如果我们使用前 90%的数据用于训练,剩余 10%用于测试,我们将仅在小额贷款上训练模型,而在大额贷款上测试模型。显然,这可能会造成问题。
我们将通过使用信用数据的随机样本来解决这个问题。随机样本仅仅是一个随机选择记录子集的过程。在 R 中,sample()
函数用于执行随机抽样。然而,在执行之前,一个常见的做法是设置种子值,这样可以确保随机化过程遵循一个可以在以后复制的序列。看起来这似乎违背了生成随机数的目的,但这样做是有原因的。通过set.seed()
函数提供种子值可以确保如果将来重复分析,可以得到相同的结果。
提示
你可能会想,怎么一个所谓的随机过程可以设置种子来产生相同的结果呢?这是因为计算机使用一种名为伪随机数生成器的数学函数来创建看似非常随机的随机数序列,但实际上只要知道序列中前一个值,它们是可以预测的。实际上,现代伪随机数序列与真正的随机序列几乎无法区分,但它们的优势在于计算机可以快速、轻松地生成这些序列。
以下命令使用sample()
函数从 1 到 1000 的整数序列中随机选择 900 个值。请注意,set.seed()
函数使用了一个任意值123
。如果省略这个种子值,你的训练和测试集划分将与本章其余部分所示的结果不同:
> set.seed(123)
> train_sample <- sample(1000, 900)
正如预期的那样,生成的train_sample
对象是一个包含 900 个随机整数的向量:
> str(train_sample)
int [1:900] 288 788 409 881 937 46 525 887 548 453 ...
通过使用这个向量从信用数据中选择行,我们可以将其分为我们所需的 90%的训练数据集和 10%的测试数据集。请记住,在选择测试记录时使用的破折号运算符告诉 R 选择那些不在指定行中的记录;换句话说,测试数据仅包含那些不在训练样本中的行。
> credit_train <- credit[train_sample, ]
> credit_test <- credit[-train_sample, ]
如果一切顺利,我们应该在每个数据集中都有大约 30%的违约贷款:
> prop.table(table(credit_train$default))
no yes
0.7033333 0.2966667
> prop.table(table(credit_test$default))
no yes
0.67 0.33
这似乎是一个相当均匀的划分,所以我们现在可以构建我们的决策树。
提示
如果你的结果不完全匹配,请确保在创建train_sample
向量之前立即运行了命令set.seed(123)
。
第 3 步 – 在数据上训练模型
我们将使用C50
包中的 C5.0 算法来训练我们的决策树模型。如果你还没有安装该包,可以通过install.packages("C50")
来安装,并使用library(C50)
将其加载到 R 会话中。
以下语法框列出了构建决策树时最常用的一些命令。与我们之前使用的机器学习方法相比,C5.0 算法提供了更多定制模型以适应特定学习问题的方式,但也提供了更多选项。一旦加载了C50
包,?C5.0Control
命令将显示帮助页面,以获得有关如何精细调整算法的更多细节。
对于我们信用审批模型的第一次迭代,我们将使用默认的 C5.0 配置,如下所示的代码。credit_train
的第 17 列是default
类别变量,因此我们需要将其从训练数据框中排除,但作为分类的目标因子向量提供:
> credit_model <- C5.0(credit_train[-17], credit_train$default)
credit_model
对象现在包含一个 C5.0 决策树。我们可以通过输入它的名称来查看树的一些基本数据:
> credit_model
Call:
C5.0.default(x = credit_train[-17], y = credit_train$default)
Classification Tree
Number of samples: 900
Number of predictors: 16
Tree size: 57
Non-standard options: attempt to group attributes
上述文本显示了有关决策树的一些基本事实,包括生成该树的函数调用、特征数量(标记为predictors
)和用于生成树的示例(标记为samples
)。还列出了树的大小为 57,表示该树有 57 个决策层级——比我们之前考虑的示例树要大得多!
要查看树的决策,我们可以在模型上调用summary()
函数:
> summary(credit_model)
这将产生以下输出:
上述输出显示了决策树中的一些初步分支。前三行可以用简单的语言表示为:
-
如果支票账户余额未知或大于 200 德国马克,则分类为“ unlikely to default”(不太可能违约)。
-
否则,如果支票账户余额小于零德国马克或在 1 至 200 德国马克之间。
-
如果信用历史完美或非常好,则分类为“ likely to default”(可能违约)。
括号中的数字表示满足该决策标准的示例数量,以及被该决策错误分类的数量。例如,在第一行,412/50
表示在达到该决策的 412 个示例中,50 个被错误分类为不太可能违约。换句话说,尽管模型预测相反,实际上有 50 个申请人违约了。
提示
有时,决策树会产生一些逻辑上没有意义的决策。例如,为什么信用历史非常好的申请人可能会违约,而支票账户余额未知的申请人不太可能违约?像这样的矛盾规则有时会出现。它们可能反映了数据中的真实模式,或者可能是统计异常。无论是哪种情况,调查这些奇怪的决策,看看树的逻辑是否适用于业务使用,都是很重要的。
在决策树之后,summary(credit_model)
的输出显示了一个混淆矩阵,这是一个交叉表,表示模型在训练数据中错误分类的记录:
Evaluation on training data (900 cases):
Decision Tree
----------------
Size Errors
56 133(14.8%) <<
(a) (b) <-classified as
---- ----
598 35 (a): class no
98 169 (b): class yes
错误输出指出,模型正确分类了 900 个训练实例中除了 133 个实例之外的所有实例,错误率为 14.8%。总共有 35 个实际的"no"被错误分类为"yes"(假阳性),而 98 个"yes"被错误分类为"no"(假阴性)。
决策树因其容易将模型过度拟合训练数据而闻名。因此,报告的训练数据错误率可能过于乐观,特别重要的是要在测试数据集上评估决策树。
第四步 – 评估模型性能
为了将我们的决策树应用于测试数据集,我们使用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'))
这导致了以下表格:
在 100 个测试贷款申请记录中,我们的模型正确预测了 59 个未违约和 14 个违约,准确率为 73%,错误率为 27%。这比其在训练数据上的表现稍差,但考虑到模型在未见数据上的表现通常较差,这并不意外。还要注意,模型只正确预测了测试数据中 33 个实际贷款违约中的 14 个,正确率为 42%。不幸的是,这种类型的错误可能是一个非常昂贵的错误,因为银行在每次违约时都会损失资金。让我们看看是否可以通过更多的努力来改进结果。
第五步 – 改进模型性能
我们模型的错误率可能太高,无法在实时信用评分应用中部署。事实上,如果模型对每个测试案例都预测为"无违约",它的正确率将是 67%,这与我们模型的结果差不多,但所需的努力要小得多!从 900 个样本中预测贷款违约似乎是一个具有挑战性的问题。
更糟糕的是,我们的模型在识别那些确实违约的申请人时表现尤其糟糕。幸运的是,有几种简单的方法可以调整 C5.0 算法,这可能有助于提高模型的整体性能,并改善那些更为昂贵的错误类型。
提高决策树的准确率
C5.0 算法通过增加自适应提升(adaptive boosting)改进了 C4.5 算法。这是一个过程,其中构建了多个决策树,并且这些树对每个实例的最佳类别进行投票。
注意
提升的理念主要基于 Rob Schapire 和 Yoav Freund 的研究。欲了解更多信息,请尝试在网上搜索他们的出版物或他们的近期教材《Boosting: Foundations and Algorithms》。MIT 出版社(2012 年)。
由于提升可以更广泛地应用于任何机器学习算法,它将在本书后续章节中详细介绍,第十一章,提高模型性能。现在,我们只需说提升基于这样一个概念:通过将多个表现较弱的学习者结合起来,可以创建一个比任何单独的学习者都强大的团队。每个模型都有独特的优缺点,它们在解决某些问题时可能表现得更好或更差。因此,使用多个具有互补优缺点的学习者的组合,可以显著提高分类器的准确性。
C5.0()
函数使得在我们的 C5.0 决策树中添加提升变得非常简单。我们只需要添加一个额外的trials
参数,指示要在提升团队中使用的单独决策树的数量。trials
参数设置了上限;如果算法识别到额外的试验似乎并没有改善准确性,它将停止添加树。我们将从 10 次试验开始,这是一个事实上的标准,研究表明这样可以将测试数据的错误率降低大约 25%:
> credit_boost10 <- C5.0(credit_train[-17], credit_train$default,
trials = 10)
在检查结果模型时,我们可以看到一些额外的线条被添加进来,表明了变化:
> credit_boost10
Number of boosting iterations: 10
Average tree size: 47.5
在这 10 次迭代中,我们的树的大小缩小了。如果您愿意,可以通过在命令提示符下键入summary(credit_boost10)
来查看所有 10 棵树。它还列出了模型在训练数据上的表现:
> summary(credit_boost10)
(a) (b) <-classified as
---- ----
629 4 (a): class no
30 237 (b): class yes
分类器在 900 个训练样本上犯了 34 个错误,错误率为 3.8%。这相比我们在添加提升前注意到的 13.9%的训练误差率有了相当大的改进!然而,是否能在测试数据上看到类似的改进仍然有待观察。让我们来看一下:
> 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'))
结果表格如下:
在这里,我们将总错误率从提升前的 27%降低到了提升后模型的 18%。这看起来并不是一个很大的增益,但实际上它比我们预期的 25%的降低要大。另一方面,模型在预测违约方面仍然表现不佳,只有20/33 = 61%的预测是正确的。没有看到更大改进的原因可能是我们相对较小的训练数据集,或者这可能只是一个非常难以解决的问题。
话虽如此,如果提升(boosting)可以如此轻松地添加,为什么不默认将其应用于每个决策树呢?原因有二。首先,如果建立一个决策树需要大量的计算时间,那么构建多个树可能在计算上是不可行的。其次,如果训练数据非常嘈杂,那么提升可能根本不会带来改进。不过,如果需要更高的准确度,尝试一下还是值得的。
使错误的成本比其他错误更高
向可能违约的申请人发放贷款可能是一个昂贵的错误。减少错误负样本数量的一种解决方案可能是拒绝更多边缘申请人,假设银行从高风险贷款中获得的利息远远不能弥补若贷款完全无法偿还时所遭受的巨额损失。
C5.0 算法允许我们为不同类型的错误分配惩罚,以避免决策树犯更多代价更高的错误。惩罚值被指定在成本矩阵中,矩阵定义了每个错误相对于其他预测的成本。
要开始构建成本矩阵,我们需要首先指定维度。由于预测值和实际值都可以取“是”或“否”两种值,我们需要描述一个 2 x 2 矩阵,使用两个向量的列表,每个向量包含两个值。同时,我们还将为矩阵的维度命名,以避免日后混淆:
> 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(credit_train[-17], credit_train$default,
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'))
这将生成以下混淆矩阵:
与我们提升后的模型相比,这个版本的错误更多:这里的错误率为 37%,而提升模型的错误率为 18%。然而,错误的类型却大不相同。之前的模型仅有 42%和 61%的违约案例被正确分类,而在这个模型中,79%的实际违约被预测为非违约。这种以增加假阳性为代价减少假阴性的权衡,若我们的成本估算准确,可能是可以接受的。
理解分类规则
分类规则以逻辑的 if-else 语句形式表示知识,赋予未标记示例一个类别。它们通过前件和后件来指定;这些形成一个假设,声明“如果发生这个,那么就会发生那个”。一个简单的规则可能是,“如果硬盘发出点击声,那么它即将发生故障。”前件包含某些特征值的组合,而后件则指定当规则条件满足时,应该分配的类别值。
规则学习者通常以类似于决策树学习者的方式使用。像决策树一样,它们可以用于生成未来行动的知识应用,如:
-
确定导致机械设备硬件故障的条件
-
描述群体的关键特征用于客户细分
-
寻找股票市场上股价大幅下跌或上涨之前的条件
另一方面,规则学习者在某些任务中相比树形结构提供了一些独特的优势。与必须通过一系列决策从上到下应用的树不同,规则是一种命题,可以像陈述事实一样被读取。此外,出于稍后会讨论的原因,规则学习者的结果可能比基于相同数据构建的决策树更简单、直接且更容易理解。
提示
正如你将在本章后面看到的,规则可以通过决策树生成。那么,为什么还要使用一个单独的规则学习算法呢?原因在于,决策树给任务带来了一些特定的偏差,而规则学习者通过直接识别规则来避免这些偏差。
规则学习者通常应用于特征主要或完全为名义型的数据问题。即使罕见事件仅在特征值之间的某种特定交互作用下发生,它们也能很好地识别这些罕见事件。
分而治之
分类规则学习算法利用一种叫做分而治之的启发式方法。这个过程包括识别一个覆盖训练数据子集的规则,然后将这个子集与剩余数据分离。当规则被添加时,数据的其他子集也会被分离,直到整个数据集被覆盖,且没有更多的例子留下。
想象规则学习过程的一种方式是通过创建越来越具体的规则来逐步深入数据,以识别类别值。假设你的任务是创建规则来判断一个动物是否是哺乳动物。你可以将所有动物的集合描绘成一个大空间,如下图所示:
规则学习者首先利用可用的特征来找到同质的群体。例如,使用一个表示物种是通过陆地、海洋还是空中移动的特征,第一个规则可能建议所有陆地动物都是哺乳动物:
你注意到这个规则有什么问题吗?如果你是一个动物爱好者,你可能已经意识到青蛙是两栖动物,而不是哺乳动物。因此,我们的规则需要更具体一点。我们可以进一步深入,假设哺乳动物必须在陆地上行走并且有尾巴:
可以定义一个额外的规则来分离蝙蝠,它是唯一剩下的哺乳动物。因此,这个子集可以与其他数据分开。
可以定义一个额外的规则来区分蝙蝠,它是唯一剩下的哺乳动物。一个可能的特征是蝙蝠与其他剩余动物的区别在于它们有毛发。通过使用基于这个特征的规则,我们就正确地识别了所有的动物:
到这一点时,由于所有训练实例都已经被分类,规则学习过程将停止。我们总共学到了三个规则:
-
会走在陆地上并且有尾巴的动物是哺乳动物。
-
如果动物没有毛发,它就不是哺乳动物。
-
否则,动物就是哺乳动物。
上面的例子说明了规则如何逐渐消耗更大、更大的数据片段,最终对所有实例进行分类。
由于规则似乎覆盖了数据的部分内容,分离并征服算法也被称为覆盖算法,而由此产生的规则则被称为覆盖规则。在下一节中,我们将通过研究一个简单的规则学习算法来了解覆盖规则是如何在实践中应用的。然后我们将研究一个更复杂的规则学习者,并将这两者应用于实际问题。
1R 算法
假设有一个电视游戏节目,节目中有一个轮盘,轮盘上有十个大小均等的彩色分区。三个分区是红色的,三个是蓝色的,四个是白色的。在转动轮盘之前,你需要选择其中一种颜色。当轮盘停止时,如果显示的颜色与你的预测匹配,你将赢得一大笔现金奖励。你应该选择哪种颜色?
如果你选择白色,当然更有可能赢得奖品——这是轮盘上最常见的颜色。显然,这个游戏节目有点荒谬,但它展示了最简单的分类器ZeroR,一个实际上不学习任何规则的规则学习器(因此得名)。对于每一个未标记的样本,无论其特征值如何,都会预测最常见的类别。
1R 算法(单规则或OneR)通过选择一个规则改进了 ZeroR。尽管这看起来可能过于简化,但它往往比你想象的表现更好。正如实证研究所示,对于许多实际任务,这个算法的准确率可以接近更复杂算法的表现。
注意
对 1R 算法出乎意料的表现进行深入了解,请参见 Holte RC. 非常简单的分类规则在大多数常用数据集上表现良好。《机器学习》1993 年;11:63-91。
1R 算法的优缺点如下表所示:
优点 | 缺点 |
---|
|
-
生成一个简单易懂、可读性强的经验法则
-
经常表现得出奇的好
-
可以作为更复杂算法的基准
|
-
仅使用一个特征
-
可能过于简化
|
这个算法的工作原理很简单。对于每个特征,1R 将数据根据特征的相似值划分成组。然后,对于每个子集,算法预测多数类。计算基于每个特征的规则的错误率,选择错误最少的规则作为最终的单一规则。
以下表格展示了这个算法如何作用于我们在本节中早些时候看到的动物数据:
对于通过何种方式移动特征,数据集被划分为三组:空中、陆地和海洋。空中和海洋组的动物被预测为非哺乳动物,而陆地组的动物则被预测为哺乳动物。这导致了两个错误:蝙蝠和青蛙。是否有毛发特征将动物分为两组。有毛发的被预测为哺乳动物,而没有毛发的则被预测为非哺乳动物。统计了三个错误:猪、大象和犀牛。由于通过何种方式移动特征导致的错误较少,1R 算法将基于通过何种方式移动返回以下“单一规则”:
-
如果动物通过空气移动,它不是哺乳动物
-
如果动物通过陆地移动,它是哺乳动物
-
如果动物通过海洋移动,它不是哺乳动物
算法在这里停止,已找到最重要的单一规则。
显然,这个规则学习算法对于某些任务可能过于基础。你希望医疗诊断系统只考虑一个症状,还是希望自动驾驶系统仅根据一个因素来决定停车或加速?对于这些类型的任务,可能需要更复杂的规则学习器。我们将在接下来的章节中了解一个。
RIPPER 算法
早期的规则学习算法存在一些问题。首先,它们以速度慢而闻名,这使得它们在处理日益增多的大型数据集时效果不佳。其次,它们在噪声数据上通常容易出现不准确的情况。
解决这些问题的第一步由 Johannes Furnkranz 和 Gerhard Widmer 于 1994 年提出。他们的增量减少误差修剪(IREP)算法结合了预修剪和后修剪方法,这些方法使得规则变得非常复杂,然后在从完整数据集中分离实例之前进行修剪。尽管这一策略提高了规则学习者的性能,但决策树通常仍然表现得更好。
注意
关于 IREP 的更多信息,参见 Furnkranz J, Widmer G. 增量减少误差修剪。1994 年《第 11 届国际机器学习大会论文集》:70-77。
规则学习算法在 1995 年迈出了新的一步,当时 William W. Cohen 提出了重复增量修剪以产生误差减少(RIPPER)算法,该算法在 IREP 的基础上进行了改进,生成的规则能够匹配或超越决策树的性能。
注意
关于 RIPPER 的更多细节,参见 Cohen WW. 快速有效的规则归纳。1995 年《第 12 届国际机器学习大会论文集》:115-123。
如下表所示,RIPPER 的优缺点与决策树基本相当。主要的优点是,它们可能生成一个稍微更简洁的模型:
优势 | 劣势 |
---|
|
-
生成易于理解的人类可读规则
-
在大型和噪声数据集上效率高
-
通常比可比的决策树生成更简单的模型
|
-
可能会生成似乎违背常识或专家知识的规则
-
不适合处理数值数据
-
可能不如更复杂的模型表现得好
|
RIPPER 算法是从多个规则学习算法的迭代中发展而来的,它是规则学习的高效启发式算法的拼凑。由于其复杂性,技术实现的详细讨论超出了本书的范围。然而,可以将其大致理解为一个三步过程:
-
增长
-
修剪
-
优化
增长阶段使用分离与征服技术,贪婪地向规则中添加条件,直到它完美分类数据子集或没有更多的属性可用于拆分。与决策树类似,信息增益标准用于确定下一个拆分属性。当增加规则的特异性不再减少熵时,规则会立即被修剪。步骤一和步骤二会反复进行,直到达到停止标准,此时使用各种启发式方法优化整个规则集。
RIPPER 算法能够创建比 1R 算法更复杂的规则,因为它可以考虑多个特征。这意味着它可以创建具有多个前提的规则,例如“如果动物会飞并且有毛发,那么它是哺乳动物”。这提高了算法处理复杂数据的能力,但就像决策树一样,这也意味着规则可能会迅速变得更难理解。
注意
分类规则学习者的发展并未止步于 RIPPER。新的规则学习算法正在快速提出。文献调查显示了诸如 IREP++、SLIPPER、TRIPPER 等多种算法。
来自决策树的规则
分类规则也可以直接从决策树中获得。从一个叶节点开始,沿着分支回到根节点,你将获得一系列决策。这些可以组合成一条规则。下图展示了如何从决策树构建规则来预测电影的成功:
从根节点到每个叶子节点的路径,规则将是:
-
如果明星数量较少,那么电影将会是票房惨败。
-
如果明星数量较多且预算较高,那么电影将会是主流热片。
-
如果明星数量较多且预算较低,那么电影将会是口碑成功。
由于接下来的部分将会阐明的原因,使用决策树生成规则的主要缺点是,结果的规则通常比规则学习算法学到的规则更复杂。决策树采用的分治策略与规则学习者的偏差不同。另一方面,有时从树中生成规则在计算上更加高效。
提示
C5.0()
函数在C50
包中会生成一个使用分类规则的模型,前提是你在训练模型时指定rules = TRUE
。
是什么让树和规则显得贪心?
决策树和规则学习者被称为贪心学习者,因为它们按先到先得的方式使用数据。决策树使用的分治策略和规则学习者使用的分离策略都试图一次做出一个分割,首先找到最同质的分割,然后是下一个最好的分割,依此类推,直到所有实例都被分类。
贪婪方法的缺点是,贪婪算法并不保证为特定数据集生成最佳、最准确或最少数量的规则。通过提前采摘低悬果实,贪婪学习者可能会迅速找到一个对某个数据子集准确的单一规则;然而,采取这种做法,学习者可能会错失开发一个更加细致的规则集的机会,而这个规则集在整个数据集上具有更好的整体准确性。然而,如果不使用贪婪方法进行规则学习,那么对于除了最小的数据集以外的所有情况,规则学习将变得计算上不可行。
尽管树和规则都采用贪婪学习启发式,但它们在构建规则的方式上存在微妙的差异。也许区分它们最好的方式是注意到,一旦分治法在某个特征上进行分裂,分裂产生的分区就不能被重新征服,只能进一步细分。通过这种方式,树会永久地受到过去决策历史的限制。相反,一旦分离与征服法找到一个规则,任何不被所有规则条件覆盖的示例都可以被重新征服。
为了说明这一对比,考虑前面的例子,其中我们构建了一个规则学习器来判断一种动物是否是哺乳动物。规则学习器识别了三个规则,能够完美地分类示例动物:
-
上陆行走且有尾巴的动物是哺乳动物(熊、猫、狗、大象、猪、兔子、老鼠、犀牛)
-
如果动物没有毛发,那么它就不是哺乳动物(鸟类、电鳗、鱼类、青蛙、昆虫、鲨鱼)
-
否则,该动物是哺乳动物(蝙蝠)
相反,基于相同数据构建的决策树可能会提出四个规则来实现相同的完美分类:
-
如果动物上陆行走且有毛发,那么它是哺乳动物(熊、猫、狗、大象、猪、兔子、老鼠、犀牛)
-
如果一种动物上陆行走且没有毛发,那么它就不是哺乳动物(青蛙)
-
如果动物不上陆行走且有毛发,那么它是哺乳动物(蝙蝠)
-
如果动物不上陆行走且没有毛发,那么它就不是哺乳动物(鸟类、昆虫、鲨鱼、鱼类、电鳗)
这两种方法产生不同结果的原因在于青蛙在“上陆行走”决策后发生的变化。规则学习器允许青蛙被“没有毛发”决策重新分类,而决策树无法修改现有的分区,因此必须将青蛙归入自己的规则中。
一方面,由于规则学习器可以重新审视那些曾被考虑但最终未被先前规则覆盖的案例,规则学习器通常会找到比决策树生成的规则集更加简洁的规则集。另一方面,这种数据的重复使用意味着规则学习器的计算成本可能比决策树略高。
示例——使用规则学习器识别有毒蘑菇
每年,许多人因食用有毒野生蘑菇而生病,有时甚至死亡。由于许多蘑菇在外观上非常相似,偶尔甚至经验丰富的蘑菇采摘者也会中毒。
与识别有毒植物(如毒橡树或常春藤)不同,识别野生蘑菇是否有毒或可食用并没有像“叶子三片,留它们”这样的明确规则。更复杂的是,许多传统规则,如“有毒蘑菇是鲜艳的颜色”,提供了危险或误导性的信息。如果有简单、明确且一致的规则来识别有毒蘑菇,它们可能会拯救采蘑菇者的生命。
由于规则学习算法的一个优势是它们生成易于理解的规则,因此它们似乎非常适合这项分类任务。然而,这些规则的有效性将取决于它们的准确性。
第 1 步——收集数据
为了识别区分有毒蘑菇的规则,我们将使用卡内基梅隆大学 Jeff Schlimmer 的蘑菇数据集。原始数据集可以在 UCI 机器学习库中免费获得(archive.ics.uci.edu/ml
)。
数据集包含来自《北美蘑菇观鸟指南》(1981 年版)中 23 种带柄蘑菇的 8,124 个蘑菇样本的信息。在该指南中,每种蘑菇的种类都被标定为“肯定可食用”,“肯定有毒”或“可能有毒,不建议食用”。对于该数据集,后者与“肯定有毒”类别合并,形成了两个类别:有毒和无毒。UCI 网站上的数据字典描述了蘑菇样本的 22 个特征,包括盖形、盖色、气味、鳃的大小和颜色、柄形状以及栖息地等特征。
提示
本章使用的是经过略微修改的蘑菇数据。如果你打算跟着例子一起操作,请从 Packt Publishing 网站下载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 ...
如果你觉得一个因子只有一个水平很奇怪,那么你是对的。数据字典列出了该特征的两个水平:部分和普遍。然而,我们数据中的所有示例都被归类为部分。很可能这个数据元素被错误地编码了。无论如何,由于面纱类型在样本间没有变化,它不会为预测提供任何有用信息。我们将通过以下命令从分析中删除该变量:
> mushrooms$veil_type <- NULL
通过将NULL
赋值给面纱类型向量,R 会从mushrooms
数据框中删除该特征。
在深入分析之前,我们应该快速查看一下数据集中蘑菇type
类别变量的分布:
> table(mushrooms$type)
edible poisonous
4208 3916
大约 52%的蘑菇样本(N = 4,208)是可食用的,而 48%(N = 3,916)是有毒的。
为了本实验的目的,我们将蘑菇数据集中的 8,214 个样本视为所有可能野生蘑菇的完整集合。这是一个重要的假设,因为这意味着我们不需要将一些样本从训练数据中排除用于测试。我们不是试图开发涵盖未知蘑菇类型的规则;我们只是试图找到准确描述已知蘑菇类型完整集合的规则。因此,我们可以在相同的数据上构建并测试模型。
步骤 3 – 在数据上训练模型
如果我们在这些数据上训练一个假设的 ZeroR 分类器,它会预测什么?由于 ZeroR 忽略所有特征,仅仅预测目标的众数,用简单的话来说,它的规则会说所有蘑菇都是可食用的。显然,这不是一个很有用的分类器,因为它会让采蘑菇的人生病或死亡,因为几乎一半的蘑菇样本有可能是有毒的。我们的规则必须比这个做得好得多,才能提供可以发布的安全建议。同时,我们需要简单易记的规则。
由于简单规则通常具有极强的预测性,让我们看看一个非常简单的规则学习器在蘑菇数据上的表现。最后,我们将应用 1R 分类器,它将识别目标类别中最具预测性的单一特征,并使用它来构建一套规则。
我们将使用RWeka
包中的 1R 实现,名为OneR()
。你可能记得我们在第一章,机器学习介绍中,作为安装和加载包的教程一部分,安装了RWeka
。如果你没有按照这些说明安装该包,你将需要使用install.packages("RWeka")
命令,并确保你的系统上安装了 Java(参阅安装说明获取更多详情)。完成这些步骤后,输入library(RWeka)
加载该包:
OneR()
实现使用 R 公式语法来指定要训练的模型。公式语法使用~
运算符(称为波浪号)来表达目标变量与其预测变量之间的关系。要学习的类别变量放在波浪号的左侧,预测特征写在右侧,用+
运算符分隔。如果你想建模y
类与预测变量x1
和x2
之间的关系,你可以写成y ~ x1 + x2
。如果你想在模型中包含所有变量,可以使用特殊术语.
。例如,y ~ .
指定了y
与数据集中的所有其他特征之间的关系。
提示
R 公式语法在许多 R 函数中被广泛使用,并提供了一些强大的功能来描述预测变量之间的关系。我们将在后面的章节中探索其中的一些特性。如果你急于了解,可以使用?formula
命令查看文档。
使用type ~ .
公式,我们将允许我们的第一个OneR()
规则学习器在构建预测类型的规则时,考虑蘑菇数据中的所有可能特征:
> mushroom_1R <- OneR(type ~ ., data = mushrooms)
要检查它创建的规则,我们可以输入分类器对象的名称,在这个例子中是mushroom_1R
:
> mushroom_1R
odor:
almond -> edible
anise -> edible
creosote -> poisonous
fishy -> poisonous
foul -> poisonous
musty -> poisonous
none -> edible
pungent -> poisonous
spicy -> poisonous
(8004/8124 instances correct)
在输出的第一行,我们看到选中了“气味”特征用于规则生成。气味的类别,如杏仁、茴香等,指定了蘑菇是否可能是可食用或有毒的规则。例如,如果蘑菇闻起来有腥味、腐臭味、霉味、刺激味、辣味或柏油味,那么蘑菇很可能是有毒的。另一方面,闻起来更宜人的气味,如杏仁和茴香,或者没有气味的蘑菇,通常被预测为可食用。对于蘑菇采集的野外指南来说,这些规则可以总结为一个简单的经验法则:“如果蘑菇闻起来不舒服,那么它很可能是有毒的。”
第 4 步 – 评估模型性能
输出的最后一行指出,规则正确预测了 8,124 个蘑菇样本中 8,004 个的可食性,约占 99%。我们可以使用summary()
函数获得分类器的更多细节,如下例所示:
> summary(mushroom_1R)
=== Summary ===
Correctly Classified Instances 8004 98.5229 %
Incorrectly Classified Instances 120 1.4771 %
Kappa statistic 0.9704
Mean absolute error 0.0148
Root mean squared error 0.1215
Relative absolute error 2.958 %
Root relative squared error 24.323 %
Coverage of cases (0.95 level) 98.5229 %
Mean rel. region size (0.95 level) 50 %
Total Number of Instances 8124
=== Confusion Matrix ===
a b <-- classified as
4208 0 | a = edible
120 3796 | b = poisonous
标记为Summary
的部分列出了多种不同的方式来衡量我们 1R 分类器的性能。我们将在第十章中详细介绍其中许多统计量,评估模型性能,因此现在暂时不讨论这些内容。
标有混淆矩阵
的部分与之前使用的相似。在这里,我们可以看到规则出现错误的地方。右侧显示了关键,其中a = 可食用
,b = 有毒
。表格的列表示预测的蘑菇类别,行则将 4,208 个可食用蘑菇与 3,916 个有毒蘑菇分开。通过检查表格,我们可以看到,尽管 1R 分类器没有将任何可食用蘑菇错误分类为有毒蘑菇,但它却将 120 个有毒蘑菇错误分类为可食用的——这可是一个非常危险的错误!
考虑到学习者仅使用了一个特征,模型表现得相当不错;如果在采摘蘑菇时避免不愉快的气味,他们几乎可以避免去医院的风险。尽管如此,当涉及到生命时,“接近”并不足够,更不用说当读者生病时,野外指南的出版商可能会因为面临诉讼而不高兴。让我们看看能否增加一些规则,开发出一个更好的分类器。
步骤 5 – 改进模型性能
对于更复杂的规则学习器,我们将使用JRip()
,它是一个基于 Java 的 RIPPER 规则学习算法实现。与之前使用的 1R 实现一样,JRip()
包含在RWeka
包中。如果尚未安装,确保使用library(RWeka)
命令加载该包:
如语法框所示,训练JRip()
模型的过程与我们之前训练OneR()
模型的方式非常相似。这是RWeka
包的一个优点;不同算法的语法一致,使得比较多个不同模型的过程变得非常简单。
让我们像训练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 (cap_color = white) => type=poisonous (8.0/0.0)
(stalk_color_above_ring = yellow) => type=poisonous (8.0/0.0)
=> type=edible (4208.0/0.0)
Number of Rules : 9
JRip()
分类器从蘑菇数据中学习了共九条规则。可以将这些规则看作是一系列的 if-else 语句,类似于编程逻辑。前面三条规则可以表示为:
-
如果气味刺鼻,则蘑菇类型为有毒
-
如果鳃的大小狭窄且鳃的颜色为浅棕色,则蘑菇类型为有毒
-
如果鳃的大小狭窄且气味刺鼻,则蘑菇类型为有毒
最后,第九条规则意味着任何未被前面八条规则涵盖的蘑菇样本都是可食用的。按照我们编程逻辑的示例,可以这样理解:
- 否则,蘑菇是可食用的
每条规则旁边的数字表示该规则涵盖的实例数量以及误分类实例的数量。值得注意的是,使用这九条规则时,没有任何蘑菇样本被误分类。因此,最后一条规则涵盖的实例数量正好等于数据中可食用蘑菇的数量(N = 4,208)。
以下图表大致说明了规则如何应用于蘑菇数据。如果你把椭圆内的所有内容想象为所有种类的蘑菇,规则学习器识别了特征或特征集,将同质的群体从更大的群体中分离出来。首先,算法找到了一大群具有独特恶臭味的有毒蘑菇。接着,它找到了更小、更具体的有毒蘑菇群体。通过识别每种有毒蘑菇的覆盖规则,剩余的所有蘑菇都被确定为可食用。感谢大自然,每种蘑菇的特性足够独特,以至于分类器能够实现 100%的准确率。
总结
本章介绍了两种分类方法,它们使用所谓的“贪婪”算法,根据特征值将数据进行划分。决策树采用分而治之的策略,创建类似流程图的结构,而规则学习器则通过分离和征服数据,识别逻辑的 if-else 规则。两种方法生成的模型都可以在没有统计背景的情况下进行解释。
一种流行且高度可配置的决策树算法是 C5.0。我们使用 C5.0 算法创建了一个决策树来预测贷款申请人是否会违约。通过使用提升和成本敏感错误的选项,我们能够提高准确率,避免那些会使银行蒙受更大损失的高风险贷款。
我们还使用了两种规则学习器,1R 和 RIPPER,来开发规则以识别有毒蘑菇。1R 算法使用一个特征,在识别潜在致命的蘑菇样本时达到了 99%的准确率。另一方面,使用更复杂的 RIPPER 算法生成的九条规则正确地识别了每个蘑菇的可食性。
本章仅仅触及了树和规则应用的表面。在第六章,数值数据预测—回归方法中,我们将学习回归树和模型树等技术,它们使用决策树进行数值预测而非分类。在第十一章,提升模型性能中,我们将探讨如何通过将决策树组合成一个叫做随机森林的模型来提升其性能。在第八章,寻找模式—使用关联规则的市场篮子分析中,我们将看到如何利用关联规则(分类规则的一种相关形式)来识别交易数据中的项目组。
第六章:数值数据预测——回归方法
数学关系帮助我们理解日常生活中的许多方面。例如,体重是摄入卡路里的函数,收入通常与教育和工作经验有关,民意调查数据帮助我们估计总统候选人连任的几率。
当这些关系用精确的数字表示时,我们获得了更多的清晰度。例如,每天额外摄入 250 千卡的热量可能导致每月增加近 1 公斤体重;每增加一年的工作经验,年薪可能增加$1,000;而在经济强劲时,总统连任的可能性更大。显然,这些方程式并不完美适用于每种情况,但我们可以预期它们在平均情况下是相对正确的。
本章通过超越前面所述的分类方法,引入了估计数值数据之间关系的技术,从而扩展了我们的机器学习工具包。在研究几个现实世界的数值预测任务时,您将学习到:
-
回归中使用的基本统计原理,一种模拟数值关系的大小和强度的技术
-
如何准备回归分析的数据,并估算和解读回归模型
-
一对混合技术,称为回归树和模型树,它们将决策树分类器适应于数值预测任务
基于统计学领域的大量研究,本章所用的方法在数学方面略重于之前所讲的内容,但别担心!即使你的代数技能有些生疏,R 语言会帮你完成繁重的计算。
理解回归
回归分析关注的是指定单一数值因变量(即要预测的值)与一个或多个数值自变量(即预测变量)之间的关系。顾名思义,因变量取决于自变量的值。回归的最简单形式假设自变量与因变量之间的关系呈直线。
注意
“回归”一词用于描述将线条拟合到数据的过程,源自 19 世纪末弗朗西斯·高尔顿爵士在遗传学研究中的发现。他发现,极端矮小或极端高大的父亲,往往有身高更接近平均值的儿子。他将这一现象称为“回归到均值”。
你可能还记得从基础代数中,直线可以用斜率-截距形式来定义,类似于 y = a + bx。在这种形式中,字母y表示因变量,x表示自变量。斜率项b指定了直线在x每增加一个单位时上升的量。正值定义了向上倾斜的直线,而负值则定义了向下倾斜的直线。项a被称为截距,因为它指定了直线交叉或截取垂直y轴的点。它表示当x = 0时y的值。
回归方程使用类似于斜率-截距形式的数据模型。机器的任务是确定* a 和 b 的值,使得指定的直线能最好地将提供的x值与y*值关联起来。可能并不总是存在一个完美关联这些值的单一函数,因此机器还必须有某种方式来量化误差范围。我们稍后会深入讨论这一点。
回归分析通常用于建模数据元素之间的复杂关系,估计处理对结果的影响,并进行未来的外推。尽管它可以应用于几乎任何任务,但一些特定的应用案例包括:
-
检验群体和个体在其测量特征上的变异性,广泛应用于经济学、社会学、心理学、物理学和生态学等多个学科的科学研究。
-
定量分析事件与响应之间的因果关系,例如临床药物试验、工程安全测试或市场调研中的因果关系。
-
识别可以用来根据已知标准预测未来行为的模式,如预测保险理赔、自然灾害损失、选举结果和犯罪率等。
回归方法也用于统计假设检验,该方法通过观察到的数据来判断一个前提是否可能为真或为假。回归模型对关系的强度和一致性的估计提供了可以用来评估观察结果是否仅仅由偶然因素造成的信息。
注意
假设检验是极其复杂的,超出了机器学习的范畴。如果你对这个话题感兴趣,入门级统计学教材是一个很好的起点。
回归分析并不等同于单一算法。相反,它是一个涵盖大量方法的总称,这些方法可以适应几乎任何机器学习任务。如果你只能选择一种方法,回归方法是一个不错的选择。有人可以将一生献给这一领域,或许仍然有许多要学习的内容。
本章将仅关注最基本的线性回归模型——那些使用直线的模型。当只有一个自变量时,称为简单线性回归。当有两个或更多自变量时,称为多元线性回归,或简称为“多元回归”。这两种技术都假设因变量是连续量度的。
回归还可以用于其他类型的因变量,甚至一些分类任务。例如,逻辑回归用于建模二元分类结果,而泊松回归——以法国数学家西门·泊松命名——用于建模整数计数数据。被称为多项式逻辑回归的方法则用于建模分类结果,因此它可以用于分类。所有回归方法遵循相同的基本原理,因此在理解了线性回归后,学习其他方法相对简单。
提示
许多专业的回归方法属于广义线性模型(GLM)类。使用 GLM 时,线性模型可以通过使用链接函数来推广到其他模式,这些函数为x和y之间的关系指定了更复杂的形式。这样,回归就可以应用于几乎任何类型的数据。
我们将从简单线性回归的基本情况开始。尽管名字中有“简单”二字,这种方法并不简单,仍能解决复杂问题。在下一节中,我们将看到如何通过使用简单线性回归模型来避免一次悲剧性的工程灾难。
简单线性回归
1986 年 1 月 28 日,美国航天飞机挑战者号的七名机组成员在火箭助推器发生故障后丧生,导致灾难性的解体。在事后,专家们将发射温度视为潜在的罪魁祸首。负责密封火箭接头的橡胶 O 型环从未在 40ºF(4ºC)以下的温度下进行过测试,而发射当天的天气异常寒冷,气温低于冰点。
从后见之明来看,这起事故成为了数据分析和可视化重要性的案例研究。尽管目前不清楚火箭工程师和决策者在发射前掌握了哪些信息,但不可否认的是,如果有更好的数据并加以谨慎使用,很可能能够避免这场灾难。
注意
本节分析基于 Dalal SR, Fowlkes EB, Hoadley B. 航天飞机的风险分析:挑战者号发射前的故障预测。美国统计学会学报,1989 年;84:945-957。有关数据如何改变结果的一个视角,参见 Tufte ER. 视觉解释:图像与数量、证据与叙述。Graphics Press,1997 年。一个反观点请参见 Robison W, Boisioly R, Hoeker D, Young, S. 代表与误代表:Tufte 与 Morton Thiokol 工程师对挑战者号的看法。科学与工程伦理,2002 年;8:59-81。
火箭工程师几乎肯定知道,低温可能使部件变脆,无法正确密封,从而导致更高的危险燃料泄漏的可能性。然而,考虑到继续发射的政治压力,他们需要数据来支持这一假设。一个能够展示温度与 O 型环故障之间关联的回归模型,并能够根据预期的发射温度预测故障的可能性,可能会非常有帮助。
为了构建回归模型,科学家们可能使用了来自 23 次成功航天飞机发射的数据,包括发射温度和部件故障数据。部件故障表示两种问题之一。第一个问题叫做侵蚀,当过热烧坏 O 型环时就会发生这种情况。第二个问题叫做泄漏,当热气体通过或“冲过”密封不良的 O 型环时,就会发生泄漏。由于航天飞机总共有六个主要 O 型环,因此每次飞行最多可能发生六个故障事件。尽管火箭可以在发生一个或多个故障事件的情况下生还,或者在发生一个故障事件的情况下就失败,但每增加一个故障事件,灾难性失败的概率就会增加。
以下散点图展示了前 23 次发射中检测到的主要 O 型环故障与发射温度的关系:
观察图表,可以发现一个明显的趋势。发生在较高温度下的发射,O 型环故障事件较少。
此外,最冷的发射温度(53º F)发生了两次故障事件,这是在另外一次发射中才达到的水平。掌握了这些信息后,可以看出挑战者号计划在比这低 20 多度的温度下发射,这似乎令人担忧。那么我们到底应该多担心呢?为了回答这个问题,我们可以借助简单线性回归。
简单线性回归模型通过使用一个由方程定义的直线,定义了一个因变量与单一自变量之间的关系,方程形式如下:
不要被希腊字符吓到,这个方程仍然可以通过之前描述的斜截式形式来理解。截距,α(阿尔法),描述了直线与y轴的交点,而斜率,β(贝塔),描述了当x增加时,y的变化。对于航天飞机发射数据,斜率告诉我们每升高 1 度发射温度,O 型环故障的预期减少量。
提示
希腊字符常常在统计学领域中用来表示统计函数的参数变量。因此,进行回归分析时,需要找到参数估计值,即α和β的估计值。阿尔法和贝塔的参数估计值通常用a和b表示,尽管你可能会发现这些术语和符号有时可以互换使用。
假设我们知道航天飞机发射数据的回归方程中估计的回归参数为:a = 3.70和b = -0.048。
因此,完整的线性方程是y = 3.70 – 0.048x。暂时忽略这些数字是如何得出的,我们可以像这样将直线绘制在散点图上:
如图所示,在华氏 60 度时,我们预测 O 型环故障略低于 1 个;在华氏 70 度时,我们预计约有 0.3 个故障。如果我们将模型外推到 31 度——挑战者号发射时的预测温度——我们预计将有大约3.70 - 0.048 * 31 = 2.21个 O 型环故障事件。假设每个 O 型环故障都有相同的概率导致灾难性的燃料泄漏,那么在 31 度时,挑战者号发射的风险几乎是 60 度典型发射的三倍,是 70 度发射的八倍以上。
注意,直线并不是精确通过每一个数据点的。相反,它大致穿过数据,部分预测值高于直线,部分低于直线。在下一部分中,我们将学习为什么选择了这条特定的直线。
普通最小二乘估计
为了确定α和β的最优估计值,使用了一种叫做普通最小二乘法(OLS)的估计方法。在 OLS 回归中,选择的斜率和截距是为了最小化平方误差的和,即预测的y值与实际的y值之间的垂直距离。这些误差被称为残差,并在以下图示中为若干点进行了说明:
用数学术语表达,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 是温度,依赖变量 y 是distress_ct
。然后我们可以使用 R 的cov()
和var()
函数来估算 b *:
> b <- cov(launch$temperature, launch$distress_ct) /
var(launch$temperature)
> b
[1] -0.04753968
从这里我们可以使用mean()
函数估算* a *:
> a <- mean(launch$distress_ct) - b * mean(launch$temperature)
> a
[1] 3.698413
手动估计回归方程并不理想,因此 R 提供了自动执行此计算的函数。我们将很快使用这些方法。首先,我们将通过学习一种测量线性关系强度的方法来扩展我们对回归的理解,然后我们将看到如何将线性回归应用于包含多个自变量的数据。
相关性
相关性是两个变量之间的一个数字,表示它们的关系与一条直线的贴合程度。通常,相关性指的是 Pearson 相关系数,它由 20 世纪的数学家 Karl Pearson 开发。相关性范围在 -1 到 +1 之间。极端值表示完美的线性关系,而接近零的相关性则表示缺乏线性关系。
以下公式定义了 Pearson 相关性:
提示
这里引入了更多的希腊符号。第一个符号(看起来像小写字母 p)是 rho,用于表示 Pearson 相关性统计量。那些看起来像横过的 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
或者,我们可以使用 R 的相关性函数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 值的影响可以单独估计。换句话说,y 会因每个 x[i] 单位增加而改变 β[i] 的数量。截距 α 则是当所有独立变量为零时 y 的期望值。
由于截距项α与其他任何回归参数并无不同,它有时也被表示为β[0](读作 beta 零),如以下公式所示:
就像之前一样,截距与任何自变量x并无直接关系。然而,出于很快会变得清晰的原因,帮助理解的方式是将β[0]想象成与一个常数项x[0]相乘,其中x[0]的值为 1:
为了估计回归参数的值,每个观测值的因变量y必须通过前述形式的回归方程与观测值的自变量x相关联。以下图展示了这种结构:
前面图示中展示的大量行列数据,可以用粗体字体的矩阵符号来简洁地表示,表明每个术语代表多个值:
现在,因变量是一个向量Y,每一行对应一个示例。自变量已经合并成一个矩阵X,其中每列对应一个特征,另外还有一列全为'1'的值,代表截距项。每列有一个对应示例的行。回归系数β和残差ε现在也都是向量。
目标是解出β,即回归系数向量,它最小化预测值与实际Y值之间的平方误差和。寻找最优解需要使用矩阵代数;因此,推导过程需要比本书所能提供的更为详细的关注。不过,如果你愿意相信他人的研究,β向量的最佳估计可以通过以下方式计算:
这个解决方案使用了一对矩阵运算——T表示矩阵X的转置,而负指数表示矩阵的逆。使用 R 的内置矩阵运算,我们可以实现一个简单的多元回归学习器。接下来,我们将这个公式应用于挑战者发射数据。
提示
如果你对前面的矩阵运算不熟悉,维基百科上关于转置和矩阵逆的页面提供了详细的介绍,即使没有强大的数学背景,也能很好地理解。
使用以下代码,我们可以创建一个名为reg()
的基本回归函数,它接受一个参数y
和一个参数x
,并返回一个估计的 beta 系数向量:
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()
用于转置矩阵 -
%*%
用于两个矩阵的乘法
通过将这些组合在一起,我们的函数将返回一个向量 b
,其中包含线性模型中与 x
相关的估计参数。函数中的最后两行给 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 ...
$ 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
这些值与我们之前的结果完全一致,因此我们可以使用该函数来构建一个多元回归模型。我们将像之前一样应用它,不过这次我们指定三列数据,而不仅仅是其中的一列:
> 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 型环故障事件数减少。现场检查压力是指发射前对 O 型环施加的压力。虽然检查压力最初是 50 psi,但在某些发射中,它被提高到 100 psi 和 200 psi,这使得一些人认为它可能是 O 型环腐蚀的原因。该系数为正,但较小。飞行编号被包含在内,以考虑航天飞机的使用年限。随着使用年限的增加,其部件可能变得更脆弱或更容易发生故障。飞行编号与故障次数之间的微弱正相关可能反映了这一事实。
到目前为止,我们只触及了线性回归建模的表面。虽然我们的工作有助于我们准确理解回归模型是如何构建的,但 R 的函数还包括一些额外的功能,必要时可用于更复杂的建模任务和诊断输出,这些对于帮助模型解释和评估拟合度是必需的。让我们将回归知识应用于一个更具挑战性的学习任务。
示例 – 使用线性回归预测医疗费用
为了使健康保险公司盈利,它需要收取的年度保费高于用于支付受益人医疗费用的支出。因此,保险公司在开发能够准确预测被保险人群体医疗费用的模型上投入了大量的时间和金钱。
医疗费用很难估算,因为最昂贵的疾病较为罕见且看似随机。然而,某些疾病在特定人群中更为常见。例如,吸烟者比非吸烟者更容易患上肺癌,而肥胖者可能更容易患上心脏病。
本次分析的目标是使用患者数据来估算特定人群的平均医疗费用。这些估算结果可以用于创建精算表,根据预期的治疗费用来调整年度保费价格。
第 1 步 – 收集数据
本次分析将使用一个模拟数据集,其中包含美国患者的假设医疗费用数据。这些数据是使用美国人口普查局的统计数据为本书创建的,因此大致反映了现实世界的情况。
提示
如果你希望互动地跟随本书的内容,可以从 Packt Publishing 网站下载insurance.csv
文件,并将其保存在 R 的工作文件夹中。
insurance.csv
文件包括 1,338 个目前已参加保险计划的受益人示例,文件中包含患者的特征以及该年度总医疗费用。特征包括:
-
age
:一个整数,表示主要受益人的年龄(不包括 64 岁以上的人群,因为他们通常由政府提供保险)。 -
sex
:保险持有人的性别,可以是男性或女性。 -
bmi
:身体质量指数(BMI),它表示一个人的体重与身高的关系。BMI 等于体重(以千克为单位)除以身高(以米为单位)的平方。理想的 BMI 范围是 18.5 到 24.9。 -
children
:一个整数,表示保险计划所涵盖的子女/受抚养人数量。 -
smoker
:一个是或否的分类变量,表示被保险人是否定期吸烟。 -
region
:受益人在美国的居住地,分为四个地理区域:东北、东南、西南或西北。
考虑这些变量如何与计费医疗费用相关是非常重要的。例如,我们可能预期,老年人和吸烟者面临较大医疗费用的风险。与许多其他机器学习方法不同,在回归分析中,特征之间的关系通常由用户指定,而不是自动检测。我们将在下一节探讨这些潜在的关系。
第 2 步 – 探索和准备数据
如我们之前所做的,我们将使用read.csv()
函数加载数据进行分析。我们可以安全地使用stringsAsFactors = TRUE
,因为将这三个名义变量转换为因子是合适的:
> insurance <- read.csv("insurance.csv", stringsAsFactors = TRUE)
str()
函数确认数据格式符合我们的预期:
> str(insurance)
'data.frame': 1338 obs. of 7 variables:
$ age : int 19 18 28 33 32 31 46 37 37 60 ...
$ sex : Factor w/ 2 levels "female","male": 1 2 2 2 2 1 ...
$ bmi : num 27.9 33.8 33 22.7 28.9 25.7 33.4 27.7 ...
$ children: int 0 1 3 0 0 0 1 3 2 0 ...
$ smoker : Factor w/ 2 levels "no","yes": 2 1 1 1 1 1 1 1 ...
$ region : Factor w/ 4 levels "northeast","northwest",..: ...
$ expenses: num 16885 1726 4449 21984 3867 ...
我们模型的因变量是expenses
,它表示每个人在一年内为保险计划支付的医疗费用。在构建回归模型之前,检查数据是否符合正态分布通常是有帮助的。尽管线性回归并不严格要求因变量正态分布,但当这一假设成立时,模型往往拟合得更好。让我们看一下汇总统计信息:
> summary(insurance$expenses)
Min. 1st Qu. Median Mean 3rd Qu. Max.
1122 4740 9382 13270 16640 63770
因为均值大于中位数,这意味着保险费用的分布是右偏的。我们可以通过直方图来直观确认这一点:
> hist(insurance$expenses)
输出结果如下所示:
正如预期的那样,图表显示了右偏的分布。它还表明,尽管分布的尾部远超这些峰值,但我们数据中的大多数人年医疗费用在零到$15,000 之间。虽然这个分布并不适合线性回归,但提前了解这一弱点有助于我们设计出更适合的模型。
在我们解决这个问题之前,还有另一个问题需要处理。回归模型要求每个特征都是数值型的,但我们在数据框中有三个因子型特征。例如,性别变量分为男性和女性两个级别,而吸烟者分为是和否。从summary()
输出中,我们知道region
变量有四个级别,但我们需要进一步查看它们的分布:
> table(insurance$region)
northeast northwest southeast southwest
324 325 364 325
在这里,我们看到数据几乎均匀地划分到四个地理区域中。稍后我们将看到 R 的线性回归函数如何处理这些因子变量。
探索特征之间的关系——相关矩阵
在拟合回归模型之前,确定自变量与因变量及相互之间的关系是很有帮助的。相关矩阵提供了这些关系的快速概览。给定一组变量,它为每一对关系提供相关性。
要为保险数据框中的四个数值变量创建相关矩阵,可以使用cor()
命令:
> cor(insurance[c("age", "bmi", "children", "expenses")])
age bmi children expenses
age 1.0000000 0.10934101 0.04246900 0.29900819
bmi 0.1093410 1.00000000 0.01264471 0.19857626
children 0.0424690 0.01264471 1.00000000 0.06799823
expenses 0.2990082 0.19857626 0.06799823 1.00000000
在每一行和列的交点处,会列出由该行和列所表示的变量的相关性。对角线上的值始终是1.0000000
,因为一个变量与它自身的相关性总是完美的。对角线上的值上下是对称的,因此相关性是对称的。换句话说,cor(x, y)
等于cor(y, x)
。
矩阵中的相关性都不算强,但有一些显著的关联。例如,age
和bmi
之间似乎存在弱正相关,意味着随着年龄的增长,体重通常会增加。age
与expenses
、bmi
与expenses
、以及children
与expenses
之间也有中等程度的正相关。这些关联意味着随着年龄、体重和孩子数量的增加,预期的保险费用也会上升。我们将在构建最终回归模型时更清晰地揭示这些关系。
可视化特征间的关系 – 散点图矩阵
使用散点图来可视化数值特征之间的关系也很有帮助。尽管我们可以为每一种可能的关系创建散点图,但对于大量特征来说,这样做可能会变得很繁琐。
另一种选择是创建一个散点图矩阵(有时简写为SPLOM),它仅仅是一个按网格排列的散点图集合。它用于检测三个或更多变量之间的模式。散点图矩阵并不是真正的多维可视化,因为每次仅检查两个特征。但它提供了一个大致的感知,显示数据可能的相互关系。
我们可以使用 R 的图形功能为四个数值特征(age
、bmi
、children
和expenses
)创建一个散点图矩阵。pairs()
函数在默认的 R 安装中提供,能够生成基本的散点图矩阵功能。要调用该函数,只需提供要展示的数据框。这里,我们将insurance
数据框限制为四个感兴趣的数值变量:
> pairs(insurance[c("age", "bmi", "children", "expenses")])
这会生成如下图表:
在散点图矩阵中,每一行和每一列的交点处展示的是由行和列所表示的变量的散点图。对角线以上和以下的图表是转置的,因为x轴和y轴已被交换。
你在这些图表中注意到任何模式吗?尽管有些看起来像是随机的点云,但有些似乎展示了一些趋势。age
与expenses
之间的关系显示了几条相对直的线,而bmi
与expenses
的图表则有两组明显不同的点。在其他图表中很难发现趋势。
如果我们在图表中添加更多信息,它将变得更加有用。可以使用psych
包中的pairs.panels()
函数创建一个增强版的散点图矩阵。如果你没有安装该包,请输入install.packages("psych")
进行安装,并通过library(psych)
命令加载它。然后,我们可以像之前那样创建一个散点图矩阵:
> pairs.panels(insurance[c("age", "bmi", "children", "expenses")])
这会生成一个稍微更具信息量的散点图矩阵,如下所示:
对角线以上的散点图已被相关矩阵替代。对角线上的直方图显示了每个特征值的分布情况。最后,对角线下方的散点图现在呈现了更多的视觉信息。
每个散点图上椭圆形的物体是一个相关椭圆。它提供了相关性强度的可视化。椭圆中心的点表示 x 和 y 轴变量的均值位置。变量之间的相关性通过椭圆的形状来表示;它越被拉伸,相关性越强。几乎完全圆形的椭圆,如 bmi
和 children
之间的关系,表明相关性非常弱(在这种情况下为 0.01)。
散点图上绘制的曲线称为局部加权回归曲线(loess curve)。它表示 x 和 y 轴变量之间的一般关系。通过示例最容易理解。age
和 children
的曲线呈倒 U 形,峰值出现在中年左右。这意味着样本中最年长和最年轻的人群在保险计划中有的孩子比中年人少。由于这一趋势是非线性的,仅凭相关性是无法得出这一结论的。另一方面,age
和 bmi
的 loess 曲线是逐渐向上的直线,表明随着年龄的增长,体重指数也在增加,但我们已经通过相关矩阵推断出这一点。
步骤 3 – 在数据上训练模型
要在 R 中拟合线性回归模型,可以使用 lm()
函数。该函数包含在 stats
包中,默认情况下应该随着 R 安装一起加载。lm()
的语法如下:
以下命令拟合一个线性回归模型,将六个自变量与总医疗费用相关联。R 的公式语法使用波浪号字符 ~
来描述模型;因变量 expenses
位于波浪号的左侧,而自变量位于右侧,并由 +
符号分隔。由于回归模型的截距项默认假定存在,因此无需明确指定:
> ins_model <- lm(expenses ~ age + children + bmi + sex +
smoker + region, data = insurance)
由于 .
字符可以用来指定所有特征(排除公式中已指定的特征),因此以下命令等同于之前的命令:
> ins_model <- lm(expenses ~ ., data = insurance)
构建模型后,只需输入模型对象的名称即可查看估计的贝塔系数:
> ins_model
Call:
lm(formula = expenses ~ ., data = insurance)
Coefficients:
(Intercept) age sexmale
-11941.6 256.8 -131.4
bmi children smokeryes
339.3 475.7 23847.5
regionnorthwest regionsoutheast regionsouthwest
-352.8 -1035.6 -959.3
理解回归系数是相当简单的。截距是当所有自变量为零时,expenses
的预测值。正如这里的情况,截距通常单独没有太大意义,因为所有特征不可能都有零值。例如,由于不存在年龄为零且 BMI 为零的人,因此截距没有实际意义。出于这个原因,在实践中,截距通常会被忽略。
贝塔系数表示每增加一个特征值,假设其他所有值保持不变,医疗费用的预估增加。例如,每增加一年年龄,平均预计医疗费用增加$256.80,假设其他条件不变。类似地,每增加一个孩子,平均每年医疗费用增加$475.70,而每增加一个单位的 BMI,平均每年医疗费用增加$339.30,其他条件不变。
你可能会注意到,尽管我们在模型公式中只指定了六个特征,但报告的系数除了截距之外还有八个。这是因为lm()
函数自动对我们在模型中包含的每个因子类型变量应用了一种称为虚拟编码的技术。
虚拟编码通过为特征的每个类别创建一个二元变量,通常称为虚拟 变量,将一个名义特征处理为数值型特征。如果观察值属于指定的类别,则虚拟变量为1
,否则为0
。例如,sex
特征有两个类别:male
和female
。这将被拆分为两个二元变量,R 分别命名为sexmale
和sexfemale
。对于sex = male
的观察值,sexmale = 1
且sexfemale = 0
;反之,如果sex = female
,则sexmale = 0
且sexfemale = 1
。对于具有三个或更多类别的变量也适用相同的编码方式。例如,R 将四类别特征region
拆分为四个虚拟变量:regionnorthwest
、regionsoutheast
、regionsouthwest
和regionnortheast
。
在向回归模型中添加虚拟变量时,始终会有一个类别被排除作为参考类别。然后,系数会相对于该参考类别进行解释。在我们的模型中,R 自动排除了sexfemale
、smokerno
和regionnortheast
变量,将女性非吸烟者所在的东北地区作为参考组。因此,相对于女性,每年男性的医疗费用少$131.40,而吸烟者每年比非吸烟者多花费$23,847.50。模型中每个三大地区的系数都是负数,这意味着参考组——东北地区的平均费用最高。
提示
默认情况下,R 使用因子变量的第一个级别作为参考。如果你希望使用其他级别,可以使用relevel()
函数手动指定参考组。欲了解更多信息,请在 R 中使用?relevel
命令。
线性回归模型的结果是合乎逻辑的:老年、吸烟和肥胖倾向于与额外的健康问题相关,而额外的家庭成员抚养负担可能导致医生就诊次数和预防性护理(如疫苗接种和年度体检)的增加。然而,我们目前并不清楚模型拟合数据的效果如何。我们将在下一节回答这个问题。
步骤 4 – 评估模型性能
我们通过输入ins_model
获得的参数估计值告诉我们自变量与因变量之间的关系,但它们并没有告诉我们模型与数据的拟合度。为了评估模型性能,我们可以对存储的模型使用summary()
命令:
> summary(ins_model)
这会产生以下输出。请注意,输出已经标注了说明性标签:
summary()
输出一开始可能让人感到困惑,但基本概念很容易掌握。如前输出中的编号标签所示,输出提供了评估模型性能或拟合度的三种关键方法:
-
残差部分提供了我们预测中错误的汇总统计信息,其中一些显然相当大。由于残差等于真实值减去预测值,因此最大误差为 29981.7,表明模型在至少一个观测值中低估了近$30,000 的支出。另一方面,50%的误差落在 1Q 和 3Q 值(第一四分位数和第三四分位数)之间,因此大多数预测结果介于真实值之上$2,850.90 和真实值之下$1,383.90 之间。
-
对于每个估计的回归系数,p 值,用
Pr(>|t|)
表示,提供了一个估算值,表示在给定估计值的情况下,真实系数为零的概率。小的 p 值表明真实系数非常不可能为零,这意味着该特征极不可能与因变量没有关系。请注意,某些 p 值有星号(***
),这些星号对应脚注,表示估计值所达到的显著性水平。这个水平是一个阈值,在建立模型之前选择,用来指示“真实”的发现,而不是仅仅由于偶然因素;小于显著性水平的 p 值被视为统计显著。如果模型中有很少这样的项,可能需要引起关注,因为这表明所使用的特征对结果的预测能力较弱。这里,我们的模型有多个高度显著的变量,而且它们似乎与结果在逻辑上相关。 -
多重 R 方值(也叫决定系数)提供了衡量我们模型整体解释因变量值的能力。它类似于相关系数,数值越接近 1.0,模型对数据的解释越完美。由于 R 方值为 0.7494,我们知道模型解释了因变量近 75% 的变化。由于具有更多特征的模型总是能解释更多变化,调整 R 方值通过对具有大量独立变量的模型进行惩罚来修正 R 方值。它对于比较具有不同数量解释变量的模型表现非常有用。
根据前述的三个性能指标,我们的模型表现得相当不错。回归模型在实际数据中往往会有较低的 R 方值,0.75 的值实际上已经相当不错。一些误差的大小有些令人担忧,但考虑到医疗费用数据的特性,这并不令人惊讶。然而,正如下一节所示,我们可能通过稍微不同的方式指定模型来提高模型的性能。
第 5 步 – 提高模型性能
如前所述,回归建模与其他机器学习方法的一个关键区别在于,回归通常将特征选择和模型指定留给用户。因此,如果我们了解某个特征与结果之间的关系,我们可以利用这些信息来指导模型的指定,从而可能提高模型的性能。
模型指定 – 添加非线性关系
在线性回归中,自变量与因变量之间的关系假定为线性,但这不一定为真。例如,年龄对医疗支出的影响可能在所有年龄值中并非恒定;对于最年长的群体,治疗可能变得不成比例地昂贵。
如果你记得的话,典型的回归方程通常类似于此:
为了考虑非线性关系,我们可以向回归模型中添加更高阶的项,将模型视为多项式。实际上,我们将模拟这样的关系:
这两个模型之间的区别在于将估计一个额外的贝塔系数,旨在捕捉 x 的平方项的影响。这使得年龄的影响可以作为年龄平方的函数来衡量。
要将非线性年龄加入模型,我们只需创建一个新的变量:
> insurance$age2 <- insurance$age²
然后,当我们生成我们改进的模型时,我们将使用expenses ~ age + age2
形式将age
和age2
都添加到lm()
公式中。这将使模型能够分离年龄对医疗支出的线性和非线性影响。
转换 - 将数值变量转换为二元指示器
假设我们有一种直觉,即某个特征的影响不是累积的,而是只有在达到特定阈值后才会产生影响。例如,BMI 对于正常体重范围内的个体可能没有医疗支出的影响,但对于肥胖者(即 BMI 达到或超过 30)可能与更高的费用密切相关。
我们可以通过创建一个二元肥胖指示变量来建模这种关系,如果 BMI 至少为 30,则为 1,否则为 0。然后,这个二元特征的估计β值将指示 BMI 达到或超过 30 的个体对医疗支出的平均净影响,相对于 BMI 低于 30 的个体。
要创建该特征,我们可以使用ifelse()
函数,该函数对向量中的每个元素测试指定的条件,并根据条件是真还是假返回一个值。对于 BMI 大于或等于 30,我们将返回1
,否则返回0
:
> insurance$bmi30 <- ifelse(insurance$bmi >= 30, 1, 0)
我们接下来可以在我们改进的模型中包含bmi30
变量,可以替换原来的bmi
变量或者同时加入,这取决于我们是否认为肥胖的影响会在单独的线性 BMI 效应之外发生。如果没有充分的理由做出改变,我们将在最终模型中都包含它们。
提示
如果您在决定是否包含一个变量时遇到困难,一个常见的做法是将其包含在内并检查 P 值。如果变量在统计上不显著,您有理由在将来排除它。
模型规范化 - 添加交互效应
到目前为止,我们只考虑了每个特征对结果的个体贡献。如果某些特征共同影响因变量呢?例如,吸烟和肥胖可能分别具有有害效应,但合并效应可能比单独每个因素的总和更糟糕是合理的假设。
当两个特征具有联合效应时,这被称为交互作用。如果我们怀疑两个变量之间存在交互作用,我们可以通过将它们的交互项添加到模型中来测试这一假设。交互效应使用 R 公式语法指定。为了让肥胖指示器(bmi30
)和吸烟指示器(smoker
)交互,我们会编写如下形式的公式:expenses ~ bmi30*smoker
。
*
运算符是一个快捷方式,指示 R 模型为expenses ~ bmi30 + smokeryes + bmi30:smokeryes
。在扩展形式中,冒号:
运算符表示bmi30:smokeryes
是两个变量之间的交互作用。请注意,扩展形式还自动包括了bmi30
和smoker
变量以及交互作用。
提示
交互项在模型中绝不能单独包含,而不添加每个交互变量。如果你总是使用*
运算符来创建交互作用,那么这个问题就不会出现,因为 R 会自动添加所需的组件。
综合起来——一个改进的回归模型
基于对医学费用可能与患者特征相关的一些主题知识,我们开发了我们认为更准确的回归公式。总结改进的内容,我们:
-
为年龄添加了一个非线性项
-
创建了一个用于肥胖的指标
-
指定了肥胖与吸烟之间的交互作用
我们将像之前一样使用lm()
函数来训练模型,但这次我们将添加新构建的变量和交互项:
> ins_model2 <- lm(expenses ~ age + age2 + children + bmi + sex +
bmi30*smoker + region, data = insurance)
接下来,我们总结结果:
> summary(ins_model2)
输出如下所示:
模型拟合统计量有助于确定我们的修改是否提高了回归模型的性能。与我们的第一个模型相比,R 平方值从 0.75 提高到了大约 0.87。同样,考虑到模型复杂度增加的调整后 R 平方值也从 0.75 提高到了 0.87。我们的模型现在解释了 87%的医疗费用变异。此外,我们关于模型功能形式的理论似乎得到了验证。高阶age2
项是统计显著的,肥胖指标bmi30
也是如此。肥胖与吸烟的交互作用表明有巨大的影响;除了吸烟本身导致的超过$13,404 的费用外,肥胖吸烟者每年还要额外花费$19,810。这可能表明吸烟加剧了与肥胖相关的疾病。
注意
严格来说,回归建模对数据做出了一些强假设。这些假设对于数值预测来说并不那么重要,因为模型的价值并不依赖于它是否真正捕捉到了潜在的过程——我们关心的是其预测的准确性。然而,如果你希望从回归模型系数中得出明确的推论,就有必要进行诊断测试,确保回归假设没有被违反。关于这一主题的优秀介绍,参见 Allison PD《多元回归:基础》,Pine Forge Press,1998 年。
理解回归树和模型树
如果你记得第五章,分而治之 – 使用决策树和规则进行分类,决策树构建的模型就像一个流程图,其中决策节点、叶节点和分支定义了一系列决策,用于分类示例。这样的树也可以通过对树生长算法进行小的调整,用于数值预测。在本节中,我们只会讨论用于数值预测的树与用于分类的树的不同之处。
用于数值预测的树分为两类。第一类称为回归树,它们在 1980 年代作为开创性的分类与回归树(CART)算法的一部分被引入。尽管名称中有回归二字,回归树并没有使用本章前面描述的线性回归方法,而是基于达到叶节点的示例的平均值来进行预测。
注意
CART 算法在 Breiman L, Friedman JH, Stone CJ, Olshen RA 的《分类与回归树》一书中有详细描述。Belmont, CA: Chapman and Hall; 1984 年。
第二类用于数值预测的树被称为模型树。它们比回归树晚几年引入,虽然不太为人所知,但可能更强大。模型树的生长方式与回归树非常相似,但在每个叶节点上,会基于到达该节点的示例构建一个多元线性回归模型。根据叶节点的数量,模型树可能会构建几十个甚至几百个这样的模型。这可能使得模型树比等效的回归树更难理解,但好处是,它们可能会产生更精确的模型。
注意
最早的模型树算法,M5,在 Quinlan JR 的《使用连续类别进行学习》中有描述。1992 年,澳大利亚第五届联合人工智能会议论文集:343-348。
向树中添加回归
可以执行数值预测的树提供了一种引人注目但常被忽视的回归建模替代方案。回归树和模型树相对于更常见的回归方法的优缺点在下表中列出:
优点 | 缺点 |
---|
|
-
结合了决策树的优点和建模数值数据的能力
-
无需用户提前指定模型
-
使用自动特征选择,允许该方法应用于非常大量的特征
-
可能比线性回归更适合某些类型的数据
-
不需要统计学知识来解释模型
|
-
不像线性回归那样知名
-
需要大量的训练数据
-
难以确定单个特征对结果的整体净影响
-
大型树可能比回归模型更难以解释
|
虽然传统的回归方法通常是数值预测任务的首选,但在某些情况下,数值决策树提供了明显的优势。例如,决策树可能更适合具有许多特征或特征与结果之间存在许多复杂的非线性关系的任务。这些情况对回归造成了挑战。回归建模还对数值数据的分布方式做出了假设,而这些假设在现实世界的数据中经常被违反。而这种情况在树中并非如此。
用于数值预测的树的构建方式与分类中的方式基本相同。从根节点开始,根据能够在拆分后最大增加结果均匀性的特征使用分治策略对数据进行分区。在分类树中,您会记得均匀性是通过熵来衡量的,但对于数值数据,熵是未定义的。相反,对于数值决策树,均匀性是通过方差、标准偏差或与均值的绝对偏差等统计量来衡量的。
一种常见的拆分标准称为标准偏差减少(SDR)。它由以下公式定义:
在这个公式中,sd(T)函数指的是集合T中值的标准偏差,而T[1]、T[2]、...、T[n]是由特征拆分产生的值集合。|T|项指的是集合T中的观测数量。本质上,该公式通过比较拆分前的标准偏差与加权后的标准偏差来衡量标准偏差的减少。
例如,考虑以下情况,树正在决定是否在二进制特征 A 或 B 上执行拆分:
使用提议的拆分结果得到的组,我们可以计算如下的 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]*中的。然后,模型树可以使用这两个线性模型中的任何一个为新示例做出预测。
为了进一步说明这两种方法之间的差异,让我们通过一个现实世界的例子来说明。
示例 – 使用回归树和模型树估计葡萄酒的质量
酿酒业是一个具有挑战性且竞争激烈的行业,具有巨大的盈利潜力。然而,葡萄酒厂的盈利能力受到多种因素的影响。作为一种农产品,天气和生长环境等变量会影响葡萄酒的质量。瓶装和生产过程也会对味道产生影响,可能是更好也可能是更差。即便是产品的营销方式,从瓶子设计到定价,也会影响消费者对味道的认知。
因此,酿酒行业在数据收集和机器学习方法上的投资非常巨大,这些方法有助于酿酒决策科学。例如,机器学习已经被用于发现不同地区葡萄酒化学成分的关键差异,或者识别导致葡萄酒味道更甜的化学因素。
最近,机器学习已被用来协助葡萄酒质量的评分——这是一项著名的困难任务。著名酒评家的评论往往决定了产品最终是上架还是下架,尽管即使是专家在盲测中评分时也常常表现出不一致性。
在这个案例研究中,我们将使用回归树和模型树创建一个系统,能够模仿专家对葡萄酒的评分。由于树结构生成的模型易于理解,这可以帮助酿酒师识别出对高评分葡萄酒至关重要的因素。或许更重要的是,该系统不受品酒时人类因素的影响,比如评分者的情绪或味觉疲劳。因此,计算机辅助的葡萄酒测试可能会带来更好的产品,并且评分更加客观、一致和公平。
步骤 1 – 收集数据
为了开发葡萄酒评级模型,我们将使用 P. Cortez, A. Cerdeira, F. Almeida, T. Matos 和 J. Reis 捐赠给 UCI 机器学习数据库的数据(archive.ics.uci.edu/ml
)。这些数据包括来自葡萄牙的红葡萄酒和白葡萄酒样本—葡萄牙是世界领先的葡萄酒生产国之一。由于影响葡萄酒高评分的因素可能在红葡萄酒和白葡萄酒之间有所不同,因此在本次分析中,我们只会考察更受欢迎的白葡萄酒。
提示
要跟随本示例,请从 Packt Publishing 网站下载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")
葡萄酒数据包括 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 0.12 ...
$ 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)
这将生成以下图形:
酒的质量值似乎遵循一个相当正常的钟形分布,集中在六的值附近。这是直观合理的,因为大多数酒的质量是中等的;很少有酒特别差或者特别好。尽管这里没有展示结果,但检查 summary(wine)
输出中的异常值或其他潜在的数据问题也是很有用的。尽管树模型对杂乱数据相当稳健,但检查严重问题总是明智的。现在,我们假设数据是可靠的。
我们的最后一步是将数据划分为训练集和测试集。由于 wine
数据集已经是随机排序的,我们可以按照以下方式将其分成两组连续的行:
> wine_train <- wine[1:3750, ]
> wine_test <- wine[3751:4898, ]
为了与 Cortez 使用的条件相符,我们分别使用了 75% 和 25% 的数据集进行训练和测试。我们将评估基于树的模型在测试数据上的表现,以查看我们是否能获得与先前研究相当的结果。
第三步 – 在数据上训练模型
我们将首先训练一棵回归树模型。尽管几乎任何决策树的实现都可以用于回归树建模,但 rpart
(递归分区)包提供了最忠实的回归树实现,正如 CART 团队所描述的那样。作为 CART 的经典 R 实现,rpart
包也有很好的文档支持,并提供了可视化和评估 rpart
模型的函数。
使用 install.packages("rpart")
命令安装 rpart
包。然后可以使用 library(rpart)
命令将其加载到 R 会话中。以下语法将使用默认设置训练一棵树,这些设置通常工作得相当好。如果需要更精细的设置,请使用 ?rpart.control
命令查看控制参数的文档。
使用 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
。由于酒精在树中首先被使用,它是预测葡萄酒质量的最重要因素。
由*
指示的节点是终端节点或叶节点,这意味着它们会产生预测结果(在此列为yval
)。例如,节点 5 的yval
为 5.971091。当使用该树进行预测时,任何酒精含量alcohol < 10.85
且挥发性酸度volatile.acidity < 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)
生成的树形图如下所示:
除了控制图中数字位数的digits
参数外,许多可视化的其他方面也可以进行调整。以下命令展示了几种有用的选项:fallen.leaves
参数强制叶节点对齐到图的底部,而type
和extra
参数则影响决策和节点的标签方式:
> rpart.plot(m.rpart, digits = 4, fallen.leaves = TRUE,
type = 3, extra = 101)
这些更改的结果是一个外观截然不同的树形图:
像这样的可视化图可能有助于回归树结果的传播,因为即使没有数学背景的人也能轻松理解。在这两种情况下,叶节点中显示的数字是到达该节点的示例的预测值。因此,将图表展示给葡萄酒生产者,可能有助于确定预测高评分葡萄酒的关键因素。
第 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。在从零到 10 的质量评分尺度上,这似乎表明我们的模型表现相当不错。
另一方面,请记住,大多数葡萄酒既不特别好也不特别差;典型的质量评分大约在五到六之间。因此,仅仅预测平均值的分类器根据这个标准仍然可能表现得相当好。
训练数据中的平均质量评分如下:
> mean(wine_train$quality)
[1] 5.870933
如果我们对每个葡萄酒样本都预测值为 5.87,我们的平均绝对误差将仅约为 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 – 改进模型性能
为了提高学习器的性能,我们可以尝试构建一个模型树。回想一下,模型树通过将叶节点替换为回归模型,从而改进了回归树。这通常会比回归树产生更准确的结果,因为回归树在叶节点使用的只是一个单一的预测值。
当前模型树的最新技术是M5'算法(M5-prime),由 Y. Wang 和 I.H. Witten 提出,它是 J.R. Quinlan 于 1992 年提出的原始 M5 模型树算法的变种。
注意
欲了解更多有关 M5'算法的信息,请参阅 Wang Y, Witten IH. 用于预测连续类别的模型树诱导。欧洲机器学习会议海报论文集,1997 年。
M5 算法可以通过 R 中的RWeka
包和M5P()
函数来实现。该函数的语法如下表所示。如果你还没有安装RWeka
包,请确保先安装。由于它依赖于 Java,安装说明已包含在第一章中,引入机器学习。
我们将使用与回归树相同的语法来拟合模型树:
> library(RWeka)
> m.m5p <- M5P(quality ~ ., data = wine_train)
可以通过输入树的名称来检查树本身。在这种情况下,树非常大,只有前几行输出会显示出来:
> m.m5p
M5 pruned model tree:
(using smoothed linear models)
alcohol <= 10.85 :
| volatile.acidity <= 0.238 :
| | fixed.acidity <= 6.85 : LM1 (406/66.024%)
| | fixed.acidity > 6.85 :
| | | free.sulfur.dioxide <= 24.5 : LM2 (113/87.697%)
你会注意到,这些分裂与我们之前构建的回归树非常相似。酒精是最重要的变量,其次是挥发性酸度和游离二氧化硫。然而,一个关键的区别是,节点的终止不是数值预测,而是一个线性模型(在此显示为LM1
和LM2
)。
线性模型本身会在输出中显示。例如,LM1
的模型将在接下来的输出中显示。这些值的解释方式与我们在本章前面构建的多重回归模型完全相同。每个数字表示相关特征对预测酒质的净效应。固定酸度的系数为0.266
,意味着酸度增加 1 单位时,预计酒质将增加0.266
:
LM num: 1
quality =
0.266 * fixed.acidity
- 2.3082 * volatile.acidity
- 0.012 * citric.acid
+ 0.0421 * residual.sugar
+ 0.1126 * chlorides
+ 0 * free.sulfur.dioxide
- 0.0015 * total.sulfur.dioxide
- 109.8813 * density
+ 0.035 * pH
+ 1.4122 * sulphates
- 0.0046 * alcohol
+ 113.1021
需要注意的是,由LM1
估算的效果仅适用于达到该节点的酒样;在这个模型树中总共构建了 36 个线性模型,每个模型都对固定酸度和其他 10 个特征的影响进行了不同的估算。
若要查看模型拟合训练数据的统计信息,可以对 M5P 模型应用summary()
函数。然而,请注意,由于这些统计信息是基于训练数据的,因此它们仅应作为粗略的诊断依据:
> summary(m.m5p)
=== Summary ===
Correlation coefficient 0.6666
Mean absolute error 0.5151
Root mean squared error 0.6614
Relative absolute error 76.4921 %
Root relative squared error 74.6259 %
Total Number of Instances 3750
相反,我们将查看模型在未见过的测试数据上的表现。predict()
函数为我们提供了一组预测值:
> p.m5p <- predict(m.m5p, wine_test)
模型树似乎预测了比回归树更广泛的值范围:
> summary(p.m5p)
Min. 1st Qu. Median Mean 3rd Qu. Max.
4.389 5.430 5.863 5.874 6.305 7.437
相关性似乎也显著更高:
> cor(p.m5p, wine_test$quality)
[1] 0.6272973
此外,该模型略微减少了平均绝对误差:
> MAE(wine_test$quality, p.m5p)
[1] 0.5463023
尽管我们在回归树的基础上并没有取得太大进展,但我们超越了 Cortez 发布的神经网络模型的表现,并且我们逐渐接近支持向量机模型发布的平均绝对误差值 0.45,所有这些都是通过使用一种更简单的学习方法实现的。
提示
毫不意外,我们确认预测葡萄酒质量是一个困难的问题;毕竟,葡萄酒品鉴本质上是主观的。如果你想要额外练习,可以在阅读第十一章,提高模型性能后再次尝试解决这个问题,里面涵盖了一些可能带来更好结果的技术。
总结
在本章中,我们研究了两种建模数值数据的方法。第一种方法是线性回归,它涉及将直线拟合到数据上。第二种方法使用决策树进行数值预测。后者有两种形式:回归树,它通过叶节点处示例的平均值来进行数值预测;以及模型树,它在每个叶节点构建一个回归模型,这是一种混合方法,在某些方面是两者的最佳结合。
我们使用线性回归模型来计算不同人口群体的预期医疗费用。由于特征与目标变量之间的关系通过估算的回归模型得到了很好的描述,我们能够识别出某些人群,如吸烟者和肥胖者,可能需要被收取更高的保险费率,以覆盖高于平均水平的医疗费用。
回归树和模型树被用来根据可衡量的特征对葡萄酒的主观质量进行建模。在此过程中,我们了解到回归树提供了一种简单的方法来解释特征与数值结果之间的关系,但更复杂的模型树可能更为准确。在这个过程中,我们学习了几种评估数值模型表现的方法。
与本章涉及的机器学习方法(这些方法能够清晰地理解输入与输出之间的关系)形成鲜明对比的是,下章将介绍一些导致近乎难以理解的模型的方法。其优点是,这些方法是极为强大的技术——是最强大的股票分类器之一——可以应用于分类和数值预测问题。
第七章:黑箱方法——神经网络与支持向量机
已故科幻作家阿瑟·C·克拉克曾写道:“任何足够先进的技术都无法与魔法区分开。”本章涵盖了一对初看起来像魔法的机器学习方法。尽管它们极其强大,但其内部原理可能难以理解。
在工程学中,这些被称为黑箱过程,因为将输入转化为输出的机制被一个虚拟的盒子所遮掩。例如,闭源软件的黑箱故意隐藏专有算法,政治立法的黑箱根植于官僚流程,而香肠制造的黑箱则涉及一些故意的(但美味的)无知。在机器学习的情况下,黑箱则源于其运作所依赖的复杂数学。
虽然它们可能不易理解,但盲目应用黑箱模型是危险的。因此,在本章中,我们将一窥黑箱内部,并调查拟合此类模型所涉及的统计香肠制作过程。你将发现:
-
神经网络模仿动物大脑的结构来模拟任意函数
-
支持向量机使用多维表面来定义特征与结果之间的关系
-
尽管它们的复杂性,依然可以轻松应用于现实世界问题
如果幸运的话,你会意识到,解决黑箱机器学习方法并不需要统计学的黑带——完全没有必要感到畏惧!
了解神经网络
人工神经网络(ANN)使用一种基于我们对生物大脑如何响应来自感官输入的刺激的理解所衍生的模型,来模拟输入信号与输出信号之间的关系。就像大脑通过一组叫做神经元的相互连接的细胞构建一个巨大的并行处理器,ANN 则利用一组人工神经元或节点来解决学习问题。
人脑由约 850 亿个神经元组成,形成了一个能够表示大量知识的网络。正如你所预料的,这一数量远远超过其他生物的脑量。例如,一只猫大约有 10 亿个神经元,一只老鼠大约有 7500 万个神经元,而一只蟑螂只有大约 100 万个神经元。相比之下,许多 ANN 包含的神经元要少得多,通常只有几百个,因此我们目前离制造人工大脑还远——即便是一个拥有 10 万个神经元的果蝇大脑,也远远超出了现有的最先进 ANN 的能力。
虽然完全模拟一只蟑螂的大脑可能不可行,但神经网络仍然可以提供一个足够的启发式模型来模拟其行为。假设我们开发了一个算法,能够模仿蟑螂在被发现时逃跑的反应。如果机器人蟑螂的行为让人信服,那么它的大脑是否与活体生物一样复杂重要吗?这个问题正是有争议的图灵测试的基础。图灵测试由开创性计算机科学家艾伦·图灵于 1950 年提出,旨在通过判断一个人类是否无法将机器的行为与活体生物区分开,来评定机器是否具备智能。
基本的人工神经网络(ANNs)已经被使用超过 50 年,用来模拟大脑解决问题的方法。最初,这包括学习简单的函数,比如逻辑与(AND)函数或逻辑或(OR)函数。这些早期的练习主要是为了帮助科学家理解生物大脑如何运作。然而,随着近年来计算机性能的不断增强,人工神经网络的复杂性也大幅增加,以至于现在它们常常被应用于更实际的问题,包括:
-
语音和手写识别程序,例如语音邮件转录服务和邮政邮件分拣机所使用的技术
-
智能设备的自动化,如办公楼的环境控制、自动驾驶汽车和自驾无人机
-
精密的天气和气候模式、抗拉强度、流体动力学以及许多其他科学、社会或经济现象的模型
广义来说,人工神经网络是多才多艺的学习者,几乎可以应用于任何学习任务:分类、数值预测,甚至是无监督的模式识别。
提示
无论是否值得,人工神经网络学习器常常在媒体中被大肆宣传。例如,谷歌最近开发的“人工大脑”因其能够识别 YouTube 上的猫视频而受到推崇。这种炒作可能与人工神经网络的独特性关系不大,而更多地与人工神经网络因其与活体思维的相似性而引人入胜有关。
人工神经网络最适用于那些输入数据和输出数据定义明确或至少相对简单,但输入与输出之间的过程极其复杂的问题。作为一种黑箱方法,它们在这类黑箱问题中表现优异。
从生物神经元到人工神经元
因为人工神经网络(ANNs)是故意设计为人类大脑活动的概念模型,因此首先理解生物神经元的功能是很有帮助的。如以下图所示,细胞的树突通过生化过程接收传入的信号。这个过程使得冲动信号可以根据其相对重要性或频率进行加权。当细胞体开始积累传入的信号时,会达到一个阈值,在此阈值下细胞会激发,并通过电化学过程将输出信号沿着轴突传递。在轴突的末端,电信号再次被处理为化学信号,并通过一个被称为突触的小间隙传递给相邻的神经元。
单个人工神经元的模型可以用非常类似于生物模型的方式理解。如以下图所示,一个有向网络图定义了输入信号(由树突接收的 x 变量)和输出信号(y 变量)之间的关系。就像生物神经元一样,每个树突的信号根据其重要性被加权(w 值)——暂时忽略这些权重是如何确定的。输入信号被细胞体汇总,并根据激活函数 f 传递信号:
一个典型的人工神经元有n个输入树突,可以通过以下公式表示。w 权重使得每个n个输入(用x[i]表示)能够对输入信号的总和贡献更多或更少。总和会被激活函数 f(x) 使用,结果信号 y(x) 就是输出轴突的信号:
神经网络使用这样定义的神经元作为构建块来构建复杂的数据模型。尽管神经网络有许多变种,但每个变种都可以通过以下特征来定义:
-
激活函数,它将神经元的综合输入信号转换为一个单一的输出信号,然后将其进一步广播到网络中
-
网络拓扑(或架构),它描述了模型中神经元的数量、层数以及它们如何连接的方式
-
训练算法,它指定了如何设置连接权重,以便根据输入信号来抑制或激发神经元
让我们看一下每个类别内的一些变化,看看它们如何被用来构建典型的神经网络模型。
激活函数
激活函数是人工神经元处理传入信息并将其传递到整个网络的机制。就像人工神经元是基于生物神经元模型的,激活函数也是基于自然界的设计模型的。
在生物学的情况下,激活函数可以被想象为一个过程,涉及将所有输入信号加总并确定是否达到触发阈值。如果是,神经元将传递信号;否则,它什么也不做。在人工神经网络(ANN)术语中,这被称为阈值激活函数,因为它只有在达到指定的输入阈值后才会产生输出信号。
以下图显示了一个典型的阈值函数;在这种情况下,当输入信号的总和至少为零时,神经元触发。因为它的形状类似楼梯,有时被称为单位阶跃激活函数。
尽管阈值激活函数因其与生物学的相似性而具有趣味性,但在人工神经网络中很少使用。摆脱了生物化学的限制,ANN 的激活函数可以根据它们展示期望的数学特性和准确建模数据之间关系的能力来选择。
或许最常用的替代函数是sigmoid 激活函数(更具体来说,是logistic sigmoid),如以下图所示。请注意,在公式中,e是自然对数的底数(约为 2.72)。尽管它与阈值激活函数共享类似的阶梯或“S”形状,但输出信号不再是二进制的;输出值可以落在 0 到 1 的范围内。此外,sigmoid 是可微分的,这意味着可以计算整个输入范围内的导数。如你稍后所学,这一特性对于创建高效的 ANN 优化算法至关重要。
尽管 sigmoid 可能是最常用的激活函数,且通常作为默认选项使用,但一些神经网络算法允许选择其他替代函数。以下图展示了这种激活函数的选择:
区分这些激活函数的主要细节是输出信号的范围。通常,这个范围是(0,1)、(-1,+1)或(-∞,+∞)。激活函数的选择会影响神经网络的偏置,使其能够更适合某些类型的数据,从而构建专门的神经网络。例如,线性激活函数会使神经网络非常类似于线性回归模型,而高斯激活函数则会导致一个称为径向基函数(RBF)网络的模型。每个模型都有其更适合某些学习任务的优点。
重要的是要认识到,对于许多激活函数来说,影响输出信号的输入值范围相对较窄。例如,在 Sigmoid 函数的情况下,当输入信号低于-5或高于+5时,输出信号始终接近 0 或 1。这种信号压缩会导致在高低端出现饱和信号,就像将吉他放大器音量调得过高导致声音失真一样,因为音波的峰值被削波。由于这会将输入值压缩到更小的输出范围,像 Sigmoid 这样的激活函数有时被称为压缩函数。
解决压缩问题的方法是对所有神经网络输入进行变换,使得特征的值落在接近 0 的一个小范围内。通常,这涉及对特征进行标准化或归一化。通过限制输入值的范围,激活函数将在整个范围内起作用,从而防止像家庭收入这样的高值特征主导像家庭中孩子数量这样的低值特征。一个额外的好处是,模型的训练速度可能更快,因为算法可以更快速地在有效的输入值范围内进行迭代。
提示
尽管理论上神经网络可以通过多次迭代调整其权重来适应非常动态的特征,但在极端情况下,许多算法会在此之前停止迭代。如果你的模型预测结果不合理,请仔细检查是否正确标准化了输入数据。
网络拓扑
神经网络的学习能力根植于其拓扑结构,即互联神经元的模式和结构。尽管网络架构形式多种多样,但它们可以通过三个关键特征来区分:
-
层数
-
网络中的信息是否允许反向传播
-
网络中每一层的节点数量
拓扑结构决定了网络能够学习的任务复杂性。通常,更大且更复杂的网络能够识别更微妙的模式和复杂的决策边界。然而,网络的能力不仅取决于网络的大小,还与单元的排列方式有关。
层数
为了定义拓扑结构,我们需要一种术语来区分基于其在网络中位置的人工神经元。接下来的图示例了一个非常简单网络的拓扑结构。一组被称为输入节点的神经元直接接收来自输入数据的未处理信号。每个输入节点负责处理数据集中一个单独的特征;该特征的值将通过对应节点的激活函数进行转换。输入节点发送的信号被输出节点接收,输出节点利用自己的激活函数生成最终预测(此处表示为p)。
输入节点和输出节点按层的形式分组排列。由于输入节点以接收到的数据的原始形式进行处理,因此网络只有一组连接权重(此处标记为w[1],w[2],和w[3])。因此,它被称为单层网络。单层网络可以用于基本的模式分类,尤其是对于线性可分的模式,但大多数学习任务需要更复杂的网络。
正如你可能预期的那样,创建更复杂网络的一种明显方法是通过增加额外的层。如图所示,多层网络增加了一个或多个隐藏层,它们在信号到达输出节点之前处理来自输入节点的信号。大多数多层网络是全连接的,这意味着一个层中的每个节点都与下一层中的每个节点相连,但这并不是必须的。
信息流动的方向
你可能已经注意到,在之前的例子中,箭头用来表示信号仅朝一个方向流动。那些输入信号从一个连接到另一个连接,持续朝一个方向传输直到到达输出层的网络被称为前馈网络。
尽管存在信息流动的限制,前馈网络仍然提供了令人惊讶的灵活性。例如,可以改变每一层的节点数和层数,可以同时建模多个结果,或可以应用多个隐藏层。具有多个隐藏层的神经网络被称为深度神经网络(DNN),而训练这种网络的实践有时被称为深度学习。
相比之下,递归网络(或反馈网络)允许信号通过循环在两个方向上传播。这一特性,更加接近生物神经网络的工作方式,使得学习极为复杂的模式成为可能。加入短期记忆或延迟,极大增强了递归网络的能力。特别是,这使得网络能够理解一段时间内的事件序列。这可以用于股市预测、语音理解或天气预报等应用。一个简单的递归网络如下所示:
尽管具有潜力,递归网络仍然主要是理论性的,实际中很少使用。另一方面,前馈网络已广泛应用于现实问题中。实际上,多层前馈网络,有时被称为多层感知器(MLP),是事实上的标准人工神经网络拓扑结构。如果有人提到他们正在拟合神经网络,那么他们很可能是在指 MLP。
每层的节点数量
除了层数和信息传递方向的变化外,神经网络的复杂性还可以通过每层的节点数量来变化。输入节点的数量由输入数据中的特征数量预先确定。类似地,输出节点的数量由要建模的结果数量或结果中的类别层次数预先确定。然而,隐藏层节点的数量则由用户在训练模型之前决定。
不幸的是,没有可靠的规则来确定隐藏层中的神经元数量。合适的数量取决于输入节点的数量、训练数据的数量、噪声数据的数量以及学习任务的复杂性等许多因素。
一般来说,具有更多网络连接的复杂网络拓扑结构可以学习更复杂的问题。更多的神经元将导致一个更贴近训练数据的模型,但这也带来了过拟合的风险;它可能在未来数据上表现不佳。大型神经网络还可能在计算上非常昂贵,并且训练速度较慢。
最佳做法是使用最少的节点,以在验证数据集上获得足够的性能。在大多数情况下,即使只有少量的隐藏节点——通常只有几个——神经网络也能提供极大的学习能力。
提示
已经证明,至少具有一个隐藏层且神经元足够的神经网络是通用函数逼近器。这意味着神经网络可以用于在有限区间内,以任意精度逼近任何连续函数。
使用反向传播训练神经网络
网络拓扑结构本身是空白的,尚未学习到任何内容。就像一个新生儿,它必须通过经验来训练。当神经网络处理输入数据时,神经元之间的连接会被加强或削弱,类似于婴儿大脑在经历环境时的发育过程。网络的连接权重会根据观察到的模式随时间调整。
通过调整连接权重训练神经网络非常耗费计算资源。因此,尽管人工神经网络(ANNs)在此之前已被研究了几十年,但直到 1980 年代中后期,当一种高效的 ANN 训练方法被发现时,ANN 才被应用于现实世界的学习任务。这种算法通过反向传播误差的策略训练网络,现在通常被称为反向传播(backpropagation)。
注意
巧合的是,多个研究团队在大致相同的时间独立地发现并发布了反向传播算法。其中,最常被引用的工作之一是:Rumelhart DE, Hinton GE, Williams RJ. 通过反向传播误差学习表示。自然杂志,1986 年;323:533-566。
尽管相对于许多其他机器学习算法仍然非常缓慢,反向传播方法却促使了对人工神经网络(ANNs)的重新关注。因此,使用反向传播算法的多层前馈网络现在在数据挖掘领域中非常常见。这些模型具有以下优点和缺点:
优势 | 劣势 |
---|
|
-
可以适用于分类或数值预测问题
-
能够建模比几乎任何其他算法更复杂的模式
-
对数据的潜在关系假设较少
|
-
计算量极大且训练速度慢,特别是当网络拓扑结构复杂时
-
非常容易导致过拟合训练数据
-
结果是一个复杂的黑箱模型,难以理解,甚至不可能理解。
|
在其最一般的形式中,反向传播算法通过两个过程的多个循环进行迭代。每个循环被称为一个周期(epoch)。因为网络不包含任何先验(已有的)知识,所以起始权重通常是随机设置的。然后,算法在两个过程中迭代,直到达到停止标准。反向传播算法中的每个周期包括:
-
一个前向阶段,在该阶段,神经元从输入层到输出层依次激活,并在过程中应用每个神经元的权重和激活函数。当达到最后一层时,会生成一个输出信号。
-
一个后向阶段,在该阶段,网络在前向阶段产生的输出信号与训练数据中的真实目标值进行比较。网络输出信号与真实值之间的差异导致一个误差,该误差向后传播至网络,以修改神经元之间的连接权重,减少未来的误差。
随着时间的推移,网络会利用向后传播的信息来减少网络的总误差。然而,仍然有一个问题:由于每个神经元的输入和输出之间的关系是复杂的,算法如何确定权重应变化多少?这个问题的答案涉及到一个名为梯度下降的技术。从概念上讲,它的工作原理类似于被困在丛林中的探险者如何找到通往水源的路径。通过检查地形并不断朝着最大下坡的方向前进,探险者最终会到达最低的山谷,这里很可能就是河床。
在类似的过程中,反向传播算法使用每个神经元激活函数的导数来识别各输入权重方向上的梯度——因此,具有可微分激活函数非常重要。梯度指示了权重变化时,误差会如何陡峭地增加或减少。该算法将尝试通过一个称为学习率的量来改变权重,以实现误差的最大减少。学习率越大,算法越快地沿着梯度下降,这可以减少训练时间,但也可能有超越最低点的风险。
尽管这个过程看起来复杂,但在实际应用中却很容易操作。我们将运用对多层前馈网络的理解来解决一个现实问题。
示例 – 使用 ANNs 建模混凝土强度
在工程领域,准确估算建筑材料的性能至关重要。这些估算对于制定建筑物、桥梁和道路施工材料的安全指南是必要的。
估算混凝土的强度是一个特别具有挑战性的任务。尽管混凝土几乎在每个建筑项目中都有使用,但其性能因多种不同的成分以及复杂的相互作用而存在很大差异。因此,很难准确预测最终产品的强度。一个能够根据输入材料的组成来可靠预测混凝土强度的模型,可能会导致更安全的建筑实践。
第一步 – 收集数据
对于本分析,我们将使用由叶怡诚提供给 UCI 机器学习数据集库的混凝土抗压强度数据(archive.ics.uci.edu/ml
)。由于他成功地利用神经网络建模这些数据,我们也将尝试使用简单的神经网络模型在 R 中复制他的工作。
注意
欲了解叶氏方法在此学习任务中的应用,请参阅:叶怡诚. 使用人工神经网络建模高性能混凝土的强度. 水泥与混凝土研究. 1998; 28:1797-1808.
根据网站信息,混凝土数据集包含 1,030 个混凝土样本,这些样本有八个特征,描述了混合物中所用成分的情况。这些特征被认为与最终的抗压强度有关,包含了水泥、矿渣、粉煤灰、水、减水剂、粗骨料和细骨料的数量(单位为千克每立方米),以及养护时间(以天为单位)。
提示
要跟随这个示例,请从 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()
函数进行标准化可能是有意义的。另一方面,如果数据呈均匀分布或严重偏离正态分布,则可能更适合将数据归一化到 0-1 范围。在这种情况下,我们将使用后者。
在第三章,懒学习——使用最近邻进行分类中,我们定义了自己的normalize()
函数,如下所示:
> normalize <- function(x) {
return((x - min(x)) / (max(x) - min(x)))
}
执行此代码后,我们的normalize()
函数可以使用lapply()
函数应用于混凝土数据框的每一列,如下所示:
> concrete_norm <- as.data.frame(lapply(concrete, normalize))
为了确认归一化操作已生效,我们可以看到,最小强度和最大强度现在分别为 0 和 1:
> 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.44 35.82 46.14 82.60
提示
在训练模型之前对数据应用的任何转换,之后都必须逆向应用,以便转换回原始的计量单位。为了方便重新缩放,最好保存原始数据,或者至少保存原始数据的汇总统计信息。
根据 Yeh 在原始出版物中的做法,我们将数据分为训练集和测试集,训练集占 75%的样本,测试集占 25%。我们使用的 CSV 文件已经是随机排序的,因此我们只需要将其分成两部分:
> concrete_train <- concrete_norm[1:773, ]
> concrete_test <- concrete_norm[774:1030, ]
我们将使用训练数据集来构建神经网络,并使用测试数据集评估模型如何将结果推广到未来。由于神经网络容易发生过拟合,因此这个步骤非常重要。
第三步 – 在数据上训练模型
为了建模混凝土中所用原料与成品强度之间的关系,我们将使用一个多层前馈神经网络。Stefan Fritsch 和 Frauke Guenther 的neuralnet
包提供了这样网络的标准且易于使用的实现。它还提供了一个函数用于绘制网络拓扑。因此,neuralnet
的实现是学习神经网络的一个强有力选择,尽管这并不意味着它不能用于完成实际工作——它是一个非常强大的工具,正如你很快会看到的那样。
提示
还有几个其他常用的包可以用来训练 R 中的 ANN 模型,每个包都有独特的优缺点。由于它作为 R 标准安装的一部分,nnet
包可能是引用最多的 ANN 实现。它使用的算法比标准的反向传播算法稍微复杂一些。另一个强大的选择是RSNNS
包,它提供了完整的神经网络功能,但缺点是它更难学习。
由于neuralnet
不包含在基础 R 中,你需要通过输入install.packages("neuralnet")
来安装它,并使用library(neuralnet)
命令加载它。所包含的neuralnet()
函数可以用于训练用于数值预测的神经网络,语法如下:
我们将从训练最简单的多层前馈网络开始,只有一个隐藏节点:
> concrete_model <- neuralnet(strength ~ cement + slag + ash + water + superplastic + coarseagg + fineagg + age, data = concrete_train)
然后我们可以使用结果模型对象上的plot()
函数来可视化网络拓扑:
> plot(concrete_model)
在这个简单模型中,每个八个特征都有一个输入节点,接着是一个隐藏节点和一个输出节点,输出节点预测混凝土强度。每个连接的权重也被描绘出来,偏置项(由标记为1的节点表示)也被显示。偏置项是数值常数,它允许指示节点的值上下移动,类似于线性方程中的截距。
提示
拥有一个隐藏节点的神经网络可以被看作是我们在第六章中学习的线性回归模型的远亲,预测数值数据 – 回归方法。每个输入节点和隐藏节点之间的权重类似于回归系数,而偏置项的权重类似于截距。
在图的底部,R 报告了训练步骤的数量和一个名为平方误差和(SSE)的误差度量,正如你所预期的那样,它是预测值减去实际值的平方和。较低的 SSE 意味着更好的预测性能。这有助于估计模型在训练数据上的表现,但不能告诉我们它在未见数据上的表现。
第 4 步 – 评估模型性能
网络拓扑图为我们提供了一窥人工神经网络黑匣子的视角,但并未提供关于模型如何拟合未来数据的详细信息。要在测试数据集上生成预测,我们可以使用如下的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.8064655576
提示
如果你的结果不同,不要惊慌。由于神经网络以随机权重开始,预测结果可能因模型而异。如果你想完全匹配这些结果,请尝试在构建神经网络之前使用set.seed(12345)
。
接近 1 的相关性表明两个变量之间有很强的线性关系。因此,这里约为 0.806 的相关性表明了相当强的关系。这意味着我们的模型做得相当不错,即使只有一个隐藏节点。
鉴于我们只使用了一个隐藏节点,我们很可能可以提高模型的性能。让我们试着再做得更好一些。
第 5 步 – 改进模型性能
鉴于更复杂的拓扑结构的网络能够学习更复杂的概念,让我们看看当我们将隐藏节点数增加到五时会发生什么。我们像之前一样使用neuralnet()
函数,但添加了hidden = 5
参数:
> concrete_model2 <- neuralnet(strength ~ cement + slag +
ash + water + superplastic +
coarseagg + fineagg + age,
data = concrete_train, hidden = 5)
再次绘制网络,我们看到连接数量大幅增加。我们可以看到这如何影响了性能,如下所示:
> plot(concrete_model2)
注意,报告的误差(再次由 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
有趣的是,在最初的出版物中,Yeh 报告使用一个非常相似的神经网络获得了 0.885 的平均相关性。这意味着,凭借相对较少的努力,我们就能够匹配专家的表现。如果你想更深入地练习神经网络,可能可以尝试应用本章早些时候学到的原理,看看它如何影响模型性能。也许可以尝试使用不同数量的隐藏节点,应用不同的激活函数等等。?neuralnet
帮助页面提供了更多关于可调参数的信息。
理解支持向量机
支持向量机(SVM)可以被想象为一个表面,它在多维空间中为数据点创建边界,这些数据点代表示例及其特征值。SVM 的目标是创建一个平坦的边界,称为超平面,该超平面将空间划分为相对均匀的两侧。通过这种方式,SVM 学习结合了第三章中介绍的基于实例的最近邻学习,懒惰学习——使用最近邻进行分类,以及第六章中描述的线性回归建模,预测数值数据——回归方法。这种结合极为强大,使 SVM 能够建模高度复杂的关系。
尽管驱动 SVM 的基础数学已经存在几十年,但它们最近爆发式地流行起来。当然,这源于它们的先进性能,但也可能与获奖的 SVM 算法在多个流行且得到良好支持的库中实现有关,涵盖了包括 R 在内的多种编程语言。因此,SVM 被更广泛的用户群体采用,原本这些用户可能无法应用实现 SVM 所需的复杂数学。好消息是,尽管数学可能很困难,但基本概念是可以理解的。
SVM 可以适应几乎任何类型的学习任务,包括分类和数值预测。该算法的许多关键成功案例出现在模式识别中。显著的应用包括:
-
在生物信息学领域,对微阵列基因表达数据进行分类,以识别癌症或其他遗传疾病
-
文本分类,例如识别文档使用的语言或根据主题对文档进行分类
-
检测稀有但重要的事件,如燃烧发动机故障、安全漏洞或地震
SVM 最容易理解当它用于二元分类时,这也是该方法传统应用的方式。因此,在剩余的部分中,我们将仅专注于 SVM 分类器。但不要担心,因为你在这里学到的原则同样适用于将 SVM 适应到其他学习任务,比如数值预测。
超平面分类
正如前面所述,SVM 使用一种称为超平面的边界来将数据分成类似类值的群体。例如,下图展示了在二维和三维中分隔圆圈和方块的超平面。因为圆圈和方块可以通过直线或平面完美分开,所以它们被称为线性可分。首先,我们将仅考虑这种情况,在其中这是真实的,但 SVM 也可以扩展到数据点不是线性可分的问题。
小贴士
为了方便起见,在 2D 空间中,超平面通常被描绘为一条线,但这只是因为在超过两个维度的空间中描绘空间是困难的。实际上,超平面是高维空间中的一个平面——这是一个可能很难理解的概念。
在二维空间中,SVM 算法的任务是识别一个分隔两个类别的直线。如下图所示,在圆圈和方块的群体之间有多种分隔线的选择。这些可能性标记为a、b和c。算法是如何选择的?
那个问题的答案涉及寻找最大间隔超平面(MMH),它在两个类别之间创建了最大的分离。虽然分隔圆圈和方块的三条线都可以正确分类所有数据点,但最大间隔的线可能对未来数据的泛化效果最好。最大间隔将提高即使在随机噪声的情况下,点仍然保持在边界的正确一侧的机会。
支持向量(在接下来的图中用箭头标出)是每个类别中距离 MMH 最近的点;每个类别必须至少有一个支持向量,但可以有多个。仅使用支持向量,就可以定义 MMH。这是 SVM 的一个关键特征;支持向量提供了一种非常紧凑的方式来存储分类模型,即使特征数量极大。
识别支持向量的算法依赖于向量几何,并涉及一些超出本书范围的相当棘手的数学。然而,该过程的基本原理是相当简单的。
注意
有关支持向量机(SVM)数学原理的更多信息,可以参考经典论文:Cortes C, Vapnik V. Support-vector network. Machine Learning. 1995; 20:273-297。初学者级别的讨论可以参考:Bennett KP, Campbell C. Support vector machines: hype or hallelujah. SIGKDD Explorations. 2003; 2:1-13。更深入的探讨可以参阅:Steinwart I, Christmann A. Support Vector Machines. New York: Springer; 2008。
线性可分数据的案例
在假设类别是线性可分的情况下,最容易理解如何找到最大间隔。在这种情况下,最大间隔超平面(MMH)距离两组数据点的外边界尽可能远。这些外边界被称为 凸包。最大间隔超平面是两条凸包之间最短线段的垂直平分线。使用一种叫做 二次优化 的技术的复杂计算机算法能够通过这种方式找到最大间隔。
另一种(但等效的)方法涉及通过搜索每个可能的超平面空间,以找到一对将数据点分成同质组的平行平面,并且这两个平面之间的距离尽可能远。用一个比喻来说,可以把这个过程想象成在试图找到一个能顺利通过楼梯到卧室的最厚的床垫。
为了理解这个搜索过程,我们需要准确地定义什么是超平面。在 n 维空间中,使用以下方程:
如果你不熟悉这种符号,上面字母上的箭头表示它们是向量,而不是单个数字。特别地,w 是一个 n 维的权重向量,即 {w[1], w[2], ..., w[n]},而 b 是一个单一数字,称为 偏置。偏置在概念上等同于 第六章 中讨论的斜截式方程中的截距项,数值数据预测 - 回归方法。
提示
如果你很难想象这个平面,不要担心细节。只需将这个方程视为指定一个表面的方式,就像在二维空间中使用斜截式方程 (y = mx + b) 来指定直线一样。
使用这个公式,过程的目标是找到一组权重,指定两个超平面,如下所示:
我们还需要要求这些超平面是按以下方式指定的:使得一个类别的所有点都位于第一个超平面之上,而另一个类别的所有点都位于第二个超平面之下。只要数据是线性可分的,这是可能的。
向量几何定义了这两个平面之间的距离如下:
在这里,||w|| 表示欧几里得范数(即原点到向量 w 的距离)。由于 ||w|| 位于分母,为了最大化距离,我们需要最小化 ||w||。这个任务通常会被重新表达为一组约束条件,如下所示:
虽然这看起来有些混乱,但实际上从概念上理解并不太复杂。基本上,第一行表示我们需要最小化欧几里得范数(平方后除以 2 以简化计算)。第二行表示这是受约束(s.t.)的,即每个 y[i] 数据点必须被正确分类。请注意,y 表示类别值(转化为 +1 或 -1),而倒立的 "A" 是 "对于所有" 的简写。
与寻找最大间隔的另一种方法一样,解决这个问题的任务最好交给二次优化软件。尽管这可能会占用大量处理器资源,但专门的算法能够迅速解决这些问题,即使是在相当大的数据集上。
非线性可分数据的情况
在我们分析了 SVM 背后的理论后,你可能会想知道一个问题:如果数据不是线性可分的,会发生什么?解决这个问题的方法是使用松弛变量,它创建了一个软间隔,允许一些数据点落在间隔的错误一侧。接下来的图示说明了两个数据点落在直线的错误一侧,并显示了相应的松弛项(用希腊字母 Xi 表示):
对所有违反约束的点应用一个成本值(记为 C),与其寻找最大间隔,算法会尝试最小化总成本。因此,我们可以将优化问题修改为:
如果你仍然感到困惑,不用担心,你不是唯一一个。幸运的是,SVM 软件包会很高兴地为你优化这一过程,而无需你理解技术细节。需要理解的重要部分是成本参数 C的引入。修改该值将调整惩罚,例如,数据点落在超平面错误的一侧。成本参数越大,优化过程越会努力实现 100% 的分类准确率。另一方面,较低的成本参数则会将重点放在更宽的整体间隔上。为了创建一个能很好地泛化到未来数据的模型,平衡这两者是非常重要的。
使用核函数处理非线性空间
在许多实际应用中,变量之间的关系是非线性的。正如我们刚刚发现的,SVM 仍然可以通过添加松弛变量来训练这类数据,从而允许某些示例被误分类。然而,这并不是处理非线性问题的唯一方式。SVM 的一个关键特点是它们能够使用一种称为 核技巧 的过程将问题映射到一个更高维度的空间。在这样做的过程中,非线性关系可能突然变得非常线性。
尽管这看起来像是废话,但实际上通过示例很容易说明。在下图中,左侧的散点图描绘了天气类别(晴天或雪天)与两个特征:纬度和经度之间的非线性关系。图中心的点属于雪天类别,而图边缘的点都是晴天。这些数据可能来自一组天气报告,其中一些来自山顶附近的气象站,另一些则来自山脚下的气象站。
在图的右侧,应用了核技巧后,我们通过一个新维度——海拔高度来观察数据。随着这个特征的加入,类别现在可以完美地线性分开。这之所以可能,是因为我们从一个新的视角来看待数据。在左图中,我们是从鸟瞰视角观察这座山,而在右图中,我们是从地面远处观察这座山。在这里,趋势非常明显:雪天出现在更高的海拔。
使用非线性核的 SVM 通过增加数据的额外维度来创造分离。实际上,核技巧包括构建新特征的过程,这些特征表达了度量特征之间的数学关系。例如,海拔特征可以数学地表达为纬度和经度之间的交互作用——该点离这两个尺度的中心越近,海拔就越高。这使得 SVM 能够学习到原始数据中没有明确度量的概念。
使用非线性核的 SVM 是非常强大的分类器,尽管它们确实有一些缺点,如下表所示:
优势 | 弱点 |
---|
|
-
可用于分类或数值预测问题
-
不容易受到噪声数据的影响,也不容易发生过拟合
-
可能比神经网络更容易使用,尤其是因为存在多个得到良好支持的 SVM 算法
-
由于其高准确率和在数据挖掘竞赛中的高调获胜,SVM 正在获得越来越多的关注
|
-
寻找最佳模型需要测试不同核函数和模型参数的组合
-
训练可能较慢,特别是当输入数据集具有大量特征或示例时
-
结果是一个复杂的黑箱模型,难以解释,甚至可能无法解释
|
核函数通常具有以下形式。由希腊字母 phi 表示的函数,即 ϕ(x),是将数据映射到另一个空间。因此,一般的核函数对特征向量 x[i] 和 x[j] 进行某种变换,并使用 点积将它们结合,点积操作将两个向量转换为一个单一的数字。
使用这种形式,已经为许多不同领域的数据开发了核函数。以下列出了几种最常用的核函数。几乎所有的 SVM 软件包都会包含这些核函数及其他许多核函数。
线性核完全不对数据进行转换。因此,它可以简单地表示为特征的点积:
多项式核的度数为 d,对数据进行简单的非线性变换:
Sigmoid 核导致的 SVM 模型与使用 sigmoid 激活函数的神经网络有些类似。希腊字母 kappa 和 delta 被用作核参数:
高斯 RBF 核类似于 RBF 神经网络。RBF 核在许多类型的数据上表现良好,且被认为是许多学习任务的合理起点:
没有可靠的规则可以将特定的核函数与某个学习任务匹配。匹配程度很大程度上依赖于要学习的概念、训练数据的数量以及特征之间的关系。通常,需要通过在验证数据集上训练和评估多个 SVM 来进行一些试错法。话虽如此,在许多情况下,核函数的选择是任意的,因为性能可能会有所波动。为了实践这种方法,让我们将 SVM 分类应用到一个现实问题中。
示例 – 使用 SVM 进行 OCR(光学字符识别)
图像处理是许多类型的机器学习算法面临的困难任务。像素模式与更高层次概念之间的关系极为复杂且难以定义。例如,人类很容易识别出一张脸、一只猫或字母 "A",但将这些模式定义为严格的规则却十分困难。此外,图像数据往往存在噪声。图像的捕获方式可能因光线、方向和主体位置的不同而有所变化。
支持向量机(SVM)非常适合应对图像数据的挑战。它们能够学习复杂的模式而不对噪声过于敏感,从而能够高精度地识别视觉模式。此外,SVM 的一个关键弱点——黑箱模型表示——对于图像处理的影响较小。如果一个 SVM 能区分猫和狗,那么它是如何做到的就不那么重要了。
在本节中,我们将开发一个类似于光学字符识别(OCR)软件中核心使用的模型,这类软件通常与桌面文档扫描仪捆绑在一起。此类软件的目的是通过将打印或手写文本转换为电子形式来处理纸质文档,以便保存到数据库中。当然,由于手写风格和打印字体的多样性,这是一个困难的问题。尽管如此,软件用户仍然期望完美,因为错误或错别字可能在商业环境中导致尴尬或代价高昂的错误。让我们看看我们的 SVM 是否能够完成这项任务。
步骤 1 – 收集数据
当 OCR 软件首次处理文档时,它会将纸张分割成一个矩阵,使得网格中的每个单元格包含一个单独的字形,字形只是指一个字母、符号或数字的术语。接下来,软件将尝试将每个单元格的字形与它识别的所有字符集合进行匹配。最后,单个字符将被重新组合成单词,并且可以选择通过字典对文档语言中的单词进行拼写检查。
在本次练习中,我们假设我们已经开发了算法,将文档划分为每个包含单个字符的矩形区域。我们还假设文档仅包含英文字母字符。因此,我们将模拟一个过程,涉及将字形与 26 个字母(A 到 Z)中的一个进行匹配。
为此,我们将使用 W. Frey 和 D. J. Slate 捐赠给 UCI 机器学习数据集库(archive.ics.uci.edu/ml
)的数据集。该数据集包含 20,000 个示例,展示了使用 20 种不同随机形变和失真的黑白字体打印的 26 个英文字母的大写字母。
注意
有关此数据集的更多信息,请参考 Slate DJ, Frey W.《使用霍兰德风格适应分类器的字母识别》。机器学习. 1991; 6:161-182。
以下图像由 Frey 和 Slate 发布,提供了一些打印字形的示例。这些字形被扭曲后,计算机很难识别,但人类却能轻松识别:
步骤 2 – 探索和准备数据
根据 Frey 和 Slate 提供的文档,当字形被扫描到计算机中时,它们会被转换为像素,并记录 16 个统计属性。
这些属性测量了字形的水平和垂直维度、黑色(与白色)像素的比例,以及像素的平均水平和垂直位置。可以推测,字形中黑色像素在不同区域的浓度差异应能提供一种区分 26 个字母的方法。
提示
为了跟随这个例子,请从 Packt Publishing 网站下载letterdata.csv
文件,并将其保存到你的 R 工作目录中。
将数据读入 R 后,我们确认已经接收到包含定义每个字母类别的 16 个特征的数据。正如预期的那样,字母类有 26 个水平:
> letters <- read.csv("letterdata.csv")
> str(letters)
'data.frame': 20000 obs. of 17 variables:
$ letter: Factor w/ 26 levels "A","B","C","D",..
$ 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 包在拟合 SVM 模型时会自动执行重新缩放。
由于数据准备工作已经基本完成,我们可以直接进入机器学习过程的训练和测试阶段。在之前的分析中,我们随机地将数据分为训练集和测试集。尽管我们可以在这里这样做,但 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 库,这是一个广泛使用的用 C++编写的开源 SVM 程序。如果你已经熟悉 LIBSVM,你可能希望从这里开始。
注意
有关 LIBSVM 的更多信息,请访问作者的官方网站:www.csie.ntu.edu.tw/~cjlin/libsvm/
。
同样,如果你已经使用 SVMlight 算法,来自多特蒙德大学(TU Dortmund)统计学系的klaR
包提供了直接在 R 中使用该 SVM 实现的功能。
注意
关于 SVMlight 的信息,可以查看svmlight.joachims.org/
。
最后,如果你是从头开始,最好从kernlab
包中的 SVM 函数开始。这个包的一个有趣的优点是它是原生用 R 开发的,而不是用 C 或 C++,这使得它非常容易自定义;包的内部实现完全透明,不会隐藏任何内容。或许更重要的是,与其他选项不同,kernlab
可以与caret
包一起使用,这使得 SVM 模型能够使用各种自动化方法进行训练和评估(在第十一章中有介绍,提升模型性能)。
注意
如果你想更详细地了解kernlab
,请参阅作者的论文,地址为www.jstatsoft.org/v11/i09/
。
使用kernlab
训练 SVM 分类器的语法如下。如果你确实使用的是其他包,命令大体是类似的。默认情况下,ksvm()
函数使用高斯 RBF 核,但也提供了其他多种选项。
为了提供 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 = "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 代码,我们就超越了他们的结果,尽管我们也得到了二十多年额外机器学习研究的好处。考虑到这一点,我们可能能够做得更好。
第五步 – 提高模型性能
我们之前的 SVM 模型使用了简单的线性核函数。通过使用更复杂的核函数,我们可以将数据映射到更高维的空间,并可能获得更好的模型拟合。
然而,从众多不同的核函数中选择合适的一个可能是具有挑战性的。一种常见的做法是从高斯 RBF 核开始,因为它已被证明在许多类型的数据上表现良好。我们可以像这样使用ksvm()
函数训练基于 RBF 的 SVM:
> 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
275 3725
> prop.table(table(agreement_rbf))
agreement_rbf
FALSE TRUE
0.06875 0.93125
提示
由于ksvm
RBF 核函数的随机性,你的结果可能与这里展示的有所不同。如果你希望它们完全匹配,可以在运行 ksvm()函数之前使用set.seed(12345)
。
通过简单地更改核函数,我们成功将字符识别模型的准确率从 84%提高到了 93%。如果 OCR 程序的表现仍然不令人满意,可以尝试其他核函数,或者调整约束参数 C 的成本,以修改决策边界的宽度。作为练习,你应该尝试这些参数,看看它们如何影响最终模型的成功。
总结
在本章中,我们考察了两种机器学习方法,它们具有巨大的潜力,但由于复杂性经常被忽视。希望你现在能看到,这种声誉至少在某种程度上是不公平的。驱动 ANNs 和 SVM 的基本概念相当容易理解。
另一方面,由于人工神经网络(ANNs)和支持向量机(SVMs)已经存在了几十年,每种方法都有许多变种。本章只是简单介绍了这些方法可能实现的部分内容。通过运用你在这里学到的术语,你应该能够掌握那些每天都在发展的各种进展之间的细微差别。
现在我们已经花了一些时间学习了从简单到复杂的多种预测模型;在下一章,我们将开始考虑其他类型学习任务的方法。这些无监督学习技术将揭示数据中的迷人模式。
第八章:发现模式—使用关联规则进行市场篮子分析
回想一下你上一次冲动购买的经历。也许你在超市结账时买了一包口香糖或一块巧克力棒。也许你在深夜去买尿布和奶粉时顺便拿了一瓶含咖啡因的饮料或一六瓶啤酒。你可能甚至是在书店推荐下冲动地买了这本书。这些冲动购买可不是偶然的,零售商通过使用复杂的数据分析技术来识别那些能够驱动零售行为的模式。
在过去的几年里,这种推荐系统主要依赖于营销专业人员和库存经理或采购员的主观直觉。近年来,随着条形码扫描仪、计算机化库存系统和在线购物趋势积累了大量的交易数据,机器学习被越来越多地应用于学习购买模式。由于这种技术经常应用于超市数据,因此这一实践通常被称为市场篮子分析。
尽管该技术起源于购物数据,但它在其他背景下也同样有用。当你读完这一章时,你将能够将市场篮子分析技术应用到你自己的任务中,无论这些任务是什么。一般而言,这项工作包括:
-
使用简单的性能指标在大型数据库中寻找关联
-
理解交易数据的特殊性
-
知道如何识别有用且可操作的模式
市场篮子分析的结果是可操作的模式。因此,当我们应用这项技术时,即使你与零售链没有任何关联,也很可能会识别出对你的工作有用的应用。
理解关联规则
市场篮子分析的基本构件是可能出现在任何给定交易中的商品。一个或多个商品组成的组被括号括起来,表示它们形成了一个集合,或者更具体地说,是一个在数据中有规律出现的项集。交易是通过项集来指定的,例如在典型的杂货店中可能找到的以下交易:
市场篮子分析的结果是一组关联规则,这些规则指定了在商品项集之间的关系中发现的模式。关联规则总是由项集的子集组成,并通过将规则的左侧(LHS)中的一个项集与右侧(RHS)中的另一个项集联系起来来表示。LHS 是需要满足的条件,以触发规则,而 RHS 是满足该条件后预期的结果。一个从示例交易中识别出的规则可以表示为:
用简单的话来说,这个关联规则表示,如果花生酱和果冻一起购买,那么面包也很可能会被购买。换句话说,“花生酱和果冻意味着面包。”
在零售交易数据库的背景下,关联规则不是用来进行预测,而是用于在大型数据库中进行无监督的知识发现。这与前几章介绍的分类和数值预测算法不同。尽管如此,你会发现关联规则学习器与第五章中的分类规则学习器紧密相关,并共享许多相同的特征,分而治之——使用决策树和规则进行分类。
由于关联规则学习器是无监督的,因此算法无需进行训练;数据无需提前标注。程序只是被释放到数据集上,希望能够发现有趣的关联。当然,缺点是,除了通过定性有用性评估规则学习器外,没有简单的方法来客观衡量其性能——通常需要某种形式的人工检查。
虽然关联规则最常用于市场篮分析,但它们在发现多种不同类型数据的模式中也很有帮助。其他潜在应用包括:
-
在癌症数据中搜索有趣且频繁出现的 DNA 和蛋白质序列模式
-
寻找与信用卡或保险欺诈使用相关联的购买模式或医疗索赔模式
-
识别导致客户取消手机服务或升级有线电视套餐的行为组合
关联规则分析用于在大量元素中搜索有趣的关联。人类能够凭直觉进行这样的洞察,但通常需要专家级的知识或大量经验,才能完成规则学习算法在几分钟甚至几秒钟内就能完成的任务。此外,一些数据集对于人类来说过于庞大和复杂,难以从中找到“针尖”。
关联规则学习的 Apriori 算法
正如人类感到具有挑战性一样,交易数据使得关联规则挖掘对于机器来说也是一项挑战。交易数据集通常非常庞大,无论是交易数量还是监控的商品或特征数量都很庞大。问题在于,潜在项集的数量随着特征数量的增加呈指数级增长。假设有k个商品可能会出现在集合中或不出现在集合中,那么有2^k个潜在的项集可能成为规则。一个只卖 100 种商品的零售商,可能需要评估大约2¹⁰⁰ = 1.27e+30个项集——这看似是不可能完成的任务。
与其逐一评估这些项集,不如利用更智能的规则学习算法,利用许多潜在的商品组合在实践中很少出现这一事实。例如,即使商店同时出售汽车配件和女性化妆品,{机油, 口红}这种组合也可能极其不常见。通过忽略这些稀有(而且可能不太重要)组合,可以将规则搜索的范围限制到一个更易管理的大小。
已经做了大量工作来识别启发式算法,以减少需要搜索的项集数量。也许最广泛使用的高效搜索大数据库中规则的方法是Apriori算法。该算法由 Rakesh Agrawal 和 Ramakrishnan Srikant 于 1994 年提出,此后已成为关联规则学习的代名词。其名称来源于该算法利用一种简单的先验信念(即a priori)关于频繁项集的属性。
在我们深入讨论之前,值得注意的是,这种算法与所有学习算法一样,并非没有优缺点。以下是其中的一些优缺点:
优势 | 劣势 |
---|
|
-
能够处理大量的交易数据
-
产生易于理解的规则
-
有助于“数据挖掘”和发现数据库中的意外知识
|
-
对于小型数据集不太有用
-
需要努力将真正的洞察力与常识区分开
-
容易从随机模式中得出虚假的结论
|
如前所述,Apriori 算法通过简单的a priori信念来减少关联规则搜索空间:频繁项集的所有子集也必须是频繁的。这一启发式方法被称为Apriori 属性。利用这一精明的观察,我们可以大幅限制需要搜索的规则数量。例如,{机油, 口红}集只有在{机油}和{口红}都频繁出现时,才能是频繁的。因此,如果机油或口红出现频率较低,那么包含这些物品的任何集合都可以从搜索中排除。
注意
如需了解 Apriori 算法的更多细节,请参阅:Agrawal R, Srikant R. 快速挖掘关联规则的算法。第 20 届国际大型数据库会议论文集,1994:487-499。
为了了解这一原则如何应用于更现实的场景,让我们考虑一个简单的交易数据库。下表显示了一个假想医院礼品店的五笔已完成交易:
交易编号 | 购买的物品 |
---|---|
1 | {花, 康复卡片, 苏打水} |
2 | {毛绒玩具熊, 花, 气球, 巧克力棒} |
3 | {康复卡片, 巧克力棒, 花} |
4 | {毛绒玩具熊, 气球, 苏打水} |
5 | {花, 康复卡片, 苏打水} |
通过查看购买集,可以推断出一些典型的购买模式。拜访生病的朋友或家人时,人们往往会购买一张祝早日康复的卡片和鲜花,而拜访新妈妈的人则倾向于购买毛绒玩具熊和气球。这些模式之所以引人注目,是因为它们频繁出现,足以引起我们的兴趣;我们只需运用一些逻辑和专业经验来解释这些规则。
类似地,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 的置信度是不同的。例如,{鲜花} → {祝贺卡} 的置信度是 0.6 / 0.8 = 0.75。相比之下,{祝贺卡} → {鲜花} 的置信度是 0.6 / 0.6 = 1.0。这意味着购买鲜花的交易有 75% 的可能性会同时购买祝贺卡,而购买祝贺卡的交易则有 100% 的可能性会购买鲜花。这些信息对于礼品店管理层非常有用。
提示
你可能已经注意到支持度、置信度和贝叶斯概率规则之间的相似性,这些内容在第四章,概率学习——使用朴素贝叶斯分类中有提到。事实上,support(A, B) 就是 P(A∩B),而 confidence(A → B) 就是 P(B|A)。只是上下文不同。
类似 {祝贺卡} → {鲜花} 的规则被称为强规则,因为它们具有较高的支持度和置信度。找到更多强规则的一种方法是检查礼品店中每一对物品的所有可能组合,衡量支持度和置信度的值,然后仅报告那些符合特定兴趣水平的规则。然而,如前所述,这种策略通常只适用于最小的数据集。
在下一部分中,你将看到 Apriori 算法如何利用最小支持度和置信度水平,并结合 Apriori 原则,通过减少规则数量到一个更易管理的水平,快速找到强规则。
使用 Apriori 原则构建规则集
回忆一下,Apriori 原则指出,频繁项集的所有子集也必须是频繁的。换句话说,如果 {A, B} 是频繁的,那么 {A} 和 {B} 都必须是频繁的。还要记住,根据定义,支持度表示项集在数据中出现的频率。因此,如果我们知道 {A} 未达到所需的支持度阈值,那么就没有理由考虑 {A, B} 或任何包含 {A} 的项集;它不可能是频繁的。
Apriori 算法利用这种逻辑,在实际评估潜在的关联规则之前,先排除不可能的规则。创建规则的实际过程分为两个阶段:
-
确定所有满足最小支持度阈值的项集。
-
使用满足最小置信度阈值的项集来创建规则。
第一个阶段在多个迭代中进行。每次迭代都涉及评估一组越来越大的项集的支持度。例如,第一次迭代评估 1 项项集(1-itemsets),第二次迭代评估 2 项项集,以此类推。每次迭代i的结果是满足最低支持度阈值的所有i-项集的集合。
第i次迭代中的所有项集将被合并,以生成第i + 1次迭代的候选项集进行评估。但是,Apriori 原理甚至可以在下一轮开始之前就排除其中的一些项集。如果在第一次迭代中{A}、{B}和{C}是频繁项集,而{D}不是,那么第二次迭代将只考虑{A, B}、{A, C}和{B, C}。因此,算法只需要评估三个项集,而不是评估包含D的六个项集,如果没有通过先验排除这些项集的话。
继续这个思路,假设在第二次迭代中,发现{A, B}和{B, C}是频繁项集,但{A, C}不是。尽管第三次迭代通常会通过评估{A, B, C}的支持度来开始,但并不强制要求这一步一定发生。为什么不呢?Apriori 原理指出,由于子集{A, C}不是频繁的,{A, B, C}不可能是频繁项集。因此,在第三次迭代中没有生成新的项集,算法可能会停止。
此时,Apriori 算法的第二阶段可以开始。给定频繁项集的集合,从所有可能的子集生成关联规则。例如,{A, B}将生成{A} → {B}和{B} → {A}的候选规则。这些规则将根据最低置信度阈值进行评估,任何不满足所需置信度水平的规则都会被淘汰。
示例 – 使用关联规则识别常购商品
如本章介绍所述,市场篮子分析在许多实体店和在线零售商的推荐系统背后默默发挥着作用。通过学习得到的关联规则能够指示出经常一起购买的商品组合。了解这些模式为杂货连锁店提供了新的洞察力,帮助优化库存、进行促销广告或组织商店的物理布局。例如,如果顾客经常购买咖啡或橙汁与早餐点心一起食用,那么通过将点心移近咖啡和橙汁的位置,可能会提高利润。
在本教程中,我们将对来自一家杂货店的交易数据进行市场篮子分析。然而,这些技术可以应用于许多不同类型的问题,从电影推荐到约会网站,再到寻找药物之间的危险相互作用。在这个过程中,我们将看到 Apriori 算法如何高效地评估一个潜在的庞大的关联规则集。
第一步 – 收集数据
我们的市场篮分析将利用来自一家现实世界杂货店运营一个月的购买数据。数据包含 9,835 笔交易,约合每天 327 笔交易(在一个 12 小时的营业日中大约 30 笔交易),表明该零售商规模既不特别大,也不特别小。
注意
这里使用的数据集改编自arules
R 包中的Groceries
数据集。有关更多信息,请参见:Hahsler M, Hornik K, Reutterer T. 概率数据建模对挖掘关联规则的影响。载:Gaul W, Vichi M, Weihs C, ed. 分类学、数据分析与知识组织研究:从数据与信息分析到知识工程。纽约:Springer; 2006:598–605。
一般的杂货店提供种类繁多的商品。可能会有五种不同品牌的牛奶、十几种不同类型的洗衣粉和三种品牌的咖啡。鉴于零售商的规模适中,我们假设他们并不太关心寻找只适用于某一特定品牌牛奶或洗衣粉的规则。考虑到这一点,可以从购买数据中去除所有品牌名称。这样,商品种类减少为 169 种,更易于管理,涵盖了鸡肉、冷冻餐、玛琪琳和苏打水等广泛类别。
提示
如果你希望识别非常具体的关联规则——例如,客户是否喜欢将葡萄酒或草莓果酱与花生酱搭配——你将需要大量的事务数据。大型连锁零售商使用数百万笔交易的数据库来发现特定品牌、颜色或口味的物品之间的关联。
你有什么猜测关于哪些类型的物品可能会一起购买吗?葡萄酒和奶酪会是常见的搭配吗?面包和黄油?茶和蜂蜜?让我们深入分析这些数据,看看是否能确认我们的猜测。
第 2 步——探索与准备数据
事务数据以与我们之前使用的格式略有不同的方式存储。我们以前的大多数分析使用的是矩阵形式的数据,其中行表示示例实例,列表示特征。由于矩阵格式的结构,所有示例都必须具有完全相同的特征集。
相比之下,事务数据的形式更加自由。像往常一样,数据中的每一行表示一个单独的示例——在这种情况下,是一次交易。然而,与其有固定数量的特征,每条记录由一个用逗号分隔的物品列表组成,数量从一个到多个不等。实质上,特征可能会因示例而异。
提示
为了跟随本次分析,请从 Packt Publishing 网站下载groceries.csv
文件,并将其保存在你的 R 工作目录中。
grocery.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 会欣然执行,并将数据读取成如下的矩阵形式:
你会注意到,R 创建了四列来存储事务数据中的项目:V1
、V2
、V3
和 V4
。虽然这看起来合情合理,但如果我们使用这种格式的数据,后续会遇到问题。R 选择创建四个变量是因为第一行恰好有四个用逗号分隔的值。然而,我们知道,杂货购买可能包含超过四个项目;在四列设计中,这样的交易会被拆分到矩阵中的多行。我们可以尝试通过将包含最多项目的交易放在文件的顶部来解决这个问题,但这忽略了另一个更为棘手的问题。
通过这种方式构建数据,R 创建了一组特征,不仅记录了交易中的商品,还记录了它们出现的顺序。如果我们把学习算法想象成是在尝试找出 V1
、V2
、V3
和 V4
之间的关系,那么 V1
中的全脂牛奶可能会与 V2
中的全脂牛奶有所不同。相反,我们需要的是一个数据集,它不会把一笔交易视为一组需要填写(或不填写)特定商品的位置,而是将其视为一个市场购物篮,里面包含或不包含每一个特定商品。
数据准备 – 为事务数据创建稀疏矩阵
解决这个问题的方法是使用一种名为 稀疏矩阵 的数据结构。你可能还记得,我们在第四章中,概率学习 – 使用朴素贝叶斯分类,使用了稀疏矩阵来处理文本数据。和前面的数据集一样,稀疏矩阵中的每一行表示一次交易。然而,稀疏矩阵为每个可能出现在某人购物袋中的商品提供了一个列(即特征)。由于我们的杂货店数据中有 169 种不同的商品,所以我们的稀疏矩阵将包含 169 列。
为什么不直接像我们在大多数分析中那样将其存储为数据框(data frame)呢?原因是随着更多的交易和商品被添加,传统的数据结构很快会变得太大,无法适应可用的内存。即使是这里使用的相对较小的交易数据集,矩阵也包含近 170 万个单元格,其中大多数是零(因此称为“稀疏”矩阵——非零值非常少)。由于存储所有这些零值没有任何意义,稀疏矩阵实际上并不会将整个矩阵存储在内存中;它只存储那些由项目占用的单元格。这使得该结构比同等大小的矩阵或数据框更节省内存。
为了从交易数据中创建稀疏矩阵数据结构,我们可以使用arules
包提供的功能。通过执行install.packages("arules")
和library(arules)
命令来安装和加载该包。
注意
欲了解更多有关 arules 包的信息,请参考:Hahsler M, Gruen B, Hornik K. arules – a computational environment for mining association rules and frequent item sets. Journal of Statistical Software. 2005; 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 / 8,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 个项目。第一四分位数和中位数购买大小分别为 2 和 3 个项目,这意味着 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
arules
包包含一些有用的功能,用于检查交易数据。要查看稀疏矩阵的内容,请将inspect()
函数与向量操作符结合使用。可以按如下方式查看前五个交易:
> 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}
这些交易与我们查看原始 CSV 文件时的数据一致。要检查特定的项目(即一列数据),可以使用[row, column]
矩阵概念。将其与itemFrequency()
函数结合使用,可以查看包含该项目的交易比例。例如,这使我们能够查看groceries
数据中前 3 个项目的支持度:
> 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%支持度的八个项目的直方图:
如果你希望限制图表中的项目数量,可以使用带有topN
参数的itemFrequencyPlot()
:
> itemFrequencyPlot(groceries, topN = 20)
然后,直方图按支持度递减排序,如下图所示,展示了groceries
数据中的前 20 个项目:
可视化交易数据 - 绘制稀疏矩阵
除了查看商品外,还可以可视化整个稀疏矩阵。为此,请使用image()
函数。显示前五笔交易的稀疏矩阵的命令如下:
> image(groceries[1:5])
结果图表描绘了一个包含 5 行和 169 列的矩阵,表示我们请求的 5 笔交易和 169 个可能的商品。矩阵中的单元格用黑色填充,表示在某笔交易(行)中购买了某个商品(列)。
尽管前面的图表较小且可能略显难以阅读,但你可以看到第一、第四和第五行的交易每行都包含四个项目,因为它们的行中有四个单元格被填充。你还可以看到第三、第五、第二和第四行在右侧图表中有一个共同的项目。
这种可视化可以成为探索数据的有用工具。首先,它可能有助于识别潜在的数据问题。完全填充的列可能表明在每一笔交易中都有购买某个商品——这可能是一个问题,例如,如果零售商的名称或识别号不小心包含在交易数据集中。
此外,图表中的模式可能有助于揭示交易和商品中的可操作见解,尤其是当数据以有趣的方式排序时。例如,如果交易按日期排序,黑色点的模式可能揭示购买的商品数量或类型的季节性变化。或许在圣诞节或光明节时,玩具更为常见;而在万圣节时,糖果可能变得受欢迎。如果商品也被分类,这种可视化可能尤其强大。然而,在大多数情况下,图表看起来将相当随机,就像电视屏幕上的雪花。
请记住,这种可视化对于极大的交易数据库来说并不那么有用,因为单元格会小到难以辨认。尽管如此,通过将其与sample()
函数结合使用,你可以查看一个随机抽样的交易集的稀疏矩阵。创建 100 笔交易随机选择的命令如下:
> image(sample(groceries, 100))
这将生成一个有 100 行和 169 列的矩阵图:
一些列看起来填充得相当密集,表明商店里有一些非常受欢迎的商品。但总体来说,点的分布似乎相当随机。既然没有其他值得注意的内容,我们继续进行分析。
步骤 3 – 在数据上训练模型
数据准备完成后,我们现在可以开始寻找购物车物品之间的关联了。我们将使用arules
包中的 Apriori 算法实现,这个包我们之前已经用来探索和准备杂货数据。如果你还没有安装和加载这个包,记得先操作。以下表格显示了使用apriori()
函数创建规则集的语法:
尽管运行apriori()
函数非常直接,但有时可能需要一定的试错过程来找到产生合理数量关联规则的support
和confidence
参数。如果你设定这些值过高,你可能找不到规则,或者找到的规则过于宽泛,无法提供实际价值。另一方面,阈值设定得过低可能会导致规则数量过多,甚至更糟,可能会导致学习阶段运行时间过长或内存不足。
在这种情况下,如果我们尝试使用默认设置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% 的时间。这将剔除最不可靠的规则,同时为我们通过有针对性的促销调整行为提供一定的空间。
我们现在可以生成一些规则。除了最小的 support
和 confidence
参数外,设置 minlen = 2
也很有帮助,以排除那些包含少于两个项目的规则。这可以防止一些无趣的规则被创建,比如仅因为某个项目购买频繁而生成的规则,如 {} → whole milk。这条规则符合最小支持度和置信度,因为全脂牛奶在超过 25% 的交易中被购买,但它并不是一个非常有行动价值的洞察。
使用 Apriori 算法查找关联规则的完整命令如下:
> groceryrules <- apriori(groceries, parameter = list(support =
0.006, confidence = 0.25, minlen = 2))
这将我们的规则保存在 rules
对象中,可以通过键入其名称来查看:
> groceryrules
set of 463 rules
我们的 groceryrules
对象包含了一组 463 条关联规则。为了确定它们是否有用,我们需要深入挖掘。
步骤 4 – 评估模型性能
为了获得关联规则的高层概览,我们可以按如下方式使用 summary()
。规则长度分布告诉我们每条规则包含的项目数量。在我们的规则集中,150 条规则只有两个项目,297 条规则有三个项目,16 条规则有四个项目。与此分布相关的 summary
统计数据也会提供:
> 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 lift
Min. :0.006101 Min. :0.2500 Min. :0.9932
1st Qu.:0.007117 1st Qu.:0.2971 1st Qu.:1.6229
Median :0.008744 Median :0.3554 Median :1.9332
Mean :0.011539 Mean :0.3786 Mean :2.0351
3rd Qu.:0.012303 3rd Qu.:0.4495 3rd Qu.:2.3565
Max. :0.074835 Max. :0.6600 Max. :3.9565
第三列是我们尚未考虑的度量。规则的 lift 衡量的是在已知另一个项目或项目集被购买的情况下,某个项目或项目集的购买可能性相对于其通常购买率的提高程度。这个值通过以下方程定义:
提示
与置信度不同,提升度 lift(X → Y) 与 lift(Y → X) 是相同的。
例如,假设在一家超市里,大多数人都购买牛奶和面包。仅凭偶然,我们就可以预期会有许多同时购买牛奶和面包的交易。然而,如果 lift(牛奶 → 面包) 大于 1,这意味着这两种商品比偶然情况下更常一起出现。因此,较高的提升值是规则重要性的强烈指示,反映了商品之间的真实联系。
在 summary()
输出的最后部分,我们会看到挖掘信息,告诉我们规则是如何选择的。在这里,我们看到包含 9,835 个交易的 groceries
数据被用来构建规则,最小支持度为 0.0006,最小置信度为 0.25:
mining info:
data ntransactions support confidence
groceries 9835 0.006 0.25
我们可以使用 inspect()
函数查看特定规则。例如,可以通过以下方式查看 groceryrules
对象中的前三条规则:
> inspect(groceryrules[1:3])
第一个规则可以用简单的语言表述为:“如果顾客购买盆栽植物,他们也会购买全脂牛奶。”通过支持度为 0.007 和置信度为 0.400,我们可以确定这个规则覆盖了 0.7% 的交易,并且在涉及盆栽植物的购买中正确率为 40%。提升值告诉我们,相比于平均顾客,购买盆栽植物的顾客购买全脂牛奶的可能性有多大。由于我们知道大约 25.6% 的顾客购买了全脂牛奶(support
),而 40% 购买盆栽植物的顾客购买了全脂牛奶(confidence
),因此我们可以计算提升值为 0.40 / 0.256 = 1.56,这与显示的值一致。
注意
请注意,标有 support
的列表示的是规则的支持值,而不是 lhs
或 rhs
的支持值单独的支持值。
尽管置信度和提升值很高,{盆栽植物} → {全脂牛奶} 看起来仍然像是一个非常有用的规则吗?可能不是,因为似乎没有逻辑理由说明某人购买盆栽植物时更有可能购买牛奶。然而,我们的数据却暗示了不同的结果。我们如何解释这一事实呢?
一种常见的方法是将关联规则分为以下三类:
-
可操作的
-
微不足道的
-
无法解释的
显然,市场篮分析的目标是找到可操作的规则,这些规则能提供明确且有用的洞察。有些规则很明确,有些规则很有用;同时具备这两者的组合则较为少见。
所谓的微不足道规则是指那些过于明显、没有价值的规则——它们很清晰,但没有用处。假设你是一名市场咨询师,受雇于客户,获取丰厚的报酬去识别新的交叉销售机会。如果你报告发现 {纸尿裤} → {奶粉},你可能不会被邀请回来进行下一次咨询工作。
提示
微不足道的规则也可能伪装成更有趣的结果。例如,假设你发现某个特定品牌的儿童谷物和某部 DVD 电影之间存在关联。如果这部电影的主角出现在谷物盒的封面上,那么这个发现就没有太大意义。
如果规则之间的关联不明确,导致无法或几乎无法理解如何使用这些信息,则该规则是无法解释的。这个规则可能仅仅是数据中的一个随机模式,例如,一个规则表明{腌黄瓜} → {巧克力冰淇淋},可能是因为某个顾客的孕妇妻子经常对奇怪的食物组合有特殊的渴望。
最佳规则是隐藏的宝石——那些似乎一旦被发现就显而易见的模式洞察。给定足够的时间,可以评估每一条规则来发现这些宝石。然而,我们(执行市场购物篮分析的人)可能并不是判断规则是否具有可操作性、微不足道或无法解释的最佳人选。在接下来的部分中,我们将通过使用排序和共享已学规则的方法来提高我们的工作效用,从而使最有趣的结果能够浮到最上面。
第 5 步 – 提高模型表现
专家可能能够非常迅速地识别有用的规则,但让他们评估成百上千的规则将是浪费他们时间。因此,能够根据不同标准对规则进行排序,并将它们从 R 中导出,以便与营销团队共享并深入研究是很有用的。通过这种方式,我们可以通过使结果更具可操作性来提高规则的表现。
排序关联规则集合
根据市场购物篮分析的目标,最有用的规则可能是那些具有最高support
、confidence
或lift
值的规则。arules
包包含一个sort()
函数,可以用来重新排序规则列表,使得具有最高或最低质量度量值的规则排在最前面。
要重新排序groceryrules
对象,我们可以在by
参数中指定一个"support"
、"confidence"
或"lift"
值并应用sort()
函数。通过将sort
函数与向量操作符结合使用,我们可以获得一个特定数量的有趣规则。例如,按照 lift 统计值排序,可以使用以下命令检查最佳的五条规则:
> inspect(sort(groceryrules, by = "lift")[1:5])
输出如下所示:
这些规则似乎比我们之前查看的规则更有趣。第一条规则的lift
约为 3.96,意味着购买香草的人比普通顾客更可能购买根菜类蔬菜,其可能性是普通顾客的近四倍——也许是为了做某种炖菜?第二条规则也很有趣。打发奶油在购物车中与浆果一起出现的概率是其他购物车的三倍多,这或许暗示了一种甜点搭配?
提示
默认情况下,排序顺序是降序的,意味着最大的值排在前面。要反转此顺序,只需添加一行 parameterdecreasing = FALSE
。
获取关联规则的子集
假设根据前面的规则,营销团队对创建一个广告来推广当前正当季的浆果感到兴奋。然而,在最终确定活动之前,他们要求你调查一下浆果是否经常与其他商品一起购买。为了回答这个问题,我们需要找出所有包含浆果的规则。
subset()
函数提供了一种方法来查找交易、项目或规则的子集。要使用它查找任何包含 berries
的规则,可以使用以下命令。它会将规则存储在一个名为 berryrules
的新对象中:
> berryrules <- subset(groceryrules, items %in% "berries")
然后,我们可以像处理更大数据集时那样检查这些规则:
> inspect(berryrules)
结果是以下一组规则:
涉及浆果的规则有四条,其中两条似乎足够有趣,可以称为可执行的。除了鲜奶油,浆果也经常与酸奶一起购买——这种搭配既适合早餐或午餐,也可以作为甜点。
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 逻辑运算符结合使用,如与(
&
)、或(|
)和非(!
)。
使用这些选项,你可以将规则的选择限制得尽可能具体或宽泛。
将关联规则保存到文件或数据框
为了分享你的市场购物篮分析结果,你可以使用 write()
函数将规则保存为 CSV 文件。这将生成一个可以在大多数电子表格程序(包括 Microsoft Excel)中使用的 CSV 文件:
> 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 4 variables:
$ rules : Factor w/ 463 levels "{baking powder} => {other vegetables}",..: 340 302 207 206 208 341 402 21 139 140 ...
$ support : num 0.00691 0.0061 0.00702 0.00773 0.00773 ...
$ confidence: num 0.4 0.405 0.431 0.475 0.475 ...
$ lift : num 1.57 1.59 3.96 2.45 1.86 ...
如果你想对规则执行进一步处理或需要将它们导出到另一个数据库中,你可以选择这样做。
总结
关联规则通常用于在大型零售商的海量交易数据库中提供有用的洞察。作为一种无监督学习过程,关联规则学习器能够从大型数据库中提取知识,而无需提前了解要寻找什么样的模式。问题在于,它需要一定的努力才能将大量信息缩减为一个更小、更易管理的结果集。我们在本章中研究的 Apriori 算法通过设置最小兴趣阈值,并仅报告满足这些标准的关联,从而实现了这一目标。
我们在进行一个中型超市一个月交易数据的市场购物篮分析时,运用了 Apriori 算法。即使在这个小示例中,也发现了大量的关联。在这些关联中,我们注意到了一些可能对未来营销活动有用的模式。我们应用的相同方法也被用于更大规模的零售商,分析的数据集大小是这些的多倍。
在下一章中,我们将研究另一种无监督学习算法。与关联规则类似,它也旨在在数据中找到模式。但与寻找特征间模式的关联规则不同,下一章中的方法更关注在示例之间发现联系。
第九章:数据分组—使用 k-means 进行聚类
您是否曾花时间观察过一大群人?如果有,您可能已经见过一些反复出现的个性。也许某种类型的人,穿着刚熨好的西装,手拿公文包,成为了典型的“肥猫”商界高管。一个穿着紧身牛仔裤、法兰绒衬衫和太阳镜的二十多岁年轻人可能被称为“嬉皮士”,而一位从小面包车里抱出孩子的女性则可能被标记为“足球妈妈”。
当然,这些刻板印象在应用到个体时是危险的,因为没有两个完全相同的人。然而,作为描述一个集体的方式,这些标签捕捉到了群体内个体之间某些潜在的相似性。
正如您很快会学到的,聚类或在数据中识别模式的过程,与在人群中识别模式并没有太大不同。在这一章中,您将学到:
-
聚类任务与我们之前研究的分类任务有何不同
-
聚类如何定义一个组,以及如何通过 k-means 这一经典且易于理解的聚类算法识别这些组
-
将聚类应用于现实世界任务的步骤,比如在青少年社交媒体用户中识别营销细分
在开始实际操作之前,我们将深入探讨聚类的具体内容。
理解聚类
聚类是一种无监督的机器学习任务,它会自动将数据分成聚类,即一组组相似的项目。它在不知道如何预先划分组的情况下进行。这就像我们可能甚至不知道自己在寻找什么,聚类用于知识发现而非预测。它为我们提供了数据中自然分组的洞察。
在没有预先了解一个聚类由什么组成的情况下,计算机如何可能知道一个组何时结束,另一个组何时开始呢?答案很简单。聚类是由一个原则指导的:聚类内部的项应该彼此非常相似,而与聚类外部的项有很大不同。相似性的定义可能会因应用场景的不同而有所变化,但基本思想始终相同——将数据分组,使相关元素聚集在一起。
生成的聚类可以用于实际操作。例如,您可能会发现聚类方法应用于以下场景:
-
将客户按相似的人口统计信息或购买模式进行分组,以进行定向营销活动
-
通过识别使用模式,发现超出已知聚类范围的异常行为,例如未经授权的网络入侵
-
通过将具有相似值的特征分组为更少的同质类别,从而简化极其庞大的数据集
总的来说,聚类在数据多样且可以通过更少的组来概括时非常有用。它能产生有意义且可操作的数据结构,减少复杂性并为关系模式提供洞察。
聚类作为机器学习任务
聚类与我们之前探讨的分类、数值预测和模式检测任务有所不同。在这些任务中,结果是一个模型,将特征与结果或特征之间的关系进行关联;从概念上讲,模型描述了数据中的现有模式。相比之下,聚类会创造新的数据。未标记的示例会被赋予一个聚类标签,该标签完全是根据数据内部的关系推断出来的。因此,有时你会看到聚类任务被称为无监督分类,因为从某种意义上来说,它是对未标记的示例进行分类。
关键在于,来自无监督分类器的类别标签没有内在意义。聚类将告诉你哪些示例组之间有紧密的关系——例如,它可能会返回 A、B 和 C 组——但你需要为这些组应用一个可操作且有意义的标签。为了了解这对聚类任务的影响,我们来看一个假设的例子。
假设你正在组织一个关于数据科学的会议。为了促进专业的社交和合作,你计划根据三种研究专长之一将与会者分组:计算机和/或数据库科学、数学和统计学、以及机器学习。不幸的是,在发送会议邀请后,你意识到忘记了包含一项调查,询问与会者希望与哪个学科的人员坐在一起。
一时灵感迸发,你意识到可以通过检查每个学者的出版历史来推断他们的研究专长。为此,你开始收集每个与会者在计算机科学相关期刊上发表的文章数量,以及在数学或统计学相关期刊上发表的文章数量。通过收集几位学者的数据,你绘制了一个散点图:
正如预期的那样,似乎确实存在一个模式。我们可能会猜测,位于左上角的群体,代表那些在计算机科学方面有许多出版物,但在数学方面文章较少的人,可能是计算机科学家的聚类。按照这一逻辑,右下角可能是数学家的群体。同样,右上角那些既有数学又有计算机科学经验的人,可能是机器学习专家。
我们的分组是通过视觉方式形成的;我们只是简单地将数据点作为紧密聚集的群体进行识别。然而,尽管这些分组看起来显而易见,但不幸的是,我们没有办法知道这些分组是否真正同质化,因为我们无法逐个询问每位学者的学术专长。我们所应用的标签要求我们对可能属于该组的人进行定性、假设性的判断。因此,你可以把群体标签理解为不确定的术语,如下所示:
与其主观地定义群体边界,不如使用机器学习来客观地定义它们。考虑到前面图中的轴向分割,我们的问题似乎是第五章中所描述的决策树的明显应用,分治法 – 使用决策树和规则进行分类。这可能会为我们提供一个规则,形式为“如果学者的数学出版物较少,那么他/她就是计算机科学专家。”不幸的是,这个计划存在问题。由于我们没有每个点的真实类别数据,监督学习算法无法学习到这样的模式,因为它无法知道哪些分割能形成同质化的群体。
另一方面,聚类算法使用的过程与我们通过视觉检查散点图所做的非常相似。通过衡量样本之间的关系程度,可以识别出同质的群体。在接下来的部分中,我们将开始探讨聚类算法是如何实现的。
提示
这个例子突出了聚类的一个有趣应用。如果你从未标记的数据开始,你可以使用聚类来创建类别标签。从那里,你可以应用监督学习算法,如决策树,来找出这些类别最重要的预测因素。这被称为半监督学习。
k-means 聚类算法
k-means 算法可能是最常用的聚类方法。经过数十年的研究,它为许多更复杂的聚类技术奠定了基础。如果你理解了它使用的简单原理,你就具备了理解今天几乎所有聚类算法所需的知识。许多此类方法列在以下网站上,即CRAN 聚类任务视图:cran.r-project.org/web/views/Cluster.html
。
注意
随着 k-means 的不断发展,该算法有许多不同的实现。一个常见的方法描述在:Hartigan JA, Wong MA. A k-means clustering algorithm. Applied Statistics. 1979; 28:100-108。
尽管聚类方法自 k-means 诞生以来已有了进展,但这并不意味着 k-means 已经过时。事实上,这种方法现在可能比以往任何时候都更受欢迎。以下表格列出了一些 k-means 仍然广泛使用的原因:
优势 | 弱点 |
---|
|
-
使用简单的原则,可以用非统计学术语进行解释
-
高度灵活,可以通过简单的调整适应,解决几乎所有的缺点
-
在许多实际应用场景中表现良好
|
-
比不上更现代的聚类算法复杂
-
由于它使用了随机因素,因此无法保证找到最优的聚类集
-
需要合理猜测数据中自然存在多少个聚类
-
对于非球形聚类或密度差异较大的聚类不理想
|
如果“k-means”这个名字让你觉得熟悉,你可能是在回忆第三章中讨论的 k-NN 算法,懒学习 – 使用最近邻分类。正如你将很快看到的,k-means 与 k 最近邻的相似之处远不止字母 k。
k-means 算法将每个n个样本分配到k个聚类中的一个,k是一个事先确定的数字。目标是最小化每个聚类内的差异,并最大化聚类之间的差异。
除非k和n非常小,否则计算所有可能组合的最优聚类是不可行的。相反,算法使用启发式过程来寻找局部最优解。简单来说,这意味着它从初始聚类分配开始,然后稍微修改分配,看看这些变化是否能提高聚类内部的一致性。
我们将很快深入讨论这个过程,但该算法基本上分为两个阶段。首先,它将样本分配到初始的k个聚类中。然后,它通过根据当前落入聚类中的样本调整聚类边界来更新分配。更新和分配的过程会重复几次,直到变化不再改善聚类拟合为止。此时,过程停止,聚类最终确定。
提示
由于 k-means 的启发式特性,仅通过轻微改变初始条件,你可能会得到略有不同的最终结果。如果结果差异很大,这可能意味着存在问题。例如,数据可能没有自然分组,或者k的值选择不当。考虑到这一点,最好多次尝试聚类分析,以测试结果的稳健性。
为了了解分配和更新的过程如何在实践中工作,让我们重新审视一下假设的数据科学会议的案例。虽然这是一个简单的例子,但它将展示 k-means 在背后如何运作的基础。
使用距离分配和更新聚类
与 k-NN 一样,k-means 将特征值视为多维特征空间中的坐标。对于这次会议数据,只有两个特征,因此我们可以将特征空间表示为如前所示的二维散点图。
k-means 算法首先通过在特征空间中选择 k 个点作为聚类中心。这些中心是推动剩余样本归类的催化剂。通常,这些点通过从训练数据集中随机选择 k 个样本来确定。假设我们希望识别三个聚类,按照这种方法,k = 3 个点将被随机选择。这些点在下图中分别用星号、三角形和菱形表示:
值得注意的是,尽管前图中的三个聚类中心恰好相距较远,但这并不一定总是如此。由于它们是随机选择的,这三个中心也有可能是相邻的三个点。由于 k-means 算法对聚类中心的初始位置高度敏感,这意味着随机因素可能对最终的聚类结果产生重大影响。
为了解决这个问题,k-means 可以通过不同的方法来选择初始中心。例如,一种变体会在特征空间中的任意位置选择随机值(而不仅仅是从数据中选择已观察到的值)。另一种选择是完全跳过这一步;通过将每个样本随机分配到一个聚类中,算法可以立即跳到更新阶段。每种方法都会对最终的聚类集添加特定的偏差,你可能可以利用这些偏差来改进你的结果。
注意
2007 年,提出了一种名为 k-means++ 的算法,它提供了一种选择初始聚类中心的替代方法。该方法声称是一种高效的方式,能够在减少随机因素影响的同时,接近最优的聚类结果。欲了解更多信息,请参阅 Arthur D, Vassilvitskii S。k-means++: 精心初始化的优势。第十八届 ACM-SIAM 离散算法年会论文集,2007:1027–1035。
选择初始聚类中心后,其他样本会根据距离函数被分配到最近的聚类中心。你应该还记得我们在学习 k-最近邻时研究了距离函数。传统上,k-means 使用欧几里得距离,但也有时使用曼哈顿距离或闵可夫斯基距离。
回想一下,如果 n 表示特征的数量,则示例 x 和示例 y 之间的欧几里得距离公式为:
例如,如果我们要比较一位有五篇计算机科学论文和一篇数学论文的访客与一位没有计算机科学论文但有两篇数学论文的访客,我们可以在 R 中按如下方式计算:
> sqrt((5 - 0)² + (1 - 2)²)
[1] 5.09902
使用该距离函数,我们计算每个示例与每个聚类中心之间的距离。然后,示例被分配到最近的聚类中心。
提示
请记住,由于我们正在使用距离计算,所有特征需要是数值型的,且值应提前标准化到一个标准范围。第三章中讨论的方法,惰性学习——使用最近邻分类,对这个任务会很有帮助。
如下图所示,三个聚类中心将示例分为三个部分,分别标记为聚类 A、聚类 B和聚类 C。虚线表示由聚类中心创建的Voronoi 图的边界。Voronoi 图显示了离某个聚类中心比其他任何中心都近的区域;三个边界相交的顶点是离所有三个聚类中心最远的点。利用这些边界,我们可以轻松看到每个初始 k-means 种子所占据的区域:
现在初始分配阶段已经完成,k-means 算法进入更新阶段。更新聚类的第一步是将初始中心移动到新的位置,称为质心,其位置是当前分配给该聚类的点的平均位置。下图展示了当聚类中心移动到新的质心时,Voronoi 图中的边界也会发生变化,而原本在聚类 B中的一个点(由箭头表示)被加入到了聚类 A中:
由于此次重新分配,k-means 算法将继续进行另一个更新阶段。在移动聚类中心、更新聚类边界并重新分配点到新聚类之后(如箭头所示),图形如下所示:
由于又有两个点被重新分配,因此必须进行另一次更新,这将移动中心并更新聚类边界。然而,由于这些变化没有导致任何重新分配,k-means 算法停止。聚类分配现在是最终的:
最终的聚类结果可以通过两种方式之一来报告。首先,你可以简单地报告每个样本的聚类分配,如 A、B 或 C。或者,你可以报告最终更新后聚类中心的坐标。无论哪种报告方式,你都可以通过计算中心点或将每个样本分配给其最近的聚类来定义聚类边界。
选择适当数量的聚类
在 k-means 的介绍中,我们了解到该算法对随机选择的聚类中心非常敏感。实际上,如果我们在前一个例子中选择了不同的三个起始点,可能会得到与我们预期不同的数据分组。同样,k-means 对聚类数量也非常敏感;这个选择需要一个微妙的平衡。将 k 设置得非常大将提高聚类的同质性,但同时也有过拟合数据的风险。
理想情况下,你应该具有 先验 知识(即先前的信念)关于真实的分组情况,并可以利用这些信息来选择聚类数量。例如,如果你在对电影进行聚类,你可能会将 k 设置为奥斯卡奖提名的类型数量。在我们之前讨论的数据科学会议座位问题中,k 可能反映了受邀的学术领域数量。
有时候,聚类的数量由业务需求或分析的动机决定。例如,会议厅中的桌子数量可能决定了应该从数据科学参与者名单中创建多少个小组。将这个思路扩展到另一个商业案例,如果市场部门只有限制资源来创建三种不同的广告活动,那么将 k = 3 设置为将所有潜在客户分配给三个吸引点中的一个可能是合理的。
在没有任何先验知识的情况下,有一个经验法则建议将 k 设置为 (n / 2) 的平方根,其中 n 是数据集中的样本数量。然而,对于大型数据集来说,这个经验法则可能会导致聚类数量过多而难以处理。幸运的是,还有其他统计方法可以帮助找到合适的 k-means 聚类集。
一种名为 肘部法则 的技术试图衡量在不同的 k 值下,聚类内的同质性或异质性是如何变化的。正如下图所示,随着聚类数量的增加,聚类内部的同质性预期会增加;同样,异质性也会随着聚类数量的增加而继续减少。尽管你可以继续观察到每个样本被分配到其自己的聚类,目标不是最大化同质性或最小化异质性,而是找到一个 k,在这个点之后,收益递减。这种 k 值被称为 肘部点,因为它看起来像一个肘部。
有许多统计方法可以衡量聚类内部的同质性和异质性,这些方法可以与肘部法(以下信息框提供了详细的引用)一起使用。然而,在实际应用中,并不总是可行的反复测试大量k值。部分原因是,聚类大数据集本身就可能非常耗时;而反复进行数据聚类则更加浪费时间。无论如何,需要精确的最优聚类集的应用相对较少。在大多数聚类应用中,选择一个方便的k值而非严格的性能要求通常就足够了。
注意
对于关于聚类性能度量的大量综述,请参考:Halkidi M, Batistakis Y, Vazirgiannis M。关于聚类验证技术。智能信息系统杂志。2001;17:107-145。
设置k的过程本身有时会带来有趣的洞见。通过观察随着k变化,聚类的特征如何变化,可能会推测出数据自然的边界所在。聚集得更紧密的组变化较小,而异质性较大的组则会随着时间的推移不断形成和解散。
一般来说,花费很少的时间去担心k是否完全准确是明智的。下一个例子将展示,即使是从一部好莱坞电影中借来的一点点专业知识,也能用于设定k,以便发现可操作且有趣的聚类。由于聚类是无监督的,任务的本质实际上是你如何理解它;真正的价值在于从算法发现中获得的洞察。
示例——使用 k-means 聚类寻找青少年市场细分
与朋友在社交网络服务(SNS)上互动,例如 Facebook、Tumblr 和 Instagram,已经成为全球青少年的一项通行仪式。这些青少年通常拥有相对较多的可支配收入,因此成为了企业争相吸引的群体,企业希望通过他们销售零食、饮料、电子产品和卫生用品。
使用这些网站的数百万青少年消费者已经吸引了市场营销人员的关注,他们在日益竞争激烈的市场中努力寻找竞争优势。一种获得这种优势的方法是识别出具有相似品味的青少年群体,以便客户避免将广告投放给那些对所售产品没有兴趣的青少年。例如,运动服饰可能很难成功地销售给对体育不感兴趣的青少年。
给定青少年 SNS 页面的文本,我们可以识别出一些具有共同兴趣的群体,例如体育、宗教或音乐。聚类可以自动化发现这一人群中的自然分段过程。然而,是否认为这些聚类有趣,以及如何利用它们进行广告投放,仍然需要我们自己决定。让我们从头到尾尝试这一过程。
第 1 步 – 收集数据
对于本次分析,我们将使用一个代表 2006 年在一个著名 SNS 上有个人资料的 30,000 名美国高中生的随机样本数据集。为了保护用户的匿名性,SNS 的名称将保持不公开。然而,在数据收集时,这个 SNS 是美国青少年常用的网络平台。因此,可以合理假设这些个人资料代表了 2006 年美国青少年群体的一个较为广泛的横截面。
提示
该数据集是 Brett Lantz 在大学进行青少年身份的社会学研究时编制的。如果你用于研究目的,请引用这本书的章节。完整数据集可在 Packt Publishing 网站上下载,文件名为snsdata.csv
。为了进行互动操作,本章假设你已将该文件保存到你的 R 工作目录中。
数据在四个高中毕业年份(2006 年至 2009 年)之间均匀抽样,代表了数据收集时的高年级、低年级、二年级和一年级学生。使用自动化的网络爬虫,下载了 SNS 个人资料的完整文本,并记录了每个青少年的性别、年龄和 SNS 好友数量。
使用了一个文本挖掘工具,将其余的 SNS 页面内容分割成单词。从所有页面中出现的前 500 个单词中,选择了 36 个单词来代表五类兴趣:即课外活动、时尚、宗教、浪漫和反社会行为。选中的 36 个单词包括足球、性感、亲吻、圣经、购物、死亡和毒品等。最终的数据集显示了每个人在其 SNS 个人资料中每个单词出现的次数。
第 2 步 – 探索和准备数据
我们可以使用read.csv()
的默认设置将数据加载到数据框中:
> teens <- read.csv("snsdata.csv")
让我们也快速查看一下数据的具体情况。str()
输出的前几行如下:
> str(teens)
'data.frame': 30000 obs. of 40 variables:
$ gradyear : int 2006 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 条记录(占 9%)缺少性别数据。有趣的是,SNS 数据中女性的数量是男性的四倍多,这表明男性使用 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%)缺少年龄数据。令人担忧的是,最小和最大值似乎不合理;一个 3 岁或 106 岁的学生不太可能上高中。为了确保这些极端值不会对分析造成问题,我们需要在继续之前清理它们。
高中生的合理年龄范围应包括至少 13 岁但不满 20 岁的学生。任何超出此范围的年龄值应视为缺失数据——我们无法信任提供的年龄。为了重新编码年龄变量,我们可以使用ifelse()
函数,当年龄至少为 13 岁且小于 20 岁时,将teen$age
的值设为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.26 17.25 18.22 20.00 5523
不幸的是,现在我们制造了一个更大的缺失数据问题。在继续分析之前,我们需要找到一种处理这些缺失值的方法。
数据准备——虚拟编码缺失值
处理缺失值的一个简单方法是排除任何缺失值的记录。然而,如果你考虑这种做法的后果,可能会在做之前三思而后行——仅仅因为这种方法简单,并不意味着它是个好主意!这种方法的问题在于,即使缺失值的数量不多,你也可能会轻易排除大量数据。
例如,假设在我们的数据中,缺失性别值的人群与缺失年龄数据的人群完全不同。这意味着,如果你排除了缺失性别或年龄的记录,你将排除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 个缺失的年龄值。由于年龄是数值型的,为未知值创建一个额外的类别没有意义——相对于其他年龄,你会将"未知"排名在哪里呢?相反,我们将使用一种称为插补的不同策略,它涉及用真实值的猜测填补缺失数据。
你能想到我们如何能够利用 SNS 数据来推断青少年的年龄吗?如果你考虑使用毕业年份,那么你有正确的想法。一个毕业队列中的大多数人在一个日历年内出生。如果我们能够确定每个队列的典型年龄,我们将有一个相当合理的估计来描述该毕业年份的学生的年龄。
找到典型值的一种方法是计算平均值或均值。如果我们尝试应用 mean()
函数,就像我们之前分析过的那样,会有一个问题:
> mean(teens$age)
[1] NA
问题在于对包含缺失数据的向量计算均值是未定义的。由于我们的年龄数据包含缺失值,mean(teens$age)
返回一个缺失值。我们可以通过添加额外的参数在计算均值之前删除缺失值来纠正这一点:
> 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()
函数,它返回一个包含组均值的向量,这样结果的长度与原始向量相同:
> 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 安装中。如果你碰巧没有这个包,可以像安装其他包一样安装它,并使用 library(stats)
命令加载它。尽管各种 R 包中有很多 k-means 函数,但 stats
包中的 kmeans()
函数被广泛使用,并提供了该算法的标准实现。
kmeans()
函数需要一个仅包含数值数据的数据框以及一个指定所需簇数的参数。如果你准备好了这两样东西,实际构建模型的过程就很简单。问题在于,选择合适的数据和簇的组合有时是一门艺术;这通常需要经过大量的反复试验。
我们将通过仅考虑代表青少年 SNS 个人资料中各种兴趣出现次数的 36 个特征来开始我们的聚类分析。为方便起见,我们创建一个仅包含这些特征的数据框:
> interests <- teens[5:40]
如果你记得第三章,懒学习 - 使用最近邻分类,在进行任何距离计算分析之前的常见做法是对特征进行归一化或 z-score 标准化,以确保每个特征使用相同的范围。通过这样做,你可以避免某些特征仅因为它们具有比其他特征更大的数值范围而主导结果的问题。
z-score 标准化过程会重新缩放特征,使其均值为零,标准差为一。这种转换改变了数据的解释方式,这在此处可能会有用。具体来说,如果某人在其个人资料中提到足球三次,在没有更多信息的情况下,我们无法判断这是否意味着他们比其他人更喜欢足球。另一方面,如果 z-score 为三,我们就知道他们提到足球的次数远远超过了平均水平的青少年。
要对interests
数据框应用 z-score 标准化,我们可以使用lapply()
配合scale()
函数,如下所示:
> interests_z <- as.data.frame(lapply(interests, scale))
由于lapply()
返回的是矩阵,因此必须使用as.data.frame()
函数将其转换回数据框形式。
我们最后的决定是决定使用多少个簇来对数据进行分段。如果我们使用太多的簇,可能会发现它们过于具体,无法发挥作用;相反,选择太少的簇可能导致分组不均。你应该敢于尝试不同的k值。如果你不喜欢结果,可以轻松尝试另一个值并重新开始。
提示
如果你对分析群体有所了解,选择簇的数量会更加容易。对自然分组的真实数量有直觉可以帮助你节省一些试错的时间。
为了帮助我们预测数据中的簇的数量,我想引用我最喜欢的电影之一,《早餐俱乐部》,这是一部 1985 年上映的成长喜剧片,由约翰·休斯执导。电影中的青少年角色按照五种刻板印象进行分类:学霸、运动员、怪胎、公主和罪犯。考虑到这些身份在流行的青少年小说中常常出现,五个似乎是k的一个合理起点。
要使用 k-means 算法将青少年的兴趣数据分成五个簇,我们在interests
数据框上使用kmeans()
函数。由于 k-means 算法使用随机起始点,因此使用set.seed()
函数来确保结果与以下示例中的输出一致。如果你记得前几章的内容,这个命令初始化了 R 的随机数生成器,设置为特定的序列。如果没有这个命令,每次运行 k-means 算法时结果都会不同:
> set.seed(2345)
> teen_clusters <- kmeans(interests_z, 5)
k-means 聚类过程的结果是一个名为teen_clusters
的列表,存储了五个聚类的各项属性。让我们深入了解一下,看看算法是如何将青少年的兴趣数据划分的。
提示
如果你发现你的结果与这里展示的不同,请确保在运行kmeans()
函数之前,立即执行set.seed(2345)
命令。
步骤 4 – 评估模型性能
评估聚类结果可能是有一定主观性的。最终,模型的成功或失败取决于聚类是否能为其预期的目的提供帮助。由于本次分析的目标是识别具有相似兴趣的青少年群体,以便用于市场营销,我们将主要从定性角度来衡量成功。对于其他聚类应用,可能需要更多定量的成功衡量标准。
评估一组聚类有效性的最基本方法之一是检查每个组中示例的数量。如果这些组太大或太小,它们可能不会非常有用。要获取kmeans()
聚类的大小,请使用teen_clusters$size
组件,如下所示:
> teen_clusters$size
[1] 871 600 5981 1034 21514
在这里,我们看到了我们请求的五个聚类。最小的聚类有 600 个青少年(占 2%),而最大的聚类有 21,514 个(占 72%)。虽然最大和最小聚类之间人数差距较大,这有点令人担忧,但在没有更仔细检查这些组的情况下,我们无法知道这是否表示存在问题。也许,聚类大小的差异反映了某些实际情况,例如一大群有相似兴趣的青少年,或者它可能是由初始的 k-means 聚类中心引起的随机巧合。随着我们开始查看每个聚类的同质性,我们会了解更多。
提示
有时,k-means 可能会找到极小的聚类——有时甚至只有一个点。如果初始聚类中心恰好落在一个远离其他数据的离群点上,就可能发生这种情况。是否将这样的极小聚类视为一个真实的发现,代表一个极端案例的聚类,或者视为由随机机会引起的问题,并不总是很明确。如果遇到这个问题,可以考虑使用不同的随机种子重新运行 k-means 算法,看看这个小聚类是否对不同的起始点具有稳健性。
要更深入地了解这些聚类,我们可以通过teen_clusters$centers
组件检查聚类中心的坐标,以下是前四个兴趣的情况:
> teen_clusters$centers
basketball football soccer softball
1 0.16001227 0.2364174 0.10385512 0.07232021
2 -0.09195886 0.0652625 -0.09932124 -0.01739428
3 0.52755083 0.4873480 0.29778605 0.37178877
4 0.34081039 0.3593965 0.12722250 0.16384661
5 -0.16695523 -0.1641499 -0.09033520 -0.11367669
输出的行(标记为1
到5
)表示五个聚类,而每行中的数字表示该聚类在每列顶部列出的兴趣项的平均值。由于这些值是经过 z 分数标准化的,正值表示该兴趣项高于所有青少年总体均值,负值表示低于总体均值。例如,第三行在篮球这一列中的值最高,这意味着聚类3
在所有聚类中对篮球的平均兴趣最高。
通过检查聚类在每个兴趣类别上是否高于或低于平均水平,我们可以开始注意到区分聚类的模式。实际上,这涉及打印聚类中心并搜索其中的模式或极端值,类似于一个数字版的单词搜索谜题。以下截图显示了五个聚类在 36 个青少年兴趣中的 19 个兴趣的突出模式:
根据这一子集的兴趣数据,我们已经能够推断出一些聚类的特征。聚类 3在所有体育项目上的兴趣水平都显著高于平均水平。这表明这可能是早餐俱乐部刻板印象中的运动员群体。聚类 1包括最多提到“啦啦队”的内容、词汇“热”和高于平均水平的足球兴趣。这些是所谓的公主吗?
通过继续以这种方式检查聚类,我们可以构建一张表,列出每个群体的主要兴趣。在以下表格中,显示了每个聚类与其他聚类最具区别性的特征,以及最能准确描述该群体特点的早餐俱乐部身份。
有趣的是,聚类 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 4 <NA> 18.995 10
使用aggregate()
函数,我们还可以查看各群体的不同人口特征。群体间的平均年龄变化不大,这并不令人惊讶,因为这些青少年的身份通常在上高中之前就已确定。情况如下所示:
> aggregate(data = teens, age ~ cluster, mean)
cluster age
1 1 16.86497
2 2 17.39037
3 3 17.07656
4 4 17.11957
5 5 17.29849
另一方面,不同群体中女性所占比例存在一些显著差异。这是一个非常有趣的发现,因为我们并未使用性别数据来创建群体,但群体仍然能预测性别:
> aggregate(data = teens, female ~ cluster, mean)
cluster female
1 1 0.8381171
2 2 0.7250000
3 3 0.8378198
4 4 0.8027079
5 5 0.6994515
回想一下,SNS 用户中大约 74%是女性。群体 1,即所谓的公主群体,女性比例接近 84%,而群体 2和群体 5的女性比例仅约为 70%。这些差异表明青少年男孩和女孩在社交网络页面上讨论的兴趣有所不同。
鉴于我们在预测性别上的成功,您可能也会怀疑群体能否预测用户拥有的朋友数量。这个假设似乎得到了数据的支持,具体如下:
> aggregate(data = teens, friends ~ cluster, mean)
cluster friends
1 1 41.43054
2 2 32.57333
3 3 37.16185
4 4 30.50290
5 5 27.70052
平均而言,公主拥有最多的朋友(41.4),其次是运动员(37.2)和学霸(32.6)。最低的是罪犯(30.5)和精神病患者(27.7)。与性别一样,青少年朋友数量与其预测聚类之间的联系非常显著,尽管我们并未将朋友数据作为聚类算法的输入。另外有趣的是,朋友数量似乎与每个群体在高中受欢迎程度的刻板印象相关;那些在刻板印象中受欢迎的群体往往拥有更多的朋友。
群体成员、性别和朋友数量之间的关联表明,聚类可以是行为的有用预测指标。通过这种方式验证它们的预测能力,可以使聚类在向营销团队推销时更具吸引力,从而最终提升算法的表现。
总结
我们的发现支持了那句流行的格言:“物以类聚,人以群分。”通过使用机器学习方法将青少年与有相似兴趣的人进行聚类,我们能够开发出一种青少年身份的分类法,该分类法能够预测个人特征,如性别和朋友数量。这些相同的方法也可以应用于其他情境,并取得类似的结果。
本章仅介绍了聚类的基本概念。作为一种非常成熟的机器学习方法,k-means 算法有许多变体,还有许多其他聚类算法,它们为任务带来了独特的偏差和启发式方法。基于本章的基础,你将能够理解并应用其他聚类方法来解决新的问题。
在下一章,我们将开始探讨衡量学习算法成功的方法,这些方法适用于许多机器学习任务。虽然我们的过程一直在评估学习的成功,但为了获得最高的性能水平,能够在最严格的术语下定义和衡量它是至关重要的。
第十章:评估模型性能
当只有富人能负担得起教育时,测试和考试并没有评估学生的潜力。相反,教师是根据家长的要求来评判的,家长们希望知道他们的孩子是否学到了足够的知识,以证明教员的薪水。显然,随着时间的推移,这种情况发生了变化。现在,这些评估被用来区分高成就和低成就的学生,并将他们筛选到职业和其他机会中。
鉴于这个过程的重要性,投入了大量精力来开发准确的学生评估。公平的评估有大量的问题,覆盖广泛的主题,奖励真实的知识而不是运气猜测。它们还要求学生思考他们之前从未遇到过的问题。因此,正确的回答表明学生能够更广泛地概括他们的知识。
评估机器学习算法的过程与评估学生的过程非常相似。由于算法有不同的优缺点,测试应该能够区分不同的学习者。预测学习者在未来数据上的表现也同样重要。
本章提供了评估机器学习者所需的信息,例如:
-
为什么预测准确率不足以衡量性能,以及你可以使用的其他性能度量
-
确保性能度量合理反映模型预测或预测未见过案例的能力的方法
-
如何使用 R 语言将这些更有用的度量和方法应用到前几章中讨论的预测模型上
就像学习某个主题的最佳方式是尝试将其教授给别人一样,教学和评估机器学习者的过程将为你提供更多关于迄今为止所学方法的洞察。
衡量分类的性能
在前几章中,我们通过将正确预测的比例除以预测的总数来衡量分类器的准确性。这表示学习者在多少情况下是正确的或错误的。例如,假设在 100,000 名新生儿中,有 99,990 名婴儿的基因缺陷是否携带被分类器正确预测。这样的话,准确率将是 99.99%,错误率仅为 0.01%。
乍一看,这似乎是一个极其准确的分类器。然而,在将你孩子的生命交给该测试之前,最好先收集更多的信息。如果这种基因缺陷仅在每 10 万个婴儿中有 10 个发现,怎么办?无论在什么情况下,始终预测没有缺陷的测试对 99.99%的所有案例都是正确的,但对 100%最重要的案例却是错误的。换句话说,尽管预测非常准确,但这个分类器对于防止可治疗的出生缺陷并没有多大帮助。
提示
这是类别不平衡问题的一个后果,指的是数据中大多数记录属于同一类别所带来的问题。
尽管有许多方法可以衡量分类器的性能,但最好的衡量标准总是能够捕捉分类器在其预期目标上是否成功的标准。定义性能度量时,关键是要以效用为导向,而非单纯的准确率。为此,我们将开始探索从混淆矩阵中衍生出的各种替代性性能度量方法。然而,在我们开始之前,我们需要考虑如何准备分类器进行评估。
在 R 中处理分类预测数据
评估分类模型的目标是更好地理解其性能如何推断到未来的案例。由于在实际环境中测试一个尚未验证的模型通常是不可行的,我们通常通过要求模型对一个包含类似未来任务的案例的数据集进行分类,从而模拟未来的情况。通过观察学习者对这一检验的回应,我们可以了解其优点和缺点。
尽管我们在前面的章节中评估了分类器,但值得反思一下我们所拥有的数据类型:
-
实际类别值
-
预测类别值
-
预测的估计概率
实际值和预测的类别值可能是显而易见的,但它们是评估的关键。就像老师用答案解析来评估学生的答案一样,我们需要知道机器学习者预测的正确答案。目标是保持两个数据向量:一个存储正确或实际的类别值,另一个存储预测的类别值。这两个向量必须包含相同数量的值,并按相同的顺序排列。预测值和实际值可以存储为独立的 R 向量,或者在一个 R 数据框中作为列存储。
获取这些数据很容易。实际的类别值直接来自测试数据集中的目标特征。预测类别值是通过基于训练数据构建的分类器来获取的,并应用于测试数据。对于大多数机器学习包来说,这通常涉及对模型对象和测试数据框应用predict()
函数,例如:predicted_outcome <- predict(model, test_data)
。
到目前为止,我们仅仅使用这两个数据向量来检查分类预测。然而,大多数模型可以提供另一个有用的信息。即使分类器对每个样本做出单一的预测,它对于某些决策的信心可能会高于其他决策。例如,分类器可能有 99% 的把握认为包含“免费”和“铃声”字样的短信是垃圾短信,但对含有“今晚”字样的短信只有 51% 的把握是垃圾短信。在这两种情况下,分类器都会将消息归类为垃圾短信,但对其中一个决策的信心远高于另一个。
研究这些内部预测概率可以提供有用的数据来评估模型的表现。如果两个模型犯了相同数量的错误,但其中一个能够更准确地评估其不确定性,那么这个模型更为智能。理想情况下,应该找到一个在做出正确预测时非常自信,而在面对不确定时则保持谨慎的学习者。信心与谨慎之间的平衡是模型评估的关键部分。
不幸的是,获取内部预测概率可能有些棘手,因为不同的分类器获取预测概率的方法不同。通常,对于大多数分类器,predict()
函数用于指定所需的预测类型。要获取单一预测类别(如垃圾邮件或正常邮件),通常需要将type = "class"
参数设置为该值。要获取预测概率,type
参数应根据所使用的分类器设置为"prob"
、"posterior"
、"raw"
或"probability"
之一。
提示
本书中介绍的几乎所有分类器都会提供预测概率。在每个模型的语法框中都会包含type
参数。
例如,要输出在第五章中构建的 C5.0 分类器的预测概率,可以使用predict()
函数,并设置type = "prob"
,如下所示:
> predicted_prob <- predict(credit_model, credit_test, type = "prob")
为了进一步说明评估学习算法的过程,让我们更详细地看看在第四章中开发的 SMS 垃圾邮件分类模型的表现,概率学习 – 使用朴素贝叶斯分类。要输出朴素贝叶斯的预测概率,可以使用predict()
函数,并设置type = "raw"
,如下所示:
> sms_test_prob <- predict(sms_classifier, sms_test, type = "raw")
在大多数情况下,predict()
函数会为每个结果类别返回一个概率。例如,在像 SMS 分类器这样的二分类模型中,预测的概率可能是一个矩阵或数据框,如下所示:
> 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,因为这是唯一的两个可能结果。在构建评估数据集时,确保你使用的是与所关注类别级别相符的正确概率非常重要。为了避免混淆,在二分类情况下,甚至可以考虑去掉其中一个类别的预测向量。
为了方便评估过程,可以构建一个数据框,包含预测的类别值、实际类别值,以及感兴趣的估计概率。
提示
构建评估数据集的步骤为了简洁起见已被省略,但它们包含在本章的代码中,可以在 Packt Publishing 网站上找到。要跟随本示例操作,请下载sms_results.csv
文件,并使用sms_results <- read.csv("sms_results.csv")
命令将其加载到数据框中。
sms_results
数据框非常简单,它包含四个向量,包含 1,390 个值。一个向量包含表示实际短信类型(spam
或ham
)的值,一个向量表示朴素贝叶斯模型的预测类型,第三个和第四个向量分别表示消息是spam
或ham
的概率:
> 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
对于这六个测试案例,预测值与实际的短信类型一致;模型正确地预测了它们的状态。此外,预测的概率表明模型对这些预测极其自信,因为它们的值都接近零或一。
当预测值和实际值远离零和一时,会发生什么?使用subset()
函数,我们可以找出一些这样的记录。以下输出显示了模型预测的spam
概率介于 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
这些案例说明了一个重要的事实:模型可以非常有信心,但也可能极其错误。所有这六个测试案例都是spam
,而分类器认为它们被判定为ham
的概率不低于 98%。
尽管存在这些错误,这个模型是否仍然有用呢?我们可以通过对评估数据应用各种错误度量来回答这个问题。事实上,许多这样的度量是基于我们在前几章中已广泛使用的工具。
更深入地看混淆矩阵
混淆 矩阵是一个表格,用于根据预测值是否与实际值匹配来对预测进行分类。表格的一个维度表示预测值的可能类别,另一个维度表示实际值的类别。尽管我们至今只见过 2 x 2 的混淆矩阵,但也可以为预测任何类别值的模型创建矩阵。下图展示了熟悉的二分类模型的混淆矩阵,以及三类模型的 3 x 3 混淆矩阵。
当预测值与实际值相同,说明是正确分类。正确的预测会出现在混淆矩阵的对角线上(用O表示)。对角线外的矩阵单元格(用X表示)表示预测值与实际值不同的情况,这些是错误预测。分类模型的性能度量是基于这些表格中对角线上的预测数和对角线外的预测数:
最常见的性能衡量指标考虑的是模型区分一个类别与所有其他类别的能力。关注类别被称为正类,而所有其他类别被称为负类。
提示
使用“正类”和“负类”这些术语并不意味着任何价值判断(即好与坏),也不一定表示结果是存在或不存在(例如,出生缺陷与否)。正类的选择甚至可以是任意的,比如在模型预测“晴天与雨天”或“狗与猫”等类别的情况下。
正类和负类预测之间的关系可以通过一个 2 x 2 的混淆矩阵来表示,矩阵记录了预测是否属于以下四个类别之一:
-
真正(TP):正确地分类为关注类别
-
真负(TN):正确地分类为非关注类别
-
假正(FP):错误地分类为关注类别
-
假负(FN):错误地分类为非关注类别
对于垃圾邮件分类器,正类是 spam
,因为这是我们希望检测的结果。我们可以将混淆矩阵想象为以下示意图所示:
以这种方式呈现的混淆矩阵是许多重要模型性能指标的基础。在接下来的部分,我们将使用该矩阵更好地理解准确率的含义。
使用混淆矩阵衡量性能
使用 2 x 2 混淆矩阵,我们可以形式化定义预测的准确率(有时称为成功率)为:
在此公式中,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)
结果是一个包含大量附加细节的混淆矩阵:
我们在前几章中已经使用了CrossTable()
,所以现在你应该对输出结果比较熟悉。如果你忘记了如何解读输出结果,只需参考关键部分(标记为Cell Contents
),它提供了表格单元格中每个数字的定义。
我们可以使用混淆矩阵来获得准确率和误差率。由于准确率是(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 - 0.9748201
[1] 0.0251799
尽管这些计算看起来很简单,但重要的是要练习思考混淆矩阵的各个组成部分是如何相互关联的。在接下来的章节中,你将看到如何将这些组件以不同方式组合,从而创建各种附加的性能度量。
除了准确度——其他的性能度量
无数的性能度量已经为特定目的在诸如医学、信息检索、市场营销和信号检测理论等领域开发并使用。涵盖所有这些度量将填满数百页,因此在这里进行全面描述是不可行的。相反,我们将只考虑机器学习文献中最常用和最常引用的一些度量。
Max Kuhn 的分类与回归训练包caret
包括计算许多此类性能度量的函数。该包提供了大量的工具,用于准备、训练、评估和可视化机器学习模型和数据。除了在这里的使用外,我们还将在第十一章,提升模型性能中广泛使用caret
。在继续之前,你需要使用install.packages("caret")
命令安装该包。
提示
关于caret
的更多信息,请参考:Kuhn M. 使用 caret 包在 R 中构建预测模型。统计学期刊软件。2008 年;28。
caret
包提供了另一个函数来创建混淆矩阵。如下命令所示,其语法与 table()
类似,但有一个小的差异。因为 caret
提供了考虑到分类正类能力的模型性能度量,所以应指定 positive
参数。在本例中,由于 SMS 分类器旨在检测 spam
,我们将设置 positive = "spam"
,如下所示:
> library(caret)
> confusionMatrix(sms_results$predict_type,
sms_results$actual_type, positive = "spam")
这将产生如下输出:
输出顶部是一个混淆矩阵,类似于 table()
函数生成的矩阵,但它被转置了。输出还包括一组性能度量。其中一些,如准确性,是我们熟悉的,而许多其他度量则是新的。让我们看看几个最重要的指标。
Kappa 统计量
Kappa 统计量(在之前的输出中标记为Kappa
)通过考虑仅凭随机猜测就能做出正确预测的可能性来调整准确性。这对于具有严重类别不平衡的数据集尤为重要,因为分类器只需始终猜测最频繁的类别就能获得高准确率。Kappa 统计量只有在分类器的正确率超过这种简单策略时,才会给予奖励。
Kappa 值的范围从 0 到最大值 1,表示模型预测与真实值之间的完美协议。值小于 1 表示协议不完全。根据模型的使用方式,Kappa 统计量的解释可能有所不同。以下是常见的解释:
-
差的协议 = 小于 0.20
-
公平协议 = 0.20 到 0.40
-
中等协议 = 0.40 到 0.60
-
良好的协议 = 0.60 到 0.80
-
非常好的协议 = 0.80 到 1.00
需要注意的是,这些类别是主观的。虽然“良好的协议”可能足以预测某人最喜欢的冰淇淋口味,但如果目标是识别出生缺陷,单凭“非常好的协议”可能不足够。
提示
有关前述量表的更多信息,请参阅:Landis JR, Koch GG. The measurement of observer agreement for categorical data. Biometrics. 1997; 33:159-174.
以下是计算 Kappa 统计量的公式。在这个公式中,Pr(a) 指的是实际协议的比例,而 Pr(e) 指的是在假设随机选择的情况下,分类器与真实值之间的期望协议:
提示
定义 Kappa 统计量的方法不止一种。这里描述的最常见方法使用 Cohen 的 Kappa 系数,该方法在论文中有所阐述:Cohen J. A coefficient of agreement for nominal scales. Education and Psychological Measurement. 1960; 20:37-46.
这些比例可以通过混淆矩阵轻松获得,一旦你知道该从哪里查找。让我们考虑使用CrossTable()
函数创建的 SMS 分类模型的混淆矩阵,为了方便起见,这里重复显示:
请记住,每个单元格底部的值表示所有实例中落入该单元格的比例。因此,计算观察到的一致性Pr(a)时,我们只需将预测类型与实际短信类型一致的所有实例的比例相加。这样,我们可以计算Pr(a)如下:
> pr_a <- 0.865 + 0.109
> pr_a
[1] 0.974
对于这个分类器,观察值和实际值有 97.4%的时间是一致的——你会注意到这与准确度是相同的。kappa 统计量根据预期一致性Pr(e)调整了准确度,Pr(e)是指在假设两者都是根据观察到的比例随机选择的前提下,仅凭运气,预测值和实际值匹配的概率。
为了找到这些观察到的比例,我们可以使用我们在第四章中学到的概率规则,概率学习 – 使用朴素贝叶斯分类。假设两个事件是独立的(意味着一个事件不会影响另一个事件),概率规则指出,两者同时发生的概率等于每个事件发生概率的乘积。例如,我们知道两者都选择正常邮件的概率是:
Pr(实际类型是正常邮件) * Pr(预测类型是正常邮件)
两者都选择垃圾邮件的概率是:
Pr(实际类型是垃圾邮件) * Pr(预测类型是垃圾邮件)
预测类型或实际类型是垃圾邮件(spam)或正常邮件(ham)的概率可以从行或列的总计中获得。例如,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。Visualizing Categorical Data (vcd
) 包中的 Kappa()
函数(请注意大写的 'K')使用预测值和实际值的混淆矩阵。在安装该包后(使用命令 install.packages("vcd")
),可以使用以下命令获取 kappa 值:
> library(vcd)
> Kappa(table(sms_results$actual_type, sms_results$predict_type))
value ASE
Unweighted 0.8825203 0.01949315
Weighted 0.8825203 0.01949315
我们关心的是不带权的 kappa。值 0.88 与我们预期的相符。
提示
加权 kappa 用于存在不同程度一致性的情况。例如,使用“冷、凉、温暖、热”这样的尺度时,温暖与热的值更为接近,而与冷的值差异较大。在二分类事件中,例如垃圾邮件和正常邮件,带权和不带权的 kappa 统计量将是相同的。
Inter-Rater Reliability (irr
) 包中的 kappa2()
函数可以用来计算存储在数据框中的预测值和实际值向量的 kappa。安装该包(使用 install.packages("irr")
命令)后,可以使用以下命令获取 kappa 值:
> 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 统计量完全无关!
灵敏度和特异性
寻找一个有用的分类器通常涉及在过于保守和过于激进的预测之间做出平衡。例如,一个电子邮件过滤器可以通过激进地过滤几乎所有的正常邮件来确保删除每一封垃圾邮件。另一方面,为了确保不误过滤正常邮件,可能需要允许不可接受的垃圾邮件通过过滤器。一对性能度量捕捉了这种权衡:灵敏度和特异性。
模型的灵敏度(也叫做真正率)衡量的是正例中被正确分类的比例。因此,如下公式所示,它是通过将真正例数除以所有正例的总数(包括正确分类的真正例和错误分类的假负例)来计算的:
模型的特异性(也叫做真负率)衡量的是负例中被正确分类的比例。与灵敏度类似,这个值是通过将真负例数除以所有负例的总数(包括真负例和假正例)来计算的:
给定短信分类器的混淆矩阵,我们可以轻松手动计算这些度量。假设垃圾邮件为正类,我们可以确认 confusionMatrix()
输出中的数字是正确的。例如,灵敏度的计算公式如下:
> sens <- 152 / (152 + 31)
> sens
[1] 0.8306011
同样,对于特异性,我们可以计算:
> spec <- 1203 / (1203 + 4)
> spec
[1] 0.996686
caret
包提供了从预测值和实际值的向量直接计算灵敏度和特异性(sensitivity and specificity)的函数。请小心地指定 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 这样的竞争对手。在短信垃圾过滤器的案例中,高精准度意味着模型能够精确地识别垃圾短信,同时忽略非垃圾短信。
另一方面,召回率是衡量结果完整性的一个指标。如以下公式所示,召回率定义为真阳性数占所有阳性数的比例。你可能已经认识到这与灵敏度相同。然而,在这种情况下,解释略有不同。一个具有高召回率的模型捕捉到了大量的正例,这意味着它具有广泛的覆盖范围。例如,一个具有高召回率的搜索引擎会返回大量与搜索查询相关的文档。同样,短信垃圾邮件过滤器具有高召回率时,意味着大多数垃圾短信都能被正确识别。
我们可以从混淆矩阵中计算精确度和召回率。再次假设spam
是正类,精确度为:
> 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-measure
一种结合了精确度和召回率的模型性能度量方法称为F-measure(有时也叫F[1]分数或F-score)。F-measure 通过调和平均数将精确度和召回率结合起来,调和平均数是一种用于变化率的平均值类型。由于精确度和召回率都表示为介于零和一之间的比例,可以解释为比率,因此使用调和平均数而非常见的算术平均数。以下是 F-measure 的公式:
要计算 F-measure,请使用之前计算的精确度和召回率值:
> f <- (2 * prec * rec) / (prec + rec)
> f
[1] 0.8967552
这个计算结果与使用混淆矩阵中的计数值完全相同:
> f <- (2 * 152) / (2 * 152 + 4 + 31)
> f
[1] 0.8967552
由于 F 度量在一个数字中描述了模型的性能,它提供了一种方便的方式来并排比较多个模型。然而,这假设了精确度和召回率应该赋予相同的权重,这一假设并不总是有效。可以使用不同的权重来计算 F 分数,但选择权重可能在最好的情况下比较棘手,最坏的情况下则显得任意。更好的做法是将像 F 分数这样的度量与更全面考虑模型优缺点的方法结合使用,如下一节中描述的方法。
可视化性能权衡
可视化有助于更详细地理解机器学习算法的性能。当统计数据如敏感性和特异性,或精确度和召回率试图将模型性能简化为一个数字时,可视化则描绘了学习者在各种条件下的表现。
由于学习算法有不同的偏差,两个模型可能在准确率相似的情况下,在实现准确率的方式上存在巨大的差异。有些模型可能在一些预测上遇到困难,而其他模型则轻松完成这些预测,同时对于其他模型无法正确预测的情况表现得游刃有余。可视化提供了一种理解这些权衡的方法,通过将多个学习者并排比较在一个图表中。
ROCR
包提供了一套易于使用的函数,用于可视化分类模型的性能。它包括用于计算常见性能度量和可视化的大量函数。ROCR
官网 rocr.bioinf.mpi-sb.mpg.de/
列出了完整的功能集以及多个可视化功能示例。继续之前,请使用install.packages("ROCR")
命令安装该包。
提示
有关 ROCR 开发的更多信息,请参见:Sing T, Sander O, Beerenwinkel N, Lengauer T. ROCR:在 R 中可视化分类器性能。生物信息学。2005;21:3940-3941。
要使用ROCR
创建可视化,需要两个数据向量。第一个必须包含预测的类别值,第二个必须包含正类的估计概率。这些数据用于创建预测对象,然后可以通过ROCR
的绘图功能进行检查。
SMS 分类器的预测对象需要分类器的垃圾邮件概率估计值和实际类别标签。这些数据通过prediction()
函数结合在以下几行中:
> library(ROCR)
> pred <- prediction(predictions = sms_results$prob_spam,
labels = sms_results$actual_type)
接下来,performance()
函数将允许我们从刚刚创建的prediction
对象中计算性能度量,然后可以使用 R 的plot()
函数进行可视化。通过这三步,可以创建多种有用的可视化图。
ROC 曲线
接收者操作特征(ROC)曲线通常用于检验在避免假阳性的同时,检测真实阳性的权衡。正如你可能从名称中猜到的,ROC 曲线最初由通信领域的工程师开发。在二战时期,雷达和无线电操作员使用 ROC 曲线来衡量接收器区分真实信号和假警报的能力。今天,这一技术在可视化机器学习模型的有效性时依然非常有用。
典型 ROC 图的特征如下面的图所示。曲线定义在一个图上,纵轴表示真实阳性比例,横轴表示假阳性比例。由于这些值分别等同于灵敏度和(1 – 特异性),因此该图也被称为灵敏度/特异性图:
组成 ROC 曲线的点表示在不同假阳性阈值下的真实阳性率。为了创建这些曲线,分类器的预测结果按模型对正类的估计概率排序,最大的值排在前面。从原点开始,每个预测对真实阳性率和假阳性率的影响将导致曲线向上(对于正确预测)或向右(对于错误预测)延伸。
为了说明这一概念,前面的图中对比了三种假设的分类器。首先,从图的左下角到右上角的对角线代表一个没有预测价值的分类器。这种分类器以相同的速度检测到真实阳性和假阳性,意味着分类器无法区分二者。这是其他分类器评判的基准线。接近此线的 ROC 曲线表示模型没有太大用处。完美分类器的曲线通过一个点,表示 100%的真实阳性率和 0%的假阳性率。它能够在错误分类任何负结果之前正确识别所有的正结果。大多数现实世界的分类器与测试分类器类似,它们的表现位于完美分类器和无用分类器之间。
曲线越接近完美分类器,越能更好地识别正值。这可以通过一个统计量来衡量,称为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 却相同。正因为如此,单独使用 AUC 可能会产生误导。最佳做法是结合 AUC 和 ROC 曲线的定性检查一起使用。
使用ROCR
包创建 ROC 曲线涉及从我们之前计算的prediction
对象中构建一个performance
对象。由于 ROC 曲线绘制的是真实正例率与假正例率之间的关系,我们只需调用performance()
函数并指定tpr
和fpr
这两个度量值,如下代码所示:
> perf <- performance(pred, measure = "tpr", x.measure = "fpr")
使用perf
对象,我们可以通过 R 的plot()
函数可视化 ROC 曲线。如以下代码所示,可以使用许多标准参数来调整可视化效果,例如main
(添加标题)、col
(改变线条颜色)和lwd
(调整线条宽度):
> plot(perf, main = "ROC curve for SMS spam filter",
col = "blue", lwd = 3)
尽管plot()
命令足以创建有效的 ROC 曲线,但添加参考线来指示一个没有预测价值的分类器的表现会更有帮助。
为了绘制这样的曲线,我们将使用abline()
函数。这个函数可以用来指定一个斜截式方程,其中a
是截距,b
是斜率。由于我们需要一条通过原点的单位线,我们将截距设置为a=0
,斜率设置为b=1
,如下图所示。lwd
参数调整线条的粗细,而lty
参数调整线条的类型。例如,lty = 2
表示虚线:
> abline(a = 0, b = 1, lwd = 2, lty = 2)
最终结果是带有虚线参考线的 ROC 图:
从定性上看,我们可以看到这条 ROC 曲线似乎占据了图表的左上角区域,这表明它比表示无用分类器的虚线更接近一个完美的分类器。为了定量验证这一点,我们可以使用 ROCR 包来计算 AUC。为此,我们首先需要创建另一个performance
对象,这次指定measure = "auc"
,如下代码所示:
> perf.auc <- performance(pred, measure = "auc")
由于perf.auc
是一个 R 对象(具体来说是 S4 对象),我们需要使用特殊的符号来访问其中存储的值。S4 对象在被称为槽位的位置存储信息。可以使用str()
函数查看一个对象的所有槽位:
> str(perf.auc)
Formal class 'performance' [package "ROCR"] with 6 slots
..@ x.name : chr "None"
..@ y.name : chr "Area under the ROC curve"
..@ alpha.name : chr "none"
..@ x.values : list()
..@ y.values :List of 1
.. ..$ : num 0.984
..@ alpha.values: list()
请注意,槽位前面有@
符号。在访问存储在y.values
槽位中的 AUC 值时,我们可以使用@
符号和unlist()
函数,后者将列表简化为数值向量:
> unlist(perf.auc@y.values)
[1] 0.9835862
SMS 分类器的 AUC 为 0.98,这非常高。但是我们怎么知道该模型是否同样能够在另一个数据集上表现良好呢?为了回答这些问题,我们需要更好地理解我们能够将模型的预测结果从测试数据外推的范围。
估计未来表现
一些 R 语言机器学习包在构建模型过程中会显示混淆矩阵和性能度量。这些统计数据的目的是提供关于模型重新替换误差的见解,这种误差发生在即使模型直接从训练数据构建,训练数据仍被错误预测时。这些信息可以用作粗略的诊断工具,以识别明显表现不佳的模型。
重新替换误差并不是一个非常有用的未来性能指标。例如,一个通过死记硬背完美分类每个训练实例并且零重新替换误差的模型,将无法将其预测泛化到从未见过的数据上。因此,训练数据上的错误率可能对模型未来的表现过于乐观。
与其依赖重新替换误差,更好的做法是评估模型在它尚未见过的数据上的表现。我们在前几章中使用了这种方法,将可用数据分为训练集和测试集。然而,在某些情况下,创建训练集和测试集并不总是理想的。例如,在只有一小部分数据的情况下,你可能不希望再进一步减少样本量。
幸运的是,还有其他方法可以估计模型在未见数据上的表现。我们用来计算性能度量的caret
包也提供了多个函数来估计未来的表现。如果你正在跟随 R 语言代码示例并且尚未安装caret
包,请安装它。你还需要将该包加载到 R 会话中,使用library(caret)
命令。
保留法
我们在前几章中使用的将数据划分为训练集和测试集的过程被称为保留法。如下面的图示所示,训练集用于生成模型,然后将其应用于测试集以生成预测结果并进行评估。通常,大约三分之一的数据用于测试,三分之二用于训练,但这个比例可以根据可用数据的多少而有所不同。为了确保训练数据和测试数据没有系统性的差异,它们的样本会被随机地分配到两个组中。
为了使保留法真正准确地估计未来的性能,在任何时候都不应让测试数据集上的表现影响模型。很容易不知不觉地违反这一规则,通过反复测试选择最佳模型。例如,假设我们在训练数据上构建了多个模型,并选择了在测试数据上表现最好的那个。因为我们已经挑选了最佳结果,所以测试性能并不能公正地衡量在未见数据上的表现。
为了避免这个问题,最好将原始数据分割,以便除了训练数据集和测试数据集之外,还可以有一个验证数据集。验证数据集将用于迭代和优化所选择的模型,留出测试数据集仅在最后一步使用,用于报告未来预测的估计误差率。训练、测试和验证的典型分割比例是 50%、25%和 25%。
提示
一位敏锐的读者会注意到,前几章中使用了保留测试数据集来评估模型并提高模型性能。这样做是为了说明问题,但确实违反了前面提到的规则。因此,所展示的模型性能统计数据并不是对未来在未见数据上的性能的有效估计,整个过程应该更准确地称为验证。
创建保留样本的一种简单方法是使用随机数生成器将记录分配到不同的分区中。这个技术最早在第五章,分而治之 – 使用决策树和规则进行分类中,用于创建训练数据集和测试数据集。
提示
如果你希望跟随以下的示例进行操作,可以从 Packt 出版网站下载credit.csv
数据集,并使用credit <- read.csv("credit.csv")
命令将其加载到数据框中。
假设我们有一个名为 credit 的数据框,包含 1000 行数据。我们可以按如下方式将其分为三个分区。首先,使用runif()
函数创建一个随机排序的行 ID 向量,范围从 1 到 1000。runif()
函数默认生成指定数量的 0 到 1 之间的随机值。runif()
函数的名称来源于随机均匀分布,这一点在第二章,管理和理解数据中已有讨论。
> random_ids <- order(runif(1000))
这里使用的order()
返回一个向量,表示 1,000 个随机数的排名顺序。例如,order(c(0.5, 0.25, 0.75, 0.1))
返回序列4 2 1 3
,因为最小的数(0.1)排在第四,第二小的(0.25)排在第二,依此类推。
我们可以使用生成的随机 ID 将credit
数据框分成包含 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-fold CV)。为什么是 10 个折叠?原因在于经验数据表明,使用更多折叠的好处并不显著。对于每个 10 折中的折叠(每个折叠包含总数据的 10%),机器学习模型会在剩余的 90%数据上进行训练。然后,使用与折叠匹配的 10%样本进行模型评估。经过 10 次训练和评估(10 种不同的训练/测试组合)后,报告所有折叠的平均表现。
提示
k 折交叉验证的一个极端情况是留一法,它使用数据的每个示例作为一个折叠来进行 k 折交叉验证。这确保了最大限度地利用数据来训练模型。尽管这看起来很有用,但由于计算成本极高,它在实际中很少使用。
数据集的交叉验证可以通过caret
包中的createFolds()
函数来创建。与分层随机留出采样类似,这个函数会尝试在每个折叠中保持与原始数据集相同的类别平衡。以下是创建 10 个折叠的命令:
> folds <- createFolds(credit$default, k = 10)
createFolds()
函数的结果是一个向量列表,存储着每个请求的k = 10
个折叠的行号。我们可以通过str()
查看其内容:
> str(folds)
List of 10
$ Fold01: int [1:100] 1 5 12 13 19 21 25 32 36 38 ...
$ Fold02: int [1:100] 16 49 78 81 84 93 105 108 128 134 ...
$ Fold03: int [1:100] 15 48 60 67 76 91 102 109 117 123 ...
$ Fold04: int [1:100] 24 28 59 64 75 85 95 97 99 104 ...
$ Fold05: int [1:100] 9 10 23 27 29 34 37 39 53 61 ...
$ Fold06: int [1:100] 4 8 41 55 58 103 118 121 144 146 ...
$ Fold07: int [1:100] 2 3 7 11 14 33 40 45 51 57 ...
$ Fold08: int [1:100] 17 30 35 52 70 107 113 129 133 137 ...
$ Fold09: int [1:100] 6 20 26 31 42 44 46 63 79 101 ...
$ Fold10: int [1:100] 18 22 43 50 68 77 80 88 106 111 ...
在这里,我们看到第一个折叠被命名为Fold01
,并存储了100
个整数,表示第一个折叠中的信用数据框中的 100 行。为了创建训练集和测试集以构建和评估模型,还需要额外的步骤。以下命令展示了如何为第一个折叠创建数据。我们将选定的 10%分配给测试数据集,并使用负号将剩余的 90%分配给训练数据集:
> credit01_test <- credit[folds$Fold01, ]
> credit01_train <- credit[-folds$Fold01, ]
要执行完整的 10 次交叉验证(10-fold CV),此步骤需要重复 10 次;每次都要构建模型并计算模型的表现。最后,所有表现指标会被平均,以获得整体性能。幸运的是,我们可以通过应用之前学到的几种技术来自动化这一过程。
为了演示该过程,我们将使用 10 次交叉验证估计信用数据的 C5.0 决策树模型的卡帕统计量。首先,我们需要加载一些 R 包:caret
(用于创建折叠)、C50
(用于决策树)和 irr
(用于计算卡帕)。后两个包是为了示例目的选择的;如果你愿意,也可以使用其他模型或其他性能指标,沿用相同的步骤。
> library(caret)
> library(C50)
> library(irr)
接下来,我们将像之前一样创建一个包含 10 个折叠的列表。这里使用 set.seed()
函数来确保如果再次运行相同的代码,结果是一致的:
> set.seed(123)
> folds <- createFolds(credit$default, k = 10)
最后,我们将使用 lapply()
函数对折叠列表应用一系列相同的步骤。如以下代码所示,由于没有现成的函数可以完美满足我们的需求,我们必须定义自己的函数并传递给 lapply()
。我们自定义的函数将信用数据框分为训练数据和测试数据,使用 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)
})
结果的卡帕统计量被编译成一个列表,存储在 cv_results
对象中,我们可以使用 str()
函数检查它:
> str(cv_results)
List of 10
$ Fold01: num 0.343
$ Fold02: num 0.255
$ Fold03: num 0.109
$ Fold04: num 0.107
$ Fold05: num 0.338
$ Fold06: num 0.474
$ Fold07: num 0.245
$ Fold08: num 0.0365
$ Fold09: num 0.425
$ Fold10: num 0.505
10 次交叉验证过程只剩下最后一步:我们必须计算这 10 个值的平均值。虽然你可能会倾向于输入 mean(cv_results)
,但是由于 cv_results
不是一个数值向量,这样会导致错误。相反,应该使用 unlist()
函数,它可以消除列表结构,将 cv_results
转化为数值向量。然后,我们就可以像预期的那样计算平均卡帕值:
> mean(unlist(cv_results))
[1] 0.283796
这个卡帕统计量相对较低,对应于解释尺度中的“公平”,这表明信用评分模型的表现仅略优于随机猜测。在下一章中,我们将研究基于 10 次交叉验证的自动化方法,帮助我们提升该模型的表现。
提示
也许目前最可靠的模型性能估计方法是 重复的 k 折交叉验证(repeated k-fold CV)。正如你从名字中猜到的那样,这涉及到反复应用 k 折交叉验证,并平均结果。一种常见的策略是进行 10 次 10 折交叉验证。尽管这种方法计算量大,但它提供了一个非常稳健的估计。
自助抽样
一种使用频率稍低的替代方法是自助采样(bootstrap sampling),简称bootstrap或自助法。一般而言,这些指的是使用数据的随机样本来估计较大数据集属性的统计方法。当这个原理应用于机器学习模型的性能时,它意味着创建几个随机选择的训练集和测试集,然后用它们来估计性能统计数据。来自不同随机数据集的结果会被平均,最终得到对未来性能的估计。
那么,这个过程与 k 折交叉验证有何不同呢?交叉验证将数据划分为不同的分区,其中每个示例只能出现一次,而自助法通过有放回采样允许示例被多次选择。这意味着,从原始的n个示例的数据集中,自助法程序将创建一个或多个新的训练数据集,这些数据集也包含n个示例,其中一些是重复的。相应的测试数据集则从未被选入相应训练数据集的示例集中构建。
使用前面描述的带有替代的采样方法,任何给定实例被包含在训练数据集中的概率为 63.2%。因此,任何实例出现在测试数据集中的概率为 36.8%。换句话说,训练数据仅代表了 63.2%的可用示例,其中一些是重复的。与使用 90%示例用于训练的 10 折交叉验证(10-fold CV)相比,自助法样本对完整数据集的代表性较差。
因为仅在 63.2%的训练数据上训练的模型可能会比在更大训练集上训练的模型表现差,所以自助法的性能估计可能比后来在完整数据集上训练模型时的结果低得多。自助法的一个特例,称为0.632 自助法,通过将最终的性能度量计算为训练数据(过于乐观)和测试数据(过于悲观)性能的函数来考虑这一点。最终的误差率可以通过以下方式估算:
自助法相较于交叉验证的一个优点是它在非常小的数据集上表现得更好。此外,自助采样不仅用于性能测量,还有其他应用。特别是在下一章中,我们将学习如何利用自助采样的原理来提高模型性能。
摘要
本章介绍了评估机器学习分类模型性能的几种常见度量和技术。尽管准确度提供了一种简单的方法来检查模型的正确性,但在稀有事件的情况下,这可能会误导,因为此类事件的实际成本可能与它们出现的频率成反比。
基于混淆矩阵的多个度量方法更好地捕捉了各种错误类型成本之间的平衡。仔细审视灵敏度与特异性,或者精确度与召回率之间的权衡,可能是思考现实世界中错误影响的一个有用工具。像 ROC 曲线这样的可视化工具也有助于这一点。
还值得一提的是,有时评估一个模型性能的最佳标准是考虑它在满足或未能满足其他目标方面的表现。例如,你可能需要用简单的语言解释模型的逻辑,这将排除一些模型的考虑范围。此外,即使一个模型表现非常好,如果它太慢或难以在生产环境中扩展,那么它也是完全没有用的。
测量性能的一个明显扩展是找到自动化方法,为特定任务寻找最佳模型。在下一章中,我们将基于目前的工作,研究通过系统地迭代、优化和结合学习算法来构建更智能模型的方法。
第十一章:改进模型性能
当一支运动队未能达到其目标时——无论目标是获得奥运金牌、联赛冠军还是世界纪录——它必须寻找可能的改进方向。假设你是该队的教练,你会如何安排训练?或许你会指示运动员更加努力训练或改变训练方式,以最大化他们的潜力。或者,你可能会强调更好的团队合作,更聪明地利用运动员的长处和短处。
现在,假设你正在训练一款世界级的机器学习算法。也许你希望参加数据挖掘竞赛,比如 Kaggle 上发布的竞赛(www.kaggle.com/competitions
)。或者你仅仅是希望改善商业成果。你该从哪里开始?尽管背景不同,但提升运动队表现的策略同样可以用来提升统计学习器的表现。
作为教练,你的任务是找到训练技巧和团队协作技能的组合,以帮助你实现性能目标。本章在本书中所覆盖的内容的基础上,介绍了一组提高机器学习器预测性能的技巧。你将学习:
-
如何通过系统地寻找最佳训练条件的组合来自动化模型性能调优
-
将模型组合成利用团队合作解决困难学习任务的方法
-
如何应用一种变体的决策树,这种决策树因其出色的表现而迅速流行
这些方法并非对每个问题都有效。然而,从机器学习竞赛的获奖作品来看,你可能会发现其中至少使用了某种方法。为了具有竞争力,你也需要将这些技能纳入你的技能库。
调优标准模型以提高性能
一些学习问题非常适合前几章中介绍的标准模型。在这种情况下,可能不需要花费太多时间反复调整和优化模型;它可能已经足够好。然而,另一方面,有些问题本质上更为复杂。需要学习的基本概念可能极为复杂,涉及许多微妙的关系,或者它可能受到随机变化的影响,使得在噪音中定义信号变得困难。
开发在困难问题上表现极佳的模型既是一门艺术,也是一门科学。在尝试找出性能提升的方向时,直觉有时是有帮助的。在其他情况下,找到提升的方法可能需要一种蛮力式的反复试验方法。当然,借助自动化程序,搜索可能的改进方法的过程可以得到帮助。
在第五章,分治法——使用决策树和规则进行分类中,我们尝试了解决一个难题:识别可能进入违约的贷款。尽管我们通过性能调优方法获得了大约 82%的可接受分类准确率,但在第十章,模型性能评估中仔细检查后,我们发现高准确率有些误导。尽管准确率合理,但 Kappa 统计量只有大约 0.28,表明模型的实际表现并不理想。在这一节中,我们将重新审视信用评分模型,看看是否可以改善结果。
提示
要跟随示例,下载 Packt Publishing 网站上的 credit.csv
文件,并将其保存到你的 R 工作目录中。使用命令 credit <- read.csv("credit.csv")
将文件加载到 R 中。
你可能还记得,我们首先使用了一个标准的 C5.0 决策树来构建信用数据的分类器。然后,我们尝试通过调整 trials
参数来增加提升迭代次数,从而提高模型性能。通过将迭代次数从默认值 1 增加到 10,我们成功提高了模型的准确度。这个调整模型选项以找出最佳拟合的过程称为参数 调优。
参数调优不限于决策树。例如,当我们搜索最佳 k 值时,我们对 k-NN 模型进行了调优。当我们调整神经网络和支持向量机的节点数或隐藏层数,或者选择不同的核函数时,我们也进行了调优。大多数机器学习算法允许调整至少一个参数,而最复杂的模型提供了大量调节模型拟合的方法。尽管这使得模型能够更好地适应学习任务,但所有可能选项的复杂性可能会让人感到压倒。此时,更系统的方法是必要的。
使用 caret 进行自动化参数调优
与其为每个模型的参数选择任意的值——这不仅是繁琐的,而且有些不科学——不如通过搜索多个可能的参数值来找到最佳组合。
caret
包,我们在第十章,模型性能评估中广泛使用,提供了帮助自动化参数调优的工具。核心功能由 train()
函数提供,该函数作为一个标准化接口,支持超过 175 种不同的机器学习模型,用于分类和回归任务。通过使用这个函数,可以通过选择不同的评估方法和指标,自动化地搜索最优模型。
提示
不要被大量模型吓到——我们在前面的章节中已经介绍了很多模型。其他的模型只是基础概念的简单变体或扩展。考虑到你目前所学的内容,你应该有信心能理解所有可用的方法。
自动调优参数需要你考虑三个问题:
-
应该在数据上训练什么类型的机器学习模型(以及具体的实现)?
-
哪些模型参数可以调整,应该如何调节这些参数以找到最佳设置?
-
应该使用什么标准来评估模型,以找到最佳候选模型?
回答第一个问题需要在机器学习任务和 175 个模型之间找到一个合适的匹配。显然,这需要对机器学习模型的广度和深度有一定了解。进行排除法也会有所帮助。根据任务是分类还是数值预测,几乎一半的模型可以被排除;其他的可以根据数据的格式或是否需要避免使用黑箱模型来排除。无论如何,也没有理由不能尝试多种方法并比较每种方法的最佳结果。
解决第二个问题在很大程度上取决于模型的选择,因为每个算法使用一组独特的参数。本书中涵盖的预测模型的可用调优参数列在下表中。请记住,虽然一些模型可能有未显示的额外选项,但caret
仅支持表中列出的选项进行自动调优。
模型 | 学习任务 | 方法名称 | 参数 |
---|---|---|---|
k 近邻算法 | 分类 | knn |
k |
朴素贝叶斯 | 分类 | nb |
fL , usekernel |
决策树 | 分类 | C5.0 |
model , trials , winnow |
OneR 规则学习器 | 分类 | OneR |
无 |
RIPPER 规则学习器 | 分类 | JRip |
NumOpt |
线性回归 | 回归 | lm |
无 |
回归树 | 回归 | rpart |
cp |
模型树 | 回归 | M5 |
pruned , smoothed , rules |
神经网络 | 双重用途 | nnet |
size , decay |
支持向量机(线性核) | 双重用途 | svmLinear |
C |
支持向量机(径向基核) | 双重用途 | svmRadial |
C, sigma |
随机森林 | 双重用途 | rf |
mtry |
提示
要查看caret
所涵盖的模型及其调优参数的完整列表,请参考包作者 Max Kuhn 提供的表格:topepo.github.io/caret/modelList.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 个不同的候选模型,这些模型由 3³ = 27 个 model
、trials
和 winnow
设置的组合构成。然而,实际上,只会测试 12 个模型。这是因为 model
和 winnow
参数只能取两个值(分别是 tree
与 rules
和 TRUE
与 FALSE
),所以网格大小为 3 * 2 * 2 = 12。
提示
由于默认的搜索网格可能并不适合你的学习问题,caret
允许你通过一个简单的命令提供自定义的搜索网格,我们将在后面介绍。
自动调整模型的第三步也是最后一步,涉及从候选模型中识别最佳模型。这个过程使用了在第十章中讨论的方法,即通过选择重采样策略来创建训练集和测试集,并使用模型性能统计量来衡量预测准确性。
所有我们学到的重采样策略和许多性能统计量都得到了 caret
的支持。这些包括精度、Kappa(对于分类器)以及 R-squared 或 RMSE(对于数值模型)等统计量。如果需要,还可以使用如灵敏度、特异性和 ROC 曲线下的面积(AUC)等成本敏感度度量。
默认情况下,caret
会选择具有最大性能度量值的候选模型。由于这种做法有时会选择那些通过大幅增加模型复杂度来实现边际性能提升的模型,因此提供了替代的模型选择函数。
鉴于有各种各样的选项,许多默认设置是合理的,这一点非常有帮助。例如,caret
将使用在自助法样本上的预测准确度来选择最佳的分类模型表现者。通过这些默认值开始,我们可以调整 train()
函数来设计各种各样的实验。
创建一个简单的调整模型
为了说明调整模型的过程,我们首先观察一下当我们尝试使用 caret
包的默认设置来调整信用评分模型时会发生什么。接下来,我们将根据需要调整选项。
调整学习器的最简单方法只需通过method
参数指定模型类型即可。由于我们之前在credit
模型中使用了 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
数据框中的所有其他特征来建模贷款违约状态(yes
或no
)。参数method = "C5.0"
告诉caret
使用 C5.0 决策树算法。
在输入前述命令后,可能会有一个较长的延迟(具体取决于你计算机的性能),因为调整过程正在进行。尽管这是一个相对较小的数据集,但仍需要进行大量的计算。R 必须反复生成随机数据样本,构建决策树,计算性能统计量并评估结果。
实验的结果保存在一个名为m
的对象中。如果你想检查该对象的内容,str(m)
命令会列出所有相关数据,但这可能会显得有些繁杂。相反,只需输入对象的名称,即可获得一个简洁的结果概览。例如,输入m
会产生以下输出(注意,为了清晰起见,已添加标签):
标签突出显示了输出中的四个主要组成部分:
-
输入数据集的简要描述:如果你对自己的数据比较熟悉,并且正确应用了
train()
函数,这些信息应该不会让你感到惊讶。 -
应用的预处理和重采样方法报告:在这里,我们看到使用了 25 个 bootstrap 样本,每个样本包括 1,000 个示例,用于训练模型。
-
评估的候选模型列表:在这一部分,我们可以确认测试了 12 个不同的模型,这些模型是基于三个 C5.0 调整参数的组合——
model
、trials
和winnow
。每个候选模型的准确性和卡帕统计量的平均值和标准差也在这里显示。 -
最佳模型的选择:正如脚注所述,选择了准确性最高的模型。这个模型使用了一个有 20 次试验的决策树,并设置
winnow = FALSE
。
在确定最佳模型后,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 个示例中,只有两个被错误分类。然而,非常重要的是要注意,由于模型是基于训练数据和测试数据构建的,因此该准确性是乐观的,不能作为未见数据的性能指标。总结输出中的 73% 自助法估计值是对未来表现的更现实估计。
使用 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.86115561
3 1.0000000 0.00000000
4 0.7720279 0.22797208
5 0.2948062 0.70519385
6 0.8583715 0.14162851
即使在底层模型使用不同字符串表示预测概率的情况下(例如,naiveBayes
模型使用 "raw"
),predict()
函数也会在幕后将 type = "prob"
转换为适当的字符串。
自定义调优过程
我们之前创建的决策树展示了 caret
包在最小干预下生成优化模型的能力。默认设置使得优化模型的创建变得简单。然而,也可以更改默认设置,使其更符合学习任务的具体需求,这有助于解锁更高水平的性能。
模型选择过程中的每个步骤都可以定制。为了说明这一灵活性,让我们修改在信用决策树中的工作,使其与我们在第十章中使用的过程相似,评估模型性能。如果你还记得,我们使用 10 折交叉验证估算了 kappa 统计量。我们将在这里做同样的事情,使用 kappa 来优化决策树的提升参数。请注意,决策树提升在第五章中已有介绍,并将在本章后面更详细地讨论。
trainControl()
函数用于创建一组配置选项,称为控制对象,它指导train()
函数的执行。这些选项允许管理模型评估标准,例如重采样策略和用于选择最佳模型的度量标准。尽管此函数可以用来修改调优实验的几乎每个方面,我们将重点关注两个重要的参数:method
和selectionFunction
。
提示
如果你渴望获取更多细节,可以使用?trainControl
命令查看所有参数的列表。
对于trainControl()
函数,method
参数用于设置重采样方法,如留出法或 k 折交叉验证。下表列出了所有可能的重采样方法类型以及调整样本大小和迭代次数的其他参数。尽管这些重采样方法的默认选项遵循了常见的惯例,但你可以根据数据集的大小和模型的复杂性调整这些选项。
重采样方法 | 方法名称 | 额外选项及默认值 |
---|---|---|
留出法采样 | 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 折交叉验证和 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")
生成的网格数据框包含 1 * 8 * 1 = 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()
函数将使用每行的模型参数组合构建一个候选模型进行评估。
给定这个搜索网格和先前创建的控制列表,我们准备运行一个彻底定制的 train()
实验。像之前一样,我们将随机种子设置为任意数字 300
,以确保结果可重复。但这次,我们将在传递控制对象和调参网格的同时,添加一个参数 metric = "Kappa"
,指示模型评估函数要使用的统计量——在这种情况下为 "oneSE"
。完整命令如下:
> set.seed(300)
> m <- train(default ~ ., data = credit, method = "C5.0",
metric = "Kappa",
trControl = ctrl,
tuneGrid = grid)
这将生成一个对象,我们可以通过输入其名称查看:
> m
尽管大部分输出与自动调优后的模型相似,但仍有一些值得注意的差异。由于使用了 10 折交叉验证,构建每个候选模型的样本大小被减少到了 900,而不是自助法中的 1,000。根据我们的要求,测试了八个候选模型。此外,由于model
和winnow
保持不变,它们的值不再显示在结果中,而是作为脚注列出。
这里的最佳模型与之前的实验差异明显。之前,最佳模型使用了trials = 20
,而这里使用的是trials = 1
。这个看似奇怪的发现是因为我们使用了oneSE
规则,而不是best
规则来选择最佳模型。尽管 35 次实验模型根据 kappa 值提供了最好的原始性能,但 1 次实验模型在性能上几乎相同,而且形式更加简单。简单模型不仅计算效率更高,而且还能减少过拟合训练数据的可能性。
通过元学习提高模型性能
作为提高单一模型性能的替代方法,可以将多个模型组合成一个强大的团队。就像最优秀的运动队拥有互补而非重叠技能的球员一样,一些最好的机器学习算法利用互补模型的团队。由于每个模型都会为学习任务带来独特的偏差,它可能很容易学习某个子集的样本,但对另一个子集则可能表现不佳。因此,通过智能地利用多个不同团队成员的优势,可以创建一个由多个弱学习者组成的强大团队。
结合和管理多个模型预测的技术属于元学习方法的一部分,定义了涉及学习如何学习的技术。这包括从简单的算法(通过反复设计决策逐步改进性能——例如,本章早些时候提到的自动参数调优)到使用借鉴自进化生物学和遗传学的概念进行自我修改和适应学习任务的高度复杂算法。
在本章剩余部分,我们将专注于元学习,仅限于建模多个模型预测与期望结果之间关系的内容。本节中介绍的基于团队合作的技术非常强大,并且在构建更有效的分类器时被广泛使用。
理解集成模型
假设你是电视答题节目的一名参赛者,可以选择五个朋友组成团队来帮助你回答最终的百万美元大奖问题。大多数人会试图选取一组多样化的学科专家。一个包含文学、科学、历史、艺术教授以及当代流行文化专家的团队,将是一个均衡的团队。考虑到他们的知识广度,几乎不可能有一个问题能让这个团队感到难倒。
利用类似于创建一个多样化专家团队的原理的元学习方法被称为集成方法。所有的集成方法都基于这样一个理念:通过将多个较弱的学习器组合起来,创造出一个更强的学习器。各种集成方法的区别,主要可以通过以下两个问题的答案来区分:
-
如何选择和/或构建弱学习模型?
-
如何将弱学习器的预测结果组合成一个最终的预测?
在回答这些问题时,想象集成方法的过程图可能会很有帮助;几乎所有的集成方法都遵循这个模式:
首先,使用输入的训练数据来构建多个模型。分配函数决定了每个模型接收多少训练数据。它们是否每个都接收到完整的训练数据集,还是仅仅接收到一个样本?它们是否每个都接收到所有特征,还是仅接收到一部分特征?
虽然理想的集成方法包括多样化的模型集,但分配函数可以通过人为改变输入数据来增加多样性,从而使得生成的学习器产生偏差,即使它们是同一类型的。例如,它可能使用自助抽样(bootstrap sampling)来构建独特的训练数据集,或者将不同的特征或样本子集传递给每个模型。另一方面,如果集成方法已经包含了多种算法——如神经网络、决策树和 k-NN 分类器——那么分配函数可能会将数据传递给每个算法,而数据保持相对不变。
在模型构建完成后,它们可以用于生成一组预测结果,这些预测结果必须以某种方式进行管理。组合函数决定了如何解决预测之间的分歧。例如,集成方法可能会使用多数投票来确定最终的预测结果,或者使用更复杂的策略,比如根据每个模型的历史表现来加权每个模型的投票。
一些集成方法甚至使用另一个模型来学习从各种预测组合中得到一个组合函数。例如,假设当M1和M2都投票“是”时,实际的类别值通常是“否”。在这种情况下,集成方法可以学习忽略M1和M2的投票,当它们一致时。这种使用多个模型的预测结果来训练最终裁定模型的过程被称为堆叠。
使用集成方法的一个好处是,它们可能让你在追求单一最佳模型时花费更少的时间。你可以训练多个合理强大的候选模型并将其结合起来。然而,方便性并不是集成方法在机器学习竞赛中持续获胜的唯一原因;集成方法在多个方面也提供了相对于单一模型的性能优势:
-
更好的泛化能力:由于多个学习者的意见被整合到最终的预测中,因此没有任何单一的偏差能够主导预测结果。这减少了过拟合学习任务的风险。
-
在大规模或极小数据集上提升性能:许多模型在使用非常大规模的特征或样本集时会遇到内存或复杂度的限制,这时训练多个小模型比训练一个完整的模型更高效。相反,集成方法在最小的数据集上也表现良好,因为许多集成设计本身就包含了如自助抽样(bootstrapping)等重采样方法。或许最重要的是,集成方法通常可以通过分布式计算方法并行训练。
-
合成来自不同领域的数据的能力:由于没有一种适用于所有情况的学习算法,集成方法能够结合来自多种学习者的证据,这在复杂现象依赖于来自不同领域的数据时变得越来越重要。
-
对困难学习任务的更细致理解:现实世界中的现象往往非常复杂,包含许多相互作用的细节。将任务划分为更小部分的模型,往往能更准确地捕捉到单一全局模型可能忽略的微妙模式。
如果你无法轻松地在 R 中应用集成方法,那么这些好处将大打折扣,幸运的是,已经有许多包可以用来实现这一点。我们来看一下几种最流行的集成方法,以及它们如何帮助提升我们正在研究的信用模型的性能。
自助法(Bagging)
第一个获得广泛认可的集成方法使用了一种叫做自助聚合(bootstrap aggregating),简称Bagging的方法。正如 Leo Breiman 在 1994 年所描述的那样,自助法通过对原始训练数据进行自助抽样生成多个训练数据集。这些数据集随后被用来生成一组模型,使用同一个学习算法。这些模型的预测结果通过投票(分类)或平均(数值预测)进行结合。
注意事项
关于自助法的更多信息,请参阅 Breiman L. Bagging predictors. Machine Learning. 1996; 24:123-140.
虽然袋装是一种相对简单的集成方法,但只要与相对不稳定的学习器一起使用,它可以表现得相当好。所谓不稳定学习器,就是那些生成的模型在输入数据发生轻微变化时会发生显著变化的模型。为了确保集成的多样性,即使是从 bootstrap 训练数据集之间仅有微小的变化,不稳定的模型是至关重要的。正因如此,袋装方法常常与决策树一起使用,因为决策树在输入数据发生微小变化时往往会发生剧烈变化。
ipred
包提供了经典的袋装决策树实现。为了训练模型,bagging()
函数的工作方式与之前使用的许多模型类似。nbagg
参数用于控制在集成中投票的决策树数量(默认值为25
)。根据学习任务的难度和训练数据的数量,增加这个数量可能会提高模型的性能,但也有一个限制。缺点是,这会带来额外的计算开销,因为训练大量的树可能需要一些时间。
安装ipred
包后,我们可以按如下方式创建集成。我们将保持默认的 25 棵决策树:
> library(ipred)
> set.seed(300)
> mybag <- bagging(default ~ ., data = credit, nbagg = 25)
生成的模型按预期工作,可以使用predict()
函数:
> credit_pred <- predict(mybag, credit)
> table(credit_pred, credit$default)
credit_pred no yes
no 699 2
yes 1 298
根据之前的结果,该模型似乎非常适合训练数据。为了查看这一点如何转化为未来的表现,我们可以使用带有 10 倍交叉验证的袋装树,并使用caret
包中的train()
函数。请注意,ipred
袋装树函数的方法名是treebag
:
> library(caret)
> 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 Accuracy SD Kappa SD
0.735 0.3297726 0.03439961 0.08590462
该模型的 kappa 统计量为 0.33,表明集成树模型的表现至少与我们在本章早些时候调优的最佳 C5.0 决策树相当。这说明了集成方法的强大;一组简单的学习器协同工作可以超越非常复杂的模型。
为了超越决策树的袋装,caret
包还提供了一个更通用的bag()
函数。它原生支持一些模型,尽管通过一些额外的努力,它可以适配到其他类型的模型。bag()
函数使用控制对象来配置袋装过程。它需要指定三个函数:一个用于拟合模型,一个用于做出预测,一个用于聚合投票。
例如,假设我们想要创建一个袋装支持向量机模型,可以使用我们在第七章中使用的kernlab
包中的ksvm()
函数。bag()
函数要求我们提供训练 SVM、做出预测和统计投票的功能。
我们无需自己编写这些功能,caret
包内置的svmBag
列表对象提供了三个我们可以使用的函数:
> str(svmBag)
List of 3
$ fit :function (x, y, ...)
$ pred :function (object, x)
$ aggregate:function (x, type = "class")
通过查看svmBag$fit
函数,我们可以看到它只是调用了kernlab
包中的ksvm()
函数并返回结果:
> svmBag$fit
function (x, y, ...)
{
library(kernlab)
out <- ksvm(as.matrix(x), y, prob.model = is.factor(y), ...)
out
}
<environment: namespace:caret>
svmBag
的pred
和aggregate
函数也同样简单。通过研究这些函数并以相同的格式创建自己的函数,可以使用袋装法与任何你想要的机器学习算法。
提示
caret
包还包括了朴素贝叶斯模型袋(nbBag
)、决策树(ctreeBag
)和神经网络(nnetBag
)的示例对象。
通过应用svmBag
列表中的三个函数,我们可以创建一个袋装控制对象:
> bagctrl <- bagControl(fit = svmBag$fit,
predict = svmBag$pred,
aggregate = svmBag$aggregate)
通过将此与train()
函数和之前定义的训练控制对象(ctrl
)一起使用,我们可以如下评估袋装 SVM 模型(请注意,kernlab
包是必需的,如果尚未安装,需要先安装它):
> set.seed(300)
> svmbag <- train(default ~ ., data = credit, "bag",
trControl = ctrl, bagControl = bagctrl)
> svmbag
Bagged Model
1000 samples
16 predictors
2 classes: 'no', 'yes'
No pre-processing
Resampling: Cross-Validation (10 fold)
Summary of sample sizes: 900, 900, 900, 900, 900, 900, ...
Resampling results
Accuracy Kappa Accuracy SD Kappa SD
0.728 0.2929505 0.04442222 0.1318101
Tuning parameter 'vars' was held constant at a value of 35
由于 kappa 统计量低于 0.30,似乎袋装 SVM 模型的表现不如袋装决策树模型。值得指出的是,与袋装决策树模型相比,kappa 统计量的标准差相当大。这表明,在交叉验证的各个折叠中,性能变化很大。这种变化可能意味着通过增加集成中的模型数量,性能可能会进一步提高。
提升法
另一种常见的基于集成的方法称为提升法,因为它通过提升弱学习器的表现来达到强学习器的表现。这种方法主要基于 Robert Schapire 和 Yoav Freund 的工作,他们在这个主题上发表了大量的研究。
注意
有关提升法的更多信息,请参考 Schapire RE, Freund Y. Boosting: Foundations and Algorithms。剑桥,马萨诸塞州,麻省理工学院出版社;2012 年。
类似于袋装法,提升法(boosting)使用在重采样数据上训练的模型集和投票来确定最终预测。这里有两个关键的区别。首先,提升法中的重采样数据集是特别构建的,目的是生成互补的学习器。其次,提升法不是给每个学习器平等的投票,而是根据其过去的表现为每个学习器的投票加权。表现较好的模型在集成中的最终预测中有更大的影响。
提升法通常会产生比集成中最好的模型更优的表现,且绝不会差于最强的模型。由于集成中的模型是为了互补性而构建的,假设每个分类器的表现优于随机机会,理论上可以通过增加额外的分类器来随意提高集成的性能。考虑到这一发现的明显实用性,提升法被认为是机器学习领域最重要的发现之一。
提示
尽管提升方法可以创建一个满足任意低误差率的模型,但在实践中这可能并不总是合理的。首先,随着更多学习器的加入,性能提升会越来越小,这使得某些阈值在实际中不可行。此外,追求纯粹的准确度可能会导致模型过拟合训练数据,而无法推广到未见过的数据。
一种名为AdaBoost或自适应增强的提升算法由 Freund 和 Schapire 于 1997 年提出。该算法基于生成弱学习器的思想,通过对频繁被误分类的样本给予更多关注(即,赋予更大的权重),迭代地学习更多难以分类的样本。
从一个未加权的数据集开始,第一个分类器尝试对结果进行建模。分类器正确预测的样本将不太可能出现在下一个分类器的训练数据集中,反之,难以分类的样本将更频繁地出现。随着更多轮弱学习器的加入,它们在越来越难分类的样本上进行训练。该过程持续进行,直到达到预期的整体误差率或性能不再提高为止。此时,每个分类器的投票将根据其在训练数据上的准确性进行加权。
虽然提升原理几乎可以应用于任何类型的模型,但这些原理最常见的应用是与决策树一起使用。我们在第五章中,分治法 – 使用决策树和规则进行分类,已经使用提升方法来提高 C5.0 决策树的性能。
AdaBoost.M1算法提供了另一个基于树的 AdaBoost 实现,用于分类。AdaBoost.M1 算法可以在adabag
包中找到。
注意
如需了解更多关于adabag
包的信息,请参考 Alfaro E, Gamez M, Garcia N 的文章。adabag – an R package for classification with boosting and bagging。统计软件杂志。2013;54:1-35。
让我们为信用数据创建一个AdaBoost.M1
分类器。该算法的一般语法与其他建模技术类似:
> 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
你注意到 AdaBoost 模型没有犯错吗?在你兴奋之前,记住之前的混淆矩阵是基于模型在训练数据上的表现。由于提升方法允许将错误率降低到任意低的水平,学习器会继续训练直到不再犯错。这很可能导致了在训练数据集上的过拟合。
为了更准确地评估在未见数据上的表现,我们需要使用另一种评估方法。adabag
包提供了一个简单的函数来使用 10 折交叉验证(10-fold CV):
> set.seed(300)
> adaboost_cv <- boosting.cv(default ~ ., data = credit)
根据你计算机的性能,这可能需要一些时间,期间它会在屏幕上记录每次迭代。完成后,我们可以查看一个更合理的混淆矩阵:
> adaboost_cv$confusion
Observed Class
Predicted Class no yes
no 594 151
yes 106 149
我们可以使用vcd
包找到 kappa 统计量,具体方法参见第十章,评估模型性能。
> library(vcd)
> Kappa(adaboost_cv$confusion)
value ASE
Unweighted 0.3606965 0.0323002
Weighted 0.3606965 0.0323002
该模型的 kappa 值约为 0.36,这是我们迄今为止表现最好的信用评分模型。让我们看看它与最后一种集成方法的比较。
提示
可以通过在caret
中指定method = "AdaBoost.M1"
来调整 AdaBoost.M1 算法。
随机森林
另一种基于集成的方法,称为随机森林(或决策树森林),仅专注于决策树的集成。这一方法由 Leo Breiman 和 Adele Cutler 提倡,结合了袋装(bagging)和随机特征选择的基本原则,以增加决策树模型的多样性。在生成了决策树集成(森林)之后,模型通过投票来合并这些树的预测结果。
注意
关于随机森林构建的更多细节,请参阅 Breiman L. Random Forests。机器学习,2001;45:5-32。
随机森林将多样性和强大功能融合为单一的机器学习方法。由于集成只使用了完整特征集中的一小部分随机特征,随机森林可以处理非常大的数据集,在这些数据集中,所谓的“维度灾难”可能会导致其他模型失败。与此同时,它在大多数学习任务上的错误率与几乎所有其他方法相当。
提示
虽然“随机森林”(Random Forests)这个术语是由 Breiman 和 Cutler 注册的商标,但有时人们也用它来泛指任何类型的决策树集成。一个严格的学者会使用更通用的术语“决策树森林”,除非是在指 Breiman 和 Cutler 的具体实现。
值得注意的是,相对于其他基于集成的方法,随机森林非常具有竞争力,并且相较于其他方法具有关键优势。例如,随机森林往往更容易使用,且不容易发生过拟合。下表列出了随机森林模型的一般优缺点:
优点 | 缺点 |
---|
|
-
一种在大多数问题上表现良好的通用模型
-
能处理噪声或缺失数据以及类别型或连续型特征
-
仅选择最重要的特征
-
可以用于具有极大量特征或样本的数据
|
-
与决策树不同,该模型不容易解释
-
可能需要一些工作来调整模型以适应数据
|
由于随机森林具有强大的功能、广泛的适应性和易用性,它们正在迅速成为最受欢迎的机器学习方法之一。在本章后面,我们将把随机森林模型与增强版的 C5.0 决策树进行正面比较。
训练随机森林
虽然在 R 中有多个包可以创建随机森林,但randomForest
包可能是最忠实于 Breiman 和 Cutler 规范的实现,并且也受到caret
包的支持,可以进行自动化调参。训练该模型的语法如下:
默认情况下,randomForest()
函数会创建一个包含 500 棵树的集成模型,每棵树在每次分裂时都会考虑sqrt(p)
个随机特征,其中p
是训练数据集中的特征数量,sqrt()
是 R 的平方根函数。是否使用这些默认参数取决于学习任务和训练数据的性质。一般来说,更复杂的学习问题和更大的数据集(无论是更多的特征还是更多的样本)都能通过更多的树来取得更好的效果,但这需要与训练更多树的计算开销进行平衡。
使用大量决策树的目的是训练足够多的树,以便每个特征都有机会出现在多个模型中。这也是mtry
参数的默认值sqrt(p)
的基础;使用该值限制特征的数量,确保树与树之间有足够的随机变化。例如,由于信用数据有 16 个特征,每棵树在任何时候只能在四个特征上进行分裂。
让我们来看一下默认的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.8%
Confusion matrix:
no yes class.error
no 640 60 0.08571429
yes 178 122 0.59333333
输出结果显示随机森林包括了 500 棵树,并且在每次分裂时尝试了四个变量,正如我们所期望的那样。乍一看,你可能会对困惑矩阵中看似不理想的表现感到惊讶——23.8%的错误率远高于到目前为止其他集成方法的重置误差。然而,这个困惑矩阵并没有显示重置误差。相反,它反映的是袋外错误率(在输出中列为OOB estimate of error rate
),与重置误差不同,袋外错误率是测试集错误的无偏估计。这意味着它应该是对未来表现的相当合理的估计。
袋外估计在构建随机森林时计算。实际上,任何没有被选为单棵树的自助法样本的例子,都可以用来测试模型在未见过数据上的表现。在森林构建结束时,每个例子在每次被保留时的预测都会被统计,并通过投票来确定该例子的最终预测结果。这样的预测的总误差率即为袋外误差率。
评估随机森林性能
如前所述,randomForest()
函数得到了caret
的支持,这使我们能够优化模型,同时计算袋外误差率以外的性能度量。为了增加趣味性,我们将比较一个自动调优的随机森林与我们开发的最佳自动调优提升 C5.0 模型。我们将这次实验视为希望找到一个候选模型,提交到机器学习竞赛中。
我们首先需要加载caret
并设置训练控制选项。为了对模型性能进行最准确的比较,我们将使用重复的 10 折交叉验证,或者说是重复 10 次的 10 折交叉验证。这意味着模型的构建时间将大大增加,并且评估计算量会更大,但由于这是我们的最终比较,我们必须非常确保我们做出了正确的选择;这场对决的胜者将是我们唯一进入机器学习竞赛的模型。
> library(caret)
> ctrl <- trainControl(method = "repeatedcv",
number = 10, repeats = 10)
接下来,我们将为随机森林设置调优网格。该模型的唯一调优参数是mtry
,它定义了每次分裂时随机选择多少特征。默认情况下,我们知道随机森林将使用sqrt(16)
,即每棵树使用四个特征。为了全面起见,我们还将测试该值的一半、两倍以及完整的 16 个特征。因此,我们需要创建一个包含2
、4
、8
和16
的网格,如下所示:
> grid_rf <- expand.grid(.mtry = c(2, 4, 8, 16))
提示
一个在每次分裂时考虑完整特征集的随机森林,本质上与一个集成决策树模型相同。
我们可以将生成的网格传递给train()
函数,并使用ctrl
对象,如下所示。我们将使用 kappa 指标来选择最佳模型:
> set.seed(300)
> m_rf <- train(default ~ ., data = credit, method = "rf",
metric = "Kappa", trControl = ctrl,
tuneGrid = grid_rf)
前面的命令可能需要一些时间才能完成,因为它有很多工作要做!完成后,我们将与使用10
、20
、30
和40
次迭代的提升树进行比较:
> grid_c50 <- expand.grid(.model = "tree",
.trials = c(10, 20, 30, 40),
.winnow = "FALSE")
> set.seed(300)
> m_c50 <- train(default ~ ., data = credit, method = "C5.0",
metric = "Kappa", trControl = ctrl,
tuneGrid = grid_c50)
当 C5.0 决策树最终完成时,我们可以将这两种方法进行并排比较。对于随机森林模型,结果是:
> m_rf
Resampling results across tuning parameters:
mtry Accuracy Kappa Accuracy SD Kappa SD
2 0.7247 0.1284142 0.01690466 0.06364740
4 0.7499 0.2933332 0.02989865 0.08768815
8 0.7539 0.3379986 0.03107160 0.08353988
16 0.7556 0.3613151 0.03379439 0.08891300
对于提升 C5.0 模型,结果是:
> m_c50
Resampling results across tuning parameters:
trials Accuracy Kappa Accuracy SD Kappa SD
10 0.7325 0.3215655 0.04021093 0.09519817
20 0.7343 0.3268052 0.04033333 0.09711408
30 0.7381 0.3343137 0.03672709 0.08942323
40 0.7388 0.3335082 0.03934514 0.09746073
在mtry = 16
时,随机森林模型的 kappa 约为 0.361,是这八个模型中表现最好的。它高于最佳的 C5.0 决策树,其 kappa 约为 0.334,并且略高于 kappa 约为 0.360 的AdaBoost.M1
模型。根据这些结果,我们将提交随机森林模型作为最终模型。在没有实际在竞赛数据上评估模型的情况下,我们无法确定它是否最终获胜,但根据我们的性能评估,它是更安全的选择。幸运的话,也许我们能赢得奖项。
总结
阅读本章后,你现在应该知道了在数据挖掘和机器学习竞赛中获胜所需的基本技术。自动调优方法可以帮助从单一模型中挤出每一分性能。另一方面,通过创建多个协同工作的机器学习模型,也可以实现性能的提升。
尽管本章旨在帮助你准备竞赛级别的模型,但请注意,你的竞争对手也可以使用相同的技术。你不能停滞不前;因此,继续将独特的方法加入你的工具箱。也许你能带来独特的专业知识,或者也许你的优势在于数据准备时对细节的关注。无论如何,实践出真知,因此利用开放竞赛来测试、评估和提升你自己的机器学习技能。
在下一章——本书的最后一章——我们将从更高的角度审视如何利用 R 语言将机器学习应用于一些高度专业化且复杂的领域。你将获得将机器学习应用于前沿任务所需的知识。
第十二章:专门的机器学习主题
恭喜你已经到达了机器学习旅程的这一阶段!如果你还没有开始自己的项目,很快就会开始。而在这过程中,你可能会发现将数据转化为行动的任务比最初想象的要更为困难。
当你收集数据时,你可能已经意识到信息被困在专有格式中,或者分布在互联网上的各个页面上。更糟糕的是,经过几个小时的重新格式化,可能因为内存不足,电脑变得极其缓慢。也许 R 甚至崩溃或冻结了你的机器。希望你没有气馁,因为这些问题可以通过多一点努力得到解决。
本章介绍了可能并不适用于所有项目的技术,但在处理这些专业性问题时,将会非常有用。如果你经常处理以下类型的数据,你可能会特别觉得这些信息有用:
-
存储在无结构或专有格式中,例如网页、Web API 或电子表格
-
来自生物信息学或社交网络分析等专业领域
-
数据太大无法加载到内存中,或者分析需要非常长的时间才能完成
如果你遇到这些问题,你并不孤单。虽然没有万能的解决方案——这些问题是数据科学家的痛点,也是数据技能需求高涨的原因——通过 R 社区的努力,许多 R 包为解决这些问题提供了一个起点。
本章提供了一本这样的解决方案食谱。即使你是经验丰富的 R 老手,你也可能会发现一个简化工作流程的包。或者,也许有一天,你会编写一个让大家的工作变得更轻松的包!
处理专有文件和数据库
与本书中的例子不同,现实世界中的数据很少以简单的 CSV 格式进行打包,可以从网站下载。相反,准备数据进行分析需要付出相当大的努力。数据必须被收集、合并、排序、过滤或重新格式化,以满足学习算法的要求。这个过程通常被称为数据清理或数据整理。
随着典型数据集的大小从兆字节增长到千兆字节,且数据来自无关且凌乱的来源,许多数据被存储在庞大的数据库中,数据准备变得更加重要。以下章节列出了几个用于检索和处理专有数据格式及数据库的包和资源。
从 Microsoft Excel、SAS、SPSS 和 Stata 文件中读取和写入数据
数据分析的一个令人沮丧的方面是需要花费大量工作去从各种专有格式中提取和结合数据。海量的数据存储在文件和数据库中,只需解锁它们,便可用于 R 中。幸运的是,正是为了这个目的,存在相关的 R 包。
过去,需要掌握多个 R 包中的特定技巧和工具,才能完成繁琐且费时的过程。而现在,由于一个相对较新的 R 包rio
(代表 R 输入输出),这一过程变得轻而易举。这个包由 Chung-hong Chan、Geoffrey CH Chan、Thomas J. Leeper 和 Christopher Gandrud 开发,被描述为“数据的瑞士军刀”。它能够导入和导出多种文件格式,包括但不限于:制表符分隔(.tsv
)、逗号分隔(.csv
)、JSON(.json
)、Stata(.dta
)、SPSS(.sav
和.por
)、Microsoft Excel(.xls
和.xlsx
)、Weka(.arff
)和 SAS(.sas7bdat
和.xpt
)等。
注意
有关rio
可以导入和导出的文件类型的完整列表,以及更详细的使用示例,请参见cran.r-project.org/web/packages/rio/vignettes/rio.html
。
rio
包包含三个用于处理专有数据格式的函数:import()
、export()
和convert()
。根据函数的名称,它们分别完成预期的操作。与该包保持简单的理念一致,每个函数通过文件名扩展名来猜测要导入、导出或转换的文件类型。
例如,要导入前几章中的信用数据,它以 CSV 格式存储,只需键入:
> library(rio)
> credit <- import("credit.csv")
这将创建预期的credit
数据框;作为额外好处,我们不仅无需指定 CSV 文件类型,rio
还自动设置了stringsAsFactors = FALSE
以及其他合理的默认值。
要将credit
数据框导出为 Microsoft Excel(.xlsx
)格式,请使用export()
函数并指定所需的文件名,如下所示。对于其他格式,只需将文件扩展名更改为所需的输出类型:
> export(credit, "credit.xlsx")
也可以直接使用convert()
函数将 CSV 文件转换为另一种格式,无需导入步骤。例如,这将credit.csv
文件转换为 Stata(.dta
)格式:
> convert("credit.csv", "credit.dta")
尽管rio
包覆盖了许多常见的专有数据格式,但它并不支持所有操作。下一节将介绍通过数据库查询将数据导入 R 的其他方法。
查询 SQL 数据库中的数据
大型数据集通常存储在数据库管理系统(DBMSs)中,如 Oracle、MySQL、PostgreSQL、Microsoft SQL 或 SQLite。这些系统允许使用结构化查询语言(SQL)访问数据集,SQL 是一种用于从数据库中提取数据的编程语言。如果你的 DBMS 配置为允许开放数据库连接(ODBC),则可以使用 Brian Ripley 的RODBC
包将数据直接导入 R 数据框。
提示
如果您在使用 ODBC 连接到数据库时遇到困难,可以尝试使用一些特定于 DBMS 的 R 包。这些包包括 ROracle
、RMySQL
、RPostgresSQL
和 RSQLite
。虽然它们的功能与这里的指令大致相似,但请参考 CRAN 上的包文档,获取每个包特定的说明。
ODBC 是一种标准协议,用于连接数据库,与操作系统或数据库管理系统无关。如果您之前连接过 ODBC 数据库,您很可能会通过其 数据源名称 (DSN) 来引用它。要使用 RODBC
,您需要 DSN 以及用户名和密码(如果数据库需要的话)。
提示
配置 ODBC 连接的指令非常依赖于操作系统和数据库管理系统(DBMS)的组合。如果您在设置 ODBC 连接时遇到问题,请与您的数据库管理员联系。另一种获取帮助的方式是通过 RODBC
包的 vignette
,在安装了 RODBC
包之后,可以通过 R 中的 vignette("RODBC")
命令访问。
要为数据库使用 my_dsn
DSN 打开一个名为 my_db
的连接,请使用 odbcConnect()
函数:
> library(RODBC)
> my_db <- odbcConnect("my_dsn")
如果您的 ODBC 连接需要用户名和密码,应该在调用 odbcConnect()
函数时指定:
> my_db <- odbcConnect("my_dsn",
uid = "my_username",
pwd = "my_password")
在打开的数据库连接下,我们可以使用 sqlQuery()
函数根据 SQL 查询拉取的数据库行创建 R 数据框。此函数与许多创建数据框的函数类似,允许我们指定 stringsAsFactors = FALSE
,以防止 R 自动将字符数据转换为因子。
sqlQuery()
函数使用典型的 SQL 查询,如下面的命令所示:
> my_query <- "select * from my_table where my_value = 1"
> results_df <- sqlQuery(channel = my_db, query = sql_query,
stringsAsFactors = FALSE)
结果 results_df
对象是一个数据框,包含了使用存储在 sql_query
中的 SQL 查询选择的所有行。
一旦完成使用数据库,可以使用以下命令关闭连接:
> odbcClose(my_db)
尽管 R 会在 R 会话结束时自动关闭 ODBC 连接,但最好明确地执行此操作。
使用在线数据和服务
随着来自网络来源的数据量不断增加,机器学习项目能否访问并与在线服务互动变得越来越重要。R 本身能够从在线源读取数据,但有一些注意事项。首先,默认情况下,R 无法访问安全网站(即使用 https://
而非 http://
协议的网站)。其次,需要注意的是,大多数网页并没有以 R 可以理解的格式提供数据。在数据可以有用之前,它需要被 解析,即拆解并重建为结构化的形式。我们稍后将讨论一些解决方法。
然而,如果这些警告都不适用(也就是说,如果数据已经在一个不安全的网站上并且是表格形式,如 CSV 格式,R 可以原生理解),那么 R 的read.csv()
和read.table()
函数就能像访问本地机器上的数据一样,访问网络上的数据。只需提供数据集的完整 URL,如下所示:
> mydata <- read.csv("http://www.mysite.com/mydata.csv")
R 还提供了从网络下载其他文件的功能,即使 R 无法直接使用它们。对于文本文件,可以尝试以下readLines()
函数:
> mytext <- readLines("http://www.mysite.com/myfile.txt")
对于其他类型的文件,可以使用download.file()
函数。要将文件下载到 R 的当前工作目录,只需提供 URL 和目标文件名,如下所示:
> download.file("http://www.mysite.com/myfile.zip", "myfile.zip")
除了这些基本功能外,还有许多包扩展了 R 处理在线数据的能力。最基础的部分将在接下来的章节中介绍。由于网络庞大且不断变化,这些章节远不是 R 连接到在线数据的所有方法的全面集合。实际上,几乎每个领域都有成百上千个包,涵盖从小型项目到大型项目的各种需求。
注意
要获取最完整且最新的包列表,请参考定期更新的 CRAN 网页技术与服务任务视图,地址为cran.r-project.org/web/views/WebTechnologies.html
。
下载网页的完整文本
Duncan Temple Lang 的RCurl
包提供了一种更强大的方式来访问网页,通过为curl(URL 客户端)工具提供一个 R 接口,这个工具是一个命令行工具,用于通过网络传输数据。curl 程序就像一个可编程的网页浏览器;给定一组命令,它可以访问并下载网络上几乎所有可用的内容。与 R 不同,它不仅可以访问安全网站,还能向在线表单提交数据。它是一个极其强大的工具。
注意
正因为 curl 功能如此强大,完整的 curl 教程超出了本章的范围。相反,请参考在线RCurl
文档,地址为www.omegahat.org/RCurl/
。
安装RCurl
包后,下载一个页面就像输入以下命令那样简单:
> library(RCurl)
> packt_page <- ("https://www.packtpub.com/")
这将把 Packt Publishing 主页的完整文本(包括所有网页标记)保存到名为packt_page
的 R 字符对象中。正如接下来的几行所示,这并不是非常有用:
> str(packt_page, nchar.max=200)
chr "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"en\" xml:lang=\"en\">\n\t<head>\n\t\t<title>Packt Publishing | Technology Books, eBooks & Videos</title>\n\t\t<script>\n\t\t\tdata"| __truncated__
页面前 200 个字符看起来像是乱码的原因是因为网站使用超文本标记语言(HTML)编写,它将页面文本与特殊的标签结合,这些标签告诉浏览器如何显示文本。这里的<title>
和</title>
标签包围了页面标题,告诉浏览器这是 Packt Publishing 主页。类似的标签用于标示页面的其他部分。
虽然 curl 是访问在线内容的跨平台标准,但如果你在 R 中频繁处理网页数据,Hadley Wickham 开发的 httr
包基于 RCurl
打造,使其更加便捷且符合 R 的使用习惯。通过尝试使用 httr
包的 GET()
函数下载 Packt Publishing 的主页,我们可以立刻看到一些不同:
> library(httr)
> packt_page <- GET("https://www.packtpub.com")
> str(packt_page, max.level = 1)
List of 9
$ url : chr "https://www.packtpub.com/"
$ status_code: int 200
$ headers : List of 11
$ all_headers: List of 1
$ cookies : list()
$ content : raw [1:58560] 3c 21 44 4f ...
$ date : POSIXct[1:1], format: "2015-05-24 20:46:40"
$ times : Named num [1:6] 0 0.000071 0.000079 ...
$ request : List of 5
在 RCurl
中的 getURL()
函数仅下载 HTML 内容,而 GET()
函数则返回一个包含网站属性的列表,此外还包括 HTML 内容。要访问页面内容本身,我们需要使用 content()
函数:
> str(content(packt_page, type="text"), nchar.max=200)
chr "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"en\" xml:lang=\"en\">\n\t<head>\n\t\t<title>Packt Publishing | Technology Books, eBooks & Videos</title>\n\t\t<script>\n\t\t\tdata"| __truncated__
要在 R 程序中使用这些数据,需要处理页面,将其转化为像列表或数据框这样的结构化格式。如何进行这一步骤将在接下来的章节中讨论。
注意
要了解更详细的 httr
文档和教程,请访问项目的 GitHub 页面:github.com/hadley/httr
。快速入门指南特别有助于学习基本功能。
从网页抓取数据
由于许多网页的 HTML 标签结构是统一的,我们可以编写程序来寻找页面中的目标部分,并将其提取出来,最终将数据编译成一个数据集。这一从网站抓取数据并将其转换为结构化形式的过程,称为 网页抓取。
提示
尽管网页抓取被广泛使用,但它应该被视为从 Web 获取数据的最后手段。原因在于,任何对基础 HTML 结构的更改都可能使代码失效,修复时可能需要额外的工作。更糟糕的是,这可能会在数据中引入不可察觉的错误。此外,许多网站的使用条款明确禁止自动化数据提取,更不用说你的程序的流量可能会超载他们的服务器。在开始项目前,务必检查网站的条款;你甚至可能发现该网站通过开发者协议免费提供数据。
Hadley Wickham 开发的 rvest
包("harvest" 的双关语)使得网页抓取过程基本不费吹灰之力,前提是你想要的数据可以在 HTML 中的某个固定位置找到。
让我们从一个简单的例子开始,使用 Packt Publishing 的主页。我们首先使用 rvest
包中的 html()
函数下载页面。请注意,这个函数在提供 URL 时,实际上是调用了 Hadley Wickham 的 httr
包中的 GET()
函数:
> library(rvest)
> packt_page <- html("https://www.packtpub.com")
假设我们想要抓取页面标题。通过查看之前的 HTML 代码,我们知道每个页面只有一个标题,它被包裹在 <title>
和 </title>
标签之间。为了提取标题,我们将标签名称传递给 html_node()
函数,代码如下:
> html_node(packt_page, "title")
<title>Packt Publishing | Technology Books, eBooks & Videos</title>
这样可以保持 HTML 格式不变,包括 <title>
标签和 &
代码,这是 HTML 中代表和符号的标记。要将其转换为纯文本,我们只需通过 html_text()
函数进行处理,如下所示:
> html_node(packt_page, "title") %>% html_text()
[1] "Packt Publishing | Technology Books, eBooks & Videos"
注意使用了%>%
操作符。这被称为管道,因为它本质上是将数据从一个函数“传输”到另一个函数。管道的使用允许创建强大的函数链来处理 HTML 数据。
注意
管道操作符是magrittr
包的一部分,由 Stefan Milton Bache 和 Hadley Wickham 开发,并与rvest
包一起默认安装。这个名字来源于 René Magritte 的著名画作《管子》,你可能记得它出现在第一章,介绍机器学习中。有关该项目的更多信息,请访问其 GitHub 页面:github.com/smbache/magrittr
。
让我们尝试一个稍微有趣一点的例子。假设我们想抓取 CRAN 机器学习任务视图中的所有包的列表。我们与之前一样,通过使用html()
函数下载 HTML 页面开始。由于我们不知道页面的结构,因此我们还会通过键入cran_ml
,即我们创建的 R 对象的名称,来查看 HTML 内容:
> library(rvest)
> cran_ml <- html("http://cran.r-project.org/web/views/MachineLearning.html")
> cran_ml
查看输出结果,我们发现有一个部分似乎包含了我们感兴趣的数据。请注意,这里只显示了输出的一个子集:
<h3>CRAN packages:</h3>
<ul>
<li><a href="../packages/ahaz/index.html">ahaz</a></li>
<li><a href="../packages/arules/index.html">arules</a></li>
<li><a href="../packages/bigrf/index.html">bigrf</a></li>
<li><a href="../packages/bigRR/index.html">bigRR</a></li>
<li><a href="../packages/bmrm/index.html">bmrm</a></li>
<li><a href="../packages/Boruta/index.html">Boruta</a></li>
<li><a href="../packages/bst/index.html">bst</a></li>
<li><a href="../packages/C50/index.html">C50</a></li>
<li><a href="../packages/caret/index.html">caret</a></li>
<h3>
标签表示一个大小为 3 的标题,而<ul>
和<li>
标签分别用于创建无序列表和列表项。我们想要的数据元素被<a>
标签包围,这些是超链接锚标签,指向每个包的 CRAN 页面。
提示
由于 CRAN 页面是持续维护的,且随时可能发生变化,因此如果你的结果与这里展示的不同,不要感到惊讶。
有了这些知识,我们可以像之前那样抓取链接。唯一的例外是,由于我们预计会找到多个结果,我们需要使用html_nodes()
函数来返回一个结果向量,而不是html_node()
,后者只返回单个项:
> ml_packages <- html_nodes(cran_ml, "a")
让我们使用head()
函数查看结果:
> head(ml_packages, n = 7)
[[1]]
<a href="../packages/nnet/index.html">nnet</a>
[[2]]
<a href="../packages/RSNNS/index.html">RSNNS</a>
[[3]]
<a href="../packages/rpart/index.html">rpart</a>
[[4]]
<a href="../packages/tree/index.html">tree</a>
[[5]]
<a href="../packages/rpart/index.html">rpart</a>
[[6]]
<a href="http://www.cs.waikato.ac.nz/~ml/weka/">Weka</a>
[[7]]
<a href="../packages/RWeka/index.html">RWeka</a>
如我们在第 6 行所看到的,似乎一些其他项目的链接也出现在了结果中。这是因为有些包被超链接到其他网站;在这种情况下,RWeka
包同时链接到 CRAN 和其主页。为了排除这些结果,你可以将这个输出链接到另一个函数,查找超链接中的/packages
字符串。
提示
一般而言,网页抓取总是一个不断迭代和改进的过程,随着你识别出更具体的标准来排除或包括特定的情况。最困难的情况可能甚至需要人工检查才能达到 100%的准确性。
这些都是简单的示例,仅仅触及了rvest
包的可能性。通过使用管道功能,实际上可以查找嵌套在标签中的标签或特定类别的 HTML 标签。对于这些复杂的例子,请参考包的文档。
解析 XML 文档
XML 是一种纯文本、可读性强的结构化标记语言,许多文档格式都基于它。它使用类似 HTML 的标签结构,但在格式上要严格得多。因此,它是一个流行的在线格式,用于存储结构化数据集。
Duncan Temple Lang 的 XML
包提供了一套基于流行的 C 语言 libxml2
解析器的 R 功能,能够读取和写入 XML 文档。它是 R 中 XML 解析包的祖先,至今仍广泛使用。
注意
关于 XML
包的信息,包括一些简单的示例,可以在该项目的官方网站上找到:www.omegahat.org/RSXML/
。
最近,Hadley Wickham 的 xml2
包作为一个更简单、更像 R 的接口,浮出水面,成为对 libxml2
库的封装。前面章节中提到的 rvest
包在幕后利用 xml2
来解析 HTML。此外,rvest
也可以用来解析 XML。
注意
xml2
的 GitHub 页面可以在此找到:github.com/hadley/xml2
。
由于解析 XML 与解析 HTML 密切相关,因此这里不涉及具体的语法。有关示例,请参考这些软件包的文档。
从 Web API 解析 JSON
在线应用程序通过称为应用程序编程接口 (APIs) 的 Web 可访问函数进行互相通信。这些接口的工作方式类似于典型的网站;它们通过特定的 URL 接收客户端请求,并返回响应。不同之处在于,普通网站返回的是用于在 Web 浏览器中显示的 HTML,而 API 通常返回的是结构化的数据,供机器处理。
尽管基于 XML 的 API 并不少见,但今天最常见的 API 数据结构可能是JavaScript 对象表示法 (JSON)。像 XML 一样,JSON 是一种标准的纯文本格式,通常用于 Web 上的数据结构和对象。由于其与基于浏览器的 JavaScript 应用程序的联系,JSON 最近变得流行,但尽管如此,它的应用并不限于 Web。JSON 数据结构易于人类理解且易于机器解析,这使得它成为许多项目类型中有吸引力的数据结构。
JSON 基于简单的 {key: value}
格式。{ }
括号表示一个 JSON 对象,key
和 value
参数表示对象的属性及该属性的状态。一个对象可以包含任意数量的属性,且属性本身也可以是对象。例如,这本书的 JSON 对象可能如下所示:
{
"title": "Machine Learning with R",
"author": "Brett Lantz",
"publisher": {
"name": "Packt Publishing",
"url": "https://www.packtpub.com"
},
"topics": ["R", "machine learning", "data mining"],
"MSRP": 54.99
}
这个例子展示了 JSON 可用的数据类型:数字、字符、数组(由 [
和 ]
括起来)和对象。未显示的是 null
和布尔值(true
或 false
)。这些类型的对象在应用程序与应用程序之间,以及应用程序与 Web 浏览器之间的传输,是许多最受欢迎网站的动力来源。
注意
有关 JSON 格式的详细信息,请访问 www.json.org/
。
公共 API 允许像 R 这样的程序系统地查询网站,使用像 RCurl
和 httr
这样的包以 JSON 格式检索结果。虽然使用 Web API 的完整教程值得单独成书,但基本过程只依赖于几个步骤——真正复杂的是细节。
假设我们想要查询 Google Maps API 以定位法国埃菲尔铁塔的纬度和经度。首先,我们需要查看 Google Maps API 文档,以确定进行此查询所需的 URL 和参数。然后,我们将这些信息提供给 httr
包的 GET()
函数,并添加一组查询参数以进行地址搜索:
> library(httr)
> map_search <-
GET("https://maps.googleapis.com/maps/api/geocode/json",
query = list(address = "Eiffel Tower"))
通过输入结果对象的名称,我们可以查看关于请求的一些详细信息:
> map_search
Response [https://maps.googleapis.com/maps/api/geocode/json?address=Eiffel%20T ower]
Status: 200
Content-Type: application/json; charset=UTF-8
Size: 2.34 kB
{
"results" : [
{
"address_components" : [
{
"long_name" : "Eiffel Tower",
"short_name" : "Eiffel Tower",
"types" : [ "point_of_interest", "establishment" ]
},
{
...
要访问 httr
包自动解析的 JSON 结果,我们使用 content()
函数。为简便起见,这里只展示了几行代码:
> content(map_search)
$results[[1]]$formatted_address
[1] "Eiffel Tower, Champ de Mars, 5 Avenue Anatole France, 75007 Paris, France"
$results[[1]]$geometry
$results[[1]]$geometry$location
$results[[1]]$geometry$location$lat
[1] 48.85837
$results[[1]]$geometry$location$lng
[1] 2.294481
要单独访问这些内容,只需使用列表语法引用它们。名称基于 Google API 返回的 JSON 对象。例如,整个结果集在一个名为 results
的对象中,每个结果都有编号。在此案例中,我们将访问第一个结果的格式化地址属性,以及纬度和经度:
> content(map_search)$results[[1]]$formatted_address
[1] "Eiffel Tower, Champ de Mars, 5 Avenue Anatole France, 75007 Paris, France"
> content(map_search)$results[[1]]$geometry$location$lat
[1] 48.85837
> content(map_search)$results[[1]]$geometry$location$lng
[1] 2.294481
这些数据元素可以在 R 程序中根据需要使用。
提示
因为 Google Maps API 可能在未来进行更新,如果你发现你的结果与此处显示的不同,请检查 Packt Publishing 支持页面以获取更新的代码。
另一方面,如果你想在 httr
包之外进行 JSON 格式的转换,可以使用一些包来实现这个功能。
Alex Couture-Beil 的 rjson
包是最早允许 R 数据结构与 JSON 格式互转的包之一。其语法简单。在安装了 rjson
包后,要将 R 对象转换为 JSON 字符串,我们使用 toJSON()
函数。请注意,引用字符已使用 \"
符号转义:
> library(rjson)
> ml_book <- list(book_title = "Machine Learning with R",
author = "Brett Lantz")
> toJSON(ml_book)
[1] "{\"book_title\":\"Machine Learning with R\",
\"author\":\"Brett Lantz\"}"
要将 JSON 字符串转换为 R 对象,使用 fromJSON()
函数。字符串中的引号需要转义,如下所示:
> ml_book_json <- "{
\"title\": \"Machine Learning with R\",
\"author\": \"Brett Lantz\",
\"publisher\": {
\"name\": \"Packt Publishing\",
\"url\": \"https://www.packtpub.com\"
},
\"topics\": [\"R\", \"machine learning\", \"data mining\"],
\"MSRP\": 54.99
}"
> ml_book_r <- fromJSON(ml_book_json)
这将产生一个与原始 JSON 形式相似的列表结构:
> str(ml_book_r)
List of 5
$ title : chr "Machine Learning with R"
$ author : chr "Brett Lantz"
$ publisher:List of 2
..$ name: chr "Packt Publishing"
..$ url : chr "https://www.packtpub.com"
$ topics : chr [1:3] "R" "machine learning" "data mining"
$ MSRP : num 55
最近,出现了两个新的 JSON 包。第一个是由 Duncan Temple Lang 开发的RJSONIO
,旨在成为rjson
包的更快和更可扩展的版本,尽管现在它们几乎是相同的。第二个包是由 Jeroen Ooms 开发的jsonlite
,它因能够创建更一致且更符合 R 语言的数据结构而迅速受到关注,尤其是在使用来自 Web API 的数据时。你选择使用哪个包是个人偏好问题;实际上,所有三个包在实践中几乎是相同的,因为它们都实现了fromJSON()
和toJSON()
函数。
注意
有关jsonlite
包的更多信息,参见:Ooms J. The jsonlite package: a practical and consistent mapping between JSON data and R objects. 2014. 可访问:arxiv.org/abs/1403.2805
使用特定领域的数据
机器学习无疑已应用于各个学科的问题。虽然基本技术在所有领域中都很相似,但有些技术非常专业化,以至于形成了特定社区,专门开发解决该领域独特挑战的方法。这促使了新技术和新术语的出现,这些术语仅与特定领域的问题相关。
本节介绍了一对广泛使用机器学习技术,但需要专业知识才能充分发挥其潜力的领域。由于这些话题已有专门的书籍撰写,本节仅提供最简短的介绍。欲了解更多详情,请参考每节中引用的资源。
分析生物信息学数据
生物信息学领域关注计算机和数据分析在生物领域的应用,尤其是在更好地理解基因组方面。由于基因数据与许多其他类型的数据不同,生物信息学领域的数据分析面临着一些独特的挑战。例如,由于生物体拥有大量基因且基因测序仍然相对昂贵,典型的数据集比它们长的维度宽;也就是说,它们的特征(基因)多于样本(已测序的生物体)。这在尝试应用传统可视化、统计测试和机器学习方法时会遇到问题。此外,越来越多的专有微阵列“芯片上实验室”技术的使用,仅仅加载基因数据就需要高度专业的知识。
注意
一个 CRAN 任务视图列出了 R 语言在统计遗传学和生物信息学方面的一些专用包,具体内容可参见:cran.r-project.org/web/views/Genetics.html
。
Bioconductor 项目是位于华盛顿州西雅图的弗雷德·哈钦森癌症研究中心发起的,旨在通过提供一套标准化的基因组数据分析方法来解决一些问题。Bioconductor 以 R 语言为基础,向其基础软件添加了生物信息学特定的包和文档。
Bioconductor 提供了用于分析来自常见微阵列平台(如 Affymetrix、Illumina、Nimblegen 和 Agilent)的 DNA 和蛋白质微阵列数据的工作流程。其他功能包括序列注释、多重测试程序、专业可视化、教程、文档等。
注意
有关 Bioconductor 项目的更多信息,请访问项目网站 www.bioconductor.org
。
分析和可视化网络数据
社交网络数据和图形数据集由描述连接或 链接(有时也叫 边)的结构组成,这些连接发生在人或物体之间,这些人或物体称为 节点。对于 N 个节点,可以创建一个 N x N = N[2] 的潜在链接矩阵。随着节点数量的增加,这会带来巨大的计算复杂性。
网络分析领域关注的是识别连接的有意义模式的统计度量和可视化。例如,下面的图展示了三个由圆形节点组成的集群,所有这些节点通过位于中心的方形节点连接。网络分析可能揭示方形节点的重要性,以及其他关键度量。
Carter T. Butts、David Hunter 和 Mark S. Handcock 的 network
包提供了一种专门的数据结构用于处理网络。由于需要存储 N[2] 个潜在链接的矩阵很快就会超过可用内存,因此这个数据结构是必要的;network
数据结构使用稀疏表示法,只存储存在的链接,如果大多数关系不存在,这可以节省大量内存。一个密切相关的包 sna
(社交网络分析)允许对 network
对象进行分析和可视化。
注意
有关 network
和 sna
的更多信息,包括非常详细的教程和文档,请参考由华盛顿大学主办的项目网站 www.statnet.org/
。
Gábor Csárdi 的 igraph
包提供了一组用于可视化和分析网络数据的工具。它能够计算非常大的网络的度量。igraph
的另一个优点是,它拥有适用于 Python 和 C 编程语言的类似包,使得可以在几乎任何地方进行分析。正如我们稍后将演示的,它非常易于使用。
注意
有关 igraph
包的更多信息,包括演示和教程,请访问主页 igraph.org/r/
。
在 R 中使用网络数据需要使用专门的格式,因为网络数据通常不会以 CSV 文件或数据框等典型的表格数据结构存储。如前所述,由于在N个网络节点之间有N[2]个潜在连接,除非是最小的N值,否则表格结构很快就会变得不适用。因此,图形数据通常以只列出实际存在的连接的形式存储;缺失的连接通过缺少数据来推断。
其中最简单的一种格式是边列表,这是一种文本文件,每行表示一个网络连接。每个节点必须分配一个唯一的标识符,节点之间的链接通过将连接节点的标识符放在同一行中并用空格分隔来定义。例如,以下边列表定义了节点 0 与节点 1、2 和 3 之间的三条连接:
0 1
0 2
0 3
要将网络数据加载到 R 中,igraph
包提供了一个read.graph()
函数,可以读取边列表文件以及其他更复杂的格式,如图建模语言(GML)。为了说明这一功能,我们将使用一个描述小型空手道俱乐部成员之间友谊的数据集。要跟随操作,请从 Packt Publishing 网站下载karate.txt
文件并将其保存在 R 的工作目录中。安装了igraph
包后,可以通过以下方式将空手道网络读入 R 中:
> library(igraph)
> karate <- read.graph("karate.txt", "edgelist", directed = FALSE)
这将创建一个稀疏矩阵对象,可用于绘图和网络分析。注意,directed = FALSE
参数强制网络使用节点之间的无向或双向链接。由于空手道数据集描述的是友谊关系,这意味着如果人 1 是人 2 的朋友,那么人 2 也必须是人 1 的朋友。另一方面,如果数据集描述的是战斗结果,那么人 1 击败人 2 并不意味着人 2 击败了人 1。在这种情况下,应设置directed = TRUE
参数。
注意
这里使用的空手道网络数据集由密歇根大学的M.E.J. Newman编制。该数据集首次出现在 Zachary WW 的《小组中的信息流模型:冲突与分裂》一文中。人类学研究期刊,1977;33:452-473。
要查看图形,可以使用plot()
函数:
> plot(karate)
这将产生如下图形:
通过检查网络可视化图,可以明显看到空手道俱乐部中有一些连接度较高的成员。节点 1、33 和 34 似乎比其他节点更为中心,其他节点则位于俱乐部的边缘。
使用igraph
计算图度量时,可以从分析中证明我们的直觉。度数是指节点连接的其他节点数量。degree()
函数确认了我们的直觉,即节点 1、33 和 34 比其他节点更为连接,分别有16
、12
和17
个连接:
> degree(karate)
[1] 16 9 10 6 3 4 4 4 5 2 3 1 2 5 2 2 2 2
[19] 2 3 2 2 2 5 3 3 2 4 3 4 4 6 12 17
因为某些连接比其他连接更重要,所以已经开发出多种网络度量方法来衡量节点的连接性。一个名为 介数中心性 的网络度量旨在捕捉节点之间通过每个节点的最短路径数量。真正对整个图形更为核心的节点会有更高的介数中心性值,因为它们充当了其他节点之间的桥梁。我们可以使用 betweenness()
函数获得中心性度量的向量,方法如下:
> betweenness(karate)
[1] 231.0714286 28.4785714 75.8507937 6.2880952
[5] 0.3333333 15.8333333 15.8333333 0.0000000
[9] 29.5293651 0.4476190 0.3333333 0.0000000
[13] 0.0000000 24.2158730 0.0000000 0.0000000
[17] 0.0000000 0.0000000 0.0000000 17.1468254
[21] 0.0000000 0.0000000 0.0000000 9.3000000
[25] 1.1666667 2.0277778 0.0000000 11.7920635
[29] 0.9476190 1.5428571 7.6095238 73.0095238
[33] 76.6904762 160.5515873
由于节点 1 和 34 的介数值远高于其他节点,它们在空手道俱乐部的友谊网络中更加核心。这两个拥有广泛个人友谊网络的人,可能是将整个网络联系在一起的“粘合剂”。
提示
介数中心性只是众多用于捕捉节点重要性的度量之一,它甚至不是唯一的中心性度量。请参考 igraph
文档以获取其他网络属性的定义。
sna
和 igraph
包能够计算许多此类图形度量,计算结果可以作为机器学习函数的输入。例如,假设我们试图建立一个预测谁会赢得俱乐部会长选举的模型。节点 1 和 34 之间的良好连接表明他们可能具备担任此类领导角色所需的社交资本。这些可能是选举结果的关键预测因子。
提示
通过将网络分析与机器学习相结合,像 Facebook、Twitter 和 LinkedIn 这样的服务提供了大量的网络数据,用以预测用户未来的行为。一个引人注目的例子是 2012 年美国总统竞选,在该竞选中,首席数据科学家 Rayid Ghani 利用 Facebook 数据识别出那些可能被说服去投票给巴拉克·奥巴马的人。
提高 R 性能
R 以运行缓慢和内存效率低而著称,这种声誉至少在某种程度上是当之无愧的。对于包含数千条记录的数据集,这些缺点在现代 PC 上通常不易察觉,但当数据集包含百万条记录或更多时,可能会超出当前消费级硬件所能处理的极限。如果数据集包含许多特征或使用复杂的学习算法,这个问题会更加严重。
注意
CRAN 提供了一个高性能计算任务视图,列出了推动 R 功能极限的相关包。可以通过 cran.r-project.org/web/views/HighPerformanceComputing.html
进行查看。
扩展 R 功能的包正在快速开发。这项工作主要体现在两个方面:一些包通过加快数据操作速度或允许数据大小超过可用系统内存的限制,来管理极大的数据集;其他包则使 R 工作更高效,可能通过将工作分布到更多计算机或处理器上,利用专用硬件,或提供针对大数据问题优化的机器学习算法。
管理超大数据集
极大的数据集可能会导致 R 在系统内存不足以存储数据时陷入停顿。即使整个数据集能够装入可用内存,数据处理仍然需要额外的内存开销。此外,极大的数据集可能会因为记录量庞大而花费很长时间进行分析;即使是简单操作,在进行数百万次时也可能会造成延迟。
多年前,许多人会在 R 外的其他编程语言中进行数据准备,或使用 R 但只在数据的较小子集上进行分析。然而,现在不再需要这样做,因为已经有多个包被贡献到 R 中,以解决这些问题。
使用 dplyr 对表格数据结构进行泛化
dplyr
包由 Hadley Wickham 和 Romain Francois 于 2014 年推出,它可能是开始在 R 中处理大数据集最直接的方式。尽管其他包在原始速度或数据大小上可能超过它的能力,dplyr
仍然非常强大。更重要的是,在初步学习曲线过后,它几乎是透明的。
注意
有关 dplyr
的更多信息,包括一些非常有用的教程,请访问该项目的 GitHub 页面:github.com/hadley/dplyr
。
简而言之,该包提供了一个名为 tbl
的对象,它是表格数据的抽象。它的作用类似于数据框,但有几个重要的例外:
-
关键功能已经用 C++ 编写,作者表示,这对于许多操作能带来 20 倍到 1000 倍的性能提升。
-
R 数据框受到可用内存的限制。
dplyr
版本的数据框可以透明地与基于磁盘的数据库链接,这些数据库的存储量超过了内存能容纳的数据。 -
dplyr
包对数据框做出合理假设,从而优化了你的工作效率和内存使用。它不会自动改变数据类型。而且,如果可能,它会避免通过指向原始值来创建数据副本。 -
新的操作符被引入,可以用更少的代码完成常见的数据转换,同时保持高度可读性。
从数据框转换到 dplyr
非常简单。要将现有的数据框转换为 tbl
对象,可以使用 as.tbl()
函数:
> library(dplyr)
> credit <- read.csv("credit.csv")
> credit_tbl <- as.tbl(credit)
输入表名会提供有关该对象的信息。即使在这里,我们也看到了dplyr
和典型 R 行为之间的区别;传统的数据框会显示大量数据行,而dplyr
对象则更考虑实际需求。例如,输入对象名称时,输出会以适合单屏显示的形式进行总结:
> credit_tbl
将dplyr
连接到外部数据库也很简单。dplyr
包提供了连接到 MySQL、PostgreSQL 和 SQLite 数据库的函数。这些函数创建一个连接对象,允许从数据库中提取tbl
对象。
我们使用src_sqlite()
函数创建一个 SQLite 数据库来存储信用数据。SQLite 是一个简单的数据库,不需要服务器,它只是连接到一个数据库文件,我们将其命名为credit.sqlite3
。由于该文件尚未创建,我们需要设置create = TRUE
参数来创建该文件。请注意,要使此步骤正常工作,如果您尚未安装RSQLite
包,可能需要先安装它:
> credit_db_conn <- src_sqlite("credit.sqlite3", create = TRUE)
创建连接后,我们需要使用copy_to()
函数将数据加载到数据库中。此函数使用credit_tbl
对象在由credit_db_conn
指定的数据库中创建一个数据库表。temporary = FALSE
参数强制立即创建该表。由于dplyr
尽量避免复制数据,除非必须,它只有在明确要求时才会创建表:
> copy_to(credit_db_conn, credit_tbl, temporary = FALSE)
执行copy_to()
函数将把数据存储到credit.sqlite3
文件中,该文件可以根据需要传输到其他系统。要稍后访问该文件,只需重新打开数据库连接并创建tbl
对象,如下所示:
> credit_db_conn <- src_sqlite("credit.sqlite3")
> credit_tbl <- tbl(credit_db_conn, "credit_tbl")
尽管dplyr
通过数据库进行路由,但这里的credit_tbl
对象将像其他任何tbl
对象一样工作,并且将获得dplyr
包的所有其他好处。
使用data.table
加速数据框操作
data.table
包由 Matt Dowle、Tom Short、Steve Lianoglou 和 Arun Srinivasan 开发,提供了一种称为数据表的增强版数据框。与数据框相比,data.table
对象在子集化、连接和分组操作中通常更快。对于最大的数据集——包含数百万行的数据集——这些对象可能比dplyr
对象更快。然而,由于它本质上是一个改进版的数据框,生成的对象仍然可以被任何接受数据框的 R 函数使用。
注意
data.table
项目可以在 GitHub 上找到,网址是github.com/Rdatatable/data.table/wiki
。
安装data.table
包后,fread()
函数将读取类似 CSV 的表格文件并将其转换为数据表对象。例如,要加载之前使用的信用数据,可以输入:
> library(data.table)
> credit <- fread("credit.csv")
然后,可以使用类似于 R 中 [row, col]
形式的语法查询信用数据表,但它经过优化以提高速度并提供一些额外的实用功能。特别是,数据表结构允许 row
部分使用简化的子集命令选择行,col
部分则可以使用一个对所选行执行某些操作的函数。例如,以下命令计算具有良好信用历史的人的平均请求贷款金额:
> credit[credit_history == "good", mean(amount)]
[1] 3040.958
通过使用这种简单的语法构建更大的查询,可以在数据表上执行非常复杂的操作。由于数据结构经过优化以提高速度,因此可以在大型数据集上使用。
data.table
结构的一个限制是,与数据框一样,它们受限于系统可用内存。接下来的两个章节讨论了可以克服这一缺点的包,但代价是破坏与许多 R 函数的兼容性。
小贴士
dplyr
和 data.table
包各自有独特的优点。要进行深入比较,可以查看以下 Stack Overflow 讨论:stackoverflow.com/questions/21435339/data-table-vs-dplyr-can-one-do-something-well-the-other-cant-or-does-poorly
。也可以两者兼得,因为 data.table
结构可以通过 tbl_dt()
函数加载到 dplyr
中。
使用 ff 创建基于磁盘的数据框
由 Daniel Adler、Christian Gläser、Oleg Nenadic、Jens Oehlschlägel 和 Walter Zucchini 提供的 ff
包提供了一种替代数据框(ffdf
)的方法,允许创建超过二十亿行的数据集,即使这远超出可用系统内存。
ffdf
结构具有一个物理组件,用于以高效的形式将数据存储在磁盘上,和一个虚拟组件,类似于典型的 R 数据框,但透明地指向存储在物理组件中的数据。你可以把 ffdf
对象想象成一个指向磁盘上数据位置的映射。
注意
ff
项目可以在网上访问:ff.r-forge.r-project.org/
。
ffdf
数据结构的一个缺点是大多数 R 函数不能原生使用它们。相反,数据必须分块处理,结果必须稍后合并。分块处理数据的好处是,可以使用本章后面介绍的并行计算方法同时在多个处理器上进行任务分配。
安装 ff
包后,要读取一个大型 CSV 文件,可以使用 read.csv.ffdf()
函数,示例如下:
> library(ff)
> credit <- read.csv.ffdf(file = "credit.csv", header = TRUE)
不幸的是,我们无法直接与 ffdf
对象进行操作,因为试图将其当作传统数据框使用会导致错误消息:
> mean(credit$amount)
[1] NA
Warning message:
In mean.default(credit$amount) :
argument is not numeric or logical: returning NA
Edwin de Jonge、Jan Wijffels 和 Jan van der Laan 所开发的ffbase
软件包通过为ff
对象提供基本分析功能,部分解决了这一问题。这样可以直接使用ff
对象进行数据探索。例如,安装ffbase
软件包后,均值函数将按预期工作:
> library(ffbase)
> mean(credit$amount)
[1] 3271.258
该软件包还提供了其他基本功能,如数学运算符、查询函数、摘要统计和与优化过的机器学习算法(如biglm
,将在本章后面描述)配合使用的包装器。虽然这些功能不能完全消除处理极大数据集时的挑战,但它们使整个过程变得更加顺畅。
注意
欲了解更多关于高级功能的信息,请访问ffbase
项目网站:github.com/edwindj/ffbase
。
使用 bigmemory 的大型矩阵
Michael J. Kane、John W. Emerson 和 Peter Haverty 开发的bigmemory
软件包允许使用超出可用系统内存的大型矩阵。这些矩阵可以存储在磁盘或共享内存中,使得它们可以被同一计算机上的其他进程或通过网络的进程使用。这促进了并行计算方法的应用,如本章后面讨论的方法。
注意
相关文档可在bigmemory
软件包的网站找到:www.bigmemory.org/
。
由于bigmemory
矩阵与数据框有意不同,它们不能直接与本书中涵盖的大多数机器学习方法一起使用。它们也只能用于数值数据。尽管如此,鉴于它们与典型的 R 矩阵相似,创建可以转换为标准 R 数据结构的较小样本或数据块是很容易的。
作者们还提供了bigalgebra
、biganalytics
和bigtabulate
软件包,允许对这些矩阵执行简单的分析。特别值得注意的是,biganalytics
包中的bigkmeans()
函数,它执行如第九章中所描述的 k 均值聚类,数据分组—使用 k 均值聚类。由于这些软件包的高度专业化,使用案例超出了本章的范围。
使用并行计算加速学习
在计算机早期,处理器以串行方式执行指令,这意味着它们一次只能执行一个任务。在完成上一条指令之前,下一条指令无法开始。尽管广泛认为通过同时完成多个步骤可以更高效地完成许多任务,但当时的技术尚不支持这一点。
这个问题通过并行计算方法得到了改进,后者使用两台或更多的处理器或计算机来解决更大的问题。许多现代计算机都设计为支持并行计算。即使它们只有一个处理器,它们通常也拥有两个或更多能够并行工作的核心。这使得任务可以独立完成。
多台计算机组成的网络,称为集群,也可以用于并行计算。一个大型集群可能包含多种硬件,并且分布在较远的距离上。在这种情况下,集群被称为网格。如果将其推向极限,使用普通硬件的数百或数千台计算机组成的集群或网格可能成为一个非常强大的系统。
然而,问题是,并不是所有问题都可以并行化。有些问题更适合并行执行。一些人可能期望添加 100 个处理器会使得在相同的时间内完成 100 倍的工作(即整体执行时间为 1/100),但通常并非如此。原因是管理这些工作者需要付出努力。工作必须被分割成等量的、不重叠的任务,并且每个工作者的结果必须合并成最终答案。
所谓的极度并行问题是理想的。这类任务容易被拆分为不重叠的工作块,并且可以重新组合结果。一个极度并行的机器学习任务例子是 10 折交叉验证;一旦将 10 个样本划分好,每一个工作块都是独立的,意味着它们不会相互影响。正如你很快将看到的,这项任务可以通过并行计算显著加速。
测量执行时间
如果无法系统地衡量节省的时间,那么加速 R 的努力将会白费。虽然使用秒表是一种选择,但一个更简单的解决方案是将代码封装在system.time()
函数中。
例如,在我的笔记本电脑上,system.time()
函数显示生成 100 万个随机数大约需要0.093
秒:
> system.time(rnorm(1000000))
user system elapsed
0.092 0.000 0.093
相同的函数可以用来评估使用刚才描述的方法或任何 R 函数所获得的性能提升。
注意
说到这里,值得一提的是,当第一版发布时,生成 100 万个随机数需要 0.13 秒。尽管我现在使用的计算机稍微更强大一些,但仅仅两年后,处理时间减少了约 30%,这展示了计算机硬件和软件是如何快速发展的。
使用多核和雪崩并行工作
parallel
包,现在已包含在 R 版本 2.14.0 及更高版本中,降低了部署并行算法的门槛,提供了一个标准框架来设置工作进程,使它们可以同时完成任务。它通过包含multicore
和snow
包的组件来实现,每个组件对多任务处理采取不同的方法。
如果你的计算机相对较新,你很可能能够使用并行处理。要确定你的机器有多少个核心,可以使用detectCores()
函数,如下所示。请注意,输出结果会根据你的硬件规格有所不同:
> library(parallel)
> detectCores()
[1] 8
multicore
包由 Simon Urbanek 开发,允许在具有多个处理器或处理器核心的单台机器上进行并行处理。它利用计算机操作系统的多任务能力,通过fork额外的 R 会话,共享相同的内存。它可能是开始使用 R 进行并行处理的最简单方法。不幸的是,由于 Windows 不支持 fork,这种解决方案并非适用于所有环境。
使用multicore
功能的一个简单方法是使用mclapply()
函数,它是lapply()
的并行版本。例如,以下代码块演示了如何将生成一百万个随机数的任务分配到 1、2、4 和 8 个核心上。在每个核心完成其任务后,使用unlist()
函数将并行结果(一个列表)合并成一个单一的向量:
> system.time(l1 <- rnorm(1000000))
user system elapsed
0.094 0.003 0.097
> system.time(l2 <- unlist(mclapply(1:2, function(x) {
rnorm(500000)}, mc.cores = 2)))
user system elapsed
0.106 0.045 0.076
> system.time(l4 <- unlist(mclapply(1:4, function(x) {
rnorm(250000) }, mc.cores = 4)))
user system elapsed
0.135 0.055 0.063
> system.time(l8 <- unlist(mclapply(1:8, function(x) {
rnorm(125000) }, mc.cores = 8)))
user system elapsed
0.123 0.058 0.055
注意,当核心数增加时,经过的时间减少,但收益逐渐减小。虽然这是一个简单的例子,但它可以轻松地适应许多其他任务。
snow
包(工作站的简单网络连接)由 Luke Tierney、A. J. Rossini、Na Li 和 H. Sevcikova 开发,允许在多核或多处理器机器以及多个机器的网络上进行并行计算。它使用起来稍微有些复杂,但提供了更多的能力和灵活性。安装snow
后,要在单台机器上设置集群,可以使用makeCluster()
函数,并指定要使用的核心数量:
> library(snow)
> 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
的更详细介绍,包括如何在多台计算机之间通过网络配置并行计算的相关信息,请参阅homepage.stat.uiowa.edu/~luke/classes/295-hpc/notes/snow.pdf
。
利用并行与 foreach 和 doParallel
由 Revolution Analytics 的 Steve Weston 开发的foreach
包提供了可能是最简单的并行计算入门方式,特别是如果你在 Windows 上运行 R,因为其他一些包是特定于平台的。
该包的核心是一个新的foreach
循环结构。如果你曾使用过其他编程语言,可能对它有所了解。它本质上允许在一个集合中的多个项上进行循环,而无需显式地计算项的数量;换句话说,对于每一个集合中的项,执行某些操作。
注意
除了foreach
包外,Revolution Analytics(最近被微软收购)还开发了高性能、企业级的 R 构建版本。免费版本可供试用和学术使用。欲了解更多信息,请访问他们的网站:www.revolutionanalytics.com/
。
如果你认为 R 已经提供了一组用于循环集合项的 apply 函数(例如,apply()
、lapply()
、sapply()
等等),你是对的。然而,foreach
循环有一个额外的好处:循环的迭代可以使用非常简单的语法并行完成。让我们看看它是如何工作的。
回想一下我们一直在使用的生成一百万个随机数的命令:
> system.time(l1 <- rnorm(1000000))
user system elapsed
0.096 0.000 0.096
安装foreach
包后,它可以通过一个循环来表示,该循环并行生成四组各 250,000 个随机数。.combine
参数是一个可选设置,用来告诉foreach
它应该使用哪个函数来组合每次循环迭代的最终结果。在这种情况下,由于每次迭代都会生成一组随机数,我们只是使用c()
连接函数来创建一个单一的合并向量:
> library(foreach)
> system.time(l4 <- foreach(i = 1:4, .combine = 'c')
%do% rnorm(250000))
user system elapsed
0.106 0.003 0.109
如果你注意到这个函数没有带来速度改进,那就很好!原因是默认情况下 foreach
软件包在串行模式下运行每个循环迭代。doParallel
的姐妹软件包为 foreach
提供了一个并行后端,利用了本章早些时候描述的 R 包中包含的 parallel
软件包。安装 doParallel
软件包后,只需注册核心数量,并将 %do%
命令替换为 %dopar%
,如下所示:
> library(doParallel)
> registerDoParallel(cores = 4)
> system.time(l4p <- foreach(i = 1:4, .combine = 'c')
%dopar% rnorm(250000))
user system elapsed
0.062 0.030 0.054
如输出所示,此代码导致预期的性能改进,几乎将执行时间减少了一半。
要关闭 doParallel
集群,只需键入:
> stopImplicitCluster()
尽管在 R 会话结束时集群会自动关闭,但最好还是显式关闭。
使用 MapReduce 和 Hadoop 进行并行云计算
MapReduce 编程模型是在 Google 开发的,用于在大型网络计算机集群上处理它们的数据。MapReduce 将并行编程定义为一个两步过程:
-
map 步骤将问题分解为较小的任务,分布在集群中的计算机上
-
reduce 步骤中,将小块工作的结果收集并合成为解决原始问题的最终解决方案
一个流行的开源替代专有 MapReduce 框架的选项是 Apache Hadoop。Hadoop 软件包括 MapReduce 概念,以及一个分布式文件系统,能够在计算机集群中存储大量数据。
注意
Packt Publishing 出版了大量关于 Hadoop 的书籍。要搜索当前的产品,请访问 www.packtpub.com/all/?search=hadoop
。
正在开发几个将 R 接口提供给 Hadoop 的 R 项目。Revolution Analytics 的 RHadoop 项目提供了一个 R 接口给 Hadoop。该项目提供了一个名为 rmr
的软件包,旨在为 R 开发人员编写 MapReduce 程序提供一种简单的方式。另一个伴侣软件包 plyrmr
提供了类似于 dplyr
软件包的功能,用于处理大型数据集。其他 RHadoop 软件包提供了访问 Hadoop 分布式数据存储的 R 函数。
注意
有关 RHadoop 项目的更多信息,请参见 github.com/RevolutionAnalytics/RHadoop/wiki
。
另一个类似的项目是由 Saptarshi Guha 开发的 RHIPE,它试图通过管理 R 和 Hadoop 之间的通信,将 Hadoop 的分割和重新组合哲学带入 R 中。
注意
RHIPE
软件包目前尚未在 CRAN 上提供,但可以从 www.datadr.org
上的源代码构建。
GPU 计算
一种并行处理的替代方法是使用计算机的图形处理单元(GPU)来加速数学计算。GPU 是一种专用处理器,经过优化以快速在计算机屏幕上显示图像。由于计算机通常需要显示复杂的 3D 图形(特别是用于视频游戏),许多 GPU 使用为并行处理和极为高效的矩阵与向量计算而设计的硬件。一个额外的好处是,它们可以高效地解决某些类型的数学问题。计算机处理器可能有 16 个核心,而 GPU 可能有成千上万个。
GPU 计算的缺点是,它需要特定的硬件,而许多计算机并不包含此类硬件。在大多数情况下,需要使用 Nvidia 的 GPU,因为 Nvidia 提供了一种名为完全统一设备架构(CUDA)的专有框架,使得 GPU 可以使用 C++等常见语言进行编程。
注意
想了解更多 Nvidia 在 GPU 计算中的角色,请访问www.nvidia.com/object/what-is-gpu-computing.html
。
gputools
包由 Josh Buckner、Mark Seligman 和 Justin Wilson 开发,包含多个 R 函数,例如使用 Nvidia CUDA 工具包进行矩阵运算、聚类和回归建模。该包需要 CUDA 1.3 或更高版本的 GPU,并且需要安装 Nvidia CUDA 工具包。
部署优化的学习算法
本书中讨论的部分机器学习算法能够通过相对较小的修改,在极大的数据集上工作。例如,使用前面章节中描述的大数据集数据结构来实现朴素贝叶斯或 Apriori 算法将是相当直接的。一些类型的学习器,如集成方法,非常适合并行化,因为每个模型的工作可以分配到集群中的处理器或计算机上。另一方面,有些算法需要对数据或算法进行较大的改动,或者完全重新设计,才能在庞大的数据集上使用。
以下部分将探讨提供优化版本的学习算法的包,这些算法是我们迄今为止使用过的。
使用 biglm 构建更大的回归模型
Thomas Lumley 开发的biglm
包提供了在可能无法完全载入内存的大数据集上训练回归模型的功能。它通过使用迭代过程,将小块数据逐步更新模型,尽管这是不同的方法,但结果几乎与在整个数据集上运行传统的lm()
函数所得结果相同。
为了方便处理最大的数据库,biglm()
函数允许使用 SQL 数据库代替数据框。该模型也可以使用通过之前提到的ff
包创建的数据对象的块来进行训练。
使用bigrf
扩展随机森林的规模和速度
Aloysius Lim 的bigrf
包实现了在无法完全加载到内存中的数据集上进行分类和回归的随机森林训练。它使用前面章节中描述的bigmemory
对象。为了更快地增长森林,可以将该包与前面提到的foreach
和doParallel
包一起使用,以并行方式生长树木。
注意
欲了解更多信息,包括示例和 Windows 安装说明,请访问此软件包的 wiki,地址为github.com/aloysius-lim/bigrf
。
使用caret
并行训练和评估模型
Max Kuhn 的caret
包(在第十章,评估模型性能 和 第十一章,改进模型性能 中有详细介绍)将自动使用并行后端,如果通过前面提到的foreach
包已在 R 中注册。
让我们看一个简单的例子,在这个例子中,我们尝试在信用数据集上训练一个随机森林模型。如果不使用并行化,模型训练大约需要 109 秒:
> library(caret)
> credit <- read.csv("credit.csv")
> system.time(train(default ~ ., data = credit, method = "rf"))
user system elapsed
107.862 0.990 108.873
另一方面,如果我们使用doParallel
包注册四个核心进行并行,模型的训练时间不到 32 秒——不到原来的三分之一——而且我们甚至没有需要修改caret
代码中的一行:
> library(doParallel)
> registerDoParallel(cores = 4)
> system.time(train(default ~ ., data = credit, method = "rf"))
user system elapsed
114.578 2.037 31.362
许多涉及训练和评估模型的任务,如创建随机样本和反复测试 10 折交叉验证的预测,都是典型的并行任务,十分适合性能提升。因此,在开始caret
项目之前,建议始终注册多个核心。
注意
配置说明和使caret
启用并行处理所需的性能改进案例研究,可以在项目网站topepo.github.io/caret/parallel.html
上找到。
总结
现在是学习机器学习的好时光。并行和分布式计算领域的不断发展为挖掘大数据中的知识提供了巨大的潜力。蓬勃发展的数据科学社区得益于免费的开源 R 编程语言,这为入门提供了非常低的门槛——你只需要愿意学习。
你在本章和之前章节中学到的内容为理解更高级的机器学习方法打下了基础。现在,继续学习并为自己的工具库添加新工具是你的责任。在这个过程中,一定要牢记无免费午餐定理——没有任何一种学习算法能做到全能,它们各自有不同的优缺点。正因为如此,机器学习中将始终存在一个人类因素,提供领域特定的知识,并能够将合适的算法与眼前的任务匹配。
在未来的几年里,随着机器学习与人类学习之间的界限越来越模糊,看看人类一方如何变化将会是非常有趣的。像亚马逊的机械土耳其人(Mechanical Turk)这样的服务提供了众包智能,汇集了一群随时准备执行简单任务的人类大脑。也许有一天,正如我们曾利用计算机执行人类无法轻易完成的任务一样,计算机会雇佣人类来做相反的事情。真是令人深思的有趣话题!